fe-map: optional measurement along a sphere (shape conceded, radius free, haversine distance); land dots in the 3D view
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+38
-3
@@ -27,7 +27,7 @@ _spec = importlib.util.spec_from_file_location("pingstats_build", os.path.join(S
|
||||
_pb = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(_pb)
|
||||
live, land = _pb.live, _pb.land # src/build.py
|
||||
from models import R, C_FIBER, globe as gc, ae_xy, dualpole_xy, mercator_xy
|
||||
from fit import Problem, sphere_fit
|
||||
from fit import Problem, SphereProblem, sphere_fit, true_offsets
|
||||
|
||||
PRES = os.path.join(SRC, "handoff", "The_Shape_of_Distance_PRESENTATION.html")
|
||||
KM_PER_MS = C_FIBER / 2 / 1000 # one-way km per ms of round trip, light in glass
|
||||
@@ -55,6 +55,32 @@ def page_const(html, name):
|
||||
return json.JSONDecoder().raw_decode(js[i:])[0]
|
||||
|
||||
|
||||
def land_dots(polys, n=20000):
|
||||
"""Points spread evenly over the sphere (Fibonacci lattice) that fall on land: the 3D view draws land
|
||||
as these dots."""
|
||||
def contains(ring, x, y): # even-odd ray casting in longitude-latitude
|
||||
r = np.asarray(ring, float); inside = np.zeros(len(x), bool)
|
||||
box = (x >= r[:, 0].min()) & (x <= r[:, 0].max()) & (y >= r[:, 1].min()) & (y <= r[:, 1].max())
|
||||
if not box.any():
|
||||
return inside
|
||||
xs, ys = x[box], y[box]; hit = np.zeros(len(xs), bool)
|
||||
for (x1, y1), (x2, y2) in zip(r, np.roll(r, -1, axis=0)):
|
||||
if y1 == y2:
|
||||
continue
|
||||
c = (y1 > ys) != (y2 > ys)
|
||||
hit ^= c & (xs < (x2 - x1) * (ys - y1) / (y2 - y1) + x1)
|
||||
inside[box] = hit
|
||||
return inside
|
||||
k = np.arange(n) + 0.5; lat = np.degrees(np.arcsin(1 - 2 * k / n)); lon = (np.degrees(np.pi * (1 + 5 ** 0.5) * k) + 180) % 360 - 180
|
||||
on = np.zeros(n, bool)
|
||||
for poly in polys:
|
||||
inside = contains(poly[0], lon, lat)
|
||||
for hole in poly[1:]:
|
||||
inside &= ~contains(hole, lon, lat)
|
||||
on |= inside
|
||||
return [[round(float(a), 1), round(float(b), 1)] for a, b in zip(lat[on], lon[on])]
|
||||
|
||||
|
||||
def aeq(lat, lon, lat0, lon0):
|
||||
"""Azimuthal equidistant map centred on (lat0, lon0), km. North-pole AE is (90, 0); dual-pole is (0, 0)."""
|
||||
p1, l1, p0, l0 = map(np.radians, (np.asarray(lat), np.asarray(lon), lat0, lon0))
|
||||
@@ -243,6 +269,13 @@ def main():
|
||||
sph = sphere_fit(X3)
|
||||
print(f"3D: {k3} broken, radius {sph['radius']:.0f} km, wrap {sph['wrap']:.0f} deg, offset {sph['offset']:.3f}", flush=True)
|
||||
|
||||
# Measured along a sphere instead (the shape conceded, its size left free), from the best flat layout.
|
||||
sp = SphereProblem(len(C), prob.fa, prob.fb, prob.fl, prob.la, prob.lb, prob.lu)
|
||||
Ra, Ua, bka = sp.arc_best_effort(best["X"], LO, HI)
|
||||
offa = true_offsets(Ua, C[:, 0], C[:, 1], Ra)
|
||||
arc = dict(radius=Ra, **bka, off_median=float(np.median(offa)), off_p90=float(np.percentile(offa, 90)))
|
||||
print(f"along a sphere: radius {Ra:.0f} km, broken {bka}, cities from true place: median {arc['off_median']:.0f} km", flush=True)
|
||||
|
||||
bands = []
|
||||
for j, (lo, hi) in enumerate(BANDS[:1] if FAST else BANDS):
|
||||
cand = out_jobs[seeds + NB * j: seeds + NB * (j + 1)]
|
||||
@@ -266,7 +299,7 @@ def main():
|
||||
below80=int((gr < 0.8).sum()), below85=int((gr < 0.85).sum()), meanlog=float(np.abs(np.log(gr)).mean())),
|
||||
starts_broken=[[x["label"], x["broken"]] for x in res if "refined" not in x],
|
||||
n_best=sum(x["broken"] == best["broken"] for x in res),
|
||||
three=dict(broken=k3, **counts(prob, X3, LO, HI), **sph),
|
||||
three=dict(broken=k3, **counts(prob, X3, LO, HI), **sph), arc=arc,
|
||||
globe_long=[[int(prob.fa[i]), int(prob.fb[i]), round(float(gr[i]), 3)] for i in np.where(gr > 1 + HI)[0]],
|
||||
globe_short=[[int(prob.fa[i]), int(prob.fb[i]), round(float(gr[i]), 3)] for i in np.where(gr < 1 - LO)[0]],
|
||||
tri=D["tri"], lo=LO, hi=HI, km_per_ms=KM_PER_MS, end_slack=END_SLACK, match_km=MATCH_KM,
|
||||
@@ -280,7 +313,7 @@ def main():
|
||||
f["ab"][:5] + [strip(f["ab"][5])], f["ba"][:5] + [strip(f["ba"][5])]] for f in fl],
|
||||
lim=[[l["a"], l["b"], l["U"], l["kind"], l["note"], l["label"], l["url"]] for l in lim],
|
||||
gf=[round(float(x), 1) for x in GF], gl=[round(float(x), 1) for x in GL],
|
||||
land=land(),
|
||||
land=land(), dots=land_dots(land()),
|
||||
starts={k: dict(label=v[0], centre=v[1], X=np.round(v[2], 1).tolist()) for k, v in starts.items()},
|
||||
best=dict(label=best["label"], centre=best["centre"], X=np.round(best["X"], 1).tolist()),
|
||||
best3=dict(X=np.round(X3, 1).tolist()),
|
||||
@@ -336,6 +369,8 @@ def copy_values(out):
|
||||
FIT_B75=next((fmt(x["flat"]["below75"]) for x in out["bands"] if x["flat"]["short"] + x["flat"]["long"] + x["flat"]["limits"] == 0), "–"),
|
||||
FIT_B60=next((fmt(x["flat"]["below60"]) for x in out["bands"] if x["flat"]["short"] + x["flat"]["long"] + x["flat"]["limits"] == 0), "–"),
|
||||
GLOBE_B75=fmt(out["bands"][0]["globe"]["below75"]), GLOBE_B60=fmt(out["bands"][0]["globe"]["below60"]),
|
||||
ARC_R=fmt(round(s["arc"]["radius"])), ARC_BROKEN=fmt(s["arc"]["long"] + s["arc"]["short"] + s["arc"]["limits"]),
|
||||
ARC_OFF=fmt(round(s["arc"]["off_median"])),
|
||||
GLOBE_SHORT_LIST=", ".join(f"{names[a]}–{names[bb]}" for a, bb, x in sorted(s["globe_short"], key=lambda t: t[2])[:4]),
|
||||
)
|
||||
return v
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -94,3 +94,79 @@ def sphere_fit(X):
|
||||
u = (X - c) / d[:, None]; m = u.mean(0); m /= np.linalg.norm(m)
|
||||
return dict(radius=r, offset=float(np.median(np.abs(d - r) / r)),
|
||||
wrap=float(np.degrees(np.arccos(np.clip(u @ m, -1, 1))).max()), centre=c.tolist())
|
||||
|
||||
|
||||
# ---- measured along a sphere: concedes the shape, leaves its size free ----
|
||||
def _arcs(P, R, a, b):
|
||||
nrm = np.linalg.norm(P, axis=1); U = P / nrm[:, None]
|
||||
u, v = U[a], U[b]; c = np.clip((u * v).sum(1), -1, 1); s = np.linalg.norm(np.cross(u, v), axis=1)
|
||||
th = np.arctan2(s, c); s = np.maximum(s, 1e-9)
|
||||
gu = -(v - c[:, None] * u) / (s * nrm[a])[:, None]; gv = -(u - c[:, None] * v) / (s * nrm[b])[:, None]
|
||||
return R * th, th, gu, gv
|
||||
|
||||
|
||||
class SphereProblem(Problem):
|
||||
"""Cities held on a sphere of free radius; each distance measured along its surface (the haversine
|
||||
arc, R x central angle). Stage 1 fits the radius with the cities; later stages hold it."""
|
||||
|
||||
def _pairs(self, P, R):
|
||||
return _arcs(P, R, self.fa, self.fb), _arcs(P, R, self.la, self.lb)
|
||||
|
||||
def _apply(self, ga, gl, fa_, la_, R):
|
||||
(dd, th, gu, gv), (ld, lth, lgu, lgv) = fa_, la_; n = self.n; G = np.zeros((n, 3))
|
||||
for q in range(3):
|
||||
G[:, q] = (np.bincount(self.fa, ga * R * gu[:, q], n) + np.bincount(self.fb, ga * R * gv[:, q], n)
|
||||
+ np.bincount(self.la, gl * R * lgu[:, q], n) + np.bincount(self.lb, gl * R * lgv[:, q], n))
|
||||
return G.ravel(), float((ga * th).sum() + (gl * lth).sum())
|
||||
|
||||
def arc_l2(self, wl=100.0):
|
||||
def f(x):
|
||||
P = x[:-1].reshape(self.n, 3); R = x[-1]; F_, L_ = self._pairs(P, R); dd, ld = F_[0], L_[0]
|
||||
e = dd / self.fl - 1; ex = np.maximum(0, ld / self.lu - 1)
|
||||
G, gR = self._apply(2 * e / self.fl, wl * 2 * ex / self.lu, F_, L_, R)
|
||||
return (e ** 2).sum() + wl * (ex ** 2).sum(), np.r_[G, gR]
|
||||
return f
|
||||
|
||||
def arc_sparse(self, R, lo, hi, w, wl):
|
||||
a = self.fl * (1 - lo + MARGIN); b = self.fl * (1 + hi - MARGIN); ub = self.lu * (1 - MARGIN)
|
||||
def f(x):
|
||||
P = x.reshape(self.n, 3); F_, L_ = self._pairs(P, R); dd, ld = F_[0], L_[0]
|
||||
eh = np.maximum(0, dd / b - 1); el = np.maximum(0, 1 - dd / a)
|
||||
sh = np.sqrt(eh ** 2 + DELTA ** 2); sl = np.sqrt(el ** 2 + DELTA ** 2)
|
||||
ex = np.maximum(0, ld / ub - 1); sx = np.sqrt(ex ** 2 + DELTA ** 2)
|
||||
G, _ = self._apply(w * (eh / sh / b - el / sl / a), wl * ex / sx / ub, F_, L_, R)
|
||||
return (w * (sh + sl - 2 * DELTA)).sum() + (wl * (sx - DELTA)).sum(), G
|
||||
return f
|
||||
|
||||
def arc_broken(self, P, R, lo, hi):
|
||||
F_, L_ = self._pairs(P, R); r = F_[0] / self.fl
|
||||
return dict(long=int((r > 1 + hi).sum()), short=int((r < 1 - lo).sum()), limits=int((L_[0] > self.lu * 1.0005).sum()))
|
||||
|
||||
def arc_best_effort(self, X2, lo, hi, rounds=ROUNDS, maxiter=30000, eps=EPS, limit_w=LIMIT_W):
|
||||
"""From a flat layout laid on a spherical cap whose radius is a third of the layout's width."""
|
||||
X2 = X2 - X2.mean(0); R0 = float(np.ptp(X2, axis=0).max()) / 3
|
||||
x = np.r_[np.c_[X2, np.full(self.n, R0)].ravel(), R0]
|
||||
x = minimize(self.arc_l2(), x, jac=True, method="L-BFGS-B", options={"maxiter": maxiter, "maxcor": 30}).x
|
||||
P, R = x[:-1].reshape(self.n, 3), float(x[-1])
|
||||
k = sum(self.arc_broken(P, R, lo, hi).values()); best = (k, P.copy())
|
||||
w = np.ones(len(self.fl)); wl = np.full(len(self.lu), limit_w)
|
||||
for _ in range(rounds):
|
||||
P = minimize(self.arc_sparse(R, lo, hi, w, wl), P.ravel(), jac=True, method="L-BFGS-B",
|
||||
options={"maxiter": maxiter, "maxcor": 30}).x.reshape(self.n, 3)
|
||||
k = sum(self.arc_broken(P, R, lo, hi).values())
|
||||
if k < best[0]:
|
||||
best = (k, P.copy())
|
||||
F_, L_ = self._pairs(P, R); r = F_[0] / self.fl
|
||||
e = np.maximum(0, r / (1 + hi) - 1) + np.maximum(0, 1 - r / (1 - lo)); l = np.maximum(0, L_[0] / self.lu - 1)
|
||||
w = eps / (e + eps); wl = limit_w * eps / (l + eps)
|
||||
P = best[1]; U = P / np.linalg.norm(P, axis=1)[:, None]
|
||||
return R, U, self.arc_broken(P, R, lo, hi)
|
||||
|
||||
|
||||
def true_offsets(U, lat, lon, R):
|
||||
"""Distance of each solved city from its true place, after the orthogonal transformation that best
|
||||
lines the true positions up with the solved ones (used to place coastlines and to check the fit)."""
|
||||
p, l = np.radians(lat), np.radians(lon)
|
||||
T = np.c_[np.cos(p) * np.cos(l), np.cos(p) * np.sin(l), np.sin(p)]
|
||||
Us, _, Vt = np.linalg.svd(U.T @ T); Q = Us @ Vt
|
||||
return R * np.arccos(np.clip(((T @ Q.T) * U).sum(1), -1, 1))
|
||||
|
||||
+175
-28
File diff suppressed because one or more lines are too long
+173
-26
@@ -60,6 +60,7 @@ button{font:inherit;color:inherit}
|
||||
.btn{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 11px;font:500 12.5px var(--sans);cursor:pointer;color:var(--ink);white-space:nowrap}
|
||||
.btn.primary{border-color:rgba(62,230,255,.55)}
|
||||
.btn:disabled{opacity:.45;cursor:default}
|
||||
#arc[aria-pressed="true"]{border-color:var(--warn);color:var(--warn);background:rgba(255,201,74,.08)}
|
||||
.chk{display:flex;align-items:center;gap:5px;cursor:pointer;white-space:nowrap}
|
||||
.chk input{accent-color:var(--track);margin:0}
|
||||
.chk span{font-variant-numeric:tabular-nums;color:var(--faint)}
|
||||
@@ -213,17 +214,17 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
|
||||
</div>
|
||||
<aside class="panel side">
|
||||
<h2>Lift the rule</h2>
|
||||
<p class="note" style="color:var(--dim)">Same cities, same measurements, same solver. The points may now leave the plane; nothing tells them what shape to take. It starts from the flat map above with each city nudged a few kilometres up or down.</p>
|
||||
<div class="grp"><button type="button" class="btn primary" id="lift">Release into 3D</button><button type="button" class="btn" id="spin" aria-pressed="true">Turning</button></div>
|
||||
<p class="note" style="color:var(--dim)">Same cities, same measurements, same solver, without the plane.</p>
|
||||
<div class="grp"><button type="button" class="btn primary" id="lift">Release into 3D</button><button type="button" class="btn" id="spin" aria-pressed="true">Turning</button><button type="button" class="btn" id="arc" aria-pressed="false">Measure along a sphere</button></div>
|
||||
<dl class="ro">
|
||||
<div><dt>Broken</dt><dd id="r3b">–</dd></div>
|
||||
<div><dt>Stage</dt><dd id="r3s">–</dd></div>
|
||||
<div><dt>Wraps round its centre</dt><dd id="r3w">–</dd></div>
|
||||
<div><dt>Best-fit sphere radius</dt><dd id="r3r">–</dd></div>
|
||||
<div><dt>Median distance off it</dt><dd id="r3o">–</dd></div>
|
||||
<div><dt id="r3rL">Best-fit sphere radius</dt><dd id="r3r">–</dd></div>
|
||||
<div><dt id="r3oL">Median distance off it</dt><dd id="r3o">–</dd></div>
|
||||
<div><dt>Broken on the plane</dt><dd id="r3f">–</dd></div>
|
||||
</dl>
|
||||
<p class="note">The sphere is fitted after the solve to describe the shape and plays no part in it. Lengths may land anywhere inside their tolerance, so its radius is only loosely set.</p>
|
||||
<p class="note" id="note3"></p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -231,7 +232,7 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
|
||||
<h2>Result</h2>
|
||||
<p>Best of {{N_STARTS}} starting maps (North-pole and dual-pole AE, the layout of the flight times alone, and AE maps centred on {{N_STARTS_AE}} other points): <b>{{BEST_BROKEN}} measurements broken</b>. The plane keeps almost the whole northern network, but it cannot close the ring of routes around the southern oceans: {{LONG_LIST}}. Each of those needs its aircraft faster than it can fly. To keep the rest, the plane squeezes {{BEST_B80}} flights below 80% of their timed length; on the globe {{GLOBE_B80}} are.</p>
|
||||
<p>On the globe, by the same rules: {{GLOBE_LONG}} flight longer than its aircraft can fly (San Salvador–San José, a short hop where the 15-minute allowance for climb and descent is too generous), {{GLOBE_LIM}} limits broken, and {{GLOBE_SHORT}} flights below {{LO_PCT}}%: detours around closed airspace, indirect airways, and short hops where climb and approach take most of the time ({{GLOBE_SHORT_LIST}}). Surveyed triangles do not depend on the layout: a plane gives every triangle exactly 180°, and {{TRI_FLAT}} of the {{N_TRI}} measured triangles exceed that by more than four times their survey's angle noise ({{TRI_GLOBE}} on the globe; <a href="https://distance.aethercosmology.com/#triangles">The Triangle Test</a>).</p>
|
||||
<p>Released into 3D, the same cities and measurements close into a ball. The cities wrap {{WRAP3}}° round its centre, with {{BROKEN3}} measurements broken.</p>
|
||||
<p>Released into 3D, the same cities and measurements close into a ball. The cities wrap {{WRAP3}}° round its centre, with {{BROKEN3}} measurements broken. Measured along a sphere instead, with the shape conceded and its size left free (<i>Measure along a sphere</i>, above), the flights fit a sphere of radius {{ARC_R}} km with {{ARC_BROKEN}} measurements broken, and the cities fall a median {{ARC_OFF}} km from their true places. Earth's mean radius is 6,371 km; routes flown run slightly longer than the shortest path.</p>
|
||||
<h3>Wider tolerances</h3>
|
||||
<p>The further short a flight may fall, the more of the network the plane can squeeze to make room for the southern routes. It keeps everything once flights may fall to {{FIT_LO}}% of their timed length, which means routes up to twice as long as its straight lines. That layout has {{FIT_B75}} flights below 75% and {{FIT_B60}} below 60%. The globe has {{GLOBE_B75}} and {{GLOBE_B60}}.</p>
|
||||
<div class="tw"><table class="res" id="bands"><thead><tr><th scope="col">Flights kept between</th><th scope="col">Plane: too long</th><th scope="col">Plane: too short</th><th scope="col">Plane: limits</th><th scope="col">Plane: below 75%</th><th scope="col">Globe: too long</th><th scope="col">Globe: too short</th><th scope="col">Globe: limits</th></tr></thead><tbody></tbody></table></div>
|
||||
@@ -244,6 +245,7 @@ footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
|
||||
<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 {{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 <a href="https://pingstats.aethercosmology.com/">pingstats</a>. {{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.</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 {{N_STARTS}} 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. 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>Along a sphere</dt><dd>Optional, and it assumes the shape. The flat layout is laid on a spherical cap whose radius is a third of the layout's width; the cities are then held on a sphere and each distance is measured along its surface (radius × central angle, the haversine distance). The first stage fits the radius with the cities; the later stages hold it. Land is placed by the rotation or reflection that best lines the cities' true positions up with the solved ones (orthogonal Procrustes); the distance of each city from its true place is measured after it.</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>
|
||||
@@ -316,7 +318,7 @@ function makeSolver(dim, X0) {
|
||||
};
|
||||
sv.G = new Float64Array(n); sv.F = sv.evalF(sv.X, sv.G);
|
||||
sv.Xn = new Float64Array(n); sv.Gn = new Float64Array(n); sv.d = new Float64Array(n);
|
||||
sv.stall = 0;
|
||||
sv.stall = 0; sv.dist = (X, a, b) => dist(X, dim, a, b);
|
||||
return sv;
|
||||
}
|
||||
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
|
||||
@@ -350,8 +352,8 @@ function lbfgsStep(sv) {
|
||||
}
|
||||
function excess(sv) {
|
||||
const { dim, X } = sv; const e = new Float64Array(act.f.length), l = new Float64Array(act.l.length);
|
||||
for (let k = 0; k < act.fa.length; k++) { const r = dist(X, dim, act.fa[k], act.fb[k]) / act.fL[k]; e[k] = Math.max(0, r / (1 + S.hi) - 1) + Math.max(0, 1 - r / (1 - S.lo)); }
|
||||
for (let k = 0; k < act.la.length; k++) l[k] = Math.max(0, dist(X, dim, act.la[k], act.lb[k]) / act.lU[k] - 1);
|
||||
for (let k = 0; k < act.fa.length; k++) { const r = sv.dist(X, act.fa[k], act.fb[k]) / act.fL[k]; e[k] = Math.max(0, r / (1 + S.hi) - 1) + Math.max(0, 1 - r / (1 - S.lo)); }
|
||||
for (let k = 0; k < act.la.length; k++) l[k] = Math.max(0, sv.dist(X, act.la[k], act.lb[k]) / act.lU[k] - 1);
|
||||
return { e, l };
|
||||
}
|
||||
function nBroken(sv) { const { e, l } = excess(sv); let k = 0; for (const v of e) if (v > 0) k++; for (const v of l) if (v > 5e-4) k++; return k; }
|
||||
@@ -359,6 +361,7 @@ function nextStage(sv) {
|
||||
const k = nBroken(sv);
|
||||
if (k < sv.bestK) { sv.bestK = k; sv.best = Float64Array.from(sv.X); }
|
||||
if (sv.stage >= ROUNDS) { sv.done = true; if (sv.best) sv.X.set(sv.best); sv.F = sv.evalF(sv.X, sv.G); return; }
|
||||
if (sv.arc && sv.stage === 0) sv.Rfit = sv.X[NR];
|
||||
if (sv.stage > 0) { const { e, l } = excess(sv); for (let i = 0; i < e.length; i++) sv.w[i] = EPS / (e[i] + EPS); for (let i = 0; i < l.length; i++) sv.wl[i] = LIMIT_W * EPS / (l[i] + EPS); }
|
||||
sv.stage++; sv.it = 0; sv.mem.length = 0; sv.stall = 0; sv.F = sv.evalF(sv.X, sv.G);
|
||||
}
|
||||
@@ -372,6 +375,81 @@ function work(sv, ms, maxIt) {
|
||||
function dist(X, dim, a, b) { let s = 0; for (let q = 0; q < dim; q++) { const t = X[a * dim + q] - X[b * dim + q]; s += t * t; } return Math.sqrt(s); }
|
||||
const STAGE = s => s === 0 ? "fitting lengths" : `round ${s} of ${ROUNDS}`;
|
||||
|
||||
/* ---------------- measured along a sphere (optional; assumes the shape, leaves its size free) ---------------- */
|
||||
const NR = 3 * N; // index of the radius among an along-the-sphere solver's variables
|
||||
let aU0 = 0, aU1 = 0, aU2 = 0, aV0 = 0, aV1 = 0, aV2 = 0, aC = 0, aS = 1, aNa = 1, aNb = 1;
|
||||
function arcGeom(X, a, b) { // central angle between cities a and b; keeps what its gradient needs
|
||||
const ax = X[3 * a], ay = X[3 * a + 1], az = X[3 * a + 2], bx = X[3 * b], by = X[3 * b + 1], bz = X[3 * b + 2];
|
||||
aNa = Math.sqrt(ax * ax + ay * ay + az * az); aNb = Math.sqrt(bx * bx + by * by + bz * bz);
|
||||
aU0 = ax / aNa; aU1 = ay / aNa; aU2 = az / aNa; aV0 = bx / aNb; aV1 = by / aNb; aV2 = bz / aNb;
|
||||
aC = aU0 * aV0 + aU1 * aV1 + aU2 * aV2;
|
||||
const x = aU1 * aV2 - aU2 * aV1, y = aU2 * aV0 - aU0 * aV2, z = aU0 * aV1 - aU1 * aV0, s = Math.sqrt(x * x + y * y + z * z);
|
||||
aS = Math.max(s, 1e-9); return Math.atan2(s, aC);
|
||||
}
|
||||
function arcGrad(G, a, b, g) { // adds g x d(angle)/d(position) for the pair last passed to arcGeom
|
||||
const ka = -g / (aS * aNa), kb = -g / (aS * aNb);
|
||||
G[3 * a] += ka * (aV0 - aC * aU0); G[3 * a + 1] += ka * (aV1 - aC * aU1); G[3 * a + 2] += ka * (aV2 - aC * aU2);
|
||||
G[3 * b] += kb * (aU0 - aC * aV0); G[3 * b + 1] += kb * (aU1 - aC * aV1); G[3 * b + 2] += kb * (aU2 - aC * aV2);
|
||||
}
|
||||
const arcDist = (X, a, b) => X[NR] * arcGeom(X, a, b);
|
||||
function makeArcSolver(X2) { // the flat layout laid on a spherical cap whose radius is a third of the layout's width
|
||||
let mx = 0, my = 0, x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
||||
for (let i = 0; i < N; i++) { const x = X2[2 * i], y = X2[2 * i + 1]; mx += x / N; my += y / N; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
|
||||
const R0 = Math.max(x1 - x0, y1 - y0) / 3, n = NR + 1, X0 = new Float64Array(n);
|
||||
for (let i = 0; i < N; i++) { X0[3 * i] = X2[2 * i] - mx; X0[3 * i + 1] = X2[2 * i + 1] - my; X0[3 * i + 2] = R0; }
|
||||
X0[NR] = R0;
|
||||
const sv = { dim: 3, arc: true, X: 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: [], done: false, best: null, bestK: Infinity, acc: 0, Rfit: null, stall: 0 };
|
||||
sv.evalF = (X, G) => {
|
||||
G.fill(0); let F = 0, gR = 0; const R = X[NR]; const { fa, fb, fL, la, lb, lU } = act; const lo = S.lo, hi = S.hi;
|
||||
for (let k = 0; k < fa.length; k++) {
|
||||
const th = arcGeom(X, fa[k], fb[k]), dd = R * th; let ga;
|
||||
if (sv.stage === 0) { const e = dd / fL[k] - 1; F += e * e; ga = 2 * e / fL[k]; }
|
||||
else {
|
||||
const A = fL[k] * (1 - lo + MARGIN), B = fL[k] * (1 + hi - MARGIN);
|
||||
const eh = Math.max(0, dd / B - 1), el = Math.max(0, 1 - dd / A);
|
||||
const sh = Math.sqrt(eh * eh + DELTA * DELTA), sl = Math.sqrt(el * el + DELTA * DELTA);
|
||||
F += sv.w[k] * (sh + sl - 2 * DELTA); ga = sv.w[k] * (eh / sh / B - el / sl / A);
|
||||
}
|
||||
arcGrad(G, fa[k], fb[k], ga * R); gR += ga * th;
|
||||
}
|
||||
for (let k = 0; k < la.length; k++) {
|
||||
const th = arcGeom(X, la[k], lb[k]), dd = R * th; let gl;
|
||||
if (sv.stage === 0) { const ex = Math.max(0, dd / lU[k] - 1); if (!ex) continue; F += 100 * ex * ex; gl = 200 * ex / lU[k]; }
|
||||
else {
|
||||
const ub = lU[k] * (1 - MARGIN), ex = Math.max(0, dd / ub - 1); if (!ex) continue;
|
||||
const sx = Math.sqrt(ex * ex + DELTA * DELTA); F += sv.wl[k] * (sx - DELTA); gl = sv.wl[k] * ex / sx / ub;
|
||||
}
|
||||
arcGrad(G, la[k], lb[k], gl * R); gR += gl * th;
|
||||
}
|
||||
G[NR] = sv.stage === 0 ? gR : 0; // the radius is fitted in the first stage and held after it
|
||||
return F;
|
||||
};
|
||||
sv.dist = arcDist;
|
||||
sv.G = new Float64Array(n); sv.F = sv.evalF(sv.X, sv.G);
|
||||
sv.Xn = new Float64Array(n); sv.Gn = new Float64Array(n); sv.d = new Float64Array(n);
|
||||
return sv;
|
||||
}
|
||||
// land and true city positions as unit vectors; Q turns true directions onto solved ones
|
||||
const unitLL = (lat, lon) => { const p = lat * D2R, l = lon * D2R; return [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)]; };
|
||||
const TRUE_U = D.cities.map(c => unitLL(c[1], c[2])), DOT_U = D.dots.map(d => unitLL(d[0], d[1]));
|
||||
function invT(M) { // inverse transpose of a 3 x 3 (row-major): cofactors over the determinant
|
||||
const [a, b, c, d, e, f, g, h, i] = M;
|
||||
const C = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g, b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
|
||||
const det = a * C[0] + b * C[1] + c * C[2]; return Math.abs(det) < 1e-12 ? null : C.map(v => v / det);
|
||||
}
|
||||
function bestTurn(X) { // rotation or reflection Q minimising the distance from Q·true to solved (orthogonal Procrustes, polar factor)
|
||||
let H = [0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const x = X[3 * i], y = X[3 * i + 1], z = X[3 * i + 2], n = Math.hypot(x, y, z), u = [x / n, y / n, z / n], t = TRUE_U[i];
|
||||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) H[3 * r + c] += u[r] * t[c] / N;
|
||||
}
|
||||
for (let k = 0; k < 40; k++) { const T = invT(H); if (!T) break; H = H.map((v, j) => (v + T[j]) / 2); }
|
||||
return H;
|
||||
}
|
||||
const mul = (Q, v) => [Q[0] * v[0] + Q[1] * v[1] + Q[2] * v[2], Q[3] * v[0] + Q[4] * v[1] + Q[5] * v[2], Q[6] * v[0] + Q[7] * v[1] + Q[8] * v[2]];
|
||||
const mulT = (Q, v) => [Q[0] * v[0] + Q[3] * v[1] + Q[6] * v[2], Q[1] * v[0] + Q[4] * v[1] + Q[7] * v[2], Q[2] * v[0] + Q[5] * v[1] + Q[8] * v[2]];
|
||||
|
||||
/* ---------------- maps and rubber-sheet coastlines ---------------- */
|
||||
function aeq(lat, lon, lat0, lon0) {
|
||||
const p1 = lat * D2R, l1 = lon * D2R, p0 = lat0 * D2R, l0 = lon0 * D2R;
|
||||
@@ -438,7 +516,26 @@ function buildSheet(centre, srcX) {
|
||||
for (let j = 0; j < n; j++) { const dx = x - sx[j], dy = y - sy[j]; K[p * m + j] = tpsU(dx * dx + dy * dy); }
|
||||
K[p * m + n] = 1; K[p * m + n + 1] = x; K[p * m + n + 2] = y;
|
||||
}
|
||||
SHEET = { Ainv, K, M, m, rings, near, out2: new Float32Array(M * 2), out3: new Float32Array(M * 3) };
|
||||
// land dots for the 3D view, within 1,600 km of a city on the starting map
|
||||
const di = [], dk = [];
|
||||
D.dots.forEach(([lat, lon], j) => {
|
||||
const p = aeq(lat, lon, centre[0], centre[1]); if (p[2] > 168 * D2R) return;
|
||||
const x = p[0] / U, y = p[1] / U; let dmin = Infinity; for (let i = 0; i < n; i++) dmin = Math.min(dmin, (x - sx[i]) ** 2 + (y - sy[i]) ** 2);
|
||||
if (dmin < 1.6 * 1.6) { di.push(j); dk.push(x, y); }
|
||||
});
|
||||
const MD = di.length, KD = new Float32Array(MD * m);
|
||||
for (let p = 0; p < MD; p++) {
|
||||
const x = dk[2 * p], y = dk[2 * p + 1];
|
||||
for (let j = 0; j < n; j++) { const dx = x - sx[j], dy = y - sy[j]; KD[p * m + j] = tpsU(dx * dx + dy * dy); }
|
||||
KD[p * m + n] = 1; KD[p * m + n + 1] = x; KD[p * m + n + 2] = y;
|
||||
}
|
||||
SHEET = { Ainv, K, M, m, rings, near, out2: new Float32Array(M * 2), out3: new Float32Array(M * 3), KD, MD, outD: new Float32Array(MD * 3) };
|
||||
}
|
||||
function warpDots(X) { // the same spline, at the land dots, in 3D
|
||||
const { Ainv, KD, MD, m } = SHEET; const W = new Float64Array(m * 3);
|
||||
for (let r = 0; r < m; r++) for (let q = 0; q < 3; q++) { let s = 0; for (let j = 0; j < N; j++) s += Ainv[r * m + j] * X[j * 3 + q]; W[r * 3 + q] = s; }
|
||||
for (let p = 0; p < MD; p++) for (let q = 0; q < 3; q++) { let s = 0; const o = p * m; for (let r = 0; r < m; r++) s += KD[o + r] * W[r * 3 + q]; SHEET.outD[p * 3 + q] = s; }
|
||||
return SHEET.outD;
|
||||
}
|
||||
function warp(X, dim) { // thin-plate spline through (source -> X), evaluated at every coastline point
|
||||
const { Ainv, K, M, m } = SHEET; const n = N; const W = new Float64Array(m * dim);
|
||||
@@ -486,14 +583,14 @@ function continueSolve() { // Solve pressed on a finished layout: solve again
|
||||
}
|
||||
|
||||
/* ---------------- scoring ---------------- */
|
||||
function score(X, dim) {
|
||||
function score(X, dim, d = (a, b) => dist(X, dim, a, b)) {
|
||||
const out = { long: [], short: [], rt: [], rr: [], sat: [], ratio: new Float64Array(FL.length).fill(NaN), lim: new Map() };
|
||||
if (S.fl) for (const f of FL) {
|
||||
const r = dist(X, dim, f.a, f.b) / f.L; out.ratio[f.i] = r;
|
||||
const r = d(f.a, f.b) / f.L; out.ratio[f.i] = r;
|
||||
if (r > 1 + S.hi) out.long.push([f, r]); else if (r < 1 - S.lo) out.short.push([f, r]);
|
||||
}
|
||||
for (const l of act.l) {
|
||||
const U = l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U; const r = dist(X, dim, l.a, l.b) / U;
|
||||
const U = l.kind === "rt" && S.vac ? l.U * 1.4682 : l.U; const r = d(l.a, l.b) / U;
|
||||
if (r > 1.0005) { const k = l.kind === "rt" ? "rt" : l.kind === "sat" ? "sat" : "rr"; out[k].push([l, r]); out.lim.set(l.i, r); }
|
||||
}
|
||||
return out;
|
||||
@@ -721,7 +818,7 @@ $("#dl").addEventListener("click", () => {
|
||||
|
||||
/* ---------------- 3D ---------------- */
|
||||
const c3 = $("#c3"), g3 = c3.getContext("2d");
|
||||
let W3 = 0, H3 = 0, s3 = null, yaw = 0.4, pitch = -0.5, spin = true, coast3 = null, coast3At = -1, sph = null, flatWrap = null;
|
||||
let W3 = 0, H3 = 0, s3 = null, yaw = 0.4, pitch = -0.5, spin = true, dots3 = null, dots3At = -1, sph = null, ARC = false, Q3 = null, Q3At = -1, off3 = null;
|
||||
function sphereFit(X) { // least-squares sphere through the points; reach = angle from mean direction to farthest point
|
||||
const A = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], B = [0, 0, 0, 0];
|
||||
for (let i = 0; i < N; i++) { const x = X[3 * i], y = X[3 * i + 1], z = X[3 * i + 2]; const row = [2 * x, 2 * y, 2 * z, 1], rhs = x * x + y * y + z * z; for (let p = 0; p < 4; p++) { B[p] += row[p] * rhs; for (let q = 0; q < 4; q++) A[p][q] += row[p] * row[q]; } }
|
||||
@@ -735,10 +832,25 @@ function sphereFit(X) { // least-squares sphere through the points; reach = an
|
||||
off.sort((p, q) => p - q); return { rad, off: off[N >> 1], wrap: wrap / D2R, c };
|
||||
}
|
||||
function lift() {
|
||||
if (ARC) { s3 = makeArcSolver(sv.X); yaw = -1.92; pitch = 0.35; } // facing Europe and Africa, north tilted towards the viewer
|
||||
else {
|
||||
const X0 = new Float64Array(N * 3); let seed = 7;
|
||||
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
|
||||
for (let i = 0; i < N; i++) { const u = Math.max(1e-9, rnd()), v = rnd(); X0[3 * i] = sv.X[2 * i]; X0[3 * i + 1] = sv.X[2 * i + 1]; X0[3 * i + 2] = 50 * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); }
|
||||
s3 = makeSolver(3, X0); s3.flatBroken = SC.long.length + SC.short.length + SC.rt.length + SC.rr.length + SC.sat.length; coast3At = -1; $("#lift").textContent = "Release again";
|
||||
s3 = makeSolver(3, X0);
|
||||
}
|
||||
s3.flatBroken = SC.long.length + SC.short.length + SC.rt.length + SC.rr.length + SC.sat.length;
|
||||
dots3At = -1; Q3At = -1; off3 = null; sph = null; $("#lift").textContent = "Release again";
|
||||
}
|
||||
const NOTE3 = {
|
||||
free: "No shape is assumed. The cities start from the flat map above, each nudged a few kilometres up or down, and each distance is a straight line through space. The dashed sphere is fitted afterwards to describe the result and plays no part in it. A long flight's straight line passes beneath the surface it follows, so the shape comes out larger and more open than the surface the flights describe.",
|
||||
arc: "Assumes a sphere. The flat map above is laid on a spherical cap (radius a third of the map's width), the cities are held on a sphere of free radius, and each distance is measured along its surface with the haversine formula. It shows what size of sphere the flights describe and how well they fit it, not that the surface is a sphere. Land is placed by the rotation that best lines the cities' true positions up with the solved ones."
|
||||
};
|
||||
function mode3() {
|
||||
$("#note3").textContent = NOTE3[ARC ? "arc" : "free"];
|
||||
$("#r3rL").textContent = ARC ? "Radius that fits the flights best" : "Best-fit sphere radius";
|
||||
$("#r3oL").textContent = ARC ? "Cities from their true place (median)" : "Median distance off it";
|
||||
for (const id of ["#r3w", "#r3r", "#r3o"]) $(id).textContent = "–";
|
||||
}
|
||||
function rot(x, y, z) { // yaw about vertical, then pitch
|
||||
const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
|
||||
@@ -753,21 +865,15 @@ function draw3() {
|
||||
let rmax = 1; for (let i = 0; i < N; i++) rmax = Math.max(rmax, Math.hypot(X[3 * i] - c[0], X[3 * i + 1] - c[1], X[3 * i + 2] - c[2]));
|
||||
const sc = Math.min(W3, H3) * 0.44 / rmax;
|
||||
const pj = (x, y, z) => { const r = rot(x - c[0], y - c[1], z - c[2]); return [W3 / 2 + r[0] * sc, H3 / 2 - r[1] * sc, r[2]]; };
|
||||
if (coast3At !== s3.total) { coast3 = warp(X, 3); coast3At = s3.total; }
|
||||
if (s3.arc) return drawArc(X);
|
||||
if (dots3At !== s3.total) { dots3 = warpDots(X); dots3At = s3.total; }
|
||||
if (s3.done && sph) { // the sphere fitted after the solve, drawn for reference only
|
||||
const q = pj(...sph.c); g3.beginPath(); g3.arc(q[0], q[1], sph.rad * sc, 0, 7); g3.fillStyle = "rgba(62,230,255,.035)"; g3.fill();
|
||||
g3.setLineDash([3, 5]); g3.strokeStyle = "rgba(62,230,255,.35)"; g3.lineWidth = 1; g3.stroke(); g3.setLineDash([]);
|
||||
}
|
||||
g3.lineWidth = 0.9;
|
||||
for (const r of SHEET.rings) { // land within reach of the cities; far coasts would only be extrapolated
|
||||
if (!r.fill) continue;
|
||||
let prev = null, pn = 0;
|
||||
for (const p of r.idx) {
|
||||
const q = pj(coast3[3 * p], coast3[3 * p + 1], coast3[3 * p + 2]);
|
||||
if (prev && pn && SHEET.near[p]) { g3.strokeStyle = (q[2] + prev[2]) / 2 > 0 ? "rgba(120,170,230,.95)" : "rgba(58,90,128,.3)"; g3.beginPath(); g3.moveTo(prev[0], prev[1]); g3.lineTo(q[0], q[1]); g3.stroke(); }
|
||||
prev = q; pn = SHEET.near[p];
|
||||
}
|
||||
}
|
||||
const fr = new Path2D(), bk = new Path2D(); // land within reach of the cities; farther land would only be extrapolated
|
||||
for (let p = 0; p < SHEET.MD; p++) { const q = pj(dots3[3 * p], dots3[3 * p + 1], dots3[3 * p + 2]); (q[2] > 0 ? fr : bk).rect(q[0] - 0.9, q[1] - 0.9, 1.8, 1.8); }
|
||||
g3.fillStyle = "rgba(58,90,128,.35)"; g3.fill(bk); g3.fillStyle = "rgba(120,170,230,.7)"; g3.fill(fr);
|
||||
const sc3 = score(X, 3);
|
||||
g3.lineWidth = 1.6;
|
||||
const L3 = (a, b, col, dash) => { const p = pj(X[3 * a], X[3 * a + 1], X[3 * a + 2]), q = pj(X[3 * b], X[3 * b + 1], X[3 * b + 2]); g3.setLineDash(dash || []); g3.strokeStyle = col; g3.beginPath(); g3.moveTo(p[0], p[1]); g3.lineTo(q[0], q[1]); g3.stroke(); };
|
||||
@@ -786,6 +892,47 @@ c3.addEventListener("pointerdown", e => { const d = { x: e.clientX, y: e.clientY
|
||||
const mv = ev => { yaw = d.yaw + (ev.clientX - d.x) * 0.008; pitch = Math.max(-1.5, Math.min(1.5, d.pitch + (ev.clientY - d.y) * 0.008)); };
|
||||
const up = () => { c3.removeEventListener("pointermove", mv); c3.removeEventListener("pointerup", up); }; c3.addEventListener("pointermove", mv); c3.addEventListener("pointerup", up); });
|
||||
$("#lift").addEventListener("click", lift);
|
||||
$("#arc").addEventListener("click", e => { ARC = !ARC; e.currentTarget.setAttribute("aria-pressed", String(ARC)); e.currentTarget.textContent = ARC ? "Measuring along a sphere" : "Measure along a sphere"; mode3(); if (s3) lift(); });
|
||||
mode3();
|
||||
function drawArc(X) { // the cities on their sphere, turned so that their true positions line up (north up)
|
||||
const R = X[NR], rs = Math.min(W3, H3) * 0.42, cx = W3 / 2, cy = H3 / 2;
|
||||
if (Q3At !== s3.total) { Q3 = bestTurn(X); Q3At = s3.total; }
|
||||
const view = v => { const r = rot(v[0], v[2], -v[1]); return [cx + r[0] * rs, cy - r[1] * rs, r[2]]; }; // true frame, z up
|
||||
const city = i => { const x = X[3 * i], y = X[3 * i + 1], z = X[3 * i + 2], n = Math.hypot(x, y, z); return mulT(Q3, [x / n, y / n, z / n]); };
|
||||
const gr = g3.createRadialGradient(cx - rs * 0.35, cy - rs * 0.35, rs * 0.1, cx, cy, rs);
|
||||
gr.addColorStop(0, "#10264a"); gr.addColorStop(1, "#060f20");
|
||||
g3.beginPath(); g3.arc(cx, cy, rs, 0, 2 * Math.PI); g3.fillStyle = gr; g3.fill(); g3.strokeStyle = "rgba(62,230,255,.35)"; g3.lineWidth = 1; g3.stroke();
|
||||
const land = new Path2D();
|
||||
for (const t of DOT_U) { const q = view(t); if (q[2] > 0) land.rect(q[0] - 1, q[1] - 1, 2, 2); }
|
||||
g3.fillStyle = "rgba(120,170,230,.55)"; g3.fill(land);
|
||||
const sc3 = score(X, 3, (a, b) => arcDist(X, a, b));
|
||||
const arcLine = (a, b, col, dash) => { // along the surface, front half solid, back half faint
|
||||
const u = city(a), v = city(b), c = Math.max(-1, Math.min(1, u[0] * v[0] + u[1] * v[1] + u[2] * v[2])), th = Math.acos(c), sn = Math.sin(th) || 1;
|
||||
g3.setLineDash(dash || []); g3.strokeStyle = col; let prev = null;
|
||||
for (let k = 0; k <= 32; k++) {
|
||||
const t = k / 32, wa = Math.sin((1 - t) * th) / sn, wb = Math.sin(t * th) / sn, q = view([wa * u[0] + wb * v[0], wa * u[1] + wb * v[1], wa * u[2] + wb * v[2]]);
|
||||
if (prev) { g3.globalAlpha = q[2] > 0 && prev[2] > 0 ? 1 : 0.2; g3.beginPath(); g3.moveTo(prev[0], prev[1]); g3.lineTo(q[0], q[1]); g3.stroke(); }
|
||||
prev = q;
|
||||
}
|
||||
g3.globalAlpha = 1; g3.setLineDash([]);
|
||||
};
|
||||
g3.lineWidth = 1.6;
|
||||
for (const [f] of sc3.short) arcLine(f.a, f.b, COL.warn);
|
||||
for (const k of ["rt", "rr", "sat"]) for (const [l] of sc3[k]) arcLine(l.a, l.b, COL.bad, [5, 4]);
|
||||
for (const [f] of sc3.long) arcLine(f.a, f.b, COL.bad);
|
||||
for (let i = 0; i < N; i++) { const q = view(city(i)); g3.beginPath(); g3.arc(q[0], q[1], 2.2, 0, 7); g3.fillStyle = q[2] > 0 ? "#fff" : "rgba(255,255,255,.18)"; g3.fill(); }
|
||||
const nb = sc3.long.length + sc3.short.length + sc3.rt.length + sc3.rr.length + sc3.sat.length;
|
||||
$("#r3b").textContent = fmt(nb); $("#r3s").textContent = s3.done ? "done" : STAGE(s3.stage);
|
||||
$("#r3f").textContent = fmt(s3.flatBroken);
|
||||
$("#r3r").textContent = fmt(s3.Rfit || R) + " km";
|
||||
if (s3.total % 10 === 0 || s3.done) { const P = new Float64Array(NR); for (let i = 0; i < N; i++) { const u = city(i); for (let q = 0; q < 3; q++) P[3 * i + q] = u[q] * R; } sph = sphereFit(P); }
|
||||
if (sph) $("#r3w").textContent = Math.round(sph.wrap) + "°";
|
||||
if (s3.done && !off3) { // each city's distance from its true place, after the turn
|
||||
const o = []; for (let i = 0; i < N; i++) { const u = city(i), t = TRUE_U[i]; o.push(R * Math.acos(Math.max(-1, Math.min(1, u[0] * t[0] + u[1] * t[1] + u[2] * t[2])))); }
|
||||
o.sort((p, q) => p - q); off3 = o[N >> 1];
|
||||
}
|
||||
$("#r3o").textContent = off3 ? fmt(off3) + " km" : "–";
|
||||
}
|
||||
$("#spin").addEventListener("click", e => { spin = !spin; e.target.setAttribute("aria-pressed", String(spin)); e.target.textContent = spin ? "Turning" : "Still"; });
|
||||
|
||||
/* ---------------- results table ---------------- */
|
||||
|
||||
@@ -97,6 +97,19 @@ console.log('3D', JSON.stringify(r3));
|
||||
if (!(parseInt(r3.w) > 120)) fail('3D release did not wrap round', r3.w);
|
||||
if (!(+r3.b.replace(/,/g, '') < +r3.f.replace(/,/g, ''))) fail('3D not better than the plane', r3);
|
||||
await p.locator('.lift').screenshot({ path: path.join(here, 'out/femap_3d.png') });
|
||||
// measured along a sphere (assumes the shape, leaves its size free)
|
||||
await p.click('#arc');
|
||||
await p.waitForFunction(() => document.getElementById('r3s').textContent === 'done', null, { timeout: 180000 });
|
||||
const ra = await p.evaluate(() => ({ b: +document.getElementById('r3b').textContent.replace(/,/g, ''), r: +document.getElementById('r3r').textContent.replace(/[^\d]/g, ''),
|
||||
o: +document.getElementById('r3o').textContent.replace(/[^\d]/g, ''), note: document.getElementById('note3').textContent }));
|
||||
console.log('Along a sphere', JSON.stringify({ b: ra.b, r: ra.r, o: ra.o }));
|
||||
if (!(ra.r > 6200 && ra.r < 6700)) fail('radius along a sphere out of range', ra.r);
|
||||
if (!(ra.b <= 5) || !(ra.o > 0 && ra.o < 1000)) fail('sphere fit poor', ra);
|
||||
if (!/haversine/.test(ra.note) || !/Assumes a sphere/.test(ra.note)) fail('sphere caveat missing', ra.note);
|
||||
await p.locator('.lift').screenshot({ path: path.join(here, 'out/femap_arc.png') });
|
||||
await p.click('#arc');
|
||||
await p.waitForFunction(() => document.getElementById('r3s').textContent === 'done', null, { timeout: 180000 });
|
||||
if (!/No shape is assumed/.test(await p.evaluate(() => document.getElementById('note3').textContent))) fail('free-mode note not restored');
|
||||
if (errs.length) fail('errors', errs.slice(0, 3));
|
||||
console.log(fails ? `femap: ${fails} FAILS` : 'femap OK');
|
||||
await b.close();
|
||||
|
||||
Reference in New Issue
Block a user