Files
omarchy/shell/plugins/panels/elsewhen/GlobeModel.js
T
David Heinemeier HanssonandClaude Opus 5.5 e7218e36f2 Move Elsewhen into Omarchy as omarchy.elsewhen (#13429)
* Move Elsewhen into Omarchy as omarchy.elsewhen

The world clock ships in the shell tree instead of its own package. A
migration renames existing bar entries with their settings and removes
the retired package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove Elsewhen's shelved Earth row, overlap band and sky tint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tidy Elsewhen's models, data helper and docs, and drop currency

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restructure Elsewhen's panel and globe on the shared shell components

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show Elsewhen's bar globe upright

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the shared city search for Elsewhen's globe jump bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep legacy Elsewhen entries single and retry offline geocodes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch Elsewhen's weather with curl instead of a Python helper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:15:00 +02:00

301 lines
10 KiB
JavaScript

.pragma library
// Orthographic globe math: projection, the day/night terminator, the moon,
// and label placement.
var DEG = Math.PI / 180
// Earth's obliquity, also the hero icon's lean.
var AXIAL_TILT = 23.44
// Every `keepEvery`th vertex of a flat [lon, lat, ...] ring, for drawing while
// the globe is scaled down. Rings at or under `minPoints` are returned whole.
function decimateRing(ring, keepEvery, minPoints) {
var step = keepEvery === undefined ? 2 : keepEvery
var floor = minPoints === undefined ? 8 : minPoints
var n = ring.length / 2
if (step < 2 || n <= floor) return ring
var out = []
for (var i = 0; i < n; i += step) out.push(ring[i * 2], ring[i * 2 + 1])
// End on the original last vertex, not a chord back to the start.
var lastI = (n - 1) * 2
if (out[out.length - 2] !== ring[lastI] || out[out.length - 1] !== ring[lastI + 1])
out.push(ring[lastI], ring[lastI + 1])
return out
}
// A drawn pixel size that follows the shell's UI scale, floored so hairlines
// do not drop out of the raster.
function scalePx(px, scale, minPx) {
var s = (typeof scale === "number" && isFinite(scale) && scale > 0) ? scale : 1
var n = px * s
var floor = (minPx === undefined) ? 1 : minPx
return n < floor ? floor : n
}
// Orthographic projection onto a disc of radius r seen from over (viewLat, spin).
function project(lat, lon, spin, viewLat, r) {
var phi = lat * DEG
var lam = (lon - spin) * DEG
var p0 = viewLat * DEG
var cosc = Math.sin(p0) * Math.sin(phi) + Math.cos(p0) * Math.cos(phi) * Math.cos(lam)
return {
x: r * Math.cos(phi) * Math.sin(lam),
y: -r * (Math.cos(p0) * Math.sin(phi) - Math.sin(p0) * Math.cos(phi) * Math.cos(lam)),
visible: cosc > 0,
cosc: cosc
}
}
// The point with the sun directly overhead, good to a fraction of a degree.
function subsolarPoint(ms) {
var d = new Date(ms)
var jd = ms / 86400000 + 2440587.5
var n = jd - 2451545.0
var L = (280.460 + 0.9856474 * n) % 360 // mean longitude
var g = ((357.528 + 0.9856003 * n) % 360) * DEG // mean anomaly
var lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG // ecliptic longitude
var eps = (AXIAL_TILT - 0.0000004 * n) * DEG // obliquity, slowly drifting
var decl = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG
// Equation of time, in minutes, then the subsolar meridian.
var alpha = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG
var eot = (L - alpha + 540) % 360 - 180 // degrees, wrapped to +-180
var utcHours = d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600
var lon = -15 * (utcHours - 12) - eot
lon = ((lon + 540) % 360) - 180
return { lat: decl, lon: lon }
}
// Degrees above the horizon, negative below it.
function solarElevation(lat, lon, sub) {
var cosz = Math.sin(lat * DEG) * Math.sin(sub.lat * DEG)
+ Math.cos(lat * DEG) * Math.cos(sub.lat * DEG) * Math.cos((lon - sub.lon) * DEG)
return Math.asin(Math.max(-1, Math.min(1, cosz))) / DEG
}
// -0.833 allows for refraction and the sun's disc, as sunrise tables do.
function isDaylight(lat, lon, sub) {
return solarElevation(lat, lon, sub) > -0.833
}
// The great circle 90 degrees from the subsolar point: the day/night line.
function terminator(sub, steps) {
var n = steps || 180
var out = []
var slat = sub.lat * DEG, slon = sub.lon * DEG
// Build an orthonormal frame around the subsolar axis and sweep a circle.
var s = [Math.cos(slat) * Math.cos(slon), Math.cos(slat) * Math.sin(slon), Math.sin(slat)]
var up = Math.abs(s[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0]
var a = norm(cross(up, s))
var b = norm(cross(s, a))
for (var i = 0; i <= n; i++) {
var t = i / n * 2 * Math.PI
var v = [a[0] * Math.cos(t) + b[0] * Math.sin(t),
a[1] * Math.cos(t) + b[1] * Math.sin(t),
a[2] * Math.cos(t) + b[2] * Math.sin(t)]
out.push([Math.asin(v[2]) / DEG, Math.atan2(v[1], v[0]) / DEG])
}
return out
}
function cross(u, v) {
return [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
}
function norm(v) {
var m = Math.hypot(v[0], v[1], v[2]) || 1
return [v[0] / m, v[1] / m, v[2] / m]
}
// Keep points, in priority order, that clear every kept point by minDist.
// Points marked `keep` always survive.
function declutter(points, minDist) {
var kept = []
for (var i = 0; i < points.length; i++) {
var p = points[i]
if (p.keep) { kept.push(p); continue }
var clash = false
for (var j = 0; j < kept.length; j++) {
if (Math.hypot(p.x - kept[j].x, p.y - kept[j].y) < minDist) { clash = true; break }
}
if (!clash) kept.push(p)
}
return kept
}
// ---- the moon
// The mean synodic month against a known new moon: good to a few hours.
var SYNODIC_MONTH = 29.530588853 // days
var KNOWN_NEW_MOON_JD = 2451550.1 // 2000-01-06 18:14 UTC
// Position through the lunation: 0 new, 0.25 first quarter, 0.5 full,
// 0.75 last quarter.
function moonPhase(ms) {
var jd = Number(ms) / 86400000 + 2440587.5
var p = ((jd - KNOWN_NEW_MOON_JD) / SYNODIC_MONTH) % 1
return p < 0 ? p + 1 : p
}
// The lit part of the moon on a disc of radius r at the origin: the limb, then
// the terminator back, a semicircle squashed by the signed cos(2*pi*phase).
function moonLitOutline(phase, r, steps) {
var n = steps || 24
var theta = 2 * Math.PI * Number(phase)
var squash = Math.cos(theta)
var side = Number(phase) > 0.5 ? -1 : 1 // waning lights the other limb
var out = []
var i, t
for (i = 0; i <= n; i++) {
t = Math.PI * i / n
out.push({ x: side * r * Math.sin(t), y: -r * Math.cos(t) })
}
for (i = n; i >= 0; i--) {
t = Math.PI * i / n
out.push({ x: side * r * squash * Math.sin(t), y: -r * Math.cos(t) })
}
return out
}
// ---- clipping to the disc
// Where a [lat, lon] segment crosses the horizon, by bisection.
function limbCrossing(a, b, spin, viewLat, r) {
// Segments spanning the antimeridian cannot be interpolated in lat/lon.
if (Math.abs(b[1] - a[1]) > 180) return null
var lo = 0, hi = 1
for (var i = 0; i < 8; i++) {
var m = (lo + hi) / 2
var p = project(a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, spin, viewLat, r)
if (p.visible) lo = m; else hi = m
}
return project(a[0] + (b[0] - a[0]) * lo, a[1] + (b[1] - a[1]) * lo, spin, viewLat, r)
}
// The near-side runs of a polyline, each ending exactly on the horizon.
function visibleSegments(pts, spin, viewLat, r) {
var out = [], run = [], prev = null, prevVis = false
function flush() { if (run.length > 1) out.push(run); run = [] }
for (var i = 0; i < pts.length; i++) {
var p = project(pts[i][0], pts[i][1], spin, viewLat, r)
if (p.visible) {
if (run.length === 0 && prev !== null && !prevVis) {
var enter = limbCrossing(pts[i], prev, spin, viewLat, r)
if (enter) run.push(enter)
}
run.push(p)
} else {
if (run.length > 0 && prev !== null) {
var exit = limbCrossing(prev, pts[i], spin, viewLat, r)
if (exit) run.push(exit)
}
flush()
}
prev = pts[i]; prevVis = p.visible
}
flush()
return out
}
// A flat [lon, lat, ...] ring clipped to the visible hemisphere as one polygon
// that follows the limb, so its area changes smoothly as the globe turns.
function clipRingToDisc(ring, spin, viewLat, r) {
var pts = []
for (var k = 0; k < ring.length; k += 2) pts.push([ring[k + 1], ring[k]])
var out = []
for (var i = 0; i < pts.length; i++) {
var A = pts[i], B = pts[(i + 1) % pts.length]
var pa = project(A[0], A[1], spin, viewLat, r)
var pb = project(B[0], B[1], spin, viewLat, r)
if (pa.visible && pb.visible) out.push({ p: pb, limb: false })
else if (pa.visible) {
var ex = limbCrossing(A, B, spin, viewLat, r)
if (ex) out.push({ p: ex, limb: true })
} else if (pb.visible) {
var en = limbCrossing(B, A, spin, viewLat, r)
if (en) out.push({ p: en, limb: true })
out.push({ p: pb, limb: false })
}
}
if (out.length < 3) return []
var res = []
for (var j = 0; j < out.length; j++) {
res.push(out[j].p)
var nx = out[(j + 1) % out.length]
if (!out[j].limb || !nx.limb) continue
var a0 = Math.atan2(out[j].p.y, out[j].p.x)
var a1 = Math.atan2(nx.p.y, nx.p.x)
var d = a1 - a0
while (d > Math.PI) d -= 2 * Math.PI
while (d < -Math.PI) d += 2 * Math.PI
var steps = Math.max(1, Math.round(Math.abs(d) / 0.15))
for (var t = 1; t < steps; t++) {
var a = a0 + d * t / steps
res.push({ x: r * Math.cos(a), y: r * Math.sin(a) })
}
}
return res
}
// Greedy label placement by rank, then nearest the disc center; a label that
// would pass `maxX` flips to the left of its dot. `gap` is dot to name.
function layoutLabels(candidates, charWidth, lineHeight, limit, maxX, gap) {
var g = (typeof gap === "number" && isFinite(gap) && gap > 0) ? gap : 6
var placed = []
var sorted = candidates.slice().sort(function (p, q) {
if (p.rank !== q.rank) return p.rank - q.rank
return q.cosc - p.cosc
})
for (var i = 0; i < sorted.length; i++) {
var c = sorted[i]
var w = c.name.length * charWidth
var x = c.x + g
if (maxX !== undefined && x + w > maxX) x = c.x - g - w
var box = { x: x, y: c.y - lineHeight / 2, w: w, h: lineHeight }
var clash = false
for (var j = 0; j < placed.length; j++) {
var o = placed[j].box
if (box.x < o.x + o.w && box.x + box.w > o.x && box.y < o.y + o.h && box.y + box.h > o.y) {
clash = true
break
}
}
if (clash) continue
placed.push({ index: c.index, box: box })
if (limit && placed.length >= limit) break
}
return placed
}
// Signed degrees in (-180, 180] to turn from one longitude to another.
function shortestTurn(from, to) {
var d = ((to - from) % 360 + 360) % 360
return d > 180 ? d - 360 : d
}
if (typeof module !== "undefined") {
module.exports = {
AXIAL_TILT: AXIAL_TILT,
SYNODIC_MONTH: SYNODIC_MONTH,
decimateRing: decimateRing,
scalePx: scalePx,
project: project,
subsolarPoint: subsolarPoint,
solarElevation: solarElevation,
isDaylight: isDaylight,
terminator: terminator,
declutter: declutter,
moonPhase: moonPhase,
moonLitOutline: moonLitOutline,
limbCrossing: limbCrossing,
visibleSegments: visibleSegments,
clipRingToDisc: clipRingToDisc,
layoutLabels: layoutLabels,
shortestTurn: shortestTurn
}
}