Add dark mode

This commit is contained in:
Andras Schmelczer 2026-02-01 13:07:24 +00:00
parent 5e210e14bd
commit 7235df0a97
14 changed files with 304 additions and 139 deletions

View file

@ -21,6 +21,7 @@ interface MapProps {
selectedHexagonId: string | null;
onHexagonClick: (h3: string) => void;
initialViewState?: ViewState;
theme?: 'light' | 'dark';
}
// Twemoji CDN base URL
@ -42,7 +43,8 @@ const INITIAL_VIEW: ViewState = {
pitch: 0,
};
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
const MAP_STYLE_LIGHT = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
const MAP_STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// Gradient stops for normalized [0,1] values
const GRADIENT: { t: number; color: [number, number, number] }[] = [
@ -204,7 +206,7 @@ function PostcodeSearch({
setError(null);
}}
placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white"
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"
/>
<button
type="submit"
@ -215,7 +217,7 @@ function PostcodeSearch({
</button>
</div>
{error && (
<span className="text-xs text-red-600 bg-white/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-warm-800/90 rounded px-2 py-0.5 shadow">{error}</span>
)}
</form>
);
@ -240,7 +242,7 @@ function MapLegend({
};
return (
<div className="absolute top-3 right-3 z-10 bg-white rounded shadow-lg p-3 text-xs min-w-[160px]">
<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="flex items-center justify-between mb-2">
<span className="font-semibold text-sm">{featureLabel}</span>
{showCancel && (
@ -268,7 +270,7 @@ function MapLegend({
'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">
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
<span>{formatVal(range[0])}</span>
<span>{formatVal(range[1])}</span>
</div>
@ -289,6 +291,7 @@ export default memo(function Map({
selectedHexagonId,
onHexagonClick,
initialViewState,
theme = 'light',
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
@ -343,28 +346,39 @@ export default memo(function Map({
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const themeRef = useRef(theme);
themeRef.current = theme;
// Make place labels more legible over the colored hexagons
const handleMapLoad = useCallback(
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
const map = evt.target;
for (const layer of map.getStyle().layers || []) {
if (layer.type !== 'symbol') continue;
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
map.setPaintProperty(layer.id, 'text-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222');
}
// Make water more prominent
for (const layer of map.getStyle().layers || []) {
if (layer.id === 'water' || layer.id.startsWith('water')) {
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
if (themeRef.current === 'light') {
for (const layer of map.getStyle().layers || []) {
if (layer.type !== 'symbol') continue;
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
map.setPaintProperty(layer.id, 'text-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222');
}
// Make water more prominent
for (const layer of map.getStyle().layers || []) {
if (layer.id === 'water' || layer.id.startsWith('water')) {
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
}
}
}
map.setLayoutProperty('building', 'visibility', 'none');
map.setLayoutProperty('building-top', 'visibility', 'none');
try {
map.setLayoutProperty('building', 'visibility', 'none');
map.setLayoutProperty('building-top', 'visibility', 'none');
} catch {
// layers may not exist in dark style
}
},
[]
);
const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
// Popup state for POI hover
const [popupInfo, setPopupInfo] = useState<{
x: number;
@ -541,20 +555,20 @@ export default memo(function Map({
getPosition: (d) => [d.lon as number, d.lat as number],
getText: (d) => d.postcode as string,
getSize: 11,
getColor: [30, 30, 30, 220],
getColor: theme === 'dark' ? [220, 220, 220, 220] : [30, 30, 30, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [255, 255, 255, 200],
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
billboard: false,
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,
})
: null,
[postcodeData, showPostcodes]
[postcodeData, showPostcodes, theme]
);
const layers = useMemo(
@ -593,12 +607,14 @@ export default memo(function Map({
}
}
const isDark = themeRef.current === 'dark';
return {
html: `<div style="padding: 8px; font-size: 12px;">${lines.join('')}</div>`,
style: {
backgroundColor: 'white',
backgroundColor: isDark ? '#292524' : 'white',
color: isDark ? '#e7e5e4' : 'inherit',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
boxShadow: isDark ? '0 2px 4px rgba(0,0,0,0.5)' : '0 2px 4px rgba(0,0,0,0.2)',
},
};
},
@ -611,9 +627,14 @@ export default memo(function Map({
{...viewState}
onMove={handleMove}
onLoad={handleMapLoad as never}
mapStyle={MAP_STYLE}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
dragRotate={false}
touchZoomRotate={true}
touchPitch={false}
keyboard={true}
pitchWithRotate={false}
>
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL>
@ -628,7 +649,7 @@ export default memo(function Map({
)}
{popupInfo && (
<div
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
className="absolute pointer-events-none bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,
@ -637,7 +658,7 @@ export default memo(function Map({
}}
>
<strong>{popupInfo.name}</strong>
<div className="text-gray-500 text-xs">{popupInfo.category}</div>
<div className="text-gray-500 dark:text-warm-400 text-xs">{popupInfo.category}</div>
</div>
)}
</div>