diff --git a/Dockerfile b/Dockerfile index bd543c8..7441776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,4 +56,4 @@ EXPOSE 8001 HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ CMD curl -f http://localhost:8001/health || exit 1 ENTRYPOINT ["./property-map-server"] -CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/data/crime_by_postcode_by_year.parquet", "--area-crime-averages-path", "/app/data/area_crime_averages.parquet", "--population-path", "/app/data/population_by_postcode.parquet", "--developments-path", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"] +CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/data/crime_by_postcode_by_year.parquet", "--crime-records-path", "/app/data/crime_records.parquet", "--area-crime-averages-path", "/app/data/area_crime_averages.parquet", "--population-path", "/app/data/population_by_postcode.parquet", "--developments-path", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"] diff --git a/Makefile.data b/Makefile.data index dbcf1c4..d576639 100644 --- a/Makefile.data +++ b/Makefile.data @@ -27,7 +27,10 @@ POSTCODES_RAW := $(DATA_DIR)/gb-postcodes-v5 POSTCODES_PQ := $(DATA_DIR)/postcode.parquet PROPERTIES_PQ := $(DATA_DIR)/properties.parquet MERGE_STAMP := $(DATA_DIR)/.merge_done +PRICE_INPUTS := $(DATA_DIR)/price_inputs.parquet +POSTCODE_CENTROIDS := $(DATA_DIR)/postcode_centroids.parquet PRICE_INDEX := $(DATA_DIR)/price_index.parquet +PRICE_ESTIMATES := $(DATA_DIR)/price_estimates.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done EPC := $(MANUAL_DATA)/domestic-csv.zip ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet @@ -38,6 +41,8 @@ TENURE := $(DATA_DIR)/tenure_by_lsoa.parquet CRIME_DIR := $(DATA_DIR)/crime CRIME := $(DATA_DIR)/crime_by_postcode.parquet CRIME_BY_YEAR := $(DATA_DIR)/crime_by_postcode_by_year.parquet +CRIME_RECORDS := $(DATA_DIR)/crime_records.parquet +AREA_CRIME_AVERAGES := $(DATA_DIR)/area_crime_averages.parquet POPULATION := $(DATA_DIR)/population_by_postcode.parquet CRIME_STAMP := $(CRIME_DIR)/.downloaded NOISE := $(DATA_DIR)/road_noise.parquet @@ -91,8 +96,11 @@ VALIDATE_OUTPUTS := uv run python -m pipeline.validate_outputs POI_PROXIMITY_DEPS := pipeline/transform/poi_proximity.py pipeline/utils/poi_counts.py MERGE_DEPS := pipeline/transform/merge.py +AREA_CRIME_AVERAGES_DEPS := pipeline/transform/area_crime_averages.py PRICE_INDEX_DEPS := pipeline/transform/price_estimation/index.py pipeline/transform/price_estimation/shrinkage.py pipeline/transform/price_estimation/utils.py PRICE_ESTIMATE_DEPS := pipeline/transform/price_estimation/estimate.py pipeline/transform/price_estimation/knn.py pipeline/transform/price_estimation/utils.py +PRICE_JOIN_DEPS := pipeline/transform/join_price_estimates.py pipeline/transform/price_estimation/utils.py +PRICE_INPUTS_DEPS := pipeline/transform/property_base.py pipeline/utils/postcode_mapping.py TREE_DENSITY_DEPS := pipeline/transform/tree_density.py PC_BOUNDARIES_DEPS := pipeline/transform/postcode_boundaries/__main__.py \ pipeline/transform/postcode_boundaries/greenspace.py \ @@ -116,11 +124,11 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_ download-postcodes download-noise download-inspire download-crime \ download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-nfi download-ofs-register download-places download-median-age download-population download-england-boundary download-rightmove-outcodes \ download-map-assets \ - transform-pois transform-epc-pp transform-crime transform-poi-proximity \ + transform-pois transform-epc-pp transform-crime transform-poi-proximity transform-area-crime-averages \ transform-school-catchments transform-tree-density \ generate-postcode-boundaries generate-travel-times enrich-actual-listings download-development-sites -prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times $(DEVELOPMENT_SITES) $(POPULATION) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) +prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times $(DEVELOPMENT_SITES) $(POPULATION) $(CRIME_RECORDS) $(AREA_CRIME_AVERAGES) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX) --postcode-boundary-match "$(POSTCODES_PQ)::$(PC_BOUNDARIES)" --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX) merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" @@ -178,6 +186,7 @@ transform-pois: $(POIS_FILTERED) transform-epc-pp: $(EPC_PP) transform-crime: $(CRIME) transform-poi-proximity: $(POI_PROXIMITY) +transform-area-crime-averages: $(AREA_CRIME_AVERAGES) transform-school-catchments: $(SCHOOL_CATCH) transform-tree-density: $(TREE_DENSITY_PC) generate-postcode-boundaries: $(PC_BOUNDARIES_STAMP) @@ -383,9 +392,9 @@ $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(OFSTE $(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@ -$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py +$(CRIME) $(CRIME_BY_YEAR) $(CRIME_RECORDS) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py $(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv" - uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) + uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) --output-records $(CRIME_RECORDS) $(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DEPS) uv run python -m pipeline.transform.poi_proximity --arcgis $(ARCGIS) --pois $(POIS_FILTERED) --greenspace $(OS_GREENSPACE) --output $@ @@ -450,16 +459,42 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ $(POSTCODES_PQ) $(PROPERTIES_PQ) &: $(MERGE_STAMP) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" -$(PRICE_INDEX): $(MERGE_STAMP) $(PRICE_INDEX_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) - uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@ +# Slim price-estimation inputs, built straight from epc_pp + arcgis (NOT the +# merge outputs). This is what decouples the expensive index/kNN from merge: +# adding an area or property column to merge does not change these files, so the +# price index and estimates are reused and only the cheap join re-runs. +$(PRICE_INPUTS) $(POSTCODE_CENTROIDS) &: $(EPC_PP) $(ARCGIS) $(PRICE_INPUTS_DEPS) + uv run python -m pipeline.transform.property_base --epc-pp $(EPC_PP) --arcgis $(ARCGIS) --output $(PRICE_INPUTS) --centroids $(POSTCODE_CENTROIDS) + $(VALIDATE_OUTPUTS) --parquet $(PRICE_INPUTS) --parquet $(POSTCODE_CENTROIDS) + +$(PRICE_INDEX): $(PRICE_INPUTS) $(POSTCODE_CENTROIDS) $(PRICE_INDEX_DEPS) + uv run python -m pipeline.transform.price_estimation.index --input $(PRICE_INPUTS) --postcodes $(POSTCODE_CENTROIDS) --output $@ $(VALIDATE_OUTPUTS) --parquet $@ -$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) +# Estimate prices on the slim inputs and write a standalone price_estimates.parquet +# (natural key + the two estimate columns). Never touches properties.parquet. +$(PRICE_ESTIMATES): $(PRICE_INPUTS) $(POSTCODE_CENTROIDS) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) + uv run python -m pipeline.transform.price_estimation.estimate --input $(PRICE_INPUTS) --postcodes $(POSTCODE_CENTROIDS) --index $(PRICE_INDEX) --output $@ + $(VALIDATE_OUTPUTS) --parquet $@ + +# Join the estimate columns back onto properties.parquet by natural key +# (idempotent, atomic). Re-runs whenever merge or the estimates change. +$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_ESTIMATES) $(PRICE_JOIN_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) $(PRICE_INDEX) @rm -f $@ - uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX) + uv run python -m pipeline.transform.join_price_estimates --properties $(PROPERTIES_PQ) --estimates $(PRICE_ESTIMATES) $(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX) @touch $@ +# ── Area crime averages (post-merge) ──────────────────── +# National / per-outcode / per-sector property-weighted mean headline crime +# rates for the right pane. Depends on $(PRICES_STAMP) so it reads the +# finalised properties.parquet (the price-estimate join rewrites it); only the +# per-postcode property counts and the crime values from postcode.parquet are +# used, both unaffected by that join. +$(AREA_CRIME_AVERAGES): $(PRICES_STAMP) $(AREA_CRIME_AVERAGES_DEPS) | $(POSTCODES_PQ) $(PROPERTIES_PQ) + uv run python -m pipeline.transform.area_crime_averages --postcodes $(POSTCODES_PQ) --properties $(PROPERTIES_PQ) --output $@ + $(VALIDATE_OUTPUTS) --parquet $@ + $(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \ $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ $(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \ diff --git a/docker-compose.yml b/docker-compose.yml index 81a8413..45825c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: command: > bash -c " cargo install cargo-watch && - cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --population-path /app/property-data/population_by_postcode.parquet' + cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --crime-records-path /app/property-data/crime_records.parquet --area-crime-averages-path /app/property-data/area_crime_averages.parquet --population-path /app/property-data/population_by_postcode.parquet' " ports: - "8001:8001" diff --git a/frontend/public/assets/poi-icons/public_transport/london_dlr.svg b/frontend/public/assets/poi-icons/public_transport/london_dlr.svg new file mode 100644 index 0000000..d1bc20e --- /dev/null +++ b/frontend/public/assets/poi-icons/public_transport/london_dlr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/twemoji/1f688.png b/frontend/public/assets/twemoji/1f688.png new file mode 100644 index 0000000..26aa866 Binary files /dev/null and b/frontend/public/assets/twemoji/1f688.png differ diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs index ec1fdcb..d0c4151 100644 --- a/frontend/scripts/check-translations.mjs +++ b/frontend/scripts/check-translations.mjs @@ -214,16 +214,41 @@ function readServerFeatureNames() { return [...new Set(names)]; } +// Names of the Enum/Numeric feature *configs* (each needs a description + detail +// translation). We take the FIRST `name:` field after every Feature::Enum( / +// Feature::Numeric( opening. This deliberately skips macro-generated configs +// whose name is a `concat!(...)` expression (the crime rates — handled via +// deriveLegacyCrimeKeys instead) and stops the lazy match from running past such +// a config into an unrelated FeatureGroup `name:` (which previously made the +// group name "Properties" look like a required feature). function readServerFeatureConfigNames() { const src = readFileSync(FEATURES_RS, 'utf8'); const names = []; - const re = /Feature::(?:Enum|Numeric)\([^]*?name:\s*"((?:\\.|[^"\\])*)"/g; - for (const match of src.matchAll(re)) { - names.push(JSON.parse(`"${match[1]}"`)); + const re = /Feature::(?:Enum|Numeric)\(/g; + let m; + while ((m = re.exec(src))) { + const after = src.slice(m.index + m[0].length); + const nm = after.match(/\bname:\s*(?:"((?:\\.|[^"\\])*)"|concat!)/); + if (nm && nm[1] !== undefined) names.push(JSON.parse(`"${nm[1]}"`)); } return [...new Set(names)]; } +// The crime-rate features are generated by the crime_rate_features! macro and +// named " (/yr, 7y|2y)". Their description/detail translations are shared +// across both windows under a single legacy " (avg/yr)" key. This mirror +// of legacyCrimeFeatureKey() in descriptions.ts MUST use the same suffix regex, +// derived straight from the server keys so the checker and the runtime lookup +// stay in lock-step. +function deriveLegacyCrimeKeys(serverKeys) { + const legacy = new Set(); + for (const key of serverKeys) { + const match = key.match(/^(.*) \(\/yr, \d+y\)$/); + if (match) legacy.add(`${match[1]} (avg/yr)`); + } + return legacy; +} + function readNamedRecord(file, varName) { const sf = parseFile(join(I18N_DIR, file)); const init = findVarInitializer(sf, varName); @@ -355,7 +380,7 @@ function checkLocales(supportedCodes) { } } -function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys) { +function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys, legacyCrimeKeys) { const record = readNamedRecord(file, varName); const expected = supportedCodes.filter((c) => c !== 'en'); const present = Object.keys(record); @@ -397,10 +422,12 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys, required } } - // Every key here must also be a translatable feature name in en.ts > server. - // Otherwise the description is unreachable — ts() looks up server.${name}. + // Every key here must also be a translatable feature name in en.ts > server, + // or a legacy crime key that maps onto the per-window server keys (see + // deriveLegacyCrimeKeys / legacyCrimeFeatureKey). Otherwise the description is + // unreachable — ts() looks up server.${name}. for (const key of union) { - if (!serverKeys.has(key)) { + if (!serverKeys.has(key) && !legacyCrimeKeys.has(key)) { fail(`${file}: key "${key}" has no matching entry in en.ts > server`); } } @@ -478,16 +505,25 @@ function main() { checkLocaleLoaders(supportedCodes); const en = readLocale('en'); const serverKeys = new Set(Object.keys(en.server ?? {})); - const sourceFeatureKeys = readServerFeatureConfigNames(); + const legacyCrimeKeys = deriveLegacyCrimeKeys(serverKeys); + const requiredKeys = [...readServerFeatureConfigNames(), ...legacyCrimeKeys]; checkServerSourceCoverage(serverKeys); checkRecordCoverage( 'descriptions.ts', 'descriptions', supportedCodes, serverKeys, - sourceFeatureKeys + requiredKeys, + legacyCrimeKeys + ); + checkRecordCoverage( + 'details.ts', + 'details', + supportedCodes, + serverKeys, + requiredKeys, + legacyCrimeKeys ); - checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys, sourceFeatureKeys); checkForbiddenVisibleStrings(); for (const w of warnings) console.warn(`warn: ${w}`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1274f4e..5e03eb6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,7 @@ import { } from './lib/seoRoutes'; import Header, { type Page } from './components/ui/Header'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; -import { fetchWithRetry, apiUrl, logNonAbortError, readDemoChoice, setDemoCenter } from './lib/api'; +import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api'; import { trackEvent } from './lib/analytics'; import { parseUrlState } from './lib/url-state'; import pb from './lib/pocketbase'; @@ -209,34 +209,6 @@ export default function App() { }, []); const initialRoute = useMemo(() => pathToPage(window.location.pathname), []); const [mapUrlState, setMapUrlState] = useState(urlState); - // Demo location: a fresh visitor (no explicit URL view) is offered the "check - // your area" prompt (in MapPage). A choice made earlier this session is reused — - // GPS coords re-centre the map and are sent to the server (via lib/api), while - // 'declined' keeps Canary Wharf. Resolve it once, before children fetch, so the - // very first data request already carries the centre. - // Read license synchronously from the persisted session so we never apply the - // demo location for a returning licensed user (they bypass the free zone, and we - // don't want their location in request URLs). - const licensedAtMount = useMemo( - () => - pb.authStore.isValid && - (pb.authStore.record?.subscription === 'licensed' || pb.authStore.record?.is_admin === true), - [] - ); - const initialDemoChoice = useMemo(() => { - const choice = readDemoChoice(); - if (choice && choice !== 'declined' && !licensedAtMount) setDemoCenter(choice); - return choice; - }, [licensedAtMount]); - const [demoView] = useState(() => - initialDemoChoice && initialDemoChoice !== 'declined' && !licensedAtMount - ? { - ...INITIAL_VIEW_STATE, - latitude: initialDemoChoice.lat, - longitude: initialDemoChoice.lon, - } - : null - ); const [dashboardRouteKey, setDashboardRouteKey] = useState(() => window.location.pathname === '/dashboard' ? window.location.search : '' ); @@ -248,8 +220,8 @@ export default function App() { ); const activePageRef = useRef('home'); const initialViewState = useMemo( - () => (mapUrlState.hasExplicitView ? mapUrlState.viewState : (demoView ?? INITIAL_VIEW_STATE)), - [mapUrlState.hasExplicitView, mapUrlState.viewState, demoView] + () => (mapUrlState.hasExplicitView ? mapUrlState.viewState : INITIAL_VIEW_STATE), + [mapUrlState.hasExplicitView, mapUrlState.viewState] ); const isScreenshotMode = useMemo(() => { @@ -302,16 +274,6 @@ export default function App() { refreshAuth, clearError, } = useAuth(); - // Only offer the "check your area" demo prompt to non-licensed visitors on a - // fresh visit. `user` is hydrated synchronously from the persisted PocketBase - // session, so a returning premium user is recognised at mount (no flash). - const offerDemoLocation = - !urlState.hasExplicitView && initialDemoChoice == null && !hasFullAccess(user); - // Stop transmitting the demo location once the user is licensed (in-session - // upgrade, cross-tab sign-in, or a stale session that refreshed to licensed). - useEffect(() => { - if (hasFullAccess(user)) setDemoCenter(null); - }, [user]); const { startCheckout: startPostAuthCheckout } = useLicense(); const [showAuthModal, setShowAuthModal] = useState(false); const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); @@ -351,8 +313,8 @@ export default function App() { if (!completed) { setPostAuthIntent(null); postAuthCheckoutReturnPathRef.current = null; - // Only protected pages bounce home; the dashboard stays open in demo - // mode (server-enforced free zone) when the modal is dismissed. + // Only protected pages bounce home; the dashboard stays open when the + // modal is dismissed. if (isProtectedPage(activePageRef.current)) { window.history.replaceState({ page: 'home', hash: '' }, '', '/'); setRouteHash(''); @@ -611,9 +573,8 @@ export default function App() { const isProtectedPageActive = isProtectedPage(activePage); // Only protected pages (account/saved) prompt for login on entry. The - // dashboard opens straight into demo mode (server-enforced free zone) so - // visitors can try it without logging in; the upgrade modal still appears - // when they pan outside the free zone. + // dashboard opens straight away so visitors can try it without logging in; + // the upgrade modal appears when they hit the free filter cap. useEffect(() => { if (authLoading) return; if (isProtectedPageActive && !user) { @@ -768,7 +729,6 @@ export default function App() { poiCategoryGroups={poiCategoryGroups} initialFilters={mapUrlState.filters} initialViewState={initialViewState} - offerDemoLocation={offerDemoLocation} initialPOICategories={mapUrlState.poiCategories} initialOverlays={mapUrlState.overlays} initialCrimeTypes={mapUrlState.crimeTypes} diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 0c18e12..3e19e44 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -383,7 +383,9 @@ export function SavedPage({ }) .catch((err) => { if (!cancelled) { - setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links'); + setShareLinksError( + err instanceof Error ? err.message : t('accountPage.fetchShareLinksError') + ); } }) .finally(() => { @@ -393,7 +395,7 @@ export function SavedPage({ return () => { cancelled = true; }; - }, []); + }, [t]); const tabClass = (tab: string) => `px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ @@ -738,7 +740,7 @@ function InviteSection({ user }: { user: AuthUser }) { setInviteUrl((prev) => ({ ...prev, [type]: data.url })); fetchInviteHistory(); } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to create invite'; + const msg = err instanceof Error ? err.message : t('invitesPage.createInviteError'); setInviteError((prev) => ({ ...prev, [type]: msg })); } finally { setCreatingInvite((prev) => ({ ...prev, [type]: false })); @@ -931,7 +933,8 @@ export default function AccountPage({ assertOk(res, 'Update newsletter'); await onRefreshAuth(); } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to update newsletter'; + const msg = + err instanceof Error ? err.message : t('accountPage.updateNewsletterError'); setNewsletterError(msg); } finally { setNewsletterSaving(false); diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 70cc34d..d43ba0b 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -150,7 +150,7 @@ function ProductDemoVideo() { diff --git a/frontend/src/components/home/ProductShowcase.tsx b/frontend/src/components/home/ProductShowcase.tsx index b4bc291..4273c72 100644 --- a/frontend/src/components/home/ProductShowcase.tsx +++ b/frontend/src/components/home/ProductShowcase.tsx @@ -74,7 +74,7 @@ const DEMO_FEATURES: FeatureMeta[] = [ prefix: '£', }, { - name: 'Serious crime (avg/yr)', + name: 'Serious crime (/yr, 7y)', type: 'numeric', group: 'Crime', min: 0, @@ -763,7 +763,7 @@ function RightPaneOnlyScreen({ {t('home.showcasePoliticalVoteShare')} - 2024 GE + {t('home.showcaseGe2024')} setIsPlaying(false)} onEnded={() => setIsPlaying(false)} > - + {!isPlaying && (
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 64ebd40..d990940 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -1,15 +1,8 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MutableRefObject, - type ReactNode, -} from 'react'; +import { useCallback, useMemo, useState, type MutableRefObject, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ts } from '../../i18n/server'; import type { + CrimeRecordsResponse, FeatureFilters, FeatureGroup, FeatureMeta, @@ -18,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, @@ -39,12 +36,14 @@ import { } from '../../lib/consts'; import { useNearbyStations } from '../../hooks/useNearbyStations'; import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop'; +import { useRevealOnExpand } from '../../hooks/useRevealOnExpand'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import EnumBarChart from './EnumBarChart'; import StackedBarChart from './StackedBarChart'; import StackedEnumChart from './StackedEnumChart'; import PriceHistoryChart from './PriceHistoryChart'; import CrimeYearChart from './CrimeYearChart'; +import CrimeGroupBody from './CrimeGroupBody'; import NumberLine, { type NumberLinePoint } from './NumberLine'; import ExternalSearchLinks from './ExternalSearchLinks'; import { InfoIcon, TransitIcon } from '../ui/icons'; @@ -72,6 +71,7 @@ interface AreaPaneProps { shareCode?: string; isGroupExpanded: (name: string) => boolean; onToggleGroup: (name: string) => void; + onLoadCrimeRecords?: (offset: number) => Promise; scrollTopRef?: MutableRefObject; scrollRestoreKey?: string | null; scrollSaveDisabled?: boolean; @@ -215,7 +215,7 @@ function NearbyStationRow({ station }: { station: NearbyStation }) { )}
- {station.name} + {stationDisplayName(station.name)}
{ts(station.category)}
@@ -242,6 +242,7 @@ export default function AreaPane({ shareCode, isGroupExpanded, onToggleGroup, + onLoadCrimeRecords, scrollTopRef, scrollRestoreKey, scrollSaveDisabled, @@ -297,61 +298,22 @@ export default function AreaPane({ // When the user expands a group, scroll the bottom of the newly opened content // into view so the whole group is revealed without manual scrolling. The group // header stays pinned at the top via its sticky positioning. - const scrollContainerRef = useRef(null); + const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand(); const setScrollNode = useCallback( (node: HTMLDivElement | null) => { - scrollContainerRef.current = node; + setContainer(node); scrollRef(node); }, - [scrollRef] + [setContainer, scrollRef] ); - const groupRefs = useRef>(new Map()); - // A fresh object each toggle so re-expanding the same group still re-triggers. - const [groupToReveal, setGroupToReveal] = useState<{ name: string } | null>(null); const handleToggleGroup = useCallback( (name: string) => { const willExpand = !isGroupExpanded(name); onToggleGroup(name); - setGroupToReveal(willExpand ? { name } : null); + revealGroupOnToggle(name, willExpand); }, - [isGroupExpanded, onToggleGroup] + [isGroupExpanded, onToggleGroup, revealGroupOnToggle] ); - useEffect(() => { - if (!groupToReveal) return; - const el = groupRefs.current.get(groupToReveal.name); - const container = scrollContainerRef.current; - if (!el || !container) return; - - // Scroll down just enough to bring the bottom of the opened group into view. - // For groups taller than the pane this scrolls past the header, but it stays - // visible via its sticky positioning. Never scroll up (group already in view). - const alignBottom = () => { - const delta = el.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom; - if (delta <= 1) return; - container.scrollTo({ top: container.scrollTop + delta }); - }; - - alignBottom(); - - // The group's charts and async data (e.g. nearby stations) can grow its - // height after this first pass, so keep the bottom pinned as it settles. - // Stop once the user scrolls or after a short grace period so we never fight - // a deliberate scroll. - let timer = 0; - const observer = new ResizeObserver(alignBottom); - const stop = () => { - observer.disconnect(); - container.removeEventListener('wheel', stop); - container.removeEventListener('touchmove', stop); - window.clearTimeout(timer); - }; - observer.observe(el); - container.addEventListener('wheel', stop, { passive: true }); - container.addEventListener('touchmove', stop, { passive: true }); - timer = window.setTimeout(stop, 1500); - - return stop; - }, [groupToReveal]); const numericByName = useMemo(() => { if (!stats) return new Map(); @@ -363,26 +325,21 @@ export default function AreaPane({ return new Map(stats.enum_features.map((feature) => [feature.name, feature])); }, [stats]); - // Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary"). - // We also index by the configured feature name (with " (avg/yr)" suffix) so the - // metric-row renderer can look it up using the feature name it already has. - const crimeByYearByFeatureName = useMemo(() => { + // Crime-by-year series, keyed by the bare crime type (e.g. "Burglary"). + const crimeByYearByType = useMemo(() => { const map = new Map[number]>(); for (const entry of stats?.crime_by_year ?? []) { map.set(entry.name, entry); - map.set(`${entry.name} (avg/yr)`, entry); } return map; }, [stats]); - // Per-crime-type outcode/sector averages, keyed by both the bare crime type - // and the " (avg/yr)" feature name (same convention as crimeByYearByFeatureName) - // so renderers can look them up by the feature name they already hold. + // Per-rate-feature outcode/sector averages, keyed by the FULL rate-feature name + // (e.g. "Burglary (/yr, 7y)") the comparison is computed against. const crimeAreaAvgByName = useMemo(() => { const map = new Map[number]>(); for (const entry of stats?.crime_area_averages ?? []) { map.set(entry.name, entry); - map.set(`${entry.name} (avg/yr)`, entry); } return map; }, [stats]); @@ -440,7 +397,11 @@ export default function AreaPane({ <>
-
+
@@ -615,13 +576,7 @@ export default function AreaPane({ ); return ( -
{ - if (el) groupRefs.current.set(group.name, el); - else groupRefs.current.delete(group.name); - }} - > +
{showNearbyStations && } - {stackedCharts?.map((chart) => { - const segments = chart.components - .map((name) => ({ - name, - value: numericByName.get(name)?.mean ?? 0, - })) - .filter((s) => s.value > 0); + {group.name === 'Crime' ? ( + + ) : ( + <> + {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. 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 (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 - ? crimeByYearByFeatureName.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 = crimeByYearByFeatureName.get(feature.name); - const crimeAreaAvg = crimeAreaAvgByName.get(feature.name); - // National avg is shown for every metric here as a - // tooltip; for crime metrics the outcode and sector - // averages join it on their own lines, and the exact - // national mean is preferred over the histogram one. - const nationalAvg = crimeAreaAvg?.national ?? globalMean; - const valueTitle = - [ - nationalAvg != null - ? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}` - : null, - crimeAreaAvg?.outcode != null - ? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}` - : null, - crimeAreaAvg?.sector != null - ? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}` - : null, - ] - .filter(Boolean) - .join('\n') || undefined; + 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/CrimeGroupBody.tsx b/frontend/src/components/map/CrimeGroupBody.tsx new file mode 100644 index 0000000..20e8181 --- /dev/null +++ b/frontend/src/components/map/CrimeGroupBody.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ts } from '../../i18n/server'; +import type { + CrimeAreaAverage, + CrimeRecordsResponse, + CrimeYearStats, + FeatureMeta, + HexagonStatsResponse, + NumericFeatureStats, +} from '../../types'; +import { STACKED_GROUPS, STACKED_SEGMENT_COLORS } from '../../lib/consts'; +import { formatValue, calculateHistogramMean } from '../../lib/format'; +import { FeatureLabel } from '../ui/FeatureLabel'; +import StackedBarChart from './StackedBarChart'; +import NumberLine, { type NumberLinePoint } from './NumberLine'; +import CrimeYearChart from './CrimeYearChart'; +import CrimeRecordsSection from './CrimeRecordsSection'; + +type CrimeWindow = '7y' | '2y'; + +/** Strip the " (/yr, 7y|2y)" suffix to the bare crime type. */ +function bareType(featureName: string): string { + return featureName.replace(/ \(\/yr, \dy\)$/, ''); +} +/** Re-target a crime-feature name at the chosen averaging window. */ +function toWindow(featureName: string, window: CrimeWindow): string { + return featureName.replace(/(\/yr, )\dy/, `$1${window}`); +} + +/** + * Segment colours keyed by the BARE crime type. The stacked bar then labels each + * segment with the clean crime name (e.g. "Burglary") and keeps one stable colour + * regardless of whether the 7-year or 2-year window is selected. + */ +const SEGMENT_COLOR_BY_BARE: Record = Object.fromEntries( + Object.entries(STACKED_SEGMENT_COLORS).map(([name, color]) => [bareType(name), color]) +); + +interface CrimeGroupBodyProps { + stats: HexagonStatsResponse; + numericByName: Map; + /** Keyed by the FULL crime-feature name (e.g. "Burglary (/yr, 7y)"). */ + crimeAreaAvgByName: Map; + /** Keyed by the bare crime type (e.g. "Burglary"). */ + crimeByYearByType: Map; + globalFeatureByName: Map; + onShowInfo: (feature: FeatureMeta) => void; + onLoadCrimeRecords?: (offset: number) => Promise; + selectionKey: string | null; +} + +export default function CrimeGroupBody({ + stats, + numericByName, + crimeAreaAvgByName, + crimeByYearByType, + globalFeatureByName, + onShowInfo, + onLoadCrimeRecords, + selectionKey, +}: CrimeGroupBodyProps) { + const { t } = useTranslation(); + // One selector drives every value and comparison in this group. + const [crimeWindow, setCrimeWindow] = useState('7y'); + + const fmtCount = (value?: number) => (value == null ? '—' : formatValue(value)); + + const rollupCards = STACKED_GROUPS.Crime ?? []; + + return ( + <> +
+ + {t('areaPane.crimeWindowLabel')} + +
+ {(['7y', '2y'] as const).map((w) => { + const active = crimeWindow === w; + return ( + + ); + })} +
+
+ + {rollupCards.map((card) => { + if (!card.feature) return null; + const bare = bareType(card.feature); + const windowFeature = toWindow(card.feature, crimeWindow); + const rate = numericByName.get(windowFeature)?.mean; + + const segments = card.components + .map((name) => ({ + name: bareType(name), + value: numericByName.get(toWindow(name, crimeWindow))?.mean ?? 0, + })) + .filter((s) => s.value > 0); + const total = segments.reduce((sum, s) => sum + s.value, 0); + if (rate == null && total === 0) return null; + + const displayValue = rate ?? total; + // Info popup / icon stay anchored to the 7-year feature so the metric + // definition is stable across windows; only the numbers change. + const featureMeta = globalFeatureByName.get(card.feature); + const globalMean = featureMeta?.histogram + ? calculateHistogramMean(featureMeta.histogram) + : undefined; + const crimeAreaAvg = crimeAreaAvgByName.get(windowFeature); + const nationalAvg = crimeAreaAvg?.national ?? globalMean; + const cardTitle = t('areaPane.crimeCardTitle', { name: ts(card.label) }); + + 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) + : []; + + const series = crimeByYearByType.get(bare); + + return ( +
+
+ {featureMeta ? ( + + ) : ( + + {cardTitle} + + )} +
+ + {fmtCount(displayValue)} + +
+
+ + {total > 0 && ( + + )} + {numberLinePoints.length >= 2 && ( +
+ +
+ )} + {series && series.points.length > 1 && ( +
+ +
+ )} +
+ ); + })} + + {onLoadCrimeRecords && ( + + )} + + ); +} diff --git a/frontend/src/components/map/CrimeRecordsSection.tsx b/frontend/src/components/map/CrimeRecordsSection.tsx new file mode 100644 index 0000000..eed6e0f --- /dev/null +++ b/frontend/src/components/map/CrimeRecordsSection.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ts } from '../../i18n/server'; +import type { CrimeIncident, CrimeRecordsResponse } from '../../types'; +import { isAbortError, logNonAbortError } from '../../lib/api'; + +function formatMonth(month: string, locale: string): string { + const [year, m] = month.split('-').map(Number); + if (!year || !m) return month; + return new Date(Date.UTC(year, m - 1, 1)).toLocaleDateString(locale, { + year: 'numeric', + month: 'short', + timeZone: 'UTC', + }); +} + +interface CrimeRecordsSectionProps { + /** Identity of the current selection; changing it resets the list. */ + selectionKey: string | null; + /** Total records for the selection (from stats); the section hides when 0. */ + total?: number; + onLoad: (offset: number) => Promise; +} + +export default function CrimeRecordsSection({ + selectionKey, + total, + onLoad, +}: CrimeRecordsSectionProps) { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const [records, setRecords] = useState([]); + const [loadedTotal, setLoadedTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + // Bumped whenever the selection changes; in-flight responses for a previous + // selection are ignored when they resolve. + const selectionIdRef = useRef(0); + + useEffect(() => { + selectionIdRef.current += 1; + setOpen(false); + setRecords([]); + setLoadedTotal(0); + setLoading(false); + setError(false); + }, [selectionKey]); + + if (!total || total <= 0) return null; + + const fetchPage = async (offset: number) => { + const myId = selectionIdRef.current; + setLoading(true); + setError(false); + try { + const data = await onLoad(offset); + if (myId !== selectionIdRef.current) return; + setRecords((prev) => (offset === 0 ? data.records : [...prev, ...data.records])); + setLoadedTotal(data.total); + } catch (err) { + if (isAbortError(err)) return; + logNonAbortError('Failed to fetch crime records', err); + if (myId === selectionIdRef.current) setError(true); + } finally { + if (myId === selectionIdRef.current) setLoading(false); + } + }; + + const handleToggle = () => { + const next = !open; + setOpen(next); + if (next && records.length === 0 && !loading) fetchPage(0); + }; + + const shownTotal = loadedTotal || total; + const canLoadMore = records.length < loadedTotal; + + return ( +
+ + + {open && ( +
+ {error ? ( +
+ {t('areaPane.crimeRecordsError')} +
+ ) : records.length === 0 && loading ? ( +
+ + {t('areaPane.crimeRecordsLoading')} +
+ ) : records.length === 0 ? ( +
+ {t('areaPane.crimeRecordsEmpty')} +
+ ) : ( + <> +
    + {records.map((record, index) => ( +
  1. +
    + + {ts(record.type)} + + + {formatMonth(record.month, i18n.language)} + +
    +
    + {record.location ?? t('areaPane.crimeNoLocation')} +
    +
    + {record.outcome ?? t('areaPane.crimeOutcomeUnknown')} +
    +
  2. + ))} +
+ {canLoadMore && ( + + )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/map/CrimeYearChart.tsx b/frontend/src/components/map/CrimeYearChart.tsx index b518c21..dc60ffe 100644 --- a/frontend/src/components/map/CrimeYearChart.tsx +++ b/frontend/src/components/map/CrimeYearChart.tsx @@ -82,7 +82,12 @@ export default function CrimeYearChart({ points, latestAvailableYear }: CrimeYea r={1.6} className="fill-rose-700 dark:fill-rose-300" > - {`${p.year}: ${p.count.toFixed(1)}/yr`} + + {t('areaPane.crimeYearPointTooltip', { + year: p.year, + rate: p.count.toFixed(1), + })} + ))} void; - onUseDefault: () => void; -} - -/** - * First-visit demo prompt: offers to centre the map on the visitor's GPS location - * (with consent) or start at the Canary Wharf default. Rendered over the map (which - * is already showing Canary Wharf), so declining is just a dismiss. - */ -export default function DemoLocationPrompt({ - locating, - error, - onUseLocation, - onUseDefault, -}: DemoLocationPromptProps) { - const { t } = useTranslation(); - const dialogRef = useModalA11y(); - - return ( -
-
-
-

- {t('demoLocation.title')} -

-

{t('demoLocation.body')}

-
- -
- - - {error &&

{error}

} -
-
-
- ); -} diff --git a/frontend/src/components/map/ExternalSearchLinks.tsx b/frontend/src/components/map/ExternalSearchLinks.tsx index 1204f06..b6b863f 100644 --- a/frontend/src/components/map/ExternalSearchLinks.tsx +++ b/frontend/src/components/map/ExternalSearchLinks.tsx @@ -40,7 +40,7 @@ export default function ExternalSearchLinks({ const radiusMiles = location.isPostcode ? POSTCODE_RADIUS_MILES : (H3_RADIUS_MILES[location.resolution] ?? 1); - const label = `${radiusMiles}mi radius`; + const label = t('externalSearch.radiusMi', { count: radiusMiles }); if (!urls) return null; diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index a3d62b6..a6bf288 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -33,6 +33,10 @@ interface FeatureBrowserProps { onClearOpenInfoFeature?: () => void; travelTimeEntries: TravelTimeEntry[]; onAddTravelTimeEntry: (mode: TransportMode) => void; + /** Registers a group wrapper so an expanded group can be scrolled into view. */ + registerGroup?: (name: string) => (node: HTMLDivElement | null) => void; + /** Notifies the reveal-on-expand mechanism when a group is toggled. */ + onGroupToggle?: (name: string, willExpand: boolean) => void; } export default function FeatureBrowser({ @@ -46,6 +50,8 @@ export default function FeatureBrowser({ onClearOpenInfoFeature, travelTimeEntries: _travelTimeEntries, onAddTravelTimeEntry, + registerGroup, + onGroupToggle, }: FeatureBrowserProps) { const { t } = useTranslation(); const modes = useTranslatedModes(); @@ -115,11 +121,15 @@ export default function FeatureBrowser({ {mergedGrouped.map((group) => { const isExpanded = isSearching || isGroupExpanded(group.name); return ( -
+
toggleGroup(group.name)} + onToggle={() => { + const willExpand = !isExpanded; + toggleGroup(group.name); + onGroupToggle?.(group.name, willExpand); + }} 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" > diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 32ee222..28e8f82 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -21,6 +21,16 @@ import { isSpecificCrimeFeatureName, isSpecificCrimeFilterName, } from '../../lib/crime-filter'; +import { + CRIME_SEVERITY_FILTER_NAMES, + getCrimeSeverityFeatureName, + getCrimeSeverityFilterMeta, + getCrimeSeverityFilterName, + getDefaultCrimeSeverityFeatureName, + isCrimeSeverityFeatureName, + isCrimeSeverityFilterName, + type CrimeSeverityFilterName, +} from '../../lib/crime-severity-filter'; import { ELECTION_VOTE_SHARE_FILTER_NAME, getDefaultElectionVoteShareFeatureName, @@ -37,7 +47,22 @@ import { isEthnicityFeatureName, isEthnicityFilterName, } from '../../lib/ethnicity-filter'; -import { isQualificationFeatureName } from '../../lib/qualification-filter'; +import { + QUALIFICATIONS_FILTER_NAME, + getDefaultQualificationFeatureName, + getQualificationFeatureName, + getQualificationFilterMeta, + isQualificationFeatureName, + isQualificationFilterName, +} from '../../lib/qualification-filter'; +import { + TENURE_FILTER_NAME, + getDefaultTenureFeatureName, + getTenureFeatureName, + getTenureFilterMeta, + isTenureFeatureName, + isTenureFilterName, +} from '../../lib/tenure-filter'; import { SCHOOL_FILTER_NAME, getDefaultSchoolFeatureName, @@ -182,6 +207,33 @@ export default memo(function Filters({ [features] ); const ethnicityMeta = useMemo(() => getEthnicityFilterMeta(features), [features]); + const defaultQualificationFeatureName = useMemo( + () => getDefaultQualificationFeatureName(features), + [features] + ); + const qualificationMeta = useMemo(() => getQualificationFilterMeta(features), [features]); + const defaultTenureFeatureName = useMemo(() => getDefaultTenureFeatureName(features), [features]); + const tenureMeta = useMemo(() => getTenureFilterMeta(features), [features]); + const crimeSeverityMetas = useMemo( + () => + Object.fromEntries( + CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [ + filterName, + getCrimeSeverityFilterMeta(features, filterName), + ]) + ) as Record, + [features] + ); + const defaultCrimeSeverityFeatureNames = useMemo( + () => + Object.fromEntries( + CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [ + filterName, + getDefaultCrimeSeverityFeatureName(features, filterName), + ]) + ) as Record, + [features] + ); const defaultPoiDistanceFeatureName = useMemo( () => getDefaultPoiDistanceFeatureName(features), [features] @@ -256,6 +308,18 @@ export default memo(function Filters({ return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' }; }); }, [filters, features, specificCrimeMeta]); + const crimeSeverityFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isCrimeSeverityFilterName) + .map((name) => { + const backendName = getCrimeSeverityFeatureName(name); + const filterName = getCrimeSeverityFilterName(name) ?? CRIME_SEVERITY_FILTER_NAMES[0]; + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? crimeSeverityMetas[filterName]), name, group: 'Crime' }; + }); + }, [filters, features, crimeSeverityMetas]); const electionVoteShareFilterItems = useMemo(() => { return Object.keys(filters) .filter(isElectionVoteShareFilterName) @@ -278,6 +342,28 @@ export default memo(function Filters({ return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' }; }); }, [filters, features, ethnicityMeta]); + const qualificationFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isQualificationFilterName) + .map((name) => { + const backendName = getQualificationFeatureName(name); + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? qualificationMeta), name, group: 'Neighbours' }; + }); + }, [filters, features, qualificationMeta]); + const tenureFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isTenureFilterName) + .map((name) => { + const backendName = getTenureFeatureName(name); + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? tenureMeta), name, group: 'Neighbours' }; + }); + }, [filters, features, tenureMeta]); const poiDistanceFilterItems = useMemo(() => { return Object.keys(filters) .filter(isPoiDistanceFilterName) @@ -300,6 +386,8 @@ export default memo(function Filters({ let insertedSpecificCrimeFilter = false; let insertedElectionVoteShareFilter = false; let insertedEthnicityFilter = false; + let insertedQualificationFilter = false; + let insertedTenureFilter = false; const insertedPoiFilters = new Set(); const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => { if ( @@ -311,6 +399,17 @@ export default memo(function Filters({ insertedPoiFilters.add(filterName); } }; + const insertedCrimeSeverityFilters = new Set(); + const maybeInsertCrimeSeverityFilter = (filterName: CrimeSeverityFilterName | null) => { + if ( + filterName && + defaultCrimeSeverityFeatureNames[filterName] && + !insertedCrimeSeverityFilters.has(filterName) + ) { + result.push(crimeSeverityMetas[filterName]); + insertedCrimeSeverityFilters.add(filterName); + } + }; for (const feature of features) { if (feature.group === 'Transport') { @@ -330,6 +429,12 @@ export default memo(function Filters({ } continue; } + // "Serious crime" and "Minor crime" each fold their 7y/2y windows into one + // card with a period toggle (no variant dropdown — a single feature each). + if (isCrimeSeverityFeatureName(feature.name)) { + maybeInsertCrimeSeverityFilter(getCrimeSeverityFilterName(feature.name)); + continue; + } if (isElectionVoteShareFeatureName(feature.name)) { if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) { result.push(electionVoteShareMeta); @@ -344,14 +449,28 @@ export default memo(function Filters({ } continue; } + // The seven qualification bands fold into one "Qualifications" filter + // whose dropdown picks a band, rather than seven separate sliders. + if (isQualificationFeatureName(feature.name)) { + if (defaultQualificationFeatureName && !insertedQualificationFilter) { + result.push(qualificationMeta); + insertedQualificationFilter = true; + } + continue; + } + // The three tenure categories fold into one "Tenure" filter + // whose dropdown picks a category, rather than three separate sliders. + if (isTenureFeatureName(feature.name)) { + if (defaultTenureFeatureName && !insertedTenureFilter) { + result.push(tenureMeta); + insertedTenureFilter = true; + } + continue; + } if (isPoiFilterFeatureName(feature.name)) { maybeInsertPoiFilter(getPoiFilterName(feature.name)); continue; } - // Qualification breakdown is display-only (shown as the stacked - // "Qualifications" composition in the area pane), so keep its seven - // component features out of the filter browser. - if (isQualificationFeatureName(feature.name)) continue; if (!enabledFeatures.has(feature.name)) result.push(feature); } @@ -363,10 +482,16 @@ export default memo(function Filters({ schoolMeta, defaultSpecificCrimeFeatureName, specificCrimeMeta, + defaultCrimeSeverityFeatureNames, + crimeSeverityMetas, defaultElectionVoteShareFeatureName, electionVoteShareMeta, defaultEthnicityFeatureName, ethnicityMeta, + defaultQualificationFeatureName, + qualificationMeta, + defaultTenureFeatureName, + tenureMeta, defaultPoiFilterFeatureNames, poiFilterMetas, ]); @@ -376,6 +501,8 @@ export default memo(function Filters({ let insertedSpecificCrimeFilters = false; let insertedElectionVoteShareFilters = false; let insertedEthnicityFilters = false; + let insertedQualificationFilters = false; + let insertedTenureFilters = false; const insertedPoiFilters = new Set(); const insertPoiFilterItems = (filterName: PoiFilterName | null) => { if (!filterName || insertedPoiFilters.has(filterName)) return; @@ -384,6 +511,16 @@ export default memo(function Filters({ ); insertedPoiFilters.add(filterName); }; + const insertedCrimeSeverityFilters = new Set(); + const insertCrimeSeverityFilterItems = (filterName: CrimeSeverityFilterName | null) => { + if (!filterName || insertedCrimeSeverityFilters.has(filterName)) return; + result.push( + ...crimeSeverityFilterItems.filter( + (item) => getCrimeSeverityFilterName(item.name) === filterName + ) + ); + insertedCrimeSeverityFilters.add(filterName); + }; for (const feature of features) { if (feature.group === 'Transport') { @@ -403,6 +540,10 @@ export default memo(function Filters({ } continue; } + if (isCrimeSeverityFeatureName(feature.name)) { + insertCrimeSeverityFilterItems(getCrimeSeverityFilterName(feature.name)); + continue; + } if (isElectionVoteShareFeatureName(feature.name)) { if (!insertedElectionVoteShareFilters) { result.push(...electionVoteShareFilterItems); @@ -417,6 +558,20 @@ export default memo(function Filters({ } continue; } + if (isQualificationFeatureName(feature.name)) { + if (!insertedQualificationFilters) { + result.push(...qualificationFilterItems); + insertedQualificationFilters = true; + } + continue; + } + if (isTenureFeatureName(feature.name)) { + if (!insertedTenureFilters) { + result.push(...tenureFilterItems); + insertedTenureFilters = true; + } + continue; + } if (isPoiFilterFeatureName(feature.name)) { insertPoiFilterItems(getPoiFilterName(feature.name)); continue; @@ -430,8 +585,11 @@ export default memo(function Filters({ enabledFeatures, schoolFilterItems, specificCrimeFilterItems, + crimeSeverityFilterItems, electionVoteShareFilterItems, ethnicityFilterItems, + qualificationFilterItems, + tenureFilterItems, poiDistanceFilterItems, ]); @@ -460,10 +618,15 @@ export default memo(function Filters({ (name: string): string | null => { if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools'; if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime'; + if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) { + return crimeSeverityMetas[name as CrimeSeverityFilterName].group ?? 'Crime'; + } if (name === ELECTION_VOTE_SHARE_FILTER_NAME) { return electionVoteShareMeta.group ?? 'Neighbours'; } if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours'; + if (name === QUALIFICATIONS_FILTER_NAME) return qualificationMeta.group ?? 'Neighbours'; + if (name === TENURE_FILTER_NAME) return tenureMeta.group ?? 'Neighbours'; if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { return poiFilterMetas[name as PoiFilterName].group ?? null; } @@ -472,8 +635,11 @@ export default memo(function Filters({ [ electionVoteShareMeta.group, ethnicityMeta.group, + qualificationMeta.group, + tenureMeta.group, features, poiFilterMetas, + crimeSeverityMetas, schoolMeta.group, specificCrimeMeta.group, ] @@ -514,6 +680,28 @@ export default memo(function Filters({ onAddFilter(ETHNICITIES_FILTER_NAME); return; } + if (name === QUALIFICATIONS_FILTER_NAME) { + if (!defaultQualificationFeatureName) return; + queueActiveFilterScroll( + QUALIFICATIONS_FILTER_NAME, + getAddFilterGroupName(QUALIFICATIONS_FILTER_NAME) + ); + onAddFilter(QUALIFICATIONS_FILTER_NAME); + return; + } + if (name === TENURE_FILTER_NAME) { + if (!defaultTenureFeatureName) return; + queueActiveFilterScroll(TENURE_FILTER_NAME, getAddFilterGroupName(TENURE_FILTER_NAME)); + onAddFilter(TENURE_FILTER_NAME); + return; + } + if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) { + const severityFilterName = name as CrimeSeverityFilterName; + if (!defaultCrimeSeverityFeatureNames[severityFilterName]) return; + queueActiveFilterScroll(severityFilterName, getAddFilterGroupName(severityFilterName)); + onAddFilter(severityFilterName); + return; + } if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { const filterName = name as PoiFilterName; if (!defaultPoiFilterFeatureNames[filterName]) return; @@ -528,8 +716,11 @@ export default memo(function Filters({ [ defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, + defaultCrimeSeverityFeatureNames, defaultElectionVoteShareFeatureName, defaultEthnicityFeatureName, + defaultQualificationFeatureName, + defaultTenureFeatureName, defaultPoiFilterFeatureNames, getAddFilterGroupName, onAddFilter, @@ -686,8 +877,11 @@ export default memo(function Filters({ ...features, schoolMeta, specificCrimeMeta, + ...Object.values(crimeSeverityMetas), electionVoteShareMeta, ethnicityMeta, + qualificationMeta, + tenureMeta, poiDistanceMeta, transportDistanceMeta, poiCount2KmMeta, @@ -696,8 +890,11 @@ export default memo(function Filters({ pinnedFeature={pinnedFeature} defaultSchoolFeatureName={defaultSchoolFeatureName} defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName} + defaultCrimeSeverityFeatureNames={defaultCrimeSeverityFeatureNames} defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName} defaultEthnicityFeatureName={defaultEthnicityFeatureName} + defaultQualificationFeatureName={defaultQualificationFeatureName} + defaultTenureFeatureName={defaultTenureFeatureName} defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames} openInfoFeature={openInfoFeature} travelTimeEntries={travelTimeEntries} diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index be04ef6..d738f37 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -5,8 +5,11 @@ import { formatValue } from '../../lib/format'; import { ts } from '../../i18n/server'; import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter'; import { getSpecificCrimeFeatureName } from '../../lib/crime-filter'; +import { getCrimeSeverityFeatureName } from '../../lib/crime-severity-filter'; import { getElectionVoteShareFeatureName } from '../../lib/election-filter'; import { getEthnicityFeatureName } from '../../lib/ethnicity-filter'; +import { getQualificationFeatureName } from '../../lib/qualification-filter'; +import { getTenureFeatureName } from '../../lib/tenure-filter'; import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName, @@ -52,14 +55,20 @@ export default memo(function HoverCard({ for (const name of activeFilterNames.slice(0, 4)) { const schoolBackendName = getSchoolBackendFeatureName(name); const specificCrimeFeatureName = getSpecificCrimeFeatureName(name); + const crimeSeverityFeatureName = getCrimeSeverityFeatureName(name); const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name); const ethnicityFeatureName = getEthnicityFeatureName(name); + const qualificationFeatureName = getQualificationFeatureName(name); + const tenureFeatureName = getTenureFeatureName(name); const poiDistanceFeatureName = getPoiDistanceFeatureName(name); const backendName = schoolBackendName ?? specificCrimeFeatureName ?? + crimeSeverityFeatureName ?? electionVoteShareFeatureName ?? ethnicityFeatureName ?? + qualificationFeatureName ?? + tenureFeatureName ?? poiDistanceFeatureName ?? name; const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`]; diff --git a/frontend/src/components/map/JourneyInstructions.test.tsx b/frontend/src/components/map/JourneyInstructions.test.tsx index a8cfcd8..40dfd9f 100644 --- a/frontend/src/components/map/JourneyInstructions.test.tsx +++ b/frontend/src/components/map/JourneyInstructions.test.tsx @@ -16,6 +16,8 @@ vi.mock('react-i18next', () => ({ if (key === 'travel.noBuses') return 'No buses'; if (key === 'areaPane.walk') return 'Walk'; if (key === 'areaPane.cycle') return 'Cycle'; + if (key === 'journey.bus') return 'Bus'; + if (key === 'journey.lineSuffix') return 'line'; if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps'; if (key === 'areaPane.noJourneyData') return 'No journey data'; return key; diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx index c4667e0..fb32486 100644 --- a/frontend/src/components/map/JourneyInstructions.tsx +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import type { JourneyLeg } from '../../types'; import { resolveTransitVariant, type TravelTimeEntry } from '../../hooks/useTravelTime'; import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api'; @@ -106,15 +107,25 @@ function stripId(label: string): string { return label.replace(/\s+\([A-Za-z0-9]+\)$/, ''); } -function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } { +function getRouteDisplay( + mode: string, + t: TFunction +): { label: string; color: string; darkText: boolean } { const clean = stripId(mode); const known = ROUTE_COLORS[clean]; if (known) { - const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`; + const label = + NON_TUBE_NAMES.has(clean) || clean.includes('line') + ? clean + : `${clean} ${t('journey.lineSuffix')}`; return { label, color: known.color, darkText: !!known.darkText }; } if (/^\d+[A-Za-z]?$/.test(clean.trim())) { - return { label: `Bus ${clean}`, color: '#0d9488', darkText: false }; + return { + label: `${t('journey.bus')} ${clean}`, + color: '#0d9488', + darkText: false, + }; } return { label: clean, color: '#6b7280', darkText: false }; } @@ -203,7 +214,8 @@ function invertLegs(legs: JourneyLeg[]): JourneyLeg[] { } function RouteBadge({ mode }: { mode: string }) { - const { label, color, darkText } = getRouteDisplay(mode); + const { t } = useTranslation(); + const { label, color, darkText } = getRouteDisplay(mode, t); return (
diff --git a/frontend/src/components/map/ListingPopups.tsx b/frontend/src/components/map/ListingPopups.tsx index f04e99b..dda406b 100644 --- a/frontend/src/components/map/ListingPopups.tsx +++ b/frontend/src/components/map/ListingPopups.tsx @@ -19,15 +19,21 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n export const ListingPopupSingleContent = memo(function ListingPopupSingleContent({ listing, + clickedUrls, + onOpen, }: { listing: ActualListing; + clickedUrls: Set; + onOpen: (url: string) => void; }) { const { t } = useTranslation(); + const visited = clickedUrls.has(listing.listing_url); return ( onOpen(listing.listing_url)} className="block px-3 py-2" > {listing.asking_price != null && ( @@ -57,7 +63,7 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent )} {listing.floor_area_sqm != null && (
- {Math.round(listing.floor_area_sqm)} sqm + {Math.round(listing.floor_area_sqm)} {t('common.sqm')} {listing.asking_price_per_sqm != null ? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm` : ''} @@ -72,8 +78,11 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent ))} )} -
- Open listing ↗ +
+ {visited && ( + ✓ {t('listing.viewed')} + )} + {t('listing.openListing')} ↗
); @@ -82,9 +91,13 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent export const ListingClusterPopupContent = memo(function ListingClusterPopupContent({ count, listings, + clickedUrls, + onOpen, }: { count: number; listings: ActualListing[]; + clickedUrls: Set; + onOpen: (url: string) => void; }) { const { t } = useTranslation(); const visibleCount = listings.length; @@ -92,32 +105,41 @@ export const ListingClusterPopupContent = memo(function ListingClusterPopupConte
- {count.toLocaleString()} listings + {count.toLocaleString()} {t('listing.listings')}
{visibleCount > 0 - ? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}` - : 'Grouped near this map position'} + ? t('listing.showingOf', { visible: visibleCount, count }) + : t('listing.groupedNear')}
{visibleCount > 0 && (
{listings.map((listing, idx) => { const headline = formatListingHeadline(listing, t); + const visited = clickedUrls.has(listing.listing_url); return ( onOpen(listing.listing_url)} className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60" >
-
- {listing.asking_price != null - ? formatListingPrice(listing.asking_price) - : 'Listing'} +
+ + {listing.asking_price != null + ? formatListingPrice(listing.asking_price) + : t('listing.listing')} + + {visited && ( + + ✓ Viewed + + )}
{headline && (
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index c31a649..caee2d9 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -452,6 +452,8 @@ export default memo(function Map({ visiblePois, listingPopup, clearListingPopup, + clickedListingUrls, + markListingClicked, developmentPopup, clearDevelopmentPopup, hoverPosition, @@ -696,11 +698,17 @@ export default memo(function Map({ {listingPopup.mode === 'single' ? ( - + ) : ( )}
diff --git a/frontend/src/components/map/MapErrorBoundary.tsx b/frontend/src/components/map/MapErrorBoundary.tsx index 4b19af0..fbade47 100644 --- a/frontend/src/components/map/MapErrorBoundary.tsx +++ b/frontend/src/components/map/MapErrorBoundary.tsx @@ -1,6 +1,7 @@ import { Component, Fragment, type ReactNode } from 'react'; import * as Sentry from '@sentry/react'; +import i18n from '../../i18n'; import { MapFallback } from './map-page/Fallbacks'; /** @@ -113,17 +114,20 @@ export class MapErrorBoundary extends Component

- The map ran into a problem + {i18n.t('map.error.heading', { defaultValue: 'The map ran into a problem' })}

- This can happen when your browser's graphics context is interrupted. + {i18n.t('map.error.body', { + defaultValue: + 'This can happen when your browser’s graphics context is interrupted.', + })}

diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index b951e36..27bf3cb 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -21,21 +21,14 @@ import { resolveTransitVariant, travelFieldKey, useTravelTime, + type TransportMode, } from '../../hooks/useTravelTime'; -import { - apiUrl, - authHeaders, - buildFilterString, - persistDemoChoice, - readDemoChoice, - setDemoCenter, -} from '../../lib/api'; -import DemoLocationPrompt from './DemoLocationPrompt'; +import { apiUrl, authHeaders, buildFilterString } from '../../lib/api'; import { useFilterCounts } from '../../hooks/useFilterCounts'; import { trackEvent } from '../../lib/analytics'; import { DEMO_MAX_FILTERS, - DEV_LOCATION, + REGISTERED_MAX_FILTERS, INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD, } from '../../lib/consts'; @@ -85,8 +78,6 @@ import type { MapFlyTo, MapPageProps } from './map-page/types'; export type { ExportState } from './map-page/types'; -declare const __DEV__: boolean; - const EMPTY_ACTUAL_LISTINGS: ActualListing[] = []; const EMPTY_POIS: POI[] = []; @@ -95,7 +86,6 @@ export default function MapPage({ poiCategoryGroups, initialFilters, initialViewState, - offerDemoLocation, initialPOICategories, initialOverlays, initialCrimeTypes, @@ -155,11 +145,50 @@ export default function MapPage({ initialPostcode ?? null ); - // Demo (unlicensed) users are limited to DEMO_MAX_FILTERS filters; hitting the - // cap opens the upgrade modal. Licensed users and admins are unlimited. + // Filter allowance scales with the account tier: anonymous → DEMO_MAX_FILTERS, + // registered free → REGISTERED_MAX_FILTERS, licensed/admin → unlimited. Hitting the + // cap opens the upgrade modal. + const isLoggedIn = !!user; const filtersUnlimited = user?.subscription === 'licensed' || user?.isAdmin === true; + const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS; + const filterLimit = filtersUnlimited ? null : effectiveFilterCap; + // On a shared link, non-paying users may view and adjust the shared filters but + // not add new ones — so a richer shared search can't become a way around the cap. + const lockFilterAdds = !filtersUnlimited && !!shareCode; + // Which upsell the modal frames: a shared-link block, or a plain filter-cap hit. + const upgradeReason: 'filters' | 'shared' = lockFilterAdds ? 'shared' : 'filters'; const [filterLimitHit, setFilterLimitHit] = useState(false); - const handleFilterLimitReached = useCallback(() => setFilterLimitHit(true), []); + const handleFilterLimitReached = useCallback(() => { + trackEvent('Upgrade Modal Shown'); + setFilterLimitHit(true); + }, []); + + // Travel-time destinations count toward the same cap as feature filters (each one + // restricts which properties match). Cap an over-budget initial travel set (from a + // bookmarked/shared URL), preferring feature filters, so the first data request + // never exceeds the server cap. + const cappedInitialTravelTime = useMemo(() => { + const initialEntries = initialTravelTime?.entries ?? []; + if (filtersUnlimited || initialEntries.length === 0) return initialTravelTime; + const travelBudget = Math.max(0, effectiveFilterCap - Object.keys(initialFilters).length); + return initialEntries.length > travelBudget + ? { ...initialTravelTime, entries: initialEntries.slice(0, travelBudget) } + : initialTravelTime; + }, [filtersUnlimited, effectiveFilterCap, initialFilters, initialTravelTime]); + + const { + entries, + activeEntries, + handleAddEntry, + handleRemoveEntry, + handleSetDestination, + handleSetEntries, + handleTimeRangeChange, + handleToggleBest, + handleToggleNoChange, + handleToggleOneChange, + handleToggleNoBuses, + } = useTravelTime(cappedInitialTravelTime); const { filters, @@ -183,7 +212,10 @@ export default function MapPage({ } = useFilters({ initialFilters, features, - filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS, + filterLimit, + // Travel-time entries reserve slots from the same cap (see cappedInitialTravelTime). + reservedFilterSlots: filtersUnlimited ? 0 : entries.length, + lockAdds: lockFilterAdds, onFilterLimitReached: handleFilterLimitReached, }); @@ -196,20 +228,6 @@ export default function MapPage({ summary: aiFilterSummary, } = useAiFilters(); - const { - entries, - activeEntries, - handleAddEntry, - handleRemoveEntry, - handleSetDestination, - handleSetEntries, - handleTimeRangeChange, - handleToggleBest, - handleToggleNoChange, - handleToggleOneChange, - handleToggleNoBuses, - } = useTravelTime(initialTravelTime); - const mapFlyToRef = useRef(null); const areaPaneScrollTopRef = useRef(0); const propertiesPaneScrollTopRef = useRef(0); @@ -262,13 +280,22 @@ export default function MapPage({ const result = await fetchAiFilters(query, hasContext ? context : undefined); if (!result) return; - handleSetFilters(result.filters); + // Non-paying users share one cap across feature filters and travel-time + // filters, so clamp the AI's combined result (features first, then travel). + const cappedFilters = filtersUnlimited + ? result.filters + : Object.fromEntries(Object.entries(result.filters).slice(0, effectiveFilterCap)); + handleSetFilters(cappedFilters); // Filter out variants the UI cannot represent (e.g. transit-one-change*) // FIRST so the same filtered list drives both entry state and fly-to. // Otherwise we'd fly to a destination for a mode the user can't see. - const representable = result.travelTimeFilters + const representableAll = result.travelTimeFilters .map((tt) => ({ tt, parsed: parseServerMode(tt.mode) })) .filter((x): x is { tt: typeof x.tt; parsed: NonNullable } => !!x.parsed); + const travelBudget = filtersUnlimited + ? representableAll.length + : Math.max(0, effectiveFilterCap - Object.keys(cappedFilters).length); + const representable = representableAll.slice(0, travelBudget); handleSetEntries( representable.map(({ tt, parsed }) => ({ @@ -323,12 +350,26 @@ export default function MapPage({ activeEntries, fetchAiFilters, filters, + filtersUnlimited, getMobileMapFlyToOptions, handleSetEntries, handleSetFilters, ] ); + // Travel-time entries share the non-paying user's filter cap, so block adding one + // when the combined feature + travel count is already at the limit. + const handleAddTravelTimeEntry = useCallback( + (mode: TransportMode) => { + if (!filtersUnlimited && Object.keys(filters).length + entries.length >= DEMO_MAX_FILTERS) { + handleFilterLimitReached(); + return; + } + handleAddEntry(mode); + }, + [filtersUnlimited, filters, entries, handleAddEntry, handleFilterLimitReached] + ); + const handleClearAll = useCallback(() => { handleSetFilters({}); handleCancelPin(); @@ -407,6 +448,7 @@ export default function MapPage({ handlePropertiesTabClick, handleLoadMoreProperties, handleCloseSelection, + loadCrimeRecords, selectedPostcodeGeometry, handleLocationSearch, handleCurrentLocationSearch, @@ -476,93 +518,6 @@ export default function MapPage({ [handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo] ); - const shareReturnViewRef = useRef(shareCode ? initialViewState : null); - // Hide the upgrade modal as soon as the user dismisses it. We can't rely on - // the camera fly alone to close it: flying back to the free/shared zone only - // clears `licenseRequired` once the resulting refetch returns non-403, and - // when the fly target equals the current view (e.g. "back to shared area" - // while already at the shared view) `jumpTo` is a no-op, so no refetch fires - // and the modal would otherwise stay stuck open. - const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false); - const handleZoomToFreeZone = useCallback(() => { - setUpgradeModalDismissed(true); - setFilterLimitHit(false); - // Fly back to the visitor's own free zone. Prefer the server-reported zone - // (reflects their chosen demo centre — including a GPS choice made this - // session), then a share-return view, then the initial centre. - const fz = mapData.freeZone; - const target = - shareReturnViewRef.current ?? - (fz - ? { - latitude: (fz.south + fz.north) / 2, - longitude: (fz.west + fz.east) / 2, - zoom: initialViewState.zoom, - } - : initialViewState); - mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom); - }, [initialViewState, mapData.freeZone]); - - // "Check your area" demo prompt: GPS recenters the map AND moves the free zone - // (the chosen centre is sent to the server via lib/api on each request); - // declining keeps Canary Wharf. Persisted per session so we don't re-ask. - const [demoPromptOpen, setDemoPromptOpen] = useState( - // Also check the persisted choice directly: MapPage remounts on navigation - // (route key change) while App's `offerDemoLocation` is frozen at mount, so a - // choice made earlier this session must still suppress a re-prompt. - () => !!offerDemoLocation && !screenshotMode && !ogMode && readDemoChoice() == null - ); - const [demoLocating, setDemoLocating] = useState(false); - const [demoLocationError, setDemoLocationError] = useState(null); - - const handleUseMyLocation = useCallback(() => { - const applyCenter = (center: { lat: number; lon: number }) => { - setDemoCenter(center); - persistDemoChoice(center); - setDemoLocating(false); - setDemoPromptOpen(false); - // handleFlyTo uses map.jumpTo (instant) — no flashing 403s through - // intermediate viewports; the move triggers a refetch carrying demoLat/Lon. - mapFlyToRef.current?.( - center.lat, - center.lon, - INITIAL_VIEW_STATE.zoom, - getMobileMapFlyToOptions() - ); - }; - if (typeof navigator === 'undefined' || !navigator.geolocation) { - setDemoLocationError(t('locationSearch.geolocationUnsupported')); - return; - } - setDemoLocating(true); - setDemoLocationError(null); - navigator.geolocation.getCurrentPosition( - (position) => applyCenter({ lat: position.coords.latitude, lon: position.coords.longitude }), - () => { - // In dev, geolocation is usually blocked (e.g. served over http), so fall - // back to 10 Downing Street to keep the "data follows you" flow testable. - if (__DEV__) { - applyCenter({ lat: DEV_LOCATION.latitude, lon: DEV_LOCATION.longitude }); - return; - } - setDemoLocating(false); - setDemoLocationError(t('locationSearch.geolocationFailed')); - }, - { enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 } - ); - }, [t, getMobileMapFlyToOptions]); - - const handleUseDefaultLocation = useCallback(() => { - persistDemoChoice('declined'); - setDemoPromptOpen(false); - }, []); - - // Never show the demo prompt to licensed/admin users — e.g. if the session - // hydrated as free then refreshed to licensed, or they signed in in another tab. - useEffect(() => { - if (filtersUnlimited) setDemoPromptOpen(false); - }, [filtersUnlimited]); - const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories); // Disabling POIs (clearing every category) must remove all POI cards/markers // immediately, not on the next fetch tick — gate on the selection itself so a @@ -630,7 +585,6 @@ export default function MapPage({ dataLength: mapData.data.length, postcodeDataLength: mapData.postcodeData.length, usePostcodeView: mapData.usePostcodeView, - licenseRequired: mapData.licenseRequired, }); const handleMobileHexagonClick = useCallback( @@ -649,7 +603,7 @@ export default function MapPage({ mapData.resolution, areaStats ); - const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired); + const tutorial = useTutorial(initialLoading, isMobile, deferTutorial); const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]); const densityLabel = t('mapLegend.historicalMatches'); const mobileLegendMeta = useMobileLegendMeta(viewFeature, features); @@ -724,11 +678,7 @@ export default function MapPage({ }, [dashboardParams, onDashboardParamsChange]); const dashboardReady = - !initialLoading && - !mapData.loading && - !mapData.licenseRequired && - mapData.bounds != null && - mapData.currentView != null; + !initialLoading && !mapData.loading && mapData.bounds != null && mapData.currentView != null; useEffect(() => { onDashboardReadyChange?.(dashboardReady); @@ -740,16 +690,6 @@ export default function MapPage({ }; }, [onDashboardReadyChange]); - useEffect(() => { - if (mapData.licenseRequired) { - trackEvent('Upgrade Modal Shown'); - } else { - // Back in a viewable area — re-arm so the modal shows again the next time - // the user pans into a gated area. - setUpgradeModalDismissed(false); - } - }, [mapData.licenseRequired]); - // Clear the filter-cap upsell once it's no longer relevant: the user became // licensed/admin, or dropped back under the cap by removing filters. Prevents a // stale flag from resurfacing the modal spuriously. @@ -806,6 +746,7 @@ export default function MapPage({ shareCode={shareCode} isGroupExpanded={isAreaGroupExpanded} onToggleGroup={toggleAreaGroup} + onLoadCrimeRecords={loadCrimeRecords} scrollTopRef={areaPaneScrollTopRef} scrollRestoreKey={ selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null @@ -823,6 +764,7 @@ export default function MapPage({ hexagonLocation, isAreaGroupExpanded, loadingAreaStats, + loadCrimeRecords, selectedHexagon, setAreaStatsUseFilters, shareCode, @@ -906,7 +848,7 @@ export default function MapPage({ openInfoFeature={pendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature} travelTimeEntries={entries} - onTravelTimeAddEntry={handleAddEntry} + onTravelTimeAddEntry={handleAddTravelTimeEntry} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeRangeChange={handleTimeRangeChange} @@ -953,7 +895,7 @@ export default function MapPage({ features, filterCounts.impacts, filters, - handleAddEntry, + handleAddTravelTimeEntry, handleAddFilter, handleAiFilterSubmit, handleClearAll, @@ -1079,51 +1021,25 @@ export default function MapPage({
) : null; - // The upgrade modal serves two cases: panning outside the free zone - // (licenseRequired) and hitting the demo filter cap (filterLimitHit). The zone - // case takes precedence; for the filter case, dismissing just closes the modal - // (no camera move). - const showZoneUpgradeModal = mapData.licenseRequired && !upgradeModalDismissed; - const showFilterUpgradeModal = !showZoneUpgradeModal && filterLimitHit && !filtersUnlimited; - const upgradeModal = - showZoneUpgradeModal || showFilterUpgradeModal ? ( - - - onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick() - } - onRegisterClick={() => - onCheckoutRegisterClick - ? onCheckoutRegisterClick(checkoutReturnPath) - : onRegisterClick() - } - onStartCheckout={() => license.startCheckout(checkoutReturnPath)} - onZoomToFreeZone={ - showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone - } - isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current} - /> - - ) : null; - - const demoLocationPrompt = demoPromptOpen ? ( - + // The upgrade modal is shown when a non-paying user hits the filter cap. + // Dismissing it just closes the modal (the filters they have stay applied). + const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited; + const upgradeModal = showFilterUpgradeModal ? ( + + + onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick() + } + onRegisterClick={() => + onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick() + } + onStartCheckout={() => license.startCheckout(checkoutReturnPath)} + onClose={() => setFilterLimitHit(false)} + /> + ) : null; - // Both overlays render where the page components place {upgradeModal}. - const overlayModals = ( - <> - {upgradeModal} - {demoLocationPrompt} - - ); - if (isMobile) { return ( ); @@ -1235,7 +1151,7 @@ export default function MapPage({ renderAreaPane={renderAreaPane} renderPropertiesPane={renderPropertiesPane} toasts={toasts} - upgradeModal={overlayModals} + upgradeModal={upgradeModal} /> ); } diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx index 39bdf6c..18cd292 100644 --- a/frontend/src/components/map/MobileDrawer.tsx +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -105,7 +105,11 @@ export default function MobileDrawer({ return (