has issues
This commit is contained in:
parent
2e112d7398
commit
c645b0f1d4
96 changed files with 2147083 additions and 5787 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue