From 04630e15713d4eab9422eaa41366cb79c4e0110d Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 03:51:22 +0000 Subject: [PATCH] The zones seam: a list can have its own poster clusters, and today they all share UPG-100's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 2 of the pattern (dx-shepherd UPGPAT). Membership was already parameterised — lists.json registers five lists, and four of them are the Driver's own project numbers (doxa = Project 2000, unreached = 7000, frontier = 4700, frontier-500k = 700) — and resolvePeopleGroupList already picks one from config.list / ?list= / window.MAP_LIST. The gap was ZONES: a list says WHICH groups, nothing said how to cluster them into the ~100-group printable zones a poster needs. ★ A REGISTRY, NOT A DYNAMIC IMPORT, and the reason is load-bearing. The two consumers do `import { lists, asia_lists, uncovered_lists } from './data/clusters.json'`, and that NAMED import is deliberate — its own comment says only the zone arrays ship while the file's eight bookkeeping fields are tree-shaken out. `await import(path)` would ship the whole file in every bundle AND make the zone list asynchronous in code that reads it synchronously at setup. So alternates are registered as static imports and the bundler can still see which zone files a bundle reaches. ⚠ INERT UNTIL A SECOND LIST EXISTS, deliberately. Every list resolves to UPG-100's zones today, which is exactly what every caller already gets — so this lands with zero behaviour change. That is the point: the pattern has to exist BEFORE the second list, or it is not the thing the second list gets built against. A list with no zones of its own warns ONCE, naming what it substituted, because showing UPG-100's zones under another list's name is silent-wrong-data a reader cannot detect. WHAT A NEW LIST SHIPS — the three-step recipe another agent follows: 1. data/zones/.json the four zone-bearing keys, clusters.json's shape 2. data/zoning-declarations..json bloc zones, expected orphans, known overlaps, page budget 3. one line in ZONES_BY_LIST then `check:zoning-declared --list ` grades it, and the legend, poster wall and packet all follow, because all three already enumerate through leafZones(buildLegendNodes(zones)). ⚠ countZones COUNTS deaf_lists AS ONE, not as its two members — legend-tree collapses them into a single unified leaf ("13 + 12 + 2 + 1 deaf = 28", legend-tree.js:106). Measured both ways: the naive four-key sum gives 29 against a 28-leaf tree, which is a number that disagrees with every other count in the app. ⚠ AND THE MODULE IS BROWSER/VITE-ONLY, documented in it rather than discovered later. A bare `import … from './x.json'` is a bundler feature: plain Node throws ERR_IMPORT_ASSERTION_TYPE_MISSING, which is how I found it. That matches the existing convention — NO Node-side file imports clusters.json as a module; packet.mjs and every gate read it with readFileSync + JSON.parse (packet.mjs:125) — so a Node consumer should read the JSON and pass the object in. Verified: Vite builds it, check:no-free-identifiers PASS, and the resolver's count agrees with leafZones at 28. The two .vue call sites are NOT switched over yet — that is the behaviour-visible half and it goes in its own commit so a regression there is bisectable to one change. ★ FOUND WHILE READING THE REGISTRY: the Driver named FIVE project lists (7000 / 4700 / 3000 / 2000 / 700) and lists.json registers four. PROJECT 3000 IS NOT REGISTERED. Flagged, not invented — which list of groups it denotes is his to say. --- .../my-upg-100-list/data/zones-for-list.js | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 plugin-designs/my-upg-100-list/data/zones-for-list.js diff --git a/plugin-designs/my-upg-100-list/data/zones-for-list.js b/plugin-designs/my-upg-100-list/data/zones-for-list.js new file mode 100644 index 000000000..a0e8d4b44 --- /dev/null +++ b/plugin-designs/my-upg-100-list/data/zones-for-list.js @@ -0,0 +1,104 @@ +/** + * zones-for-list — which ZONES a project list is clustered into. + * + * THE GAP THIS CLOSES. Membership is already parameterised by list: library/lists/lists.json registers + * five (doxa = Project 2000, unreached = 7000, frontier = 4700, frontier-500k = 700, unengaged), and + * resolvePeopleGroupList(profileConfig) already picks one from `config.list` / `?list=` / window.MAP_LIST + * with a default. What NO list has is ZONES: a list file says WHICH people groups, and nothing says how + * to cluster them into the ~100-group printable zones a poster needs. clusters.json is UPG-100's, and it + * was the only answer available. + * + * ★ WHY THIS IS A RESOLVER AND NOT A DYNAMIC IMPORT. The two consumers do: + * import { lists, asia_lists, uncovered_lists } from './data/clusters.json' + * and that NAMED import is load-bearing — its own comment says so: only the zone arrays ship, while + * clusters.json's eight bookkeeping fields (version, generated, source_total, validation_run, …) are + * tree-shaken out. Swapping it for `await import(path)` would ship the whole file in every bundle, and + * would make the zone list asynchronous in code that reads it synchronously at setup. So the default + * stays a static named import, and alternates are REGISTERED rather than computed from a path. + * + * ⚠ AND THE DEFAULT MUST BE THE OLD BEHAVIOUR EXACTLY. A list with no zones of its own gets UPG-100's, + * which is what every caller gets today — so this file is inert until someone registers a second set. + * That is deliberate: the pattern has to be landable before any second list exists, or it cannot be the + * thing the second list is built against. + * + * ★ WHAT A NEW LIST ACTUALLY SHIPS, and this is the pattern for another agent to follow: + * 1. data/zones/.json the four zone-bearing keys, same shape as clusters.json: + * lists / deaf_lists / asia_lists / uncovered_lists + * 2. data/zoning-declarations..json the facts a gate cannot infer — bloc zones, expected orphans, + * known geographic overlaps, page budget + * 3. one line in ZONES_BY_LIST below + * Then `npm run check:zoning-declared -- --list ` grades the zoning, and the legend, poster wall and + * packet all follow, because all three already enumerate through leafZones(buildLegendNodes(zones)). + * + * ZONING RULE, from the Driver (2026-09-27), recorded here because it is the judgement the format cannot + * capture: cluster by GEOGRAPHY FIRST so the poster maps make sense, balanced with LANGUAGE — languages + * usually cluster geographically, so the two mostly agree, and where they disagree geography wins for a + * map's sake. UPG-100's own 28 already mix the two: the country_codes zones are geographic, and the 12 + * India/China pg_slugs zones are hand groupings where language and geography were traded off. + * + * ⚠ THE DEAF ZONE IS THE KNOWN EXCEPTION AND MUST NOT BE FORCED. `deaf` is affinity bloc A017: 123 groups + * spanning every region, global by construction, and the one poster whose zone cannot be a map extent. It + * breaks the geography-first rule the rest of the pattern rests on, and the Driver has said he has not + * solved it. A list whose clusters include an affinity or language bloc inherits the same open question — + * declare it in bloc_zones so the gate treats it as a second partition, and do NOT invent a geography for + * it to make a validator happy. + * + * ⚠ THIS MODULE IS BROWSER/VITE-ONLY, BY CONSTRUCTION. The static JSON import below is what keeps the + * tree-shaking (see above), and bare `import … from './x.json'` is a bundler feature: plain Node throws + * ERR_IMPORT_ASSERTION_TYPE_MISSING without an `with { type: 'json' }` attribute. That is fine and is the + * existing convention here — NO Node-side file imports clusters.json as a module; packet.mjs and every + * gate read it with readFileSync + JSON.parse (packet.mjs:125). So a Node consumer that wants the same + * resolution should read the JSON itself and pass the object on, rather than importing this file. + */ +import { lists, deaf_lists, asia_lists, uncovered_lists } from './clusters.json' + +/** UPG-100's zones — the default for every list until one registers its own. */ +const UPG_100_ZONES = { lists, deaf_lists, asia_lists, uncovered_lists } + +/** + * list id → its zone set. + * ★ A REGISTRY, NOT A DIRECTORY SCAN: each entry is a static import somewhere, so the bundler can see + * which zone files a bundle actually reaches and drop the rest. A scan would defeat that and would also + * make a missing file a runtime error instead of a build one. + */ +const ZONES_BY_LIST = { + // 'project-3000': PROJECT_3000_ZONES, ← the shape a second list adds +} + +/** + * The zones for a list id. + * + * @param {string} [listId] as returned by resolvePeopleGroupList(profileConfig) + * @returns {{lists:Array, deaf_lists?:Array, asia_lists:Array, uncovered_lists:Array}} + * + * ⚠ FALLS BACK RATHER THAN THROWING, and says so once. A list without registered zones is the NORMAL + * state right now (five lists, one zone set), so throwing would break every caller for a condition that + * is expected. But it is silent-wrong-data territory — a reader would see UPG-100's zones under another + * list's name and have no way to tell — so it warns exactly once per id, naming what it did. + */ +const warned = new Set() +export function zonesForList(listId) { + const id = String(listId || '') + const own = ZONES_BY_LIST[id] + if (own) return own + if (id && !warned.has(id) && typeof console !== 'undefined') { + warned.add(id) + console.warn(`[zones] list "${id}" has no zones of its own; showing UPG-100's ${countZones(UPG_100_ZONES)} ` + + 'zones. Register a set in data/zones-for-list.js to give it its own poster clusters.') + } + return UPG_100_ZONES +} + +/** Zone count across the zone-bearing keys. NOT a leaf count — buildLegendNodes collapses deaf_lists. */ +export function countZones(zones) { + let n = 0 + for (const k of ['lists', 'asia_lists', 'uncovered_lists']) n += (zones?.[k] || []).length + // ★ deaf_lists is counted as ONE, not as its member count: legend-tree collapses its two entries into a + // single unified leaf (legend-tree.js:106, "13 + 12 + 2 + 1 deaf = 28"). Counting its members would + // report 29 for a 28-leaf tree — a number that disagrees with every other count in the app. + if ((zones?.deaf_lists || []).length) n += 1 + return n +} + +/** Every list id that has its own zones — for a gate or a diagnostic to enumerate. */ +export function listsWithOwnZones() { return Object.keys(ZONES_BY_LIST) }