lgtm
This commit is contained in:
parent
f7e0814a38
commit
fd2860070a
55 changed files with 4084 additions and 186 deletions
|
|
@ -146,7 +146,14 @@ function ProductDemoVideo() {
|
|||
onPlay={() => setIsVideoPlaying(true)}
|
||||
onPause={() => setIsVideoPlaying(false)}
|
||||
onEnded={() => setIsVideoPlaying(false)}
|
||||
/>
|
||||
>
|
||||
<track
|
||||
kind="captions"
|
||||
srcLang={(i18n.language ?? 'en').split('-')[0]}
|
||||
label="Captions"
|
||||
src={`/video/${productDemoSlug}.vtt`}
|
||||
/>
|
||||
</video>
|
||||
{!isVideoPlaying && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const SOCIAL_VIDEOS: { slug: string; titleKey: string; descKey: string }[] = [
|
|||
descKey: 'learnPage.video07Desc',
|
||||
},
|
||||
{
|
||||
slug: 'ad-08-cheap-insurance',
|
||||
slug: 'ad-08-value',
|
||||
titleKey: 'learnPage.video08Title',
|
||||
descKey: 'learnPage.video08Desc',
|
||||
},
|
||||
|
|
@ -261,7 +261,9 @@ function SocialVideoCard({
|
|||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
>
|
||||
<track kind="captions" srcLang="en" label="Captions" src={`/video/${slug}.vtt`} />
|
||||
</video>
|
||||
{!isPlaying && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { useMemo, useState, type MutableRefObject, type ReactNode } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import type {
|
||||
|
|
@ -37,6 +45,7 @@ import StackedBarChart from './StackedBarChart';
|
|||
import StackedEnumChart from './StackedEnumChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import CrimeYearChart from './CrimeYearChart';
|
||||
import NumberLine, { type NumberLinePoint } from './NumberLine';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, TransitIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
|
|
@ -239,6 +248,7 @@ export default function AreaPane({
|
|||
}: AreaPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const propertyCount = stats?.count;
|
||||
const population = stats?.population;
|
||||
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
|
||||
const filtersActive = activeFilterCount > 0;
|
||||
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
|
||||
|
|
@ -284,6 +294,65 @@ export default function AreaPane({
|
|||
suspendSave: scrollSaveDisabled ?? (loading && stats == null),
|
||||
});
|
||||
|
||||
// When the user expands a group, scroll the bottom of the newly opened content
|
||||
// into view so the whole group is revealed without manual scrolling. The group
|
||||
// header stays pinned at the top via its sticky positioning.
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const setScrollNode = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
scrollContainerRef.current = node;
|
||||
scrollRef(node);
|
||||
},
|
||||
[scrollRef]
|
||||
);
|
||||
const groupRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
// A fresh object each toggle so re-expanding the same group still re-triggers.
|
||||
const [groupToReveal, setGroupToReveal] = useState<{ name: string } | null>(null);
|
||||
const handleToggleGroup = useCallback(
|
||||
(name: string) => {
|
||||
const willExpand = !isGroupExpanded(name);
|
||||
onToggleGroup(name);
|
||||
setGroupToReveal(willExpand ? { name } : null);
|
||||
},
|
||||
[isGroupExpanded, onToggleGroup]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!groupToReveal) return;
|
||||
const el = groupRefs.current.get(groupToReveal.name);
|
||||
const container = scrollContainerRef.current;
|
||||
if (!el || !container) return;
|
||||
|
||||
// Scroll down just enough to bring the bottom of the opened group into view.
|
||||
// For groups taller than the pane this scrolls past the header, but it stays
|
||||
// visible via its sticky positioning. Never scroll up (group already in view).
|
||||
const alignBottom = () => {
|
||||
const delta = el.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom;
|
||||
if (delta <= 1) return;
|
||||
container.scrollTo({ top: container.scrollTop + delta });
|
||||
};
|
||||
|
||||
alignBottom();
|
||||
|
||||
// The group's charts and async data (e.g. nearby stations) can grow its
|
||||
// height after this first pass, so keep the bottom pinned as it settles.
|
||||
// Stop once the user scrolls or after a short grace period so we never fight
|
||||
// a deliberate scroll.
|
||||
let timer = 0;
|
||||
const observer = new ResizeObserver(alignBottom);
|
||||
const stop = () => {
|
||||
observer.disconnect();
|
||||
container.removeEventListener('wheel', stop);
|
||||
container.removeEventListener('touchmove', stop);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
observer.observe(el);
|
||||
container.addEventListener('wheel', stop, { passive: true });
|
||||
container.addEventListener('touchmove', stop, { passive: true });
|
||||
timer = window.setTimeout(stop, 1500);
|
||||
|
||||
return stop;
|
||||
}, [groupToReveal]);
|
||||
|
||||
const numericByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
|
||||
|
|
@ -306,6 +375,18 @@ export default function AreaPane({
|
|||
return map;
|
||||
}, [stats]);
|
||||
|
||||
// Per-crime-type outcode/sector averages, keyed by both the bare crime type
|
||||
// and the " (avg/yr)" feature name (same convention as crimeByYearByFeatureName)
|
||||
// so renderers can look them up by the feature name they already hold.
|
||||
const crimeAreaAvgByName = useMemo(() => {
|
||||
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_area_averages']>[number]>();
|
||||
for (const entry of stats?.crime_area_averages ?? []) {
|
||||
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]
|
||||
|
|
@ -359,7 +440,7 @@ export default function AreaPane({
|
|||
<>
|
||||
<div className="relative flex h-full flex-col">
|
||||
<IndeterminateProgressBar show={loading && stats != null} />
|
||||
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
|
||||
<div ref={setScrollNode} onScroll={onScroll} className="flex-1 overflow-y-auto">
|
||||
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
|
@ -380,12 +461,24 @@ export default function AreaPane({
|
|||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('common.propertiesPlural')}
|
||||
<div className="flex shrink-0 items-start gap-4 text-right">
|
||||
{population != null && (
|
||||
<div title={t('areaPane.residentsTooltip')}>
|
||||
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||
{population.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('areaPane.residents')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('common.propertiesPlural')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -522,11 +615,17 @@ export default function AreaPane({
|
|||
);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<div
|
||||
key={group.name}
|
||||
ref={(el) => {
|
||||
if (el) groupRefs.current.set(group.name, el);
|
||||
else groupRefs.current.delete(group.name);
|
||||
}}
|
||||
>
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={expanded}
|
||||
onToggle={() => onToggleGroup(group.name)}
|
||||
onToggle={() => handleToggleGroup(group.name)}
|
||||
className="area-pane-group-header sticky top-0 z-10 bg-white px-3 pb-1.5 pt-4 text-[11px] font-bold uppercase tracking-wide text-warm-500 hover:bg-warm-50 dark:bg-navy-950 dark:text-warm-400 dark:hover:bg-navy-900"
|
||||
/>
|
||||
{expanded && (
|
||||
|
|
@ -571,6 +670,47 @@ export default function AreaPane({
|
|||
const globalMean = featureMeta?.histogram
|
||||
? calculateHistogramMean(featureMeta.histogram)
|
||||
: undefined;
|
||||
const crimeAreaAvg = infoFeatureName
|
||||
? crimeAreaAvgByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
// For crime, prefer the exact national mean so it shares
|
||||
// one estimator with the outcode/sector/selection values.
|
||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||
|
||||
// Crime metrics get a number line comparing this area to
|
||||
// its sector / outcode / nation instead of a flat list.
|
||||
const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
|
||||
? (
|
||||
[
|
||||
{
|
||||
kind: 'area',
|
||||
label: t('areaPane.thisArea'),
|
||||
value: displayValue,
|
||||
},
|
||||
nationalAvg != null
|
||||
? {
|
||||
kind: 'national',
|
||||
label: t('areaPane.national'),
|
||||
value: nationalAvg,
|
||||
}
|
||||
: null,
|
||||
crimeAreaAvg.outcode != null
|
||||
? {
|
||||
kind: 'outcode',
|
||||
label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'),
|
||||
value: crimeAreaAvg.outcode,
|
||||
}
|
||||
: null,
|
||||
crimeAreaAvg.sector != null
|
||||
? {
|
||||
kind: 'sector',
|
||||
label: stats?.crime_sector ?? t('areaPane.sectorAvg'),
|
||||
value: crimeAreaAvg.sector,
|
||||
}
|
||||
: null,
|
||||
] as (NumberLinePoint | null)[]
|
||||
).filter((p): p is NumberLinePoint => p !== null)
|
||||
: [];
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
|
|
@ -601,9 +741,12 @@ export default function AreaPane({
|
|||
{formatValue(displayValue)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
{globalMean != null && (
|
||||
{/* Crime shows its national/outcode/sector
|
||||
comparison on the number line below; other
|
||||
stacked metrics keep the inline national avg. */}
|
||||
{!crimeAreaAvg && nationalAvg != null && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
||||
{t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -617,6 +760,11 @@ export default function AreaPane({
|
|||
: STACKED_SEGMENT_COLORS
|
||||
}
|
||||
/>
|
||||
{numberLinePoints.length >= 2 && (
|
||||
<div className="mt-2">
|
||||
<NumberLine points={numberLinePoints} format={formatValue} />
|
||||
</div>
|
||||
)}
|
||||
{crimeSeries && crimeSeries.points.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<CrimeYearChart
|
||||
|
|
@ -653,6 +801,26 @@ export default function AreaPane({
|
|||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
|
||||
const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
|
||||
// National avg is shown for every metric here as a
|
||||
// tooltip; for crime metrics the outcode and sector
|
||||
// averages join it on their own lines, and the exact
|
||||
// national mean is preferred over the histogram one.
|
||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||
const valueTitle =
|
||||
[
|
||||
nationalAvg != null
|
||||
? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
|
||||
: null,
|
||||
crimeAreaAvg?.outcode != null
|
||||
? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
|
||||
: null,
|
||||
crimeAreaAvg?.sector != null
|
||||
? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n') || undefined;
|
||||
|
||||
return (
|
||||
<MetricRow
|
||||
|
|
@ -712,11 +880,7 @@ export default function AreaPane({
|
|||
)
|
||||
}
|
||||
value={formatValue(numericStats.mean, feature)}
|
||||
valueTitle={
|
||||
globalMean != null
|
||||
? `${t('areaPane.nationalAvg')}: ${formatValue(globalMean)}`
|
||||
: undefined
|
||||
}
|
||||
valueTitle={valueTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
72
frontend/src/components/map/DevelopmentPopup.tsx
Normal file
72
frontend/src/components/map/DevelopmentPopup.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Development } from '../../types';
|
||||
|
||||
/** Turn a raw server status like "full-planning-permission" into "Full planning permission". */
|
||||
function prettifyStatus(value: string): string {
|
||||
const cleaned = value.replace(/[-_]+/g, ' ').trim();
|
||||
if (!cleaned) return value;
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
export const DevelopmentPopupContent = memo(function DevelopmentPopupContent({
|
||||
development,
|
||||
}: {
|
||||
development: Development;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const min = development.min_dwellings ?? null;
|
||||
const max = development.max_dwellings ?? null;
|
||||
|
||||
let dwellings: string | null = null;
|
||||
if (min != null && max != null) {
|
||||
dwellings =
|
||||
min === max
|
||||
? t('newDevelopments.homesExact', { count: max })
|
||||
: t('newDevelopments.homesRange', { min, max });
|
||||
} else if (max != null) {
|
||||
dwellings = t('newDevelopments.homesUpTo', { count: max });
|
||||
} else if (min != null) {
|
||||
dwellings = t('newDevelopments.homesExact', { count: min });
|
||||
}
|
||||
|
||||
const sourceLabel =
|
||||
development.source === 'homes-england'
|
||||
? t('newDevelopments.sourceHomesEngland')
|
||||
: t('newDevelopments.sourceBrownfield');
|
||||
|
||||
return (
|
||||
<div className="max-w-[260px] px-3 py-2">
|
||||
<div className="text-sm font-bold text-blue-700 dark:text-blue-400">
|
||||
{development.name || t('newDevelopments.title')}
|
||||
</div>
|
||||
{dwellings && (
|
||||
<div className="mt-0.5 text-xs font-medium text-warm-700 dark:text-warm-200">
|
||||
{dwellings}
|
||||
</div>
|
||||
)}
|
||||
{development.planning_status && (
|
||||
<div className="mt-0.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
{t('newDevelopments.planningStatus')}: {prettifyStatus(development.planning_status)}
|
||||
</div>
|
||||
)}
|
||||
{development.local_authority && (
|
||||
<div className="mt-0.5 text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{t('newDevelopments.localAuthority')}: {development.local_authority}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[11px] text-warm-400 dark:text-warm-500">{sourceLabel}</div>
|
||||
{development.url && (
|
||||
<a
|
||||
href={development.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1.5 block text-[11px] font-medium text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t('newDevelopments.viewRecord')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
isEthnicityFeatureName,
|
||||
isEthnicityFilterName,
|
||||
} from '../../lib/ethnicity-filter';
|
||||
import { isQualificationFeatureName } from '../../lib/qualification-filter';
|
||||
import {
|
||||
SCHOOL_FILTER_NAME,
|
||||
getDefaultSchoolFeatureName,
|
||||
|
|
@ -347,6 +348,10 @@ export default memo(function Filters({
|
|||
maybeInsertPoiFilter(getPoiFilterName(feature.name));
|
||||
continue;
|
||||
}
|
||||
// Qualification breakdown is display-only (shown as the stacked
|
||||
// "Qualifications" composition in the area pane), so keep its seven
|
||||
// component features out of the filter browser.
|
||||
if (isQualificationFeatureName(feature.name)) continue;
|
||||
if (!enabledFeatures.has(feature.name)) result.push(feature);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,13 +24,24 @@ import {
|
|||
getMapStyle,
|
||||
getMapDataBeforeId,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
selectSpacedItems,
|
||||
type PlacedItem,
|
||||
} from '../../lib/map-utils';
|
||||
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_AUTO_CARD_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||
import {
|
||||
MAP_MIN_ZOOM,
|
||||
MAP_BOUNDS,
|
||||
POI_AUTO_CARD_ZOOM_THRESHOLD,
|
||||
MAX_AUTO_POI_CARDS,
|
||||
AUTO_POI_CARD_MIN_DX,
|
||||
AUTO_POI_CARD_MIN_DY,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||
import { useDevelopments } from '../../hooks/useDevelopments';
|
||||
import { useMapCardLayout } from '../../hooks/useMapCardLayout';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { type OverlayId } from '../../lib/overlays';
|
||||
|
|
@ -41,6 +52,7 @@ import { OverlayTileLayers } from './OverlayTileLayers';
|
|||
import { MapTopCards } from './MapTopCards';
|
||||
import { PoiPopupCardContent } from './PoiPopupCard';
|
||||
import { ListingClusterPopupContent, ListingPopupSingleContent } from './ListingPopups';
|
||||
import { DevelopmentPopupContent } from './DevelopmentPopup';
|
||||
import { HoverCardOverlay } from './HoverCardOverlay';
|
||||
|
||||
interface MapProps {
|
||||
|
|
@ -425,6 +437,14 @@ export default memo(function Map({
|
|||
return center ? { lat: center.lat, lng: center.lng } : null;
|
||||
}, []);
|
||||
|
||||
// Only fetch/show developments once zoomed in, matching the tile overlays
|
||||
// (noise/crime/trees/borders) which all gate on POSTCODE_ZOOM_THRESHOLD.
|
||||
const developmentsEnabled =
|
||||
activeOverlays.has('new-developments') && viewState.zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||
const { developments } = useDevelopments(viewportBounds ?? null, {
|
||||
enabled: developmentsEnabled,
|
||||
});
|
||||
|
||||
const {
|
||||
layers,
|
||||
popupInfo,
|
||||
|
|
@ -432,6 +452,8 @@ export default memo(function Map({
|
|||
visiblePois,
|
||||
listingPopup,
|
||||
clearListingPopup,
|
||||
developmentPopup,
|
||||
clearDevelopmentPopup,
|
||||
hoverPosition,
|
||||
countRange,
|
||||
postcodeCountRange,
|
||||
|
|
@ -444,6 +466,7 @@ export default memo(function Map({
|
|||
zoom: viewState.zoom,
|
||||
pois,
|
||||
actualListings,
|
||||
developments,
|
||||
viewFeature,
|
||||
colorRange,
|
||||
filterRange,
|
||||
|
|
@ -468,7 +491,8 @@ export default memo(function Map({
|
|||
return [];
|
||||
}
|
||||
|
||||
return visiblePois.flatMap((poi) => {
|
||||
const candidates: PlacedItem<POI>[] = [];
|
||||
for (const poi of visiblePois) {
|
||||
const point = map.project([poi.lng, poi.lat]);
|
||||
if (
|
||||
!Number.isFinite(point.x) ||
|
||||
|
|
@ -478,10 +502,18 @@ export default memo(function Map({
|
|||
point.y < 0 ||
|
||||
point.y > dimensions.height
|
||||
) {
|
||||
return [];
|
||||
continue;
|
||||
}
|
||||
return [{ poi, x: point.x, y: point.y }];
|
||||
});
|
||||
candidates.push({ item: poi, x: point.x, y: point.y });
|
||||
}
|
||||
// Cull overlapping cards so a dense area with many categories doesn't render a
|
||||
// wall of hundreds of cards over the map.
|
||||
return selectSpacedItems(
|
||||
candidates,
|
||||
AUTO_POI_CARD_MIN_DX,
|
||||
AUTO_POI_CARD_MIN_DY,
|
||||
MAX_AUTO_POI_CARDS
|
||||
);
|
||||
// viewState isn't read directly but drives map.project — recompute when the camera moves.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
|
||||
|
|
@ -599,7 +631,7 @@ export default memo(function Map({
|
|||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
{autoPoiCards.map(({ poi, x, y }) => (
|
||||
{autoPoiCards.map(({ item: poi, x, y }) => (
|
||||
<div
|
||||
key={poi.id}
|
||||
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
||||
|
|
@ -673,6 +705,27 @@ export default memo(function Map({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{developmentPopup && (
|
||||
<div
|
||||
className="pointer-events-auto absolute max-w-[280px] rounded-lg bg-white text-sm shadow-lg dark:bg-warm-800 dark:text-white"
|
||||
style={{
|
||||
left: developmentPopup.x,
|
||||
top: developmentPopup.y - 12,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
zIndex: 30,
|
||||
}}
|
||||
onMouseLeave={clearDevelopmentPopup}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
|
||||
onClick={clearDevelopmentPopup}
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
<DevelopmentPopupContent development={developmentPopup.development} />
|
||||
</div>
|
||||
)}
|
||||
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
|
||||
<HoverCardOverlay
|
||||
x={hoverPosition.x}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import type { ActualListing, PostcodeGeometry } from '../../types';
|
||||
import type { ActualListing, POI, PostcodeGeometry } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import { useMapData } from '../../hooks/useMapData';
|
||||
import { usePOIData } from '../../hooks/usePOIData';
|
||||
|
|
@ -88,6 +88,7 @@ export type { ExportState } from './map-page/types';
|
|||
declare const __DEV__: boolean;
|
||||
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
const EMPTY_POIS: POI[] = [];
|
||||
|
||||
export default function MapPage({
|
||||
features,
|
||||
|
|
@ -562,7 +563,11 @@ export default function MapPage({
|
|||
if (filtersUnlimited) setDemoPromptOpen(false);
|
||||
}, [filtersUnlimited]);
|
||||
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
// Disabling POIs (clearing every category) must remove all POI cards/markers
|
||||
// immediately, not on the next fetch tick — gate on the selection itself so a
|
||||
// stale fetch result can never keep cards on screen.
|
||||
const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS;
|
||||
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
|
||||
const actualListingsFilterParam = useMemo(
|
||||
() => buildFilterString(filters, features),
|
||||
|
|
|
|||
83
frontend/src/components/map/NumberLine.test.ts
Normal file
83
frontend/src/components/map/NumberLine.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { computeNumberLineLayout, type NumberLinePoint } from './NumberLine';
|
||||
|
||||
const fmt = (v: number) => v.toFixed(0);
|
||||
|
||||
const points: NumberLinePoint[] = [
|
||||
{ kind: 'area', label: 'This area', value: 50 },
|
||||
{ kind: 'national', label: 'National', value: 30 },
|
||||
{ kind: 'outcode', label: 'SE3', value: 49 },
|
||||
{ kind: 'sector', label: 'SE3 9', value: 51 },
|
||||
];
|
||||
|
||||
describe('computeNumberLineLayout', () => {
|
||||
it('returns null when there is nothing to draw', () => {
|
||||
expect(computeNumberLineLayout(points, 0, fmt)).toBeNull();
|
||||
expect(computeNumberLineLayout([], 300, fmt)).toBeNull();
|
||||
});
|
||||
|
||||
it('places ticks ordered by value', () => {
|
||||
const layout = computeNumberLineLayout(points, 300, fmt)!;
|
||||
expect(layout).not.toBeNull();
|
||||
// Items are sorted by tick position, which mirrors value order.
|
||||
const ticks = layout.items.map((i) => i.tickX);
|
||||
for (let i = 1; i < ticks.length; i++) {
|
||||
expect(ticks[i]).toBeGreaterThanOrEqual(ticks[i - 1]);
|
||||
}
|
||||
// The smallest value (national, 30) sits left of the largest (sector, 51).
|
||||
const national = layout.items.find((i) => i.kind === 'national')!;
|
||||
const sector = layout.items.find((i) => i.kind === 'sector')!;
|
||||
expect(national.tickX).toBeLessThan(sector.tickX);
|
||||
});
|
||||
|
||||
it('maps the lowest value to the left edge and the highest to the right', () => {
|
||||
const layout = computeNumberLineLayout(
|
||||
[
|
||||
{ kind: 'national', label: 'N', value: 30 },
|
||||
{ kind: 'area', label: 'A', value: 50 },
|
||||
{ kind: 'sector', label: 'S', value: 45 },
|
||||
],
|
||||
300,
|
||||
fmt
|
||||
)!;
|
||||
const low = layout.items.find((i) => i.kind === 'national')!; // 30 — lowest
|
||||
const high = layout.items.find((i) => i.kind === 'area')!; // 50 — highest
|
||||
expect(low.tickX).toBeCloseTo(layout.plotLeft, 5);
|
||||
expect(high.tickX).toBeCloseTo(layout.plotRight, 5);
|
||||
});
|
||||
|
||||
it('centres ticks when all values are equal', () => {
|
||||
const layout = computeNumberLineLayout(
|
||||
[
|
||||
{ kind: 'area', label: 'A', value: 7 },
|
||||
{ kind: 'national', label: 'N', value: 7 },
|
||||
],
|
||||
300,
|
||||
fmt
|
||||
)!;
|
||||
const centre = (layout.plotLeft + layout.plotRight) / 2;
|
||||
for (const item of layout.items) {
|
||||
expect(item.tickX).toBeCloseTo(centre, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it('spreads clustered labels so their boxes never overlap', () => {
|
||||
const layout = computeNumberLineLayout(points, 300, fmt)!;
|
||||
for (let i = 1; i < layout.items.length; i++) {
|
||||
const prev = layout.items[i - 1];
|
||||
const cur = layout.items[i];
|
||||
// Boxes [labelX ± halfWidth] must not overlap (GAP only adds more margin).
|
||||
expect(cur.labelX - cur.halfWidth).toBeGreaterThanOrEqual(
|
||||
prev.labelX + prev.halfWidth - 1e-6
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps labels within the plot bounds', () => {
|
||||
const layout = computeNumberLineLayout(points, 300, fmt)!;
|
||||
for (const item of layout.items) {
|
||||
expect(item.labelX - item.halfWidth).toBeGreaterThanOrEqual(layout.plotLeft - 1e-6);
|
||||
expect(item.labelX + item.halfWidth).toBeLessThanOrEqual(layout.plotRight + 1e-6);
|
||||
}
|
||||
});
|
||||
});
|
||||
208
frontend/src/components/map/NumberLine.tsx
Normal file
208
frontend/src/components/map/NumberLine.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export type NumberLineKind = 'area' | 'national' | 'outcode' | 'sector';
|
||||
|
||||
export interface NumberLinePoint {
|
||||
kind: NumberLineKind;
|
||||
/** Short label shown above the tick (e.g. "This area", "National", "SE3", "SE3 9"). */
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface NumberLineProps {
|
||||
points: NumberLinePoint[];
|
||||
format: (value: number) => string;
|
||||
}
|
||||
|
||||
const HEIGHT = 54;
|
||||
const PAD_X = 4;
|
||||
const NAME_Y = 9;
|
||||
const VALUE_Y = 19;
|
||||
const LEADER_TOP = 23;
|
||||
const BASE_Y = 40;
|
||||
const GAP = 4;
|
||||
|
||||
// Per-kind tick (stroke) and label (fill) colours. `area` is the subject and
|
||||
// is emphasised; the rest are reference points.
|
||||
const KIND_STYLE: Record<NumberLineKind, { tick: string; text: string }> = {
|
||||
area: { tick: 'stroke-teal-600 dark:stroke-teal-400', text: 'fill-teal-700 dark:fill-teal-300' },
|
||||
national: {
|
||||
tick: 'stroke-warm-400 dark:stroke-warm-500',
|
||||
text: 'fill-warm-500 dark:fill-warm-300',
|
||||
},
|
||||
outcode: {
|
||||
tick: 'stroke-amber-500 dark:stroke-amber-400',
|
||||
text: 'fill-amber-700 dark:fill-amber-400',
|
||||
},
|
||||
sector: {
|
||||
tick: 'stroke-indigo-500 dark:stroke-indigo-400',
|
||||
text: 'fill-indigo-600 dark:fill-indigo-300',
|
||||
},
|
||||
};
|
||||
|
||||
export interface NumberLineLayoutItem extends NumberLinePoint {
|
||||
valueText: string;
|
||||
/** x of the tick (the true value position on the scale). */
|
||||
tickX: number;
|
||||
/** x of the (de-collided) label centre. */
|
||||
labelX: number;
|
||||
/** half the estimated label-box width, used for collision spacing. */
|
||||
halfWidth: number;
|
||||
}
|
||||
|
||||
export interface NumberLineLayout {
|
||||
items: NumberLineLayoutItem[];
|
||||
plotLeft: number;
|
||||
plotRight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure layout: place each value's tick on a scale spanning the lowest→highest
|
||||
* value (not anchored at 0, to maximise separation) and spread the labels so
|
||||
* their boxes never overlap (nested area/sector/outcode values tend to cluster),
|
||||
* clamped within the plot bounds while preserving order. Returns null when there
|
||||
* is nothing to draw. Exported for testing.
|
||||
*/
|
||||
export function computeNumberLineLayout(
|
||||
points: NumberLinePoint[],
|
||||
width: number,
|
||||
format: (value: number) => string
|
||||
): NumberLineLayout | null {
|
||||
if (width <= 0 || points.length === 0) return null;
|
||||
|
||||
const values = points.map((p) => p.value);
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const span = hi - lo;
|
||||
const plotLeft = PAD_X;
|
||||
const plotRight = width - PAD_X;
|
||||
const plotW = Math.max(1, plotRight - plotLeft);
|
||||
// Scale spans the lowest→highest value (not anchored at 0) to maximise the
|
||||
// separation between the clustered ticks. When all values are equal, centre them.
|
||||
const scaleX = (value: number) =>
|
||||
span > 0 ? plotLeft + ((value - lo) / span) * plotW : plotLeft + plotW / 2;
|
||||
|
||||
const items: NumberLineLayoutItem[] = points.map((point) => {
|
||||
const valueText = format(point.value);
|
||||
// Rough text-width estimate at the 9px font (≈5px/char), padded a little.
|
||||
const chars = Math.max(point.label.length, valueText.length);
|
||||
const halfWidth = Math.max(9, (chars * 5) / 2 + 2);
|
||||
const tickX = scaleX(point.value);
|
||||
return { ...point, valueText, tickX, halfWidth, labelX: tickX };
|
||||
});
|
||||
|
||||
// Spread labels left-to-right so adjacent boxes never overlap, then clamp to
|
||||
// the plot bounds (right pass, then left pass) preserving order.
|
||||
items.sort((a, b) => a.tickX - b.tickX);
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth;
|
||||
if (items[i].labelX < minX) items[i].labelX = minX;
|
||||
}
|
||||
const last = items.length - 1;
|
||||
if (last >= 0) {
|
||||
items[last].labelX = Math.min(items[last].labelX, plotRight - items[last].halfWidth);
|
||||
for (let i = last - 1; i >= 0; i--) {
|
||||
const maxX = items[i + 1].labelX - items[i + 1].halfWidth - GAP - items[i].halfWidth;
|
||||
if (items[i].labelX > maxX) items[i].labelX = maxX;
|
||||
}
|
||||
items[0].labelX = Math.max(items[0].labelX, plotLeft + items[0].halfWidth);
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth;
|
||||
if (items[i].labelX < minX) items[i].labelX = minX;
|
||||
}
|
||||
}
|
||||
|
||||
return { items, plotLeft, plotRight };
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact horizontal number line that places one tick per value on a scale
|
||||
* spanning the lowest→highest value, so the selection can be read against its
|
||||
* reference points (national / outcode / sector) at a glance. Labels are spread
|
||||
* to avoid overlap
|
||||
* — common since the nested area/sector/outcode values cluster — and connected
|
||||
* back to their tick with a thin leader line.
|
||||
*/
|
||||
export default function NumberLine({ points, format }: NumberLineProps) {
|
||||
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 laidOut = useMemo(
|
||||
() => computeNumberLineLayout(points, width, format),
|
||||
[points, width, format]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full">
|
||||
{laidOut && (
|
||||
<svg width={width} height={HEIGHT} className="block">
|
||||
<line
|
||||
x1={laidOut.plotLeft}
|
||||
y1={BASE_Y}
|
||||
x2={laidOut.plotRight}
|
||||
y2={BASE_Y}
|
||||
strokeWidth={1}
|
||||
className="stroke-warm-300 dark:stroke-warm-600"
|
||||
/>
|
||||
{laidOut.items.map((item) => {
|
||||
const style = KIND_STYLE[item.kind];
|
||||
const emphasized = item.kind === 'area';
|
||||
const halfTick = emphasized ? 7 : 5;
|
||||
return (
|
||||
<g key={item.kind}>
|
||||
<line
|
||||
x1={item.labelX}
|
||||
y1={LEADER_TOP}
|
||||
x2={item.tickX}
|
||||
y2={BASE_Y - halfTick}
|
||||
strokeWidth={0.75}
|
||||
className="stroke-warm-300 dark:stroke-warm-600"
|
||||
/>
|
||||
<line
|
||||
x1={item.tickX}
|
||||
y1={BASE_Y - halfTick}
|
||||
x2={item.tickX}
|
||||
y2={BASE_Y + halfTick}
|
||||
strokeWidth={emphasized ? 2.5 : 1.5}
|
||||
strokeLinecap="round"
|
||||
className={style.tick}
|
||||
/>
|
||||
<text
|
||||
x={item.labelX}
|
||||
y={NAME_Y}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fontWeight={emphasized ? 700 : 500}
|
||||
className={style.text}
|
||||
>
|
||||
{item.label}
|
||||
</text>
|
||||
<text
|
||||
x={item.labelX}
|
||||
y={VALUE_Y}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
className={style.text}
|
||||
>
|
||||
{item.valueText}
|
||||
</text>
|
||||
<title>{`${item.label}: ${item.valueText}`}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -79,8 +79,43 @@ export function OverlayTileLayers({
|
|||
10,
|
||||
1,
|
||||
],
|
||||
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
|
||||
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
|
||||
// police.uk snaps incidents to a sparse, fixed set of anonymised
|
||||
// "map point" anchors (tens-to-hundreds of metres apart), so a
|
||||
// pixel-fixed blur fragments into isolated dots as you zoom in
|
||||
// (the ground a pixel covers halves each zoom level). Grow the
|
||||
// radius ~geometrically with zoom to hold a roughly constant
|
||||
// ~140m ground footprint, so neighbouring anchors' kernels keep
|
||||
// overlapping into a connected surface — kernel-density smoothing
|
||||
// is the honest form of "interpolating between points" for this
|
||||
// data; we deliberately do NOT triangulate/IDW, which would
|
||||
// invent crime values across parks/water and over-claim a
|
||||
// precision the anonymised snap-points don't have. Cap past z16 so
|
||||
// the blur doesn't saturate the whole screen at street zoom (where
|
||||
// an honestly-connected crime surface isn't achievable anyway).
|
||||
// Intensity is lowered to match: wider overlapping kernels pile up
|
||||
// density, so the old isolated-dot boost would now go all-red.
|
||||
'heatmap-intensity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
14,
|
||||
0.6,
|
||||
16,
|
||||
0.9,
|
||||
18,
|
||||
1.2,
|
||||
],
|
||||
'heatmap-radius': [
|
||||
'interpolate',
|
||||
['exponential', 2],
|
||||
['zoom'],
|
||||
14,
|
||||
24,
|
||||
16,
|
||||
94,
|
||||
18,
|
||||
120,
|
||||
],
|
||||
'heatmap-opacity': 0.72,
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export function ActiveFiltersPanel({
|
|||
<div className="px-3 pb-2 space-y-2">
|
||||
<button
|
||||
onClick={onShowPhilosophy}
|
||||
data-video-hide="ai-cta"
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
FeatureMeta,
|
||||
Bounds,
|
||||
ActualListing,
|
||||
Development,
|
||||
} from '../types';
|
||||
import {
|
||||
DENSITY_GRADIENT,
|
||||
|
|
@ -23,6 +24,7 @@ import { getFeatureFillColor } from '../lib/map-utils';
|
|||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { usePoiLayers } from './usePoiLayers';
|
||||
import { useListingLayers } from './useListingLayers';
|
||||
import { useDevelopmentLayers } from './useDevelopmentLayers';
|
||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||
import { PieHexExtension } from '../lib/PieHexExtension';
|
||||
import { normalizeColorOpacity } from '../lib/color-opacity';
|
||||
|
|
@ -34,6 +36,7 @@ interface UseDeckLayersProps {
|
|||
zoom: number;
|
||||
pois: POI[];
|
||||
actualListings: ActualListing[];
|
||||
developments: Development[];
|
||||
viewFeature: string | null;
|
||||
colorRange: [number, number] | null;
|
||||
filterRange: [number, number] | null;
|
||||
|
|
@ -85,6 +88,7 @@ export function useDeckLayers({
|
|||
zoom,
|
||||
pois,
|
||||
actualListings,
|
||||
developments,
|
||||
viewFeature,
|
||||
colorRange,
|
||||
filterRange,
|
||||
|
|
@ -126,6 +130,11 @@ export function useDeckLayers({
|
|||
zoom,
|
||||
isDark,
|
||||
});
|
||||
const { developmentLayers, developmentPopup, clearDevelopmentPopup } = useDevelopmentLayers({
|
||||
developments,
|
||||
zoom,
|
||||
isDark,
|
||||
});
|
||||
|
||||
// --- Refs for deck.gl accessors ---
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
|
|
@ -709,6 +718,7 @@ export function useDeckLayers({
|
|||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
|
||||
if (listingLayers.length > 0) baseLayers.push(...listingLayers);
|
||||
if (developmentLayers.length > 0) baseLayers.push(...developmentLayers);
|
||||
return baseLayers;
|
||||
}, [
|
||||
usePostcodeView,
|
||||
|
|
@ -720,6 +730,7 @@ export function useDeckLayers({
|
|||
marchingAntsLayer,
|
||||
currentLocationLayer,
|
||||
listingLayers,
|
||||
developmentLayers,
|
||||
]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
|
|
@ -727,8 +738,9 @@ export function useDeckLayers({
|
|||
setHoveredPostcode(null);
|
||||
clearPopupInfo();
|
||||
clearListingPopup();
|
||||
clearDevelopmentPopup();
|
||||
onHexagonHoverRef.current(null);
|
||||
}, [clearPopupInfo, clearListingPopup]);
|
||||
}, [clearPopupInfo, clearListingPopup, clearDevelopmentPopup]);
|
||||
|
||||
return {
|
||||
layers,
|
||||
|
|
@ -737,6 +749,8 @@ export function useDeckLayers({
|
|||
clearPopupInfo,
|
||||
listingPopup,
|
||||
clearListingPopup,
|
||||
developmentPopup,
|
||||
clearDevelopmentPopup,
|
||||
hoverPosition,
|
||||
countRange,
|
||||
postcodeCountRange,
|
||||
|
|
|
|||
152
frontend/src/hooks/useDevelopmentLayers.ts
Normal file
152
frontend/src/hooks/useDevelopmentLayers.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
|
||||
import type { Development } from '../types';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
const COUNT_LABEL_MIN_ZOOM = 14;
|
||||
|
||||
export interface DevelopmentPopupInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
development: Development;
|
||||
}
|
||||
|
||||
interface UseDevelopmentLayersProps {
|
||||
developments: Development[];
|
||||
zoom: number;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
function dwellingCount(d: Development): number | null {
|
||||
return d.max_dwellings ?? d.min_dwellings ?? null;
|
||||
}
|
||||
|
||||
// Pixel radius scales with the number of homes so larger schemes read as larger
|
||||
// dots. sqrt compresses the range (a 5,000-home site shouldn't dwarf a 20-home
|
||||
// one) and the result is clamped to a sane band; sites with no count get the floor.
|
||||
function radiusForDwellings(d: Development): number {
|
||||
const count = dwellingCount(d) ?? 0;
|
||||
return Math.min(22, 4 + Math.sqrt(Math.max(count, 0)) * 0.8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders development sites as blue point markers (a soft shadow, a pin, and a
|
||||
* dwelling-count label at higher zoom). Sparse data — the endpoint caps the
|
||||
* viewport at a few thousand points — so no clustering is needed, unlike the
|
||||
* listings layer. Hover shows a popup; click opens the planning record.
|
||||
*/
|
||||
export function useDevelopmentLayers({ developments, zoom, isDark }: UseDevelopmentLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<DevelopmentPopupInfo | null>(null);
|
||||
|
||||
// A fresh fetch returns a new array; the previously hovered object is gone.
|
||||
useEffect(() => {
|
||||
setPopupInfo(null);
|
||||
}, [developments]);
|
||||
|
||||
const points = useMemo(
|
||||
() => developments.filter((d) => Number.isFinite(d.lat) && Number.isFinite(d.lon)),
|
||||
[developments]
|
||||
);
|
||||
|
||||
const handleHover = useCallback((info: PickingInfo<Development>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({ x: info.x, y: info.y, development: info.object });
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((info: PickingInfo<Development>) => {
|
||||
const url = info.object?.url;
|
||||
if (!url) return;
|
||||
trackEvent('Development Site Click', { url });
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
const handleHoverRef = useRef(handleHover);
|
||||
handleHoverRef.current = handleHover;
|
||||
const stableHover = useCallback(
|
||||
(info: PickingInfo<Development>) => handleHoverRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClickRef = useRef(handleClick);
|
||||
handleClickRef.current = handleClick;
|
||||
const stableClick = useCallback(
|
||||
(info: PickingInfo<Development>) => handleClickRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const shadowLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<Development>({
|
||||
id: 'development-shadow',
|
||||
data: points,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: (d) => radiusForDwellings(d) + 1.5,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
|
||||
pickable: false,
|
||||
}),
|
||||
[points, isDark]
|
||||
);
|
||||
|
||||
const pinLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<Development>({
|
||||
id: 'development-pin',
|
||||
data: points,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: radiusForDwellings,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: [37, 99, 235, 235],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
autoHighlight: true,
|
||||
highlightColor: [29, 228, 195, 220],
|
||||
onHover: stableHover,
|
||||
onClick: stableClick,
|
||||
}),
|
||||
[points, stableHover, stableClick]
|
||||
);
|
||||
|
||||
const countLabelLayer = useMemo(() => {
|
||||
if (zoom < COUNT_LABEL_MIN_ZOOM) return null;
|
||||
const labeled = points.filter((d) => (dwellingCount(d) ?? 0) > 0);
|
||||
return new TextLayer<Development>({
|
||||
id: 'development-count',
|
||||
data: labeled,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getText: (d) => String(dwellingCount(d) ?? ''),
|
||||
getSize: 11,
|
||||
getPixelOffset: (d) => [0, -(radiusForDwellings(d) + 6)],
|
||||
getColor: isDark ? [191, 219, 254, 240] : [30, 58, 138, 240],
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 700,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'bottom',
|
||||
outlineWidth: 3,
|
||||
outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230],
|
||||
fontSettings: { sdf: true },
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 9,
|
||||
sizeMaxPixels: 13,
|
||||
pickable: false,
|
||||
});
|
||||
}, [points, zoom, isDark]);
|
||||
|
||||
const developmentLayers = useMemo(() => {
|
||||
const layers: Layer[] = [shadowLayer, pinLayer];
|
||||
if (countLabelLayer) layers.push(countLabelLayer);
|
||||
return layers;
|
||||
}, [shadowLayer, pinLayer, countLabelLayer]);
|
||||
|
||||
const clearDevelopmentPopup = useCallback(() => setPopupInfo(null), []);
|
||||
|
||||
return { developmentLayers, developmentPopup: popupInfo, clearDevelopmentPopup };
|
||||
}
|
||||
81
frontend/src/hooks/useDevelopments.ts
Normal file
81
frontend/src/hooks/useDevelopments.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Bounds, Development, DevelopmentsResponse } from '../types';
|
||||
import { apiUrl, authHeaders, isAbortError, logNonAbortError } from '../lib/api';
|
||||
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
interface UseDevelopmentsOptions {
|
||||
/** Only fetch when the "new developments" overlay is toggled on. */
|
||||
enabled?: boolean;
|
||||
shareCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches planned/pipeline development sites (brownfield register + Homes
|
||||
* England) within the current viewport. Mirrors useActualListings, but gated by
|
||||
* an `enabled` flag so nothing is fetched until the overlay is switched on.
|
||||
*/
|
||||
export function useDevelopments(
|
||||
bounds: Bounds | null,
|
||||
{ enabled = false, shareCode = '' }: UseDevelopmentsOptions = {}
|
||||
) {
|
||||
const [developments, setDevelopments] = useState<Development[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
|
||||
if (!enabled || !bounds) {
|
||||
abortControllerRef.current?.abort();
|
||||
if (developments.length !== 0) setDevelopments([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
setLoading(true);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
try {
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
const res = await fetch(
|
||||
apiUrl('developments', params),
|
||||
authHeaders({ signal: abortControllerRef.current.signal })
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (requestIdRef.current === requestId) {
|
||||
setDevelopments([]);
|
||||
setLoading(false);
|
||||
}
|
||||
throw new Error(`Developments fetch failed: HTTP ${res.status}`);
|
||||
}
|
||||
const json: DevelopmentsResponse = await res.json();
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setDevelopments(json.developments || []);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (requestIdRef.current === requestId && !isAbortError(err)) {
|
||||
setLoading(false);
|
||||
}
|
||||
logNonAbortError('Failed to fetch developments', err);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
// developments intentionally excluded — it's internal state, not an input.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, bounds, shareCode]);
|
||||
|
||||
return { developments, loading };
|
||||
}
|
||||
|
|
@ -186,6 +186,47 @@ describe('usePoiLayers', () => {
|
|||
expect(result.current.popupInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('dismisses a stuck hover popup once its POI is gone', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ pois }) => usePoiLayers({ pois, zoom: 15, isDark: false }),
|
||||
{ initialProps: { pois: [supermarket, busStop] } }
|
||||
);
|
||||
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
|
||||
|
||||
act(() => {
|
||||
(backgroundLayer.props.onHover as (info: unknown) => void)({
|
||||
object: supermarket,
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
});
|
||||
expect(result.current.popupInfo?.id).toBe(supermarket.id);
|
||||
|
||||
// Disabling POIs empties the list — the popup must not stay stuck on screen.
|
||||
rerender({ pois: [] });
|
||||
expect(result.current.popupInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('dismisses the hover popup when only its specific POI is deselected', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ pois }) => usePoiLayers({ pois, zoom: 15, isDark: false }),
|
||||
{ initialProps: { pois: [supermarket, busStop] } }
|
||||
);
|
||||
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
|
||||
act(() => {
|
||||
(backgroundLayer.props.onHover as (info: unknown) => void)({
|
||||
object: supermarket,
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
});
|
||||
expect(result.current.popupInfo?.id).toBe(supermarket.id);
|
||||
|
||||
// busStop remains but the hovered supermarket is gone -> popup clears.
|
||||
rerender({ pois: [busStop] });
|
||||
expect(result.current.popupInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('creates cluster hover popup state from clustered POIs', () => {
|
||||
const clusteredPois = Array.from(
|
||||
{ length: 4 },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import Supercluster from 'supercluster';
|
||||
|
|
@ -67,6 +67,18 @@ function getPoiIconSize(poi: POI): number {
|
|||
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||
|
||||
// Dismiss a lingering hover/cluster popup once the POIs behind it are gone — e.g.
|
||||
// after the user clears the POI categories. Without this the card stays stuck on
|
||||
// screen because it is only otherwise cleared on mouse-leave or the close button.
|
||||
useEffect(() => {
|
||||
setPopupInfo((current) => {
|
||||
if (!current) return current;
|
||||
if (pois.length === 0) return null;
|
||||
if (current.isCluster) return current;
|
||||
return pois.some((poi) => poi.id === current.id) ? current : null;
|
||||
});
|
||||
}, [pois]);
|
||||
|
||||
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
|
|
|
|||
|
|
@ -77,6 +77,25 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Public order (avg/yr)': 'Moyenne annuelle des troubles à l’ordre public dans le secteur',
|
||||
'Other crime (avg/yr)': 'Moyenne annuelle des autres infractions dans le secteur',
|
||||
'Median age': 'Âge médian de la population locale',
|
||||
'% No qualifications': 'Part des résidents (16+) sans aucun diplôme',
|
||||
'% Some GCSEs':
|
||||
'Part des résidents (16+) dont le diplôme le plus élevé équivaut à 1 à 4 GCSE environ (niveau 1)',
|
||||
'% Good GCSEs':
|
||||
'Part des résidents (16+) dont le diplôme le plus élevé est 5 GCSE ou plus (niveau 2)',
|
||||
'% Apprenticeship':
|
||||
'Part des résidents (16+) dont le diplôme le plus élevé est un apprentissage',
|
||||
'% A-levels':
|
||||
'Part des résidents (16+) dont le diplôme le plus élevé est le A-levels (niveau 3)',
|
||||
'% Degree or higher':
|
||||
'Part des résidents (16+) ayant un diplôme de niveau universitaire ou supérieur',
|
||||
'% Other qualifications':
|
||||
"Part des résidents (16+) ayant d'autres diplômes, y compris professionnels ou obtenus à l'étranger",
|
||||
'% Owner occupied':
|
||||
'Part des ménages propriétaires de leur logement, intégralement ou avec un crédit',
|
||||
'% Social rent':
|
||||
"Part des ménages locataires auprès d'une collectivité locale ou d'un bailleur social",
|
||||
'% Private rent':
|
||||
'Part des ménages locataires dans le privé ou logés à titre gratuit',
|
||||
'% White': 'Part de la population s’identifiant comme blanche',
|
||||
'% South Asian': 'Part de la population s’identifiant comme sud-asiatique',
|
||||
'% Black': 'Part de la population s’identifiant comme noire',
|
||||
|
|
@ -170,6 +189,23 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
|
||||
'Other crime (avg/yr)': 'Jährlicher Durchschnitt sonstiger Straftaten in der Gegend',
|
||||
'Median age': 'Medianalter der lokalen Bevölkerung',
|
||||
'% No qualifications': 'Anteil der Einwohner (16+) ohne formalen Abschluss',
|
||||
'% Some GCSEs':
|
||||
'Anteil der Einwohner (16+), deren höchster Abschluss etwa 1–4 GCSEs entspricht (Level 1)',
|
||||
'% Good GCSEs':
|
||||
'Anteil der Einwohner (16+), deren höchster Abschluss 5+ GCSEs entspricht (Level 2)',
|
||||
'% Apprenticeship': 'Anteil der Einwohner (16+), deren höchster Abschluss eine Ausbildung ist',
|
||||
'% A-levels': 'Anteil der Einwohner (16+), deren höchster Abschluss A-levels ist (Level 3)',
|
||||
'% Degree or higher':
|
||||
'Anteil der Einwohner (16+) mit einem Abschluss auf Hochschulniveau oder höher',
|
||||
'% Other qualifications':
|
||||
'Anteil der Einwohner (16+) mit sonstigen Abschlüssen, einschließlich beruflicher oder im Ausland erworbener',
|
||||
'% Owner occupied':
|
||||
'Anteil der Haushalte, die ihre Wohnung besitzen, schuldenfrei oder mit Hypothek',
|
||||
'% Social rent':
|
||||
'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten',
|
||||
'% Private rent':
|
||||
'Anteil der Haushalte, die privat mieten oder mietfrei wohnen',
|
||||
'% White': 'Anteil der Personen, die sich als weiß identifizieren',
|
||||
'% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren',
|
||||
'% Black': 'Anteil der Personen, die sich als schwarz identifizieren',
|
||||
|
|
@ -245,6 +281,16 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Public order (avg/yr)': '该地区年均扰乱公共秩序数',
|
||||
'Other crime (avg/yr)': '该地区年均其他犯罪数',
|
||||
'Median age': '当地人口的中位年龄',
|
||||
'% No qualifications': '无任何学历的居民(16岁以上)占比',
|
||||
'% Some GCSEs': '最高学历约为 1–4 门 GCSE(Level 1)的居民(16岁以上)占比',
|
||||
'% Good GCSEs': '最高学历为 5 门及以上 GCSE(Level 2)的居民(16岁以上)占比',
|
||||
'% Apprenticeship': '最高学历为学徒制的居民(16岁以上)占比',
|
||||
'% A-levels': '最高学历为 A-levels(Level 3)的居民(16岁以上)占比',
|
||||
'% Degree or higher': '拥有学位或更高学历的居民(16岁以上)占比',
|
||||
'% Other qualifications': '拥有其他学历(包括职业资格或境外取得)的居民(16岁以上)占比',
|
||||
'% Owner occupied': '拥有自住房(全款或按揭)的家庭比例',
|
||||
'% Social rent': '向地方政府或住房协会租房的家庭比例',
|
||||
'% Private rent': '私人租房或免租居住的家庭比例',
|
||||
'% White': '白人人口比例',
|
||||
'% South Asian': '南亚裔人口比例',
|
||||
'% Black': '黑人人口比例',
|
||||
|
|
@ -324,6 +370,21 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Public order (avg/yr)': 'क्षेत्र में सार्वजनिक व्यवस्था अपराधों का सालाना औसत',
|
||||
'Other crime (avg/yr)': 'क्षेत्र में अन्य अपराधों का सालाना औसत',
|
||||
'Median age': 'स्थानीय आबादी की मध्य आयु',
|
||||
'% No qualifications': 'बिना किसी औपचारिक योग्यता वाले निवासियों (16+) का हिस्सा',
|
||||
'% Some GCSEs':
|
||||
'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता लगभग 1–4 GCSE (Level 1) है',
|
||||
'% Good GCSEs': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता 5+ GCSE (Level 2) है',
|
||||
'% Apprenticeship': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता अप्रेंटिसशिप है',
|
||||
'% A-levels': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता A-levels (Level 3) है',
|
||||
'% Degree or higher': 'डिग्री स्तर या उससे ऊंची योग्यता वाले निवासियों (16+) का हिस्सा',
|
||||
'% Other qualifications':
|
||||
'अन्य योग्यताओं वाले निवासियों (16+) का हिस्सा, जिनमें व्यावसायिक या विदेश में प्राप्त योग्यताएं शामिल हैं',
|
||||
'% Owner occupied':
|
||||
'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ',
|
||||
'% Social rent':
|
||||
'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा',
|
||||
'% Private rent':
|
||||
'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा',
|
||||
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||
|
|
@ -409,6 +470,22 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Public order (avg/yr)': 'Közrend elleni bűncselekmények éves átlaga a környéken',
|
||||
'Other crime (avg/yr)': 'Egyéb bűncselekmények éves átlaga a környéken',
|
||||
'Median age': 'A helyi lakosság medián életkora',
|
||||
'% No qualifications': 'A végzettség nélküli lakosok (16+) aránya',
|
||||
'% Some GCSEs':
|
||||
'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége kb. 1–4 GCSE (Level 1)',
|
||||
'% Good GCSEs':
|
||||
'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége 5+ GCSE (Level 2)',
|
||||
'% Apprenticeship': 'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége tanoncképzés',
|
||||
'% A-levels': 'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége A-levels (Level 3)',
|
||||
'% Degree or higher': 'A diploma szintű vagy magasabb végzettségű lakosok (16+) aránya',
|
||||
'% Other qualifications':
|
||||
'Az egyéb végzettségű lakosok (16+) aránya, ideértve a szakmai vagy külföldön szerzett képesítéseket is',
|
||||
'% Owner occupied':
|
||||
'Azon háztartások aránya, amelyek saját tulajdonú lakásban élnek, tehermentesen vagy jelzáloggal',
|
||||
'% Social rent':
|
||||
'Azon háztartások aránya, amelyek önkormányzattól vagy lakásszövetkezettől bérelnek',
|
||||
'% Private rent':
|
||||
'Azon háztartások aránya, amelyek magánbérleményben élnek vagy lakbér nélkül laknak',
|
||||
'% White': 'A fehérként azonosított lakosság aránya',
|
||||
'% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya',
|
||||
'% Black': 'A feketeként azonosított lakosság aránya',
|
||||
|
|
|
|||
|
|
@ -95,6 +95,26 @@ export const details: Record<string, Record<string, string>> = {
|
|||
"Nombre moyen annuel d'infractions « other crime » dans un rayon de 50 m du code postal, d’après les données de criminalité street-level de police.uk. Catégorie résiduelle pour les infractions non classées ailleurs.",
|
||||
'Median age':
|
||||
"Provient du Census 2021 (TS007A). Âge médian des résidents habituels dans le LSOA, calculé par interpolation linéaire à partir des effectifs par tranche d'âge de cinq ans. Les zones à population plus jeune ont tendance à être urbaines, universitaires ou à accueillir davantage de familles ; les médianes plus élevées sont typiques des zones rurales et côtières.",
|
||||
'% No qualifications':
|
||||
"D'après le recensement de 2021 (TS067). Pourcentage des résidents habituels âgés de 16 ans et plus dans le quartier qui ne possèdent aucun diplôme officiel.",
|
||||
'% Some GCSEs':
|
||||
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé correspond à environ 1 à 4 GCSE aux notes 9–4 (A*–C), ou à des qualifications de niveau d'entrée ou fondamental.",
|
||||
'% Good GCSEs':
|
||||
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé correspond à environ 5 GCSE ou plus aux notes 9–4 (A*–C), à un apprentissage intermédiaire ou équivalent.",
|
||||
'% Apprenticeship':
|
||||
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est un apprentissage.",
|
||||
'% A-levels':
|
||||
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est le A-levels, AS-levels, T-levels, un apprentissage avancé ou équivalent — généralement étudié après 16 ans et avant un diplôme universitaire.",
|
||||
'% Degree or higher':
|
||||
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est de niveau universitaire ou supérieur — licence, master ou doctorat, foundation degree, HNC/HND, NVQ 4–5 ou qualification professionnelle supérieure. Le recensement ne distingue pas les diplômes de premier cycle de ceux de troisième cycle.",
|
||||
'% Other qualifications':
|
||||
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est classé « autre » — qualifications professionnelles ou techniques non rattachées à un niveau britannique, et diplômes obtenus hors du Royaume-Uni.",
|
||||
'% Owner occupied':
|
||||
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) qui possèdent leur logement sans crédit, le possèdent avec un emprunt ou un crédit, ou le détiennent en propriété partagée.",
|
||||
'% Social rent':
|
||||
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'une collectivité ou d'une autorité locale, ou d'un bailleur social ou autre bailleur à vocation sociale.",
|
||||
'% Private rent':
|
||||
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'un propriétaire privé ou d'une agence de location, plus la faible part logée à titre gratuit.",
|
||||
'% White':
|
||||
"Provient du Census 2021. Pourcentage de la population du quartier (LSOA) s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).",
|
||||
'% South Asian':
|
||||
|
|
@ -235,6 +255,25 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Durchschnittliche Anzahl sonstiger Straftaten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene. Eine Sammelkategorie für Straftaten, die nicht anderweitig eingestuft sind.',
|
||||
'Median age':
|
||||
'Aus dem Census 2021 (TS007A). Medianalter der ortsansässigen Bevölkerung im LSOA, berechnet durch lineare Interpolation aus Fünfjahres-Altersband-Zählungen. Gebiete mit jüngerer Bevölkerung sind tendenziell städtisch, Universitätsstädte oder haben mehr Familien; höhere Mediane sind typisch für ländliche und Küstengebiete.',
|
||||
'% No qualifications':
|
||||
'Aus dem Census 2021 (TS067). Prozentsatz der gewöhnlichen Einwohner ab 16 Jahren im Viertel, die keinen formalen Bildungsabschluss haben.',
|
||||
'% Some GCSEs':
|
||||
'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 1 bis 4 GCSEs mit den Noten 9–4 (A*–C) oder Abschlüssen auf Einstiegs- bzw. Grundniveau.',
|
||||
'% Good GCSEs':
|
||||
'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 5 oder mehr GCSEs mit den Noten 9–4 (A*–C), einer mittleren Ausbildung oder Gleichwertigem.',
|
||||
'% Apprenticeship': 'Aus dem Census 2021 (TS067). Der höchste Abschluss ist eine Ausbildung.',
|
||||
'% A-levels':
|
||||
'Aus dem Census 2021 (TS067). Der höchste Abschluss sind A-levels, AS-levels, T-levels, eine weiterführende Ausbildung oder Gleichwertiges — meist nach dem 16. Lebensjahr und vor einem Studienabschluss erworben.',
|
||||
'% Degree or higher':
|
||||
'Aus dem Census 2021 (TS067). Der höchste Abschluss liegt auf Hochschulniveau oder darüber — Bachelor, Master oder Doktortitel, Foundation Degree, HNC/HND, NVQ 4–5 oder höhere berufliche Qualifikation. Der Census unterscheidet nicht zwischen Bachelor- und Masterabschlüssen.',
|
||||
'% Other qualifications':
|
||||
'Aus dem Census 2021 (TS067). Der höchste Abschluss wird als „sonstiger“ eingestuft — berufliche oder fachliche Qualifikationen, die keinem britischen Niveau zugeordnet sind, sowie außerhalb des Vereinigten Königreichs erworbene Abschlüsse.',
|
||||
'% Owner occupied':
|
||||
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die ihre Wohnung schuldenfrei besitzen, sie mit Hypothek oder Darlehen besitzen oder sie über gemeinschaftliches Eigentum (Shared Ownership) halten.',
|
||||
'% Social rent':
|
||||
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einer Kommune oder Gemeindeverwaltung oder von einer Wohnungsbaugesellschaft oder einem anderen sozialen Vermieter mieten.',
|
||||
'% Private rent':
|
||||
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einem privaten Vermieter oder einer Vermittlungsagentur mieten, zuzüglich des kleinen Anteils, der mietfrei wohnt.',
|
||||
'% White':
|
||||
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Nachbarschaft (LSOA), die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).',
|
||||
'% South Asian':
|
||||
|
|
@ -373,6 +412,25 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'LSOA内每年其他犯罪的平均数量,来自police.uk街道级犯罪数据。此类别涵盖未在其他分类中列出的犯罪行为。',
|
||||
'Median age':
|
||||
'来自2021年Census(TS007A)。通过对五岁年龄段人口数进行线性插值计算得出的LSOA常住居民年龄中位数。年轻人口集中的地区往往是城市、大学城或家庭聚居地;年龄中位数较高的地区多见于农村和沿海地区。',
|
||||
'% No qualifications':
|
||||
'数据来自 2021 年 Census(TS067)。指该社区 16 岁及以上常住居民中没有任何正式学历者所占的百分比。',
|
||||
'% Some GCSEs':
|
||||
'数据来自 2021 年 Census(TS067)。最高学历约为 1 至 4 门成绩为 9–4(A*–C)的 GCSE,或入门级/基础级别的资格。',
|
||||
'% Good GCSEs':
|
||||
'数据来自 2021 年 Census(TS067)。最高学历约为 5 门或以上成绩为 9–4(A*–C)的 GCSE、中级学徒制或同等资格。',
|
||||
'% Apprenticeship': '数据来自 2021 年 Census(TS067)。最高学历为学徒制。',
|
||||
'% A-levels':
|
||||
'数据来自 2021 年 Census(TS067)。最高学历为 A-levels、AS-levels、T-levels、高级学徒制或同等资格——通常在 16 岁之后、获得学位之前修读。',
|
||||
'% Degree or higher':
|
||||
'数据来自 2021 年 Census(TS067)。最高学历为学位及以上——学士、硕士或博士、预科学位、HNC/HND、NVQ 4–5,或更高级别的职业资格。该 Census 不区分本科与研究生学位。',
|
||||
'% Other qualifications':
|
||||
'数据来自 2021 年 Census(TS067)。最高学历被归为“其他”——未对应到英国级别的职业或专业资格,以及在英国境外取得的学历。',
|
||||
'% Owner occupied':
|
||||
'数据来自 2021 年 Census(TS054)。本地社区(LSOA)中全款拥有自住房、以按揭或贷款拥有自住房,或通过共享产权持有住房的家庭百分比。',
|
||||
'% Social rent':
|
||||
'数据来自 2021 年 Census(TS054)。本地社区(LSOA)中向地方议会或地方政府,或向住房协会及其他社会房东租房的家庭百分比。',
|
||||
'% Private rent':
|
||||
'数据来自 2021 年 Census(TS054)。本地社区(LSOA)中向私人房东或租赁中介租房的家庭百分比,外加少量免租居住的家庭。',
|
||||
'% White':
|
||||
'来自2021年Census。本地社区(LSOA)人口中认同为白人(英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景)的百分比。',
|
||||
'% South Asian':
|
||||
|
|
@ -508,6 +566,25 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'LSOA में प्रति वर्ष अन्य आपराधिक अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा से. यह उन अपराधों के लिए सामान्य श्रेणी है जिन्हें कहीं और वर्गीकृत नहीं किया गया है.',
|
||||
'Median age':
|
||||
'Census 2021 (TS007A) से. LSOA में सामान्य निवासियों की मध्य आयु, पांच-वर्षीय आयु समूहों की गणना से रैखिक इंटरपोलेशन द्वारा निकाली गई. युवा आबादी वाले क्षेत्र आमतौर पर शहरी, विश्वविद्यालय नगर या अधिक परिवारों वाले होते हैं; अधिक मध्य आयु वाले क्षेत्र आमतौर पर ग्रामीण और तटीय इलाकों में मिलते हैं.',
|
||||
'% No qualifications':
|
||||
'2021 की Census (TS067) से। पड़ोस में 16 वर्ष और उससे अधिक उम्र के उन सामान्य निवासियों का प्रतिशत जिनके पास कोई औपचारिक योग्यता नहीं है।',
|
||||
'% Some GCSEs':
|
||||
'2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 1 से 4 GCSE है जिनके ग्रेड 9–4 (A*–C) हैं, या एंट्री-लेवल/फाउंडेशन स्तर की योग्यताएं हैं।',
|
||||
'% Good GCSEs':
|
||||
'2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 5 या अधिक GCSE है जिनके ग्रेड 9–4 (A*–C) हैं, एक इंटरमीडिएट अप्रेंटिसशिप, या समकक्ष।',
|
||||
'% Apprenticeship': '2021 की Census (TS067) से। सर्वोच्च योग्यता एक अप्रेंटिसशिप है।',
|
||||
'% A-levels':
|
||||
'2021 की Census (TS067) से। सर्वोच्च योग्यता A-levels, AS-levels, T-levels, एक एडवांस्ड अप्रेंटिसशिप, या समकक्ष है — आमतौर पर 16 वर्ष की उम्र के बाद और डिग्री से पहले पढ़ी जाती है।',
|
||||
'% Degree or higher':
|
||||
'2021 की Census (TS067) से। सर्वोच्च योग्यता डिग्री स्तर या उससे ऊपर है — बैचलर, मास्टर या PhD, फाउंडेशन डिग्री, HNC/HND, NVQ 4–5, या उच्च व्यावसायिक योग्यता। Census स्नातक और स्नातकोत्तर डिग्री में अंतर नहीं करती।',
|
||||
'% Other qualifications':
|
||||
"2021 की Census (TS067) से। सर्वोच्च योग्यता 'अन्य' श्रेणी में आती है — ऐसी व्यावसायिक या पेशेवर योग्यताएं जो किसी UK स्तर से मेल नहीं खातीं, और UK के बाहर प्राप्त योग्यताएं।",
|
||||
'% Owner occupied':
|
||||
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो अपना घर पूर्ण रूप से अपने पास रखते हैं, बंधक या ऋण के साथ रखते हैं, या साझा स्वामित्व के माध्यम से रखते हैं।',
|
||||
'% Social rent':
|
||||
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो स्थानीय काउंसिल या स्थानीय प्राधिकरण से, या किसी हाउसिंग एसोसिएशन या अन्य सामाजिक मकान-मालिक से किराए पर रहते हैं।',
|
||||
'% Private rent':
|
||||
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो किसी निजी मकान-मालिक या किराया एजेंसी से किराए पर रहते हैं, साथ ही बिना किराए के रहने वाला छोटा हिस्सा।',
|
||||
'% White':
|
||||
'Census 2021 से. स्थानीय पड़ोस (LSOA) की आबादी का प्रतिशत जो खुद को श्वेत (अंग्रेज़, वेल्श, स्कॉटिश, उत्तरी आयरिश, ब्रिटिश, आयरिश, जिप्सी या आयरिश ट्रैवलर, रोमा या किसी अन्य श्वेत पृष्ठभूमि) के रूप में पहचानता है.',
|
||||
'% South Asian':
|
||||
|
|
@ -648,6 +725,25 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Az egyéb bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból. Gyűjtőkategória azoknak a bűncselekményeknek, amelyek máshol nem kerülnek besorolásra.',
|
||||
'Median age':
|
||||
'A 2021-es Census alapján (TS007A). Az LSOA szokásos lakóinak medián életkora, ötéves korcsoport-számlálásokból lineáris interpolációval számítva. A fiatalabb népességű területek jellemzően városiak, egyetemi városok vagy több családot vonzanak; az idősebb medián értékek jellemzően vidéki és tengerparti területekre jellemzők.',
|
||||
'% No qualifications':
|
||||
'A 2021-es Census (TS067) alapján. A városrész 16 éves és idősebb állandó lakosainak aránya, akik nem rendelkeznek semmilyen formális végzettséggel.',
|
||||
'% Some GCSEs':
|
||||
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 1–4 GCSE 9–4 (A*–C) osztályzattal, vagy belépő szintű/alapszintű képesítés.',
|
||||
'% Good GCSEs':
|
||||
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 5 vagy több GCSE 9–4 (A*–C) osztályzattal, középszintű tanoncképzés vagy ezzel egyenértékű.',
|
||||
'% Apprenticeship': 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség tanoncképzés.',
|
||||
'% A-levels':
|
||||
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség A-levels, AS-levels, T-levels, emelt szintű tanoncképzés vagy ezzel egyenértékű — jellemzően 16 éves kor után és a diploma előtt szerezhető meg.',
|
||||
'% Degree or higher':
|
||||
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség diploma szintű vagy afölötti — alapdiploma, mesterdiploma vagy PhD, foundation degree, HNC/HND, NVQ 4–5, vagy magasabb szakmai képesítés. A Census nem különbözteti meg az alap- és mesterszintű diplomákat.',
|
||||
'% Other qualifications':
|
||||
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség „egyéb“ besorolású — olyan szakmai vagy hivatásbeli képesítések, amelyek nem feleltethetők meg egy brit szintnek, valamint az Egyesült Királyságon kívül szerzett képesítések.',
|
||||
'% Owner occupied':
|
||||
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek tehermentesen birtokolják lakásukat, jelzáloggal vagy kölcsönnel birtokolják, vagy résztulajdon (shared ownership) keretében tartják.',
|
||||
'% Social rent':
|
||||
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek helyi önkormányzattól vagy hatóságtól, illetve lakásszövetkezettől vagy más szociális bérbeadótól bérelnek.',
|
||||
'% Private rent':
|
||||
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek magán bérbeadótól vagy ingatlanügynökségtől bérelnek, kiegészülve a lakbér nélkül lakók kis arányával.',
|
||||
'% White':
|
||||
'A 2021-es Census alapján. A helyi környéken (LSOA) fehérként (angol, walesi, skót, észak-ír, brit, ír, cigány vagy ír vándor, roma, vagy bármely más fehér háttér) azonosított népesség százaléka.',
|
||||
'% South Asian':
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import type { Translations } from './en';
|
||||
|
||||
const de: Translations = {
|
||||
newDevelopments: {
|
||||
title: 'Bauvorhaben',
|
||||
homesUpTo: 'Bis zu {{count}} Wohnungen',
|
||||
homesRange: '{{min}}–{{max}} Wohnungen',
|
||||
homesExact: '{{count}} Wohnungen',
|
||||
planningStatus: 'Planungsstatus',
|
||||
sourceBrownfield: 'Brachflächenregister',
|
||||
sourceHomesEngland: 'Homes-England-Fläche',
|
||||
localAuthority: 'Kommunalbehörde',
|
||||
viewRecord: 'Planungsunterlagen ansehen ↗',
|
||||
},
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Speichern',
|
||||
|
|
@ -787,10 +798,10 @@ const de: Translations = {
|
|||
describeIdealArea: 'Beschreibe, wo du wohnen möchtest',
|
||||
aiSearch: 'KI-Suche',
|
||||
describeHint: 'beschreibe, wonach du suchst',
|
||||
placeholder: 'z. B. 2-bed unter £525k, 45 Min. zur Arbeit, ruhig...',
|
||||
example1: '2-bed unter £525k, 45 Min. zur Arbeit',
|
||||
example2: 'Familienfreundliche Gebiete nahe guter Schulen unter £650k',
|
||||
example3: 'Mehr Platz mit vernünftigem Pendelweg',
|
||||
placeholder: 'z. B. gleiche Schulen, günstigere Postleitzahl, unter £500k…',
|
||||
example1: 'Gleiche Schulen, günstigere Postleitzahl',
|
||||
example2: 'Bestes Preis-Leistungs-Verhältnis in 30 Minuten zur Arbeit',
|
||||
example3: 'Unterbewertete Gegenden nahe guter Grundschulen',
|
||||
analysing: 'Anfrage wird ausgewertet...',
|
||||
searchingDestinations: 'Ziele werden gesucht...',
|
||||
generatingFilters: 'Filter werden generiert...',
|
||||
|
|
@ -888,7 +899,13 @@ const de: Translations = {
|
|||
walk: 'Zu Fuß',
|
||||
cycle: 'Fahrrad',
|
||||
nationalAvg: 'England-Schnitt',
|
||||
outcodeAvg: 'Outcode-Schnitt',
|
||||
sectorAvg: 'Sektor-Schnitt',
|
||||
thisArea: 'Dieses Gebiet',
|
||||
national: 'England',
|
||||
crimeDataEnds: 'Polizeidaten für dieses Gebiet enden {{year}}',
|
||||
residents: 'Einwohner',
|
||||
residentsTooltip: 'Wohnbevölkerung (ONS-Zensus 2021)',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
@ -1132,30 +1149,30 @@ const de: Translations = {
|
|||
videosTitle: 'Social-Media-Videos',
|
||||
videosIntro:
|
||||
'Kurze Clips aus unseren Social-Media-Kanälen – jeder zeigt eine einzelne Suche in Aktion, von ruhigen Straßen über Schuleinzugsgebiete bis zu Pendelzeiten.',
|
||||
video01Title: 'Ein Satz, jede Postleitzahl',
|
||||
video01Title: 'Finde den günstigeren Zwilling',
|
||||
video01Desc:
|
||||
'Gib deinen kompletten Wohnungswunsch in normaler Sprache ein und sieh zu, wie jede passende Postleitzahl in England aufleuchtet.',
|
||||
video02Title: 'Die 20-Minuten-Karte',
|
||||
'Gib deinen kompletten Wohnungswunsch in einem schlichten Satz ein und sieh zu, wie sich jede passende Postleitzahl in England nach Preis-Leistung sortiert – und der günstigere Zwilling auftaucht, den niemand hochgeboten hat.',
|
||||
video02Title: 'Die 20-Minuten-Pendelkarte',
|
||||
video02Desc:
|
||||
'Färbe die Karte nach Pendelzeit und sieh genau, was dir 20 Minuten ins Zentrum von London wirklich übrig lassen.',
|
||||
video03Title: 'Jede Postleitzahl hat eine Akte',
|
||||
'Färbe London nach Pendelzeit ins Zentrum, beschränke auf eine 20-minütige Fahrt und sieh, wie sich identische Wege in die Namen, die jeder kennt, und ruhigere Postleitzahlen aufteilen, die niemand hochgeboten hat.',
|
||||
video03Title: 'Die Beweisakte zur Postleitzahl',
|
||||
video03Desc:
|
||||
'Tippe auf eine Postleitzahl und lies ihre Akte – verkaufte Preise, Schulen, Kriminalität und Street View an einem Ort.',
|
||||
video04Title: 'Ein Foto kann man nicht hören',
|
||||
'Tippe auf eine Postleitzahl und ein Panel öffnet sich mit ihren verkauften Preisen, Schul-Einzugsgebieten, Kriminalität und Street View – so erkennst du, ob du für Substanz zahlst oder nur für einen Ruf.',
|
||||
video04Title: 'Die übersehene ruhige Straße',
|
||||
video04Desc:
|
||||
'Anzeigenfotos sind stumm. Filtere nach Lärmpegel und finde die wirklich ruhigen Straßen unter 55 Dezibel.',
|
||||
video05Title: 'Die Schulweg-Karte',
|
||||
'Berühmte Postleitzahlen preisen ihren Ruf ein, doch ein Anzeigenfoto schweigt zum Lärm. Filtere nach Dezibel und finde die wirklich ruhige Straße eine weiter, die niemand hochgeboten hat.',
|
||||
video05Title: 'Das Einzugsgebiet ohne den Aufschlag',
|
||||
video05Desc:
|
||||
'Gute Grundschul-Einzugsgebiete, wenig Kriminalität und ein Budget – der Familienwunsch, über eine ganze Stadt kartiert.',
|
||||
video06Title: 'Der Waitrose-Test',
|
||||
'Eine Familie in Leeds sucht eine gute Grundschule, wenig Kriminalität und ein Budget unter £350k – und findet die still günstigeren Straßen, die genau dasselbe Einzugsgebiet teilen.',
|
||||
video06Title: 'Der Waitrose-Effekt, eingepreist',
|
||||
video06Desc:
|
||||
'Zu Fuß zu einem Supermarkt, einer U-Bahn-Station und einem Park – filtere nach dem Leben, nicht nur nach dem Grundriss.',
|
||||
video07Title: 'Auch Mieter bekommen eine Karte',
|
||||
'Zu Fuß zu einem Waitrose, einer U-Bahn-Station und einem Park ist ein eingepreister Aufschlag – finde die nahen Postleitzahlen mit denselben Annehmlichkeiten für weniger pro Quadratmeter.',
|
||||
video07Title: 'Eine Preis-Leistungs-Karte für Mieter',
|
||||
video07Desc:
|
||||
'Miete im Budget, kurzer Arbeitsweg und eine ruhige Straße – Mietportale zeigen Wohnungen, das hier zeigt dir Gegenden.',
|
||||
video08Title: '9,99 £ gegen einen verlorenen Samstag',
|
||||
'Auch namhafte Postleitzahlen kosten mehr Miete. Lege deine Miete, deinen Arbeitsweg und eine ruhige Straße fest und sieh, welche Londoner Postleitzahlen wirklich ins Budget passen.',
|
||||
video08Title: 'Hör auf, für einen Namen zu viel zu zahlen',
|
||||
video08Desc:
|
||||
'Eine schlechte Besichtigung kostet eine Zugfahrt und ein halbes Wochenende. Sieh vorher, wo du nicht hinmusst.',
|
||||
'Lege Budget, Arbeitsweg, Kriminalität und Schulen fest, und die London-Karte füllt sich mit unterbewerteten Postleitzahlen, die eine Straße neben den berühmten Namen liegen.',
|
||||
source: 'Quelle:',
|
||||
optOut: 'Widerspruch gegen öffentliche Offenlegung',
|
||||
attribution: 'Quellenangaben',
|
||||
|
|
@ -1581,6 +1598,16 @@ const de: Translations = {
|
|||
|
||||
// ─ Feature names (Neighbours) ─
|
||||
'Median age': 'Medianalter',
|
||||
'% No qualifications': '% Ohne Abschluss',
|
||||
'% Some GCSEs': '% Einige GCSEs',
|
||||
'% Good GCSEs': '% Gute GCSEs',
|
||||
'% Apprenticeship': '% Ausbildung',
|
||||
'% A-levels': '% A-levels',
|
||||
'% Degree or higher': '% Hochschulabschluss oder höher',
|
||||
'% Other qualifications': '% Sonstige Abschlüsse',
|
||||
'% Owner occupied': '% Wohneigentum',
|
||||
'% Social rent': '% Sozialwohnung',
|
||||
'% Private rent': '% Privatmiete',
|
||||
'% White': '% weiß',
|
||||
'% South Asian': '% südasiatisch',
|
||||
'% Black': '% schwarz',
|
||||
|
|
@ -1627,6 +1654,8 @@ const de: Translations = {
|
|||
'Minor crime': 'Leichte Straftaten',
|
||||
'Ethnic composition': 'Ethnische Zusammensetzung',
|
||||
'Political vote share': 'Politischer Stimmenanteil',
|
||||
Qualifications: 'Bildungsabschlüsse',
|
||||
Tenure: 'Eigentumsverhältnis',
|
||||
'Anti-social': 'Antisoziales Verhalten',
|
||||
Vehicle: 'Fahrzeug',
|
||||
Burglary: 'Einbruch',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
const en = {
|
||||
newDevelopments: {
|
||||
title: 'Development site',
|
||||
homesUpTo: 'Up to {{count}} homes',
|
||||
homesRange: '{{min}}–{{max}} homes',
|
||||
homesExact: '{{count}} homes',
|
||||
planningStatus: 'Planning status',
|
||||
sourceBrownfield: 'Brownfield land register',
|
||||
sourceHomesEngland: 'Homes England land',
|
||||
localAuthority: 'Local authority',
|
||||
viewRecord: 'View planning record ↗',
|
||||
},
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Save',
|
||||
|
|
@ -773,10 +784,10 @@ const en = {
|
|||
describeIdealArea: 'Describe where you want to live',
|
||||
aiSearch: 'AI Search',
|
||||
describeHint: 'describe what you’re looking for',
|
||||
placeholder: 'e.g. 2-bed under £525k, 45 mins to work, quiet...',
|
||||
example1: '2-bed under £525k, 45 mins to work',
|
||||
example2: 'Family areas near good schools under £650k',
|
||||
example3: 'More space with a sane commute',
|
||||
placeholder: 'e.g. same schools, cheaper postcode, under £500k…',
|
||||
example1: 'Same schools, cheaper postcode',
|
||||
example2: 'Best value within 30 minutes of work',
|
||||
example3: 'Underpriced areas near good primaries',
|
||||
analysing: 'Analysing your query...',
|
||||
searchingDestinations: 'Searching for destinations...',
|
||||
generatingFilters: 'Generating filters...',
|
||||
|
|
@ -872,7 +883,13 @@ const en = {
|
|||
walk: 'Walk',
|
||||
cycle: 'Cycle',
|
||||
nationalAvg: 'National avg',
|
||||
outcodeAvg: 'Outcode avg',
|
||||
sectorAvg: 'Sector avg',
|
||||
thisArea: 'This area',
|
||||
national: 'National',
|
||||
crimeDataEnds: 'Police data for this area ends {{year}}',
|
||||
residents: 'Residents',
|
||||
residentsTooltip: 'Usual residents (ONS Census 2021)',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
@ -1114,30 +1131,30 @@ const en = {
|
|||
videosTitle: 'Social media videos',
|
||||
videosIntro:
|
||||
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
|
||||
video01Title: 'One sentence, every postcode',
|
||||
video01Title: 'Find the cheaper twin',
|
||||
video01Desc:
|
||||
'Type your whole house brief in plain English and watch every matching postcode in England light up.',
|
||||
video02Title: 'The 20-minute map',
|
||||
'Type your whole house brief in one plain sentence and watch every matching postcode in England sort by value, surfacing the cheaper twin nobody bid up.',
|
||||
video02Title: 'The 20-minute commute map',
|
||||
video02Desc:
|
||||
'Colour the map by commute time and see exactly what a 20-minute journey to central London actually leaves you.',
|
||||
video03Title: 'Every postcode has a file',
|
||||
'Colour London by commute to the centre, prune to a 20-minute ride, and watch identical journeys split into the names everyone knows and quieter postcodes nobody bid up.',
|
||||
video03Title: 'The postcode evidence file',
|
||||
video03Desc:
|
||||
'Tap any postcode to read its file — sold prices, schools, crime and Street View, all in one place.',
|
||||
video04Title: 'You can’t hear a photo',
|
||||
'Tap any postcode and a panel opens with its sold prices, school catchments, crime and Street View — so you can tell whether you are paying for value or just a reputation.',
|
||||
video04Title: 'The overlooked quiet street',
|
||||
video04Desc:
|
||||
'Listing photos are silent. Filter by noise level to find the genuinely quiet streets under 55 decibels.',
|
||||
video05Title: 'The school-run map',
|
||||
'Famous postcodes price in their reputation, but a listing photo stays silent on noise. Filter by decibels to find the genuinely quiet street one over that nobody bid up.',
|
||||
video05Title: 'The catchment without the premium',
|
||||
video05Desc:
|
||||
'Good primary catchments, low crime and a budget — the family brief, mapped across a whole city.',
|
||||
video06Title: 'The Waitrose test',
|
||||
'A Leeds family search for a good primary, low crime and a budget under £350k surfaces the quietly cheaper streets that share the very same catchment.',
|
||||
video06Title: 'The Waitrose effect, priced in',
|
||||
video06Desc:
|
||||
'Walking distance to a Waitrose, a tube station and a park — filter for the life, not just the floor plan.',
|
||||
video07Title: 'Renters get a map too',
|
||||
'Walking distance to a Waitrose, a tube station and a park is a priced-in premium — find the nearby postcodes with the same amenities for less per square metre.',
|
||||
video07Title: 'A value map for renters',
|
||||
video07Desc:
|
||||
'Rent under budget, a short commute and a quiet street — letting sites show flats, this shows you areas.',
|
||||
video08Title: '£9.99 vs a wasted Saturday',
|
||||
'Big-name postcodes cost more to rent, too. Set your rent, commute and a quiet street, and see which London postcodes actually fit the money.',
|
||||
video08Title: 'Stop overpaying for a name',
|
||||
video08Desc:
|
||||
'A bad viewing costs a train ticket and half a weekend. See where not to go before you book one.',
|
||||
'Set a budget, commute, crime and schools, and the London map fills with underpriced postcodes sitting one street over from the famous names.',
|
||||
source: 'Source:',
|
||||
optOut: 'Opt out of public disclosure',
|
||||
attribution: 'Attribution',
|
||||
|
|
@ -1452,7 +1469,7 @@ const en = {
|
|||
travelDestination: '{{count}} travel time destination',
|
||||
travelDestinations: '{{count}} travel time destinations',
|
||||
propertiesMatch: '{{count}} properties match',
|
||||
setFilters: 'Set {{count}} filter(s): {{list}}',
|
||||
setFilters: 'Set {{count}} filters: {{list}}',
|
||||
noFiltersSet: 'No filters set',
|
||||
toDestination: '{{mode}} to {{label}} {{bounds}}',
|
||||
lessThanMin: '< {{max}} min',
|
||||
|
|
@ -1555,6 +1572,16 @@ const en = {
|
|||
|
||||
// ─ Feature names (Neighbours) ─
|
||||
'Median age': 'Median age',
|
||||
'% No qualifications': '% No qualifications',
|
||||
'% Some GCSEs': '% Some GCSEs',
|
||||
'% Good GCSEs': '% Good GCSEs',
|
||||
'% Apprenticeship': '% Apprenticeship',
|
||||
'% A-levels': '% A-levels',
|
||||
'% Degree or higher': '% Degree or higher',
|
||||
'% Other qualifications': '% Other qualifications',
|
||||
'% Owner occupied': '% Owner occupied',
|
||||
'% Social rent': '% Social rent',
|
||||
'% Private rent': '% Private rent',
|
||||
'% White': '% White',
|
||||
'% South Asian': '% South Asian',
|
||||
'% Black': '% Black',
|
||||
|
|
@ -1601,6 +1628,8 @@ const en = {
|
|||
'Minor crime': 'Minor crime',
|
||||
'Ethnic composition': 'Ethnic composition',
|
||||
'Political vote share': 'Political vote share',
|
||||
Qualifications: 'Qualifications',
|
||||
Tenure: 'Tenure',
|
||||
'Anti-social': 'Anti-social',
|
||||
Vehicle: 'Vehicle',
|
||||
Burglary: 'Burglary',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import { Translations } from './en';
|
||||
|
||||
const fr: Translations = {
|
||||
newDevelopments: {
|
||||
title: 'Site de construction',
|
||||
homesUpTo: "Jusqu'à {{count}} logements",
|
||||
homesRange: '{{min}}–{{max}} logements',
|
||||
homesExact: '{{count}} logements',
|
||||
planningStatus: "Statut d'urbanisme",
|
||||
sourceBrownfield: 'Registre des friches',
|
||||
sourceHomesEngland: 'Terrain Homes England',
|
||||
localAuthority: 'Autorité locale',
|
||||
viewRecord: "Voir le dossier d'urbanisme ↗",
|
||||
},
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Enregistrer',
|
||||
|
|
@ -801,10 +812,10 @@ const fr: Translations = {
|
|||
describeIdealArea: 'Décrivez où vous voulez vivre',
|
||||
aiSearch: 'Recherche IA',
|
||||
describeHint: 'décrivez ce que vous recherchez',
|
||||
placeholder: 'ex. 2 chambres à moins de £525k, 45 min du travail, calme...',
|
||||
example1: '2 chambres à moins de £525k, 45 min du travail',
|
||||
example2: 'Quartiers familiaux près de bonnes écoles à moins de £650k',
|
||||
example3: 'Plus d’espace avec un trajet raisonnable',
|
||||
placeholder: 'ex. mêmes écoles, code postal moins cher, sous £500k…',
|
||||
example1: 'Mêmes écoles, code postal moins cher',
|
||||
example2: 'Meilleur rapport qualité-prix à 30 minutes du travail',
|
||||
example3: 'Zones sous-cotées près de bonnes écoles primaires',
|
||||
analysing: 'Analyse de votre requête...',
|
||||
searchingDestinations: 'Recherche de destinations...',
|
||||
generatingFilters: 'Génération des filtres...',
|
||||
|
|
@ -901,7 +912,13 @@ const fr: Translations = {
|
|||
walk: 'Marche',
|
||||
cycle: 'Vélo',
|
||||
nationalAvg: 'Moyenne nationale',
|
||||
outcodeAvg: 'Moyenne outcode',
|
||||
sectorAvg: 'Moyenne secteur',
|
||||
thisArea: 'Cette zone',
|
||||
national: 'National',
|
||||
crimeDataEnds: "Les données de police pour cette zone s'arrêtent en {{year}}",
|
||||
residents: 'Habitants',
|
||||
residentsTooltip: 'Résidents habituels (recensement ONS 2021)',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
@ -1146,30 +1163,30 @@ const fr: Translations = {
|
|||
videosTitle: 'Vidéos pour les réseaux sociaux',
|
||||
videosIntro:
|
||||
'De courtes vidéos de nos réseaux sociaux — chacune montre une recherche en action, des rues calmes aux secteurs scolaires en passant par les temps de trajet.',
|
||||
video01Title: 'Une phrase, chaque code postal',
|
||||
video01Title: 'Trouvez le jumeau moins cher',
|
||||
video01Desc:
|
||||
'Décrivez tout votre projet immobilier en langage courant et voyez s’allumer chaque code postal correspondant en Angleterre.',
|
||||
video02Title: 'La carte des 20 minutes',
|
||||
'Décrivez tout votre projet immobilier en une phrase simple et voyez chaque code postal correspondant en Angleterre se classer par rapport qualité-prix, révélant le jumeau moins cher que personne n’a fait monter aux enchères.',
|
||||
video02Title: 'La carte des trajets de 20 minutes',
|
||||
video02Desc:
|
||||
'Colorez la carte selon le temps de trajet et voyez exactement ce que 20 minutes du centre de Londres vous laissent vraiment.',
|
||||
video03Title: 'Chaque code postal a sa fiche',
|
||||
'Colorez Londres selon le temps de trajet vers le centre, limitez à 20 minutes, et voyez des trajets identiques se diviser entre les noms que tout le monde connaît et les codes postaux plus calmes que personne n’a fait monter.',
|
||||
video03Title: 'Le dossier de preuves du code postal',
|
||||
video03Desc:
|
||||
'Touchez un code postal pour lire sa fiche — prix de vente, écoles, criminalité et Street View, au même endroit.',
|
||||
video04Title: 'Une photo, ça ne s’entend pas',
|
||||
'Touchez un code postal et un panneau s’ouvre avec ses prix de vente, ses secteurs scolaires, la criminalité et Street View — pour savoir si vous payez pour de la valeur ou seulement pour une réputation.',
|
||||
video04Title: 'La rue calme qu’on néglige',
|
||||
video04Desc:
|
||||
'Les photos d’annonces sont muettes. Filtrez par niveau de bruit pour trouver les rues vraiment calmes, sous 55 décibels.',
|
||||
video05Title: 'La carte du trajet d’école',
|
||||
'Les codes postaux célèbres intègrent leur réputation au prix, mais une photo d’annonce reste muette sur le bruit. Filtrez par décibels pour trouver la rue vraiment calme juste à côté que personne n’a fait monter.',
|
||||
video05Title: 'Le secteur scolaire sans le surcoût',
|
||||
video05Desc:
|
||||
'Bons secteurs d’école primaire, faible criminalité et un budget — le projet des familles, cartographié sur toute une ville.',
|
||||
video06Title: 'Le test Waitrose',
|
||||
'Une famille de Leeds cherche une bonne école primaire, peu de criminalité et un budget sous £350k, et fait apparaître les rues discrètement moins chères qui partagent exactement le même secteur.',
|
||||
video06Title: 'L’effet Waitrose, déjà dans le prix',
|
||||
video06Desc:
|
||||
'À pied d’un supermarché, d’une station de métro et d’un parc — filtrez selon votre vie, pas seulement selon le plan.',
|
||||
video07Title: 'Les locataires aussi ont une carte',
|
||||
'Être à pied d’un Waitrose, d’une station de métro et d’un parc est un surcoût déjà intégré — trouvez les codes postaux voisins offrant les mêmes commodités pour moins cher au mètre carré.',
|
||||
video07Title: 'Une carte de la valeur pour les locataires',
|
||||
video07Desc:
|
||||
'Loyer dans le budget, trajet court et rue calme — les sites de location montrent des logements, ceci vous montre des quartiers.',
|
||||
video08Title: '9,99 £ contre un samedi gâché',
|
||||
'Les codes postaux réputés coûtent aussi plus cher à louer. Indiquez votre loyer, votre trajet et une rue calme, et voyez quels codes postaux de Londres tiennent vraiment dans le budget.',
|
||||
video08Title: 'Arrêtez de surpayer pour un nom',
|
||||
video08Desc:
|
||||
'Une mauvaise visite coûte un billet de train et la moitié d’un week-end. Voyez où ne pas aller avant d’en réserver une.',
|
||||
'Définissez un budget, un trajet, la criminalité et les écoles, et la carte de Londres se remplit de codes postaux sous-cotés situés à une rue des noms célèbres.',
|
||||
source: 'Source :',
|
||||
optOut: 'Refus de la publication publique',
|
||||
attribution: 'Attribution',
|
||||
|
|
@ -1494,7 +1511,7 @@ const fr: Translations = {
|
|||
travelDestination: '{{count}} destination de trajet',
|
||||
travelDestinations: '{{count}} destinations de trajet',
|
||||
propertiesMatch: '{{count}} biens correspondent',
|
||||
setFilters: 'Définir {{count}} filtre(s) : {{list}}',
|
||||
setFilters: 'Définir {{count}} filtres : {{list}}',
|
||||
noFiltersSet: 'Aucun filtre défini',
|
||||
toDestination: '{{mode}} vers {{label}} {{bounds}}',
|
||||
lessThanMin: '< {{max}} min',
|
||||
|
|
@ -1598,6 +1615,16 @@ const fr: Translations = {
|
|||
|
||||
// ─ Feature names (Neighbours) ─
|
||||
'Median age': 'Âge médian',
|
||||
'% No qualifications': '% Sans diplôme',
|
||||
'% Some GCSEs': '% Quelques GCSE',
|
||||
'% Good GCSEs': '% Bons GCSE',
|
||||
'% Apprenticeship': '% Apprentissage',
|
||||
'% A-levels': '% A-levels',
|
||||
'% Degree or higher': '% Diplôme universitaire ou plus',
|
||||
'% Other qualifications': '% Autres diplômes',
|
||||
'% Owner occupied': '% Propriétaires occupants',
|
||||
'% Social rent': '% Logements sociaux',
|
||||
'% Private rent': '% Location privée',
|
||||
'% White': '% Blancs',
|
||||
'% South Asian': '% Sud-Asiatiques',
|
||||
'% Black': '% Noirs',
|
||||
|
|
@ -1644,6 +1671,8 @@ const fr: Translations = {
|
|||
'Minor crime': 'Infractions mineures',
|
||||
'Ethnic composition': 'Composition ethnique',
|
||||
'Political vote share': 'Répartition des voix',
|
||||
Qualifications: 'Diplômes',
|
||||
Tenure: 'Statut d’occupation',
|
||||
'Anti-social': 'Antisocial',
|
||||
Vehicle: 'Véhicule',
|
||||
Burglary: 'Cambriolage',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import type { Translations } from './en';
|
||||
|
||||
const hi: Translations = {
|
||||
newDevelopments: {
|
||||
title: 'विकास स्थल',
|
||||
homesUpTo: '{{count}} तक घर',
|
||||
homesRange: '{{min}}–{{max}} घर',
|
||||
homesExact: '{{count}} घर',
|
||||
planningStatus: 'योजना स्थिति',
|
||||
sourceBrownfield: 'ब्राउनफ़ील्ड रजिस्टर',
|
||||
sourceHomesEngland: 'Homes England भूमि',
|
||||
localAuthority: 'स्थानीय प्राधिकरण',
|
||||
viewRecord: 'योजना रिकॉर्ड देखें ↗',
|
||||
},
|
||||
common: {
|
||||
save: 'सहेजें',
|
||||
update: 'अपडेट करें',
|
||||
|
|
@ -764,10 +775,10 @@ const hi: Translations = {
|
|||
describeIdealArea: 'बताएं आप कहां रहना चाहते हैं',
|
||||
aiSearch: 'AI खोज',
|
||||
describeHint: 'बताएं आप क्या खोज रहे हैं',
|
||||
placeholder: 'जैसे 2 बेडरूम £525,000 से कम, काम तक 45 मिनट, शांत...',
|
||||
example1: '2 बेडरूम £525,000 से कम, काम तक 45 मिनट',
|
||||
example2: '£650,000 से कम अच्छे स्कूलों के पास परिवारों वाले क्षेत्र',
|
||||
example3: 'समझदारी वाले आवागमन के साथ ज्यादा जगह',
|
||||
placeholder: 'जैसे वही स्कूल, सस्ता पोस्टकोड, £500,000 से कम…',
|
||||
example1: 'वही स्कूल, सस्ता पोस्टकोड',
|
||||
example2: 'काम से 30 मिनट के भीतर सबसे अच्छा मूल्य',
|
||||
example3: 'अच्छे प्राइमरी स्कूलों के पास कम-कीमत वाले इलाके',
|
||||
analysing: 'आपकी क्वेरी का विश्लेषण हो रहा है...',
|
||||
searchingDestinations: 'गंतव्य खोजे जा रहे हैं...',
|
||||
generatingFilters: 'फ़िल्टर बनाए जा रहे हैं...',
|
||||
|
|
@ -860,7 +871,13 @@ const hi: Translations = {
|
|||
walk: 'पैदल',
|
||||
cycle: 'साइकिल',
|
||||
nationalAvg: 'राष्ट्रीय औसत',
|
||||
outcodeAvg: 'आउटकोड औसत',
|
||||
sectorAvg: 'सेक्टर औसत',
|
||||
thisArea: 'यह क्षेत्र',
|
||||
national: 'राष्ट्रीय',
|
||||
crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है',
|
||||
residents: 'निवासी',
|
||||
residentsTooltip: 'सामान्य निवासी (ONS जनगणना 2021)',
|
||||
},
|
||||
|
||||
streetView: {
|
||||
|
|
@ -1093,30 +1110,30 @@ const hi: Translations = {
|
|||
videosTitle: 'सोशल मीडिया वीडियो',
|
||||
videosIntro:
|
||||
'हमारे सोशल चैनलों की छोटी क्लिप्स — हर एक एक खोज को क्रिया में दिखाती है, शांत गलियों से लेकर स्कूल कैचमेंट और सफर के समय तक.',
|
||||
video01Title: 'एक वाक्य, हर पोस्टकोड',
|
||||
video01Title: 'सस्ता जुड़वाँ खोजें',
|
||||
video01Desc:
|
||||
'अपनी पूरी घर की ज़रूरत सामान्य भाषा में लिखें और इंग्लैंड का हर मेल खाता पोस्टकोड जगमगाते देखें.',
|
||||
video02Title: '20 मिनट का नक्शा',
|
||||
'अपनी पूरी घर की ज़रूरत एक सरल वाक्य में लिखें और देखें कि इंग्लैंड का हर मेल खाता पोस्टकोड मूल्य के अनुसार छँटता है, उस सस्ते जुड़वाँ को सामने लाता है जिसकी किसी ने बोली नहीं बढ़ाई.',
|
||||
video02Title: '20 मिनट का आवागमन नक्शा',
|
||||
video02Desc:
|
||||
'नक्शे को सफर के समय के अनुसार रंगें और देखें कि सेंट्रल लंदन से 20 मिनट वास्तव में आपको क्या देते हैं.',
|
||||
video03Title: 'हर पोस्टकोड की एक फ़ाइल है',
|
||||
'लंदन को केंद्र तक आवागमन के अनुसार रंगें, 20 मिनट की सवारी तक सीमित करें, और देखें कि एक जैसे सफर कैसे बँट जाते हैं — मशहूर नामों और उन शांत पोस्टकोडों में जिनकी किसी ने बोली नहीं बढ़ाई.',
|
||||
video03Title: 'पोस्टकोड की सबूत-फ़ाइल',
|
||||
video03Desc:
|
||||
'किसी भी पोस्टकोड पर टैप करके उसकी फ़ाइल पढ़ें — बिक्री मूल्य, स्कूल, अपराध और स्ट्रीट व्यू, सब एक जगह.',
|
||||
video04Title: 'फ़ोटो सुनी नहीं जा सकती',
|
||||
'किसी भी पोस्टकोड पर टैप करें और एक पैनल खुलता है जिसमें उसके बिक्री मूल्य, स्कूल कैचमेंट, अपराध और स्ट्रीट व्यू होते हैं — ताकि आप जान सकें कि आप मूल्य के लिए चुका रहे हैं या सिर्फ़ एक नाम के लिए.',
|
||||
video04Title: 'अनदेखी की गई शांत गली',
|
||||
video04Desc:
|
||||
'विज्ञापन की तस्वीरें खामोश होती हैं. शोर के स्तर से छानकर 55 डेसिबल से नीचे की सचमुच शांत गलियाँ खोजें.',
|
||||
video05Title: 'स्कूल-रन नक्शा',
|
||||
'मशहूर पोस्टकोड अपनी प्रतिष्ठा को कीमत में जोड़ लेते हैं, पर विज्ञापन की तस्वीर शोर पर खामोश रहती है. डेसिबल से छानकर ठीक बगल की सचमुच शांत गली खोजें जिसकी किसी ने बोली नहीं बढ़ाई.',
|
||||
video05Title: 'बिना प्रीमियम वाला कैचमेंट',
|
||||
video05Desc:
|
||||
'अच्छे प्राइमरी कैचमेंट, कम अपराध और एक बजट — परिवार की ज़रूरत, पूरे शहर पर मैप की गई.',
|
||||
video06Title: 'वेट्रोज़ टेस्ट',
|
||||
'लीड्स का एक परिवार अच्छे प्राइमरी, कम अपराध और £350,000 से कम बजट की खोज करता है, और उन चुपचाप सस्ती गलियों को सामने लाता है जो ठीक वही कैचमेंट साझा करती हैं.',
|
||||
video06Title: 'वेट्रोज़ असर, कीमत में शामिल',
|
||||
video06Desc:
|
||||
'सुपरमार्केट, ट्यूब स्टेशन और पार्क से पैदल दूरी — सिर्फ़ फ़्लोर प्लान नहीं, अपनी ज़िंदगी के हिसाब से छानें.',
|
||||
video07Title: 'किराएदारों के लिए भी नक्शा',
|
||||
'किसी वेट्रोज़, ट्यूब स्टेशन और पार्क से पैदल दूरी एक कीमत-में-शामिल प्रीमियम है — पास के वे पोस्टकोड खोजें जिनमें वही सुविधाएँ प्रति वर्ग मीटर कम दाम पर मिलती हैं.',
|
||||
video07Title: 'किराएदारों के लिए मूल्य नक्शा',
|
||||
video07Desc:
|
||||
'बजट में किराया, छोटा सफर और शांत गली — किराये की साइटें फ़्लैट दिखाती हैं, यह आपको इलाके दिखाता है.',
|
||||
video08Title: '£9.99 बनाम एक बर्बाद शनिवार',
|
||||
'मशहूर पोस्टकोड किराये पर भी महँगे पड़ते हैं. अपना किराया, आवागमन और एक शांत गली तय करें, और देखें कि लंदन के कौन-से पोस्टकोड वाकई बजट में बैठते हैं.',
|
||||
video08Title: 'नाम के लिए ज़्यादा चुकाना बंद करें',
|
||||
video08Desc:
|
||||
'एक ख़राब विज़िट एक ट्रेन टिकट और आधा सप्ताहांत ले जाती है. बुकिंग से पहले देखें कि कहाँ नहीं जाना है.',
|
||||
'बजट, आवागमन, अपराध और स्कूल तय करें, और लंदन का नक्शा उन कम-कीमत वाले पोस्टकोडों से भर जाता है जो मशहूर नामों से बस एक गली दूर बैठे हैं.',
|
||||
source: 'स्रोत:',
|
||||
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
|
||||
attribution: 'श्रेय',
|
||||
|
|
@ -1501,6 +1518,16 @@ const hi: Translations = {
|
|||
'Public order (avg/yr)': 'सार्वजनिक व्यवस्था अपराध (घनत्व)',
|
||||
'Other crime (avg/yr)': 'अन्य अपराध (घनत्व)',
|
||||
'Median age': 'मध्य आयु',
|
||||
'% No qualifications': '% कोई योग्यता नहीं',
|
||||
'% Some GCSEs': '% कुछ GCSE',
|
||||
'% Good GCSEs': '% अच्छे GCSE',
|
||||
'% Apprenticeship': '% अप्रेंटिसशिप',
|
||||
'% A-levels': '% A-levels',
|
||||
'% Degree or higher': '% डिग्री या उससे ऊपर',
|
||||
'% Other qualifications': '% अन्य योग्यताएं',
|
||||
'% Owner occupied': '% स्वामित्व वाले घर',
|
||||
'% Social rent': '% सामाजिक किराया',
|
||||
'% Private rent': '% निजी किराया',
|
||||
'% White': '% श्वेत',
|
||||
'% South Asian': '% दक्षिण एशियाई',
|
||||
'% Black': '% अश्वेत',
|
||||
|
|
@ -1538,6 +1565,8 @@ const hi: Translations = {
|
|||
'Minor crime': 'मामूली अपराध',
|
||||
'Ethnic composition': 'जातीय संरचना',
|
||||
'Political vote share': 'राजनीतिक मत हिस्सेदारी',
|
||||
Qualifications: 'योग्यताएं',
|
||||
Tenure: 'आवास स्वामित्व',
|
||||
'Anti-social': 'असामाजिक',
|
||||
Vehicle: 'वाहन',
|
||||
Burglary: 'सेंधमारी',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import type { Translations } from './en';
|
||||
|
||||
const hu: Translations = {
|
||||
newDevelopments: {
|
||||
title: 'Fejlesztési terület',
|
||||
homesUpTo: 'Akár {{count}} lakás',
|
||||
homesRange: '{{min}}–{{max}} lakás',
|
||||
homesExact: '{{count}} lakás',
|
||||
planningStatus: 'Tervezési állapot',
|
||||
sourceBrownfield: 'Barnamezős nyilvántartás',
|
||||
sourceHomesEngland: 'Homes England terület',
|
||||
localAuthority: 'Helyi önkormányzat',
|
||||
viewRecord: 'Tervezési dokumentum megtekintése ↗',
|
||||
},
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Mentés',
|
||||
|
|
@ -790,10 +801,10 @@ const hu: Translations = {
|
|||
describeIdealArea: 'Írd le, hol szeretnél élni',
|
||||
aiSearch: 'AI-keresés',
|
||||
describeHint: 'Írd le, mit keresel',
|
||||
placeholder: 'pl. 2 hálószoba £525k alatt, 45 perc munkába, csendes...',
|
||||
example1: '2 hálószoba £525k alatt, 45 perc munkába',
|
||||
example2: 'Családbarát területek jó iskolák közelében £650k alatt',
|
||||
example3: 'Több hely és ésszerű ingázás',
|
||||
placeholder: 'pl. ugyanazok az iskolák, olcsóbb irányítószám, £500k alatt…',
|
||||
example1: 'Ugyanazok az iskolák, olcsóbb irányítószám',
|
||||
example2: 'A legjobb ár-érték a munkahelytől 30 percen belül',
|
||||
example3: 'Alulárazott területek jó általános iskolák közelében',
|
||||
analysing: 'Lekérdezés elemzése...',
|
||||
searchingDestinations: 'Úticélok keresése...',
|
||||
generatingFilters: 'Szűrők létrehozása...',
|
||||
|
|
@ -889,7 +900,13 @@ const hu: Translations = {
|
|||
walk: 'Gyalog',
|
||||
cycle: 'Kerékpár',
|
||||
nationalAvg: 'Országos átlag',
|
||||
outcodeAvg: 'Outcode átlag',
|
||||
sectorAvg: 'Szektor átlag',
|
||||
thisArea: 'Ez a terület',
|
||||
national: 'Országos',
|
||||
crimeDataEnds: 'A körzet rendőrségi adatai {{year}}-ig érhetők el',
|
||||
residents: 'Lakosok',
|
||||
residentsTooltip: 'Állandó lakosok (ONS 2021-es népszámlálás)',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
@ -1130,30 +1147,30 @@ const hu: Translations = {
|
|||
videosTitle: 'Közösségimédia-videók',
|
||||
videosIntro:
|
||||
'Rövid klipek a közösségi csatornáinkról – mindegyik egy-egy keresést mutat be működés közben, a csendes utcáktól az iskolai körzeteken át az utazási időkig.',
|
||||
video01Title: 'Egy mondat, minden irányítószám',
|
||||
video01Title: 'Találd meg az olcsóbb ikertestvért',
|
||||
video01Desc:
|
||||
'Írd le a teljes lakáskeresési igényedet hétköznapi nyelven, és nézd, ahogy Anglia minden illeszkedő irányítószáma felgyúl.',
|
||||
video02Title: 'A 20 perces térkép',
|
||||
'Írd le a teljes lakáskeresési igényedet egyetlen egyszerű mondatban, és nézd, ahogy Anglia minden illeszkedő irányítószáma ár-érték szerint rendeződik, felszínre hozva az olcsóbb ikertestvért, amit senki sem vert fel.',
|
||||
video02Title: 'A 20 perces ingázási térkép',
|
||||
video02Desc:
|
||||
'Színezd a térképet utazási idő szerint, és lásd pontosan, mit hagy neked valójában 20 perc London központjától.',
|
||||
video03Title: 'Minden irányítószámnak van aktája',
|
||||
'Színezd Londont a központba tartó ingázás szerint, szűkítsd 20 perces útra, és nézd, ahogy az azonos utak kettéválnak a mindenki által ismert nevekre és a csendesebb irányítószámokra, amelyeket senki sem vert fel.',
|
||||
video03Title: 'Az irányítószám bizonyítékaktája',
|
||||
video03Desc:
|
||||
'Koppints bármelyik irányítószámra, és olvasd el az aktáját – eladási árak, iskolák, bűnözés és Street View egy helyen.',
|
||||
video04Title: 'Egy fotót nem lehet meghallani',
|
||||
'Koppints bármelyik irányítószámra, és megnyílik egy panel az eladási áraival, iskolai körzeteivel, bűnözéssel és Street View-val – így megállapíthatod, valódi értékért fizetsz-e, vagy csak egy hírnévért.',
|
||||
video04Title: 'A figyelmen kívül hagyott csendes utca',
|
||||
video04Desc:
|
||||
'A hirdetésfotók némák. Szűrj zajszint szerint, és találd meg a valóban csendes, 55 decibel alatti utcákat.',
|
||||
video05Title: 'Az iskolába vezető út térképe',
|
||||
'A híres irányítószámak beárazzák a hírnevüket, de egy hirdetésfotó néma a zajról. Szűrj decibel szerint, és találd meg az igazán csendes utcát egy sarokkal arrébb, amit senki sem vert fel.',
|
||||
video05Title: 'A körzet felár nélkül',
|
||||
video05Desc:
|
||||
'Jó általános iskolai körzetek, alacsony bűnözés és egy költségvetés – a családi igény, egy egész városra térképezve.',
|
||||
video06Title: 'A Waitrose-teszt',
|
||||
'Egy leedsi család jó általános iskolát, alacsony bűnözést és £350k alatti költségvetést keres, és felszínre hozza a csendben olcsóbb utcákat, amelyek ugyanazt a körzetet osztják.',
|
||||
video06Title: 'A Waitrose-hatás, beárazva',
|
||||
video06Desc:
|
||||
'Sétatávolságra egy szupermarkettől, egy metrómegállótól és egy parktól – az életedre szűrj, ne csak az alaprajzra.',
|
||||
video07Title: 'A bérlőknek is jár térkép',
|
||||
'Sétatávolságra egy Waitrose-tól, egy metrómegállótól és egy parktól – ez egy beárazott felár; találd meg a közeli irányítószámokat ugyanazokkal a szolgáltatásokkal, négyzetméterenként olcsóbban.',
|
||||
video07Title: 'Ár-érték térkép bérlőknek',
|
||||
video07Desc:
|
||||
'Költségvetésbe férő bérleti díj, rövid ingázás és csendes utca – a bérleti oldalak lakásokat mutatnak, ez környékeket.',
|
||||
video08Title: '9,99 £ egy elpazarolt szombat ellen',
|
||||
'A neves irányítószámok bérelni is drágábbak. Add meg a bérleti díjat, az ingázást és egy csendes utcát, és lásd, mely londoni irányítószámok férnek tényleg a pénztárcádba.',
|
||||
video08Title: 'Ne fizess túl egy névért',
|
||||
video08Desc:
|
||||
'Egy rossz megtekintés egy vonatjegybe és egy fél hétvégébe kerül. Még foglalás előtt lásd, hova ne menj.',
|
||||
'Állíts be költségvetést, ingázást, bűnözést és iskolákat, és a londoni térkép megtelik alulárazott irányítószámokkal, amelyek egy utcányira fekszenek a híres nevektől.',
|
||||
source: 'Forrás:',
|
||||
optOut: 'Nyilvános közzététel visszautasítása',
|
||||
attribution: 'Forrásmegnevezés',
|
||||
|
|
@ -1577,6 +1594,16 @@ const hu: Translations = {
|
|||
|
||||
// ─ Feature names (Neighbours) ─
|
||||
'Median age': 'Medián életkor',
|
||||
'% No qualifications': '% Végzettség nélkül',
|
||||
'% Some GCSEs': '% Néhány GCSE',
|
||||
'% Good GCSEs': '% Jó GCSE',
|
||||
'% Apprenticeship': '% Tanoncképzés',
|
||||
'% A-levels': '% A-levels',
|
||||
'% Degree or higher': '% Diploma vagy magasabb',
|
||||
'% Other qualifications': '% Egyéb végzettség',
|
||||
'% Owner occupied': '% Saját tulajdonú lakás',
|
||||
'% Social rent': '% Szociális bérlakás',
|
||||
'% Private rent': '% Magánbérlemény',
|
||||
'% White': '% fehér',
|
||||
'% South Asian': '% dél-ázsiai',
|
||||
'% Black': '% fekete',
|
||||
|
|
@ -1623,6 +1650,8 @@ const hu: Translations = {
|
|||
'Minor crime': 'Kisebb bűncselekmény',
|
||||
'Ethnic composition': 'Etnikai összetétel',
|
||||
'Political vote share': 'Szavazati megoszlás',
|
||||
Qualifications: 'Végzettség',
|
||||
Tenure: 'Lakhatási jogviszony',
|
||||
'Anti-social': 'Közösségellenes',
|
||||
Vehicle: 'Jármű',
|
||||
Burglary: 'Betörés',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import type { Translations } from './en';
|
||||
|
||||
const zh: Translations = {
|
||||
newDevelopments: {
|
||||
title: '开发地块',
|
||||
homesUpTo: '最多 {{count}} 套住宅',
|
||||
homesRange: '{{min}}–{{max}} 套住宅',
|
||||
homesExact: '{{count}} 套住宅',
|
||||
planningStatus: '规划状态',
|
||||
sourceBrownfield: '棕地登记册',
|
||||
sourceHomesEngland: 'Homes England 用地',
|
||||
localAuthority: '地方政府',
|
||||
viewRecord: '查看规划记录 ↗',
|
||||
},
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: '保存',
|
||||
|
|
@ -735,10 +746,10 @@ const zh: Translations = {
|
|||
describeIdealArea: '描述您想住在哪里',
|
||||
aiSearch: 'AI 搜索',
|
||||
describeHint: '描述您要找的区域',
|
||||
placeholder: '例如:两居室,£525,000 以下,到公司 45 分钟,安静...',
|
||||
example1: '两居室,£525,000 以下,到公司 45 分钟',
|
||||
example2: '靠近好学校、低于 £650,000 的家庭友好区域',
|
||||
example3: '空间更大,通勤别太累',
|
||||
placeholder: '例如:同样的学校,更便宜的邮编,£500,000 以下…',
|
||||
example1: '同样的学校,更便宜的邮编',
|
||||
example2: '通勤 30 分钟内性价比最高',
|
||||
example3: '优质小学附近被低估的区域',
|
||||
analysing: '正在分析您的需求...',
|
||||
searchingDestinations: '正在搜索目的地...',
|
||||
generatingFilters: '正在生成筛选条件...',
|
||||
|
|
@ -831,7 +842,13 @@ const zh: Translations = {
|
|||
walk: '步行',
|
||||
cycle: '骑行',
|
||||
nationalAvg: '全国平均',
|
||||
outcodeAvg: '邮区平均',
|
||||
sectorAvg: '分区平均',
|
||||
thisArea: '本区域',
|
||||
national: '全国',
|
||||
crimeDataEnds: '该地区的警方数据截至{{year}}年',
|
||||
residents: '居民',
|
||||
residentsTooltip: '常住居民(ONS 2021 年人口普查)',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
@ -1066,22 +1083,30 @@ const zh: Translations = {
|
|||
videosTitle: '社交媒体视频',
|
||||
videosIntro:
|
||||
'来自我们社交平台的短片——每一个都展示一次实际搜索,从安静街道到学校学区,再到通勤时间。',
|
||||
video01Title: '一句话,每个邮编',
|
||||
video01Desc: '用日常语言写下你的全部购房需求,看着英格兰每个符合条件的邮编亮起来。',
|
||||
video02Title: '20 分钟地图',
|
||||
video02Desc: '按通勤时间为地图着色,看清从伦敦市中心 20 分钟究竟能到达哪里。',
|
||||
video03Title: '每个邮编都有一份档案',
|
||||
video03Desc: '点按任意邮编即可查看其档案——成交价、学校、犯罪和街景,尽在一处。',
|
||||
video04Title: '照片听不见声音',
|
||||
video04Desc: '房源照片是无声的。按噪音水平筛选,找到真正安静、低于 55 分贝的街道。',
|
||||
video05Title: '上学路线地图',
|
||||
video05Desc: '优质小学学区、低犯罪率和预算——把家庭需求映射到整座城市。',
|
||||
video06Title: 'Waitrose 测试',
|
||||
video06Desc: '步行可达超市、地铁站和公园——按你的生活方式筛选,而不只是户型图。',
|
||||
video07Title: '租房者也有地图',
|
||||
video07Desc: '预算内的租金、短通勤和安静街道——租房网站展示房源,这里为你展示区域。',
|
||||
video08Title: '£9.99 对比浪费的周六',
|
||||
video08Desc: '一次糟糕的看房要花一张车票和半个周末。在预约之前就看清哪里不该去。',
|
||||
video01Title: '找到更便宜的孪生邮编',
|
||||
video01Desc:
|
||||
'用一句平实的话写下你的全部购房需求,看着英格兰每个符合条件的邮编按性价比排序,浮现出那个无人抬价、更便宜的孪生邮编。',
|
||||
video02Title: '20 分钟通勤地图',
|
||||
video02Desc:
|
||||
'按到市中心的通勤时间为伦敦着色,收窄到 20 分钟车程,看着相同的通勤路程被分成人人皆知的名字,和那些无人抬价、更安静的邮编。',
|
||||
video03Title: '邮编证据档案',
|
||||
video03Desc:
|
||||
'点按任意邮编,便会打开一个面板,列出它的成交价、学校学区、犯罪和街景——让你看清自己付的是真正的价值,还是仅仅一个名声。',
|
||||
video04Title: '被忽视的安静街道',
|
||||
video04Desc:
|
||||
'知名邮编把名声计入了价格,但房源照片对噪音却只字不提。按分贝筛选,找到隔壁那条真正安静、无人抬价的街道。',
|
||||
video05Title: '没有溢价的学区',
|
||||
video05Desc:
|
||||
'利兹的一家人寻找优质小学、低犯罪率以及 £350,000 以下的预算,便浮现出那些悄然更便宜、却共享同一学区的街道。',
|
||||
video06Title: 'Waitrose 效应,已计入价格',
|
||||
video06Desc:
|
||||
'步行可达 Waitrose、地铁站和公园是一种已计入价格的溢价——找到附近拥有同样配套、每平方米却更便宜的邮编。',
|
||||
video07Title: '为租房者准备的价值地图',
|
||||
video07Desc:
|
||||
'知名邮编租起来也更贵。设定你的租金、通勤和一条安静街道,看看伦敦哪些邮编真正符合预算。',
|
||||
video08Title: '别再为一个名字多花钱',
|
||||
video08Desc:
|
||||
'设定预算、通勤、犯罪和学校,伦敦地图便会布满被低估的邮编,它们就坐落在知名地段隔壁一条街。',
|
||||
source: '来源:',
|
||||
optOut: '选择不公开',
|
||||
attribution: '数据引用声明',
|
||||
|
|
@ -1488,6 +1513,16 @@ const zh: Translations = {
|
|||
|
||||
// ─ Feature names (Neighbours) ─
|
||||
'Median age': '中位年龄',
|
||||
'% No qualifications': '% 无学历',
|
||||
'% Some GCSEs': '% 部分 GCSE',
|
||||
'% Good GCSEs': '% 良好 GCSE',
|
||||
'% Apprenticeship': '% 学徒制',
|
||||
'% A-levels': '% A-levels',
|
||||
'% Degree or higher': '% 学位及以上',
|
||||
'% Other qualifications': '% 其他学历',
|
||||
'% Owner occupied': '% 自有住房',
|
||||
'% Social rent': '% 社会租赁',
|
||||
'% Private rent': '% 私人租赁',
|
||||
'% White': '% 白人',
|
||||
'% South Asian': '% 南亚裔',
|
||||
'% Black': '% 黑人',
|
||||
|
|
@ -1534,6 +1569,8 @@ const zh: Translations = {
|
|||
'Minor crime': '轻微犯罪',
|
||||
'Ethnic composition': '族裔组成',
|
||||
'Political vote share': '政党得票率',
|
||||
Qualifications: '学历',
|
||||
Tenure: '住房产权',
|
||||
'Anti-social': '反社会',
|
||||
Vehicle: '车辆',
|
||||
Burglary: '入室盗窃',
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const DEMO_GATED_ENDPOINTS = new Set([
|
|||
'hexagon-properties',
|
||||
'journey',
|
||||
'actual-listings',
|
||||
'developments',
|
||||
'export',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,14 @@ export const POI_CLUSTER_MAX_ZOOM = 15;
|
|||
/** Zoom level at which individual POI cards are shown without hovering */
|
||||
export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1;
|
||||
|
||||
/** Hard cap on auto POI cards rendered at once. With every category enabled a
|
||||
* dense area can yield hundreds of overlapping cards — an unreadable wall — so we
|
||||
* show a spaced subset and rely on the map markers (+ hover) for the rest. */
|
||||
export const MAX_AUTO_POI_CARDS = 40;
|
||||
/** Minimum screen-space gap (px) between two auto POI cards before one is culled. */
|
||||
export const AUTO_POI_CARD_MIN_DX = 130;
|
||||
export const AUTO_POI_CARD_MIN_DY = 34;
|
||||
|
||||
/**
|
||||
* Groups whose features should be collapsed into stacked bar charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||
|
|
@ -290,6 +298,19 @@ export const STACKED_GROUPS: Record<
|
|||
'% Other',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Qualifications',
|
||||
unit: '%',
|
||||
components: [
|
||||
'% No qualifications',
|
||||
'% Some GCSEs',
|
||||
'% Good GCSEs',
|
||||
'% Apprenticeship',
|
||||
'% A-levels',
|
||||
'% Degree or higher',
|
||||
'% Other qualifications',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Political vote share',
|
||||
unit: '%',
|
||||
|
|
@ -302,6 +323,11 @@ export const STACKED_GROUPS: Record<
|
|||
'% Other parties',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Tenure',
|
||||
unit: '%',
|
||||
components: ['% Owner occupied', '% Social rent', '% Private rent'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -470,6 +496,18 @@ export const STACKED_SEGMENT_COLORS: Record<string, string> = {
|
|||
'% Black': '#8b5cf6',
|
||||
'% Mixed': '#14b8a6',
|
||||
'% Other': '#6b7280',
|
||||
// Qualifications: low-attainment (red) → high-attainment (green) ramp.
|
||||
'% No qualifications': '#ef4444',
|
||||
'% Some GCSEs': '#f97316',
|
||||
'% Good GCSEs': '#eab308',
|
||||
'% Apprenticeship': '#14b8a6',
|
||||
'% A-levels': '#3b82f6',
|
||||
'% Degree or higher': '#22c55e',
|
||||
'% Other qualifications': '#6b7280',
|
||||
// Tenure (Census 2021 TS054): owner-occupied (green) → social rent (amber) → private rent (blue).
|
||||
'% Owner occupied': '#22c55e',
|
||||
'% Social rent': '#f59e0b',
|
||||
'% Private rent': '#3b82f6',
|
||||
'Anti-social': '#14b8a6',
|
||||
Vehicle: '#3b82f6',
|
||||
Burglary: '#eab308',
|
||||
|
|
|
|||
|
|
@ -21,9 +21,39 @@ import {
|
|||
getFeatureFillColor,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
getPoiIconUrl,
|
||||
selectSpacedItems,
|
||||
zoomToResolution,
|
||||
} from './map-utils';
|
||||
|
||||
describe('selectSpacedItems', () => {
|
||||
const mk = (id: string, x: number, y: number) => ({ item: id, x, y });
|
||||
|
||||
it('keeps every item when none overlap', () => {
|
||||
const items = [mk('a', 0, 0), mk('b', 200, 0), mk('c', 0, 200)];
|
||||
const kept = selectSpacedItems(items, 130, 34, 40);
|
||||
expect(kept.map((k) => k.item)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('culls items that fall within the spacing of an already-kept item', () => {
|
||||
// b and c sit on top of a; d is clear.
|
||||
const items = [mk('a', 100, 100), mk('b', 120, 110), mk('c', 150, 100), mk('d', 400, 400)];
|
||||
const kept = selectSpacedItems(items, 130, 34, 40);
|
||||
expect(kept.map((k) => k.item)).toEqual(['a', 'd']);
|
||||
});
|
||||
|
||||
it('treats dx and dy independently (far enough on either axis is kept)', () => {
|
||||
const items = [mk('a', 0, 0), mk('b', 10, 40)]; // dx<130 but dy>=34
|
||||
const kept = selectSpacedItems(items, 130, 34, 40);
|
||||
expect(kept.map((k) => k.item)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('stops at the max cap', () => {
|
||||
const items = Array.from({ length: 100 }, (_, i) => mk(String(i), i * 500, 0));
|
||||
const kept = selectSpacedItems(items, 130, 34, 40);
|
||||
expect(kept).toHaveLength(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('map utilities', () => {
|
||||
it('maps zoom levels to H3 resolutions at configured thresholds', () => {
|
||||
expect(zoomToResolution(6.9)).toBe(5);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,36 @@ import {
|
|||
MAP_MIN_ZOOM,
|
||||
type GradientStop,
|
||||
} from './consts';
|
||||
/** A candidate overlay anchored at screen-space (x, y). */
|
||||
export interface PlacedItem<T> {
|
||||
item: T;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedily keep a spaced-out subset of screen-space items so dense areas don't
|
||||
* render a wall of overlapping cards. Walks `candidates` in order, keeping one
|
||||
* only when it clears every already-kept item by `minDx`/`minDy`, and stops at
|
||||
* `max`. Order-dependent by design — pass candidates in priority order.
|
||||
*/
|
||||
export function selectSpacedItems<T>(
|
||||
candidates: PlacedItem<T>[],
|
||||
minDx: number,
|
||||
minDy: number,
|
||||
max: number
|
||||
): PlacedItem<T>[] {
|
||||
const kept: PlacedItem<T>[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (kept.length >= max) break;
|
||||
const overlaps = kept.some(
|
||||
(k) => Math.abs(k.x - candidate.x) < minDx && Math.abs(k.y - candidate.y) < minDy
|
||||
);
|
||||
if (!overlaps) kept.push(candidate);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
const ROAD_OPACITY = 0.4;
|
||||
const TILE_SIZE = 512;
|
||||
const MAX_MERCATOR_LATITUDE = 85;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export const OVERLAY_IDS = [
|
|||
'crime-hotspots',
|
||||
'trees-outside-woodlands',
|
||||
'property-borders',
|
||||
'new-developments',
|
||||
] as const;
|
||||
|
||||
export type OverlayId = (typeof OVERLAY_IDS)[number];
|
||||
|
|
@ -47,6 +48,13 @@ export const OVERLAYS: OverlayDefinition[] = [
|
|||
detail:
|
||||
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are "general boundaries" for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 85–90% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
|
||||
},
|
||||
{
|
||||
id: 'new-developments',
|
||||
label: 'New developments',
|
||||
description: 'Planned new-home sites (brownfield register + Homes England)',
|
||||
detail:
|
||||
'A forward-looking pipeline of new housing. Blue markers mark sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Overlays shown on a fresh visit, before any `overlay` URL params apply. */
|
||||
|
|
@ -73,4 +81,5 @@ export const OVERLAY_MIN_ZOOM: Record<OverlayId, number> = {
|
|||
noise: 12,
|
||||
'property-borders': 12,
|
||||
'trees-outside-woodlands': 12,
|
||||
'new-developments': 12,
|
||||
};
|
||||
|
|
|
|||
22
frontend/src/lib/qualification-filter.ts
Normal file
22
frontend/src/lib/qualification-filter.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* The Census 2021 qualification-breakdown features (TS067). These render as a
|
||||
* single stacked "Qualifications" composition in the area pane (see
|
||||
* STACKED_GROUPS["Neighbours"] in consts.ts) and are display-only: they are
|
||||
* hidden from the filter browser rather than offered as seven individual
|
||||
* sliders, so the breakdown reads as a ratio without cluttering the filter list.
|
||||
*/
|
||||
export const QUALIFICATION_FEATURE_NAMES = [
|
||||
'% No qualifications',
|
||||
'% Some GCSEs',
|
||||
'% Good GCSEs',
|
||||
'% Apprenticeship',
|
||||
'% A-levels',
|
||||
'% Degree or higher',
|
||||
'% Other qualifications',
|
||||
] as const;
|
||||
|
||||
const QUALIFICATION_FEATURE_NAME_SET = new Set<string>(QUALIFICATION_FEATURE_NAMES);
|
||||
|
||||
export function isQualificationFeatureName(name: string): boolean {
|
||||
return QUALIFICATION_FEATURE_NAME_SET.has(name);
|
||||
}
|
||||
|
|
@ -165,6 +165,27 @@ export interface ActualListingsResponse {
|
|||
total: number;
|
||||
}
|
||||
|
||||
export interface Development {
|
||||
lat: number;
|
||||
lon: number;
|
||||
source: 'brownfield' | 'homes-england';
|
||||
name?: string | null;
|
||||
min_dwellings?: number | null;
|
||||
max_dwellings?: number | null;
|
||||
planning_status?: string | null;
|
||||
permission_type?: string | null;
|
||||
permission_date?: string | null;
|
||||
hectares?: number | null;
|
||||
local_authority?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export interface DevelopmentsResponse {
|
||||
developments: Development[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface POICategoryGroup {
|
||||
name: string;
|
||||
categories: string[];
|
||||
|
|
@ -294,6 +315,18 @@ export interface CrimeYearStats {
|
|||
points: CrimeYearPoint[];
|
||||
}
|
||||
|
||||
export interface CrimeAreaAverage {
|
||||
/** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */
|
||||
name: string;
|
||||
/** Exact national mean (avg/yr). Preferred over the histogram-bin national
|
||||
* average for crime so all reference numbers share one estimator. */
|
||||
national?: number;
|
||||
/** Mean headline rate (avg/yr) across the selection's outcode. */
|
||||
outcode?: number;
|
||||
/** Mean headline rate (avg/yr) across the selection's postcode sector. */
|
||||
sector?: number;
|
||||
}
|
||||
|
||||
export interface FilterExclusion {
|
||||
name: string;
|
||||
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
||||
|
|
@ -319,6 +352,18 @@ export interface HexagonStatsResponse {
|
|||
* since mid-2019) and its crime figures are captioned as stale.
|
||||
*/
|
||||
crime_latest_year?: number;
|
||||
/** Outward code (e.g. "E14") of the selection's central postcode, when
|
||||
* outcode crime averages are available for it. */
|
||||
crime_outcode?: string;
|
||||
/** Postcode sector (e.g. "E14 2") of the selection's central postcode, when
|
||||
* sector crime averages are available for it. */
|
||||
crime_sector?: string;
|
||||
/** Per-crime-type average rates across the central postcode's outcode and
|
||||
* sector, shown alongside the national average for each crime metric. */
|
||||
crime_area_averages?: CrimeAreaAverage[];
|
||||
central_postcode?: string;
|
||||
/** Total usual residents (ONS Census 2021) across the postcodes in this
|
||||
* selection. Display-only; independent of active filters. */
|
||||
population?: number;
|
||||
filter_exclusions?: FilterExclusion[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue