Lint & small changes

This commit is contained in:
Andras Schmelczer 2026-04-04 22:59:07 +01:00
parent 0c6d207967
commit 55238f59aa
21 changed files with 2522 additions and 423 deletions

View file

@ -11,6 +11,8 @@ import { SearchIcon } from '../ui/icons/SearchIcon';
export interface SearchedLocation {
postcode: string;
geometry: PostcodeGeometry;
latitude: number;
longitude: number;
}
const ZOOM_FOR_TYPE: Record<string, number> = {
@ -94,7 +96,12 @@ export default function LocationSearch({
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
});
search.clear();
if (isMobile) setExpanded(false);
} catch {
@ -139,7 +146,12 @@ export default function LocationSearch({
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
});
search.clear();
if (isMobile) setExpanded(false);
} catch {

View file

@ -116,7 +116,7 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileMapHeight, mobileResizeHandlers, mobileMapRef] = usePaneResize(
const [, mobileResizeHandlers, mobileMapRef] = usePaneResize(
Math.round(window.innerHeight * 0.4),
120,
0.8,
@ -167,16 +167,32 @@ export default function MapPage({
features,
});
const aiFilters = useAiFilters();
const {
fetchAiFilters,
loading: aiFilterLoading,
error: aiFilterError,
errorType: aiFilterErrorType,
notes: aiFilterNotes,
summary: aiFilterSummary,
} = useAiFilters();
const travelTime = useTravelTime(initialTravelTime);
const {
entries,
activeEntries,
handleAddEntry,
handleRemoveEntry,
handleSetDestination,
handleSetEntries,
handleTimeRangeChange,
handleToggleBest,
} = useTravelTime(initialTravelTime);
const handleAiFilterSubmit = useCallback(
async (query: string) => {
// Build context from current filters for conversational refinement
const context = {
filters,
travelTime: travelTime.activeEntries.map((entry) => ({
travelTime: activeEntries.map((entry) => ({
mode: entry.mode,
label: entry.label,
min: entry.timeRange?.[0],
@ -185,7 +201,7 @@ export default function MapPage({
};
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
const result = await fetchAiFilters(query, hasContext ? context : undefined);
if (!result) return;
handleSetFilters(result.filters);
// Always sync travel time entries — clear stale ones when AI returns none
@ -196,40 +212,34 @@ export default function MapPage({
timeRange: [tt.min ?? 0, tt.max ?? 120] as [number, number],
useBest: false,
}));
travelTime.handleSetEntries(newEntries);
handleSetEntries(newEntries);
},
[
aiFilters.fetchAiFilters,
handleSetFilters,
travelTime.handleSetEntries,
travelTime.activeEntries,
filters,
]
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters]
);
const handleClearAll = useCallback(() => {
handleSetFilters({});
handleCancelPin();
travelTime.handleSetEntries([]);
}, [handleSetFilters, handleCancelPin, travelTime.handleSetEntries]);
handleSetEntries([]);
}, [handleSetFilters, handleCancelPin, handleSetEntries]);
const handleTravelTimeRemoveEntry = useCallback(
(index: number) => {
const entry = travelTime.entries[index];
const entry = entries[index];
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
handleCancelPin();
}
travelTime.handleRemoveEntry(index);
handleRemoveEntry(index);
},
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
[handleRemoveEntry, entries, pinnedFeature, handleCancelPin]
);
const handleTravelTimeDragEnd = useCallback(
(index: number) => {
const dv = handleDragEndNoCommit();
if (dv) travelTime.handleTimeRangeChange(index, dv);
if (dv) handleTimeRangeChange(index, dv);
},
[handleDragEndNoCommit, travelTime.handleTimeRangeChange]
[handleDragEndNoCommit, handleTimeRangeChange]
);
const license = useLicense();
@ -241,28 +251,46 @@ export default function MapPage({
features,
viewFeature,
activeFeature,
travelTimeEntries: travelTime.entries,
travelTimeEntries: entries,
});
const filterCounts = useFilterCounts(filters, features, mapData.bounds, travelTime.entries);
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries);
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string, lat: number, lon: number) => {
travelTime.handleSetDestination(index, slug, label);
handleSetDestination(index, slug, label);
if (slug) {
mapFlyToRef.current?.(lat, lon, mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom);
}
},
[travelTime.handleSetDestination, mapData.currentView?.zoom]
[handleSetDestination, mapData.currentView?.zoom]
);
// First transit destination — used to pick the best central_postcode for journey display
const journeyDest = useMemo(() => {
const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug);
const entry = entries.find((e) => e.mode === 'transit' && e.slug);
return entry ? { mode: entry.mode, slug: entry.slug } : null;
}, [travelTime.entries]);
}, [entries]);
const selection = useHexagonSelection({
const {
selectedHexagon,
properties,
propertiesTotal,
loadingProperties,
areaStats,
loadingAreaStats,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
} = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
@ -272,13 +300,13 @@ export default function MapPage({
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
selection.handleLocationSearch(result.postcode, result.geometry);
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
if (isMobile) setMobileDrawerOpen(true);
} else {
selection.handleCloseSelection();
handleCloseSelection();
}
},
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
[handleLocationSearch, handleCloseSelection, isMobile]
);
const handleZoomToFreeZone = useCallback(() => {
@ -292,18 +320,11 @@ export default function MapPage({
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
mapData.currentView,
filters,
features,
selectedPOICategories,
selection.rightPaneTab,
travelTime.entries
);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, rightPaneTab, entries);
useEffect(() => {
mapData.setInitialView(initialViewState);
selection.setRightPaneTab(initialTab);
setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Navigate to a specific postcode on mount (e.g. from saved properties)
@ -329,7 +350,7 @@ export default function MapPage({
geometry: PostcodeGeometry;
}) => {
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
selection.handleLocationSearch(data.postcode, data.geometry);
handleLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude);
if (isMobile) setMobileDrawerOpen(true);
}
)
@ -361,7 +382,6 @@ export default function MapPage({
return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile]);
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
handleHexagonClick(id, isPostcode, geometry);
@ -373,8 +393,8 @@ export default function MapPage({
);
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const isPostcode = selection.selectedHexagon?.type === 'postcode';
const hexId = selectedHexagon?.id;
const isPostcode = selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData; postcode string is the selection id
@ -390,16 +410,16 @@ export default function MapPage({
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
postcode: selection.areaStats?.central_postcode,
postcode: areaStats?.central_postcode,
};
}
}, [
selection.selectedHexagon?.id,
selection.selectedHexagon?.type,
selectedHexagon?.id,
selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
selection.areaStats?.central_postcode,
areaStats?.central_postcode,
]);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
@ -548,7 +568,7 @@ export default function MapPage({
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeEntries={entries}
/>
</div>
);
@ -556,22 +576,20 @@ export default function MapPage({
const renderAreaPane = () => (
<AreaPane
stats={selection.areaStats}
stats={areaStats}
globalFeatures={features}
loading={selection.loadingAreaStats}
hexagonId={selection.selectedHexagon?.id || null}
isPostcode={selection.selectedHexagon?.type === 'postcode'}
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selection.selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find(
(f) => f.properties.postcode === selection.selectedHexagon?.id
) || null
selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) || null
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
onViewProperties={handleViewPropertiesFromArea}
hexagonLocation={hexagonLocation}
filters={filters}
travelTimeEntries={travelTime.activeEntries}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
@ -579,11 +597,11 @@ export default function MapPage({
const renderPropertiesPane = () => (
<PropertiesPane
properties={selection.properties}
total={selection.propertiesTotal}
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
@ -618,18 +636,18 @@ export default function MapPage({
onTogglePin={handleTogglePin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
onTravelTimeAddEntry={travelTime.handleAddEntry}
travelTimeEntries={entries}
onTravelTimeAddEntry={handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
aiFilterErrorType={aiFilters.errorType}
aiFilterNotes={aiFilters.notes}
aiFilterSummary={aiFilters.summary}
onTravelTimeToggleBest={handleToggleBest}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
aiFilterNotes={aiFilterNotes}
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
@ -661,7 +679,6 @@ export default function MapPage({
<div
ref={mobileMapRef}
className="relative overflow-hidden"
style={{ height: mobileMapHeight }}
>
<Map
data={mapData.data}
@ -675,19 +692,19 @@ export default function MapPage({
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={selection.handleHexagonHover}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={travelTime.entries}
travelTimeEntries={entries}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@ -773,17 +790,17 @@ export default function MapPage({
<div className="flex-1 min-h-0">{renderFilters()}</div>
</div>
{mobileDrawerOpen && selection.selectedHexagon && (
{mobileDrawerOpen && selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={selection.rightPaneTab}
tab={rightPaneTab}
onTabChange={(t) => {
if (t === 'properties') {
selection.handlePropertiesTabClick();
handlePropertiesTabClick();
} else {
selection.setRightPaneTab(t);
setRightPaneTab(t);
}
}}
/>
@ -860,18 +877,18 @@ export default function MapPage({
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
onHexagonClick={selection.handleHexagonClick}
onHexagonHover={selection.handleHexagonHover}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={filterCounts.total || undefined}
/>
@ -902,7 +919,7 @@ export default function MapPage({
)}
</div>
{selection.selectedHexagon && (
{selectedHexagon && (
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
@ -922,16 +939,16 @@ export default function MapPage({
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
isActive={rightPaneTab === 'properties'}
onClick={handlePropertiesTabClick}
/>
<button
onClick={selection.handleCloseSelection}
onClick={handleCloseSelection}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
>
@ -940,7 +957,7 @@ export default function MapPage({
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
{rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer, PolygonLayer } from '@deck.gl/layers';
import { cellToBoundary } from 'h3-js';
import Supercluster from 'supercluster';
import type { PickingInfo } from '@deck.gl/core';
@ -26,6 +26,7 @@ import {
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
interface UseDeckLayersProps {
data: HexagonData[];
@ -66,6 +67,17 @@ interface ClusterPoint {
clusterId: number;
}
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
function distToRatios(dist: unknown): number[] {
if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let total = 0;
for (let i = 0; i < dist.length; i++) total += (dist[i] as number) || 0;
if (total === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const r = new Array<number>(10).fill(0);
for (let i = 0; i < Math.min(dist.length, 10); i++) r[i] = ((dist[i] as number) || 0) / total;
return r;
}
export function useDeckLayers({
data,
postcodeData,
@ -294,215 +306,331 @@ export function useDeckLayers({
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
// --- Layers ---
const hexLayer = useMemo(
() =>
new H3HexagonLayer<HexagonData>({
// For enum features, we bypass H3HexagonLayer and use PolygonLayer directly.
// H3HexagonLayer has double CompositeLayer nesting (H3 → PolygonLayer → SolidPolygonLayer)
// which prevents custom binary attributes from reaching the fill sublayer.
// PolygonLayer has only one level of nesting, so _subLayerProps.fill works reliably.
const hexLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
if (isEnum) {
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
const n = data.length;
// Pre-compute hex boundaries and binary attribute buffers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const polyData: any[] = new Array(n);
const centers = new Float32Array(n * 2);
const r0 = new Float32Array(n * 4);
const r1 = new Float32Array(n * 4);
const r2 = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const d = data[i];
polyData[i] = { ...d, polygon: cellToBoundary(d.h3, true) };
centers[i * 2] = d.lon as number;
centers[i * 2 + 1] = d.lat as number;
const r = distToRatios(d[distKey]);
r0[i * 4] = r[0];
r0[i * 4 + 1] = r[1];
r0[i * 4 + 2] = r[2];
r0[i * 4 + 3] = r[3];
r1[i * 4] = r[4];
r1[i * 4 + 1] = r[5];
r1[i * 4 + 2] = r[6];
r1[i * 4 + 3] = r[7];
r2[i * 2] = r[8];
r2[i * 2 + 1] = r[9];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new (PolygonLayer as any)({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr) {
// Travel time feature: dim hexagons with no data
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
255
);
}
// Regular feature
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255,
enumCountRef.current
);
}
}
// Density fallback
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
255
);
},
getLineColor: (d) => {
data: polyData,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getPolygon: (d: any) => d.polygon,
getFillColor: [200, 200, 200],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getLineColor: (d: any) => {
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
return [29, 228, 195, 200];
return [0, 0, 0, 0];
},
getLineWidth: (d) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getLineWidth: (d: any) => {
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
},
extensions: [new PieHexExtension()],
_subLayerProps: {
fill: {
instancePieCenter: { value: centers, size: 2 },
instanceRatios0: { value: r0, size: 4 },
instanceRatios1: { value: r1, size: 4 },
instanceRatios2: { value: r2, size: 2 },
},
},
extruded: false,
pickable: true,
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
}),
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
);
});
}
const postcodeLayer = useMemo(
() =>
new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons',
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
// Non-enum: use H3HexagonLayer as normal
return new H3HexagonLayer<HexagonData>({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr) {
// Travel time feature: dim postcodes with no data
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
180
);
}
// Regular feature
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
180,
enumCountRef.current
);
if (vf && clr) {
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
255
);
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
180
);
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255,
enumCountRef.current
);
}
}
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
255
);
},
getLineColor: (d) => {
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
},
extruded: false,
pickable: true,
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
});
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
const postcodeLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
// Same binary buffer approach as hexagons, routed via _subLayerProps.
// GeoJsonLayer → 'polygons-fill' (PolygonLayer) → 'fill' (SolidPolygonLayer)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let pieProps: any = {};
if (isEnum) {
const n = postcodeData.length;
const centers = new Float32Array(n * 2);
const r0 = new Float32Array(n * 4);
const r1 = new Float32Array(n * 4);
const r2 = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const centroid = postcodeData[i].properties.centroid as [number, number];
centers[i * 2] = centroid[0];
centers[i * 2 + 1] = centroid[1];
const r = distToRatios(postcodeData[i].properties[distKey]);
r0[i * 4] = r[0];
r0[i * 4 + 1] = r[1];
r0[i * 4 + 2] = r[2];
r0[i * 4 + 3] = r[3];
r1[i * 4] = r[4];
r1[i * 4 + 1] = r[5];
r1[i * 4 + 2] = r[6];
r1[i * 4 + 3] = r[7];
r2[i * 2] = r[8];
r2[i * 2 + 1] = r[9];
}
const fillAttrs = {
instancePieCenter: { value: centers, size: 2 },
instanceRatios0: { value: r0, size: 4 },
instanceRatios1: { value: r1, size: 4 },
instanceRatios2: { value: r2, size: 2 },
};
pieProps = {
extensions: [new PieHexExtension()],
_subLayerProps: {
'polygons-fill': {
_subLayerProps: {
fill: fillAttrs,
},
},
},
getLineColor: (f) => {
const pc = f.properties.postcode;
const dark = isDarkRef.current;
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
number,
number,
];
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
if (pc === hoveredPostcodeRef.current) return 2;
return 1;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
},
extruded: false,
pickable: true,
onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
}),
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
);
};
}
return new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons',
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr) {
// Travel time feature: dim postcodes with no data
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
180
);
}
// Regular feature (for enum, the extension overrides this color)
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
180,
enumCountRef.current
);
}
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
180
);
},
getLineColor: (f) => {
const pc = f.properties.postcode;
const dark = isDarkRef.current;
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
number,
number,
];
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
if (pc === hoveredPostcodeRef.current) return 2;
return 1;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
},
extruded: false,
pickable: true,
onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback,
beforeId: 'landuse_park',
...pieProps,
});
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
const postcodeLabelsLayer = useMemo(
() =>

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { latLngToCell } from 'h3-js';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
@ -287,25 +288,68 @@ export function useHexagonSelection({
return () => {
cancelled = true;
};
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats, rightPaneTab, fetchHexagonProperties, fetchPostcodeProperties]);
}, [
filterStr,
selectedHexagon,
fetchHexagonStats,
fetchPostcodeStats,
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
]);
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {
(postcode: string, geometry: PostcodeGeometry, lat?: number, lng?: number) => {
trackEvent('Postcode Search');
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setRightPaneTab('area');
setLoadingAreaStats(true);
// First try the postcode; if it has no properties, fall back to hexagons
fetchPostcodeStats(postcode)
.then((stats) => setAreaStats(stats))
.then(async (stats) => {
if (stats.count > 0) {
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
return;
}
// No properties in this postcode — fall back to hexagons
if (lat == null || lng == null) {
// No coordinates available, show empty postcode anyway
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
return;
}
// Try progressively coarser H3 resolutions until we find >1 property
const resolutions = [9, 8, 7, 6, 5];
for (const res of resolutions) {
const h3 = latLngToCell(lat, lng, res);
const hexStats = await fetchHexagonStats(h3, res);
if (hexStats.count > 1) {
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: res });
setSelectedPostcodeGeometry(null);
setAreaStats(hexStats);
return;
}
}
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
const h3 = latLngToCell(lat, lng, 9);
const fallbackStats = await fetchHexagonStats(h3, 9);
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: 9 });
setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
},
[resolution, fetchPostcodeStats]
[resolution, fetchPostcodeStats, fetchHexagonStats]
);
return {

View file

@ -75,6 +75,12 @@ export function useMapData({
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
// Determine if the current viewFeature is an enum (for enum_dist param)
const viewFeatureIsEnum = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature)?.type === 'enum' : false),
[viewFeature, features]
);
const buildFilterParam = useCallback(
(): string => buildFilterString(filters, features),
[filters, features]
@ -134,6 +140,7 @@ export function useMapData({
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -151,6 +158,7 @@ export function useMapData({
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -168,7 +176,18 @@ export function useMapData({
dragAbortRef.current = null;
}
};
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam, buildTravelParam]);
}, [
activeFeature,
bounds,
resolution,
filters,
features,
usePostcodeView,
travelParam,
buildTravelParam,
viewFeature,
viewFeatureIsEnum,
]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
@ -196,6 +215,7 @@ export function useMapData({
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
@ -226,6 +246,7 @@ export function useMapData({
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
const res = await fetch(
apiUrl('hexagons', params),
authHeaders({
@ -268,7 +289,16 @@ export function useMapData({
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
}, [
resolution,
bounds,
filters,
buildFilterParam,
viewFeature,
viewFeatureIsEnum,
usePostcodeView,
travelParam,
]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data =

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useLayoutEffect } from 'react';
interface PaneResizeHandlers {
onPointerDown: (e: React.PointerEvent) => void;
@ -22,9 +22,24 @@ export function usePaneResize(
const isVertical = side === 'top' || side === 'bottom';
const styleProp = isVertical ? 'height' : 'width';
const targetCallbackRef = useCallback((el: HTMLElement | null) => {
targetRef.current = el;
}, []);
const targetCallbackRef = useCallback(
(el: HTMLElement | null) => {
targetRef.current = el;
if (el) {
el.style[styleProp] = `${liveSizeRef.current}px`;
}
},
[styleProp]
);
// Keep DOM in sync when React state commits (e.g. on pointerUp).
// This ensures the ref-managed element always reflects the latest size
// without relying on React-controlled style props.
useLayoutEffect(() => {
if (targetRef.current) {
targetRef.current.style[styleProp] = `${size}px`;
}
}, [size, styleProp]);
const computeSize = useCallback(
(e: React.PointerEvent): number => {

View file

@ -35,12 +35,22 @@ export function useTranslatedModes() {
const { t } = useTranslation();
const label = useCallback(
(mode: TransportMode): string =>
({ car: t('travel.modeCar'), bicycle: t('travel.modeBicycle'), walking: t('travel.modeWalking'), transit: t('travel.modeTransit') })[mode],
({
car: t('travel.modeCar'),
bicycle: t('travel.modeBicycle'),
walking: t('travel.modeWalking'),
transit: t('travel.modeTransit'),
})[mode],
[t]
);
const desc = useCallback(
(mode: TransportMode): string =>
({ car: t('travel.modeCarDesc'), bicycle: t('travel.modeBicycleDesc'), walking: t('travel.modeWalkingDesc'), transit: t('travel.modeTransitDesc') })[mode],
({
car: t('travel.modeCarDesc'),
bicycle: t('travel.modeBicycleDesc'),
walking: t('travel.modeWalkingDesc'),
transit: t('travel.modeTransitDesc'),
})[mode],
[t]
);
return { label, desc };

View file

@ -8,14 +8,54 @@ const STORAGE_KEY = 'tutorial_completed';
export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked = false) {
const { t } = useTranslation();
const steps: Step[] = useMemo(() => [
{ target: '[data-tutorial="filters"]', title: t('tutorial.step1Title'), content: t('tutorial.step1Content'), placement: 'right' as const, disableBeacon: true },
{ target: '[data-tutorial="ai-filters"]', title: t('tutorial.step2Title'), content: t('tutorial.step2Content'), placement: 'right' as const, disableBeacon: true },
{ target: '[data-tutorial="map"]', title: t('tutorial.step3Title'), content: t('tutorial.step3Content'), placement: 'bottom' as const, disableBeacon: true },
{ target: '[data-tutorial="search"]', title: t('tutorial.step4Title'), content: t('tutorial.step4Content'), placement: 'bottom' as const, disableBeacon: true },
{ target: '[data-tutorial="right-pane"]', title: t('tutorial.step5Title'), content: t('tutorial.step5Content'), placement: 'left' as const, disableBeacon: true },
{ target: '[data-tutorial="poi-button"]', title: t('tutorial.step6Title'), content: t('tutorial.step6Content'), placement: 'left' as const, disableBeacon: true, styles: { tooltip: { transform: 'translateY(-50px)' } } },
], [t]);
const steps: Step[] = useMemo(
() => [
{
target: '[data-tutorial="filters"]',
title: t('tutorial.step1Title'),
content: t('tutorial.step1Content'),
placement: 'right' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="ai-filters"]',
title: t('tutorial.step2Title'),
content: t('tutorial.step2Content'),
placement: 'right' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="map"]',
title: t('tutorial.step3Title'),
content: t('tutorial.step3Content'),
placement: 'bottom' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="search"]',
title: t('tutorial.step4Title'),
content: t('tutorial.step4Content'),
placement: 'bottom' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="right-pane"]',
title: t('tutorial.step5Title'),
content: t('tutorial.step5Content'),
placement: 'left' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="poi-button"]',
title: t('tutorial.step6Title'),
content: t('tutorial.step6Content'),
placement: 'left' as const,
disableBeacon: true,
styles: { tooltip: { transform: 'translateY(-50px)' } },
},
],
[t]
);
const [run, setRun] = useState(() => {
if (isMobile) return false;
@ -50,6 +90,6 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
handleCallback,
resetTutorial,
}),
[shouldRun, handleCallback, resetTutorial]
[steps, shouldRun, handleCallback, resetTutorial]
);
}

View file

@ -12,7 +12,8 @@ import i18n from 'i18next';
*/
const descriptions: Record<string, Record<string, string>> = {
fr: {
'Listing status': 'Indique si le bien provient de ventes historiques, est en vente ou en location',
'Listing status':
'Indique si le bien provient de ventes historiques, est en vente ou en location',
'Property type': 'Type de bien : individuel, jumelé, mitoyen, appartement ou autre',
'Leasehold/Freehold': 'Indique si le bien est en bail ou en pleine propriété',
'Last known price': 'Dernier prix de vente enregistré au Land Registry',
@ -25,43 +26,56 @@ const descriptions: Record<string, Record<string, string>> = {
'Asking rent (monthly)': 'Loyer mensuel affiché pour les biens en location',
'Total floor area (sqm)': 'Surface intérieure issue du diagnostic EPC',
'Number of bedrooms & living rooms': 'Nombre de pièces habitables selon le diagnostic EPC',
'Bedrooms': 'Nombre de chambres selon lannonce en ligne',
'Bathrooms': 'Nombre de salles de bain selon lannonce en ligne',
Bedrooms: 'Nombre de chambres selon lannonce en ligne',
Bathrooms: 'Nombre de salles de bain selon lannonce en ligne',
'Construction year': 'Année de construction estimée selon lEPC',
'Date of last transaction': 'Date de la dernière vente enregistrée au Land Registry',
'Listing date': 'Date de première mise en ligne du bien',
'Former council house': 'Indique si le bien a été répertorié comme logement social',
'Current energy rating': 'Classement énergétique EPC actuel (A = meilleur, G = pire)',
'Potential energy rating': 'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
'Potential energy rating':
'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
'Interior height (m)': 'Hauteur moyenne détage selon le diagnostic EPC',
'Distance to nearest train or tube station (km)': 'Distance à la gare ou station de métro la plus proche',
'Good+ primary schools within 2km': 'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km': 'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ primary schools within 5km': 'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km': 'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Education, Skills and Training Score': 'Score de qualité éducative du secteur (plus élevé = meilleur)',
'Distance to nearest train or tube station (km)':
'Distance à la gare ou station de métro la plus proche',
'Good+ primary schools within 2km':
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km':
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ primary schools within 5km':
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km':
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Education, Skills and Training Score':
'Score de qualité éducative du secteur (plus élevé = meilleur)',
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
'Employment Score (rate)': 'Taux de précarité demploi, inversé (plus élevé = moins précaire)',
'Health Deprivation and Disability Score': 'Score de santé et handicap (plus élevé = meilleurs résultats)',
'Living Environment Score': 'Qualité de lenvironnement intérieur et extérieur (plus élevé = meilleur)',
'Health Deprivation and Disability Score':
'Score de santé et handicap (plus élevé = meilleurs résultats)',
'Living Environment Score':
'Qualité de lenvironnement intérieur et extérieur (plus élevé = meilleur)',
'Indoors Sub-domain Score': 'Qualité et état du logement (plus élevé = meilleur)',
'Outdoors Sub-domain Score': 'Qualité de lair et sécurité routière (plus élevé = meilleur)',
'Serious crime per 1k residents (avg/yr)': 'Taux de crimes graves pour 1 000 habitants par an',
'Minor crime per 1k residents (avg/yr)': 'Taux de délits mineurs pour 1 000 habitants par an',
'Serious crime (avg/yr)': 'Agrégat des catégories de crimes graves par an',
'Minor crime (avg/yr)': 'Agrégat des catégories de délits mineurs par an',
'Violence and sexual offences (avg/yr)': 'Moyenne annuelle des violences et infractions sexuelles dans le secteur',
'Violence and sexual offences (avg/yr)':
'Moyenne annuelle des violences et infractions sexuelles dans le secteur',
'Burglary (avg/yr)': 'Moyenne annuelle des cambriolages dans le secteur',
'Robbery (avg/yr)': 'Moyenne annuelle des vols avec violence dans le secteur',
'Vehicle crime (avg/yr)': 'Moyenne annuelle des crimes liés aux véhicules dans le secteur',
'Anti-social behaviour (avg/yr)': 'Moyenne annuelle des comportements antisociaux dans le secteur',
'Criminal damage and arson (avg/yr)': 'Moyenne annuelle des dégradations et incendies criminels dans le secteur',
'Anti-social behaviour (avg/yr)':
'Moyenne annuelle des comportements antisociaux dans le secteur',
'Criminal damage and arson (avg/yr)':
'Moyenne annuelle des dégradations et incendies criminels dans le secteur',
'Other theft (avg/yr)': 'Moyenne annuelle des autres vols dans le secteur',
'Theft from the person (avg/yr)': 'Moyenne annuelle des vols à la personne dans le secteur',
'Shoplifting (avg/yr)': 'Moyenne annuelle des vols à létalage dans le secteur',
'Bicycle theft (avg/yr)': 'Moyenne annuelle des vols de vélos dans le secteur',
'Drugs (avg/yr)': 'Moyenne annuelle des infractions liées aux stupéfiants dans le secteur',
'Possession of weapons (avg/yr)': 'Moyenne annuelle des infractions de possession darmes dans le secteur',
'Possession of weapons (avg/yr)':
'Moyenne annuelle des infractions de possession darmes dans le secteur',
'Public order (avg/yr)': 'Moyenne annuelle des troubles à lordre public dans le secteur',
'Other crime (avg/yr)': 'Moyenne annuelle des autres crimes dans le secteur',
'Median age': 'Âge médian de la population locale',
@ -69,18 +83,22 @@ const descriptions: Record<string, Record<string, string>> = {
'% South Asian': 'Pourcentage de la population se déclarant Sud-Asiatique',
'% Black': 'Pourcentage de la population se déclarant Noire',
'% East Asian': 'Pourcentage de la population se déclarant Est-Asiatique',
'% Mixed': 'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
'% Mixed':
'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
'% Other': 'Pourcentage de la population se déclarant dun autre groupe ethnique',
'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche',
'Number of parks within 2km': 'Nombre de parcs et espaces verts à moins de 2 km',
'Number of restaurants within 2km': 'Nombre de restaurants et cafés à moins de 2 km',
'Number of grocery shops and supermarkets within 2km': 'Nombre dépiceries et supermarchés à moins de 2 km',
'Number of grocery shops and supermarkets within 2km':
'Nombre dépiceries et supermarchés à moins de 2 km',
'Noise (dB)': 'Niveau de bruit routier au code postal en décibels (Lden)',
'Max available download speed (Mbps)': 'Débit descendant maximal disponible au code postal',
},
de: {
'Listing status': 'Ob die Immobilie aus historischen Verkäufen stammt, aktuell zum Verkauf oder zur Miete steht',
'Property type': 'Immobilientyp: freistehend, Doppelhaushälfte, Reihenhaus, Wohnung oder sonstige',
'Listing status':
'Ob die Immobilie aus historischen Verkäufen stammt, aktuell zum Verkauf oder zur Miete steht',
'Property type':
'Immobilientyp: freistehend, Doppelhaushälfte, Reihenhaus, Wohnung oder sonstige',
'Leasehold/Freehold': 'Ob die Immobilie Erbbaurecht oder Volleigentum ist',
'Last known price': 'Letzter Verkaufspreis laut Land Registry',
'Estimated current price': 'Inflationsbereinigter Schätzwert der Immobilie',
@ -92,8 +110,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Asking rent (monthly)': 'Angebotene Monatsmiete für Mietimmobilien',
'Total floor area (sqm)': 'Wohnfläche laut EPC-Gutachten',
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer Räume laut EPC-Gutachten',
'Bedrooms': 'Anzahl Schlafzimmer laut Online-Inserat',
'Bathrooms': 'Anzahl Badezimmer laut Online-Inserat',
Bedrooms: 'Anzahl Schlafzimmer laut Online-Inserat',
Bathrooms: 'Anzahl Badezimmer laut Online-Inserat',
'Construction year': 'Geschätztes Baujahr laut EPC',
'Date of last transaction': 'Datum des letzten Verkaufs laut Land Registry',
'Listing date': 'Datum der Erstveröffentlichung des Inserats',
@ -101,49 +119,67 @@ const descriptions: Record<string, Record<string, string>> = {
'Current energy rating': 'Aktuelle EPC-Energieeffizienzklasse (A = beste, G = schlechteste)',
'Potential energy rating': 'Potenzielle EPC-Klasse bei Umsetzung aller empfohlenen Maßnahmen',
'Interior height (m)': 'Durchschnittliche Geschosshöhe laut EPC-Gutachten',
'Distance to nearest train or tube station (km)': 'Entfernung zum nächsten Bahn- oder U-Bahnhof',
'Good+ primary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Distance to nearest train or tube station (km)':
'Entfernung zum nächsten Bahn- oder U-Bahnhof',
'Good+ primary schools within 2km':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
'Income Score (rate)': 'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Employment Score (rate)': 'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Health Deprivation and Disability Score': 'Gesundheits- und Behinderungsscore (höher = bessere Ergebnisse)',
'Income Score (rate)':
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Employment Score (rate)':
'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Health Deprivation and Disability Score':
'Gesundheits- und Behinderungsscore (höher = bessere Ergebnisse)',
'Living Environment Score': 'Qualität der Innen- und Außenumgebung (höher = besser)',
'Indoors Sub-domain Score': 'Wohnqualität und -zustand (höher = besser)',
'Outdoors Sub-domain Score': 'Luftqualität und Verkehrssicherheit (höher = besser)',
'Serious crime per 1k residents (avg/yr)': 'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr',
'Minor crime per 1k residents (avg/yr)': 'Rate leichter Straftaten pro 1.000 Einwohner pro Jahr',
'Serious crime per 1k residents (avg/yr)':
'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr',
'Minor crime per 1k residents (avg/yr)':
'Rate leichter Straftaten pro 1.000 Einwohner pro Jahr',
'Serious crime (avg/yr)': 'Summe der schweren Straftaten-Kategorien pro Jahr',
'Minor crime (avg/yr)': 'Summe der leichten Straftaten-Kategorien pro Jahr',
'Violence and sexual offences (avg/yr)': 'Jährlicher Durchschnitt der Gewalt- und Sexualdelikte in der Gegend',
'Violence and sexual offences (avg/yr)':
'Jährlicher Durchschnitt der Gewalt- und Sexualdelikte in der Gegend',
'Burglary (avg/yr)': 'Jährlicher Durchschnitt der Einbrüche in der Gegend',
'Robbery (avg/yr)': 'Jährlicher Durchschnitt der Raubüberfälle in der Gegend',
'Vehicle crime (avg/yr)': 'Jährlicher Durchschnitt der Fahrzeugkriminalität in der Gegend',
'Anti-social behaviour (avg/yr)': 'Jährlicher Durchschnitt des antisozialen Verhaltens in der Gegend',
'Criminal damage and arson (avg/yr)': 'Jährlicher Durchschnitt der Sachbeschädigungen und Brandstiftungen in der Gegend',
'Anti-social behaviour (avg/yr)':
'Jährlicher Durchschnitt des antisozialen Verhaltens in der Gegend',
'Criminal damage and arson (avg/yr)':
'Jährlicher Durchschnitt der Sachbeschädigungen und Brandstiftungen in der Gegend',
'Other theft (avg/yr)': 'Jährlicher Durchschnitt des sonstigen Diebstahls in der Gegend',
'Theft from the person (avg/yr)': 'Jährlicher Durchschnitt des Taschendiebstahls in der Gegend',
'Shoplifting (avg/yr)': 'Jährlicher Durchschnitt des Ladendiebstahls in der Gegend',
'Bicycle theft (avg/yr)': 'Jährlicher Durchschnitt des Fahrraddiebstahls in der Gegend',
'Drugs (avg/yr)': 'Jährlicher Durchschnitt der Drogendelikte in der Gegend',
'Possession of weapons (avg/yr)': 'Jährlicher Durchschnitt der Waffenbesitzdelikte in der Gegend',
'Public order (avg/yr)': 'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
'Possession of weapons (avg/yr)':
'Jährlicher Durchschnitt der Waffenbesitzdelikte in der Gegend',
'Public order (avg/yr)':
'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',
'% White': 'Anteil der Bevölkerung, der sich als Weiß identifiziert',
'% South Asian': 'Anteil der Bevölkerung, der sich als Südasiatisch identifiziert',
'% Black': 'Anteil der Bevölkerung, der sich als Schwarz identifiziert',
'% East Asian': 'Anteil der Bevölkerung, der sich als Ostasiatisch identifiziert',
'% Mixed': 'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
'% Mixed':
'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
'% Other': 'Anteil der Bevölkerung, der sich einer anderen ethnischen Gruppe zuordnet',
'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche',
'Number of parks within 2km': 'Anzahl Parks und Grünflächen im Umkreis von 2 km',
'Number of restaurants within 2km': 'Anzahl Restaurants und Cafés im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km':
'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
'Noise (dB)': 'Straßenlärmpegel an der Postleitzahl in Dezibel (Lden)',
'Max available download speed (Mbps)': 'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
'Max available download speed (Mbps)':
'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
},
zh: {
'Listing status': '该房产是历史销售、当前在售还是出租',
@ -159,8 +195,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Asking rent (monthly)': '当前出租房产的挂牌月租',
'Total floor area (sqm)': 'EPC评估的室内建筑面积',
'Number of bedrooms & living rooms': 'EPC评估的宜居房间数',
'Bedrooms': '在线房源中的卧室数量',
'Bathrooms': '在线房源中的浴室数量',
Bedrooms: '在线房源中的卧室数量',
Bathrooms: '在线房源中的浴室数量',
'Construction year': 'EPC评估的建造年份',
'Date of last transaction': 'Land Registry记录的最近一次销售日期',
'Listing date': '房产首次在线上市的日期',
@ -226,24 +262,33 @@ const descriptions: Record<string, Record<string, string>> = {
'Asking rent (monthly)': 'A kiadó ingatlanok hirdetett havi bérleti díja',
'Total floor area (sqm)': 'Az EPC felmérésből származó belső alapterület',
'Number of bedrooms & living rooms': 'Lakószobák száma az EPC felmérés alapján',
'Bedrooms': 'Hálószobák száma az online hirdetés szerint',
'Bathrooms': 'Fürdőszobák száma az online hirdetés szerint',
Bedrooms: 'Hálószobák száma az online hirdetés szerint',
Bathrooms: 'Fürdőszobák száma az online hirdetés szerint',
'Construction year': 'Becsült építési év az EPC alapján',
'Date of last transaction': 'Az utolsó eladás dátuma a Land Registry szerint',
'Listing date': 'Az ingatlan első online megjelenésének dátuma',
'Former council house': 'Az ingatlan szerepelt-e valaha önkormányzati lakásként',
'Current energy rating': 'Jelenlegi EPC energiabesorolás (A = legjobb, G = legrosszabb)',
'Potential energy rating': 'Potenciális EPC besorolás az összes javasolt fejlesztés elvégzése után',
'Potential energy rating':
'Potenciális EPC besorolás az összes javasolt fejlesztés elvégzése után',
'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
'Distance to nearest train or tube station (km)': 'Távolság a legközelebbi vasút- vagy metróállomásig',
'Good+ primary schools within 2km': 'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
'Good+ secondary schools within 2km': 'Ofsted által Jó vagy Kiváló minősítésű középiskolák 2 km-en belül',
'Good+ primary schools within 5km': 'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km': 'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
'Education, Skills and Training Score': 'A környék oktatási minőségi pontszáma (magasabb = jobb)',
'Distance to nearest train or tube station (km)':
'Távolság a legközelebbi vasút- vagy metróállomásig',
'Good+ primary schools within 2km':
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
'Good+ secondary schools within 2km':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 2 km-en belül',
'Good+ primary schools within 5km':
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
'Education, Skills and Training Score':
'A környék oktatási minőségi pontszáma (magasabb = jobb)',
'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Employment Score (rate)': 'Foglalkoztatási deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Health Deprivation and Disability Score': 'Egészségügyi és fogyatékossági pontszám (magasabb = jobb eredmények)',
'Employment Score (rate)':
'Foglalkoztatási deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Health Deprivation and Disability Score':
'Egészségügyi és fogyatékossági pontszám (magasabb = jobb eredmények)',
'Living Environment Score': 'Belső és külső környezet minősége (magasabb = jobb)',
'Indoors Sub-domain Score': 'Lakásminőség és állapot (magasabb = jobb)',
'Outdoors Sub-domain Score': 'Levegőminőség és közlekedésbiztonság (magasabb = jobb)',
@ -251,7 +296,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Minor crime per 1k residents (avg/yr)': 'Kisebb bűncselekmények aránya 1000 lakosra évente',
'Serious crime (avg/yr)': 'Súlyos bűncselekményi kategóriák éves összesítése',
'Minor crime (avg/yr)': 'Kisebb bűncselekményi kategóriák éves összesítése',
'Violence and sexual offences (avg/yr)': 'Erőszakos és szexuális bűncselekmények éves átlaga a környéken',
'Violence and sexual offences (avg/yr)':
'Erőszakos és szexuális bűncselekmények éves átlaga a környéken',
'Burglary (avg/yr)': 'Betörések éves átlaga a környéken',
'Robbery (avg/yr)': 'Rablások éves átlaga a környéken',
'Vehicle crime (avg/yr)': 'Gépjárművel kapcsolatos bűncselekmények éves átlaga a környéken',
@ -262,7 +308,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Shoplifting (avg/yr)': 'Bolti lopások éves átlaga a környéken',
'Bicycle theft (avg/yr)': 'Kerékpárlopások éves átlaga a környéken',
'Drugs (avg/yr)': 'Kábítószerrel kapcsolatos bűncselekmények éves átlaga a környéken',
'Possession of weapons (avg/yr)': 'Fegyvertartással kapcsolatos bűncselekmények éves átlaga a környéken',
'Possession of weapons (avg/yr)':
'Fegyvertartással kapcsolatos bűncselekmények éves átlaga a környéken',
'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',
@ -275,9 +322,11 @@ const descriptions: Record<string, Record<string, string>> = {
'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig',
'Number of parks within 2km': 'Parkok és zöldterületek száma 2 km-en belül',
'Number of restaurants within 2km': 'Éttermek és kávézók száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km': 'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km':
'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Noise (dB)': 'Közúti zajszint az irányítószámnál decibelben (Lden)',
'Max available download speed (Mbps)': 'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
'Max available download speed (Mbps)':
'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
},
};
@ -299,5 +348,5 @@ export function tsDesc(featureName: string, englishFromServer: string): string {
export function tsDetail(featureName: string, englishFromServer: string): string {
const lang = i18n.language;
if (lang === 'en') return englishFromServer;
return details[lang]?.[featureName] ?? englishFromServer;
return descriptions[lang]?.[featureName] ?? englishFromServer;
}

View file

@ -0,0 +1,530 @@
/**
* Feature detail translations (the longer explanatory paragraph in the info card).
* Same structure as descriptions: keyed by language, then by feature name.
* English details come from the server NOT duplicated here.
*/
export const details: Record<string, Record<string, string>> = {
fr: {
'Listing status':
"Indique la source de l'enregistrement de la propriété : « Vente historique » provenant des données HM Land Registry Price Paid, « À vendre » provenant des annonces d'achat en ligne actuelles, ou « À louer » provenant des annonces de location en ligne actuelles.",
'Property type':
'Provient des données HM Land Registry Price Paid et des certificats EPC. Individuelle, Semi-individuelle, Mitoyenne (inclut tous les sous-types de maisons en rangée), Appartements/Maisons duplex, ou Autre (bungalows, mobil-homes, etc.).',
'Leasehold/Freehold':
"Provient des données HM Land Registry Price Paid. Freehold signifie que vous êtes propriétaire du bâtiment et du terrain sur lequel il se trouve. Leasehold signifie que vous êtes propriétaire du bâtiment mais pas du terrain : vous disposez d'un bail accordé par le propriétaire du terrain pour un nombre d'années déterminé.",
'Last known price':
"Le dernier prix de vente enregistré pour ce bien provenant des données HM Land Registry Price Paid. Couvre les ventes résidentielles en Angleterre. Peut dater de plusieurs années si le bien n'a pas été vendu récemment.",
'Estimated current price':
"Basé sur le dernier prix de vente, ajusté en fonction des évolutions locales des prix au fil du temps à l'aide d'un indice de ventes répétées (suivi par secteur de code postal et type de bien). Si des améliorations postérieures à la vente sont détectées d'après les relevés EPC, une prime de rénovation est ajoutée. Les ventes récentes seront proches du prix d'origine ; les ventes plus anciennes font l'objet d'un ajustement plus important.",
'Asking price':
"Le prix demandé tel qu'annoncé sur les portails immobiliers en ligne. Disponible uniquement pour les annonces « À vendre ».",
'Price per sqm':
'Calculé en divisant le dernier prix de vente connu par la surface habitable totale indiquée dans le certificat EPC. Utile pour comparer la valeur entre des biens de tailles différentes. Disponible uniquement lorsque les données de prix et de surface existent toutes les deux.',
'Est. price per sqm':
"Calculé en divisant le prix actuel estimé et ajusté à l'inflation (y compris toute prime de rénovation) par la surface habitable totale indiquée dans le certificat EPC. Fournit une comparaison prix/superficie plus actualisée que le prix au sqm basé sur le prix de vente historique.",
'Asking price per sqm':
'Calculé en divisant le prix demandé affiché par la surface habitable totale. Disponible uniquement pour les biens actuellement mis en vente pour lesquels les données de surface existent.',
'Estimated monthly rent':
"Prix médian mensuel de location provenant des statistiques sommaires du marché locatif privé de l'ONS (octobre 2022 - septembre 2023), correspondant à l'autorité locale et au nombre de chambres. Basé sur les données de locations de l'Agence d'évaluation (Valuation Office Agency).",
'Asking rent (monthly)':
'Le prix de location annoncé sur les portails immobiliers en ligne, converti en montant mensuel si nécessaire (par exemple, annonces hebdomadaires ou annuelles). Disponible uniquement pour les annonces « À louer ».',
'Total floor area (sqm)':
"Surface habitable totale en mètres carrés telle que mesurée lors de l'évaluation du certificat de performance énergétique (EPC). Inclut toutes les pièces habitables mais exclut les garages, dépendances et espaces extérieurs.",
'Number of bedrooms & living rooms':
"Nombre total de pièces habitables (chambres et salons) tel qu'enregistré dans le certificat de performance énergétique (EPC). Les cuisines et salles de bain sont généralement exclues, sauf si elles sont suffisamment grandes pour être comptées comme pièces habitables.",
Bedrooms:
"Nombre de chambres tel qu'annoncé dans l'annonce immobilière en ligne. Renseigné uniquement pour les annonces en ligne (vente et location) ; nul pour les ventes historiques.",
Bathrooms:
"Nombre de salles de bain tel qu'annoncé dans l'annonce immobilière en ligne. Renseigné uniquement pour les annonces en ligne (vente et location) ; nul pour les ventes historiques.",
'Construction year':
"Dérivé de la tranche d'âge de construction indiquée dans l'EPC (par exemple « 1930-1949 ») en prenant le point médian. Moins précis pour les bâtiments anciens où la tranche d'âge s'étend sur plusieurs décennies.",
'Date of last transaction':
'La date de la vente enregistrée la plus récente pour ce bien, provenant des données HM Land Registry Price Paid. Stockée sous forme de date/heure dans les données ; convertie en année fractionnaire pour le filtrage et les graphiques.',
'Listing date':
"La date à laquelle l'annonce immobilière est apparue pour la première fois sur le portail immobilier en ligne. Stockée sous forme de date/heure ; convertie en année fractionnaire pour le filtrage. Renseignée uniquement pour les annonces en ligne.",
'Former council house':
"Dérivé du champ TENURE dans les données du certificat de performance énergétique (EPC). Si l'un des certificats EPC pour ce bien a enregistré le régime d'occupation comme location sociale, cela indique que le bien faisait partie du parc de logements du conseil municipal ou d'une association de logement au moment de cette inspection. Les biens qui ont été vendus ultérieurement (par exemple via le Right to Buy) conservent cet indicateur.",
'Current energy rating':
"La note d'efficacité énergétique actuelle issue du certificat de performance énergétique (EPC). Va de A (plus efficace) à G (moins efficace). Basée sur la consommation d'énergie du bien par mètre carré de surface habitable.",
'Potential energy rating':
"La note d'efficacité énergétique potentielle issue du certificat de performance énergétique (EPC), si toutes les améliorations rentables recommandées dans le rapport EPC étaient réalisées. Va de A (plus efficace) à G (moins efficace).",
'Interior height (m)':
"Hauteur intérieure moyenne (sol au plafond) en mètres telle qu'enregistrée lors de l'évaluation du certificat de performance énergétique (EPC). Calculée en divisant le volume intérieur total par la surface habitable totale.",
'Distance to nearest train or tube station (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à la gare ferroviaire ou la station de métro/tram la plus proche.",
'Good+ primary schools within 2km':
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Good+ secondary schools within 2km':
"Lycées et collèges financés par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Good+ primary schools within 5km':
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Good+ secondary schools within 5km':
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Education, Skills and Training Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise. Des scores plus élevés indiquent moins de déprivation.",
'Income Score (rate)':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des valeurs plus élevées indiquent moins de déprivation de revenus. Basé sur les allocations de soutien au revenu, l'allocation de demandeur d'emploi sous condition de ressources, l'allocation d'emploi et de soutien sous condition de ressources, le crédit de retraite, le crédit d'impôt pour le travail et les enfants, l'Universal Credit et les demandeurs d'asile.",
'Employment Score (rate)':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des valeurs plus élevées indiquent moins de déprivation d'emploi. Basé sur les allocataires de l'allocation de demandeur d'emploi, de l'allocation d'emploi et de soutien, de l'allocation d'incapacité, de l'allocation de handicap sévère, de l'allocation d'aidant et les bénéficiaires pertinents de l'Universal Credit.",
'Health Deprivation and Disability Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des scores plus élevés indiquent un risque de décès prématuré plus faible et une meilleure qualité de vie. Dérivé des années de vie potentielle perdues, du ratio comparatif de maladie et d'invalidité, de la morbidité aiguë et des troubles de l'humeur et d'anxiété.",
'Living Environment Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Combine la qualité du logement (état, chauffage central) et l'environnement extérieur (qualité de l'air, sécurité routière). Des scores plus élevés indiquent de meilleurs environnements de vie.",
'Indoors Sub-domain Score':
'Provient des Indices de Déprivation anglais, domaine Environnement de Vie (inversé afin que plus le score est élevé, meilleur est le résultat). Mesure la qualité du parc immobilier : disponibilité du chauffage central, état des logements et normes Decent Homes. Des scores plus élevés indiquent de meilleures conditions de logement.',
'Outdoors Sub-domain Score':
"Provient des Indices de Déprivation anglais, domaine Environnement de Vie (inversé afin que plus le score est élevé, meilleur est le résultat). Mesure la qualité de l'environnement de vie extérieur à travers des indicateurs de qualité de l'air et les victimes d'accidents de la route impliquant des piétons et des cyclistes. Des scores plus élevés indiquent de meilleurs environnements extérieurs.",
'Serious crime per 1k residents (avg/yr)':
"Violences, braquages, cambriolages et possession d'armes pour 1 000 résidents habituels par an dans le LSOA. Utilise les données de criminalité au niveau de la rue de police.uk (2023-2025) et les décomptes de population du Census 2021. Normalise en fonction de la densité de population afin que les zones soient comparables quelle que soit leur taille.",
'Minor crime per 1k residents (avg/yr)':
"Comportements antisociaux, vols à l'étalage, vols de vélos et autres crimes de moindre gravité pour 1 000 résidents habituels par an dans le LSOA. Utilise les données de criminalité au niveau de la rue de police.uk (2023-2025) et les décomptes de population du Census 2021. Normalise en fonction de la densité de population afin que les zones soient comparables quelle que soit leur taille.",
'Serious crime (avg/yr)':
"Somme des violences, braquages, cambriolages et possessions d'armes par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Fournit un indicateur unique de criminalité grave.",
'Minor crime (avg/yr)':
"Somme des comportements antisociaux, vols à l'étalage, vols de vélos et autres crimes de moindre gravité par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Fournit un indicateur unique de criminalité mineure.",
'Violence and sexual offences (avg/yr)':
'Nombre moyen de violences et infractions sexuelles par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les agressions, le harcèlement et les infractions sexuelles.',
'Burglary (avg/yr)':
'Nombre moyen de cambriolages par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les cambriolages résidentiels et commerciaux.',
'Robbery (avg/yr)':
'Nombre moyen de braquages par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Le braquage implique un vol avec usage ou menace de la force.',
'Vehicle crime (avg/yr)':
"Nombre moyen d'incidents de criminalité liés aux véhicules par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut le vol de véhicules et les vols à l'intérieur des véhicules.",
'Anti-social behaviour (avg/yr)':
"Nombre moyen d'incidents de comportement antisocial par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les nuisances, les comportements antisociaux environnementaux et personnels.",
'Criminal damage and arson (avg/yr)':
"Nombre moyen d'incidents de dommages criminels et d'incendie volontaire par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025).",
'Other theft (avg/yr)':
"Nombre moyen d'infractions de « vol divers » par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les vols ne relevant pas des catégories cambriolage, criminalité liée aux véhicules, vol à l'étalage ou vol de vélos.",
'Theft from the person (avg/yr)':
"Nombre moyen d'infractions de vol à la tire par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut le pickpocket et l'arrachage de sac sans violence.",
'Shoplifting (avg/yr)':
"Nombre moyen d'infractions de vol à l'étalage par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025).",
'Bicycle theft (avg/yr)':
"Nombre moyen d'infractions de vol de vélos par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025).",
'Drugs (avg/yr)':
"Nombre moyen d'infractions liées aux drogues par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les infractions de possession et de trafic.",
'Possession of weapons (avg/yr)':
"Nombre moyen d'infractions de possession d'armes par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025).",
'Public order (avg/yr)':
"Nombre moyen d'infractions à l'ordre public par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les actes causant de la peur, de l'alarme ou de la détresse.",
'Other crime (avg/yr)':
"Nombre moyen d'autres infractions criminelles par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Catégorie fourre-tout 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.",
'% White':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).",
'% South Asian':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Indien, Pakistanais, Bangladais ou toute autre origine asiatique.",
'% Black':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Noir, Noir britannique, Caribéen ou Africain.",
'% East Asian':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Chinois.",
'% Mixed':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Mixte ou appartenant à plusieurs groupes ethniques (Blanc et Noir caribéen, Blanc et Noir africain, Blanc et Asiatique, ou tout autre fond mixte ou multiple).",
'% Other':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme appartenant à un autre groupe ethnique (Arabe ou tout autre groupe ethnique non couvert par les catégories principales).",
'Distance to nearest park (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
'Number of parks within 2km':
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 2km du centroïde du code postal de la propri<72><69>té. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
'Number of restaurants within 2km':
'Restaurants, cafés et établissements de restauration dans un rayon de 2km du code postal. Source : OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
"Nombre de supermarchés, épiceries et autres commerces alimentaires dans un rayon de 2km du centroïde du code postal de la propriété. Dérivé des données POI d'OpenStreetMap.",
'Noise (dB)':
"Niveau de bruit routier en décibels (Lden, moyenne pondérée sur 24 heures) provenant de la cartographie stratégique du bruit de Defra, 4e cycle (2022). Modélisé à 4m au-dessus du sol sur une grille de 10m. Au-dessus d'environ 55 dB, le bruit est généralement perceptible ; au-dessus d'environ 70 dB, il est considéré comme nocif par l'OMS.",
'Max available download speed (Mbps)':
"Vitesse de téléchargement fixe maximale disponible auprès de n'importe quel fournisseur, provenant d'Ofcom Connected Nations 2025. Représente le maximum théorique, et non les vitesses réellement atteintes. 10 Mbps = basique, 30 = superrapide, 100+ = ultra-rapide, 1000 = gigabit.",
},
de: {
'Listing status':
'Gibt die Quelle des Immobilieneintrags an: „Historical sale" aus den HM Land Registry Price Paid-Daten, „For sale" aus aktuellen Online-Kaufangeboten oder „For rent" aus aktuellen Online-Mietangeboten.',
'Property type':
'Aus den HM Land Registry Price Paid-Daten und EPC-Zertifikaten. Freistehend, Doppelhaushälfte, Reihenhaus (umfasst alle Untertypen), Wohnungen/Maisonettes oder Sonstiges (Bungalows, Mobilheime usw.).',
'Leasehold/Freehold':
'Aus den HM Land Registry Price Paid-Daten. Freehold bedeutet, dass Sie das Gebäude und das Grundstück besitzen. Leasehold bedeutet, dass Sie das Gebäude, aber nicht das Grundstück besitzen: Sie haben einen Pachtvertrag vom Freeholder für eine festgelegte Anzahl von Jahren.',
'Last known price':
'Der zuletzt erfasste Verkaufspreis für diese Immobilie aus den HM Land Registry Price Paid-Daten. Umfasst Wohnimmobilienverkäufe in England. Kann Jahre alt sein, wenn die Immobilie nicht kürzlich verkauft wurde.',
'Estimated current price':
'Basiert auf dem letzten Verkaufspreis, angepasst an lokale Preisveränderungen im Laufe der Zeit mithilfe eines Repeat-Sales-Index (erfasst pro Postleitzahlensektor und Immobilientyp). Wenn nach dem Verkauf durchgeführte Renovierungen aus EPC-Aufzeichnungen erkennbar sind, wird ein Renovierungsaufschlag hinzugefügt. Kürzliche Verkäufe liegen nahe am ursprünglichen Preis; ältere Verkäufe werden stärker angepasst.',
'Asking price':
'Der beworbene Angebotspreis aus Online-Immobilienportalen. Nur für „For sale"-Angebote verfügbar.',
'Price per sqm':
'Berechnet durch Division des zuletzt bekannten Verkaufspreises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Nützlich zum Vergleich des Wertes verschiedener Immobiliengrößen. Nur verfügbar, wenn sowohl Preis- als auch Flächendaten vorhanden sind.',
'Est. price per sqm':
'Berechnet durch Division des inflationsbereinigten geschätzten aktuellen Preises (einschließlich etwaiger Renovierungsaufschläge) durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Bietet einen aktuelleren Preis-pro-Fläche-Vergleich als der historische Verkaufspreis pro sqm.',
'Asking price per sqm':
'Berechnet durch Division des angebotenen Kaufpreises durch die Gesamtnutzfläche. Nur für zum Verkauf angebotene Immobilien verfügbar, für die Flächendaten vorhanden sind.',
'Estimated monthly rent':
'Monatlicher Median-Mietpreis aus den ONS Private Rental Market Summary Statistics (Okt. 2022 Sep. 2023), abgeglichen nach Gemeinde und Zimmeranzahl. Basiert auf Vermietungsdaten der Valuation Office Agency.',
'Asking rent (monthly)':
'Der beworbene Mietpreis aus Online-Immobilienportalen, bei Bedarf in einen monatlichen Betrag umgerechnet (z. B. bei wöchentlichen oder jährlichen Angeboten). Nur für „For rent"-Angebote verfügbar.',
'Total floor area (sqm)':
'Gesamte nutzbare Wohnfläche in Quadratmetern, gemessen während der Bewertung für das Energieausweis-Zertifikat. Umfasst alle Wohnräume, schließt jedoch Garagen, Nebengebäude und Außenbereiche aus.',
'Number of bedrooms & living rooms':
'Gesamtanzahl der Wohnräume (Schlaf- und Wohnzimmer), wie im Energieausweis-Zertifikat erfasst. Küchen und Badezimmer sind in der Regel ausgeschlossen, sofern sie nicht groß genug sind, um als Wohnräume zu gelten.',
Bedrooms:
'Anzahl der Schlafzimmer, wie im Online-Immobilienangebot angegeben. Nur für Online-Angebote (Kauf und Miete) verfügbar; bei historischen Verkäufen nicht angegeben.',
Bathrooms:
'Anzahl der Badezimmer, wie im Online-Immobilienangebot angegeben. Nur für Online-Angebote (Kauf und Miete) verfügbar; bei historischen Verkäufen nicht angegeben.',
'Construction year':
'Abgeleitet aus dem Baualtersband im EPC (z. B. „19301949") durch Verwendung des Mittelpunkts. Bei älteren Gebäuden, bei denen das Altersband mehrere Jahrzehnte umfasst, weniger präzise.',
'Date of last transaction':
'Das Datum des zuletzt erfassten Verkaufs dieser Immobilie aus den HM Land Registry Price Paid-Daten. In den Daten als Datum-/Uhrzeitangabe gespeichert; für Filterung und Diagramme in ein Dezimaljahr umgerechnet.',
'Listing date':
'Das Datum, an dem das Immobilienangebot erstmals auf dem Online-Immobilienportal erschien. Als Datum-/Uhrzeitangabe gespeichert; für die Filterung in ein Dezimaljahr umgerechnet. Nur für Online-Angebote verfügbar.',
'Former council house':
'Abgeleitet aus dem TENURE-Feld in den Energieausweis-Daten. Wenn für diese Immobilie ein EPC-Zertifikat das Nutzungsverhältnis als Sozialmiete erfasste, deutet dies darauf hin, dass die Immobilie zum Zeitpunkt dieser Inspektion Gemeinde- oder Wohnungsbaugesellschaftsbestand war. Immobilien, die später verkauft wurden (z. B. über Right to Buy), behalten dieses Merkmal.',
'Current energy rating':
'Die aktuelle Energieeffizienzklasse aus dem Energieausweis-Zertifikat. Reicht von A (am effizientesten) bis G (am wenigsten effizient). Basiert auf dem Energieverbrauch der Immobilie pro Quadratmeter Wohnfläche.',
'Potential energy rating':
'Die potenzielle Energieeffizienzklasse aus dem Energieausweis-Zertifikat, wenn alle im EPC-Bericht empfohlenen kosteneffizienten Verbesserungen durchgeführt würden. Reicht von A (am effizientesten) bis G (am wenigsten effizient).',
'Interior height (m)':
'Durchschnittliche lichte Raumhöhe in Metern, wie während der Energieausweis-Begutachtung erfasst. Berechnet durch Division des gesamten Innenvolumens durch die Gesamtwohnfläche.',
'Distance to nearest train or tube station (km)':
'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zur nächsten Bahnstation oder U-Bahn-/Metro-/Straßenbahnhaltestelle.',
'Good+ primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 2km':
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ primary schools within 5km':
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Education, Skills and Training Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse. Höhere Werte weisen auf geringere Benachteiligung hin.',
'Income Score (rate)':
"Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf geringere Einkommensbenachteiligung hin. Basiert auf Income Support, einkommensbasiertem Jobseeker's Allowance, einkommensbasiertem Employment and Support Allowance, Pension Credit, Working Tax Credit und Child Tax Credit, Universal Credit sowie Asylbewerbern.",
'Employment Score (rate)':
"Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf geringere Beschäftigungsbenachteiligung hin. Basiert auf Empfängern von Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance und relevanten Universal Credit-Empfängern.",
'Health Deprivation and Disability Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf ein geringeres Risiko eines vorzeitigen Todes und eine bessere Lebensqualität hin. Abgeleitet aus verlorenen Lebensjahren, vergleichender Krankheits- und Behinderungsquote, akuter Morbidität sowie Stimmungs- und Angststörungen.',
'Living Environment Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Kombiniert Wohnqualität (Zustand, Zentralheizung) und Außenumgebung (Luftqualität, Verkehrssicherheit). Höhere Werte weisen auf bessere Wohnumgebungen hin.',
'Indoors Sub-domain Score':
'Aus den englischen Deprivationsindizes, Bereich Wohnumgebung (invertiert, sodass höher = besser bedeutet). Misst die Qualität des Wohnungsbestands: Verfügbarkeit von Zentralheizung, Wohnungszustand und Decent Homes-Standards. Höhere Werte weisen auf bessere Wohnbedingungen hin.',
'Outdoors Sub-domain Score':
'Aus den englischen Deprivationsindizes, Bereich Wohnumgebung (invertiert, sodass höher = besser bedeutet). Misst die Qualität der Außenwohnumgebung anhand von Luftqualitätsindikatoren und Straßenverkehrsunfällen mit Fußgängern und Radfahrern. Höhere Werte weisen auf bessere Außenumgebungen hin.',
'Serious crime per 1k residents (avg/yr)':
'Gewalt, Raub, Einbruch und Waffenbesitz pro 1.000 Einwohner pro Jahr im LSOA. Verwendet police.uk-Kriminalitätsdaten auf Straßenebene (20232025) und Census 2021-Bevölkerungszahlen. Normalisiert nach Bevölkerungsdichte, sodass Gebiete unabhängig von ihrer Größe vergleichbar sind.',
'Minor crime per 1k residents (avg/yr)':
'Asoziales Verhalten, Ladendiebstahl, Fahrraddiebstahl und andere weniger schwere Straftaten pro 1.000 Einwohner pro Jahr im LSOA. Verwendet police.uk-Kriminalitätsdaten auf Straßenebene (20232025) und Census 2021-Bevölkerungszahlen. Normalisiert nach Bevölkerungsdichte, sodass Gebiete unabhängig von ihrer Größe vergleichbar sind.',
'Serious crime (avg/yr)':
'Summe aus Gewalt, Raub, Einbruch und Waffenbesitz pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Bietet einen einzelnen Indikator für schwere Kriminalität.',
'Minor crime (avg/yr)':
'Summe aus asozialem Verhalten, Ladendiebstahl, Fahrraddiebstahl und anderen weniger schweren Straftaten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Bietet einen einzelnen Indikator für leichte Kriminalität.',
'Violence and sexual offences (avg/yr)':
'Durchschnittliche Anzahl von Gewalt- und Sexualdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Körperverletzung, Belästigung und Sexualdelikte.',
'Burglary (avg/yr)':
'Durchschnittliche Anzahl von Einbruchsdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Wohnungs- und Gewerbeeinbrüche.',
'Robbery (avg/yr)':
'Durchschnittliche Anzahl von Raubdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Raub umfasst Diebstahl unter Anwendung von Gewalt oder Gewaltandrohung.',
'Vehicle crime (avg/yr)':
'Durchschnittliche Anzahl von Fahrzeugkriminalität pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Diebstahl von und aus Fahrzeugen.',
'Anti-social behaviour (avg/yr)':
'Durchschnittliche Anzahl von Vorfällen asozialen Verhaltens pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst störendes, umweltbezogenes und persönlich asoziales Verhalten.',
'Criminal damage and arson (avg/yr)':
'Durchschnittliche Anzahl von Sachbeschädigungs- und Brandstiftungsvorfällen pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025).',
'Other theft (avg/yr)':
'Durchschnittliche Anzahl von „sonstigen Diebstählen" pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Diebstähle, die nicht unter Einbruch, Fahrzeugkriminalität, Ladendiebstahl oder Fahrraddiebstahl eingestuft sind.',
'Theft from the person (avg/yr)':
'Durchschnittliche Anzahl von Taschendiebstählen und ähnlichen Delikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Taschendiebstahl und Handtaschenraub ohne Gewaltanwendung.',
'Shoplifting (avg/yr)':
'Durchschnittliche Anzahl von Ladendiebstahlsdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025).',
'Bicycle theft (avg/yr)':
'Durchschnittliche Anzahl von Fahrraddiebstahlsdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025).',
'Drugs (avg/yr)':
'Durchschnittliche Anzahl von Drogendelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Besitz- und Handelsdelikte.',
'Possession of weapons (avg/yr)':
'Durchschnittliche Anzahl von Waffenbesitzdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025).',
'Public order (avg/yr)':
'Durchschnittliche Anzahl von Delikten gegen die öffentliche Ordnung pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst das Verursachen von Furcht, Alarm oder Bedrängnis.',
'Other crime (avg/yr)':
'Durchschnittliche Anzahl sonstiger Straftaten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). 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.',
'% White':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).',
'% South Asian':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Indisch, Pakistanisch, Bangladeschisch oder mit sonstigem asiatischen Hintergrund identifiziert.',
'% Black':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Schwarz, Schwarz-Britisch, Karibisch oder Afrikanisch identifiziert.',
'% East Asian':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Chinesisch identifiziert.',
'% Mixed':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als gemischt oder mit mehreren ethnischen Zugehörigkeiten identifiziert (Weiß und Schwarzkaribisch, Weiß und Schwarzafrikanisch, Weiß und Asiatisch oder sonstiger gemischter Hintergrund).',
'% Other':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als einer anderen ethnischen Gruppe zugehörig identifiziert (Arabisch oder eine andere ethnische Gruppe, die nicht von den Hauptkategorien abgedeckt wird).',
'Distance to nearest park (km)':
'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zum nächsten Parkeingang. Umfasst öffentliche Parks, Gärten, Sportplätze und Spielbereiche. Verwendet Zugangspunktstandorte aus dem OS Open Greenspace-Datensatz, sodass Immobilien an der Grenze eines großen Parks korrekt eine kurze Entfernung anzeigen.',
'Number of parks within 2km':
'Anzahl öffentlicher Parks, Gärten, Sportplätze und Spielbereiche mit mindestens einem Eingang innerhalb eines 2-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus dem OS Open Greenspace-Datensatz (Ordnance Survey) unter Verwendung von Parkeingangsstandorten für genaues Abstandsmatching.',
'Number of restaurants within 2km':
'Restaurants, Cafés und Gastronomiebetriebe innerhalb von 2 km vom Postleitzahlenzentrum. Bezogen aus OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Anzahl von Supermärkten, Lebensmittelläden und anderen Lebensmittelgeschäften innerhalb eines 2-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus OpenStreetMap-POI-Daten.',
'Noise (dB)':
'Straßenlärmpegel in Dezibel (Lden, ein 24-Stunden-gewichteter Durchschnitt) aus Defras Strategic Noise Mapping Round 4 (2022). Modelliert in 4 m Höhe über dem Boden auf einem 10-m-Raster. Über ~55 dB ist in der Regel wahrnehmbar; über ~70 dB gilt laut WHO als gesundheitsschädlich.',
'Max available download speed (Mbps)':
'Maximale verfügbare Festnetz-Download-Geschwindigkeit von einem beliebigen Anbieter, aus Ofcom Connected Nations 2025. Gibt die theoretische Höchstgeschwindigkeit an, keine tatsächlich erreichten Geschwindigkeiten. 10 Mbps = Basis, 30 = Superfast, 100+ = Ultrafast, 1000 = Gigabit.',
},
zh: {
'Listing status':
'表示房产记录的来源:"Historical sale"来自英国土地注册局价格数据,"For sale"来自当前在线买卖房源,"For rent"来自当前在线租赁房源。',
'Property type':
'来自英国土地注册局价格数据和EPC证书。包括独立式、半独立式、联排式含所有联排子类型、公寓/复式公寓,或其他类型(平房、移动式住宅等)。',
'Leasehold/Freehold':
'来自英国土地注册局价格数据。Freehold永久产权意味着您拥有建筑物及其所在土地。Leasehold租赁产权意味着您拥有建筑物但不拥有土地您从永久产权人处获得一定年限的租约。',
'Last known price':
'来自英国土地注册局价格数据中该房产最近一次记录的成交价格。涵盖英格兰地区的住宅销售。若该房产近期未出售,数据可能已有数年之久。',
'Estimated current price':
'基于最后一次成交价格使用重复销售指数按邮政编码区段和房产类型追踪调整当地房价随时间的变化。若EPC记录显示售后有改造记录则会增加装修溢价。近期销售与原价接近较早的销售调整幅度更大。',
'Asking price': '来自在线房产平台的挂牌要价。仅适用于"For sale"房源。',
'Price per sqm':
'用最后已知成交价除以EPC证书中的总建筑面积计算得出。便于比较不同面积房产的价值。仅在价格和面积数据均存在时才可用。',
'Est. price per sqm':
'用经通胀调整的估算当前价格含装修溢价除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比提供更为最新的单位面积价格对比。',
'Asking price per sqm':
'用挂牌要价除以总建筑面积计算得出。仅适用于当前在售且存在面积数据的房产。',
'Estimated monthly rent':
'来自ONS私人租赁市场摘要统计2022年10月至2023年9月的月租金中位数按地方政府和卧室数量匹配。基于估价署租赁数据。',
'Asking rent (monthly)':
'来自在线房产平台的挂牌租金,如有需要会换算为月租金(例如按周或按年计价的房源)。仅适用于"For rent"房源。',
'Total floor area (sqm)':
'在能源性能证书EPC评估期间测量的总可用建筑面积平方米。包括所有可居住房间但不含车库、附属建筑和外部区域。',
'Number of bedrooms & living rooms':
'EPC中记录的可居住房间总数卧室加客厅。厨房和浴室通常不计入除非面积足够大可算作可居住房间。',
Bedrooms: '在线房产房源中所列的卧室数量。仅适用于在线房源(出售和出租);历史销售记录为空。',
Bathrooms: '在线房产房源中所列的浴室数量。仅适用于在线房源(出售和出租);历史销售记录为空。',
'Construction year':
'根据EPC中的建造年代段例如"1930-1949")取中间值推算。对于年代段跨越数十年的老建筑,精度较低。',
'Date of last transaction':
'来自英国土地注册局价格数据中该房产最近一次成交的记录日期。数据中以日期时间格式存储;在筛选和图表中转换为小数年份。',
'Listing date':
'该房产房源首次出现在在线房产平台上的日期。以日期时间格式存储;筛选时转换为小数年份。仅适用于在线房源。',
'Former council house':
'来自EPC数据中的TENURE字段。若该房产的任何一份EPC证书将产权记录为社会租赁则表明该房产在该次评估时为政府或住房协会存量房。通过Right to Buy等方式出售后的房产仍保留此标记。',
'Current energy rating':
'来自EPC的当前能源效率等级。从A最高效到G最低效。基于每平方米建筑面积的能源使用量。',
'Potential energy rating':
'若实施EPC报告中建议的所有具有成本效益的改进措施后该房产的潜在能源效率等级。从A最高效到G最低效。',
'Interior height (m)':
'EPC评估期间记录的平均室内净高。通过将室内总容积除以总建筑面积计算得出。',
'Distance to nearest train or tube station (km)':
'从邮政编码到最近铁路站或地铁/城铁/轻轨站的直线距离km。',
'Good+ primary schools within 2km':
'2km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 2km':
'2km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Good+ primary schools within 5km':
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 5km':
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Education, Skills and Training Score':
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
'Income Score (rate)':
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示收入剥夺程度越低。基于收入支持、基于收入的求职者津贴、基于收入的就业与支持津贴、养老金补贴、工作税收抵免和子女税收抵免、普惠信用以及寻求庇护者等数据。',
'Employment Score (rate)':
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示就业剥夺程度越低。基于求职者津贴、就业与支持津贴、丧失劳动能力津贴、严重残疾津贴、护理者津贴申领者及相关普惠信用申领者等数据。',
'Health Deprivation and Disability Score':
'来自英格兰剥夺指数(取反后越高越好)。分数越高表示过早死亡风险越低、生活质量越好。来源于潜在寿命损失年、比较疾病和残疾率、急性发病率以及情绪和焦虑障碍等指标。',
'Living Environment Score':
'来自英格兰剥夺指数(取反后越高越好)。综合住房质量(状况、中央供暖)和室外环境(空气质量、道路安全)。分数越高表示居住环境越好。',
'Indoors Sub-domain Score':
'来自英格兰剥夺指数的居住环境领域取反后越高越好。衡量住房存量质量中央供暖覆盖率、住房状况以及Decent Homes标准。分数越高表示住房条件越好。',
'Outdoors Sub-domain Score':
'来自英格兰剥夺指数的居住环境领域(取反后越高越好)。通过空气质量指标以及涉及行人和骑行者的道路交通事故伤亡人数衡量室外生活环境质量。分数越高表示室外环境越好。',
'Serious crime per 1k residents (avg/yr)':
'LSOA内每1,000名常住居民每年发生的暴力、抢劫、入室盗窃和持有武器犯罪数量。使用police.uk街道级犯罪数据2023-2025年和Census 2021人口数据。按人口密度标准化便于不同规模地区之间的比较。',
'Minor crime per 1k residents (avg/yr)':
'LSOA内每1,000名常住居民每年发生的反社会行为、商店行窃、自行车盗窃及其他较轻微犯罪数量。使用police.uk街道级犯罪数据2023-2025年和Census 2021人口数据。按人口密度标准化便于不同规模地区之间的比较。',
'Serious crime (avg/yr)':
'来自police.uk街道级犯罪数据2023-2025年的LSOA内每年暴力、抢劫、入室盗窃和持有武器犯罪总和。提供单一的严重犯罪指标。',
'Minor crime (avg/yr)':
'来自police.uk街道级犯罪数据2023-2025年的LSOA内每年反社会行为、商店行窃、自行车盗窃及其他较轻微犯罪总和。提供单一的轻微犯罪指标。',
'Violence and sexual offences (avg/yr)':
'LSOA内每年暴力和性犯罪的平均数量来自police.uk街道级犯罪数据2023-2025年。包括攻击、骚扰和性犯罪。',
'Burglary (avg/yr)':
'LSOA内每年入室盗窃的平均数量来自police.uk街道级犯罪数据2023-2025年。包括住宅和商业入室盗窃。',
'Robbery (avg/yr)':
'LSOA内每年抢劫案的平均数量来自police.uk街道级犯罪数据2023-2025年。抢劫涉及以暴力或威胁手段实施的盗窃。',
'Vehicle crime (avg/yr)':
'LSOA内每年车辆犯罪事件的平均数量来自police.uk街道级犯罪数据2023-2025年。包括盗窃车辆及从车辆内盗窃。',
'Anti-social behaviour (avg/yr)':
'LSOA内每年反社会行为事件的平均数量来自police.uk街道级犯罪数据2023-2025年。包括滋扰、环境和个人反社会行为。',
'Criminal damage and arson (avg/yr)':
'LSOA内每年刑事损毁和纵火事件的平均数量来自police.uk街道级犯罪数据2023-2025年。',
'Other theft (avg/yr)':
'LSOA内每年"其他盗窃"案的平均数量来自police.uk街道级犯罪数据2023-2025年。包括未被归类为入室盗窃、车辆犯罪、商店行窃或自行车盗窃的盗窃行为。',
'Theft from the person (avg/yr)':
'LSOA内每年针对人身盗窃案的平均数量来自police.uk街道级犯罪数据2023-2025年。包括扒窃和未使用暴力的抢包行为。',
'Shoplifting (avg/yr)':
'LSOA内每年商店行窃案的平均数量来自police.uk街道级犯罪数据2023-2025年。',
'Bicycle theft (avg/yr)':
'LSOA内每年自行车盗窃案的平均数量来自police.uk街道级犯罪数据2023-2025年。',
'Drugs (avg/yr)':
'LSOA内每年毒品犯罪的平均数量来自police.uk街道级犯罪数据2023-2025年。包括持有和贩运毒品犯罪。',
'Possession of weapons (avg/yr)':
'LSOA内每年持有武器犯罪的平均数量来自police.uk街道级犯罪数据2023-2025年。',
'Public order (avg/yr)':
'LSOA内每年公共秩序违法行为的平均数量来自police.uk街道级犯罪数据2023-2025年。包括引起他人恐惧、惊扰或困扰的行为。',
'Other crime (avg/yr)':
'LSOA内每年其他犯罪的平均数量来自police.uk街道级犯罪数据2023-2025年。此类别涵盖未在其他分类中列出的犯罪行为。',
'Median age':
'来自2021年CensusTS007A。通过对五岁年龄段人口数进行线性插值计算得出的LSOA常住居民年龄中位数。年轻人口集中的地区往往是城市、大学城或家庭聚居地年龄中位数较高的地区多见于农村和沿海地区。',
'% White':
'来自2021年Census。地方政府人口中认同为白人英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景的百分比。',
'% South Asian':
'来自2021年Census。地方政府人口中认同为印度人、巴基斯坦人、孟加拉国人或其他亚洲背景的百分比。',
'% Black': '来自2021年Census。地方政府人口中认同为黑人、英国黑人、加勒比人或非洲人的百分比。',
'% East Asian': '来自2021年Census。地方政府人口中认同为华人的百分比。',
'% Mixed':
'来自2021年Census。地方政府人口中认同为<E5908C><E4B8BA>血或多种族群体白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔或其他混血或多种族背景的百分比。',
'% Other':
'来自2021年Census。地方政府人口中认同为其他族裔群体阿拉伯人或其他未被主要类别涵盖的族裔的百分比。',
'Distance to nearest park (km)':
'从邮政编码到最近公园入口的直线距离km。涵盖公共公园、花园、运动<E8BF90><E58AA8><EFBFBD>和游乐场地。使用OS Open Greenspace数据集中的出入口位置因此紧邻大型公园的房产可正确显示较短距离。',
'Number of parks within 2km':
'以房产邮政编码中心点为圆心2km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集英国地形测量局使用公园入口位置进行精确近距离匹配。',
'Number of restaurants within 2km':
'邮政编码2km范围内的餐厅、咖啡馆和餐饮场所数量。来源于OpenStreetMap。',
'Number of grocery shops and supermarkets within 2km':
'以房产邮政编码中心点为圆心2km半径内的超市、便利店和其他杂货店数量。来源于OpenStreetMap POI数据。',
'Noise (dB)':
'来自Defra战略噪声图第4轮2022年的道路噪声水平单位为分贝Lden24小时加权平均值。在地面以上4m、10m网格间距处建模。一般而言超过约55 dB可明显感知超过约70 dB被世卫组织认定为有害。',
'Max available download speed (Mbps)':
'来自Ofcom Connected Nations 2025的任意运营商可提供的最大固定宽带下载速度。代表理论最大值而非实际达到的速度。10 Mbps为基础级30为超快级100+为极速级1000为千兆级。',
},
hu: {
'Listing status':
"Az ingatlan bejegyzés forrását jelzi: 'Korábbi adásvétel' az HM Land Registry Price Paid adatokból, 'Eladó' az aktuális online vételi hirdetésekből, vagy 'Kiadó' az aktuális online bérleti hirdetésekből.",
'Property type':
'Az HM Land Registry Price Paid adatokból és EPC tanúsítványokból. Különálló, Ikerház, Sorház (minden sorház altípust tartalmaz), Lakás/Maisonette, vagy Egyéb (bungaló, mobilház stb.).',
'Leasehold/Freehold':
'Az HM Land Registry Price Paid adatokból. A Freehold azt jelenti, hogy az épület és a telek is az Ön tulajdona. A Leasehold azt jelenti, hogy az épület az Ön tulajdona, de a telek nem: a telektulajdonostól meghatározott számú évre szóló bérleti jogot kapott.',
'Last known price':
'Az ingatlan utolsó rögzített adásvételi ára az HM Land Registry Price Paid adatokból. Az angliai lakóingatlan-értékesítésekre vonatkozik. Lehet, hogy évekkel ezelőtti adat, ha az ingatlan nem kelt el a közelmúltban.',
'Estimated current price':
'Az utolsó adásvételi áron alapul, amelyet az idő múlásával bekövetkezett helyi árváltozásokhoz igazítottak egy ismételt értékesítési index segítségével (irányítószám-szektor és ingatlan típusa szerint nyomon követve). Ha az EPC-adatokból az értékesítés utáni felújítás észlelhető, felújítási prémium kerül hozzáadásra. A közelmúltbeli adásvételek közel lesznek az eredeti árhoz; a régebbi adásvételeket jobban korrigálják.',
'Asking price':
"Az online ingatlanportálokon hirdetett kért ár. Csak az 'Eladó' hirdetéseknél érhető el.",
'Price per sqm':
'Az utolsó ismert adásvételi árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Hasznos a különböző méretű ingatlanok értékének összehasonlításához. Csak akkor elérhető, ha mind az ár, mind az alapterület adatai rendelkezésre állnak.',
'Est. price per sqm':
'Az inflációval korrigált becsült aktuális árat (beleértve az esetleges felújítási prémiumot) az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Naprakészebb ár/terület összehasonlítást nyújt, mint a korábbi adásvételi ár per sqm.',
'Asking price per sqm':
'A megadott kért árat az összes alapterülettel elosztva számítják ki. Csak azon ingatlanokra vonatkozik, amelyek jelenleg eladók, és amelyekről alapterület-adatok állnak rendelkezésre.',
'Estimated monthly rent':
'Az ONS Magánbérleti Piaci Összefoglaló Statisztikákból (2022. október 2023. szeptember) származó medián havi bérleti díj, helyi hatóság és hálószobák száma szerint párosítva. A Valuation Office Agency bérbeadási adatain alapul.',
'Asking rent (monthly)':
"Az online ingatlanportálokon hirdetett bérleti díj, szükség esetén havi összegre átszámítva (pl. heti vagy éves hirdetések esetén). Csak a 'Kiadó' hirdetéseknél érhető el.",
'Total floor area (sqm)':
'Az Energy Performance Certificate felmérése során mért teljes hasznos alapterület négyzetméterben. Tartalmazza az összes lakható helyiséget, de kizárja a garázsokat, melléképületeket és külső területeket.',
'Number of bedrooms & living rooms':
'Az Energy Performance Certificate-ben rögzített összes lakható helyiség száma (hálószobák és nappali szobák összege). A konyhák és fürdőszobák jellemzően nem számítanak bele, kivéve, ha elég nagyok ahhoz, hogy lakható helyiségnek minősüljenek.',
Bedrooms:
'Az online ingatlanhirdetésben meghirdetett hálószobák száma. Csak online hirdetéseknél (eladó és kiadó ingatlanok) kerül feltüntetésre; korábbi adásvételek esetén üres.',
Bathrooms:
'Az online ingatlanhirdetésben meghirdetett fürdőszobák száma. Csak online hirdetéseknél (eladó és kiadó ingatlanok) kerül feltüntetésre; korábbi adásvételek esetén üres.',
'Construction year':
"Az EPC-ben szereplő építési korszak alapján (pl. '19301949') a középértékkel becsülve. Régebbi épületeknél kevésbé pontos, ahol a korcsoport több évtizedet ölel fel.",
'Date of last transaction':
'Az ingatlan legutóbbi rögzített adásvételének dátuma az HM Land Registry Price Paid adatokból. Az adatokban dátum/idő formátumban tárolódik; szűréshez és diagramokhoz törtéves formátumra konvertálva.',
'Listing date':
'Az a dátum, amikor az ingatlanhirdetés először jelent meg az online ingatlanportálon. Dátum/idő formátumban tárolva; szűréshez törtéves formátumra konvertálva. Csak online hirdetéseknél kerül feltüntetésre.',
'Former council house':
'Az Energy Performance Certificate adatok TENURE mezőjéből származtatva. Ha az ingatlan bármely EPC tanúsítványa szociális bérlakásként rögzítette a bérleti jogviszonyt, ez azt jelzi, hogy az ingatlan az adott ellenőrzés idején önkormányzati vagy lakásszövetkezeti állomány volt. Azok az ingatlanok, amelyeket később értékesítettek (pl. Right to Buy útján), megőrzik ezt a jelzést.',
'Current energy rating':
'Az Energy Performance Certificate aktuális energiahatékonysági besorolása. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed. Az ingatlan alapterületre vetített energiafelhasználásán alapul.',
'Potential energy rating':
'Az Energy Performance Certificate potenciális energiahatékonysági besorolása, amennyiben az EPC-jelentésben ajánlott összes költséghatékony fejlesztést elvégeznék. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed.',
'Interior height (m)':
'Az Energy Performance Certificate felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
'Distance to nearest train or tube station (km)':
'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi vasút- vagy metró-/városi vasút-/villamosmegállóig.',
'Good+ primary schools within 2km':
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 2km':
'2 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ primary schools within 5km':
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Education, Skills and Training Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában. A magasabb pontszámok kisebb mértékű nélkülözést jeleznek.',
'Income Score (rate)':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb értékek kisebb mértékű jövedelmi nélkülözést jeleznek. A jövedelempótló támogatás, jövedelemalapú Munkaügyi Segély, jövedelemalapú Foglalkoztatási és Támogatási Segély, Nyugdíjkiegészítés, Munkavállalói és Gyermekadókedvezmény, Univerzális Hitel és menedékkérők alapján.',
'Employment Score (rate)':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb értékek kisebb mértékű foglalkoztatási nélkülözést jeleznek. A Munkaügyi Segély, Foglalkoztatási és Támogatási Segély, Munkaképtelenségi Juttatás, Súlyos Rokkantsági Pótlék, Gondozói Juttatás igénylői és a vonatkozó Univerzális Hitel igénylői alapján.',
'Health Deprivation and Disability Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb pontszámok alacsonyabb korai halálozási kockázatot és jobb életminőséget jeleznek. Az elveszített potenciális életévekből, a komparatív betegségi és rokkantsági arányból, az akut morbiditásból, valamint a hangulati és szorongásos zavarokból vezethető le.',
'Living Environment Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Ötvözi a lakásminőséget (állapot, gázfűtés) és a külső környezetet (levegőminőség, közlekedésbiztonság). A magasabb pontszámok jobb lakókörnyezetet jeleznek.',
'Indoors Sub-domain Score':
'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából (megfordítva, így magasabb = jobb). A lakásállomány minőségét méri: gázfűtés rendelkezésre állása, lakásállapot és Decent Homes szabványok. A magasabb pontszámok jobb lakáskörülményeket jeleznek.',
'Outdoors Sub-domain Score':
'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából (megfordítva, így magasabb = jobb). A külső lakókörnyezet minőségét méri a levegőminőségi mutatók és a gyalogosokat, kerékpárosokat érintő közúti közlekedési baleseti áldozatok alapján. A magasabb pontszámok jobb külső környezetet jeleznek.',
'Serious crime per 1k residents (avg/yr)':
'Erőszakos bűncselekmények, rablás, betörés és fegyverbirtoklás 1 000 szokásos lakóra vetítve évente az LSOA-ban. A police.uk utcai szintű bűnügyi adatait (20232025) és a Census 2021 népességszámait használja. Normalizálja a népsűrűséget, így a területek mérettől függetlenül összehasonlíthatók.',
'Minor crime per 1k residents (avg/yr)':
'Antiszociális magatartás, boltlopás, kerékpárlopás és egyéb kisebb súlyosságú bűncselekmények 1 000 szokásos lakóra vetítve évente az LSOA-ban. A police.uk utcai szintű bűnügyi adatait (20232025) és a Census 2021 népességszámait használja. Normalizálja a népsűrűséget, így a területek mérettől függetlenül összehasonlíthatók.',
'Serious crime (avg/yr)':
'Az erőszakos bűncselekmények, rablás, betörés és fegyverbirtoklás éves összege az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Egyetlen súlyos bűnözési mutatót ad.',
'Minor crime (avg/yr)':
'Az antiszociális magatartás, boltlopás, kerékpárlopás és egyéb kisebb súlyosságú bűncselekmények éves összege az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Egyetlen kisebb bűnözési mutatót ad.',
'Violence and sexual offences (avg/yr)':
'Az erőszakos és szexuális bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a testi sértést, zaklatást és szexuális bűncselekményeket.',
'Burglary (avg/yr)':
'A betörések átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a lakó- és kereskedelmi célú betöréseket.',
'Robbery (avg/yr)':
'A rablások átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). A rablás erővel vagy erőszakkal fenyegetéssel járó lopást jelent.',
'Vehicle crime (avg/yr)':
'A járművel kapcsolatos bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a járművek ellopását és a járművekből való lopást.',
'Anti-social behaviour (avg/yr)':
'Az antiszociális magatartási esetek átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a zavarást, környezeti és személyes antiszociális magatartást.',
'Criminal damage and arson (avg/yr)':
'A rongálás és gyújtogatás átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025).',
'Other theft (avg/yr)':
"Az 'egyéb lopás' kategóriájú bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a betörés, járműves bűncselekmény, boltlopás vagy kerékpárlopás alá nem sorolt lopásokat.",
'Theft from the person (avg/yr)':
'A személytől való lopás átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a zsebtolvajlást és erő nélküli táskavágást.',
'Shoplifting (avg/yr)':
'A boltlopások átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025).',
'Bicycle theft (avg/yr)':
'A kerékpárlopások átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025).',
'Drugs (avg/yr)':
'A kábítószer-bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a birtoklási és terjesztési bűncselekményeket.',
'Possession of weapons (avg/yr)':
'A fegyverbirtoklási bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025).',
'Public order (avg/yr)':
'A közrend elleni bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a félelemkeltést, riasztást vagy szorongást okozó cselekményeket.',
'Other crime (avg/yr)':
'Az egyéb bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). 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.',
'% White':
'A 2021-es Census alapján. A helyi hatóság területén 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':
'A 2021-es Census alapján. A helyi hatóság területén indiai, pakisztáni, bangladesi vagy bármely más ázsiai háttérként azonosított népesség százaléka.',
'% Black':
'A 2021-es Census alapján. A helyi hatóság területén fekete, brit fekete, karibi vagy afrikai háttérként azonosított népesség százaléka.',
'% East Asian':
'A 2021-es Census alapján. A helyi hatóság területén kínaiként azonosított népesség százaléka.',
'% Mixed':
'A 2021-es Census alapján. A helyi hatóság területén vegyes vagy többes etnikai csoportként (fehér és fekete karibi, fehér és fekete afrikai, fehér és ázsiai, vagy bármely más vegyes vagy többes háttér) azonosított népesség százaléka.',
'% Other':
'A 2021-es Census alapján. A helyi hatóság területén egyéb etnikai csoportként (arab vagy bármely más, a főkategóriák által nem lefedett etnikai csoport) azonosított népesség százaléka.',
'Distance to nearest park (km)':
'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi park bejáratáig. Magában foglalja a közparkokat, kerteket, játszótereket és szabadidős területeket. Az OS Open Greenspace adatkészlet hozzáférési pont helyszíneit használja, így a nagy park szomszédságában lévő ingatlanok helyesen rövid távolságot mutatnak.',
'Number of parks within 2km':
'A közparkok, kertek, játszóterek és szabadidős területek száma, amelyeknek legalább egy bejárata van az ingatlan irányítószám centroidjától számított 2 km-es körzetben. Az OS Open Greenspace adatkészletből (Ordnance Survey) származik, park bejárati helyszíneket használva a pontos közelségi egyeztetéshez.',
'Number of restaurants within 2km':
'Az ingatlan irányítószámjától 2 km-en belüli éttermek, kávézók és vendéglátóhelyek. Forrás: OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Az ingatlan irányítószám centroidjától számított 2 km-es körzetben lévő szupermarketek, kisboltok és egyéb élelmiszerboltok száma. Az OpenStreetMap POI-adatokból származtatva.',
'Noise (dB)':
'Közúti zajszint decibel (Lden, 24 órás súlyozott átlag) értékben, a Defra Stratégiai Zajtérképezés 4. fordulójából (2022). 4 m magasságban, 10 m-es rácson modellezve. ~55 dB felett általában érzékelhető; ~70 dB felett az WHO károsnak minősíti.',
'Max available download speed (Mbps)':
'Bármely szolgáltatótól elérhető maximális rögzített szélessávú letöltési sebesség, az Ofcom Connected Nations 2025 adataiból. Az elméleti maximumot jelöli, nem a valós sebességet. 10 Mbps = alapszintű, 30 = szupergyors, 100+ = ultragyors, 1000 = gigabites.',
},
};

View file

@ -7,11 +7,11 @@ import hu from './locales/hu';
import zh from './locales/zh';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English', flag: '\uD83C\uDDEC\uD83C\uDDE7' },
{ code: 'fr', label: 'Fran\u00E7ais', flag: '\uD83C\uDDEB\uD83C\uDDF7' },
{ code: 'de', label: 'Deutsch', flag: '\uD83C\uDDE9\uD83C\uDDEA' },
{ code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' },
{ code: 'zh', label: '\u4E2D\u6587', flag: '\uD83C\uDDE8\uD83C\uDDF3' },
{ code: 'en', label: 'English', flag: '\uD83C\uDDEC\uD83C\uDDE7' },
{ code: 'fr', label: 'Fran\u00E7ais', flag: '\uD83C\uDDEB\uD83C\uDDF7' },
{ code: 'de', label: 'Deutsch', flag: '\uD83C\uDDE9\uD83C\uDDEA' },
{ code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' },
{ code: 'zh', label: '\u4E2D\u6587', flag: '\uD83C\uDDE8\uD83C\uDDF3' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
@ -19,37 +19,37 @@ export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
const supportedCodes: Set<string> = new Set(SUPPORTED_LANGUAGES.map((l) => l.code));
function detectLanguage(): string {
// 1. Explicit user choice (persisted from the language dropdown)
const stored = localStorage.getItem('language');
if (stored && supportedCodes.has(stored)) return stored;
// 1. Explicit user choice (persisted from the language dropdown)
const stored = localStorage.getItem('language');
if (stored && supportedCodes.has(stored)) return stored;
// 2. Browser preference (navigator.languages falls back to navigator.language)
for (const tag of navigator.languages ?? [navigator.language]) {
// Match full tag first (e.g. "zh-CN" → "zh"), then just the prefix
const lower = tag.toLowerCase();
if (supportedCodes.has(lower)) return lower;
const prefix = lower.split('-')[0];
if (supportedCodes.has(prefix)) return prefix;
}
// 2. Browser preference (navigator.languages falls back to navigator.language)
for (const tag of navigator.languages ?? [navigator.language]) {
// Match full tag first (e.g. "zh-CN" → "zh"), then just the prefix
const lower = tag.toLowerCase();
if (supportedCodes.has(lower)) return lower;
const prefix = lower.split('-')[0];
if (supportedCodes.has(prefix)) return prefix;
}
return 'en';
return 'en';
}
const initialLang = detectLanguage();
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
fr: { translation: fr },
de: { translation: de },
hu: { translation: hu },
zh: { translation: zh },
},
lng: initialLang,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already escapes
},
resources: {
en: { translation: en },
fr: { translation: fr },
de: { translation: de },
hu: { translation: hu },
zh: { translation: zh },
},
lng: initialLang,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already escapes
},
});
/**
@ -57,8 +57,8 @@ i18n.use(initReactI18next).init({
* Bypasses the strict type checking on t() for dynamic key construction.
*/
export function tDynamic(key: string): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (i18n.t as any)(key);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (i18n.t as any)(key);
}
export default i18n;

View file

@ -11,5 +11,5 @@ export function ts(value: string): string {
return typeof result === 'string' ? result : value;
}
// Re-export tsDesc from descriptions.ts for convenience
export { tsDesc } from './descriptions';
// Re-export tsDesc and tsDetail from descriptions.ts for convenience
export { tsDesc, tsDetail } from './descriptions';

View file

@ -0,0 +1,124 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { LayerExtension } from '@deck.gl/core';
import { ENUM_PALETTE } from './consts';
/**
* LayerExtension that turns polygon fills into pie charts.
* Injects a fragment shader that computes angle from each fragment's position
* to the polygon centroid, then picks a slice color from the enum palette.
*
* Works with H3HexagonLayer (hex fills) and GeoJsonLayer (postcode fills).
* Only activates on SolidPolygonLayer sublayers (fill), not PathLayer (stroke).
*
* Required layer props when this extension is active:
* getCenter: (d) => [lon, lat] polygon centroid in world coordinates
* getRatios0: (d) => number[4] pie ratios for slices 0-3
* getRatios1: (d) => number[4] pie ratios for slices 4-7
* getRatios2: (d) => number[2] pie ratios for slices 8-9
*/
// Build palette as GLSL vec3 constants (normalized 0-1)
const PALETTE_GLSL = ENUM_PALETTE.map(
(c) =>
`vec3(${(c[0] / 255).toFixed(4)}, ${(c[1] / 255).toFixed(4)}, ${(c[2] / 255).toFixed(4)})`
).join(',\n ');
export class PieHexExtension extends LayerExtension {
static extensionName = 'PieHexExtension';
isEnabled(layer: any): boolean {
// Only apply to fill sublayers (SolidPolygonLayer), not stroke (PathLayer)
return layer.id.endsWith('-fill');
}
getShaders(extension: any): any {
if (!extension.isEnabled(this)) return null;
return {
modules: [
{
name: 'pieHex',
inject: {
'vs:#decl': `\
in vec2 instancePieCenter;
in vec4 instanceRatios0;
in vec4 instanceRatios1;
in vec2 instanceRatios2;
out vec2 vPieCenter;
out vec2 vPieFragPos;
out vec4 vRatios0;
out vec4 vRatios1;
out vec2 vRatios2;`,
'vs:#main-end': `\
vPieCenter = project_position(vec3(instancePieCenter, 0.0)).xy;
vPieFragPos = geometry.position.xy;
vRatios0 = instanceRatios0;
vRatios1 = instanceRatios1;
vRatios2 = instanceRatios2;`,
'fs:#decl': `\
in vec2 vPieCenter;
in vec2 vPieFragPos;
in vec4 vRatios0;
in vec4 vRatios1;
in vec2 vRatios2;
const vec3 pieColors[10] = vec3[10](
${PALETTE_GLSL}
);`,
'fs:DECKGL_FILTER_COLOR': `\
{
vec2 delta = vPieFragPos - vPieCenter;
float angle = atan(delta.x, -delta.y) / (2.0 * 3.14159265) + 0.5;
float ratios[10];
ratios[0] = vRatios0.x; ratios[1] = vRatios0.y;
ratios[2] = vRatios0.z; ratios[3] = vRatios0.w;
ratios[4] = vRatios1.x; ratios[5] = vRatios1.y;
ratios[6] = vRatios1.z; ratios[7] = vRatios1.w;
ratios[8] = vRatios2.x; ratios[9] = vRatios2.y;
float cumulative = 0.0;
vec3 sliceColor = pieColors[0];
for (int i = 0; i < 10; i++) {
cumulative += ratios[i];
if (angle < cumulative) {
sliceColor = pieColors[i];
break;
}
}
color = vec4(sliceColor, 1.0);
}`,
},
uniformTypes: {},
},
],
};
}
initializeState(this: any, _context: any, extension: any): void {
if (!extension.isEnabled(this)) return;
const am = this.getAttributeManager();
if (!am) return;
am.addInstanced({
instancePieCenter: {
size: 2,
type: 'float32',
accessor: 'getCenter',
},
instanceRatios0: {
size: 4,
type: 'float32',
accessor: 'getRatios0',
},
instanceRatios1: {
size: 4,
type: 'float32',
accessor: 'getRatios1',
},
instanceRatios2: {
size: 2,
type: 'float32',
accessor: 'getRatios2',
},
});
}
}

View file

@ -46,6 +46,7 @@ export function parseInputValue(
export function formatDuration(d: string): string {
if (d === 'F' || d === 'L') {
// These are server enum values — translate via ts()
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
if (d === 'F') return ts('Freehold');
return ts('Leasehold');
@ -86,7 +87,10 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
}
export function formatRelativeTime(isoDate: string): string {
const i18n = require('../i18n').default as { t: (key: string, opts?: Record<string, unknown>) => string };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {
t: (key: string, opts?: Record<string, unknown>) => string;
};
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;

View file

@ -232,7 +232,7 @@ export function getFeatureFillColor(
}
}
// Discrete coloring for enum features
// Discrete coloring for enum features (used as base; PieHexExtension overrides when active)
if (enumCount > 0) {
const rgb = enumIndexToColor(Math.round(value as number));
return [...rgb, alpha] as [number, number, number, number];

View file

@ -160,7 +160,11 @@ export function stateToParams(
}
export function summarizeParams(queryString: string): string {
const i18n = require('../i18n').default as { t: (key: string, opts?: Record<string, unknown>) => string };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {
t: (key: string, opts?: Record<string, unknown>) => string;
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
const params = new URLSearchParams(queryString);
const parts: string[] = [];