Live PRT dot map of Earth with every ortus unit

Static page: day/night lit dot map, PRT in ms/s/m/h/d/w, PRT day and
week progress, milestones, next equinox/solstice, Date <-> PRT converter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 21:08:59 +00:00
co-authored by Claude Opus 5.5
commit f017c684da
7 changed files with 430 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
import { PRT } from 'ortus'
import { R, LAND } from './land.js'
const $ = s => document.getElementById(s)
const { sin, cos, asin, atan2, max, min, floor, abs, PI } = Math
const D = PI / 180, TAU = 2 * PI
const E = PRT.toUnixMs(0)
const UNITS = { ms: 'milliseconds', s: 'seconds', m: 'minutes', h: 'hours', d: 'days', w: 'weeks' }
const nf = new Intl.NumberFormat('en-US')
const num = n => nf.format(n).replace(/,/g, '<i>,</i>')
const pad = n => String(n).padStart(2, '0')
const dur = t => {
const s = floor(t / 1e3), d = floor(s / 86400)
return (d ? d + 'd ' : '') + [floor(s / 3600) % 24, floor(s / 60) % 60, s % 60].map(pad).join(':')
}
const SHORT = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }
const LONG = { ...SHORT, second: '2-digit', timeZoneName: 'short' }
const loc = (t, o = SHORT) => new Date(t).toLocaleString(undefined, o)
const iso = t => new Date(t).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'
const set = (id, h) => { const e = $(id); e.innerHTML != h && (e.innerHTML = h) }
const ll = (v, p, n) => abs(v).toFixed(1) + '°' + (v < 0 ? n : p)
// Low-precision solar ephemeris (~0.01°): declination + subsolar longitude, radians
const sun = t => {
const d = t / 864e5 - 10957.5, g = (357.529 + .98560028 * d) * D, q = 280.459 + .98564736 * d
const L = (q + 1.915 * sin(g) + .02 * sin(2 * g)) * D, e = (23.439 - 3.6e-7 * d) * D
const ra = atan2(cos(e) * sin(L), cos(L)), gmst = (280.46061837 + 360.98564736629 * d) * D
return { dec: asin(sin(e) * sin(L)), lon: (((ra - gmst) % TAU) + 3 * PI) % TAU - PI, L: ((L / D) % 360 + 360) % 360 }
}
// Next equinox/solstice: Newton-step the ecliptic longitude onto the next multiple of 90°
const SEASONS = ['March equinox', 'June solstice', 'September equinox', 'December solstice']
const season = now => {
const q = floor(sun(now).L / 90 + 1e-9) + 1, g = q * 90 % 360
let t = now
for (let i = 0; i < 6; i++) t += (((g - sun(t).L) % 360 + 540) % 360 - 180 || 0) / .98565 * 864e5
t < now && (t += 365.2422 * 864e5 / 4)
return [SEASONS[q % 4], t]
}
const alt = (lat, lon, s) => asin(sin(lat) * sin(s.dec) + cos(lat) * cos(s.dec) * cos(lon - s.lon))
// Land mask: RLE rows, 90N → 90S
const mask = LAND.split(' ').map(r => {
const a = new Uint8Array(360 * R)
let i = 0, v = 0
for (const n of r.split('.')) a.fill(v, i, i += parseInt(n, 36)), v ^= 1
return a
})
const isLand = (lat, lon) => mask[min(180 * R - 1, max(0, floor((90 - lat) * R)))][floor((lon + 180) * R) % (360 * R)]
// Map
const LAT0 = 84, LAT1 = -58, NL = 16
const cv = $('map'), cx = cv.getContext('2d'), sunEl = $('sun'), tip = $('tip')
let W, H, dpr, dots = [], P
const mix = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t)
const ramp = (st, t) => {
const i = min(st.length - 2, floor(t * (st.length - 1)))
return mix(st[i], st[i + 1], t * (st.length - 1) - i)
}
const rgba = c => `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${c[3].toFixed(3)})`
const LANDC = [[34, 66, 160, .62], [44, 96, 215, .8], [70, 150, 255, .92], [150, 222, 255, 1]]
const SEAC = [[30, 60, 150, .1], [60, 130, 255, .2]]
const COL = [...Array(NL)].map((_, i) => rgba(ramp(SEAC, i / (NL - 1)))).concat([...Array(NL)].map((_, i) => rgba(ramp(LANDC, i / (NL - 1)))))
const smooth = (a, b, x) => (x = min(1, max(0, (x - a) / (b - a))), x * x * (3 - 2 * x))
const layout = () => {
W = cv.clientWidth, H = cv.clientHeight, dpr = min(devicePixelRatio || 1, 2)
cv.width = W * dpr, cv.height = H * dpr
P = max(4.2, W / 200)
const rh = P * .866
dots = []
for (let j = 0, y = rh / 2; y < H; y += rh, j++)
for (let x = j & 1 ? P : P / 2; x < W; x += P) {
const lat = LAT0 - y / H * (LAT0 - LAT1), lon = x / W * 360 - 180
dots.push({ x, y, s: sin(lat * D), c: cos(lat * D), l: lon * D, k: isLand(lat, lon) })
}
draw()
}
const draw = () => {
const s = sun(Date.now()), sd = sin(s.dec), cd = cos(s.dec)
const sx = (s.lon / D + 180) / 360 * W, sy = (LAT0 - s.dec / D) / (LAT0 - LAT1) * H
cx.setTransform(dpr, 0, 0, dpr, 0, 0)
cx.clearRect(0, 0, W, H)
for (const o of [-W, 0, W]) {
const g = cx.createRadialGradient(sx + o, sy, 0, sx + o, sy, W * .32)
g.addColorStop(0, 'rgba(60,140,255,.22)'), g.addColorStop(1, 'rgba(60,140,255,0)')
cx.fillStyle = g, cx.fillRect(0, 0, W, H)
}
const B = [...Array(2 * NL)].map(() => new Path2D())
for (const d of dots) {
const a = d.s * sd + d.c * cd * cos(d.l - s.lon)
const t = smooth(-.31, .1, a) * .7 + max(a, 0) * .3
const b = d.k * NL + min(NL - 1, floor(t * NL)), r = d.k ? P * (.27 + .11 * t) : P * .2
B[b].moveTo(d.x + r, d.y), B[b].arc(d.x, d.y, r, 0, TAU)
}
B.forEach((p, i) => (cx.fillStyle = COL[i], cx.fill(p)))
sunEl.style.left = sx / W * 100 + '%', sunEl.style.top = sy / H * 100 + '%'
}
cv.addEventListener('pointermove', e => {
const r = cv.getBoundingClientRect(), x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height
const lat = LAT0 - y * (LAT0 - LAT1), lon = x * 360 - 180, s = sun(Date.now())
const a = alt(lat * D, lon * D, s) / D, st = ((12 + (lon - s.lon / D) / 15) % 24 + 24) % 24
tip.hidden = false
tip.style.left = x * 100 + '%', tip.style.top = y * 100 + '%'
tip.classList.toggle('l', x > .7)
tip.innerHTML = `<b>${ll(lat, 'N', 'S')} ${ll(lon, 'E', 'W')}</b>${a > 0 ? 'sun ' + a.toFixed(0) + '° up' : a > -18 ? 'twilight' : 'night'} · solar ${pad(floor(st))}:${pad(floor(st % 1 * 60))}`
})
cv.addEventListener('pointerleave', () => tip.hidden = true)
new ResizeObserver(layout).observe(cv)
setInterval(draw, 5e3)
// Unit cards
const KS = Object.keys(UNITS).slice(1)
$('units').innerHTML = KS.map(u => `<button class="unit" data-u="${u}"><span class="ul">${UNITS[u]}<em>${u}</em></span><span class="uv" id="v${u}">–</span><span class="bar"><i id="b${u}"></i></span><span class="un" id="n${u}"></span></button>`).join('')
const last = {}
const toast = m => { const t = $('toast'); t.textContent = m, t.hidden = false, clearTimeout(toast.t), toast.t = setTimeout(() => t.hidden = true, 1400) }
const copy = v => navigator.clipboard?.writeText(String(v)).then(() => toast('Copied ' + nf.format(v)))
document.addEventListener('click', e => {
const u = e.target.closest('[data-u]')?.dataset.u
u && copy(PRT.now(u))
})
$('ms').addEventListener('click', () => copy(PRT.now()))
$('npm').addEventListener('click', () => navigator.clipboard?.writeText('npm i ortus').then(() => toast('Copied npm i ortus')))
// Static bits
$('ep').textContent = `A Saturday · ${loc(E, LONG)} local`
$('pips').innerHTML = '<i></i>'.repeat(7)
const pips = [...$('pips').children]
// Milestones: next round number per unit (2 significant digits), plus the trillion
const miles = now => {
const m = new Map(), add = (u, n) => {
const t = PRT.toUnixMs(n, u)
t > now && m.set(t, [...(m.get(t) || []), `${num(n)} <em>${u}</em>`])
}
for (const u in UNITS) {
const v = PRT.fromUnixMs(now, u), k = 10 ** max(1, floor(Math.log10(abs(v) || 1)) - 1)
add(u, (floor(v / k) + 1) * k)
}
add('ms', 1e12), add('s', 1e9), add('d', 11111)
return [...m].sort((a, b) => a[0] - b[0]).slice(0, 6)
}
const age = now => {
const at = (y, mo) => { const d = new Date(E); d.setUTCFullYear(d.getUTCFullYear() + y, d.getUTCMonth() + mo); return +d }
let y = 0, mo = 0
while (at(y + 1, 0) <= now) y++
while (at(y, mo + 1) <= now) mo++
const r = now - at(y, mo)
return `${y}y ${mo}mo ${floor(r / 864e5)}d ${dur(r % 864e5)}`
}
const slow = now => {
set('utc', iso(now))
set('loc', loc(now, LONG))
set('age', age(now))
set('unix', `Unix ${num(floor(now / 1e3))} s · epoch ${num(E)} ms`)
const d = PRT.fromUnixMs(now, 'd'), d0 = PRT.toUnixMs(d, 'd'), d1 = PRT.toUnixMs(d + 1, 'd')
set('day', `#${num(d)}`)
$('dayb').style.transform = `scaleX(${(now - d0) / (d1 - d0)})`
set('daym', `began ${loc(d0, { hour: '2-digit', minute: '2-digit' })} · ends in ${dur(d1 - now)}`)
const w = PRT.fromUnixMs(now, 'w'), wd = ((d % 7) + 7) % 7
set('wk', `#${num(w)} · day ${wd + 1}/7`)
set('wkm', `Weeks start Saturday 03:00 UTC · next in ${dur(PRT.toUnixMs(w + 1, 'w') - now)}`)
pips.forEach((p, i) => p.className = i < wd ? 'on' : i == wd ? 'cur' : '')
const s = sun(now)
set('ss', `${ll(s.dec / D, 'N', 'S')} ${ll(s.lon / D, 'E', 'W')}`)
const [sn, st] = season(now)
set('ssm', `Subsolar point · declination ${(s.dec / D).toFixed(2)}°<br>${sn} ≈ ${loc(st)} · in ${dur(st - now)}`)
set('miles', miles(now).map(([t, l]) => `<li><span class="mv">${l.join(' <s>=</s> ')}</span><span class="mt">${loc(t)}</span><span class="mc">in ${dur(t - now)}</span></li>`).join(''))
}
// Main loop
let sec
const tick = () => {
const now = Date.now()
const ms = PRT.fromUnixMs(now)
last.ms != ms && ($('ms').innerHTML = num(last.ms = ms))
for (const u of KS) {
const v = PRT.fromUnixMs(now, u), a = PRT.toUnixMs(v, u), b = PRT.toUnixMs(v + 1, u)
last[u] != v && ($('v' + u).innerHTML = num(last[u] = v))
$('b' + u).style.transform = `scaleX(${(now - a) / (b - a)})`
set('n' + u, u == 's' ? `next in ${((b - now) / 1e3).toFixed(1)}s` : `next in ${dur(b - now)}`)
}
floor(now / 1e3) != sec && slow(now, sec = floor(now / 1e3))
requestAnimationFrame(tick)
}
tick()
// Converter
$('pu').innerHTML = Object.entries(UNITS).map(([u, n]) => `<option value="${u}">${n}</option>`).join('')
const dl = rows => rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')
const err = e => `<dt>!</dt><dd class="err">${e.message}</dd>`
const toLocalInput = t => { const d = new Date(t - new Date(t).getTimezoneOffset() * 6e4); return d.toISOString().slice(0, 23) }
const conv = () => {
const v = $('cin').value
if (!v) return $('cout').innerHTML = ''
try {
const dt = new Date(v + ($('cz').checked ? 'Z' : ''))
$('cout').innerHTML = dl(Object.keys(UNITS).map(u => [u, num(PRT.fromDate(dt, u))]))
} catch (e) { $('cout').innerHTML = err(e) }
}
const pconv = () => {
try {
const n = Number($('pin').value.replace(/[\s,_]/g, '')), t = PRT.toUnixMs(n, $('pu').value)
$('pout').innerHTML = dl([['UTC', iso(t)], ['local', loc(t, LONG)], ['day', new Date(t).toLocaleDateString(undefined, { weekday: 'long' })], ['unix', num(t) + ' ms']])
} catch (e) { $('pout').innerHTML = err(e) }
}
const fill = () => $('cin').value = $('cz').checked ? new Date().toISOString().slice(0, 23) : toLocalInput(Date.now())
$('cnow').onclick = () => (fill(), conv())
$('cz').onchange = () => (fill(), conv())
$('cin').oninput = conv
$('pin').oninput = $('pu').onchange = pconv
fill(), conv(), pconv()