diff --git a/femap/site/index.html b/femap/site/index.html
index 8b00b1b..2ee8ca4 100644
--- a/femap/site/index.html
+++ b/femap/site/index.html
@@ -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}
Rule. 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.
Measurements. 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.
Tolerance. 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.
- Solver. From a starting flat map it moves the cities to keep as many measurements as it can and marks the rest red (faster than possible) or amber (far off the straight line). Coastlines follow the cities like a rubber sheet.
+ Solver. The page opens on a starting flat map as drawn. Solve moves the cities to keep as many measurements as it can and marks the rest red (faster than possible) or amber (far off the straight line). Coastlines follow the cities like a rubber sheet.
@@ -182,11 +185,14 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
Drag to pan · scroll to zoom · click a city
+
Flight longer than its aircraft can fly
Flight squeezed short
Limit broken (signal faster than light, line longer than its track)
Kept (selected city)
+ A city's move from the starting map
+ Land near a city · land with no city nearby
@@ -238,7 +244,7 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
Flights From The Measured Sphere ("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.
Limits A ping is a round trip: the signal goes out and comes back. A ping of t 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 pingstats . 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.
Solver 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 (fit.py) ran from 311 starting maps and then refined the 20 best layouts with twelve rounds.
- Drawing 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.
+ Drawing 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.
3D 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.
Download data (JSON)
@@ -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 => `${Math.round((1 - b.lo) * 100)}% – ${Math.round((1 + b.hi) * 100)}% ${b.flat.long} ${b.flat.short} ${b.flat.limits} ${b.flat.below75} ${b.globe.long} ${b.globe.short} ${b.globe.limits} `).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 = `${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 (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 = `${cityName(sel)} : ${kept} of ${n} flights kept (green )` + (long ? `, ${long} longer than the aircraft can fly (red )` : "") + (short ? `, ${short} squeezed short (amber )` : "") + ". 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 ${fmt(k)} `;
+ 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 ${fmt(k)} `;
+ caption(k);
}
drawMap();
if (s3 && !s3.done) work(s3, 7, 300);
diff --git a/femap/template.html b/femap/template.html
index fad0fbe..9e32bac 100644
--- a/femap/template.html
+++ b/femap/template.html
@@ -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}
Rule. The surface is a plane. Each of the {{N_CITIES}} cities may sit anywhere on it. No latitude, longitude, radius, projection or spherical formula enters the solver.
Measurements. Flight times give lengths: {{N_FLIGHTS}} routes, cruise speed × time in the air, both directions. Pings ({{N_RT}}), rail and road lengths ({{N_RAIL}}) and satellite passes ({{N_SAT}}) give limits: no farther apart than this.
Tolerance. A flight holds between {{LO_PCT}}% and {{HI_PCT}}% 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.
- Solver. From a starting flat map it moves the cities to keep as many measurements as it can and marks the rest red (faster than possible) or amber (far off the straight line). Coastlines follow the cities like a rubber sheet.
+ Solver. The page opens on a starting flat map as drawn. Solve moves the cities to keep as many measurements as it can and marks the rest red (faster than possible) or amber (far off the straight line). Coastlines follow the cities like a rubber sheet.
@@ -182,11 +185,14 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
Drag to pan · scroll to zoom · click a city
+
Flight longer than its aircraft can fly
Flight squeezed short
Limit broken (signal faster than light, line longer than its track)
Kept (selected city)
+ A city's move from the starting map
+ Land near a city · land with no city nearby
@@ -238,7 +244,7 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
Flights From The Measured Sphere ("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.
Limits A ping is a round trip: the signal goes out and comes back. A ping of t ms caps a distance at {{KM_PER_MS}} 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 pingstats . {{N_DROPPED}} 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.
Solver 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 (fit.py) ran from {{N_STARTS}} starting maps and then refined the 20 best layouts with twelve rounds.
- Drawing 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.
+ Drawing 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.
3D 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.
Download data (JSON)
@@ -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 => `${Math.round((1 - b.lo) * 100)}% – ${Math.round((1 + b.hi) * 100)}% ${b.flat.long} ${b.flat.short} ${b.flat.limits} ${b.flat.below75} ${b.globe.long} ${b.globe.short} ${b.globe.limits} `).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 = `${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 (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 = `${cityName(sel)} : ${kept} of ${n} flights kept (green )` + (long ? `, ${long} longer than the aircraft can fly (red )` : "") + (short ? `, ${short} squeezed short (amber )` : "") + ". 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 ${fmt(k)} `;
+ 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 ${fmt(k)} `;
+ caption(k);
}
drawMap();
if (s3 && !s3.done) work(s3, 7, 300);
diff --git a/tests/femap.mjs b/tests/femap.mjs
index 190c777..82b40d9 100644
--- a/tests/femap.mjs
+++ b/tests/femap.mjs
@@ -49,11 +49,21 @@ const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
const errs = []; p.on('pageerror', e => errs.push(e.message));
await p.goto(url, { waitUntil: 'load' });
const broken = () => p.evaluate(() => +(document.getElementById('status').textContent.match(/broken ([\d,]+)/) || [0, 'NaN'])[1].replace(/,/g, ''));
-await p.waitForTimeout(300);
+await p.waitForTimeout(600);
const k0 = await broken();
+const st0 = await p.evaluate(() => [document.getElementById('status').textContent, document.getElementById('mapcap').textContent]);
+if (!/as drawn/.test(st0[0]) || !/Press Solve/.test(st0[1])) fail('page should open paused on the starting map', st0);
+await p.waitForTimeout(600);
+if (await broken() !== k0) fail('starting map changed before Solve was pressed');
+await p.click('#run');
+await p.waitForTimeout(2000);
+const moving = await p.evaluate(() => sv.total);
+if (!(moving > 0 && moving < 400)) fail('solve should start slowly so the move can be followed', moving);
await p.waitForFunction(() => /^Done/.test(document.getElementById('status').textContent), null, { timeout: 120000 });
const k1 = await broken();
-console.log('AE start: broken', k0, '->', k1);
+const cap1 = await p.evaluate(() => document.getElementById('mapcap').textContent);
+if (!/^Santiago: \d+ of \d+ flights kept/.test(cap1)) fail('Santiago should be selected when the solve ends', cap1);
+console.log('AE start: broken', k0, '->', k1, '|', cap1);
if (!(k1 < k0)) fail('solver did not reduce the broken count', k0, k1);
if (k1 > 60) fail('live solve far from the offline best', k1);
await p.screenshot({ path: path.join(here, 'out/femap_solved.png') });