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
+13
View File
@@ -0,0 +1,13 @@
# time.planetrenox.com
Live **PRT (Planet Renox Time)**, milliseconds since `1998-03-14T03:00:00.000Z`, in every [ortus](https://github.com/planetrenox/ortus) unit (`ms s m h d w`), over a dot map of Earth lit by the real-time sun.
Also on the page: PRT day/week progress, upcoming round-number milestones, next equinox/solstice, a subsolar marker with hover readout (lat/lon, sun altitude, solar time), and a Date ⇄ PRT converter.
Static site, no build step. `ortus` loads from jsDelivr via an import map. Land dots come from Natural Earth 50m land, rasterized to a half-degree RLE mask in `land.js`.
```sh
python3 -m http.server # then open http://localhost:8000
```
Cloudflare Pages: no build command, output directory `/`.
+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()
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx=".35" cy=".35"><stop offset="0" stop-color="#c9efff"/><stop offset=".55" stop-color="#3d8bff"/><stop offset="1" stop-color="#0b2a7a"/></radialGradient><pattern id="d" width="4" height="4" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1.1" fill="#030815" fill-opacity=".55"/></pattern></defs><circle cx="16" cy="16" r="15" fill="url(#g)"/><circle cx="16" cy="16" r="15" fill="url(#d)"/></svg>

After

Width:  |  Height:  |  Size: 493 B

+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PRT — Planet Renox Time</title>
<meta name="description" content="Planet Renox Time, live: milliseconds since 1998-03-14 03:00:00 UTC, in every unit, over a day/night map of Earth.">
<meta name="theme-color" content="#030815">
<meta property="og:title" content="PRT — Planet Renox Time">
<meta property="og:description" content="Milliseconds since 1998-03-14 03:00:00 UTC, live.">
<meta property="og:image" content="https://time.planetrenox.com/og.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&family=Space+Grotesk:wght@400;500;600&display=swap">
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"ortus":"https://cdn.jsdelivr.net/npm/ortus@0.1/index.js"}}</script>
<script type="module" src="app.js"></script>
</head>
<body>
<header class="top wrap">
<a class="brand" href="/"><span class="orb"></span>PRT</a>
<nav><a href="https://www.npmjs.com/package/ortus">ortus ↗</a><a href="https://github.com/planetrenox/ortus">GitHub ↗</a></nav>
</header>
<main class="wrap" role="timer">
<section class="hero">
<h1>Planet Renox Time</h1>
<p class="sub">Milliseconds since <b>1998-03-14 03:00:00 UTC</b></p>
<div class="map">
<canvas id="map" aria-label="Dot map of Earth showing current daylight"></canvas>
<div id="sun" title="Subsolar point"></div>
<div id="tip" hidden></div>
</div>
<div class="now">
<div id="ms" class="big">–</div>
<div class="big-u">ms <span>official unit</span></div>
</div>
</section>
<section class="units" id="units"></section>
<section class="grid">
<article class="card"><h2>Epoch</h2><p class="v">1998-03-14 03:00:00 UTC</p><p class="m" id="ep"></p></article>
<article class="card"><h2>Now</h2><p class="v" id="utc">–</p><p class="m" id="loc"></p></article>
<article class="card"><h2>Elapsed</h2><p class="v" id="age">–</p><p class="m" id="unix"></p></article>
<article class="card"><h2>PRT day</h2><p class="v" id="day">–</p><div class="bar"><i id="dayb"></i></div><p class="m" id="daym"></p></article>
<article class="card"><h2>PRT week</h2><p class="v" id="wk">–</p><div class="pips" id="pips"></div><p class="m" id="wkm"></p></article>
<article class="card"><h2>Sun</h2><p class="v" id="ss">–</p><p class="m" id="ssm"></p></article>
<article class="card wide"><h2>Upcoming milestones</h2><ol id="miles"></ol></article>
<article class="card wide conv">
<h2>Convert</h2>
<div class="cv">
<div>
<label>Date → PRT <span class="tz"><input type="checkbox" id="cz"> UTC</span></label>
<div class="row"><input type="datetime-local" id="cin" step="0.001"><button id="cnow">now</button></div>
<dl id="cout"></dl>
</div>
<div>
<label>PRT → Date</label>
<div class="row"><input id="pin" inputmode="numeric" spellcheck="false" value="1000000000000"><select id="pu"></select></div>
<dl id="pout"></dl>
</div>
</div>
</article>
</section>
</main>
<footer class="wrap">
<p>Integers, floored toward −∞ · negative before the epoch · 1 d = 86,400,000 ms · 1 w = 7 d · leap seconds ignored</p>
<button class="npm" id="npm">npm i ortus</button>
</footer>
<div id="toast" hidden></div>
<noscript><p class="wrap">PRT needs JavaScript to tick.</p></noscript>
</body>
</html>
+2
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

+121
View File
@@ -0,0 +1,121 @@
:root {
--bg: #030815; --ink: #e4eeff; --mute: #7b8fb8; --dim: #3d5180;
--cyan: #7fd6ff; --blue: #3d8bff; --line: rgba(110, 160, 255, .13);
--card: linear-gradient(160deg, rgba(30, 60, 140, .22), rgba(10, 22, 55, .35));
--mono: 'JetBrains Mono', ui-monospace, monospace;
--sans: 'Space Grotesk', system-ui, sans-serif;
}
* { box-sizing: border-box; margin: 0 }
html { background: var(--bg); color: var(--ink); font: 15px/1.5 var(--sans); -webkit-font-smoothing: antialiased }
body {
min-height: 100vh;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(40, 100, 255, .18), transparent 70%),
radial-gradient(rgba(120, 170, 255, .07) 1px, transparent 1.5px) 0 0 / 22px 22px;
}
a { color: inherit; text-decoration: none }
button, input, select { font: inherit; color: inherit }
.wrap { width: min(1240px, 100% - 40px); margin-inline: auto }
.top { display: flex; align-items: center; justify-content: space-between; padding: 22px 0 }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: .14em }
.orb { width: 14px; height: 14px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #c9efff, var(--blue) 55%, #0b2a7a); box-shadow: 0 0 14px var(--blue) }
nav { display: flex; gap: 22px; color: var(--mute); font-size: 14px }
nav a:hover { color: var(--cyan) }
.hero { text-align: center; padding-top: 18px }
h1 { font-weight: 500; font-size: clamp(28px, 4.4vw, 48px); letter-spacing: -.02em; background: linear-gradient(90deg, #cfe9ff, #7fb6ff 60%, #5d8cff); -webkit-background-clip: text; background-clip: text; color: transparent }
.sub { color: var(--mute); margin-top: 6px }
.sub b { color: var(--ink); font-weight: 500; font-family: var(--mono); font-size: .92em }
.map {
position: relative; margin: 34px 0 0;
--fy: linear-gradient(transparent, #000 6%, #000 88%, transparent);
--fx: linear-gradient(90deg, transparent, #000 3%, #000 97%, transparent);
-webkit-mask: var(--fy), var(--fx); -webkit-mask-composite: source-in;
mask: var(--fy), var(--fx); mask-composite: intersect;
}
#map { display: block; width: 100%; aspect-ratio: 360 / 142; cursor: crosshair; touch-action: pan-y }
#sun { position: absolute; width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; background: #fff; box-shadow: 0 0 10px 3px #bfe8ff, 0 0 40px 12px rgba(80, 170, 255, .55); pointer-events: none }
#sun::after { content: ''; position: absolute; inset: -5px; border-radius: 50%; border: 1px solid rgba(180, 225, 255, .8); animation: ping 2.6s ease-out infinite }
@keyframes ping { to { inset: -26px; opacity: 0 } }
#tip { position: absolute; transform: translate(14px, -50%); padding: 7px 10px; border-radius: 8px; background: rgba(6, 16, 42, .88); border: 1px solid var(--line); backdrop-filter: blur(8px); font: 12px/1.45 var(--mono); color: var(--mute); white-space: nowrap; pointer-events: none }
#tip.l { transform: translate(calc(-100% - 14px), -50%) }
#tip b { display: block; color: var(--ink); font-weight: 500 }
.now { margin-top: -6px; cursor: copy }
.big { font: 300 clamp(30px, 8.4vw, 104px)/1 var(--mono); letter-spacing: -.03em; font-variant-numeric: tabular-nums; color: #e3f3ff; text-shadow: 0 0 22px rgba(80, 160, 255, .55), 0 0 60px rgba(40, 110, 255, .35) }
.big i, .uv i, .mv i, dd i { font-style: normal; opacity: .38 }
.big i { opacity: 1; color: #2f63d6; text-shadow: none; margin: 0 -.12em }
.big-u { margin-top: 12px; font: 500 14px var(--mono); color: var(--cyan); letter-spacing: .2em; text-transform: uppercase }
.big-u span { color: var(--dim); letter-spacing: .1em; margin-left: 8px }
.units { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; margin-top: 54px }
.unit, .card { position: relative; border: 1px solid var(--line); border-radius: 16px; background: var(--card); backdrop-filter: blur(6px) }
.unit { display: flex; flex-direction: column; gap: 10px; padding: 18px 18px 16px; text-align: left; cursor: copy; transition: border-color .2s, transform .2s }
.unit:hover { border-color: rgba(127, 214, 255, .45); transform: translateY(-2px) }
.ul { display: flex; justify-content: space-between; color: var(--mute); font-size: 13px; letter-spacing: .06em; text-transform: uppercase }
.ul em { font: 500 12px var(--mono); color: var(--cyan); text-transform: none }
.uv { font: 400 clamp(18px, 1.9vw, 26px)/1.1 var(--mono); font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap }
.un { font: 12px var(--mono); color: var(--dim) }
.bar { display: block; height: 3px; border-radius: 3px; background: rgba(90, 140, 255, .14); overflow: hidden }
.bar i { display: block; height: 100%; transform-origin: left; transform: scaleX(0); background: linear-gradient(90deg, #2f6bff, var(--cyan)); box-shadow: 0 0 10px var(--cyan) }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 14px 0 0 }
.card { padding: 18px 20px 20px }
.wide { grid-column: span 3 }
h2 { font: 500 12px var(--sans); letter-spacing: .14em; text-transform: uppercase; color: var(--mute); margin-bottom: 10px }
.v { font: 400 18px/1.35 var(--mono); font-variant-numeric: tabular-nums }
.m { margin-top: 8px; font-size: 13px; color: var(--mute) }
.card .bar { margin-top: 12px }
.pips { display: flex; gap: 5px; margin-top: 12px }
.pips i { flex: 1; height: 3px; border-radius: 3px; background: rgba(90, 140, 255, .14) }
.pips .on { background: #2f6bff }
.pips .cur { background: var(--cyan); box-shadow: 0 0 10px var(--cyan) }
#miles { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 26px }
#miles li { display: flex; flex-direction: column; gap: 2px; padding: 10px 0 10px 14px; border-left: 2px solid rgba(110, 160, 255, .25) }
#miles li:first-child { border-color: var(--cyan) }
.mv { font: 15px var(--mono); font-variant-numeric: tabular-nums }
.mv em, dd em { font-style: normal; color: var(--cyan) }
.mv s { text-decoration: none; color: var(--dim) }
.mt { font-size: 13px; color: var(--mute) }
.mc { font: 12px var(--mono); color: var(--dim) }
.cv { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 30px }
.cv label { display: flex; justify-content: space-between; font-size: 13px; color: var(--mute); margin-bottom: 8px }
.tz { display: flex; align-items: center; gap: 6px; cursor: pointer }
.tz input { accent-color: var(--blue) }
.row { display: flex; gap: 8px }
.row input, .row select, .row button { height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line); background: rgba(4, 12, 32, .7); font: 14px var(--mono); outline: none; color-scheme: dark }
.row input { flex: 1; min-width: 0 }
.row input:focus, .row select:focus { border-color: rgba(127, 214, 255, .5) }
.row button { cursor: pointer; color: var(--cyan) }
.row button:hover { border-color: rgba(127, 214, 255, .5) }
dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; margin-top: 14px; font: 14px var(--mono); font-variant-numeric: tabular-nums }
dt { color: var(--cyan); text-align: right }
dd { overflow-wrap: anywhere }
.err { color: #ff9db0 }
footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 34px 0 44px; color: var(--dim); font-size: 13px }
.npm { flex-shrink: 0; white-space: nowrap; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--line); background: rgba(4, 12, 32, .6); font: 13px var(--mono); color: var(--cyan); cursor: copy }
#toast { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); padding: 9px 16px; border-radius: 10px; background: rgba(10, 30, 80, .92); border: 1px solid rgba(127, 214, 255, .35); font: 13px var(--mono); box-shadow: 0 10px 40px rgba(0, 0, 0, .5) }
@media (max-width: 960px) {
.units { grid-template-columns: repeat(6, minmax(0, 1fr)) }
.unit { grid-column: span 2 }
.unit:nth-child(n+4) { grid-column: span 3 }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)) }
.wide { grid-column: span 2 }
#miles { grid-template-columns: repeat(2, minmax(0, 1fr)) }
}
@media (max-width: 620px) {
.units { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 36px }
.unit, .unit:nth-child(n+4) { grid-column: auto }
.unit:last-child { grid-column: span 2 }
.grid, .cv, #miles { grid-template-columns: minmax(0, 1fr) }
.wide { grid-column: auto }
.map { margin: 22px -20px 0 }
footer { flex-direction: column; align-items: flex-start }
}
@media (prefers-reduced-motion: reduce) { #sun::after { animation: none } .unit { transition: none } }