Good changes
This commit is contained in:
parent
80a5a2a774
commit
791bc6976b
24 changed files with 890 additions and 312 deletions
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -17,8 +17,9 @@ All commands use [Task](https://taskfile.dev) runner. Python uses `uv run`. Fron
|
|||
task dev:server # Rust backend on :8001 (cargo run --release)
|
||||
task dev:frontend # Webpack dev server on :3001 (proxies /api to :8001)
|
||||
|
||||
# Data pipeline
|
||||
task prepare # Build wide.parquet from all pre-downloaded sources
|
||||
# Data pipeline (uses Make, not Task — see Makefile.data)
|
||||
make -f Makefile.data prepare # Build properties.parquet (merge + price estimation)
|
||||
make -f Makefile.data merge # Just the merge step (no price estimation)
|
||||
|
||||
# Assets
|
||||
task download:map-assets # Download font glyphs + twemoji PNGs into frontend/public/assets/
|
||||
|
|
@ -55,28 +56,30 @@ uv run pytest pipeline/utils/test_haversine.py -k "test_name" # Single test
|
|||
```
|
||||
Raw sources → [Download scripts] → data/*.parquet
|
||||
→ [Fuzzy join EPC ↔ Price-Paid] → epc_pp.parquet
|
||||
→ [Merge all datasets] → wide.parquet
|
||||
→ [Merge all datasets] → properties.parquet
|
||||
→ [Price estimation] → properties.parquet (augmented with estimated prices)
|
||||
→ [Rust server loads into memory + precomputes H3 + spatial grid]
|
||||
→ [Frontend renders deck.gl H3HexagonLayer over MapLibre GL]
|
||||
```
|
||||
|
||||
### Data Pipeline (`pipeline/`)
|
||||
|
||||
Python + Polars. Two phases:
|
||||
Python + Polars. Orchestrated by `Makefile.data` (Make DAG with sentinel files like `.merge_done`, `.prices_done`). Two phases:
|
||||
|
||||
1. **Download** (`pipeline/download/`) — Each script fetches one raw dataset into `data/`
|
||||
2. **Transform** (`pipeline/transform/`) — Joins and derives features:
|
||||
- `join_epc_pp.py` — Fuzzy-joins EPC ↔ price-paid by address within postcode buckets
|
||||
- `merge.py` — **Main pipeline**: joins all datasets → `wide.parquet` with human-readable column names
|
||||
- `merge.py` — **Main pipeline**: joins all datasets → `properties.parquet` with human-readable column names
|
||||
- `price_estimation/` — Post-merge step: adds "Estimated current price" and "Est. price per sqm" columns to `properties.parquet`. Uses repeat-sales price index + kNN spatial blending. Requires `price_index.parquet` (built by `price_estimation/index.py`). Run via `make -f Makefile.data prepare` (the `merge` target alone skips this).
|
||||
- `transform_poi.py` — Filters POIs, maps to friendly names + emoji (exhaustive category validation)
|
||||
- `poi_proximity.py` — Counts POIs within 2km per postcode using 0.05° spatial grid
|
||||
- `crime.py` — Aggregates crime CSVs into yearly averages by LSOA
|
||||
|
||||
**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `wide.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames:
|
||||
**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `properties.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames:
|
||||
- `pp_address` → `Address per Property Register`
|
||||
- `postcode` → `Postcode`
|
||||
- `latest_price` → `Last known price`
|
||||
- `duration` → `Leashold/Freehold`
|
||||
- `duration` → `Leasehold/Freehold`
|
||||
- `total_floor_area` → `Total floor area (sqm)`
|
||||
- `current_energy_rating` → `Current energy rating`
|
||||
|
||||
|
|
@ -321,7 +324,7 @@ Follow these conventions in all Rust code:
|
|||
- **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage
|
||||
- **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score
|
||||
- **Filter parsing is strict**: `parse_filters()` returns `Result` — malformed entries, unknown feature names, and unparseable numbers all return 400 Bad Request. No silent skipping of invalid filters.
|
||||
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. Required parquet columns must exist at startup.
|
||||
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in at least one data source — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server. Polars `diagonal: true` concat fills nulls for features that exist in some but not all sources (e.g. "Listing date" from listings only).
|
||||
- **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option<String>` — returns 503 if travel time requested without R5 configured.
|
||||
- **Filter bounds format**: `south,west,north,east` (not standard bbox order)
|
||||
- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`.
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ def transform_property(
|
|||
"lat": lat,
|
||||
"Postcode": postcode,
|
||||
"Address per Property Register": address,
|
||||
"Leashold/Freehold": None, # not available from home.co.uk
|
||||
"Leasehold/Freehold": None, # not available from home.co.uk
|
||||
"Property type": map_property_type(listing_type),
|
||||
"Property sub-type": listing_type or "Unknown",
|
||||
"price": int(price),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
HexagonStatsResponse,
|
||||
PostcodeFeature,
|
||||
} from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
|
|
@ -16,14 +17,14 @@ import StackedBarChart from './StackedBarChart';
|
|||
import StackedEnumChart from './StackedEnumChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from '../ui/icons';
|
||||
import { InfoIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import StreetViewEmbed from './StreetViewEmbed';
|
||||
import HistogramLegend from './HistogramLegend';
|
||||
import JourneyInstructions from './JourneyInstructions';
|
||||
|
||||
interface AreaPaneProps {
|
||||
stats: HexagonStatsResponse | null;
|
||||
|
|
@ -33,10 +34,10 @@ interface AreaPaneProps {
|
|||
isPostcode?: boolean;
|
||||
postcodeData?: PostcodeFeature | null;
|
||||
onViewProperties: () => void;
|
||||
onClose: () => void;
|
||||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
|
|
@ -47,10 +48,10 @@ export default function AreaPane({
|
|||
isPostcode = false,
|
||||
postcodeData,
|
||||
onViewProperties,
|
||||
onClose,
|
||||
hexagonLocation,
|
||||
filters,
|
||||
onNavigateToSource,
|
||||
travelTimeEntries,
|
||||
}: AreaPaneProps) {
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
|
|
@ -84,16 +85,10 @@ export default function AreaPane({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div className="absolute top-2 right-2 z-20">
|
||||
<IconButton onClick={onClose} title="Close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : 'Area Statistics'}
|
||||
|
|
@ -129,6 +124,16 @@ export default function AreaPane({
|
|||
{hexagonLocation && stats && (
|
||||
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
||||
)}
|
||||
{(() => {
|
||||
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
|
||||
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
|
||||
<JourneyInstructions
|
||||
postcode={journeyPostcode}
|
||||
entries={travelTimeEntries}
|
||||
label={!isPostcode ? journeyPostcode : undefined}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
|
|
@ -260,7 +265,7 @@ export default function AreaPane({
|
|||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={formatFilterValue}
|
||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
|
|
@ -268,7 +273,7 @@ export default function AreaPane({
|
|||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={formatFilterValue}
|
||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -386,6 +391,6 @@ export default function AreaPane({
|
|||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,19 @@
|
|||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import {
|
||||
buildPropertySearchUrls,
|
||||
H3_RADIUS_MILES,
|
||||
type HexagonLocation,
|
||||
} from '../../lib/external-search';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
import outcodeIds from '../../lib/rightmove-outcodes.json';
|
||||
|
||||
function useRightmoveLocationId(postcode: string | undefined): string | undefined {
|
||||
const [locationId, setLocationId] = useState<string | undefined>();
|
||||
const rightmoveOutcodes = outcodeIds as Record<string, string>;
|
||||
|
||||
useEffect(() => {
|
||||
if (!postcode) {
|
||||
setLocationId(undefined);
|
||||
return;
|
||||
}
|
||||
setLocationId(undefined);
|
||||
const controller = new AbortController();
|
||||
fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.location_identifier) setLocationId(data.location_identifier);
|
||||
})
|
||||
.catch((err) => logNonAbortError('rightmove-location', err));
|
||||
return () => controller.abort();
|
||||
}, [postcode]);
|
||||
|
||||
return locationId;
|
||||
function getRightmoveLocationId(postcode: string | undefined): string | undefined {
|
||||
if (!postcode) return undefined;
|
||||
const outcode = postcode.trim().split(/\s+/)[0].toUpperCase();
|
||||
const id = rightmoveOutcodes[outcode];
|
||||
return id ? `OUTCODE^${id}` : undefined;
|
||||
}
|
||||
|
||||
export default function ExternalSearchLinks({
|
||||
|
|
@ -38,7 +23,7 @@ export default function ExternalSearchLinks({
|
|||
location: HexagonLocation;
|
||||
filters: FeatureFilters;
|
||||
}) {
|
||||
const rightmoveLocationId = useRightmoveLocationId(location.postcode);
|
||||
const rightmoveLocationId = getRightmoveLocationId(location.postcode);
|
||||
const urls = useMemo(
|
||||
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
|
||||
[location, filters, rightmoveLocationId]
|
||||
|
|
@ -69,7 +54,7 @@ export default function ExternalSearchLinks({
|
|||
Rightmove
|
||||
</a>
|
||||
) : (
|
||||
<span className={disabledClass} title="Loading...">
|
||||
<span className={disabledClass} title="Outcode not recognised">
|
||||
Rightmove
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ function SliderLabels({
|
|||
displayValues,
|
||||
isAtMin,
|
||||
isAtMax,
|
||||
raw,
|
||||
}: {
|
||||
min: number;
|
||||
max: number;
|
||||
|
|
@ -39,6 +40,7 @@ function SliderLabels({
|
|||
displayValues?: [number, number];
|
||||
isAtMin?: boolean;
|
||||
isAtMax?: boolean;
|
||||
raw?: boolean;
|
||||
}) {
|
||||
const range = max - min || 1;
|
||||
const leftPct = ((value[0] - min) / range) * 100;
|
||||
|
|
@ -50,13 +52,13 @@ function SliderLabels({
|
|||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
{isAtMin ? 'min' : formatFilterValue(labels[0])}
|
||||
{isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
|
||||
</span>
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${rightPct}%` }}
|
||||
>
|
||||
{isAtMax ? 'max' : formatFilterValue(labels[1])}
|
||||
{isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -224,6 +226,15 @@ export default memo(function Filters({
|
|||
[onAddFilter, features, expandGroup]
|
||||
);
|
||||
|
||||
const handleAddTravelTimeAndScroll = useCallback(
|
||||
(mode: TransportMode) => {
|
||||
expandGroup('Travel Time');
|
||||
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
|
||||
onTravelTimeAddEntry(mode);
|
||||
},
|
||||
[onTravelTimeAddEntry, travelTimeEntries.length, expandGroup]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const name = pendingScrollRef.current;
|
||||
if (!name) return;
|
||||
|
|
@ -232,7 +243,7 @@ export default memo(function Filters({
|
|||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, [enabledFeatureList]);
|
||||
}, [enabledFeatureList, travelTimeEntries]);
|
||||
const enabledGroups = useMemo(
|
||||
() => groupFeaturesByCategory(enabledFeatureList),
|
||||
[enabledFeatureList]
|
||||
|
|
@ -311,8 +322,8 @@ export default memo(function Filters({
|
|||
{!collapsedGroups.has('Travel Time') && (
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-7">
|
||||
<TravelTimeCard
|
||||
key={index}
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
|
|
@ -325,6 +336,7 @@ export default memo(function Filters({
|
|||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -460,6 +472,7 @@ export default memo(function Filters({
|
|||
displayValues={scale ? displayValue : undefined}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={feature.raw}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -488,7 +501,7 @@ export default memo(function Filters({
|
|||
openInfoFeature={openInfoFeature}
|
||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={onTravelTimeAddEntry}
|
||||
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
||||
isLicensed={isLicensed}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function HistogramLegend() {
|
|||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Grey bars</span> show the
|
||||
overall distribution across all areas
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
262
frontend/src/components/map/JourneyInstructions.tsx
Normal file
262
frontend/src/components/map/JourneyInstructions.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import type { JourneyLeg } from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
||||
|
||||
interface JourneyInstructionsProps {
|
||||
postcode: string;
|
||||
entries: TravelTimeEntry[];
|
||||
/** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface JourneyData {
|
||||
slug: string;
|
||||
label: string;
|
||||
legs: JourneyLeg[] | null;
|
||||
/** Median (50th percentile) total travel time from R5, including waiting. */
|
||||
minutes: number | null;
|
||||
/** Best-case (5th percentile) total travel time from R5. */
|
||||
bestMinutes: number | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
// Official TfL line colors + other known London transit
|
||||
const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
|
||||
Bakerloo: { color: '#B36305' },
|
||||
Central: { color: '#E32017' },
|
||||
Circle: { color: '#FFD300', darkText: true },
|
||||
District: { color: '#00782A' },
|
||||
'Elizabeth line': { color: '#6950A1' },
|
||||
Elizabeth: { color: '#6950A1' },
|
||||
'Hammersmith & City': { color: '#F3A9BB', darkText: true },
|
||||
'Hammersmith and City': { color: '#F3A9BB', darkText: true },
|
||||
Jubilee: { color: '#A0A5A9', darkText: true },
|
||||
Metropolitan: { color: '#9B0056' },
|
||||
Northern: { color: '#333333' },
|
||||
Piccadilly: { color: '#003688' },
|
||||
Victoria: { color: '#0098D4' },
|
||||
'Waterloo & City': { color: '#95CDBA', darkText: true },
|
||||
'Waterloo and City': { color: '#95CDBA', darkText: true },
|
||||
DLR: { color: '#00A4A7' },
|
||||
'London Overground': { color: '#EE7C0E' },
|
||||
};
|
||||
|
||||
const NON_TUBE_NAMES = new Set(['DLR', 'London Overground', 'Elizabeth line']);
|
||||
|
||||
function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } {
|
||||
const known = ROUTE_COLORS[mode];
|
||||
if (known) {
|
||||
const label = NON_TUBE_NAMES.has(mode) || mode.includes('line') ? mode : `${mode} line`;
|
||||
return { label, color: known.color, darkText: !!known.darkText };
|
||||
}
|
||||
if (/^\d+[A-Za-z]?$/.test(mode.trim())) {
|
||||
return { label: `Bus ${mode}`, color: '#0d9488', darkText: false };
|
||||
}
|
||||
return { label: mode, color: '#6b7280', darkText: false };
|
||||
}
|
||||
|
||||
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
||||
return [...legs]
|
||||
.reverse()
|
||||
.map((leg) => (leg.from && leg.to ? { ...leg, from: leg.to, to: leg.from } : leg));
|
||||
}
|
||||
|
||||
function RouteBadge({ mode }: { mode: string }) {
|
||||
const { label, color, darkText } = getRouteDisplay(mode);
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center text-[10px] font-bold px-1.5 py-px rounded-sm leading-tight tracking-wide"
|
||||
style={{ backgroundColor: color, color: darkText ? '#292524' : '#fff' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
||||
const isAccess = leg.mode === 'walk' || leg.mode === 'bicycle';
|
||||
|
||||
if (isAccess) {
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="flex flex-col items-center w-4 mr-2">
|
||||
<div className="w-2 h-2 rounded-full border-[1.5px] border-warm-400 dark:border-warm-500 shrink-0 mt-1" />
|
||||
{!isLast && (
|
||||
<div className="flex-1 min-h-[4px] border-l border-dashed border-warm-300 dark:border-warm-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 py-0.5 min-w-0">
|
||||
{leg.mode === 'walk' ? (
|
||||
<WalkingIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
) : (
|
||||
<BicycleIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{leg.mode === 'walk' ? 'Walk' : 'Cycle'} · {leg.minutes} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { color } = getRouteDisplay(leg.mode);
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="flex flex-col items-center w-4 mr-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pb-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<RouteBadge mode={leg.mode} />
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} min</span>
|
||||
</div>
|
||||
{leg.from && leg.to && (
|
||||
<div className="text-[11px] text-warm-600 dark:text-warm-300 mt-0.5 truncate">
|
||||
{leg.from} → {leg.to}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) {
|
||||
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
||||
|
||||
// Only transit entries with a destination set
|
||||
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
|
||||
|
||||
useEffect(() => {
|
||||
if (transitEntries.length === 0) {
|
||||
setJourneys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const results: JourneyData[] = transitEntries.map((e) => ({
|
||||
slug: e.slug,
|
||||
label: e.label,
|
||||
legs: null,
|
||||
minutes: null,
|
||||
bestMinutes: null,
|
||||
loading: true,
|
||||
}));
|
||||
setJourneys([...results]);
|
||||
|
||||
transitEntries.forEach((entry, idx) => {
|
||||
const params = new URLSearchParams({
|
||||
postcode,
|
||||
mode: 'transit',
|
||||
slug: entry.slug,
|
||||
});
|
||||
fetch(apiUrl('journey', params), { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(
|
||||
(data: {
|
||||
journey: JourneyLeg[] | null;
|
||||
minutes: number | null;
|
||||
best_minutes: number | null;
|
||||
}) => {
|
||||
setJourneys((prev) =>
|
||||
prev.map((j, i) =>
|
||||
i === idx
|
||||
? {
|
||||
...j,
|
||||
legs: data.journey,
|
||||
minutes: data.minutes,
|
||||
bestMinutes: data.best_minutes,
|
||||
loading: false,
|
||||
}
|
||||
: j
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
.catch((err) => {
|
||||
logNonAbortError('journey', err);
|
||||
setJourneys((prev) =>
|
||||
prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (transitEntries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-3 mt-2 space-y-2">
|
||||
{label && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400">Journeys from {label}</div>
|
||||
)}
|
||||
{journeys.map((j) => {
|
||||
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
||||
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
||||
const totalMin = j.minutes ?? legSum;
|
||||
const waitingMin = j.minutes != null ? Math.max(0, j.minutes - legSum) : null;
|
||||
const bestWaitingMin =
|
||||
j.bestMinutes != null ? Math.max(0, j.bestMinutes - legSum) : null;
|
||||
|
||||
return (
|
||||
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
||||
To {j.label || j.slug}
|
||||
</span>
|
||||
{displayLegs && displayLegs.length > 0 && (
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
{totalMin} min
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{j.loading ? (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Loading...</span>
|
||||
</div>
|
||||
) : displayLegs && displayLegs.length > 0 ? (
|
||||
<div>
|
||||
{displayLegs.map((leg, i) => (
|
||||
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
|
||||
))}
|
||||
{waitingMin != null && waitingMin > 0 && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-warm-200 dark:border-warm-700 flex items-baseline justify-between">
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
Waiting & transfers
|
||||
</span>
|
||||
<span className="text-[11px] text-warm-600 dark:text-warm-300">
|
||||
{waitingMin} min
|
||||
{bestWaitingMin != null && (
|
||||
<span className="text-warm-400 dark:text-warm-500">
|
||||
{' '}
|
||||
(best: {bestWaitingMin === 0 ? '~0' : bestWaitingMin} min)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
No journey data available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -244,6 +244,7 @@ export default memo(function Map({
|
|||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
theme={theme}
|
||||
raw={colorFeatureMeta.raw}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default function MapLegend({
|
|||
theme = 'light',
|
||||
inline = false,
|
||||
suffix,
|
||||
raw,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
|
|
@ -24,26 +25,65 @@ export default function MapLegend({
|
|||
theme?: 'light' | 'dark';
|
||||
inline?: boolean;
|
||||
suffix?: string;
|
||||
raw?: boolean;
|
||||
}) {
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
|
||||
const fmt = raw ? { raw: true } : undefined;
|
||||
|
||||
const rangeMin =
|
||||
mode === 'density' ? (
|
||||
<TickerValue text={formatValue(range[0])} />
|
||||
) : enumValues && enumValues.length > 0 ? (
|
||||
<span>{enumValues[0]}</span>
|
||||
) : (
|
||||
<TickerValue text={formatValue(range[0], fmt) + (suffix || '')} />
|
||||
);
|
||||
|
||||
const rangeMax =
|
||||
mode === 'density' ? (
|
||||
<TickerValue text={formatValue(range[1])} />
|
||||
) : enumValues && enumValues.length > 0 ? (
|
||||
<span>{enumValues[enumValues.length - 1]}</span>
|
||||
) : (
|
||||
<TickerValue text={formatValue(range[1], fmt) + (suffix || '')} />
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
inline
|
||||
? 'bg-white dark:bg-warm-800 dark:text-white p-3 text-xs border-b border-warm-200 dark:border-warm-700'
|
||||
: 'absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]'
|
||||
}
|
||||
<div className="bg-warm-100 dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-3 py-1.5 text-xs border-b border-warm-200 dark:border-warm-700 flex items-center gap-2">
|
||||
<span className="font-semibold text-xs text-warm-800 dark:text-warm-200 truncate">
|
||||
{featureLabel}
|
||||
</span>
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Clear colour view"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
|
||||
{rangeMin}
|
||||
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
|
||||
{rangeMax}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
|
||||
title="Clear color view"
|
||||
title="Clear colour view"
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -51,22 +91,8 @@ export default function MapLegend({
|
|||
</div>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
|
||||
{mode === 'density' ? (
|
||||
<>
|
||||
<TickerValue text={formatValue(range[0])} />
|
||||
<TickerValue text={formatValue(range[1])} />
|
||||
</>
|
||||
) : enumValues && enumValues.length > 0 ? (
|
||||
<>
|
||||
<span>{enumValues[0]}</span>
|
||||
<span>{enumValues[enumValues.length - 1]}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TickerValue text={formatValue(range[0]) + (suffix || '')} />
|
||||
<TickerValue text={formatValue(range[1]) + (suffix || '')} />
|
||||
</>
|
||||
)}
|
||||
{rangeMin}
|
||||
{rangeMax}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -161,10 +161,17 @@ export default function MapPage({
|
|||
travelTimeEntries: travelTime.entries,
|
||||
});
|
||||
|
||||
// First transit destination — used to pick the best central_postcode for journey display
|
||||
const journeyDest = useMemo(() => {
|
||||
const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug);
|
||||
return entry ? { mode: entry.mode, slug: entry.slug } : null;
|
||||
}, [travelTime.entries]);
|
||||
|
||||
const selection = useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
resolution: mapData.resolution,
|
||||
journeyDest,
|
||||
});
|
||||
|
||||
const handleLocationSearchResult = useCallback(
|
||||
|
|
@ -196,6 +203,17 @@ export default function MapPage({
|
|||
selection.setRightPaneTab(initialTab);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Prevent browser back/forward navigation from horizontal trackpad swipes
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => document.removeEventListener('wheel', handleWheel);
|
||||
}, []);
|
||||
|
||||
const { handleHexagonClick } = selection;
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
|
||||
|
|
@ -351,9 +369,9 @@ export default function MapPage({
|
|||
: null
|
||||
}
|
||||
onViewProperties={selection.handleViewPropertiesFromArea}
|
||||
onClose={selection.handleCloseSelection}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
travelTimeEntries={travelTime.activeEntries}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -501,6 +519,7 @@ export default function MapPage({
|
|||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
theme={theme}
|
||||
inline
|
||||
raw={mobileLegendMeta.raw}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
||||
import { DestinationDropdown } from '../ui/DestinationDropdown';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
|
|
@ -13,7 +13,7 @@ import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
|||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||
import { TransitIcon } from '../ui/icons/TransitIcon';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
||||
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
|
|
@ -51,29 +51,14 @@ export function TravelTimeCard({
|
|||
onToggleBest,
|
||||
onRemove,
|
||||
}: TravelTimeCardProps) {
|
||||
const search = useLocationSearch(mode);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
||||
const [showBestInfo, setShowBestInfo] = useState(false);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
search.close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [search.close]);
|
||||
|
||||
const selectResult = useCallback(
|
||||
(result: SearchResult) => {
|
||||
if (result.type === 'place') {
|
||||
onSetDestination(result.slug, result.name);
|
||||
search.clear();
|
||||
}
|
||||
const handleDestinationSelect = useCallback(
|
||||
(selectedSlug: string, selectedLabel: string) => {
|
||||
onSetDestination(selectedSlug, selectedLabel);
|
||||
},
|
||||
[onSetDestination, search.clear],
|
||||
[onSetDestination],
|
||||
);
|
||||
|
||||
const sliderMin = 0;
|
||||
|
|
@ -120,16 +105,12 @@ export function TravelTimeCard({
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef} className="relative">
|
||||
<PlaceSearchInput
|
||||
search={search}
|
||||
onSelect={selectResult}
|
||||
placeholder="Search stations..."
|
||||
size="xs"
|
||||
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
|
||||
portal
|
||||
<DestinationDropdown
|
||||
destinations={destinations}
|
||||
loading={destinationsLoading}
|
||||
onSelect={handleDestinationSelect}
|
||||
placeholder="Select destination..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||
|
|
|
|||
222
frontend/src/components/ui/DestinationDropdown.tsx
Normal file
222
frontend/src/components/ui/DestinationDropdown.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Destination } from '../../hooks/useTravelDestinations';
|
||||
import { MapPinIcon } from './icons/MapPinIcon';
|
||||
import { ChevronIcon } from './icons/ChevronIcon';
|
||||
|
||||
interface DestinationDropdownProps {
|
||||
destinations: Destination[];
|
||||
loading: boolean;
|
||||
onSelect: (slug: string, label: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function DestinationDropdown({
|
||||
destinations,
|
||||
loading,
|
||||
onSelect,
|
||||
placeholder = 'Select destination...',
|
||||
}: DestinationDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return destinations;
|
||||
const lower = filter.toLowerCase();
|
||||
return destinations.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(lower) ||
|
||||
d.city?.toLowerCase().includes(lower),
|
||||
);
|
||||
}, [destinations, filter]);
|
||||
|
||||
// Position the dropdown portal
|
||||
const updatePos = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePos();
|
||||
window.addEventListener('scroll', updatePos, true);
|
||||
window.addEventListener('resize', updatePos);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePos, true);
|
||||
window.removeEventListener('resize', updatePos);
|
||||
};
|
||||
}, [open, updatePos]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0 || !listRef.current) return;
|
||||
const item = listRef.current.children[activeIndex] as HTMLElement;
|
||||
item?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(dest: Destination) => {
|
||||
onSelect(dest.slug, dest.name);
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
setActiveIndex(-1);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) =>
|
||||
prev < filtered.length - 1 ? prev + 1 : prev,
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < filtered.length) {
|
||||
handleSelect(filtered[activeIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
}
|
||||
},
|
||||
[filtered, activeIndex, handleSelect],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
// Focus input after opening
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const dropdown = open && (
|
||||
<div
|
||||
className="bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 overflow-hidden"
|
||||
style={
|
||||
pos
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
width: pos.width,
|
||||
zIndex: 50,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Filter input */}
|
||||
<div className="p-1.5 border-b border-warm-100 dark:border-warm-700">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type to filter..."
|
||||
className="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
<div ref={listRef} className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-warm-400 dark:text-warm-500 text-center">
|
||||
{loading ? 'Loading...' : 'No destinations found'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((dest, idx) => (
|
||||
<button
|
||||
key={dest.slug}
|
||||
type="button"
|
||||
className={`w-full text-left flex items-center cursor-pointer px-2 py-1.5 gap-1.5 text-xs ${
|
||||
idx === activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(dest);
|
||||
}}
|
||||
>
|
||||
<MapPinIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
<span className="text-warm-700 dark:text-warm-200 truncate">
|
||||
{dest.name}
|
||||
{dest.city && (
|
||||
<span className="text-warm-400 dark:text-warm-500">
|
||||
{' '}
|
||||
({dest.city})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 hover:border-warm-300 dark:hover:border-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
|
||||
) : (
|
||||
<MapPinIcon className="w-3 h-3 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left truncate">{placeholder}</span>
|
||||
<ChevronIcon
|
||||
direction={open ? 'up' : 'down'}
|
||||
className="w-3 h-3 shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && createPortal(dropdown, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ export function FeatureActions({
|
|||
)}
|
||||
<IconButton
|
||||
onClick={() => onTogglePin(feature.name)}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
|
||||
active={isPinned}
|
||||
size="md"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,21 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#0f1528" />
|
||||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Perfect Postcode — Every neighbourhood in England</title>
|
||||
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
|
||||
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
||||
<script>
|
||||
(function() {
|
||||
if (localStorage.getItem('theme') === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
var theme = localStorage.getItem('theme');
|
||||
var isDark = theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
// Override theme-color when user has explicit preference
|
||||
if (theme === 'dark' || theme === 'light') {
|
||||
var color = theme === 'dark' ? '#0a0e1a' : '#fafaf9';
|
||||
document.querySelectorAll('meta[name="theme-color"]').forEach(function(m) { m.setAttribute('content', color); });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ def main():
|
|||
& pl.col("_prev_rooms").is_not_null()
|
||||
& (pl.col("NUMBER_HABITABLE_ROOMS") != pl.col("_prev_rooms"))
|
||||
)
|
||||
.then(pl.lit("Remodeling"))
|
||||
.then(pl.lit("Remodelling"))
|
||||
.when(
|
||||
pl.col("TOTAL_FLOOR_AREA").is_not_null()
|
||||
& pl.col("_prev_area").is_not_null()
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ _AREA_COLUMNS = [
|
|||
"Indoors Sub-domain Score",
|
||||
"Outdoors Sub-domain Score",
|
||||
# Ethnicity
|
||||
"% Asian",
|
||||
"% South Asian",
|
||||
"% East Asian",
|
||||
"% Black",
|
||||
"% Mixed",
|
||||
"% White",
|
||||
|
|
@ -49,7 +50,8 @@ _AREA_COLUMNS = [
|
|||
"Number of restaurants within 2km",
|
||||
"Number of grocery shops and supermarkets within 2km",
|
||||
"Number of parks within 2km",
|
||||
"Number of public transport stations within 2km",
|
||||
"Train or tube stations within 1km",
|
||||
"Distance to nearest train or tube station (km)",
|
||||
# Environment
|
||||
"Noise (dB)",
|
||||
"Max available download speed (Mbps)",
|
||||
|
|
@ -298,7 +300,7 @@ def _build(
|
|||
"pp_address": "Address per Property Register",
|
||||
"epc_address": "Address per EPC",
|
||||
"postcode": "Postcode",
|
||||
"duration": "Leashold/Freehold",
|
||||
"duration": "Leasehold/Freehold",
|
||||
"current_energy_rating": "Current energy rating",
|
||||
"potential_energy_rating": "Potential energy rating",
|
||||
"total_floor_area": "Total floor area (sqm)",
|
||||
|
|
@ -306,7 +308,8 @@ def _build(
|
|||
"restaurants_2km": "Number of restaurants within 2km",
|
||||
"groceries_2km": "Number of grocery shops and supermarkets within 2km",
|
||||
"parks_2km": "Number of parks within 2km",
|
||||
"public_transport_2km": "Number of public transport stations within 2km",
|
||||
"train_tube_1km": "Train or tube stations within 1km",
|
||||
"train_tube_nearest_km": "Distance to nearest train or tube station (km)",
|
||||
"latest_price": "Last known price",
|
||||
"number_habitable_rooms": "Number of bedrooms & living rooms",
|
||||
"noise_lden_db": "Noise (dB)",
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ set -euo pipefail
|
|||
# --demo only compute Bank + TCR, transit only (quick test)
|
||||
|
||||
# --- Defaults ---
|
||||
THREADS=4
|
||||
HEAP=12g
|
||||
THREADS=8
|
||||
HEAP=16g
|
||||
NETWORK_DIR=property-data/r5-network
|
||||
OUTPUT_BASE=property-data/travel-times
|
||||
R5_DIR=r5-java
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ use polars::lazy::frame::LazyFrame;
|
|||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use tracing::info;
|
||||
|
||||
/// Per-postcode travel time data: median and optional best-case (transit only).
|
||||
#[derive(Clone, Copy)]
|
||||
/// Per-postcode travel time data: median, optional best-case (transit only),
|
||||
/// and optional journey instructions (JSON leg array, transit only with --paths).
|
||||
#[derive(Clone)]
|
||||
pub struct TravelDataRow {
|
||||
pub minutes: i16,
|
||||
pub best_minutes: Option<i16>,
|
||||
pub journey: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
/// Cached postcode → travel time data for a single destination file.
|
||||
|
|
@ -198,17 +200,26 @@ impl TravelTimeStore {
|
|||
.column("best_minutes")
|
||||
.ok()
|
||||
.map(|col| col.i16().expect("'best_minutes' is not i16"));
|
||||
let journeys = df
|
||||
.column("journey")
|
||||
.ok()
|
||||
.map(|col| col.str().expect("'journey' is not string"));
|
||||
|
||||
let mut map = FxHashMap::default();
|
||||
map.reserve(df.height());
|
||||
for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
|
||||
if let (Some(pc), Some(min)) = (pc, min) {
|
||||
let best_min = best.as_ref().and_then(|b| b.get(i));
|
||||
let journey = journeys
|
||||
.as_ref()
|
||||
.and_then(|j| j.get(i))
|
||||
.map(Arc::from);
|
||||
map.insert(
|
||||
pc.to_string(),
|
||||
TravelDataRow {
|
||||
minutes: min,
|
||||
best_minutes: best_min,
|
||||
journey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ pub struct HexagonStatsParams {
|
|||
/// Comma-separated feature names to include in stats response.
|
||||
/// Only listed features are computed; if absent or empty, no features are returned.
|
||||
pub fields: Option<String>,
|
||||
/// When set (with journey_slug), pick central_postcode as the postcode with the
|
||||
/// shortest travel time for this mode+slug (so it has journey data).
|
||||
pub journey_mode: Option<String>,
|
||||
pub journey_slug: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_hexagon_stats(
|
||||
|
|
@ -107,6 +111,17 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
||||
// Load travel time data for central_postcode selection (if requested)
|
||||
let journey_travel_data = match (¶ms.journey_mode, ¶ms.journey_slug) {
|
||||
(Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => {
|
||||
state
|
||||
.travel_time_store
|
||||
.get(mode, slug)
|
||||
.ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || {
|
||||
let start_time = std::time::Instant::now();
|
||||
let precomputed = &state.h3_cells;
|
||||
|
|
@ -138,8 +153,42 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
let total_count = matching_rows.len();
|
||||
|
||||
// Find the postcode of the property closest to the hexagon center
|
||||
// Pick central_postcode: prefer the postcode with the shortest travel time
|
||||
// for the requested journey destination (so it has journey data). Fall back
|
||||
// to geographic proximity to the hexagon center.
|
||||
let central_postcode = if !matching_rows.is_empty() {
|
||||
if let Some(ref travel_data) = journey_travel_data {
|
||||
// Find the row with the shortest travel time in the travel data
|
||||
let best_row = matching_rows
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|row| {
|
||||
let pc = state.data.postcode(row);
|
||||
travel_data.get(pc).map(|td| (row, td.minutes))
|
||||
})
|
||||
.min_by_key(|&(_, mins)| mins)
|
||||
.map(|(row, _)| row);
|
||||
|
||||
// Fall back to geographic center if no row has travel data
|
||||
let row = best_row.unwrap_or_else(|| {
|
||||
let center: h3o::LatLng = cell.into();
|
||||
let center_lat = center.lat() as f32;
|
||||
let center_lon = center.lng() as f32;
|
||||
matching_rows
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|&a, &b| {
|
||||
let da = (state.data.lat[a] - center_lat).powi(2)
|
||||
+ (state.data.lon[a] - center_lon).powi(2);
|
||||
let db = (state.data.lat[b] - center_lat).powi(2)
|
||||
+ (state.data.lon[b] - center_lon).powi(2);
|
||||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.expect("matching_rows is non-empty")
|
||||
});
|
||||
Some(state.data.postcode(row).to_string())
|
||||
} else {
|
||||
// No journey destination requested — use geographic center
|
||||
let center: h3o::LatLng = cell.into();
|
||||
let center_lat = center.lat() as f32;
|
||||
let center_lon = center.lng() as f32;
|
||||
|
|
@ -147,18 +196,15 @@ pub async fn get_hexagon_stats(
|
|||
.iter()
|
||||
.copied()
|
||||
.min_by(|&a, &b| {
|
||||
let da_lat = state.data.lat[a] - center_lat;
|
||||
let da_lon = state.data.lon[a] - center_lon;
|
||||
let db_lat = state.data.lat[b] - center_lat;
|
||||
let db_lon = state.data.lon[b] - center_lon;
|
||||
let dist_a = da_lat * da_lat + da_lon * da_lon;
|
||||
let dist_b = db_lat * db_lat + db_lon * db_lon;
|
||||
dist_a
|
||||
.partial_cmp(&dist_b)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
let da = (state.data.lat[a] - center_lat).powi(2)
|
||||
+ (state.data.lon[a] - center_lon).powi(2);
|
||||
let db = (state.data.lat[b] - center_lat).powi(2)
|
||||
+ (state.data.lon[b] - center_lon).powi(2);
|
||||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.expect("matching_rows is non-empty");
|
||||
Some(state.data.postcode(closest_row).to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ struct InviteValidation {
|
|||
used: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateInviteRequest {
|
||||
/// Admins can explicitly choose "admin" or "referral". Ignored for non-admins.
|
||||
invite_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RedeemRequest {
|
||||
code: String,
|
||||
|
|
@ -66,12 +72,12 @@ fn generate_invite_code() -> String {
|
|||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Create an invite. Admins create "admin" invites (free license).
|
||||
/// Licensed non-admin users create "referral" invites (30% off).
|
||||
/// Create an invite. Admins create "admin" invites (free license) by default,
|
||||
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
|
||||
pub async fn post_invites(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
_body: Json<serde_json::Value>,
|
||||
Json(body): Json<CreateInviteRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
|
|
@ -79,7 +85,10 @@ pub async fn post_invites(
|
|||
};
|
||||
|
||||
let invite_type = if user.is_admin {
|
||||
"admin"
|
||||
match body.invite_type.as_deref() {
|
||||
Some("referral") => "referral",
|
||||
_ => "admin",
|
||||
}
|
||||
} else if user.subscription == "licensed" {
|
||||
"referral"
|
||||
} else {
|
||||
|
|
|
|||
58
server-rs/src/routes/journey.rs
Normal file
58
server-rs/src/routes/journey.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JourneyQuery {
|
||||
postcode: String,
|
||||
mode: String,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct JourneyResponse {
|
||||
/// Raw JSON array of journey legs, or null if no journey data available.
|
||||
journey: Option<serde_json::Value>,
|
||||
/// Median (50th percentile) total travel time in minutes.
|
||||
minutes: Option<i16>,
|
||||
/// Best-case (5th percentile) total travel time in minutes (transit only).
|
||||
best_minutes: Option<i16>,
|
||||
}
|
||||
|
||||
pub async fn get_journey(
|
||||
state: Arc<AppState>,
|
||||
query: axum::extract::Query<JourneyQuery>,
|
||||
) -> Result<Json<JourneyResponse>, (StatusCode, String)> {
|
||||
let store = &state.travel_time_store;
|
||||
|
||||
if !store.has_destination(&query.mode, &query.slug) {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No travel data for mode={} slug={}", query.mode, query.slug),
|
||||
));
|
||||
}
|
||||
|
||||
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to load travel data: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let row = travel_data.get(&query.postcode);
|
||||
let journey = row
|
||||
.and_then(|r| r.journey.as_ref())
|
||||
.and_then(|j| serde_json::from_str::<serde_json::Value>(j).ok());
|
||||
let minutes = row.map(|r| r.minutes);
|
||||
let best_minutes = row.and_then(|r| r.best_minutes);
|
||||
|
||||
Ok(Json(JourneyResponse {
|
||||
journey,
|
||||
minutes,
|
||||
best_minutes,
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TypeaheadParams {
|
||||
pub postcode: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TypeaheadResponse {
|
||||
pub location_identifier: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveMatch {
|
||||
#[serde(rename = "type")]
|
||||
match_type: String,
|
||||
#[serde(rename = "displayName")]
|
||||
display_name: String,
|
||||
id: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveTypeaheadResponse {
|
||||
matches: Vec<RightmoveMatch>,
|
||||
}
|
||||
|
||||
pub async fn get_rightmove_typeahead(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<TypeaheadParams>,
|
||||
) -> Result<Json<TypeaheadResponse>, axum::response::Response> {
|
||||
let postcode = params.postcode.trim().to_uppercase();
|
||||
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(TYPEAHEAD_URL)
|
||||
.query(&[("query", &postcode), ("limit", &"10".to_string())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "Rightmove typeahead request failed");
|
||||
(StatusCode::BAD_GATEWAY, "Rightmove typeahead unavailable").into_response()
|
||||
})?;
|
||||
|
||||
let data: RightmoveTypeaheadResponse = resp.json().await.map_err(|err| {
|
||||
warn!(error = %err, "Failed to parse Rightmove typeahead response");
|
||||
(StatusCode::BAD_GATEWAY, "Invalid typeahead response").into_response()
|
||||
})?;
|
||||
|
||||
// Look for POSTCODE match first, then OUTCODE
|
||||
for match_type in &["POSTCODE", "OUTCODE"] {
|
||||
for m in &data.matches {
|
||||
if m.match_type == *match_type
|
||||
&& m.display_name.to_uppercase().replace(' ', "")
|
||||
== postcode.replace(' ', "")
|
||||
{
|
||||
let id = match &m.id {
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
return Ok(Json(TypeaheadResponse {
|
||||
location_identifier: format!("{}^{}", match_type, id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No Rightmove location found for: {}", postcode),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
const VALID_SUBSCRIPTIONS: &[&str] = &["free", "licensed"];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateSubscriptionRequest {
|
||||
subscription: String,
|
||||
}
|
||||
|
||||
pub async fn patch_subscription(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Json(req): Json<UpdateSubscriptionRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
if !user.is_admin {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
if !VALID_SUBSCRIPTIONS.contains(&req.subscription.as_str()) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid subscription: {}", req.subscription),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to authenticate as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": req.subscription }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(&user.id);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase user update failed ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
89
server-rs/src/routes/travel_destinations.rs
Normal file
89
server-rs/src/routes/travel_destinations.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::data::slugify;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DestinationResult {
|
||||
name: String,
|
||||
slug: String,
|
||||
place_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
city: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DestinationsResponse {
|
||||
destinations: Vec<DestinationResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DestinationsParams {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
pub async fn get_travel_destinations(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<DestinationsParams>,
|
||||
) -> Result<Json<DestinationsResponse>, (StatusCode, String)> {
|
||||
let mode = params.mode;
|
||||
|
||||
let destinations = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let pd = &state.place_data;
|
||||
let tt_store = &state.travel_time_store;
|
||||
|
||||
let slug_set = match tt_store.destinations.get(&mode) {
|
||||
Some(slugs) => slugs,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
// Find places that have travel time data for this mode
|
||||
let mut matches: Vec<(usize, String, u8, u32, usize)> = pd
|
||||
.name
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
let slug = slugify(name);
|
||||
if slug_set.contains(&slug) {
|
||||
Some((idx, slug, pd.type_rank[idx], pd.population[idx], name.len()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort: type rank asc, population desc, name length asc
|
||||
matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4)));
|
||||
|
||||
let results: Vec<DestinationResult> = matches
|
||||
.into_iter()
|
||||
.map(|(idx, slug, ..)| DestinationResult {
|
||||
name: pd.name[idx].clone(),
|
||||
slug,
|
||||
place_type: pd.place_type.get(idx).to_string(),
|
||||
city: pd.city[idx].clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
info!(
|
||||
mode = mode.as_str(),
|
||||
results = results.len(),
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/travel-destinations"
|
||||
);
|
||||
|
||||
results
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
||||
|
||||
Ok(Json(DestinationsResponse { destinations }))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue