Delete 7 unreachable library components, and gate the thing a census cannot see

Seven components with no importer, removed. The interesting part is not the
deletion, it is that an import census was NOT sufficient to authorise it, and
two other agents proved that rather than me.

WHAT AN IMPORT CENSUS MISSED. internal/check-gospel-set-charts.mjs iterated
['BoundedSet.vue', 'CenteredSetTarget.vue'] as literal STRINGS and read their
props off disk. No import, so no census and no build would have warned; deleting
them would have turned a gate red on main with nothing to explain it. gospel
found that in their own file after I asked, and retargeted the arm at the
components that actually render (GiBoundedSetMap / GiCenteredSetMap, 2d2e853a7).
Their retarget also fixed a quieter defect: the seed guarantee had been asserted
against two files NOTHING renders, so it would have kept passing while the
shipping component grew a seed prop.

I did not take either agent's word for it. Verified gospel's retarget on their
sha and ran their gate green before deleting; verified upg100's structural claim
that packet-render.js cannot mount a component at all — 0 resolveComponent, 0
defineAsyncComponent, 0 dynamic import(), with the same probe returning a hit
elsewhere in library so an empty result is a real absence.

DELETED: charts/LanguageTreeChart.vue, components/charts/VisionFunnel.vue,
components/charts/BoundedSet.vue, components/charts/CenteredSetTarget.vue,
components/legends/ListSelectorLegend.vue,
components/dashboards/DashboardBreadcrumb.vue, composables/useMapSelection.js.

NOT DELETED, and worth stating because the names collide: the lowercase
boundedSet.js / centeredSet.js HELPERS are load-bearing — packet-render.js and
GiBoundedSetMap import them directly, and check-gospel-set-charts still does too.
Also left alone: colors/resource.js (a colour STRATEGY resolved by MODE NAME
through _registry.js, 'resource' appearing in mapStore.js and GiResources.vue)
and layouts/app-wrapper-material/nav.js (115 mentions). Both look dead to an
import census and would break a working map.

check:no-dead-library scans for a filename as a STRING across internal/,
library/ and plugin-designs/, and:

  · DISTINGUISHES A READ FROM A COMMENT (gospel's ask). check-packet-variants.mjs
    discusses CenteredSetTarget.vue in prose while asserting against JSON. A
    comment hit is reported, never fatal — a gate that cannot tell the two apart
    cries wolf once and is then ignored. It currently reports 4 such mentions.
  · IS TWO-SIDED (upg100's ask). It also asserts a KNOWN-PRESENT dynamic
    reference is found — LegendDesktop lazy-loads LegendMobile through import().
    Mutation-tested: blinding the scanner makes it FAIL on that control instead
    of passing with "no references found", which is what a one-sided version
    would have done.
  · ignores a longer identifier that merely contains the name, so GiBoundedSet.vue
    is not read as a reference to BoundedSet.vue.

Both arms mutation-tested and every mutated file restored byte-identical.
Registered in all three places and verified by re-reading the parsed
package.json — the suite insert silently hit the scripts KEY instead of the suite
string on the first attempt, which verifying caught and trusting would not have.
This commit is contained in:
Doxa Maps 2026-09-28 23:25:51 +00:00
parent 36f2818b37
commit c5c01deada
9 changed files with 91 additions and 851 deletions

View file

@ -0,0 +1,88 @@
/**
* check-no-dead-library.mjs — a library module must be reachable, and the scanner must prove it works.
*
* ★ WHY A NAME-GREP IS NOT ENOUGH, and why this file exists in this shape. Seven library components
* were deleted as unreachable. Two of them were NOT reachable by import and still could not be
* deleted safely: internal/check-gospel-set-charts.mjs iterated ['BoundedSet.vue',
* 'CenteredSetTarget.vue'] AS STRINGS and read their props off disk. No import, no build error —
* deleting them would have turned a gate red on main with nothing to explain it. So this scans for a
* filename appearing as a STRING anywhere in internal/ and library/, not only in import statements.
*
* ★ IT DISTINGUISHES A READ FROM A MENTION (gospel's ask). check-packet-variants.mjs discusses
* CenteredSetTarget.vue in prose while asserting against a JSON file. A gate that cannot tell a read
* from a comment cries wolf once and is then ignored, so a comment-only hit is REPORTED, never fatal.
*
* ★ IT IS TWO-SIDED (upg100's ask, learned the same night). A gate that only asserts "no references
* found" passes identically when the scanner is broken and when the tree is clean. So it also
* asserts a KNOWN-PRESENT reference is found: LegendDesktop.vue lazy-loads LegendMobile.vue through
* a dynamic import(). If that positive control ever stops being found, the scanner is lying and the
* gate fails — green here means "the scanner works AND found nothing".
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const ROOT = new URL('../', import.meta.url).pathname
const SCAN = ['internal', 'library', 'plugin-designs']
const files = []
const walk = (d) => {
if (!existsSync(d)) return
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(d, e.name)
if (e.isDirectory()) walk(p)
else if (/\.(vue|js|mjs)$/.test(e.name)) files.push(p)
}
}
for (const d of SCAN) walk(join(ROOT, d))
const text = new Map(files.map((p) => [p, readFileSync(p, 'utf8')]))
/** Every hit of `name` outside its own file, split into reads and comment-only mentions. */
function hitsFor(name, selfPath) {
const reads = [], mentions = []
for (const [p, src] of text) {
if (p.endsWith(selfPath)) continue
// This gate NAMES every deleted module in its own DELETED list, so it would report itself
// as the reader of all seven. Excluding it is not a loophole: the positive control below
// is what proves the scanner still sees real references.
if (p.endsWith('internal/check-no-dead-library.mjs')) continue
for (const line of src.split('\n')) {
if (!line.includes(name)) continue
// A longer identifier that merely CONTAINS the name is not a reference to it
// (GiBoundedSet.vue contains BoundedSet.vue).
const idx = line.indexOf(name)
const before = idx > 0 ? line[idx - 1] : ''
if (/[A-Za-z0-9_]/.test(before)) continue
const trimmed = line.trim()
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) mentions.push(`${p.split('/').slice(-2).join('/')}`)
else reads.push(`${p.split('/').slice(-2).join('/')}`)
}
}
return { reads, mentions }
}
const fail = []
// ── THE POSITIVE CONTROL. A known dynamic reference the scanner must find. ─────────────────────
const control = hitsFor('LegendMobile.vue', 'components/legends/LegendMobile.vue')
if (control.reads.length === 0) {
fail.push('POSITIVE CONTROL FAILED: LegendMobile.vue is lazy-loaded by LegendDesktop.vue and the scanner did not find it — the scanner is broken, so any "no references" result below is meaningless')
}
// ── THE DELETED SET must stay deleted and unreferenced. ───────────────────────────────────────
const DELETED = ['LanguageTreeChart.vue', 'VisionFunnel.vue', 'useMapSelection.js', 'ListSelectorLegend.vue',
'BoundedSet.vue', 'CenteredSetTarget.vue', 'DashboardBreadcrumb.vue']
const notes = []
for (const name of DELETED) {
const { reads, mentions } = hitsFor(name, `__deleted__/${name}`)
if (reads.length) fail.push(`${name}: deleted, but READ by ${[...new Set(reads)].join(', ')}`)
if (mentions.length) notes.push(`${name}: mentioned in a comment by ${[...new Set(mentions)].join(', ')} (harmless)`)
}
if (fail.length) {
console.error('NO-DEAD-LIBRARY: FAIL')
for (const f of fail) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log(`NO-DEAD-LIBRARY: PASS — positive control found (${control.reads.length} read of LegendMobile.vue), ${DELETED.length} deleted module(s) with no reads.`)
for (const n of notes) console.log(` · ${n}`)

View file

@ -1,160 +0,0 @@
<script setup>
/**
* LanguageTreeChart.vue — every language family a roster belongs to, as a radial tree on a whiteboard.
*
* Promoted from gospel-internet's GiTree (Driver brief §B). It is deliberately THIN: the drawing is
* library/charts/whiteboard/tiered-points.js, the surface is library/maps/whiteboard-map.js, the
* hierarchy is library/charts/whiteboard/language-tree-model.js and the camera is
* library/charts/whiteboard/frame-target.js — all four tested headlessly. What is left here is wiring,
* because an SFC cannot be run by a gate and so should hold as little as possible.
*
* ★ PROPS IN, EVENTS OUT. GiTree had NO props and NO emits: it read a board composable and a module
* singleton, which is what made it gospel's rather than anybody's. Everything it reached for is now a
* prop, and `selected` is a v-model so the HOST decides what a click means.
*
* ★ THE CLICK TOGGLE IS THE HOST'S, DELIBERATELY. GiTree cleared the selection when you clicked the
* selected node again — and that toggle lived inside the component's own click handler, not at the call
* site. Moving it out MOVES behaviour rather than codifying it, which is worth saying plainly: a host
* that wants the toggle must write it. In exchange the chart holds no selection state at all.
*
* ★ FRAME ON SELECT, NOT ON CHANGE. Clearing does NOT re-frame: the camera stays where the reader left
* it, because yanking it back is a motion nobody asked for. "Frames when the selection changes" is the
* obvious phrasing and it is wrong in exactly one case, which is the case that annoys people.
*
* ★ AND A SELECTION SET BEFORE THE MAP IS READY IS REPLAYED. GiTree survived this by ordering: its host
* set the node through a composable that waited for data, so the value always arrived after the map. As
* a PROP the ordering belongs to the host, and a prop whose effect depends on WHEN you set it is a trap
* for every future consumer — a shared deep link would work or not depending on load timing, which is
* unreportable. So ready replays whatever `selected` currently is.
*/
import { ref, shallowRef, computed, watch, onMounted, onBeforeUnmount, markRaw } from 'vue'
import { createWhiteboardMap, reducedMotion as prefersReducedMotion } from '@map/maps/whiteboard-map.js'
import { addTieredPointLayers } from '@map/charts/whiteboard/tiered-points.js'
import { indexTree, nodeFC, linkFC, subtreeCoords, worstOfDescendants } from '@map/charts/whiteboard/language-tree-model.js'
import { frameTarget, applyFrame } from '@map/charts/whiteboard/frame-target.js'
import { labelFont } from '@map/maps/label-font.js'
const props = defineProps({
/** the built tree: { nodes: [...] } from library/scripts/build-language-tree.mjs */
tree: { type: Object, default: null },
/** ids or ISO codes currently on the board; null means everything is in scope */
inScope: { type: Object, default: null },
/** v-model: the selected node id, '' for none */
selected: { type: String, default: '' },
/** a node id to point at; never moves the camera */
hovered: { type: String, default: '' },
/** (key, fallback) => string */
t: { type: Function, default: (k, en) => en || k },
/** (node, { index, inScope }) => colour; defaults to the worst status among IN-SCOPE descendants */
colorFor: { type: Function, default: null },
/** engine config: { provider, tk, lang } — the same profile-config shape the maps take */
profileConfig: { type: Object, default: null },
token: { type: String, default: '' },
/** override the media query, for hosts that expose their own setting */
reducedMotion: { type: Boolean, default: null },
})
const emit = defineEmits(['update:selected', 'node-click', 'ready', 'error'])
const el = ref(null)
const mapRef = shallowRef(null)
const ready = ref(false)
const err = ref('')
let layers = null
let unmounted = false
const index = computed(() => (props.tree?.nodes ? indexTree(props.tree.nodes) : null))
const PALETTE = { g: '#1f8f5f', y: '#e3a82b', r: '#b42318' }
const colour = (n, ctx) => {
if (props.colorFor) return props.colorFor(n, ctx)
const st = worstOfDescendants(ctx.index, n.id, { inScope: ctx.inScope })
return PALETTE[st] || '#8d968e'
}
const points = computed(() => (index.value ? nodeFC(index.value, { colorFor: colour, inScope: props.inScope }) : null))
// One symbol layer per tier, each entering at its own zoom: families first, then languages, then
// branches, then dialects. The engine resolves the collisions; this only says what may appear when.
const TIERS = [
{ tier: 'f', minzoom: 0, size: [0, 10, 1, 11, 6, 15], weight: 'bold', offset: 1.1, filter: ['==', ['get', 'root'], 1] },
{ tier: 'l', minzoom: 3.6, size: [3.6, 10, 8, 13], offset: 0.8 },
{ tier: 'b', minzoom: 4.4, size: [4.4, 9, 8, 11], offset: 0.7, muted: true },
{ tier: 'd', minzoom: 5.6, size: [5.6, 9, 9, 11], offset: 0.7, muted: true },
]
const motion = () => (props.reducedMotion === null ? prefersReducedMotion() : props.reducedMotion)
function frameSelection(id) {
const map = mapRef.value
if (!map || !ready.value || !id || !index.value) return
applyFrame(map, frameTarget(subtreeCoords(index.value, id), {
reducedMotion: motion(), currentZoom: map.getZoom ? map.getZoom() : 0,
}))
}
onMounted(async () => {
if (!el.value || !props.tree?.nodes?.length) { err.value = props.t('tree.noData', 'No tree data'); return }
const made = await createWhiteboardMap(el.value, props.profileConfig, { token: props.token })
if (unmounted) { try { made.map?.remove() } catch (_) {} return }
if (made.error) { err.value = made.error; emit('error', made.error); return }
const map = made.map
mapRef.value = markRaw(map)
map.addSource('lt-links', { type: 'geojson', data: linkFC(index.value) })
map.addLayer({
id: 'lt-links', type: 'line', source: 'lt-links',
paint: { 'line-color': '#8d968e', 'line-width': ['interpolate', ['linear'], ['zoom'], 1, 0.3, 7, 1], 'line-opacity': 0.4 },
})
layers = addTieredPointLayers(map, {
sourceId: 'lt', data: points.value, tiers: TIERS,
colorExpr: ['get', 'colour'],
radiusExpr: ['interpolate', ['linear'], ['zoom'], 1, ['case', ['==', ['get', 'root'], 1], 3, 1.4], 8, ['case', ['==', ['get', 'root'], 1], 7, 4]],
opacityExpr: ['case', ['==', ['get', 'm'], 0], 0.15, 0.95],
dish: { filter: ['==', ['get', 'root'], 1], radiusExpr: ['interpolate', ['exponential', 2], ['zoom'], 1, ['*', ['get', 'R'], 1], 8, ['*', ['get', 'R'], 128]] },
font: labelFont,
// The chart reports the click; it does NOT decide what a click means, and it holds no selection.
onClick: (id, f) => { emit('node-click', id, f); emit('update:selected', id) },
onHover: (id) => { if (id !== props.hovered) emit('update:hovered', id) },
})
ready.value = true
// The opening view: the whole tapestry, instantly. An opening view is not a motion the reader asked
// for, so it is never animated however the motion setting reads.
const all = points.value.features.filter((f) => f.properties.root === 1).map((f) => f.geometry.coordinates)
applyFrame(map, frameTarget(all, { reducedMotion: true }))
// ...and THEN replay whatever the host already selected, so a deep link works regardless of ordering.
if (props.selected) { layers.setSelected(props.selected); frameSelection(props.selected) }
if (props.hovered) layers.setHovered(props.hovered)
emit('ready', map)
})
watch(() => props.selected, (id) => {
if (!layers || !ready.value) return
layers.setSelected(id)
if (id) frameSelection(id) // frame on SELECT; clearing leaves the camera alone
})
// Hover only ever sets a filter. There is no camera call on this path, structurally.
watch(() => props.hovered, (id) => { if (layers && ready.value) layers.setHovered(id) })
watch(points, (fc) => { const m = mapRef.value; if (m && fc) { try { m.getSource('lt').setData(fc) } catch (_) {} } })
onBeforeUnmount(() => {
unmounted = true
try { layers?.destroy() } catch (_) {}
try { mapRef.value?.remove() } catch (_) {}
})
</script>
<template>
<div class="ltc">
<div ref="el" class="ltc-map" role="region"
:aria-label="t('tree.aria', 'Language tree: every language family of these people groups')"></div>
<div v-if="err" class="ltc-msg" role="alert">{{ err }}</div>
<div v-else-if="!ready" class="ltc-msg" role="status">{{ t('tree.loading', 'Loading…') }}</div>
<slot name="actions" :map="mapRef" />
<slot name="card" :selected="selected" :node="index && selected ? index.byId.get(selected) : null" />
</div>
</template>
<style scoped>
.ltc { position: absolute; inset: 0; }
.ltc-map { position: absolute; inset: 0; }
.ltc-msg { position: absolute; inset: 0; display: grid; place-items: center; font: 13px/1.4 system-ui, sans-serif; color: #6d766e; }
</style>

View file

@ -1,69 +0,0 @@
<script setup>
/**
* BoundedSet — a circle with REACHED groups inside it and unreached outside.
*
* Promoted from gospel-internet's GiBoundedSet, which keeps the fetching and the board state; this
* takes already-joined groups as a prop so any consumer can draw the same figure. The layout itself
* is unchanged and shared — library/components/charts/boundedSet.js, one implementation.
*
* ★ UNKNOWN IS ITS OWN CATEGORY AND IS COUNTED IN INK. A group whose reached-ness did not join is
* not drawn outside the circle, because "outside" means unreached and we do not know that. It is
* reported instead. This matters more in print than on screen: a printed page has no hover, and a
* category the reader cannot see is a category the reader will assume is zero.
*
* ★ NO SEED PROP, DELIBERATELY (gospel). Position is hashed from the group's own id, so a group
* lands in the same place on every render, in every process, forever — two printings of one packet
* are identical and a reader comparing last month's sheet to this month's sees real movement rather
* than reshuffling. A seed would silently rescramble every dot while the data was unchanged.
*
* ★ AND THE BOUNDARY GAP IS NOT NEGOTIABLE (gospel): inside points stay within 0.92r, outside
* beyond 1.12r, because a dot sitting ON the line is the one thing a bounded set cannot express.
* To fit a page, shrink the whole figure — never compress the gap.
*/
import { computed } from 'vue'
import { layoutBoundedSet, boundedSetTally } from './boundedSet.js'
const props = defineProps({
/** [{ id, label, reached }] — reached undefined means UNKNOWN, which is not the same as false. */
groups: { type: Array, required: true },
radius: { type: Number, default: 120 },
spread: { type: Number, default: 260 },
/** Every string is passed in; this component writes no prose. */
labels: { type: Object, default: () => ({}) },
dotRadius: { type: Number, default: 2.2 },
})
const layout = computed(() => layoutBoundedSet(props.groups, { r: props.radius, spread: props.spread }))
const tally = computed(() => boundedSetTally(layout.value))
const box = computed(() => Math.round(props.spread * 1.08))
</script>
<template>
<figure class="bset">
<svg class="bset__svg" :viewBox="`${-box} ${-box} ${box * 2} ${box * 2}`" role="img" :aria-label="labels.aria || ''">
<circle class="bset__ring" cx="0" cy="0" :r="radius" />
<circle v-for="p in layout.outside" :key="p.id" class="bset__out" :cx="p.x" :cy="p.y" :r="dotRadius" />
<circle v-for="p in layout.inside" :key="p.id" class="bset__in" :cx="p.x" :cy="p.y" :r="dotRadius" />
</svg>
<figcaption class="bset__cap">
{{ labels.caption || '' }}
<b dir="ltr">{{ tally.inside }} / {{ tally.outside }}</b>
</figcaption>
<!-- Printed, not hidden behind a v-if on truthiness alone: when unknown is 0 the line is
omitted, but whenever there IS an unknown it is named and counted. -->
<p v-if="tally.unknown" class="bset__warn">
{{ labels.unknown || '' }} <b dir="ltr">{{ tally.unknown }}</b>
</p>
</figure>
</template>
<style scoped>
.bset { margin: 0; display: flex; flex-direction: column; align-items: center; color: var(--pk-ink, #3b463d); }
.bset__svg { max-width: 100%; }
.bset__ring { fill: none; stroke: var(--pk-ink, #3b463d); stroke-width: 1.5; }
.bset__in { fill: var(--pk-in, #92b195); }
.bset__out { fill: var(--pk-out, #6d766e); opacity: 0.65; }
.bset__cap { margin-block-start: 8px; font: 400 13px Georgia, serif; text-align: center; max-width: 46ch; }
.bset__warn { font: 400 12px Georgia, serif; color: var(--pk-out, #6d766e); margin-block-start: 4px; }
@media print { .bset__in, .bset__out, .bset__ring { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
</style>

View file

@ -1,122 +0,0 @@
<script setup>
/**
* CenteredSetTarget — the cross at the centre, every unreached group on a ring by how close it is
* to a REACHED neighbour, arrows pointing inward.
*
* Promoted from gospel-internet's GiCenteredSet; the layout is unchanged and shared
* (library/components/charts/centeredSet.js). This takes joined groups as a prop and every label
* from the caller, so the packet and the board draw the same figure from the same code.
*
* ★ FOUR THINGS THAT LOOK LIKE DETAIL AND ARE NOT (gospel's invariants, kept in their words):
*
* ring 0 IS REACHED, and it IS the centre. It is not unplaced, and drawing it as missing would say
* the opposite of what it means.
*
* ring -1 IS "no relation found" AND IS NOT RING 10. The outermost ring is a measurement; no
* relation is the absence of one. Collapsing them prints a group as "furthest" when the truth is
* "we could not place it" — 11 rows fleet-wide, and every one of them would be a false statement.
*
* THE ARROW AND THE RADIUS MUST NAME THE SAME NEIGHBOUR. Both come from `rings`, never from
* links[u][0], which is the older E-scale ordering and can disagree — that draws a ring-3 group with
* an arrow at its ring-8 neighbour and nothing on the page says so. inwardArrow returns null when no
* neighbour is recorded, and a null arrow is simply not drawn: an arrow is a CLAIM about a
* relationship, so a guessed one is a fabricated claim.
*
* EVERY RING IS LISTED, INCLUDING EMPTY ONES. Under a filter several of the nine legitimately go to
* zero, and a decoder that omits its empty rows stops being a decoder. Presence by identity, never
* by count — the same rule the zone legend follows.
*
* ★ AND NO SEED: position is hashed from the group's own id, so two printings of one packet are
* identical and a reader comparing sheets sees real movement rather than reshuffling.
*
* ★ A NOTE FOR WHOEVER BUILDS `labels.rings` — DO NOT PASS groupings.label AS A NAME. Measured on
* the published file: those labels run 7 to 206 characters, and the long ones are the zone's MEMBER
* LIST, not its name. Tibet's reads "Tibet Autonomous Region + Sichuan Province + Xinjiang Uyghur
* Autonomous Region + Gansu Province + ..." — only 2 of the 14 carry no "+" list at all (Gujarāt,
* and "Scattered / Diaspora"). Used as a heading on a printed sheet that sets a 206-character title.
* The field is a DEFINITION and is genuinely useful where there is room to say what a zone contains;
* for a name, humanise the slug, which is uniform by construction and tops out around 34 characters.
* gospel found this and carries the two separately; this component asserts nothing about the words,
* so the whole hazard lives at the call site, which is exactly why it is written down here.
*
* ★ THE AGREED RING VOCABULARY, for a caller building `labels.rings`. Settled between gospel and
* exp and derived from exp's ringFor() rather than hand-numbered, so the numbering cannot drift
* from the code that produces it. The screen legend already renders these words, so a printed sheet
* using them cannot disagree with the screen:
*
* 1 Same dialect, same people 6 Same language, different culture
* 2 Same dialect, near culture 7 Same family, same people
* 3 Same dialect, different culture 8 Same family, near culture
* 4 Same language, same people 9 Same family, different culture
* 5 Same language, near culture 10 Nearby only
*
* ⚠ ONE CELL IS PROVISIONAL: "people" vs the brief's "culture" in column 1 is open on the Driver's
* row 89. If he keeps "culture", exactly THREE cells change — 1, 4 and 7 — and nothing else: not
* the numbering, not the columns, not the colours. That is why this map is written here as
* VOCABULARY and is NOT a default: a default would bake a provisional word into every consumer that
* forgot to pass one, and the component would become the thing that decided a Driver question. The
* same reason ShareCalendar refuses to name its own metric.
*/
import { computed } from 'vue'
import { layoutCenteredSet, ringTally, inwardArrow, ringRadii, RING_MIN, RING_MAX } from './centeredSet.js'
const props = defineProps({
/** [{ id, label, ring, neighborId }] — ring 1..10 placed, 0 reached/centre, -1 no relation. */
groups: { type: Array, required: true },
innerR: { type: Number, default: 26 },
ringGap: { type: Number, default: 24 },
showArrows: { type: Boolean, default: true },
/** labels.rings is a map of ring number -> name, agreed once with the screen legend. */
labels: { type: Object, default: () => ({}) },
dotRadius: { type: Number, default: 2.2 },
})
const opts = computed(() => ({ innerR: props.innerR, ringGap: props.ringGap }))
const layout = computed(() => layoutCenteredSet(props.groups, opts.value))
const tally = computed(() => ringTally(layout.value))
const radii = computed(() => ringRadii(opts.value))
const arrows = computed(() => (props.showArrows
? layout.value.placed.map((p) => inwardArrow(p, opts.value)).filter(Boolean)
: []))
const box = computed(() => Math.round((props.innerR + RING_MAX * props.ringGap) * 1.12))
/** Every ring, empty ones included. */
const rows = computed(() => radii.value.map(({ ring }) => ({
ring,
name: (props.labels.rings && props.labels.rings[ring]) || String(ring),
count: tally.value.counts.get(ring) || 0,
})))
</script>
<template>
<figure class="cset">
<svg class="cset__svg" :viewBox="`${-box} ${-box} ${box * 2} ${box * 2}`" role="img" :aria-label="labels.aria || ''">
<circle v-for="r in radii" :key="r.ring" class="cset__ring" cx="0" cy="0" :r="r.r" />
<line v-for="(a, i) in arrows" :key="'a' + i" class="cset__arrow" :x1="a.x1" :y1="a.y1" :x2="a.x2" :y2="a.y2" />
<circle v-for="p in layout.placed" :key="p.id" class="cset__dot" :cx="p.x" :cy="p.y" :r="dotRadius" />
<!-- The cross. Drawn last so nothing overlaps it. -->
<path class="cset__cross" :d="`M 0 ${-innerR * 0.55} V ${innerR * 0.55} M ${-innerR * 0.38} 0 H ${innerR * 0.38}`" />
</svg>
<figcaption class="cset__cap">{{ labels.caption || '' }} <b dir="ltr">{{ tally.placed }}</b></figcaption>
<!-- centre, unplaced and unmatched are SEPARATE lines and never folded into a ring. -->
<p v-if="tally.centre || tally.unplaced" class="cset__warn">
<span v-if="tally.centre">{{ labels.reached || '' }} <b dir="ltr">{{ tally.centre }}</b>. </span>
<span v-if="tally.unplaced">{{ labels.noRelation || '' }} <b dir="ltr">{{ tally.unplaced }}</b>.</span>
</p>
<ul class="cset__decoder">
<li v-for="r in rows" :key="r.ring"><b dir="ltr">{{ r.ring }}</b> {{ r.name }} <b dir="ltr">{{ r.count }}</b></li>
</ul>
</figure>
</template>
<style scoped>
.cset { margin: 0; display: flex; flex-direction: column; align-items: center; color: var(--pk-ink, #3b463d); }
.cset__svg { max-width: 100%; }
.cset__ring { fill: none; stroke: var(--pk-rule, #d8d3c4); stroke-width: 1; }
.cset__arrow { stroke: var(--pk-out, #6d766e); stroke-width: 0.5; opacity: 0.55; }
.cset__dot { fill: var(--pk-out, #6d766e); }
.cset__cross { stroke: var(--pk-ink, #3b463d); stroke-width: 2.5; fill: none; }
.cset__cap { margin-block-start: 8px; font: 400 13px Georgia, serif; text-align: center; max-width: 46ch; }
.cset__warn { font: 400 12px Georgia, serif; color: var(--pk-out, #6d766e); margin-block-start: 4px; }
.cset__decoder { list-style: none; margin: 6px 0 0; padding: 0; font: 400 11px Georgia, serif; columns: 2; }
@media print { .cset__dot, .cset__ring, .cset__cross, .cset__arrow { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
</style>

View file

@ -1,145 +0,0 @@
<script setup>
/**
* VisionFunnel — the inverted triangle: all peoples → unreached → frontier, and where a list sits.
*
* ★ STEPS AND OVERLAYS ARE DIFFERENT THINGS, and this is the whole design.
* A funnel's rhetoric is containment: of all peoples these are unreached, of those these are
* frontier. That reading is only honest while each step is a SUBSET of the one above. DOXA is not:
* of its 2,107 groups 2,082 are unreached and 1,745 are frontier, so 362 — one group in six — are
* not frontier at all. Drawn as a fourth nested band it would misstate 17.2% of the list with a
* clean shape and no error of any kind.
*
* So steps stack, and overlays are drawn as BRACKETS BESIDE the steps they reach into, never inside
* them. The component asks packetCharts.overlayPlacement() whether an overlay is a slice rather
* than deciding for itself, and refuses to render one as a band when it is not.
*
* PRINT FIRST (upg100): no hover, no tooltip. Every number is inked, including each overlay's parts.
*
* RTL: the triangle is vertically directional — it narrows downward — so it is NOT mirrored; a
* scaleX flip would reverse nothing meaningful and invert any text inside it. What does need to
* mirror is the label and bracket side, which is why those use logical properties (inset-inline,
* text-align: start) rather than left/right.
*/
import { computed } from 'vue'
import { assertNested, assertOnePublisher, bracketSpan, overlayCaption, shareOfLists } from './packetCharts.js'
const props = defineProps({
/** Strictly nested, outermost first: [{ id, label, groups, population, source }] */
steps: { type: Array, required: true },
/** NOT slices: [{ id, label, groups, population, within: { stepId: count } }] */
overlays: { type: Array, default: () => [] },
highlight: { type: String, default: '' },
/** { index, total } — K is unsettled (27 or 29), so it is passed in and nothing is asserted about it. */
ofK: { type: Object, default: null },
labels: { type: Object, default: () => ({}) },
})
/** Refuses rather than drawing a plausible picture: a printed funnel cannot be corrected. */
const problem = computed(() => {
try {
assertOnePublisher(props.steps)
assertNested(props.steps)
return null
} catch (e) { return e.message }
})
const W = 520, STEP_H = 74, PAD = 10
const maxGroups = computed(() => Math.max(1, ...props.steps.map((s) => Number(s.groups) || 0)))
/** Each step is a trapezoid whose width is proportional to its count. */
const bands = computed(() => props.steps.map((s, i) => {
const next = props.steps[i + 1]
const wTop = (Number(s.groups) || 0) / maxGroups.value * W
const wBot = next ? (Number(next.groups) || 0) / maxGroups.value * W : wTop * 0.55
const y = PAD + i * STEP_H
const cx = PAD + W / 2
return {
id: s.id, label: s.label, groups: s.groups, population: s.population, y, h: STEP_H,
points: [
[cx - wTop / 2, y], [cx + wTop / 2, y],
[cx + wBot / 2, y + STEP_H], [cx - wBot / 2, y + STEP_H],
].map((p) => p.join(',')).join(' '),
}
}))
const height = computed(() => PAD * 2 + props.steps.length * STEP_H)
/**
* An overlay's bracket spans the steps it reaches into. It is positioned BESIDE the funnel, so it
* can cross two steps without asserting that either contains it.
*/
const placed = computed(() => props.overlays.map((o) => ({ o, span: bracketSpan(o, props.steps) })))
const brackets = computed(() => placed.value.filter((x) => x.span).map(({ o, span }) => ({
...overlayCaption(o, props.steps),
label: o.label,
population: o.population,
y: PAD + span.from * STEP_H,
h: (span.to - span.from + 1) * STEP_H,
isHighlight: o.id === props.highlight,
})))
/**
* Overlays we cannot place. Named in ink rather than dropped: an overlay whose `within` is missing
* or names an unknown step has no known relationship to the steps, and silently omitting it loses
* its groups from the page with nothing to say so.
*/
const unplaced = computed(() => placed.value.filter((x) => !x.span).map(({ o }) => o))
const ofKText = computed(() => {
const k = props.ofK ? shareOfLists(props.ofK.index, props.ofK.total) : null
return k ? { ...k, percent: k.percent.toFixed(1) } : null
})
</script>
<template>
<figure class="vf">
<p v-if="problem" class="vf__error" role="alert">{{ problem }}</p>
<template v-else>
<figcaption v-if="labels.title" class="vf__title">{{ labels.title }}</figcaption>
<div class="vf__body">
<svg class="vf__svg" :viewBox="`0 0 ${W + PAD * 2} ${height}`" role="img" :aria-label="labels.title || ''">
<polygon v-for="(b, i) in bands" :key="b.id" :points="b.points" class="vf__band" :style="{ opacity: 0.45 + i * 0.18 }" />
<!-- Brackets sit OUTSIDE the polygons. An overlay is never painted inside a step, because
being inside a step is exactly the claim it cannot make. -->
<g v-for="b in brackets" :key="b.id">
<rect :x="2" :y="b.y + 2" :width="4" :height="b.h - 4" class="vf__bracket" :class="{ 'vf__bracket--hi': b.isHighlight }" />
</g>
</svg>
<ul class="vf__steps">
<li v-for="b in bands" :key="b.id" class="vf__step">
<b>{{ b.label }}</b> — {{ b.groups }}
</li>
</ul>
</div>
<!-- The overlay's parts, as text. upg100: an unexplained band on paper tells a reader nothing. -->
<ul class="vf__overlays">
<li v-for="b in brackets" :key="b.id" class="vf__overlay" :class="{ 'vf__overlay--hi': b.isHighlight }">
<b>{{ b.label }}</b> — {{ b.total }}<template v-for="p in b.parts" :key="p.stepId">, {{ p.groups }} {{ (labels.steps && labels.steps[p.stepId]) || p.stepId }}</template>
<span v-if="!b.isSlice && labels.notASlice" class="vf__caveat"> {{ labels.notASlice }}</span>
</li>
</ul>
<p v-for="o in unplaced" :key="o.id" class="vf__unplaced" role="alert">
{{ o.label || o.id }} — {{ o.groups }}{{ labels.unplaced ? ' · ' + labels.unplaced : ' · cannot be placed: no `within` naming a step in this funnel' }}
</p>
<p v-if="ofKText" class="vf__ofk">{{ labels.ofK || '' }} {{ ofKText.index }} / {{ ofKText.total }} · {{ ofKText.percent }}%</p>
</template>
</figure>
</template>
<style scoped>
.vf { margin: 0; color: var(--pk-ink, #1b1a17); }
.vf__title { font: 700 40px Georgia, serif; margin: 0 0 6px; }
.vf__body { display: flex; gap: 14px; align-items: flex-start; }
.vf__svg { flex: 1 1 auto; max-width: 100%; }
.vf__band { fill: var(--pk-on, #2f6d4f); stroke: #fff; stroke-width: 1; }
.vf__bracket { fill: var(--pk-rule, #d8d3c4); }
.vf__bracket--hi { fill: var(--pk-ink, #1b1a17); }
.vf__steps, .vf__overlays { list-style: none; margin: 0; padding: 0; }
.vf__step, .vf__overlay, .vf__ofk { font: 400 12px Georgia, serif; margin-top: 4px; text-align: start; }
.vf__overlay--hi { font-weight: 700; }
.vf__caveat { opacity: 0.8; }
.vf__unplaced { font: 700 12px Georgia, serif; color: #8a1c1c; margin-top: 4px; text-align: start; }
.vf__error { font: 700 13px Georgia, serif; color: #8a1c1c; border: 2px solid #8a1c1c; padding: 8px; }
@media print { .vf__band, .vf__bracket { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
</style>

View file

@ -1,70 +0,0 @@
<script setup>
/**
* ── KIT LANDING 4c-4: MOVED FROM plugin-designs/research-dashboard/ ───────────────────────────
* The breadcrumb completes the facet-cascade group the spec scheduled together — rail (4c-3), chips
* (4c-2), breadcrumb. It was left behind when the other two moved, which is the whole reason it is
* called out here: a group scheduled as one landing was delivered as two, and the leftover is
* invisible unless someone re-reads the spec against the tree.
*
* Like the chip bar, it is mounted by NOBODY today (it was exported from the plugin barrel and
* rendered nowhere), so its gates mean "it mounts and degrades", not "it looks right".
*/
import { tStatic } from '@map/i18n/index.js'
const L = (key, en) => tStatic('dashboard.' + key) || en
/**
* Breadcrumb — RDASH phase 8 delighter: drill-down breadcrumb (World ▸ Asia ▸ Hinduism …).
* Every filter pushes a crumb; click a crumb to pop back to that altitude (legible + reversible
* linked state). Reads filterSet; click delegation (defineCustomElement-safe).
*/
import { inject, computed } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// No research label import: the shell supplies the resolver on dashboardConfig.legend (landing 4b).
const bus = inject('selectionBus', null)
const cfg = inject('dashboardConfig', {})
const filterSet = bus?.filterSet || {}
// A crumb is a user-facing surface, so People CODES are resolved to names here exactly as the
// filter chips do (dashboard.config.js:54-57 — codes must never reach a user-facing menu).
// DISPLAY ONLY: `k` is what popTo() acts on and the stored value is never rewritten, so the bus's
// byte-match contract is untouched.
// ★ THE SAME RESOLVER, AND THE SAME UNCONFIGURED DEFAULT, AS THE CHIP BAR (4c-2). Identical on
// purpose: the breadcrumb and the chips render the same filterSet, so if their defaults differed a
// half-configured dashboard would show one value resolved and the other raw, in the same band.
// The list join matters for the same reason it did there — a filterSet value is an ARRAY once two
// values are selected (spec D1), and `String(['A012','A013'])` prints comma-jammed.
// `dataStore` and `searchSourceId` are NOT injected any more: they existed only to construct
// useTreeLegendLabels, and an unused inject is a claim about the host.
const labelFor = (facet, value) => (cfg?.legend?.labelFor ? cfg.legend.labelFor(facet, value) : (Array.isArray(value) ? value.join(', ') : value))
const crumbs = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, v: String(labelFor(k, v)) })))
function popTo(k) {
const keys = Object.keys(filterSet); const i = keys.indexOf(k)
if (i < 0 || !bus) return
keys.slice(i).forEach((kk) => bus.setFilter(kk, null))
}
function onClick(e) {
const el = e.target && e.target.closest && e.target.closest('[data-crumb]')
if (!el || !bus) return
const k = el.getAttribute('data-crumb')
if (k === '__root__') bus.clearFilters(); else popTo(k)
}
useShadowStyles(`
.rd-breadcrumb{ display:flex; align-items:center; gap:4px; font-size:11px; color:var(--dx-line, #d1d4cd); overflow:hidden; }
.rd-crumb{ cursor:pointer; padding:2px 6px; border-radius:4px; white-space:nowrap; }
.rd-crumb:hover{ background:#ffffff1f; color:#fff; }
.rd-crumb.root{ color:var(--dx-muted, #6d766e); } .rd-crumb.cur{ color:#fff; font-weight:700; }
.rd-sep{ opacity:.45; }
`, 'rd-breadcrumb')
</script>
<template>
<nav class="rd-breadcrumb" data-breadcrumb @click="onClick" :aria-label="tStatic('aria.filterBreadcrumb') || 'Filter breadcrumb'">
<span class="rd-crumb root" data-crumb="__root__">{{ L('world', 'World') }}</span>
<template v-for="(c, i) in crumbs" :key="c.k">
<span class="rd-sep">▸</span>
<span class="rd-crumb" :class="{ cur: i === crumbs.length - 1 }" data-crumb-item :data-crumb="c.k">{{ c.v }}</span>
</template>
</nav>
</template>

View file

@ -1,92 +0,0 @@
<script setup>
/**
* ListSelectorLegend.vue — the Driver's VERTICAL LEGEND, used to SELECT a list and filter by it.
*
* @description The shared vertical legend wired as a SELECTOR: it renders the region → zone tree of
* a people-group list and emits the selection, so any dashboard can filter its groups by it.
*
* ★ IT WRAPS <SemanticTreeLegend>, IT DOES NOT REPLACE IT. That component is already the Driver's
* vertical legend and already drives my-upg-100-list's 26-zone tapestry, so every fix it receives —
* theme-awareness, the progress bar, the mobile-sheet path, the scroll-to-selected watcher — keeps
* applying here. A second widget would have to re-earn all of it.
*
* ★ IT EMITS A ZONE ID, NOT A SELECTOR OR RESOLVED GROUPS (gospel's contract). An id is
* SERIALISABLE — it survives ?zone=, profile-config and a deep link, which is the chain this
* dashboard already resolves its list from — and a predicate is none of those. It also keeps this
* component promotable: the moment it emits a selector it has to know what it selects OVER, and the
* consuming dashboard holds four different row shapes across its tabs.
* And the join stays in ONE place. Resolving zones to groups means folding diacritics (the
* membership keys are ASCII while the province data is not — a naive join silently lost 495 groups),
* bridging provinces, and asserting a known total. That logic lives in the consumer; a second copy
* here would drift, and the drift would surface as a wrong COUNT, which nobody notices.
* ★ AND IT NEVER DROPS A ZONE THE TREE FORGOT. Four joinable zones are absent from zone-regions.json
* today — guizhou, xinjiang (100 groups on its own), bihar, madhya-pradesh — so grouping by region
* loses them with no error and no empty state, just a shorter tree. Pass `allZoneIds` and they
* appear in an explicit bucket instead. The consumer said it plainly: better the legend look wrong
* than look fine.
*
* A REGION ROW SELECTS THE UNION OF ITS ZONES (dx-shepherd, provisional and reversible): clicking a
* parent means "everything under here", which is what a reader expects of a tree and what makes the
* top row useful as "all of this list".
*/
import { computed } from 'vue'
import SemanticTreeLegend from './SemanticTreeLegend.vue'
// The selection rule lives beside this file so it can be tested without mounting a component.
import { withUnassigned, selectedId } from './listSelection.js'
const props = defineProps({
// The region → zone tree, as built from the list's own zoning. Passed in rather than built here:
// the builder lives in the plugin that owns the zoning today, and a cross-plugin import is barred
// by check:no-cross-plugin-imports — so promoting it to library/ is a separate, coordinated move.
nodes: { type: Array, default: () => [] },
// Every zone the data can join, tree or no tree. Any id absent from `nodes` is rendered in an
// explicit bucket rather than dropped — see withUnassigned(). Omit it and the legend behaves as
// before, which is why this is a prop and not a rewrite.
allZoneIds: { type: Array, default: null },
unassignedLabel: { type: String, default: 'Unassigned' },
// Which list this tree belongs to. Emitted back with every selection so a consumer holding more
// than one list can tell them apart without tracking it itself.
listId: { type: String, default: '' },
title: { type: String, default: 'Select a list' },
// Which generation the legend opens on; passed through (country-posters needed this).
defaultTab: { type: Number, default: 0 },
columns: { type: Array, default: () => ['count'] },
columnLabel: { type: String, default: '' },
open: { type: Boolean, default: null },
})
const emit = defineEmits(['zone-change', 'update:open'])
// The tree the legend actually renders: the caller's, plus a bucket for any joinable zone it omits.
const treeNodes = computed(() => (
props.allZoneIds ? withUnassigned(props.nodes, props.allZoneIds, props.unassignedLabel) : (props.nodes || [])
))
const hasNodes = computed(() => treeNodes.value.length > 0)
function onSelect(node) {
// null is a real value — "cleared" — and the consumer needs it to drop its filter. Emitting
// nothing on clear would leave a dashboard stuck on the last zone with no way back.
emit('zone-change', selectedId(node))
}
</script>
<template>
<SemanticTreeLegend
v-if="hasNodes"
:nodes="treeNodes"
:title="title"
:default-tab="defaultTab"
:columns="columns"
:column-label="columnLabel"
:open="open"
@select="onSelect"
@update:open="$emit('update:open', $event)"
/>
<!-- No tree yet: say so rather than rendering an empty frame that looks like a broken legend. -->
<p v-else class="lsl-empty" role="status">{{ $t ? $t('legend.noList') : 'No list loaded.' }}</p>
</template>
<style scoped>
.lsl-empty { padding: 12px; font-size: 13px; color: var(--dx-muted, #6d766e); }
</style>

View file

@ -1,192 +0,0 @@
// useMapSelection.js - Map Selection Composable
// Handles region/family selection and visibility updates
// Vue 3 Composition API - ES Module with NPM packages
// Import Vue functions from NPM package
import { ref, computed } from 'vue';
// Import zoom utilities
import { getCircleRadiusInterpolation } from '../constants/zoom.js';
/**
* useMapSelection composable - manages map selection state and visibility
*
* @param {Object} options - Configuration options
* @param {Function} options.getMap - Function that returns the map instance
* @param {string} options.mapId - Unique map identifier for logging
* @param {Object} options.mapStore - Pinia mapStore for shared state
* @param {Function} options.addFamilyConnectionLines - Function to add connection lines
* @param {Function} options.removeFamilyConnectionLines - Function to remove connection lines
* @param {Function} options.getRegionIsoMap - Returns region-slug → array of iso_3166_1_alpha_3 codes
*
* @returns {Object} Selection management functions and state
*/
export function useMapSelection(options = {}) {
const {
getMap = () => null,
mapId = 'unknown',
mapStore = null,
addFamilyConnectionLines = () => {},
removeFamilyConnectionLines = () => {},
// regionIsoMap is built in useMapLayers.addRegionsLayer (region slug →
// array of alpha-3 ISO codes). The regions-fill features come from the
// country-boundaries-v1 tileset which exposes iso_3166_1_alpha_3 (NOT a
// `region` property), so selection highlighting must match on ISO.
getRegionIsoMap = () => ({})
} = options;
// Local state for color scheme
const colorScheme = ref('doxa-regions');
// Computed from mapStore if available
const selectedRegion = computed(() => mapStore?.selectedRegion || null);
const selectedFamily = computed(() => mapStore?.selectedFamily || null);
/**
* Update region layer visibility based on selection
* Highlights selected region, fades others
*/
function updateRegionVisibility() {
const map = getMap();
if (!map || !map.getLayer('regions-fill')) {
return;
}
const region = selectedRegion.value;
if (region) {
// Resolve the selected region's ISO alpha-3 codes. selectedRegion may
// arrive as a display label ('Asia') while regionIsoMap is keyed by
// slug ('asia'); normalize (lowercase, strip non-alpha) for a robust
// match without re-keying the map.
const rMap = getRegionIsoMap() || {};
const norm = (s) => String(s).toLowerCase().replace(/[^a-z]/g, '');
let isos = rMap[region] || [];
if (!isos.length) {
const target = norm(region);
for (const [k, v] of Object.entries(rMap)) {
if (norm(k) === target) { isos = v; break; }
}
}
// Highlight selected region (0.85, vibrant), fade others (0.15, dim).
// Match on iso_3166_1_alpha_3 — the country-boundaries-v1 tileset has
// no `region` property.
map.setPaintProperty('regions-fill', 'fill-opacity', [
'case',
['in', ['get', 'iso_3166_1_alpha_3'], ['literal', isos]],
0.85,
0.15
]);
} else {
// Deselect → restore the vibrant base for all regions.
const opacity = colorScheme.value === 'none' ? 0.2 : 0.7;
map.setPaintProperty('regions-fill', 'fill-opacity', opacity);
}
}
/**
* Update pin opacity based on region selection
* Dims pins outside selected region
*/
function updatePinOpacityByRegion() {
const map = getMap();
if (!map || !map.getLayer('language-family-pins')) {
return;
}
const region = selectedRegion.value;
// If no region selected, show all pins at normal opacity
if (!region) {
map.setPaintProperty('language-family-pins', 'circle-opacity', 0.9);
return;
}
// Dim pins based on PHYSICAL region location (not cluster membership)
map.setPaintProperty('language-family-pins', 'circle-opacity', [
'case',
['==', ['get', 'doxaRegion'], region],
0.9, // In selected region: bright
0.2 // Outside selected region: dimmed
]);
}
/**
* Update language family layer visibility based on selection
* Highlights selected family, fades others
*
* @param {Array} peopleGroups - People groups data (for connection lines)
*/
function updateLanguageFamilyVisibility(peopleGroups = []) {
const map = getMap();
if (!map || !map.getLayer('language-family-pins')) {
return;
}
const family = selectedFamily.value;
if (family) {
// Highlight selected family, fade others
map.setPaintProperty('language-family-pins', 'circle-opacity', [
'case',
['==', ['get', 'languageFamily'], family],
1,
0.15
]);
// Keep standard radius (no size change on selection)
// NOTE: Cannot use zoom expressions inside case statements in Mapbox
// Add connecting lines for selected family (tapestry effect)
addFamilyConnectionLines(family, peopleGroups);
} else {
// Show all at normal opacity and size
map.setPaintProperty('language-family-pins', 'circle-opacity', 0.9);
map.setPaintProperty('language-family-pins', 'circle-radius',
getCircleRadiusInterpolation('standard')
);
// Remove connection lines
removeFamilyConnectionLines();
}
}
/**
* Set the color scheme for region display
*/
function setColorScheme(scheme) {
colorScheme.value = scheme;
}
/**
* Clear all selections via mapStore
*/
function clearSelections() {
if (mapStore) {
mapStore.selectRegion(null);
mapStore.selectFamily(null);
}
}
return {
// State
selectedRegion,
selectedFamily,
colorScheme,
// Visibility updates
updateRegionVisibility,
updatePinOpacityByRegion,
updateLanguageFamilyVisibility,
// Configuration
setColorScheme,
clearSelections
};
}
// ES Module export

View file

@ -185,6 +185,7 @@
"check:no-credentials-omit:self-test": "node internal/check-no-credentials-omit.mjs --self-test",
"check:no-cross-plugin-imports": "node internal/check-no-cross-plugin-imports.mjs",
"check:no-cross-plugin-imports:self-test": "node internal/check-no-cross-plugin-imports.mjs --self-test",
"check:no-dead-library": "node internal/check-no-dead-library.mjs",
"check:no-eval": "node internal/check-no-eval.mjs",
"check:no-free-identifiers": "node plugin-designs/research-map-plus-grid/scripts/check-no-free-identifiers.mjs",
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs",
@ -303,7 +304,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
@ -464,6 +465,7 @@
"check:no-credentials-omit": "no data fetch in library/ or plugin-designs/ uses credentials:'omit': behind a login gate a same-origin request then drops the session and is refused (the Research Gallery's 'Could not load Big Picture'). 'same-origin' never sends credentials cross-origin. A deliberate omit says so on its line: // credentials-omit: <reason>.",
"check:no-credentials-omit:self-test": "a planted omit is caught; prose and an annotated site are not.",
"check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/\u2026). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
"check:no-dead-library": "a deleted library module must stay unreferenced, and the scanner must prove it still works. Scans for a filename as a STRING across internal/, library/ and plugin-designs/ — not just imports — because check-gospel-set-charts.mjs read two components by filename off disk with no import anywhere, so a census and a build would both have missed it. Distinguishes a READ (fatal) from a comment MENTION (reported, harmless). TWO-SIDED: also asserts a known dynamic reference IS found (LegendDesktop lazy-loads LegendMobile), so green means the scanner works AND found nothing, rather than merely found nothing.",
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
"check:no-silent-engine": "A profile must NEVER fall back to a different map engine than its provider asked for. `engineGlobal(provider) || window.mapboxgl` looks defensive and is not: engineGlobal already returns the right global for the requested provider (useMapInstance.js:115 falls back to mapbox's NAME only for an UNKNOWN provider, never a missing engine), so the || fires in exactly one case — the page asked for maplibre and maplibre failed to load — and hands back the TOKEN-GATED engine to a page that asked for the KEYLESS one. Worse, accessToken is gated on !isMaplibre, so the substitute arrives WITHOUT a token: 401s on every tile instead of a legible failure. Found by regions' C3 read 2026-09-26; 2 offenders remained (cities-internet, gospel-resources) after my-upg-100-list was fixed. NOT a duplicate of check:leaf-engine-per-provider / check:provider-switch / check:auto-engine — all three assert the HAPPY path (what loads) and this bug only appears when something does NOT load, so all three were green while it existed. A SOURCE check on purpose: reproducing it live means serving a leaf with MapLibre deliberately blocked, spending a browser slot on what a grep decides. Exits 75 when no file calls engineGlobal — 'found nothing' is not 'found nothing wrong'. Run :self-test to prove the pattern still matches.",