-
-
-
-
-
-
-
- {isPostcode ? hexagonId : t('areaPane.areaOverview')}
-
- {loading && (
-
+
+
+
+
+
+
+
+ {isPostcode ? hexagonId : t('areaPane.areaOverview')}
+
+ {loading && (
+
+ )}
+
+
+ {t('areaPane.statsFor', {
+ type: isPostcode
+ ? t('common.postcode').toLowerCase()
+ : t('common.area').toLowerCase(),
+ })}
+
+
+
+
+ {propertyCount == null ? '...' : propertyCount.toLocaleString()}
+
+
+ {t('common.propertiesPlural')}
+
+
+
+
+
+
+
+ {t('areaPane.statsBasis')}
+
+
+ onStatsUseFiltersChange(true)}
+ className={`rounded px-2 py-1 text-xs font-medium ${
+ statsUseFilters && filtersActive
+ ? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
+ : 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
+ }`}
+ >
+ {t('areaPane.matchingFiltersOption')}
+
+ onStatsUseFiltersChange(false)}
+ className={`rounded px-2 py-1 text-xs font-medium ${
+ !statsUseFilters || !filtersActive
+ ? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
+ : 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
+ }`}
+ >
+ {t('areaPane.allPropertiesOption')}
+
+
+
+
+ {filtersActive
+ ? statsUseFilters
+ ? t('areaPane.filtersAffectStats', { count: activeFilterCount })
+ : t('areaPane.filtersIgnoredForStats')
+ : t('areaPane.noFiltersAffectStats')}
+
+
+
+ {showFlipToggleCallout && (
+
+
{t('areaPane.filteredStatsEmpty')}
+
+ {unfilteredCount != null
+ ? t('areaPane.showAllStatsHint', { count: unfilteredCount })
+ : t('areaPane.showAllStatsFallback')}
+
+
onStatsUseFiltersChange(false)}
+ className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
+ >
+ {t('areaPane.showAllStats')}
+
+ {filterExclusions.length > 0 && (
+
+
{t('areaPane.closestBlockingFilters')}
+
+ {filterExclusions.map((exclusion) => (
+
+
+ {getExclusionLabel(exclusion)}
+
+
+ {getExclusionAdjustment(exclusion)}
+
+
+ ))}
+
+
)}
-
- {t('areaPane.statsFor', {
- type: isPostcode
- ? t('common.postcode').toLowerCase()
- : t('common.area').toLowerCase(),
- })}
-
-
-
-
- {propertyCount == null ? '...' : propertyCount.toLocaleString()}
-
-
- {t('common.propertiesPlural')}
-
-
-
-
-
-
-
- {t('areaPane.statsBasis')}
-
-
- onStatsUseFiltersChange(true)}
- className={`rounded px-2 py-1 text-xs font-medium ${
- statsUseFilters && filtersActive
- ? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
- : 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
- }`}
- >
- {t('areaPane.matchingFiltersOption')}
-
- onStatsUseFiltersChange(false)}
- className={`rounded px-2 py-1 text-xs font-medium ${
- !statsUseFilters || !filtersActive
- ? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
- : 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
- }`}
- >
- {t('areaPane.allPropertiesOption')}
-
-
-
-
- {filtersActive
- ? statsUseFilters
- ? t('areaPane.filtersAffectStats', { count: activeFilterCount })
- : t('areaPane.filtersIgnoredForStats')
- : t('areaPane.noFiltersAffectStats')}
-
-
-
- {showFlipToggleCallout && (
-
-
{t('areaPane.filteredStatsEmpty')}
-
- {unfilteredCount != null
- ? t('areaPane.showAllStatsHint', { count: unfilteredCount })
- : t('areaPane.showAllStatsFallback')}
-
-
onStatsUseFiltersChange(false)}
- className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
- >
- {t('areaPane.showAllStats')}
-
- {filterExclusions.length > 0 && (
-
-
{t('areaPane.closestBlockingFilters')}
-
- {filterExclusions.map((exclusion) => (
-
- {getExclusionLabel(exclusion)}
-
- {getExclusionAdjustment(exclusion)}
-
-
- ))}
-
-
- )}
-
- )}
-
-
-
- {hexagonLocation && stats && (
-
- )}
- {(() => {
- const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
- return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
-
- ) : null;
- })()}
- {loading && !stats ? (
-
- ) : stats ? (
-
- {hexagonLocation &&
}
- {stats.count > 0 &&
}
- {stats.price_history &&
- (() => {
- const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
- return uniqueYears.size > 1;
- })() && (
-
-
- {t('areaPane.priceHistory')}
-
-
-
)}
- {featureGroups.map((group) => {
- const hasData = group.features.some(
- (feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
- );
- if (!hasData) return null;
+
+
- const stackedCharts = STACKED_GROUPS[group.name];
- const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
+ {hexagonLocation && stats && (
+
+ )}
+ {(() => {
+ const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
+ return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
+
+ ) : null;
+ })()}
+ {loading && !stats ? (
+
+ ) : stats ? (
+
+ {hexagonLocation &&
}
+ {stats.count > 0 &&
}
+ {stats.price_history &&
+ (() => {
+ const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
+ return uniqueYears.size > 1 ? (
+
+
+ {t('areaPane.priceHistory')}
+
+
+
+ ) : null;
+ })()}
+ {displayFeatureGroups.map((group) => {
+ const showNearbyStations =
+ hexagonLocation != null && STATION_GROUP_NAMES.has(group.name);
+ const hasData = group.features.some(
+ (feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
+ );
+ if (!hasData && !showNearbyStations) return null;
- const stackedEnumFeatureNames = new Set
(
- stackedEnumCharts?.flatMap((c) =>
- [c.feature, ...c.components].filter((s): s is string => Boolean(s))
- ) ?? []
- );
+ const stackedCharts = STACKED_GROUPS[group.name];
+ const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
- const expanded = isGroupExpanded(group.name);
+ const stackedEnumFeatureNames = new Set(
+ stackedEnumCharts?.flatMap((c) =>
+ [c.feature, ...c.components].filter((s): s is string => Boolean(s))
+ ) ?? []
+ );
- return (
-
-
onToggleGroup(group.name)}
- className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
- />
- {expanded && (
-
- {stackedCharts?.map((chart) => {
- const segments = chart.components
- .map((name) => ({
- name,
- value: numericByName.get(name)?.mean ?? 0,
- }))
- .filter((s) => s.value > 0);
+ const expanded = isGroupExpanded(group.name);
- const isPercentageComposition = chart.unit === '%' && !chart.feature;
- const displaySegments = isPercentageComposition
- ? normalizePercentageSegments(segments)
- : segments;
-
- const aggregateStats = chart.feature
- ? numericByName.get(chart.feature)
- : undefined;
- const total = aggregateStats
- ? aggregateStats.mean
- : displaySegments.reduce((sum, s) => sum + s.value, 0);
-
- // Use rateFeature (e.g. per-1k) for display if available
- const rateStats = chart.rateFeature
- ? numericByName.get(chart.rateFeature)
- : undefined;
- const displayValue = isPercentageComposition
- ? 100
- : rateStats
- ? rateStats.mean
- : total;
-
- // Use rateFeature for info popup and national average when available
- const infoFeatureName = chart.rateFeature ?? chart.feature;
- const featureMeta = infoFeatureName
- ? globalFeatureByName.get(infoFeatureName)
- : undefined;
-
- const globalMean = featureMeta?.histogram
- ? calculateHistogramMean(featureMeta.histogram)
- : undefined;
-
- if (total === 0) return null;
-
- return (
-
-
- {featureMeta ? (
-
- ) : (
-
- {ts(chart.label)}
-
- )}
-
-
- {formatValue(displayValue)}
- {chart.unit ? ` ${chart.unit}` : ''}
-
- {globalMean != null && (
-
- {t('areaPane.nationalAvg')}: {formatValue(globalMean)}
-
- )}
-
-
-
-
- );
- })}
- {(() => {
- const stackedFeatureNames = new Set
(
- stackedCharts?.flatMap((c) =>
- [c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
- Boolean(s)
- )
- ) ?? []
- );
- return group.features
- .filter(
- (f) =>
- !stackedFeatureNames.has(f.name) &&
- !stackedEnumFeatureNames.has(f.name)
- )
- .map((feature) => {
- const numericStats = numericByName.get(feature.name);
- const enumStats = enumByName.get(feature.name);
-
- if (numericStats) {
- const globalFeature = globalFeatureByName.get(feature.name);
- const globalHistogram = globalFeature?.histogram;
- const globalMean = globalHistogram
- ? calculateHistogramMean(globalHistogram)
- : undefined;
-
- return (
-
-
-
-
- {formatValue(numericStats.mean, feature)}
-
-
- {numericStats.histogram &&
- (globalHistogram ? (
-
- formatFilterValue(
- v,
- feature.suffix === '%'
- ? { raw: feature.raw, suffix: feature.suffix }
- : feature.raw
- )
- }
- />
- ) : (
-
- formatFilterValue(
- v,
- feature.suffix === '%'
- ? { raw: feature.raw, suffix: feature.suffix }
- : feature.raw
- )
- }
- />
- ))}
-
- );
- }
-
- if (enumStats) {
- const globalFeature = globalFeatureByName.get(feature.name);
- return (
-
-
-
-
- );
- }
-
- return null;
- });
- })()}
- {stackedEnumCharts?.map((chart) => {
- const featureMeta = chart.feature
- ? globalFeatureByName.get(chart.feature)
- : undefined;
-
- if (chart.components.length === 1) {
- const stats = enumByName.get(chart.components[0]);
- if (!stats) return null;
-
- const segments = chart.valueOrder
- .map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
+ return (
+
+
onToggleGroup(group.name)}
+ className="area-pane-group-header sticky top-0 z-10 bg-white px-3 pb-1.5 pt-4 text-[11px] font-bold uppercase tracking-wide text-warm-500 hover:bg-warm-50 dark:bg-navy-950 dark:text-warm-400 dark:hover:bg-navy-900"
+ />
+ {expanded && (
+
+ {showNearbyStations &&
}
+ {stackedCharts?.map((chart) => {
+ const segments = chart.components
+ .map((name) => ({
+ name,
+ value: numericByName.get(name)?.mean ?? 0,
+ }))
.filter((s) => s.value > 0);
- const total = segments.reduce((sum, s) => sum + s.value, 0);
+
+ const isPercentageComposition = chart.unit === '%' && !chart.feature;
+ const displaySegments = isPercentageComposition
+ ? normalizePercentageSegments(segments)
+ : segments;
+
+ const aggregateStats = chart.feature
+ ? numericByName.get(chart.feature)
+ : undefined;
+ const total = aggregateStats
+ ? aggregateStats.mean
+ : displaySegments.reduce((sum, s) => sum + s.value, 0);
+
+ // Use rateFeature (e.g. per-1k) for display if available
+ const rateStats = chart.rateFeature
+ ? numericByName.get(chart.rateFeature)
+ : undefined;
+ const displayValue = isPercentageComposition
+ ? 100
+ : rateStats
+ ? rateStats.mean
+ : total;
+
+ // Use rateFeature for info popup and national average when available
+ const infoFeatureName = chart.rateFeature ?? chart.feature;
+ const featureMeta = infoFeatureName
+ ? globalFeatureByName.get(infoFeatureName)
+ : undefined;
+
+ const globalMean = featureMeta?.histogram
+ ? calculateHistogramMean(featureMeta.histogram)
+ : undefined;
+
if (total === 0) return null;
return (
@@ -538,7 +542,7 @@ export default function AreaPane({
{featureMeta ? (
@@ -547,62 +551,223 @@ export default function AreaPane({
{ts(chart.label)}
)}
-
- {total.toLocaleString()}
-
+
+
+ {formatValue(displayValue)}
+ {chart.unit ? ` ${chart.unit}` : ''}
+
+ {globalMean != null && (
+
+ {t('areaPane.nationalAvg')}: {formatValue(globalMean)}
+
+ )}
+
[v, chart.valueColors[i]])
- )}
+ colorMap={
+ chart.label === 'Political vote share'
+ ? PARTY_FEATURE_COLORS
+ : STACKED_SEGMENT_COLORS
+ }
/>
);
- }
+ })}
+ {(() => {
+ const stackedFeatureNames = new Set(
+ stackedCharts?.flatMap((c) =>
+ [c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
+ Boolean(s)
+ )
+ ) ?? []
+ );
+ return group.features
+ .filter(
+ (f) =>
+ !stackedFeatureNames.has(f.name) &&
+ !stackedEnumFeatureNames.has(f.name)
+ )
+ .map((feature) => {
+ const numericStats = numericByName.get(feature.name);
+ const enumStats = enumByName.get(feature.name);
- const components = chart.components
- .map((name) => {
- const stats = enumByName.get(name);
- return stats ? { label: name, stats } : null;
- })
- .filter((c): c is NonNullable => c !== null);
+ if (numericStats) {
+ const globalFeature = globalFeatureByName.get(feature.name);
+ const globalHistogram = globalFeature?.histogram;
+ const globalMean = globalHistogram
+ ? calculateHistogramMean(globalHistogram)
+ : undefined;
- if (components.length === 0) return null;
+ return (
+
+ }
+ chart={
+ numericStats.histogram &&
+ (globalHistogram ? (
+
+ formatFilterValue(
+ v,
+ feature.suffix === '%'
+ ? { raw: feature.raw, suffix: feature.suffix }
+ : feature.raw
+ )
+ }
+ integerAxisLabels={feature.step === 1}
+ compact
+ />
+ ) : (
+
+ formatFilterValue(
+ v,
+ feature.suffix === '%'
+ ? { raw: feature.raw, suffix: feature.suffix }
+ : feature.raw
+ )
+ }
+ integerAxisLabels={feature.step === 1}
+ compact
+ />
+ ))
+ }
+ value={formatValue(numericStats.mean, feature)}
+ valueTitle={
+ globalMean != null
+ ? `${t('areaPane.nationalAvg')}: ${formatValue(globalMean)}`
+ : undefined
+ }
+ />
+ );
+ }
- return (
-
-
- {featureMeta ? (
-
+
+
+
+ );
+ }
+
+ return null;
+ });
+ })()}
+ {stackedEnumCharts?.map((chart) => {
+ const featureMeta = chart.feature
+ ? globalFeatureByName.get(chart.feature)
+ : undefined;
+
+ if (chart.components.length === 1) {
+ const stats = enumByName.get(chart.components[0]);
+ if (!stats) return null;
+
+ const segments = chart.valueOrder
+ .map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
+ .filter((s) => s.value > 0);
+ const total = segments.reduce((sum, s) => sum + s.value, 0);
+ if (total === 0) return null;
+
+ return (
+
+
+ {featureMeta ? (
+
+ ) : (
+
+ {ts(chart.label)}
+
+ )}
+
+ {total.toLocaleString()}
+
+
+
[v, chart.valueColors[i]])
+ )}
/>
- ) : (
-
- {ts(chart.label)}
-
- )}
+
+ );
+ }
+
+ const components = chart.components
+ .map((name) => {
+ const stats = enumByName.get(name);
+ return stats ? { label: name, stats } : null;
+ })
+ .filter((c): c is NonNullable
=> c !== null);
+
+ if (components.length === 0) return null;
+
+ return (
+
+
+ {featureMeta ? (
+
+ ) : (
+
+ {ts(chart.label)}
+
+ )}
+
+
-
-
- );
- })}
-
- )}
-
- );
- })}
-
- ) : null}
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+ ) : null}
diff --git a/frontend/src/components/map/DualHistogram.test.ts b/frontend/src/components/map/DualHistogram.test.ts
new file mode 100644
index 0000000..92aac74
--- /dev/null
+++ b/frontend/src/components/map/DualHistogram.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import { compactHistogramLabel } from './DualHistogram';
+
+describe('compactHistogramLabel', () => {
+ it('rounds low-cardinality count labels to integers', () => {
+ const fmt = (value: number) => value.toFixed(2);
+ const labels = [0, 0.99, 2.98, 4.96, 5.95].map((center, index) =>
+ compactHistogramLabel(index, 5, 0, 5.95, center, fmt, true)
+ );
+
+ expect(labels).toEqual(['0', '1', '3', '5', '6+']);
+ });
+
+ it('labels the first integer count bucket as zero when it means below one', () => {
+ const fmt = (value: number) => value.toFixed(2);
+
+ expect(compactHistogramLabel(0, 5, 0.99, 5.95, 0.99, fmt, true)).toBe('0');
+ });
+
+ it('keeps fractional labels when integer labels are not requested', () => {
+ const fmt = (value: number) => value.toFixed(2);
+
+ expect(compactHistogramLabel(1, 5, 0, 5.95, 0.99, fmt, false)).toBe('0.99');
+ });
+});
diff --git a/frontend/src/components/map/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx
index 76a8ecf..bd2670f 100644
--- a/frontend/src/components/map/DualHistogram.tsx
+++ b/frontend/src/components/map/DualHistogram.tsx
@@ -30,6 +30,42 @@ function pickTicks(min: number, max: number, count: number): number[] {
return ticks;
}
+function isLowCardinalityHistogram(counts: number[], p1: number, p99: number): boolean {
+ return counts.length > 0 && counts.length <= 10 && p99 > p1 && p99 - p1 <= 10;
+}
+
+export function compactHistogramLabel(
+ index: number,
+ barCount: number,
+ p1: number,
+ p99: number,
+ center: number,
+ formatLabel: (value: number) => string,
+ integerLabels = false
+): string {
+ const formatAxisValue = (value: number) =>
+ integerLabels ? Math.round(value).toLocaleString() : formatLabel(value);
+
+ if (barCount <= 1) return formatAxisValue(center);
+
+ const middleBins = barCount - 2;
+ if (index === 0) {
+ if (!integerLabels) return `<${formatLabel(p1)}`;
+ const firstBoundary = Math.ceil(p1);
+ return firstBoundary <= 1 ? '0' : `<${firstBoundary.toLocaleString()}`;
+ }
+ if (index === barCount - 1) {
+ if (!integerLabels) return `${formatLabel(p99)}+`;
+ return `${Math.ceil(p99).toLocaleString()}+`;
+ }
+
+ const middleWidth = middleBins > 0 ? (p99 - p1) / middleBins : 0;
+ if (Math.abs(middleWidth - 1) < 0.001) {
+ return formatAxisValue(p1 + index - 1);
+ }
+ return formatAxisValue(center);
+}
+
export function DualHistogram({
localCounts,
globalCounts,
@@ -38,6 +74,8 @@ export function DualHistogram({
globalMean,
meanLabel,
formatLabel,
+ compact = false,
+ integerAxisLabels = false,
}: {
localCounts: number[];
globalCounts: number[];
@@ -46,9 +84,15 @@ export function DualHistogram({
globalMean?: number;
meanLabel?: string;
formatLabel?: (value: number) => string;
+ compact?: boolean;
+ integerAxisLabels?: boolean;
}) {
const { t } = useTranslation();
- const targetBars = 25;
+ const showCompactAxisLabels =
+ compact &&
+ isLowCardinalityHistogram(localCounts, p1, p99) &&
+ isLowCardinalityHistogram(globalCounts, p1, p99);
+ const targetBars = compact ? (showCompactAxisLabels ? localCounts.length : 16) : 25;
const localBars = downsampleBars(localCounts, targetBars);
const globalBars = downsampleBars(globalCounts, targetBars);
@@ -59,6 +103,8 @@ export function DualHistogram({
const fmt =
formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
+ if (barCount === 0) return null;
+
// Compute center value for each bar.
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier.
const middleBins = Math.max(barCount - 2, 0);
@@ -97,6 +143,60 @@ export function DualHistogram({
? { right: 0 }
: { left: '50%', transform: 'translateX(-50%)' };
+ if (compact) {
+ const axisLabels = showCompactAxisLabels
+ ? barCenters.map((center, index) =>
+ compactHistogramLabel(index, barCount, p1, p99, center, fmt, integerAxisLabels)
+ )
+ : [];
+ const chartTitle = [
+ `${fmt(p1)} - ${fmt(p99)}`,
+ globalMean != null ? `${meanLabel ?? t('areaPane.nationalAvg')}: ${fmt(globalMean)}` : null,
+ ]
+ .filter(Boolean)
+ .join('\n');
+
+ return (
+
+
+ {Array.from({ length: barCount }).map((_, index) => {
+ const globalHeight = (globalBars[index] / globalMax) * 100;
+ const localHeight = (localBars[index] / localMax) * 100;
+ return (
+
+
0 ? 8 : 0)}%` }}
+ />
+ {localBars[index] > 0 && (
+
+ )}
+
+ );
+ })}
+
+ {showCompactAxisLabels && (
+
+ {axisLabels.map((label, index) => (
+
+ {label}
+
+ ))}
+
+ )}
+
+ );
+ }
+
return (
@@ -152,35 +252,29 @@ export function DualHistogram({
function SkeletonHistogram() {
return (
-
-
-
- {Array.from({ length: 15 }).map((_, i) => (
+
+
+
+ {Array.from({ length: 12 }).map((_, i) => (
))}
-
+
);
}
export function LoadingSkeleton() {
return (
-
+
{[0, 1, 2].map((groupIdx) => (
-
-
+
+
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
))}
diff --git a/frontend/src/components/map/EnumBarChart.tsx b/frontend/src/components/map/EnumBarChart.tsx
index e8177e6..b98ef9e 100644
--- a/frontend/src/components/map/EnumBarChart.tsx
+++ b/frontend/src/components/map/EnumBarChart.tsx
@@ -1,16 +1,34 @@
import { ts } from '../../i18n/server';
import { getEnumValueColor } from '../../lib/consts';
+function shortenAxisLabel(label: string, total: number): string {
+ if (label.length <= 3) return label;
+ const parts = label.split(/[\s/&-]+/).filter(Boolean);
+ if (parts.length > 1) {
+ return parts
+ .map((part) => Array.from(part)[0])
+ .join('')
+ .slice(0, 3);
+ }
+ return Array.from(label)
+ .slice(0, total <= 5 ? 3 : 2)
+ .join('');
+}
+
export default function EnumBarChart({
counts,
globalCounts,
featureName,
+ compact = false,
}: {
counts: Record
;
globalCounts?: Record;
featureName: string;
+ compact?: boolean;
}) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
+ if (entries.length === 0) return null;
+
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
// When global counts are available, normalize both to percentages for comparison
@@ -28,6 +46,71 @@ export default function EnumBarChart({
// Fallback to raw count scaling when no global data
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
+ if (compact) {
+ const title = entries
+ .map(([label, count]) => {
+ const localPct = localTotal > 0 ? (count / localTotal) * 100 : 0;
+ const globalPct =
+ hasGlobal && globalTotal > 0 ? ((globalCounts[label] ?? 0) / globalTotal) * 100 : null;
+ return `${ts(label)}: ${count.toLocaleString()} (${localPct.toFixed(1)}%)${
+ globalPct != null ? ` / ${globalPct.toFixed(1)}%` : ''
+ }`;
+ })
+ .join('\n');
+
+ return (
+
+
+ {entries.map(([label, count]) => {
+ const localPct = localTotal > 0 ? count / localTotal : 0;
+ const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
+ const localHeight = hasGlobal
+ ? maxPct > 0
+ ? (localPct / maxPct) * 100
+ : 0
+ : (count / maxCount) * 100;
+ const globalHeight = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
+ const color = getEnumValueColor(featureName, label);
+
+ return (
+
+ {hasGlobal && (
+
0 ? 8 : 0)}%` }}
+ />
+ )}
+ {count > 0 && (
+
+ )}
+
+ );
+ })}
+
+
+ {entries.map(([label]) => {
+ const translated = ts(label);
+ return (
+
+ {shortenAxisLabel(translated, entries.length)}
+
+ );
+ })}
+
+
+ );
+ }
+
return (
{entries.map(([label, count]) => {
diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx
index 40c28d4..a3d62b6 100644
--- a/frontend/src/components/map/FeatureBrowser.tsx
+++ b/frontend/src/components/map/FeatureBrowser.tsx
@@ -86,10 +86,7 @@ export default function FeatureBrowser({
const showTravelModes =
visibleModes.length > 0 &&
- (!search ||
- 'travel time journey commute car bicycle walking transit transport station tube train'.includes(
- search.toLowerCase()
- ));
+ (!search || t('filters.travelTimeKeywords').toLowerCase().includes(search.toLowerCase()));
// Keep "Transport" first because journey and transport proximity controls belong together.
const mergedGrouped = useMemo(() => {
@@ -123,7 +120,7 @@ export default function FeatureBrowser({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
- className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
+ className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
>
{group.features.length +
diff --git a/frontend/src/components/map/HistogramLegend.tsx b/frontend/src/components/map/HistogramLegend.tsx
index 874615b..a15433d 100644
--- a/frontend/src/components/map/HistogramLegend.tsx
+++ b/frontend/src/components/map/HistogramLegend.tsx
@@ -3,35 +3,18 @@ import { useTranslation } from 'react-i18next';
export default function HistogramLegend() {
const { t } = useTranslation();
return (
-
-
-
-
-
-
- {t('histogramLegend.tealBars')}
- {' '}
- {t('histogramLegend.tealBarsDesc')}
-
-
-
-
-
-
- {t('histogramLegend.greyBars')}
- {' '}
- {t('histogramLegend.greyBarsDesc')}
-
-
-
-
-
-
- {t('histogramLegend.dashedLine')}
- {' '}
- {t('histogramLegend.dashedLineDesc')}
-
-
+
+
+
+
+ {t('histogramLegend.tealBars')}
+
+
+
+
+
+ {t('histogramLegend.greyBars')}
+
);
diff --git a/frontend/src/components/map/JourneyInstructions.test.tsx b/frontend/src/components/map/JourneyInstructions.test.tsx
index cd59579..20885d4 100644
--- a/frontend/src/components/map/JourneyInstructions.test.tsx
+++ b/frontend/src/components/map/JourneyInstructions.test.tsx
@@ -9,6 +9,7 @@ vi.mock('react-i18next', () => ({
if (key === 'areaPane.to') return `To ${values?.destination}`;
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
if (key === 'common.min') return 'min';
+ if (key === 'common.minute') return 'min';
if (key === 'common.loading') return 'Loading';
if (key === 'travel.bestCase') return 'Best case';
if (key === 'areaPane.walk') return 'Walk';
diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx
index 8365441..90da9bb 100644
--- a/frontend/src/components/map/JourneyInstructions.tsx
+++ b/frontend/src/components/map/JourneyInstructions.tsx
@@ -170,7 +170,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
)}
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes}{' '}
- {t('common.min')}
+ {t('common.minute')}
@@ -191,7 +191,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
- {leg.minutes} {t('common.min')}
+ {leg.minutes} {t('common.minute')}
{leg.from && leg.to && (
@@ -333,7 +333,7 @@ export default function JourneyInstructions({
{!j.loading && totalMin > 0 && (
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
- {totalMin} {t('common.min')}
+ {totalMin} {t('common.minute')}
)}
@@ -381,7 +381,7 @@ export default function JourneyInstructions({
)}
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
- {t('common.min')}
+ {t('common.minute')}
{showGoogleMapsLink && (
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx
index b0a353e..fa63096 100644
--- a/frontend/src/components/map/Map.tsx
+++ b/frontend/src/components/map/Map.tsx
@@ -1,6 +1,7 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
@@ -85,10 +86,10 @@ function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
-function formatListingHeadline(listing: ActualListing): string | null {
+function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
const parts: string[] = [];
- if (listing.bedrooms != null) parts.push(`${listing.bedrooms} bed`);
- if (listing.bathrooms != null) parts.push(`${listing.bathrooms} bath`);
+ if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
+ if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
if (listing.property_sub_type) parts.push(listing.property_sub_type);
else if (listing.property_type) parts.push(listing.property_type);
return parts.length > 0 ? parts.join(' · ') : null;
@@ -730,9 +731,9 @@ export default memo(function Map({
) : null}
)}
- {formatListingHeadline(listingPopup.listing) && (
+ {formatListingHeadline(listingPopup.listing, t) && (
- {formatListingHeadline(listingPopup.listing)}
+ {formatListingHeadline(listingPopup.listing, t)}
)}
{listingPopup.listing.address && (
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx
index d0d2df9..cc32247 100644
--- a/frontend/src/components/map/MapPage.tsx
+++ b/frontend/src/components/map/MapPage.tsx
@@ -6,6 +6,7 @@ import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings';
+import { buildTravelParam } from '../../lib/travel-params';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
@@ -15,7 +16,7 @@ import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
-import { apiUrl, authHeaders } from '../../lib/api';
+import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
@@ -146,6 +147,8 @@ export default function MapPage({
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
const pendingLocationSearchFlyToRef = useRef
(null);
const mobileDrawerPanelRectRef = useRef(null);
+ const areaPaneScrollTopRef = useRef(0);
+ const propertiesPaneScrollTopRef = useRef(0);
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
if (!isMobile) return undefined;
@@ -408,10 +411,15 @@ export default function MapPage({
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
- const { listings: actualListings } = useActualListings(
- mapData.bounds,
- mapData.currentView?.zoom ?? 0
+ const actualListingsFilterParam = useMemo(
+ () => buildFilterString(filters, features),
+ [filters, features]
);
+ const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
+ const { listings: actualListings } = useActualListings(mapData.bounds, {
+ filterParam: actualListingsFilterParam,
+ travelParam: actualListingsTravelParam,
+ });
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
@@ -464,11 +472,7 @@ export default function MapPage({
mapData.resolution,
areaStats
);
- const tutorial = useTutorial(
- initialLoading,
- isMobile,
- deferTutorial || mapData.licenseRequired
- );
+ const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
@@ -499,15 +503,7 @@ export default function MapPage({
entries,
shareCode
).toString(),
- [
- entries,
- features,
- filters,
- rightPaneTab,
- selectedPOICategories,
- shareCode,
- shareAndSaveView,
- ]
+ [entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
);
const handleSaveSearch = useCallback(
async (name: string) => {
@@ -564,6 +560,11 @@ export default function MapPage({
shareCode={shareCode}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
+ scrollTopRef={areaPaneScrollTopRef}
+ scrollRestoreKey={
+ selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
+ }
+ scrollSaveDisabled={loadingAreaStats && areaStats == null}
/>
);
@@ -576,6 +577,11 @@ export default function MapPage({
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
+ scrollTopRef={propertiesPaneScrollTopRef}
+ scrollRestoreKey={
+ selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
+ }
+ scrollSaveDisabled={loadingProperties && properties.length === 0}
/>
);
@@ -652,11 +658,7 @@ export default function MapPage({
};
const exportToast = (
-
+
);
const toasts = exportToast;
@@ -671,9 +673,7 @@ export default function MapPage({
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{
- strong: (
-
- ),
+ strong: ,
}}
/>
diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx
index 366c9a2..472aa03 100644
--- a/frontend/src/components/map/POIPane.tsx
+++ b/frontend/src/components/map/POIPane.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
-import { POI_CATEGORY_LOGOS } from '../../lib/consts';
+import { getPoiCategoryLogoUrl } from '../../lib/map-utils';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@@ -188,7 +188,7 @@ export default function POIPane({
{group.categories.map((category) => {
- const logo = POI_CATEGORY_LOGOS[category];
+ const logo = getPoiCategoryLogoUrl(category);
return (
void;
onNavigateToSource?: (slug: string) => void;
+ scrollTopRef?: MutableRefObject;
+ scrollRestoreKey?: string | null;
+ scrollSaveDisabled?: boolean;
}
export function PropertiesPane({
@@ -26,10 +30,18 @@ export function PropertiesPane({
hexagonId,
onLoadMore,
onNavigateToSource,
+ scrollTopRef,
+ scrollRestoreKey,
+ scrollSaveDisabled,
}: PropertiesPaneProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
+ const { scrollRef, onScroll } = useRetainedScrollTop({
+ restoreKey: scrollRestoreKey ?? hexagonId,
+ scrollTopRef,
+ suspendSave: scrollSaveDisabled ?? (loading && properties.length === 0),
+ });
useEffect(() => {
setSearch('');
@@ -60,65 +72,68 @@ export function PropertiesPane({
return (
0} />
-
- {showInfo && (
-
setShowInfo(false)}
- sourceLink={
- onNavigateToSource
- ? {
- label: t('common.viewDataSource'),
- onClick: () => {
- onNavigateToSource('epc');
- setShowInfo(false);
- },
- }
- : undefined
- }
- >
-
- {t('propertyCard.propertyDataDesc')}
-
-
- )}
-
-
-
-
-
-
- {loading && properties.length === 0 ? (
-
- ) : (
- <>
- {filtered.map((property, idx) => (
-
- ))}
- {properties.length < total && (
-
- {loading ? (
-
-
- {t('common.loading')}
-
- ) : (
- `${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
- )}
-
- )}
- >
+
+ {showInfo && (
+
setShowInfo(false)}
+ sourceLink={
+ onNavigateToSource
+ ? {
+ label: t('common.viewDataSource'),
+ onClick: () => {
+ onNavigateToSource('epc');
+ setShowInfo(false);
+ },
+ }
+ : undefined
+ }
+ >
+
+ {t('propertyCard.propertyDataDesc')}
+
+
)}
-
+
+
+
+
+
+
+ {loading && properties.length === 0 ? (
+
+ ) : (
+ <>
+ {filtered.map((property) => (
+
+ ))}
+ {properties.length < total && (
+
+ {loading ? (
+
+
+ {t('common.loading')}
+
+ ) : (
+ `${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
+ )}
+
+ )}
+ >
+ )}
+
);
diff --git a/frontend/src/components/map/StackedBarChart.tsx b/frontend/src/components/map/StackedBarChart.tsx
index b8297e5..3f2155b 100644
--- a/frontend/src/components/map/StackedBarChart.tsx
+++ b/frontend/src/components/map/StackedBarChart.tsx
@@ -12,6 +12,7 @@ interface StackedBarChartProps {
segments: Segment[];
total: number;
colorMap: Record;
+ compact?: boolean;
}
/** Strip common suffixes/prefixes to produce short legend labels */
@@ -28,7 +29,27 @@ function shortenLabel(name: string): string {
.trim();
}
-export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
+function shortenAxisLabel(name: string, total: number): string {
+ const label = shortenLabel(name);
+ if (label.length <= 3) return label;
+ const parts = label.split(/[\s/&-]+/).filter(Boolean);
+ if (parts.length > 1) {
+ return parts
+ .map((part) => Array.from(part)[0])
+ .join('')
+ .slice(0, 3);
+ }
+ return Array.from(label)
+ .slice(0, total <= 5 ? 3 : 2)
+ .join('');
+}
+
+export default function StackedBarChart({
+ segments,
+ total,
+ colorMap,
+ compact = false,
+}: StackedBarChartProps) {
const { t } = useTranslation();
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
@@ -55,6 +76,53 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
return color;
};
+ if (compact) {
+ const maxValue = Math.max(...sortedSegments.map((segment) => segment.value), 1);
+ const showAxisLabels = sortedSegments.length <= 8;
+ const title = sortedSegments
+ .map((segment, i) => {
+ const label = shortenLabel(ts(segment.name));
+ return `${label}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`;
+ })
+ .join('\n');
+
+ return (
+
+
+ {sortedSegments.map((segment) => {
+ const height = (segment.value / maxValue) * 100;
+ return (
+
+ );
+ })}
+
+ {showAxisLabels && (
+
+ {sortedSegments.map((segment) => {
+ const label = shortenLabel(ts(segment.name));
+ return (
+
+ {shortenAxisLabel(label, sortedSegments.length)}
+
+ );
+ })}
+
+ )}
+
+ );
+ }
+
return (
{/* Stacked bar */}
diff --git a/frontend/src/components/map/StackedEnumChart.tsx b/frontend/src/components/map/StackedEnumChart.tsx
index 33f2775..229091b 100644
--- a/frontend/src/components/map/StackedEnumChart.tsx
+++ b/frontend/src/components/map/StackedEnumChart.tsx
@@ -7,6 +7,7 @@ interface StackedEnumChartProps {
components: { label: string; stats: EnumFeatureStats }[];
valueOrder: string[];
valueColors: string[];
+ compact?: boolean;
}
/** Strip common suffixes to produce short row labels */
@@ -14,10 +15,24 @@ function shortenLabel(name: string): string {
return name.replace(/ risk$/, '');
}
+function shortenAxisLabel(name: string): string {
+ const label = shortenLabel(name);
+ if (label.length <= 3) return label;
+ const parts = label.split(/[\s/&-]+/).filter(Boolean);
+ if (parts.length > 1) {
+ return parts
+ .map((part) => Array.from(part)[0])
+ .join('')
+ .slice(0, 3);
+ }
+ return Array.from(label).slice(0, 3).join('');
+}
+
export default function StackedEnumChart({
components,
valueOrder,
valueColors,
+ compact = false,
}: StackedEnumChartProps) {
const { t } = useTranslation();
const visibleRows = components.filter(({ stats }) => {
@@ -35,6 +50,63 @@ export default function StackedEnumChart({
);
}
+ if (compact) {
+ return (
+
+ {visibleRows.map(({ label, stats }) => {
+ const counts = valueOrder.map((value) => stats.counts[value] ?? 0);
+ const total = counts.reduce((a, b) => a + b, 0);
+ const roundedPcts = roundedPercentages(counts, total, 0);
+ const title = valueOrder
+ .map((value, i) => `${ts(value)}: ${counts[i]} (${roundedPcts[i]}%)`)
+ .join('\n');
+
+ return (
+
+
+ {shortenLabel(ts(label))}
+
+
+ {valueOrder.map((value, i) => {
+ const count = counts[i];
+ const pct = (count / total) * 100;
+ if (pct < 0.5) return null;
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
+ {valueOrder.map((value) => (
+
+ {shortenAxisLabel(ts(value))}
+
+ ))}
+
+
+ );
+ }
+
return (
{visibleRows.map(({ label, stats }) => {
diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx
index 0e7ed63..c4974c0 100644
--- a/frontend/src/components/map/TravelTimeCard.tsx
+++ b/frontend/src/components/map/TravelTimeCard.tsx
@@ -79,7 +79,7 @@ export function TravelTimeCard({
>
{/* Header */}
-
+
{t('travel.travelTime', { mode: modes.label(mode) })}
@@ -158,10 +158,10 @@ export function TravelTimeCard({
/>
- {formatFilterValue(displayRange[0])} {t('common.min')}
+ {formatFilterValue(displayRange[0])} {t('common.minute')}
- {formatFilterValue(displayRange[1])} {t('common.min')}
+ {formatFilterValue(displayRange[1])} {t('common.minute')}
{filterImpact != null && filterImpact > 0 && (
diff --git a/frontend/src/components/map/filters/ActiveFilterList.tsx b/frontend/src/components/map/filters/ActiveFilterList.tsx
index 4cb41d1..588968e 100644
--- a/frontend/src/components/map/filters/ActiveFilterList.tsx
+++ b/frontend/src/components/map/filters/ActiveFilterList.tsx
@@ -294,7 +294,7 @@ export function ActiveFilterList({
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
- className="sticky top-0 z-10 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
+ className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
>
{count}
diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
index b7bb163..4b6736d 100644
--- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
+++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
@@ -110,7 +110,7 @@ export function ActiveFiltersPanel({
>
diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx
index 14a5768..e89b1bc 100644
--- a/frontend/src/components/map/filters/AddFilterPanel.tsx
+++ b/frontend/src/components/map/filters/AddFilterPanel.tsx
@@ -110,7 +110,7 @@ export function AddFilterPanel({
>
{t('filters.addFilter')}
@@ -122,8 +122,8 @@ export function AddFilterPanel({
{(!collapsed || !isLicensed) && (
-
- {!collapsed && (
+ {!collapsed && (
+
- )}
- {!isLicensed && (
-
-
- {t('filters.upgradePrompt')}
-
-
- {t('filters.oneTimeLifetime')}
-
-
- {t('filters.upgradeToFullMap')}
-
-
-
-
-
-
-
-
- )}
-
+
+ )}
+ {!isLicensed && (
+
+
+ {t('filters.upgradePrompt')}
+
+
+ {t('filters.oneTimeLifetime')}
+
+
+ {t('filters.upgradeToFullMap')}
+
+
+
+
+
+
+
+
+ )}
)}
diff --git a/frontend/src/components/map/filters/ClearFiltersDialog.tsx b/frontend/src/components/map/filters/ClearFiltersDialog.tsx
index 15e3052..dc72cf4 100644
--- a/frontend/src/components/map/filters/ClearFiltersDialog.tsx
+++ b/frontend/src/components/map/filters/ClearFiltersDialog.tsx
@@ -1,4 +1,4 @@
-import { useEffect, type FormEvent } from 'react';
+import { useEffect, useRef, type FormEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
@@ -30,6 +30,8 @@ export function ClearFiltersDialog({
}: ClearFiltersDialogProps) {
const { t } = useTranslation();
const isEditing = !!editingSearchName && !!onUpdateAndClear;
+ const dialogRef = useRef(null);
+ const previouslyFocusedRef = useRef(null);
useEffect(() => {
if (!open) return;
@@ -40,17 +42,41 @@ export function ClearFiltersDialog({
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
+ useEffect(() => {
+ if (!open) return;
+ previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
+ const firstFocusable = dialogRef.current?.querySelector(
+ 'input, button, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'
+ );
+ (firstFocusable ?? dialogRef.current)?.focus();
+ return () => {
+ previouslyFocusedRef.current?.focus?.();
+ };
+ }, [open]);
+
if (!open) return null;
return (
-
-
+
+
e.stopPropagation()}
>
-
+
{t('filters.clearAllTitle')}
- ),
+ strong: ,
}}
/>
diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx
index b6be0d9..2c8577c 100644
--- a/frontend/src/components/ui/AuthModal.tsx
+++ b/frontend/src/components/ui/AuthModal.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
+import { useModalA11y } from '../../hooks/useModalA11y';
type View = 'login' | 'register' | 'forgot';
@@ -34,11 +35,20 @@ export default function AuthModal({
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [resetSent, setResetSent] = useState(false);
+ const dialogRef = useModalA11y();
useEffect(() => {
trackEvent('Auth Modal Open', { tab: initialTab });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
const switchView = useCallback(
(newView: View) => {
setView(newView);
@@ -97,14 +107,26 @@ export default function AuthModal({
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
+ role="presentation"
>
-
-
+
+
{/* Header */}
-
{title}
+
+ {title}
+
diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx
index b0fd36a..d56b371 100644
--- a/frontend/src/components/ui/FeatureLabel.tsx
+++ b/frontend/src/components/ui/FeatureLabel.tsx
@@ -26,6 +26,7 @@ export function FeatureLabel({
}: FeatureLabelProps) {
const { t } = useTranslation();
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
+ const gapClass = size === 'sm' ? 'gap-2' : 'gap-1';
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
const featureIcon = getFeatureIcon(feature.name, iconClass);
@@ -56,7 +57,7 @@ export function FeatureLabel({
return (
{featureIcon}
{GroupIcon &&
}
diff --git a/frontend/src/components/ui/IndeterminateProgressBar.tsx b/frontend/src/components/ui/IndeterminateProgressBar.tsx
index 4250ab7..97e89e8 100644
--- a/frontend/src/components/ui/IndeterminateProgressBar.tsx
+++ b/frontend/src/components/ui/IndeterminateProgressBar.tsx
@@ -3,10 +3,7 @@ interface IndeterminateProgressBarProps {
className?: string;
}
-export function IndeterminateProgressBar({
- show,
- className = '',
-}: IndeterminateProgressBarProps) {
+export function IndeterminateProgressBar({ show, className = '' }: IndeterminateProgressBarProps) {
if (!show) return null;
return (
diff --git a/frontend/src/components/ui/InfoPopup.tsx b/frontend/src/components/ui/InfoPopup.tsx
index 5938b88..adbddaf 100644
--- a/frontend/src/components/ui/InfoPopup.tsx
+++ b/frontend/src/components/ui/InfoPopup.tsx
@@ -1,5 +1,7 @@
-import { useRef, useCallback, type ReactNode } from 'react';
+import { useCallback, useEffect, useId, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
import { useClickOutside } from '../../hooks/useClickOutside';
+import { useModalA11y } from '../../hooks/useModalA11y';
import { CloseIcon } from './icons';
import { IconButton } from './IconButton';
@@ -11,7 +13,8 @@ interface InfoPopupProps {
}
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
- const popupRef = useRef
(null);
+ const popupRef = useModalA11y();
+ const titleId = useId();
const handleClose = useCallback(() => {
onClose();
@@ -19,14 +22,31 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
useClickOutside(popupRef, handleClose);
- return (
-
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ const popup = (
+
-
{title}
+
+ {title}
+
@@ -43,4 +63,8 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
);
+
+ if (typeof document === 'undefined') return popup;
+
+ return createPortal(popup, document.body);
}
diff --git a/frontend/src/components/ui/LicenseSuccessModal.tsx b/frontend/src/components/ui/LicenseSuccessModal.tsx
index 074b4c4..4478965 100644
--- a/frontend/src/components/ui/LicenseSuccessModal.tsx
+++ b/frontend/src/components/ui/LicenseSuccessModal.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SpinnerIcon } from './icons/SpinnerIcon';
+import { useModalA11y } from '../../hooks/useModalA11y';
interface LicenseSuccessModalProps {
onClose: () => void;
@@ -14,6 +15,7 @@ export default function LicenseSuccessModal({
const { t } = useTranslation();
const isSuccess = status === 'success';
const isVerifying = status === 'verifying';
+ const dialogRef = useModalA11y();
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
@@ -36,6 +38,14 @@ export default function LicenseSuccessModal({
return () => clearTimeout(timer);
}, [isSuccess, onClose]);
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
const title =
status === 'verifying'
? t('licenseSuccess.verifyingTitle')
@@ -56,9 +66,12 @@ export default function LicenseSuccessModal({
: t('licenseSuccess.description');
return (
-
+
{isSuccess && (
-
+
{particles.map((p) => (
)}
-
+
{isVerifying ? (
@@ -87,7 +107,9 @@ export default function LicenseSuccessModal({
{isSuccess ? '🎉' : '✓'}
)}
-
{title}
+
+ {title}
+
{subtitle}
diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx
index 2964505..33415d8 100644
--- a/frontend/src/components/ui/MobileMenu.tsx
+++ b/frontend/src/components/ui/MobileMenu.tsx
@@ -160,7 +160,7 @@ export default function MobileMenu({
{/* Menu panel */}
-
+
{t('mobileMenu.menu')}
{
@@ -44,18 +46,32 @@ export default function SaveSearchModal({
}, [onClose]);
return (
-
-
+
+
e.stopPropagation()}
>
-
+
{saved ? t('saveSearch.saved') : t('saveSearch.title')}
diff --git a/frontend/src/components/ui/UpgradeModal.tsx b/frontend/src/components/ui/UpgradeModal.tsx
index cb99ddb..e5237e1 100644
--- a/frontend/src/components/ui/UpgradeModal.tsx
+++ b/frontend/src/components/ui/UpgradeModal.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { apiUrl, logNonAbortError } from '../../lib/api';
+import { useModalA11y } from '../../hooks/useModalA11y';
interface UpgradeModalProps {
isLoggedIn: boolean;
@@ -28,6 +29,7 @@ export default function UpgradeModal({
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [pricePence, setPricePence] = useState(null);
+ const dialogRef = useModalA11y();
useEffect(() => {
fetch(apiUrl('pricing'))
@@ -38,6 +40,14 @@ export default function UpgradeModal({
.catch((err) => logNonAbortError('Failed to fetch pricing', err));
}, []);
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onZoomToFreeZone();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onZoomToFreeZone]);
+
const priceLabel =
pricePence === null
? '...'
@@ -59,11 +69,23 @@ export default function UpgradeModal({
};
return (
-
-
+
+
{/* Close button */}
@@ -71,7 +93,9 @@ export default function UpgradeModal({
{/* Header */}
-
{t('upgrade.title')}
+
+ {t('upgrade.title')}
+
{isShareReturn ? t('upgrade.sharedAreaDescription') : t('upgrade.description')}
diff --git a/frontend/src/hooks/useActualListings.ts b/frontend/src/hooks/useActualListings.ts
index 225defc..e8c056e 100644
--- a/frontend/src/hooks/useActualListings.ts
+++ b/frontend/src/hooks/useActualListings.ts
@@ -4,9 +4,16 @@ import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
-export function useActualListings(bounds: Bounds | null) {
+interface UseActualListingsOptions {
+ filterParam?: string;
+ travelParam?: string;
+}
+
+export function useActualListings(
+ bounds: Bounds | null,
+ { filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
+) {
const [listings, setListings] = useState
([]);
- const [truncated, setTruncated] = useState(false);
const debounceRef = useRef | null>(null);
const abortControllerRef = useRef(null);
const requestIdRef = useRef(0);
@@ -18,7 +25,6 @@ export function useActualListings(bounds: Bounds | null) {
if (!bounds) {
abortControllerRef.current?.abort();
if (listings.length !== 0) setListings([]);
- if (truncated) setTruncated(false);
return;
}
@@ -30,6 +36,8 @@ export function useActualListings(bounds: Bounds | null) {
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ bounds: boundsStr });
+ if (filterParam) params.set('filters', filterParam);
+ if (travelParam) params.set('travel', travelParam);
const res = await fetch(
apiUrl('actual-listings', params),
authHeaders({ signal: abortControllerRef.current.signal })
@@ -38,7 +46,6 @@ export function useActualListings(bounds: Bounds | null) {
const json: ActualListingsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setListings(json.listings || []);
- setTruncated(Boolean(json.truncated));
} catch (err) {
logNonAbortError('Failed to fetch actual listings', err);
}
@@ -48,9 +55,9 @@ export function useActualListings(bounds: Bounds | null) {
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
- // listings/truncated intentionally excluded — they're internal state, not inputs.
+ // listings intentionally excluded — it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [bounds]);
+ }, [bounds, filterParam, travelParam]);
- return { listings, truncated };
+ return { listings };
}
diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts
index ed88ded..f15cfc5 100644
--- a/frontend/src/hooks/useDeckLayers.ts
+++ b/frontend/src/hooks/useDeckLayers.ts
@@ -111,7 +111,6 @@ export function useDeckLayers({
isDark,
hexagonData: data,
postcodeData,
- resolution: usePostcodeView ? 0 : Math.round(zoom),
usePostcodeView,
});
@@ -280,21 +279,33 @@ export function useDeckLayers({
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
+ // Per-render memo: each of getRatios0/1/2 would otherwise call distToRatios
+ // on the same row, tripling the work. Cache by row reference.
+ const ratiosCache = new WeakMap();
+ const getRatios = (d: HexagonData): number[] => {
+ let r = ratiosCache.get(d);
+ if (!r) {
+ r = distToRatios(d[distKey]);
+ ratiosCache.set(d, r);
+ }
+ return r;
+ };
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum
? {
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => {
- const r = distToRatios(d[distKey]);
+ const r = getRatios(d);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (d: HexagonData) => {
- const r = distToRatios(d[distKey]);
+ const r = getRatios(d);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
- const r = distToRatios(d[distKey]);
+ const r = getRatios(d);
return [r[8], r[9]];
},
updateTriggers: {
diff --git a/frontend/src/hooks/useFilterCounts.ts b/frontend/src/hooks/useFilterCounts.ts
index 79ed2b9..acc9e69 100644
--- a/frontend/src/hooks/useFilterCounts.ts
+++ b/frontend/src/hooks/useFilterCounts.ts
@@ -1,12 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
-import {
- apiUrl,
- buildFilterString,
- logNonAbortError,
- authHeaders,
- isAbortError,
-} from '../lib/api';
+import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import type { TravelTimeEntry } from './useTravelTime';
import { buildTravelParam } from '../lib/travel-params';
diff --git a/frontend/src/hooks/useHexagonSelection.ts b/frontend/src/hooks/useHexagonSelection.ts
index a846fe9..7dc4d94 100644
--- a/frontend/src/hooks/useHexagonSelection.ts
+++ b/frontend/src/hooks/useHexagonSelection.ts
@@ -206,7 +206,6 @@ export function useHexagonSelection({
const params = new URLSearchParams({
h3,
resolution: res.toString(),
- limit: '100',
offset: offset.toString(),
});
@@ -250,7 +249,6 @@ export function useHexagonSelection({
try {
const params = new URLSearchParams({
postcode,
- limit: '100',
offset: offset.toString(),
});
if (focusAddress && offset === 0) {
diff --git a/frontend/src/hooks/useListingLayers.ts b/frontend/src/hooks/useListingLayers.ts
index 7afafce..3e0f0b7 100644
--- a/frontend/src/hooks/useListingLayers.ts
+++ b/frontend/src/hooks/useListingLayers.ts
@@ -45,31 +45,46 @@ export function useListingLayers({
}: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState(null);
- const visibleListings = useMemo(() => {
- if (listings.length === 0) return listings;
- if (usePostcodeView) {
- const allowed = new Set();
- for (const feature of postcodeData) {
- if (feature.properties.count > 0) {
- allowed.add(normalizePostcode(feature.properties.postcode));
- }
- }
- if (allowed.size === 0) return [];
- return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
- }
+ // Split into two memos so the inactive view's data changes don't invalidate
+ // the active filtered list. (e.g. in postcode view, hexagonData updates must
+ // not retrigger filtering / downstream layer rebuilds.)
+ const postcodeFilteredListings = useMemo(() => {
+ if (!usePostcodeView || listings.length === 0) return null;
const allowed = new Set();
- for (const cell of hexagonData) {
- if (cell.count > 0) allowed.add(cell.h3);
+ for (const feature of postcodeData) {
+ if (feature.properties.count > 0) {
+ allowed.add(normalizePostcode(feature.properties.postcode));
+ }
}
if (allowed.size === 0) return [];
+ return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
+ }, [listings, postcodeData, usePostcodeView]);
+
+ const hexFilteredListings = useMemo(() => {
+ if (usePostcodeView || listings.length === 0) return null;
+ const allowed = new Set();
+ let cellResolution: number | null = null;
+ for (const cell of hexagonData) {
+ if (cell.count > 0) {
+ allowed.add(cell.h3);
+ if (cellResolution == null) cellResolution = getResolution(cell.h3);
+ }
+ }
+ if (allowed.size === 0 || cellResolution == null) return [];
+ const resolutionForLookup = cellResolution;
return listings.filter((listing) => {
try {
- return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
+ return allowed.has(latLngToCell(listing.lat, listing.lon, resolutionForLookup));
} catch {
return false;
}
});
- }, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
+ }, [listings, hexagonData, usePostcodeView]);
+
+ const visibleListings = useMemo(() => {
+ if (listings.length === 0) return listings;
+ return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? [];
+ }, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]);
const handleHover = useCallback((info: PickingInfo) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
diff --git a/frontend/src/hooks/useLocationSearch.ts b/frontend/src/hooks/useLocationSearch.ts
index 6f492c3..75c104c 100644
--- a/frontend/src/hooks/useLocationSearch.ts
+++ b/frontend/src/hooks/useLocationSearch.ts
@@ -127,7 +127,7 @@ export function useLocationSearch(mode?: string) {
const controller = new AbortController();
abortRef.current = controller;
try {
- const params = new URLSearchParams({ q: trimmed, limit: '20' });
+ const params = new URLSearchParams({ q: trimmed });
if (mode) params.set('mode', mode);
const res = await fetch(
`/api/places?${params}`,
diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts
index f9fade3..7188030 100644
--- a/frontend/src/hooks/useMapData.ts
+++ b/frontend/src/hooks/useMapData.ts
@@ -26,8 +26,8 @@ import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/
import { type TravelTimeEntry } from './useTravelTime';
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
-/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
-function percentile(sorted: number[], p: number): number {
+/** Return the p-th percentile (0–100) from a sorted typed array via linear interpolation. */
+function percentile(sorted: Float64Array, p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = (p / 100) * (sorted.length - 1);
@@ -262,10 +262,20 @@ export function useMapData({
useEffect(() => {
if (!activeFeature || !activeDragRequest) return;
+ // Abort any in-flight previous drag fetch before starting a new one.
if (dragAbortRef.current) dragAbortRef.current.abort();
- dragAbortRef.current = new AbortController();
+
+ // Capture the controller locally so this effect's cleanup unambiguously
+ // aborts THIS request's controller, even if `dragAbortRef.current` has
+ // been swapped by a subsequent effect run.
+ const controller = new AbortController();
+ dragAbortRef.current = controller;
+ const { signal } = controller;
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
+ // Capture activeFeature in a local so the async .then() callback cannot
+ // observe a stale-or-newer value via closure surprise.
+ const effectActiveFeature = activeFeature;
latestDragRequestKeyRef.current = requestKey;
setDragDataKey('');
dragFeatureRef.current = null;
@@ -278,14 +288,15 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
- fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
+ fetch(apiUrl('postcodes', params), authHeaders({ signal }))
.then((res) => res.json())
.then((json: { features: PostcodeFeature[] }) => {
+ if (signal.aborted) return;
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragPostcodeData(json.features);
setDragHexData(null);
setDragDataKey(requestKey);
- dragFeatureRef.current = activeFeature;
+ dragFeatureRef.current = effectActiveFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
} else {
@@ -299,31 +310,36 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
- fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
+ fetch(apiUrl('hexagons', params), authHeaders({ signal }))
.then((res) => res.json())
.then((json: ApiResponse) => {
+ if (signal.aborted) return;
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragHexData(json.features);
setDragPostcodeData(null);
setDragDataKey(requestKey);
- dragFeatureRef.current = activeFeature;
+ dragFeatureRef.current = effectActiveFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
}
return () => {
- if (dragAbortRef.current) {
- dragAbortRef.current.abort();
+ // Abort the controller captured by THIS effect run rather than reading
+ // from the ref (which may already have been replaced by a newer run).
+ controller.abort();
+ if (dragAbortRef.current === controller) {
dragAbortRef.current = null;
}
- if (latestDragRequestKeyRef.current === requestKey) {
- latestDragRequestKeyRef.current = '';
- }
+ // Do not clear latestDragRequestKeyRef here: a newer effect run will
+ // overwrite it with its own requestKey, and clearing it would create a
+ // brief window in which a late-resolving fetch from this run could pass
+ // the staleness check against an empty key.
};
}, [
activeFeature,
activeDragRequest,
dataViewFeature,
+ resolution,
usePostcodeView,
viewFeatureIsEnum,
shareCode,
@@ -538,10 +554,14 @@ export function useMapData({
}
if (vals.length === 0) return null;
- vals.sort((a, b) => a - b);
+ // Typed-array sort uses the engine's optimized numeric sort with no
+ // per-element comparator call — measurably faster than `vals.sort((a,b)=>a-b)`
+ // for the 5k–10k samples a busy viewport produces.
+ const sorted = Float64Array.from(vals);
+ sorted.sort();
return [
- percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
- percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
+ percentile(sorted, COLOR_RANGE_LOW_PERCENTILE),
+ percentile(sorted, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [
bounds,
diff --git a/frontend/src/hooks/useModalA11y.ts b/frontend/src/hooks/useModalA11y.ts
new file mode 100644
index 0000000..8b56119
--- /dev/null
+++ b/frontend/src/hooks/useModalA11y.ts
@@ -0,0 +1,64 @@
+import { useEffect, useRef } from 'react';
+
+/**
+ * Shared modal accessibility behavior: locks body scroll, traps Tab focus
+ * inside the dialog, restores focus on unmount, and focuses the first
+ * focusable element (or the dialog itself) on mount.
+ */
+export function useModalA11y(): React.RefObject {
+ const dialogRef = useRef(null);
+
+ useEffect(() => {
+ const previouslyFocused = document.activeElement as HTMLElement | null;
+ const dialog = dialogRef.current;
+ const focusableSelector =
+ 'input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
+
+ const firstFocusable = dialog?.querySelector(focusableSelector);
+ (firstFocusable ?? dialog)?.focus();
+
+ // Lock body scroll while preserving scroll position.
+ const prevOverflow = document.body.style.overflow;
+ const prevPaddingRight = document.body.style.paddingRight;
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
+ document.body.style.overflow = 'hidden';
+ if (scrollbarWidth > 0) {
+ document.body.style.paddingRight = `${scrollbarWidth}px`;
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== 'Tab' || !dialog) return;
+ const focusables = Array.from(dialog.querySelectorAll(focusableSelector)).filter(
+ (el) => el.offsetParent !== null || el === document.activeElement
+ );
+ if (focusables.length === 0) {
+ e.preventDefault();
+ dialog.focus();
+ return;
+ }
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+ const active = document.activeElement as HTMLElement | null;
+ if (e.shiftKey) {
+ if (active === first || !dialog.contains(active)) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else if (active === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = prevOverflow;
+ document.body.style.paddingRight = prevPaddingRight;
+ previouslyFocused?.focus?.();
+ };
+ }, []);
+
+ return dialogRef;
+}
diff --git a/frontend/src/hooks/useNearbyStations.ts b/frontend/src/hooks/useNearbyStations.ts
new file mode 100644
index 0000000..d9a6ade
--- /dev/null
+++ b/frontend/src/hooks/useNearbyStations.ts
@@ -0,0 +1,59 @@
+import { useEffect, useState } from 'react';
+
+import type { NearbyStation, GeoPoint } from '../lib/nearby-stations';
+import {
+ STATION_CATEGORIES,
+ selectNearbyStations,
+ stationSearchBounds,
+} from '../lib/nearby-stations';
+import type { POIResponse } from '../types';
+import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../lib/api';
+
+function boundsParam(bounds: ReturnType): string {
+ return `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
+}
+
+export function useNearbyStations(origin: GeoPoint | null) {
+ const [stations, setStations] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const lat = origin?.lat;
+ const lon = origin?.lon;
+
+ useEffect(() => {
+ if (lat == null || lon == null) {
+ setStations([]);
+ setLoading(false);
+ return;
+ }
+
+ const controller = new AbortController();
+ setStations([]);
+ setLoading(true);
+
+ const originPoint = { lat, lon };
+ const bounds = stationSearchBounds(originPoint);
+ const params = new URLSearchParams({
+ bounds: boundsParam(bounds),
+ categories: STATION_CATEGORIES.join(','),
+ });
+
+ fetch(apiUrl('pois', params), authHeaders({ signal: controller.signal }))
+ .then((response) => {
+ assertOk(response, 'nearby stations');
+ return response.json() as Promise;
+ })
+ .then((json) => {
+ if (!controller.signal.aborted) {
+ setStations(selectNearbyStations(json.pois ?? [], originPoint));
+ }
+ })
+ .catch((error) => logNonAbortError('Failed to fetch nearby stations', error))
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+
+ return () => controller.abort();
+ }, [lat, lon]);
+
+ return { stations, loading };
+}
diff --git a/frontend/src/hooks/usePoiLayers.ts b/frontend/src/hooks/usePoiLayers.ts
index 912b73e..18be1d6 100644
--- a/frontend/src/hooks/usePoiLayers.ts
+++ b/frontend/src/hooks/usePoiLayers.ts
@@ -44,7 +44,7 @@ function getPoiIconUrlForPoi(poi: POI): string {
}
function isBundledPoiIcon(url: string): boolean {
- return url.startsWith('/assets/poi-icons/');
+ return url.startsWith('/assets/poi-icons/') || url.startsWith('data:image/svg+xml');
}
function hasBundledPoiLogo(poi: POI): boolean {
diff --git a/frontend/src/hooks/useRetainedScrollTop.test.tsx b/frontend/src/hooks/useRetainedScrollTop.test.tsx
new file mode 100644
index 0000000..592177e
--- /dev/null
+++ b/frontend/src/hooks/useRetainedScrollTop.test.tsx
@@ -0,0 +1,80 @@
+import { cleanup, fireEvent, render } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import type { MutableRefObject } from 'react';
+
+import { useRetainedScrollTop } from './useRetainedScrollTop';
+
+function ScrollPane({
+ restoreKey,
+ savedScrollTopRef,
+ suspendSave,
+}: {
+ restoreKey: string;
+ savedScrollTopRef: MutableRefObject;
+ suspendSave: boolean;
+}) {
+ const { scrollRef, onScroll } = useRetainedScrollTop({
+ restoreKey,
+ scrollTopRef: savedScrollTopRef,
+ suspendSave,
+ });
+
+ return (
+
+ );
+}
+
+describe('useRetainedScrollTop', () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('keeps the saved scroll offset while replacement content is loading', () => {
+ const savedScrollTopRef = { current: 0 };
+ const view = render(
+
+ );
+ const pane = view.getByTestId('pane');
+
+ pane.scrollTop = 360;
+ fireEvent.scroll(pane);
+ expect(savedScrollTopRef.current).toBe(360);
+
+ view.rerender(
+
+ );
+
+ pane.scrollTop = 0;
+ fireEvent.scroll(pane);
+ expect(savedScrollTopRef.current).toBe(360);
+
+ view.rerender(
+
+ );
+
+ expect(pane.scrollTop).toBe(360);
+ });
+
+ it('restores the saved offset when a pane remounts', () => {
+ const savedScrollTopRef = { current: 220 };
+ const view = render(
+
+ );
+
+ expect(view.getByTestId('pane').scrollTop).toBe(220);
+ });
+});
diff --git a/frontend/src/hooks/useRetainedScrollTop.ts b/frontend/src/hooks/useRetainedScrollTop.ts
new file mode 100644
index 0000000..42dfb60
--- /dev/null
+++ b/frontend/src/hooks/useRetainedScrollTop.ts
@@ -0,0 +1,63 @@
+import { useCallback, useLayoutEffect, useRef, type MutableRefObject, type UIEvent } from 'react';
+
+interface UseRetainedScrollTopOptions {
+ restoreKey: string | null;
+ scrollTopRef?: MutableRefObject;
+ suspendSave?: boolean;
+}
+
+export function useRetainedScrollTop({
+ restoreKey,
+ scrollTopRef,
+ suspendSave = false,
+}: UseRetainedScrollTopOptions) {
+ const fallbackScrollTopRef = useRef(0);
+ const savedScrollTopRef = scrollTopRef ?? fallbackScrollTopRef;
+ const nodeRef = useRef(null);
+ const previousRestoreKeyRef = useRef(restoreKey);
+ const pendingRestoreTopRef = useRef(null);
+ const suspendSaveRef = useRef(suspendSave);
+
+ suspendSaveRef.current = suspendSave;
+
+ const scrollRef = useCallback(
+ (node: T | null) => {
+ const previousNode = nodeRef.current;
+ if (!node && previousNode && !suspendSaveRef.current) {
+ savedScrollTopRef.current = previousNode.scrollTop;
+ }
+
+ nodeRef.current = node;
+ if (node) {
+ node.scrollTop = pendingRestoreTopRef.current ?? savedScrollTopRef.current;
+ }
+ },
+ [savedScrollTopRef]
+ );
+
+ const onScroll = useCallback(
+ (event: UIEvent) => {
+ if (suspendSaveRef.current) return;
+ savedScrollTopRef.current = event.currentTarget.scrollTop;
+ },
+ [savedScrollTopRef]
+ );
+
+ useLayoutEffect(() => {
+ if (previousRestoreKeyRef.current !== restoreKey) {
+ previousRestoreKeyRef.current = restoreKey;
+ pendingRestoreTopRef.current = savedScrollTopRef.current;
+ }
+
+ const node = nodeRef.current;
+ const pendingRestoreTop = pendingRestoreTopRef.current;
+ if (!node || pendingRestoreTop == null) return;
+
+ node.scrollTop = pendingRestoreTop;
+ if (!suspendSave) {
+ pendingRestoreTopRef.current = null;
+ }
+ }, [restoreKey, savedScrollTopRef, suspendSave]);
+
+ return { scrollRef, onScroll };
+}
diff --git a/frontend/src/hooks/useSavedSearches.ts b/frontend/src/hooks/useSavedSearches.ts
index bfa7787..c69d5bd 100644
--- a/frontend/src/hooks/useSavedSearches.ts
+++ b/frontend/src/hooks/useSavedSearches.ts
@@ -12,8 +12,16 @@ export interface SavedSearch {
created: string;
}
-const POLL_INTERVAL_MS = 2000;
-const MAX_POLL_ATTEMPTS = 15;
+// Exponential backoff: 2s, 3s, 4s, 6s, 8s, 12s, ... capped at 15s.
+// Caps total wait under a minute while staying responsive for fast jobs.
+const POLL_BASE_MS = 2000;
+const POLL_MAX_MS = 15000;
+const POLL_BACKOFF = 1.5;
+const MAX_POLL_ATTEMPTS = 8;
+
+function nextPollDelay(attempt: number): number {
+ return Math.min(POLL_MAX_MS, Math.round(POLL_BASE_MS * Math.pow(POLL_BACKOFF, attempt)));
+}
export function useSavedSearches(userId: string | null) {
const [searches, setSearches] = useState([]);
@@ -21,14 +29,16 @@ export function useSavedSearches(userId: string | null) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
- const pollTimerRef = useRef | null>(null);
+ const pollTimerRef = useRef | null>(null);
const pollAttemptsRef = useRef(0);
+ const pollInFlightRef = useRef(false);
+ const isMountedRef = useRef(true);
const userIdRef = useRef(userId);
userIdRef.current = userId;
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
- clearInterval(pollTimerRef.current);
+ clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
pollAttemptsRef.current = 0;
@@ -37,6 +47,15 @@ export function useSavedSearches(userId: string | null) {
// Clean up polling on unmount or userId change
useEffect(() => stopPolling, [userId, stopPolling]);
+ // Mark the hook as unmounted so late-arriving async work doesn't touch state
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ stopPolling();
+ };
+ }, [stopPolling]);
+
const fetchRecords = useCallback(async (uid: string): Promise => {
const records = await pb.collection('saved_searches').getFullList({
sort: '-created',
@@ -57,28 +76,41 @@ export function useSavedSearches(userId: string | null) {
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
pollAttemptsRef.current = 0;
- pollTimerRef.current = setInterval(async () => {
+ pollInFlightRef.current = false;
+
+ const scheduleNext = () => {
+ if (!isMountedRef.current) return;
+ const delay = nextPollDelay(pollAttemptsRef.current);
+ pollTimerRef.current = setTimeout(tick, delay);
+ };
+
+ const tick = async () => {
+ pollTimerRef.current = null;
+ if (pollInFlightRef.current) {
+ scheduleNext();
+ return;
+ }
const uid = userIdRef.current;
- if (!uid) {
- stopPolling();
- return;
- }
+ if (!uid) return;
pollAttemptsRef.current++;
- if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
- stopPolling();
- return;
- }
+ if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return;
+ pollInFlightRef.current = true;
try {
const mapped = await fetchRecords(uid);
+ if (!isMountedRef.current) return;
setSearches(mapped);
- if (!mapped.some((s) => !s.screenshotUrl)) {
- stopPolling();
- }
+ if (!mapped.some((s) => !s.screenshotUrl)) return;
+ scheduleNext();
} catch {
- // Silent — background poll errors don't surface to UI
+ // Silent — background poll errors don't surface to UI; keep trying.
+ if (isMountedRef.current) scheduleNext();
+ } finally {
+ pollInFlightRef.current = false;
}
- }, POLL_INTERVAL_MS);
- }, [stopPolling, fetchRecords]);
+ };
+
+ scheduleNext();
+ }, [fetchRecords]);
const fetchSearches = useCallback(async () => {
if (!userId) return;
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index 6f3b396..c441c96 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -25,21 +25,28 @@ const de: Translations = {
total: 'Gesamt',
min: 'Min.',
max: 'Max.',
+ minute: 'Min.',
or: 'oder',
area: 'Gebiet',
properties: 'Immobilien',
postcode: 'Postleitzahl',
noAreaSelected: 'Kein Gebiet ausgewählt',
noAreaSelectedDesc:
- 'Klicke auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
+ 'Klicken Sie auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
clickForDetails: 'Für Details klicken',
property: 'Immobilie',
propertiesPlural: 'Immobilien',
+ bedsCount: '{{count}} Schlafzimmer',
+ bedsCount_other: '{{count}} Schlafzimmer',
+ bathsCount: '{{count}} Bad',
+ bathsCount_other: '{{count}} Bäder',
places: 'Orte',
noData: 'Keine Daten',
allLow: 'Alles niedrig',
connectingToServer: 'Verbindung zum Server...',
closePane: 'Bereich schließen',
+ yes: 'Ja',
+ no: 'Nein',
},
// ── Header / Nav ───────────────────────────────────
@@ -310,8 +317,7 @@ const de: Translations = {
'Family trade-offs to compare': 'Familienkompromisse zum Vergleich',
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
'Kombinieren Sie Schulen mit Parks, Straßenlärm, Kriminalität, Wohnfläche, Pendelweg, Breitband und Preis, damit die Auswahlliste den gesamten Umzug widerspiegelt.',
- 'Does this show school catchment guarantees?':
- 'Zeigt dies garantierte Schul-Einzugsgebiete?',
+ 'Does this show school catchment guarantees?': 'Zeigt dies garantierte Schul-Einzugsgebiete?',
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
'Nein. Es hilft dabei, vielversprechende Gebiete zu identifizieren, Einzugsgebiete und Zulassungen müssen jedoch bei der Schule oder der örtlichen Behörde überprüft werden.',
'Can I combine school filters with parks and safety?':
@@ -480,8 +486,7 @@ const de: Translations = {
'Wie Konto- und gespeicherte Suchdaten im Produkt verarbeitet werden.',
'Compare Bristol postcodes': 'Vergleichen Sie die Postleitzahlen von Bristol',
'Trust and coverage': 'Vertrauen und Abdeckung',
- 'Perfect Postcode data sources and coverage':
- 'Perfect Postcode – Datenquellen und Abdeckung',
+ 'Perfect Postcode data sources and coverage': 'Perfect Postcode – Datenquellen und Abdeckung',
'Perfect Postcode data sources - Property, schools, commute and local context':
'Perfect Postcode – Datenquellen: Immobilien, Schulen, Pendelweg und lokaler Kontext',
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
@@ -501,8 +506,7 @@ const de: Translations = {
'Travel-time data': 'Reisezeitdaten',
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
'Reisezeitfilter sind für einen konsistenten Gebietsvergleich konzipiert. Bevor Sie sich für ein Gebiet entscheiden, sollten Sie Routenverfügbarkeit, Störungen, Parkmöglichkeiten, Fußläufigkeit und Fahrplandetails überprüfen.',
- 'Why does coverage focus on England?':
- 'Warum konzentriert sich die Abdeckung auf England?',
+ 'Why does coverage focus on England?': 'Warum konzentriert sich die Abdeckung auf England?',
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
'Mehrere zentrale Datensätze zu Immobilien, Bildung und lokalem Kontext sind jurisdiktionsspezifisch. Eine Abdeckung von England sorgt für konsistentere Vergleiche.',
'How should I handle stale or missing data?':
@@ -588,15 +592,15 @@ const de: Translations = {
createAccount: 'Konto erstellen',
resetPassword: 'Passwort zurücksetzen',
valueProp:
- 'Speichere Suchen, merke dir Immobilien und erstelle eine Auswahlliste passender Gebiete.',
+ 'Speichern Sie Suchen, merken Sie sich Immobilien und erstellen Sie eine Auswahlliste passender Gebiete.',
continueWithGoogle: 'Weiter mit Google',
email: 'E-Mail',
- emailPlaceholder: 'du@beispiel.de',
+ emailPlaceholder: 'name@beispiel.de',
password: 'Passwort',
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
- passwordPlaceholderLogin: 'Dein Passwort',
+ passwordPlaceholderLogin: 'Ihr Passwort',
forgotPassword: 'Passwort vergessen?',
- resetSent: 'Prüfe deine E-Mails für einen Link zum Zurücksetzen.',
+ resetSent: 'Prüfen Sie Ihre E-Mails für einen Link zum Zurücksetzen.',
pleaseWait: 'Bitte warten...',
sendResetLink: 'Link zum Zurücksetzen senden',
backToLogin: 'Zurück zur Anmeldung',
@@ -606,7 +610,7 @@ const de: Translations = {
upgrade: {
title: 'Jede passende Postleitzahl finden',
description:
- 'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
+ 'Sie erkunden gerade das Demogebiet. Erhalten Sie lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
free: 'Kostenlos',
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
@@ -618,7 +622,7 @@ const de: Translations = {
continueWithDemo: 'Mit Demo fortfahren',
backToSharedArea: 'Zurück zum geteilten Gebiet',
sharedAreaDescription:
- 'Du siehst ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichere dir lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
+ 'Sie sehen ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichern Sie sich lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
},
@@ -626,7 +630,7 @@ const de: Translations = {
saveSearch: {
title: 'Suche speichern',
saved: 'Suche gespeichert',
- savedSuccess: 'Deine Suche wurde erfolgreich gespeichert.',
+ savedSuccess: 'Ihre Suche wurde erfolgreich gespeichert.',
viewSavedSearches: 'Gespeicherte Suchen ansehen',
name: 'Name',
namePlaceholder: 'Meine Suche',
@@ -636,15 +640,15 @@ const de: Translations = {
// ── License Success ────────────────────────────────
licenseSuccess: {
verifyingTitle: 'Zugang wird geprüft',
- verifyingSubtitle: 'Wir prüfen dein Konto, bevor wir die Karte freischalten.',
+ verifyingSubtitle: 'Wir prüfen Ihr Konto, bevor wir die Karte freischalten.',
verifyingDescription: 'Das dauert nach dem Bezahlen normalerweise nur ein paar Sekunden.',
activationDelayedTitle: 'Zahlung erhalten',
activationDelayedSubtitle: 'Der Zugang wird noch aktiviert.',
activationDelayedDescription:
- 'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisiere gleich noch einmal oder kontaktiere den Support, falls der Zugang nicht erscheint.',
+ 'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisieren Sie gleich noch einmal oder kontaktieren Sie den Support, falls der Zugang nicht erscheint.',
stayOnPricing: 'Auf der Preisseite bleiben',
- title: 'Du bist dabei.',
- subtitle: 'Dein lebenslanger Zugang ist jetzt aktiv.',
+ title: 'Sie sind dabei.',
+ subtitle: 'Ihr lebenslanger Zugang ist jetzt aktiv.',
description: 'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
startExploring: 'Jetzt entdecken',
},
@@ -655,18 +659,18 @@ const de: Translations = {
addFilter: 'Filter hinzufügen',
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
addFiltersHint:
- 'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
+ 'Fügen Sie unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die Ihren Kriterien entsprechen',
upgradePrompt:
- 'Finde passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
+ 'Finden Sie passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
upgradeToFullMap: 'Zur Vollversion upgraden',
chooseFilters:
- 'Klicke auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
+ 'Klicken Sie auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
searchFeatures: 'Filter durchsuchen...',
noMatchingFeatures: 'Keine passenden Filter',
- tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
+ tryDifferentSearch: 'Versuchen Sie einen anderen Suchbegriff',
allFeaturesActive: 'Alle Filter sind aktiv',
- removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
+ removeFilterHint: 'Entfernen Sie einen Filter, um verfügbare Merkmale zu sehen',
featureInfo: 'Über diese Daten',
aboutData: 'Über diese Daten',
aboutDataShort: 'Info',
@@ -679,7 +683,7 @@ const de: Translations = {
replayTutorial: 'Interaktives Tutorial erneut abspielen',
clearAll: 'Alle löschen',
clearAllTitle: 'Alle Filter löschen?',
- clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
+ clearAllSavePrompt: 'Möchten Sie Ihre aktuellen Filter vor dem Löschen speichern?',
clearAllUpdatePrompt:
'{{name}} mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
saveAndClear: 'Speichern & löschen',
@@ -700,12 +704,14 @@ const de: Translations = {
ethnicity: 'Ethnie',
poiType: 'POI-Typ',
party: 'Partei',
+ travelTimeKeywords:
+ 'Reisezeit Fahrzeit Pendelzeit Pendeln Fahrt Reise Auto Fahrrad Rad Radfahren zu Fuß Gehen ÖPNV Verkehr Transport Bahnhof Bahn U-Bahn S-Bahn Zug Bus Straßenbahn öffentlich Route travel time journey commute car bicycle bike walking transit transport station train tube bus metro rail route',
},
// ── Philosophy Popup ───────────────────────────────
philosophy: {
intro:
- 'Beginne mit deinen Muss-Kriterien, dann füge Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn du Filter hinzufügst. Die verbleibenden Gebiete sind deine besten Treffer.',
+ 'Beginnen Sie mit Ihren Muss-Kriterien, dann fügen Sie Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn Sie Filter hinzufügen. Die verbleibenden Gebiete sind Ihre besten Treffer.',
step1Title: 'Budget und Grundlagen',
step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
step2Title: 'Pendelweg',
@@ -718,7 +724,7 @@ const de: Translations = {
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
step6Title: 'Energie',
step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
- tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
+ tip: 'Tipp: Wenn nichts passt, lockern Sie eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
},
// ── Travel Time ────────────────────────────────────
@@ -755,14 +761,14 @@ const de: Translations = {
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
mainDesc: 'Zeigt die Reisezeit vom ausgewählten Ziel zu jedem Gebiet.',
- sliderHint: 'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
+ sliderHint: 'Verwenden Sie den Schieberegler, um Ihre maximale Pendelzeit festzulegen.',
},
// ── AI Filter ──────────────────────────────────────
aiFilter: {
- describeIdealArea: 'Beschreibe, wo du leben möchtest',
+ describeIdealArea: 'Beschreiben Sie, wo Sie leben möchten',
aiSearch: 'KI-Suche',
- describeHint: 'beschreibe, wonach du suchst',
+ describeHint: 'beschreiben Sie, wonach Sie suchen',
placeholder: 'z. B. 2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhig...',
example1: '2 Schlafzimmer unter £525k, 45 Min. zur Arbeit',
example2: 'Familienfreundliche Gebiete nahe guten Schulen unter £650k',
@@ -772,7 +778,7 @@ const de: Translations = {
generatingFilters: 'Filter werden generiert...',
refiningResults: 'Ergebnisse werden verfeinert...',
weeklyLimitReached:
- 'Du hast das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
+ 'Sie haben das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
},
// ── Map Legend ─────────────────────────────────────
@@ -836,6 +842,8 @@ const de: Translations = {
showAllStatsFallback:
'Wechseln Sie zu allen Immobilien, um dieses Gebiet ohne aktive Filter zu prüfen.',
showAllStats: 'Alle Immobilien anzeigen',
+ closestStations: 'Nächste Bahnhöfe',
+ noNearbyStations: 'Keine Bahn- oder U-Bahn-Station im Umkreis von 2 km',
closestBlockingFilters: 'Nächste Änderungen, um dieses Gebiet einzuschließen',
lowerMinTo: 'Minimum auf {{value}} senken',
raiseMaxTo: 'Maximum auf {{value}} erhöhen',
@@ -1268,7 +1276,7 @@ const de: Translations = {
upgrade: 'Upgraden',
redirecting: 'Weiterleitung…',
receiveNewsletter: 'Newsletter-E-Mails erhalten',
- needHelp: 'Brauchst du Hilfe? Schreib uns an',
+ needHelp: 'Brauchen Sie Hilfe? Schreiben Sie uns an',
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
shareLinksTitle: 'Geteilte Links',
noShareLinksYet: 'Noch keine geteilten Links',
@@ -1281,12 +1289,12 @@ const de: Translations = {
searches: 'Suchen',
noSavedSearches: 'Noch keine gespeicherten Suchen',
noSavedSearchesDesc:
- 'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
+ 'Speichern Sie Ihre Filter und Kartenansicht, um genau dort weiterzumachen, wo Sie aufgehört haben.',
clickToRename: 'Klicken zum Umbenennen',
- notesPlaceholder: 'Notiere deine Gedanken...',
+ notesPlaceholder: 'Notieren Sie Ihre Gedanken...',
deleteSearch: 'Suche löschen',
deleteSearchConfirm:
- 'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
+ 'Möchten Sie diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
isBeingUpdated: '{{name}} wird aktualisiert',
updating: 'Aktualisiere...',
},
@@ -1301,7 +1309,7 @@ const de: Translations = {
copyInviteLink: 'Einladungslink kopieren',
adminInvitesTitle: 'Admin-Einladungen (100% Rabatt)',
referralInvitesTitle: 'Empfehlungseinladungen (30% Rabatt)',
- yourInviteLinks: 'Deine Einladungslinks',
+ yourInviteLinks: 'Ihre Einladungslinks',
noInvitesYet: 'Noch keine Einladungen erstellt',
link: 'Link',
status: 'Status',
@@ -1312,13 +1320,13 @@ const de: Translations = {
// ── Invite Page ────────────────────────────────────
invitePage: {
- youreInvited: 'Du bist eingeladen!',
+ youreInvited: 'Sie sind eingeladen!',
specialOffer: 'Sonderangebot!',
- invitedByFree: '{{name}} hat dich eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
- invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
- genericFreeInvite: 'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
- genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
- exploreEvery: 'Finde Postleitzahlen, die zu deinem Leben passen',
+ invitedByFree: '{{name}} hat Sie eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
+ invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
+ genericFreeInvite: 'Sie wurden eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
+ genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
+ exploreEvery: 'Finden Sie Postleitzahlen, die zu Ihrem Leben passen',
propertyInfo: 'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
invalidInvite: 'Ungültige Einladung',
inviteAlreadyUsed: 'Einladung bereits verwendet',
@@ -1326,13 +1334,13 @@ const de: Translations = {
invalidInviteLink: 'Ungültiger Einladungslink',
invalidInviteLinkDesc: 'Dieser Einladungslink ist ungültig oder abgelaufen.',
licenseActivated: 'Lizenz aktiviert!',
- fullAccessGranted: 'Du hast jetzt vollen Zugang zu Perfect Postcode.',
+ fullAccessGranted: 'Sie haben jetzt vollen Zugang zu Perfect Postcode.',
activating: 'Wird aktiviert...',
activateLicense: 'Lizenz aktivieren',
claimDiscount: 'Rabatt einlösen',
registerToClaim: 'Registrieren zum Einlösen',
- youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
- accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
+ youAlreadyHaveLicense: 'Sie haben bereits eine Lizenz',
+ accountHasFullAccess: 'Ihr Konto hat bereits vollen Zugang.',
failedToValidate: 'Einladungslink konnte nicht validiert werden',
},
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index a6f25c1..8a83f36 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -23,6 +23,7 @@ const en = {
total: 'Total',
min: 'min',
max: 'max',
+ minute: 'min',
or: 'or',
area: 'Area',
properties: 'Properties',
@@ -33,11 +34,17 @@ const en = {
clickForDetails: 'Click for details',
property: 'property',
propertiesPlural: 'properties',
+ bedsCount: '{{count}} bed',
+ bedsCount_other: '{{count}} beds',
+ bathsCount: '{{count}} bath',
+ bathsCount_other: '{{count}} baths',
places: 'places',
noData: 'No data',
allLow: 'All low',
connectingToServer: 'Connecting to server...',
closePane: 'Close pane',
+ yes: 'Yes',
+ no: 'No',
},
// ── Header / Nav ───────────────────────────────────
@@ -653,7 +660,8 @@ const en = {
clearAll: 'Clear all',
clearAllTitle: 'Clear all filters?',
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
- clearAllUpdatePrompt: 'Update {{name}} with your current filters before clearing?',
+ clearAllUpdatePrompt:
+ 'Update {{name}} with your current filters before clearing?',
saveAndClear: 'Save & Clear',
updateAndClear: 'Update & Clear',
clearWithoutSaving: 'Clear without saving',
@@ -672,6 +680,8 @@ const en = {
ethnicity: 'Ethnicity',
poiType: 'POI type',
party: 'Party',
+ travelTimeKeywords:
+ 'travel time journey commute car bicycle bike cycling walking walk transit transport public station tube train bus metro subway underground rail route',
},
// ── Philosophy Popup ───────────────────────────────
@@ -806,6 +816,8 @@ const en = {
showAllStatsFallback:
'Switch to all properties to inspect this area without the active filters.',
showAllStats: 'Show all properties',
+ closestStations: 'Closest stations',
+ noNearbyStations: 'No train or tube stations within 2km',
closestBlockingFilters: 'Closest changes to include this area',
lowerMinTo: 'Lower minimum to {{value}}',
raiseMaxTo: 'Raise maximum to {{value}}',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index f91c412..60d22de 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -25,6 +25,7 @@ const fr: Translations = {
total: 'Total',
min: 'min',
max: 'max',
+ minute: 'min',
or: 'ou',
area: 'Zone',
properties: 'Propriétés',
@@ -35,11 +36,17 @@ const fr: Translations = {
clickForDetails: 'Cliquez pour les détails',
property: 'propriété',
propertiesPlural: 'propriétés',
+ bedsCount: '{{count}} ch.',
+ bedsCount_other: '{{count}} ch.',
+ bathsCount: '{{count}} sdb',
+ bathsCount_other: '{{count}} sdb',
places: 'lieux',
noData: 'Aucune donnée',
allLow: 'Tout est faible',
connectingToServer: 'Connexion au serveur...',
closePane: 'Fermer le panneau',
+ yes: 'Oui',
+ no: 'Non',
},
// ── Header / Nav ───────────────────────────────────
@@ -318,7 +325,7 @@ const fr: Translations = {
"Oui. La recherche adaptée à l'école peut être combinée avec la criminalité, les parcs, les déplacements domicile-travail, le prix, la taille de la propriété et les services locaux.",
'Is Ofsted the only school signal?': 'Ofsted est-il le seul signal scolaire ?',
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
- "Aucun score isolé ne devrait décider d’un déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur l’école.",
+ 'Aucun score isolé ne devrait décider d’un déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur l’école.',
'See where education, property, transport, and environment data comes from.':
"Découvrez d'où proviennent les données sur l'éducation, l'immobilier, les transports et l'environnement.",
'Explore school-aware searches': "Explorez les recherches adaptées à l'école",
@@ -337,7 +344,7 @@ const fr: Translations = {
'Compare postcodes consistently across England.':
'Comparez les codes postaux de manière cohérente dans toute l’Angleterre.',
'Check the street before spending a viewing slot':
- "Vérifiez la rue avant d’y consacrer un créneau de visite",
+ 'Vérifiez la rue avant d’y consacrer un créneau de visite',
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
"Utilisez le vérificateur de code postal pour examiner l'historique des prix, le contexte local, les commodités, les écoles et les signaux environnementaux avant de consacrer du temps à votre visite.",
'Compare neighbouring postcodes': 'Comparez les codes postaux voisins',
@@ -702,6 +709,8 @@ const fr: Translations = {
ethnicity: 'Origine ethnique',
poiType: 'Type de POI',
party: 'Parti',
+ travelTimeKeywords:
+ 'temps trajet déplacement navette domicile-travail voiture vélo bicyclette cyclisme marche à pied piéton transports en commun public station gare train métro tramway bus RER itinéraire route travel time journey commute car bicycle bike walking transit transport station tube train',
},
// ── Philosophy Popup ───────────────────────────────
@@ -839,12 +848,13 @@ const fr: Translations = {
showAllStatsFallback:
'Passez à toutes les propriétés pour inspecter cette zone sans les filtres actifs.',
showAllStats: 'Afficher toutes les propriétés',
+ closestStations: 'Stations les plus proches',
+ noNearbyStations: 'Aucune gare ou station de métro à moins de 2 km',
closestBlockingFilters: 'Modifications les plus proches pour inclure cette zone',
lowerMinTo: 'Abaisser le minimum à {{value}}',
raiseMaxTo: 'Augmenter le maximum à {{value}}',
allowCategory: 'Autoriser {{value}}',
- missingFilterValue:
- 'Aucune valeur pour ce filtre ; supprimez-le',
+ missingFilterValue: 'Aucune valeur pour ce filtre ; supprimez-le',
noFilterDataShort: 'Aucune donnée',
travelTo: 'Trajet vers {{destination}}',
viewProperties: 'Voir {{count}} propriétés',
@@ -1297,7 +1307,8 @@ const fr: Translations = {
// ── Invites Page ───────────────────────────────────
invitesPage: {
- inviteLinksLicensed: 'Les liens d’invitation sont disponibles pour les utilisateurs sous licence.',
+ inviteLinksLicensed:
+ 'Les liens d’invitation sont disponibles pour les utilisateurs sous licence.',
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
generateFreeInvite: 'Générer un lien d’invitation gratuit',
diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts
index b94c1d6..570cd28 100644
--- a/frontend/src/i18n/locales/hi.ts
+++ b/frontend/src/i18n/locales/hi.ts
@@ -22,8 +22,9 @@ const hi: Translations = {
none: 'कोई नहीं',
viewDataSource: 'डेटा स्रोत देखें',
total: 'कुल',
- min: 'मिनट',
+ min: 'न्यूनतम',
max: 'अधिकतम',
+ minute: 'मिनट',
or: 'या',
area: 'क्षेत्र',
properties: 'संपत्तियां',
@@ -34,11 +35,17 @@ const hi: Translations = {
clickForDetails: 'विवरण के लिए क्लिक करें',
property: 'संपत्ति',
propertiesPlural: 'संपत्तियां',
+ bedsCount: '{{count}} बेड',
+ bedsCount_other: '{{count}} बेड',
+ bathsCount: '{{count}} बाथ',
+ bathsCount_other: '{{count}} बाथ',
places: 'स्थान',
noData: 'कोई डेटा नहीं',
allLow: 'सभी कम',
connectingToServer: 'सर्वर से कनेक्ट हो रहा है...',
closePane: 'पैन बंद करें',
+ yes: 'हाँ',
+ no: 'नहीं',
},
header: {
@@ -670,6 +677,8 @@ const hi: Translations = {
ethnicity: 'जातीय समूह',
poiType: 'POI प्रकार',
party: 'पार्टी',
+ travelTimeKeywords:
+ 'यात्रा यात्रा समय सफर आवागमन कार गाड़ी साइकिल बाइक पैदल चलना सार्वजनिक परिवहन परिवहन यातायात स्टेशन ट्रेन रेल मेट्रो ट्यूब बस मार्ग travel time journey commute car bicycle bike walking transit transport station tube train',
},
philosophy: {
@@ -797,6 +806,8 @@ const hi: Translations = {
showAllStatsFallback:
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStats: 'सभी संपत्तियां दिखाएं',
+ closestStations: 'निकटतम स्टेशन',
+ noNearbyStations: '2 किमी के भीतर कोई ट्रेन या ट्यूब स्टेशन नहीं',
closestBlockingFilters: 'इस क्षेत्र को शामिल करने के निकटतम बदलाव',
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',
diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts
index 6e5a4d7..3472986 100644
--- a/frontend/src/i18n/locales/hu.ts
+++ b/frontend/src/i18n/locales/hu.ts
@@ -23,8 +23,9 @@ const hu: Translations = {
none: 'Egyik sem',
viewDataSource: 'Adatforrás megtekintése',
total: 'Összesen',
- min: 'perc',
+ min: 'min.',
max: 'max.',
+ minute: 'perc',
or: 'vagy',
area: 'Terület',
properties: 'Ingatlanok',
@@ -35,11 +36,17 @@ const hu: Translations = {
clickForDetails: 'Kattints a részletekhez',
property: 'ingatlan',
propertiesPlural: 'ingatlanok',
+ bedsCount: '{{count}} hsz.',
+ bedsCount_other: '{{count}} hsz.',
+ bathsCount: '{{count}} fsz.',
+ bathsCount_other: '{{count}} fsz.',
places: 'helyek',
noData: 'Nincs adat',
allLow: 'Mind alacsony',
connectingToServer: 'Kapcsolódás a szerverhez...',
closePane: 'Panel bezárása',
+ yes: 'Igen',
+ no: 'Nem',
},
// ── Header / Nav ───────────────────────────────────
@@ -446,7 +453,8 @@ const hu: Translations = {
'Make commute constraints explicit': 'Tegye egyértelművé az ingázási korlátozásokat',
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
'Ha fontos a központ, állomás, kórház, egyetem vagy üzleti park elérése, először használja az utazási idő szűrőit, majd hasonlítsa össze a fennmaradó irányítószámokat ingatlanadatok alapján.',
- 'Compare value, not just headline price': 'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
+ 'Compare value, not just headline price':
+ 'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
'Használja együtt az ár-, ingatlantípus- és alapterület-szűrőket. Ez segít megkülönböztetni az alacsonyabb költségű területeket azoktól a területektől, amelyek egyszerűen kisebb vagy eltérő otthonokat tartalmaznak.',
'Screen environmental and local-service signals':
@@ -686,6 +694,8 @@ const hu: Translations = {
ethnicity: 'Etnikai csoport',
poiType: 'POI-típus',
party: 'Párt',
+ travelTimeKeywords:
+ 'utazási idő utazás ingázás menetidő autó kocsi kerékpár bicikli biciklizés gyaloglás gyalog séta tömegközlekedés közlekedés közösségi közlekedés állomás vonat metró villamos busz HÉV útvonal travel time journey commute car bicycle bike walking transit transport station tube train',
},
// ── Philosophy Popup ───────────────────────────────
@@ -820,6 +830,8 @@ const hu: Translations = {
showAllStatsFallback:
'Váltson az összes ingatlanra, hogy aktív szűrők nélkül tekintse át ezt a területet.',
showAllStats: 'Összes ingatlan mutatása',
+ closestStations: 'Legközelebbi állomások',
+ noNearbyStations: 'Nincs vonat- vagy metróállomás 2 km-en belül',
closestBlockingFilters: 'A terület bevonásához legközelebbi módosítások',
lowerMinTo: 'Minimum csökkentése erre: {{value}}',
raiseMaxTo: 'Maximum növelése erre: {{value}}',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 91ebb35..4eb7ad9 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -25,6 +25,7 @@ const zh: Translations = {
total: '总计',
min: '最小',
max: '最大',
+ minute: '分钟',
or: '或',
area: '区域',
properties: '房产',
@@ -34,11 +35,17 @@ const zh: Translations = {
clickForDetails: '点击查看详情',
property: '处房产',
propertiesPlural: '处房产',
+ bedsCount: '{{count}} 室',
+ bedsCount_other: '{{count}} 室',
+ bathsCount: '{{count}} 卫',
+ bathsCount_other: '{{count}} 卫',
places: '地点',
noData: '无数据',
allLow: '全部为低',
connectingToServer: '正在连接服务器...',
closePane: '关闭面板',
+ yes: '是',
+ no: '否',
},
// ── Header / Nav ───────────────────────────────────
@@ -86,439 +93,439 @@ const zh: Translations = {
reviewDataSources: '查看数据来源',
whatYouCanCompare: '可比较内容',
whatYouCanCompareDesc:
- '每个页面都围绕真实筛选工作构建:排除不可能的地点、比较剩余邮编,并决定下一步要验证什么。',
+ '每个页面都围绕真实的筛选流程而设:剔除不可能的地点、比较剩余邮编、决定下一步验证什么。',
howToUseIt: '使用方式',
- howToUseItDesc: '在打开房源网站或预约看房前,用这些流程先让页面发挥作用。',
- methodAndLimitations: '方法与限制',
+ howToUseItDesc: '打开房源网站或预约看房之前,先按这些流程把页面用起来。',
+ methodAndLimitations: '方法与局限',
methodAndLimitationsDesc:
- '这些数据用于比较和初筛。重要决定仍需结合最新房源、专业检查和直接的本地验证。',
+ '这些数据适合比较和初筛。重大决定仍需结合最新房源、专业检查和实地核验。',
questionsBuyersAsk: '买家常问的问题',
relatedGuides: '相关指南',
- relatedGuidesDesc: '通过规范的内部链接继续浏览已索引的公开页面。',
+ relatedGuidesDesc: '通过规范的内部链接,继续浏览已收录的公开页面。',
frequentlyAskedQuestions: '常见问题',
relatedPages: '相关页面',
- relatedPagesDesc: '通过这些内部链接,从另一个角度比较同一套房产搜索流程。',
+ relatedPagesDesc: '借助这些内部链接,从另一个角度审视同一套房产搜索流程。',
pages: {
'Property price map': '房产价格地图',
'Compare property prices across every postcode in England': '比较英格兰每个邮编的房价',
'Property price map for England - Compare postcodes before viewing':
'英格兰房地产价格地图 - 看房前比较邮编',
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.':
- '在搜索房源之前,比较各个英格兰邮编的售价、估计当前价值、每平方米价格和当地情况。',
+ '逛房源之前,先在英格兰各邮编之间横向比较成交价、当前估值、每平方米单价与周边背景。',
'Perfect Postcode maps sold prices, estimated current value, price per square metre, property type, floor area, tenure, and local context so buyers can find realistic search areas before opening listing portals.':
- 'Perfect Postcode 在地图上展示成交价、估计当前价值、每平方米价格、房产类型、建筑面积、产权和当地背景,让买家在打开房源平台之前找到切合实际的搜索区域。',
+ 'Perfect Postcode 在地图上同时呈现成交价、当前估值、每平方米单价、房产类型、建筑面积、产权和周边背景,帮买家在打开房源平台之前就锁定真正可行的搜索范围。',
'Screen historical sale prices and current-value estimates by postcode.':
- '按邮编筛选历史销售价格和当前价值估计。',
+ '按邮编筛选历史成交价与当前估值。',
'Compare value with commute, schools, broadband, crime, noise, and amenities.':
- '将价值与通勤、学校、宽带、犯罪、噪音和便利设施进行比较。',
+ '把房价与通勤、学校、宽带、治安、噪音、便利设施放在一起比较。',
'Build a shortlist before spending weekends on viewings.':
- '在花周末时间看房之前先建立候选名单。',
+ '别急着用周末跑看房,先建好候选名单。',
'Find postcodes that fit the budget before listings appear':
- '在房源出现之前找到符合预算的邮编',
+ '抢在房源上架之前,先锁定符合预算的邮编',
'Start with a maximum price and property type, then colour the map by price per square metre or estimated current price. This helps reveal areas where similar homes have historically traded within reach, even when there are no live listings today.':
- '先设置最高价格和房产类型,然后按每平方米价格或估计当前价格为地图着色。即使目前没有实时房源,也能揭示历史上类似房屋曾以可负担价格成交的区域。',
+ '先设定价格上限和房产类型,再用每平方米单价或当前估值给地图着色。即使眼下没有在售房源,也能找出同类房屋曾经成交在预算之内的区域。',
'Filter by last known sale price, estimated current value, property type, tenure, and floor area.':
- '按最近一次成交价、估计当前价值、房产类型、产权和建筑面积进行筛选。',
+ '按最近成交价、当前估值、房产类型、产权和建筑面积筛选。',
'Compare nearby postcodes using the same criteria instead of relying on area reputation.':
- '使用相同的标准比较附近的邮编,而不是依赖区域口碑。',
+ '用同一套标准横向比较邻近邮编,不再凭片区口碑下判断。',
'Use the results as a shortlist for listing alerts, local research, and viewings.':
- '将结果作为候选名单,用于设置房源提醒、当地调研和看房安排。',
- 'Separate cheap from good value': '区分便宜和物有所值',
+ '把结果当作候选名单,用来设置房源提醒、做实地调研、安排看房。',
+ 'Separate cheap from good value': '分清「便宜」和「值得」',
'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode isn’t automatically treated as the best option.':
- '较低的价格可能反映出房屋较小、交通较弱、噪音较大或本地服务较少。地图使这些权衡显而易见,因此最便宜的邮编不会自动被视为最佳选择。',
- 'Start from area value, not listing availability': '从区域价值出发,而不是看是否有在售房源',
+ '价格偏低,往往背后是房屋更小、交通不便、噪音偏大或本地服务不足。地图把这些取舍一并摆出来,避免最便宜的邮编被当成默认的最佳选择。',
+ 'Start from area value, not listing availability': '从区域价值出发,而不是看眼下有没有房源',
'Listing portals only show homes for sale today. A postcode-level property price map lets you compare wider areas, understand local price patterns, and avoid missing places where the next suitable listing might appear.':
- '房源平台仅显示今天待售的房屋。邮编级别的房产价格地图可让您比较更广泛的区域,了解当地的价格模式,并避免遗漏下一个合适的列表可能出现的位置。',
- 'Use prices alongside real constraints': '使用价格和实际限制',
+ '房源平台只显示当下在售的房子。邮编级的房产价格地图把视野拉得更宽,让您看清各地价格走势,不错过下一个合适房源可能出现的区域。',
+ 'Use prices alongside real constraints': '把价格放进真实条件里看',
'Budget rarely matters on its own. Perfect Postcode combines price filters with travel time, school quality, property size, energy performance, local environment, and services so your shortlist reflects how you actually want to live.':
- '预算本身往往无法单独决定一切。Perfect Postcode 将价格筛选条件与出行时间、学校质量、房屋面积、能源性能、当地环境和服务结合起来,让您的候选名单真正反映您想要的生活方式。',
- 'What the price data is for': '价格数据的用途',
+ '预算单看从来不够。Perfect Postcode 把价格筛选与出行时间、学校质量、房屋面积、能源表现、周边环境和本地服务串在一起,让候选名单真正贴合您想要的生活。',
+ 'What the price data is for': '价格数据用来做什么',
'Use the map to compare areas and spot search candidates. It isn’t a valuation, mortgage decision, survey, legal search, or live listing feed.':
- '使用地图比较区域并找到搜索候选者。它不是评估、抵押贷款决策、调查、法律调查或实时房源数据。',
- 'How to validate a promising area': '如何验证有潜力的区域',
+ '地图用于横向比较区域、圈出候选范围,并不代替估价、按揭决策、验房、法律检索或实时房源信息。',
+ 'How to validate a promising area': '看好一个区域之后,如何进一步验证',
'Once a postcode looks promising, check current listings, sold-price comparables, agent details, flood searches, legal packs, surveys, and local authority information before making a decision.':
- '一旦某个邮编看起来有潜力,请在做出决定前查看当前房源、可比成交价、中介详情、洪水风险查询、法律资料包、验房报告和地方政府信息。',
- 'Is this a replacement for Rightmove or Zoopla?': '这是 Rightmove 或 Zoopla 的替代品吗?',
+ '某个邮编看起来有戏,决定之前请先核对当前房源、可比成交价、中介信息、洪水风险查询、法律资料包、验房报告以及地方政府信息。',
+ 'Is this a replacement for Rightmove or Zoopla?': '它能取代 Rightmove 或 Zoopla 吗?',
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show what’s currently for sale.':
- '不是。请在使用房源平台之前及与之配合使用。Perfect Postcode 帮您决定到哪里去看,房源平台展示当前在售的房屋。',
+ '不能,它应当配合房源平台使用:Perfect Postcode 帮您决定该去哪儿找,房源平台告诉您当下哪些房子在售。',
'Can I compare price with schools or commute time?':
- '我可以将价格与学校或通勤时间进行比较吗?',
+ '可以把价格和学校、出行时间放在一起比较吗?',
'Yes. Price filters can be combined with travel-time, schools, crime, broadband, road-noise, amenities, and environment filters.':
- '是的。价格筛选条件可以与出行时间、学校、犯罪、宽带、道路噪音、便利设施和环境筛选条件结合起来。',
- 'Does the map cover all of the UK?': '地图涵盖了整个英国吗?',
+ '可以。价格筛选可叠加出行时间、学校、治安、宽带、道路噪音、便利设施和周边环境等条件。',
+ 'Does the map cover all of the UK?': '地图覆盖整个英国吗?',
'The current product focuses on England because several core property and postcode datasets are England-specific.':
- '当前产品主要面向英格兰,因为若干核心的房产和邮编数据集仅适用于英格兰。',
+ '目前聚焦英格兰,因为若干核心的房产与邮编数据集只覆盖英格兰。',
'Birmingham property search guide': '伯明翰房产搜索指南',
'A worked example for balancing price, commute, and family trade-offs.':
- '一个实际案例,演示如何在价格、通勤和家庭需求之间取得平衡。',
- 'Data sources and coverage': '数据来源及覆盖范围',
+ '一个实操案例,演示如何在价格、通勤与家庭需求之间取得平衡。',
+ 'Data sources and coverage': '数据来源与覆盖范围',
'See which datasets sit behind the postcode filters and where they have limits.':
- '查看邮编筛选条件后面的数据集以及它们的限制。',
+ '看看邮编筛选背后用到了哪些数据集,又各自有哪些局限。',
Methodology: '方法论',
'Understand how the map is intended to support shortlisting, not replace due diligence.':
- '了解地图如何支持候选,而不是取代尽职调查。',
- 'Postcode checker': '邮编检查器',
+ '地图的定位是辅助初筛,而非取代尽职调查。',
+ 'Postcode checker': '邮编速查',
'Check one postcode before you spend time on a viewing.':
- '在您花时间看房之前,请检查一个邮编。',
+ '花时间去看房前,先把这个邮编查一查。',
'Explore the property map': '探索房产地图',
'Postcode property search': '邮编房产搜索',
- 'Find postcodes that match your property search criteria':
- '查找符合您的房产搜索条件的邮编',
+ 'Find postcodes that match your property search criteria': '找出符合您购房条件的邮编',
'Postcode property search - Find areas that match your criteria':
- '邮编房产搜索 - 查找符合您条件的区域',
+ '邮编房产搜索 - 找出符合您条件的区域',
'Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.':
- '按预算、房产类型、建筑面积、产权、通勤、学校、犯罪、宽带、噪音、公园和当地设施搜索每个邮编。',
+ '按预算、房产类型、建筑面积、产权、通勤、学校、治安、宽带、噪音、公园与周边设施检索每一个邮编。',
'Search every postcode by budget, property type, size, tenure, commute, schools, crime, broadband, noise, parks, and local amenities instead of checking areas one at a time.':
- '按预算、房产类型、面积、产权、通勤、学校、犯罪、宽带、噪音、公园和当地设施搜索每个邮编,而不是一次检查一个区域。',
- 'Filter England-wide postcode data from one map.':
- '从一张地图中筛选英格兰范围内的邮编数据。',
+ '一次性按预算、房产类型、面积、产权、通勤、学校、治安、宽带、噪音、公园与周边设施检索每个邮编,不必再一个一个区域翻看。',
+ 'Filter England-wide postcode data from one map.': '在一张地图上筛选覆盖全英格兰的邮编数据。',
'Shortlist unfamiliar areas with comparable evidence.':
- '基于可比的数据证据,把陌生区域纳入候选名单。',
- 'Save and share search areas before booking viewings.': '在预约看房之前保存并共享搜索区域。',
- 'Turn a broad brief into postcode candidates': '将广泛的简介转变为候选邮编',
+ '凭可对比的数据,把陌生区域也纳入候选名单。',
+ 'Save and share search areas before booking viewings.': '预约看房前,先保存并分享搜索区域。',
+ 'Turn a broad brief into postcode candidates': '把宽泛的购房需求,落到具体的候选邮编',
'Enter the practical constraints first: budget, property size, tenure, travel time, school needs, broadband, and tolerance for road noise or crime levels. The map removes places that fail those constraints and keeps the remaining options comparable.':
- '首先输入实际限制:预算、房产面积、产权、出行时间、学校需求、宽带以及对道路噪音或犯罪水平的容忍度。该地图删除了不符合这些限制的地点,并保持其余选项的可比性。',
- 'Relax one constraint at a time': '一次放松一项限制',
+ '先把硬条件填进去:预算、房屋面积、产权、出行时间、学校需求、宽带,以及对道路噪音和治安的容忍度。地图会剔除不符合条件的区域,让剩下的选项依然可以横向比较。',
+ 'Relax one constraint at a time': '一次只放宽一个条件',
'When the search becomes too narrow, loosen a single filter and watch which postcodes reappear. This makes compromise explicit instead of relying on guesswork.':
- '当搜索范围变得太窄时,松开单个筛选条件并观察哪些邮编重新出现。这使得妥协变得明确,而不是依赖猜测。',
- 'Turn vague areas into specific postcodes': '将模糊区域变成特定的邮编',
+ '搜索范围过窄时,每次只放宽一个筛选条件,看看哪些邮编重新浮现。取舍一目了然,不必靠猜。',
+ 'Turn vague areas into specific postcodes': '把模糊的「片区」落到具体邮编',
'Broad town or borough searches hide large differences between streets. Perfect Postcode helps you move from a general area to postcodes that satisfy your hard requirements.':
- '广泛的城镇或行政区搜索隐藏了街道之间的巨大差异。Perfect Postcode可帮助您从一般区域转移到满足您硬要求的邮编。',
- 'Keep trade-offs visible': '让权衡清晰可见',
+ '以整个城镇或行政区为单位搜索,会掩盖街区之间的巨大差异。Perfect Postcode 帮您从笼统的片区,精确到真正符合硬条件的邮编。',
+ 'Keep trade-offs visible': '让取舍一目了然',
'When there are too many or too few matches, adjust one constraint at a time and see exactly which postcodes reappear. That makes compromises explicit instead of relying on guesswork.':
- '当匹配项太多或太少时,一次调整一个约束并准确查看哪些邮编重新出现。这使得妥协变得明确,而不是依赖猜测。',
- 'Why postcode-level comparison matters': '为什么邮编级别的比较很重要',
+ '匹配结果过多或过少时,每次只调整一个条件,看看哪些邮编重新浮现。取舍一目了然,不必靠猜。',
+ 'Why postcode-level comparison matters': '为什么要细到邮编层级去比较',
'Two nearby postcodes can differ on schools, road noise, transport access, property mix, and price. Comparing at postcode level reduces the chance of treating a whole town as one uniform market.':
- '附近的两个邮编在学校、道路噪音、交通便利、房产构成和价格方面可能有所不同。在邮编级别进行比较减少了将整个城镇视为一个统一市场的机会。',
- 'How to use the results': '如何使用结果',
+ '相邻的两个邮编,在学校、道路噪音、交通条件、房屋构成和价格上都可能差异悬殊。细到邮编去比较,才不会把整座城镇当成一个均质市场。',
+ 'How to use the results': '搜索结果怎么用',
'Treat matching postcodes as a research queue: check live listings, visit streets, confirm schools and admissions, and review current official sources.':
- '将匹配的邮编视为待研究清单:查看实时房源、实地走访街道、确认学校和招生情况,并参考当前的官方来源。',
- 'Can I save a postcode property search?': '我可以保存邮编房产搜索吗?',
+ '把匹配到的邮编当作待研究清单:查看实时房源、实地走街、核实学校与招生,再比对最新官方资料。',
+ 'Can I save a postcode property search?': '可以保存邮编房产搜索吗?',
'Yes. Licensed users can save searches and return to them later. Saved searches are designed for shortlists and comparison notes.':
- '是的。已授权用户可以保存搜索并稍后返回。保存的搜索专为候选列表和比较注释而设计。',
- 'Can I search without knowing the area?': '我可以在不知道区域的情况下进行搜索吗?',
+ '可以。已授权用户可以保存搜索,随时回来接着用。保存的搜索专为整理候选名单和比对笔记而设计。',
+ 'Can I search without knowing the area?': '不熟悉当地,也能搜索吗?',
'Yes. The map is designed to surface unfamiliar areas that match practical constraints, not just places you already know.':
- '是的。该地图旨在显示符合实际限制的不熟悉的区域,而不仅仅是您已经知道的地方。',
- 'Are the results live property listings?': '结果是实时房产房源吗?',
+ '可以。地图天生就为发掘陌生区域而设计——只要符合您的条件,它就会推到您面前,而不只是把您已经知道的地方再列一遍。',
+ 'Are the results live property listings?': '搜索结果是实时房源吗?',
'No. The tool compares postcode data and historical/contextual property signals. You still need listing portals for current availability.':
- '不是。该工具比较邮编数据以及历史和背景类的房产信号。当前可售情况仍需查看房源平台。',
+ '不是。本工具比较的是邮编数据,以及历史与背景类的房产信号。当下哪些房子在售,仍需去房源平台查看。',
'Manchester property search guide': '曼彻斯特房产搜索指南',
'A regional guide for narrowing a broad search around Greater Manchester.':
- '用于缩小大曼彻斯特广泛搜索范围的区域指南。',
+ '一份大曼彻斯特地区指南,帮您把宽泛的搜索范围逐步收窄。',
'Start a postcode search': '开始邮编搜索',
- 'Commute property search': '通勤房产搜索',
- 'Search for places to live by commute time': '按通勤时间搜索居住地',
+ 'Commute property search': '按通勤搜索房产',
+ 'Search for places to live by commute time': '按出行时间挑选居住地',
'Commute property search - Find places to live by travel time':
- '通勤房产搜索 - 按出行时间查找居住地',
+ '通勤房产搜索 - 按出行时间挑选居住地',
'Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.':
- '按通勤时间筛选邮编,然后在一张地图上比较价格、学校、安全、宽带、道路噪音、公园和房产数据。',
+ '按出行时间筛选邮编,再在同一张地图上比较价格、学校、治安、宽带、道路噪音、公园和房产数据。',
'Filter postcodes by modelled car, cycling, walking, and public transport travel times, then layer on property price, schools, crime, broadband, noise, and local amenities.':
- '按模型汽车、自行车、步行和公共交通出行时间筛选邮编,然后按房价、学校、犯罪、宽带、噪音和当地便利设施进行分层。',
+ '按模拟出来的驾车、骑行、步行、公共交通出行时间筛选邮编,再叠加房价、学校、治安、宽带、噪音、周边设施等条件。',
'Compare reachable postcodes by realistic travel-time bands.':
- '按实际出行时间范围比较可到达的邮编。',
+ '按贴近真实的出行时间档位,比较可达的邮编。',
'Search by destination first, then filter for property and neighbourhood fit.':
- '首先按目的地搜索,然后筛选适合的房产和社区。',
+ '先定目的地,再筛选合适的房产与社区。',
'Avoid areas that look close on a map but fail the daily journey.':
- '避开那些在地图上看起来很接近但日常行程却失败的区域。',
- 'Start with the destination that matters': '从重要的目的地开始',
+ '避开那些地图上看似很近、实际通勤却走不通的地方。',
+ 'Start with the destination that matters': '从最重要的目的地出发',
'Choose a commute destination, transport mode, and time range, then add the property filters. This prevents a cheap-looking area from reaching the shortlist if the daily journey doesn’t work.':
- '选择通勤目的地、交通方式和时间范围,然后添加房产筛选条件。如果日常行程不起作用,这可以防止看似廉价的地区进入候选名单。',
- 'Compare the commute against the rest of daily life': '将通勤与日常生活的其他部分进行比较',
+ '先选定通勤目的地、交通方式和时间范围,再叠加房产筛选。这样一来,日常通勤走不通的区域,再便宜也不会误入候选名单。',
+ 'Compare the commute against the rest of daily life': '把通勤放进整体生活里衡量',
'A fast commute isn’t enough if the property size, school context, safety threshold, broadband, or road-noise exposure don’t fit. The map keeps those signals side by side.':
- '如果房产面积、学校环境、安全阈值、宽带或道路噪音暴露不合适,快速通勤是不够的。地图将这些信号并排保存。',
- 'Commute from postcodes, not just place names': '从邮编通勤,而不仅仅是地名',
+ '如果房屋面积、学校情况、治安底线、宽带或道路噪音都不合适,光是通勤快也不够。地图把这些信号并排摆出来,方便一并衡量。',
+ 'Commute from postcodes, not just place names': '从邮编算通勤,而不只是看地名',
'Two streets in the same town can have very different station access, road routes, and public transport options. Postcode-level travel-time filtering keeps that difference visible.':
- '同一城镇的两条街道可能有截然不同的车站通道、道路路线和公共交通选择。邮编级别的出行时间筛选使这种差异可见。',
- 'Balance journey time with the rest of the move': '平衡旅途时间与其余的搬家时间',
+ '同一城镇里的两条街,到车站的距离、道路走向、公共交通选择都可能天差地别。细到邮编去筛选出行时间,这些差异才看得见。',
+ 'Balance journey time with the rest of the move': '在通勤时长和其他迁居因素之间找平衡',
'A fast commute only helps if the area also fits your budget, housing needs, school preferences, safety threshold, broadband requirement, and tolerance for road noise.':
- '只有当该地区也符合您的预算、住房需求、学校偏好、安全阈值、宽带要求和道路噪音容忍度时,快速通勤才有帮助。',
- 'How travel-time filters should be interpreted': '应如何解释出行时间筛选条件',
+ '只有当区域同时契合预算、居住需求、学校偏好、治安底线、宽带要求和道路噪音容忍度时,通勤快才真的算优势。',
+ 'How travel-time filters should be interpreted': '该如何看待出行时间筛选',
'Travel-time modelling is useful for comparing areas consistently. Before committing, check current timetables, disruption patterns, parking, cycling conditions, and walking routes.':
- '出行时间建模对于一致地比较区域很有用。在做出决定之前,请检查当前的时间表、中断模式、停车、骑行条件和步行路线。',
- 'Why commute filters are combined with property data': '为什么通勤筛选条件与房产数据相结合',
+ '出行时间模型适合用统一口径横向比较各区域。真正做决定前,请再核实最新班次时刻、线路中断情况、停车条件、骑行路况和步行路线。',
+ 'Why commute filters are combined with property data': '为什么要把通勤筛选与房产数据结合',
'Commute search is most useful when it removes impossible areas while still showing whether the remaining options are affordable and liveable.':
- '当通勤搜索删除不可能的区域,同时仍显示剩余选项是否负担得起且宜居时,它是最有用的。',
+ '通勤搜索最有价值的地方,是在剔除不可行区域的同时,还能让您看清剩下的选项是否买得起、住得舒服。',
'Can I compare car, cycling, walking, and public transport?':
- '我可以比较汽车、自行车、步行和公共交通吗?',
+ '可以同时比较驾车、骑行、步行和公共交通吗?',
'The product supports multiple travel modes where precomputed destination data is available.':
- '该产品支持多种出行模式,其中预先计算的目的地数据可用。',
- 'Are travel times exact?': '出行时间准确吗?',
+ '在已预先计算目的地数据的范围内,本产品支持多种出行方式。',
+ 'Are travel times exact?': '出行时间精确吗?',
'No. Treat them as a consistent comparison model, then verify the real route before making viewing or purchase decisions.':
- '不准确。请将它们视为一致的对比模型,然后在做出看房或购买决定之前验证真实路线。',
+ '并不精确。请把它们视为统一口径的比较模型;决定看房或购房前,再核实一次真实路线。',
'Can I combine commute filters with schools and price?':
- '我可以将通勤筛选条件与学校和价格结合起来吗?',
+ '通勤筛选可以与学校、价格一起组合使用吗?',
'Yes. The commute filter can be layered with property price, size, schools, broadband, crime, amenities, and environmental signals.':
- '是的。通勤筛选条件可以根据房价、面积、学校、宽带、犯罪、便利设施和环境信号进行分层。',
+ '可以。通勤筛选可与房价、面积、学校、宽带、治安、便利设施和环境信号叠加使用。',
'Bristol property search guide': '布里斯托尔房产搜索指南',
'A worked example for balancing city access, price, and local context.':
- '一个实际案例,演示如何在进城便利、价格和当地环境之间取得平衡。',
+ '一个实操案例,演示如何在进城便利、价格和周边背景之间取得平衡。',
'Search by commute time': '按通勤时间搜索',
- 'Schools and property search': '学校和房产搜索',
+ 'Schools and property search': '学校与房产搜索',
'Find property search areas with schools and family trade-offs in view':
- '寻找考虑学校和家庭权衡的房产搜索区域',
+ '兼顾学校与家庭取舍,找出可以重点关注的区域',
'School property search - Compare postcodes for family moves':
- '学校房产搜索 - 比较家庭搬迁的邮编',
+ '学校房产搜索 - 为家庭搬迁比较邮编',
'Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.':
- '在建立看房候选名单之前,比较附近的学校、房产面积、价格、公园、安全、通勤和当地便利设施。',
+ '建立看房候选名单之前,把附近学校、房屋面积、价格、公园、治安、通勤和周边设施一并比较。',
'Compare nearby Ofsted ratings, education context, property size, budget, safety, parks, commute, and local amenities before narrowing your viewing shortlist.':
- '比较附近的 Ofsted 评级、教育背景、房产面积、预算、安全、公园、通勤和当地设施,然后再缩小您的看房候选名单。',
+ '收窄看房候选名单之前,把附近 Ofsted 评级、教育背景、房屋面积、预算、治安、公园、通勤和周边设施一并对比。',
'Filter for nearby school quality alongside housing requirements.':
- '筛选附近学校的质量以及住房要求。',
+ '把附近学校的水平与居住需求一同纳入筛选。',
'Compare family-friendly trade-offs across unfamiliar postcodes.':
- '比较不熟悉的邮编中适合家庭的权衡。',
+ '在陌生邮编之间比较家庭层面的各项取舍。',
'Use the map as a shortlist tool before checking admissions and catchments.':
- '在检查招生和流域之前,请使用地图作为候选名单工具。',
- 'Use school context without ignoring the home': '利用学校环境而不忽视家庭',
+ '核对招生与学区之前,先把地图当作候选名单工具用起来。',
+ 'Use school context without ignoring the home': '看重学校,但别忽视房子本身',
'Start with property size, budget, and commute constraints, then layer in nearby school quality and local context. This prevents school-led searches from hiding affordability or daily-life problems.':
- '从房产面积、预算和通勤限制开始,然后分层考虑附近的学校质量和当地环境。这可以防止学校主导的搜索隐藏负担能力或日常生活问题。',
- 'Verify admissions before deciding': '决定前核实录取情况',
+ '先从房屋面积、预算、通勤这些硬条件入手,再叠加附近学校水平和周边背景。这样以学校为导向的搜索,才不会掩盖买不起或生活不便的问题。',
+ 'Verify admissions before deciding': '做决定前先核实招生政策',
'School data can point to promising areas, but admissions rules and catchments can change. Confirm current arrangements with schools and local authorities.':
- '学校数据可以指向有潜力的区域,但招生规则和学区可能发生变化。请与学校和地方政府确认当前的安排。',
- 'School quality is one part of the shortlist': '学校质量是候选名单之一',
+ '学校数据能指出有潜力的区域,但招生规则和学区都可能调整。请直接向学校与地方政府核实当前安排。',
+ 'School quality is one part of the shortlist': '学校只是候选名单中的一环',
'Perfect Postcode helps you compare nearby school data with the other practical constraints that shape a family move: space, price, commute, parks, safety, and local services.':
- 'Perfect Postcode 可帮助您将附近的学校数据与影响家庭搬家的其他实际限制因素进行比较:空间、价格、通勤、公园、安全和当地服务。',
- 'Check catchments before making decisions': '做出决定前检查流域',
+ 'Perfect Postcode 把附近学校数据与影响家庭搬迁的其他硬条件——空间、价格、通勤、公园、治安、本地服务——放在一起比较。',
+ 'Check catchments before making decisions': '做决定前先查清学区',
'Admissions rules and catchment boundaries can change. Use postcode-level school data to find promising areas, then verify current admissions details with the school or local authority.':
- '招生规则和学区边界可能会发生变化。使用邮编级别的学校数据来寻找有潜力的地区,然后与学校或地方政府核实当前的招生详细信息。',
- 'How to treat school filters': '如何处理学校筛选条件',
+ '招生规则与学区边界都可能调整。先用邮编层级的学校数据锁定有潜力的区域,再向学校或地方政府核实最新招生细节。',
+ 'How to treat school filters': '该如何看待学校筛选',
'Use school filters to narrow research, not to assume admission eligibility. Ratings, distance, admissions criteria, and school capacity should all be checked with current official sources.':
- '使用学校筛选条件来缩小研究范围,而不是假设入学资格。评级、距离、录取标准和学校容量都应通过当前的官方来源进行检查。',
- 'Family trade-offs to compare': '家庭权衡比较',
+ '把学校筛选当作缩小研究范围的工具,而不是入学资格的依据。评级、距离、招生标准、学校容量都应以最新官方来源为准。',
+ 'Family trade-offs to compare': '需要权衡的家庭因素',
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
- '将学校与公园、道路噪音、犯罪、房产面积、通勤、宽带和价格结合起来,这样候选名单就能反映整个搬迁情况。',
- 'Does this show school catchment guarantees?': '这是否表明学校学区有保证?',
+ '把学校与公园、道路噪音、治安、房屋面积、通勤、宽带、价格综合起来,候选名单才能反映搬家的全貌。',
+ 'Does this show school catchment guarantees?': '这能保证孩子能进对应学区吗?',
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
- '不会。它有助于确定有潜力的地区,但学区和招生必须经过学校或地方政府的核实。',
+ '不能。它有助于锁定有潜力的区域,但学区与招生最终必须由学校或地方政府确认。',
'Can I combine school filters with parks and safety?':
- '我可以将学校筛选条件与公园和安全结合起来吗?',
+ '学校筛选可以与公园、治安一起组合使用吗?',
'Yes. School-aware search can be combined with crime, parks, commute, price, property size, and local services.':
- '可以。结合学校的搜索可以与犯罪、公园、通勤、价格、房产面积和当地服务相组合。',
- 'Is Ofsted the only school signal?': 'Ofsted 是唯一的学校信号吗?',
+ '可以。结合学校的搜索可与治安、公园、通勤、价格、房屋面积、本地服务一并使用。',
+ 'Is Ofsted the only school signal?': 'Ofsted 评级就是看学校的全部依据吗?',
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
- '任何一个分数都不能决定行动。使用地图作为起点,然后详细查看当前学校信息。',
+ '任何单一评分都不该成为搬家的唯一依据。把地图当作起点,再深入了解学校最新的具体情况。',
'See where education, property, transport, and environment data comes from.':
- '查看教育、房地产、交通和环境数据的来源。',
- 'Explore school-aware searches': '探索学校相关的搜索',
- 'Check postcode data before you book a viewing': '在预约看房之前检查邮编数据',
+ '了解教育、房产、交通、环境数据的来源。',
+ 'Explore school-aware searches': '探索结合学校的搜索',
+ 'Check postcode data before you book a viewing': '预约看房前,先把邮编数据查一查',
'Postcode checker - Property, crime, broadband, noise and schools':
- '邮编检查器 - 房产、犯罪、宽带、噪音和学校',
+ '邮编速查 - 房产、治安、宽带、噪音与学校',
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.':
- '检查邮编级别的房地产价格、EPC 数据、犯罪、宽带、道路噪音、学校、市政税、便利设施和出行时间背景。',
+ '查询邮编层级的房价、EPC 数据、治安、宽带、道路噪音、学校、Council Tax、周边设施与出行时间背景。',
'Review property prices, EPC context, crime, broadband, road noise, local amenities, schools, deprivation, council tax, and travel-time data from one postcode-first map.':
- '从一张邮编优先的地图中查看房地产价格、EPC 背景、犯罪、宽带、道路噪音、当地便利设施、学校、贫困、市政税和出行时间数据。',
- 'Check multiple local signals before visiting a street.': '在实地走访街道之前查看多项当地信息指标。',
+ '在一张以邮编为核心的地图上一次看清房价、EPC 背景、治安、宽带、道路噪音、周边设施、学校、贫困指数、Council Tax 与出行时间数据。',
+ 'Check multiple local signals before visiting a street.':
+ '实地走街之前,先把当地各项信号查一遍。',
'Use official and open datasets rather than reputation alone.':
- '使用官方和开放的数据集,而不仅仅是声誉。',
- 'Compare postcodes consistently across England.': '一致比较英格兰各地的邮编。',
- 'Check the street before spending a viewing slot': '在花时间看房之前检查一下街道',
+ '让判断依据落到官方与公开数据集上,而非片区口碑。',
+ 'Compare postcodes consistently across England.': '在整个英格兰用同一套口径比较邮编。',
+ 'Check the street before spending a viewing slot': '把看房名额留给真正值得的街道',
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
- '在您花时间看房之前,使用邮编检查器查看价格历史记录、当地背景、便利设施、学校和环境信号。',
- 'Compare neighbouring postcodes': '比较邻近的邮编',
+ '动身看房前,用邮编速查把价格走势、周边背景、便利设施、学校与环境信号先过一遍。',
+ 'Compare neighbouring postcodes': '比较相邻邮编',
'If one postcode looks promising, compare adjacent areas using the same filters. This often reveals whether a concern is street-specific or part of a wider pattern.':
- '如果一个邮编看起来很有希望,请使用相同的筛选条件比较相邻区域。这通常可以揭示问题是特定于街道的还是更广泛模式的一部分。',
- 'Useful before and alongside listing portals': '在使用房源平台之前及与之配合使用都很有用',
+ '某个邮编看起来有戏,就用同一套筛选条件看相邻区域。这往往能看出某个问题是这条街的特例,还是整个片区的通病。',
+ 'Useful before and alongside listing portals': '搭配房源平台使用,事半功倍',
'Listing photos rarely tell you enough about the surrounding street. Perfect Postcode gives you an evidence-led postcode check before you commit time to a viewing.':
- '房源照片很少能告诉您有关周围街道的足够信息。Perfect Postcode在您投入时间看房之前为您提供以证据为主导的邮编检查。',
- 'A screening tool, not professional advice': '筛查工具,而非专业建议',
+ '房源照片很难讲清周围街道的真实情况。Perfect Postcode 让您出门看房前,先用数据把邮编查个清楚。',
+ 'A screening tool, not professional advice': '是初筛工具,不是专业建议',
'The data is designed for shortlisting and comparison. Any purchase still needs current listing checks, legal due diligence, flood searches, lender requirements, and survey findings.':
- '该数据旨在用于筛选和比较。任何购买仍需要当前的清单检查、法律尽职调查、大量搜索、贷方要求和验房结果。',
- 'What a postcode check can catch': '邮编检查可以发现什么',
+ '这些数据是为初筛和比较而设计。真正下单购房,仍需结合最新房源核对、法律尽调、洪水风险查询、贷款机构要求与验房结果。',
+ 'What a postcode check can catch': '邮编速查能看出什么',
'A postcode check can surface price context, environmental signals, nearby amenities, and other local indicators that are easy to miss in a listing.':
- '邮编检查可以显示价格背景、环境信号、附近的便利设施以及列表中容易错过的其他本地指标。',
- 'What a postcode check can’t prove': '邮编检查无法证明什么',
+ '邮编速查能呈现房源描述里容易被忽略的价格背景、环境信号、周边设施等本地指标。',
+ 'What a postcode check can’t prove': '邮编速查证明不了什么',
'It can’t confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.':
- '它无法确认房屋的状况、未来的开发、法定所有权、贷款人要求或当前的街道经验。这些仍然需要直接检查。',
- 'Can I use the checker before a viewing?': '我可以在看房前使用检查器吗?',
+ '它无法确认房屋本身的状况、未来开发规划、法定产权、贷款机构要求,也替代不了亲自走在街上的感受。这些仍需直接核查。',
+ 'Can I use the checker before a viewing?': '看房前可以先用速查工具吗?',
'Yes. That’s one of the main use cases: screen the postcode first, then decide whether the viewing is worth the time.':
- '是的。这是主要用例之一:首先筛选邮编,然后决定是否值得花时间查看。',
- 'Does the checker include exact property condition?': '检查器是否包括准确的财产状况?',
+ '可以,这正是它的主要用途之一:先把邮编筛一遍,再决定这趟看房值不值得花时间。',
+ 'Does the checker include exact property condition?': '速查工具能反映房屋的具体状况吗?',
'No. Property condition requires listing details, surveys, and direct inspection.':
- '不可以。房产状况需要列出详细信息、调查和直接检查。',
- 'Can I compare multiple postcodes?': '我可以比较多个邮编吗?',
+ '不能。房屋具体状况要靠房源详情、验房报告和实地查看才能确定。',
+ 'Can I compare multiple postcodes?': '可以同时比较多个邮编吗?',
'Yes. The map is designed for consistent comparison across postcodes.':
- '是的。该地图旨在实现跨邮编的一致比较。',
- 'Check postcodes on the map': '检查地图上的邮编',
+ '可以。地图本身就是为跨邮编的统一口径比较而设计。',
+ 'Check postcodes on the map': '在地图上查询邮编',
'Regional guide': '区域指南',
'How to compare Birmingham postcodes before a property search':
- '如何在房产搜索前比较伯明翰邮编',
+ '展开房产搜索前,如何比较伯明翰各邮编',
'Birmingham property search - Compare postcodes by price and commute':
- '伯明翰房产搜索 - 按价格和通勤比较邮编',
+ '伯明翰房产搜索 - 按价格与通勤比较邮编',
'Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.':
- '在查看之前,使用邮编级别的数据来比较伯明翰的房价、通勤权衡、学校、犯罪、宽带和当地设施。',
+ '看房前,用邮编层级的数据比较伯明翰的房价、通勤取舍、学校、治安、宽带和周边设施。',
'Birmingham searches can change quickly from street to street. Use postcode-level evidence to compare budget, commute, schools, noise, crime, and local services before deciding where to watch listings.':
- '伯明翰的搜索可能因街道而异。在决定关注哪里的房源之前,使用邮编级别的证据来比较预算、通勤、学校、噪音、犯罪和当地服务。',
- 'Start with commute corridors': '从通勤走廊开始',
+ '伯明翰一街之隔,情况可能就大不相同。在决定关注哪片房源之前,先用邮编层级的数据比较预算、通勤、学校、噪音、治安和本地服务。',
+ 'Start with commute corridors': '从通勤动线入手',
'Choose the destination that matters, such as a workplace, station, university, or hospital, then compare reachable postcodes by transport mode and travel-time band.':
- '选择重要的目的地,例如工作场所、车站、大学或医院,然后按交通方式和出行时间范围比较可到达的邮编。',
+ '先锁定最重要的目的地——公司、车站、大学或医院——再按交通方式和出行时长档位比较可达的邮编。',
'Use commute time as a hard filter before judging price.':
- '在判断价格之前,使用通勤时间作为硬筛选条件。',
+ '判断价格之前,先把出行时间当作硬性筛选条件。',
'Compare public transport with car, cycling, or walking where available.':
- '将公共交通与汽车、骑自行车或步行(如果有)进行比较。',
- 'Check the route manually before booking viewings.': '在预约看房之前手动检查路线。',
- 'Compare price with property type': '将价格与房产类型进行比较',
+ '条件允许时,把公共交通与驾车、骑行、步行做对照。',
+ 'Check the route manually before booking viewings.': '预约看房前,再手动核实一次真实路线。',
+ 'Compare price with property type': '把价格和房产类型一并比较',
'Median prices alone can be misleading if the local property mix changes. Add property type, tenure, floor area, and price filters so similar areas are compared fairly.':
- '如果当地房地产结构发生变化,中位价格可能会产生误导。添加房产类型、产权、建筑面积和价格筛选条件,以便公平比较相似的区域。',
- 'Keep family and environment trade-offs visible': '让家庭和环境之间的权衡显而易见',
+ '一旦本地房屋结构发生变化,单看中位价就容易被误导。叠加房产类型、产权、建筑面积和价格筛选,才能在可比口径上比较相似区域。',
+ 'Keep family and environment trade-offs visible': '让家庭与环境层面的取舍一目了然',
'Layer school context, parks, road noise, broadband, and crime signals on top of the property filters. That makes it easier to decide which compromises are acceptable.':
- '将学校环境、公园、道路噪音、宽带和犯罪信号叠加在房产筛选条件之上。这使得更容易决定哪些妥协是可以接受的。',
+ '在房产筛选之上再叠加学校背景、公园、道路噪音、宽带和治安信号,更容易判断哪些妥协可以接受。',
'Can Perfect Postcode tell me the best area in Birmingham?':
- 'Perfect Postcode可以告诉我 伯明翰 最好的区域吗?',
+ 'Perfect Postcode 能告诉我伯明翰最好的区域吗?',
'No tool can decide the best area for every buyer. It helps compare postcodes against your own constraints so you can build a better shortlist.':
- '没有任何工具可以为每个买家决定最佳区域。它有助于将邮编与您自己的限制进行比较,以便您可以构建更好的候选名单。',
- 'Should I use this instead of local knowledge?': '我应该使用这个而不是本地知识吗?',
+ '没有任何工具能为所有买家定义「最好」。它的作用,是把邮编与您自己的条件做对照,帮您整理出更靠谱的候选名单。',
+ 'Should I use this instead of local knowledge?': '它能取代本地经验吗?',
'No. Use it to find and compare candidates, then validate them with visits, local advice, listings, and official checks.':
- '不是。用它来寻找和比较候选区域,然后通过实地走访、当地建议、房源信息和官方核查加以验证。',
+ '不能。请用它来发现和比较候选区域,再通过实地走访、本地建议、房源信息和官方核查加以验证。',
'Compare price patterns before looking at live listings.':
- '在查看实时房源之前先比较价格模式。',
+ '浏览实时房源之前,先把价格走势对比一遍。',
'Search by travel time and then layer on property requirements.':
- '按出行时间搜索,然后按财产要求分层。',
- 'Understand how to interpret filters and limitations.': '了解如何解释筛选条件和限制。',
- 'Compare Birmingham postcodes': '比较伯明翰 邮编',
+ '先按出行时间搜索,再叠加房产方面的需求。',
+ 'Understand how to interpret filters and limitations.': '了解如何解读筛选条件及其局限。',
+ 'Compare Birmingham postcodes': '比较伯明翰邮编',
'How to compare Manchester postcodes for a property search':
- '如何比较曼彻斯特邮编以进行房产搜索',
+ '展开房产搜索时,如何比较曼彻斯特各邮编',
'Manchester property search - Compare postcodes before viewing':
'曼彻斯特房产搜索 - 看房前比较邮编',
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.':
- '在预约看房之前,请按预算、通勤、房产类型、学校、宽带、犯罪、噪音和便利设施比较曼彻斯特地区的邮编。',
+ '预约看房之前,按预算、通勤、房产类型、学校、宽带、治安、噪音和周边设施比较曼彻斯特地区的邮编。',
'A Manchester-area search can span city-centre, suburban, and commuter options. Perfect Postcode helps keep each postcode comparable against the same property and daily-life constraints.':
- '曼彻斯特地区的搜索可以涵盖市中心、郊区和通勤选项。Perfect Postcode有助于使每个邮编在相同的财产和日常生活限制下具有可比性。',
- 'Use travel time to define the real search area': '使用出行时间来定义真正的搜索区域',
+ '曼彻斯特地区的搜索可能横跨市中心、近郊和通勤区。Perfect Postcode 让每个邮编都在同一套房产与生活条件下保持可比。',
+ 'Use travel time to define the real search area': '用出行时间划定真正的搜索范围',
'Start from the destinations that matter, then compare reachable postcodes rather than assuming every nearby place has the same practical journey.':
- '从重要的目的地开始,然后比较可到达的邮编,而不是假设附近的每个地方都有相同的实际旅程。',
- 'Compare housing requirements before lifestyle preferences': '在生活方式偏好之前比较住房要求',
+ '从真正重要的目的地出发,再去比较可达的邮编,而不是想当然地以为附近每个地方通勤都一样方便。',
+ 'Compare housing requirements before lifestyle preferences':
+ '先比较硬性居住需求,再谈生活方式偏好',
'Filter by property type, floor area, tenure, and price before judging amenities. That keeps the shortlist grounded in homes that could realistically work.':
- '在评判便利设施之前,先按房产类型、建筑面积、产权和价格进行筛选。这能让候选名单基于切实可行的房源。',
- 'Check local context consistently': '一致地查看当地背景',
+ '在评估周边设施之前,先按房产类型、建筑面积、产权和价格筛选,让候选名单立足于真正可行的房子之上。',
+ 'Check local context consistently': '用同一把尺子衡量周边背景',
'Use broadband, crime, road noise, parks, schools, and amenities as comparable signals. Then validate the strongest candidates with current local checks.':
- '将宽带、犯罪、道路噪音、公园、学校和便利设施作为可比信号。然后对最有潜力的候选区域通过当前的当地查证加以核实。',
+ '把宽带、治安、道路噪音、公园、学校、便利设施作为可比信号;再针对最具潜力的候选区域,通过最新的本地查证进一步核实。',
'Can I compare Manchester suburbs with city-centre postcodes?':
- '我可以将曼彻斯特郊区与市中心的邮编进行比较吗?',
+ '可以把曼彻斯特郊区与市中心的邮编放在一起比较吗?',
'Yes. Use the same budget, property, commute, and local-context filters across both so trade-offs remain visible.':
- '是的。在两者中使用相同的预算、房产、通勤和当地环境筛选条件,以便权衡仍然可见。',
- 'Does this include live listings?': '这包括实时房源吗?',
+ '可以。两边用同一套预算、房产、通勤与周边背景的筛选条件,取舍才会一目了然。',
+ 'Does this include live listings?': '里面包含实时房源吗?',
'No. Use it to decide where to search, then use listing portals for current homes for sale.':
- '不会。用它来决定在哪里搜索,然后使用当前待售房屋的房源平台。',
+ '不包含。请用它来决定去哪儿找,再到房源平台查看当下在售的房子。',
'Move from a broad search brief to specific postcode candidates.':
- '从宽泛的搜索意向转向具体的候选邮编。',
+ '把宽泛的购房需求落到具体的候选邮编。',
'Data sources': '数据来源',
'Review the datasets used for property and local-context comparison.':
- '查看用于房产和当地背景比较的数据集。',
- 'Check a single postcode before arranging a viewing.': '在安排看房之前检查单个邮编。',
+ '查看用于房产和周边背景比较的数据集。',
+ 'Check a single postcode before arranging a viewing.': '安排看房前,先查一个邮编。',
'Compare Manchester postcodes': '比较曼彻斯特邮编',
'How to compare Bristol postcodes before a property search':
- '如何在房产搜索前比较布里斯托尔邮编',
+ '展开房产搜索前,如何比较布里斯托尔各邮编',
'Bristol property search - Compare postcodes by commute and price':
- '布里斯托尔房产搜索 - 按通勤和价格比较邮编',
+ '布里斯托尔房产搜索 - 按通勤与价格比较邮编',
'Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.':
- '在查看之前,按价格、通勤、房产面积、学校、宽带、犯罪、道路噪音、公园和便利设施比较布里斯托尔邮编。',
+ '看房之前,按价格、通勤、房屋面积、学校、宽带、治安、道路噪音、公园和便利设施比较布里斯托尔的各个邮编。',
'Bristol searches often involve sharp trade-offs between price, journey time, property size, and neighbourhood context. A postcode-first comparison keeps those trade-offs visible.':
- '布里斯托尔的搜索通常涉及价格、出行时间、房产面积和社区环境之间的尖锐权衡。邮编优先的比较使这些权衡显而易见。',
- 'Make commute constraints explicit': '明确通勤限制',
+ '在布里斯托尔找房,价格、通勤时间、房屋面积、社区背景之间常常要做艰难取舍。以邮编为单位比较,才能让这些取舍清晰可见。',
+ 'Make commute constraints explicit': '把通勤条件摆到明面上',
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
- '如果前往中心、车站、医院、大学或商业园区很重要,请首先使用出行时间筛选条件,然后通过房产数据比较剩余的邮编。',
- 'Compare value, not just headline price': '比较价值,而不仅仅是标题价格',
+ '如果前往市中心、车站、医院、大学或商业园区的便利度很关键,请先用出行时间筛选,再用房产数据比较剩下的邮编。',
+ 'Compare value, not just headline price': '比的是性价比,不是表面价',
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
- '将价格、房产类型和建筑面积筛选条件结合使用。这有助于将低成本区域与仅包含较小或不同房屋的区域区分开来。',
- 'Screen environmental and local-service signals': '筛选环境和本地服务信号',
+ '把价格、房产类型、建筑面积筛选一起使用,才能区分真正便宜的区域和那些只是房子更小或户型不同的区域。',
+ 'Screen environmental and local-service signals': '初筛环境与本地服务信号',
'Road noise, parks, broadband, crime, and amenities can affect whether a property works day to day. Use them as screening criteria before booking viewings.':
- '道路噪音、公园、宽带、犯罪和便利设施都会影响房产的日常运作。在预约看房之前将它们用作筛选标准。',
+ '道路噪音、公园、宽带、治安、便利设施都会影响房子日常住起来好不好。预约看房前,请把这些作为筛选标准。',
'Can I use this for commuter villages around Bristol?':
- '我可以将其用于布里斯托尔周围的通勤村庄吗?',
+ '能用它来看布里斯托尔周边的通勤小镇吗?',
'Yes, where the relevant postcode and travel-time data is available. Always verify routes and services manually before deciding.':
- '是的,只要有相关邮编和出行时间数据即可。在做出决定之前,请务必手动验证路线和服务。',
- 'Can this tell me whether a listing is good value?': '这可以告诉我列表是否物有所值吗?',
+ '在相关邮编和出行时间数据齐备的范围内可以。决定之前,请务必手动核实路线和班次。',
+ 'Can this tell me whether a listing is good value?': '它能判断某套房源是否物有所值吗?',
'It can provide area context, but a specific listing still needs comparable sales, condition checks, survey findings, and professional advice where appropriate.':
- '它可以提供区域背景,但特定列表仍然需要可比较的销售、状况检查、验房结果和适当的专业建议。',
+ '它能提供区域背景,但具体某套房源仍需结合可比成交、房屋状况检查、验房报告以及必要的专业建议来判断。',
'Search by reachable postcodes before refining by budget and local context.':
- '先按可达的邮编进行搜索,然后再按预算和当地情况进行细化。',
+ '先按可达的邮编搜索,再按预算和周边背景细化。',
'Understand price patterns before setting listing alerts.':
- '在设置房源提醒之前了解价格模式。',
- 'Privacy and security': '隐私和安全',
+ '设置房源提醒之前,先摸清价格走势。',
+ 'Privacy and security': '隐私与安全',
'How account and saved-search data is handled in the product.':
- '产品中如何处理帐户和已保存的搜索数据。',
+ '了解产品如何处理账户与已保存搜索的数据。',
'Compare Bristol postcodes': '比较布里斯托尔邮编',
'Trust and coverage': '可信度与覆盖范围',
- 'Perfect Postcode data sources and coverage': 'Perfect Postcode 数据来源和覆盖范围',
+ 'Perfect Postcode data sources and coverage': 'Perfect Postcode 的数据来源与覆盖范围',
'Perfect Postcode data sources - Property, schools, commute and local context':
- 'Perfect Postcode 数据来源 - 房产、学校、通勤和当地背景',
+ 'Perfect Postcode 数据来源 - 房产、学校、通勤与周边背景',
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
- '查看 Perfect Postcode 使用的公共和官方数据集,包括房价、EPC、学校、犯罪、宽带、噪音和出行时间背景。',
+ '了解 Perfect Postcode 所用的公开与官方数据集,涵盖房价、EPC、学校、治安、宽带、噪音和出行时间。',
'Perfect Postcode combines property, transport, education, environment, and local-service datasets so buyers can compare postcodes consistently. This page explains what the data is for and where it should be verified.':
- 'Perfect Postcode 结合了房地产、交通、教育、环境和本地服务数据集,因此买家可以一致地比较邮编。此页面解释了数据的用途以及应在何处验证数据。',
- 'Property and housing context': '房产和住房背景',
+ 'Perfect Postcode 把房产、交通、教育、环境和本地服务等多个数据集整合在一起,让买家可以用统一口径比较各个邮编。本页说明这些数据的用途,以及哪些地方还需进一步核实。',
+ 'Property and housing context': '房产与住房背景',
'The product uses property transaction and housing-context datasets to support filters such as sale price, property type, tenure, floor area, energy performance, and estimated current value.':
- '该产品使用房产交易和住房背景数据集来支持销售价格、房产类型、产权、建筑面积、能源绩效和估计当前价值等筛选条件。',
+ '产品基于房产交易与住房背景数据集,支持按成交价、房产类型、产权、建筑面积、能源表现、当前估值等条件筛选。',
'Use these fields to compare areas, not as a formal valuation.':
- '使用这些字段来比较区域,而不是作为正式估值。',
+ '请把这些字段当作区域比较的依据,而非正式估值。',
'Check current listings, title information, lender requirements, and survey results before buying.':
- '购买前检查当前房源、产权信息、贷方要求和验房结果。',
- 'Schools, safety, broadband, and environment': '学校、安全、宽带和环境',
+ '购房前请核对最新房源、产权信息、贷款机构要求和验房结果。',
+ 'Schools, safety, broadband, and environment': '学校、治安、宽带与环境',
'Local-context filters help compare postcodes on signals that affect daily life. They should be treated as screening data and checked against current official sources for decisions.':
- '当地背景筛选条件有助于按影响日常生活的指标比较邮编。它们应被视为初筛数据,决策时需参照当前的官方来源核实。',
+ '周边背景筛选可从影响日常生活的指标上比较各邮编。请把它们视为初筛数据,真正做决定时再以最新官方来源为准。',
'Travel-time data': '出行时间数据',
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
- '出行时间筛选条件旨在实现一致的区域比较。在选定某个区域之前,应验证路线可用性、中断情况、停车位、步行通道和时刻表详情。',
- 'Why does coverage focus on England?': '为什么覆盖范围聚焦于英格兰?',
+ '出行时间筛选用于以统一口径比较各区域。锁定某个区域之前,请核实线路是否开通、是否有中断、停车条件、步行通达性以及具体班次。',
+ 'Why does coverage focus on England?': '为什么覆盖范围以英格兰为主?',
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
- '若干核心的房产、教育和当地背景数据集仅适用于特定司法辖区。聚焦英格兰能让比较保持更一致。',
- 'How should I handle stale or missing data?': '我应该如何处理陈旧或丢失的数据?',
+ '若干核心的房产、教育和周边背景数据集只覆盖特定司法辖区。聚焦英格兰,比较的口径才更一致。',
+ 'How should I handle stale or missing data?': '遇到数据过时或缺失,该怎么办?',
'Use the map as a shortlist tool. If a postcode matters, verify the latest details with current official sources and direct local checks.':
- '将地图当作候选名单工具。如果某个邮编很关键,请通过当前的官方来源核实最新信息,并直接进行当地查证。',
- 'How filters and comparisons should be interpreted.': '应如何理解筛选条件和比较结果。',
- 'Review postcode-level context before a viewing.': '在看房前查看邮编层级的背景信息。',
- 'How saved searches and account data are handled.': '如何处理保存的搜索和帐户数据。',
+ '请把地图当作候选名单工具。某个邮编若对您很关键,请通过最新官方来源核实详情,再辅以实地查证。',
+ 'How filters and comparisons should be interpreted.': '了解应如何解读筛选条件和比较结果。',
+ 'Review postcode-level context before a viewing.': '看房前先查邮编层级的背景信息。',
+ 'How saved searches and account data are handled.': '了解已保存搜索与账户数据的处理方式。',
'How to use the map': '如何使用地图',
'Methodology for postcode property research': '邮编房产研究方法论',
'Perfect Postcode methodology - How to interpret postcode property data':
- 'Perfect Postcode 方法论 - 如何理解邮编房产数据',
+ 'Perfect Postcode 方法论 - 如何解读邮编房产数据',
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.':
- '了解如何使用邮编筛选条件、房产估算、出行时间数据、学校背景和当地信号作为购房候选名单工具。',
+ '了解如何把邮编筛选、房产估值、出行时间数据、学校背景和本地信号当作购房候选名单工具来使用。',
'Perfect Postcode is designed to make area shortlisting more evidence-led. It doesn’t replace estate agents, surveyors, conveyancers, lenders, school admissions teams, or local authority checks.':
- 'Perfect Postcode 旨在让区域候选更以证据为导向。它不能取代房地产中介、测量师、产权转让律师、贷款机构、学校招生部门或地方政府的核查。',
- 'Start with hard constraints': '从硬性约束开始',
+ 'Perfect Postcode 的目标是让区域初筛更有数据依据。它替代不了房产中介、测量师、产权过户律师、贷款机构、学校招生部门或地方政府的核查。',
+ 'Start with hard constraints': '从硬条件入手',
'Begin with non-negotiables such as budget, property type, floor area, commute time, and essential services. This removes impossible postcodes before softer preferences are considered.':
- '从预算、房产类型、建筑面积、通勤时间和基本服务等不可妥协的条件入手。在考虑次要偏好之前,先排除不可能的邮编。',
- 'Use colour layers for trade-offs': '使用颜色层进行权衡',
+ '先把不可让步的条件填上:预算、房产类型、建筑面积、通勤时间、基本服务。这样才能在考虑次要偏好之前,先把根本不可行的邮编剔除。',
+ 'Use colour layers for trade-offs': '用颜色图层呈现取舍',
'After filtering, colour the remaining map by one signal at a time: price per square metre, road noise, school context, commute time, broadband, or crime. This makes trade-offs easier to discuss.':
- '筛选后,一次按一个信号对剩余地图进行着色:每平方米价格、道路噪音、学校环境、通勤时间、宽带或犯罪情况。这使得权衡更容易讨论。',
- 'Measure what’s working': '衡量哪些内容有效',
+ '筛选完成后,每次只用一个维度为剩余地图着色:每平方米单价、道路噪音、学校背景、通勤时间、宽带或治安。讨论取舍时会直观得多。',
+ 'Measure what’s working': '检验效果',
'Use Search Console and analytics to track which public pages are indexed, which queries produce impressions, and which pages convert visitors into dashboard exploration. Review Core Web Vitals after every substantial frontend change.':
- '使用 Search Console 和分析来跟踪哪些公共页面被索引、哪些查询产生展示次数以及哪些页面将访问者转化为仪表板探索。在每次重大前端更改后查看 Core Web Vitals。',
- 'Can the tool choose the right postcode for me?': '该工具可以为我选择正确的邮编吗?',
+ '通过 Search Console 与分析工具跟踪哪些公开页面已被收录、哪些搜索词带来了展示、哪些页面把访客引入了仪表板的深入探索。每次前端有较大改动后,记得复查 Core Web Vitals。',
+ 'Can the tool choose the right postcode for me?': '工具能替我选出合适的邮编吗?',
'No. It helps compare evidence and reduce the search area. The final decision needs direct visits, current listings, legal checks, surveys, and personal judgement.':
- '不能。它能帮您比较证据并缩小搜索范围。最终决定还需要实地走访、当前房源、法律核查、验房和个人判断。',
- 'How should I use estimates?': '我应该如何使用估算值?',
+ '不能。它帮您比对数据、缩小范围;最终的决定仍需结合实地走访、最新房源、法律核查、验房和个人判断。',
+ 'How should I use estimates?': '估值数据该怎么用?',
'Use estimates as comparison signals, not as professional valuations or purchase advice.':
- '将估算值作为比较参考,而不是专业估价或购房建议。',
- 'Understand where key filters come from.': '了解关键筛选条件的来源。',
- 'Apply the methodology to price-led area comparison.': '将该方法应用于价格主导的区域比较。',
- 'Apply the methodology to destination-led search.': '将该方法应用于以目的地为主导的搜索。',
- Trust: '信任',
+ '请把估值当作比较参考,而非专业估价或购房建议。',
+ 'Understand where key filters come from.': '了解关键筛选条件的数据来源。',
+ 'Apply the methodology to price-led area comparison.': '将这套方法用于以价格为主的区域比较。',
+ 'Apply the methodology to destination-led search.': '将这套方法用于以目的地为主的搜索。',
+ Trust: '可信赖',
'Privacy and security for saved property searches': '已保存房产搜索的隐私与安全',
'Perfect Postcode privacy and security - Saved searches and account data':
- 'Perfect Postcode 隐私与安全 - 已保存的搜索和账户数据',
+ 'Perfect Postcode 隐私与安全 - 已保存搜索与账户数据',
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.':
- '了解 Perfect Postcode 如何在考虑隐私和安全的情况下处理已保存的搜索、帐户数据和房产研究工作流程。',
+ '了解 Perfect Postcode 如何在隐私与安全前提下处理已保存搜索、账户数据与房产研究流程。',
'Property research can reveal personal priorities, budgets, and locations. The product keeps public SEO pages separate from account-only areas and marks private dashboard/account routes as noindex.':
- '房产研究可能暴露个人偏好、预算和地点。本产品将公共 SEO 页面与仅限账户访问的区域分开,并将私人面板/账户路径标记为 noindex(禁止索引)。',
- 'Public pages and private areas are separated': '公共页面和私人区域分开',
+ '房产研究可能透露个人偏好、预算与所在地点。本产品将公开的 SEO 页面与仅限账户访问的区域严格分开,并将私有的面板/账户路径标记为 noindex(不被索引)。',
+ 'Public pages and private areas are separated': '公开页面与私有区域相互分离',
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
- '营销、方法论、指南和支持页面是可被搜索引擎索引的。面板、账户、已保存搜索、邀请和邀请页面则被标记为 noindex,或在适当情况下被阻止爬虫访问。',
- 'Saved search data is account-scoped': '已保存的搜索数据仅限账户范围',
+ '营销、方法论、指南与支持页面可被搜索引擎索引;面板、账户、已保存搜索、邀请及相关路径则会被标记为 noindex,或在必要时屏蔽爬虫访问。',
+ 'Saved search data is account-scoped': '已保存搜索的数据仅限账户内部',
'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
- '已保存的搜索和分享链接仅供已登录用户使用。它们不包含在公共站点地图中,也不应作为公共内容被爬取。',
- 'Search measurement without exposing private data': '在不暴露私人数据的前提下衡量搜索效果',
+ '已保存的搜索与分享链接仅面向已登录用户。它们不会进入公开站点地图,也不应作为公开内容被爬取。',
+ 'Search measurement without exposing private data': '衡量搜索效果,又不暴露私有数据',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.':
- 'SEO 衡量应基于公共页面,使用聚合分析和 Search Console 数据完成。私有查询参数和账户视图不应成为可被索引的落地页。',
- 'Are saved searches listed in the sitemap?': '站点地图中是否列出了已保存的搜索?',
+ 'SEO 衡量应只在公开页面上进行,借助聚合分析与 Search Console 数据完成。私有查询参数和账户视图不应变成可被索引的落地页。',
+ 'Are saved searches listed in the sitemap?': '已保存的搜索会出现在站点地图里吗?',
'No. Public SEO pages are listed; account and saved-search routes are intentionally excluded.':
- '不会。仅列出公共 SEO 页面;账户和已保存搜索的路径被有意排除。',
- 'Can private dashboard URLs appear in search?': '私人面板的 URL 会出现在搜索结果中吗?',
+ '不会。站点地图只列出公开的 SEO 页面,账户与已保存搜索的路径都被有意排除在外。',
+ 'Can private dashboard URLs appear in search?': '私有面板的网址会出现在搜索结果里吗?',
'They shouldn’t be indexed. The server marks private routes noindex and the sitemap only lists public pages.':
- '它们不应被索引。服务器会将私有路径标记为 noindex,并且站点地图仅列出公共页面。',
- 'How to use public postcode data responsibly.': '如何负责任地使用公共邮编数据。',
- 'What data powers the public comparisons.': '哪些数据支持公众比较。',
- 'Explore public postcode-search workflows.': '探索公共邮编搜索工作流程。',
+ '不应该。服务器会把私有路径标记为 noindex,站点地图也只列出公开页面。',
+ 'How to use public postcode data responsibly.': '了解如何负责任地使用公开的邮编数据。',
+ 'What data powers the public comparisons.': '了解哪些数据支撑着公开的比较结果。',
+ 'Explore public postcode-search workflows.': '探索公开的邮编搜索流程。',
},
},
@@ -636,6 +643,8 @@ const zh: Translations = {
ethnicity: '族裔',
poiType: 'POI 类型',
party: '政党',
+ travelTimeKeywords:
+ '通勤 通勤时间 出行 出行时间 旅行 旅行时间 路程 行程 驾车 开车 汽车 自行车 单车 骑行 骑车 步行 走路 公共交通 公交 交通 运输 车站 地铁 火车 公共汽车 巴士 路线 travel time journey commute car bicycle bike walking transit transport station tube train',
},
// ── Philosophy Popup ───────────────────────────────
@@ -766,6 +775,8 @@ const zh: Translations = {
showAllStatsHint: '筛选前这里有 {{count}} 处房产。切换到全部房产即可查看该区域。',
showAllStatsFallback: '切换到全部房产即可在不应用当前筛选条件的情况下查看该区域。',
showAllStats: '显示全部房产',
+ closestStations: '最近的车站',
+ noNearbyStations: '2 公里内没有火车站或地铁站',
closestBlockingFilters: '纳入该区域所需的最小调整',
lowerMinTo: '将最小值降至 {{value}}',
raiseMaxTo: '将最大值提高至 {{value}}',
@@ -837,13 +848,14 @@ const zh: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
- heroEyebrow: '适合正在问“我到底该看哪里?”的买家',
+ heroEyebrow: '献给那些正在问"我到底该去哪儿找"的买家',
heroTitle1: '找到真正',
heroTitle2: '适合您生活的邮编',
- heroTitle3: '不只局限于您已经知道的区域。',
- heroSubtitle: '从伦敦街区到通勤城镇和英格兰各地城市,可研究的地方太多,无法一个个筛查。',
+ heroTitle3: '不只局限于您已经熟悉的区域。',
+ heroSubtitle:
+ '从伦敦街区,到通勤城镇,再到英格兰各地的城市——可看的地方太多,根本一个个筛不过来。',
heroDescription:
- '设定预算、通勤、学校、安全、噪音、宽带和生活方式需求。Perfect Postcode 会扫描英格兰的邮编,显示真正匹配的地方,包括您从未想过要在房源网站上搜索的区域。',
+ '把预算、通勤、学校、治安、噪音、宽带、生活方式的要求都设好。Perfect Postcode 会扫描英格兰每一个邮编,找出真正契合的地方,连您压根没想过要在房源网站上搜的区域也一并呈现。',
exploreTheMap: '找到匹配的邮编',
seeTheDifference: '查看使用方式',
productDemoLabel: 'Perfect Postcode 产品演示',
@@ -868,12 +880,12 @@ const zh: Translations = {
showcaseSendShortlist: '发送候选名单',
showcaseDownloadXlsx: '下载 .xlsx',
showcaseTopThree: '前 3 名',
- showcaseScoutBullet1: '在房源搜索缩小选择之前,先实地走走街道。',
- showcaseScoutBullet2: '从真实门牌测试通勤,而不是只看行政区名称。',
- showcaseScoutBullet3: '带着已有证据比较看房结果。',
+ showcaseScoutBullet1: '别等房源把选项缩小,先去街上走一走。',
+ showcaseScoutBullet2: '从真实门牌算通勤,而不是只看行政区名。',
+ showcaseScoutBullet3: '带着已有证据去比较看房结果。',
showcaseStep1Tab: '筛选',
showcaseStep1Title: '把模糊需求变成精准搜索',
- showcaseStep1Body: '设置真正重要的条件,并清楚看到每项要求为您排除了多少不合适的邮编。',
+ showcaseStep1Body: '设好真正重要的条件,看清每加一个要求帮您剔除了多少不合适的邮编。',
showcaseStep1Chip1: '安静街道',
showcaseStep1Chip2: '顶级小学',
showcaseStep1Chip3: '£500,000 以内',
@@ -881,14 +893,14 @@ const zh: Translations = {
showcaseStep2Tab: '匹配',
showcaseStep2Title: '让地图浮现您原本不会输入的地方',
showcaseStep2Body:
- '按匹配度扫描英格兰,而不是从熟悉的地名开始。房源门户缩小您的想象之前,隐藏的好区域会先显现出来。',
+ '按匹配度扫描整个英格兰,不再从熟悉的地名出发。趁房源平台还没收窄您的想象,那些藏起来的好区域就会先浮上来。',
showcaseStep2Region: '大伦敦',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: '匹配集群',
showcaseStep3Tab: '检查',
- showcaseStep3Title: '查看某个邮编为什么入选',
+ showcaseStep3Title: '看清某个邮编为什么入选',
showcaseStep3Body:
- '打开任何匹配区域,在一个面板中查看价格、安全、学校、宽带和取舍,再决定是否花一个周末去实地看。',
+ '打开任意匹配区域,在同一个面板里把价格、治安、学校、宽带和取舍一次看完,再决定是否花一个周末去实地走一趟。',
showcaseStep3HeaderArea: '您的理想邮编',
showcaseStep3HeaderFit: '社区证据',
showcaseStep3Stat1Label: '成交价走势',
@@ -902,7 +914,7 @@ const zh: Translations = {
showcaseStep4Tab: '踏勘',
showcaseStep4Title: '亲自去看一看',
showcaseStep4Body:
- '带着三个有数据支撑的起点走进现实。实地走街、测试通勤,并带着背景信息比较看房结果。',
+ '带上三个有数据撑腰的起点走进现实——实地走街、亲身试通勤,再带着背景资料比较看房结果。',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: '导出到 Excel',
showcaseStep4ColPostcode: '邮编',
@@ -916,40 +928,39 @@ const zh: Translations = {
statPostcodeInEngland: '英格兰每个邮编',
ourPhilosophy: '先明确重要条件,再找到合适的邮编',
philosophyP1:
- '大多数房产网站先问您想住哪里。在伦敦这个问题尤其困难,但英格兰各地都有同样的问题:买家通常只能从几个熟悉的地方开始,然后分别查询通勤、学校、犯罪率、街景、宽带和成交价。',
+ '大多数房产网站上来就问:您想住哪儿?在伦敦尤其难答,英格兰其他地方也是一样的——买家通常只能从几个熟悉的地方入手,再分别去查通勤、学校、治安、街景、宽带和成交价。',
philosophyP2:
- 'Perfect Postcode 反过来做搜索。告诉地图什么重要,它会显示符合条件的邮编,并解释为什么值得查看。先看数据,再去现场感受。',
- streetTitle: '每条街都可能不同',
+ 'Perfect Postcode 把搜索方向反了过来:告诉地图什么对您重要,它就会显示符合条件的邮编,并讲清楚为什么值得一看。先看数据,再去现场感受。',
+ streetTitle: '一街之隔,可能就大不一样',
streetIntro:
- '大的区域名称会掩盖关键细节:车站哪一侧、道路噪音、学校组合、真实通勤时间,以及类似房产的实际成交价。',
+ '笼统的区域名容易掩盖关键差异:在车站哪一侧、道路噪音、学校组合、真实通勤时间,以及同类房产的实际成交价。',
streetCard1Title: '发现您可能错过的区域',
- streetCard1Body:
- '根据您的条件找出匹配的邮编,而不是只依赖熟悉的地名、朋友推荐或“潜力区域”的宣传。',
+ streetCard1Body: '根据您的条件找出匹配的邮编,不再只凭熟悉的地名、朋友推荐或"潜力区域"的宣传。',
streetCard2Title: '看房前先看清取舍',
streetCard2Body:
- '在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。',
+ '把周末花在看房之前,先把价格、空间、通勤、治安、学校、宽带、噪音和能源评级一并对比。',
othersVs: '与其他平台对比',
checkMyPostcode: '房源门户',
areaGuides: '邮编报告',
- compSearchWithout: '在知道名称前先发现区域',
- compSearchWithoutSub: '(先需求,后地点)',
- compAreaData: '邮编级社区证据',
- compAreaDataSub: '(犯罪率、学校、噪音、宽带、设施)',
- compPropertyData: '房产级历史记录',
+ compSearchWithout: '不知道地名也能先发现区域',
+ compSearchWithoutSub: '(先看需求,再定地点)',
+ compAreaData: '邮编级的社区证据',
+ compAreaDataSub: '(治安、学校、噪音、宽带、配套)',
+ compPropertyData: '房产级的历史记录',
compPropertyDataSub: '(成交价、EPC、面积、估值)',
compFilters: '56 项联动筛选',
- compFiltersSub: '(不是一次查一个邮编或一个房源)',
- ctaTitle: '别再猜哪里值得买。',
- ctaDescription: '先建立符合真实生活需求的邮编候选名单,再去实地感受。',
+ compFiltersSub: '(不必一次只查一个邮编或一套房)',
+ ctaTitle: '别再猜哪儿值得买。',
+ ctaDescription: '先按真实生活需求建好邮编候选名单,再去实地感受。',
},
// ── Pricing Page ───────────────────────────────────
pricingPage: {
- title: '用更好的搜索区域来买房',
- subtitle: '终身访问这张地图,在预约看房前先弄清应该看哪里。',
+ title: '用更靠谱的搜索范围去买房',
+ subtitle: '终身访问这张地图,预约看房前先弄清楚该去哪儿看。',
costContext:
- '买家常常把晚上花在拼接房源、通勤查询、学校报告、犯罪地图、Street View 和成交价上。在伦敦这尤其折磨人,但同样的研究问题存在于整个英格兰。Perfect Postcode 会先把区域研究放在一张地图上,再让您投入周末、费用和精力。',
- lessThanSurvey: '费用低于一次房屋测量,却能更大程度地指导您的选择。',
+ '买家常常把晚上耗在拼凑房源、通勤查询、学校报告、犯罪地图、Street View 和成交价上。这件事在伦敦尤其折磨人,但同样的研究困境存在于整个英格兰。Perfect Postcode 把区域研究先收进一张地图,再让您投入周末、费用和精力。',
+ lessThanSurvey: '花费不及一次验房,对您的选择却能起到更大的指导作用。',
currentTier: '当前档位',
firstNUsers: '前 {{count}} 名用户',
everyoneAfter: '之后的所有人',
@@ -981,12 +992,12 @@ const zh: Translations = {
articles: '文章',
support: '支持',
dataSourcesIntro:
- '本应用整合了 {{count}} 个开放数据集,涵盖房产价格、能源性能、交通、人口统计、犯罪、环境等领域。',
+ '本应用整合了 {{count}} 个公开数据集,覆盖房价、能源性能、交通、人口、犯罪、环境等多个领域。',
faqIntro:
- '无论您是在缩小首次购房搜索范围、核查陌生邮编,还是建立看房候选名单,以下是 Perfect Postcode 如何帮您弄清该看哪里。',
+ '无论您是在缩小首次购房的搜索范围、核查一个陌生邮编,还是建立看房候选名单,Perfect Postcode 都能帮您弄清楚到底该去哪儿看——下面是常见问题。',
articlesIntro:
- '浏览关于房产搜索、通勤、学校、邮编检查、区域对比、数据覆盖、方法论和隐私的公开指南。',
- supportIntro: '有问题?请查看我们的常见问题或直接联系我们。',
+ '浏览关于房产搜索、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
+ supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
source: '来源:',
optOut: '退出公开披露',
attribution: '数据引用声明',
@@ -1077,102 +1088,102 @@ const zh: Translations = {
// FAQ items — Finding Your Area
faqFinding1Q: '明显的区域太贵时,我应该去哪里找?',
faqFinding1A:
- '设置预算、房产类型、室内面积、通勤、学校、犯罪率、噪音、宽带、公园等硬性条件。地图会排除不符合这些条件的邮编,让容易被忽略的区域在您开始看房源之前先浮现出来。',
- faqFinding2Q: '如何在不熟悉的地方找到好的邮编?',
+ '把预算、房产类型、室内面积、通勤、学校、治安、噪音、宽带、公园这些硬条件都设好。地图会剔除不符合的邮编,让容易被忽略的区域抢在您逛房源之前先浮现出来。',
+ faqFinding2Q: '不熟悉的地方,如何找到好邮编?',
faqFinding2A:
- '先用硬性条件筛选整张地图,再查看剩下的聚集区域。您可以按通勤、成交价、学校、犯罪率、宽带、噪音和配套来比较陌生邮编,而不是只依赖口碑。',
- faqFinding3Q: '搜索结果太多或太少时该怎么办?',
+ '先用硬条件把整张地图筛一遍,再看剩下来的聚集区。陌生邮编可以按通勤、成交价、学校、治安、宽带、噪音和配套来比较,不必只依赖口碑。',
+ faqFinding3Q: '搜索结果过多或过少,该怎么办?',
faqFinding3A:
- '先保留硬性条件,再按一个要比较的因素为地图着色,例如每平方米价格、道路噪音、学校评分或通勤时间。如果结果太少,放宽一个滑块,就能看到哪个变化会打开更多选择。',
+ '硬条件先保留不动,再用一个想比较的维度给地图着色:每平方米单价、道路噪音、学校评分或通勤时间都行。如果结果太少,放宽其中一个滑块,看看哪个让步能带来最多选项。',
// FAQ items — Commute and Travel
faqCommute1Q: '出行时间是如何计算的?',
faqCommute1A:
- '出行时间会针对每个已保存目的地提前计算。我们会判断哪些邮编可以通过开车、骑车、步行或公共交通到达该目的地,然后保存结果,让您筛选时地图能快速响应。',
- faqCommute2Q: '这些出行时间数字有什么限制?',
+ '我们会对每个已保存的目的地预先算好出行时间,判断哪些邮编能通过驾车、骑行、步行或公共交通到达,并把结果保存下来,让您筛选时地图能即时响应。',
+ faqCommute2Q: '这些出行时间数字有什么局限?',
faqCommute2A:
- '公共交通时间基于工作日早晨通勤,出发时间在 07:30 到 08:30 之间。普通设置显示该时段内的典型行程。这些是用于规划的估算,不包含实时延误、交通状况或临时站台变化。',
- faqCommute3Q: '什么时候使用“最佳情况”按钮?',
+ '公共交通时间基于工作日早晨通勤,出发时间在 07:30 到 08:30 之间,默认显示该时段的典型行程。这些是用于规划的估算,不包含实时延误、交通状况或临时改月台。',
+ faqCommute3Q: '什么时候用"最佳情况"按钮?',
faqCommute3A:
- '在公共交通中,如果想查看出发时间配合较好、换乘顺利时的通勤情况,可以使用“最佳情况”按钮。日常比较时保持关闭即可。',
+ '在公共交通模式下,若想查看出发时间踩得准、换乘也顺利时的通勤表现,就开"最佳情况"。日常比较时关掉即可。',
// FAQ items — Budget and Value
faqBudget1Q: '你们如何估算当前房价?',
faqBudget1A:
- '估算从 HM Land Registry 记录的最近成交价开始。我们会观察类似房屋的价值如何随时间变化,尤其是附近同类型房屋,从而把这次成交价调整到更接近今天的市场。当地成交较少时,会更多参考更大区域的趋势。最后还会结合附近近期成交和房屋面积进行校验。',
- faqBudget2Q: '为什么要用估计当前价格,而不是最近成交价?',
+ '估算从 HM Land Registry 记录的最近成交价出发。我们会观察类似房屋的价值如何随时间变化——尤其是附近同类型房屋——把这笔成交调整到接近今天的市场水平。当地成交较少时,会更多参考更大范围的走势;最后再结合附近近期成交和房屋面积做校验。',
+ faqBudget2Q: '为什么用估计当前价格,而不是最近成交价?',
faqBudget2A:
- '最近成交价可能是几年甚至几十年前的价格,而挂牌价只覆盖今天正在出售的房源。估计当前价格把旧成交放到更接近今天市场的水平,方便比较更多房屋,并发现可能更有价值的区域。请把它当作筛选参考,而不是银行估值。',
+ '最近成交价可能是好几年甚至几十年前的价格,挂牌价又只覆盖当下在售的房源。估计当前价格把旧成交折算到接近今天的市场水平,可比较的房屋更多,也更容易发现潜在价值区域。请把它当作筛选参考,而非银行估值。',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: '这个邮编周边常见哪些犯罪类型?',
faqSafety1A:
- '警方记录的犯罪会按类型拆分,包括暴力、入室盗窃、抢劫、车辆犯罪、反社会行为、商店行窃、毒品和公共秩序等。您可以按自己关心的具体风险筛选,而不是依赖一个模糊的安全分。',
- faqSafety2Q: '看一条陌生街道前应该先查什么?',
+ '警方记录的犯罪按类型分项呈现:暴力、入室盗窃、抢劫、车辆犯罪、反社会行为、商店行窃、毒品、公共秩序等等。您可以按自己关心的具体风险筛选,而不是看一个笼统的安全分。',
+ faqSafety2Q: '看一条陌生街道之前,该先查什么?',
faqSafety2A:
- '预约前先查犯罪率、道路噪音、宽带、公园、食品店、学校和通勤。房源照片仍然有用,但不应该是您第一次了解这条街的方式。',
+ '预约前先把治安、道路噪音、宽带、公园、食品店、学校和通勤查一遍。房源照片仍然有用,但不该成为您第一次了解这条街的方式。',
// FAQ items — Families and Schools
faqFamilies1Q: '哪些区域在学校、空间、安全和通勤之间取得了合适平衡?',
faqFamilies1A:
- '把学校评分、犯罪率、公园、通勤、空间、房屋类型和预算放到一张地图上。结果是实用的家庭候选名单,而不是一堆分散查询。',
- faqFamilies2Q: '这能证明我在某所学校的招生范围内吗?',
+ '把学校评分、治安、公园、通勤、空间、房屋类型和预算汇到同一张地图上,结果就是一份实用的家庭候选名单,不必再东查一处、西查一处。',
+ faqFamilies2Q: '这能证明我在某所学校的招生范围里吗?',
faqFamilies2A:
- '不能。我们显示附近学校质量和本地教育信息,但招生边界和优先规则可能变化。请用 Perfect Postcode 先筛选地点,再向学校或地方政府核实招生范围和录取规则。',
+ '不能。我们呈现的是附近学校质量和本地教育情况,但招生边界和优先规则随时可能变。请先用 Perfect Postcode 圈定地点,再向学校或地方政府核实招生范围和录取规则。',
// FAQ items — Environment and Quality of Life
faqEnv1Q: '如何避开嘈杂道路,同时不牺牲通勤或宽带质量?',
faqEnv1A:
- '按道路噪音筛选,同时保留通勤、宽带、价格和房屋筛选条件。您可以按某一项给地图着色,而其他条件会保持候选名单可靠。',
- faqEnv2Q: '是否显示洪水、地基沉降或验房风险?',
+ '按道路噪音筛选的同时,把通勤、宽带、价格和房屋条件一并保留。再用其中一项给地图着色,其他筛选会继续把候选名单守得稳稳的。',
+ faqEnv2Q: '会显示洪水、地基沉降或验房风险吗?',
faqEnv2A:
- '目前不提供。我们会显示道路噪音、能源评级、建造年代和邮编周边环境。洪水风险、法律问题、结构问题、贷款问题和验房结果仍需要在购房前单独核实。',
- faqEnv3Q: '看房前能做哪些运行成本检查?',
+ '目前不会。我们呈现的是道路噪音、能源评级、建造年代和邮编周边环境。洪水风险、法律问题、结构隐患、贷款条件和验房结果,仍需在购房前单独核实。',
+ faqEnv3Q: '看房前能做哪些用房成本预估?',
faqEnv3A:
- '看房前可以先查看能源评级、建筑面积、建造年代、市政税辖区、宽带和噪音。这无法预测您的精确账单,但能帮助您尽早避开明显不合适的房子。',
+ '看房前可以先看能源评级、建筑面积、建造年代、市政税辖区、宽带和噪音。这没法预测您每月的具体账单,但能帮您尽早排除明显不合适的房子。',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: '应该在查看 Rightmove 前还是之后使用?',
faqDueDiligence1A:
- 'Perfect Postcode 适合在房源网站之前和同时使用。Rightmove、Zoopla 和 OnTheMarket 仍然用于查看当前在售房源、照片、中介、预约看房和提醒。Perfect Postcode 帮助您先判断哪些邮编值得搜索。',
+ 'Perfect Postcode 适合在打开房源网站之前以及同时使用。Rightmove、Zoopla、OnTheMarket 仍负责呈现当下在售的房源、照片、中介、预约和提醒;Perfect Postcode 则帮您先判断哪些邮编值得去搜。',
faqDueDiligence2Q: '可以按花园、车库、户型或房源描述筛选吗?',
faqDueDiligence2A:
- '这些细节并不是每套房都可靠可得。Perfect Postcode 可以按面积、房屋类型、产权类型、能源评级、成交价和本地信息筛选。花园、车库、朝向、户型和中介描述仍需要在房源页面和看房时核实。',
- faqDueDiligence3Q: '可以看到降价历史或房源上线多久了吗?',
+ '这些细节并不是每套房都能拿到可靠数据。Perfect Postcode 支持按面积、房屋类型、产权、能源评级、成交价和本地信息筛选。花园、车库、朝向、户型和中介描述,仍需在房源页面和看房时核实。',
+ faqDueDiligence3Q: '能看到降价历史或房源上线多久了吗?',
faqDueDiligence3A:
- '目前不支持。Perfect Postcode 基于成交价、能源评级、邮编、通勤时间和社区信息,而不是实时房源变化。您仍可以用成交历史、估计当前价值和每平方米价格来判断挂牌价是否偏高。',
- faqDueDiligence4Q: '出价前还需要核实什么?',
+ '暂不支持。Perfect Postcode 立足于成交价、能源评级、邮编、出行时间和社区信息,而不是实时的房源变动。但您仍然可以用成交历史、估计当前价值和每平方米单价来判断挂牌价是否偏高。',
+ faqDueDiligence4Q: '出价前还要核实哪些事?',
faqDueDiligence4A:
- '可以先用 Perfect Postcode 检查区域和大致价值,然后在出价前确认房源细节。还应核实产权类型、租赁细节、服务费、规划历史、洪水风险、法律问题、贷款要求和验房结果。',
+ '可以先用 Perfect Postcode 把区域和大致价值过一遍,再在出价前核对房源细节。此外还应核实产权类型、租赁条款、服务费、规划历史、洪水风险、法律问题、贷款要求和验房结果。',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: '你们会存储关于我的个人数据吗?',
faqPrivacy1A:
- '房产和社区信息不包含您的个人资料。如果您创建账户,我们只会存储运行服务所需的信息,例如邮箱地址、访问状态、新闻邮件选择、已保存的搜索、已保存的房产,以及由 Stripe 处理的付款。账户数据会按英国隐私法律处理。',
+ '房产与社区信息本身不含您的个人资料。若您创建账户,我们只存储运行服务所必需的信息:邮箱地址、访问状态、新闻邮件订阅选择、已保存的搜索、已保存的房产,以及由 Stripe 处理的付款。账户数据按英国隐私法律处理。',
// FAQ items — Why Perfect Postcode
- faqWhy1Q: '它显示了房源门户通常不显示的什么信息?',
+ faqWhy1Q: '它展示了哪些房源门户通常看不到的信息?',
faqWhy1A:
- '房源网站从当前在售的房子开始。Perfect Postcode 从适合您生活和预算的地方开始,在打开房源前就结合成交价、空间、通勤、学校、犯罪率、噪音、宽带、能源评级、产权类型和配套。',
- faqWhy2Q: '这能节省多少手动研究?',
+ '房源网站从当下在售的房子出发;Perfect Postcode 从契合您生活与预算的地方出发,在您打开房源之前就把成交价、空间、通勤、学校、治安、噪音、宽带、能源评级、产权和配套综合到一起。',
+ faqWhy2Q: '这能省下多少手动调研?',
faqWhy2A:
- '您可以自己做,但这意味着逐个邮编检查成交价、能源评级、犯罪率、学校、宽带、本地信息、环境、出行时间和地图。Perfect Postcode 把这些来源放到一张可搜索的英格兰地图中。',
+ '您当然可以自己做,但意味着逐个邮编去查成交价、能源评级、治安、学校、宽带、本地资讯、环境、出行时间和地图。Perfect Postcode 把这些来源汇成一张可搜索的英格兰地图。',
faqWhy3Q: '数据有多可靠?',
faqWhy3A:
- '主要来源是官方或广泛使用的公开数据,包括成交价、能源评级、本地信息、学校评分、宽带、犯罪率、环境、地图和街道数据。它们适合筛选和比较,但购房决定仍需要最新核查,必要时还要咨询专业人士。',
+ '主要来源是官方或被广泛使用的公开数据,涵盖成交价、能源评级、本地信息、学校评分、宽带、治安、环境、地图和街道数据。它们适合用于筛选和比较;真正决定购房时,仍需最新核查,必要时还要咨询专业人士。',
// FAQ items — Pricing and Access
faqPricing1Q: '既然邮编报告是免费的,为什么还要付费?',
faqPricing1A:
- '免费的邮编工具在您已经知道要查什么时很有用。Perfect Postcode 用来按您的需求扫描英格兰每个邮编、组合筛选、比较选项、保存搜索,并在投入周末看房前导出候选名单。',
+ '免费的邮编工具在您已经知道要查什么时确实好用。Perfect Postcode 的价值,在于按您的条件扫描英格兰每个邮编、组合筛选、横向比较、保存搜索,并在投入周末看房之前导出候选名单。',
faqPricing2Q: '终身访问是什么意思?',
faqPricing2A:
- '终身访问指一次付款后,您的账户可在 Perfect Postcode 服务存续期间持续访问付费地图。它不是月度或年度订阅,并包含正常的数据更新。您可以在本次找房期间使用,之后再回来查看;如果将来再次搬家,也仍然保留访问权限。',
+ '终身访问就是一次付款后,您的账户在 Perfect Postcode 服务存续期间都能持续访问付费地图。它不是按月或按年订阅,且涵盖后续数据更新。本次找房可以用,事后回来再看也行;将来再次搬家时,访问权限依然有效。',
faqPricing3Q: '免费版能用哪些功能?',
faqPricing3A:
- '免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',
+ '免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内体验全部功能。要访问英格兰其他地区的数据,则需获取终身访问权限。',
// FAQ items — Tips and Tricks
faqTips1Q: '如何在地图上预览筛选条件?',
faqTips1A:
- '点击筛选条件或数据项旁边的眼睛图标,即可按该项为地图着色。当前启用的筛选条件会保持不变,因此您可以快速比较价格、通勤时间、学校、犯罪率或噪音等单项,而不会改变候选范围。',
- faqTips2Q: '如何了解筛选条件的含义?',
+ '点击筛选条件或数据项旁的眼睛图标,就能按该项给地图着色。当前的筛选保持不变,因此可以快速对比价格、出行时间、学校、治安或噪音等单项,候选范围不会改变。',
+ faqTips2Q: '如何了解某个筛选条件的含义?',
faqTips2A:
- '点击筛选条件或数据项旁边的信息按钮,可查看简短说明,了解它的含义以及如何阅读。地图中的一些部分,例如出行时间卡片,也有自己的信息按钮。',
+ '点击筛选条件或数据项旁的信息按钮,会有一段简短说明,告诉您它是什么、该怎么读。地图中的一些部分——例如出行时间卡片——也有各自的信息按钮。',
faqTips3Q: '如何刷新地图颜色?',
faqTips3A:
- '当眼睛预览正在为地图着色时,在地图图例中使用“重置颜色比例”即可刷新当前结果的颜色。在移动地图、缩放或更改筛选条件后,这很有用。',
+ '当眼睛预览正在给地图着色时,在地图图例里点"重置颜色比例"即可按当前结果重新配色。平移、缩放或调整筛选之后,特别管用。',
},
// ── Account Page ───────────────────────────────────
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 59c182d..7b883ae 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -28,6 +28,17 @@ button:not(:disabled),
cursor: pointer;
}
+.area-pane-group-header {
+ box-shadow: inset 0 -1px 0 #e7e5e4;
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease;
+}
+
+html.dark .area-pane-group-header {
+ box-shadow: inset 0 -1px 0 #1e2d50;
+}
+
/* Smooth theme transitions (scoped to avoid map performance issues) */
body,
div,
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index fea022b..38abb5b 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -18,7 +18,9 @@ function AppErrorFallback() {
Something went wrong
-
Refresh the page to try again.
+
+ Refresh the page to try again.
+
);
diff --git a/frontend/src/lib/bugsink.tsx b/frontend/src/lib/bugsink.tsx
index d4df734..484dfbd 100644
--- a/frontend/src/lib/bugsink.tsx
+++ b/frontend/src/lib/bugsink.tsx
@@ -74,13 +74,13 @@ export function initBugsink(): boolean {
),
release:
nonempty(runtimeConfig.release) ??
- readBuildTimeString(typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined),
+ readBuildTimeString(
+ typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined
+ ),
sendDefaultPii:
runtimeConfig.sendDefaultPii ??
readBuildTimeBoolean(
- typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean'
- ? __BUGSINK_SEND_DEFAULT_PII__
- : undefined
+ typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean' ? __BUGSINK_SEND_DEFAULT_PII__ : undefined
),
tracesSampleRate: 0,
});
diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts
index 59ed271..aea4f33 100644
--- a/frontend/src/lib/consts.ts
+++ b/frontend/src/lib/consts.ts
@@ -132,6 +132,7 @@ export const POI_GROUP_COLORS: Record = {
export const POI_CATEGORY_LOGOS: Record = {
Airport: '/assets/twemoji/2708.png',
Aldi: '/assets/poi-icons/logos/aldi.svg',
+ 'Allendale Co-operative Society': '/assets/poi-icons/logos/coop.svg',
Amazon: '/assets/poi-icons/brands_2024/amazon_fresh.svg',
Asda: '/assets/poi-icons/logos/asda.svg',
'Asda Express': '/assets/poi-icons/logos/asda.svg',
@@ -147,18 +148,26 @@ export const POI_CATEGORY_LOGOS: Record = {
'Bus stop': '/assets/twemoji/1f68f.png',
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
Centra: '/assets/poi-icons/logos/centra.svg',
+ 'Central England Co-operative': '/assets/poi-icons/logos/coop.svg',
+ 'Chelmsford Star Co-operative Society': '/assets/poi-icons/logos/coop.svg',
+ 'Clydebank Co-operative': '/assets/poi-icons/logos/coop.svg',
'Co-op': '/assets/poi-icons/logos/coop.svg',
+ 'Coniston Co-operative Society': '/assets/poi-icons/logos/coop.svg',
COOK: '/assets/poi-icons/brands_2024/cook.svg',
'Convenience Store': '/assets/twemoji/1f3ea.png',
Costco: '/assets/poi-icons/logos/costco.svg',
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
'Dunnes Stores': '/assets/poi-icons/brands_2024/dunnes_stores.svg',
+ 'East of England Co-operative': '/assets/poi-icons/logos/coop.svg',
Farmfoods: '/assets/poi-icons/brands_2023/supermarkets/farmfoods.svg',
Ferry: '/assets/twemoji/26f4.png',
Greengrocer: '/assets/twemoji/1f96c.png',
+ 'Heart of England Co-operative': '/assets/poi-icons/logos/coop.svg',
'Heron Foods': '/assets/poi-icons/brands_2023/supermarkets/heron_foods.svg',
Iceland: '/assets/poi-icons/brands_2024/iceland.svg',
Lidl: '/assets/poi-icons/logos/lidl.svg',
+ 'Langdale Co-operative Society': '/assets/poi-icons/logos/coop.svg',
+ 'Lincolnshire Co-operative': '/assets/poi-icons/logos/coop.svg',
Makro: '/assets/poi-icons/brands_2024/makro.svg',
'M&S': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Clothing': '/assets/poi-icons/brands_2024/mns.svg',
@@ -166,6 +175,7 @@ export const POI_CATEGORY_LOGOS: Record = {
'M&S Hospital': '/assets/poi-icons/brands_2024/mns.svg',
'M&S MSA': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Outlet': '/assets/poi-icons/brands_2024/mns.svg',
+ 'Midcounties Co-operative': '/assets/poi-icons/logos/coop.svg',
Morrisons: '/assets/poi-icons/logos/morrisons.svg',
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
@@ -173,12 +183,16 @@ export const POI_CATEGORY_LOGOS: Record = {
'Rail station': '/assets/twemoji/1f686.png',
"Sainsbury's": '/assets/poi-icons/logos/sainsburys.svg',
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
+ 'Scottish Midland Co-operative': '/assets/poi-icons/logos/coop.svg',
Spar: '/assets/poi-icons/logos/spar.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
+ 'Tamworth Co-operative Society': '/assets/poi-icons/logos/coop.svg',
Tesco: '/assets/poi-icons/logos/tesco.svg',
'Tesco Express': '/assets/poi-icons/logos/tesco_express.svg',
'Tesco Extra': '/assets/poi-icons/logos/tesco_extra.svg',
'Taxi rank': '/assets/twemoji/1f695.png',
+ 'The Radstock Co-operative Society': '/assets/poi-icons/logos/coop.svg',
+ 'The Southern Co-operative': '/assets/poi-icons/logos/coop.svg',
'The Food Warehouse': '/assets/poi-icons/logos/the_food_warehouse.png',
'Tube station': '/assets/poi-icons/public_transport/london_tube.svg',
Waitrose: '/assets/poi-icons/logos/waitrose.svg',
diff --git a/frontend/src/lib/map-utils.test.ts b/frontend/src/lib/map-utils.test.ts
index 5f628c1..7cfc55c 100644
--- a/frontend/src/lib/map-utils.test.ts
+++ b/frontend/src/lib/map-utils.test.ts
@@ -71,7 +71,7 @@ describe('map utilities', () => {
expect(enumIndexToColor(ENUM_PALETTE.length, ENUM_PALETTE)).toEqual(ENUM_PALETTE[0]);
});
- it('resolves POI category logos and rejects unknown icon categories', () => {
+ it('resolves POI category logos and generates a fallback for unknown chains', () => {
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/logos/waitrose.svg');
expect(getPoiIconUrl('Iceland', '🛒', 'The Food Warehouse')).toBe(
'/assets/poi-icons/logos/the_food_warehouse.png'
@@ -83,8 +83,8 @@ describe('map utilities', () => {
expect(getPoiIconUrl('M&S', '🛒', undefined, 'M&S Simply Food')).toBe(
'/assets/poi-icons/visuals/mns.svg'
);
- expect(() => getPoiIconUrl('Unknown category', '🛒')).toThrow(
- "Missing POI icon for category 'Unknown category'"
+ expect(getPoiIconUrl('Tian Tian', '🛒')).toMatch(
+ /^data:image\/svg\+xml;charset=utf-8,/
);
});
diff --git a/frontend/src/lib/map-utils.ts b/frontend/src/lib/map-utils.ts
index 55ad8f6..8824a58 100644
--- a/frontend/src/lib/map-utils.ts
+++ b/frontend/src/lib/map-utils.ts
@@ -309,9 +309,67 @@ function inferPoiIconCategory(category: string, name?: string): string | undefin
}
}
-export function getPoiIconUrl(
+const GENERATED_POI_LOGO_COLORS: [string, string][] = [
+ ['#0f766e', '#ccfbf1'],
+ ['#1d4ed8', '#dbeafe'],
+ ['#b45309', '#fef3c7'],
+ ['#be123c', '#ffe4e6'],
+ ['#6d28d9', '#ede9fe'],
+ ['#047857', '#d1fae5'],
+ ['#9d174d', '#fce7f3'],
+ ['#334155', '#e2e8f0'],
+];
+
+const generatedPoiLogoCache = new Map();
+
+function hashLabel(label: string): number {
+ let hash = 0;
+ for (let i = 0; i < label.length; i += 1) {
+ hash = (hash * 31 + label.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+}
+
+function escapeSvgText(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function getPoiLogoInitials(label: string): string {
+ const words = label.match(/[A-Za-z0-9]+/g) ?? [];
+ const significantWords = words.filter(
+ (word) => !['and', 'of', 'the'].includes(word.toLowerCase())
+ );
+ const selectedWords = significantWords.length > 0 ? significantWords : words;
+ if (selectedWords.length === 0) return 'POI';
+ if (selectedWords.length === 1) return selectedWords[0].slice(0, 3).toUpperCase();
+ return selectedWords
+ .slice(0, 3)
+ .map((word) => word[0])
+ .join('')
+ .toUpperCase();
+}
+
+function getGeneratedPoiLogoUrl(label: string): string {
+ const key = label.trim() || 'POI';
+ const cached = generatedPoiLogoCache.get(key);
+ if (cached) return cached;
+
+ const [background, foreground] = GENERATED_POI_LOGO_COLORS[
+ hashLabel(key) % GENERATED_POI_LOGO_COLORS.length
+ ];
+ const initials = escapeSvgText(getPoiLogoInitials(key));
+ const svg = `${initials} `;
+ const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+ generatedPoiLogoCache.set(key, url);
+ return url;
+}
+
+export function getPoiCategoryLogoUrl(
category: string,
- _emoji: string,
iconCategory?: string,
name?: string
): string {
@@ -319,11 +377,16 @@ export function getPoiIconUrl(
if (resolvedIconCategory && POI_CATEGORY_LOGOS[resolvedIconCategory]) {
return POI_CATEGORY_LOGOS[resolvedIconCategory];
}
- const categoryLogo = POI_CATEGORY_LOGOS[category];
- if (!categoryLogo) {
- throw new Error(`Missing POI icon for category '${category}'`);
- }
- return categoryLogo;
+ return POI_CATEGORY_LOGOS[category] ?? getGeneratedPoiLogoUrl(resolvedIconCategory || category);
+}
+
+export function getPoiIconUrl(
+ category: string,
+ _emoji: string,
+ iconCategory?: string,
+ name?: string
+): string {
+ return getPoiCategoryLogoUrl(category, iconCategory, name);
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
diff --git a/frontend/src/lib/nearby-stations.test.ts b/frontend/src/lib/nearby-stations.test.ts
new file mode 100644
index 0000000..1d4935b
--- /dev/null
+++ b/frontend/src/lib/nearby-stations.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+
+import type { POI } from '../types';
+import {
+ formatStationDistance,
+ selectNearbyStations,
+ stationSearchBounds,
+} from './nearby-stations';
+
+function poi(name: string, lat: number, lng: number): POI {
+ return {
+ id: name,
+ name,
+ category: 'Rail station',
+ icon_category: 'Rail station',
+ group: 'Public Transport',
+ lat,
+ lng,
+ emoji: '',
+ };
+}
+
+describe('selectNearbyStations', () => {
+ const origin = { lat: 51.5, lon: -0.1 };
+
+ it('returns every station within 1km when any station is inside 1km', () => {
+ const stations = selectNearbyStations(
+ [poi('Close A', 51.501, -0.1), poi('Close B', 51.502, -0.1), poi('Fallback', 51.511, -0.1)],
+ origin
+ );
+
+ expect(stations.map((station) => station.name)).toEqual(['Close A', 'Close B']);
+ });
+
+ it('falls back to the nearest three stations within 2km when none are inside 1km', () => {
+ const stations = selectNearbyStations(
+ [
+ poi('Fourth', 51.516, -0.1),
+ poi('First', 51.51, -0.1),
+ poi('Outside', 51.53, -0.1),
+ poi('Third', 51.514, -0.1),
+ poi('Second', 51.512, -0.1),
+ ],
+ origin
+ );
+
+ expect(stations.map((station) => station.name)).toEqual(['First', 'Second', 'Third']);
+ });
+
+ it('formats sub-kilometre and kilometre distances', () => {
+ expect(formatStationDistance(0.234)).toBe('234m');
+ expect(formatStationDistance(1.234)).toBe('1.2km');
+ });
+});
+
+describe('stationSearchBounds', () => {
+ it('builds a box around the origin', () => {
+ const bounds = stationSearchBounds({ lat: 51.5, lon: -0.1 });
+
+ expect(bounds.south).toBeLessThan(51.5);
+ expect(bounds.north).toBeGreaterThan(51.5);
+ expect(bounds.west).toBeLessThan(-0.1);
+ expect(bounds.east).toBeGreaterThan(-0.1);
+ });
+});
diff --git a/frontend/src/lib/nearby-stations.ts b/frontend/src/lib/nearby-stations.ts
new file mode 100644
index 0000000..a5053a5
--- /dev/null
+++ b/frontend/src/lib/nearby-stations.ts
@@ -0,0 +1,71 @@
+import type { Bounds, POI } from '../types';
+
+export const STATION_CATEGORIES = ['Rail station', 'Tube station'] as const;
+export const STATION_SEARCH_RADIUS_KM = 2;
+const PRIMARY_STATION_RADIUS_KM = 1;
+const FALLBACK_STATION_LIMIT = 3;
+const EARTH_RADIUS_KM = 6371;
+const KM_PER_DEGREE_LAT = 111.32;
+
+export interface NearbyStation extends POI {
+ distanceKm: number;
+}
+
+export interface GeoPoint {
+ lat: number;
+ lon: number;
+}
+
+function toRadians(value: number): number {
+ return (value * Math.PI) / 180;
+}
+
+export function distanceKm(a: GeoPoint, b: GeoPoint): number {
+ const dLat = toRadians(b.lat - a.lat);
+ const dLon = toRadians(b.lon - a.lon);
+ const lat1 = toRadians(a.lat);
+ const lat2 = toRadians(b.lat);
+
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
+
+ return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(h));
+}
+
+export function stationSearchBounds(origin: GeoPoint, radiusKm = STATION_SEARCH_RADIUS_KM): Bounds {
+ const latDelta = radiusKm / KM_PER_DEGREE_LAT;
+ const cosLat = Math.max(Math.cos(toRadians(origin.lat)), 0.01);
+ const lonDelta = radiusKm / (KM_PER_DEGREE_LAT * cosLat);
+
+ return {
+ south: origin.lat - latDelta,
+ west: origin.lon - lonDelta,
+ north: origin.lat + latDelta,
+ east: origin.lon + lonDelta,
+ };
+}
+
+export function selectNearbyStations(pois: POI[], origin: GeoPoint): NearbyStation[] {
+ const stations = pois
+ .map((poi) => ({
+ ...poi,
+ distanceKm: distanceKm(origin, { lat: poi.lat, lon: poi.lng }),
+ }))
+ .filter((poi) => poi.distanceKm <= STATION_SEARCH_RADIUS_KM)
+ .sort((a, b) => a.distanceKm - b.distanceKm || a.name.localeCompare(b.name));
+
+ const withinPrimaryRadius = stations.filter(
+ (station) => station.distanceKm <= PRIMARY_STATION_RADIUS_KM
+ );
+
+ return withinPrimaryRadius.length > 0
+ ? withinPrimaryRadius
+ : stations.slice(0, FALLBACK_STATION_LIMIT);
+}
+
+export function formatStationDistance(distanceKmValue: number): string {
+ if (distanceKmValue < 1) {
+ return `${Math.round(distanceKmValue * 1000)}m`;
+ }
+
+ return `${distanceKmValue.toFixed(1)}km`;
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 2c08618..b04454e 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -135,7 +135,6 @@ export interface ActualListing {
export interface ActualListingsResponse {
listings: ActualListing[];
total: number;
- truncated: boolean;
}
export interface POICategoryGroup {
@@ -198,14 +197,19 @@ export interface Property {
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
}
-export interface HexagonPropertiesResponse {
+/** Shared paginated list of `Property` records returned by both
+ * `/api/hexagon-properties` and `/api/postcode-properties`. */
+export interface PropertyListResponse {
properties: Property[];
total: number;
- limit: number;
offset: number;
truncated: boolean;
}
+/** @deprecated Use `PropertyListResponse`. Kept as an alias during the
+ * rollout so consumers can migrate without breaking. */
+export type HexagonPropertiesResponse = PropertyListResponse;
+
export interface NumericFeatureStats {
name: string;
count: number;
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
index c552960..e01e893 100644
--- a/frontend/webpack.config.js
+++ b/frontend/webpack.config.js
@@ -4,6 +4,9 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
+const TerserPlugin = require('terser-webpack-plugin');
+const CompressionPlugin = require('compression-webpack-plugin');
+const zlib = require('zlib');
const sharp = require('sharp');
const webpack = require('webpack');
const packageJson = require('./package.json');
@@ -150,11 +153,49 @@ module.exports = (env, argv) => {
filename: '[name].[contenthash:8].css',
chunkFilename: '[name].[contenthash:8].css',
}),
+ new CompressionPlugin({
+ filename: '[path][base].gz',
+ algorithm: 'gzip',
+ test: /\.(js|css|html|svg|json|wasm)$/,
+ threshold: 1024,
+ minRatio: 0.8,
+ }),
+ new CompressionPlugin({
+ filename: '[path][base].br',
+ algorithm: 'brotliCompress',
+ test: /\.(js|css|html|svg|json|wasm)$/,
+ compressionOptions: {
+ params: {
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 11,
+ },
+ },
+ threshold: 1024,
+ minRatio: 0.8,
+ }),
]
: [new ReactRefreshWebpackPlugin()]),
],
optimization: isProduction
? {
+ minimize: true,
+ minimizer: [
+ new TerserPlugin({
+ parallel: true,
+ extractComments: false,
+ terserOptions: {
+ compress: {
+ drop_console: true,
+ drop_debugger: true,
+ passes: 2,
+ },
+ format: {
+ comments: false,
+ },
+ keep_classnames: true,
+ keep_fnames: false,
+ },
+ }),
+ ],
splitChunks: {
chunks: 'all',
cacheGroups: {
diff --git a/pipeline/download/greenspace_water.py b/pipeline/download/greenspace_water.py
index d85dd9a..d15ebd1 100644
--- a/pipeline/download/greenspace_water.py
+++ b/pipeline/download/greenspace_water.py
@@ -7,15 +7,19 @@ Reuses the same england-latest.osm.pbf as pois.py.
"""
import argparse
+import logging
from pathlib import Path
import osmium
import polars as pl
from pyproj import Transformer
from shapely import wkb
+from shapely.errors import GEOSException
from shapely.geometry import MultiPolygon, Polygon
from tqdm import tqdm
+logger = logging.getLogger(__name__)
+
MIN_AREA_SQM = 5_000 # ~70m x 70m — skip pocket parks and small ponds
@@ -68,6 +72,7 @@ class GreenspaceHandler(osmium.SimpleHandler):
self._wkb_factory = osmium.geom.WKBFactory()
self._progress = progress
self.geometries = []
+ self.skipped_areas = 0
def area(self, a):
self._progress.update(1)
@@ -76,7 +81,14 @@ class GreenspaceHandler(osmium.SimpleHandler):
try:
wkb_data = self._wkb_factory.create_multipolygon(a)
geom = wkb.loads(wkb_data, hex=True)
- except Exception:
+ except (RuntimeError, GEOSException, ValueError) as exc:
+ self.skipped_areas += 1
+ logger.warning(
+ "Failed to assemble multipolygon for area orig_id=%s (%s)",
+ getattr(a, "orig_id", lambda: "?")(),
+ type(exc).__name__,
+ exc_info=True,
+ )
return
if geom.is_empty or not geom.is_valid:
@@ -113,6 +125,11 @@ def main():
print(
f"Found {len(handler.geometries)} greenspace/water polygons >= {MIN_AREA_SQM} sqm"
)
+ if handler.skipped_areas:
+ logger.warning(
+ "Skipped %d areas due to geometry assembly errors",
+ handler.skipped_areas,
+ )
# Merge overlapping geometries per 10km grid cell for efficiency
if handler.geometries:
diff --git a/pipeline/download/os_greenspace.py b/pipeline/download/os_greenspace.py
index ce8b0c9..da2be08 100644
--- a/pipeline/download/os_greenspace.py
+++ b/pipeline/download/os_greenspace.py
@@ -14,6 +14,7 @@ License: Open Government Licence v3.0
"""
import argparse
+import logging
import tempfile
from pathlib import Path
@@ -21,10 +22,13 @@ import numpy as np
import polars as pl
import shapefile as shp
from pyproj import Transformer
+from shapely.errors import GEOSException
from shapely.geometry import shape as to_shapely
from pipeline.utils.download import download, extract_zip
+logger = logging.getLogger(__name__)
+
URL = "https://api.os.uk/downloads/v1/products/OpenGreenspace/downloads?area=GB&format=ESRI%C2%AE+Shapefile&redirect"
_to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True)
@@ -76,6 +80,7 @@ def _read_access_points(
lngs: list[float] = []
categories: list[str] = []
skipped = 0
+ error_skipped = 0
for sr in reader.shapeRecords():
site_id = sr.record[ref_idx]
@@ -89,7 +94,13 @@ def _read_access_points(
if geom.is_empty:
continue
lng, lat = _to_wgs84.transform(geom.x, geom.y)
- except Exception:
+ except (GEOSException, ValueError, AttributeError, TypeError):
+ error_skipped += 1
+ logger.warning(
+ "Failed to process access point geometry for site_id=%s",
+ site_id,
+ exc_info=True,
+ )
continue
lats.append(lat)
@@ -98,6 +109,11 @@ def _read_access_points(
if skipped:
print(f" Skipped {skipped:,} access points with unknown site ID")
+ if error_skipped:
+ logger.warning(
+ "Skipped %d access point records due to geometry/transform errors",
+ error_skipped,
+ )
return lats, lngs, categories
@@ -116,6 +132,7 @@ def _read_site_centroids(
lats: list[float] = []
lngs: list[float] = []
categories: list[str] = []
+ error_skipped = 0
for sr in reader.shapeRecords():
site_id = sr.record[id_idx]
@@ -129,13 +146,25 @@ def _read_site_centroids(
continue
centroid = geom.centroid
lng, lat = _to_wgs84.transform(centroid.x, centroid.y)
- except Exception:
+ except (GEOSException, ValueError, AttributeError, TypeError):
+ error_skipped += 1
+ logger.warning(
+ "Failed to compute centroid for site_id=%s",
+ site_id,
+ exc_info=True,
+ )
continue
lats.append(lat)
lngs.append(lng)
categories.append(func)
+ if error_skipped:
+ logger.warning(
+ "Skipped %d site centroid records due to geometry/transform errors",
+ error_skipped,
+ )
+
return lats, lngs, categories
diff --git a/pipeline/download/pois.py b/pipeline/download/pois.py
index 3207d51..c369375 100644
--- a/pipeline/download/pois.py
+++ b/pipeline/download/pois.py
@@ -1,10 +1,12 @@
import argparse
+import logging
from pathlib import Path
from tempfile import mkdtemp
import osmium
import polars as pl
from shapely import make_valid
+from shapely.errors import GEOSException
from shapely.geometry import Point
from shapely.wkb import loads as load_wkb
from tqdm import tqdm
@@ -17,6 +19,8 @@ from pipeline.utils.england_geometry import (
load_england_polygon,
)
+logger = logging.getLogger(__name__)
+
BATCH_SIZE = 50_000
MIN_OCCURENCE_COUNT = 20
@@ -57,6 +61,7 @@ class POIHandler(osmium.SimpleHandler):
self._tmp_dir = tmp_dir
self._batch_num = 0
self.poi_count = 0
+ self.skipped_areas = 0
self._progress = progress
self._england = england_polygon
self._wkb_factory = osmium.geom.WKBFactory()
@@ -120,7 +125,14 @@ class POIHandler(osmium.SimpleHandler):
def _point_from_area(self, area: osmium.osm.Area) -> tuple[float, float] | None:
try:
geom = load_wkb(self._wkb_factory.create_multipolygon(area), hex=True)
- except Exception:
+ except (RuntimeError, GEOSException, ValueError) as exc:
+ self.skipped_areas += 1
+ logger.warning(
+ "Failed to build multipolygon WKB for area orig_id=%s (%s)",
+ getattr(area, "orig_id", lambda: "?")(),
+ type(exc).__name__,
+ exc_info=True,
+ )
return None
return _representative_lat_lon(geom, self._england)
@@ -185,6 +197,11 @@ def main() -> None:
handler._flush_batch() # write any remaining POIs
print(f"Extracted {handler.poi_count:,} POIs")
+ if handler.skipped_areas:
+ logger.warning(
+ "Skipped %d areas due to geometry assembly errors",
+ handler.skipped_areas,
+ )
batch_files = sorted(tmp_dir.glob("batch_*.parquet"))
df = pl.concat([pl.scan_parquet(f) for f in batch_files])
diff --git a/pipeline/transform/test_transform_poi.py b/pipeline/transform/test_transform_poi.py
index a9e0a0a..ca9ad92 100644
--- a/pipeline/transform/test_transform_poi.py
+++ b/pipeline/transform/test_transform_poi.py
@@ -15,7 +15,7 @@ def test_transform_grocery_retail_points_outputs_chain_categories():
}
)
- pois = transform_grocery_retail_points(raw)
+ pois = transform_grocery_retail_points(raw, min_chain_locations=1)
assert pois.select(
"id", "name", "category", "icon_category", "group", "emoji"
@@ -69,7 +69,7 @@ def test_transform_grocery_retail_points_keeps_fascia_icon_category():
}
)
- pois = transform_grocery_retail_points(raw)
+ pois = transform_grocery_retail_points(raw, min_chain_locations=1)
assert pois.select("category", "icon_category").to_dicts() == [
{"category": "Tesco", "icon_category": "Tesco Express"},
@@ -96,7 +96,7 @@ def test_transform_grocery_retail_points_accepts_base_fascias():
}
)
- pois = transform_grocery_retail_points(raw)
+ pois = transform_grocery_retail_points(raw, min_chain_locations=1)
assert pois.select("category", "icon_category").to_dicts() == [
{"category": "Aldi", "icon_category": "Aldi"},
@@ -118,6 +118,29 @@ def test_transform_grocery_retail_points_drops_invalid_rows():
}
)
- pois = transform_grocery_retail_points(raw)
+ pois = transform_grocery_retail_points(raw, min_chain_locations=1)
assert pois["category"].to_list() == ["Waitrose"]
+
+
+def test_transform_grocery_retail_points_includes_unmapped_chains_with_five_locations():
+ raw = pl.DataFrame(
+ {
+ "id": list(range(1, 10)),
+ "retailer": ["Tian Tian"] * 5 + ["Corner Shop"] * 4,
+ "fascia": ["Tian Tian Market"] * 5 + ["Corner Shop"] * 4,
+ "store_name": [f"Store {i}" for i in range(1, 10)],
+ "long_wgs": [-0.1] * 9,
+ "lat_wgs": [51.5] * 9,
+ }
+ )
+
+ pois = transform_grocery_retail_points(raw)
+
+ assert pois.select("id", "category", "icon_category").to_dicts() == [
+ {"id": "glx-1", "category": "Tian Tian", "icon_category": "Tian Tian"},
+ {"id": "glx-2", "category": "Tian Tian", "icon_category": "Tian Tian"},
+ {"id": "glx-3", "category": "Tian Tian", "icon_category": "Tian Tian"},
+ {"id": "glx-4", "category": "Tian Tian", "icon_category": "Tian Tian"},
+ {"id": "glx-5", "category": "Tian Tian", "icon_category": "Tian Tian"},
+ ]
diff --git a/pipeline/transform/transform_poi.py b/pipeline/transform/transform_poi.py
index 5804f9f..552bb75 100644
--- a/pipeline/transform/transform_poi.py
+++ b/pipeline/transform/transform_poi.py
@@ -5,7 +5,6 @@ import polars as pl
from pipeline.utils.england_geometry import in_england_mask
-
DROP_CATEGORIES = {
# Street furniture & infrastructure
"amenity/advice",
@@ -1165,49 +1164,44 @@ COOP_RETAILERS = {
"The Southern Co-operative",
}
-GROCERY_RETAILER_DISPLAY_NAMES: dict[str, str] = {
- "Aldi": "Aldi",
- "Asda": "Asda",
- "Booths": "Booths",
- "Budgens": "Budgens",
- "Centra": "Centra",
+MIN_GROCERY_CHAIN_LOCATIONS = 5
+
+GROCERY_RETAILER_DISPLAY_NAME_OVERRIDES: dict[str, str] = {
"Cook": "COOK",
- "Costco": "Costco",
- "Dunnes Stores": "Dunnes Stores",
- "Farmfoods": "Farmfoods",
"Heron": "Heron Foods",
- "Iceland": "Iceland",
- "Lidl": "Lidl",
- "Makro": "Makro",
"Marks and Spencer": "M&S",
- "Morrisons": "Morrisons",
- "Planet Organic": "Planet Organic",
"Sainsburys": "Sainsbury's",
- "Spar": "Spar",
- "Tesco": "Tesco",
- "Waitrose": "Waitrose",
- "Whole Foods Market": "Whole Foods Market",
- **{retailer: "Co-op" for retailer in COOP_RETAILERS},
+ "The Co-operative Group": "Co-op",
}
GROCERY_FASCIA_ICON_NAMES: dict[str, str] = {
- **GROCERY_RETAILER_DISPLAY_NAMES,
+ "Aldi": "Aldi",
"Aldi Local": "Aldi",
+ "Asda": "Asda",
"Asda Express": "Asda Express",
"Asda Living": "Asda Living",
- "Asda PFS": "Asda PFS",
+ "Asda PFS": "Asda",
"Asda Supercentre": "Asda Supercentre",
"Asda Supermarket": "Asda Supermarket",
"Asda Superstore": "Asda Superstore",
+ "Booths": "Booths",
+ "Budgens": "Budgens",
+ "Centra": "Centra",
"Cooltrader": "Heron Foods",
"Co-op Food": "Co-op",
"Cook": "COOK",
+ "Costco": "Costco",
+ "Dunnes Stores": "Dunnes Stores",
"Eurospar": "Spar",
"Eurospar PFS": "Spar",
+ "Farmfoods": "Farmfoods",
"Heron": "Heron Foods",
+ "Iceland": "Iceland",
+ "Lidl": "Lidl",
"Little Waitrose": "Little Waitrose",
"Little Waitrose Shell": "Little Waitrose",
+ "Makro": "Makro",
"Marks and Spencer": "M&S",
"Marks and Spencer BP": "M&S Food",
"Marks and Spencer Clothing": "M&S Clothing",
@@ -1221,41 +1215,44 @@ GROCERY_FASCIA_ICON_NAMES: dict[str, str] = {
"Marks and Spencer Travel SF": "M&S Food",
"Morrisons Daily": "Morrisons Daily",
"Morrisons Select": "Morrisons",
+ "Planet Organic": "Planet Organic",
"Sainsbury's Local": "Sainsbury's Local",
"Sainsburys": "Sainsbury's",
"Sainsburys Local": "Sainsbury's Local",
+ "Spar": "Spar",
"Spar PFS": "Spar",
+ "Tesco": "Tesco",
"Tesco Express": "Tesco Express",
"Tesco Express Esso": "Tesco Express",
"Tesco Extra": "Tesco Extra",
"The Co-operative Food": "Co-op",
"The Co-operative Food PFS": "Co-op",
"The Food Warehouse": "The Food Warehouse",
+ "Waitrose": "Waitrose",
"Waitrose MSA": "Waitrose",
+ "Whole Foods Market": "Whole Foods Market",
}
def normalize_grocery_retailer(retailer: str | None) -> str:
if retailer is None:
return ""
- display_name = GROCERY_RETAILER_DISPLAY_NAMES.get(retailer)
- if display_name is None:
- raise ValueError(f"Missing grocery retailer display name for {retailer!r}")
- return display_name
+ retailer = retailer.strip()
+ return GROCERY_RETAILER_DISPLAY_NAME_OVERRIDES.get(retailer, retailer)
def normalize_grocery_icon_category(fascia: str | None, retailer: str | None) -> str:
if fascia:
- icon_name = GROCERY_FASCIA_ICON_NAMES.get(fascia)
- if icon_name is None:
- raise ValueError(f"Missing grocery fascia icon name for {fascia!r}")
- return icon_name
+ icon_name = GROCERY_FASCIA_ICON_NAMES.get(fascia.strip())
+ if icon_name is not None:
+ return icon_name
return normalize_grocery_retailer(retailer)
def transform_grocery_retail_points(
grocery_df: pl.DataFrame,
boundary_path: Path | None = None,
+ min_chain_locations: int = MIN_GROCERY_CHAIN_LOCATIONS,
) -> pl.DataFrame:
"""Convert GEOLYTIX Grocery Retail Points into the POI parquet schema."""
required = {"id", "retailer", "fascia", "store_name", "long_wgs", "lat_wgs"}
@@ -1272,6 +1269,11 @@ def transform_grocery_retail_points(
pl.col("lat_wgs").cast(pl.Float64).alias("lat"),
pl.col("long_wgs").cast(pl.Float64).alias("lng"),
)
+ .with_columns(
+ pl.col("retailer").str.strip_chars(),
+ pl.col("fascia").str.strip_chars(),
+ pl.col("store_name").str.strip_chars(),
+ )
.drop_nulls(["id", "retailer", "lat", "lng"])
.filter(pl.col("retailer").str.len_chars() > 0)
)
@@ -1284,6 +1286,14 @@ def transform_grocery_retail_points(
)
df = df.filter(pl.Series(mask))
+ eligible_retailers = (
+ df.group_by("retailer")
+ .len()
+ .filter(pl.col("len") >= min_chain_locations)
+ .select("retailer")
+ )
+ df = df.join(eligible_retailers, on="retailer", how="semi")
+
return df.with_columns(
pl.concat_str([pl.lit("glx-"), pl.col("id")]).alias("id"),
pl.coalesce(["store_name", "fascia", "retailer"])
diff --git a/property-data2/.gitignore b/property-data2/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/property-data2/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/pyproject.toml b/pyproject.toml
index e50faca..0610211 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ dependencies = [
"numpy>=1.26.0",
"pandas>=2.0.0",
"plotly>=6.5.2",
- "polars>=1.37.1",
+ "polars>=1.37.1,<2.0.0",
"pyarrow>=15.0.0",
"tqdm>=4.67.1",
"fastexcel>=0.19.0",
@@ -26,8 +26,6 @@ dependencies = [
"pillow>=12.0.0",
"folium>=0.20.0",
"pyogrio>=0.12.1",
- "httpx",
- "polars",
]
[tool.uv]
diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml
index b271eb3..7a718cf 100644
--- a/server-rs/Cargo.toml
+++ b/server-rs/Cargo.toml
@@ -47,4 +47,7 @@ lto = "thin"
[profile.production]
inherits = "release"
-lto = true
+lto = "fat"
+codegen-units = 1
+strip = true
+panic = "abort"
diff --git a/server-rs/src/api_error.rs b/server-rs/src/api_error.rs
new file mode 100644
index 0000000..d50badb
--- /dev/null
+++ b/server-rs/src/api_error.rs
@@ -0,0 +1,99 @@
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Json, Response};
+use serde::Serialize;
+
+/// Uniform API error type. Implements `IntoResponse` and serializes as JSON so
+/// every endpoint returns a structurally-identical error body the frontend
+/// can rely on, regardless of which route raised it.
+#[derive(Debug, Clone)]
+pub enum ApiError {
+ BadRequest(String),
+ Unauthorized,
+ Forbidden(String),
+ NotFound(String),
+ Conflict(String),
+ Internal(String),
+ BadGateway(String),
+ ServiceUnavailable(String),
+}
+
+#[derive(Serialize)]
+struct ErrorBody {
+ error: String,
+ message: String,
+}
+
+impl ApiError {
+ fn status(&self) -> StatusCode {
+ match self {
+ Self::BadRequest(_) => StatusCode::BAD_REQUEST,
+ Self::Unauthorized => StatusCode::UNAUTHORIZED,
+ Self::Forbidden(_) => StatusCode::FORBIDDEN,
+ Self::NotFound(_) => StatusCode::NOT_FOUND,
+ Self::Conflict(_) => StatusCode::CONFLICT,
+ Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
+ Self::BadGateway(_) => StatusCode::BAD_GATEWAY,
+ Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
+ }
+ }
+
+ fn code(&self) -> &'static str {
+ match self {
+ Self::BadRequest(_) => "bad_request",
+ Self::Unauthorized => "unauthorized",
+ Self::Forbidden(_) => "forbidden",
+ Self::NotFound(_) => "not_found",
+ Self::Conflict(_) => "conflict",
+ Self::Internal(_) => "internal_error",
+ Self::BadGateway(_) => "upstream_error",
+ Self::ServiceUnavailable(_) => "service_unavailable",
+ }
+ }
+
+ fn message(&self) -> String {
+ match self {
+ Self::Unauthorized => "Authentication required".to_string(),
+ Self::BadRequest(m)
+ | Self::Forbidden(m)
+ | Self::NotFound(m)
+ | Self::Conflict(m)
+ | Self::Internal(m)
+ | Self::BadGateway(m)
+ | Self::ServiceUnavailable(m) => m.clone(),
+ }
+ }
+}
+
+impl IntoResponse for ApiError {
+ fn into_response(self) -> Response {
+ let status = self.status();
+ let body = ErrorBody {
+ error: self.code().to_string(),
+ message: self.message(),
+ };
+ (status, Json(body)).into_response()
+ }
+}
+
+/// Bridge from the legacy `(StatusCode, String)` tuples to the new error type
+/// so partially-migrated routes keep compiling while the migration progresses.
+impl From<(StatusCode, String)> for ApiError {
+ fn from((status, message): (StatusCode, String)) -> Self {
+ match status {
+ StatusCode::BAD_REQUEST => Self::BadRequest(message),
+ StatusCode::UNAUTHORIZED => Self::Unauthorized,
+ StatusCode::FORBIDDEN => Self::Forbidden(message),
+ StatusCode::NOT_FOUND => Self::NotFound(message),
+ StatusCode::CONFLICT => Self::Conflict(message),
+ StatusCode::BAD_GATEWAY => Self::BadGateway(message),
+ StatusCode::SERVICE_UNAVAILABLE => Self::ServiceUnavailable(message),
+ _ => Self::Internal(message),
+ }
+ }
+}
+
+impl From for ApiError {
+ fn from(message: String) -> Self {
+ Self::Internal(message)
+ }
+}
diff --git a/server-rs/src/consts.rs b/server-rs/src/consts.rs
index a95dd2a..64ea799 100644
--- a/server-rs/src/consts.rs
+++ b/server-rs/src/consts.rs
@@ -13,8 +13,10 @@ pub const GRID_CELL_SIZE: f32 = 0.01;
pub const MAX_CELLS_PER_REQUEST: usize = 200000;
pub const MAX_POIS_PER_REQUEST: usize = 3000;
-pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
-pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
+pub const PROPERTIES_LIMIT: usize = 100;
+pub const ACTUAL_LISTINGS_LIMIT: usize = 500;
+pub const PLACES_LIMIT: usize = 20;
+pub const PRICE_HISTORY_POINTS_LIMIT: usize = 5000;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
diff --git a/server-rs/src/data/actual_listings.rs b/server-rs/src/data/actual_listings.rs
index 2054a87..f7d2c70 100644
--- a/server-rs/src/data/actual_listings.rs
+++ b/server-rs/src/data/actual_listings.rs
@@ -268,6 +268,32 @@ fn extract_opt_datetime_iso(df: &DataFrame, name: &str) -> Result Result>> {
+ let column = df
+ .column(name)
+ .with_context(|| format!("Missing column '{name}'"))?;
+ let list = column
+ .list()
+ .with_context(|| format!("Column '{name}' is not a list column"))?;
+ let mut out = Vec::with_capacity(list.len());
+ for series_opt in list.into_iter() {
+ let entries = match series_opt {
+ Some(series) => {
+ let strings = series.str().with_context(|| {
+ format!("Column '{name}' list inner is not a string column")
+ })?;
+ strings
+ .into_iter()
+ .filter_map(|value| value.map(ToString::to_string))
+ .collect()
+ }
+ None => Vec::new(),
+ };
+ out.push(entries);
+ }
+ Ok(out)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -298,29 +324,3 @@ mod tests {
assert!(!any_listing.listing_url.is_empty());
}
}
-
-fn extract_str_list(df: &DataFrame, name: &str) -> Result>> {
- let column = df
- .column(name)
- .with_context(|| format!("Missing column '{name}'"))?;
- let list = column
- .list()
- .with_context(|| format!("Column '{name}' is not a list column"))?;
- let mut out = Vec::with_capacity(list.len());
- for series_opt in list.into_iter() {
- let entries = match series_opt {
- Some(series) => {
- let strings = series.str().with_context(|| {
- format!("Column '{name}' list inner is not a string column")
- })?;
- strings
- .into_iter()
- .filter_map(|value| value.map(ToString::to_string))
- .collect()
- }
- None => Vec::new(),
- };
- out.push(entries);
- }
- Ok(out)
-}
diff --git a/server-rs/src/data/places.rs b/server-rs/src/data/places.rs
index b523add..cc9579c 100644
--- a/server-rs/src/data/places.rs
+++ b/server-rs/src/data/places.rs
@@ -331,7 +331,10 @@ impl PlaceData {
let lon = extract_f32_col(&df, "lon")?;
let population: Vec = if df.column("population").is_ok() {
let pop_f32 = extract_f32_col(&df, "population")?;
- pop_f32.iter().map(|&val| val.max(0.0) as u32).collect()
+ pop_f32
+ .iter()
+ .map(|&val| val.max(0.0).min(u32::MAX as f32) as u32)
+ .collect()
} else {
vec![0; row_count]
};
@@ -419,11 +422,11 @@ mod tests {
fn test_city_rows() -> [(&'static str, f32, f32, u32); 5] {
[
- ("London", 51.5074456, -0.1277653, 8_908_083),
- ("Westminster", 51.4973206, -0.137149, 211_365),
- ("City of London", 51.5156177, -0.0919983, 10_847),
- ("Cambridge", 52.2055314, 0.1186637, 145_818),
- ("Oxford", 51.7520131, -1.2578499, 165_000),
+ ("London", 51.507_446, -0.1277653, 8_908_083),
+ ("Westminster", 51.497_322, -0.137149, 211_365),
+ ("City of London", 51.515_617, -0.0919983, 10_847),
+ ("Cambridge", 52.205_532, 0.1186637, 145_818),
+ ("Oxford", 51.752_014, -1.2578499, 165_000),
]
}
@@ -503,7 +506,7 @@ mod tests {
let cities = test_city_candidates();
assert_eq!(
- nearest_display_city(51.3713049, -0.101957, &cities),
+ nearest_display_city(51.371_304, -0.101957, &cities),
Some("London")
);
}
@@ -513,7 +516,7 @@ mod tests {
let cities = test_city_candidates();
assert_eq!(
- nearest_display_city(52.1277704, -0.0813098, &cities),
+ nearest_display_city(52.127_77, -0.0813098, &cities),
Some("Cambridge")
);
}
diff --git a/server-rs/src/data/poi.rs b/server-rs/src/data/poi.rs
index 9220c26..5968704 100644
--- a/server-rs/src/data/poi.rs
+++ b/server-rs/src/data/poi.rs
@@ -30,6 +30,16 @@ const GROCERY_DASHBOARD_CATEGORIES: &[&str] = &[
"Budgens",
"Centra",
"Co-op",
+ "Central England Co-operative",
+ "Chelmsford Star Co-operative Society",
+ "East of England Co-operative",
+ "Heart of England Co-operative",
+ "Lincolnshire Co-operative",
+ "Midcounties Co-operative",
+ "Scottish Midland Co-operative",
+ "Tamworth Co-operative Society",
+ "The Radstock Co-operative Society",
+ "The Southern Co-operative",
"COOK",
"Costco",
"Dunnes Stores",
diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs
index fd51b6c..975315b 100644
--- a/server-rs/src/features.rs
+++ b/server-rs/src/features.rs
@@ -1014,6 +1014,22 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
];
+/// Feature names that describe an individual property (price, size, type, etc.) rather
+/// than the surrounding area. Use this to skip filters that should not exclude live
+/// listings on the map even though they hide aggregated property rows.
+pub fn property_level_feature_names() -> Vec<&'static str> {
+ const PROPERTY_GROUPS: &[&str] = &["Properties", "Property prices"];
+ FEATURE_GROUPS
+ .iter()
+ .filter(|group| PROPERTY_GROUPS.contains(&group.name))
+ .flat_map(|group| group.features.iter())
+ .map(|feature| match feature {
+ Feature::Numeric(c) => c.name,
+ Feature::Enum(c) => c.name,
+ })
+ .collect()
+}
+
/// Flat ordered list of all numeric feature names (follows group order).
pub fn all_numeric_feature_names() -> Vec<&'static str> {
FEATURE_GROUPS
diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs
index fa669aa..95dd7a8 100644
--- a/server-rs/src/main.rs
+++ b/server-rs/src/main.rs
@@ -1,6 +1,7 @@
#![allow(clippy::min_ident_chars)]
mod aggregation;
+mod api_error;
mod auth;
mod bugsink;
mod checkout_sessions;
diff --git a/server-rs/src/routes/actual_listings.rs b/server-rs/src/routes/actual_listings.rs
index 648ca1c..2c37df1 100644
--- a/server-rs/src/routes/actual_listings.rs
+++ b/server-rs/src/routes/actual_listings.rs
@@ -1,80 +1,230 @@
use std::sync::Arc;
use axum::extract::{Query, State};
-use axum::http::StatusCode;
use axum::response::Json;
+use rustc_hash::FxHashSet;
use serde::{Deserialize, Serialize};
use tracing::info;
+use crate::api_error::ApiError;
+use crate::consts::ACTUAL_LISTINGS_LIMIT;
use crate::data::ActualListing;
-use crate::parsing::require_bounds;
-use crate::state::SharedState;
+use crate::features::property_level_feature_names;
+use crate::parsing::{
+ parse_filters_with_poi, require_bounds, row_passes_filters, row_passes_poi_filters,
+};
+use crate::state::{AppState, SharedState};
-const MAX_RESULTS: usize = 5000;
+use super::travel_time::{parse_optional_travel, row_passes_travel_filters, TravelEntry};
#[derive(Deserialize)]
pub struct ActualListingsParams {
bounds: Option,
+ /// `;;`-separated filters: `name:min:max;;...`
+ filters: Option,
+ /// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`
+ travel: Option,
+ /// Number of results to skip. Defaults to 0.
+ offset: Option,
}
#[derive(Serialize)]
pub struct ActualListingsResponse {
pub listings: Vec,
pub total: usize,
+ pub offset: usize,
pub truncated: bool,
}
pub async fn get_actual_listings(
State(shared): State>,
Query(params): Query,
-) -> Result, (StatusCode, String)> {
+) -> Result, ApiError> {
let state = shared.load_state();
+ let limit = ACTUAL_LISTINGS_LIMIT;
+ let offset = params.offset.unwrap_or(0);
let Some(actual_listings) = state.actual_listings.clone() else {
return Ok(Json(ActualListingsResponse {
listings: Vec::new(),
total: 0,
+ offset,
truncated: false,
}));
};
- let (south, west, north, east) = require_bounds(params.bounds)?;
+ let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
- let response = tokio::task::spawn_blocking(move || {
- let t0 = std::time::Instant::now();
- let row_indices = actual_listings.grid.query(south, west, north, east);
- let total = row_indices.len();
- let truncated = total > MAX_RESULTS;
+ let quant = state.data.quant_ref();
+ let poi_quant = state.data.poi_metrics.quant_ref();
+ let (mut parsed_filters, mut parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
+ params.filters.as_deref(),
+ &state.feature_name_to_index,
+ &state.data.enum_values,
+ &quant,
+ &state.data.poi_metrics.name_to_index,
+ &poi_quant,
+ )
+ .map_err(ApiError::BadRequest)?;
- let mut listings: Vec = row_indices
- .iter()
- .take(MAX_RESULTS)
- .map(|&row| actual_listings.listing_at(row as usize))
- .collect();
+ // Drop property-level filters (price, sqm, build year, beds, type, etc.) so they
+ // don't hide live listings — those are individual-property concerns the user can
+ // judge from the pin itself. We only keep area/postcode-level filters here.
+ let property_level_idxs: FxHashSet = property_level_feature_names()
+ .into_iter()
+ .filter_map(|name| state.feature_name_to_index.get(name).copied())
+ .collect();
+ parsed_filters.retain(|f| !property_level_idxs.contains(&f.feat_idx));
+ parsed_enum_filters.retain(|f| !property_level_idxs.contains(&f.feat_idx));
- // Sort newest first so the most relevant pins win when the viewport is busy.
- listings.sort_by(|left, right| {
- right
- .listing_date_iso
- .cmp(&left.listing_date_iso)
- .then_with(|| right.asking_price.cmp(&left.asking_price))
- });
+ let travel_entries =
+ parse_optional_travel(params.travel.as_deref()).map_err(ApiError::BadRequest)?;
- let elapsed = t0.elapsed();
- info!(
- results = listings.len(),
- total,
- truncated,
- ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
- "GET /api/actual-listings"
- );
+ let has_area_filters = !parsed_filters.is_empty()
+ || !parsed_enum_filters.is_empty()
+ || !parsed_poi_filters.is_empty()
+ || !travel_entries.is_empty();
- ActualListingsResponse {
- listings,
- total,
- truncated,
- }
- })
- .await
- .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
+ let state_clone = state.clone();
+ let response =
+ tokio::task::spawn_blocking(move || -> Result {
+ let t0 = std::time::Instant::now();
+
+ let passing_postcodes = if has_area_filters {
+ Some(compute_passing_postcodes(
+ &state_clone,
+ south,
+ west,
+ north,
+ east,
+ &parsed_filters,
+ &parsed_enum_filters,
+ &parsed_poi_filters,
+ &travel_entries,
+ )?)
+ } else {
+ None
+ };
+
+ let row_indices = actual_listings.grid.query(south, west, north, east);
+ let total_in_bounds = row_indices.len();
+
+ // Build (row, sort_key) pairs so we can sort by index without
+ // materializing the full ActualListing for every matching row.
+ let mut matching_rows: Vec = row_indices
+ .iter()
+ .filter_map(|&row_idx| {
+ let row = row_idx as usize;
+ if let Some(allowed) = passing_postcodes.as_ref() {
+ if !allowed.contains(actual_listings.postcode[row].as_str()) {
+ return None;
+ }
+ }
+ Some(row)
+ })
+ .collect();
+
+ let total_matching = matching_rows.len();
+
+ matching_rows.sort_by(|&left, &right| {
+ actual_listings.listing_date_iso[right]
+ .cmp(&actual_listings.listing_date_iso[left])
+ .then_with(|| {
+ actual_listings.asking_price[right].cmp(&actual_listings.asking_price[left])
+ })
+ });
+
+ let truncated = total_matching > offset.saturating_add(limit);
+ let listings: Vec = matching_rows
+ .iter()
+ .skip(offset)
+ .take(limit)
+ .map(|&row| actual_listings.listing_at(row))
+ .collect();
+
+ let elapsed = t0.elapsed();
+ info!(
+ results = listings.len(),
+ total = total_matching,
+ total_in_bounds,
+ offset,
+ filtered = passing_postcodes.is_some(),
+ ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
+ "GET /api/actual-listings"
+ );
+
+ Ok(ActualListingsResponse {
+ listings,
+ total: total_matching,
+ offset,
+ truncated,
+ })
+ })
+ .await
+ .map_err(|error| ApiError::Internal(error.to_string()))?
+ .map_err(ApiError::Internal)?;
Ok(Json(response))
}
+
+#[allow(clippy::too_many_arguments)]
+fn compute_passing_postcodes(
+ state: &AppState,
+ south: f64,
+ west: f64,
+ north: f64,
+ east: f64,
+ parsed_filters: &[crate::parsing::ParsedFilter],
+ parsed_enum_filters: &[crate::parsing::ParsedEnumFilter],
+ parsed_poi_filters: &[crate::parsing::ParsedPoiFilter],
+ travel_entries: &[TravelEntry],
+) -> Result, String> {
+ let num_features = state.data.num_features;
+ let feature_data = &state.data.feature_data;
+ let poi_metrics = &state.data.poi_metrics;
+ let has_poi_filters = !parsed_poi_filters.is_empty();
+
+ let travel_data = if travel_entries.is_empty() {
+ Vec::new()
+ } else {
+ let store = &state.travel_time_store;
+ travel_entries
+ .iter()
+ .map(|entry| {
+ store
+ .get(&entry.mode, &entry.slug)
+ .map_err(|err| format!("Failed to load travel data: {}", err))
+ })
+ .collect::, _>>()?
+ };
+ let has_travel = !travel_entries.is_empty();
+
+ let mut passing: FxHashSet = FxHashSet::default();
+
+ state
+ .grid
+ .for_each_in_bounds(south, west, north, east, |row_idx| {
+ let row = row_idx as usize;
+ if !row_passes_filters(
+ row,
+ parsed_filters,
+ parsed_enum_filters,
+ feature_data,
+ num_features,
+ ) {
+ return;
+ }
+ if has_poi_filters && !row_passes_poi_filters(row, parsed_poi_filters, poi_metrics) {
+ return;
+ }
+ let postcode = state.data.postcode(row);
+ if has_travel && !row_passes_travel_filters(postcode, travel_entries, &travel_data) {
+ return;
+ }
+ // Property postcodes share the same canonical "OUT IN" format used by
+ // ActualListingData::load (normalize_postcode), so we can match by string.
+ if !passing.contains(postcode) {
+ passing.insert(postcode.to_string());
+ }
+ });
+
+ Ok(passing)
+}
diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs
index c3bae11..0c9e696 100644
--- a/server-rs/src/routes/hexagon_stats.rs
+++ b/server-rs/src/routes/hexagon_stats.rs
@@ -1,6 +1,8 @@
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
-use std::sync::Arc;
+use std::sync::{Arc, Once};
+
+static OUT_OF_RANGE_WARN: Once = Once::new();
use axum::extract::{Query, State};
use axum::http::StatusCode;
@@ -260,6 +262,14 @@ pub(super) fn top_filter_exclusions(
continue;
};
let Some(category) = values.get(raw as usize) else {
+ OUT_OF_RANGE_WARN.call_once(|| {
+ warn!(
+ feature = %data.feature_names[filter.feat_idx],
+ raw,
+ max = values.len(),
+ "Enum value index out of range (logged once)"
+ );
+ });
continue;
};
@@ -372,10 +382,10 @@ pub(super) fn top_filter_exclusions(
.unwrap_or(f32::INFINITY);
let replace = path_score < current_score
- || (path_score == current_score
+ || (path_score.total_cmp(¤t_score) == std::cmp::Ordering::Equal
&& best_path
.as_ref()
- .map_or(true, |current| path.len() < current.len()));
+ .is_none_or(|current| path.len() < current.len()));
if replace {
best_path = Some(path);
}
@@ -394,8 +404,7 @@ pub(super) fn top_filter_exclusions(
exclusions.sort_by(|a, b| {
a.relative_difference
- .partial_cmp(&b.relative_difference)
- .unwrap_or(std::cmp::Ordering::Equal)
+ .total_cmp(&b.relative_difference)
.then_with(|| b.rejected_count.cmp(&a.rejected_count))
.then_with(|| a.name.cmp(&b.name))
});
@@ -524,6 +533,27 @@ pub async fn get_hexagon_stats(
// for the requested journey destination (so it has journey data). Fall back
// to geographic proximity to the hexagon center.
let central_postcode = if !matching_rows.is_empty() {
+ let center: h3o::LatLng = cell.into();
+ let center_lat = center.lat() as f32;
+ let center_lon = center.lng() as f32;
+ let lat = state.data.lat.as_slice();
+ let lon = state.data.lon.as_slice();
+ let distance_sq = |row: usize| -> Option {
+ match (lat.get(row), lon.get(row)) {
+ (Some(&la), Some(&lo)) if la.is_finite() && lo.is_finite() => {
+ Some((la - center_lat).powi(2) + (lo - center_lon).powi(2))
+ }
+ _ => {
+ OUT_OF_RANGE_WARN.call_once(|| {
+ warn!(
+ "matching_rows index out of range or non-finite lat/lon (logged once)"
+ );
+ });
+ None
+ }
+ }
+ };
+
if let Some(ref travel_data) = journey_travel_data {
// Find the row with the shortest travel time in the travel data
let best_row = matching_rows
@@ -537,40 +567,24 @@ pub async fn get_hexagon_stats(
.map(|(row, _)| row);
// Fall back to geographic center if no row has travel data
- let row = best_row.unwrap_or_else(|| {
- let center: h3o::LatLng = cell.into();
- let center_lat = center.lat() as f32;
- let center_lon = center.lng() as f32;
+ let row = best_row.or_else(|| {
matching_rows
.iter()
.copied()
- .min_by(|&a, &b| {
- let da = (state.data.lat[a] - center_lat).powi(2)
- + (state.data.lon[a] - center_lon).powi(2);
- let db = (state.data.lat[b] - center_lat).powi(2)
- + (state.data.lon[b] - center_lon).powi(2);
- da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
- })
- .expect("matching_rows is non-empty")
+ .filter_map(|row| distance_sq(row).map(|d| (row, d)))
+ .min_by(|a, b| a.1.total_cmp(&b.1))
+ .map(|(row, _)| row)
});
- Some(state.data.postcode(row).to_string())
+ row.map(|row| state.data.postcode(row).to_string())
} else {
// No journey destination requested — use geographic center
- let center: h3o::LatLng = cell.into();
- let center_lat = center.lat() as f32;
- let center_lon = center.lng() as f32;
let closest_row = matching_rows
.iter()
.copied()
- .min_by(|&a, &b| {
- let da = (state.data.lat[a] - center_lat).powi(2)
- + (state.data.lon[a] - center_lon).powi(2);
- let db = (state.data.lat[b] - center_lat).powi(2)
- + (state.data.lon[b] - center_lon).powi(2);
- da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
- })
- .expect("matching_rows is non-empty");
- Some(state.data.postcode(closest_row).to_string())
+ .filter_map(|row| distance_sq(row).map(|d| (row, d)))
+ .min_by(|a, b| a.1.total_cmp(&b.1))
+ .map(|(row, _)| row);
+ closest_row.map(|row| state.data.postcode(row).to_string())
}
} else {
None
diff --git a/server-rs/src/routes/invites.rs b/server-rs/src/routes/invites.rs
index 71befcb..0d252ef 100644
--- a/server-rs/src/routes/invites.rs
+++ b/server-rs/src/routes/invites.rs
@@ -292,6 +292,47 @@ async fn mark_invite_used(
return Err(StatusCode::BAD_GATEWAY.into_response());
}
+ // Defense in depth: PocketBase has no atomic compare-and-swap for record
+ // updates, and our local + distributed locks could in principle fail (lock
+ // server timeout, server restart mid-redemption). Re-read the record and
+ // confirm WE actually own it — if a concurrent redemption beat us to the
+ // PATCH, both writes succeeded but the loser's user_id is overwritten and
+ // we must NOT grant a license.
+ let verify_url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
+ let verify_resp = match state
+ .http_client
+ .get(&verify_url)
+ .header("Authorization", format!("Bearer {token}"))
+ .send()
+ .await
+ {
+ Ok(r) => r,
+ Err(err) => {
+ warn!("Failed to verify invite redemption: {err}");
+ return Err(StatusCode::BAD_GATEWAY.into_response());
+ }
+ };
+ if !verify_resp.status().is_success() {
+ return Err(StatusCode::BAD_GATEWAY.into_response());
+ }
+ let body: serde_json::Value = match verify_resp.json().await {
+ Ok(v) => v,
+ Err(err) => {
+ warn!("Failed to parse invite verify response: {err}");
+ return Err(StatusCode::BAD_GATEWAY.into_response());
+ }
+ };
+ let actual_user = body["used_by_id"].as_str().unwrap_or("");
+ if actual_user != user_id {
+ warn!(
+ invite_id,
+ expected = user_id,
+ actual = actual_user,
+ "Invite redemption race lost — invite already claimed by another user"
+ );
+ return Err((StatusCode::CONFLICT, "Invite was already redeemed").into_response());
+ }
+
Ok(())
}
@@ -512,11 +553,16 @@ pub async fn get_invite(
.await
{
Ok(resp) if resp.status().is_success() => {
- let user_body: serde_json::Value = resp.json().await.unwrap_or_default();
- user_body["email"]
- .as_str()
- .and_then(|e| e.split('@').next())
- .and_then(sanitize_invited_by)
+ match resp.json::().await {
+ Ok(user_body) => user_body["email"]
+ .as_str()
+ .and_then(|e| e.split('@').next())
+ .and_then(sanitize_invited_by),
+ Err(err) => {
+ tracing::error!("Failed to parse inviter user record JSON: {err}");
+ return StatusCode::BAD_GATEWAY.into_response();
+ }
+ }
}
_ => None,
}
@@ -689,26 +735,6 @@ pub async fn post_redeem_invite(
.into_response()
}
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
- let filter = redeemable_invite_filter("abc123", "user123").unwrap();
- assert_eq!(
- filter,
- "code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
- );
- }
-
- #[test]
- fn redeemable_invite_filter_rejects_unsafe_values() {
- assert!(redeemable_invite_filter("bad-code", "user123").is_err());
- assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
- }
-}
-
/// List invites. Users only see invites they created, including admins.
pub async fn get_invites(
State(shared): State>,
@@ -787,3 +813,23 @@ pub async fn get_invites(
Json(InviteListResponse { invites }).into_response()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
+ let filter = redeemable_invite_filter("abc123", "user123").unwrap();
+ assert_eq!(
+ filter,
+ "code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
+ );
+ }
+
+ #[test]
+ fn redeemable_invite_filter_rejects_unsafe_values() {
+ assert!(redeemable_invite_filter("bad-code", "user123").is_err());
+ assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
+ }
+}
diff --git a/server-rs/src/routes/places.rs b/server-rs/src/routes/places.rs
index 76bb64b..fef10fb 100644
--- a/server-rs/src/routes/places.rs
+++ b/server-rs/src/routes/places.rs
@@ -1,11 +1,12 @@
use std::sync::Arc;
use axum::extract::{Query, State};
-use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
+use crate::api_error::ApiError;
+use crate::consts::PLACES_LIMIT;
use crate::data::{normalize_search_text, slugify};
use crate::state::SharedState;
@@ -41,7 +42,6 @@ pub struct PlacesResponse {
#[allow(clippy::min_ident_chars)]
pub struct PlacesParams {
q: String,
- limit: Option,
/// If set, only return places that have travel time data for this mode.
mode: Option,
}
@@ -96,15 +96,15 @@ fn postcode_starts_with_compact(postcode: &str, compact_query: &str) -> bool {
pub async fn get_places(
State(shared): State>,
Query(params): Query,
-) -> Result, (StatusCode, String)> {
+) -> Result, ApiError> {
let state = shared.load_state();
let query = if params.q.is_empty() {
- return Err((StatusCode::BAD_REQUEST, "'q' must not be empty".into()));
+ return Err(ApiError::BadRequest("'q' must not be empty".into()));
} else {
params.q
};
- let limit = params.limit.unwrap_or(7).min(20);
+ let limit = PLACES_LIMIT;
let mode_filter = params.mode;
let places = tokio::task::spawn_blocking(move || {
@@ -264,7 +264,7 @@ pub async fn get_places(
(results, postcodes, addresses)
})
.await
- .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
+ .map_err(|error| ApiError::Internal(error.to_string()))?;
Ok(Json(PlacesResponse {
places: places.0,
diff --git a/server-rs/src/routes/pois.rs b/server-rs/src/routes/pois.rs
index 88946c9..951bc3f 100644
--- a/server-rs/src/routes/pois.rs
+++ b/server-rs/src/routes/pois.rs
@@ -1,11 +1,11 @@
use std::sync::Arc;
use axum::extract::{Query, State};
-use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
+use crate::api_error::ApiError;
use crate::consts::MAX_POIS_PER_REQUEST;
use crate::data::{resolve_poi_category_filter, POICategoryGroup};
use crate::parsing::require_bounds;
@@ -39,9 +39,9 @@ pub struct POIParams {
pub async fn get_pois(
State(shared): State>,
Query(params): Query,
-) -> Result, (StatusCode, String)> {
+) -> Result, ApiError> {
let state = shared.load_state();
- let (south, west, north, east) = require_bounds(params.bounds)?;
+ let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
let category_filter: Option> = params
.categories
@@ -109,7 +109,7 @@ pub async fn get_pois(
pois
})
.await
- .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
+ .map_err(|error| ApiError::Internal(error.to_string()))?;
Ok(Json(POIsResponse { pois }))
}
diff --git a/server-rs/src/routes/postcode_properties.rs b/server-rs/src/routes/postcode_properties.rs
index 057d0ed..44e40c2 100644
--- a/server-rs/src/routes/postcode_properties.rs
+++ b/server-rs/src/routes/postcode_properties.rs
@@ -8,13 +8,13 @@ use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
-use crate::consts::{DEFAULT_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET};
+use crate::consts::{POSTCODE_SEARCH_OFFSET, PROPERTIES_LIMIT};
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
-use super::properties::{HexagonPropertiesResponse, Property};
+use super::properties::{Property, PropertyListResponse};
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
@@ -24,7 +24,6 @@ pub struct PostcodePropertiesParams {
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option,
- pub limit: Option,
pub offset: Option,
/// Exact address to rank first when opening properties from address search.
pub focus_address: Option,
@@ -36,7 +35,7 @@ pub async fn get_postcode_properties(
State(shared): State>,
Extension(user): Extension,
Query(params): Query,
-) -> Result, axum::response::Response> {
+) -> Result, axum::response::Response> {
let state = shared.load_state();
let normalized = normalize_postcode(¶ms.postcode);
@@ -151,7 +150,7 @@ pub async fn get_postcode_properties(
});
let total = matching_rows.len();
- let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
+ let limit = PROPERTIES_LIMIT;
let page_offset = params.offset.unwrap_or(0);
let truncated = total > page_offset + limit;
@@ -183,10 +182,9 @@ pub async fn get_postcode_properties(
"GET /api/postcode-properties"
);
- Ok(HexagonPropertiesResponse {
+ Ok(PropertyListResponse {
properties,
total,
- limit,
offset: page_offset,
truncated,
})
diff --git a/server-rs/src/routes/properties.rs b/server-rs/src/routes/properties.rs
index e32ff3a..6f141e9 100644
--- a/server-rs/src/routes/properties.rs
+++ b/server-rs/src/routes/properties.rs
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
-use crate::consts::DEFAULT_PROPERTIES_LIMIT;
+use crate::consts::PROPERTIES_LIMIT;
use crate::data::RenovationEvent;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
@@ -29,7 +29,6 @@ pub struct HexagonPropertiesParams {
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option,
- pub limit: Option,
pub offset: Option,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option,
@@ -62,11 +61,13 @@ pub struct Property {
pub features: FxHashMap,
}
+/// Shared paginated list of `Property` records. Used by both
+/// `/api/hexagon-properties` (lookup by H3 cell) and `/api/postcode-properties`
+/// (lookup by postcode) so the frontend can render either result the same way.
#[derive(Serialize)]
-pub struct HexagonPropertiesResponse {
+pub struct PropertyListResponse {
pub properties: Vec,
pub total: usize,
- pub limit: usize,
pub offset: usize,
pub truncated: bool,
}
@@ -183,7 +184,7 @@ pub async fn get_hexagon_properties(
State(shared): State>,
Extension(user): Extension,
Query(params): Query,
-) -> Result, axum::response::Response> {
+) -> Result, axum::response::Response> {
let state = shared.load_state();
let cell = h3o::CellIndex::from_str(¶ms.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
@@ -273,7 +274,7 @@ pub async fn get_hexagon_properties(
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
let total = matching_rows.len();
- let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
+ let limit = PROPERTIES_LIMIT;
let offset = params.offset.unwrap_or(0);
let truncated = total > offset + limit;
@@ -306,10 +307,9 @@ pub async fn get_hexagon_properties(
"GET /api/hexagon-properties"
);
- Ok(HexagonPropertiesResponse {
+ Ok(PropertyListResponse {
properties,
total,
- limit,
offset,
truncated,
})
diff --git a/server-rs/src/routes/stats.rs b/server-rs/src/routes/stats.rs
index 41b369c..aa6f4ea 100644
--- a/server-rs/src/routes/stats.rs
+++ b/server-rs/src/routes/stats.rs
@@ -4,7 +4,7 @@ use metrics::counter;
use rustc_hash::FxHashMap;
use tracing::error;
-use crate::consts::MAX_PRICE_HISTORY_POINTS;
+use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
use super::hexagon_stats::{EnumFeatureStats, HistogramStats, NumericFeatureStats, PricePoint};
@@ -32,9 +32,9 @@ pub fn extract_price_history(
}
})
.collect();
- if points.len() > MAX_PRICE_HISTORY_POINTS {
- let step = points.len() as f64 / MAX_PRICE_HISTORY_POINTS as f64;
- points = (0..MAX_PRICE_HISTORY_POINTS)
+ if points.len() > PRICE_HISTORY_POINTS_LIMIT {
+ let step = points.len() as f64 / PRICE_HISTORY_POINTS_LIMIT as f64;
+ points = (0..PRICE_HISTORY_POINTS_LIMIT)
.map(|i| {
let idx = (i as f64 * step) as usize;
PricePoint {
diff --git a/uv.lock b/uv.lock
index 4412511..fcc460a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1401,7 +1401,6 @@ dev = [
requires-dist = [
{ name = "fastexcel", specifier = ">=0.19.0" },
{ name = "folium", specifier = ">=0.20.0" },
- { name = "httpx" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "ipywidgets", specifier = ">=8.0.0" },
{ name = "jupyter", specifier = ">=1.0.0" },
@@ -1411,8 +1410,7 @@ requires-dist = [
{ name = "pandas", specifier = ">=2.0.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "plotly", specifier = ">=6.5.2" },
- { name = "polars" },
- { name = "polars", specifier = ">=1.37.1" },
+ { name = "polars", specifier = ">=1.37.1,<2.0.0" },
{ name = "pyarrow", specifier = ">=15.0.0" },
{ name = "pyogrio", specifier = ">=0.12.1" },
{ name = "pyproj", specifier = ">=3.7.2" },