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.
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
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).
|
||||
|
||||

|
||||
|
||||
|
|
@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
- **`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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 633 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 495 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 686 KiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 1,015 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 925 KiB After Width: | Height: | Size: 1.1 MiB |
68
documentation/readme-images/src/delegation-model.svg
Normal 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 |
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
internal/check-research-gallery-embed.mjs
Normal 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')
|
||||
|
|
@ -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})`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '.',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 || '' })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
plugin-designs/doxa-maps-spa/EMBED.md
Normal 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
|
||||
```
|
||||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"><{{ c.tag }}></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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
const rowModel = (r) => ({
|
||||
key: r.key,
|
||||
label: r.label,
|
||||
emoji: r.emoji,
|
||||
icon: r.icon,
|
||||
sub: '<code><' + esc(r.tag) + '></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><' + esc(n.tag) + '></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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
195
plugin-designs/upg-neighbor/src/bloom-cockpit.vue
Normal 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>
|
||||
356
plugin-designs/upg-neighbor/src/cockpit/ChartStrip.vue
Normal 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>
|
||||
408
plugin-designs/upg-neighbor/src/cockpit/FlowerCell.vue
Normal 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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
363
plugin-designs/upg-neighbor/src/cockpit/FlowerClusters.vue
Normal 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>
|
||||
400
plugin-designs/upg-neighbor/src/cockpit/ListCell.vue
Normal 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>
|
||||
481
plugin-designs/upg-neighbor/src/cockpit/MapCell.vue
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
568
plugin-designs/upg-neighbor/src/cockpit/useCockpit.js
Normal 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]
|
||||
}))
|
||||
}
|
||||
36973
plugin-designs/upg-neighbor/src/data/reached-neighbors.json
Normal file
515
plugin-designs/upg-neighbor/src/flower-mesh.vue
Normal 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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
574
plugin-designs/upg-neighbor/src/lang-cluster.vue
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
523
plugin-designs/upg-neighbor/src/send-map.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
plugin-designs/upg-neighbor/upg-neighbor.meta.json
Normal 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."
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
214
public/doxa-maps/country-posters/country-posters.js
generated
21
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
67
public/doxa-maps/manifest.json
generated
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
569
public/doxa-maps/upg-neighbor/upg-neighbor.js
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||