← 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
A .gitignoreA package.jsonA public/app.jsA public/data.jsA public/index.htmlA server.js
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 →