doxa-maps/documentation/architecture/file-map.md
Doxa Maps c73d713b43 Delete useMapPacket.js: nothing imports it since both packets print through the shared HTML renderer
research-map and countries-map moved to the shared printer, leaving the
composable with no importer on main. jsPDF stays: useMapPoster.toPdf,
posterSizes and my-upg-100-list still reach it. The file map row goes and the
library index is regenerated.
2026-09-28 06:44:14 +00:00

264 lines
42 KiB
Markdown
Raw Permalink 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.

# Architecture — the file map (every file, one line each)
> The lean lookup (zones, directory tree, config, quick-ref) is in [../reference.md](../reference.md).
> This is the exhaustive per-file catalog with the gotchas each file carries.
Generated by a per-file scan. The `@map/…` import path mirrors each `library/` file path
(see *Importing from `@map`* in the reference).
## library/ — shared core (`@map` alias)
### Root files
| File | What it is | Agents should know |
|---|---|---|
| `library/ProfileLoader.vue` | Root component of every custom element: parses the `profile-config` JSON attribute, async-loads the profile .vue, provides shared context (mapboxToken, dataSource, colorSet, profileConfig, instanceId, lang, uiStore/mapStore/dataStore). | Only reader of `profile-config`; uses injected `appI18n` (useI18n() throws at custom-element root); bundle entry MUST `app.provide('profileModules', import.meta.glob('./*.vue'))` or setup throws. Descendants inject stores, never call `useXxxStore()`. |
| `library/index.js` | The `@map` barrel entry — intentionally thin; exports `MAP_CORE_VERSION` only. | Keep the barrel thin: subpath imports (`@map/components/...`, `@map/stores/...`, `@map/colors/_registry.js`) are the preferred pattern; stores/JSON deliberately not re-exported (tree-shaking). |
### api/
| File | What it is | Agents should know |
|---|---|---|
| `library/api/DataSourceManager.js` | Data-loading class: fetches people-group data (csv / json api / rest-api per `sources.json`), normalizes rows to system field names, caches per source. | Exports `DataSourceManager` class + legacy `dataSourceManager` singleton — prefer `new DataSourceManager()` per map. `sources.json` is inlined at build time; 0,0-coord rows dropped; `{value,label}` objects split into field + fieldLabel. |
| `library/api/_registry.js` | Front door to the data-source seam (parallel of `colors/_registry.js` for data): `SOURCE_TYPES`, `SOURCES`, `getActiveSourceId`, `getSourceConfig`, `listSources`, `getApiBaseUrl`, `DataSourceManager`. | Connections are pure data in `sources.json` (activeSource + sources by id, each with type + fieldMappings) — add/change sources there, not in components. |
| `library/api/apiBaseUrl.js` | Runtime API base URL resolver: `getApiBaseUrl()` = window override > Vite env var > `''` (same-origin). | Hosts set `window.MAP_APP_API_URL` before the IIFE loads to repoint the API without a rebuild. |
| `library/api/sources.json` | Data-source registry: pray-tools REST API + doxa-csv fallback, with endpoints, fieldMappings, poll settings, systemFields, `_meta.mapboxTokenUrl`. | Single source of truth for ALL bundles' data config; fieldMappings keys ARE the canonical camelCase field names; base/token URLs resolve at runtime via `window.MAP_APP_API_URL` / `window.MAP_TOKEN_URL`. |
### colors/
| File | What it is | Agents should know |
|---|---|---|
| `library/colors/_registry.js` | Color-strategy engine: eager-globs sibling strategy files; exports `COLOR_MODES`, `registerStrategies`, `getColorStrategy`, `buildColorExpression`, `getFeatureColor`, `listStrategies`. | Dropping `<name>.js` beside it (or in a bundle's `src/colors/` via `registerStrategies`) IS registration — filename derives the mode (kebab→camelCase); `_`/`.`-prefixed or no-default files skipped; bundle-local modes override shared ones per-bundle; unknown mode falls back to LANGUAGE_FAMILY. |
| `library/colors/adoption.js` | Binary red/green strategy on `adoptionStatus`. | Exports PROPERTY_KEY, PALETTE, LABELS, getColor, applyColor + default strategy object; getColor falls back to `_raw.adopted_by_churches`; LABELS keys (notAdopted/hasAdopted) differ from PALETTE keys (notAdopted/hasAdoption) — easy to trip on. |
| `library/colors/engagement.js` | Binary red-unengaged/green-engaged strategy. | getColor tolerates boolean/1/number>0/{value} shapes with `_raw.people_committed` fallback, but the Mapbox expression only matches true/1 — normalize before styling. |
| `library/colors/language-family.js` | Maps language-family names to hex colors for pins/legend/expressions; exports PALETTE, `getLanguageFamilyColor`, `canonicalFamilyName`, applyColor + compat aliases. | Exactly one PALETTE key per family — `canonicalFamilyName()` folds variants onto it (dupes break legend rows). JS fallback is black #000000; the Mapbox match fallback is grey #95a5a6 — intentional divergence. |
| `library/colors/prayer-progress.js` | 3-tier prayer-coverage strategy (red/orange/green) keyed on `peoplePraying`; `FULL_PRAYER_THRESHOLD` = 144 (hardcoded). | applyColor supports colorSource 'properties' vs 'feature-state'; JS getters fall back to `_raw.people_praying` but Mapbox expressions read only `peoplePraying`. |
| `library/colors/religion.js` | Maps Doxa religion codes (first letter = family) to pin colors; exports RELIGION_FAMILIES, PALETTE, `getReligionFamily`, applyColor/buildColorExpression. | Family = FIRST letter of religion code (unknown → 'U'); blue is reserved exclusively for Christianity — never add another blue. `buildColorExpression` alias is required by registry callers. |
### components/
| File | What it is | Agents should know |
|---|---|---|
| `library/components/PeopleGroupDetail.vue` | Detail panel for a selected people group: CTAs, PPLR stats + live prayer count, image, description. Props: peopleGroup (required), hideHeader, dark, action. | Injects 'uiStore' — parent must provide. Fetches `/api/people-groups/detail/{slug}` with deliberately NO cache-buster (CF 5-min cache) — never re-add one or stub the count. Styles duplicated in useShadowStyles string AND scoped block — edit both. |
| `library/components/ResearchMapFilterPanel.vue` | Multi-field filter panel (population range, engagement/adoption selects, language/religion chips) for the research-map. | Injects mapStore/dataStore; only writes `mapStore.filters` — the profile deep-watches and applies; options come from `dataStore.facets`. |
| `library/components/ResearchMapSideMenu.vue` | Research-map side drawer: cluster-mode toggle, pin-dim slider, filter-panel slot, poster/reload buttons. Props: isOpen; emits close/open-poster/reload-map. | Writes `mapStore.clusteringMode` + `clusterPinOpacity` (map reacts via useMapClustering). Keep box-shadow on `.open` only. |
| `library/components/SideMenuDrawer.vue` | Shared slide-in left drawer (75% width, max 420px) over the map area, slot content + reload button. Props: isOpen, isDark. | Absolute positioning — parent must be a positioned container inside the custom element; needs i18n keys buttons.closeMenu/reloadMap, messages.menuComingSoon. |
### components/legends/
| File | What it is | Agents should know |
|---|---|---|
| `library/components/legends/LegendDesktop.vue` | Desktop legend card: collapsible pill rendering LegendRows tabs, swaps to PeopleGroupDetail on pin select, defers to LegendMobile on mobile. Props: legendType, initiallyCollapsed, isDark, popupAction, disableCollapse. | Requires injected mapStore/dataStore/uiStore; styles live in useShadowStyles only. Language-family tab intentionally empty here (SemanticTreeLegend is a sibling). Geometry (top:60px/left:8px/380px) must stay in sync with SemanticTreeLegend's `.stl-panel`. Listens for `doxa:openDesktopLegend` scoped by mapId. |
| `library/components/legends/LegendMobile.vue` | Mobile bottom-sheet legend chrome (drag/collapse/detail) wrapping the same LegendRows/SemanticTreeLegend/PeopleGroupDetail. | All CSS lives in a useShadowStyles template literal — a stray backtick in its comments silently blanks the whole mobile stylesheet. |
| `library/components/legends/LegendRows.vue` | Flat-grid legend table: colored clickable filter rows, count/population columns, collapsible children, sticky title + totals footer. Emits filter-click. | ONE flat CSS grid with subgrid rows (spec: documentation/legend-spec.md); `.lrg-item` wrappers are display:contents on desktop only for LegendMobile compat — don't add layout to them. Never use scrollIntoView (host-page scroll regression 554bf10). |
| `library/components/legends/LegendTools.vue` | Floating pill toolbar (auto-fly tour + clusters toggle) above the desktop legend card. Emits legend-row-fly. | Only renders for doxa-regions / language-family legend types; must stay a SIBLING of LegendDesktop (own `.lt-` CSS namespace); flight/cluster state lives in injected mapStore; hidden below 768px. |
| `library/components/legends/SemanticTreeLegend.vue` | Hierarchical tabbed legend panel (collapsible, sortable, drill-up/down tree). Props: nodes, tabs, columns, open, exportEnabled; emits select/update:open/export. | Syncs selection via the per-instance mediator (usePplrInstance) when present, else local state; styles via useShadowStyles. |
### components/map-controls/
| File | What it is | Agents should know |
|---|---|---|
| `library/components/map-controls/index.js` | Barrel re-exporting all map-control components. | New controls need an export line here or they're invisible to consumers. |
| `library/components/map-controls/MapControlButton.vue` | Round base button all toolbar controls wrap; props isDark, title, active, disabled; emits click. | CSS via useShadowStyles key 'map-control-button'; keep changes parameterized (shared by all bundles); tight box-shadow is deliberate. |
| `library/components/map-controls/MapToolbar.vue` | Absolute column-flex layout shell for control buttons; prop hasGeocoder; slot for profile-chosen buttons. | Pure layout; force-slims slotted `.mcb`/`.hbg-btn` controls to 32px; hasGeocoder=false collapses the mobile top offset. |
| `library/components/map-controls/GeocoderComponent.vue` | Mapbox Geocoder search bar with DOXA local-search results and dual mount modes (map control vs standalone header). Emits result, people-group-result, aggregate-result, clear, error. | Depends on CDN globals MapboxGeocoder/mapboxgl + injected stores; on select it mutates store filter state directly; standalone mode uses `onAdd()` with a stub map because `addTo()` breaks in shadow roots. |
| `library/components/map-controls/HamburgerButton.vue` | Renderless hamburger/X toggle mounted as a Mapbox custom control (top-left). Props map, isOpen, isDark; emits toggle. | DOM built imperatively via map.addControl; parent owns open/close state; silently no-ops if `map` isn't set at mount. |
| `library/components/map-controls/FullscreenButton.vue` | Fullscreen toggle on the shadow host, with iOS pseudo-fullscreen CSS fallback. Props mapContainer, isDark; emits change. | iOS path mutates host/body/html inline styles and snapshots/restores them — don't set inline styles on the host while fullscreen is active; listens on the ShadowRoot's own fullscreenchange. |
| `library/components/map-controls/HelpButton.vue` | "?" button that emits `open-help`. | Presentational only; i18n key buttons.aboutThisMap. |
| `library/components/map-controls/LocationButton.vue` | Flies to user geolocation, falls back to default center/zoom on denial/timeout. Props map, center, zoom, userZoom. | Errors silently swallowed into flyToDefault (5s timeout, 60s maximumAge); i18n key buttons.resetView. |
| `library/components/map-controls/ShareButton.vue` | Share button + popover (copy link / copy iframe snippet with size presets). Props isDark, embedPath (tree-relative `<bundle>/<profile>/index.html`). | Share URL via `utils/embedBase.js`: profileConfig.shareUrl > embedPath prop / profileConfig.shareEmbedPath joined to the embed base (profileConfig.embedBaseUrl > window.MAP_EMBED_BASE_URL > the bundle's own script url two segments up) > the page itself; keep the execCommand copy fallback for file:// contexts. |
| `library/components/map-controls/ThemeToggleButton.vue` | Sun/moon button emitting `toggle`. | Dumb emitter — parent handles uiStore.toggleTheme() + style/theme work; i18n keys buttons.switchToLightMode/switchToDarkMode. |
| `library/components/map-controls/ZoomInButton.vue` | Zoom-in one step. Props map, isDark. | map prop nullable (optional-chained); i18n key buttons.zoomIn. |
| `library/components/map-controls/ZoomOutButton.vue` | Zoom-out one step. Props map, isDark. | map prop nullable; i18n key buttons.zoomOut. |
### components/poster/ (+ slots/)
| File | What it is | Agents should know |
|---|---|---|
| `library/components/poster/PosterDialog.vue` | Modal for configuring (size/DPI/orientation/format/title/slots) and triggering poster export, with live preview + blob download. Props: modelValue, getMap (required fn), legendRows, metadata, initialSize. | Pass a getMap accessor FUNCTION, not a map instance; footer attribution forced-on from constants/posterDefaults; size change auto-overrides DPI/orientation via posterSizes presets. |
| `library/components/poster/PosterPreview.vue` | Scaled live poster preview: snapshots the map canvas to PNG and overlays slot components at layout positions. Props: spec, getMap, legendRows, metadata. | Snapshot re-captures only on mount and spec dims/dpi change (not pan/zoom); legendRows prop is declared but unused (rows come from spec.slots.legend.rows). |
| `library/components/poster/slots/TitleSlot.vue` | Poster title strip (title/subtitle/align). | Preview-only — the headless export path uses separate SVG stubs in useMapPoster, so visual changes here don't affect exported posters. |
| `library/components/poster/slots/FooterSlot.vue` | Stub footer slot (display:none). | Intentional stub; keep the `spec` prop and `data-slot="FooterSlot"` contract expected by PosterPreview. |
| `library/components/poster/slots/LegendSlot.vue` | Stub legend slot (display:none). | Implement here when poster export is wired; keep the spec/data-slot contract. |
| `library/components/poster/slots/NorthArrowSlot.vue` | Stub north-arrow slot (display:none). | Knowing stub wired into PosterPreview; pending per build-friction notes. |
| `library/components/poster/slots/ScaleBarSlot.vue` | Stub scale-bar slot (display:none). | Implement here rather than creating a new slot file. |
### composables/
| File | What it is | Agents should know |
|---|---|---|
| `library/composables/useAvatarGenerator.js` | Single-letter avatar generator (letter + 8-color palette by charCode). Exports generateAvatar, generateAvatarHTML, getAvatarStyle. | Pure functions, no reactivity; AVATAR_COLORS is hardcoded here, not in colors/_registry; generateAvatarHTML interpolates into raw HTML. |
| `library/composables/useCountryOutline.js` | Instant outline-only country highlight using Mapbox country-boundaries-v1 vector tiles. Returns showCountry(iso3)/clearCountry(). | Singleton source/layer per map (repeats swap the filter); worldview filter required or outlines draw 2-3x; camera framing is the caller's job; ops are try/catch no-ops pre-style-ready. |
| `library/composables/useGeocoderSearch.js` | The single copy of geocoder-result → map-selection logic (kind-switch, per-kind filter builder, legend-tab routing, clear handling). Returns onPeopleGroupResult, onAggregateResult, onClear, clearGeocoderProgrammatic. | Kind→property map + filter builder live ONLY here — never re-implement in a profile; legendRouters injects divergent per-profile logic; busAdapter reroutes handlers to a non-map sink. Always clear via clearGeocoderProgrammatic (feedback-loop guard). |
| `library/composables/useMapClustering.js` | Three clustering modes (mapbox bubbles, MST lines, network lines) behind one API. Returns enabled, mode, setMode, applyClustering, clearClustering, cleanup, … | inject('mapStore') — never import stores directly; clear/toggle hides layers + setData(EMPTY_FC) instead of removing (GL heap-leak avoidance); only cleanup() removes sources/layers — call from host onBeforeUnmount. |
| `library/composables/useMapData.js` | Loads people-group data via injected DataSourceManager, canonicalizes languageFamily/region keys, builds derived legend datasets. Returns loadData, peopleGroups, normalizedPeopleGroups, regionsData, stats, …. | Pass dataSourceManager + markRaw in options. Language family MUST derive from ISO 639-3 code (langFamilyByIso), never localized labels; doxa-region registry reads are lazy and guarded on propertyKey==='doxaRegion'; regions map ISO alpha-3 → stable slug. |
| `library/composables/useMapEvents.js` | Mapbox layer event handlers: pin/region/cluster clicks, popups, cursor hover, cleanup. | Call during setup (injects 'uiStore', useI18n). If uiStore has legendMode, pin clicks route to the legend panel instead of a popup. Hardcoded layer IDs ('language-family-pins', 'regions-fill', 'clusters'); attach after layers exist, detach on unmount. |
| `library/composables/useMapFly.js` | Camera navigation: fly/fitBounds helpers, panel-offset centering, antimeridian-safe legend-row zoom. | zoomToLegendRow reads getSource()._data and re-implements Mapbox filter evaluation in JS — extend _matchesFilter/_resolveExpr for new expression ops; wide spans ease to a world overview instead of fitBounds. |
| `library/composables/useMapInstance.js` | One map's lifecycle: shadow-DOM CSS injection, aspect-aware dynamic minZoom floor, edge scroll strips, teardown. Returns map, isMapReady, initializeMap, destroy. | initializeMap() in onMounted with a real DOM element ref (string IDs break in shadow DOM); minZoom is a lower bound raised to a desktop-only no-duplicate-world floor; expects sibling `.map-scroll-edge` strips and global mapboxgl; always call destroy(). |
| `library/composables/useMapLayers.js` | Creates/manages all Mapbox layers (pin GeoJSON with family derivation + co-location fan-out, regions fill/border, connection lines, prayer-glow rAF ripple) and theme-aware pin ring widths. | Do NOT add a style.load listener here — the host profile owns post-setStyle reconciliation (flash race). addPrayerGlowLayers is idempotent; keep zoom 'case' inside interpolate OUTPUTS; dark-ring exceptions are config-only, keyed by strategy name. |
| `library/composables/useMapSelection.js` | Applies region/family selection to layer paint props (highlight/fade opacity, connection lines). | Region matching is on iso_3166_1_alpha_3 (tileset has no `region` prop); labels fuzzy-normalized to slugs; selection state lives in the Pinia mapStore. |
| `library/composables/useMapTheme.js` | Seamless light/dark toggle by flipping Mapbox Standard's lightPreset/theme config props. Returns bootStyle, applyTheme, swapTheme, isDarkTheme. | Never setStyle to switch themes (reloads map, wipes custom layers); applyTheme must run after style.load and re-asserts once on 'idle' (fast-toggle grey-film race); profiles should watch(uiStore.theme) → applyTheme. |
| `library/composables/usePplrInstance.js` | Per-map mediator "instance store" (provide/inject refs) so map, geocoder, legend in one cluster communicate. Exports createPplrInstance, provideInstance, useInstance, selectNode, clearAll. | Not Pinia — plain refs under key 'pplrInstance'; useInstance() returns null when standalone, so consumers must null-guard; profiles bridge instance.selection to their own store. |
| `library/composables/usePrayerStatistics.js` | Fetches the server-side prayer-coverage aggregate (`/api/people-groups/statistics`) — single source of truth for all legends. | Module-level singleton state (one shared fetch); never recompute prayer counts from loaded pins (client scans diverge from parent site); load() is idempotent; bucket getters are exclusive and sum to total. |
| `library/composables/useSelectedPin.js` | Highlights the selected people-group dot and floats a black SVG "GO" pin over it. Returns initialize, cleanup, updateSelectedPin. | Pass getActivePinColor via options — inject() never resolves here, and omission silently renders black. Marker pin is deliberately always black; requires injected 'uiStore' + a 'language-families' source; circle-emissive-strength re-asserted per selection (Standard lighting bug). |
| `library/composables/useShadowStyles.js` | Injects a component's CSS string into the containing Shadow DOM root on mount (defineCustomElement drops async-child styles). | Call at setup() time; no-ops in light DOM; idempotent via label/CSS-hash key — same label across components suppresses injection. |
### composables/legends/
| File | What it is | Agents should know |
|---|---|---|
| `library/composables/legends/useLegendData.js` | Builds the unified LegendItem tree (rows, columns, title, filter wiring, totals) for every legend type from injected stores. | Research-only strategies (resource, doxa-region, affinity-block) are read LAZILY from _registry at compute time and GUARDED on propertyKey — getColorStrategy silently falls back to language-family. Prayer counts come from usePrayerStatistics, not client pin scans. |
| `library/composables/legends/researchLegendOptions.js` | Research-map options object (aliases, religion-family exclusions, custom row builders) fed into useLegendData. Exports RESEARCH_LEGEND_OPTIONS. | doxa-region/resource strategies are registered by the research bundle's src/colors glob — all registry reads MUST stay lazy (inside builders/getters, not module scope), guarded on propertyKey. |
| `library/composables/legends/useAffinityBlockLegendData.js` | Aggregates pins into a 4-tier Affinity Block → Cluster → People Group → PG-in-country tree for SemanticTreeLegend. | Mirrors useLanguageFamilyLegendData's node shape; colors read lazily + guarded on propertyKey==='affinityBlock'; all tier filters use uniqueId member lists, not code matching. |
| `library/composables/legends/useLanguageFamilyLegendData.js` | Aggregates features into a family → language → dialect legend tree (rows, sorting, expand state, highlight events, filter tree). | API's language_family is null — family is derived client-side from comma-inverted primary_language labels via langFamilyByLanguage.json + sign-language rule; langTree filters match pins by `language` string. Only EMITS `legend:highlight` window events; the map listener (research-map.vue) dims pins. |
| `library/composables/legends/useWagfRegionsLegendData.js` | Aggregates items into a 3-tier WAGF Region → Block → Country tree with filter expressions and region colors. | REGION_COLORS is keyed on the stable wagf_region slug (never localized labels) and must stay mirrored with SLUG_PALETTE in the research bundle's src/colors/doxa-region.js; highlight() uses the `legend:highlight` window CustomEvent. |
### composables/poster/
| File | What it is | Agents should know |
|---|---|---|
| `library/composables/poster/useMapPoster.js` | Renders a live map to a poster-grade PNG/PDF blob via a hidden off-screen map (single-canvas ≤4096px or tile-stitch) plus SVG overlay slots. Returns render, progress, isRendering, cancel; also posterPixels. | jspdf is lazy-imported for PDF and throws if missing; mutates window.devicePixelRatio during hidden-map spawn; requires window.mapboxgl; Mapbox attribution footer always composited (ToS); headless slots use stub SVGs, not the Vue slot components. |
| `library/composables/poster/usePosterLayout.js` | Pure layout math: PosterSpec + pixel dims → absolute {x,y,w,h} boxes per slot. Exports computeLayout, inchToPx, usePosterLayout. | Pure/no reactivity despite the use* name; absent slots return zero boxes; footer is ALWAYS present (attribution per ToS); defaults from constants/posterDefaults.js. |
### constants/
| File | What it is | Agents should know |
|---|---|---|
| `library/constants/mapDefaults.js` | Default Mapbox init settings + canonical LAYER_ORDER, overridable per app-profile. | Keep style = Mapbox Standard (theme is a lightPreset flip, not setStyle); keep renderWorldCopies:true with no maxBounds (zoom-out capped by computeWorldMinZoom); never hardcode a token here. |
| `library/constants/posterDefaults.js` | Default poster-print layout constants (margins, bleed, slot sizes, typography) + `defaultPosterSpec(size)` factory. | All measurements in inches (px via spec DPI); footer attribution mandatory; override via the PosterSpec passed to useMapPoster, don't edit defaults per-map. |
| `library/constants/posterSizes.js` | Preset poster dimensions (US + ISO) with inch/mm/300-DPI px. Exports POSTER_SIZES, POSTER_ORDER, getPosterSize, listPosterSizes, listByCategory. | Pure data; new sizes go in BOTH POSTER_SIZES and POSTER_ORDER; getPosterSize returns null on unknown id; orientation flips via PosterSpec, not here. |
| `library/constants/zoom.js` | Centralized zoom constants: region fly-to configs, min/max constraints, interpolation curves, clustering thresholds, fly durations + getters. | CLUSTERING_ZOOM is NOT the runtime source of truth — ClusterHelpers.DEFAULT_MAPBOX_CLUSTER_OPTIONS is; keep them in lockstep and clusterMaxZoom ~2 below the map zoom cap or clusters never break apart. |
### data/
| File | What it is | Agents should know |
|---|---|---|
| `library/data/iso-numeric-to-alpha2.json` | ISO 3166 numeric country code → alpha-2 lookup. | Currently has NO code consumers (docs mention only). Importable via `@map/data/*.json`. |
| `library/data/langFamilyByIso.json` | ISO 639-3 language code → language-family name lookup. | Generated from the EN API + langFamilyByLanguage.json; the canonical family-derivation source (labels are locale-fragile). |
| `library/data/langFamilyByLanguage.json` | English language name → family name lookup. | Its lookup logic (reversed-name fallback, stripped variants) is duplicated in 4 composables (useMapData, useMapLayers, useLanguageFamilyLegendData) — keep them consistent. |
### i18n/
| File | What it is | Agents should know |
|---|---|---|
| `library/i18n/index.js` | Vue I18n setup: eagerly inlines all locale JSON catalogs via glob, builds a per-map i18n instance with document-lang detection. Exports createAppI18n (default), detectLocale, SUPPORTED_LOCALES, RTL_LOCALES. | Folder name IS the locale, filename IS the $t() namespace; new locales must also be added to SUPPORTED_LOCALES (+ RTL_LOCALES if RTL). Do NOT convert to lazy imports (IIFE grew ~22 KB when tried). |
| `library/i18n/locales/*` (97 catalogs) | 11 locales (ar, de, en, es, fr, hi, it, pt, ro, ru, zh), one JSON per UI area (buttons, legend, detail, options, search, messages, aria, prayerLaps, tabs, popup). | Add a language by copying `en/`, translating values without renaming/dropping keys. `en/` is the fallback source of truth; missing keys/files silently fall back to English. File sets are uneven: en/fr have 10 areas, ar/de/es/pt/ru 9, hi/it/ro/zh only 8 (no tabs.json — their tab strings render in English). |
### stores/
| File | What it is | Agents should know |
|---|---|---|
| `library/stores/dataStore.js` | Pinia store caching fetched datasets per sourceId with loading/error state. Exports useDataStore. | All fetching routes through this store (no fetch() in components); markRaw() is mandatory on cached payloads. |
| `library/stores/mapStore.js` | Pinia store: registry of live map instances + cross-map state (selections, filters, auto-fly, clustering, fullscreen, persisted prefs). Exports useMapStore. | Selections are mutually exclusive — each select* action nulls the others; single store shared across ALL maps on a page (activeMapId tracks focus); prefs persist to localStorage 'mapUserPreferences'. |
| `library/stores/uiStore.js` | Pinia 'ui' store: theme, tabs, mobile legend sheet, search/hamburger, pin filters, selected people group, toasts, modals, loading, breakpoints, a11y. Exports useUIStore. | `toggleHamburgerMenu` is defined TWICE in actions (second silently wins); actions validate and silently no-op on invalid values; call init() once; selectPeopleGroup() hijacks legendState/legendMode. |
### utils/
| File | What it is | Agents should know |
|---|---|---|
| `library/utils/ClusterHelpers.js` | Pure geometric/GeoJSON helpers + default Mapbox cluster options/ramps shared by all clustering modes. | Distances are degree-space Euclidean (viz only); convexHull mutates its input and returns an open ring; DEFAULT_MAPBOX_CLUSTER_OPTIONS.clusterMaxZoom (6) must stay below the map zoom cap or clusters never break apart. |
| `library/utils/MSTClusteringUtil.js` | Kruskal MST + Union-Find turning grouped lat/lng points into GeoJSON LineStrings per group with no orphans. | Distances in raw degrees (maxConnectionDistance is degrees); O(n²) edge building per group; maxConnectionDistance can silently split groups (console.warn only). |
| `library/utils/NetworkClusteringUtil.js` | Nearest-neighbor "network/shard" cluster-line generator (denser alternative to MST). | Naive Euclidean degrees, NOT a true MST; points need parseFloat-able latitude/longitude; groups with <2 valid points return no lines. |
| `library/utils/geoUtils.js` | Pure geo/GeoJSON helpers (bounds, centroid, zoom, coordinate/feature validation, color helpers) shared across bundles. | Pure/no-deps by contract (no Vue/Mapbox). darkenColor has hardcoded per-region hex overrides bypassing the percent math; isValidCoord takes an array, isValidCoordinate takes (lon, lat) numbers. |
| `library/utils/DataSourceManager.js` | Back-compat shim re-exporting the real module from `library/api/DataSourceManager.js`. | Shim only — new code imports from `@map/api/`; don't add logic here. |
| `library/utils/apiBaseUrl.js` | Back-compat shim re-exporting getApiBaseUrl from `library/api/apiBaseUrl.js`. | Deprecated alias; import from `@map/api/apiBaseUrl.js` directly in new code. |
---
## plugin-designs/ — one folder per bundle
### demo-map/
| File | What it is | Agents should know |
|---|---|---|
| `plugin-designs/demo-map/index.js` | Bundle entry: defines/registers the `<demo-map>` custom element wrapping @map/ProfileLoader. | import.meta.glob calls must live HERE (build-time, relative): `./*.vue` → 'profileModules', `./src/colors/*.js` → registerStrategies. i18n provided as 'appI18n' (custom-element root can't useI18n()). |
| `plugin-designs/demo-map/index.html` | Dev/staging page: loads Mapbox GL peer, mounts `<demo-map>` with a profile-config attribute, pulls ./index.js as Vite entry. | %VITE_MAPBOX_TOKEN% replaced by Vite from gitignored .env; sets window.DOXA_TOKEN + MAP_APP_BASE_URL; Mapbox GL must load before the bundle script. |
| `plugin-designs/demo-map/demo-map.vue` | Demo profile: two-tab map (religion coloring vs all-purple pins) composed from @map composables + a local single-color strategy. | Relies on ProfileLoader injections; tab colorStrategy IDs must exist in the registry; layer name 'language-family-pins' is hardcoded for tab repaints; legend duck-types the strategy shape. |
| `plugin-designs/demo-map/src/colors/colors.json` | Static hex palette (pins, polygons, fallback) — code-free edit surface. | Edit hex strings only; per-value logic goes in a strategy .js in the same folder. |
| `plugin-designs/demo-map/src/colors/single-color.js` | Local strategy painting every pin one constant purple. | Auto-registered via the src/colors glob — filename becomes the mode key (single-color → singleColor); keep the buildColorExpression alias and default `name`; applyColor must return a Mapbox expression array. |
### doxa-research-map/
| File | What it is | Agents should know |
|---|---|---|
| `plugin-designs/doxa-research-map/index.js` | Bundle entry: globs profiles + colors, registers strategies, sets up the RTL plugin, defines `<doxa-research-map>`. | Globs are relative to THIS file — profile .vue at bundle root, strategies in src/colors/, or they silently don't register. Deliberately does NOT register legacy `<doxa-map>` (owned by doxa-simple-map; one-shot customElements.define). |
| `plugin-designs/doxa-research-map/index.html` | Staging showcase: gem-framed instances from a flat DOXA_SLOTS array, profile tabs, overview grid. | Slot `profile` must match a .vue filename at bundle root; profile + tk (+ ?locale=) auto-injected into config; ./index.js script tag rewritten to the IIFE at build. |
| `plugin-designs/doxa-research-map/research-map.vue` | The research-map profile (~2100 lines): 7-tab map (prayer/adoption/engagement/people-groups/regions/languages/religions) with SemanticTreeLegend, geocoder, filters, detail panel, lazy poster/packet export. | Tabs are hardcoded in ALL_TABS/FEATURES (NOT profile-config); config arrives only via injections; maxZoom pinned to 22 deliberately; heavy deps (poster, packet, clustering, detail panel, mobile legend) are lazy-loaded — keep them off the boot path. |
| `library/colors/affinity-block.js` | Strategy mapping ROP1 affinity-block codes (A001–A017) to names and pin colors. | ROP1 code is the primary key (name lookups are lossy legacy); reads properties.affinityBlock falling back to `_raw.ROP1`; keep NAMES and PALETTE keys in sync; A016 intentionally absent. |
| `library/colors/doxa-region.js` | Strategy coloring pins by DOXA/WAGF region; SLUG_PALETTE is authoritative. | Color by slug, never localized display labels (break in non-EN locales); uses @map/utils/geoUtils generateColorFromString as fallback. |
| `library/colors/resource.js` | Binary green/black coloring by gospel-resource availability + per-resource palette for the legend. | Default coloring is intentionally binary despite the per-resource PALETTE; resource values must literally equal 'Available' (properties or _raw CSV columns via getCSVColumn); legacy aliases kept for compat. |
### doxa-simple-map/
| File | What it is | Agents should know |
|---|---|---|
| `plugin-designs/doxa-simple-map/index.js` | Bundle entry registering `<doxa-map>` (legacy) AND `<doxa-simple-map>` mounting ProfileLoader with per-instance Pinia/i18n. | Q3 contract: the bundle runs import.meta.glob('./*.vue') itself and provides 'profileModules' (ProfileLoader can't glob across @map). defineCustomElement called twice on purpose (one class per tag); RTL plugin registered once. |
| `plugin-designs/doxa-simple-map/index.html` | Staging showcase: gem-framed `<doxa-map>` slots (Engagement/Prayer/Adoption) with profile tabs and overview grid. | Edit only DOXA_SLOTS; `profile` must match a bundle-root .vue; ?locale= forwarded into every config; build rewrites the module script to ../../app/doxa-simple-map.iife.js. |
| `plugin-designs/doxa-simple-map/doxa-simple-map.vue` | Tabbed (prayer/engagement/adoption) single-map profile wiring library composables, legend, geocoder, and strategies into one Shadow-DOM-safe embed. | Behavior gated by in-file FEATURES flags (geocoder/hamburger/overlapDensity/activeOverlayEmphasis OFF here); tabs come from injected profileConfig; SHARE_EMBED_BY_ID hardcodes instance-page URLs — renaming instances in simple-map.instances.json breaks share links. |
| `plugin-designs/doxa-simple-map/simple-map.instances.json` | Instance manifest: build emits N preset standalone embed pages (prayer/engagement/adoption) from the one parameterized profile. | Build-consumed data, not runtime code: each instance becomes `doxa-simple-map/<id>/index.html`; instances SPLIT one profile into many render targets; colorStrategy/legend/popup values must match registered strategy names. |
### template-bundle/ (copy-me starter)
| File | What it is | Agents should know |
|---|---|---|
| `plugin-designs/template-bundle/index.js` | Copy-me entry: globs flat .vue profiles, registers private strategies, defines `<template-bundle>`. | Globs are position-bound: `./*.vue` flat (filename = profile name) and `./src/colors/*.js` (colors.json ignored by the glob); tag name = bundle folder name. |
| `plugin-designs/template-bundle/index.html` | Staging page rendering both template profiles side-by-side. | The profile-config `"profile"` field selects which bundle-root .vue renders; copying this bundle means updating tag names and profile strings. |
| `plugin-designs/template-bundle/template-map-a.vue` | Minimal example profile "A" — colored placeholder, no Mapbox/data, demonstrates profile-config injection + sandbox color JSON. | Filename minus .vue IS the profile name; must sit at bundle root; sandbox imports are relative (./src/...) — @map alias is library-only. |
| `plugin-designs/template-bundle/template-map-b.vue` | Minimal example profile "B" — shows multi-profile bundles; both profiles compile into one bundle JS. | Host selects it via profile-config `{"profile":"template-map-b"}`. |
| `plugin-designs/template-bundle/src/colors/colors.json` | Plain-values hex palette a non-programmer edits. | Values-only; logic goes in a strategy .js beside it; imported relatively from the bundle-root .vue. |
| `plugin-designs/template-bundle/src/colors/example-mode.js` | Copy-me template for a bundle-private strategy (two-bucket example on peoplePraying). | Filename IS the mode key (kebab → camelCase); matching a library mode overrides it for this bundle only; `name` in the default export is required or the tab/legend label is blank. |
---
## internal/ — build machinery and staging (do not edit for per-map styling)
| File | What it is | Agents should know |
|---|---|---|
| `internal/clean.mjs` | Pre-build script that deletes/recreates the output tree `public/<name>/`. | Reads the tree name from vite.doxa-maps-build-config.json (fallback "doxa-maps"); must run BEFORE per-bundle vite builds. |
| `internal/generate-tree.mjs` | Post-vite step that scans plugin-designs/ and writes the self-contained doxa-maps/ output tree (manifest.json, staging pages, standalone profile/instance pages) and copies to configured destinations. | Runs AFTER per-bundle builds (expects the IIFE at `public/<name>/<bundle>/<bundle>.js`); discovery is convention-driven (bundle = non-underscore dir with .vue at ROOT — profiles moved into src/ silently vanish); all URLs stay relative unless embedBaseUrl is set; inlines staging-forge.{css,js}. |
| `internal/generate-library-index.mjs` | Scans live library/ code and regenerates documentation/reference-library-index.md (one-line import catalog). | Output is DERIVED — never hand-edit the index; rerun after any library/ change; descriptions come from each file's first header-comment line. |
| `internal/drop-portability-check.mjs` | CLI gate (`check:drop`): scans built .html/.json for root-relative refs and fails the build if any would break subpath/Drop hosting. | Defaults treeDir to `public/<name>` from the build config — build first; only checks .html/.json and a fixed manifest key list (iframeUrl\|scriptUrl\|url\|embedUrl\|output) — new URL keys need adding here. |
| `internal/inline-parse-check.mjs` | Post-build verifier (`check:parse`): syntax-checks every inline classic `<script>` in the built HTML via `node --check`. | Only matches bare `<script>` (no attributes), checked as classic scripts — import.meta or a literal `</script>` in snippets is exactly the bug class it catches. |
| `internal/staging-forge.js` | Client script for the generated staging pages: semantic bundle/profile/instance tree sidebar + live map iframe stage + token paste-once UX. | Must stay file://-safe (no import/export; manifest inlined as window.FORGE.manifest); the DEV-HMR block is stripped by generate-tree.mjs — keep the DEV-HMR-START/END markers intact. |
| `internal/staging-forge.css` | Dark "Embeddable JAMstack Maps" theme for the generated staging pages (tree sidebar, token bar, gem-framed stage, mobile layout). | Selectors are a contract with the staging HTML/JS generator (which sets --gem/--gem-rgb per node); pure presentation for internal pages. |
| `internal/staging-reference-v2.html` | Hand-authored reference mockup of the staging-root tree page (nested details, lazy iframes) that generate-tree.mjs's template replicates. | Not served or built — spec-by-example; rules encoded: inline manifest (file:// safe), lazy iframe src on toggle, tk never baked, normal-flow header. |
| `library/scripts/research/` | Table + resource builders shared by cities-internet, gospel-resources and key-cities. In `library/` because more than one profile consumes the output. |
| `library/scripts/posters/` | Poster and preview generator shared by country-posters and my-upg-100-list. Its `out/` tree is render output and is gitignored; what ships is the exported subset in `public/assets/`. |
| `plugin-designs/gospel-resources/scripts/` | Link and click-sheet verifiers. Single-profile, so they live with the profile rather than in the library. |
| `internal/write-token.mjs` | Writes `public/api/maps/token.json` from the `MAPBOX_TOKEN` environment variable. Refuses an `sk.*`; writes nothing when unset. |
---
## Root config files
| File | What it is | Agents should know |
|---|---|---|
| `vite.config.js` | Vite config building one IIFE bundle per invocation (BUNDLE env var) from auto-discovered plugin-designs/; serves all bundles in dev. | Build requires `BUNDLE=<name>` (`npm run build` loops all); bundle discovery needs BOTH index.html AND index.js at profile root (`_`/`.` dirs skipped); `@map` alias → library/; jspdf externalized to an esm.sh CDN URL in builds; output name from vite.doxa-maps-build-config.json. |
| `vite.doxa-maps-build-config.json` | The single committed build-config: output tree name ("doxa-maps"), extra copy destinations (paths), embedBaseUrl, Mapbox tokenUrl/token. | Edit this ONE file then `bun run build`; empty embedBaseUrl keeps manifest URLs tree-relative (drop-portability gate); token ladder: per-map prop > ?tk= > baked token > window.MAP_TOKEN_URL > tokenUrl > same-origin /api/maps/token. tokenUrl is a URL, not a secret. |
| `package.json` | Root npm/bun manifest: scripts (dev, build, build:bundle, clean, tree, check:drop, check:parse) + deps (vue 3, pinia, vue-i18n, topojson-client, world-atlas, jspdf; vite 5 toolchain). | `build` iterates plugin-designs/*/ setting BUNDLE per invocation — adding a folder auto-builds; internal/ scripts run as part of build (check:drop currently invoked twice, likely unintentional). Bun-first toolchain. |
---
## The Semantic Tree Legend (`@map/components/legends/SemanticTreeLegend.vue`)
`@map/components/legends/SemanticTreeLegend.vue` is a reusable hierarchical legend for any
one-to-many semantic tree (Affinity Block → Cluster → People Group, or Language Family →
Language). You feed it a tree of nodes; it renders one tab per generation plus rows.
Node shape: `{ id, label, color?, count?, pop?, filter?, info?, children?: [ … ] }`
| Prop | Meaning |
|---|---|
| `nodes` | The tree (array of root nodes). |
| `tabs` | Optional labels per generation; auto-generated from depth if omitted. |
| `columns` | Numeric columns to show (default `['count','pop']`). |
| `title` | Panel heading. |
| `hideTabs` | Render a single flat level. |
| `exportEnabled` | Opt-in flag revealing the (scaffold) Export affordance. |
Selecting a node walks the tree to highlight it with its ancestors/descendants and emits a
`select` event the map listens to for filtering. If a per-instance store is provided (via
`usePplrInstance`) it syncs `activeTab`/`selection` through that shared store; otherwise it
falls back to local state and still works standalone. It never imports the map or geocoder —
the profile screen wires its `select` emission to map filtering.