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 {