diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index defba35..adba0be 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -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" >
- - {f.description && ( - - {f.description} - - )} +
setTravelInfoMode(mode)} title="Feature info" + size="md" > - + diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 7aec19a..39015e6 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -112,8 +112,8 @@ function SliderLabels({ onValueChange?: (v: [number, number]) => void; }) { const range = max - min || 1; - const leftPct = ((value[0] - min) / range) * 100; - const rightPct = ((value[1] - min) / range) * 100; + const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100)); + const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100)); const labels = displayValues || value; const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw); @@ -133,7 +133,7 @@ function SliderLabels({ onValueChange([Math.min(v, labels[1]), labels[1]])} + onCommit={(v) => onValueChange([v, Math.max(v, labels[1])])} prefix={feature.prefix} suffix={feature.suffix} style={{ left: `${leftPct}%`, transform: leftTranslate }} @@ -141,7 +141,7 @@ function SliderLabels({ onValueChange([labels[0], Math.max(v, labels[0])])} + onCommit={(v) => onValueChange([Math.min(labels[0], v), v])} prefix={feature.prefix} suffix={feature.suffix} style={{ left: `${rightPct}%`, transform: rightTranslate }} @@ -184,6 +184,7 @@ interface FiltersProps { onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; onTravelTimeRangeChange: (index: number, range: [number, number]) => void; + onTravelTimeDragEnd: (index: number) => void; onTravelTimeToggleBest: (index: number) => void; aiFilterLoading: boolean; aiFilterError: string | null; @@ -220,6 +221,7 @@ export default memo(function Filters({ onTravelTimeRemoveEntry, onTravelTimeSetDestination, onTravelTimeRangeChange, + onTravelTimeDragEnd, onTravelTimeToggleBest, aiFilterLoading, aiFilterError, @@ -415,7 +417,7 @@ export default memo(function Filters({ -
+
onTogglePin(travelFieldKey(entry))} onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)} onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} + onDragStart={() => onDragStart(travelFieldKey(entry))} + onDragChange={onDragChange} + onDragEnd={() => onTravelTimeDragEnd(index)} onToggleBest={() => onTravelTimeToggleBest(index)} onRemove={() => onTravelTimeRemoveEntry(index)} /> @@ -592,7 +599,7 @@ export default memo(function Filters({ min={scale ? 0 : feature.min!} max={scale ? 100 : feature.max!} value={sliderValue} - displayValues={scale ? displayValue : undefined} + displayValues={displayValue} isAtMin={isAtMin} isAtMax={isAtMax} raw={feature.raw} diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 4519b16..001dfc7 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -1,5 +1,5 @@ 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 'maplibre-gl/dist/maplibre-gl.css'; import type { @@ -212,6 +212,9 @@ export default memo(function Map({ maxBounds={MAP_BOUNDS} > + {!screenshotMode && ( + + )} {screenshotMode ? ( ogMode ? ( diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 62b93b7..a2976ee 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -105,8 +105,8 @@ export default function MapPage({ const [selectedPOICategories, setSelectedPOICategories] = useState>(initialPOICategories); - const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left'); - const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 500, 'right'); + const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left'); + const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right'); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [poiPaneOpen, setPoiPaneOpen] = useState(false); @@ -141,6 +141,7 @@ export default function MapPage({ handleDragStart, handleDragChange, handleDragEnd, + handleDragEndNoCommit, handleTogglePin, handleSetPin, handleCancelPin, @@ -204,12 +205,8 @@ export default function MapPage({ const handleTravelTimeSetDestination = useCallback( (index: number, slug: string, label: string) => { 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( @@ -223,6 +220,14 @@ export default function MapPage({ [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 mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null); @@ -571,6 +576,7 @@ export default function MapPage({ onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeRangeChange={travelTime.handleTimeRangeChange} + onTravelTimeDragEnd={handleTravelTimeDragEnd} onTravelTimeToggleBest={travelTime.handleToggleBest} aiFilterLoading={aiFilters.loading} aiFilterError={aiFilters.error} diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 02b68ba..e432a65 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -19,9 +19,14 @@ interface TravelTimeCardProps { timeRange: [number, number] | null; useBest: boolean; isPinned: boolean; + isActive: boolean; + dragValue: [number, number] | null; onTogglePin: () => void; onSetDestination: (slug: string, label: string) => void; onTimeRangeChange: (range: [number, number]) => void; + onDragStart: () => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; onToggleBest: () => void; onRemove: () => void; } @@ -33,9 +38,14 @@ export function TravelTimeCard({ timeRange, useBest, isPinned, + isActive, + dragValue, onTogglePin, onSetDestination, onTimeRangeChange, + onDragStart, + onDragChange, + onDragEnd, onToggleBest, onRemove, }: TravelTimeCardProps) { @@ -52,13 +62,13 @@ export function TravelTimeCard({ const sliderMin = 0; const sliderMax = 120; - const displayRange = timeRange ?? [sliderMin, sliderMax]; + const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]); const ModeIcon = MODE_ICONS[mode]; return (
{/* Header */}
@@ -130,7 +140,9 @@ export function TravelTimeCard({ max={sliderMax} step={1} value={[displayRange[0], displayRange[1]]} - onValueChange={([min, max]) => onTimeRangeChange([min, max])} + onValueChange={([min, max]) => onDragChange([min, max])} + onPointerDown={() => onDragStart()} + onPointerUp={() => onDragEnd()} />
{formatFilterValue(displayRange[0])} min diff --git a/frontend/src/components/ui/FeatureIcons.tsx b/frontend/src/components/ui/FeatureIcons.tsx index 1dbce65..f07fbb0 100644 --- a/frontend/src/components/ui/FeatureIcons.tsx +++ b/frontend/src/components/ui/FeatureIcons.tsx @@ -23,7 +23,7 @@ export function FeatureActions({
{feature.detail && onShowInfo && ( onShowInfo(feature)} title="Feature info" size="md"> - + )} - + {onAdd && ( )} {onRemove && ( diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index d261859..178fba3 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -14,6 +14,7 @@ interface FeatureLabelProps { onShowInfo?: (feature: FeatureMeta) => void; className?: string; size?: 'xs' | 'sm'; + description?: string; } export function FeatureLabel({ @@ -21,6 +22,7 @@ export function FeatureLabel({ onShowInfo, className = '', size = 'xs', + description, }: FeatureLabelProps) { 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'; @@ -31,12 +33,8 @@ export function FeatureLabel({ ? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ') : null; - return ( -
- {featureIcon} - {GroupIcon && } + const nameContent = ( + <> @@ -56,6 +54,23 @@ export function FeatureLabel({ )} + + ); + + return ( +
+ {featureIcon} + {GroupIcon && } + {description ? ( +
+
{nameContent}
+ {description} +
+ ) : ( + nameContent + )}
); } diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts index 920af1e..438d240 100644 --- a/frontend/src/hooks/useDeckLayers.ts +++ b/frontend/src/hooks/useDeckLayers.ts @@ -24,7 +24,7 @@ import { POI_CLUSTER_MAX_ZOOM, } from '../lib/consts'; import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils'; -import { type TravelTimeEntry, travelFieldKey } from './useTravelTime'; +import type { TravelTimeEntry } from './useTravelTime'; import { MarchingAntsExtension } from '../lib/MarchingAntsExtension'; interface UseDeckLayersProps { @@ -120,9 +120,6 @@ export function useDeckLayers({ const hoveredPostcodeRef = useRef(hoveredPostcode); hoveredPostcodeRef.current = hoveredPostcode; - const travelTimeEntriesRef = useRef(travelTimeEntries); - travelTimeEntriesRef.current = travelTimeEntries; - const colorFeatureMeta = useMemo( () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), [viewFeature, features] @@ -302,28 +299,6 @@ export function useDeckLayers({ getHexagon: (d) => d.h3, getFillColor: (d) => { 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 clr = colorRangeRef.current; const fr = filterRangeRef.current; @@ -425,28 +400,6 @@ export function useDeckLayers({ getFillColor: (f) => { const d = f.properties; 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 clr = colorRangeRef.current; const fr = filterRangeRef.current; diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index 73e27f3..3235d11 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -120,6 +120,20 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { 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) => { setFilters(newFilters); setActiveFeature(null); @@ -159,6 +173,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { handleDragStart, handleDragChange, handleDragEnd, + handleDragEndNoCommit, handleTogglePin, handleSetPin, handleCancelPin, diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 661e7a1..9ef6d16 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -81,25 +81,34 @@ export function useMapData({ ); // Build the travel param string from entries with destinations. - // timeRange is NOT included — range filtering is handled purely client-side - // (dimming in useDeckLayers) so slider changes never trigger server refetches. - const travelParam = useMemo((): string => { - const segments: string[] = []; - for (const entry of travelTimeEntries) { - if (!entry.slug) continue; - let seg = `${entry.mode}:${entry.slug}`; - if (entry.useBest) seg += ':best'; - segments.push(seg); - } - return segments.join('|'); - }, [travelTimeEntries]); + // Format: mode:slug[:best][:min:max] — server filters rows outside [min,max]. + // When excludeFieldKey is set, that entry's time range is omitted (for drag preview). + const buildTravelParam = useCallback( + (excludeFieldKey?: string): string => { + const segments: string[] = []; + for (const entry of travelTimeEntries) { + if (!entry.slug) continue; + let seg = `${entry.mode}:${entry.slug}`; + 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); + } + return segments.join('|'); + }, + [travelTimeEntries] + ); + + const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]); // Keep activeFeatureRef in sync useEffect(() => { activeFeatureRef.current = 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(() => { if (!activeFeature || !bounds) return; @@ -108,11 +117,14 @@ export function useMapData({ const filtersStr = buildFilterString(filters, features, activeFeature); const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; + const isTravelTimeDrag = activeFeature.startsWith('tt_'); + const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam; if (usePostcodeView) { const params = new URLSearchParams({ bounds: boundsStr }); if (filtersStr) params.set('filters', filtersStr); params.set('fields', activeFeature); + if (dragTravelParam) params.set('travel', dragTravelParam); fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal })) .then((res) => res.json()) @@ -129,7 +141,7 @@ export function useMapData({ }); if (filtersStr) params.set('filters', filtersStr); 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 })) .then((res) => res.json()) @@ -147,7 +159,7 @@ export function useMapData({ 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 useEffect(() => { diff --git a/frontend/src/hooks/usePaneResize.ts b/frontend/src/hooks/usePaneResize.ts index abd04e7..57ab46f 100644 --- a/frontend/src/hooks/usePaneResize.ts +++ b/frontend/src/hooks/usePaneResize.ts @@ -24,10 +24,11 @@ export function usePaneResize( const handlePointerMove = useCallback( (e: React.PointerEvent) => { if (!draggingRef.current) return; + const resolvedMax = maxWidth <= 1 ? window.innerWidth * maxWidth : maxWidth; const newWidth = side === 'left' - ? Math.min(maxWidth, Math.max(minWidth, e.clientX)) - : Math.min(maxWidth, Math.max(minWidth, window.innerWidth - e.clientX)); + ? Math.min(resolvedMax, Math.max(minWidth, e.clientX)) + : Math.min(resolvedMax, Math.max(minWidth, window.innerWidth - e.clientX)); setWidth(newWidth); }, [side, minWidth, maxWidth] diff --git a/frontend/src/index.css b/frontend/src/index.css index c4b3b10..28a675e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 */ .scrollbar-hide { -ms-overflow-style: none; diff --git a/frontend/src/lib/map-utils.ts b/frontend/src/lib/map-utils.ts index 1829b39..805a717 100644 --- a/frontend/src/lib/map-utils.ts +++ b/frontend/src/lib/map-utils.ts @@ -10,7 +10,6 @@ import { BUFFER_MULTIPLIER, ENUM_PALETTE, } from './consts'; - const ROAD_OPACITY = 0.4; export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {