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 FAQPage from './components/FAQPage';
import HomePage from './components/HomePage'; import HomePage from './components/HomePage';
import Header, { type Page } from './components/Header'; import Header, { type Page } from './components/Header';
import { ChevronIcon } from './components/ui/Icons';
import { TabButton } from './components/ui/TabButton'; import { TabButton } from './components/ui/TabButton';
import type { import type {
FeatureMeta, FeatureMeta,
@ -29,8 +28,9 @@ import type {
Property, Property,
HexagonPropertiesResponse, HexagonPropertiesResponse,
HexagonStatsResponse, HexagonStatsResponse,
NumericFeatureStats,
} from './types'; } 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 { parseUrlState, DEFAULT_VIEW } from './lib/url-state';
import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils'; import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils';
import { useTheme } from './hooks/useTheme'; import { useTheme } from './hooks/useTheme';
@ -108,8 +108,10 @@ export default function App() {
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null); const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false); const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false); const [leftPaneWidth, setLeftPaneWidth] = useState(384); // 24rem = 384px
const [rightPaneCollapsed, setRightPaneCollapsed] = useState(false); const [rightPaneWidth, setRightPaneWidth] = useState(288); // 18rem = 288px
const leftDraggingRef = useRef(false);
const rightDraggingRef = useRef(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null); const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null); const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
@ -503,6 +505,32 @@ export default function App() {
[filters, features] [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( const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => { async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true); setLoadingProperties(true);
@ -545,12 +573,13 @@ export default function App() {
} else { } else {
const type = isPostcode ? 'postcode' : 'hexagon'; const type = isPostcode ? 'postcode' : 'hexagon';
setSelectedHexagon({ id, type, resolution }); setSelectedHexagon({ id, type, resolution });
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0); setPropertiesOffset(0);
setRightPaneTab('area'); setRightPaneTab('area');
if (isPostcode) { if (isPostcode) {
// For postcodes, we don't have a stats API yet, so skip setAreaStats(buildPostcodeStats(id));
setAreaStats(null);
setLoadingAreaStats(false); setLoadingAreaStats(false);
} else { } else {
setLoadingAreaStats(true); setLoadingAreaStats(true);
@ -561,7 +590,7 @@ export default function App() {
} }
} }
}, },
[selectedHexagon, resolution, fetchHexagonStats] [selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
); );
const handleHexagonHover = useCallback((h3: string | null) => { const handleHexagonHover = useCallback((h3: string | null) => {
@ -576,6 +605,14 @@ export default function App() {
} }
}, [selectedHexagon, fetchHexagonProperties]); }, [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(() => { const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') { if (selectedHexagon && selectedHexagon.type === 'hexagon') {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset); fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
@ -588,6 +625,40 @@ export default function App() {
setAreaStats(null); 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) { if (isScreenshotMode) {
return ( return (
<div className="h-screen w-screen"> <div className="h-screen w-screen">
@ -660,46 +731,43 @@ export default function App() {
</div> </div>
)} )}
<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 ? ( <div className="flex-1 flex flex-col overflow-hidden">
<button <Filters
onClick={() => setLeftPaneCollapsed(false)} features={features}
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" filters={filters}
title="Expand filters" activeFeature={activeFeature}
> dragValue={dragValue}
<ChevronIcon direction="right" className="w-5 h-5" /> enabledFeatures={enabledFeatures}
<span className="text-xs font-medium writing-mode-vertical">Filters</span> onAddFilter={handleAddFilter}
</button> onRemoveFilter={handleRemoveFilter}
) : ( onFilterChange={handleFilterChange}
<div className="flex-1 flex flex-col overflow-hidden"> onDragStart={handleDragStart}
<Filters onDragChange={handleDragChange}
features={features} onDragEnd={handleDragEnd}
filters={filters} zoom={zoom}
activeFeature={activeFeature} itemCount={usePostcodeView ? postcodeData.length : data.length}
dragValue={dragValue} usePostcodeView={usePostcodeView}
enabledFeatures={enabledFeatures} pinnedFeature={pinnedFeature}
onAddFilter={handleAddFilter} onTogglePin={handleTogglePin}
onRemoveFilter={handleRemoveFilter} onCancelPin={handleCancelPin}
onFilterChange={handleFilterChange} onNavigateToSource={(slug, featureName) => {
onDragStart={handleDragStart} navigateTo('data-sources', slug, featureName);
onDragChange={handleDragChange} }}
onDragEnd={handleDragEnd} openInfoFeature={pendingInfoFeature}
zoom={zoom} onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
itemCount={usePostcodeView ? postcodeData.length : data.length} />
usePostcodeView={usePostcodeView} </div>
pinnedFeature={pinnedFeature} <div
onTogglePin={handleTogglePin} 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"
onCancelPin={handleCancelPin} onPointerDown={handleLeftSeparatorPointerDown}
onNavigateToSource={(slug, featureName) => { onPointerMove={handleLeftSeparatorPointerMove}
navigateTo('data-sources', slug, featureName); onPointerUp={handleLeftSeparatorPointerUp}
}} >
openInfoFeature={pendingInfoFeature} <div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
onClearOpenInfoFeature={() => setPendingInfoFeature(null)} </div>
onCollapse={() => setLeftPaneCollapsed(true)}
/>
</div>
)}
</div> </div>
<div className="flex-1 relative"> <div className="flex-1 relative">
<Map <Map
@ -732,106 +800,91 @@ export default function App() {
<DataSources onNavigate={() => navigateTo('data-sources')} /> <DataSources onNavigate={() => navigateTo('data-sources')} />
</div> </div>
<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 ? ( <div
<button 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"
onClick={() => setRightPaneCollapsed(false)} onPointerDown={handleRightSeparatorPointerDown}
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" onPointerMove={handleRightSeparatorPointerMove}
title="Expand panel" onPointerUp={handleRightSeparatorPointerUp}
> >
<ChevronIcon direction="left" className="w-5 h-5" /> <div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
<span className="text-xs font-medium writing-mode-vertical"> </div>
{rightPaneTab === 'area' <div className="flex-1 flex flex-col overflow-hidden">
? 'Area' <div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
: rightPaneTab === 'properties' <TabButton
? 'Properties' label="Area"
: 'POIs'} count={areaStats?.count}
</span> isActive={rightPaneTab === 'area'}
</button> onClick={() => setRightPaneTab('area')}
) : ( />
<> <TabButton
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm"> label="Properties"
<button count={propertiesTotal > 0 ? propertiesTotal : undefined}
onClick={() => setRightPaneCollapsed(true)} isActive={rightPaneTab === 'properties'}
className="px-2 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 border-r border-warm-200 dark:border-navy-700" onClick={handlePropertiesTabClick}
title="Collapse panel" />
> <TabButton
<ChevronIcon direction="right" className="w-4 h-4" /> label="POIs"
</button> count={pois.length > 0 ? pois.length : undefined}
<TabButton isActive={rightPaneTab === 'pois'}
label="Area" onClick={() => setRightPaneTab('pois')}
count={areaStats?.count} />
isActive={rightPaneTab === 'area'} </div>
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="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{rightPaneTab === 'area' ? ( {rightPaneTab === 'area' ? (
<AreaPane <AreaPane
stats={areaStats} stats={areaStats}
globalFeatures={features} globalFeatures={features}
loading={loadingAreaStats} loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null} hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'} isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={ postcodeData={
selectedHexagon?.type === 'postcode' selectedHexagon?.type === 'postcode'
? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null ? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null
: null : null
} }
onViewProperties={handleViewPropertiesFromArea} onViewProperties={handleViewPropertiesFromArea}
onClose={handleCloseProperties} onClose={handleCloseProperties}
hexagonLocation={(() => { hexagonLocation={(() => {
const hexId = selectedHexagon?.id; const hexId = selectedHexagon?.id;
const hex = hexId ? data.find((d) => d.h3 === hexId) : null; const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
return null; return null;
return { return {
lat: hex.lat as number, lat: hex.lat as number,
lon: hex.lon as number, lon: hex.lon as number,
resolution, resolution,
}; };
})()} })()}
filters={filters} filters={filters}
onNavigateToSource={(slug, featureName) => { onNavigateToSource={(slug, featureName) => {
navigateTo('data-sources', slug, featureName); navigateTo('data-sources', slug, featureName);
}} }}
/> />
) : rightPaneTab === 'properties' ? ( ) : rightPaneTab === 'properties' ? (
<PropertiesPane <PropertiesPane
properties={properties} properties={properties}
total={propertiesTotal} total={propertiesTotal}
loading={loadingProperties} loading={loadingProperties}
hexagonId={selectedHexagon?.id || null} hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties} onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties} onClose={handleCloseProperties}
onNavigateToSource={(slug) => navigateTo('data-sources', slug)} onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
/> />
) : ( ) : (
<POIPane <POIPane
groups={poiCategoryGroups} groups={poiCategoryGroups}
selectedCategories={selectedPOICategories} selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories} onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length} poiCount={pois.length}
onNavigateToSource={(slug) => navigateTo('data-sources', slug)} onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
/> />
)} )}
</div> </div>
</> </div>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -3,10 +3,11 @@ import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData }
import type { HexagonLocation } from '../lib/external-search'; import type { HexagonLocation } from '../lib/external-search';
import { formatValue, calculateHistogramMean } from '../lib/format'; import { formatValue, calculateHistogramMean } from '../lib/format';
import { groupFeaturesByCategory } from '../lib/features'; import { groupFeaturesByCategory } from '../lib/features';
import { CRIME_BREAKDOWNS } from '../lib/consts'; import { STACKED_GROUPS } from '../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart'; import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart'; import StackedBarChart from './StackedBarChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks'; import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from './ui/Icons'; import { InfoIcon, CloseIcon } from './ui/Icons';
import { IconButton } from './ui/IconButton'; import { IconButton } from './ui/IconButton';
@ -104,17 +105,19 @@ export default function AreaPane({
<LoadingSkeleton /> <LoadingSkeleton />
) : stats ? ( ) : stats ? (
<div className="p-3 space-y-4"> <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) => { {featureGroups.map((group) => {
const hasData = group.features.some( const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name) (feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
); );
if (!hasData) return null; if (!hasData) return null;
// For Crime group, only show aggregate features with stacked breakdown const stackedCharts = STACKED_GROUPS[group.name];
const isCrimeGroup = group.name === 'Crime';
const featuresToRender = isCrimeGroup
? group.features.filter((f) => f.name in CRIME_BREAKDOWNS)
: group.features;
return ( return (
<div key={group.name}> <div key={group.name}>
@ -122,38 +125,43 @@ export default function AreaPane({
{group.name} {group.name}
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{featuresToRender.map((feature) => { {stackedCharts
const numericStats = numericByName.get(feature.name); ? // Render stacked charts for this group
const enumStats = enumByName.get(feature.name); stackedCharts.map((chart) => {
const segments = chart.components
if (numericStats) { .map((name) => ({
// Check if this is a crime aggregate that should show breakdown name,
const breakdown = CRIME_BREAKDOWNS[feature.name]; value: numericByName.get(name)?.mean ?? 0,
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,
};
})
.filter((s) => s.value > 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 ( return (
<div <div
key={feature.name} key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2" className="bg-warm-50 dark:bg-warm-800 rounded p-2"
> >
<div className="flex justify-between items-baseline mb-1.5"> <div className="flex justify-between items-baseline mb-1.5">
<div className="flex items-center gap-1 min-w-0 mr-2"> <div className="flex items-center gap-1 min-w-0 mr-2">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate"> <span className="text-xs text-warm-700 dark:text-warm-300 truncate">
{feature.name} {chart.label}
</span> </span>
{feature.detail && ( {featureMeta?.detail && (
<button <button
onClick={() => setInfoFeature(feature)} onClick={() => setInfoFeature(featureMeta)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0" className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Feature info" title="Feature info"
> >
@ -162,96 +170,105 @@ export default function AreaPane({
)} )}
</div> </div>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap"> <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> </span>
</div> </div>
<StackedBarChart segments={segments} total={numericStats.mean} /> <StackedBarChart segments={segments} total={total} />
</div> </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 if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name); const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram; const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram) ? calculateHistogramMean(globalHistogram)
: undefined; : undefined;
return ( return (
<div <div
key={feature.name} key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2" className="bg-warm-50 dark:bg-warm-800 rounded p-2"
> >
<div className="flex justify-between items-baseline"> <div className="flex justify-between items-baseline">
<div className="flex items-center gap-1 min-w-0 mr-2"> <div className="flex items-center gap-1 min-w-0 mr-2">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate"> <span className="text-xs text-warm-700 dark:text-warm-300 truncate">
{feature.name} {feature.name}
</span> </span>
{feature.detail && ( {feature.detail && (
<button <button
onClick={() => setInfoFeature(feature)} onClick={() => setInfoFeature(feature)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0" className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Feature info" title="Feature info"
> >
<InfoIcon className="w-3 h-3" /> <InfoIcon className="w-3 h-3" />
</button> </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> </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) { if (enumStats) {
return ( return (
<div <div
key={feature.name} key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2" className="bg-warm-50 dark:bg-warm-800 rounded p-2"
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs text-warm-700 dark:text-warm-300"> <span className="text-xs text-warm-700 dark:text-warm-300">
{feature.name} {feature.name}
</span> </span>
{feature.detail && ( {feature.detail && (
<button <button
onClick={() => setInfoFeature(feature)} onClick={() => setInfoFeature(feature)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300" className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Feature info" title="Feature info"
> >
<InfoIcon className="w-3 h-3" /> <InfoIcon className="w-3 h-3" />
</button> </button>
)} )}
</div> </div>
<EnumBarChart counts={enumStats.counts} /> <EnumBarChart counts={enumStats.counts} />
</div> </div>
); );
} }
return null; return null;
})} })}
</div> </div>
</div> </div>
); );

View file

@ -155,6 +155,7 @@ export default memo(function Filters({
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const [splitFraction, setSplitFraction] = useState(0.65); const [splitFraction, setSplitFraction] = useState(0.65);
const draggingRef = useRef(false); const draggingRef = useRef(false);
const [showPhilosophy, setShowPhilosophy] = useState(false); const [showPhilosophy, setShowPhilosophy] = useState(false);
@ -169,8 +170,9 @@ export default memo(function Filters({
const handleSeparatorPointerMove = useCallback((e: React.PointerEvent) => { const handleSeparatorPointerMove = useCallback((e: React.PointerEvent) => {
if (!draggingRef.current || !containerRef.current) return; if (!draggingRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const headerHeight = headerRef.current?.offsetHeight ?? 0;
const y = e.clientY - rect.top; 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); setSplitFraction(fraction);
}, []); }, []);
@ -183,7 +185,7 @@ export default memo(function Filters({
ref={containerRef} ref={containerRef}
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full" 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 <button
onClick={() => setShowPhilosophy(true)} 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" 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; themeRef.current = theme;
const handleMapLoad = useCallback( const handleMapLoad = useCallback(
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { (_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
const map = evt.target; // Hexagons render below roads/buildings/labels so map features show on top
// Hide buildings to reduce visual clutter over hexagons
try {
map.setLayoutProperty('buildings', 'visibility', 'none');
} catch {
// layer may not exist
}
}, },
[] []
); );
@ -337,15 +331,15 @@ export default memo(function Map({
} }
} }
const range = clr[1] - clr[0]; 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 t = ((val as number) - clr[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t))); 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 cr = countRangeRef.current;
const c = d.count as number; const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min); 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, number,
number, number,
@ -377,7 +371,7 @@ export default memo(function Map({
onClick: handleHexagonClick, onClick: handleHexagonClick,
onHover: handleHexagonHover, onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'water_waterway_label', beforeId: 'landuse_park',
}), }),
[data, colorTrigger, handleHexagonClick, handleHexagonHover] [data, colorTrigger, handleHexagonClick, handleHexagonHover]
); );
@ -404,15 +398,15 @@ export default memo(function Map({
} }
} }
const range = clr[1] - clr[0]; 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 t = ((val as number) - clr[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t))); 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 cr = postcodeCountRangeRef.current;
const c = d.count as number; const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min); 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, number,
number, number,
@ -442,7 +436,7 @@ export default memo(function Map({
onClick: handlePostcodeClick, onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback, onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'water_waterway_label', beforeId: 'landuse_park',
}), }),
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback] [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 { useMemo } from 'react';
import { CRIME_SEGMENT_COLORS } from '../lib/consts'; import { SEGMENT_COLORS } from '../lib/consts';
import { formatValue } from '../lib/format'; import { formatValue } from '../lib/format';
interface Segment { interface Segment {
@ -12,10 +12,11 @@ interface StackedBarChartProps {
total: number; total: number;
} }
/** Shorten crime category names for the legend */ /** Strip common suffixes/prefixes to produce short legend labels */
function shortenCrimeName(name: string): string { function shortenLabel(name: string): string {
return name return name
.replace(' (avg/yr)', '') .replace(' (avg/yr)', '')
.replace(/^% /, '')
.replace('and sexual offences', '') .replace('and sexual offences', '')
.replace('and arson', '') .replace('and arson', '')
.replace('from the person', '') .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"> <div className="flex h-4 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{sortedSegments.map((segment, i) => { {sortedSegments.map((segment, i) => {
const pct = (segment.value / total) * 100; const pct = (segment.value / total) * 100;
if (pct < 0.5) return null; // Skip tiny segments if (pct < 0.5) return null;
return ( return (
<div <div
key={segment.name} key={segment.name}
className="h-full transition-all" className="h-full"
style={{ style={{
width: `${pct}%`, 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> </div>
{/* Legend - compact grid */} {/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5"> <div className="flex flex-wrap gap-x-3 gap-y-0.5">
{sortedSegments.map((segment, i) => ( {sortedSegments.map((segment, i) => (
<div key={segment.name} className="flex items-center gap-1"> <div key={segment.name} className="flex items-center gap-1">
<span <span
className="w-2 h-2 rounded-sm shrink-0" className="w-2 h-2 rounded-sm shrink-0"
style={{ 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"> <span className="text-[10px] text-warm-600 dark:text-warm-400">
{shortenCrimeName(segment.name)} {shortenLabel(segment.name)}
</span> </span>
<span className="text-[10px] text-warm-400 dark:text-warm-500"> <span className="text-[10px] text-warm-400 dark:text-warm-500">
{formatValue(segment.value)} {formatValue(segment.value)}

View file

@ -51,8 +51,3 @@ h3 {
transform: translateY(0); 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; min: number;
max: number; max: number;
mean: 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 { export interface EnumFeatureStats {
@ -128,8 +128,14 @@ export interface EnumFeatureStats {
counts: Record<string, number>; counts: Record<string, number>;
} }
export interface PricePoint {
year: number;
price: number;
}
export interface HexagonStatsResponse { export interface HexagonStatsResponse {
count: number; count: number;
numeric_features: NumericFeatureStats[]; numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[]; enum_features: EnumFeatureStats[];
price_history?: PricePoint[];
} }