vibes
This commit is contained in:
parent
80c093b7ba
commit
f72c43a9fa
101 changed files with 2168 additions and 1177 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { FeatureFilters } from '../types';
|
||||
import type { TransportMode, TravelTimeEntry } from './useTravelTime';
|
||||
import type { TransportMode } from './useTravelTime';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||
|
||||
export interface AiTravelTimeFilter {
|
||||
|
|
@ -37,10 +37,7 @@ interface UseAiFiltersResult {
|
|||
}
|
||||
|
||||
/** Build a human-readable summary of the AI result. */
|
||||
function buildSummary(
|
||||
filters: FeatureFilters,
|
||||
travelTimeFilters: AiTravelTimeFilter[]
|
||||
): string {
|
||||
function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFilter[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useCollapsibleGroups(): [Set<string>, (name: string) => void, (name: string) => void] {
|
||||
export function useCollapsibleGroups(): [
|
||||
Set<string>,
|
||||
(name: string) => void,
|
||||
(name: string) => void,
|
||||
] {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = useCallback((name: string) => {
|
||||
|
|
|
|||
|
|
@ -24,13 +24,9 @@ import {
|
|||
POI_CLUSTER_MAX_ZOOM,
|
||||
} from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
import {
|
||||
type TravelTimeEntry,
|
||||
travelFieldKey,
|
||||
} from './useTravelTime';
|
||||
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
|
||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||
|
||||
|
||||
interface UseDeckLayersProps {
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeFeature[];
|
||||
|
|
@ -314,8 +310,17 @@ export function useDeckLayers({
|
|||
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];
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +334,12 @@ export function useDeckLayers({
|
|||
if (vf.startsWith('tt_')) {
|
||||
const ttVal = d[`avg_${vf}`];
|
||||
if (ttVal == null) {
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
return getFeatureFillColor(
|
||||
ttVal as number,
|
||||
|
|
@ -423,8 +433,17 @@ export function useDeckLayers({
|
|||
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];
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -438,7 +457,12 @@ export function useDeckLayers({
|
|||
if (vf.startsWith('tt_')) {
|
||||
const ttVal = d[`avg_${vf}`];
|
||||
if (ttVal == null) {
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
return getFeatureFillColor(
|
||||
ttVal as number,
|
||||
|
|
@ -673,8 +697,7 @@ export function useDeckLayers({
|
|||
id: 'poi-cluster-text',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) =>
|
||||
d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count),
|
||||
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 255],
|
||||
fontWeight: 700,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { useCallback, useLayoutEffect, useState } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
export function useDropdownPosition(
|
||||
anchorRef: React.RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
) {
|
||||
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,12 @@ interface UseHexagonSelectionOptions {
|
|||
journeyDest?: JourneyDest | null;
|
||||
}
|
||||
|
||||
export function useHexagonSelection({ filters, features, resolution, journeyDest }: UseHexagonSelectionOptions) {
|
||||
export function useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
resolution,
|
||||
journeyDest,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||
|
|
@ -39,8 +44,9 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest
|
|||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
||||
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] =
|
||||
useState<PostcodeGeometry | null>(null);
|
||||
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
||||
|
|
@ -204,7 +210,13 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest
|
|||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}
|
||||
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties, fetchPostcodeProperties]);
|
||||
}, [
|
||||
selectedHexagon,
|
||||
properties.length,
|
||||
loadingProperties,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
]);
|
||||
|
||||
const handleLoadMoreProperties = useCallback(() => {
|
||||
if (!selectedHexagon) return;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@ function normalizePostcode(s: string): string {
|
|||
|
||||
export type SearchResult =
|
||||
| { type: 'postcode'; label: string }
|
||||
| { type: 'place'; name: string; slug: 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(mode?: string) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
|
@ -29,60 +37,63 @@ export function useLocationSearch(mode?: string) {
|
|||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setQuery(value);
|
||||
setActiveIndex(-1);
|
||||
const handleInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setQuery(value);
|
||||
setActiveIndex(-1);
|
||||
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mode && looksLikePostcode(trimmed)) {
|
||||
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
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 }),
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const json: { places: PlaceResult[] } = await res.json();
|
||||
const placeResults: SearchResult[] = json.places.map((p) => ({
|
||||
type: 'place' as const,
|
||||
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);
|
||||
} catch (err) {
|
||||
logNonAbortError('places search', err);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
}, 200);
|
||||
}, [mode]);
|
||||
|
||||
if (!mode && looksLikePostcode(trimmed)) {
|
||||
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
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 })
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const json: { places: PlaceResult[] } = await res.json();
|
||||
const placeResults: SearchResult[] = json.places.map((p) => ({
|
||||
type: 'place' as const,
|
||||
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);
|
||||
} catch (err) {
|
||||
logNonAbortError('places search', err);
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
[mode]
|
||||
);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
|
|
@ -112,7 +123,7 @@ export function useLocationSearch(mode?: string) {
|
|||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[results, activeIndex, query],
|
||||
[results, activeIndex, query]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
|
|
|
|||
|
|
@ -8,7 +8,14 @@ import type {
|
|||
ViewChangeParams,
|
||||
ApiResponse,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } 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 { type TravelTimeEntry } from './useTravelTime';
|
||||
|
|
@ -243,8 +250,11 @@ export function useMapData({
|
|||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
|
||||
|
||||
// Use drag data when it matches the current view feature, otherwise fall back to rawData
|
||||
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
|
||||
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
|
||||
const data =
|
||||
(viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
|
||||
const effectivePostcodeData =
|
||||
(viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ??
|
||||
postcodeData;
|
||||
|
||||
// Compute p5/p95 from committed data for the viewed feature.
|
||||
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ export function useSavedProperties(userId: string | null) {
|
|||
const raw = r as Record<string, unknown>;
|
||||
let data: SavedPropertyData = {};
|
||||
try {
|
||||
data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {};
|
||||
data =
|
||||
typeof raw.data === 'string'
|
||||
? JSON.parse(raw.data)
|
||||
: (raw.data as SavedPropertyData) || {};
|
||||
} catch {
|
||||
// Invalid JSON — use empty data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ export function useTravelDestinations(mode: TransportMode) {
|
|||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
fetch(
|
||||
`/api/travel-destinations?mode=${mode}`,
|
||||
authHeaders({ signal: controller.signal }),
|
||||
)
|
||||
fetch(`/api/travel-destinations?mode=${mode}`, authHeaders({ signal: controller.signal }))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function useTravelModes() {
|
|||
})
|
||||
.then((data: { modes: TravelModeInfo[] }) => {
|
||||
const modes = new Set<TransportMode>(
|
||||
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode),
|
||||
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode)
|
||||
);
|
||||
setAvailableModes(modes);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,58 +40,39 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
|
||||
|
||||
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 }]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveEntry = useCallback((index: number) => {
|
||||
setEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleTimeRangeChange = useCallback(
|
||||
(index: number, range: [number, number]) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, timeRange: range } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleBest = useCallback(
|
||||
(index: number) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, useBest: !entry.useBest } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleToggleBest = useCallback((index: number) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
||||
setEntries(newEntries);
|
||||
}, []);
|
||||
|
||||
/** Entries that have a destination selected (slug is set) */
|
||||
const activeEntries = useMemo(
|
||||
() => entries.filter((e) => e.slug !== ''),
|
||||
[entries]
|
||||
);
|
||||
const activeEntries = useMemo(() => entries.filter((e) => e.slug !== ''), [entries]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
|
|
|
|||
|
|
@ -32,8 +32,7 @@ const STEPS: Step[] = [
|
|||
{
|
||||
target: '[data-tutorial="search"]',
|
||||
title: 'Search Locations',
|
||||
content:
|
||||
'Search for a place name or postcode to jump directly to that area on the map.',
|
||||
content: 'Search for a place name or postcode to jump directly to that area on the map.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue