lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
|
|
@ -117,7 +117,7 @@ export function useAreaSummary({
|
|||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [fetchSummary]);
|
||||
|
||||
return { summary, loading, error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface AuthUser {
|
|||
verified: boolean;
|
||||
isAdmin: boolean;
|
||||
subscription: string;
|
||||
newsletter: boolean;
|
||||
}
|
||||
|
||||
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
|
||||
|
|
@ -19,6 +20,7 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
|
|||
verified: typeof record.verified === 'boolean' ? record.verified : false,
|
||||
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
|
||||
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
|
||||
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -115,8 +117,32 @@ export function useAuth() {
|
|||
}, []);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
const result = await pb.collection('users').authRefresh();
|
||||
setUser(recordToUser(result.record));
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pb.collection('users').authRefresh();
|
||||
setUser(recordToUser(result.record));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Auth refresh failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestVerification = useCallback(async (email: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('users').requestVerification(email);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Verification request failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
|
|
@ -132,6 +158,7 @@ export function useAuth() {
|
|||
loginWithOAuth,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
requestVerification,
|
||||
refreshAuth,
|
||||
clearError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import type {
|
||||
HexagonData,
|
||||
|
|
@ -14,9 +14,8 @@ import type {
|
|||
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
import {
|
||||
TRANSPORT_MODES,
|
||||
type TransportMode,
|
||||
type TravelTimeEntries,
|
||||
type TravelTimeEntry,
|
||||
travelFieldKey,
|
||||
} from './useTravelTime';
|
||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||
|
||||
|
|
@ -46,8 +45,8 @@ interface UseDeckLayersProps {
|
|||
theme: 'light' | 'dark';
|
||||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||
bounds?: Bounds | null;
|
||||
travelTimeEntries?: TravelTimeEntries;
|
||||
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
travelTimeColorRanges?: Map<number, [number, number]>;
|
||||
}
|
||||
|
||||
export interface PopupInfo {
|
||||
|
|
@ -58,15 +57,15 @@ export interface PopupInfo {
|
|||
id: string;
|
||||
}
|
||||
|
||||
/** Find the primary travel mode: first mode (in canonical order) with a destination and color range. */
|
||||
function getPrimaryTravelMode(
|
||||
entries: TravelTimeEntries,
|
||||
colorRanges: Partial<Record<TransportMode, [number, number]>>
|
||||
): TransportMode | null {
|
||||
for (const mode of TRANSPORT_MODES) {
|
||||
if (entries[mode]?.destination && colorRanges[mode]) return mode;
|
||||
/** Find the primary travel time entry: first entry with a slug and color range. */
|
||||
function getPrimaryTravelIndex(
|
||||
entries: TravelTimeEntry[],
|
||||
colorRanges: Map<number, [number, number]>
|
||||
): number {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (entries[i].slug && colorRanges.has(i)) return i;
|
||||
}
|
||||
return null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function useDeckLayers({
|
||||
|
|
@ -85,8 +84,8 @@ export function useDeckLayers({
|
|||
theme,
|
||||
selectedPostcodeGeometry,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries = {},
|
||||
travelTimeColorRanges = {},
|
||||
travelTimeEntries = [],
|
||||
travelTimeColorRanges = new Map(),
|
||||
}: UseDeckLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
|
@ -105,7 +104,7 @@ export function useDeckLayers({
|
|||
const isDark = theme === 'dark';
|
||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
|
||||
// --- Refs for deck.gl accessors (avoid re-creating layers on every change) ---
|
||||
// --- Refs for deck.gl accessors ---
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
viewFeatureRef.current = viewFeature;
|
||||
const colorRangeRef = useRef(colorRange);
|
||||
|
|
@ -128,12 +127,12 @@ export function useDeckLayers({
|
|||
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
|
||||
travelTimeColorRangesRef.current = travelTimeColorRanges;
|
||||
|
||||
const primaryTravelMode = useMemo(
|
||||
() => getPrimaryTravelMode(travelTimeEntries, travelTimeColorRanges),
|
||||
const primaryTravelIndex = useMemo(
|
||||
() => getPrimaryTravelIndex(travelTimeEntries, travelTimeColorRanges),
|
||||
[travelTimeEntries, travelTimeColorRanges]
|
||||
);
|
||||
const primaryTravelModeRef = useRef(primaryTravelMode);
|
||||
primaryTravelModeRef.current = primaryTravelMode;
|
||||
const primaryTravelIndexRef = useRef(primaryTravelIndex);
|
||||
primaryTravelIndexRef.current = primaryTravelIndex;
|
||||
|
||||
const colorFeatureMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
|
|
@ -260,13 +259,12 @@ export function useDeckLayers({
|
|||
}, []);
|
||||
|
||||
// --- Color triggers ---
|
||||
// Build travel time trigger from all entries
|
||||
const ttTrigger = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
for (const mode of TRANSPORT_MODES) {
|
||||
const entry = travelTimeEntries[mode];
|
||||
const cr = travelTimeColorRanges[mode];
|
||||
parts.push(`${mode}:${entry?.destination?.[0]}|${entry?.destination?.[1]}|${cr?.[0]}|${cr?.[1]}|${entry?.timeRange?.[0]}|${entry?.timeRange?.[1]}`);
|
||||
for (let i = 0; i < travelTimeEntries.length; i++) {
|
||||
const entry = travelTimeEntries[i];
|
||||
const cr = travelTimeColorRanges.get(i);
|
||||
parts.push(`${i}:${entry.slug}|${cr?.[0]}|${cr?.[1]}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
|
||||
}
|
||||
return parts.join(';');
|
||||
}, [travelTimeEntries, travelTimeColorRanges]);
|
||||
|
|
@ -283,23 +281,26 @@ export function useDeckLayers({
|
|||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
const dark = isDarkRef.current;
|
||||
const pm = primaryTravelModeRef.current;
|
||||
const pti = primaryTravelIndexRef.current;
|
||||
const entries = travelTimeEntriesRef.current;
|
||||
const colorRanges = travelTimeColorRangesRef.current;
|
||||
|
||||
// Travel time coloring: primary mode colors, others dim-filter
|
||||
if (pm) {
|
||||
const ttVal = d[`travel_time_${pm}`];
|
||||
const ttClr = colorRanges[pm];
|
||||
// Travel time coloring: primary entry colors, others dim-filter
|
||||
if (pti >= 0) {
|
||||
const primaryEntry = entries[pti];
|
||||
const fieldKey = travelFieldKey(primaryEntry);
|
||||
const ttVal = d[`avg_${fieldKey}`];
|
||||
const ttClr = colorRanges.get(pti);
|
||||
if (ttVal == null) {
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
}
|
||||
|
||||
// Check all modes with time ranges as filters (including primary)
|
||||
for (const mode of TRANSPORT_MODES) {
|
||||
const entry = entries[mode];
|
||||
if (!entry?.timeRange) continue;
|
||||
const modeVal = d[`travel_time_${mode}`];
|
||||
// Check all entries with time ranges as filters
|
||||
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];
|
||||
}
|
||||
|
|
@ -504,7 +505,7 @@ export function useDeckLayers({
|
|||
[pois, stablePoiHover]
|
||||
);
|
||||
|
||||
// Marching ants highlight layer for selected postcode (click or search)
|
||||
// Marching ants highlight layer for selected postcode
|
||||
const marchingAntsLayer = useMemo(() => {
|
||||
if (!selectedPostcodeGeometry) return null;
|
||||
return new GeoJsonLayer({
|
||||
|
|
@ -527,42 +528,12 @@ export function useDeckLayers({
|
|||
});
|
||||
}, [selectedPostcodeGeometry, marchTime]);
|
||||
|
||||
// Destination markers: one red dot per mode with a destination
|
||||
const destinationMarkerData = useMemo(() => {
|
||||
const points: { position: [number, number] }[] = [];
|
||||
for (const mode of TRANSPORT_MODES) {
|
||||
const entry = travelTimeEntries[mode];
|
||||
if (entry?.destination) {
|
||||
points.push({ position: [entry.destination[1], entry.destination[0]] });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}, [travelTimeEntries]);
|
||||
|
||||
const destinationMarkerLayer = useMemo(() => {
|
||||
if (destinationMarkerData.length === 0) return null;
|
||||
return new ScatterplotLayer({
|
||||
id: 'travel-time-destinations',
|
||||
data: destinationMarkerData,
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 8,
|
||||
getFillColor: [239, 68, 68, 220],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels' as const,
|
||||
radiusUnits: 'pixels' as const,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
});
|
||||
}, [destinationMarkerData]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const baseLayers: any[] = usePostcodeView
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
: [hexLayer, poiLayer];
|
||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
|
||||
return baseLayers;
|
||||
}, [
|
||||
usePostcodeView,
|
||||
|
|
@ -571,7 +542,6 @@ export function useDeckLayers({
|
|||
postcodeLabelsLayer,
|
||||
poiLayer,
|
||||
marchingAntsLayer,
|
||||
destinationMarkerLayer,
|
||||
]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
|
|
@ -590,6 +560,6 @@ export function useDeckLayers({
|
|||
colorFeatureMeta,
|
||||
handleMouseLeave,
|
||||
hoveredPostcode,
|
||||
primaryTravelMode,
|
||||
primaryTravelIndex,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
37
frontend/src/hooks/useLicense.ts
Normal file
37
frontend/src/hooks/useLicense.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { apiUrl, authHeaders, assertOk } from '../lib/api';
|
||||
|
||||
export function useLicense() {
|
||||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startCheckout = useCallback(async (referralCode?: string) => {
|
||||
setCheckingOut(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: Record<string, string> = {};
|
||||
if (referralCode) body.referral_code = referralCode;
|
||||
|
||||
const res = await fetch(apiUrl('checkout'), {
|
||||
method: 'POST',
|
||||
...authHeaders({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
});
|
||||
assertOk(res, 'Checkout');
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Checkout failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setCheckingOut(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { startCheckout, checkingOut, error };
|
||||
}
|
||||
|
|
@ -10,9 +10,9 @@ export function looksLikePostcode(s: string) {
|
|||
|
||||
export type SearchResult =
|
||||
| { type: 'postcode'; label: string }
|
||||
| { type: 'place'; name: string; place_type: string; lat: number; lon: number; city?: string };
|
||||
| { type: 'place'; name: string; slug: string; place_type: string; lat: number; lon: number; city?: string };
|
||||
|
||||
export function useLocationSearch() {
|
||||
export function useLocationSearch(mode?: string) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
|
@ -34,7 +34,7 @@ export function useLocationSearch() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (looksLikePostcode(trimmed)) {
|
||||
if (!mode && looksLikePostcode(trimmed)) {
|
||||
setResults([{ type: 'postcode', label: trimmed.toUpperCase() }]);
|
||||
setOpen(true);
|
||||
return;
|
||||
|
|
@ -51,6 +51,7 @@ export function useLocationSearch() {
|
|||
abortRef.current = controller;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmed, limit: '7' });
|
||||
if (mode) params.set('mode', mode);
|
||||
const res = await fetch(
|
||||
`/api/places?${params}`,
|
||||
authHeaders({ signal: controller.signal }),
|
||||
|
|
@ -59,7 +60,12 @@ export function useLocationSearch() {
|
|||
const json: { places: PlaceResult[] } = await res.json();
|
||||
const placeResults: SearchResult[] = json.places.map((p) => ({
|
||||
type: 'place' as const,
|
||||
...p,
|
||||
name: p.name,
|
||||
slug: p.slug,
|
||||
place_type: p.place_type,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
city: p.city,
|
||||
}));
|
||||
setResults(placeResults);
|
||||
setOpen(placeResults.length > 0);
|
||||
|
|
@ -67,7 +73,7 @@ export function useLocationSearch() {
|
|||
logNonAbortError('places search', err);
|
||||
}
|
||||
}, 200);
|
||||
}, []);
|
||||
}, [mode]);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import type {
|
|||
ViewChangeParams,
|
||||
ApiResponse,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
|
||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { TRANSPORT_MODES, type TransportMode, type TravelTimeEntries } from './useTravelTime';
|
||||
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
|
||||
|
||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
|
|
@ -33,7 +33,7 @@ interface UseMapDataOptions {
|
|||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
dragData: HexagonData[] | null;
|
||||
travelTimeEntries: TravelTimeEntries;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
}
|
||||
|
||||
export function useMapData({
|
||||
|
|
@ -56,6 +56,8 @@ export function useMapData({
|
|||
longitude: number;
|
||||
zoom: number;
|
||||
} | null>(null);
|
||||
const [licenseRequired, setLicenseRequired] = useState(false);
|
||||
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -69,13 +71,16 @@ export function useMapData({
|
|||
);
|
||||
|
||||
// Build the travel param string from entries with destinations
|
||||
// Format: mode:slug|mode:slug or mode:slug:min:max|mode:slug
|
||||
const travelParam = useMemo((): string => {
|
||||
const segments: string[] = [];
|
||||
for (const mode of TRANSPORT_MODES) {
|
||||
const entry = travelTimeEntries[mode];
|
||||
if (entry?.destination) {
|
||||
segments.push(`${entry.destination[0]},${entry.destination[1]},${mode}`);
|
||||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let seg = `${entry.mode}:${entry.slug}`;
|
||||
if (entry.timeRange) {
|
||||
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
}
|
||||
segments.push(seg);
|
||||
}
|
||||
return segments.join('|');
|
||||
}, [travelTimeEntries]);
|
||||
|
|
@ -109,7 +114,16 @@ export function useMapData({
|
|||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
);
|
||||
if (res.status === 403) {
|
||||
const errBody = await res.json();
|
||||
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||||
setLicenseRequired(true);
|
||||
setFreeZone(errBody.free_zone);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assertOk(res, 'postcodes');
|
||||
setLicenseRequired(false);
|
||||
const json: { features: PostcodeFeature[] } = await res.json();
|
||||
setPostcodeData(json.features);
|
||||
setRawData([]);
|
||||
|
|
@ -129,13 +143,22 @@ export function useMapData({
|
|||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
);
|
||||
if (res.status === 403) {
|
||||
const errBody = await res.json();
|
||||
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||||
setLicenseRequired(true);
|
||||
setFreeZone(errBody.free_zone);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assertOk(res, 'hexagons');
|
||||
setLicenseRequired(false);
|
||||
const json: ApiResponse = await res.json();
|
||||
setRawData(json.features);
|
||||
setPostcodeData([]);
|
||||
}
|
||||
} catch (err) {
|
||||
logNonAbortError('Failed to fetch data', err);
|
||||
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -151,7 +174,6 @@ export function useMapData({
|
|||
const data = dragData ?? rawData;
|
||||
|
||||
// Compute p5/p95 from visible data for the viewed feature
|
||||
// Only considers hexagons/postcodes whose center falls within the viewport bounds
|
||||
const dataRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
|
|
@ -207,13 +229,13 @@ export function useMapData({
|
|||
return null;
|
||||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||||
|
||||
// Color ranges for travel time per mode (computed from response data)
|
||||
const travelTimeColorRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
|
||||
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
|
||||
for (const mode of TRANSPORT_MODES) {
|
||||
const entry = travelTimeEntries[mode];
|
||||
if (!entry?.destination) continue;
|
||||
const fieldName = `travel_time_${mode}`;
|
||||
// Color ranges for travel time per entry (computed from response data)
|
||||
const travelTimeColorRanges = useMemo((): Map<number, [number, number]> => {
|
||||
const ranges = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < travelTimeEntries.length; i++) {
|
||||
const entry = travelTimeEntries[i];
|
||||
if (!entry.slug) continue;
|
||||
const fieldName = `avg_${travelFieldKey(entry)}`;
|
||||
const vals: number[] = [];
|
||||
for (const item of data) {
|
||||
if (bounds) {
|
||||
|
|
@ -226,10 +248,10 @@ export function useMapData({
|
|||
}
|
||||
if (vals.length === 0) continue;
|
||||
vals.sort((a, b) => a - b);
|
||||
ranges[mode] = [
|
||||
ranges.set(i, [
|
||||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||
];
|
||||
]);
|
||||
}
|
||||
return ranges;
|
||||
}, [travelTimeEntries, data, bounds]);
|
||||
|
|
@ -276,5 +298,7 @@ export function useMapData({
|
|||
travelTimeColorRanges,
|
||||
handleViewChange,
|
||||
setInitialView,
|
||||
licenseRequired,
|
||||
freeZone,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
);
|
||||
if (!res.ok) throw new Error(`POIs fetch failed: HTTP ${res.status}`);
|
||||
const json: POIResponse = await res.json();
|
||||
setPois(json.pois || []);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -12,71 +12,73 @@ export const MODE_LABELS: Record<TransportMode, string> = {
|
|||
};
|
||||
|
||||
export interface TravelTimeEntry {
|
||||
destination: [number, number] | null; // [lat, lon]
|
||||
destinationLabel: string;
|
||||
mode: TransportMode;
|
||||
slug: string;
|
||||
label: string;
|
||||
timeRange: [number, number] | null;
|
||||
}
|
||||
|
||||
export type TravelTimeEntries = Partial<Record<TransportMode, TravelTimeEntry>>;
|
||||
/** Unique key for a travel time entry */
|
||||
export function travelEntryKey(entry: TravelTimeEntry): string {
|
||||
return `${entry.mode}:${entry.slug}`;
|
||||
}
|
||||
|
||||
/** Field key matching the backend response: tt_{mode}_{slug} */
|
||||
export function travelFieldKey(entry: TravelTimeEntry): string {
|
||||
return `tt_${entry.mode}_${entry.slug}`;
|
||||
}
|
||||
|
||||
export interface TravelTimeInitial {
|
||||
entries?: TravelTimeEntries;
|
||||
entries?: TravelTimeEntry[];
|
||||
}
|
||||
|
||||
export function useTravelTime(initial?: TravelTimeInitial) {
|
||||
const [entries, setEntries] = useState<TravelTimeEntries>(initial?.entries ?? {});
|
||||
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
|
||||
|
||||
const activeModes = useMemo(
|
||||
() => TRANSPORT_MODES.filter((m) => m in entries),
|
||||
[entries]
|
||||
);
|
||||
|
||||
const modesWithDestination = useMemo(
|
||||
() => TRANSPORT_MODES.filter((m) => entries[m]?.destination != null),
|
||||
[entries]
|
||||
);
|
||||
|
||||
const handleEnableMode = useCallback((mode: TransportMode) => {
|
||||
setEntries((prev) => ({
|
||||
const handleAddEntry = useCallback((mode: TransportMode) => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
[mode]: { destination: null, destinationLabel: '', timeRange: null },
|
||||
}));
|
||||
{ mode, slug: '', label: '', timeRange: null },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const handleDisableMode = useCallback((mode: TransportMode) => {
|
||||
setEntries((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[mode];
|
||||
return next;
|
||||
});
|
||||
const handleRemoveEntry = useCallback((index: number) => {
|
||||
setEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleSetDestination = useCallback(
|
||||
(mode: TransportMode, lat: number, lon: number, label: string) => {
|
||||
setEntries((prev) => ({
|
||||
...prev,
|
||||
[mode]: { ...prev[mode], destination: [lat, lon] as [number, number], destinationLabel: label },
|
||||
}));
|
||||
(index: number, slug: string, label: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: null } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTimeRangeChange = useCallback(
|
||||
(mode: TransportMode, range: [number, number]) => {
|
||||
setEntries((prev) => ({
|
||||
...prev,
|
||||
[mode]: { ...prev[mode], timeRange: range },
|
||||
}));
|
||||
(index: number, range: [number, number]) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, timeRange: range } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Entries that have a destination selected (slug is set) */
|
||||
const activeEntries = useMemo(
|
||||
() => entries.filter((e) => e.slug !== ''),
|
||||
[entries]
|
||||
);
|
||||
|
||||
return {
|
||||
entries,
|
||||
activeModes,
|
||||
modesWithDestination,
|
||||
handleEnableMode,
|
||||
handleDisableMode,
|
||||
activeEntries,
|
||||
handleAddEntry,
|
||||
handleRemoveEntry,
|
||||
handleSetDestination,
|
||||
handleTimeRangeChange,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { stateToParams } from '../lib/url-state';
|
||||
import type { TravelTimeEntries } from './useTravelTime';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
const URL_DEBOUNCE_MS = 300;
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ export function useUrlSync(
|
|||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'properties' | 'area',
|
||||
travelTimeEntries?: TravelTimeEntries
|
||||
travelTimeEntries?: TravelTimeEntry[]
|
||||
) {
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue