UI changes

This commit is contained in:
Andras Schmelczer 2026-03-24 20:50:24 +00:00
parent 0aba73a2a3
commit 8616837c01
13 changed files with 126 additions and 100 deletions

View file

@ -130,12 +130,7 @@ export default function FeatureBrowser({
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300" className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
> >
<div className="min-w-0 mr-2"> <div className="min-w-0 mr-2">
<FeatureLabel feature={f} size="sm" /> <FeatureLabel feature={f} size="sm" description={f.description} />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div> </div>
<FeatureActions <FeatureActions
feature={f} feature={f}
@ -174,15 +169,16 @@ export default function FeatureBrowser({
<IconButton <IconButton
onClick={() => setTravelInfoMode(mode)} onClick={() => setTravelInfoMode(mode)}
title="Feature info" title="Feature info"
size="md"
> >
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton> </IconButton>
<button <button
onClick={() => onAddTravelTimeEntry(mode)} onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`} title={`Add ${MODE_LABELS[mode]} travel time`}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40" className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
> >
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} /> <PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
</button> </button>
</div> </div>
</div> </div>

View file

@ -112,8 +112,8 @@ function SliderLabels({
onValueChange?: (v: [number, number]) => void; onValueChange?: (v: [number, number]) => void;
}) { }) {
const range = max - min || 1; const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100; const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
const rightPct = ((value[1] - min) / range) * 100; const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
const labels = displayValues || value; const labels = displayValues || value;
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw); const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
@ -133,7 +133,7 @@ function SliderLabels({
<EditableLabel <EditableLabel
value={labels[0]} value={labels[0]}
formatted={minLabel} formatted={minLabel}
onCommit={(v) => onValueChange([Math.min(v, labels[1]), labels[1]])} onCommit={(v) => onValueChange([v, Math.max(v, labels[1])])}
prefix={feature.prefix} prefix={feature.prefix}
suffix={feature.suffix} suffix={feature.suffix}
style={{ left: `${leftPct}%`, transform: leftTranslate }} style={{ left: `${leftPct}%`, transform: leftTranslate }}
@ -141,7 +141,7 @@ function SliderLabels({
<EditableLabel <EditableLabel
value={labels[1]} value={labels[1]}
formatted={maxLabel} formatted={maxLabel}
onCommit={(v) => onValueChange([labels[0], Math.max(v, labels[0])])} onCommit={(v) => onValueChange([Math.min(labels[0], v), v])}
prefix={feature.prefix} prefix={feature.prefix}
suffix={feature.suffix} suffix={feature.suffix}
style={{ left: `${rightPct}%`, transform: rightTranslate }} style={{ left: `${rightPct}%`, transform: rightTranslate }}
@ -184,6 +184,7 @@ interface FiltersProps {
onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void; onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void; onTravelTimeToggleBest: (index: number) => void;
aiFilterLoading: boolean; aiFilterLoading: boolean;
aiFilterError: string | null; aiFilterError: string | null;
@ -220,6 +221,7 @@ export default memo(function Filters({
onTravelTimeRemoveEntry, onTravelTimeRemoveEntry,
onTravelTimeSetDestination, onTravelTimeSetDestination,
onTravelTimeRangeChange, onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest, onTravelTimeToggleBest,
aiFilterLoading, aiFilterLoading,
aiFilterError, aiFilterError,
@ -415,7 +417,7 @@ export default memo(function Filters({
</div> </div>
</div> </div>
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto"> <div ref={scrollRef} className="md:flex-1 md:overflow-y-auto overflow-x-hidden">
<AiFilterInput <AiFilterInput
loading={aiFilterLoading} loading={aiFilterLoading}
error={aiFilterError} error={aiFilterError}
@ -470,9 +472,14 @@ export default memo(function Filters({
timeRange={entry.timeRange} timeRange={entry.timeRange}
useBest={entry.useBest} useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)} isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))} onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)} onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)} onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)} onRemove={() => onTravelTimeRemoveEntry(index)}
/> />
@ -592,7 +599,7 @@ export default memo(function Filters({
min={scale ? 0 : feature.min!} min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!} max={scale ? 100 : feature.max!}
value={sliderValue} value={sliderValue}
displayValues={scale ? displayValue : undefined} displayValues={displayValue}
isAtMin={isAtMin} isAtMin={isAtMin}
isAtMax={isAtMax} isAtMax={isAtMax}
raw={feature.raw} raw={feature.raw}

View file

@ -1,5 +1,5 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import { Map as MapGL, useControl } from 'react-map-gl/maplibre'; import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import type { import type {
@ -212,6 +212,9 @@ export default memo(function Map({
maxBounds={MAP_BOUNDS} maxBounds={MAP_BOUNDS}
> >
<DeckOverlay layers={layers} getTooltip={null} /> <DeckOverlay layers={layers} getTooltip={null} />
{!screenshotMode && (
<ScaleControl position="bottom-left" maxWidth={100} unit="metric" />
)}
</MapGL> </MapGL>
{screenshotMode ? ( {screenshotMode ? (
ogMode ? ( ogMode ? (

View file

@ -105,8 +105,8 @@ export default function MapPage({
const [selectedPOICategories, setSelectedPOICategories] = const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories); useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left'); const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 500, 'right'); const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false); const [poiPaneOpen, setPoiPaneOpen] = useState(false);
@ -141,6 +141,7 @@ export default function MapPage({
handleDragStart, handleDragStart,
handleDragChange, handleDragChange,
handleDragEnd, handleDragEnd,
handleDragEndNoCommit,
handleTogglePin, handleTogglePin,
handleSetPin, handleSetPin,
handleCancelPin, handleCancelPin,
@ -204,12 +205,8 @@ export default function MapPage({
const handleTravelTimeSetDestination = useCallback( const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string) => { (index: number, slug: string, label: string) => {
travelTime.handleSetDestination(index, slug, label); travelTime.handleSetDestination(index, slug, label);
const entry = travelTime.entries[index];
if (entry) {
handleSetPin(`tt_${entry.mode}_${slug}`);
}
}, },
[travelTime.handleSetDestination, travelTime.entries, handleSetPin] [travelTime.handleSetDestination]
); );
const handleTravelTimeRemoveEntry = useCallback( const handleTravelTimeRemoveEntry = useCallback(
@ -223,6 +220,14 @@ export default function MapPage({
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin] [travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
); );
const handleTravelTimeDragEnd = useCallback(
(index: number) => {
const dv = handleDragEndNoCommit();
if (dv) travelTime.handleTimeRangeChange(index, dv);
},
[handleDragEndNoCommit, travelTime.handleTimeRangeChange]
);
const license = useLicense(); const license = useLicense();
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null); const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
@ -571,6 +576,7 @@ export default function MapPage({
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange} onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={travelTime.handleToggleBest} onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading} aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error} aiFilterError={aiFilters.error}

View file

@ -19,9 +19,14 @@ interface TravelTimeCardProps {
timeRange: [number, number] | null; timeRange: [number, number] | null;
useBest: boolean; useBest: boolean;
isPinned: boolean; isPinned: boolean;
isActive: boolean;
dragValue: [number, number] | null;
onTogglePin: () => void; onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void; onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void; onTimeRangeChange: (range: [number, number]) => void;
onDragStart: () => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onToggleBest: () => void; onToggleBest: () => void;
onRemove: () => void; onRemove: () => void;
} }
@ -33,9 +38,14 @@ export function TravelTimeCard({
timeRange, timeRange,
useBest, useBest,
isPinned, isPinned,
isActive,
dragValue,
onTogglePin, onTogglePin,
onSetDestination, onSetDestination,
onTimeRangeChange, onTimeRangeChange,
onDragStart,
onDragChange,
onDragEnd,
onToggleBest, onToggleBest,
onRemove, onRemove,
}: TravelTimeCardProps) { }: TravelTimeCardProps) {
@ -52,13 +62,13 @@ export function TravelTimeCard({
const sliderMin = 0; const sliderMin = 0;
const sliderMax = 120; const sliderMax = 120;
const displayRange = timeRange ?? [sliderMin, sliderMax]; const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
const ModeIcon = MODE_ICONS[mode]; const ModeIcon = MODE_ICONS[mode];
return ( return (
<div <div
className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`} className={`space-y-2 px-2 py-2 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -130,7 +140,9 @@ export function TravelTimeCard({
max={sliderMax} max={sliderMax}
step={1} step={1}
value={[displayRange[0], displayRange[1]]} value={[displayRange[0], displayRange[1]]}
onValueChange={([min, max]) => onTimeRangeChange([min, max])} onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart()}
onPointerUp={() => onDragEnd()}
/> />
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight"> <div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span> <span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span>

View file

@ -23,7 +23,7 @@ export function FeatureActions({
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
{feature.detail && onShowInfo && ( {feature.detail && onShowInfo && (
<IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md"> <IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md">
<InfoIcon className="w-7 h-7 md:w-3.5 md:h-3.5" /> <InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton> </IconButton>
)} )}
<IconButton <IconButton
@ -32,7 +32,7 @@ export function FeatureActions({
active={isPinned} active={isPinned}
size="md" size="md"
> >
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" /> <EyeIcon filled={isPinned} className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton> </IconButton>
{onAdd && ( {onAdd && (
<button <button
@ -40,7 +40,7 @@ export function FeatureActions({
title="Add filter" title="Add filter"
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40" className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
> >
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} /> <PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
</button> </button>
)} )}
{onRemove && ( {onRemove && (

View file

@ -14,6 +14,7 @@ interface FeatureLabelProps {
onShowInfo?: (feature: FeatureMeta) => void; onShowInfo?: (feature: FeatureMeta) => void;
className?: string; className?: string;
size?: 'xs' | 'sm'; size?: 'xs' | 'sm';
description?: string;
} }
export function FeatureLabel({ export function FeatureLabel({
@ -21,6 +22,7 @@ export function FeatureLabel({
onShowInfo, onShowInfo,
className = '', className = '',
size = 'xs', size = 'xs',
description,
}: FeatureLabelProps) { }: FeatureLabelProps) {
const textClass = size === 'sm' ? 'text-sm' : 'text-xs'; const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const iconClass = 'w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0'; const iconClass = 'w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0';
@ -31,12 +33,8 @@ export function FeatureLabel({
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ') ? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
: null; : null;
return ( const nameContent = (
<div <>
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
>
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}
<span <span
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`} className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
> >
@ -56,6 +54,23 @@ export function FeatureLabel({
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</button> </button>
)} )}
</>
);
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
>
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}
{description ? (
<div className="min-w-0">
<div className="flex items-center gap-1">{nameContent}</div>
<span className="text-xs text-warm-400 dark:text-warm-500 block">{description}</span>
</div>
) : (
nameContent
)}
</div> </div>
); );
} }

View file

@ -24,7 +24,7 @@ import {
POI_CLUSTER_MAX_ZOOM, POI_CLUSTER_MAX_ZOOM,
} from '../lib/consts'; } from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils'; import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime'; import type { TravelTimeEntry } from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension'; import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
interface UseDeckLayersProps { interface UseDeckLayersProps {
@ -120,9 +120,6 @@ export function useDeckLayers({
const hoveredPostcodeRef = useRef(hoveredPostcode); const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode; hoveredPostcodeRef.current = hoveredPostcode;
const travelTimeEntriesRef = useRef(travelTimeEntries);
travelTimeEntriesRef.current = travelTimeEntries;
const colorFeatureMeta = useMemo( const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features] [viewFeature, features]
@ -302,28 +299,6 @@ export function useDeckLayers({
getHexagon: (d) => d.h3, getHexagon: (d) => d.h3,
getFillColor: (d) => { getFillColor: (d) => {
const dark = isDarkRef.current; const dark = isDarkRef.current;
const entries = travelTimeEntriesRef.current;
// Dim-filter: all travel entries with timeRange dim hexagons outside range
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
const fk = travelFieldKey(entry);
const modeVal = d[`avg_${fk}`];
if (
modeVal == null ||
(modeVal as number) < entry.timeRange[0] ||
(modeVal as number) > entry.timeRange[1]
) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
number,
number,
number,
number,
];
}
}
const vf = viewFeatureRef.current; const vf = viewFeatureRef.current;
const clr = colorRangeRef.current; const clr = colorRangeRef.current;
const fr = filterRangeRef.current; const fr = filterRangeRef.current;
@ -425,28 +400,6 @@ export function useDeckLayers({
getFillColor: (f) => { getFillColor: (f) => {
const d = f.properties; const d = f.properties;
const dark = isDarkRef.current; const dark = isDarkRef.current;
const entries = travelTimeEntriesRef.current;
// Dim-filter: all travel entries with timeRange dim postcodes outside range
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
const fk = travelFieldKey(entry);
const modeVal = d[`avg_${fk}`];
if (
modeVal == null ||
(modeVal as number) < entry.timeRange[0] ||
(modeVal as number) > entry.timeRange[1]
) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
number,
number,
number,
number,
];
}
}
const vf = viewFeatureRef.current; const vf = viewFeatureRef.current;
const clr = colorRangeRef.current; const clr = colorRangeRef.current;
const fr = filterRangeRef.current; const fr = filterRangeRef.current;

View file

@ -120,6 +120,20 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
dragValueRef.current = null; dragValueRef.current = null;
}, []); }, []);
/** End drag without committing to filters — caller handles the commit (e.g. travel time). */
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
if (pendingDragRef.current) {
pendingDragRef.current = null;
return null;
}
const dv = dragValueRef.current;
setActiveFeature(null);
setDragValue(null);
dragActiveRef.current = null;
dragValueRef.current = null;
return dv;
}, []);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => { const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(newFilters); setFilters(newFilters);
setActiveFeature(null); setActiveFeature(null);
@ -159,6 +173,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
handleDragStart, handleDragStart,
handleDragChange, handleDragChange,
handleDragEnd, handleDragEnd,
handleDragEndNoCommit,
handleTogglePin, handleTogglePin,
handleSetPin, handleSetPin,
handleCancelPin, handleCancelPin,

View file

@ -81,25 +81,34 @@ export function useMapData({
); );
// Build the travel param string from entries with destinations. // Build the travel param string from entries with destinations.
// timeRange is NOT included — range filtering is handled purely client-side // Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
// (dimming in useDeckLayers) so slider changes never trigger server refetches. // When excludeFieldKey is set, that entry's time range is omitted (for drag preview).
const travelParam = useMemo((): string => { const buildTravelParam = useCallback(
(excludeFieldKey?: string): string => {
const segments: string[] = []; const segments: string[] = [];
for (const entry of travelTimeEntries) { for (const entry of travelTimeEntries) {
if (!entry.slug) continue; if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`; let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best'; if (entry.useBest) seg += ':best';
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
if (entry.timeRange && !isExcluded) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
segments.push(seg); segments.push(seg);
} }
return segments.join('|'); return segments.join('|');
}, [travelTimeEntries]); },
[travelTimeEntries]
);
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
// Keep activeFeatureRef in sync // Keep activeFeatureRef in sync
useEffect(() => { useEffect(() => {
activeFeatureRef.current = activeFeature; activeFeatureRef.current = activeFeature;
}, [activeFeature]); }, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter // Drag prefetch: when activeFeature starts, fetch data excluding that filter.
// For regular filters: excludes the filter from the filter string.
// For travel time: excludes the time range from that entry's travel param segment.
useEffect(() => { useEffect(() => {
if (!activeFeature || !bounds) return; if (!activeFeature || !bounds) return;
@ -108,11 +117,14 @@ export function useMapData({
const filtersStr = buildFilterString(filters, features, activeFeature); const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const isTravelTimeDrag = activeFeature.startsWith('tt_');
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
if (usePostcodeView) { if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr }); const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr); if (filtersStr) params.set('filters', filtersStr);
params.set('fields', activeFeature); params.set('fields', activeFeature);
if (dragTravelParam) params.set('travel', dragTravelParam);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal })) fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json()) .then((res) => res.json())
@ -129,7 +141,7 @@ export function useMapData({
}); });
if (filtersStr) params.set('filters', filtersStr); if (filtersStr) params.set('filters', filtersStr);
params.set('fields', activeFeature); params.set('fields', activeFeature);
if (travelParam) params.set('travel', travelParam); if (dragTravelParam) params.set('travel', dragTravelParam);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal })) fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json()) .then((res) => res.json())
@ -147,7 +159,7 @@ export function useMapData({
dragAbortRef.current = null; dragAbortRef.current = null;
} }
}; };
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam]); }, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam, buildTravelParam]);
// Fetch hexagons or postcodes when bounds/filters change // Fetch hexagons or postcodes when bounds/filters change
useEffect(() => { useEffect(() => {

View file

@ -24,10 +24,11 @@ export function usePaneResize(
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (!draggingRef.current) return; if (!draggingRef.current) return;
const resolvedMax = maxWidth <= 1 ? window.innerWidth * maxWidth : maxWidth;
const newWidth = const newWidth =
side === 'left' side === 'left'
? Math.min(maxWidth, Math.max(minWidth, e.clientX)) ? Math.min(resolvedMax, Math.max(minWidth, e.clientX))
: Math.min(maxWidth, Math.max(minWidth, window.innerWidth - e.clientX)); : Math.min(resolvedMax, Math.max(minWidth, window.innerWidth - e.clientX));
setWidth(newWidth); setWidth(newWidth);
}, },
[side, minWidth, maxWidth] [side, minWidth, maxWidth]

View file

@ -201,6 +201,13 @@ h3 {
} }
} }
/* MapLibre scale control — dark mode */
.dark .maplibregl-ctrl-scale {
border-color: #d6d3d1;
color: #d6d3d1;
background-color: rgba(28, 25, 23, 0.5);
}
/* Hide scrollbar for pill groups on mobile */ /* Hide scrollbar for pill groups on mobile */
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;

View file

@ -10,7 +10,6 @@ import {
BUFFER_MULTIPLIER, BUFFER_MULTIPLIER,
ENUM_PALETTE, ENUM_PALETTE,
} from './consts'; } from './consts';
const ROAD_OPACITY = 0.4; const ROAD_OPACITY = 0.4;
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification { export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {