Spice up website
This commit is contained in:
parent
f7d586a1e9
commit
7627818e98
9 changed files with 831 additions and 164 deletions
|
|
@ -3,21 +3,26 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
|||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { IconLayer } from '@deck.gl/layers';
|
||||
import { IconLayer, PolygonLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta, PostcodeData } from '../types';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
pois: POI[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
viewFeature: string | null;
|
||||
viewRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
onCancelPin: () => void;
|
||||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
onHexagonClick: (h3: string) => void;
|
||||
initialViewState?: ViewState;
|
||||
postcodeData: PostcodeData[];
|
||||
selectedPostcode: string | null;
|
||||
onPostcodeClick: (postcode: string) => void;
|
||||
}
|
||||
|
||||
// Twemoji CDN base URL
|
||||
|
|
@ -123,7 +128,8 @@ function DeckOverlay({
|
|||
layers,
|
||||
getTooltip,
|
||||
}: {
|
||||
layers: (H3HexagonLayer<HexagonData> | IconLayer<POI>)[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
layers: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getTooltip: any;
|
||||
}) {
|
||||
|
|
@ -207,7 +213,7 @@ function PostcodeSearch({
|
|||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-3 py-2 bg-blue-500 text-white text-sm hover:bg-blue-600 disabled:opacity-50"
|
||||
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Go'}
|
||||
</button>
|
||||
|
|
@ -221,16 +227,70 @@ function PostcodeSearch({
|
|||
);
|
||||
}
|
||||
|
||||
function MapLegend({
|
||||
featureLabel,
|
||||
range,
|
||||
showCancel,
|
||||
onCancel,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
showCancel: boolean;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const formatVal = (v: number) => {
|
||||
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(v)) return v.toString();
|
||||
return v.toFixed(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute top-3 right-3 z-10 bg-white 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"
|
||||
title="Clear color view"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</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))',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between mt-1 text-warm-600">
|
||||
<span>{formatVal(range[0])}</span>
|
||||
<span>{formatVal(range[1])}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
pois,
|
||||
onViewChange,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
viewFeature,
|
||||
viewRange,
|
||||
viewSource,
|
||||
onCancelPin,
|
||||
features,
|
||||
selectedHexagonId,
|
||||
onHexagonClick,
|
||||
initialViewState,
|
||||
postcodeData,
|
||||
selectedPostcode,
|
||||
onPostcodeClick,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
||||
|
|
@ -332,15 +392,15 @@ export default memo(function Map({
|
|||
|
||||
// Memoize feature lookup to avoid new reference each render
|
||||
const colorFeatureMeta = useMemo(
|
||||
() => (activeFeature ? features.find((f) => f.name === activeFeature) || null : null),
|
||||
[activeFeature, features]
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
);
|
||||
|
||||
// Use refs for values that change during drag so layers aren't recreated
|
||||
const activeFeatureRef = useRef(activeFeature);
|
||||
activeFeatureRef.current = activeFeature;
|
||||
const dragValueRef = useRef(dragValue);
|
||||
dragValueRef.current = dragValue;
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
viewFeatureRef.current = viewFeature;
|
||||
const viewRangeRef = useRef(viewRange);
|
||||
viewRangeRef.current = viewRange;
|
||||
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
||||
colorFeatureMetaRef.current = colorFeatureMeta;
|
||||
const countRangeRef = useRef(countRange);
|
||||
|
|
@ -348,6 +408,10 @@ export default memo(function Map({
|
|||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||
selectedHexagonIdRef.current = selectedHexagonId;
|
||||
|
||||
// Postcode refs
|
||||
const selectedPostcodeRef = useRef(selectedPostcode);
|
||||
selectedPostcodeRef.current = selectedPostcode;
|
||||
|
||||
// Stable click handler using ref
|
||||
const onHexagonClickRef = useRef(onHexagonClick);
|
||||
onHexagonClickRef.current = onHexagonClick;
|
||||
|
|
@ -360,6 +424,17 @@ export default memo(function Map({
|
|||
[]
|
||||
);
|
||||
|
||||
const onPostcodeClickRef = useRef(onPostcodeClick);
|
||||
onPostcodeClickRef.current = onPostcodeClick;
|
||||
const handlePostcodeClick = useCallback(
|
||||
(info: PickingInfo<PostcodeData>) => {
|
||||
if (info.object && 'postcode' in info.object) {
|
||||
onPostcodeClickRef.current(info.object.postcode);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Stable hover handler using ref
|
||||
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||
handlePoiHoverRef.current = handlePoiHover;
|
||||
|
|
@ -368,7 +443,7 @@ export default memo(function Map({
|
|||
}, []);
|
||||
|
||||
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
|
||||
const colorTrigger = `${activeFeature}|${dragValue?.[0]}|${dragValue?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
|
||||
const colorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
|
||||
|
||||
// Hexagon layer — only recreated when data or color trigger changes
|
||||
const hexLayer = useMemo(
|
||||
|
|
@ -378,17 +453,17 @@ export default memo(function Map({
|
|||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
const af = activeFeatureRef.current;
|
||||
const dv = dragValueRef.current;
|
||||
const vf = viewFeatureRef.current;
|
||||
const vr = viewRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
if (af && dv && cfm) {
|
||||
const val = d[`min_${af}`];
|
||||
if (vf && vr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
const min = dv[0];
|
||||
const max = dv[1];
|
||||
const minVal = d[`min_${af}`] as number;
|
||||
const maxVal = d[`max_${af}`] as number;
|
||||
// Gray out hexagons outside drag range
|
||||
const min = vr[0];
|
||||
const max = vr[1];
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
// Gray out hexagons outside range
|
||||
if (maxVal < min || minVal > max) {
|
||||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
}
|
||||
|
|
@ -428,6 +503,79 @@ export default memo(function Map({
|
|||
[data, colorTrigger, handleHexagonClick]
|
||||
);
|
||||
|
||||
// Postcode count range
|
||||
const postcodeCountRange = useMemo(() => {
|
||||
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of postcodeData) {
|
||||
const c = d.count as number;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [postcodeData]);
|
||||
|
||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
||||
postcodeCountRangeRef.current = postcodeCountRange;
|
||||
|
||||
// Postcode color trigger
|
||||
const postcodeColorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}`;
|
||||
|
||||
// Postcode polygon layer
|
||||
const postcodeLayer = useMemo(
|
||||
() =>
|
||||
new PolygonLayer<PostcodeData>({
|
||||
id: 'postcode-polygons',
|
||||
data: postcodeData,
|
||||
getPolygon: (d) => d.polygon,
|
||||
getFillColor: (d) => {
|
||||
const vf = viewFeatureRef.current;
|
||||
const vr = viewRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
if (vf && vr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
const min = vr[0];
|
||||
const max = vr[1];
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < min || minVal > max) {
|
||||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
}
|
||||
const range = max - min;
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
const t = ((val as number) - min) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 200] 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 [number, number, number, number];
|
||||
},
|
||||
getLineColor: (d) =>
|
||||
(d.postcode === selectedPostcodeRef.current
|
||||
? [255, 255, 255, 255]
|
||||
: [160, 160, 160, 200]) as [number, number, number, number],
|
||||
getLineWidth: (d) => (d.postcode === selectedPostcodeRef.current ? 2 : 1),
|
||||
lineWidthUnits: 'pixels' as const,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pickable: true,
|
||||
updateTriggers: {
|
||||
getFillColor: [postcodeColorTrigger],
|
||||
getLineColor: [postcodeColorTrigger],
|
||||
getLineWidth: [postcodeColorTrigger],
|
||||
},
|
||||
onClick: handlePostcodeClick,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: "waterway_label",
|
||||
}),
|
||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick]
|
||||
);
|
||||
|
||||
// POI layer — independent, only recreated when POI data changes
|
||||
const poiLayer = useMemo(
|
||||
() =>
|
||||
|
|
@ -449,23 +597,34 @@ export default memo(function Map({
|
|||
[pois, stablePoiHover]
|
||||
);
|
||||
|
||||
const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]);
|
||||
const layers = useMemo(
|
||||
() => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]),
|
||||
[postcodeData.length, postcodeLayer, hexLayer, poiLayer]
|
||||
);
|
||||
|
||||
// Tooltip uses refs to avoid being a layer dependency
|
||||
const featuresRef = useRef(features);
|
||||
featuresRef.current = features;
|
||||
|
||||
const getTooltip = useCallback(
|
||||
({ object }: { object?: HexagonData }) => {
|
||||
if (!object || !('h3' in object)) return null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
({ object }: { object?: any }) => {
|
||||
if (!object) return null;
|
||||
|
||||
// Handle both hexagon and postcode objects
|
||||
const isPostcode = 'postcode' in object;
|
||||
const isHexagon = 'h3' in object;
|
||||
if (!isPostcode && !isHexagon) return null;
|
||||
|
||||
const hex = object;
|
||||
const lines: string[] = [];
|
||||
lines.push(`<strong>${(hex.count as number).toLocaleString()} properties</strong>`);
|
||||
if (isPostcode) {
|
||||
lines.push(`<strong>${object.postcode}</strong>`);
|
||||
}
|
||||
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
|
||||
|
||||
for (const f of featuresRef.current) {
|
||||
const minVal = hex[`min_${f.name}`];
|
||||
const maxVal = hex[`max_${f.name}`];
|
||||
const minVal = object[`min_${f.name}`];
|
||||
const maxVal = object[`max_${f.name}`];
|
||||
if (minVal != null && maxVal != null) {
|
||||
const minStr =
|
||||
typeof minVal === 'number'
|
||||
|
|
@ -475,8 +634,8 @@ export default memo(function Map({
|
|||
typeof maxVal === 'number'
|
||||
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: String(maxVal);
|
||||
const highlight = f.name === activeFeatureRef.current ? 'font-weight: bold;' : '';
|
||||
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
|
||||
const highlight = f.name === viewFeatureRef.current ? 'font-weight: bold;' : '';
|
||||
lines.push(`<div style="${highlight}">${f.name}: ${minStr} - ${maxStr}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,6 +664,14 @@ export default memo(function Map({
|
|||
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
||||
</MapGL>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} />
|
||||
{viewFeature && viewRange && colorFeatureMeta && (
|
||||
<MapLegend
|
||||
featureLabel={colorFeatureMeta.name}
|
||||
range={viewRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
/>
|
||||
)}
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue