diff --git a/.dockerignore b/.dockerignore index 4832cf7..908ba90 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,4 @@ analyses/ property-data manual-data !property-data/arcgis_data.parquet +Dockerfile diff --git a/Makefile.data b/Makefile.data index d6043d4..30d95b9 100644 --- a/Makefile.data +++ b/Makefile.data @@ -32,7 +32,7 @@ PRICE_INDEX := $(DATA_DIR)/price_index.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done EPC := $(MANUAL_DATA)/domestic-csv.zip ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet -CRIME_DIR := $(MANUAL_DATA)/crime +CRIME_DIR := $(DATA_DIR)/crime CRIME := $(DATA_DIR)/crime_by_lsoa.parquet CRIME_STAMP := $(CRIME_DIR)/.downloaded NOISE := $(DATA_DIR)/road_noise.parquet @@ -44,12 +44,13 @@ RENTAL := $(DATA_DIR)/rental_prices.parquet INSPIRE_DIR := $(DATA_DIR)/inspire OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet -PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries +PC_BOUNDARIES := $(DATA_DIR)/postcode_boundaries TRANSIT_DIR := $(DATA_DIR)/transit TRANSIT_STAMP := $(TRANSIT_DIR)/.done GREENSPACE := $(DATA_DIR)/greenspace_water.parquet OS_GREENSPACE := $(DATA_DIR)/os_greenspace.parquet PBF := $(DATA_DIR)/england-latest.osm.pbf +FR_TOW := $(DATA_DIR)/FR_TOW_V1_ALL.zip PLACES := $(DATA_DIR)/places.parquet LSOA_POP := $(DATA_DIR)/lsoa_population.parquet MEDIAN_AGE := $(DATA_DIR)/median_age.parquet @@ -70,13 +71,13 @@ PMTILES_VERSION := 1.22.3 download-arcgis download-price-paid download-deprivation download-ethnicity \ download-naptan download-pois download-grocery-retail-points download-ofsted download-broadband download-rental-prices \ 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-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \ + download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \ download-map-assets \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \ - transform-school-proximity transform-postcode-boundaries \ - generate-postcode-boundaries + transform-school-proximity \ + generate-postcode-boundaries generate-travel-times -prepare: $(PRICES_STAMP) +prepare: $(PRICES_STAMP) download-places tiles generate-postcode-boundaries download-map-assets merge: $(MERGE_STAMP) tiles: $(TILES) download-arcgis: $(ARCGIS) @@ -99,6 +100,7 @@ download-transit-network: $(TRANSIT_STAMP) download-greenspace: $(GREENSPACE) download-os-greenspace: $(OS_GREENSPACE) download-pbf: $(PBF) +download-fr-tow: $(FR_TOW) download-places: $(PLACES) download-lsoa-population: $(LSOA_POP) download-median-age: $(MEDIAN_AGE) @@ -111,13 +113,14 @@ transform-epc-pp: $(EPC_PP) transform-crime: $(CRIME) transform-poi-proximity: $(POI_PROXIMITY) transform-school-proximity: $(SCHOOL_PROX) -transform-postcode-boundaries: $(PC_BOUNDARIES) generate-postcode-boundaries: $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) uv run python -m pipeline.transform.postcode_boundaries \ --uprn $(UPRN_LOOKUP) \ --oa-boundaries $(OA_BOUNDARIES) \ --inspire $(INSPIRE_DIR) \ --output $(PC_BOUNDARIES) +generate-travel-times: $(ARCGIS) $(PLACES) $(PBF) $(TRANSIT_STAMP) + ./r5-java/run.sh # ── Downloads ───────────────────────────────────────────────────────────────── @@ -159,6 +162,11 @@ $(PBF): curl -L -o $@.tmp https://download.geofabrik.de/europe/united-kingdom/england-latest.osm.pbf mv $@.tmp $@ +$(FR_TOW): + @mkdir -p $(DATA_DIR) + curl -L -A "Mozilla/5.0" -o $@.tmp "https://www.mediafire.com/file_premium/p5fve6wswwwjqrq/FR_TOW_V1_ALL.zip/file" + mv $@.tmp $@ + $(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY) uv run python -m pipeline.download.pois --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY) diff --git a/README.md b/README.md index 9a7f1b8..c7d77f4 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,7 @@ The running server expects the same structure under Travel times are built separately because they are expensive: ```bash -make -f Makefile.data download-transit-network -./r5-java/run.sh --threads 8 --heap 40g +make -f Makefile.data generate-travel-times ``` For a quick R5 smoke test: @@ -200,9 +199,3 @@ docker build -t property-map . The container entrypoint runs `property-map-server` with the expected data paths under `/app/data` and serves `frontend/dist` when `--dist` is present. - - - - make -f Makefile.data tiles prepare generate-postcode-boundaries download-places - make -f Makefile.data download-map-assets - ./r5-java/run.sh --paths diff --git a/docker-compose.yml b/docker-compose.yml index 8d739bf..482d778 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: server: image: rust:1.84 init: true + tty: true + stdin_open: true working_dir: /app/server-rs command: > bash -c " diff --git a/frontend/public/video/poster.jpg b/frontend/public/video/poster.jpg index ea74d31..3131ea8 100644 Binary files a/frontend/public/video/poster.jpg and b/frontend/public/video/poster.jpg differ diff --git a/frontend/public/video/recording.mp4 b/frontend/public/video/recording.mp4 index 8902753..a3c6321 100644 Binary files a/frontend/public/video/recording.mp4 and b/frontend/public/video/recording.mp4 differ diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs index c68effd..0ec2a1c 100644 --- a/frontend/scripts/check-translations.mjs +++ b/frontend/scripts/check-translations.mjs @@ -9,19 +9,23 @@ // with the same multiset, in the translated string. // 4. descriptions.ts and details.ts: the union of feature-name keys across // languages is treated as canonical; every language must cover all of them. +// 5. The lazy locale loader map covers every non-English supported language. +// 6. Selected visible UI strings that previously slipped through are not +// hardcoded outside the i18n files. // // The script parses the TypeScript source with the compiler API and walks the // AST — no runtime import, no transpilation, no temp files. Run it with: // node frontend/scripts/check-translations.mjs import { readFileSync, readdirSync } from 'fs'; -import { dirname, join } from 'path'; +import { dirname, join, relative } from 'path'; import { fileURLToPath } from 'url'; import ts from 'typescript'; const __dirname = dirname(fileURLToPath(import.meta.url)); const I18N_DIR = join(__dirname, '..', 'src', 'i18n'); const LOCALES_DIR = join(I18N_DIR, 'locales'); +const SRC_DIR = join(__dirname, '..', 'src'); const PLACEHOLDER_RE = /\{\{\s*[a-zA-Z_][\w]*\s*\}\}/g; const HTML_TAG_RE = /<\/?[a-zA-Z][\w]*\b[^>]*>/g; @@ -31,6 +35,71 @@ const warnings = []; const fail = (msg) => errors.push(msg); const warn = (msg) => warnings.push(msg); +const SAME_AS_EN_PATH_ALLOWLIST = new Set([ + 'streetView.title', + 'home.showcaseMinutes', + 'home.showcaseStep2Sources', + 'format.lessThanMin', + 'format.moreThanMin', + 'learnPage.attrOglLink', + 'learnPage.attrOsmContrib', + 'learnPage.attrOsmLicenseLink', + 'home.showcaseTopThree', + 'learnPage.dsTravelOrigin', + 'learnPage.dsParkOrigin', + 'learnPage.dsCrimeOrigin', + 'learnPage.dsDemographicsOrigin', + 'learnPage.dsElectionOrigin', + 'learnPage.dsPoiOrigin', + 'learnPage.dsEthnicityOrigin', + 'learnPage.dsBroadbandOrigin', +]); + +const SAME_AS_EN_PATH_ALLOWLIST_RE = [/^learnPage\.ds[A-Za-z0-9]+Origin$/]; + +const SAME_AS_EN_VALUE_ALLOWLIST = new Set([ + 'Perfect Postcode', + 'Land Registry', + 'HM Land Registry', + 'ONS', + 'OpenStreetMap', + 'Ofsted', + 'Rightmove', + 'Zoopla', + 'Google', + 'Excel', + 'UK', + 'Reform UK', + 'Labour', + 'Conservative', + 'Liberal Democrat', +]); + +const FORBIDDEN_VISIBLE_STRINGS = [ + ['without this filter', 'filters.withoutThisFilter'], + ['Connecting to server...', 'common.connectingToServer'], + ['Property saved!', 'toasts.propertySaved'], + ['View saved', 'toasts.viewSaved'], + ["Don't show again", 'toasts.dontShowAgain'], + ['Close pane', 'common.closePane'], + ['Points of interest', 'poiPane.pointsOfInterest'], + ['No data', 'common.noData'], + ['All low', 'common.allLow'], + ['School type', 'filters.schoolType'], + ['School rating', 'filters.schoolRating'], + ['School distance', 'filters.schoolDistance'], + ['Crime type', 'filters.crimeType'], + ['POI type', 'filters.poiType'], + ['Matching homes', 'home.showcaseMatchingHomesLabel'], + ['Journey routes', 'home.showcaseJourneyRoutes'], + ['...and lots more', 'home.showcaseLotsMore'], + ['Send the shortlist', 'home.showcaseSendShortlist'], + ['Download .xlsx', 'home.showcaseDownloadXlsx'], + ['Product demo', 'home.productDemoLabel'], + ['Play product demo', 'home.playProductDemo'], + ['Scroll to product demo', 'home.scrollToProductDemo'], +]; + function parseFile(path) { const src = readFileSync(path, 'utf8'); return ts.createSourceFile(path, src, ts.ScriptTarget.Latest, true); @@ -44,6 +113,9 @@ function literalToJs(node) { if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) { return literalToJs(node.expression); } + if (ts.isSatisfiesExpression?.(node)) { + return literalToJs(node.expression); + } if (ts.isObjectLiteralExpression(node)) { const out = {}; for (const prop of node.properties) { @@ -79,6 +151,31 @@ function findVarInitializer(sourceFile, name) { return result; } +function propertyNameText(name) { + if (ts.isIdentifier(name)) return name.text; + if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text; + return undefined; +} + +function objectLiteralKeys(node) { + if (!node) return undefined; + if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) { + return objectLiteralKeys(node.expression); + } + if (ts.isSatisfiesExpression?.(node)) { + return objectLiteralKeys(node.expression); + } + if (!ts.isObjectLiteralExpression(node)) return undefined; + const keys = []; + for (const prop of node.properties) { + if (ts.isPropertyAssignment(prop)) { + const key = propertyNameText(prop.name); + if (key) keys.push(key); + } + } + return keys; +} + function readSupportedLanguages() { const sf = parseFile(join(I18N_DIR, 'index.ts')); const init = findVarInitializer(sf, 'SUPPORTED_LANGUAGES'); @@ -88,6 +185,15 @@ function readSupportedLanguages() { return arr.map((entry) => entry.code); } +function readLocaleLoaderCodes() { + const sf = parseFile(join(I18N_DIR, 'index.ts')); + const init = findVarInitializer(sf, 'localeLoaders'); + if (!init) throw new Error('Could not find localeLoaders in index.ts'); + const keys = objectLiteralKeys(init); + if (!keys) throw new Error('localeLoaders is not an object literal'); + return keys; +} + function readLocale(code) { const path = join(LOCALES_DIR, `${code}.ts`); const sf = parseFile(path); @@ -140,6 +246,9 @@ function checkLeafConsistency(path, enValue, trValue, lang) { fail(`[${lang}] ${path}: empty translation`); return; } + if (isSuspiciousSameAsEnglish(path, enValue, trValue)) { + warn(`[${lang}] ${path}: same as English; verify this is intentional`); + } for (const [re, label] of [ [PLACEHOLDER_RE, 'placeholder'], [HTML_TAG_RE, 'HTML tag'], @@ -155,6 +264,47 @@ function checkLeafConsistency(path, enValue, trValue, lang) { } } +function isSuspiciousSameAsEnglish(path, enValue, trValue) { + if (typeof enValue !== 'string' || typeof trValue !== 'string') return false; + if (enValue.trim() !== trValue.trim()) return false; + if (SAME_AS_EN_PATH_ALLOWLIST.has(path)) return false; + if (SAME_AS_EN_PATH_ALLOWLIST_RE.some((re) => re.test(path))) return false; + if (SAME_AS_EN_VALUE_ALLOWLIST.has(enValue.trim())) return false; + if (path.startsWith('server.')) return false; + + const text = enValue.trim(); + if (text.length < 8) return false; + if (!/[A-Za-z]/.test(text) || !/[a-z]/.test(text)) return false; + if (!/\s/.test(text)) return false; + if (/^https?:\/\//i.test(text)) return false; + if (/^[A-Z0-9 .&/()%+-]+$/.test(text)) return false; + return true; +} + +function checkLocaleLoaders(supportedCodes) { + let loaderCodes; + try { + loaderCodes = readLocaleLoaderCodes(); + } catch (e) { + fail(e.message); + return; + } + + const expected = supportedCodes.filter((code) => code !== 'en'); + for (const code of expected) { + if (!loaderCodes.includes(code)) { + fail(`localeLoaders is missing non-English supported language "${code}"`); + } + } + for (const code of loaderCodes) { + if (code === 'en') { + fail('localeLoaders should not include "en"; English is imported eagerly'); + } else if (!expected.includes(code)) { + fail(`localeLoaders includes "${code}" but it is not in SUPPORTED_LANGUAGES`); + } + } +} + function checkLocales(supportedCodes) { const localeFiles = readdirSync(LOCALES_DIR) .filter((f) => f.endsWith('.ts')) @@ -230,6 +380,57 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys) { } } +function collectSourceFiles(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + const rel = relative(SRC_DIR, path).replace(/\\/g, '/'); + if (entry.isDirectory()) { + if (rel === 'i18n' || rel.includes('/__tests__') || entry.name === '__tests__') continue; + collectSourceFiles(path, out); + continue; + } + if (!entry.isFile()) continue; + if (!/\.(ts|tsx)$/.test(entry.name)) continue; + if (/\.d\.ts$/.test(entry.name) || /\.(test|spec)\.(ts|tsx)$/.test(entry.name)) continue; + out.push(path); + } + return out; +} + +function lineNumberAt(src, index) { + return src.slice(0, index).split(/\r\n|\r|\n/).length; +} + +function checkForbiddenVisibleStrings() { + for (const file of collectSourceFiles(SRC_DIR)) { + const rel = relative(join(__dirname, '..'), file).replace(/\\/g, '/'); + const src = readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + function checkText(textNode, value) { + for (const [text, key] of FORBIDDEN_VISIBLE_STRINGS) { + if (!value.includes(text)) continue; + fail( + `${rel}:${lineNumberAt(src, textNode.getStart(sf))}: hardcoded visible string ` + + `"${text}" should use "${key}"` + ); + } + } + + function visit(node) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + checkText(node, node.text); + } else if (ts.isJsxText(node)) { + checkText(node, node.getText(sf)); + } else if (ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) { + checkText(node, node.text); + } + ts.forEachChild(node, visit); + } + visit(sf); + } +} + function main() { let supportedCodes; try { @@ -240,10 +441,12 @@ function main() { } checkLocales(supportedCodes); + checkLocaleLoaders(supportedCodes); const en = readLocale('en'); const serverKeys = new Set(Object.keys(en.server ?? {})); checkRecordCoverage('descriptions.ts', 'descriptions', supportedCodes, serverKeys); checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys); + checkForbiddenVisibleStrings(); for (const w of warnings) console.warn(`warn: ${w}`); if (errors.length > 0) { @@ -251,9 +454,7 @@ function main() { console.error(`\n${errors.length} translation error(s).`); process.exit(1); } - console.log( - `i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).` - ); + console.log(`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`); } main(); diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 2ad6974..d9b5d5e 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -333,7 +333,7 @@ function SavedSearchesTab({ @@ -441,7 +441,7 @@ function SavedPropertiesTab({ diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index fff1df4..b74f9a2 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -17,7 +17,7 @@ const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm- const HOME_PRIMARY_BUTTON_CLASS = 'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center'; const PRODUCT_DEMO_VIDEO_SRC = '/video/recording.mp4'; -const PRODUCT_DEMO_POSTER_SRC = '/video/poster.jpg'; +const PRODUCT_DEMO_POSTER_SRC = '/video/recording.jpg'; const PRODUCT_DEMO_SECTION_ID = 'product-demo-video'; function highlightBrandText(text: string) { @@ -37,6 +37,7 @@ function highlightBrandText(text: string) { } function ProductDemoVideo() { + const { t } = useTranslation(); const sectionRef = useRef(null); const videoRef = useRef(null); const [shouldLoadVideo, setShouldLoadVideo] = useState(false); @@ -94,7 +95,7 @@ function ProductDemoVideo() { playsInline preload={shouldLoadVideo ? 'metadata' : 'none'} className="block aspect-video w-full bg-navy-950 object-contain" - aria-label="Perfect Postcode product demo" + aria-label={t('home.productDemoLabel')} onPlay={() => setIsVideoPlaying(true)} onPause={() => setIsVideoPlaying(false)} onEnded={() => setIsVideoPlaying(false)} @@ -105,7 +106,7 @@ function ProductDemoVideo() { type="button" onClick={playVideo} className="pointer-events-auto group flex h-20 w-20 items-center justify-center rounded-full bg-white/95 text-coral-500 shadow-2xl shadow-navy-950/40 ring-1 ring-white/60 transition-transform hover:scale-105 focus:outline-none focus-visible:scale-105 focus-visible:ring-4 focus-visible:ring-teal-300/75 md:h-24 md:w-24" - aria-label="Play Perfect Postcode product demo" + aria-label={t('home.playProductDemo')} > @@ -327,7 +328,7 @@ export default function HomePage({ + + + +

+ {filtersActive + ? statsUseFilters + ? t('areaPane.filtersAffectStats', { count: activeFilterCount }) + : t('areaPane.filtersIgnoredForStats') : t('areaPane.noFiltersAffectStats')}

- {hasFilteredOutArea && ( + {showFlipToggleCallout && (
-

{t('areaPane.noFilteredMatches')}

+

{t('areaPane.filteredStatsEmpty')}

- {unfilteredCount != null && unfilteredCount > 0 - ? t('areaPane.unfilteredAreaCount', { count: unfilteredCount }) - : unfilteredCount === 0 - ? t('areaPane.noUnfilteredAreaProperties') - : t('areaPane.relaxFiltersHint')} + {unfilteredCount != null + ? t('areaPane.showAllStatsHint', { count: unfilteredCount }) + : t('areaPane.showAllStatsFallback')}

- {onClearFilters && ( - - )} +
)} - {stats && stats.count > 0 && ( + {canViewProperties && ( diff --git a/frontend/src/components/map/StackedBarChart.tsx b/frontend/src/components/map/StackedBarChart.tsx index b7b04ea..b8297e5 100644 --- a/frontend/src/components/map/StackedBarChart.tsx +++ b/frontend/src/components/map/StackedBarChart.tsx @@ -1,4 +1,6 @@ import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ts } from '../../i18n/server'; import { formatValue, roundedPercentages } from '../../lib/format'; interface Segment { @@ -27,6 +29,7 @@ function shortenLabel(name: string): string { } export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) { + const { t } = useTranslation(); const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]); const roundedPcts = useMemo( () => @@ -39,7 +42,9 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa ); if (total === 0) { - return
No data
; + return ( +
{t('common.noData')}
+ ); } const colorFor = (segmentName: string): string => { @@ -57,6 +62,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa {sortedSegments.map((segment, i) => { const pct = (segment.value / total) * 100; if (pct < 0.5) return null; + const label = shortenLabel(ts(segment.name)); return (
); })} @@ -73,22 +79,23 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa {/* Legend */}
- {sortedSegments.map((segment) => ( -
- - - {shortenLabel(segment.name)} - - - {formatValue(segment.value)} - -
- ))} + {sortedSegments.map((segment) => { + const label = shortenLabel(ts(segment.name)); + return ( +
+ + {label} + + {formatValue(segment.value)} + +
+ ); + })}
); diff --git a/frontend/src/components/map/StackedEnumChart.tsx b/frontend/src/components/map/StackedEnumChart.tsx index 1602dbf..33f2775 100644 --- a/frontend/src/components/map/StackedEnumChart.tsx +++ b/frontend/src/components/map/StackedEnumChart.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; +import { ts } from '../../i18n/server'; import type { EnumFeatureStats } from '../../types'; import { roundedPercentages } from '../../lib/format'; @@ -17,6 +19,7 @@ export default function StackedEnumChart({ valueOrder, valueColors, }: StackedEnumChartProps) { + const { t } = useTranslation(); const visibleRows = components.filter(({ stats }) => { const total = Object.values(stats.counts).reduce((a, b) => a + b, 0); if (total === 0) return false; @@ -25,7 +28,11 @@ export default function StackedEnumChart({ }); if (visibleRows.length === 0) { - return
All low
; + return ( +
+ {t('common.allLow')} +
+ ); } return ( @@ -38,7 +45,7 @@ export default function StackedEnumChart({ return (
- {shortenLabel(label)} + {shortenLabel(ts(label))}
{valueOrder.map((value, i) => { @@ -53,7 +60,7 @@ export default function StackedEnumChart({ width: `${pct}%`, backgroundColor: valueColors[i], }} - title={`${value}: ${count} (${roundedPcts[i]}%)`} + title={`${ts(value)}: ${count} (${roundedPcts[i]}%)`} /> ); })} @@ -70,7 +77,7 @@ export default function StackedEnumChart({ className="w-2 h-2 rounded-sm shrink-0" style={{ backgroundColor: valueColors[i] }} /> - {value} + {ts(value)}
))}
diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index ba61329..6a4d8a2 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -166,7 +166,7 @@ export function TravelTimeCard({ {filterImpact != null && filterImpact > 0 && (

- +{formatNumber(filterImpact)} without this filter + {t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}

)} diff --git a/frontend/src/components/map/filters/ActiveFilterList.tsx b/frontend/src/components/map/filters/ActiveFilterList.tsx index 93baaa9..8edd781 100644 --- a/frontend/src/components/map/filters/ActiveFilterList.tsx +++ b/frontend/src/components/map/filters/ActiveFilterList.tsx @@ -3,6 +3,10 @@ import { Fragment } from 'react'; import type { FeatureFilters, FeatureMeta } from '../../../types'; import type { PercentileScale } from '../../../lib/format'; import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter'; +import { + getElectionVoteShareFeatureName, + isElectionVoteShareFilterName, +} from '../../../lib/election-filter'; import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter'; import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter'; import { @@ -14,6 +18,7 @@ import { EthnicityFilterCard } from './EthnicityFilterCard'; import { PoiDistanceFilterCard } from './PoiDistanceFilterCard'; import { SchoolFilterCard } from './SchoolFilterCard'; import { SpecificCrimeFilterCard } from './SpecificCrimeFilterCard'; +import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard'; import { EnumFeatureFilterCard } from './EnumFeatureFilterCard'; import { NumericFeatureFilterCard } from './NumericFeatureFilterCard'; import { TravelTimeFilterCards } from './TravelTimeFilterCards'; @@ -156,6 +161,40 @@ export function ActiveFilterList({ ); } + if (isElectionVoteShareFilterName(feature.name)) { + const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name); + return ( + + {insertTravelCards && travelCards} + onRemoveFilter(feature.name)} + /> + + ); + } + if (isEthnicityFilterName(feature.name)) { const ethnicityBackendName = getEthnicityFeatureName(feature.name); return ( diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx index d9bec44..8d84464 100644 --- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx +++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx @@ -12,6 +12,7 @@ import { ActiveFilterList } from './ActiveFilterList'; interface ActiveFiltersPanelProps { scrollRef: RefObject; collapsed: boolean; + addFiltersExpanded: boolean; badgeCount: number; activeEntryCount: number; features: FeatureMeta[]; @@ -59,6 +60,7 @@ interface ActiveFiltersPanelProps { export function ActiveFiltersPanel({ scrollRef, collapsed, + addFiltersExpanded, badgeCount, activeEntryCount, features, @@ -102,7 +104,7 @@ export function ActiveFiltersPanel({
+ ); + }) + )} +
+ + ); + + return ( +
+ + + {open && createPortal(dropdown, document.body)} +
+ ); +} diff --git a/frontend/src/components/map/filters/SchoolFilterCard.tsx b/frontend/src/components/map/filters/SchoolFilterCard.tsx index d06ac4f..41d5180 100644 --- a/frontend/src/components/map/filters/SchoolFilterCard.tsx +++ b/frontend/src/components/map/filters/SchoolFilterCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { Slider } from '../../ui/Slider'; import { FeatureActions } from '../../ui/FeatureIcons'; import { FeatureLabel } from '../../ui/FeatureLabel'; @@ -47,6 +48,7 @@ export function SchoolFilterCard({ onShowInfo: (feature: FeatureMeta) => void; onRemove: () => void; }) { + const { t } = useTranslation(); const config = getSchoolFilterConfig(schoolFeature.name); const schoolMeta = getSchoolFilterMeta(features); const backendFeature = config @@ -124,9 +126,9 @@ export function SchoolFilterCard({
- School type + {t('filters.schoolType')}
-
+
- Rating + {t('filters.rating')}
-
+
- Distance + {t('filters.distance')}
-
+
diff --git a/frontend/src/components/map/filters/SliderLabels.tsx b/frontend/src/components/map/filters/SliderLabels.tsx index e66c495..eee4e3c 100644 --- a/frontend/src/components/map/filters/SliderLabels.tsx +++ b/frontend/src/components/map/filters/SliderLabels.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import type React from 'react'; +import { useTranslation } from 'react-i18next'; import type { FeatureMeta } from '../../../types'; import { formatFilterValue, parseInputValue } from '../../../lib/format'; @@ -92,22 +93,23 @@ export function SliderLabels({ feature?: FeatureMeta; onValueChange?: (v: [number, number]) => void; }) { + const { t } = useTranslation(); const range = max - min || 1; const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100)); const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100)); const labels = displayValues || value; const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw; - const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat); - const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat); + const minLabel = isAtMin ? t('common.min') : formatFilterValue(labels[0], labelFormat); + const maxLabel = isAtMax ? t('common.max') : formatFilterValue(labels[1], labelFormat); // Smoothly spread labels apart as thumbs get close to prevent overlap. - // t=1 (centered) when far apart, t=0 (split) when touching. + // gapRatio=1 (centered) when far apart, gapRatio=0 (split) when touching. const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating const gapPct = rightPct - leftPct; - const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD)); - const leftTranslate = `translateX(${-100 + t * 50}%)`; - const rightTranslate = `translateX(${-t * 50}%)`; + const gapRatio = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD)); + const leftTranslate = `translateX(${-100 + gapRatio * 50}%)`; + const rightTranslate = `translateX(${-gapRatio * 50}%)`; if (feature && onValueChange) { return ( diff --git a/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx b/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx index d47bf49..7eaa6ab 100644 --- a/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx +++ b/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { ts } from '../../../i18n/server'; import { Slider } from '../../ui/Slider'; import { ChevronIcon } from '../../ui/icons'; @@ -51,6 +52,7 @@ export function SpecificCrimeFilterCard({ onShowInfo: (feature: FeatureMeta) => void; onRemove: () => void; }) { + const { t } = useTranslation(); const specificCrimeMeta = getSpecificCrimeFilterMeta(features); const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) => features.find((feature) => feature.name === name) @@ -145,7 +147,7 @@ export function SpecificCrimeFilterCard({
- {/* Results list */} -
+
{filtered.length === 0 ? (
{loading ? t('common.loading') : t('travel.noDestinations')} diff --git a/frontend/src/components/ui/LanguageDropdown.tsx b/frontend/src/components/ui/LanguageDropdown.tsx index 09d0c3e..512478b 100644 --- a/frontend/src/components/ui/LanguageDropdown.tsx +++ b/frontend/src/components/ui/LanguageDropdown.tsx @@ -7,7 +7,7 @@ import { } from '../../i18n'; export default function LanguageDropdown() { - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); @@ -34,7 +34,7 @@ export default function LanguageDropdown() {