Checkpoint all changes

This commit is contained in:
Andras Schmelczer 2026-02-01 19:30:33 +00:00
parent 65877acf95
commit 66c2a25457
28 changed files with 3035 additions and 621 deletions

View file

@ -19,7 +19,9 @@ interface MapProps {
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (h3: string) => void;
onHexagonHover: (h3: string | null) => void;
initialViewState?: ViewState;
theme?: 'light' | 'dark';
}
@ -74,7 +76,8 @@ function normalizedToColor(t: number): [number, number, number] {
}
function zoomToResolution(zoom: number): number {
if (zoom < 7) return 7;
if (zoom < 6) return 5;
if (zoom < 7) return 6;
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
@ -145,13 +148,30 @@ function DeckOverlay({
return null;
}
// Sequential blue scale for count-based coloring
// Vibrant density scale: light cyan → teal → deep indigo
const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] }, // Light cyan (few)
{ t: 0.5, color: [20, 140, 180] }, // Ocean blue (moderate)
{ t: 1, color: [88, 28, 140] }, // Deep indigo (many)
];
function countToColor(t: number): [number, number, number] {
// light blue (209, 226, 243) -> dark blue (33, 102, 172)
const r = Math.round(209 + (33 - 209) * t);
const g = Math.round(226 + (102 - 226) * t);
const b = Math.round(243 + (172 - 243) * t);
return [r, g, b];
if (t <= 0) return DENSITY_GRADIENT[0].color;
if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
const lo = DENSITY_GRADIENT[i];
const hi = DENSITY_GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
}
function PostcodeSearch({
@ -206,7 +226,7 @@ function PostcodeSearch({
setError(null);
}}
placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-warm-800 dark:text-warm-100 dark:placeholder-warm-500"
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
/>
<button
type="submit"
@ -217,7 +237,7 @@ function PostcodeSearch({
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-warm-800/90 rounded px-2 py-0.5 shadow">{error}</span>
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">{error}</span>
)}
</form>
);
@ -228,11 +248,15 @@ function MapLegend({
range,
showCancel,
onCancel,
mode,
enumValues,
}: {
featureLabel: string;
range: [number, number];
showCancel: boolean;
onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
}) {
const formatVal = (v: number) => {
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
@ -241,14 +265,19 @@ function MapLegend({
return v.toFixed(1);
};
const gradientStyle =
mode === 'density'
? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))'
: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))';
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-warm-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm">{featureLabel}</span>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 ml-2"
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view"
>
<svg
@ -265,14 +294,25 @@ function MapLegend({
</div>
<div
className="h-3 rounded"
style={{
background:
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
}}
style={{ background: gradientStyle }}
/>
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
<span>{formatVal(range[0])}</span>
<span>{formatVal(range[1])}</span>
{mode === 'density' ? (
<>
<span>Few</span>
<span>Many</span>
</>
) : enumValues && enumValues.length > 0 ? (
<>
<span>{enumValues[0]}</span>
<span>{enumValues[enumValues.length - 1]}</span>
</>
) : (
<>
<span>{formatVal(range[0])}</span>
<span>{formatVal(range[1])}</span>
</>
)}
</div>
</div>
);
@ -289,7 +329,9 @@ export default memo(function Map({
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
theme = 'light',
}: MapProps) {
@ -433,6 +475,8 @@ export default memo(function Map({
countRangeRef.current = countRange;
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
// Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick);
@ -443,6 +487,17 @@ export default memo(function Map({
}
}, []);
// Stable hover handler using ref
const onHexagonHoverRef = useRef(onHexagonHover);
onHexagonHoverRef.current = onHexagonHover;
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonHoverRef.current(info.object.h3);
} else {
onHexagonHoverRef.current(null);
}
}, []);
// Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
@ -451,7 +506,7 @@ export default memo(function Map({
}, []);
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo(
@ -493,14 +548,16 @@ export default memo(function Map({
number,
];
},
getLineColor: (d) =>
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
number,
number,
number,
number,
],
getLineWidth: (d) => (d.h3 === selectedHexagonIdRef.current ? 2 : 0),
getLineColor: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return [255, 255, 255, 255] as [number, number, number, number];
if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return 3;
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
@ -512,10 +569,11 @@ export default memo(function Map({
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'waterway_label',
}),
[data, colorTrigger, handleHexagonClick]
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
);
// POI layer — independent, only recreated when POI data changes
@ -576,51 +634,6 @@ export default memo(function Map({
[hexLayer, poiLayer, postcodeLayer]
);
// Tooltip uses refs to avoid being a layer dependency
const featuresRef = useRef(features);
featuresRef.current = features;
const getTooltip = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ object }: { object?: any }) => {
if (!object) return null;
if (!('h3' in object)) return null;
const lines: string[] = [];
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
for (const f of featuresRef.current) {
const minVal = object[`min_${f.name}`];
const maxVal = object[`max_${f.name}`];
if (minVal != null && maxVal != null) {
const minStr =
typeof minVal === 'number'
? minVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(minVal);
const maxStr =
typeof maxVal === 'number'
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(maxVal);
const highlight = f.name === viewFeatureRef.current ? 'font-weight: bold;' : '';
lines.push(`<div style="${highlight}">${f.name}: ${minStr} - ${maxStr}</div>`);
}
}
const isDark = themeRef.current === 'dark';
return {
html: `<div style="padding: 8px; font-size: 12px;">${lines.join('')}</div>`,
style: {
backgroundColor: isDark ? '#292524' : 'white',
color: isDark ? '#e7e5e4' : 'inherit',
borderRadius: '4px',
boxShadow: isDark ? '0 2px 4px rgba(0,0,0,0.5)' : '0 2px 4px rgba(0,0,0,0.2)',
},
};
},
[]
);
return (
<div className="flex-1 h-full relative" ref={containerRef}>
<MapGL
@ -635,21 +648,33 @@ export default memo(function Map({
touchPitch={false}
keyboard={true}
pitchWithRotate={false}
minZoom={5}
maxBounds={[-12, 49, 4, 62]}
>
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} />
{viewFeature && colorRange && colorFeatureMeta && (
{viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={colorFeatureMeta.name}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
/>
) : (
<MapLegend
featureLabel="Property density"
range={[0, 0]}
showCancel={false}
onCancel={onCancelPin}
mode="density"
/>
)}
{popupInfo && (
<div
className="absolute pointer-events-none bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
className="absolute pointer-events-none bg-white dark:bg-navy-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,