Checkpoint all changes
This commit is contained in:
parent
65877acf95
commit
66c2a25457
28 changed files with 3035 additions and 621 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue