diff --git a/plugin-designs/my-upg-100-list/poster-wall.vue b/plugin-designs/my-upg-100-list/poster-wall.vue index fcc024118..e71fddfbd 100644 --- a/plugin-designs/my-upg-100-list/poster-wall.vue +++ b/plugin-designs/my-upg-100-list/poster-wall.vue @@ -39,7 +39,7 @@ * IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF. */ import { computed, inject, ref } from 'vue' -import { useOptionalT } from '@map/composables/useOptionalT.js' +import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js' // The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups. import { resolvePeopleGroupList } from '@map/lists/peopleGroupList.js' import PosterWall from '@map/components/poster/PosterWall.vue' @@ -151,9 +151,13 @@ const regionFor = (id) => { // buildLegendNodes collapses the two deaf_lists into one unified row. This spread still yields 27 because // it reads the three geographic buckets and the deaf row is synthesised — so the number is right for what // this list IS, and the old comment was describing the wrong thing. +const { locale: wallLocale } = useOptionalI18n('') const items = computed(() => { const z = zonesForList(resolvePeopleGroupList(profileConfig)) - const locale = getRuntimeLocale() || detectLocale() + // The i18n locale REF, read here so this computed re-runs on a runtime language switch: getRuntimeLocale() + // is a plain getter, and a computed that reads only it is resolved once and never again (a frozen label). + const l = wallLocale && (wallLocale.value ?? wallLocale) + const locale = (typeof l === 'string' && l) || getRuntimeLocale() || detectLocale() return [ ...(z.lists || []), ...(z.asia_lists || []), diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index 6b4cb0183..8e98410e3 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -417,7 +417,9 @@ const { locale: packetLocale, t } = useI18n() // The first status a reader sees. Set in a watcher, not once at setup, so a language switch while the // map is still loading re-resolves it; the first real status replaces it and the watcher then leaves it. let loadingText = '' -watch(packetLocale, () => { +// Watched through packetLang(), not packetLocale itself: the host may install i18n with a plain-string +// locale (see packetLang), and watch() on a non-ref is an invalid source that never fires. +watch(() => packetLang(), () => { if (status.value === '' || status.value === loadingText) status.value = loadingText = t('legend.loadingData') }, { immediate: true }) @@ -438,6 +440,24 @@ function legendOpts(records) { regionTitle: (english) => (REGION_KEY[english] ? t(`poster.${REGION_KEY[english]}`) : english), } } +// ★ AND RE-BUILT WHEN THE LANGUAGE CHANGES. The tree above is built once and STORED, so a runtime switch +// (ProfileLoader drives the i18n locale mid-session) left every region and zone name in the boot language +// while the chrome around it changed — a frozen label that check:frozen-labels cannot see, because t() +// runs inside a function. One builder for all three build sites remembers the last records, and a watch +// on the locale rebuilds the tree, relabels the flat zone list IN PLACE (colours and selectors stay), and +// refreshes the search feed built from those labels. +let legendRecords = null +function buildLegend(records) { + if (records !== undefined) legendRecords = records + return buildLegendNodes(listZones(), legendOpts(legendRecords)) +} +watch(() => packetLang(), () => { + if (!legendNodes.value.length) return + legendNodes.value = buildLegend() + const fresh = new Map(leafZones(legendNodes.value).map((l) => [l.id, l.label])) + for (const z of leafZoneList) if (fresh.has(z.id)) z.label = fresh.get(z.id) + if (searchRecords) buildSearch(searchRecords) +}) // ⚠ IT WAS A HARDCODED ENGLISH LITERAL, and SemanticTreeLegend passes `title` through RAW — // the caller owns localizing it. MEASURED 2026-09-25 on the live Chinese render: the legend // header read "My UPG 100 List — Legend" in full English inside an otherwise localized panel, @@ -952,7 +972,9 @@ function clearSelection() { // useGeoSearch consumes; zone rows from the legend leafZones; territory rows // from admin-counts (ADM1) + ISO3 names (ADM0 countries). countryNameMap (built // from the live records) gives the exact country label the legend shows. +let searchRecords = null // the records the feed was last built from (see the locale watch) function buildSearch(records) { + searchRecords = records // (i) Feed the SHARED GeocoderComponent: unify PG + zone + territory into ONE // record list and cache it under our source so useGeoSearch (inside the // component) indexes it. Zones/territories ride as synthetic records with a @@ -2162,7 +2184,7 @@ function bootstrapLegend() { // the two would disagree with nothing reporting it. Found by grepping for `clusters.` rather than by // trusting the convention comment. const z = listZones() - const nodes = buildLegendNodes(z, legendOpts(null)) + const nodes = buildLegend(null) legendNodes.value = nodes leafZoneList = leafZones(nodes) const byId = {} @@ -2193,7 +2215,7 @@ async function enrichLegendFromRecords() { if (!resp.ok) throw new Error(`list ${resp.status}`) const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig)) countryNameMap = buildCountryNameMap(records) - legendNodes.value = buildLegendNodes(listZones(), legendOpts(records)) + legendNodes.value = buildLegend(records) } catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ } } @@ -2556,7 +2578,7 @@ onMounted(async () => { // Re-shape the legend tree WITH records so every node carries `pop` (slice // 04u §1b). Selectors/colors/counts are unchanged; only `pop` is added. - legendNodes.value = buildLegendNodes(listZones(), legendOpts(records)) + legendNodes.value = buildLegend(records) // ── pins layer (kept from 04b, now stamped with zoneKey for isolate dim) ──