mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
wip(mobile): checkpoint the globe engine and vision mockup in progress
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
36 files changed
+10296
-623
No files matched your search
+2072
-112
File diff suppressed because it is too large.
Load diff
@@ -572,3 +572,366 @@ input, textarea { font: inherit; color: inherit; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .12s !important; }
|
||||
}
|
||||
|
||||
/* =====================================================================================
|
||||
ROUND TWO — God's Eye identity: altitude bands, sensor framing, telemetry voice
|
||||
===================================================================================== */
|
||||
:root {
|
||||
--b-orbit: #B79CFF; --b-sky: #8CD4FF; --b-ground: #FFA53D; --b-inside: #F2CF7A; --harm: #FF3B30;
|
||||
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||
--bg: #050608; --card: #0B0D11; --card2: #10131a;
|
||||
}
|
||||
.mono { font-family: var(--mono); letter-spacing: .02em; }
|
||||
.tab.on { color: #fff; }
|
||||
.tablens { background: rgba(255,255,255,.14); }
|
||||
.dot { background: var(--harm); }
|
||||
|
||||
/* ---------- hero: the orbit view with a reticle and burned-in telemetry ---------- */
|
||||
.now-hero { height: calc(var(--sat) + clamp(330px, 46vh, 440px)); }
|
||||
.now-hero .hud { position: absolute; inset: calc(var(--sat) + 8px) 12px auto 12px; height: calc(100% - 150px - var(--sat)); pointer-events: none; }
|
||||
.now-hero .hud .wm { position: absolute; left: 6px; top: 4px; font: 800 13px/1 var(--display); letter-spacing: .34em; color: #fff; text-shadow: 0 0 18px rgba(0,0,0,.8); }
|
||||
.hb { position: absolute; width: 22px; height: 22px; border: 0 solid rgba(255,255,255,.55); }
|
||||
.hb.a { left: 0; top: 26px; border-width: 1.5px 0 0 1.5px; } .hb.b { right: 0; top: 26px; border-width: 1.5px 1.5px 0 0; }
|
||||
.hb.c { left: 0; bottom: 0; border-width: 0 0 1.5px 1.5px; } .hb.d { right: 0; bottom: 0; border-width: 0 1.5px 1.5px 0; }
|
||||
.xh { position: absolute; left: 50%; top: 52%; width: 46px; height: 46px; margin: -23px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(255,255,255,.35); }
|
||||
.xh::before, .xh::after { content: ""; position: absolute; background: rgba(255,255,255,.45); }
|
||||
.xh::before { left: -14px; right: -14px; top: 50%; height: 1px; } .xh::after { top: -14px; bottom: -14px; left: 50%; width: 1px; }
|
||||
.hud-t { position: absolute; left: 30px; right: 30px; bottom: 6px; font-size: 9.5px; color: rgba(220,210,255,.82); letter-spacing: .08em; text-align: center; text-shadow: 0 1px 6px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.now-hero .lt .kicker { font-size: 10.5px; letter-spacing: .12em; color: rgba(255,255,255,.7); }
|
||||
.now-hero .lt h1 { font: 800 30px/1.08 var(--display); letter-spacing: -.02em; margin-top: 6px; max-width: 13ch; }
|
||||
.counts { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 10px; font-size: 10.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--text3); }
|
||||
.counts b { color: var(--text); font-weight: 600; } .counts .w b { color: var(--b-inside); }
|
||||
.navbar .wm { font: 800 13px/1 var(--display); letter-spacing: .34em; }
|
||||
|
||||
/* ---------- altitude rail (band filter) ---------- */
|
||||
.bandrail { position: sticky; top: 0; z-index: 6; display: flex; align-items: stretch; gap: 0; padding: calc(var(--sat) + 8px) 10px 10px; margin-top: -2px; background: linear-gradient(180deg, rgba(5,6,8,.96) 70%, rgba(5,6,8,0)); }
|
||||
.bcell { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 8px 7px 7px; border-radius: 12px; position: relative; color: var(--text2); transition: background .2s, color .2s; }
|
||||
.bcell i { width: 100%; height: 2.5px; border-radius: 2px; background: var(--c); opacity: .45; margin-bottom: 5px; }
|
||||
.bcell b { font: 700 10.5px/1.1 var(--mono); letter-spacing: .1em; color: var(--text); }
|
||||
.bcell span { font: 600 15px/1.1 var(--display); color: var(--text); }
|
||||
.bcell em { font: 500 8.5px/1.2 var(--mono); font-style: normal; letter-spacing: .04em; color: var(--text3); text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.bcell.on { background: color-mix(in oklab, var(--c) 14%, rgba(255,255,255,.04)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--c) 55%, transparent); }
|
||||
.bcell.on i { opacity: 1; box-shadow: 0 0 10px var(--c); } .bcell.on b { color: var(--c); }
|
||||
.rarr { flex: none; width: 6px; align-self: center; height: 1px; background: rgba(255,255,255,.18); }
|
||||
.band-h { margin: 2px 16px 10px; padding: 10px 12px; border-radius: 12px; background: color-mix(in oklab, var(--c) 9%, transparent); box-shadow: inset 2px 0 0 var(--c); font-size: 12.5px; color: var(--text2); display: flex; gap: 10px; align-items: baseline; }
|
||||
.band-h b { color: var(--c); font: 700 12px var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.feed-end { text-align: center; font-size: 9.5px; letter-spacing: .1em; color: rgba(246,223,160,.55); padding: 26px 20px 10px; display: flex; gap: 6px; justify-content: center; align-items: center; }
|
||||
#feed { display: flex; flex-direction: column; gap: 10px; padding-bottom: 6px; }
|
||||
#feed > .fc.wide { margin: 0 12px; }
|
||||
|
||||
/* ---------- feed cards: sensor frames ---------- */
|
||||
.fc { border-radius: 14px; background: var(--card); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
|
||||
.fc .fr { position: relative; width: 100%; overflow: hidden; background: #06080c; border-radius: 14px 14px 0 0; }
|
||||
.fc .fr canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
.fc .fr::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0,0,0,.42), transparent 22%, transparent 74%, rgba(0,0,0,.7)); }
|
||||
.bk { position: absolute; z-index: 2; width: 14px; height: 14px; border: 0 solid color-mix(in oklab, var(--c) 85%, white); opacity: .9; pointer-events: none; }
|
||||
.bk.a { left: 7px; top: 7px; border-width: 1.5px 0 0 1.5px; } .bk.b { right: 7px; top: 7px; border-width: 1.5px 1.5px 0 0; }
|
||||
.bk.c { left: 7px; bottom: 7px; border-width: 0 0 1.5px 1.5px; } .bk.d { right: 7px; bottom: 7px; border-width: 0 1.5px 1.5px 0; }
|
||||
.ftop { position: absolute; z-index: 3; left: 14px; right: 14px; top: 11px; display: flex; gap: 6px; align-items: center; font: 700 9px/1 var(--mono); letter-spacing: .12em; pointer-events: none; }
|
||||
.ftop .bt, .tags .bt, .rhead .bt { color: var(--c); padding: 3px 5px 2px; border-radius: 4px; background: rgba(0,0,0,.55); box-shadow: inset 0 0 0 .5px color-mix(in oklab, var(--c) 60%, transparent); font: 700 9px/1 var(--mono); letter-spacing: .12em; }
|
||||
.ftop .at { color: rgba(255,255,255,.75); }
|
||||
.ftop .lv, .ftop .mine { margin-left: auto; display: inline-flex; gap: 4px; align-items: center; color: #fff; background: rgba(255,59,48,.9); padding: 3px 5px 2px; border-radius: 4px; }
|
||||
.ftop .mine { background: var(--b-inside); color: #1b1408; }
|
||||
.ftop .lv i { width: 5px; height: 5px; border-radius: 50%; background: #fff; animation: blink 1.2s steps(2) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.tele { position: absolute; z-index: 3; left: 14px; right: 14px; bottom: 11px; font: 600 8.5px/1.25 var(--mono); letter-spacing: .07em; color: rgba(255,255,255,.88); text-shadow: 0 1px 3px #000, 0 0 6px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
||||
.fc.wide .tele { font-size: 10px; }
|
||||
.readout { position: absolute; z-index: 3; left: 14px; bottom: 26px; font: 700 16px/1 var(--mono); letter-spacing: .02em; color: #fff; text-shadow: 0 1px 10px rgba(0,0,0,.9); }
|
||||
.a-track.b-sky .readout { color: #fff; } .fc[data-fid="squawk"] .readout { color: #FF6B61; }
|
||||
.fseal { position: absolute; z-index: 3; right: 12px; top: 30px; width: 24px; height: 24px; filter: drop-shadow(0 0 8px rgba(233,196,106,.7)); }
|
||||
.numov { position: absolute; z-index: 3; inset: auto 14px 26px 14px; }
|
||||
.a-number .fr canvas { filter: brightness(.62) saturate(1.1); }
|
||||
.numov .big { font: 800 40px/1 var(--display); letter-spacing: -.03em; color: #fff; text-shadow: 0 2px 20px rgba(0,0,0,.8); }
|
||||
.numov .big small { font: 600 12px var(--mono); letter-spacing: .06em; color: var(--c); margin-left: 5px; text-transform: uppercase; }
|
||||
.numov .nl { font-size: 12px; color: rgba(255,255,255,.82); margin-top: 3px; line-height: 1.25; }
|
||||
.numov .nsp { width: 100%; height: 30px; margin-top: 6px; overflow: visible; }
|
||||
.r4d { position: absolute; z-index: 3; left: 14px; top: 30px; display: flex; gap: 6px; }
|
||||
.fm { padding: 9px 11px 11px; }
|
||||
.fm .ttl { font-size: 14.5px; font-weight: 650; line-height: 1.26; letter-spacing: -.01em; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.fc.wide .fm .ttl { font-size: 17px; }
|
||||
.where { display: flex; flex-wrap: wrap; gap: 2px 8px; margin-top: 6px; font-size: 11.5px; color: var(--text3); }
|
||||
.where .d { color: var(--text2); font-weight: 600; font-family: var(--mono); font-size: 10.5px; letter-spacing: .02em; }
|
||||
.where span + span::before { content: "·"; margin-right: 8px; color: var(--text4); }
|
||||
.soc { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; margin-top: 8px; font-size: 11.5px; color: var(--text3); }
|
||||
.soc b { color: var(--text); font-weight: 600; }
|
||||
.soc .sl { display: inline-flex; align-items: center; gap: 4px; color: var(--b-inside); } .soc .sl b { color: var(--b-inside); }
|
||||
.soc .sl .ic { width: 13px; height: 13px; }
|
||||
.who { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; font-size: 12px; }
|
||||
.who b { font-weight: 650; }
|
||||
.bdg { display: inline-flex; align-items: center; gap: 3px; height: 18px; padding: 0 6px; border-radius: 5px; font: 650 9.5px/1 var(--font); letter-spacing: .02em; white-space: nowrap; }
|
||||
.bdg .ic { width: 11px; height: 11px; }
|
||||
.bdg.there { background: rgba(242,207,122,.16); color: var(--b-inside); box-shadow: inset 0 0 0 .5px rgba(242,207,122,.5); }
|
||||
.bdg.sealed { background: rgba(242,207,122,.08); color: var(--gold2); }
|
||||
.bdg.local { background: rgba(255,165,61,.12); color: var(--b-ground); }
|
||||
.bdg.domain { background: rgba(140,212,255,.12); color: var(--b-sky); }
|
||||
.bdg.unv { background: repeating-linear-gradient(135deg, rgba(255,255,255,.06) 0 4px, transparent 4px 8px); color: var(--text3); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.2); }
|
||||
.fc .cta { margin-top: 9px; display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 8px; background: color-mix(in oklab, var(--c) 16%, transparent); color: var(--c); font-size: 12.5px; font-weight: 700; box-shadow: inset 0 0 0 .5px color-mix(in oklab, var(--c) 50%, transparent); }
|
||||
.fc.mine { box-shadow: inset 0 0 0 1px rgba(242,207,122,.55), 0 0 30px rgba(242,207,122,.12); }
|
||||
.fc.flash { animation: flash 1.2s ease; }
|
||||
|
||||
/* room card */
|
||||
.fc.room { padding: 12px 13px 12px; background: linear-gradient(180deg, rgba(242,207,122,.06), rgba(255,255,255,.015) 40%), var(--card); box-shadow: inset 0 0 0 .5px rgba(242,207,122,.22); }
|
||||
.rhead { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text2); --c: var(--b-inside); }
|
||||
.rtag { display: inline-flex; align-items: center; gap: 4px; font: 700 9px/1 var(--mono); letter-spacing: .12em; color: #1a1306; background: var(--b-inside); padding: 3px 5px 2px; border-radius: 4px; }
|
||||
.rtag .ic { width: 11px; height: 11px; stroke-width: 2.4; }
|
||||
.lvw { display: inline-flex; align-items: center; gap: 5px; margin-left: 2px; } .lvw b { color: var(--text); }
|
||||
.rhead .stack { margin-left: auto; }
|
||||
.fc.room h3 { margin: 9px 0 0; font: 750 19px/1.15 var(--display); letter-spacing: -.01em; }
|
||||
.claims { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.cl { display: grid; grid-template-columns: 74px 1fr; gap: 2px 8px; font-size: 13px; line-height: 1.35; color: rgba(240,244,250,.9); }
|
||||
.cl b { font: 700 9px/1.9 var(--mono); letter-spacing: .12em; color: var(--text3); }
|
||||
.cl.known b { color: var(--b-inside); } .cl.disputed b { color: #FFB25C; } .cl.open b { color: #fff; } .cl.unknown b { color: var(--text3); }
|
||||
.cl .cites { grid-column: 2; display: flex; gap: 4px; } .cl .cites i { font: 600 9px/1 var(--mono); font-style: normal; padding: 3px 4px; border-radius: 4px; background: rgba(255,255,255,.07); color: var(--text2); }
|
||||
.evs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 12px; }
|
||||
.evs .evc { width: 100%; aspect-ratio: 4/3; border-radius: 8px; display: block; background: #06080c; }
|
||||
.rfoot { display: flex; justify-content: space-between; align-items: center; margin-top: 11px; font-size: 11.5px; color: var(--text3); }
|
||||
.rfoot b { color: var(--text); font-weight: 600; } .rfoot .join { color: var(--b-inside); font-weight: 700; display: inline-flex; gap: 2px; align-items: center; }
|
||||
|
||||
/* ---------- sheet: room ---------- */
|
||||
.sh-hd .tags { display: flex; gap: 6px; align-items: center; } .sh-hd .tags .age { font-size: 12px; color: var(--text3); }
|
||||
.sh-hd .pl b { color: var(--text); font-weight: 600; }
|
||||
.presence-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12.5px; color: var(--text2); }
|
||||
.presence-row b { color: var(--text); } .presence-row .rate { color: var(--text3); font-family: var(--mono); font-size: 10.5px; }
|
||||
.seg2 { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin: 12px 0 10px; padding: 3px; border-radius: 12px; background: rgba(255,255,255,.06); }
|
||||
.seg2 button { height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; font-weight: 650; color: var(--text2); }
|
||||
.seg2 button.on { background: rgba(255,255,255,.14); color: #fff; box-shadow: inset 0 .5px 0 rgba(255,255,255,.3); }
|
||||
.seg2 button span { font-size: 11px; color: var(--text3); }
|
||||
.synth { border-radius: 14px; padding: 11px 12px 9px; background: linear-gradient(180deg, rgba(183,156,255,.09), rgba(183,156,255,.02)); box-shadow: inset 0 0 0 .5px rgba(183,156,255,.3); }
|
||||
.syh { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #CDBBFF; font-weight: 600; }
|
||||
.syh span { flex: 1; min-width: 0; }
|
||||
.regen { display: inline-flex; gap: 4px; align-items: center; height: 26px; padding: 0 9px; border-radius: 8px; font-size: 11.5px; font-weight: 700; background: rgba(183,156,255,.16); color: #E2D6FF; flex: none; }
|
||||
.regen.busy { opacity: .5; }
|
||||
.sy { display: grid; grid-template-columns: 74px 1fr; gap: 0 8px; margin-top: 8px; }
|
||||
.sy b { font: 700 9px/2.1 var(--mono); letter-spacing: .12em; grid-row: span 9; }
|
||||
.sy.kn b { color: var(--b-inside); } .sy.di b { color: #FFB25C; } .sy.un b { color: var(--text3); }
|
||||
.sy p { margin: 0 0 4px; font-size: 13px; line-height: 1.38; color: rgba(240,244,250,.9); grid-column: 2; }
|
||||
.sy p.none { color: var(--text3); } .sy p.stream { white-space: pre-wrap; font-family: var(--mono); font-size: 11.5px; }
|
||||
.syf { margin-top: 6px; font-size: 9.5px; color: var(--text3); letter-spacing: .03em; }
|
||||
.cite { display: inline-block; font: 650 9.5px/1 var(--mono); padding: 3px 4px 2px; border-radius: 4px; background: rgba(255,255,255,.08); color: var(--text2); vertical-align: 1px; margin-right: 2px; }
|
||||
.cite:active { background: rgba(255,255,255,.2); }
|
||||
.sortrow { display: flex; gap: 6px; align-items: center; margin: 14px 0 8px; } .sortrow .sl { font-size: 9.5px; letter-spacing: .12em; color: var(--text3); margin-right: 2px; }
|
||||
.chip.sm { height: 28px; padding: 0 11px; font-size: 12.5px; }
|
||||
.posts { display: flex; flex-direction: column; gap: 8px; }
|
||||
.post { border-radius: 14px; padding: 10px 11px 9px; background: rgba(255,255,255,.035); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07); transition: box-shadow .4s, background .4s; cursor: pointer; }
|
||||
.post.hl { background: rgba(242,207,122,.08); box-shadow: inset 0 0 0 1px rgba(242,207,122,.6); }
|
||||
.post.k-question { box-shadow: inset 2px 0 0 #fff, inset 0 0 0 .5px rgba(255,255,255,.07); }
|
||||
.post.noted { background: rgba(255,255,255,.02); }
|
||||
.post.noted .pt { color: var(--text3); text-decoration: line-through; text-decoration-color: rgba(255,255,255,.25); }
|
||||
.post.mine { box-shadow: inset 0 0 0 1px rgba(242,207,122,.45); }
|
||||
.ph { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.ph .avatar { margin-top: 1px; }
|
||||
.pw { flex: 1; min-width: 0; font-size: 12.5px; } .pw b { font-weight: 650; } .pw .pid { font: 600 9.5px var(--mono); color: var(--text3); margin-left: 6px; } .pw .tm { color: var(--text3); margin-left: 6px; font-size: 11.5px; }
|
||||
.bds { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
||||
.kind { flex: none; display: inline-flex; align-items: center; gap: 4px; font: 700 9px/1 var(--mono); letter-spacing: .1em; padding: 4px 6px 3px; border-radius: 5px; background: rgba(255,255,255,.07); color: var(--text2); }
|
||||
.kind .ic { width: 11px; height: 11px; }
|
||||
.kind.k-witness { color: var(--b-inside); background: rgba(242,207,122,.12); } .kind.k-question { color: #fff; background: rgba(255,255,255,.14); }
|
||||
.kind.k-correction { color: #FFB25C; background: rgba(255,178,92,.12); } .kind.k-context { color: var(--b-sky); background: rgba(140,212,255,.1); } .kind.k-prediction { color: var(--b-orbit); background: rgba(183,156,255,.12); }
|
||||
.pt { margin: 7px 0 0; font-size: 14px; line-height: 1.42; color: rgba(242,245,250,.92); }
|
||||
.resolves { display: inline-block; margin-left: 6px; font: 600 9.5px var(--mono); color: var(--b-orbit); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.patt { position: relative; margin-top: 8px; border-radius: 10px; overflow: hidden; background: #06080c; aspect-ratio: 16/10; }
|
||||
.patt canvas { width: 100%; height: 100%; display: block; }
|
||||
.patt .tier { position: absolute; left: 8px; bottom: 8px; display: inline-flex; gap: 4px; align-items: center; font: 700 9px/1 var(--mono); letter-spacing: .1em; padding: 4px 6px 3px; border-radius: 4px; background: rgba(0,0,0,.6); color: var(--b-inside); }
|
||||
.patt .tier .ic { width: 12px; height: 12px; }
|
||||
.patt.unverified .tier { color: var(--text2); } .patt.eye .tier { color: var(--b-ground); }
|
||||
.patt .plive { position: absolute; right: 8px; top: 8px; display: inline-flex; gap: 5px; align-items: center; font: 700 9px/1 var(--mono); letter-spacing: .1em; background: rgba(255,59,48,.9); padding: 4px 6px 3px; border-radius: 4px; }
|
||||
.patt .plive .dot { background: #fff; width: 5px; height: 5px; }
|
||||
.ancs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; align-items: center; }
|
||||
.anc { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border-radius: 7px; font-size: 11.5px; font-weight: 600; color: var(--text2); background: rgba(255,255,255,.06); }
|
||||
.anc .ic { width: 12px; height: 12px; color: var(--b-ground); }
|
||||
.note { margin-top: 9px; border-radius: 10px; padding: 9px 10px; background: rgba(140,212,255,.07); box-shadow: inset 0 0 0 .5px rgba(140,212,255,.3); }
|
||||
.note .nh { display: flex; gap: 5px; align-items: center; font-size: 11.5px; font-weight: 700; color: var(--b-sky); }
|
||||
.note p { margin: 5px 0 0; font-size: 13px; line-height: 1.4; color: rgba(240,244,250,.92); text-decoration: none; }
|
||||
.bridge { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.br { display: grid; grid-template-columns: 1fr 90px 34px; gap: 8px; align-items: center; font-size: 11px; color: var(--text3); }
|
||||
.br b { color: var(--text); font-weight: 600; text-align: right; }
|
||||
.br i { height: 5px; border-radius: 3px; background: linear-gradient(90deg, var(--b-sky) calc(var(--v) * 100%), rgba(255,255,255,.1) 0); }
|
||||
.note details { margin-top: 6px; font-size: 11.5px; color: var(--text2); } .note summary { cursor: pointer; color: var(--b-sky); font-weight: 600; }
|
||||
.note details p { font-size: 12px; color: var(--text2); }
|
||||
.answered { margin-top: 8px; display: flex; gap: 5px; align-items: center; font-size: 11.5px; color: var(--b-inside); }
|
||||
.answered.open { color: var(--text2); } .answered b { font-weight: 650; }
|
||||
.pf { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
||||
.rxb, .rep { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 7px; border-radius: 7px; font-size: 11.5px; color: var(--text2); background: rgba(255,255,255,.05); }
|
||||
.rcap { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 9px; border-radius: 8px; font-size: 11.5px; font-weight: 700; color: var(--b-inside); background: rgba(242,207,122,.12); }
|
||||
.room-srcs { margin-top: 18px; } .room-srcs .h3 { font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text3); margin-bottom: 4px; }
|
||||
.room-srcs .r { grid-template-columns: 30px 1fr; }
|
||||
.fine { font-size: 11px; color: var(--text3); margin: 14px 0 90px; }
|
||||
/* composer & reactions */
|
||||
.roomcomp { position: absolute; z-index: 41; left: 10px; right: 10px; bottom: calc(var(--sab) + 10px); display: none; gap: 8px; align-items: flex-end; }
|
||||
.roomcomp.on { display: flex; animation: rise .4s var(--spring); }
|
||||
.capbtn { flex: none; width: 66px; height: 58px; border-radius: 18px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; background: var(--b-inside); color: #1b1408; font-size: 10.5px; font-weight: 800; box-shadow: 0 8px 30px rgba(242,207,122,.25); }
|
||||
.capbtn .ic { width: 22px; height: 22px; stroke-width: 2; }
|
||||
.rcin { flex: 1; min-width: 0; border-radius: 18px; padding: 6px; }
|
||||
.rcin .kinds { display: none; gap: 4px; overflow-x: auto; scrollbar-width: none; padding: 2px 2px 6px; }
|
||||
.roomcomp.typing .kinds { display: flex; }
|
||||
.rcin .kinds button { flex: none; display: inline-flex; gap: 4px; align-items: center; height: 26px; padding: 0 9px; border-radius: 8px; font-size: 11.5px; font-weight: 650; color: var(--text2); background: rgba(255,255,255,.06); }
|
||||
.rcin .kinds button.on { background: #fff; color: #05070B; }
|
||||
.rcrow { display: flex; gap: 6px; align-items: center; }
|
||||
.rcrow input { flex: 1; min-width: 0; height: 44px; border: 0; outline: 0; background: transparent; padding: 0 10px; font-size: 15px; }
|
||||
.rcrow .send { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.14); }
|
||||
.app[data-mode="situation"] .sheet .sc { padding-bottom: 96px; }
|
||||
.rx { position: absolute; z-index: 40; right: 14px; bottom: calc(var(--sab) + 84px); width: 36px; height: 260px; pointer-events: none; display: none; }
|
||||
.app[data-mode="situation"] .rx { display: block; }
|
||||
.rx span { position: absolute; bottom: 0; left: 6px; animation: floatUp 2.6s ease-out forwards; color: var(--harm); filter: drop-shadow(0 2px 6px rgba(0,0,0,.6)); }
|
||||
.rx span:nth-child(3n) { color: var(--b-inside); } .rx span:nth-child(3n+1) { color: #fff; }
|
||||
.lbl.speech { padding: 4px 9px 4px 4px; font-size: 11.5px; border-radius: 12px 12px 12px 3px; }
|
||||
.lbl.speech .sg { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.12); color: #fff; }
|
||||
.lbl.speech .sg.k-witness { background: var(--b-inside); color: #1b1408; } .lbl.speech .sg.k-question { background: #fff; color: #05070B; }
|
||||
.lbl.speech .sg .ic { width: 12px; height: 12px; stroke-width: 2.2; }
|
||||
.lbl.speech.hl { box-shadow: inset 0 0 0 1.5px var(--b-inside); }
|
||||
@media (min-width: 600px) {
|
||||
.roomcomp { left: 10px; right: auto; width: calc(var(--paneW) - 20px); z-index: 36; }
|
||||
.rx { left: calc(var(--paneW) - 50px); right: auto; z-index: 36; }
|
||||
.bandrail { padding-top: 8px; }
|
||||
.now-hero .hud { display: none; }
|
||||
.now-hero .lt h1 { font-size: 26px; max-width: none; }
|
||||
}
|
||||
.where { display: block; margin-top: 6px; }
|
||||
.where .pl { font-size: 12px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.where .d { font-size: 9.5px; letter-spacing: .06em; color: var(--text3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.where span + span::before { content: none; }
|
||||
.soc span { display: inline-flex; align-items: center; gap: 4px; } .soc .ic { width: 13px; height: 13px; }
|
||||
/* accessory: a person live, with a moving thumbnail */
|
||||
.accessory .accdot { width: 10px; display: grid; place-items: center; }
|
||||
.accessory .accv { width: 54px; height: 30px; border-radius: 6px; flex: none; background: #06080c; }
|
||||
/* concept tag sits above the sticky rail on Now */
|
||||
.app[data-tab="now"] .concept-tag { top: calc(var(--sat) + 8px); font-size: 8.5px; padding: 2px 6px; }
|
||||
.bandrail { padding-top: calc(var(--sat) + 28px); }
|
||||
.now-hero .hud { top: calc(var(--sat) + 30px); }
|
||||
.now-hero .hud .wm { top: -22px; }
|
||||
.hb.a, .hb.b { top: 0; }
|
||||
|
||||
/* ---------- capture: dial of four, live, upload, attach ---------- */
|
||||
.status .alt { font-size: 10.5px; letter-spacing: .06em; margin-left: 4px; }
|
||||
.dial button { padding: 0 2px; }
|
||||
.dial { gap: 18px; }
|
||||
.livecv { position: absolute; inset: 0; width: 100%; height: 100%; display: none; background: #000; }
|
||||
.livecv.on { display: block; }
|
||||
.cap-ui.m-live .compass-tape, .cap-ui.m-live .callouts, .cap-ui.m-upload .callouts { display: none; }
|
||||
.cap-ui.m-upload::before { content: ""; position: absolute; inset: 0; background: rgba(3,4,7,.62); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); pointer-events: none; }
|
||||
.replyto { position: absolute; top: calc(var(--sat) + 60px); left: 12px; right: 12px; height: 38px; border-radius: 12px; display: flex; align-items: center; gap: 8px; padding: 0 6px 0 12px; font-size: 13px; color: var(--b-inside); }
|
||||
.replyto span { flex: 1; } .replyto b { color: #fff; } .replyto button { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.1); }
|
||||
.shutter.s-live i { background: #FF3B30; }
|
||||
.shutter.s-live.rec i { border-radius: 8px; transform: scale(.5); }
|
||||
.shutter.s-upload i { background: var(--b-inside); }
|
||||
#modeHost { position: absolute; inset: 0; pointer-events: none; }
|
||||
#modeHost > * { pointer-events: auto; }
|
||||
.lv-pre { position: absolute; left: 14px; right: 14px; bottom: calc(var(--sab) + 300px); border-radius: 18px; padding: 14px 15px; font-size: 13.5px; color: var(--text2); }
|
||||
.lv-pre b { color: #fff; font-size: 16px; } .lv-pre p { margin: 8px 0 10px; line-height: 1.45; }
|
||||
.lvph { display: flex; gap: 10px; align-items: center; }
|
||||
.lvnear { display: flex; flex-wrap: wrap; gap: 6px; } .lvnear span { display: inline-flex; gap: 5px; align-items: center; font-size: 12px; height: 26px; padding: 0 9px; border-radius: 8px; background: rgba(242,207,122,.12); color: var(--b-inside); }
|
||||
.lvfine { margin-top: 10px; font-size: 9px; letter-spacing: .1em; color: var(--text3); }
|
||||
.lv-top { position: absolute; top: calc(var(--sat) + 60px); left: 12px; right: 12px; display: flex; gap: 8px; align-items: center; }
|
||||
.lvpill { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px; border-radius: 8px; background: #FF3B30; font: 800 12px/1 var(--mono); letter-spacing: .08em; color: #fff; }
|
||||
.lvpill i { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: blink 1.2s steps(2) infinite; }
|
||||
.lvpill.pre { background: rgba(255,255,255,.12); } .lvpill b { font-weight: 700; }
|
||||
.lvv { display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 10px; border-radius: 8px; font-size: 13px; }
|
||||
.lvseal { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 9px; border-radius: 8px; background: rgba(0,0,0,.45); color: var(--b-inside); font: 700 10px/1 var(--mono); letter-spacing: .1em; }
|
||||
.lv-to { position: absolute; top: calc(var(--sat) + 100px); left: 12px; height: 30px; border-radius: 8px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; font-size: 12px; color: var(--text2); } .lv-to b { color: var(--b-inside); }
|
||||
.lv-chat { position: absolute; left: 12px; right: 70px; bottom: calc(var(--sab) + 330px); display: flex; flex-direction: column; gap: 6px; justify-content: flex-end; mask-image: linear-gradient(to top, #000 70%, transparent); -webkit-mask-image: linear-gradient(to top, #000 70%, transparent); pointer-events: none !important; }
|
||||
.lv-chat .bub .bx { padding: 6px 10px; font-size: 12.5px; } .lv-chat .lj { display: flex; gap: 6px; align-items: center; font-size: 11.5px; color: rgba(255,255,255,.75); text-shadow: 0 1px 4px #000; }
|
||||
.lvrx { position: absolute; right: 14px; bottom: calc(var(--sab) + 330px); width: 36px; height: 260px; pointer-events: none !important; }
|
||||
.lvrx span { position: absolute; bottom: 0; left: 6px; animation: floatUp 2.6s ease-out forwards; color: var(--harm); } .lvrx span:nth-child(2n) { color: #fff; }
|
||||
.lv-tele { position: absolute; left: 14px; right: 14px; bottom: calc(var(--sab) + 302px); font-size: 9.5px; letter-spacing: .06em; color: rgba(255,255,255,.88); text-shadow: 0 1px 3px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lv-chain { position: absolute; left: 0; right: 0; bottom: calc(var(--sab) + 214px); display: flex; align-items: center; gap: 0; overflow-x: auto; padding: 0 14px; scrollbar-width: none; }
|
||||
.lv-chain::-webkit-scrollbar { display: none; }
|
||||
.lv-chain .seg { flex: none; width: 86px; border-radius: 10px; padding: 5px; background: rgba(8,10,14,.78); box-shadow: inset 0 0 0 .5px rgba(242,207,122,.45); display: flex; flex-direction: column; gap: 3px; }
|
||||
.lv-chain .seg img { width: 100%; height: 34px; object-fit: cover; border-radius: 6px; display: block; }
|
||||
.lv-chain .seg .si { font-size: 9px; color: var(--text3); } .lv-chain .seg .sh { font-size: 9px; color: var(--b-inside); }
|
||||
.lv-chain .seg .sk { display: inline-flex; gap: 3px; align-items: center; font-size: 9px; color: var(--text2); } .lv-chain .seg .sk .ic { width: 11px; height: 11px; }
|
||||
.lv-chain .seg.cur { box-shadow: inset 0 0 0 .5px rgba(255,255,255,.25); }
|
||||
.lv-chain .seg .fill { height: 34px; border-radius: 6px; background: rgba(255,255,255,.06); overflow: hidden; } .lv-chain .seg .fill i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, rgba(242,207,122,.15), rgba(242,207,122,.45)); }
|
||||
.lv-chain .ln { flex: none; width: 12px; height: 2px; background: linear-gradient(90deg, var(--b-inside), rgba(242,207,122,.3)); }
|
||||
.lv-chain .seg.flash { animation: flash 1.2s ease; }
|
||||
.up { position: absolute; left: 12px; right: 12px; top: calc(var(--sat) + 62px); max-height: calc(100% - var(--sat) - 62px - var(--sab) - 230px); overflow: auto; border-radius: 20px; padding: 14px; font-size: 13.5px; color: var(--text2); scrollbar-width: none; }
|
||||
.uph { display: flex; justify-content: space-between; align-items: center; gap: 10px; } .uph b { color: #fff; font-size: 16px; }
|
||||
.up p { margin: 8px 0 12px; line-height: 1.45; } .up p b { color: #fff; }
|
||||
.tierb { display: inline-flex; gap: 4px; align-items: center; font: 700 9px/1 var(--mono); letter-spacing: .1em; padding: 4px 6px 3px; border-radius: 4px; background: rgba(0,0,0,.6); color: var(--b-inside); }
|
||||
.tierb.unv { color: var(--text2); background: repeating-linear-gradient(135deg, rgba(255,255,255,.08) 0 4px, rgba(0,0,0,.5) 4px 8px); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.25); }
|
||||
.drop { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 22px 12px; border-radius: 16px; border: 1.5px dashed rgba(255,255,255,.22); color: var(--text2); cursor: pointer; margin-bottom: 10px; text-align: center; }
|
||||
.drop b { color: #fff; font-size: 15px; } .drop span { font-size: 12px; color: var(--text3); }
|
||||
.up .fine { margin: 10px 0 0; font-size: 9px; letter-spacing: .1em; text-align: center; }
|
||||
.upimg { position: relative; border-radius: 12px; overflow: hidden; } .upimg img { width: 100%; display: block; max-height: 220px; object-fit: cover; } .upimg .tierb { position: absolute; left: 8px; top: 8px; }
|
||||
.exif { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; margin: 10px 0; font-size: 10px; letter-spacing: .03em; }
|
||||
.exif div { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .exif span { color: var(--text3); font-size: 8.5px; letter-spacing: .12em; } .exif b { color: #fff; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.placer { position: relative; border-radius: 12px; overflow: hidden; touch-action: none; } .placer canvas { width: 100%; display: block; }
|
||||
.placer .pin { position: absolute; width: 34px; height: 34px; margin: -34px 0 0 -17px; display: grid; place-items: center; color: #1b1408; background: var(--b-inside); border-radius: 50% 50% 50% 4px; transform: rotate(-45deg); box-shadow: 0 6px 18px rgba(0,0,0,.6); cursor: grab; touch-action: none; }
|
||||
.placer .pin .ic { transform: rotate(45deg); }
|
||||
.placer .hint { position: absolute; left: 8px; bottom: 8px; font-size: 9.5px; letter-spacing: .06em; color: #fff; background: rgba(0,0,0,.6); padding: 4px 6px; border-radius: 4px; }
|
||||
.uptime { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; margin: 12px 0; font-size: 12px; } .uptime input { accent-color: var(--b-inside); } .uptime b { color: #fff; font-size: 11.5px; }
|
||||
.attach-ov { position: absolute; inset: 0; z-index: 78; display: none; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.5); }
|
||||
.attach-ov.on { display: flex; animation: viewIn .3s var(--out); }
|
||||
.att { width: 100%; max-width: 560px; max-height: 92%; overflow: auto; border-radius: 28px 28px 0 0; padding: 6px 16px calc(var(--sab) + 16px); scrollbar-width: none; }
|
||||
.att .grab { height: 18px; display: grid; place-items: center; } .att .grab::before { content: ""; width: 38px; height: 5px; border-radius: 3px; background: rgba(255,255,255,.3); }
|
||||
.ath { display: grid; grid-template-columns: 112px 1fr; gap: 12px; align-items: center; }
|
||||
.atimg { position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 4/3; background: #06080c; } .atimg img { width: 100%; height: 100%; object-fit: cover; display: block; } .atimg .tierb { position: absolute; left: 6px; bottom: 6px; font-size: 7.5px; }
|
||||
.ath h2 { margin: 0; font: 750 21px/1.15 var(--display); } .ath p { margin: 4px 0 0; font-size: 10.5px; color: var(--text3); letter-spacing: .02em; }
|
||||
.ath2 { margin: 14px 0 7px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text3); }
|
||||
.sugs { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sug { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 14px; background: rgba(255,255,255,.05); text-align: left; box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08); }
|
||||
.sug i { width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 10px var(--c); flex: none; }
|
||||
.sug span { flex: 1; min-width: 0; } .sug b { display: block; font-size: 14.5px; } .sug small { font-size: 12px; color: var(--text3); }
|
||||
.sug em { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.25); color: transparent; flex: none; }
|
||||
.sug.on { background: rgba(242,207,122,.1); box-shadow: inset 0 0 0 1px rgba(242,207,122,.55); } .sug.on em { background: var(--b-inside); color: #1b1408; box-shadow: none; }
|
||||
.kinds2, .prec { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.kinds2 button, .prec button { height: 32px; padding: 0 12px; border-radius: 10px; font-size: 13px; font-weight: 650; color: var(--text2); background: rgba(255,255,255,.06); }
|
||||
.kinds2 button.on, .prec button.on { background: #fff; color: #05070B; }
|
||||
.prec { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.att textarea { width: 100%; margin-top: 10px; border: 0; outline: 0; resize: none; border-radius: 12px; padding: 10px 12px; background: rgba(255,255,255,.06); font-size: 14.5px; }
|
||||
.fine2 { font-size: 11.5px; color: var(--text3); margin: 7px 2px 0; line-height: 1.4; } .fine2.priv { display: flex; gap: 6px; align-items: flex-start; margin: 10px 2px 12px; }
|
||||
.att .row2.holo { margin-top: 10px; border-radius: 14px; background: rgba(255,255,255,.04); }
|
||||
.lbl.eyelbl { font-size: 11px; padding: 4px 9px 4px 4px; }
|
||||
.lbl.eyelbl .ei { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--b-inside); color: #1b1408; }
|
||||
.lbl.eyelbl .ei.k-cctv { background: var(--b-ground); } .lbl.eyelbl.k-you { box-shadow: inset 0 0 0 1.5px var(--b-inside), 0 0 24px rgba(242,207,122,.35); }
|
||||
.app[data-tab="capture"] .concept-tag { top: auto; bottom: calc(var(--sab) + 200px); left: 14px; right: auto; }
|
||||
.cap-top .st { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.cap-top .st:first-child { flex: 1 1 auto; } .cap-top .st:last-child { flex: 0 1 auto; }
|
||||
.lv-chat { right: 120px; } .lv-chat .bub { max-width: 100%; }
|
||||
.att { background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.01)), rgba(13,16,22,.97); }
|
||||
|
||||
/* ---------- cold open: the descent ---------- */
|
||||
.cold { justify-content: center; padding: 0; background: radial-gradient(120% 80% at 50% 50%, transparent 40%, rgba(0,0,0,.55)); }
|
||||
.ladder { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); height: 300px; width: 120px; display: flex; flex-direction: column; justify-content: space-between; opacity: 0; transition: opacity .6s; }
|
||||
.cold.s0 .ladder { opacity: 1; }
|
||||
.ladder::before { content: ""; position: absolute; left: 4px; top: 6px; bottom: 6px; width: 1px; background: linear-gradient(var(--b-orbit), var(--b-sky), var(--b-ground), var(--b-inside)); opacity: .5; }
|
||||
.lr { position: relative; padding-left: 18px; display: flex; flex-direction: column; gap: 2px; opacity: .38; transition: opacity .4s; }
|
||||
.lr i { position: absolute; left: 0; top: 3px; width: 9px; height: 9px; border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--c); }
|
||||
.lr b { font: 700 11px/1 var(--mono); letter-spacing: .16em; color: var(--c); } .lr span { font: 500 9px/1 var(--mono); letter-spacing: .06em; color: rgba(255,255,255,.6); }
|
||||
.lr.on { opacity: 1; } .lr.on i { background: var(--c); box-shadow: 0 0 12px var(--c); }
|
||||
.lmark { position: absolute; left: -3px; width: 15px; height: 2px; background: #fff; box-shadow: 0 0 10px #fff; top: 0; transition: top .15s linear; }
|
||||
.calt { position: absolute; left: 16px; top: calc(50% + 172px); font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #fff; text-shadow: 0 2px 16px #000; opacity: 0; transition: opacity .5s; }
|
||||
.cold.s0 .calt { opacity: 1; }
|
||||
.ccap { position: absolute; left: 16px; right: 16px; bottom: calc(var(--sab) + 40px); font-size: 11px; letter-spacing: .12em; color: rgba(255,255,255,.85); text-shadow: 0 1px 8px #000; }
|
||||
.cwit { position: absolute; left: 50%; top: 50%; width: min(78vw, 360px); aspect-ratio: 440/560; transform: translate(-50%, -50%) scale(1.25); opacity: 0; border-radius: 6px; overflow: hidden; transition: opacity .7s ease, transform 1.4s var(--out); --c: var(--b-inside); box-shadow: 0 30px 80px rgba(0,0,0,.6); }
|
||||
.cwit canvas { width: 100%; height: 100%; display: block; }
|
||||
.cwit .tele { position: absolute; left: 14px; right: 14px; bottom: 11px; font: 600 9.5px/1.2 var(--mono); letter-spacing: .08em; color: #fff; text-shadow: 0 1px 4px #000; }
|
||||
.cold.s3 .cwit { opacity: 1; transform: translate(-50%, -58%) scale(1); }
|
||||
.cold.s3 .ladder, .cold.s3 .calt { opacity: .0; }
|
||||
.cttl { position: absolute; left: 0; right: 0; bottom: calc(var(--sab) + 9vh); text-align: center; }
|
||||
.cttl .word { font: 800 34px/1 var(--display); letter-spacing: .3em; margin-left: .3em; opacity: 0; transition: opacity 1s ease, letter-spacing 1.8s var(--out); }
|
||||
.cttl .tag { font: 600 12px/1.4 var(--mono); letter-spacing: .2em; text-transform: uppercase; color: var(--b-inside); margin-top: 12px; opacity: 0; transition: opacity 1s ease .3s; }
|
||||
.cold.s4 .cttl .word { opacity: 1; letter-spacing: .2em; } .cold.s4 .cttl .tag { opacity: 1; }
|
||||
.cold.s3 .ccap { opacity: 0; }
|
||||
.rooms-why { margin: 8px 20px 4px; font-size: 13px; line-height: 1.45; color: var(--text2); }
|
||||
.rooms-why b { color: var(--text); font-weight: 600; }
|
||||
.rooms-live { display: flex; flex-direction: column; gap: 10px; padding: 0 12px; }
|
||||
@media (min-width: 600px) {
|
||||
.now-hero { height: auto; padding-top: calc(var(--sat) + 46px); }
|
||||
.now-hero .hud { display: block; position: absolute; top: calc(var(--sat) + 30px); left: 20px; right: 20px; height: 1px; }
|
||||
.now-hero .hud .hb, .now-hero .hud .xh, .now-hero .hud .hud-t { display: none; }
|
||||
.now-hero .hud .wm { left: 0; top: -14px; }
|
||||
.concept-tag, .app[data-tab] .concept-tag, .app[data-mode="situation"] .concept-tag { top: calc(var(--sat) + 62px); right: 66px; left: auto; bottom: auto; }
|
||||
.app[data-tab="now"] .concept-tag { top: calc(var(--sat) + 62px); }
|
||||
}
|
||||
/* LIVE is not harm: hazard red stays reserved for things that hurt people */
|
||||
.dot { background: #fff; }
|
||||
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,255,255,.55); } 70% { box-shadow: 0 0 0 7px rgba(255,255,255,0); } 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); } }
|
||||
.ftop .lv, .patt .plive { background: rgba(255,255,255,.94); color: #05070B; }
|
||||
.ftop .lv i, .patt .plive .dot { background: #05070B; }
|
||||
.lvpill { background: #fff; color: #05070B; } .lvpill i { background: #FF3B30; }
|
||||
.ribbon .nowbtn.live { background: rgba(255,255,255,.14); color: #fff; }
|
||||
.ribbon.breath::before { animation: none; }
|
||||
.badge.live .dot { background: #fff; }
|
||||
/* instrument readouts in the brief */
|
||||
.metric .v { font: 700 21px/1.1 var(--mono); letter-spacing: -.02em; }
|
||||
.metric .v small { font-family: var(--font); }
|
||||
.metric .l { font-size: 11.5px; } .metric .s { font-family: var(--mono); font-size: 9.5px; letter-spacing: .03em; }
|
||||
@@ -129,6 +129,8 @@
|
||||
<button class="backbtn circ glass" id="back" aria-label="Back"><svg class="ic"><use href="#i-chevl"/></svg></button>
|
||||
<div class="sitbtns" id="sitbtns"><button class="circ glass" id="sitFollow" aria-label="Follow"><svg class="ic"><use href="#i-bellplus"/></svg></button><button class="circ glass" id="sitShare" aria-label="Share"><svg class="ic"><use href="#i-share"/></svg></button></div>
|
||||
<div class="sheet glass strong" id="sheet"><div class="grab" id="grab"></div><div class="sc" id="sheetC"></div></div>
|
||||
<div class="rx" id="rx" aria-hidden="true"></div>
|
||||
<div class="roomcomp" id="roomComp"></div>
|
||||
|
||||
<div class="ride" id="ride"></div>
|
||||
<section id="v-capture" class="view" aria-label="Capture"></section>
|
||||
@@ -142,7 +144,7 @@
|
||||
<button class="tab on" data-tab="now" role="tab"><svg class="ic"><use href="#i-radiowaves"/></svg><span>Now</span></button>
|
||||
<button class="tab" data-tab="globe" role="tab"><svg class="ic"><use href="#i-globe"/></svg><span>Globe</span></button>
|
||||
<button class="tab" data-tab="capture" role="tab"><svg class="ic"><use href="#i-viewfinder"/></svg><span>Capture</span></button>
|
||||
<button class="tab" data-tab="threads" role="tab"><svg class="ic"><use href="#i-threads"/></svg><span>Threads</span></button>
|
||||
<button class="tab" data-tab="threads" role="tab"><svg class="ic"><use href="#i-threads"/></svg><span>Rooms</span></button>
|
||||
<button class="tab" data-tab="you" role="tab"><svg class="ic"><use href="#i-person"/></svg><span>You</span></button>
|
||||
</div>
|
||||
<button class="accessory glass" id="accMini"></button>
|
||||
@@ -151,14 +153,17 @@
|
||||
|
||||
<div class="morph" id="morph"></div>
|
||||
<div class="seal-ov" id="sealOv"></div>
|
||||
<div class="attach-ov" id="attachOv"></div>
|
||||
<div class="inspector" id="inspector"></div>
|
||||
<div class="toastp glass" id="toast"></div>
|
||||
<div class="concept-tag">Concept · illustrative data</div>
|
||||
<div class="cold" id="cold">
|
||||
<button class="skip glass" id="skip">Skip</button>
|
||||
<svg class="eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.1"><path d="M2.6 12S6 5.6 12 5.6 21.4 12 21.4 12 18 18.4 12 18.4 2.6 12 2.6 12Z"/><circle cx="12" cy="12" r="3.6"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/></svg>
|
||||
<div class="word">God’s Eye</div>
|
||||
<div class="tag">The window to the world.</div>
|
||||
<div class="ladder" id="ladder"><div class="lr" data-b="orbit" style="--c:var(--b-orbit)"><i></i><b>ORBIT</b><span>36,000–400 KM</span></div><div class="lr" data-b="sky" style="--c:var(--b-sky)"><i></i><b>SKY</b><span>15 KM</span></div><div class="lr" data-b="ground" style="--c:var(--b-ground)"><i></i><b>GROUND</b><span>0 M</span></div><div class="lr" data-b="inside" style="--c:var(--b-inside)"><i></i><b>INSIDE</b><span>A WITNESS</span></div><div class="lmark" id="lmark"></div></div>
|
||||
<div class="calt mono" id="coldAlt">ALT 35,786 KM</div>
|
||||
<div class="ccap mono" id="coldCap"></div>
|
||||
<div class="cwit" id="coldWit"><canvas id="coldCv" width="440" height="560"></canvas><i class="bk a"></i><i class="bk b"></i><i class="bk c"></i><i class="bk d"></i><svg class="fseal"><use href="#i-seal"/></svg><div class="tele mono" id="coldTele">WITNESS · SEALED · ±4 M</div></div>
|
||||
<div class="cttl"><div class="word">GOD’S EYE</div><div class="tag">From orbit to the witness.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!--@TEX-->
|
||||
|
||||
@@ -279,6 +279,24 @@ const PEOPLE = {
|
||||
crowd: { h: 'row_m_seat14', name: 'Lena', hue: 300 },
|
||||
news: { h: 'chopper_cam9', name: 'Desk 9', hue: 10 },
|
||||
ai: { h: 'claude', name: 'AI (proposal)', hue: 260, ai: true },
|
||||
// round two: people across the bands (all fictional)
|
||||
nadia: { h: 'nadia_basco', name: 'Nadia R.', hue: 18, home: [20.45, 121.97], where: 'Basco, Batanes' },
|
||||
rafa: { h: 'rafa_aparri', name: 'Rafael D.', hue: 140, home: [18.36, 121.64], where: 'Aparri' },
|
||||
mei: { h: 'mei_taitung', name: 'Mei-Ling C.', hue: 345, home: [22.76, 121.14], where: 'Taitung' },
|
||||
joon: { h: 'joon_pilotboat', name: 'Joon-ho P.', hue: 190, domain: 'Mariner', home: [22.61, 120.28], where: 'Kaohsiung' },
|
||||
lin: { h: 'lin_forecasts', name: 'Lin W.', hue: 225, domain: 'Met', where: 'Taipei' },
|
||||
repost: { h: 'breaking_now_24', name: 'breaking_now_24', hue: 0 },
|
||||
aiko: { h: 'aiko_sendai', name: 'Aiko S.', hue: 300, home: [38.27, 140.87], where: 'Sendai' },
|
||||
tetsu: { h: 'tetsu_rail', name: 'Tetsuya O.', hue: 60, domain: 'Rail ops', where: 'Fukushima' },
|
||||
tomas: { h: 'tomas_atc', name: 'Tomás V.', hue: 205, domain: 'Pilot', where: 'Reykjavík' },
|
||||
seat: { h: 'seat_23F', name: 'Passenger, 23F', hue: 40 },
|
||||
ines: { h: 'ines_coimbra', name: 'Inês M.', hue: 330, home: [40.2, -8.42], where: 'Coimbra' },
|
||||
bomb: { h: 'bombeiro_ruiz', name: 'Cpt. Ruiz', hue: 8, domain: 'First responder', home: [40.1, -8.0], where: 'Pampilhosa' },
|
||||
carla: { h: 'carla_avellaneda', name: 'Carla G.', hue: 280, home: [-34.66, -58.37], where: 'Avellaneda' },
|
||||
dev: { h: 'dev_storms', name: 'Devansh K.', hue: 110, domain: 'Storm spotter', where: 'Arlington, TX' },
|
||||
yuki: { h: 'yuki_kagoshima', name: 'Yuki T.', hue: 170, home: [31.6, 130.56], where: 'Kagoshima' },
|
||||
ola: { h: 'ola_tromso', name: 'Ola B.', hue: 150, home: [69.65, 18.96], where: 'Tromsø' },
|
||||
amara: { h: 'amara_lakefront', name: 'Amara J.', hue: 25, home: [41.88, -87.62], where: 'Chicago' },
|
||||
};
|
||||
|
||||
Object.assign(GE, { AP, apByIata, FLIGHTS, flightAt, OPNAME, LANES, LANE_V, SHIPS, shipAt, SATS, ISS, CSS, satAt, QUAKES, BIGQ, FIRES, FIRE_CLUSTERS, CYCLONE, CITY_V, PEOPLE });
|
||||
@@ -320,6 +320,9 @@ W.updateDynamic = function () {
|
||||
if (W.on('wind')) for (const w of WIND) { const age = ((t / w.life + w.ph) % 1 + 1) % 1; let la = w.lat, lo = w.lon; const steps = 4, dtH = (age * w.life) / 3600 / steps; for (let k = 0; k < steps; k++) { const [u, v] = windVel(la, lo, t); la += v * dtH; lo += u * dtH; } const [u, v] = windVel(la, lo, t); const fade = Math.sin(age * Math.PI); pushSpr(a, geo.vec(la, lo, 3), [0.75, 0.85, 1, 0.55 * fade], 14, 15, Math.atan2(u * Math.cos(la * D2R), v), -1e9, 1e9, 0, 0, 0.1); }
|
||||
for (const hh of W.userHolos) pushSpr(a, geo.vec(hh.lat, hh.lon, 0.06), [1, 0.85, 0.45, 1], 24, 6, -10, -1e9, 1e9, 0, 0.2, 0.4);
|
||||
if (W.activeExtrasId === 'climb') { const f = clamp((W.simMs - GE.T_CLIMB) / (2.23 * 3600000), 0, 1); const [la, lo] = geo.offset(40.7553, -73.9838, 16, 23.5); pushSpr(a, geo.vec(la, lo, (12 + f * 372) / 1000), [1, 0.3, 0.3, 1], 16, 9, -10, -1e9, 1e9, 0, 0.7, 0.9); }
|
||||
for (const sp of W.extraSprites || []) pushSpr(a, sp.v, sp.col, sp.size || 24, sp.glyph ?? 9, -10, -1e9, 1e9, 0, sp.seed || 0.3, 0.8);
|
||||
// eyes: people and cameras looking right now
|
||||
if (W.on('cctv') || W.on('signed') || W.on('streams')) for (const e of GE.EYES || []) pushSpr(a, geo.vec(e.lat, e.lon, 0.05), e.kind === 'you' ? [1, 0.9, 0.55, 1] : e.kind === 'cctv' ? [1, 0.66, 0.26, 1] : [0.96, 0.82, 0.48, 1], e.kind === 'you' ? 26 : 15, 9, -10, -1e9, 1e9, 0, e.lat * 0.1, 0.6);
|
||||
// situation pins
|
||||
for (const p of W.situationPins) pushSpr(a, geo.vec(p.lat, p.lon, 0.2), p.col, p.size || 16, 9, -10, -1e9, 1e9, 0, p.seed || 0, 0.6);
|
||||
// selection highlight
|
||||
@@ -336,6 +339,7 @@ W.updateDynamic = function () {
|
||||
}
|
||||
if (W.selRoute) pushPolyline(ln, W.selRoute, [1, 0.85, 0.45, 0.8], 1);
|
||||
for (const extra of W.extraLines) pushPolyline(ln, extra.pts, extra.col, extra.dashed || 0);
|
||||
if (GE.eyeLines && (W.on('cctv') || W.on('signed') || W.on('streams'))) for (const ex of GE.eyeLines(W.lastRange)) pushPolyline(ln, ex.pts, ex.col, ex.dashed || 0);
|
||||
for (const hh of W.userHolos) pushPolyline(ln, [geo.vec(hh.lat, hh.lon, 0), geo.vec(hh.lat, hh.lon, 0.06)], [1, 0.85, 0.5, 0.9]);
|
||||
uploadDyn('dynLineBuf', 'dynLineVao', 'dynLineCap', 'dynLineCount', ln, 'line');
|
||||
};
|
||||
@@ -526,14 +530,14 @@ W.post = function (chain, outFb, outW, outH, mode, opts) {
|
||||
gl.viewport(0, 0, opts.vpW ?? (outFb ? outW : gl.drawingBufferWidth), opts.vpH ?? (outFb ? outH : gl.drawingBufferHeight));
|
||||
GL.bindTex(Cp, 'uScene', chain.scene.tex, 0); GL.bindTex(Cp, 'uBloom', bloomOn ? lv[0].b.tex : lv[0].a.tex, 1);
|
||||
gl.uniform2f(Cp.u.uTexel, 1 / chain.w, 1 / chain.h);
|
||||
gl.uniform1f(Cp.u.uExposure, (opts.exposure ?? 1) * (mode === 2 ? 1.25 : 0.62));
|
||||
gl.uniform1f(Cp.u.uExposure, (opts.exposure ?? 1) * (mode === 2 ? 1.25 : mode === 0 ? 0.58 : 0.62));
|
||||
gl.uniform1f(Cp.u.uBloomK, bloomOn ? (mode === 4 ? 0.3 : 0.16) : 0);
|
||||
gl.uniform1f(Cp.u.uVig, opts.vig ?? (mode === 1 ? 0.25 : 0.9));
|
||||
gl.uniform1f(Cp.u.uGrain, (opts.grain ?? W.quality.grain) && mode !== 1 ? 0.022 : 0);
|
||||
gl.uniform1f(Cp.u.uT, performance.now() / 1000 % 100);
|
||||
gl.uniform1f(Cp.u.uFade, opts.fade ?? 1);
|
||||
gl.uniform1f(Cp.u.uFxaa, 1);
|
||||
gl.uniform1f(Cp.u.uSat, mode === 1 ? 1.0 : 1.08);
|
||||
gl.uniform1f(Cp.u.uSat, mode === 1 ? 1.0 : 1.12);
|
||||
gl.uniform1i(Cp.u.uMode, mode);
|
||||
gl.uniform3fv(Cp.u.uLift, [0.00017, 0.00038, 0.001]);
|
||||
gl.uniform3fv(Cp.u.uGain, mode === 0 ? [1.02, 1.0, 0.97] : [1, 1, 1]);
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
/* Painters — procedural sensor frames. Every feed card, room attachment and capture scene is drawn
|
||||
here as the instrument that saw it would show it: IR cloud tops with an enhancement table, radar
|
||||
reflectivity in dBZ bins over a street grid, CCTV frames with burned-in OSD, SAR speckle, shake maps,
|
||||
long-exposure launch arcs. Geography comes from the bundled NASA masks; weather and people are
|
||||
synthetic (Concept · illustrative data). */
|
||||
const PT = (GE.paint = {});
|
||||
const MONO = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace';
|
||||
PT.MONO = MONO;
|
||||
const hexrgb = (s) => [parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16)];
|
||||
const mk = (w, h) => { const c = document.createElement('canvas'); c.width = Math.max(1, w | 0); c.height = Math.max(1, h | 0); return c; };
|
||||
PT.mk = mk;
|
||||
|
||||
/* ---------- value noise + fbm ---------- */
|
||||
function noiseGen(seed) {
|
||||
const r = rng(seed * 7919 + 13); const p = new Uint8Array(512); const v = new Float32Array(256);
|
||||
for (let i = 0; i < 256; i++) { p[i] = i; v[i] = r(); }
|
||||
for (let i = 255; i > 0; i--) { const j = Math.floor(r() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; }
|
||||
for (let i = 0; i < 256; i++) p[i + 256] = p[i];
|
||||
const n = (x, y) => {
|
||||
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi, X = xi & 255, Y = yi & 255;
|
||||
const u = xf * xf * (3 - 2 * xf), w = yf * yf * (3 - 2 * yf);
|
||||
const a = v[p[p[X] + Y]], b = v[p[p[X + 1] + Y]], c = v[p[p[X] + Y + 1]], d = v[p[p[X + 1] + Y + 1]];
|
||||
return a + (b - a) * u + (c - a) * w + (a - b - c + d) * u * w;
|
||||
};
|
||||
n.fbm = (x, y, o = 5, g = 0.5, l = 2.03) => { let s = 0, a = 0.5, f = 1, t = 0; for (let i = 0; i < o; i++) { s += a * n(x * f + i * 17.3, y * f - i * 9.1); t += a; a *= g; f *= l; } return s / t; };
|
||||
n.ridge = (x, y, o = 5) => { let s = 0, a = 0.5, f = 1, t = 0; for (let i = 0; i < o; i++) { const q = 1 - Math.abs(n(x * f + i * 5.2, y * f + i * 1.7) * 2 - 1); s += a * q * q; t += a; a *= 0.5; f *= 2.1; } return s / t; };
|
||||
return n;
|
||||
}
|
||||
PT.noise = noiseGen;
|
||||
|
||||
/* ---------- colour ramps → 256 LUT ---------- */
|
||||
function lut(stops) {
|
||||
const L = new Uint8ClampedArray(256 * 3); const S = stops.map(([t, c]) => [t, hexrgb(c)]);
|
||||
for (let i = 0; i < 256; i++) { const t = i / 255; let k = 1; while (k < S.length - 1 && S[k][0] < t) k++; const a = S[k - 1], b = S[k]; const f = clamp((t - a[0]) / (b[0] - a[0] || 1), 0, 1); for (let j = 0; j < 3; j++) L[i * 3 + j] = a[1][j] + (b[1][j] - a[1][j]) * f; }
|
||||
return L;
|
||||
}
|
||||
const LUT = {
|
||||
inferno: lut([[0, '#000004'], [0.15, '#1f0c48'], [0.3, '#550f6d'], [0.45, '#88226a'], [0.6, '#ba3655'], [0.72, '#e35933'], [0.84, '#f98e09'], [0.94, '#f6d746'], [1, '#fcffa4']]),
|
||||
mmi: lut([[0, '#bfccff'], [0.14, '#a0e6ff'], [0.28, '#80ffff'], [0.42, '#7aff93'], [0.56, '#ffff00'], [0.7, '#ffc800'], [0.84, '#ff6a00'], [1, '#c80000']]),
|
||||
};
|
||||
PT.LUT = LUT;
|
||||
// IR enhancement (BD-style): warm = dark grey; cold cloud tops step through cyan, blue, green, yellow, red, black, white
|
||||
function irColor(T, out) {
|
||||
if (T > -30) { const g = clamp(lerp(28, 196, (30 - T) / 60), 0, 255); out[0] = g; out[1] = g; out[2] = g + 4; return; }
|
||||
const st = [[-30, [150, 222, 245]], [-38, [52, 150, 225]], [-44, [28, 70, 200]], [-50, [40, 190, 70]], [-56, [235, 232, 70]], [-61, [246, 150, 40]], [-66, [226, 46, 30]], [-71, [120, 14, 18]], [-75, [12, 12, 14]], [-79, [96, 96, 100]], [-83, [236, 236, 240]], [-95, [255, 255, 255]]];
|
||||
for (let i = 1; i < st.length; i++) if (T >= st[i][0]) { const a = st[i - 1], b = st[i]; const f = (a[0] - T) / (a[0] - b[0]); for (let j = 0; j < 3; j++) out[j] = a[1][j] + (b[1][j] - a[1][j]) * f; return; }
|
||||
out[0] = out[1] = out[2] = 255;
|
||||
}
|
||||
const DBZ = [[18, '#03b33a'], [25, '#02d802'], [30, '#019a00'], [35, '#f4ec00'], [40, '#e4b800'], [45, '#fd8e00'], [50, '#fb0d00'], [55, '#cf0000'], [60, '#a80000'], [65, '#f400f6'], [70, '#9a52cc'], [75, '#ffffff']].map(([d, c]) => [d, hexrgb(c)]);
|
||||
function dbzColor(z, out) { if (z < 18) { out[3] = 0; return; } let k = 0; while (k < DBZ.length - 1 && DBZ[k + 1][0] <= z) k++; const c = DBZ[k][1]; out[0] = c[0]; out[1] = c[1]; out[2] = c[2]; out[3] = 225; }
|
||||
|
||||
/* ---------- raster helpers ---------- */
|
||||
// fn(u, v, out, x, y) fills out[0..3] for each cell of a w/k × h/k field; drawn scaled onto ctx
|
||||
function field(ctx, w, h, k, fn, smoothOn = true, comp) {
|
||||
const fw = Math.max(1, Math.round(w / k)), fh = Math.max(1, Math.round(h / k));
|
||||
const c = mk(fw, fh), cx = c.getContext('2d'); const id = cx.createImageData(fw, fh); const d = id.data; const out = [0, 0, 0, 255];
|
||||
for (let y = 0; y < fh; y++) for (let x = 0; x < fw; x++) { out[3] = 255; fn(x / fw, y / fh, out, x, y, fw, fh); const o = (y * fw + x) * 4; d[o] = out[0]; d[o + 1] = out[1]; d[o + 2] = out[2]; d[o + 3] = out[3]; }
|
||||
cx.putImageData(id, 0, 0);
|
||||
ctx.save(); if (comp) ctx.globalCompositeOperation = comp; ctx.imageSmoothingEnabled = smoothOn; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(c, 0, 0, w, h); ctx.restore();
|
||||
}
|
||||
PT.field = field;
|
||||
let GRAIN = null;
|
||||
function grain(ctx, w, h, a = 0.07) {
|
||||
if (!GRAIN) { GRAIN = mk(192, 192); const g = GRAIN.getContext('2d'); const id = g.createImageData(192, 192); const r = rng(99); for (let i = 0; i < id.data.length; i += 4) { const v = r() * 255; id.data[i] = id.data[i + 1] = id.data[i + 2] = v; id.data[i + 3] = 255; } g.putImageData(id, 0, 0); }
|
||||
ctx.save(); ctx.globalAlpha = a; ctx.globalCompositeOperation = 'overlay'; ctx.fillStyle = ctx.createPattern(GRAIN, 'repeat'); const ox = (Math.random() * 192) | 0, oy = (Math.random() * 192) | 0; ctx.translate(-ox, -oy); ctx.fillRect(0, 0, w + 192, h + 192); ctx.restore();
|
||||
}
|
||||
function vignette(ctx, w, h, a = 0.55, inner = 0.35) { const g = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * inner, w / 2, h / 2, Math.hypot(w, h) * 0.6); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, `rgba(0,0,0,${a})`); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); }
|
||||
function scan(ctx, w, h, a = 0.08, step = 3) { ctx.save(); ctx.fillStyle = `rgba(0,0,0,${a})`; for (let y = 0; y < h; y += step) ctx.fillRect(0, y, w, 1); ctx.restore(); }
|
||||
function glow(ctx, x, y, r, col, a = 1) { const g = ctx.createRadialGradient(x, y, 0, x, y, r); g.addColorStop(0, col.replace('A', a)); g.addColorStop(0.35, col.replace('A', a * 0.35)); g.addColorStop(1, col.replace('A', 0)); ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2); }
|
||||
// OSD text, camera style
|
||||
function osd(ctx, txt, x, y, px, align = 'left', col = 'rgba(255,255,255,.92)', base = 'top') {
|
||||
ctx.save(); ctx.font = `600 ${px}px ${MONO}`; ctx.textAlign = align; ctx.textBaseline = base; ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = Math.max(2, px / 3.4); ctx.strokeStyle = 'rgba(0,0,0,.72)'; ctx.strokeText(txt, x, y); ctx.fillStyle = col; ctx.fillText(txt, x, y); ctx.restore();
|
||||
}
|
||||
function label(ctx, txt, x, y, px, col = 'rgba(235,240,248,.75)', align = 'left') { ctx.save(); ctx.font = `600 ${px}px ${MONO}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = col; ctx.shadowColor = 'rgba(0,0,0,.9)'; ctx.shadowBlur = 4; ctx.fillText(txt, x, y); ctx.restore(); }
|
||||
Object.assign(PT, { grain, vignette, scan, glow, osd, label, lut, irColor });
|
||||
const z2 = (n) => String(n).padStart(2, '0');
|
||||
PT.stamp = (ms, sep = ' ') => { const d = new Date(ms); return `${d.getUTCFullYear()}-${z2(d.getUTCMonth() + 1)}-${z2(d.getUTCDate())}${sep}${z2(d.getUTCHours())}:${z2(d.getUTCMinutes())}:${z2(d.getUTCSeconds())}`; };
|
||||
|
||||
/* ---------- maps (equirectangular, square km pixels) ---------- */
|
||||
function mapView(lat0, lon0, spanLon, w, h) {
|
||||
const spanLat = spanLon * Math.cos(lat0 * D2R) * (h / w);
|
||||
return { lat0, lon0, spanLon, spanLat, w, h, X: (lon) => ((((lon - lon0) + 540) % 360) - 180) / spanLon * w + w / 2, Y: (lat) => h / 2 - ((lat - lat0) / spanLat) * h, lon: (x) => lon0 + ((x - w / 2) / w) * spanLon, lat: (y) => lat0 + ((h / 2 - y) / h) * spanLat, kmPx: (spanLon * 111.32 * Math.cos(lat0 * D2R)) / w };
|
||||
}
|
||||
PT.mapView = mapView;
|
||||
const cropCache = new Map();
|
||||
function cropData(name, mv, w, h) {
|
||||
const key = [name, mv.lat0.toFixed(3), mv.lon0.toFixed(3), mv.spanLon.toFixed(3), w, h].join('|');
|
||||
if (cropCache.has(key)) return cropCache.get(key);
|
||||
const img = GE.world.img && GE.world.img[name]; const c = mk(w, h), cx = c.getContext('2d', { willReadFrequently: true });
|
||||
if (img) { cx.imageSmoothingQuality = 'high'; const iw = img.width, ih = img.height; const sx = ((mv.lon0 - mv.spanLon / 2 + 180) / 360) * iw, sy = ((90 - (mv.lat0 + mv.spanLat / 2)) / 180) * ih; cx.drawImage(img, sx, sy, (mv.spanLon / 360) * iw, (mv.spanLat / 180) * ih, 0, 0, w, h); }
|
||||
const d = cx.getImageData(0, 0, w, h).data; if (cropCache.size > 40) cropCache.clear(); cropCache.set(key, d); return d;
|
||||
}
|
||||
PT.cropData = cropData;
|
||||
// dark instrument map: land/sea from the water mask, optional relief (topo) and night lights
|
||||
function mapBase(ctx, mv, o = {}) {
|
||||
const { w, h } = mv; const k = o.k || 1; const fw = Math.round(w / k), fh = Math.round(h / k);
|
||||
const wm = cropData('water', mv, fw, fh); const tp = o.relief ? cropData('topo', mv, fw, fh) : null; const nl = o.lights ? cropData('night', mv, fw, fh) : null;
|
||||
const land = hexrgb(o.land || '#121922'), sea = hexrgb(o.sea || '#05080c'), coast = hexrgb(o.coast || '#46566c');
|
||||
field(ctx, w, h, k, (u, v, out, x, y) => {
|
||||
const i = (y * fw + x) * 4; const m = wm[i] / 255; const l = smooth(0.62, 0.38, m);
|
||||
let r = lerp(sea[0], land[0], l), g = lerp(sea[1], land[1], l), b = lerp(sea[2], land[2], l);
|
||||
if (tp && l > 0.3) { const e0 = tp[i], ex = tp[Math.min(i + 4, tp.length - 4)] - tp[Math.max(i - 4, 0)], ey = tp[Math.min(i + fw * 4, tp.length - 4)] - tp[Math.max(i - fw * 4, 0)]; const s = clamp(1 + (ey - ex) * 0.035 * (o.relief || 1) + e0 / 255 * 0.5, 0.55, 1.9); r *= s; g *= s; b *= s; }
|
||||
if (nl) { const L = Math.max(0, nl[i + 1] - 10) / 245 * o.lights; r += L * 255; g += L * 190; b += L * 110; }
|
||||
const e = smooth(0.6, 0.95, 1 - Math.abs(m - 0.5) * 2) * (o.coastA ?? 1); r = lerp(r, coast[0], e); g = lerp(g, coast[1], e); b = lerp(b, coast[2], e);
|
||||
out[0] = r; out[1] = g; out[2] = b;
|
||||
});
|
||||
}
|
||||
PT.mapBase = mapBase;
|
||||
function graticule(ctx, mv, step, col = 'rgba(160,190,230,.10)') {
|
||||
ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 1; ctx.setLineDash([2, 4]);
|
||||
for (let lo = Math.ceil((mv.lon0 - mv.spanLon / 2) / step) * step; lo < mv.lon0 + mv.spanLon / 2; lo += step) { const x = mv.X(lo); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, mv.h); ctx.stroke(); }
|
||||
for (let la = Math.ceil((mv.lat0 - mv.spanLat / 2) / step) * step; la < mv.lat0 + mv.spanLat / 2; la += step) { const y = mv.Y(la); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(mv.w, y); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
function scaleBar(ctx, mv, km, x, y, col = 'rgba(235,240,248,.8)') { const px = km / mv.kmPx; ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x, y - 4); ctx.lineTo(x, y); ctx.lineTo(x + px, y); ctx.lineTo(x + px, y - 4); ctx.stroke(); ctx.restore(); label(ctx, km + ' KM', x + px + 5, y - 2, Math.max(8, mv.w / 42), col); }
|
||||
function plane(ctx, x, y, hdg, s, col) { ctx.save(); ctx.translate(x, y); ctx.rotate(hdg); ctx.scale(s / 24, s / 24); ctx.translate(-12, -12); ctx.fillStyle = col; ctx.fill(new Path2D('M12 2.8c.9 0 1.4.9 1.4 2v4.6l7.1 4.2v1.9l-7.1-2.1v4.4l2.2 1.7v1.6L12 20.2l-3.6.9v-1.6l2.2-1.7v-4.4l-7.1 2.1v-1.9l7.1-4.2V4.8c0-1.1.5-2 1.4-2Z')); ctx.restore(); }
|
||||
function cross(ctx, x, y, r, col, lw = 1.4) { ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.beginPath(); ctx.moveTo(x - r, y); ctx.lineTo(x + r, y); ctx.moveTo(x, y - r); ctx.lineTo(x, y + r); ctx.stroke(); ctx.restore(); }
|
||||
function poly(ctx, pts, close) { ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]))); if (close) ctx.closePath(); }
|
||||
Object.assign(PT, { graticule, scaleBar, plane, cross, poly });
|
||||
|
||||
/* =====================================================================================
|
||||
ORBIT
|
||||
===================================================================================== */
|
||||
// IR cloud tops of a tropical cyclone with eyewall lightning and the advisory track
|
||||
PT.ir = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(o.seed || 3);
|
||||
const C = GE.CYCLONE.at(0); const mv = mapView(C.lat + (o.dy ?? 0.6), C.lon + (o.dx ?? -0.8), o.span || 13, w, h);
|
||||
const k = 2, fw = Math.round(w / k), fh = Math.round(h / k); const wm = cropData('water', mv, fw, fh); const col = [0, 0, 0];
|
||||
const cl = Math.cos(C.lat * D2R);
|
||||
field(ctx, w, h, k, (u, v, out, x, y) => {
|
||||
const lat = mv.lat(v * h), lon = mv.lon(u * w); const dx = (lon - C.lon) * cl / 1.7, dy = (lat - C.lat) / 1.7; const r = Math.hypot(dx, dy), th = Math.atan2(dy, dx);
|
||||
const wn = N.fbm(dx * 0.8 + 11, dy * 0.8 + 3, 4);
|
||||
const arms = 0.5 + 0.5 * Math.sin(3 * (th - 2.1 * Math.log(r + 0.15)) + wn * 5.5);
|
||||
const conv = N.fbm(dx * 1.9 + 4, dy * 1.9 - 2, 5);
|
||||
const cdo = smooth(2.3, 0.9, r + (wn - 0.5) * 0.9), eye = smooth(0.16, 0.34, r), wall = Math.exp(-(((r - 0.4) / 0.15) ** 2));
|
||||
let c = Math.max(cdo * (0.8 + 0.22 * conv), arms * smooth(7.5, 1.8, r) * (0.3 + 0.7 * conv) * smooth(0.28, 0.62, conv));
|
||||
c = Math.max(c, wall * 0.97) * eye;
|
||||
const bg = smooth(0.64, 0.86, N.fbm(dx * 0.55 + 40, dy * 0.55 + 9, 5)) * 0.62; c = Math.max(c, bg * (1 - cdo) * smooth(1.5, 4, r));
|
||||
c += wall * smooth(0.6, 0.95, N(dx * 8, dy * 8)) * 0.12 + cdo * smooth(0.72, 0.98, N(dx * 5 + 3, dy * 5)) * 0.08;
|
||||
const i = (y * fw + x) * 4; const land = 1 - wm[i] / 255;
|
||||
irColor(26 - c * 114 + (c < 0.2 ? land * -10 : 0), col);
|
||||
const edge = smooth(0.62, 0.95, 1 - Math.abs(wm[i] / 255 - 0.5) * 2) * (c < 0.55 ? 0.9 : 0.35);
|
||||
out[0] = lerp(col[0], 236, edge); out[1] = lerp(col[1], 214, edge); out[2] = lerp(col[2], 96, edge);
|
||||
});
|
||||
graticule(ctx, mv, 5, 'rgba(255,255,255,.16)');
|
||||
// advisory track: past (solid dots), forecast (dashed + cone)
|
||||
const tr = GE.CYCLONE.track; ctx.save(); ctx.lineWidth = Math.max(1.2, w / 260);
|
||||
const P = (q) => [mv.X(q[2]), mv.Y(q[1])];
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.85)'; poly(ctx, tr.filter((q) => q[0] <= 0).map(P)); ctx.stroke();
|
||||
ctx.setLineDash([4, 4]); poly(ctx, tr.filter((q) => q[0] >= 0).map(P)); ctx.stroke(); ctx.setLineDash([]);
|
||||
const fut = tr.filter((q) => q[0] >= 0); const L = [], Rr = [];
|
||||
for (let i = 0; i < fut.length; i++) { const q = fut[i], n = fut[Math.min(i + 1, fut.length - 1)], p0 = fut[Math.max(i - 1, 0)]; const b = geo.bearing(p0[1], p0[2], n[1], n[2]); const km = GE.CYCLONE.cone(q[0]); const a = geo.dest(q[1], q[2], b - Math.PI / 2, km), c2 = geo.dest(q[1], q[2], b + Math.PI / 2, km); L.push([mv.X(a[1]), mv.Y(a[0])]); Rr.push([mv.X(c2[1]), mv.Y(c2[0])]); }
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1; poly(ctx, [...L, ...Rr.reverse()], true); ctx.stroke();
|
||||
for (const q of tr) { const [x, y] = P(q); ctx.beginPath(); ctx.arc(x, y, q[0] === 0 ? w / 70 : w / 150, 0, TAU); ctx.fillStyle = q[0] < 0 ? '#fff' : q[0] === 0 ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,.4)'; ctx.fill(); if (q[0] === 0) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); } }
|
||||
ctx.restore();
|
||||
// lightning in the eyewall and bands
|
||||
const R = rng(o.seed || 5); const cx0 = mv.X(C.lon), cy0 = mv.Y(C.lat); const pxDeg = w / mv.spanLon;
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = 0; i < (o.flashes ?? 34); i++) { const rr = (i < 12 ? 0.35 + R() * 0.25 : 0.7 + R() * 3.2) * 1.7, a = R() * TAU; const x = cx0 + Math.cos(a) * rr * pxDeg, y = cy0 - Math.sin(a) * rr * pxDeg; glow(ctx, x, y, w / (i < 4 ? 9 : 26), 'rgba(200,230,255,A)', i < 4 ? 0.55 : 0.4); cross(ctx, x, y, w / 90, 'rgba(255,255,255,.95)', 1.2); }
|
||||
ctx.restore();
|
||||
label(ctx, 'IR 10.4 µm', w * 0.05, h * PT.topY, Math.max(9, w / 34), 'rgba(255,255,255,.85)');
|
||||
// enhancement legend
|
||||
const lx = w * 0.6, ly = h * PT.botY, lw = w * 0.34, lh = Math.max(4, h / 90);
|
||||
for (let i = 0; i < 60; i++) { irColor(10 - (i / 59) * 100, col); ctx.fillStyle = `rgb(${col[0]},${col[1]},${col[2]})`; ctx.fillRect(lx + (i / 60) * lw, ly, lw / 60 + 0.5, lh); }
|
||||
label(ctx, '+10', lx, ly - lh, Math.max(7, w / 48), 'rgba(255,255,255,.7)'); label(ctx, '−90 °C', lx + lw, ly - lh, Math.max(7, w / 48), 'rgba(255,255,255,.7)', 'right');
|
||||
};
|
||||
|
||||
// ISS external camera: the night side raycast from 418 km, airglow on the limb, truss in frame
|
||||
PT.isscam = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const lat0 = o.lat ?? 25.5, lon0 = o.lon ?? 31.0; const altKm = 418;
|
||||
const reg = mapView(lat0 + 14, lon0 + 2, 46, 900, 900 * 0.75);
|
||||
const RW = 900, RH = Math.round(900 * 0.75); const night = cropData('night', reg, RW, RH), clouds = cropData('clouds', reg, RW, RH), day = cropData('day', reg, RW, RH);
|
||||
const en = geo.enu(lat0, lon0); const eye = geo.vec(lat0, lon0, altKm); const hd = (o.hdg ?? 8) * D2R, pitch = (o.pitch ?? 24) * D2R;
|
||||
const fwdH = v3.add(v3.scale(en.north, Math.cos(hd)), v3.scale(en.east, Math.sin(hd)));
|
||||
const fwd = v3.norm(v3.add(v3.scale(fwdH, Math.cos(pitch)), v3.scale(en.up, -Math.sin(pitch))));
|
||||
const right = v3.norm(v3.cross(fwd, en.up)), up = v3.cross(right, fwd); const tanF = Math.tan((o.fov ?? 64) * D2R / 2), asp = w / h;
|
||||
const N = noiseGen(31); const R2 = rng(4);
|
||||
const samp = (D, lat, lon) => { const x = clamp(Math.round(((lon - (reg.lon0 - reg.spanLon / 2)) / reg.spanLon) * RW), 0, RW - 1), y = clamp(Math.round((((reg.lat0 + reg.spanLat / 2) - lat) / reg.spanLat) * RH), 0, RH - 1); return (y * RW + x) * 4; };
|
||||
field(ctx, w, h, 2, (u, v, out) => {
|
||||
const sx = (u * 2 - 1) * tanF * asp, sy = (1 - v * 2) * tanF; const d = v3.norm(v3.add(fwd, v3.add(v3.scale(right, sx), v3.scale(up, sy))));
|
||||
const b = v3.dot(eye, d), c = v3.dot(eye, eye) - 1, disc = b * b - c;
|
||||
if (disc > 0 && -b - Math.sqrt(disc) > 0) {
|
||||
const t = -b - Math.sqrt(disc); const p = v3.madd(eye, d, t); const [la, lo] = geo.ll(p); const i = samp(null, la, lo);
|
||||
const graz = clamp(-v3.dot(d, v3.norm(p)), 0, 1); const haze = Math.pow(1 - graz, 3);
|
||||
const L = Math.pow(night[i + 1] / 255, 1.25) * 1.5; const cl = clouds[i] / 255;
|
||||
out[0] = clamp(L * 255 * 1.05 + day[i] * 0.03 + cl * 14 + haze * 24, 0, 255); out[1] = clamp(L * 190 + day[i + 1] * 0.035 + cl * 18 + haze * 34, 0, 255); out[2] = clamp(L * 95 + day[i + 2] * 0.05 + cl * 26 + haze * 56, 0, 255);
|
||||
const lit = (1 - cl * 0.6); out[0] *= lit; out[1] *= lit; out[2] *= lit; out[0] += cl * 10; out[1] += cl * 12; out[2] += cl * 20;
|
||||
} else {
|
||||
const tc = Math.max(0, -b); const cp = v3.madd(eye, d, tc); const a = (v3.len(cp) - 1) * EARTH_KM;
|
||||
const glowG = Math.exp(-(((a - 92) / 9) ** 2)) * 0.95, glowR = Math.exp(-(((a - 110) / 14) ** 2)) * 0.35, sc = Math.exp(-Math.max(0, a) / 18) * 0.55;
|
||||
out[0] = clamp(glowR * 150 + sc * 40, 0, 255); out[1] = clamp(glowG * 150 + sc * 70, 0, 255); out[2] = clamp(glowG * 70 + sc * 120 + 6, 0, 255);
|
||||
if (a > 140 && R2() < 0.006) { const s = 120 + R2() * 135; out[0] = s; out[1] = s; out[2] = s + 10; }
|
||||
}
|
||||
});
|
||||
// a lightning flash under cloud near the horizon
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, w * 0.28, h * 0.36, w * 0.12, 'rgba(170,200,255,A)', 0.7); glow(ctx, w * 0.31, h * 0.37, w * 0.05, 'rgba(240,248,255,A)', 0.8); ctx.restore();
|
||||
// truss & solar array in the corner, rim-lit
|
||||
ctx.save(); ctx.translate(w * 0.98, h * 0.02); ctx.rotate(0.42); ctx.fillStyle = '#07080b';
|
||||
ctx.fillRect(-w * 0.62, -h * 0.07, w * 0.9, h * 0.1); ctx.strokeStyle = 'rgba(214,170,90,.55)'; ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 16; i++) { ctx.beginPath(); ctx.moveTo(-w * 0.62 + i * w * 0.056, -h * 0.07); ctx.lineTo(-w * 0.62 + i * w * 0.056, h * 0.03); ctx.stroke(); }
|
||||
ctx.strokeStyle = 'rgba(255,214,140,.7)'; ctx.beginPath(); ctx.moveTo(-w * 0.62, h * 0.03); ctx.lineTo(w * 0.3, h * 0.03); ctx.stroke(); ctx.restore();
|
||||
vignette(ctx, w, h, 0.45, 0.4); grain(ctx, w, h, 0.09);
|
||||
osd(ctx, `ISS EXT CAM 2 · ${altKm} KM`, w * 0.05, h * PT.topY, Math.max(9, w / 36));
|
||||
};
|
||||
|
||||
// your sky: the station's pass drawn as an arc on an alt-az chart
|
||||
PT.skychart = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const cx = w / 2, cy = h * 0.47, R = Math.min(w, h) * 0.4;
|
||||
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h)); g.addColorStop(0, '#0d1430'); g.addColorStop(0.55, '#070a18'); g.addColorStop(1, '#020308'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
|
||||
const r = rng(o.seed || 12); ctx.save();
|
||||
for (let i = 0; i < 420; i++) { const a = r() * TAU, d = Math.sqrt(r()) * R * 1.35; const x = cx + Math.cos(a) * d, y = cy + Math.sin(a) * d; const m = Math.pow(r(), 6); ctx.fillStyle = `rgba(${220 + m * 35},${225 + m * 30},255,${0.25 + m * 0.75})`; ctx.fillRect(x, y, 1 + m * 2, 1 + m * 2); }
|
||||
// a dipper-shaped asterism
|
||||
const ast = [[-0.62, 0.3], [-0.48, 0.25], [-0.34, 0.29], [-0.22, 0.37], [-0.18, 0.51], [-0.02, 0.53], [0.02, 0.39]].map(([a, b]) => [cx + a * R, cy + b * R]);
|
||||
ctx.strokeStyle = 'rgba(160,190,255,.22)'; ctx.lineWidth = 1; poly(ctx, ast); ctx.stroke(); for (const [x, y] of ast) { ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(x, y, w / 200 + 0.8, 0, TAU); ctx.fill(); }
|
||||
ctx.strokeStyle = 'rgba(183,156,255,.28)'; ctx.setLineDash([2, 4]);
|
||||
for (const k of [1, 2 / 3, 1 / 3]) { ctx.beginPath(); ctx.arc(cx, cy, R * k, 0, TAU); ctx.stroke(); }
|
||||
ctx.setLineDash([]); ctx.strokeStyle = 'rgba(183,156,255,.6)'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.arc(cx, cy, R, 0, TAU); ctx.stroke();
|
||||
const fs = Math.max(9, w / 30); for (const [t, a] of [['N', -90], ['E', 0], ['S', 90], ['W', 180]]) label(ctx, t, cx + Math.cos(a * D2R) * (R + fs * 0.9), cy + Math.sin(a * D2R) * (R + fs * 0.9), fs, t === 'N' ? '#FF6A3D' : 'rgba(220,210,255,.8)', 'center');
|
||||
label(ctx, '30°', cx + 3, cy - R * 2 / 3 + 7, fs * 0.72, 'rgba(183,156,255,.6)'); label(ctx, '60°', cx + 3, cy - R / 3 + 7, fs * 0.72, 'rgba(183,156,255,.6)');
|
||||
// the pass: rises WSW, culminates ~54°, sets ENE
|
||||
const az0 = 245 * D2R, az1 = 62 * D2R, maxEl = (o.max ?? 54) * D2R; const pts = [];
|
||||
for (let i = 0; i <= 60; i++) { const f = i / 60; let az = az0 + (((az1 - az0 + TAU * 1.5) % TAU) - Math.PI) * 0 + f * (((az1 - az0 + 3 * Math.PI) % TAU) - Math.PI); const el = Math.sin(f * Math.PI) * maxEl; const rr = R * (1 - el / (Math.PI / 2)); pts.push([cx + Math.sin(az) * rr, cy - Math.cos(az) * rr, f]); }
|
||||
ctx.lineWidth = Math.max(2, w / 120); const gr = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[60][0], pts[60][1]); gr.addColorStop(0, 'rgba(201,167,255,.25)'); gr.addColorStop(0.5, 'rgba(230,214,255,1)'); gr.addColorStop(1, 'rgba(201,167,255,.25)'); ctx.strokeStyle = gr; poly(ctx, pts); ctx.stroke();
|
||||
const p0 = o.at ?? Date.now() + 3.2 * 3600000;
|
||||
for (const k of [0, 20, 30, 40, 60]) { const [x, y] = pts[k]; ctx.fillStyle = '#E6D6FF'; ctx.beginPath(); ctx.arc(x, y, k === 30 ? w / 60 : w / 120, 0, TAU); ctx.fill(); if (k % 20 === 0 || k === 30) label(ctx, fmt.hm(p0 + (k / 60) * 6.5 * 60000), x + 7, y - 9, fs * 0.8, 'rgba(240,232,255,.9)'); }
|
||||
ctx.globalCompositeOperation = 'lighter'; glow(ctx, pts[30][0], pts[30][1], w / 10, 'rgba(200,170,255,A)', 0.55); ctx.restore();
|
||||
// moon
|
||||
ctx.save(); ctx.translate(cx + R * 0.62, cy + R * 0.45); ctx.fillStyle = '#E8E4D8'; ctx.beginPath(); ctx.arc(0, 0, w / 40, 0, TAU); ctx.fill(); ctx.fillStyle = '#070a18'; ctx.beginPath(); ctx.arc(w / 110, -w / 160, w / 42, 0, TAU); ctx.fill(); ctx.restore();
|
||||
label(ctx, `MAX ${Math.round(maxEl * R2D)}°`, pts[30][0], pts[30][1] + fs * 1.6, fs * 0.85, '#E6D6FF', 'center');
|
||||
};
|
||||
|
||||
// long-range tracking camera: launch arc + sunlit "jellyfish" plume in twilight
|
||||
PT.plume = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const r = rng(o.seed || 21); const N = noiseGen(8);
|
||||
const hz = h * 0.78; const sky = ctx.createLinearGradient(0, 0, 0, hz);
|
||||
sky.addColorStop(0, '#02040c'); sky.addColorStop(0.45, '#0a1638'); sky.addColorStop(0.78, '#1d3a6a'); sky.addColorStop(0.93, '#5a5e7a'); sky.addColorStop(1, '#c47b4a');
|
||||
ctx.fillStyle = sky; ctx.fillRect(0, 0, w, hz);
|
||||
for (let i = 0; i < 260; i++) { const y = r() * hz * 0.7; ctx.fillStyle = `rgba(255,255,255,${0.15 + r() * 0.6 * (1 - y / hz)})`; ctx.fillRect(r() * w, y, 1, 1); }
|
||||
// arc: pad at lower left, pitching over to the right
|
||||
const pad = [w * 0.16, hz - 2]; const top = [w * 0.66, h * 0.22]; const ctrl = [w * 0.2, h * 0.18];
|
||||
const B = (t) => [(1 - t) ** 2 * pad[0] + 2 * (1 - t) * t * ctrl[0] + t * t * top[0], (1 - t) ** 2 * pad[1] + 2 * (1 - t) * t * ctrl[1] + t * t * top[1]];
|
||||
// sunlit plume (behind the arc): dome expanding up/right, iridescent rim
|
||||
const pc = B(0.86); ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = 0; i < 90; i++) { const t = 0.55 + (i / 90) * 0.45; const p = B(t); const s = (t - 0.55) * w * 0.95; const a = (0.022 + 0.02 * N(i * 0.3, 2)) * (t > 0.7 ? 1 : (t - 0.55) / 0.15);
|
||||
const g = ctx.createRadialGradient(p[0] + s * 0.25, p[1] - s * 0.2, s * 0.05, p[0] + s * 0.25, p[1] - s * 0.2, s * 0.85); g.addColorStop(0, `rgba(210,235,255,${a * 1.2})`); g.addColorStop(0.55, `rgba(120,190,255,${a * 0.7})`); g.addColorStop(0.85, `rgba(255,160,120,${a * 0.35})`); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); }
|
||||
// jellyfish tendrils
|
||||
ctx.strokeStyle = 'rgba(190,225,255,.22)'; ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 26; i++) { const a = -0.3 + (i / 25) * 2.2; ctx.beginPath(); ctx.moveTo(pc[0], pc[1]); for (let k = 1; k <= 12; k++) { const d = k * w * 0.028; ctx.lineTo(pc[0] + Math.cos(a) * d + N(i, k * 0.4) * 10, pc[1] + Math.sin(a) * d * 0.6 + k * k * 0.5); } ctx.stroke(); }
|
||||
ctx.restore();
|
||||
// the long-exposure streak
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineCap = 'round';
|
||||
for (const [lw, col] of [[w / 22, 'rgba(255,120,40,.10)'], [w / 50, 'rgba(255,150,60,.35)'], [w / 120, 'rgba(255,220,170,.95)'], [w / 300, 'rgba(255,255,255,1)']]) {
|
||||
ctx.lineWidth = lw; ctx.strokeStyle = col; ctx.beginPath(); for (let i = 0; i <= 80; i++) { const t = i / 80; const p = B(t * 0.88); i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); }
|
||||
glow(ctx, pad[0], pad[1], w * 0.2, 'rgba(255,150,70,A)', 0.55); ctx.restore();
|
||||
// coast: land silhouette with lights, water reflecting the streak
|
||||
ctx.fillStyle = '#040508'; ctx.beginPath(); ctx.moveTo(0, hz); for (let x = 0; x <= w; x += 6) ctx.lineTo(x, hz - 3 - N.fbm(x / 60, 1, 3) * 12 * (x < w * 0.5 ? 1 : 0.4)); ctx.lineTo(w, hz + 8); ctx.lineTo(0, hz + 8); ctx.fill();
|
||||
for (let i = 0; i < 70; i++) { const x = r() * w; ctx.fillStyle = `rgba(255,${190 + r() * 50},${120 + r() * 60},${0.5 + r() * 0.5})`; ctx.fillRect(x, hz - 2 - r() * 6, 1.4, 1.4); }
|
||||
const wg = ctx.createLinearGradient(0, hz, 0, h); wg.addColorStop(0, '#0d1424'); wg.addColorStop(1, '#030407'); ctx.fillStyle = wg; ctx.fillRect(0, hz + 6, w, h - hz);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (let y = hz + 8; y < h; y += 2) { const f = (y - hz) / (h - hz); const jit = (N(y * 0.2, 3) - 0.5) * 18 * f; ctx.fillStyle = `rgba(255,140,60,${0.35 * (1 - f)})`; ctx.fillRect(pad[0] - 4 - f * 10 + jit, y, 8 + f * 22, 1.4); ctx.fillStyle = `rgba(150,190,255,${0.08 * (1 - f)})`; ctx.fillRect(w * 0.5 + jit * 2, y, w * 0.3, 1); } ctx.restore();
|
||||
vignette(ctx, w, h, 0.4); grain(ctx, w, h, 0.1);
|
||||
osd(ctx, 'TRK CAM 3 · 400 MM · LONG EXPOSURE', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.8)');
|
||||
};
|
||||
|
||||
// active fire seen from orbit: hillshaded terrain, burn scar, front, hotspot pixels, smoke plume
|
||||
PT.firesat = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(o.seed || 17); const s = 2.1 / w;
|
||||
const scarC = [w * 0.62, h * 0.6], scarR = w * 0.26; const front = [];
|
||||
const H = (x, y) => N.ridge(x * s, y * s, 5) * 0.9 + N.fbm(x * s * 0.4 + 5, y * s * 0.4, 3) * 0.6;
|
||||
const scarAt = (x, y) => { const dx = x - scarC[0], dy = (y - scarC[1]) * 1.35; const a = Math.atan2(dy, dx); const rr = scarR * (0.75 + 0.45 * N(Math.cos(a) * 1.4 + 3, Math.sin(a) * 1.4 + 7)); return Math.hypot(dx, dy) / rr; };
|
||||
field(ctx, w, h, 2, (u, v, out) => {
|
||||
const x = u * w, y = v * h; const e = 2.2; const hc = H(x, y), hx = H(x + e, y), hy = H(x, y + e); const shade = clamp(0.82 + (hc - hx) * 15 - (hc - hy) * 11, 0.45, 1.35);
|
||||
const veg = smooth(0.42, 0.7, N.fbm(x * s * 1.8 + 30, y * s * 1.8, 4));
|
||||
let r = lerp(150, 62, veg), g = lerp(132, 82, veg), b = lerp(88, 48, veg);
|
||||
const fld = N(x * s * 6 + 70, y * s * 6) > 0.72 && hc < 0.55 ? 1 : 0; if (fld) { r = 168; g = 150; b = 98; }
|
||||
r *= shade; g *= shade; b *= shade;
|
||||
const sc = scarAt(x, y); const burnt = smooth(1.02, 0.94, sc); const rim = Math.exp(-(((sc - 1) / 0.05) ** 2));
|
||||
r = lerp(r, 34 + hc * 18, burnt); g = lerp(g, 30 + hc * 14, burnt); b = lerp(b, 28 + hc * 12, burnt);
|
||||
r = lerp(r, 92, rim * 0.6); g = lerp(g, 60, rim * 0.6); b = lerp(b, 40, rim * 0.6);
|
||||
out[0] = r; out[1] = g; out[2] = b;
|
||||
});
|
||||
// river & roads & a village
|
||||
ctx.save(); ctx.strokeStyle = 'rgba(40,70,90,.9)'; ctx.lineWidth = w / 120; ctx.beginPath(); for (let i = 0; i <= 40; i++) { const t = i / 40; const x = t * w, y = h * 0.18 + Math.sin(t * 5) * h * 0.05 + N(t * 4, 9) * h * 0.08; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(225,215,190,.45)'; ctx.lineWidth = w / 260; for (const k of [0, 1, 2]) { ctx.beginPath(); for (let i = 0; i <= 30; i++) { const t = i / 30; const x = t * w, y = h * (0.35 + k * 0.28) + Math.sin(t * 3 + k) * h * 0.06 + N(t * 3, k * 5) * 20; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke(); }
|
||||
const vr = rng(3); for (let i = 0; i < 55; i++) { ctx.fillStyle = `rgba(${200 + vr() * 40},${190 + vr() * 30},${170 + vr() * 30},.8)`; ctx.fillRect(w * 0.2 + vr.gauss() * w * 0.04, h * 0.42 + vr.gauss() * h * 0.03, 2, 2); }
|
||||
ctx.restore();
|
||||
// fire front on the downwind (west) edge + hotspot pixels
|
||||
const R = rng(9); ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = 0; i < 160; i++) { const a = Math.PI * (0.55 + R() * 0.95); let lo = 0.5, hi = 1.6; for (let k = 0; k < 14; k++) { const m = (lo + hi) / 2; const x = scarC[0] + Math.cos(a) * scarR * m, y = scarC[1] + (Math.sin(a) * scarR * m) / 1.35; if (scarAt(x, y) < 1) lo = m; else hi = m; } const x = scarC[0] + Math.cos(a) * scarR * lo, y = scarC[1] + (Math.sin(a) * scarR * lo) / 1.35; front.push([x, y]); glow(ctx, x, y, w / 36, 'rgba(255,90,20,A)', 0.4); }
|
||||
for (const [x, y] of front) { const q = R(); const ps = Math.max(2, w / 110); ctx.fillStyle = q > 0.8 ? 'rgba(255,240,150,.95)' : q > 0.4 ? 'rgba(255,140,30,.9)' : 'rgba(230,40,20,.85)'; ctx.fillRect(Math.round(x / ps) * ps, Math.round(y / ps) * ps, ps, ps); }
|
||||
ctx.restore();
|
||||
// smoke: drifts WNW from the front
|
||||
const src = [scarC[0] - scarR * 0.85, scarC[1] - scarR * 0.1], dir = v3.norm([-1, -0.55, 0]);
|
||||
field(ctx, w, h, 3, (u, v, out) => {
|
||||
const x = u * w - src[0], y = v * h - src[1]; const al = x * dir[0] + y * dir[1], ac = -x * dir[1] + y * dir[0]; if (al < -20) { out[3] = 0; return; }
|
||||
const wd = 16 + Math.max(0, al) * 0.5; const n = N.fbm(al / 46 + 4, ac / 22, 5); const dn = Math.exp(-((ac / wd) ** 2) * (1.4 - n)) * smooth(-20, 30, al) * Math.exp(-Math.max(0, al) / (w * 2.6)) * (0.5 + 0.8 * n);
|
||||
const a = clamp(dn * 1.1, 0, 0.92); out[0] = lerp(215, 160, n); out[1] = lerp(212, 160, n); out[2] = lerp(206, 170, n); out[3] = a * 255;
|
||||
}, true);
|
||||
label(ctx, 'VIIRS-CLASS 375 M · FRP 840 MW', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.85)');
|
||||
const mv = { kmPx: 18 / w, w }; scaleBar(ctx, mv, 5, w * 0.06, h * PT.botY);
|
||||
// wind arrow
|
||||
ctx.save(); ctx.translate(w * 0.86, h * PT.botY - 6); ctx.rotate(Math.atan2(dir[1], dir[0])); ctx.strokeStyle = 'rgba(255,255,255,.85)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(14, 0); ctx.lineTo(8, -5); ctx.moveTo(14, 0); ctx.lineTo(8, 5); ctx.stroke(); ctx.restore();
|
||||
};
|
||||
|
||||
// synthetic-aperture radar pass over an anchorage: speckle, bright hull returns with sidelobes
|
||||
PT.sar = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(o.seed || 23); const R = rng(o.seed || 23);
|
||||
const coast = (v) => w * (0.3 + 0.08 * Math.sin(v * 5) + 0.06 * N(v * 4, 2));
|
||||
field(ctx, w, h, 1.5, (u, v, out) => {
|
||||
const x = u * w, cxl = coast(v); const sea = smooth(cxl - 3, cxl + 3, x); const sp = -Math.log(Math.max(1e-4, R())) * 0.55;
|
||||
const fieldsTex = N(u * 26, v * 26) > 0.55 ? 0.22 : 0; const town = Math.exp(-(((u - 0.12) / 0.08) ** 2 + ((v - 0.62) / 0.12) ** 2)) * (N(u * 90, v * 90) > 0.5 ? 0.9 : 0.25);
|
||||
const land = (0.26 + N.fbm(u * 10, v * 10, 5) * 0.36 + fieldsTex + town) * (0.55 + sp * 0.6);
|
||||
const wave = (0.035 + N.fbm(u * 24 + 2, v * 6, 3) * 0.05) * (0.3 + sp * 1.3) + Math.exp(-(((x - cxl) / 7) ** 2)) * 0.25;
|
||||
const g = clamp(lerp(land, wave, sea), 0, 1) * 255; out[0] = g * 0.93; out[1] = g * 0.97; out[2] = g;
|
||||
}, false);
|
||||
// dredged channel + breakwater
|
||||
ctx.save(); ctx.strokeStyle = 'rgba(0,0,0,.75)'; ctx.lineWidth = w / 60; ctx.beginPath(); ctx.moveTo(coast(0.05) + 4, 0); ctx.lineTo(w * 0.42, h * 0.45); ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,.8)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(coast(0.5), h * 0.5); ctx.lineTo(w * 0.5, h * 0.47); ctx.stroke(); ctx.restore();
|
||||
// anchorage: hull returns with cross-shaped sidelobes
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; const n = o.ships || 48;
|
||||
for (let i = 0; i < n; i++) { const cl = i % 3; const x = w * ([0.62, 0.78, 0.56][cl] + R.gauss() * 0.08), y = h * ([0.3, 0.55, 0.78][cl] + R.gauss() * 0.07); const L = w / 30 * (0.6 + R() * 0.9); const a = 0.4 + R() * 0.5; ctx.save(); ctx.translate(x, y); ctx.rotate(a); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.fillRect(-L / 2, -1.6, L, 3.2); ctx.restore(); ctx.fillStyle = 'rgba(255,255,255,.16)'; ctx.fillRect(x - L * 1.6, y - 0.5, L * 3.2, 1); ctx.fillRect(x - 0.5, y - L * 1.3, 1, L * 2.6); glow(ctx, x, y, L * 0.7, 'rgba(255,255,255,A)', 0.22); }
|
||||
// one moving ship with a wake and Doppler offset
|
||||
ctx.fillStyle = 'rgba(255,255,255,.9)'; ctx.fillRect(w * 0.44, h * 0.18, w / 22, 3); ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(w * 0.44, h * 0.18 + 1); ctx.lineTo(w * 0.3, h * 0.12); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,.35)'; ctx.fillRect(w * 0.47, h * 0.24, w / 22, 2);
|
||||
ctx.restore();
|
||||
label(ctx, 'SAR · C-BAND · VV · 10 M', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.85)');
|
||||
scaleBar(ctx, { kmPx: 22 / w, w }, 5, w * 0.06, h * PT.botY);
|
||||
};
|
||||
|
||||
/* =====================================================================================
|
||||
SKY
|
||||
===================================================================================== */
|
||||
// radar reflectivity: a bowing squall line crossing a city's street grid, lightning, warning box
|
||||
PT.radar = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(o.seed || 41); const R = rng(o.seed || 41);
|
||||
ctx.fillStyle = '#0a0e14'; ctx.fillRect(0, 0, w, h);
|
||||
const cores = [[w * 0.62, h * 0.5, w * 0.34], [w * 0.22, h * 0.56, w * 0.22]];
|
||||
const urb = (x, y) => Math.max(...cores.map(([cx, cy, r]) => Math.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (r * r))));
|
||||
field(ctx, w, h, 2, (u, v, out) => { const x = u * w, y = v * h; const k = urb(x, y) * (0.6 + 0.5 * N.fbm(u * 9, v * 9, 3)); out[0] = 10 + k * 22; out[1] = 14 + k * 24; out[2] = 20 + k * 30; });
|
||||
// street grid
|
||||
ctx.save(); ctx.lineWidth = 1; const step = Math.max(5, w / 60);
|
||||
for (let x = 0; x < w; x += step) for (let y = 0; y < h; y += step) { const k = urb(x, y); if (R() > k * 1.3) continue; ctx.strokeStyle = `rgba(170,190,220,${0.06 + k * 0.1})`; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + step, y); ctx.moveTo(x, y); ctx.lineTo(x, y + step); ctx.stroke(); }
|
||||
// lakes, river, highways
|
||||
ctx.fillStyle = '#0f2236'; for (const [x, y, rx, ry] of [[0.42, 0.22, 0.05, 0.03], [0.86, 0.3, 0.04, 0.06], [0.1, 0.8, 0.05, 0.025]]) { ctx.beginPath(); ctx.ellipse(x * w, y * h, rx * w, ry * h, 0.4, 0, TAU); ctx.fill(); }
|
||||
ctx.strokeStyle = 'rgba(70,120,170,.6)'; ctx.lineWidth = w / 200; ctx.beginPath(); for (let i = 0; i <= 50; i++) { const t = i / 50; const x = t * w, y = h * 0.62 + Math.sin(t * 9) * h * 0.03 + N(t * 5, 2) * h * 0.06; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(190,205,230,.32)'; ctx.lineWidth = w / 170;
|
||||
for (const [x1, y1, x2, y2] of [[0, 0.5, 1, 0.47], [0.62, 0, 0.6, 1], [0.22, 0, 0.25, 1], [0, 0.15, 1, 0.92], [0, 0.9, 1, 0.12]]) { ctx.beginPath(); ctx.moveTo(x1 * w, y1 * h); ctx.lineTo(x2 * w, y2 * h); ctx.stroke(); }
|
||||
ctx.beginPath(); ctx.ellipse(w * 0.62, h * 0.5, w * 0.16, h * 0.13, 0, 0, TAU); ctx.stroke(); ctx.beginPath(); ctx.ellipse(w * 0.22, h * 0.56, w * 0.1, h * 0.08, 0, 0, TAU); ctx.stroke(); ctx.restore();
|
||||
// reflectivity: bowing line (arc), trailing stratiform, isolated cells ahead
|
||||
const ac = [w * 0.05, h * 0.5], AR = w * 0.58;
|
||||
const col = [0, 0, 0, 0];
|
||||
field(ctx, w, h, 3, (u, v, out) => {
|
||||
const x = u * w, y = v * h; const dx = x - ac[0], dy = y - ac[1]; const r = Math.hypot(dx, dy), a = Math.atan2(dy, dx);
|
||||
const span = smooth(1.25, 0.75, Math.abs(a)); const d = (r - AR - Math.cos(a * 2) * w * 0.05) / w;
|
||||
const n = N.fbm(x / 26, y / 26, 4), n2 = N(x / 9, y / 9);
|
||||
const core = Math.exp(-((d / 0.035) ** 2)) * span * (0.65 + 0.6 * n) * (1 + 0.5 * Math.exp(-((a / 0.22) ** 2)));
|
||||
const strat = Math.exp(-(((d + 0.12) / 0.11) ** 2)) * span * (0.5 + 0.7 * n);
|
||||
let cell = 0; for (const [cx, cy, cr] of [[0.82, 0.25, 0.04], [0.9, 0.62, 0.03], [0.76, 0.82, 0.035]]) cell = Math.max(cell, Math.exp(-((x - cx * w) ** 2 + (y - cy * h) ** 2) / (cr * w) ** 2) * (0.7 + 0.4 * n2));
|
||||
const z = 8 + Math.max(core * 58, strat * 28, cell * 52) + (n2 - 0.5) * 6; dbzColor(z, col); out[0] = col[0]; out[1] = col[1]; out[2] = col[2]; out[3] = col[3];
|
||||
}, false);
|
||||
// tornado-warning polygon at the bow apex (harm → hazard red)
|
||||
const ap = [ac[0] + AR + w * 0.05, ac[1]]; ctx.save(); ctx.strokeStyle = '#FF3B30'; ctx.lineWidth = Math.max(1.5, w / 160); poly(ctx, [[ap[0] - w * 0.06, ap[1] - h * 0.09], [ap[0] + w * 0.13, ap[1] - h * 0.06], [ap[0] + w * 0.12, ap[1] + h * 0.07], [ap[0] - w * 0.05, ap[1] + h * 0.08]], true); ctx.stroke(); ctx.restore();
|
||||
// lightning
|
||||
for (let i = 0; i < 40; i++) { const a = (R() - 0.5) * 1.6, rr = AR + (R() - 0.6) * w * 0.07; const x = ac[0] + Math.cos(a) * rr, y = ac[1] + Math.sin(a) * rr; cross(ctx, x, y, w / 110, i % 5 ? 'rgba(255,255,255,.9)' : 'rgba(255,230,0,1)', 1.2); }
|
||||
// storm motion
|
||||
ctx.save(); ctx.translate(ap[0] + w * 0.16, ap[1] + h * 0.17); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(-18, 0); ctx.lineTo(16, 0); ctx.lineTo(10, -5); ctx.moveTo(16, 0); ctx.lineTo(10, 5); ctx.stroke(); ctx.restore();
|
||||
label(ctx, '72 KM/H', ap[0] + w * 0.16, ap[1] + h * 0.21, Math.max(8, w / 44), 'rgba(255,255,255,.85)', 'center');
|
||||
for (const [t, x, y] of [['FORT WORTH', 0.22, 0.6], ['DALLAS', 0.62, 0.55], ['ARLINGTON', 0.43, 0.66]]) label(ctx, t, x * w, y * h, Math.max(7, w / 50), 'rgba(220,230,245,.55)', 'center');
|
||||
label(ctx, '0.5° REFLECTIVITY · dBZ', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.85)');
|
||||
const lx = w * 0.05, ly = h * PT.botY; DBZ.forEach(([d, c], i) => { ctx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`; ctx.fillRect(lx + i * (w * 0.035), ly, w * 0.035, Math.max(4, h / 90)); }); label(ctx, '20', lx, ly - 6, Math.max(7, w / 52), 'rgba(255,255,255,.7)'); label(ctx, '75', lx + DBZ.length * w * 0.035, ly - 6, Math.max(7, w / 52), 'rgba(255,255,255,.7)', 'right');
|
||||
};
|
||||
|
||||
// one aircraft squawking 7700: the track turns red where it declared, diverts and descends
|
||||
PT.track = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const T = o.track;
|
||||
const lats = T.pts.map((p) => p[0]).concat(T.div.lat), lons = T.pts.map((p) => p[1]).concat(T.div.lon);
|
||||
const la0 = (Math.min(...lats) + Math.max(...lats)) / 2, lo0 = (Math.min(...lons) + Math.max(...lons)) / 2;
|
||||
const span = Math.max(8, (Math.max(...lons) - Math.min(...lons)) * 1.5, ((Math.max(...lats) - Math.min(...lats)) * 1.6 * w) / h / Math.cos(la0 * D2R));
|
||||
const mv = mapView(la0, lo0, span, w, h);
|
||||
mapBase(ctx, mv, { lights: 0.9, relief: 0.6, land: '#151c26', sea: '#04070b', coast: '#3a4a60' });
|
||||
graticule(ctx, mv, 5);
|
||||
const P = T.pts.map(([la, lo]) => [mv.X(lo), mv.Y(la)]); const k = T.sqAt;
|
||||
ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = Math.max(1.5, w / 200); ctx.strokeStyle = 'rgba(190,215,240,.75)'; poly(ctx, P.slice(0, k + 1)); ctx.stroke();
|
||||
ctx.lineWidth = Math.max(2.2, w / 130); ctx.strokeStyle = '#FF3B30'; ctx.shadowColor = 'rgba(255,59,48,.8)'; ctx.shadowBlur = 10; poly(ctx, P.slice(k)); ctx.stroke(); ctx.shadowBlur = 0;
|
||||
// remaining leg to the diversion field
|
||||
const cur = P[P.length - 1]; const dv = [mv.X(T.div.lon), mv.Y(T.div.lat)]; ctx.setLineDash([5, 5]); ctx.lineWidth = 1.4; ctx.strokeStyle = 'rgba(255,120,110,.8)'; ctx.beginPath(); ctx.moveTo(cur[0], cur[1]); ctx.quadraticCurveTo((cur[0] + dv[0]) / 2 + w * 0.05, (cur[1] + dv[1]) / 2, dv[0], dv[1]); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (const r of [w / 30, w / 18]) { ctx.strokeStyle = 'rgba(255,255,255,.7)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(dv[0], dv[1], r, 0, TAU); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
label(ctx, T.div.iata, dv[0] + w / 16, dv[1], Math.max(9, w / 30), '#fff');
|
||||
const p0 = P[P.length - 2] || P[0]; plane(ctx, cur[0], cur[1], Math.atan2(cur[0] - p0[0], -(cur[1] - p0[1])), Math.max(14, w / 14), '#fff');
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, cur[0], cur[1], w / 6, 'rgba(255,59,48,A)', 0.55); ctx.restore();
|
||||
// squawk tag
|
||||
const tx = clamp(cur[0] + w * 0.04, 4, w - w * 0.36), ty = clamp(cur[1] - h * 0.12, 4, h * 0.7); ctx.fillStyle = '#FF3B30'; ctx.fillRect(tx, ty, w * 0.3, h * 0.065); label(ctx, 'SQ 7700', tx + w * 0.15, ty + h * 0.034, Math.max(9, w / 26), '#fff', 'center');
|
||||
// descent profile inset
|
||||
const ix = w * 0.05, iy = h * 0.6, iw = w * 0.42, ih = h * 0.14; ctx.fillStyle = 'rgba(5,8,12,.72)'; ctx.fillRect(ix, iy, iw, ih); ctx.strokeStyle = 'rgba(255,255,255,.15)'; ctx.strokeRect(ix + 0.5, iy + 0.5, iw - 1, ih - 1);
|
||||
const prof = T.alt; const mx = Math.max(...prof); ctx.beginPath(); prof.forEach((a, i) => { const x = ix + 4 + (i / (prof.length - 1)) * (iw - 8), y = iy + ih - 5 - (a / mx) * (ih - 14); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }); ctx.strokeStyle = '#FF6B61'; ctx.lineWidth = 1.6; ctx.stroke();
|
||||
label(ctx, 'ALT ↓ FL370 → FL' + Math.round(prof[prof.length - 1] / 100), ix + 5, iy + 8, Math.max(7, w / 50), 'rgba(255,255,255,.8)');
|
||||
};
|
||||
|
||||
// lightning density over a basin (number card backdrop)
|
||||
PT.strikes = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const mv = mapView(o.lat ?? 26.5, o.lon ?? -89, o.span ?? 22, w, h);
|
||||
mapBase(ctx, mv, { lights: 0.7, land: '#10161e', sea: '#04070b', coast: '#2c3a4c' }); const R = rng(o.seed || 77);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
const cells = [[25.6, -88.2, 1.4], [27.4, -91.5, 1.0], [24.4, -84.9, 0.9], [28.6, -86.4, 0.7], [23.2, -92.8, 1.1]];
|
||||
for (const [la, lo, s] of cells) for (let i = 0; i < 260 * s; i++) { const x = mv.X(lo + R.gauss() * s), y = mv.Y(la + R.gauss() * s * 0.7); const age = R(); ctx.fillStyle = age < 0.15 ? 'rgba(255,255,255,.95)' : age < 0.5 ? 'rgba(255,230,90,.75)' : 'rgba(255,140,40,.45)'; ctx.fillRect(x, y, age < 0.15 ? 2.2 : 1.6, age < 0.15 ? 2.2 : 1.6); }
|
||||
for (const [la, lo] of cells) glow(ctx, mv.X(lo), mv.Y(la), w / 5, 'rgba(255,200,80,A)', 0.18);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// GNSS interference: hex bins of degraded integrity, tracks with gaps
|
||||
PT.gnss = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const mv = mapView(o.lat ?? 56.8, o.lon ?? 21.5, o.span ?? 17, w, h);
|
||||
mapBase(ctx, mv, { lights: 0.8, land: '#121821', sea: '#05080c', coast: '#33425a' }); const R = rng(o.seed || 5);
|
||||
const hot = [[55.0, 20.5, 1.6], [59.3, 24.2, 1.2], [57.2, 19.0, 0.8]]; const hs = w / 16;
|
||||
ctx.save();
|
||||
for (let row = 0; row * hs * 0.87 < h + hs; row++) for (let col = 0; col * hs < w + hs; col++) {
|
||||
const x = col * hs + (row % 2 ? hs / 2 : 0), y = row * hs * 0.87; const la = mv.lat(y), lo = mv.lon(x);
|
||||
let k = 0; for (const [a, b, s] of hot) k = Math.max(k, Math.exp(-(((la - a) / s) ** 2 + ((lo - b) / (s * 1.8)) ** 2)));
|
||||
if (k < 0.12) continue; ctx.beginPath(); for (let i = 0; i < 6; i++) { const an = Math.PI / 6 + (i * Math.PI) / 3; ctx.lineTo(x + Math.cos(an) * hs * 0.5, y + Math.sin(an) * hs * 0.5); } ctx.closePath();
|
||||
ctx.fillStyle = `rgba(255,${Math.round(150 - k * 110)},${Math.round(60 - k * 40)},${0.12 + k * 0.5})`; ctx.fill(); ctx.strokeStyle = `rgba(255,140,90,${k * 0.5})`; ctx.stroke();
|
||||
}
|
||||
// airliner tracks: dashed where position integrity drops
|
||||
ctx.lineWidth = 1.2;
|
||||
for (let i = 0; i < 26; i++) { const y0 = R() * h, y1 = R() * h; let prevBad = null; for (let s = 0; s < 40; s++) { const t0 = s / 40, t1 = (s + 1) / 40; const x0 = t0 * w, x1 = t1 * w, ya = lerp(y0, y1, t0), yb = lerp(y0, y1, t1); const la = mv.lat(ya), lo = mv.lon(x0); let k = 0; for (const [a, b, sd] of hot) k = Math.max(k, Math.exp(-(((la - a) / sd) ** 2 + ((lo - b) / (sd * 1.8)) ** 2))); const bad = k > 0.4; ctx.strokeStyle = bad ? 'rgba(255,120,90,.0)' : 'rgba(150,205,255,.55)'; if (!bad) { ctx.beginPath(); ctx.moveTo(x0, ya); ctx.lineTo(x1, yb); ctx.stroke(); } if (bad && s % 2 === 0) { ctx.fillStyle = 'rgba(255,170,140,.7)'; ctx.fillRect(x0, ya + (R() - 0.5) * 6, 1.5, 1.5); } } }
|
||||
ctx.restore();
|
||||
label(ctx, 'NIC/NACp ≤ 4', w * 0.05, h * PT.topY, Math.max(8, w / 40), 'rgba(255,255,255,.85)');
|
||||
};
|
||||
|
||||
// helicopter school: the circuit flown again and again over the field
|
||||
PT.heli = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const N = noiseGen(11);
|
||||
field(ctx, w, h, 2, (u, v, out) => { const n = N.fbm(u * 7, v * 7, 4), f = N(u * 22, v * 22); const g = f > 0.6 ? 1 : 0; out[0] = lerp(58, 92, n) + g * 18; out[1] = lerp(72, 98, n) + g * 22; out[2] = lerp(42, 60, n); });
|
||||
// tree lines & roads
|
||||
ctx.save(); ctx.fillStyle = 'rgba(25,40,22,.55)'; const R = rng(3); for (let i = 0; i < 40; i++) { ctx.beginPath(); ctx.ellipse(R() * w, R() * h, w * 0.06 * R(), h * 0.012, R() * 3, 0, TAU); ctx.fill(); } ctx.restore();
|
||||
ctx.save(); ctx.translate(w * 0.5, h * 0.5); ctx.rotate(0.06);
|
||||
ctx.fillStyle = '#6d6f70'; ctx.fillRect(-w * 0.36, -h * 0.025, w * 0.72, h * 0.05); ctx.save(); ctx.rotate(1.1); ctx.fillStyle = '#6a6c6d'; ctx.fillRect(-w * 0.3, -h * 0.02, w * 0.6, h * 0.04); ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.7)'; ctx.setLineDash([6, 6]); ctx.beginPath(); ctx.moveTo(-w * 0.34, 0); ctx.lineTo(w * 0.34, 0); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (let i = 0; i < 12; i++) { ctx.fillStyle = '#7d7f80'; ctx.fillRect(-w * 0.3 + i * w * 0.05, h * 0.08, w * 0.035, w * 0.035); }
|
||||
// circuit laps (oldest faint)
|
||||
const n = o.laps ?? 14; const cw = w * 0.62, ch = h * 0.42;
|
||||
for (let i = 0; i < n; i++) { const j = i / n; const off = (hash01(i + 1) - 0.5) * w * 0.03; ctx.strokeStyle = `rgba(155,240,150,${0.12 + j * 0.65})`; ctx.lineWidth = i === n - 1 ? 2.4 : 1.1; const r = w * 0.06; const x = -cw / 2 + off, y = -ch / 2 - h * 0.02 + off; ctx.beginPath(); ctx.roundRect(x, y, cw, ch, r); ctx.stroke(); }
|
||||
// helicopter
|
||||
const hx = cw / 2 - w * 0.02, hy = -ch * 0.1; ctx.fillStyle = '#E8FFE4'; ctx.beginPath(); ctx.arc(hx, hy, w / 60, 0, TAU); ctx.fill(); ctx.strokeStyle = 'rgba(232,255,228,.8)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(hx, hy, w / 22, 0, TAU); ctx.stroke(); ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx, hy + w / 14); ctx.stroke();
|
||||
ctx.restore();
|
||||
const fs = Math.max(7, w / 44); for (const [t, x, y] of [['UPWIND', 0.5, 0.25], ['DOWNWIND', 0.5, 0.79], ['BASE', 0.12, 0.52], ['CROSSWIND', 0.88, 0.48]]) label(ctx, t, x * w, y * h, fs, 'rgba(225,255,220,.75)', 'center');
|
||||
vignette(ctx, w, h, 0.35);
|
||||
};
|
||||
|
||||
/* =====================================================================================
|
||||
GROUND
|
||||
===================================================================================== */
|
||||
// shake map: intensity bands over the coast, epicentre, stations
|
||||
PT.shake = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const Q = GE.BIGQ; const mv = mapView(Q.lat + 0.6, Q.lon - 1.6, o.span ?? 6.5, w, h);
|
||||
mapBase(ctx, mv, { relief: 1.1, land: '#161d26', sea: '#05080c', coast: '#5a6e88', lights: 0.6 });
|
||||
const fw = Math.round(w / 2); const wm = cropData('water', mv, fw, Math.round(h / 2)); const strike = 0.35;
|
||||
const mmi = (la, lo) => { const dx = (lo - Q.lon) * Math.cos(Q.lat * D2R), dy = la - Q.lat; const a = dx * Math.cos(strike) + dy * Math.sin(strike), b = -dx * Math.sin(strike) + dy * Math.cos(strike); const d = Math.hypot(a / 1.7, b) * 111; return clamp(8.2 - 2.1 * Math.log10(1 + d / 9) * 1.55, 1, 9); };
|
||||
field(ctx, w, h, 2, (u, v, out, x, y) => { const la = mv.lat(v * h), lo = mv.lon(u * w); const I = mmi(la, lo); const band = Math.floor(I); const t = clamp((band - 1) / 7, 0, 1); const j = Math.round(t * 255) * 3; const sea = wm[(y * fw + x) * 4] / 255 > 0.5; out[0] = LUT.mmi[j]; out[1] = LUT.mmi[j + 1]; out[2] = LUT.mmi[j + 2]; out[3] = I < 4 ? 0 : (sea ? 50 : 120) + (I - 4) * 26; }, true);
|
||||
// contour lines
|
||||
ctx.save(); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(255,255,255,.35)';
|
||||
for (const I of [4, 5, 6, 7]) { const d = 9 * (Math.pow(10, (8.2 - I) / (2.1 * 1.55)) - 1) / 111; ctx.beginPath(); for (let k = 0; k <= 72; k++) { const an = (k / 72) * TAU; const a = Math.cos(an) * d * 1.7, b = Math.sin(an) * d; const dx = a * Math.cos(strike) - b * Math.sin(strike), dy = a * Math.sin(strike) + b * Math.cos(strike); const x = mv.X(Q.lon + dx / Math.cos(Q.lat * D2R)), y = mv.Y(Q.lat + dy); k ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke(); }
|
||||
ctx.restore();
|
||||
// rupture
|
||||
ctx.save(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; const e = 0.45; ctx.beginPath(); ctx.moveTo(mv.X(Q.lon - Math.cos(strike) * e), mv.Y(Q.lat - Math.sin(strike) * e)); ctx.lineTo(mv.X(Q.lon + Math.cos(strike) * e), mv.Y(Q.lat + Math.sin(strike) * e)); ctx.stroke(); ctx.restore();
|
||||
// stations
|
||||
const R = rng(14); for (let i = 0; i < 46; i++) { const la = Q.lat + R.gauss() * 1.3 + 0.5, lo = Q.lon - 1.2 + R.gauss() * 1.2; const m = cropData('water', mv, fw, Math.round(h / 2)); const x = mv.X(lo), y = mv.Y(la); if (x < 0 || y < 0 || x >= w || y >= h) continue; if (m[(Math.floor(y / 2) * fw + Math.floor(x / 2)) * 4] > 128) continue; const I = mmi(la, lo) + R.gauss() * 0.4; const j = Math.round(clamp((I - 1) / 7, 0, 1) * 255) * 3; ctx.fillStyle = `rgb(${LUT.mmi[j]},${LUT.mmi[j + 1]},${LUT.mmi[j + 2]})`; ctx.strokeStyle = 'rgba(0,0,0,.7)'; const s = w / 70; ctx.beginPath(); ctx.moveTo(x, y - s); ctx.lineTo(x + s * 0.9, y + s * 0.6); ctx.lineTo(x - s * 0.9, y + s * 0.6); ctx.closePath(); ctx.fill(); ctx.stroke(); }
|
||||
// epicentre star
|
||||
const ex = mv.X(Q.lon), ey = mv.Y(Q.lat); ctx.save(); ctx.translate(ex, ey); ctx.fillStyle = '#FF3B30'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < 10; i++) { const r = i % 2 ? w / 50 : w / 22; const a = -Math.PI / 2 + (i * Math.PI) / 5; ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); } ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore();
|
||||
for (const [t, la, lo] of [['SENDAI', 38.27, 140.87], ['FUKUSHIMA', 37.75, 140.47], ['MITO', 36.37, 140.47], ['TOKYO', 35.68, 139.69]]) { const x = mv.X(lo), y = mv.Y(la); ctx.fillStyle = '#fff'; ctx.fillRect(x - 1.5, y - 1.5, 3, 3); label(ctx, t, x - 5, y, Math.max(7, w / 48), 'rgba(235,240,248,.85)', 'right'); }
|
||||
const lx = w * 0.05, ly = h * PT.botY; for (let i = 2; i <= 8; i++) { const j = Math.round(((i - 1) / 7) * 255) * 3; ctx.fillStyle = `rgb(${LUT.mmi[j]},${LUT.mmi[j + 1]},${LUT.mmi[j + 2]})`; ctx.fillRect(lx + (i - 2) * w * 0.05, ly, w * 0.05, Math.max(4, h / 90)); label(ctx, ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'][i], lx + (i - 2) * w * 0.05 + w * 0.025, ly - 7, Math.max(6, w / 60), 'rgba(255,255,255,.7)', 'center'); }
|
||||
label(ctx, 'MMI · INSTRUMENTAL INTENSITY', w * 0.05, h * PT.topY, Math.max(8, w / 42), 'rgba(255,255,255,.85)');
|
||||
};
|
||||
|
||||
// crowd density inside and around a stadium, top-down, with an egress crush point
|
||||
PT.stadium = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 66); const N = noiseGen(66);
|
||||
ctx.fillStyle = '#0b0e12'; ctx.fillRect(0, 0, w, h);
|
||||
ctx.save(); ctx.fillStyle = '#12161c'; for (let i = 0; i < 70; i++) { const x = R() * w, y = R() * h; if (Math.hypot((x - w / 2) / w, (y - h / 2) / h) < 0.36) continue; ctx.fillRect(x, y, w * (0.04 + R() * 0.07), h * (0.03 + R() * 0.05)); } ctx.restore();
|
||||
const cx = w / 2, cy = h * 0.48, RX = w * 0.36, RY = h * 0.3;
|
||||
ctx.fillStyle = '#1b2028'; ctx.beginPath(); ctx.ellipse(cx, cy, RX * 1.08, RY * 1.08, 0, 0, TAU); ctx.fill();
|
||||
// intensity buffer of people
|
||||
const k = 2, fw = Math.round(w / k), fh = Math.round(h / k); const I = new Float32Array(fw * fh);
|
||||
const splat = (x, y, s, a) => { const r = Math.ceil(s * 2.5), x0 = Math.round(x / k), y0 = Math.round(y / k); for (let j = -r; j <= r; j++) for (let i = -r; i <= r; i++) { const X = x0 + i, Y = y0 + j; if (X < 0 || Y < 0 || X >= fw || Y >= fh) continue; I[Y * fw + X] += a * Math.exp(-(i * i + j * j) / (s * s)); } };
|
||||
for (let n = 0; n < 2600; n++) { const a = R() * TAU, t = 0.55 + R() * 0.45; splat(cx + Math.cos(a) * RX * t, cy + Math.sin(a) * RY * t, 1.6, 0.35 + 0.5 * N(Math.cos(a) * 3, Math.sin(a) * 3)); }
|
||||
const gates = [[-0.25, 'A'], [1.2, 'B'], [Math.PI * 0.62, 'C'], [Math.PI * 1.25, 'D']];
|
||||
for (const [ga, gl] of gates) { const gx = cx + Math.cos(ga) * RX * 1.08, gy = cy + Math.sin(ga) * RY * 1.08; const crush = gl === 'C'; for (let n = 0; n < (crush ? 900 : 260); n++) { const d = Math.pow(R(), crush ? 1.8 : 1) * w * (crush ? 0.2 : 0.3); const sp = R.gauss() * (crush ? 0.18 : 0.12); splat(gx + Math.cos(ga + sp) * d, gy + Math.sin(ga + sp) * d, 1.8, crush ? 0.6 : 0.3); } }
|
||||
const c2 = mk(fw, fh), cx2 = c2.getContext('2d'); const id = cx2.createImageData(fw, fh);
|
||||
for (let i = 0; i < fw * fh; i++) { const v = clamp(I[i] / 3.2, 0, 1); const j = Math.round(v * 255) * 3; id.data[i * 4] = LUT.inferno[j]; id.data[i * 4 + 1] = LUT.inferno[j + 1]; id.data[i * 4 + 2] = LUT.inferno[j + 2]; id.data[i * 4 + 3] = v < 0.04 ? 0 : clamp(v * 900, 0, 235); }
|
||||
cx2.putImageData(id, 0, 0); ctx.save(); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(c2, 0, 0, w, h); ctx.restore();
|
||||
// pitch
|
||||
ctx.save(); ctx.fillStyle = '#16301e'; ctx.fillRect(cx - RX * 0.42, cy - RY * 0.42, RX * 0.84, RY * 0.84); ctx.strokeStyle = 'rgba(220,255,220,.35)'; ctx.lineWidth = 1; ctx.strokeRect(cx - RX * 0.42, cy - RY * 0.42, RX * 0.84, RY * 0.84); ctx.beginPath(); ctx.moveTo(cx, cy - RY * 0.42); ctx.lineTo(cx, cy + RY * 0.42); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, RY * 0.12, 0, TAU); ctx.stroke(); ctx.restore();
|
||||
for (const [ga, gl] of gates) { const gx = cx + Math.cos(ga) * RX * 1.16, gy = cy + Math.sin(ga) * RY * 1.16; label(ctx, 'GATE ' + gl, gx, gy, Math.max(7, w / 46), gl === 'C' ? '#fff' : 'rgba(230,236,245,.6)', 'center'); }
|
||||
const ga = gates[2][0]; const hx = cx + Math.cos(ga) * RX * 1.3, hy = cy + Math.sin(ga) * RY * 1.3; ctx.save(); ctx.strokeStyle = '#FF3B30'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(hx, hy, w / 13, 0, TAU); ctx.stroke(); ctx.restore();
|
||||
label(ctx, '4.6 P/M²', hx, hy + w / 9, Math.max(9, w / 30), '#FF6B61', 'center');
|
||||
label(ctx, 'CROWD DENSITY · P/M²', w * 0.05, h * PT.topY, Math.max(8, w / 42), 'rgba(255,255,255,.85)');
|
||||
};
|
||||
|
||||
/* ---------- CCTV-style scenes (animated: static base cached, dynamic layer per frame) ---------- */
|
||||
const SCN = (PT.scenes = {});
|
||||
function cctvOSD(ctx, w, h, t, o) {
|
||||
const fs = Math.max(8, Math.round(w / 34)); const y0 = h * (o.osdY ?? PT.osdY), y1 = h * (o.osdB ?? PT.osdB);
|
||||
osd(ctx, o.cam || 'CAM 0412', w * 0.045, y0, fs); osd(ctx, PT.stamp(o.at ?? Date.now()), w * 0.955, y0, fs, 'right');
|
||||
if (o.place) osd(ctx, o.place, w * 0.045, y1 - fs, fs * 0.9);
|
||||
if (o.rec !== false && Math.floor(t * 1.2) % 2 === 0) { ctx.fillStyle = '#FF3B30'; ctx.beginPath(); ctx.arc(w * 0.955 - fs * 0.3, y1 - fs * 0.5, fs * 0.32, 0, TAU); ctx.fill(); osd(ctx, 'REC', w * 0.955 - fs * 0.9, y1 - fs, fs * 0.9, 'right'); }
|
||||
}
|
||||
PT.osdY = 0.115; PT.osdB = 0.87; PT.topY = 0.125; PT.botY = 0.86;
|
||||
PT.cctvOSD = cctvOSD;
|
||||
|
||||
// harbour at night: gantry cranes, a lit ship, fog, reflections, rain before a typhoon
|
||||
SCN.harbour = {
|
||||
base(w, h, o) {
|
||||
const c = mk(w, h), ctx = c.getContext('2d'); const R = rng(o.seed || 8); const N = noiseGen(8); const hz = h * 0.56;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, hz); sky.addColorStop(0, '#0b0d12'); sky.addColorStop(0.7, '#2a2522'); sky.addColorStop(1, '#5a4430'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, hz);
|
||||
// far shore: city lights band
|
||||
ctx.fillStyle = '#121010'; ctx.fillRect(0, hz - h * 0.035, w, h * 0.04);
|
||||
for (let i = 0; i < 260; i++) { const x = R() * w, y = hz - R() * h * 0.05; ctx.fillStyle = `rgba(255,${170 + R() * 70},${90 + R() * 80},${0.4 + R() * 0.6})`; ctx.fillRect(x, y, 1.3, 1.3); }
|
||||
for (let i = 0; i < 9; i++) { const x = R() * w, bw = w * (0.02 + R() * 0.03), bh = h * (0.05 + R() * 0.12); ctx.fillStyle = '#0d0c0d'; ctx.fillRect(x, hz - bh, bw, bh); for (let k = 0; k < 18; k++) { if (R() < 0.5) continue; ctx.fillStyle = `rgba(255,220,160,${0.3 + R() * 0.5})`; ctx.fillRect(x + R() * bw, hz - R() * bh, 1.4, 1.4); } }
|
||||
// gantry cranes
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x = w * (0.08 + i * 0.2) + R() * w * 0.04, s = h * (0.34 - i * 0.03), top = hz - s; ctx.strokeStyle = '#08080a'; ctx.fillStyle = '#08080a'; ctx.lineWidth = Math.max(2, w / 160);
|
||||
ctx.beginPath(); ctx.moveTo(x, hz + h * 0.04); ctx.lineTo(x + w * 0.012, top); ctx.moveTo(x + w * 0.07, hz + h * 0.04); ctx.lineTo(x + w * 0.058, top); ctx.stroke();
|
||||
ctx.fillRect(x - w * 0.09, top - h * 0.012, w * 0.26, h * 0.016); ctx.fillRect(x + w * 0.01, top - h * 0.06, w * 0.05, h * 0.05);
|
||||
ctx.lineWidth = 1; for (let k = 0; k < 8; k++) { ctx.beginPath(); ctx.moveTo(x + w * 0.012 * (k / 8) + 1, hz + h * 0.04 - (k * s) / 8); ctx.lineTo(x + w * 0.07 - w * 0.012 * ((k + 1) / 8), hz + h * 0.04 - ((k + 1) * s) / 8); ctx.stroke(); }
|
||||
for (let k = 0; k < 6; k++) { ctx.fillStyle = 'rgba(255,236,200,.85)'; ctx.fillRect(x - w * 0.08 + k * w * 0.045, top - h * 0.002, 2, 2); }
|
||||
}
|
||||
// container ship
|
||||
const sy = hz + h * 0.02; ctx.fillStyle = '#0d0f12'; ctx.beginPath(); ctx.moveTo(w * 0.12, sy); ctx.lineTo(w * 0.92, sy); ctx.lineTo(w * 0.88, sy + h * 0.07); ctx.lineTo(w * 0.15, sy + h * 0.07); ctx.closePath(); ctx.fill();
|
||||
const cols = ['#3b2420', '#1f2d3b', '#2b3326', '#3a3324', '#2a2a30', '#3a1f2a'];
|
||||
for (let i = 0; i < 46; i++) for (let j = 0; j < 4; j++) { if (R() < 0.12) continue; ctx.fillStyle = cols[R.int(cols.length)]; ctx.fillRect(w * 0.18 + i * w * 0.0145, sy - (j + 1) * h * 0.014, w * 0.013, h * 0.012); }
|
||||
ctx.fillStyle = '#16181c'; ctx.fillRect(w * 0.8, sy - h * 0.1, w * 0.07, h * 0.1); for (let k = 0; k < 14; k++) { ctx.fillStyle = 'rgba(255,230,180,.85)'; ctx.fillRect(w * 0.805 + (k % 7) * w * 0.009, sy - h * 0.09 + Math.floor(k / 7) * h * 0.02, 2, 2); }
|
||||
// water
|
||||
const wg = ctx.createLinearGradient(0, hz, 0, h); wg.addColorStop(0, '#15120f'); wg.addColorStop(1, '#050506'); ctx.fillStyle = wg; ctx.fillRect(0, sy + h * 0.07, w, h);
|
||||
// quay & lamps in the foreground
|
||||
ctx.fillStyle = '#0a0a0b'; ctx.beginPath(); ctx.moveTo(0, h * 0.86); ctx.lineTo(w, h * 0.8); ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.fill();
|
||||
for (let i = 0; i < 4; i++) { const x = w * (0.1 + i * 0.28), y = h * (0.86 - i * 0.015); ctx.fillStyle = '#060606'; ctx.fillRect(x, y - h * 0.16, 2, h * 0.16); }
|
||||
// fog
|
||||
for (let y = 0; y < h; y += 4) { const f = Math.exp(-(((y - hz) / (h * 0.12)) ** 2)) * 0.18; ctx.fillStyle = `rgba(150,130,110,${f})`; ctx.fillRect(0, y, w, 4); }
|
||||
c.lights = []; for (let i = 0; i < 4; i++) c.lights.push([w * (0.1 + i * 0.28) + 1, h * (0.86 - i * 0.015) - h * 0.16]);
|
||||
c.hz = hz; c.sy = sy + h * 0.07; return c;
|
||||
},
|
||||
frame(ctx, w, h, t, o, B) {
|
||||
ctx.drawImage(B, 0, 0); const N = this._n || (this._n = noiseGen(4)); const R = rng(Math.floor(t * 8));
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
// reflections shimmer
|
||||
for (let y = B.sy; y < h * 0.86; y += 2) { const f = (y - B.sy) / (h * 0.86 - B.sy); for (const [lx, a] of [[0.83, 0.35], [0.3, 0.12], [0.55, 0.1], [0.12, 0.08]]) { const j = (N(y * 0.15, t * 2 + lx * 9) - 0.5) * 16 * (0.5 + f); ctx.fillStyle = `rgba(255,170,90,${a * (1 - f * 0.7)})`; ctx.fillRect(lx * w + j - 3 - f * 8, y, 6 + f * 16, 1.3); } }
|
||||
for (const [x, y] of B.lights) glow(ctx, x, y, w * 0.06, 'rgba(255,170,80,A)', 0.55);
|
||||
for (let i = 0; i < 4; i++) { const on = Math.sin(t * 3 + i * 1.7) > 0; if (!on) continue; glow(ctx, w * (0.1 + i * 0.2) + w * 0.035, B.hz - h * (0.34 - i * 0.03) - h * 0.065, w * 0.025, 'rgba(255,40,30,A)', 0.9); }
|
||||
ctx.restore();
|
||||
// light rain
|
||||
ctx.save(); ctx.strokeStyle = 'rgba(200,200,210,.18)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < 90; i++) { const x = (R() * w + t * 60) % w, y = R() * h; ctx.moveTo(x, y); ctx.lineTo(x - 3, y + h * 0.03); } ctx.stroke(); ctx.restore();
|
||||
scan(ctx, w, h, 0.06); vignette(ctx, w, h, 0.5); grain(ctx, w, h, 0.12);
|
||||
cctvOSD(ctx, w, h, t, o);
|
||||
},
|
||||
};
|
||||
|
||||
// scramble crossing at night in the rain: umbrellas from a high camera, neon on wet asphalt
|
||||
SCN.crossing = {
|
||||
// high camera over a scramble: ground plane in perspective, crowds waiting and crossing in the rain
|
||||
map(w, h) { const top = h * 0.25, zF = 2.3, zN = 1; return (u, v) => { const z = lerp(zF, zN, v); const y = top + (h - top) * ((1 / z - 1 / zF) / (1 / zN - 1 / zF)); return [w / 2 + ((u - 0.5) * w * 1.3) / z, y, 1 / z]; }; },
|
||||
base(w, h, o) {
|
||||
const c = mk(w, h), ctx = c.getContext('2d'); const R = rng(o.seed || 31); const M = this.map(w, h); const top = h * 0.25;
|
||||
// facades, video screens and signs (abstract, no marks)
|
||||
const fg = ctx.createLinearGradient(0, 0, 0, top); fg.addColorStop(0, '#060609'); fg.addColorStop(1, '#141218'); ctx.fillStyle = fg; ctx.fillRect(0, 0, w, top + 2);
|
||||
for (let i = 0; i < 7; i++) { const x = (i / 7) * w + R() * 10, bw = w / 7 - 4; ctx.fillStyle = `rgb(${12 + R() * 10},${12 + R() * 8},${16 + R() * 10})`; ctx.fillRect(x, R() * top * 0.2, bw, top); for (let k = 0; k < 40; k++) { if (R() < 0.5) continue; ctx.fillStyle = `rgba(255,${210 + R() * 40},${160 + R() * 70},${0.25 + R() * 0.4})`; ctx.fillRect(x + R() * bw, R() * top, 2, 1.5); } }
|
||||
const neon = ['255,70,130', '70,200,255', '255,190,70', '170,110,255', '90,255,190'];
|
||||
const screens = []; for (let i = 0; i < 4; i++) { const x = w * (0.05 + i * 0.25) + R() * 10, y = top * (0.14 + R() * 0.3), sw = w * (0.09 + R() * 0.05), sh = top * (0.22 + R() * 0.16); screens.push([x, y, sw, sh, neon[R.int(5)], neon[R.int(5)]]); }
|
||||
for (const [x, y, sw, sh, a, b] of screens) { ctx.fillStyle = '#050507'; ctx.fillRect(x - 3, y - 3, sw + 6, sh + 6); const g = ctx.createLinearGradient(x, y, x + sw, y + sh); g.addColorStop(0, `rgba(${a},.75)`); g.addColorStop(0.6, `rgba(${b},.55)`); g.addColorStop(1, `rgba(${a},.35)`); ctx.fillStyle = g; ctx.fillRect(x, y, sw, sh); ctx.fillStyle = 'rgba(255,255,255,.35)'; for (let k = 0; k < 3; k++) ctx.fillRect(x + sw * 0.1, y + sh * (0.55 + k * 0.12), sw * (0.3 + R() * 0.5), Math.max(1, sh * 0.05)); ctx.fillStyle = 'rgba(0,0,0,.25)'; for (let yy = y; yy < y + sh; yy += 2) ctx.fillRect(x, yy, sw, 1); ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, x + sw / 2, y + sh / 2, sw * 0.8, `rgba(${a},A)`, 0.18); ctx.restore(); }
|
||||
for (let i = 0; i < 9; i++) { const x = R() * w, y = R() * top * 0.7; ctx.fillStyle = `rgba(${neon[R.int(5)]},.6)`; ctx.fillRect(x, y, w * 0.008, top * (0.15 + R() * 0.25)); }
|
||||
// asphalt
|
||||
ctx.fillStyle = '#121117'; ctx.beginPath(); const q = [M(0, 0), M(1, 0), M(1, 1), M(0, 1)]; ctx.moveTo(-w, top); ctx.lineTo(w * 2, top); ctx.lineTo(w * 2, h); ctx.lineTo(-w, h); ctx.fill();
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (const [x, y, sw, sh, a] of screens) { const g = ctx.createLinearGradient(0, top, 0, h); g.addColorStop(0, `rgba(${a},.30)`); g.addColorStop(0.6, `rgba(${a},.05)`); g.addColorStop(1, `rgba(${a},0)`); ctx.fillStyle = g; for (let k = 0; k < 6; k++) ctx.fillRect(x + sw * (0.1 + k * 0.15) + R.gauss() * 3, top, sw * 0.08, h - top); }
|
||||
ctx.restore();
|
||||
// crosswalk stripes as quads on the ground plane
|
||||
const zebra = (u1, v1, u2, v2, n, wd) => { const du = u2 - u1, dv = v2 - v1, L = Math.hypot(du, dv); const nu = -dv / L * wd, nv = du / L * wd; ctx.fillStyle = 'rgba(205,208,218,.26)'; for (let i = 0; i < n; i++) { const t0 = (i + 0.15) / n, t1 = (i + 0.62) / n; const a = M(u1 + du * t0 + nu, v1 + dv * t0 + nv), b = M(u1 + du * t1 + nu, v1 + dv * t1 + nv), cc = M(u1 + du * t1 - nu, v1 + dv * t1 - nv), d = M(u1 + du * t0 - nu, v1 + dv * t0 - nv); poly(ctx, [a, b, cc, d], true); ctx.fill(); } };
|
||||
zebra(0.1, 0.14, 0.9, 0.14, 26, 0.06); zebra(0.08, 0.9, 0.92, 0.9, 26, 0.07); zebra(0.08, 0.2, 0.08, 0.84, 20, 0.055); zebra(0.92, 0.2, 0.92, 0.84, 20, 0.055); zebra(0.17, 0.22, 0.83, 0.82, 30, 0.028); zebra(0.83, 0.22, 0.17, 0.82, 30, 0.028);
|
||||
// kerbs/corner pavements
|
||||
ctx.fillStyle = '#1d1c22'; for (const [u0, v0] of [[-0.3, -0.2], [1.0, -0.2], [-0.3, 1.0], [1.0, 1.0]]) { poly(ctx, [M(u0, v0), M(u0 + 0.3, v0), M(u0 + 0.3, v0 + 0.2), M(u0, v0 + 0.2)], true); ctx.fill(); }
|
||||
// vehicles waiting, lights on
|
||||
for (const [u, v, d] of [[-0.05, 0.4, 1], [-0.05, 0.55, 1], [-0.05, 0.7, 1], [1.05, 0.35, -1], [1.05, 0.5, -1], [0.35, -0.05, 0], [0.55, 1.08, 0]]) { const [x, y, s] = M(u, v); ctx.fillStyle = '#18181d'; ctx.fillRect(x - 22 * s, y - 9 * s, 44 * s, 18 * s); ctx.save(); ctx.globalCompositeOperation = 'lighter'; if (d >= 0) { glow(ctx, x + 22 * s, y - 5 * s, 26 * s, 'rgba(255,248,225,A)', 0.8); glow(ctx, x + 22 * s, y + 5 * s, 26 * s, 'rgba(255,248,225,A)', 0.8); } else { glow(ctx, x + 22 * s, y, 18 * s, 'rgba(255,40,40,A)', 0.7); } ctx.restore(); }
|
||||
// people
|
||||
const P = []; const corners = [[0.06, 0.08], [0.94, 0.08], [0.06, 0.94], [0.94, 0.94]]; const cols = ['226,60,72', '34,44,64', '236,236,242', '56,134,228', '248,188,44', '26,26,32', '118,196,124', '240,120,170', '40,40,46', '190,190,200'];
|
||||
for (let i = 0; i < 520; i++) { const wait = i < 170; const ci = i % 4; const a = corners[ci], b = wait ? a : corners[[3, 2, 1, 0, 1, 0, 3, 2][(i >> 2) % 8] ?? 3 - ci]; const umb = R() < 0.72; P.push({ a, b, wait, off: R(), sp: 0.035 + R() * 0.03, jx: R.gauss() * (wait ? 0.035 : 0.03), jy: R.gauss() * (wait ? 0.03 : 0.022), umb, col: cols[R.int(cols.length)], s: 0.8 + R() * 0.45 }); }
|
||||
c.P = P; c.M = M; return c;
|
||||
},
|
||||
frame(ctx, w, h, t, o, B) {
|
||||
ctx.drawImage(B, 0, 0); const M = B.M; const list = [];
|
||||
for (const p of B.P) { const u = p.wait ? 0 : (p.off + t * p.sp) % 1; const [x, y, s] = M(lerp(p.a[0], p.b[0], u) + p.jx, lerp(p.a[1], p.b[1], u) + p.jy); list.push([y, x, s * (w / 46) * p.s, p]); }
|
||||
list.sort((a, b) => a[0] - b[0]);
|
||||
for (const [y, x, s, p] of list) { if (p.umb) { ctx.fillStyle = `rgba(${p.col},.95)`; ctx.beginPath(); ctx.ellipse(x, y, s * 1.1, s * 0.82, 0, 0, TAU); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,.16)'; ctx.beginPath(); ctx.ellipse(x - s * 0.25, y - s * 0.22, s * 0.5, s * 0.32, 0, 0, TAU); ctx.fill(); } else { ctx.fillStyle = '#17161b'; ctx.beginPath(); ctx.ellipse(x, y + s * 0.2, s * 0.55, s * 0.4, 0, 0, TAU); ctx.fill(); ctx.fillStyle = '#2b2622'; ctx.beginPath(); ctx.arc(x, y - s * 0.1, s * 0.33, 0, TAU); ctx.fill(); } }
|
||||
ctx.save(); ctx.strokeStyle = 'rgba(210,215,230,.15)'; ctx.lineWidth = 1; const R = rng(Math.floor(t * 10)); ctx.beginPath(); for (let i = 0; i < 160; i++) { const x = R() * w, y = R() * h; ctx.moveTo(x, y); ctx.lineTo(x - 2, y + h * 0.025); } ctx.stroke(); ctx.restore();
|
||||
vignette(ctx, w, h, 0.45); scan(ctx, w, h, 0.05); grain(ctx, w, h, 0.1);
|
||||
if (!o.noOSD) cctvOSD(ctx, w, h, t, o);
|
||||
},
|
||||
};
|
||||
|
||||
// waterfront in a storm at night: looking along the wall, swells breaking over it, sodium lamps on a
|
||||
// flooded road, a town behind; handheld when a person films it
|
||||
SCN.seawall = {
|
||||
base(w, h, o) {
|
||||
const c = mk(w, h), ctx = c.getContext('2d'); const R = rng(o.seed || 51); const N = noiseGen(o.seed || 51); const hz = h * 0.4;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, hz); sky.addColorStop(0, '#06070a'); sky.addColorStop(1, o.skyline ? '#2a2129' : '#191a20'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, hz + 2);
|
||||
field(ctx, w, hz, 3, (u, v, out) => { const n = N.fbm(u * 2.5 + v, v * 4, 5); out[0] = 34 + n * 40; out[1] = 32 + n * 38; out[2] = 38 + n * 40; out[3] = 150 * smooth(0.35, 0.8, n); });
|
||||
// town / skyline on the right half, beyond the road
|
||||
const town = (x0, x1, tall) => { for (let i = 0; i < (tall ? 26 : 34); i++) { const x = lerp(x0, x1, R()), bw = w * (tall ? 0.02 + R() * 0.035 : 0.025 + R() * 0.04), bh = h * (tall ? 0.04 + Math.pow(R(), 2) * 0.24 : 0.02 + R() * 0.05); ctx.fillStyle = '#0b0a0d'; ctx.fillRect(x, hz - bh, bw, bh + 2); const n = Math.floor(bw * bh * 0.012); for (let k = 0; k < n; k++) { if (R() < 0.5) continue; ctx.fillStyle = `rgba(255,${190 + R() * 60},${120 + R() * 90},${0.3 + R() * 0.6})`; ctx.fillRect(x + R() * bw, hz - R() * bh, 1.4, 1.4); } } };
|
||||
town(w * 0.5, w * 1.0, !!o.skyline);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, w * 0.8, hz, w * 0.5, 'rgba(255,140,60,A)', o.skyline ? 0.22 : 0.12); ctx.restore();
|
||||
// sea on the left: dark swells with streaked foam, perspective-compressed toward the horizon
|
||||
field(ctx, w, h, 2, (u, v, out) => { const y = v * h; if (y < hz) { out[3] = 0; return; } const d = (y - hz) / (h - hz); const z = 1 / (0.05 + d); const n = N.fbm(u * 3 * z * 0.25 + 3, d * 9 + z * 0.15, 5); const sw = Math.sin(z * 1.6 + n * 4); const crest = smooth(0.7, 0.98, sw) * smooth(0.45, 0.7, n); const foam = smooth(0.68, 0.82, N(u * 30 * z * 0.1, d * 60)) * 0.35 * smooth(0.5, 0.75, n);
|
||||
out[0] = 10 + n * 22 + (crest + foam) * 170; out[1] = 13 + n * 25 + (crest + foam) * 172; out[2] = 18 + n * 28 + (crest + foam) * 178; out[3] = 255; });
|
||||
// the wall: a parapet running from bottom-left toward the horizon; road + puddles on its right
|
||||
const vp = [w * 0.56, hz + h * 0.005]; const L0 = [w * -0.02, h * 1.0], L1 = [w * 0.22, h * 1.0];
|
||||
ctx.fillStyle = '#1e1d1f'; poly(ctx, [L0, vp, [vp[0] + 2, vp[1]], L1], true); ctx.fill(); // wall top
|
||||
const road = [[w * 0.22, h], [vp[0] + 2, vp[1]], [w * 0.9, vp[1] + h * 0.02], [w * 1.2, h]]; const rg = ctx.createLinearGradient(0, hz, 0, h); rg.addColorStop(0, '#141316'); rg.addColorStop(1, '#0a0a0b'); ctx.fillStyle = rg; poly(ctx, road, true); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(200,190,170,.18)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(L1[0], L1[1]); ctx.lineTo(vp[0] + 2, vp[1]); ctx.stroke();
|
||||
// lamps along the road edge
|
||||
const lamps = []; for (let i = 0; i < 7; i++) { const f = Math.pow(i / 7, 0.8); const bx = lerp(w * 0.5, vp[0] + w * 0.02, f), by = lerp(h * 1.0, vp[1] + h * 0.004, f); const lh = lerp(h * 0.5, h * 0.02, f); ctx.fillStyle = '#050506'; ctx.fillRect(bx, by - lh, Math.max(1.2, 6 * (1 - f)), lh); ctx.fillRect(bx - lh * 0.12, by - lh, lh * 0.14, Math.max(1, 4 * (1 - f))); lamps.push([bx - lh * 0.1, by - lh + 3, 1 - f, by]); }
|
||||
if (o.palm) { ctx.save(); ctx.translate(w * 0.9, h * 0.98); ctx.strokeStyle = '#040404'; ctx.lineWidth = w / 45; ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(-w * 0.02, -h * 0.3, -w * 0.12, -h * 0.5); ctx.stroke(); ctx.lineWidth = w / 160; for (let k = 0; k < 9; k++) { const a = -2.6 + k * 0.28; ctx.beginPath(); ctx.moveTo(-w * 0.12, -h * 0.5); ctx.quadraticCurveTo(-w * 0.12 + Math.cos(a) * w * 0.08, -h * 0.5 + Math.sin(a) * h * 0.04, -w * 0.12 + Math.cos(a) * w * 0.17 - w * 0.05, -h * 0.5 + Math.sin(a) * h * 0.06 + h * 0.06); ctx.stroke(); } ctx.restore(); }
|
||||
c.lamps = lamps; c.hz = hz; c.vp = vp; return c;
|
||||
},
|
||||
frame(ctx, w, h, t, o, B) {
|
||||
const shake = o.handheld ? [Math.sin(t * 7.1) * w * 0.004 + Math.sin(t * 2.3) * w * 0.006, Math.cos(t * 6.3) * h * 0.003] : [0, 0];
|
||||
ctx.save(); ctx.translate(shake[0], shake[1]); ctx.drawImage(B, -4, -4, w + 8, h + 8);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (const [x, y, f, gy] of B.lamps) { glow(ctx, x, y, w * 0.14 * (0.3 + f), 'rgba(255,150,50,A)', 0.55 + 0.1 * Math.sin(t * 13 + x)); const g = ctx.createLinearGradient(0, gy - h * 0.01, 0, gy + h * 0.16 * f); g.addColorStop(0, 'rgba(255,150,60,.0)'); g.addColorStop(0.2, 'rgba(255,150,60,.3)'); g.addColorStop(1, 'rgba(255,150,60,0)'); ctx.fillStyle = g; ctx.fillRect(x - w * 0.012 * (0.5 + f), gy - h * 0.01, w * 0.024 * (0.5 + f) + 1.5, h * 0.17 * f); }
|
||||
// breaking swells: spray sheets rise off the wall and blow right with the wind
|
||||
const R0 = rng(7); for (let b = 0; b < 3; b++) { const per = 3.6 + b * 1.3; const tt = t + b * 1.7; const ph = (tt % per) / per; if (ph > 0.6) continue; const k = Math.sin((ph / 0.6) * Math.PI); const f = [0.18, 0.42, 0.68][b]; const bx = lerp(w * 0.08, B.vp[0], f), by = lerp(h * 0.98, B.vp[1], f); const sz = (1 - f * 0.85) * h * 0.55; const R = rng(b * 97 + Math.floor(tt / per));
|
||||
for (let i = 0; i < 90; i++) { const up = Math.pow(R(), 0.7) * sz * k, side = (R() - 0.35) * sz * 0.35 + up * 0.6 * ph; const x = bx + side, y = by - up; const r = (0.002 + R() * 0.007) * w * (1 - f * 0.6) * (0.6 + k); ctx.fillStyle = `rgba(210,218,230,${(0.04 + R() * 0.1) * k})`; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill(); }
|
||||
ctx.strokeStyle = `rgba(220,226,236,${0.08 * k})`; ctx.lineWidth = Math.max(1, w / 400); ctx.beginPath(); for (let i = 0; i < 40; i++) { const up = R() * sz * k; const x = bx + (R() - 0.3) * sz * 0.3, y = by - up * 0.3; ctx.moveTo(x, y); ctx.lineTo(x + sz * 0.12 * ph + R() * 6, y - up * 0.7); } ctx.stroke();
|
||||
const g = ctx.createRadialGradient(bx + sz * 0.25 * ph, by - sz * 0.35 * k, 0, bx + sz * 0.25 * ph, by - sz * 0.35 * k, sz * 0.55); g.addColorStop(0, `rgba(190,198,212,${0.13 * k})`); g.addColorStop(1, 'rgba(190,198,212,0)'); ctx.fillStyle = g; ctx.fillRect(bx - sz, by - sz * 1.2, sz * 2.2, sz * 1.4); }
|
||||
ctx.restore();
|
||||
// rain, wind-driven, brighter near the lamps
|
||||
const R = rng(Math.floor(t * 24)); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(205,212,228,.26)'; ctx.beginPath(); for (let i = 0; i < 320; i++) { const x = R() * w * 1.25, y = R() * h; ctx.moveTo(x, y); ctx.lineTo(x - w * 0.035, y + h * 0.05); } ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(255,190,120,.35)'; ctx.beginPath(); for (const [x, y, f] of B.lamps) for (let i = 0; i < 10 * f; i++) { const xx = x + (R() - 0.5) * w * 0.2 * f, yy = y + (R() - 0.3) * h * 0.2 * f; ctx.moveTo(xx, yy); ctx.lineTo(xx - w * 0.02, yy + h * 0.03); } ctx.stroke();
|
||||
ctx.restore();
|
||||
vignette(ctx, w, h, 0.55); grain(ctx, w, h, 0.12);
|
||||
if (!o.noOSD) cctvOSD(ctx, w, h, t, o);
|
||||
},
|
||||
};
|
||||
|
||||
// a quiet volcano webcam by day: ash column over the bay
|
||||
SCN.volcano = {
|
||||
base(w, h, o) {
|
||||
const c = mk(w, h), ctx = c.getContext('2d'); const N = noiseGen(12); const R = rng(12); const hz = h * 0.66;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, hz); sky.addColorStop(0, '#6f8fb0'); sky.addColorStop(1, '#c9cfd2'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, hz);
|
||||
// ash plume
|
||||
const vx = w * 0.46, vy = hz - h * 0.3;
|
||||
field(ctx, w, h, 2, (u, v, out) => { const x = u * w - vx, y = v * h - vy; if (y > 4) { out[3] = 0; return; } const up = -y; const drift = up * 0.55; const wd = 10 + up * 0.32; const n = N.fbm((x - drift) / 40, up / 40, 5); const d = Math.exp(-(((x - drift) / wd) ** 2) * (1.2 - n)) * smooth(0, 20, up) * (0.4 + 0.9 * n) * Math.exp(-up / (h * 1.2)); const a = clamp(d * 1.3, 0, 0.95); const sh = 0.6 + (x - drift) / wd * 0.15; out[0] = (120 + n * 70) * sh; out[1] = (108 + n * 62) * sh; out[2] = (98 + n * 58) * sh; out[3] = a * 255; });
|
||||
// cone
|
||||
const prof = (x) => { const d = Math.abs(x - vx) / w; return vy + (d < 0.035 ? (0.035 - d) * h * 0.25 : 0) + Math.pow(d, 1.35) * h * 0.95 + N(x / 22, 0) * 5; };
|
||||
field(ctx, w, h, 1.5, (u, v, out) => { const x = u * w, y = v * h; const top = prof(x); if (y < top || y > hz + 2) { out[3] = 0; return; } const d = (x - vx) / w; const n = N.fbm(u * 14, v * 6, 4); const gul = Math.pow(Math.abs(Math.sin(d * 60 + n * 3)), 6); const shade = 0.75 + d * 1.1 - gul * 0.25; const veg = smooth(0.08, 0.22, (y - top) / h) * smooth(0.4, 0.7, n); out[0] = (78 + n * 30) * shade - veg * 20; out[1] = (72 + n * 26) * shade + veg * 6; out[2] = (66 + n * 22) * shade - veg * 10; out[3] = 255; });
|
||||
// bay
|
||||
const wg = ctx.createLinearGradient(0, hz, 0, h); wg.addColorStop(0, '#7f9aa8'); wg.addColorStop(1, '#3d5664'); ctx.fillStyle = wg; ctx.fillRect(0, hz, w, h - hz);
|
||||
for (let y = hz; y < h; y += 2) { ctx.fillStyle = `rgba(255,255,255,${0.04 + R() * 0.05})`; ctx.fillRect(R() * w, y, w * 0.2 * R(), 1); }
|
||||
// town on the near shore
|
||||
ctx.fillStyle = '#2f3438'; ctx.fillRect(0, h * 0.86, w, h * 0.14); for (let i = 0; i < 60; i++) { const x = R() * w, bw = w * (0.02 + R() * 0.04), bh = h * (0.02 + R() * 0.06); ctx.fillStyle = ['#d8d4cc', '#b9b4aa', '#8e8a84', '#6c6a68'][R.int(4)]; ctx.fillRect(x, h * 0.88 - bh, bw, bh); }
|
||||
return c;
|
||||
},
|
||||
frame(ctx, w, h, t, o, B) { ctx.drawImage(B, 0, 0); vignette(ctx, w, h, 0.25); grain(ctx, w, h, 0.07); if (!o.noOSD) cctvOSD(ctx, w, h, t, o); },
|
||||
};
|
||||
|
||||
/* =====================================================================================
|
||||
INSIDE — what a person saw
|
||||
===================================================================================== */
|
||||
PT.aurora = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 61); const N = noiseGen(61); const hz = h * 0.66;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, hz); sky.addColorStop(0, '#02030a'); sky.addColorStop(1, '#0b1a22'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h);
|
||||
for (let i = 0; i < 500; i++) { ctx.fillStyle = `rgba(255,255,255,${R() * 0.8})`; ctx.fillRect(R() * w, R() * hz, 1, 1); }
|
||||
// curtains (per-pixel: base line, height and ray striation per column)
|
||||
const curtains = [[0.42, 0.06, 1.3, [170, 80, 255]], [0.55, 0.04, 4.2, [200, 90, 255]]];
|
||||
field(ctx, w, h, 3, (u, v, out) => { let r = 0, g = 0, b = 0; const y = v * h; for (const [yb, amp, sd, top] of curtains) { const base = h * yb + Math.sin(u * 5 + sd) * amp * h + N(u * 6, sd) * amp * h * 1.4; const ht = h * (0.18 + 0.28 * N.fbm(u * 9 + sd, sd, 3)); const f = (base - y) / ht; if (f < -0.03 || f > 1) continue; const ray = 0.35 + 0.65 * Math.pow(N(u * 70, sd * 3), 2); const low = f < 0 ? smooth(-0.03, 0, f) : 1; const gk = Math.exp(-f * 3.2) * low * ray, pk = smooth(0.25, 0.9, f) * (1 - f) * ray * 0.6; r += gk * 60 + pk * top[0]; g += gk * 255 + pk * top[1] * 0.3; b += gk * 140 + pk * top[2]; } out[0] = r * 0.7; out[1] = g * 0.7; out[2] = b * 0.7; out[3] = 255; }, true, 'lighter');
|
||||
// mountains + fjord reflection
|
||||
ctx.fillStyle = '#05070a'; ctx.beginPath(); ctx.moveTo(0, hz); for (let x = 0; x <= w; x += 3) ctx.lineTo(x, hz - h * 0.05 - N.ridge(x / w * 3, 2, 4) * h * 0.16); ctx.lineTo(w, hz); ctx.fill();
|
||||
const img = ctx.getImageData(0, 0, w, Math.floor(hz)); const ref = mk(w, Math.floor(hz)); ref.getContext('2d').putImageData(img, 0, 0);
|
||||
ctx.save(); ctx.translate(0, hz * 2); ctx.scale(1, -1); ctx.globalAlpha = 0.45; for (let y = 0; y < hz; y += 3) { const j = Math.sin(y * 0.4) * 2; ctx.drawImage(ref, 0, y, w, 3, j, y, w, 3); } ctx.restore();
|
||||
ctx.fillStyle = 'rgba(2,4,8,.35)'; ctx.fillRect(0, hz, w, h - hz);
|
||||
ctx.fillStyle = '#020305'; ctx.fillRect(0, h * 0.9, w, h * 0.1); for (let i = 0; i < 26; i++) { const x = R() * w, th = h * (0.06 + R() * 0.1); ctx.beginPath(); ctx.moveTo(x, h * 0.9 - th); ctx.lineTo(x - th * 0.22, h * 0.91); ctx.lineTo(x + th * 0.22, h * 0.91); ctx.fill(); }
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, w * 0.7, h * 0.885, w * 0.05, 'rgba(255,190,110,A)', 0.8); ctx.restore();
|
||||
vignette(ctx, w, h, 0.45); grain(ctx, w, h, 0.12);
|
||||
};
|
||||
|
||||
// a hillside on fire, from the road
|
||||
PT.firewit = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 71); const N = noiseGen(71);
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, h * 0.7); sky.addColorStop(0, '#07060a'); sky.addColorStop(0.7, '#3a1a10'); sky.addColorStop(1, '#8a3a12'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h);
|
||||
// smoke lit from below
|
||||
field(ctx, w, h * 0.7, 3, (u, v, out) => { const n = N.fbm(u * 3 + 0.5 * v, v * 4, 5); const lit = smooth(0.2, 0.95, v); out[0] = 40 + n * 90 * lit + 60 * lit; out[1] = 25 + n * 45 * lit; out[2] = 20 + n * 25 * lit; out[3] = 255 * smooth(0.35, 0.75, n) * 0.85; });
|
||||
const ridge = (x) => h * 0.52 - N.fbm(x / w * 2.4, 3, 4) * h * 0.22 + (x / w) * h * 0.06;
|
||||
ctx.fillStyle = '#060404'; ctx.beginPath(); ctx.moveTo(0, h); for (let x = 0; x <= w; x += 3) ctx.lineTo(x, ridge(x)); ctx.lineTo(w, h); ctx.fill();
|
||||
// flames along the ridge
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (let x = 0; x < w; x += 2) { const y = ridge(x); const k = smooth(0.25, 0.6, N(x / 40, 7)); if (k < 0.05) continue; const fh = h * (0.02 + 0.07 * N(x / 9, 2)) * k; const g = ctx.createLinearGradient(0, y, 0, y - fh); g.addColorStop(0, `rgba(255,220,120,${0.9 * k})`); g.addColorStop(0.4, `rgba(255,120,30,${0.7 * k})`); g.addColorStop(1, 'rgba(200,40,10,0)'); ctx.fillStyle = g; ctx.fillRect(x, y - fh, 2.4, fh + 2); }
|
||||
for (let x = 0; x < w; x += w / 14) { const y = ridge(x); glow(ctx, x, y, w * 0.12, 'rgba(255,100,20,A)', 0.25 * smooth(0.25, 0.6, N(x / 40, 7))); }
|
||||
for (let i = 0; i < 120; i++) { const x = R() * w, y = R() * h * 0.55; ctx.fillStyle = `rgba(255,${150 + R() * 80},60,${R() * 0.8})`; ctx.fillRect(x, y, 1.6, 1.6); }
|
||||
ctx.restore();
|
||||
// road & tail lights
|
||||
ctx.fillStyle = '#0b0b0c'; ctx.beginPath(); ctx.moveTo(0, h); ctx.lineTo(w * 0.42, h * 0.74); ctx.lineTo(w * 0.52, h * 0.74); ctx.lineTo(w, h); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,220,120,.4)'; ctx.setLineDash([8, 10]); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(w * 0.62, h); ctx.lineTo(w * 0.475, h * 0.745); ctx.stroke(); ctx.setLineDash([]);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const [x, y, s] of [[0.36, 0.86, 1], [0.44, 0.8, 0.6], [0.47, 0.77, 0.4]]) { glow(ctx, x * w, y * h, w * 0.04 * s, 'rgba(255,30,30,A)', 0.8); glow(ctx, x * w + w * 0.06 * s, y * h, w * 0.04 * s, 'rgba(255,30,30,A)', 0.8); } ctx.restore();
|
||||
vignette(ctx, w, h, 0.5); grain(ctx, w, h, 0.13);
|
||||
};
|
||||
|
||||
// inside the cabin of a diverting airliner: masks down, emergency floor lighting, a calm crew
|
||||
PT.cabin = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 81); const vx = w * 0.52, vy = h * 0.42;
|
||||
const bg = ctx.createRadialGradient(vx, vy, 0, vx, vy, h); bg.addColorStop(0, '#5a5040'); bg.addColorStop(0.35, '#2a2620'); bg.addColorStop(1, '#0c0b0a'); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
const P = (x, y, z) => [vx + (x - vx) * z, vy + (y - vy) * z];
|
||||
// ceiling & bins
|
||||
ctx.fillStyle = '#1e1c1a'; poly(ctx, [[0, 0], [w, 0], P(w, 0, 0.12), P(0, 0, 0.12)], true); ctx.fill();
|
||||
for (const side of [0, 1]) { const x0 = side ? w : 0; ctx.fillStyle = '#2c2a27'; poly(ctx, [[x0, h * 0.05], P(x0, h * 0.05, 0.12), P(x0, h * 0.3, 0.12), [x0, h * 0.3]], true); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,236,200,.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x0, h * 0.3); const e = P(x0, h * 0.3, 0.12); ctx.lineTo(e[0], e[1]); ctx.stroke(); }
|
||||
// windows (dark night outside, faint blue)
|
||||
for (let i = 0; i < 9; i++) { const z = 1 / (1 + i * 0.55); for (const side of [0, 1]) { const x0 = side ? w : 0; const [x, y] = P(x0, h * 0.36, z); const ww = w * 0.05 * z, wh = h * 0.08 * z; ctx.fillStyle = '#0b1424'; ctx.beginPath(); ctx.ellipse(x + (side ? -ww : ww), y, ww * 0.5, wh * 0.5, 0, 0, TAU); ctx.fill(); } }
|
||||
// seat rows receding
|
||||
for (let i = 9; i >= 0; i--) { const z = 1 / (1 + i * 0.55); for (const side of [0, 1]) { for (let k = 0; k < 3; k++) { const sx = side ? lerp(w * 0.62, w * 1.05, k / 2.4) : lerp(w * 0.42, -w * 0.05, k / 2.4); const [x, y] = P(sx, h * 0.6, z); const sw = w * 0.13 * z, sh = h * 0.3 * z; ctx.fillStyle = `rgb(${18 + i * 2},${24 + i * 2},${44 + i * 2})`; ctx.beginPath(); ctx.roundRect(x - sw / 2, y - sh * 0.55, sw * 0.92, sh, sw * 0.18); ctx.fill(); ctx.fillStyle = `rgba(210,205,190,${0.5 - i * 0.03})`; ctx.fillRect(x - sw * 0.38, y - sh * 0.52, sw * 0.7, sh * 0.12); if (R() < 0.75) { ctx.fillStyle = `rgba(${40 + R() * 40},${30 + R() * 30},${25 + R() * 20},.95)`; ctx.beginPath(); ctx.arc(x - sw * 0.05, y - sh * 0.62, sw * 0.2, 0, TAU); ctx.fill(); } } } }
|
||||
// oxygen masks hanging on their tubes
|
||||
for (let i = 0; i < 26; i++) { const z = 1 / (1 + R() * 5); const side = R() < 0.5; const sx = side ? lerp(w * 0.62, w * 1.0, R()) : lerp(w * 0.42, 0, R()); const [x, y0] = P(sx, h * 0.3, z); const L = h * (0.12 + R() * 0.16) * z; const sway = (R() - 0.5) * 10 * z; ctx.strokeStyle = 'rgba(240,240,230,.7)'; ctx.lineWidth = Math.max(0.6, 1.4 * z); ctx.beginPath(); ctx.moveTo(x, y0); ctx.quadraticCurveTo(x + sway, y0 + L * 0.6, x + sway * 0.6, y0 + L); ctx.stroke(); ctx.fillStyle = '#F4C21A'; ctx.beginPath(); ctx.ellipse(x + sway * 0.6, y0 + L + 5 * z, 7 * z * (w / 400), 9 * z * (w / 400), 0, 0, TAU); ctx.fill(); }
|
||||
// aisle with emergency floor path lighting
|
||||
ctx.fillStyle = '#161412'; poly(ctx, [[w * 0.36, h], [w * 0.68, h], P(w * 0.56, h, 0.1), P(w * 0.48, h, 0.1)], true); ctx.fill();
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (let i = 0; i < 18; i++) { const z = 1 / (1 + i * 0.4); for (const xx of [w * 0.38, w * 0.66]) { const [x, y] = P(xx, h * 0.99, z); glow(ctx, x, y, 10 * z * (w / 300), 'rgba(255,170,70,A)', 0.9); } } glow(ctx, vx, vy - h * 0.05, w * 0.3, 'rgba(255,220,170,A)', 0.25); ctx.restore();
|
||||
// a crew member down the aisle
|
||||
const [cx, cy] = P(w * 0.52, h * 0.66, 0.42); ctx.fillStyle = '#0d0c10'; ctx.beginPath(); ctx.roundRect(cx - w * 0.03, cy - h * 0.16, w * 0.06, h * 0.17, 6); ctx.fill(); ctx.beginPath(); ctx.arc(cx, cy - h * 0.18, w * 0.022, 0, TAU); ctx.fill();
|
||||
// motion blur feel + grain
|
||||
ctx.save(); ctx.globalAlpha = 0.18; ctx.drawImage(cv, 3, 1); ctx.restore();
|
||||
vignette(ctx, w, h, 0.6); grain(ctx, w, h, 0.16);
|
||||
};
|
||||
|
||||
// a city block after the power went out: one building on generator light
|
||||
PT.blackout = function (cv, o = {}) {
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height; const R = rng(o.seed || 91);
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, h); sky.addColorStop(0, '#05060a'); sky.addColorStop(1, '#10121a'); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h);
|
||||
for (let i = 0; i < 7; i++) { const x = i * w / 6 - 10, bw = w / 6 + 10, bh = h * (0.25 + R() * 0.3); ctx.fillStyle = '#08090c'; ctx.fillRect(x, h * 0.72 - bh, bw, bh); }
|
||||
const hx = w * 0.56, hy = h * 0.38; ctx.fillStyle = '#121418'; ctx.fillRect(hx - w * 0.16, hy, w * 0.32, h * 0.34);
|
||||
for (let r = 0; r < 7; r++) for (let c = 0; c < 10; c++) { if (R() < 0.45) continue; ctx.fillStyle = `rgba(255,${225 + R() * 30},${180 + R() * 50},${0.45 + R() * 0.5})`; ctx.fillRect(hx - w * 0.145 + c * w * 0.03, hy + h * 0.025 + r * h * 0.042, w * 0.016, h * 0.022); }
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; glow(ctx, hx, hy + h * 0.15, w * 0.4, 'rgba(255,230,190,A)', 0.18); glow(ctx, w * 0.2, h * 0.86, w * 0.1, 'rgba(255,40,40,A)', 0.6); ctx.restore();
|
||||
const rg = ctx.createLinearGradient(0, h * 0.72, 0, h); rg.addColorStop(0, '#0b0c10'); rg.addColorStop(1, '#030304'); ctx.fillStyle = rg; ctx.fillRect(0, h * 0.72, w, h * 0.28);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.25; for (let y = h * 0.74; y < h; y += 2) { ctx.fillStyle = 'rgba(255,220,170,.5)'; ctx.fillRect(hx - w * 0.08 + Math.sin(y) * 4, y, w * 0.16, 1); } ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(200,210,230,.2)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < 160; i++) { const x = R() * w, y = R() * h; ctx.moveTo(x, y); ctx.lineTo(x - 4, y + 14); } ctx.stroke();
|
||||
vignette(ctx, w, h, 0.55); grain(ctx, w, h, 0.15);
|
||||
};
|
||||
|
||||
/* ---------- registry: id → draw(canvas, opts); scenes are drawn at t ---------- */
|
||||
PT.draw = function (id, cv, o = {}) {
|
||||
if (SCN[id]) { const sc = SCN[id]; const key = id + '|' + cv.width + 'x' + cv.height + '|' + (o.seed || 0) + '|' + (o.skyline ? 1 : 0) + (o.palm ? 1 : 0); PT._bases = PT._bases || new Map(); let B = PT._bases.get(key); if (!B) { B = sc.base(cv.width, cv.height, o); PT._bases.set(key, B); } sc.frame(cv.getContext('2d'), cv.width, cv.height, o.t ?? performance.now() / 1000, o, B); return true; }
|
||||
const f = PT[id]; if (typeof f !== 'function') return false; f(cv, o); return true;
|
||||
};
|
||||
PT.animated = (id) => !!SCN[id];
|
||||
@@ -37,7 +37,8 @@ function dayReplay(lat, lon, localHour = 15.5) {
|
||||
while (t > NOW) t -= 86400000; return { at: t, start: t - 3 * 3600000, end: t + 3 * 3600000, replay: true };
|
||||
}
|
||||
SIT.dayReplay = dayReplay;
|
||||
const LAUNCH_T0 = NOW + (2 * 3600 + 14 * 60 + 33) * 1000;
|
||||
const LAUNCH_T0 = NOW - (34 * 60 + 12) * 1000;
|
||||
GE.LAUNCH_T0 = LAUNCH_T0;
|
||||
const KOSHI_T = GE.scenes.FLOOD.startMs;
|
||||
const AURORA_T = (() => { const d = new Date(NOW); d.setUTCHours(21, 30, 0, 0); if (d.getTime() < NOW) d.setUTCDate(d.getUTCDate() + 1); return d.getTime(); })();
|
||||
|
||||
@@ -123,11 +124,11 @@ add({
|
||||
});
|
||||
add({
|
||||
id: 'launch', short: 'Launch · SLC-40', fam: 'orbit', kind: 'launch', section: 'now', live: true, aspect: 1.5, ageS: 300, watchers: 33800,
|
||||
title: 'Rideshare launch from SLC-40 — countdown', place: 'Cape Canaveral, Florida', lat: 28.5618, lon: -80.5772,
|
||||
title: 'Twilight launch from SLC-40 — plume seen along 600 km of coast', place: 'Cape Canaveral, Florida', lat: 28.5618, lon: -80.5772,
|
||||
camera: { lat: 28.5585, lon: -80.5825, range: 0.00045, heading: 0.85, tilt: 1.28 }, labels3d: [[28.5618, -80.5772, 'SLC-40 · vehicle on pad', 0.11]], thumbCam: { lat: 28.25, lon: -80.95, range: 0.028, heading: 0.75, tilt: 1.0 }, layers: ['launches', 'flights', 'ais', 'tracks'], source: 'Range schedule (illustrative)',
|
||||
metric: () => [`<span class="num">T−${fmt.dur((LAUNCH_T0 - Date.now()) / 1000)}</span>`, 'Rideshare to SSO · trajectory NE'],
|
||||
metrics: () => [{ v: fmt.dur((LAUNCH_T0 - Date.now()) / 1000), u: '', l: 'Countdown', s: 'Range · 5 min' }, { v: 92, u: '%', l: 'Weather go', s: 'Forecast · 1 h' }, { v: 14, u: '', l: 'Vessels in hazard area', s: 'AIS · 40 s' }],
|
||||
brief: { lede: 'Vehicle is vertical on the pad. The hazard area offshore is still being cleared — 14 vessels remain inside. Trajectory heads northeast along the coast.', series: [{ label: 'Vessels in hazard area', unit: '', data: [41, 39, 36, 33, 31, 28, 24, 21, 19, 17, 15, 14], src: 'AIS · 15 min · 40 s', fmt: (v) => v }], sources: [['Range schedule (illustrative)', '5 min', 'live'], ['AIS', '40 s', 'live']] },
|
||||
metric: () => [`<span class="num">T+${fmt.dur((Date.now() - LAUNCH_T0) / 1000)}</span>`, 'Rideshare to SSO · 2nd stage coasting'],
|
||||
metrics: () => [{ v: fmt.dur((Date.now() - LAUNCH_T0) / 1000), u: '', l: 'Since liftoff', s: 'Range · live' }, { v: 212, u: 'km', l: '2nd stage altitude', s: 'Tracking · 10 s' }, { v: 418, u: '', l: 'Sky captures', s: 'Signed · 4 min' }],
|
||||
brief: { lede: 'The vehicle lifted off just after sunset. Its exhaust climbed into sunlight above the dark ground and spread into a glowing "jellyfish" seen from Georgia to the Keys. The second stage is coasting to its burn; the pad is being safed.', series: [{ label: 'Sky captures per minute', unit: '', data: [0, 2, 14, 61, 120, 96, 70, 44, 30, 22, 15, 11], src: 'Signed bundles · 1 min', fmt: (v) => v }], sources: [['Range schedule (illustrative)', '5 min', 'live'], ['Tracking camera (illustrative)', '10 s', 'live'], ['Signed sky captures', '4 min', 'live']] },
|
||||
});
|
||||
add({
|
||||
id: 'koshi', short: 'Bhote Koshi · 4D', fam: 'haz', kind: 'recon', section: 'recon', live: false, aspect: 1.33, ageS: 53 * 86400, watchers: 12800, thread: 'koshi', d4: true,
|
||||
@@ -211,6 +212,21 @@ add({
|
||||
brief: { lede: 'Two traffic separation lanes, one of the densest anchorages anywhere.', series: [{ label: 'Transits per hour', unit: '', data: walk(24, 80, 4, 0, 61).map(Math.round), src: 'AIS · hourly', fmt: (v) => v }], sources: [['AIS', '40 s', 'live']] },
|
||||
});
|
||||
|
||||
/* round two: situations behind the new feed cards (lite: camera, layers, brief) */
|
||||
const lite = (o) => add({ live: true, aspect: 1.25, section: 'now', watchers: 3000, metric: () => [o.m0 || '', o.m1 || ''], metrics: () => o.ms.map(([v, u, l, sx]) => ({ v, u, l, s: sx })), brief: { lede: o.lede, series: o.series || [], sources: o.sources || [] }, ...o });
|
||||
lite({ id: 'dfw', short: 'Squall line · DFW', fam: 'wx', kind: 'storm', title: 'Bowing squall line crossing Dallas–Fort Worth', place: 'Dallas–Fort Worth, Texas', lat: 32.82, lon: -97.0, camera: { lat: 32.62, lon: -97.12, range: 0.05, heading: 0.3, tilt: 0.9 }, layers: ['radar', 'lightning', 'flights', 'traffic'], source: 'Radar mosaic', watchers: 26100, ageS: 90,
|
||||
ms: [[62, 'dBZ', 'Peak reflectivity', 'Radar · 2 min'], [72, 'km/h', 'Line motion', 'Derived'], [1, '', 'Tornado warning', 'Warning feed · 4 min']], lede: 'A bowing line is racing east at 72 km/h. The apex is producing 100 km/h gusts and a brief tornado warning covers Arlington. Departures at DFW are on a ground stop.', series: [{ label: 'Peak dBZ', unit: '', data: [38, 41, 47, 52, 55, 58, 61, 62], src: 'Radar mosaic · 5 min', fmt: (v) => v }], sources: [['Radar mosaic (illustrative)', '2 min', 'live'], ['Lightning network', '30 s', 'live']] });
|
||||
lite({ id: 'gulf', short: 'Lightning · Gulf', fam: 'wx', kind: 'lightning', title: '4,812 lightning strikes in the last hour over the Gulf', place: 'Gulf of Mexico', lat: 26.5, lon: -89, camera: { lat: 24.5, lon: -89.5, range: 0.3, heading: 0, tilt: 0.5 }, layers: ['lightning', 'clouds', 'flights', 'ais'], source: 'Lightning network', watchers: 4100, ageS: 60,
|
||||
ms: [['4,812', '', 'Strikes / h', 'Network · 1 min'], [5, '', 'Active cells', 'Derived'], [31, '', 'Flights deviating', 'ADS-B · 2 min']], lede: 'Five clusters of deep convection are firing over warm Gulf water. Airliners are deviating around the two strongest cells.', sources: [['Lightning network (illustrative)', '1 min', 'live']] });
|
||||
lite({ id: 'shibuya', short: 'Shibuya crossing', fam: 'eyes', kind: 'eye', title: 'Shibuya crossing in the rain — public camera', place: 'Shibuya, Tokyo', lat: 35.6595, lon: 139.7005, camera: { lat: 35.6575, lon: 139.6985, range: 0.0009, heading: 0.6, tilt: 1.1 }, layers: ['cctv', 'transit', 'traffic', 'venues'], source: 'Public camera', watchers: 9400, ageS: 3,
|
||||
ms: [['~2,400', '', 'People per cycle', 'Camera count · 1 min'], [12, 'mm/h', 'Rain', 'Gauge · 10 min'], [46, 's', 'Signal cycle', 'Observed']], lede: 'A public street camera over the scramble. Counts are estimated on-device; no faces are kept.', sources: [['Public street camera (illustrative)', 'live', 'live']] });
|
||||
lite({ id: 'derby', short: 'Derby egress', fam: 'ground', kind: 'crowd', title: 'Derby egress: crowd density building at Gate C', place: 'Avellaneda, Buenos Aires', lat: -34.6705, lon: -58.3685, camera: { lat: -34.6735, lon: -58.3715, range: 0.0012, heading: 0.4, tilt: 1.0 }, layers: ['venues', 'transit', 'traffic', 'cctv'], source: 'Crowd sensing (illustrative)', watchers: 11800, ageS: 40,
|
||||
ms: [[4.6, 'p/m²', 'Peak density', 'Derived · 30 s'], ['41k', '', 'Leaving', 'Estimate'], [3, '', 'Gates open', 'Venue feed']], lede: 'Fans are leaving after the final whistle. Density outside Gate C has passed 4 people per square metre while Gates A and D are flowing freely. Stewards are being redirected.', series: [{ label: 'Density at Gate C', unit: 'p/m²', data: [1.1, 1.4, 2.0, 2.8, 3.5, 4.1, 4.6], src: 'Derived · 1 min', fmt: (v) => v.toFixed(1) }], sources: [['Crowd sensing (illustrative)', '30 s', 'live'], ['Signed captures', '3 min', 'live']] });
|
||||
lite({ id: 'sakurajima', short: 'Sakurajima cam', fam: 'haz', kind: 'volcano', title: 'Sakurajima ash column to 3.2 km — webcam', place: 'Kagoshima Bay, Japan', lat: 31.58, lon: 130.66, camera: { lat: 31.52, lon: 130.6, range: 0.012, heading: 0.5, tilt: 1.15 }, layers: ['volcanoes', 'flights', 'wind', 'cctv'], source: 'Volcano webcam', watchers: 5300, ageS: 120,
|
||||
ms: [[3.2, 'km', 'Ash column', 'Webcam · 2 min'], ['ESE', '', 'Drift', 'Wind model'], [0, '', 'Flights affected', 'ADS-B']], lede: 'A routine but tall explosion. Ash is drifting away from the city; the airport is open.', sources: [['Volcano webcam (illustrative)', '2 min', 'live'], ['Wind model', '1 h', 'live']] });
|
||||
lite({ id: 'lakefront', short: 'Lake Michigan gale', fam: 'wx', kind: 'gale', title: 'Gale on Lake Michigan — waves over the lakefront trail', place: 'Chicago lakefront', lat: 41.866, lon: -87.607, camera: { lat: 41.62, lon: -87.42, range: 0.035, heading: -0.35, tilt: 1.0 }, layers: ['cctv', 'signed', 'traffic'], source: 'Buoy + signed captures', watchers: 1900, ageS: 300, section: 'near',
|
||||
ms: [[3.4, 'm', 'Wave height', 'Buoy · 10 min'], [74, 'km/h', 'Gusts', 'Station · 5 min'], [2, '', 'Trail closures', 'City feed']], lede: 'North-northeast gales are piling waves onto the lakefront. The trail is closed between two harbours.', sources: [['Lake buoy (illustrative)', '10 min', 'live'], ['Signed captures', 'live', 'live']] });
|
||||
|
||||
for (const s of S) if (s.time && s.time.replay) { s.live = false; s.replayLabel = 'Today ' + new Date(s.time.at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }) + ' UTC'; }
|
||||
SIT.list = S;
|
||||
SIT.byId = Object.fromEntries(S.map((s) => [s.id, s]));
|
||||
@@ -220,6 +236,7 @@ SIT.sections = [
|
||||
// dynamic cameras
|
||||
SIT.cameraFor = function (s) {
|
||||
if (s.id === 'starlink') { const g = GE.world.sun.gmst; const p = GE.satAt(GE.SATS.find((x) => x.kind === 5), GE.world.t, g); const [la, lo] = geo.ll(p); return { lat: la - 6, lon: lo - 8, range: 0.32, heading: 0.6, tilt: 0.85 }; }
|
||||
if (s.id === 'squawk' && GE.feed && GE.feed._sq) return s.camera;
|
||||
if (s.id === 'squawk') { const f = GE.FLIGHTS[GE.world.squawks[0]]; const p = GE.flightAt(f, GE.world.t) || { lat: 50, lon: -30 }; return { lat: p.lat - 4, lon: p.lon - 3, range: 0.3, heading: 0.3, tilt: 0.75 }; }
|
||||
if (s.id === 'iss') { const p = GE.satAt(GE.ISS, GE.world.t, GE.world.sun.gmst); const [la, lo] = geo.ll(p); return { lat: la - 9, lon: lo - 4, range: 0.48, heading: 0.35, tilt: 0.78 }; }
|
||||
return s.camera;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Rooms — every situation has one. Structured posts (Witness / Context / Question / Correction /
|
||||
Prediction) anchored in place and time, badges earned from evidence, community notes shown only when
|
||||
raters who usually disagree both find them helpful, and a Known / Disputed / Unknown synthesis whose
|
||||
every line cites a post or an observation. All people, posts and claims are fictional. */
|
||||
const RM = (GE.rooms = { byId: {} });
|
||||
const MIN_ = 60000, HR2 = 3600000;
|
||||
RM.KINDS = {
|
||||
witness: { name: 'Witness', icon: 'eye', tone: 'gold', hint: 'I saw or recorded this' },
|
||||
context: { name: 'Context', icon: 'layers', tone: 'sky', hint: 'Background, a map, a source' },
|
||||
question: { name: 'Question', icon: 'bubble', tone: 'white', hint: 'Floats to the top until answered' },
|
||||
correction: { name: 'Correction', icon: 'flag', tone: 'amber', hint: 'Something here is wrong' },
|
||||
prediction: { name: 'Prediction', icon: 'clock', tone: 'orbit', hint: 'What happens next — scored later' },
|
||||
};
|
||||
const P0 = GE.PEOPLE;
|
||||
const kmBetween = (a, b, c, d) => geo.dist(a, b, c, d) * EARTH_KM;
|
||||
RM.kmBetween = kmBetween;
|
||||
|
||||
/* ---------- room content ---------- */
|
||||
const CY = GE.CYCLONE.at(0);
|
||||
const ROOMS = {
|
||||
cyclone: {
|
||||
watching: 3204, rate: 4,
|
||||
src: { O1: 'Geostationary IR mosaic · 01:40Z', O2: 'Advisory + 14 ensemble tracks · 3 h', O3: 'AIS vessel positions · 40 s', S1: 'Sealed live stream, 6 segments · @nadia_basco' },
|
||||
posts: [
|
||||
{ id: 'cy1', kind: 'witness', by: 'nadia', ago: 4, live: true, text: 'Waves are coming over the seawall by the port every 30–40 seconds. The road behind it is ankle-deep. The wall itself is intact from what I can see — I\'m 200 m up the slope.', place: { lat: 20.4485, lon: 121.9705, label: 'Basco seawall' }, att: { paint: 'seawall', o: { palm: true, handheld: true, noOSD: true, seed: 52 }, tier: 'sealed-live', segs: 6, distKm: 0.2 }, reacts: { eye: 1240, heart: 212, alert: 48 }, replies: 31 },
|
||||
{ id: 'cy2', kind: 'question', by: 'rafa', ago: 6, text: 'Is the Magapit bridge into Aparri still open? Nobody here knows and the power is out.', place: { lat: 18.12, lon: 121.65, label: 'Magapit bridge' }, reacts: { eye: 96 }, replies: 2 },
|
||||
{ id: 'cy3', kind: 'context', by: 'lin', ago: 11, text: 'An eyewall replacement looks likely in the next 12 h. Expect the peak wind number to drop while the wind field gets wider. For Batanes that is not good news, so don\'t read a lower number as "weakening".', cites: ['O1', 'O2'], reacts: { heart: 340, eye: 520 }, replies: 18 },
|
||||
{ id: 'cy4', kind: 'witness', by: 'repost', ago: 9, text: 'VIDEO: Basco port pier has COLLAPSED!! Share before it\'s deleted', att: { paint: 'harbour', o: { seed: 19, cam: 'CAM 03', place: 'PORT' }, tier: 'unverified', distKm: null }, reacts: { alert: 860 }, replies: 77,
|
||||
note: { text: 'This clip is from a 2019 storm at a different port. Reverse-image search finds 3 earlier copies. The pier is visible and intact in tonight\'s sealed live stream from Basco [P1].', helpfulA: 0.81, helpfulB: 0.74, ratersA: 118, ratersB: 94, cites: ['P1'] } },
|
||||
{ id: 'cy5', kind: 'prediction', by: 'joon', ago: 14, text: 'Kaohsiung will close the port to outbound traffic by 06:00 local. 11 ships have already left the anchorage and are heading south of the track.', place: { lat: 22.61, lon: 120.27, label: 'Kaohsiung pier 70' }, att: { paint: 'harbour', o: { seed: 8, cam: 'HBR-07', place: 'KAOHSIUNG PIER 70' }, tier: 'eye', distKm: null }, cites: ['O3'], reacts: { eye: 210, heart: 41 }, replies: 6, resolves: '06:00 local' },
|
||||
{ id: 'cy6', kind: 'witness', by: 'rafa', ago: 22, text: 'Power out across Aparri centro since 01:10. The hospital is on generators. Rain is sideways.', place: { lat: 18.357, lon: 121.637, label: 'Aparri centro' }, att: { paint: 'blackout', o: { seed: 91 }, tier: 'sealed', distKm: 0.4 }, reacts: { heart: 190, eye: 300 }, replies: 9 },
|
||||
{ id: 'cy7', kind: 'correction', by: 'ais', ago: 18, text: 'Correction to an earlier post: there are 2 ships holding off Batanes, not 6. The other 4 are at anchor in the lee of Sabtang and are fine.', cites: ['O3'], place: { lat: 20.34, lon: 121.85, label: 'Sabtang lee' }, reacts: { heart: 88 }, replies: 3 },
|
||||
],
|
||||
synth: {
|
||||
known: [['Waves are overtopping the Basco seawall near the port; the wall itself is intact as of a few minutes ago.', ['P1', 'S1']], ['Eyewall cloud tops are colder than −80 °C with frequent lightning.', ['O1']], ['Aparri centro has lost power; the hospital is on generators.', ['P6']]],
|
||||
disputed: [['A viral "collapsed pier" video — a community note shows it predates this storm.', ['P4', 'N1']], ['Whether Halcyon is weakening: peak wind estimates dip while the wind field broadens.', ['P3', 'O2']]],
|
||||
unknown: [['Whether the Magapit bridge into Aparri is open — asked 6 min ago, no evidence yet.', ['P2']], ['Landfall point: guidance still spreads ±150 km at 36 h.', ['O2']]],
|
||||
},
|
||||
},
|
||||
quake: {
|
||||
watching: 1880, rate: 2,
|
||||
src: { O1: 'Seismic catalog · revised 12 min ago', O2: 'Tide gauges · 3 min', O3: 'Rail status boards · 9 min' },
|
||||
posts: [
|
||||
{ id: 'qk1', kind: 'question', by: 'aiko', ago: 48, text: 'Are trains running north of Sendai yet? Stuck at the station with my kids.', place: { lat: 38.26, lon: 140.88, label: 'Sendai station' }, answered: 'qk2', reacts: { eye: 140 }, replies: 5 },
|
||||
{ id: 'qk2', kind: 'context', by: 'tetsu', ago: 41, text: 'Northbound service resumed at 02:41 after track inspection. Expect 20–30 min delays through Fukushima.', cites: ['O3'], reacts: { heart: 220 }, replies: 4 },
|
||||
{ id: 'qk3', kind: 'witness', by: 'aiko', ago: 300, text: 'Long rolling shake, maybe 40 seconds. Shelves came down in the store and everyone came out onto the street. Nobody hurt here.', place: { lat: 38.262, lon: 140.871, label: 'Aoba-ku' }, att: { paint: 'crossing', o: { seed: 33, cam: 'PHONE', rec: false }, tier: 'sealed', distKm: 162 }, reacts: { heart: 410, eye: 700 }, replies: 22 },
|
||||
{ id: 'qk4', kind: 'correction', by: 'geo', ago: 280, text: 'The depth is 32 km in the revised solution, not the 10 km in early reposts. That matters for how far the shaking reached.', cites: ['O1'], reacts: { heart: 160 }, replies: 7 },
|
||||
{ id: 'qk5', kind: 'witness', by: 'repost', ago: 310, text: 'TSUNAMI 3 METERS hitting the coast right now!!!', reacts: { alert: 1900 }, replies: 140,
|
||||
note: { text: 'Tide gauges along the coast recorded at most 20 cm [O2]. No tsunami warning was issued. The video attached to the original claim is from 2011.', helpfulA: 0.88, helpfulB: 0.79, ratersA: 302, ratersB: 241, cites: ['O2'] } },
|
||||
{ id: 'qk6', kind: 'prediction', by: 'geo', ago: 200, text: 'Chance of an M5+ aftershock in the next 72 h is about 1 in 4 for a sequence like this. Not a reason to panic, a reason to secure shelves.', cites: ['O1'], reacts: { heart: 98 }, replies: 11, resolves: 'in 72 h' },
|
||||
],
|
||||
synth: {
|
||||
known: [['An M 6.4 thrust event at 32 km depth off the Honshu coast.', ['O1', 'P4']], ['Tide gauges recorded at most 20 cm; no tsunami.', ['O2', 'N1']], ['Northbound trains resumed at 02:41 with delays.', ['P2', 'O3']]],
|
||||
disputed: [['Early reposts claimed a 3 m tsunami — rated false by raters across perspectives.', ['P5', 'N1']]],
|
||||
unknown: [['Damage inland of Fukushima — the only sealed capture so far is from Sendai.', ['P3']], ['Whether a larger aftershock follows (≈ 1 in 4 within 72 h).', ['P6']]],
|
||||
},
|
||||
},
|
||||
squawk: {
|
||||
watching: 2410, rate: 3,
|
||||
src: { O1: 'ADS-B · 1 Hz', O2: 'Diversion airport board · 2 min' },
|
||||
posts: [
|
||||
{ id: 'sq1', kind: 'witness', by: 'seat', ago: 7, text: 'On board. Masks dropped about 20 minutes ago. Crew are calm, we\'re descending and they said we\'re landing soon. Everyone is OK.', att: { paint: 'cabin', o: { seed: 81 }, tier: 'sealed', distKm: 0 }, reacts: { heart: 1820, eye: 2100 }, replies: 64 },
|
||||
{ id: 'sq2', kind: 'context', by: 'tomas', ago: 12, text: 'Squawk 7700, a turn toward the nearest long runway and a steady ~2,400 ft/min descent to FL100 is what a pressurisation problem looks like. It is a well-practised procedure.', cites: ['O1'], reacts: { heart: 960 }, replies: 21 },
|
||||
{ id: 'sq3', kind: 'question', by: 'sky', ago: 5, text: 'Does anyone know which runway they\'ll use? Wind there is strong from the east.', reacts: { eye: 44 }, replies: 1 },
|
||||
{ id: 'sq4', kind: 'prediction', by: 'tomas', ago: 9, text: 'They\'ll be on the ground in about 34 minutes.', cites: ['O1', 'O2'], reacts: { heart: 120 }, replies: 2, resolves: 'at landing' },
|
||||
],
|
||||
synth: {
|
||||
known: [['The aircraft squawked 7700, turned toward the diversion airport and is descending.', ['O1']], ['A passenger on board reports masks deployed and the crew calm.', ['P1']]],
|
||||
disputed: [],
|
||||
unknown: [['The cause — the descent profile fits a pressurisation problem, unconfirmed.', ['P2']], ['Landing runway and time (est. ~34 min).', ['P3', 'P4']]],
|
||||
},
|
||||
},
|
||||
fires: {
|
||||
watching: 1460, rate: 2,
|
||||
src: { O1: 'VIIRS 375 m hotspots · 38 min', O2: 'Wind model · 1 h' },
|
||||
posts: [
|
||||
{ id: 'fi1', kind: 'witness', by: 'ines', ago: 26, text: 'The ridge above the N2 is burning along about two kilometres. Ash is falling on the road. Traffic is still moving south.', place: { lat: 40.13, lon: -8.05, label: 'N2 at the ridge' }, att: { paint: 'firewit', o: { seed: 71 }, tier: 'sealed', distKm: 1.8 }, reacts: { heart: 260, eye: 900 }, replies: 18 },
|
||||
{ id: 'fi2', kind: 'context', by: 'bomb', ago: 20, text: 'Front is moving west at about 1.2 km/h. The N2 will close between the two junctions within the hour. Please don\'t stop on the road to film.', cites: ['O1', 'O2'], reacts: { heart: 700 }, replies: 12 },
|
||||
{ id: 'fi3', kind: 'question', by: 'ines', ago: 8, text: 'Is the IC8 open toward Coimbra?', reacts: { eye: 60 }, replies: 0 },
|
||||
],
|
||||
synth: { known: [['A fire front about 2 km long is burning above the N2, moving west ~1.2 km/h.', ['P1', 'P2', 'O1']]], disputed: [], unknown: [['Whether the IC8 toward Coimbra is open.', ['P3']]] },
|
||||
},
|
||||
};
|
||||
// light rooms for the rest: a context post from the brief + an open question
|
||||
function liteRoom(s) {
|
||||
const by = ['kora', 'sky', 'geo', 'ais', 'hydro'][s.id.length % 5];
|
||||
return { watching: Math.round((s.watchers || 2000) * 0.06) + 40, rate: 1, src: Object.fromEntries((s.brief?.sources || []).slice(0, 3).map(([n, a], i) => ['O' + (i + 1), `${n} · ${a}`])),
|
||||
posts: [{ id: s.id + '-1', kind: 'context', by, ago: 12, text: s.brief?.lede || s.title, cites: ['O1'], reacts: { heart: 40, eye: 120 }, replies: 3 }, { id: s.id + '-2', kind: 'question', by: 'kora', ago: 5, text: 'Anyone close enough to send a sealed capture?', reacts: { eye: 20 }, replies: 0 }],
|
||||
synth: { known: [[s.brief?.lede?.split('. ')[0] + '.', ['O1', 'P1']]], disputed: [], unknown: [['No witness captures yet.', ['P2']]] } };
|
||||
}
|
||||
|
||||
/* ---------- derived: badges, evidence score, ordering ---------- */
|
||||
RM.badges = function (room, p) {
|
||||
const s = GE.SIT.byId[room.sit]; const who = P0[p.by] || {}; const out = [];
|
||||
const sealed = p.att && /^sealed/.test(p.att.tier);
|
||||
if (sealed && p.att.distKm != null && p.att.distKm <= 5) out.push(['there', `Was there · ${p.att.distKm < 1 ? Math.round(p.att.distKm * 1000) + ' m' : p.att.distKm.toFixed(1) + ' km'}`]);
|
||||
if (sealed) out.push(['sealed', p.att.tier === 'sealed-live' ? 'Sealed live' : 'Sealed']);
|
||||
if (who.home && s && kmBetween(who.home[0], who.home[1], p.place?.lat ?? s.lat, p.place?.lon ?? s.lon) < 120) out.push(['local', 'Local']);
|
||||
if (who.domain) out.push(['domain', who.domain]);
|
||||
if (p.att && p.att.tier === 'unverified') out.push(['unv', 'Unverified upload']);
|
||||
if (p.by === 'you' && GE.ME && s && kmBetween(GE.ME.lat, GE.ME.lon, s.lat, s.lon) < 120 && !out.some((b) => b[0] === 'local')) out.push(['local', 'Local']);
|
||||
return out;
|
||||
};
|
||||
RM.score = function (room, p) { let sc = 0; for (const [k] of RM.badges(room, p)) sc += { there: 4, sealed: 3, domain: 2, local: 1, unv: -2 }[k] || 0; if (p.att?.tier === 'eye') sc += 1; if (p.note) sc -= 3; return sc; };
|
||||
RM.ordered = function (room, mode = 'evidence') {
|
||||
const ps = room.posts.slice();
|
||||
const openQ = (p) => p.kind === 'question' && !p.answered;
|
||||
if (mode === 'latest') return ps.sort((a, b) => b.at - a.at);
|
||||
if (mode === 'questions') return ps.filter((p) => p.kind === 'question').sort((a, b) => (openQ(b) - openQ(a)) || b.at - a.at);
|
||||
return ps.sort((a, b) => (openQ(b) - openQ(a)) || RM.score(room, b) - RM.score(room, a) || b.at - a.at);
|
||||
};
|
||||
RM.pid = (room, p) => 'P' + (room.posts.indexOf(p) + 1);
|
||||
RM.byPid = (room, pid) => room.posts[+pid.slice(1) - 1];
|
||||
|
||||
/* ---------- ledger per room (real SHA-256 chain) ---------- */
|
||||
RM.append = async function (room, type, actor, payload) {
|
||||
const L = room.ledger; const prev = L.length ? L[L.length - 1].hash : '0'.repeat(64);
|
||||
const e = { i: L.length, ts: Date.now(), type, actor, payload }; e.digest = await sha256(canon(payload)); e.prev = prev; e.hash = await sha256(canon({ i: e.i, ts: e.ts, type: e.type, actor: e.actor, digest: e.digest, prev })); L.push(e); return e;
|
||||
};
|
||||
|
||||
/* ---------- build ---------- */
|
||||
RM.get = function (sitId) {
|
||||
if (RM.byId[sitId]) return RM.byId[sitId];
|
||||
const s = GE.SIT.byId[sitId]; if (!s) return null;
|
||||
const R0 = ROOMS[sitId] || liteRoom(s);
|
||||
const room = { sit: sitId, ...R0, ledger: [], posts: R0.posts.map((p) => ({ ...p, at: NOW - (p.ago || 1) * MIN_ })) };
|
||||
for (const p of room.posts) if (p.att && p.att.paint === 'seawall' && p.live) p.att.o.cam = '@' + P0[p.by].h;
|
||||
RM.byId[sitId] = room;
|
||||
(async () => { for (const p of room.posts) await RM.append(room, 'post.' + p.kind, P0[p.by]?.h || p.by, { id: p.id, text: p.text, att: p.att ? { tier: p.att.tier } : null, place: p.place || null }); })();
|
||||
return room;
|
||||
};
|
||||
RM.count = (sitId) => { const r = ROOMS[sitId]; return r ? r.posts.length : 2; };
|
||||
RM.watching = (sitId) => (RM.byId[sitId]?.watching) ?? ROOMS[sitId]?.watching ?? Math.round((GE.SIT.byId[sitId]?.watchers || 2000) * 0.06) + 40;
|
||||
RM.sealedCount = (sitId) => { const r = RM.byId[sitId] || ROOMS[sitId]; return r ? r.posts.filter((p) => p.att && /^sealed/.test(p.att.tier)).length : 0; };
|
||||
RM.topClaim = function (sitId) {
|
||||
const r = ROOMS[sitId]; if (!r) return null;
|
||||
const open = r.posts.find((p) => p.kind === 'question' && !p.answered);
|
||||
const k = r.synth.known[0], d = r.synth.disputed[0];
|
||||
return { q: open?.text, known: k?.[0], disputed: d?.[0], sealed: r.posts.filter((p) => p.att && /^sealed/.test(p.att.tier)).length };
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/* Feed model — mixed archetypes across the four altitude bands, ordered for diversity.
|
||||
Bands answer "where is the sensor": Orbit, Sky, Ground, and Inside (a person). */
|
||||
const FD = (GE.feed = {});
|
||||
GE.BANDS = {
|
||||
orbit: { name: 'Orbit', c: '#B79CFF', alt: '400–36,000 km', sensor: 'satellites · ISS · launches' },
|
||||
sky: { name: 'Sky', c: '#8CD4FF', alt: '0–15 km', sensor: 'aircraft · radar · lightning' },
|
||||
ground: { name: 'Ground', c: '#FFA53D', alt: '0 m', sensor: 'cameras · seismometers · crowds' },
|
||||
inside: { name: 'Inside', c: '#F2CF7A', alt: 'you', sensor: 'witnesses · broadcasts · rooms' },
|
||||
};
|
||||
GE.ARCH = { sensor: 'Sensor', eye: 'Eye', track: 'Track', number: 'Number', witness: 'Witness', room: 'Room' };
|
||||
const MIN3 = 60000;
|
||||
FD.fmtDist = (km) => (km < 1 ? Math.round(km * 1000) + ' m' : km < 100 ? km.toFixed(km < 10 ? 1 : 0) + ' km' : fmt.n(Math.round(km)) + ' km');
|
||||
FD.distFromMe = (lat, lon) => geo.dist(GE.ME.lat, GE.ME.lon, lat, lon) * EARTH_KM;
|
||||
const llz = (lat, lon) => `${Math.abs(lat).toFixed(2)}${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(2)}${lon >= 0 ? 'E' : 'W'}`;
|
||||
const hmsZ = () => { const d = new Date(); return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')}Z`; };
|
||||
FD.hmsZ = hmsZ; FD.llz = llz;
|
||||
|
||||
/* the diverting flight, computed from the synthetic traffic so card, room and globe agree */
|
||||
FD.squawkTrack = function () {
|
||||
if (FD._sq) return FD._sq;
|
||||
const f = GE.FLIGHTS[GE.world.squawks[0]]; const pts = [];
|
||||
for (let k = -90; k <= 0; k += 3) { const p = GE.flightAt(f, GE.world.t + k * 60); if (p) pts.push([p.lat, p.lon]); }
|
||||
let ok = pts.length > 12;
|
||||
if (!ok) { pts.length = 0; for (let k = 0; k <= 30; k++) pts.push(k < 18 ? [55 + k * 0.12, -10 - k * 0.9] : [57.1 + (k - 18) * 0.25, -26.2 - (k - 18) * 0.35]); }
|
||||
const cur = pts[pts.length - 1]; let div = null, bd = 1e9;
|
||||
for (const a of GE.AP) { if (a.w < 8) continue; const d = geo.dist(cur[0], cur[1], a.lat, a.lon); if (d < bd && d > 0.02) { bd = d; div = a; } }
|
||||
if (!ok) div = GE.apByIata.RKV || div;
|
||||
// bend the last third toward the diversion field
|
||||
const k0 = Math.floor(pts.length * 0.62); const bend = pts.slice(0, k0 + 1); const a = pts[k0];
|
||||
for (let i = 1; i <= pts.length - 1 - k0; i++) { const t = (i / (pts.length - 1 - k0)) * 0.45; bend.push([lerp(a[0], div.lat, t), lerp(a[1], div.lon, t)]); }
|
||||
const alt = [37000, 37000, 36000, 31000, 27000, 24000];
|
||||
FD._sq = { pts: bend, sqAt: k0, div: { lat: div.lat, lon: div.lon, iata: div.iata, city: div.city }, alt, cur: bend[bend.length - 1] };
|
||||
// put the same track on the globe: white before the declaration, red after, the aircraft at its head
|
||||
const W = GE.world; const C2 = FD._sq.cur; const alt3 = (i) => 10.5 - Math.max(0, i - k0) * 0.25;
|
||||
W.extraLines.push({ pts: bend.slice(0, k0 + 1).map(([la, lo], i) => geo.vec(la, lo, alt3(i))), col: [0.85, 0.9, 1, 0.55] }, { pts: bend.slice(k0).map(([la, lo], i) => geo.vec(la, lo, alt3(i + k0))), col: [1, 0.25, 0.2, 0.95] }, { pts: [geo.vec(C2[0], C2[1], 7), geo.vec(div.lat, div.lon, 0)], col: [1, 0.45, 0.4, 0.5], dashed: 1 });
|
||||
(W.extraSprites = W.extraSprites || []).push({ v: geo.vec(C2[0], C2[1], 7), col: [1, 0.23, 0.19, 1], size: 30 });
|
||||
const s = GE.SIT.byId.squawk; if (s) { s.lat = C2[0]; s.lon = C2[1]; s.camera = { lat: C2[0] - 4, lon: C2[1] - 3, range: 0.14, heading: 0.35, tilt: 0.85 }; }
|
||||
return FD._sq;
|
||||
};
|
||||
|
||||
/* ---------- items ---------- */
|
||||
FD.items = function () {
|
||||
if (FD._items) return FD._items;
|
||||
const C = GE.CYCLONE.at(0); const sq = FD.squawkTrack(); const iss = GE.SIT.issPass(Date.now());
|
||||
const it = [
|
||||
{ id: 'basco', arch: 'witness', band: 'inside', sit: 'cyclone', post: 'cy1', wide: true, prio: 100, live: true, by: 'nadia', paint: 'seawall', po: { palm: true, handheld: true, noOSD: true, seed: 52 }, aspect: 0.62,
|
||||
title: 'Waves over the Basco seawall — live from the port', place: 'Basco, Batanes', lat: 20.4485, lon: 121.9705, ago: 14, cam: { lat: 18.6, lon: 123.4, range: 0.18, heading: -0.55, tilt: 0.62 },
|
||||
tele: () => `WITNESS · SEALED LIVE · ±4 M · ${fmt.n(Math.round(RM.kmBetween(20.4485, 121.9705, C.lat, C.lon)))} KM FROM EYE` },
|
||||
{ id: 'ir', arch: 'sensor', band: 'orbit', sit: 'cyclone', prio: 95, paint: 'ir', aspect: 1.25, title: 'Typhoon Halcyon: −83 °C cloud tops and lightning in the eyewall', place: 'Philippine Sea', lat: C.lat, lon: C.lon, ago: 2,
|
||||
tele: () => `ORBIT · GEO IR · 35,786 KM · ${llz(C.lat, C.lon)}` },
|
||||
{ id: 'cabin', arch: 'witness', band: 'inside', sit: 'squawk', post: 'sq1', prio: 92, by: 'seat', paint: 'cabin', aspect: 1.3, title: '“Masks dropped. Crew calm. We’re descending.” — on board the 7700', place: 'Over the North Atlantic', lat: sq.cur[0], lon: sq.cur[1], ago: 7,
|
||||
tele: () => 'WITNESS · SEALED · ON BOARD · FL240' },
|
||||
{ id: 'harbour', arch: 'eye', band: 'ground', sit: 'cyclone', post: 'cy5', prio: 90, live: true, paint: 'harbour', po: { seed: 8, cam: 'HBR-07', place: 'KAOHSIUNG · PIER 70' }, aspect: 1.0, title: 'Kaohsiung harbour cam: ships leave ahead of Halcyon', place: 'Kaohsiung, Taiwan', lat: 22.61, lon: 120.27, ago: 0, cam: { lat: 21.2, lon: 121.6, range: 0.2, heading: -0.7, tilt: 0.6 },
|
||||
tele: () => `GROUND · CAM HBR-07 · ${llz(22.61, 120.27)} · ${hmsZ()}` },
|
||||
{ id: 'squawk', arch: 'track', band: 'sky', sit: 'squawk', prio: 89, live: true, paint: 'track', po: { track: sq }, aspect: 1.15, title: `Squawk 7700 — diverting to ${sq.div.iata} ${sq.div.city ? '(' + sq.div.city + ')' : ''}`, place: 'Over the North Atlantic', lat: sq.cur[0], lon: sq.cur[1], ago: 3,
|
||||
readout: 'SQ 7700 · FL240 ↓', tele: () => 'SKY · ADS-B · 2,400 FPM ↓ · 412 KT', cta: ['airplane', 'Follow the track'] },
|
||||
{ id: 'room-cy', arch: 'room', band: 'inside', sit: 'cyclone', prio: 88, wide: true, title: 'Typhoon Halcyon', place: 'Batanes · Babuyan · Luzon Strait', lat: 20.45, lon: 121.97, ago: 1, ev: [['seawall', { palm: true, noOSD: true, seed: 52 }], ['harbour', { seed: 8, cam: 'HBR-07' }], ['blackout', { seed: 91 }], ['ir', {}]] },
|
||||
{ id: 'radar', arch: 'sensor', band: 'sky', sit: 'dfw', prio: 86, paint: 'radar', aspect: 1.2, title: 'Bowing squall line crossing Dallas–Fort Worth — tornado warning', place: 'Dallas–Fort Worth', lat: 32.82, lon: -97.0, ago: 2,
|
||||
tele: () => 'SKY · RADAR 0.5° · 62 DBZ · 72 KM/H E' },
|
||||
{ id: 'crossing', arch: 'eye', band: 'ground', sit: 'shibuya', prio: 84, live: true, paint: 'crossing', po: { seed: 31, cam: 'CAM 0412', place: 'SHIBUYA SCRAMBLE' }, aspect: 1.25, title: 'Shibuya crossing in the rain: ~2,400 people every cycle', place: 'Shibuya, Tokyo', lat: 35.6595, lon: 139.7005, ago: 0,
|
||||
tele: () => `GROUND · CAM 0412 · ${llz(35.6595, 139.7005)} · ${hmsZ()}` },
|
||||
{ id: 'shake', arch: 'sensor', band: 'ground', sit: 'quake', prio: 83, paint: 'shake', aspect: 1.15, title: 'M 6.4 off Honshu: strongest shaking on the Fukushima coast', place: 'Off eastern Honshu', lat: GE.BIGQ.lat, lon: GE.BIGQ.lon, ago: 324,
|
||||
tele: () => 'GROUND · 46 STATIONS · MMI VI · 32 KM DEEP' },
|
||||
{ id: 'firesat', arch: 'sensor', band: 'orbit', sit: 'fires', prio: 80, paint: 'firesat', aspect: 1.1, title: 'Central Portugal: a 60 km smoke plume drifts toward the coast', place: 'Pampilhosa da Serra', lat: 40.05, lon: -7.95, ago: 38,
|
||||
tele: () => 'ORBIT · 375 M · FRP 840 MW · WIND ESE 31' },
|
||||
{ id: 'isscam', arch: 'eye', band: 'orbit', sit: 'iss', prio: 79, live: true, paint: 'isscam', aspect: 1.3, title: 'From the station’s external camera: the Nile at night', place: '418 km over Egypt', lat: 27.1, lon: 31.2, ago: 0,
|
||||
tele: () => 'ORBIT · ISS · 418 KM · 7.66 KM/S · 27.1N 31.2E' },
|
||||
{ id: 'firewit', arch: 'witness', band: 'inside', sit: 'fires', post: 'fi1', prio: 78, by: 'ines', paint: 'firewit', aspect: 1.25, title: 'The ridge above the N2 is burning — 1.8 km from the front', place: 'N2, central Portugal', lat: 40.13, lon: -8.05, ago: 26,
|
||||
tele: () => 'WITNESS · SEALED · ±6 M · 1.8 KM FROM FRONT' },
|
||||
{ id: 'lightning', arch: 'number', band: 'sky', sit: 'gulf', prio: 76, paint: 'strikes', aspect: 1.05, title: 'Lightning over the Gulf of Mexico', place: 'Gulf of Mexico', lat: 26.5, lon: -89, ago: 1,
|
||||
big: '4,812', unit: 'strikes', label: 'in the last hour over the Gulf', spark: [1200, 1500, 1900, 2600, 3100, 3800, 4300, 4812], tele: () => 'SKY · LIGHTNING NETWORK · 5 CELLS' },
|
||||
{ id: 'stadium', arch: 'sensor', band: 'ground', sit: 'derby', prio: 75, live: true, paint: 'stadium', aspect: 1.1, title: 'Derby egress: 4.6 people/m² building at Gate C', place: 'Avellaneda, Buenos Aires', lat: -34.6705, lon: -58.3685, ago: 1,
|
||||
tele: () => 'GROUND · CROWD SENSING · GATE C · 4.6 P/M²' },
|
||||
{ id: 'aurora', arch: 'witness', band: 'inside', sit: 'aurora', prio: 74, by: 'ola', paint: 'aurora', aspect: 1.2, title: 'Corona overhead in Tromsø, sealed 18 minutes ago', place: 'Tromsø, Norway', lat: 69.65, lon: 18.96, ago: 18,
|
||||
tele: () => 'WITNESS · SEALED · ±5 M · KP 6' },
|
||||
{ id: 'skychart', arch: 'track', band: 'orbit', sit: 'iss', prio: 72, paint: 'skychart', po: { at: iss.start, max: Math.round(iss.max) }, aspect: 1.0, title: `The ISS crosses your sky at ${fmt.hm(iss.maxAt)} UTC`, place: `Over ${GE.ME.name}`, lat: GE.ME.lat, lon: GE.ME.lon, ago: 0,
|
||||
readout: `MAX ${Math.round(iss.max)}° · ${Math.round((iss.end - iss.start) / 60000)} MIN`, tele: () => 'ORBIT · ISS PASS · TLE · COMPUTED', cta: ['bell', 'Remind me'] },
|
||||
{ id: 'plume', arch: 'track', band: 'orbit', sit: 'launch', prio: 71, paint: 'plume', aspect: 1.3, title: 'Twilight launch: the plume was seen along 600 km of coast', place: 'Cape Canaveral', lat: 28.5618, lon: -80.5772, ago: 34,
|
||||
readout: () => `T+${fmt.dur((Date.now() - GE.LAUNCH_T0) / 1000)}`, tele: () => 'ORBIT · TRACKING CAM · 2ND STAGE 212 KM' },
|
||||
{ id: 'room-qk', arch: 'room', band: 'inside', sit: 'quake', prio: 70, title: 'M 6.4 off Honshu', place: 'Fukushima · Miyagi · Ibaraki', lat: GE.BIGQ.lat, lon: GE.BIGQ.lon, ago: 3, ev: [['shake', {}], ['crossing', { seed: 33, cam: 'PHONE', rec: false }]] },
|
||||
{ id: 'gnss', arch: 'number', band: 'sky', sit: 'gnss', prio: 68, paint: 'gnss', aspect: 1.05, title: 'GPS interference over the Baltic', place: 'Baltic Sea', lat: 56.8, lon: 21.5, ago: 10,
|
||||
big: '340', unit: 'aircraft', label: 'lost GPS integrity in the last hour', spark: [120, 160, 150, 210, 260, 240, 300, 340], tele: () => 'SKY · ADS-B NIC/NACp · 10 MIN' },
|
||||
{ id: 'heli', arch: 'track', band: 'sky', sit: 'novosel', prio: 66, live: true, paint: 'heli', po: { laps: 14 }, aspect: 1.1, title: 'HAWK 21 is flying its 14th circuit at Fort Novosel', place: 'Cairns Army Airfield, Alabama', lat: 31.276, lon: -85.713, ago: 0, ride: true,
|
||||
readout: 'CIRCUIT 14 · 500 FT', tele: () => 'SKY · ADS-B/MLAT · 72 KT · 500 FT AGL', cta: ['helicopter', 'Ride along'] },
|
||||
{ id: 'koshi', arch: 'room', band: 'inside', sit: 'koshi', recon: 'koshi', prio: 64, wide: true, paint: 'globe', aspect: 0.56, title: 'Bhote Koshi outburst flood — 4D reconstruction', place: 'Rasuwa, Nepal · 26 Aug 2026', lat: 28.215, lon: 85.36, ago: 120 },
|
||||
{ id: 'sar', arch: 'sensor', band: 'orbit', sit: 'suez', prio: 62, paint: 'sar', aspect: 1.15, title: 'Radar satellite pass: 48 ships waiting off Suez', place: 'Gulf of Suez', lat: 29.86, lon: 32.55, ago: 47,
|
||||
tele: () => 'ORBIT · SAR C-BAND · 10 M · DESC PASS' },
|
||||
{ id: 'volcano', arch: 'eye', band: 'ground', sit: 'sakurajima', prio: 60, live: true, paint: 'volcano', po: { cam: 'VOLCANO CAM 2', place: 'KAGOSHIMA BAY' }, aspect: 1.05, title: 'Sakurajima: ash column to 3.2 km, drifting away from the city', place: 'Kagoshima, Japan', lat: 31.58, lon: 130.66, ago: 2,
|
||||
tele: () => `GROUND · VOLCANO CAM 2 · ${llz(31.58, 130.66)}` },
|
||||
{ id: 'blackout', arch: 'witness', band: 'inside', sit: 'cyclone', post: 'cy6', prio: 58, by: 'rafa', paint: 'blackout', aspect: 1.15, title: 'Aparri is dark — the hospital is running on generators', place: 'Aparri, Cagayan', lat: 18.357, lon: 121.637, ago: 22,
|
||||
tele: () => 'WITNESS · SEALED · ±8 M · LOCAL' },
|
||||
];
|
||||
for (const x of it) { x.at = NOW - x.ago * MIN3; x.dist = FD.distFromMe(x.lat, x.lon); }
|
||||
return (FD._items = it);
|
||||
};
|
||||
FD.user = []; // your own posts land here (newest first)
|
||||
|
||||
/* diversity-aware ordering: never two of the same archetype or band side by side when avoidable */
|
||||
FD.order = function (items) {
|
||||
const rest = items.slice().sort((a, b) => b.prio - a.prio); const out = [];
|
||||
while (rest.length) {
|
||||
const prev = out[out.length - 1], prev2 = out[out.length - 2];
|
||||
const fits = (x, strict) => !prev || ((x.arch !== prev.arch || !strict) && x.band !== prev.band && (!strict || !prev2 || x.arch !== prev2.arch));
|
||||
let i = rest.findIndex((x) => fits(x, true)); if (i < 0) i = rest.findIndex((x) => fits(x, false)); if (i < 0) i = 0;
|
||||
out.push(rest.splice(i, 1)[0]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
FD.list = function (band = 'all') {
|
||||
const all = [...FD.user, ...FD.items()];
|
||||
const f = band === 'all' ? all : band === 'near' ? all.filter((x) => x.dist < 2500) : all.filter((x) => x.band === band);
|
||||
const user = f.filter((x) => x.mine); const ordered = FD.order(f.filter((x) => !x.mine));
|
||||
return [...user, ...ordered];
|
||||
};
|
||||
FD.counts = function () { const c = { all: 0, orbit: 0, sky: 0, ground: 0, inside: 0 }; for (const x of [...FD.user, ...FD.items()]) { c.all++; c[x.band]++; } return c; };
|
||||
@@ -67,11 +67,17 @@ APP.updateChrome = function () {
|
||||
|
||||
/* ---------- accessory ---------- */
|
||||
APP.renderAccessory = function () {
|
||||
const s = GE.SIT.byId[APP.following]; if (!s) return;
|
||||
const html = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(GE.CYCLONE.name)}</div><div class="sub num">${esc(s.accessory ? s.accessory() : '')}</div></span><svg class="spk" width="54" height="22" viewBox="0 0 54 22">${sparkPath(GE.CYCLONE.track.slice(0, 7).map((x) => x[3]), 54, 22, '#FF6A3D')}</svg><span class="ab"><svg class="ic sm"><use href="#i-chevu"/></svg></span>`;
|
||||
$('#acc').innerHTML = html;
|
||||
$('#accMini').innerHTML = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(GE.CYCLONE.name)}</div><div class="sub num">${esc(s.accessory ? s.accessory() : '')}</div></span>`;
|
||||
// the tab-bar accessory follows a person, not a number: the live witness you follow (or you, when live)
|
||||
const me = GE.capture?.live; let ttl, sub, open;
|
||||
if (me && me.on) { ttl = `You’re live · ${fmt.dur((Date.now() - me.t0) / 1000).slice(3)}`; sub = `Sealed live · ${me.segs.length} segments · ${fmt.n(me.viewers)} watching`; open = () => APP.setTab('capture'); }
|
||||
else { const r = GE.rooms.byId.cyclone; const n = r ? r.watching : 3204; ttl = '@nadia_basco · Basco seawall'; sub = `Sealed live · Typhoon Halcyon room · ${fmt.n(n)} watching`; open = () => { const it = GE.feed.items().find((x) => x.id === 'basco'); GE.now._item = it; GE.sheet.open('cyclone', { post: 'cy1' }); }; }
|
||||
APP._accOpen = open;
|
||||
const html = `<span class="accdot"><span class="dot pulse"></span></span><span class="txt"><div class="ttl">${esc(ttl)}</div><div class="sub num">${esc(sub)}</div></span><canvas class="accv" width="120" height="68"></canvas><span class="ab"><svg class="ic sm"><use href="#i-chevu"/></svg></span>`;
|
||||
const a = $('#acc'); if (a._k !== ttl + sub) { a.innerHTML = html; a._k = ttl + sub; }
|
||||
const m = $('#accMini'); if (m._k !== ttl + sub) { m.innerHTML = `<span class="dot pulse"></span><span class="txt"><div class="ttl">${esc(ttl)}</div><div class="sub num">${esc(sub)}</div></span>`; m._k = ttl + sub; }
|
||||
};
|
||||
APP.accFrame = function () { const cv = $('#acc .accv'); if (!cv || $('#accHost').classList.contains('hidden')) return; const me = GE.capture?.live; if (me && me.on) GE.paint.draw('seawall', cv, { skyline: true, noOSD: true, seed: 53, t: performance.now() / 1000 }); else GE.paint.draw('seawall', cv, { palm: true, noOSD: true, seed: 52, t: performance.now() / 1000 }); };
|
||||
setInterval(() => { try { APP.accFrame(); } catch {} }, 200);
|
||||
function sparkPath(data, w, h, col, sw = 1.6) {
|
||||
const mn = Math.min(...data), mx = Math.max(...data), n = data.length;
|
||||
const pts = data.map((v, i) => [(i / (n - 1)) * (w - 4) + 2, h - 3 - ((v - mn) / (mx - mn || 1)) * (h - 6)]);
|
||||
@@ -92,6 +98,8 @@ APP.updateLabels = function (view) {
|
||||
const short = s.short || s.title.split(/ [—–-] /)[0].slice(0, 28);
|
||||
want.push({ k: 's:' + s.id, lat: s.lat, lon: s.lon, alt: 0.2, pr: s.watchers, html: `<i style="--c:${GE.FAM[s.fam].color}"></i>${esc(short)}`, w: short.length * 6.6 + 32, cls: 'glass', onclick: () => GE.sheet.open(s.id, { fromGlobe: true }) });
|
||||
}
|
||||
const showEyes = (APP.tab === 'globe' || APP.isWide()) && !APP.intro && APP.mode !== 'ride' && APP.mode !== 'workspace';
|
||||
if (showEyes) for (const e of GE.EYES || []) { if (view.range > 0.9 && e.kind !== 'you') continue; const t = e.label + (e.live ? ' · LIVE' : ''); want.push({ k: 'e:' + e.id + (e.live ? 'L' : ''), lat: e.lat, lon: e.lon, alt: 0.05, pr: e.kind === 'you' ? 2e9 : 6e4, html: `<i class="ei k-${e.kind}"><svg class="ic xs"><use href="#i-${e.kind === 'cctv' ? 'video' : 'eye'}"/></svg></i>${esc(t)}`, w: t.length * 6.4 + 40, cls: 'glass eyelbl k-' + e.kind, onclick: () => { if (e.id === 'you' && GE.live.on) { APP.setTab('capture'); return; } if (e.sit) { const it = GE.feed.items().find((x) => x.post && x.post === e.post); GE.now._item = it || null; GE.sheet.open(e.sit, { post: e.post }); } } }); }
|
||||
if (GE.world.sel && GE.world.sel.v && APP.mode !== 'ride') want.push({ k: 'sel', v: GE.world.sel.v, pr: 1e9, html: `<b>${esc(GE.world.sel.title)}</b>`, w: 90, cls: 'plain tag' });
|
||||
for (const [k, l] of GE.extraLabels || []) want.push({ k, pr: 1e8, w: 120, ...l });
|
||||
want.sort((a, b) => b.pr - a.pr);
|
||||
@@ -126,7 +134,7 @@ GE.extraLabels = new Map();
|
||||
/* ---------- wiring ---------- */
|
||||
APP.initShell = function () {
|
||||
$$('.tab').forEach((b) => b.addEventListener('click', () => APP.setTab(b.dataset.tab)));
|
||||
const openFollowed = () => GE.sheet.open(APP.following);
|
||||
const openFollowed = () => (APP._accOpen ? APP._accOpen() : GE.sheet.open(APP.following));
|
||||
$('#acc').addEventListener('click', openFollowed); $('#accMini').addEventListener('click', openFollowed);
|
||||
$('#btnSearch').addEventListener('click', () => GE.gui.openSearch());
|
||||
// minimize tab bar on scroll (feed-like views)
|
||||
|
||||
+117
-51
@@ -57,74 +57,140 @@ TH.fallback = function (s, canvas) {
|
||||
};
|
||||
|
||||
/* ---------- feed ---------- */
|
||||
const BAND_ORDER = ['all', 'orbit', 'sky', 'ground', 'inside'];
|
||||
NOWV.band = store.get('band', 'all');
|
||||
const famIcon = (f) => GE.FAM[f].icon;
|
||||
function cardHTML(s) {
|
||||
const c = GE.FAM[s.fam].color;
|
||||
const [m, ml] = s.metric();
|
||||
const badge = s.d4 ? `<span class="badge d4">4D</span><span class="badge replay">Thread</span>` : s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : `<span class="badge replay">${s.replayLabel ? 'Replay · ' + s.replayLabel.replace('Today ', '') : 'Replay'}</span>`;
|
||||
const right = s.thread ? `<span class="stack">${['tamang', 'hydro', 'kora', 'geo'].map((k) => `<span class="avatar sm" style="--h:${GE.PEOPLE[k].hue}">${GE.PEOPLE[k].h[0].toUpperCase()}</span>`).join('')}</span>` : `<span class="r">${ic('eye', 'xs')}<span class="num">${fmt.k(s.watchers)}</span></span>`;
|
||||
return `<div class="thumb" style="aspect-ratio:${1}/${s.aspect}"><canvas width="360" height="${Math.round(360 * s.aspect)}"></canvas>
|
||||
<div class="tl">${badge}</div><div class="tr" style="--c:${c}">${ic(famIcon(s.fam), 'sm')}</div>
|
||||
<div class="bl"><div class="metric num" data-m>${m}</div><div class="mlabel num" data-ml>${ml}</div></div></div>
|
||||
<div class="meta"><div class="ttl">${esc(s.title)}</div><div class="src"><span class="l"><span style="width:6px;height:6px;border-radius:50%;background:${c};flex:none"></span><span data-age>${esc(s.source)} · ${fmt.ago(Date.now() - s.updatedAt)}</span></span>${right}</div>
|
||||
${s.ride ? `<button class="cta" data-ride>${ic('helicopter', 'sm')}Jump into the chopper</button>` : ''}</div>`;
|
||||
const avatarHTML = (k, cls = 'sm') => { const p = GE.PEOPLE[k] || { h: k, hue: 205 }; return `<span class="avatar ${cls}" style="--h:${p.hue}">${esc((p.h || '?')[0].toUpperCase())}</span>`; };
|
||||
GE.avatarHTML = avatarHTML;
|
||||
const bandTag = (b) => `<span class="bt">${GE.BANDS[b].name.toUpperCase()}</span>`;
|
||||
function cvSize(x) { const W0 = x.wide ? 800 : 400; return [W0, Math.round(W0 * x.aspect)]; }
|
||||
function whereHTML(x) { const ago = x.live && x.ago < 1 ? 'LIVE NOW' : x.live ? `LIVE · ${fmt.ago(Date.now() - x.at).replace(' ago', '').toUpperCase()}` : fmt.ago(Date.now() - x.at).toUpperCase(); return `<div class="where"><div class="pl">${esc(x.place)}</div><div class="d mono">${x.dist < 60 ? 'NEAR YOU · ' : ''}${GE.feed.fmtDist(x.dist).toUpperCase()} AWAY · ${ago}</div></div>`; }
|
||||
function socHTML(x) {
|
||||
const r = GE.rooms; const n = r.watching(x.sit); const posts = r.count(x.sit); const sealed = r.sealedCount(x.sit);
|
||||
const ppl = (GE.rooms.byId[x.sit]?.posts || []).slice(0, 3).map((p) => p.by);
|
||||
const stack = (ppl.length ? ppl : ['kora', 'sky', 'geo']).map((k) => avatarHTML(k)).join('');
|
||||
return `<div class="soc">${x.wide ? `<span class="stack">${stack}</span>` : ''}<span>${ic('threads', 'xs')}<b class="num">${fmt.k(n)}</b> in room</span>${sealed ? `<span class="sl">${ic('seal', 'xs')}<b class="num">${sealed}</b> sealed</span>` : `<span><b class="num">${posts}</b> posts</span>`}</div>`;
|
||||
}
|
||||
function frameHTML(x, inner = '') {
|
||||
const [w, h] = cvSize(x);
|
||||
return `<div class="fr" style="aspect-ratio:${1}/${x.aspect}"><canvas width="${w}" height="${h}"></canvas><i class="bk a"></i><i class="bk b"></i><i class="bk c"></i><i class="bk d"></i>
|
||||
<div class="ftop">${bandTag(x.band)}<span class="at">${GE.ARCH[x.arch].toUpperCase()}</span>${x.live ? '<span class="lv"><i></i>LIVE</span>' : ''}${x.mine ? '<span class="mine">YOU</span>' : ''}</div>
|
||||
${inner}${x.tele ? `<div class="tele" data-tele>${esc(x.tele())}</div>` : ''}</div>`;
|
||||
}
|
||||
function cardHTML(x) {
|
||||
const B = GE.BANDS[x.band];
|
||||
if (x.arch === 'room' && !x.recon) return roomCardHTML(x);
|
||||
let inner = '';
|
||||
if (x.arch === 'number') inner = `<div class="numov"><div class="big num">${x.big}<small>${esc(x.unit)}</small></div><div class="nl">${esc(x.label)}</div><svg class="nsp" viewBox="0 0 160 34" preserveAspectRatio="none">${GE.sparkPath(x.spark, 160, 34, B.c, 2)}</svg></div>`;
|
||||
if (x.readout) inner += `<div class="readout num" data-ro>${esc(typeof x.readout === 'function' ? x.readout() : x.readout)}</div>`;
|
||||
if (x.arch === 'witness') inner += `<svg class="fseal"><use href="#i-seal"/></svg>`;
|
||||
if (x.recon) inner += `<div class="r4d"><span class="badge d4">4D</span><span class="badge">Reconstruction</span></div>`;
|
||||
const who = x.by ? (() => { const p = GE.PEOPLE[x.by]; const room = GE.rooms.get(x.sit); const post = room?.posts.find((q) => q.id === x.post); const bd = post ? GE.rooms.badges(room, post).slice(0, 2) : []; return `<div class="who">${avatarHTML(x.by)}<b>@${esc(p.h)}</b>${bd.map(([k, l]) => `<span class="bdg ${k}">${esc(l)}</span>`).join('')}</div>`; })() : '';
|
||||
const cta = x.cta ? `<button class="cta" data-cta>${ic(x.cta[0], 'sm')}${esc(x.cta[1])}</button>` : '';
|
||||
const recon = x.recon ? `<div class="soc"><span><b class="num">23</b> observations</span><span><b class="num">9</b> hypotheses</span><span class="sl">${ic('link', 'xs')}ledger verified</span></div>` : socHTML(x);
|
||||
return `${frameHTML(x, inner)}<div class="fm">${who}<div class="ttl">${esc(x.title)}</div>${whereHTML(x)}${recon}${cta}</div>`;
|
||||
}
|
||||
function roomCardHTML(x) {
|
||||
const room = GE.rooms.get(x.sit); const syn = room.synth; const open = room.posts.find((p) => p.kind === 'question' && !p.answered);
|
||||
const ppl = [...new Set(room.posts.map((p) => p.by))].slice(0, 5);
|
||||
const line = (cls, lab, t, c) => t ? `<div class="cl ${cls}"><b>${lab}</b><span>${esc(t)}</span>${c ? `<span class="cites">${c.map((z) => `<i>${z}</i>`).join('')}</span>` : ''}</div>` : '';
|
||||
const ev = (x.ev || []).map((e, i) => `<canvas class="evc" data-ev="${i}" width="200" height="150"></canvas>`).join('');
|
||||
const sealed = room.posts.filter((p) => p.att && /^sealed/.test(p.att.tier)).length; const notes = room.posts.filter((p) => p.note).length;
|
||||
return `<div class="rhead"><span class="rtag">${ic('threads', 'xs')}ROOM</span>${bandTag('inside')}<span class="lvw"><span class="dot pulse"></span><b class="num" data-watch>${fmt.n(room.watching)}</b> watching</span><span class="stack">${ppl.map((k) => avatarHTML(k)).join('')}</span></div>
|
||||
<h3>${esc(x.title)}</h3>${whereHTML(x)}
|
||||
<div class="claims">${line('known', 'KNOWN', syn.known[0]?.[0], syn.known[0]?.[1])}${line('disputed', 'DISPUTED', syn.disputed[0]?.[0], syn.disputed[0]?.[1])}${open ? line('open', 'ASKED', '“' + open.text + '”', null) : line('unknown', 'UNKNOWN', syn.unknown[0]?.[0], syn.unknown[0]?.[1])}</div>
|
||||
${ev ? `<div class="evs">${ev}</div>` : ''}
|
||||
<div class="rfoot"><span><b class="num">${room.posts.length}</b> posts · <b class="num">${sealed}</b> sealed${notes ? ` · <b class="num">${notes}</b> note` : ''}</span><span class="join">Join the room ${ic('chevr', 'xs')}</span></div>`;
|
||||
}
|
||||
NOWV.render = function () {
|
||||
const v = $('#v-now');
|
||||
const now = new Date();
|
||||
const kick = now.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' });
|
||||
v.innerHTML = `<div class="navbar">Now</div>
|
||||
const v = $('#v-now'); const now = new Date();
|
||||
const kick = now.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).toUpperCase();
|
||||
v.innerHTML = `<div class="navbar"><span class="wm">GOD’S EYE</span></div>
|
||||
<div class="now-hero">
|
||||
<div class="lt"><div class="kicker"><span class="dot pulse"></span><span>${kick}</span>·<span class="num" id="nowClock">${fmt.hm(Date.now())} UTC</span></div>
|
||||
<div class="row"><h1>Now</h1><button class="avatar md" style="--h:205" aria-label="You" id="nowMe">Y</button></div></div>
|
||||
<div class="livebar"><span><b class="num" id="cSit">${GE.SIT.list.length}</b> situations</span><span><b class="num" id="cAir">–</b> aircraft</span><span><b class="num" id="cShip">–</b> vessels</span><span><b class="num" id="cSat">${fmt.n(GE.SATS.length)}</b> satellites</span></div>
|
||||
<div class="hud"><span class="wm">GOD’S EYE</span><i class="hb a"></i><i class="hb b"></i><i class="hb c"></i><i class="hb d"></i><i class="xh"></i><div class="hud-t mono" id="heroTele"></div></div>
|
||||
<div class="lt"><div class="kicker mono"><span class="dot pulse"></span><span>LIVE · ${kick} · <span class="num" id="nowClock">${GE.feed.hmsZ()}</span></span></div>
|
||||
<div class="row"><h1>Right now, at every altitude.</h1><button class="avatar md" style="--h:205" aria-label="You" id="nowMe">Y</button></div>
|
||||
<div class="counts mono"><span><b class="num" id="cAir">–</b> aircraft</span><span><b class="num" id="cShip">–</b> vessels</span><span><b class="num">${fmt.n(GE.SATS.length)}</b> satellites</span><span><b class="num">408</b> cameras</span><span class="w"><b class="num" id="cWit">14</b> witnesses live</span></div></div>
|
||||
</div>
|
||||
<div class="now-body"><div class="chips" id="nowChips">
|
||||
${[['all', 'For you'], ['now', 'Happening now'], ['near', 'Near you'], ['recon', 'Reconstructions'], ['always', 'Always on']].map(([k, l]) => `<button class="chip ${NOWV.filter === k ? 'on' : ''}" data-f="${k}">${l}</button>`).join('')}
|
||||
</div><div id="nowSecs"></div></div>`;
|
||||
<div class="now-body"><div class="bandrail" id="bandRail" role="tablist" aria-label="Altitude"></div><div id="feed"></div><div class="feed-end mono">${ic('sparkles', 'xs')} CONCEPT · ILLUSTRATIVE DATA · PEOPLE AND POSTS ARE FICTIONAL</div></div>`;
|
||||
$('#nowMe').addEventListener('click', () => APP.setTab('you'));
|
||||
$$('#nowChips .chip').forEach((b) => b.addEventListener('click', () => { NOWV.filter = b.dataset.f; $$('#nowChips .chip').forEach((x) => x.classList.toggle('on', x === b)); NOWV.renderSections(); haptic(4); }));
|
||||
NOWV.renderSections();
|
||||
NOWV.renderRail(); NOWV.renderFeed();
|
||||
};
|
||||
NOWV.renderSections = function () {
|
||||
const host = $('#nowSecs'); host.innerHTML = '';
|
||||
for (const sec of GE.SIT.sections) {
|
||||
if (NOWV.filter !== 'all' && NOWV.filter !== sec.id) continue;
|
||||
const items = GE.SIT.list.filter((s) => s.section === sec.id);
|
||||
const hd = h('div', { class: 'sec-h' }, h('h2', null, sec.title), h('span', null, sec.sub));
|
||||
const grid = h('div', { class: 'masonry' }); const cols = [h('div', { class: 'col' }), h('div', { class: 'col' })]; grid.append(...cols);
|
||||
const hs = [0, 0];
|
||||
for (const s of items) {
|
||||
const el = h('article', { class: 'card', 'data-id': s.id, tabindex: 0, role: 'button', 'aria-label': s.title }); el.innerHTML = cardHTML(s);
|
||||
const ci = hs[0] <= hs[1] ? 0 : 1; cols[ci].append(el); hs[ci] += s.aspect + 0.62;
|
||||
el.addEventListener('click', (e) => { if (e.target.closest('[data-ride]')) { e.stopPropagation(); NOWV.open(s, el, { ride: true }); return; } NOWV.open(s, el); });
|
||||
el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); NOWV.open(s, el); } });
|
||||
const cv = el.querySelector('canvas');
|
||||
const cached = TH.done.get(s.id);
|
||||
if (cached) cv.getContext('2d').drawImage(cached, 0, 0);
|
||||
else { TH.fallback(s, cv); TH.enqueue(() => { const c2 = document.createElement('canvas'); c2.width = cv.width; c2.height = cv.height; TH.renderSituation(s, c2); TH.done.set(s.id, c2); $$(`.card[data-id="${s.id}"] canvas, .tcard[data-id="${s.id}"] canvas`).forEach((x) => { const xc = x.getContext('2d'); xc.drawImage(c2, 0, 0, x.width, x.height); }); }); }
|
||||
}
|
||||
host.append(hd, grid);
|
||||
NOWV.renderRail = function () {
|
||||
const c = GE.feed.counts();
|
||||
$('#bandRail').innerHTML = BAND_ORDER.map((b) => { const B = GE.BANDS[b]; return `<button class="bcell ${NOWV.band === b ? 'on' : ''}" data-b="${b}" role="tab" aria-selected="${NOWV.band === b}" style="--c:${B ? B.c : '#E8EEF8'}"><i></i><b>${b === 'all' ? 'ALL' : B.name.toUpperCase()}</b><span class="num">${c[b]}</span><em>${b === 'all' ? 'orbit → you' : esc(B.alt)}</em></button>`; }).join('<span class="rarr"></span>');
|
||||
$$('#bandRail .bcell').forEach((el) => el.addEventListener('click', () => { NOWV.band = el.dataset.b; store.set('band', NOWV.band); NOWV.renderRail(); NOWV.renderFeed(); haptic(5); const v = $('#v-now'); const r = $('#bandRail'); if (v.scrollTop > r.offsetTop) v.scrollTo({ top: r.offsetTop - 4, behavior: 'smooth' }); }));
|
||||
};
|
||||
NOWV.cardEl = function (x) {
|
||||
const el = h('article', { class: `card fc a-${x.arch} b-${x.band}${x.wide ? ' wide' : ''}${x.arch === 'room' && !x.recon ? ' room' : ''}${x.mine ? ' mine' : ''}`, 'data-fid': x.id, 'data-id': x.sit, tabindex: 0, role: 'button', 'aria-label': x.title, style: `--c:${GE.BANDS[x.band].c}` });
|
||||
el.innerHTML = cardHTML(x);
|
||||
const go = (e) => { if (e && e.target.closest('[data-cta]')) { e.stopPropagation(); if (x.ride) { NOWV.open(GE.SIT.byId[x.sit], el, { ride: true }); return; } if (x.cta[0] === 'bell') { APP.toast(`${ic('bell', 'sm')} Reminder set · 5 min before the pass`); return; } } NOWV.openItem(x, el); };
|
||||
el.addEventListener('click', go); el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
||||
// paint
|
||||
const cv = el.querySelector('.fr canvas');
|
||||
if (cv) NOWV.paint(x, cv);
|
||||
el.querySelectorAll('canvas.evc').forEach((c) => { const [id, o] = x.ev[+c.dataset.ev]; TH.enqueue(() => GE.paint.draw(id, c, { ...o, t: 2 + +c.dataset.ev })); });
|
||||
return el;
|
||||
};
|
||||
NOWV.paint = function (x, cv) {
|
||||
if (x.img) { const im = new Image(); im.onload = () => { const cx = cv.getContext('2d'); const s = Math.max(cv.width / im.width, cv.height / im.height); cx.drawImage(im, (cv.width - im.width * s) / 2, (cv.height - im.height * s) / 2, im.width * s, im.height * s); }; im.src = x.img; return; }
|
||||
if (x.paint === 'globe') { const s = GE.SIT.byId[x.sit]; const cached = TH.done.get(s.id); if (cached) cv.getContext('2d').drawImage(cached, 0, 0, cv.width, cv.height); else { TH.fallback(s, cv); TH.enqueue(() => { const c2 = document.createElement('canvas'); c2.width = cv.width; c2.height = cv.height; TH.renderSituation(s, c2); TH.done.set(s.id, c2); $$(`[data-id="${s.id}"] .fr canvas`).forEach((z) => z.getContext('2d').drawImage(c2, 0, 0, z.width, z.height)); }); } return; }
|
||||
const key = x.id + '|' + cv.width; const done = NOWV.painted.get(key);
|
||||
const cx = cv.getContext('2d'); cx.fillStyle = '#06080c'; cx.fillRect(0, 0, cv.width, cv.height);
|
||||
if (GE.paint.animated(x.paint)) { NOWV.anim.set(cv, x); TH.enqueue(() => GE.paint.draw(x.paint, cv, { ...x.po, t: performance.now() / 1000 })); return; }
|
||||
if (done) { cx.drawImage(done, 0, 0); return; }
|
||||
TH.enqueue(() => { if (!GE.paint.draw(x.paint, cv, { ...x.po })) return; const c2 = GE.paint.mk(cv.width, cv.height); c2.getContext('2d').drawImage(cv, 0, 0); NOWV.painted.set(key, c2); });
|
||||
};
|
||||
NOWV.painted = new Map(); NOWV.anim = new Map(); NOWV.vis = new Set();
|
||||
NOWV.renderFeed = function () {
|
||||
const host = $('#feed'); if (!host) return; host.innerHTML = ''; NOWV.anim.clear();
|
||||
const list = GE.feed.list(NOWV.band);
|
||||
if (NOWV.band !== 'all') { const B = GE.BANDS[NOWV.band]; host.append(h('div', { class: 'band-h', style: `--c:${B.c}`, html: `<b>${B.name}</b><span>${esc(B.alt)} · ${esc(B.sensor)}</span>` })); }
|
||||
let grid = null, cols = null, hs = [0, 0];
|
||||
const flush = () => { grid = null; };
|
||||
for (const x of list) {
|
||||
const el = NOWV.cardEl(x);
|
||||
if (x.wide) { flush(); host.append(el); continue; }
|
||||
if (!grid) { grid = h('div', { class: 'masonry' }); cols = [h('div', { class: 'col' }), h('div', { class: 'col' })]; grid.append(...cols); host.append(grid); hs = [0, 0]; }
|
||||
const ci = hs[0] <= hs[1] ? 0 : 1; cols[ci].append(el); hs[ci] += (x.aspect || 0.9) + (x.arch === 'number' ? 0.5 : 0.62) + (x.by ? 0.16 : 0) + (x.cta ? 0.2 : 0);
|
||||
}
|
||||
NOWV.observe();
|
||||
};
|
||||
NOWV.observe = function () {
|
||||
if (!('IntersectionObserver' in window)) { NOWV.anim.forEach((x, cv) => NOWV.vis.add(cv)); return; }
|
||||
NOWV.io && NOWV.io.disconnect(); NOWV.vis.clear();
|
||||
NOWV.io = new IntersectionObserver((es) => { for (const e of es) { const cv = e.target; if (e.isIntersecting) NOWV.vis.add(cv); else NOWV.vis.delete(cv); } }, { root: $('#v-now'), rootMargin: '80px' });
|
||||
NOWV.anim.forEach((x, cv) => NOWV.io.observe(cv));
|
||||
};
|
||||
// animated eyes: CCTV and live scenes redraw at ~8 fps while visible
|
||||
NOWV.animate = function () {
|
||||
if (APP.tab !== 'now' || APP.mode || APP.intro) return; const t = performance.now() / 1000;
|
||||
for (const cv of NOWV.vis) { const x = NOWV.anim.get(cv); if (!x) continue; GE.paint.draw(x.paint, cv, { ...x.po, t }); }
|
||||
};
|
||||
setInterval(() => { try { NOWV.animate(); } catch (e) { console.warn(e); } }, 125);
|
||||
NOWV.openItem = function (x, el) {
|
||||
if (x.recon) { APP.setTab('threads'); GE.threads.openWs(x.recon); return; }
|
||||
const s = GE.SIT.byId[x.sit]; NOWV._item = x;
|
||||
if (x.arch === 'room') { GE.sheet.open(x.sit, { post: x.post }); return; }
|
||||
NOWV.open(s, el, { cam: x.cam, post: x.post, seg: x.arch === 'number' || (x.arch === 'sensor' && !x.post) ? 'room' : 'room' });
|
||||
};
|
||||
NOWV.tick = function () {
|
||||
const W = GE.world;
|
||||
const c = W.counts; const air = (c.flights || 0) + (c.military || 0) + (c.ga || 0) + GE.HELI_CIRCUITS.length;
|
||||
const a = $('#cAir'); if (a) a.textContent = fmt.n(air);
|
||||
const sh = $('#cShip'); if (sh) sh.textContent = fmt.n(GE.SHIPS.length);
|
||||
const ck = $('#nowClock'); if (ck) ck.textContent = fmt.hm(Date.now()) + ' UTC';
|
||||
for (const el of $$('.card[data-id]')) {
|
||||
const s = GE.SIT.byId[el.dataset.id]; if (!s) continue;
|
||||
const [m, ml] = s.metric(); const me = el.querySelector('[data-m]'), mle = el.querySelector('[data-ml]');
|
||||
if (me && me.innerHTML !== m) { me.innerHTML = m; } if (mle && mle.innerHTML !== ml) mle.innerHTML = ml;
|
||||
const ag = el.querySelector('[data-age]'); if (ag) ag.textContent = `${s.source} · ${fmt.ago(Date.now() - s.updatedAt)}`;
|
||||
}
|
||||
const ck = $('#nowClock'); if (ck) ck.textContent = GE.feed.hmsZ();
|
||||
const ht = $('#heroTele'); if (ht && GE.mainView) { const v = GE.mainView; const [la, lo, alt] = geo.ll(v.eye); ht.textContent = `ORBIT · ALT ${fmt.n(Math.round(alt))} KM · NADIR ${GE.feed.llz(la, lo)} · TERMINATOR ${W.sun.lat >= 0 ? 'N' : 'S'} · ${GE.feed.hmsZ()}`; }
|
||||
if (APP.tab !== 'now') return;
|
||||
const items = new Map([...GE.feed.user, ...GE.feed.items()].map((x) => [x.id, x]));
|
||||
for (const el of $$('#feed .fc[data-fid]')) { const x = items.get(el.dataset.fid); if (!x) continue; const t = el.querySelector('[data-tele]'); if (t && x.tele) { const s2 = x.tele(); if (t.textContent !== s2) t.textContent = s2; } const ro = el.querySelector('[data-ro]'); if (ro && typeof x.readout === 'function') ro.textContent = x.readout(); const w = el.querySelector('[data-watch]'); if (w) { const r = GE.rooms.byId[x.sit]; if (r) w.textContent = fmt.n(r.watching); } }
|
||||
};
|
||||
GE.rooms.drift = setInterval(() => { for (const r of Object.values(GE.rooms.byId)) r.watching += Math.round((Math.random() - 0.42) * r.rate * 3); }, 2000);
|
||||
|
||||
/* ---------- hero morph: card → live globe ---------- */
|
||||
NOWV.open = function (s, cardEl, o = {}) {
|
||||
const wide = APP.isWide();
|
||||
const thumb = cardEl?.querySelector('.thumb canvas');
|
||||
const thumb = cardEl?.querySelector('.fr canvas') || cardEl?.querySelector('.evc');
|
||||
if (wide || !thumb || prefersReduced()) { GE.sheet.open(s.id, { ...o, fade: !wide }); return; }
|
||||
const r = thumb.getBoundingClientRect(); const app = $('#app').getBoundingClientRect();
|
||||
const M = $('#morph'); M.innerHTML = ''; const cv = document.createElement('canvas'); cv.width = thumb.width; cv.height = thumb.height; cv.getContext('2d').drawImage(thumb, 0, 0); M.append(cv);
|
||||
@@ -144,9 +210,9 @@ NOWV.open = function (s, cardEl, o = {}) {
|
||||
}));
|
||||
};
|
||||
NOWV.foldBack = function (id) {
|
||||
const cardEl = $(`#v-now .card[data-id="${id}"]`); const M = $('#morph');
|
||||
const cardEl = (NOWV._morphCard && document.body.contains(NOWV._morphCard) && NOWV._morphCard.dataset.id === id) ? NOWV._morphCard : $(`#v-now .card[data-id="${id}"]`); const M = $('#morph');
|
||||
if (!cardEl || prefersReduced() || APP.isWide()) return false;
|
||||
const thumb = cardEl.querySelector('.thumb canvas'); const app = $('#app').getBoundingClientRect();
|
||||
const thumb = cardEl.querySelector('.fr canvas') || cardEl.querySelector('.evc'); if (!thumb) return false; const app = $('#app').getBoundingClientRect();
|
||||
M.innerHTML = ''; const cv = document.createElement('canvas'); cv.width = thumb.width; cv.height = thumb.height; cv.getContext('2d').drawImage(thumb, 0, 0); M.append(cv);
|
||||
const tw = app.width, th = app.height * 0.57;
|
||||
Object.assign(M.style, { left: '0px', top: '0px', width: tw + 'px', height: th + 'px', transformOrigin: '0 0', transition: 'none', transform: 'none', borderRadius: '0px', opacity: '0' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Situation sheet: detents (peek / medium / large), drag with velocity snapping, the world always
|
||||
wins (touching the globe collapses to peek). Tufte-grade brief: direct-labelled sparklines with
|
||||
uncertainty, small multiples rendered from the globe, evidence strip, every number sourced + aged. */
|
||||
const SH = (GE.sheet = { id: null, detent: 'medium' });
|
||||
const SH = (GE.sheet = { id: null, detent: 'medium', seg: 'room' });
|
||||
const sheetEl = () => $('#sheet');
|
||||
SH.heights = function () {
|
||||
const H = $('#app').clientHeight; const sab = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sab')) || 0;
|
||||
@@ -19,7 +19,7 @@ SH.setDetent = function (d, animate = true) {
|
||||
SH.open = function (id, o = {}) {
|
||||
const s = GE.SIT.byId[id]; if (!s) return;
|
||||
const W = GE.world;
|
||||
SH.id = id; SH.sit = s;
|
||||
SH.fromTab = APP.mode === 'situation' ? SH.fromTab : APP.tab; SH.id = id; SH.sit = s; SH.opts = o; SH.seg = o.seg || 'room';
|
||||
if (APP.tab !== 'globe' && !APP.isWide()) { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.tab').forEach((b) => b.classList.toggle('on', b.dataset.tab === 'globe')); $$('.pane .view').forEach((v) => v.classList.remove('on')); APP.moveLens(); }
|
||||
APP.setMode('situation');
|
||||
// layers & time window
|
||||
@@ -28,9 +28,9 @@ SH.open = function (id, o = {}) {
|
||||
bus.emit('layers', W.layers);
|
||||
if (s.time) { W.setWindow({ start: s.time.start ?? s.time.at - 6 * 3600000, end: s.time.end ?? s.time.at + 6 * 3600000 }); W.setTime(s.time.at); }
|
||||
W.auroraK = s.auroraK ?? 0.85;
|
||||
GE.setExtras && GE.setExtras(s.id); GE.setLabels3d(s.thread ? GE.threads.data[s.thread].labels : s.labels3d);
|
||||
GE.setExtras && GE.setExtras(s.id); GE.setLabels3d(s.thread ? GE.threads.data[s.thread].labels : s.labels3d); GE.roomUI.labels(s, o.post);
|
||||
// camera
|
||||
const cam = GE.SIT.cameraFor(s);
|
||||
const cam = o.cam || GE.SIT.cameraFor(s);
|
||||
const hs = SH.heights();
|
||||
const shiftY = APP.isWide() ? 0 : hs.medium / hs.H;
|
||||
SH.targetShift = shiftY;
|
||||
@@ -48,13 +48,14 @@ SH.open = function (id, o = {}) {
|
||||
SH.close = function (silent, keepTime) {
|
||||
if (!SH.id) return; const id = SH.id; const W = GE.world;
|
||||
const el = sheetEl(); el.classList.remove('drag', 'wopen'); el.style.transform = APP.isWide() ? '' : 'translateY(100%)';
|
||||
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null); GE.setLabels3d(null);
|
||||
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null); GE.setLabels3d(null); GE.roomUI.composer(false);
|
||||
if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); }
|
||||
if (!keepTime) { W.setWindow(null); W.setTime(null); } W.auroraK = 0.85;
|
||||
APP.setMode('');
|
||||
if (silent) return;
|
||||
const fromNow = !APP.isWide() && GE.now && $(`#v-now .card[data-id="${id}"]`);
|
||||
if (fromNow) { APP.setTab('now', { force: true }); requestAnimationFrame(() => GE.now.foldBack(id)); }
|
||||
if (SH.fromTab === 'threads' || SH.fromTab === 'you') APP.setTab(SH.fromTab, { force: true });
|
||||
else if (fromNow) { APP.setTab('now', { force: true }); requestAnimationFrame(() => GE.now.foldBack(id)); }
|
||||
else { Cam.flyTo({ range: Math.max(Cam.range * 3, 0.6), shiftY: APP.isWide() ? 0 : 0.06, tilt: 0 }); }
|
||||
};
|
||||
|
||||
@@ -127,8 +128,26 @@ function barsSVG(items, w) {
|
||||
items.forEach(([k, v], i) => { const y = i * 22; s += `<text x="0" y="${y + 14}" font-size="12" fill="rgba(232,238,248,.75)" font-family="var(--font)">${k}</text><rect x="92" y="${y + 6}" width="${(w - 132) * v}" height="10" rx="3" fill="rgba(124,196,255,.55)"/><text x="${92 + (w - 132) * v + 6}" y="${y + 15}" font-size="11" fill="rgba(232,238,248,.55)" font-family="var(--font)">${Math.round(v * 100)}</text>`; });
|
||||
return `<svg viewBox="0 0 ${w} ${hgt}" width="${w}" height="${hgt}">${s}</svg>`;
|
||||
}
|
||||
const bandOfSit = (s) => (GE.now?._item?.sit === s.id ? GE.now._item.band : { orbit: 'orbit', sky: 'sky', wx: 'sky', eyes: 'ground' }[s.fam] || 'ground');
|
||||
SH.render = function (s) {
|
||||
const c = GE.FAM[s.fam].color; const el = $('#sheetC');
|
||||
const el = $('#sheetC'); const b = bandOfSit(s); const B = GE.BANDS[b]; const room = GE.rooms.get(s.id);
|
||||
const live = s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : s.d4 ? `<span class="badge d4">4D</span>` : `<span class="badge replay">Replay</span>`;
|
||||
const ppl = [...new Set(room.posts.map((p) => p.by))].slice(0, 5);
|
||||
el.innerHTML = `<div class="sh-hd"><div class="t"><div class="tags" style="--c:${B.c}"><span class="bt">${B.name.toUpperCase()}</span>${live}<span class="age num" data-sage>${fmt.ago(Date.now() - s.updatedAt)}</span></div>
|
||||
<h2>${esc(s.title)}</h2><div class="pl">${esc(s.place)} · <span class="mono">${fmt.ll(s.lat, s.lon, 2)}</span> · <b>${GE.feed.fmtDist(GE.feed.distFromMe(s.lat, s.lon))}</b> from you</div></div></div>
|
||||
<div class="presence-row"><span class="stack">${ppl.map((k) => GE.avatarHTML(k)).join('')}</span><span><b class="num" data-watch>${fmt.n(room.watching)}</b> watching</span><span class="rate">${room.rate} posts/min</span></div>
|
||||
<div class="seg2" id="shSegs"><button data-seg="room" class="${SH.seg === 'room' ? 'on' : ''}">${ic('threads', 'xs')}Room <span class="num">${room.posts.length}</span></button><button data-seg="brief" class="${SH.seg === 'brief' ? 'on' : ''}">${ic('signal', 'xs')}Brief & sources</button></div>
|
||||
<div id="shSeg"></div>`;
|
||||
el.querySelectorAll('[data-seg]').forEach((bt) => bt.addEventListener('click', () => { SH.seg = bt.dataset.seg; el.querySelectorAll('[data-seg]').forEach((x) => x.classList.toggle('on', x === bt)); SH.renderSeg(s); haptic(4); }));
|
||||
SH.renderSeg(s);
|
||||
};
|
||||
SH.renderSeg = function (s) {
|
||||
const host = $('#shSeg'); GE.roomUI.composer(SH.seg === 'room');
|
||||
if (SH.seg === 'room') { GE.roomUI.render(host, s, { post: SH.opts?.post }); return; }
|
||||
SH.renderBrief(s, host);
|
||||
};
|
||||
SH.renderBrief = function (s, el) {
|
||||
const c = GE.FAM[s.fam].color;
|
||||
const ms = s.metrics();
|
||||
const live = s.live ? `<span class="badge live"><span class="dot pulse"></span>Live</span>` : s.d4 ? `<span class="badge d4">4D</span>` : `<span class="badge replay">Replay</span>`;
|
||||
const acts = [
|
||||
@@ -139,8 +158,6 @@ SH.render = function (s) {
|
||||
].join('');
|
||||
const b = s.brief || {};
|
||||
el.innerHTML = `
|
||||
<div class="sh-hd"><div class="t"><div style="display:flex;gap:6px;align-items:center"><span class="badge fam" style="--c:${c}"><i></i>${GE.FAM[s.fam].name}</span>${live}<span style="font-size:12px;color:var(--text3)" class="num" data-sage>${fmt.ago(Date.now() - s.updatedAt)}</span></div>
|
||||
<h2>${esc(s.title)}</h2><div class="pl">${esc(s.place)} · <span class="mono">${fmt.ll(s.lat, s.lon, 2)}</span></div></div></div>
|
||||
<div class="metrics" id="shMetrics">${ms.map((m) => `<div class="metric"><div class="v">${m.v}<small>${m.u}</small></div><div class="l">${m.l}</div><div class="s">${m.s}</div></div>`).join('')}</div>
|
||||
<div class="acts sh-acts">${acts}</div>
|
||||
<div class="brief">
|
||||
|
||||
@@ -136,7 +136,10 @@ GUI.drawRibbon = function () {
|
||||
el.classList.toggle('future', cur > 0.02 && !W.window);
|
||||
el.classList.toggle('breath', W.live && !prefersReduced());
|
||||
const nb = $('#rbNow'); nb.classList.toggle('live', W.live); nb.innerHTML = W.window ? `${ic('clock', 'xs')}EVENT` : W.live ? '<span class="dot pulse"></span>LIVE' : 'NOW';
|
||||
const stt = $('#statusTxt'); if (stt) { stt.textContent = W.window ? 'Replay' : W.live ? 'Live' : cur > 0 ? 'Forecast' : 'Replay'; $('#statusTime').textContent = fmt.utc(simMs, false) + ' UTC'; $('#status .dot').style.background = W.live ? '' : cur > 0 ? 'var(--wx)' : 'var(--gold)'; }
|
||||
const stt = $('#statusTxt'); if (stt) {
|
||||
const alt = (GE.mainView ? geo.ll(GE.mainView.eye)[2] : Cam.range * EARTH_KM); const band = alt > 120 ? 'ORBIT' : alt > 0.8 ? 'SKY' : 'GROUND'; const bc = { ORBIT: 'var(--b-orbit)', SKY: 'var(--b-sky)', GROUND: 'var(--b-ground)' }[band];
|
||||
if (GE.live && GE.live.on) { stt.innerHTML = `You’re live · <b class="num">${fmt.dur((Date.now() - GE.live.t0) / 1000).slice(3)}</b>`; $('#statusTime').textContent = 'return'; $('#status .dot').style.background = ''; }
|
||||
else { stt.innerHTML = `${W.window ? 'Replay' : W.live ? 'Live' : cur > 0 ? 'Forecast' : 'Replay'} <span class="alt mono" style="color:${bc}">${band} · ${alt >= 10 ? fmt.n(Math.round(alt)) + ' KM' : alt >= 1 ? alt.toFixed(1) + ' KM' : fmt.n(Math.round(alt * 1000)) + ' M'}</span>`; $('#statusTime').textContent = fmt.utc(simMs, false) + ' UTC'; $('#status .dot').style.background = W.live ? '' : cur > 0 ? 'var(--wx)' : 'var(--gold)'; } }
|
||||
};
|
||||
GUI.pips = function () {
|
||||
const nowMs = Date.now(); const W = GE.world;
|
||||
@@ -217,5 +220,5 @@ GUI.init = function () {
|
||||
$('#btnLens').addEventListener('click', () => { const ids = LENSES.map((l) => l[0]); const i = (ids.indexOf(GE.world.lens) + 1) % ids.length; GE.world.setLens(ids[i]); APP.toast(`${ic('sparkles', 'sm')} ${LENSES[i][1]} lens`); $('#btnLens').classList.toggle('on', ids[i] !== 'cinematic'); });
|
||||
$('#btnHome').addEventListener('click', () => { GUI.showPick(null); Cam.flyTo({ range: 2.6, tilt: 0, heading: 0, shiftY: APP.isWide() ? 0 : 0.06 }); });
|
||||
$('#btnCompass').addEventListener('click', () => Cam.flyTo({ heading: 0, tilt: 0 }, { duration: 0.8 }));
|
||||
$('#status').addEventListener('click', () => GUI.snapNow());
|
||||
$('#status').addEventListener('click', () => { if (GE.live && GE.live.on) { APP.setTab('capture'); return; } GUI.snapNow(); });
|
||||
};
|
||||
@@ -51,32 +51,42 @@ CAP.enter = function () {
|
||||
CAP.render();
|
||||
KEY.get();
|
||||
};
|
||||
CAP.exit = function () { if (!CAP.on) return; CAP.on = false; GE.renderOverride = null; Cam.setRig(null); Cam.rigBlend = 1; $('#sealOv').classList.remove('on'); };
|
||||
CAP.exit = function () { if (!CAP.on) return; CAP.on = false; CAP.cover = false; GE.renderOverride = null; Cam.setRig(null); Cam.rigBlend = 1; $('#sealOv').classList.remove('on'); };
|
||||
CAP.MODES = [['look', 'Look'], ['witness', 'Witness'], ['live', 'Live'], ['upload', 'Upload']];
|
||||
CAP.setMode = function (m) { if (CAP.mode === 'live' && m !== 'live' && CAP.live?.on) { APP.toast(`${ic('live', 'sm')} End the broadcast first`); return; } CAP.mode = m; if (CAP.on) CAP.render(); };
|
||||
CAP.render = function () {
|
||||
const el = $('#v-capture'); const last = GE.captures[GE.captures.length - 1];
|
||||
el.innerHTML = `<div class="cap-ui">
|
||||
const el = $('#v-capture'); const last = GE.captures[GE.captures.length - 1]; const m = CAP.mode;
|
||||
const rt = CAP.replyTo && GE.SIT.byId[CAP.replyTo.sit];
|
||||
CAP.cover = m === 'live';
|
||||
el.innerHTML = `<canvas class="livecv ${m === 'live' ? 'on' : ''}" id="liveCv"></canvas><div class="cap-ui m-${m}">
|
||||
<svg class="compass-tape" id="ctape"></svg>
|
||||
<div class="callouts" id="callouts"></div>
|
||||
<div class="cap-top"><span class="st glass">${ic('location', 'xs')}<span>Simulated camera · ${SPOT.name}</span></span><span class="st glass" id="capMode">${CAP.mode === 'look' ? `${ic('eye', 'xs')}Look · AR` : `${ic('key', 'xs')}Witness · signing`}</span></div>
|
||||
<div class="sensors glass ${CAP.mode === 'witness' ? 'on' : ''}" id="sensors"></div>
|
||||
<div class="cap-top"><span class="st glass">${ic('location', 'xs')}<span>Simulated camera · ${SPOT.name}</span></span><span class="st glass" id="capMode">${{ look: `${ic('eye', 'xs')}Look · AR`, witness: `${ic('key', 'xs')}Witness · signing`, live: `${ic('live', 'xs')}Live · sealed every 10 s`, upload: `${ic('gallery', 'xs')}Upload · unverified` }[m]}</span></div>
|
||||
${rt ? `<div class="replyto glass">${ic('threads', 'xs')}<span>Replying in <b>${esc(rt.short || rt.title.split(' — ')[0])}</b></span><button id="rtX" aria-label="Cancel reply">${ic('x', 'xs')}</button></div>` : ''}
|
||||
<div class="sensors glass ${m === 'witness' ? 'on' : ''}" id="sensors"></div>
|
||||
<div id="modeHost"></div>
|
||||
<div class="cap-bottom">
|
||||
<div class="zooms glass">${[0.5, 1, 3].map((z) => `<button data-z="${z}" class="${CAP.zoom === z ? 'on' : ''}">${z === 0.5 ? '.5' : z}×</button>`).join('')}</div>
|
||||
<div class="dial" id="dial"><button data-m="look" class="${CAP.mode === 'look' ? 'on' : ''}">Look</button><button data-m="witness" class="${CAP.mode === 'witness' ? 'on' : ''}">Witness</button></div>
|
||||
${m === 'look' || m === 'witness' ? `<div class="zooms glass">${[0.5, 1, 3].map((z) => `<button data-z="${z}" class="${CAP.zoom === z ? 'on' : ''}">${z === 0.5 ? '.5' : z}×</button>`).join('')}</div>` : ''}
|
||||
<div class="dial" id="dial">${CAP.MODES.map(([k, l]) => `<button data-m="${k}" class="${m === k ? 'on' : ''}">${l}</button>`).join('')}</div>
|
||||
<div class="shutrow"><button class="lastcap" id="lastcap" aria-label="Last capture" style="${last ? `background-image:url(${last.thumb})` : ''}">${last ? '<svg class="seal"><use href="#i-seal"/></svg>' : ''}</button>
|
||||
<button class="shutter ${CAP.mode === 'look' ? 'look' : ''}" id="shutter" aria-label="${CAP.mode === 'look' ? 'Switch to Witness' : 'Capture and sign'}"><i></i></button>
|
||||
<button class="circ glass" id="capFlip" aria-label="Layers in view">${ic('layers')}</button></div>
|
||||
<button class="shutter s-${m} ${CAP.live?.on ? 'rec' : ''}" id="shutter" aria-label="${{ look: 'Switch to Witness', witness: 'Capture and sign', live: CAP.live?.on ? 'End broadcast' : 'Go live', upload: 'Choose a file' }[m]}"><i></i></button>
|
||||
<button class="circ glass" id="capFlip" aria-label="${m === 'live' ? 'See yourself on the globe' : 'Layers in view'}">${ic(m === 'live' ? 'globe' : 'layers')}</button></div>
|
||||
</div></div>`;
|
||||
el.querySelectorAll('[data-z]').forEach((b) => b.addEventListener('click', () => { CAP.zoom = +b.dataset.z; CAP.syncZoom(); haptic(4); }));
|
||||
el.querySelectorAll('[data-m]').forEach((b) => b.addEventListener('click', () => { CAP.mode = b.dataset.m; CAP.render(); haptic(6); }));
|
||||
$('#shutter').addEventListener('click', () => { if (CAP.mode === 'look') { CAP.mode = 'witness'; CAP.render(); return; } CAP.shoot(); });
|
||||
el.querySelectorAll('[data-m]').forEach((b) => b.addEventListener('click', () => { CAP.setMode(b.dataset.m); haptic(6); }));
|
||||
$('#rtX')?.addEventListener('click', () => { CAP.replyTo = null; CAP.render(); });
|
||||
$('#shutter').addEventListener('click', () => { if (m === 'look') { CAP.setMode('witness'); return; } if (m === 'witness') CAP.shoot(); if (m === 'live') (CAP.live?.on ? GE.live.stop() : GE.live.start()); if (m === 'upload') $('#upFile')?.click(); });
|
||||
$('#lastcap').addEventListener('click', () => { if (GE.captures.length) GE.you.inspect(GE.captures.length - 1); });
|
||||
$('#capFlip').addEventListener('click', () => APP.toast(`${ic('layers', 'sm')} Showing Sky + Orbit in view`));
|
||||
$('#capFlip').addEventListener('click', () => { if (m === 'live') GE.live.showOnGlobe(); else APP.toast(`${ic('layers', 'sm')} Showing Sky + Orbit in view`); });
|
||||
if (m === 'live') GE.live.renderUI($('#modeHost'));
|
||||
if (m === 'upload') GE.upload.renderUI($('#modeHost'));
|
||||
};
|
||||
CAP.syncZoom = () => $$('#v-capture [data-z]').forEach((x) => x.classList.toggle('on', +x.dataset.z === CAP.zoom));
|
||||
|
||||
/* ---------- per-frame AR overlay ---------- */
|
||||
CAP.frame = function (view) {
|
||||
if (!CAP.on) return;
|
||||
if (CAP.mode === 'live' || CAP.mode === 'upload') { GE.live.frame(); return; }
|
||||
const W = GE.world; const t = CAP.t + (performance.now() / 1000 - (CAP._t0 || (CAP._t0 = performance.now() / 1000)));
|
||||
const host = $('#callouts'); if (!host) return;
|
||||
// compass tape
|
||||
@@ -158,8 +168,8 @@ CAP.ceremony = function (b, cv) {
|
||||
<div class="step" data-s="3"><span class="ck">${ic('check', 'xs')}</span><div><b>Bundle hash</b><div class="h" id="sh3">…</div></div></div>
|
||||
<div class="step" data-s="4"><span class="ck">${ic('check', 'xs')}</span><div><b>Signed</b> · ECDSA P-256, key generated on device, non-extractable<div class="h" id="sh4">…</div></div></div>
|
||||
</div>
|
||||
<div class="btnrow" id="bbtns" style="opacity:.35;pointer-events:none"><button class="btn gold" id="bdrop">${ic('cube', 'sm')}Drop as hologram here</button></div>
|
||||
<div class="btnrow" id="bbtns2" style="opacity:.35;pointer-events:none"><button class="btn" id="bver">${ic('check', 'sm')}Verify</button><button class="btn" id="bdone">Done</button></div>
|
||||
<div class="btnrow" id="bbtns" style="opacity:.35;pointer-events:none"><button class="btn gold" id="bdrop">${ic('threads', 'sm')}Attach & post</button></div>
|
||||
<div class="btnrow" id="bbtns2" style="opacity:.35;pointer-events:none"><button class="btn" id="bver">${ic('check', 'sm')}Verify</button><button class="btn" id="bdone">Keep private</button></div>
|
||||
<p style="margin:0;font-size:11px;color:var(--text3);text-align:center">None of this proves truth. It raises the cost of lying.</p></div>`;
|
||||
const ph = $('#bph'); const c2 = document.createElement('canvas'); c2.width = 600; c2.height = 450; c2.getContext('2d').drawImage(cv, 0, 0, 600, 450); ph.prepend(c2);
|
||||
ov.classList.add('on');
|
||||
@@ -173,7 +183,7 @@ CAP.ceremonyFill = async function (b) {
|
||||
$(`.step[data-s="4"]`).classList.add('on'); await stream('sh4', b.sig ? b.sig : 'WebCrypto unavailable here — bundle hashed but unsigned'); await step(4);
|
||||
$('#bigseal').classList.add('on'); haptic([10, 40, 20]);
|
||||
for (const id of ['bbtns', 'bbtns2']) { const e = $('#' + id); e.style.opacity = 1; e.style.pointerEvents = 'auto'; }
|
||||
$('#bdrop').addEventListener('click', () => { GE.world.userHolos.push({ lat: b.lat, lon: b.lon, title: 'Your capture · ' + b.place, by: 'you', at: b.at }); b.dropped = true; $('#sealOv').classList.remove('on'); APP.toast(`${ic('cube', 'sm')} Hologram dropped · visible to people here`); CAP.render(); GE.you.render(); });
|
||||
$('#bdrop').addEventListener('click', () => { $('#sealOv').classList.remove('on'); GE.attach.open({ kind: 'witness', tier: 'sealed', img: b.thumb, lat: b.lat, lon: b.lon, at: b.at, place: b.place, hash: b.hash, cap: b }); });
|
||||
$('#bver').addEventListener('click', () => { $('#sealOv').classList.remove('on'); GE.you.inspect(GE.captures.indexOf(b)); });
|
||||
$('#bdone').addEventListener('click', () => { $('#sealOv').classList.remove('on'); CAP.render(); GE.you.render(); });
|
||||
};
|
||||
@@ -156,12 +156,20 @@ THR.init = async function () {
|
||||
/* ---------- list ---------- */
|
||||
THR.render = function () {
|
||||
const v = $('#v-threads');
|
||||
v.innerHTML = `<div class="tlist" id="tlist" style="background:var(--bg);min-height:100%"><div class="navbar">Threads</div><div class="lt"><div class="kicker">${ic('seal', 'xs')} Signed evidence · open ledgers</div><div class="row"><h1>Threads</h1><span class="concept" style="margin-bottom:8px">${ic('sparkles', 'xs')} Concept · illustrative data</span></div></div>
|
||||
<div class="livebar" style="padding-bottom:14px"><span class="presence"><span class="stack">${['kora', 'hydro', 'tamang', 'geo', 'sky'].map((k) => `<span class="avatar sm on" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></span><span><b class="num">37</b> people reconstructing now</span></div>
|
||||
v.innerHTML = `<div class="tlist" id="tlist" style="background:var(--bg);min-height:100%"><div class="navbar">Rooms</div><div class="lt"><div class="kicker mono">${ic('threads', 'xs')} CONVERSATIONS ON THE EVIDENCE</div><div class="row"><h1>Rooms</h1><span class="concept" style="margin-bottom:8px">${ic('sparkles', 'xs')} Concept · illustrative data</span></div></div>
|
||||
<div class="livebar" style="padding-bottom:6px"><span class="presence"><span class="stack">${['nadia', 'lin', 'aiko', 'tomas', 'ines'].map((k) => GE.avatarHTML(k)).join('')}</span></span><span><b class="num">${fmt.n(Object.values(GE.rooms.byId).reduce((a, r) => a + r.watching, 0) || 9400)}</b> people in rooms now</span></div>
|
||||
<div class="rooms-why">Every situation has a room. Posts are <b>Witness</b>, <b>Context</b>, <b>Question</b>, <b>Correction</b> or <b>Prediction</b>, ordered by evidence — never by followers.</div>
|
||||
<div class="sec-h"><h2>Live rooms</h2><span>heating up</span></div><div id="roomsLive" class="rooms-live"></div>
|
||||
<div class="sec-h"><h2>Reconstructions</h2><span>4D · signed · open ledgers</span></div>
|
||||
${Object.values(THREADS).map((T) => { const s = GE.SIT.byId[T.sit]; return `<article class="tcard" data-t="${T.id}" data-id="${T.sit}"><div class="thumb"><canvas width="640" height="360"></canvas><div class="bl"><div><h3>${esc(T.title)}</h3><p>${esc(T.place)} · ${esc(T.date)}</p></div><span class="badge d4">4D</span></div></div><div class="stats"><div><b>${T.obs.length}</b>observations</div><div><b>${T.hyps.length}</b>hypotheses</div><div><b>${THR.ledgers[T.id]?.length || '–'}</b>ledger</div><span class="stack">${T.people.slice(0, 4).map((k) => `<span class="avatar sm" style="--h:${P[k].hue}">${P[k].h[0].toUpperCase()}</span>`).join('')}</span></div></article>`; }).join('')}
|
||||
<div style="padding:6px 14px 150px"><button class="btn" style="width:100%" id="newThread">${ic('plus', 'sm')}Start a reconstruction</button><p style="font-size:11.5px;color:var(--text3);text-align:center;margin-top:12px">Concept · illustrative data. Witness posts, handles and claims are fictional.</p></div></div>
|
||||
<div class="ws" id="ws"></div>`;
|
||||
$('#newThread').addEventListener('click', () => APP.toast(`${ic('threads', 'sm')} New thread · add your first observation`));
|
||||
const host = $('#roomsLive'); const sq = GE.feed.squawkTrack();
|
||||
const extra = [{ id: 'room-sq', arch: 'room', band: 'inside', sit: 'squawk', title: 'Squawk 7700 over the North Atlantic', place: 'Diverting to ' + sq.div.iata, lat: sq.cur[0], lon: sq.cur[1], ago: 3, ev: [['cabin', { seed: 81 }], ['track', { track: sq }]] }, { id: 'room-fi', arch: 'room', band: 'inside', sit: 'fires', title: 'Central Portugal fires', place: 'N2 · Pampilhosa da Serra', lat: 40.13, lon: -8.05, ago: 8, ev: [['firewit', { seed: 71 }], ['firesat', {}]] }];
|
||||
const mine = GE.feed.user.filter((x) => x.sit && !['cyclone', 'quake', 'squawk', 'fires'].includes(x.sit)).map((x) => ({ id: 'room-' + x.sit, arch: 'room', band: 'inside', sit: x.sit, title: GE.SIT.byId[x.sit].title, place: GE.SIT.byId[x.sit].place, lat: x.lat, lon: x.lon, ago: 0 }));
|
||||
const items = [...mine, ...GE.feed.items().filter((x) => x.arch === 'room' && !x.recon), ...extra];
|
||||
for (const x of items) { x.at = x.at || NOW - (x.ago || 1) * 60000; x.dist = GE.feed.distFromMe(x.lat, x.lon); x.wide = true; host.append(GE.now.cardEl(x)); }
|
||||
$$('.tcard').forEach((c) => {
|
||||
c.addEventListener('click', () => THR.openWs(c.dataset.t));
|
||||
const cv = c.querySelector('canvas'); const s = GE.SIT.byId[c.dataset.id]; const done = GE.thumbs.done.get(s.id);
|
||||
|
||||
@@ -6,10 +6,12 @@ YOU.render = function () {
|
||||
const caps = GE.captures; const holos = [...W.userHolos, ...GE.HOLOS.filter((x) => x.by === 'you')];
|
||||
v.innerHTML = `<div style="background:var(--bg);min-height:100%;padding-bottom:150px"><div class="navbar">You</div>
|
||||
<div class="lt"><div class="kicker">${ic('key', 'xs')} Device key · P-256 · ${GE.crypto.KEY.pair ? 'ready' : 'generating…'}</div><h1>You</h1></div>
|
||||
<div class="you-h"><span class="avatar xl" style="--h:205">Y</span><div><h2>Your window</h2><div style="color:var(--text2);font-size:13px;margin:2px 0 8px">Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}</div><span class="concept">${ic('sparkles', 'xs')} Concept · illustrative data</span></div></div>
|
||||
<div class="stats4"><div><b>${caps.length}</b>captures</div><div><b>${holos.length}</b>holograms</div><div><b>${Object.values(YOU.follows).filter(Boolean).length}</b>following</div><div><b>${caps.length ? '100%' : '—'}</b>verified</div></div>
|
||||
<div class="you-h"><span class="avatar xl" style="--h:205">Y</span><div><h2>Your eye</h2><div style="color:var(--text2);font-size:13px;margin:2px 0 8px">Watching from ${esc(GE.ME.name)} · ${esc(GE.ME.tz)}</div><span class="concept">${ic('sparkles', 'xs')} Concept · illustrative data</span></div></div>
|
||||
<div class="stats4"><div><b>${GE.feed.user.length}</b>posts</div><div><b>${holos.length}</b>holograms</div><div><b>${Object.values(YOU.follows).filter(Boolean).length}</b>following</div><div><b>${caps.length ? '100%' : '—'}</b>verified</div></div>
|
||||
<div class="sec-h"><h2>Captures</h2><span>sealed on this device</span></div>
|
||||
<div class="capgrid">${caps.length ? caps.map((c, i) => `<div class="capc" data-c="${i}"><div class="im" style="background-image:url(${c.thumb})"><svg class="seal"><use href="#i-seal"/></svg></div><div class="cm"><b>${esc(c.place)}</b><span class="num">${fmt.utc(c.at)}</span><div class="mono">${c.hash ? c.hash.slice(0, 18) + '…' : 'sealing…'}</div></div></div>`).join('') : `<div style="grid-column:1/-1;color:var(--text3);font-size:14px;padding:6px 4px">No captures yet — open Capture › Witness and press the shutter.</div>`}</div>
|
||||
<div class="sec-h"><h2>Your posts</h2><span>in rooms</span></div>
|
||||
<div class="grp">${GE.feed.user.length ? GE.feed.user.map((x) => { const s = GE.SIT.byId[x.sit]; const p = GE.rooms.byId[x.sit]?.posts.find((q) => q.id === x.post); const t = p?.att?.tier || 'text'; return `<div class="row2" data-post="${x.sit}|${x.post}"><span class="ri" style="--c:var(--b-inside)">${ic(t === 'sealed-live' ? 'live' : t === 'unverified' ? 'gallery' : 'seal')}</span><span class="rt">${esc(x.title)}<small>${esc(s ? (s.short || s.title.split(' — ')[0]) : '')} · ${{ 'sealed-live': 'Sealed live' + (p?.att?.segs ? ' · ' + p.att.segs + ' segments' : ''), sealed: 'Sealed', unverified: 'Unverified upload' }[t] || 'Text'} · ${fmt.ago(Date.now() - x.at)}</small></span><button class="rv">${ic('chevr', 'sm')}</button></div>`; }).join('') : `<div class="row2"><span class="ri" style="--c:var(--b-inside)">${ic('viewfinder')}</span><span class="rt">Nothing posted yet<small>Capture › Witness, Live or Upload, then attach it to a room</small></span></div>`}</div>
|
||||
<div class="sec-h"><h2>Holograms</h2><span>left in places</span></div>
|
||||
<div class="grp">${(holos.length ? holos : [{ title: 'Drop one from a Witness capture', lat: 0, lon: 0, empty: true }]).map((x) => `<div class="row2"><span class="ri" style="--c:var(--gold)">${ic('cube')}</span><span class="rt">${esc(x.title)}<small>${x.empty ? 'Visible to people nearby' : fmt.ll(x.lat, x.lon, 3) + ' · visible to people nearby'}</small></span>${x.empty ? '' : `<button class="rv" data-fly="${x.lat},${x.lon}">${ic('chevr', 'sm')}</button>`}</div>`).join('')}</div>
|
||||
<div class="sec-h"><h2>Following</h2><span>alerts</span></div>
|
||||
@@ -30,6 +32,7 @@ YOU.render = function () {
|
||||
<p style="margin:0"><b>Renderer:</b> hand-written WebGL2 — ray-traced planet with single-scatter atmosphere, day/night terminator from the real sun position, ocean glint, clouds, stars, procedural cities and terrain, GPU-propagated flights and orbits.</p></div></div>`;
|
||||
v.scrollTop = st;
|
||||
v.querySelectorAll('[data-c]').forEach((el) => el.addEventListener('click', () => YOU.inspect(+el.dataset.c)));
|
||||
v.querySelectorAll('[data-post]').forEach((el) => el.addEventListener('click', () => { const [sid, pid] = el.dataset.post.split('|'); GE.now._item = { band: 'inside', sit: sid }; GE.sheet.open(sid, { post: pid }); }));
|
||||
v.querySelectorAll('[data-f]').forEach((el) => el.addEventListener('click', () => { const id = el.dataset.f; YOU.follows[id] = !YOU.follows[id]; el.classList.toggle('on', YOU.follows[id]); el.setAttribute('aria-checked', YOU.follows[id]); store.set('follows', YOU.follows); haptic(5); }));
|
||||
v.querySelectorAll('[data-q]').forEach((el) => el.addEventListener('click', () => { const k = el.dataset.q; W.quality[k] = !W.quality[k]; el.classList.toggle('on', W.quality[k]); haptic(5); }));
|
||||
v.querySelectorAll('[data-fly]').forEach((el) => el.addEventListener('click', () => { const [la, lo] = el.dataset.fly.split(',').map(Number); APP.setTab('globe'); Cam.flyTo({ lat: la, lon: lo, range: 0.002, tilt: 1.1 }); }));
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Room UI (inside the situation sheet): AI synthesis that cites every line, evidence-first posts with
|
||||
earned badges, place + time anchors that fly the globe and scrub the ribbon, community notes with the
|
||||
bridging rule explained, presence + floating reactions, and Reply → Capture first. */
|
||||
const RUI = (GE.roomUI = { sort: 'evidence' });
|
||||
const KIND_ICON = { witness: 'eye', context: 'layers', question: 'bubble', correction: 'flag', prediction: 'clock' };
|
||||
const tierLabel = (a) => ({ 'sealed-live': `SEALED LIVE${a.segs ? ' · ' + a.segs + ' SEGMENTS' : ''}`, sealed: 'SEALED', unverified: 'UNVERIFIED UPLOAD', eye: 'PUBLIC CAMERA' })[a.tier] || a.tier.toUpperCase();
|
||||
function citeChips(cites) { return (cites || []).map((c) => `<button class="cite" data-cite="${c}">${c}</button>`).join(''); }
|
||||
RUI.postHTML = function (room, p) {
|
||||
const who = GE.PEOPLE[p.by] || { h: p.by, hue: 205 }; const K = GE.rooms.KINDS[p.kind]; const pid = GE.rooms.pid(room, p);
|
||||
const bd = GE.rooms.badges(room, p).map(([k, l]) => `<span class="bdg ${k}">${k === 'sealed' ? ic('seal', 'xs') : ''}${esc(l)}</span>`).join('');
|
||||
const att = p.att ? `<div class="patt ${p.att.tier}"><canvas width="${p.att.img ? 480 : 480}" height="300" data-att></canvas><span class="tier">${p.att.tier.startsWith('sealed') ? ic('seal', 'xs') : ''}${tierLabel(p.att)}</span>${p.live ? '<span class="plive"><span class="dot pulse"></span>LIVE</span>' : ''}</div>` : '';
|
||||
const anc = [p.place ? `<button class="anc" data-anc="place">${ic('location', 'xs')}${esc(p.place.label)}</button>` : '', `<button class="anc" data-anc="time">${ic('clock', 'xs')}${fmt.hm(p.time || p.at)}Z</button>`].join('');
|
||||
const ans = p.answered ? (() => { const a = room.posts.find((q) => q.id === p.answered); return a ? `<div class="answered">${ic('check', 'xs')}Answered by <b>@${esc(GE.PEOPLE[a.by].h)}</b> · ${GE.rooms.badges(room, a).map(([, l]) => esc(l)).join(' · ')}</div>` : ''; })() : p.kind === 'question' ? `<div class="answered open">${ic('arrowup', 'xs')}Open question · pinned to the top until someone answers with evidence</div>` : '';
|
||||
const note = p.note ? `<div class="note"><div class="nh">${ic('people', 'xs')}Readers added context</div><p>${esc(p.note.text)} ${citeChips(p.note.cites)}</p>
|
||||
<div class="bridge"><div class="br"><span>Raters who usually lean <b>A</b></span><i style="--v:${p.note.helpfulA}"></i><b class="num">${Math.round(p.note.helpfulA * 100)}%</b></div><div class="br"><span>Raters who usually lean <b>B</b></span><i style="--v:${p.note.helpfulB}"></i><b class="num">${Math.round(p.note.helpfulB * 100)}%</b></div></div>
|
||||
<details><summary>Why is this note showing?</summary><p>A note only appears when people who usually <i>disagree</i> with each other both rate it helpful. Raters are grouped by how they have voted on past notes, not by what they say about themselves. Here ${p.note.ratersA} raters from one side and ${p.note.ratersB} from the other agreed, so it is shown. Brigading by one side can't make a note visible.</p></details></div>` : '';
|
||||
const rx = p.reacts || {}; const rxs = [['eye', rx.eye], ['heart', rx.heart], ['exclamation', rx.alert]].filter(([, n]) => n).map(([i, n]) => `<button class="rxb" data-rx="${i}">${ic(i, 'xs')}<span class="num">${fmt.k(n)}</span></button>`).join('');
|
||||
return `<article class="post k-${p.kind}${p.note ? ' noted' : ''}${p.mine ? ' mine' : ''}" data-p="${p.id}">
|
||||
<div class="ph">${GE.avatarHTML(p.by)}<div class="pw"><div><b>@${esc(who.h)}</b><span class="pid">${pid}</span><span class="tm">${fmt.ago(Date.now() - p.at)}</span></div><div class="bds">${bd}</div></div><span class="kind k-${p.kind}">${ic(KIND_ICON[p.kind], 'xs')}${K.name.toUpperCase()}</span></div>
|
||||
<div class="pt">${esc(p.text)}${p.resolves ? `<span class="resolves">resolves ${esc(p.resolves)}</span>` : ''}</div>
|
||||
${att}<div class="ancs">${anc}${citeChips(p.cites)}</div>${note}${ans}
|
||||
<div class="pf">${rxs}<span class="rep">${ic('bubble', 'xs')}<span class="num">${p.replies || 0}</span></span><button class="rcap" data-reply>${ic('viewfinder', 'xs')}Reply with a capture</button></div></article>`;
|
||||
};
|
||||
RUI.synthHTML = function (room) {
|
||||
const S = room.synth; const sec = (k, lab, cls) => (S[k] || []).length ? `<div class="sy ${cls}"><b>${lab}</b>${S[k].map(([t, c]) => `<p>${esc(t)} ${citeChips(c)}</p>`).join('')}</div>` : `<div class="sy ${cls}"><b>${lab}</b><p class="none">Nothing ${k} right now.</p></div>`;
|
||||
const L = room.ledger; const last = room.synthEntry || L[L.length - 1];
|
||||
return `<div class="synth" id="synth"><div class="syh">${ic('sparkles', 'xs')}<span>AI synthesis · every line cites a post or an observation</span><button class="regen" id="regen">${ic('wand', 'xs')}Re-synthesize</button></div>
|
||||
<div id="syBody">${sec('known', 'KNOWN', 'kn')}${sec('disputed', 'DISPUTED', 'di')}${sec('unknown', 'UNKNOWN', 'un')}</div>
|
||||
<div class="syf mono" id="syFoot">${room.synthModel ? esc(room.synthModel) + ' · ' : ''}${room.dropped ? room.dropped + ' uncited line removed · ' : ''}prompt + ${room.posts.length + Object.keys(room.src || {}).length} inputs in the ledger${last ? ` · #${last.i} ${last.hash.slice(0, 10)}…` : ''}</div></div>`;
|
||||
};
|
||||
RUI.render = function (host, s, o = {}) {
|
||||
const room = GE.rooms.get(s.id); RUI.room = room; RUI.sit = s;
|
||||
const list = GE.rooms.ordered(room, RUI.sort);
|
||||
host.innerHTML = `${RUI.synthHTML(room)}
|
||||
<div class="sortrow"><span class="sl mono">ORDER</span>${[['evidence', 'Evidence first'], ['latest', 'Latest'], ['questions', 'Questions']].map(([k, l]) => `<button class="chip sm ${RUI.sort === k ? 'on' : ''}" data-sort="${k}">${l}</button>`).join('')}</div>
|
||||
<div class="posts" id="posts">${list.map((p) => RUI.postHTML(room, p)).join('')}</div>
|
||||
<div class="srcs room-srcs"><div class="h3">Sources in this room</div>${Object.entries(room.src || {}).map(([k, v]) => `<div class="r"><span class="tagid">${k}</span><b style="font-weight:500">${esc(v)}</b></div>`).join('')}</div>
|
||||
<p class="fine">Posts are ordered by evidence, then time — never by followers. Concept · illustrative data; people and posts are fictional.</p>`;
|
||||
RUI.bind(host, room, s);
|
||||
if (o.post) setTimeout(() => RUI.focusPost(o.post, true), 60);
|
||||
};
|
||||
RUI.bind = function (host, room, s) {
|
||||
host.querySelectorAll('[data-sort]').forEach((b) => b.addEventListener('click', () => { RUI.sort = b.dataset.sort; RUI.render(host, s); haptic(4); }));
|
||||
$('#regen')?.addEventListener('click', () => RUI.resynth(room, s));
|
||||
host.querySelectorAll('.post').forEach((el) => {
|
||||
const p = room.posts.find((q) => q.id === el.dataset.p);
|
||||
const cv = el.querySelector('[data-att]'); if (cv && p.att) RUI.paintAtt(cv, p);
|
||||
el.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button, summary, details'); if (b && !b.matches('[data-anc]')) return;
|
||||
RUI.anchor(p, b?.dataset.anc); });
|
||||
el.querySelector('[data-reply]')?.addEventListener('click', (e) => { e.stopPropagation(); RUI.replyCapture(s, p); });
|
||||
el.querySelectorAll('[data-rx]').forEach((b) => b.addEventListener('click', (e) => { e.stopPropagation(); RUI.float(b.dataset.rx); const k = { eye: 'eye', heart: 'heart', exclamation: 'alert' }[b.dataset.rx]; p.reacts = p.reacts || {}; p.reacts[k] = (p.reacts[k] || 0) + 1; b.querySelector('.num').textContent = fmt.k(p.reacts[k]); haptic(4); }));
|
||||
});
|
||||
host.querySelectorAll('[data-cite]').forEach((b) => b.addEventListener('click', (e) => { e.stopPropagation(); RUI.cite(b.dataset.cite); }));
|
||||
};
|
||||
RUI.paintAtt = function (cv, p) {
|
||||
if (p.att.img) { const im = new Image(); im.onload = () => { const cx = cv.getContext('2d'); const sc = Math.max(cv.width / im.width, cv.height / im.height); cx.drawImage(im, (cv.width - im.width * sc) / 2, (cv.height - im.height * sc) / 2, im.width * sc, im.height * sc); }; im.src = p.att.img; return; }
|
||||
const draw = () => GE.paint.draw(p.att.paint, cv, { ...p.att.o, t: performance.now() / 1000 });
|
||||
GE.thumbs.enqueue(draw);
|
||||
if (GE.paint.animated(p.att.paint)) { RUI.animCvs = RUI.animCvs || new Set(); RUI.animCvs.add([cv, p]); }
|
||||
};
|
||||
setInterval(() => { if (!RUI.animCvs || APP.mode !== 'situation') return; for (const e of RUI.animCvs) { const [cv, p] = e; if (!document.body.contains(cv)) { RUI.animCvs.delete(e); continue; } const r = cv.getBoundingClientRect(); if (r.bottom < 0 || r.top > innerHeight) continue; GE.paint.draw(p.att.paint, cv, { ...p.att.o, t: performance.now() / 1000 }); } }, 160);
|
||||
RUI.focusPost = function (id, fly) {
|
||||
const el = $(`.post[data-p="${id}"]`); if (!el) return; el.classList.add('hl'); setTimeout(() => el.classList.remove('hl'), 2200);
|
||||
const sc = $('#sheetC'); if (sc && GE.sheet.detent === 'large') sc.scrollTo({ top: el.offsetTop - 80, behavior: 'smooth' });
|
||||
if (fly) { const p = RUI.room.posts.find((q) => q.id === id); if (p?.place) RUI.anchor(p, 'place', true); }
|
||||
};
|
||||
RUI.anchor = function (p, which, quiet) {
|
||||
const W = GE.world; const s = RUI.sit;
|
||||
if (p.place && which !== 'time') { const wide = APP.isWide(); const hs = GE.sheet.heights(); Cam.flyTo({ lat: p.place.lat - 0.12, lon: p.place.lon - 0.05, range: 0.03, heading: 0.4, tilt: 0.95, shiftY: wide ? 0 : hs[GE.sheet.detent === 'large' ? 'medium' : GE.sheet.detent] / hs.H, shiftX: 0 }, { duration: 1.8 }); GE.roomUI.labels(s, p.id); }
|
||||
const at = p.time || p.at; if (which !== 'place') W.setTime(at);
|
||||
if (!quiet) APP.toast(`${ic(p.place && which !== 'time' ? 'location' : 'clock', 'sm')} ${p.place && which !== 'time' ? esc(p.place.label) + ' · ' : ''}${fmt.hm(at)} UTC · ${fmt.ago(Date.now() - at)}`);
|
||||
if (GE.sheet.detent === 'large' && !APP.isWide()) GE.sheet.setDetent('medium');
|
||||
haptic(5);
|
||||
};
|
||||
RUI.cite = function (c) {
|
||||
const room = RUI.room; if (!room) return;
|
||||
if (c[0] === 'P') { const p = GE.rooms.byPid(room, c); if (p) { if (GE.sheet.detent !== 'large' && !APP.isWide()) GE.sheet.setDetent('large'); setTimeout(() => RUI.focusPost(p.id), 120); } return; }
|
||||
if (c[0] === 'N') { const p = room.posts.find((q) => q.note); if (p) { if (GE.sheet.detent !== 'large' && !APP.isWide()) GE.sheet.setDetent('large'); setTimeout(() => RUI.focusPost(p.id), 120); } return; }
|
||||
APP.toast(`${ic('hash', 'sm')} ${c} · ${esc((room.src || {})[c] || 'observation')}`, 2600);
|
||||
};
|
||||
// speech glyphs on the globe for anchored posts
|
||||
RUI.labels = function (s, hl) {
|
||||
const room = GE.rooms.get(s.id); for (const k of [...GE.extraLabels.keys()]) if (k.startsWith('p:')) GE.extraLabels.delete(k);
|
||||
for (const p of room.posts) { if (!p.place) continue; const who = GE.PEOPLE[p.by] || { h: p.by }; GE.extraLabels.set('p:' + p.id, { lat: p.place.lat, lon: p.place.lon, alt: 0.03, html: `<i class="sg k-${p.kind}">${ic(KIND_ICON[p.kind], 'xs')}</i><span>@${esc(who.h)}</span>`, w: who.h.length * 6.4 + 46, cls: 'glass speech' + (p.id === hl ? ' hl' : ''), onclick: () => { if (GE.sheet.detent === 'peek') GE.sheet.setDetent('medium'); RUI.focusPost(p.id); } }); }
|
||||
};
|
||||
/* presence & reactions */
|
||||
RUI.float = function (icon) { const host = $('#rx'); if (!host) return; const sp = h('span', { html: ic(icon || ['heart', 'eye', 'heart', 'exclamation', 'bolt'][Math.floor(Math.random() * 5)], 'sm'), style: `--dx:${(Math.random() - 0.5) * 30}px` }); sp.dataset.i = icon || ''; host.append(sp); setTimeout(() => sp.remove(), 2700); };
|
||||
setInterval(() => { if (APP.mode === 'situation' && GE.sheet.seg === 'room' && !document.hidden) { if (Math.random() < 0.7) RUI.float(); const w = $('#sheetC [data-watch]'); if (w && RUI.room) w.textContent = fmt.n(RUI.room.watching); } }, 900);
|
||||
|
||||
/* composer: Capture first, text second */
|
||||
RUI.composer = function (show) {
|
||||
const c = $('#roomComp'); if (!c) return; c.classList.toggle('on', !!show); if (!show) return;
|
||||
if (c.dataset.bound) return; c.dataset.bound = '1';
|
||||
c.innerHTML = `<button class="capbtn" id="rcCap" aria-label="Reply with a capture">${ic('viewfinder')}<span>Capture</span></button><div class="rcin glass"><div class="kinds" id="rcKinds">${Object.entries(GE.rooms.KINDS).map(([k, K]) => `<button data-k="${k}" class="${k === 'context' ? 'on' : ''}">${ic(KIND_ICON[k], 'xs')}${K.name}</button>`).join('')}</div><div class="rcrow"><input id="rcText" placeholder="Add context, ask, correct…" autocomplete="off"><button class="send" id="rcSend" aria-label="Post">${ic('arrowup', 'sm')}</button></div></div>`;
|
||||
let kind = 'context';
|
||||
c.querySelectorAll('[data-k]').forEach((b) => b.addEventListener('click', () => { kind = b.dataset.k; c.querySelectorAll('[data-k]').forEach((x) => x.classList.toggle('on', x === b)); haptic(3); }));
|
||||
$('#rcCap').addEventListener('click', () => RUI.replyCapture(RUI.sit));
|
||||
const inp = $('#rcText'); inp.addEventListener('focus', () => c.classList.add('typing')); inp.addEventListener('blur', () => setTimeout(() => c.classList.remove('typing'), 150));
|
||||
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); $('#rcSend').click(); } });
|
||||
$('#rcSend').addEventListener('click', async () => {
|
||||
const t = inp.value.trim(); if (!t || !RUI.room) return; inp.value = '';
|
||||
const p = { id: 'u' + Date.now(), kind, by: 'you', at: Date.now(), text: t, mine: true, reacts: {}, replies: 0 };
|
||||
RUI.room.posts.push(p); const e = await GE.rooms.append(RUI.room, 'post.' + kind, 'you', { id: p.id, text: t });
|
||||
RUI.render($('#shSeg'), RUI.sit); setTimeout(() => RUI.focusPost(p.id), 80);
|
||||
APP.toast(`${ic('link', 'sm')} Posted · ledger #${e.i} · text ranks below evidence`, 2600); haptic(8);
|
||||
});
|
||||
};
|
||||
RUI.replyCapture = function (s, p) {
|
||||
GE.capture.replyTo = { sit: s.id, post: p?.id };
|
||||
APP.toast(`${ic('viewfinder', 'sm')} Capture a reply for ${esc(s.short || s.title.split(' — ')[0])}`, 2000);
|
||||
GE.sheet.close(true); APP.setTab('capture'); GE.capture.setMode('witness');
|
||||
};
|
||||
|
||||
/* AI synthesis: window.claude sample on click, scripted fallback; uncited lines are dropped */
|
||||
RUI.resynth = async function (room, s, o = {}) {
|
||||
const btn = $('#regen'); if (!btn || btn.disabled) return; btn.disabled = true; btn.classList.add('busy');
|
||||
const body = $('#syBody'); const foot = $('#syFoot');
|
||||
const posts = room.posts.map((p) => `[${GE.rooms.pid(room, p)}] ${GE.rooms.KINDS[p.kind].name} by @${(GE.PEOPLE[p.by] || { h: p.by }).h} (${GE.rooms.badges(room, p).map((b) => b[1]).join(', ') || 'no evidence badges'}), ${fmt.ago(Date.now() - p.at)}: ${p.text}${p.note ? ` — Community note [N1]: ${p.note.text}` : ''}`).join('\n');
|
||||
const srcs = Object.entries(room.src || {}).map(([k, v]) => `[${k}] ${v}`).join('\n');
|
||||
const prompt = `You summarise a live discussion about a real-world situation for an app that separates evidence from claims.\nSituation: ${s.title} — ${s.place}. Posts and people are illustrative.\nObservations:\n${srcs}\nPosts:\n${posts}\nWrite at most 6 lines, each starting with exactly one of "KNOWN:", "DISPUTED:" or "UNKNOWN:". Every line must end with one or more citations in square brackets like [P1] [O2] [N1]. Prefer sealed witness posts and observations over unverified posts. Never state anything you cannot cite. Plain text, no markdown.`;
|
||||
body.innerHTML = '<div class="sy kn"><b>WRITING</b><p class="stream" id="syStream">…</p></div>';
|
||||
let out = '', model = 'scripted fallback (no AI available here)';
|
||||
const show = (t) => { out = t; const el = $('#syStream'); if (el) el.textContent = t || '…'; };
|
||||
let sample = null; if (!o.auto) { try { sample = window.claude?.use ? await window.claude.use('sample') : null; } catch { sample = null; } }
|
||||
if (o.auto) model = 'auto-update (scripted) · tap Re-synthesize to ask AI';
|
||||
if (sample) { try { const r = await sample(prompt, { onText: ({ text }) => show(text) }); show(r?.text ?? out); model = 'claude · window.claude sample'; } catch { sample = null; } }
|
||||
if (!sample) {
|
||||
const S = room.synth; const lines = [...S.known.map(([t, c]) => `KNOWN: ${t} ${c.map((x) => `[${x}]`).join(' ')}`), ...S.disputed.map(([t, c]) => `DISPUTED: ${t} ${c.map((x) => `[${x}]`).join(' ')}`), ...S.unknown.map(([t, c]) => `UNKNOWN: ${t} ${c.map((x) => `[${x}]`).join(' ')}`)];
|
||||
const mine = room.posts.filter((p) => p.mine && p.att); if (mine.length) { const p = mine[mine.length - 1]; lines.splice(1, 0, `KNOWN: A new ${p.att.tier.startsWith('sealed') ? 'sealed' : 'unverified'} capture from @you at ${p.place?.label || 'the scene'} adds a view of the situation. [${GE.rooms.pid(room, p)}]`); }
|
||||
lines.push('UNKNOWN: Whether conditions improve in the next hour — the posts do not say.');
|
||||
const full = lines.join('\n'); for (let i = 1; i <= full.length; i += 4) { show(full.slice(0, i)); await new Promise((r) => setTimeout(r, prefersReduced() ? 0 : 8)); } show(full);
|
||||
}
|
||||
// parse: keep only cited lines
|
||||
const next = { known: [], disputed: [], unknown: [] }; let dropped = 0;
|
||||
for (const ln of out.split('\n')) { const m = /^\s*(KNOWN|DISPUTED|UNKNOWN)\s*:\s*(.+)$/i.exec(ln); if (!m) continue; const cites = [...m[2].matchAll(/\[([PONS]\d+)\]/g)].map((x) => x[1]); const text = m[2].replace(/\s*\[[^\]]+\]/g, '').trim(); if (!cites.length) { dropped++; continue; } next[m[1].toLowerCase()].push([text, [...new Set(cites)]]); }
|
||||
if (next.known.length + next.disputed.length + next.unknown.length) room.synth = next;
|
||||
room.dropped = dropped; room.synthModel = model;
|
||||
const e = await GE.rooms.append(room, 'ai.synthesis', 'claude', { prompt: await sha256(prompt), inputs: room.posts.map((p) => GE.rooms.pid(room, p)).concat(Object.keys(room.src || {})), model, output: await sha256(out), dropped });
|
||||
room.synthEntry = e;
|
||||
const wrap = $('#synth'); if (wrap) { wrap.outerHTML = RUI.synthHTML(room); $('#regen')?.addEventListener('click', () => RUI.resynth(room, s)); $('#synth').querySelectorAll('[data-cite]').forEach((b) => b.addEventListener('click', (ev) => { ev.stopPropagation(); RUI.cite(b.dataset.cite); })); }
|
||||
APP.toast(`${ic('sparkles', 'sm')} Synthesis recorded · ledger #${e.i}${dropped ? ' · ' + dropped + ' uncited line dropped' : ''}`, 2800);
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
/* Live, Upload and the Attach sheet.
|
||||
Live: a simulated broadcast (rendered lakefront in a gale) that seals a segment every 10 s into a real
|
||||
SHA-256 hash chain signed with the device key; you appear on the globe as an eye with a view cone.
|
||||
Upload: a real file input; EXIF GPS/time are read with a tiny parser; you place it on a map; it is
|
||||
labelled Unverified everywhere. Attach: suggests where it belongs, kind, precision, hologram; posting
|
||||
lands in the room, the feed and on the globe. */
|
||||
const LV = (GE.live = { on: false, segs: [], viewers: 0 });
|
||||
GE.capture.live = LV;
|
||||
const CAPm = GE.capture; const SPOTm = CAPm.spot;
|
||||
/* ---------- eyes on the globe (people and cameras that are looking right now) ---------- */
|
||||
GE.EYES = [
|
||||
{ id: 'basco', lat: 20.4485, lon: 121.9705, hdg: 205, fov: 64, label: '@nadia_basco', kind: 'witness', live: true, sit: 'cyclone', post: 'cy1' },
|
||||
{ id: 'kaoh', lat: 22.612, lon: 120.268, hdg: 250, fov: 72, label: 'HBR-07', kind: 'cctv', sit: 'cyclone', post: 'cy5' },
|
||||
{ id: 'shib', lat: 35.6597, lon: 139.7002, hdg: 135, fov: 84, label: 'CAM 0412', kind: 'cctv', sit: 'shibuya' },
|
||||
{ id: 'ines', lat: 40.13, lon: -8.05, hdg: 80, fov: 60, label: '@ines_coimbra', kind: 'witness', sit: 'fires', post: 'fi1' },
|
||||
{ id: 'ola', lat: 69.649, lon: 18.957, hdg: 10, fov: 74, label: '@ola_tromso', kind: 'witness', sit: 'aurora' },
|
||||
{ id: 'sak', lat: 31.597, lon: 130.56, hdg: 95, fov: 48, label: 'VOLCANO CAM 2', kind: 'cctv', sit: 'sakurajima' },
|
||||
{ id: 'rafa', lat: 18.357, lon: 121.637, hdg: 40, fov: 60, label: '@rafa_aparri', kind: 'witness', sit: 'cyclone', post: 'cy6' },
|
||||
];
|
||||
GE.eyeLines = function (rangeR) {
|
||||
const out = []; const D = clamp((rangeR || 1) * EARTH_KM * 0.07, 0.25, 420);
|
||||
for (const e of GE.EYES) {
|
||||
const col = e.kind === 'you' ? [1, 0.86, 0.5, 0.95] : e.kind === 'cctv' ? [1, 0.66, 0.26, 0.7] : [0.96, 0.82, 0.48, 0.8];
|
||||
const tip = geo.vec(e.lat, e.lon, 0.03 + D * 0.004); const half = (e.fov / 2) * D2R; const h0 = e.hdg * D2R; const cs = [];
|
||||
for (const [da, z] of [[-half, 0], [half, 0], [half, D * 0.32], [-half, D * 0.32]]) { const [la, lo] = geo.dest(e.lat, e.lon, h0 + da, D); cs.push(geo.vec(la, lo, z)); }
|
||||
for (const c of cs) out.push({ pts: [tip, c], col });
|
||||
out.push({ pts: [...cs, cs[0]], col: [col[0], col[1], col[2], col[3] * 0.8] });
|
||||
const mid = geo.dest(e.lat, e.lon, h0, D); out.push({ pts: [tip, geo.vec(mid[0], mid[1], D * 0.16)], col: [col[0], col[1], col[2], 0.35], dashed: 1 });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/* ---------- Live ---------- */
|
||||
const CHAT_LIVE = [['amara', 'Same at Oak Street — the trail is underwater'], ['kora', 'Stay back from the edge please 🙏'], ['sky', 'That spray must be 8 m'], ['geo', 'Buoy says 3.4 m waves, matches what we see'], ['hydro', 'Is the harbour gate closed?'], ['amara', 'Streaming this into the gale room'], ['ais', 'Two freighters sheltering off the breakwater'], ['kora', 'The sealed badge is so reassuring tbh']];
|
||||
const nearestSits = (lat, lon, n = 3) => GE.SIT.list.filter((s) => !s.dyn).map((s) => ({ s, km: RM.kmBetween(lat, lon, s.lat, s.lon) })).sort((a, b) => a.km - b.km).slice(0, n);
|
||||
LV.renderUI = function (host) {
|
||||
const near = nearestSits(SPOTm.lat, SPOTm.lon, 2); LV.sit = near[0].s;
|
||||
if (!LV.on) {
|
||||
host.innerHTML = `<div class="lv-pre glass strong"><div class="lvph"><span class="lvpill pre"><i></i>READY</span><b>Go live as a sealed eye</b></div>
|
||||
<p>Your camera becomes a sensor on the globe. Every 10 seconds the stream is sealed into a hash chain and signed on this device, with GPS, heading and motion. Rooms nearby can watch.</p>
|
||||
<div class="lvnear">${near.map(({ s, km }) => `<span>${ic('threads', 'xs')}${esc(s.short || s.title.split(' — ')[0])} · ${GE.feed.fmtDist(km)}</span>`).join('')}</div>
|
||||
<div class="lvfine mono">SIMULATED CAMERA · RENDERED SCENE · NO CAMERA OR MIC IS USED</div></div>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = `<div class="lv-top"><span class="lvpill"><i></i>LIVE <b class="num" id="lvT">00:00</b></span><span class="lvv glass">${ic('eye', 'xs')}<b class="num" id="lvV">${LV.viewers}</b></span><span class="lvseal">${ic('seal', 'xs')}SEALED LIVE</span></div>
|
||||
<div class="lv-to glass">${ic('threads', 'xs')}<span>In the <b>${esc(LV.sit.short || LV.sit.title)}</b> room · ${GE.feed.fmtDist(RM.kmBetween(SPOTm.lat, SPOTm.lon, LV.sit.lat, LV.sit.lon))}</span></div>
|
||||
<div class="lv-chat" id="lvChat"></div><div class="lvrx" id="lvRx"></div>
|
||||
<div class="lv-tele mono" id="lvTele"></div><div class="lv-chain" id="lvChain"></div>`;
|
||||
LV.drawChain(); LV.chatRestore();
|
||||
};
|
||||
LV.start = async function () {
|
||||
if (LV.on) return; await GE.crypto.KEY.get();
|
||||
Object.assign(LV, { on: true, t0: Date.now(), segT: Date.now(), segs: [], viewers: 3, prev: '0'.repeat(64), chat: [], hdg: 64 });
|
||||
GE.EYES.push({ id: 'you', lat: SPOTm.lat, lon: SPOTm.lon, hdg: LV.hdg, fov: 62, label: 'You · LIVE', kind: 'you', live: true, sit: LV.sit?.id });
|
||||
LV.timer = setInterval(() => LV.seal(), 10000); LV.soc = setInterval(() => LV.social(), 1500);
|
||||
haptic([10, 30, 10]); CAPm.render(); APP.renderAccessory(); APP.toast(`${ic('live', 'sm')} You’re live · first segment seals in 10 s`);
|
||||
};
|
||||
LV.stop = function () {
|
||||
if (!LV.on) return; clearInterval(LV.timer); clearInterval(LV.soc); LV.on = false; LV.t1 = Date.now();
|
||||
const me = GE.EYES.find((e) => e.id === 'you'); if (me) { me.live = false; me.label = 'You · replay'; }
|
||||
CAPm.render(); APP.renderAccessory();
|
||||
const seg = LV.segs[LV.segs.length - 1];
|
||||
GE.attach.open({ kind: 'witness', tier: 'sealed-live', img: LV.posterImg || seg?.thumb, lat: SPOTm.lat, lon: SPOTm.lon, at: LV.t0, place: SPOTm.name, segs: LV.segs.length, dur: (LV.t1 - LV.t0) / 1000, hash: seg?.hash, hdg: LV.hdg, live: true });
|
||||
};
|
||||
LV.seal = async function () {
|
||||
const cv = $('#liveCv'); const small = GE.paint.mk(320, 180);
|
||||
if (cv && cv.width) small.getContext('2d').drawImage(cv, 0, 0, 320, 180); else GE.paint.draw('seawall', small, { skyline: true, noOSD: true, seed: 53, t: performance.now() / 1000 });
|
||||
const blob = await new Promise((r) => small.toBlob(r, 'image/jpeg', 0.7)); const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const seg = { i: LV.segs.length, t0: LV.segT, t1: Date.now(), frame: await sha256(bytes), bytes: bytes.length, gps: { lat: +SPOTm.lat.toFixed(6), lon: +SPOTm.lon.toFixed(6), acc: 4 }, hdg: LV.hdg, imu: 500, prev: LV.prev };
|
||||
seg.hash = await sha256(canon(seg)); seg.sig = await GE.crypto.signHex(seg.hash); seg.thumb = small.toDataURL('image/jpeg', 0.6);
|
||||
if (!LV.posterImg) { const pc = GE.paint.mk(480, 300); pc.getContext('2d').drawImage(cv || small, 0, 0, 480, 300); LV.posterImg = pc.toDataURL('image/jpeg', 0.8); }
|
||||
LV.segs.push(seg); LV.prev = seg.hash; LV.segT = seg.t1; haptic(6);
|
||||
LV.drawChain(true); APP.renderAccessory();
|
||||
};
|
||||
LV.drawChain = function (flash) {
|
||||
const host = $('#lvChain'); if (!host) return;
|
||||
const blocks = LV.segs.map((s) => `<div class="seg ok" title="segment ${s.i} · ${s.hash}"><span class="si mono">#${String(s.i + 1).padStart(3, '0')}</span><img src="${s.thumb}" alt=""><span class="sh mono">${s.hash.slice(0, 8)}</span><span class="sk">${ic('seal', 'xs')}${s.sig ? 'signed' : 'hashed'}</span></div>`).join('<i class="ln"></i>');
|
||||
host.innerHTML = `${blocks}${LV.segs.length ? '<i class="ln"></i>' : ''}<div class="seg cur"><span class="si mono">#${String(LV.segs.length + 1).padStart(3, '0')}</span><div class="fill"><i id="lvFill"></i></div><span class="sh mono">sealing…</span></div>`;
|
||||
host.scrollLeft = host.scrollWidth; if (flash) host.lastElementChild.previousElementSibling?.previousElementSibling?.classList.add('flash');
|
||||
};
|
||||
LV.social = function () {
|
||||
if (!LV.on) return; LV.viewers += 1 + Math.floor(Math.random() * (LV.viewers > 60 ? 18 : 6));
|
||||
const chat = $('#lvChat'); const k = Math.floor((Date.now() - LV.t0) / 1500);
|
||||
const ev = k % 3 === 0 ? { join: ['amara', 'kora', 'sky', 'geo', 'hydro', 'ais', 'ola'][k % 7] } : { msg: CHAT_LIVE[k % CHAT_LIVE.length] };
|
||||
LV.chat.push(ev); if (LV.chat.length > 4) LV.chat.shift(); if (chat) LV.chatRestore();
|
||||
const rx = $('#lvRx'); if (rx) for (let i = 0; i < 2; i++) { const sp = h('span', { html: ic(['heart', 'eye', 'heart', 'bolt'][Math.floor(Math.random() * 4)], 'sm'), style: `--dx:${(Math.random() - 0.5) * 30}px` }); rx.append(sp); setTimeout(() => sp.remove(), 2700); }
|
||||
};
|
||||
LV.chatRestore = function () {
|
||||
const chat = $('#lvChat'); if (!chat) return;
|
||||
chat.innerHTML = LV.chat.map((e) => e.join ? `<div class="lj">${GE.avatarHTML(e.join)}<span><b>@${esc(GE.PEOPLE[e.join].h)}</b> joined</span></div>` : `<div class="bub">${GE.avatarHTML(e.msg[0])}<div class="bx glass"><b>@${esc(GE.PEOPLE[e.msg[0]].h)}</b>${esc(e.msg[1])}</div></div>`).join('');
|
||||
};
|
||||
LV.frame = function () {
|
||||
const cv = $('#liveCv'); if (!cv || CAPm.mode !== 'live') return;
|
||||
const W0 = Math.round(cv.clientWidth * 0.8), H0 = Math.round(cv.clientHeight * 0.8); if (!W0 || !H0) return;
|
||||
if (cv.width !== W0 || cv.height !== H0) { cv.width = W0; cv.height = H0; }
|
||||
const now = performance.now(); if (LV._lf && now - LV._lf < 40) return; LV._lf = now;
|
||||
GE.paint.draw('seawall', cv, { skyline: true, handheld: true, noOSD: true, seed: 53, t: now / 1000 });
|
||||
if (!LV.on) return;
|
||||
const tEl = $('#lvT'); if (tEl) tEl.textContent = fmt.dur((Date.now() - LV.t0) / 1000).slice(3);
|
||||
const vEl = $('#lvV'); if (vEl) vEl.textContent = fmt.n(LV.viewers);
|
||||
const f = $('#lvFill'); if (f) f.style.width = clamp((Date.now() - LV.segT) / 10000, 0, 1) * 100 + '%';
|
||||
const te = $('#lvTele'); if (te) te.textContent = `${GE.feed.llz(SPOTm.lat, SPOTm.lon)} · HDG ${String(LV.hdg + Math.round(Math.sin(now / 900) * 3)).padStart(3, '0')}° · ±4 M · ALT 182 M · SEG ${String(LV.segs.length + 1).padStart(3, '0')} · ${GE.feed.hmsZ()}`;
|
||||
};
|
||||
LV.showOnGlobe = function () {
|
||||
const back = LV.on; APP.setTab('globe'); Cam.flyTo({ lat: SPOTm.lat - 0.035, lon: SPOTm.lon - 0.05, range: 0.016, heading: 0.75, tilt: 1.0, shiftY: 0.06, shiftX: 0 }, { duration: 2.4 });
|
||||
APP.toast(back ? `${ic('eye', 'sm')} That cone is you — still live` : `${ic('eye', 'sm')} Your view cone on the globe`, 2600);
|
||||
};
|
||||
|
||||
/* ---------- EXIF (read + write) ---------- */
|
||||
const EX = (GE.exif = {});
|
||||
EX.parse = function (buf) {
|
||||
try {
|
||||
const v = new DataView(buf); if (v.getUint16(0) !== 0xffd8) return null; let o = 2;
|
||||
while (o < v.byteLength - 4) { const m = v.getUint16(o); if ((m & 0xff00) !== 0xff00) break; const len = v.getUint16(o + 2); if (m === 0xffe1 && v.getUint32(o + 4) === 0x45786966) return EX.tiff(v, o + 10); if (m === 0xffda) break; o += 2 + len; }
|
||||
} catch (e) { console.warn('exif', e); }
|
||||
return null;
|
||||
};
|
||||
EX.tiff = function (v, t) {
|
||||
const le = v.getUint16(t) === 0x4949; const u16 = (p) => v.getUint16(p, le), u32 = (p) => v.getUint32(p, le);
|
||||
const ifd = (p) => { const n = u16(p); const e = {}; for (let i = 0; i < n; i++) { const q = p + 2 + i * 12; e[u16(q)] = { type: u16(q + 2), count: u32(q + 4), off: q + 8 }; } return e; };
|
||||
const val = (en) => {
|
||||
if (!en) return null; const { type, count } = en; const size = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 }[type] || 1; const p = size * count > 4 ? t + u32(en.off) : en.off;
|
||||
if (type === 2) { let s = ''; for (let i = 0; i < count; i++) { const c = v.getUint8(p + i); if (!c) break; s += String.fromCharCode(c); } return s.trim(); }
|
||||
if (type === 5 || type === 10) { const out = []; for (let i = 0; i < count; i++) { const a = type === 5 ? u32(p + i * 8) : v.getInt32(p + i * 8, le), b = type === 5 ? u32(p + i * 8 + 4) : v.getInt32(p + i * 8 + 4, le); out.push(b ? a / b : 0); } return count === 1 ? out[0] : out; }
|
||||
if (type === 3) return u16(p); if (type === 4) return u32(p); return v.getUint8(p);
|
||||
};
|
||||
const i0 = ifd(t + u32(t + 4)); const out = { make: val(i0[0x010f]), model: val(i0[0x0110]), date: val(i0[0x0132]) };
|
||||
if (i0[0x8769]) { const ex = ifd(t + val(i0[0x8769])); out.date = val(ex[0x9003]) || out.date; }
|
||||
if (i0[0x8825]) { const g = ifd(t + val(i0[0x8825])); const dms = (a) => (a && a.length === 3 ? a[0] + a[1] / 60 + a[2] / 3600 : null); const la = dms(val(g[2])), lo = dms(val(g[4])); if (la != null && lo != null) { out.lat = la * (val(g[1]) === 'S' ? -1 : 1); out.lon = lo * (val(g[3]) === 'W' ? -1 : 1); } const al = val(g[6]); if (al != null) out.alt = al; }
|
||||
return out;
|
||||
};
|
||||
EX.when = (s) => { const m = /^(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)/.exec(s || ''); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]) : null; };
|
||||
// write a little-endian TIFF/EXIF block with Make, Model, DateTimeOriginal and GPS (used by the sample)
|
||||
EX.build = function ({ make, model, date, lat, lon, alt }) {
|
||||
const B = []; const u8 = (x) => B.push(x & 255), u16 = (x) => { u8(x); u8(x >> 8); }, u32 = (x) => { u16(x & 0xffff); u16(x >>> 16); };
|
||||
const str = (s) => { const a = [...new TextEncoder().encode(s), 0]; if (a.length % 2) a.push(0); return a; };
|
||||
const mk = str(make), md = str(model), dt = str(date);
|
||||
const IFD0 = 8, D0 = IFD0 + 2 + 5 * 12 + 4, pMk = D0, pMd = pMk + mk.length, pDt = pMd + md.length, EXIF = pDt + dt.length, pDo = EXIF + 2 + 12 + 4, GPS = pDo + dt.length, pLa = GPS + 2 + 7 * 12 + 4, pLo = pLa + 24, pAl = pLo + 24;
|
||||
const ent = (tag, type, count, v) => { u16(tag); u16(type); u32(count); u32(v); };
|
||||
const asc2 = (c) => c.charCodeAt(0);
|
||||
u16(0x4949); u16(42); u32(IFD0);
|
||||
u16(5); ent(0x010f, 2, mk.length - (mk[mk.length - 1] === 0 && mk[mk.length - 2] === 0 ? 1 : 0), pMk); ent(0x0110, 2, md.length - (md[md.length - 1] === 0 && md[md.length - 2] === 0 ? 1 : 0), pMd); ent(0x0132, 2, 20, pDt); ent(0x8769, 4, 1, EXIF); ent(0x8825, 4, 1, GPS); u32(0);
|
||||
B.push(...mk, ...md, ...dt);
|
||||
u16(1); ent(0x9003, 2, 20, pDo); u32(0); B.push(...dt);
|
||||
u16(7); ent(0, 1, 4, 0x00000302); ent(1, 2, 2, asc2(lat >= 0 ? 'N' : 'S')); ent(2, 5, 3, pLa); ent(3, 2, 2, asc2(lon >= 0 ? 'E' : 'W')); ent(4, 5, 3, pLo); ent(5, 1, 1, 0); ent(6, 5, 1, pAl); u32(0);
|
||||
const rat = (x) => { const d = Math.floor(x), mm = Math.floor((x - d) * 60), ss = Math.round(((x - d) * 60 - mm) * 60 * 100); u32(d); u32(1); u32(mm); u32(1); u32(ss); u32(100); };
|
||||
rat(Math.abs(lat)); rat(Math.abs(lon)); u32(Math.round((alt || 0) * 10)); u32(10);
|
||||
const tiff = new Uint8Array(B); const seg = new Uint8Array(4 + 6 + tiff.length); const L = 2 + 6 + tiff.length;
|
||||
seg.set([0xff, 0xe1, L >> 8, L & 255, 0x45, 0x78, 0x69, 0x66, 0, 0]); seg.set(tiff, 10); return seg;
|
||||
};
|
||||
EX.inject = (jpeg, app1) => { const out = new Uint8Array(jpeg.length + app1.length); out.set(jpeg.subarray(0, 2)); out.set(app1, 2); out.set(jpeg.subarray(2), 2 + app1.length); return out; };
|
||||
const exifDate = (ms) => { const d = new Date(ms); const z = (n) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}:${z(d.getUTCMonth() + 1)}:${z(d.getUTCDate())} ${z(d.getUTCHours())}:${z(d.getUTCMinutes())}:${z(d.getUTCSeconds())}`; };
|
||||
|
||||
/* ---------- Upload ---------- */
|
||||
const UP = (GE.upload = { f: null });
|
||||
UP.renderUI = function (host) {
|
||||
const f = UP.f;
|
||||
if (!f) {
|
||||
host.innerHTML = `<div class="up glass strong"><div class="uph"><b>Add a photo or video of a place</b><span class="tierb unv">UNVERIFIED</span></div>
|
||||
<p>Uploads weren’t sealed when they were taken, so they carry an <b>Unverified</b> label everywhere. A hypothesis can cite one, but it can never become an observation.</p>
|
||||
<label class="drop"><input type="file" id="upFile" accept="image/*,video/*" hidden>${ic('gallery', 'lg')}<b>Choose from your library</b><span>A JPEG with location in its EXIF places itself</span></label>
|
||||
<button class="btn sm" id="upSample" style="width:100%">${ic('sparkles', 'sm')}No photo handy? Use a sample with GPS in its EXIF</button>
|
||||
<p class="fine mono">READ LOCALLY WITH FILEREADER · NOTHING LEAVES THIS PAGE</p></div>`;
|
||||
$('#upFile').addEventListener('change', (e) => { const file = e.target.files && e.target.files[0]; if (file) UP.load(file); });
|
||||
$('#upSample').addEventListener('click', () => UP.sample());
|
||||
return;
|
||||
}
|
||||
const ex = f.exif || {}; const has = f.lat != null;
|
||||
host.innerHTML = `<div class="up glass strong loaded"><div class="upimg"><img src="${f.img}" alt="Your upload"><span class="tierb unv">UNVERIFIED</span></div>
|
||||
<div class="exif mono"><div><span>FILE</span><b>${esc(f.name)} · ${fmt.k(f.size / 1024)} KB</b></div><div><span>CAMERA</span><b>${esc([ex.make, ex.model].filter(Boolean).join(' ') || '—')}</b></div><div><span>TAKEN</span><b>${ex.date ? esc(ex.date) : '—'}</b></div><div><span>GPS</span><b>${ex.lat != null ? GE.feed.llz(ex.lat, ex.lon) + ' · from EXIF' : 'none in file — place it'}</b></div></div>
|
||||
<div class="placer"><canvas id="upMap" width="640" height="300"></canvas><div class="pin" id="upPin">${ic('location', 'sm')}</div><div class="hint mono" id="upLL"></div></div>
|
||||
<div class="uptime"><span>When</span><input type="range" id="upT" min="-4320" max="0" step="5" value="${Math.round(((f.at || Date.now()) - Date.now()) / 60000)}"><b class="num" id="upTv"></b></div>
|
||||
<div class="btnrow"><button class="btn sm" id="upRe">Choose another</button><button class="btn gold sm" id="upGo">${ic('threads', 'sm')}Continue</button></div></div>`;
|
||||
UP.drawMap(); UP.bindPin();
|
||||
const t = $('#upT'); const tv = $('#upTv'); const upd = () => { f.at = Date.now() + +t.value * 60000; tv.textContent = `${fmt.hm(f.at)} UTC · ${fmt.ago(Date.now() - f.at)}`; }; t.addEventListener('input', upd); upd();
|
||||
$('#upRe').addEventListener('click', () => { UP.f = null; UP.renderUI(host); });
|
||||
$('#upGo').addEventListener('click', () => GE.attach.open({ kind: 'witness', tier: 'unverified', img: f.img, lat: f.lat, lon: f.lon, at: f.at, place: f.name, upload: true }));
|
||||
};
|
||||
UP.load = function (file) {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const buf = r.result; const ex = file.type === 'image/jpeg' || /\.jpe?g$/i.test(file.name) ? EX.parse(buf) : null;
|
||||
const f = { name: file.name || 'upload', size: file.size, type: file.type, exif: ex, lat: ex?.lat ?? null, lon: ex?.lon ?? null, at: EX.when(ex?.date) || file.lastModified || Date.now() };
|
||||
if (f.lat == null) { f.lat = GE.ME.lat; f.lon = GE.ME.lon; f.guess = true; }
|
||||
const url = URL.createObjectURL(file);
|
||||
const done = (cv) => { f.img = cv.toDataURL('image/jpeg', 0.82); UP.f = f; URL.revokeObjectURL(url); UP.renderUI($('#modeHost')); APP.toast(ex?.lat != null ? `${ic('location', 'sm')} Placed from EXIF GPS · ${GE.feed.llz(f.lat, f.lon)}` : `${ic('location', 'sm')} No GPS in this file — drag the pin`, 2600); };
|
||||
if (file.type.startsWith('video')) { const v = document.createElement('video'); v.muted = true; v.playsInline = true; v.src = url; v.addEventListener('loadeddata', () => { v.currentTime = Math.min(0.5, v.duration / 2 || 0); }, { once: true }); v.addEventListener('seeked', () => { const cv = GE.paint.mk(640, Math.round(640 * (v.videoHeight / v.videoWidth || 0.56))); cv.getContext('2d').drawImage(v, 0, 0, cv.width, cv.height); done(cv); }, { once: true }); v.addEventListener('error', () => APP.toast('Could not read that video here'), { once: true }); return; }
|
||||
const im = new Image(); im.onload = () => { const sc = Math.min(1, 1280 / Math.max(im.width, im.height)); const cv = GE.paint.mk(im.width * sc, im.height * sc); cv.getContext('2d').drawImage(im, 0, 0, cv.width, cv.height); done(cv); }; im.onerror = () => APP.toast('Could not read that image'); im.src = url;
|
||||
};
|
||||
r.onerror = () => APP.toast('Could not read that file'); r.readAsArrayBuffer(file);
|
||||
};
|
||||
UP.sample = async function () {
|
||||
const cv = GE.paint.mk(1200, 900); GE.paint.draw('volcano', cv, { noOSD: true, t: 1 });
|
||||
const blob = await new Promise((r) => cv.toBlob(r, 'image/jpeg', 0.86)); const jpg = new Uint8Array(await blob.arrayBuffer());
|
||||
const app1 = EX.build({ make: 'Concept', model: 'Sample Phone', date: exifDate(Date.now() - 23 * 60000), lat: 31.5937, lon: 130.5626, alt: 4.2 });
|
||||
const file = new File([EX.inject(jpg, app1)], 'IMG_4021.JPG', { type: 'image/jpeg', lastModified: Date.now() - 20 * 60000 }); UP.load(file);
|
||||
};
|
||||
UP.drawMap = function () {
|
||||
const f = UP.f; const cv = $('#upMap'); if (!cv) return; const span = 4.2; const mv = GE.paint.mapView(f.lat, f.lon, span, cv.width, cv.height); UP.mv = mv;
|
||||
const ctx = cv.getContext('2d'); GE.paint.mapBase(ctx, mv, { lights: 1.1, relief: 0.8, land: '#18202b', sea: '#05080c', coast: '#5b6e86' }); GE.paint.graticule(ctx, mv, 1);
|
||||
for (const { s, km } of nearestSits(f.lat, f.lon, 6)) { const x = mv.X(s.lon), y = mv.Y(s.lat); if (x < 0 || y < 0 || x > cv.width || y > cv.height) continue; ctx.fillStyle = '#FF6A3D'; ctx.beginPath(); ctx.arc(x, y, 6, 0, TAU); ctx.fill(); GE.paint.label(ctx, (s.short || s.title).toUpperCase(), x + 10, y, 15, '#fff'); }
|
||||
GE.paint.scaleBar(ctx, mv, 50, 18, cv.height - 18);
|
||||
};
|
||||
UP.bindPin = function () {
|
||||
const f = UP.f; const pin = $('#upPin'), cv = $('#upMap'); const place = () => { const r = cv.getBoundingClientRect(); const sx = r.width / cv.width; pin.style.left = UP.mv.X(f.lon) * sx + 'px'; pin.style.top = UP.mv.Y(f.lat) * sx + 'px'; $('#upLL').textContent = `${GE.feed.llz(f.lat, f.lon)} · ${f.guess ? 'GUESSED — DRAG ME' : f.moved ? 'PLACED BY YOU' : 'FROM EXIF'}`; };
|
||||
requestAnimationFrame(place); let drag = false;
|
||||
const mv = (e) => { if (!drag) return; const r = cv.getBoundingClientRect(); const sx = cv.width / r.width; const x = clamp((e.clientX - r.left) * sx, 0, cv.width), y = clamp((e.clientY - r.top) * sx, 0, cv.height); f.lat = UP.mv.lat(y); f.lon = UP.mv.lon(x); f.moved = true; f.guess = false; place(); };
|
||||
pin.addEventListener('pointerdown', (e) => { drag = true; pin.setPointerCapture(e.pointerId); e.preventDefault(); }); pin.addEventListener('pointermove', mv); pin.addEventListener('pointerup', () => { drag = false; haptic(4); });
|
||||
cv.addEventListener('click', (e) => { drag = true; mv(e); drag = false; });
|
||||
};
|
||||
|
||||
/* ---------- Attach sheet ---------- */
|
||||
const AT = (GE.attach = {});
|
||||
const bandOfFam = (f) => ({ orbit: 'orbit', sky: 'sky', wx: 'sky', eyes: 'ground' })[f] || 'ground';
|
||||
AT.open = function (c) {
|
||||
AT.c = c; const near = nearestSits(c.lat, c.lon, 3); const pre = CAPm.replyTo?.sit || (near[0].km < 400 ? near[0].s.id : 'new');
|
||||
AT.sel = pre; AT.kind = c.kind || 'witness'; AT.prec = c.upload ? '100' : 'exact'; AT.holo = !c.upload;
|
||||
const opts = [...(CAPm.replyTo && !near.some((n) => n.s.id === CAPm.replyTo.sit) ? [{ s: GE.SIT.byId[CAPm.replyTo.sit], km: RM.kmBetween(c.lat, c.lon, GE.SIT.byId[CAPm.replyTo.sit].lat, GE.SIT.byId[CAPm.replyTo.sit].lon) }] : []), ...near];
|
||||
const tier = c.tier === 'sealed-live' ? `SEALED LIVE · ${c.segs} SEGMENTS · ${fmt.dur(c.dur || 0).slice(3)}` : c.tier === 'sealed' ? 'SEALED · ECDSA P-256' : 'UNVERIFIED UPLOAD';
|
||||
const ov = $('#attachOv');
|
||||
ov.innerHTML = `<div class="att glass strong"><div class="grab"></div>
|
||||
<div class="ath"><div class="atimg"><img src="${c.img || ''}" alt=""><span class="tierb ${c.tier === 'unverified' ? 'unv' : ''}">${c.tier === 'unverified' ? '' : ic('seal', 'xs')}${tier}</span></div><div><h2>Where does this belong?</h2><p class="mono">${GE.feed.llz(c.lat, c.lon)} · ${fmt.hm(c.at)} UTC${c.hash ? ' · ' + c.hash.slice(0, 10) + '…' : ''}</p></div></div>
|
||||
<div class="ath2">Suggested from where and when it was taken</div>
|
||||
<div class="sugs">${opts.map(({ s, km }) => `<button class="sug ${AT.sel === s.id ? 'on' : ''}" data-s="${s.id}" style="--c:${GE.BANDS[bandOfFam(s.fam)].c}"><i></i><span><b>${esc(s.short || s.title.split(' — ')[0])}</b><small>${GE.feed.fmtDist(km)} away · ${s.live ? 'live now' : 'replay'} · ${fmt.k(GE.rooms.watching(s.id))} in room</small></span><em>${ic('check', 'xs')}</em></button>`).join('')}
|
||||
<button class="sug ${AT.sel === 'new' ? 'on' : ''}" data-s="new" style="--c:#fff"><i></i><span><b>New place</b><small>Start a room at ${GE.feed.llz(c.lat, c.lon)}</small></span><em>${ic('check', 'xs')}</em></button></div>
|
||||
<div class="ath2">Post as</div><div class="kinds2">${Object.entries(GE.rooms.KINDS).map(([k, K]) => `<button data-k="${k}" class="${AT.kind === k ? 'on' : ''}">${K.name}</button>`).join('')}</div>
|
||||
<textarea id="atNote" rows="2" placeholder="${c.live ? 'Waves are coming over the lakefront trail…' : 'What does this show? (optional)'}"></textarea>
|
||||
<div class="ath2">Share location as</div><div class="prec">${[['exact', 'Exact'], ['100', '100 m'], ['1000', '1 km']].map(([k, l]) => `<button data-p="${k}" class="${AT.prec === k ? 'on' : ''}">${l}</button>`).join('')}</div>
|
||||
<p class="fine2">${c.tier === 'unverified' ? 'An upload has no seal, so precision is all anyone gets.' : 'The seal still proves the exact point to verifiers without publishing it.'}</p>
|
||||
<div class="row2 holo"><span class="ri" style="--c:var(--b-inside)">${ic('cube')}</span><span class="rt">Leave it here as a hologram<small>People who come to this spot later can find it</small></span><button class="sw2 ${AT.holo ? 'on' : ''}" id="atHolo" role="switch" aria-label="Hologram"></button></div>
|
||||
<p class="fine2 priv">${ic('eyeslash', 'xs')} Faces and plates are blurred on-device before upload in the native build. This web concept does not blur — a disclosed limitation.</p>
|
||||
<div class="btnrow"><button class="btn" id="atX">Cancel</button><button class="btn gold" id="atGo">${ic('arrowup', 'sm')}Post</button></div></div>`;
|
||||
ov.classList.add('on');
|
||||
ov.querySelectorAll('[data-s]').forEach((b) => b.addEventListener('click', () => { AT.sel = b.dataset.s; ov.querySelectorAll('[data-s]').forEach((x) => x.classList.toggle('on', x === b)); AT.label(); haptic(4); }));
|
||||
ov.querySelectorAll('[data-k]').forEach((b) => b.addEventListener('click', () => { AT.kind = b.dataset.k; ov.querySelectorAll('[data-k]').forEach((x) => x.classList.toggle('on', x === b)); haptic(3); }));
|
||||
ov.querySelectorAll('[data-p]').forEach((b) => b.addEventListener('click', () => { AT.prec = b.dataset.p; ov.querySelectorAll('[data-p]').forEach((x) => x.classList.toggle('on', x === b)); haptic(3); }));
|
||||
$('#atHolo').addEventListener('click', (e) => { AT.holo = !AT.holo; e.currentTarget.classList.toggle('on', AT.holo); });
|
||||
$('#atX').addEventListener('click', () => ov.classList.remove('on'));
|
||||
$('#atGo').addEventListener('click', () => AT.post());
|
||||
AT.label();
|
||||
};
|
||||
AT.label = function () { const b = $('#atGo'); if (!b) return; const s = GE.SIT.byId[AT.sel]; b.innerHTML = `${ic('arrowup', 'sm')}Post to ${esc(s ? (s.short || s.title.split(' — ')[0]) : 'a new room')}`; };
|
||||
AT.post = async function () {
|
||||
const c = AT.c; let sitId = AT.sel;
|
||||
if (sitId === 'new') { sitId = 'mine-' + Date.now(); GE.SIT.list.push({ id: sitId, short: 'Your place', fam: 'eyes', kind: 'place', title: 'New place · ' + (c.place || 'your capture'), place: GE.feed.llz(c.lat, c.lon), lat: c.lat, lon: c.lon, camera: { lat: c.lat - 0.01, lon: c.lon - 0.01, range: 0.01, heading: 0.4, tilt: 1.0 }, layers: ['signed', 'holo', 'cctv'], source: 'You', live: true, watchers: 12, updatedAt: Date.now(), metric: () => ['', ''], metrics: () => [{ v: 1, u: '', l: 'Captures', s: 'You' }, { v: 0, u: '', l: 'Replies', s: '' }, { v: '—', u: '', l: 'Nearby rooms', s: '' }], brief: { lede: 'A place you started. Others nearby will see it.', series: [], sources: [] } }); GE.SIT.byId[sitId] = GE.SIT.list[GE.SIT.list.length - 1]; }
|
||||
const s = GE.SIT.byId[sitId]; const room = GE.rooms.get(sitId);
|
||||
const q = AT.prec === 'exact' ? 0 : AT.prec === '100' ? 0.001 : 0.01; const rd = (x) => (q ? Math.round(x / q) * q : x);
|
||||
const km = RM.kmBetween(c.lat, c.lon, s.lat, s.lon);
|
||||
const note = ($('#atNote').value || '').trim() || (c.live ? 'Live from the lakefront: waves are breaking over the trail and the path is closed.' : c.upload ? 'Photo from my library, placed from its EXIF.' : 'Sealed capture from here.');
|
||||
const p = { id: 'y' + Date.now(), kind: AT.kind, by: 'you', at: c.at || Date.now(), text: note, mine: true, place: { lat: rd(c.lat), lon: rd(c.lon), label: c.place || 'Your capture' }, att: { img: c.img, tier: c.tier, segs: c.segs, distKm: c.tier === 'unverified' ? null : Math.min(km, 4.9) }, reacts: {}, replies: 0, prec: AT.prec };
|
||||
if (CAPm.replyTo?.post) p.replyTo = CAPm.replyTo.post;
|
||||
room.posts.push(p); const e = await GE.rooms.append(room, 'post.' + AT.kind, 'you', { id: p.id, text: note, att: { tier: c.tier, hash: c.hash || null, segs: c.segs || 0 }, place: p.place, precision: AT.prec });
|
||||
// feed
|
||||
const tierT = c.tier === 'sealed-live' ? 'SEALED LIVE' : c.tier === 'sealed' ? 'SEALED' : 'UNVERIFIED';
|
||||
GE.feed.user.unshift({ id: 'f' + p.id, mine: true, arch: 'witness', band: 'inside', sit: sitId, post: p.id, by: 'you', img: c.img, aspect: 0.75, title: note.length > 90 ? note.slice(0, 88) + '…' : note, place: c.place || s.place, lat: c.lat, lon: c.lon, ago: 0, at: Date.now(), dist: 0, prio: 999,
|
||||
tele: () => `WITNESS · ${tierT} · ${AT.prec === 'exact' ? '±4 M' : AT.prec === '100' ? '±100 M' : '±1 KM'} · ${GE.feed.fmtDist(km).toUpperCase()} FROM EVENT` });
|
||||
// globe: hologram + an eye with a cone for sealed captures
|
||||
if (AT.holo) GE.world.userHolos.push({ lat: rd(c.lat), lon: rd(c.lon), title: 'Your capture · ' + (c.place || ''), by: 'you', at: c.at });
|
||||
if (c.tier !== 'unverified' && !GE.EYES.some((x) => x.id === 'you')) GE.EYES.push({ id: 'you', lat: c.lat, lon: c.lon, hdg: c.hdg ?? 64, fov: 62, label: 'You', kind: 'you', sit: sitId, post: p.id });
|
||||
const me = GE.EYES.find((x) => x.id === 'you'); if (me) { me.sit = sitId; me.post = p.id; }
|
||||
$('#attachOv').classList.remove('on'); CAPm.replyTo = null; if (c.upload) UP.f = null;
|
||||
GE.now.renderRail?.(); GE.now.renderFeed(); GE.you.render();
|
||||
APP.toast(`${ic('threads', 'sm')} Posted to ${esc(s.short || s.title.split(' — ')[0])} · ledger #${e.i}`, 2600); haptic([8, 30, 8]);
|
||||
GE.now._item = { band: 'inside', sit: sitId }; APP.setTab('globe', { force: true });
|
||||
setTimeout(() => { GE.sheet.open(sitId, { post: p.id }); setTimeout(() => GE.roomUI.resynth(GE.rooms.get(sitId), s, { auto: true }), 900); }, 250);
|
||||
};
|
||||
@@ -34,7 +34,8 @@ function frame(now) {
|
||||
W.simMs = Date.now() + W.offsetH * 3600000; W.t = (W.simMs - NOW) / 1000; W.sun = astro.sun(W.simMs);
|
||||
Cam.update(dt);
|
||||
if (APP.mode === 'situation' && !Cam.anim && GE.sheet.targetShift != null && !APP.isWide()) Cam.shiftY += (GE.sheet.targetShift - Cam.shiftY) * Math.min(1, dt * 7);
|
||||
if (globeVisible()) {
|
||||
if (globeVisible() && GE.capture.on && GE.capture.cover && !APP.isWide()) { GE.capture.frame(null); }
|
||||
else if (globeVisible()) {
|
||||
const [cw, chh] = sizeCanvas();
|
||||
if (now - lastDyn > 40) { W.updateDynamic(); lastDyn = now; }
|
||||
const view = Cam.view(cw, chh); GE.mainView = view;
|
||||
@@ -50,36 +51,52 @@ function frame(now) {
|
||||
}
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) running = false; else if (!running) { running = true; lastT = performance.now(); requestAnimationFrame(frame); } });
|
||||
|
||||
/* ---------- cold open ---------- */
|
||||
/* ---------- cold open: a descent from orbit to a person's sealed frame ---------- */
|
||||
function coldOpen() {
|
||||
const cold = $('#cold'); const app = $('#app');
|
||||
const skipAll = HASH.nointro || HASH.tab;
|
||||
const red = prefersReduced();
|
||||
W.sun = astro.sun(Date.now()); const finalCam = APP.heroCam();
|
||||
const CITIES2 = [{ lat: 40.7585, lon: -73.9855, name: '7TH AVE & W 45TH ST', cam: { lat: 40.7525, lon: -73.9905, range: 0.0016, heading: 0.62, tilt: 1.18 } }, { lat: 35.6595, lon: 139.7005, name: 'SHIBUYA SCRAMBLE', cam: { lat: 35.652, lon: 139.694, range: 0.0024, heading: 0.6, tilt: 1.12 } }, { lat: 41.8789, lon: -87.6359, name: 'STATE & MADISON', cam: { lat: 41.872, lon: -87.643, range: 0.0018, heading: 0.6, tilt: 1.15 } }, { lat: 51.5136, lon: -0.1365, name: 'OXFORD CIRCUS', cam: { lat: 51.508, lon: -0.143, range: 0.0024, heading: 0.6, tilt: 1.12 } }];
|
||||
const tgt = CITIES2.map((c) => ({ c, e: astro.elev(Date.now(), c.lat, c.lon) })).sort((a, b) => a.e - b.e)[0].c;
|
||||
let ended = false;
|
||||
const end = () => {
|
||||
if (!APP.intro) return; APP.intro = false; GE.introFade = 1; W.setTime(null);
|
||||
if (ended) return; ended = true; APP.intro = false; GE.introFade = 1; W.setTime(null);
|
||||
cold.classList.add('out'); app.classList.remove('intro'); setTimeout(() => cold.remove(), 1100);
|
||||
Cam.cancel(); if (Cam.range > 2.2 && !APP.isWide()) Cam.flyTo(finalCam, { duration: 0.6 });
|
||||
Cam.cancel(); Cam.set(finalCam); if (!APP.isWide()) { Cam.set({ ...finalCam, range: finalCam.range * 0.7 }); Cam.flyTo(finalCam, { duration: 1.2 }); }
|
||||
Cam.autoSpin = 0.3; APP.updateChrome(); APP.moveLens();
|
||||
if (HASH.tab) APP.setTab(HASH.tab, { force: true });
|
||||
};
|
||||
APP.intro = true; app.classList.add('intro'); APP.updateChrome();
|
||||
if (skipAll) { Cam.set(finalCam); end(); return; }
|
||||
Cam.set({ lat: 8, lon: finalCam.lon + 75, range: 5.8, heading: 0, tilt: 0, shiftY: 0.0, shiftX: 0 });
|
||||
const T = (red ? 1.2 : 3.4) * (HASH.slow ? +HASH.slow : 1); const t0 = performance.now();
|
||||
GE.introFade = 0;
|
||||
if (!red) Cam.flyTo(finalCam, { duration: T });
|
||||
const k = HASH.slow ? +HASH.slow : 1; const TA = (red ? 0.2 : 4.4) * k, TB = TA + 0.8 * k, TC = TB + 2.4 * k;
|
||||
if (!red) { Cam.set({ lat: tgt.lat - 18, lon: tgt.lon - 50, range: 6.4, heading: 0, tilt: 0, shiftY: 0, shiftX: 0 }); Cam.flyTo({ ...tgt.cam, shiftY: 0, shiftX: 0 }, { duration: TA * 0.95 }); }
|
||||
else Cam.set(finalCam);
|
||||
GE.introFade = 0; const t0 = performance.now(); const cv = $('#coldCv'); $('#coldTele').textContent = `WITNESS · SEALED · ±4 M · ${tgt.name}`;
|
||||
const caps = [[120, 'ORBIT · GEO IR · 35,786 KM'], [30, 'ORBIT · ISS · 412 KM · 7.66 KM/S'], [0.8, 'SKY · FL350 · 487 KT · ADS-B'], [-1, 'GROUND · CAM 0412 · 1.2 KM']];
|
||||
const pos = (altKm) => clamp(1 - (Math.log10(Math.max(0.05, altKm)) + 1.3) / (Math.log10(36000) + 1.3), 0, 1) * 0.72;
|
||||
const tick = () => {
|
||||
if (!APP.intro) return; const u = clamp((performance.now() - t0) / 1000 / T, 0, 1);
|
||||
GE.introFade = ease.out(clamp(u * 2.2, 0, 1));
|
||||
if (!red) W.setTime(Date.now() - (1 - ease.outQuint(u)) * 9 * 3600000);
|
||||
if (u > 0.12) cold.classList.add('s1'); if (u > 0.36) cold.classList.add('s2');
|
||||
if (u >= 1) { setTimeout(end, red ? 200 : 500); return; } requestAnimationFrame(tick);
|
||||
if (ended) return; const el = (performance.now() - t0) / 1000;
|
||||
GE.introFade = ease.out(clamp(el / 1.2, 0, 1)); cold.classList.add('s0');
|
||||
const alt = GE.mainView ? Math.max(0.05, geo.ll(GE.mainView.eye)[2]) : 36000;
|
||||
const lad = $('#ladder'); const H = lad.clientHeight;
|
||||
if (el < TA) {
|
||||
const band = alt > 120 ? 'orbit' : alt > 0.8 ? 'sky' : 'ground';
|
||||
lad.querySelectorAll('.lr').forEach((r) => r.classList.toggle('on', r.dataset.b === band));
|
||||
$('#lmark').style.top = (pos(alt) * H).toFixed(1) + 'px';
|
||||
$('#coldAlt').textContent = 'ALT ' + (alt >= 10 ? fmt.n(Math.round(alt)) + ' KM' : alt >= 1 ? alt.toFixed(1) + ' KM' : fmt.n(Math.round(alt * 1000)) + ' M');
|
||||
const cp = caps.find(([a]) => alt > a) || caps[3]; $('#coldCap').textContent = cp[1];
|
||||
} else {
|
||||
cold.classList.add('s3'); lad.querySelectorAll('.lr').forEach((r) => r.classList.toggle('on', r.dataset.b === 'inside')); $('#lmark').style.top = H - 8 + 'px';
|
||||
GE.paint.draw('crossing', cv, { seed: 31, cam: '@', rec: false, noOSD: true, osdY: -1, t: el });
|
||||
if (el > TB) cold.classList.add('s4');
|
||||
if (el > TC) { setTimeout(end, 300); return; }
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
$('#skip').addEventListener('click', (e) => { e.stopPropagation(); Cam.set(finalCam); end(); });
|
||||
cold.addEventListener('click', () => { Cam.set(finalCam); end(); });
|
||||
$('#skip').addEventListener('click', (e) => { e.stopPropagation(); end(); });
|
||||
cold.addEventListener('click', () => end());
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
Reference in new issue
Block a user