diff --git a/internal/check-preview-flag-dev-only.mjs b/internal/check-preview-flag-dev-only.mjs new file mode 100644 index 000000000..1363c9f5d --- /dev/null +++ b/internal/check-preview-flag-dev-only.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * check-preview-flag-dev-only.mjs — the "preview unreleased content" flag never reaches a built bundle. + * + * globalThis.DXM_PREVIEW_UNRELEASED = true makes a page show what is withheld from the public (the whole-India + * country poster). It is set only under the Vite dev server (library/elements/defineMapElement.js, behind + * import.meta.env.DEV, a compile-time false in every build). The built tree (public/dx-maps) goes to the research + * landing and to DX Life's /dx-maps, so one assignment there would print India publicly. + * + * ASSERTS: + * A. no file under public/ ASSIGNS the flag (any spelling a minifier writes: = true, =!0, =1, …); reading it + * (=== true) is fine; + * B. every SOURCE line that assigns it (library/, plugin-designs/, outside scripts/ and tests) is behind + * import.meta.env.DEV on the same line. + * `--self-test` plants each defect in memory and exits 0 only if both are caught. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, dirname, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const ASSIGN = /DXM_PREVIEW_UNRELEASED\s*=(?!=)/ +const TEXT = /\.(m?js|html|json|css|txt)$/ + +function* walk(dir) { + let names = [] + try { names = readdirSync(dir) } catch (_) { return } + for (const n of names) { + if (n === 'node_modules' || n.startsWith('.')) continue + const p = join(dir, n) + let st; try { st = statSync(p) } catch (_) { continue } + if (st.isDirectory()) yield* walk(p) + else yield p + } +} + +function auditBuilt(files) { + const fails = [] + for (const [rel, text] of files) { + if (!ASSIGN.test(text)) continue + const line = text.split('\n').findIndex((l) => ASSIGN.test(l)) + 1 + fails.push(`A ${rel}:${line} ASSIGNS DXM_PREVIEW_UNRELEASED in the built tree (it would show withheld content publicly)`) + } + return fails +} +function auditSource(files) { + const fails = [] + for (const [rel, text] of files) { + text.split('\n').forEach((l, i) => { + if (ASSIGN.test(l) && !/import\.meta\.env\.DEV/.test(l) && !/^\s*(\/\/|\*)/.test(l)) fails.push(`B ${rel}:${i + 1} assigns DXM_PREVIEW_UNRELEASED outside an import.meta.env.DEV guard`) + }) + } + return fails +} + +const built = () => [...walk(join(ROOT, 'public'))].filter((p) => TEXT.test(p)).map((p) => [relative(ROOT, p), readFileSync(p, 'utf8')]) +const source = () => [...walk(join(ROOT, 'library')), ...walk(join(ROOT, 'plugin-designs'))] + .filter((p) => /\.(m?js|vue)$/.test(p) && !/\/(scripts|tests?|__tests__)\//.test(p)) + .map((p) => [relative(ROOT, p), readFileSync(p, 'utf8')]) + +if (process.argv.includes('--self-test')) { + const a = auditBuilt([['public/dx-maps/x/x.js', 'var a=1;globalThis.DXM_PREVIEW_UNRELEASED=!0;'], ['public/dx-maps/x/ok.js', 'if(globalThis.DXM_PREVIEW_UNRELEASED===true)f()']]) + const b = auditSource([['library/x.js', 'globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/ok.js', 'if (import.meta.env.DEV) globalThis.DXM_PREVIEW_UNRELEASED = true']]) + const okA = a.length === 1 && /x\/x\.js/.test(a[0]), okB = b.length === 1 && /library\/x\.js/.test(b[0]) + console.log(okA ? '✓ caught: a built bundle that assigns the flag (minified =!0); a read (===) is not flagged' : '✗ MISSED: the built-tree assignment') + console.log(okB ? '✓ caught: a source assignment outside import.meta.env.DEV; the guarded one passes' : '✗ MISSED: the unguarded source assignment') + process.exitCode = okA && okB ? 0 : 1 +} else { + const fails = [...auditBuilt(built()), ...auditSource(source())] + if (fails.length) { for (const f of fails) console.log('✗ ' + f); console.log(`FAIL check:preview-flag-dev-only (${fails.length})`); process.exitCode = 1 } + else console.log('PASS check:preview-flag-dev-only — no built file assigns DXM_PREVIEW_UNRELEASED; every source assignment is behind import.meta.env.DEV') +} diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue index 6e869b176..71ae01970 100644 --- a/library/components/legends/LegendMobile.vue +++ b/library/components/legends/LegendMobile.vue @@ -602,7 +602,13 @@ const SNAP_MIN_PX = 24 // the .dragging class flag). const sheetRef = ref(null) const isDragging = ref(false) -const drag = { pending: null, active: false, startY: 0, startH: 0, startState: '', containerH: 0, maxH: 0, lastH: 0 } +const drag = { pending: null, active: false, startY: 0, startH: 0, startState: '', containerH: 0, maxH: 0, lastH: 0, tapTarget: null } +// One height write per frame while dragging (the latest finger position wins), never one per touchmove. +let _dragRaf = 0 +function flushDragHeight() { _dragRaf = 0; uiStore.setLegendHeight(`${drag.lastH}px`) } +// A control the reader may need the browser's own handling for (focus, a keyboard): never taken over. +const NATIVE_TAP = /^(INPUT|SELECT|TEXTAREA)$/ +const DRAG_SLOP_PX = 6 let lastDragEndAt = 0 // A drag that starts on a button (caret / close X / strip) can still be followed // by a synthesized click on release in some browsers — ignore it. @@ -724,6 +730,21 @@ function onSheetTouchStart(event) { if (!t || !sheet) return const inGrabZone = (t.clientY - sheet.getBoundingClientRect().top) <= GRAB_ZONE_PX if (!inGrabZone) return + // ★ THE HANDLE OWNS THE GESTURE FROM THE FIRST TOUCH (2026-10-02, reported from a phone: a resize drag scrolled + // the page). Only the strip and the title rows carry touch-action:none, so a drag that began anywhere else in the + // 44px grab zone let the browser start panning before our touchmove could cancel it, and from then on its + // touchmoves were not cancelable: the page scrolled while the sheet resized. preventDefault on touchstart + // claims the gesture for the whole grab zone, whatever touch-action says. It also swallows the browser's own + // click, so a touch that never becomes a drag is turned back into one on release (tapTarget.click()), and + // inputs keep their native tap. + // The element to click on release: the nearest HTML element (a tap on a button's SVG icon lands on a , + // which has no click()), so the button's own @click runs. + let target = (event.composedPath && event.composedPath()[0]) || event.target + while (target && typeof target.click !== 'function') target = target.parentNode + if (!(target && NATIVE_TAP.test(target.tagName || '')) && event.cancelable) { + event.preventDefault() + drag.tapTarget = target + } else drag.tapTarget = null drag.pending = 'grab' drag.active = false drag.startY = t.clientY @@ -743,23 +764,38 @@ function onSheetTouchMove(event) { if (!t) return const dy = drag.startY - t.clientY // + = finger moved up = sheet grows if (!drag.active) { - if (dy === 0) return + // TAP SLOP: a real thumb tap moves 1-3 px, and counted as a drag it lost its click (the caret, the ✕). A touch + // becomes a drag only past DRAG_SLOP_PX; the claimed touchstart already stops the page panning meanwhile. + if (Math.abs(dy) < (drag.tapTarget ? DRAG_SLOP_PX : 1)) return drag.pending = null drag.active = true isDragging.value = true // .dragging → transition:none, same flush as the height } - event.preventDefault() // no page/content scroll while resizing - const h = Math.round(Math.min(drag.maxH, Math.max(COLLAPSED_PX, drag.startH + dy))) - drag.lastH = h - uiStore.setLegendHeight(`${h}px`) + if (event.cancelable) event.preventDefault() // no page/content scroll while resizing + drag.lastH = Math.round(Math.min(drag.maxH, Math.max(COLLAPSED_PX, drag.startH + dy))) + if (!_dragRaf) { + if (typeof requestAnimationFrame === 'function') _dragRaf = requestAnimationFrame(flushDragHeight) + else flushDragHeight() + } } -function onSheetTouchEnd() { +function onSheetTouchEnd(event) { drag.pending = null - if (!drag.active) return // a tap — the @click handlers own it + const tapTarget = drag.tapTarget + drag.tapTarget = null + if (!drag.active) { + // A tap. The @click handlers own it; if touchstart took the browser's click, hand it back (not on a cancel). + if (tapTarget && !(event && event.type === 'touchcancel') && typeof tapTarget.click === 'function') tapTarget.click() + return + } + // The last frame's height lands before the snap reads it. + if (_dragRaf) { if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(_dragRaf); flushDragHeight() } drag.active = false isDragging.value = false // transition back on BEFORE the snap so it animates - lastDragEndAt = Date.now() + // The post-drag click guard (justDragged) exists for the browser's OWN click at the end of a drag. A drag that + // claimed its touchstart produces no such click, so arming the guard there only ate the reader's NEXT real tap + // (measured: a caret tap right after a drag did not collapse the sheet). Armed only for an unclaimed drag. + if (!tapTarget) lastDragEndAt = Date.now() const h = drag.lastH const travel = h - drag.startH const openH = drag.containerH * 0.3 // the collapse threshold's basis: half of this = 15% of the map diff --git a/library/components/map-controls/MapControlMenu.vue b/library/components/map-controls/MapControlMenu.vue index 533d7d772..4b667baa0 100644 --- a/library/components/map-controls/MapControlMenu.vue +++ b/library/components/map-controls/MapControlMenu.vue @@ -132,9 +132,13 @@ function onOtherOpened(e) { if (props.open && e.detail !== el.value) emit('close // it sat over the Share panel's ✕ (measured 2026-10-02, cities-with-internet). So every panel says it opened, as // a composed event that leaves the shadow root, and MapInfo puts its pointer away (library/components/info/MapInfo.vue). const PANEL_OPEN_EVENT = 'dxmaps:panel-open' +// ...and its twin when it closes (✕, an outside tap, Esc, or unmounted while open), so a HOST can hide its own chrome +// while a panel is up: DX Life's top scroll strip sat over the Share panel's ✕. +const PANEL_CLOSE_EVENT = 'dxmaps:panel-close' +const announceClose = () => { try { host.value?.dispatchEvent(new CustomEvent(PANEL_CLOSE_EVENT, { bubbles: true, composed: true })) } catch (_) {} } -watch(() => props.open, async (open) => { - if (!open) return +watch(() => props.open, async (open, was) => { + if (!open) { if (was) announceClose(); return } await nextTick() place() try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {} @@ -166,6 +170,7 @@ onMounted(() => { document.addEventListener('keydown', onKeyDown) }) onBeforeUnmount(() => { + if (props.open) announceClose() host.value?.removeEventListener(OPEN_EVENT, onOtherOpened) window.removeEventListener('resize', onResize) document.removeEventListener('click', onClickOutside, true) diff --git a/library/elements/defineMapElement.js b/library/elements/defineMapElement.js index 644549f2a..e370ca601 100644 --- a/library/elements/defineMapElement.js +++ b/library/elements/defineMapElement.js @@ -37,6 +37,13 @@ import ProfileLoader from '../ProfileLoader.vue' import { createAppI18n } from '../i18n/index.js' import { provideOwn, withoutInheritedProvides } from './provide-own.js' +// ★ THE PILOT'S HOT-RELOAD SERVER PREVIEWS UNRELEASED CONTENT. globalThis.DXM_PREVIEW_UNRELEASED lets a preview show +// what is withheld from the public (the whole-India country poster, library/packet/countrySheet.js). It is set here, +// in every element's first module, ONLY under the Vite dev server: import.meta.env.DEV is a compile-time false in +// every build, so the build drops the line and no built bundle (the public tree, the research landing, DX Life's +// /dx-maps) can carry it. check:preview-flag-dev-only greps the built tree for any assignment of the flag. +if (import.meta.env && import.meta.env.DEV && typeof globalThis !== 'undefined') globalThis.DXM_PREVIEW_UNRELEASED = true + /** * @param {Object} o * @param {Object} o.profileModules the profile's `import.meta.glob('./*.vue')` map — per-plugin, which diff --git a/library/packet/pins-in-state.js b/library/packet/pins-in-state.js new file mode 100644 index 000000000..93ee80d1e --- /dev/null +++ b/library/packet/pins-in-state.js @@ -0,0 +1,237 @@ +/** + * pins-in-state.js — place people-group pins on a multi-state poster so that + * (a) no two pins overlap (every pair ≥ 2r+gap apart, where feasible), AND + * (b) every pin stays inside its OWN state's land. + * + * It is a self-contained copy of the poster engine's pin de-overlap relaxation + * (poster-render.js, the pass that settles the `_fixed` pins): a repulsion loop + * in pixel space that pushes any pair closer than MIN_SEP apart along the line + * between them, capped so a pin never drifts far from its true position. The one + * addition is the state constraint: where the engine re-anchors a drifted pin to + * the map rect, this re-anchors it to its state polygon — a pin pushed outside is + * pulled back to the nearest point just inside (by `inset`). The engine is read, + * not called, so the whole-India classic4 mode can own the constraint without + * reaching into the renderer. + * + * PX ONLY. Pins and polygons arrive already projected to poster pixels through + * the renderer's own proj(lng,lat); this module never projects. + * + * DETERMINISTIC. Fixed iteration order, golden-angle split for exact coincidence, + * no Math.random — the same input yields the same output bytes, so the sheet is + * stable across runs. + * + * @param {object} opts + * @param {Array} opts.pins [{ id, x, y, stateId }] in poster px. Not mutated. + * @param {object} opts.states { [stateId]: polygonsPx }, where polygonsPx is a + * GeoJSON-Polygon-like list of PARTS; each part is + * [outerRing, ...holeRings]; each ring is [[x,y],…] + * in px. "Inside the state" = inside ANY part's + * outer ring and outside that part's holes. + * @param {number} opts.r pin radius in px. + * @param {number} [opts.gap=0] extra clearance between pin edges; MIN_SEP = 2r+gap. + * @param {number} [opts.iters=260] relaxation passes (matches the engine). + * @param {number} [opts.maxDrift=58] hard cap on how far a pin may move from truth. + * @param {number} [opts.inset=0.5] how far INSIDE the boundary a pulled-back pin lands. + * @returns {{ + * pins: Array<{id,x,y,stateId,moved:boolean,dx:number,dy:number}>, + * moved: number, maxDisp: number, meanDisp: number, + * overlapsRemaining: number, + * outsideState: number, + * infeasible: Array<{ stateId: string, pins: Array }>, + * }} + */ +export function placePinsInState({ pins, states, r, gap = 0, iters = 260, maxDrift = 58, inset = 0.5 }) { + if (!Array.isArray(pins)) throw new TypeError('pins must be an array'); + if (!states || typeof states !== 'object') throw new TypeError('states must be an object'); + if (!(r > 0)) throw new TypeError('r must be a positive number'); + + const MIN_SEP = r * 2 + gap; + + // Working copy — input is never mutated. _ox/_oy hold the true (projected) origin. + const work = pins.map((p) => ({ + id: p.id, stateId: p.stateId, + x: p.x, y: p.y, _ox: p.x, _oy: p.y, + })); + + // Resolve each pin's state parts once. A pin whose stateId has no polygon is + // left unconstrained by the land test (it still de-overlaps) and is reported. + const partsOf = (stateId) => { + const poly = states[stateId]; + return Array.isArray(poly) ? poly : null; + }; + + for (let pass = 0; pass < iters; pass++) { + let moved = false; + + // Pairwise repulsion — same shape as the engine's pass, same fixed order. + for (let i = 0; i < work.length; i++) { + for (let j = i + 1; j < work.length; j++) { + const a = work[i], b = work[j]; + let dx = b.x - a.x, dy = b.y - a.y; + let dist = Math.hypot(dx, dy); + if (dist >= MIN_SEP) continue; + if (dist < 0.0001) { // exactly coincident — split on a + const ang = i * 2.399963; // golden angle, deterministic + dx = Math.cos(ang); dy = Math.sin(ang); dist = 1; + } + const push = (MIN_SEP - dist) / 2 + 0.15; + const ux = dx / dist, uy = dy / dist; + a.x -= ux * push; a.y -= uy * push; + b.x += ux * push; b.y += uy * push; + moved = true; + } + } + + // Re-anchor: cap drift from truth, then keep each pin inside its own state. + for (const it of work) { + const ddx = it.x - it._ox, ddy = it.y - it._oy; + const d = Math.hypot(ddx, ddy); + if (d > maxDrift) { it.x = it._ox + (ddx / d) * maxDrift; it.y = it._oy + (ddy / d) * maxDrift; } + const parts = partsOf(it.stateId); + if (parts && !pointInState(it.x, it.y, parts)) { + const near = nearestInside(it.x, it.y, parts, inset); + it.x = near.x; it.y = near.y; + } + } + + if (!moved) break; + } + + // ── Report ──────────────────────────────────────────────────────────────── + let maxDisp = 0, sumDisp = 0, movedCount = 0, outsideState = 0; + const outPins = work.map((it) => { + const dx = it.x - it._ox, dy = it.y - it._oy; + const disp = Math.hypot(dx, dy); + const didMove = disp > 1e-6; + if (didMove) { movedCount++; sumDisp += disp; if (disp > maxDisp) maxDisp = disp; } + const parts = partsOf(it.stateId); + if (parts && !pointInState(it.x, it.y, parts)) outsideState++; + return { id: it.id, x: it.x, y: it.y, stateId: it.stateId, moved: didMove, dx, dy }; + }); + + // Overlaps remaining: count unordered pairs still closer than MIN_SEP. + let overlapsRemaining = 0; + const infeasibleByState = new Map(); + for (let i = 0; i < outPins.length; i++) { + for (let j = i + 1; j < outPins.length; j++) { + const a = outPins[i], b = outPins[j]; + if (Math.hypot(b.x - a.x, b.y - a.y) < MIN_SEP - 1e-6) { + overlapsRemaining++; + // An overlap that survives is a feasibility failure of the state(s) + // holding these pins — record both so nothing is silently stacked. + for (const p of [a, b]) { + if (!infeasibleByState.has(p.stateId)) infeasibleByState.set(p.stateId, new Set()); + infeasibleByState.get(p.stateId).add(p.id); + } + } + } + } + + const infeasible = [...infeasibleByState.entries()] + .map(([stateId, ids]) => ({ stateId, pins: [...ids] })) + .sort((p, q) => String(p.stateId).localeCompare(String(q.stateId))); + + return { + pins: outPins, + moved: movedCount, + maxDisp, + meanDisp: movedCount ? sumDisp / movedCount : 0, + overlapsRemaining, + outsideState, + infeasible, + }; +} + +// ── Geometry (self-contained; px space) ─────────────────────────────────────── + +// Ray-casting point-in-ring. A point ON an edge counts as inside (poster pins on +// a coast must not be rejected for landing exactly on the line). +function pointInRing(x, y, ring) { + let inside = false; + for (let i = 0, k = ring.length - 1; i < ring.length; k = i++) { + const xi = ring[i][0], yi = ring[i][1]; + const xk = ring[k][0], yk = ring[k][1]; + const intersect = ((yi > y) !== (yk > y)) && + (x < ((xk - xi) * (y - yi)) / ((yk - yi) || 1e-12) + xi); + if (intersect) inside = !inside; + } + return inside; +} + +// Inside the state = inside ANY part's outer ring AND outside that part's holes. +function pointInState(x, y, parts) { + for (const part of parts) { + if (!part || !part.length) continue; + const [outer, ...holes] = part; + if (!outer || outer.length < 3) continue; + if (!pointInRing(x, y, outer)) continue; + let inHole = false; + for (const h of holes) { if (h && h.length >= 3 && pointInRing(x, y, h)) { inHole = true; break; } } + if (!inHole) return true; + } + return false; +} + +// Nearest point ON a ring's edges to (x,y), as {x,y,d2}. +function nearestOnRing(x, y, ring) { + let best = null; + for (let i = 0, k = ring.length - 1; i < ring.length; k = i++) { + const p = nearestOnSegment(x, y, ring[k][0], ring[k][1], ring[i][0], ring[i][1]); + if (!best || p.d2 < best.d2) best = p; + } + return best; +} + +function nearestOnSegment(px, py, ax, ay, bx, by) { + const abx = bx - ax, aby = by - ay; + const len2 = abx * abx + aby * aby; + let t = len2 ? ((px - ax) * abx + (py - ay) * aby) / len2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const x = ax + t * abx, y = ay + t * aby; + const dx = px - x, dy = py - y; + return { x, y, d2: dx * dx + dy * dy }; +} + +// Pull an outside point to the nearest point just INSIDE the state, by `inset`. +// Nearest boundary over ALL parts' rings (outer + holes), then step inward along +// the direction from the boundary toward the pin, re-tested; if that still reads +// outside (a thin neck), fall to the nearest part's centroid, which is inside a +// simple part by construction and a safe honest fallback for a degenerate one. +function nearestInside(x, y, parts, inset) { + let best = null, bestPart = null; + for (const part of parts) { + for (const ring of part) { + if (!ring || ring.length < 2) continue; + const cand = nearestOnRing(x, y, ring); + if (cand && (!best || cand.d2 < best.d2)) { best = cand; bestPart = part; } + } + } + if (!best) return { x, y }; + // Step from the boundary toward the original pin (inward for an outer edge); + // if the pin sat in a hole this points out of the hole, also inward to land. + const vx = x - best.x, vy = y - best.y; + const vlen = Math.hypot(vx, vy) || 1; + let nx = best.x + (vx / vlen) * inset; + let ny = best.y + (vy / vlen) * inset; + if (pointInState(nx, ny, parts)) return { x: nx, y: ny }; + // Thin-neck fallback: step the other way, then the part centroid. + nx = best.x - (vx / vlen) * inset; ny = best.y - (vy / vlen) * inset; + if (pointInState(nx, ny, parts)) return { x: nx, y: ny }; + const c = ringCentroid(bestPart[0]); + return pointInState(c.x, c.y, parts) ? c : { x: best.x, y: best.y }; +} + +function ringCentroid(ring) { + let a = 0, cx = 0, cy = 0; + for (let i = 0, k = ring.length - 1; i < ring.length; k = i++) { + const cross = ring[k][0] * ring[i][1] - ring[i][0] * ring[k][1]; + a += cross; cx += (ring[k][0] + ring[i][0]) * cross; cy += (ring[k][1] + ring[i][1]) * cross; + } + if (Math.abs(a) < 1e-9) { // degenerate: mean of vertices + let mx = 0, my = 0; for (const p of ring) { mx += p[0]; my += p[1]; } + return { x: mx / ring.length, y: my / ring.length }; + } + return { x: cx / (3 * a), y: cy / (3 * a) }; +} + +export default placePinsInState; diff --git a/library/packet/scripts/check-pins-in-state.mjs b/library/packet/scripts/check-pins-in-state.mjs new file mode 100644 index 000000000..12081839e --- /dev/null +++ b/library/packet/scripts/check-pins-in-state.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +/** + * check-pins-in-state.mjs — unit check for library/packet/pins-in-state.js. + * + * Verifies, at the whole-India 60x96in poster's px scale (sheet 2500x4000, + * PIN_R_BASE 8.0, MIN_SEP = 2r*1.16 ≈ the engine's), that placePinsInState(): + * 1. leaves 0 pins outside their own state polygon, + * 2. leaves 0 overlaps that are NOT reported in `infeasible`, + * 3. is DETERMINISTIC (two runs, identical output), and + * 4. is poison-tested: a state too small for its pins is NAMED in `infeasible`, + * never silently overlapped. + * + * Runs on the REAL India data when it is present (the state-assignment JSON that + * reg-post provides); otherwise on a self-contained synthetic 60x96 fixture, so + * the gate is runnable now and tightens automatically once the data lands. A + * vacuous pass is refused: the run asserts a floor on how many pins it exercised. + * + * No browser, no network. Exit 0 PASS, 1 FAIL (reason flushed first), 75 NOT + * MEASURED only if the module cannot be imported. + */ +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, '..', '..', '..'); + +let placePinsInState; +try { + ({ placePinsInState } = await import('../pins-in-state.js')); +} catch (e) { + console.error(`NOT MEASURED: cannot import pins-in-state.js — ${e.message}`); + process.exitCode = 75; +} + +if (placePinsInState) { + // ── Scale constants, taken from poster-render.js for the 60x96 sheet ───────── + const SHEET_W = 2500, SHEET_H = 4000; // '60x96': { w:2500, h:4000 } + const GUTTER = 572; + const MAP_X = GUTTER, MAP_W = SHEET_W - 2 * GUTTER; // ≈1356 + const MAP_Y = 360, MAP_H = SHEET_H - 760; // generous India map band + const R = 8.0; // PIN_R_BASE + const GAP = R * 2 * 0.16; // MIN_SEP = 2r+GAP ≈ 2r*1.16 + const MIN_SEP = R * 2 + GAP; + + let pins, states, source; + const real = findRealData(); + if (real) { ({ pins, states } = real); source = 'REAL India data'; } + else { ({ pins, states } = synthFixture()); source = 'synthetic 60x96 fixture'; } + + const FLOOR = 200; // refuse a vacuous pass + const fails = []; + const note = []; + + if (pins.length < FLOOR) { + fails.push(`exercised only ${pins.length} pins (floor ${FLOOR}) — vacuous`); + } + + // ── 1+2: the real placement ───────────────────────────────────────────────── + const res = placePinsInState({ pins, states, r: R, gap: GAP, maxDrift: 58 }); + note.push(`${source}: ${pins.length} pins across ${Object.keys(states).length} states`); + note.push(`moved ${res.moved}, maxDisp ${res.maxDisp.toFixed(1)}px, meanDisp ${res.meanDisp.toFixed(1)}px, ` + + `overlapsRemaining ${res.overlapsRemaining}, outsideState ${res.outsideState}, ` + + `infeasible states ${res.infeasible.length}`); + + if (res.outsideState !== 0) fails.push(`${res.outsideState} pins ended OUTSIDE their own state`); + + // Input must be unmutated. + if (pins.some((p, i) => p.x !== (real ? real.origX[i] : p.x))) { /* only meaningful for real */ } + + // Every surviving overlap must be accounted for in `infeasible`. + const infeasibleIds = new Set(res.infeasible.flatMap((f) => f.pins)); + let unaccountedOverlaps = 0; + for (let i = 0; i < res.pins.length; i++) { + for (let j = i + 1; j < res.pins.length; j++) { + const a = res.pins[i], b = res.pins[j]; + if (Math.hypot(b.x - a.x, b.y - a.y) < MIN_SEP - 1e-6) { + if (!infeasibleIds.has(a.id) || !infeasibleIds.has(b.id)) unaccountedOverlaps++; + } + } + } + if (unaccountedOverlaps !== 0) fails.push(`${unaccountedOverlaps} overlaps NOT reported in infeasible (silent stacking)`); + + // ── 3: determinism ─────────────────────────────────────────────────────────── + const res2 = placePinsInState({ pins, states, r: R, gap: GAP, maxDrift: 58 }); + const same = JSON.stringify(res.pins) === JSON.stringify(res2.pins); + if (!same) fails.push('NON-DETERMINISTIC: two runs produced different output'); + + // ── 4: poison — a UT too small to hold its pins must be NAMED infeasible ────── + const poison = poisonFixture(R, GAP); + const pres = placePinsInState({ pins: poison.pins, states: poison.states, r: R, gap: GAP }); + const poisonNamed = pres.infeasible.some((f) => f.stateId === 'tiny-UT' && f.pins.length >= 2); + // Count overlaps inside the tiny UT; they MUST all be reported (not silent). + let poisonSilent = 0; + for (let i = 0; i < pres.pins.length; i++) + for (let j = i + 1; j < pres.pins.length; j++) { + const a = pres.pins[i], b = pres.pins[j]; + if (Math.hypot(b.x - a.x, b.y - a.y) < MIN_SEP - 1e-6) { + const ids = new Set(pres.infeasible.flatMap((f) => f.pins)); + if (!ids.has(a.id) || !ids.has(b.id)) poisonSilent++; + } + } + if (!poisonNamed) fails.push('POISON: a too-small state was NOT named in infeasible'); + if (poisonSilent !== 0) fails.push(`POISON: ${poisonSilent} overlaps in the tiny state went unreported`); + // Poison pins must still be INSIDE the tiny state (constraint beats separation). + if (pres.outsideState !== 0) fails.push(`POISON: ${pres.outsideState} pins escaped the tiny state`); + note.push(`poison: tiny-UT named=${poisonNamed}, silent overlaps=${poisonSilent}, outside=${pres.outsideState}`); + + // ── 5: multipolygon + hole + island-fallback paths (the island/coast cases) ── + { + const outer = [[100, 100], [400, 100], [400, 400], [100, 400], [100, 100]]; + const hole = [[200, 200], [300, 200], [300, 300], [200, 300], [200, 200]]; + const island = [[600, 100], [680, 100], [680, 180], [600, 180], [600, 100]]; + const mstates = { A: [[outer, hole], [island]] }; + const mpins = [ + { id: 'inHole', x: 250, y: 250, stateId: 'A' }, // in the mainland hole → must leave it + { id: 'onIsland', x: 640, y: 140, stateId: 'A' }, // on the island part → inside + { id: 'mainland', x: 150, y: 150, stateId: 'A' }, // inside mainland + ]; + const mres = placePinsInState({ pins: mpins, states: mstates, r: R, gap: GAP }); + if (mres.outsideState !== 0) fails.push(`multipoly: ${mres.outsideState} pins outside a 2-part state with a hole`); + const moved = mres.pins.find((p) => p.id === 'inHole'); + if (Math.abs(moved.dx) + Math.abs(moved.dy) < 1e-6) fails.push('multipoly: the in-hole pin was not moved onto land'); + + // Island fallback: a pin geocoded OFF its state is pulled onto the state's land. + const istates = { Lakshadweep: [[[[50, 50], [90, 50], [90, 90], [50, 90], [50, 50]]]] }; + const ires = placePinsInState({ pins: [{ id: 'offshore', x: 300, y: 300, stateId: 'Lakshadweep' }], states: istates, r: R, gap: GAP }); + if (ires.outsideState !== 0) fails.push('island: an offshore pin was not pulled onto its state'); + note.push(`multipoly+hole outside=${mres.outsideState}, in-hole moved=${(Math.abs(moved.dx) + Math.abs(moved.dy) > 1e-6)}; island pulled ${Math.hypot(ires.pins[0].dx, ires.pins[0].dy).toFixed(1)}px onto land`); + } + + for (const n of note) console.log(` · ${n}`); + if (fails.length) { + console.error(`FAIL check:pins-in-state (${source})`); + for (const f of fails) console.error(` ✗ ${f}`); + process.exitCode = 1; + } else { + console.log(`PASS check:pins-in-state — ${pins.length} pins, 0 outside-state, 0 silent overlaps, deterministic, poison caught (${source})`); + } +} + +// ── Real-data loader (best-effort; the shapes reg-post controls) ─────────────── +function findRealData() { + // Candidate committed locations for the India state-assignment + boundary px. + const candidates = [ + 'plugin-designs/country-posters/data/india-pins-in-state.json', + 'plugin-designs/country-posters/data/india-state-pins-px.json', + ]; + for (const rel of candidates) { + const p = resolve(REPO, rel); + if (!existsSync(p)) continue; + try { + const j = JSON.parse(readFileSync(p, 'utf8')); + if (Array.isArray(j.pins) && j.states && typeof j.states === 'object') { + const origX = j.pins.map((q) => q.x); + return { pins: j.pins, states: j.states, origX }; + } + } catch { /* fall through to synthetic */ } + } + return null; +} + +// ── Synthetic 60x96 fixture: a grid of states, pins clustered so de-overlap bites, +// every pin tagged to the state it sits in. Deterministic, ≥ FLOOR pins. ────── +function synthFixture() { + const states = {}; + const pins = []; + const COLS = 6, ROWS = 7; // 42 states + const CW = 1356 / COLS, CH = 2600 / ROWS; // within the India map band + const X0 = 572, Y0 = 400, PAD = 6; + let pid = 0; + for (let c = 0; c < COLS; c++) { + for (let r = 0; r < ROWS; r++) { + const id = `S${c}-${r}`; + const x0 = X0 + c * CW + PAD, y0 = Y0 + r * CH + PAD; + const x1 = X0 + (c + 1) * CW - PAD, y1 = Y0 + (r + 1) * CH - PAD; + // Single-part rectangle (outer ring only). + states[id] = [[[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]]]; + // 5 pins per state, deliberately stacked near the centre so they must spread. + const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; + for (let k = 0; k < 5; k++) { + pins.push({ id: `p${pid++}`, x: cx + (k % 2), y: cy + (k * 0.7), stateId: id }); + } + } + } + return { pins, states }; // 210 pins ≥ FLOOR +} + +// ── Poison: one tiny state with far more pins than fit at MIN_SEP ────────────── +function poisonFixture(R, GAP) { + const MIN_SEP = R * 2 + GAP; + // A box barely larger than one pin — cannot separate 4 pins to MIN_SEP. + const s = MIN_SEP * 0.8; + const x0 = 1000, y0 = 1000; + const states = { 'tiny-UT': [[[[x0, y0], [x0 + s, y0], [x0 + s, y0 + s], [x0, y0 + s], [x0, y0]]]] }; + const cx = x0 + s / 2, cy = y0 + s / 2; + const pins = []; + for (let k = 0; k < 4; k++) pins.push({ id: `tp${k}`, x: cx, y: cy, stateId: 'tiny-UT' }); + return { pins, states }; +} diff --git a/package.json b/package.json index 35005a00e..885d6e7ed 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "check:poster-sizes": "node internal/check-poster-sizes.mjs", "check:pin-hint-floor": "node internal/check-pin-hint-floor.mjs", "check:pin-hint-floor:self-test": "node internal/check-pin-hint-floor.mjs --self-test", + "check:preview-flag-dev-only": "node internal/check-preview-flag-dev-only.mjs", + "check:preview-flag-dev-only:self-test": "node internal/check-preview-flag-dev-only.mjs --self-test", "check:no-fleet-names": "node internal/check-no-fleet-names.mjs", "check:no-fleet-names:self-test": "node internal/check-no-fleet-names.mjs --self-test", "check:dashboard-filter-a11y": "node plugin-designs/research-dashboard/scripts/check-dashboard-filter-a11y.mjs", @@ -223,6 +225,7 @@ "check:selector-fields:self-test": "node internal/check-selector-fields.mjs --self-test", "check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs", "check:packet-variants": "node internal/check-packet-variants.mjs", + "check:pins-in-state": "node library/packet/scripts/check-pins-in-state.mjs", "check:label-key-coverage": "node internal/check-label-key-coverage.mjs", "check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs", "check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs", @@ -350,8 +353,8 @@ "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:script-flags 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: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:auto-engine check:engine-contract 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:no-silent-engine:self-test 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:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test", - "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:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated 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:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate", + "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:script-flags 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: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:auto-engine check:engine-contract 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:no-silent-engine:self-test 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:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test", + "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:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated 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:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -469,6 +472,7 @@ "check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.", "check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.", "check:poster-sizes": "the shared poster-size ladder (library/constants/posterSizes.js) is right: picker order = ladder, dot-free keys, portrait only, the sheets glue up to the finished size, photo slots on exactly 66x66 + 60x96, paper prints and the rest go to a print-shop PDF, every alias resolves, paperFor() picks Letter or A4 by region. Run the `check:poster-sizes:self-test` script for the NEGATIVE test", + "check:preview-flag-dev-only": "the flag that previews withheld content (globalThis.DXM_PREVIEW_UNRELEASED, e.g. the whole-India poster) never reaches a built bundle: no file under public/ assigns it in any spelling a minifier writes, and every source assignment is behind import.meta.env.DEV (set only by the Vite dev server, the pilot's hot-reload preview). The built tree goes to the research landing and DX Life's /dx-maps, where the flag would show India publicly. Run the `check:preview-flag-dev-only:self-test` script for the NEGATIVE test (2 planted defects).", "check:pin-hint-floor": "the pin-hint pill sits at the map's bottom on a page that scrolls (the fold is not an obstacle there) and is still lifted above the fold on a full-screen page; the legend sheet and the licence credit still lift it; PinHint.vue takes its floor from pinHintFloor.js. Run the `check:pin-hint-floor:self-test` script for the NEGATIVE test", "check:no-fleet-names": "tracked source (not data/, public/ or the translation catalogues) names no build-team member, attributes no request to a user and carries no dated QA-round tag; an allowance is a named LINE, never a whole file. Run the `check:no-fleet-names:self-test` script for the NEGATIVE test", "check:dashboard-filter-a11y": "the dashboard's cross-filter is operable and audible: every filter chip is a