These work
This commit is contained in:
parent
3599803589
commit
1588c01b19
19 changed files with 260 additions and 201 deletions
|
|
@ -3,6 +3,7 @@ import MapPage, { type ExportState } from './components/map/MapPage';
|
|||
import PricingPage from './components/pricing/PricingPage';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
|
||||
import LearnPage from './components/learn/LearnPage';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import AuthModal from './components/ui/AuthModal';
|
||||
import SaveSearchModal from './components/ui/SaveSearchModal';
|
||||
|
|
@ -27,6 +28,8 @@ function pageToPath(page: Page): string {
|
|||
return '/dashboard';
|
||||
case 'saved-searches':
|
||||
return '/saved';
|
||||
case 'learn':
|
||||
return '/learn';
|
||||
case 'pricing':
|
||||
return '/pricing';
|
||||
default:
|
||||
|
|
@ -37,6 +40,7 @@ case 'saved-searches':
|
|||
function pathToPage(pathname: string): Page | null {
|
||||
if (pathname === '/dashboard') return 'dashboard';
|
||||
if (pathname === '/saved') return 'saved-searches';
|
||||
if (pathname === '/learn') return 'learn';
|
||||
if (pathname === '/pricing') return 'pricing';
|
||||
if (pathname === '/') return 'home';
|
||||
return null;
|
||||
|
|
@ -75,14 +79,6 @@ export default function App() {
|
|||
// Restore from history state (e.g. popstate)
|
||||
if (window.history.state?.page) return window.history.state.page;
|
||||
|
||||
// Backward compat: dashboard params on unknown path
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('lat') || params.has('filter') || params.has('poi') || params.has('tab') || params.has('v') || params.has('f') || params.has('dest')) {
|
||||
// Rewrite URL to /dashboard keeping query params
|
||||
window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`);
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
return 'home';
|
||||
});
|
||||
|
||||
|
|
@ -235,6 +231,8 @@ export default function App() {
|
|||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
|
||||
) : activePage === 'pricing' ? (
|
||||
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
|
||||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
) : activePage === 'saved-searches' ? (
|
||||
<SavedSearchesPage
|
||||
searches={savedSearches.searches}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import MapComponent from '../map/Map';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { apiUrl, authHeaders } from '../../lib/api';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { FeatureMeta, HexagonData } from '../../types';
|
||||
|
||||
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
|
||||
|
|
@ -23,6 +24,8 @@ interface HomeDemoProps {
|
|||
|
||||
export default function HomeDemo({ features, theme }: HomeDemoProps) {
|
||||
const [hexData, setHexData] = useState<HexagonData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [sliderValues, setSliderValues] = useState<Record<string, [number, number]>>({});
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||
|
|
@ -83,10 +86,18 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
|
|||
}
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setFetching(true);
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
.then((data: { features: HexagonData[] }) => setHexData(data.features))
|
||||
.catch(() => {});
|
||||
.then((data: { features: HexagonData[] }) => {
|
||||
setHexData(data.features);
|
||||
setLoading(false);
|
||||
setFetching(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
logNonAbortError('Failed to fetch demo hexagons', err);
|
||||
setFetching(false);
|
||||
});
|
||||
}, [features, sliderValues]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -133,7 +144,7 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
|
|||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
|
||||
.catch(() => {});
|
||||
.catch((err) => logNonAbortError('Failed to fetch demo drag data', err));
|
||||
},
|
||||
[features, sliderValues]
|
||||
);
|
||||
|
|
@ -182,6 +193,21 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
|
|||
hideLegend={true}
|
||||
/>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
||||
Connecting to server...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && fetching && (
|
||||
<div className="absolute top-3 left-3 z-50 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{/* Colour spectrum legend */}
|
||||
<div className="absolute bottom-3 left-3 right-3 z-50 pointer-events-none">
|
||||
<div className="bg-white/90 dark:bg-warm-800/90 rounded-lg px-3 py-2 backdrop-blur-sm text-xs">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
|||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
import { formatFilterValue, buildPercentileScale } from '../../lib/format';
|
||||
import type { PercentileScale } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
|
|
@ -21,27 +22,30 @@ function SliderLabels({
|
|||
min,
|
||||
max,
|
||||
value,
|
||||
displayValues,
|
||||
}: {
|
||||
min: number;
|
||||
max: number;
|
||||
value: [number, number];
|
||||
displayValues?: [number, number];
|
||||
}) {
|
||||
const range = max - min || 1;
|
||||
const leftPct = ((value[0] - min) / range) * 100;
|
||||
const rightPct = ((value[1] - min) / range) * 100;
|
||||
const labels = displayValues || value;
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
{formatFilterValue(value[0])}
|
||||
{formatFilterValue(labels[0])}
|
||||
</span>
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${rightPct}%` }}
|
||||
>
|
||||
{formatFilterValue(value[1])}
|
||||
{formatFilterValue(labels[1])}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -119,6 +123,16 @@ export default memo(function Filters({
|
|||
[enabledFeatureList]
|
||||
);
|
||||
|
||||
const percentileScales = useMemo(() => {
|
||||
const scales = new Map<string, PercentileScale>();
|
||||
for (const f of features) {
|
||||
if (f.type === 'numeric' && f.histogram) {
|
||||
scales.set(f.name, buildPercentileScale(f.histogram));
|
||||
}
|
||||
}
|
||||
return scales;
|
||||
}, [features]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
|
|
@ -230,7 +244,10 @@ export default memo(function Filters({
|
|||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
|
||||
const step = feature.step ?? (feature.max! - feature.min!) / 100;
|
||||
const scale = percentileScales.get(feature.name);
|
||||
const sliderValue: [number, number] = scale
|
||||
? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))]
|
||||
: displayValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -248,15 +265,24 @@ export default memo(function Filters({
|
|||
</div>
|
||||
<div>
|
||||
<Slider
|
||||
min={feature.min!}
|
||||
max={feature.max!}
|
||||
step={step}
|
||||
value={[displayValue[0], displayValue[1]]}
|
||||
onValueChange={([min, max]) => onDragChange([min, max])}
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)])
|
||||
: ([min, max]) => onDragChange([min, max])
|
||||
}
|
||||
onPointerDown={() => onDragStart(feature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels min={feature.min!} max={feature.max!} value={displayValue} />
|
||||
<SliderLabels
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
value={sliderValue}
|
||||
displayValues={scale ? displayValue : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -142,18 +142,14 @@ function PropertyLoadingSkeleton() {
|
|||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const price = getNum(property, 'Last known price', 'latest_price');
|
||||
const price = getNum(property, 'Last known price');
|
||||
const estimatedPrice = getNum(property, 'Estimated current price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm');
|
||||
const estPricePerSqm = getNum(property, 'Est. price per sqm');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
||||
const rooms = getNum(
|
||||
property,
|
||||
'Rooms (including bedrooms & bathrooms)',
|
||||
'number_habitable_rooms'
|
||||
);
|
||||
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
||||
const transactionDate = getNum(property, 'Date of last transaction', 'date_of_transfer');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)');
|
||||
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)');
|
||||
const age = getNum(property, 'Approximate construction age');
|
||||
const transactionDate = getNum(property, 'Date of last transaction');
|
||||
const councilTax = getNum(property, 'Council tax (£/yr)');
|
||||
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'pricing';
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing';
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
|
|
@ -133,6 +133,9 @@ export default function Header({
|
|||
Saved
|
||||
</button>
|
||||
)}
|
||||
<button className={tabClass('learn')} onClick={() => onPageChange('learn')}>
|
||||
Learn
|
||||
</button>
|
||||
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
|
||||
Pricing
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export default function MobileMenu({
|
|||
{mobileNavItem('home', 'Home')}
|
||||
{mobileNavItem('dashboard', 'Dashboard')}
|
||||
{user && mobileNavItem('saved-searches', 'Saved')}
|
||||
{mobileNavItem('learn', 'Learn')}
|
||||
{mobileNavItem('pricing', 'Pricing')}
|
||||
|
||||
{/* Dashboard actions */}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
signal: dragAbortRef.current.signal,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((json: ApiResponse) => setDragData(json.features || []))
|
||||
.then((json: ApiResponse) => setDragData(json.features))
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
|
||||
},
|
||||
[filters, features]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
ApiResponse,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
|
||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||||
|
|
@ -101,7 +101,7 @@ export function useMapData({
|
|||
})
|
||||
);
|
||||
const json: { features: PostcodeFeature[] } = await res.json();
|
||||
setPostcodeData(json.features || []);
|
||||
setPostcodeData(json.features);
|
||||
setRawData([]);
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -121,7 +121,7 @@ export function useMapData({
|
|||
})
|
||||
);
|
||||
const json: ApiResponse = await res.json();
|
||||
setRawData(json.features || []);
|
||||
setRawData(json.features);
|
||||
setPostcodeData([]);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -51,25 +51,19 @@ export function useSavedSearches(userId: string | null) {
|
|||
try {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
|
||||
// Try to capture a screenshot via the screenshot endpoint
|
||||
let screenshotBlob: Blob | null = null;
|
||||
try {
|
||||
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
|
||||
const res = await fetch(screenshotUrl);
|
||||
if (res.ok) {
|
||||
screenshotBlob = await res.blob();
|
||||
}
|
||||
} catch {
|
||||
// Screenshot is optional — save without it
|
||||
// Capture a screenshot via the screenshot endpoint
|
||||
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
|
||||
const screenshotRes = await fetch(screenshotUrl);
|
||||
if (!screenshotRes.ok) {
|
||||
throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`);
|
||||
}
|
||||
const screenshotBlob = await screenshotRes.blob();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('user', userId);
|
||||
formData.append('name', name);
|
||||
formData.append('params', params);
|
||||
if (screenshotBlob) {
|
||||
formData.append('screenshot', screenshotBlob, 'screenshot.png');
|
||||
}
|
||||
formData.append('screenshot', screenshotBlob, 'screenshot.png');
|
||||
|
||||
await pb.collection('saved_searches').create(formData);
|
||||
await fetchSearches();
|
||||
|
|
|
|||
|
|
@ -65,6 +65,81 @@ export function formatRelativeTime(isoDate: string): string {
|
|||
return new Date(isoDate).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Percentile-based scale: maps between percentile space (0–100) and absolute values
|
||||
// using the histogram's CDF. Each percentile step = 1% of data.
|
||||
export interface PercentileScale {
|
||||
toValue: (percentile: number) => number;
|
||||
toPercentile: (value: number) => number;
|
||||
}
|
||||
|
||||
export function buildPercentileScale(hist: {
|
||||
min: number;
|
||||
max: number;
|
||||
p1: number;
|
||||
p99: number;
|
||||
counts: number[];
|
||||
}): PercentileScale {
|
||||
const n = hist.counts.length;
|
||||
const total = hist.counts.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (n === 0 || total === 0) {
|
||||
const range = hist.max - hist.min || 1;
|
||||
return {
|
||||
toValue: (p) => hist.min + (p / 100) * range,
|
||||
toPercentile: (v) => ((v - hist.min) / range) * 100,
|
||||
};
|
||||
}
|
||||
|
||||
// Bin boundaries: [min, p1, ..middle edges.., p99, max]
|
||||
const boundaries: number[] = [];
|
||||
if (n === 1) {
|
||||
boundaries.push(hist.min, hist.max);
|
||||
} else {
|
||||
boundaries.push(hist.min, hist.p1);
|
||||
if (n > 2) {
|
||||
const middleWidth = (hist.p99 - hist.p1) / (n - 2);
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
boundaries.push(hist.p1 + i * middleWidth);
|
||||
}
|
||||
}
|
||||
boundaries.push(hist.max);
|
||||
}
|
||||
|
||||
// Cumulative fraction: cumFrac[0]=0, cumFrac[n]=1
|
||||
const cumFrac: number[] = [0];
|
||||
for (let i = 0; i < n; i++) {
|
||||
cumFrac.push(cumFrac[i] + hist.counts[i] / total);
|
||||
}
|
||||
cumFrac[n] = 1; // ensure exact 1.0
|
||||
|
||||
return {
|
||||
toValue(percentile: number): number {
|
||||
const target = Math.max(0, Math.min(1, percentile / 100));
|
||||
if (target <= 0) return boundaries[0];
|
||||
if (target >= 1) return boundaries[n];
|
||||
let i = 0;
|
||||
for (; i < n - 1; i++) {
|
||||
if (cumFrac[i + 1] > target) break;
|
||||
}
|
||||
const binFrac = cumFrac[i + 1] - cumFrac[i];
|
||||
const t = binFrac > 0 ? (target - cumFrac[i]) / binFrac : 0;
|
||||
return boundaries[i] + t * (boundaries[i + 1] - boundaries[i]);
|
||||
},
|
||||
|
||||
toPercentile(value: number): number {
|
||||
if (value <= boundaries[0]) return 0;
|
||||
if (value >= boundaries[n]) return 100;
|
||||
let i = 0;
|
||||
for (; i < n - 1; i++) {
|
||||
if (boundaries[i + 1] > value) break;
|
||||
}
|
||||
const binWidth = boundaries[i + 1] - boundaries[i];
|
||||
const t = binWidth > 0 ? (value - boundaries[i]) / binWidth : 0;
|
||||
return (cumFrac[i] + t * (cumFrac[i + 1] - cumFrac[i])) * 100;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate weighted mean from histogram with outlier bins.
|
||||
// Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max].
|
||||
export function calculateHistogramMean(histogram: {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import type { Property } from '../types';
|
||||
|
||||
// Generic getter for any field names (for dynamic lookups)
|
||||
export function getNum(property: Property, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
}
|
||||
export function getNum(property: Property, key: string): number | undefined {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,30 +27,6 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
|||
return Object.keys(filters).length > 0 ? filters : undefined;
|
||||
}
|
||||
|
||||
/** Backward compat: parse old comma-packed `f` param */
|
||||
function parseLegacyFilters(f: string): FeatureFilters | undefined {
|
||||
const filters: FeatureFilters = {};
|
||||
for (const segment of f.split(',')) {
|
||||
const colonIdx = segment.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const name = segment.substring(0, colonIdx);
|
||||
const rest = segment.substring(colonIdx + 1);
|
||||
if (rest.includes(':')) {
|
||||
const [minStr, maxStr] = rest.split(':');
|
||||
const min = Number(minStr);
|
||||
const max = Number(maxStr);
|
||||
if (!isNaN(min) && !isNaN(max)) {
|
||||
filters[name] = [min, max];
|
||||
}
|
||||
} else if (rest.includes('|')) {
|
||||
filters[name] = rest.split('|');
|
||||
} else {
|
||||
filters[name] = [rest];
|
||||
}
|
||||
}
|
||||
return Object.keys(filters).length > 0 ? filters : undefined;
|
||||
}
|
||||
|
||||
export function parseUrlState(): {
|
||||
viewState?: ViewState;
|
||||
filters?: FeatureFilters;
|
||||
|
|
@ -72,45 +48,21 @@ export function parseUrlState(): {
|
|||
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
|
||||
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
|
||||
}
|
||||
} else {
|
||||
// Backward compat: old packed `v=lat,lon,zoom`
|
||||
const v = params.get('v');
|
||||
if (v) {
|
||||
const parts = v.split(',').map(Number);
|
||||
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
||||
result.viewState = { latitude: parts[0], longitude: parts[1], zoom: parts[2], pitch: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filters: repeated `filter` params
|
||||
result.filters = parseFilters(params);
|
||||
if (!result.filters) {
|
||||
// Backward compat: old packed `f` param
|
||||
const f = params.get('f');
|
||||
if (f) result.filters = parseLegacyFilters(f);
|
||||
}
|
||||
|
||||
// POI categories: repeated `poi` params
|
||||
const poiParams = params.getAll('poi');
|
||||
if (poiParams.length > 0) {
|
||||
// Handle both new (repeated params) and old (comma-separated) formats
|
||||
const categories = poiParams.flatMap((p) => p.split(',')).filter(Boolean);
|
||||
if (categories.length > 0) {
|
||||
result.poiCategories = new Set(categories);
|
||||
}
|
||||
result.poiCategories = new Set(poiParams.filter(Boolean));
|
||||
}
|
||||
|
||||
// Tab: full name
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'properties' || tab === 'pois' || tab === 'area') {
|
||||
result.tab = tab;
|
||||
} else if (tab === 'p') {
|
||||
result.tab = 'properties'; // backward compat
|
||||
} else if (tab === 'o') {
|
||||
result.tab = 'pois';
|
||||
} else if (tab === 'a') {
|
||||
result.tab = 'area';
|
||||
}
|
||||
|
||||
// Travel time
|
||||
|
|
@ -121,7 +73,7 @@ export function parseUrlState(): {
|
|||
const tt: TravelTimeInitial = {
|
||||
destination: [parts[0], parts[1]],
|
||||
destinationLabel: params.get('destLabel') || '',
|
||||
mode: (params.get('tmode') as TransportMode) || 'transit',
|
||||
mode: (params.get('tmode') as TransportMode) || 'car',
|
||||
};
|
||||
const ttRange = params.get('tt');
|
||||
if (ttRange) {
|
||||
|
|
@ -178,7 +130,7 @@ export function stateToParams(
|
|||
if (travelTime.destinationLabel) {
|
||||
params.set('destLabel', travelTime.destinationLabel);
|
||||
}
|
||||
if (travelTime.mode !== 'transit') {
|
||||
if (travelTime.mode !== 'car') {
|
||||
params.set('tmode', travelTime.mode);
|
||||
}
|
||||
if (travelTime.timeRange) {
|
||||
|
|
@ -193,7 +145,6 @@ export function summarizeParams(queryString: string): string {
|
|||
const params = new URLSearchParams(queryString);
|
||||
const parts: string[] = [];
|
||||
|
||||
// New format: repeated `filter` params
|
||||
const filterParams = params.getAll('filter');
|
||||
if (filterParams.length > 0) {
|
||||
const filterNames = filterParams
|
||||
|
|
@ -207,28 +158,11 @@ export function summarizeParams(queryString: string): string {
|
|||
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Backward compat: old packed `f` param
|
||||
const f = params.get('f');
|
||||
if (f) {
|
||||
const filterNames = f
|
||||
.split(',')
|
||||
.map((seg) => {
|
||||
const colonIdx = seg.indexOf(':');
|
||||
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (filterNames.length > 0) {
|
||||
parts.push(
|
||||
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const poiParams = params.getAll('poi');
|
||||
if (poiParams.length > 0) {
|
||||
const count = poiParams.flatMap((p) => p.split(',')).filter(Boolean).length;
|
||||
const count = poiParams.filter(Boolean).length;
|
||||
if (count > 0) {
|
||||
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue