From 2efa4d9f47afb4ff0730a7706e82c207484dbcae Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 25 Jun 2026 22:08:36 +0100 Subject: [PATCH 1/3] Language --- frontend/scripts/check-translations.mjs | 56 +++++- frontend/src/i18n/descriptions.ts | 18 +- frontend/src/i18n/locales/de.ts | 220 ++++++++++++++++++++-- frontend/src/i18n/locales/en.ts | 229 +++++++++++++++++++++-- frontend/src/i18n/locales/fr.ts | 232 ++++++++++++++++++++++-- frontend/src/i18n/locales/hi.ts | 214 ++++++++++++++++++++-- frontend/src/i18n/locales/hu.ts | 222 +++++++++++++++++++++-- frontend/src/i18n/locales/zh.ts | 212 ++++++++++++++++++++-- frontend/src/types.ts | 46 ++++- 9 files changed, 1324 insertions(+), 125 deletions(-) diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs index ec1fdcb..d0c4151 100644 --- a/frontend/scripts/check-translations.mjs +++ b/frontend/scripts/check-translations.mjs @@ -214,16 +214,41 @@ function readServerFeatureNames() { return [...new Set(names)]; } +// Names of the Enum/Numeric feature *configs* (each needs a description + detail +// translation). We take the FIRST `name:` field after every Feature::Enum( / +// Feature::Numeric( opening. This deliberately skips macro-generated configs +// whose name is a `concat!(...)` expression (the crime rates — handled via +// deriveLegacyCrimeKeys instead) and stops the lazy match from running past such +// a config into an unrelated FeatureGroup `name:` (which previously made the +// group name "Properties" look like a required feature). function readServerFeatureConfigNames() { const src = readFileSync(FEATURES_RS, 'utf8'); const names = []; - const re = /Feature::(?:Enum|Numeric)\([^]*?name:\s*"((?:\\.|[^"\\])*)"/g; - for (const match of src.matchAll(re)) { - names.push(JSON.parse(`"${match[1]}"`)); + const re = /Feature::(?:Enum|Numeric)\(/g; + let m; + while ((m = re.exec(src))) { + const after = src.slice(m.index + m[0].length); + const nm = after.match(/\bname:\s*(?:"((?:\\.|[^"\\])*)"|concat!)/); + if (nm && nm[1] !== undefined) names.push(JSON.parse(`"${nm[1]}"`)); } return [...new Set(names)]; } +// The crime-rate features are generated by the crime_rate_features! macro and +// named " (/yr, 7y|2y)". Their description/detail translations are shared +// across both windows under a single legacy " (avg/yr)" key. This mirror +// of legacyCrimeFeatureKey() in descriptions.ts MUST use the same suffix regex, +// derived straight from the server keys so the checker and the runtime lookup +// stay in lock-step. +function deriveLegacyCrimeKeys(serverKeys) { + const legacy = new Set(); + for (const key of serverKeys) { + const match = key.match(/^(.*) \(\/yr, \d+y\)$/); + if (match) legacy.add(`${match[1]} (avg/yr)`); + } + return legacy; +} + function readNamedRecord(file, varName) { const sf = parseFile(join(I18N_DIR, file)); const init = findVarInitializer(sf, varName); @@ -355,7 +380,7 @@ function checkLocales(supportedCodes) { } } -function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys) { +function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys, legacyCrimeKeys) { const record = readNamedRecord(file, varName); const expected = supportedCodes.filter((c) => c !== 'en'); const present = Object.keys(record); @@ -397,10 +422,12 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys, required } } - // Every key here must also be a translatable feature name in en.ts > server. - // Otherwise the description is unreachable — ts() looks up server.${name}. + // Every key here must also be a translatable feature name in en.ts > server, + // or a legacy crime key that maps onto the per-window server keys (see + // deriveLegacyCrimeKeys / legacyCrimeFeatureKey). Otherwise the description is + // unreachable — ts() looks up server.${name}. for (const key of union) { - if (!serverKeys.has(key)) { + if (!serverKeys.has(key) && !legacyCrimeKeys.has(key)) { fail(`${file}: key "${key}" has no matching entry in en.ts > server`); } } @@ -478,16 +505,25 @@ function main() { checkLocaleLoaders(supportedCodes); const en = readLocale('en'); const serverKeys = new Set(Object.keys(en.server ?? {})); - const sourceFeatureKeys = readServerFeatureConfigNames(); + const legacyCrimeKeys = deriveLegacyCrimeKeys(serverKeys); + const requiredKeys = [...readServerFeatureConfigNames(), ...legacyCrimeKeys]; checkServerSourceCoverage(serverKeys); checkRecordCoverage( 'descriptions.ts', 'descriptions', supportedCodes, serverKeys, - sourceFeatureKeys + requiredKeys, + legacyCrimeKeys + ); + checkRecordCoverage( + 'details.ts', + 'details', + supportedCodes, + serverKeys, + requiredKeys, + legacyCrimeKeys ); - checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys, sourceFeatureKeys); checkForbiddenVisibleStrings(); for (const w of warnings) console.warn(`warn: ${w}`); diff --git a/frontend/src/i18n/descriptions.ts b/frontend/src/i18n/descriptions.ts index ad42df8..dcad67a 100644 --- a/frontend/src/i18n/descriptions.ts +++ b/frontend/src/i18n/descriptions.ts @@ -516,6 +516,18 @@ const descriptions: Record> = { }, }; +/** + * The crime features carry a window suffix ("Burglary (/yr, 7y)"), but the + * per-locale description/detail/icon maps are keyed by the bare type's legacy + * "(avg/yr)" name — and the text is the same for both windows. Map a crime + * feature name back to that legacy key so the existing translations resolve + * without duplicating every entry per window. + */ +export function legacyCrimeFeatureKey(featureName: string): string { + const match = featureName.match(/^(.*) \(\/yr, \d+y\)$/); + return match ? `${match[1]} (avg/yr)` : featureName; +} + /** * Translate a feature description. * - English: returns the server-provided description (single source of truth) @@ -524,7 +536,8 @@ const descriptions: Record> = { export function tsDesc(featureName: string, englishFromServer: string): string { const lang = i18n.language; if (lang === 'en') return englishFromServer; - return descriptions[lang]?.[featureName] ?? englishFromServer; + const key = legacyCrimeFeatureKey(featureName); + return descriptions[lang]?.[key] ?? englishFromServer; } /** @@ -534,5 +547,6 @@ export function tsDesc(featureName: string, englishFromServer: string): string { export function tsDetail(featureName: string, englishFromServer: string): string { const lang = i18n.language; if (lang === 'en') return englishFromServer; - return details[lang]?.[featureName] ?? englishFromServer; + const key = legacyCrimeFeatureKey(featureName); + return details[lang]?.[key] ?? englishFromServer; } diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 3090e16..4e00891 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -26,6 +26,8 @@ const de: Translations = { copy: 'Kopieren', copied: 'Kopiert!', copiedToClipboard: 'In die Zwischenablage kopiert', + captions: 'Untertitel', + sqm: 'm²', loading: 'Wird geladen...', loadMore: 'Mehr anzeigen', remaining: 'noch {{count}}', @@ -622,6 +624,10 @@ const de: Translations = { backToLogin: 'Zurück zur Anmeldung', registerConsent: 'Mit der Kontoerstellung akzeptierst du unsere Nutzungsbedingungen und unsere Datenschutzerklärung.', + loginFailed: 'Anmeldung fehlgeschlagen', + registrationFailed: 'Registrierung fehlgeschlagen', + oauthFailed: 'OAuth-Anmeldung fehlgeschlagen', + passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen', }, // ── Upgrade Modal ────────────────────────────────── @@ -718,6 +724,11 @@ const de: Translations = { outstanding: 'Outstanding', distance: 'Entfernung', crimeType: 'Deliktart', + qualificationLevel: 'Qualifikationsniveau', + tenureType: 'Wohnform', + crimeWindow: 'Zeitraum', + crimeWindow7y: '7 Jahre', + crimeWindow2y: '2 Jahre', ethnicity: 'Ethnie', poiType: 'POI-Typ', party: 'Partei', @@ -808,6 +819,7 @@ const de: Translations = { refiningResults: 'Ergebnisse werden verfeinert...', weeklyLimitReached: 'Du hast das wöchentliche KI-Limit erreicht. Es wird nächste Woche automatisch zurückgesetzt.', + generateFailed: 'Filter konnten nicht generiert werden', }, // ── Map Legend ───────────────────────────────────── @@ -827,6 +839,18 @@ const de: Translations = { ogSchools: 'Schulen', ogCrimeStats: 'Kriminalitätsdaten', ogTransport: 'Transport', + basemap: { standard: 'Karte', satellite: 'Satellit' }, + error: { + heading: 'Bei der Karte ist ein Problem aufgetreten', + body: 'Das kann passieren, wenn der Grafikkontext deines Browsers unterbrochen wird.', + reload: 'Karte neu laden', + }, + actualListings: { + label: 'Inserate', + show: 'Tatsächliche Inserate anzeigen', + hide: 'Tatsächliche Inserate ausblenden', + }, + poi: { zoomInToSeeDetails: 'Für Details hineinzoomen' }, }, // ── Properties Pane ──────────────────────────────── @@ -906,6 +930,20 @@ const de: Translations = { crimeDataEnds: 'Polizeidaten für dieses Gebiet enden {{year}}', residents: 'Einwohner', residentsTooltip: 'Wohnbevölkerung (ONS-Zensus 2021)', + crimeWindow7y: 'Letzte 7 Jahre', + crimeWindow2y: 'Letzte 2 Jahre', + crimeWindowLabel: 'Zeitraum', + crimeCardTitle: '{{name}} pro Jahr', + crimeRecordsToggle: 'Einzelne Straftaten anzeigen', + crimeRecordsHide: 'Einzelne Straftaten ausblenden', + crimeRecordsCount: '{{count}} Straftaten', + crimeRecordsLoading: 'Straftaten werden geladen…', + crimeRecordsEmpty: 'Keine einzelnen Straftaten erfasst', + crimeRecordsError: 'Straftaten konnten nicht geladen werden', + crimeRecordsLoadMore: 'Mehr laden', + crimeNoLocation: 'Ort nicht angegeben', + crimeOutcomeUnknown: 'Ergebnis nicht erfasst', + crimeYearPointTooltip: '{{year}}: {{rate}}/Jahr', }, // ── Street View ──────────────────────────────────── @@ -930,6 +968,7 @@ const de: Translations = { searchOn: 'Im Umkreis von {{radius}} suchen bei', exact: 'genau', outcodeNotRecognised: 'Outcode nicht erkannt', + radiusMi: 'Umkreis {{count}} mi', }, // ── Location Search ──────────────────────────────── @@ -986,6 +1025,7 @@ const de: Translations = { showcaseJourneyRoutes: 'Routen', showcaseNearby: '{{value}} in der Nähe', showcasePoliticalVoteShare: 'Politischer Stimmenanteil', + showcaseGe2024: 'Unterhauswahl 2024', showcaseLotsMore: '...und vieles mehr', showcaseMinutes: '{{count}} Min.', showcaseSendShortlist: 'Shortlist senden', @@ -1418,6 +1458,8 @@ const de: Translations = { noShareLinksYet: 'Noch keine geteilten Links', copyShareLink: 'Geteilten Link kopieren', clicksLabel: 'Klicks', + fetchShareLinksError: 'Geteilte Links konnten nicht abgerufen werden', + updateNewsletterError: 'Newsletter konnte nicht aktualisiert werden', }, // ── Saved Page ───────────────────────────────────── @@ -1433,6 +1475,12 @@ const de: Translations = { 'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.', isBeingUpdated: '{{name}} wird aktualisiert', updating: 'Wird aktualisiert...', + loadFailed: 'Suchen konnten nicht geladen werden', + saveFailed: 'Suche konnte nicht gespeichert werden', + deleteFailed: 'Suche konnte nicht gelöscht werden', + updateNotesFailed: 'Notizen konnten nicht aktualisiert werden', + updateNameFailed: 'Name konnte nicht aktualisiert werden', + updateFailed: 'Suche konnte nicht aktualisiert werden', }, // ── Invites Page ─────────────────────────────────── @@ -1452,6 +1500,7 @@ const de: Translations = { created: 'Erstellt', redeemed: 'Eingelöst', pending: 'Ausstehend', + createInviteError: 'Einladung konnte nicht erstellt werden', }, // ── Invite Page ──────────────────────────────────── @@ -1478,6 +1527,99 @@ const de: Translations = { youAlreadyHaveLicense: 'Du hast bereits eine Lizenz', accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.', failedToValidate: 'Einladungslink konnte nicht geprüft werden', + redeemFailed: 'Einladung konnte nicht eingelöst werden', + }, + + errors: { + appCrashTitle: 'Etwas ist schiefgelaufen', + appCrashBody: 'Lade die Seite neu, um es erneut zu versuchen.', + }, + + listing: { + viewed: 'Angesehen', + openListing: 'Inserat öffnen', + listings: 'Inserate', + listing: 'Inserat', + showingOf: '{{visible}} von {{count}} werden angezeigt', + groupedNear: 'Gruppiert an dieser Kartenposition', + }, + + overlays: { + heading: 'Overlays', + baseMap: 'Basiskarte', + colourOpacity: 'Farbdeckkraft', + dataOverlays: 'Daten-Overlays', + about: 'Über {{name}}', + zoomWarning: 'Zoome weiter hinein, um das ausgewählte Overlay zu sehen.', + zoomWarning_other: 'Zoome weiter hinein, um die ausgewählten Overlays zu sehen.', + noise: { + label: 'Lärm', + detail: + 'Defra Strategic Noise Mapping Round 4 (2022), kombiniert Quellen aus Straße, Schiene und Flughäfen. Die Werte verwenden die EU-Standardgröße Lden (gewichteter 24-Stunden-Mittelwert für Tag, Abend und Nacht), modelliert auf einem 10-m-Raster in 4 m Höhe über Grund. Hellere Bereiche zeigen höhere modellierte Lärmwerte an. Lizenziert unter der Open Government Licence v3.0.', + }, + crimeHotspots: { + label: 'Kriminalitäts-Hotspots', + detail: + 'Clientseitige Heatmap der von police.uk veröffentlichten Straftaten auf Straßenebene der letzten Monate. Die Koordinaten von police.uk sind anonymisierte, auf ein Raster gerundete Punkte und keine exakten Tatorte. Die Heatmap sollte daher als Näherung der relativen Dichte und nicht als präzise Karte einzelner Vorfälle gelesen werden.', + }, + treesOutsideWoodlands: { + label: 'Bäume & Wald', + detail: + 'Canopy-Polygone aus Forest Research Trees Outside Woodland (TOW) v1 — Einzelbäume und Baumgruppen — vereint mit Waldflächen des National Forest Inventory (NFI) (≥0,5 ha), die TOW bewusst ausschließt. Zusammen erfassen sie sowohl Straßenbäume als auch tatsächliche Wälder. Die Deckkraft der Polygone skaliert mit der Kronenfläche.', + }, + propertyBorders: { + label: 'Grundstücksgrenzen', + detail: + 'HM Land Registry INSPIRE Index Polygons — die Lage und ungefähre Ausdehnung von im Freehold eingetragenem Eigentum in England & Wales, dargestellt als Umrisse auf Straßenebene. Dies sind „general boundaries“ nur zur Orientierung und nicht die genaue rechtliche Grundstücksgrenze; sie schließen reine Leasehold-Interessen und nicht eingetragenes Land aus (rund 85–90 % des Freehold-Lands sind erfasst). Diese Informationen unterliegen dem Crown copyright und den Datenbankrechten 2026 und werden mit Genehmigung des HM Land Registry wiedergegeben. Die Polygone (einschließlich der zugehörigen Geometrie, namentlich x-, y-Koordinaten) unterliegen dem Crown copyright und den Datenbankrechten 2026 Ordnance Survey AC0000851063. Lizenziert unter der Open Government Licence v3.0.', + }, + newDevelopments: { + label: 'Neubauprojekte', + detail: + 'Eine vorausschauende Pipeline neuer Wohnbauten. Blaue Marker kennzeichnen Standorte in den gesetzlichen MHCLG Brownfield Land Registers — jeweils mit einer geschätzten Netto-Wohnungskapazität und einem Status der Baugenehmigung — zusammen mit Veräußerungsstandorten des Homes England Land Hub. Sie zeigen, wo neue Wohnungen geplant sind, oft Jahre bevor sie in EPC- oder Verkaufsdaten auftauchen. Die Wohnungszahlen sind Kapazitätsschätzungen, keine Zusagen, und ein Standort in einem Register ist eine Gelegenheit und keine Garantie für eine Bebauung. Lizenziert unter der Open Government Licence v3.0.', + }, + }, + + crimeTypes: { + violenceAndSexualOffences: 'Gewalt- & Sexualdelikte', + antiSocialBehaviour: 'Antisoziales Verhalten', + criminalDamageAndArson: 'Sachbeschädigung & Brandstiftung', + publicOrder: 'Öffentliche Ordnung', + shoplifting: 'Ladendiebstahl', + vehicleCrime: 'Fahrzeugkriminalität', + burglary: 'Einbruch', + otherTheft: 'Sonstiger Diebstahl', + theftFromThePerson: 'Diebstahl an Personen', + bicycleTheft: 'Fahrraddiebstahl', + drugs: 'Drogen', + robbery: 'Raub', + possessionOfWeapons: 'Waffenbesitz', + otherCrime: 'Sonstige Kriminalität', + }, + + journey: { + bus: 'Bus', + lineSuffix: 'Linie', + }, + + poiPopup: { + school: { + capacity: 'Kapazität', + type: 'Art', + ages: 'Alter', + gender: 'Geschlecht', + pupilsLabel: 'Schüler', + pupils: 'Schüler', + freeMeal: 'Kostenloses Essen', + ofsted: 'Ofsted', + sixthForm: 'Oberstufe', + religion: 'Religion', + admissions: 'Aufnahme', + trust: 'Trust', + address: 'Adresse', + localAuthority: 'LA', + head: 'Schulleitung', + website: 'Website', + }, }, // ── Format / Time ────────────────────────────────── @@ -1519,6 +1661,9 @@ const de: Translations = { step6Title: 'Was ist in der Nähe?', step6Content: 'Blende Schulen, Geschäfte, Bahnhöfe, Parks und Restaurants auf der Karte ein, um zu sehen, was erreichbar ist.', + step7Title: 'Mehr Kontext einblenden', + step7Content: + 'Blende Overlays wie Kriminalitäts-Hotspots, Lärm, Baumbestand, Grundstücksgrenzen und Neubaugebiete auf der Karte ein und wechsle die Basiskarte nach Bedarf.', }, // ── Server-derived values ────────────────────────── @@ -1541,11 +1686,11 @@ const de: Translations = { 'Property type': 'Immobilientyp', 'Leasehold/Freehold': 'Leasehold/Freehold', 'Last known price': 'Letzter bekannter Preis', - 'Estimated price': 'Geschätzter Preis', - 'Estimated current price': 'Geschätzter heutiger Preis', + 'Estimated price': 'Gesch. Preis', + 'Estimated current price': 'Gesch. heutiger Preis', 'Price per sqm': 'Preis pro m²', 'Est. price per sqm': 'Gesch. Preis pro m²', - 'Estimated monthly rent': 'Geschätzte Monatsmiete', + 'Estimated monthly rent': 'Gesch. Monatsmiete', 'Total floor area (sqm)': 'Gesamtwohnfläche (m²)', 'Number of bedrooms & living rooms': 'Anzahl Schlaf- und Wohnzimmer', 'Construction year': 'Baujahr', @@ -1578,23 +1723,58 @@ const de: Translations = { 'Housing Conditions Score': 'Wert der Wohnbedingungen', 'Air Quality and Road Safety Score': 'Wert für Luftqualität und Verkehrssicherheit', - // ─ Feature names (Crime) ─ - 'Serious crime (avg/yr)': 'Schwere Straftaten (Dichte)', - 'Minor crime (avg/yr)': 'Leichte Straftaten (Dichte)', - 'Violence and sexual offences (avg/yr)': 'Gewalt- und Sexualdelikte (Dichte)', - 'Burglary (avg/yr)': 'Einbrüche (Dichte)', - 'Robbery (avg/yr)': 'Raubüberfälle (Dichte)', - 'Vehicle crime (avg/yr)': 'Fahrzeugkriminalität (Dichte)', - 'Anti-social behaviour (avg/yr)': 'Antisoziales Verhalten (Dichte)', - 'Criminal damage and arson (avg/yr)': 'Sachbeschädigung und Brandstiftung (Dichte)', - 'Other theft (avg/yr)': 'Sonstiger Diebstahl (Dichte)', - 'Theft from the person (avg/yr)': 'Diebstahl von der Person (Dichte)', - 'Shoplifting (avg/yr)': 'Ladendiebstahl (Dichte)', - 'Bicycle theft (avg/yr)': 'Fahrraddiebstahl (Dichte)', - 'Drugs (avg/yr)': 'Drogendelikte (Dichte)', - 'Possession of weapons (avg/yr)': 'Waffenbesitz (Dichte)', - 'Public order (avg/yr)': 'Störung der öffentlichen Ordnung (Dichte)', - 'Other crime (avg/yr)': 'Sonstige Straftaten (Dichte)', + // ─ Feature names (Crime) — bevölkerungsnormierte Rate, 7 Jahre & 2 Jahre ─ + 'Serious crime (/yr, 7y)': 'Schwere Straftaten (pro Jahr, 7 J.)', + 'Serious crime (/yr, 2y)': 'Schwere Straftaten (pro Jahr, 2 J.)', + 'Minor crime (/yr, 7y)': 'Leichte Straftaten (pro Jahr, 7 J.)', + 'Minor crime (/yr, 2y)': 'Leichte Straftaten (pro Jahr, 2 J.)', + 'Violence and sexual offences (/yr, 7y)': + 'Gewalt- und Sexualdelikte (pro Jahr, 7 J.)', + 'Violence and sexual offences (/yr, 2y)': + 'Gewalt- und Sexualdelikte (pro Jahr, 2 J.)', + 'Burglary (/yr, 7y)': 'Einbrüche (pro Jahr, 7 J.)', + 'Burglary (/yr, 2y)': 'Einbrüche (pro Jahr, 2 J.)', + 'Robbery (/yr, 7y)': 'Raubüberfälle (pro Jahr, 7 J.)', + 'Robbery (/yr, 2y)': 'Raubüberfälle (pro Jahr, 2 J.)', + 'Vehicle crime (/yr, 7y)': 'Fahrzeugkriminalität (pro Jahr, 7 J.)', + 'Vehicle crime (/yr, 2y)': 'Fahrzeugkriminalität (pro Jahr, 2 J.)', + 'Anti-social behaviour (/yr, 7y)': 'Antisoziales Verhalten (pro Jahr, 7 J.)', + 'Anti-social behaviour (/yr, 2y)': 'Antisoziales Verhalten (pro Jahr, 2 J.)', + 'Criminal damage and arson (/yr, 7y)': + 'Sachbeschädigung und Brandstiftung (pro Jahr, 7 J.)', + 'Criminal damage and arson (/yr, 2y)': + 'Sachbeschädigung und Brandstiftung (pro Jahr, 2 J.)', + 'Other theft (/yr, 7y)': 'Sonstiger Diebstahl (pro Jahr, 7 J.)', + 'Other theft (/yr, 2y)': 'Sonstiger Diebstahl (pro Jahr, 2 J.)', + 'Theft from the person (/yr, 7y)': 'Diebstahl von der Person (pro Jahr, 7 J.)', + 'Theft from the person (/yr, 2y)': 'Diebstahl von der Person (pro Jahr, 2 J.)', + 'Shoplifting (/yr, 7y)': 'Ladendiebstahl (pro Jahr, 7 J.)', + 'Shoplifting (/yr, 2y)': 'Ladendiebstahl (pro Jahr, 2 J.)', + 'Bicycle theft (/yr, 7y)': 'Fahrraddiebstahl (pro Jahr, 7 J.)', + 'Bicycle theft (/yr, 2y)': 'Fahrraddiebstahl (pro Jahr, 2 J.)', + 'Drugs (/yr, 7y)': 'Drogendelikte (pro Jahr, 7 J.)', + 'Drugs (/yr, 2y)': 'Drogendelikte (pro Jahr, 2 J.)', + 'Possession of weapons (/yr, 7y)': 'Waffenbesitz (pro Jahr, 7 J.)', + 'Possession of weapons (/yr, 2y)': 'Waffenbesitz (pro Jahr, 2 J.)', + 'Public order (/yr, 7y)': 'Störung der öffentlichen Ordnung (pro Jahr, 7 J.)', + 'Public order (/yr, 2y)': 'Störung der öffentlichen Ordnung (pro Jahr, 2 J.)', + 'Other crime (/yr, 7y)': 'Sonstige Straftaten (pro Jahr, 7 J.)', + 'Other crime (/yr, 2y)': 'Sonstige Straftaten (pro Jahr, 2 J.)', + // Reine Deliktbezeichnungen (Aufschlüsselung nach Art + Einzeldatensätze). + // („Burglary“ ist bereits bei den spezifischen Delikten unten definiert.) + 'Violence and sexual offences': 'Gewalt- und Sexualdelikte', + Robbery: 'Raubüberfälle', + 'Vehicle crime': 'Fahrzeugkriminalität', + 'Anti-social behaviour': 'Antisoziales Verhalten', + 'Criminal damage and arson': 'Sachbeschädigung und Brandstiftung', + 'Other theft': 'Sonstiger Diebstahl', + 'Theft from the person': 'Diebstahl von der Person', + Shoplifting: 'Ladendiebstahl', + 'Bicycle theft': 'Fahrraddiebstahl', + Drugs: 'Drogendelikte', + 'Possession of weapons': 'Waffenbesitz', + 'Public order': 'Störung der öffentlichen Ordnung', + 'Other crime': 'Sonstige Straftaten', // ─ Feature names (Neighbours) ─ 'Median age': 'Medianalter', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 2238d58..07e71a1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -56,6 +56,8 @@ const en = { closePane: 'Close pane', yes: 'Yes', no: 'No', + captions: 'Captions', + sqm: 'sqm', }, // ── Header / Nav ─────────────────────────────────── @@ -610,6 +612,10 @@ const en = { backToLogin: 'Back to login', registerConsent: 'By creating an account you agree to our Terms of Service and Privacy Policy.', + loginFailed: 'Login failed', + registrationFailed: 'Registration failed', + oauthFailed: 'OAuth login failed', + passwordResetFailed: 'Password reset request failed', }, // ── Upgrade Modal ────────────────────────────────── @@ -704,6 +710,11 @@ const en = { outstanding: 'Outstanding', distance: 'Distance', crimeType: 'Crime type', + qualificationLevel: 'Qualification level', + tenureType: 'Tenure type', + crimeWindow: 'Averaging period', + crimeWindow7y: '7-year', + crimeWindow2y: '2-year', ethnicity: 'Ethnicity', poiType: 'POI type', party: 'Party', @@ -794,6 +805,7 @@ const en = { refiningResults: 'Refining results...', weeklyLimitReached: 'You’ve reached the weekly AI usage limit. It’ll reset automatically next week.', + generateFailed: 'Failed to generate filters', }, // ── Map Legend ───────────────────────────────────── @@ -813,6 +825,23 @@ const en = { ogSchools: 'Schools', ogCrimeStats: 'Crime stats', ogTransport: 'Transport', + basemap: { + standard: 'Map', + satellite: 'Satellite', + }, + error: { + heading: 'The map ran into a problem', + body: 'This can happen when your browser’s graphics context is interrupted.', + reload: 'Reload the map', + }, + actualListings: { + label: 'Listings', + show: 'Show actual listings', + hide: 'Hide actual listings', + }, + poi: { + zoomInToSeeDetails: 'Zoom in to see details', + }, }, // ── Properties Pane ──────────────────────────────── @@ -890,6 +919,20 @@ const en = { crimeDataEnds: 'Police data for this area ends {{year}}', residents: 'Residents', residentsTooltip: 'Usual residents (ONS Census 2021)', + crimeWindow7y: 'Last 7 years', + crimeWindow2y: 'Last 2 years', + crimeWindowLabel: 'Time period', + crimeCardTitle: '{{name}} per year', + crimeRecordsToggle: 'Show individual crimes', + crimeRecordsHide: 'Hide individual crimes', + crimeRecordsCount: '{{count}} crimes', + crimeRecordsLoading: 'Loading crimes…', + crimeRecordsEmpty: 'No individual crimes recorded', + crimeRecordsError: 'Could not load crimes', + crimeRecordsLoadMore: 'Load more', + crimeNoLocation: 'Location withheld', + crimeOutcomeUnknown: 'Outcome not recorded', + crimeYearPointTooltip: '{{year}}: {{rate}}/yr', }, // ── Street View ──────────────────────────────────── @@ -914,6 +957,7 @@ const en = { searchOn: 'Search {{radius}} on', exact: 'exact', outcodeNotRecognised: 'Outcode not recognised', + radiusMi: '{{count}} mi radius', }, // ── Location Search ──────────────────────────────── @@ -971,6 +1015,7 @@ const en = { showcaseJourneyRoutes: 'Journey routes', showcaseNearby: '{{value}} nearby', showcasePoliticalVoteShare: 'Political vote share', + showcaseGe2024: '2024 GE', showcaseLotsMore: '...and lots more', showcaseMinutes: '{{count}} min', showcaseSendShortlist: 'Send the shortlist', @@ -1395,6 +1440,8 @@ const en = { noShareLinksYet: 'No shared links yet', copyShareLink: 'Copy shared link', clicksLabel: 'clicks', + fetchShareLinksError: 'Failed to fetch share links', + updateNewsletterError: 'Failed to update newsletter', }, // ── Saved Page ───────────────────────────────────── @@ -1409,6 +1456,12 @@ const en = { deleteSearchConfirm: 'Are you sure you want to delete this saved search? This can’t be undone.', isBeingUpdated: '{{name}} is being updated', updating: 'Updating...', + loadFailed: 'Failed to load searches', + saveFailed: 'Failed to save search', + deleteFailed: 'Failed to delete search', + updateNotesFailed: 'Failed to update notes', + updateNameFailed: 'Failed to update name', + updateFailed: 'Failed to update search', }, // ── Invites Page ─────────────────────────────────── @@ -1428,6 +1481,7 @@ const en = { created: 'Created', redeemed: 'Redeemed', pending: 'Pending', + createInviteError: 'Failed to create invite', }, // ── Invite Page ──────────────────────────────────── @@ -1454,6 +1508,105 @@ const en = { youAlreadyHaveLicense: 'You already have a license', accountHasFullAccess: 'Your account already has full access.', failedToValidate: 'Failed to validate invite link', + redeemFailed: 'Failed to redeem invite', + }, + + // ── Error Boundaries ─────────────────────────────── + errors: { + appCrashTitle: 'Something went wrong', + appCrashBody: 'Refresh the page to try again.', + }, + + // ── Listing Popups ───────────────────────────────── + listing: { + viewed: 'Viewed', + openListing: 'Open listing', + listings: 'listings', + listing: 'Listing', + showingOf: 'Showing {{visible}} of {{count}}', + groupedNear: 'Grouped near this map position', + }, + + // ── Map Overlays Pane ────────────────────────────── + overlays: { + heading: 'Overlays', + baseMap: 'Base map', + colourOpacity: 'Colour opacity', + dataOverlays: 'Data overlays', + about: 'About {{name}}', + zoomWarning: 'Zoom in further to see the selected overlay.', + zoomWarning_other: 'Zoom in further to see the selected overlays.', + noise: { + label: 'Noise', + detail: + 'Defra Strategic Noise Mapping Round 4 (2022), combining road, rail, and airport sources. Values are the EU-standard Lden metric (day-evening-night 24-hour weighted average), modelled on a 10m grid at 4m above ground. Brighter areas indicate higher modelled noise. Licensed under the Open Government Licence v3.0.', + }, + crimeHotspots: { + label: 'Crime hotspots', + detail: + 'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.', + }, + treesOutsideWoodlands: { + label: 'Trees & woodland', + detail: + 'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — unioned with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.', + }, + propertyBorders: { + label: 'Property borders', + detail: + 'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are "general boundaries" for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 85–90% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.', + }, + newDevelopments: { + label: 'New developments', + detail: + 'A forward-looking pipeline of new housing. Blue markers mark sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.', + }, + }, + + // ── Crime-type overlay selector ──────────────────── + crimeTypes: { + violenceAndSexualOffences: 'Violence & sexual offences', + antiSocialBehaviour: 'Anti-social behaviour', + criminalDamageAndArson: 'Criminal damage & arson', + publicOrder: 'Public order', + shoplifting: 'Shoplifting', + vehicleCrime: 'Vehicle crime', + burglary: 'Burglary', + otherTheft: 'Other theft', + theftFromThePerson: 'Theft from the person', + bicycleTheft: 'Bicycle theft', + drugs: 'Drugs', + robbery: 'Robbery', + possessionOfWeapons: 'Possession of weapons', + otherCrime: 'Other crime', + }, + + // ── Journey Instructions ─────────────────────────── + journey: { + bus: 'Bus', + lineSuffix: 'line', + }, + + // ── POI Popups ───────────────────────────────────── + poiPopup: { + school: { + capacity: 'Capacity', + type: 'Type', + ages: 'Ages', + gender: 'Gender', + pupilsLabel: 'Pupils', + pupils: 'pupils', + freeMeal: 'Free meal', + ofsted: 'Ofsted', + sixthForm: 'Sixth form', + religion: 'Religion', + admissions: 'Admissions', + trust: 'Trust', + address: 'Address', + localAuthority: 'LA', + head: 'Head', + website: 'Website', + }, }, // ── Format / Time ────────────────────────────────── @@ -1495,6 +1648,9 @@ const en = { step6Title: 'What’s nearby?', step6Content: 'Toggle schools, shops, stations, parks, and restaurants on the map to see what’s within reach.', + step7Title: 'Layer on more context', + step7Content: + 'Toggle overlays like crime hotspots, noise, tree cover, property boundaries, and new-build sites on the map, and switch the basemap to suit.', }, // ── Server-derived values ────────────────────────── @@ -1517,11 +1673,11 @@ const en = { 'Property type': 'Property type', 'Leasehold/Freehold': 'Leasehold/Freehold', 'Last known price': 'Last known price', - 'Estimated price': 'Estimated price', - 'Estimated current price': 'Estimated current price', + 'Estimated price': 'Est. price', + 'Estimated current price': 'Est. current price', 'Price per sqm': 'Price per sqm', 'Est. price per sqm': 'Est. price per sqm', - 'Estimated monthly rent': 'Estimated monthly rent', + 'Estimated monthly rent': 'Est. monthly rent', 'Total floor area (sqm)': 'Total floor area (sqm)', 'Number of bedrooms & living rooms': 'Number of bedrooms & living rooms', 'Construction year': 'Construction year', @@ -1552,23 +1708,56 @@ const en = { 'Housing Conditions Score': 'Housing Conditions Score', 'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score', - // ─ Feature names (Crime) ─ - 'Serious crime (avg/yr)': 'Serious crime (density)', - 'Minor crime (avg/yr)': 'Minor crime (density)', - 'Violence and sexual offences (avg/yr)': 'Violence and sexual offences (density)', - 'Burglary (avg/yr)': 'Burglary (density)', - 'Robbery (avg/yr)': 'Robbery (density)', - 'Vehicle crime (avg/yr)': 'Vehicle crime (density)', - 'Anti-social behaviour (avg/yr)': 'Anti-social behaviour (density)', - 'Criminal damage and arson (avg/yr)': 'Criminal damage and arson (density)', - 'Other theft (avg/yr)': 'Other theft (density)', - 'Theft from the person (avg/yr)': 'Theft from the person (density)', - 'Shoplifting (avg/yr)': 'Shoplifting (density)', - 'Bicycle theft (avg/yr)': 'Bicycle theft (density)', - 'Drugs (avg/yr)': 'Drugs (density)', - 'Possession of weapons (avg/yr)': 'Possession of weapons (density)', - 'Public order (avg/yr)': 'Public order (density)', - 'Other crime (avg/yr)': 'Other crime (density)', + // ─ Feature names (Crime) — population-normalised rate, 7-year & 2-year ─ + 'Serious crime (/yr, 7y)': 'Serious crime (per year, 7yr)', + 'Serious crime (/yr, 2y)': 'Serious crime (per year, 2yr)', + 'Minor crime (/yr, 7y)': 'Minor crime (per year, 7yr)', + 'Minor crime (/yr, 2y)': 'Minor crime (per year, 2yr)', + 'Violence and sexual offences (/yr, 7y)': + 'Violence & sexual offences (per year, 7yr)', + 'Violence and sexual offences (/yr, 2y)': + 'Violence & sexual offences (per year, 2yr)', + 'Burglary (/yr, 7y)': 'Burglary (per year, 7yr)', + 'Burglary (/yr, 2y)': 'Burglary (per year, 2yr)', + 'Robbery (/yr, 7y)': 'Robbery (per year, 7yr)', + 'Robbery (/yr, 2y)': 'Robbery (per year, 2yr)', + 'Vehicle crime (/yr, 7y)': 'Vehicle crime (per year, 7yr)', + 'Vehicle crime (/yr, 2y)': 'Vehicle crime (per year, 2yr)', + 'Anti-social behaviour (/yr, 7y)': 'Anti-social behaviour (per year, 7yr)', + 'Anti-social behaviour (/yr, 2y)': 'Anti-social behaviour (per year, 2yr)', + 'Criminal damage and arson (/yr, 7y)': 'Criminal damage & arson (per year, 7yr)', + 'Criminal damage and arson (/yr, 2y)': 'Criminal damage & arson (per year, 2yr)', + 'Other theft (/yr, 7y)': 'Other theft (per year, 7yr)', + 'Other theft (/yr, 2y)': 'Other theft (per year, 2yr)', + 'Theft from the person (/yr, 7y)': 'Theft from the person (per year, 7yr)', + 'Theft from the person (/yr, 2y)': 'Theft from the person (per year, 2yr)', + 'Shoplifting (/yr, 7y)': 'Shoplifting (per year, 7yr)', + 'Shoplifting (/yr, 2y)': 'Shoplifting (per year, 2yr)', + 'Bicycle theft (/yr, 7y)': 'Bicycle theft (per year, 7yr)', + 'Bicycle theft (/yr, 2y)': 'Bicycle theft (per year, 2yr)', + 'Drugs (/yr, 7y)': 'Drugs (per year, 7yr)', + 'Drugs (/yr, 2y)': 'Drugs (per year, 2yr)', + 'Possession of weapons (/yr, 7y)': 'Possession of weapons (per year, 7yr)', + 'Possession of weapons (/yr, 2y)': 'Possession of weapons (per year, 2yr)', + 'Public order (/yr, 7y)': 'Public order (per year, 7yr)', + 'Public order (/yr, 2y)': 'Public order (per year, 2yr)', + 'Other crime (/yr, 7y)': 'Other crime (per year, 7yr)', + 'Other crime (/yr, 2y)': 'Other crime (per year, 2yr)', + // Bare crime-type labels (per-type breakdown rows + records list). + // ("Burglary" is already defined in the specific-crime overflow keys below.) + 'Violence and sexual offences': 'Violence & sexual offences', + Robbery: 'Robbery', + 'Vehicle crime': 'Vehicle crime', + 'Anti-social behaviour': 'Anti-social behaviour', + 'Criminal damage and arson': 'Criminal damage & arson', + 'Other theft': 'Other theft', + 'Theft from the person': 'Theft from the person', + Shoplifting: 'Shoplifting', + 'Bicycle theft': 'Bicycle theft', + Drugs: 'Drugs', + 'Possession of weapons': 'Possession of weapons', + 'Public order': 'Public order', + 'Other crime': 'Other crime', // ─ Feature names (Neighbours) ─ 'Median age': 'Median age', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 4a0d12b..fa9375c 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -58,6 +58,8 @@ const fr: Translations = { closePane: 'Fermer le panneau', yes: 'Oui', no: 'Non', + captions: 'Légendes', + sqm: 'm²', }, // ── Header / Nav ─────────────────────────────────── @@ -635,6 +637,10 @@ const fr: Translations = { backToLogin: 'Retour à la connexion', registerConsent: 'En créant un compte, vous acceptez nos Conditions d’utilisation et notre Politique de confidentialité.', + loginFailed: 'Échec de la connexion', + registrationFailed: 'Échec de l’inscription', + oauthFailed: 'Échec de la connexion OAuth', + passwordResetFailed: 'Échec de la demande de réinitialisation du mot de passe', }, // ── Upgrade Modal ────────────────────────────────── @@ -732,6 +738,11 @@ const fr: Translations = { outstanding: 'Outstanding', distance: 'Distance', crimeType: 'Type d’infraction', + qualificationLevel: 'Niveau de qualification', + tenureType: 'Statut d’occupation', + crimeWindow: 'Période', + crimeWindow7y: '7 ans', + crimeWindow2y: '2 ans', ethnicity: 'Origine ethnique', poiType: 'Type de point d’intérêt', party: 'Parti', @@ -822,6 +833,7 @@ const fr: Translations = { refiningResults: 'Affinage des résultats...', weeklyLimitReached: 'Vous avez atteint la limite hebdomadaire d’utilisation de l’IA. Elle se réinitialisera automatiquement la semaine prochaine.', + generateFailed: 'Échec de la génération des filtres', }, // ── Map Legend ───────────────────────────────────── @@ -841,6 +853,18 @@ const fr: Translations = { ogSchools: 'Écoles', ogCrimeStats: 'Criminalité', ogTransport: 'Transports', + basemap: { standard: 'Carte', satellite: 'Satellite' }, + error: { + heading: 'La carte a rencontré un problème', + body: 'Cela peut se produire lorsque le contexte graphique de votre navigateur est interrompu.', + reload: 'Recharger la carte', + }, + actualListings: { + label: 'Annonces', + show: 'Afficher les annonces réelles', + hide: 'Masquer les annonces réelles', + }, + poi: { zoomInToSeeDetails: 'Zoomez pour voir les détails' }, }, // ── Properties Pane ──────────────────────────────── @@ -919,6 +943,20 @@ const fr: Translations = { crimeDataEnds: "Les données de police pour cette zone s'arrêtent en {{year}}", residents: 'Habitants', residentsTooltip: 'Résidents habituels (recensement ONS 2021)', + crimeWindow7y: 'Ces 7 dernières années', + crimeWindow2y: 'Ces 2 dernières années', + crimeWindowLabel: 'Période', + crimeCardTitle: '{{name}} par an', + crimeRecordsToggle: 'Afficher les infractions individuelles', + crimeRecordsHide: 'Masquer les infractions individuelles', + crimeRecordsCount: '{{count}} infractions', + crimeRecordsLoading: 'Chargement des infractions…', + crimeRecordsEmpty: 'Aucune infraction individuelle enregistrée', + crimeRecordsError: 'Impossible de charger les infractions', + crimeRecordsLoadMore: 'Charger plus', + crimeNoLocation: 'Lieu non communiqué', + crimeOutcomeUnknown: 'Résultat non enregistré', + crimeYearPointTooltip: '{{year}} : {{rate}}/an', }, // ── Street View ──────────────────────────────────── @@ -943,6 +981,7 @@ const fr: Translations = { searchOn: 'Rechercher dans un rayon de {{radius}} sur', exact: 'exact', outcodeNotRecognised: 'Code postal non reconnu', + radiusMi: 'rayon de {{count}} mi', }, // ── Location Search ──────────────────────────────── @@ -999,6 +1038,7 @@ const fr: Translations = { showcaseJourneyRoutes: 'Itinéraires', showcaseNearby: '{{value}} à proximité', showcasePoliticalVoteShare: 'Répartition des voix', + showcaseGe2024: 'Législatives 2024', showcaseLotsMore: '...et bien plus', showcaseMinutes: '{{count}} min', showcaseSendShortlist: 'Envoyer la sélection', @@ -1435,6 +1475,8 @@ const fr: Translations = { noShareLinksYet: 'Aucun lien partagé pour l’instant', copyShareLink: 'Copier le lien partagé', clicksLabel: 'clics', + fetchShareLinksError: 'Échec de la récupération des liens partagés', + updateNewsletterError: 'Échec de la mise à jour de la newsletter', }, // ── Saved Page ───────────────────────────────────── @@ -1450,6 +1492,12 @@ const fr: Translations = { 'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.', isBeingUpdated: 'Mise à jour de {{name}}', updating: 'Mise à jour...', + loadFailed: 'Échec du chargement des recherches', + saveFailed: 'Échec de l’enregistrement de la recherche', + deleteFailed: 'Échec de la suppression de la recherche', + updateNotesFailed: 'Échec de la mise à jour des notes', + updateNameFailed: 'Échec de la mise à jour du nom', + updateFailed: 'Échec de la mise à jour de la recherche', }, // ── Invites Page ─────────────────────────────────── @@ -1470,6 +1518,7 @@ const fr: Translations = { created: 'Créé', redeemed: 'Utilisé', pending: 'En attente', + createInviteError: 'Échec de la création de l’invitation', }, // ── Invite Page ──────────────────────────────────── @@ -1496,6 +1545,105 @@ const fr: Translations = { youAlreadyHaveLicense: 'Vous avez déjà une licence', accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.', failedToValidate: 'Échec de la validation du lien d’invitation', + redeemFailed: 'Échec de l’utilisation de l’invitation', + }, + + // ── Errors ───────────────────────────────────────── + errors: { + appCrashTitle: 'Une erreur est survenue', + appCrashBody: 'Actualisez la page pour réessayer.', + }, + + // ── Listing ──────────────────────────────────────── + listing: { + viewed: 'Consulté', + openListing: 'Ouvrir l’annonce', + listings: 'annonces', + listing: 'Annonce', + showingOf: 'Affichage de {{visible}} sur {{count}}', + groupedNear: 'Regroupées près de cette position sur la carte', + }, + + // ── Overlays ─────────────────────────────────────── + overlays: { + heading: 'Calques', + baseMap: 'Fond de carte', + colourOpacity: 'Opacité des couleurs', + dataOverlays: 'Calques de données', + about: 'À propos de {{name}}', + zoomWarning: 'Zoomez davantage pour voir le calque sélectionné.', + zoomWarning_other: 'Zoomez davantage pour voir les calques sélectionnés.', + noise: { + label: 'Bruit', + detail: + 'Cartographie stratégique du bruit Defra, 4ᵉ cycle (2022), combinant les sources routières, ferroviaires et aéroportuaires. Les valeurs correspondent à l’indicateur Lden standard de l’EU (moyenne pondérée sur 24 heures jour-soir-nuit), modélisé sur une grille de 10 m à 4 m au-dessus du sol. Les zones plus claires indiquent un bruit modélisé plus élevé. Sous licence Open Government Licence v3.0.', + }, + crimeHotspots: { + label: 'Points chauds de criminalité', + detail: + 'Carte de chaleur générée côté client des délits de proximité publiés par police.uk au cours des mois les plus récents. Les coordonnées de police.uk sont des points anonymisés calés sur une grille, et non les lieux exacts des infractions ; la carte de chaleur doit donc être lue comme une approximation de la densité relative plutôt que comme une carte précise des incidents.', + }, + treesOutsideWoodlands: { + label: 'Arbres et boisements', + detail: + 'Polygones de canopée Forest Research Trees Outside Woodland (TOW) v1 — arbres isolés et groupes d’arbres — fusionnés avec les blocs boisés du National Forest Inventory (NFI) (≥0,5 ha) que TOW exclut délibérément. Ensemble, ils couvrent à la fois les arbres de rue et les véritables bois. L’opacité des polygones varie selon la surface de canopée.', + }, + propertyBorders: { + label: 'Limites de propriété', + detail: + 'INSPIRE Index Polygons du HM Land Registry — la position et l’étendue indicative des biens en pleine propriété (freehold) enregistrés en Angleterre et au pays de Galles, tracés en contours au niveau de la rue. Il s’agit de « limites générales » fournies à titre indicatif uniquement, et non de la limite légale précise d’un bien ; elles excluent les intérêts uniquement en location longue durée (leasehold) et les terrains non enregistrés (environ 85 à 90 % des terrains en pleine propriété sont couverts). Ces informations sont soumises au Crown copyright et aux droits sur les bases de données 2026 et sont reproduites avec l’autorisation du HM Land Registry. Les polygones (y compris la géométrie associée, à savoir les coordonnées x, y) sont soumis au Crown copyright et aux droits sur les bases de données 2026 Ordnance Survey AC0000851063. Sous licence Open Government Licence v3.0.', + }, + newDevelopments: { + label: 'Nouveaux développements', + detail: + 'Un pipeline prospectif de nouveaux logements. Les repères bleus signalent les sites des registres statutaires des terrains en friche (Brownfield Land) du MHCLG — chacun assorti d’une capacité nette estimée en logements et d’un statut de permis de construire — ainsi que les sites de cession du Homes England Land Hub. Ils indiquent où de nouveaux logements sont prévus, souvent des années avant qu’ils n’apparaissent dans les registres EPC ou de ventes. Les chiffres de logements sont des estimations de capacité, et non des engagements, et un site inscrit à un registre est une opportunité plutôt qu’une garantie de construction. Sous licence Open Government Licence v3.0.', + }, + }, + + // ── Crime Types ──────────────────────────────────── + crimeTypes: { + violenceAndSexualOffences: 'Violences et infractions sexuelles', + antiSocialBehaviour: 'Comportement antisocial', + criminalDamageAndArson: 'Dégradations et incendies volontaires', + publicOrder: 'Atteintes à l’ordre public', + shoplifting: 'Vol à l’étalage', + vehicleCrime: 'Délits liés aux véhicules', + burglary: 'Cambriolage', + otherTheft: 'Autres vols', + theftFromThePerson: 'Vol à la tire', + bicycleTheft: 'Vol de vélo', + drugs: 'Stupéfiants', + robbery: 'Vol avec violence', + possessionOfWeapons: 'Port d’armes', + otherCrime: 'Autres délits', + }, + + // ── Journey ──────────────────────────────────────── + journey: { + bus: 'Bus', + lineSuffix: 'ligne', + }, + + // ── POI Popup ────────────────────────────────────── + poiPopup: { + school: { + capacity: 'Capacité', + type: 'Type', + ages: 'Âges', + gender: 'Mixité', + pupilsLabel: 'Élèves', + pupils: 'élèves', + freeMeal: 'Repas gratuit', + ofsted: 'Ofsted', + sixthForm: 'Lycée (sixth form)', + religion: 'Religion', + admissions: 'Admissions', + trust: 'Trust', + address: 'Adresse', + localAuthority: 'LA', + head: 'Directeur', + website: 'Site web', + }, }, // ── Format / Time ────────────────────────────────── @@ -1538,6 +1686,9 @@ const fr: Translations = { step6Title: 'Qu’y a-t-il à proximité ?', step6Content: 'Activez les écoles, commerces, gares, parcs et restaurants sur la carte pour voir ce qui est à portée.', + step7Title: 'Ajoutez du contexte', + step7Content: + 'Activez des calques comme les points chauds de criminalité, le bruit, la couverture arborée, les limites de propriété et les nouveaux programmes immobiliers, et changez de fond de carte selon vos besoins.', }, // ── Server-derived values ────────────────────────── @@ -1595,23 +1746,70 @@ const fr: Translations = { 'Housing Conditions Score': 'Score des conditions de logement', 'Air Quality and Road Safety Score': 'Score qualité de l’air et sécurité routière', - // ─ Feature names (Crime) ─ - 'Serious crime (avg/yr)': 'Infractions graves (densité)', - 'Minor crime (avg/yr)': 'Infractions mineures (densité)', - 'Violence and sexual offences (avg/yr)': 'Violences et infractions sexuelles (densité)', - 'Burglary (avg/yr)': 'Cambriolages (densité)', - 'Robbery (avg/yr)': 'Vols avec violence (densité)', - 'Vehicle crime (avg/yr)': 'Infractions liées aux véhicules (densité)', - 'Anti-social behaviour (avg/yr)': 'Comportements antisociaux (densité)', - 'Criminal damage and arson (avg/yr)': 'Dégradations et incendies criminels (densité)', - 'Other theft (avg/yr)': 'Autres vols (densité)', - 'Theft from the person (avg/yr)': 'Vols à la personne (densité)', - 'Shoplifting (avg/yr)': 'Vols à l’étalage (densité)', - 'Bicycle theft (avg/yr)': 'Vols de vélos (densité)', - 'Drugs (avg/yr)': 'Infractions liées aux stupéfiants (densité)', - 'Possession of weapons (avg/yr)': 'Possession d’armes (densité)', - 'Public order (avg/yr)': 'Troubles à l’ordre public (densité)', - 'Other crime (avg/yr)': 'Autres infractions (densité)', + // ─ Feature names (Crime) — taux normalisé par population, 7 ans & 2 ans ─ + 'Serious crime (/yr, 7y)': 'Infractions graves (par an, 7 ans)', + 'Serious crime (/yr, 2y)': 'Infractions graves (par an, 2 ans)', + 'Minor crime (/yr, 7y)': 'Infractions mineures (par an, 7 ans)', + 'Minor crime (/yr, 2y)': 'Infractions mineures (par an, 2 ans)', + 'Violence and sexual offences (/yr, 7y)': + 'Violences et infractions sexuelles (par an, 7 ans)', + 'Violence and sexual offences (/yr, 2y)': + 'Violences et infractions sexuelles (par an, 2 ans)', + 'Burglary (/yr, 7y)': 'Cambriolages (par an, 7 ans)', + 'Burglary (/yr, 2y)': 'Cambriolages (par an, 2 ans)', + 'Robbery (/yr, 7y)': 'Vols avec violence (par an, 7 ans)', + 'Robbery (/yr, 2y)': 'Vols avec violence (par an, 2 ans)', + 'Vehicle crime (/yr, 7y)': + 'Infractions liées aux véhicules (par an, 7 ans)', + 'Vehicle crime (/yr, 2y)': + 'Infractions liées aux véhicules (par an, 2 ans)', + 'Anti-social behaviour (/yr, 7y)': + 'Comportements antisociaux (par an, 7 ans)', + 'Anti-social behaviour (/yr, 2y)': + 'Comportements antisociaux (par an, 2 ans)', + 'Criminal damage and arson (/yr, 7y)': + 'Dégradations et incendies criminels (par an, 7 ans)', + 'Criminal damage and arson (/yr, 2y)': + 'Dégradations et incendies criminels (par an, 2 ans)', + 'Other theft (/yr, 7y)': 'Autres vols (par an, 7 ans)', + 'Other theft (/yr, 2y)': 'Autres vols (par an, 2 ans)', + 'Theft from the person (/yr, 7y)': + 'Vols à la personne (par an, 7 ans)', + 'Theft from the person (/yr, 2y)': + 'Vols à la personne (par an, 2 ans)', + 'Shoplifting (/yr, 7y)': 'Vols à l’étalage (par an, 7 ans)', + 'Shoplifting (/yr, 2y)': 'Vols à l’étalage (par an, 2 ans)', + 'Bicycle theft (/yr, 7y)': 'Vols de vélos (par an, 7 ans)', + 'Bicycle theft (/yr, 2y)': 'Vols de vélos (par an, 2 ans)', + 'Drugs (/yr, 7y)': + 'Infractions liées aux stupéfiants (par an, 7 ans)', + 'Drugs (/yr, 2y)': + 'Infractions liées aux stupéfiants (par an, 2 ans)', + 'Possession of weapons (/yr, 7y)': + 'Possession d’armes (par an, 7 ans)', + 'Possession of weapons (/yr, 2y)': + 'Possession d’armes (par an, 2 ans)', + 'Public order (/yr, 7y)': + 'Troubles à l’ordre public (par an, 7 ans)', + 'Public order (/yr, 2y)': + 'Troubles à l’ordre public (par an, 2 ans)', + 'Other crime (/yr, 7y)': 'Autres infractions (par an, 7 ans)', + 'Other crime (/yr, 2y)': 'Autres infractions (par an, 2 ans)', + // Libellés de type d’infraction seuls (ventilation par type + liste des enregistrements). + // (« Burglary » est déjà défini parmi les infractions spécifiques ci-dessous.) + 'Violence and sexual offences': 'Violences et infractions sexuelles', + Robbery: 'Vols avec violence', + 'Vehicle crime': 'Infractions liées aux véhicules', + 'Anti-social behaviour': 'Comportements antisociaux', + 'Criminal damage and arson': 'Dégradations et incendies criminels', + 'Other theft': 'Autres vols', + 'Theft from the person': 'Vols à la personne', + Shoplifting: 'Vols à l’étalage', + 'Bicycle theft': 'Vols de vélos', + Drugs: 'Infractions liées aux stupéfiants', + 'Possession of weapons': 'Possession d’armes', + 'Public order': 'Troubles à l’ordre public', + 'Other crime': 'Autres infractions', // ─ Feature names (Neighbours) ─ 'Median age': 'Âge médian', diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index 1b40e0c..a31d467 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -57,6 +57,8 @@ const hi: Translations = { closePane: 'पैन बंद करें', yes: 'हाँ', no: 'नहीं', + captions: 'कैप्शन', + sqm: 'वर्ग मीटर', }, header: { @@ -608,6 +610,10 @@ const hi: Translations = { backToLogin: 'लॉग इन पर वापस जाएं', registerConsent: 'खाता बनाकर आप हमारी सेवा की शर्तों और गोपनीयता नीति से सहमत होते हैं.', + loginFailed: 'लॉग इन विफल रहा', + registrationFailed: 'पंजीकरण विफल रहा', + oauthFailed: 'OAuth लॉग इन विफल रहा', + passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा', }, upgrade: { @@ -699,6 +705,11 @@ const hi: Translations = { outstanding: 'उत्कृष्ट', distance: 'दूरी', crimeType: 'अपराध प्रकार', + qualificationLevel: 'योग्यता स्तर', + tenureType: 'आवास स्वामित्व', + crimeWindow: 'अवधि', + crimeWindow7y: '7 वर्ष', + crimeWindow2y: '2 वर्ष', ethnicity: 'जातीय समूह', poiType: 'रुचि-स्थल प्रकार', party: 'पार्टी', @@ -785,6 +796,7 @@ const hi: Translations = { refiningResults: 'परिणाम सुधारे जा रहे हैं...', weeklyLimitReached: 'आप साप्ताहिक AI उपयोग सीमा तक पहुंच गए हैं. यह अगले सप्ताह अपने आप फिर से शुरू हो जाएगी.', + generateFailed: 'फ़िल्टर बनाने में विफल', }, mapLegend: { @@ -802,6 +814,18 @@ const hi: Translations = { ogSchools: 'स्कूल', ogCrimeStats: 'अपराध आंकड़े', ogTransport: 'परिवहन', + basemap: { standard: 'मैप', satellite: 'सैटेलाइट' }, + error: { + heading: 'मैप में एक समस्या आ गई', + body: 'ऐसा तब हो सकता है जब आपके ब्राउज़र का ग्राफिक्स संदर्भ बाधित हो जाए.', + reload: 'मैप फिर से लोड करें', + }, + actualListings: { + label: 'लिस्टिंग', + show: 'वास्तविक लिस्टिंग दिखाएं', + hide: 'वास्तविक लिस्टिंग छिपाएं', + }, + poi: { zoomInToSeeDetails: 'विवरण देखने के लिए ज़ूम इन करें' }, }, propertyCard: { @@ -878,6 +902,20 @@ const hi: Translations = { crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है', residents: 'निवासी', residentsTooltip: 'सामान्य निवासी (ONS जनगणना 2021)', + crimeWindow7y: 'पिछले 7 वर्ष', + crimeWindow2y: 'पिछले 2 वर्ष', + crimeWindowLabel: 'समयावधि', + crimeCardTitle: '{{name}} प्रति वर्ष', + crimeRecordsToggle: 'अलग-अलग अपराध दिखाएँ', + crimeRecordsHide: 'अलग-अलग अपराध छिपाएँ', + crimeRecordsCount: '{{count}} अपराध', + crimeRecordsLoading: 'अपराध लोड हो रहे हैं…', + crimeRecordsEmpty: 'कोई अलग-अलग अपराध दर्ज नहीं', + crimeRecordsError: 'अपराध लोड नहीं हो सके', + crimeRecordsLoadMore: 'और लोड करें', + crimeNoLocation: 'स्थान नहीं बताया गया', + crimeOutcomeUnknown: 'परिणाम दर्ज नहीं', + crimeYearPointTooltip: '{{year}}: {{rate}}/वर्ष', }, streetView: { @@ -899,6 +937,7 @@ const hi: Translations = { searchOn: 'इन पर {{radius}} खोजें:', exact: 'सटीक', outcodeNotRecognised: 'आउटकोड पहचाना नहीं गया', + radiusMi: '{{count}} मील की त्रिज्या', }, locationSearch: { @@ -952,6 +991,7 @@ const hi: Translations = { showcaseJourneyRoutes: 'यात्रा मार्ग', showcaseNearby: '{{value}} पास में', showcasePoliticalVoteShare: 'राजनीतिक वोट हिस्सेदारी', + showcaseGe2024: '2024 आम चुनाव', showcaseLotsMore: '...और भी बहुत कुछ', showcaseMinutes: '{{count}} मिनट', showcaseSendShortlist: 'शॉर्टलिस्ट भेजें', @@ -1359,6 +1399,8 @@ const hi: Translations = { noShareLinksYet: 'अभी कोई साझा लिंक नहीं', copyShareLink: 'साझा लिंक कॉपी करें', clicksLabel: 'क्लिक', + fetchShareLinksError: 'साझा लिंक प्राप्त करने में विफल', + updateNewsletterError: 'न्यूज़लेटर अपडेट करने में विफल', }, savedPage: { @@ -1373,6 +1415,12 @@ const hi: Translations = { 'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.', isBeingUpdated: '{{name}} अपडेट हो रहा है', updating: 'अपडेट हो रहा है...', + loadFailed: 'खोजें लोड करने में विफल', + saveFailed: 'खोज सहेजने में विफल', + deleteFailed: 'खोज हटाने में विफल', + updateNotesFailed: 'नोट्स अपडेट करने में विफल', + updateNameFailed: 'नाम अपडेट करने में विफल', + updateFailed: 'खोज अपडेट करने में विफल', }, invitesPage: { @@ -1391,6 +1439,7 @@ const hi: Translations = { created: 'बनाया गया', redeemed: 'भुनाया गया', pending: 'लंबित', + createInviteError: 'आमंत्रण बनाने में विफल', }, invitePage: { @@ -1416,6 +1465,99 @@ const hi: Translations = { youAlreadyHaveLicense: 'आपके पास पहले से लाइसेंस है', accountHasFullAccess: 'आपके खाते में पहले से पूरी पहुँच है.', failedToValidate: 'आमंत्रण लिंक सत्यापित नहीं हो सका', + redeemFailed: 'आमंत्रण भुनाने में विफल', + }, + + errors: { + appCrashTitle: 'कुछ गलत हो गया', + appCrashBody: 'फिर से प्रयास करने के लिए पेज रीफ्रेश करें.', + }, + + listing: { + viewed: 'देखा गया', + openListing: 'लिस्टिंग खोलें', + listings: 'लिस्टिंग', + listing: 'लिस्टिंग', + showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं', + groupedNear: 'इस मैप स्थिति के पास समूहीकृत', + }, + + overlays: { + heading: 'ओवरले', + baseMap: 'बेस मैप', + colourOpacity: 'रंग अपारदर्शिता', + dataOverlays: 'डेटा ओवरले', + about: '{{name}} के बारे में', + zoomWarning: 'चुने गए ओवरले को देखने के लिए और ज़ूम इन करें.', + zoomWarning_other: 'चुने गए ओवरले देखने के लिए और ज़ूम इन करें.', + noise: { + label: 'शोर', + detail: + 'Defra Strategic Noise Mapping Round 4 (2022), जिसमें सड़क, रेल और हवाई अड्डे के स्रोत शामिल हैं. मान EU-मानक Lden मीट्रिक (दिन-शाम-रात 24-घंटे भारित औसत) हैं, जो ज़मीन से 4 मीटर ऊपर 10 मीटर ग्रिड पर मॉडल किए गए हैं. अधिक चमकीले क्षेत्र अधिक मॉडल किए गए शोर को दर्शाते हैं. Open Government Licence v3.0 के तहत लाइसेंस प्राप्त.', + }, + crimeHotspots: { + label: 'अपराध हॉटस्पॉट', + detail: + 'police.uk द्वारा सबसे हाल के महीनों में प्रकाशित सड़क-स्तरीय अपराधों का क्लाइंट-साइड हीटमैप. Police.uk के निर्देशांक गुमनाम स्नैप-टू-ग्रिड बिंदु हैं, अपराध के सटीक स्थान नहीं, इसलिए हीटमैप को घटनाओं के सटीक मानचित्र के बजाय सापेक्ष घनत्व के अनुमान के रूप में पढ़ा जाना चाहिए.', + }, + treesOutsideWoodlands: { + label: 'पेड़ और वुडलैंड', + detail: + 'Forest Research Trees Outside Woodland (TOW) v1 कैनोपी पॉलीगॉन — अकेले पेड़ और पेड़ों के समूह — National Forest Inventory (NFI) वुडलैंड ब्लॉक्स (≥0.5 हेक्टेयर) के साथ जोड़े गए हैं जिन्हें TOW जानबूझकर बाहर रखता है. साथ में ये सड़क के पेड़ और असली जंगल दोनों को कवर करते हैं. पॉलीगॉन की अपारदर्शिता कैनोपी क्षेत्र के अनुसार बढ़ती है.', + }, + propertyBorders: { + label: 'संपत्ति सीमाएं', + detail: + 'HM Land Registry INSPIRE Index Polygons — इंग्लैंड और वेल्स में फ्रीहोल्ड पंजीकृत संपत्ति की स्थिति और संकेतात्मक विस्तार, सड़क स्तर पर रूपरेखा के रूप में बनाई गई. ये केवल मार्गदर्शन के लिए "सामान्य सीमाएं" हैं, किसी संपत्ति की सटीक कानूनी सीमा नहीं, और इनमें केवल-लीजहोल्ड हित और अपंजीकृत भूमि शामिल नहीं हैं (लगभग 85–90% फ्रीहोल्ड भूमि कवर है). यह जानकारी Crown copyright और डेटाबेस अधिकार 2026 के अधीन है और HM Land Registry की अनुमति से पुनरुत्पादित की गई है. पॉलीगॉन (संबंधित ज्यामिति सहित, अर्थात् x, y निर्देशांक) Crown copyright और डेटाबेस अधिकार 2026 Ordnance Survey AC0000851063 के अधीन हैं. Open Government Licence v3.0 के तहत लाइसेंस प्राप्त.', + }, + newDevelopments: { + label: 'नए विकास', + detail: + 'नए आवास की एक भविष्योन्मुखी पाइपलाइन. नीले मार्कर वैधानिक MHCLG Brownfield Land registers पर साइटों को चिह्नित करते हैं — प्रत्येक में अनुमानित शुद्ध-आवास क्षमता और योजना-अनुमति स्थिति होती है — साथ ही Homes England Land Hub निपटान साइटें. ये दर्शाते हैं कि नए घर कहां योजनाबद्ध हैं, अक्सर EPC या बिक्री रिकॉर्ड में दिखाई देने से वर्षों पहले. आवास आंकड़े क्षमता अनुमान हैं, प्रतिबद्धताएं नहीं, और किसी रजिस्टर पर साइट निर्माण की गारंटी के बजाय एक अवसर है. Open Government Licence v3.0 के तहत लाइसेंस प्राप्त.', + }, + }, + + crimeTypes: { + violenceAndSexualOffences: 'हिंसा और यौन अपराध', + antiSocialBehaviour: 'असामाजिक व्यवहार', + criminalDamageAndArson: 'आपराधिक क्षति और आगजनी', + publicOrder: 'सार्वजनिक व्यवस्था', + shoplifting: 'दुकान से चोरी', + vehicleCrime: 'वाहन अपराध', + burglary: 'सेंधमारी', + otherTheft: 'अन्य चोरी', + theftFromThePerson: 'व्यक्ति से चोरी', + bicycleTheft: 'साइकिल चोरी', + drugs: 'नशीले पदार्थ', + robbery: 'डकैती', + possessionOfWeapons: 'हथियार रखना', + otherCrime: 'अन्य अपराध', + }, + + journey: { + bus: 'बस', + lineSuffix: 'लाइन', + }, + + poiPopup: { + school: { + capacity: 'क्षमता', + type: 'प्रकार', + ages: 'आयु', + gender: 'लिंग', + pupilsLabel: 'विद्यार्थी', + pupils: 'विद्यार्थी', + freeMeal: 'मुफ्त भोजन', + ofsted: 'Ofsted', + sixthForm: 'सिक्स्थ फॉर्म', + religion: 'धर्म', + admissions: 'प्रवेश', + trust: 'ट्रस्ट', + address: 'पता', + localAuthority: 'LA', + head: 'प्रधानाध्यापक', + website: 'वेबसाइट', + }, }, format: { @@ -1455,6 +1597,9 @@ const hi: Translations = { step6Title: 'पास में क्या है?', step6Content: 'मानचित्र पर स्कूल, दुकानें, स्टेशन, पार्क और रेस्तरां चालू करें और देखें क्या पहुंच में है.', + step7Title: 'और संदर्भ जोड़ें', + step7Content: + 'मानचित्र पर अपराध हॉटस्पॉट, शोर, वृक्षावरण, संपत्ति सीमाएं और नई आवासीय परियोजनाओं जैसी ओवरले परतें चालू करें और जरूरत के अनुसार आधार मानचित्र बदलें.', }, server: { @@ -1501,22 +1646,59 @@ const hi: Translations = { 'Health Deprivation and Disability Score': 'स्वास्थ्य वंचना और विकलांगता स्कोर', 'Housing Conditions Score': 'आवास स्थिति स्कोर', 'Air Quality and Road Safety Score': 'हवा की गुणवत्ता और सड़क सुरक्षा स्कोर', - 'Serious crime (avg/yr)': 'गंभीर अपराध (घनत्व)', - 'Minor crime (avg/yr)': 'मामूली अपराध (घनत्व)', - 'Violence and sexual offences (avg/yr)': 'हिंसा और यौन अपराध (घनत्व)', - 'Burglary (avg/yr)': 'सेंधमारी (घनत्व)', - 'Robbery (avg/yr)': 'लूट (घनत्व)', - 'Vehicle crime (avg/yr)': 'वाहन अपराध (घनत्व)', - 'Anti-social behaviour (avg/yr)': 'असामाजिक व्यवहार (घनत्व)', - 'Criminal damage and arson (avg/yr)': 'आपराधिक क्षति और आगजनी (घनत्व)', - 'Other theft (avg/yr)': 'अन्य चोरी (घनत्व)', - 'Theft from the person (avg/yr)': 'व्यक्ति से चोरी (घनत्व)', - 'Shoplifting (avg/yr)': 'दुकान से चोरी (घनत्व)', - 'Bicycle theft (avg/yr)': 'साइकिल चोरी (घनत्व)', - 'Drugs (avg/yr)': 'ड्रग्स (घनत्व)', - 'Possession of weapons (avg/yr)': 'हथियार रखने के अपराध (घनत्व)', - 'Public order (avg/yr)': 'सार्वजनिक व्यवस्था अपराध (घनत्व)', - 'Other crime (avg/yr)': 'अन्य अपराध (घनत्व)', + 'Serious crime (/yr, 7y)': 'गंभीर अपराध (प्रति वर्ष, 7 वर्ष)', + 'Serious crime (/yr, 2y)': 'गंभीर अपराध (प्रति वर्ष, 2 वर्ष)', + 'Minor crime (/yr, 7y)': 'मामूली अपराध (प्रति वर्ष, 7 वर्ष)', + 'Minor crime (/yr, 2y)': 'मामूली अपराध (प्रति वर्ष, 2 वर्ष)', + 'Violence and sexual offences (/yr, 7y)': + 'हिंसा और यौन अपराध (प्रति वर्ष, 7 वर्ष)', + 'Violence and sexual offences (/yr, 2y)': + 'हिंसा और यौन अपराध (प्रति वर्ष, 2 वर्ष)', + 'Burglary (/yr, 7y)': 'सेंधमारी (प्रति वर्ष, 7 वर्ष)', + 'Burglary (/yr, 2y)': 'सेंधमारी (प्रति वर्ष, 2 वर्ष)', + 'Robbery (/yr, 7y)': 'लूट (प्रति वर्ष, 7 वर्ष)', + 'Robbery (/yr, 2y)': 'लूट (प्रति वर्ष, 2 वर्ष)', + 'Vehicle crime (/yr, 7y)': 'वाहन अपराध (प्रति वर्ष, 7 वर्ष)', + 'Vehicle crime (/yr, 2y)': 'वाहन अपराध (प्रति वर्ष, 2 वर्ष)', + 'Anti-social behaviour (/yr, 7y)': 'असामाजिक व्यवहार (प्रति वर्ष, 7 वर्ष)', + 'Anti-social behaviour (/yr, 2y)': 'असामाजिक व्यवहार (प्रति वर्ष, 2 वर्ष)', + 'Criminal damage and arson (/yr, 7y)': + 'आपराधिक क्षति और आगजनी (प्रति वर्ष, 7 वर्ष)', + 'Criminal damage and arson (/yr, 2y)': + 'आपराधिक क्षति और आगजनी (प्रति वर्ष, 2 वर्ष)', + 'Other theft (/yr, 7y)': 'अन्य चोरी (प्रति वर्ष, 7 वर्ष)', + 'Other theft (/yr, 2y)': 'अन्य चोरी (प्रति वर्ष, 2 वर्ष)', + 'Theft from the person (/yr, 7y)': 'व्यक्ति से चोरी (प्रति वर्ष, 7 वर्ष)', + 'Theft from the person (/yr, 2y)': 'व्यक्ति से चोरी (प्रति वर्ष, 2 वर्ष)', + 'Shoplifting (/yr, 7y)': 'दुकान से चोरी (प्रति वर्ष, 7 वर्ष)', + 'Shoplifting (/yr, 2y)': 'दुकान से चोरी (प्रति वर्ष, 2 वर्ष)', + 'Bicycle theft (/yr, 7y)': 'साइकिल चोरी (प्रति वर्ष, 7 वर्ष)', + 'Bicycle theft (/yr, 2y)': 'साइकिल चोरी (प्रति वर्ष, 2 वर्ष)', + 'Drugs (/yr, 7y)': 'ड्रग्स (प्रति वर्ष, 7 वर्ष)', + 'Drugs (/yr, 2y)': 'ड्रग्स (प्रति वर्ष, 2 वर्ष)', + 'Possession of weapons (/yr, 7y)': + 'हथियार रखने के अपराध (प्रति वर्ष, 7 वर्ष)', + 'Possession of weapons (/yr, 2y)': + 'हथियार रखने के अपराध (प्रति वर्ष, 2 वर्ष)', + 'Public order (/yr, 7y)': 'सार्वजनिक व्यवस्था अपराध (प्रति वर्ष, 7 वर्ष)', + 'Public order (/yr, 2y)': 'सार्वजनिक व्यवस्था अपराध (प्रति वर्ष, 2 वर्ष)', + 'Other crime (/yr, 7y)': 'अन्य अपराध (प्रति वर्ष, 7 वर्ष)', + 'Other crime (/yr, 2y)': 'अन्य अपराध (प्रति वर्ष, 2 वर्ष)', + // केवल अपराध-प्रकार के लेबल (प्रकार-वार विवरण + रिकॉर्ड सूची)। + // ("Burglary" पहले से ही नीचे विशिष्ट अपराधों में परिभाषित है।) + 'Violence and sexual offences': 'हिंसा और यौन अपराध', + Robbery: 'लूट', + 'Vehicle crime': 'वाहन अपराध', + 'Anti-social behaviour': 'असामाजिक व्यवहार', + 'Criminal damage and arson': 'आपराधिक क्षति और आगजनी', + 'Other theft': 'अन्य चोरी', + 'Theft from the person': 'व्यक्ति से चोरी', + Shoplifting: 'दुकान से चोरी', + 'Bicycle theft': 'साइकिल चोरी', + Drugs: 'ड्रग्स', + 'Possession of weapons': 'हथियार रखने के अपराध', + 'Public order': 'सार्वजनिक व्यवस्था अपराध', + 'Other crime': 'अन्य अपराध', 'Median age': 'मध्य आयु', '% No qualifications': '% कोई योग्यता नहीं', '% Some GCSEs': '% कुछ GCSE', diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts index 106b94c..12a5d85 100644 --- a/frontend/src/i18n/locales/hu.ts +++ b/frontend/src/i18n/locales/hu.ts @@ -58,6 +58,8 @@ const hu: Translations = { closePane: 'Panel bezárása', yes: 'Igen', no: 'Nem', + captions: 'Feliratok', + sqm: 'm²', }, // ── Header / Nav ─────────────────────────────────── @@ -626,6 +628,10 @@ const hu: Translations = { backToLogin: 'Vissza a bejelentkezéshez', registerConsent: 'A fiók létrehozásával elfogadod a Felhasználási feltételeket és az Adatvédelmi tájékoztatót.', + loginFailed: 'A bejelentkezés sikertelen', + registrationFailed: 'A regisztráció sikertelen', + oauthFailed: 'Az OAuth-bejelentkezés sikertelen', + passwordResetFailed: 'A jelszó-visszaállítási kérés sikertelen', }, // ── Upgrade Modal ────────────────────────────────── @@ -721,6 +727,11 @@ const hu: Translations = { outstanding: 'Kiváló', distance: 'Távolság', crimeType: 'Bűncselekménytípus', + qualificationLevel: 'Képzettségi szint', + tenureType: 'Lakhatási forma', + crimeWindow: 'Időszak', + crimeWindow7y: '7 év', + crimeWindow2y: '2 év', ethnicity: 'Etnikai csoport', poiType: 'POI-típus', party: 'Párt', @@ -810,6 +821,7 @@ const hu: Translations = { generatingFilters: 'Szűrők létrehozása...', refiningResults: 'Eredmények finomhangolása...', weeklyLimitReached: 'Elérted a heti AI-használati limitet. Automatikusan visszaáll jövő héten.', + generateFailed: 'A szűrők létrehozása sikertelen', }, // ── Map Legend ───────────────────────────────────── @@ -829,6 +841,18 @@ const hu: Translations = { ogSchools: 'Iskolák', ogCrimeStats: 'Bűnözési adatok', ogTransport: 'Közlekedés', + basemap: { standard: 'Térkép', satellite: 'Műhold' }, + error: { + heading: 'A térkép hibába ütközött', + body: 'Ez akkor fordulhat elő, ha a böngésződ grafikus környezete megszakad.', + reload: 'Térkép újratöltése', + }, + actualListings: { + label: 'Hirdetések', + show: 'Valódi hirdetések megjelenítése', + hide: 'Valódi hirdetések elrejtése', + }, + poi: { zoomInToSeeDetails: 'Nagyíts rá a részletekért' }, }, // ── Properties Pane ──────────────────────────────── @@ -907,6 +931,20 @@ const hu: Translations = { crimeDataEnds: 'A körzet rendőrségi adatai {{year}}-ig érhetők el', residents: 'Lakosok', residentsTooltip: 'Állandó lakosok (ONS 2021-es népszámlálás)', + crimeWindow7y: 'Az elmúlt 7 év', + crimeWindow2y: 'Az elmúlt 2 év', + crimeWindowLabel: 'Időszak', + crimeCardTitle: '{{name}} évente', + crimeRecordsToggle: 'Egyedi bűncselekmények megjelenítése', + crimeRecordsHide: 'Egyedi bűncselekmények elrejtése', + crimeRecordsCount: '{{count}} bűncselekmény', + crimeRecordsLoading: 'Bűncselekmények betöltése…', + crimeRecordsEmpty: 'Nincs rögzített egyedi bűncselekmény', + crimeRecordsError: 'A bűncselekményeket nem sikerült betölteni', + crimeRecordsLoadMore: 'Több betöltése', + crimeNoLocation: 'A helyszín nincs megadva', + crimeOutcomeUnknown: 'Az eredmény nincs rögzítve', + crimeYearPointTooltip: '{{year}}: {{rate}}/év', }, // ── Street View ──────────────────────────────────── @@ -931,6 +969,7 @@ const hu: Translations = { searchOn: 'Keresés {{radius}} sugárban ezen:', exact: 'pontos', outcodeNotRecognised: 'Ismeretlen irányítószám-körzet', + radiusMi: '{{count}} mérföldes sugár', }, // ── Location Search ──────────────────────────────── @@ -1036,6 +1075,7 @@ const hu: Translations = { showcaseStep4ColCommute: 'Ingázás', showcaseStep4ColPrice: 'Medián eladási ár', showcaseStep4Conclusion: 'Innen már el tudod indítani a keresést.', + showcaseGe2024: '2024-es választás', statProperties: 'korábbi eladás', statFilters: 'kombinálható szűrő', statEvery: 'Minden', @@ -1414,6 +1454,8 @@ const hu: Translations = { noShareLinksYet: 'Még nincsenek megosztott hivatkozások', copyShareLink: 'Megosztott hivatkozás másolása', clicksLabel: 'kattintás', + fetchShareLinksError: 'A megosztott hivatkozások lekérése sikertelen', + updateNewsletterError: 'A hírlevél frissítése sikertelen', }, // ── Saved Page ───────────────────────────────────── @@ -1429,6 +1471,12 @@ const hu: Translations = { 'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.', isBeingUpdated: '{{name}} frissítése folyamatban', updating: 'Frissítés...', + loadFailed: 'A keresések betöltése sikertelen', + saveFailed: 'A keresés mentése sikertelen', + deleteFailed: 'A keresés törlése sikertelen', + updateNotesFailed: 'A jegyzetek frissítése sikertelen', + updateNameFailed: 'A név frissítése sikertelen', + updateFailed: 'A keresés frissítése sikertelen', }, // ── Invites Page ─────────────────────────────────── @@ -1448,6 +1496,7 @@ const hu: Translations = { created: 'Létrehozva', redeemed: 'Beváltva', pending: 'Függőben', + createInviteError: 'A meghívó létrehozása sikertelen', }, // ── Invite Page ──────────────────────────────────── @@ -1476,6 +1525,105 @@ const hu: Translations = { youAlreadyHaveLicense: 'Már van licenced', accountHasFullAccess: 'A fiókod már teljes hozzáféréssel rendelkezik.', failedToValidate: 'Nem sikerült a meghívó hivatkozás érvényesítése', + redeemFailed: 'A meghívó beváltása sikertelen', + }, + + // ── Errors ───────────────────────────────────────── + errors: { + appCrashTitle: 'Valami hiba történt', + appCrashBody: 'Frissítsd az oldalt az újrapróbálkozáshoz.', + }, + + // ── Listing ──────────────────────────────────────── + listing: { + viewed: 'Megtekintve', + openListing: 'Hirdetés megnyitása', + listings: 'hirdetés', + listing: 'Hirdetés', + showingOf: '{{visible}} / {{count}} megjelenítve', + groupedNear: 'Ennél a térképponton csoportosítva', + }, + + // ── Overlays ─────────────────────────────────────── + overlays: { + heading: 'Rétegek', + baseMap: 'Alaptérkép', + colourOpacity: 'Színek átlátszósága', + dataOverlays: 'Adatrétegek', + about: 'Névjegy: {{name}}', + zoomWarning: 'Nagyíts tovább a kiválasztott réteg megtekintéséhez.', + zoomWarning_other: 'Nagyíts tovább a kiválasztott rétegek megtekintéséhez.', + noise: { + label: 'Zaj', + detail: + 'Defra Strategic Noise Mapping Round 4 (2022), amely az úti, vasúti és repülőtéri forrásokat egyesíti. Az értékek az EU-szabványos Lden mutató szerintiek (nappal-este-éjjel 24 órás súlyozott átlag), 10 m-es rácson, a talaj felett 4 m-en modellezve. A világosabb területek magasabb modellezett zajt jeleznek. Licenc: Open Government Licence v3.0.', + }, + crimeHotspots: { + label: 'Bűnözési gócpontok', + detail: + 'A police.uk által közzétett, utcaszintű bűncselekmények kliensoldali hőtérképe a legutóbbi hónapokból. A police.uk koordinátái anonimizált, rácshoz illesztett pontok, nem pontos bűncselekményi helyszínek, ezért a hőtérképet a relatív sűrűség közelítéseként érdemes olvasni, nem az esetek pontos térképeként.', + }, + treesOutsideWoodlands: { + label: 'Fák és erdő', + detail: + 'Forest Research Trees Outside Woodland (TOW) v1 lombkorona-poligonok — magányos fák és facsoportok — egyesítve a National Forest Inventory (NFI) erdőblokkjaival (≥0,5 ha), amelyeket a TOW szándékosan kihagy. Együtt lefedik mind az utcai fákat, mind a valódi erdőket. A poligonok átlátszatlansága a lombkorona-területtel arányosan változik.', + }, + propertyBorders: { + label: 'Telekhatárok', + detail: + 'HM Land Registry INSPIRE Index Polygons — a szabad tulajdonú, bejegyzett ingatlanok helyzete és tájékoztató kiterjedése Angliában és Walesben, utcaszinten körvonalként ábrázolva. Ezek csak tájékoztató „általános határok”, nem az ingatlan pontos jogi határai, és nem tartalmazzák a kizárólag bérleti jogú érdekeltségeket és a be nem jegyzett földeket (a szabad tulajdonú föld nagyjából 85–90%-a van lefedve). Ezek az információk Crown copyright és adatbázis-jogok 2026 hatálya alá tartoznak, és a HM Land Registry engedélyével kerülnek közlésre. A poligonok (beleértve a hozzájuk tartozó geometriát, azaz az x, y koordinátákat) Crown copyright és adatbázis-jogok 2026 Ordnance Survey AC0000851063 hatálya alá tartoznak. Licenc: Open Government Licence v3.0.', + }, + newDevelopments: { + label: 'Új fejlesztések', + detail: + 'Előretekintő lista az új lakásépítésekről. A kék jelölők a törvényi MHCLG Brownfield Land nyilvántartásokban szereplő helyszíneket mutatják — mindegyik becsült nettó lakáskapacitással és építési engedélyezési státusszal —, a Homes England Land Hub értékesítési helyszíneivel együtt. Ezek megmutatják, hol terveznek új otthonokat, gyakran évekkel azelőtt, hogy megjelennének az EPC- vagy eladási nyilvántartásokban. A lakásszámok kapacitásbecslések, nem kötelezettségvállalások, és egy nyilvántartásban szereplő helyszín lehetőség, nem az építkezés garanciája. Licenc: Open Government Licence v3.0.', + }, + }, + + // ── Crime Types ──────────────────────────────────── + crimeTypes: { + violenceAndSexualOffences: 'Erőszakos és szexuális bűncselekmények', + antiSocialBehaviour: 'Közösségellenes viselkedés', + criminalDamageAndArson: 'Rongálás és gyújtogatás', + publicOrder: 'Közrend elleni cselekmény', + shoplifting: 'Bolti lopás', + vehicleCrime: 'Járművel kapcsolatos bűncselekmény', + burglary: 'Betörés', + otherTheft: 'Egyéb lopás', + theftFromThePerson: 'Személytől való lopás', + bicycleTheft: 'Kerékpárlopás', + drugs: 'Kábítószer', + robbery: 'Rablás', + possessionOfWeapons: 'Fegyvertartás', + otherCrime: 'Egyéb bűncselekmény', + }, + + // ── Journey ──────────────────────────────────────── + journey: { + bus: 'Busz', + lineSuffix: 'vonal', + }, + + // ── POI Popup ────────────────────────────────────── + poiPopup: { + school: { + capacity: 'Kapacitás', + type: 'Típus', + ages: 'Életkor', + gender: 'Nem', + pupilsLabel: 'Tanulók', + pupils: 'tanuló', + freeMeal: 'Ingyenes étkezés', + ofsted: 'Ofsted', + sixthForm: 'Felső középfok', + religion: 'Vallás', + admissions: 'Felvétel', + trust: 'Fenntartó', + address: 'Cím', + localAuthority: 'Önk.', + head: 'Igazgató', + website: 'Weboldal', + }, }, // ── Format / Time ────────────────────────────────── @@ -1517,6 +1665,9 @@ const hu: Translations = { step6Title: 'Mi van a közelben?', step6Content: 'Kapcsolja be az iskolákat, üzleteket, állomásokat, parkokat és éttermeket a térképen, hogy lássa, mi érhető el.', + step7Title: 'Adjon hozzá több réteget', + step7Content: + 'Kapcsoljon be olyan rétegeket a térképen, mint a bűnözési gócpontok, zaj, faállomány, telekhatárok és új építésű területek, és váltson alaptérképet igény szerint.', }, // ── Server-derived values ────────────────────────── @@ -1574,23 +1725,60 @@ const hu: Translations = { 'Housing Conditions Score': 'Lakáskörülmények pontszám', 'Air Quality and Road Safety Score': 'Levegőminőség és közlekedésbiztonság pontszám', - // ─ Feature names (Crime) ─ - 'Serious crime (avg/yr)': 'Súlyos bűncselekmény (sűrűség)', - 'Minor crime (avg/yr)': 'Kisebb bűncselekmény (sűrűség)', - 'Violence and sexual offences (avg/yr)': 'Erőszak és szexuális bűncselekmények (sűrűség)', - 'Burglary (avg/yr)': 'Betörés (sűrűség)', - 'Robbery (avg/yr)': 'Rablás (sűrűség)', - 'Vehicle crime (avg/yr)': 'Járművel kapcsolatos bűncselekmények (sűrűség)', - 'Anti-social behaviour (avg/yr)': 'Közösségellenes magatartás (sűrűség)', - 'Criminal damage and arson (avg/yr)': 'Rongálás és gyújtogatás (sűrűség)', - 'Other theft (avg/yr)': 'Egyéb lopás (sűrűség)', - 'Theft from the person (avg/yr)': 'Személytől történő lopás (sűrűség)', - 'Shoplifting (avg/yr)': 'Bolti lopás (sűrűség)', - 'Bicycle theft (avg/yr)': 'Kerékpárlopás (sűrűség)', - 'Drugs (avg/yr)': 'Kábítószer (sűrűség)', - 'Possession of weapons (avg/yr)': 'Fegyverbirtoklás (sűrűség)', - 'Public order (avg/yr)': 'Közrend (sűrűség)', - 'Other crime (avg/yr)': 'Egyéb bűncselekmény (sűrűség)', + // ─ Feature names (Crime) — népességre normált ráta, 7 év és 2 év ─ + 'Serious crime (/yr, 7y)': 'Súlyos bűncselekmény (évente, 7 év)', + 'Serious crime (/yr, 2y)': 'Súlyos bűncselekmény (évente, 2 év)', + 'Minor crime (/yr, 7y)': 'Kisebb bűncselekmény (évente, 7 év)', + 'Minor crime (/yr, 2y)': 'Kisebb bűncselekmény (évente, 2 év)', + 'Violence and sexual offences (/yr, 7y)': + 'Erőszak és szexuális bűncselekmények (évente, 7 év)', + 'Violence and sexual offences (/yr, 2y)': + 'Erőszak és szexuális bűncselekmények (évente, 2 év)', + 'Burglary (/yr, 7y)': 'Betörés (évente, 7 év)', + 'Burglary (/yr, 2y)': 'Betörés (évente, 2 év)', + 'Robbery (/yr, 7y)': 'Rablás (évente, 7 év)', + 'Robbery (/yr, 2y)': 'Rablás (évente, 2 év)', + 'Vehicle crime (/yr, 7y)': + 'Járművel kapcsolatos bűncselekmények (évente, 7 év)', + 'Vehicle crime (/yr, 2y)': + 'Járművel kapcsolatos bűncselekmények (évente, 2 év)', + 'Anti-social behaviour (/yr, 7y)': 'Közösségellenes magatartás (évente, 7 év)', + 'Anti-social behaviour (/yr, 2y)': 'Közösségellenes magatartás (évente, 2 év)', + 'Criminal damage and arson (/yr, 7y)': + 'Rongálás és gyújtogatás (évente, 7 év)', + 'Criminal damage and arson (/yr, 2y)': + 'Rongálás és gyújtogatás (évente, 2 év)', + 'Other theft (/yr, 7y)': 'Egyéb lopás (évente, 7 év)', + 'Other theft (/yr, 2y)': 'Egyéb lopás (évente, 2 év)', + 'Theft from the person (/yr, 7y)': 'Személytől történő lopás (évente, 7 év)', + 'Theft from the person (/yr, 2y)': 'Személytől történő lopás (évente, 2 év)', + 'Shoplifting (/yr, 7y)': 'Bolti lopás (évente, 7 év)', + 'Shoplifting (/yr, 2y)': 'Bolti lopás (évente, 2 év)', + 'Bicycle theft (/yr, 7y)': 'Kerékpárlopás (évente, 7 év)', + 'Bicycle theft (/yr, 2y)': 'Kerékpárlopás (évente, 2 év)', + 'Drugs (/yr, 7y)': 'Kábítószer (évente, 7 év)', + 'Drugs (/yr, 2y)': 'Kábítószer (évente, 2 év)', + 'Possession of weapons (/yr, 7y)': 'Fegyverbirtoklás (évente, 7 év)', + 'Possession of weapons (/yr, 2y)': 'Fegyverbirtoklás (évente, 2 év)', + 'Public order (/yr, 7y)': 'Közrend (évente, 7 év)', + 'Public order (/yr, 2y)': 'Közrend (évente, 2 év)', + 'Other crime (/yr, 7y)': 'Egyéb bűncselekmény (évente, 7 év)', + 'Other crime (/yr, 2y)': 'Egyéb bűncselekmény (évente, 2 év)', + // Csak a bűncselekménytípus megnevezések (típusonkénti bontás + rekordlista). + // (A „Burglary” már szerepel lent a konkrét bűncselekmények között.) + 'Violence and sexual offences': 'Erőszak és szexuális bűncselekmények', + Robbery: 'Rablás', + 'Vehicle crime': 'Járművel kapcsolatos bűncselekmények', + 'Anti-social behaviour': 'Közösségellenes magatartás', + 'Criminal damage and arson': 'Rongálás és gyújtogatás', + 'Other theft': 'Egyéb lopás', + 'Theft from the person': 'Személytől történő lopás', + Shoplifting: 'Bolti lopás', + 'Bicycle theft': 'Kerékpárlopás', + Drugs: 'Kábítószer', + 'Possession of weapons': 'Fegyverbirtoklás', + 'Public order': 'Közrend', + 'Other crime': 'Egyéb bűncselekmény', // ─ Feature names (Neighbours) ─ 'Median age': 'Medián életkor', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 0f61f69..97d73fc 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -57,6 +57,8 @@ const zh: Translations = { closePane: '关闭面板', yes: '是', no: '否', + captions: '字幕', + sqm: '平方米', }, // ── Header / Nav ─────────────────────────────────── @@ -574,6 +576,10 @@ const zh: Translations = { backToLogin: '返回登录', registerConsent: '创建账户即表示您同意我们的服务条款隐私政策。', + loginFailed: '登录失败', + registrationFailed: '注册失败', + oauthFailed: 'OAuth 登录失败', + passwordResetFailed: '密码重置请求失败', }, // ── Upgrade Modal ────────────────────────────────── @@ -667,6 +673,11 @@ const zh: Translations = { outstanding: '优秀', distance: '距离', crimeType: '犯罪类别', + qualificationLevel: '学历水平', + tenureType: '住房产权类型', + crimeWindow: '统计周期', + crimeWindow7y: '7 年', + crimeWindow2y: '2 年', ethnicity: '族裔', poiType: '地点类型', party: '政党', @@ -755,6 +766,7 @@ const zh: Translations = { generatingFilters: '正在生成筛选条件...', refiningResults: '正在优化结果...', weeklyLimitReached: '您已达到每周 AI 使用上限。下周将自动重置。', + generateFailed: '生成筛选条件失败', }, // ── Map Legend ───────────────────────────────────── @@ -774,6 +786,14 @@ const zh: Translations = { ogSchools: '学校', ogCrimeStats: '治安数据', ogTransport: '交通', + basemap: { standard: '地图', satellite: '卫星' }, + error: { + heading: '地图遇到了问题', + body: '当您浏览器的图形上下文被中断时,可能会出现这种情况。', + reload: '重新加载地图', + }, + actualListings: { label: '房源', show: '显示实际房源', hide: '隐藏实际房源' }, + poi: { zoomInToSeeDetails: '放大以查看详情' }, }, // ── Properties Pane ──────────────────────────────── @@ -849,6 +869,20 @@ const zh: Translations = { crimeDataEnds: '该地区的警方数据截至{{year}}年', residents: '居民', residentsTooltip: '常住居民(ONS 2021 年人口普查)', + crimeWindow7y: '过去 7 年', + crimeWindow2y: '过去 2 年', + crimeWindowLabel: '时间范围', + crimeCardTitle: '{{name}}(每年)', + crimeRecordsToggle: '显示单起犯罪', + crimeRecordsHide: '隐藏单起犯罪', + crimeRecordsCount: '{{count}} 起犯罪', + crimeRecordsLoading: '正在加载犯罪记录…', + crimeRecordsEmpty: '没有单起犯罪记录', + crimeRecordsError: '无法加载犯罪记录', + crimeRecordsLoadMore: '加载更多', + crimeNoLocation: '地点未公开', + crimeOutcomeUnknown: '结果未记录', + crimeYearPointTooltip: '{{year}}: {{rate}}/年', }, // ── Street View ──────────────────────────────────── @@ -873,6 +907,7 @@ const zh: Translations = { searchOn: '在 {{radius}} 范围内搜索', exact: '精确', outcodeNotRecognised: '无法识别该邮编区域', + radiusMi: '{{count}} 英里范围内', }, // ── Location Search ──────────────────────────────── @@ -929,6 +964,7 @@ const zh: Translations = { showcaseJourneyRoutes: '出行路线', showcaseNearby: '附近 {{value}} 个', showcasePoliticalVoteShare: '政党得票份额', + showcaseGe2024: '2024 大选', showcaseLotsMore: '……还有更多', showcaseMinutes: '{{count}} 分钟', showcaseSendShortlist: '发送候选名单', @@ -1340,6 +1376,8 @@ const zh: Translations = { noShareLinksYet: '暂无已分享的链接', copyShareLink: '复制分享链接', clicksLabel: '点击', + fetchShareLinksError: '获取分享链接失败', + updateNewsletterError: '更新新闻邮件设置失败', }, // ── Saved Page ───────────────────────────────────── @@ -1353,6 +1391,12 @@ const zh: Translations = { deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。', isBeingUpdated: '正在更新 {{name}}', updating: '更新中...', + loadFailed: '加载搜索失败', + saveFailed: '保存搜索失败', + deleteFailed: '删除搜索失败', + updateNotesFailed: '更新备注失败', + updateNameFailed: '更新名称失败', + updateFailed: '更新搜索失败', }, // ── Invites Page ─────────────────────────────────── @@ -1372,6 +1416,7 @@ const zh: Translations = { created: '创建时间', redeemed: '已兑换', pending: '待兑换', + createInviteError: '创建邀请失败', }, // ── Invite Page ──────────────────────────────────── @@ -1398,6 +1443,105 @@ const zh: Translations = { youAlreadyHaveLicense: '您已拥有授权', accountHasFullAccess: '您的账户已拥有完整访问权限。', failedToValidate: '验证邀请链接失败', + redeemFailed: '兑换邀请失败', + }, + + // ── Errors ───────────────────────────────────────── + errors: { + appCrashTitle: '出错了', + appCrashBody: '请刷新页面后重试。', + }, + + // ── Listing ──────────────────────────────────────── + listing: { + viewed: '已查看', + openListing: '打开房源', + listings: '套房源', + listing: '房源', + showingOf: '显示 {{count}} 套中的 {{visible}} 套', + groupedNear: '按此地图位置聚合', + }, + + // ── Overlays ─────────────────────────────────────── + overlays: { + heading: '叠加图层', + baseMap: '底图', + colourOpacity: '颜色不透明度', + dataOverlays: '数据叠加层', + about: '关于{{name}}', + zoomWarning: '请进一步放大以查看所选叠加层。', + zoomWarning_other: '请进一步放大以查看所选叠加层。', + noise: { + label: '噪音', + detail: + 'Defra 第 4 轮战略噪音地图(2022 年),综合道路、铁路和机场来源。数值采用 EU 标准的 Lden 指标(昼-暮-夜 24 小时加权平均),在距地面 4 米高度的 10 米网格上建模。颜色越亮表示建模噪音越高。依据 Open Government Licence v3.0 授权。', + }, + crimeHotspots: { + label: '犯罪热点', + detail: + '基于 police.uk 近几个月公布的街道级犯罪数据,在客户端生成的热力图。police.uk 的坐标是经过匿名化、对齐到网格的点位,而非确切的案发地点,因此该热力图应被视为相对密度的近似呈现,而非确切的案件分布图。', + }, + treesOutsideWoodlands: { + label: '树木与林地', + detail: + 'Forest Research Trees Outside Woodland (TOW) v1 树冠多边形——单棵树木和树木群——与 TOW 有意排除的 National Forest Inventory (NFI) 林地地块(≥0.5 公顷)合并。二者共同覆盖行道树和真正的林地。多边形的不透明度随树冠面积变化。', + }, + propertyBorders: { + label: '房产边界', + detail: + 'HM Land Registry INSPIRE Index Polygons——英格兰和威尔士已登记的自由保有房产的位置和大致范围,以街道级的轮廓线绘制。这些是仅供参考的“一般边界”,并非房产的确切法律边界,且不包含仅租赁权益和未登记土地(大约覆盖 85–90% 的自由保有土地)。本信息受 Crown copyright and database rights 2026 保护,经 HM Land Registry 许可复制。这些多边形(包括相关几何信息,即 x、y 坐标)受 Crown copyright and database rights 2026 Ordnance Survey AC0000851063 保护。依据 Open Government Licence v3.0 授权。', + }, + newDevelopments: { + label: '新开发项目', + detail: + '前瞻性的新住房供应管线。蓝色标记表示位于法定 MHCLG Brownfield Land 登记册上的地块——每个都附有预估的净住宅容量和规划许可状态——以及 Homes England Land Hub 处置地块。这些显示了新住房的规划地点,通常比它们出现在 EPC 或成交记录中早数年。住宅数量为容量估算值,而非承诺,登记册上的地块是机会,而非建设的保证。依据 Open Government Licence v3.0 授权。', + }, + }, + + // ── Crime Types ──────────────────────────────────── + crimeTypes: { + violenceAndSexualOffences: '暴力与性侵犯', + antiSocialBehaviour: '反社会行为', + criminalDamageAndArson: '故意毁坏与纵火', + publicOrder: '扰乱公共秩序', + shoplifting: '入店行窃', + vehicleCrime: '车辆犯罪', + burglary: '入室盗窃', + otherTheft: '其他盗窃', + theftFromThePerson: '扒窃', + bicycleTheft: '自行车盗窃', + drugs: '毒品', + robbery: '抢劫', + possessionOfWeapons: '持有武器', + otherCrime: '其他犯罪', + }, + + // ── Journey ──────────────────────────────────────── + journey: { + bus: '公交', + lineSuffix: '线', + }, + + // ── POI Popup ────────────────────────────────────── + poiPopup: { + school: { + capacity: '容量', + type: '类型', + ages: '年龄段', + gender: '性别', + pupilsLabel: '学生', + pupils: '名学生', + freeMeal: '免费餐', + ofsted: 'Ofsted', + sixthForm: '六年级', + religion: '宗教', + admissions: '招生', + trust: '学校联盟', + address: '地址', + localAuthority: 'LA', + head: '校长', + website: '网站', + }, }, // ── Format / Time ────────────────────────────────── @@ -1437,6 +1581,9 @@ const zh: Translations = { step5Content: '查看区域统计、直方图和单个房产记录:价格、建筑面积、能效评级等。', step6Title: '附近有什么?', step6Content: '在地图上开启学校、商店、车站、公园和餐厅图层,查看周边设施。', + step7Title: '叠加更多信息', + step7Content: + '在地图上开启犯罪热点、噪音、树木覆盖、房产边界和新建住宅项目等叠加图层,并按需切换底图。', }, // ── Server-derived values ────────────────────────── @@ -1493,23 +1640,54 @@ const zh: Translations = { 'Housing Conditions Score': '住房状况得分', 'Air Quality and Road Safety Score': '空气质量与道路安全得分', - // ─ Feature names (Crime) ─ - 'Serious crime (avg/yr)': '严重犯罪(密度)', - 'Minor crime (avg/yr)': '轻微犯罪(密度)', - 'Violence and sexual offences (avg/yr)': '暴力和性犯罪(密度)', - 'Burglary (avg/yr)': '入室盗窃(密度)', - 'Robbery (avg/yr)': '抢劫(密度)', - 'Vehicle crime (avg/yr)': '车辆犯罪(密度)', - 'Anti-social behaviour (avg/yr)': '反社会行为(密度)', - 'Criminal damage and arson (avg/yr)': '刑事毁坏和纵火(密度)', - 'Other theft (avg/yr)': '其他盗窃(密度)', - 'Theft from the person (avg/yr)': '人身盗窃(密度)', - 'Shoplifting (avg/yr)': '商店盗窃(密度)', - 'Bicycle theft (avg/yr)': '自行车盗窃(密度)', - 'Drugs (avg/yr)': '毒品犯罪(密度)', - 'Possession of weapons (avg/yr)': '非法持有武器(密度)', - 'Public order (avg/yr)': '扰乱公共秩序(密度)', - 'Other crime (avg/yr)': '其他犯罪(密度)', + // ─ Feature names (Crime) — 按人口标准化的比率,7 年与 2 年 ─ + 'Serious crime (/yr, 7y)': '严重犯罪(每年,7 年)', + 'Serious crime (/yr, 2y)': '严重犯罪(每年,2 年)', + 'Minor crime (/yr, 7y)': '轻微犯罪(每年,7 年)', + 'Minor crime (/yr, 2y)': '轻微犯罪(每年,2 年)', + 'Violence and sexual offences (/yr, 7y)': '暴力和性犯罪(每年,7 年)', + 'Violence and sexual offences (/yr, 2y)': '暴力和性犯罪(每年,2 年)', + 'Burglary (/yr, 7y)': '入室盗窃(每年,7 年)', + 'Burglary (/yr, 2y)': '入室盗窃(每年,2 年)', + 'Robbery (/yr, 7y)': '抢劫(每年,7 年)', + 'Robbery (/yr, 2y)': '抢劫(每年,2 年)', + 'Vehicle crime (/yr, 7y)': '车辆犯罪(每年,7 年)', + 'Vehicle crime (/yr, 2y)': '车辆犯罪(每年,2 年)', + 'Anti-social behaviour (/yr, 7y)': '反社会行为(每年,7 年)', + 'Anti-social behaviour (/yr, 2y)': '反社会行为(每年,2 年)', + 'Criminal damage and arson (/yr, 7y)': '刑事毁坏和纵火(每年,7 年)', + 'Criminal damage and arson (/yr, 2y)': '刑事毁坏和纵火(每年,2 年)', + 'Other theft (/yr, 7y)': '其他盗窃(每年,7 年)', + 'Other theft (/yr, 2y)': '其他盗窃(每年,2 年)', + 'Theft from the person (/yr, 7y)': '人身盗窃(每年,7 年)', + 'Theft from the person (/yr, 2y)': '人身盗窃(每年,2 年)', + 'Shoplifting (/yr, 7y)': '商店盗窃(每年,7 年)', + 'Shoplifting (/yr, 2y)': '商店盗窃(每年,2 年)', + 'Bicycle theft (/yr, 7y)': '自行车盗窃(每年,7 年)', + 'Bicycle theft (/yr, 2y)': '自行车盗窃(每年,2 年)', + 'Drugs (/yr, 7y)': '毒品犯罪(每年,7 年)', + 'Drugs (/yr, 2y)': '毒品犯罪(每年,2 年)', + 'Possession of weapons (/yr, 7y)': '非法持有武器(每年,7 年)', + 'Possession of weapons (/yr, 2y)': '非法持有武器(每年,2 年)', + 'Public order (/yr, 7y)': '扰乱公共秩序(每年,7 年)', + 'Public order (/yr, 2y)': '扰乱公共秩序(每年,2 年)', + 'Other crime (/yr, 7y)': '其他犯罪(每年,7 年)', + 'Other crime (/yr, 2y)': '其他犯罪(每年,2 年)', + // 仅犯罪类型标签(按类型细分 + 记录列表)。 + // (“Burglary” 已在下方具体犯罪中定义。) + 'Violence and sexual offences': '暴力和性犯罪', + Robbery: '抢劫', + 'Vehicle crime': '车辆犯罪', + 'Anti-social behaviour': '反社会行为', + 'Criminal damage and arson': '刑事毁坏和纵火', + 'Other theft': '其他盗窃', + 'Theft from the person': '人身盗窃', + Shoplifting: '商店盗窃', + 'Bicycle theft': '自行车盗窃', + Drugs: '毒品犯罪', + 'Possession of weapons': '非法持有武器', + 'Public order': '扰乱公共秩序', + 'Other crime': '其他犯罪', // ─ Feature names (Neighbours) ─ 'Median age': '中位年龄', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 52bfb70..bf648ed 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -310,23 +310,51 @@ export interface CrimeYearPoint { } export interface CrimeYearStats { - /** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */ + /** Bare crime type (e.g. "Burglary"). */ name: string; points: CrimeYearPoint[]; } export interface CrimeAreaAverage { - /** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */ + /** Full rate-feature name (e.g. "Burglary (per 1k/yr, 7y)"). */ name: string; - /** Exact national mean (avg/yr). Preferred over the histogram-bin national + /** Exact national mean rate. Preferred over the histogram-bin national * average for crime so all reference numbers share one estimator. */ national?: number; - /** Mean headline rate (avg/yr) across the selection's outcode. */ + /** Mean rate across the selection's outcode. */ outcode?: number; - /** Mean headline rate (avg/yr) across the selection's postcode sector. */ + /** Mean rate across the selection's postcode sector. */ sector?: number; } +export interface CrimeRawStats { + /** Bare crime type (e.g. "Burglary"). */ + name: string; + /** Mean recorded incidents/yr over the last 7 years. */ + per_yr_7y?: number; + /** Mean recorded incidents/yr over the last 2 years. */ + per_yr_2y?: number; +} + +/** One individual police.uk crime, from /api/crime-records. */ +export interface CrimeIncident { + /** "YYYY-MM". */ + month: string; + /** Crime type (e.g. "Burglary"). */ + type: string; + location?: string; + outcome?: string; + lat: number; + lon: number; +} + +export interface CrimeRecordsResponse { + records: CrimeIncident[]; + total: number; + offset: number; + truncated: boolean; +} + export interface FilterExclusion { name: string; kind: 'numeric' | 'enum' | 'poi' | 'travel'; @@ -358,9 +386,15 @@ export interface HexagonStatsResponse { /** Postcode sector (e.g. "E14 2") of the selection's central postcode, when * sector crime averages are available for it. */ crime_sector?: string; - /** Per-crime-type average rates across the central postcode's outcode and + /** Per-rate-feature average rates across the central postcode's outcode and * sector, shown alongside the national average for each crime metric. */ crime_area_averages?: CrimeAreaAverage[]; + /** Raw (un-normalised) recorded incidents/yr per crime type, shown beside the + * normalised rate. Display-only. */ + crime_raw?: CrimeRawStats[]; + /** Total individual crime records (last 7 years) across the selection's + * postcodes — the count behind the "individual crimes" list. */ + crime_total_records?: number; central_postcode?: string; /** Total usual residents (ONS Census 2021) across the postcodes in this * selection. Display-only; independent of active filters. */ From 5e73287eafa9ba4b5d61f3141050496cd5b65c44 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 25 Jun 2026 22:29:52 +0100 Subject: [PATCH 2/3] lgtm --- Dockerfile | 2 +- Makefile.data | 51 +- docker-compose.yml | 2 +- .../src/components/account/AccountPage.tsx | 8 +- frontend/src/components/home/HomePage.tsx | 2 +- .../src/components/home/ProductShowcase.tsx | 4 +- frontend/src/components/invite/InvitePage.tsx | 4 +- frontend/src/components/learn/LearnPage.tsx | 2 +- frontend/src/components/map/AreaPane.tsx | 112 ++-- .../src/components/map/CrimeGroupBody.tsx | 204 ++++++ .../components/map/CrimeRecordsSection.tsx | 147 +++++ .../src/components/map/CrimeYearChart.tsx | 7 +- .../components/map/ExternalSearchLinks.tsx | 2 +- .../src/components/map/FeatureBrowser.tsx | 14 +- frontend/src/components/map/Filters.tsx | 207 +++++- frontend/src/components/map/HoverCard.tsx | 9 + .../map/JourneyInstructions.test.tsx | 2 + .../components/map/JourneyInstructions.tsx | 22 +- frontend/src/components/map/ListingPopups.tsx | 40 +- frontend/src/components/map/Map.tsx | 10 +- .../src/components/map/MapErrorBoundary.tsx | 9 +- frontend/src/components/map/MapPage.tsx | 3 + frontend/src/components/map/MobileDrawer.tsx | 6 +- frontend/src/components/map/OverlayPane.tsx | 48 +- frontend/src/components/map/PoiPopupCard.tsx | 43 +- .../src/components/map/PropertiesPane.tsx | 6 +- .../src/components/map/StackedBarChart.tsx | 1 + .../map/filters/ActiveFilterList.tsx | 127 +++- .../map/filters/ActiveFiltersPanel.tsx | 20 +- .../components/map/filters/AddFilterPanel.tsx | 49 +- ...meFilterCard.tsx => VariantFilterCard.tsx} | 179 ++++-- .../map/map-page/DesktopMapPage.tsx | 9 +- .../components/map/map-page/MobileMapPage.tsx | 16 +- .../components/map/map-page/derivedState.ts | 6 + .../ui/IndeterminateProgressBar.tsx | 6 +- frontend/src/hooks/useAiFilters.ts | 6 +- frontend/src/hooks/useAuth.ts | 18 +- frontend/src/hooks/useClickedListings.test.ts | 98 +++ frontend/src/hooks/useClickedListings.ts | 82 +++ frontend/src/hooks/useDeckLayers.ts | 33 +- frontend/src/hooks/useFilters.ts | 164 ++++- frontend/src/hooks/useHexagonSelection.ts | 27 +- frontend/src/hooks/useLicense.ts | 6 +- frontend/src/hooks/useListingLayers.ts | 60 +- frontend/src/hooks/useMapData.ts | 6 + frontend/src/hooks/usePoiLayers.test.ts | 12 +- frontend/src/hooks/usePoiLayers.ts | 8 +- frontend/src/hooks/useRevealOnExpand.test.tsx | 80 +++ frontend/src/hooks/useRevealOnExpand.ts | 97 +++ frontend/src/hooks/useSavedSearches.ts | 26 +- frontend/src/hooks/useTutorial.ts | 7 + frontend/src/index.tsx | 10 +- frontend/src/lib/api.test.ts | 46 +- frontend/src/lib/api.ts | 29 +- frontend/src/lib/consts.ts | 64 +- frontend/src/lib/crime-filter.test.ts | 79 +++ frontend/src/lib/crime-filter.ts | 105 +++- .../src/lib/crime-severity-filter.test.ts | 85 +++ frontend/src/lib/crime-severity-filter.ts | 193 ++++++ frontend/src/lib/crime-types.ts | 51 +- frontend/src/lib/feature-icons.tsx | 7 +- frontend/src/lib/qualification-filter.ts | 117 +++- frontend/src/lib/tenure-filter.ts | 121 ++++ frontend/src/lib/url-state.test.ts | 108 +++- frontend/src/lib/url-state.ts | 106 ++++ frontend/src/lib/variant-filter.ts | 80 +++ frontend/src/types.ts | 27 +- pipeline/transform/area_crime_averages.py | 212 +++++++ pipeline/transform/crime_spatial.py | 591 +++++++++++------- pipeline/transform/join_price_estimates.py | 149 +++++ pipeline/transform/merge.py | 214 ++----- .../transform/postcode_boundaries/README.md | 5 +- .../postcode_boundaries/greenspace.py | 44 +- .../transform/postcode_boundaries/output.py | 164 +++++ .../test_postcode_boundaries.py | 231 +++++++ .../transform/price_estimation/estimate.py | 67 +- pipeline/transform/price_estimation/utils.py | 20 + pipeline/transform/property_base.py | 217 +++++++ .../transform/test_area_crime_averages.py | 81 +++ pipeline/transform/test_crime_spatial.py | 396 ++++++------ .../transform/test_join_price_estimates.py | 136 ++++ pipeline/transform/test_merge.py | 79 ++- server-rs/src/data.rs | 2 + server-rs/src/data/area_crime_averages.rs | 254 +++++++- server-rs/src/data/crime_records.rs | 534 ++++++++++++++++ server-rs/src/data/property/mod.rs | 105 ---- server-rs/src/data/spill.rs | 233 +++++++ server-rs/src/features.rs | 338 +++------- server-rs/src/main.rs | 39 +- server-rs/src/pocketbase.rs | 43 ++ server-rs/src/routes.rs | 2 + server-rs/src/routes/ai_filters/parsing.rs | 4 +- server-rs/src/routes/ai_filters/prompt.rs | 12 +- server-rs/src/routes/crime_records.rs | 194 ++++++ server-rs/src/routes/hexagon_stats.rs | 62 +- server-rs/src/routes/postcode_stats.rs | 6 + server-rs/src/routes/shorten.rs | 21 + server-rs/src/routes/stats.rs | 50 +- server-rs/src/state.rs | 12 +- 99 files changed, 6392 insertions(+), 1462 deletions(-) create mode 100644 frontend/src/components/map/CrimeGroupBody.tsx create mode 100644 frontend/src/components/map/CrimeRecordsSection.tsx rename frontend/src/components/map/filters/{SpecificCrimeFilterCard.tsx => VariantFilterCard.tsx} (51%) create mode 100644 frontend/src/hooks/useClickedListings.test.ts create mode 100644 frontend/src/hooks/useClickedListings.ts create mode 100644 frontend/src/hooks/useRevealOnExpand.test.tsx create mode 100644 frontend/src/hooks/useRevealOnExpand.ts create mode 100644 frontend/src/lib/crime-filter.test.ts create mode 100644 frontend/src/lib/crime-severity-filter.test.ts create mode 100644 frontend/src/lib/crime-severity-filter.ts create mode 100644 frontend/src/lib/tenure-filter.ts create mode 100644 frontend/src/lib/variant-filter.ts create mode 100644 pipeline/transform/area_crime_averages.py create mode 100644 pipeline/transform/join_price_estimates.py create mode 100644 pipeline/transform/property_base.py create mode 100644 pipeline/transform/test_area_crime_averages.py create mode 100644 pipeline/transform/test_join_price_estimates.py create mode 100644 server-rs/src/data/crime_records.rs create mode 100644 server-rs/src/routes/crime_records.rs diff --git a/Dockerfile b/Dockerfile index bd543c8..7441776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,4 +56,4 @@ EXPOSE 8001 HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ CMD curl -f http://localhost:8001/health || exit 1 ENTRYPOINT ["./property-map-server"] -CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/data/crime_by_postcode_by_year.parquet", "--area-crime-averages-path", "/app/data/area_crime_averages.parquet", "--population-path", "/app/data/population_by_postcode.parquet", "--developments-path", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"] +CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/data/crime_by_postcode_by_year.parquet", "--crime-records-path", "/app/data/crime_records.parquet", "--area-crime-averages-path", "/app/data/area_crime_averages.parquet", "--population-path", "/app/data/population_by_postcode.parquet", "--developments-path", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"] diff --git a/Makefile.data b/Makefile.data index dbcf1c4..d576639 100644 --- a/Makefile.data +++ b/Makefile.data @@ -27,7 +27,10 @@ POSTCODES_RAW := $(DATA_DIR)/gb-postcodes-v5 POSTCODES_PQ := $(DATA_DIR)/postcode.parquet PROPERTIES_PQ := $(DATA_DIR)/properties.parquet MERGE_STAMP := $(DATA_DIR)/.merge_done +PRICE_INPUTS := $(DATA_DIR)/price_inputs.parquet +POSTCODE_CENTROIDS := $(DATA_DIR)/postcode_centroids.parquet PRICE_INDEX := $(DATA_DIR)/price_index.parquet +PRICE_ESTIMATES := $(DATA_DIR)/price_estimates.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done EPC := $(MANUAL_DATA)/domestic-csv.zip ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet @@ -38,6 +41,8 @@ TENURE := $(DATA_DIR)/tenure_by_lsoa.parquet CRIME_DIR := $(DATA_DIR)/crime CRIME := $(DATA_DIR)/crime_by_postcode.parquet CRIME_BY_YEAR := $(DATA_DIR)/crime_by_postcode_by_year.parquet +CRIME_RECORDS := $(DATA_DIR)/crime_records.parquet +AREA_CRIME_AVERAGES := $(DATA_DIR)/area_crime_averages.parquet POPULATION := $(DATA_DIR)/population_by_postcode.parquet CRIME_STAMP := $(CRIME_DIR)/.downloaded NOISE := $(DATA_DIR)/road_noise.parquet @@ -91,8 +96,11 @@ VALIDATE_OUTPUTS := uv run python -m pipeline.validate_outputs POI_PROXIMITY_DEPS := pipeline/transform/poi_proximity.py pipeline/utils/poi_counts.py MERGE_DEPS := pipeline/transform/merge.py +AREA_CRIME_AVERAGES_DEPS := pipeline/transform/area_crime_averages.py PRICE_INDEX_DEPS := pipeline/transform/price_estimation/index.py pipeline/transform/price_estimation/shrinkage.py pipeline/transform/price_estimation/utils.py PRICE_ESTIMATE_DEPS := pipeline/transform/price_estimation/estimate.py pipeline/transform/price_estimation/knn.py pipeline/transform/price_estimation/utils.py +PRICE_JOIN_DEPS := pipeline/transform/join_price_estimates.py pipeline/transform/price_estimation/utils.py +PRICE_INPUTS_DEPS := pipeline/transform/property_base.py pipeline/utils/postcode_mapping.py TREE_DENSITY_DEPS := pipeline/transform/tree_density.py PC_BOUNDARIES_DEPS := pipeline/transform/postcode_boundaries/__main__.py \ pipeline/transform/postcode_boundaries/greenspace.py \ @@ -116,11 +124,11 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_ download-postcodes download-noise download-inspire download-crime \ download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-nfi download-ofs-register download-places download-median-age download-population download-england-boundary download-rightmove-outcodes \ download-map-assets \ - transform-pois transform-epc-pp transform-crime transform-poi-proximity \ + transform-pois transform-epc-pp transform-crime transform-poi-proximity transform-area-crime-averages \ transform-school-catchments transform-tree-density \ generate-postcode-boundaries generate-travel-times enrich-actual-listings download-development-sites -prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times $(DEVELOPMENT_SITES) $(POPULATION) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) +prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times $(DEVELOPMENT_SITES) $(POPULATION) $(CRIME_RECORDS) $(AREA_CRIME_AVERAGES) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX) --postcode-boundary-match "$(POSTCODES_PQ)::$(PC_BOUNDARIES)" --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX) merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" @@ -178,6 +186,7 @@ transform-pois: $(POIS_FILTERED) transform-epc-pp: $(EPC_PP) transform-crime: $(CRIME) transform-poi-proximity: $(POI_PROXIMITY) +transform-area-crime-averages: $(AREA_CRIME_AVERAGES) transform-school-catchments: $(SCHOOL_CATCH) transform-tree-density: $(TREE_DENSITY_PC) generate-postcode-boundaries: $(PC_BOUNDARIES_STAMP) @@ -383,9 +392,9 @@ $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(OFSTE $(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@ -$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py +$(CRIME) $(CRIME_BY_YEAR) $(CRIME_RECORDS) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py $(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv" - uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) + uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) --output-records $(CRIME_RECORDS) $(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DEPS) uv run python -m pipeline.transform.poi_proximity --arcgis $(ARCGIS) --pois $(POIS_FILTERED) --greenspace $(OS_GREENSPACE) --output $@ @@ -450,16 +459,42 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ $(POSTCODES_PQ) $(PROPERTIES_PQ) &: $(MERGE_STAMP) $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" -$(PRICE_INDEX): $(MERGE_STAMP) $(PRICE_INDEX_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) - uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@ +# Slim price-estimation inputs, built straight from epc_pp + arcgis (NOT the +# merge outputs). This is what decouples the expensive index/kNN from merge: +# adding an area or property column to merge does not change these files, so the +# price index and estimates are reused and only the cheap join re-runs. +$(PRICE_INPUTS) $(POSTCODE_CENTROIDS) &: $(EPC_PP) $(ARCGIS) $(PRICE_INPUTS_DEPS) + uv run python -m pipeline.transform.property_base --epc-pp $(EPC_PP) --arcgis $(ARCGIS) --output $(PRICE_INPUTS) --centroids $(POSTCODE_CENTROIDS) + $(VALIDATE_OUTPUTS) --parquet $(PRICE_INPUTS) --parquet $(POSTCODE_CENTROIDS) + +$(PRICE_INDEX): $(PRICE_INPUTS) $(POSTCODE_CENTROIDS) $(PRICE_INDEX_DEPS) + uv run python -m pipeline.transform.price_estimation.index --input $(PRICE_INPUTS) --postcodes $(POSTCODE_CENTROIDS) --output $@ $(VALIDATE_OUTPUTS) --parquet $@ -$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) +# Estimate prices on the slim inputs and write a standalone price_estimates.parquet +# (natural key + the two estimate columns). Never touches properties.parquet. +$(PRICE_ESTIMATES): $(PRICE_INPUTS) $(POSTCODE_CENTROIDS) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) + uv run python -m pipeline.transform.price_estimation.estimate --input $(PRICE_INPUTS) --postcodes $(POSTCODE_CENTROIDS) --index $(PRICE_INDEX) --output $@ + $(VALIDATE_OUTPUTS) --parquet $@ + +# Join the estimate columns back onto properties.parquet by natural key +# (idempotent, atomic). Re-runs whenever merge or the estimates change. +$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_ESTIMATES) $(PRICE_JOIN_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) $(PRICE_INDEX) @rm -f $@ - uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX) + uv run python -m pipeline.transform.join_price_estimates --properties $(PROPERTIES_PQ) --estimates $(PRICE_ESTIMATES) $(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX) @touch $@ +# ── Area crime averages (post-merge) ──────────────────── +# National / per-outcode / per-sector property-weighted mean headline crime +# rates for the right pane. Depends on $(PRICES_STAMP) so it reads the +# finalised properties.parquet (the price-estimate join rewrites it); only the +# per-postcode property counts and the crime values from postcode.parquet are +# used, both unaffected by that join. +$(AREA_CRIME_AVERAGES): $(PRICES_STAMP) $(AREA_CRIME_AVERAGES_DEPS) | $(POSTCODES_PQ) $(PROPERTIES_PQ) + uv run python -m pipeline.transform.area_crime_averages --postcodes $(POSTCODES_PQ) --properties $(PROPERTIES_PQ) --output $@ + $(VALIDATE_OUTPUTS) --parquet $@ + $(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \ $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ $(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \ diff --git a/docker-compose.yml b/docker-compose.yml index 81a8413..45825c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: command: > bash -c " cargo install cargo-watch && - cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --population-path /app/property-data/population_by_postcode.parquet' + cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --crime-records-path /app/property-data/crime_records.parquet --area-crime-averages-path /app/property-data/area_crime_averages.parquet --population-path /app/property-data/population_by_postcode.parquet' " ports: - "8001:8001" diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 0c18e12..a067b91 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -383,7 +383,7 @@ export function SavedPage({ }) .catch((err) => { if (!cancelled) { - setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links'); + setShareLinksError(err instanceof Error ? err.message : t('accountPage.fetchShareLinksError')); } }) .finally(() => { @@ -393,7 +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 ${ @@ -738,7 +738,7 @@ function InviteSection({ user }: { user: AuthUser }) { setInviteUrl((prev) => ({ ...prev, [type]: data.url })); fetchInviteHistory(); } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to create invite'; + const msg = err instanceof Error ? err.message : t('invitesPage.createInviteError'); setInviteError((prev) => ({ ...prev, [type]: msg })); } finally { setCreatingInvite((prev) => ({ ...prev, [type]: false })); @@ -931,7 +931,7 @@ export default function AccountPage({ assertOk(res, 'Update newsletter'); await onRefreshAuth(); } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to update newsletter'; + const msg = err instanceof Error ? err.message : t('accountPage.updateNewsletterError'); setNewsletterError(msg); } finally { setNewsletterSaving(false); diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 70cc34d..d43ba0b 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -150,7 +150,7 @@ function ProductDemoVideo() { diff --git a/frontend/src/components/home/ProductShowcase.tsx b/frontend/src/components/home/ProductShowcase.tsx index b4bc291..4273c72 100644 --- a/frontend/src/components/home/ProductShowcase.tsx +++ b/frontend/src/components/home/ProductShowcase.tsx @@ -74,7 +74,7 @@ const DEMO_FEATURES: FeatureMeta[] = [ prefix: '£', }, { - name: 'Serious crime (avg/yr)', + name: 'Serious crime (/yr, 7y)', type: 'numeric', group: 'Crime', min: 0, @@ -763,7 +763,7 @@ function RightPaneOnlyScreen({ {t('home.showcasePoliticalVoteShare')} - 2024 GE + {t('home.showcaseGe2024')} setIsPlaying(false)} onEnded={() => setIsPlaying(false)} > - + {!isPlaying && (
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 64ebd40..8b0b80d 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -1,15 +1,8 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MutableRefObject, - type ReactNode, -} from 'react'; +import { useCallback, useMemo, useState, type MutableRefObject, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ts } from '../../i18n/server'; import type { + CrimeRecordsResponse, FeatureFilters, FeatureGroup, FeatureMeta, @@ -39,12 +32,14 @@ import { } from '../../lib/consts'; import { useNearbyStations } from '../../hooks/useNearbyStations'; import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop'; +import { useRevealOnExpand } from '../../hooks/useRevealOnExpand'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import EnumBarChart from './EnumBarChart'; import StackedBarChart from './StackedBarChart'; import StackedEnumChart from './StackedEnumChart'; import PriceHistoryChart from './PriceHistoryChart'; import CrimeYearChart from './CrimeYearChart'; +import CrimeGroupBody from './CrimeGroupBody'; import NumberLine, { type NumberLinePoint } from './NumberLine'; import ExternalSearchLinks from './ExternalSearchLinks'; import { InfoIcon, TransitIcon } from '../ui/icons'; @@ -72,6 +67,7 @@ interface AreaPaneProps { shareCode?: string; isGroupExpanded: (name: string) => boolean; onToggleGroup: (name: string) => void; + onLoadCrimeRecords?: (offset: number) => Promise; scrollTopRef?: MutableRefObject; scrollRestoreKey?: string | null; scrollSaveDisabled?: boolean; @@ -242,6 +238,7 @@ export default function AreaPane({ shareCode, isGroupExpanded, onToggleGroup, + onLoadCrimeRecords, scrollTopRef, scrollRestoreKey, scrollSaveDisabled, @@ -297,61 +294,22 @@ export default function AreaPane({ // When the user expands a group, scroll the bottom of the newly opened content // into view so the whole group is revealed without manual scrolling. The group // header stays pinned at the top via its sticky positioning. - const scrollContainerRef = useRef(null); + const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand(); const setScrollNode = useCallback( (node: HTMLDivElement | null) => { - scrollContainerRef.current = node; + setContainer(node); scrollRef(node); }, - [scrollRef] + [setContainer, scrollRef] ); - const groupRefs = useRef>(new Map()); - // A fresh object each toggle so re-expanding the same group still re-triggers. - const [groupToReveal, setGroupToReveal] = useState<{ name: string } | null>(null); const handleToggleGroup = useCallback( (name: string) => { const willExpand = !isGroupExpanded(name); onToggleGroup(name); - setGroupToReveal(willExpand ? { name } : null); + revealGroupOnToggle(name, willExpand); }, - [isGroupExpanded, onToggleGroup] + [isGroupExpanded, onToggleGroup, revealGroupOnToggle] ); - useEffect(() => { - if (!groupToReveal) return; - const el = groupRefs.current.get(groupToReveal.name); - const container = scrollContainerRef.current; - if (!el || !container) return; - - // Scroll down just enough to bring the bottom of the opened group into view. - // For groups taller than the pane this scrolls past the header, but it stays - // visible via its sticky positioning. Never scroll up (group already in view). - const alignBottom = () => { - const delta = el.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom; - if (delta <= 1) return; - container.scrollTo({ top: container.scrollTop + delta }); - }; - - alignBottom(); - - // The group's charts and async data (e.g. nearby stations) can grow its - // height after this first pass, so keep the bottom pinned as it settles. - // Stop once the user scrolls or after a short grace period so we never fight - // a deliberate scroll. - let timer = 0; - const observer = new ResizeObserver(alignBottom); - const stop = () => { - observer.disconnect(); - container.removeEventListener('wheel', stop); - container.removeEventListener('touchmove', stop); - window.clearTimeout(timer); - }; - observer.observe(el); - container.addEventListener('wheel', stop, { passive: true }); - container.addEventListener('touchmove', stop, { passive: true }); - timer = window.setTimeout(stop, 1500); - - return stop; - }, [groupToReveal]); const numericByName = useMemo(() => { if (!stats) return new Map(); @@ -363,26 +321,21 @@ export default function AreaPane({ return new Map(stats.enum_features.map((feature) => [feature.name, feature])); }, [stats]); - // Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary"). - // We also index by the configured feature name (with " (avg/yr)" suffix) so the - // metric-row renderer can look it up using the feature name it already has. - const crimeByYearByFeatureName = useMemo(() => { + // Crime-by-year series, keyed by the bare crime type (e.g. "Burglary"). + const crimeByYearByType = useMemo(() => { const map = new Map[number]>(); for (const entry of stats?.crime_by_year ?? []) { map.set(entry.name, entry); - map.set(`${entry.name} (avg/yr)`, entry); } return map; }, [stats]); - // Per-crime-type outcode/sector averages, keyed by both the bare crime type - // and the " (avg/yr)" feature name (same convention as crimeByYearByFeatureName) - // so renderers can look them up by the feature name they already hold. + // Per-rate-feature outcode/sector averages, keyed by the FULL rate-feature name + // (e.g. "Burglary (/yr, 7y)") the comparison is computed against. const crimeAreaAvgByName = useMemo(() => { const map = new Map[number]>(); for (const entry of stats?.crime_area_averages ?? []) { map.set(entry.name, entry); - map.set(`${entry.name} (avg/yr)`, entry); } return map; }, [stats]); @@ -440,7 +393,11 @@ export default function AreaPane({ <>
-
+
@@ -615,13 +572,7 @@ 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) => ({ @@ -651,7 +615,7 @@ export default function AreaPane({ ? aggregateStats.mean : displaySegments.reduce((sum, s) => sum + s.value, 0); - // Use rateFeature (e.g. per-1k) for display if available + // Use rateFeature (e.g. a percentage) for display if available const rateStats = chart.rateFeature ? numericByName.get(chart.rateFeature) : undefined; @@ -715,7 +679,7 @@ export default function AreaPane({ if (total === 0) return null; const crimeSeries = chart.feature - ? crimeByYearByFeatureName.get(chart.feature) + ? crimeByYearByType.get(chart.feature) : undefined; return ( @@ -800,7 +764,7 @@ export default function AreaPane({ const globalMean = globalHistogram ? calculateHistogramMean(globalHistogram) : undefined; - const crimeSeries = crimeByYearByFeatureName.get(feature.name); + 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 @@ -992,6 +956,8 @@ export default function AreaPane({
); })} + + )}
)}
diff --git a/frontend/src/components/map/CrimeGroupBody.tsx b/frontend/src/components/map/CrimeGroupBody.tsx new file mode 100644 index 0000000..20e8181 --- /dev/null +++ b/frontend/src/components/map/CrimeGroupBody.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ts } from '../../i18n/server'; +import type { + CrimeAreaAverage, + CrimeRecordsResponse, + CrimeYearStats, + FeatureMeta, + HexagonStatsResponse, + NumericFeatureStats, +} from '../../types'; +import { STACKED_GROUPS, STACKED_SEGMENT_COLORS } from '../../lib/consts'; +import { formatValue, calculateHistogramMean } from '../../lib/format'; +import { FeatureLabel } from '../ui/FeatureLabel'; +import StackedBarChart from './StackedBarChart'; +import NumberLine, { type NumberLinePoint } from './NumberLine'; +import CrimeYearChart from './CrimeYearChart'; +import CrimeRecordsSection from './CrimeRecordsSection'; + +type CrimeWindow = '7y' | '2y'; + +/** Strip the " (/yr, 7y|2y)" suffix to the bare crime type. */ +function bareType(featureName: string): string { + return featureName.replace(/ \(\/yr, \dy\)$/, ''); +} +/** Re-target a crime-feature name at the chosen averaging window. */ +function toWindow(featureName: string, window: CrimeWindow): string { + return featureName.replace(/(\/yr, )\dy/, `$1${window}`); +} + +/** + * Segment colours keyed by the BARE crime type. The stacked bar then labels each + * segment with the clean crime name (e.g. "Burglary") and keeps one stable colour + * regardless of whether the 7-year or 2-year window is selected. + */ +const SEGMENT_COLOR_BY_BARE: Record = Object.fromEntries( + Object.entries(STACKED_SEGMENT_COLORS).map(([name, color]) => [bareType(name), color]) +); + +interface CrimeGroupBodyProps { + stats: HexagonStatsResponse; + numericByName: Map; + /** Keyed by the FULL crime-feature name (e.g. "Burglary (/yr, 7y)"). */ + crimeAreaAvgByName: Map; + /** Keyed by the bare crime type (e.g. "Burglary"). */ + crimeByYearByType: Map; + globalFeatureByName: Map; + onShowInfo: (feature: FeatureMeta) => void; + onLoadCrimeRecords?: (offset: number) => Promise; + selectionKey: string | null; +} + +export default function CrimeGroupBody({ + stats, + numericByName, + crimeAreaAvgByName, + crimeByYearByType, + globalFeatureByName, + onShowInfo, + onLoadCrimeRecords, + selectionKey, +}: CrimeGroupBodyProps) { + const { t } = useTranslation(); + // One selector drives every value and comparison in this group. + const [crimeWindow, setCrimeWindow] = useState('7y'); + + const fmtCount = (value?: number) => (value == null ? '—' : formatValue(value)); + + const rollupCards = STACKED_GROUPS.Crime ?? []; + + return ( + <> +
+ + {t('areaPane.crimeWindowLabel')} + +
+ {(['7y', '2y'] as const).map((w) => { + const active = crimeWindow === w; + return ( + + ); + })} +
+
+ + {rollupCards.map((card) => { + if (!card.feature) return null; + const bare = bareType(card.feature); + const windowFeature = toWindow(card.feature, crimeWindow); + const rate = numericByName.get(windowFeature)?.mean; + + const segments = card.components + .map((name) => ({ + name: bareType(name), + value: numericByName.get(toWindow(name, crimeWindow))?.mean ?? 0, + })) + .filter((s) => s.value > 0); + const total = segments.reduce((sum, s) => sum + s.value, 0); + if (rate == null && total === 0) return null; + + const displayValue = rate ?? total; + // Info popup / icon stay anchored to the 7-year feature so the metric + // definition is stable across windows; only the numbers change. + const featureMeta = globalFeatureByName.get(card.feature); + const globalMean = featureMeta?.histogram + ? calculateHistogramMean(featureMeta.histogram) + : undefined; + const crimeAreaAvg = crimeAreaAvgByName.get(windowFeature); + const nationalAvg = crimeAreaAvg?.national ?? globalMean; + const cardTitle = t('areaPane.crimeCardTitle', { name: ts(card.label) }); + + const numberLinePoints: NumberLinePoint[] = crimeAreaAvg + ? ( + [ + { kind: 'area', label: t('areaPane.thisArea'), value: displayValue }, + nationalAvg != null + ? { kind: 'national', label: t('areaPane.national'), value: nationalAvg } + : null, + crimeAreaAvg.outcode != null + ? { + kind: 'outcode', + label: stats.crime_outcode ?? t('areaPane.outcodeAvg'), + value: crimeAreaAvg.outcode, + } + : null, + crimeAreaAvg.sector != null + ? { + kind: 'sector', + label: stats.crime_sector ?? t('areaPane.sectorAvg'), + value: crimeAreaAvg.sector, + } + : null, + ] as (NumberLinePoint | null)[] + ).filter((p): p is NumberLinePoint => p !== null) + : []; + + const series = crimeByYearByType.get(bare); + + return ( +
+
+ {featureMeta ? ( + + ) : ( + + {cardTitle} + + )} +
+ + {fmtCount(displayValue)} + +
+
+ + {total > 0 && ( + + )} + {numberLinePoints.length >= 2 && ( +
+ +
+ )} + {series && series.points.length > 1 && ( +
+ +
+ )} +
+ ); + })} + + {onLoadCrimeRecords && ( + + )} + + ); +} diff --git a/frontend/src/components/map/CrimeRecordsSection.tsx b/frontend/src/components/map/CrimeRecordsSection.tsx new file mode 100644 index 0000000..8326513 --- /dev/null +++ b/frontend/src/components/map/CrimeRecordsSection.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ts } from '../../i18n/server'; +import type { CrimeIncident, CrimeRecordsResponse } from '../../types'; +import { isAbortError, logNonAbortError } from '../../lib/api'; + +function formatMonth(month: string, locale: string): string { + const [year, m] = month.split('-').map(Number); + if (!year || !m) return month; + return new Date(Date.UTC(year, m - 1, 1)).toLocaleDateString(locale, { + year: 'numeric', + month: 'short', + timeZone: 'UTC', + }); +} + +interface CrimeRecordsSectionProps { + /** Identity of the current selection; changing it resets the list. */ + selectionKey: string | null; + /** Total records for the selection (from stats); the section hides when 0. */ + total?: number; + onLoad: (offset: number) => Promise; +} + +export default function CrimeRecordsSection({ + selectionKey, + total, + onLoad, +}: CrimeRecordsSectionProps) { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const [records, setRecords] = useState([]); + const [loadedTotal, setLoadedTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + // Bumped whenever the selection changes; in-flight responses for a previous + // selection are ignored when they resolve. + const selectionIdRef = useRef(0); + + useEffect(() => { + selectionIdRef.current += 1; + setOpen(false); + setRecords([]); + setLoadedTotal(0); + setLoading(false); + setError(false); + }, [selectionKey]); + + if (!total || total <= 0) return null; + + const fetchPage = async (offset: number) => { + const myId = selectionIdRef.current; + setLoading(true); + setError(false); + try { + const data = await onLoad(offset); + if (myId !== selectionIdRef.current) return; + setRecords((prev) => (offset === 0 ? data.records : [...prev, ...data.records])); + setLoadedTotal(data.total); + } catch (err) { + if (isAbortError(err)) return; + logNonAbortError('Failed to fetch crime records', err); + if (myId === selectionIdRef.current) setError(true); + } finally { + if (myId === selectionIdRef.current) setLoading(false); + } + }; + + const handleToggle = () => { + const next = !open; + setOpen(next); + if (next && records.length === 0 && !loading) fetchPage(0); + }; + + const shownTotal = loadedTotal || total; + const canLoadMore = records.length < loadedTotal; + + return ( +
+ + + {open && ( +
+ {error ? ( +
+ {t('areaPane.crimeRecordsError')} +
+ ) : records.length === 0 && loading ? ( +
+ + {t('areaPane.crimeRecordsLoading')} +
+ ) : records.length === 0 ? ( +
+ {t('areaPane.crimeRecordsEmpty')} +
+ ) : ( + <> +
    + {records.map((record, index) => ( +
  1. +
    + + {ts(record.type)} + + + {formatMonth(record.month, i18n.language)} + +
    +
    + {record.location ?? t('areaPane.crimeNoLocation')} +
    +
    + {record.outcome ?? t('areaPane.crimeOutcomeUnknown')} +
    +
  2. + ))} +
+ {canLoadMore && ( + + )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/map/CrimeYearChart.tsx b/frontend/src/components/map/CrimeYearChart.tsx index b518c21..dc60ffe 100644 --- a/frontend/src/components/map/CrimeYearChart.tsx +++ b/frontend/src/components/map/CrimeYearChart.tsx @@ -82,7 +82,12 @@ export default function CrimeYearChart({ points, latestAvailableYear }: CrimeYea r={1.6} className="fill-rose-700 dark:fill-rose-300" > - {`${p.year}: ${p.count.toFixed(1)}/yr`} + + {t('areaPane.crimeYearPointTooltip', { + year: p.year, + rate: p.count.toFixed(1), + })} + ))} void; travelTimeEntries: TravelTimeEntry[]; onAddTravelTimeEntry: (mode: TransportMode) => void; + /** Registers a group wrapper so an expanded group can be scrolled into view. */ + registerGroup?: (name: string) => (node: HTMLDivElement | null) => void; + /** Notifies the reveal-on-expand mechanism when a group is toggled. */ + onGroupToggle?: (name: string, willExpand: boolean) => void; } export default function FeatureBrowser({ @@ -46,6 +50,8 @@ export default function FeatureBrowser({ onClearOpenInfoFeature, travelTimeEntries: _travelTimeEntries, onAddTravelTimeEntry, + registerGroup, + onGroupToggle, }: FeatureBrowserProps) { const { t } = useTranslation(); const modes = useTranslatedModes(); @@ -115,11 +121,15 @@ export default function FeatureBrowser({ {mergedGrouped.map((group) => { const isExpanded = isSearching || isGroupExpanded(group.name); return ( -
+
toggleGroup(group.name)} + onToggle={() => { + const willExpand = !isExpanded; + toggleGroup(group.name); + onGroupToggle?.(group.name, willExpand); + }} className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800" > diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 32ee222..28e8f82 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -21,6 +21,16 @@ import { isSpecificCrimeFeatureName, isSpecificCrimeFilterName, } from '../../lib/crime-filter'; +import { + CRIME_SEVERITY_FILTER_NAMES, + getCrimeSeverityFeatureName, + getCrimeSeverityFilterMeta, + getCrimeSeverityFilterName, + getDefaultCrimeSeverityFeatureName, + isCrimeSeverityFeatureName, + isCrimeSeverityFilterName, + type CrimeSeverityFilterName, +} from '../../lib/crime-severity-filter'; import { ELECTION_VOTE_SHARE_FILTER_NAME, getDefaultElectionVoteShareFeatureName, @@ -37,7 +47,22 @@ import { isEthnicityFeatureName, isEthnicityFilterName, } from '../../lib/ethnicity-filter'; -import { isQualificationFeatureName } from '../../lib/qualification-filter'; +import { + QUALIFICATIONS_FILTER_NAME, + getDefaultQualificationFeatureName, + getQualificationFeatureName, + getQualificationFilterMeta, + isQualificationFeatureName, + isQualificationFilterName, +} from '../../lib/qualification-filter'; +import { + TENURE_FILTER_NAME, + getDefaultTenureFeatureName, + getTenureFeatureName, + getTenureFilterMeta, + isTenureFeatureName, + isTenureFilterName, +} from '../../lib/tenure-filter'; import { SCHOOL_FILTER_NAME, getDefaultSchoolFeatureName, @@ -182,6 +207,33 @@ export default memo(function Filters({ [features] ); const ethnicityMeta = useMemo(() => getEthnicityFilterMeta(features), [features]); + const defaultQualificationFeatureName = useMemo( + () => getDefaultQualificationFeatureName(features), + [features] + ); + const qualificationMeta = useMemo(() => getQualificationFilterMeta(features), [features]); + const defaultTenureFeatureName = useMemo(() => getDefaultTenureFeatureName(features), [features]); + const tenureMeta = useMemo(() => getTenureFilterMeta(features), [features]); + const crimeSeverityMetas = useMemo( + () => + Object.fromEntries( + CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [ + filterName, + getCrimeSeverityFilterMeta(features, filterName), + ]) + ) as Record, + [features] + ); + const defaultCrimeSeverityFeatureNames = useMemo( + () => + Object.fromEntries( + CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [ + filterName, + getDefaultCrimeSeverityFeatureName(features, filterName), + ]) + ) as Record, + [features] + ); const defaultPoiDistanceFeatureName = useMemo( () => getDefaultPoiDistanceFeatureName(features), [features] @@ -256,6 +308,18 @@ export default memo(function Filters({ return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' }; }); }, [filters, features, specificCrimeMeta]); + const crimeSeverityFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isCrimeSeverityFilterName) + .map((name) => { + const backendName = getCrimeSeverityFeatureName(name); + const filterName = getCrimeSeverityFilterName(name) ?? CRIME_SEVERITY_FILTER_NAMES[0]; + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? crimeSeverityMetas[filterName]), name, group: 'Crime' }; + }); + }, [filters, features, crimeSeverityMetas]); const electionVoteShareFilterItems = useMemo(() => { return Object.keys(filters) .filter(isElectionVoteShareFilterName) @@ -278,6 +342,28 @@ export default memo(function Filters({ return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' }; }); }, [filters, features, ethnicityMeta]); + const qualificationFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isQualificationFilterName) + .map((name) => { + const backendName = getQualificationFeatureName(name); + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? qualificationMeta), name, group: 'Neighbours' }; + }); + }, [filters, features, qualificationMeta]); + const tenureFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isTenureFilterName) + .map((name) => { + const backendName = getTenureFeatureName(name); + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? tenureMeta), name, group: 'Neighbours' }; + }); + }, [filters, features, tenureMeta]); const poiDistanceFilterItems = useMemo(() => { return Object.keys(filters) .filter(isPoiDistanceFilterName) @@ -300,6 +386,8 @@ export default memo(function Filters({ let insertedSpecificCrimeFilter = false; let insertedElectionVoteShareFilter = false; let insertedEthnicityFilter = false; + let insertedQualificationFilter = false; + let insertedTenureFilter = false; const insertedPoiFilters = new Set(); const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => { if ( @@ -311,6 +399,17 @@ export default memo(function Filters({ insertedPoiFilters.add(filterName); } }; + const insertedCrimeSeverityFilters = new Set(); + const maybeInsertCrimeSeverityFilter = (filterName: CrimeSeverityFilterName | null) => { + if ( + filterName && + defaultCrimeSeverityFeatureNames[filterName] && + !insertedCrimeSeverityFilters.has(filterName) + ) { + result.push(crimeSeverityMetas[filterName]); + insertedCrimeSeverityFilters.add(filterName); + } + }; for (const feature of features) { if (feature.group === 'Transport') { @@ -330,6 +429,12 @@ export default memo(function Filters({ } continue; } + // "Serious crime" and "Minor crime" each fold their 7y/2y windows into one + // card with a period toggle (no variant dropdown — a single feature each). + if (isCrimeSeverityFeatureName(feature.name)) { + maybeInsertCrimeSeverityFilter(getCrimeSeverityFilterName(feature.name)); + continue; + } if (isElectionVoteShareFeatureName(feature.name)) { if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) { result.push(electionVoteShareMeta); @@ -344,14 +449,28 @@ export default memo(function Filters({ } continue; } + // The seven qualification bands fold into one "Qualifications" filter + // whose dropdown picks a band, rather than seven separate sliders. + if (isQualificationFeatureName(feature.name)) { + if (defaultQualificationFeatureName && !insertedQualificationFilter) { + result.push(qualificationMeta); + insertedQualificationFilter = true; + } + continue; + } + // The three tenure categories fold into one "Tenure" filter + // whose dropdown picks a category, rather than three separate sliders. + if (isTenureFeatureName(feature.name)) { + if (defaultTenureFeatureName && !insertedTenureFilter) { + result.push(tenureMeta); + insertedTenureFilter = true; + } + continue; + } if (isPoiFilterFeatureName(feature.name)) { maybeInsertPoiFilter(getPoiFilterName(feature.name)); continue; } - // Qualification breakdown is display-only (shown as the stacked - // "Qualifications" composition in the area pane), so keep its seven - // component features out of the filter browser. - if (isQualificationFeatureName(feature.name)) continue; if (!enabledFeatures.has(feature.name)) result.push(feature); } @@ -363,10 +482,16 @@ export default memo(function Filters({ schoolMeta, defaultSpecificCrimeFeatureName, specificCrimeMeta, + defaultCrimeSeverityFeatureNames, + crimeSeverityMetas, defaultElectionVoteShareFeatureName, electionVoteShareMeta, defaultEthnicityFeatureName, ethnicityMeta, + defaultQualificationFeatureName, + qualificationMeta, + defaultTenureFeatureName, + tenureMeta, defaultPoiFilterFeatureNames, poiFilterMetas, ]); @@ -376,6 +501,8 @@ export default memo(function Filters({ let insertedSpecificCrimeFilters = false; let insertedElectionVoteShareFilters = false; let insertedEthnicityFilters = false; + let insertedQualificationFilters = false; + let insertedTenureFilters = false; const insertedPoiFilters = new Set(); const insertPoiFilterItems = (filterName: PoiFilterName | null) => { if (!filterName || insertedPoiFilters.has(filterName)) return; @@ -384,6 +511,16 @@ export default memo(function Filters({ ); insertedPoiFilters.add(filterName); }; + const insertedCrimeSeverityFilters = new Set(); + const insertCrimeSeverityFilterItems = (filterName: CrimeSeverityFilterName | null) => { + if (!filterName || insertedCrimeSeverityFilters.has(filterName)) return; + result.push( + ...crimeSeverityFilterItems.filter( + (item) => getCrimeSeverityFilterName(item.name) === filterName + ) + ); + insertedCrimeSeverityFilters.add(filterName); + }; for (const feature of features) { if (feature.group === 'Transport') { @@ -403,6 +540,10 @@ export default memo(function Filters({ } continue; } + if (isCrimeSeverityFeatureName(feature.name)) { + insertCrimeSeverityFilterItems(getCrimeSeverityFilterName(feature.name)); + continue; + } if (isElectionVoteShareFeatureName(feature.name)) { if (!insertedElectionVoteShareFilters) { result.push(...electionVoteShareFilterItems); @@ -417,6 +558,20 @@ export default memo(function Filters({ } continue; } + if (isQualificationFeatureName(feature.name)) { + if (!insertedQualificationFilters) { + result.push(...qualificationFilterItems); + insertedQualificationFilters = true; + } + continue; + } + if (isTenureFeatureName(feature.name)) { + if (!insertedTenureFilters) { + result.push(...tenureFilterItems); + insertedTenureFilters = true; + } + continue; + } if (isPoiFilterFeatureName(feature.name)) { insertPoiFilterItems(getPoiFilterName(feature.name)); continue; @@ -430,8 +585,11 @@ export default memo(function Filters({ enabledFeatures, schoolFilterItems, specificCrimeFilterItems, + crimeSeverityFilterItems, electionVoteShareFilterItems, ethnicityFilterItems, + qualificationFilterItems, + tenureFilterItems, poiDistanceFilterItems, ]); @@ -460,10 +618,15 @@ export default memo(function Filters({ (name: string): string | null => { if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools'; if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime'; + if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) { + return crimeSeverityMetas[name as CrimeSeverityFilterName].group ?? 'Crime'; + } if (name === ELECTION_VOTE_SHARE_FILTER_NAME) { return electionVoteShareMeta.group ?? 'Neighbours'; } if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours'; + if (name === QUALIFICATIONS_FILTER_NAME) return qualificationMeta.group ?? 'Neighbours'; + if (name === TENURE_FILTER_NAME) return tenureMeta.group ?? 'Neighbours'; if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { return poiFilterMetas[name as PoiFilterName].group ?? null; } @@ -472,8 +635,11 @@ export default memo(function Filters({ [ electionVoteShareMeta.group, ethnicityMeta.group, + qualificationMeta.group, + tenureMeta.group, features, poiFilterMetas, + crimeSeverityMetas, schoolMeta.group, specificCrimeMeta.group, ] @@ -514,6 +680,28 @@ export default memo(function Filters({ onAddFilter(ETHNICITIES_FILTER_NAME); return; } + if (name === QUALIFICATIONS_FILTER_NAME) { + if (!defaultQualificationFeatureName) return; + queueActiveFilterScroll( + QUALIFICATIONS_FILTER_NAME, + getAddFilterGroupName(QUALIFICATIONS_FILTER_NAME) + ); + onAddFilter(QUALIFICATIONS_FILTER_NAME); + return; + } + if (name === TENURE_FILTER_NAME) { + if (!defaultTenureFeatureName) return; + queueActiveFilterScroll(TENURE_FILTER_NAME, getAddFilterGroupName(TENURE_FILTER_NAME)); + onAddFilter(TENURE_FILTER_NAME); + return; + } + if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) { + const severityFilterName = name as CrimeSeverityFilterName; + if (!defaultCrimeSeverityFeatureNames[severityFilterName]) return; + queueActiveFilterScroll(severityFilterName, getAddFilterGroupName(severityFilterName)); + onAddFilter(severityFilterName); + return; + } if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { const filterName = name as PoiFilterName; if (!defaultPoiFilterFeatureNames[filterName]) return; @@ -528,8 +716,11 @@ export default memo(function Filters({ [ defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, + defaultCrimeSeverityFeatureNames, defaultElectionVoteShareFeatureName, defaultEthnicityFeatureName, + defaultQualificationFeatureName, + defaultTenureFeatureName, defaultPoiFilterFeatureNames, getAddFilterGroupName, onAddFilter, @@ -686,8 +877,11 @@ export default memo(function Filters({ ...features, schoolMeta, specificCrimeMeta, + ...Object.values(crimeSeverityMetas), electionVoteShareMeta, ethnicityMeta, + qualificationMeta, + tenureMeta, poiDistanceMeta, transportDistanceMeta, poiCount2KmMeta, @@ -696,8 +890,11 @@ export default memo(function Filters({ pinnedFeature={pinnedFeature} defaultSchoolFeatureName={defaultSchoolFeatureName} defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName} + defaultCrimeSeverityFeatureNames={defaultCrimeSeverityFeatureNames} defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName} defaultEthnicityFeatureName={defaultEthnicityFeatureName} + defaultQualificationFeatureName={defaultQualificationFeatureName} + defaultTenureFeatureName={defaultTenureFeatureName} defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames} openInfoFeature={openInfoFeature} travelTimeEntries={travelTimeEntries} diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index be04ef6..d738f37 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -5,8 +5,11 @@ import { formatValue } from '../../lib/format'; import { ts } from '../../i18n/server'; import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter'; import { getSpecificCrimeFeatureName } from '../../lib/crime-filter'; +import { getCrimeSeverityFeatureName } from '../../lib/crime-severity-filter'; import { getElectionVoteShareFeatureName } from '../../lib/election-filter'; import { getEthnicityFeatureName } from '../../lib/ethnicity-filter'; +import { getQualificationFeatureName } from '../../lib/qualification-filter'; +import { getTenureFeatureName } from '../../lib/tenure-filter'; import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName, @@ -52,14 +55,20 @@ export default memo(function HoverCard({ for (const name of activeFilterNames.slice(0, 4)) { const schoolBackendName = getSchoolBackendFeatureName(name); const specificCrimeFeatureName = getSpecificCrimeFeatureName(name); + const crimeSeverityFeatureName = getCrimeSeverityFeatureName(name); const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name); const ethnicityFeatureName = getEthnicityFeatureName(name); + const qualificationFeatureName = getQualificationFeatureName(name); + const tenureFeatureName = getTenureFeatureName(name); const poiDistanceFeatureName = getPoiDistanceFeatureName(name); const backendName = schoolBackendName ?? specificCrimeFeatureName ?? + crimeSeverityFeatureName ?? electionVoteShareFeatureName ?? ethnicityFeatureName ?? + qualificationFeatureName ?? + tenureFeatureName ?? poiDistanceFeatureName ?? name; const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`]; diff --git a/frontend/src/components/map/JourneyInstructions.test.tsx b/frontend/src/components/map/JourneyInstructions.test.tsx index a8cfcd8..40dfd9f 100644 --- a/frontend/src/components/map/JourneyInstructions.test.tsx +++ b/frontend/src/components/map/JourneyInstructions.test.tsx @@ -16,6 +16,8 @@ vi.mock('react-i18next', () => ({ if (key === 'travel.noBuses') return 'No buses'; if (key === 'areaPane.walk') return 'Walk'; if (key === 'areaPane.cycle') return 'Cycle'; + if (key === 'journey.bus') return 'Bus'; + if (key === 'journey.lineSuffix') return 'line'; if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps'; if (key === 'areaPane.noJourneyData') return 'No journey data'; return key; diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx index c4667e0..fb32486 100644 --- a/frontend/src/components/map/JourneyInstructions.tsx +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import type { JourneyLeg } from '../../types'; import { resolveTransitVariant, type TravelTimeEntry } from '../../hooks/useTravelTime'; import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api'; @@ -106,15 +107,25 @@ function stripId(label: string): string { return label.replace(/\s+\([A-Za-z0-9]+\)$/, ''); } -function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } { +function getRouteDisplay( + mode: string, + t: TFunction +): { label: string; color: string; darkText: boolean } { const clean = stripId(mode); const known = ROUTE_COLORS[clean]; if (known) { - const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`; + const label = + NON_TUBE_NAMES.has(clean) || clean.includes('line') + ? clean + : `${clean} ${t('journey.lineSuffix')}`; return { label, color: known.color, darkText: !!known.darkText }; } if (/^\d+[A-Za-z]?$/.test(clean.trim())) { - return { label: `Bus ${clean}`, color: '#0d9488', darkText: false }; + return { + label: `${t('journey.bus')} ${clean}`, + color: '#0d9488', + darkText: false, + }; } return { label: clean, color: '#6b7280', darkText: false }; } @@ -203,7 +214,8 @@ function invertLegs(legs: JourneyLeg[]): JourneyLeg[] { } function RouteBadge({ mode }: { mode: string }) { - const { label, color, darkText } = getRouteDisplay(mode); + const { t } = useTranslation(); + const { label, color, darkText } = getRouteDisplay(mode, t); return (
diff --git a/frontend/src/components/map/ListingPopups.tsx b/frontend/src/components/map/ListingPopups.tsx index f04e99b..f7ffbeb 100644 --- a/frontend/src/components/map/ListingPopups.tsx +++ b/frontend/src/components/map/ListingPopups.tsx @@ -19,15 +19,21 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n export const ListingPopupSingleContent = memo(function ListingPopupSingleContent({ listing, + clickedUrls, + onOpen, }: { listing: ActualListing; + clickedUrls: Set; + onOpen: (url: string) => void; }) { const { t } = useTranslation(); + const visited = clickedUrls.has(listing.listing_url); return ( onOpen(listing.listing_url)} className="block px-3 py-2" > {listing.asking_price != null && ( @@ -57,7 +63,7 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent )} {listing.floor_area_sqm != null && (
- {Math.round(listing.floor_area_sqm)} sqm + {Math.round(listing.floor_area_sqm)} {t('common.sqm')} {listing.asking_price_per_sqm != null ? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm` : ''} @@ -72,8 +78,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent ))} )} -
- Open listing ↗ +
+ {visited && ✓ {t('listing.viewed')}} + {t('listing.openListing')} ↗
); @@ -82,9 +89,13 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent export const ListingClusterPopupContent = memo(function ListingClusterPopupContent({ count, listings, + clickedUrls, + onOpen, }: { count: number; listings: ActualListing[]; + clickedUrls: Set; + onOpen: (url: string) => void; }) { const { t } = useTranslation(); const visibleCount = listings.length; @@ -92,32 +103,41 @@ export const ListingClusterPopupContent = memo(function ListingClusterPopupConte
- {count.toLocaleString()} listings + {count.toLocaleString()} {t('listing.listings')}
{visibleCount > 0 - ? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}` - : 'Grouped near this map position'} + ? t('listing.showingOf', { visible: visibleCount, count }) + : t('listing.groupedNear')}
{visibleCount > 0 && (
{listings.map((listing, idx) => { const headline = formatListingHeadline(listing, t); + const visited = clickedUrls.has(listing.listing_url); return ( onOpen(listing.listing_url)} className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60" >
-
- {listing.asking_price != null - ? formatListingPrice(listing.asking_price) - : 'Listing'} +
+ + {listing.asking_price != null + ? formatListingPrice(listing.asking_price) + : t('listing.listing')} + + {visited && ( + + ✓ Viewed + + )}
{headline && (
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index c31a649..caee2d9 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -452,6 +452,8 @@ export default memo(function Map({ visiblePois, listingPopup, clearListingPopup, + clickedListingUrls, + markListingClicked, developmentPopup, clearDevelopmentPopup, hoverPosition, @@ -696,11 +698,17 @@ export default memo(function Map({ {listingPopup.mode === 'single' ? ( - + ) : ( )}
diff --git a/frontend/src/components/map/MapErrorBoundary.tsx b/frontend/src/components/map/MapErrorBoundary.tsx index 4b19af0..550d16e 100644 --- a/frontend/src/components/map/MapErrorBoundary.tsx +++ b/frontend/src/components/map/MapErrorBoundary.tsx @@ -1,6 +1,7 @@ import { Component, Fragment, type ReactNode } from 'react'; import * as Sentry from '@sentry/react'; +import i18n from '../../i18n'; import { MapFallback } from './map-page/Fallbacks'; /** @@ -113,17 +114,19 @@ export class MapErrorBoundary extends Component

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

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

diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index b951e36..039646f 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -407,6 +407,7 @@ export default function MapPage({ handlePropertiesTabClick, handleLoadMoreProperties, handleCloseSelection, + loadCrimeRecords, selectedPostcodeGeometry, handleLocationSearch, handleCurrentLocationSearch, @@ -806,6 +807,7 @@ export default function MapPage({ shareCode={shareCode} isGroupExpanded={isAreaGroupExpanded} onToggleGroup={toggleAreaGroup} + onLoadCrimeRecords={loadCrimeRecords} scrollTopRef={areaPaneScrollTopRef} scrollRestoreKey={ selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null @@ -823,6 +825,7 @@ export default function MapPage({ hexagonLocation, isAreaGroupExpanded, loadingAreaStats, + loadCrimeRecords, selectedHexagon, setAreaStatsUseFilters, shareCode, diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx index 39bdf6c..18cd292 100644 --- a/frontend/src/components/map/MobileDrawer.tsx +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -105,7 +105,11 @@ export default function MobileDrawer({ return (