viz: portfolio source, source sample-&-hold gates, MIDI clock out

- 💰 your holdings (sky menu): declare coins/tokenized metals + quantities;
  quantities are WEIGHTS (per-holding gain = share of bag). Prices poll from
  Binance public spot API straight to the browser — server never sees the
  ledger (localStorage only). Sources: me.<sym> (labels show live share %),
  me.portfolio (drift), me.portfolio.vel (churn). Routes where money lives in
  all nine skins (Hod, the West, Nidavellir, Metal, hopes-and-fears...).
- Source groove grids: sample & hold — a source's value is only sampled on
  lit steps and held between, turning smooth feeds into stepped sequences
  ("quantize in time", source side).
- MIDI clock out: 24-ppqn 0xF8 on the godtime transport whenever an out port
  is selected — DAWs can slave to the planet (or to your heart).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-05 20:11:58 +10:00
parent 9c51097258
commit 35fc3c8d86

View File

@ -75,6 +75,29 @@
}
#inspect .midibtn:disabled { opacity: 0.4; cursor: default; }
/* the holdings panel — your private ledger, localStorage only */
#holdings {
position: fixed; top: 96px; right: 14px; z-index: 12; width: 272px;
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
border-radius: 12px; padding: 12px 14px; display: none; color: #c7d4ea;
font-size: 12px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
}
#holdings.open { display: block; }
#holdings h3 { margin: 0 24px 4px 0; font-size: 13px; color: #ffd98a; }
#holdings .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
#holdings .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
#holdings .hrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
#holdings input {
flex: 1; min-width: 0; background: rgba(30,40,60,0.8);
border: 1px solid rgba(120,150,200,0.3); border-radius: 6px;
color: #dbe6f5; padding: 4px 8px; font-size: 12px;
}
#holdings .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
#holdings .midibtn {
background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer;
}
/* the groove grid — piano roll / 808 steps inside a voice's tab */
.seqgrid { display: grid; grid-template-columns: repeat(16, 12px); gap: 2px; margin-top: 6px; }
.seqcell {
@ -505,7 +528,7 @@
<p><b>♥ Your heart — live now.</b> If you have a Bluetooth heart-rate device (a chest strap, many watches and fitbits in broadcast mode), right-click the sky and choose <b>♥ connect your heart</b>. The browser itself asks your permission — that is the opt-in — and from that moment <kbd>body.heart</kbd> is a source like any other: your pulse in beats per minute, normalized 40→180, ready to be patched into anything. It routes where a heart belongs in every tradition: the <b>anahata</b> chakra, <b>Tiferet</b> at the Tree's solar centre, <b>Qalb</b> among the lata'if, the <b>Fire</b> phase (the heart is the fire-organ in Chinese medicine), the <b>Sun gate</b> on the Hermetic ladder, the <b>Querent</b> in the spread. And there is a second, deeper wiring: choose <b>godtime follows your heart</b> and the instrument's tempo becomes your actual pulse — the whole planet playing at the speed of your blood. Dance, and the world keeps up. Rest, and it rests. (Web Bluetooth needs Chrome or Edge; your pulse never touches the server.)</p>
<p><b>💰 Your holdings — designed, coming.</b> The dealgod market already plays one person's commerce; the general form is <b>the portfolio source</b>: you declare what you hold — tickers, coins, metals — and <i>how much of each</i>, and the amounts become <b>weights</b>. Ten shares of one thing and a thousand of another, and the thousand sings ten times louder: <kbd>me.portfolio</kbd> as your total's daily breath (drift), <kbd>me.portfolio.vel</kbd> as its churn (flux), each holding optionally its own feed. Gold droning at the foundation, your tech stocks glittering at Hod, the whole bag weighted exactly as your risk is weighted. Your finances are already an instrument; we only add the speakers.</p>
<p><b>💰 Your holdings — live now.</b> Right-click the sky → <b>💰 your holdings</b>. Declare what you hold — coins, and metals through their tokens (<kbd>PAXG</kbd> is an ounce of gold) — and <i>how much of each</i>, and the amounts become <b>weights</b>: a bigger bag sings louder, each holding's influence auto-scaled by its share of the total. Prices poll straight from the public market to <i>your browser</i> — the server never learns your bag, and the ledger lives only on your machine. Each holding becomes <kbd>me.btc</kbd>, <kbd>me.paxg</kbd>…, the whole bag breathes as <kbd>me.portfolio</kbd> (drift) with <kbd>me.portfolio.vel</kbd> as its churn (flux) — and it routes where money lives in every tradition: Hod's quicksilver, the Wardens' wages in the West, the dwarves' gold in Nidavellir, the Metal phase, your hopes and fears in the spread. Stock tickers come later (they need a keyed API); your coins and your gold can sing today. Your finances were already an instrument; we only added the speakers.</p>
<p><b>✨ Your stars — designed, coming.</b> The natal layer: your birth — date, time to the ten-minute, place — computed by the same Swiss Ephemeris that already turns tonight's sky. Your natal positions become fixed reference tones, and the <b>live transits against them</b> become sources: the sky's slow conversation with the chart you were born under, audible. Saturn crossing your natal Sun as a months-long bass note. And nothing stops you loading a lover's chart, or a friend's — the instrument plays relationships too.</p>
@ -802,7 +825,7 @@
<li><b>Click a wheel — go inside it (mind mode).</b> In chakra view, click any chakra's core and the camera steps <i>into</i> the wheel: its mandala of petals turns around your whole field of view, and that chakra's feeds circle <i>you</i> — drag to look around the full 360°. A <b>vector console appears under your hands</b>: 12 keys of that wheel's own scale (C minor pentatonic at the root, rising key by key to B hirajoshi at the crown), played by clicking, by the <kbd>Q</kbd><kbd>]</kbd> row, or by any connected <b>MIDI keyboard</b> — every note runs through the world's own reverb and grit, so you are jamming <i>inside</i> the instrument, in that chakra's mode, while the planet plays around you. <kbd></kbd> <kbd></kbd> travel the spine wheel to wheel; <kbd>esc</kbd> steps back out. And for powerful machines, right-click the sky and set graphics to <b>ultra ✦</b>: every orbiting feed grows a glowing comet trail, and the orbits breathe so the trails weave slow spirals.</li>
<li><b>Skins — the same spine, another cosmology.</b> Right-click the sky and cycle <b>skin</b>. Beyond the seven <i>chakras</i> there is <b>the planes</b>: the Seven Planes &amp; forty-nine sub-planes of the Cosmic Physical Plane from the Alice Bailey / D.K. teachings — <b>I Divine</b> (white light) down through <b>Monadic</b> (violet), <b>Atmic</b>, <b>Buddhic</b>, <b>Mental</b> (where the egoic lotus sits), <b>Astral</b>, to <b>VII Physical</b> (red). The spine becomes the 49-sub-plane spectrum ladder; the side-brackets mark the <b>Monad</b>, the <b>Spiritual Triad</b> and the <b>Personality</b>; and every feed climbs to the plane that matches its rarefaction — the sun's own wind at the Divine, orbits and the sky's geometry at the Monadic, the great cyclic wheels at the Atmic, omen and aspect at the Buddhic, abstract thought and money-as-idea at the Mental, desire and fire and felt sensation at the Astral, and the dense body of the world — weather, quakes, the mortal rates, your own hands — at the Physical. Go inside any plane (mind mode) and its keys play a scale pitched to its altitude: airy whole-tone up at the Divine, grounded minor-pentatonic down at the Physical.</li>
<li><b>One Source, many Spirits, one Matter.</b> That is the whole architecture: the <b>Source</b> is the world's data, the same for everyone; the <b>Spirit</b> is the skin — the belief system through which you read it; the <b>Matter</b> is the sound. Choose your spirit, and the same planet plays through it. The shelf so far, each rendered from its own published lore, literally and without frills: the <b>Tree of Life</b> (Hermetic Qabalah, Golden Dawn attributions — ten sephirot on three pillars, all twenty-two canonical paths, Mercury's data at Hod, the Moon's at Yesod, the body of the world at Malkut); <b>the Lodge</b> (from published Masonic sources only — Worshipful Master in the East, Wardens West and South, the North dark, Boaz and Jachin at the porch where <i>your own hands</i> stand as the candidate, the Blazing Star, the Volume of the Sacred Law as humanity's open book, and music given to the South, for Beauty holds the arts); the <b>Hermetic ladder</b> of the seven planetary gates, lead to silver; <b>Yggdrasil</b> and its nine worlds, the dwarves keeping the markets in Nidavellir; the Sufi <b>lata'if</b>, where the wind data literally breathes at Ruh, spirit-as-breath; and the <b>Wu Xing</b>, five phases in their generating ring and controlling star, the metal birds of the air routed to Metal; and <b>the Spread</b> — the Celtic Cross of the tarot (Waite's published layout), dealt live and never finished: the heavens in <i>what crowns you</i>, the deep debts and quake-roots in <i>what is beneath you</i>, the retrogrades in <i>what is behind you</i>, births and seasons in <i>what is before you</i>, money and fire in <i>your hopes and fears</i> — and <b>your own hands in the Querent's place</b>, because you are the one asking. Right-click the sky → <b>skin</b> to turn the wheel of traditions. Each map stands as its true figure — the Tree as a tree, the Lodge as a lodge — and every station opens into mind mode with its own scale under your hands.</li>
<li><b>The groove grids — sequence the voices.</b> Open any voice's tab (shift-click it) and there's a <b>16-step grid on the godtime clock</b>. The note voices (<i>lead, bass</i>) get a <b>mini piano roll</b> — eight rows of minor pentatonic, click to place a note, click again to clear — and while your groove is on, <i>your melody replaces the data's</i>: bitcoin lets go of the lead and you take it. Every other voice gets an <b>808-style gate row</b> that rhythmically chops the world's signal — the reverb, the grit, the percussion breathing in your pattern while the planet still decides how hard. Grooves ride the godtime tempo (lock the dial, or let your heart drive it), and they <b>save with your patch</b>.</li></ul>
<li><b>The groove grids — sequence the voices.</b> Open any voice's tab (shift-click it) and there's a <b>16-step grid on the godtime clock</b>. The note voices (<i>lead, bass</i>) get a <b>mini piano roll</b> — eight rows of minor pentatonic, click to place a note, click again to clear — and while your groove is on, <i>your melody replaces the data's</i>: bitcoin lets go of the lead and you take it. Every other voice gets an <b>808-style gate row</b> that rhythmically chops the world's signal — the reverb, the grit, the percussion breathing in your pattern while the planet still decides how hard. Grooves ride the godtime tempo (lock the dial, or let your heart drive it), and they <b>save with your patch</b>. And the <b>sources</b> get the grid too, with a different power: <b>sample &amp; hold</b> — while a source's groove is on, the world's number is only <i>sampled</i> on your lit steps and held between them, so a smooth drifting feed becomes stepped, rhythmic, quantized in time. Bitcoin arpeggiating in your pattern. The wind as a sequence. If an out port is chosen in ⚙, godtime also sends <b>MIDI clock</b> (24 ppqn), so a DAW can slave its tempo to the planet — or to your pulse.</li></ul>
<div class="hr"></div>
@ -957,7 +980,7 @@
if (["tof","light","hand","audio","mic","touch","room","cam","body"].includes(p)) return "body";
if (["weather","air","quake","seismic","tide","temp"].includes(p)) return "planet";
if (["sun","iss","planes","plane","sky","aurora","moon","space"].includes(p)) return "sky";
if (["crypto","market","stock","btc","eth","fx"].includes(p)) return "markets";
if (["crypto","market","stock","btc","eth","fx","me"].includes(p)) return "markets";
if (["wiki","net","news","social","edit"].includes(p)) return "human";
return "other";
}
@ -1097,6 +1120,7 @@
// "Quantize in time" — the rhythmic sibling of the matrix's quantize-to-scale.
const seqs = {}; // destKey -> {on, steps[16]} (notes: -1|row, CC: 0|1)
let seqPhase = 0, seqCurStep = 0, seqGridRefresh = null;
let midiClockPhase = 0; // 24-ppqn accumulator for MIDI clock out
const SEQ_ROWS = [0, 3, 5, 7, 10, 12, 15, 17]; // minor pentatonic, two octaves, low->high
const seqRootFor = (key) => /bass/.test(key) ? 36 : 60;
function seqFor(key, isNote) {
@ -1145,7 +1169,13 @@
if (!clientMatrix) return;
const sv = {};
for (const [k, n] of sources) {
sv[k] = (soloKey && k !== soloKey) ? 0 : tweakNorm(k, n.norm || 0);
let v = (soloKey && k !== soloKey) ? 0 : tweakNorm(k, n.norm || 0);
const sq = seqs[k];
if (sq && sq.on) { // sample & hold: the world only speaks on your steps
if (sq.steps[seqCurStep] && sq._sampled !== seqCurStep) { sq.held = v; sq._sampled = seqCurStep; }
if (sq.held != null) v = sq.held;
}
sv[k] = v;
n.muted = isMutedLocal(k) || !!(soloKey && k !== soloKey);
}
const destVals = {}, notes = {};
@ -1613,6 +1643,17 @@
const st16 = Math.floor(seqPhase) % 16;
if (st16 !== seqCurStep) { seqCurStep = st16; seqTick(); }
// MIDI clock out — 24 ppqn (0xF8) whenever an out port is chosen, so a DAW
// can slave to godtime. (rAF-timed: fine for lights & loose sync; a
// lookahead scheduler is the upgrade path if tight sync ever matters.)
if (midiOut) {
midiClockPhase += dt * godtime.bpm / 60 * 24;
while (midiClockPhase >= 1) {
midiClockPhase -= 1;
try { midiOut.send([0xF8]); } catch (e) {}
}
}
if (layoutDirty) doLayout();
computeDests(); // YOUR patch -> dests (synth + viz + midi)
@ -1995,7 +2036,7 @@
// throat — the species talking to itself
if (["wiki","net","news","social","edit"].includes(p)) return "throat";
// solar plexus — fire, will, money
if (["crypto","market","stock","btc","eth","fx","dealgod","fire"].includes(p) || /inflation/.test(k)) return "solar";
if (["crypto","market","stock","btc","eth","fx","dealgod","fire","me"].includes(p) || /inflation/.test(k)) return "solar";
// sacral — water, creation, birth
if (/birth|fertil|precip|humid|rain|tide|moon|pop/.test(k)) return "sacral";
// root — ground, survival, the heavy and the mortal
@ -2060,7 +2101,7 @@
if (["wiki","net","news","social","edit","econ","debt"].includes(p) || // V · abstract thought & money-as-idea
/inflation/.test(k) || k === "market.turnover" || k === "crypto.price") return "mental";
if (/vel$|velocity/.test(k) || // VI · desire, fire, felt sensation
["fire","crypto","market","light","ambient","audio","mic"].includes(p)) return "astral";
["fire","crypto","market","light","ambient","audio","mic","me"].includes(p)) return "astral";
return "physical"; // VII · the dense body of the world
}
@ -2105,7 +2146,7 @@
if (k === "astro.mars" || p === "fire" || k === "quake.event" || k === "astro.tension" || k === "world.hunger") return "gevurah";
if (/^sky\./.test(k)) return "tiferet"; // the solar heart
if (/season|almanac\.(element|fertile)/.test(k)) return "netzach"; // Venus: nature's rhythms
if (["wiki","market","crypto","planes","econ"].includes(p) || /retro/.test(k)) return "hod"; // Mercury: mind & number
if (["wiki","market","crypto","planes","econ","me"].includes(p) || /retro/.test(k)) return "hod"; // Mercury: mind & number
if (/^astro\.moon|almanac\.dec|ambient|light\./.test(k)) return "yesod"; // Levanah: the astral machinery
return "malkut"; // the Kingdom: body & world
}
@ -2139,7 +2180,7 @@
if (p === "hand" || p === "tof" || p === "touch") // the candidate at the porch
return /\.x$|open/.test(k) ? "boaz" : "jachin";
if (/^sky\.|sun_lon|_lon|zodiac/.test(k) || k === "astro.saturn" || k === "astro.mars") return "east"; // the heavens, read from the East
if (["crypto","market","econ","debt"].includes(p)) return "west"; // the wages, paid in the West
if (["crypto","market","econ","debt","me"].includes(p)) return "west"; // the wages, paid in the West
if (p === "audio" || /season|almanac|lifeexp|births/.test(k)) return "south"; // Beauty holds music & the arts
if (/deaths|hunger|poverty|quake|pm25|aqi/.test(k)) return "north"; // what waits in the dark
if (k === "sun.speed" || k === "astro.harmony" || k === "almanac.fertile") return "star"; // Providence
@ -2173,7 +2214,7 @@
if (k === "astro.mars" || p === "fire" || /tension|quake\.event|deaths/.test(k)) return "mars";
if (k === "sun.speed" || /^sky\./.test(k)) return "sun";
if (/season|almanac\.element|centroid/.test(k)) return "venus";
if (["wiki","market","crypto","planes","iss"].includes(p) || /retro/.test(k)) return "mercury";
if (["wiki","market","crypto","planes","iss","me"].includes(p) || /retro/.test(k)) return "mercury";
return "moon"; // the sublunary world of flux
}
@ -2204,7 +2245,7 @@
if (/ambient|light\.|harmony/.test(k)) return "alfheim";
if (p === "wiki" || p === "hand" || p === "planes" || /lifeexp|popvel/.test(k)) return "midgard";
if (/wind|pressure|tension|quake\.event/.test(k)) return "jotunheim"; // the storm-giants
if (["crypto","market","debt","econ"].includes(p)) return "nidavellir"; // the dwarves' gold & its curse
if (["crypto","market","debt","econ","me"].includes(p)) return "nidavellir"; // the dwarves' gold & its curse
if (p === "fire" || k === "weather.temp") return "muspelheim";
if (/pm25|aqi/.test(k)) return "niflheim"; // the poison-mists
if (/deaths|hunger|poverty|quake\.depth/.test(k)) return "helheim";
@ -2260,7 +2301,7 @@
if (/births|popvel|season|fertile|lifeexp|wind|harmony|almanac\.element/.test(k)) return "wood";
if (p === "fire" || /temp|mars|tension|ambient|light\.|sky\./.test(k) || k === "sun.speed") return "fire";
if (/saturn|quake|hunger|poverty|pressure|debt\.total/.test(k)) return "earth";
if (["market","planes","audio"].includes(p) || /inflation|crypto\.price|debt\.vel/.test(k)) return "metal"; // coin, bells, metal birds
if (["market","planes","audio","me"].includes(p) || /inflation|crypto\.price|debt\.vel/.test(k)) return "metal"; // coin, bells, metal birds
return "water"; // the deep: moon, wisdom, the word
}
@ -2297,7 +2338,7 @@
if (/quake\.depth|debt\.total|saturn|poverty/.test(k)) return "foundation"; // the deep structure beneath
if (/tension|quake\.event|pm25|aqi|inflation|hunger/.test(k)) return "crossing"; // what stands in the way
if (/births|popvel|season|fertile|almanac/.test(k)) return "before"; // what approaches
if (["crypto","market","fire"].includes(p) || k === "debt.vel") return "hopes"; // money & fire: hope and fear at once
if (["crypto","market","fire","me"].includes(p) || k === "debt.vel") return "hopes"; // money & fire: hope and fear at once
if (p === "weather" || p === "planes" || p === "wiki") return "house"; // the world around you, talking
if (/lifeexp|astro\.moon|harmony/.test(k)) return "outcome"; // the culminating light
return "present"; // what covers you right now
@ -3316,6 +3357,8 @@
ctxItem((heartDrivesGodtime ? "✓ " : "") + "godtime follows your heart",
() => { heartDrivesGodtime = !heartDrivesGodtime; });
}
ctxItem("💰 your holdings…" + (holdings.length ? " (" + holdings.length + ")" : ""),
() => openHoldings());
}
ctxEl.classList.add("open");
ctxEl.style.left = Math.min(px, window.innerWidth - 252) + "px";
@ -3430,9 +3473,10 @@
inspectEl.appendChild(row);
}
// ---- the groove grid (voices only): piano roll for notes, 808 row for CC
if (!isSource) {
const sq = seqFor(node.key, node.isNote);
// ---- the groove grid: piano roll for note voices, 808 gate row for CC
// voices, sample-&-hold steps for sources (stepped data — "quantize in time")
{
const sq = seqFor(node.key, !isSource && node.isNote);
const wrap = document.createElement("div"); wrap.style.marginTop = "10px";
const head = document.createElement("div"); head.className = "sub";
const tog = document.createElement("button"); tog.className = "midibtn";
@ -3445,8 +3489,8 @@
head.appendChild(tog);
const lbl = document.createElement("span");
lbl.style.marginLeft = "8px";
lbl.textContent = node.isNote
? "16 steps · " + midiToName(seqRootFor(node.key)) + " minor pent"
lbl.textContent = isSource ? "sample & hold — the data steps in your time"
: node.isNote ? "16 steps · " + midiToName(seqRootFor(node.key)) + " minor pent"
: "16-step gate on the data";
head.appendChild(lbl);
wrap.appendChild(head);
@ -3569,6 +3613,117 @@
heartDrivesGodtime = false;
}
// ---- your holdings (the portfolio source, opt-in & client-side) ----------
// You declare what you hold and how much; the amounts are WEIGHTS. Prices
// poll from Binance's public spot API (no key, CORS-open) straight to your
// browser — the server never learns your bag. Symbols: anything with a USDT
// pair (BTC, ETH, SOL… PAXG is tokenized gold). Each holding becomes
// me.<sym>, its influence auto-scaled by its share of the total; the whole
// bag breathes as me.portfolio (drift) and me.portfolio.vel (churn).
let holdings = []; // [{sym, qty}]
try { holdings = JSON.parse(localStorage.getItem("gs_holdings") || "[]"); } catch (e) {}
const holdNorm = {}; // sym -> {min, max} session range
let holdTimer = null, lastPortfolio = 0, portfolioVel = 0;
function holdingsStart() {
if (holdTimer) clearInterval(holdTimer);
if (!holdings.length) return;
pollHoldings();
holdTimer = setInterval(pollHoldings, 30000); // ponytail: 30s poll; ws stream if it ever matters
}
async function pollHoldings() {
if (!holdings.length) return;
const syms = holdings.map(h => '"' + h.sym.toUpperCase() + 'USDT"').join(",");
let arr;
try {
const r = await fetch("https://api.binance.com/api/v3/ticker/price?symbols=[" + syms + "]");
arr = await r.json();
} catch (e) { return; } // offline / blocked: try again next poll
if (!Array.isArray(arr)) {
eventTicker.unshift({ text: "holdings: a symbol has no USDT pair on binance", tAdded: now(), src: "sys" });
return;
}
const price = {};
for (const t of arr) price[t.symbol.replace(/USDT$/, "")] = parseFloat(t.price);
let total = 0;
for (const h of holdings) total += (price[h.sym.toUpperCase()] || 0) * h.qty;
for (const h of holdings) {
const sym = h.sym.toUpperCase(), pv = price[sym];
if (!pv) continue;
const key = "me." + sym.toLowerCase();
const nrm = holdNorm[sym] || (holdNorm[sym] = { min: pv, max: pv });
nrm.min = Math.min(nrm.min, pv); nrm.max = Math.max(nrm.max, pv);
const share = total > 0 ? (pv * h.qty) / total : 0;
const fresh = !sources.has(key);
const n = ensureSource(key, { label: sym + " · " + Math.round(share * 100) + "%" });
n.label = sym + " · " + Math.round(share * 100) + "%";
n.raw = pv;
n.norm = nrm.max > nrm.min ? (pv - nrm.min) / (nrm.max - nrm.min) : 0.5;
if (fresh) tw(key).gain = clamp(share * 2, 0.2, 2); // your weights, literally
}
if (total > 0) {
const tn = holdNorm.__total || (holdNorm.__total = { min: total, max: total });
tn.min = Math.min(tn.min, total); tn.max = Math.max(tn.max, total);
const nTot = ensureSource("me.portfolio", { label: "your portfolio" });
nTot.raw = total;
nTot.norm = tn.max > tn.min ? (total - tn.min) / (tn.max - tn.min) : 0.5;
portfolioVel = lastPortfolio > 0 ? Math.abs(total - lastPortfolio) / lastPortfolio : 0;
lastPortfolio = total;
const nVel = ensureSource("me.portfolio.vel", { label: "portfolio churn" });
nVel.raw = portfolioVel * 100;
nVel.norm = clamp(portfolioVel * 120, 0, 1); // ~0.8% move per poll = full scale
}
}
// the holdings panel — a small private ledger, localStorage only
const holdEl = document.createElement("div");
holdEl.id = "holdings";
document.body.appendChild(holdEl);
function closeHoldings() { holdEl.classList.remove("open"); }
function openHoldings() {
holdEl.innerHTML = "";
const h3 = document.createElement("h3"); h3.textContent = "💰 your holdings";
const x = document.createElement("span"); x.className = "close"; x.textContent = "×";
x.onclick = closeHoldings;
holdEl.appendChild(x); holdEl.appendChild(h3);
const sub = document.createElement("div"); sub.className = "sub";
sub.textContent = "amounts are weights — a bigger bag sings louder. prices poll straight to your browser; the server never sees this. symbols need a USDT pair (BTC · ETH · SOL · PAXG = gold).";
holdEl.appendChild(sub);
const rows = document.createElement("div");
holdEl.appendChild(rows);
const addRow = (sym, qty) => {
const row = document.createElement("div"); row.className = "hrow";
const si = document.createElement("input"); si.placeholder = "BTC"; si.value = sym || ""; si.maxLength = 8;
const qi = document.createElement("input"); qi.placeholder = "0.5"; qi.value = qty != null ? qty : ""; qi.type = "number"; qi.step = "any";
const rm = document.createElement("span"); rm.className = "x"; rm.textContent = "×";
rm.onclick = () => row.remove();
row.appendChild(si); row.appendChild(qi); row.appendChild(rm);
rows.appendChild(row);
};
for (const h of holdings) addRow(h.sym, h.qty);
if (!holdings.length) addRow();
const add = document.createElement("button"); add.className = "midibtn"; add.textContent = "+ add";
add.onclick = () => addRow();
const save = document.createElement("button"); save.className = "midibtn"; save.style.marginLeft = "8px";
save.textContent = "save & listen";
save.onclick = () => {
holdings = [...rows.querySelectorAll(".hrow")].map(r => {
const [si, qi] = r.querySelectorAll("input");
return { sym: si.value.trim().toUpperCase(), qty: parseFloat(qi.value) };
}).filter(h => h.sym && h.qty > 0);
try { localStorage.setItem("gs_holdings", JSON.stringify(holdings)); } catch (e) {}
holdingsStart();
closeHoldings();
if (holdings.length)
eventTicker.unshift({ text: "💰 your bag is in the choir (" + holdings.length + " holdings)", tAdded: now(), src: "sys" });
};
const btns = document.createElement("div"); btns.style.marginTop = "8px";
btns.appendChild(add); btns.appendChild(save);
holdEl.appendChild(btns);
holdEl.classList.add("open");
}
if (holdings.length) holdingsStart(); // returning holders rejoin the choir on load
// ---- Web MIDI ----
async function enableMidi() {
if (!navigator.requestMIDIAccess) {