docs: migrate color-strategy contract to docs/architecture; simple README+graphic; add shared-vs-app-profiles balance graphic

This commit is contained in:
doxa-maps 2026-09-11 17:49:30 +00:00
parent 81db8dbebb
commit 92be1ca4ef
3 changed files with 111 additions and 92 deletions

View file

@ -5,6 +5,27 @@ You build your map by making a folder **here**, `app-profiles/<your-map>/`, and
template's building blocks. Your folder is yours (colors, API, i18n); the template is shared
(Apache-2.0, propagated to every map on the next build).
## Shared vs. yours — the balance
```
library/ (@map) ── SHARED, reusable, parameterized ──► legend · map · map-controls
│ (every map reuses these)
│ imported by
▼
app-profiles/<your-map>/ ── YOURS ──► the map's "main" class (the .vue profile = the Vue
│ instance for that map)
└── src/ ── YOURS ──► this map's OWN non-reusable components, colors, API, utils
One bundle can hold as many maps as you want — add another .vue profile and rebuild.
```
- **Reusable → `library/`.** The legend, the map, the map-controls are shared, parameterized
components every map reuses. Fix once, every map benefits. Never edit it to customize one map.
- **This map's own → `app-profiles/<your-map>/`.** It holds two things: the map's **"main" class**
(each `.vue` profile *is* the Vue instance for that map) **and** that map's **own non-reusable
components** (in `src/`). Nothing here leaks to another map.
- **Many maps per bundle.** The bundler bundles one map or many — add another flat `.vue` and rebuild.
## The pattern in one picture
```

View file

@ -0,0 +1,71 @@
# Architecture — color strategies (the full contract)
> The simple overview is in `library/colors/README.md`. This is the complete contract for
> **writing or overriding** a color strategy.
A *color strategy* decides how people-group pins are colored for a given color mode (language
family, prayer progress, religion, …). One strategy = one file. `_registry.js` auto-discovers
every `<name>.js` via `import.meta.glob` and builds the `COLOR_MODES` enum + lookup from the
filenames — no manual registration.
**Filename → mode mapping:** `prayer-progress.js` → `prayerProgress` / `COLOR_MODES.PRAYER_PROGRESS`;
`religion.js` → `religion` / `COLOR_MODES.RELIGION`. Files starting with `_` or `.` are skipped.
## The strategy contract
Every `<name>.js` MUST `export default` this shape. A missing `name` (regression card #20) leaves
the tab/legend label blank (the map still paints):
```js
export const PROPERTY_KEY = 'myField' // GeoJSON feature property this reads
export function getColor(properties) { ... } // (props) -> '#rrggbb' — JS-side resolver
export function applyColor(options = {}) { ... } // -> Mapbox expression array (head = operator)
export const buildColorExpression = applyColor // alias used by the tab-switch handler
export default {
name: 'My Strategy', // REQUIRED — human label, never omit
propertyKey: PROPERTY_KEY,
getColor, applyColor, buildColorExpression,
// …any palette/labels you also want on the default export
}
```
`applyColor()` must return a valid Mapbox paint expression — an array whose first element is an
operator (`'match'`, `'case'`, `'interpolate'`, …). It feeds `circle-color` in
`useMapLayers.addLanguageFamilyLayer`.
## Add a custom strategy (checklist)
1. **Create `<name>.js`** following the contract. Deriving color from a string? reuse
`generateColorFromString` from `../utils/geoUtils.js` (see `doxa-region.js`). The mode key is the
camelCased filename — pick the filename to match the mode string consumers pass.
2. **Build — it auto-registers.** No `_registry.js` edit. (Needs a `export default` with a non-empty
`name`; files without a default export are ignored.)
3. **Make the pin feature carry your `PROPERTY_KEY`** — pin properties are built in
`composables/useMapLayers.js → addLanguageFamilyLayer`. Missing field → every pin falls to default.
4. **Wire the legend** (only if the mode shows a legend tab) in
`composables/legends/useLegendData.js`: add `TITLE_KEYS`, `activeFilter`, `setFilter`, and `items`
branches. Religion is the worked example (reads `PALETTE`, `RELIGION_FAMILIES`, `getReligionFamily`
via named exports, so export those too).
5. **Verify** — the structural tooling resolves every mode and asserts a non-empty `name`, a valid
expression array, and a hex `getFeatureColor`.
## Profile-local strategies (override / add without touching the library)
`library/colors/` is the **shared** set. A single map that wants its own coloring drops a `<name>.js`
in that bundle's `app-profiles/<bundle>/src/colors/`. At init, `index.js` calls
`registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true }))`, merging local files
**over** the shared set:
- **Same mode key** as a shared strategy → local **overrides** it, for that bundle only.
- **New mode key** → local **adds** a bundle-private strategy.
Each bundle is its own IIFE, so a local strategy can't leak to another map. This is the "local by
default, promote deliberately" seam. See `contributing/COLORS.md` and
`app-profiles/template-bundle/src/colors/` for the copy-me example.
## Status / custom modes
`COLOR_MODES.STATUS` and `COLOR_MODES.CUSTOM` are declared but have no strategy file —
`getColorStrategy()` falls back to `LANGUAGE_FAMILY` for any unknown mode. Add a real file (steps
above) when one graduates from placeholder.

View file

@ -1,97 +1,24 @@
# Color Strategies — modular folder
# library/colors/ — how pins get colored
One strategy = one file. Each strategy decides how people-group pins are colored on
the map for a given "color mode" (language family, prayer progress, religion, …).
`_registry.js` **auto-discovers** every `<name>.js` in this folder via Vite's
`import.meta.glob` (the same pattern app-profiles use) and builds the `COLOR_MODES`
enum + strategy lookup from the filenames — **no manual registration**. There is no
monolith shim: import the real modules directly — `_registry.js`,
`language-family.js`, `engagement.js`, `adoption.js`, `prayer-progress.js`.
Each file here is one **color strategy** — a way to color the map's pins (by language family, prayer
progress, religion, …). Drop a `<name>.js` in and it's auto-discovered; no registration.
**Filename → mode mapping:** `prayer-progress.js` → mode `prayerProgress` /
`COLOR_MODES.PRAYER_PROGRESS`; `religion.js` → `religion` / `COLOR_MODES.RELIGION`.
Files whose name starts with `_` (like this registry) or `.` are skipped.
(Non-shared strategies — `doxa-region.js`, `affinity-block.js`, `resource.js` — live in
`app-profiles/doxa-research-map/src/colors/` and merge over this set for that map only.)
## The strategy contract
Every `<name>.js` file MUST `export default` an object with this shape. Missing
`name` is the exact regression card #20 fixed — `listStrategies()` and any UI that
labels the strategy read `strategy.name`, so an absent `name` shows up as
`undefined` (the map still paints, but the tab/legend label is blank).
```js
export const PROPERTY_KEY = 'myField' // GeoJSON feature property this reads
export function getColor(properties) { ... } // (props) -> '#rrggbb' — JS-side resolver
export function applyColor(options = {}) { ... } // -> Mapbox expression array (head = operator)
export const buildColorExpression = applyColor // alias used by the tab-switch handler
export default {
name: 'My Strategy', // REQUIRED — human label. Never omit.
propertyKey: PROPERTY_KEY,
getColor,
applyColor,
buildColorExpression,
// …any palette/labels you also want on the default export
}
```
library/colors/ a map picks a mode
┌──────────────────────────┐ ┌───────────────────────┐
│ language-family.js │ │ getColorStrategy( │
│ prayer-progress.js │──auto───► │ 'language-family') │──► colored pins
│ religion.js engagement.js│ glob │ │
│ _registry.js (the scanner)│ └───────────────────────┘
└──────────────────────────┘
shared set a single map can add/override its own in
app-profiles/<map>/src/colors/ (that map only)
```
`applyColor()` must return a valid Mapbox paint expression — an array whose first
element is an operator (`'match'`, `'case'`, `'interpolate'`, …). It is fed
directly to `circle-color` in `useMapLayers.addLanguageFamilyLayer`.
- **Shared here** = reused by every map. **Your map's own** color → put it in your bundle's
`src/colors/` (it merges over the shared set, for your map only — never edit the library to
recolor one map).
- Filename = mode key (`prayer-progress.js` → `prayerProgress`). Files starting with `_`/`.` are skipped.
## Add a custom strategy (full checklist)
1. **Create `<name>.js`** next to this file, following the contract above.
If you derive color from a string, reuse `generateColorFromString` from
`../utils/geoUtils.js` (see `doxa-region.js`).
The mode key is auto-derived from the filename (`my-thing.js` → `myThing` /
`COLOR_MODES.MY_THING`) — pick the filename so that camelCase matches the mode
string you want consumers to pass.
2. **Build — it auto-registers.** `_registry.js` globs this folder; your file
appears in `COLOR_MODES`, `getColorStrategy`, `listStrategies` automatically.
**No `_registry.js` edit needed.** (Just make sure the file has a `export
default` with a non-empty `name` — files without a default export are ignored.)
3. **Make sure the pin feature carries your `PROPERTY_KEY`.** Pin properties are
built in `composables/useMapLayers.js → addLanguageFamilyLayer` (the big
`properties: { … }` block). If your field isn't there, the Mapbox expression
reads `undefined` and every pin falls to the default color.
4. **Wire the legend** (only if the map shows a legend tab for this mode) in
`composables/legends/useLegendData.js`: add a `TITLE_KEYS` entry, an `activeFilter`
branch, a `setFilter` branch, and an `items` branch that emits the rows.
Religion is the worked example — it reads its palette/labels via the named
exports (`PALETTE`, `RELIGION_FAMILIES`, `getReligionFamily`), not the default
export, so export those too if the legend needs them.
5. **Verify** by running the structural tooling (it resolves every mode, asserts a
non-empty `name`, a valid expression array, and a hex `getFeatureColor`):
```
node /tmp/colorstrat-verify.mjs # see card #20; copy into a repo test if you want it permanent
```
## Profile-local strategies (override / add without touching the library)
This folder is the **shared** set — a strategy here is reused across profiles. A single
map that wants its OWN coloring should NOT edit the library. Instead, drop a `<name>.js`
in that bundle's `app-profiles/<bundle>/src/colors/` folder. At bundle init,
`index.js` calls `registerStrategies(import.meta.glob('./src/colors/*.js', { eager:
true }))`, which merges the bundle's local files **over** this shared set:
- **Same mode key** (derived from filename) as a shared strategy → the local file
**overrides** it, for that bundle only.
- **New mode key** → the local file **adds** a bundle-private strategy.
Each bundle builds as its own IIFE, so a local strategy affects only that bundle — it
cannot leak into another map. This is the "local by default, promote deliberately" seam:
keep a color local until a second profile genuinely needs it, then promote it here. See
`../../contributing/COLORS.md` and `app-profiles/template-bundle/src/colors/`
for the copy-me example.
## Status / custom modes
`COLOR_MODES.STATUS` and `COLOR_MODES.CUSTOM` are declared but have no dedicated
strategy file — `getColorStrategy()` falls back to `LANGUAGE_FAMILY` for any
unknown/unregistered mode. Add a real file + registration (steps above) when one
of these graduates from placeholder to a real coloring.
**Writing or overriding a strategy?** The full contract (required exports, legend wiring, checklist)
is in **[../../docs/architecture/color-strategies.md](../../docs/architecture/color-strategies.md)**.