Update chars
This commit is contained in:
parent
609dd5278c
commit
46585a4b2b
8 changed files with 522 additions and 285 deletions
|
|
@ -11,7 +11,6 @@ import DataSourcesPage from './components/DataSourcesPage';
|
|||
import FAQPage from './components/FAQPage';
|
||||
import HomePage from './components/HomePage';
|
||||
import Header, { type Page } from './components/Header';
|
||||
import { ChevronIcon } from './components/ui/Icons';
|
||||
import { TabButton } from './components/ui/TabButton';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
|
|
@ -29,8 +28,9 @@ import type {
|
|||
Property,
|
||||
HexagonPropertiesResponse,
|
||||
HexagonStatsResponse,
|
||||
NumericFeatureStats,
|
||||
} from './types';
|
||||
import { fetchWithRetry, getApiBaseUrl, buildFilterString, apiUrl, logNonAbortError } from './lib/api';
|
||||
import { fetchWithRetry, buildFilterString, apiUrl, logNonAbortError } from './lib/api';
|
||||
import { parseUrlState, DEFAULT_VIEW } from './lib/url-state';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
|
|
@ -108,8 +108,10 @@ export default function App() {
|
|||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
|
||||
const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false);
|
||||
const [rightPaneCollapsed, setRightPaneCollapsed] = useState(false);
|
||||
const [leftPaneWidth, setLeftPaneWidth] = useState(384); // 24rem = 384px
|
||||
const [rightPaneWidth, setRightPaneWidth] = useState(288); // 18rem = 288px
|
||||
const leftDraggingRef = useRef(false);
|
||||
const rightDraggingRef = useRef(false);
|
||||
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
||||
|
|
@ -503,6 +505,32 @@ export default function App() {
|
|||
[filters, features]
|
||||
);
|
||||
|
||||
/** Build stats from already-loaded PostcodeData (min/max per feature). */
|
||||
const buildPostcodeStats = useCallback(
|
||||
(postcode: string): HexagonStatsResponse | null => {
|
||||
const pc = postcodeData.find((d) => d.postcode === postcode);
|
||||
if (!pc) return null;
|
||||
|
||||
const numeric_features: NumericFeatureStats[] = [];
|
||||
for (const f of features) {
|
||||
if (f.type !== 'numeric') continue;
|
||||
const minVal = pc[`min_${f.name}`];
|
||||
const maxVal = pc[`max_${f.name}`];
|
||||
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
|
||||
numeric_features.push({
|
||||
name: f.name,
|
||||
count: pc.count,
|
||||
min: minVal,
|
||||
max: maxVal,
|
||||
mean: (minVal + maxVal) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
return { count: pc.count, numeric_features, enum_features: [] };
|
||||
},
|
||||
[postcodeData, features]
|
||||
);
|
||||
|
||||
const fetchHexagonProperties = useCallback(
|
||||
async (h3: string, res: number, offset = 0) => {
|
||||
setLoadingProperties(true);
|
||||
|
|
@ -545,12 +573,13 @@ export default function App() {
|
|||
} else {
|
||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||
setSelectedHexagon({ id, type, resolution });
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setRightPaneTab('area');
|
||||
|
||||
if (isPostcode) {
|
||||
// For postcodes, we don't have a stats API yet, so skip
|
||||
setAreaStats(null);
|
||||
setAreaStats(buildPostcodeStats(id));
|
||||
setLoadingAreaStats(false);
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
|
|
@ -561,7 +590,7 @@ export default function App() {
|
|||
}
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonStats]
|
||||
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||
|
|
@ -576,6 +605,14 @@ export default function App() {
|
|||
}
|
||||
}, [selectedHexagon, fetchHexagonProperties]);
|
||||
|
||||
const handlePropertiesTabClick = useCallback(() => {
|
||||
setRightPaneTab('properties');
|
||||
if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) {
|
||||
setPropertiesOffset(0);
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]);
|
||||
|
||||
const handleLoadMoreProperties = useCallback(() => {
|
||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
|
||||
|
|
@ -588,6 +625,40 @@ export default function App() {
|
|||
setAreaStats(null);
|
||||
}, []);
|
||||
|
||||
// Left pane resize handlers
|
||||
const handleLeftSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
leftDraggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleLeftSeparatorPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!leftDraggingRef.current) return;
|
||||
const newWidth = Math.min(600, Math.max(200, e.clientX));
|
||||
setLeftPaneWidth(newWidth);
|
||||
}, []);
|
||||
|
||||
const handleLeftSeparatorPointerUp = useCallback(() => {
|
||||
leftDraggingRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Right pane resize handlers
|
||||
const handleRightSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
rightDraggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleRightSeparatorPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!rightDraggingRef.current) return;
|
||||
const newWidth = Math.min(500, Math.max(200, window.innerWidth - e.clientX));
|
||||
setRightPaneWidth(newWidth);
|
||||
}, []);
|
||||
|
||||
const handleRightSeparatorPointerUp = useCallback(() => {
|
||||
rightDraggingRef.current = false;
|
||||
}, []);
|
||||
|
||||
if (isScreenshotMode) {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
|
|
@ -660,46 +731,43 @@ export default function App() {
|
|||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex ${leftPaneCollapsed ? 'w-10' : 'w-96'} bg-white dark:bg-navy-950 shadow-lg overflow-hidden`}
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
style={{ width: leftPaneWidth }}
|
||||
>
|
||||
{leftPaneCollapsed ? (
|
||||
<button
|
||||
onClick={() => setLeftPaneCollapsed(false)}
|
||||
className="w-full h-full flex flex-col items-center justify-center gap-2 text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-warm-50 dark:hover:bg-navy-800"
|
||||
title="Expand filters"
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-5 h-5" />
|
||||
<span className="text-xs font-medium writing-mode-vertical">Filters</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
itemCount={usePostcodeView ? postcodeData.length : data.length}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onCollapse={() => setLeftPaneCollapsed(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
itemCount={usePostcodeView ? postcodeData.length : data.length}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
onPointerDown={handleLeftSeparatorPointerDown}
|
||||
onPointerMove={handleLeftSeparatorPointerMove}
|
||||
onPointerUp={handleLeftSeparatorPointerUp}
|
||||
>
|
||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
|
|
@ -732,106 +800,91 @@ export default function App() {
|
|||
<DataSources onNavigate={() => navigateTo('data-sources')} />
|
||||
</div>
|
||||
<div
|
||||
className={`${rightPaneCollapsed ? 'w-10' : 'w-72'} bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col`}
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
style={{ width: rightPaneWidth }}
|
||||
>
|
||||
{rightPaneCollapsed ? (
|
||||
<button
|
||||
onClick={() => setRightPaneCollapsed(false)}
|
||||
className="w-full h-full flex flex-col items-center justify-center gap-2 text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-warm-50 dark:hover:bg-navy-800"
|
||||
title="Expand panel"
|
||||
>
|
||||
<ChevronIcon direction="left" className="w-5 h-5" />
|
||||
<span className="text-xs font-medium writing-mode-vertical">
|
||||
{rightPaneTab === 'area'
|
||||
? 'Area'
|
||||
: rightPaneTab === 'properties'
|
||||
? 'Properties'
|
||||
: 'POIs'}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<button
|
||||
onClick={() => setRightPaneCollapsed(true)}
|
||||
className="px-2 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 border-r border-warm-200 dark:border-navy-700"
|
||||
title="Collapse panel"
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-4 h-4" />
|
||||
</button>
|
||||
<TabButton
|
||||
label="Area"
|
||||
count={areaStats?.count}
|
||||
isActive={rightPaneTab === 'area'}
|
||||
onClick={() => setRightPaneTab('area')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Properties"
|
||||
count={propertiesTotal > 0 ? propertiesTotal : undefined}
|
||||
isActive={rightPaneTab === 'properties'}
|
||||
onClick={() => setRightPaneTab('properties')}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
count={pois.length > 0 ? pois.length : undefined}
|
||||
isActive={rightPaneTab === 'pois'}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
onPointerDown={handleRightSeparatorPointerDown}
|
||||
onPointerMove={handleRightSeparatorPointerMove}
|
||||
onPointerUp={handleRightSeparatorPointerUp}
|
||||
>
|
||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<TabButton
|
||||
label="Area"
|
||||
count={areaStats?.count}
|
||||
isActive={rightPaneTab === 'area'}
|
||||
onClick={() => setRightPaneTab('area')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Properties"
|
||||
count={propertiesTotal > 0 ? propertiesTotal : undefined}
|
||||
isActive={rightPaneTab === 'properties'}
|
||||
onClick={handlePropertiesTabClick}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
count={pois.length > 0 ? pois.length : undefined}
|
||||
isActive={rightPaneTab === 'pois'}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={areaStats}
|
||||
globalFeatures={features}
|
||||
loading={loadingAreaStats}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
isPostcode={selectedHexagon?.type === 'postcode'}
|
||||
postcodeData={
|
||||
selectedHexagon?.type === 'postcode'
|
||||
? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null
|
||||
: null
|
||||
}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
hexagonLocation={(() => {
|
||||
const hexId = selectedHexagon?.id;
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
||||
return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
resolution,
|
||||
};
|
||||
})()}
|
||||
filters={filters}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
properties={properties}
|
||||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
) : (
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={areaStats}
|
||||
globalFeatures={features}
|
||||
loading={loadingAreaStats}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
isPostcode={selectedHexagon?.type === 'postcode'}
|
||||
postcodeData={
|
||||
selectedHexagon?.type === 'postcode'
|
||||
? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null
|
||||
: null
|
||||
}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
hexagonLocation={(() => {
|
||||
const hexId = selectedHexagon?.id;
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
||||
return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
resolution,
|
||||
};
|
||||
})()}
|
||||
filters={filters}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
properties={properties}
|
||||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
) : (
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData }
|
|||
import type { HexagonLocation } from '../lib/external-search';
|
||||
import { formatValue, calculateHistogramMean } from '../lib/format';
|
||||
import { groupFeaturesByCategory } from '../lib/features';
|
||||
import { CRIME_BREAKDOWNS } from '../lib/consts';
|
||||
import { STACKED_GROUPS } from '../lib/consts';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import StackedBarChart from './StackedBarChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
|
@ -104,17 +105,19 @@ export default function AreaPane({
|
|||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div className="p-3 space-y-4">
|
||||
{stats.price_history && stats.price_history.length > 0 && (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
);
|
||||
if (!hasData) return null;
|
||||
|
||||
// For Crime group, only show aggregate features with stacked breakdown
|
||||
const isCrimeGroup = group.name === 'Crime';
|
||||
const featuresToRender = isCrimeGroup
|
||||
? group.features.filter((f) => f.name in CRIME_BREAKDOWNS)
|
||||
: group.features;
|
||||
const stackedCharts = STACKED_GROUPS[group.name];
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
|
|
@ -122,38 +125,43 @@ export default function AreaPane({
|
|||
{group.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{featuresToRender.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
// Check if this is a crime aggregate that should show breakdown
|
||||
const breakdown = CRIME_BREAKDOWNS[feature.name];
|
||||
if (breakdown) {
|
||||
// Build segments from component crime means
|
||||
const segments = breakdown
|
||||
.map((componentName) => {
|
||||
const componentStats = numericByName.get(componentName);
|
||||
return {
|
||||
name: componentName,
|
||||
value: componentStats?.mean ?? 0,
|
||||
};
|
||||
})
|
||||
{stackedCharts
|
||||
? // Render stacked charts for this group
|
||||
stackedCharts.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
// Use aggregate feature stats if available, otherwise sum components
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
{chart.label}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
{featureMeta?.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
onClick={() => setInfoFeature(featureMeta)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
|
|
@ -162,96 +170,105 @@ export default function AreaPane({
|
|||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)} avg/yr
|
||||
{formatValue(total)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={numericStats.mean} />
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})
|
||||
: // Default: render each feature individually
|
||||
group.features.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
// Regular numeric feature with histogram
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram && (
|
||||
<>
|
||||
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
<span>{formatValue(numericStats.histogram.min)}</span>
|
||||
<span>{formatValue(numericStats.histogram.max)}</span>
|
||||
</div>
|
||||
{globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
globalMean={globalMean}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
<span>{formatValue(numericStats.histogram.min)}</span>
|
||||
<span>{formatValue(numericStats.histogram.max)}</span>
|
||||
</div>
|
||||
{globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
globalMean={globalMean}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export default memo(function Filters({
|
|||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [splitFraction, setSplitFraction] = useState(0.65);
|
||||
const draggingRef = useRef(false);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
|
|
@ -169,8 +170,9 @@ export default memo(function Filters({
|
|||
const handleSeparatorPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const headerHeight = headerRef.current?.offsetHeight ?? 0;
|
||||
const y = e.clientY - rect.top;
|
||||
const fraction = Math.min(0.8, Math.max(0.15, y / rect.height));
|
||||
const fraction = Math.min(0.8, Math.max(0.15, (y - headerHeight) / rect.height));
|
||||
setSplitFraction(fraction);
|
||||
}, []);
|
||||
|
||||
|
|
@ -183,7 +185,7 @@ export default memo(function Filters({
|
|||
ref={containerRef}
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full"
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div ref={headerRef} className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
|
|
|
|||
|
|
@ -171,14 +171,8 @@ export default memo(function Map({
|
|||
themeRef.current = theme;
|
||||
|
||||
const handleMapLoad = useCallback(
|
||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
const map = evt.target;
|
||||
// Hide buildings to reduce visual clutter over hexagons
|
||||
try {
|
||||
map.setLayoutProperty('buildings', 'visibility', 'none');
|
||||
} catch {
|
||||
// layer may not exist
|
||||
}
|
||||
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
// Hexagons render below roads/buildings/labels so map features show on top
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -337,15 +331,15 @@ export default memo(function Map({
|
|||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 200] as [number, number, number, number];
|
||||
return [...rgb, 255] as [number, number, number, number];
|
||||
}
|
||||
const cr = countRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
|
|
@ -377,7 +371,7 @@ export default memo(function Map({
|
|||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'water_waterway_label',
|
||||
beforeId: 'landuse_park',
|
||||
}),
|
||||
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
||||
);
|
||||
|
|
@ -404,15 +398,15 @@ export default memo(function Map({
|
|||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 200] as [number, number, number, number];
|
||||
return [...rgb, 255] as [number, number, number, number];
|
||||
}
|
||||
const cr = postcodeCountRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
|
|
@ -442,7 +436,7 @@ export default memo(function Map({
|
|||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'water_waterway_label',
|
||||
beforeId: 'landuse_park',
|
||||
}),
|
||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
||||
);
|
||||
|
|
|
|||
169
frontend/src/components/PriceHistoryChart.tsx
Normal file
169
frontend/src/components/PriceHistoryChart.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { PricePoint } from '../types';
|
||||
import { formatValue } from '../lib/format';
|
||||
|
||||
interface PriceHistoryChartProps {
|
||||
points: PricePoint[];
|
||||
}
|
||||
|
||||
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
|
||||
const HEIGHT = 120;
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
const { yearMin, yearMax, priceMin, priceMax, averages, priceTicks } = useMemo(() => {
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity,
|
||||
pMin = Infinity,
|
||||
pMax = -Infinity;
|
||||
for (const p of points) {
|
||||
if (p.year < yMin) yMin = p.year;
|
||||
if (p.year > yMax) yMax = p.year;
|
||||
if (p.price < pMin) pMin = p.price;
|
||||
if (p.price > pMax) pMax = p.price;
|
||||
}
|
||||
// Add 5% padding to price range
|
||||
const pRange = pMax - pMin || 1;
|
||||
pMin = Math.max(0, pMin - pRange * 0.05);
|
||||
pMax = pMax + pRange * 0.05;
|
||||
|
||||
// Yearly averages
|
||||
const byYear = new Map<number, { sum: number; count: number }>();
|
||||
for (const p of points) {
|
||||
const yr = Math.floor(p.year);
|
||||
const entry = byYear.get(yr);
|
||||
if (entry) {
|
||||
entry.sum += p.price;
|
||||
entry.count += 1;
|
||||
} else {
|
||||
byYear.set(yr, { sum: p.price, count: 1 });
|
||||
}
|
||||
}
|
||||
const avgs = Array.from(byYear.entries())
|
||||
.map(([yr, { sum, count }]) => ({ year: yr + 0.5, price: sum / count }))
|
||||
.sort((a, b) => a.year - b.year);
|
||||
|
||||
// Price ticks (3-5 nice round numbers)
|
||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
||||
|
||||
return { yearMin: yMin, yearMax: yMax, priceMin: pMin, priceMax: pMax, averages: avgs, priceTicks: ticks };
|
||||
}, [points]);
|
||||
|
||||
const scaleY = (price: number) => {
|
||||
const ratio = (price - priceMin) / (priceMax - priceMin || 1);
|
||||
return PADDING.top + (1 - ratio) * (HEIGHT - PADDING.top - PADDING.bottom);
|
||||
};
|
||||
|
||||
const yearRange = yearMax - yearMin || 1;
|
||||
|
||||
// Year labels: every 5 years
|
||||
const yearStart = Math.ceil(yearMin / 5) * 5;
|
||||
const yearLabels: number[] = [];
|
||||
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
|
||||
|
||||
const VB_W = 1000;
|
||||
|
||||
const scaleX = (year: number) => {
|
||||
const ratio = (year - yearMin) / yearRange;
|
||||
return PADDING.left + ratio * (VB_W - PADDING.left - PADDING.right);
|
||||
};
|
||||
|
||||
const avgPolyline = averages.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VB_W} ${HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
className="w-full"
|
||||
style={{ height: HEIGHT }}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{priceTicks.map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1={PADDING.left}
|
||||
y1={scaleY(tick)}
|
||||
x2={VB_W - PADDING.right}
|
||||
y2={scaleY(tick)}
|
||||
className="stroke-warm-200 dark:stroke-warm-700"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={scaleX(p.year)}
|
||||
cy={scaleY(p.price)}
|
||||
r={4}
|
||||
className="fill-teal-500 dark:fill-teal-400"
|
||||
opacity={0.35}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Average line */}
|
||||
{averages.length > 1 && (
|
||||
<polyline
|
||||
points={avgPolyline}
|
||||
fill="none"
|
||||
className="stroke-teal-600 dark:stroke-teal-400"
|
||||
strokeWidth={3}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{priceTicks.map((tick) => (
|
||||
<text
|
||||
key={`label-${tick}`}
|
||||
x={PADDING.left - 4}
|
||||
y={scaleY(tick)}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
style={{ fontSize: 28 }}
|
||||
>
|
||||
{formatValue(tick)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis year labels */}
|
||||
{yearLabels.map((yr) => (
|
||||
<text
|
||||
key={yr}
|
||||
x={scaleX(yr)}
|
||||
y={HEIGHT - 2}
|
||||
textAnchor="middle"
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
style={{ fontSize: 28 }}
|
||||
>
|
||||
{yr}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Generate ~count nice round tick values spanning [min, max]. */
|
||||
function niceTicksForRange(min: number, max: number, count: number): number[] {
|
||||
const range = max - min;
|
||||
if (range <= 0) return [min];
|
||||
const rough = range / count;
|
||||
// Round to a nice step: 1, 2, 5, 10, 20, 50, 100k, 200k, 500k, etc.
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
|
||||
let step: number;
|
||||
const normalized = rough / magnitude;
|
||||
if (normalized <= 1.5) step = magnitude;
|
||||
else if (normalized <= 3.5) step = 2 * magnitude;
|
||||
else if (normalized <= 7.5) step = 5 * magnitude;
|
||||
else step = 10 * magnitude;
|
||||
|
||||
const ticks: number[] = [];
|
||||
const start = Math.ceil(min / step) * step;
|
||||
for (let t = start; t <= max; t += step) {
|
||||
ticks.push(t);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
import { CRIME_SEGMENT_COLORS } from '../lib/consts';
|
||||
import { SEGMENT_COLORS } from '../lib/consts';
|
||||
import { formatValue } from '../lib/format';
|
||||
|
||||
interface Segment {
|
||||
|
|
@ -12,10 +12,11 @@ interface StackedBarChartProps {
|
|||
total: number;
|
||||
}
|
||||
|
||||
/** Shorten crime category names for the legend */
|
||||
function shortenCrimeName(name: string): string {
|
||||
/** Strip common suffixes/prefixes to produce short legend labels */
|
||||
function shortenLabel(name: string): string {
|
||||
return name
|
||||
.replace(' (avg/yr)', '')
|
||||
.replace(/^% /, '')
|
||||
.replace('and sexual offences', '')
|
||||
.replace('and arson', '')
|
||||
.replace('from the person', '')
|
||||
|
|
@ -43,33 +44,33 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp
|
|||
<div className="flex h-4 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
|
||||
{sortedSegments.map((segment, i) => {
|
||||
const pct = (segment.value / total) * 100;
|
||||
if (pct < 0.5) return null; // Skip tiny segments
|
||||
if (pct < 0.5) return null;
|
||||
return (
|
||||
<div
|
||||
key={segment.name}
|
||||
className="h-full transition-all"
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: CRIME_SEGMENT_COLORS[i % CRIME_SEGMENT_COLORS.length],
|
||||
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
title={`${shortenCrimeName(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
|
||||
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend - compact grid */}
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{sortedSegments.map((segment, i) => (
|
||||
<div key={segment.name} className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: CRIME_SEGMENT_COLORS[i % CRIME_SEGMENT_COLORS.length],
|
||||
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
||||
{shortenCrimeName(segment.name)}
|
||||
{shortenLabel(segment.name)}
|
||||
</span>
|
||||
<span className="text-[10px] text-warm-400 dark:text-warm-500">
|
||||
{formatValue(segment.value)}
|
||||
|
|
|
|||
|
|
@ -51,8 +51,3 @@ h3 {
|
|||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Vertical text for collapsed pane labels */
|
||||
.writing-mode-vertical {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export interface NumericFeatureStats {
|
|||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
histogram: { min: number; max: number; bin_width: number; counts: number[] };
|
||||
histogram?: { min: number; max: number; bin_width: number; counts: number[] };
|
||||
}
|
||||
|
||||
export interface EnumFeatureStats {
|
||||
|
|
@ -128,8 +128,14 @@ export interface EnumFeatureStats {
|
|||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PricePoint {
|
||||
year: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface HexagonStatsResponse {
|
||||
count: number;
|
||||
numeric_features: NumericFeatureStats[];
|
||||
enum_features: EnumFeatureStats[];
|
||||
price_history?: PricePoint[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue