24 KiB
Reference
Look-up tables and contracts for doxa-maps. For learning, see the tutorial; for tasks, how-to.md; for the why, why.md; for the short "what it is", explanation.md.
doxa-maps is a Vite bundler: one shared library, N independent embeddable bundles. Each
bundle is a single self-contained .js file that registers a custom HTML element
(<doxa-map>, <doxa-research-map>, …) you can drop on any web page.
The three zones
| Zone | Path | Alias | License | You edit it… |
|---|---|---|---|---|
| Shared library | library/ |
@map |
Apache-2.0 | …once; the fix reaches every bundle on next build |
| Your app-profile | plugin-designs/<name>/ |
— | yours | …freely; nothing here leaks to other maps |
| The build machine | internal/ |
— | Apache-2.0 | …never — it's the bundler's own scripts |
Terminology. "Profile" is used at two levels: a bundle / app-profile is a folder under
plugin-designs/; a profile screen is one .vue file sitting flat in that folder (no
profiles/ subfolder — the entry globs ./*.vue). A bundle can hold several screens; the host
picks which to render with the profile field of profile-config.
Two words first. An element is a custom HTML tag this repo invents, such as <doxa-map>: the browser
learns it once, then you can paste it into any page as often as you like; each pasted tag is one live copy, an
instance (people also say "web component"). An app profile (this repo also says mounting file) is one
flat .vue in plugin-designs/<bundle>/ that defines a whole map app — its main component; the settings on the
tag (profile-config) name which app profile to load and the shared ProfileLoader picks it. In class-and-object
terms: the element class is the class, each tag is an instance, the app profile is the screen that instance loads.
Most repos need one such file per app; doxa-maps builds hundreds of maps from one shared library/, so every map
gets its own app profile — and those files come in five shapes:
Profile types — the ladder, smallest first (head words are established terms; the -element compounds are
this repo's own; the coined names stay as a.k.a.):
single-element → preset-element → multi-view-element → composite-element → app-shell.
| Type (a.k.a.) | Machine key | What it is | How the build recognises it |
|---|---|---|---|
| single-element (single) | single |
one app profile, one map — paste its tag anywhere, set it up in profile-config; the default |
a flat .vue with no <profile>.meta.json and no *.instances.json in the folder |
| preset-element (parameterized) | parameterized |
one app profile + a small table of named presets; each row is a ready-made map with its settings baked in, with its own embed page and sidebar entry — same code, same tag | the bundle ships <name>.instances.json — { "instances": [{ "id", "title", "props" }] } or a bare array; the build writes one embed page per row at <bundle>/<profile>/<id>/index.html |
| multi-view-element (monolithic) | monolithic |
one app profile holding several screens you switch between — tabs or steps — in one .vue, shown one screen at a time |
declared: <profile>.meta.json → { "type": "monolithic" } (or "multi-view-element") |
| composite-element (nested-elements-mfe) | nested-elements-mfe |
an app profile whose template places other app profiles' maps together (stacked or side by side, e.g. a map + a poster wall) and ships the set as one new element — one tag embeds it all | declared: <profile>.meta.json → { "type": "nested-elements-mfe", "composes": [ … ] } (or "composite-element") |
| app-shell (nested-mfe-spa) | nested-mfe-spa |
the outer frame — sidebar, header, navigation — that loads any of the other kinds and switches between them without a reload; the top rung, the full doxa-maps app | a compiled Vue custom element today — plugin-designs/doxa-maps-spa/doxa-maps-spa.vue, built via defineCustomElement like every other rung (its shell layout is library/layouts/app-wrapper-material/). app-wrapper-material/index.html is now only the dev-time plugin-gallery scaffold, not the shipped shell |
Composite kinds are always declared in <profile>.meta.json ({ "type": …, "label": … }, either spelling —
see TYPE_ALIASES in internal/plugin-gallery/discover.mjs) — the build never guesses them from source (tag-scanning
produced false positives). Discovery is one pure module,
internal/plugin-gallery/discover.mjs; bun run dev renders its result as the plugin gallery (the same
generated page the build writes to public/index.html — see "Plugin gallery" under Build scripts: a list grouped by
bundle, every map deep-linkable at #/<bundle>/<profile>[/<instance>]), and bun run smoke:gallery proves every
element actually boots inside it.
Directory tree
doxa-maps/
├── library/ ← SHARED LIBRARY (alias @map, Apache-2.0)
│ ├── index.js ← thin barrel; prefer subpath imports for tree-shaking
│ ├── ProfileLoader.vue ← the ONE component that reads profile-config & loads a screen
│ ├── components/ ← map UI: legends/, map-controls/, poster/, dashboards/, drawers
│ ├── composables/ ← useMapInstance, useMapData, useMapLayers, … (+ legends/, poster/)
│ ├── api/ ← DataSourceManager (csv | api | rest-api) + sources.json
│ ├── colors/ ← one color strategy per file, auto-registered by _registry.js
│ ├── constants/ ← never-change values: mapDefaults.js, zoom.js, poster*.js
│ ├── i18n/locales/ ← 11 base locales: ar de en es fr hi it pt ro ru zh
│ ├── stores/ ← Pinia: dataStore, mapStore, uiStore (per-instance)
│ └── utils/ ← pure helpers (geoUtils, clustering)
│
├── plugin-designs/ ← ALL bundles live here (each subfolder = 1 bundle)
│ ├── <bundle>/
│ │ ├── index.js ← bundle ENTRY — globs ./*.vue, registers the web component
│ │ ├── index.html ← Vite html entry = the dev STAGING page
│ │ ├── <profile>.vue ← 1..N FLAT profile screens (no profiles/ subfolder)
│ │ └── src/ ← everything else the map needs (its own colors/, api/, …)
│ └── template-bundle/ ← minimal copy-me reference bundle
│ (folders starting with _ or . are SKIPPED by the build)
│
├── internal/ ← the bundler's own build scripts — never edited by map builders
├── layouts/ ← the app shell: mounts several bundles as one single-page app
├── capacitor/ ← native projects (android/, ios/)
├── public/ ← THE SERVED WEB-ROOT, committed:
│ ├── index.html the app shell
│ ├── doxa-maps/<bundle>/ built bundles + manifest.json + staging HTML
│ └── assets/ posters & data the maps fetch at runtime
├── vite.config.js ← multi-entry: discovers plugin-designs/<name>/ folders
└── vite.doxa-maps-build-config.json ← output tree name + copy destinations + token settings
The profile-config runtime contract
A bundle exposes ONE attribute, profile-config — a JSON string parsed by
ProfileLoader.vue at runtime. It is the entire wiring between host page and map, so the same
built bundle re-points to different data/colors/language with no rebuild.
<doxa-map profile-config='{
"profile": "doxa-simple-map", // which flat .vue screen to render (required)
"tk": "pk.eyJ...", // Mapbox token (required; host-supplied, never baked)
"instanceId": "my-map", // optional; isolates window events between embeds
"dataSource": "pray-tools", // or { "type":"rest-api","endpoint":"https://…" }
"colorSet": "default",
"locale": "en",
"provider": "mapbox", // map engine: "mapbox" (default) | "maplibre" (preview);
// also flippable per page via ?provider= or window.MAP_PROVIDER
"assetsBase": "/assets", // where tables/, boundaries/, previews/ are served (see below)
"shareUrl": "https://…", // optional; the WHOLE url the Share button links/embeds (beats everything)
"embedBaseUrl": "https://…/doxa-maps", // optional; served tree root the Share button joins embed pages to
"tabs": [ /* parameterized screens read this */ ]
}'></doxa-map>
-
assetsBase— data-driven profiles (connectivity-map, resources-map, cities-map, the poster walls and data tables) fetch${assetsBase}/tables/*.jsonand${assetsBase}/boundaries/*.geojsonbefore they create their map. Unset, it defaults to./assetsrelative to the host page; if the host doesn't serve that path the fetch gets an HTML fallback,JSON.parsethrows, and the map is never created (no console error, only the profile's status line). The generated pages and the shell set it for you (../../../assetsor the absoluteassetsBaseUrlfrom the build config); any other host must point it at a copy ofpublic/assets/. -
Share ladder (
library/utils/embedBase.js) — what the Share button links and iframes:shareUrl(a whole url, e.g. the plugin gallery's own deep link) > the profile's tree-relative embed page (<bundle>/<profile>/index.html, theembedPathprop orshareEmbedPath) joined to an embed base —embedBaseUrl>window.MAP_EMBED_BASE_URL> the root the built bundle derives from its own script url (two segments above<bundle>/<bundle>.js, so a dropped tree works on any host/CDN with no config) > the page's own location. Never hardcode/js/doxa-maps/…in a profile. -
profilemust match a flat.vuefilename in the bundle. Mismatch → an inline "Profile not found" error box listing the available names. -
tkis supplied at runtime so the token is never compiled in (dev uses.env'sVITE_MAPBOX_TOKEN). -
instanceIdscopes cross-instance window events so multiple embeds never interfere. -
dataSourceis handed toDataSourceManager(csv|api|rest-api). -
ProfileLoaderprovide()s to descendants:mapboxToken,dataSource(both computed refs —unrefbefore use),colorSet,profileConfig,instanceId,lang, and the instance-scoped Pinia storesuiStore/mapStore/dataStore. Descendants inject; never calluseXxxStore()directly (it would cross-bleed between embeds).
Each bundle registers a shadow-DOM web component via defineCustomElement, with its own
ShadowRoot, styles, Pinia, and i18n. customElements.define() is one-shot per tag name, so
each bundle registers a distinct tag.
Build scripts & output
| Script | What it does |
|---|---|
bun run dev |
Vite dev server + HMR (default http://localhost:5173, or the next free port — read the terminal). / is the plugin gallery of every element — internal/generate-gallery.mjs in mode dev over internal/plugin-gallery/{discover,dev-plugin}.mjs; every bundle is also served at its own html path. Dev-only routes: /__plugin-gallery/manifest.json (the manifest-shaped model the page fetches), /__plugin-gallery/registry.json (the discovery registry the smoke tools read), /assets/* (served from public/assets/, else plugin-designs/*/data/). Writes nothing to public/ — no HMR across that boundary. |
bun run smoke:gallery |
Opens every element of the plugin gallery by deep link in Firefox and proves it boots: element defined, shadow DOM populated, no error card, a WebGL canvas for map profiles (checked through nested shadow roots), no HTML-for-JSON data responses. --json <file> writes the evidence; exit 1 on any failure. The pre-handoff gate. |
bun run smoke:share |
The share round-trip gate (Firefox): opens a host page (--plugin-gallery = every canvas element of the dev plugin gallery; --url <page> --tag <element> = the website or any CDN page), clicks Share, reads the URL, fetches it, reopens it in a fresh page and asserts the SAME custom element mounts with a WebGL canvas. --json <file>; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). |
bun run check:public-hygiene [<dir>] [--strict] |
Proves a served folder is fit to publish: scans for local paths, internal folder names, dev-server URLs, JWT-shaped tokens, .env/token files, source maps, documentation and staging/preview pages, and prints an inventory (bundles, leaf pages, assets by folder). Under --strict any root entry other than index.html, assets/ and doxa-maps/ is a finding ("outside the drop-in contract"). Exit 1 on a finding. Run it on the website's drop-in before any public push. |
bun run sync:dropin <dest> [--from-config | --only a,b] [--prune] / bun run deploy:dev |
Syncs the built public/ into a host checkout (e.g. the website's public/embed/doxa-maps-build/). The drop-in contract lives in ONE place — vite.doxa-maps-build-config.json → dropin.include, exactly three root entries: index.html (the built plugin gallery — the root page generated by internal/generate-gallery.mjs, listing every bundle/profile/instance and mounting the chosen one), assets/ (data) and doxa-maps/ (the bundle folders + manifest.json). --from-config copies only those, --prune removes everything else in the destination, so a host carries exactly the served web-root and nothing else at its root. dropin.exclude (same file) lists built files inside the allowlist that no page fetches — the full people-groups CSV, the tables only internal scripts read, the table index; they stay in public/ for the scripts, are skipped by --from-config (counted as "excluded (not shipped)") and removed from the destination if a copy is there. Without an allowlist it is a plain full mirror. Never copies credentials, docs or staging pages; --protect a,b keeps host-owned files; --dry-run first; prints a copied/unchanged/deleted/pruned/excluded summary and the resulting top-level tree. deploy:dev = build → sync --from-config --prune into the website (DOXA_DROPIN_DEST overrides the path) — the only sanctioned way a map changes on the site; then commit in both repos. |
bun run build |
clean → one vite build per plugin-designs/*/ folder → tree → check:drop → check:parse → verify → gallery (writes public/index.html, the built plugin gallery, from the manifest). |
bun run build:bundle |
Builds ONE bundle (needs BUNDLE=<name>). |
bun run build:watch |
Rebuilds into public/ on every change (integration loop). |
bun run clean |
Wipes + recreates the public/<name>/ output tree. |
bun run tree |
Writes manifest.json + self-contained staging HTML, then copies the tree to every build-config paths destination. |
bun run verify |
Data-integrity + shipped-bundle + network-policy guards (fast, no browser). |
bun run verify:full |
+ rendered posters and live apps in Firefox. |
Plugin gallery. One generator, two modes. internal/generate-gallery.mjs exports
renderGallery(model, { mode }), a pure function from a manifest-shaped model (bundles → profiles →
instances, with scriptUrl / iframeUrl / embed / integrity) to the whole page: the list, the stage,
#/<bundle>/<profile>[/<instance>] deep links, Open page, Copy embed, the token ladder, lazy engine load,
the phone layout and the baked fallback. Mode build (bun run gallery) renders
public/doxa-maps/manifest.json into public/index.html — the root page of the served web-root and,
through the drop-in, the website's /doxa-maps page. Mode dev is what bun run dev serves at /: the
same page over galleryModel(discoverSpaRegistry()) (internal/plugin-gallery/dev-plugin.mjs), differing
only in the loader (it imports /plugin-designs/<bundle>/index.js instead of adding the built IIFE), the live
model route (/__plugin-gallery/manifest.json), the /assets/* dev route and the composability ladder's
"?" popovers in the header — kept out of the built page to keep it lean. Nothing about the gallery is
written twice.
How a build works. vite.config.js is multi-entry by discovery: at build time it
registers every plugin-designs/ subfolder that (a) doesn't start with _/. and (b) has
both index.html and index.js. One vite build builds exactly one bundle, selected
by BUNDLE=<name>, so the output is a self-contained IIFE (inlineDynamicImports: true).
bun run build loops every folder. Each bundle only bundles what its screens import, so sizes
differ (simple ≈ 420 KB, research ≈ 1.3 MB).
Where output goes. One bundle folder → one file: public/doxa-maps/<bundle>/<bundle>.js.
CSS is injected by JS (cssCodeSplit: false), so there is no separate .css. The tree root
name comes from vite.doxa-maps-build-config.json name (default doxa-maps).
emptyOutDir: false keeps sibling bundles intact when you rebuild one.
Shadow-style comments are stripped from production bundles. Component CSS is injected
from useShadowStyles(\…`)template literals that ship verbatim, sointernal/vite-plugin-strip-shadow-css-comments.mjs(build only,enforce: 'pre') removes the /* … */blocks inside those literals and collapses the blank lines they leave; a literal with${…}` is left untouched with a warning, and dev mode keeps every comment.
What a built bundle folder contains. Beyond the IIFE, bun run tree generates an
index.html per bundle (staging/showcase), per profile, and per instance —
each profile/instance page is a standalone, directly linkable/iframe-able map — plus
public/doxa-maps/manifest.json with ready-made embed.iframe/embed.script snippets for
every profile. Full design: architecture/embeddability.md.
Preset instances. A <profile>.instances.json beside a parameterized profile
({ profile, apiBaseUrl?, instances: [{ id, title?, props }] }) makes the build emit one
standalone page per instance at <bundle>/<id>/index.html. Recipe:
how-to.md.
Bun is required. bun.lock is the committed lockfile; bun x vite runs project-local
Vite. npm run … resolves the same scripts but Bun must be on PATH.
vite.doxa-maps-build-config.json fields
Edit this one file, then bun run build.
| Field | What it does |
|---|---|
name |
Output folder name (default doxa-maps). |
paths |
Extra folders to copy the whole built tree into. Point at a host repo's public/ → that host serves the maps and becomes the CDN. Empty (the default) ⇒ written to public/<name>/ only, since public/ is already the committed web-root. |
embedBaseUrl |
The public URL the tree is served from, used to generate absolute, paste-anywhere embed snippets. Empty ⇒ relative URLs (position-independent; keeps the drop-portability gate green; required for Capacitor, which loads from https://localhost). |
tokenUrl |
Endpoint each generated page fetches its Mapbox token from — a URL, not a secret. Set absolute (https://…/api/maps/token) so a CDN folder and the mobile app both work; endpoint must send CORS for the embedding origins. window.MAP_TOKEN_URL overrides at runtime. |
token |
Opt-in literal token baked into every page. Leave empty — prefer tokenUrl. |
assetsBaseUrl |
Where the shared asset tree (previews, posters, boundaries) is served from. Empty ⇒ a relative path per page depth (self-contained under one web-root). Set absolute for partner embeds and mobile, where there is no shared web-root to climb to. |
Token resolution ladder: per-map tk prop > ?tk= > baked token > window.MAP_TOKEN_URL > tokenUrl > same-origin /api/maps/token.
Data sources
Maps read data through @map/api/ driven by library/api/sources.json. Three source types:
| Type | What | Points at |
|---|---|---|
rest-api |
versioned REST endpoint (default) | the live people-group API; base URL resolves at runtime (window.MAP_APP_API_URL → VITE_API_BASE_URL → same-origin) |
csv |
a local CSV parsed at load | the Joshua Project dataset (+ photo fallbacks) |
api |
a single JSON URL | any plain JSON endpoint |
Country/region polygons are not a sources.json connection — they render from Mapbox's built-in
country-boundaries-v1 tileset plus a lazy-loaded WAGF-regions GeoJSON.
Adding or changing a source? Full contract + checklist: architecture/data-sources.md.
Colors
Reused by more than one map → shared library/colors/. Only your map uses it → your bundle's
src/colors/ (merges over the shared set, that bundle only). Filename derives the mode key
(language-family.js → languageFamily).
Writing or overriding a strategy? Full contract + checklist: architecture/color-strategies.md · two homes in depth: ../contributing/COLORS.md.
Importing from @map
Everything under library/ is importable via the @map/… alias (vite.config.js maps @map
→ library/). The import path mirrors the file path: a file at library/<sub>/<file> imports
as @map/<sub>/<file>. Examples:
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useMapData } from '@map/composables/useMapData.js'
import langFamilyByIso from '@map/data/langFamilyByIso.json'
Prefer subpath imports over the thin @map barrel (which exports MAP_CORE_VERSION only) so
tree-shaking works. The per-file catalog in architecture/file-map.md is the
authoritative menu — every library/ file listed is importable this way. A generated one-line import index can also be produced with
node internal/generate-library-index.mjs.
Going deeper — the architecture folder
Simple lookups live here; the full contracts, catalogs, and wiring detail live in
architecture/:
| Doc | What it holds |
|---|---|
| file-map.md | every file, one line each, with the gotcha each carries |
| data-sources.md | the api/ seam: connection contract + checklist |
| color-strategies.md | the strategy contract + legend wiring |
| components.md | shared component catalog + conventions |
| i18n.md | the locale contract + add-a-language checklist |
| embeddability.md | generated per-map pages + manifest — embeddable by default |
| app-wrappers.md | the SPA shell: nav.js + PROFILES contracts |
| poster-generation.md | the poster/preview generator pipeline |
| patterns-and-provenance.md | the two multi-map patterns + provenance |
The Semantic Tree Legend component (a reusable hierarchical legend) is documented in file-map.md.
Quick reference — "I want to…"
| I want to… | Do this |
|---|---|
| Add a new map | copy plugin-designs/template-bundle/ → rename folder + tag in index.js |
| Add a screen to an existing bundle | drop a .vue flat in that bundle's folder, request it via profile-config.profile |
| Fix a legend / control for ALL maps | edit it once in library/ (@map); rebuild bundles |
| Add a color strategy | new file in @map/colors/ (shared) or the bundle's src/colors/ (map-private) |
| Build everything | bun run build → public/doxa-maps/<bundle>/<bundle>.js |
| Build just one | BUNDLE=<name> bun run build:bundle |
| Preview in isolation | bun run dev → http://localhost:5173/plugin-designs/<name>/index.html |
| See a change on the served site | bun run build, then refresh (NOT dev) |
| Repoint the API without a rebuild | set window.MAP_APP_API_URL before the bundle loads |
| Repoint the Mapbox token | pass tk in profile-config, or set window.MAP_TOKEN_URL / build-config tokenUrl |