doxa-maps/library/composables/useCountryOutline.js
doxa-maps d5ba21fa76 doxa-maps — portable bundler for embeddable JAMstack maps
Standalone repo that builds embeddable JAMstack map bundles (web components),
composes them into a lazy-loaded SPA via layouts/, and optionally wraps a
Capacitor Android/iOS app — one build, choose your output/CDN. Diátaxis docs.

History reset to a single clean commit; no PII, IPs, or internal terms.
2026-09-10 01:53:07 +00:00

154 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* useCountryOutline.js — country boundary OUTLINE overlay on country search.
*
* Shared across every map profile (research / countries / simple / homepage).
* When the user selects a country result from the geocoder, we:
* 1. resolve the aggregate → its alpha-3 ISO (locale-independent, from the
* people-group data — `countryIso` is already alpha-3, e.g. 'IND'),
* 2. draw an OUTLINE-ONLY line layer from the SAME Mapbox vector tileset the
* Regions-tab legend already uses (`mapbox.country-boundaries-v1`), so the
* highlight is INSTANT (vector tiles stream from the same CDN as the
* basemap and are cached) — no per-country GeoJSON fetch.
*
* WHY VECTOR TILES (an earlier fix): the previous implementation fetched a
* per-country geoBoundaries ADM0 GeoJSON over the network, which cost ~5s on the
* first selection and duplicated the country-polygon data the Regions legend
* already paints from `country-boundaries-v1`. Reusing that one tileset removes
* the delay AND the duplicate code path — one country-highlight source, used
* everywhere. Camera framing is the caller's job (every call site already
* fitBounds() to the country bbox from the search aggregate's `bounds`).
*
* The outline is a singleton (one source + one line layer per map). Calling
* showCountry() again swaps the filter; clearCountry() removes it (wired to the
* geocoder "clear" + any non-country pick so it disappears when the user clicks
* elsewhere or clears the search).
*
* Usage:
* const outline = useCountryOutline(() => map.value)
* const iso = resolveCountryIso(evt, normalizedPeopleGroups)
* outline.showCountry(iso)
* ...
* outline.clearCountry()
*/
const SOURCE_ID = 'doxa-country-outline'
const LAYER_ID = 'doxa-country-outline-line'
// Same vector tileset the Regions-tab legend highlights from (see
// useMapLayers.addRegionsLayer). Exposes iso_3166_1_alpha_3 (alpha-3) so we
// match the locale-independent ISO the search aggregate resolves to.
const COUNTRY_BOUNDARIES_URL = 'mapbox://mapbox.country-boundaries-v1'
const SOURCE_LAYER = 'country_boundaries'
/**
* Resolve a doxa-country search aggregate to its alpha-3 ISO using the
* normalized people-group data. Matches on the aggregate's memberIds first
* (the exact pins it was built from), then falls back to the canonical
* countryName/country field. Both keys are locale-INDEPENDENT, so the lookup
* never depends on the active UI locale or a translated country label.
*
* @param {object} evt the @aggregate-result payload (has .memberIds, .label)
* @param {Array} pgs normalized people-groups (each has .countryIso alpha-3)
* @returns {string} alpha-3 ISO uppercased, or '' if unresolved
*/
export function resolveCountryIso(evt, pgs) {
if (!evt) return ''
const list = Array.isArray(pgs) ? pgs : []
const ids = Array.isArray(evt.memberIds) ? evt.memberIds.map(String) : []
const idSet = new Set(ids)
let pg = ids.length
? list.find(p => idSet.has(String(p.uniqueId ?? p.id ?? p.slug ?? '')))
: null
if (!pg) {
const want = String(evt.label ?? '').toLowerCase()
pg = list.find(p => String(p.countryName ?? p.country ?? '').toLowerCase() === want)
}
return String(pg?.countryIso ?? '').trim().toUpperCase()
}
// The country-boundaries-v1 tileset carries one feature per WORLDVIEW (e.g.
// 'all', 'US', 'CN', 'IN'); without a worldview filter an outline would be
// drawn 2-3× over itself. Mirror the homepage CountryMap.vue filter: keep the
// 'all' worldview plus the US-recognized worldview.
function countryFilter(code) {
return [
'all',
['==', ['get', 'iso_3166_1_alpha_3'], code],
['any', ['==', ['get', 'worldview'], 'all'], ['in', 'US', ['get', 'worldview']]]
]
}
/**
* @param {() => any} getMap returns the mapbox-gl Map instance (or null)
* @param {object} [opts]
* @param {string} [opts.lineColor='#2563eb']
* @param {number} [opts.lineWidth=2.5]
* @param {number} [opts.lineOpacity=0.8]
*/
export function useCountryOutline(getMap, opts = {}) {
const lineColor = opts.lineColor ?? '#2563eb'
const lineWidth = opts.lineWidth ?? 2.5
const lineOpacity = opts.lineOpacity ?? 0.8
function clearCountry() {
const m = typeof getMap === 'function' ? getMap() : null
if (!m) return
try {
if (m.getLayer(LAYER_ID)) m.removeLayer(LAYER_ID)
if (m.getSource(SOURCE_ID)) m.removeSource(SOURCE_ID)
} catch (_) { /* style mid-load — nothing to remove */ }
}
/**
* Draw the country's outline from the country-boundaries vector tileset.
* INSTANT — no network fetch. Camera framing is the caller's responsibility
* (every call site already fitBounds() to the aggregate's bbox).
*
* @param {string} iso3 alpha-3 ISO (e.g. 'IND')
*/
function showCountry(iso3) {
const code = String(iso3 || '').trim().toUpperCase()
if (code.length !== 3) return
const m = typeof getMap === 'function' ? getMap() : null
if (!m) return
const filter = countryFilter(code)
// Fast path: source/layer already present from a prior pick — just swap the
// filter so the highlight moves to the new country with zero teardown.
if (m.getLayer(LAYER_ID)) {
try { m.setFilter(LAYER_ID, filter); return } catch (_) { /* fall through to rebuild */ }
}
// Swap any stale source, then (re)create the singleton outline layer.
clearCountry()
try {
m.addSource(SOURCE_ID, { type: 'vector', url: COUNTRY_BOUNDARIES_URL })
m.addLayer({
id: LAYER_ID,
type: 'line',
source: SOURCE_ID,
'source-layer': SOURCE_LAYER,
filter,
layout: { 'line-join': 'round', 'line-cap': 'round' },
// Outline only — no fill layer — so pins / labels under the country
// stay fully visible (coder spec: "just an outline/border").
paint: {
'line-color': lineColor,
'line-width': lineWidth,
'line-opacity': lineOpacity,
// Bypass Standard v3 scene-lighting so the outline keeps its colour on the
// night preset instead of darkening to near-black (mirror of useMapLayers.js:248).
'line-emissive-strength': 1,
},
})
} catch (_) {
// style not ready — outline is a nice-to-have, never break the search flow.
}
}
return { showCountry, clearCountry }
}
export default useCountryOutline