diff --git a/frontend/src/components/legal/legal-content.ts b/frontend/src/components/legal/legal-content.ts
index 7ea6a7d..edd923f 100644
--- a/frontend/src/components/legal/legal-content.ts
+++ b/frontend/src/components/legal/legal-content.ts
@@ -41,14 +41,14 @@ export const TERMS: LegalDoc = {
{
heading: '2. Accounts',
paragraphs: [
- 'You need an account to use the service beyond the free demo area. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
+ 'You can browse the map without an account. To save searches or buy lifetime access you need an account. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
'We may suspend or close accounts that breach these terms, abuse the service, or attempt to circumvent access restrictions. If we close your account without cause, we will refund the price you paid.',
],
},
{
- heading: '3. Free demo and lifetime access',
+ heading: '3. Free access and lifetime access',
paragraphs: [
- 'Free accounts can explore all features within the demo area (inner London). Lifetime access is a one-time payment that gives your account ongoing access to the paid map — every postcode, every filter — for as long as the service runs. It is not a subscription, and routine data updates are included.',
+ 'Free users can explore the full map of England with every feature, applying up to three filters at a time. Lifetime access is a one-time payment that removes the filter limit and gives your account ongoing access — every postcode, every filter, unlimited — for as long as the service runs. It is not a subscription, and routine data updates are included.',
'Lifetime access is personal and non-transferable, and is for personal, non-commercial property research. If you would like to use Perfect Postcode commercially (for example in lettings, relocation or research services), contact us first.',
],
},
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx
index 8b0b80d..d990940 100644
--- a/frontend/src/components/map/AreaPane.tsx
+++ b/frontend/src/components/map/AreaPane.tsx
@@ -11,7 +11,11 @@ import type {
} from '../../types';
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
-import { formatStationDistance, type NearbyStation } from '../../lib/nearby-stations';
+import {
+ formatStationDistance,
+ stationDisplayName,
+ type NearbyStation,
+} from '../../lib/nearby-stations';
import {
formatValue,
formatFilterValue,
@@ -211,7 +215,7 @@ function NearbyStationRow({ station }: { station: NearbyStation }) {
)}
- {station.name}
+ {stationDisplayName(station.name)}
{ts(station.category)}
@@ -595,367 +599,370 @@ export default function AreaPane({
/>
) : (
<>
- {stackedCharts?.map((chart) => {
- const segments = chart.components
- .map((name) => ({
- name,
- value: numericByName.get(name)?.mean ?? 0,
- }))
- .filter((s) => s.value > 0);
+ {stackedCharts?.map((chart) => {
+ const segments = chart.components
+ .map((name) => ({
+ name,
+ value: numericByName.get(name)?.mean ?? 0,
+ }))
+ .filter((s) => s.value > 0);
- const isPercentageComposition = chart.unit === '%' && !chart.feature;
- const displaySegments = isPercentageComposition
- ? normalizePercentageSegments(segments)
- : segments;
+ 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);
+ 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. a percentage) for display if available
- const rateStats = chart.rateFeature
- ? numericByName.get(chart.rateFeature)
- : undefined;
- const displayValue = isPercentageComposition
- ? 100
- : rateStats
- ? rateStats.mean
- : total;
+ // Use rateFeature (e.g. a percentage) 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;
+ // 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;
- const crimeAreaAvg = infoFeatureName
- ? crimeAreaAvgByName.get(infoFeatureName)
- : undefined;
- // For crime, prefer the exact national mean so it shares
- // one estimator with the outcode/sector/selection values.
- const nationalAvg = crimeAreaAvg?.national ?? globalMean;
+ const globalMean = featureMeta?.histogram
+ ? calculateHistogramMean(featureMeta.histogram)
+ : undefined;
+ const crimeAreaAvg = infoFeatureName
+ ? crimeAreaAvgByName.get(infoFeatureName)
+ : undefined;
+ // For crime, prefer the exact national mean so it shares
+ // one estimator with the outcode/sector/selection values.
+ const nationalAvg = crimeAreaAvg?.national ?? globalMean;
- // Crime metrics get a number line comparing this area to
- // its sector / outcode / nation instead of a flat list.
- const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
- ? (
- [
- {
- kind: 'area',
- label: t('areaPane.thisArea'),
- value: displayValue,
- },
- nationalAvg != null
- ? {
- kind: 'national',
- label: t('areaPane.national'),
- value: nationalAvg,
- }
- : null,
- crimeAreaAvg.outcode != null
- ? {
- kind: 'outcode',
- label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'),
- value: crimeAreaAvg.outcode,
- }
- : null,
- crimeAreaAvg.sector != null
- ? {
- kind: 'sector',
- label: stats?.crime_sector ?? t('areaPane.sectorAvg'),
- value: crimeAreaAvg.sector,
- }
- : null,
- ] as (NumberLinePoint | null)[]
- ).filter((p): p is NumberLinePoint => p !== null)
- : [];
+ // Crime metrics get a number line comparing this area to
+ // its sector / outcode / nation instead of a flat list.
+ const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
+ ? (
+ [
+ {
+ kind: 'area',
+ label: t('areaPane.thisArea'),
+ value: displayValue,
+ },
+ nationalAvg != null
+ ? {
+ kind: 'national',
+ label: t('areaPane.national'),
+ value: nationalAvg,
+ }
+ : null,
+ crimeAreaAvg.outcode != null
+ ? {
+ kind: 'outcode',
+ label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'),
+ value: crimeAreaAvg.outcode,
+ }
+ : null,
+ crimeAreaAvg.sector != null
+ ? {
+ kind: 'sector',
+ label: stats?.crime_sector ?? t('areaPane.sectorAvg'),
+ value: crimeAreaAvg.sector,
+ }
+ : null,
+ ] as (NumberLinePoint | null)[]
+ ).filter((p): p is NumberLinePoint => p !== null)
+ : [];
- if (total === 0) return null;
+ if (total === 0) return null;
- const crimeSeries = chart.feature
- ? crimeByYearByType.get(chart.feature)
- : undefined;
+ const crimeSeries = chart.feature
+ ? crimeByYearByType.get(chart.feature)
+ : undefined;
- return (
-
-
- {featureMeta ? (
-
- ) : (
-
- {ts(chart.label)}
-
- )}
-
-
- {formatValue(displayValue)}
- {chart.unit ? ` ${chart.unit}` : ''}
-
- {/* Crime shows its national/outcode/sector
+ return (
+
+
+ {featureMeta ? (
+
+ ) : (
+
+ {ts(chart.label)}
+
+ )}
+
+
+ {formatValue(displayValue)}
+ {chart.unit ? ` ${chart.unit}` : ''}
+
+ {/* Crime shows its national/outcode/sector
comparison on the number line below; other
stacked metrics keep the inline national avg. */}
- {!crimeAreaAvg && nationalAvg != null && (
-
- {t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
+ {!crimeAreaAvg && nationalAvg != null && (
+
+ {t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
+
+ )}
+
+
+
+ {numberLinePoints.length >= 2 && (
+
+
+
+ )}
+ {crimeSeries && crimeSeries.points.length > 1 && (
+
+
)}
-
-
- {numberLinePoints.length >= 2 && (
-
-
-
- )}
- {crimeSeries && crimeSeries.points.length > 1 && (
-
-
-
- )}
-
- );
- })}
- {(() => {
- 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 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;
- const crimeSeries = crimeByYearByType.get(feature.name);
- const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
- // National avg is shown for every metric here as a
- // tooltip; for crime metrics the outcode and sector
- // averages join it on their own lines, and the exact
- // national mean is preferred over the histogram one.
- const nationalAvg = crimeAreaAvg?.national ?? globalMean;
- const valueTitle =
- [
- nationalAvg != null
- ? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
- : null,
- crimeAreaAvg?.outcode != null
- ? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
- : null,
- crimeAreaAvg?.sector != null
- ? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
- : null,
- ]
- .filter(Boolean)
- .join('\n') || undefined;
+ if (numericStats) {
+ const globalFeature = globalFeatureByName.get(feature.name);
+ const globalHistogram = globalFeature?.histogram;
+ const globalMean = globalHistogram
+ ? calculateHistogramMean(globalHistogram)
+ : undefined;
+ const crimeSeries = crimeByYearByType.get(feature.name);
+ const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
+ // National avg is shown for every metric here as a
+ // tooltip; for crime metrics the outcode and sector
+ // averages join it on their own lines, and the exact
+ // national mean is preferred over the histogram one.
+ const nationalAvg = crimeAreaAvg?.national ?? globalMean;
+ const valueTitle =
+ [
+ nationalAvg != null
+ ? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
+ : null,
+ crimeAreaAvg?.outcode != null
+ ? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
+ : null,
+ crimeAreaAvg?.sector != null
+ ? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join('\n') || undefined;
- return (
-
+ }
+ chart={
+ crimeSeries && crimeSeries.points.length > 1 ? (
+
+ ) : (
+ 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={valueTitle}
/>
- }
- chart={
- crimeSeries && crimeSeries.points.length > 1 ? (
-
- ) : (
- 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={valueTitle}
- />
- );
- }
+ );
+ }
+
+ 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,
+ }))
+ .filter((s) => s.value > 0);
+ const total = segments.reduce((sum, s) => sum + s.value, 0);
+ if (total === 0) return null;
- if (enumStats) {
- const globalFeature = globalFeatureByName.get(feature.name);
return (
-
-
+ {featureMeta ? (
+
+ ) : (
+
+ {ts(chart.label)}
+
+ )}
+
+ {total.toLocaleString()}
+
+
+ [v, chart.valueColors[i]])
+ )}
/>
);
}
- return null;
- });
- })()}
- {stackedEnumCharts?.map((chart) => {
- const featureMeta = chart.feature
- ? globalFeatureByName.get(chart.feature)
- : undefined;
+ 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 (chart.components.length === 1) {
- const stats = enumByName.get(chart.components[0]);
- if (!stats) return null;
+ if (components.length === 0) 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]])
- )}
- />
-
- );
- }
-
- 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 ? (
-
+
+ {featureMeta ? (
+
+ ) : (
+
+ {ts(chart.label)}
+
+ )}
+
+
- ) : (
-
- {ts(chart.label)}
-
- )}
-
-
-
- );
- })}
+
+ );
+ })}
>
)}
diff --git a/frontend/src/components/map/CrimeRecordsSection.tsx b/frontend/src/components/map/CrimeRecordsSection.tsx
index 8326513..eed6e0f 100644
--- a/frontend/src/components/map/CrimeRecordsSection.tsx
+++ b/frontend/src/components/map/CrimeRecordsSection.tsx
@@ -110,7 +110,10 @@ export default function CrimeRecordsSection({
<>