← 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
M public/app.jsM public/index.html
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 →