From a5fe852429c8b35558df8771b69f45587a2d1a06 Mon Sep 17 00:00:00 2001 From: Toby <25832191+aetherbird@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:40:46 -0400 Subject: [PATCH] fe-map: data and tolerance changes re-score without moving cities; caption for the maximum-only case Co-Authored-By: Claude Opus 5.5 (1M context) --- femap/site/index.html | 31 +++++++++++++++++++++---------- femap/template.html | 31 +++++++++++++++++++++---------- tests/femap.mjs | 8 ++++++-- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/femap/site/index.html b/femap/site/index.html index 2ee8ca4..b5d3d53 100644 --- a/femap/site/index.html +++ b/femap/site/index.html @@ -203,7 +203,6 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
Map distance ÷ timed length, every flight

Farthest out

-

Globe: the same rules applied to great-circle distances on a sphere of radius 6,371 km.

@@ -472,7 +471,16 @@ function restart() { $("#run").textContent = "Solve"; update(true); } -function continueSolve() { // data or tolerance changed: keep the current layout and solve again +function dataChanged() { // data or tolerance changed: re-score the current layout; only Solve moves the cities + if (!sv) return restart(); + buildActive(); + const asDrawn = sv.total === 0 && !sv.done && !sv.changed, watch = sv.watch, X = Float64Array.from(sv.X); + sv = makeSolver(2, X); + if (running) sv.watch = watch; // mid-solve: carry on with the new set + else { sv.watch = asDrawn; sv.changed = !asDrawn; $("#run").textContent = "Solve"; } + update(true); +} +function continueSolve() { // Solve pressed on a finished layout: solve again from it if (!sv) return restart(); buildActive(); const X = Float64Array.from(sv.X); sv = makeSolver(2, X); sv.watch = false; running = true; $("#run").textContent = "Pause"; update(true); } @@ -499,7 +507,6 @@ function globeScore() { let SC = null, GS = null; function update(full) { SC = score(sv.X, 2); if (full) GS = globeScore(); - $("#flnote").hidden = S.fl; const nAct = { rt: act.l.filter(l => l.kind === "rt").length, rr: act.l.filter(l => l.kind === "rail" || l.kind === "road").length, sat: act.l.filter(l => l.kind === "sat").length }; const row = (label, a, b, n, cls) => `${label}${fmt(a)} / ${fmt(n)}${fmt(b)}`; const nf = S.fl ? FL.length : 0; @@ -699,13 +706,13 @@ $("#run").addEventListener("click", () => { running = !running; $("#run").textContent = running ? "Pause" : "Solve"; }); $("#reset").addEventListener("click", () => restart()); -for (const [id, key] of [["#d-fl", "fl"], ["#d-rt", "rt"], ["#d-rr", "rr"], ["#d-sat", "sat"]]) $(id).addEventListener("change", e => { S[key] = e.target.checked; continueSolve(); }); +for (const [id, key] of [["#d-fl", "fl"], ["#d-rt", "rt"], ["#d-rr", "rr"], ["#d-sat", "sat"]]) $(id).addEventListener("change", e => { S[key] = e.target.checked; dataChanged(); }); $("#lo").addEventListener("input", e => { $("#lov").textContent = e.target.value + "%"; }); $("#hi").addEventListener("input", e => { $("#hiv").textContent = e.target.value + "%"; }); -$("#lo").addEventListener("change", e => { S.lo = 1 - e.target.value / 100; continueSolve(); }); -$("#hi").addEventListener("change", e => { S.hi = e.target.value / 100 - 1; continueSolve(); }); +$("#lo").addEventListener("change", e => { S.lo = 1 - e.target.value / 100; dataChanged(); }); +$("#hi").addEventListener("change", e => { S.hi = e.target.value / 100 - 1; dataChanged(); }); for (const b of document.querySelectorAll("#light button")) b.addEventListener("click", () => { - S.vac = b.dataset.v === "vacuum"; for (const x of document.querySelectorAll("#light button")) x.setAttribute("aria-pressed", String(x === b)); continueSolve(); + S.vac = b.dataset.v === "vacuum"; for (const x of document.querySelectorAll("#light button")) x.setAttribute("aria-pressed", String(x === b)); dataChanged(); }); $("#dl").addEventListener("click", () => { const blob = new Blob([JSON.stringify(D)], { type: "application/json" }); const a = document.createElement("a"); @@ -790,10 +797,14 @@ const pace = sv => sv.total < 100 ? 0.35 : sv.stage === 0 ? (sv.total < 200 ? 1 let frame = 0, capText = ""; function caption(k) { let t; - if (!sv.done && sv.total === 0 && !running) + if (sv.changed && sv.total === 0 && !running) + t = `Measurements changed: ${fmt(k)} broken on this layout. It was solved for the previous set. Press Solve to fit it to these.`; + else if (!sv.done && sv.total === 0 && !running) t = `${START_LABEL[S.start]}, as drawn: ${fmt(k)} measurements broken. Red: a flight longer than its aircraft can fly, or (dashed) a ping faster than light. Amber: a flight far shorter than it takes to fly. Press Solve to let the cities move.`; else if (!sv.done) t = `${running ? "Moving" : "Paused moving"} the cities to fit the measurements. Grey lines: each city's move from the starting map.`; + else if (!S.fl) + t = `Done: ${fmt(k)} measurements broken. Without flight times, every measurement left is a maximum distance (no farther apart than this), and pulling cities closer always meets a maximum. These measurements can show that a map as drawn is wrong, but no flat map can fail them.`; else if (sel >= 0) { let n = 0, kept = 0, long = 0, short = 0; for (const f of act.f) if (f.a === sel || f.b === sel) { n++; const r = SC.ratio[f.i]; if (r > 1 + S.hi) long++; else if (r < 1 - S.lo) short++; else kept++; } @@ -805,12 +816,12 @@ function loop() { frame++; if (sv && running && !sv.done) { if (sv.watch) { sv.acc += pace(sv); const n = Math.floor(sv.acc); sv.acc -= n; if (n) work(sv, 9, n); } else work(sv, 9, 400); - if (sv.done) { running = false; $("#run").textContent = "Solve"; if (sv.watch && sel < 0) sel = SANTIAGO; } + if (sv.done) { running = false; $("#run").textContent = "Solve"; if (sv.watch && sel < 0 && S.fl) sel = SANTIAGO; } } if (sv && (running || frame % 6 === 0)) update(frame % 30 === 0); if (sv) { const k = SC.long.length + SC.short.length + SC.rt.length + SC.rr.length + SC.sat.length; - const head = sv.done ? (sv.isBest ? `Best of ${D.stats.n_starts}` : "Done") : sv.total === 0 && !running ? `${START_LABEL[S.start]}, as drawn` : (running ? "Solving, " : "Paused, ") + STAGE(sv.stage); + const head = sv.done ? (sv.isBest ? `Best of ${D.stats.n_starts}` : "Done") : sv.total === 0 && !running ? (sv.changed ? "Measurements changed" : `${START_LABEL[S.start]}, as drawn`) : (running ? "Solving, " : "Paused, ") + STAGE(sv.stage); $("#status").innerHTML = `${head} · broken ${fmt(k)}`; caption(k); } diff --git a/femap/template.html b/femap/template.html index 9e32bac..e153afd 100644 --- a/femap/template.html +++ b/femap/template.html @@ -203,7 +203,6 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
Map distance ÷ timed length, every flight

Farthest out

-

Globe: the same rules applied to great-circle distances on a sphere of radius 6,371 km.

@@ -472,7 +471,16 @@ function restart() { $("#run").textContent = "Solve"; update(true); } -function continueSolve() { // data or tolerance changed: keep the current layout and solve again +function dataChanged() { // data or tolerance changed: re-score the current layout; only Solve moves the cities + if (!sv) return restart(); + buildActive(); + const asDrawn = sv.total === 0 && !sv.done && !sv.changed, watch = sv.watch, X = Float64Array.from(sv.X); + sv = makeSolver(2, X); + if (running) sv.watch = watch; // mid-solve: carry on with the new set + else { sv.watch = asDrawn; sv.changed = !asDrawn; $("#run").textContent = "Solve"; } + update(true); +} +function continueSolve() { // Solve pressed on a finished layout: solve again from it if (!sv) return restart(); buildActive(); const X = Float64Array.from(sv.X); sv = makeSolver(2, X); sv.watch = false; running = true; $("#run").textContent = "Pause"; update(true); } @@ -499,7 +507,6 @@ function globeScore() { let SC = null, GS = null; function update(full) { SC = score(sv.X, 2); if (full) GS = globeScore(); - $("#flnote").hidden = S.fl; const nAct = { rt: act.l.filter(l => l.kind === "rt").length, rr: act.l.filter(l => l.kind === "rail" || l.kind === "road").length, sat: act.l.filter(l => l.kind === "sat").length }; const row = (label, a, b, n, cls) => `${label}${fmt(a)} / ${fmt(n)}${fmt(b)}`; const nf = S.fl ? FL.length : 0; @@ -699,13 +706,13 @@ $("#run").addEventListener("click", () => { running = !running; $("#run").textContent = running ? "Pause" : "Solve"; }); $("#reset").addEventListener("click", () => restart()); -for (const [id, key] of [["#d-fl", "fl"], ["#d-rt", "rt"], ["#d-rr", "rr"], ["#d-sat", "sat"]]) $(id).addEventListener("change", e => { S[key] = e.target.checked; continueSolve(); }); +for (const [id, key] of [["#d-fl", "fl"], ["#d-rt", "rt"], ["#d-rr", "rr"], ["#d-sat", "sat"]]) $(id).addEventListener("change", e => { S[key] = e.target.checked; dataChanged(); }); $("#lo").addEventListener("input", e => { $("#lov").textContent = e.target.value + "%"; }); $("#hi").addEventListener("input", e => { $("#hiv").textContent = e.target.value + "%"; }); -$("#lo").addEventListener("change", e => { S.lo = 1 - e.target.value / 100; continueSolve(); }); -$("#hi").addEventListener("change", e => { S.hi = e.target.value / 100 - 1; continueSolve(); }); +$("#lo").addEventListener("change", e => { S.lo = 1 - e.target.value / 100; dataChanged(); }); +$("#hi").addEventListener("change", e => { S.hi = e.target.value / 100 - 1; dataChanged(); }); for (const b of document.querySelectorAll("#light button")) b.addEventListener("click", () => { - S.vac = b.dataset.v === "vacuum"; for (const x of document.querySelectorAll("#light button")) x.setAttribute("aria-pressed", String(x === b)); continueSolve(); + S.vac = b.dataset.v === "vacuum"; for (const x of document.querySelectorAll("#light button")) x.setAttribute("aria-pressed", String(x === b)); dataChanged(); }); $("#dl").addEventListener("click", () => { const blob = new Blob([JSON.stringify(D)], { type: "application/json" }); const a = document.createElement("a"); @@ -790,10 +797,14 @@ const pace = sv => sv.total < 100 ? 0.35 : sv.stage === 0 ? (sv.total < 200 ? 1 let frame = 0, capText = ""; function caption(k) { let t; - if (!sv.done && sv.total === 0 && !running) + if (sv.changed && sv.total === 0 && !running) + t = `Measurements changed: ${fmt(k)} broken on this layout. It was solved for the previous set. Press Solve to fit it to these.`; + else if (!sv.done && sv.total === 0 && !running) t = `${START_LABEL[S.start]}, as drawn: ${fmt(k)} measurements broken. Red: a flight longer than its aircraft can fly, or (dashed) a ping faster than light. Amber: a flight far shorter than it takes to fly. Press Solve to let the cities move.`; else if (!sv.done) t = `${running ? "Moving" : "Paused moving"} the cities to fit the measurements. Grey lines: each city's move from the starting map.`; + else if (!S.fl) + t = `Done: ${fmt(k)} measurements broken. Without flight times, every measurement left is a maximum distance (no farther apart than this), and pulling cities closer always meets a maximum. These measurements can show that a map as drawn is wrong, but no flat map can fail them.`; else if (sel >= 0) { let n = 0, kept = 0, long = 0, short = 0; for (const f of act.f) if (f.a === sel || f.b === sel) { n++; const r = SC.ratio[f.i]; if (r > 1 + S.hi) long++; else if (r < 1 - S.lo) short++; else kept++; } @@ -805,12 +816,12 @@ function loop() { frame++; if (sv && running && !sv.done) { if (sv.watch) { sv.acc += pace(sv); const n = Math.floor(sv.acc); sv.acc -= n; if (n) work(sv, 9, n); } else work(sv, 9, 400); - if (sv.done) { running = false; $("#run").textContent = "Solve"; if (sv.watch && sel < 0) sel = SANTIAGO; } + if (sv.done) { running = false; $("#run").textContent = "Solve"; if (sv.watch && sel < 0 && S.fl) sel = SANTIAGO; } } if (sv && (running || frame % 6 === 0)) update(frame % 30 === 0); if (sv) { const k = SC.long.length + SC.short.length + SC.rt.length + SC.rr.length + SC.sat.length; - const head = sv.done ? (sv.isBest ? `Best of ${D.stats.n_starts}` : "Done") : sv.total === 0 && !running ? `${START_LABEL[S.start]}, as drawn` : (running ? "Solving, " : "Paused, ") + STAGE(sv.stage); + const head = sv.done ? (sv.isBest ? `Best of ${D.stats.n_starts}` : "Done") : sv.total === 0 && !running ? (sv.changed ? "Measurements changed" : `${START_LABEL[S.start]}, as drawn`) : (running ? "Solving, " : "Paused, ") + STAGE(sv.stage); $("#status").innerHTML = `${head} · broken ${fmt(k)}`; caption(k); } diff --git a/tests/femap.mjs b/tests/femap.mjs index 82b40d9..7d650b9 100644 --- a/tests/femap.mjs +++ b/tests/femap.mjs @@ -76,13 +76,17 @@ console.log('Best start: broken', kb, 'build says', want); if (kb !== want) fail('best layout count differs from the build', kb, want); // flights off: the plane can satisfy limits alone by shrinking await p.click('#d-fl'); +await p.waitForTimeout(500); +const ch = await p.evaluate(() => [document.getElementById('status').textContent, sv.total, running]); +if (!/^Measurements changed/.test(ch[0]) || ch[1] !== 0 || ch[2]) fail('changing the data should re-score without moving the cities', ch); +await p.click('#run'); await p.waitForFunction(() => /^Done/.test(document.getElementById('status').textContent), null, { timeout: 120000 }); const kf = await broken(); +const capf = await p.evaluate(() => document.getElementById('mapcap').textContent); +if (!/no flat map can fail them/.test(capf)) fail('flight-times-off caption missing', capf); console.log('Limits only: broken', kf); if (kf > 5) fail('limits alone should be (nearly) satisfiable on a plane', kf); -if (await p.evaluate(() => document.getElementById('flnote').hidden)) fail('flight-times-off note not shown'); await p.click('#d-fl'); -if (!(await p.evaluate(() => document.getElementById('flnote').hidden))) fail('flight-times-off note still shown'); await p.click('#starts button[data-start="best"]'); await p.waitForTimeout(300); // 3D release