has issues

This commit is contained in:
Andras Schmelczer 2026-05-25 13:20:17 +01:00
parent 2e112d7398
commit c645b0f1d4
96 changed files with 2147083 additions and 5787 deletions

View file

@ -1,10 +1,14 @@
import { useState, useCallback, useRef } from 'react';
import type { FeatureFilters } from '../types';
import type { TransportMode } from './useTravelTime';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
export interface AiTravelTimeFilter {
mode: TransportMode;
/**
* Server-side mode string. May be a base mode (car|bicycle|walking|transit)
* or a transit variant (transit-no-bus, transit-no-change, ). Callers must
* normalise via parseServerMode before constructing a TravelTimeEntry.
*/
mode: string;
slug: string;
label: string;
min?: number;
@ -132,7 +136,7 @@ export function useAiFilters(): UseAiFiltersResult {
const json = await response.json();
const travelTimeFilters: AiTravelTimeFilter[] = (json.travel_time_filters || []).map(
(tt: { mode: string; slug: string; label: string; min?: number; max?: number }) => ({
mode: tt.mode as TransportMode,
mode: tt.mode,
slug: tt.slug,
label: tt.label,
min: tt.min,

View file

@ -82,7 +82,7 @@ describe('usePoiLayers', () => {
width: 96,
height: 48,
});
expect(getSize(waitrose)).toBe(24);
expect(getSize(waitrose)).toBe(14);
});
it('prefers POI fascia icon categories for map marker icons', () => {
@ -98,7 +98,7 @@ describe('usePoiLayers', () => {
width: 96,
height: 48,
});
expect(getSize(foodWarehouse)).toBe(24);
expect(getSize(foodWarehouse)).toBe(14);
});
it('keeps generic emoji POIs at the compact marker size', () => {
@ -114,7 +114,7 @@ describe('usePoiLayers', () => {
width: 72,
height: 72,
});
expect(getSize(supermarket)).toBe(18);
expect(getSize(supermarket)).toBe(11);
});
it('hides the circular marker badge behind bundled logo icons', () => {
@ -129,12 +129,12 @@ describe('usePoiLayers', () => {
const getLineColor = backgroundLayer.props.getLineColor as (poi: POI) => PoiColor;
expect(getShadowRadius(waitrose)).toBe(0);
expect(getBackgroundRadius(waitrose)).toBe(24);
expect(getBackgroundRadius(waitrose)).toBe(14);
expect(getFillColor(waitrose)).toEqual([0, 0, 0, 0]);
expect(getLineColor(waitrose)).toEqual([0, 0, 0, 0]);
expect(getShadowRadius(supermarket)).toBe(16);
expect(getBackgroundRadius(supermarket)).toBe(14);
expect(getShadowRadius(supermarket)).toBe(10);
expect(getBackgroundRadius(supermarket)).toBe(8);
expect(getFillColor(supermarket)).toEqual([255, 255, 255, 255]);
expect(getLineColor(supermarket)).toEqual([34, 197, 94, 255]);
});

View file

@ -3,7 +3,7 @@ import type { PickingInfo } from '@deck.gl/core';
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import Supercluster from 'supercluster';
import type { POI } from '../types';
import type { POI, SchoolMetadata } from '../types';
import {
POI_GROUP_COLORS,
MINOR_POI_CATEGORIES,
@ -24,6 +24,7 @@ export interface PopupInfo {
id: string;
isCluster?: boolean;
clusterCount?: number;
school?: SchoolMetadata;
}
interface ClusterPoint {
@ -60,7 +61,7 @@ function getPoiGroupColor(group: string): [number, number, number] {
}
function getPoiIconSize(poi: POI): number {
return hasBundledPoiLogo(poi) ? 24 : 18;
return hasBundledPoiLogo(poi) ? 14 : 11;
}
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
@ -77,6 +78,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
group: info.object.group,
emoji: info.object.emoji,
id: info.object.id,
school: info.object.school,
});
} else {
setPopupInfo(null);
@ -162,7 +164,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
id: 'poi-shadow',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => (hasBundledPoiLogo(d) ? 0 : 16),
getRadius: (d) => (hasBundledPoiLogo(d) ? 0 : 10),
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
pickable: false,
@ -177,7 +179,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
id: 'poi-background',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => (hasBundledPoiLogo(d) ? 24 : 14),
getRadius: (d) => (hasBundledPoiLogo(d) ? 14 : 8),
radiusUnits: 'pixels',
getFillColor: (d) =>
hasBundledPoiLogo(d)

View file

@ -1,12 +1,22 @@
import { useState, useEffect } from 'react';
import { logNonAbortError } from '../lib/api';
import type { TransportMode } from './useTravelTime';
import { TRANSPORT_MODES, type TransportMode } from './useTravelTime';
/**
* Server may report transit variants (transit-no-bus, transit-no-change, )
* alongside the four base modes. The UI mode picker only exposes the base modes;
* the transit variants are surfaced via toggles on a transit entry. This typing
* keeps the data model honest: the server speaks strings, we narrow at the edge.
*/
interface TravelModeInfo {
mode: TransportMode;
mode: string;
destinations: number;
}
function isBaseMode(mode: string): mode is TransportMode {
return (TRANSPORT_MODES as readonly string[]).includes(mode);
}
/** Fetches which transport modes have precomputed travel time data. */
export function useTravelModes() {
const [availableModes, setAvailableModes] = useState<Set<TransportMode> | null>(null);
@ -20,9 +30,19 @@ export function useTravelModes() {
return res.json();
})
.then((data: { modes: TravelModeInfo[] }) => {
const modes = new Set<TransportMode>(
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode)
);
const modes = new Set<TransportMode>();
let anyTransitVariantHasData = false;
for (const m of data.modes) {
if (m.destinations <= 0) continue;
if (isBaseMode(m.mode)) {
modes.add(m.mode);
} else if (m.mode.startsWith('transit-')) {
// Variant directories ensure the transit mode is reachable even if
// someone deletes the base `transit/` parquet folder by mistake.
anyTransitVariantHasData = true;
}
}
if (anyTransitVariantHasData) modes.add('transit');
setAvailableModes(modes);
})
.catch((err) => logNonAbortError('travel modes', err));

View file

@ -1,7 +1,13 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { travelFieldKey, useTravelTime, type TravelTimeEntry } from './useTravelTime';
import {
parseServerMode,
resolveTransitVariant,
travelFieldKey,
useTravelTime,
type TravelTimeEntry,
} from './useTravelTime';
describe('useTravelTime', () => {
it('creates backend field keys from mode and destination slug', () => {
@ -21,7 +27,15 @@ describe('useTravelTime', () => {
act(() => result.current.handleAddEntry('transit'));
expect(result.current.entries).toEqual([
{ mode: 'transit', slug: '', label: '', timeRange: null, useBest: false },
{
mode: 'transit',
slug: '',
label: '',
timeRange: null,
useBest: false,
noChange: false,
noBuses: false,
},
]);
expect(result.current.activeEntries).toEqual([]);
@ -29,7 +43,7 @@ describe('useTravelTime', () => {
expect(result.current.entries[0]).toMatchObject({
slug: 'bank',
label: 'Bank',
timeRange: [0, 120],
timeRange: [0, 90],
});
expect(result.current.activeEntries).toHaveLength(1);
@ -112,4 +126,99 @@ describe('useTravelTime', () => {
},
]);
});
it('toggles noChange and noBuses independently', () => {
const { result } = renderHook(() => useTravelTime());
act(() => result.current.handleAddEntry('transit'));
act(() => result.current.handleSetDestination(0, 'bank', 'Bank'));
expect(result.current.entries[0]).toMatchObject({ noChange: false, noBuses: false });
act(() => result.current.handleToggleNoChange(0));
expect(result.current.entries[0]).toMatchObject({ noChange: true, noBuses: false });
act(() => result.current.handleToggleNoBuses(0));
expect(result.current.entries[0]).toMatchObject({ noChange: true, noBuses: true });
act(() => result.current.handleToggleNoChange(0));
expect(result.current.entries[0]).toMatchObject({ noChange: false, noBuses: true });
});
});
describe('resolveTransitVariant', () => {
const base: TravelTimeEntry = {
mode: 'transit',
slug: 'bank',
label: 'Bank',
timeRange: [0, 90],
useBest: false,
};
it('passes non-transit modes through unchanged', () => {
expect(resolveTransitVariant({ ...base, mode: 'car' })).toBe('car');
expect(resolveTransitVariant({ ...base, mode: 'bicycle' })).toBe('bicycle');
expect(resolveTransitVariant({ ...base, mode: 'walking' })).toBe('walking');
});
it('maps transit toggle combinations to the right variant string', () => {
expect(resolveTransitVariant(base)).toBe('transit');
expect(resolveTransitVariant({ ...base, noChange: true })).toBe('transit-no-change');
expect(resolveTransitVariant({ ...base, noBuses: true })).toBe('transit-no-bus');
expect(resolveTransitVariant({ ...base, noChange: true, noBuses: true })).toBe(
'transit-no-change-no-bus'
);
});
it('treats undefined flags as false', () => {
expect(resolveTransitVariant({ ...base, noChange: undefined, noBuses: undefined })).toBe(
'transit'
);
});
});
describe('parseServerMode', () => {
it('round-trips the four toggle-reachable variants', () => {
expect(parseServerMode('transit')).toEqual({ mode: 'transit', noChange: false, noBuses: false });
expect(parseServerMode('transit-no-bus')).toEqual({
mode: 'transit',
noChange: false,
noBuses: true,
});
expect(parseServerMode('transit-no-change')).toEqual({
mode: 'transit',
noChange: true,
noBuses: false,
});
expect(parseServerMode('transit-no-change-no-bus')).toEqual({
mode: 'transit',
noChange: true,
noBuses: true,
});
});
it('parses non-transit base modes', () => {
expect(parseServerMode('car')).toEqual({ mode: 'car', noChange: false, noBuses: false });
expect(parseServerMode('bicycle')).toEqual({ mode: 'bicycle', noChange: false, noBuses: false });
expect(parseServerMode('walking')).toEqual({ mode: 'walking', noChange: false, noBuses: false });
});
it('returns null for variants the UI cannot represent (no silent broadening)', () => {
expect(parseServerMode('transit-one-change')).toBeNull();
expect(parseServerMode('transit-one-change-no-bus')).toBeNull();
expect(parseServerMode('unknown-mode')).toBeNull();
});
it('travelFieldKey uses the resolved variant', () => {
expect(
travelFieldKey({
mode: 'transit',
slug: 'bank',
label: 'Bank',
timeRange: [0, 90],
useBest: false,
noChange: true,
noBuses: true,
})
).toBe('tt_transit-no-change-no-bus_bank');
});
});

View file

@ -64,13 +64,68 @@ export interface TravelTimeEntry {
timeRange: [number, number] | null;
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
useBest: boolean;
/** Restrict transit to walk-transit-walk (0 changes). Optional; defaults to false. */
noChange?: boolean;
/** Drop buses from the allowed transit modes. Optional; defaults to false. */
noBuses?: boolean;
}
/** Field key matching the backend response: tt_{mode}_{slug} */
export function travelFieldKey(entry: TravelTimeEntry): string {
return `tt_${entry.mode}_${entry.slug}`;
/**
* The Java pipeline emits 6 transit variants as separate parquet directories.
* The UI represents transit with two toggle booleans; this maps the toggle
* state back to the directory/mode name the server expects.
*
* For non-transit modes the entry.mode passes through unchanged.
*
* Note: the transit-one-change* variants exist server-side but are not reachable
* from the UI toggles (only no-change + no-buses are exposed). They're available
* via direct API access for callers that want them.
*/
export function resolveTransitVariant(entry: TravelTimeEntry): string {
if (entry.mode !== 'transit') return entry.mode;
const nc = entry.noChange ?? false;
const nb = entry.noBuses ?? false;
if (nc && nb) return 'transit-no-change-no-bus';
if (nc) return 'transit-no-change';
if (nb) return 'transit-no-bus';
return 'transit';
}
/**
* Parse a server-side mode string (incl. transit variants) back into a base
* TransportMode + UI toggle booleans. Returns null for mode strings the UI
* cannot represent (currently: transit-one-change, transit-one-change-no-bus).
* Callers should skip entries that parse to null rather than silently
* normalising to a different variant.
*/
export function parseServerMode(
modeStr: string
): { mode: TransportMode; noChange: boolean; noBuses: boolean } | null {
if (modeStr === 'car' || modeStr === 'bicycle' || modeStr === 'walking') {
return { mode: modeStr, noChange: false, noBuses: false };
}
switch (modeStr) {
case 'transit':
return { mode: 'transit', noChange: false, noBuses: false };
case 'transit-no-bus':
return { mode: 'transit', noChange: false, noBuses: true };
case 'transit-no-change':
return { mode: 'transit', noChange: true, noBuses: false };
case 'transit-no-change-no-bus':
return { mode: 'transit', noChange: true, noBuses: true };
default:
return null;
}
}
/** Field key matching the backend response: tt_{server-mode}_{slug} */
export function travelFieldKey(entry: TravelTimeEntry): string {
return `tt_${resolveTransitVariant(entry)}_${entry.slug}`;
}
/** Slider/data ceiling (minutes). Mirrors MAX_TRIP_DURATION_MINUTES in the R5 pipeline. */
export const MAX_TRAVEL_MINUTES = 90;
export interface TravelTimeInitial {
entries?: TravelTimeEntry[];
}
@ -81,7 +136,10 @@ export function useTravelTime(initial?: TravelTimeInitial) {
);
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
setEntries((prev) => [
...prev,
{ mode, slug: '', label: '', timeRange: null, useBest: false, noChange: false, noBuses: false },
]);
}, []);
const handleRemoveEntry = useCallback((index: number) => {
@ -92,7 +150,9 @@ export function useTravelTime(initial?: TravelTimeInitial) {
setEntries((prev) =>
dedupeTravelTimeEntries(
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
i === index
? { ...entry, slug, label, timeRange: slug ? [0, MAX_TRAVEL_MINUTES] : null }
: entry
)
)
);
@ -114,6 +174,26 @@ export function useTravelTime(initial?: TravelTimeInitial) {
);
}, []);
const handleToggleNoChange = useCallback((index: number) => {
setEntries((prev) =>
dedupeTravelTimeEntries(
prev.map((entry, i) =>
i === index ? { ...entry, noChange: !(entry.noChange ?? false) } : entry
)
)
);
}, []);
const handleToggleNoBuses = useCallback((index: number) => {
setEntries((prev) =>
dedupeTravelTimeEntries(
prev.map((entry, i) =>
i === index ? { ...entry, noBuses: !(entry.noBuses ?? false) } : entry
)
)
);
}, []);
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
setEntries(dedupeTravelTimeEntries(newEntries));
}, []);
@ -130,5 +210,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
handleSetEntries,
handleTimeRangeChange,
handleToggleBest,
handleToggleNoChange,
handleToggleNoBuses,
};
}

View file

@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state';
import type { OverlayId } from '../lib/overlays';
import type { TravelTimeEntry } from './useTravelTime';
const URL_DEBOUNCE_MS = 300;
@ -12,7 +13,8 @@ export function useUrlSync(
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntry[],
share?: string
share?: string,
selectedOverlays?: Set<OverlayId>
) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -28,7 +30,8 @@ export function useUrlSync(
selectedPOICategories,
rightPaneTab,
travelTimeEntries,
share
share,
selectedOverlays
);
const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
@ -46,5 +49,6 @@ export function useUrlSync(
rightPaneTab,
travelTimeEntries,
share,
selectedOverlays,
]);
}