doxa-maps/docs/explanation.md
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

14 KiB

Explanation — why doxa-maps is shaped this way

The reasoning behind the architecture. For how to build a map, see the tutorial and how-to.md; for lookups, reference.md.


One shared library, many bundles, one build

There are exactly two kinds of source:

Path Role
The shared library library/ (alias @map) One Apache-2.0 template of building blocks: components, composables, color strategies, API connectors, stores, i18n. Reusable across every map.
The bundles app-profiles/<bundle>/ One folder per shippable <script src> bundle. Each holds the .vue screens that compose @map blocks into a specific map.

The mental model: one .vue file = one map = one profile. The library provides the blocks; your profile composes them. The only place a map's identity lives is its app-profiles/<bundle>/ folder — never fork library/ per map. Fix a legend or control once in @map and every map gets it on the next build.

bun run build runs one Vite build per bundle folder, emitting one self-contained IIFE to public/doxa-maps/<bundle>/<bundle>.js, plus a manifest.json scraped from each profile's optional @description header. (Build pipeline details and settings: reference.md.)


The three zones, and why they're separated

Zone What it is Why it exists
library/ Shared runtime code, reused via the @map alias. One copy of every shared block. Edited only by people who know the whole system — a change here reaches every map.
app-profiles/ One folder per bundle — the map-maker's sandbox. A programmer building one map gets one folder that is entirely theirs; nothing inside can affect another map.
internal/ The bundler's own machinery: the tree/clean/index scripts, portability + parse checks, staging generators. Named after Go's enforced internal/ convention: code consumers must not touch. It's the machine, not the product.

The zones separate three audiences: map-makers (app-profiles/), library authors (library/), and the build system (internal/).

The bundle-root contract. A bundle's root holds only the profile .vue file(s) plus the scanner-contract files (index.js, index.html, *.instances.json, README.md). Everything else lives in that bundle's src/. Why: the scanner discovers profiles by flat-listing the root, so every root-level .vue is a profile and needs no configuration — a profile moved into src/ silently vanishes from discovery.

The _-prefix convention. Files starting with _ are engine code (e.g. colors/_registry.js, api/_registry.js). The auto-discovery globs mechanically skip _-prefixed names, so an engine file can sit next to the content files it discovers without discovering itself. Reading rule: see _, don't edit.

Why each library folder has its name. api/ = the data layer, a declarative connector registry (sources.json) plus the engine that reads it. colors/ = one engine plus shared strategy files; a map's own colors live in its src/colors/ — same folder name on both sides on purpose. components/ + composables/ are grouped by feature (legends/, map-controls/, poster/), the Vue folder-by-feature convention, chosen because it makes micro-frontend extraction easy — you can lift a whole feature out in one move, which is exactly what this bundler does. composables/ (reactive Composition-API logic) is kept separate from utils/ (pure functions, no reactivity). constants/, data/, stores/, i18n/ are the standard homes for values, static lookups, Pinia state, and locales.


The reusability rule — library vs profile folder

The single most important call for keeping the library clean as maps grow into the hundreds:

A component belongs in… when…
the shared library (library/) it is used by 2+ profiles AND is truly reusable — parameterized so each profile passes its own config.
the profile folder (app-profiles/<bundle>/) it is used by one profile, is experimental / not-yet-proven reusable, or hard-codes one map's behaviour.

Default to the profile folder. A component only earns promotion into library/ once a second profile genuinely needs it and it has been generalized. "It might be reusable someday" is not enough. The rule solves two problems at once:

  1. No orphan library components. Because a profile's custom components live in its own folder, excluding or .gitignore-ing a profile removes its components with it. You never publish shared blocks that no visible map consumes.
  2. Profiles don't break each other. A component is allowed in library/ only if it is truly parameterized, so every dependent profile passes its own options and keeps working. If a component cannot be generalized without one map fighting another for it, fork it into the profile folder — a clean profile-specific copy beats a brittle forced share. A staging environment that renders every map at once is how you verify a shared-component change didn't regress the others.

The same rule governs dashboards: a dashboard is just an app-profile that composes the shared library/components/dashboards/ blocks and passes a config object (facets, columns, charts, KPIs, data source) instead of writing panels by hand. "Build a dashboard" is the same job as "build a map."

Publishing a subset. Keep one local kit with every profile; to ship only some maps, .gitignore the profiles a target shouldn't receive. Their folders (and their profile-specific components) drop out cleanly while the genuinely-shared library blocks remain. Git worktrees make this ergonomic — one worktree per branch/target.


The no-rebuild contract

profile-config is an HTML attribute read at runtime in the browser, so nothing about a map's identity is fixed at build time. The same built bundle re-points to a different screen, token, data source, color set, or language just by changing the JSON on the page. This is what lets one bundle serve many sites and teams: build once, configure per embed. ProfileLoader.vue reads the attribute in setup, and the data layer resolves its source at runtime (from sources.json's activeSource unless profile-config.dataSource overrides it). (Field-by-field contract: reference.md.)


One build, three ways to ship

The same web-component bundles serve three shapes — all from one bun run build (mobile is a separate opt-in step, never part of the build):

  1. Embed — drop one bundle on any partner site (iframe, or <script> + custom element).
  2. SPA — the layouts/ wrapper composes many bundles into one single-page app: a nav/tab shell that lazy-mounts each map on demand (WebGL-context-safe — only the active map holds a live GL context), with a nested pane-wrapper for showing several maps in one view. The shell build step installs the chosen wrapper (layouts/<wrapper>/, default app-wrapper-material) as the public/ entry point. Optional — skip it and you just have standalone bundles.
  3. App — Capacitor wraps public/ (the SPA + bundles) into an Android/iOS app. Opt-in (apk:debug), reusing the same build.

Same bundles, same build: embed one, compose many into a SPA, or wrap the whole thing as an app.

Why a standalone JAMstack bundler (not a framework)

The thesis: the bundle compiles to a web-standard custom element, carries its own framework, takes its backend and data locations as runtime config, and serves static assets from one exposed directory. So the same artifact runs inside any host — React, Vue, Nuxt, WordPress, a Capacitor WebView, a bare directory on nginx — without a rebuild, and any backend can be swapped without touching the client.

Decoupled is drop-in — the doxa-base plugin pattern. Because the artifact is a standard custom element, it is tied to no host (fully decoupled) and a zero-modification drop-in into any host that speaks the custom-element contract — including doxa-base's plugin tier, where a built bundle loads as a plugin with no changes. Decoupling and drop-in are the same property, not a trade-off. That's why doxa-maps is the reference pattern for every doxa-base plugin: build a standard bundle, and it runs standalone and drops into doxa-base.

Five load-bearing decisions:

  1. Ship a custom element, not a framework component. A Vue component needs a Vue host; a custom element is a browser primitive every host consumes natively. The partner integration story is "add two lines to your page," not "adopt our framework."
  2. Single-file IIFE — one script tag, no loader. inlineDynamicImports: true; no module graph for the host to resolve. The cost: because everything imported is inlined, one careless import of a data file inlines it — a single static import once took two bundles to 2.0 MB and 2.9 MB. The discipline is fetch() at runtime for anything data-shaped, enforced by a size ceiling in the verify guards.
  3. Config-only, fail-closed backend. There is deliberately no default host; an unconfigured build makes zero requests rather than phoning home. This is what makes "swap the backend" real, and it's a privacy property — a fork can't leak users back to the original author.
  4. public/ is the one served surface. The web root points at public/; app-profiles/, internal/, and library/ have no URL that maps to them. That's stronger than a deny-list — it's the absence of a route. Apps read data by URL at runtime, so the public folder is not bundled.
  5. Data location is a runtime input (assetsBase). One knob, two deployment shapes: default ./assets on the web; point it at a remote host for Android/iOS so large datasets stay off the device and out of the app package.

What it costs — stated honestly:

  • You own the framework upgrade. Vue lives inside each bundle; a security patch means rebuilding and redistributing every bundle.
  • Duplicated runtime across bundles. Each bundle carries its own Vue. Accepted deliberately — isolation beats shared-runtime efficiency when partners control the page.
  • The inline trap is one careless import away (decision 2). Guarded, not prevented.
  • No SSR / SEO for map content. These render client-side.
  • Not fully self-contained today. Maps load Mapbox from a CDN (a few requests per page to api.mapbox.com, which sees the viewer's IP). A truly airgapped deployment renders data tables but not the map. This is an open design decision (a MapLibre + self-hosted-tiles path is proven in the repo for token-free rendering), not a solved problem.
  • "Many APIs" cuts both ways. JAMstack makes adding backends trivial, but every backend added is another host that sees your users — which is why the verify guards count third-party hosts and fail the build.

Every architectural claim is backed by the verify guards, each proven to fail on corrupted input before being trusted: bun run verify (data integrity, shipped bundles, network policy) and bun run verify:full (+ rendered posters and live apps in a browser). A check that has only ever printed green proves nothing.


How panes talk — props down, composed events up

For reusable elements to communicate, the web-standard idiom is properties down, events up, and it is a documented contract (MDN Using custom elements, MDN Event.composed, web.dev Custom element best practices):

  • Down: attributes for primitives, properties for rich data (objects/arrays can't survive an attribute without serialization).
  • Up: a CustomEvent dispatched by the element, listened to by whoever owns it. A component never reaches into a sibling.

The load-bearing constraint: composed. Each pane is a separate custom element with its own shadow root. A CustomEvent dispatched inside pane A does not escape its shadow boundary unless created with both composed: true and bubbles: true. A plain new CustomEvent('selection-changed', { detail }) — the obvious thing to write — dies inside the pane silently: no error, the listener is correctly attached, and nothing arrives. It's the same family as the shadow-style trap (useShadowStyles early-returns in light DOM) — a shadow-DOM behaviour a light-DOM test cannot reproduce.

The decision. Adopt props-down / composed-events-up as the cross-element contract. The host owns the wiring: it hears doxa:selection-changed from pane A and sets a property on pane B; the panes never reference each other. A shared Pinia store (keyed by instanceId) may stay as an internal cache, but never as the contract — a store makes two panes work together, while props/events make each pane work anywhere, which is the property this architecture exists to sell. Event names are namespaced (doxa:) so a host embedding several vendors' elements can't collide.

// PANE A — announce. BOTH flags are REQUIRED or this never leaves the shadow root.
this.dispatchEvent(new CustomEvent('doxa:selection-changed', {
  detail: { iso3, people_group },
  bubbles: true,     // required for propagation at all
  composed: true,    // required to cross the shadow boundary
}))

// HOST — own the wiring. Rich data is a PROPERTY (objects can't ride an attribute).
paneA.addEventListener('doxa:selection-changed', (e) => { paneB.selection = e.detail })

React interop — verified, with a required workaround. Measured against the real shipped bundle in React 18.3.1 and 19.2.8: both directions work, but a rich object passed as a JSX prop is silently stringified to "[object Object]" on both versions. React 19's property support only applies to accessors present before connectedCallback, and this bundle's are installed in it, so React falls back to the attribute path. A React host must therefore use a ref and assign imperatively — ref.current.selection = obj — and bind doxa: events with addEventListener via that ref (the portable form across React versions). Config still rides a JSON string attribute (profile-config), which round-trips fine.

Acceptance checks that follow from this: every cross-pane event must carry both bubbles and composed (an explicit review check, because nothing throws); test the event crossing the shadow boundary in the bundle, not in a standalone mount; and no pane may hold a reference to another pane.