diff --git a/Dockerfile b/Dockerfile index 7441776..bd543c8 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", "--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"] +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"] diff --git a/Makefile.data b/Makefile.data index d576639..dbcf1c4 100644 --- a/Makefile.data +++ b/Makefile.data @@ -27,10 +27,7 @@ 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 @@ -41,8 +38,6 @@ 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 @@ -96,11 +91,8 @@ 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 \ @@ -124,11 +116,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-area-crime-averages \ + transform-pois transform-epc-pp transform-crime transform-poi-proximity \ 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) $(CRIME_RECORDS) $(AREA_CRIME_AVERAGES) | $(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) | $(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)" @@ -186,7 +178,6 @@ 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) @@ -392,9 +383,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_RECORDS) &: $(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_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) --output-records $(CRIME_RECORDS) + uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) $(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 $@ @@ -459,42 +450,16 @@ $(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)" -# 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 $@ +$(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 $@ $(VALIDATE_OUTPUTS) --parquet $@ -# 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) +$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) @rm -f $@ - uv run python -m pipeline.transform.join_price_estimates --properties $(PROPERTIES_PQ) --estimates $(PRICE_ESTIMATES) + uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX) $(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 45825c3..81a8413 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 --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' + 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' " 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 deleted file mode 100644 index d1bc20e..0000000 --- a/frontend/public/assets/poi-icons/public_transport/london_dlr.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/assets/twemoji/1f688.png b/frontend/public/assets/twemoji/1f688.png deleted file mode 100644 index 26aa866..0000000 Binary files a/frontend/public/assets/twemoji/1f688.png and /dev/null differ diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs index d0c4151..ec1fdcb 100644 --- a/frontend/scripts/check-translations.mjs +++ b/frontend/scripts/check-translations.mjs @@ -214,41 +214,16 @@ 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)\(/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]}"`)); + const re = /Feature::(?:Enum|Numeric)\([^]*?name:\s*"((?:\\.|[^"\\])*)"/g; + for (const match of src.matchAll(re)) { + names.push(JSON.parse(`"${match[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); @@ -380,7 +355,7 @@ function checkLocales(supportedCodes) { } } -function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys, legacyCrimeKeys) { +function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys) { const record = readNamedRecord(file, varName); const expected = supportedCodes.filter((c) => c !== 'en'); const present = Object.keys(record); @@ -422,12 +397,10 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys, required } } - // 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}. + // Every key here must also be a translatable feature name in en.ts > server. + // Otherwise the description is unreachable — ts() looks up server.${name}. for (const key of union) { - if (!serverKeys.has(key) && !legacyCrimeKeys.has(key)) { + if (!serverKeys.has(key)) { fail(`${file}: key "${key}" has no matching entry in en.ts > server`); } } @@ -505,25 +478,16 @@ function main() { checkLocaleLoaders(supportedCodes); const en = readLocale('en'); const serverKeys = new Set(Object.keys(en.server ?? {})); - const legacyCrimeKeys = deriveLegacyCrimeKeys(serverKeys); - const requiredKeys = [...readServerFeatureConfigNames(), ...legacyCrimeKeys]; + const sourceFeatureKeys = readServerFeatureConfigNames(); checkServerSourceCoverage(serverKeys); checkRecordCoverage( 'descriptions.ts', 'descriptions', supportedCodes, serverKeys, - requiredKeys, - legacyCrimeKeys - ); - checkRecordCoverage( - 'details.ts', - 'details', - supportedCodes, - serverKeys, - requiredKeys, - legacyCrimeKeys + sourceFeatureKeys ); + 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 5e03eb6..1274f4e 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 } from './lib/api'; +import { fetchWithRetry, apiUrl, logNonAbortError, readDemoChoice, setDemoCenter } from './lib/api'; import { trackEvent } from './lib/analytics'; import { parseUrlState } from './lib/url-state'; import pb from './lib/pocketbase'; @@ -209,6 +209,34 @@ 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 : '' ); @@ -220,8 +248,8 @@ export default function App() { ); const activePageRef = useRef('home'); const initialViewState = useMemo( - () => (mapUrlState.hasExplicitView ? mapUrlState.viewState : INITIAL_VIEW_STATE), - [mapUrlState.hasExplicitView, mapUrlState.viewState] + () => (mapUrlState.hasExplicitView ? mapUrlState.viewState : (demoView ?? INITIAL_VIEW_STATE)), + [mapUrlState.hasExplicitView, mapUrlState.viewState, demoView] ); const isScreenshotMode = useMemo(() => { @@ -274,6 +302,16 @@ 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'); @@ -313,8 +351,8 @@ export default function App() { if (!completed) { setPostAuthIntent(null); postAuthCheckoutReturnPathRef.current = null; - // Only protected pages bounce home; the dashboard stays open when the - // modal is dismissed. + // Only protected pages bounce home; the dashboard stays open in demo + // mode (server-enforced free zone) when the modal is dismissed. if (isProtectedPage(activePageRef.current)) { window.history.replaceState({ page: 'home', hash: '' }, '', '/'); setRouteHash(''); @@ -573,8 +611,9 @@ export default function App() { const isProtectedPageActive = isProtectedPage(activePage); // Only protected pages (account/saved) prompt for login on entry. The - // dashboard opens straight away so visitors can try it without logging in; - // the upgrade modal appears when they hit the free filter cap. + // 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. useEffect(() => { if (authLoading) return; if (isProtectedPageActive && !user) { @@ -729,6 +768,7 @@ 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 3e19e44..0c18e12 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -383,9 +383,7 @@ export function SavedPage({ }) .catch((err) => { if (!cancelled) { - setShareLinksError( - err instanceof Error ? err.message : t('accountPage.fetchShareLinksError') - ); + setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links'); } }) .finally(() => { @@ -395,7 +393,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 ${ @@ -740,7 +738,7 @@ function InviteSection({ user }: { user: AuthUser }) { setInviteUrl((prev) => ({ ...prev, [type]: data.url })); fetchInviteHistory(); } catch (err) { - const msg = err instanceof Error ? err.message : t('invitesPage.createInviteError'); + const msg = err instanceof Error ? err.message : 'Failed to create invite'; setInviteError((prev) => ({ ...prev, [type]: msg })); } finally { setCreatingInvite((prev) => ({ ...prev, [type]: false })); @@ -933,8 +931,7 @@ export default function AccountPage({ assertOk(res, 'Update newsletter'); await onRefreshAuth(); } catch (err) { - const msg = - err instanceof Error ? err.message : t('accountPage.updateNewsletterError'); + const msg = err instanceof Error ? err.message : 'Failed to update newsletter'; setNewsletterError(msg); } finally { setNewsletterSaving(false); diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index d43ba0b..70cc34d 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 4273c72..b4bc291 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 (/yr, 7y)', + name: 'Serious crime (avg/yr)', type: 'numeric', group: 'Crime', min: 0, @@ -763,7 +763,7 @@ function RightPaneOnlyScreen({ {t('home.showcasePoliticalVoteShare')} - {t('home.showcaseGe2024')} + 2024 GE 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 edd923f..7ea6a7d 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 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.', + '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.', '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 access and lifetime access', + heading: '3. Free demo and lifetime access', paragraphs: [ - '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.', + '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.', '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 d990940..64ebd40 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -1,8 +1,15 @@ -import { useCallback, useMemo, useState, type MutableRefObject, type ReactNode } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, + type ReactNode, +} from 'react'; import { useTranslation } from 'react-i18next'; import { ts } from '../../i18n/server'; import type { - CrimeRecordsResponse, FeatureFilters, FeatureGroup, FeatureMeta, @@ -11,11 +18,7 @@ import type { } from '../../types'; import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime'; import type { HexagonLocation } from '../../lib/external-search'; -import { - formatStationDistance, - stationDisplayName, - type NearbyStation, -} from '../../lib/nearby-stations'; +import { formatStationDistance, type NearbyStation } from '../../lib/nearby-stations'; import { formatValue, formatFilterValue, @@ -36,14 +39,12 @@ 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'; @@ -71,7 +72,6 @@ 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 }) { )}
- {stationDisplayName(station.name)} + {station.name}
{ts(station.category)}
@@ -242,7 +242,6 @@ export default function AreaPane({ shareCode, isGroupExpanded, onToggleGroup, - onLoadCrimeRecords, scrollTopRef, scrollRestoreKey, scrollSaveDisabled, @@ -298,22 +297,61 @@ 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 { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand(); + const scrollContainerRef = useRef(null); const setScrollNode = useCallback( (node: HTMLDivElement | null) => { - setContainer(node); + scrollContainerRef.current = node; scrollRef(node); }, - [setContainer, scrollRef] + [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); - revealGroupOnToggle(name, willExpand); + setGroupToReveal(willExpand ? { name } : null); }, - [isGroupExpanded, onToggleGroup, revealGroupOnToggle] + [isGroupExpanded, onToggleGroup] ); + useEffect(() => { + if (!groupToReveal) return; + const el = groupRefs.current.get(groupToReveal.name); + const container = scrollContainerRef.current; + if (!el || !container) return; + + // Scroll down just enough to bring the bottom of the opened group into view. + // For groups taller than the pane this scrolls past the header, but it stays + // visible via its sticky positioning. Never scroll up (group already in view). + const alignBottom = () => { + const delta = el.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom; + if (delta <= 1) return; + container.scrollTo({ top: container.scrollTop + delta }); + }; + + alignBottom(); + + // The group's charts and async data (e.g. nearby stations) can grow its + // height after this first pass, so keep the bottom pinned as it settles. + // Stop once the user scrolls or after a short grace period so we never fight + // a deliberate scroll. + let timer = 0; + const observer = new ResizeObserver(alignBottom); + const stop = () => { + observer.disconnect(); + container.removeEventListener('wheel', stop); + container.removeEventListener('touchmove', stop); + window.clearTimeout(timer); + }; + observer.observe(el); + container.addEventListener('wheel', stop, { passive: true }); + container.addEventListener('touchmove', stop, { passive: true }); + timer = window.setTimeout(stop, 1500); + + return stop; + }, [groupToReveal]); const numericByName = useMemo(() => { if (!stats) return new Map(); @@ -325,21 +363,26 @@ export default function AreaPane({ return new Map(stats.enum_features.map((feature) => [feature.name, feature])); }, [stats]); - // Crime-by-year series, keyed by the bare crime type (e.g. "Burglary"). - const crimeByYearByType = useMemo(() => { + // 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(() => { 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-rate-feature outcode/sector averages, keyed by the FULL rate-feature name - // (e.g. "Burglary (/yr, 7y)") the comparison is computed against. + // Per-crime-type outcode/sector averages, keyed by both the bare crime type + // and the " (avg/yr)" feature name (same convention as crimeByYearByFeatureName) + // so renderers can look them up by the feature name they already hold. const crimeAreaAvgByName = useMemo(() => { const map = new Map[number]>(); for (const entry of stats?.crime_area_averages ?? []) { map.set(entry.name, entry); + map.set(`${entry.name} (avg/yr)`, entry); } return map; }, [stats]); @@ -397,11 +440,7 @@ export default function AreaPane({ <>
-
+
@@ -576,7 +615,13 @@ export default function AreaPane({ ); return ( -
+
{ + if (el) groupRefs.current.set(group.name, el); + else groupRefs.current.delete(group.name); + }} + > {showNearbyStations && } - {group.name === 'Crime' ? ( - - ) : ( - <> - {stackedCharts?.map((chart) => { - const segments = chart.components - .map((name) => ({ - name, - value: numericByName.get(name)?.mean ?? 0, - })) - .filter((s) => s.value > 0); + {stackedCharts?.map((chart) => { + const segments = chart.components + .map((name) => ({ + name, + value: numericByName.get(name)?.mean ?? 0, + })) + .filter((s) => s.value > 0); - const isPercentageComposition = chart.unit === '%' && !chart.feature; - const displaySegments = isPercentageComposition - ? normalizePercentageSegments(segments) - : segments; + const isPercentageComposition = chart.unit === '%' && !chart.feature; + const displaySegments = isPercentageComposition + ? normalizePercentageSegments(segments) + : segments; - const aggregateStats = chart.feature - ? numericByName.get(chart.feature) - : undefined; - const total = aggregateStats - ? aggregateStats.mean - : displaySegments.reduce((sum, s) => sum + s.value, 0); + const aggregateStats = chart.feature + ? numericByName.get(chart.feature) + : undefined; + const total = aggregateStats + ? aggregateStats.mean + : displaySegments.reduce((sum, s) => sum + s.value, 0); - // Use rateFeature (e.g. a percentage) for display if available - const rateStats = chart.rateFeature - ? numericByName.get(chart.rateFeature) - : undefined; - const displayValue = isPercentageComposition - ? 100 - : rateStats - ? rateStats.mean - : total; + // Use rateFeature (e.g. per-1k) for display if available + const rateStats = chart.rateFeature + ? numericByName.get(chart.rateFeature) + : undefined; + const displayValue = isPercentageComposition + ? 100 + : rateStats + ? rateStats.mean + : total; - // Use rateFeature for info popup and national average when available - const infoFeatureName = chart.rateFeature ?? chart.feature; - const featureMeta = infoFeatureName - ? globalFeatureByName.get(infoFeatureName) - : undefined; + // Use rateFeature for info popup and national average when available + const infoFeatureName = chart.rateFeature ?? chart.feature; + const featureMeta = infoFeatureName + ? globalFeatureByName.get(infoFeatureName) + : undefined; - const globalMean = featureMeta?.histogram - ? calculateHistogramMean(featureMeta.histogram) - : undefined; - const crimeAreaAvg = infoFeatureName - ? crimeAreaAvgByName.get(infoFeatureName) - : undefined; - // For crime, prefer the exact national mean so it shares - // one estimator with the outcode/sector/selection values. - const nationalAvg = crimeAreaAvg?.national ?? globalMean; + const globalMean = featureMeta?.histogram + ? calculateHistogramMean(featureMeta.histogram) + : undefined; + const crimeAreaAvg = infoFeatureName + ? crimeAreaAvgByName.get(infoFeatureName) + : undefined; + // For crime, prefer the exact national mean so it shares + // one estimator with the outcode/sector/selection values. + const nationalAvg = crimeAreaAvg?.national ?? globalMean; - // Crime metrics get a number line comparing this area to - // its sector / outcode / nation instead of a flat list. - const numberLinePoints: NumberLinePoint[] = crimeAreaAvg - ? ( - [ - { - kind: 'area', - label: t('areaPane.thisArea'), - value: displayValue, - }, - nationalAvg != null - ? { - kind: 'national', - label: t('areaPane.national'), - value: nationalAvg, - } - : null, - crimeAreaAvg.outcode != null - ? { - kind: 'outcode', - label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'), - value: crimeAreaAvg.outcode, - } - : null, - crimeAreaAvg.sector != null - ? { - kind: 'sector', - label: stats?.crime_sector ?? t('areaPane.sectorAvg'), - value: crimeAreaAvg.sector, - } - : null, - ] as (NumberLinePoint | null)[] - ).filter((p): p is NumberLinePoint => p !== null) - : []; + // Crime metrics get a number line comparing this area to + // its sector / outcode / nation instead of a flat list. + const numberLinePoints: NumberLinePoint[] = crimeAreaAvg + ? ( + [ + { + kind: 'area', + label: t('areaPane.thisArea'), + value: displayValue, + }, + nationalAvg != null + ? { + kind: 'national', + label: t('areaPane.national'), + value: nationalAvg, + } + : null, + crimeAreaAvg.outcode != null + ? { + kind: 'outcode', + label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'), + value: crimeAreaAvg.outcode, + } + : null, + crimeAreaAvg.sector != null + ? { + kind: 'sector', + label: stats?.crime_sector ?? t('areaPane.sectorAvg'), + value: crimeAreaAvg.sector, + } + : null, + ] as (NumberLinePoint | null)[] + ).filter((p): p is NumberLinePoint => p !== null) + : []; - if (total === 0) return null; + if (total === 0) return null; - const crimeSeries = chart.feature - ? crimeByYearByType.get(chart.feature) - : undefined; + const crimeSeries = chart.feature + ? crimeByYearByFeatureName.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)} -
- )} -
-
- - {numberLinePoints.length >= 2 && ( -
- -
- )} - {crimeSeries && crimeSeries.points.length > 1 && ( -
- + {!crimeAreaAvg && nationalAvg != null && ( +
+ {t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
)}
- ); - })} - {(() => { - 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); +
+ + {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); - 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} - /> - ); - } - - 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 (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; return ( -
-
- {featureMeta ? ( - + } + chart={ + crimeSeries && crimeSeries.points.length > 1 ? ( + ) : ( - - {ts(chart.label)} - - )} - - {total.toLocaleString()} - -
- [v, chart.valueColors[i]]) - )} + 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 ( +
+ +
); } - const components = chart.components - .map((name) => { - const stats = enumByName.get(name); - return stats ? { label: name, stats } : null; - }) - .filter((c): c is NonNullable => c !== null); + return null; + }); + })()} + {stackedEnumCharts?.map((chart) => { + const featureMeta = chart.feature + ? globalFeatureByName.get(chart.feature) + : undefined; - if (components.length === 0) return null; + if (chart.components.length === 1) { + const stats = enumByName.get(chart.components[0]); + if (!stats) return null; - return ( -
-
- {featureMeta ? ( - - ) : ( - - {ts(chart.label)} - - )} -
- + 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 ? ( + + ) : ( + + {ts(chart.label)} + + )} +
+ +
+ ); + })}
)}
diff --git a/frontend/src/components/map/CrimeGroupBody.tsx b/frontend/src/components/map/CrimeGroupBody.tsx deleted file mode 100644 index 20e8181..0000000 --- a/frontend/src/components/map/CrimeGroupBody.tsx +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index eed6e0f..0000000 --- a/frontend/src/components/map/CrimeRecordsSection.tsx +++ /dev/null @@ -1,150 +0,0 @@ -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 dc60ffe..b518c21 100644 --- a/frontend/src/components/map/CrimeYearChart.tsx +++ b/frontend/src/components/map/CrimeYearChart.tsx @@ -82,12 +82,7 @@ export default function CrimeYearChart({ points, latestAvailableYear }: CrimeYea r={1.6} className="fill-rose-700 dark:fill-rose-300" > - - {t('areaPane.crimeYearPointTooltip', { - year: p.year, - rate: p.count.toFixed(1), - })} - + {`${p.year}: ${p.count.toFixed(1)}/yr`} ))} 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 b6b863f..1204f06 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 = t('externalSearch.radiusMi', { count: radiusMiles }); + const label = `${radiusMiles}mi radius`; if (!urls) return null; diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index a6bf288..a3d62b6 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -33,10 +33,6 @@ 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({ @@ -50,8 +46,6 @@ export default function FeatureBrowser({ onClearOpenInfoFeature, travelTimeEntries: _travelTimeEntries, onAddTravelTimeEntry, - registerGroup, - onGroupToggle, }: FeatureBrowserProps) { const { t } = useTranslation(); const modes = useTranslatedModes(); @@ -121,15 +115,11 @@ export default function FeatureBrowser({ {mergedGrouped.map((group) => { const isExpanded = isSearching || isGroupExpanded(group.name); return ( -
+
{ - const willExpand = !isExpanded; - toggleGroup(group.name); - onGroupToggle?.(group.name, willExpand); - }} + onToggle={() => toggleGroup(group.name)} className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-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 28e8f82..32ee222 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -21,16 +21,6 @@ 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, @@ -47,22 +37,7 @@ import { isEthnicityFeatureName, isEthnicityFilterName, } from '../../lib/ethnicity-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 { isQualificationFeatureName } from '../../lib/qualification-filter'; import { SCHOOL_FILTER_NAME, getDefaultSchoolFeatureName, @@ -207,33 +182,6 @@ 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] @@ -308,18 +256,6 @@ 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) @@ -342,28 +278,6 @@ 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) @@ -386,8 +300,6 @@ 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 ( @@ -399,17 +311,6 @@ 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') { @@ -429,12 +330,6 @@ 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); @@ -449,28 +344,14 @@ 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); } @@ -482,16 +363,10 @@ export default memo(function Filters({ schoolMeta, defaultSpecificCrimeFeatureName, specificCrimeMeta, - defaultCrimeSeverityFeatureNames, - crimeSeverityMetas, defaultElectionVoteShareFeatureName, electionVoteShareMeta, defaultEthnicityFeatureName, ethnicityMeta, - defaultQualificationFeatureName, - qualificationMeta, - defaultTenureFeatureName, - tenureMeta, defaultPoiFilterFeatureNames, poiFilterMetas, ]); @@ -501,8 +376,6 @@ 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; @@ -511,16 +384,6 @@ 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') { @@ -540,10 +403,6 @@ export default memo(function Filters({ } continue; } - if (isCrimeSeverityFeatureName(feature.name)) { - insertCrimeSeverityFilterItems(getCrimeSeverityFilterName(feature.name)); - continue; - } if (isElectionVoteShareFeatureName(feature.name)) { if (!insertedElectionVoteShareFilters) { result.push(...electionVoteShareFilterItems); @@ -558,20 +417,6 @@ 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; @@ -585,11 +430,8 @@ export default memo(function Filters({ enabledFeatures, schoolFilterItems, specificCrimeFilterItems, - crimeSeverityFilterItems, electionVoteShareFilterItems, ethnicityFilterItems, - qualificationFilterItems, - tenureFilterItems, poiDistanceFilterItems, ]); @@ -618,15 +460,10 @@ 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; } @@ -635,11 +472,8 @@ export default memo(function Filters({ [ electionVoteShareMeta.group, ethnicityMeta.group, - qualificationMeta.group, - tenureMeta.group, features, poiFilterMetas, - crimeSeverityMetas, schoolMeta.group, specificCrimeMeta.group, ] @@ -680,28 +514,6 @@ 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; @@ -716,11 +528,8 @@ export default memo(function Filters({ [ defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, - defaultCrimeSeverityFeatureNames, defaultElectionVoteShareFeatureName, defaultEthnicityFeatureName, - defaultQualificationFeatureName, - defaultTenureFeatureName, defaultPoiFilterFeatureNames, getAddFilterGroupName, onAddFilter, @@ -877,11 +686,8 @@ export default memo(function Filters({ ...features, schoolMeta, specificCrimeMeta, - ...Object.values(crimeSeverityMetas), electionVoteShareMeta, ethnicityMeta, - qualificationMeta, - tenureMeta, poiDistanceMeta, transportDistanceMeta, poiCount2KmMeta, @@ -890,11 +696,8 @@ 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 d738f37..be04ef6 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -5,11 +5,8 @@ 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, @@ -55,20 +52,14 @@ 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 40dfd9f..a8cfcd8 100644 --- a/frontend/src/components/map/JourneyInstructions.test.tsx +++ b/frontend/src/components/map/JourneyInstructions.test.tsx @@ -16,8 +16,6 @@ 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 fb32486..c4667e0 100644 --- a/frontend/src/components/map/JourneyInstructions.tsx +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -1,6 +1,5 @@ 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'; @@ -107,25 +106,15 @@ function stripId(label: string): string { return label.replace(/\s+\([A-Za-z0-9]+\)$/, ''); } -function getRouteDisplay( - mode: string, - t: TFunction -): { label: string; color: string; darkText: boolean } { +function getRouteDisplay(mode: string): { 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} ${t('journey.lineSuffix')}`; + const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`; return { label, color: known.color, darkText: !!known.darkText }; } if (/^\d+[A-Za-z]?$/.test(clean.trim())) { - return { - label: `${t('journey.bus')} ${clean}`, - color: '#0d9488', - darkText: false, - }; + return { label: `Bus ${clean}`, color: '#0d9488', darkText: false }; } return { label: clean, color: '#6b7280', darkText: false }; } @@ -214,8 +203,7 @@ function invertLegs(legs: JourneyLeg[]): JourneyLeg[] { } function RouteBadge({ mode }: { mode: string }) { - const { t } = useTranslation(); - const { label, color, darkText } = getRouteDisplay(mode, t); + const { label, color, darkText } = getRouteDisplay(mode); return (
diff --git a/frontend/src/components/map/ListingPopups.tsx b/frontend/src/components/map/ListingPopups.tsx index dda406b..f04e99b 100644 --- a/frontend/src/components/map/ListingPopups.tsx +++ b/frontend/src/components/map/ListingPopups.tsx @@ -19,21 +19,15 @@ 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 && ( @@ -63,7 +57,7 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent )} {listing.floor_area_sqm != null && (
- {Math.round(listing.floor_area_sqm)} {t('common.sqm')} + {Math.round(listing.floor_area_sqm)} sqm {listing.asking_price_per_sqm != null ? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm` : ''} @@ -78,11 +72,8 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent ))} )} -
- {visited && ( - ✓ {t('listing.viewed')} - )} - {t('listing.openListing')} ↗ +
+ Open listing ↗
); @@ -91,13 +82,9 @@ 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; @@ -105,41 +92,32 @@ export const ListingClusterPopupContent = memo(function ListingClusterPopupConte
- {count.toLocaleString()} {t('listing.listings')} + {count.toLocaleString()} listings
{visibleCount > 0 - ? t('listing.showingOf', { visible: visibleCount, count }) - : t('listing.groupedNear')} + ? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}` + : 'Grouped near this map position'}
{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) - : t('listing.listing')} - - {visited && ( - - ✓ Viewed - - )} +
+ {listing.asking_price != null + ? formatListingPrice(listing.asking_price) + : 'Listing'}
{headline && (
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index caee2d9..c31a649 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -452,8 +452,6 @@ export default memo(function Map({ visiblePois, listingPopup, clearListingPopup, - clickedListingUrls, - markListingClicked, developmentPopup, clearDevelopmentPopup, hoverPosition, @@ -698,17 +696,11 @@ 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 fbade47..4b19af0 100644 --- a/frontend/src/components/map/MapErrorBoundary.tsx +++ b/frontend/src/components/map/MapErrorBoundary.tsx @@ -1,7 +1,6 @@ import { Component, Fragment, type ReactNode } from 'react'; import * as Sentry from '@sentry/react'; -import i18n from '../../i18n'; import { MapFallback } from './map-page/Fallbacks'; /** @@ -114,20 +113,17 @@ export class MapErrorBoundary extends Component

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

- {i18n.t('map.error.body', { - defaultValue: - 'This can happen when your browser’s graphics context is interrupted.', - })} + 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 27bf3cb..b951e36 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -21,14 +21,21 @@ import { resolveTransitVariant, travelFieldKey, useTravelTime, - type TransportMode, } from '../../hooks/useTravelTime'; -import { apiUrl, authHeaders, buildFilterString } from '../../lib/api'; +import { + apiUrl, + authHeaders, + buildFilterString, + persistDemoChoice, + readDemoChoice, + setDemoCenter, +} from '../../lib/api'; +import DemoLocationPrompt from './DemoLocationPrompt'; import { useFilterCounts } from '../../hooks/useFilterCounts'; import { trackEvent } from '../../lib/analytics'; import { DEMO_MAX_FILTERS, - REGISTERED_MAX_FILTERS, + DEV_LOCATION, INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD, } from '../../lib/consts'; @@ -78,6 +85,8 @@ 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[] = []; @@ -86,6 +95,7 @@ export default function MapPage({ poiCategoryGroups, initialFilters, initialViewState, + offerDemoLocation, initialPOICategories, initialOverlays, initialCrimeTypes, @@ -145,50 +155,11 @@ export default function MapPage({ initialPostcode ?? null ); - // 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; + // Demo (unlicensed) users are limited to DEMO_MAX_FILTERS filters; hitting the + // cap opens the upgrade modal. Licensed users and admins are unlimited. 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(() => { - 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 handleFilterLimitReached = useCallback(() => setFilterLimitHit(true), []); const { filters, @@ -212,10 +183,7 @@ export default function MapPage({ } = useFilters({ initialFilters, features, - filterLimit, - // Travel-time entries reserve slots from the same cap (see cappedInitialTravelTime). - reservedFilterSlots: filtersUnlimited ? 0 : entries.length, - lockAdds: lockFilterAdds, + filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS, onFilterLimitReached: handleFilterLimitReached, }); @@ -228,6 +196,20 @@ 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); @@ -280,22 +262,13 @@ export default function MapPage({ const result = await fetchAiFilters(query, hasContext ? context : undefined); if (!result) return; - // 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); + handleSetFilters(result.filters); // 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 representableAll = result.travelTimeFilters + const representable = 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 }) => ({ @@ -350,26 +323,12 @@ 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(); @@ -448,7 +407,6 @@ export default function MapPage({ handlePropertiesTabClick, handleLoadMoreProperties, handleCloseSelection, - loadCrimeRecords, selectedPostcodeGeometry, handleLocationSearch, handleCurrentLocationSearch, @@ -518,6 +476,93 @@ 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 @@ -585,6 +630,7 @@ export default function MapPage({ dataLength: mapData.data.length, postcodeDataLength: mapData.postcodeData.length, usePostcodeView: mapData.usePostcodeView, + licenseRequired: mapData.licenseRequired, }); const handleMobileHexagonClick = useCallback( @@ -603,7 +649,7 @@ export default function MapPage({ mapData.resolution, areaStats ); - const tutorial = useTutorial(initialLoading, isMobile, deferTutorial); + const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired); const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]); const densityLabel = t('mapLegend.historicalMatches'); const mobileLegendMeta = useMobileLegendMeta(viewFeature, features); @@ -678,7 +724,11 @@ export default function MapPage({ }, [dashboardParams, onDashboardParamsChange]); const dashboardReady = - !initialLoading && !mapData.loading && mapData.bounds != null && mapData.currentView != null; + !initialLoading && + !mapData.loading && + !mapData.licenseRequired && + mapData.bounds != null && + mapData.currentView != null; useEffect(() => { onDashboardReadyChange?.(dashboardReady); @@ -690,6 +740,16 @@ 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. @@ -746,7 +806,6 @@ export default function MapPage({ shareCode={shareCode} isGroupExpanded={isAreaGroupExpanded} onToggleGroup={toggleAreaGroup} - onLoadCrimeRecords={loadCrimeRecords} scrollTopRef={areaPaneScrollTopRef} scrollRestoreKey={ selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null @@ -764,7 +823,6 @@ export default function MapPage({ hexagonLocation, isAreaGroupExpanded, loadingAreaStats, - loadCrimeRecords, selectedHexagon, setAreaStatsUseFilters, shareCode, @@ -848,7 +906,7 @@ export default function MapPage({ openInfoFeature={pendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature} travelTimeEntries={entries} - onTravelTimeAddEntry={handleAddTravelTimeEntry} + onTravelTimeAddEntry={handleAddEntry} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeRangeChange={handleTimeRangeChange} @@ -895,7 +953,7 @@ export default function MapPage({ features, filterCounts.impacts, filters, - handleAddTravelTimeEntry, + handleAddEntry, handleAddFilter, handleAiFilterSubmit, handleClearAll, @@ -1021,25 +1079,51 @@ export default function MapPage({
) : null; - // 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)} - /> - + // 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 ? ( + ) : null; + // Both overlays render where the page components place {upgradeModal}. + const overlayModals = ( + <> + {upgradeModal} + {demoLocationPrompt} + + ); + if (isMobile) { return ( ); @@ -1151,7 +1235,7 @@ export default function MapPage({ renderAreaPane={renderAreaPane} renderPropertiesPane={renderPropertiesPane} toasts={toasts} - upgradeModal={upgradeModal} + upgradeModal={overlayModals} /> ); } diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx index 18cd292..39bdf6c 100644 --- a/frontend/src/components/map/MobileDrawer.tsx +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -105,11 +105,7 @@ export default function MobileDrawer({ return (