Merge main and regenerate the served tree
Some checks are pending
build-gate / build (push) Waiting to run

Generated index.html conflicted; resolved by regenerating rather than hand-merging.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 19:07:41 +00:00
commit b105fcebcf
86 changed files with 43313 additions and 19249 deletions

4
.gitignore vendored
View file

@ -130,3 +130,7 @@ _qa-cycle/
# measurements and the one exception (previews/, which has no data/ source
# and must stay tracked).
# The tracked source for shipped assets is data/<name>.
# Private datasets never live in this repo (see DOXA_PRIVATE_DIR in
# library/scripts/research/build-tabulator-tables.mjs). Guard against re-creation.
data-private/

View file

@ -14,31 +14,40 @@
**Ship a commit, then write two sentences. Not the reverse.** The artifact is the diff. A long,
honest report attached to no commit is the failure mode this file exists to end.
**Push it.** A local commit is invisible — the Driver runs a laptop watcher against the Forgejo repo
and sees changes in his browser *from that repo*. Work that stops at `git commit` is not delivered.
**Land it on `main`.** A local commit is invisible — the Driver watches **`main`** on the Forgejo
repo, so work that stops at `git commit`, or at a side branch, is not delivered. Work in your OWN
worktree off `main`, then land on `main` through the **merge queue** (`main-capture`). Pushes to `main`
without it are REJECTED by the server: several agents share one Forgejo account, so the lock is the
only thing that serializes them.
```sh
TOKEN="$(pass show vivarium/forgejo-certified-sandbox | head -1)"
git -c http.postBuffer=524288000 \
push "https://oauth2:${TOKEN}@git.nuxtbase.app/doxa-life/doxa-maps.git" HEAD:doxa-maps-plugin-studio \
2>&1 | sed "s/${TOKEN}/[REDACTED]/g"
unset TOKEN
# Credentials: route git through the host's askpass helper. NEVER put a token in a URL, a
# command line, or .git/config, and never let git prompt (a prompt hangs a headless agent).
export GIT_ASKPASS="$(dirname "$(command -v main-capture)")/forgejo-askpass"
export FORGEJO_PASS_ENTRY=vivarium/forgejo-certified-sandbox GIT_TERMINAL_PROMPT=0
unset SSH_ASKPASS DISPLAY
git fetch origin && git rebase origin/main # you resolve every conflict, not main
if main-capture capture --wait; then # --wait retries with backoff while held;
# release ONLY a lock you actually took
main-capture scan && git push origin HEAD:main
main-capture release # ...but ALWAYS release one you took
fi
```
Never echo the token; always filter output through `sed`; never let it reach `.git/config`.
**Release is not owner-checked: it deletes whoever's lock is there.** So the two rules pull in
opposite directions and the `if` satisfies both. The older one-liner `capture && scan && push ;
release` runs release even when capture FAILED because someone else held the lock — and freed
THEIR lock mid-landing (measured 2026-09-24). Never pipe `capture` into `tail`/`grep` either: the
`if` would then test the filter's exit code, not the capture's.
★ **PUSH TO `main` TOO, once your change is verified.** The Driver's laptop dev-server watches
**`main`**, not the working branch — so work pushed only to `doxa-maps-plugin-studio` is invisible
to him no matter how well it was tested. That is how 287 commits accumulated without him seeing a
single one.
Grade the push by its EXIT CODE (`${PIPESTATUS[0]}` if you pipe it), never by grepping its output:
the word `main` also appears in the rejection line, and a grep once reported 8 rejected pushes as
landed. After pushing, `git fetch origin` and confirm your commit is on `origin/main`.
```sh
git -c http.postBuffer=524288000 \
push "https://oauth2:${TOKEN}@git.nuxtbase.app/doxa-life/doxa-maps.git" HEAD:main \
2>&1 | sed "s/${TOKEN}/[REDACTED]/g"
```
Only push to `main` what you have actually verified — it is the branch a human is looking at.
Only push to `main` what you have actually verified — it is the branch a human is looking at. The
old `doxa-maps-plugin-studio` branch is retired; do not push to it.
## Do not run the full build

429
README.md
View file

@ -1,4 +1,4 @@
# doxa-maps — one build, maps that embed everywhere
# dx-maps — one build, maps that embed everywhere
> **The map cruncher.** Many hand-built maps go in; one shared library, one repo and one build
> come out — and every map fans out to five deployment targets from the same artifacts.
@ -18,24 +18,99 @@
> (proven) (proven) (built) (built) (built)
> ```
**A Vite bundler that turns each map into a self-contained web component.** Write a map in
`plugin-designs/<your-map>/`, run one build, and get a folder you can drop on any host — iframe it,
script-embed it, wrap it as a mobile app, or open every map at once as one SPA.
This README has three parts, in the order you need them:
1. **[The 5 plugin capabilities](#1-the-5-plugin-capabilities)** — what you can build, simplest to richest.
2. **[The 5 deployment methods](#2-the-5-deployment-methods)** — where a plugin can run.
3. **[The Plugin Studio](#3-the-plugin-studio)** — ★ where you *work*. Start here if you are new.
script-embed it, wrap it as a mobile app, or open every map at once as one app.
---
## 1. The 5 plugin capabilities — most advanced first
## What dx-maps is for — and how each objective is met
Maps multiply a prayer movement only when they can live on *every* partner's site, not just one.
dx-maps exists to make that cheap. It set out to meet seven objectives; each one is met by something
you can open or run, not by a claim.
| # | Objective | How it is met | See it / check it |
|---|-----------|---------------|-------------------|
| 1 | **One build → one folder.** Seven maps that were seven codebases become one repo and one build. | `bun run build` writes a single drop-anywhere tree, `public/doxa-maps/`, with a `manifest.json` describing every bundle. | `bun run check:drop` · `bun run check:settled` |
| 2 | **Every map is embeddable.** Any map, on any site, with a copy-paste snippet. | Each bundle is one custom element; `manifest.json` ships an iframe snippet and an SRI-pinned `<script>` snippet for it. | [Deployment methods](#3-the-5-deployment-methods) · `bun run check:embed-targets` |
| 3 | **Every map is interoperable.** Maps talk to each other through props and events. | Composites wire their children with props, events and shared layouts — no copy-pasted code. | `bun run verify:interop` |
| 4 | **One app nests every map — and each stays independently embeddable.** | The **Research Gallery** is a single capability-5 plugin that nests all seven production maps. | [The Research Gallery](#1-the-research-gallery--five-capabilities-in-one-plugin) · `bun run check:composite-reg` |
| 5 | **5 capabilities × 5 deployment methods.** Anything you build, at any tier, ships everywhere. | A plugin's tier is read from its files; every tier builds to the same tree, and the tree feeds all five methods. | [Capabilities](#2-the-5-plugin-capabilities--most-advanced-first) · `bun run verify:5-caps` |
| 6 | **A clean shared/private boundary.** Reuse without breaking each other. | `library/` holds shared, parameterized pieces (generated index); anything map-specific stays in its own `plugin-designs/` folder. | [reference-library-index.md](./documentation/reference-library-index.md) · `bun run check:library-index` |
| 7 | **Many builders, one green `main`.** The repo is built to be worked by a swarm of agents at once. | One agent per plugin, one worktree each, and every change landed through a merge queue. | [Build it with an agent swarm](#5-build-it-with-an-agent-swarm) |
Plus one practical objective: **a token-free option.** Mapbox is the default engine; setting a single
prop, `provider: "maplibre"`, swaps in MapLibre so a partner running many embeds needs no Mapbox key.
`bun run check:provider-switch` proves the switch really changes the engine.
## Contents
1. **[The Research Gallery](#1-the-research-gallery--five-capabilities-in-one-plugin)** — the showcase: five capabilities in one plugin.
2. **[The 5 plugin capabilities](#2-the-5-plugin-capabilities--most-advanced-first)** — what you can build, most advanced first.
3. **[The 5 deployment methods](#3-the-5-deployment-methods)** — where a plugin can run.
4. **[The Plugin Studio](#4-the-plugin-studio)** — ★ where you *work*, and every command to get it running. Start here if you are new.
5. **[Build it with an agent swarm](#5-build-it-with-an-agent-swarm)** — how to run many agents on one laptop without collisions.
---
## 1. The Research Gallery — five capabilities in one plugin
The Research Gallery (`plugin-designs/doxa-maps-spa/`) is the product a user opens. It is **one
capability-5 plugin** — one custom element, `<doxa-maps-spa>` — and every tab inside it is a
**real, independently embeddable plugin** from a lower tier. Nothing is copied into it and nothing is
framed: each tab loads its own bundle and mounts that bundle's own custom element.
![Capability 5 — the Research Gallery app shell (doxa-maps-spa)](documentation/readme-images/cap5-doxa-maps-spa.png)
This is how five different capabilities compose into one:
```text
Research Gallery <doxa-maps-spa> capability 5 — app shell
│
├─ Dashboards
│ ├─ Big Picture dashboard-plus-grid capability 4 — composite
│ │ ├─ research dashboard research-dashboard capability 3 — monolithic
│ │ └─ people-groups grid people-groups-grid capability 2 — prop-table
│ ├─ Simple Research research-map-plus-grid capability 4 — composite
│ │ ├─ research map doxa-research-map capability 3 — monolithic
│ │ └─ people-groups grid people-groups-grid capability 2 — prop-table
│ └─ Regions regions-mfe capability 4 — composite
│ ├─ regions list regions-list capability 1 — single
│ └─ region dashboards region-dashboards capability 1 — single
├─ Prayer Mobilization
│ ├─ UPG-100 List upg-100-mfe capability 4 — composite
│ │ ├─ list upg-100-list capability 1 — single
│ │ └─ poster wall poster-wall capability 1 — single
│ └─ Prayer Progress doxa-simple-map (prayer) capability 2 — prop-table preset
├─ Gospel Research
│ ├─ Cities with Internet connectivity-map capability 1 — single
│ └─ Gospel Resources resources-map capability 1 — single
└─ Experimental (folder) every other bundle, discovered automatically from manifest.json
```
What that buys you:
- **Seven production maps, one URL.** The three sections above are the seven curated tabs.
- **Every tab is still a standalone embed.** Want only the UPG-100 list on a partner site? Embed
`upg-100-mfe` — or just its `upg-100-list` leaf. Taking the whole app apart costs nothing.
- **Proving the whole proves the parts.** When the Research Gallery was embedded cross-origin on a
foreign host page (see [deployment](#3-the-5-deployment-methods)), every nested plugin rode along.
- **New plugins appear on their own.** Any bundle not placed in a named section lands in
*Experimental*, read from the built `manifest.json`. A new folder in `plugin-designs/` shows up with
zero edits to the shell.
The tab order and grouping live in one place, `NAV_GROUPS` in
[`doxa-maps-spa.vue`](./plugin-designs/doxa-maps-spa/doxa-maps-spa.vue); `bun run check:spa-tab-order`
guards it.
---
## 2. The 5 plugin capabilities — most advanced first
A "capability" is **how much a plugin nests** — not how hard it is to use. The tour below starts from
the most advanced thing this repo produces (a whole app of apps) and walks down to the simplest
(one element), because the top of the ladder is the point: every rung below it stays independently
the most advanced thing this repo produces (an app of apps) and walks down to the simplest (one
element), because the top of the ladder is the point: every rung below it stays independently
embeddable. Every screenshot is a real bundle from this repo's build output.
| # | Capability | What it is | Example bundle |
@ -52,20 +127,15 @@ embeddable. Every screenshot is a real bundle from this repo's build output.
> **[documentation/reference-capability-exemplars.md](./documentation/reference-capability-exemplars.md)**
> — cite that file rather than remembering.
### Capability 5 — nested-SPA / app shell
Every plugin in one deep-linkable page. Two cap-5 apps ship, and the difference matters when you embed
one: **`doxa-maps-spa` is what an end user gets; `plugin-studio` is what a developer works in.**
*`doxa-maps-spa` — the curated product SPA*, with tabs grouped by audience.
![Capability 5 — the curated product SPA (doxa-maps-spa)](documentation/readme-images/cap5-doxa-maps-spa.png)
one: **`doxa-maps-spa` (the Research Gallery, [section 1](#1-the-research-gallery--five-capabilities-in-one-plugin))
is what an end user gets; `plugin-studio` is what a developer works in.**
*`plugin-studio` — the developer studio*, grouping every built plugin by capability (5 → 1). It is
auto-generated: the build discovers every folder in `plugin-designs/`, so a new plugin appears in the
studio without any wiring. This is [section 3](#3-the-plugin-studio).
studio without any wiring. This is [section 4](#4-the-plugin-studio).
![Capability 5 — the developer studio (plugin-studio)](documentation/readme-images/cap5-plugin-studio.png)
@ -104,7 +174,7 @@ group on one map.
---
## 2. The 5 deployment methods
## 3. The 5 deployment methods
Ordered easiest to hardest. **Status is stated honestly** — "proven" means it has been run and seen, and
nothing below is upgraded past what the files support.
@ -119,12 +189,12 @@ nothing below is upgraded past what the files support.
**What "not shot" means:** the code exists and its contract test passes, but nobody has captured it
running on a real device or install. Each is blocked by a missing local toolchain, not by missing code —
Android SDK for method 2, a live WordPress for method 4, and a GUI (this box is headless) for method 5.
Android SDK for method 2, a live WordPress for method 4, and a GUI for method 5.
Methods **1 and 3 are the two proven-live paths.** If you need a map on a site today, use one of those.
Both were **certified cross-origin on 2026-09-24** — the entire capability-5 app, embedded on a
foreign host page, every inner plugin riding along (prove-the-whole = prove-the-parts):
Both were **certified cross-origin on 2026-09-24** — the entire Research Gallery, embedded on a
foreign host page, every inner plugin riding along (prove the whole = prove the parts):
| Method 1 — iframe on a host site | Method 3 — JS embed on a host site |
|----------------------------------|-------------------------------------|
@ -139,8 +209,8 @@ The full per-method command matrix, with every prerequisite, is in
### The Mapbox token (read this before you file a bug)
The maps use Mapbox, which needs a `pk.` token. **The token works differently in dev than in the built
tree**, and diagnosing one from the other is the most common way to lose an hour.
The maps use Mapbox by default, which needs a `pk.` token. **The token works differently in dev than in
the built tree**, and diagnosing one from the other is the most common way to lose an hour.
| Where | How it gets a token |
|---|---|
@ -153,8 +223,7 @@ So on a built leaf page you typically need:
http://localhost:4173/doxa-maps/demo-map/demo-map/index.html?tk=pk.your_token_here
```
To use your own token in dev, create a `.env` — it is **gitignored** (`.gitignore` lines 21-24), so it
never reaches the repo:
To use your own token in dev, create a `.env` — it is **gitignored**, so it never reaches the repo:
```bash
printf 'VITE_MAPBOX_TOKEN=%s\n' 'pk.your_token_here' > .env
@ -163,39 +232,16 @@ printf 'VITE_MAPBOX_TOKEN=%s\n' 'pk.your_token_here' > .env
**If a basemap is an empty grey void while the pins still draw, that is the token failing** — not a
broken app.
**Why this is genuinely awkward:** the five deployment methods each handle the token differently, and a
JAMstack embed has nowhere safe to keep a secret. The answer is not to hide the token but to make hiding
it unnecessary: a public `pk.` token is restricted to specific URLs in the Mapbox dashboard. **MapLibre
is being pursued to remove the per-embed token requirement entirely — that work is IN PROGRESS, not
done** (`maplibre-gl` is a dependency and the page generator has a MapLibre path, but no plugin-design
runs on it yet).
**Or skip the token entirely.** A public `pk.` token can be restricted to specific URLs in the Mapbox
dashboard, but a JAMstack embed still has nowhere to hide one. So the engine is one prop: set
`provider: "maplibre"` on an instance (or add `?provider=maplibre` to a leaf page) and the map renders
on MapLibre with no Mapbox key. `plugin-designs/doxa-research-map/research-map.instances.json` ships a
ready-made MapLibre instance. The trade-off is deliberate: under MapLibre the place-search bar is
hidden, because the geocoder would still bill the Mapbox key the instance exists to avoid.
---
## The repo at a glance
```text
doxa-maps/
├── plugin-designs/ YOUR maps live here — one folder per plugin (the FREE zone).
│ ├── doxa-maps-spa/ the capability-5 app (nests everything below)
│ ├── dashboard-plus-grid/ capability-4 composite: dashboard + people-groups grid
│ ├── my-upg-100-list/ capability-4: UPG-100 lists + poster wall + map
│ ├── doxa-simple-map/ capability-2: one root.vue, three preset maps
│ └── template-bundle/ copy this to start a new plugin
├── library/ the SHARED zone (@map) — reusable, parameterized only.
│ │ A change here reaches EVERY bundle: high bar, see
│ │ documentation/contributing/.
│ ├── components/ map controls, legends, search, poster, detail panels
│ ├── composables/ useMapInstance, shadow styles, print-in-place, retry
│ └── styles/ colors/ data/ style registry, palettes, lookup tables
├── data/ the public datasets the maps ship with
├── documentation/ tutorial · how-to · reference · explanation + architecture
├── internal/ the build generator + the verification gates
└── public/doxa-maps/ the BUILD OUTPUT — the drop-anywhere tree (bundles,
leaf pages, manifest.json). What gets served + embedded.
```
## 3. The Plugin Studio
## 4. The Plugin Studio
★ **This is where you work — and the keyword is INTEROPERABILITY.**
@ -206,27 +252,22 @@ lowest**, so you can see the whole catalogue instead of hunting through folders.
That sorting is the point. Once you can see every plugin side by side, you can tell your agent **which
ones to group together** — any number of them — and compose them into **higher-capability plugins**
using **props, events and layouts**. Group cap-1 and cap-2 elements and you get a cap-4 nested-element
SPA; group those and you get a cap-5 app shell. That is what makes the studio the right place to build
map plugins, rather than a gallery you merely look at.
**Why this matters.** Without a working studio, a developer *sees the fruit but not the deeper
strategy* — they get the maps, but they cannot reproduce or extend them. The studio is what turns a set
of finished maps into something you can build on.
SPA; group those and you get a cap-5 app shell. That is exactly how the Research Gallery was built.
### Get it running
Four steps from nothing to every map in one place.
Four steps from nothing to every map in one place. You need **git**, **[Bun](https://bun.sh)** and
**Node ≥ 18**.
```bash
# 1. Clone (the repo root IS the studio — there is no subfolder to cd into).
# This host is PRIVATE: it will prompt for your Forgejo username + token.
git clone --branch doxa-maps-plugin-studio https://git.nuxtbase.app/doxa-life/doxa-maps.git
# 1. Clone (the repo root IS the studio — there is no subfolder to cd into)
git clone https://git.nuxtbase.app/doxa-life/doxa-maps.git
cd doxa-maps
# 2. Install dependencies (installs 206 packages; needs Node >= 18)
# 2. Install dependencies
bun install --frozen-lockfile
# 3. Optional — your own Mapbox token for dev (see section 2)
# 3. Optional — your own Mapbox token for dev (see section 3)
printf 'VITE_MAPBOX_TOKEN=%s\n' 'pk.your_token_here' > .env
# 4. Open the Plugin Studio
@ -239,16 +280,29 @@ spot. (If port 5173 is busy Vite picks another one and prints it; read the line
> **You do not need to build first.** The build output in `public/` is committed, so a fresh clone has
> plugins to show the moment the dev server starts.
> **If step 1 fails with `remote: Unauthorized`,** the clone is not broken — `git.nuxtbase.app` is a
> private self-hosted Forgejo and anonymous cloning is refused. Ask for an account, then use your
> username plus a Forgejo **access token** (not your password) when git prompts.
Two things worth knowing:
- **`/` is the studio; `/__gallery` is the old staging gallery**, kept but demoted to an explicit URL.
- **Edits hot-reload.** The studio mounts the real `plugin-studio` custom element through its own
`index.js` — the same path the built bundle takes — so what you see is the real element, not a preview.
### Every command you need
| Command | What it does |
|---|---|
| `bun run dev` | The Plugin Studio with hot reload, at http://localhost:5173 |
| `bun x vite --port 5299 --strictPort` | A dev server on your own port (see the swarm rules below) |
| `BUNDLE=<name> bun x vite build` | Build **one** bundle, without cleaning anyone else's output |
| `BUILD_ALLOW=1 bun run build` | The full production tree in `public/doxa-maps/` (~4 minutes, takes a lock) |
| `bun run serve` | Serve the built tree at http://localhost:4173, the way production does |
| `bun run check:settled` | Exit 0 = the built tree is safe to measure or publish right now |
| `bun run gates:pre` / `bun run gates:post` | The static and post-build verification gates |
| `bun run verify:5-caps` · `bun run verify:interop` | Prove the five capabilities, and cap-4 interoperability, in a real browser |
| `bun run shots` | Regenerate the README screenshots |
The full list — every deployment command with its prerequisites — is in
[documentation/README-SECTION-3-cli-commands.md](./documentation/README-SECTION-3-cli-commands.md).
### Work with your agent
The studio's companion is your coding agent. The loop:
@ -270,150 +324,148 @@ Two shared layouts do the composing, both in `library/layouts/`:
- **`StackLayout`** — the opposite: elements stacked top-to-bottom at natural height, so the page
scrolls through them.
### Working fast: use the dev server, not the build
A full `bun run build` rebuilds every bundle and takes minutes. **You do not need it to see a
change.** The dev server hot-reloads:
```bash
bun run dev # http://localhost:5173
bun x vite --port 5299 --strictPort # any other port, when 5173 is taken
```
★ **If more than one of you is working in this repo at once, EVERY dev server must name its own
port, and must pass `--strictPort`.** Vite's default behaviour on a busy port is to silently move to
the next one — 5173 becomes 5174 — and five gates (`check-facet-changes-map`,
`plugin-gallery-smoke`, `smoke-studio`, `generate-gallery`, `check-scrim-covers-viewport`) have
`5173` written into them. A second server without `--strictPort` therefore leaves those gates
talking to somebody else's server, or to none, and reporting a confident wrong answer with no error.
With `--strictPort` the duplicate exits non-zero instead of drifting — verified: port taken → exit 1.
Reach for the full build only when you need the deployable tree, an SRI-consistent leaf page, or a
manifest. To check one bundle's compiled output without touching anyone else's:
```bash
BUNDLE=<name> bun x vite build # this bundle only; does NOT clean the tree
```
### Everything must reach Forgejo
**A local commit is invisible.** The Driver runs a laptop watcher against
`git.nuxtbase.app/doxa-life/doxa-maps` and sees changes in the browser from THAT repo — so work that
stops at `git commit` has not been delivered, no matter how well it was verified locally.
Push every increment:
```bash
TOKEN="$(pass show vivarium/forgejo-certified-sandbox | head -1)"
git -c http.postBuffer=524288000 \
push "https://oauth2:${TOKEN}@git.nuxtbase.app/doxa-life/doxa-maps.git" HEAD:doxa-maps-plugin-studio \
2>&1 | sed "s/${TOKEN}/[REDACTED]/g"
unset TOKEN
```
Never write the credential into `.git/config` (see the clone note above), always filter output
through `sed`, and prefer many small pushed commits over one large local one.
★ After pushing to an INLINE url, `git log @{u}..` still reports the commits as unpushed, because git
only moves a remote-tracking ref when it pushes to a NAMED remote. Sync it so the next reader is not
misled:
```bash
git fetch "https://oauth2:${TOKEN}@git.nuxtbase.app/doxa-life/doxa-maps.git" \
'refs/heads/doxa-maps-plugin-studio:refs/remotes/origin/doxa-maps-plugin-studio'
```
### The production build
The dev server is for working. When you want the deployable tree:
The dev server is for working; **you do not need a build to see a change.** When you want the
deployable tree:
```bash
bun run build # ~4 minutes; writes public/doxa-maps/
BUILD_ALLOW=1 bun run build # ~4 minutes; writes public/doxa-maps/
bun run serve # serve the BUILT tree at http://localhost:4173
```
`bun run build` takes a **lock**: if another build is already running it fails fast rather than
**`BUILD_ALLOW=1` is required on purpose.** Without it the build refuses (exit 75) and prints cheaper
alternatives, because a full build wipes the output tree that every other agent in a swarm is
reading. On your own machine, with no one else working, set it.
The build also takes a **lock**: if another build is already running it fails fast rather than
corrupting the shared output. Set `BUILD_LOCK_WAIT=600` to queue behind it instead.
### Before you MEASURE the built tree — `bun run check:settled`
```bash
bun run check:settled # exit 0 = safe to measure/publish; exit 1 = do not
```
**The lock protects a second WRITER. It does not protect a READER.** Every build begins with
`bun run clean`, which `rm -rf`s `public/doxa-maps/`, so while a build runs the tree is transiently
near-empty — and anything reading it then gets a confident, wrong answer: "that bundle is missing",
"the dashboard tab does not exist", "461 files were deleted". Those are not hypotheticals; each has
happened here, and one nearly produced a bug report against a correct change.
So run this gate before any browser check, any publish, and any claim about what the tree contains.
It reports `builders`, `waiters`, `churn(45s)`, `dirs`, `leaves` and `quietFor`, and **grade it by
exit code** — like every gate here, it prints teaching prose after its verdict, so `| tail -1` will
mislead you.
★ **Do not substitute a `pgrep` check for this.** A process list samples an INSTANT; the lock knows
the QUEUE. Between one queued build finishing and the next acquiring the lock there is a real window
where zero build processes exist and the tree is nonetheless about to be wiped — and a long quiet
period makes that window look *more* convincing, not less, because the queue is idle precisely
because it is between runs. Measured 2026-09-23: `pgrep` reported 0 builders while the gate reported
`builders=1 waiters=1`, and `ps` confirmed a build had started 17 seconds earlier.
If you are one of several agents working this repo at once, the stronger rule is simply: **do not run
`bun run build` at all.** Edit source, and let one owner run the single authoritative build. To
sanity-check one bundle without touching anyone else's output, use
`BUNDLE=<name> bun x vite build` — it does not clean the whole tree.
**The lock protects a second WRITER. It does not protect a READER.** Every build begins by deleting
`public/doxa-maps/`, so while a build runs the tree is transiently near-empty — and anything reading
it then gets a confident, wrong answer ("that bundle is missing"). Run `bun run check:settled` before
any browser check, any publish, and any claim about what the tree contains, and **grade it by exit
code**: like every gate here, it prints explanatory prose after its verdict, so `| tail -1` will
mislead you. Do not substitute a `pgrep` check — a process list samples an instant, while the lock
knows the queue.
---
## The delegation model — one build, many agents, one main
## 5. Build it with an agent swarm
This repo is designed for **many builders working in parallel without stepping on each
other**. The model is a set of concentric rings:
This repo is designed for **many builders working in parallel without stepping on each other** — and
the builders can be coding agents on your own laptop. It is **trunk-based development with a merge
queue**, the same model as GitHub's merge queue, GitLab's merge trains and Rust's bors, arranged as
concentric rings:
![The delegation model: concentric rings from main outward to the shepherd](documentation/readme-images/delegation-model.png)
![The delegation model: agents in their own worktrees send changes through one merge queue into main](documentation/readme-images/delegation-model.png)
- **`main`** — the one shared branch. Everything converges here.
- **Capture ring** — before pushing to `main`, a builder must *capture* it: **one builder at
a time, first-come-first-serve.** Whoever holds it **resolves all merge conflicts**, lands
their work, then releases. The next builder waits and retries. No two pushes race.
- **Plugin Studio** — one repo, every plugin visible in one place, so each builder can see
what already exists and reuse it.
- **Plugins** — each is built in the shared bundler, so its shared, parameterized components
become available to every other plugin (private components stay inside each plugin's own
folder).
- **Agents** — **one builder per plugin.** Each owns their plugin end to end. A cluster of
experimental plugins is owned by a single roaming builder.
- **Shepherd** — one builder keeps everyone honoring the model: plugins built in the studio,
shared components truly shared, and every push serialized through the capture ring.
- **`main` — the trunk.** The one shared branch. Everything converges here, and it is always green.
- **Merge queue.** Changes land **one at a time, in order.** Each change is rebased onto the latest
`main` — its author resolves any conflicts — and lands only if the gates pass. A change that fails
leaves the queue; the next one goes. Nothing writes to `main` except the queue.
- **Plugin Studio.** One repo and one shared `library/`, with every plugin visible in one place, so
each builder can see what already exists and reuse it instead of rebuilding it.
- **Agents.** **One agent per plugin, each in its own git worktree.** Each owns its plugin end to end;
its private components stay inside its own `plugin-designs/` folder.
- **Maintainer.** One reviewer — human or agent — keeps everyone honoring the model: plugins built in
the studio, shared components genuinely shared, and every change going through the queue.
The effect is cooperative: each builder races to land the most working features, but the only
way to score is to pass through the capture ring one at a time and **leave `main` green.**
The effect is cooperative: every agent races to land working features, but the only way to land is
through the queue, one change at a time, **leaving `main` green.**
The diagram's source is [`documentation/readme-images/src/delegation-model.svg`](./documentation/readme-images/src/delegation-model.svg).
### Spin one up on your laptop
**1. One worktree per agent**, as sibling folders, so no two agents share a working directory:
```bash
git worktree add -b regions/work ../dx-maps-regions main
git worktree add -b upg100/work ../dx-maps-upg100 main
git worktree add -b maintainer/work ../dx-maps-maintainer main
git worktree list
```
**2. One agent per worktree, each owning one plugin.** Start your coding agent in each folder and give
it a single `plugin-designs/<name>/` folder to own. The maintainer gets no plugin; it reviews what
lands and pushes back when shared code is duplicated or a plugin reaches into another's folder.
**3. One dev server per agent, each on its own port, always with `--strictPort`:**
```bash
bun install --frozen-lockfile # once per worktree
bun x vite --port 5301 --strictPort # regions agent
bun x vite --port 5302 --strictPort # upg100 agent
```
Without `--strictPort`, Vite silently moves a busy port to the next one — and several gates have
`5173` written into them, so a drifted server leaves those gates reporting a confident wrong answer
about somebody else's page. With `--strictPort` the duplicate exits non-zero instead.
**4. One owner for the full build.** Agents edit source and check their own bundle with
`BUNDLE=<name> bun x vite build`. Only one agent runs `BUILD_ALLOW=1 bun run build`, because every full build wipes
the shared output tree first.
**5. Land through the merge queue.** On one machine, a file lock is a complete merge queue — agents
wait their turn on it (`flock` ships with Linux; on macOS, `brew install flock`):
```bash
flock /tmp/dx-maps-main.lock sh -c '
git fetch origin &&
git rebase origin/main &&
bun run gates:pre &&
git push origin HEAD:main
'
```
The lock guarantees one landing at a time; the rebase makes the lander resolve every conflict; the
gates keep `main` green. When several machines share one remote, use the hosting platform's merge
queue instead (GitHub merge queue, GitLab merge trains), or protect `main` so only one builder at a
time may push.
Agents joining this repo should also read [AGENT-BRIEF.md](./AGENT-BRIEF.md).
---
## Repository layout
Two directories matter to a plugin author:
```text
doxa-maps/
├── plugin-designs/ YOUR maps live here — one folder per plugin (the FREE zone).
│ ├── doxa-maps-spa/ the Research Gallery, capability 5 (nests the production maps)
│ ├── plugin-studio/ the developer studio, capability 5 (nests every plugin)
│ ├── dashboard-plus-grid/ capability-4 composite: research dashboard + people-groups grid
│ ├── my-upg-100-list/ capability-4: UPG-100 list + poster wall
│ ├── doxa-simple-map/ capability-2: one root.vue, three preset maps
│ └── template-bundle/ copy this to start a new plugin
├── library/ the SHARED zone (@map) — reusable, parameterized only.
│ ├── layouts/ VerticalTabsLayout, StackLayout — how composites compose
│ ├── components/ map controls, legends, search, poster, detail panels
│ ├── composables/ map instance, shadow styles, print-in-place, retry
│ └── styles/ colors/ data/ style registry, palettes, lookup tables
├── data/ the public datasets the maps ship with
├── deployment/ Capacitor (mobile), WordPress plugin, Tauri (desktop)
├── documentation/ tutorial · how-to · reference · explanation + architecture
├── internal/ the build generator + the verification gates
└── public/doxa-maps/ the BUILD OUTPUT — the drop-anywhere tree (bundles, leaf pages,
manifest.json). What gets served and embedded.
```
plugin-designs/ ← YOUR FREE ZONE. One folder per plugin. Copy template-bundle/ to start.
library/ ← the SHARED toolkit (layouts, components, composables, colors, stores, i18n).
```
Everything else is supporting cast: `internal/` (build + gates), `public/` (committed build output),
`deployment/` (Capacitor, WordPress, Tauri), `documentation/`.
**`plugin-designs/` is a free zone.** Your plugin is its own application in its own folder, building to
its own output. Nothing you do there can break another author's map.
**`library/` is a HIGH BAR.** It is multi-tenant — reused by many maps and authors — so every edit is a
breaking-change risk to everyone. Only contribute here when a component is genuinely reusable *and*
parameterized, so other maps can use it. Never widen or casually add. Keep map-specific,
non-parameterized code in your own plugin-design.
parameterized, so other maps can use it. Keep map-specific, non-parameterized code in your own
plugin-design. See [documentation/contributing/](./documentation/contributing/).
---
## More docs
Start at **[documentation/README.md](./documentation/README.md)** — the index of everything below.
| Read this | When |
|---|---|
| **[documentation/develop-a-plugin-and-see-it-live.md](./documentation/develop-a-plugin-and-see-it-live.md)** | ★ the golden path — zero to a plugin live, in 6 steps |
@ -429,4 +481,5 @@ non-parameterized code in your own plugin-design.
> **Bottom line: maps should be open source, run everywhere, and multiply.** This bundler makes each map
> a self-contained file that embeds on your site, your partners' sites, WordPress, and mobile — from one
> build. The Plugin Studio is where you compose them into something bigger.
> build. The Plugin Studio is where you compose them into something bigger; the Research Gallery is
> what that looks like when you do.

View file

@ -1,14 +0,0 @@
# data-private/ — NEVER published
**The law: nothing in this folder is ever copied into the web-root or any public
repo.** It exists for internal analysis only. The build's copy steps read from
`data/` — deliberately not from here.
Classification rationale (release governance): datasets whose publication could
help close doors — e.g. field-access/visa patterns — stay here. Public-good
datasets (internet gaps, gospel-resource gaps, people-group pairings) live in
`data/` and ship.
| File | Why private |
|---|---|
| `tables/field-access-visas.json` | Visa/field-access patterns per destination — publication could accelerate access closures |

File diff suppressed because it is too large Load diff

View file

@ -1,75 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>legacy map host</title>
<style>html,body{margin:0;height:100%;background:#f3f3f1}.host{position:fixed;inset:0}</style>
</head>
<body>
<div class="host" id="host"></div>
<script>
// Sandboxed host for the LEGACY upg-neighbor IIFE (pre-bundler architecture). This page runs
// inside an IFRAME so the old engine and its dependencies cannot collide with the modern
// bundles -- the isolation pattern the roundup mandates ("add without breaking doxa-maps").
var q = new URLSearchParams(location.search);
var el = (q.get('el') || 'send-map').replace(/[^a-z0-9-]/g, '');
var tk = q.get('tk') || '';
// Token globals the legacy build may read.
window.MAPBOX_TOKEN = tk; window.MAP_TOKEN = tk; window.UPG_TOKEN = tk;
// The legacy code fetches relative "/api/people-groups/..." which only resolved on its old
// origin. Shim fetch so those calls reach the live API from ANY host (static tree, iframe).
var API = 'https://pray.doxa.life';
// The cockpit profile fails closed without a configured API base and names this global in
// its own error banner (measured: "No API base configured (apiBase / window.MAP_APP_API_URL
// / VITE_API_BASE_URL) -- failing closed").
window.MAP_APP_API_URL = API;
var origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
try {
var url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.indexOf('/api/') === 0) return origFetch(API + url, init);
} catch (e) { /* fall through */ }
return origFetch(input, init);
};
// ── KEYLESS SHIM ──────────────────────────────────────────────────────────
// The legacy bundle expects a mapboxgl GLOBAL and requests mapbox://styles/mapbox/light-v11.
// MapLibre is API-compatible with the mapbox-gl v1 surface the bundle uses, and OpenFreeMap
// Positron is the keyless twin of light-v11 -- so the shim below makes every legacy map run
// WITHOUT a Mapbox token: load our self-hosted MapLibre, alias it as mapboxgl, and rewrite
// any mapbox:// style to the keyless URL inside a patched Map constructor.
var KEYLESS_STYLE = 'https://tiles.openfreemap.org/styles/positron';
var lib = document.createElement('link'); lib.rel='stylesheet'; lib.href='./maplibre-gl.css';
document.head.appendChild(lib);
var ml = document.createElement('script');
ml.src = './maplibre-gl.js';
ml.onload = function () {
var Real = window.maplibregl.Map;
function PatchedMap (opts) {
if (opts && typeof opts.style === 'string' && opts.style.indexOf('mapbox://') === 0) opts.style = KEYLESS_STYLE;
return new Real(opts);
}
PatchedMap.prototype = Real.prototype;
window.mapboxgl = Object.assign({}, window.maplibregl, { Map: PatchedMap, accessToken: '' });
loadLegacy();
};
ml.onerror = function () { loadLegacy(); };
document.head.appendChild(ml);
function loadLegacy () {
var s = document.createElement('script');
s.src = './upg-neighbor.iife.js';
s.onload = function () {
var node = document.createElement(el);
node.style.cssText = 'display:block;width:100%;height:100%';
// The legacy elements use the SAME profile-config attribute contract as the modern
// bundles (measured: without it the element renders its own usage banner).
node.setAttribute('profile-config', JSON.stringify({ profile: el, tk: tk }));
document.getElementById('host').appendChild(node);
};
s.onerror = function () { document.body.innerHTML = '<p style="font:14px system-ui;padding:20px">legacy bundle failed to load</p>'; };
document.head.appendChild(s);
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,8 @@
# Contributing to doxa-maps
Everything a contributor needs, short. Recipes live in [documentation/how-to.md](./documentation/how-to.md),
lookups in [documentation/reference.md](./documentation/reference.md), depth in
[documentation/architecture/](./documentation/architecture/).
Everything a contributor needs, short. Recipes live in [documentation/how-to.md](./how-to.md),
lookups in [documentation/reference.md](./reference.md), depth in
[documentation/architecture/](./architecture/).
## The mental model — three zones
@ -26,7 +26,7 @@ genuinely needs it. One fix in `library/` reaches every map on the next build.
Pick A when the maps differ by *configuration*, B when they differ by *layout/behavior*.
Full walkthrough + the migration story:
[documentation/architecture/patterns-and-provenance.md](./documentation/architecture/patterns-and-provenance.md).
[documentation/architecture/patterns-and-provenance.md](./architecture/patterns-and-provenance.md).
## The two build steps (never confuse them)
@ -45,7 +45,7 @@ into `public/` on save) · terminal 2 serves the host site / `public/`.
`public/` is the committed web-root — serve it from anywhere and every map is embeddable from
it (script tag or the generated per-map page). Same-origin vs external-CDN modes, `paths`,
`embedBaseUrl`: [documentation/how-to.md → "Where do the maps get served from?"](./documentation/how-to.md).
`embedBaseUrl`: [documentation/how-to.md → "Where do the maps get served from?"](./how-to.md).
## Ground rules

View file

@ -274,5 +274,5 @@ this box, so `"all"` would fail two of three formats at the very end of a ~6-min
Still **not "proven"** — say *built, not shot*. The GUI launch is unverified because this VPS is
headless: the binary panics in `tao` with "Failed to initialize GTK" (no display, not a defect) and
no Xvfb is installed. See [`deployment/src-tauri/README.md`](../src-tauri/README.md) for placement rules, the
no Xvfb is installed. See [`deployment/src-tauri/README.md`](../deployment/src-tauri/README.md) for placement rules, the
token/CORS caveat and the full artifact table.

View file

@ -26,7 +26,7 @@ the goal is never one trained person, but trainers who train trainers. This READ
pass it on with the same button — **[the Playbook](#the-playbook--start-a-prayer-movement-with-this-map)**.
Everything below is for the people who build the maps. If you only want to *run the campaign*, jump to the
Playbook; the full walkthrough is [documentation/tutorial.md](./documentation/tutorial.md).
Playbook; the full walkthrough is [documentation/tutorial.md](./tutorial.md).
---
@ -275,11 +275,11 @@ Your map data has to come from somewhere. Common sources:
| Read this | When |
|---|---|
| **[documentation/why.md](./documentation/why.md)** | 2 min — the problem, the idea, one diagram |
| **[documentation/tutorial.md](./documentation/tutorial.md)** | build your first map, step by step (great to hand to your agent) |
| **[documentation/how-to.md](./documentation/how-to.md)** | task recipes (embed, data, mobile) |
| **[documentation/why.md](./why.md)** | 2 min — the problem, the idea, one diagram |
| **[documentation/tutorial.md](./tutorial.md)** | build your first map, step by step (great to hand to your agent) |
| **[documentation/how-to.md](./how-to.md)** | task recipes (embed, data, mobile) |
| **[library/layouts/README.md](../library/layouts/README.md)** | the SPA composition machinery (many maps → one app) |
| **[documentation/architecture/](./documentation/architecture/)** | the deep end — embeddability, app-wrappers, file map |
| **[documentation/architecture/](./architecture/)** | the deep end — embeddability, app-wrappers, file map |
---

View file

@ -15,5 +15,13 @@
- **[architecture/](./architecture/)** — the deep end: full contracts, the per-file catalog.
- **[README-embed-example.md](./README-embed-example.md)** — the drop-in README used when this bundler is embedded inside a host repo.
Working on the repo:
- **[develop-a-plugin-and-see-it-live.md](./develop-a-plugin-and-see-it-live.md)** — ★ the golden path: zero to a plugin live, in 6 steps.
- **[README-SECTION-3-cli-commands.md](./README-SECTION-3-cli-commands.md)** — every command, per deployment method, with prerequisites.
- **[reference-capability-exemplars.md](./reference-capability-exemplars.md)** — which bundle is at which capability tier (generated).
- **[reference-library-index.md](./reference-library-index.md)** — the reusable `library/` pieces you can import (generated).
- **[CONTRIBUTING.md](./CONTRIBUTING.md)** and **[contributing/](./contributing/)** — how to contribute, and the bar for `library/`.
Nothing on the simple path is longer than a few minutes; everything complex lives in
`architecture/`, linked from where you'd need it.

View file

@ -63,7 +63,17 @@ The built tree has TWO kinds of page, and they need DIFFERENT framing policy:
| Surface | Paths | `frame-ancestors` |
|---|---|---|
| **Direct embed** (the product) | `<bundle>/<profile>/index.html`, `<bundle>/<profile>/<instance>/index.html` — the bare map pages the manifest's `iframeUrl` points at | **`*`** — framable on any partner site (that's the whole point) |
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html`, the SPA shell | **`'self'`** — preview only |
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html` | **`'self'`** — preview only |
The Research Gallery (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
other leaf. Its full embed contract (script and iframe) is in
`plugin-designs/doxa-maps-spa/EMBED.md`.
**CORS is a separate requirement, and it covers the whole tree.** A script embed runs in the
partner's document, so everything a composed surface `fetch()`es — `manifest.json` at the tree
root, and `assets/` — needs `Access-Control-Allow-Origin: *`. Applying it only to the ≥2-segment
embed pages leaves `manifest.json` uncovered. `bun run check:gallery-embed` proves the whole
chain from a second origin.
`frame-ancestors` can ONLY be set by an HTTP response header (HTML `<meta>` cannot set it), so
**the server/CDN owns this split — the bundler cannot enforce it.** Whoever serves the tree must

View file

@ -14,17 +14,17 @@ being duplicated.
"**Local by default, promote deliberately.**" When in doubt, keep your change local to your
own map; something graduates into the shared library only once a *second* map genuinely
needs it. Full reasoning — the promotion / orphan-audit rule — is in
[`../documentation/explanation.md`](../documentation/explanation.md).
[`../documentation/explanation.md`](../explanation.md).
## Start here
| You want to… | Read |
|---|---|
| Understand the whole picture (one library, many bundles, no rebuild) | [`../README.md`](../README.md) · [`../documentation/explanation.md`](../documentation/explanation.md) |
| Learn step-by-step with no jargon (non-coders welcome) | [`../documentation/tutorial.md`](../documentation/tutorial.md) |
| Do a task (add a map, embed, change colors, pick a CDN) | [`../documentation/how-to.md`](../documentation/how-to.md) |
| Look something up (file map, config fields, library index) | [`../documentation/reference.md`](../documentation/reference.md) |
| Understand build-vs-dev + partner-site embedding | [`../documentation/CONTRIBUTING.md`](../documentation/CONTRIBUTING.md) |
| Understand the whole picture (one library, many bundles, no rebuild) | [`../README.md`](../README.md) · [`../documentation/explanation.md`](../explanation.md) |
| Learn step-by-step with no jargon (non-coders welcome) | [`../documentation/tutorial.md`](../tutorial.md) |
| Do a task (add a map, embed, change colors, pick a CDN) | [`../documentation/how-to.md`](../how-to.md) |
| Look something up (file map, config fields, library index) | [`../documentation/reference.md`](../reference.md) |
| Understand build-vs-dev + partner-site embedding | [`../documentation/CONTRIBUTING.md`](../CONTRIBUTING.md) |
| Edit or add colors (the two places a color can live) | [`COLORS.md`](./COLORS.md) |
## Colors live here
@ -33,4 +33,4 @@ needs it. Full reasoning — the promotion / orphan-audit rule — is in
can live** — the shared `library/colors/` set vs your bundle's private
`plugin-designs/<bundle>/src/colors/` — and how the filename decides override-vs-add. The
strategy **contract** (the required `export default` shape) is in
[`../library/colors/README.md`](../library/colors/README.md).
[`../library/colors/README.md`](../../library/colors/README.md).

View file

@ -110,13 +110,13 @@ MAPBOX_TOKEN=$MAPBOX_TOKEN node internal/embed-smoke.mjs --grep <your-plugin>
> **Do not fetch the token from `https://doxa.life/api/maps/token` here — measured 2026-09-23, it does
> not work for this.** That endpoint serves a token URL-restricted to `doxa.life`, so a tile request
> without that Referer returns **403** and every basemap in your smoke test renders blank while the pins
> draw fine. That is the exact symptom the Driver reported as *"it's loading the pins, but it's not
> draw fine. That is the exact symptom first reported as *"it's loading the pins, but it's not
> loading the actual underlying map"*, and it is why `tokenUrl` was emptied in the build config on
> 2026-09-22. This doc used to hand you that command, so an inheritor's first run reproduced the bug.
>
> The `token` field in `vite.doxa-maps-build-config.json` is the one that works: measured, the config
> token returns **200** for a tile request with no Referer while the endpoint token returns **403**. It
> is a `pk.` public key, baked deliberately (Driver ruling 2026-09-21) so a map works on any host —
> is a `pk.` public key, baked deliberately (a project decision, 2026-09-21) so a map works on any host —
> protected by URL restrictions rather than secrecy. `check:public-hygiene` allows `pk.` and fails the
> build on `sk.`/`tk.`, so this is safe to read and never a secret to guard.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 KiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 KiB

After

Width:  |  Height:  |  Size: 1,015 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 925 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1010" viewBox="0 0 1200 1010" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#64748b"/></marker>
<marker id="arM" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#b91c1c"/></marker>
<radialGradient id="gM" cx="45%" cy="40%"><stop offset="0" stop-color="#16a34a"/><stop offset="1" stop-color="#166534"/></radialGradient>
</defs>
<rect width="1200" height="1010" fill="#ffffff"/>
<circle cx="600" cy="475" r="450" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2.5"/>
<circle cx="600" cy="475" r="365" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
<circle cx="600" cy="475" r="262" fill="#fffbeb" stroke="#d97706" stroke-width="2.5"/>
<circle cx="600" cy="475" r="168" fill="#fef2f2" stroke="#dc2626" stroke-width="2.5" stroke-dasharray="8 6"/>
<rect x="578.0" y="297.0" width="44" height="20" fill="#fef2f2"/>
<line x1="578.0" y1="291.0" x2="578.0" y2="323.0" stroke="#dc2626" stroke-width="5" stroke-linecap="round"/>
<line x1="622.0" y1="291.0" x2="622.0" y2="323.0" stroke="#dc2626" stroke-width="5" stroke-linecap="round"/>
<text x="600.0" y="283.0" text-anchor="middle" font-size="13" font-weight="700" fill="#b91c1c">ONE GATE</text>
<line x1="600.0" y1="319.0" x2="600" y2="389" stroke="#b91c1c" stroke-width="3" marker-end="url(#arM)"/>
<text x="610" y="355.0" font-size="12" font-weight="700" fill="#b91c1c">merge</text>
<rect x="515.7941844763895" y="305.1584883484701" width="48" height="26" rx="13" fill="#ffffff" stroke="#dc2626" stroke-width="2"/>
<text x="539.7941844763895" y="323.1584883484701" text-anchor="middle" font-size="13" font-weight="700" fill="#b91c1c">#1</text>
<rect x="465.7820831295948" y="335.2087905225743" width="48" height="26" rx="13" fill="#ffffff" stroke="#dc2626" stroke-width="2"/>
<text x="489.7820831295948" y="353.2087905225743" text-anchor="middle" font-size="13" font-weight="700" fill="#b91c1c">#2</text>
<rect x="429.0638892005815" y="380.55198379861537" width="48" height="26" rx="13" fill="#ffffff" stroke="#dc2626" stroke-width="2"/>
<text x="453.0638892005815" y="398.55198379861537" text-anchor="middle" font-size="13" font-weight="700" fill="#b91c1c">#3</text>
<text x="431.7820831295948" y="328.2087905225743" text-anchor="middle" font-size="12" fill="#b91c1c" font-style="italic">waiting in line</text>
<text x="600" y="581" text-anchor="middle" font-size="16" font-weight="800" fill="#b91c1c">MERGE QUEUE</text>
<text x="600" y="599" text-anchor="middle" font-size="12" fill="#7f1d1d">one change at a time · rebased on main</text>
<text x="600" y="614" text-anchor="middle" font-size="12" fill="#7f1d1d">lands only if the gates pass</text>
<circle cx="600" cy="475" r="82" fill="url(#gM)" stroke="#14532d" stroke-width="2"/>
<text x="600" y="473" text-anchor="middle" font-size="30" font-weight="800" fill="#ffffff">main</text>
<text x="600" y="497" text-anchor="middle" font-size="13" fill="#dcfce7">trunk · always green</text>
<rect x="405.70000000000005" y="683.0" width="388.59999999999997" height="30" rx="15" fill="#ffffff" stroke="#d97706" stroke-width="1.5"/>
<text x="600" y="703.0" text-anchor="middle" font-size="14" font-weight="700" fill="#92400e">PLUGIN STUDIO · one repo, shared library/</text>
<rect x="371.3" y="777.5" width="457.4" height="30" rx="15" fill="#ffffff" stroke="#2563eb" stroke-width="1.5"/>
<text x="600" y="797.5" text-anchor="middle" font-size="14" font-weight="700" fill="#1e40af">AGENTS · one per plugin, each in its own worktree</text>
<rect x="336.9" y="867.5" width="526.2" height="30" rx="15" fill="#ffffff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="600" y="887.5" text-anchor="middle" font-size="14" font-weight="700" fill="#5b21b6">MAINTAINER · reviews what lands, keeps shared code shared</text>
<circle cx="834.6174775355869" cy="195.393778261573" r="58" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
<text x="834.6174775355869" y="189.393778261573" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">Research</text>
<text x="834.6174775355869" y="206.393778261573" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">Gallery</text>
<text x="834.6174775355869" y="224.393778261573" text-anchor="middle" font-size="10.5" fill="#64748b">agent + worktree</text>
<line x1="796.0502209543945" y1="241.3564448487117" x2="711.8450440854579" y2="341.7082668972978" stroke="#64748b" stroke-width="2" marker-end="url(#ar)"/>
<circle cx="965.0" cy="475.0" r="58" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
<text x="965.0" y="469.0" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">Big</text>
<text x="965.0" y="486.0" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">Picture</text>
<text x="965.0" y="504.0" text-anchor="middle" font-size="10.5" fill="#64748b">agent + worktree</text>
<line x1="905.0" y1="475.0" x2="774.0" y2="475.0" stroke="#64748b" stroke-width="2" marker-end="url(#ar)"/>
<circle cx="858.0939751330899" cy="733.0939751330898" r="58" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
<text x="858.0939751330899" y="736.0939751330898" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">Regions</text>
<text x="858.0939751330899" y="762.0939751330898" text-anchor="middle" font-size="10.5" fill="#64748b">agent + worktree</text>
<line x1="815.667568261897" y1="690.667568261897" x2="723.0365799264592" y2="598.0365799264592" stroke="#64748b" stroke-width="2" marker-end="url(#ar)"/>
<circle cx="341.9060248669102" cy="733.0939751330899" r="58" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
<text x="341.9060248669102" y="727.0939751330899" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">UPG-100</text>
<text x="341.9060248669102" y="744.0939751330899" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">List</text>
<text x="341.9060248669102" y="762.0939751330899" text-anchor="middle" font-size="10.5" fill="#64748b">agent + worktree</text>
<line x1="384.332431738103" y1="690.667568261897" x2="476.96342007354076" y2="598.0365799264592" stroke="#64748b" stroke-width="2" marker-end="url(#ar)"/>
<circle cx="235.0" cy="475.00000000000006" r="58" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
<text x="235.0" y="469.00000000000006" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">Gospel</text>
<text x="235.0" y="486.00000000000006" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">Resources</text>
<text x="235.0" y="504.00000000000006" text-anchor="middle" font-size="10.5" fill="#64748b">agent + worktree</text>
<line x1="295.0" y1="475.00000000000006" x2="426.0" y2="475.0" stroke="#64748b" stroke-width="2" marker-end="url(#ar)"/>
<circle cx="320.39377826157306" cy="240.38252246441309" r="58" fill="#ffffff" stroke="#2563eb" stroke-width="2" stroke-dasharray="5 4"/>
<text x="320.39377826157306" y="234.38252246441309" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">your next</text>
<text x="320.39377826157306" y="251.38252246441309" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">plugin</text>
<text x="320.39377826157306" y="269.3825224644131" text-anchor="middle" font-size="10.5" fill="#64748b">agent + worktree</text>
<text x="600" y="25" dy="-0" />
<text x="600" y="968" text-anchor="middle" font-size="18" font-weight="800" fill="#0f172a">Many agents build in parallel. Changes land one at a time. main stays green.</text>
<text x="600" y="992" text-anchor="middle" font-size="13" fill="#475569">Each arrow is a change entering the merge queue; only the queue writes to main.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -344,7 +344,7 @@ export MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-ma
> diagnosed the blank basemap and then kept prescribing its cause.
>
> The `token` field in `vite.doxa-maps-build-config.json` returns **200** on the identical request
> from any origin. It is a `pk.` public key baked deliberately (Driver ruling 2026-09-21), protected
> from any origin. It is a `pk.` public key baked deliberately (a project decision, 2026-09-21), protected
> by URL restrictions rather than secrecy; `check:public-hygiene` allows `pk.` and fails the build on
> `sk.`/`tk.`. So the SHELL COMMANDS above — the ones that curl a token and hand it to a gate as
> `MAPBOX_TOKEN` — now work where they previously fetched a token that 403s.
@ -514,7 +514,7 @@ Country/region polygons are **not** a `sources.json` connection — they render
**Writing or overriding a strategy?** Full contract + checklist:
[architecture/color-strategies.md](./architecture/color-strategies.md) ·
two homes in depth: [../contributing/COLORS.md](../contributing/COLORS.md).
two homes in depth: [../contributing/COLORS.md](./contributing/COLORS.md).
---

View file

@ -56,8 +56,17 @@ async function probe(browser, { name, width, height, mobile }) {
const before = await page.evaluate(`(${WALK})(document).filter(e => (e.className||'').toString().includes('stl-export')).length`)
// On a phone the legend starts closed — open it first, or every row query returns nothing.
// ⚠ ONLY OPEN THE SHEET IF IT IS SHUT. This clicked the toggle unconditionally, which is
// right for my-upg-100-list (its sheet starts CLOSED) and exactly wrong for country-posters,
// whose sheet opens EXPANDED — measured 2026-09-24: 8 rows visible before the click, 0 after.
// The gate was closing the panel it came to inspect and then reporting "no selectable row",
// which reads as a product defect and is purely the probe. Toggle only when there is nothing
// to select yet.
const openedSheet = await page.evaluate(`(() => {
const all = (${WALK})(document)
const vis = (e) => { const r = e.getBoundingClientRect(); return r.width > 2 && r.height > 2 }
const rowsNow = all.filter(e => (e.className||'').toString().includes('stl-row')).filter(vis).length
if (rowsNow > 0) return false
const b = all.filter(e => e.tagName === 'BUTTON')
.find(e => /show or hide the legend|toggle.?legend/i.test(e.getAttribute('aria-label') || ''))
if (!b) return false

View file

@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* check-research-gallery-embed.mjs — the cap-5 Research Gallery (<doxa-maps-spa>) must embed on
* SOMEONE ELSE'S page, in both embed modes, with every nested map painting.
*
* WHAT THIS PROTECTS. The gallery is going onto the DX-life research page. Every other gate loads
* the gallery on its OWN generated leaf page, which bakes the three things a nested map needs —
* window.MAP_APP_API_URL, window.MAP_TOKEN and a correct assets path — and pins the tag
* position:fixed. A foreign page supplies none of that, so those gates cannot see the embed failing.
* Measured 2026-09-24 before the fix, from a second origin: the gallery drew OVER the host's header
* (the element had no :host box, so the layout's position:absolute root anchored to the viewport),
* and 8 of 13 tabs showed an error card because every data call hit the host's origin.
*
* HOW. Serves the built tree with internal/serve-strict.mjs (production parity: strict directories,
* ACAO:*) on 127.0.0.1, and a stand-in host page on `localhost` — a DIFFERENT ORIGIN, so CORS is
* genuinely exercised. The host page has its own header and footer around the embed. Then, per mode:
* js — `<script src=…/doxa-maps-spa.js>` + a bare `<doxa-maps-spa>` tag, NO attributes: the
* zero-config embed the docs promise.
* iframe — an <iframe> at the gallery's leaf page.
* and for every tab in the rail: select it, wait for it to mount, then assert.
*
* ASSERTIONS (a FAIL names the tab and the reason):
* 1. js mode: the layout root sits INSIDE the element's box (not at the viewport origin).
* 2. every tab reaches state 'ready' (never 'error', never stuck 'idle'/'loading').
* 3. every CURATED tab paints a WebGL canvas — they are all maps or map composites.
* Experimental tabs need only mount: some are cards or legacy frames with no canvas.
* 4. zero failed requests to the TREE's own origin (a 404/CORS on our assets is our bug; third-
* party tile/glyph hiccups are reported, not failed — they are not this repo's to fix).
*
* PRECONDITION: a built tree in public/doxa-maps (this starts its own servers; no dev server).
* Usage: node internal/check-research-gallery-embed.mjs [--mode js|iframe] [--only id,id]
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no browser)
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const argOf = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const MODES = argOf('--mode', '') ? [argOf('--mode', '')] : ['js', 'iframe']
const ONLY = argOf('--only', '') ? argOf('--only', '').split(',') : null
// ★ FREE PORTS, ASKED OF THE OS — never fixed ones. A stray server left on a fixed port by an
// earlier crashed run answered in place of the tree under test, and this gate PASSED a known-broken
// tree (caught by its own negative control, 2026-09-24): spawn failed with EADDRINUSE silently and
// the old server served the fixed build. A port the OS just handed out cannot be someone else's.
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const TREE_PORT = await freePort()
const HOST_PORT = await freePort()
const TREE = `http://127.0.0.1:${TREE_PORT}/doxa-maps`
if (!existsSync(resolve(ROOT, 'public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js'))) {
console.log('SKIP check-research-gallery-embed: no built gallery at public/doxa-maps/doxa-maps-spa/')
process.exit(2)
}
let chromium
try { ({ chromium } = await import('playwright')) } catch {
console.log('SKIP check-research-gallery-embed: playwright not installed'); process.exit(2)
}
let mode = 'js'
const hostPage = () => {
const embed = mode === 'iframe'
? `<iframe src="${TREE}/doxa-maps-spa/doxa-maps-spa/index.html" style="width:100%;height:760px;border:0"></iframe>`
: `<script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa></doxa-maps-spa>`
return `<!doctype html><html><head><meta charset=utf-8><title>host</title>
<style>body{margin:0} header,footer{height:60px;background:#eee}</style></head>
<body><header id=hdr>HOST HEADER</header><main style="max-width:1200px;margin:0 auto">${embed}</main><footer>HOST FOOTER</footer></body></html>`
}
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' })
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(hostPage()) }).listen(HOST_PORT, 'localhost')
const cleanup = () => { try { tree.kill() } catch {} try { host.close() } catch {} }
await new Promise(r => setTimeout(r, 800))
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
try {
for (mode of MODES) {
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
const own = new Set(), foreign = new Set()
const note = (url, why) => (url.startsWith(`http://127.0.0.1:${TREE_PORT}/`) ? own : foreign).add(why + ' ' + url.split('?')[0])
page.on('response', r => { if (r.status() >= 400) note(r.url(), String(r.status())) })
page.on('requestfailed', q => { const e = (q.failure() && q.failure().errorText) || ''; if (!/ABORTED/.test(e)) note(q.url(), e) })
await page.goto(`http://localhost:${HOST_PORT}/`)
const frame = mode === 'iframe' ? await (await page.waitForSelector('iframe')).contentFrame() : page.mainFrame()
await frame.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 30000 })
if (mode === 'js') {
const g = await page.evaluate(() => {
const s = document.querySelector('doxa-maps-spa'), v = s.shadowRoot.querySelector('.vtl')
const a = s.getBoundingClientRect(), b = v.getBoundingClientRect()
return { host: [a.top, a.left, a.width, a.height].map(Math.round), vtl: [b.top, b.left, b.width, b.height].map(Math.round) }
})
const inside = g.vtl[0] >= g.host[0] - 1 && g.vtl[1] >= g.host[1] - 1 && g.vtl[2] <= g.host[2] + 1 && g.vtl[3] <= g.host[3] + 1 && g.host[3] >= 400
console.log(`${inside ? 'PASS' : 'FAIL'} [js] layout inside the host box — host ${g.host} layout ${g.vtl}`)
if (!inside) fails.push(`[js] layout escaped the <doxa-maps-spa> box (host ${g.host}, layout ${g.vtl})`)
}
const tabs = await frame.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => ({ id: t.id, discovered: !!t.discovered })))
for (const { id, discovered } of tabs) {
if (ONLY && !ONLY.includes(id)) continue
await frame.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
let v
for (let k = 0; k < 50; k++) {
await page.waitForTimeout(400)
v = await frame.evaluate(id => {
const vm = document.querySelector('doxa-maps-spa')._instance.proxy
const i = vm.tabs.findIndex(x => x.id === id), t = vm.tabs[i]
const el = vm._slots && vm._slots[i] && vm._slots[i].firstElementChild
let canvas = 0
const walk = r => { for (const n of r.querySelectorAll('*')) { if (n.tagName === 'CANVAS') canvas++; if (n.shadowRoot) walk(n.shadowRoot) } }
if (el) { walk(el); if (el.shadowRoot) walk(el.shadowRoot) }
return { state: t.state, error: t.error, canvas }
}, id)
if (v.state === 'error' || (v.state === 'ready' && (v.canvas || discovered && k > 5))) break
}
const needCanvas = !discovered
const ok = v.state === 'ready' && (!needCanvas || v.canvas > 0)
const why = v.state !== 'ready' ? `state ${v.state}${v.error ? ' — ' + v.error : ''}` : (needCanvas && !v.canvas ? 'mounted but painted no map canvas' : '')
console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] ${id}${why ? ' — ' + why : ''}`)
if (!ok) fails.push(`[${mode}] ${id}: ${why}`)
}
for (const f of own) { console.log(`FAIL [${mode}] tree request failed: ${f}`); fails.push(`[${mode}] ${f}`) }
for (const f of foreign) console.log(`note [${mode}] third-party request failed (not gated): ${f}`)
await page.close()
}
} finally { await browser.close(); cleanup() }
if (fails.length) {
console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f)
process.exit(1)
}
console.log('\ncheck-research-gallery-embed: PASS — the Research Gallery embeds on a foreign origin in ' + MODES.join(' + ') + ' mode')

View file

@ -25,10 +25,19 @@
* Shots whose path is not a /doxa-maps/ leaf (the synthetic iframe host) are REPORTED as skipped
* rather than dropped — an unchecked shot must be visible, not invisible.
*
* NEGATIVE-TESTED: `touch public/doxa-maps/doxa-simple-map/doxa-simple-map.js` makes this FAIL
* naming the three cap2-* shots; re-running readme-shots.mjs returns it to PASS.
* WHICH CLOCK: GIT, NOT THE FILESYSTEM, for any file that is committed and unmodified.
* This compared mtimes, and a clone (or a new worktree) writes files in whatever order git checks
* them out — so every fresh checkout failed, pristine main included (11 shots "2s behind" their
* bundles). A gate that cannot pass on a clone teaches the recipient to ignore it. A committed,
* clean file is dated by its last commit; a file with local edits (or no git at all, e.g. a
* tarball) falls back to its mtime, because then the working copy IS the newest version.
*
* NEGATIVE-TESTED: appending a byte to public/doxa-maps/doxa-simple-map/doxa-simple-map.js makes
* this FAIL naming the three cap2-* shots (a dirty file is dated by mtime); reverting it returns it
* to PASS. A bare `touch` no longer fails it — a touch changes no content, so nothing is stale.
*/
import { readFileSync, existsSync, statSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -42,6 +51,23 @@ if (!existsSync(SHOTS_SRC) || !existsSync(OUT)) {
process.exit(0)
}
// Dirty = modified or untracked under the two trees compared. null = git unavailable -> mtimes only.
const git = (...a) => execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
let dirty = null
try {
dirty = new Set(git('status', '--porcelain', '--', 'public/doxa-maps', 'documentation/readme-images')
.split('\n').filter(Boolean).map((l) => resolve(ROOT, l.slice(3).replace(/^"|"$/g, ''))))
} catch { /* not a git checkout */ }
function stamp(file) {
if (dirty && !dirty.has(file)) {
try {
const ct = git('log', '-1', '--format=%ct', '--', file).trim()
if (ct) return { ms: Number(ct) * 1000, by: 'commit' }
} catch { /* fall through */ }
}
return { ms: statSync(file).mtimeMs, by: 'mtime' }
}
// Parse `await shot('name', '/path/...')` — the generator is the source of truth for its own map.
const src = readFileSync(SHOTS_SRC, 'utf8')
const shots = [...src.matchAll(/shot\(\s*'([^']+)'\s*,\s*'([^']*)'/g)].map(m => ({ name: m[1], path: m[2] }))
@ -60,10 +86,10 @@ for (const { name, path } of shots) {
if (!existsSync(bundle)) { skipped.push(`${name} (bundle ${m[1]} not built)`); continue }
checked++
const bMs = statSync(bundle).mtimeMs, pMs = statSync(png).mtimeMs
if (bMs > pMs) {
const b = stamp(bundle), p = stamp(png)
if (b.ms > p.ms) {
stale.push(`${name}.png is OLDER than the bundle it shows (${m[1]}) — ` +
`${Math.round((bMs - pMs) / 1000)}s behind`)
`${Math.round((b.ms - p.ms) / 1000)}s behind (bundle by ${b.by}, png by ${p.by})`)
}
}

View file

@ -1233,6 +1233,16 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
embedBase: EMBED_BASE,
bundleCount: bundles.length,
profileCount: bundles.reduce((n, b) => n + b.profiles.length, 0),
// THE PAGE GLOBALS A LEAF PAGE BAKES, published for embeds that have no leaf page. A composed
// surface (the cap-5 Research Gallery) mounted by `<script>` on a FOREIGN page runs in that
// page's document, which carries neither window.MAP_APP_API_URL nor window.MAP_TOKEN — measured
// 2026-09-24: 8 of 13 gallery tabs errored with the host's HTML in place of API JSON. The shell
// reads this block as its default (a host attribute still wins). ADDITIVE: every key is optional
// and old consumers ignore it. The token is the same opt-in public pk. every leaf already bakes.
runtime: {
...(DEST_CONFIG.apiBaseUrl ? { apiBaseUrl: DEST_CONFIG.apiBaseUrl } : {}),
...(bakedToken ? { token: bakedToken } : {}),
},
// `extraConfig` is a GENERATOR-INTERNAL carrier (<profile>.meta.json `config` → the leaf
// page's BASE_CONFIG) and is stripped here so the published manifest shape is unchanged.
// Consumers read the manifest as a contract; a new key would be a silent schema change.
@ -1400,11 +1410,6 @@ function main() {
// vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime.
'vendor/tabulator/tabulator.min.js',
'vendor/tabulator/tabulator.min.css',
// legacy sandbox — fetched by the upg-neighbor wrapper's iframe at runtime.
'legacy/upg-neighbor/index.html',
'legacy/upg-neighbor/upg-neighbor.iife.js',
'legacy/upg-neighbor/maplibre-gl.js',
'legacy/upg-neighbor/maplibre-gl.css',
// vendor/vue — the GALLERY SHELL's own Vue (generate-gallery.mjs loadVue()), not a bundle's.
//
// ★ ADDED 2026-09-22, and it had never been staged. generate-gallery.mjs:542 requests

View file

@ -58,6 +58,13 @@ export function resolveDevAsset(rel, root = REPO_ROOT) {
// maps' Share button use it, so a shared link reopens the same map
// embed the snippets the build will write (generate-tree's template, tree-relative;
// integrity only exists after a build), so Copy embed previews what ships
function readComposes(bundle, profile) {
try {
const meta = JSON.parse(readFileSync(resolve(REPO_ROOT, 'plugin-designs', bundle, `${profile}.meta.json`), 'utf8'))
return Array.isArray(meta?.composes) ? meta.composes.map(String).filter(Boolean) : []
} catch { return [] }
}
export function galleryModel(reg) {
const iframe = (u) => `<iframe src="${u}?utm_source=YOUR_SOURCE" width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>`
// A REAL DEV LEAF, not a hash deep-link into the gallery.
@ -101,7 +108,12 @@ export function galleryModel(reg) {
if (instances.length) p.instances = instances
return p
})
return { bundle: b.bundle, scriptUrl, integrity: '', elements: [b.tag], profiles }
// COMPOSITION EDGES, the same source and shape generate-tree publishes: the union of every
// profile's <profile>.meta.json `composes`, at bundle level, omitted when none. This model used
// to drop them, so under the dev server — which is what the Driver's laptop runs — the studio's
// Composed-of panel and composition trees were silently empty for every composite.
const composes = [...new Set([...presets.keys()].flatMap((profile) => readComposes(b.bundle, profile)))]
return { bundle: b.bundle, scriptUrl, integrity: '', elements: [b.tag], ...(composes.length ? { composes } : {}), profiles }
})
return {
schema: 'doxa-maps-manifest@1', generatedFrom: 'plugin-designs/*/*.vue (dev discovery)', embedBase: '.',

View file

@ -65,7 +65,7 @@ const LEAK = [
// ★ HARD in every mode (Driver-critical, 2026-09-24): field-access / visa data must never
// ship in ANY public tree -- it maps how workers enter closed countries. Found live-served
// before this rule existed. Filename AND content patterns, no strict-tier softening.
[/field-access-visas/, 'PRIVATE data-class: field-access/visa table (see data-private/README.md)'],
[/field-access-visas/, 'PRIVATE data-class: field-access/visa table (private tables live outside the repo, see DOXA_PRIVATE_DIR)'],
[/"visa_/, 'PRIVATE data-class: visa field in shipped data'],
]
// ★ ADDED 2026-09-24 (public-exposure audit, policies P3/P4/P7). A public tree carries

View file

@ -86,6 +86,7 @@ const browser = await chromium.launch({ args: [
// ★ A SHOT THAT FAILS MUST NOT SILENTLY PRODUCE A README IMAGE. Collected and asserted at the end.
const failures = []
const ONLY = process.env.ONLY ? process.env.ONLY.split(',') : null
// mapbox canvas selector to wait on (leaf pages mount a custom element with a canvas)
const CANVAS = 'canvas'
@ -114,6 +115,8 @@ async function shot(name, path, {
// generalised: the predicate is "does this surface use iframes", not "did this shot fail".
mapboxReferer = true,
} = {}) {
// ONLY=<name>[,<name>] retakes just those shots, leaving every other image untouched.
if (ONLY && !ONLY.includes(name)) return
const ctx = await browser.newContext({ viewport: vp, deviceScaleFactor: dsf })
// ★ SEND THE REFERER THE TOKEN IS SCOPED TO, or every map ships a BLANK BASEMAP.
//

View file

@ -22,7 +22,13 @@
A row/group may carry EITHER an `emoji` (a unicode glyph) OR an `icon` (a LOCAL icon name resolved
to inline SVG — no CDN, offline safe). `icon` wins if both are set. A GROUP with `folder:true`
(and optional `open:false`) renders a COLLAPSIBLE section (click the heading — chevron rotates); a
plain group is an always-open labelled section (the "Subscriptions" style).
plain group is an always-open labelled section (the "Subscriptions" style). A folder heading also
shows the group's `badge` + `count` when given.
Two optional row fields let a rail show a TREE whose nodes repeat elsewhere:
depth : indent level (0/omitted = flush) — draws the row nested under the row above it
selects : the `key` of the canonical row this one stands for. An ALIAS row has its own unique
`key` (for v-for) but clicking it selects `selects`, it highlights whenever that key is
active, and it is skipped by keyboard nav so arrow keys visit each canonical row once.
Selection is controlled via v-model:active (the active item's `key`). The host owns pane content
and mount logic — this component only paints the SHELL (top bar + rail + a content frame) and
exposes slots so each app keeps its unique chrome + its own mount machinery:
@ -141,7 +147,9 @@
@click="toggleFolder(grp.key)">
<!-- NO section-header logo (Driver rule: logos only on the vertical TAB rows, never on the
section dividers). The folder chevron below is an affordance, not a logo, so it stays. -->
<span class="vtl-cap" v-if="grp.badge">{{ grp.badge }}</span>
<span class="vtl-tl">{{ grp.label }}</span>
<span class="vtl-td" v-if="grp.count != null">{{ grp.count }}</span>
<span class="vtl-chev" :class="{ open: !isFolderClosed(grp) }" aria-hidden="true" v-html="iconSvg('chevron')"></span>
</button>
<!-- SECTION heading = a static always-visible label (with optional badge/count). No logo here
@ -155,11 +163,12 @@
<div class="vtl-rows" v-show="!grp.folder || !isFolderClosed(grp)">
<button
v-for="row in grp.rows" :key="row.key" type="button" role="tab"
class="vtl-tab" :class="{ active: row.key === active }"
:aria-selected="String(row.key === active)"
:tabindex="row.key === active ? 0 : -1"
:ref="el => registerTab(row.key, el)"
@click="pick(row.key)" @keydown="onKey($event)">
class="vtl-tab" :class="{ active: target(row) === active, alias: !!row.selects }"
:data-depth="row.depth || null" :style="row.depth ? { '--vtl-depth': row.depth } : null"
:aria-selected="String(target(row) === active)"
:tabindex="!row.selects && row.key === active ? 0 : -1"
:ref="el => { if (!row.selects) registerTab(row.key, el) }"
@click="pick(target(row))" @keydown="onKey($event)">
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
<!-- Slim-rail fallback (desktop collapsed only): a row with NEITHER icon NOR emoji would
@ -357,6 +366,8 @@ const VTL_SHADOW_CSS = `
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block) and the two copies must stay
byte-identical — edit both or neither. */
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
.vtl-tab[data-depth] { padding-left:calc(12px + var(--vtl-depth) * 16px); }
.vtl-tab[data-depth]::before { content:''; flex:0 0 auto; align-self:stretch; width:0; margin-right:-3px; border-left:2px solid var(--vtl-accent); opacity:.45; }
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
@ -451,6 +462,7 @@ const VTL_SHADOW_CSS = `
.vtl.rail-collapsed .vtl-tab-text { display:none; }
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
.vtl.rail-collapsed .vtl-tab[data-depth]::before { display:none; }
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
@ -475,6 +487,8 @@ const VTL_SHADOW_CSS = `
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
.vtl.rail-collapsed .vtl-tab[data-depth] { padding-left:calc(12px + var(--vtl-depth) * 16px); }
.vtl.rail-collapsed .vtl-tab[data-depth]::before { display:block; }
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
@ -538,7 +552,9 @@ export default defineComponent({
return this.items
},
// Flat ordered list of keys — drives keyboard nav.
orderedKeys() { return this.allRows.map(r => r.key) },
// An ALIAS row (row.selects set) repeats another row — it is skipped here, so arrow keys walk
// each plugin once instead of stopping on every copy.
orderedKeys() { return this.allRows.filter(r => !r.selects).map(r => r.key) },
// Rows that ask THIS layout to host a nested custom element (have an `element` tag).
elementRows() { return this.allRows.filter(r => r && r.element) },
// Element rows that have been activated at least once — only these get a rendered/mounted pane
@ -672,6 +688,8 @@ export default defineComponent({
const wasOpen = cur != null ? cur : (grp && grp.open === true)
this.folderOverride = { ...this.folderOverride, [key]: !wasOpen }
},
// The key a row selects: its own, or — for an ALIAS row — the canonical row it points at.
target(row) { return row.selects || row.key },
pick(key) {
this.$emit('update:active', key)
// On mobile, picking a tab closes the off-canvas drawer. On desktop navOpen must stay true (it gates
@ -800,6 +818,8 @@ export default defineComponent({
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block) and the two copies must stay
byte-identical — edit both or neither. */
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
.vtl-tab[data-depth] { padding-left:calc(12px + var(--vtl-depth) * 16px); }
.vtl-tab[data-depth]::before { content:''; flex:0 0 auto; align-self:stretch; width:0; margin-right:-3px; border-left:2px solid var(--vtl-accent); opacity:.45; }
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
@ -894,6 +914,7 @@ export default defineComponent({
.vtl.rail-collapsed .vtl-tab-text { display:none; }
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
.vtl.rail-collapsed .vtl-tab[data-depth]::before { display:none; }
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
@ -918,6 +939,8 @@ export default defineComponent({
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
.vtl.rail-collapsed .vtl-tab[data-depth] { padding-left:calc(12px + var(--vtl-depth) * 16px); }
.vtl.rail-collapsed .vtl-tab[data-depth]::before { display:block; }
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }

View file

@ -32,8 +32,12 @@ import { dirname, join, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = join(__dirname, 'out');
const TABLES = join(OUT, 'tables');
// Tables that must NEVER ship publicly (see data-private/README.md).
// Tables that must NEVER ship publicly. They are never written inside this repo: set
// DOXA_PRIVATE_DIR to a folder OUTSIDE the checkout to keep them for internal analysis, or
// leave it unset and they are skipped. (They used to live in a tracked data-private/ folder;
// a tracked folder in a public repo is not private.)
const PRIVATE_TABLES = new Set(['field-access-visas']);
const PRIVATE_DIR = process.env.DOXA_PRIVATE_DIR ? resolve(process.env.DOXA_PRIVATE_DIR) : null;
const PROFILES = resolve(__dirname, '..', '..', '..', 'plugin-designs');
const read = (f) => JSON.parse(readFileSync(join(OUT, f), 'utf8'));
@ -66,13 +70,17 @@ function emit(name, rows, columns, meta, assert) {
}
}
if (assert) assert(rows);
// RELEASE GOVERNANCE: tables in PRIVATE_TABLES are written to data-private/
// (never copied to the web-root, never indexed) — see data-private/README.md.
// RELEASE GOVERNANCE: tables in PRIVATE_TABLES never land in this repo — only in
// DOXA_PRIVATE_DIR (outside the checkout), or nowhere.
if (PRIVATE_TABLES.has(name)) {
const priv = join(TABLES, '..', '..', 'data-private', 'tables');
if (!PRIVATE_DIR) {
console.log(`[tables] ${name.padEnd(26)} ${String(rows.length).padStart(5)} rows PRIVATE — skipped (set DOXA_PRIVATE_DIR to keep it outside the repo)`);
return { name, rows: rows.length, private: true };
}
const priv = join(PRIVATE_DIR, 'tables');
if (!existsSync(priv)) mkdirSync(priv, { recursive: true });
writeFileSync(join(priv, `${name}.json`), JSON.stringify({ meta, columns, rows }, null, 1));
console.log(`[tables] ${name.padEnd(26)} ${String(rows.length).padStart(5)} rows PRIVATE → data-private/`);
console.log(`[tables] ${name.padEnd(26)} ${String(rows.length).padStart(5)} rows PRIVATE → ${priv}`);
return { name, rows: rows.length, private: true };
}
if (!existsSync(TABLES)) mkdirSync(TABLES, { recursive: true });
@ -1319,7 +1327,7 @@ for (const b of built) {
const app = ROUTE[b.name];
if (!app) continue;
// ★ THE LEAK THIS LOOP CAUSED (found live 2026-09-24, Driver-critical): emit() already
// respects PRIVATE_TABLES and writes those to data-private/ -- but THIS loop copied from
// respects PRIVATE_TABLES and keeps those out of the repo -- but THIS loop copied from
// out/tables into the BUNDLE's data dir without the same check, so field-access-visas.json
// landed in plugin-designs/key-cities/data/tables/, flowed into the public snapshot AND the
// served catalog, and was fetchable by anonymous clone + live 200. A privacy rule enforced

View file

@ -146,6 +146,7 @@
"check:synced-css": "node internal/check-synced-css-copies.mjs",
"check:composite-reg": "node internal/check-composite-registration.mjs",
"check:filter-width": "node internal/check-filter-width-stable.mjs",
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs # the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes — bare <script>+<doxa-maps-spa> and <iframe> — with the layout inside the host box and every curated tab painting a map. Starts its own servers on OS-assigned ports; needs only a built tree. --mode js|iframe, --only <tab,tab>. Exit 2 = SKIP (no built tree / no playwright).",
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs # the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
"check:mjs-parse": "node internal/check-mjs-parses.mjs",

View file

@ -163,7 +163,10 @@ async function buildFeatures() {
])
const doxaSet = new Set()
for (const pg of doxa) {
const rop = String(pg.peopleGroupId || pg.rop3 || '')
// The prayer API's ROP3 is imb_reg_of_people_3, which sources.json normalises to
// peopleGroupCountry — the API source maps NO peopleGroupId (only the CSV source does). Reading
// peopleGroupId alone left this set empty and the legend read "DOXA people groups 0".
const rop = String(pg.peopleGroupId || pg.rop3 || pg.peopleGroupCountry || '')
if (!rop) continue
doxaSet.add(rop)
if (!doxaBySlugRop.has(rop)) doxaBySlugRop.set(rop, { slug: pg.slug || '', name: pg.name || '' })

View file

@ -968,6 +968,16 @@ function _findNodeInTree(nodes, predicate) {
// and never reached mapStore.selectFamily → applyDimFilter, so the
// dim-on-select / restore-on-deselect path never ran (qa: 2026-05-03 iter-18).
provide('onSemanticTreeSelect', (node) => onSemanticTreeSelect(node))
// The SAME export handler the desktop legend gets via @export, reaching the mobile sheet the
// same way @select does. Without it <LegendMobile>'s embedded SemanticTreeLegend rendered with
// exportEnabled defaulting to FALSE, so a phone reader could browse the countries and had no
// way to export a poster or a packet — measured 2026-09-24, the same gap fixed on
// my-upg-100-list, with the same two causes (no export wiring, and no :tabs so there was no
// selectable row to export in the first place).
// ⚠ RESPECTS EXPORT_FEATURE_ENABLED. The desktop passes :export-enabled="EXPORT_FEATURE_ENABLED",
// so providing unconditionally would give the phone an affordance the desktop deliberately
// hides whenever that flag is off.
provide('onLegendExport', EXPORT_FEATURE_ENABLED ? onExport : null)
// Resolves the active tab's strategy → color for a given feature, used by
// useSelectedPin.js so the selection-pin matches the underlying dot color
// (qa: 2026-05-06 user feedback). research-mfe tab strategyKeys already

View file

@ -0,0 +1,78 @@
# Embedding the Research Gallery
`<doxa-maps-spa>` (labeled **Research Gallery**) is the capability-5 app shell: one element whose
tab rail nests every production map. It is built to drop onto somebody else's page. Below,
`TREE` means the URL of the built `doxa-maps/` folder wherever it is served, for example
`https://YOUR-HOST/doxa-maps`.
## Script embed (recommended)
```html
<script src="TREE/doxa-maps-spa/doxa-maps-spa.js"></script>
<doxa-maps-spa></doxa-maps-spa>
```
That is the whole embed. The element finds its tree from its own `<script>` URL, reads
`TREE/manifest.json`, and takes the data API and public map token from the manifest's
`runtime` block. It sizes itself as a block that is `85vh` tall (minimum 480px) and lays out
inside its own box. To change the height, either:
```html
<doxa-maps-spa style="height: 900px"></doxa-maps-spa>
<!-- or, from your stylesheet -->
<style> doxa-maps-spa { --doxa-maps-spa-height: 70vh; } </style>
```
## iframe embed
```html
<iframe src="TREE/doxa-maps-spa/doxa-maps-spa/index.html"
style="width:100%; height:85vh; border:0" allow="fullscreen"></iframe>
```
Add `?tab=<id>` to the `src` to open a specific map.
## Attributes (all optional)
| Attribute | Default | What it does |
|---|---|---|
| `tab` | device default (Big Picture on desktop, Simple Research on phones) | Tab id to open first. Changing it later switches tabs. `?tab=` on the page URL works too. |
| `language` | parent page / saved choice | UI language pushed to every tab (`en`, `es`, `fr`, …). |
| `api-base-url` | manifest `runtime.apiBaseUrl` | Data API. Only applied when the page has not already set `window.MAP_APP_API_URL`. |
| `tk` | manifest `runtime.token` | Mapbox public token. Only fills `window.MAP_TOKEN` when the page has not set one. |
| `token-url` | none | Token endpoint forwarded to each map. |
| `tree-base` | derived from the script URL | Pin the tree root explicitly (needed only when the script is re-hosted apart from its tree). |
| `heading` | `DX-Maps` | Shell title (shown only when the shell header is enabled). |
Tab ids: `dashboard-plus-grid` (Big Picture), `research-map-plus-grid` (Simple Research),
`regions-dashboard`, `upg-100`, `prayer-map` (Prayer Progress), `cities-internet`,
`gospel-resources`, and every Experimental bundle by its bundle name.
## Events
The element dispatches `tab-change` whenever the visible map changes, so the host page can
mirror it into its own URL or analytics:
```js
document.querySelector('doxa-maps-spa')
.addEventListener('tab-change', (e) => console.log(e.detail[0].id, e.detail[0].label))
```
## What the server must send
Both headers are **required** on a host other than the one serving the tree:
- **`Access-Control-Allow-Origin: *` on the whole tree**, including `manifest.json` at its root
and `assets/`. The gallery and its maps `fetch()` these after mounting, and `fetch()` enforces
CORS; a `<script>` tag alone does not. If `manifest.json` lacks the header, every tab shows
"Could not load …".
- **`Content-Security-Policy: frame-ancestors *`** on `doxa-maps-spa/doxa-maps-spa/index.html`,
for the iframe embed only.
`serving/traefik-embed-headers.yaml` has a ready rule for both.
## Prove it
```sh
bun run check:gallery-embed # serves the built tree + a foreign host page, drives every tab in both modes
```

View file

@ -1,13 +1,14 @@
{
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app-shell of the whole DX-maps app. Its single element <doxa-maps-spa> renders a VERTICAL-TAB layout that nests the OTHER plugins as its tabs — tab-1 = the Cat-4 Big Picture (mounted directly via its real <bp-big-picture> tag + its built .js, token-free), the other tabs = each production research map, nested as its OWN full SPA via that map's standalone leaf page (iframe, so each map keeps its own peer-engine + Mapbox-token ladder + profile-config and there is NO customElements.define collision — the one-shot-define hazard is avoided by giving each map its own document). This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it). Filename must be <profile>.meta.json where profile = the root .vue name (doxa-maps-spa). generate-gallery.readSourceTiers() reads this and bakes capability 5 for the plugin, so it appears in the studio manifest as the app-shell (nested-mfe-spa).",
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element <doxa-maps-spa> renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the seven curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's Composed-of panel shows it). Bundles not placed in a section appear under Experimental automatically. This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it).",
"type": "nested-mfe-spa",
"composes": [
"bp-big-picture",
"doxa-research-map",
"doxa-simple-map",
"cities-internet-map",
"gospel-resources-map",
"my-upg-100-list-map"
"dashboard-plus-grid/dashboard-plus-grid",
"research-map-plus-grid/research-map-plus-grid",
"regions-dashboard/regions-mfe",
"my-upg-100-list/upg-100-mfe",
"doxa-simple-map/doxa-simple-map",
"cities-internet/connectivity-map",
"gospel-resources/resources-map"
],
"label": "Research Gallery (vertical-tab app-shell: Big Picture + every research map)"
"label": "Research Gallery (vertical-tab app shell: the seven production maps)"
}

View file

@ -727,8 +727,21 @@ export default defineComponent({
// effectiveLocale below, which prefers this prop, then profile-config, then the manual switcher.
// NOTE: no `locale` PROP here on purpose — `locale` is a data() field (the manual switcher's
// state); a same-named prop+data collides in Vue. The alias lives in profile-config instead.
language: { type: String, default: '' }
language: { type: String, default: '' },
// ── THE EMBED CONTRACT (card DELEGATE-NEW-TAB-REGA-RESEARCH-GALLERY-CAP5-EMBEDDABILITY) ──
// A host that drops <doxa-maps-spa> onto its own page (the DX-life research page) supplies NONE
// of the page globals a generated leaf page bakes — window.MAP_APP_API_URL, window.MAP_TOKEN, a
// correct assets path. Measured 2026-09-24 from a second origin: the shell mounted and 8 of 13
// tabs painted an error card, every data call returning the HOST's HTML ("API returned text/html
// … Server at may be down"). Each of these is therefore settable here, and each defaults from
// the tree's own manifest.json (its `runtime` block), so a bare two-line embed needs no config.
// See EMBED.md beside this file.
apiBaseUrl: { type: String, default: '' },
// Which tab opens first, by tab id (e.g. "upg-100", "gospel-resources"). Lets a host page deep-
// link into one map of the gallery. Falls back to ?tab= on the page URL, then the device default.
tab: { type: String, default: '' }
},
emits: ['tab-change'],
setup() {
// index.js provides the tree base it derived from the running <script> URL. Prop/attr override
// it if present (resolved in `base` below).
@ -764,6 +777,10 @@ export default defineComponent({
// scriptUrls are resolved against THIS, not the document — see resolve(). Empty until a
// manifest lands, which is exactly when there are no scriptUrls to resolve anyway.
manifestBase: '',
// The manifest's `runtime` block — the build's own apiBaseUrl + public map token, published so
// an embed on a foreign page can default to them. {} until a manifest lands (or on an older
// tree that predates the block), in which case only host-supplied values are forwarded.
runtime: {},
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
locale: loadLocale(),
localeOpen: false,
@ -800,6 +817,24 @@ export default defineComponent({
if (!raw) return '' // relative: leaf pages sit beside this bundle's folder
return raw.replace(/\/+$/, '') + '/'
},
// The ABSOLUTE url of the tree's shared asset pool, forwarded to every nested element.
//
// ★ WHY THE SHELL MUST SAY IT. The pool ships INSIDE the tree (<tree>/assets/ — generate-tree's
// assetsBaseFor), but a nested element given no absolute assetsBase falls to
// library/utils/embedBase.js resolveAssetsBase, which climbs to <tree>/../assets — the web-root
// SIBLING pool the publisher does not ship. Measured 2026-09-24: live
// /plugin-assets/doxa-maps/assets/tables/… = 200, /plugin-assets/assets/tables/… = 404, and
// locally the sibling copy lacks internet-by-country.json / gospel-by-country.json, so
// Cities with Internet and Gospel Resources failed in BOTH embed modes. Fonts fared worse: a
// raw './assets' resolved against the shell's own leaf page (…/doxa-maps-spa/doxa-maps-spa/assets).
// An absolute value is ladder rung 1 there, so saying it here fixes every tab at once.
// Precedence: an absolute host value, then the directory the manifest answered from (that IS the
// tree root in both the built and dev trees), then nothing (children keep their own ladder).
assetsBase() {
const v = String(this.cfg.assetsBase || '').trim()
if (/^https?:\/\//i.test(v) || v.startsWith('/')) return v.replace(/\/+$/, '')
return this.manifestBase ? this.manifestBase.replace(/\/+$/, '') + '/assets' : ''
},
// The GROUPED rail model the shared VerticalTabsLayout paints, built from NAV_MODEL (the Driver's
// ported sections/folder) projected over the REAL tabs. Each row's `key` = the tab id (stable),
// which onSelect maps back to the tab index the mount machinery uses — so the sections are pure
@ -883,7 +918,7 @@ export default defineComponent({
this.watchWidth()
// Open the first tab immediately (its map is the one load the SPA pays on open). The shared
// VerticalTabsLayout owns rail open/close + narrow-screen collapse now.
this.select(this.firstVisibleIndex())
this.select(this.initialIndex())
// …then read the manifest in the background: it completes each curated tab (tag + scriptUrl) and
// fills the Experimental folder. Deliberately AFTER select() and never awaited — the rail must
// paint instantly. select() defers the actual mount until scriptUrl exists, and applyManifest
@ -911,19 +946,13 @@ export default defineComponent({
try {
const slot = this._slots && this._slots[this.activeIndex]
const el = slot && slot.firstElementChild
if (el && el.setAttribute) {
// Rebuild the SAME config the mount used, so a language switch cannot silently drop the
// profile/instance/token and re-render a different view in the new language.
const pc = { profile: t.profile || t.id, instanceId: 'dms-' + t.id }
if (t.instance) pc.instanceId = t.instance
if (loc) pc.locale = loc
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
if (this.base) pc.treeBase = this.base
el.setAttribute('profile-config', JSON.stringify(pc))
}
// Rebuild the SAME config the mount used (childConfig), so a language switch cannot silently
// drop the profile/instance/token and re-render a different view in the new language.
if (el && el.setAttribute) el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
} catch { /* non-fatal: the tab keeps its previous language */ }
}
},
// A host re-pointing the `tab` attribute after mount (e.g. from its own nav) switches the gallery.
tab(id) { if (id) this.onSelect(id) }
},
methods: {
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
@ -1039,6 +1068,10 @@ export default defineComponent({
// then append the discovered Experimental tabs — and finally (re)drive the active tab's mount,
// which could not run before its scriptUrl existed.
applyManifest(m) {
// The build's published defaults for a foreign-page embed (apiBaseUrl + public token). Set the
// page globals BEFORE select() below mounts anything — a nested map reads them at init/fetch.
this.runtime = (m && m.runtime && typeof m.runtime === 'object') ? m.runtime : {}
this.applyPageGlobals()
const patch = resolveTabsFromManifest(m, TAB_DEFS)
const activeId = this.activeKey
// PRUNE rather than paint a dead row: a curated tab whose bundle is absent from this tree can
@ -1060,8 +1093,29 @@ export default defineComponent({
this.tabs = kept
// Re-point activeIndex at the SAME tab id — pruning can shift indices under it, and activeIndex
// is what every pane is keyed on. If that tab is gone, fall back to the first visible row.
// A host-requested tab may be one only the manifest could supply (an Experimental bundle), so
// it is honoured here as well as at mount.
const want = this.requestedTab()
const w = want ? this.tabs.findIndex(t => t.id === want) : -1
const i = this.tabs.findIndex(t => t.id === activeId)
this.select(i >= 0 ? i : this.firstVisibleIndex())
if (w >= 0 && !this.isBigPictureDisabled(want) && !this._honouredTab) this.select(w)
else this.select(i >= 0 ? i : this.firstVisibleIndex())
this._honouredTab = true
},
// The tab a HOST asked to open first: the `tab` attribute, then profile-config.tab, then ?tab= on
// the page URL (so an iframe src or a shared link can deep-link). '' when nobody asked.
requestedTab() {
let q = ''
try { q = new URLSearchParams(location.search).get('tab') || '' } catch { /* non-browser */ }
return String(this.tab || this.cfg.tab || q || '').trim()
},
// The index to open on mount: the requested tab when it exists and is enabled at this width,
// otherwise the device default (firstVisibleIndex).
initialIndex() {
const want = this.requestedTab()
const i = want ? this.tabs.findIndex(t => t.id === want) : -1
if (i >= 0) this._honouredTab = true
return (i >= 0 && !this.isBigPictureDisabled(want)) ? i : this.firstVisibleIndex()
},
// Switch UI language. Persist the choice and let the `effectiveLocale` WATCHER apply it — it
// re-sets profile-config on the active tab's element, generically, with no per-tab special case.
@ -1140,6 +1194,9 @@ export default defineComponent({
}
const t = this.tabs[i]
if (!t) return
// Tell the host which map is showing (a CustomEvent 'tab-change' on <doxa-maps-spa>, detail[0] =
// { id, label }), so a host page can mirror it into its own URL or analytics.
if (prevIndex !== i || !this._announced) { this._announced = true; this.$emit('tab-change', { id: t.id, label: t.label }) }
// Not yet completed by the manifest — applyManifest calls select() again once it lands.
if (!t.scriptUrl) return
// A heavy tab's slot only exists after its v-if subtree renders, so wait a tick before mounting
@ -1166,6 +1223,43 @@ export default defineComponent({
// deleted rather than kept "just in case": an unreachable second token path is one more thing to
// keep in sync, and the ?locale= it emitted is precisely what produced the 404 URLs the Driver
// saw (".../index.html?locale=en").
// THE ONE profile-config every nested element receives — built here and nowhere else, so the mount
// and the language watcher can never disagree about what a tab was told.
childConfig(t) {
// `profile` is the manifest's profile name, NOT the tab id: one tag can serve several profiles
// (gospel-resources ships four views through <gospel-resources-map>), so the tab id would select
// the wrong view — or none. instanceId stays tab-scoped so two panes of the same element never
// collide; a preset instance replaces it only where this tree ships one.
const pc = { profile: t.profile || t.id, instanceId: t.instance || ('dms-' + t.id) }
if (this.effectiveLocale) pc.locale = this.effectiveLocale
// TOKEN LADDER. Direct mounting shares THIS document, so a leaf page's baked token does not
// apply — there is no leaf page. Host prop/config first, then the tree's published public token
// (manifest runtime), and when neither exists the element keeps its own ladder (window.MAP_TOKEN
// on the SPA's own leaf page, which bakes it).
const tk = this.tk || this.cfg.tk || this.runtime.token
if (tk) pc.tk = tk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
// PASS THE TREE BASE DOWN — a nested cap-5 shell (or any element that fetches the manifest)
// cannot guess it, and guessing is what produced the original 404 class.
if (this.base) pc.treeBase = this.base
if (this.assetsBase) pc.assetsBase = this.assetsBase
return pc
},
// The PAGE GLOBALS a generated leaf page bakes and a foreign host page does not — set here, and
// ONLY when the page has not set them itself: a host's own value wins, always.
// window.MAP_APP_API_URL — the data API every nested map calls. library/api/apiBaseUrl.js reads
// only this global (there is no profile-config path), so the shell cannot pass it any other way.
// window.MAP_TOKEN — profile-config.tk (childConfig) reaches every element with a ProfileLoader,
// but a BAKED composite mounts its map components directly and reads only this global.
// Measured 2026-09-24: Big Picture (dashboard-plus-grid) logged "No Mapbox access token
// provided" on a foreign page and painted no map, while every other tab took tk from config.
applyPageGlobals() {
if (typeof window === 'undefined') return
const api = (this.apiBaseUrl || this.cfg.apiBaseUrl || this.runtime.apiBaseUrl || '').trim()
if (api && !window.MAP_APP_API_URL) window.MAP_APP_API_URL = api.replace(/\/+$/, '')
const tk = this.tk || this.cfg.tk || this.runtime.token
if (tk && !window.MAP_TOKEN) window.MAP_TOKEN = tk
},
// Mount a tab's REAL custom element: inject the bundle's script once, wait for the tag to be
// registered, then create it in the pane's empty slot. This is the ONE path for every tab.
mountElement(i) {
@ -1190,26 +1284,7 @@ export default defineComponent({
// Remove the loading hint, insert the real element with its profile-config.
slot.innerHTML = ''
const el = document.createElement(t.tag)
// `profile` is the manifest's profile name, NOT the tab id: one tag can serve several
// profiles (gospel-resources ships four views through <gospel-resources-map>), so the tab
// id would select the wrong view — or none. instanceId stays tab-scoped so two panes of the
// same element never collide.
const pc = { profile: t.profile || t.id, instanceId: 'dms-' + t.id }
// A preset instance, only where this tree actually ships one (see resolveTabsFromManifest).
if (t.instance) pc.instanceId = t.instance
// Honor the effective language on first mount too (not only on a later manual switch), so a
// tab opened with an inherited `language` prop already renders in that language.
if (this.effectiveLocale) pc.locale = this.effectiveLocale
// TOKEN LADDER. Direct mounting shares THIS document, so a nested map reads the page's
// window.MAP_TOKEN — the leaf pages' baked token no longer applies, because there are no
// leaf pages. Forward whatever the host handed us; each element keeps its own full ladder
// when we pass nothing.
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
// PASS THE TREE BASE DOWN — a nested cap-5 shell (or any element that fetches the manifest)
// cannot guess it, and guessing is what produced the original 404 class.
if (this.base) pc.treeBase = this.base
el.setAttribute('profile-config', JSON.stringify(pc))
el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
// ★ ALSO SET tree-base AS A PLAIN ATTRIBUTE, not only inside profile-config. Not every
// element reads profile-config: an element that mounts its OWN SFC has no ProfileLoader
// above it and therefore no provide('profileConfig'), so it takes tree-base as a prop
@ -1247,6 +1322,20 @@ export default defineComponent({
</script>
<style>
/* ★ THE HOST BOX — what makes a bare <doxa-maps-spa> embeddable on someone else's page.
The shared layout's root is `.vtl { position:absolute; inset:0 }`, and a custom element is
display:INLINE with no positioning by default. So on any page that did not happen to wrap the tag
in a positioned box, .vtl anchored to the nearest positioned ANCESTOR — usually the viewport —
and the gallery painted over the host's own header. Measured 2026-09-24 on a foreign test page:
the element's box sat at (59,100) 1200x760 while .vtl drew at (0,0) 1400x760, covering the site
header. The generated leaf page never showed it because it pins the tag `position:fixed`.
:host makes the element its OWN containing block with a real height, so the gallery always lives
inside the box the host placed it in. The height is a DEFAULT, not a lock: a host's own
style="height:…" (or any page rule) outranks :host, and --doxa-maps-spa-height tunes it without
CSS specificity games. */
:host { display: block; position: relative; height: var(--doxa-maps-spa-height, 85vh); min-height: 480px; }
:host([hidden]) { display: none; }
/* The shell (top bar + rail + content frame) is the shared VerticalTabsLayout. Only the PANE-local
styles for this SPA's own tab content remain here — panes fill the shell's content slot, and the
element slot / loading hint each fill their pane.

View file

@ -36,7 +36,7 @@ const TABS = [
{ id: 'languages', label: 'By Country', file: 'languages-by-country' },
{ id: 'operators', label: 'ISPs & Carriers', file: 'network-operators' },
{ id: 'messaging', label: 'Messaging Apps', file: 'messaging-channels' },
// Field-access/visa data is PRIVATE (data-private/ — release governance):
// Field-access/visa data is PRIVATE (kept outside the repo — release governance):
// publishing it could accelerate access closures. Tab removed from the
// public build; the dataset stays available for internal analysis.
]

View file

@ -8,7 +8,9 @@
ONE element, <plugin-studio>, renders a VERTICAL-TAB layout whose sidebar is the composability
ladder grouped by CAPABILITY (5 → 1). Under each capability heading it lists every element that
tier holds; clicking a row nests that element in the main slot. This is the DX viewing surface:
tier holds; clicking a row nests that element in the main slot. Below the tiers, one collapsible
"Inside <composite>" section per composite draws its full composition tree, so a defect can be
pinned on the exact nested element responsible. This is the DX viewing surface:
the spot to SEE every plugin element work, in one place, ordered by capability, with the embed
snippet for each — so a developer (or the Driver) can review each element and give feedback.
@ -123,13 +125,9 @@
<button type="button" class="ps-composed-row" :data-child-key="c.key"
:aria-label="'Open ' + c.label + ', capability ' + c.cap + ', ' + c.capLabel"
@click="select(c.key)">
<!-- ★ NO EMOJI GLYPH HERE, DELIBERATELY. MEASURED 2026-09-23 on this box: 🔗 and 🧩
measure EXACTLY the same width as U+10FFFD (10.2px, vs 11.6px for "A") — i.e. no
emoji font is installed and every CAP_EMOJI renders as an empty tofu box. The
rail has always had this (it is not caused by this panel and its fix belongs to
whoever owns CAP_EMOJI), but there is no reason to propagate a known-blank glyph
into a NEW surface: the capability badge on the right already carries the tier,
and carries it as readable text rather than as decoration. -->
<!-- No glyph here: the capability badge on the right carries the tier as readable
text. (Emoji used to lead these rows and rendered as empty tofu boxes on a box
with no emoji font; the rail now uses local SVG icons — see CAP_ICON.) -->
<span class="ps-composed-name">{{ c.label }}</span>
<span class="ps-composed-tag">&lt;{{ c.tag }}&gt;</span>
<span class="ps-composed-badge" :data-cap="c.cap">CAP {{ c.cap }} · {{ c.capShort }}</span>
@ -292,7 +290,10 @@ const CAP_LABEL = {
// Composition tier (machine key) → capability number. 5 = app-shell … 1 = single.
const PT = { 'nested-mfe-spa': 0, 'nested-elements-mfe': 1, 'monolithic': 2, 'parameterized': 3, 'single': 4 }
const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
const CAP_EMOJI = { 5: '🧩', 4: '🔗', 3: '🗂️', 2: '🎛️', 1: '🔹' }
// Rail row icon per capability: LOCAL inline SVG names (app-wrapper-material/icons.js), not emoji.
// These were emoji until 2026-09-24, and on a box with no emoji font (measured: this one) every rail
// row led with an empty tofu box — the first thing a developer saw on every plugin.
const CAP_ICON = { 5: 'monitor', 4: 'tree', 3: 'layers', 2: 'grid', 1: 'embed' }
// The same five names, shortened to fit a badge beside a child's name in the Composed-of panel.
// Same vocabulary as CAP_LABEL, just the distinguishing word — "App shell", not "App shell ·
// nested-mfe-spa" — because the badge sits inline and the full label would wrap every row.
@ -443,9 +444,11 @@ export default defineComponent({
// ★ COMPOSED OF — the composition model for the ACTIVE plugin only. One entry per DIRECTLY
// nested child. Empty for a leaf, which is how the panel disappears for a non-composite.
//
// ★ WHY THIS IS A PANEL AND NOT A RAIL FOLDER (researched over Storybook, Bit, Backstage, Figma,
// Nx and Omlet, 2026-09-23; this replaces the collapsible composition folders that shipped in
// cb07c55). EVERY mature system SEPARATES THE NAVIGATION TREE FROM THE COMPOSITION GRAPH. None
// ★ THE PANEL IS THE DEPTH-1 VIEW; the rail's composition trees (see compositionTrees) are the
// full-depth one. The trees came back on 2026-09-24 as ALIAS rows — the reasoning below is why
// they must never again be independent nodes.
// ★ WHY COMPOSITION IS NOT SEPARATE NAVIGATION (researched over Storybook, Bit, Backstage, Figma,
// Nx and Omlet, 2026-09-23). EVERY mature system SEPARATES THE NAVIGATION TREE FROM THE COMPOSITION GRAPH. None
// of the six encodes "what composes what" as folder nesting, for one reason: a tree answers
// WHERE DO I FIND IT, and it can only answer that if each thing lives in exactly one place.
// What is INSIDE an entity is a different question and gets its own per-entity surface.
@ -454,8 +457,8 @@ export default defineComponent({
// subfolder of the catalog tree.
// - Storybook: the sidebar is capped at Category / Group / Component — two levels, as ours is.
//
// ★ AND WHY NOT MIRROR A CHILD INTO ITS PARENT'S SECTION. The folders this replaces listed each
// child inside every composite that nested it: people-groups-grid appeared in FIVE rail folders
// ★ AND WHY A MIRRORED CHILD MUST BE AN ALIAS. The cb07c55 folders listed each child inside every
// composite that nested it as its own node: people-groups-grid appeared in FIVE rail folders
// plus its own capability tier — six nav nodes for one plugin. No code was duplicated, but
// IDENTITY was, which is precisely the "I can't name the plugin I'm reporting" problem. These
// rows are LINKS to the one canonical entry (they call select() on the child's real key), so
@ -489,19 +492,10 @@ export default defineComponent({
const refs = b && b.composes
if (!refs || !refs.length) return empty
const unresolved = []
const resolveRef = (ref) => {
const byKey = this.rows.find(r => r.key === ref)
if (byKey) return byKey
const byTag = this.rows.find(r => r.tag === ref)
if (byTag) return byTag
const byBundle = this.rows.find(r => r.bundle === ref)
if (byBundle) return byBundle
return null
}
const seen = new Set()
const out = []
for (const ref of refs) {
const row = resolveRef(ref)
const row = this.resolveRef(ref, a.bundle)
// ★ AN UNRESOLVABLE REF IS COUNTED, NOT SILENTLY SWALLOWED. Measured 2026-09-23:
// big-picture declares composes:["bp-research-header","people-groups-grid"] but
// "bp-research-header" is not a bundle, not any profile's element, and not any row key —
@ -538,24 +532,125 @@ export default defineComponent({
composedUnresolved() { return this.composition.unresolved },
// Unique per active row so the list's aria-labelledby always points at THIS panel's heading.
composedHeadingId() { return 'ps-composed-h' },
// The GROUPED rail model the shared VerticalTabsLayout paints: the capability tiers, and ONLY
// the capability tiers. Every plugin has exactly ONE node, in its own tier. Composition is not
// navigation — it renders in the Composed-of panel above the preview (see composedOf).
// The GROUPED rail model the shared VerticalTabsLayout paints, in two parts (Driver, 2026-09-24:
// "clear capability sections 5→1, every plugin reachable, composition tree legible"):
//
// 1. CAPABILITY SECTIONS, CAP 5 → CAP 1 — where each plugin LIVES. Exactly one node per plugin,
// so every plugin has one name to report it by. Collapsible folders, open by default.
// 2. COMPOSITION TREES — what each composite is MADE OF. One collapsible section per composite
// (closed by default so the rail stays short), headed by the composite itself, with every
// element nested into it drawn as an indented tree, ordered by capability at each level.
// A child repeats in every section that nests it — the Driver's spec: "duplication across
// sections is intended", because the question here is "what composes THIS plugin".
//
// ★ HOW THE REPEATS STAY ONE IDENTITY. Every tree row is an ALIAS (VerticalTabsLayout `selects`):
// clicking people-groups-grid under big-picture selects the SAME canonical row as clicking it in
// CAP 2, highlights both, and shows the same Composed-of panel. afe6abe removed the earlier rail
// folders because six independent nodes for one plugin meant there was no single name to report
// it by; an alias has no separate identity, so the tree and the one-name rule now coexist.
navGroups() {
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const rowModel = (r) => ({
key: r.key,
label: r.label,
emoji: r.emoji,
icon: r.icon,
sub: '<code>&lt;' + esc(r.tag) + '&gt;</code>'
})
return this.groups.map(g => ({
const tiers = this.groups.map(g => ({
key: 'cap-' + g.cap,
badge: 'CAP ' + g.cap,
label: g.label,
count: g.rows.length,
folder: true,
open: true,
rows: g.rows.map(rowModel)
}))
// A composite is named by its BUNDLE here, not its profile: "Inside my-upg-100-list" says which
// plugin you are looking into; "Inside upg-100-mfe" made you already know the answer.
const trees = this.compositionTrees.map(t => ({
key: 'tree-' + t.bundle,
label: 'Inside ' + t.bundle,
count: t.nodes.length - 1,
folder: true,
open: false,
rows: t.nodes.map((n, i) => ({
key: 'tree-' + t.bundle + '-' + i,
// An empty selects (the studio's own head, or a part inside it) still gets a key no row
// has, so it stays an alias: click is a no-op and arrow keys skip it.
selects: n.selects || ('none:' + t.bundle),
depth: n.depth || null,
label: n.label,
badge: n.part ? 'part' : '',
sub: n.part
? 'no standalone page'
: 'CAP ' + n.cap + ' · <code>&lt;' + esc(n.tag) + '&gt;</code>'
}))
}))
// Two row-less plain groups act as part titles, so the eye can tell "where each plugin lives"
// from "what each composite is made of" — the two halves otherwise share one heading style.
const title = (key, label) => ({ key, label, rows: [] })
return [
title('part-caps', 'Every plugin, by capability'), ...tiers,
...(trees.length ? [title('part-trees', 'What each composite is made of'), ...trees] : [])
]
},
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
// or child lists live in this file, so a new composite appears here with zero edits.
//
// Depth is FULL, not 1 (the Composed-of panel is the depth-1 view). The Driver's acceptance test is
// "can I point at a defect and NAME the single element responsible" — and a defect in the grid
// inside big-picture inside this studio is two levels down, so a depth-1 list could not name it.
// A child is expanded only when it is itself a composite (CAP 4+) whose bundle declares edges; a
// leaf PROFILE of a composite bundle (country-posters/countries-map) is a leaf. `path` guards a
// cycle, so a manifest that ever declares A→B→A costs one row, not a hung rail.
//
// A ref that resolves to no published plugin (big-picture's bp-research-header) is KEPT as a
// `part` row pointing at its parent. The Driver's worked example is exactly this split: Big
// Picture = research header on top + people-groups grid below, and he must be able to say which
// half is broken. Dropping the part would hide half of that answer; it cannot be a link to its
// own page because it has none, so it opens the composite it lives in.
compositionTrees() {
const out = []
const walk = (row, depth, path, nodes) => {
const b = this.manifestBundles.find(x => x.bundle === row.bundle)
const refs = (row.cap >= 4 && b && b.composes) || []
const kids = []
const parts = []
const seen = new Set()
for (const ref of refs) {
const child = this.resolveRef(ref, row.bundle)
if (!child) { parts.push(ref); continue }
if (child.key === row.key || seen.has(child.key)) continue
seen.add(child.key)
kids.push(child)
}
kids.sort((a, b2) => b2.cap - a.cap || a.label.localeCompare(b2.label))
for (const ref of parts) {
nodes.push({ label: ref, part: true, selects: row.real ? row.key : '', depth: depth + 1 })
}
for (const k of kids) {
nodes.push({ label: k.label, tag: k.tag, cap: k.cap, selects: k.key, depth: depth + 1 })
if (!path.has(k.key)) walk({ ...k, real: true }, depth + 1, new Set([...path, k.key]), nodes)
}
}
for (const b of this.manifestBundles) {
if (!b.composes || !b.composes.length) continue
// The composite's own row: its highest-capability profile (rows are sorted cap desc).
// ★ plugin-studio HAS NO ROW — it never lists itself as its own tab (mounting <plugin-studio>
// inside itself is infinite regress). Its tree is still the one a developer most wants, so it
// gets a head built from the manifest entry with an empty `selects`: select()'s dead-key guard
// turns a click on it into a no-op rather than a blank pane.
const own = this.rows.find(r => r.bundle === b.bundle)
const p0 = (b.profiles && b.profiles[0]) || {}
const head = own
? { ...own, real: true }
: { key: '', bundle: b.bundle, label: p0.profile || b.bundle, tag: p0.element || b.bundle, cap: capFor(p0.type || 'single'), real: false }
const nodes = [{ label: head.label, tag: head.tag, cap: head.cap, selects: head.key, depth: 0 }]
walk(head, 0, new Set([head.key]), nodes)
if (nodes.length > 1) out.push({ bundle: b.bundle, label: head.label, cap: head.cap, nodes })
}
out.sort((a, b) => b.cap - a.cap || a.bundle.localeCompare(b.bundle))
return out
},
active() { return this.rows.find(r => r.key === this.activeKey) || null },
// ★ SAY WHAT IS BEING COUNTED (2026-09-23). This read "18 bundles · 27 elements" while the
@ -575,6 +670,16 @@ export default defineComponent({
this.loadManifest()
},
methods: {
// Resolve one `composes` entry to a studio row. The manifest's refs are NOT one shape (see
// composition): an element TAG, a "<bundle>/<profile>" row KEY, a BUNDLE name whose tag differs,
// or a bare SIBLING profile name (gospel-resources composes "resources-map", "data-tables" — its
// own profiles). Try sibling → key → tag → bundle; first match wins. null = published nowhere.
resolveRef(ref, fromBundle) {
return (fromBundle && this.rows.find(r => r.key === fromBundle + '/' + ref)) ||
this.rows.find(r => r.key === ref) ||
this.rows.find(r => r.tag === ref) ||
this.rows.find(r => r.bundle === ref) || null
},
setSlot(key, el) { if (el) this._slots = Object.assign(this._slots || {}, { [key]: el }) },
// Switch preview language. languages.js's contract: switching = re-setting profile-config.locale
// on the mounted element. Persist, then re-set it on the element this pane already holds. Every
@ -651,7 +756,7 @@ export default defineComponent({
label: p.profile,
tag,
cap,
emoji: CAP_EMOJI[cap] || '🔹',
icon: CAP_ICON[cap] || 'embed',
description: p.description || '',
heavy,
bundleJs: b.scriptUrl || (b.bundle + '/' + b.bundle + '.js'),
@ -703,7 +808,7 @@ export default defineComponent({
},
select(key) {
// ★ BAIL BEFORE TOUCHING STATE if `key` names no real row. A composition-folder's TOP row can be
// a SYNTHETIC head (see composeGroups) for a composite that cannot list itself as one of its own
// a SYNTHETIC head (see compositionTrees) for a composite that cannot list itself as one of its own
// tabs (plugin-studio nesting <plugin-studio> would be infinite regress — same reasoning
// doxa-maps-spa's NOT_EXPERIMENTAL already documents). Setting activeKey to a dead key BEFORE this
// guard would blank the content pane (flatRows has no matching entry to render) with the "Choose

View file

@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>upg-neighbor (dev)</title>
<link rel="icon" href="data:,">
<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>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#f3f3f1}
@ -12,7 +14,17 @@
</style>
</head>
<body>
<upg-neighbor profile-config='{"profile":"upg-neighbor","legacyElement":"send-map","assetsBase":"/public/assets"}'></upg-neighbor>
<!-- ?tk=<token> and ?view=<lens> (send-map · cockpit · flower-mesh · lang-cluster · flowers) -->
<script>
(function () {
var q = new URLSearchParams(location.search)
var el = document.createElement('upg-neighbor')
el.setAttribute('profile-config', JSON.stringify({
profile: 'upg-neighbor', view: q.get('view') || 'send-map', tk: q.get('tk') || window.MAPBOX_TOKEN || ''
}))
document.body.appendChild(el)
})()
</script>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -1,6 +1,35 @@
/**
* upg-neighbor / index.js — bundle entry (emits upg-neighbor.js), defines <upg-neighbor>.
*
* The standard ProfileLoader shape (see doxa-simple-map/index.js): the glob captures this
* folder's one profile, upg-neighbor.vue; its lenses live in src/. The five preset renders
* are rows of upg-neighbor.instances.json, each opening a different lens via `view`.
*
* Mapbox RTL text plugin: registered ONCE at module init — several SEND targets (Urdu/Arabic) are RTL.
*/
import { defineCustomElement } from 'vue'
import UpgNeighbor from './upg-neighbor.vue'
import { createPinia } from 'pinia'
import ProfileLoader from '@map/ProfileLoader.vue'
import { createAppI18n } from '@map/i18n/index.js'
// One tag, three preset instances (see upg-neighbor.instances.json) — the standard cap-2 shape.
const El = defineCustomElement(UpgNeighbor)
if (!customElements.get('upg-neighbor')) customElements.define('upg-neighbor', El)
const profileModules = import.meta.glob('./*.vue')
if (typeof window !== 'undefined' && window.mapboxgl?.setRTLTextPlugin) {
try {
window.mapboxgl.setRTLTextPlugin(
'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
null, true
)
} catch (_) { /* already set this session */ }
}
const El = defineCustomElement(ProfileLoader, {
configureApp(app) {
app.use(createPinia())
const i18n = createAppI18n()
app.use(i18n)
app.provide('appI18n', i18n)
app.provide('profileModules', profileModules)
}
})
if (typeof customElements !== 'undefined' && !customElements.get('upg-neighbor')) customElements.define('upg-neighbor', El)

View file

@ -0,0 +1,195 @@
<script setup>
/**
* cockpit.vue — Application Profile: My UPG Neighbor · BLOOMFIELD COCKPIT (shell)
*
* @description Single-canvas crossfilter cockpit binding a red harvest MAP, a binned
* radial FLOWER, a sortable sender LIST and a 4-chart STRIP to ONE shared
* filterState store — reds-only by default, greens bloom on select, with a
* geo↔flower morph, language lens, color-by toggle and CSV export.
* @profile bloom-cockpit
* @bundle doxa-upg-neighbor
* @element bloom-cockpit
*
* THE FOUNDATION (this file): header (removable filter chips + search + honest counter +
* provenance pill) · upper row (MAP ~58% + FLOWER ~42% with a MAP⇄FLOWER segmented
* control) · lower row (CHART STRIP ~42% + SENDER LIST ~58%) · legend ribbon docked
* bottom. The four cells are imported from ./cockpit/ and INJECT the shared store
* (provided here). The Cells phase fills the cell bodies; this shell owns the grid,
* the store wiring, and the loading/error states.
*
* 0-DEPENDENCY / SHADOW-DOM SAFE: imports only Vue + @map/useShadowStyles + the local
* cockpit store + the four cells. All CSS via useShadowStyles (no per-cell <style>).
*
* profile-config: { "profile":"cockpit", "tk":"pk...", "apiBase":"https://pray.doxa.life",
* "target":"India", "instanceId":"..." }
*/
import { inject, ref, computed, onMounted, provide } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { createCockpitStore, COLORS } from './cockpit/useCockpit.js'
import MapCell from './cockpit/MapCell.vue'
import FlowerCell from './cockpit/FlowerCell.vue'
import ListCell from './cockpit/ListCell.vue'
import ChartStrip from './cockpit/ChartStrip.vue'
// ─── inject() from ProfileLoader ──────────────────────────────────────────────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const cfg = computed(() => profileConfig?.value || {})
// ─── the ONE shared store — created here, provided to every cell ──────────────
const store = createCockpitStore()
provide('cockpitStore', store)
const rootEl = ref(null)
onMounted(() => {
// seed an optional default target from profile-config (selected after load resolves)
store.loadData({ ...cfg.value, tk: mapboxToken?.value || cfg.value.tk })
// Test-only hook (inert in production — platform never sets debugExpose): expose the
// shared store on the host so a harness can drive interactions deterministically.
if (cfg.value.debugExpose && typeof window !== 'undefined') {
try { (rootEl.value?.getRootNode?.()?.host || window).__cockpit = store } catch (_) {}
}
})
// ─── header helpers ───────────────────────────────────────────────────────────
// NOTE: `store` is a plain object, so its nested refs are NOT auto-unwrapped in the
// template — always read `store.X.value`. (No top-level ref aliases, to avoid the
// auto-unwrap inconsistency between `alias` and `store.alias`.)
function onSearch(e) { store.setSearch(e.target.value) }
function setMode(m) { store.setMode(m) }
// ─── Shadow-DOM CSS (single template; locked palette interpolated, no nested backticks) ─
useShadowStyles(`
.cpt-root { position:absolute; inset:0; display:grid; overflow:hidden;
grid-template-rows: auto minmax(0,1.45fr) minmax(0,1fr) auto;
grid-template-areas: "header" "upper" "lower" "legend";
background:#eef2f7; color:#1f2937; isolation:isolate; container-type:inline-size;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
/* ── HEADER ── */
.cpt-header { grid-area:header; display:flex; flex-wrap:wrap; gap:8px; align-items:center;
padding:8px 12px; background:${COLORS.NAVY}; color:#fff; border-bottom:1px solid #001b44; }
.cpt-brand { font:800 12px/1 ui-monospace,monospace; letter-spacing:.05em; text-transform:uppercase;
padding:6px 10px; background:rgba(255,255,255,.1); border-radius:7px; }
.cpt-chips { display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
.cpt-chip { display:inline-flex; align-items:center; gap:5px; font:600 11px/1 -apple-system,sans-serif;
padding:4px 8px; border-radius:999px; background:#fff; color:${COLORS.NAVY}; cursor:pointer; border:0; }
.cpt-chip.focus { background:${COLORS.GOLD}; color:#3a2600; }
.cpt-chip .x { font-weight:800; opacity:.6; }
.cpt-chip:hover .x { opacity:1; }
.cpt-search { flex:1 1 180px; min-width:120px; max-width:360px; margin-left:auto;
background:#0e1f44; border:1px solid #284a86; color:#dbe4f3; font:600 12px/1 -apple-system,sans-serif;
padding:7px 11px; border-radius:8px; }
.cpt-search::placeholder { color:#7e90b6; }
.cpt-counter { font:700 11px/1.3 ui-monospace,monospace; color:#cdd9f2; text-align:right; white-space:nowrap; }
.cpt-counter b { color:#fff; }
.cpt-prov { font:600 10px/1.3 ui-monospace,monospace; color:#9fb3d9; background:rgba(0,0,0,.25);
border:1px solid #284a86; border-radius:7px; padding:5px 9px; max-width:280px; }
.cpt-prov.err { color:#fecaca; border-color:#9f1239; }
/* ── UPPER ROW: map + flower ── */
.cpt-upper { grid-area:upper; display:grid; grid-template-columns: 58fr 42fr; gap:1px;
background:#cbd5e1; position:relative; min-height:0; }
.cpt-cell { position:relative; overflow:hidden; min-height:0; background:#fff; }
.cpt-seg { position:absolute; top:8px; left:50%; transform:translateX(-50%); z-index:40;
display:inline-flex; background:rgba(255,255,255,.95); border:1px solid #cbd5e1; border-radius:999px;
padding:3px; gap:2px; box-shadow:0 2px 10px rgba(0,0,0,.15); }
.cpt-seg button { border:0; background:transparent; color:#475569; cursor:pointer;
font:700 11px/1 ui-monospace,monospace; padding:6px 12px; border-radius:999px; }
.cpt-seg button.on { background:${COLORS.NAVY}; color:#fff; }
/* ── LOWER ROW: charts + list ── */
.cpt-lower { grid-area:lower; display:grid; grid-template-columns: 42fr 58fr; gap:1px;
background:#cbd5e1; min-height:0; }
/* ── LEGEND RIBBON ── */
.cpt-legend { grid-area:legend; display:flex; flex-wrap:wrap; gap:6px 16px; align-items:center;
padding:6px 12px; background:#0e1730; color:#cdd9f2; font:600 10px/1.4 ui-monospace,monospace;
border-top:1px solid #1c2742; }
.cpt-lg { display:inline-flex; align-items:center; gap:6px; }
.cpt-sw { width:12px; height:12px; border-radius:50%; flex:0 0 auto; border:1px solid rgba(255,255,255,.3); }
.cpt-line { width:18px; height:0; border-top:2px solid; flex:0 0 auto; }
.cpt-ramp { display:inline-flex; gap:0; }
.cpt-ramp i { width:13px; height:11px; display:inline-block; }
/* ── loading / error overlay ── */
.cpt-overlay { position:absolute; inset:0; z-index:80; display:flex; align-items:center;
justify-content:center; background:rgba(238,242,247,.92); color:${COLORS.NAVY};
font:600 13px/1.5 ui-monospace,monospace; text-align:center; padding:24px; }
.cpt-overlay.err { color:${COLORS.RED_DK}; }
/* ── responsive: stack the two cells per row on narrow frames ── */
@container (max-width: 720px) {
.cpt-upper { grid-template-columns: 1fr; grid-template-rows: 1fr; }
.cpt-upper .cpt-cell.flower { display:none; }
.cpt-upper.flower-mode .cpt-cell.map { display:none; }
.cpt-upper.flower-mode .cpt-cell.flower { display:block; }
.cpt-lower { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
.cpt-prov { display:none; }
}
`, 'cockpit-shell')
</script>
<template>
<div ref="rootEl" class="cpt-root">
<!-- HEADER: brand · removable chips · search · honest counter · provenance pill -->
<div class="cpt-header">
<span class="cpt-brand">My UPG Neighbor · Overview</span>
<div class="cpt-chips">
<button v-for="(c, i) in store.chips.value" :key="i" class="cpt-chip" :class="{ focus: c.kind === 'focus' }"
@click="store.removeChip(c)" :title="'remove ' + c.label">
{{ c.label }} <span class="x">×</span>
</button>
</div>
<input class="cpt-search" type="search" :value="store.search.value" @input="onSearch"
placeholder="Search target / sender / language…" aria-label="Search" />
<span class="cpt-counter">
<b>{{ store.counts.value.senders.toLocaleString() }}</b> senders →
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> clusters · {{ store.counts.value.hidden }} hidden
</span>
<span class="cpt-prov" :class="{ err: store.provenance.value.apiError }"
:title="store.provenance.value.reachedFilter">
<template v-if="store.provenance.value.apiError">{{ store.provenance.value.apiError }}</template>
<template v-else>
LIVE {{ store.provenance.value.liveUnreached.toLocaleString() }} unreached · baked senders {{ store.provenance.value.bakedGenerated }}
</template>
</span>
</div>
<!-- UPPER ROW: MAP (hero) + FLOWER, with the MAP⇄FLOWER segmented control -->
<div class="cpt-upper" :class="{ 'flower-mode': store.mode.value === 'flower' }">
<div class="cpt-cell map"><MapCell /></div>
<div class="cpt-cell flower"><FlowerCell /></div>
<div class="cpt-seg" role="tablist" aria-label="Map or flower view">
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">MAP</button>
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">FLOWER</button>
</div>
</div>
<!-- LOWER ROW: CHART STRIP + SENDER LIST -->
<div class="cpt-lower">
<div class="cpt-cell"><ChartStrip /></div>
<div class="cpt-cell"><ListCell /></div>
</div>
<!-- LEGEND RIBBON (one shared key for all four cells) -->
<div class="cpt-legend">
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.RED }"></span> unreached target</span>
<span class="cpt-lg">reached %
<span class="cpt-ramp">
<i v-for="(g, i) in COLORS.GREEN_BINS" :key="i" :style="{ background: g }"></i>
</span>
</span>
<span class="cpt-lg"><span class="cpt-line" :style="{ borderColor: COLORS.NAVY }"></span> SEND (solid=same-lang · dashed=cluster · dotted=in-country)</span>
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> selection · pop = log radius · Luke 10:2</span>
</div>
<!-- loading / error overlay -->
<div v-if="!store.ready.value" class="cpt-overlay">Loading the harvest field…</div>
<div v-else-if="store.loadError.value && !store.allClusters.value.length" class="cpt-overlay err">
{{ store.loadError.value }}
</div>
</div>
</template>

View file

@ -0,0 +1,356 @@
<script setup>
/**
* ChartStrip.vue — cockpit CHART STRIP cell (the crossfilter brushes + the global controls).
*
* Four ZERO-DEP hand-rolled SVG charts read from store.chartData and BRUSH the shared store
* on click, so the map pales, the flower sheds petals, the list contracts and the headline
* recomputes — all four cells breathe together (cockpit / crossfilter):
* 1) sender-language bar → toggleFacet('languages', …)
* 2) sender-country bar → toggleFacet('countries', …)
* 3) reached% quantile hist (5 GREEN bins) → setReachedMin(lower-edge)
* 4) bridge / religion donut → toggleFacet('bridges'|'religions', …) (follows colorBy)
*
* Plus the three global control seams the strip owns:
* • SEARCH with target / language / sender autocomplete → setSearch + selectTarget
* • LANGUAGE LENS picker → setLanguageLens (surfaces the bridge-language asset)
* • COLOR-BY toggle → setColorBy ('pct' depth ⇄ 'religion' family)
*
* 0-DEPENDENCY (vue + @map only). Shadow-DOM safe: ALL CSS via useShadowStyles (a child's own
* <style> is dropped by defineCustomElement). Locked color contract via COLORS / chartData.
*/
import { ref, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useCockpitStore, COLORS, GREEN_BIN_LABELS } from './useCockpit.js'
const store = useCockpitStore()
// ── local UI state (NOT filter state — that all lives in the store) ──────────────
const showSuggest = ref(false)
// ── crossfilter constants ────────────────────────────────────────────────────────
// lower %Christian edge of each quantile bin (matches quantileBin in useCockpit.js)
const BIN_MIN = [0, 10, 25, 50, 75]
const trunc = (s, n) => { const v = String(s ?? ''); return v.length > n ? v.slice(0, n - 1) + '…' : v }
// ── derived chart inputs (all from the shared, crossfilter-live store) ────────────
const cd = computed(() => store.chartData.value)
const donutKind = computed(() => (store.colorBy.value === 'religion' ? 'religions' : 'bridges'))
// distinct sender languages by count — feeds the lens picker + the search autocomplete
const langCounts = computed(() => {
const m = new Map()
for (const s of store.allSenders.value) { const k = s.lang || '—'; m.set(k, (m.get(k) || 0) + 1) }
return [...m.entries()].map(([lang, count]) => ({ lang, count })).sort((a, b) => b.count - a.count)
})
// SEARCH autocomplete: targets → languages → senders (capped) ----------------------
const suggestions = computed(() => {
const q = store.search.value.trim().toLowerCase()
if (q.length < 1) return []
const out = []
for (const c of store.allClusters.value) {
if (c.country.toLowerCase().includes(q)) {
out.push({ type: 'target', label: c.country + ' · ' + c.count + ' unreached', value: c.id, targetId: c.id })
if (out.length >= 6) break
}
}
for (const l of langCounts.value) {
if (l.lang.toLowerCase().includes(q)) { out.push({ type: 'lang', label: l.lang + ' · ' + l.count + ' senders', value: l.lang }); if (out.length >= 9) break }
}
for (const s of store.allSenders.value) {
if (s.name.toLowerCase().includes(q)) { out.push({ type: 'sender', label: s.name + ' → ' + s.country, value: s.name, targetId: s.targetId }); if (out.length >= 13) break }
}
return out.slice(0, 11)
})
// ── BAR layout (horizontal): language + country (shared geometry) ─────────────────
const BAR_VB_W = 300, ROW_H = 15, ROW_GAP = 5, BAR_X = 94, BAR_MAX_W = 150
function barRows(items, kind) {
const set = store.facets.value[kind]
const anyActive = set && set.size > 0
let max = 1
for (const d of items) if (d.value > max) max = d.value
return items.map((d, i) => {
const w = Math.max(2, (d.value / max) * BAR_MAX_W)
const active = set ? set.has(d.key) : false
return {
key: d.key, label: d.label, value: d.value,
y: i * (ROW_H + ROW_GAP), h: ROW_H, x: BAR_X, w,
ty: i * (ROW_H + ROW_GAP) + ROW_H - 4,
vx: BAR_X + w + 4,
active, dim: anyActive && !active
}
})
}
const barVbH = (items) => Math.max(ROW_H, items.length * (ROW_H + ROW_GAP))
const langRows = computed(() => barRows(cd.value.languageBar, 'languages'))
const countryRows = computed(() => barRows(cd.value.countryBar, 'countries'))
// ── reached% histogram (5 vertical GREEN quantile bars) ───────────────────────────
const HIST_VB_W = 230, HIST_VB_H = 120, HIST_TOP = 10, HIST_BOT = 96, HIST_L = 12
const histBars = computed(() => {
const bins = cd.value.reachedHist
let max = 1
for (const b of bins) if (b.value > max) max = b.value
const n = bins.length, slot = (HIST_VB_W - HIST_L - 8) / n, bw = slot * 0.7
const cur = store.facets.value.reachedMin
const plotH = HIST_BOT - HIST_TOP
return bins.map((b, i) => {
const h = (b.value / max) * plotH
const x = HIST_L + i * slot + (slot - bw) / 2
const min = BIN_MIN[i]
const active = cur != null && cur === min
return { key: i, label: GREEN_BIN_LABELS[i], value: b.value, color: b.color, x, w: bw, y: HIST_BOT - h, h, cx: x + bw / 2, min, active, dim: cur != null && cur !== min && min > 0 }
})
})
function onBin(b) {
if (b.min <= 0 || store.facets.value.reachedMin === b.min) store.setReachedMin(null)
else store.setReachedMin(b.min)
}
// ── bridge / religion donut (annular sectors, hand-built arc paths) ────────────────
const DON_CX = 80, DON_CY = 80, DON_RO = 70, DON_RI = 42
function annular(a0, a1) {
const px = (r, a) => DON_CX + r * Math.cos(a)
const py = (r, a) => DON_CY + r * Math.sin(a)
const large = (a1 - a0) > Math.PI ? 1 : 0
return 'M' + px(DON_RO, a0) + ' ' + py(DON_RO, a0) +
' A' + DON_RO + ' ' + DON_RO + ' 0 ' + large + ' 1 ' + px(DON_RO, a1) + ' ' + py(DON_RO, a1) +
' L' + px(DON_RI, a1) + ' ' + py(DON_RI, a1) +
' A' + DON_RI + ' ' + DON_RI + ' 0 ' + large + ' 0 ' + px(DON_RI, a0) + ' ' + py(DON_RI, a0) + ' Z'
}
const donutArcs = computed(() => {
const slices = cd.value.donut
const set = store.facets.value[donutKind.value]
const anyActive = set && set.size > 0
let total = 0
for (const d of slices) total += d.value
total = total || 1
let a = -Math.PI / 2
return slices.map(d => {
let span = (d.value / total) * Math.PI * 2
if (span >= Math.PI * 2) span = Math.PI * 2 - 0.0001
const a0 = a, a1 = a + span
a = a1
const active = set ? set.has(d.key) : false
return { key: d.key, label: d.label, value: d.value, color: d.color, path: annular(a0, a1), active, dim: anyActive && !active }
})
})
const donutTotal = computed(() => donutArcs.value.reduce((s, d) => s + d.value, 0))
// ── interaction handlers ──────────────────────────────────────────────────────────
function onSearchInput(e) { store.setSearch(e.target.value); showSuggest.value = true }
function pickSuggestion(s) {
if (s.type === 'target') { store.selectTarget(s.targetId); store.setSearch('') }
else if (s.type === 'sender') { if (s.targetId) store.selectTarget(s.targetId); store.setSearch(s.value) }
else { store.setSearch(s.value) }
showSuggest.value = false
}
function onLens(e) { store.setLanguageLens(e.target.value || null) }
// ── shadow-DOM-safe styles (a child's own <style> is dropped by defineCustomElement) ──
useShadowStyles(`
.cpt-cs{position:absolute;inset:0;display:flex;flex-direction:column;overflow:auto;
background:#f8fafc;color:${COLORS.NAVY};
font:600 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;}
/* control bar */
.cs-controls{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;align-items:center;
gap:8px;padding:8px 10px;background:rgba(248,250,252,.96);border-bottom:1px solid #e2e8f0;}
.cs-searchwrap{position:relative;flex:1 1 180px;min-width:140px;}
.cs-search{width:100%;box-sizing:border-box;padding:6px 9px;border:1px solid #cbd5e1;border-radius:8px;
background:#fff;color:${COLORS.NAVY};font:600 11px/1.2 ui-monospace,monospace;outline:none;}
.cs-search:focus{border-color:${COLORS.NAVY};box-shadow:0 0 0 2px rgba(0,46,110,.12);}
.cs-suggest{position:absolute;left:0;right:0;top:calc(100% + 3px);z-index:60;margin:0;padding:4px;
list-style:none;background:#fff;border:1px solid #cbd5e1;border-radius:8px;
box-shadow:0 8px 22px rgba(0,0,0,.16);max-height:230px;overflow:auto;}
.cs-suggest li{display:flex;align-items:center;gap:6px;padding:5px 7px;border-radius:6px;cursor:pointer;
font:600 11px/1.25 ui-monospace,monospace;color:${COLORS.NAVY};}
.cs-suggest li:hover{background:#eef2f7;}
.cs-tag{flex:0 0 auto;font:700 8px/1 ui-monospace,monospace;letter-spacing:.5px;color:#fff;
padding:2px 4px;border-radius:4px;background:#94a3b8;}
.cs-suggest li.t-target .cs-tag{background:${COLORS.RED};}
.cs-suggest li.t-lang .cs-tag{background:${COLORS.GOLD};}
.cs-suggest li.t-sender .cs-tag{background:${COLORS.NAVY};}
.cs-lens{display:inline-flex;align-items:center;gap:5px;font:700 10px/1 ui-monospace,monospace;color:#475569;}
.cs-lens select{padding:5px 6px;border:1px solid #cbd5e1;border-radius:7px;background:#fff;
color:${COLORS.NAVY};font:600 10px/1.1 ui-monospace,monospace;max-width:140px;}
.cs-colorby{display:inline-flex;align-items:center;gap:4px;font:700 10px/1 ui-monospace,monospace;color:#475569;}
.cs-colorby button{border:1px solid #cbd5e1;background:#fff;color:#475569;cursor:pointer;
font:700 10px/1 ui-monospace,monospace;padding:5px 8px;border-radius:7px;}
.cs-colorby button.on{background:${COLORS.NAVY};color:#fff;border-color:${COLORS.NAVY};}
/* charts grid */
.cs-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:8px 10px 12px;}
@container (max-width:520px){ .cs-grid{grid-template-columns:1fr;} }
.cs-card{display:flex;flex-direction:column;min-width:0;background:#fff;border:1px solid #e2e8f0;
border-radius:10px;padding:7px 8px;}
.cs-card header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;}
.cs-card h4{margin:0;font:700 10px/1.2 ui-monospace,monospace;letter-spacing:.3px;
text-transform:uppercase;color:#475569;}
.cs-clr{border:0;background:transparent;color:${COLORS.RED};cursor:pointer;
font:700 9px/1 ui-monospace,monospace;padding:2px 4px;}
.cs-clr:hover{text-decoration:underline;}
.cs-svg{width:100%;height:auto;display:block;}
.cs-empty{margin:8px 0;color:#94a3b8;font:600 10px/1 ui-monospace,monospace;text-align:center;}
/* horizontal bars */
.cs-barrow{cursor:pointer;}
.cs-bar{fill:${COLORS.NAVY};transition:opacity .15s;}
.cs-bar--country{fill:#1f5fa8;}
.cs-barrow:hover .cs-bar{fill:${COLORS.GOLD};}
.cs-barrow.active .cs-bar{fill:${COLORS.GOLD};}
.cs-barrow.dim{opacity:.4;}
.cs-blabel{fill:#334155;font:600 9px/1 ui-monospace,monospace;}
.cs-bval{fill:#64748b;font:600 8.5px/1 ui-monospace,monospace;dominant-baseline:alphabetic;}
.cs-barrow.active .cs-blabel,.cs-barrow.active .cs-bval{fill:${COLORS.NAVY};}
/* histogram */
.cs-axis{stroke:#cbd5e1;stroke-width:1;}
.cs-histbar{cursor:pointer;transition:opacity .15s;}
.cs-histbar rect{stroke:#fff;stroke-width:.5;}
.cs-histbar:hover rect{stroke:${COLORS.NAVY};stroke-width:1.5;}
.cs-histbar.active rect{stroke:${COLORS.GOLD};stroke-width:2;}
.cs-histbar.dim{opacity:.4;}
.cs-hval{fill:#334155;font:700 8px/1 ui-monospace,monospace;}
.cs-hlabel{fill:#64748b;font:600 6.5px/1 ui-monospace,monospace;}
/* donut */
.cs-donutwrap{display:flex;align-items:center;gap:8px;}
.cs-donut{flex:0 0 96px;width:96px;height:96px;}
.cs-arc{cursor:pointer;stroke:#fff;stroke-width:1;transition:opacity .15s;}
.cs-arc:hover{stroke:${COLORS.NAVY};stroke-width:1.5;}
.cs-arc.active{stroke:${COLORS.GOLD};stroke-width:2.5;}
.cs-arc.dim{opacity:.4;}
.cs-dctr{fill:${COLORS.NAVY};font:700 16px/1 ui-monospace,monospace;}
.cs-legend{flex:1 1 auto;margin:0;padding:0;list-style:none;max-height:104px;overflow:auto;min-width:0;}
.cs-legend li{display:flex;align-items:center;gap:5px;padding:2px 2px;cursor:pointer;border-radius:4px;
font:600 9.5px/1.3 ui-monospace,monospace;color:#334155;white-space:nowrap;overflow:hidden;}
.cs-legend li:hover{background:#eef2f7;}
.cs-legend li.active{color:${COLORS.NAVY};font-weight:800;}
.cs-legend li.dim{opacity:.45;}
.cs-legend li i{flex:0 0 auto;width:10px;height:10px;border-radius:3px;border:1px solid rgba(0,0,0,.12);}
.cs-legend li b{margin-left:auto;color:#64748b;}
`, 'chartstrip')
</script>
<template>
<div class="cpt-cs">
<!-- ── CONTROL BAR: search (autocomplete) · language lens · color-by ── -->
<div class="cs-controls">
<div class="cs-searchwrap">
<input class="cs-search" type="search" :value="store.search.value"
@input="onSearchInput" @focus="showSuggest = true"
@blur="showSuggest = false"
placeholder="Search target / language / sender…" aria-label="Search and brush" />
<ul v-if="showSuggest && suggestions.length" class="cs-suggest" role="listbox">
<li v-for="(s, i) in suggestions" :key="i" :class="'t-' + s.type" role="option"
@mousedown.prevent="pickSuggestion(s)">
<span class="cs-tag">{{ s.type === 'target' ? 'RED' : s.type === 'lang' ? 'LANG' : 'SEND' }}</span>
{{ s.label }}
</li>
</ul>
</div>
<label class="cs-lens">
<span>Lens</span>
<select :value="store.languageLens.value || ''" @change="onLens" aria-label="Language lens">
<option value="">— no lens —</option>
<option v-for="l in langCounts.slice(0, 40)" :key="l.lang" :value="l.lang">
{{ l.lang }} ({{ l.count }})
</option>
</select>
</label>
<div class="cs-colorby" role="group" aria-label="Color reached senders by">
<span>Color</span>
<button :class="{ on: store.colorBy.value === 'pct' }" @click="store.setColorBy('pct')">% reached</button>
<button :class="{ on: store.colorBy.value === 'religion' }" @click="store.setColorBy('religion')">religion</button>
</div>
</div>
<!-- ── THE 4 CROSSFILTER CHARTS ── -->
<div class="cs-grid">
<!-- 1) sender language bar -->
<section class="cs-card">
<header>
<h4>Sender language</h4>
<button v-if="store.facets.value.languages.size" class="cs-clr"
@click="store.facets.value.languages.forEach(v => store.toggleFacet('languages', v))">clear</button>
</header>
<svg v-if="langRows.length" class="cs-svg" :viewBox="'0 0 ' + BAR_VB_W + ' ' + barVbH(cd.languageBar)"
preserveAspectRatio="xMinYMin meet" role="img" aria-label="Senders by language">
<g v-for="r in langRows" :key="r.key" class="cs-barrow" :class="{ active: r.active, dim: r.dim }"
@click="store.toggleFacet('languages', r.key)">
<text class="cs-blabel" :x="BAR_X - 4" :y="r.ty" text-anchor="end">{{ trunc(r.label, 13) }}</text>
<rect class="cs-bar" :x="r.x" :y="r.y" :width="r.w" :height="r.h" />
<text class="cs-bval" :x="r.vx" :y="r.ty">{{ r.value }}</text>
</g>
</svg>
<p v-else class="cs-empty">no senders</p>
</section>
<!-- 2) sender country bar -->
<section class="cs-card">
<header>
<h4>Sender country</h4>
<button v-if="store.facets.value.countries.size" class="cs-clr"
@click="store.facets.value.countries.forEach(v => store.toggleFacet('countries', v))">clear</button>
</header>
<svg v-if="countryRows.length" class="cs-svg" :viewBox="'0 0 ' + BAR_VB_W + ' ' + barVbH(cd.countryBar)"
preserveAspectRatio="xMinYMin meet" role="img" aria-label="Senders by country">
<g v-for="r in countryRows" :key="r.key" class="cs-barrow cs-barrow--country" :class="{ active: r.active, dim: r.dim }"
@click="store.toggleFacet('countries', r.key)">
<text class="cs-blabel" :x="BAR_X - 4" :y="r.ty" text-anchor="end">{{ trunc(r.label, 13) }}</text>
<rect class="cs-bar cs-bar--country" :x="r.x" :y="r.y" :width="r.w" :height="r.h" />
<text class="cs-bval" :x="r.vx" :y="r.ty">{{ r.value }}</text>
</g>
</svg>
<p v-else class="cs-empty">no senders</p>
</section>
<!-- 3) reached% quantile histogram (5 GREEN bins → reached-floor brush) -->
<section class="cs-card">
<header>
<h4>% reached (quantile)</h4>
<button v-if="store.facets.value.reachedMin != null" class="cs-clr" @click="store.setReachedMin(null)">clear</button>
</header>
<svg class="cs-svg" :viewBox="'0 0 ' + HIST_VB_W + ' ' + HIST_VB_H"
preserveAspectRatio="xMidYMid meet" role="img" aria-label="Reached percent histogram">
<line class="cs-axis" :x1="HIST_L" :y1="HIST_BOT" :x2="HIST_VB_W - 4" :y2="HIST_BOT" />
<g v-for="b in histBars" :key="b.key" class="cs-histbar" :class="{ active: b.active, dim: b.dim }"
@click="onBin(b)">
<rect :x="b.x" :y="b.y" :width="b.w" :height="b.h" :fill="b.color" />
<text class="cs-hval" :x="b.cx" :y="b.y - 3" text-anchor="middle">{{ b.value }}</text>
<text class="cs-hlabel" :x="b.cx" :y="HIST_BOT + 12" text-anchor="middle">{{ b.label }}</text>
</g>
</svg>
</section>
<!-- 4) bridge / religion donut -->
<section class="cs-card">
<header>
<h4>{{ store.colorBy.value === 'religion' ? 'Religion family' : 'Bridge type' }}</h4>
<button v-if="store.facets.value[donutKind].size" class="cs-clr"
@click="store.facets.value[donutKind].forEach(v => store.toggleFacet(donutKind, v))">clear</button>
</header>
<div class="cs-donutwrap">
<svg v-if="donutTotal" class="cs-donut" viewBox="0 0 160 160"
preserveAspectRatio="xMidYMid meet" role="img" aria-label="Donut by category">
<path v-for="d in donutArcs" :key="d.key" class="cs-arc" :class="{ active: d.active, dim: d.dim }"
:d="d.path" :fill="d.color" @click="store.toggleFacet(donutKind, d.key)" />
<text class="cs-dctr" x="80" y="84" text-anchor="middle">{{ donutTotal }}</text>
</svg>
<ul class="cs-legend">
<li v-for="d in donutArcs" :key="d.key" :class="{ active: d.active, dim: d.dim }"
@click="store.toggleFacet(donutKind, d.key)">
<i :style="{ background: d.color }"></i>{{ trunc(d.label, 16) }} <b>{{ d.value }}</b>
</li>
</ul>
</div>
<p v-if="!donutTotal" class="cs-empty">no senders</p>
</section>
</div>
</div>
</template>

View file

@ -0,0 +1,408 @@
<script setup>
/**
* FlowerCell.vue — the BLOOM / MORPH radial flower of the BloomField Cockpit.
*
* Renders the FOCUSED red target at the center and its reached GREEN senders
* (store.visibleSenders, crossfilter-live) as a BINNED radial flower:
* • inner whorl = same-country senders (adjacency === 'same-country')
* • outer petals = cross-border senders (everything else)
* • navy spokes = the SEND path center → each sender (bridgeDash line style)
* • orange chords= same-language collaboration mesh, built by GROUPING (O(n)),
* NEVER the O(n²) all-pairs double loop of the old flower-mesh.
* Petals are capped (~24) with a single "+N more" petal that uncaps in place.
*
* THE SIGNATURE MORPH: every node carries BOTH a geographic position (gx,gy —
* its real lat/lng equirectangular-projected into the viewBox) and a flower
* position (fx,fy — its radial slot). A hand-rolled rAF tween of `morphT`
* (0 = geography, 1 = flower) lerps between them when store.mode toggles, so the
* selected cluster literally "flies off the map into the flower's heart" — and
* snaps instantly when prefers-reduced-motion is set. This REPLACES the dead
* upgn:drill-flower CustomEvent: it is driven purely by the shared store.
*
* 0-DEPENDENCY (vue + @map only). SHADOW-DOM SAFE — ALL css via useShadowStyles
* because a glob-loaded child's own <style> is dropped by defineCustomElement.
*/
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import {
useCockpitStore, COLORS, reachedColor, redColor, logRadius, bridgeDash, fmtPop
} from './useCockpit.js'
const store = useCockpitStore()
// ── geometry constants (square user-unit viewBox) ───────────────────────────
const VB = 1000
const CX = VB / 2
const CY = VB / 2
const R_IN = 212 // inner whorl — same-country
const R_OUT = 372 // outer petals — cross-border
const R_CTR = 48 // center node radius
const INNER_CAP = 8
const OUTER_CAP = 16
// ── local view state (NOT filter state — that lives only in the store) ──────
const morphT = ref(store.mode.value === 'flower' ? 1 : 0) // 0 geo → 1 flower
const showAll = ref(false) // "+N more" uncap
const hoveredId = ref(null)
const hoveredLang = ref('')
const tip = ref({ show: false, x: 0, y: 0, html: '' })
// ── helpers ─────────────────────────────────────────────────────────────────
function polar(cx, cy, r, angleDeg) {
const a = (angleDeg - 90) * Math.PI / 180
return [cx + r * Math.cos(a), cy + r * Math.sin(a)]
}
function langKey(s) { return String((s && (s.rol3 || s.lang)) || '').trim().toLowerCase() }
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
// ── categorical religion-family palette (stable, deterministic) ─────────────
const famColor = computed(() => {
const fams = [...new Set(store.visibleSenders.value.map(s => s.religionFamily || 'Unknown'))].sort()
const m = {}
fams.forEach((f, i) => { m[f] = COLORS.CAT[i % COLORS.CAT.length] })
return m
})
// ── the single layout pass: binned rings + dual (geo/flower) positions + mesh ─
const placed = computed(() => {
const ft = store.focusedTarget.value
if (!ft) return null
// rank by mobilization readiness (strongest × largest) so the cap keeps the best
const ranked = [...store.visibleSenders.value].sort((a, b) => (b.pct - a.pct) || (b.pop - a.pop))
const innerAll = ranked.filter(s => s.adjacency === 'same-country')
const outerAll = ranked.filter(s => s.adjacency !== 'same-country')
const showInner = showAll.value ? innerAll : innerAll.slice(0, INNER_CAP)
const showOuter = showAll.value ? outerAll : outerAll.slice(0, OUTER_CAP)
const hidden = (innerAll.length - showInner.length) + (outerAll.length - showOuter.length)
const hasMore = hidden > 0 && !showAll.value
// colorBy seam: pct-depth green ⇄ religion-family categorical
const fam = famColor.value
const colr = (s) => store.colorBy.value === 'religion'
? (fam[s.religionFamily || 'Unknown'] || COLORS.CAT[0])
: reachedColor(s.pct)
// equirectangular projection params over the shown senders + the target center
const lngs = [], lats = []
for (const s of showInner) { lngs.push(s.lng); lats.push(s.lat) }
for (const s of showOuter) { lngs.push(s.lng); lats.push(s.lat) }
let clng = ft.lng, clat = ft.lat
if (!isFinite(clng) || !isFinite(clat)) {
clng = lngs.length ? lngs.reduce((a, b) => a + b, 0) / lngs.length : 0
clat = lats.length ? lats.reduce((a, b) => a + b, 0) / lats.length : 0
}
lngs.push(clng); lats.push(clat)
const minX = Math.min(...lngs), maxX = Math.max(...lngs)
const minY = Math.min(...lats), maxY = Math.max(...lats)
const w = Math.max(maxX - minX, 0.01), h = Math.max(maxY - minY, 0.01)
const pad = 130
let scale = Math.min((VB - pad * 2) / w, (VB - pad * 2) / h)
if (!isFinite(scale) || scale <= 0) scale = 1
const cx0 = (minX + maxX) / 2, cy0 = (minY + maxY) / 2
const geo = (lng, lat) => [CX + (lng - cx0) * scale, CY - (lat - cy0) * scale]
// ring placement: inner spread 0°, outer spread half-step offset → interleaved flower
const nodes = []
const outerItems = showOuter.map(s => ({ kind: 'node', s }))
if (hasMore) outerItems.push({ kind: 'more' })
const Ni = showInner.length
showInner.forEach((s, k) => {
const ang = Ni ? (k + 0.5) / Ni * 360 : 0
const [fx, fy] = polar(CX, CY, R_IN, ang)
const [gx, gy] = geo(s.lng, s.lat)
nodes.push({ id: s.id, s, isMore: false, ring: 'inner', ang,
r: logRadius(s.pop, 7, 20), color: colr(s), lang: langKey(s), fx, fy, gx, gy })
})
const No = outerItems.length
const offset = No ? 180 / No : 0
outerItems.forEach((it, k) => {
const ang = No ? (k + 0.5) / No * 360 + offset : 0
const [fx, fy] = polar(CX, CY, R_OUT, ang)
if (it.kind === 'more') {
const [gx, gy] = geo(clng, clat)
nodes.push({ id: '__more__', isMore: true, ring: 'outer', ang, r: 18,
color: COLORS.NAVY, lang: '', fx, fy, gx, gy, hidden })
} else {
const s = it.s
const [gx, gy] = geo(s.lng, s.lat)
nodes.push({ id: s.id, s, isMore: false, ring: 'outer', ang,
r: logRadius(s.pop, 7, 20), color: colr(s), lang: langKey(s), fx, fy, gx, gy })
}
})
// center (the unreached red target) — dual position too, so it slides to heart
const [gcx, gcy] = geo(clng, clat)
const center = { fx: CX, fy: CY, gx: gcx, gy: gcy,
country: ft.country, count: ft.count, color: redColor(ft.count) }
// same-language mesh — GROUP then chain consecutive (O(n) total, never O(n²)),
// and only across CURRENTLY-DISPLAYED (i.e. currently-filtered) petals.
const groups = new Map()
for (const n of nodes) {
if (n.isMore || !n.lang) continue
let g = groups.get(n.lang)
if (!g) { g = []; groups.set(n.lang, g) }
g.push(n)
}
const mesh = []
for (const [lang, arr] of groups) {
if (arr.length < 2) continue
for (let i = 0; i < arr.length - 1; i++)
mesh.push({ id: 'mesh-' + arr[i].id + '-' + arr[i + 1].id, from: arr[i].id, to: arr[i + 1].id, lang })
}
return { nodes, center, mesh, hidden, shown: showInner.length + showOuter.length, total: ranked.length }
})
const nodeById = computed(() => {
const m = {}
if (placed.value) for (const n of placed.value.nodes) m[n.id] = n
return m
})
// lit set keyed by hovered language — O(1) per node, never a per-node scan
function isLit(n) { return !hoveredLang.value || (!n.isMore && n.lang === hoveredLang.value) }
function isMeshLit(link) { return !hoveredLang.value || link.lang === hoveredLang.value }
function isFaceted(n) {
return !n.isMore && store.facets.value.languages.has(n.s.lang)
}
// ── morph position accessors (lerp geo→flower by morphT) ─────────────────────
function mx(n) { return n.gx + (n.fx - n.gx) * morphT.value }
function my(n) { return n.gy + (n.fy - n.gy) * morphT.value }
function mcx() { const c = placed.value.center; return c.gx + (c.fx - c.gx) * morphT.value }
function mcy() { const c = placed.value.center; return c.gy + (c.fy - c.gy) * morphT.value }
function spokeD(n) {
return 'M ' + mcx().toFixed(1) + ' ' + mcy().toFixed(1) +
' L ' + mx(n).toFixed(1) + ' ' + my(n).toFixed(1)
}
function meshD(link) {
const a = nodeById.value[link.from], b = nodeById.value[link.to]
if (!a || !b) return ''
const ax = mx(a), ay = my(a), bx = mx(b), by = my(b)
const midx = (ax + bx) / 2, midy = (ay + by) / 2
// curve inward toward heart only as it blooms; straight in geography
const ctrlX = midx + (CX - midx) * 0.45 * morphT.value
const ctrlY = midy + (CY - midy) * 0.45 * morphT.value
return 'M ' + ax.toFixed(1) + ' ' + ay.toFixed(1) +
' Q ' + ctrlX.toFixed(1) + ' ' + ctrlY.toFixed(1) + ' ' + bx.toFixed(1) + ' ' + by.toFixed(1)
}
function dashStr(n) {
if (n.isMore) return '4 6'
const d = bridgeDash(n.s.bridge) // null=solid | [4,3]=dashed | [1,3]=dotted
return d ? d.map(v => v * 3).join(' ') : 'none'
}
function petalXY(n) { return [mx(n), my(n)] }
// ── interactions (drive the SHARED store; never own filter state) ───────────
function onEnter(n, ev) {
hoveredId.value = n.id
hoveredLang.value = n.isMore ? '' : n.lang
if (!n.isMore) { try { store.setHover && store.setHover(n.id) } catch (_) {} } // shared hover bus → list scroll
if (n.isMore) {
tip.value = { show: true, x: ev.clientX, y: ev.clientY,
html: '<strong>+' + n.hidden + ' more senders</strong>' +
'<div class="cf-tip-sub">click to bloom the full roster</div>' }
return
}
const s = n.s
tip.value = { show: true, x: ev.clientX, y: ev.clientY,
html: '<strong>' + esc(s.name) + '</strong>' +
'<div class="cf-tip-sub">' + esc(s.country) + ' · ' + esc(s.lang || '—') + '</div>' +
'<div class="cf-tip-row"><span class="cf-tip-k">reached</span> ' + Math.round(s.pct || 0) + '% Christian</div>' +
'<div class="cf-tip-row"><span class="cf-tip-k">people</span> ' + fmtPop(s.pop) + '</div>' +
'<div class="cf-tip-row"><span class="cf-tip-k">bridge</span> ' + esc(s.bridge || '—') + '</div>' +
'<div class="cf-tip-row"><span class="cf-tip-k">near</span> ' + esc(s.adjacency || '—') + '</div>' }
}
function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value.y = ev.clientY } }
function onLeave() { hoveredId.value = null; hoveredLang.value = ''; try { store.setHover && store.setHover(null) } catch (_) {} ; tip.value = { ...tip.value, show: false } }
function onClick(n) {
if (n.isMore) { showAll.value = true; return }
// crossfilter by this sender's language (same gesture as the chart-strip language bar)
store.toggleFacet('languages', n.s.lang)
}
function toggleBloom() { store.setMode(store.mode.value === 'flower' ? 'map' : 'flower') }
// ── the hand-rolled tween (rAF; prefers-reduced-motion aware) ───────────────
const reduced = typeof window !== 'undefined' && window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
let raf = 0
function animateTo(target) {
cancelAnimationFrame(raf)
if (reduced || typeof requestAnimationFrame === 'undefined') { morphT.value = target; return }
const start = morphT.value
const t0 = performance.now()
const dur = 720
const ease = (p) => p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2
const step = (now) => {
const p = Math.min(1, (now - t0) / dur)
morphT.value = start + (target - start) * ease(p)
if (p < 1) raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
}
watch(() => store.mode.value, (m) => animateTo(m === 'flower' ? 1 : 0))
watch(() => store.focusedTargetId.value, () => { showAll.value = false })
onBeforeUnmount(() => cancelAnimationFrame(raf))
// ── shadow-DOM CSS (single template literal, consts interpolated, no nested backticks) ─
useShadowStyles(
'.cpt-flower{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;isolation:isolate;' +
'background:radial-gradient(circle at 50% 44%, #16203a 0%, #0b1020 72%);color:#e5e7eb;' +
'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;}' +
'.cf-bar{position:absolute;top:10px;left:10px;right:10px;z-index:30;display:flex;gap:8px;align-items:center;' +
'flex-wrap:wrap;pointer-events:none;}' +
'.cf-bar > *{pointer-events:auto;}' +
'.cf-title{background:' + COLORS.NAVY + ';color:#fff;font:700 11px/1 ui-monospace,monospace;letter-spacing:.07em;' +
'text-transform:uppercase;padding:8px 12px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.4);}' +
'.cf-meta{font:600 11px/1.4 ui-monospace,monospace;color:#9fb3d9;background:rgba(8,12,24,.7);' +
'border:1px solid #1c2742;border-radius:8px;padding:6px 10px;}' +
'.cf-meta b{color:#fff;}' +
'.cf-bloom{margin-left:auto;background:#13224a;border:1px solid #3f7bd6;color:#fff;cursor:pointer;' +
'font:700 11px/1 ui-monospace,monospace;letter-spacing:.04em;padding:8px 13px;border-radius:8px;' +
'display:inline-flex;align-items:center;gap:7px;user-select:none;}' +
'.cf-bloom:hover{background:#1b2e5e;}' +
'.cf-bloom .cf-dot{width:8px;height:8px;border-radius:50%;background:' + COLORS.GOLD + ';}' +
'.cf-stage{flex:1 1 0;position:relative;}' +
'.cf-svg{position:absolute;inset:0;width:100%;height:100%;display:block;}' +
'.cf-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;' +
'color:#7e90b6;font:600 12px/1.6 ui-monospace,monospace;padding:24px;}' +
'.cf-petal{transition:opacity .15s;}' +
'.cf-spoke{fill:none;stroke:' + COLORS.NAVY + ';stroke-width:2;opacity:.45;transition:opacity .15s;}' +
'.cf-spoke.lit{opacity:.9;stroke-width:2.6;}' +
'.cf-spoke.dim{opacity:.06;}' +
'.cf-mesh{fill:none;stroke:' + COLORS.GOLD + ';stroke-width:1.6;opacity:.5;stroke-linecap:round;transition:opacity .15s;}' +
'.cf-mesh.lit{opacity:.95;stroke-width:2.6;}' +
'.cf-mesh.dim{opacity:.05;}' +
'.cf-node{cursor:pointer;transition:opacity .15s;}' +
'.cf-node.dim{opacity:.2;}' +
'.cf-node circle{stroke:#0b1020;stroke-width:2;}' +
'.cf-node.hov circle{stroke:#fff;stroke-width:3;}' +
'.cf-node.fac circle{stroke:' + COLORS.GOLD + ';stroke-width:3.5;}' +
'.cf-more circle{fill:' + COLORS.NAVY + ';stroke:#3f7bd6;stroke-width:2;stroke-dasharray:4 4;}' +
'.cf-more text{fill:#cfe0ff;font:700 13px/1 ui-monospace,monospace;text-anchor:middle;}' +
'.cf-label{fill:#cdd9f2;font:600 12px/1 -apple-system,sans-serif;text-anchor:middle;paint-order:stroke;' +
'stroke:#0b1020;stroke-width:3px;pointer-events:none;}' +
'.cf-center-glow{opacity:.18;}' +
'.cf-center circle{stroke:#ffd9bd;stroke-width:3;}' +
'.cf-center-name{fill:#fff;font:800 18px/1 -apple-system,sans-serif;text-anchor:middle;paint-order:stroke;' +
'stroke:' + COLORS.RED_DK + ';stroke-width:3px;}' +
'.cf-center-sub{fill:#ffe1cb;font:600 11px/1 ui-monospace,monospace;text-anchor:middle;}' +
'.cf-rings{position:absolute;left:10px;bottom:10px;z-index:25;background:rgba(8,12,24,.8);' +
'border:1px solid #1c2742;border-radius:8px;padding:7px 10px;font:600 10px/1.5 ui-monospace,monospace;color:#9fb3d9;}' +
'.cf-rings b{color:#dbe4f3;}' +
'.cf-tip{position:fixed;z-index:60;pointer-events:none;max-width:240px;background:#0e1730;border:1px solid #34508c;' +
'border-radius:9px;padding:9px 11px;box-shadow:0 6px 22px rgba(0,0,0,.55);font-size:11.5px;color:#dbe4f3;}' +
'.cf-tip strong{font-size:13px;color:#fff;}' +
'.cf-tip-sub{color:' + COLORS.GOLD + ';font:600 10px/1.3 ui-monospace,monospace;margin:2px 0 6px;}' +
'.cf-tip-row{display:flex;gap:6px;line-height:1.5;}' +
'.cf-tip-k{color:#7e90b6;min-width:54px;}' +
'@media (max-width:720px){.cf-meta{display:none;}}',
'cockpit-flowercell'
)
</script>
<template>
<div class="cpt-flower" @mousemove="onMove">
<!-- header -->
<div class="cf-bar">
<span class="cf-title">Bloom</span>
<span v-if="placed" class="cf-meta">
<b>{{ placed.center.country }}</b> · {{ placed.center.count }} unreached ·
<b>{{ placed.shown }}</b>/{{ placed.total }} senders
</span>
<button class="cf-bloom" type="button" @click="toggleBloom">
<span class="cf-dot"></span>{{ store.mode.value === 'flower' ? 'To map' : 'Bloom' }} ▸
</button>
</div>
<div class="cf-stage">
<!-- empty state -->
<div v-if="!placed" class="cf-empty">
Pick a red target on the map —<br>its reached neighbors bloom here as a flower.
</div>
<svg v-else class="cf-svg" :viewBox="'0 0 ' + VB + ' ' + VB" preserveAspectRatio="xMidYMid meet">
<!-- petal backdrops (fade in as it blooms) -->
<g v-if="morphT > 0.05">
<ellipse v-for="n in placed.nodes" :key="'pt-' + n.id"
v-show="n.ring === 'outer' && !n.isMore"
class="cf-petal"
:cx="petalXY(n)[0]" :cy="petalXY(n)[1]" rx="74" ry="38"
:fill="n.color"
:opacity="0.13 * morphT"
:transform="'rotate(' + n.ang + ' ' + petalXY(n)[0] + ' ' + petalXY(n)[1] + ')'" />
</g>
<!-- SEND spokes: center → each sender -->
<g>
<path v-for="n in placed.nodes" :key="'sp-' + n.id"
class="cf-spoke"
:class="{ lit: hoveredLang && isLit(n), dim: hoveredLang && !isLit(n) }"
:d="spokeD(n)"
:stroke-dasharray="dashStr(n)" />
</g>
<!-- same-language collaboration mesh -->
<g>
<path v-for="m in placed.mesh" :key="m.id"
class="cf-mesh"
:class="{ lit: hoveredLang && isMeshLit(m), dim: hoveredLang && !isMeshLit(m) }"
:d="meshD(m)" />
</g>
<!-- sender nodes + the "+N more" petal -->
<g>
<g v-for="n in placed.nodes" :key="'nd-' + n.id"
class="cf-node"
:class="{ dim: hoveredLang && !isLit(n), hov: hoveredId === n.id || store.hoveredId.value === n.id, fac: isFaceted(n), 'cf-more': n.isMore }"
@click.stop="onClick(n)"
@mouseenter="onEnter(n, $event)" @mouseleave="onLeave">
<template v-if="n.isMore">
<circle :cx="mx(n)" :cy="my(n)" :r="n.r" />
<text :x="mx(n)" :y="my(n) + 4">+{{ n.hidden }}</text>
</template>
<template v-else>
<circle :cx="mx(n)" :cy="my(n)" :r="n.r" :fill="n.color" />
<text v-if="hoveredId === n.id || n.r >= 12"
class="cf-label" :x="mx(n)" :y="my(n) + n.r + 14">{{ n.s.name }}</text>
</template>
</g>
</g>
<!-- center: the unreached red target -->
<g class="cf-center" style="pointer-events:none">
<circle class="cf-center-glow" :cx="mcx()" :cy="mcy()" :r="R_CTR + 26" :fill="placed.center.color" />
<circle :cx="mcx()" :cy="mcy()" :r="R_CTR" :fill="placed.center.color" />
<text class="cf-center-name" :x="mcx()" :y="mcy() - 1">{{ placed.center.country }}</text>
<text class="cf-center-sub" :x="mcx()" :y="mcy() + 16">{{ placed.center.count }} unreached</text>
</g>
</svg>
<!-- ring legend -->
<div v-if="placed" class="cf-rings">
<b>inner</b> same-country · <b>petals</b> cross-border<br>
navy = SEND · <span :style="{ color: COLORS.GOLD }">gold</span> = same-language mesh
</div>
</div>
<!-- tooltip -->
<div v-if="tip.show" class="cf-tip"
:style="{ left: (tip.x + 14) + 'px', top: (tip.y + 14) + 'px' }"
v-html="tip.html"></div>
</div>
</template>

View file

@ -0,0 +1,363 @@
<script setup>
/**
* FlowerClusters.vue — the FLOWER-CLUSTERS schematic (UPGN-FLOWER-CLUSTERS).
*
* Cluster the DOXA unreached people-groups BY LANGUAGE → each cluster is a RED CORE
* (flower center) at the centroid of its member coords. Around each core, the GRANULAR
* green REACHED people-groups of the SAME language (real per-PG PPLR lat/lng) = petals,
* with navy connector lines (one core → many reached = a one-to-many flower).
*
* • Reds CLUSTERED (one core per language group) • Greens GRANULAR (every reached pin)
*
* THREE MODE CIRCLES (top): cluster-by Language-Family · Language · Dialect. Clicking one
* re-forms the cores at that granularity (FAMILY = fewer/bigger flowers, LANGUAGE = more,
* DIALECT = most) and the green petals reconnect.
*
* TAXONOMY SOURCE (read-only): @map/composables/legends/useLanguageFamilyLegendData.js — the
* research-map semantic-tree's family→language→dialect grouping (family derived from the
* language label via langFamilyByLanguage.json; base language = pre-comma; dialect = post-
* comma). We IMPORT it; we never edit the research map.
*
* 0-dep (vue + @map only), shadow-DOM safe. Mapbox via @map/useMapInstance; flower cores +
* green pins + links are GL layers (the basemap needs a token; the data layer is verifiable
* token-less via the gated host hook). Lives inside the doxa-upg-neighbors merged profile
* as the "Flowers" view; reuses the granular per-PG coords from UPGN-MERGE.
*/
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useLanguageFamilyLegendData } from '@map/composables/legends/useLanguageFamilyLegendData.js'
import langFamilyByLanguage from '@map/data/langFamilyByLanguage.json'
import bakedReached from '../data/reached-neighbors.json'
const C_RED = '#e11d48', C_RED_DK = '#9f1239', C_NAVY = '#002e6e', C_GOLD = '#f59e0b'
function reachedColor(pct) {
const p = +pct || 0
if (p >= 80) return '#0f7a3d'; if (p >= 40) return '#1f9d55'
if (p >= 15) return '#5cb874'; return '#9bcf9b'
}
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const cfg = computed(() => profileConfig?.value || {})
const fcRoot = ref(null)
const mapContainer = ref(null)
const appReady = ref(false)
const statusMsg = ref('Clustering the harvest by language…')
const mode = ref('language') // 'family' | 'language' | 'dialect'
const selectedKey = ref(null)
const doxaFeatures = ref([]) // normalized doxa unreached for the taxonomy composable
// ─── taxonomy (read-only research-map composable over the doxa unreached) ─────
// family = derived language family; language = base (pre-comma). For DIALECT (finest)
// we cluster by the FULL language label so EVERY PG forms its finest core — the
// composable's dialectRows only covers PGs with an explicit post-comma dialect (~272),
// which would make "dialect" FEWER than "language", not the most. Full-label grouping
// gives the most flowers (family < language < dialect), as the schematic intends.
const { rows: familyRows, languageRows } =
useLanguageFamilyLegendData(doxaFeatures, { showAllKnown: false })
function unwrapLabel(v) { return (v && typeof v === 'object') ? (v.label || v.value || '') : (v || '') }
const dialectRows = computed(() => {
const m = new Map()
for (const it of doxaFeatures.value) {
const lbl = (unwrapLabel(it.primary_language) || '— Unknown —').trim()
const lat = parseFloat(it.latitude), lng = parseFloat(it.longitude)
let e = m.get(lbl)
if (!e) { e = { key: 'full:' + lbl, label: lbl, color: C_RED, peopleGroupCount: 0, population: 0, coords: [], originalLabels: [lbl] }; m.set(lbl, e) }
e.peopleGroupCount++
e.population += (parseInt(it.population, 10) || 0)
if (isFinite(lat) && isFinite(lng)) e.coords.push([lng, lat])
}
return [...m.values()].sort((a, b) => b.peopleGroupCount - a.peopleGroupCount)
})
// ─── language helpers — MIRROR the composable's pure logic so we can bucket the
// REACHED senders the same way (the composable only buckets the doxa side). ─
function baseLangOf(label) {
if (!label || typeof label !== 'string') return ''
const c = label.indexOf(','); return (c > 0 ? label.slice(0, c) : label).trim()
}
function dialectFullOf(label) { return (label || '').trim() }
function familyOf(label) {
if (!label || typeof label !== 'string') return 'Unknown'
const parts = label.split(',').map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) {
const full = [...parts].reverse().join(' ')
if (langFamilyByLanguage[full]) return langFamilyByLanguage[full]
if (parts.length >= 3) { const two = [parts[1], parts[0]].join(' '); if (langFamilyByLanguage[two]) return langFamilyByLanguage[two] }
}
if (langFamilyByLanguage[label]) return langFamilyByLanguage[label]
const stripped = label.replace(/\s*\(.*?\)\s*$/, '').trim()
if (stripped !== label && langFamilyByLanguage[stripped]) return langFamilyByLanguage[stripped]
if (/ sign language$/i.test(label)) return 'Sign Language'
return 'Unknown'
}
// ─── flat reached senders (real coords) + their taxonomy keys ─────────────────
const reachedAll = computed(() => {
const out = []
const seen = new Set()
for (const t of Object.values(bakedReached.countries || {})) {
for (const n of (t.neighbors || [])) {
const lat = Number(n.lat), lng = Number(n.lng)
if (!isFinite(lat) || !isFinite(lng)) continue
const id = (n.code || n.name) + '|' + n.country
if (seen.has(id)) continue
seen.add(id)
out.push({
id, name: n.name, country: n.country, lang: n.lang, pct: n.pct_christian || 0,
pop: n.pop || 0, lat, lng,
family: familyOf(n.lang), base: baseLangOf(n.lang), full: dialectFullOf(n.lang)
})
}
}
return out
})
// ─── CORES for the active mode (red clusters) ─────────────────────────────────
// Each core: { key, label, level, count, lat, lng, color, matchKeys:Set } where matchKeys
// is the set of values a reached sender must hit (by family/base/full) to be a petal.
function centroid(coords) {
let x = 0, y = 0, n = 0
for (const c of coords || []) { if (Array.isArray(c)) { x += c[0]; y += c[1]; n++ } }
return n ? [x / n, y / n] : null
}
const cores = computed(() => {
const m = mode.value
const src = m === 'family' ? familyRows.value : m === 'dialect' ? dialectRows.value : languageRows.value
const out = []
for (const r of src) {
if (!r.peopleGroupCount) continue
const c = centroid(r.coords)
if (!c) continue
let matchKeys
if (m === 'family') matchKeys = { kind: 'family', set: new Set([r.label]) }
else if (m === 'dialect') matchKeys = { kind: 'full', set: new Set(r.originalLabels || [r.label]) }
else matchKeys = { kind: 'base', set: new Set([r.label]) }
out.push({
key: (r.key || r.label), label: r.label, level: m,
count: r.peopleGroupCount, pop: r.population || 0,
lng: c[0], lat: c[1], color: r.color || C_RED, match: matchKeys
})
}
out.sort((a, b) => b.count - a.count)
return out
})
// reached petals grouped per core (granular). Returns { greens:[{...,coreKey}], links:[{from,to}] }
const bloom = computed(() => {
const greens = [], links = []
const reached = reachedAll.value
for (const core of cores.value) {
const { kind, set } = core.match
for (const r of reached) {
const v = kind === 'family' ? r.family : kind === 'full' ? r.full : r.base
if (!set.has(v)) continue
greens.push({ ...r, coreKey: core.key, color: reachedColor(r.pct) })
links.push({ coreKey: core.key, from: [r.lng, r.lat], to: [core.lng, core.lat] })
}
}
return { greens, links }
})
const stats = computed(() => ({
cores: cores.value.length,
unreached: cores.value.reduce((s, c) => s + c.count, 0),
petals: bloom.value.greens.length
}))
const selectedCore = computed(() => cores.value.find(c => c.key === selectedKey.value) || null)
// ─── Map ──────────────────────────────────────────────────────────────────────
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken?.value || cfg.value.tk || '',
style: cfg.value.style ?? 'mapbox://styles/mapbox/light-v11',
center: cfg.value.center ?? [55, 22], zoom: cfg.value.zoom ?? 1.7, minZoom: 1, maxZoom: 12
})
const SRC_CORE='fc-core-src', SRC_GREEN='fc-green-src', SRC_LINK='fc-link-src'
const L_CORE='fc-core', L_CORE_LBL='fc-core-lbl', L_GREEN='fc-green', L_LINK='fc-link'
const fc = (features) => ({ type: 'FeatureCollection', features })
function coreFeatures() {
return fc(cores.value.map(c => ({
type: 'Feature', geometry: { type: 'Point', coordinates: [c.lng, c.lat] },
properties: { key: c.key, label: c.label + ' (' + c.count + ')', count: c.count, color: c.color }
})))
}
function greenFeatures() {
return fc(bloom.value.greens.map(g => ({
type: 'Feature', geometry: { type: 'Point', coordinates: [g.lng, g.lat] },
properties: { name: g.name, country: g.country, lang: g.lang, pct: g.pct, color: g.color, coreKey: g.coreKey }
})))
}
function linkFeatures() {
return fc(bloom.value.links.map(l => ({
type: 'Feature', geometry: { type: 'LineString', coordinates: [l.from, l.to] }, properties: { coreKey: l.coreKey }
})))
}
function setSrc(m, id, data) { const s = m.getSource(id); if (s) s.setData(data); else m.addSource(id, { type: 'geojson', data }) }
function paint() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
setSrc(m, SRC_LINK, linkFeatures())
setSrc(m, SRC_GREEN, greenFeatures())
setSrc(m, SRC_CORE, coreFeatures())
if (!m.getLayer(L_LINK)) m.addLayer({
id: L_LINK, type: 'line', source: SRC_LINK,
paint: { 'line-color': C_NAVY, 'line-width': 0.6, 'line-opacity': 0.12 }
})
if (!m.getLayer(L_GREEN)) m.addLayer({
id: L_GREEN, type: 'circle', source: SRC_GREEN,
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 1.4, 6, 3], 'circle-color': ['get', 'color'],
'circle-stroke-width': 0.4, 'circle-stroke-color': '#fff', 'circle-opacity': 0.82 }
})
if (!m.getLayer(L_CORE)) m.addLayer({
id: L_CORE, type: 'circle', source: SRC_CORE,
paint: { 'circle-radius': ['interpolate', ['linear'], ['get', 'count'], 1, 8, 25, 18, 120, 30],
'circle-color': C_RED, 'circle-opacity': 0.5, 'circle-stroke-width': 2, 'circle-stroke-color': C_RED_DK }
})
if (!m.getLayer(L_CORE_LBL)) m.addLayer({
id: L_CORE_LBL, type: 'symbol', source: SRC_CORE, minzoom: 2.2,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.4], 'text-anchor': 'top',
'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': C_RED_DK, 'text-halo-color': '#fff', 'text-halo-width': 1.4 }
})
applySelection()
}
function applySelection() {
const m = map.value
if (!m || !m.getLayer(L_LINK)) return
const k = selectedKey.value
// selected core's petals bright; otherwise the whole mesh stays faint
m.setPaintProperty(L_LINK, 'line-opacity', k ? ['case', ['==', ['get', 'coreKey'], k], 0.7, 0.04] : 0.12)
m.setPaintProperty(L_LINK, 'line-color', k ? ['case', ['==', ['get', 'coreKey'], k], C_GOLD, C_NAVY] : C_NAVY)
if (m.getLayer(L_GREEN)) m.setPaintProperty(L_GREEN, 'circle-opacity',
k ? ['case', ['==', ['get', 'coreKey'], k], 0.95, 0.18] : 0.82)
// item 5: clicking a flower (core) DIMS all other flowers to ~0.4 (in the 0.3–0.5
// band), labels kept (L_CORE_LBL is a separate symbol layer, unaffected).
if (m.getLayer(L_CORE)) m.setPaintProperty(L_CORE, 'circle-opacity',
k ? ['case', ['==', ['get', 'key'], k], 0.9, 0.4] : 0.5)
}
function selectCore(key) {
selectedKey.value = (selectedKey.value === key) ? null : key
applySelection()
const c = selectedCore.value, m = map.value
if (c && m) {
const pts = bloom.value.links.filter(l => l.coreKey === c.key).map(l => l.from).concat([[c.lng, c.lat]])
let minLng = c.lng, maxLng = c.lng, minLat = c.lat, maxLat = c.lat
for (const p of pts) { minLng = Math.min(minLng, p[0]); maxLng = Math.max(maxLng, p[0]); minLat = Math.min(minLat, p[1]); maxLat = Math.max(maxLat, p[1]) }
try { m.stop(); m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 90, maxZoom: 5.5, duration: 700 }) } catch (_) {}
}
}
function setMode(md) { if (mode.value !== md) { mode.value = md; selectedKey.value = null } }
watch([mode, () => doxaFeatures.value.length], () => { if (appReady.value) paint() })
// ─── doxa fetch (live unreached) ──────────────────────────────────────────────
async function fetchDoxa() {
const base = cfg.value.apiBase || (typeof window !== 'undefined' && (window.MAP_APP_API_URL || window.VITE_API_BASE_URL)) || 'https://pray.doxa.life'
const fields = 'slug,name,latitude,longitude,country_code,primary_language,wagf_block'
const r = await fetch(base.replace(/\/?$/, '') + '/api/people-groups/list?fields=' + fields + '&lang=en', { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
const posts = j.posts || j.data || j.records || j.results || (Array.isArray(j) ? j : [])
// shape for the taxonomy composable: it reads item.id + item.primary_language/latitude/longitude/population
return posts.map(p => ({ ...p, id: p.slug }))
}
onMounted(async () => {
await initializeMap()
const m = map.value
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
try { doxaFeatures.value = await fetchDoxa() }
catch (e) { statusMsg.value = 'Live doxa API unreachable — ' + e.message; appReady.value = true; return }
appReady.value = true
if (cfg.value.debugExpose && typeof window !== 'undefined') {
try { (fcRoot.value?.getRootNode?.()?.host || window).__flowers = { get mode() { return mode.value }, setMode, get stats() { return stats.value }, selectCore } } catch (_) {}
}
const wire = () => {
paint()
m.on('click', L_CORE, (e) => { const f = e.features?.[0]; if (f) selectCore(f.properties.key) })
m.on('mouseenter', L_CORE, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', L_CORE, () => { m.getCanvas().style.cursor = '' })
}
if (m?.isStyleLoaded?.()) wire(); else m?.once?.('load', wire)
m?.once?.('idle', () => { if (appReady.value) paint() })
})
onBeforeUnmount(() => { destroy() })
useShadowStyles(`
.fc-root { position:absolute; inset:0; overflow:hidden; background:#ffffff; color:#1f2937; isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.fc-map { position:absolute; inset:0; }
.fc-loading { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:${C_NAVY}; font:13px/1 ui-monospace,monospace; z-index:5; }
/* the 3 MODE CIRCLES at the top */
.fc-modes { position:absolute; top:12px; left:50%; transform:translateX(-50%); z-index:30; display:flex; gap:14px; align-items:flex-start; }
.fc-mode { display:flex; flex-direction:column; align-items:center; gap:5px; cursor:pointer; background:none; border:0; }
.fc-circle { width:54px; height:54px; border-radius:50%; background:#fff; border:3px solid #d1d5db; display:flex;
align-items:center; justify-content:center; font:800 11px/1 ui-monospace,monospace; color:#9ca3af; box-shadow:0 2px 10px rgba(0,0,0,.12); transition:all .15s; }
.fc-mode.on .fc-circle { border-color:${C_RED}; color:${C_RED_DK}; transform:scale(1.08); box-shadow:0 4px 16px rgba(225,29,72,.3); }
.fc-mode:hover .fc-circle { border-color:${C_RED}; }
.fc-mode-lbl { font:700 10px/1 ui-monospace,monospace; letter-spacing:.04em; text-transform:uppercase; color:#6b7280; }
.fc-mode.on .fc-mode-lbl { color:${C_RED_DK}; }
.fc-mode-sz { font:600 9px/1 ui-monospace,monospace; color:#9ca3af; }
.fc-stats { position:absolute; top:14px; right:12px; z-index:30; background:rgba(255,255,255,.95); border:1px solid #d1d5db;
border-radius:8px; padding:7px 11px; font:600 11px/1.5 ui-monospace,monospace; color:#374151; text-align:right; }
.fc-stats b { color:${C_RED_DK}; }
.fc-legend { position:absolute; left:12px; bottom:30px; z-index:25; background:rgba(255,255,255,.95); border:1px solid #d1d5db;
border-radius:10px; padding:9px 12px; max-width:250px; box-shadow:0 4px 14px rgba(0,0,0,.12); font-size:11px; color:#374151; }
.fc-legend h4 { font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; text-transform:uppercase; color:#6b7280; margin-bottom:6px; }
.fc-lrow { display:flex; align-items:center; gap:8px; padding:2px 0; }
.fc-dot { width:12px; height:12px; border-radius:50%; border:1px solid rgba(0,0,0,.25); flex:0 0 auto; }
.fc-footer { position:absolute; left:0; right:0; bottom:0; z-index:20; background:${C_NAVY}; color:#dbe4f3;
font:10px/1.4 ui-monospace,monospace; padding:5px 12px; display:flex; flex-wrap:wrap; gap:3px 14px; }
.fc-footer b { color:#fff; }
`, 'flower-clusters')
const MODES = [
{ key: 'family', label: 'Family', sz: 'fewer · bigger' },
{ key: 'language', label: 'Language', sz: 'more' },
{ key: 'dialect', label: 'Dialect', sz: 'most' }
]
</script>
<template>
<div ref="fcRoot" class="fc-root">
<div ref="mapContainer" class="fc-map"></div>
<div v-if="!appReady" class="fc-loading">{{ statusMsg }}</div>
<!-- the 3 cluster-by MODE CIRCLES -->
<div class="fc-modes" role="tablist" aria-label="Cluster by">
<button v-for="md in MODES" :key="md.key" class="fc-mode" :class="{ on: mode === md.key }"
role="tab" :aria-selected="mode === md.key" @click="setMode(md.key)">
<span class="fc-circle">{{ md.label.slice(0,3).toUpperCase() }}</span>
<span class="fc-mode-lbl">{{ md.label }}</span>
<span class="fc-mode-sz">{{ md.sz }}</span>
</button>
</div>
<div v-if="appReady" class="fc-stats">
<b>{{ stats.cores }}</b> flowers · <b>{{ stats.unreached.toLocaleString() }}</b> unreached<br>
<b>{{ stats.petals.toLocaleString() }}</b> reached petals · by {{ mode }}
</div>
<div v-if="appReady" class="fc-legend">
<h4>Flower clusters</h4>
<div class="fc-lrow"><span class="fc-dot" :style="{ background: C_RED }"></span> Unreached language cluster (core, size = #PGs)</div>
<div class="fc-lrow"><span class="fc-dot" :style="{ background: reachedColor(80) }"></span> Reached neighbor (granular, real coord)</div>
<div class="fc-lrow" style="color:#6b7280;font-size:10px;margin-top:4px">click a top circle to re-cluster · click a core to bloom its petals</div>
</div>
<div v-if="appReady" class="fc-footer">
<span><b>Flower-clusters</b> red cores (clustered) → granular green reached petals (same language)</span>
<span>taxonomy = research-map semantic tree (family→language→dialect)</span>
<span>unreached = LIVE pray.doxa.life · reached = baked PPLR</span>
<span>Luke 10:2</span>
</div>
</div>
</template>

View file

@ -0,0 +1,400 @@
<script setup>
/**
* ListCell.vue — cockpit SENDER LIST cell (the send-board mobilization column).
*
* The right-hand roster of the lower row. Reads the shared cockpit store and renders
* the reached senders for the focused red target as a sortable, checkbox-selectable
* send list:
* - Header: "Send N reached neighbors INTO [Target] — they speak [language]"
* (N = TRUE rendered count = store.listRows.length).
* - SENDERS-AVAILABLE badge from focusedTarget.senderCount (+ honest hidden count).
* - Rows: quantile swatch · name · country · lang(rol3) · %Christian · pop ·
* bridge chip (LINE STYLE, never color) · religion chip · tap-to-pin tooltip.
* - Sort toggle (mobilization default · %Christian · pop · name · bridge ·
* "Frontier first").
* - "Build send list" checkboxes → Export-Selection → CSV (store.exportSelectionCSV)
* and a navigator.clipboard copy with the textarea+execCommand fallback (the
* upg-100-list.vue formatClipboard / legacyCopy pattern).
* - Two-way link: hover/click a row → store hover (pulses the map+flower node/arc);
* a map/flower hover scrolls the matching row into view.
*
* 0-DEPENDENCY (vue + @map only). Shadow-DOM safe: ALL CSS via useShadowStyles
* (a child's own <style> is dropped by defineCustomElement). Color/size/bin ONLY via
* the exported helpers; the locked color contract is honored.
*/
import { ref, computed, watch, nextTick } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import {
useCockpitStore, COLORS, GREEN_BIN_LABELS,
reachedColor, quantileBin, bridgeDash, fmtPop
} from './useCockpit.js'
const store = useCockpitStore()
// ── store-derived reads (always via .value — store is a plain object) ─────────
const rows = computed(() => store.listRows.value)
const focusedTarget = computed(() => store.focusedTarget.value)
const counts = computed(() => store.counts.value)
const sortKey = computed(() => store.sortKey.value)
// header language: what the unreached target speaks (the bridge the senders cross)
const targetLang = computed(() => {
const c = focusedTarget.value
if (!c) return ''
const langs = c.target_top_langs && c.target_top_langs.length ? c.target_top_langs : (c.langs || [])
return langs[0] || ''
})
const headerTitle = computed(() => {
const c = focusedTarget.value
if (!c) return 'Pick a red target to build a send list'
const n = rows.value.length
const tail = targetLang.value ? ' — they speak ' + targetLang.value : ''
return 'Send ' + n.toLocaleString() + ' reached neighbor' + (n === 1 ? '' : 's') +
' INTO ' + c.country + tail
})
// SENDERS-AVAILABLE badge: the full roster size for the target (pre-filter), with the
// honest hidden count when facets/search have narrowed the rendered rows.
const availableTotal = computed(() => focusedTarget.value ? (focusedTarget.value.senderCount || 0) : 0)
const hiddenCount = computed(() => counts.value.hidden || 0)
// ── sort options ──────────────────────────────────────────────────────────────
const SORTS = [
{ key: 'mobilization', label: 'Mobilization' },
{ key: 'pct', label: '%Christian' },
{ key: 'pop', label: 'Population' },
{ key: 'bridge', label: 'Bridge' },
{ key: 'name', label: 'Name' },
{ key: 'frontier', label: 'Frontier first' }
]
function pickSort(key) { store.setSort(key) }
// ── batch selection ("Build send list") ──────────────────────────────────────
const selectedIds = ref(new Set())
function toggleRow(id) {
const s = new Set(selectedIds.value)
if (s.has(id)) s.delete(id); else s.add(id)
selectedIds.value = s
copyFlash.value = ''
}
const selectedRows = computed(() => rows.value.filter(r => selectedIds.value.has(r.id)))
const selCount = computed(() => selectedRows.value.length)
const allSelected = computed(() =>
rows.value.length > 0 && rows.value.every(r => selectedIds.value.has(r.id)))
function toggleAll() {
selectedIds.value = allSelected.value ? new Set() : new Set(rows.value.map(r => r.id))
copyFlash.value = ''
}
function clearSelection() { selectedIds.value = new Set(); copyFlash.value = '' }
// rows to act on: the explicit selection if any, else the whole filtered list
const exportRows = computed(() => selCount.value ? selectedRows.value : rows.value)
// ── Export → CSV (store does the injection-safe quoting + provenance + download) ─
function exportCsv() {
if (!exportRows.value.length) return
store.exportSelectionCSV(exportRows.value)
copyFlash.value = 'CSV downloaded (' + exportRows.value.length + ')'
flashClear()
}
// ── Copy (clipboard) — reuse the upg-100-list.vue formatClipboard / legacyCopy path ─
const copyFlash = ref('')
let flashTimer = null
function flashClear() {
if (flashTimer) clearTimeout(flashTimer)
flashTimer = setTimeout(() => { copyFlash.value = '' }, 1900)
}
function formatClipboard(list) {
const c = focusedTarget.value
const head = 'Send list — ' + (c ? c.country : 'all targets') +
(targetLang.value ? ' (' + targetLang.value + ')' : '') +
' · ' + list.length + ' senders'
const lines = list.map(r =>
r.name + ' · ' + r.country + ' · ' + r.lang + (r.rol3 ? ' (' + r.rol3 + ')' : '') +
' · ' + Math.round(r.pct) + '% Christian · ' + fmtPop(r.pop) + ' · ' + r.bridge)
return head + '\n' + lines.join('\n') + '\n'
}
function legacyCopy(text) {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.style.top = '0'
document.body.appendChild(ta)
ta.focus(); ta.select()
const ok = document.execCommand && document.execCommand('copy')
document.body.removeChild(ta)
return !!ok
} catch (_) { return false }
}
async function copyList() {
const list = exportRows.value
if (!list.length) return
const text = formatClipboard(list)
let ok = false
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text); ok = true
} else { ok = legacyCopy(text) }
} catch (_) { ok = legacyCopy(text) }
copyFlash.value = ok ? 'Copied (' + list.length + ')' : 'Copy failed — press Ctrl/Cmd-C'
flashClear()
}
// ── two-way row ↔ map/flower hover link ──────────────────────────────────────
// The store has no dedicated hover field yet (reported for integration). We call/read
// defensively so the link lights up the moment integration adds `hoveredSenderId`
// (ref) + `setHover(id)` (action), and never crashes in the meantime.
function setHover(id) {
if (typeof store.setHover === 'function') store.setHover(id)
}
const hoveredId = computed(() =>
store.hoveredSenderId && typeof store.hoveredSenderId === 'object'
? store.hoveredSenderId.value : null)
const rowsEl = ref(null)
// when a map/flower hover changes the store hover, scroll the matching row into view
watch(hoveredId, (id) => {
if (!id || !rowsEl.value) return
nextTick(() => {
const el = rowsEl.value && rowsEl.value.querySelector('[data-sid="' + cssEscape(id) + '"]')
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' })
})
})
function cssEscape(s) { return String(s).replace(/["\\]/g, '\\$&') }
// row click → pin the hover (focus the node/arc); a re-click clears it
function onRowClick(id) { setHover(hoveredId.value === id ? null : id) }
// ── tap-to-pin tooltip (full sender provenance) ──────────────────────────────
const pinnedInfo = ref(null)
function toggleInfo(id) { pinnedInfo.value = pinnedInfo.value === id ? null : id }
// ── presentation helpers (all binning/coloring via the locked exports) ───────
function swatchColor(pct) { return reachedColor(pct) }
function binLabel(pct) { return GREEN_BIN_LABELS[quantileBin(pct)] }
function bridgeLineStyle(bridge) {
const d = bridgeDash(bridge)
if (d === null) return 'solid'
return d[0] === 4 ? 'dashed' : 'dotted'
}
function pctLabel(pct) { return Math.round(+pct || 0) + '%' }
// ─────────────────────────────────────────────────────────────────────────────
// SHADOW-DOM CSS — single template literal, color consts interpolated, NO nested
// backticks (a stray backtick would close the literal and blank the stylesheet).
// ─────────────────────────────────────────────────────────────────────────────
useShadowStyles(`
.cpt-list{position:absolute;inset:0;display:flex;flex-direction:column;background:#ffffff;
color:${COLORS.NAVY};font:400 12px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
overflow:hidden}
.cpt-list *{box-sizing:border-box}
/* header */
.cpt-l-head{flex:0 0 auto;padding:8px 10px 6px;border-bottom:1px solid #e6eaf2;background:#fbfcfe}
.cpt-l-title{font-size:12.5px;font-weight:700;color:${COLORS.NAVY};line-height:1.35}
.cpt-l-sub{margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;
font-size:10.5px;color:#5b6577}
.cpt-l-badge{display:inline-flex;align-items:center;gap:5px;font-weight:700;font-size:10px;
letter-spacing:.03em;text-transform:uppercase;color:${COLORS.NAVY};
background:rgba(0,46,110,.08);border:1px solid rgba(0,46,110,.18);
padding:2px 7px;border-radius:999px}
.cpt-l-badge b{font-size:11px}
.cpt-l-hidden{color:#9aa3b2}
/* sort row */
.cpt-l-sorts{display:flex;gap:4px;flex-wrap:wrap;margin-top:7px}
.cpt-l-sort{cursor:pointer;border:1px solid #d6dceb;background:#fff;color:#4a5468;
font:600 10px/1 ui-monospace,monospace;padding:5px 8px;border-radius:7px;white-space:nowrap}
.cpt-l-sort:hover{border-color:${COLORS.NAVY};color:${COLORS.NAVY}}
.cpt-l-sort.active{background:${COLORS.NAVY};border-color:${COLORS.NAVY};color:#fff}
.cpt-l-sort.frontier.active{background:${COLORS.GOLD};border-color:${COLORS.GOLD};color:#3a2a00}
/* action bar */
.cpt-l-actions{flex:0 0 auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;
padding:6px 10px;border-bottom:1px solid #eef1f7;background:#fff}
.cpt-l-selall{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;color:#5b6577;
cursor:pointer;user-select:none}
.cpt-l-selcount{font-weight:700;color:${COLORS.NAVY}}
.cpt-l-btn{margin-left:0;cursor:pointer;border:1px solid #cdd5e6;background:#fff;
color:${COLORS.NAVY};font:600 11px/1 ui-monospace,monospace;padding:7px 10px;border-radius:8px}
.cpt-l-btn:hover{border-color:${COLORS.NAVY};background:rgba(0,46,110,.06)}
.cpt-l-btn:disabled{opacity:.45;cursor:not-allowed}
.cpt-l-btn.primary{background:${COLORS.NAVY};border-color:${COLORS.NAVY};color:#fff}
.cpt-l-btn.primary:hover{background:#013a8a}
.cpt-l-btn.ghost{color:#7b8498;border-color:#e0e5f0}
.cpt-l-spacer{flex:1 1 auto}
.cpt-l-flash{font-size:10px;color:#0f7a3d;min-height:12px;white-space:nowrap}
.cpt-l-flash.warn{color:${COLORS.RED}}
/* rows */
.cpt-l-rows{flex:1 1 auto;overflow:auto;padding:2px 0}
.cpt-l-row{display:grid;grid-template-columns:auto 12px 1fr auto;align-items:center;gap:8px;
padding:6px 10px;border-bottom:1px solid #f1f3f8;cursor:pointer;position:relative}
.cpt-l-row:hover,.cpt-l-row.hovered{background:rgba(245,158,11,.10)}
.cpt-l-row.hovered{box-shadow:inset 3px 0 0 0 ${COLORS.GOLD}}
.cpt-l-row.picked{background:rgba(0,46,110,.05)}
.cpt-l-chk{width:15px;height:15px;cursor:pointer;accent-color:${COLORS.NAVY};margin:0}
.cpt-l-swatch{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,.18)}
.cpt-l-body{min-width:0}
.cpt-l-name{font-weight:700;font-size:12px;color:${COLORS.NAVY};
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cpt-l-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:2px;
font-size:10px;color:#6b7587}
.cpt-l-meta .sep{color:#c4ccda}
.cpt-l-lang{font-family:ui-monospace,monospace}
.cpt-l-rol3{color:#9aa3b2}
.cpt-l-right{display:flex;align-items:center;gap:8px;justify-self:end;white-space:nowrap}
.cpt-l-pct{font-weight:700;font-size:11.5px;font-family:ui-monospace,monospace}
.cpt-l-pop{font-size:10px;color:#6b7587;font-family:ui-monospace,monospace;min-width:34px;text-align:right}
/* chips */
.cpt-l-chip{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;
padding:1px 6px;border-radius:999px;border:1px solid #dde3ef;color:#4a5468;background:#f7f9fc;
white-space:nowrap}
.cpt-l-chip.relig{border-color:#e3dcf0;background:#f6f3fc;color:#5b4a86}
.cpt-l-bridgeline{display:inline-block;width:14px;height:0;
border-top:2px solid ${COLORS.NAVY}}
.cpt-l-info{flex:0 0 auto;width:16px;height:16px;border-radius:50%;cursor:pointer;
border:1px solid #d6dceb;background:#fff;color:#7b8498;font:700 10px/14px ui-monospace,monospace;
text-align:center}
.cpt-l-info:hover,.cpt-l-info.on{border-color:${COLORS.NAVY};color:${COLORS.NAVY}}
/* pinned tooltip */
.cpt-l-tip{grid-column:1 / -1;margin-top:6px;padding:7px 9px;border-radius:8px;
background:${COLORS.NAVY};color:#eef3fb;font-size:10px;line-height:1.5}
.cpt-l-tip dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:2px 10px}
.cpt-l-tip dt{color:#9fb6db;font-weight:600}
.cpt-l-tip dd{margin:0;color:#eef3fb;word-break:break-word}
/* empty / placeholder states */
.cpt-l-empty{flex:1 1 auto;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:6px;text-align:center;color:#8a93a5;padding:24px}
.cpt-l-empty .big{font-size:13px;font-weight:700;color:${COLORS.NAVY}}
.cpt-l-empty .sm{font-size:11px}
/* container-query polish for narrow frames */
@container (max-width: 420px){
.cpt-l-pop{display:none}
.cpt-l-meta .relig{display:none}
}
`, 'cockpit-listcell')
</script>
<template>
<div class="cpt-list" style="container-type:inline-size">
<!-- header -->
<div class="cpt-l-head">
<div class="cpt-l-title">{{ headerTitle }}</div>
<div class="cpt-l-sub" v-if="focusedTarget">
<span class="cpt-l-badge">
<b>{{ availableTotal.toLocaleString() }}</b> senders available
</span>
<span v-if="hiddenCount" class="cpt-l-hidden">· {{ hiddenCount.toLocaleString() }} hidden by filters</span>
</div>
<div class="cpt-l-sorts" v-if="focusedTarget">
<button
v-for="s in SORTS"
:key="s.key"
class="cpt-l-sort"
:class="{ active: sortKey === s.key, frontier: s.key === 'frontier' }"
type="button"
@click="pickSort(s.key)"
>{{ s.label }}</button>
</div>
</div>
<!-- action bar -->
<div class="cpt-l-actions" v-if="focusedTarget && rows.length">
<label class="cpt-l-selall">
<input class="cpt-l-chk" type="checkbox" :checked="allSelected" @change="toggleAll" />
Build send list
<span v-if="selCount" class="cpt-l-selcount">({{ selCount }})</span>
</label>
<span class="cpt-l-spacer"></span>
<span class="cpt-l-flash" :class="{ warn: copyFlash.indexOf('fail') !== -1 }">{{ copyFlash }}</span>
<button class="cpt-l-btn ghost" type="button" v-if="selCount" @click="clearSelection">Clear</button>
<button class="cpt-l-btn" type="button" :disabled="!exportRows.length" @click="copyList">Copy</button>
<button class="cpt-l-btn primary" type="button" :disabled="!exportRows.length" @click="exportCsv">
Export {{ selCount ? 'selection' : 'all' }} · CSV
</button>
</div>
<!-- rows -->
<div class="cpt-l-rows" ref="rowsEl" v-if="focusedTarget && rows.length">
<div
v-for="r in rows"
:key="r.id"
class="cpt-l-row"
:class="{ hovered: hoveredId === r.id, picked: selectedIds.has(r.id) }"
:data-sid="r.id"
@mouseenter="setHover(r.id)"
@mouseleave="setHover(null)"
@click="onRowClick(r.id)"
>
<input
class="cpt-l-chk"
type="checkbox"
:checked="selectedIds.has(r.id)"
@click.stop
@change="toggleRow(r.id)"
/>
<span class="cpt-l-swatch" :style="{ background: swatchColor(r.pct) }" :title="binLabel(r.pct)"></span>
<div class="cpt-l-body">
<div class="cpt-l-name" :title="r.name">{{ r.name }}</div>
<div class="cpt-l-meta">
<span>{{ r.country }}</span>
<span class="sep">·</span>
<span class="cpt-l-lang">{{ r.lang }}<span v-if="r.rol3" class="cpt-l-rol3"> ({{ r.rol3 }})</span></span>
<span class="cpt-l-chip" :title="'bridge: ' + r.bridge">
<span class="cpt-l-bridgeline" :style="{ borderTopStyle: bridgeLineStyle(r.bridge) }"></span>
{{ r.bridge }}
</span>
<span class="cpt-l-chip relig" :title="r.religion_bg">{{ r.religionFamily }}</span>
</div>
</div>
<div class="cpt-l-right">
<span class="cpt-l-pct" :style="{ color: swatchColor(r.pct) }">{{ pctLabel(r.pct) }}</span>
<span class="cpt-l-pop">{{ fmtPop(r.pop) }}</span>
<button
class="cpt-l-info"
:class="{ on: pinnedInfo === r.id }"
type="button"
title="Details"
@click.stop="toggleInfo(r.id)"
>i</button>
</div>
<!-- tap-to-pin tooltip -->
<div class="cpt-l-tip" v-if="pinnedInfo === r.id" @click.stop>
<dl>
<dt>People group</dt><dd>{{ r.name }}</dd>
<dt>Country</dt><dd>{{ r.country }}</dd>
<dt>Language</dt><dd>{{ r.lang }}<span v-if="r.rol3"> · {{ r.rol3 }}</span></dd>
<dt>%Christian</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
<dt>Population</dt><dd>{{ fmtPop(r.pop) }}</dd>
<dt>Bridge</dt><dd>{{ r.bridge }}</dd>
<dt>Adjacency</dt><dd>{{ r.adjacency }}</dd>
<dt>Religion</dt><dd>{{ r.religion_bg || r.religionFamily }}</dd>
<dt v-if="r.code">Code</dt><dd v-if="r.code">{{ r.code }}</dd>
</dl>
</div>
</div>
</div>
<!-- empty: target focused but no rows match filters -->
<div class="cpt-l-empty" v-else-if="focusedTarget">
<div class="big">No senders match the current filters</div>
<div class="sm" v-if="hiddenCount">{{ hiddenCount.toLocaleString() }} senders hidden — clear a facet to see them</div>
<div class="sm" v-else>This target has no reached neighbors in the baked set yet</div>
</div>
<!-- placeholder: nothing focused -->
<div class="cpt-l-empty" v-else>
<div class="big">Pick a red target</div>
<div class="sm">{{ counts.clusters.toLocaleString() }} unreached clusters · {{ counts.senders.toLocaleString() }} reached senders nearby</div>
<div class="sm">Tap a red bubble on the map to build its send list.</div>
</div>
</div>
</template>

View file

@ -0,0 +1,481 @@
<script setup>
/**
* MapCell.vue — cockpit MAP cell (the HERO map · "lang-cluster done right").
*
* DEFAULT reds-only: every unreached CLUSTER as a labelled bubble at its real
* centroid, sized by target_upg count, collision-thinned labels, SOLID filled =
* has >=1 sender, HOLLOW ring = frontier (0 senders, never blank).
* SELECT store.selectTarget(id): camera frames it; OTHER reds dim to 0.5 (labels
* kept); its greens fade in at REAL lat/lng (5 quantile bins, or religion when
* colorBy='religion'); navy great-circle SEND arcs (marching-dash, line-style by
* bridge) from each green INTO the red center; same-country senders aggregate into
* ONE 'in-country pool (N)' node.
* PROVINCE store.provinceDrill: overlay admin-1 boundaries (IN/CN sub-division).
*
* READS the shared cockpit store; DRIVES interaction via store actions only — it owns
* no filter state. 0-DEP (vue + @map). SHADOW-DOM SAFE: ALL CSS via useShadowStyles
* (this child's own <style> would be dropped by defineCustomElement).
*/
import { inject, ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useCockpitStore, COLORS, redColor, reachedColor, logRadius, fmtPop } from './useCockpit.js'
const store = useCockpitStore()
// ─── inject() from ProfileLoader (same contract as the sibling profiles) ──────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const cfg = computed(() => profileConfig?.value || {})
const tokenMissing = computed(() => !(mapboxToken?.value || cfg.value.tk))
// ─── refs ─────────────────────────────────────────────────────────────────────
const mcRoot = ref(null)
const mapContainer = ref(null)
const ready = ref(false)
const tip = ref({ show: false, pinned: false, x: 0, y: 0, html: '' })
const STROKE_GREEN = COLORS.GREEN_BINS[3] // ring on reds that have senders
// ─── source / layer ids ────────────────────────────────────────────────────────
const SRC_RED = 'cpt-red-src', SRC_GREEN = 'cpt-green-src', SRC_ARC = 'cpt-arc-src'
const L_RED = 'cpt-red', L_RED_LBL = 'cpt-red-lbl'
const L_GREEN = 'cpt-green', L_GREEN_LBL = 'cpt-green-lbl'
const L_ARC_SOLID = 'cpt-arc-solid', L_ARC_DASH = 'cpt-arc-dash', L_ARC_DOT = 'cpt-arc-dot'
const L_PROV = 'cpt-prov'
// ─── Map instance (shadow-DOM safe; reuses the proven @map composable) ────────
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken?.value || cfg.value.tk || '',
style: cfg.value.style ?? 'mapbox://styles/mapbox/light-v11',
center: cfg.value.center ?? [55, 22],
zoom: cfg.value.zoom ?? 1.7,
minZoom: cfg.value.minZoom ?? 1,
maxZoom: cfg.value.maxZoom ?? 12
})
// ─── religion → stable color (only used when colorBy === 'religion') ──────────
// Deterministic: distinct families of the focused senders, sorted, mapped to CAT.
const religionColorMap = computed(() => {
const fams = [...new Set(store.visibleSenders.value.map(s => s.religionFamily).filter(Boolean))].sort()
const m = {}
fams.forEach((f, i) => { m[f] = COLORS.CAT[i % COLORS.CAT.length] })
return m
})
// ─── geometry helpers ───────────────────────────────────────────────────────────
const fc = (features) => ({ type: 'FeatureCollection', features })
/** Great-circle polyline [lng,lat]→[lng,lat] (n segments) — navy SEND arcs curve. */
function greatCircle(a, b, n = 48) {
const R = Math.PI / 180, D = 180 / Math.PI
const lat1 = a[1] * R, lon1 = a[0] * R, lat2 = b[1] * R, lon2 = b[0] * R
const dLat = (lat2 - lat1) / 2, dLon = (lon2 - lon1) / 2
const h = Math.sin(dLat) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon) ** 2
const d = 2 * Math.asin(Math.min(1, Math.sqrt(h)))
if (!d) return [a, b]
const pts = []
for (let i = 0; i <= n; i++) {
const f = i / n
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2) * Math.cos(lon2)
const y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2) * Math.sin(lon2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
pts.push([Math.atan2(y, x) * D, Math.atan2(z, Math.sqrt(x * x + y * y)) * D])
}
return pts
}
// ─── feature builders (pure; read store derived state) ────────────────────────
function redFeatures() {
const feats = []
for (const c of store.clusters.value) {
if (!isFinite(c.lat) || !isFinite(c.lng)) continue
feats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [c.lng, c.lat] },
properties: {
id: c.id, count: c.count, hasSenders: !!c.hasSenders, senderCount: c.senderCount || 0,
color: redColor(c.count), r: Math.min(34, 6 + Math.sqrt(Math.max(0, c.count)) * 2.4),
label: c.country + ' (' + c.count + ')', country: c.country,
langs: (c.target_top_langs || []).slice(0, 3).join(', ')
}
})
}
return fc(feats)
}
function greenFeatures() {
const feats = []
const byReligion = store.colorBy.value === 'religion'
for (const n of store.mapNodes.value) {
if (!isFinite(n.lng) || !isFinite(n.lat)) continue
let color, r, pctTxt, popTxt, religion
if (n.isPool) {
const members = n.members || []
const mp = members.length ? members.reduce((s, x) => s + (x.pct || 0), 0) / members.length : 0
const pop = members.reduce((s, x) => s + (x.pop || 0), 0)
color = reachedColor(mp); r = logRadius(pop, 7, 26); pctTxt = ''; popTxt = fmtPop(pop); religion = ''
} else {
color = byReligion ? (religionColorMap.value[n.religionFamily] || COLORS.GREEN_BINS[2]) : (n.color || reachedColor(n.pct))
r = logRadius(n.pop); pctTxt = Math.round(n.pct) + '%'; popTxt = fmtPop(n.pop); religion = n.religionFamily || ''
}
feats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [n.lng, n.lat] },
properties: {
id: n.id, name: n.name, label: n.name, color, r, isPool: !!n.isPool,
country: n.country || '', lang: n.lang || '', pct: pctTxt, pop: popTxt, religion,
bridge: n.bridge || ''
}
})
}
return fc(feats)
}
function arcFeatures() {
const t = store.focusedTarget.value
if (!t || !isFinite(t.lat) || !isFinite(t.lng)) return fc([])
const center = [t.lng, t.lat]
const feats = []
for (const n of store.mapNodes.value) {
if (!isFinite(n.lng) || !isFinite(n.lat)) continue
const bridge = n.isPool ? 'same-country' : (n.bridge || 'same-country')
feats.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: greatCircle([n.lng, n.lat], center) },
properties: { bridge }
})
}
return fc(feats)
}
// ─── paint (idempotent; survives style reloads — the lang-cluster pattern) ────
function setSrc(m, id, data) {
const s = m.getSource(id)
if (s) s.setData(data); else m.addSource(id, { type: 'geojson', data })
}
function firstSymbolId(m) {
const layers = (m.getStyle && m.getStyle().layers) || []
const s = layers.find(l => l.type === 'symbol')
return s ? s.id : undefined
}
function ensureLayers(m) {
// SEND arcs (navy) — one layer per bridge LINE STYLE (line-dasharray is not
// data-driven in Mapbox, so the three bridge styles are three filtered layers).
if (!m.getLayer(L_ARC_SOLID)) m.addLayer({
id: L_ARC_SOLID, type: 'line', source: SRC_ARC,
filter: ['==', ['get', 'bridge'], 'same-language'],
layout: { 'line-cap': 'round' },
paint: { 'line-color': COLORS.NAVY, 'line-width': 1.4, 'line-opacity': 0.6 }
})
if (!m.getLayer(L_ARC_DASH)) m.addLayer({
id: L_ARC_DASH, type: 'line', source: SRC_ARC,
filter: ['==', ['get', 'bridge'], 'related-cluster'],
layout: { 'line-cap': 'butt' },
paint: { 'line-color': COLORS.NAVY, 'line-width': 1.4, 'line-opacity': 0.6, 'line-dasharray': [4, 3] }
})
if (!m.getLayer(L_ARC_DOT)) m.addLayer({
id: L_ARC_DOT, type: 'line', source: SRC_ARC,
filter: ['all', ['!=', ['get', 'bridge'], 'same-language'], ['!=', ['get', 'bridge'], 'related-cluster']],
layout: { 'line-cap': 'round' },
paint: { 'line-color': COLORS.NAVY, 'line-width': 1.6, 'line-opacity': 0.6, 'line-dasharray': [1, 3] }
})
// GREEN reached senders (at REAL coords) + in-country pool
if (!m.getLayer(L_GREEN)) m.addLayer({
id: L_GREEN, type: 'circle', source: SRC_GREEN,
paint: {
'circle-color': ['get', 'color'], 'circle-radius': ['max', 2, ['*', ['get', 'r'], 0.55]],
'circle-stroke-width': ['case', ['get', 'isPool'], 2.5, 0.8],
'circle-stroke-color': ['case', ['get', 'isPool'], COLORS.GOLD, '#ffffff'],
'circle-opacity': 0.92, 'circle-opacity-transition': { duration: 350 }
}
})
if (!m.getLayer(L_GREEN_LBL)) m.addLayer({
id: L_GREEN_LBL, type: 'symbol', source: SRC_GREEN, minzoom: 3.4,
layout: { 'text-field': ['get', 'label'], 'text-size': 10, 'text-offset': [0, 1.1],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': '#0b5e33', 'text-halo-color': '#ffffff', 'text-halo-width': 1.3 }
})
// RED unreached clusters (the default field) + collision-thinned labels
if (!m.getLayer(L_RED)) m.addLayer({
id: L_RED, type: 'circle', source: SRC_RED,
paint: {
'circle-color': ['get', 'color'], 'circle-radius': ['get', 'r'],
'circle-opacity': 0.82, 'circle-stroke-width': 2, 'circle-stroke-color': STROKE_GREEN,
'circle-opacity-transition': { duration: 300 }
}
})
if (!m.getLayer(L_RED_LBL)) m.addLayer({
id: L_RED_LBL, type: 'symbol', source: SRC_RED, minzoom: 2.4,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.2],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': COLORS.RED_DK, 'text-halo-color': '#ffffff', 'text-halo-width': 1.4 }
})
}
/** Focus dim/selection: focused red bright, others dim to 0.5 (labels kept). */
function applySelection(m) {
if (!m.getLayer(L_RED)) return
const fid = store.focusedTargetId.value
m.setPaintProperty(L_RED, 'circle-opacity',
fid ? ['case', ['==', ['get', 'id'], fid], 0.95, 0.5]
: ['case', ['get', 'hasSenders'], 0.82, 0.14])
m.setPaintProperty(L_RED, 'circle-stroke-color',
fid ? ['case', ['==', ['get', 'id'], fid], COLORS.GOLD,
['case', ['get', 'hasSenders'], STROKE_GREEN, COLORS.RED_DK]]
: ['case', ['get', 'hasSenders'], STROKE_GREEN, COLORS.RED_DK])
m.setPaintProperty(L_RED, 'circle-stroke-width',
fid ? ['case', ['==', ['get', 'id'], fid], 4, ['case', ['get', 'hasSenders'], 2.4, 1.6]]
: ['case', ['get', 'hasSenders'], 2.4, 1.6])
}
/** PROVINCE drill — overlay admin-1 boundaries (IN/CN sub-division). Best-effort:
* uses the base style's vector 'admin' layer (no province polygons ship on disk,
* so this is a boundary overlay, not a data-keyed choropleth). */
function setProvince(m, on) {
if (on) {
if (!m.getLayer(L_PROV)) {
try {
const before = m.getLayer(L_ARC_SOLID) ? L_ARC_SOLID : firstSymbolId(m)
m.addLayer({
id: L_PROV, type: 'line', source: 'composite', 'source-layer': 'admin',
filter: ['all', ['==', ['get', 'admin_level'], 1], ['==', ['get', 'maritime'], 'false']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': COLORS.NAVY, 'line-width': 0.7, 'line-opacity': 0.45, 'line-dasharray': [2, 2] }
}, before)
} catch (_) { /* base source lacks an admin layer — silent */ }
} else { try { m.setLayoutProperty(L_PROV, 'visibility', 'visible') } catch (_) {} }
} else if (m.getLayer(L_PROV)) {
try { m.setLayoutProperty(L_PROV, 'visibility', 'none') } catch (_) {}
}
}
function paint() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
setSrc(m, SRC_ARC, arcFeatures())
setSrc(m, SRC_GREEN, greenFeatures())
setSrc(m, SRC_RED, redFeatures())
ensureLayers(m)
applySelection(m)
setHoverHighlight(_hoverId)
setProvince(m, store.provinceDrill.value)
if (store.focusedTargetId.value) startMarch(); else stopMarch()
}
// ─── camera framing ─────────────────────────────────────────────────────────────
function frame() {
const m = map.value
if (!m) return
const t = store.focusedTarget.value
if (!t) {
try { m.stop(); m.easeTo({ center: cfg.value.center ?? [55, 22], zoom: cfg.value.zoom ?? 1.7, duration: 700 }) } catch (_) {}
return
}
const pts = []
if (isFinite(t.lat) && isFinite(t.lng)) pts.push([t.lng, t.lat])
for (const n of store.mapNodes.value) if (isFinite(n.lng) && isFinite(n.lat)) pts.push([n.lng, n.lat])
if (!pts.length) return
let minLng = pts[0][0], maxLng = pts[0][0], minLat = pts[0][1], maxLat = pts[0][1]
for (const p of pts) {
minLng = Math.min(minLng, p[0]); maxLng = Math.max(maxLng, p[0])
minLat = Math.min(minLat, p[1]); maxLat = Math.max(maxLat, p[1])
}
try {
m.stop()
if (minLng === maxLng && minLat === maxLat) m.easeTo({ center: [minLng, minLat], zoom: 4.5, duration: 800 })
else m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 80, maxZoom: 6, duration: 800 })
} catch (_) {}
}
// ─── marching-ant dash animation on the SEND arcs (dashed + dotted layers) ────
const DASH_SEQ = [
[0, 4, 3], [0.5, 4, 2.5], [1, 4, 2], [1.5, 4, 1.5], [2, 4, 1], [2.5, 4, 0.5],
[3, 4, 0], [0, 0.5, 4, 2.5], [0, 1, 4, 2], [0, 1.5, 4, 1.5], [0, 2, 4, 1], [0, 2.5, 4, 0.5]
]
const DOT_SEQ = [[1, 3], [0, 0.75, 1, 2.25], [0, 1.5, 1, 1.5], [0, 2.25, 1, 0.75]]
let dashTimer = null, dashIx = 0
function startMarch() {
if (dashTimer) return
dashTimer = setInterval(() => {
const m = map.value
if (!m) return
dashIx++
try {
if (m.getLayer(L_ARC_DASH)) m.setPaintProperty(L_ARC_DASH, 'line-dasharray', DASH_SEQ[dashIx % DASH_SEQ.length])
if (m.getLayer(L_ARC_DOT)) m.setPaintProperty(L_ARC_DOT, 'line-dasharray', DOT_SEQ[dashIx % DOT_SEQ.length])
} catch (_) {}
}, 80)
}
function stopMarch() { if (dashTimer) { clearInterval(dashTimer); dashTimer = null } }
// ─── two-way hover link (map ↔ list/flower) ──────────────────────────────────
// The store does not (yet) expose a shared hover field; we drive a LOCAL highlight
// for immediate map feedback and also call store.setHover(id) / react to
// store.hoveredId IF the foundation later adds them (both calls are no-op-safe).
let _hoverId = null
function setHoverHighlight(id) {
_hoverId = id
const m = map.value
if (!m || !m.getLayer(L_GREEN)) return
m.setPaintProperty(L_GREEN, 'circle-stroke-width',
id ? ['case', ['==', ['get', 'id'], id], 4, ['case', ['get', 'isPool'], 2.5, 1.2]]
: ['case', ['get', 'isPool'], 2.5, 1.2])
m.setPaintProperty(L_GREEN, 'circle-stroke-color',
id ? ['case', ['==', ['get', 'id'], id], COLORS.GOLD, ['case', ['get', 'isPool'], COLORS.GOLD, '#ffffff']]
: ['case', ['get', 'isPool'], COLORS.GOLD, '#ffffff'])
}
// ─── tooltips (hover-pin; HTML built by concatenation — no nested backticks) ──
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
function onMove(ev) { if (tip.value.show && !tip.value.pinned) { tip.value.x = ev.clientX; tip.value.y = ev.clientY } }
function redTipHtml(p) {
return '<strong>' + esc(p.country) + '</strong>'
+ '<div class="cpt-mc-tsub">' + esc(p.count) + ' unreached groups</div>'
+ (p.langs ? '<div class="cpt-mc-trow"><span class="cpt-mc-tk">langs</span> ' + esc(p.langs) + '</div>' : '')
+ '<div class="cpt-mc-trow"><span class="cpt-mc-tk">senders</span> '
+ (p.hasSenders ? esc(p.senderCount) + ' reached nearby' : 'frontier — 0 (pray for laborers)') + '</div>'
+ '<div class="cpt-mc-trow" style="color:#9fb3d9">click → SEND view</div>'
}
function greenTipHtml(p) {
if (p.isPool) return '<strong>' + esc(p.name) + '</strong>'
+ '<div class="cpt-mc-tsub">same-country reached pool</div>'
+ '<div class="cpt-mc-trow"><span class="cpt-mc-tk">pop</span> ' + esc(p.pop) + '</div>'
+ '<div class="cpt-mc-trow" style="color:#9fb3d9">spiderfies in the list</div>'
return '<strong>' + esc(p.name) + '</strong>'
+ '<div class="cpt-mc-tsub">' + esc(p.country) + ' · ' + esc(p.lang) + '</div>'
+ '<div class="cpt-mc-trow"><span class="cpt-mc-tk">reached</span> ' + esc(p.pct) + ' Christian</div>'
+ '<div class="cpt-mc-trow"><span class="cpt-mc-tk">pop</span> ' + esc(p.pop) + '</div>'
+ (p.bridge ? '<div class="cpt-mc-trow"><span class="cpt-mc-tk">bridge</span> ' + esc(p.bridge) + '</div>' : '')
+ (p.religion ? '<div class="cpt-mc-trow"><span class="cpt-mc-tk">religion</span> ' + esc(p.religion) + '</div>' : '')
+ '<div class="cpt-mc-trow" style="color:#9fb3d9">click → pin · highlights the list</div>'
}
// ─── boot ──────────────────────────────────────────────────────────────────────
onMounted(async () => {
await initializeMap()
const m = map.value
if (!m) { return } // no/invalid token — overlay shows the hint, store still drives the rest
try { m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
// Gated test hook (inert in production): lets a harness use the live map.project().
if (cfg.value.debugExpose && typeof window !== 'undefined') {
try { (mcRoot.value?.getRootNode?.()?.host || window).__cockpitmap = m } catch (_) {}
}
const hover = (layer, html) => {
m.on('mouseenter', layer, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', layer, () => { m.getCanvas().style.cursor = ''; if (!tip.value.pinned) tip.value = { ...tip.value, show: false } })
m.on('mousemove', layer, (e) => {
const f = e.features?.[0]; if (!f) return
tip.value = { show: true, x: e.originalEvent.clientX, y: e.originalEvent.clientY, html: html(f.properties) }
})
}
const wire = () => {
paint()
// click a red bubble → select its target (the cockpit's primary interaction)
m.on('click', L_RED, (e) => { const f = e.features?.[0]; if (f) { tip.value = { ...tip.value, show: false, pinned: false }; store.selectTarget(f.properties.id) } })
hover(L_RED, redTipHtml)
hover(L_GREEN, greenTipHtml)
// green hover → local highlight + (no-op-safe) store hover for the two-way link
m.on('mousemove', L_GREEN, (e) => { const f = e.features?.[0]; if (f) { setHoverHighlight(f.properties.id); try { store.setHover && store.setHover(f.properties.id) } catch (_) {} } })
m.on('mouseleave', L_GREEN, () => { setHoverHighlight(null); try { store.setHover && store.setHover(null) } catch (_) {} })
// click a green node → fly to it (pool spiderfies via the list)
// click a green reached pin → PIN its neighbor profile (stays open) + highlight/scroll
// its list row (two-way) + fly to it.
m.on('click', L_GREEN, (e) => {
const f = e.features?.[0]; if (!f) return
const ev = e.originalEvent || {}
tip.value = { show: true, pinned: true, x: ev.clientX || tip.value.x, y: ev.clientY || tip.value.y, html: greenTipHtml(f.properties) }
try { store.setHover && store.setHover(f.properties.id) } catch (_) {}
try { m.stop(); m.easeTo({ center: f.geometry.coordinates, zoom: Math.max(m.getZoom(), 4.5), duration: 700 }) } catch (_) {}
})
ready.value = true
if (store.focusedTargetId.value) frame()
}
if (m.isStyleLoaded?.()) wire(); else m.once?.('load', wire)
// belt-and-suspenders repaint (style timing race — the lang-cluster lesson)
m.once?.('idle', () => { if (ready.value) paint() })
})
// ─── react to store state ───────────────────────────────────────────────────────
watch(() => [store.ready.value, store.clusters.value, store.mapNodes.value, store.colorBy.value, store.provinceDrill.value],
() => paint())
watch(() => store.focusedTargetId.value, () => { paint(); frame() })
watch(() => store.mode.value, () => { nextTick(() => { try { map.value?.resize() } catch (_) {} }) })
// optional incoming half of the two-way hover link (if the store later adds it)
if (store.hoveredId) watch(() => store.hoveredId.value, (id) => setHoverHighlight(id))
onBeforeUnmount(() => { stopMarch(); destroy() })
// ─── Shadow-DOM CSS (single template; palette interpolated; no nested backticks) ─
useShadowStyles(`
.cpt-mc-root { position:absolute; inset:0; overflow:hidden; background:#eef2f7; isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.cpt-mc-map { position:absolute; inset:0; }
.cpt-mc-loading { position:absolute; inset:0; z-index:5; display:flex; align-items:center; justify-content:center;
background:#eef2f7; color:${COLORS.NAVY}; font:600 12px/1.5 ui-monospace,monospace; text-align:center; padding:18px; }
/* on-map control cluster (top-left) — color-by + province drill + reset */
.cpt-mc-ctl { position:absolute; top:8px; left:8px; z-index:30; display:flex; flex-direction:column; gap:6px; align-items:flex-start; }
.cpt-mc-seg { display:inline-flex; background:rgba(255,255,255,.95); border:1px solid #cbd5e1; border-radius:999px; padding:2px; gap:2px; box-shadow:0 2px 8px rgba(0,0,0,.14); }
.cpt-mc-seg button { border:0; background:transparent; color:#475569; cursor:pointer; font:700 10px/1 ui-monospace,monospace; padding:5px 9px; border-radius:999px; }
.cpt-mc-seg button.on { background:${COLORS.NAVY}; color:#fff; }
.cpt-mc-btn { border:1px solid #cbd5e1; background:rgba(255,255,255,.95); color:${COLORS.NAVY}; cursor:pointer;
font:700 10px/1 ui-monospace,monospace; padding:6px 10px; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.14); }
.cpt-mc-btn.on { background:${COLORS.NAVY}; color:#fff; border-color:${COLORS.NAVY}; }
.cpt-mc-btn.gold { background:${COLORS.GOLD}; color:#3a2600; border-color:${COLORS.GOLD}; }
/* SEND headline (bottom-left) when a target is focused */
.cpt-mc-headline { position:absolute; left:8px; bottom:8px; right:8px; z-index:28; pointer-events:none;
background:rgba(0,46,110,.92); color:#fff; border-radius:9px; padding:7px 11px; max-width:560px;
font:700 12px/1.35 -apple-system,sans-serif; box-shadow:0 4px 14px rgba(0,0,0,.28); }
/* hover-pin tooltip */
.cpt-mc-tip { position:fixed; z-index:60; pointer-events:none; max-width:250px; background:#0e1730;
border:1px solid #34508c; border-radius:9px; padding:9px 11px; box-shadow:0 6px 22px rgba(0,0,0,.5); font-size:11.5px; color:#dbe4f3; }
.cpt-mc-tip strong { font-size:13px; color:#fff; }
.cpt-mc-tsub { color:#fda4af; font:600 10px/1.3 ui-monospace,monospace; margin:2px 0 6px; }
.cpt-mc-trow { display:flex; gap:6px; line-height:1.5; }
.cpt-mc-tk { color:#7e90b6; min-width:62px; }
@media (max-width:560px){ .cpt-mc-headline{ font-size:11px; } }
`, 'cockpit-mapcell')
</script>
<template>
<div ref="mcRoot" class="cpt-mc-root" @mousemove="onMove">
<div ref="mapContainer" class="cpt-mc-map"></div>
<!-- on-map controls: exposes color-by (8) + province drill (9) + reset focus -->
<div v-if="ready" class="cpt-mc-ctl">
<div class="cpt-mc-seg" role="group" aria-label="Green color encoding">
<button :class="{ on: store.colorBy.value === 'pct' }" @click="store.setColorBy('pct')">% reached</button>
<button :class="{ on: store.colorBy.value === 'religion' }" @click="store.setColorBy('religion')">religion</button>
</div>
<button class="cpt-mc-btn" :class="{ on: store.provinceDrill.value }" @click="store.toggleProvince()"
title="Overlay IN/CN admin-1 boundaries">IN/CN provinces</button>
<button v-if="store.focusedTarget.value" class="cpt-mc-btn gold" @click="store.clearFocus()">✕ reset focus</button>
</div>
<!-- SEND headline when focused -->
<div v-if="ready && store.focusedTarget.value" class="cpt-mc-headline">{{ store.headline.value }}</div>
<!-- token/load hint (map never initialized — store still drives the other cells) -->
<div v-if="!ready" class="cpt-mc-loading">
<span v-if="tokenMissing">Map needs a Mapbox token (tk in profile-config)…</span>
<span v-else>Rendering the harvest field…</span>
</div>
<!-- hover-pin tooltip -->
<div v-if="tip.show" class="cpt-mc-tip" :style="{ left: (tip.x + 14) + 'px', top: (tip.y + 14) + 'px' }" v-html="tip.html"></div>
</div>
</template>

View file

@ -0,0 +1,568 @@
/**
* useCockpit.js — the SHARED crossfilter store for the "BloomField Cockpit".
*
* ONE reactive store is the single source of truth binding the four cockpit cells
* (MAP + FLOWER + LIST + CHART STRIP). Every cell INJECTs the same instance, so a
* facet click in the chart strip, a red tap on the map, a search in the header and a
* sort in the list all breathe together (cockpit / crossfilter pattern).
*
* 0-DEPENDENCY: imports ONLY from 'vue'. The baked data (senders + admin1) is
* STATIC-imported so it ships INSIDE the bundle (a runtime ./data fetch 404s in the
* real platform gem-frame — the PPLR-1 lesson). The live unreached "reds" are fetched
* from pray.doxa.life in loadData().
*
* USAGE
* // shell (cockpit.vue):
* import { createCockpitStore } from './cockpit/useCockpit.js'
* const store = createCockpitStore()
* provide('cockpitStore', store)
* onMounted(() => store.loadData(profileConfig?.value))
*
* // any cell:
* import { useCockpitStore } from './useCockpit.js'
* const store = useCockpitStore()
* // store.clusters.value, store.visibleSenders.value, store.selectTarget(id), …
*
* Every store member that is a ref/computed is consumed with `.value`. Actions are
* plain functions. Deterministic — NO Date.now()/Math.random() inside any computed
* (the one timestamp, loadedAt, is captured in the loadData ACTION).
*
* Full field-by-field contract: ./STORE-API.md
*/
import { ref, computed, inject } from 'vue'
// ─────────────────────────────────────────────────────────────────────────────
// LOCKED COLOR CONTRACT (shared by all four cells — import from here, never re-hardcode)
// ─────────────────────────────────────────────────────────────────────────────
export const COLORS = {
RED: '#e11d48', // unreached target (base)
RED_DK: '#9f1239',
NAVY: '#002e6e', // SEND arcs / chrome
GOLD: '#f59e0b', // selection highlight
WHITE: '#ffffff',
// GREEN = 5 QUANTILE bins on pct_christian (2-10 / 10-25 / 25-50 / 50-75 / 75-100)
GREEN_BINS: ['#9bcf9b', '#5cb874', '#1f9d55', '#0f7a3d', '#0a5c2e'],
// RED orange-red ramp — deeper = MORE unreached (target_upg buckets)
RED_RAMP: ['#fdba74', '#fb923c', '#f97316', '#ea580c', '#c2410c'],
// donut categorical (used when a chart must color bridge/religion categories)
CAT: ['#002e6e', '#1f9d55', '#f59e0b', '#e11d48', '#7c3aed', '#0891b2', '#65a30d', '#be123c', '#a16207']
}
export const GREEN_BIN_LABELS = ['2–10%', '10–25%', '25–50%', '50–75%', '75–100%']
// ─────────────────────────────────────────────────────────────────────────────
// PURE HELPERS (exported so every cell bins/colors/sizes IDENTICALLY)
// ─────────────────────────────────────────────────────────────────────────────
/** 5-bin quantile index for a %Christian value → 0..4 (matches GREEN_BINS / GREEN_BIN_LABELS). */
export function quantileBin(pct) {
const p = +pct || 0
if (p < 10) return 0
if (p < 25) return 1
if (p < 50) return 2
if (p < 75) return 3
return 4
}
/** Green color for a reached sender's %Christian (locked quantile ramp). */
export function reachedColor(pct) { return COLORS.GREEN_BINS[quantileBin(pct)] }
/** Orange-red color for a red cluster by its unreached count (deeper = more unreached). */
export function redColor(count) {
const c = +count || 0
if (c >= 80) return COLORS.RED_RAMP[4]
if (c >= 40) return COLORS.RED_RAMP[3]
if (c >= 15) return COLORS.RED_RAMP[2]
if (c >= 5) return COLORS.RED_RAMP[1]
return COLORS.RED_RAMP[0]
}
/** LOG-scaled radius for a population (px). 30 → ~4px, 901M → ~22px. Shared map+flower. */
export function logRadius(pop, min = 4, max = 22) {
const p = Math.max(1, +pop || 0)
const t = Math.min(1, Math.log10(p) / 9) // log10(1e9) = 9 → full scale at ~1B
return min + (max - min) * t
}
/** Bridge type → LINE STYLE (color is NEVER used for bridge — line style only). */
export function bridgeDash(bridge) {
if (bridge === 'same-language') return null // solid
if (bridge === 'related-cluster') return [4, 3] // dashed
return [1, 3] // dotted (same-country / other)
}
/** Religion background → coarse family (text before " - "). */
export function religionFamily(s) {
const v = String(s || '').trim()
if (!v) return 'Unknown'
return v.split(' - ')[0].trim() || 'Unknown'
}
/** Human population formatter (30 / 12K / 2.1M / 1.3B). */
export function fmtPop(n) {
const v = +n || 0
if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B'
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'
if (v >= 1e3) return Math.round(v / 1e3) + 'K'
return String(Math.round(v))
}
const norm = (s) => String(s || '').trim().toLowerCase()
const lbl = (f) => (f && typeof f === 'object') ? (f.label ?? f.value ?? '') : (f ?? '')
const val = (f) => (f && typeof f === 'object') ? (f.value ?? f.label ?? '') : (f ?? '')
// Live doxa country labels → baked-JSON country strings (so reds line up with senders).
const COUNTRY_DISPLAY = {
"People's Republic of China": 'China',
'Lao People’s Democratic Republic': 'Laos',
"Lao People's Democratic Republic": 'Laos'
}
const normCountry = (s) => COUNTRY_DISPLAY[s] || s
// ─────────────────────────────────────────────────────────────────────────────
// STATIC baked data (ships inside the bundle — no runtime ./data fetch)
// ─────────────────────────────────────────────────────────────────────────────
import bakedSenders from '../data/reached-neighbors.json'
import bakedAdmin1 from '../data/admin1-membership.json'
// ─────────────────────────────────────────────────────────────────────────────
// THE FACTORY
// ─────────────────────────────────────────────────────────────────────────────
export function createCockpitStore() {
// ── primitive state ───────────────────────────────────────────────────────
const focusedTargetId = ref(null) // cluster id (== target country) | null
const facets = ref({
languages: new Set(), // sender/target language labels
countries: new Set(), // sender/target country labels
religions: new Set(), // religion family labels
bridges: new Set(), // bridge type values
reachedMin: null // number | null (pct_christian floor)
})
const search = ref('')
const colorBy = ref('pct') // 'pct' | 'religion'
const languageLens = ref(null) // string | null (a bridge language)
const provinceDrill = ref(false) // IN/CN admin1 choropleth toggle
const mode = ref('map') // 'map' | 'flower' (the morph)
const sortKey = ref('mobilization') // see listRows
// shared HOVER bus for the two-way map ↔ list ↔ flower link (interaction #3):
// a sender id (or '__pool__') that is currently hovered in ANY cell. Each cell
// writes it via setHover(id) and reacts to it for a synchronized pulse/scroll.
const hoveredId = ref(null) // Sender.id | '__pool__' | null
// ── load lifecycle ────────────────────────────────────────────────────────
const ready = ref(false)
const loadError = ref('')
const loadedAt = ref('') // ISO string captured in the ACTION
// raw caches (filled by loadData)
const allClusters = ref([]) // [Cluster] (red, unfiltered)
const allSenders = ref([]) // [Sender] (flat, unfiltered)
const admin1 = ref(bakedAdmin1 || {}) // { pg-slug : "Province" }
const meta = ref(bakedSenders?.meta || {})
// ── DATA LOAD (async action) ──────────────────────────────────────────────
async function loadData(profileConfig) {
const cfg = profileConfig || {}
loadError.value = ''
// 1) baked senders → flat list (real lat/lng on every row)
allSenders.value = buildSenders(bakedSenders)
// index senderCount + meta per target country
const counts = senderCountsByCountry(allSenders.value)
// 2) live unreached reds → clusters grouped by country
let reds = []
try { reds = await fetchReds(cfg) }
catch (e) {
loadError.value = 'Live unreached API unreachable — ' + (e?.message || e)
// still usable: build clusters from the baked target meta so the cockpit renders
allClusters.value = clustersFromBaked(bakedSenders, counts)
loadedAt.value = new Date().toISOString()
ready.value = true
return
}
allClusters.value = buildClusters(reds, counts)
loadedAt.value = new Date().toISOString()
ready.value = true
}
// ── DERIVED: clusters after facets + search + language lens ────────────────
const clusters = computed(() => {
let list = allClusters.value
const f = facets.value
if (f.countries.size) list = list.filter(c => f.countries.has(c.country))
if (f.languages.size) list = list.filter(c => c.langs.some(l => f.languages.has(l)))
const q = norm(search.value)
if (q) list = list.filter(c => norm(c.label).includes(q) || norm(c.country).includes(q) ||
c.langs.some(l => norm(l).includes(q)))
if (languageLens.value) {
const lens = norm(languageLens.value)
const reachable = sendersForLens(lens)
list = list.filter(c => reachable.has(c.id))
}
// default ordering = mobilization readiness (senderCount desc × unreached pop desc)
return [...list].sort((a, b) =>
(b.senderCount - a.senderCount) || (b.target_pop - a.target_pop) || (b.count - a.count))
})
const focusedTarget = computed(() =>
focusedTargetId.value ? allClusters.value.find(c => c.id === focusedTargetId.value) || null : null)
// senders for the focused target, BEFORE facets (for the honest "hidden" count)
const sendersForFocus = computed(() =>
focusedTargetId.value ? allSenders.value.filter(s => s.targetId === focusedTargetId.value) : [])
// ── DERIVED: visible senders (focused target, matching facets + search) ────
const visibleSenders = computed(() => {
let list = sendersForFocus.value
const f = facets.value
if (f.languages.size) list = list.filter(s => f.languages.has(s.lang))
if (f.countries.size) list = list.filter(s => f.countries.has(s.country))
if (f.religions.size) list = list.filter(s => f.religions.has(s.religionFamily))
if (f.bridges.size) list = list.filter(s => f.bridges.has(s.bridge))
if (f.reachedMin != null) list = list.filter(s => s.pct >= f.reachedMin)
const q = norm(search.value)
if (q) list = list.filter(s => norm(s.name).includes(q) || norm(s.lang).includes(q) ||
norm(s.country).includes(q))
return list
})
// ── DERIVED: map nodes — GRANULAR per-PG pins (UPGN-MERGE / Driver) ─────────
// Every reached people-group is its OWN pin at its ACTUAL per-PG lat/lng from
// PPLR (the data carries real Lat/Long for all senders — incl. same-country,
// which are NOT co-located: e.g. India's 150 senders spread across real states).
// No more in-country pool node, no centroids — the "granular actual people-group
// xy coordinates from PPLR for the actual reached people" the Driver asked for.
// (The earlier pool existed only because the OLD data smeared same-country senders
// onto one centroid; the PPLR-2 data fixed that, so granular is now correct.)
const mapNodes = computed(() =>
visibleSenders.value
.filter(s => isFinite(s.lat) && isFinite(s.lng))
.map(s => ({ ...s, isPool: false })))
// ── DERIVED: honest counters ───────────────────────────────────────────────
const counts = computed(() => {
const cl = clusters.value.length
if (focusedTargetId.value) {
const total = sendersForFocus.value.length
const vis = visibleSenders.value.length
return { senders: vis, clusters: cl, hidden: Math.max(0, total - vis) }
}
const senders = clusters.value.reduce((s, c) => s + c.senderCount, 0)
return { senders, clusters: cl, hidden: 0 }
})
// ── DERIVED: headline string ───────────────────────────────────────────────
const headline = computed(() => {
const c = focusedTarget.value
if (!c) {
return counts.value.clusters + ' unreached clusters · ' +
counts.value.senders.toLocaleString() + ' reached senders nearby — pick a red to send'
}
const vis = visibleSenders.value
const lensWord = languageLens.value || [...facets.value.languages][0] || ''
const top = [...vis].sort((a, b) => (b.pct - a.pct) || (b.pop - a.pop))[0]
const pre = (lensWord ? lensWord + ' × ' : '') + c.country
const topStr = top ? ' · top ' + top.name + ' ' + Math.round(top.pct) + '%, ' + fmtPop(top.pop) : ''
return pre + ': ' + c.count + ' unreached · ' + vis.length +
(lensWord ? ' nearby ' + lensWord + ' senders' : ' nearby senders') + topStr
})
// ── DERIVED: list rows (sorted senders of the focused target) ──────────────
const listRows = computed(() => {
const rows = [...visibleSenders.value]
const bridgeRank = { 'same-language': 0, 'related-cluster': 1, 'same-country': 2 }
switch (sortKey.value) {
case 'pct': rows.sort((a, b) => b.pct - a.pct || b.pop - a.pop); break
case 'pop': rows.sort((a, b) => b.pop - a.pop || b.pct - a.pct); break
case 'name': rows.sort((a, b) => a.name.localeCompare(b.name)); break
case 'bridge': rows.sort((a, b) =>
(bridgeRank[a.bridge] ?? 9) - (bridgeRank[b.bridge] ?? 9) || b.pct - a.pct); break
case 'frontier': // fewest-senders × largest-pop → big reached pops, weakest first
rows.sort((a, b) => b.pop - a.pop || a.pct - b.pct); break
case 'mobilization': // DEFAULT: readiness = strongest × largest
default: rows.sort((a, b) => b.pct - a.pct || b.pop - a.pop); break
}
return rows
})
// ── DERIVED: chart data (4 zero-dep SVG charts; computed from visibleSenders) ─
const chartData = computed(() => {
const base = focusedTargetId.value ? visibleSenders.value
: allSenders.value.filter(s => clusterIds().has(s.targetId))
return {
languageBar: topBars(base, s => s.lang, 12),
countryBar: topBars(base, s => s.country, 12),
reachedHist: GREEN_BIN_LABELS.map((label, i) => ({
key: i, label, value: base.filter(s => s.bin === i).length, color: COLORS.GREEN_BINS[i]
})),
donut: colorBy.value === 'religion'
? donutBy(base, s => s.religionFamily)
: donutBy(base, s => s.bridge)
}
})
// ── DERIVED: provenance (for the header pill — honesty contract) ───────────
const provenance = computed(() => ({
liveTargets: allClusters.value.length,
liveUnreached: allClusters.value.reduce((s, c) => s + c.count, 0),
loadedAt: loadedAt.value,
bakedGenerated: meta.value?.generated || '',
bakedSenders: meta.value?.total_neighbors || allSenders.value.length,
reachedFilter: meta.value?.reached_filter || '',
admin1Level: 'IN/CN admin1 (point-in-polygon, ' + Object.keys(admin1.value).length + ' groups)',
apiError: loadError.value
}))
// ── DERIVED: active filter chips (header breadcrumb; each removable) ────────
const chips = computed(() => {
const out = []
const f = facets.value
if (focusedTargetId.value && focusedTarget.value)
out.push({ kind: 'focus', value: focusedTarget.value.country, label: focusedTarget.value.country })
for (const v of f.languages) out.push({ kind: 'languages', value: v, label: v })
for (const v of f.countries) out.push({ kind: 'countries', value: v, label: v })
for (const v of f.religions) out.push({ kind: 'religions', value: v, label: v })
for (const v of f.bridges) out.push({ kind: 'bridges', value: v, label: v })
if (f.reachedMin != null) out.push({ kind: 'reachedMin', value: f.reachedMin, label: 'reached% ≥ ' + f.reachedMin })
if (languageLens.value) out.push({ kind: 'lens', value: languageLens.value, label: 'lens: ' + languageLens.value })
return out
})
// ───────────────────────────────────────────────────────────────────────────
// ACTIONS
// ───────────────────────────────────────────────────────────────────────────
function selectTarget(id) { focusedTargetId.value = id }
function clearFocus() { focusedTargetId.value = null }
function toggleFacet(kind, value) {
const set = facets.value[kind]
if (!(set instanceof Set)) return
if (set.has(value)) set.delete(value); else set.add(value)
facets.value = { ...facets.value } // trigger reactivity (Set mutation)
}
function setReachedMin(n) {
facets.value = { ...facets.value, reachedMin: (n == null || n === '' ? null : +n) }
}
function setSearch(s) { search.value = String(s ?? '') }
function setColorBy(v) { colorBy.value = (v === 'religion') ? 'religion' : 'pct' }
function setLanguageLens(l) { languageLens.value = l || null }
function toggleProvince() { provinceDrill.value = !provinceDrill.value }
function setMode(m) { mode.value = (m === 'flower') ? 'flower' : 'map' }
function setSort(key) { sortKey.value = key || 'mobilization' }
/** Shared hover bus — set/clear the currently-hovered sender id (two-way link). */
function setHover(id) { hoveredId.value = id || null }
/** Remove a single chip (from the header breadcrumb). */
function removeChip(chip) {
if (!chip) return
if (chip.kind === 'focus') return clearFocus()
if (chip.kind === 'reachedMin') return setReachedMin(null)
if (chip.kind === 'lens') return setLanguageLens(null)
return toggleFacet(chip.kind, chip.value)
}
function clearAllFacets() {
facets.value = { languages: new Set(), countries: new Set(), religions: new Set(),
bridges: new Set(), reachedMin: null }
languageLens.value = null
search.value = ''
}
/**
* Export the given rows (defaults to listRows) to a CSV download via a native Blob.
* CSV-injection-safe: any cell beginning with = + - @ is prefixed with a single quote,
* and every field is double-quote escaped. First line = a provenance comment.
*/
function exportSelectionCSV(rows) {
const data = Array.isArray(rows) ? rows : listRows.value
const cols = ['name', 'country', 'lang', 'rol3', 'pct', 'pop', 'bridge', 'adjacency', 'religion_bg']
const safe = (v) => {
let s = String(v ?? '')
if (/^[=+\-@]/.test(s)) s = "'" + s // neutralize formula injection
return '"' + s.replace(/"/g, '""') + '"'
}
const prov = '# BloomField Cockpit — reached neighbors for ' +
(focusedTarget.value?.country || 'all targets') +
' · baked ' + (meta.value?.generated || '') +
' · ' + data.length + ' rows · ' + (loadedAt.value || '')
const header = cols.join(',')
const body = data.map(r => cols.map(c => safe(r[c])).join(',')).join('\n')
const csv = prov + '\n' + header + '\n' + body + '\n'
try {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'send-list-' + (focusedTarget.value?.country || 'all').replace(/\W+/g, '-').toLowerCase() + '.csv'
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (_) { /* download blocked (sandbox) — silent */ }
return csv
}
// ── internal helpers (closure over caches) ─────────────────────────────────
function clusterIds() { return new Set(clusters.value.map(c => c.id)) }
// map: target country -> Set of cluster ids that have a sender of the lens language
function sendersForLens(lensLk) {
const ids = new Set()
for (const s of allSenders.value) if (norm(s.lang) === lensLk) ids.add(s.targetId)
return ids
}
// expose
return {
// primitive state
focusedTargetId, facets, search, colorBy, languageLens, provinceDrill, mode, sortKey,
// shared hover bus (exposed under BOTH names the cells consume — same ref)
hoveredId, hoveredSenderId: hoveredId,
// lifecycle
ready, loadError, loadedAt, meta, admin1,
// raw caches (read-only by convention)
allClusters, allSenders,
// derived
clusters, focusedTarget, sendersForFocus, visibleSenders, mapNodes,
counts, headline, listRows, chartData, provenance, chips,
// actions
loadData, selectTarget, clearFocus, toggleFacet, setReachedMin, setSearch,
setColorBy, setLanguageLens, toggleProvince, setMode, setSort, setHover, removeChip,
clearAllFacets, exportSelectionCSV,
// helpers re-exported on the instance for convenience
quantileBin, reachedColor, redColor, logRadius, bridgeDash, religionFamily, fmtPop, COLORS
}
}
/** Inject the shared store provided by cockpit.vue. */
export function useCockpitStore() {
const store = inject('cockpitStore', null)
if (!store) throw new Error('[useCockpitStore] no cockpitStore provided — mount inside <cockpit>')
return store
}
// ─────────────────────────────────────────────────────────────────────────────
// BUILDERS (pure module functions — no Vue, fully testable)
// ─────────────────────────────────────────────────────────────────────────────
/** Flatten reached-neighbors.json into [Sender] with targetId + quantile bin + religion family. */
function buildSenders(json) {
const out = []
const countries = json?.countries || {}
for (const [targetId, t] of Object.entries(countries)) {
for (const n of (t.neighbors || [])) {
const lat = Number(n.lat), lng = Number(n.lng)
if (!isFinite(lat) || !isFinite(lng)) continue
const pct = +n.pct_christian || 0
out.push({
id: n.code || (targetId + '|' + n.name + '|' + n.country + '|' + (n.rol3 || '')),
targetId,
name: n.name, country: n.country, lang: n.lang, rol3: n.rol3 || '',
pct, pop: +n.pop || 0,
bridge: n.bridge || '', adjacency: n.adjacency || '',
religion_bg: n.religion_bg || '', religionFamily: religionFamily(n.religion_bg),
lat, lng, code: n.code || '',
bin: quantileBin(pct), color: reachedColor(pct)
})
}
}
return out
}
/** target country -> { count, pop } sender aggregate. */
function senderCountsByCountry(senders) {
const m = new Map()
for (const s of senders) {
let e = m.get(s.targetId)
if (!e) { e = { count: 0, pop: 0 }; m.set(s.targetId, e) }
e.count++; e.pop += s.pop
}
return m
}
/** Build red clusters from LIVE doxa people-groups, grouped by country. */
function buildClusters(reds, senderCounts) {
const m = new Map()
for (const r of reds) {
const lat = Number(r.latitude), lng = Number(r.longitude)
const country = normCountry(lbl(r.country_code) || '(unknown)')
const lang = lbl(r.primary_language) || ''
const slug = r.slug || val(r.primary_language) + ':' + r.name
let c = m.get(country)
if (!c) {
c = { id: country, country, label: country, count: 0, target_pop: 0,
_sumLat: 0, _sumLng: 0, _n: 0, langSet: new Set(), members: [] }
m.set(country, c)
}
c.count++
if (lang) c.langSet.add(lang)
c.members.push({ name: r.name, slug, lang, lat, lng })
if (isFinite(lat) && isFinite(lng)) { c._sumLat += lat; c._sumLng += lng; c._n++ }
}
return finalizeClusters([...m.values()], senderCounts)
}
/** Fallback: build clusters straight from the baked target meta (live API down). */
function clustersFromBaked(json, senderCounts) {
const out = []
for (const [country, t] of Object.entries(json?.countries || {})) {
const sc = senderCounts.get(country)
out.push({
id: country, country, label: country,
count: +t.target_upg || 0, target_pop: +t.target_pop || 0,
lat: null, lng: null, langs: t.target_top_langs || [],
target_top_langs: t.target_top_langs || [], target_top_religion: t.target_top_religion || '',
senderCount: sc ? sc.count : 0, hasSenders: !!sc, color: redColor(+t.target_upg || 0)
})
}
return out
}
function finalizeClusters(list, senderCounts) {
const out = []
for (const c of list) {
if (c._n) { c.lat = c._sumLat / c._n; c.lng = c._sumLng / c._n }
else { c.lat = null; c.lng = null }
const sc = senderCounts.get(c.id)
out.push({
id: c.id, country: c.country, label: c.label,
count: c.count, target_pop: c.target_pop, lat: c.lat, lng: c.lng,
langs: [...c.langSet],
target_top_langs: [...c.langSet].slice(0, 4),
target_top_religion: '',
members: c.members,
senderCount: sc ? sc.count : 0,
hasSenders: !!sc,
color: redColor(c.count)
})
}
out.sort((a, b) => b.count - a.count)
return out
}
/** Fetch the live unreached "reds" from pray.doxa.life. */
async function fetchReds(cfg) {
const base = cfg.apiBase
|| (typeof window !== 'undefined' && (window.MAP_APP_API_URL || window.VITE_API_BASE_URL))
|| 'https://pray.doxa.life'
const fields = 'slug,name,latitude,longitude,country_code,primary_language,wagf_block'
const url = base.replace(/\/?$/, '') + '/api/people-groups/list?fields=' + fields + '&lang=en'
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
return j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : [])
}
/** Top-N category bars [{ key, label, value }] sorted desc, by an accessor. */
function topBars(rows, keyOf, n) {
const m = new Map()
for (const r of rows) { const k = keyOf(r) || '—'; m.set(k, (m.get(k) || 0) + 1) }
return [...m.entries()].map(([label, value]) => ({ key: label, label, value }))
.sort((a, b) => b.value - a.value).slice(0, n)
}
/** Donut slices [{ key, label, value, color }] by an accessor, categorical palette. */
function donutBy(rows, keyOf) {
const m = new Map()
for (const r of rows) { const k = keyOf(r) || '—'; m.set(k, (m.get(k) || 0) + 1) }
const sorted = [...m.entries()].sort((a, b) => b[1] - a[1])
return sorted.map(([label, value], i) => ({
key: label, label, value, color: COLORS.CAT[i % COLORS.CAT.length]
}))
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,515 @@
<script setup>
/**
* flower-mesh.vue — Application Profile: My UPG Neighbor · FLOWER-MESH
*
* @description Mesh-network "flower" — an UNREACHED people group (doxa-2000) at the
* flower CENTER, surrounded by its REACHED NEIGHBOR PGs radiating by
* adjacency (own country = inner whorl, surrounding countries = outer
* petals), with same-language mesh links between collaborators.
* @profile flower-mesh
* @bundle doxa-upg-neighbor
* @element flower-mesh
*
* WHY A FLOWER, NOT A MAP (card UPGN-PPLR):
* "My UPG 100 List" finds the unreached. "My UPG Neighbor" answers: who is already
* reached, near, and language-bridged enough to be SENT to reach them? This profile
* renders that as a radial mesh — the unreached target in the center; every reached
* neighbor a petal node; a stem (spoke) from each neighbor INTO the center = a SEND
* path; and chords between neighbors who share a language = the collaboration mesh.
*
* 0-DEPENDENCY / SHADOW-DOM-SAFE (hard constraints from the card):
* - Hand-rolled SVG. NO Mapbox, NO d3, NO chart lib. `npm install` is impossible in
* this tree (node_modules symlinks into frozen doxa-life), so this file imports
* ONLY Vue + the shared @map/useShadowStyles composable. All geometry is computed
* here in plain JS.
* - All CSS goes through useShadowStyles (scoped <style> in glob-loaded children is
* silently dropped by defineCustomElement — see useShadowStyles.js).
*
* DATA (see _qa-cycle/01-ideation-research/PPLR-RE-FINDINGS.md for the full RE):
* - REACHED neighbors: data/reached-neighbors.json — the baked Joshua-Project /
* IMB-PPLR-derived 4-D join (frozen 2026-06-12), keyed by unreached target country.
* Each neighbor: { name, country, lang, pct_christian, pop, bridge, adjacency,
* religion_bg, province? }.
* - UNREACHED center (doxa-2000): the per-country target summary already in that JSON
* (target_upg / target_pop / target_top_langs / target_top_religion). Optionally
* enriched live from pray.doxa.life (the project's pray-tools source) when reachable.
*
* PROVINCE GRANULARITY — HONEST LIMIT:
* The card asked for province-level granularity. The RE proved PPLR (ArcGIS IMB
* layers) and the baked JSON are BOTH country-level only; the finest sub-country
* signal is a free-text `Segment` / UN-M49 sub-region. So the flower groups at the
* honest finest grain (country), renders a `province`/`segment` tier WHERE PRESENT,
* and the data contract carries an optional `province` field so true admin-1 data can
* be dropped in later with zero code change. The provenance footer states this.
*
* profile-config shape:
* {
* "profile": "flower-mesh",
* "dataUrl": "<url-to-reached-neighbors.json>", // optional override
* "target": "India", // optional default target
* "apiBase": "https://pray.doxa.life", // optional live doxa enrich
* "instanceId": "any-stable-id"
* }
*/
import { inject, ref, computed, onMounted } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// ─── Palette (mirrors send-map.vue — orange unreached / green reached / navy chrome) ─
const C_ORANGE = '#FF6600' // unreached center (the target we WANT to reach)
const C_NAVY = '#002e6e' // chrome / SEND spokes
const C_BG = '#0b1020' // deep field backdrop (the night the harvest waits in)
// reached-strength green ramp by %Christian (deeper = more laborers to send)
function reachedColor(pct) {
const p = +pct || 0
if (p >= 80) return '#0f7a3d'
if (p >= 40) return '#1f9d55'
if (p >= 15) return '#5cb874'
return '#9bcf9b'
}
// ─── Config (provide/inject from ProfileLoader) ──────────────────────────────
const profileConfig = inject('profileConfig')
const dataSource = inject('dataSource', null)
const cfg = computed(() => profileConfig?.value || {})
// ─── State ────────────────────────────────────────────────────────────────────
const dataset = ref(null) // parsed reached-neighbors.json
const appReady = ref(false)
const loadError = ref('')
const activeTarget = ref(cfg.value.target ?? 'India')
const showMesh = ref(true) // same-language collaboration chords
const selectedId = ref(null) // clicked node id (highlight focus)
const liveCenter = ref(null) // optional live doxa-2000 enrichment
const tip = ref({ show: false, x: 0, y: 0, html: '' })
// ─── Geometry constants (SVG user units; viewBox is square VB×VB) ────────────
const VB = 1000
const CX = VB / 2
const CY = VB / 2
const R_IN = 205 // inner whorl — same-country reached PGs
const R_OUT = 360 // outer petals — surrounding-country reached PGs
const R_CTR = 46 // center node radius
// polar → cartesian. angle in degrees, 0° = straight up, clockwise.
function polar(cx, cy, r, angleDeg) {
const a = (angleDeg - 90) * Math.PI / 180
return [cx + r * Math.cos(a), cy + r * Math.sin(a)]
}
// node radius by population (sqrt scale, clamped) — keeps tiny + huge PGs legible.
function nodeR(pop) {
const p = Math.max(0, +pop || 0)
return Math.max(7, Math.min(22, 6 + Math.sqrt(p) / 320))
}
// normalize a language string for same-language matching.
function langKey(s) { return String(s || '').trim().toLowerCase() }
// ─── Derived: target list + the selected target's record ─────────────────────
const targetNames = computed(() =>
dataset.value ? Object.keys(dataset.value.countries) : [])
const targetRec = computed(() =>
dataset.value?.countries?.[activeTarget.value] ?? null)
// All qualifying reached neighbors for the active target, split by adjacency ring.
const neighbors = computed(() => {
const t = targetRec.value
if (!t) return []
return (t.neighbors || []).map((n, i) => ({ ...n, _i: i }))
})
const innerNodes = computed(() =>
neighbors.value.filter(n => n.adjacency === 'same-country'))
const outerNodes = computed(() =>
neighbors.value.filter(n => n.adjacency !== 'same-country'))
// ─── Lay out every node: assign each a {id, x, y, r, color, ring, ...node} ────
// Inner ring spread evenly over 360°; outer ring spread evenly, offset by a half
// step so the two whorls interleave into a flower rather than stacking radially.
const layout = computed(() => {
const out = []
const place = (list, radius, offsetDeg) => {
const N = list.length
list.forEach((n, k) => {
const ang = N ? (k + 0.5) / N * 360 + offsetDeg : 0
const [x, y] = polar(CX, CY, radius, ang)
out.push({
id: 'n' + n._i,
node: n,
x, y, ang,
r: nodeR(n.pop),
color: reachedColor(n.pct_christian),
ring: radius === R_IN ? 'inner' : 'outer'
})
})
}
place(innerNodes.value, R_IN, 0)
place(outerNodes.value, R_OUT, (outerNodes.value.length ? 180 / outerNodes.value.length : 0))
return out
})
// Map id → laid-out node, for link lookups.
const byId = computed(() => Object.fromEntries(layout.value.map(p => [p.id, p])))
// ─── SEND spokes: center → each neighbor (the SEND path) ──────────────────────
// Style encodes the language bridge: solid = same-language (strong), dashed = cluster.
const spokes = computed(() => layout.value.map(p => ({
id: 's-' + p.id,
x1: CX, y1: CY, x2: p.x, y2: p.y,
strong: p.node.bridge === 'same-language',
to: p.id
})))
// ─── Collaboration mesh: chords between neighbors sharing a language ──────────
// This is the "mesh-network" of the flower — reached PGs who could partner because
// they speak the same language. Curved toward center for an organic petal weave.
const meshLinks = computed(() => {
if (!showMesh.value) return []
const L = layout.value
const links = []
for (let a = 0; a < L.length; a++) {
for (let b = a + 1; b < L.length; b++) {
// PPLR-3: chord neighbors who share a language by rol3 (ISO 639-3) code, name fallback.
const la = L[a].node.rol3 || langKey(L[a].node.lang)
if (!la) continue
if (la === (L[b].node.rol3 || langKey(L[b].node.lang))) {
// quadratic control point pulled ~40% toward center → gentle inward arc
const mx = (L[a].x + L[b].x) / 2
const my = (L[a].y + L[b].y) / 2
const ctrlX = mx + (CX - mx) * 0.45
const ctrlY = my + (CY - my) * 0.45
links.push({
id: `m-${L[a].id}-${L[b].id}`,
d: `M ${L[a].x.toFixed(1)} ${L[a].y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${L[b].x.toFixed(1)} ${L[b].y.toFixed(1)}`,
from: L[a].id, to: L[b].id
})
}
}
}
return links
})
// ─── Petal backdrops behind outer nodes (the flower silhouette) ──────────────
const petals = computed(() => layout.value
.filter(p => p.ring === 'outer')
.map(p => ({
id: 'p-' + p.id,
// ellipse centered a little outward of the node, rotated to point away from center
cx: polar(CX, CY, R_OUT - 6, p.ang)[0],
cy: polar(CX, CY, R_OUT - 6, p.ang)[1],
rot: p.ang,
color: p.color
})))
// ─── Center summary (doxa-2000 unreached target) ─────────────────────────────
const center = computed(() => {
const t = targetRec.value
const live = liveCenter.value
return {
name: activeTarget.value,
upg: live?.upg ?? t?.target_upg ?? 0,
pop: live?.pop ?? t?.target_pop ?? 0,
religion: t?.target_top_religion ?? '',
langs: t?.target_top_langs ?? []
}
})
// counts for the header line
const stats = computed(() => ({
total: neighbors.value.length,
inner: innerNodes.value.length,
outer: outerNodes.value.length,
countries: new Set(neighbors.value.map(n => n.country)).size,
meshes: meshLinks.value.length
}))
// ─── Highlight focus: when a node is selected, dim everything not connected ───
function isLinkLit(link) {
if (!selectedId.value) return true
return link.from === selectedId.value || link.to === selectedId.value
}
function isSpokeLit(sp) {
if (!selectedId.value) return true
return sp.to === selectedId.value
}
function isNodeLit(p) {
if (!selectedId.value) return true
if (p.id === selectedId.value) return true
// lit if it shares a mesh link with the selected node
return meshLinks.value.some(m =>
(m.from === selectedId.value && m.to === p.id) ||
(m.to === selectedId.value && m.from === p.id))
}
// ─── Number formatting ────────────────────────────────────────────────────────
const fmt = (n) => (+n || 0).toLocaleString()
function fmtPop(n) {
const v = +n || 0
if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B'
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'
if (v >= 1e3) return (v / 1e3).toFixed(0) + 'K'
return String(v)
}
// ─── Interaction ──────────────────────────────────────────────────────────────
function onNodeEnter(p, ev) {
const n = p.node
const prov = n.province || n.segment
tip.value = {
show: true,
x: ev.clientX, y: ev.clientY,
html: `<strong>${esc(n.name)}</strong>`
+ `<div class="fm-tip-sub">${esc(n.country)}${prov ? ' · ' + esc(prov) : ''}</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">reached</span> ${Math.round(n.pct_christian || 0)}% Christian</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">language</span> ${esc(n.lang || '—')}</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">people</span> ${fmtPop(n.pop)}</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">bridge</span> ${esc(n.bridge || '—')}</div>`
+ `<div class="fm-tip-row"><span class="fm-tip-k">near</span> ${esc(n.adjacency || '—')}</div>`
}
}
function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value.y = ev.clientY } }
function onNodeLeave() { tip.value = { ...tip.value, show: false } }
function onNodeClick(p) { selectedId.value = (selectedId.value === p.id) ? null : p.id }
function clearFocus() { selectedId.value = null }
function onSelectTarget(e) { activeTarget.value = e.target.value; selectedId.value = null }
// tiny HTML escaper for tooltip (defense-in-depth; data is trusted but cheap to guard)
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
// ─── Data load ────────────────────────────────────────────────────────────────
// PPLR-1: the reached-neighbor data is INLINED (static import) so it ships INSIDE the bundle. vite
// does not deploy each profile's data/ folder, so a runtime fetch('./data/...') 404s in the REAL
// platform gem-frame (it worked in `npm run dev` only — Vite serves the source data dir there). A
// profile-config `dataUrl` still overrides live. Same harness-vs-platform class as the ProfileLoader fix.
import bakedReached from './data/reached-neighbors.json'
async function loadDataset() {
const cfgUrl = cfg.value.dataUrl
if (cfgUrl) {
try { const res = await fetch(cfgUrl); if (res.ok) return await res.json() } catch (_) { /* fall back to baked */ }
}
return bakedReached
}
// Optional: enrich the center with live doxa-2000 counts (best-effort, non-blocking).
async function enrichCenterLive() {
const apiBase = cfg.value.apiBase
|| (typeof window !== 'undefined' && (window.MAP_APP_API_URL || window.VITE_API_BASE_URL))
if (!apiBase) return
try {
// statistics gives global doxa-2000 totals; used only as a soft live signal.
const res = await fetch(apiBase.replace(/\/?$/, '') + '/api/people-groups/statistics')
if (res.ok) {
const s = await res.json()
// We don't have per-country live counts here without a heavier query; keep the
// baked per-country numbers and just confirm liveness. (Seam for future wiring.)
if (s && typeof s.total === 'number') liveCenter.value = null
}
} catch (_) { /* offline / CORS — baked numbers stand */ }
}
onMounted(async () => {
dataset.value = await loadDataset()
if (!dataset.value) {
loadError.value = 'Could not load reached-neighbors.json'
appReady.value = true
return
}
if (!targetNames.value.includes(activeTarget.value)) {
activeTarget.value = targetNames.value[0] || activeTarget.value
}
appReady.value = true
enrichCenterLive()
})
// ─── Shadow-DOM CSS ───────────────────────────────────────────────────────────
useShadowStyles(`
.fm-root { position:absolute; inset:0; display:flex; flex-direction:column; overflow:hidden;
background:radial-gradient(circle at 50% 42%, #16203a 0%, ${C_BG} 70%); color:#e5e7eb;
isolation:isolate; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
/* top bar */
.fm-top { position:absolute; top:12px; left:12px; right:12px; z-index:30;
display:flex; flex-wrap:wrap; gap:8px; align-items:center; pointer-events:none; }
.fm-top > * { pointer-events:auto; }
.fm-title { background:${C_NAVY}; color:#fff; font:700 12px/1 ui-monospace,monospace;
letter-spacing:.05em; text-transform:uppercase; padding:9px 13px; border-radius:9px;
box-shadow:0 2px 10px rgba(0,0,0,.4); }
.fm-select { background:#0e1730; border:1px solid #2b3b63; color:#dbe4f3;
font:600 12px/1 -apple-system,sans-serif; padding:8px 11px; border-radius:9px; cursor:pointer; max-width:240px; }
.fm-toggle { background:#0e1730; border:1px solid #2b3b63; color:#9fb3d9;
font:600 11px/1 ui-monospace,monospace; padding:8px 11px; border-radius:9px; cursor:pointer;
display:inline-flex; align-items:center; gap:7px; user-select:none; }
.fm-toggle.on { color:#fff; border-color:#3f7bd6; background:#13224a; }
.fm-toggle .fm-sw { width:9px; height:9px; border-radius:50%; background:#3f7bd6; opacity:.35; }
.fm-toggle.on .fm-sw { opacity:1; }
.fm-stats { margin-left:auto; font:600 11px/1.5 ui-monospace,monospace; color:#9fb3d9;
background:rgba(8,12,24,.7); border:1px solid #1c2742; border-radius:9px; padding:7px 11px; text-align:right; }
.fm-stats b { color:#fff; }
/* canvas */
.fm-stage { flex:1 1 0; position:relative; }
.fm-svg { position:absolute; inset:0; width:100%; height:100%; display:block; }
.fm-loading { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:#9fb3d9; font:13px/1 ui-monospace,monospace; z-index:5; }
/* svg node + link styling driven by classes */
.fm-spoke { stroke:#3a4f86; stroke-width:1.4; fill:none; opacity:.5; transition:opacity .15s; }
.fm-spoke.strong { stroke:#5b7fd0; stroke-width:2; opacity:.7; }
.fm-spoke.dim { opacity:.06; }
.fm-mesh { stroke:#ffae66; stroke-width:1.3; fill:none; opacity:.5; stroke-linecap:round; transition:opacity .15s; }
.fm-mesh.lit { opacity:.92; stroke-width:2; }
.fm-mesh.dim { opacity:.05; }
.fm-petal { opacity:.13; transition:opacity .15s; }
.fm-petal.dim { opacity:.04; }
.fm-node { cursor:pointer; transition:opacity .15s; }
.fm-node.dim { opacity:.18; }
.fm-node circle { stroke:#0b1020; stroke-width:2; }
.fm-node.sel circle { stroke:#ffffff; stroke-width:3; }
.fm-node-label { fill:#cdd9f2; font:600 12px/1 -apple-system,sans-serif; paint-order:stroke;
stroke:#0b1020; stroke-width:3px; text-anchor:middle; pointer-events:none; }
.fm-center-glow { fill:${C_ORANGE}; opacity:.16; }
.fm-center circle { fill:${C_ORANGE}; stroke:#ffd9bd; stroke-width:3; }
.fm-center-name { fill:#fff; font:800 19px/1 -apple-system,sans-serif; text-anchor:middle;
paint-order:stroke; stroke:#7a2e00; stroke-width:3px; }
.fm-center-sub { fill:#ffe1cb; font:600 11px/1 ui-monospace,monospace; text-anchor:middle; }
/* legend */
.fm-legend { position:absolute; left:12px; bottom:38px; z-index:25; background:rgba(8,12,24,.82);
border:1px solid #1c2742; border-radius:10px; padding:10px 12px; max-width:230px;
box-shadow:0 4px 16px rgba(0,0,0,.45); }
.fm-legend h4 { font:700 9px/1 ui-monospace,monospace; letter-spacing:.12em; text-transform:uppercase;
color:#7e90b6; margin-bottom:8px; }
.fm-lrow { display:flex; align-items:center; gap:8px; padding:3px 0; font-size:11px; color:#c4d0e8; }
.fm-dot { width:12px; height:12px; border-radius:50%; flex:0 0 auto; }
.fm-line { width:18px; height:0; border-top:2px solid; flex:0 0 auto; }
/* footer provenance (honesty contract) */
.fm-footer { position:absolute; left:0; right:0; bottom:0; z-index:20; background:rgba(8,12,24,.86);
color:#8fa2c8; font:10px/1.4 ui-monospace,monospace; padding:6px 12px;
display:flex; flex-wrap:wrap; gap:3px 14px; align-items:center; border-top:1px solid #1c2742; }
.fm-footer b { color:#dbe4f3; }
/* tooltip */
.fm-tip { position:fixed; z-index:60; pointer-events:none; max-width:240px;
background:#0e1730; border:1px solid #34508c; border-radius:9px; padding:9px 11px;
box-shadow:0 6px 22px rgba(0,0,0,.55); font-size:11.5px; color:#dbe4f3; }
.fm-tip strong { font-size:13px; color:#fff; }
.fm-tip-sub { color:#ffae66; font:600 10px/1.3 ui-monospace,monospace; margin:2px 0 6px; }
.fm-tip-row { display:flex; gap:6px; line-height:1.5; }
.fm-tip-k { color:#7e90b6; min-width:56px; }
@media (max-width:720px) {
.fm-stats { display:none; }
.fm-legend { max-width:170px; }
}
`, 'flower-mesh')
</script>
<template>
<div class="fm-root" @mousemove="onMove">
<!-- top bar -->
<div class="fm-top">
<span class="fm-title">My UPG Neighbor · Flower-Mesh</span>
<select v-if="targetNames.length" class="fm-select"
:value="activeTarget" @change="onSelectTarget"
aria-label="Select unreached target country">
<option v-for="name in targetNames" :key="name" :value="name">{{ name }}</option>
</select>
<span class="fm-toggle" :class="{ on: showMesh }" role="switch" :aria-checked="showMesh"
tabindex="0" @click="showMesh = !showMesh"
@keydown.enter.prevent="showMesh = !showMesh" @keydown.space.prevent="showMesh = !showMesh">
<span class="fm-sw"></span> language mesh
</span>
<span v-if="targetRec" class="fm-stats">
<b>{{ stats.total }}</b> reached neighbors · <b>{{ stats.countries }}</b> countries<br>
<b>{{ fmt(center.upg) }}</b> UPGs in center · <b>{{ stats.meshes }}</b> mesh links
</span>
</div>
<!-- the flower -->
<div class="fm-stage">
<div v-if="!appReady" class="fm-loading">Growing the flower…</div>
<div v-else-if="loadError" class="fm-loading">{{ loadError }}</div>
<svg v-else class="fm-svg" :viewBox="`0 0 ${VB} ${VB}`"
preserveAspectRatio="xMidYMid meet" @click="clearFocus">
<!-- petal backdrops (flower silhouette) -->
<g>
<ellipse v-for="pt in petals" :key="pt.id" class="fm-petal"
:class="{ dim: selectedId }"
:cx="pt.cx" :cy="pt.cy" rx="78" ry="40"
:fill="pt.color"
:transform="`rotate(${pt.rot} ${pt.cx} ${pt.cy})`" />
</g>
<!-- SEND spokes: center → each neighbor -->
<g>
<line v-for="sp in spokes" :key="sp.id" class="fm-spoke"
:class="{ strong: sp.strong, dim: !isSpokeLit(sp) }"
:x1="sp.x1" :y1="sp.y1" :x2="sp.x2" :y2="sp.y2"
:stroke-dasharray="sp.strong ? '0' : '5 5'" />
</g>
<!-- collaboration mesh: same-language chords -->
<g>
<path v-for="m in meshLinks" :key="m.id" class="fm-mesh"
:class="{ lit: isLinkLit(m) && selectedId, dim: selectedId && !isLinkLit(m) }"
:d="m.d" />
</g>
<!-- neighbor nodes -->
<g>
<g v-for="p in layout" :key="p.id" class="fm-node"
:class="{ dim: !isNodeLit(p), sel: p.id === selectedId }"
@click.stop="onNodeClick(p)"
@mouseenter="onNodeEnter(p, $event)" @mouseleave="onNodeLeave">
<title>{{ p.node.name }} — {{ Math.round(p.node.pct_christian || 0) }}% Christian, {{ p.node.lang }}</title>
<circle :cx="p.x" :cy="p.y" :r="p.r" :fill="p.color" />
<text v-if="p.r >= 9 || p.id === selectedId" class="fm-node-label"
:x="p.x" :y="p.y + p.r + 14">{{ p.node.name }}</text>
</g>
</g>
<!-- center: the unreached target (doxa-2000) -->
<g class="fm-center" @click.stop="clearFocus" style="cursor:default">
<circle class="fm-center-glow" :cx="CX" :cy="CY" :r="R_CTR + 26" />
<circle :cx="CX" :cy="CY" :r="R_CTR" />
<text class="fm-center-name" :x="CX" :y="CY - 2">{{ center.name }}</text>
<text class="fm-center-sub" :x="CX" :y="CY + 16">{{ fmt(center.upg) }} UPGs</text>
<text class="fm-center-sub" :x="CX" :y="CY + 30" style="opacity:.85">{{ fmtPop(center.pop) }} people</text>
</g>
</svg>
<!-- legend -->
<div v-if="appReady && !loadError" class="fm-legend">
<h4>The flower</h4>
<div class="fm-lrow"><span class="fm-dot" :style="{ background: C_ORANGE }"></span> Unreached target (doxa-2000)</div>
<div class="fm-lrow"><span class="fm-dot" :style="{ background: reachedColor(90) }"></span> Reached neighbor (deeper = more Christian)</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#5b7fd0"></span> SEND path → into the unreached</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#3a4f86;border-top-style:dashed"></span> via related language cluster</div>
<div class="fm-lrow"><span class="fm-line" style="border-color:#ffae66"></span> Same-language collaboration mesh</div>
<div class="fm-lrow" style="color:#7e90b6;margin-top:5px;font-size:10px">inner whorl = same country · petals = surrounding countries</div>
</div>
</div>
<!-- provenance / honesty contract -->
<div class="fm-footer">
<span><b>Flower-Mesh</b> reached neighbor → unreached center</span>
<span>center = doxa-2000 (pray.doxa.life)</span>
<span>neighbors = baked {{ dataset?.meta?.generated || '2026-06-12' }} (≥{{ dataset?.meta?.threshold_pct_christian || 10 }}% Christian, {{ dataset?.meta?.adjacency_km || 1500 }} km)</span>
<span>province N/A in source — finest grain = country (see RE)</span>
<span>Luke 10:2 · send laborers into the harvest</span>
</div>
<!-- tooltip -->
<div v-if="tip.show" class="fm-tip"
:style="{ left: (tip.x + 14) + 'px', top: (tip.y + 14) + 'px' }"
v-html="tip.html"></div>
</div>
</template>

View file

@ -0,0 +1,574 @@
<script setup>
/**
* lang-cluster.vue — Application Profile: My UPG Neighbor · LANGUAGE-CLUSTER MAP
*
* @description Geographic bounded-set view — all ~2,106 doxa unreached people-groups
* plotted, grouped into LANGUAGE clusters (language×country; language×PROVINCE
* for India & China), each red cluster surrounded by the GREEN reached
* neighbors that match its language. The map twin of the radial flower-mesh.
* @profile lang-cluster
* @bundle doxa-upg-neighbor
* @element lang-cluster
*
* BOUNDED-SET DECOMPOSITION (shared with flower-mesh — two views of ONE clustering):
* 2000 doxa unreached → group by language × country (language × province for IN/CN)
* → each group = a BOUNDED SET (red)
* → reached neighbors matching the group's LANGUAGE = green
* This profile renders the MAP view; clicking a cluster drills into the FLOWER view
* (flower-mesh profile) via an `upgn:drill-flower` CustomEvent on the host element.
*
* DATA (RE: _qa-cycle/01-ideation-research/DOXA-PROVINCE-RE.md):
* - Unreached: LIVE pray.doxa.life /api/people-groups/list (~2,106; lean projection).
* Each carries latitude/longitude (per-PG), country_code{value,label},
* primary_language{value,label}. NO province field in the record.
* - Province (IN/CN only): baked data/admin1-membership.json — slug→admin1, the TRUE
* province from my-upg-100-list's point-in-polygon over real ADM1 polygons (reused,
* not re-derived). Non-IN/CN PGs cluster by language×country.
* - Reached: baked data/reached-neighbors.json — each neighbor carries `lang`; matched
* to a cluster by normalized language NAME (reached side has no ISO code).
*
* 0-DEP / SHADOW-DOM-SAFE: imports only Vue + @map (useMapInstance/useShadowStyles).
* Mapbox GL is the external peer (loaded by the host), reused via @map — no npm install.
*
* profile-config: { "profile":"lang-cluster", "tk":"pk...", "apiBase":"https://pray.doxa.life",
* "dataUrl":"<reached-neighbors.json>", "instanceId":"..." }
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// ─── Palette ──────────────────────────────────────────────────────────────────
const C_RED = '#e11d48' // unreached language cluster (the bounded set we WANT to reach)
const C_RED_DK = '#9f1239'
const C_NAVY = '#002e6e'
const C_GOLD = '#f59e0b' // selection highlight
function reachedColor(pct) { // green ramp by %Christian (sender strength)
const p = +pct || 0
if (p >= 80) return '#0f7a3d'
if (p >= 40) return '#1f9d55'
if (p >= 15) return '#5cb874'
return '#9bcf9b'
}
// ─── inject() from ProfileLoader ──────────────────────────────────────────────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const injectedInstanceId = inject('instanceId', null)
const cfg = computed(() => profileConfig?.value || {})
const mapId = (injectedInstanceId?.value || injectedInstanceId) ||
('lang-cluster-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8))
// ─── State ────────────────────────────────────────────────────────────────────
const lcRoot = ref(null)
const mapContainer = ref(null)
const appReady = ref(false)
const statusMsg = ref('Loading people groups…')
const clusters = ref([]) // [{key,lang,country,province,scope,count,lat,lng,reached:[]}]
const reachedAll = ref([]) // flat reached list with placement coords
const selectedKey = ref(null)
const tip = ref({ show: false, x: 0, y: 0, html: '' })
// ─── Country centroids (for placing GREEN reached — reached rows have no lat/lng) ─
const COUNTRY_CENTROIDS = {
'India': [79.0, 22.0], 'Pakistan': [69.3, 30.0], 'Indonesia': [113.0, -1.0],
'Bangladesh': [90.3, 23.7], 'China': [104.0, 35.0], 'Japan': [138.0, 37.0],
'Iran': [53.0, 32.0], 'Türkiye (Turkey)': [35.0, 39.0], 'Egypt': [30.0, 26.5],
'Nigeria': [8.0, 9.5], 'Thailand': [101.0, 15.0], 'Sudan': [30.0, 15.5],
'Algeria': [3.0, 28.0], 'Iraq': [44.0, 33.0], 'Myanmar (Burma)': [96.0, 21.0],
'Afghanistan': [66.0, 34.0], 'Yemen': [47.5, 15.5], 'Morocco': [-6.0, 32.0],
'Uzbekistan': [64.0, 41.5], 'Saudi Arabia': [45.0, 24.0], 'Ethiopia': [39.5, 9.0],
'Niger': [9.0, 17.5], 'Korea, North': [127.0, 40.0], 'Nepal': [84.0, 28.2],
'Mali': [-3.5, 17.0], 'Somalia': [46.5, 5.5], 'Malaysia': [108.0, 3.5],
'Russia': [90.0, 60.0], 'Cambodia': [105.0, 12.7], 'Kazakhstan': [67.0, 48.0],
'Senegal': [-14.5, 14.5], 'United States': [-98.0, 39.0], 'Guinea': [-11.0, 10.5],
'Tunisia': [9.5, 34.0], 'Chad': [19.0, 15.5], 'Tajikistan': [71.0, 38.7],
'Jordan': [36.5, 31.0], 'Azerbaijan': [47.5, 40.3], 'Vietnam': [106.0, 16.0],
'Israel': [35.0, 31.3], 'Armenia': [45.0, 40.2], 'Austria': [14.5, 47.5],
'Bahrain': [50.6, 26.0], 'Brunei': [114.7, 4.5], 'Bulgaria': [25.5, 42.7],
'Burkina Faso': [-1.7, 12.3], 'Cameroon': [12.5, 6.0], 'Canada': [-106.0, 56.0],
'Cape Verde': [-23.6, 16.0], 'China, Hong Kong': [114.2, 22.3], 'China, Macau': [113.5, 22.2],
'Cyprus': [33.2, 35.1], "Côte d'Ivoire": [-5.5, 7.5], 'Eritrea': [39.0, 15.3],
'France': [2.5, 47.0], 'Georgia': [43.5, 42.0], 'Ghana': [-1.0, 7.9], 'Greece': [22.0, 39.0],
'Guinea-Bissau': [-15.0, 12.0], 'Italy': [12.5, 42.5], 'Kenya': [38.0, 0.2],
'Korea, South': [128.0, 36.5], 'Kuwait': [47.7, 29.3], 'Kyrgyzstan': [74.5, 41.3],
'Lebanon': [35.8, 33.9], 'Liberia': [-9.4, 6.5], 'Moldova': [28.5, 47.2],
'Oman': [56.0, 21.0], 'Portugal': [-8.0, 39.5], 'Qatar': [51.2, 25.3], 'Serbia': [21.0, 44.0],
'Sierra Leone': [-11.8, 8.5], 'Singapore': [103.8, 1.35], 'South Sudan': [31.0, 7.5],
'Spain': [-3.7, 40.3], 'Switzerland': [8.2, 46.8], 'Syria': [38.0, 35.0],
'Tanzania': [35.0, -6.3], 'Togo': [1.0, 8.6], 'Turkmenistan': [59.0, 39.0],
'Uganda': [32.5, 1.4], 'Ukraine': [31.0, 49.0], 'United Arab Emirates': [54.0, 24.0]
}
function jittered(country, key) {
const base = COUNTRY_CENTROIDS[country]
if (!base) return null
let h = 0; const s = String(key || '')
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff
return [base[0] + ((h & 0xff) / 255 - 0.5) * 4, base[1] + (((h >> 8) & 0xff) / 255 - 0.5) * 4]
}
const langKey = (s) => String(s || '').trim().toLowerCase()
// ─── Map instance (shadow-DOM safe, @map) ────────────────────────────────────
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken?.value || cfg.value.tk || '',
style: cfg.value.style ?? 'mapbox://styles/mapbox/light-v11',
center: cfg.value.center ?? [55, 22],
zoom: cfg.value.zoom ?? 1.7,
minZoom: cfg.value.minZoom ?? 1,
maxZoom: cfg.value.maxZoom ?? 12
})
// ─── Layer / source ids ──────────────────────────────────────────────────────
const SRC_CL = 'lc-clusters-src', SRC_PIN = 'lc-pins-src', SRC_RE = 'lc-reached-src', SRC_LK = 'lc-links-src'
const L_CL = 'lc-clusters', L_CL_LABEL = 'lc-cluster-labels', L_PIN = 'lc-pins'
const L_RE = 'lc-reached', L_LK = 'lc-links'
// ─── Derived stats ────────────────────────────────────────────────────────────
const stats = computed(() => {
const cl = clusters.value
const provinces = new Set(cl.filter(c => c.scope === 'province').map(c => c.province))
const langs = new Set(cl.map(c => c.lang))
return {
clusters: cl.length,
pgs: cl.reduce((s, c) => s + c.count, 0),
provinces: provinces.size,
langs: langs.size,
withReach: cl.filter(c => c.reached.length).length
}
})
const selected = computed(() => clusters.value.find(c => c.key === selectedKey.value) || null)
// ─── Normalize a {value,label} or string → label string ──────────────────────
function lbl(f) { return (f && typeof f === 'object') ? (f.label ?? f.value ?? '') : (f ?? '') }
function val(f) { return (f && typeof f === 'object') ? (f.value ?? f.label ?? '') : (f ?? '') }
const COUNTRY_DISPLAY = {
"People's Republic of China": 'China',
'Lao People’s Democratic Republic': 'Laos',
"Lao People's Democratic Republic": 'Laos'
}
function normCountry(s) { return COUNTRY_DISPLAY[s] || s }
// ─── Build clusters from the live records + baked province membership ─────────
function buildClusters(records, adminProv) {
const map = new Map()
for (const r of records) {
const lat = Number(r.latitude), lng = Number(r.longitude)
const lang = lbl(r.primary_language) || '(unknown language)'
const country = normCountry(lbl(r.country_code) || '(unknown)')
const slug = r.slug || val(r.primary_language) + ':' + r.name
const province = adminProv[slug] || null // only present for IN/CN
const scope = province ? 'province' : 'country'
const scopeName = province || country
const key = lang + ' · ' + scopeName + (province ? ' (' + country + ')' : '')
let c = map.get(key)
if (!c) {
// PPLR-3: carry rol3 (ISO 639-3) = primary_language.value, the clean join key to reached neighbors.
c = { key, lang, rol3: val(r.primary_language) || '', country, province, scope, scopeName, count: 0,
_sumLat: 0, _sumLng: 0, _n: 0, members: [], reached: [] }
map.set(key, c)
}
c.count++
c.members.push({ name: r.name, slug, lang, country })
if (isFinite(lat) && isFinite(lng)) { c._sumLat += lat; c._sumLng += lng; c._n++ }
}
const out = []
for (const c of map.values()) {
// centroid = mean of members with coords; skip clusters with no geocoded member
if (!c._n) continue
c.lat = c._sumLat / c._n
c.lng = c._sumLng / c._n
delete c._sumLat; delete c._sumLng; delete c._n
out.push(c)
}
out.sort((a, b) => b.count - a.count)
return out
}
// ─── Build the flat reached set + match to clusters by language ───────────────
// PPLR-3: join reached neighbors to unreached clusters by rol3 (ISO 639-3) CODE — exact — with a
// language-NAME fallback for rows lacking a code. The old name-only match hit only 31/100 reached
// langs (label drift, e.g. "Korean" vs the doxa label); the PPLR-2 data now carries rol3 on every
// neighbor and doxa primary_language.value IS the rol3, so the code join densifies green well past that.
function buildReached(reachedJson, clusterList) {
const clusterRol3 = new Set(clusterList.map(c => c.rol3).filter(Boolean))
const clusterNames = new Set(clusterList.map(c => langKey(c.lang)))
const seen = new Set()
const flat = []
const byRol3 = new Map()
const byName = new Map()
for (const t of Object.values(reachedJson.countries || {})) {
for (const n of (t.neighbors || [])) {
const lk = langKey(n.lang)
const rol3 = n.rol3 || ''
const codeHit = rol3 && clusterRol3.has(rol3)
if (!codeHit && !clusterNames.has(lk)) continue // rol3 exact, else language-name fallback
const id = n.name + '|' + n.country + '|' + (rol3 || lk)
if (seen.has(id)) continue
seen.add(id)
// PPLR-2 gave each neighbor REAL Lat/Long — use it; fall back to country-centroid jitter.
const coord = (Number(n.lng) && Number(n.lat)) ? [Number(n.lng), Number(n.lat)] : jittered(n.country, id)
if (!coord) continue
const row = { name: n.name, country: n.country, lang: n.lang, rol3, lk,
pct: n.pct_christian || 0, pop: n.pop || 0, lng: coord[0], lat: coord[1] }
flat.push(row)
if (rol3) { if (!byRol3.has(rol3)) byRol3.set(rol3, []); byRol3.get(rol3).push(row) }
if (!byName.has(lk)) byName.set(lk, []); byName.get(lk).push(row)
}
}
// attach matched reached to each cluster — rol3 code first, language-name fallback, unioned + deduped
for (const c of clusterList) {
const fromCode = c.rol3 ? (byRol3.get(c.rol3) || []) : []
const fromName = byName.get(langKey(c.lang)) || []
c.reached = (fromCode.length && fromName.length) ? [...new Set([...fromCode, ...fromName])]
: (fromCode.length ? fromCode : fromName)
}
return flat
}
// ─── GeoJSON helpers ──────────────────────────────────────────────────────────
const fc = (features) => ({ type: 'FeatureCollection', features })
function clusterFeatures() {
return fc(clusters.value.map(c => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [c.lng, c.lat] },
properties: { key: c.key, lang: c.lang, count: c.count, scope: c.scope,
hasReach: c.reached.length > 0, label: c.lang + ' (' + c.count + ')' }
})))
}
function pinFeatures() {
const feats = []
for (const c of clusters.value) {
for (const m of c.members) {
// member coords aren't stored individually; place small dots around the cluster centroid
// (the cluster bubble carries the true mean; pins are visual context at high zoom)
const p = jittered(c.country, m.slug) // light spread; falls back to centroid below
const coord = p || [c.lng, c.lat]
feats.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [c.lng + (coord[0] - (COUNTRY_CENTROIDS[c.country]?.[0] ?? coord[0])) * 0.15, c.lat] },
properties: { key: c.key } })
}
}
return fc(feats)
}
function reachedFeatures() {
const sel = selected.value
const selRol3 = sel ? (sel.rol3 || '') : ''
const selName = sel ? langKey(sel.lang) : null
return fc(reachedAll.value.map(r => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
properties: { name: r.name, country: r.country, lang: r.lang, pct: r.pct,
color: reachedColor(r.pct),
matched: sel ? ((selRol3 && r.rol3 === selRol3) || (!!selName && r.lk === selName)) : false }
})))
}
function linkFeatures() {
const c = selected.value
if (!c) return fc([])
return fc(c.reached.map(r => ({
type: 'Feature',
geometry: { type: 'LineString', coordinates: [[c.lng, c.lat], [r.lng, r.lat]] },
properties: {}
})))
}
// ─── Paint (idempotent; survives style reloads) ──────────────────────────────
function setSrc(m, id, data) {
const s = m.getSource(id)
if (s) s.setData(data); else m.addSource(id, { type: 'geojson', data })
}
function paintAll() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
setSrc(m, SRC_LK, linkFeatures())
setSrc(m, SRC_PIN, pinFeatures())
setSrc(m, SRC_RE, reachedFeatures())
setSrc(m, SRC_CL, clusterFeatures())
if (!m.getLayer(L_LK)) m.addLayer({
id: L_LK, type: 'line', source: SRC_LK,
paint: { 'line-color': C_GOLD, 'line-width': 1.1, 'line-opacity': 0.5, 'line-dasharray': [2, 2] }
})
if (!m.getLayer(L_PIN)) m.addLayer({
id: L_PIN, type: 'circle', source: SRC_PIN, minzoom: 3.6,
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 3.6, 1.6, 8, 4],
'circle-color': C_RED, 'circle-opacity': 0.5 }
})
if (!m.getLayer(L_RE)) m.addLayer({
id: L_RE, type: 'circle', source: SRC_RE,
paint: {
'circle-radius': ['case', ['get', 'matched'], 4.5, ['interpolate', ['linear'], ['zoom'], 1, 2, 6, 3.5]],
'circle-color': ['get', 'color'],
'circle-stroke-width': ['case', ['get', 'matched'], 1.5, 0.5],
'circle-stroke-color': ['case', ['get', 'matched'], C_GOLD, '#ffffff'],
'circle-opacity': ['case', ['boolean', ['feature-state', 'dim'], false], 0.15,
['case', ['get', 'matched'], 0.95, 0.6]]
}
})
if (!m.getLayer(L_CL)) m.addLayer({
id: L_CL, type: 'circle', source: SRC_CL,
paint: {
'circle-radius': ['interpolate', ['linear'], ['get', 'count'], 1, 7, 25, 16, 80, 26, 130, 34],
'circle-color': C_RED,
'circle-opacity': ['case', ['==', ['get', 'key'], ['literal', '']], 0.6, 0.55],
'circle-stroke-width': ['case', ['get', 'hasReach'], 2.4, 1],
'circle-stroke-color': ['case', ['get', 'hasReach'], reachedColor(80), C_RED_DK]
}
})
if (!m.getLayer(L_CL_LABEL)) m.addLayer({
id: L_CL_LABEL, type: 'symbol', source: SRC_CL, minzoom: 2.6,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.3],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': C_RED_DK, 'text-halo-color': '#ffffff', 'text-halo-width': 1.4 }
})
applySelectionPaint()
}
function applySelectionPaint() {
const m = map.value
if (!m || !m.getLayer(L_CL)) return
// gold stroke on the selected cluster
m.setPaintProperty(L_CL, 'circle-stroke-color',
selectedKey.value
? ['case', ['==', ['get', 'key'], selectedKey.value], C_GOLD,
['case', ['get', 'hasReach'], reachedColor(80), C_RED_DK]]
: ['case', ['get', 'hasReach'], reachedColor(80), C_RED_DK])
m.setPaintProperty(L_CL, 'circle-stroke-width',
selectedKey.value
? ['case', ['==', ['get', 'key'], selectedKey.value], 4, ['case', ['get', 'hasReach'], 2.4, 1]]
: ['case', ['get', 'hasReach'], 2.4, 1])
}
// ─── Selection + drill ────────────────────────────────────────────────────────
function selectCluster(key) {
selectedKey.value = (selectedKey.value === key) ? null : key
const m = map.value
if (m) { setSrc(m, SRC_RE, reachedFeatures()); setSrc(m, SRC_LK, linkFeatures()); applySelectionPaint() }
const c = selected.value
if (c && m) {
try {
let minLng = c.lng, maxLng = c.lng, minLat = c.lat, maxLat = c.lat
for (const r of c.reached) {
minLng = Math.min(minLng, r.lng); maxLng = Math.max(maxLng, r.lng)
minLat = Math.min(minLat, r.lat); maxLat = Math.max(maxLat, r.lat)
}
m.stop()
if (minLng === maxLng && minLat === maxLat) m.easeTo({ center: [c.lng, c.lat], zoom: 4, duration: 800 })
else m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 110, maxZoom: 5.5, duration: 800 })
} catch (_) {}
}
}
function drillToFlower() {
const c = selected.value
if (!c) return
const detail = { target: c.country, language: c.lang, province: c.province || null, scope: c.scope, clusterKey: c.key }
try {
const host = lcRoot.value && lcRoot.value.getRootNode && lcRoot.value.getRootNode().host
const node = host || lcRoot.value
node && node.dispatchEvent(new CustomEvent('upgn:drill-flower', { bubbles: true, composed: true, detail }))
} catch (_) {}
}
// ─── Tooltip ──────────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value.y = ev.clientY } }
// ─── Boot ─────────────────────────────────────────────────────────────────────
// PPLR-1: baked data INLINED (static imports) so it ships inside the bundle — vite doesn't deploy
// the profile data/ folder, so fetch('./data/...') 404s in the REAL platform (worked in dev only).
// adminUrl/dataUrl/dataBase still override live (loadJSON below).
import bakedReached from './data/reached-neighbors.json'
import bakedAdmin1 from './data/admin1-membership.json'
async function loadJSON(relCandidates) {
for (const url of relCandidates) {
try { const r = await fetch(url); if (r.ok) return await r.json() } catch (_) {}
}
return null
}
async function fetchDoxa() {
const base = cfg.value.apiBase
|| (typeof window !== 'undefined' && (window.MAP_APP_API_URL || window.VITE_API_BASE_URL))
|| 'https://pray.doxa.life'
const fields = 'slug,name,latitude,longitude,country_code,primary_language,wagf_block,population'
const url = base.replace(/\/?$/, '') + '/api/people-groups/list?fields=' + fields + '&lang=en'
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
return j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : [])
}
onMounted(async () => {
await initializeMap()
const m = map.value
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
// baked data (own bundle dir). `base` lets the host say where data/ is served from
// (platform deploy serves data/ alongside the bundle, like send-map/flower-mesh).
// override-then-baked: a host-provided adminUrl/dataUrl/dataBase fetches live; otherwise the
// INLINED baked imports stand (no runtime fetch -> no 404 in the platform).
const base = (cfg.value.dataBase) || (typeof window !== 'undefined' && window.MAP_APP_BASE_URL) || ''
const baseUrl = (f) => base ? base.replace(/\/?$/, '/') + 'data/' + f : null
const adminProv = (await loadJSON([cfg.value.adminUrl, baseUrl('admin1-membership.json')].filter(Boolean))) || bakedAdmin1 || {}
const reachedJson = (await loadJSON([cfg.value.dataUrl, baseUrl('reached-neighbors.json')].filter(Boolean))) || bakedReached || { countries: {} }
// live unreached doxa-2000
let records = []
try { records = await fetchDoxa() }
catch (e) { statusMsg.value = 'Live doxa API unreachable — ' + e.message; appReady.value = true; return }
clusters.value = buildClusters(records, adminProv)
reachedAll.value = buildReached(reachedJson, clusters.value)
appReady.value = true
// PPLR-3 verify hook: reached pin count + how many unreached clusters now have a matched reached
// neighbor (the green densification metric — rol3 join + name fallback).
try {
window.__lcStats = {
reachedPins: reachedAll.value.length,
matchedClusters: clusters.value.filter(c => c.reached && c.reached.length).length,
totalClusters: clusters.value.length,
matchedByRol3: reachedAll.value.filter(r => r.rol3).length,
}
} catch (_) { /* verify hook */ }
// click + hover wiring
const wire = () => {
paintAll()
m.on('click', L_CL, (e) => { const f = e.features?.[0]; if (f) selectCluster(f.properties.key) })
const hover = (layer, html) => {
m.on('mouseenter', layer, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', layer, () => { m.getCanvas().style.cursor = ''; tip.value = { ...tip.value, show: false } })
m.on('mousemove', layer, (e) => {
const f = e.features?.[0]; if (!f) return
tip.value = { show: true, x: e.originalEvent.clientX, y: e.originalEvent.clientY, html: html(f.properties) }
})
}
hover(L_CL, p => `<strong>${esc(p.lang)}</strong><div class="lc-tip-sub">${esc(p.key)}</div>`
+ `<div class="lc-tip-row"><span class="lc-tip-k">unreached</span> ${p.count} groups</div>`
+ `<div class="lc-tip-row"><span class="lc-tip-k">reached</span> ${p.hasReach ? 'matching neighbors ✓' : 'none found'}</div>`
+ `<div class="lc-tip-row" style="color:#9fb3d9">click → bounded set + flower</div>`)
hover(L_RE, p => `<strong>${esc(p.name)}</strong><div class="lc-tip-sub">${esc(p.country)} · ${esc(p.lang)}</div>`
+ `<div class="lc-tip-row"><span class="lc-tip-k">reached</span> ${Math.round(p.pct)}% Christian</div>`)
}
if (m?.isStyleLoaded?.()) wire()
else m?.once?.('load', wire)
// belt-and-suspenders repaint (style timing race — Lesson-03 in the sibling)
m?.once?.('idle', () => { if (appReady.value) paintAll() })
})
onBeforeUnmount(() => { destroy() })
// ─── Shadow-DOM CSS (single template; color consts interpolated, no nested backticks) ─
useShadowStyles(`
.lc-root { position:absolute; inset:0; display:flex; flex-direction:column; overflow:hidden;
background:#ffffff; color:#1f2937; isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.lc-map { position:absolute; inset:0; }
.lc-loading { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:#ffffff; color:${C_NAVY}; font:13px/1 ui-monospace,monospace; z-index:5; }
.lc-top { position:absolute; top:10px; left:10px; right:10px; z-index:30; display:flex;
flex-wrap:wrap; gap:8px; align-items:center; pointer-events:none; }
.lc-top > * { pointer-events:auto; }
.lc-title { background:${C_NAVY}; color:#fff; font:700 12px/1 ui-monospace,monospace;
letter-spacing:.04em; text-transform:uppercase; padding:8px 12px; border-radius:8px; }
.lc-stats { margin-left:auto; background:rgba(255,255,255,.94); border:1px solid #d1d5db;
border-radius:8px; padding:7px 11px; font:600 11px/1.5 ui-monospace,monospace; color:#374151; text-align:right; }
.lc-stats b { color:${C_RED_DK}; }
.lc-legend { position:absolute; left:10px; bottom:34px; z-index:25; background:rgba(255,255,255,.95);
border:1px solid #d1d5db; border-radius:10px; padding:9px 12px; max-width:240px; box-shadow:0 4px 14px rgba(0,0,0,.12); }
.lc-legend h4 { font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; text-transform:uppercase; color:#6b7280; margin-bottom:8px; }
.lc-lrow { display:flex; align-items:center; gap:8px; padding:3px 0; font-size:11px; color:#374151; }
.lc-dot { width:12px; height:12px; border-radius:50%; flex:0 0 auto; border:1px solid rgba(0,0,0,.25); }
.lc-panel { position:absolute; top:52px; right:10px; bottom:34px; z-index:26; width:290px; max-width:48vw;
background:rgba(255,255,255,.98); border:1px solid #d1d5db; border-radius:10px; box-shadow:0 4px 16px rgba(0,0,0,.16);
display:flex; flex-direction:column; overflow:hidden; }
.lc-panel-head { padding:11px 13px; background:${C_RED}; color:#fff; }
.lc-panel-head .lc-h-lang { font:800 15px/1.2 -apple-system,sans-serif; }
.lc-panel-head .lc-h-sub { margin-top:3px; font:600 11px/1.3 ui-monospace,monospace; color:#ffe4e6; }
.lc-panel-body { flex:1 1 0; overflow-y:auto; padding:9px 11px; }
.lc-sec { font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; text-transform:uppercase; color:#6b7280; margin:6px 0 6px; }
.lc-chip { display:inline-block; font:600 10px/1 ui-monospace,monospace; padding:3px 7px; border-radius:999px;
border:1px solid #d1d5db; color:#374151; background:#f9fafb; margin:0 4px 4px 0; }
.lc-re { border:1px solid #e5e7eb; border-radius:8px; padding:6px 8px; margin-bottom:5px; }
.lc-re-name { font:700 12px/1.2 -apple-system,sans-serif; color:#111827; display:flex; align-items:center; gap:6px; }
.lc-re-meta { margin-top:3px; font:11px/1.3 ui-monospace,monospace; color:#4b5563; }
.lc-drill { display:block; width:100%; margin-top:8px; padding:9px; border:0; border-radius:8px; cursor:pointer;
background:${C_NAVY}; color:#fff; font:700 12px/1 -apple-system,sans-serif; }
.lc-drill:hover { background:#013a8a; }
.lc-muted { color:#6b7280; font-size:11px; padding:8px 2px; }
.lc-footer { position:absolute; left:0; right:0; bottom:0; z-index:20; background:${C_NAVY}; color:#dbe4f3;
font:10px/1.4 ui-monospace,monospace; padding:5px 12px; display:flex; flex-wrap:wrap; gap:3px 14px; align-items:center; }
.lc-footer b { color:#fff; }
.lc-tip { position:fixed; z-index:60; pointer-events:none; max-width:240px; background:#0e1730;
border:1px solid #34508c; border-radius:9px; padding:9px 11px; box-shadow:0 6px 22px rgba(0,0,0,.5); font-size:11.5px; color:#dbe4f3; }
.lc-tip strong { font-size:13px; color:#fff; }
.lc-tip-sub { color:#fda4af; font:600 10px/1.3 ui-monospace,monospace; margin:2px 0 6px; }
.lc-tip-row { display:flex; gap:6px; line-height:1.5; }
.lc-tip-k { color:#7e90b6; min-width:64px; }
@media (max-width:720px){ .lc-stats{ display:none; } .lc-panel{ width:210px; } .lc-legend{ max-width:160px; } }
`, 'lang-cluster')
</script>
<template>
<div ref="lcRoot" class="lc-root" @mousemove="onMove">
<div ref="mapContainer" class="lc-map"></div>
<div v-if="!appReady" class="lc-loading">{{ statusMsg }}</div>
<!-- top bar -->
<div class="lc-top">
<span class="lc-title">My UPG Neighbor · Language Clusters</span>
<span v-if="appReady" class="lc-stats">
<b>{{ stats.clusters }}</b> language clusters · <b>{{ stats.pgs }}</b> unreached<br>
<b>{{ stats.provinces }}</b> IN/CN provinces · <b>{{ stats.withReach }}</b> with reached match
</span>
</div>
<!-- legend -->
<div v-if="appReady" class="lc-legend">
<h4>Bounded sets</h4>
<div class="lc-lrow"><span class="lc-dot" :style="{ background: C_RED }"></span> Unreached language cluster (size = #groups)</div>
<div class="lc-lrow"><span class="lc-dot" :style="{ background: reachedColor(80) }"></span> Reached neighbor (matched by language)</div>
<div class="lc-lrow" style="color:#6b7280;font-size:10px;margin-top:4px">green ring = cluster has a reached match · click a cluster to drill</div>
</div>
<!-- selected bounded-set panel -->
<div v-if="appReady && selected" class="lc-panel">
<div class="lc-panel-head">
<div class="lc-h-lang">{{ selected.lang }}</div>
<div class="lc-h-sub">{{ selected.scope === 'province' ? selected.province + ' · ' + selected.country : selected.country }}</div>
</div>
<div class="lc-panel-body">
<div class="lc-sec">Bounded set — {{ selected.count }} unreached groups</div>
<span v-for="(m, i) in selected.members.slice(0, 18)" :key="i" class="lc-chip">{{ m.name }}</span>
<span v-if="selected.members.length > 18" class="lc-chip">+{{ selected.members.length - 18 }} more</span>
<div class="lc-sec" style="margin-top:10px">Reached neighbors (same language) — {{ selected.reached.length }}</div>
<div v-if="!selected.reached.length" class="lc-muted">No reached people-group shares this language in the baked set. A frontier — pray for laborers (Luke 10:2).</div>
<div v-for="(r, i) in selected.reached" :key="'r'+i" class="lc-re">
<div class="lc-re-name"><span class="lc-dot" :style="{ background: reachedColor(r.pct) }"></span>{{ r.name }}</div>
<div class="lc-re-meta">{{ r.country }} · {{ Math.round(r.pct) }}% Christian · {{ r.lang }}</div>
</div>
<button class="lc-drill" @click="drillToFlower">Open flower view ▸</button>
</div>
</div>
<!-- provenance -->
<div v-if="appReady" class="lc-footer">
<span><b>Language clusters</b> red unreached · green reached-by-language</span>
<span>unreached = LIVE pray.doxa.life ({{ stats.pgs }})</span>
<span>IN/CN province = real ADM1 (my-upg-100-list point-in-polygon)</span>
<span>reached = baked reached-neighbors</span>
<span>Luke 10:2 · bounded sets → flowers</span>
</div>
<div v-if="tip.show" class="lc-tip" :style="{ left: (tip.x + 14) + 'px', top: (tip.y + 14) + 'px' }" v-html="tip.html"></div>
</div>
</template>

View file

@ -0,0 +1,523 @@
<script setup>
/**
* send-map.vue — Application Profile: My UPG Neighbor (cross-border SEND model)
*
* @profile send-map
* @bundle doxa-upg-neighbor
* @element send-map
*
* Renders the SEND model on an interactive Mapbox map:
* After "My UPG 100 List" identifies the unreached (≤0.1% Christian, ≥500k
* pop), "My UPG Neighbor" finds, for each unreached PG, the REACHED neighbor
* PG that can be *sent* to reach it.
*
* Authoritative vision + data contract: AT Tier-3 spec
* app-profiles/my-upg-neighbor/_qa-cycle/01-ideation-research/AT-TIER3-SPEC.md
*
* A qualifying neighbor satisfies the 4-dimension JOIN (spec §1):
* 1. Adjacency — same-country, else neighbor within 1500 km centroid
* 2. Language pair — same PrimaryLanguageName (strong) else PeopleCluster
* 3. Reached % — neighbor ≥ 10% Christian (higher = more laborers)
* 4. Lower persecution (reported, not hard-filtered)
* → OUTPUT: SEND the reached neighbor INTO the unreached target.
*
* Structural mirror of app-profiles/impact-map/profiles/impact-map.vue:
* reads profile-config via inject() from ProfileLoader, uses useMapInstance for
* shadow-DOM-safe init + useShadowStyles for CSS, fetches its OWN baked JSON
* (data/reached-neighbors.json — the 4-D join output, frozen 2026-06-12) and
* builds raw Mapbox layers (no people-group DataSourceManager pipeline — this
* profile carries country-level SEND nodes, not the pray-tools PG dataset).
*
* The baked JSON has no per-row lat/lng — "neighbor" is a reached PEOPLE pool
* keyed by country. We join each row to a country centroid (COUNTRY_CENTROIDS
* below) so a target's qualifying senders render as nodes around it, with a
* dashed SEND arrow drawn from each sender's country centroid INTO the target.
*
* profile-config shape:
* {
* "profile": "send-map",
* "tk": "pk.YOUR_MAPBOX_TOKEN",
* "dataUrl": "<url-to-reached-neighbors.json>", // optional override
* "target": "India", // optional default target
* "instanceId": "any-stable-id"
* }
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// ─── Palette (spec §5 — light mode; orange/navy; NO brown) ───────────────────
const C_ORANGE = '#FF6600' // unreached / target
const C_NAVY = '#002e6e' // chrome / sender connectors
const C_GREEN = '#1f9d55' // reached (high %Christian)
const C_GREEN_LO = '#9bcf9b' // reached (low %Christian)
// ─── Shadow-DOM style injection ──────────────────────────────────────────────
useShadowStyles(`
.sn-root { position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;background:#ffffff;color:#1f2937;isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.sn-map-area { flex:1 1 0;position:relative;overflow:hidden; }
.sn-map-canvas { position:absolute;inset:0; }
.sn-loading { position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#ffffff;color:${C_NAVY};font-size:13px;z-index:5; }
/* ── Top bar: title + target selector ── */
.sn-top { position:absolute;top:10px;left:10px;z-index:30;display:flex;flex-wrap:wrap;gap:8px;align-items:center; }
.sn-title { background:${C_NAVY};color:#fff;font:700 12px/1 ui-monospace,monospace;letter-spacing:.04em;text-transform:uppercase;padding:8px 12px;border-radius:8px; }
.sn-select { background:#fff;border:1px solid ${C_NAVY};color:${C_NAVY};font:600 12px/1 -apple-system,sans-serif;padding:7px 10px;border-radius:8px;cursor:pointer;max-width:240px; }
/* ── Left: 4-D JOIN legend (the gate the connectors pass through) ── */
.sn-legend { position:absolute;top:58px;left:10px;z-index:25;background:rgba(255,255,255,0.95);border:1px solid #d1d5db;border-radius:10px;padding:9px 12px;box-shadow:0 4px 14px rgba(0,0,0,0.12);max-width:230px; }
.sn-legend h4 { font:700 9px/1 ui-monospace,monospace;letter-spacing:.1em;text-transform:uppercase;color:#6b7280;margin-bottom:8px; }
.sn-legend-row { display:flex;align-items:flex-start;gap:7px;padding:3px 0;font-size:11px;color:#374151;line-height:1.3; }
.sn-dot { width:11px;height:11px;border-radius:50%;flex:0 0 auto;border:1px solid rgba(0,0,0,0.35);margin-top:1px; }
.sn-legend .sn-num { font-weight:700;color:${C_NAVY};flex:0 0 auto; }
/* ── Right: worked sender table for the selected target ── */
.sn-panel { position:absolute;top:10px;right:10px;bottom:42px;z-index:25;width:280px;max-width:46vw;background:rgba(255,255,255,0.97);border:1px solid #d1d5db;border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,0.12);display:flex;flex-direction:column;overflow:hidden; }
.sn-panel-head { padding:10px 12px;border-bottom:1px solid #e5e7eb;background:${C_NAVY};color:#fff; }
.sn-panel-head .sn-target { font:700 13px/1.2 -apple-system,sans-serif; }
.sn-panel-head .sn-sub { margin-top:4px;font:11px/1.3 ui-monospace,monospace;color:#c7d2fe; }
.sn-panel-body { flex:1 1 0;overflow-y:auto;padding:6px 8px; }
.sn-sender { border:1px solid #e5e7eb;border-radius:8px;padding:7px 9px;margin-bottom:6px;cursor:pointer;transition:border-color .12s,box-shadow .12s; }
.sn-sender:hover { border-color:${C_ORANGE};box-shadow:0 2px 8px rgba(255,102,0,0.18); }
.sn-sender-name { font:700 12px/1.2 -apple-system,sans-serif;color:#111827;display:flex;align-items:center;gap:6px; }
.sn-sender-meta { margin-top:4px;display:flex;flex-wrap:wrap;gap:4px; }
.sn-chip { font:600 10px/1 ui-monospace,monospace;padding:3px 6px;border-radius:999px;border:1px solid #d1d5db;color:#374151;background:#f9fafb; }
.sn-chip.pct { color:#0b5e33;background:#e7f6ec;border-color:#b7e0c4; }
.sn-chip.bridge { color:${C_NAVY};background:#e7edf8;border-color:#c2d2ee; }
.sn-chip.adj { color:#7a3e00;background:#fdeedd;border-color:#f3d4ac; }
/* ── Footer: provenance (honesty contract, spec §3 + §5) ── */
.sn-footer { flex:0 0 auto;background:${C_NAVY};color:#dbe4f3;font:10px/1.4 ui-monospace,monospace;padding:6px 12px;display:flex;flex-wrap:wrap;gap:4px 14px;align-items:center; }
.sn-footer strong { color:#fff; }
@media(max-width:760px){
.sn-panel { width:200px; }
.sn-legend { max-width:180px; }
}
`, 'send-map')
// ─── Config from ProfileLoader (provide/inject) ──────────────────────────────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const injectedInstanceId = inject('instanceId', null)
const mapId = (injectedInstanceId?.value || injectedInstanceId) ||
('send-map-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8))
// ─── Refs (declared before useMapInstance so containerRef binds the canvas) ───
const snRoot = ref(null)
const mapContainer = ref(null)
const appReady = ref(false)
const dataset = ref(null) // parsed reached-neighbors.json
const activeTarget = ref(profileConfig?.value?.target ?? 'India')
// ─── Country centroids ────────────────────────────────────────────────────────
// [lng, lat] for every target + neighbor country present in the baked JSON.
// The JSON keys SEND rows by country (the reached pool is people-by-country),
// so we need a centroid per country to place sender nodes + draw SEND arrows.
// Approximate population/area centroids — sufficient for a country-level SEND
// schematic (this is NOT an admin-tile choropleth). Names match the JSON's
// country strings VERBATIM (incl. "Türkiye (Turkey)", "Myanmar (Burma)",
// "Korea, North/South", "China, Hong Kong/Macau", "Côte d'Ivoire").
const COUNTRY_CENTROIDS = {
'India': [79.0, 22.0], 'Pakistan': [69.3, 30.0], 'Indonesia': [113.0, -1.0],
'Bangladesh': [90.3, 23.7], 'China': [104.0, 35.0], 'Japan': [138.0, 37.0],
'Iran': [53.0, 32.0], 'Türkiye (Turkey)': [35.0, 39.0], 'Egypt': [30.0, 26.5],
'Nigeria': [8.0, 9.5], 'Thailand': [101.0, 15.0], 'Sudan': [30.0, 15.5],
'Algeria': [3.0, 28.0], 'Iraq': [44.0, 33.0], 'Myanmar (Burma)': [96.0, 21.0],
'Afghanistan': [66.0, 34.0], 'Yemen': [47.5, 15.5], 'Morocco': [-6.0, 32.0],
'Uzbekistan': [64.0, 41.5], 'Saudi Arabia': [45.0, 24.0], 'Ethiopia': [39.5, 9.0],
'Niger': [9.0, 17.5], 'Korea, North': [127.0, 40.0], 'Nepal': [84.0, 28.2],
'Mali': [-3.5, 17.0], 'Somalia': [46.5, 5.5], 'Malaysia': [108.0, 3.5],
'Russia': [90.0, 60.0], 'Cambodia': [105.0, 12.7], 'Kazakhstan': [67.0, 48.0],
'Senegal': [-14.5, 14.5], 'United States': [-98.0, 39.0], 'Guinea': [-11.0, 10.5],
'Tunisia': [9.5, 34.0], 'Chad': [19.0, 15.5], 'Tajikistan': [71.0, 38.7],
'Jordan': [36.5, 31.0], 'Azerbaijan': [47.5, 40.3], 'Vietnam': [106.0, 16.0],
'Israel': [35.0, 31.3],
// ── neighbor-only countries ──
'Armenia': [45.0, 40.2], 'Austria': [14.5, 47.5], 'Bahrain': [50.6, 26.0],
'Brunei': [114.7, 4.5], 'Bulgaria': [25.5, 42.7], 'Burkina Faso': [-1.7, 12.3],
'Cameroon': [12.5, 6.0], 'Canada': [-106.0, 56.0], 'Cape Verde': [-23.6, 16.0],
'China, Hong Kong': [114.2, 22.3], 'China, Macau': [113.5, 22.2], 'Cyprus': [33.2, 35.1],
"Côte d'Ivoire": [-5.5, 7.5], 'Eritrea': [39.0, 15.3], 'France': [2.5, 47.0],
'Georgia': [43.5, 42.0], 'Ghana': [-1.0, 7.9], 'Greece': [22.0, 39.0],
'Guinea-Bissau': [-15.0, 12.0], 'Italy': [12.5, 42.5], 'Kenya': [38.0, 0.2],
'Korea, South': [128.0, 36.5], 'Kuwait': [47.7, 29.3], 'Kyrgyzstan': [74.5, 41.3],
'Lebanon': [35.8, 33.9], 'Liberia': [-9.4, 6.5], 'Moldova': [28.5, 47.2],
'Oman': [56.0, 21.0], 'Portugal': [-8.0, 39.5], 'Qatar': [51.2, 25.3],
'Serbia': [21.0, 44.0], 'Sierra Leone': [-11.8, 8.5], 'Singapore': [103.8, 1.35],
'South Sudan': [31.0, 7.5], 'Spain': [-3.7, 40.3], 'Syria': [38.0, 35.0],
'Tanzania': [35.0, -6.3], 'Togo': [1.0, 8.6], 'Turkmenistan': [59.0, 39.0],
'Uganda': [32.5, 1.4], 'Ukraine': [31.0, 49.0], 'United Arab Emirates': [54.0, 24.0]
}
function centroidFor(country, jitterKey) {
const base = COUNTRY_CENTROIDS[country]
if (!base) return null
// Deterministic small jitter so multiple same-country senders don't stack on
// one point (e.g. India's senders are all adjacency:same-country).
let h = 0
const s = String(jitterKey || '')
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff
const dx = ((h & 0xff) / 255 - 0.5) * 3.2
const dy = (((h >> 8) & 0xff) / 255 - 0.5) * 3.2
return [base[0] + dx, base[1] + dy]
}
// ─── Map instance (shadow-DOM safe) ──────────────────────────────────────────
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: profileConfig?.value?.style ?? 'mapbox://styles/mapbox/light-v11',
center: profileConfig?.value?.center ?? [60, 25],
zoom: profileConfig?.value?.zoom ?? 2.4,
minZoom: profileConfig?.value?.minZoom ?? 1,
maxZoom: profileConfig?.value?.maxZoom ?? 12
})
// ─── Derived state ────────────────────────────────────────────────────────────
const targetNames = computed(() => dataset.value ? Object.keys(dataset.value.countries) : [])
const targetData = computed(() => dataset.value?.countries?.[activeTarget.value] ?? null)
// Senders sorted by %Christian desc (spec §3 + §5 right-panel contract).
const senders = computed(() => {
const t = targetData.value
if (!t) return []
return [...(t.neighbors || [])]
.filter(n => COUNTRY_CENTROIDS[n.country])
.sort((a, b) => (b.pct_christian || 0) - (a.pct_christian || 0))
})
function senderColor(pct) {
// Higher %Christian = stronger sender = deeper green (more laborers).
return (pct >= 50) ? C_GREEN : C_GREEN_LO
}
// ─── Layer ids ────────────────────────────────────────────────────────────────
const SRC_ARROWS = 'sn-arrows-src'
const SRC_SENDERS = 'sn-senders-src'
const SRC_TARGET = 'sn-target-src'
const SRC_TARGETS_ALL = 'sn-targets-all-src' // ALL unreached targets (clickable)
const L_ARROWS = 'sn-arrows'
const L_SENDERS = 'sn-senders'
const L_SENDER_LABEL = 'sn-sender-labels'
const L_TARGET = 'sn-target'
const L_TARGET_LABEL = 'sn-target-label'
const L_TARGETS_ALL = 'sn-targets-all' // the always-on clickable target field
// ALL unreached targets as clickable circles (the field the user picks from on the
// map itself — the click-to-select replacement for the dropdown). One feature per
// target country that has a known centroid.
function buildAllTargets() {
const ds = dataset.value
if (!ds) return fc([])
const feats = []
for (const name of Object.keys(ds.countries)) {
const c = COUNTRY_CENTROIDS[name]
if (!c) continue
feats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: c },
properties: { name, kind: 'target', upg: ds.countries[name]?.target_upg || 0 }
})
}
return fc(feats)
}
function buildFeatures() {
const t = targetData.value
if (!t) return { arrows: fc([]), senders: fc([]), target: fc([]) }
const tc = COUNTRY_CENTROIDS[activeTarget.value] || [60, 25]
const targetFeat = {
type: 'Feature',
geometry: { type: 'Point', coordinates: tc },
properties: { name: activeTarget.value, kind: 'target' }
}
const senderFeats = []
const arrowFeats = []
for (const n of senders.value) {
const c = centroidFor(n.country, n.name + n.lang)
if (!c) continue
senderFeats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: c },
properties: {
name: n.name, country: n.country, pct: n.pct_christian || 0,
color: senderColor(n.pct_christian || 0)
}
})
// SEND arrow: sender centroid → target centroid.
arrowFeats.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: [c, tc] },
properties: { bridge: n.bridge, adjacency: n.adjacency }
})
}
return { arrows: fc(arrowFeats), senders: fc(senderFeats), target: fc([targetFeat]) }
}
function fc(features) { return { type: 'FeatureCollection', features } }
function syncLayers() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
const { arrows, senders: sf, target } = buildFeatures()
// ── sources (create-or-setData) ──
setSrc(m, SRC_ARROWS, arrows)
setSrc(m, SRC_SENDERS, sf)
setSrc(m, SRC_TARGET, target)
setSrc(m, SRC_TARGETS_ALL, buildAllTargets())
// ── ALL unreached targets (clickable field; selected one is emphasized by the
// big L_TARGET on top, the rest DIM when a target is selected) ──
if (!m.getLayer(L_TARGETS_ALL)) {
m.addLayer({
id: L_TARGETS_ALL, type: 'circle', source: SRC_TARGETS_ALL,
paint: {
'circle-color': C_ORANGE,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 5, 5, 9, 9, 13],
'circle-stroke-width': 1.5, 'circle-stroke-color': '#ffffff'
}
})
}
// ── SEND arrows (navy dashed connectors) ──
if (!m.getLayer(L_ARROWS)) {
m.addLayer({
id: L_ARROWS, type: 'line', source: SRC_ARROWS,
layout: { 'line-cap': 'round' },
paint: {
'line-color': C_NAVY, 'line-width': 1.4, 'line-opacity': 0.55,
'line-dasharray': [2, 2]
}
})
}
// ── sender nodes (green-scaled by %Christian) ──
if (!m.getLayer(L_SENDERS)) {
m.addLayer({
id: L_SENDERS, type: 'circle', source: SRC_SENDERS,
paint: {
'circle-color': ['get', 'color'],
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2.5, 5, 4, 9, 6],
'circle-stroke-width': 0.8, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.92
}
})
}
if (!m.getLayer(L_SENDER_LABEL)) {
m.addLayer({
id: L_SENDER_LABEL, type: 'symbol', source: SRC_SENDERS,
minzoom: 3.4,
layout: {
'text-field': ['get', 'name'], 'text-size': 10,
'text-offset': [0, 1.1], 'text-anchor': 'top', 'text-allow-overlap': false
},
paint: { 'text-color': '#0b5e33', 'text-halo-color': '#ffffff', 'text-halo-width': 1.3 }
})
}
// ── target node (orange, the unreached) ──
if (!m.getLayer(L_TARGET)) {
m.addLayer({
id: L_TARGET, type: 'circle', source: SRC_TARGET,
paint: {
'circle-color': C_ORANGE,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 8, 5, 13, 9, 18],
'circle-stroke-width': 2.5, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.95
}
})
}
if (!m.getLayer(L_TARGET_LABEL)) {
m.addLayer({
id: L_TARGET_LABEL, type: 'symbol', source: SRC_TARGET,
layout: {
'text-field': ['get', 'name'], 'text-size': 13, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'],
'text-offset': [0, -1.4], 'text-anchor': 'bottom', 'text-allow-overlap': true
},
paint: { 'text-color': '#8a3800', 'text-halo-color': '#ffffff', 'text-halo-width': 1.6 }
})
}
applySelectionDim(m)
}
// When a target is selected: the selected target is drawn big by L_TARGET (on top),
// so hide its small dot in the all-targets field, and DIM every OTHER target so the
// SEND focus is unmistakable. With no selection, all targets show at full strength.
function applySelectionDim(m) {
if (!m || !m.getLayer(L_TARGETS_ALL)) return
const sel = activeTarget.value
m.setPaintProperty(L_TARGETS_ALL, 'circle-opacity',
sel ? ['case', ['==', ['get', 'name'], sel], 0, 0.28] : 0.9)
m.setPaintProperty(L_TARGETS_ALL, 'circle-stroke-opacity',
sel ? ['case', ['==', ['get', 'name'], sel], 0, 0.5] : 0.9)
}
function setSrc(m, id, data) {
if (m.getSource(id)) m.getSource(id).setData(data)
else m.addSource(id, { type: 'geojson', data })
}
// ─── Camera: frame the selected target + its senders ─────────────────────────
function frameTarget() {
const m = map.value
if (!m) return
const tc = COUNTRY_CENTROIDS[activeTarget.value]
if (!tc) return
let minLng = tc[0], maxLng = tc[0], minLat = tc[1], maxLat = tc[1]
for (const n of senders.value) {
const c = COUNTRY_CENTROIDS[n.country]
if (!c) continue
minLng = Math.min(minLng, c[0]); maxLng = Math.max(maxLng, c[0])
minLat = Math.min(minLat, c[1]); maxLat = Math.max(maxLat, c[1])
}
try {
m.stop()
if (minLng === maxLng && minLat === maxLat) {
m.easeTo({ center: tc, zoom: 4, duration: 900 })
} else {
m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 90, maxZoom: 5.5, duration: 900 })
}
} catch (_) {}
}
// ─── Handlers ─────────────────────────────────────────────────────────────────
// Shared selection path — used by BOTH a target-circle click (primary) and the
// dropdown (a11y/keyboard fallback). Selecting re-syncs senders+arrows for the
// new target, dims the other targets, and frames the SEND.
function selectTarget(name) {
if (!name || !COUNTRY_CENTROIDS[name]) return
activeTarget.value = name
syncLayers()
frameTarget()
}
function onSelectTarget(e) { selectTarget(e.target.value) }
function flyToSender(n) {
const m = map.value
const c = COUNTRY_CENTROIDS[n.country]
if (!m || !c) return
try { m.stop(); m.easeTo({ center: c, zoom: 4.5, duration: 800 }) } catch (_) {}
}
// ─── Boot ────────────────────────────────────────────────────────────────────
// PPLR-1: reached data INLINED (static import) so it ships inside the bundle — vite doesn't deploy
// the profile data/ folder, so fetch('./data/...') 404s in the REAL platform (worked in dev only).
// profile-config `dataUrl` still overrides live.
import bakedReached from './data/reached-neighbors.json'
async function loadDataset() {
const cfgUrl = profileConfig?.value?.dataUrl
if (cfgUrl) {
try { const res = await fetch(cfgUrl); if (res.ok) return await res.json() } catch (_) { /* fall back to baked */ }
}
return bakedReached
}
onMounted(async () => {
await initializeMap()
const m = map.value
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
// Test-only hook (inert in production — platform never sets debugExpose). Lets an
// automated harness use the live map.project() to click exact circle positions.
if (profileConfig?.value?.debugExpose && typeof window !== 'undefined') {
try { (snRoot.value?.getRootNode?.()?.host || window).__sendmap = m } catch (_) {}
}
dataset.value = await loadDataset()
if (!dataset.value) { appReady.value = true; return }
if (!targetNames.value.includes(activeTarget.value)) {
activeTarget.value = targetNames.value[0] || activeTarget.value
}
const wire = () => {
syncLayers()
// PRIMARY interaction (Driver bug fix): click an unreached TARGET circle →
// select it → dim the other targets + show ONLY its connected green senders
// (+ SEND arrows) + list them in the right panel. Replaces dropdown-only.
m.on('click', L_TARGETS_ALL, (ev) => {
const f = ev.features?.[0]; if (!f) return
selectTarget(f.properties.name)
})
m.on('mouseenter', L_TARGETS_ALL, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', L_TARGETS_ALL, () => { m.getCanvas().style.cursor = '' })
// Sender click → fly to that sender's country.
m.on('click', L_SENDERS, (ev) => {
const f = ev.features?.[0]; if (!f) return
try { m.stop(); m.easeTo({ center: f.geometry.coordinates, zoom: 4.5, duration: 800 }) } catch (_) {}
})
m.on('mouseenter', L_SENDERS, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', L_SENDERS, () => { m.getCanvas().style.cursor = '' })
frameTarget()
appReady.value = true
}
if (m?.isStyleLoaded?.()) wire()
else m?.once?.('load', wire)
})
onBeforeUnmount(() => { destroy() })
</script>
<template>
<div ref="snRoot" class="sn-root">
<div class="sn-map-area">
<div ref="mapContainer" class="sn-map-canvas"></div>
<div v-if="!appReady" class="sn-loading">Loading SEND map…</div>
<!-- Top: title + target selector -->
<div class="sn-top">
<span class="sn-title">My UPG Neighbor · SEND</span>
<select v-if="targetNames.length" class="sn-select"
:value="activeTarget" @change="onSelectTarget"
aria-label="Select unreached target country">
<option v-for="name in targetNames" :key="name" :value="name">{{ name }}</option>
</select>
</div>
<!-- Left: the 4-D JOIN legend (the gate the connectors pass through) -->
<div class="sn-legend">
<h4>4-D SEND join</h4>
<div class="sn-legend-row"><span class="sn-num">1·</span><span><strong>Adjacency</strong> — same-country, else neighbor ≤1500 km</span></div>
<div class="sn-legend-row"><span class="sn-num">2·</span><span><strong>Language pair</strong> — same language (strong) / cluster (soft)</span></div>
<div class="sn-legend-row"><span class="sn-num">3·</span><span><strong>Reached %</strong> — sender ≥10% Christian (more = more laborers)</span></div>
<div class="sn-legend-row"><span class="sn-num">4·</span><span><strong>Lower persecution</strong> — sender from a freer place</span></div>
<div style="border-top:1px solid #e5e7eb;margin:7px 0 5px;"></div>
<div class="sn-legend-row"><span class="sn-dot" :style="{ background: C_ORANGE }"></span><span>Unreached target — <strong>click one</strong> to SEND-focus</span></div>
<div class="sn-legend-row"><span class="sn-dot" :style="{ background: C_GREEN }"></span><span>Reached sender (SEND →)</span></div>
</div>
<!-- Right: worked sender table for the selected target -->
<div v-if="targetData" class="sn-panel">
<div class="sn-panel-head">
<div class="sn-target">{{ activeTarget }}</div>
<div class="sn-sub">
{{ (targetData.target_upg || 0).toLocaleString() }} UPGs ·
{{ senders.length }} reached senders
</div>
</div>
<div class="sn-panel-body">
<div v-for="(n, i) in senders" :key="n.name + i" class="sn-sender" @click="flyToSender(n)">
<div class="sn-sender-name">
<span class="sn-dot" :style="{ background: senderColor(n.pct_christian || 0) }"></span>
{{ n.name }}
</div>
<div class="sn-sender-meta">
<span class="sn-chip pct">{{ Math.round(n.pct_christian || 0) }}% Christian</span>
<span class="sn-chip bridge">{{ n.bridge }}</span>
<span class="sn-chip adj">{{ n.adjacency }}</span>
<span class="sn-chip">{{ n.lang }}</span>
</div>
</div>
<div v-if="!senders.length" style="padding:14px;font-size:12px;color:#6b7280;">
No qualifying reached senders with a known country centroid for this target.
</div>
</div>
</div>
<!-- Footer: provenance honesty contract + dependency chain -->
<div class="sn-footer">
<span><strong>SEND</strong> reached neighbor → unreached target</span>
<span>target = LIVE (JP UnreachedPeoplesByCountry)</span>
<span>senders = baked {{ dataset?.meta?.generated || '2026-06-12' }} (≥{{ dataset?.meta?.threshold_pct_christian || 10 }}% Christian, {{ dataset?.meta?.adjacency_km || 1500 }} km)</span>
<span>Hab 2:2 · mobilize existing laborers</span>
<span>chain: UPG-100 → FPG base → My UPG Neighbor</span>
</div>
</div>
</div>
</template>

View file

@ -1,46 +1,41 @@
{
"//": "Five preset renders of the legacy-wrapper profile -- one per legacy map rescued by the experimental roundup (EXPERIMENTAL-MAPS-ROUNDUP.md). props follow the cap-2 instances contract (props, not config -- measured: config was silently dropped from BASE_CONFIG and every instance page rendered the default send-map).",
"//": "Five preset renders of the upg-neighbor profile -- one per lens of the old doxa-upg-neighbor bundle (moved natively from app-profiles on 2026-09-24; EXPERIMENTAL-MAPS-ROUNDUP.md). Each row opens a different lens via props.view; the view-switcher reaches the rest. Ids are unchanged from the retired iframe wrapper so existing embed URLs keep working.",
"profile": "upg-neighbor",
"instances": [
{
"id": "send-map",
"title": "Send Map (legacy)",
"title": "Send Map",
"props": {
"legacyElement": "send-map",
"legacyTitle": "Send Map"
"view": "send-map"
}
},
{
"id": "upg-neighbors",
"title": "UPG Neighbors (legacy)",
"title": "UPG Neighbors",
"props": {
"legacyElement": "upg-neighbors",
"legacyTitle": "UPG Neighbors"
"view": "cockpit"
}
},
{
"id": "lang-cluster",
"title": "Language Clusters (legacy)",
"title": "Language Clusters",
"props": {
"legacyElement": "lang-cluster",
"legacyTitle": "Language Clusters"
"view": "lang-cluster"
}
},
{
"id": "bloom-cockpit",
"title": "Bloom Cockpit (legacy)",
"title": "Bloom Cockpit",
"props": {
"legacyElement": "bloom-cockpit",
"legacyTitle": "Bloom Cockpit"
"view": "cockpit"
}
},
{
"id": "flower-mesh",
"title": "Flower Mesh (legacy)",
"title": "Flower Mesh",
"props": {
"legacyElement": "flower-mesh",
"legacyTitle": "Flower Mesh"
"view": "flower-mesh"
}
}
]
}
}

View file

@ -0,0 +1,5 @@
{
"type": "monolithic",
"label": "My UPG Neighbors",
"note": "Monolithic composite (capability 3): ONE root .vue nests five internal lenses — Send Map, the Bloom cockpit, Flower Mesh, Lang Cluster and Flowers — behind a view-switcher. index.js registers exactly ONE tag (upg-neighbor); none of the lenses is independently embeddable. The five rows of upg-neighbor.instances.json are preset opening lenses, not separate elements."
}

View file

@ -1,56 +1,109 @@
<script setup>
/**
* upg-neighbor.vue — LEGACY-WRAPPER bundle (experimental roundup).
* upg-neighbor.vue — My UPG Neighbors: the five neighbor lenses in one native profile.
*
* @description Rescues the five pre-bundler experimental maps that live inside the legacy
* upg-neighbor IIFE — send-map, the upg-neighbors/bloom cockpit, lang-cluster and flower-mesh — without rebuilding them and
* without letting their old engine touch the modern bundles: each profile mounts its element
* inside a sandboxed IFRAME whose host page (assets/legacy/upg-neighbor/index.html) loads the
* prebuilt script, shims its relative /api fetches to the live API, and passes the token.
* The iframe is the whole isolation contract — a legacy crash dies inside its own frame.
* Refactor-to-native happens later, per map, only if a map earns it (roundup doc).
* @description Who goes to whom: reached neighbors matched to unreached people groups, five lenses
*
* Send Map, the Bloom cockpit, Flower Mesh, Lang Cluster and Flowers behind one view-switcher.
* Pick the opening lens with profile-config 'view'.
* @profile upg-neighbor
* @bundle upg-neighbor
* @element upg-neighbor
*
* MOVED 2026-09-24 from the old app-profiles/doxa-upg-neighbor bundle (root-dx-life/_imports/
* upg-neighbor-maps) into this plugin-designs folder. The lenses are the original SFCs, now in
* src/; this file is the original doxa-upg-neighbors hub. Until this move the five maps ran as a
* prebuilt legacy IIFE inside a sandboxed iframe; they now build from source like every bundle.
*
* Each lens inherits profileConfig/mapboxToken via inject() from ProfileLoader. v-if mounts ONE
* view at a time → one map instance at a time (each lens destroys its map onBeforeUnmount).
*
* profile-config: { "profile":"upg-neighbor", "tk":"pk...", "view":"send-map",
* "apiBase":"https://pray.doxa.life", "target":"India" }
*/
import { computed } from 'vue'
import { inject, ref, computed, onMounted } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import BloomCockpit from './src/bloom-cockpit.vue'
import SendMap from './src/send-map.vue'
import FlowerMesh from './src/flower-mesh.vue'
import LangCluster from './src/lang-cluster.vue'
import FlowerClusters from './src/cockpit/FlowerClusters.vue'
// ★ PROP, NOT INJECT. This bundle defines its element DIRECTLY (index.js), so it never sits
// under ProfileLoader and inject('profileConfig') is always null -- measured: the attribute
// carried legacyElement:"lang-cluster" while the iframe src said el=send-map (the default),
// and assetsBase degraded to a root-relative path that only worked because the sibling
// web-root assets copy happened to exist. Direct-defined bundles read the profile-config
// ATTRIBUTE as a prop (the people-groups-grid pattern); the send-map default masked the miss
// because send-map is also instance #1.
const props = defineProps({ profileConfig: { type: String, default: '' } })
const cfg = computed(() => {
try { return props.profileConfig ? JSON.parse(props.profileConfig) : {} }
catch (_) { return {} }
})
const element = computed(() => String(cfg.value.legacyElement || 'send-map').replace(/[^a-z0-9-]/g, ''))
const title = computed(() => cfg.value.legacyTitle || element.value)
const profileConfig = inject('profileConfig')
const cfg = computed(() => profileConfig?.value || {})
// assetsBase is a RESERVED config key -- raw cfg reads miss it in some hosts; every
// data-fetching bundle resolves it through the shared helper (DashboardMapPanel.vue:33),
// which also degrades correctly when nothing is provided. Measured before this fix: the
// iframe resolved against the LEAF page dir and 404'd in all three instances.
const frameSrc = computed(() => {
const base = String(resolveAssetsBase({ value: cfg.value }) || cfg.value.assetsBase || './assets').replace(/\/+$/, '')
const tk = cfg.value.tk || (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
return base + '/legacy/upg-neighbor/index.html?el=' + element.value + (tk ? '&tk=' + encodeURIComponent(tk) : '')
const VIEWS = [
{ key: 'cockpit', label: 'Overview', hint: 'crossfilter hub' },
{ key: 'send-map', label: 'Send Map', hint: 'reached → unreached arcs' },
{ key: 'flower-mesh', label: 'Flower Mesh', hint: 'radial bloom' },
{ key: 'lang-cluster',label: 'Lang Cluster', hint: 'language bounded-sets' },
{ key: 'flowers', label: 'Flowers', hint: 'family · language · dialect cores + granular petals' }
]
const VALID = new Set(VIEWS.map(v => v.key))
// 'legacyElement' is the key the retired iframe wrapper read; old embeds still carry it.
const LEGACY_VIEW = { 'upg-neighbors': 'cockpit', 'bloom-cockpit': 'cockpit' }
// Chosen at SETUP, not onMounted: switching after mount tears down Send Map while its map is
// still initializing (measured: "reading 'getRootNode'" on every non-default lens).
// Send Map is the default — "who goes, to whom" (EXPERIMENTAL-MAPS-ROUNDUP.md ranks it first).
const initial = cfg.value.view || LEGACY_VIEW[cfg.value.legacyElement] || cfg.value.legacyElement
const view = ref(VALID.has(initial) ? initial : 'send-map')
const rootEl = ref(null)
function setView(k) { if (VALID.has(k)) view.value = k }
onMounted(() => {
// gated test hook (inert in prod) — lets a harness drive the switcher deterministically
if (cfg.value.debugExpose && typeof window !== 'undefined') {
try {
const host = rootEl.value?.getRootNode?.()?.host || window
host.__neighbors = { get view() { return view.value }, setView }
} catch (_) {}
}
})
useShadowStyles(
'.unw{position:relative;width:100%;height:100%;min-height:520px;background:#f3f3f1}' +
'.unw iframe{position:absolute;inset:0;width:100%;height:100%;border:0}' +
'.unw .tag{position:absolute;top:8px;right:8px;z-index:2;font:600 10px ui-monospace,monospace;' +
'letter-spacing:.06em;text-transform:uppercase;color:#6d766e;background:#fff;' +
'border:1px solid #d1d4cd;border-radius:100px;padding:3px 8px}',
'upg-neighbor-wrapper')
useShadowStyles(`
.dun-root { position:absolute; inset:0; display:flex; flex-direction:column; overflow:hidden;
background:#0e1730; isolation:isolate;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.dun-bar { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:7px 12px;
background:#001b44; border-bottom:1px solid #002e6e; z-index:50; }
.dun-brand { font:800 11px/1 ui-monospace,monospace; letter-spacing:.06em; text-transform:uppercase;
color:#fff; background:rgba(255,255,255,.1); padding:6px 10px; border-radius:7px; white-space:nowrap; }
.dun-seg { display:inline-flex; background:#0e1730; border:1px solid #284a86; border-radius:999px;
padding:3px; gap:2px; flex-wrap:wrap; }
.dun-seg button { border:0; background:transparent; color:#9fb3d9; cursor:pointer;
font:700 11px/1 ui-monospace,monospace; padding:6px 12px; border-radius:999px; }
.dun-seg button.on { background:#002e6e; color:#fff; }
.dun-seg button:hover:not(.on) { color:#dbe4f3; }
.dun-hint { margin-left:auto; font:600 10px/1.3 ui-monospace,monospace; color:#7e90b6; white-space:nowrap; }
.dun-stage { flex:1 1 0; position:relative; min-height:0; background:#fff; }
/* each child view root is position:absolute;inset:0 → fills the stage */
@media (max-width:640px){ .dun-hint{ display:none; } .dun-brand{ display:none; } }
`, 'upg-neighbor')
const activeHint = computed(() => VIEWS.find(v => v.key === view.value)?.hint || '')
</script>
<template>
<div class="unw">
<span class="tag">experimental · legacy</span>
<iframe :src="frameSrc" :title="title" loading="lazy"></iframe>
<div ref="rootEl" class="dun-root">
<!-- view-switcher -->
<div class="dun-bar">
<span class="dun-brand">My UPG Neighbors</span>
<div class="dun-seg" role="tablist" aria-label="Choose a view">
<button v-for="v in VIEWS" :key="v.key" role="tab" :aria-selected="view === v.key"
:class="{ on: view === v.key }" @click="setView(v.key)">{{ v.label }}</button>
</div>
<span class="dun-hint">{{ activeHint }}</span>
</div>
<!-- one view at a time (each lens manages its own map; v-if = one Mapbox instance) -->
<div class="dun-stage">
<BloomCockpit v-if="view === 'cockpit'" />
<SendMap v-else-if="view === 'send-map'" />
<FlowerMesh v-else-if="view === 'flower-mesh'" />
<LangCluster v-else-if="view === 'lang-cluster'" />
<FlowerClusters v-else-if="view === 'flowers'" />
</div>
</div>
</template>

View file

@ -1,75 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>legacy map host</title>
<style>html,body{margin:0;height:100%;background:#f3f3f1}.host{position:fixed;inset:0}</style>
</head>
<body>
<div class="host" id="host"></div>
<script>
// Sandboxed host for the LEGACY upg-neighbor IIFE (pre-bundler architecture). This page runs
// inside an IFRAME so the old engine and its dependencies cannot collide with the modern
// bundles -- the isolation pattern the roundup mandates ("add without breaking doxa-maps").
var q = new URLSearchParams(location.search);
var el = (q.get('el') || 'send-map').replace(/[^a-z0-9-]/g, '');
var tk = q.get('tk') || '';
// Token globals the legacy build may read.
window.MAPBOX_TOKEN = tk; window.MAP_TOKEN = tk; window.UPG_TOKEN = tk;
// The legacy code fetches relative "/api/people-groups/..." which only resolved on its old
// origin. Shim fetch so those calls reach the live API from ANY host (static tree, iframe).
var API = 'https://pray.doxa.life';
// The cockpit profile fails closed without a configured API base and names this global in
// its own error banner (measured: "No API base configured (apiBase / window.MAP_APP_API_URL
// / VITE_API_BASE_URL) -- failing closed").
window.MAP_APP_API_URL = API;
var origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
try {
var url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.indexOf('/api/') === 0) return origFetch(API + url, init);
} catch (e) { /* fall through */ }
return origFetch(input, init);
};
// ── KEYLESS SHIM ──────────────────────────────────────────────────────────
// The legacy bundle expects a mapboxgl GLOBAL and requests mapbox://styles/mapbox/light-v11.
// MapLibre is API-compatible with the mapbox-gl v1 surface the bundle uses, and OpenFreeMap
// Positron is the keyless twin of light-v11 -- so the shim below makes every legacy map run
// WITHOUT a Mapbox token: load our self-hosted MapLibre, alias it as mapboxgl, and rewrite
// any mapbox:// style to the keyless URL inside a patched Map constructor.
var KEYLESS_STYLE = 'https://tiles.openfreemap.org/styles/positron';
var lib = document.createElement('link'); lib.rel='stylesheet'; lib.href='./maplibre-gl.css';
document.head.appendChild(lib);
var ml = document.createElement('script');
ml.src = './maplibre-gl.js';
ml.onload = function () {
var Real = window.maplibregl.Map;
function PatchedMap (opts) {
if (opts && typeof opts.style === 'string' && opts.style.indexOf('mapbox://') === 0) opts.style = KEYLESS_STYLE;
return new Real(opts);
}
PatchedMap.prototype = Real.prototype;
window.mapboxgl = Object.assign({}, window.maplibregl, { Map: PatchedMap, accessToken: '' });
loadLegacy();
};
ml.onerror = function () { loadLegacy(); };
document.head.appendChild(ml);
function loadLegacy () {
var s = document.createElement('script');
s.src = './upg-neighbor.iife.js';
s.onload = function () {
var node = document.createElement(el);
node.style.cssText = 'display:block;width:100%;height:100%';
// The legacy elements use the SAME profile-config attribute contract as the modern
// bundles (measured: without it the element renders its own usage banner).
node.setAttribute('profile-config', JSON.stringify({ profile: el, tk: tk }));
document.getElementById('host').appendChild(node);
};
s.onerror = function () { document.body.innerHTML = '<p style="font:14px system-ui;padding:20px">legacy bundle failed to load</p>'; };
document.head.appendChild(s);
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-+uSJg/LdhuPzecJG34oeevbtghdX7Pqaop+MyNzs4tyqd3/1frmrwQimIJgcv/yo" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-amheyqep13c83pKspBiNh8x4DIm5S/L7ifL34zSxsAe+LbpB0YW7DPH+a30ba6Xc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-+uSJg/LdhuPzecJG34oeevbtghdX7Pqaop+MyNzs4tyqd3/1frmrwQimIJgcv/yo" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-amheyqep13c83pKspBiNh8x4DIm5S/L7ifL34zSxsAe+LbpB0YW7DPH+a30ba6Xc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-+uSJg/LdhuPzecJG34oeevbtghdX7Pqaop+MyNzs4tyqd3/1frmrwQimIJgcv/yo" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-amheyqep13c83pKspBiNh8x4DIm5S/L7ifL34zSxsAe+LbpB0YW7DPH+a30ba6Xc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-fmp37RdhrkxuguGQ5MmwnakDu0So2YCJSYj8Bca867HoKCT1497o9z3K/wlHDlxP" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-fmp37RdhrkxuguGQ5MmwnakDu0So2YCJSYj8Bca867HoKCT1497o9z3K/wlHDlxP" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,10 @@
"embedBase": ".",
"bundleCount": 22,
"profileCount": 35,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life",
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
},
"bundles": [
{
"bundle": "all-people-groups",
@ -154,7 +158,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "doxa-maps/country-posters/country-posters.js",
"integrity": "sha384-+uSJg/LdhuPzecJG34oeevbtghdX7Pqaop+MyNzs4tyqd3/1frmrwQimIJgcv/yo",
"integrity": "sha384-amheyqep13c83pKspBiNh8x4DIm5S/L7ifL34zSxsAe+LbpB0YW7DPH+a30ba6Xc",
"elements": [
"country-posters-map"
],
@ -174,7 +178,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-+uSJg/LdhuPzecJG34oeevbtghdX7Pqaop+MyNzs4tyqd3/1frmrwQimIJgcv/yo\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-amheyqep13c83pKspBiNh8x4DIm5S/L7ifL34zSxsAe+LbpB0YW7DPH+a30ba6Xc\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -188,7 +192,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-+uSJg/LdhuPzecJG34oeevbtghdX7Pqaop+MyNzs4tyqd3/1frmrwQimIJgcv/yo\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-amheyqep13c83pKspBiNh8x4DIm5S/L7ifL34zSxsAe+LbpB0YW7DPH+a30ba6Xc\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -202,7 +206,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-+uSJg/LdhuPzecJG34oeevbtghdX7Pqaop+MyNzs4tyqd3/1frmrwQimIJgcv/yo\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-amheyqep13c83pKspBiNh8x4DIm5S/L7ifL34zSxsAe+LbpB0YW7DPH+a30ba6Xc\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -298,17 +302,18 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8",
"integrity": "sha384-fmp37RdhrkxuguGQ5MmwnakDu0So2YCJSYj8Bca867HoKCT1497o9z3K/wlHDlxP",
"elements": [
"doxa-maps-spa"
],
"composes": [
"bp-big-picture",
"doxa-research-map",
"doxa-simple-map",
"cities-internet-map",
"gospel-resources-map",
"my-upg-100-list-map"
"dashboard-plus-grid/dashboard-plus-grid",
"research-map-plus-grid/research-map-plus-grid",
"regions-dashboard/regions-mfe",
"my-upg-100-list/upg-100-mfe",
"doxa-simple-map/doxa-simple-map",
"cities-internet/connectivity-map",
"gospel-resources/resources-map"
],
"profiles": [
{
@ -322,7 +327,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-fmp37RdhrkxuguGQ5MmwnakDu0So2YCJSYj8Bca867HoKCT1497o9z3K/wlHDlxP\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{
@ -1086,7 +1091,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "doxa-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q",
"integrity": "sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz",
"elements": [
"upg-neighbor"
],
@ -1094,25 +1099,24 @@
{
"profile": "upg-neighbor",
"file": "upg-neighbor.vue",
"description": "Rescues the five pre-bundler experimental maps that live inside the legacy",
"description": "Who goes to whom: reached neighbors matched to unreached people groups, five lenses",
"element": "upg-neighbor",
"type": "parameterized",
"isMap": false,
"type": "monolithic",
"isMap": true,
"iframeUrl": "./upg-neighbor/upg-neighbor/index.html",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{
"id": "send-map",
"title": "Send Map (legacy)",
"title": "Send Map",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "send-map",
"legacyTitle": "Send Map"
"view": "send-map"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/send-map/index.html",
@ -1122,12 +1126,11 @@
},
{
"id": "upg-neighbors",
"title": "UPG Neighbors (legacy)",
"title": "UPG Neighbors",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "upg-neighbors",
"legacyTitle": "UPG Neighbors"
"view": "cockpit"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/upg-neighbors/index.html",
@ -1137,12 +1140,11 @@
},
{
"id": "lang-cluster",
"title": "Language Clusters (legacy)",
"title": "Language Clusters",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "lang-cluster",
"legacyTitle": "Language Clusters"
"view": "lang-cluster"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/lang-cluster/index.html",
@ -1152,12 +1154,11 @@
},
{
"id": "bloom-cockpit",
"title": "Bloom Cockpit (legacy)",
"title": "Bloom Cockpit",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "bloom-cockpit",
"legacyTitle": "Bloom Cockpit"
"view": "cockpit"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/bloom-cockpit/index.html",
@ -1167,12 +1168,11 @@
},
{
"id": "flower-mesh",
"title": "Flower Mesh (legacy)",
"title": "Flower Mesh",
"apiBaseUrl": "",
"props": {
"profile": "upg-neighbor",
"legacyElement": "flower-mesh",
"legacyTitle": "Flower Mesh"
"view": "flower-mesh"
},
"element": "upg-neighbor",
"iframeUrl": "./upg-neighbor/upg-neighbor/flower-mesh/index.html",
@ -1180,7 +1180,8 @@
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/flower-mesh/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
}
]
],
"bundle": "upg-neighbor"
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bloom Cockpit (legacy) · upg-neighbor</title>
<title>Bloom Cockpit · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.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>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"bloom-cockpit","legacyTitle":"Bloom Cockpit"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"cockpit"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flower Mesh (legacy) · upg-neighbor</title>
<title>Flower Mesh · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.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>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"flower-mesh","legacyTitle":"Flower Mesh"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"flower-mesh"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -5,10 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>upg-neighbor · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.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>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +48,7 @@
var BASE_CONFIG = {"profile":"upg-neighbor","dataSource":"pray-tools","instanceId":"upg-neighbor-upg-neighbor","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Language Clusters (legacy) · upg-neighbor</title>
<title>Language Clusters · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.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>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"lang-cluster","legacyTitle":"Language Clusters"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"lang-cluster"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Send Map (legacy) · upg-neighbor</title>
<title>Send Map · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.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>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"send-map","legacyTitle":"Send Map"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"send-map"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UPG Neighbors (legacy) · upg-neighbor</title>
<title>UPG Neighbors · upg-neighbor</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.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>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-JDJr4qoVZuiSi/rvgdzeuUNrzf7vjPdyh/g8dg7X8RHkZk/Y+tYXIQ3gpEzrU07q" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-nhreYhnnIGRgIkQXr3h0xmwX71B2Yhd47erUuAOAqAYgTyNw/IvXaO/FjR09wvKz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -42,10 +45,10 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","legacyElement":"upg-neighbors","legacyTitle":"UPG Neighbors"};
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"upg-neighbor","view":"cockpit"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -3,8 +3,18 @@
# Direct-embed pages (>=2 path segments under the tree) must be framable anywhere;
# staging pages stay preview-only. frame-ancestors is header-only, so the proxy
# owns this — the bundler can only document and template it.
#
# CORS (added 2026-09-24): the WHOLE tree must send Access-Control-Allow-Origin, not only the
# embed pages. A composed surface (the cap-5 Research Gallery, cap-4 composites) mounted by
# <script> on a partner page fetch()es manifest.json at the tree ROOT and assets/ after it mounts,
# and fetch() enforces CORS. Measured live: manifest.json answered 200 with NO ACAO (it sits one
# segment deep, outside the embed-pages rule), so every gallery tab would fail on doxa.life.
http:
middlewares:
doxa-maps-cors:
headers:
accessControlAllowOriginList: ["*"]
accessControlAllowMethods: ["GET", "HEAD", "OPTIONS"]
doxa-maps-embed-framable:
headers:
customResponseHeaders:
@ -14,5 +24,10 @@ http:
doxa-maps-embed-pages:
rule: "PathRegexp(`^/doxa-maps/[^/]+/[^/]+/`)"
priority: 70 # above the site catch-all
middlewares: [doxa-maps-embed-framable]
middlewares: [doxa-maps-embed-framable, doxa-maps-cors]
service: your-static-service
doxa-maps-tree:
rule: "PathPrefix(`/doxa-maps/`)"
priority: 60 # below the embed-pages router, above the site catch-all
middlewares: [doxa-maps-cors]
service: your-static-service