fe-map: open paused on the starting map as drawn; paced solve; trails from the starting map; land fades away from cities; Santiago selected at the end

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Toby
2026-10-31 12:24:09 -04:00
parent 9b597a312d
commit 68b9c3cf05
3 changed files with 194 additions and 50 deletions
+91 -24
View File
@@ -76,6 +76,9 @@ button{font:inherit;color:inherit}
@media (max-width:620px){.mapbox{aspect-ratio:1/1.05}}
.mapbox canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab;border-radius:10px}
.maphint{position:absolute;left:10px;bottom:8px;font-size:11.5px;color:var(--faint);pointer-events:none}
.mapcap{margin:0;padding:10px 12px 0;font-size:13.5px;line-height:1.5;color:var(--dim);border-top:1px solid var(--line)}
.mapcap b{color:var(--ink);font-weight:600}
.mapcap .k-ok{color:var(--ok)}
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;margin:0;padding:10px 12px 12px;list-style:none;color:var(--dim);font-size:12.5px;border-top:1px solid var(--line)}
.legend li{display:flex;align-items:center;gap:7px}
.legend svg{width:24px;height:12px;flex:none}
@@ -144,7 +147,7 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
<p><b>Rule.</b> The surface is a plane. Each of the 219 cities may sit anywhere on it. No latitude, longitude, radius, projection or spherical formula enters the solver.</p>
<p><b>Measurements.</b> Flight times give lengths: 1,344 routes, cruise speed × time in the air, both directions. Pings (6,958), rail and road lengths (70) and satellite passes (56) give limits: no farther apart than this.</p>
<p><b>Tolerance.</b> A flight holds between 75% and 110% of its length. Shorter means a route over a third longer than the straight line; longer means an aircraft above its top speed the whole way.</p>
<p><b>Solver.</b> From a starting flat map it moves the cities to keep as many measurements as it can and marks the rest <span class="k-bad">red</span> (faster than possible) or <span class="k-warn">amber</span> (far off the straight line). Coastlines follow the cities like a rubber sheet.</p>
<p><b>Solver.</b> The page opens on a starting flat map as drawn. <i>Solve</i> moves the cities to keep as many measurements as it can and marks the rest <span class="k-bad">red</span> (faster than possible) or <span class="k-warn">amber</span> (far off the straight line). Coastlines follow the cities like a rubber sheet.</p>
</section>
<div class="bar">
@@ -182,11 +185,14 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
<section class="wrap main" aria-label="The plane">
<div class="panel">
<div class="mapbox"><canvas id="map" aria-label="The flat map the solver is building"></canvas><span class="maphint">Drag to pan · scroll to zoom · click a city</span></div>
<p class="mapcap" id="mapcap" aria-live="polite"></p>
<ul class="legend">
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ff4d5e" stroke-width="2"/></svg>Flight longer than its aircraft can fly</li>
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ffc94a" stroke-width="2"/></svg>Flight squeezed short</li>
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#ff4d5e" stroke-width="2" stroke-dasharray="4 3"/></svg>Limit broken (signal faster than light, line longer than its track)</li>
<li><svg viewBox="0 0 24 12" aria-hidden="true"><line x1="1" y1="6" x2="23" y2="6" stroke="#3dffa0" stroke-width="1.5"/></svg>Kept (selected city)</li>
<li><svg viewBox="0 0 24 12" aria-hidden="true"><circle cx="3" cy="6" r="2" fill="none" stroke="#8fa7c4"/><line x1="5" y1="6" x2="20" y2="6" stroke="#8fa7c4" stroke-width="1"/><circle cx="21" cy="6" r="2" fill="#fff"/></svg>A city's move from the starting map</li>
<li><svg viewBox="0 0 24 12" aria-hidden="true"><rect x="1" y="1" width="10" height="10" fill="#16263d"/><rect x="13" y="1" width="10" height="10" fill="#16263d" fill-opacity=".35"/></svg>Land near a city · land with no city nearby</li>
</ul>
</div>
<aside class="panel side" aria-label="Score">
@@ -238,7 +244,7 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
<dt>Flights</dt><dd>From <a href="https://distance.aethercosmology.com/#sphere">The Measured Sphere</a> ("each flight's own speed"): routes flown both ways, each direction's median time from wheels-off to wheels-on over two weeks (FlightAware), less 15 minutes for climb and descent, times the aircraft type's cruise speed (Mach × 1,062 km/h). Averaging the two directions cancels the wind. Click a city for each route's flights, aircraft and track logs.</dd>
<dt>Limits</dt><dd>A ping is a round trip: the signal goes out and comes back. A ping of <i>t</i> ms caps a distance at 102.1 km per ms (light in fibre; the vacuum option multiplies by 1.468), plus 25 km at each end and the distance from each server to its city's airport. Sources: WonderNetwork's 2020 archive and the live RIPE Atlas, Globalping and Vultr measurements on <a href="https://pingstats.aethercosmology.com/">pingstats</a>. 2 pings shorter than every surface allows were left out as mislocated servers. Rail and road: the straight line can be no longer than the published route. Satellite passes: two stations can be no farther apart than the two closest ranges plus the satellite's measured flight between them.</dd>
<dt>Solver</dt><dd>First a least-squares fit of every flight's relative error. Then eight rounds of a reweighted, nearly linear penalty on how far each measurement falls outside its band, which gives up on as few measurements as it can instead of straining many (the layout with the fewest broken is kept). Limits count three times a flight. L-BFGS throughout. The page runs the same steps live. Offline, the same code (<code>fit.py</code>) ran from 311 starting maps and then refined the 20 best layouts with twelve rounds.</dd>
<dt>Drawing</dt><dd>Coastlines (Natural Earth 1:110m) are projected on the starting map, then carried along by a thin-plate spline through the cities' starting and current positions. Land near the starting map's rim is outlined only.</dd>
<dt>Drawing</dt><dd>Coastlines (Natural Earth 1:110m) are projected on the starting map, then carried along by a thin-plate spline through the cities' starting and current positions. Land near the starting map's rim is outlined only. Land more than 900 km from every city is drawn faint, and beyond 1,500 km fainter still: no measurement fixes its shape. Each city's move from the starting map is drawn as a thin line.</dd>
<dt>3D</dt><dd>The same objective with a third coordinate. After the solve, a least-squares sphere is fitted to the points to describe the shape; how far round it reaches is the angle from the cities' mean direction to the farthest city.</dd>
</dl>
<button type="button" class="btn" id="dl">Download data (JSON)</button>
@@ -279,7 +285,7 @@ const LIMIT_W = 3, MARGIN = 0.005, DELTA = 0.003, EPS = 0.02, ROUNDS = 8;
function makeSolver(dim, X0) {
const n = N * dim;
const sv = { dim, X: Float64Array.from(X0), stage: 0, it: 0, total: 0, w: new Float64Array(act.f.length).fill(1),
wl: new Float64Array(act.l.length).fill(LIMIT_W), mem: [], g: null, F: null, done: false, best: null, bestK: Infinity, hist: [] };
wl: new Float64Array(act.l.length).fill(LIMIT_W), mem: [], g: null, F: null, done: false, best: null, bestK: Infinity, hist: [], acc: 0 };
sv.evalF = (X, G) => {
G.fill(0); let F = 0;
const { fa, fb, fL, la, lb, lU } = act; const lo = S.lo, hi = S.hi;
@@ -444,7 +450,9 @@ function warp(X, dim) { // thin-plate spline through (source -> X), evaluated
}
/* ---------------- state ---------------- */
let sv = null, running = true, sel = -1, view = null, userView = false, coast = null, coastAt = -1;
let sv = null, running = false, sel = -1, view = null, userView = false, coast = null, coastAt = -1, ORIG = null;
const SANTIAGO = D.cities.findIndex(c => c[0] === "Santiago");
const START_LABEL = { ae: "North-pole AE map", dp: "Dual-pole AE map", mds: "Flight-time layout" };
function startPositions(key) {
if (key === "best") return { centre: D.best.centre, X: D.best.X, label: D.best.label };
const s = D.starts[key]; return { centre: s.centre, X: s.X, label: s.label };
@@ -456,15 +464,17 @@ function restart() {
// the flight-times-only start is drawn from North-pole AE)
const src = D.cities.map(c => aeq(c[1], c[2], st.centre[0], st.centre[1]));
if (!SHEET || SHEET.key !== S.start) { buildSheet(st.centre, src); SHEET.key = S.start; }
sv = makeSolver(2, st.X.flat());
if (S.start === "best") { sv.done = true; sv.stage = ROUNDS; }
running = !sv.done; userView = false; view = null; coastAt = -1;
$("#run").textContent = running ? "Pause" : "Solve";
sv = makeSolver(2, st.X.flat()); sv.watch = true;
ORIG = Float64Array.from(S.start === "best" ? src.flatMap(p => [p[0], p[1]]) : st.X.flat());
sel = -1;
if (S.start === "best") { sv.done = true; sv.stage = ROUNDS; sv.isBest = true; sel = SANTIAGO; }
running = false; userView = false; view = null; coastAt = -1;
$("#run").textContent = "Solve";
update(true);
}
function continueSolve() { // data or tolerance changed: keep the current layout and solve again
if (!sv) return restart();
buildActive(); const X = Float64Array.from(sv.X); sv = makeSolver(2, X); running = true; $("#run").textContent = "Pause"; update(true);
buildActive(); const X = Float64Array.from(sv.X); sv = makeSolver(2, X); sv.watch = false; running = true; $("#run").textContent = "Pause"; update(true);
}
/* ---------------- scoring ---------------- */
@@ -508,6 +518,8 @@ function update(full) {
/* ---------------- 2D drawing ---------------- */
const cv = $("#map"), g = cv.getContext("2d");
const landC = document.createElement("canvas"), lg = landC.getContext("2d"), maskC = document.createElement("canvas"), mg = maskC.getContext("2d");
let placed = []; // label boxes drawn this frame
let W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
@@ -534,35 +546,70 @@ function drawMap() {
// coastlines
if (coastAt !== sv.total) { coast = warp(X, 2); coastAt = sv.total; }
g.lineJoin = "round";
for (const r of SHEET.rings) {
g.beginPath();
r.idx.forEach((p, k) => { const q = P(coast[2 * p], coast[2 * p + 1]); k ? g.lineTo(q[0], q[1]) : g.moveTo(q[0], q[1]); });
if (r.fill) { g.closePath(); g.fillStyle = COL.land; g.fill(); g.strokeStyle = COL.coast; g.lineWidth = 0.8; g.stroke(); }
else { g.strokeStyle = "rgba(58,90,128,.55)"; g.lineWidth = 0.8; g.setLineDash([3, 3]); g.stroke(); g.setLineDash([]); }
const land = (c, fill) => {
for (const r of SHEET.rings) {
if (r.fill !== fill) continue;
c.beginPath();
r.idx.forEach((p, k) => { const q = P(coast[2 * p], coast[2 * p + 1]); k ? c.lineTo(q[0], q[1]) : c.moveTo(q[0], q[1]); });
if (fill) { c.closePath(); c.fillStyle = COL.land; c.fill(); c.strokeStyle = COL.coast; c.lineWidth = 0.8; c.stroke(); }
else { c.strokeStyle = "rgba(58,90,128,.55)"; c.lineWidth = 0.8; c.setLineDash([3, 3]); c.stroke(); c.setLineDash([]); }
}
};
// only land near a city has its shape fixed by measurements: land fades from 900 km to 1,600 km from the nearest city
if (landC.width !== cv.width || landC.height !== cv.height) { landC.width = maskC.width = cv.width; landC.height = maskC.height = cv.height; }
lg.setTransform(DPR, 0, 0, DPR, 0, 0); lg.globalCompositeOperation = "source-over"; lg.clearRect(0, 0, W, H); lg.lineJoin = "round"; land(lg, true);
mg.setTransform(DPR, 0, 0, DPR, 0, 0); mg.globalCompositeOperation = "source-over"; mg.clearRect(0, 0, W, H);
mg.fillStyle = "rgba(255,255,255,.28)"; mg.fillRect(0, 0, W, H); mg.globalCompositeOperation = "lighter";
const r0 = 900 * view.s, r1 = 1600 * view.s;
for (let i = 0; i < N; i++) {
const q = P(X[2 * i], X[2 * i + 1]); if (q[0] < -r1 || q[1] < -r1 || q[0] > W + r1 || q[1] > H + r1) continue;
const gr = mg.createRadialGradient(q[0], q[1], r0, q[0], q[1], r1); gr.addColorStop(0, "rgba(255,255,255,1)"); gr.addColorStop(1, "rgba(255,255,255,0)");
mg.fillStyle = gr; mg.beginPath(); mg.arc(q[0], q[1], r1, 0, 2 * Math.PI); mg.fill();
}
lg.setTransform(1, 0, 0, 1, 0, 0); lg.globalCompositeOperation = "destination-in"; lg.drawImage(maskC, 0, 0); lg.globalCompositeOperation = "source-over";
g.setTransform(1, 0, 0, 1, 0, 0); g.drawImage(landC, 0, 0); g.setTransform(DPR, 0, 0, DPR, 0, 0);
land(g, false);
// each city's move from the starting map
if (ORIG) {
g.lineWidth = 0.8; g.strokeStyle = "rgba(143,167,196,.3)"; g.beginPath();
for (let i = 0; i < N; i++) { const a = P(ORIG[2 * i], ORIG[2 * i + 1]), b = P(X[2 * i], X[2 * i + 1]); if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 3) continue; g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1]); }
g.stroke();
g.fillStyle = "rgba(143,167,196,.5)"; g.beginPath();
for (let i = 0; i < N; i++) { const a = P(ORIG[2 * i], ORIG[2 * i + 1]), b = P(X[2 * i], X[2 * i + 1]); if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 3) continue; g.moveTo(a[0] + 1.4, a[1]); g.arc(a[0], a[1], 1.4, 0, 7); }
g.fill();
}
// selected city: its kept measurements
if (sel >= 0) {
g.lineWidth = 1; g.strokeStyle = "rgba(61,255,160,.55)";
for (const f of act.f) if ((f.a === sel || f.b === sel)) { const r = SC.ratio[f.i]; if (r <= 1 + S.hi && r >= 1 - S.lo) seg(X, f.a, f.b); }
}
// broken measurements
g.lineWidth = 1.6;
g.strokeStyle = COL.warn; for (const [f] of SC.short) if (sel < 0 || f.a === sel || f.b === sel) seg(X, f.a, f.b);
g.setLineDash([5, 4]); g.strokeStyle = COL.bad;
for (const k of ["rt", "rr", "sat"]) for (const [l] of SC[k]) if (sel < 0 || l.a === sel || l.b === sel) seg(X, l.a, l.b);
g.setLineDash([]); g.lineWidth = 2; g.strokeStyle = COL.bad;
for (const [f] of SC.long) if (sel < 0 || f.a === sel || f.b === sel) seg(X, f.a, f.b);
// broken measurements; with a city selected, those not touching it stay faint
for (const mine of sel < 0 ? [true] : [false, true]) {
const on = x => (sel < 0 || x.a === sel || x.b === sel) === mine;
g.globalAlpha = mine ? 1 : 0.22;
g.lineWidth = 1.6;
g.strokeStyle = COL.warn; for (const [f] of SC.short) if (on(f)) seg(X, f.a, f.b);
g.setLineDash([5, 4]); g.strokeStyle = COL.bad;
for (const k of ["rt", "rr", "sat"]) for (const [l] of SC[k]) if (on(l)) seg(X, l.a, l.b);
g.setLineDash([]); g.lineWidth = 2; g.strokeStyle = COL.bad;
for (const [f] of SC.long) if (on(f)) seg(X, f.a, f.b);
}
g.globalAlpha = 1;
// cities
for (let i = 0; i < N; i++) {
const q = P(X[2 * i], X[2 * i + 1]);
g.beginPath(); g.arc(q[0], q[1], i === sel ? 4.5 : 2.4, 0, 7); g.fillStyle = i === sel ? COL.track : COL.city || "#fff"; g.fill();
}
placed = [];
if (sel >= 0) { const q = P(X[2 * sel], X[2 * sel + 1]); label(q, cityName(sel)); }
for (const [f] of SC.long.slice().sort((p, q) => q[1] - p[1]).slice(0, 4)) for (const i of [f.a, f.b]) if (i !== sel) label(P(X[2 * i], X[2 * i + 1]), cityName(i), true);
}
function seg(X, a, b) { const p = P(X[2 * a], X[2 * a + 1]), q = P(X[2 * b], X[2 * b + 1]); g.beginPath(); g.moveTo(p[0], p[1]); g.lineTo(q[0], q[1]); g.stroke(); }
function label(q, t, small) {
g.font = `${small ? 500 : 600} ${small ? 11 : 12.5}px Inter, system-ui, sans-serif`; g.textBaseline = "middle";
const box = [q[0] + 5, q[1] - 15, q[0] + 9 + g.measureText(t).width, q[1] + 1]; // skip a label that would overlap one already drawn
if (placed.some(b => box[0] < b[2] && b[0] < box[2] && box[1] < b[3] && b[1] < box[3])) return;
placed.push(box);
g.lineWidth = 3; g.strokeStyle = "rgba(4,10,22,.9)"; g.strokeText(t, q[0] + 7, q[1] - 7); g.fillStyle = small ? COL.dim : COL.ink; g.fillText(t, q[0] + 7, q[1] - 7);
}
@@ -738,14 +785,34 @@ $("#spin").addEventListener("click", e => { spin = !spin; e.target.setAttribute(
$("#bands tbody").innerHTML = D.bands.map(b => `<tr><th scope="row">${Math.round((1 - b.lo) * 100)}% – ${Math.round((1 + b.hi) * 100)}%</th><td>${b.flat.long}</td><td>${b.flat.short}</td><td>${b.flat.limits}</td><td>${b.flat.below75}</td><td>${b.globe.long}</td><td>${b.globe.short}</td><td>${b.globe.limits}</td></tr>`).join("");
/* ---------------- loop ---------------- */
let frame = 0;
// a solve started from a map runs slowly at first, where the cities move most, so the move can be followed
const pace = sv => sv.total < 100 ? 0.35 : sv.stage === 0 ? (sv.total < 200 ? 1 : 10) : sv.stage === 1 ? 12 : 400;
let frame = 0, capText = "";
function caption(k) {
let t;
if (!sv.done && sv.total === 0 && !running)
t = `<b>${START_LABEL[S.start]}, as drawn: ${fmt(k)} measurements broken.</b> 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 <b>Solve</b> 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 (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++; }
t = `<b>${cityName(sel)}</b>: ${kept} of ${n} flights kept (<span class="k-ok">green</span>)` + (long ? `, ${long} longer than the aircraft can fly (<span class="k-bad">red</span>)` : "") + (short ? `, ${short} squeezed short (<span class="k-warn">amber</span>)` : "") + ". Click any city to see its flights.";
} else t = `${sv.isBest ? "Best of " + D.stats.n_starts : "Done"}: ${fmt(k)} measurements broken. Click any city to see its flights.`;
if (t !== capText) { $("#mapcap").innerHTML = t; capText = t; }
}
function loop() {
frame++;
if (sv && running && !sv.done) { work(sv, 9, 400); if (sv.done) { running = false; $("#run").textContent = "Solve"; } }
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 && (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;
$("#status").innerHTML = `${sv.done ? "Done" : "Solving, " + STAGE(sv.stage)} · broken <b>${fmt(k)}</b>`;
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);
$("#status").innerHTML = `${head} · broken <b>${fmt(k)}</b>`;
caption(k);
}
drawMap();
if (s3 && !s3.done) work(s3, 7, 300);