Update chars

This commit is contained in:
Andras Schmelczer 2026-02-07 10:00:13 +00:00
parent 609dd5278c
commit 46585a4b2b
8 changed files with 522 additions and 285 deletions

View file

@ -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>
)}

View file

@ -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>
);

View file

@ -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"

View file

@ -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]
);

View 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;
}

View file

@ -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)}

View file

@ -51,8 +51,3 @@ h3 {
transform: translateY(0);
}
/* Vertical text for collapsed pane labels */
.writing-mode-vertical {
writing-mode: vertical-rl;
text-orientation: mixed;
}

View file

@ -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[];
}