doxa-maps/documentation/how-to.md
Doxa Maps a8db328ad5 Docs: the series is the UPG100 List
The README and documentation call the series what the product now calls it: "UPG100 List", one term,
no hyphen. 28 prose mentions of "UPG-100", "UPG 100 List" and "List of 100 UPG's" change across the
README (19 lines, including the print section and the layout tree, whose columns stay aligned), four
documentation files and the delegation diagram's plugin label (PNG re-rendered at 2x). Paths, ids and
identifiers stay as they are (my-upg-100-list, upg-100-mfe, the image file names); the one code value
named, SERIES_NAME's default, follows series.json's seriesName "UPG100 List".
2026-10-02 21:41:03 +00:00

25 KiB
Raw Blame History

How-to — task recipes

Short recipes for specific tasks. New to the project? Read the tutorial first, then use this page to look up a task. For contracts and field tables, see reference.md.

The three zones govern everything: reuse library/ (@map), build in plugin-designs/<your-map>/, never touch internal/.

Pick your task: embed on a site · link/iframe directly · add a map · preset variants · imitate a profile · change colors · choose a CDN · build a SPA · mobile app · swap engine · API + token · agent guardrail · troubleshooting


Embed a map on a partner site

A built bundle is a plain <script src> plus a custom element — no framework, no build step on the partner's end. A partner page loads the Mapbox peer, then the bundle, then drops in the element with its own profile-config.

<!-- Mapbox peer (once per page, before the bundle) -->
<link  href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>

<!-- The bundle (relative path = host serves it; absolute https = external CDN) -->
<script src="/dx-maps/my-map/my-map.js"></script>

<!-- The map. `profile` = your .vue filename. `tk` = the Mapbox token. -->
<my-map profile-config='{"profile":"my-map","tk":"pk.eyJ..."}'></my-map>

profile-config is read at runtime, so the same bundle re-points at different data, colors, or language with no rebuild — just change the JSON on the page. Without a valid tk the bundle loads but the basemap stays blank (see Set the API + Mapbox token below).


You don't have to use the script tag. Every profile also builds its own standalone page — a bare, full-screen map at:

https://<your-cdn>/dx-maps/<bundle>/<profile>/index.html

Link it, share it, or iframe it (perfect for CMSs and tools that only accept iframes, like Start.me or Notion):

<iframe src="https://<your-cdn>/dx-maps/doxa-research-map/research-map/index.html"
        width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>

You never write these snippets by hand — public/dx-maps/manifest.json lists a ready-made embed.iframe and embed.script snippet for every profile, and the staging pages show them copy-paste ready. Design details: architecture/embeddability.md.

After each release: iframe embeds update automatically. A script embed with an integrity hash pins the exact bytes, so re-copy its snippet from manifest.json after each release — once the bundle changes, the old hash stops matching and the browser refuses to run the script.


Add a new map profile

The fastest path is copy template-bundle and rename it. A bundle is one folder under plugin-designs/; its name becomes the bundle name and the output filename. Don't prefix it with _ or . — those are skipped by the build.

  1. Copy the template.
    cp -r plugin-designs/template-bundle plugin-designs/my-new-map
    
  2. Rename the element tag. In plugin-designs/my-new-map/index.js, change the customElements.define('…', …) tag to your map's tag. The entry must glob the bundle's flat profiles and hand them to the loader — it already does this; keep the shape:
    const profileModules = import.meta.glob('./*.vue')   // FLAT — matches only this bundle's screens
    // …app.provide('profileModules', profileModules) inside configureApp…
    customElements.define('my-new-map', buildElement())
    
  3. Keep index.html. Vite needs it to discover the bundle; it's also the dev staging page. At minimum it ends with <script type="module" src="./index.js"></script>.
  4. Write your profile screen my-new-map.vue, flat in the folder (no profiles/ subfolder — the glob is ./*.vue). The filename (without .vue) is the profile name the host requests via profile-config.profile. Compose it from @map blocks and read host inputs with inject (mapboxToken, dataSource, profileConfig, instanceId, and the instance-scoped Pinia stores) — never prop-drill, never call useXxxStore() directly.
  5. Pick reusable pieces from the @map library index in reference.md. Import each with the @map/… alias; do not copy their code into your folder.
  6. Build and verify.
    BUNDLE=my-new-map bun run build:bundle   # builds just yours → public/dx-maps/my-new-map/my-new-map.js
    bun run verify                           # data guards (fast, no browser)
    
    A green build is not a working map — the build proves the bundle compiles; the guards prove the data is right and the app renders it. Confirm the pane actually paints; a profile that fails to mount looks identical to one that mounted and rendered nothing.

Two ways to ship several maps from one bundle: (A) Parameterized — one .vue, mounted many times with different profile-config (same profile, different data/colors). (B) Multi-profile — several .vue files in one folder, selected by the profile field. Both ship one script tag. Pick A when maps differ only by config, B when they're genuinely different map types that share the same slice of the library.


Ship N preset variants of one screen (instances)

One parameterized profile can emit many standalone embed pages — no extra .vue. Drop a <profile>.instances.json next to the profile:

{
  "profile": "doxa-simple-map",
  "apiBaseUrl": "https://…",            // optional
  "instances": [
    { "id": "prayer", "title": "Prayer Map",
      "props": { "instanceId": "embed-prayer", "dataSource": "pray-tools",
                 "tabs": [{ "id": "prayer", "colorStrategy": "prayer" }] } }
  ]
}

bun run build then emits a page per instance at dx-maps/<bundle>/<id>/index.html — each directly linkable/iframe-able with its preset props baked into the page (the base profile still accepts live props). Instances split one profile into many render targets; tabs nest many views into one element — different tools.


Imitate an existing profile (don't re-derive it)

You have a map that works and want a second one that behaves almost the same. Copy the profile and change parameters in the copy — do not build it from scratch, and do not add a mode flag to the original.

The rule: PROFILES duplicate. COMPONENTS get promoted and parameterized. A profile is cheap — a duplicated profile that reads top-to-bottom beats one profile with a flag threaded through 40 branches. A component used by two profiles is different: promote it to library/components/ and give it props. Duplicating a profile duplicates configuration (the part that's supposed to differ); duplicating a component duplicates behaviour (the part that shouldn't).

Steps:

  1. Find the profile you're imitating — including nested ones. A .vue profile can contain another full profile inside a tab. Search for the behaviour you want, not the app name; if you copy the wrong level you inherit the wrong thing.
  2. Copy the folder, then the profile file. profile is the filename, not the folder. One folder = one bundle = one element; each flat .vue inside is one profile.
  3. Change ONLY named parameters. If you're typing new logic, you've stopped imitating — go find the parameter in the original that already does what you're about to write. Count parameter sites by diffing the two files, not by memory.

    Trap: onMounted runs last and overwrites your default. If the source initializes a value in its mount handler, setting it at declaration is not enough — one constant must drive both the initial ref and the onMounted path.

  4. Wire it into the shell registry (library/layouts/app-wrapper-material/index.html, the PROFILES object). A nav entry with no registry entry renders a silent placeholder. Take src from the folder, tag from customElements.define in index.js, profile from the .vue filename — never from a guess.
  5. Load data per tab with fetch, never import. The single-file IIFE inlines every import, so a static import of a data table inlines it into the bundle (one such import took two bundles to 2.0 MB and 2.9 MB; per-tab fetch brought them to 308 KB and 647 KB).
  6. Verify it mounts. Open it and confirm the pane paints — don't infer it from a clean build.

One-line test: are you changing a named constant, or writing logic? Constant → you're imitating. Logic → stop.


Change colors

Colors are values, not logic — you edit values, the library owns the mechanism. Which of two places a color lives follows one rule: reused by more than one map → shared library; only your map uses it → your profile.

Path A — a shared color (affects every map that uses it). Edit library/colors/<name>.js (e.g. religion.js) and change a hex in the PALETTE. The library auto-discovers every <name>.js in that folder, so there's no registration step. Be deliberate — this changes every map using that mode.

Path B — a color private to your map. Create plugin-designs/<your-map>/src/colors/<name>.js (copy template-bundle/src/colors/example-mode.js as a starter). Your bundle's index.js runs registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true })) at startup, merging your local files over the shared set. The filename derives the mode key (language-family.js → languageFamily):

  • Same key as a shared strategy → your file overrides it (for your map only).
  • A new key → your file adds a private strategy (only your map sees it).

Because each bundle is its own self-contained IIFE with its own copy of the registry, a local strategy can never leak into another map. The required export default shape (the strategy contract) is in architecture/color-strategies.md; religion.js is the canonical example.


Where do the maps get served from?

Your built maps are just files. To show them on other websites, the files need a public home on the internet that any site can pull from — that's what a "CDN" is. You choose the home, and there are options:

  • Your own site (doxa-life). Put the built maps in doxa-life's public/ folder — its Nuxt site serves them, so doxa-life is the CDN. No third party.
  • GitHub. Push the built maps to a public GitHub repo and serve them free through GitHub Pages or jsDelivr — GitHub is the CDN.
  • A Git host you run (for example Forgejo). Push the maps there and serve them from it, with no extra service to set up.
  • Anywhere else. Any static host works — Cloudflare, an S3-style bucket, etc.

Pick one home and every partner site embeds the same map from it — change the map once and it updates everywhere.

How it's wired (one file: vite.doxa-maps-build-config.json; full field table in reference.md): bun run build always writes to public/dx-maps/<bundle>/. Set paths to also copy the tree into another host's folder (e.g. doxa-life's public/). Set embedBaseUrl to the public URL of your chosen home — it generates absolute, paste-anywhere embed snippets (leave it empty for relative URLs that work wherever the folder is dropped).

dev never updates a built public/. Run bun run build (or BUNDLE=<name> bun run build:bundle) and refresh to see a change on the served site. bun run dev is for iterating on a map in isolation.


Hosting the dx-maps folder: caching, compression, CORS

Why it matters. On a cold load a map's layers draw about 10–17 s after the page opens (measured on the UPG100 List page). The bundle and the map engine take under 2 s of that; the rest is basemap tiles and data files such as the country-boundary file (1,866 KB raw, 597 KB gzipped). Compression cuts every visit; caching means a returning reader doesn't download the same files again.

Every file in dx-maps/ has a fixed name today. Nothing is content-hashed, so a new release overwrites files in place: bundles (<bundle>/<bundle>.js), their pages (index.html) and manifest.json change whenever the build changes them. Images, fonts and data files change rarely, but under the same names.

Files Cache-Control Why
everything in dx-maps/, as it ships today public, max-age=86400, stale-while-revalidate=604800, with an ETag a revisit revalidates cheaply and never re-downloads unchanged bytes, and a release shows up within a day
content-hashed names, if you add them (e.g. world-adm0.3f9a1c2e.geojson) public, max-age=31536000, immutable a changed file gets a new name, so a year-long cache is always safe

Never send immutable for a fixed name: an update would stay invisible for a year.

Compress: serve br or gzip, and give data files a JSON content type (application/json, or application/geo+json for .geojson) so CDNs compress them. Many servers send .geojson as application/octet-stream, which CDNs don't compress.

CORS for script embeds: every embed.script snippet loads the bundle with an integrity hash and crossorigin="anonymous". For that to work on another site, the host must send Access-Control-Allow-Origin: *; without it the browser can't check the hash across origins and refuses the script. iframe embeds need no CORS.

On Cloudflare Pages or Netlify, a _headers file at the folder's root sets the CORS and cache headers:

/*
  Access-Control-Allow-Origin: *
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800

Check the content type your host gives .geojson files (curl -sI <url>) before relying on compression.


Compose maps into a SPA (the app shell)

Several maps + one layout = a single-page app, served from the same public/ tree.

  1. Pick a wrapper in library/layouts/ (app-wrapper-material is the full shell: sidebar, lazy-loading, i18n).
  2. Register your map in two places inside the wrapper:
    • nav.js — add a sidebar entry ({ kind, id, label, icon, target }; target controls desktop/mobile packaging).
    • the PROFILES object in its index.html — tell the shell what to mount: { tag: '<your-element>', src: './dx-maps/<bundle>/<bundle>.js', views: ['map'] }.
  3. Install the shell: bun run shell — copies the wrapper to public/index.html, so the site root IS the SPA and the bundles sit beneath it. (Also required for the mobile app.)

    Note: the build's own front page — the Plugin Studio — is public/dx-maps/index.html, inside the output folder, so installing the shell at public/index.html does not replace it. The next bun run build leaves the shell's copy alone and regenerates the output folder.

  4. Build & serve: bun run build, then serve public/ — the shell lazy-loads each bundle the first time you open its nav entry.

Contracts and gotchas (PROFILES shape, tag-vs-bundle names, --no-token): architecture/app-wrappers.md.


Build the Android / iOS app (optional)

Mobile is separate from bun run build. The default build produces only the dx-maps web folder — the embeddable maps (and the SPA, if configured). It never touches Capacitor. The Android/iOS app is an opt-in step that reuses that web build: Capacitor's webDir points at public/dx-maps/ (appId: life.doxa.maps, appName: "Doxa Maps"), so cap sync wraps the already-built folder into a native app. You never need to build mobile to ship the web maps.

The quickest app is one plugin, with no rebuild: bun run phone:deep-research installs Deep Research as an app. The whole tutorial: how to build mobile apps from plugins.

Android (project is committed; needs Android Studio + a JDK):

bun run apk:debug     # build web → cap sync → verify → assemble a debug APK (the full build asks for BUILD_ALLOW=1)
bun run apk:install   # adb install onto a connected device/emulator

Step by step instead: bun run build → bun run cap:sync → open deployment/capacitor/android in Android Studio.

iOS (scaffold once on a Mac): ./node_modules/.bin/cap add ios, then bun run build && ./node_modules/.bin/cap sync ios, and open deployment/capacitor/ios in Xcode. Same webDir: public.

Invoke the local bin, not bunx. bunx is not on PATH in every environment (it exits 127, command not found), which is what broke cap:sync → apk:debug → phone at step one. The Capacitor CLI is installed locally, so ./node_modules/.bin/cap always resolves. Elsewhere this repo uses the spaced bun x form for the same reason.

cap sync copies public/ into the native project's assets. A wrong webDir gives a blank white screen at launch (empty assets), not an error — bun run cap:verify guards against it.


Swap the map engine (Mapbox ↔ MapLibre)

The engine is a runtime parameter — same bundle, no rebuild. Resolution order (first defined wins): profile-config.provider → ?provider= in the page URL → window.MAP_PROVIDER → default auto.

auto is MapLibre: keyless, with no request to Mapbox. Since 2026-09-28 a token no longer chooses the engine. Mapbox is an explicit provider: "mapbox", and the token then authenticates it (a tk in the profile-config, window.MAP_TOKEN, ?tk=, a token pasted once in the tree, the studio's ⚙ Settings, or a configured token URL). An explicit provider always wins.

<!-- per embed -->
<my-map profile-config='{"profile":"my-map","provider":"maplibre"}'></my-map>

<!-- or flip any generated page from the URL -->
https://<cdn>/dx-maps/<bundle>/<profile>/index.html?provider=maplibre

maplibre runs token-free on the OpenFreeMap style (the right GL script/CSS is injected automatically); the geocoder search bar keeps working in both engines. Its place results come from Mapbox geocoding, on either engine, and only with a token: without one no request goes to Mapbox, the box says "place search needs a Mapbox token", and people-group, language, religion and country search (local data) keep working. mapbox behaves exactly as before, and an embed that passes a token keeps getting Mapbox under auto.

Status: on main. Generated pages honor provider:'maplibre' end-to-end — the page loads the MapLibre peer instead of Mapbox and skips the token ladder entirely (no account, no key). Living demo: demo-map/demo-map/maplibre/index.html (a preconfigured instance); the embed smoke (bun run smoke:embeds) verifies it mounts with a live canvas.


Show a different people-group list

Every map shows DOXA's people groups unless you ask for another list — same bundle, no rebuild. Resolution order (first defined wins): profile-config.list → ?list= in the page URL → window.MAP_LIST → default doxa.

list also accepts shows
doxa 2000, project-2000 Project 2000: DOXA's people groups
unreached 7000, project-7000 Project 7000: unreached people groups
frontier 4700, project-4700 Project 4700: frontier people groups
frontier-500k 700, project-700 Project 700: frontier people groups of 500,000 or more
unengaged — Unengaged unreached people groups
<!-- per embed -->
<my-map profile-config='{"profile":"my-map","list":"frontier-500k"}'></my-map>

<!-- or flip any generated page from the URL -->
https://<cdn>/dx-maps/<bundle>/<profile>/index.html?list=700

What you get today: the DOXA groups that belong to that list. Rows and prayer data come from the DOXA API, so a list's groups that DOXA does not carry are not shown yet. An unknown list falls back to doxa with a console warning. If a list's membership file cannot load, the map shows no groups rather than DOXA's whole list under the wrong name. Composites pass list to every child. The definitions live in library/lists/lists.json; bun run lists regenerates the membership files. Full contract: reference-universal-props.md, section 5.


Hide the Plugin Studio header

The Studio header is where you set the universal props (language, engine, token). Hide header collapses it so the plugin takes the full height; a Show header button stays in the top corner to bring it back. The choice is remembered in this browser.


Embed under a strict Content-Security-Policy

No bundle needs 'unsafe-eval' (translations compile without eval; bun run check:no-eval), and every third-party script is pinned with an integrity hash (library/constants/cdn.js, bun run check:cdn-pins). A page embedding the maps needs, in addition to its own sources:

Directive Add Why
script-src https://cdn.jsdelivr.net (and https://api.mapbox.com for Mapbox) the map engine and the search geocoder
style-src 'unsafe-inline', the same CDN hosts each plugin styles its own shadow root with a <style> element
worker-src blob: the map engines run their tile workers from a blob URL
connect-src the tile/style hosts you use (https://tiles.openfreemap.org for MapLibre, https://*.mapbox.com for Mapbox), your data API tiles, styles, glyphs, data
connect-src (Bible Posters) https://bible.helloao.org the public-domain Bible text, fetched per book at render time (no text is bundled)
img-src data: blob: and the same tile hosts sprites and raster tiles

Set the API + Mapbox token

Both the data backend and the Mapbox token are runtime inputs, never baked into a bundle.

Data (the API). Maps read data through @map/api/, driven by library/api/sources.json, which lists every connection by id, picks one activeSource, and maps each source's raw field names onto the system field names components expect (fieldMappings). Three connection types: rest-api (the default — the live people-group API), csv (a local file), api (a single JSON URL). To add or change a source, edit sources.json — components stay source-agnostic. The base URL resolves at runtime from window.MAP_APP_API_URL → VITE_API_BASE_URL → '' (same-origin), so a host repoints the API with no rebuild.

The Mapbox token. Supply it per embed as tk in profile-config, or point window.MAP_TOKEN_URL (or the build-config tokenUrl) at an endpoint that returns it. The token stays remote — only the URL ships. Set tokenUrl absolute (https://…/api/maps/token) so a CDN-hosted folder and the mobile app both work; the endpoint must send CORS headers permitting the embedding origins. In dev, the token comes from .env's VITE_MAPBOX_TOKEN. A Mapbox pk. token is designed to be client-visible — protect it with URL restrictions at Mapbox, not by hiding it.


Give your agent the guardrail

If you point an AI agent at this repo, paste this first, before it reads any code:

This is the doxa-maps bundler. Three zones: library/ (@map) = reusable shared blocks — reuse via the @map/… alias, never edit. plugin-designs/<map>/ = the sandbox for one map — build everything here. internal/ = the build machine — never touch it. To build a map: copy plugin-designs/template-bundle/, read its README, pick reusable pieces from documentation/reference.md, then build the map's own code inside plugin-designs/<the-map>/. Change colors by editing values in that map's src/colors/ folder — not logic in library/. Then run bun run build. If a change seems to belong in library/, stop and ask — that's an expert move that can break every other map.

Optional power-up: Mapbox publishes official agent skills (cartography, style patterns, performance, integration) that make an agent much better at map work — /plugin marketplace add mapbox/mapbox-agent-skills then /plugin install mapbox, or npx skills add mapbox/mapbox-agent-skills.


Troubleshooting

Symptom Cause Fix
Changed a .vue, served site shows old map You ran dev, or didn't rebuild bun run build, then refresh
Set BUNDLE=<name> for build Ran vite build with no bundle selected bun run build (all) or BUNDLE=<name> bun run build:bundle
New bundle doesn't appear Missing index.html or index.js in the folder Vite discovers a bundle only when both exist
Old bundle still loads after a build Browser cached the static .js Hard-refresh / disable cache in devtools
Map is blank on a partner page Mapbox peer not loaded, or no token Load mapbox-gl.js + geocoder before the bundle; pass a valid tk
Profile not found error box profile doesn't match a flat .vue filename in the bundle Fix the profile field to match a filename