From 6624a26e73f6f44df9ca06743c9bfac47f79effa Mon Sep 17 00:00:00 2001 From: Kohei Otsuka Date: Tue, 27 Oct 2026 17:01:20 +0900 Subject: [PATCH 1/5] feat(demo): add a world-file fitting demo with contrast presets `pnpm run dev` now opens a demo portal and a fitting page that show the input GCPs (pixel board, v axis up; planar metre board near Kyoto) and the output: adopted model, the six world-file values, residual arrows, the F test against its critical value, similarity/affine SSE and warnings. Preset buttons load GCP sets whose results clearly differ: similarity vs sheared map, 2/3/5 points, spread vs nearly collinear points, and one wrong point. The presets live in demo/presets.js and are shared with the tests, which call the real fitWorldFile from src and check that each pair splits. vite and jsdom are devDependencies only; the demo imports src directly. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + demo/demo-shell.css | 352 ++++++++++++++++++ demo/main.js | 559 ++++++++++++++++++++++++++++ demo/presets.js | 173 +++++++++ demo/style.css | 211 +++++++++++ demo/worldfile-fit.html | 85 +++++ index.html | 34 ++ package.json | 8 +- pnpm-lock.yaml | 322 +++++++++++++++- scripts/verify-demo-pages-build.mjs | 69 ++++ tests/demo-presets.test.ts | 108 ++++++ tests/demo-wiring.test.ts | 207 ++++++++++ vite.config.ts | 27 ++ 13 files changed, 2150 insertions(+), 6 deletions(-) create mode 100644 demo/demo-shell.css create mode 100644 demo/main.js create mode 100644 demo/presets.js create mode 100644 demo/style.css create mode 100644 demo/worldfile-fit.html create mode 100644 index.html create mode 100644 scripts/verify-demo-pages-build.mjs create mode 100644 tests/demo-presets.test.ts create mode 100644 tests/demo-wiring.test.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index f4e2c6d..59cc6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.tsbuildinfo +dist-demo/ diff --git a/demo/demo-shell.css b/demo/demo-shell.css new file mode 100644 index 0000000..6a17374 --- /dev/null +++ b/demo/demo-shell.css @@ -0,0 +1,352 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: "Helvetica Neue", Arial, sans-serif; + background: #eef0f4; + color: #222; +} + +.c4h-portal { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.c4h-header, +.c4h-portal-header { + background: #1a2744; + color: #fff; +} + +.c4h-header { + padding: 10px 20px; + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.c4h-header h1 { + font-size: 15px; + white-space: nowrap; +} + +.c4h-portal-header { + padding: 18px 24px; +} + +.c4h-portal-header h1 { + font-size: 20px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.c4h-portal-header p { + font-size: 13px; + color: #aac; + margin-top: 4px; +} + +.c4h-back-link { + font-size: 12px; + color: #aac; + text-decoration: none; + white-space: nowrap; +} + +.c4h-back-link:hover { + color: #fff; +} + +.c4h-control-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + flex: 1; +} + +.c4h-control-row label { + font-size: 13px; +} + +.c4h-control-row select { + padding: 4px 8px; + border-radius: 4px; + border: none; + font-size: 13px; + min-width: 200px; +} + +.c4h-btn-group { + display: flex; + gap: 3px; +} + +.c4h-header-btn { + padding: 5px 13px; + border: 1.5px solid rgba(255, 255, 255, 0.3); + border-radius: 4px; + background: transparent; + color: #fff; + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} + +.c4h-header-btn:hover { + background: rgba(255, 255, 255, 0.12); +} + +.c4h-version-btn.active, +.c4h-danger-btn.active { + background: #c0392b; + border-color: #c0392b; +} + +.c4h-tab-btn.active { + background: #3a7bd5; + border-color: #3a7bd5; +} + +.c4h-divider { + width: 1px; + height: 22px; + background: rgba(255, 255, 255, 0.2); + align-self: center; +} + +.c4h-status { + font-size: 12px; + color: #aac; + white-space: nowrap; + margin-left: auto; +} + +.c4h-note { + font-size: 11px; + color: #f39c12; + padding: 2px 20px; + background: rgba(243, 156, 18, 0.1); + min-height: 20px; +} + +.c4h-instructions, +.c4h-op-hint { + font-size: 12px; + color: #c0d8f0; + padding: 5px 20px; + background: rgba(26, 39, 68, 0.94); +} + +.c4h-op-hint { + color: #8aadcc; + background: #162037; + text-align: center; + letter-spacing: 0.02em; +} + +.c4h-panels, +.c4h-main-area { + display: flex; + gap: 12px; + padding: 10px 16px; + justify-content: center; + flex-wrap: wrap; +} + +.c4h-main-area { + gap: 16px; + padding: 16px; +} + +.c4h-panel-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} + +.c4h-panel-label { + font-size: 12px; + font-weight: 700; + color: #1a2744; + background: #fff; + border-radius: 4px 4px 0 0; + padding: 4px 12px; + text-align: center; +} + +.c4h-panel, +.c4h-framed-panel { + background: #fff; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + overflow: hidden; +} + +.c4h-framed-panel { + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.c4h-framed-panel h2 { + font-size: 13px; + color: #1a2744; + text-align: center; +} + +.c4h-panel canvas, +.c4h-framed-panel canvas { + display: block; +} + +.c4h-legend, +.c4h-info-box, +.c4h-card { + background: #fff; + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} + +.c4h-legend { + margin: 0 16px 12px; + padding: 8px 14px; + font-size: 12px; + line-height: 1.8; +} + +.c4h-info-box { + margin: 0 16px 16px; + padding: 14px 20px; + font-size: 13px; + line-height: 1.8; +} + +.c4h-info-box p { + color: #444; +} + +.c4h-info-box strong { + color: #1a2744; +} + +.c4h-error { + color: #c0392b; + font-size: 13px; + padding: 4px 20px; +} + +.c4h-portal-main { + flex: 1; + padding: 32px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.c4h-demo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; + width: 100%; + max-width: 900px; +} + +.c4h-demo-card { + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + overflow: hidden; + display: flex; + flex-direction: column; + text-decoration: none; + color: inherit; + transition: box-shadow 0.18s, transform 0.18s; +} + +.c4h-demo-card:hover { + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); + transform: translateY(-2px); +} + +.c4h-card-header { + padding: 18px 20px 12px; + border-bottom: 1px solid #eef0f4; +} + +.c4h-card-header h2 { + font-size: 16px; + font-weight: 700; + color: #1a2744; + margin-bottom: 4px; +} + +.c4h-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + background: #e8f0fe; + color: #3a7bd5; + margin-bottom: 6px; +} + +.c4h-card-body { + padding: 14px 20px 18px; + font-size: 13px; + line-height: 1.7; + color: #444; + flex: 1; +} + +.c4h-card-body ul { + padding-left: 18px; + margin-top: 6px; +} + +.c4h-card-body li { + margin-bottom: 3px; +} + +.c4h-card-footer { + padding: 12px 20px; + background: #f8f9fb; + border-top: 1px solid #eef0f4; + font-size: 13px; + color: #3a7bd5; + font-weight: 600; +} + +.c4h-portal-footer { + text-align: center; + font-size: 12px; + color: #999; + padding: 16px; +} + +@media (max-width: 720px) { + .c4h-header { + align-items: flex-start; + } + + .c4h-control-row { + flex-basis: 100%; + } + + .c4h-status { + margin-left: 0; + } + + .c4h-demo-grid { + grid-template-columns: 1fr; + } +} diff --git a/demo/main.js b/demo/main.js new file mode 100644 index 0000000..d78593e --- /dev/null +++ b/demo/main.js @@ -0,0 +1,559 @@ +/** + * World-file fitting demo: DOM wiring and drawing. + * + * This file does no fitting of its own. It calls `fit` (by default `fitWorldFile` from `src`, + * injectable for tests) and only draws what comes back. Residual arrows are drawn by applying + * the returned world file to each pixel point — that is reading the output, not a second fitter. + * + * Rules kept here: + * - `canvas.getContext('2d')` may be `null` (jsdom). Every draw function returns early on a + * null context; everything else (fit, tables, events) still runs. + * - DOM text is set with `textContent` and DOM APIs only. + */ +import { fitWorldFile } from '../src/index.ts'; +import { version as packageVersion } from '../package.json'; +import { CONTRASTS, FIT_OPTIONS } from './presets.js'; + +const BOARD_WIDTH = 420; +const BOARD_HEIGHT = 320; +const BOARD_PADDING = { left: 64, right: 44, top: 44, bottom: 40 }; +/** Distance (board px) within which a press grabs a marker. */ +const GRAB_RADIUS = 14; +/** The longest residual arrow is drawn about this long (board px). */ +const ARROW_TARGET_PX = 40; + +const COLOR = { + marker: '#c0392b', + markerActive: '#3a7bd5', + outline: '#ffffff', + axis: '#8aadcc', + grid: '#dfe6ee', + label: '#1f2d3d', + arrow: '#d35400', + predicted: '#7f8c8d', +}; + +const WORLD_FILE_ROWS = [ + ['xScale', 'A: x 方向の縮尺(m/px)'], + ['ySkew', 'D: u が y に効く量'], + ['xSkew', 'B: v が x に効く量'], + ['yScale', 'E: y 方向の縮尺(m/px)'], + ['xOrigin', 'C: 原点の x(m)'], + ['yOrigin', 'F: 原点の y(m)'], +]; + +/** One-line explanation per warning code. */ +export const WARNING_TEXT = Object.freeze({ + affineRejectedNonImproving: 'affine にしても良くならないので相似を採った(正常)', + validationSkipped: '検証用の点(holdout)を渡していない(正常)', + insufficientPointCount: '点が少ないので仮の答え(preview)にとどめた', + affineSkippedInsufficientDof: '点が足りず affine を評価しなかった(5 点以上で評価する)', + nearCollinearSource: 'pixel の点が一直線に近い', + affineSkippedCollinear: '点が一直線に近いので affine を評価しなかった', + unsupportedAxisOrder: 'この軸の順序は扱えない', +}); + +const QUALITY_TEXT = { + adoptable: '採用できる', + preview: '仮の答え', + unusable: '使えない', +}; + +// --------------------------------------------------------------------------- +// formatting +// --------------------------------------------------------------------------- + +export function formatNumber(value, digits = 4) { + if (value === undefined || value === null) return '—'; + if (value === Infinity) return '∞'; + if (!Number.isFinite(value)) return String(value); + const abs = Math.abs(value); + if (abs !== 0 && (abs >= 1e6 || abs < 1e-3)) return value.toExponential(3); + const fixed = value.toFixed(digits); + if (!fixed.includes('.')) return fixed === '-0' ? '0' : fixed; + const trimmed = fixed.replace(/0+$/, '').replace(/\.$/, ''); + return trimmed === '-0' ? '0' : trimmed; +} + +/** Text for the F statistic (Infinity and "not evaluated" have their own wording). */ +export function formatFStatistic(value) { + if (value === undefined || value === null) return 'F は評価していない'; + if (value === Infinity) return 'F = ∞(affine で完全に当たる)'; + return `F = ${formatNumber(value, 3)}`; +} + +// --------------------------------------------------------------------------- +// coordinates (numbers only) +// --------------------------------------------------------------------------- + +/** Data (y up) ↔ board (y down) with one uniform scale for both axes. */ +function makeMapper(points) { + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + let minX = Math.min(...xs); + let maxX = Math.max(...xs); + let minY = Math.min(...ys); + let maxY = Math.max(...ys); + if (maxX - minX < 1e-9) { + minX -= 1; + maxX += 1; + } + if (maxY - minY < 1e-9) { + minY -= 1; + maxY += 1; + } + const innerWidth = BOARD_WIDTH - BOARD_PADDING.left - BOARD_PADDING.right; + const innerHeight = BOARD_HEIGHT - BOARD_PADDING.top - BOARD_PADDING.bottom; + const scale = Math.min(innerWidth / (maxX - minX), innerHeight / (maxY - minY)); + const offsetX = BOARD_PADDING.left + (innerWidth - scale * (maxX - minX)) / 2; + const offsetY = BOARD_PADDING.bottom + (innerHeight - scale * (maxY - minY)) / 2; + return { + scale, + bounds: { minX, maxX, minY, maxY }, + toBoard: ([x, y]) => [offsetX + (x - minX) * scale, BOARD_HEIGHT - offsetY - (y - minY) * scale], + fromBoard: ([bx, by]) => [minX + (bx - offsetX) / scale, minY + (BOARD_HEIGHT - offsetY - by) / scale], + }; +} + +/** Residuals shorter than this (metres) are floating-point noise: no arrows are drawn. */ +const RESIDUAL_NOISE_METRES = 1e-6; + +/** + * How much to magnify residual arrows so the longest is about ARROW_TARGET_PX on the board. + * Returns 0 when every residual is noise (draw no arrows); never shrinks (minimum 1). + */ +export function arrowMagnification(maxResidualMetres, boardPxPerMetre) { + if (!(maxResidualMetres >= RESIDUAL_NOISE_METRES) || !(boardPxPerMetre > 0)) return 0; + const k = ARROW_TARGET_PX / (maxResidualMetres * boardPxPerMetre); + return k < 1 ? 1 : Math.round(k); +} + +function applyWorldFile(worldFile, [u, v]) { + return [ + worldFile.xScale * u + worldFile.xSkew * v + worldFile.xOrigin, + worldFile.ySkew * u + worldFile.yScale * v + worldFile.yOrigin, + ]; +} + +function residualsOf(result, gcps) { + if (!result?.worldFile) return []; + return gcps.map((pair) => { + const predicted = applyWorldFile(result.worldFile, pair.pixel); + const dx = pair.geo[0] - predicted[0]; + const dy = pair.geo[1] - predicted[1]; + return { predicted, dx, dy, length: Math.hypot(dx, dy) }; + }); +} + +function copyGcps(gcps) { + return gcps.map((pair) => ({ pixel: [pair.pixel[0], pair.pixel[1]], geo: [pair.geo[0], pair.geo[1]] })); +} + +// --------------------------------------------------------------------------- +// drawing (each returns early when there is no 2D context) +// --------------------------------------------------------------------------- + +function drawFrame(ctx, mapper, unitLabel) { + ctx.clearRect(0, 0, BOARD_WIDTH, BOARD_HEIGHT); + ctx.save(); + ctx.strokeStyle = COLOR.grid; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, 0.5, BOARD_WIDTH - 1, BOARD_HEIGHT - 1); + // axes along the bottom-left edge of the data box + const [x0, y0] = mapper.toBoard([mapper.bounds.minX, mapper.bounds.minY]); + const [x1] = mapper.toBoard([mapper.bounds.maxX, mapper.bounds.minY]); + const [, y1] = mapper.toBoard([mapper.bounds.minX, mapper.bounds.maxY]); + ctx.strokeStyle = COLOR.axis; + ctx.beginPath(); + ctx.moveTo(x0, y1); + ctx.lineTo(x0, y0); + ctx.lineTo(x1, y0); + ctx.stroke(); + ctx.fillStyle = COLOR.label; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`${formatNumber(mapper.bounds.minX, 0)}`, x0, y0 + 14); + ctx.textAlign = 'right'; + ctx.fillText(`${formatNumber(mapper.bounds.maxX, 0)} ${unitLabel}`, x1, y0 + 14); + ctx.fillText(`${formatNumber(mapper.bounds.maxY, 0)}`, x0 - 4, y1 + 4); + ctx.fillText(`${formatNumber(mapper.bounds.minY, 0)}`, x0 - 4, y0); + ctx.restore(); +} + +function drawMarker(ctx, [bx, by], index, active) { + ctx.save(); + ctx.fillStyle = active ? COLOR.markerActive : COLOR.marker; + ctx.strokeStyle = COLOR.outline; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(bx, by, 7, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = COLOR.label; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(String(index + 1), bx + 9, by - 8); + ctx.restore(); +} + +function drawArrow(ctx, [fx, fy], [tx, ty]) { + const length = Math.hypot(tx - fx, ty - fy); + if (length < 0.5) return; + const angle = Math.atan2(ty - fy, tx - fx); + const head = Math.min(8, length / 2); + ctx.save(); + ctx.strokeStyle = COLOR.arrow; + ctx.fillStyle = COLOR.arrow; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(fx, fy); + ctx.lineTo(tx, ty); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(tx, ty); + ctx.lineTo(tx - head * Math.cos(angle - 0.4), ty - head * Math.sin(angle - 0.4)); + ctx.lineTo(tx - head * Math.cos(angle + 0.4), ty - head * Math.sin(angle + 0.4)); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} + +// --------------------------------------------------------------------------- +// the page +// --------------------------------------------------------------------------- + +export function initDemo(config = {}) { + const doc = config.document ?? globalThis.document; + const fit = config.fit ?? fitWorldFile; + const $ = (id) => doc.getElementById(id); + + const el = { + presetBar: $('preset-bar'), + expectation: $('preset-expectation'), + pixelCanvas: $('pixel-canvas'), + geoCanvas: $('geo-canvas'), + modelLabel: $('model-label'), + similarShape: $('similar-shape'), + worldfileBody: $('worldfile-body'), + qualityGate: $('quality-gate'), + qualityText: $('quality-text'), + fitStatus: $('fit-status'), + rms: $('rms'), + warningList: $('warning-list'), + residualBody: $('residual-body'), + residualScale: $('residual-scale'), + fText: $('f-text'), + fBars: $('f-bars'), + sseText: $('sse-text'), + sseBars: $('sse-bars'), + status: $('status'), + error: $('error'), + version: $('version'), + }; + + const pixelCtx = el.pixelCanvas?.getContext?.('2d') ?? null; + const geoCtx = el.geoCanvas?.getContext?.('2d') ?? null; + + const state = { + activePreset: null, + gcps: [], + pixelMapper: null, + result: null, + recalcCount: 0, + dragIndex: -1, + }; + + const setText = (node, text) => { + if (node) node.textContent = text; + }; + const clear = (node) => { + while (node?.firstChild) node.removeChild(node.firstChild); + }; + const make = (tag, className, text) => { + const node = doc.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + + // ---- preset bar -------------------------------------------------------- + + const presetButtons = []; + function renderPresetBar() { + if (!el.presetBar) return; + clear(el.presetBar); + for (const contrast of CONTRASTS) { + const group = make('div', 'daedalus-preset-group'); + group.appendChild(make('span', 'daedalus-preset-title', contrast.title)); + for (const option of contrast.options) { + const button = make('button', 'daedalus-preset-btn', option.label); + button.type = 'button'; + button.dataset.preset = `${contrast.key}:${option.key}`; + button.setAttribute('aria-pressed', 'false'); + button.addEventListener('click', () => applyPreset(contrast, option)); + presetButtons.push(button); + group.appendChild(button); + } + el.presetBar.appendChild(group); + } + } + + function applyPreset(contrast, option) { + const id = `${contrast.key}:${option.key}`; + state.activePreset = id; + state.gcps = copyGcps(option.gcps); + state.pixelMapper = makeMapper(state.gcps.map((pair) => pair.pixel)); + for (const button of presetButtons) { + const pressed = button.dataset.preset === id; + button.setAttribute('aria-pressed', String(pressed)); + button.classList.toggle('active', pressed); + } + setText(el.expectation, option.expectation); + recalculate(); + } + + // ---- outputs ------------------------------------------------------------- + + function renderWorldFile(result) { + clear(el.worldfileBody); + const wf = result.worldFile; + for (const [key, description] of WORLD_FILE_ROWS) { + const row = make('tr'); + row.appendChild(make('th', '', key)); + row.appendChild(make('td', 'daedalus-num', wf ? formatNumber(wf[key], 6) : '—')); + row.appendChild(make('td', 'daedalus-muted', description)); + el.worldfileBody?.appendChild(row); + } + if (wf && result.similarityOnly) { + setText(el.similarShape, '相似の形: xSkew = −ySkew・xScale = yScale'); + } else { + setText(el.similarShape, ''); + } + } + + function renderWarnings(result) { + clear(el.warningList); + if (result.warnings.length === 0) { + el.warningList?.appendChild(make('li', 'daedalus-muted', 'warning なし')); + return; + } + for (const warning of result.warnings) { + const item = make('li'); + item.appendChild(make('code', '', warning.code)); + item.appendChild(doc.createTextNode(` — ${WARNING_TEXT[warning.code] ?? ''}`)); + el.warningList?.appendChild(item); + } + } + + function residualMagnification(residuals, geoMapper) { + if (!geoMapper) return 0; + return arrowMagnification(Math.max(0, ...residuals.map((r) => r.length)), geoMapper.scale); + } + + function renderResiduals(residuals, k) { + clear(el.residualBody); + residuals.forEach((r, index) => { + const row = make('tr'); + row.appendChild(make('td', '', String(index + 1))); + row.appendChild(make('td', 'daedalus-num', formatNumber(r.dx, 3))); + row.appendChild(make('td', 'daedalus-num', formatNumber(r.dy, 3))); + row.appendChild(make('td', 'daedalus-num', formatNumber(r.length, 3))); + el.residualBody?.appendChild(row); + }); + setText( + el.residualScale, + residuals.length === 0 ? '' : k === 0 ? '残差はほぼ 0(矢印なし)' : `矢印は残差の ×${k}`, + ); + } + + /** Horizontal bars on a log scale (values span 0.1 … 100000). */ + function renderBars(container, bars) { + clear(container); + const scaled = (value) => Math.log10(1 + Math.max(0, value)); + const top = Math.max(1e-9, ...bars.map((bar) => scaled(bar.value))); + for (const bar of bars) { + const row = make('div', 'daedalus-bar-row'); + row.appendChild(make('span', 'daedalus-bar-label', bar.label)); + const track = make('span', 'daedalus-bar-track'); + const fill = make('span', `bar ${bar.className}`); + fill.style.width = `${Math.max(1, (scaled(bar.value) / top) * 100)}%`; + track.appendChild(fill); + row.appendChild(track); + row.appendChild(make('span', 'daedalus-num', formatNumber(bar.value, 3))); + container?.appendChild(row); + } + } + + function renderF(result) { + const debug = result.debug ?? {}; + if (debug.fStatistic === undefined) { + const reason = result.warnings.find((w) => w.code.startsWith('affineSkipped')); + setText(el.fText, `F は評価していない${reason ? `(${WARNING_TEXT[reason.code]})` : ''}`); + renderBars(el.fBars, []); + return; + } + const verdict = debug.fStatistic > debug.criticalF ? '臨界値を超える → affine が有意' : '臨界値以下 → 相似で足りる'; + setText(el.fText, `${formatFStatistic(debug.fStatistic)}・臨界値 ${formatNumber(debug.criticalF, 3)}(${verdict})`); + const bars = []; + if (Number.isFinite(debug.fStatistic)) bars.push({ label: 'F', value: debug.fStatistic, className: 'daedalus-bar-f' }); + bars.push({ label: '臨界値', value: debug.criticalF, className: 'daedalus-bar-critical' }); + renderBars(el.fBars, bars); + } + + function renderSse(result) { + const debug = result.debug ?? {}; + if (debug.sseSimilarity === undefined || debug.sseAffine === undefined) { + setText(el.sseText, '相似と affine の SSE は affine を評価したときだけ出る'); + renderBars(el.sseBars, []); + return; + } + setText(el.sseText, '残差の二乗和(m²)。相似が affine より大きく離れていれば、相似では当たらない'); + renderBars(el.sseBars, [ + { label: '相似', value: debug.sseSimilarity, className: 'daedalus-bar-similar' }, + { label: 'affine', value: debug.sseAffine, className: 'daedalus-bar-affine' }, + ]); + } + + function drawPixelBoard() { + if (!pixelCtx || !state.pixelMapper) return; + drawFrame(pixelCtx, state.pixelMapper, 'px'); + state.gcps.forEach((pair, index) => + drawMarker(pixelCtx, state.pixelMapper.toBoard(pair.pixel), index, index === state.dragIndex), + ); + } + + function drawGeoBoard(residuals, k, geoMapper) { + if (!geoCtx || !geoMapper) return; + drawFrame(geoCtx, geoMapper, 'm'); + if (k > 0) residuals.forEach((r, index) => { + const actual = state.gcps[index].geo; + const from = [actual[0] - k * r.dx, actual[1] - k * r.dy]; + drawArrow(geoCtx, geoMapper.toBoard(from), geoMapper.toBoard(actual)); + }); + state.gcps.forEach((pair, index) => drawMarker(geoCtx, geoMapper.toBoard(pair.geo), index, false)); + } + + function render(result) { + const geoMapper = state.gcps.length > 0 ? makeMapper(state.gcps.map((pair) => pair.geo)) : null; + const residuals = residualsOf(result, state.gcps); + const k = residualMagnification(residuals, geoMapper); + + setText( + el.modelLabel, + result.worldFile === null + ? '当てはめられなかった' + : result.similarityOnly + ? '相似(回転・等方スケール・平行移動)' + : 'affine(shear を含む)', + ); + el.modelLabel?.classList.toggle('daedalus-affine', result.worldFile !== null && !result.similarityOnly); + setText(el.qualityGate, result.qualityGate); + setText(el.qualityText, QUALITY_TEXT[result.qualityGate] ?? ''); + setText(el.fitStatus, result.status); + setText(el.rms, result.rmsMeters === null ? '—' : `${formatNumber(result.rmsMeters, 4)} m`); + renderWorldFile(result); + renderWarnings(result); + renderResiduals(residuals, k); + renderF(result); + renderSse(result); + drawPixelBoard(); + drawGeoBoard(residuals, k, geoMapper); + } + + function recalculate() { + state.recalcCount += 1; + setText(el.error, ''); + try { + const result = fit(copyGcps(state.gcps), FIT_OPTIONS); + state.result = result; + render(result); + setText(el.status, `計算した(${state.gcps.length} 点)`); + } catch (error) { + state.result = null; + setText(el.error, error instanceof Error ? error.message : String(error)); + setText(el.status, '計算に失敗した'); + drawPixelBoard(); + } + } + + // ---- dragging on the pixel board ---------------------------------------- + + function boardPointFromEvent(event) { + const rect = el.pixelCanvas.getBoundingClientRect(); + const sx = rect.width > 0 ? BOARD_WIDTH / rect.width : 1; + const sy = rect.height > 0 ? BOARD_HEIGHT / rect.height : 1; + return [(event.clientX - rect.left) * sx, (event.clientY - rect.top) * sy]; + } + + function onPointerDown(event) { + if (!state.pixelMapper) return; + const [bx, by] = boardPointFromEvent(event); + let best = -1; + let bestDistance = GRAB_RADIUS; + state.gcps.forEach((pair, index) => { + const [mx, my] = state.pixelMapper.toBoard(pair.pixel); + const distance = Math.hypot(mx - bx, my - by); + if (distance <= bestDistance) { + best = index; + bestDistance = distance; + } + }); + if (best < 0) return; + state.dragIndex = best; + el.pixelCanvas.setPointerCapture?.(event.pointerId); + drawPixelBoard(); + } + + function onPointerMove(event) { + if (state.dragIndex < 0) return; + const [u, v] = state.pixelMapper.fromBoard(boardPointFromEvent(event)); + state.gcps[state.dragIndex].pixel = [Math.round(u), Math.round(v)]; + drawPixelBoard(); + } + + function onPointerUp(event) { + if (state.dragIndex < 0) return; + onPointerMove(event); + state.dragIndex = -1; + recalculate(); + } + + function pixelMarkers() { + if (!state.pixelMapper || !el.pixelCanvas) return []; + const rect = el.pixelCanvas.getBoundingClientRect(); + const sx = rect.width > 0 ? rect.width / BOARD_WIDTH : 1; + const sy = rect.height > 0 ? rect.height / BOARD_HEIGHT : 1; + return state.gcps.map((pair) => { + const [bx, by] = state.pixelMapper.toBoard(pair.pixel); + return [rect.left + bx * sx, rect.top + by * sy]; + }); + } + + // ---- start --------------------------------------------------------------- + + setText(el.version, `使っている版: @c4h/daedalus ${packageVersion}`); + renderPresetBar(); + el.pixelCanvas?.addEventListener('pointerdown', onPointerDown); + el.pixelCanvas?.addEventListener('pointermove', onPointerMove); + el.pixelCanvas?.addEventListener('pointerup', onPointerUp); + el.pixelCanvas?.addEventListener('pointercancel', onPointerUp); + const first = CONTRASTS[0]; + applyPreset(first, first.options[0]); + + return { + whenSettled: () => Promise.resolve(), + getRecalcCount: () => state.recalcCount, + getState: () => ({ + activePreset: state.activePreset, + gcps: copyGcps(state.gcps), + pixelMarkers: pixelMarkers(), + result: state.result, + hasContext: { pixel: pixelCtx !== null, geo: geoCtx !== null }, + }), + }; +} + +// Start only when the page asks for it, so tests can build the DOM and inject `fit` first. +if (typeof document !== 'undefined' && document.body?.dataset?.demoAutoInit === 'true') { + initDemo(); +} diff --git a/demo/presets.js b/demo/presets.js new file mode 100644 index 0000000..b2713a0 --- /dev/null +++ b/demo/presets.js @@ -0,0 +1,173 @@ +/** + * Demo presets (data only — this module never touches the DOM). + * + * Both the demo page (`demo/main.js`) and the tests (`tests/demo-presets.test.ts`, + * `tests/demo-wiring.test.ts`) import these values, so a typo here shows up as a test that no + * longer splits the way the contrast promises. + * + * Coordinates: the CRS side is a planar metric system near Kyoto Station (JGD2011 / Japan + * Plane Rectangular CS VI, metres). The pixel side has its v axis pointing UP (origin at the + * bottom-left). The "true" map is a similarity: 2.5 m/px, rotated 4°, origin (-22000, -112000). + * A small fixed error (0.1–0.5 m) is added to every CRS point, as real GCPs always have some; + * with zero error the affine F test would never be reached for a similarity map. + */ + +export const GEO_BASIS = Object.freeze({ kind: 'planar_xy', axisOrder: 'xy', unit: 'm' }); + +export const FIT_OPTIONS = Object.freeze({ geoBasis: GEO_BASIS, debug: true }); + +/** Five well spread pixel positions (v up). */ +const PIXELS = [ + [120, 80], + [900, 140], + [480, 520], + [160, 860], + [940, 900], +]; + +/** Fixed per-point error in metres, added to the CRS side. */ +const ERRORS = [ + [0.4, -0.3], + [-0.2, 0.5], + [0.3, 0.2], + [-0.5, -0.1], + [0.1, -0.4], +]; + +const METRES_PER_PIXEL = 2.5; +const ROTATION_DEG = 4; +const ORIGIN = [-22000, -112000]; + +/** The true map: similarity (rotation + uniform scale + translation). */ +function truth([u, v]) { + const theta = (ROTATION_DEG * Math.PI) / 180; + const s = METRES_PER_PIXEL; + return [ + ORIGIN[0] + s * (Math.cos(theta) * u - Math.sin(theta) * v), + ORIGIN[1] + s * (Math.sin(theta) * u + Math.cos(theta) * v), + ]; +} + +const round = (value) => Math.round(value * 1000) / 1000; + +function gcp(pixel, geo, error) { + return Object.freeze({ + pixel: Object.freeze([pixel[0], pixel[1]]), + geo: Object.freeze([round(geo[0] + error[0]), round(geo[1] + error[1])]), + }); +} + +function gcpsFrom(pixels, mapPixel = truth) { + return Object.freeze(pixels.map((pixel, i) => gcp(pixel, mapPixel(pixel), ERRORS[i % ERRORS.length]))); +} + +const SHEAR = 0.15; +const sheared = ([u, v]) => truth([u + SHEAR * v, v]); + +const BASE = gcpsFrom(PIXELS); +const SHEARED = gcpsFrom(PIXELS, sheared); +const COLLINEAR = gcpsFrom([ + [100, 100], + [300, 301], + [500, 499], + [700, 702], + [900, 900], +]); +/** Point 3 (index 2) placed 75 m off: (+60, -45) m. */ +const ONE_WRONG = Object.freeze( + BASE.map((pair, i) => + i === 2 ? Object.freeze({ pixel: pair.pixel, geo: Object.freeze([round(pair.geo[0] + 60), round(pair.geo[1] - 45)]) }) : pair, + ), +); + +/** + * Four contrasts. Each option is one button: pressing it loads its GCPs and runs the fit once. + * `expectation` is the one line shown under the bar ("with this input you should see ..."). + */ +export const CONTRASTS = Object.freeze([ + { + key: 'model', + title: 'モデルが分かれる', + options: [ + { + key: 'similar', + label: '回転と縮尺だけの地図', + expectation: + '「相似」を採る。6 値は相似の形(xSkew = −ySkew・xScale = yScale)。残差の矢印はどれも短い。warning が 2 つ出るが正常。', + gcps: BASE, + }, + { + key: 'sheared', + label: '横にゆがんだ地図', + expectation: '「affine」を採る。xSkew と −ySkew が一致しない。F は臨界値を大きく超え、相似の SSE が affine よりずっと大きい。', + gcps: SHEARED, + }, + ], + }, + { + key: 'count', + title: '点の数で確かさが分かれる', + options: [ + { + key: 'two', + label: 'GCP 2 点', + expectation: '品質は preview(仮の答え)。affine は評価しない。', + gcps: Object.freeze(BASE.slice(0, 2)), + }, + { + key: 'three', + label: 'GCP 3 点', + expectation: '品質は adoptable(採用できる)。ただし点が足りず affine は評価しない(F の棒は出ない)。', + gcps: Object.freeze(BASE.slice(0, 3)), + }, + { + key: 'five', + label: 'GCP 5 点', + expectation: '5 点で初めて affine を評価する。F と臨界値の棒が出る(この入力では F は小さく、相似のまま)。', + gcps: BASE, + }, + ], + }, + { + key: 'layout', + title: '点の並びで警告が分かれる', + options: [ + { + key: 'spread', + label: 'ばらけた 5 点', + expectation: '一直線の警告(nearCollinearSource)は出ない。affine を評価する。', + gcps: BASE, + }, + { + key: 'collinear', + label: '一直線に近い 5 点', + expectation: 'nearCollinearSource(一直線に近い)が出て、affine を評価しない。', + gcps: COLLINEAR, + }, + ], + }, + { + key: 'outlier', + title: '誤った点が見える', + options: [ + { + key: 'correct', + label: '正しい 5 点', + expectation: 'RMS は 1 m 未満。残差の矢印はどれも短い。', + gcps: BASE, + }, + { + key: 'oneWrong', + label: '1 点だけ誤った 5 点', + expectation: + '3 番の残差の矢印だけが長く伸び、RMS が数十 m に増える。1 点の誤りは品質の札(adoptable のまま)では止まらず、残差の矢印と RMS でしか見えない。', + gcps: ONE_WRONG, + }, + ], + }, +]); + +/** Look up one option by contrast key and option key. */ +export function findPreset(contrastKey, optionKey) { + return CONTRASTS.find((contrast) => contrast.key === contrastKey)?.options.find((option) => option.key === optionKey); +} diff --git a/demo/style.css b/demo/style.css new file mode 100644 index 0000000..d5689c8 --- /dev/null +++ b/demo/style.css @@ -0,0 +1,211 @@ +/* Page-specific styles for the world-file fitting demo (the shared shell is demo-shell.css). */ + +.daedalus-presets { + padding: 12px 16px 4px; + background: #ffffff; + border-bottom: 1px solid #e3e8ef; +} + +.daedalus-preset-bar { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; +} + +.daedalus-preset-group { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.daedalus-preset-title { + font-size: 12px; + font-weight: 600; + color: #4a5b6d; + margin-right: 2px; + min-width: 11em; +} + +.daedalus-preset-btn { + padding: 5px 12px; + border: 1.5px solid #9fb3c8; + border-radius: 4px; + background: #ffffff; + color: #1f2d3d; + font-size: 12px; + cursor: pointer; +} + +.daedalus-preset-btn:hover { + background: #eef4fb; +} + +.daedalus-preset-btn[aria-pressed="true"] { + background: #3a7bd5; + border-color: #3a7bd5; + color: #ffffff; +} + +.daedalus-expectation { + margin: 10px 0 6px; + font-size: 13px; + color: #1f2d3d; +} + +.daedalus-boards { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 12px 16px; +} + +.daedalus-board { + display: block; + width: 420px; + max-width: 100%; + height: auto; + aspect-ratio: 420 / 320; + background: #f8fafc; + border: 1px solid #dfe6ee; +} + +#pixel-canvas { + cursor: grab; + touch-action: none; +} + +#pixel-canvas:active { + cursor: grabbing; +} + +.daedalus-results { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; + padding: 0 16px 16px; +} + +.daedalus-card { + padding: 12px 16px; +} + +.daedalus-card h2 { + margin: 0 0 8px; + font-size: 15px; +} + +.daedalus-card h3 { + margin: 14px 0 6px; + font-size: 13px; +} + +.daedalus-model { + display: inline-block; + margin: 0 0 8px; + padding: 4px 10px; + border-radius: 4px; + background: #e8f1fb; + color: #1f4f8f; + font-weight: 600; +} + +.daedalus-model.daedalus-affine { + background: #fdf0e6; + color: #a04000; +} + +.daedalus-facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px 12px; + margin: 0; + font-size: 13px; +} + +.daedalus-facts dt { + color: #4a5b6d; +} + +.daedalus-facts dd { + margin: 0; +} + +.daedalus-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.daedalus-table th, +.daedalus-table td { + padding: 3px 6px; + border-bottom: 1px solid #eef0f4; + text-align: left; +} + +.daedalus-num { + font-family: "SFMono-Regular", Consolas, monospace; + text-align: right; + white-space: nowrap; +} + +.daedalus-muted { + color: #6b7a89; + font-size: 12px; +} + +.daedalus-shape { + margin: 8px 0 0; + font-size: 12px; + color: #1f4f8f; +} + +.daedalus-bars { + display: grid; + gap: 4px; +} + +.daedalus-bar-row { + display: grid; + grid-template-columns: 56px 1fr 96px; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.daedalus-bar-track { + display: block; + height: 12px; + background: #eef2f7; + border-radius: 2px; + overflow: hidden; +} + +.daedalus-bar-track .bar { + display: block; + height: 100%; +} + +.daedalus-bar-f { background: #d35400; } +.daedalus-bar-critical { background: #7f8c8d; } +.daedalus-bar-similar { background: #3a7bd5; } +.daedalus-bar-affine { background: #a04000; } + +.daedalus-warnings { + margin: 0; + padding-left: 18px; + font-size: 12px; +} + +.daedalus-warnings li { + margin: 2px 0; +} + +@media (max-width: 480px) { + .daedalus-boards, + .daedalus-results { + padding-left: 8px; + padding-right: 8px; + } +} diff --git a/demo/worldfile-fit.html b/demo/worldfile-fit.html new file mode 100644 index 0000000..15a783b --- /dev/null +++ b/demo/worldfile-fit.html @@ -0,0 +1,85 @@ + + + + + + Daedalus World-file Fitting Demo + + + + +
+ ← デモ一覧 +

Daedalus - World-file Fitting Demo

+ 初期化中... +
+ +
+
+

見え方:

+
+ +
+ +
+
+
入力: pixel(地図画像の座標・v 軸は上向き・原点は左下)— 点はドラッグできる
+ +
+
+
入力: CRS の平面座標(京都駅付近・JGD2011 平面直角 VI 系・m)と残差の矢印
+ +
+
+
+ +
+
+

採用したモデル

+

—

+
+
qualityGate
+
—
+
status
+
—
+
rmsMeters
+
—
+
+
+ +
+

world file の 6 値

+ + +
+

+
+ +
+

F 検定(affine にする価値があるか)

+

+
+

SSE(相似と affine)

+

+
+

棒は対数目盛り。

+
+ +
+

warning

+
    +

    残差(実際の位置 − world file で写した位置・m)

    + + + + + +
    #dxdy長さ
    +
    +
    + +
    + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..b45f70a --- /dev/null +++ b/index.html @@ -0,0 +1,34 @@ + + + + + + Daedalus Demos + + + +
    +

    @c4h/daedalus - デモ一覧

    +

    GCP(地図画像の pixel と CRS の平面座標の組)から world file を当てはめるライブラリのデモ

    +
    + +
    + +
    + +
    @c4h/daedalus - Daedalus Demo Portal
    + + diff --git a/package.json b/package.json index 1d005bb..b4ff735 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,18 @@ "test": "vitest run", "test:types": "tsc --noEmit --strict --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck tests/api-smoke.test.ts tests/contract-freeze.test.ts", "test:watch": "vitest", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "dev": "vite --host", + "build:demo": "vite build", + "preview": "vite preview", + "verify:demo": "GITHUB_PAGES=true pnpm run build:demo && node scripts/verify-demo-pages-build.mjs" }, "devDependencies": { "@types/node": "^25.5.2", "@vitest/coverage-v8": "^3.2.6", + "jsdom": "^30.0.0", "typescript": "^5.9.3", + "vite": "^7.3.1", "vitest": "^3.2.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67acd9d..a93e565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,13 +13,19 @@ importers: version: 25.9.5 '@vitest/coverage-v8': specifier: ^3.2.6 - version: 3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/node@25.9.5)(supports-color@7.2.0)) + version: 3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/node@25.9.5)(jsdom@30.1.3)(supports-color@7.2.0)) + jsdom: + specifier: ^30.0.0 + version: 30.1.3 typescript: specifier: ^5.9.3 version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.6(@types/node@25.9.5) vitest: specifier: ^3.2.6 - version: 3.2.7(@types/node@25.9.5)(supports-color@7.2.0) + version: 3.2.7(@types/node@25.9.5)(jsdom@30.1.3)(supports-color@7.2.0) packages: @@ -27,6 +33,14 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@7.1.9': + resolution: {integrity: sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@10.0.5': + resolution: {integrity: sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -48,6 +62,46 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -204,6 +258,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -454,6 +517,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + brace-expansion@2.1.4: resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} @@ -484,6 +550,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -493,6 +567,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -506,6 +583,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -548,6 +629,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -555,6 +640,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -583,12 +671,25 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsdom@30.1.3: + resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -599,6 +700,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -622,6 +726,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -648,11 +755,23 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rollup@4.63.0: resolution: {integrity: sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -732,6 +851,21 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.4.19: + resolution: {integrity: sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==} + + tldts@7.4.19: + resolution: {integrity: sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -740,6 +874,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@8.12.3: + resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==} + engines: {node: '>=22.19.0'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -813,6 +951,22 @@ packages: jsdom: optional: true + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.3.0: + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} + engines: {node: ^22.14.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -831,6 +985,13 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + snapshots: '@ampproject/remapping@2.3.0': @@ -838,6 +999,21 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@7.1.9': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@10.0.5': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -853,6 +1029,34 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -931,6 +1135,8 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@exodus/bytes@1.16.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -1050,7 +1256,7 @@ snapshots: dependencies: undici-types: 7.24.6 - '@vitest/coverage-v8@3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/node@25.9.5)(supports-color@7.2.0))': + '@vitest/coverage-v8@3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/node@25.9.5)(jsdom@30.1.3)(supports-color@7.2.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -1065,7 +1271,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.7(@types/node@25.9.5)(supports-color@7.2.0) + vitest: 3.2.7(@types/node@25.9.5)(jsdom@30.1.3)(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -1133,6 +1339,10 @@ snapshots: balanced-match@4.0.4: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 @@ -1165,12 +1375,26 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + data-urls@8.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 7.2.0 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} eastasianwidth@0.2.0: {} @@ -1179,6 +1403,8 @@ snapshots: emoji-regex@9.2.2: {} + entities@8.1.0: {} + es-module-lexer@1.7.0: {} esbuild@0.28.2: @@ -1239,10 +1465,18 @@ snapshots: has-flag@4.0.0: {} + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} is-fullwidth-code-point@3.0.0: {} + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -1276,10 +1510,37 @@ snapshots: js-tokens@9.0.1: {} + jsdom@30.1.3: + dependencies: + '@asamuzakjp/css-color': 7.1.9 + '@asamuzakjp/dom-selector': 10.0.5 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 8.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.12.3 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + loupe@3.2.1: {} lru-cache@10.4.3: {} + lru-cache@11.5.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1294,6 +1555,8 @@ snapshots: dependencies: semver: 7.8.5 + mdn-data@2.27.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -1310,6 +1573,10 @@ snapshots: package-json-from-dist@1.0.1: {} + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-key@3.1.1: {} path-scurry@1.11.1: @@ -1331,6 +1598,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + punycode@2.3.1: {} + + require-from-string@2.0.2: {} + rollup@4.63.0: dependencies: '@types/estree': 1.0.9 @@ -1363,6 +1634,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.63.0 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} shebang-command@2.0.0: @@ -1430,10 +1705,26 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.4.19: {} + + tldts@7.4.19: + dependencies: + tldts-core: 7.4.19 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.19 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + typescript@5.9.3: {} undici-types@7.24.6: {} + undici@8.12.3: {} + vite-node@3.2.4(@types/node@25.9.5)(supports-color@7.2.0): dependencies: cac: 6.7.14 @@ -1467,7 +1758,7 @@ snapshots: '@types/node': 25.9.5 fsevents: 2.3.3 - vitest@3.2.7(@types/node@25.9.5)(supports-color@7.2.0): + vitest@3.2.7(@types/node@25.9.5)(jsdom@30.1.3)(supports-color@7.2.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -1494,6 +1785,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 + jsdom: 30.1.3 transitivePeerDependencies: - jiti - less @@ -1508,6 +1800,22 @@ snapshots: - tsx - yaml + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.3.0: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -1528,3 +1836,7 @@ snapshots: ansi-styles: 6.2.3 string-width: 5.1.2 strip-ansi: 7.2.0 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/scripts/verify-demo-pages-build.mjs b/scripts/verify-demo-pages-build.mjs new file mode 100644 index 0000000..64aa207 --- /dev/null +++ b/scripts/verify-demo-pages-build.mjs @@ -0,0 +1,69 @@ +/** + * Checks the shape of the GitHub Pages artifact in `dist-demo/` (run after + * `GITHUB_PAGES=true pnpm run build:demo`, i.e. `pnpm run verify:demo`). + * + * - both pages exist (the portal and the demo) + * - neither page is a redirect stub + * - bundled modules resolve under `/Daedalus/assets/` (the Pages base), never origin-rooted `/assets/` + * - no cross-origin isolation is injected (no service worker shim, no COOP/COEP headers) + */ +import { readdir, readFile, stat } from "node:fs/promises"; +import { relative } from "node:path"; + +const root = new URL("../dist-demo/", import.meta.url); +const requiredFiles = ["index.html", "demo/worldfile-fit.html"]; + +async function readText(path) { + return readFile(new URL(path, root), "utf8"); +} + +async function assertFile(path) { + const info = await stat(new URL(path, root)); + if (!info.isFile()) { + throw new Error(`${path} is not a file`); + } + return info; +} + +async function listFiles(dirUrl = root) { + const entries = await readdir(dirUrl, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const url = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, dirUrl); + if (entry.isDirectory()) { + files.push(...await listFiles(url)); + } else { + files.push(relative(root.pathname, url.pathname)); + } + } + return files; +} + +for (const file of requiredFiles) { + await assertFile(file); +} + +const portalHtml = await readText("index.html"); +const demoHtml = await readText("demo/worldfile-fit.html"); +if (/http-equiv=["']refresh/i.test(portalHtml) || /http-equiv=["']refresh/i.test(demoHtml)) { + throw new Error("demo pages must not be redirect stubs"); +} +if (!portalHtml.includes("/Daedalus/assets/") || !demoHtml.includes("/Daedalus/assets/")) { + throw new Error("built HTML does not resolve bundled modules under /Daedalus/assets/"); +} + +const textFiles = (await listFiles()).filter((file) => /\.(?:html|js|mjs|css)$/.test(file)); +for (const file of textFiles) { + const text = await readText(file); + if (text.includes("coi-serviceworker")) { + throw new Error(`${file} references coi-serviceworker`); + } + if (text.includes("Cross-Origin-Opener-Policy") || text.includes("Cross-Origin-Embedder-Policy")) { + throw new Error(`${file} injects COOP/COEP headers`); + } + if (/["'`](?:\/assets\/)/.test(text)) { + throw new Error(`${file} contains an origin-rooted /assets/ URL`); + } +} + +console.log("dist-demo Pages artifact verified."); diff --git a/tests/demo-presets.test.ts b/tests/demo-presets.test.ts new file mode 100644 index 0000000..e0e864f --- /dev/null +++ b/tests/demo-presets.test.ts @@ -0,0 +1,108 @@ +/** + * Demo presets: each contrast in `demo/presets.js` must produce visibly different output. + * + * This test calls the real `fitWorldFile` (imported from `src`, not `dist`) with the exact + * GCP sets the demo page uses. It guards the demo's parts only: a green run does NOT prove + * the behaviour is what a human asked for — that is judged by looking at the page. + * + * Assertions are written as properties (which model, which warnings, which point stands out), + * not as baked numbers, so small numeric drift in the fitter does not make the demo "fail". + */ +import { describe, expect, it } from 'vitest'; + +import { fitWorldFile } from '../src/index.js'; +import type { FitResult, GcpPair, WorldFile } from '../src/index.js'; +import { CONTRASTS, FIT_OPTIONS, findPreset } from '../demo/presets.js'; + +function run(contrastKey: string, optionKey: string): FitResult { + const preset = findPreset(contrastKey, optionKey); + if (preset === undefined) throw new Error(`preset ${contrastKey}:${optionKey} is missing`); + return fitWorldFile(preset.gcps as readonly GcpPair[], FIT_OPTIONS as never); +} + +function codes(result: FitResult): string[] { + return result.warnings.map((warning) => warning.code); +} + +function residuals(worldFile: WorldFile, gcps: readonly GcpPair[]): number[] { + return gcps.map(({ pixel: [u, v], geo: [x, y] }) => { + const px = worldFile.xScale * u + worldFile.xSkew * v + worldFile.xOrigin; + const py = worldFile.ySkew * u + worldFile.yScale * v + worldFile.yOrigin; + return Math.hypot(x - px, y - py); + }); +} + +describe('demo presets: shape', () => { + it('has four contrasts, each with at least two options carrying a label, an expectation line and GCPs', () => { + expect(CONTRASTS.map((contrast) => contrast.key)).toEqual(['model', 'count', 'layout', 'outlier']); + for (const contrast of CONTRASTS) { + expect(contrast.title.length).toBeGreaterThan(0); + expect(contrast.options.length).toBeGreaterThanOrEqual(2); + for (const option of contrast.options) { + expect(option.label.length).toBeGreaterThan(0); + expect(option.expectation.length).toBeGreaterThan(0); + expect(option.gcps.length).toBeGreaterThanOrEqual(2); + for (const gcp of option.gcps) { + expect(gcp.pixel).toHaveLength(2); + expect(gcp.geo).toHaveLength(2); + expect(gcp.pixel.every(Number.isFinite) && gcp.geo.every(Number.isFinite)).toBe(true); + } + } + } + }); + + it('uses planar metre coordinates with debug output', () => { + expect(FIT_OPTIONS).toEqual({ geoBasis: { kind: 'planar_xy', axisOrder: 'xy', unit: 'm' }, debug: true }); + }); +}); + +describe('demo presets: outputs split as each contrast promises', () => { + it('model: rotation/scale only → similarity; sheared → affine with F above the critical value', () => { + const a = run('model', 'similar'); + const b = run('model', 'sheared'); + expect(a.similarityOnly).toBe(true); + expect(codes(a)).toContain('affineRejectedNonImproving'); + expect(b.similarityOnly).toBe(false); + expect(b.debug?.fStatistic).toBeDefined(); + expect(b.debug!.fStatistic!).toBeGreaterThan(b.debug!.criticalF!); + // the similarity shape (xSkew = -ySkew) holds for A and breaks for B + expect(a.worldFile!.xSkew).toBeCloseTo(-a.worldFile!.ySkew, 9); + expect(Math.abs(b.worldFile!.xSkew + b.worldFile!.ySkew)).toBeGreaterThan(0.01); + }); + + it('count: 2 points → preview; 3 points → adoptable without affine; 5 points → affine evaluated', () => { + const two = run('count', 'two'); + const three = run('count', 'three'); + const five = run('count', 'five'); + expect(two.qualityGate).toBe('preview'); + expect(three.qualityGate).toBe('adoptable'); + expect(codes(three)).toContain('affineSkippedInsufficientDof'); + expect(three.debug?.fStatistic).toBeUndefined(); + expect(five.debug?.fStatistic).toBeDefined(); + expect(five.debug?.criticalF).toBeDefined(); + }); + + it('layout: only the near-collinear set warns nearCollinearSource and skips affine', () => { + const spread = run('layout', 'spread'); + const collinear = run('layout', 'collinear'); + expect(codes(spread)).not.toContain('nearCollinearSource'); + expect(codes(collinear)).toContain('nearCollinearSource'); + expect(codes(collinear)).toContain('affineSkippedCollinear'); + expect(collinear.debug?.fStatistic).toBeUndefined(); + }); + + it('outlier: one wrong point raises RMS by 10x or more and has the largest residual (point 3)', () => { + const correct = run('outlier', 'correct'); + const wrong = run('outlier', 'oneWrong'); + expect(correct.rmsMeters).not.toBeNull(); + expect(wrong.rmsMeters).not.toBeNull(); + expect(wrong.rmsMeters!).toBeGreaterThanOrEqual(correct.rmsMeters! * 10); + // if the wrong point promotes the fit to affine, the F gate must explain it + if (!wrong.similarityOnly) { + expect(wrong.debug!.fStatistic!).toBeGreaterThan(wrong.debug!.criticalF!); + } + const preset = findPreset('outlier', 'oneWrong')!; + const r = residuals(wrong.worldFile!, preset.gcps as readonly GcpPair[]); + expect(r.indexOf(Math.max(...r))).toBe(2); + }); +}); diff --git a/tests/demo-wiring.test.ts b/tests/demo-wiring.test.ts new file mode 100644 index 0000000..0bd22f8 --- /dev/null +++ b/tests/demo-wiring.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment jsdom +/** + * Demo page wiring (jsdom, injected fake `fit`). + * + * The DOM is built by reading `demo/worldfile-fit.html` itself, so renamed ids in the page + * break this test. `canvas.getContext` is NOT stubbed: jsdom has no 2D canvas and returns + * `null`, so every assertion here also proves the page keeps working without a context. + * Only `getBoundingClientRect` is given a size, because jsdom has no layout. + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { arrowMagnification, formatFStatistic, formatNumber, initDemo } from '../demo/main.js'; +import { CONTRASTS, FIT_OPTIONS } from '../demo/presets.js'; +import { version as packageVersion } from '../package.json'; + +const testsDir = dirname(fileURLToPath(import.meta.url)); +const pageHtml = readFileSync(join(testsDir, '..', 'demo', 'worldfile-fit.html'), 'utf8'); +const bodyInner = /]*>([\s\S]*)<\/body>/i.exec(pageHtml)?.[1]; + +const BOARD = { width: 420, height: 320 }; + +function mountPage(): void { + if (bodyInner === undefined) throw new Error('cannot read of worldfile-fit.html'); + document.body.innerHTML = bodyInner.replace(//gi, ''); + for (const id of ['pixel-canvas', 'geo-canvas']) { + const canvas = document.getElementById(id) as HTMLCanvasElement; + canvas.getBoundingClientRect = () => + ({ left: 0, top: 0, right: BOARD.width, bottom: BOARD.height, ...BOARD, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect; + } +} + +function fakeResult(overrides: Record = {}) { + return { + status: 'ok_with_warnings', + qualityGate: 'adoptable', + worldFile: { xScale: 2.49, xSkew: -0.17, xOrigin: -22000, ySkew: 0.17, yScale: 2.49, yOrigin: -112000 }, + similarityOnly: true, + rmsMeters: 0.42, + warnings: [{ code: 'affineRejectedNonImproving' }, { code: 'validationSkipped' }], + debug: { sse: 1, mse: 0.2, sseSimilarity: 1, sseAffine: 0.9, fStatistic: 0.15, criticalF: 6.94 }, + ...overrides, + }; +} + +const allPresets = CONTRASTS.flatMap((contrast) => + contrast.options.map((option) => ({ id: `${contrast.key}:${option.key}`, option })), +); + +function text(id: string): string { + return document.getElementById(id)?.textContent ?? ''; +} + +describe('demo wiring', () => { + beforeEach(() => { + mountPage(); + }); + + it('renders one button per preset and runs the first preset once on start', async () => { + const fit = vi.fn(() => fakeResult()); + const demo = initDemo({ document, fit }); + await demo.whenSettled(); + const buttons = [...document.querySelectorAll('[data-preset]')]; + expect(buttons.map((button) => button.dataset.preset)).toEqual(allPresets.map((preset) => preset.id)); + expect(fit).toHaveBeenCalledTimes(1); + expect(demo.getState().activePreset).toBe(allPresets[0].id); + expect(demo.getState().hasContext).toEqual({ pixel: false, geo: false }); + }); + + for (const { id, option } of allPresets) { + it(`button ${id}: calls fit exactly once with the preset GCPs and shows the outputs`, async () => { + const fit = vi.fn(() => fakeResult()); + const demo = initDemo({ document, fit }); + await demo.whenSettled(); + fit.mockClear(); + + document.querySelector(`[data-preset="${id}"]`)!.click(); + + expect(fit).toHaveBeenCalledTimes(1); + const [gcps, options] = fit.mock.calls[0] as unknown as [unknown, unknown]; + expect(gcps).toEqual(option.gcps); + expect(options).toEqual(FIT_OPTIONS); + expect(demo.getState().activePreset).toBe(id); + expect(document.querySelector(`[data-preset="${id}"]`)!.getAttribute('aria-pressed')).toBe('true'); + expect(text('preset-expectation')).toBe(option.expectation); + + expect(text('model-label')).toContain('相似'); + expect(document.querySelectorAll('#worldfile-body tr')).toHaveLength(6); + expect(text('quality-gate')).toBe('adoptable'); + expect(text('fit-status')).toBe('ok_with_warnings'); + expect(text('rms')).toContain('0.42'); + const warnings = [...document.querySelectorAll('#warning-list li')].map((li) => li.textContent ?? ''); + expect(warnings).toHaveLength(2); + expect(warnings[0]).toContain('affineRejectedNonImproving'); + expect(document.querySelectorAll('#residual-body tr')).toHaveLength(option.gcps.length); + }); + } + + it('shows the affine label, the F bars and the SSE bars when affine is adopted', async () => { + const fit = vi.fn(() => + fakeResult({ + similarityOnly: false, + warnings: [{ code: 'validationSkipped' }], + debug: { sse: 1, mse: 0.2, sseSimilarity: 800, sseAffine: 1, fStatistic: 99675, criticalF: 6.94 }, + }), + ); + const demo = initDemo({ document, fit }); + await demo.whenSettled(); + expect(text('model-label')).toContain('affine'); + expect(text('similar-shape')).toBe(''); + expect(text('f-text')).toContain('99'); + expect(document.querySelectorAll('#f-bars .bar')).toHaveLength(2); + expect(document.querySelectorAll('#sse-bars .bar')).toHaveLength(2); + }); + + it('says F was not evaluated when debug has no fStatistic', async () => { + const fit = vi.fn(() => + fakeResult({ warnings: [{ code: 'affineSkippedInsufficientDof' }], debug: { sse: 1, mse: 1 } }), + ); + const demo = initDemo({ document, fit }); + await demo.whenSettled(); + expect(text('f-text')).toContain('F は評価していない'); + expect(document.querySelectorAll('#f-bars .bar')).toHaveLength(0); + }); + + it('draws no F bar for an infinite F and explains it', async () => { + const fit = vi.fn(() => + fakeResult({ similarityOnly: false, debug: { sse: 0, mse: 0, sseSimilarity: 5, sseAffine: 0, fStatistic: Infinity, criticalF: 6.94 } }), + ); + const demo = initDemo({ document, fit }); + await demo.whenSettled(); + expect(text('f-text')).toContain('F = ∞'); + expect(document.querySelectorAll('#f-bars .bar')).toHaveLength(1); + }); + + it('dragging a pixel marker recomputes once on release with the moved point', async () => { + const fit = vi.fn(() => fakeResult()); + const demo = initDemo({ document, fit }); + await demo.whenSettled(); + fit.mockClear(); + const before = demo.getState().gcps[0].pixel; + const [bx, by] = demo.getState().pixelMarkers[0]; + const canvas = document.getElementById('pixel-canvas')!; + canvas.dispatchEvent(new MouseEvent('pointerdown', { clientX: bx, clientY: by, bubbles: true })); + canvas.dispatchEvent(new MouseEvent('pointermove', { clientX: bx + 20, clientY: by + 10, bubbles: true })); + expect(fit).not.toHaveBeenCalled(); + canvas.dispatchEvent(new MouseEvent('pointerup', { clientX: bx + 20, clientY: by + 10, bubbles: true })); + expect(fit).toHaveBeenCalledTimes(1); + const after = demo.getState().gcps[0].pixel; + expect(after[0]).toBeGreaterThan(before[0]); + expect(after[1]).toBeLessThan(before[1]); // v axis points up on the board + }); + + it('shows the package version in the footer', async () => { + const demo = initDemo({ document, fit: vi.fn(() => fakeResult()) }); + await demo.whenSettled(); + expect(text('version')).toContain(`@c4h/daedalus ${packageVersion}`); + }); + + it('shows the error and does not throw when fit throws', async () => { + const demo = initDemo({ + document, + fit: vi.fn(() => { + throw new Error('boom'); + }), + }); + await demo.whenSettled(); + expect(text('error')).toContain('boom'); + }); +}); + +describe('formatFStatistic', () => { + it('formats infinity, missing and finite values', () => { + expect(formatFStatistic(Infinity)).toContain('F = ∞'); + expect(formatFStatistic(Infinity)).toContain('affine で完全に当たる'); + expect(formatFStatistic(undefined)).toBe('F は評価していない'); + expect(formatFStatistic(0.147)).toContain('0.147'); + }); +}); + +describe('formatNumber', () => { + it('keeps trailing zeros of integers and trims only decimal zeros', () => { + expect(formatNumber(120, 0)).toBe('120'); + expect(formatNumber(-111780, 0)).toBe('-111780'); + expect(formatNumber(2.5, 4)).toBe('2.5'); + expect(formatNumber(0, 3)).toBe('0'); + expect(formatNumber(Infinity)).toBe('∞'); + expect(formatNumber(undefined)).toBe('—'); + }); +}); + +describe('arrowMagnification', () => { + it('scales the longest residual to about 40 board px', () => { + // 0.5 m at 0.1 px/m is 0.05 px → x800 + expect(arrowMagnification(0.5, 0.1)).toBe(800); + }); + it('never shrinks arrows that are already long', () => { + expect(arrowMagnification(100, 1)).toBe(1); + }); + it('returns 0 (draw no arrows) when residuals are only floating-point noise', () => { + expect(arrowMagnification(3.6e-12, 0.1)).toBe(0); + expect(arrowMagnification(0, 0.1)).toBe(0); + }); +}); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..788db12 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,27 @@ +/** + * Vite config for the demo only (the package itself is built with `tsc`). + * + * - `pnpm run dev` and the GitHub Pages build serve the same pages: the portal `index.html` + * and `demo/worldfile-fit.html`. The only difference is `base` (`/Daedalus/` on Pages). + * - The demo imports `src` directly, so no package build is needed before `dev`. + * - No `test` section here: `vitest.config.ts` owns the test settings. + */ +import { resolve } from "node:path"; +import process from "node:process"; +import { defineConfig } from "vite"; + +const isPagesBuild = process.env.GITHUB_PAGES === "true"; + +export default defineConfig({ + base: isPagesBuild ? "/Daedalus/" : "/", + build: { + outDir: "dist-demo", + copyPublicDir: false, + rollupOptions: { + input: { + index: resolve(__dirname, "index.html"), + worldfileFit: resolve(__dirname, "demo/worldfile-fit.html"), + }, + }, + }, +}); From 8240a319ddf3acfd17fe5593d760c3e44522dd90 Mon Sep 17 00:00:00 2001 From: Kohei Otsuka Date: Tue, 27 Oct 2026 17:01:21 +0900 Subject: [PATCH 2/5] ci: verify the demo build and deploy it to GitHub Pages test.yml now also runs verify:demo, and its header note describes the demo and deploy.yml instead of saying there is none. The new deploy.yml builds the same demo on pushes to master and publishes dist-demo; actions are pinned to commit SHAs, the workflow defaults to contents: read and only the deploy job gets pages: write and id-token: write. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 58 ++++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 9 ++++-- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..d32affa --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,58 @@ +name: Deploy to GitHub Pages + +# Publishes the same demo that `pnpm run dev` opens (index.html and demo/worldfile-fit.html). +on: + push: + branches: + - master + workflow_dispatch: + +# Least privilege by default; only the deploy job gets the Pages token scopes. +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build demo site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Install pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 11.27.0 + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + # The demo imports src directly, so no package build is needed first. + - name: Build and verify demo site + run: pnpm run verify:demo + - name: Setup Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: ./dist-demo + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c428a4b..33e3820 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,9 +5,10 @@ on: push: pull_request: -# Daedalus は依存を 1 件も持たない(dependencies キー自体が無い)∴ 単体 checkout で完結する。 -# 兄弟 checkout(m3-t1 設計 §6.4)は不要である。デモを持たないため deploy.yml も置かない -# (HR-m3-007 の「Pages にデプロイする設定〔あれば〕」の条件を満たさない。設計 K14)。 +# Daedalus は依存を 1 件も持たない(dependencies キー自体が無い)∴ 単体 checkout で完結し、 +# 兄弟リポジトリの checkout は不要である。デモ(index.html と demo/)は vite で作り、 +# GitHub Pages への配備は deploy.yml が master への push で行う。ここではデモの build と +# Pages 用の成果物の形だけを検査する(verify:demo)。 jobs: test: name: Test @@ -33,3 +34,5 @@ jobs: run: pnpm run test:types - name: Build package run: pnpm run build + - name: Build and verify demo site + run: pnpm run verify:demo From fa8056d89f1c2e162a6e9ab5b67be60615691155 Mon Sep 17 00:00:00 2001 From: Kohei Otsuka Date: Tue, 27 Oct 2026 17:01:21 +0900 Subject: [PATCH 3/5] docs(readme): describe the demo and what each preset shows Co-Authored-By: Claude Opus 5.5 --- README.ja.md | 9 +++++++++ README.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/README.ja.md b/README.ja.md index 0f59849..d4c3b4a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -11,6 +11,15 @@ English README is [here](./README.md). +## デモ + +デモは GitHub Pages で公開しています: (当てはめのページは [`demo/worldfile-fit.html`](https://code4history.dev/Daedalus/demo/worldfile-fit.html))。`pnpm run dev` で同じページが手元で開きます。プリセットのボタンで、明確に違う結果になる GCP の組を切り替えられます。 + +- **モデル**: 回転と縮尺だけの地図は相似で当たり、同じ点に横方向の shear を加えると affine に切り替わります(F は臨界値を大きく超えます)。 +- **点の数**: GCP 2 点は `preview`(仮の答え)、3 点は `adoptable` でも affine を評価せず、5 点で affine を評価して F の棒が出ます。 +- **点の並び**: 一直線に近い組だけが `nearCollinearSource` を出し、affine を評価しません。 +- **誤った点**: 1 点を約 75 m ずらしても品質の札は `adoptable` のままで、その点の長い残差の矢印と RMS でだけ見えます。 + ## 公開 API ```ts diff --git a/README.md b/README.md index 0e0684c..fd6d3fb 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ 日本語のREADMEは[こちら](./README.ja.md)。 +## Demo + +The demo is published on GitHub Pages: (the fitting page is [`demo/worldfile-fit.html`](https://code4history.dev/Daedalus/demo/worldfile-fit.html)). `pnpm run dev` opens the same pages locally. Preset buttons load GCP sets that produce clearly different results: + +- **Model**: a map with rotation and scale only is fitted as a similarity; the same points with a horizontal shear switch to affine, with F far above its critical value. +- **Point count**: 2 GCPs give only a `preview` answer; 3 GCPs are `adoptable` but affine is not evaluated; with 5 GCPs affine is evaluated and the F bars appear. +- **Layout**: only the nearly collinear set warns `nearCollinearSource` and skips affine. +- **A wrong point**: moving one GCP by about 75 m keeps the quality gate at `adoptable`; only its long residual arrow and the RMS reveal it. + ## Public API ```ts From 2d80ad1f75e42185b895c48e36b36fd6340e3634 Mon Sep 17 00:00:00 2001 From: Kohei Otsuka Date: Tue, 27 Oct 2026 18:27:13 +0900 Subject: [PATCH 4/5] ci: allow esbuild builds for the pnpm 11 install and try the deploy install on every PR pnpm 11 makes strictDepBuilds the default, so a clean pnpm install --frozen-lockfile with lifecycle scripts enabled stops with ERR_PNPM_IGNORED_BUILDS for esbuild. Keep the allowBuilds list in ci/pnpm-workspace.ci.yaml and copy it to pnpm-workspace.yaml right before the install in deploy.yml. A new CI job repeats the same install so the deploy path is exercised before it reaches master. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 4 ++++ .github/workflows/test.yml | 25 +++++++++++++++++++++++++ ci/pnpm-workspace.ci.yaml | 17 +++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 ci/pnpm-workspace.ci.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d32affa..7ce322d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,10 @@ jobs: with: node-version: 24 cache: pnpm + # pnpm 11 reads allowBuilds only from pnpm-workspace.yaml, which is kept out of the + # repository root for local development (see the comment in ci/pnpm-workspace.ci.yaml). + - name: Use CI pnpm settings + run: cp ci/pnpm-workspace.ci.yaml pnpm-workspace.yaml - name: Install dependencies run: pnpm install --frozen-lockfile # The demo imports src directly, so no package build is needed first. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33e3820..770bc65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,3 +36,28 @@ jobs: run: pnpm run build - name: Build and verify demo site run: pnpm run verify:demo + + # deploy.yml runs only on pushes to master, so its install path would otherwise be + # tried for the first time after merging. This job repeats the same steps (pnpm + # 11.27.0, Node.js 24, the CI pnpm settings and --frozen-lockfile) on every push + # and pull request, so a failing install shows up before the merge. + deploy-build: + name: Deploy build (same install as deploy.yml) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Install pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 11.27.0 + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + - name: Use CI pnpm settings + run: cp ci/pnpm-workspace.ci.yaml pnpm-workspace.yaml + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build and verify demo site + run: pnpm run verify:demo diff --git a/ci/pnpm-workspace.ci.yaml b/ci/pnpm-workspace.ci.yaml new file mode 100644 index 0000000..aaed3bd --- /dev/null +++ b/ci/pnpm-workspace.ci.yaml @@ -0,0 +1,17 @@ +# CI-only pnpm settings. The workflows copy this file to pnpm-workspace.yaml right +# before `pnpm install --frozen-lockfile`. +# +# Why it is not committed as pnpm-workspace.yaml: +# pnpm 11 reads allowBuilds only from pnpm-workspace.yaml (not from .npmrc or the +# package.json "pnpm" field). A pnpm-workspace.yaml at the repository root would +# also make this directory a workspace root during local development, where the +# repository is used inside a larger workspace. CI clones the repository on its +# own, so the copy is harmless there. +# +# Where the list comes from: running `pnpm install --frozen-lockfile` with pnpm +# 11.27.0, CI=true, lifecycle scripts enabled and no node_modules fails with +# ERR_PNPM_IGNORED_BUILDS for the packages below (pnpm 11 sets strictDepBuilds +# to true by default). Re-measure the same way when dependencies change. + +allowBuilds: + esbuild: true From fea3f1117051474b3ca7dc6ec647e99f6d4cf1f6 Mon Sep 17 00:00:00 2001 From: Kohei Otsuka Date: Tue, 27 Oct 2026 18:48:41 +0900 Subject: [PATCH 5/5] fix(demo): release the preset after a drag and explain what the quality gate measures Moving a point by hand changes the input, so the pressed preset button and its expectation line no longer describe it. Release the button and replace the line once a point actually moves; a click without moving keeps the preset. Say next to the quality gate that adoptable is decided by the number of points and does not look at the size of the errors, and bind the wrong point's gate to the correct one's in the preset tests, since the expectation line and both READMEs rely on it. Co-Authored-By: Claude Opus 5.5 --- README.ja.md | 3 ++- README.md | 3 ++- demo/main.js | 33 +++++++++++++++++++++++++++------ tests/demo-presets.test.ts | 4 ++++ tests/demo-wiring.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/README.ja.md b/README.ja.md index d4c3b4a..222161b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -18,7 +18,8 @@ English README is [here](./README.md). - **モデル**: 回転と縮尺だけの地図は相似で当たり、同じ点に横方向の shear を加えると affine に切り替わります(F は臨界値を大きく超えます)。 - **点の数**: GCP 2 点は `preview`(仮の答え)、3 点は `adoptable` でも affine を評価せず、5 点で affine を評価して F の棒が出ます。 - **点の並び**: 一直線に近い組だけが `nearCollinearSource` を出し、affine を評価しません。 -- **誤った点**: 1 点を約 75 m ずらしても品質の札は `adoptable` のままで、その点の長い残差の矢印と RMS でだけ見えます。 +- **誤った点**: 1 点を約 75 m ずらしても品質の札は `adoptable` のままで、その点の長い残差の矢印と RMS でだけ見えます。品質の札は点の数で決まる判定で、誤差の大きさは見ません(デモでも札の横にそう書いています)。 +- **点のドラッグ**: 点を手で動かすと、プリセットのボタンの押下が外れ、見え方の 1 行は「プリセットではない入力」の説明に替わります。 ## 公開 API diff --git a/README.md b/README.md index fd6d3fb..5d349c1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ The demo is published on GitHub Pages: (the - **Model**: a map with rotation and scale only is fitted as a similarity; the same points with a horizontal shear switch to affine, with F far above its critical value. - **Point count**: 2 GCPs give only a `preview` answer; 3 GCPs are `adoptable` but affine is not evaluated; with 5 GCPs affine is evaluated and the F bars appear. - **Layout**: only the nearly collinear set warns `nearCollinearSource` and skips affine. -- **A wrong point**: moving one GCP by about 75 m keeps the quality gate at `adoptable`; only its long residual arrow and the RMS reveal it. +- **A wrong point**: moving one GCP by about 75 m keeps the quality gate at `adoptable`; only its long residual arrow and the RMS reveal it. The quality gate is decided by the number of points and does not look at the size of the errors, so the demo says so next to it. +- **Dragging a point**: once you move a point by hand, the preset button is released and the expectation line says the input is no longer a preset. ## Public API diff --git a/demo/main.js b/demo/main.js index d78593e..06fbb41 100644 --- a/demo/main.js +++ b/demo/main.js @@ -53,8 +53,11 @@ export const WARNING_TEXT = Object.freeze({ unsupportedAxisOrder: 'この軸の順序は扱えない', }); +const MANUAL_EXPECTATION = + '点を手で動かした入力(プリセットではない)。動かした点の影響は残差の矢印と RMS で見る。プリセットのボタンで元に戻せる。'; + const QUALITY_TEXT = { - adoptable: '採用できる', + adoptable: '採用できる(点の数で決まる判定で、誤差の大きさは見ない。誤差は RMS と残差の矢印で見る)', preview: '仮の答え', unusable: '使えない', }; @@ -261,6 +264,7 @@ export function initDemo(config = {}) { result: null, recalcCount: 0, dragIndex: -1, + dragStartPixel: null, }; const setText = (node, text) => { @@ -298,20 +302,32 @@ export function initDemo(config = {}) { } } - function applyPreset(contrast, option) { - const id = `${contrast.key}:${option.key}`; - state.activePreset = id; - state.gcps = copyGcps(option.gcps); - state.pixelMapper = makeMapper(state.gcps.map((pair) => pair.pixel)); + function markPressed(id) { for (const button of presetButtons) { const pressed = button.dataset.preset === id; button.setAttribute('aria-pressed', String(pressed)); button.classList.toggle('active', pressed); } + } + + function applyPreset(contrast, option) { + const id = `${contrast.key}:${option.key}`; + state.activePreset = id; + state.gcps = copyGcps(option.gcps); + state.pixelMapper = makeMapper(state.gcps.map((pair) => pair.pixel)); + markPressed(id); setText(el.expectation, option.expectation); recalculate(); } + // Once a point is moved by hand the input is no longer the preset, so its pressed + // button and its expectation line would describe something else. + function leavePreset() { + state.activePreset = null; + markPressed(null); + setText(el.expectation, MANUAL_EXPECTATION); + } + // ---- outputs ------------------------------------------------------------- function renderWorldFile(result) { @@ -500,6 +516,7 @@ export function initDemo(config = {}) { }); if (best < 0) return; state.dragIndex = best; + state.dragStartPixel = [...state.gcps[best].pixel]; el.pixelCanvas.setPointerCapture?.(event.pointerId); drawPixelBoard(); } @@ -514,7 +531,11 @@ export function initDemo(config = {}) { function onPointerUp(event) { if (state.dragIndex < 0) return; onPointerMove(event); + const moved = state.gcps[state.dragIndex].pixel; + const start = state.dragStartPixel; state.dragIndex = -1; + state.dragStartPixel = null; + if (!start || moved[0] !== start[0] || moved[1] !== start[1]) leavePreset(); recalculate(); } diff --git a/tests/demo-presets.test.ts b/tests/demo-presets.test.ts index e0e864f..657c9d1 100644 --- a/tests/demo-presets.test.ts +++ b/tests/demo-presets.test.ts @@ -104,5 +104,9 @@ describe('demo presets: outputs split as each contrast promises', () => { const preset = findPreset('outlier', 'oneWrong')!; const r = residuals(wrong.worldFile!, preset.gcps as readonly GcpPair[]); expect(r.indexOf(Math.max(...r))).toBe(2); + // The expectation line and both READMEs say the quality gate stays `adoptable` for the + // wrong point: the gate is decided by the number of points, not by the size of errors. + expect(wrong.qualityGate).toBe('adoptable'); + expect(wrong.qualityGate).toBe(correct.qualityGate); }); }); diff --git a/tests/demo-wiring.test.ts b/tests/demo-wiring.test.ts index 0bd22f8..d59bbb6 100644 --- a/tests/demo-wiring.test.ts +++ b/tests/demo-wiring.test.ts @@ -154,6 +154,43 @@ describe('demo wiring', () => { expect(after[1]).toBeLessThan(before[1]); // v axis points up on the board }); + it('dragging a point clears the pressed preset and replaces its expectation line', async () => { + const demo = initDemo({ document, fit: vi.fn(() => fakeResult()) }); + await demo.whenSettled(); + const presetLine = text('preset-expectation'); + expect(demo.getState().activePreset).not.toBeNull(); + expect(document.querySelectorAll('#preset-bar button[aria-pressed="true"]')).toHaveLength(1); + const [bx, by] = demo.getState().pixelMarkers[0]; + const canvas = document.getElementById('pixel-canvas')!; + canvas.dispatchEvent(new MouseEvent('pointerdown', { clientX: bx, clientY: by, bubbles: true })); + canvas.dispatchEvent(new MouseEvent('pointerup', { clientX: bx + 20, clientY: by + 10, bubbles: true })); + expect(demo.getState().activePreset).toBeNull(); + expect(document.querySelectorAll('#preset-bar button[aria-pressed="true"]')).toHaveLength(0); + expect(document.querySelectorAll('#preset-bar button.active')).toHaveLength(0); + expect(text('preset-expectation')).not.toBe(presetLine); + expect(text('preset-expectation')).toContain('手で動かした'); + }); + + it('a click on a point without moving it keeps the pressed preset', async () => { + const demo = initDemo({ document, fit: vi.fn(() => fakeResult()) }); + await demo.whenSettled(); + const presetId = demo.getState().activePreset; + const presetLine = text('preset-expectation'); + const [bx, by] = demo.getState().pixelMarkers[0]; + const canvas = document.getElementById('pixel-canvas')!; + canvas.dispatchEvent(new MouseEvent('pointerdown', { clientX: bx, clientY: by, bubbles: true })); + canvas.dispatchEvent(new MouseEvent('pointerup', { clientX: bx, clientY: by, bubbles: true })); + expect(demo.getState().activePreset).toBe(presetId); + expect(text('preset-expectation')).toBe(presetLine); + }); + + it('explains next to the quality gate that adoptable does not look at the size of errors', async () => { + const demo = initDemo({ document, fit: vi.fn(() => fakeResult({ qualityGate: 'adoptable', rmsMeters: 30 })) }); + await demo.whenSettled(); + expect(text('quality-text')).toContain('点の数で決まる'); + expect(text('quality-text')).toContain('誤差の大きさは見ない'); + }); + it('shows the package version in the footer', async () => { const demo = initDemo({ document, fit: vi.fn(() => fakeResult()) }); await demo.whenSettled();