These work
This commit is contained in:
parent
3599803589
commit
1588c01b19
19 changed files with 260 additions and 201 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -72,7 +72,7 @@ Python + Polars. Two phases:
|
|||
- `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 auto-discovers features from whatever column names exist in the parquet. Key renames:
|
||||
**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:
|
||||
- `pp_address` → `Address per Property Register`
|
||||
- `postcode` → `Postcode`
|
||||
- `latest_price` → `Last known price`
|
||||
|
|
@ -80,7 +80,7 @@ Python + Polars. Two phases:
|
|||
- `total_floor_area` → `Total floor area (sqm)`
|
||||
- `current_energy_rating` → `Current energy rating`
|
||||
|
||||
The server and frontend must handle these human-readable names. See the full rename map in `merge.py`.
|
||||
The server requires these exact column names at startup (will error if missing). See the full rename map in `merge.py`.
|
||||
|
||||
### Backend (`server-rs/`)
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N
|
|||
- `useUrlSync` — URL state synchronization
|
||||
|
||||
**Key patterns:**
|
||||
- URL encodes view/filters/POI categories/active tab as query params for shareable links
|
||||
- URL encodes view/filters/POI categories/active tab as query params for shareable links. Only the current format is supported — no legacy parameter parsing (old `v=`, `f=`, or tab abbreviations are not handled).
|
||||
- AbortControllers cancel in-flight requests on new queries (150ms debounce)
|
||||
- Zoom → H3 resolution defined in `consts.ts` `ZOOM_TO_RESOLUTION_THRESHOLDS`: `<7.5→5, <9.5→6, <10.5→8, <12→9, ≥12→10`
|
||||
- `POSTCODE_ZOOM_THRESHOLD = 15`: below 15 shows H3 hexagons, at/above 15 shows postcode polygons
|
||||
|
|
@ -153,7 +153,7 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N
|
|||
- `api.ts` — `apiUrl(endpoint, params?)` builds API URLs. `logNonAbortError(label, err)` and `isAbortError(err)` for error handling.
|
||||
- `features.ts` — `groupFeaturesByCategory(features)` groups FeatureMeta[] by their `group` field.
|
||||
- `format.ts` — `formatNumber(value, decimals)` for number formatting. `calculateHistogramMean(histogram)` for weighted mean calculation.
|
||||
- `property-fields.ts` — `getNum(property, ...keys)` for getting numeric property values with fallback field names.
|
||||
- `property-fields.ts` — `getNum(property, key)` for getting a single numeric property value. Takes exactly one key — no fallback names.
|
||||
|
||||
When adding new UI, prefer using these shared components over inline implementations to maintain consistency.
|
||||
|
||||
|
|
@ -271,6 +271,7 @@ Every UI element must use the correct token from this table. Do not invent new p
|
|||
|
||||
## Coding Preferences
|
||||
|
||||
- **No backwards compatibility, no silent fallbacks**: Never add fallback codepaths for old data formats, legacy URL parameters, or alternate field names. Never silently swallow errors — always error loudly (return an error, panic, or at minimum log). If something is wrong, the code should fail visibly. One canonical name per field, one format per API, one way to do things.
|
||||
- **Unified data models over special-casing**: Prefer storing different data types uniformly (e.g., enums as f32 indices alongside numeric features) rather than maintaining separate code paths
|
||||
- **Terse tests**: Test what matters in as few tests as possible — don't overcomplicate with excessive setup or edge cases that don't add value
|
||||
- **Extract and organize**: Group related utilities into proper modules (e.g., `utils/`, `parsing/`) rather than leaving helpers scattered
|
||||
|
|
@ -313,6 +314,8 @@ Follow these conventions in all Rust code:
|
|||
- **Startup precomputation**: Static responses (like `/api/features`) are computed once at startup and cached in `AppState`
|
||||
- **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.
|
||||
- **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`.
|
||||
- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ FROM debian:bookworm-slim
|
|||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=server /app/server-rs/target/release/property-map-server ./
|
||||
COPY --from=frontend /app/frontend/dist ./dist/
|
||||
COPY --from=frontend /app/frontend/dist ./frontend/dist/
|
||||
|
||||
COPY property-data/wide.parquet ./data/
|
||||
COPY property-data/filtered_uk_pois.parquet ./data/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// Capture a screenshot via the screenshot endpoint
|
||||
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
|
||||
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');
|
||||
}
|
||||
|
||||
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) {
|
||||
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'}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,20 +191,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
let poi_category_groups = poi_data.category_groups()?;
|
||||
|
||||
// Read index.html at startup for crawler OG injection
|
||||
let frontend_dist = cli.dist.unwrap_or_else(|| {
|
||||
if let Ok(executable) = std::env::current_exe() {
|
||||
let executable_dir = executable
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let dist_next_to_binary = executable_dir.join("dist");
|
||||
if dist_next_to_binary.exists() {
|
||||
return dist_next_to_binary;
|
||||
}
|
||||
}
|
||||
PathBuf::from("frontend/dist")
|
||||
});
|
||||
let frontend_dist = cli
|
||||
.dist
|
||||
.unwrap_or_else(|| PathBuf::from("frontend/dist"));
|
||||
|
||||
let index_html = if frontend_dist.exists() {
|
||||
let index_html = {
|
||||
let index_path = frontend_dist.join("index.html");
|
||||
match std::fs::read_to_string(&index_path) {
|
||||
Ok(html) => {
|
||||
|
|
@ -212,12 +203,14 @@ async fn main() -> anyhow::Result<()> {
|
|||
Some(html)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Could not read index.html: {}", err);
|
||||
warn!(
|
||||
"Could not read {}: {} (OG injection disabled)",
|
||||
index_path.display(),
|
||||
err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
/// Filter for numeric features: value must be in [min, max] range.
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedFilter {
|
||||
pub feat_idx: usize,
|
||||
pub min: f32,
|
||||
|
|
@ -9,6 +10,7 @@ pub struct ParsedFilter {
|
|||
|
||||
/// Filter for enum features: value must be one of the allowed indices.
|
||||
/// Uses FxHashSet<u32> (f32 bits) for O(1) lookups instead of O(n) Vec::contains.
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedEnumFilter {
|
||||
pub feat_idx: usize,
|
||||
/// Allowed enum indices stored as f32 bits for exact comparison
|
||||
|
|
@ -18,31 +20,33 @@ pub struct ParsedEnumFilter {
|
|||
/// Parse comma-separated filter string into numeric and enum filters.
|
||||
/// Numeric format: `name:min:max`
|
||||
/// Enum format: `name:val1|val2|val3` (pipe-separated string values)
|
||||
///
|
||||
/// Returns an error if any filter entry is malformed or references an unknown feature.
|
||||
pub fn parse_filters(
|
||||
filter_str: Option<&str>,
|
||||
feature_name_to_index: &FxHashMap<String, usize>,
|
||||
enum_values: &FxHashMap<usize, Vec<String>>,
|
||||
) -> (Vec<ParsedFilter>, Vec<ParsedEnumFilter>) {
|
||||
) -> Result<(Vec<ParsedFilter>, Vec<ParsedEnumFilter>), String> {
|
||||
let mut numeric = Vec::new();
|
||||
let mut enums = Vec::new();
|
||||
|
||||
let input = match filter_str.filter(|text| !text.is_empty()) {
|
||||
Some(text) => text,
|
||||
None => return (numeric, enums),
|
||||
None => return Ok((numeric, enums)),
|
||||
};
|
||||
|
||||
for entry in input.split(',') {
|
||||
let parts: Vec<&str> = entry.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
continue;
|
||||
return Err(format!("Malformed filter entry (missing ':'): '{entry}'"));
|
||||
}
|
||||
let name = parts[0].trim();
|
||||
let rest = parts[1].trim();
|
||||
|
||||
// Find feature index by name (O(1) lookup)
|
||||
let Some(&feat_idx) = feature_name_to_index.get(name) else {
|
||||
continue;
|
||||
};
|
||||
let &feat_idx = feature_name_to_index
|
||||
.get(name)
|
||||
.ok_or_else(|| format!("Unknown feature in filter: '{name}'"))?;
|
||||
|
||||
// Check if this is an enum feature
|
||||
if let Some(values) = enum_values.get(&feat_idx) {
|
||||
|
|
@ -62,21 +66,23 @@ pub fn parse_filters(
|
|||
// Numeric filter: parse min:max
|
||||
let num_parts: Vec<&str> = rest.splitn(2, ':').collect();
|
||||
if num_parts.len() != 2 {
|
||||
continue;
|
||||
return Err(format!(
|
||||
"Numeric filter '{name}' must have format 'name:min:max', got '{entry}'"
|
||||
));
|
||||
}
|
||||
let min = match num_parts[0].trim().parse::<f32>() {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let max = match num_parts[1].trim().parse::<f32>() {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let min = num_parts[0]
|
||||
.trim()
|
||||
.parse::<f32>()
|
||||
.map_err(|err| format!("Invalid min value in filter '{name}': {err}"))?;
|
||||
let max = num_parts[1]
|
||||
.trim()
|
||||
.parse::<f32>()
|
||||
.map_err(|err| format!("Invalid max value in filter '{name}': {err}"))?;
|
||||
numeric.push(ParsedFilter { feat_idx, min, max });
|
||||
}
|
||||
}
|
||||
|
||||
(numeric, enums)
|
||||
Ok((numeric, enums))
|
||||
}
|
||||
|
||||
/// Check if a row passes all filters.
|
||||
|
|
@ -155,7 +161,8 @@ mod tests {
|
|||
Some("price:100:500"),
|
||||
&feature_name_to_index(),
|
||||
&enum_values(),
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(numeric[0].feat_idx, 0);
|
||||
assert_eq!(numeric[0].min, 100.0);
|
||||
|
|
@ -166,7 +173,7 @@ mod tests {
|
|||
#[test]
|
||||
fn parse_filters_enum() {
|
||||
let (numeric, enums) =
|
||||
parse_filters(Some("rating:A|C"), &feature_name_to_index(), &enum_values());
|
||||
parse_filters(Some("rating:A|C"), &feature_name_to_index(), &enum_values()).unwrap();
|
||||
assert!(numeric.is_empty());
|
||||
assert_eq!(enums.len(), 1);
|
||||
assert_eq!(enums[0].feat_idx, 2);
|
||||
|
|
@ -176,19 +183,23 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filters_empty_and_invalid() {
|
||||
let (n, e) = parse_filters(None, &feature_name_to_index(), &enum_values());
|
||||
fn parse_filters_empty() {
|
||||
let (n, e) = parse_filters(None, &feature_name_to_index(), &enum_values()).unwrap();
|
||||
assert!(n.is_empty() && e.is_empty());
|
||||
|
||||
let (n, e) = parse_filters(Some(""), &feature_name_to_index(), &enum_values());
|
||||
let (n, e) = parse_filters(Some(""), &feature_name_to_index(), &enum_values()).unwrap();
|
||||
assert!(n.is_empty() && e.is_empty());
|
||||
}
|
||||
|
||||
let (n, e) = parse_filters(
|
||||
#[test]
|
||||
fn parse_filters_unknown_feature_errors() {
|
||||
let result = parse_filters(
|
||||
Some("unknown:1:2"),
|
||||
&feature_name_to_index(),
|
||||
&enum_values(),
|
||||
);
|
||||
assert!(n.is_empty() && e.is_empty());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -226,7 +237,8 @@ mod tests {
|
|||
Some("Price:100000:500000,Area:50:200"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(numeric.len(), 2);
|
||||
assert_eq!(numeric[0].feat_idx, 0);
|
||||
|
|
@ -239,22 +251,23 @@ mod tests {
|
|||
Some("Price:100000:500000,Type:Semi|Terraced"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(enums.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_numeric_format_ignored() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
fn parse_invalid_numeric_format_errors() {
|
||||
let result = parse_filters(
|
||||
Some("Price:not_a_number:500000"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
|
||||
assert!(numeric.is_empty());
|
||||
assert!(enums.is_empty());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid min value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -263,7 +276,8 @@ mod tests {
|
|||
Some("Type:Detached|Unknown|Flat"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(enums.len(), 1);
|
||||
assert!(enums[0].allowed.contains(&(0.0_f32).to_bits())); // Detached
|
||||
|
|
@ -277,7 +291,8 @@ mod tests {
|
|||
Some("Price : 100000 : 500000 , Type : Detached | Flat"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(enums.len(), 1);
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ pub async fn get_hexagon_stats(
|
|||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
);
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ pub async fn get_postcode_stats(
|
|||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
);
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ fn non_empty_string(text: &str) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Look up an enum feature value by trying multiple possible column names.
|
||||
/// Look up an enum feature value by column name.
|
||||
/// Uses the unified feature model: enum values stored as f32 indices in feature_data.
|
||||
fn lookup_enum_value(
|
||||
feature_name_to_index: &FxHashMap<String, usize>,
|
||||
|
|
@ -71,23 +71,18 @@ fn lookup_enum_value(
|
|||
num_features: usize,
|
||||
enum_values: &FxHashMap<usize, Vec<String>>,
|
||||
row: usize,
|
||||
names: &[&str],
|
||||
name: &str,
|
||||
) -> Option<String> {
|
||||
for name in names {
|
||||
if let Some(&feat_idx) = feature_name_to_index.get(*name) {
|
||||
if let Some(values) = enum_values.get(&feat_idx) {
|
||||
let &feat_idx = feature_name_to_index.get(name)?;
|
||||
let values = enum_values.get(&feat_idx)?;
|
||||
let value = feature_data[row * num_features + feat_idx];
|
||||
if value.is_finite() {
|
||||
let idx = value as usize;
|
||||
if let Some(str_value) = values.get(idx) {
|
||||
return Some(str_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
values.get(idx).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_hexagon_properties(
|
||||
state: Arc<AppState>,
|
||||
|
|
@ -111,7 +106,8 @@ pub async fn get_hexagon_properties(
|
|||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
);
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
|
|
@ -182,7 +178,7 @@ pub async fn get_hexagon_properties(
|
|||
num_features,
|
||||
enum_values,
|
||||
row,
|
||||
&["Property type", "epc_property_type", "pp_property_type"],
|
||||
"Property type",
|
||||
),
|
||||
built_form: lookup_enum_value(
|
||||
feature_name_to_index,
|
||||
|
|
@ -190,7 +186,7 @@ pub async fn get_hexagon_properties(
|
|||
num_features,
|
||||
enum_values,
|
||||
row,
|
||||
&["Property type/built form", "built_form"],
|
||||
"Property type/built form",
|
||||
),
|
||||
duration: lookup_enum_value(
|
||||
feature_name_to_index,
|
||||
|
|
@ -198,7 +194,7 @@ pub async fn get_hexagon_properties(
|
|||
num_features,
|
||||
enum_values,
|
||||
row,
|
||||
&["Leashold/Freehold", "duration"],
|
||||
"Leashold/Freehold",
|
||||
),
|
||||
current_energy_rating: lookup_enum_value(
|
||||
feature_name_to_index,
|
||||
|
|
@ -206,7 +202,7 @@ pub async fn get_hexagon_properties(
|
|||
num_features,
|
||||
enum_values,
|
||||
row,
|
||||
&["Current energy rating", "current_energy_rating"],
|
||||
"Current energy rating",
|
||||
),
|
||||
potential_energy_rating: lookup_enum_value(
|
||||
feature_name_to_index,
|
||||
|
|
@ -214,7 +210,7 @@ pub async fn get_hexagon_properties(
|
|||
num_features,
|
||||
enum_values,
|
||||
row,
|
||||
&["Potential energy rating", "potential_energy_rating"],
|
||||
"Potential energy rating",
|
||||
),
|
||||
lat: state.data.lat[row],
|
||||
lon: state.data.lon[row],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue