diff --git a/plugin-designs/regions-dashboard/region-dashboards.vue b/plugin-designs/regions-dashboard/region-dashboards.vue index f4d4af510..49920f186 100644 --- a/plugin-designs/regions-dashboard/region-dashboards.vue +++ b/plugin-designs/regions-dashboard/region-dashboards.vue @@ -27,6 +27,9 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES } from '@map/composables/useDocumentFont.js' import FilterDropdown from '@map/components/search/FilterDropdown.vue' import UupgsList from './src/UupgsList.vue' +// Region colours come from the shared strategy (check:one-palette), and REGION_ORDER is the +// order its CVD separation was validated in — the legend must render in it for that to hold. +import { PALETTE as REGION_PALETTE, WAGF_REGIONS, REGION_ORDER } from '@map/colors/wagf-region.js' import RegionCountryMap from './src/RegionCountryMap.vue' const props = defineProps({ @@ -325,6 +328,39 @@ const summaryLine = computed(() => { // regions so the page still compares like with like. The prompt below then says how to narrow it. const isAll = computed(() => subject.value.kind === 'all') +// ── THE CHOOSER'S MAP: colour mode + legend ───────────────────────────────── +// Only the no-selection state paints by region. A SELECTED page keeps DX-life's own modes +// exactly — prayer on a region, in-country on a country — because those pages are clones. +const mapColorMode = computed(() => { + if (subject.value.kind === 'country') return 'in-country' + if (subject.value.kind === 'region') return 'prayer' + return 'region' +}) + +// In REGION_ORDER, not by count: the palette's CVD separation was measured on ADJACENT pairs in +// that order, and sorting by size would also mean the legend reshuffles as data changes. +const regionLegend = computed(() => { + const byValue = new Map(regions.value.map((r) => [r.value, r])) + return REGION_ORDER + .map((value) => { + const r = byValue.get(value) + return r ? { value, slug: r.slug, label: WAGF_REGIONS[value] || r.label, count: r.count, color: REGION_PALETTE[value] } : null + }) + .filter(Boolean) +}) + +const hoveredRegion = ref('') + +// A pin click in the chooser picks THAT PIN'S COUNTRY — the finest subject the map can express. +function onPickFromMap(d) { + const code = d?.code + if (!code) return + for (const r of regions.value) { + const c = r.countries.find((x) => x.code === code) + if (c) { sel.value = { regionSlug: r.slug, countrySlug: c.slug }; return } + } +} + // ── THE ONE DELIBERATE DIFFERENCE: the no-selection chooser ───────────────── // DX-life has no route for a dashboard without a subject (/regions is the list, // /regions/ always has one, [slug].vue 404s otherwise). Opening tab 2 first is a state @@ -362,6 +398,12 @@ const focusCodes = computed(() => { }) const mapBounds = computed(() => { + // ★ NEVER fitBounds THE WHOLE WORLD. In the chooser the focus is every country, so the bounds + // span ~180 degrees of longitude and Mapbox fits that by pulling the globe into a crescent: + // measured at 1440x1000 it rendered as a skewed arc of pins over mostly empty white, which for + // the one screen that has to invite a choice is the worst possible first impression. A plain + // world view is both correct and what a chooser wants. + if (isAll.value) return null const focus = new Set(focusCodes.value) const pts = points.value.filter((p) => focus.has(p.code)) if (pts.length < 2) return null @@ -378,12 +420,21 @@ const mapBounds = computed(() => { // Only used when bounds cannot be formed (a single people group, or none plotted yet). const mapCenter = computed(() => { + // The chooser gets a fixed world view rather than a centroid: the mean of every people group + // sits in south Asia (1309 of 2107 are in Asia), which framed the world off-centre and cut + // the Americas. 10E/20N with the zoom below puts every region on screen at once. + if (isAll.value) return [10, 28] // shifted north: the poles crop, and the data is northern const focus = new Set(focusCodes.value) const pts = points.value.filter((p) => focus.has(p.code)) if (!pts.length) return [20, 10] return [pts.reduce((a, p) => a + p.lng, 0) / pts.length, pts.reduce((a, p) => a + p.lat, 0) / pts.length] }) -const mapZoom = computed(() => (selectedCountry.value ? 4 : 2)) +const mapZoom = computed(() => { + // Mercator's world is square (512*2^z on both axes) and the frame is 16/9, so a world view + // cannot fill both. Fill the WIDTH (512*2^z ~ 1200 -> z ~ 1.23) and let the ice caps crop. + if (isAll.value) return 1.25 + return selectedCountry.value ? 4 : 2 +}) // Their map notes, verbatim, with the same %token% substitution their i18n uses. // RegionDetail.vue:38-42 and CountryDetail.vue:29-33. @@ -519,6 +570,9 @@ async function loadRegions() { points.value = posts.map((p) => ({ name: p.name || '', slug: p.slug || '', code: p.country_code?.value || '', country: p.country_code?.label || '', + // the wagf_region VALUE (e.g. 'latin_america_&_caribbean'), which is what + // library/colors/wagf-region.js keys its palette on + region: p.wagf_region?.value || '', lng: parseFloat(p.longitude), lat: parseFloat(p.latitude), committed: toNumber(p.people_committed), population: toNumber(p.population), })).filter((p) => Number.isFinite(p.lng) && Number.isFinite(p.lat)) @@ -636,6 +690,20 @@ useShadowStyles( '.rd-mapnote{ margin:0; text-align:center; color:#92b195; font-weight:700; font-size:19.5312px; }' + // .rounded-xlg on their ; the child takes border-radius:inherit from here. '.rd-mapframe{ border-radius:var(--dx-radius); overflow:hidden; }' + + // The region key. Their token scale throughout so the one screen we add does not look + // foreign next to the clone: --spacing-xs/sm gaps, --font-size-sm, the 100px pill radius + // their chips use, and --color-brand-lighter for the count. + '.rd-regionkey{ list-style:none; margin:0; padding:0; display:flex; flex-wrap:wrap;' + + ' gap:10px 13.3px; justify-content:center; }' + + '.rd-regionkey__item{ appearance:none; border:1px solid var(--dx-line); background:var(--dx-elevated);' + + ' border-radius:100px; padding:7.5188px 13.3px; cursor:pointer; font:inherit;' + + ' font-size:15.625px; color:var(--dx-text); display:inline-flex; align-items:center;' + + ' gap:7.5188px; transition:border-color .15s ease, background-color .15s ease; }' + + '.rd-regionkey__item:hover{ border-color:var(--dx-header-bg); background:#fff; }' + + '.rd-regionkey__item:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; }' + + '.rd-regionkey__dot{ width:10px; height:10px; border-radius:100px; flex:0 0 auto; }' + + '.rd-regionkey__count{ color:var(--dx-muted); font-variant-numeric:tabular-nums; }' + + '.rd-regionkey__hint{ margin:0; text-align:center; color:var(--dx-muted); font-size:15.625px; }' + // ── .switcher[data-width="xl"]: composition/_switcher.scss. --switcher-width 70rem = 700px, // gap --spacing-2xl (41.6158px). flex-basis calc((700px - 100%) * 999) is every-layout's @@ -800,9 +868,39 @@ useShadowStyles( :bounds="mapBounds" :center="mapCenter" :zoom="mapZoom" - :color-by-prayer="subject.kind !== 'country'" + :color-mode="mapColorMode" + :highlight-region="hoveredRegion" + :select-on-pin-click="isAll" + @pick-country="onPickFromMap" /> + + +