[object Object]

← back to Stock Vshape Viewer

3D volume reads: per-vertex volume color (bright=heavy/dim=light) on the ribbons, white glow marker on each stock's peak-volume day, and a per-stock<->comparable normalization toggle. Also: backtest-sweet-spot flag on scores (40-79 validated band)

5ff547082d9a7049babe14fbfe2c96667514f846 · 2026-08-28 00:28:55 -0700 · Steve

Files touched

Diff

commit 5ff547082d9a7049babe14fbfe2c96667514f846
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 28 00:28:55 2026 -0700

    3D volume reads: per-vertex volume color (bright=heavy/dim=light) on the ribbons, white glow marker on each stock's peak-volume day, and a per-stock<->comparable normalization toggle. Also: backtest-sweet-spot flag on scores (40-79 validated band)
---
 public/app.js     | 40 +++++++++++++++++++++++++++++++++-------
 public/index.html |  1 +
 2 files changed, 34 insertions(+), 7 deletions(-)

diff --git a/public/app.js b/public/app.js
index db03753..e58635a 100644
--- a/public/app.js
+++ b/public/app.js
@@ -3,7 +3,7 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
 // ============================ state ============================
 const LS = {
-  tickers: 'vshape.tickers', filters: 'vshape.filters', saved: 'vshape.saved', tf: 'vshape.tf', volLayer: 'vshape.vollayer',
+  tickers: 'vshape.tickers', filters: 'vshape.filters', saved: 'vshape.saved', tf: 'vshape.tf', volLayer: 'vshape.vollayer', volNorm: 'vshape.volnorm',
 };
 const TF = [
   { k: '1M', n: 21 }, { k: '3M', n: 63 }, { k: '6M', n: 126 },
@@ -18,6 +18,7 @@ const state = {
   saved: load(LS.saved, []),
   tfN: load(LS.tf, 126),
   volLayer: load(LS.volLayer, true),
+  volNorm: load(LS.volNorm, 'per-stock'),
   meta: Object.fromEntries(window.SEED.map(s => [s.ticker, s])),
   raw: {},        // ticker -> { days:[...full], source, asOf } | { error }
   data: {},       // ticker -> processed { days, ma50, ma200, an, min, max }
@@ -115,21 +116,31 @@ function rebuildScene() {
     scene.remove(g.group);
   }
   groups = []; laneMeshes.length = 0;
+  // global peak volume across every loaded stock (for cross-stock normalization)
+  state.globalVmax = 0;
+  for (const tk of state.tickers) { const DD = state.data[tk]; if (DD) for (const d of DD.days) { const v = d.vol || 0; if (v > state.globalVmax) state.globalVmax = v; } }
   for (const t of state.tickers) {
     const D = state.data[t]; if (!D) continue;
     const s = metaOf(t), an = D.an, n = D.days.length;
     const g = new THREE.Group();
     const baseColor = new THREE.Color(an.fits ? PASS : FAIL);
 
-    // VOLUME-EXTRUDED 3D ribbon — z-thickness at each column swells with that day's volume
-    const vmax = Math.max(...D.days.map(d => d.vol || 0)) || 1;
-    const depthAt = i => 0.1 + ((D.days[i].vol || 0) / vmax) * 1.35; // half-depth (bloat), capped to fit lane spacing
-    const pos = [], idx = [];
+    // VOLUME-EXTRUDED 3D ribbon — z-thickness AND per-column color both scale with volume.
+    // Normalization: per-stock (own peak) or global (comparable across the whole board).
+    const vmaxLocal = Math.max(...D.days.map(d => d.vol || 0)) || 1;
+    const vmaxRef = state.volNorm === 'global' ? (state.globalVmax || vmaxLocal) : vmaxLocal;
+    const volRatio = i => Math.min(1, (D.days[i].vol || 0) / vmaxRef);
+    const depthAt = i => 0.1 + volRatio(i) * 1.35; // half-depth (bloat), capped to lane spacing
+    const cLo = baseColor.clone().multiplyScalar(0.4);                      // dim = low volume
+    const cHi = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.65);    // bright = high volume
+    const pos = [], idx = [], cols = [];
     // 4 verts per column: 0 baseFront(+d), 1 topFront(+d), 2 baseBack(-d), 3 topBack(-d)
     for (let i = 0; i < n; i++) {
       const x = idxToX(n, i), y = priceToY(D, D.days[i].close), d = depthAt(i);
       pos.push(x, 0.02, d); pos.push(x, y, d);
       pos.push(x, 0.02, -d); pos.push(x, y, -d);
+      const c = cLo.clone().lerp(cHi, volRatio(i));
+      for (let k = 0; k < 4; k++) cols.push(c.r, c.g, c.b);
     }
     const Vp = 4;
     for (let i = 0; i < n - 1; i++) {
@@ -140,11 +151,22 @@ function rebuildScene() {
     }
     const geo = new THREE.BufferGeometry();
     geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
+    geo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
     geo.setIndex(idx); geo.computeVertexNormals();
-    const mat = new THREE.MeshStandardMaterial({ color: baseColor, transparent: true, opacity: 0.62,
-      side: THREE.DoubleSide, metalness: 0.25, roughness: 0.55, emissive: baseColor, emissiveIntensity: 0.14 });
+    const mat = new THREE.MeshStandardMaterial({ vertexColors: true, transparent: true, opacity: 0.72,
+      side: THREE.DoubleSide, metalness: 0.25, roughness: 0.5, emissive: baseColor, emissiveIntensity: 0.1 });
     const ribbon = new THREE.Mesh(geo, mat); ribbon.userData.ticker = t; g.add(ribbon); laneMeshes.push(ribbon);
 
+    // subtle GLOW on the single highest-volume day
+    let pk = 0; for (let i = 1; i < n; i++) if ((D.days[i].vol || 0) > (D.days[pk].vol || 0)) pk = i;
+    const gx = idxToX(n, pk), gy = priceToY(D, D.days[pk].close);
+    const glow = new THREE.Mesh(new THREE.SphereGeometry(0.26, 16, 16),
+      new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.92 }));
+    glow.position.set(gx, gy + 0.15, 0); g.add(glow);
+    const halo = new THREE.Mesh(new THREE.RingGeometry(0.34, 0.62, 22),
+      new THREE.MeshBasicMaterial({ color: 0x9fd8ff, transparent: true, opacity: 0.5, side: THREE.DoubleSide }));
+    halo.position.copy(glow.position); halo.userData.ring = true; g.add(halo);
+
     // price line
     const linePts = []; for (let i = 0; i < n; i++) linePts.push(new THREE.Vector3(idxToX(n, i), priceToY(D, D.days[i].close), 0.01));
     const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(linePts),
@@ -554,6 +576,10 @@ 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(); };
 document.getElementById('refresh').onclick = () => loadAll();
+const volnormBtn = document.getElementById('volnorm');
+const syncVolNorm = () => { volnormBtn.textContent = state.volNorm === 'global' ? '⬍ Vol: comparable' : '⬍ Vol: per-stock'; volnormBtn.classList.toggle('on', state.volNorm === 'global'); };
+syncVolNorm();
+volnormBtn.onclick = () => { state.volNorm = state.volNorm === 'global' ? 'per-stock' : 'global'; save(LS.volNorm, state.volNorm); syncVolNorm(); rebuildScene(); applyFilters(); };
 addEventListener('keydown', e => { if (e.key === 'Escape') { clearSelect(); document.getElementById('left').classList.remove('open'); } });
 addEventListener('resize', () => {
   camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight);
diff --git a/public/index.html b/public/index.html
index e5797e6..7461fed 100644
--- a/public/index.html
+++ b/public/index.html
@@ -249,6 +249,7 @@
 <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="volnorm" title="volume-thickness scale: per-stock (each ribbon vs its own peak) or comparable (all ribbons on one scale)">⬍ Vol: per-stock</button>
   <button class="btn" id="refresh">↻ Refresh prices</button>
 </div>
 <div id="hint">drag to orbit · scroll to zoom · click a layer for its chart · ☰ to screen</div>

← d1e5b8a Evidence-driven sweet-spot flag: mark scores in the backtest  ·  back to Stock Vshape Viewer  ·  yoloforever cycle 1: 3D encodings legend (height/volume/verd b40722e →