has issues
This commit is contained in:
parent
2e112d7398
commit
c645b0f1d4
96 changed files with 2147083 additions and 5787 deletions
|
|
@ -555,6 +555,7 @@ export default function App() {
|
|||
initialFilters={urlState.filters}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories}
|
||||
initialOverlays={urlState.overlays}
|
||||
initialTab={urlState.tab}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
|
|
@ -659,6 +660,7 @@ export default function App() {
|
|||
initialFilters={mapUrlState.filters}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={mapUrlState.poiCategories}
|
||||
initialOverlays={mapUrlState.overlays}
|
||||
initialTab={mapUrlState.tab}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,16 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
|||
url: 'https://www.forestresearch.gov.uk/tools-and-resources/national-trees-outside-woodland-map/',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'conservation-areas',
|
||||
url: 'https://opendata-historicengland.hub.arcgis.com/datasets/historicengland::conservation-areas/explore',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'listed-buildings',
|
||||
url: 'https://opendata-historicengland.hub.arcgis.com/datasets/historicengland::national-heritage-list-for-england-nhle/explore?layer=0',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'naptan',
|
||||
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
||||
|
|
@ -128,6 +138,16 @@ const DS_KEYS: Record<string, [string, string, string]> = {
|
|||
'learnPage.dsGreenspaceUse',
|
||||
],
|
||||
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
|
||||
'conservation-areas': [
|
||||
'learnPage.dsConservationAreasName',
|
||||
'learnPage.dsConservationAreasOrigin',
|
||||
'learnPage.dsConservationAreasUse',
|
||||
],
|
||||
'listed-buildings': [
|
||||
'learnPage.dsListedBuildingsName',
|
||||
'learnPage.dsListedBuildingsOrigin',
|
||||
'learnPage.dsListedBuildingsUse',
|
||||
],
|
||||
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
||||
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
||||
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
||||
|
|
@ -235,6 +255,17 @@ export default function LearnPage() {
|
|||
{ question: t('learnPage.faqDueDiligence4Q'), answer: t('learnPage.faqDueDiligence4A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqBehindDataTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqBehindData1Q'), answer: t('learnPage.faqBehindData1A') },
|
||||
{ question: t('learnPage.faqBehindData2Q'), answer: t('learnPage.faqBehindData2A') },
|
||||
{ question: t('learnPage.faqBehindData3Q'), answer: t('learnPage.faqBehindData3A') },
|
||||
{ question: t('learnPage.faqBehindData4Q'), answer: t('learnPage.faqBehindData4A') },
|
||||
{ question: t('learnPage.faqBehindData5Q'), answer: t('learnPage.faqBehindData5A') },
|
||||
{ question: t('learnPage.faqBehindData6Q'), answer: t('learnPage.faqBehindData6A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqPrivacyTitle'),
|
||||
items: [{ question: t('learnPage.faqPrivacy1Q'), answer: t('learnPage.faqPrivacy1A') }],
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import EnumBarChart from './EnumBarChart';
|
|||
import StackedBarChart from './StackedBarChart';
|
||||
import StackedEnumChart from './StackedEnumChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import CrimeYearChart from './CrimeYearChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, TransitIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
|
|
@ -264,6 +265,18 @@ export default function AreaPane({
|
|||
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
|
||||
}, [stats]);
|
||||
|
||||
// Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary").
|
||||
// We also index by the configured feature name (with " (avg/yr)" suffix) so the
|
||||
// metric-row renderer can look it up using the feature name it already has.
|
||||
const crimeByYearByFeatureName = useMemo(() => {
|
||||
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_by_year']>[number]>();
|
||||
for (const entry of stats?.crime_by_year ?? []) {
|
||||
map.set(entry.name, entry);
|
||||
map.set(`${entry.name} (avg/yr)`, entry);
|
||||
}
|
||||
return map;
|
||||
}, [stats]);
|
||||
|
||||
const globalFeatureByName = useMemo(
|
||||
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
||||
[globalFeatures]
|
||||
|
|
@ -599,6 +612,7 @@ export default function AreaPane({
|
|||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
|
||||
|
||||
return (
|
||||
<MetricRow
|
||||
|
|
@ -611,7 +625,9 @@ export default function AreaPane({
|
|||
/>
|
||||
}
|
||||
chart={
|
||||
numericStats.histogram &&
|
||||
crimeSeries && crimeSeries.points.length > 1 ? (
|
||||
<CrimeYearChart points={crimeSeries.points} />
|
||||
) : (numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
|
|
@ -648,7 +664,7 @@ export default function AreaPane({
|
|||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
))
|
||||
)))
|
||||
}
|
||||
value={formatValue(numericStats.mean, feature)}
|
||||
valueTitle={
|
||||
|
|
|
|||
102
frontend/src/components/map/CrimeYearChart.tsx
Normal file
102
frontend/src/components/map/CrimeYearChart.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CrimeYearPoint } from '../../types';
|
||||
|
||||
interface CrimeYearChartProps {
|
||||
points: CrimeYearPoint[];
|
||||
}
|
||||
|
||||
const PADDING = { top: 6, right: 4, bottom: 14, left: 4 };
|
||||
const HEIGHT = 48;
|
||||
|
||||
export default function CrimeYearChart({ points }: CrimeYearChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const w = entries[0].contentRect.width;
|
||||
if (w > 0) setWidth(w);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const { yearMin, yearMax, max } = useMemo(() => {
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
let m = 0;
|
||||
for (const p of points) {
|
||||
if (p.year < yMin) yMin = p.year;
|
||||
if (p.year > yMax) yMax = p.year;
|
||||
if (p.count > m) m = p.count;
|
||||
}
|
||||
return { yearMin: yMin, yearMax: yMax, max: m };
|
||||
}, [points]);
|
||||
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const plotW = Math.max(0, width - PADDING.left - PADDING.right);
|
||||
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
||||
const yearRange = Math.max(1, yearMax - yearMin);
|
||||
const scaleMax = max > 0 ? max : 1;
|
||||
|
||||
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
|
||||
const scaleY = (count: number) => PADDING.top + (1 - count / scaleMax) * plotH;
|
||||
|
||||
const baseline = PADDING.top + plotH;
|
||||
const lineFill = points
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${scaleX(p.year)},${scaleY(p.count)}`)
|
||||
.join(' ');
|
||||
const areaFill = `${lineFill} L${scaleX(yearMax)},${baseline} L${scaleX(yearMin)},${baseline} Z`;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full">
|
||||
{width > 0 && (
|
||||
<svg width={width} height={HEIGHT} className="block">
|
||||
<path d={areaFill} className="fill-rose-400/30 dark:fill-rose-500/25" />
|
||||
<path
|
||||
d={lineFill}
|
||||
fill="none"
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
className="stroke-rose-600 dark:stroke-rose-400"
|
||||
/>
|
||||
{points.map((p) => (
|
||||
<circle
|
||||
key={p.year}
|
||||
cx={scaleX(p.year)}
|
||||
cy={scaleY(p.count)}
|
||||
r={1.6}
|
||||
className="fill-rose-700 dark:fill-rose-300"
|
||||
>
|
||||
<title>{`${p.year}: ${p.count.toFixed(1)}/yr`}</title>
|
||||
</circle>
|
||||
))}
|
||||
<text
|
||||
x={scaleX(yearMin)}
|
||||
y={HEIGHT - 2}
|
||||
textAnchor="start"
|
||||
fontSize={9}
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
>
|
||||
{yearMin}
|
||||
</text>
|
||||
<text
|
||||
x={scaleX(yearMax)}
|
||||
y={HEIGHT - 2}
|
||||
textAnchor="end"
|
||||
fontSize={9}
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
>
|
||||
{yearMax}
|
||||
</text>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -91,6 +91,8 @@ interface FiltersProps {
|
|||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeDragEnd: (index: number) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
onTravelTimeToggleNoChange: (index: number) => void;
|
||||
onTravelTimeToggleNoBuses: (index: number) => void;
|
||||
aiFilterLoading: boolean;
|
||||
aiFilterError: string | null;
|
||||
aiFilterErrorType: AiFilterErrorType | null;
|
||||
|
|
@ -136,6 +138,8 @@ export default memo(function Filters({
|
|||
onTravelTimeRangeChange,
|
||||
onTravelTimeDragEnd,
|
||||
onTravelTimeToggleBest,
|
||||
onTravelTimeToggleNoChange,
|
||||
onTravelTimeToggleNoBuses,
|
||||
aiFilterLoading,
|
||||
aiFilterError,
|
||||
aiFilterErrorType,
|
||||
|
|
@ -662,6 +666,8 @@ export default memo(function Filters({
|
|||
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
||||
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
||||
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
||||
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
|
||||
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
|
||||
/>
|
||||
|
||||
<AddFilterPanel
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
|||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import { Layer, Map as MapGL, Source, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
Bounds,
|
||||
MapFlyToOptions,
|
||||
ActualListing,
|
||||
SchoolMetadata,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
|
|
@ -27,7 +28,12 @@ import {
|
|||
getPoiIconUrl,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
} from '../../lib/map-utils';
|
||||
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts';
|
||||
import {
|
||||
MAP_MIN_ZOOM,
|
||||
MAP_BOUNDS,
|
||||
POI_GROUP_COLORS,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
|
|
@ -37,12 +43,14 @@ import type { FeatureFilters } from '../../types';
|
|||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { ts } from '../../i18n/server';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeFeature[];
|
||||
usePostcodeView: boolean;
|
||||
pois: POI[];
|
||||
activeOverlays?: Set<OverlayId>;
|
||||
actualListings?: ActualListing[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
viewFeature: string | null;
|
||||
|
|
@ -81,6 +89,7 @@ interface MapProps {
|
|||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
const EMPTY_OVERLAYS = new Set<OverlayId>();
|
||||
|
||||
function formatListingPrice(price: number): string {
|
||||
return `£${price.toLocaleString()}`;
|
||||
|
|
@ -224,6 +233,131 @@ function getPoiGroupColor(group: string): [number, number, number] {
|
|||
return color;
|
||||
}
|
||||
|
||||
/** Best-effort web URL from a free-text website field — GIAS stores some with
|
||||
* "http://", some without, and some as bare hostnames. */
|
||||
function normalizeSchoolWebsiteUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSchoolMetadata(school: SchoolMetadata) {
|
||||
// First line collects the headline classification (phase, type, religious
|
||||
// character) so the popup is scannable even when most fields are absent.
|
||||
const headline: string[] = [];
|
||||
if (school.phase) headline.push(school.phase);
|
||||
if (school.type) headline.push(school.type);
|
||||
|
||||
const pupilsLine =
|
||||
school.pupils !== undefined && school.capacity !== undefined
|
||||
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils`
|
||||
: school.pupils !== undefined
|
||||
? `${school.pupils.toLocaleString()} pupils`
|
||||
: school.capacity !== undefined
|
||||
? `Capacity ${school.capacity.toLocaleString()}`
|
||||
: null;
|
||||
|
||||
const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;
|
||||
|
||||
return (
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-xs text-warm-600 dark:text-warm-300">
|
||||
{headline.length > 0 && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Type</dt>
|
||||
<dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.age_range && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Ages</dt>
|
||||
<dd className="dark:text-warm-200">{school.age_range}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.gender && school.gender !== 'Mixed' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Gender</dt>
|
||||
<dd className="dark:text-warm-200">{school.gender}</dd>
|
||||
</>
|
||||
)}
|
||||
{pupilsLine && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Pupils</dt>
|
||||
<dd className="dark:text-warm-200">{pupilsLine}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.fsm_percent !== undefined && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">FSM</dt>
|
||||
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
|
||||
</>
|
||||
)}
|
||||
{school.sixth_form === 'Has a sixth form' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
|
||||
<dd className="dark:text-warm-200">Yes</dd>
|
||||
</>
|
||||
)}
|
||||
{school.religious_character &&
|
||||
school.religious_character !== 'Does not apply' &&
|
||||
school.religious_character !== 'None' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Religion</dt>
|
||||
<dd className="dark:text-warm-200">{school.religious_character}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.admissions_policy && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Admissions</dt>
|
||||
<dd className="dark:text-warm-200">{school.admissions_policy}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.trust && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Trust</dt>
|
||||
<dd className="dark:text-warm-200">{school.trust}</dd>
|
||||
</>
|
||||
)}
|
||||
{(school.address || school.postcode) && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Address</dt>
|
||||
<dd className="dark:text-warm-200">
|
||||
{[school.address, school.postcode].filter(Boolean).join(', ')}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{school.local_authority && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">LA</dt>
|
||||
<dd className="dark:text-warm-200">{school.local_authority}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.head_name && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Head</dt>
|
||||
<dd className="dark:text-warm-200">{school.head_name}</dd>
|
||||
</>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Website</dt>
|
||||
<dd className="truncate">
|
||||
<a
|
||||
href={websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="pointer-events-auto text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{websiteUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function getRenderedViewState(map: MapRef | null): ViewState | null {
|
||||
if (!map) return null;
|
||||
|
||||
|
|
@ -275,11 +409,132 @@ function DeckOverlay({
|
|||
return null;
|
||||
}
|
||||
|
||||
function overlayTileUrl(path: string): string {
|
||||
return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`;
|
||||
}
|
||||
|
||||
function OverlayTileLayers({
|
||||
activeOverlays,
|
||||
zoom,
|
||||
}: {
|
||||
activeOverlays: Set<OverlayId>;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null;
|
||||
|
||||
const showNoise = activeOverlays.has('noise');
|
||||
const showCrime = activeOverlays.has('crime-hotspots');
|
||||
const showTrees = activeOverlays.has('trees-outside-woodlands');
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoise && (
|
||||
<Source
|
||||
id="overlay-noise-source"
|
||||
type="raster"
|
||||
tiles={[overlayTileUrl('noise')]}
|
||||
tileSize={256}
|
||||
maxzoom={14}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-noise"
|
||||
type="raster"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={{
|
||||
'raster-opacity': 0.68,
|
||||
'raster-fade-duration': 120,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showCrime && (
|
||||
<Source
|
||||
id="overlay-crime-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('crime-hotspots')]}
|
||||
maxzoom={15}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-crime-heatmap"
|
||||
type="heatmap"
|
||||
source-layer="crime_hotspots"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={
|
||||
{
|
||||
'heatmap-weight': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['coalesce', ['get', 'count'], ['get', 'weight'], 1],
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
1,
|
||||
],
|
||||
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
|
||||
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
|
||||
'heatmap-opacity': 0.72,
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
0,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
0.2,
|
||||
'rgb(253, 224, 71)',
|
||||
0.45,
|
||||
'rgb(249, 115, 22)',
|
||||
0.75,
|
||||
'rgb(220, 38, 38)',
|
||||
1,
|
||||
'rgb(127, 29, 29)',
|
||||
],
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showTrees && (
|
||||
<Source
|
||||
id="overlay-trees-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('trees-outside-woodlands')]}
|
||||
maxzoom={16}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-tree-polygons"
|
||||
type="fill"
|
||||
source-layer="trees_outside_woodlands"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={
|
||||
{
|
||||
'fill-color': '#1f9d55',
|
||||
'fill-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['coalesce', ['get', 'area_sqm'], 0],
|
||||
0,
|
||||
0.28,
|
||||
250,
|
||||
0.62,
|
||||
],
|
||||
'fill-outline-color': 'rgba(15, 81, 50, 0.65)',
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
pois,
|
||||
activeOverlays = EMPTY_OVERLAYS,
|
||||
actualListings = EMPTY_ACTUAL_LISTINGS,
|
||||
onViewChange,
|
||||
viewFeature,
|
||||
|
|
@ -514,6 +769,7 @@ export default memo(function Map({
|
|||
maxBounds={maxBounds}
|
||||
>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
<OverlayTileLayers activeOverlays={activeOverlays} zoom={viewState.zoom} />
|
||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||
</MapGL>
|
||||
{screenshotMode ? (
|
||||
|
|
@ -666,7 +922,7 @@ export default memo(function Map({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2">
|
||||
<div className="px-3 py-2 max-w-[280px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={getPoiIconUrl(
|
||||
|
|
@ -694,6 +950,7 @@ export default memo(function Map({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{popupInfo.school && renderSchoolMetadata(popupInfo.school)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,16 +15,28 @@ import { useAiFilters } from '../../hooks/useAiFilters';
|
|||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import { useTutorial } from '../../hooks/useTutorial';
|
||||
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
||||
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
|
||||
import {
|
||||
MAX_TRAVEL_MINUTES,
|
||||
parseServerMode,
|
||||
resolveTransitVariant,
|
||||
travelFieldKey,
|
||||
useTravelTime,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||
import {
|
||||
INITIAL_VIEW_STATE,
|
||||
POSTCODE_SEARCH_ZOOM,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { stateToParams } from '../../lib/url-state';
|
||||
import {
|
||||
AreaPane,
|
||||
Filters,
|
||||
OverlayPane,
|
||||
POIPane,
|
||||
PropertiesPane,
|
||||
UpgradeModal,
|
||||
|
|
@ -62,6 +74,7 @@ export default function MapPage({
|
|||
initialFilters,
|
||||
initialViewState,
|
||||
initialPOICategories,
|
||||
initialOverlays,
|
||||
initialTab,
|
||||
initialLoading,
|
||||
theme,
|
||||
|
|
@ -92,11 +105,15 @@ export default function MapPage({
|
|||
const { t } = useTranslation();
|
||||
const [selectedPOICategories, setSelectedPOICategories] =
|
||||
useState<Set<string>>(initialPOICategories);
|
||||
const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
|
||||
() => new Set(initialOverlays ?? [])
|
||||
);
|
||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
const [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
|
||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
|
||||
const {
|
||||
|
|
@ -141,6 +158,8 @@ export default function MapPage({
|
|||
handleSetEntries,
|
||||
handleTimeRangeChange,
|
||||
handleToggleBest,
|
||||
handleToggleNoChange,
|
||||
handleToggleNoBuses,
|
||||
} = useTravelTime(initialTravelTime);
|
||||
|
||||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||||
|
|
@ -181,8 +200,10 @@ export default function MapPage({
|
|||
async (query: string) => {
|
||||
const context = {
|
||||
filters,
|
||||
// Send the resolved variant so the AI sees the actual server mode in use,
|
||||
// not just the UI-side base mode. Lets the model refine no-change/no-buses.
|
||||
travelTime: activeEntries.map((entry) => ({
|
||||
mode: entry.mode,
|
||||
mode: resolveTransitVariant(entry),
|
||||
label: entry.label,
|
||||
min: entry.timeRange?.[0],
|
||||
max: entry.timeRange?.[1],
|
||||
|
|
@ -194,17 +215,29 @@ export default function MapPage({
|
|||
if (!result) return;
|
||||
|
||||
handleSetFilters(result.filters);
|
||||
// Filter out variants the UI cannot represent (e.g. transit-one-change*)
|
||||
// FIRST so the same filtered list drives both entry state and fly-to.
|
||||
// Otherwise we'd fly to a destination for a mode the user can't see.
|
||||
const representable = result.travelTimeFilters
|
||||
.map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
|
||||
.filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
|
||||
|
||||
handleSetEntries(
|
||||
result.travelTimeFilters.map((travelTimeFilter) => ({
|
||||
mode: travelTimeFilter.mode,
|
||||
slug: travelTimeFilter.slug,
|
||||
label: travelTimeFilter.label,
|
||||
timeRange: [travelTimeFilter.min ?? 0, travelTimeFilter.max ?? 120] as [number, number],
|
||||
representable.map(({ tt, parsed }) => ({
|
||||
mode: parsed.mode,
|
||||
noChange: parsed.noChange,
|
||||
noBuses: parsed.noBuses,
|
||||
slug: tt.slug,
|
||||
label: tt.label,
|
||||
timeRange: [
|
||||
tt.min ?? 0,
|
||||
Math.min(tt.max ?? MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES),
|
||||
] as [number, number],
|
||||
useBest: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const firstTravelTime = result.travelTimeFilters[0];
|
||||
const firstTravelTime = representable[0]?.tt;
|
||||
if (!firstTravelTime?.slug) return;
|
||||
|
||||
try {
|
||||
|
|
@ -411,6 +444,7 @@ export default function MapPage({
|
|||
}, []);
|
||||
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
|
||||
const actualListingsFilterParam = useMemo(
|
||||
() => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
|
|
@ -430,7 +464,8 @@ export default function MapPage({
|
|||
selectedPOICategories,
|
||||
rightPaneTab,
|
||||
entries,
|
||||
shareCode
|
||||
shareCode,
|
||||
activeOverlays
|
||||
);
|
||||
|
||||
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
|
||||
|
|
@ -485,6 +520,7 @@ export default function MapPage({
|
|||
filters,
|
||||
features,
|
||||
travelTimeEntries: entries,
|
||||
selectedOverlays: activeOverlays,
|
||||
shareCode,
|
||||
t,
|
||||
onExportStateChange,
|
||||
|
|
@ -502,9 +538,19 @@ export default function MapPage({
|
|||
selectedPOICategories,
|
||||
rightPaneTab,
|
||||
entries,
|
||||
shareCode
|
||||
shareCode,
|
||||
activeOverlays
|
||||
).toString(),
|
||||
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
|
||||
[
|
||||
activeOverlays,
|
||||
entries,
|
||||
features,
|
||||
filters,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
shareCode,
|
||||
shareAndSaveView,
|
||||
]
|
||||
);
|
||||
const handleSaveSearch = useCallback(
|
||||
async (name: string) => {
|
||||
|
|
@ -540,6 +586,7 @@ export default function MapPage({
|
|||
theme={theme}
|
||||
ogMode={ogMode}
|
||||
travelTimeEntries={entries}
|
||||
activeOverlays={activeOverlays}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -595,6 +642,17 @@ export default function MapPage({
|
|||
</Suspense>
|
||||
);
|
||||
|
||||
const renderOverlayPane = () => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<OverlayPane
|
||||
selectedOverlays={activeOverlays}
|
||||
onOverlaysChange={setActiveOverlays}
|
||||
zoomedIn={overlaysZoomedIn}
|
||||
onClose={() => setOverlayPaneOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<Filters
|
||||
|
|
@ -620,6 +678,8 @@ export default function MapPage({
|
|||
onTravelTimeRangeChange={handleTimeRangeChange}
|
||||
onTravelTimeDragEnd={handleTravelTimeDragEnd}
|
||||
onTravelTimeToggleBest={handleToggleBest}
|
||||
onTravelTimeToggleNoChange={handleToggleNoChange}
|
||||
onTravelTimeToggleNoBuses={handleToggleNoBuses}
|
||||
aiFilterLoading={aiFilterLoading}
|
||||
aiFilterError={aiFilterError}
|
||||
aiFilterErrorType={aiFilterErrorType}
|
||||
|
|
@ -645,7 +705,14 @@ export default function MapPage({
|
|||
</Suspense>
|
||||
);
|
||||
|
||||
const handleTogglePoiPane = () => setPoiPaneOpen((open) => !open);
|
||||
const handleTogglePoiPane = () => {
|
||||
setOverlayPaneOpen(false);
|
||||
setPoiPaneOpen((open) => !open);
|
||||
};
|
||||
const handleToggleOverlayPane = () => {
|
||||
setPoiPaneOpen(false);
|
||||
setOverlayPaneOpen((open) => !open);
|
||||
};
|
||||
const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => {
|
||||
if (tab === 'properties') {
|
||||
handlePropertiesTabClick();
|
||||
|
|
@ -713,6 +780,7 @@ export default function MapPage({
|
|||
initialLoading={initialLoading}
|
||||
mapData={mapData}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
mapViewFeature={mapViewFeature}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
|
|
@ -743,6 +811,9 @@ export default function MapPage({
|
|||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiButtonLabel={t('poiPane.pointsOfInterest')}
|
||||
poiPane={renderPOIPane()}
|
||||
overlayPaneOpen={overlayPaneOpen}
|
||||
onToggleOverlayPane={handleToggleOverlayPane}
|
||||
overlayPane={renderOverlayPane()}
|
||||
filtersPane={renderFilters({ destinationDropdownPortal: false })}
|
||||
mobileLegend={
|
||||
<MobileMapLegend
|
||||
|
|
@ -777,6 +848,7 @@ export default function MapPage({
|
|||
filtersPane={renderFilters()}
|
||||
mapData={mapData}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
mapViewFeature={mapViewFeature}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
|
|
@ -801,6 +873,9 @@ export default function MapPage({
|
|||
poiPaneOpen={poiPaneOpen}
|
||||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiPane={renderPOIPane()}
|
||||
overlayPaneOpen={overlayPaneOpen}
|
||||
onToggleOverlayPane={handleToggleOverlayPane}
|
||||
overlayPane={renderOverlayPane()}
|
||||
showSelectionPane={!!selectedHexagon}
|
||||
rightPaneWidth={rightPaneWidth}
|
||||
rightPaneHandlers={rightPaneHandlers}
|
||||
|
|
|
|||
81
frontend/src/components/map/OverlayPane.tsx
Normal file
81
frontend/src/components/map/OverlayPane.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { OVERLAYS, type OverlayId } from '../../lib/overlays';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { CloseIcon } from '../ui/icons';
|
||||
|
||||
interface OverlayPaneProps {
|
||||
selectedOverlays: Set<OverlayId>;
|
||||
onOverlaysChange: (overlays: Set<OverlayId>) => void;
|
||||
zoomedIn: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function OverlayPane({
|
||||
selectedOverlays,
|
||||
onOverlaysChange,
|
||||
zoomedIn,
|
||||
onClose,
|
||||
}: OverlayPaneProps) {
|
||||
const toggleOverlay = (overlay: OverlayId) => {
|
||||
const next = new Set(selectedOverlays);
|
||||
if (next.has(overlay)) {
|
||||
next.delete(overlay);
|
||||
} else {
|
||||
next.add(overlay);
|
||||
}
|
||||
onOverlaysChange(next);
|
||||
};
|
||||
|
||||
const selectNone = () => onOverlaysChange(new Set());
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
Overlays
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
{selectedOverlays.size}/{OVERLAYS.length}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="rounded border border-warm-300 px-2 py-0.5 text-xs text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Close"
|
||||
>
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!zoomedIn && (
|
||||
<div className="mt-2 rounded border border-warm-200 bg-warm-50 px-2 py-1.5 text-xs text-warm-500 dark:border-warm-700 dark:bg-navy-950 dark:text-warm-400">
|
||||
Zoom in to view overlays.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||
<PillGroup className="flex-wrap overflow-x-visible">
|
||||
{OVERLAYS.map((overlay) => (
|
||||
<PillToggle
|
||||
key={overlay.id}
|
||||
label={overlay.label}
|
||||
active={selectedOverlays.has(overlay.id)}
|
||||
onClick={() => toggleOverlay(overlay.id)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { useMemo, useState, useEffect, type MutableRefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Property } from '../../types';
|
||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||
import {
|
||||
formatDuration,
|
||||
formatAge,
|
||||
formatNumber,
|
||||
formatTransactionDate,
|
||||
formatYearMonth,
|
||||
} from '../../lib/format';
|
||||
import { getNum } from '../../lib/property-fields';
|
||||
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
|
|
@ -183,6 +189,11 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{t('propertyCard.exCouncilBadge')}
|
||||
</span>
|
||||
)}
|
||||
{property.listed_building === 'Yes' && (
|
||||
<span className="text-xs bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full px-1.5 py-0.5 font-medium leading-none">
|
||||
{ts('Listed building')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -239,6 +250,14 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{formatDuration(property.duration)}
|
||||
</div>
|
||||
)}
|
||||
{property.within_conservation_area && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">
|
||||
{t('propertyCard.withinConservationArea')}
|
||||
</span>{' '}
|
||||
{ts(property.within_conservation_area)}
|
||||
</div>
|
||||
)}
|
||||
{floorArea !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.floorArea')}</span>{' '}
|
||||
|
|
@ -273,24 +292,137 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
||||
{t('propertyCard.renovations')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.renovation_history.map((reno, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{reno.event}
|
||||
<span className="text-warm-500 dark:text-warm-400">{reno.year}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PropertyTimeline property={property} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineEvent =
|
||||
| { kind: 'sale'; year: number; month: number; price: number; sortKey: number }
|
||||
| { kind: 'reno'; year: number; event: string; sortKey: number }
|
||||
| { kind: 'built'; year: number; approximate: boolean; sortKey: number };
|
||||
|
||||
function buildTimelineEvents(property: Property): TimelineEvent[] {
|
||||
const events: TimelineEvent[] = [];
|
||||
|
||||
// Skip the most recent sale: it's already shown in the card headline.
|
||||
// historical_prices is sorted oldest→newest by the pipeline.
|
||||
const sales = property.historical_prices ?? [];
|
||||
const olderSales = sales.length > 0 ? sales.slice(0, -1) : [];
|
||||
for (const sale of olderSales) {
|
||||
events.push({
|
||||
kind: 'sale',
|
||||
year: sale.year,
|
||||
month: sale.month,
|
||||
price: sale.price,
|
||||
sortKey: sale.year + (Math.max(sale.month, 1) - 1) / 12,
|
||||
});
|
||||
}
|
||||
|
||||
for (const reno of property.renovation_history ?? []) {
|
||||
events.push({
|
||||
kind: 'reno',
|
||||
year: reno.year,
|
||||
event: reno.event,
|
||||
// Mid-year so renos sort between Jan and Dec sales of the same year.
|
||||
sortKey: reno.year + 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
const builtYear = getNum(property, 'Construction year');
|
||||
const approximate = property.is_construction_date_approximate ?? true;
|
||||
if (builtYear !== undefined && Number.isFinite(builtYear) && builtYear > 0) {
|
||||
const builtYearRounded = Math.round(builtYear);
|
||||
// New-builds (exact date from price-paid) duplicate the first sale's year.
|
||||
// Suppress the "Built" marker in that case since the sale carries the info.
|
||||
const newBuildDuplicate = !approximate && sales.some((sale) => sale.year === builtYearRounded);
|
||||
if (!newBuildDuplicate) {
|
||||
events.push({
|
||||
kind: 'built',
|
||||
year: builtYearRounded,
|
||||
approximate,
|
||||
sortKey: builtYear,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => b.sortKey - a.sortKey);
|
||||
return events;
|
||||
}
|
||||
|
||||
function PropertyTimeline({ property }: { property: Property }) {
|
||||
const { t } = useTranslation();
|
||||
const events = useMemo(() => buildTimelineEvents(property), [property]);
|
||||
if (events.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1.5">
|
||||
{t('propertyCard.historyTitle')}
|
||||
</div>
|
||||
<ol className="relative ml-1.5 border-l border-warm-200 dark:border-warm-700">
|
||||
{events.map((event, idx) => (
|
||||
<li key={idx} className="relative pl-3 pb-1.5 last:pb-0">
|
||||
<TimelineMarker kind={event.kind} />
|
||||
<div className="text-sm leading-tight">
|
||||
{event.kind === 'sale' && (
|
||||
<>
|
||||
<span className="font-semibold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(event.price)}
|
||||
</span>
|
||||
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
{formatYearMonth(event.year, event.month)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{event.kind === 'reno' && (
|
||||
<>
|
||||
<span className="text-warm-700 dark:text-warm-200">{event.event}</span>
|
||||
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
{event.year}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{event.kind === 'built' && (
|
||||
<>
|
||||
<span className="text-warm-700 dark:text-warm-200">
|
||||
{t('propertyCard.historyBuilt')}
|
||||
</span>
|
||||
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
{event.approximate ? `~${event.year}` : event.year}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineMarker({ kind }: { kind: TimelineEvent['kind'] }) {
|
||||
const base = 'absolute -left-[5px] top-1 w-2 h-2 rounded-full ring-2';
|
||||
if (kind === 'sale') {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${base} bg-teal-500 dark:bg-teal-400 ring-warm-50 dark:ring-warm-900`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === 'reno') {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${base} bg-warm-400 dark:bg-warm-500 ring-warm-50 dark:ring-warm-900`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${base} bg-warm-50 border border-warm-400 dark:bg-warm-900 dark:border-warm-500 ring-warm-50 dark:ring-warm-900`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ import { EyeIcon } from '../ui/icons/EyeIcon';
|
|||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||
import { formatFilterValue, formatNumber } from '../../lib/format';
|
||||
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
||||
import { MODE_ICONS, useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
|
||||
import {
|
||||
MAX_TRAVEL_MINUTES,
|
||||
MODE_ICONS,
|
||||
useTranslatedModes,
|
||||
type TransportMode,
|
||||
} from '../../hooks/useTravelTime';
|
||||
|
||||
interface TravelTimeCardProps {
|
||||
mode: TransportMode;
|
||||
|
|
@ -19,6 +24,8 @@ interface TravelTimeCardProps {
|
|||
label: string;
|
||||
timeRange: [number, number] | null;
|
||||
useBest: boolean;
|
||||
noChange: boolean;
|
||||
noBuses: boolean;
|
||||
isPinned: boolean;
|
||||
isActive: boolean;
|
||||
dragValue: [number, number] | null;
|
||||
|
|
@ -29,6 +36,8 @@ interface TravelTimeCardProps {
|
|||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onToggleBest: () => void;
|
||||
onToggleNoChange: () => void;
|
||||
onToggleNoBuses: () => void;
|
||||
onRemove: () => void;
|
||||
filterImpact?: number;
|
||||
destinationDropdownPortal?: boolean;
|
||||
|
|
@ -40,6 +49,8 @@ export function TravelTimeCard({
|
|||
label,
|
||||
timeRange,
|
||||
useBest,
|
||||
noChange,
|
||||
noBuses,
|
||||
isPinned,
|
||||
isActive,
|
||||
dragValue,
|
||||
|
|
@ -50,6 +61,8 @@ export function TravelTimeCard({
|
|||
onDragChange,
|
||||
onDragEnd,
|
||||
onToggleBest,
|
||||
onToggleNoChange,
|
||||
onToggleNoBuses,
|
||||
onRemove,
|
||||
filterImpact,
|
||||
destinationDropdownPortal = true,
|
||||
|
|
@ -59,6 +72,8 @@ export function TravelTimeCard({
|
|||
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [showBestInfo, setShowBestInfo] = useState(false);
|
||||
const [showNoChangeInfo, setShowNoChangeInfo] = useState(false);
|
||||
const [showNoBusesInfo, setShowNoBusesInfo] = useState(false);
|
||||
|
||||
const handleDestinationSelect = useCallback(
|
||||
(selectedSlug: string, selectedLabel: string, lat: number, lon: number) => {
|
||||
|
|
@ -68,7 +83,7 @@ export function TravelTimeCard({
|
|||
);
|
||||
|
||||
const sliderMin = 0;
|
||||
const sliderMax = 120;
|
||||
const sliderMax = MAX_TRAVEL_MINUTES;
|
||||
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
||||
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
|
|
@ -116,18 +131,42 @@ export function TravelTimeCard({
|
|||
portal={destinationDropdownPortal}
|
||||
/>
|
||||
|
||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||
{/* Transit-only toggles — shown when destination is set */}
|
||||
{slug && mode === 'transit' && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PillToggle
|
||||
label={t('travel.bestCase')}
|
||||
active={useBest}
|
||||
onClick={onToggleBest}
|
||||
size="xs"
|
||||
/>
|
||||
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</IconButton>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PillToggle
|
||||
label={t('travel.bestCase')}
|
||||
active={useBest}
|
||||
onClick={onToggleBest}
|
||||
size="xs"
|
||||
/>
|
||||
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PillToggle
|
||||
label={t('travel.noChange')}
|
||||
active={noChange}
|
||||
onClick={onToggleNoChange}
|
||||
size="xs"
|
||||
/>
|
||||
<IconButton onClick={() => setShowNoChangeInfo(true)} title={t('travel.noChangeTitle')}>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PillToggle
|
||||
label={t('travel.noBuses')}
|
||||
active={noBuses}
|
||||
onClick={onToggleNoBuses}
|
||||
size="xs"
|
||||
/>
|
||||
<IconButton onClick={() => setShowNoBusesInfo(true)} title={t('travel.noBusesTitle')}>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -141,6 +180,22 @@ export function TravelTimeCard({
|
|||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{showNoChangeInfo && (
|
||||
<InfoPopup title={t('travel.noChangeTitle')} onClose={() => setShowNoChangeInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
<Trans i18nKey="travel.noChangeDesc" components={{ strong: <strong /> }} />
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{showNoBusesInfo && (
|
||||
<InfoPopup title={t('travel.noBusesTitle')} onClose={() => setShowNoBusesInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
<Trans i18nKey="travel.noBusesDesc" components={{ strong: <strong /> }} />
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{/* Time range slider — only show when we have data */}
|
||||
{slug && (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ interface ActiveFilterListProps {
|
|||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeDragEnd: (index: number) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
onTravelTimeToggleNoChange: (index: number) => void;
|
||||
onTravelTimeToggleNoBuses: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ActiveFilterList({
|
||||
|
|
@ -85,6 +87,8 @@ export function ActiveFilterList({
|
|||
onTravelTimeRangeChange,
|
||||
onTravelTimeDragEnd,
|
||||
onTravelTimeToggleBest,
|
||||
onTravelTimeToggleNoChange,
|
||||
onTravelTimeToggleNoBuses,
|
||||
}: ActiveFilterListProps) {
|
||||
const travelCards = (
|
||||
<TravelTimeFilterCards
|
||||
|
|
@ -100,6 +104,8 @@ export function ActiveFilterList({
|
|||
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
||||
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
||||
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
||||
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
|
||||
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ interface ActiveFiltersPanelProps {
|
|||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeDragEnd: (index: number) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
onTravelTimeToggleNoChange: (index: number) => void;
|
||||
onTravelTimeToggleNoBuses: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ActiveFiltersPanel({
|
||||
|
|
@ -99,6 +101,8 @@ export function ActiveFiltersPanel({
|
|||
onTravelTimeRangeChange,
|
||||
onTravelTimeDragEnd,
|
||||
onTravelTimeToggleBest,
|
||||
onTravelTimeToggleNoChange,
|
||||
onTravelTimeToggleNoBuses,
|
||||
}: ActiveFiltersPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -201,6 +205,8 @@ export function ActiveFiltersPanel({
|
|||
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
||||
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
||||
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
||||
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
|
||||
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ interface TravelTimeFilterCardsProps {
|
|||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeDragEnd: (index: number) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
onTravelTimeToggleNoChange: (index: number) => void;
|
||||
onTravelTimeToggleNoBuses: (index: number) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
}
|
||||
|
|
@ -37,6 +39,8 @@ export function TravelTimeFilterCards({
|
|||
onTravelTimeRangeChange,
|
||||
onTravelTimeDragEnd,
|
||||
onTravelTimeToggleBest,
|
||||
onTravelTimeToggleNoChange,
|
||||
onTravelTimeToggleNoBuses,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
}: TravelTimeFilterCardsProps) {
|
||||
|
|
@ -52,6 +56,8 @@ export function TravelTimeFilterCards({
|
|||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
noChange={entry.noChange ?? false}
|
||||
noBuses={entry.noBuses ?? false}
|
||||
isPinned={pinnedFeature === fieldKey}
|
||||
isActive={activeFeature === fieldKey}
|
||||
dragValue={activeFeature === fieldKey ? dragValue : null}
|
||||
|
|
@ -64,6 +70,8 @@ export function TravelTimeFilterCards({
|
|||
onDragChange={onDragChange}
|
||||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onToggleNoChange={() => onTravelTimeToggleNoChange(index)}
|
||||
onToggleNoBuses={() => onTravelTimeToggleNoBuses(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
filterImpact={filterImpacts?.[fieldKey]}
|
||||
destinationDropdownPortal={destinationDropdownPortal}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ import type { useMapData } from '../../../hooks/useMapData';
|
|||
import type { useTutorial } from '../../../hooks/useTutorial';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import type { SearchedLocation } from '../LocationSearch';
|
||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
|
|
@ -35,6 +37,7 @@ interface DesktopMapPageProps {
|
|||
filtersPane: ReactNode;
|
||||
mapData: MapData;
|
||||
pois: POI[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
|
|
@ -59,6 +62,9 @@ interface DesktopMapPageProps {
|
|||
poiPaneOpen: boolean;
|
||||
onTogglePoiPane: () => void;
|
||||
poiPane: ReactNode;
|
||||
overlayPaneOpen: boolean;
|
||||
onToggleOverlayPane: () => void;
|
||||
overlayPane: ReactNode;
|
||||
showSelectionPane: boolean;
|
||||
rightPaneWidth: number;
|
||||
rightPaneHandlers: PaneResizeHandlers;
|
||||
|
|
@ -81,6 +87,7 @@ export function DesktopMapPage({
|
|||
filtersPane,
|
||||
mapData,
|
||||
pois,
|
||||
activeOverlays,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
|
|
@ -105,6 +112,9 @@ export function DesktopMapPage({
|
|||
poiPaneOpen,
|
||||
onTogglePoiPane,
|
||||
poiPane,
|
||||
overlayPaneOpen,
|
||||
onToggleOverlayPane,
|
||||
overlayPane,
|
||||
showSelectionPane,
|
||||
rightPaneWidth,
|
||||
rightPaneHandlers,
|
||||
|
|
@ -168,6 +178,7 @@ export function DesktopMapPage({
|
|||
postcodeData={mapData.postcodeData}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
@ -197,16 +208,30 @@ export function DesktopMapPage({
|
|||
totalCount={totalCount}
|
||||
/>
|
||||
</Suspense>
|
||||
<button
|
||||
data-tutorial="poi-button"
|
||||
onClick={onTogglePoiPane}
|
||||
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
|
||||
</button>
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col items-end gap-2">
|
||||
<button
|
||||
onClick={onToggleOverlayPane}
|
||||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||
<span className="text-sm font-medium">Overlays</span>
|
||||
</button>
|
||||
<button
|
||||
data-tutorial="poi-button"
|
||||
onClick={onTogglePoiPane}
|
||||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{overlayPaneOpen && (
|
||||
<div className="absolute bottom-28 right-4 z-10 flex h-[220px] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{overlayPane}
|
||||
</div>
|
||||
)}
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
<div className="absolute bottom-28 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{poiPane}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ import type {
|
|||
} from '../../../types';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import type { SearchedLocation } from '../LocationSearch';
|
||||
import MobileBottomSheet from '../MobileBottomSheet';
|
||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
|
|
@ -26,6 +28,7 @@ interface MobileMapPageProps {
|
|||
initialLoading: boolean;
|
||||
mapData: MapData;
|
||||
pois: POI[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
|
|
@ -56,6 +59,9 @@ interface MobileMapPageProps {
|
|||
onTogglePoiPane: () => void;
|
||||
poiButtonLabel: string;
|
||||
poiPane: ReactNode;
|
||||
overlayPaneOpen: boolean;
|
||||
onToggleOverlayPane: () => void;
|
||||
overlayPane: ReactNode;
|
||||
filtersPane: ReactNode;
|
||||
mobileLegend: ReactNode;
|
||||
renderAreaPane: () => ReactNode;
|
||||
|
|
@ -69,6 +75,7 @@ export function MobileMapPage({
|
|||
initialLoading,
|
||||
mapData,
|
||||
pois,
|
||||
activeOverlays,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
|
|
@ -99,6 +106,9 @@ export function MobileMapPage({
|
|||
onTogglePoiPane,
|
||||
poiButtonLabel,
|
||||
poiPane,
|
||||
overlayPaneOpen,
|
||||
onToggleOverlayPane,
|
||||
overlayPane,
|
||||
filtersPane,
|
||||
mobileLegend,
|
||||
renderAreaPane,
|
||||
|
|
@ -119,6 +129,7 @@ export function MobileMapPage({
|
|||
postcodeData={mapData.postcodeData}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
@ -150,16 +161,31 @@ export function MobileMapPage({
|
|||
</Suspense>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onTogglePoiPane}
|
||||
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
aria-label={poiButtonLabel}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="absolute right-3 top-3 z-20 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={onToggleOverlayPane}
|
||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||
aria-label="Overlays"
|
||||
>
|
||||
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePoiPane}
|
||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||
aria-label={poiButtonLabel}
|
||||
>
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{overlayPaneOpen && (
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex h-[220px] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{overlayPane}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute top-14 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{poiPane}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Suspense } from 'react';
|
|||
import type { FeatureMeta, ViewState } from '../../../types';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import { MapFallback } from './Fallbacks';
|
||||
import { Map } from './lazyComponents';
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ interface ScreenshotMapPageProps {
|
|||
theme: 'light' | 'dark';
|
||||
ogMode?: boolean;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
}
|
||||
|
||||
export function ScreenshotMapPage({
|
||||
|
|
@ -30,6 +32,7 @@ export function ScreenshotMapPage({
|
|||
theme,
|
||||
ogMode,
|
||||
travelTimeEntries,
|
||||
activeOverlays,
|
||||
}: ScreenshotMapPageProps) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
|
@ -39,6 +42,7 @@ export function ScreenshotMapPage({
|
|||
postcodeData={mapData.postcodeData}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={[]}
|
||||
activeOverlays={activeOverlays}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { cellToLatLng } from 'h3-js';
|
|||
import type { FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../../types';
|
||||
import type { HexagonLocation } from '../../../lib/external-search';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
|
||||
import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
|
||||
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
|
||||
|
|
@ -33,7 +33,9 @@ export function getMapPageBackendFeatureName(featureName: string): string {
|
|||
export function useJourneyDestination(entries: TravelTimeEntry[]) {
|
||||
return useMemo(() => {
|
||||
const entry = entries.find((item) => item.mode === 'transit' && item.slug);
|
||||
return entry ? { mode: entry.mode, slug: entry.slug } : null;
|
||||
// The server needs the resolved variant (e.g. transit-no-change-no-bus)
|
||||
// to look up the correct parquet directory, not the UI-side base mode.
|
||||
return entry ? { mode: resolveTransitVariant(entry), slug: entry.slug } : null;
|
||||
}, [entries]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { lazy } from 'react';
|
|||
export const Map = lazy(() => import('../Map'));
|
||||
export const Filters = lazy(() => import('../Filters'));
|
||||
export const POIPane = lazy(() => import('../POIPane'));
|
||||
export const OverlayPane = lazy(() => import('../OverlayPane'));
|
||||
export const AreaPane = lazy(() => import('../AreaPane'));
|
||||
export const PropertiesPane = lazy(() =>
|
||||
import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
ViewState,
|
||||
} from '../../../types';
|
||||
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import type { Page } from '../../ui/Header';
|
||||
import type { PointerEvent } from 'react';
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ export interface MapPageProps {
|
|||
initialFilters: FeatureFilters;
|
||||
initialViewState: ViewState;
|
||||
initialPOICategories: Set<string>;
|
||||
initialOverlays?: Set<OverlayId>;
|
||||
initialTab: 'properties' | 'area';
|
||||
initialLoading: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
|
|||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
|
||||
import { trackEvent } from '../../../lib/analytics';
|
||||
import type { ExportNotice, ExportState } from './types';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { buildTravelParam, dedupeTravelTimeEntries } from '../../../lib/travel-params';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
|
||||
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
|
||||
const EXPORT_TIMEOUT_MS = 150_000;
|
||||
|
|
@ -70,7 +71,8 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
|
|||
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
|
||||
for (const entry of dedupeTravelTimeEntries(entries)) {
|
||||
if (!entry.slug) continue;
|
||||
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||
const serverMode = resolveTransitVariant(entry);
|
||||
let value = `${serverMode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||
if (entry.useBest) value += ':b';
|
||||
if (entry.timeRange) {
|
||||
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
|
|
@ -84,6 +86,7 @@ interface UseExportControllerOptions {
|
|||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
selectedOverlays: Set<OverlayId>;
|
||||
shareCode?: string;
|
||||
t: TFunction;
|
||||
onExportStateChange?: (state: ExportState) => void;
|
||||
|
|
@ -94,6 +97,7 @@ export function useExportController({
|
|||
filters,
|
||||
features,
|
||||
travelTimeEntries,
|
||||
selectedOverlays,
|
||||
shareCode,
|
||||
t,
|
||||
onExportStateChange,
|
||||
|
|
@ -147,6 +151,9 @@ export function useExportController({
|
|||
const travelParam = buildTravelParam(travelTimeEntries);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
appendTravelStateParams(params, travelTimeEntries);
|
||||
for (const overlay of selectedOverlays) {
|
||||
params.append('overlay', overlay);
|
||||
}
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
const url = apiUrl('export', params);
|
||||
|
||||
|
|
@ -189,6 +196,7 @@ export function useExportController({
|
|||
exporting,
|
||||
features,
|
||||
filters,
|
||||
selectedOverlays,
|
||||
shareCode,
|
||||
showExportNotice,
|
||||
t,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function PillToggle({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={indeterminate ? 'mixed' : active}
|
||||
className={`${sizeClasses} ${colorClasses} inline-flex max-w-full shrink-0 items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`}
|
||||
>
|
||||
{icon}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { FeatureFilters } from '../types';
|
||||
import type { TransportMode } from './useTravelTime';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||
|
||||
export interface AiTravelTimeFilter {
|
||||
mode: TransportMode;
|
||||
/**
|
||||
* Server-side mode string. May be a base mode (car|bicycle|walking|transit)
|
||||
* or a transit variant (transit-no-bus, transit-no-change, …). Callers must
|
||||
* normalise via parseServerMode before constructing a TravelTimeEntry.
|
||||
*/
|
||||
mode: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
min?: number;
|
||||
|
|
@ -132,7 +136,7 @@ export function useAiFilters(): UseAiFiltersResult {
|
|||
const json = await response.json();
|
||||
const travelTimeFilters: AiTravelTimeFilter[] = (json.travel_time_filters || []).map(
|
||||
(tt: { mode: string; slug: string; label: string; min?: number; max?: number }) => ({
|
||||
mode: tt.mode as TransportMode,
|
||||
mode: tt.mode,
|
||||
slug: tt.slug,
|
||||
label: tt.label,
|
||||
min: tt.min,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ describe('usePoiLayers', () => {
|
|||
width: 96,
|
||||
height: 48,
|
||||
});
|
||||
expect(getSize(waitrose)).toBe(24);
|
||||
expect(getSize(waitrose)).toBe(14);
|
||||
});
|
||||
|
||||
it('prefers POI fascia icon categories for map marker icons', () => {
|
||||
|
|
@ -98,7 +98,7 @@ describe('usePoiLayers', () => {
|
|||
width: 96,
|
||||
height: 48,
|
||||
});
|
||||
expect(getSize(foodWarehouse)).toBe(24);
|
||||
expect(getSize(foodWarehouse)).toBe(14);
|
||||
});
|
||||
|
||||
it('keeps generic emoji POIs at the compact marker size', () => {
|
||||
|
|
@ -114,7 +114,7 @@ describe('usePoiLayers', () => {
|
|||
width: 72,
|
||||
height: 72,
|
||||
});
|
||||
expect(getSize(supermarket)).toBe(18);
|
||||
expect(getSize(supermarket)).toBe(11);
|
||||
});
|
||||
|
||||
it('hides the circular marker badge behind bundled logo icons', () => {
|
||||
|
|
@ -129,12 +129,12 @@ describe('usePoiLayers', () => {
|
|||
const getLineColor = backgroundLayer.props.getLineColor as (poi: POI) => PoiColor;
|
||||
|
||||
expect(getShadowRadius(waitrose)).toBe(0);
|
||||
expect(getBackgroundRadius(waitrose)).toBe(24);
|
||||
expect(getBackgroundRadius(waitrose)).toBe(14);
|
||||
expect(getFillColor(waitrose)).toEqual([0, 0, 0, 0]);
|
||||
expect(getLineColor(waitrose)).toEqual([0, 0, 0, 0]);
|
||||
|
||||
expect(getShadowRadius(supermarket)).toBe(16);
|
||||
expect(getBackgroundRadius(supermarket)).toBe(14);
|
||||
expect(getShadowRadius(supermarket)).toBe(10);
|
||||
expect(getBackgroundRadius(supermarket)).toBe(8);
|
||||
expect(getFillColor(supermarket)).toEqual([255, 255, 255, 255]);
|
||||
expect(getLineColor(supermarket)).toEqual([34, 197, 94, 255]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { PickingInfo } from '@deck.gl/core';
|
|||
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import Supercluster from 'supercluster';
|
||||
|
||||
import type { POI } from '../types';
|
||||
import type { POI, SchoolMetadata } from '../types';
|
||||
import {
|
||||
POI_GROUP_COLORS,
|
||||
MINOR_POI_CATEGORIES,
|
||||
|
|
@ -24,6 +24,7 @@ export interface PopupInfo {
|
|||
id: string;
|
||||
isCluster?: boolean;
|
||||
clusterCount?: number;
|
||||
school?: SchoolMetadata;
|
||||
}
|
||||
|
||||
interface ClusterPoint {
|
||||
|
|
@ -60,7 +61,7 @@ function getPoiGroupColor(group: string): [number, number, number] {
|
|||
}
|
||||
|
||||
function getPoiIconSize(poi: POI): number {
|
||||
return hasBundledPoiLogo(poi) ? 24 : 18;
|
||||
return hasBundledPoiLogo(poi) ? 14 : 11;
|
||||
}
|
||||
|
||||
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||
|
|
@ -77,6 +78,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
group: info.object.group,
|
||||
emoji: info.object.emoji,
|
||||
id: info.object.id,
|
||||
school: info.object.school,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
|
|
@ -162,7 +164,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
id: 'poi-shadow',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => (hasBundledPoiLogo(d) ? 0 : 16),
|
||||
getRadius: (d) => (hasBundledPoiLogo(d) ? 0 : 10),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
|
||||
pickable: false,
|
||||
|
|
@ -177,7 +179,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
id: 'poi-background',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => (hasBundledPoiLogo(d) ? 24 : 14),
|
||||
getRadius: (d) => (hasBundledPoiLogo(d) ? 14 : 8),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: (d) =>
|
||||
hasBundledPoiLogo(d)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { logNonAbortError } from '../lib/api';
|
||||
import type { TransportMode } from './useTravelTime';
|
||||
import { TRANSPORT_MODES, type TransportMode } from './useTravelTime';
|
||||
|
||||
/**
|
||||
* Server may report transit variants (transit-no-bus, transit-no-change, …)
|
||||
* alongside the four base modes. The UI mode picker only exposes the base modes;
|
||||
* the transit variants are surfaced via toggles on a transit entry. This typing
|
||||
* keeps the data model honest: the server speaks strings, we narrow at the edge.
|
||||
*/
|
||||
interface TravelModeInfo {
|
||||
mode: TransportMode;
|
||||
mode: string;
|
||||
destinations: number;
|
||||
}
|
||||
|
||||
function isBaseMode(mode: string): mode is TransportMode {
|
||||
return (TRANSPORT_MODES as readonly string[]).includes(mode);
|
||||
}
|
||||
|
||||
/** Fetches which transport modes have precomputed travel time data. */
|
||||
export function useTravelModes() {
|
||||
const [availableModes, setAvailableModes] = useState<Set<TransportMode> | null>(null);
|
||||
|
|
@ -20,9 +30,19 @@ export function useTravelModes() {
|
|||
return res.json();
|
||||
})
|
||||
.then((data: { modes: TravelModeInfo[] }) => {
|
||||
const modes = new Set<TransportMode>(
|
||||
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode)
|
||||
);
|
||||
const modes = new Set<TransportMode>();
|
||||
let anyTransitVariantHasData = false;
|
||||
for (const m of data.modes) {
|
||||
if (m.destinations <= 0) continue;
|
||||
if (isBaseMode(m.mode)) {
|
||||
modes.add(m.mode);
|
||||
} else if (m.mode.startsWith('transit-')) {
|
||||
// Variant directories ensure the transit mode is reachable even if
|
||||
// someone deletes the base `transit/` parquet folder by mistake.
|
||||
anyTransitVariantHasData = true;
|
||||
}
|
||||
}
|
||||
if (anyTransitVariantHasData) modes.add('transit');
|
||||
setAvailableModes(modes);
|
||||
})
|
||||
.catch((err) => logNonAbortError('travel modes', err));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { travelFieldKey, useTravelTime, type TravelTimeEntry } from './useTravelTime';
|
||||
import {
|
||||
parseServerMode,
|
||||
resolveTransitVariant,
|
||||
travelFieldKey,
|
||||
useTravelTime,
|
||||
type TravelTimeEntry,
|
||||
} from './useTravelTime';
|
||||
|
||||
describe('useTravelTime', () => {
|
||||
it('creates backend field keys from mode and destination slug', () => {
|
||||
|
|
@ -21,7 +27,15 @@ describe('useTravelTime', () => {
|
|||
|
||||
act(() => result.current.handleAddEntry('transit'));
|
||||
expect(result.current.entries).toEqual([
|
||||
{ mode: 'transit', slug: '', label: '', timeRange: null, useBest: false },
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: '',
|
||||
label: '',
|
||||
timeRange: null,
|
||||
useBest: false,
|
||||
noChange: false,
|
||||
noBuses: false,
|
||||
},
|
||||
]);
|
||||
expect(result.current.activeEntries).toEqual([]);
|
||||
|
||||
|
|
@ -29,7 +43,7 @@ describe('useTravelTime', () => {
|
|||
expect(result.current.entries[0]).toMatchObject({
|
||||
slug: 'bank',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 120],
|
||||
timeRange: [0, 90],
|
||||
});
|
||||
expect(result.current.activeEntries).toHaveLength(1);
|
||||
|
||||
|
|
@ -112,4 +126,99 @@ describe('useTravelTime', () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('toggles noChange and noBuses independently', () => {
|
||||
const { result } = renderHook(() => useTravelTime());
|
||||
act(() => result.current.handleAddEntry('transit'));
|
||||
act(() => result.current.handleSetDestination(0, 'bank', 'Bank'));
|
||||
|
||||
expect(result.current.entries[0]).toMatchObject({ noChange: false, noBuses: false });
|
||||
|
||||
act(() => result.current.handleToggleNoChange(0));
|
||||
expect(result.current.entries[0]).toMatchObject({ noChange: true, noBuses: false });
|
||||
|
||||
act(() => result.current.handleToggleNoBuses(0));
|
||||
expect(result.current.entries[0]).toMatchObject({ noChange: true, noBuses: true });
|
||||
|
||||
act(() => result.current.handleToggleNoChange(0));
|
||||
expect(result.current.entries[0]).toMatchObject({ noChange: false, noBuses: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTransitVariant', () => {
|
||||
const base: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 90],
|
||||
useBest: false,
|
||||
};
|
||||
|
||||
it('passes non-transit modes through unchanged', () => {
|
||||
expect(resolveTransitVariant({ ...base, mode: 'car' })).toBe('car');
|
||||
expect(resolveTransitVariant({ ...base, mode: 'bicycle' })).toBe('bicycle');
|
||||
expect(resolveTransitVariant({ ...base, mode: 'walking' })).toBe('walking');
|
||||
});
|
||||
|
||||
it('maps transit toggle combinations to the right variant string', () => {
|
||||
expect(resolveTransitVariant(base)).toBe('transit');
|
||||
expect(resolveTransitVariant({ ...base, noChange: true })).toBe('transit-no-change');
|
||||
expect(resolveTransitVariant({ ...base, noBuses: true })).toBe('transit-no-bus');
|
||||
expect(resolveTransitVariant({ ...base, noChange: true, noBuses: true })).toBe(
|
||||
'transit-no-change-no-bus'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats undefined flags as false', () => {
|
||||
expect(resolveTransitVariant({ ...base, noChange: undefined, noBuses: undefined })).toBe(
|
||||
'transit'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseServerMode', () => {
|
||||
it('round-trips the four toggle-reachable variants', () => {
|
||||
expect(parseServerMode('transit')).toEqual({ mode: 'transit', noChange: false, noBuses: false });
|
||||
expect(parseServerMode('transit-no-bus')).toEqual({
|
||||
mode: 'transit',
|
||||
noChange: false,
|
||||
noBuses: true,
|
||||
});
|
||||
expect(parseServerMode('transit-no-change')).toEqual({
|
||||
mode: 'transit',
|
||||
noChange: true,
|
||||
noBuses: false,
|
||||
});
|
||||
expect(parseServerMode('transit-no-change-no-bus')).toEqual({
|
||||
mode: 'transit',
|
||||
noChange: true,
|
||||
noBuses: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses non-transit base modes', () => {
|
||||
expect(parseServerMode('car')).toEqual({ mode: 'car', noChange: false, noBuses: false });
|
||||
expect(parseServerMode('bicycle')).toEqual({ mode: 'bicycle', noChange: false, noBuses: false });
|
||||
expect(parseServerMode('walking')).toEqual({ mode: 'walking', noChange: false, noBuses: false });
|
||||
});
|
||||
|
||||
it('returns null for variants the UI cannot represent (no silent broadening)', () => {
|
||||
expect(parseServerMode('transit-one-change')).toBeNull();
|
||||
expect(parseServerMode('transit-one-change-no-bus')).toBeNull();
|
||||
expect(parseServerMode('unknown-mode')).toBeNull();
|
||||
});
|
||||
|
||||
it('travelFieldKey uses the resolved variant', () => {
|
||||
expect(
|
||||
travelFieldKey({
|
||||
mode: 'transit',
|
||||
slug: 'bank',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 90],
|
||||
useBest: false,
|
||||
noChange: true,
|
||||
noBuses: true,
|
||||
})
|
||||
).toBe('tt_transit-no-change-no-bus_bank');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,13 +64,68 @@ export interface TravelTimeEntry {
|
|||
timeRange: [number, number] | null;
|
||||
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
|
||||
useBest: boolean;
|
||||
/** Restrict transit to walk-transit-walk (0 changes). Optional; defaults to false. */
|
||||
noChange?: boolean;
|
||||
/** Drop buses from the allowed transit modes. Optional; defaults to false. */
|
||||
noBuses?: boolean;
|
||||
}
|
||||
|
||||
/** Field key matching the backend response: tt_{mode}_{slug} */
|
||||
export function travelFieldKey(entry: TravelTimeEntry): string {
|
||||
return `tt_${entry.mode}_${entry.slug}`;
|
||||
/**
|
||||
* The Java pipeline emits 6 transit variants as separate parquet directories.
|
||||
* The UI represents transit with two toggle booleans; this maps the toggle
|
||||
* state back to the directory/mode name the server expects.
|
||||
*
|
||||
* For non-transit modes the entry.mode passes through unchanged.
|
||||
*
|
||||
* Note: the transit-one-change* variants exist server-side but are not reachable
|
||||
* from the UI toggles (only no-change + no-buses are exposed). They're available
|
||||
* via direct API access for callers that want them.
|
||||
*/
|
||||
export function resolveTransitVariant(entry: TravelTimeEntry): string {
|
||||
if (entry.mode !== 'transit') return entry.mode;
|
||||
const nc = entry.noChange ?? false;
|
||||
const nb = entry.noBuses ?? false;
|
||||
if (nc && nb) return 'transit-no-change-no-bus';
|
||||
if (nc) return 'transit-no-change';
|
||||
if (nb) return 'transit-no-bus';
|
||||
return 'transit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a server-side mode string (incl. transit variants) back into a base
|
||||
* TransportMode + UI toggle booleans. Returns null for mode strings the UI
|
||||
* cannot represent (currently: transit-one-change, transit-one-change-no-bus).
|
||||
* Callers should skip entries that parse to null rather than silently
|
||||
* normalising to a different variant.
|
||||
*/
|
||||
export function parseServerMode(
|
||||
modeStr: string
|
||||
): { mode: TransportMode; noChange: boolean; noBuses: boolean } | null {
|
||||
if (modeStr === 'car' || modeStr === 'bicycle' || modeStr === 'walking') {
|
||||
return { mode: modeStr, noChange: false, noBuses: false };
|
||||
}
|
||||
switch (modeStr) {
|
||||
case 'transit':
|
||||
return { mode: 'transit', noChange: false, noBuses: false };
|
||||
case 'transit-no-bus':
|
||||
return { mode: 'transit', noChange: false, noBuses: true };
|
||||
case 'transit-no-change':
|
||||
return { mode: 'transit', noChange: true, noBuses: false };
|
||||
case 'transit-no-change-no-bus':
|
||||
return { mode: 'transit', noChange: true, noBuses: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Field key matching the backend response: tt_{server-mode}_{slug} */
|
||||
export function travelFieldKey(entry: TravelTimeEntry): string {
|
||||
return `tt_${resolveTransitVariant(entry)}_${entry.slug}`;
|
||||
}
|
||||
|
||||
/** Slider/data ceiling (minutes). Mirrors MAX_TRIP_DURATION_MINUTES in the R5 pipeline. */
|
||||
export const MAX_TRAVEL_MINUTES = 90;
|
||||
|
||||
export interface TravelTimeInitial {
|
||||
entries?: TravelTimeEntry[];
|
||||
}
|
||||
|
|
@ -81,7 +136,10 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
);
|
||||
|
||||
const handleAddEntry = useCallback((mode: TransportMode) => {
|
||||
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ mode, slug: '', label: '', timeRange: null, useBest: false, noChange: false, noBuses: false },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveEntry = useCallback((index: number) => {
|
||||
|
|
@ -92,7 +150,9 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
setEntries((prev) =>
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
i === index
|
||||
? { ...entry, slug, label, timeRange: slug ? [0, MAX_TRAVEL_MINUTES] : null }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
@ -114,6 +174,26 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleNoChange = useCallback((index: number) => {
|
||||
setEntries((prev) =>
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, noChange: !(entry.noChange ?? false) } : entry
|
||||
)
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleNoBuses = useCallback((index: number) => {
|
||||
setEntries((prev) =>
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, noBuses: !(entry.noBuses ?? false) } : entry
|
||||
)
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
||||
setEntries(dedupeTravelTimeEntries(newEntries));
|
||||
}, []);
|
||||
|
|
@ -130,5 +210,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
handleSetEntries,
|
||||
handleTimeRangeChange,
|
||||
handleToggleBest,
|
||||
handleToggleNoChange,
|
||||
handleToggleNoBuses,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { stateToParams } from '../lib/url-state';
|
||||
import type { OverlayId } from '../lib/overlays';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
const URL_DEBOUNCE_MS = 300;
|
||||
|
|
@ -12,7 +13,8 @@ export function useUrlSync(
|
|||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'properties' | 'area',
|
||||
travelTimeEntries?: TravelTimeEntry[],
|
||||
share?: string
|
||||
share?: string,
|
||||
selectedOverlays?: Set<OverlayId>
|
||||
) {
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -28,7 +30,8 @@ export function useUrlSync(
|
|||
selectedPOICategories,
|
||||
rightPaneTab,
|
||||
travelTimeEntries,
|
||||
share
|
||||
share,
|
||||
selectedOverlays
|
||||
);
|
||||
const search = params.toString();
|
||||
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||
|
|
@ -46,5 +49,6 @@ export function useUrlSync(
|
|||
rightPaneTab,
|
||||
travelTimeEntries,
|
||||
share,
|
||||
selectedOverlays,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
|
||||
'Interior height (m)': 'Hauteur moyenne d’étage selon le diagnostic EPC',
|
||||
'Street tree density percentile': 'Percentile estimé de couverture arborée pour la rue du bien',
|
||||
'Within conservation area':
|
||||
'Indique si le point représentatif du code postal se trouve dans une zone de conservation',
|
||||
'Good+ primary schools within 2km':
|
||||
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
@ -124,6 +126,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Interior height (m)': 'Durchschnittliche Geschosshöhe laut EPC-Gutachten',
|
||||
'Street tree density percentile':
|
||||
'Geschätztes Perzentil der Baumkronenbedeckung auf der Straße der Immobilie',
|
||||
'Within conservation area':
|
||||
'Ob der repräsentative Punkt der Postleitzahl in einer Conservation Area liegt',
|
||||
'Good+ primary schools within 2km':
|
||||
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
@ -219,6 +223,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Potential energy rating': '实施所有建议改进后的潜在EPC评级',
|
||||
'Interior height (m)': 'EPC评估的平均层高',
|
||||
'Street tree density percentile': '该房产所在街道的估计树冠覆盖率百分位',
|
||||
'Within conservation area': '邮编代表点是否位于指定保护区内',
|
||||
'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
|
||||
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
||||
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
||||
|
|
@ -293,6 +298,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Potential energy rating': 'सभी सुझाए गए सुधार होने पर संभावित EPC रेटिंग',
|
||||
'Interior height (m)': 'EPC सर्वेक्षण के अनुसार औसत अंदरूनी ऊंचाई',
|
||||
'Street tree density percentile': 'संपत्ति वाली सड़क का अनुमानित वृक्ष आच्छादन प्रतिशतक',
|
||||
'Within conservation area': 'पोस्टकोड प्रतिनिधि बिंदु नामित संरक्षण क्षेत्र में है या नहीं',
|
||||
'Good+ primary schools within 2km':
|
||||
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
@ -379,6 +385,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
|
||||
'Street tree density percentile':
|
||||
'Az ingatlan utcájának becsült lombkorona-fedettségi percentilise',
|
||||
'Within conservation area':
|
||||
'Az irányítószám reprezentatív pontja kijelölt conservation area területre esik-e',
|
||||
'Good+ primary schools within 2km':
|
||||
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ export const details: Record<string, Record<string, string>> = {
|
|||
"Hauteur intérieure moyenne (sol au plafond) en mètres telle qu'enregistrée lors de l'évaluation du certificat de performance énergétique (EPC). Calculée en divisant le volume intérieur total par la surface habitable totale.",
|
||||
'Street tree density percentile':
|
||||
"Couverture arborée approximative autour du centroïde du code postal, dérivée de la carte Trees Outside Woodland 2025 de Forest Research. Les polygones de couvert arboré des arbres isolés et groupes d'arbres sont comptés dans un rayon de 50 m de chaque centroïde de code postal, puis convertis en percentile parmi les codes postaux anglais. Il s'agit d'un proxy de centroïde de code postal, pas d'une mesure exacte du bien ou du segment de rue.",
|
||||
'Within conservation area':
|
||||
"Limites de zones de conservation de Historic England, rattachées au point représentatif du code postal. Le jeu de données national est indicatif plutôt que définitif ; les décisions sensibles aux limites doivent être vérifiées auprès de l'autorité locale de planification.",
|
||||
'Good+ primary schools within 2km':
|
||||
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
@ -183,6 +185,8 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Durchschnittliche lichte Raumhöhe in Metern, wie während der Energieausweis-Begutachtung erfasst. Berechnet durch Division des gesamten Innenvolumens durch die Gesamtwohnfläche.',
|
||||
'Street tree density percentile':
|
||||
'Ungefähre Baumkronenbedeckung rund um den Postleitzahlen-Zentroiden aus der Forest-Research-Karte Trees Outside Woodland 2025. Baumkronen-Polygone für Einzelbäume und Baumgruppen werden im Umkreis von 50 m um jeden Postleitzahlen-Zentroiden gezählt und dann in ein Perzentil über englische Postleitzahlen umgerechnet. Dies ist ein Postleitzahlen-Zentroid-Proxy, keine exakte Messung für Immobilie oder Straßenabschnitt.',
|
||||
'Within conservation area':
|
||||
'Historic-England-Grenzen für Conservation Areas, dem repräsentativen Punkt der Postleitzahl zugeordnet. Der nationale Datensatz ist indikativ und nicht rechtsverbindlich; grenznahe Entscheidungen sollten bei der lokalen Planungsbehörde geprüft werden.',
|
||||
'Good+ primary schools within 2km':
|
||||
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
@ -329,6 +333,8 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'EPC评估期间记录的平均室内净高(米)。通过将室内总容积除以总建筑面积计算得出。',
|
||||
'Street tree density percentile':
|
||||
'基于 Forest Research 2025 年 Trees Outside Woodland 地图估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木和树群树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
|
||||
'Within conservation area':
|
||||
'Historic England 保护区边界,与邮编代表点匹配。全国数据集是指示性而非最终权威;涉及边界的决策应向地方规划部门核实。',
|
||||
'Good+ primary schools within 2km':
|
||||
'2km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
@ -467,6 +473,8 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'EPC आकलन के दौरान दर्ज औसत अंदरूनी फर्श-से-छत ऊंचाई, मीटर में. कुल आंतरिक आयतन को कुल फर्श क्षेत्र से भाग देकर निकाली जाती है.',
|
||||
'Street tree density percentile':
|
||||
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
|
||||
'Within conservation area':
|
||||
'Historic England संरक्षण क्षेत्र सीमाएं पोस्टकोड प्रतिनिधि बिंदु से मिलाई जाती हैं. राष्ट्रीय डेटासेट संकेतक है, अंतिम आधिकारिक नहीं; सीमा-संवेदनशील निर्णय स्थानीय योजना प्राधिकरण से जांचे जाने चाहिए.',
|
||||
'Good+ primary schools within 2km':
|
||||
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
@ -613,6 +621,8 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Az EPC-tanúsítvány felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
|
||||
'Street tree density percentile':
|
||||
'A Forest Research 2025-os Trees Outside Woodland térképéből származó hozzávetőleges lombkorona-fedettség az irányítószám-középpont körül. A magányos fák és facsoportok lombkorona-poligonjait minden irányítószám-középpont 50 méteres körzetében számoljuk, majd az angliai irányítószámok közötti percentilissé alakítjuk. Ez irányítószám-középponti proxy, nem pontos ingatlan- vagy utcaszakasz-mérés.',
|
||||
'Within conservation area':
|
||||
'Historic England conservation area határok az irányítószám reprezentatív pontjához rendelve. Az országos adatállomány tájékoztató jellegű, nem végleges; határérzékeny döntéseknél a helyi tervezési hatóság adatait kell ellenőrizni.',
|
||||
'Good+ primary schools within 2km':
|
||||
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||
'Good+ secondary schools within 2km':
|
||||
|
|
|
|||
|
|
@ -736,6 +736,14 @@ const de: Translations = {
|
|||
bestCaseTitle: 'Bestmögliche Reisezeit',
|
||||
bestCaseDesc:
|
||||
'Verwendet die schnellste realistische Reisezeit (bei guter Abfahrtsplanung und guten Anschlüssen). Standard ist der <strong>Median</strong>, der eine typische Fahrt unabhängig vom Abfahrtszeitpunkt darstellt.',
|
||||
noChange: 'Ohne Umstieg',
|
||||
noChangeTitle: 'Nur direkte Verbindungen',
|
||||
noChangeDesc:
|
||||
'Beschränkt auf Fahrten <strong>ohne Umstiege</strong> —gehen, in ein Verkehrsmittel einsteigen, zum Ziel gehen.',
|
||||
noBuses: 'Ohne Bus',
|
||||
noBusesTitle: 'Busse ausschließen',
|
||||
noBusesDesc:
|
||||
'Schließt Busse aus —nur <strong>Bahn, U-Bahn, Tram und Fähre</strong>. Praktisch, um staufreie Verbindungen zu finden.',
|
||||
previewOnMap: 'Auf Karte anzeigen',
|
||||
stopPreviewing: 'Vorschau beenden',
|
||||
removeTravelTime: 'Reisezeit entfernen',
|
||||
|
|
@ -807,6 +815,7 @@ const de: Translations = {
|
|||
type: 'Typ:',
|
||||
builtForm: 'Bauweise:',
|
||||
tenure: 'Besitzart:',
|
||||
withinConservationArea: 'In Erhaltungsgebiet:',
|
||||
floorArea: 'Wohnfläche:',
|
||||
rooms: 'Zimmer:',
|
||||
built: 'Baujahr:',
|
||||
|
|
@ -816,6 +825,9 @@ const de: Translations = {
|
|||
epcPotential: 'EPC-Potenzial:',
|
||||
renovations: 'Renovierungen',
|
||||
perSqm: '/m²',
|
||||
historyTitle: 'Verlauf',
|
||||
historySale: 'Verkauf',
|
||||
historyBuilt: 'Gebaut',
|
||||
searchPlaceholder: 'Nach Adresse oder Postleitzahl suchen...',
|
||||
propertyData: 'Immobiliendaten',
|
||||
propertyDataDesc:
|
||||
|
|
@ -1126,6 +1138,14 @@ const de: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Baumkronen-Polygone für Einzelbäume, Baumgruppen und kleine Gehölze in England. Hier verwendet, um Baumdeckungs-Perzentile rund um Postleitzahlen-Zentroide zu schätzen.',
|
||||
dsConservationAreasName: 'Historic England Erhaltungsgebiete',
|
||||
dsConservationAreasOrigin: 'Historic England und lokale Planungsbehörden',
|
||||
dsConservationAreasUse:
|
||||
'Grenzen ausgewiesener Conservation Areas in England. Wird genutzt, um zu kennzeichnen, ob der repräsentative Punkt einer Postleitzahl innerhalb eines solchen Gebiets liegt.',
|
||||
dsListedBuildingsName: 'Historic England denkmalgeschützte Gebäude',
|
||||
dsListedBuildingsOrigin: 'Historic England National Heritage List for England',
|
||||
dsListedBuildingsUse:
|
||||
'Punktdaten zu denkmalgeschützten Gebäuden in England. Wird genutzt, um Immobilien zu kennzeichnen, deren Adresse offenbar zu einem nahegelegenen Listeneintrag passt.',
|
||||
dsNaptanName: 'NaPTAN (Haltestellen des öffentlichen Verkehrs)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -1166,6 +1186,7 @@ const de: Translations = {
|
|||
faqWhyTitle: 'Warum Perfect Postcode',
|
||||
faqPricingTitle: 'Zugang',
|
||||
faqTipsTitle: 'Kartentipps',
|
||||
faqBehindDataTitle: 'Hinter den Daten',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: 'Wo soll ich suchen, wenn die offensichtlichen Gebiete zu teuer sind?',
|
||||
faqFinding1A:
|
||||
|
|
@ -1267,6 +1288,28 @@ const de: Translations = {
|
|||
faqTips3Q: 'Wie aktualisiere ich die Kartenfarben?',
|
||||
faqTips3A:
|
||||
'Wenn ein Merkmal die Karte einfärbt, nutzen Sie „Farbskala zurücksetzen“ in der Kartenlegende, um die Farben für die aktuell angezeigten Ergebnisse zu aktualisieren. Das ist nach Verschieben, Zoomen oder geänderten Filtern nützlich.',
|
||||
|
||||
// FAQ items — Behind The Data
|
||||
faqBehindData1Q: 'Warum wirkt ein Flughafen manchmal leiser als die Straßen drumherum?',
|
||||
faqBehindData1A:
|
||||
'Der Lärmwert einer Postleitzahl ist der lauteste der drei Defra-Quellen — Straße, Schiene und Flugzeug — modelliert in 4 m Höhe als 24-Stunden-Mittelwert (Lden). In einer belebten Wohnstraße dominiert der Straßenverkehr, typischerweise 65–75 dB. Innerhalb eines Flughafenzauns gibt es keine großen öffentlichen Straßen, also fällt der Straßenanteil weg, und nur der Flugzeug-Durchschnitt bleibt übrig. London City Airport etwa hat eine Nachtruhe und begrenzte Bewegungen, sodass sein 24-Stunden-Flugzeug-Lden moderat ist (ca. 60–66 dB an der Startbahn) — und das Flughafengelände erscheint leiser als die A-Straßen drumherum. Dasselbe gilt für Heathrow. Das ist ein echter Effekt der Messung von Verkehrslärm auf Wohn-Empfängerhöhe, kein Fehler.',
|
||||
faqBehindData2Q:
|
||||
'Warum erscheint der Flughafen, die Autobahn oder ein Park als eine große Fläche?',
|
||||
faqBehindData2A:
|
||||
'In Großbritannien haben Postleitzahlen offiziell keine Grenzen — Royal Mail definiert eine Postleitzahl als Liste von Zustelladressen, nicht als Fläche. Perfect Postcode erzeugt die Polygone, indem jeder Adresse ihr Anteil der umgebenden Fläche zugeordnet wird. Orte ohne Adressen, etwa eine Startbahn, eine Autobahnspur, ein Park oder ein Stausee, werden von der nächstgelegenen Wohn-Postleitzahl ausgefüllt. Daher erscheint ein Flughafen oder eine offene Fläche oft als ein einziges großes Polygon, und der angezeigte Wert stammt von den wenigen Postleitzahlen innerhalb des Geländes.',
|
||||
faqBehindData3Q: 'Warum zeigen benachbarte Postleitzahlen identische Kriminalitätszahlen?',
|
||||
faqBehindData3A:
|
||||
'Polizeilich erfasste Kriminalität auf Straßenebene wird auf LSOA-Ebene veröffentlicht — kleine Nachbarschaftsgebiete mit etwa 1.500 Einwohnern. Jede Postleitzahl innerhalb derselben LSOA übernimmt dieselben Jahreszahlen, sodass eine ruhige Wohnstraße und eine Hauptstraße einen Block entfernt identische Werte zeigen können, wenn sie auf derselben Seite der Grenze liegen. Die Pro-Kopf-Rate kann in Postleitzahlen mit Krankenhäusern, Universitätsgeländen oder Industriegebieten ungewöhnlich hoch wirken, weil dort viele Vorfälle gezählt werden, aber wenige Einwohner gemeldet sind.',
|
||||
faqBehindData4Q: 'Bedeutet „Gute Schulen in 2 km Umkreis“, dass mein Kind dorthin gehen kann?',
|
||||
faqBehindData4A:
|
||||
'Nein. Die Zählung sucht staatliche Schulen, deren eigene Postleitzahl in einem Kreis um Ihren Postleitzahl-Mittelpunkt liegt. Einzugsgebiete, konfessionelle oder selektive Aufnahmekriterien, Geschwisterregelungen und Anmeldebedingungen werden nicht modelliert — eine nahegelegene Gute oder Hervorragende Schule kann von Ihrer Adresse aus unerreichbar sein. Nutzen Sie die Zahl, um Gebiete zu vergleichen, und prüfen Sie tatsächliche Aufnahmebedingungen bei der Schule oder Gemeinde, bevor Sie sich darauf verlassen.',
|
||||
faqBehindData5Q:
|
||||
'Warum zeigt eine Postleitzahl „Gigabit“, wenn nicht jedes Haus Glasfaser hat?',
|
||||
faqBehindData5A:
|
||||
'Breitbandabdeckung aus Ofcom Connected Nations wird pro Postleitzahl als Prozentsatz der Wohnungen ausgewiesen, die jede Geschwindigkeitsstufe erreichen können. Wir zeigen die höchste Stufe mit irgendeiner Verfügbarkeit, also liest sich eine Postleitzahl als „Gigabit verfügbar“, wenn auch nur ein einziges Zuhause es bekommen kann. Das ist die richtige Antwort auf „gibt es überhaupt Glasfaser auf dieser Straße?“, aber keine Garantie, dass jede Wohnung im Block heute bestellbar ist. Prüfen Sie immer mit den Anbietern für Ihre konkrete Adresse, bevor Sie unterschreiben.',
|
||||
faqBehindData6Q: 'Warum ändern sich die ÖPNV-Zeiten nicht für Abende oder Wochenenden?',
|
||||
faqBehindData6A:
|
||||
'ÖPNV-Zeiten werden pro Ziel einmal für ein Dienstag-Morgen-Zeitfenster (07:30–08:30) anhand vollständiger GTFS-Fahrpläne berechnet. Der „normale“ Wert ist die Median-Fahrt in diesem Fenster, der „Bestfall“ das 5. Perzentil. Nebenzeiten, Spätabende und Wochenenden werden nicht modelliert, sodass eine Postleitzahl mit nur einem Hauptzeit-Bus auf der Karte trotzdem gut angebunden aussehen kann. Verstehen Sie die Zahlen als wochentägliche Pendel-Schätzung, nicht als Tagesdurchschnitt.',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
|
|
@ -1420,6 +1463,8 @@ const de: Translations = {
|
|||
'Potential energy rating': 'Potenzielle Energiebewertung',
|
||||
'Interior height (m)': 'Raumhöhe (m)',
|
||||
'Street tree density percentile': 'Perzentil der Straßenbaumdichte',
|
||||
'Within conservation area': 'In Erhaltungsgebiet',
|
||||
'Listed building': 'Denkmalgeschütztes Gebäude',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Travel time to nearest train or tube station (min)':
|
||||
|
|
|
|||
|
|
@ -712,6 +712,14 @@ const en = {
|
|||
bestCaseTitle: 'Best case travel time',
|
||||
bestCaseDesc:
|
||||
'Uses the fastest realistic journey time (if you time your departure well and catch good connections). The default uses the <strong>median</strong>, representing a typical journey regardless of when you leave.',
|
||||
noChange: 'No change',
|
||||
noChangeTitle: 'No-change journeys only',
|
||||
noChangeDesc:
|
||||
'Restricts to journeys with <strong>no transfers</strong> —walk, board one transit service, then walk to the destination. Useful when you want a single straight-through commute.',
|
||||
noBuses: 'No buses',
|
||||
noBusesTitle: 'Excluding buses',
|
||||
noBusesDesc:
|
||||
'Drops bus services from the allowed transit modes —<strong>rail, tube, tram and ferry only</strong>. Helpful for filtering to journeys that avoid traffic delays.',
|
||||
previewOnMap: 'Preview on map',
|
||||
stopPreviewing: 'Stop previewing',
|
||||
removeTravelTime: 'Remove travel time',
|
||||
|
|
@ -783,6 +791,7 @@ const en = {
|
|||
type: 'Type:',
|
||||
builtForm: 'Built form:',
|
||||
tenure: 'Tenure:',
|
||||
withinConservationArea: 'Within conservation area:',
|
||||
floorArea: 'Floor area:',
|
||||
rooms: 'Rooms:',
|
||||
built: 'Built:',
|
||||
|
|
@ -792,6 +801,9 @@ const en = {
|
|||
epcPotential: 'EPC potential:',
|
||||
renovations: 'Renovations',
|
||||
perSqm: '/m²',
|
||||
historyTitle: 'History',
|
||||
historySale: 'Sale',
|
||||
historyBuilt: 'Built',
|
||||
searchPlaceholder: 'Search by address or postcode...',
|
||||
propertyData: 'Property Data',
|
||||
propertyDataDesc:
|
||||
|
|
@ -1095,6 +1107,14 @@ const en = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Tree canopy polygons for lone trees, groups of trees, and small woodlands in England. Used here to estimate tree coverage percentiles around postcode centroids.',
|
||||
dsConservationAreasName: 'Historic England Conservation Areas',
|
||||
dsConservationAreasOrigin: 'Historic England and local planning authorities',
|
||||
dsConservationAreasUse:
|
||||
'Designated conservation area boundaries for England. Used to flag whether a postcode representative point falls within a conservation area.',
|
||||
dsListedBuildingsName: 'Historic England Listed Buildings',
|
||||
dsListedBuildingsOrigin: 'Historic England National Heritage List for England',
|
||||
dsListedBuildingsUse:
|
||||
'Listed-building point records for England. Used to flag properties whose address appears to match a nearby listed-building entry.',
|
||||
dsNaptanName: 'NaPTAN (Public Transport Stops)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -1135,6 +1155,7 @@ const en = {
|
|||
faqWhyTitle: 'Why Perfect Postcode',
|
||||
faqPricingTitle: 'Access',
|
||||
faqTipsTitle: 'Map Tips',
|
||||
faqBehindDataTitle: 'Behind The Data',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: 'Where should I look once the obvious areas are too expensive?',
|
||||
faqFinding1A:
|
||||
|
|
@ -1234,6 +1255,26 @@ const en = {
|
|||
faqTips3Q: 'How do I refresh the map colours?',
|
||||
faqTips3A:
|
||||
'When a feature is colouring the map, use Reset colour scale in the map legend to refresh the colours for the results you’re looking at now. This is useful after moving the map, zooming, or changing filters.',
|
||||
|
||||
// FAQ items — Behind The Data
|
||||
faqBehindData1Q: 'Why does an airport sometimes look quieter than the streets around it?',
|
||||
faqBehindData1A:
|
||||
'The noise figure shown for a postcode is the loudest of three Defra sources — road, rail, and aircraft — modelled at 4m above ground as a 24-hour weighted average (Lden). On a busy residential street the road component dominates, typically 65–75 dB. Inside an airport perimeter there are no major public roads, so the road term drops and only the aircraft average is left. London City Airport has a curfew and limited movements, so its 24-hour aircraft Lden is moderate (around 60–66 dB at the runway), and the airfield ends up looking quieter than the A-roads that flank it. The same effect appears at Heathrow. It’s a real artefact of measuring transport noise at residential receptor height, not a bug.',
|
||||
faqBehindData2Q: 'Why does the airport, motorway or park show up as one big shape?',
|
||||
faqBehindData2A:
|
||||
'Postcodes don’t officially have boundaries in the UK — Royal Mail defines a postcode as a list of delivery addresses, not as an area. Perfect Postcode synthesises the polygons by giving each address its share of the surrounding land. Places with no addresses, such as a runway, motorway carriageway, park or reservoir, get filled in by whichever nearby residential postcode is closest. That is why an airport or open space often appears as a single large polygon rather than many small ones, and the value shown for that polygon comes from the handful of postcodes that happen to sit inside the perimeter.',
|
||||
faqBehindData3Q: 'Why do nearby postcodes share the same crime numbers?',
|
||||
faqBehindData3A:
|
||||
'Police-recorded street-level crime is published at LSOA level — small neighbourhood areas of about 1,500 residents. Every postcode inside the same LSOA inherits the same yearly totals, so a quiet residential street and a high street one block over can show identical figures if they fall on the same side of the boundary. Per-capita rates can also look unusually high in postcodes covering hospitals, university campuses or industrial estates, because those areas record incidents normally but have very few residents on paper to divide the count across.',
|
||||
faqBehindData4Q: 'Does "Good schools within 2km" mean my child can attend them?',
|
||||
faqBehindData4A:
|
||||
'No. The count looks for state schools whose own postcode falls inside a circle around your postcode’s centroid. Catchment areas, faith and selection criteria, sibling priority and admission rules are not modelled — a Good or Outstanding school nearby may still be unreachable from your address. Use the count to compare areas, then confirm actual admissions with the school or local authority before relying on it for a decision.',
|
||||
faqBehindData5Q: 'Why does a postcode show "Gigabit" when not every home has fibre?',
|
||||
faqBehindData5A:
|
||||
'Broadband coverage from Ofcom Connected Nations is reported per postcode as the percentage of premises that can get each speed tier. We display the highest tier with any availability, so a postcode where even one home can reach Gigabit reads "Gigabit available". It is the right answer for "is full-fibre on this street at all?", but does not guarantee every flat in a block can be ordered today. Always verify with the providers for your specific address before signing.',
|
||||
faqBehindData6Q: 'Why don’t the public-transport times change for evenings or weekends?',
|
||||
faqBehindData6A:
|
||||
'Transit times are computed once per destination for a Tuesday morning departure window (07:30–08:30) using full GTFS timetables. The "normal" figure is the median journey in that window, and "best case" is the 5th percentile. Off-peak, late-night and weekend services are not modelled, so a postcode that only has a peak-only bus can still look transit-good on the map. Treat the numbers as a weekday commute proxy rather than an all-day estimate.',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
|
|
@ -1385,6 +1426,8 @@ const en = {
|
|||
'Potential energy rating': 'Potential energy rating',
|
||||
'Interior height (m)': 'Interior height (m)',
|
||||
'Street tree density percentile': 'Street tree density percentile',
|
||||
'Within conservation area': 'Within conservation area',
|
||||
'Listed building': 'Listed building',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Travel time to nearest train or tube station (min)':
|
||||
|
|
|
|||
|
|
@ -741,6 +741,14 @@ const fr: Translations = {
|
|||
bestCaseTitle: 'Meilleur temps de trajet',
|
||||
bestCaseDesc:
|
||||
'Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l’heure de départ.',
|
||||
noChange: 'Sans correspondance',
|
||||
noChangeTitle: 'Trajets directs uniquement',
|
||||
noChangeDesc:
|
||||
'Limite aux trajets <strong>sans correspondance</strong> —marche, un seul service de transport, marche jusqu’à destination.',
|
||||
noBuses: 'Sans bus',
|
||||
noBusesTitle: 'Exclure les bus',
|
||||
noBusesDesc:
|
||||
'Exclut les bus —uniquement <strong>train, métro, tram et ferry</strong>. Pratique pour éviter les retards liés au trafic.',
|
||||
previewOnMap: 'Aperçu sur la carte',
|
||||
stopPreviewing: 'Arrêter l’aperçu',
|
||||
removeTravelTime: 'Supprimer le temps de trajet',
|
||||
|
|
@ -813,6 +821,7 @@ const fr: Translations = {
|
|||
type: 'Type :',
|
||||
builtForm: 'Forme du bâti :',
|
||||
tenure: 'Régime foncier :',
|
||||
withinConservationArea: 'Dans une zone protégée :',
|
||||
floorArea: 'Surface :',
|
||||
rooms: 'Pièces :',
|
||||
built: 'Construction :',
|
||||
|
|
@ -822,6 +831,9 @@ const fr: Translations = {
|
|||
epcPotential: 'Potentiel DPE :',
|
||||
renovations: 'Rénovations',
|
||||
perSqm: '/m²',
|
||||
historyTitle: 'Historique',
|
||||
historySale: 'Vente',
|
||||
historyBuilt: 'Construit',
|
||||
searchPlaceholder: 'Rechercher par adresse ou code postal...',
|
||||
propertyData: 'Données immobilières',
|
||||
propertyDataDesc:
|
||||
|
|
@ -1130,6 +1142,14 @@ const fr: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Polygones de couvert arboré pour les arbres isolés, groupes d’arbres et petits bois en Angleterre. Utilisés ici pour estimer les percentiles de couvert arboré autour des centroïdes de codes postaux.',
|
||||
dsConservationAreasName: 'Zones de conservation de Historic England',
|
||||
dsConservationAreasOrigin: 'Historic England et autorités locales de planification',
|
||||
dsConservationAreasUse:
|
||||
'Limites des zones de conservation désignées en Angleterre. Utilisées pour indiquer si le point représentatif d’un code postal se trouve dans une zone de conservation.',
|
||||
dsListedBuildingsName: 'Bâtiments classés Historic England',
|
||||
dsListedBuildingsOrigin: 'National Heritage List for England de Historic England',
|
||||
dsListedBuildingsUse:
|
||||
'Points de bâtiments classés en Angleterre. Utilisés pour indiquer les biens dont l’adresse semble correspondre à une entrée classée proche.',
|
||||
dsNaptanName: 'NaPTAN (arrêts de transport public)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -1170,6 +1190,7 @@ const fr: Translations = {
|
|||
faqWhyTitle: 'Pourquoi Perfect Postcode',
|
||||
faqPricingTitle: 'Accès',
|
||||
faqTipsTitle: 'Astuces carte',
|
||||
faqBehindDataTitle: 'Dans les coulisses',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: 'Où chercher quand les zones évidentes sont trop chères ?',
|
||||
faqFinding1A:
|
||||
|
|
@ -1273,6 +1294,30 @@ const fr: Translations = {
|
|||
faqTips3Q: 'Comment actualiser les couleurs de la carte ?',
|
||||
faqTips3A:
|
||||
'Lorsqu’un critère colore la carte, utilisez Réinitialiser l’échelle de couleur dans la légende de la carte pour actualiser les couleurs des résultats affichés. C’est utile après un déplacement, un zoom ou une modification des filtres.',
|
||||
|
||||
// FAQ items — Behind The Data
|
||||
faqBehindData1Q: 'Pourquoi un aéroport peut-il sembler plus calme que les rues alentour ?',
|
||||
faqBehindData1A:
|
||||
'Le niveau de bruit affiché pour un code postal est le plus fort des trois sources Defra — route, rail et avion — modélisé à 4 m du sol comme une moyenne pondérée sur 24 h (Lden). Dans une rue résidentielle passante, le bruit routier domine, généralement 65–75 dB. À l’intérieur du périmètre d’un aéroport, il n’y a pas de grands axes publics, le terme routier chute et il ne reste que la moyenne aérienne. London City Airport, par exemple, a un couvre-feu et un trafic limité, donc son Lden aérien sur 24 h reste modéré (environ 60–66 dB près de la piste) — l’aéroport apparaît donc plus calme que les axes A qui le bordent. On retrouve le même effet à Heathrow. C’est un artefact réel de la mesure du bruit des transports à hauteur de récepteur résidentiel, pas un bug.',
|
||||
faqBehindData2Q:
|
||||
'Pourquoi l’aéroport, l’autoroute ou un parc apparaît-il comme une grande forme ?',
|
||||
faqBehindData2A:
|
||||
'Au Royaume-Uni, les codes postaux n’ont pas de frontières officielles — Royal Mail définit un code postal comme une liste d’adresses de livraison, pas comme une zone. Perfect Postcode synthétise les polygones en donnant à chaque adresse sa part de terrain alentour. Les endroits sans adresse (une piste, une chaussée d’autoroute, un parc, un réservoir) sont remplis par le code postal résidentiel le plus proche. C’est pourquoi un aéroport ou un espace ouvert apparaît souvent comme un seul grand polygone, et sa valeur vient des quelques codes postaux situés à l’intérieur du périmètre.',
|
||||
faqBehindData3Q:
|
||||
'Pourquoi des codes postaux voisins partagent-ils les mêmes chiffres de criminalité ?',
|
||||
faqBehindData3A:
|
||||
'La criminalité enregistrée par la police au niveau rue est publiée à l’échelle LSOA — de petits quartiers d’environ 1 500 habitants. Chaque code postal situé dans la même LSOA hérite des mêmes totaux annuels, donc une rue résidentielle calme et une rue commerçante un pâté de maisons plus loin peuvent afficher des chiffres identiques si elles sont du même côté de la limite. Les taux par habitant peuvent sembler anormalement élevés dans des codes postaux couvrant des hôpitaux, des campus ou des zones industrielles, car ils enregistrent des incidents normalement mais comptent peu de résidents officiels.',
|
||||
faqBehindData4Q:
|
||||
'« Bonnes écoles dans un rayon de 2 km » signifie-t-il que mon enfant peut y aller ?',
|
||||
faqBehindData4A:
|
||||
'Non. Le décompte cherche les écoles publiques dont le propre code postal tombe dans un cercle autour du centroïde de votre code postal. Les secteurs scolaires, critères religieux ou sélectifs, priorité fratrie et règles d’admission ne sont pas modélisés — une école Bonne ou Excellente proche peut rester inaccessible depuis votre adresse. Utilisez le décompte pour comparer des zones, puis confirmez les conditions d’admission auprès de l’école ou de la mairie avant de vous y fier.',
|
||||
faqBehindData5Q:
|
||||
'Pourquoi un code postal affiche-t-il « Gigabit » quand toutes les maisons n’en ont pas ?',
|
||||
faqBehindData5A:
|
||||
'La couverture haut débit d’Ofcom Connected Nations est publiée par code postal comme le pourcentage de locaux pouvant atteindre chaque palier de débit. Nous affichons le palier le plus élevé avec une disponibilité non nulle, donc un code postal où un seul logement peut obtenir le Gigabit affiche « Gigabit disponible ». C’est la bonne réponse à « y a-t-il de la fibre dans cette rue ? », mais cela ne garantit pas que tout appartement de l’immeuble soit éligible aujourd’hui. Vérifiez toujours auprès des opérateurs pour votre adresse précise avant de signer.',
|
||||
faqBehindData6Q: 'Pourquoi les temps de transport ne changent-ils pas le soir ou le week-end ?',
|
||||
faqBehindData6A:
|
||||
'Les temps de transport sont calculés une fois par destination pour une fenêtre de départ un mardi matin (07:30–08:30) à partir des horaires GTFS complets. La valeur « normale » est la médiane des trajets dans cette fenêtre, et le « meilleur cas » est le 5ᵉ percentile. Les services hors heures de pointe, nocturnes et de week-end ne sont pas modélisés, donc un code postal desservi uniquement par un bus de pointe peut quand même paraître bien desservi sur la carte. Considérez ces chiffres comme une estimation de trajet en semaine, pas une moyenne sur la journée.',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
|
|
@ -1427,6 +1472,8 @@ const fr: Translations = {
|
|||
'Potential energy rating': 'Classement énergétique potentiel',
|
||||
'Interior height (m)': 'Hauteur intérieure (m)',
|
||||
'Street tree density percentile': 'Percentile de densité arborée de la rue',
|
||||
'Within conservation area': 'Dans une zone protégée',
|
||||
'Listed building': 'Bâtiment classé',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Travel time to nearest train or tube station (min)':
|
||||
|
|
|
|||
|
|
@ -707,6 +707,14 @@ const hi: Translations = {
|
|||
bestCaseTitle: 'सर्वश्रेष्ठ स्थिति यात्रा समय',
|
||||
bestCaseDesc:
|
||||
'सबसे तेज यथार्थवादी यात्रा समय का उपयोग करता है (अगर आप प्रस्थान का समय सही रखें और अच्छे कनेक्शन मिलें). डिफॉल्ट <strong>मीडियन</strong> का उपयोग करता है, जो आपके निकलने के समय से स्वतंत्र एक सामान्य यात्रा दिखाता है.',
|
||||
noChange: 'बिना बदलाव',
|
||||
noChangeTitle: 'केवल सीधी यात्राएँ',
|
||||
noChangeDesc:
|
||||
'<strong>बिना ट्रांसफर</strong> वाली यात्राओं तक सीमित — पैदल, एक परिवहन सेवा, फिर पैदल गंतव्य तक.',
|
||||
noBuses: 'बिना बसें',
|
||||
noBusesTitle: 'बसों को बाहर रखें',
|
||||
noBusesDesc:
|
||||
'बस सेवाओं को छोड़ देता है — केवल <strong>ट्रेन, ट्यूब, ट्राम और फ़ेरी</strong>. ट्रैफिक देरी से बचने वाली यात्राओं को छानने के लिए उपयोगी.',
|
||||
previewOnMap: 'मानचित्र पर पूर्वावलोकन',
|
||||
stopPreviewing: 'पूर्वावलोकन रोकें',
|
||||
removeTravelTime: 'यात्रा समय हटाएं',
|
||||
|
|
@ -773,6 +781,7 @@ const hi: Translations = {
|
|||
type: 'प्रकार:',
|
||||
builtForm: 'निर्माण रूप:',
|
||||
tenure: 'कार्यकाल:',
|
||||
withinConservationArea: 'संरक्षण क्षेत्र में:',
|
||||
floorArea: 'फर्श क्षेत्र:',
|
||||
rooms: 'कमरे:',
|
||||
built: 'निर्माण:',
|
||||
|
|
@ -782,6 +791,9 @@ const hi: Translations = {
|
|||
epcPotential: 'EPC संभावित:',
|
||||
renovations: 'नवीनीकरण',
|
||||
perSqm: '/वर्ग मी',
|
||||
historyTitle: 'इतिहास',
|
||||
historySale: 'बिक्री',
|
||||
historyBuilt: 'निर्मित',
|
||||
searchPlaceholder: 'पते या पोस्टकोड से खोजें...',
|
||||
propertyData: 'संपत्ति डेटा',
|
||||
propertyDataDesc:
|
||||
|
|
@ -1074,6 +1086,14 @@ const hi: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'इंग्लैंड में अकेले पेड़ों, पेड़ों के समूहों और छोटे वन क्षेत्रों के वृक्ष आच्छादन बहुभुज. यहां पोस्टकोड केंद्रों के आसपास वृक्ष आच्छादन प्रतिशतक का अनुमान लगाने के लिए उपयोग किया गया है.',
|
||||
dsConservationAreasName: 'Historic England संरक्षण क्षेत्र',
|
||||
dsConservationAreasOrigin: 'Historic England और स्थानीय योजना प्राधिकरण',
|
||||
dsConservationAreasUse:
|
||||
'इंग्लैंड में नामित संरक्षण क्षेत्रों की सीमाएं. इसका उपयोग यह दिखाने के लिए किया जाता है कि पोस्टकोड का प्रतिनिधि बिंदु संरक्षण क्षेत्र में आता है या नहीं.',
|
||||
dsListedBuildingsName: 'Historic England सूचीबद्ध भवन',
|
||||
dsListedBuildingsOrigin: 'Historic England National Heritage List for England',
|
||||
dsListedBuildingsUse:
|
||||
'इंग्लैंड के सूचीबद्ध भवनों के बिंदु रिकॉर्ड. इसका उपयोग उन संपत्तियों को चिह्नित करने के लिए किया जाता है जिनका पता किसी पास की सूची प्रविष्टि से मेल खाता दिखता है.',
|
||||
dsNaptanName: 'NaPTAN (सार्वजनिक परिवहन स्टॉप)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -1113,6 +1133,7 @@ const hi: Translations = {
|
|||
faqWhyTitle: 'Perfect Postcode क्यों',
|
||||
faqPricingTitle: 'एक्सेस',
|
||||
faqTipsTitle: 'मानचित्र टिप्स',
|
||||
faqBehindDataTitle: 'डेटा के पीछे',
|
||||
faqFinding1Q: 'जब स्पष्ट क्षेत्र बहुत महंगे हों तो मुझे कहां देखना चाहिए?',
|
||||
faqFinding1A:
|
||||
'जिन बातों पर आप समझौता नहीं कर सकते उनसे शुरू करें: बजट, घर का प्रकार, जगह, आवागमन, स्कूल, सुरक्षा, शोर, इंटरनेट, पार्क और बाकी जरूरी बातें. मानचित्र वे जगहें छिपा देता है जो फिट नहीं बैठतीं, ताकि लिस्टिंग देखने से पहले कम स्पष्ट विकल्प सामने आ सकें.',
|
||||
|
|
@ -1200,6 +1221,26 @@ const hi: Translations = {
|
|||
faqTips3Q: 'मानचित्र के रंग कैसे ताज़ा करें?',
|
||||
faqTips3A:
|
||||
'जब कोई फीचर मानचित्र को रंग रहा हो, तो मानचित्र संकेतक में रंग स्केल रीसेट करें उपयोग करें ताकि अभी दिख रहे परिणामों के रंग ताज़ा हों. मानचित्र खिसकाने, ज़ूम करने या फिल्टर बदलने के बाद यह उपयोगी है.',
|
||||
|
||||
// FAQ items — Behind The Data
|
||||
faqBehindData1Q: 'कभी-कभी एयरपोर्ट आसपास की सड़कों से शांत क्यों दिखता है?',
|
||||
faqBehindData1A:
|
||||
'किसी पोस्टकोड के लिए दिखाया गया शोर स्तर Defra के तीन स्रोतों — सड़क, रेल और विमान — में से सबसे ऊँचा होता है, जो ज़मीन से 4 मीटर ऊपर 24-घंटे के भारित औसत (Lden) के रूप में मॉडल किया जाता है. व्यस्त आवासीय सड़क पर सड़क शोर हावी रहता है, आमतौर पर 65–75 dB. एयरपोर्ट की सीमा के भीतर कोई बड़ी सार्वजनिक सड़कें नहीं होतीं, इसलिए सड़क घटक गिर जाता है और केवल विमान औसत बचता है. उदाहरण के लिए, London City Airport में कर्फ्यू है और सीमित उड़ानें हैं, इसलिए इसका 24-घंटे विमान Lden मध्यम रहता है (रनवे पर लगभग 60–66 dB) — और एयरपोर्ट का भीतरी हिस्सा उसके आसपास की A-सड़कों से शांत दिखता है. यही प्रभाव Heathrow में भी दिखाई देता है. यह आवासीय रिसेप्टर ऊँचाई पर परिवहन शोर मापने का वास्तविक परिणाम है, बग नहीं.',
|
||||
faqBehindData2Q: 'एयरपोर्ट, हाईवे या पार्क एक बड़े आकार के रूप में क्यों दिखता है?',
|
||||
faqBehindData2A:
|
||||
'यूके में पोस्टकोड्स की आधिकारिक सीमाएँ नहीं होतीं — Royal Mail पोस्टकोड को डिलीवरी पतों की सूची के रूप में परिभाषित करता है, क्षेत्र के रूप में नहीं. Perfect Postcode प्रत्येक पते को आसपास की भूमि का उसका हिस्सा देकर बहुभुजों का संश्लेषण करता है. बिना पतों वाली जगहों (रनवे, हाईवे लेन, पार्क, जलाशय) को निकटतम आवासीय पोस्टकोड भर देता है. इसलिए एयरपोर्ट या खुला क्षेत्र अक्सर एकल बड़े बहुभुज के रूप में दिखता है, और उसका मान सीमा के भीतर मौजूद कुछ पोस्टकोड्स से आता है.',
|
||||
faqBehindData3Q: 'पास के पोस्टकोड्स में अपराध संख्या समान क्यों होती है?',
|
||||
faqBehindData3A:
|
||||
'पुलिस द्वारा दर्ज सड़क-स्तरीय अपराध डेटा LSOA स्तर पर प्रकाशित होता है — लगभग 1,500 निवासियों वाले छोटे पड़ोस क्षेत्र. एक ही LSOA के सभी पोस्टकोड्स को समान वार्षिक संख्याएँ मिलती हैं, इसलिए एक शांत आवासीय सड़क और एक ब्लॉक दूर मुख्य सड़क समान आँकड़े दिखा सकती हैं अगर वे सीमा के एक ही ओर हों. अस्पतालों, विश्वविद्यालय परिसरों या औद्योगिक क्षेत्रों को कवर करने वाले पोस्टकोड्स में प्रति-व्यक्ति दर असामान्य रूप से ऊँची लग सकती है, क्योंकि वहाँ घटनाएँ सामान्य रूप से दर्ज होती हैं पर कागज़ पर निवासी कम होते हैं.',
|
||||
faqBehindData4Q: '"2 किमी के भीतर अच्छे स्कूल" का मतलब क्या मेरा बच्चा वहाँ जा सकता है?',
|
||||
faqBehindData4A:
|
||||
'नहीं. यह गणना उन सरकारी स्कूलों को खोजती है जिनका अपना पोस्टकोड आपके पोस्टकोड के केंद्र के चारों ओर एक वृत्त के भीतर आता है. कैचमेंट क्षेत्र, धार्मिक या चयन मानदंड, भाई-बहन प्राथमिकता और प्रवेश नियम मॉडल नहीं किए जाते — पास का अच्छा या उत्कृष्ट स्कूल आपके पते से अप्राप्य भी हो सकता है. क्षेत्रों की तुलना के लिए इस संख्या का उपयोग करें, फिर निर्णय से पहले स्कूल या स्थानीय प्राधिकरण से वास्तविक प्रवेश की पुष्टि करें.',
|
||||
faqBehindData5Q: 'जब हर घर में फ़ाइबर नहीं है, तो पोस्टकोड "Gigabit" क्यों दिखाता है?',
|
||||
faqBehindData5A:
|
||||
'Ofcom Connected Nations का ब्रॉडबैंड कवरेज प्रति पोस्टकोड उस प्रतिशत के रूप में दिया जाता है जो प्रत्येक गति स्तर प्राप्त कर सकते हैं. हम किसी भी उपलब्धता वाले सर्वोच्च स्तर को दिखाते हैं, इसलिए जिस पोस्टकोड में सिर्फ एक घर Gigabit प्राप्त कर सकता है वह "Gigabit उपलब्ध" दिखाता है. "क्या इस सड़क पर बिल्कुल फ़ाइबर है?" का यह सही उत्तर है, पर इसकी गारंटी नहीं कि ब्लॉक के हर फ्लैट को आज ऑर्डर किया जा सके. हस्ताक्षर से पहले अपने सटीक पते के लिए हमेशा प्रदाताओं से जाँच करें.',
|
||||
faqBehindData6Q: 'सार्वजनिक परिवहन के समय शाम या सप्ताहांत में क्यों नहीं बदलते?',
|
||||
faqBehindData6A:
|
||||
'परिवहन समय प्रति गंतव्य एक मंगलवार सुबह की प्रस्थान विंडो (07:30–08:30) के लिए पूर्ण GTFS समय-सारणी से एक बार गणित किए जाते हैं. "सामान्य" मान उस विंडो में यात्राओं का माध्यिका है, और "सर्वोत्तम केस" 5वाँ प्रतिशतक है. ऑफ-पीक, देर रात और सप्ताहांत सेवाएँ मॉडल नहीं की गईं, इसलिए केवल पीक-समय बस वाला पोस्टकोड भी मानचित्र पर अच्छा-कनेक्टेड दिख सकता है. इन्हें कार्यदिवस यात्रा अनुमान के रूप में लें, पूरे दिन के औसत के रूप में नहीं.',
|
||||
},
|
||||
|
||||
accountPage: {
|
||||
|
|
@ -1340,6 +1381,8 @@ const hi: Translations = {
|
|||
'Potential energy rating': 'संभावित ऊर्जा रेटिंग',
|
||||
'Interior height (m)': 'भीतरी ऊंचाई (मी)',
|
||||
'Street tree density percentile': 'सड़क वृक्ष घनत्व प्रतिशतक',
|
||||
'Within conservation area': 'संरक्षण क्षेत्र में',
|
||||
'Listed building': 'सूचीबद्ध भवन',
|
||||
'Travel time to nearest train or tube station (min)':
|
||||
'निकटतम ट्रेन या ट्यूब स्टेशन तक यात्रा समय (मिनट)',
|
||||
'Good+ primary schools within 2km': '2 किमी के अंदर अच्छी या बेहतर रेटिंग वाले प्राथमिक स्कूल',
|
||||
|
|
|
|||
|
|
@ -726,6 +726,14 @@ const hu: Translations = {
|
|||
bestCaseTitle: 'Legjobb utazási idő',
|
||||
bestCaseDesc:
|
||||
'A leggyorsabb reális utazási időt használja (ha jól időzíted az indulást és jó csatlakozásokat érsz el). Az alapértelmezett a <strong>mediánt</strong> használja, ami egy átlagos utazást képvisel, függetlenül az indulás idejétől.',
|
||||
noChange: 'Átszállás nélkül',
|
||||
noChangeTitle: 'Csak közvetlen járatok',
|
||||
noChangeDesc:
|
||||
'<strong>Átszállás nélküli</strong> utazásokra korlátoz —gyaloglás, egy közlekedési eszköz, gyaloglás a célig.',
|
||||
noBuses: 'Busz nélkül',
|
||||
noBusesTitle: 'Buszok kizárása',
|
||||
noBusesDesc:
|
||||
'Kihagyja a buszokat —csak <strong>vonat, metró, villamos és komp</strong>. Hasznos a forgalmi késések elkerüléséhez.',
|
||||
previewOnMap: 'Előnézet a térképen',
|
||||
stopPreviewing: 'Előnézet leállítása',
|
||||
removeTravelTime: 'Utazási idő eltávolítása',
|
||||
|
|
@ -796,6 +804,7 @@ const hu: Translations = {
|
|||
type: 'Típus:',
|
||||
builtForm: 'Épületforma:',
|
||||
tenure: 'Tulajdonforma:',
|
||||
withinConservationArea: 'Védett területen:',
|
||||
floorArea: 'Alapterület:',
|
||||
rooms: 'Szobák:',
|
||||
built: 'Építve:',
|
||||
|
|
@ -805,6 +814,9 @@ const hu: Translations = {
|
|||
epcPotential: 'EPC potenciál:',
|
||||
renovations: 'Felújítások',
|
||||
perSqm: '/m²',
|
||||
historyTitle: 'Előzmények',
|
||||
historySale: 'Eladás',
|
||||
historyBuilt: 'Építés',
|
||||
searchPlaceholder: 'Keresés cím vagy irányítószám alapján...',
|
||||
propertyData: 'Ingatlanadatok',
|
||||
propertyDataDesc:
|
||||
|
|
@ -1112,6 +1124,14 @@ const hu: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Fa lombkorona-poligonok magányos fákhoz, facsoportokhoz és kisebb erdőfoltokhoz Angliában. Itt az irányítószám-középpontok körüli lombkorona-fedettségi percentilisek becslésére használjuk.',
|
||||
dsConservationAreasName: 'Historic England műemléki területek',
|
||||
dsConservationAreasOrigin: 'Historic England és helyi tervezési hatóságok',
|
||||
dsConservationAreasUse:
|
||||
'Anglia kijelölt conservation area határai. Annak jelzésére használjuk, hogy egy irányítószám reprezentatív pontja ilyen területre esik-e.',
|
||||
dsListedBuildingsName: 'Historic England műemlék épületek',
|
||||
dsListedBuildingsOrigin: 'Historic England National Heritage List for England',
|
||||
dsListedBuildingsUse:
|
||||
'Anglia műemlék épületeinek pontadatai. Annak jelzésére használjuk, ha egy ingatlan címe egy közeli listabejegyzéshez illeszkedni látszik.',
|
||||
dsNaptanName: 'NaPTAN (Tömegközlekedési megállók)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse:
|
||||
|
|
@ -1152,6 +1172,7 @@ const hu: Translations = {
|
|||
faqWhyTitle: 'Miért a Perfect Postcode',
|
||||
faqPricingTitle: 'Hozzáférés',
|
||||
faqTipsTitle: 'Térképtippek',
|
||||
faqBehindDataTitle: 'A háttérben',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: 'Hol keressek, ha a nyilvánvaló környékek túl drágák?',
|
||||
faqFinding1A:
|
||||
|
|
@ -1253,6 +1274,27 @@ const hu: Translations = {
|
|||
faqTips3Q: 'Hogyan frissíthetem a térkép színeit?',
|
||||
faqTips3A:
|
||||
'Amikor egy jellemző színezi a térképet, a jelmagyarázatban a Színskála visszaállítása gombbal frissítheted az aktuálisan látott eredmények színeit. Ez hasznos térképmozgatás, nagyítás vagy szűrőmódosítás után.',
|
||||
|
||||
// FAQ items — Behind The Data
|
||||
faqBehindData1Q: 'Miért tűnik egy repülőtér néha csendesebbnek, mint a körülötte lévő utcák?',
|
||||
faqBehindData1A:
|
||||
'Egy irányítószámhoz tartozó zajérték a Defra három forrásának (közút, vasút, repülő) közül a leghangosabb, 4 méter magasságban modellezve, 24 órás súlyozott átlagként (Lden). Egy forgalmas lakóutcán a közúti komponens dominál, jellemzően 65–75 dB. A repülőtér kerítésén belül nincsenek főutak, ezért a közúti tag leesik, és csak a repülőzaj átlaga marad. A London City repülőtér például éjszakai zárva tartással és korlátozott forgalommal üzemel, így a 24 órás repülő-átlag mérsékelt (a futópálya mellett kb. 60–66 dB), a repülőtér belseje pedig csendesebbnek látszik, mint a mellette húzódó főutak. Ugyanez figyelhető meg a Heathrow-nál is. Nem hiba, hanem a lakossági receptormagasságon mért közlekedési zaj természetes velejárója.',
|
||||
faqBehindData2Q: 'Miért látszik a repülőtér, autópálya vagy park egyetlen nagy foltként?',
|
||||
faqBehindData2A:
|
||||
'Az Egyesült Királyságban az irányítószámoknak hivatalosan nincs határa — a Royal Mail az irányítószámot kézbesítési címek listájaként definiálja, nem területként. A Perfect Postcode úgy állítja elő a poligonokat, hogy minden címhez hozzárendeli a körülötte lévő terület arányos részét. A címek nélküli helyeket (futópálya, autópálya, park, víztározó) a legközelebbi lakott irányítószám tölti ki. Ezért jelenik meg a repülőtér vagy nyílt terület gyakran egyetlen nagy poligonként, és az értéke a perimetren belül lévő néhány irányítószámból származik.',
|
||||
faqBehindData3Q: 'Miért egyezik meg több közeli irányítószám bűnözési száma?',
|
||||
faqBehindData3A:
|
||||
'A rendőrségi utcaszintű bűnözési adatok LSOA-szinten kerülnek közzétételre — ezek kb. 1500 lakosú kis környékek. Az ugyanazon LSOA-ban lévő minden irányítószám ugyanazokat az éves számokat kapja, így egy csendes lakóutca és egy egy háztömbnyire lévő főutca azonos értékeket mutathat, ha ugyanazon az oldalon vannak a határnak. Az egy főre jutó ráta szokatlanul magasnak tűnhet kórházakat, egyetemi kampuszokat vagy ipari területeket lefedő irányítószámoknál, mert ott normál mennyiségű incidens történik, de papíron kevés a lakos.',
|
||||
faqBehindData4Q:
|
||||
'A „2 km-en belüli Jó iskolák” azt jelenti, hogy oda be is iratkozhat a gyerekem?',
|
||||
faqBehindData4A:
|
||||
'Nem. A számláló azokat az állami iskolákat keresi, amelyek saját irányítószáma az irányítószámod középpontja körüli körben van. A körzethatárokat, vallási vagy felvételi kritériumokat, testvérprioritást és felvételi szabályokat nem modellezzük — egy közeli Jó vagy Kiváló iskola lehet, hogy a te címedről mégsem elérhető. Használd a számot területek összehasonlítására, majd a tényleges felvételi feltételeket egyeztesd az iskolával vagy az önkormányzattal, mielőtt erre alapoznál.',
|
||||
faqBehindData5Q: 'Miért mutat „Gigabit”-et egy irányítószám, ha nem minden otthon kapja?',
|
||||
faqBehindData5A:
|
||||
'Az Ofcom Connected Nations szélessáv-lefedettsége irányítószámonként az egyes sebességszinteket elérni képes helyiségek százalékát adja meg. Mi a legmagasabb elérhető szintet jelenítjük meg, így ha akár egyetlen otthon eléri a Gigabit-et, az irányítószám „Gigabit elérhető”-ként jelenik meg. Ez jól válaszol arra, hogy „van-e egyáltalán üvegszál ezen az utcán?”, de nem garantálja, hogy a tömbben minden lakásba megrendelhető. Mindig ellenőrizd a szolgáltatóknál a saját címedre vonatkozóan, mielőtt szerződnél.',
|
||||
faqBehindData6Q: 'Miért nem változnak az utazási idők estére vagy hétvégére?',
|
||||
faqBehindData6A:
|
||||
'A tömegközlekedési időket célállomásonként egyszer számoljuk ki, egy keddi reggeli indulási ablakra (07:30–08:30) a teljes GTFS menetrendek alapján. A „normál” érték az ablakon belüli utak mediánja, a „legjobb eset” pedig az 5. percentilis. A csúcsidőn kívüli, késő esti és hétvégi járatokat nem modellezzük, így egy irányítószám, amelyhez csak csúcsidőben jár busz, a térképen ettől függetlenül jó közlekedésűnek tűnhet. Tekintsd a számokat munkanapi ingázási becslésnek, nem egész napos átlagnak.',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
|
|
@ -1407,6 +1449,8 @@ const hu: Translations = {
|
|||
'Potential energy rating': 'Potenciális energetikai minősítés',
|
||||
'Interior height (m)': 'Belmagasság (m)',
|
||||
'Street tree density percentile': 'Utcai fasűrűségi percentilis',
|
||||
'Within conservation area': 'Védett területen',
|
||||
'Listed building': 'Műemlék épület',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Travel time to nearest train or tube station (min)':
|
||||
|
|
|
|||
|
|
@ -675,6 +675,14 @@ const zh: Translations = {
|
|||
bestCaseTitle: '最佳通勤时间',
|
||||
bestCaseDesc:
|
||||
'使用最快的实际出行时间(如果您把握好出发时间并赶上良好的换乘)。默认使用<strong>中位数</strong>,代表无论何时出发的典型出行时间。',
|
||||
noChange: '无换乘',
|
||||
noChangeTitle: '仅直达行程',
|
||||
noChangeDesc:
|
||||
'仅限<strong>无换乘</strong>行程 —步行、乘坐一次公共交通、再步行到目的地。',
|
||||
noBuses: '不含公交',
|
||||
noBusesTitle: '排除公交',
|
||||
noBusesDesc:
|
||||
'排除公交服务 —仅 <strong>火车、地铁、有轨电车和渡轮</strong>。便于筛选避开交通拥堵的行程。',
|
||||
previewOnMap: '在地图上预览',
|
||||
stopPreviewing: '停止预览',
|
||||
removeTravelTime: '移除通勤时间',
|
||||
|
|
@ -744,6 +752,7 @@ const zh: Translations = {
|
|||
type: '类型:',
|
||||
builtForm: '建筑形式:',
|
||||
tenure: '产权:',
|
||||
withinConservationArea: '位于保护区内:',
|
||||
floorArea: '建筑面积:',
|
||||
rooms: '房间:',
|
||||
built: '建造年份:',
|
||||
|
|
@ -753,6 +762,9 @@ const zh: Translations = {
|
|||
epcPotential: '潜在能源评级:',
|
||||
renovations: '翻新记录',
|
||||
perSqm: '/m²',
|
||||
historyTitle: '历史',
|
||||
historySale: '出售',
|
||||
historyBuilt: '建成',
|
||||
searchPlaceholder: '按地址或邮编搜索...',
|
||||
propertyData: '房产数据',
|
||||
propertyDataDesc:
|
||||
|
|
@ -1047,6 +1059,13 @@ const zh: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'英格兰孤立树木、树群和小片林地的树冠多边形。此处用于估算邮编质心周围的树冠覆盖率百分位。',
|
||||
dsConservationAreasName: 'Historic England 保护区',
|
||||
dsConservationAreasOrigin: 'Historic England 和地方规划部门',
|
||||
dsConservationAreasUse: '英格兰指定保护区边界。用于标记邮编代表点是否位于保护区内。',
|
||||
dsListedBuildingsName: 'Historic England 登录建筑',
|
||||
dsListedBuildingsOrigin: 'Historic England 英格兰国家遗产名录',
|
||||
dsListedBuildingsUse:
|
||||
'英格兰登录建筑点位记录。用于标记地址似乎与附近登录建筑条目匹配的房产。',
|
||||
dsNaptanName: 'NaPTAN(公共交通站点)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
|
||||
|
|
@ -1085,6 +1104,7 @@ const zh: Translations = {
|
|||
faqWhyTitle: '为什么选择 Perfect Postcode',
|
||||
faqPricingTitle: '访问权限',
|
||||
faqTipsTitle: '使用技巧',
|
||||
faqBehindDataTitle: '数据背后',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: '明显的区域太贵时,我应该去哪里找?',
|
||||
faqFinding1A:
|
||||
|
|
@ -1184,6 +1204,26 @@ const zh: Translations = {
|
|||
faqTips3Q: '如何刷新地图颜色?',
|
||||
faqTips3A:
|
||||
'当眼睛预览正在给地图着色时,在地图图例里点"重置颜色比例"即可按当前结果重新配色。平移、缩放或调整筛选之后,特别管用。',
|
||||
|
||||
// FAQ items — Behind The Data
|
||||
faqBehindData1Q: '为什么机场有时看起来比周围的街道更安静?',
|
||||
faqBehindData1A:
|
||||
'邮编上显示的噪音值是 Defra 三个来源(道路、铁路、飞机)中最大的一个,按离地 4 米建模为 24 小时加权平均(Lden)。在繁忙住宅街道上,道路噪音通常占主导,约 65–75 dB。机场围栏内没有大型公共道路,道路项下降,只剩下飞机平均值。例如伦敦城市机场有宵禁、航班受限,因此其 24 小时飞机 Lden 较温和(跑道处约 60–66 dB)——所以机场内部看起来比两侧 A 级公路更安静。希思罗机场也存在同样现象。这是在住宅接收点高度测量交通噪音的真实表现,而不是 bug。',
|
||||
faqBehindData2Q: '为什么机场、高速公路或公园会显示为一大块?',
|
||||
faqBehindData2A:
|
||||
'英国的邮编没有官方边界——Royal Mail 把邮编定义为投递地址列表,而不是一个区域。Perfect Postcode 把每个地址的周围土地分配给它,从而合成多边形。没有地址的地方(跑道、高速车道、公园、水库)由最近的住宅邮编填充。因此机场或开阔区域常常显示为一个大多边形,其数值来自围栏内的少数几个邮编。',
|
||||
faqBehindData3Q: '为什么相邻邮编的犯罪数字相同?',
|
||||
faqBehindData3A:
|
||||
'警方街道级犯罪数据按 LSOA 发布——大约 1,500 居民的小型社区单元。同一 LSOA 内每个邮编都继承同一年度总数,因此一条安静的住宅街和一个街区外的繁华街道,如果在同一边界内,可能显示完全相同的数据。覆盖医院、大学校园或工业园区的邮编,人均率可能异常偏高,因为那里事件数正常但纸面居民很少。',
|
||||
faqBehindData4Q: '"2 公里内的好学校"是否意味着我孩子可以入读?',
|
||||
faqBehindData4A:
|
||||
'不一定。统计查找的是自身邮编落在您邮编中心点周围圆形范围内的公立学校。学区、宗教或选拔标准、兄弟姐妹优先以及录取规则都没有建模——附近的"好"或"杰出"学校可能从您家其实无法入读。请用此数字对比区域,决策前向学校或地方政府确认实际录取条件。',
|
||||
faqBehindData5Q: '为什么并非每户都有光纤的邮编也显示"Gigabit"?',
|
||||
faqBehindData5A:
|
||||
'Ofcom Connected Nations 的宽带覆盖按邮编给出可达到每个速度档的物业百分比。我们显示有任何可用性的最高档,因此只要邮编内有一户能达到 Gigabit,就会显示"Gigabit 可用"。这正确回答了"这条街上到底有没有光纤?",但并不保证楼里每一套今天都能下单。签约前,请始终就您的具体地址向运营商核实。',
|
||||
faqBehindData6Q: '为什么公共交通时间在晚上或周末不变?',
|
||||
faqBehindData6A:
|
||||
'每个目的地的公交时间是基于完整 GTFS 时刻表,按一个周二早上的出发窗口(07:30–08:30)一次性计算的。"普通"值是该窗口内行程的中位数,"最佳情况"是第 5 百分位。非高峰、深夜和周末班次没有建模,因此只有早高峰公交的邮编在地图上仍可能显示交通便利。请把这些数字当作工作日通勤估算,而不是全天平均。',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
|
|
@ -1332,6 +1372,8 @@ const zh: Translations = {
|
|||
'Potential energy rating': '潜在能源评级',
|
||||
'Interior height (m)': '室内层高(米)',
|
||||
'Street tree density percentile': '街道树木覆盖率百分位',
|
||||
'Within conservation area': '位于保护区内',
|
||||
'Listed building': '登录建筑',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
|
||||
|
|
|
|||
|
|
@ -344,6 +344,14 @@ export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number
|
|||
Yes: [239, 68, 68],
|
||||
No: [34, 197, 94],
|
||||
},
|
||||
'Within conservation area': {
|
||||
Yes: [20, 184, 166],
|
||||
No: [107, 114, 128],
|
||||
},
|
||||
'Listed building': {
|
||||
Yes: [245, 158, 11],
|
||||
No: [107, 114, 128],
|
||||
},
|
||||
'Current energy rating': {
|
||||
A: [22, 163, 74],
|
||||
B: [132, 204, 22],
|
||||
|
|
|
|||
|
|
@ -111,6 +111,20 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<path d="M15 11c2.5 0 4.5 1.7 4.5 4 0 2.1-1.7 3.5-4 3.5" />
|
||||
</>
|
||||
),
|
||||
'Within conservation area': (
|
||||
<>
|
||||
<path d="M4 7l6-3 10 4-2 10-8 3-6-4z" />
|
||||
<path d="M9 12l2 2 4-5" />
|
||||
</>
|
||||
),
|
||||
'Listed building': (
|
||||
<>
|
||||
<path d="M4 21V9l8-6 8 6v12" />
|
||||
<path d="M9 21v-6h6v6" />
|
||||
<path d="M8 11h1m3 0h1m3 0h1" />
|
||||
<path d="M7 21h10" />
|
||||
</>
|
||||
),
|
||||
|
||||
// ── Transport ────────────────────────────────
|
||||
'Travel time to nearest train or tube station (min)': (
|
||||
|
|
|
|||
|
|
@ -98,6 +98,14 @@ export function formatTransactionDate(fractionalYear: number): string {
|
|||
);
|
||||
}
|
||||
|
||||
export function formatYearMonth(year: number, month: number): string {
|
||||
const monthIndex = Math.min(Math.max(month - 1, 0), 11);
|
||||
const language = i18n.language || undefined;
|
||||
return new Intl.DateTimeFormat(language, { month: 'short', year: 'numeric' }).format(
|
||||
new Date(Date.UTC(year, monthIndex, 1))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatAge(value: number, approximate = true): string {
|
||||
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
||||
return Math.round(value).toString();
|
||||
|
|
|
|||
33
frontend/src/lib/overlays.ts
Normal file
33
frontend/src/lib/overlays.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export const OVERLAY_IDS = ['noise', 'crime-hotspots', 'trees-outside-woodlands'] as const;
|
||||
|
||||
export type OverlayId = (typeof OVERLAY_IDS)[number];
|
||||
|
||||
export interface OverlayDefinition {
|
||||
id: OverlayId;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const OVERLAYS: OverlayDefinition[] = [
|
||||
{
|
||||
id: 'noise',
|
||||
label: 'Noise',
|
||||
description: 'High-resolution Defra Lden noise raster',
|
||||
},
|
||||
{
|
||||
id: 'crime-hotspots',
|
||||
label: 'Crime hotspots',
|
||||
description: 'Approximate police.uk street-crime heatmap',
|
||||
},
|
||||
{
|
||||
id: 'trees-outside-woodlands',
|
||||
label: 'Trees',
|
||||
description: 'Trees Outside Woodland canopy polygons',
|
||||
},
|
||||
];
|
||||
|
||||
const OVERLAY_ID_SET = new Set<string>(OVERLAY_IDS);
|
||||
|
||||
export function isOverlayId(value: string): value is OverlayId {
|
||||
return OVERLAY_ID_SET.has(value);
|
||||
}
|
||||
|
|
@ -94,4 +94,54 @@ describe('travel-params', () => {
|
|||
)
|
||||
).toBe('transit:bank-tube-station:0:1440');
|
||||
});
|
||||
|
||||
it('encodes transit variants in the mode field when no-change/no-buses are set', () => {
|
||||
expect(
|
||||
buildTravelParam([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 60],
|
||||
useBest: false,
|
||||
noChange: true,
|
||||
noBuses: false,
|
||||
},
|
||||
])
|
||||
).toBe('transit-no-change:bank-tube-station:0:60');
|
||||
|
||||
expect(
|
||||
buildTravelParam([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 60],
|
||||
useBest: true,
|
||||
noChange: true,
|
||||
noBuses: true,
|
||||
},
|
||||
])
|
||||
).toBe('transit-no-change-no-bus:bank-tube-station:best:0:60');
|
||||
});
|
||||
|
||||
it('keeps different transit variants as separate entries (no dedupe across variants)', () => {
|
||||
const transit: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 90],
|
||||
useBest: false,
|
||||
};
|
||||
const transitNoBus: TravelTimeEntry = {
|
||||
...transit,
|
||||
noBuses: true,
|
||||
};
|
||||
|
||||
const deduped = dedupeTravelTimeEntries([transit, transitNoBus]);
|
||||
expect(deduped).toHaveLength(2);
|
||||
expect(buildTravelParam([transit, transitNoBus])).toBe(
|
||||
'transit:bank-tube-station:0:90|transit-no-bus:bank-tube-station:0:90'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { TravelTimeEntry } from '../hooks/useTravelTime';
|
||||
import { resolveTransitVariant, type TravelTimeEntry } from '../hooks/useTravelTime';
|
||||
|
||||
function mergeTimeRanges(
|
||||
current: [number, number] | null,
|
||||
|
|
@ -9,6 +9,15 @@ function mergeTimeRanges(
|
|||
return [Math.max(current[0], next[0]), Math.min(current[1], next[1])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupe key includes the resolved transit variant so distinct toggle
|
||||
* combinations (e.g. `transit` vs `transit-no-change`) for the same slug
|
||||
* are kept as separate filters rather than collapsed.
|
||||
*/
|
||||
function entryDedupeKey(entry: TravelTimeEntry): string {
|
||||
return `${resolveTransitVariant(entry)}:${entry.slug}`;
|
||||
}
|
||||
|
||||
export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeEntry[] {
|
||||
const result: TravelTimeEntry[] = [];
|
||||
const indexByKey = new Map<string, number>();
|
||||
|
|
@ -19,7 +28,7 @@ export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeE
|
|||
continue;
|
||||
}
|
||||
|
||||
const key = `${entry.mode}:${entry.slug}`;
|
||||
const key = entryDedupeKey(entry);
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex == null) {
|
||||
indexByKey.set(key, result.length);
|
||||
|
|
@ -33,6 +42,11 @@ export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeE
|
|||
label: existing.label || entry.label,
|
||||
timeRange: mergeTimeRanges(existing.timeRange, entry.timeRange),
|
||||
useBest: existing.useBest || entry.useBest,
|
||||
// noChange/noBuses are part of the dedupe key, so for two entries to
|
||||
// collide here they must already have matching flags. Carry them through
|
||||
// explicitly so unset (undefined) doesn't clobber set (false/true).
|
||||
noChange: existing.noChange ?? entry.noChange,
|
||||
noBuses: existing.noBuses ?? entry.noBuses,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -49,10 +63,11 @@ export function buildTravelParam(
|
|||
for (const entry of dedupeTravelTimeEntries(entries)) {
|
||||
if (!entry.slug) continue;
|
||||
|
||||
let segment = `${entry.mode}:${entry.slug}`;
|
||||
const serverMode = resolveTransitVariant(entry);
|
||||
let segment = `${serverMode}:${entry.slug}`;
|
||||
if (entry.useBest) segment += ':best';
|
||||
|
||||
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
|
||||
const isExcluded = excludeFieldKey === `tt_${serverMode}_${entry.slug}`;
|
||||
if (isExcluded && includeUnboundedExcludedRange) {
|
||||
segment += ':0:1440';
|
||||
} else if (!isExcluded && entry.timeRange) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ describe('url-state', () => {
|
|||
label: 'Kings Cross',
|
||||
timeRange: [0, 30],
|
||||
useBest: true,
|
||||
noChange: false,
|
||||
noBuses: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -115,6 +117,8 @@ describe('url-state', () => {
|
|||
label: 'Bank',
|
||||
timeRange: [10, 45],
|
||||
useBest: false,
|
||||
noChange: false,
|
||||
noBuses: false,
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -149,6 +153,26 @@ describe('url-state', () => {
|
|||
expect(state.poiCategories).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('round-trips overlay selections', () => {
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
new Set(),
|
||||
'area',
|
||||
undefined,
|
||||
undefined,
|
||||
new Set(['noise', 'crime-hotspots'])
|
||||
);
|
||||
|
||||
expect(params.getAll('overlay')).toEqual(['noise', 'crime-hotspots']);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}&overlay=unknown`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.overlays).toEqual(new Set(['noise', 'crime-hotspots']));
|
||||
});
|
||||
|
||||
it('round-trips repeated school filters with dedicated URL params', () => {
|
||||
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
|
||||
import {
|
||||
TRANSPORT_MODES,
|
||||
type TransportMode,
|
||||
MAX_TRAVEL_MINUTES,
|
||||
parseServerMode,
|
||||
resolveTransitVariant,
|
||||
type TravelTimeEntry,
|
||||
type TravelTimeInitial,
|
||||
} from '../hooks/useTravelTime';
|
||||
|
|
@ -48,6 +49,7 @@ import {
|
|||
type PoiFilterName,
|
||||
} from './poi-distance-filter';
|
||||
import { dedupeTravelTimeEntries } from './travel-params';
|
||||
import { isOverlayId, type OverlayId } from './overlays';
|
||||
|
||||
const POI_NONE_PARAM = '__none';
|
||||
|
||||
|
|
@ -55,6 +57,7 @@ export interface UrlState {
|
|||
viewState: ViewState;
|
||||
filters: FeatureFilters;
|
||||
poiCategories: Set<string>;
|
||||
overlays: Set<OverlayId>;
|
||||
tab: 'properties' | 'area';
|
||||
travelTime?: TravelTimeInitial;
|
||||
postcode?: string;
|
||||
|
|
@ -209,6 +212,7 @@ export function parseUrlState(): UrlState {
|
|||
viewState: INITIAL_VIEW_STATE,
|
||||
filters: parseFilters(params),
|
||||
poiCategories: new Set(),
|
||||
overlays: new Set(),
|
||||
tab: 'area',
|
||||
};
|
||||
|
||||
|
|
@ -244,6 +248,11 @@ export function parseUrlState(): UrlState {
|
|||
}
|
||||
}
|
||||
|
||||
const overlayParams = params.getAll('overlay');
|
||||
if (overlayParams.length > 0) {
|
||||
result.overlays = new Set(overlayParams.filter(isOverlayId));
|
||||
}
|
||||
|
||||
// Tab: full name
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'properties' || tab === 'area') {
|
||||
|
|
@ -257,15 +266,19 @@ export function parseUrlState(): UrlState {
|
|||
}
|
||||
|
||||
// Travel time: repeated `tt` params
|
||||
// Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
|
||||
// Format: serverMode:slug:label[:b][:min:max]
|
||||
// serverMode is one of: car | bicycle | walking | transit | transit-no-bus
|
||||
// | transit-no-change | transit-no-change-no-bus. transit-one-change[-no-bus]
|
||||
// variants are server-side only and will cause the entry to be dropped here
|
||||
// (parseServerMode returns null) so we don't silently broaden the user's filter.
|
||||
const ttParams = params.getAll('tt');
|
||||
if (ttParams.length > 0) {
|
||||
const entries: TravelTimeEntry[] = [];
|
||||
for (const tt of ttParams) {
|
||||
const parts = tt.split(':');
|
||||
if (parts.length < 3) continue;
|
||||
const mode = parts[0] as TransportMode;
|
||||
if (!TRANSPORT_MODES.includes(mode)) continue;
|
||||
const parsedMode = parseServerMode(parts[0]);
|
||||
if (!parsedMode) continue;
|
||||
const slug = parts[1];
|
||||
const label = decodeURIComponent(parts[2]);
|
||||
const useBest = parts.length >= 4 && parts[3] === 'b';
|
||||
|
|
@ -275,10 +288,21 @@ export function parseUrlState(): UrlState {
|
|||
const min = Number(parts[3 + rangeOffset]);
|
||||
const max = Number(parts[4 + rangeOffset]);
|
||||
if (!isNaN(min) && !isNaN(max)) {
|
||||
timeRange = [min, max];
|
||||
// Clamp loaded max-time to the data ceiling. Older shared URLs
|
||||
// may have max=120 from the previous slider range; no data exists
|
||||
// above MAX_TRAVEL_MINUTES so the result is identical.
|
||||
timeRange = [min, Math.min(max, MAX_TRAVEL_MINUTES)];
|
||||
}
|
||||
}
|
||||
entries.push({ mode, slug, label, timeRange, useBest });
|
||||
entries.push({
|
||||
mode: parsedMode.mode,
|
||||
slug,
|
||||
label,
|
||||
timeRange,
|
||||
useBest,
|
||||
noChange: parsedMode.noChange,
|
||||
noBuses: parsedMode.noBuses,
|
||||
});
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
result.travelTime = { entries: dedupeTravelTimeEntries(entries) };
|
||||
|
|
@ -295,7 +319,8 @@ export function stateToParams(
|
|||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'properties' | 'area',
|
||||
travelTimeEntries?: TravelTimeEntry[],
|
||||
share?: string
|
||||
share?: string,
|
||||
selectedOverlays?: Set<OverlayId>
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
|
|
@ -378,11 +403,18 @@ export function stateToParams(
|
|||
params.set('tab', 'properties');
|
||||
}
|
||||
|
||||
if (selectedOverlays) {
|
||||
for (const overlay of selectedOverlays) {
|
||||
params.append('overlay', overlay);
|
||||
}
|
||||
}
|
||||
|
||||
// Travel time: repeated `tt` params
|
||||
if (travelTimeEntries) {
|
||||
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
|
||||
if (!entry.slug) continue;
|
||||
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||
const serverMode = resolveTransitVariant(entry);
|
||||
let val = `${serverMode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||
if (entry.useBest) val += ':b';
|
||||
if (entry.timeRange) {
|
||||
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,31 @@ export interface ApiResponse {
|
|||
features: HexagonData[];
|
||||
}
|
||||
|
||||
/** GIAS register fields surfaced for state-funded school POIs. Every entry is
|
||||
* optional because DfE does not populate every field for every establishment
|
||||
* type (a nursery has no sixth form, an FE college reports no FSM, etc.). */
|
||||
export interface SchoolMetadata {
|
||||
phase?: string;
|
||||
type?: string;
|
||||
type_group?: string;
|
||||
age_range?: string;
|
||||
gender?: string;
|
||||
religious_character?: string;
|
||||
admissions_policy?: string;
|
||||
nursery_provision?: string;
|
||||
sixth_form?: string;
|
||||
capacity?: number;
|
||||
pupils?: number;
|
||||
fsm_percent?: number;
|
||||
trust?: string;
|
||||
address?: string;
|
||||
postcode?: string;
|
||||
local_authority?: string;
|
||||
website?: string;
|
||||
telephone?: string;
|
||||
head_name?: string;
|
||||
}
|
||||
|
||||
export interface POI {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -105,6 +130,7 @@ export interface POI {
|
|||
lat: number;
|
||||
lng: number;
|
||||
emoji: string;
|
||||
school?: SchoolMetadata;
|
||||
}
|
||||
|
||||
export interface POIResponse {
|
||||
|
|
@ -174,6 +200,12 @@ export interface RenovationEvent {
|
|||
event: string;
|
||||
}
|
||||
|
||||
export interface HistoricalPrice {
|
||||
year: number;
|
||||
month: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface Property {
|
||||
address?: string;
|
||||
postcode?: string;
|
||||
|
|
@ -185,6 +217,8 @@ export interface Property {
|
|||
property_sub_type?: string;
|
||||
price_qualifier?: string;
|
||||
former_council_house?: string;
|
||||
within_conservation_area?: string;
|
||||
listed_building?: string;
|
||||
|
||||
// Numeric fields
|
||||
lat: number;
|
||||
|
|
@ -192,9 +226,17 @@ export interface Property {
|
|||
|
||||
is_construction_date_approximate?: boolean;
|
||||
renovation_history?: RenovationEvent[];
|
||||
historical_prices?: HistoricalPrice[];
|
||||
|
||||
// All other numeric features (dynamic, including construction_age_band)
|
||||
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
|
||||
[key: string]:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| RenovationEvent[]
|
||||
| HistoricalPrice[]
|
||||
| string[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** Shared paginated list of `Property` records returned by both
|
||||
|
|
@ -229,6 +271,17 @@ export interface PricePoint {
|
|||
price: number;
|
||||
}
|
||||
|
||||
export interface CrimeYearPoint {
|
||||
year: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CrimeYearStats {
|
||||
/** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */
|
||||
name: string;
|
||||
points: CrimeYearPoint[];
|
||||
}
|
||||
|
||||
export interface FilterExclusion {
|
||||
name: string;
|
||||
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
||||
|
|
@ -246,6 +299,8 @@ export interface HexagonStatsResponse {
|
|||
numeric_features: NumericFeatureStats[];
|
||||
enum_features: EnumFeatureStats[];
|
||||
price_history?: PricePoint[];
|
||||
/** Per-crime-type per-year counts averaged across the selection. */
|
||||
crime_by_year?: CrimeYearStats[];
|
||||
central_postcode?: string;
|
||||
filter_exclusions?: FilterExclusion[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue