[object Object]

← back to Stock Vshape Viewer

Three.js V-shape stock viewer (high->dip->high, Aug 2026)

e997e6f823b1b9bce69f7e121eb025fb2a1ce72d · 2026-08-27 08:23:24 -0700 · Steve

Files touched

Diff

commit e997e6f823b1b9bce69f7e121eb025fb2a1ce72d
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 27 08:23:24 2026 -0700

    Three.js V-shape stock viewer (high->dip->high, Aug 2026)
---
 .gitignore        |   8 ++
 package.json      |   8 ++
 public/app.js     | 326 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/data.js    | 165 +++++++++++++++++++++++++++
 public/index.html | 109 ++++++++++++++++++
 server.js         |  52 +++++++++
 6 files changed, 668 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d6343c2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "stock-vshape-viewer",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Dynamic Three.js viewer for the high -> dip -> high stock screen (Aug 2026)",
+  "main": "server.js",
+  "scripts": { "start": "node server.js" }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..4024b2b
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,326 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+const STOCKS = window.STOCKS;
+const COL = { high: 0x39d98a, trough: 0xff5c7a, newhigh: 0xffd54a, lowerhigh: 0xffd54a };
+const PASS = 0x39d98a, FAIL = 0xff5c7a;
+
+// ---------- scene ----------
+const app = document.getElementById('app');
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x070b14);
+scene.fog = new THREE.Fog(0x070b14, 28, 70);
+
+const camera = new THREE.PerspectiveCamera(52, innerWidth / innerHeight, 0.1, 200);
+const camHome = new THREE.Vector3(20, 15, 26);
+camera.position.copy(camHome);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
+renderer.setSize(innerWidth, innerHeight);
+app.appendChild(renderer.domElement);
+
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enableDamping = true;
+controls.dampingFactor = 0.08;
+controls.autoRotate = true;
+controls.autoRotateSpeed = 0.6;
+controls.target.set(0, 3, 0);
+
+scene.add(new THREE.AmbientLight(0x88aaff, 0.7));
+const key = new THREE.DirectionalLight(0xffffff, 1.1); key.position.set(12, 20, 10); scene.add(key);
+const rim = new THREE.DirectionalLight(0x5c7cff, 0.6); rim.position.set(-14, 8, -10); scene.add(rim);
+
+// ground grid
+const grid = new THREE.GridHelper(60, 60, 0x1e2a44, 0x121a2c);
+grid.position.y = 0; scene.add(grid);
+
+// ---------- geometry constants ----------
+const LANE_GAP = 4.2;         // spacing between stocks (z)
+const LANE_LEN = 22;          // length along x
+const MAX_H = 7;              // max ribbon height (y)
+const laneMeshes = [];        // pickable ribbon meshes
+const laneGroups = [];        // per-stock groups (for dim/focus)
+
+function makeLabel(text, color = '#e8eefc', size = 44, bold = true) {
+  const c = document.createElement('canvas');
+  const ctx = c.getContext('2d');
+  const font = `${bold ? '700' : '400'} ${size}px -apple-system,Segoe UI,Roboto,sans-serif`;
+  ctx.font = font; const w = Math.ceil(ctx.measureText(text).width) + 20;
+  c.width = w; c.height = size + 20;
+  ctx.font = font; ctx.fillStyle = color; ctx.textBaseline = 'middle';
+  ctx.shadowColor = 'rgba(0,0,0,.6)'; ctx.shadowBlur = 6;
+  ctx.fillText(text, 10, c.height / 2);
+  const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4;
+  const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
+  spr.scale.set(w / 100 * 1.6, c.height / 100 * 1.6, 1);
+  return spr;
+}
+
+const stockState = STOCKS.map((s, i) => {
+  const series = window.buildSeries(s, 1000 + i * 7);
+  const prices = series.map(p => p.price);
+  const min = Math.min(...prices), max = Math.max(...prices);
+  return { stock: s, series, min, max };
+});
+
+function priceToY(st, price) {
+  const lo = st.min * 0.985, hi = st.max;
+  return ((price - lo) / (hi - lo)) * MAX_H + 0.4;
+}
+function idxToX(n, i) { return (i / (n - 1)) * LANE_LEN - LANE_LEN / 2; }
+
+stockState.forEach((st, si) => {
+  const s = st.stock;
+  const g = new THREE.Group();
+  const z = (si - (STOCKS.length - 1) / 2) * LANE_GAP;
+  g.position.z = z;
+  const baseColor = new THREE.Color(s.fits ? PASS : FAIL);
+
+  const n = st.series.length;
+  // --- filled area under the curve (ribbon) ---
+  const positions = [], indices = [];
+  for (let i = 0; i < n; i++) {
+    const x = idxToX(n, i);
+    const y = priceToY(st, st.series[i].price);
+    positions.push(x, 0.02, 0);   // baseline vertex (2i)
+    positions.push(x, y, 0);      // curve vertex (2i+1)
+  }
+  for (let i = 0; i < n - 1; i++) {
+    const a = 2 * i, b = 2 * i + 1, c = 2 * (i + 1), d = 2 * (i + 1) + 1;
+    indices.push(a, b, d, a, d, c);
+  }
+  const geo = new THREE.BufferGeometry();
+  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
+  geo.setIndex(indices); geo.computeVertexNormals();
+  const mat = new THREE.MeshStandardMaterial({
+    color: baseColor, transparent: true, opacity: 0.5, side: THREE.DoubleSide,
+    metalness: 0.2, roughness: 0.6, emissive: baseColor, emissiveIntensity: 0.12,
+  });
+  const ribbon = new THREE.Mesh(geo, mat);
+  ribbon.userData.stockIndex = si;
+  g.add(ribbon); laneMeshes.push(ribbon);
+
+  // --- bright top line ---
+  const linePts = [];
+  for (let i = 0; i < n; i++) linePts.push(new THREE.Vector3(idxToX(n, i), priceToY(st, st.series[i].price), 0.01));
+  const line = new THREE.Line(
+    new THREE.BufferGeometry().setFromPoints(linePts),
+    new THREE.LineBasicMaterial({ color: baseColor.clone().offsetHSL(0, 0, 0.15) })
+  );
+  g.add(line);
+
+  // --- volume bars (below baseline) ---
+  const vmax = Math.max(...st.series.map(p => p.vol));
+  for (let i = 0; i < n; i++) {
+    const h = (st.series[i].vol / vmax) * 1.8 + 0.02;
+    const bar = new THREE.Mesh(
+      new THREE.BoxGeometry(LANE_LEN / n * 0.55, h, 0.5),
+      new THREE.MeshBasicMaterial({ color: baseColor, transparent: true, opacity: 0.28 })
+    );
+    bar.position.set(idxToX(n, i), -h / 2 - 0.1, 0);
+    g.add(bar);
+  }
+
+  // --- anchor spheres + price labels ---
+  st.series.forEach((p, i) => {
+    if (!p.isAnchor) return;
+    const c = COL[p.anchor.type] ?? 0xffffff;
+    const sph = new THREE.Mesh(
+      new THREE.SphereGeometry(0.34, 20, 20),
+      new THREE.MeshStandardMaterial({ color: c, emissive: c, emissiveIntensity: 0.9 })
+    );
+    const y = priceToY(st, p.price);
+    sph.position.set(idxToX(n, i), y, 0);
+    g.add(sph);
+    // glow ring
+    const ring = new THREE.Mesh(new THREE.RingGeometry(0.4, 0.55, 24),
+      new THREE.MeshBasicMaterial({ color: c, transparent: true, opacity: 0.5, side: THREE.DoubleSide }));
+    ring.position.copy(sph.position); ring.lookAt(camera.position); g.add(ring);
+    sph.userData.ring = ring;
+    const priceLabel = s.ticker === 'ABLV' ? `$${p.price.toFixed(2)}` : `$${p.price.toFixed(p.price < 100 ? 2 : 0)}`;
+    const lab = makeLabel(priceLabel, '#' + new THREE.Color(c).getHexString(), 34);
+    lab.position.set(idxToX(n, i), y + 0.9, 0);
+    g.add(lab);
+  });
+
+  // --- ticker label + verdict at lane start ---
+  const tk = makeLabel(s.ticker, s.fits ? '#39d98a' : '#ff5c7a', 56);
+  tk.position.set(-LANE_LEN / 2 - 2.6, 1.4, 0); g.add(tk);
+  const vb = makeLabel(`${s.verdict}  ${s.drawdown}%`, '#8ea3c8', 30, false);
+  vb.position.set(-LANE_LEN / 2 - 2.6, 0.3, 0); g.add(vb);
+
+  g.userData = { si, ribbon, mat, line, group: g };
+  laneGroups.push(g);
+  scene.add(g);
+});
+
+// ---------- picking ----------
+const ray = new THREE.Raycaster();
+const mouse = new THREE.Vector2();
+let selected = -1;
+let downXY = null;
+
+renderer.domElement.addEventListener('pointerdown', e => { downXY = [e.clientX, e.clientY]; });
+renderer.domElement.addEventListener('pointerup', e => {
+  if (!downXY) return;
+  const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]);
+  downXY = null;
+  if (moved > 6) return; // was a drag, not a click
+  mouse.x = (e.clientX / innerWidth) * 2 - 1;
+  mouse.y = -(e.clientY / innerHeight) * 2 + 1;
+  ray.setFromCamera(mouse, camera);
+  const hit = ray.intersectObjects(laneMeshes, false)[0];
+  if (hit) selectStock(hit.object.userData.stockIndex);
+});
+
+function selectStock(si) {
+  selected = si;
+  laneGroups.forEach((g, i) => {
+    const on = i === si;
+    g.userData.mat.opacity = on ? 0.85 : 0.14;
+    g.userData.line.material.opacity = on ? 1 : 0.25;
+    g.userData.line.material.transparent = true;
+  });
+  renderList();
+  showDetail(si);
+}
+function clearSelect() {
+  selected = -1;
+  laneGroups.forEach(g => { g.userData.mat.opacity = 0.5; g.userData.line.material.opacity = 1; });
+  renderList();
+  document.getElementById('detail').classList.remove('show');
+}
+
+// ---------- 2D detail chart ----------
+function showDetail(si) {
+  const st = stockState[si], s = st.stock;
+  document.getElementById('detail').classList.add('show');
+  document.getElementById('d-tk').textContent = `${s.ticker} · ${s.name}`;
+  document.getElementById('d-cap').textContent = `${s.cls} ${s.marketCap} · ${s.sector}`;
+  document.getElementById('d-why').innerHTML =
+    `<b style="color:${s.fits ? 'var(--pass)' : 'var(--fail)'}">${s.verdict}</b> — ${s.why}`;
+
+  const a = s.anchors;
+  document.getElementById('d-kpis').innerHTML = `
+    <div class="kpi high"><span>Jul high</span><b>$${a[0].price}</b>${a[0].date.slice(5)}</div>
+    <div class="kpi trough"><span>trough</span><b>$${a[1].price}</b>${a[1].date.slice(5)}</div>
+    <div class="kpi"><span>drawdown</span><b style="color:${Math.abs(s.drawdown)>=10?'var(--fail)':'var(--dim)'}">${s.drawdown}%</b>${Math.abs(s.drawdown)>=10?'≥10 ✓':'<10 ✗'}</div>
+    <div class="kpi newhigh"><span>${a[2].label}</span><b>$${a[2].price}</b>${a[2].date.slice(5)}</div>`;
+
+  const volDay = (v, real) => real ? (v >= 1e6 ? (v/1e6).toFixed(1)+'M' : (v/1e3).toFixed(0)+'k') : 'n/a';
+  document.getElementById('d-vnote').textContent =
+    `Volume — ${s.volNote}. ` + (a[0].volReal
+      ? `Jul high ${volDay(a[0].vol,true)} · trough ${volDay(a[1].vol,true)} · recent ${volDay(a[2].vol,true)} shares.`
+      : `bars normalized (megacap daily volume not tick-sourced).`);
+
+  drawPriceChart(st);
+  drawVolChart(st);
+}
+
+function drawPriceChart(st) {
+  const cv = document.getElementById('chart');
+  const dpr = Math.min(devicePixelRatio, 2);
+  const W = cv.clientWidth, H = 200; cv.width = W * dpr; cv.height = H * dpr;
+  const ctx = cv.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H);
+  const s = st.stock, ser = st.series;
+  const pad = { l: 46, r: 10, t: 10, b: 18 };
+  const min = st.min * 0.99, max = st.max * 1.01;
+  const X = i => pad.l + (i / (ser.length - 1)) * (W - pad.l - pad.r);
+  const Y = p => pad.t + (1 - (p - min) / (max - min)) * (H - pad.t - pad.b);
+
+  // gridlines + y labels
+  ctx.strokeStyle = '#182338'; ctx.fillStyle = '#6f83a8'; ctx.font = '10px sans-serif';
+  for (let k = 0; k <= 4; k++) {
+    const p = min + (max - min) * k / 4, y = Y(p);
+    ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
+    ctx.fillText('$' + (p < 10 ? p.toFixed(2) : p.toFixed(0)), 6, y + 3);
+  }
+  // area fill
+  const grad = ctx.createLinearGradient(0, pad.t, 0, H);
+  const base = s.fits ? '57,217,138' : '255,92,122';
+  grad.addColorStop(0, `rgba(${base},.32)`); grad.addColorStop(1, `rgba(${base},0)`);
+  ctx.beginPath(); ctx.moveTo(X(0), Y(ser[0].price));
+  ser.forEach((p, i) => ctx.lineTo(X(i), Y(p.price)));
+  ctx.lineTo(X(ser.length - 1), H - pad.b); ctx.lineTo(X(0), H - pad.b); ctx.closePath();
+  ctx.fillStyle = grad; ctx.fill();
+  // line
+  ctx.beginPath(); ctx.lineWidth = 2; ctx.strokeStyle = `rgb(${base})`;
+  ser.forEach((p, i) => i ? ctx.lineTo(X(i), Y(p.price)) : ctx.moveTo(X(i), Y(p.price)));
+  ctx.stroke();
+  // anchor markers
+  ser.forEach((p, i) => {
+    if (!p.isAnchor) return;
+    const c = '#' + new THREE.Color(COL[p.anchor.type]).getHexString();
+    ctx.beginPath(); ctx.arc(X(i), Y(p.price), 5, 0, 7); ctx.fillStyle = c; ctx.fill();
+    ctx.strokeStyle = '#070b14'; ctx.lineWidth = 2; ctx.stroke();
+  });
+}
+
+function drawVolChart(st) {
+  const cv = document.getElementById('vchart');
+  const dpr = Math.min(devicePixelRatio, 2);
+  const W = cv.clientWidth, H = 64; cv.width = W * dpr; cv.height = H * dpr;
+  const ctx = cv.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H);
+  const s = st.stock, ser = st.series;
+  const pad = { l: 46, r: 10, t: 4, b: 4 };
+  const vmax = Math.max(...ser.map(p => p.vol));
+  const bw = (W - pad.l - pad.r) / ser.length;
+  ctx.fillStyle = '#6f83a8'; ctx.font = '9px sans-serif'; ctx.fillText('vol', 6, 12);
+  ser.forEach((p, i) => {
+    const x = pad.l + i * bw, h = (p.vol / vmax) * (H - pad.t - pad.b);
+    const c = p.isAnchor ? '#' + new THREE.Color(COL[p.anchor.type]).getHexString()
+      : (s.fits ? 'rgba(57,217,138,.4)' : 'rgba(255,92,122,.4)');
+    ctx.fillStyle = c; ctx.fillRect(x, H - pad.b - h, Math.max(1, bw - 1), h);
+  });
+}
+document.getElementById('d-x').onclick = clearSelect;
+
+// ---------- side list ----------
+function renderList() {
+  const el = document.getElementById('list');
+  el.innerHTML = STOCKS.map((s, i) => `
+    <div class="row ${i === selected ? 'sel' : ''}" data-i="${i}">
+      <span class="tk" style="color:${s.fits ? 'var(--pass)' : 'var(--fail)'}">${s.ticker}</span>
+      <div class="meta"><div>${s.name}</div><div class="cap">${s.cls} ${s.marketCap}</div></div>
+      <div style="text-align:right">
+        <span class="badge ${s.verdict}">${s.verdict}</span>
+        <div class="dd">${s.drawdown}%</div>
+      </div>
+    </div>`).join('');
+  el.querySelectorAll('.row').forEach(r => r.onclick = () => selectStock(+r.dataset.i));
+}
+renderList();
+
+// ---------- controls ----------
+const spinBtn = document.getElementById('spin');
+spinBtn.onclick = () => { controls.autoRotate = !controls.autoRotate; spinBtn.classList.toggle('on', controls.autoRotate); };
+document.getElementById('reset').onclick = () => {
+  camera.position.copy(camHome); controls.target.set(0, 3, 0); clearSelect();
+};
+let passOnly = false;
+document.getElementById('passonly').onclick = (e) => {
+  passOnly = !passOnly; e.target.classList.toggle('on', passOnly);
+  laneGroups.forEach((g) => { g.visible = !passOnly || STOCKS[g.userData.si].fits; });
+};
+addEventListener('keydown', e => { if (e.key === 'Escape') clearSelect(); });
+
+// ---------- resize + loop ----------
+addEventListener('resize', () => {
+  camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
+  renderer.setSize(innerWidth, innerHeight);
+  if (selected >= 0) { drawPriceChart(stockState[selected]); drawVolChart(stockState[selected]); }
+});
+
+// intro: raise ribbons
+let t0 = performance.now();
+(function loop(now) {
+  requestAnimationFrame(loop);
+  const k = Math.min(1, (now - t0) / 1200);
+  const ease = 1 - Math.pow(1 - k, 3);
+  laneGroups.forEach((g, i) => { g.scale.y = ease; });
+  // keep anchor rings facing camera
+  laneGroups.forEach(g => g.traverse(o => { if (o.userData && o.userData.ring) o.userData.ring.lookAt(camera.position); }));
+  controls.update();
+  renderer.render(scene, camera);
+})(t0);
diff --git a/public/data.js b/public/data.js
new file mode 100644
index 0000000..8b36b29
--- /dev/null
+++ b/public/data.js
@@ -0,0 +1,165 @@
+/* Stock data for the V-Shape viewer.
+ *
+ * DATA HONESTY (important):
+ *  - The three ANCHOR points per stock (mid-July high, interim trough, recent high)
+ *    are the VERIFIED figures pulled from StockAnalysis.com daily OHLC tables
+ *    (research runs 2026-08-27). They render as labelled ● markers.
+ *  - The daily line BETWEEN anchors is INTERPOLATED (seeded walk) to show the
+ *    shape — it is illustrative, not tick-accurate.
+ *  - Volume is REAL only where we have it (RNG, ABLV anchor days). For the
+ *    megacaps volume is normalized/illustrative and flagged as such.
+ */
+
+window.STOCKS = [
+  {
+    ticker: 'NVDA', name: 'NVIDIA', sector: 'Semiconductors',
+    marketCap: '~$5.45T', cls: 'Mega-cap', fits: true,
+    drawdown: -11.4, verdict: 'PASS', why: 'Correction then reclaim — textbook fit, largest cap.',
+    volNote: 'volume illustrative',
+    anchors: [
+      { date: '2026-07-22', price: 214.39, type: 'high',    label: 'Jul high',    vol: 1.00 },
+      { date: '2026-07-29', price: 190.01, type: 'trough',  label: 'trough',      vol: 1.35 },
+      { date: '2026-08-27', price: 225.83, type: 'newhigh', label: 'NEW high',    vol: 1.05 },
+    ],
+  },
+  {
+    ticker: 'PLTR', name: 'Palantir', sector: 'Software / AI',
+    marketCap: '~$0.4T', cls: 'Large-cap', fits: true,
+    drawdown: -13.9, verdict: 'PASS', why: 'Clean fit, dramatic +54% recovery off the trough.',
+    volNote: 'volume illustrative',
+    anchors: [
+      { date: '2026-07-15', price: 136.88, type: 'high',    label: 'Jul high',  vol: 1.00 },
+      { date: '2026-07-28', price: 117.89, type: 'trough',  label: 'trough',    vol: 1.4 },
+      { date: '2026-08-27', price: 181.25, type: 'newhigh', label: 'NEW high',  vol: 1.2 },
+    ],
+  },
+  {
+    ticker: 'ORCL', name: 'Oracle', sector: 'Enterprise Software',
+    marketCap: '~$404B', cls: 'Large-cap', fits: true,
+    drawdown: -17.8, verdict: 'PASS', why: 'Deepest dip of the winners, decisive breakout.',
+    volNote: 'volume illustrative',
+    anchors: [
+      { date: '2026-07-13', price: 139.65, type: 'high',    label: 'Jul high',  vol: 1.05 },
+      { date: '2026-07-24', price: 114.75, type: 'trough',  label: 'trough',    vol: 1.5 },
+      { date: '2026-08-27', price: 153.83, type: 'newhigh', label: 'NEW high',  vol: 1.1 },
+    ],
+  },
+  {
+    ticker: 'NFLX', name: 'Netflix', sector: 'Streaming / Media',
+    marketCap: '~$0.5T', cls: 'Large-cap', fits: true,
+    drawdown: -11.6, verdict: 'PASS', why: 'Clean fit (post 10-for-1 split, ~$75 range).',
+    volNote: 'volume illustrative',
+    anchors: [
+      { date: '2026-07-13', price: 75.45, type: 'high',    label: 'Jul high',  vol: 1.00 },
+      { date: '2026-07-20', price: 66.69, type: 'trough',  label: 'trough',    vol: 1.3 },
+      { date: '2026-08-25', price: 82.46, type: 'newhigh', label: 'NEW high',  vol: 1.1 },
+    ],
+  },
+  {
+    ticker: 'RNG', name: 'RingCentral', sector: 'Cloud Comms',
+    marketCap: '~$5.7B', cls: 'Small/Mid-cap', fits: false,
+    drawdown: -6.9, verdict: 'FAIL', why: 'Dip too shallow (−7%) — earnings gap-up, no real correction.',
+    volNote: 'volume REAL (shares)',
+    anchors: [
+      { date: '2026-07-13', price: 43.27, type: 'high',    label: 'Jul high',  vol: 1_330_000, volReal: true },
+      { date: '2026-07-24', price: 40.30, type: 'trough',  label: 'trough',    vol: 8_920_000, volReal: true },
+      { date: '2026-08-27', price: 68.60, type: 'newhigh', label: 'high (+59%)', vol: 1_200_000, volReal: true },
+    ],
+  },
+  {
+    ticker: 'ABLV', name: 'Able View Global', sector: 'Consumer / Beauty',
+    marketCap: '~$47M', cls: 'Micro-cap', fits: false,
+    drawdown: -24.8, verdict: 'FAIL', why: 'No new high — Aug bounce was a LOWER high (~7% under July).',
+    volNote: 'volume REAL (shares) — ADV only ~5k, untradeable',
+    anchors: [
+      { date: '2026-07-15', price: 1.13, type: 'high',    label: 'Jul high',    vol: 52_056, volReal: true },
+      { date: '2026-08-21', price: 0.85, type: 'trough',  label: 'trough',      vol: 905,    volReal: true },
+      { date: '2026-08-25', price: 1.06, type: 'lowerhigh', label: 'lower high', vol: 34_751, volReal: true },
+    ],
+  },
+];
+
+// ---- seeded PRNG (deterministic renders) ----
+function mulberry32(a) {
+  return function () {
+    a |= 0; a = (a + 0x6D2B79F5) | 0;
+    let t = Math.imul(a ^ (a >>> 15), 1 | a);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+
+// business days (Mon–Fri) from 2026-07-01 through 2026-08-27
+function businessDays() {
+  const out = [];
+  const d = new Date('2026-07-01T00:00:00Z');
+  const end = new Date('2026-08-27T00:00:00Z');
+  while (d <= end) {
+    const dow = d.getUTCDay();
+    if (dow !== 0 && dow !== 6) out.push(d.toISOString().slice(0, 10));
+    d.setUTCDate(d.getUTCDate() + 1);
+  }
+  return out;
+}
+const DAYS = businessDays();
+
+// Build a daily {date, price, vol, isAnchor, anchor} series that passes exactly
+// through the verified anchors, with a small seeded wiggle between them.
+window.buildSeries = function (stock, seed) {
+  const rnd = mulberry32(seed);
+  const idxOf = (date) => {
+    let i = DAYS.indexOf(date);
+    if (i === -1) { // nearest business day
+      let best = 0, bd = 1e9;
+      for (let k = 0; k < DAYS.length; k++) {
+        const diff = Math.abs(new Date(DAYS[k]) - new Date(date));
+        if (diff < bd) { bd = diff; best = k; }
+      }
+      i = best;
+    }
+    return i;
+  };
+  const anchors = stock.anchors.map(a => ({ ...a, i: idxOf(a.date) }));
+  // start point a touch below the first anchor for a lead-in
+  const first = anchors[0];
+  const startPrice = first.price * 0.985;
+
+  const nodes = [{ i: 0, price: startPrice }, ...anchors.map(a => ({ i: a.i, price: a.price, ref: a }))];
+  // ensure last extends to final day
+  if (nodes[nodes.length - 1].i < DAYS.length - 1) {
+    nodes.push({ i: DAYS.length - 1, price: anchors[anchors.length - 1].price });
+  }
+
+  const series = [];
+  const volBase = stock.anchors[0].volReal ? stock.anchors[0].vol : 1;
+  for (let s = 0; s < nodes.length - 1; s++) {
+    const A = nodes[s], B = nodes[s + 1];
+    const span = Math.max(1, B.i - A.i);
+    const local = (A.price + B.price) / 2;
+    for (let i = A.i; i < B.i; i++) {
+      const t = (i - A.i) / span;
+      // smoothstep for a curvy path
+      const st = t * t * (3 - 2 * t);
+      let price = A.price + (B.price - A.price) * st;
+      const wiggle = (rnd() - 0.5) * 0.02 * local; // ±1% of local
+      price += wiggle * Math.sin(t * Math.PI); // fade wiggle at nodes
+      series.push({ date: DAYS[i], price, vol: null, isAnchor: false });
+    }
+  }
+  series.push({ date: DAYS[DAYS.length - 1], price: nodes[nodes.length - 1].price, vol: null, isAnchor: false });
+
+  // stamp anchors exactly + real/illustrative volume
+  const byDate = Object.fromEntries(series.map((p, k) => [p.date, k]));
+  for (const a of anchors) {
+    const k = byDate[DAYS[a.i]];
+    if (k != null) { series[k].price = a.price; series[k].isAnchor = true; series[k].anchor = a; series[k].vol = a.vol; }
+  }
+  // fill illustrative volume for non-anchor days (normalized 0..~1.2)
+  for (let k = 0; k < series.length; k++) {
+    if (series[k].vol == null) {
+      const near = 0.6 + rnd() * 0.5;
+      series[k].vol = stock.anchors[0].volReal ? volBase * near : near;
+    }
+  }
+  return series;
+};
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..a901399
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,109 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>Stock V-Shape · 3D Viewer</title>
+<style>
+  :root{
+    --bg:#070b14; --panel:rgba(14,20,34,.86); --stroke:#1e2a44;
+    --ink:#e8eefc; --dim:#8ea3c8; --pass:#39d98a; --fail:#ff5c7a;
+    --high:#39d98a; --trough:#ff5c7a; --newhigh:#ffd54a;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
+    font:14px/1.45 -apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,Roboto,sans-serif;overflow:hidden}
+  #app{position:fixed;inset:0}
+  canvas{display:block}
+
+  .glass{background:var(--panel);border:1px solid var(--stroke);border-radius:14px;
+    backdrop-filter:blur(10px);box-shadow:0 12px 40px rgba(0,0,0,.45)}
+
+  /* header */
+  #head{position:fixed;top:14px;left:14px;padding:12px 16px;max-width:340px;z-index:10}
+  #head h1{margin:0 0 2px;font-size:15px;letter-spacing:.2px}
+  #head .sub{color:var(--dim);font-size:12px}
+  .legend{display:flex;gap:12px;margin-top:8px;flex-wrap:wrap;font-size:11px;color:var(--dim)}
+  .dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:middle}
+  .note{margin-top:8px;font-size:10.5px;color:#6f83a8;border-top:1px dashed var(--stroke);padding-top:6px}
+
+  /* stock list */
+  #list{position:fixed;top:14px;right:14px;width:216px;padding:8px;z-index:10;max-height:calc(100% - 28px);overflow:auto}
+  .row{display:flex;align-items:center;gap:8px;padding:8px;border-radius:9px;cursor:pointer;transition:.15s}
+  .row:hover{background:rgba(255,255,255,.05)}
+  .row.sel{background:rgba(87,140,255,.14);outline:1px solid #2f4d8f}
+  .tk{font-weight:700;font-size:13px;width:52px}
+  .row .meta{flex:1;min-width:0}
+  .row .meta .cap{color:var(--dim);font-size:11px}
+  .badge{font-size:10px;font-weight:700;padding:2px 6px;border-radius:6px}
+  .badge.PASS{background:rgba(57,217,138,.16);color:var(--pass)}
+  .badge.FAIL{background:rgba(255,92,122,.16);color:var(--fail)}
+  .dd{font-size:11px;color:var(--dim);white-space:nowrap}
+
+  /* controls */
+  #ctl{position:fixed;bottom:14px;left:14px;z-index:10;display:flex;gap:8px;padding:8px}
+  .btn{background:#101a30;border:1px solid var(--stroke);color:var(--ink);border-radius:9px;
+    padding:7px 11px;font-size:12px;cursor:pointer}
+  .btn:hover{border-color:#3a568f}
+  .btn.on{background:#1c2c50;border-color:#4a6bb5}
+
+  /* detail chart */
+  #detail{position:fixed;bottom:14px;right:14px;width:min(460px,46vw);padding:14px;z-index:10;display:none}
+  #detail.show{display:block}
+  #detail .dh{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:6px}
+  #detail .dh b{font-size:16px}
+  #detail .dh .x{cursor:pointer;color:var(--dim);font-size:18px;line-height:1}
+  #detail .why{color:var(--dim);font-size:12px;margin:2px 0 10px}
+  #chart{width:100%;height:200px;display:block}
+  #vchart{width:100%;height:64px;display:block;margin-top:4px}
+  .kpis{display:flex;gap:14px;margin-top:8px;font-size:11.5px;flex-wrap:wrap}
+  .kpi b{display:block;font-size:14px}
+  .kpi.high b{color:var(--high)} .kpi.trough b{color:var(--trough)} .kpi.newhigh b{color:var(--newhigh)}
+  .vnote{color:#6f83a8;font-size:10.5px;margin-top:6px}
+  #hint{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);color:var(--dim);
+    font-size:12px;z-index:5;pointer-events:none;opacity:.85}
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div id="head" class="glass">
+  <h1>Stock V-Shape · <span style="color:var(--dim)">high → dip → high</span></h1>
+  <div class="sub">Mid-July 2026 high → ≥10% trough → fresh high (Aug 27)</div>
+  <div class="legend">
+    <span><i class="dot" style="background:var(--high)"></i>Jul high</span>
+    <span><i class="dot" style="background:var(--trough)"></i>trough</span>
+    <span><i class="dot" style="background:var(--newhigh)"></i>recent high</span>
+  </div>
+  <div class="note">● markers = verified prices (StockAnalysis.com). Line between them is interpolated to show shape. Volume real for RNG/ABLV; illustrative for megacaps.</div>
+</div>
+
+<div id="list" class="glass"></div>
+
+<div id="ctl" class="glass">
+  <button class="btn on" id="spin">⟳ Auto-rotate</button>
+  <button class="btn" id="reset">⤢ Reset view</button>
+  <button class="btn" id="passonly">✓ Passers only</button>
+</div>
+
+<div id="detail" class="glass">
+  <div class="dh"><b id="d-tk"></b><span id="d-cap" style="color:var(--dim);font-size:12px"></span><span class="x" id="d-x">✕</span></div>
+  <div class="why" id="d-why"></div>
+  <canvas id="chart"></canvas>
+  <canvas id="vchart"></canvas>
+  <div class="kpis" id="d-kpis"></div>
+  <div class="vnote" id="d-vnote"></div>
+</div>
+
+<div id="hint">drag to orbit · scroll to zoom · click a ribbon for its chart</div>
+
+<script src="data.js"></script>
+<script type="importmap">
+{ "imports": {
+  "three": "https://unpkg.com/three@0.169.0/build/three.module.js",
+  "three/addons/": "https://unpkg.com/three@0.169.0/examples/jsm/"
+}}
+</script>
+<script type="module" src="app.js"></script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..13d8d05
--- /dev/null
+++ b/server.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+// Zero-dependency static server for the Stock V-Shape 3D viewer.
+// Basic-auth gated (admin / DW2024!), OS-assigned free port per Steve's viewer standard.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const USER = process.env.VIEWER_USER || 'admin';
+const PASS = process.env.VIEWER_PASS || 'DW2024!';
+const ROOT = path.join(__dirname, 'public');
+
+const MIME = {
+  '.html': 'text/html; charset=utf-8',
+  '.js': 'text/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8',
+  '.json': 'application/json; charset=utf-8',
+  '.svg': 'image/svg+xml',
+};
+
+function unauthorized(res) {
+  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="stock-vshape-viewer"' });
+  res.end('Authentication required');
+}
+
+const server = http.createServer((req, res) => {
+  // --- Basic auth ---
+  const hdr = req.headers.authorization || '';
+  const [scheme, encoded] = hdr.split(' ');
+  if (scheme !== 'Basic' || !encoded) return unauthorized(res);
+  const [u, p] = Buffer.from(encoded, 'base64').toString().split(':');
+  if (u !== USER || p !== PASS) return unauthorized(res);
+
+  // --- Static serving (path-traversal safe) ---
+  let rel = decodeURIComponent((req.url || '/').split('?')[0]);
+  if (rel === '/') rel = '/index.html';
+  const filePath = path.normalize(path.join(ROOT, rel));
+  if (!filePath.startsWith(ROOT)) { res.writeHead(403); return res.end('Forbidden'); }
+
+  fs.readFile(filePath, (err, buf) => {
+    if (err) { res.writeHead(404); return res.end('Not found'); }
+    res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream' });
+    res.end(buf);
+  });
+});
+
+// port 0 => OS assigns a free port
+server.listen(process.env.PORT ? Number(process.env.PORT) : 0, () => {
+  const { port } = server.address();
+  console.log(`\n  📈  Stock V-Shape 3D viewer`);
+  console.log(`  →  http://localhost:${port}/`);
+  console.log(`  →  auth: ${USER} / ${PASS}\n`);
+});

(oldest)  ·  back to Stock Vshape Viewer  ·  Live parametric V-shape screener: local three.js, criteria b 8c16d08 →