diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 1d9e52e..1143544 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -476,7 +476,6 @@ export default function HomePage({
-

{t('home.heroEyebrow')}

{t('home.heroTitle1')}{' '} {t('home.heroTitle2')}. @@ -511,8 +510,7 @@ export default function HomePage({

{t('home.freeToExplore')}

-

{t('home.coverageNote')}

-
+
diff --git a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx index 6ff3cbf..64f10f6 100644 --- a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx +++ b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx @@ -47,7 +47,7 @@ export function EnumFeatureFilterCard({ onRemove={onRemoveFilter} />
- + {allValues.map((val) => ( ); diff --git a/frontend/src/components/ui/PillGroup.tsx b/frontend/src/components/ui/PillGroup.tsx index 0c9122a..cda8128 100644 --- a/frontend/src/components/ui/PillGroup.tsx +++ b/frontend/src/components/ui/PillGroup.tsx @@ -4,16 +4,30 @@ interface PillGroupProps { children: ReactNode; className?: string; wrap?: boolean; + /** + * Widen the scroll area by the 16px of filter-card padding on each side, and re-add it as + * inner padding. Pills rest where they did, but scroll to the sidebar edge before clipping. + */ + bleed?: boolean; } -export function PillGroup({ children, className = '', wrap = false }: PillGroupProps) { +const SCROLL = + 'flex-nowrap overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible'; + +export function PillGroup({ + children, + className = '', + wrap = false, + bleed = false, +}: PillGroupProps) { + // max-w-full would clamp a bleeding group back to the padded width, undoing the negative margin. + const scroll = bleed + ? `${SCROLL} -mx-4 px-4 md:mx-0 md:max-w-full md:px-0` + : `${SCROLL} max-w-full`; + return (
{children}
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 0094ca7..fb6449f 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -1043,7 +1043,6 @@ const de: Translations = { // ── Home Page ────────────────────────────────────── home: { - heroEyebrow: 'Für Käufer, die nicht für den Ruf eines Postcodes zu viel zahlen wollen', heroTitle1: 'Finde den', heroTitle2: 'Geheimtipp-Postcode', heroTitle3: 'Gleiche Schulen, gleicher Pendelweg, und still günstiger.', @@ -1112,8 +1111,6 @@ const de: Translations = { statFilters: 'kombinierbare Filter', statEvery: 'Jeder', statPostcodeInEngland: 'Postcode in England', - coverageNote: - 'Deckt jeden Postcode in England ab, je 200+ Datenfelder. Schottland und Wales sind in Planung.', priceStrip: 'Einmal zahlen für lebenslangen Zugang: aktuell {{price}}, steigend, sobald sich jede Stufe füllt.', priceStripSpots: 'Noch {{count}} Platz zu diesem Preis.', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c7a97d1..c6e089f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1030,7 +1030,6 @@ const en = { // ── Home Page ────────────────────────────────────── home: { - heroEyebrow: 'For buyers who refuse to overpay for a postcode’s reputation', heroTitle1: 'Find the', heroTitle2: 'hidden-gem postcode', heroTitle3: 'Same schools, same commute, quietly cheaper.', @@ -1097,8 +1096,6 @@ const en = { statFilters: 'combinable filters', statEvery: 'Every', statPostcodeInEngland: 'postcode in England', - coverageNote: - 'Covers every postcode in England, with 200+ data fields each. Scotland & Wales are on the roadmap.', priceStrip: 'Pay once for lifetime access, currently {{price}}, rising as each tier fills.', priceStripSpots: '{{count}} spot left at this price.', priceStripSpotsPlural: '{{count}} spots left at this price.', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index f7b75c4..1ee20e6 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -1053,7 +1053,6 @@ const fr: Translations = { // ── Home Page ────────────────────────────────────── home: { - heroEyebrow: 'Pour les acheteurs qui refusent de surpayer la réputation d’un code postal', heroTitle1: 'Trouvez le', heroTitle2: 'code postal pépite cachée', heroTitle3: 'Mêmes écoles, même trajet, discrètement moins cher.', @@ -1122,8 +1121,6 @@ const fr: Translations = { statFilters: 'filtres combinables', statEvery: 'Chaque', statPostcodeInEngland: 'code postal d’Angleterre', - coverageNote: - 'Couvre tous les codes postaux d’Angleterre, plus de 200 champs de données chacun. L’Écosse et le pays de Galles sont sur la feuille de route.', priceStrip: 'Payez une fois pour un accès à vie : actuellement {{price}}, en hausse à mesure que chaque palier se remplit.', priceStripSpots: '{{count}} place restante à ce prix.', diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index 1028b88..7f729d5 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -1003,8 +1003,6 @@ const hi: Translations = { }, home: { - heroEyebrow: - 'उन खरीदारों के लिए जो किसी पोस्टकोड की प्रतिष्ठा के लिए ज़्यादा कीमत चुकाने से इनकार करते हैं', heroTitle1: 'खोजें वह', heroTitle2: 'छिपा हुआ रत्न पोस्टकोड', heroTitle3: 'वही स्कूल, वही आवागमन: चुपचाप सस्ता.', @@ -1071,8 +1069,6 @@ const hi: Translations = { statFilters: 'जोड़े जा सकने वाले फ़िल्टर', statEvery: 'इंग्लैंड का हर', statPostcodeInEngland: 'पोस्टकोड', - coverageNote: - 'इंग्लैंड का हर पोस्टकोड कवर: हर एक के लिए 200+ डेटा फ़ील्ड. स्कॉटलैंड और वेल्स रोडमैप पर हैं.', priceStrip: 'आजीवन पहुँच के लिए एक बार भुगतान करें: अभी {{price}}, हर टियर भरने पर कीमत बढ़ती है.', priceStripSpots: 'इस कीमत पर {{count}} जगह बची है.', diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts index 7018aaf..0ebd0d8 100644 --- a/frontend/src/i18n/locales/hu.ts +++ b/frontend/src/i18n/locales/hu.ts @@ -1040,7 +1040,6 @@ const hu: Translations = { // ── Home Page ────────────────────────────────────── home: { - heroEyebrow: 'Vevőknek, akik nem hajlandók túlfizetni egy irányítószám hírnevéért', heroTitle1: 'Találd meg a', heroTitle2: 'rejtett kincs irányítószámot', heroTitle3: 'Ugyanazok az iskolák, ugyanaz az ingázás, csendben olcsóbban.', @@ -1110,8 +1109,6 @@ const hu: Translations = { statFilters: 'kombinálható szűrő', statEvery: 'Minden', statPostcodeInEngland: 'irányítószám Angliában', - coverageNote: - 'Anglia összes irányítószámát lefedi, egyenként 200+ adatmezővel. Skócia és Wales a terveink között szerepel.', priceStrip: 'Fizess egyszer az örökös hozzáférésért: jelenleg {{price}}, és az ár a szintek betelésével emelkedik.', priceStripSpots: 'Már csak {{count}} hely ezen az áron.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index f1888e4..d009806 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -976,7 +976,6 @@ const zh: Translations = { // ── Home Page ────────────────────────────────────── home: { - heroEyebrow: '献给不愿为邮编名气多付钱的买家', heroTitle1: '找到那个', heroTitle2: '被低估的宝藏邮编', heroTitle3: '同样的学校、同样的通勤,却悄悄更便宜。', @@ -1042,7 +1041,6 @@ const zh: Translations = { statFilters: '可组合筛选条件', statEvery: '每一个', statPostcodeInEngland: '英格兰的邮编', - coverageNote: '覆盖英格兰所有邮编,每个邮编 200 多个数据字段。苏格兰和威尔士已在计划中。', priceStrip: '一次付费,终身使用。当前价格 {{price}},随着各档名额售罄而上调。', priceStripSpots: '此价格仅剩 {{count}} 个名额。', priceStripSpotsPlural: '此价格仅剩 {{count}} 个名额。', diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index a38e246..f4cf1c0 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -270,6 +270,12 @@ export const POI_CATEGORY_LOGOS: Record = { 'Whole Foods Market': '/assets/poi-icons/brands_2024/wholefoods.svg', }; +/** Zoom level below which every POI is hidden. Deliberately set to the same + * value as the map overlays' limit (POSTCODE_ZOOM_THRESHOLD) so POIs and + * overlays clear together on the way out, but kept as its own constant so POI + * visibility can be retuned without moving the postcode/overlay limit. */ +export const POI_ZOOM_THRESHOLD = 12; + /** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */ export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']); diff --git a/pipeline/transform/merge.py b/pipeline/transform/merge.py index 294e57c..087e533 100644 --- a/pipeline/transform/merge.py +++ b/pipeline/transform/merge.py @@ -154,7 +154,8 @@ _AREA_COLUMNS = [ # postcode ever recorded as social housing per EPC, and the ever-social # subset whose latest EPC certificate is no longer social rented (sold off). # Aggregated from the per-property "was_council_house" / "latest_tenure_status" - # flags in _epc_council_by_postcode and joined onto the AREA frame only. + # flags in _epc_council_by_postcode, then joined onto the area frame and the + # wide frame (the latter carries them onto listing rows). "% Council housing", "% Ex-council", # Politics @@ -2329,7 +2330,11 @@ def _epc_council_by_postcode(wide: pl.LazyFrame) -> pl.LazyFrame: ``was_council_house`` is already "Yes"/"No" filled for every row (see ``_fill_property_level_no_defaults``), so the means are over the full postcode. - Returns a postcode-keyed LazyFrame to left-join onto the AREA frame only. + Returns a postcode-keyed LazyFrame to left-join onto the area and wide frames. + + Call this against the dwelling universe only. `_build` runs it before the + listings splice: unmatched listings append synthetic rows that are not + dwellings, so they must not reach the denominator. """ currently_social = (pl.col("latest_tenure_status") == "Rented (social)").fill_null( False @@ -2420,6 +2425,17 @@ def _build( pl.lit(None, dtype=pl.Utf8).alias(LISTED_BUILDING_FEATURE) ) + # EPC-derived council/ex-council postcode shares, computed here against the + # pre-splice dwelling universe so both output modes report identical values. + # In listings mode the splice below appends one seed row per unmatched + # listing; those rows would otherwise enter the group_by denominator, and + # since they carry no latest_tenure_status every ever-social one would count + # as ex-council. Reading `wide` here does not rebind it, so the aggregate + # keeps the pre-splice plan; the fill is idempotent with the one below. + epc_council_by_postcode = _epc_council_by_postcode( + _fill_property_level_no_defaults(wide) + ) + if actual_listings_path is not None: wide = _integrate_listings( wide, @@ -2515,12 +2531,14 @@ def _build( wide = _join_area_side_tables(wide, **area_side_tables) postcode_area = _join_area_side_tables(postcode_area, **area_side_tables) - # EPC-derived council/ex-council shares: aggregate the per-property social - # tenure flags to POSTCODE percentages and attach to the AREA frame only - # (these are area columns, like the Census tenure block, not per-property). - # Built before dropping latest_tenure_status, which is its only consumer. - epc_council_by_postcode = _epc_council_by_postcode(wide) - postcode_area = postcode_area.join(epc_council_by_postcode, on="postcode", how="left") + # EPC-derived council/ex-council shares (aggregated above): attach to both + # frames, like every other area side table. The area frame feeds + # postcode.parquet; the wide frame carries them onto listing rows, which are + # projected out of `wide` alone and never see the area frame. + wide = wide.join(epc_council_by_postcode, on="postcode", how="left") + postcode_area = postcode_area.join( + epc_council_by_postcode, on="postcode", how="left" + ) # latest_tenure_status is property-grain and not in _AREA_COLUMNS, so the # split would otherwise leak it into properties.parquet. It has served its # purpose (the postcode aggregate above), so drop it from the property frame. diff --git a/video/src/script.ts b/video/src/script.ts index 4e14ec0..74f3d36 100644 --- a/video/src/script.ts +++ b/video/src/script.ts @@ -296,7 +296,9 @@ export interface VideoConfig { * chosen here as a voice file instead. */ export interface VoiceConfig { /** Predefined voice filename on the Chatterbox server, INCLUDING `.wav` - * (e.g. "Southern-M.wav"). `GET /v1/audio/voices` lists the valid names. */ + * (e.g. "Southern-M.wav"). `GET /get_predefined_voices` lists the valid + * names; `GET /v1/audio/voices` looks like it does but answers from a + * boot-time snapshot that omits recently uploaded voices. */ voice: string; /** ISO code, e.g. "en", "de", "zh". Anything other than "en" makes synth.py * swap the server onto the multilingual checkpoint. */