Code Playground

Code collection display and playground studies

Masonry cards render code thumbnails, uploaded source studies, and collection elements authored through the CMS studio.

Solar Systemhtml
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Solar System — Small Bodies, Risk, Zoom, Particles, Dilation, Fields & Time</title>
<style>
  html, body { margin: 0; height: 100%; background: #05060a; overflow: hidden;
    font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial; }
  canvas { display: block; width: 100vw; height: 100vh; cursor: grab; }
  canvas.dragging { cursor: grabbing; }

  /* HUD bottom */
  #hud {
    position: fixed; left: 10px; right: 10px; bottom: 10px;
    display: grid; grid-template-columns: auto 1fr auto; gap: .75rem 1rem; align-items: center;
    padding: .6rem .8rem; border-radius: .8rem; color: #bcd1ea; font-size: 12px;
    background: rgba(12,16,24,.55); backdrop-filter: blur(4px);
    border: 1px solid rgba(120,175,235,.15);
  }
  #toggles { display: flex; gap: .9rem; flex-wrap: wrap; max-width: min(62vw, 1000px); }
  .chk { display: inline-flex; align-items: center; gap: .4rem; }
  .spacer { height: 1px; background: linear-gradient(90deg, transparent, rgba(120,175,235,.4), transparent); grid-column: 1/-1; }
  input[type="range"] { width: 100%; }
  #legend { justify-self: end; opacity: .85 }

  /* View & time controls (top-left) */
  #viewbox{ position:fixed; top:10px; left:10px; padding:.6rem .7rem; border-radius:.8rem;
    background: rgba(10,14,22,.55); border:1px solid rgba(120,175,235,.18); color:#d7e6ff; font-size:12px; backdrop-filter: blur(4px); width: 340px; }
  #viewbox .row{ display:grid; grid-template-columns:auto 1fr auto; gap:.5rem; align-items:center; margin:.35rem 0; }
  #viewbox button{ padding:.25rem .5rem; border-radius:.5rem; border:1px solid rgba(120,175,235,.28); background:#0b1321; color:#cfe3ff; cursor:pointer; }
  #viewbox strong{ font-weight:600; }
  #viewbox select, #viewbox input[type="number"], #viewbox input[type="date"]{ width:100%; background:#0b1321; color:#cfe3ff; border:1px solid rgba(120,175,235,.28); border-radius:.4rem; padding:.2rem .35rem; }
  #viewbox small{ opacity:.8 }

  /* Data panel (right-middle) */
  #databox{ position:fixed; top:50%; right:10px; transform:translateY(-50%);
    min-width: 300px; max-width: 380px; color:#d7e6ff; background: rgba(10,14,22,.6);
    border:1px solid rgba(120,175,235,.18); border-radius:.9rem; padding:.7rem .8rem; font-size: 12px; backdrop-filter: blur(4px); }
  #databox h3{ margin:.1rem 0 .5rem; font-size:12px; color:#9ec7ff; letter-spacing:.3px; }
  #databox .kv{ display:grid; grid-template-columns:auto 1fr; gap:.25rem .6rem; align-items:baseline; }
  #databox .kv div:nth-child(odd){ opacity:.8; }
  #databox .row{ display:flex; gap:.5rem; align-items:center; margin-top:.4rem; flex-wrap: wrap; }
  #copyBtn{ padding:.25rem .5rem; border-radius:.5rem; border:1px solid rgba(120,175,235,.28); background:#0b1321; color:#cfe3ff; cursor:pointer; }
  #eqbox{ margin-top:.6rem; padding:.5rem; background:rgba(120,175,235,.06); border:1px solid rgba(120,175,235,.18); border-radius:.6rem; line-height:1.35; }
  #eqbox code{ background:rgba(10,14,22,.5); padding:.05rem .25rem; border-radius:.3rem; }

  /* Risk & timeline (top-right) */
  #stack{ position:fixed; top:10px; right:10px; display:flex; flex-direction:column; gap:.6rem; }
  #riskbox, #timelinebox{
    min-width: 260px; max-width: 380px; color:#d7e6ff; background: rgba(10,14,22,.6);
    border:1px solid rgba(120,175,235,.18); border-radius:.9rem; padding:.7rem .8rem; font-size: 12px; backdrop-filter: blur(4px);
  }
  #riskbox h3, #timelinebox h3 { margin:0 0 .4rem; font-size: 12px; letter-spacing:.3px; color:#9ec7ff }
  #risklist { max-height: 28vh; overflow:auto; }
  .riskitem { padding:.35rem .4rem; border-radius:.5rem; margin:.25rem 0; display:grid; grid-template-columns: 1fr auto; gap:.5rem; align-items:center; }
  .riskitem .when { opacity:.85 }
  .watch  { background: rgba(255, 214, 102, .10); border:1px solid rgba(255,214,102,.22); }
  .warn   { background: rgba(255, 145,  94, .10); border:1px solid rgba(255,145, 94,.22); }
  .alert  { background: rgba(239,  71, 111, .10); border:1px solid rgba(239, 71,111,.28); }
  .ok     { background: rgba( 90, 200, 160, .10); border:1px solid rgba( 90,200,160,.18); }

  #timeline{ max-height: 24vh; overflow:auto; }
  .ev{ display:grid; grid-template-columns:auto 1fr auto; gap:.4rem; align-items:center; padding:.25rem .35rem; border-radius:.45rem; margin:.2rem 0; background: rgba(120,175,235,.05); }
  .ev .tag{ font-size:10px; opacity:.8; padding:.05rem .35rem; border:1px solid rgba(120,175,235,.25); border-radius:.35rem; }

  /* Galactic compass */
  #gcomp{ position:fixed; top:10px; left:50%; transform:translateX(-50%); width:180px; height:84px;
    color:#d7e6ff; font-size: 11px; pointer-events:none; text-align:center; }
</style>
<body>
<canvas id="sky"></canvas>

<!-- View, time, particles, observer -->
<div id="viewbox">
  <!-- Time controls -->
  <div class="row">
    <strong>Time</strong>
    <div style="display:flex; gap:.4rem; align-items:center; flex-wrap:wrap;">
      <button id="playBtn">▶ Play</button>
      <button id="stepBack">⟲ Step−</button>
      <button id="stepFwd">Step+</button>
    </div>
    <span></span>
  </div>
  <div class="row">
    <strong>Step</strong>
    <input id="stepDays" type="number" min="0.01" step="0.01" value="1" />
    <small>days</small>
  </div>
  <div class="row"><strong>Zoom</strong><input id="zoom" type="range" min="0.35" max="6" step="0.01" value="1"><span id="zoomv">1.00×</span></div>
  <div class="row"><strong>Time warp</strong><input id="timewarp" type="range" min="0.05" max="20" step="0.05" value="1"><span id="timev">1.00×</span></div>
  <div class="row"><label class="chk"><input id="tieTZ" type="checkbox" checked> Tie speed to zoom (÷zoom)</label><button id="resetView">Reset</button><span></span></div>
  <div class="row"><strong>Particles</strong><input id="particleDensity" type="range" min="0" max="1" step="0.01" value="0.6"><span id="pdv">60%</span></div>
  <div class="row"><label class="chk"><input id="showParticles" type="checkbox" checked> Cosmic particles</label><span></span><span></span></div>
  <div class="row">
    <strong>Calendar</strong>
    <input id="epochYear" type="number" step="1" value="2025" />
    <span id="calendarLabel">2025.00</span>
  </div>
  <div class="row">
    <strong>Jump</strong>
    <select id="eventSel"></select>
    <button id="jumpBtn">Go</button>
  </div>
  <div class="row"><strong>Observer</strong>
    <select id="observerSel"></select>
    <span id="obsBadge">Earth</span>
  </div>
</div>

<!-- Bottom HUD -->
<div id="hud">
  <div id="toggles">
    <label class="chk"><input id="showOrbits" type="checkbox" checked> Orbits</label>
    <label class="chk"><input id="showMoonOrbits" type="checkbox" checked> Moon orbits</label>
    <label class="chk"><input id="showLabels" type="checkbox" checked> Labels</label>
    <label class="chk"><input id="showKuiper" type="checkbox" checked> Kuiper Belt</label>
    <label class="chk"><input id="showOort" type="checkbox" checked> Oort Cloud</label>
    <label class="chk"><input id="showCraft" type="checkbox" checked> Spacecraft</label>
    <label class="chk"><input id="showCraftPaths" type="checkbox" checked> Space Paths</label>
    <label class="chk"><input id="compressAU" type="checkbox" checked> Compress &gt; 50 AU</label>
    <label class="chk"><input id="showAsteroids" type="checkbox" checked> Asteroids</label>
    <label class="chk"><input id="showDwarfs" type="checkbox" checked> Dwarf planets</label>
    <label class="chk"><input id="showComets" type="checkbox" checked> Comets</label>
    <label class="chk"><input id="showTrails" type="checkbox" checked> Trails</label>
    <label class="chk"><input id="showPredict" type="checkbox" checked> Predict paths</label>
    <label class="chk"><input id="showRisk" type="checkbox" checked> Collision risk</label>
    <label class="chk"><input id="showGravField" type="checkbox"> Grav field</label>
    <label class="chk"><input id="showEquip" type="checkbox"> Equipotentials</label>
    <label class="chk"><input id="showParker" type="checkbox"> Parker spiral</label>
  </div>
  <div class="spacer"></div>
  <div style="display:grid; gap:.25rem;">
    <strong>Base Speed</strong>
    <input id="speed" type="range" min="0.1" max="6" step="0.1" value="1" />
  </div>
  <div style="display:grid; gap:.25rem; width: min(340px, 24vw);">
    <strong>Horizon (years)</strong>
    <input id="horizon" type="range" min="1" max="200" step="1" value="50" />
  </div>
  <div id="legend">☉ Sun • ◎ Rings • ● Moons • ▲ Spacecraft • ✹ Small body</div>
</div>

<!-- Stack top-right: risk + timeline -->
<div id="stack">
  <div id="riskbox" hidden>
    <h3>Collision Risk Forecast</h3>
    <div id="riskmeta" style="margin-bottom:.4rem"></div>
    <div id="risklist"></div>
    <div style="opacity:.7; margin-top:.4rem">Thresholds: <span class="ok">&nbsp;safe&nbsp;</span> ≥0.02 AU · <span class="watch">watch</span> &lt;0.02 AU · <span class="warn">warn</span> &lt;0.005 AU · <span class="alert">alert</span> &lt;0.0026 AU (≈ lunar distance)</div>
  </div>
  <div id="timelinebox">
    <h3>Special Events Timeline</h3>
    <div id="timeline"></div>
  </div>
</div>

<!-- Physics + properties panel -->
<div id="databox">
  <h3>Atomic Clock & Observer Dilation (illustrative)</h3>
  <div class="kv" id="physkv"></div>
  <h3 style="margin-top:.6rem">Selected Object</h3>
  <div class="kv" id="selkv"></div>
  <div class="row"><button id="copyBtn">Copy JSON</button><span id="copiedMsg" style="opacity:.7"></span></div>
  <div id="eqbox">
    <h3>Field Equations (used here)</h3>
    <div>
      <div>Vis-viva: <code>v = √(μ ( 2/r − 1/a ))</code></div>
      <div>SR Lorentz: <code>γ = 1 / √(1 − v²/c²)</code></div>
      <div>GR redshift (Schwarzschild): <code>√(1 − 2GM/(rc²))</code></div>
      <div>Combined (approx): <code>dt_local/dt_atomic ≈ √(1 − 2GM/(rc²) − v²/c²)</code></div>
      <div>Gravity field: <code>g(r) = GM/r²</code> · Potential: <code>Φ(r) = −GM/r</code></div>
      <div>Parker spiral (stylized): <code>r(θ) = (v_sw/Ω_☉) (θ − θ₀)</code></div>
    </div>
  </div>
</div>

<!-- Galactic compass -->
<div id="gcomp"></div>

<script>
(() => {
  const canvas = document.getElementById('sky');
  const ctx = canvas.getContext('2d', { alpha: true });
  // Global sim clock (hoisted early)
  var simTime = 0;

  // === HUD ELEMS ===
  const speedSlider = document.getElementById('speed');
  const showOrbits = document.getElementById('showOrbits');
  const showMoonOrbits = document.getElementById('showMoonOrbits');
  const showLabels = document.getElementById('showLabels');
  const showKuiper = document.getElementById('showKuiper');
  const showOort = document.getElementById('showOort');
  const showCraft = document.getElementById('showCraft');
  const showCraftPaths = document.getElementById('showCraftPaths');
  const compressAU = document.getElementById('compressAU');
  const showAsteroids = document.getElementById('showAsteroids');
  const showDwarfs = document.getElementById('showDwarfs');
  const showComets = document.getElementById('showComets');
  const showPredict = document.getElementById('showPredict');
  const showTrails = document.getElementById('showTrails');
  const showRisk = document.getElementById('showRisk');
  const showGravField = document.getElementById('showGravField');
  const showEquip = document.getElementById('showEquip');
  const showParker = document.getElementById('showParker');
  const horizon = document.getElementById('horizon');

  const riskbox = document.getElementById('riskbox');
  const risklist = document.getElementById('risklist');
  const riskmeta = document.getElementById('riskmeta');

  // View + time controls
  const zoomSlider = document.getElementById('zoom');
  const zoomVal = document.getElementById('zoomv');
  const timewarp = document.getElementById('timewarp');
  const timeVal = document.getElementById('timev');
  const tieTZ = document.getElementById('tieTZ');
  const resetView = document.getElementById('resetView');
  const particleDensity = document.getElementById('particleDensity');
  const pdv = document.getElementById('pdv');
  const showParticles = document.getElementById('showParticles');
  const playBtn = document.getElementById('playBtn');
  const stepBack = document.getElementById('stepBack');
  const stepFwd = document.getElementById('stepFwd');
  const stepDays = document.getElementById('stepDays');
  const epochYear = document.getElementById('epochYear');
  const calendarLabel = document.getElementById('calendarLabel');
  const eventSel = document.getElementById('eventSel');
  const jumpBtn = document.getElementById('jumpBtn');

  // Data panel
  const physkv = document.getElementById('physkv');
  const selkv = document.getElementById('selkv');
  const copyBtn = document.getElementById('copyBtn');
  const copiedMsg = document.getElementById('copiedMsg');
  const observerSel = document.getElementById('observerSel');
  const obsBadge = document.getElementById('obsBadge');

  // Galactic compass
  const gcomp = document.getElementById('gcomp');

  // Dimensions & scaling
  const TAU = Math.PI * 2;
  const D2R = Math.PI / 180;
  // Hoist galactic offset early to avoid TDZ in drawGalacticCompass()
  var ECL_TO_GAL = 60 * D2R; // visual-only rotation offset
  const margin = 28;

  // Physical constants (km, s)
  const C = 299792.458; // km/s
  const MU_SUN = 1.32712440018e11; // km^3/s^2
  const AU_KM = 149597870.7; // km
  const OMEGA_SUN = 2.865e-6; // rad/s, solar rotation (approx)
  const V_SW = 400; // km/s solar wind (approx)

  // Orbital layout units (px before viewport scale)
  const ORBIT = {
    MERCURY:  35,
    VENUS:    48,
    EARTH:    70,
    MARS:    100,
    ASTS:    165,
    JUPITER: 170,
    SATURN:  220,
    URANUS:  260,
    NEPTUNE: 290,
    PLUTO:   310,
    KUIPER_INNER: 360,
    KUIPER_OUTER: 520
  };

  // Useful AU→diagram scaling (Neptune ≈ 30 AU)
  const PX_PER_AU = ORBIT.NEPTUNE / 30; // ≈ 9.67 px per AU at native scale

  // In this sim: 1 Earth year == 12 simTime units
  const YEAR_UNIT = 12; // sim units per year

  // Planets + moons (stylized)
  const planets = [
    { name:'Mercury', orbit: ORBIT.MERCURY, size: 3.0, color:'#9f5e26', period:  5, moons: [] },
    { name:'Venus',   orbit: ORBIT.VENUS,   size: 5.5, color:'#beb768', period:  8, moons: [] },
    { name:'Earth',   orbit: ORBIT.EARTH,   size: 5.0, color:'#3aa0ff', period: 12,
      moons: [ { name:'Moon', orbit: 12, size: 2.0, color:'#d8d8d8', period: 2.0 } ]
    },
    { name:'Mars',    orbit: ORBIT.MARS, size: 4.5, color:'#c56a3e', period: 20,
      moons: [ { name:'Phobos', orbit:  9, size: 1.2, color:'#c9b39e', period: 0.7 },
               { name:'Deimos', orbit: 14, size: 1.0, color:'#d2c8b9', period: 1.8 } ] },
    { name:'Jupiter', orbit: ORBIT.JUPITER, size: 10.0, color:'#e0b080', period: 30,
      moons: [ { name:'Io',       orbit: 14, size: 1.6, color:'#ffd88a', period: 1.5 },
               { name:'Europa',   orbit: 18, size: 1.5, color:'#e9edf1', period: 2.2 },
               { name:'Ganymede', orbit: 22, size: 2.0, color:'#cdbaa6', period: 3.5 },
               { name:'Callisto', orbit: 28, size: 1.8, color:'#b8a08c', period: 5.0 } ] },
    { name:'Saturn',  orbit: ORBIT.SATURN, size: 8.0, color:'#dfd3a9', period: 60, ring: true,
      moons: [ { name:'Mimas', orbit: 11, size: 0.9, color:'#f2efe8', period: 0.9 },
               { name:'Enceladus', orbit: 14, size: 1.1, color:'#ffffff', period: 1.1 },
               { name:'Tethys', orbit: 16, size: 1.2, color:'#f3efe6', period: 1.5 },
               { name:'Dione',  orbit: 19, size: 1.3, color:'#efe6da', period: 2.0 },
               { name:'Rhea',   orbit: 22, size: 1.5, color:'#e8dfd0', period: 2.8 },
               { name:'Titan',  orbit: 30, size: 2.2, color:'#d1a46f', period: 4.8 },
               { name:'Iapetus',orbit: 38, size: 1.4, color:'#cbbfb1', period: 8.5 } ] },
    { name:'Uranus',  orbit: ORBIT.URANUS, size: 6.0, color:'#82b3d1', period: 70, moons: [] },
    { name:'Neptune', orbit: ORBIT.NEPTUNE, size: 6.0, color:'#2a5af4', period:100, moons: [] },
    { name:'Pluto',   orbit: ORBIT.PLUTO,   size: 3.5, color:'#c1498e', period:120, moons: [] }
  ];

  // Sun visual params
  const sun = { r: 20, glow: 110 };

  // Small bodies (approximate)
  const smallBodies = [
    { name:'Ceres',   kind:'asteroid', color:'#d9c9ae', a:2.77, e:0.08, i:10.6, Ω:80,  ω:73,  M0:0, P:4.60, size:1.8 },
    { name:'Vesta',   kind:'asteroid', color:'#e6d2b3', a:2.36, e:0.09, i:7.1,  Ω:103, ω:151, M0:0, P:3.63, size:1.6 },
    { name:'Pallas',  kind:'asteroid', color:'#ccbca3', a:2.77, e:0.23, i:34.8, Ω:173, ω:310, M0:0, P:4.62, size:1.5 },
    { name:'Hygiea',  kind:'asteroid', color:'#c8c0b3', a:3.14, e:0.12, i:3.8,  Ω:44,  ω:312, M0:0, P:5.50, size:1.4 },

    { name:'Eris',     kind:'dwarf', color:'#ffd1f0', a:67.7, e:0.44, i:44.0, Ω:36,  ω:151, M0:0, P:558, size:2.2 },
    { name:'Haumea',   kind:'dwarf', color:'#ccf0ff', a:43.1, e:0.19, i:28.2, Ω:121, ω:240, M0:0, P:284, size:2.0 },
    { name:'Makemake', kind:'dwarf', color:'#f7d6a2', a:45.7, e:0.16, i:29.0, Ω:79,  ω:294, M0:0, P:306, size:2.0 },
    { name:'Quaoar',   kind:'dwarf', color:'#f2b4b4', a:43.7, e:0.04, i:8.0,  Ω:189, ω:163, M0:0, P:287, size:1.9 },
    { name:'Orcus',    kind:'dwarf', color:'#b4d1f2', a:39.2, e:0.22, i:20.6, Ω:268, ω:73,  M0:0, P:247, size:1.8 },
    { name:'Sedna',    kind:'dwarf', color:'#ffadad', a:518,  e:0.85, i:11.9, Ω:144, ω:311, M0:0, P:11400, size:2.6 },
    { name:'Gonggong', kind:'dwarf', color:'#e6a2f7', a:67,   e:0.50, i:30.7, Ω:336, ω:207, M0:0, P:553, size:1.9 },

    { name:'1P/Halley',     kind:'comet', color:'#66f7ff', a:17.8,  e:0.967, i:162.3, Ω:58,  ω:111, M0:0, P:75.3, size:1.8 },
    { name:'2P/Encke',      kind:'comet', color:'#9af7ff', a:2.22,  e:0.85,  i:11.8,  Ω:334, ω:186, M0:0, P:3.30, size:1.5 },
    { name:'67P/Churyumov', kind:'comet', color:'#b7ffff', a:3.46,  e:0.64,  i:7.0,   Ω:50,  ω:12,  M0:0, P:6.45, size:1.5 },
    { name:'C/1995 O1',     kind:'comet', label:'Hale–Bopp', color:'#c6f7ff', a:186, e:0.995, i:89, Ω:281, ω:130, M0:0, P:2533, size:2.0 }
  ];

  // Earth (for physics)
  const earthEl = { name:'Earth', kind:'planet', color:'#3aa0ff', a:1.000, e:0.0167, i:0, Ω:0, ω:102.9, M0:0, P:1 };

  // Belts (visual noise fields)
  const mainBelt = []; const kuiperBelt = [];
  function seedBelt(store, n, innerR, outerR, sizeMin, sizeMax, alphaMin, alphaMax) {
    store.length = 0;
    for (let i=0;i<n;i++) {
      const a = Math.random() * TAU;
      const r = innerR + Math.random() * (outerR - innerR);
      const s = sizeMin + Math.random() * (sizeMax - sizeMin);
      const alpha = alphaMin + Math.random() * (alphaMax - alphaMin);
      const tint = 180 + (Math.random()*60|0);
      store.push({ a, r, s, alpha, tint });
    }
  }

  // Starfield (offscreen)
  let starsCanvas, starsCtx;
  function buildStars() {
    starsCanvas = document.createElement('canvas');
    starsCtx = starsCanvas.getContext('2d');
    starsCanvas.width = Math.max(1, Math.round(w * dpr));
    starsCanvas.height = Math.max(1, Math.round(h * dpr));
    const sctx = starsCtx;
    sctx.setTransform(dpr,0,0,dpr,0,0);
    sctx.clearRect(0,0,w,h);
    const count = Math.round((w*h)/2400);
    for (let i=0;i<count;i++){
      const x = Math.random()*w, y = Math.random()*h;
      const r = Math.random()*1.3 + 0.2;
      const tone = 200 + (Math.random()*55|0);
      const g = sctx.createRadialGradient(x,y,0,x,y,r);
      g.addColorStop(0, `rgba(${tone},${tone},${tone},1)`);
      g.addColorStop(1, 'rgba(255,255,255,0)');
      sctx.fillStyle = g;
      sctx.beginPath(); sctx.arc(x,y,r,0,TAU); sctx.fill();
    }
  }

  // Cosmic particles
  let cosmic = [];
  function rebuildParticles(){
    const dens = parseFloat(particleDensity.value);
    const base = (w*h)/8000; // baseline
    const N = Math.round(base * (0.2 + dens*1.8));
    cosmic = new Array(N).fill(0).map(()=>{
      const R = 30 + Math.random() * (ORBIT.KUIPER_OUTER*1.2);
      const th = Math.random() * TAU;
      const d = 0.5 + Math.random()*2.5; // depth/parallax
      const w0 = (Math.random()*0.6 + 0.2) * (Math.random()<0.5?-1:1);
      const jitter = Math.random()*0.02;
      const size = 0.6 + Math.random()*1.8;
      const hue = 190 + Math.random()*60;
      return { R, th, d, w0, jitter, size, hue };
    });
  }

  function drawParticles(dt, timeFactor){
    if (!showParticles.checked || cosmic.length===0) return;
    ctx.save(); ctx.globalCompositeOperation = 'lighter';
    const s = scale; // current overall scale
    for (const p of cosmic){
      p.th += (p.w0 / p.d) * dt * timeFactor;
      const rj = p.R * (1 + p.jitter*Math.sin(p.th*3 + simTime*0.25));
      const x = vx + Math.cos(p.th) * (rj * s);
      const y = vy + Math.sin(p.th) * (rj * s);
      const r = Math.max(0.25, p.size * s * (1.2 - 0.15*p.d));
      const g = ctx.createRadialGradient(x,y,0,x,y,r*2);
      const a = 0.06 * (2.8 - p.d);
      g.addColorStop(0, `rgba(${p.hue|0},${(p.hue+20)|0},255,${a*3})`);
      g.addColorStop(1, `rgba(${p.hue|0},${(p.hue+20)|0},255,0)`);
      ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x,y,r*1.5,0,TAU); ctx.fill();
    }
    ctx.restore();
  }

  // Spacecraft
  const spacecraft = [
    { name:'Voyager 1',  color:'#ffd166', baseAU:163, headingDeg:  35, spin:  0.02 },
    { name:'Voyager 2',  color:'#06d6a0', baseAU:136, headingDeg: 220, spin: -0.018 },
    { name:'Pioneer 10', color:'#ef476f', baseAU:132, headingDeg:  85, spin:  0.016 },
    { name:'Pioneer 11', color:'#f78c6b', baseAU:103, headingDeg: 310, spin: -0.015 },
    { name:'New Horizons',color:'#118ab2',baseAU: 60, headingDeg: 290, spin:  0.010 }
  ];

  function auToPxNative(rAU) { return rAU * PX_PER_AU; }

  function compressPxRadius(px) {
    const a = ORBIT.KUIPER_INNER; // keep linear up to inner Kuiper
    const b = ORBIT.KUIPER_OUTER - 10;
    if (px <= a) return px;
    const pxMax = 3000;
    const t = Math.log(1 + (px - a)) / Math.log(1 + (pxMax - a));
    return a + t * (b - a);
  }

  // Kepler helpers
  function normPI(a){ a%=TAU; return a>Math.PI?a-TAU:(a<-Math.PI?a+TAU:a); }
  function keplerE(M, e){ let E=M; for(let k=0;k<7;k++){ const f=E-e*Math.sin(E)-M; E-= f/(1-e*Math.cos(E)); } return E; }
  function elementsToAU(el, years){
    const n = TAU / el.P;
    const M = normPI((el.M0||0) + n * (years - (el.epoch||0)));
    const e = el.e;
    const E = keplerE(M, e);
    const nu = 2*Math.atan2(Math.sqrt(1+e)*Math.sin(E/2), Math.sqrt(1-e)*Math.cos(E/2));
    const r = el.a * (1 - e * Math.cos(E));
    const u = (el.ω||el.w||0) * D2R + nu;
    const Ω = (el.Ω||0) * D2R; const i = (el.i||0) * D2R;
    const cosΩ=Math.cos(Ω), sinΩ=Math.sin(Ω), cosi=Math.cos(i), sini=Math.sin(i);
    const cosu=Math.cos(u), sinu=Math.sin(u);
    const x = r * ( cosΩ*cosu - sinΩ*sinu*cosi );
    const y = r * ( sinΩ*cosu + cosΩ*sinu*cosi );
    const z = r * ( sinu*sini );
    return { x, y, z, r, nu };
  }

  function AUVecToPx(xAU, yAU){
    const rAU = Math.hypot(xAU, yAU);
    const native = auToPxNative(rAU);
    const comp = compressAU.checked ? compressPxRadius(native) : native;
    const f = native > 0 ? comp / native : 1;
    return { x: vx + (xAU * PX_PER_AU) * f * scale, y: vy + (yAU * PX_PER_AU) * f * scale };
  }

  // State + dilation
  function stateFor(el, years){
    const st = elementsToAU(el, years);
    const r_km = st.r * AU_KM;
    const a_km = (el.a||st.r) * AU_KM;
    const v_kms = Math.sqrt(Math.max(0, MU_SUN * Math.max(0, (2/r_km - 1/Math.max(a_km,1))))); // km/s
    return { ...st, r_km, a_km, v_kms };
  }
  function srFactor(v_kms){ const beta = v_kms / C; const inside = Math.max(0, 1 - beta*beta); return 1/Math.sqrt(inside || 1e-12); }
  function grFactor(r_km){ const x = Math.max(1, r_km); const inside = Math.max(0, 1 - (2*MU_SUN)/(x*C*C)); return Math.sqrt(inside || 1e-12); }
  function combinedRatio(v_kms, r_km){ const inside = Math.max(0, 1 - (2*MU_SUN)/(r_km*C*C) - (v_kms*v_kms)/(C*C)); return Math.sqrt(inside || 1e-12); }

  let observer = earthEl; // default
  function setObserverFromSelected(sel){ if (!sel) return; if (sel.name) { observer = { ...sel }; obsBadge.textContent = sel.label||sel.name; observerSel.value = sel.name; } }

  function formatRatio(x){ if (x>=0.9999999999) return '0.9999999999'; if (x>0.99999) return x.toFixed(10); return x.toPrecision(5); }

  // === Resize & view state ===
  let dpr, w, h, cx, cy, scaleBase, scale, vx, vy, userZoom=1, panX=0, panY=0;
  function resize(){
    dpr = Math.max(1, window.devicePixelRatio || 1);
    w = Math.floor(window.innerWidth);
    h = Math.floor(window.innerHeight);
    canvas.width = Math.max(1, Math.round(w * dpr));
    canvas.height = Math.max(1, Math.round(h * dpr));
    ctx.setTransform(dpr,0,0,dpr,0,0);
    cx = w/2; cy = h/2;
    const usable = Math.min(w,h)/2 - margin;
    scaleBase = Math.max(0.2, Math.min(1, usable / (ORBIT.KUIPER_OUTER + 20)));
    updateViewScale();
    buildStars();
    seedBelt(mainBelt,  800, ORBIT.ASTS-18, ORBIT.ASTS+22, 0.6, 1.8, 0.25, 0.55);
    seedBelt(kuiperBelt,1200, ORBIT.KUIPER_INNER, ORBIT.KUIPER_OUTER, 0.5, 1.4, 0.15, 0.45);
    rebuildParticles();
    drawGalacticCompass();
  }

  function updateViewScale(){
    scale = scaleBase * userZoom;
    vx = cx + panX; vy = cy + panY;
    zoomVal.textContent = userZoom.toFixed(2) + '×';
  }

  window.addEventListener('resize', resize, { passive:true });
  resize();

  // === Interaction: zoom & pan ===
  function setZoomAround(mx,my,newZ){
    newZ = Math.min(6, Math.max(0.35, newZ));
    const oldZ = userZoom; if (newZ===oldZ) return;
    panX = (panX - (mx - cx)) * (newZ/oldZ) + (mx - cx);
    panY = (panY - (my - cy)) * (newZ/oldZ) + (my - cy);
    userZoom = newZ; updateViewScale();
  }

  canvas.addEventListener('wheel', (e)=>{
    const speed = (e.deltaMode === 1 ? 0.1 : 0.0015);
    const sgn = (e.deltaY>0? -1 : 1);
    const factor = Math.exp(sgn * 120 * speed * (e.ctrlKey?1.8:1));
    setZoomAround(e.clientX, e.clientY, userZoom * factor);
    e.preventDefault();
  }, { passive:false });

  let dragging=false, lastX=0, lastY=0;
  let hoverX=0, hoverY=0, selected = null, lastHoverCandidate=null;
  canvas.addEventListener('pointerdown', (e)=>{ dragging=true; lastX=e.clientX; lastY=e.clientY; canvas.classList.add('dragging'); canvas.setPointerCapture(e.pointerId); });
  canvas.addEventListener('pointermove', (e)=>{ if(!dragging) { hoverX=e.clientX; hoverY=e.clientY; return; } panX += (e.clientX-lastX); panY += (e.clientY-lastY); lastX=e.clientX; lastY=e.clientY; updateViewScale(); });
  canvas.addEventListener('pointerup',   (e)=>{ dragging=false; canvas.classList.remove('dragging'); canvas.releasePointerCapture(e.pointerId); });
  canvas.addEventListener('dblclick', (e)=>{ panX=0; panY=0; setZoomAround(cx, cy, 1); });
  resetView.addEventListener('click', ()=>{ panX=0; panY=0; setZoomAround(cx, cy, 1); });
  zoomSlider.addEventListener('input', ()=>{ setZoomAround(cx, cy, parseFloat(zoomSlider.value)||1); });
  canvas.addEventListener('click', ()=>{ if (lastHoverCandidate) { selected = lastHoverCandidate; updateDatabox(selected, simTime/YEAR_UNIT); setObserverFromSelected(selected); }});

  timewarp.addEventListener('input', ()=>{ timeVal.textContent = (parseFloat(timewarp.value)||1).toFixed(2)+'×'; });
  particleDensity.addEventListener('input', ()=>{ pdv.textContent = Math.round(parseFloat(particleDensity.value)*100)+'%'; rebuildParticles(); });
  pdv.textContent = Math.round(parseFloat(particleDensity.value)*100)+'%';
  timeVal.textContent = (parseFloat(timewarp.value)||1).toFixed(2)+'×';

  // Time control logic
  let paused = false;
  playBtn.addEventListener('click', ()=>{ paused = !paused; playBtn.textContent = paused ? '▶ Play' : '⏸ Pause'; });
  function stepSim(dir){ const days = Math.max(0.0001, parseFloat(stepDays.value)||1); const yrs = days/365.25; simTime += dir * yrs * YEAR_UNIT; }
  stepBack.addEventListener('click', ()=> stepSim(-1));
  stepFwd.addEventListener('click', ()=> stepSim(+1));
  jumpBtn.addEventListener('click', ()=>{ const idx = parseInt(eventSel.value); if (!isNaN(idx) && lastEvents[idx]) { stepSim(lastEvents[idx].t / (1/365.25) /* convert yr to days factor inside stepSim? no direct add */); simTime += lastEvents[idx].t * YEAR_UNIT; } });

  // === Animation ===
  let last = performance.now();
  function loop(now){
    const dt = (now - last) / 1000; last = now;
    const base = (parseFloat(speedSlider.value) || 1);
    const warp = (parseFloat(timewarp.value) || 1);
    const tz = tieTZ.checked ? (1/Math.max(0.35, userZoom)) : 1;
    let timeFactor = base * warp * tz;
    if (paused) timeFactor = 0;
    simTime += dt * timeFactor;

    // Background
    const bg = ctx.createRadialGradient(vx, vy, 0, vx, vy, Math.max(w, h)*0.7);
    bg.addColorStop(0, '#111824'); bg.addColorStop(1, '#060910');
    ctx.fillStyle = bg; ctx.fillRect(0,0,w,h);
    ctx.drawImage(starsCanvas, 0, 0, starsCanvas.width/dpr, starsCanvas.height/dpr);

    // Cosmic particles
    drawParticles(dt, timeFactor);

    // Field overlays
    if (showEquip.checked) drawEquipotentials();
    if (showGravField.checked) drawGravField();
    if (showParker.checked) drawParkerSpiral();

    // Oort Cloud
    if (showOort.checked) drawOortCloud();

    // Orbits
    if (showOrbits.checked) {
      ctx.strokeStyle = 'rgba(110,168,235,0.12)'; ctx.lineWidth = 1;
      for (const p of planets) { ctx.beginPath(); ctx.arc(vx, vy, p.orbit * scale, 0, TAU); ctx.stroke(); }
      ctx.beginPath(); ctx.arc(vx, vy, ORBIT.ASTS * scale, 0, TAU); ctx.stroke();
      if (showKuiper.checked) {
        ctx.setLineDash([4,4]); ctx.lineDashOffset = -simTime*2; ctx.strokeStyle = 'rgba(120,180,255,0.12)';
        ctx.beginPath(); ctx.arc(vx, vy, ORBIT.KUIPER_INNER*scale, 0, TAU); ctx.stroke();
        ctx.beginPath(); ctx.arc(vx, vy, ORBIT.KUIPER_OUTER*scale, 0, TAU); ctx.stroke();
        ctx.setLineDash([]);
      }
    }

    drawBelt(mainBelt, (simTime/60) * TAU);
    if (showKuiper.checked) { drawBelt(kuiperBelt, (simTime/400) * TAU, true); if (showLabels.checked) label(vx + (ORBIT.KUIPER_OUTER+10)*scale, vy, 'Kuiper Belt'); }

    // Planets
    for (const p of planets) {
      const ang = (simTime / p.period) * TAU;
      const px = vx + Math.cos(ang) * (p.orbit * scale);
      const py = vy + Math.sin(ang) * (p.orbit * scale);
      drawPlanet(px, py, p.size * scale, p.color);
      if (p.ring) drawSaturnRing(px, py, scale);
      if (showLabels.checked) label(px + 8, py - 8, p.name);
      if (p.moons && p.moons.length) for (const m of p.moons) {
        const ma = (simTime / m.period) * TAU;
        const mx = px + Math.cos(ma) * (m.orbit * scale);
        const my = py + Math.sin(ma) * (m.orbit * scale);
        if (showMoonOrbits.checked) { ctx.strokeStyle = 'rgba(110,168,235,0.10)'; ctx.beginPath(); ctx.arc(px, py, m.orbit * scale, 0, TAU); ctx.stroke(); }
        drawMoon(mx, my, m.size * scale, m.color);
        if (showLabels.checked && m.size * scale >= 1.0) label(mx + 4, my - 4, m.name, 10);
      }
    }

    // Spacecraft
    if (showCraft.checked) drawSpacecraftLayer();

    // Small bodies + dilation spots
    const years = simTime / YEAR_UNIT;
    let hoverCandidate = null; let hoverDistPx = 1e9; lastHoverCandidate = null;
    if (showAsteroids.checked || showDwarfs.checked || showComets.checked) {
      const horizonY = parseFloat(horizon.value) || 50;
      const steps = Math.max(60, Math.min(360, Math.round(horizonY * 6)));

      for (const b of smallBodies) {
        if (b.kind==='asteroid' && !showAsteroids.checked) continue;
        if (b.kind==='dwarf'    && !showDwarfs.checked) continue;
        if (b.kind==='comet'    && !showComets.checked) continue;

        const P = elementsToAU(b, years);
        const pos = AUVecToPx(P.x, P.y);

        // Prediction path + dilation hotspot
        let best = { ratio: 1, x: pos.x, y: pos.y, t: 0 };
        if (showPredict.checked) {
          ctx.save(); const dotted = (b.i||0) > 90; if (dotted) ctx.setLineDash([3,3]);
          ctx.globalAlpha = 0.75; ctx.strokeStyle = b.color; ctx.lineWidth = 1; ctx.beginPath();
          for (let s=0; s<=steps; s++) { const t = years + (s/steps)*horizonY; const st = stateFor(b, t); const ratio = combinedRatio(st.v_kms, st.r_km); const q = AUVecToPx(st.x, st.y); if (s===0) ctx.moveTo(q.x, q.y); else ctx.lineTo(q.x, q.y); if (ratio < best.ratio){ best = { ratio, x:q.x, y:q.y, t:(t-years) }; } }
          ctx.stroke(); ctx.setLineDash([]); ctx.restore();
          label(best.x + 6, best.y - 6, `⏱×${formatRatio(best.ratio)} @ ${best.t.toFixed(1)}yr`, 10);
        }

        // Tail (comets)
        if (showTrails.checked && b.kind==='comet') {
          const trailSteps = 40; ctx.save(); ctx.globalAlpha = 0.6; ctx.strokeStyle = shade(b.color, -10); ctx.beginPath();
          for (let s=0; s<=trailSteps; s++) { const t = years - (s/trailSteps) * Math.min(5, b.P*0.25); const Q = elementsToAU(b, t); const q = AUVecToPx(Q.x, Q.y); if (s===0) ctx.moveTo(pos.x, pos.y); else ctx.lineTo(q.x, q.y); }
          ctx.stroke(); ctx.restore();
        }

        // Glyph
        const rpx = Math.max(0.9, (b.size||1.5) * scale);
        const g = ctx.createRadialGradient(pos.x - rpx*0.35, pos.y - rpx*0.35, rpx*0.1, pos.x, pos.y, rpx);
        g.addColorStop(0, 'rgba(255,255,255,.9)'); g.addColorStop(0.25, b.color); g.addColorStop(1, 'rgba(0,0,0,.8)');
        ctx.fillStyle = g; ctx.beginPath(); ctx.arc(pos.x, pos.y, rpx, 0, TAU); ctx.fill();
        if (showLabels.checked) label(pos.x + 5, pos.y - 5, b.label || b.name, 10);

        // Hover pick
        const dx = pos.x - hoverX, dy = pos.y - hoverY, d2 = dx*dx + dy*dy;
        if (d2 < hoverDistPx) { hoverDistPx = d2; hoverCandidate = { ...b, pos:pos, state:P }; }
      }
    }

    // Sun
    drawSun(vx, vy, sun.r * scale, sun.glow * scale);
    if (showLabels.checked) label(vx + 10, vy - 10, 'Sun');

    // Hover crosshair + store
    if (hoverCandidate && Math.sqrt(hoverDistPx) < 16) {
      ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.setLineDash([3,3]); ctx.beginPath(); ctx.arc(hoverCandidate.pos.x, hoverCandidate.pos.y, 8, 0, TAU); ctx.stroke(); ctx.restore();
      lastHoverCandidate = hoverCandidate;
    } else {
      lastHoverCandidate = null;
    }

    // Panels
    if (showRisk.checked) { computeAndRenderRisk(years); riskbox.hidden = false; } else { riskbox.hidden = true; }
    updatePhysbox(years);
    updateDatabox(lastHoverCandidate, years);
    updateTimeline(years);
    drawGalacticCompass();

    // Calendar label
    const epoch = parseFloat(epochYear.value)||2025; const cal = epoch + years; calendarLabel.textContent = cal.toFixed(2);

    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);

  // Drawing helpers
  function drawBelt(belts, rot=0, bluish=false){
    ctx.save(); ctx.translate(vx, vy); ctx.rotate(rot);
    for (const b of belts) { const x = Math.cos(b.a) * (b.r * scale); const y = Math.sin(b.a) * (b.r * scale); ctx.globalAlpha = b.alpha; ctx.fillStyle = bluish ? `rgba(${b.tint},${b.tint+10},255,1)` : `rgba(${b.tint},${b.tint},${b.tint},1)`; ctx.beginPath(); ctx.arc(x, y, Math.max(0.25, b.s * scale), 0, TAU); ctx.fill(); }
    ctx.globalAlpha = 1; ctx.restore();
  }

  function drawPlanet(x, y, r, color){ const halo = ctx.createRadialGradient(x, y, 0, x, y, r * 2.2); halo.addColorStop(0, color); halo.addColorStop(0.75, color); halo.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(x, y, r * 1.35, 0, TAU); ctx.fill(); const body = ctx.createRadialGradient(x - r*0.45, y - r*0.45, r*0.1, x, y, r); body.addColorStop(0.0, 'rgba(255,255,255,0.85)'); body.addColorStop(0.25, color); body.addColorStop(1.0, shade(color, -35)); ctx.fillStyle = body; ctx.beginPath(); ctx.arc(x,y,r,0,TAU); ctx.fill(); }
  function drawMoon(x, y, r, color){ const g = ctx.createRadialGradient(x - r*0.4, y - r*0.4, r*0.05, x, y, r); g.addColorStop(0, 'rgba(255,255,255,0.9)'); g.addColorStop(0.25, color); g.addColorStop(1, shade(color, -40)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, Math.max(0.9, r), 0, TAU); ctx.fill(); }
  function drawSaturnRing(x, y, s){ ctx.save(); ctx.translate(x, y); ctx.rotate(-0.6); ctx.lineWidth = Math.max(1, 6 * s); ctx.strokeStyle = 'rgba(223,211,169,0.88)'; ctx.beginPath(); ctx.ellipse(0, 0, 14 * s, 7 * s, 0, 0, TAU); ctx.stroke(); ctx.lineWidth = Math.max(1, 2.5 * s); ctx.strokeStyle = 'rgba(200,190,150,0.65)'; ctx.beginPath(); ctx.ellipse(0, 0, 11.5 * s, 5.8 * s, 0, 0, TAU); ctx.stroke(); ctx.restore(); }
  function drawSun(x, y, r, glow){ const outer = ctx.createRadialGradient(x, y, 0, x, y, glow); outer.addColorStop(0, 'rgba(255,210,60,0.65)'); outer.addColorStop(1, 'rgba(255,160,30,0)'); ctx.fillStyle = outer; ctx.beginPath(); ctx.arc(x, y, glow, 0, TAU); ctx.fill(); const core = ctx.createRadialGradient(x, y, 0, x, y, r * 2.2); core.addColorStop(0, '#ffd24a'); core.addColorStop(0.4, '#f9b700'); core.addColorStop(1, '#e06317'); ctx.fillStyle = core; ctx.beginPath(); ctx.arc(x, y, r * 1.6, 0, TAU); ctx.fill(); ctx.fillStyle = '#ffd24a'; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill(); }
  function drawOortCloud(){ const inner = ORBIT.KUIPER_OUTER * scale * 1.05; const outer = Math.min(Math.min(w, h)/2 - margin*0.5, ORBIT.KUIPER_OUTER*scale*1.8); const g = ctx.createRadialGradient(vx, vy, inner, vx, vy, outer); g.addColorStop(0.00, 'rgba(173,216,255,0.02)'); g.addColorStop(0.40, 'rgba(120,180,255,0.05)'); g.addColorStop(1.00, 'rgba(120,180,255,0.00)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(vx, vy, outer, 0, TAU); ctx.fill(); if (showLabels.checked) label(vx + inner + 20, vy + 10, 'Oort Cloud'); }
  function label(x, y, text, size=11){ ctx.save(); ctx.font = `500 ${size}px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto`; ctx.fillStyle = 'rgba(210,230,255,0.9)'; ctx.strokeStyle = 'rgba(5,10,20,0.6)'; ctx.lineWidth = 3; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.restore(); }
  function shade(hex, amt){ let c = hex.replace('#',''); if (c.length === 3) c = c.split('').map(x => x+x).join(''); const n = parseInt(c, 16); let r = (n>>16)+amt, g=((n>>8)&255)+amt, b=(n&255)+amt; r = Math.max(0,Math.min(255,r)); g = Math.max(0,Math.min(255,g)); b = Math.max(0,Math.min(255,b)); return `rgb(${r},${g},${b})`; }

  function drawSpacecraftLayer(){
    for (const s of spacecraft) {
      const ang = (s.headingDeg * Math.PI/180) + s.spin * simTime;
      const nativePx = auToPxNative(s.baseAU);
      const rNative = compressAU.checked ? compressPxRadius(nativePx) : nativePx;
      const rx = vx + Math.cos(ang) * (rNative * scale);
      const ry = vy + Math.sin(ang) * (rNative * scale);
      if (showCraftPaths.checked) { ctx.save(); ctx.setLineDash([6,6]); ctx.lineDashOffset = -simTime*8; ctx.strokeStyle = s.color; ctx.globalAlpha = 0.7; ctx.beginPath(); ctx.moveTo(vx, vy); ctx.lineTo(rx, ry); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); }
      ctx.save(); ctx.translate(rx, ry); ctx.rotate(ang); ctx.fillStyle = s.color; ctx.strokeStyle = 'rgba(0,0,0,0.6)'; ctx.lineWidth = 1.5; const k = Math.max(0.75, 1.2 * scale); ctx.beginPath(); ctx.moveTo( 6*k, 0); ctx.lineTo(-6*k, -3*k); ctx.lineTo(-4*k,  0); ctx.lineTo(-6*k,  3*k); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore();
      if (showLabels.checked) label(rx + 8, ry - 6, `${s.name} — ~${Math.round(s.baseAU)} AU`);
    }
  }

  // Physics & data panel updates
  function updatePhysbox(years){
    const st = stateFor(observer, years);
    const sr = srFactor(st.v_kms);
    const gr = grFactor(st.r_km);
    const comb = combinedRatio(st.v_kms, st.r_km);
    const g_kms2 = (MU_SUN / (st.r_km*st.r_km)); // km/s^2
    const g_ms2 = g_kms2 * 1000;
    const parts = [
      ['Atomic t (sim years)', (simTime/YEAR_UNIT).toFixed(3)],
      ['Observer', observer.name || '—'],
      ['r (AU)', st.r.toFixed(6)],
      ['v (km/s)', (st.v_kms).toFixed(3)],
      ['SR γ', sr.toPrecision(12)],
      ['GR √(1-2GM/rc²)', gr.toPrecision(12)],
      ['dt_local / dt_atomic', comb.toPrecision(12) + ' (approx)'],
      ['g(r)', g_ms2.toExponential(3) + ' m/s²'],
      ['Φ(r)/μ', (-1/st.r).toFixed(6) + ' (−1/r)']
    ];
    physkv.innerHTML = parts.map(p=>`<div>${p[0]}</div><div><strong>${p[1]}</strong></div>`).join('');
  }

  // Close-approach & perihelion helpers
  function computeCloseApproachFor(body, years, horizonY){
    const steps = Math.max(120, Math.min(1200, Math.round(horizonY * 12)));
    let dmin = 1e9, tmin = 0;
    for (let s=0; s<=steps; s++){
      const t = years + (s/steps)*horizonY;
      const E = elementsToAU(earthEl, t);
      const Q = elementsToAU(body, t);
      const d = Math.hypot(Q.x-E.x, Q.y-E.y, (Q.z||0));
      if (d < dmin){ dmin = d; tmin = t - years; }
    }
    return { dmin, tmin };
  }

  function updateDatabox(hoverCandidate=null, years){
    const horizonY = parseFloat(horizon.value) || 50;
    let sel = selected || hoverCandidate || earthEl;
    const hasEl = (sel.a != null);
    let st = hasEl ? stateFor(sel, years) : stateFor(earthEl, years);
    const peri = findPerihelionWithin(sel, years, horizonY);
    const ca = hasEl ? computeCloseApproachFor(sel, years, horizonY) : null;

    const kv = [];
    kv.push(['Name', sel.label || sel.name || '—']);
    kv.push(['Kind', sel.kind || '—']);
    if (hasEl){
      kv.push(['a (AU)', (sel.a||0).toFixed(5)]);
      kv.push(['e', (sel.e||0).toFixed(5)]);
      kv.push(['i (°)', (sel.i||0).toFixed(2)]);
      kv.push(['Ω (°)', (sel.Ω||0).toFixed(2)]);
      kv.push(['ω (°)', (sel.ω||sel.w||0).toFixed(2)]);
      kv.push(['P (yr)', (sel.P||0).toFixed(3)]);
    }
    kv.push(['r now (AU)', st.r.toFixed(6)]);
    kv.push(['v now (km/s)', (st.v_kms).toFixed(3)]);
    if (ca){ kv.push(['Closest in', ca.tmin.toFixed(2)+' yr']); kv.push(['Min dist (AU)', ca.dmin.toFixed(5)]); }
    if (peri){ kv.push(['Next perihelion', (peri.t!=null?peri.t.toFixed(2)+' yr':'—')]); kv.push(['q (AU)', peri.q.toFixed(5)]); }

    selkv.innerHTML = kv.map(p=>`<div>${p[0]}</div><div><strong>${p[1]}</strong></div>`).join('');

    copyBtn.onclick = async ()=>{
      try{
        const payload = { name: sel.name, label: sel.label, kind: sel.kind, elements: { a: sel.a, e: sel.e, i: sel.i, Omega: sel.Ω, omega: sel.ω||sel.w, P: sel.P }, state: { r_AU: st.r, v_kms: st.v_kms }, closest_approach: ca, perihelion: peri };
        await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
        copiedMsg.textContent = 'Copied!'; setTimeout(()=>copiedMsg.textContent='', 1500);
      }catch(e){ copiedMsg.textContent = 'Copy failed'; setTimeout(()=>copiedMsg.textContent='', 1500); }
    };
  }

  function findPerihelionWithin(body, years, horizonY){
    if (body.a == null) return null;
    const steps = Math.max(120, Math.min(1200, Math.round(horizonY * 10)));
    let rmin = 1e9, tmin = null, q = null;
    for (let s=0; s<=steps; s++){
      const t = years + (s/steps)*horizonY;
      const st = elementsToAU(body, t);
      if (st.r < rmin){ rmin = st.r; tmin = t - years; }
    }
    if (rmin < 1e-6) return null;
    q = body.a * (1 - (body.e||0));
    return { t: tmin, q };
  }

  // Risk forecast
  function computeAndRenderRisk(years){
    const horizonY = parseFloat(horizon.value) || 50;
    const steps = Math.max(100, Math.min(600, Math.round(horizonY * 8)));
    let items = [];
    for (const b of smallBodies) {
      if (b.kind==='asteroid' && !showAsteroids.checked) continue;
      if (b.kind==='dwarf'    && !showDwarfs.checked) continue;
      if (b.kind==='comet'    && !showComets.checked) continue;
      let dmin = 1e9, tmin = 0;
      for (let s=0; s<=steps; s++){
        const t = years + (s/steps)*horizonY;
        const E = elementsToAU(earthEl, t);
        const Q = elementsToAU(b, t);
        const d = Math.hypot(Q.x-E.x, Q.y-E.y, (Q.z||0));
        if (d < dmin){ dmin = d; tmin = t - years; }
      }
      const status = (dmin < 0.0026) ? 'alert' : (dmin < 0.005) ? 'warn' : (dmin < 0.02) ? 'watch' : 'ok';
      items.push({ name:b.label||b.name, kind:b.kind, color:b.color, dmin, tmin, status });
    }
    items.sort((a,b)=> a.dmin-b.dmin);

    riskmeta.textContent = `Scanning ${items.length} objects out to ${horizonY} years (Earth-relative, illustrative).`;
    risklist.innerHTML = '';
    for (const it of items){
      const el = document.createElement('div'); el.className = `riskitem ${it.status}`;
      const dist = it.dmin.toFixed(4);
      const when = (it.tmin>=0)? `${it.tmin.toFixed(1)} yr` : `now`;
      el.innerHTML = `<div><strong style="color:${it.color}">✹ ${it.name}</strong> <span style="opacity:.7">(${it.kind})</span><br><span class="when">Closest in ${when}</span></div><div><strong>${dist} AU</strong></div>`;
      risklist.appendChild(el);
    }
  }

  // Special events timeline
  let lastEvents = [];
  let lastEventsKey = '';
  function updateTimeline(years){
    const horizonY = parseFloat(horizon.value) || 50;
    const events = [];
    for (const b of smallBodies){
      const peri = findPerihelionWithin(b, years, horizonY); if (peri && peri.t != null) events.push({ t: peri.t, tag:'peri', name: b.label||b.name, color:b.color, detail:`q=${peri.q.toFixed(3)} AU` });
      const ca = computeCloseApproachFor(b, years, horizonY); if (ca){ const st = (ca.dmin < 0.0026)?'alert':(ca.dmin<0.005?'warn':(ca.dmin<0.02?'watch':null)); if (st) events.push({ t: ca.tmin, tag: st, name:b.label||b.name, color:b.color, detail:`${ca.dmin.toFixed(4)} AU` }); }
    }
    events.sort((a,b)=> a.t-b.t);

    const timeline = document.getElementById('timeline'); timeline.innerHTML = '';
    for (const ev of events.slice(0, 40)){
      const div = document.createElement('div'); div.className='ev';
      div.innerHTML = `<span class="tag" style="border-color:${ev.color}; color:${ev.color}">${ev.tag}</span><div><strong>${ev.name}</strong><br><span style="opacity:.75">in ${ev.t.toFixed(2)} yr — ${ev.detail}</span></div><div></div>`;
      timeline.appendChild(div);
    }

    // refresh jump selector if changed
    const key = events.slice(0,15).map(e=>`${e.tag}:${e.name}:${e.t.toFixed(2)}`).join('|');
    if (key !== lastEventsKey){
      lastEventsKey = key; lastEvents = events.slice(0,15);
      eventSel.innerHTML = lastEvents.map((e,i)=>`<option value="${i}">${e.tag} • ${e.name} • in ${e.t.toFixed(2)} yr</option>`).join('');
    }
  }

  // Galactic compass (stylized)
  // ECL_TO_GAL defined earlier (hoisted)
  function drawGalacticCompass(){
    const r = 36; const cx0 = 90; const cy0 = 44; // inside #gcomp
    const ang = (simTime / 12) * TAU; // Earth's visual angle
    const galAng = ang + ECL_TO_GAL;
    gcomp.innerHTML = `<svg width="180" height="84" viewBox="0 0 180 84" xmlns="http://www.w3.org/2000/svg">
      <g fill="none" stroke="rgba(158,199,255,0.65)" stroke-width="1">
        <circle cx="${cx0}" cy="${cy0}" r="${r}"/>
        <line x1="${cx0}" y1="${cy0-r}" x2="${cx0}" y2="${cy0+r}" stroke-dasharray="2 3"/>
        <line x1="${cx0-r}" y1="${cy0}" x2="${cx0+r}" y2="${cy0}" stroke-dasharray="2 3"/>
      </g>
      <g>
        <path d="M ${cx0} ${cy0} L ${cx0 + r*Math.cos(galAng)} ${cy0 + r*Math.sin(galAng)}" stroke="#ffd166" stroke-width="2"/>
        <circle cx="${cx0}" cy="${cy0}" r="2" fill="#ffd166"/>
      </g>
      <text x="${cx0}" y="16" text-anchor="middle" fill="#cfe3ff" font-size="11">Galactic compass (stylized)</text>
      <text x="${cx0}" y="76" text-anchor="middle" fill="#9ec7ff" font-size="10">GC → arrow · l≈${((galAng)%(TAU)+TAU)%TAU * 180/Math.PI | 0}°, b≈0°</text>
    </svg>`;
  }

  // Observer list
  function populateObserverSel(){
    const options = [earthEl, ...smallBodies];
    observerSel.innerHTML = options.map(o=>`<option value="${o.name}">${o.label||o.name}</option>`).join('');
    observerSel.value = 'Earth';
  }
  observerSel.addEventListener('change', ()=>{
    const name = observerSel.value; const found = (name==='Earth')? earthEl : smallBodies.find(b=>b.name===name);
    if (found){ observer = found; obsBadge.textContent = found.label||found.name; }
  });
  populateObserverSel();

  // === Field overlay renderers ===
  function drawEquipotentials(){
    ctx.save(); ctx.strokeStyle = 'rgba(120,180,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([2,3]);
    const radii = [0.4,0.7,1,1.5,2,3,5,8,13,21,34];
    for (const rAU of radii){
      const p = AUVecToPx(rAU, 0); // use x=r,y=0 to get scaled radius in px
      const R = Math.hypot(p.x - vx, p.y - vy);
      ctx.beginPath(); ctx.arc(vx, vy, R, 0, TAU); ctx.stroke();
      if (showLabels.checked){ label(vx + R + 6, vy + 12, `Φ∝−1/r @ ${rAU} AU`, 10); }
    }
    ctx.setLineDash([]); ctx.restore();
  }

  function drawGravField(){
    ctx.save(); ctx.strokeStyle = 'rgba(255,230,160,0.25)'; ctx.fillStyle = 'rgba(255,230,160,0.35)';
    const rings = [0.5, 0.8, 1, 1.5, 2, 3, 5, 8, 13, 21, 34];
    for (const rAU of rings){
      const Rpx = Math.hypot(AUVecToPx(rAU,0).x - vx, AUVecToPx(rAU,0).y - vy);
      const N = Math.max(8, Math.round(24 * Math.sqrt(rAU)));
      for (let k=0;k<N;k++){
        const th = (k/N) * TAU;
        const x = vx + Math.cos(th) * Rpx;
        const y = vy + Math.sin(th) * Rpx;
        const L = Math.max(4, 18 / (rAU*rAU));
        const tx = x - Math.cos(th) * L;
        const ty = y - Math.sin(th) * L;
        ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(tx, ty); ctx.stroke();
        // arrow head
        const ah = 3; const ang = Math.atan2(ty-y, tx-x);
        ctx.beginPath(); ctx.moveTo(tx, ty);
        ctx.lineTo(tx + Math.cos(ang+2.7)*ah, ty + Math.sin(ang+2.7)*ah);
        ctx.lineTo(tx + Math.cos(ang-2.7)*ah, ty + Math.sin(ang-2.7)*ah);
        ctx.closePath(); ctx.fill();
      }
    }
    ctx.restore();
  }

  function drawParkerSpiral(){
    ctx.save(); ctx.strokeStyle = 'rgba(100,220,255,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4,4]);
    const arms = 6; const phase = simTime * 0.2;
    for (let a=0;a<arms;a++){
      const th0 = (a/arms) * TAU + phase*0.3;
      ctx.beginPath();
      let moved=false;
      for (let th=th0; th<th0+6.5; th+=0.02){
        const r_km = (V_SW / OMEGA_SUN) * (th - th0); // km
        const r_AU = r_km / AU_KM;
        if (r_AU > 120) break; // limit
        const q = AUVecToPx(r_AU*Math.cos(th), r_AU*Math.sin(th));
        if (!moved){ ctx.moveTo(q.x, q.y); moved=true; } else ctx.lineTo(q.x, q.y);
      }
      ctx.stroke();
    }
    ctx.setLineDash([]); ctx.restore();
    if (showLabels.checked) label(vx + 10, vy + 16, 'Parker spiral (stylized)');
  }

})();
</script>
</body>
</html>
likes 0comments 0
Amplitude Timelinehtml
Data Viz
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Multidimensional Timeline Calendar</title>
    <!-- Bootstrap 5 CDN -->
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <style>
      :root {
        /* category colours (extend as needed) */
        --climate: #86d06d;
        --astronomy: #63a2ff;
        --culture: #ff66aa;
        --mythic: #f9bf3b;
        --economy: #ff8763;
      }
      html,
      body {
        height: 100%;
        margin: 0;
        font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
          Arial, sans-serif;
      }
      #app {
        height: 100%;
        display: flex;
        flex-direction: column;
      }
      /* controls */
      #controls {
        background: #212123;
        color: #f8f9fa;
      }
      #timelineWrapper {
        flex: 1 1 auto;
        overflow-y: auto;
        position: relative;
        background: #f7f0e6;
      }
      /* svg timeline */
      svg.timeline-svg {
        position: absolute;
        left: 0;
        top: 0;
      }
      .timeline-base {
        stroke: #212123;
        stroke-width: 2px;
      }
      .event-circle {
        stroke: #212123;
        stroke-width: 1;
        fill-opacity: 0.6;
        transition: r 0.2s ease, fill-opacity 0.2s ease;
        cursor: pointer;
      }
      .event-circle:hover {
        r: calc(var(--r) + 8px);
        fill-opacity: 1;
      }
      .event-label {
        font-size: 0.85rem;
        fill: #212123;
        pointer-events: none;
        user-select: none;
      }
      .event-date {
        font-size: 0.75rem;
        fill: #666;
        pointer-events: none;
        user-select: none;
      }
      /* colour mapping for categories */
      .cat-astronomy {
        fill: var(--astronomy);
      }
      .cat-climate {
        fill: var(--climate);
      }
      .cat-culture {
        fill: var(--culture);
      }
      .cat-mythic {
        fill: var(--mythic);
      }
      .cat-economy {
        fill: var(--economy);
      }
    </style>
  </head>
  <body>
    <div id="app">
      <!-- CONTROLS -->
      <nav id="controls" class="navbar navbar-dark px-3 py-2">
        <div class="navbar-brand">Multidimensional Timeline</div>
        <form class="d-flex gap-3 flex-wrap" role="search" id="controlForm">
          <select id="calendarSelect" class="form-select form-select-sm">
            <option value="gregorian">Gregorian</option>
            <option value="julian">Julian</option>
            <option value="mayan">Mayan Long Count</option>
            <option value="chinese">Chinese Zodiac</option>
            <option value="indian">Indian Lunisolar</option>
            <option value="aztec">Aztec Tonalpohualli</option>
            <option value="nazca">Nazca Horizon</option>
            <option value="abstract">Planetary Abstract</option>
          </select>
          <div class="form-check form-switch text-nowrap">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="liveDataSwitch"
            />
            <label class="form-check-label" for="liveDataSwitch"
              >Live Celestial Data</label
            >
          </div>
          <div class="form-check form-switch text-nowrap">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="fictionSwitch"
              checked
            />
            <label class="form-check-label" for="fictionSwitch"
              >Mythic &amp; Fictional</label
            >
          </div>
        </form>
      </nav>

      <!-- TIMELINE WRAPPER -->
      <div id="timelineWrapper">
        <svg class="timeline-svg" id="timelineSvg"></svg>
      </div>
    </div>

    <!-- Optionally, Bootstrap JS (popper included) -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>

    <script>
      /* =============================================================
       *  Multidimensional Timeline Calendar – Vanilla JS (no libs)
       *  Author: ChatGPT o3, 2025
       *  =========================================================== */

      const SVG_NS = "http://www.w3.org/2000/svg";

      /* Utility: date helpers */
      function iso(date) {
        return date.toISOString().split("T")[0];
      }

      // Julian = Gregorian - 13 days (valid 1900‑2100 for demo)
      function toJulian(date) {
        const jul = new Date(date);
        jul.setDate(jul.getDate() - 13);
        return iso(jul);
      }

      // Stub converters (placeholders) – return symbolic strings
      function toMayanLongCount(date) {
        // Very rough: days since 2025‑06‑13 → add offset to 13.0.0.0.0 (Dec 21, 2012)
        const base = new Date("2012-12-21");
        const diff = Math.floor((date - base) / 864e5);
        return `13.${Math.floor(diff / 144000)}.${Math.floor((diff % 144000) / 7200)}.${Math.floor((diff % 7200) / 360)}.${diff % 360}`;
      }

      function toChineseZodiac(date) {
        const animals = [
          "Rat",
          "Ox",
          "Tiger",
          "Rabbit",
          "Dragon",
          "Snake",
          "Horse",
          "Goat",
          "Monkey",
          "Rooster",
          "Dog",
          "Pig",
        ];
        const baseYear = 1900; // Rat
        const idx = (date.getFullYear() - baseYear) % 12;
        return `${animals[idx]} Year`;
      }

      function toIndianLunisolar(date) {
        return `Paksha ${date.getDate() % 15}, Month ${(date.getMonth() + 1)}`;
      }

      function toAztec(date) {
        return `Tonalpohualli ${(date.getTime() / 864e5) % 260 | 0}`;
      }

      function toNazca(date) {
        return `Solstice offset ${(Math.abs(date - new Date(date.getFullYear(), 5, 21)) / 864e5).toFixed(0)}d`;
      }

      function toAbstract(date) {
        const k101start = new Date("2025-06-13");
        const days = Math.floor((date - k101start) / 864e5);
        return `K101+${days}`;
      }

      function convertDate(date, calendar) {
        switch (calendar) {
          case "julian":
            return toJulian(date);
          case "mayan":
            return toMayanLongCount(date);
          case "chinese":
            return toChineseZodiac(date);
          case "indian":
            return toIndianLunisolar(date);
          case "aztec":
            return toAztec(date);
          case "nazca":
            return toNazca(date);
          case "abstract":
            return toAbstract(date);
          default:
            return iso(date);
        }
      }

      /* Event generation (demo)  */
      const BASE_EVENTS = [
        {
          id: 1,
          date: new Date("2025-06-13"),
          label: "Friday 13 // K101 Era begins",
          category: "culture",
          intensity: 0.8,
        },
        {
          id: 2,
          date: new Date("2025-07-03"),
          label: "Earth Aphelion",
          category: "astronomy",
          intensity: 0.7,
        },
        {
          id: 3,
          date: new Date("2026-02-13"),
          label: "Super El Niño Peak",
          category: "climate",
          intensity: 0.9,
        },
      ];

      // Generate placeholder events year +/- range (fictional & real)
      function generateSyntheticEvents(yearsBack = 50, yearsForward = 50) {
        const synthetic = [];
        const categories = ["astronomy", "climate", "culture", "mythic", "economy"];
        const now = new Date();
        const startYear = now.getFullYear() - yearsBack;
        const endYear = now.getFullYear() + yearsForward;
        let id = 1000;
        for (let y = startYear; y <= endYear; y++) {
          // couple per year
          const count = Math.random() * 3 + 1;
          for (let i = 0; i < count; i++) {
            const d = new Date(y, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1);
            const cat = categories[(Math.random() * categories.length) | 0];
            synthetic.push({
              id: id++,
              date: d,
              label: `${cat.charAt(0).toUpperCase() + cat.slice(1)} Event`,
              category: cat,
              intensity: Math.random(),
            });
          }
        }
        return synthetic;
      }

      const EVENTS = [...BASE_EVENTS, ...generateSyntheticEvents()];

      /* SVG Timeline rendering */
      const timelineWrapper = document.getElementById("timelineWrapper");
      const svg = document.getElementById("timelineSvg");

      // timeline dimensions will be recalculated on resize/scroll
      let timelineX = 120;
      const marginTop = 40;
      const marginBottom = 40;
      const circleRadiusMin = 6;
      const circleRadiusMax = 28;

      // scale helpers
      function linearScale(domainMin, domainMax, rangeMin, rangeMax) {
        return (val) => {
          const ratio = (val - domainMin) / (domainMax - domainMin || 1);
          return rangeMin + ratio * (rangeMax - rangeMin);
        };
      }

      function renderTimeline() {
        // Clean svg
        svg.innerHTML = "";

        const wrapperRect = timelineWrapper.getBoundingClientRect();
        const width = wrapperRect.width;
        const height = EVENTS.length * 80 + marginTop + marginBottom; // rough initial height
        svg.setAttribute("width", width);
        svg.setAttribute("height", height);

        // baseline
        const baseLine = document.createElementNS(SVG_NS, "line");
        baseLine.setAttribute("x1", timelineX);
        baseLine.setAttribute("y1", 0);
        baseLine.setAttribute("x2", timelineX);
        baseLine.setAttribute("y2", height);
        baseLine.setAttribute("class", "timeline-base");
        svg.appendChild(baseLine);

        // domain (time) – earliest to latest
        const minDate = EVENTS.reduce((a, b) => (a < b.date ? a : b.date), EVENTS[0].date);
        const maxDate = EVENTS.reduce((a, b) => (a > b.date ? a : b.date), EVENTS[0].date);
        const domainMin = minDate.getTime();
        const domainMax = maxDate.getTime();
        const scaleY = linearScale(domainMin, domainMax, marginTop, height - marginBottom);

        // radius scale – intensity based
        const scaleR = linearScale(0, 1, circleRadiusMin, circleRadiusMax);

        // calendar conversion
        const calendarType = document.getElementById("calendarSelect").value;

        EVENTS.forEach((ev, index) => {
          const y = scaleY(ev.date.getTime());
          const r = scaleR(ev.intensity);

          // group
          const g = document.createElementNS(SVG_NS, "g");

          // circle
          const circle = document.createElementNS(SVG_NS, "circle");
          circle.setAttribute("cx", timelineX);
          circle.setAttribute("cy", y);
          circle.setAttribute("r", r);
          circle.setAttribute("class", `event-circle cat-${ev.category}`);
          circle.style.setProperty("--r", r + "px");

          // label
          const label = document.createElementNS(SVG_NS, "text");
          label.setAttribute("x", timelineX + r + 12);
          label.setAttribute("y", y + 4);
          label.setAttribute("class", "event-label");
          label.textContent = ev.label;

          // date
          const dateTxt = document.createElementNS(SVG_NS, "text");
          dateTxt.setAttribute("x", timelineX - r - 12);
          dateTxt.setAttribute("y", y + 4);
          dateTxt.setAttribute("text-anchor", "end");
          dateTxt.setAttribute("class", "event-date");
          dateTxt.textContent = convertDate(ev.date, calendarType);

          // tooltip (simple using title)
          circle.setAttribute("title", `${ev.label}\n${convertDate(ev.date, calendarType)}`);

          g.appendChild(circle);
          g.appendChild(label);
          g.appendChild(dateTxt);
          svg.appendChild(g);
        });
      }

      // UI interactions
      document.getElementById("calendarSelect").addEventListener("change", renderTimeline);
      document.getElementById("fictionSwitch").addEventListener("change", (e) => {
        if (e.target.checked) {
          // ensure mythic are visible
          EVENTS.forEach((ev) => {
            if (ev.category === "mythic") ev.hidden = false;
          });
        } else {
          EVENTS.forEach((ev) => {
            if (ev.category === "mythic") ev.hidden = true;
          });
        }
        renderTimeline();
      });

      // Live data fetch (placeholder)
      document.getElementById("liveDataSwitch").addEventListener("change", async (e) => {
        if (e.target.checked) {
          // Example: fetch today’s NASA APOD date and add to timeline
          try {
            const res = await fetch(
              "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY"
            );
            const json = await res.json();
            EVENTS.push({
              id: Date.now(),
              date: new Date(json.date),
              label: `NASA APOD: ${json.title}`,
              category: "astronomy",
              intensity: 0.5,
            });
            renderTimeline();
          } catch (err) {
            console.error("API error", err);
          }
        }
      });

      // initial render
      renderTimeline();

      /* Infinite scroll: if near bottom, add more future events  */
      timelineWrapper.addEventListener("scroll", () => {
        const threshold = 300; // px from bottom
        if (
          timelineWrapper.scrollTop + timelineWrapper.clientHeight + threshold >=
          timelineWrapper.scrollHeight
        ) {
          // generate +10 future years events
          const futureEvents = generateSyntheticEvents(0, 10);
          EVENTS.push(...futureEvents);
          renderTimeline();
        }
      });

      // responsive resize
      window.addEventListener("resize", renderTimeline);
    </script>
  </body>
</html>
likes 0comments 0
Bubble Charthtml
Data viz model
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Bubble Chart – Native JS + Canvas + RK4 + Clusters</title>
  <style>
    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
      background: #fff;
      overflow: hidden;
      font-family: sans-serif;
    }
    canvas { display: block; }
  </style>
</head>
<body>
<canvas id="bubbleCanvas"></canvas>
<script>
// ------------------------------
// 1. CSV Data and Parser
// ------------------------------
const csvData = `id,groupid,size,name
1,1,9080,α
2,9,4610,β
3,2,3810,γ
4,1,2990,δ
5,1,2820,ε
6,3,2430,ζ
7,6,2400,η
8,3,2090,θ
9,3,1580,ι
10,7,1290,κ
11,10,1230,λ
12,1,1210,μ
13,3,829,ν
14,3,768,ξ
15,1,745,ο
16,3,651,π
17,3,589,ρ
18,1,569,σ
19,2,502,τ
20,3,441,υ
21,2,425,φ
22,5,388,χ
23,11,378,ψ
24,11,373,ω
25,3,369,1
26,12,364,2
27,15,359,3
28,16,349,4
29,16,340,5
30,3,338,6
31,16,330,7
32,13,306,8
33,12,301,9
34,12,283,0
35,1,268
36,3,268
37,17,266
38,17,264
39,3,262
40,3,256
41,5,243
42,11,237
43,13,223
44,12,222
45,10,220
46,99,220
47,1,212
48,19,201
49,19,193
50,3,190
51,1,189
52,3,188
53,1,186
54,1,179
55,3,179
56,16,174
57,5,172
58,3,165
59,6,165
60,3,164
61,1,163
62,1,157
63,3,149
64,2,147
65,3,145
66,10,142
67,11,138
68,3,128
69,3,120
70,2,97`;

function parseCSV(str) {
  const lines = str.trim().split(/\r?\n/);
  const header = lines[0].split(",");
  const data = [];
  for (let i = 1; i < lines.length; i++) {
    const row = lines[i].split(",");
    const obj = {};
    for (let j = 0; j < header.length; j++) {
      const key = header[j].trim();
      obj[key] = row[j] !== undefined ? row[j].trim() : "";
    }
    data.push(obj);
  }
  return data;
}

// ------------------------------
// 2. Utility Functions
// ------------------------------
function mapRange(value, inMin, inMax, outMin, outMax) {
  return outMin + (outMax - outMin) * ((value - inMin) / (inMax - inMin));
}

// ------------------------------
// 3. Bubble Class
// ------------------------------
class Bubble {
  constructor(d, radiusScale) {
    this.id = +d.id;
    this.groupid = d.groupid;
    this.size = +d.size;
    this.name = d.name || "";
    this.radius = radiusScale(this.size);
    // When dragging, fx and fy will hold fixed positions.
    this.fx = null;
    this.fy = null;
  }
}

// ------------------------------
// 4. BubbleChart Class with RK4 Integration and Group Clustering
// ------------------------------
class BubbleChart {
  constructor(data) {
    this.data = data;
    // Define group colors (mapping group id to a color)
    var palette = [
            

            ["#1D1B33", "#2F4858", "#FFEDCB", "#4B8178", "#7197A8", "#362E37"],
            ["#F4B53F", "#2F4858", "#FFEDCB", "#4B8178", "#C1554E", "#6A4C57"],
            ["#453C4C", "#B3AA74", "#FFEDCB", "#9F9D8A", "#7A6154", "#204E5E"],
            ["#4C4459", "#82B59D", "#FFEDCB", "#F3AB4E", "#A44440", "#204E5E"],
            ["#203239", "#96977B", "#FFEDCB", "#DB986F", "#964744", "#2C3637"],
            ["#344C5C", "#698DA0", "#BFB8A6", "#E8CFC1", "#D96D59", "#2B3967"],
            ["#263A5B", "#61898D", "#B4C4C5", "#FAE3C0", "#7B4B5A", "#B3BC99"],
            ["#7A8671", "#D2B27C", "#E4CC91", "#BB605A", "#7B4B5A", "#F0E9A9"],
            ["#ffa943", "#2177f4", "#35fc93", "#f9cfd2", "#6eabf4", "#3714a1"],
            ["#ce2d42", "#7462f9", "#f4b53f", "#123676", "#9c223d", "#e6c7b4"],
            ["#06a0ba", "#6f3bff", "#f20a41", "#8777f7", "#4848c1", "#e6c7b4"],
            ["#71f2ff", "#81fcca", "#f91cb0", "#0239c1", "#05bdc6", "#f7f1b4"],
            ["#302D3B", "#DBF7BD", "#879369", "#9A5154", "#C3C590", "#CAA174"],
            ["#25164D", "#BFD4BF", "#316C6F", "#494190", "#D3B74F", "#ECE5DE"],
            ["#624565", "#9B9589", "#E49E81", "#DB6A60", "#FAB582", "#E3B69A"],
            ["#594C98", "#372B33", "#FE0878", "#82D6DB", "#92D0AF", "#721F4C"],
            ["#F0DEB4", "#A1A17A", "#5A8170", "#F4F3CC", "#4B8178", "#FFC7A1"],
            ["#A42534", "#3F352F", "#B74C3B", "#D4AA71", "#DCCFB2", "#693239"],
            ["#665B55", "#F5B488", "#B55053", "#8B2335", "#69837B", "#F0D2B1"],
            ["#313D51", "#FBE8AA", "#EB917B", "#B15552", "#809488", "#337F83"],
            ["#042882", "#81fcca", "#f91cb0", "#0239c1", "#8450d6", "#05bdc6"],
            ["#304B61", "#281733", "#377F86", "#D1D1AE", "#DB6D6A", "#9AC7C3"],
            ["#2F677E", "#B5B383", "#C35F4F", "#D2E1D9", "#7FD1AE", "#FAE7BF"],
            ["#2A2B41", "#673939", "#377F86", "#E3D5AE", "#EFC375", "#281733"],
            ["#FF7E42", "#2D2E3C", "#FFE1C8", "#4F9472", "#D1594D", "#384C7D"],
            ["#FF7306", "#C7B18E", "#FFE3A4", "#7F4E4D", "#233072", "#6B97A8"],
            ["#FF6705", "#ED9C7B", "#FFE1A2", "#7F4E4D", "#154150", "#BAC292"],
            ["#f2c079", "#3c3c67", "#f7edcf", "#84a0a4", "#d22f2f", "#cfd5ed"],
            ["#505978", "#8ab984", "#f7d8c6", "#7f655d", "#c6d8f7", "#78A39B"],
            ["#2F3C3E", "#7CAB93", "#B6CDA9", "#F4F3CF", "#666460", "#C9A889"],
            ["#C1554E", "#0C3E4D", "#076269", "#C5B65B", "#F7C862", "#22BB9B"],
            ["#C1554E", "#477F82", "#22BB9B", "#DFD2A8", "#F7C862", "#63AC9C"],
            ["#3E3649", "#6D7180", "#B0ACAD", "#DFD2A8", "#F7C862", "#A6BC99"],
            ["#44E2D2", "#2365B8", "#645EBA", "#664785", "#F38073", "#2E93B7"],
            ["#E7A564", "#AC966F", "#596358", "#234564", "#EB7952", "#F0BA81"],
            ["#131C3B", "#2B3C61", "#4F77C9", "#8FC2DE", "#D2DCC0", "#265670"],
            ["#D62A58", "#122959", "#4F77C9", "#8FC2DE", "#D2DCC0", "#466C88"],
            ["#DFE3DB", "#99BEA5", "#51525F", "#6C6A36", "#3F4159", "#572D54"],
            ["#066A74", "#352F51", "#601449", "#EC4E25", "#F7954A", "#792023"],
            ["#131133", "#0A405E", "#EF4F56", "#68C3A0", "#F3EFCA", "#A5282F"],
            ["#234357", "#33AFA6", "#8FE2AD", "#DBEFA9", "#EACF7B", "#408AA8"],
            ["#FDFCF8", "#A2A295", "#5A5F5F", "#2C3D3C", "#252929", "#31576E"],
            ["#2F2930", "#707485", "#99AABE", "#B6E6E8", "#FBF8F2", "#C4BEBE"],
            ["#F4B53F", "#6A4C57", "#DBE3AA", "#6EC699", "#3C4549", "#A66648"],
            ["#F4B53F", "#6A4C57", "#212D4E", "#274B64", "#628367", "#6B293C"],
            ["#F4B53F", "#6A4C57", "#21B29F", "#406B77", "#333F5B", "#3A8EA2"],
            ["#432D3A", "#42495F", "#6F7E67", "#C0B37A", "#E9C268", "#E0DEAB"],
            ["#432D3A", "#42495F", "#368991", "#E5DAB6", "#EDAB58", "#ED7B4A"],
            ["#F3E6CA", "#DCAF8A", "#A8AD75", "#40818B", "#32374A", "#96B7B7"],
            ["#492E1B", "#732737", "#5D5969", "#8F8E71", "#E8D993", "#B25963"],
            ["#25272C", "#FBEFBD", "#DCB26C", "#386B67", "#0D3844", "#497084"],
            ["#25272C", "#FBEFBD", "#AEAA9D", "#497084", "#303E61", "#3770A2"],
            ["#292127", "#9B464A", "#E0C985", "#2A979A", "#0D2F3C", "#ECEFDB"],
            ["#F4B53F", "#2F4858", "#0FB3BC", "#D6E3BD", "#C1554E", "#6A4C57"],
            ["#D4BE5B", "#A1D1B5", "#48ADB6", "#516C57", "#401D35", "#39447D"],
            ["#401D35", "#9E4557", "#F16E54", "#F2D89D", "#C4BB86", "#F9AD69"],
            ["#FEB613", "#DEE4D7", "#3EC2B2", "#356F8D", "#2E2A32", "#A5E1AC"],
            ["#8F4756", "#E7D7C4", "#A9A1A5", "#7A909D", "#352E3F", "#BA8B80"],
            ["#1A2739", "#223653", "#5C223D", "#CD253A", "#EA8353", "#15486B"],
            ["#385F8D", "#223653", "#E3564C", "#CD253A", "#764468", "#E1BAA9"],
            ["#BFE3D4", "#F1D499", "#6787A0", "#3E5277", "#341C33", "#9E667B"],
            ["#495069", "#87AD9F", "#D4C8AC", "#B67465", "#4B1F33", "#F28443"],
            ["#495069", "#528B8C", "#EBDCBE", "#F0B07D", "#BD5D60", "#82A2B9"],
            ["#507386", "#78B4AE", "#F2E1B9", "#C78379", "#8A6946", "#2D2543"],
        ];
   this.groupColors = {};
let key = 1;
for (let i = 0; i < palette.length; i++) {
  for (let j = 0; j < palette[i].length; j++) {
    this.groupColors[key.toString()] = palette[i][j];
    key++;
  }
}
    this.fallbackColor = "#FF851B";
    
    // Determine maximum size from the CSV data
    let maxSize = 0;
    for (let d of data) {
      let s = +d.size || 0;
      if (s > maxSize) maxSize = s;
    }
    // Define a square-root scale for the bubble radius
    this.radiusScale = (sz) => Math.sqrt(sz / maxSize) * 80;
    
    // Create Bubble objects
    this.bubbles = this.data.map(d => new Bubble(d, this.radiusScale));
    
    // Initialize simulation state for each bubble (position and velocity)
    this.state = [];
    for (let i = 0; i < this.bubbles.length; i++) {
      this.state.push({
        x: Math.random() * window.innerWidth,
        y: Math.random() * window.innerHeight,
        vx: 0,
        vy: 0
      });
    }
    
    // Force parameters (tweak these values as needed)
    this.groupForce = 0.0005;  // pulls each bubble toward its group's cluster center
    this.collisionPadding = 3; // extra space to avoid overlap

    // Compute cluster centers for each group so that bubbles of the same group are attracted together.
    this.computeClusterCenters();
    
    // Setup canvas and event listeners
    this.canvas = document.getElementById("bubbleCanvas");
    this.ctx = this.canvas.getContext("2d");
    this.onResize();
    window.addEventListener("resize", () => this.onResize());
    
    // Mouse dragging support
    this.draggingIndex = -1;
    this.dragOffset = { x: 0, y: 0 };
    this.canvas.addEventListener("mousedown", (e) => this.onMouseDown(e));
    this.canvas.addEventListener("mousemove", (e) => this.onMouseMove(e));
    this.canvas.addEventListener("mouseup", (e) => this.onMouseUp(e));
    
    // Start simulation loop
    requestAnimationFrame(() => this.update());
  }
  
  // Compute distinct group IDs and assign each a cluster center arranged on a circle around the canvas center.
  computeClusterCenters() {
    let groups = {};
    for (let bubble of this.bubbles) {
      groups[bubble.groupid] = true;
    }
    const groupIds = Object.keys(groups).sort();
    const numGroups = groupIds.length;
    this.clusterCenters = {};
    const rCluster = Math.min(window.innerWidth, window.innerHeight) * 0.3;
    const cx = window.innerWidth / 2;
    const cy = window.innerHeight / 2;
    for (let i = 0; i < numGroups; i++) {
      const angle = (2 * Math.PI * i) / numGroups;
      const x = cx + rCluster * Math.cos(angle);
      const y = cy + rCluster * Math.sin(angle);
      this.clusterCenters[groupIds[i]] = { x: x, y: y };
    }
  }
  
  onResize() {
    this.width = window.innerWidth;
    this.height = window.innerHeight;
    this.canvas.width = this.width;
    this.canvas.height = this.height;
    this.computeClusterCenters();
  }
  
  colorForGroup(gid) {
    return this.groupColors[gid] || this.fallbackColor;
  }
  
  // Find the bubble under the mouse (returns index or -1)
  findBubbleAt(mx, my) {
    for (let i = this.bubbles.length - 1; i >= 0; i--) {
      const s = this.state[i];
      const dx = mx - s.x, dy = my - s.y;
      if (Math.sqrt(dx * dx + dy * dy) <= this.bubbles[i].radius) {
        return i;
      }
    }
    return -1;
  }
  
  onMouseDown(e) {
    const rect = this.canvas.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;
    const idx = this.findBubbleAt(mx, my);
    if (idx >= 0) {
      this.draggingIndex = idx;
      const s = this.state[idx];
      this.dragOffset.x = mx - s.x;
      this.dragOffset.y = my - s.y;
      this.bubbles[idx].fx = s.x;
      this.bubbles[idx].fy = s.y;
    }
  }
  
  onMouseMove(e) {
    if (this.draggingIndex >= 0) {
      const rect = this.canvas.getBoundingClientRect();
      const mx = e.clientX - rect.left;
      const my = e.clientY - rect.top;
      this.bubbles[this.draggingIndex].fx = mx - this.dragOffset.x;
      this.bubbles[this.draggingIndex].fy = my - this.dragOffset.y;
    }
  }
  
  onMouseUp(e) {
    if (this.draggingIndex >= 0) {
      this.bubbles[this.draggingIndex].fx = null;
      this.bubbles[this.draggingIndex].fy = null;
      this.draggingIndex = -1;
    }
  }
  
  // Compute the acceleration for bubble i. Here we add a force pulling the bubble toward its group's center.
  computeAcceleration(i, stateArray) {
    const bubble = this.bubbles[i];
    const s = stateArray[i];
    let ax = 0, ay = 0;
    // If the bubble is pinned, no acceleration.
    if (bubble.fx !== null || bubble.fy !== null) return { ax: 0, ay: 0 };
    
    // Compute group clustering force
    const groupTarget = this.clusterCenters[bubble.groupid];
    if (groupTarget) {
      ax += (groupTarget.x - s.x) * this.groupForce;
      ay += (groupTarget.y - s.y) * this.groupForce;
    }
    
    return { ax, ay };
  }
  
  // Handle collisions between bubbles (adjust positions to resolve overlaps)
  handleCollisions(stateArray) {
    const n = this.bubbles.length;
    for (let i = 0; i < n; i++) {
      const bi = this.bubbles[i];
      const si = stateArray[i];
      for (let j = i + 1; j < n; j++) {
        const bj = this.bubbles[j];
        const sj = stateArray[j];
        let dx = sj.x - si.x, dy = sj.y - si.y;
        const rSquared = dx * dx + dy * dy;
        const minDist = bi.radius + bj.radius + this.collisionPadding;
        if (rSquared < minDist * minDist) {
          const dist = Math.sqrt(rSquared) || 0.01;
          const overlap = (minDist - dist) / (2 * dist);
          dx *= overlap;
          dy *= overlap;
          if (bi.fx === null) { si.x -= dx; si.y -= dy; }
          if (bj.fx === null) { sj.x += dx; sj.y += dy; }
        }
      }
    }
  }
  
  // RK4 integration step for smooth simulation.
  RK4Step(dt) {
    const n = this.bubbles.length;
    const k1 = [], k2 = [], k3 = [], k4 = [];
    // k values: {dx, dy, dvx, dvy} for each bubble.
    // Step 1: Compute k1 from the current state.
    for (let i = 0; i < n; i++) {
      const s = this.state[i];
      const a = this.computeAcceleration(i, this.state);
      if (this.bubbles[i].fx !== null || this.bubbles[i].fy !== null)
        k1.push({ dx: 0, dy: 0, dvx: 0, dvy: 0 });
      else
        k1.push({ dx: s.vx, dy: s.vy, dvx: a.ax, dvy: a.ay });
    }
    // Step 2: Compute mid-state for k2.
    const state2 = [];
    for (let i = 0; i < n; i++) {
      const s = this.state[i];
      const k = k1[i];
      state2.push({
        x: s.x + 0.5 * dt * k.dx,
        y: s.y + 0.5 * dt * k.dy,
        vx: s.vx + 0.5 * dt * k.dvx,
        vy: s.vy + 0.5 * dt * k.dvy
      });
    }
    for (let i = 0; i < n; i++) {
      const a = this.computeAcceleration(i, state2);
      if (this.bubbles[i].fx !== null || this.bubbles[i].fy !== null)
        k2.push({ dx: 0, dy: 0, dvx: 0, dvy: 0 });
      else
        k2.push({ dx: state2[i].vx, dy: state2[i].vy, dvx: a.ax, dvy: a.ay });
    }
    // Step 3: Compute mid-state for k3.
    const state3 = [];
    for (let i = 0; i < n; i++) {
      const s = this.state[i];
      const k = k2[i];
      state3.push({
        x: s.x + 0.5 * dt * k.dx,
        y: s.y + 0.5 * dt * k.dy,
        vx: s.vx + 0.5 * dt * k.dvx,
        vy: s.vy + 0.5 * dt * k.dvy
      });
    }
    for (let i = 0; i < n; i++) {
      const a = this.computeAcceleration(i, state3);
      if (this.bubbles[i].fx !== null || this.bubbles[i].fy !== null)
        k3.push({ dx: 0, dy: 0, dvx: 0, dvy: 0 });
      else
        k3.push({ dx: state3[i].vx, dy: state3[i].vy, dvx: a.ax, dvy: a.ay });
    }
    // Step 4: Compute state for k4.
    const state4 = [];
    for (let i = 0; i < n; i++) {
      const s = this.state[i];
      const k = k3[i];
      state4.push({
        x: s.x + dt * k.dx,
        y: s.y + dt * k.dy,
        vx: s.vx + dt * k.dvx,
        vy: s.vy + dt * k.dvy
      });
    }
    for (let i = 0; i < n; i++) {
      const a = this.computeAcceleration(i, state4);
      if (this.bubbles[i].fx !== null || this.bubbles[i].fy !== null)
        k4.push({ dx: 0, dy: 0, dvx: 0, dvy: 0 });
      else
        k4.push({ dx: state4[i].vx, dy: state4[i].vy, dvx: a.ax, dvy: a.ay });
    }
    // Combine k's to update state.
    for (let i = 0; i < n; i++) {
      const bubble = this.bubbles[i];
      if (bubble.fx !== null || bubble.fy !== null) {
        this.state[i].x = bubble.fx;
        this.state[i].y = bubble.fy;
        this.state[i].vx = 0;
        this.state[i].vy = 0;
      } else {
        const dx = (k1[i].dx + 2 * k2[i].dx + 2 * k3[i].dx + k4[i].dx) / 6;
        const dy = (k1[i].dy + 2 * k2[i].dy + 2 * k3[i].dy + k4[i].dy) / 6;
        const dvx = (k1[i].dvx + 2 * k2[i].dvx + 2 * k3[i].dvx + k4[i].dvx) / 6;
        const dvy = (k1[i].dvy + 2 * k2[i].dvy + 2 * k3[i].dvy + k4[i].dvy) / 6;
        this.state[i].x += dx * dt;
        this.state[i].y += dy * dt;
        this.state[i].vx += dvx * dt;
        this.state[i].vy += dvy * dt;
      }
    }
  }
  
  // Single simulation step: apply RK4, resolve collisions, and damp velocities.
  simStep() {
    this.RK4Step(0.9);
    this.handleCollisions(this.state);
    for (let i = 0; i < this.bubbles.length; i++) {
      if (this.bubbles[i].fx === null) {
        this.state[i].vx *= 0.98;
        this.state[i].vy *= 0.98;
      } else {
        this.state[i].vx = 0;
        this.state[i].vy = 0;
        this.state[i].x = this.bubbles[i].fx;
        this.state[i].y = this.bubbles[i].fy;
      }
    }
  }
  
  update() {
    this.simStep();
    this.draw();
    requestAnimationFrame(() => this.update());
  }
  
  draw() {
    const ctx = this.ctx;
    ctx.clearRect(0, 0, this.width, this.height);
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.font = "16px Helvetica Neue";
    // Draw each bubble using the simulation state.
    for (let i = 0; i < this.bubbles.length; i++) {
      const b = this.bubbles[i];
      const s = this.state[i];
      ctx.fillStyle = this.colorForGroup(b.groupid);
      ctx.beginPath();
      ctx.arc(s.x, s.y, b.radius, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = "#333";
      ctx.lineWidth = 1;
      ctx.stroke();
      if (b.name) {
        ctx.fillStyle = "#fff";
        ctx.fillText(b.name, s.x, s.y);
      }
    }
  }
}

// ------------------------------
// 5. Instantiate and run the chart
// ------------------------------
document.addEventListener("DOMContentLoaded", () => {
  const data = parseCSV(csvData);
  new BubbleChart(data);
});
</script>
</body>
</html>
likes 0comments 0
Riemann Zetahtml
Complexe Function
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Riemann Zeta - Plain JS + Canvas (Smooth)</title>
  <style>
    html, body {
      margin: 0; 
      padding: 0; 
      width: 100%; 
      height: 100%;
      background: #000;
      overflow: hidden;
    }
    canvas {
      display: block;
      position: absolute; 
      left: 0; top: 0;
    }
  </style>
</head>
<body>
<canvas id="riemannCanvas"></canvas>

<script>
// ======================================================================
// 1) Basic Utility: map, random, minimal noise2D
// ======================================================================
function mapRange(value, inMin, inMax, outMin, outMax){
  return outMin + (outMax - outMin)*((value-inMin)/(inMax-inMin));
}
function lerp(a,b,t){ return a + (b-a)*t; }
function fade(t){ return t*t*t*(t*(t*6 -15)+10); }

function noise2D(x,y){
  // minimal "hash" approach
  let xi = Math.floor(x), yi= Math.floor(y);
  let xf= x-xi,        yf= y-yi;
  let r1 = randomCell2D(xi,   yi);
  let r2 = randomCell2D(xi+1, yi);
  let r3 = randomCell2D(xi,   yi+1);
  let r4 = randomCell2D(xi+1, yi+1);
  let u= fade(xf), v= fade(yf);
  let i1= lerp(r1, r2, u);
  let i2= lerp(r3, r4, u);
  return lerp(i1, i2, v);
}
function randomCell2D(ix,iy){
  let s = (ix*374761393 + iy*668265263)^0xBADC0FFE;
  s = (s<<13)^s;
  let r= (1.0 - ((s*(s*s*15731+789221)+1376312589)&0x7fffffff)/1073741824.0);
  return r;
}

// ======================================================================
// 2) Vec2 class (like p5's createVector) 
// ======================================================================
class Vec2 {
  constructor(x,y){this.x=x;this.y=y;}
  add(v){ return new Vec2(this.x+v.x, this.y+v.y);}
  sub(v){ return new Vec2(this.x-v.x, this.y-v.y);}
}

// ======================================================================
// 3) Complex & Zeta
// ======================================================================
function Complex(real, imag){
  if(real && real.real!==undefined && real.imag!==undefined) return real;
  return {real, imag: imag||0};
}
function complexNeg(a){ return Complex(-a.real, -a.imag);}
function complexMul(a,b){
  let re= a.real*b.real - a.imag*b.imag;
  let im= a.real*b.imag + a.imag*b.real;
  return Complex(re,im);
}
function complexAdd(a,b){
  return Complex(a.real+b.real, a.imag+b.imag);
}
function complexSub(a,b){
  return Complex(a.real-b.real, a.imag-b.imag);
}
function complexPow(a,b){
  //  (r e^{i\theta})^(x + i y) = ...
  let r= Math.sqrt(a.real*a.real + a.imag*a.imag);
  let theta= Math.atan2(a.imag,a.real);
  let br= b.real, bi= b.imag;
  let logR= Math.log(r);
  let x= Math.exp(br*logR - bi*theta);
  let phi= bi*logR + br*theta;
  return Complex(x*Math.cos(phi), x*Math.sin(phi));
}
function binomial(n, k){
  if(k>n)return 0;
  if(k> n-k)k=n-k;
  let r=1; 
  for(let i=0;i<k;i++){
    r*= (n-i)/(i+1);
  }
  return r;
}
function sign(k){ return (k%2)? -1: 1; }
function zeta3(s, t=150){
  // partial sum approach
  // s!=1
  // sum_{n=0..t} sum_{k=0..n} [ (-1)^k binomial(n,k) / (k+1)^s ] / 2^(n+1)
  // all divided by [1-2^(1-s)]
  // from your snippet
  let sum= Complex(0,0);
  for(let n=0;n<t;n++){
    let inn= Complex(0,0);
    for(let k=0;k<=n;k++){
      let p= complexPow(Complex(k+1,0), complexNeg(s));
      let sc= sign(k)* binomial(n,k);
      let tmp= complexMul(p, Complex(sc,0));
      inn= complexAdd(inn, tmp);
    }
    let factor= Math.pow(2, -(n+1));
    inn= Complex( inn.real*factor, inn.imag*factor );
    sum= complexAdd(sum, inn);
  }
  // factor = 1/(1- 2^(1-s))
  let two= Complex(2,0);
  let oneMinusS= Complex(1-s.real, -s.imag);
  let twoOneS= complexPow(two, oneMinusS);
  let denom= complexSub(Complex(1,0), twoOneS);
  // sum / denom
  // naive complexDiv
  let dnorm= denom.real*denom.real + denom.imag*denom.imag;
  let conjRe=  denom.real, conjIm= -denom.imag;
  let cross= complexMul(sum, {real: conjRe, imag: conjIm});
  return { 
    real: cross.real/dnorm,
    imag: cross.imag/dnorm
  };
}

// ======================================================================
// 4) Riemann Logic w/ Smooth Lines
// We store all zeta points in an array and draw one continuous smooth path
// ======================================================================
class RiemannApp {
  constructor(){
    this.canvas= document.getElementById('riemannCanvas');
    this.ctx= this.canvas.getContext('2d');
    this.width=0; this.height=0;
    this.resize();
    window.addEventListener('resize', ()=>this.resize());
    window.addEventListener('keydown', (e)=>this.keyDown(e));
    // store zeta coords
    this.points= [];
    this.index= -0.2;
    this.limit= 34;
    this.offset=200;
    this.start=0;

    requestAnimationFrame(()=>this.draw());
  }

  resize(){
    this.width= window.innerWidth;
    this.height= window.innerHeight;
    this.canvas.width= this.width;
    this.canvas.height= this.height;
    this.center= new Vec2(this.width*0.5, this.height*0.5);
  }

  keyDown(e){
    if(e.key==='c'){
      this.points= [];
      this.index= -0.2;
      this.limit=34;
    } else if(e.key==='p'){
      this.limit=64;
    }
  }

  draw(){
    requestAnimationFrame(()=>this.draw());
    // background
    this.ctx.fillStyle= "rgb(6,10,43)";
    this.ctx.fillRect(0,0,this.width,this.height);

    // generate next zeta point if index < limit
    if(this.index< this.limit){
      let s= Complex(0.5,this.index);
      let comp= zeta3(s);
      let x= mapRange(comp.real,-2,2, -this.offset, this.offset);
      let y= mapRange(comp.imag,-2,2, -this.offset, this.offset);
      // shift by center
      x+= this.center.x;
      y+= this.center.y;
      this.points.push({x,y});
      this.index+=0.05;
    }

    // draw circle etc. if you want
    this.ctx.strokeStyle="rgba(255,255,255,0.5)";
    this.ctx.beginPath();
    this.ctx.arc(this.center.x, this.center.y, 200, 0,Math.PI*2);
    this.ctx.stroke();

    // draw a smooth curve through all points
    if(this.points.length>2){
      this.drawCatmullRom();
    }

    this.start+= 0.0001;
  }

  drawCatmullRom(){
    // We'll do a standard Catmull–Rom approach for a “smooth function.”
    // p[i-1], p[i], p[i+1], p[i+2] => we create a segment
    let pts= this.points;
    this.ctx.lineWidth= 3;

    this.ctx.beginPath();
    // move to first
    this.ctx.moveTo(pts[0].x, pts[0].y);

    for(let i=1; i<pts.length-2; i++){
      let c1x= (pts[i].x + pts[i+1].x)/2;
      let c1y= (pts[i].y + pts[i+1].y)/2;
      this.ctx.quadraticCurveTo(pts[i].x, pts[i].y, c1x, c1y);
    }
    // last segment
    let penult= pts[pts.length-2];
    let last=   pts[pts.length-1];
    this.ctx.quadraticCurveTo(penult.x, penult.y, last.x, last.y);

    // color logic
    // we could pick a color from wave or from length
    // for now, a static color
    this.ctx.strokeStyle="rgba(255,200,100,0.8)";
    this.ctx.stroke();
  }
}

// let's go
document.addEventListener("DOMContentLoaded", ()=>{
  new RiemannApp();
});
</script>

</body>
</html>
likes 0comments 0
Alchemyhtml
Map
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Uniphi-Alchemy Map</title>
  <style>
    :root{
      --bg:#0b0f14;
      --panel:#101824;
      --panel2:#0f1620;
      --ink:#e7eef9;
      --muted:#9fb1c8;
      --line:#213044;
      --accent:#7dd3fc;
      --accent2:#a78bfa;
      --ok:#86efac;
      --warn:#fca5a5;
      --radius:14px;
      --shadow: 0 10px 40px rgba(0,0,0,.35);
      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
      --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
    }
    *{box-sizing:border-box}
    body{
      margin:0;
      font-family:var(--sans);
      background:
        radial-gradient(900px 500px at 20% 10%, rgba(167,139,250,.14), transparent 60%),
        radial-gradient(900px 500px at 80% 0%, rgba(125,211,252,.12), transparent 55%),
        radial-gradient(800px 600px at 30% 90%, rgba(134,239,172,.08), transparent 60%),
        var(--bg);
      color:var(--ink);
      line-height:1.35;
    }
    header{
      padding:28px 18px 6px;
      max-width:1100px;
      margin:0 auto;
    }
    h1{
      margin:0 0 6px;
      letter-spacing:.2px;
      font-weight:800;
      font-size:24px;
    }
    .subtitle{
      color:var(--muted);
      max-width:70ch;
      font-size:14px;
    }
    main{
      max-width:1100px;
      margin:0 auto;
      padding:12px 18px 60px;
      display:grid;
      gap:14px;
    }
    .grid{
      display:grid;
      grid-template-columns: 1.1fr .9fr;
      gap:14px;
    }
    @media (max-width: 980px){
      .grid{grid-template-columns:1fr}
    }
    .card{
      background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02));
      border:1px solid var(--line);
      border-radius:var(--radius);
      box-shadow: var(--shadow);
      overflow:hidden;
    }
    .card h2{
      margin:0;
      padding:14px 14px 10px;
      font-size:14px;
      letter-spacing:.3px;
      text-transform:uppercase;
      color:var(--muted);
      border-bottom:1px solid var(--line);
      background: rgba(0,0,0,.14);
    }
    .content{ padding:14px; }
    label{ display:block; font-size:12px; color:var(--muted); margin:10px 0 6px; }
    input[type="text"], input[type="date"], textarea, select{
      width:100%;
      border:1px solid var(--line);
      border-radius:12px;
      background: rgba(0,0,0,.18);
      color:var(--ink);
      padding:10px 11px;
      outline:none;
      transition: border .15s ease;
    }
    textarea{ min-height:84px; resize:vertical; }
    input:focus, textarea:focus, select:focus{ border-color: rgba(125,211,252,.6); }
    .row{
      display:grid;
      grid-template-columns: 1fr 1fr;
      gap:12px;
    }
    @media (max-width: 640px){
      .row{ grid-template-columns:1fr; }
    }
    .chips{
      display:flex; flex-wrap:wrap; gap:8px;
    }
    .chip{
      display:flex;
      gap:8px;
      align-items:flex-start;
      padding:10px 10px;
      border:1px solid var(--line);
      border-radius:999px;
      background: rgba(0,0,0,.14);
      font-size:12px;
      color:var(--ink);
      cursor:pointer;
      user-select:none;
    }
    .chip input{ margin-top:2px; }
    .chip small{
      display:block;
      color:var(--muted);
      font-size:11px;
      line-height:1.2;
      margin-top:2px;
    }
    .btnbar{
      display:flex;
      flex-wrap:wrap;
      gap:10px;
      align-items:center;
    }
    button{
      border:1px solid var(--line);
      background: rgba(0,0,0,.2);
      color:var(--ink);
      padding:10px 12px;
      border-radius:12px;
      cursor:pointer;
      font-weight:600;
    }
    button:hover{ border-color: rgba(125,211,252,.55); }
    button.primary{
      border-color: rgba(125,211,252,.45);
      background: linear-gradient(135deg, rgba(125,211,252,.20), rgba(167,139,250,.12));
    }
    button.good{
      border-color: rgba(134,239,172,.45);
      background: linear-gradient(135deg, rgba(134,239,172,.16), rgba(125,211,252,.10));
    }
    button.bad{
      border-color: rgba(252,165,165,.45);
      background: linear-gradient(135deg, rgba(252,165,165,.16), rgba(167,139,250,.08));
    }
    .hint{ color:var(--muted); font-size:12px; }
    .phasewrap{
      display:flex; gap:12px; align-items:center;
      padding:10px 12px;
      border:1px dashed rgba(125,211,252,.35);
      border-radius:12px;
      background: rgba(0,0,0,.12);
      margin-top:8px;
    }
    .phasewrap input[type="range"]{ width:100%; }
    .phasepill{
      font-family:var(--mono);
      font-size:12px;
      padding:6px 10px;
      border:1px solid var(--line);
      border-radius:999px;
      white-space:nowrap;
      background: rgba(0,0,0,.16);
    }
    .list{
      display:grid;
      gap:10px;
    }
    .ingredient{
      border:1px solid var(--line);
      border-radius:14px;
      background: rgba(0,0,0,.12);
      padding:12px;
    }
    .ingredient .top{
      display:flex; align-items:center; justify-content:space-between; gap:10px;
    }
    .ingredient strong{ font-size:13px; }
    .rating{
      display:flex; align-items:center; gap:10px;
      font-family:var(--mono);
      color:var(--muted);
      font-size:12px;
    }
    .rating input[type="range"]{ width:180px; }
    @media (max-width: 640px){
      .rating input[type="range"]{ width:140px; }
    }
    details{
      border:1px solid var(--line);
      border-radius:14px;
      background: rgba(0,0,0,.12);
      padding:10px 12px;
    }
    summary{
      cursor:pointer;
      font-weight:700;
      list-style:none;
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:10px;
    }
    summary::-webkit-details-marker{ display:none; }
    .mono{ font-family:var(--mono); color:var(--muted); font-size:12px; }
    .foot{
      display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap;
      margin-top:10px;
    }
    .status{
      font-size:12px;
      color:var(--muted);
    }
    .status b{ color:var(--ink); }
    .file{
      display:flex; align-items:center; gap:10px; flex-wrap:wrap;
    }
    .file input[type="file"]{
      border:1px solid var(--line);
      border-radius:12px;
      padding:8px;
      background: rgba(0,0,0,.12);
      color:var(--muted);
    }
  </style>
</head>
<body>
  <header>
    <h1>Uniphi-Alchemy Map</h1>
    <div class="subtitle">
      A single-page ritual for building worlds: <span style="color:var(--accent)">noise</span> becomes
      <span style="color:var(--accent2)">constellations</span>, and structure becomes a gentle handrail.
      Works offline. Exports Markdown + JSON.
    </div>
  </header>

  <main class="grid">
    <section class="card">
      <h2>Identity</h2>
      <div class="content">
        <div class="row">
          <div>
            <label>Project / Work</label>
            <input id="project" type="text" placeholder="e.g. Spectra — Nebula Field UI Pass" />
          </div>
          <div>
            <label>Date</label>
            <input id="date" type="date" />
          </div>
        </div>

        <label>Phase (0–6)</label>
        <div class="phasewrap">
          <span id="phasepill" class="phasepill">0 • spark</span>
          <input id="phase" type="range" min="0" max="6" step="1" value="0" />
        </div>
        <div class="hint" style="margin-top:8px">
          0 spark • 1 calcination • 2 dissolution • 3 separation • 4 conjunction • 5 distillation • 6 coagulation
        </div>

        <label>One-line intent</label>
        <textarea id="intent" placeholder="What is the smallest true north for this cycle?"></textarea>

        <div class="foot">
          <div class="status" id="autosave">Autosave: <b>on</b> (localStorage)</div>
          <div class="status" id="wordcount">Words: <b>0</b></div>
        </div>
      </div>
    </section>

    <section class="card">
      <h2>Axioms (pick 3)</h2>
      <div class="content">
        <div class="chips" id="axioms"></div>
        <div class="hint" style="margin-top:10px">
          Motto-style axioms: Latin + French + English. Keep it short enough to remember.
        </div>
      </div>
    </section>

    <section class="card">
      <h2>9 Ingredients</h2>
      <div class="content">
        <div class="hint" style="margin-bottom:10px">
          Rate each 0–5 and write one sentence. If it feels “super-artificial”: boost <b>Sulfur</b> + <b>Witness</b>, reduce <b>Vessel</b> + <b>Guardian</b>.
        </div>
        <div class="list" id="ingredients"></div>
      </div>
    </section>

    <section class="card">
      <h2>7 Operations</h2>
      <div class="content">
        <div class="list" id="operations"></div>
      </div>
    </section>

    <section class="card">
      <h2>Archetypes + Shadow</h2>
      <div class="content">
        <label>Active archetypes (2–3 is plenty)</label>
        <div class="chips" id="archetypes"></div>

        <label>Shadow loop watch</label>
        <select id="shadow_loop">
          <option value="none">(none)</option>
          <option value="complexity_armor">Complexity as armor</option>
          <option value="security_trance">Security trance (fence becomes cage)</option>
          <option value="abstraction_no_ground">Abstraction without grounding</option>
          <option value="release_shyness">Release shyness (“one more patch”)</option>
        </select>

        <label>Counter-spell (one sentence)</label>
        <textarea id="counter_spell" placeholder="A tiny vow that breaks the loop."></textarea>
      </div>
    </section>

    <section class="card">
      <h2>Balance + 60-second ritual</h2>
      <div class="content">
        <label>Balance: smallest next step</label>
        <textarea id="balance_next_step" placeholder="One actionable step that improves balance."></textarea>

        <label>Anchor (sensory / concrete)</label>
        <input id="ritual_anchor" type="text" placeholder="e.g. The laptop fan warms the room like a small sun." />

        <label>Stake (why it matters)</label>
        <input id="ritual_stake" type="text" placeholder="e.g. I want this to welcome curiosity." />

        <label>Spell (cosmic lens)</label>
        <input id="ritual_spell" type="text" placeholder="e.g. Noise becomes constellations; structure becomes a handrail." />

        <label>As a single paragraph</label>
        <textarea id="ritual_paragraph" placeholder="Auto-composed from Anchor + Stake + Spell (you can edit)."></textarea>

        <div class="btnbar" style="margin-top:12px">
          <button class="primary" id="btn_export_md">Export Markdown</button>
          <button class="good" id="btn_download_json">Download JSON</button>
          <button id="btn_copy_md">Copy Markdown</button>
          <button class="bad" id="btn_reset">Reset</button>
        </div>

        <div class="file" style="margin-top:12px">
          <label style="margin:0">Import JSON</label>
          <input id="import_file" type="file" accept="application/json,.json" />
          <button id="btn_load_example">Load example</button>
        </div>

        <div class="hint" style="margin-top:10px">
          Tip: Keep your map in git alongside the project. It’s a living README for your own nervous system.
        </div>
      </div>
    </section>
  </main>

<script>
(() => {
  const PHASES = ["spark","calcination","dissolution","separation","conjunction","distillation","coagulation"];

  const AXIOMS = [
    { id:"solve_et_coagula", la:"Solve et Coagula", fr:"Dissoudre et coaguler", en:"Dissolve and coagulate" },
    { id:"ordo_ab_chao", la:"Ordo ab Chao", fr:"L’ordre du chaos", en:"Order from chaos" },
    { id:"iterare_est_distillare", la:"Iterare est Distillare", fr:"Itérer, c’est distiller", en:"To iterate is to distill" },
    { id:"veritas_in_structura", la:"Veritas in Structura", fr:"La vérité dans la structure", en:"Truth lives in structure" },
    { id:"nomen_est_clavis", la:"Nomen est Clavis", fr:"Le nom est clé", en:"Name is a key" },
    { id:"mensura_est_magia", la:"Mensura est Magia", fr:"La mesure est magie", en:"Measurement is magic" },
    { id:"custodia_sine_carcere", la:"Custodia sine Carcere", fr:"Garder sans enfermer", en:"Guard without imprisoning" },
    { id:"ludus_est_labor", la:"Ludus est Labor", fr:"Le jeu est travail", en:"Play is work" },
    { id:"testis_facit_mundum", la:"Testis facit Mundum", fr:"Le témoin fait le monde", en:"The witness makes the world" },
  ];

  const INGREDIENTS = [
    { key:"aether", name:"Aether", desc:"Vision" },
    { key:"mercurius", name:"Mercurius", desc:"Flow / Interoperability" },
    { key:"sulfur", name:"Sulfur", desc:"Soul / Urgency" },
    { key:"sal", name:"Sal", desc:"Structure / Truth" },
    { key:"vessel", name:"Vessel", desc:"Infrastructure / Container" },
    { key:"catalyst", name:"Catalyst", desc:"Play / Risk" },
    { key:"noise", name:"Noise", desc:"Oracle / Randomness" },
    { key:"guardian", name:"Guardian", desc:"Security / Boundaries" },
    { key:"witness", name:"Witness", desc:"Story / Audience" },
  ];

  const OPS = [
    { key:"calcination", title:"Calcination", cue:"burn illusions, keep essence" },
    { key:"dissolution", title:"Dissolution", cue:"let it flow, prototype freely" },
    { key:"separation", title:"Separation", cue:"isolate the core" },
    { key:"conjunction", title:"Conjunction", cue:"merge art + system" },
    { key:"fermentation", title:"Fermentation", cue:"invite surprise" },
    { key:"distillation", title:"Distillation", cue:"refine and clarify" },
    { key:"coagulation", title:"Coagulation", cue:"ship the artifact" },
  ];

  const ARCHETYPES = [
    { id:"cosmic_chemist", label:"Cosmic Chemist", sub:"myth + physics into matter" },
    { id:"net_weaver", label:"Net Weaver", sub:"bridges, peers, tunnels" },
    { id:"vault_guardian", label:"Vault Guardian", sub:"keys, access, integrity" },
    { id:"trickster_debugger", label:"Trickster Debugger", sub:"chaos-wrangler" },
    { id:"archivist", label:"Archivist", sub:"names, versions, reproducibility" },
    { id:"poet_witness", label:"Poet-Witness", sub:"felt meaning, human door" },
  ];

  // Elements
  const $ = (id) => document.getElementById(id);
  const elAxioms = $("axioms");
  const elIngredients = $("ingredients");
  const elOps = $("operations");
  const elArchetypes = $("archetypes");

  const stateKey = "uniphi_alchemy_map_v1";

  function defaultState(){
    const today = new Date();
    const iso = today.toISOString().slice(0,10);
    return {
      project: "",
      date: iso,
      phase: 0,
      intent: "",
      axioms: [],
      ingredients: INGREDIENTS.map(x => ({ key:x.key, rating: 3, note: "" })),
      operations: OPS.map(x => ({ key:x.key, notes: "" })),
      archetypes: [],
      shadow_loop: "none",
      counter_spell: "",
      balance_next_step: "",
      ritual: { anchor:"", stake:"", spell:"", paragraph:"" }
    };
  }

  let state = load() || defaultState();

  function save(){
    localStorage.setItem(stateKey, JSON.stringify(state));
    $("autosave").innerHTML = 'Autosave: <b>on</b> (localStorage)';
  }

  function load(){
    try{
      const raw = localStorage.getItem(stateKey);
      if(!raw) return null;
      return JSON.parse(raw);
    }catch(e){
      return null;
    }
  }

  function renderChips(container, items, selectedIds, onToggle){
    container.innerHTML = "";
    items.forEach(item => {
      const lbl = document.createElement("label");
      lbl.className = "chip";
      const cb = document.createElement("input");
      cb.type = "checkbox";
      cb.checked = selectedIds.includes(item.id);
      cb.addEventListener("change", () => onToggle(item.id, cb.checked));
      const txt = document.createElement("div");
      txt.innerHTML = `<div><b>${escapeHtml(item.la || item.label)}</b><small>${escapeHtml(item.fr || item.sub)} — ${escapeHtml(item.en || "")}</small></div>`;
      lbl.appendChild(cb);
      lbl.appendChild(txt);
      container.appendChild(lbl);
    });
  }

  function escapeHtml(s){
    return (s ?? "").toString().replace(/[&<>"']/g, (c) => ({
      "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"
    }[c]));
  }

  function render(){
    // Identity
    $("project").value = state.project || "";
    $("date").value = state.date || "";
    $("phase").value = state.phase ?? 0;
    $("intent").value = state.intent || "";
    updatePhasePill();

    // Axioms
    renderChips(elAxioms, AXIOMS, state.axioms, (id, checked) => {
      if(checked){
        if(state.axioms.length >= 3){
          // soft rule: keep 3
          // uncheck immediately
          const inputs = elAxioms.querySelectorAll("input[type=checkbox]");
          inputs.forEach(inp => {
            const parent = inp.closest("label");
            const text = parent?.innerText || "";
            // no-op, we'll just revert this one:
          });
          checked = false;
        } else {
          state.axioms.push(id);
        }
      }else{
        state.axioms = state.axioms.filter(x => x !== id);
      }
      // hard enforce length <= 3
      state.axioms = state.axioms.slice(0,3);
      save();
      render(); // re-render to reflect enforcement
    });

    // Ingredients
    elIngredients.innerHTML = "";
    INGREDIENTS.forEach((meta, idx) => {
      const data = state.ingredients[idx] || { key: meta.key, rating: 3, note: "" };
      const wrap = document.createElement("div");
      wrap.className = "ingredient";
      wrap.innerHTML = `
        <div class="top">
          <div>
            <strong>${meta.name}</strong> <span class="mono">(${meta.desc})</span>
          </div>
          <div class="rating">
            <span>0</span>
            <input type="range" min="0" max="5" step="1" value="${data.rating ?? 3}" data-i="${idx}" />
            <span>5</span>
            <span class="phasepill" style="padding:4px 10px">${data.rating ?? 3}/5</span>
          </div>
        </div>
        <label style="margin-top:10px">Sentence</label>
        <textarea data-note="${idx}" placeholder="One sentence that makes it true.">${escapeHtml(data.note || "")}</textarea>
      `;
      elIngredients.appendChild(wrap);
    });

    // Operations
    elOps.innerHTML = "";
    OPS.forEach((meta, idx) => {
      const data = state.operations[idx] || { key: meta.key, notes:"" };
      const det = document.createElement("details");
      det.open = idx < 2; // open first two by default
      det.innerHTML = `
        <summary>
          <span>${meta.title} <span class="mono">— ${meta.cue}</span></span>
          <span class="mono">${idx+1}/7</span>
        </summary>
        <div style="margin-top:10px">
          <label>Notes</label>
          <textarea data-op="${idx}" placeholder="Write in bullets or prose.">${escapeHtml(data.notes || "")}</textarea>
        </div>
      `;
      elOps.appendChild(det);
    });

    // Archetypes
    renderChips(elArchetypes, ARCHETYPES, state.archetypes, (id, checked) => {
      if(checked){
        if(!state.archetypes.includes(id)) state.archetypes.push(id);
      }else{
        state.archetypes = state.archetypes.filter(x => x !== id);
      }
      save();
      updateWordCount();
    });

    // Shadow + other
    $("shadow_loop").value = state.shadow_loop || "none";
    $("counter_spell").value = state.counter_spell || "";
    $("balance_next_step").value = state.balance_next_step || "";
    $("ritual_anchor").value = state.ritual?.anchor || "";
    $("ritual_stake").value = state.ritual?.stake || "";
    $("ritual_spell").value = state.ritual?.spell || "";
    $("ritual_paragraph").value = state.ritual?.paragraph || "";

    updateWordCount();
  }

  function updatePhasePill(){
    const p = parseInt($("phase").value,10) || 0;
    $("phasepill").textContent = `${p} • ${PHASES[p] || "?"}`;
  }

  function bind(){
    $("project").addEventListener("input", e => { state.project = e.target.value; save(); updateWordCount(); });
    $("date").addEventListener("input", e => { state.date = e.target.value; save(); });
    $("phase").addEventListener("input", e => { state.phase = parseInt(e.target.value,10) || 0; updatePhasePill(); save(); });
    $("intent").addEventListener("input", e => { state.intent = e.target.value; save(); updateWordCount(); });

    // Ingredients (delegated)
    elIngredients.addEventListener("input", (e) => {
      const t = e.target;
      if(t.matches("input[type=range][data-i]")){
        const idx = parseInt(t.getAttribute("data-i"),10);
        state.ingredients[idx].rating = parseInt(t.value,10);
        save();
        render(); // update pill
      }
      if(t.matches("textarea[data-note]")){
        const idx = parseInt(t.getAttribute("data-note"),10);
        state.ingredients[idx].note = t.value;
        save();
        updateWordCount();
      }
    });

    // Operations
    elOps.addEventListener("input", (e) => {
      const t = e.target;
      if(t.matches("textarea[data-op]")){
        const idx = parseInt(t.getAttribute("data-op"),10);
        state.operations[idx].notes = t.value;
        save();
        updateWordCount();
      }
    });

    $("shadow_loop").addEventListener("change", e => { state.shadow_loop = e.target.value; save(); });
    $("counter_spell").addEventListener("input", e => { state.counter_spell = e.target.value; save(); updateWordCount(); });
    $("balance_next_step").addEventListener("input", e => { state.balance_next_step = e.target.value; save(); updateWordCount(); });

    // Ritual
    ["ritual_anchor","ritual_stake","ritual_spell"].forEach(id => {
      $(id).addEventListener("input", () => {
        state.ritual = state.ritual || {anchor:"",stake:"",spell:"",paragraph:""};
        state.ritual.anchor = $("ritual_anchor").value;
        state.ritual.stake  = $("ritual_stake").value;
        state.ritual.spell  = $("ritual_spell").value;
        // auto compose if paragraph empty or exactly previous auto
        const auto = autoParagraph();
        if(($("ritual_paragraph").value || "").trim() === "" || ($("ritual_paragraph").dataset.auto === "1")){
          $("ritual_paragraph").value = auto;
          $("ritual_paragraph").dataset.auto = "1";
          state.ritual.paragraph = auto;
        }
        save();
        updateWordCount();
      });
    });

    $("ritual_paragraph").addEventListener("input", e => {
      state.ritual = state.ritual || {anchor:"",stake:"",spell:"",paragraph:""};
      state.ritual.paragraph = e.target.value;
      e.target.dataset.auto = "0";
      save();
      updateWordCount();
    });

    // Buttons
    $("btn_download_json").addEventListener("click", () => downloadJson());
    $("btn_export_md").addEventListener("click", () => downloadMarkdown());
    $("btn_copy_md").addEventListener("click", () => copyMarkdown());
    $("btn_reset").addEventListener("click", () => {
      if(confirm("Reset the map? (local draft will be cleared)")){
        state = defaultState();
        save();
        render();
      }
    });
    $("btn_load_example").addEventListener("click", () => loadExample());

    $("import_file").addEventListener("change", async (e) => {
      const file = e.target.files && e.target.files[0];
      if(!file) return;
      try{
        const txt = await file.text();
        const obj = JSON.parse(txt);
        state = normalizeImported(obj);
        save();
        render();
        e.target.value = "";
      }catch(err){
        alert("Could not import JSON: " + err);
      }
    });
  }

  function normalizeImported(obj){
    // Light normalization; keep user content.
    const s = defaultState();
    s.project = obj.project ?? s.project;
    s.date = obj.date ?? s.date;
    s.phase = (typeof obj.phase === "number") ? obj.phase : s.phase;
    s.intent = obj.intent ?? s.intent;
    s.axioms = Array.isArray(obj.axioms) ? obj.axioms.slice(0,3) : s.axioms;
    if(Array.isArray(obj.ingredients) && obj.ingredients.length === 9){
      s.ingredients = obj.ingredients.map((x,i) => ({
        key: INGREDIENTS[i].key,
        rating: clampInt(x.rating ?? 3, 0, 5),
        note: (x.note ?? "").toString()
      }));
    }
    if(Array.isArray(obj.operations) && obj.operations.length === 7){
      s.operations = obj.operations.map((x,i) => ({
        key: OPS[i].key,
        notes: (x.notes ?? "").toString()
      }));
    }
    s.archetypes = Array.isArray(obj.archetypes) ? obj.archetypes.filter(Boolean) : s.archetypes;
    s.shadow_loop = obj.shadow_loop ?? s.shadow_loop;
    s.counter_spell = obj.counter_spell ?? s.counter_spell;
    s.balance_next_step = obj.balance_next_step ?? s.balance_next_step;
    s.ritual = obj.ritual ?? s.ritual;
    if(!s.ritual) s.ritual = {anchor:"",stake:"",spell:"",paragraph:""};
    return s;
  }

  function clampInt(n, a, b){
    const x = parseInt(n,10);
    if(Number.isNaN(x)) return a;
    return Math.max(a, Math.min(b, x));
  }

  function autoParagraph(){
    const a = ($("ritual_anchor").value || "").trim();
    const s = ($("ritual_stake").value || "").trim();
    const p = ($("ritual_spell").value || "").trim();
    const bits = [a,s,p].filter(Boolean);
    return bits.join(" ");
  }

  function downloadJson(){
    const data = JSON.stringify(stateToExport(), null, 2);
    const blob = new Blob([data], {type:"application/json"});
    const name = fileSlug(state.project || "uniphi-alchemy-map") + ".json";
    triggerDownload(blob, name);
  }

  function copyMarkdown(){
    const md = toMarkdown();
    navigator.clipboard.writeText(md).then(
      () => alert("Markdown copied to clipboard."),
      () => alert("Could not copy (browser permission). Use Export Markdown instead.")
    );
  }

  function downloadMarkdown(){
    const md = toMarkdown();
    const blob = new Blob([md], {type:"text/markdown"});
    const name = fileSlug(state.project || "uniphi-alchemy-map") + ".md";
    triggerDownload(blob, name);
  }

  function triggerDownload(blob, filename){
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 500);
  }

  function fileSlug(s){
    return s.toLowerCase()
      .replace(/[^a-z0-9]+/g,"-")
      .replace(/^-+|-+$/g,"")
      .slice(0,64) || "uniphi-alchemy-map";
  }

  function stateToExport(){
    // Keep stable ordering + keys
    return {
      project: state.project,
      date: state.date,
      phase: state.phase,
      intent: state.intent,
      axioms: (state.axioms || []).slice(0,3).map(id => {
        const ax = AXIOMS.find(x => x.id === id);
        return ax ? ax.la : id;
      }),
      ingredients: state.ingredients.map(x => ({ key:x.key, rating:x.rating, note:x.note })),
      operations: state.operations.map(x => ({ key:x.key, notes:x.notes })),
      archetypes: (state.archetypes || []).slice(),
      shadow_loop: state.shadow_loop,
      counter_spell: state.counter_spell,
      balance_next_step: state.balance_next_step,
      ritual: state.ritual
    };
  }

  function toMarkdown(){
    const d = stateToExport();
    const phaseLabel = PHASES[d.phase] || "";
    const axLines = (d.axioms || []).map(a => `- ${a}`).join("\n") || "- (none)";
    const nameMap = {
      aether:"Aether (Vision)",
      mercurius:"Mercurius (Flow / Interoperability)",
      sulfur:"Sulfur (Soul / Urgency)",
      sal:"Sal (Structure / Truth)",
      vessel:"Vessel (Infrastructure / Container)",
      catalyst:"Catalyst (Play / Risk)",
      noise:"Noise (Oracle / Randomness)",
      guardian:"Guardian (Security / Boundaries)",
      witness:"Witness (Story / Audience)",
    };
    const opMap = {
      calcination:"Calcination",
      dissolution:"Dissolution",
      separation:"Separation",
      conjunction:"Conjunction",
      fermentation:"Fermentation",
      distillation:"Distillation",
      coagulation:"Coagulation",
    };
    const archMap = {
      cosmic_chemist:"Cosmic Chemist",
      net_weaver:"Net Weaver",
      vault_guardian:"Vault Guardian",
      trickster_debugger:"Trickster Debugger",
      archivist:"Archivist",
      poet_witness:"Poet-Witness",
    };
    const ing = d.ingredients.map(x => `### ${nameMap[x.key] || x.key} — ${x.rating}/5\n\n${x.note || ""}\n`).join("\n");
    const ops = d.operations.map(x => `### ${opMap[x.key] || x.key}\n\n${x.notes || ""}\n`).join("\n");
    const arch = (d.archetypes || []).map(a => archMap[a] || a).join(", ") || "(none)";
    const r = d.ritual || {anchor:"",stake:"",spell:"",paragraph:""};

    return `# Uniphi-Alchemy Map

**Project / Work:** ${d.project || ""}  
**Date:** ${d.date || ""}  
**Phase (0–6):** ${d.phase} (${phaseLabel})  
**One-line intent:** ${d.intent || ""}

---

## Axioms
${axLines}

---

## 9 Ingredients
${ing}

---

## 7 Operations
${ops}

---

## Archetypes
${arch}

### Shadow Loop
${d.shadow_loop || "none"}

### Counter-spell
${d.counter_spell || ""}

---

## Balance Next Step
${d.balance_next_step || ""}

---

## 60-Second Ritual
**Anchor:** ${r.anchor || ""}  
**Stake:** ${r.stake || ""}  
**Spell:** ${r.spell || ""}

${r.paragraph || ""}

`;
  }

  function loadExample(){
    // a gentle example, not overwriting if user cancels
    if(!confirm("Load the example map? This will overwrite your current draft.")) return;
    state = {
      project: "Spectra — Nebula Field UI Pass",
      date: new Date().toISOString().slice(0,10),
      phase: 4,
      intent: "Unify shader controls + observability into a single calm cockpit that still feels alive.",
      axioms: ["solve_et_coagula","veritas_in_structura","testis_facit_mundum"],
      ingredients: [
        {key:"aether",rating:5,note:"The universe-feel is the north star: everything must glow with meaning."},
        {key:"mercurius",rating:4,note:"Controls, telemetry, and visuals must flow together without friction."},
        {key:"sulfur",rating:3,note:"Keep one human pulse in the interface: warmth, not just precision."},
        {key:"sal",rating:4,note:"Naming + grouping is the truth layer; no slider without a reason."},
        {key:"vessel",rating:4,note:"Single-file deployment, predictable state, no dependencies."},
        {key:"catalyst",rating:4,note:"Allow playful toggles (hue, EM, magnetism) to invite discovery."},
        {key:"noise",rating:3,note:"Seeded randomness as oracle, not as clutter."},
        {key:"guardian",rating:3,note:"Safe defaults, but not a cage; keep the door open."},
        {key:"witness",rating:4,note:"The ‘aha’ moment: one button that reveals the universe behind the numbers."},
      ],
      operations: [
        {key:"calcination",notes:"Kill duplicate controls; keep only what changes experience. Constraint: single-file, offline."},
        {key:"dissolution",notes:"Prototype UI grouping fast; let the shader run wild while controls settle."},
        {key:"separation",notes:"Core: (1) field params, (2) color/energy, (3) observability. Decorations later."},
        {key:"conjunction",notes:"Bind UI -> shader uniforms -> telemetry in one loop, with stable naming."},
        {key:"fermentation",notes:"Leave one unknown: emergent interference patterns from parameter coupling."},
        {key:"distillation",notes:"Refactor: remove 2 sliders, merge 2 metrics, rename 5 params for clarity."},
        {key:"coagulation",notes:"Ship: a single HTML file + a one-page readme; witness: screen-recorded 30s tour."},
      ],
      archetypes: ["cosmic_chemist","net_weaver","poet_witness"],
      shadow_loop: "release_shyness",
      counter_spell: "Ship the smallest living ritual; refinement can orbit the release.",
      balance_next_step: "Add one concrete anchor line in the UI (‘temperature of the void: …’) and publish the file.",
      ritual: {
        anchor:"The laptop fan warms the room like a small sun.",
        stake:"I want the cockpit to welcome curiosity instead of intimidating it.",
        spell:"So I let noise become constellations, and structure become a gentle handrail.",
        paragraph:"The laptop fan warms the room like a small sun. I want the cockpit to welcome curiosity instead of intimidating it. So I let noise become constellations, and structure become a gentle handrail."
      }
    };
    save();
    render();
  }

  function updateWordCount(){
    const md = toMarkdown();
    const words = md.trim().split(/\s+/).filter(Boolean).length;
    $("wordcount").innerHTML = `Words: <b>${words}</b>`;
  }

  // Boot
  function ensureDate(){
    if(!state.date){
      state.date = new Date().toISOString().slice(0,10);
    }
  }

  ensureDate();
  bind();
  render();
  save();

})();
</script>
</body>
</html>
likes 0comments 0
Ideohtml
Figure de Style
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Figures de style – Jardin des Idées</title>
  <style>
    /* --------------------
       Color palette & base
    -------------------- */
    :root {
      --bg: #faf8f3;
      --fg: #213c34;
      --accent: #40f2d0;
      --accent-dark: #2ba899;
    }
    *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
    body{
      font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
      background:var(--bg);
      color:var(--fg);
      line-height:1.55;
      min-height:100svh;
      overflow-x:hidden;
    }
    header,footer{padding:1.8rem 6vw;display:flex;justify-content:space-between;align-items:center;backdrop-filter:blur(3px)}
    header h1{font-size:clamp(1.4rem,2.5vw+1rem,2.8rem);font-weight:500;letter-spacing:.03em}
    main{padding:2rem 6vw;max-width:70rem;margin-inline:auto;position:relative;z-index:1}
    #search{width:100%;padding:.7rem 1rem;border:1px solid var(--accent-dark);border-radius:.6rem;font-size:1rem;margin-bottom:1.6rem}

    /* Accordions */
    details{background:rgba(255,255,255,.65);border:1px solid var(--accent);border-radius:1rem;margin-bottom:.8rem;overflow:hidden}
    summary{cursor:pointer;padding:1rem 1.4rem;font-size:1.12rem;list-style:none;display:flex;justify-content:space-between;align-items:center;user-select:none}
    summary::after{content:"➕";transition:transform .3s ease}
    details[open] summary::after{content:"➖";transform:rotate(180deg)}
    ul.category{padding:0 1.6rem 1.2rem 2rem}
    ul.category li{margin:.4rem 0;transition:background .2s ease;padding:.2rem .4rem;border-radius:.4rem}
    ul.category li:hover{background:var(--accent);color:#fff}

    /* Footer */
    footer p{font-size:.9rem;opacity:.65}

    /* --------------
       Background canvas
    -------------- */
    #bg{position:fixed;inset:0;z-index:-1;pointer-events:none}

    /* Responsive tweaks */
    @media(max-width:600px){
      header,footer{padding:1.2rem 4vw}
      main{padding:1.4rem 4vw}
    }
  </style>
</head>
<body>
  <canvas id="bg"></canvas>
  <header>
    <h1>Jardin des Idées</h1>
    <span aria-hidden="true">🌿</span>
  </header>

  <main>
    <input type="search" id="search" placeholder="Rechercher une figure…" />

    <!-- Les différentes figures de style -->
    <details open>
      <summary>Les figures d’analogie</summary>
      <ul class="category">
        <li>La comparaison</li>
        <li>La métaphore</li>
        <li>La personnification</li>
        <li>L’allégorie <em>(notion avancée)</em></li>
      </ul>
    </details>

    <details>
      <summary>Les figures d’amplification</summary>
      <ul class="category">
        <li>L’énumération</li>
        <li>La gradation</li>
        <li>L’hyperbole</li>
      </ul>
    </details>

    <details>
      <summary>Les figures d’insistance</summary>
      <ul class="category">
        <li>La répétition</li>
        <li>L’anaphore <em>(notion avancée)</em></li>
        <li>La redondance <em>(notion avancée)</em></li>
        <li>Le pléonasme <em>(notion avancée)</em></li>
      </ul>
    </details>

    <details>
      <summary>Les figures d’atténuation et d’omission</summary>
      <ul class="category">
        <li>L’euphémisme</li>
        <li>La litote</li>
        <li>L’ellipse <em>(notion avancée)</em></li>
      </ul>
    </details>

    <details>
      <summary>Les figures d’opposition</summary>
      <ul class="category">
        <li>L’antithèse</li>
        <li>L’oxymore</li>
        <li>L’ironie</li>
        <li>Le chiasme <em>(notion avancée)</em></li>
      </ul>
    </details>

    <details>
      <summary>Les figures de substitution</summary>
      <ul class="category">
        <li>La périphrase</li>
        <li>La métonymie</li>
        <li>La synecdoque <em>(notion avancée)</em></li>
      </ul>
    </details>
  </main>

  <footer>
    <p>&copy; 2025 Kobalt – Exploration des écosystèmes créatifs</p>
  </footer>

  <script>
    /* -----------------------------
       Instant search / filter list
    ----------------------------- */
    const searchInput = document.getElementById("search");
    searchInput.addEventListener("input", e => {
      const q = e.target.value.toLowerCase();
      document.querySelectorAll("ul.category li").forEach(li => {
        li.style.display = li.textContent.toLowerCase().includes(q) ? "list-item" : "none";
      });
    });

    /* -----------------------------------------
       Minimal generative background (organic points)
    ----------------------------------------- */
    const canvas = document.getElementById("bg");
    const ctx = canvas.getContext("2d");
    let W, H;
    const dots = [];
    const DOTS = 420;

    function resize() {
      W = canvas.width = window.innerWidth * devicePixelRatio;
      H = canvas.height = window.innerHeight * devicePixelRatio;
      canvas.style.width = window.innerWidth + "px";
      canvas.style.height = window.innerHeight + "px";
      ctx.scale(devicePixelRatio, devicePixelRatio);
    }
    resize();
    window.addEventListener("resize", resize);

    // create initial field of dots following differential‑growth-ish directions
    function initDots() {
      dots.length = 0;
      for (let i = 0; i < DOTS; i++) {
        dots.push({
          x: Math.random() * window.innerWidth,
          y: Math.random() * window.innerHeight,
          vx: (Math.random() - 0.5) * 0.6,
          vy: (Math.random() - 0.5) * 0.6
        });
      }
    }
    initDots();

    function step() {
      ctx.fillStyle = "rgba(250, 248, 243, 0.1)";
      ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
      ctx.fillStyle = "rgba(33, 60, 52, 0.9)";
      dots.forEach(p => {
        // simple edge‑bounce
        if (p.x < 0 || p.x > window.innerWidth) p.vx *= -1;
        if (p.y < 0 || p.y > window.innerHeight) p.vy *= -1;
        // subtle acceleration towards centre to mimic growth toward nutrients
        const dx = window.innerWidth / 2 - p.x;
        const dy = window.innerHeight / 2 - p.y;
        p.vx += dx * 0.000015;
        p.vy += dy * 0.000015;
        // speed limit
        const spd = Math.hypot(p.vx, p.vy);
        const max = 0.9;
        if (spd > max) { p.vx *= max / spd; p.vy *= max / spd; }
        p.x += p.vx;
        p.y += p.vy;
        ctx.beginPath();
        ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2);
        ctx.fill();
      });
      requestAnimationFrame(step);
    }
    step();
  </script>
</body>
</html>
likes 0comments 0