Spice up website

This commit is contained in:
Andras Schmelczer 2026-01-31 22:04:28 +00:00
parent f7d586a1e9
commit 7627818e98
9 changed files with 831 additions and 164 deletions

View file

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