[object Object]

← back to Stock Vshape Viewer

Volume-by-price as a left-anchored bar-graph layer (bars grow from left axis = shares at each price), POC gold bar + value area, on-top overlay with Vol-layer toggle; + panel Playwright spec (npm test)

4d8a1274b1f0a8f39d67a17a02cde6f5f3105388 · 2026-08-27 13:57:56 -0700 · Steve

Files touched

Diff

commit 4d8a1274b1f0a8f39d67a17a02cde6f5f3105388
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 27 13:57:56 2026 -0700

    Volume-by-price as a left-anchored bar-graph layer (bars grow from left axis = shares at each price), POC gold bar + value area, on-top overlay with Vol-layer toggle; + panel Playwright spec (npm test)
---
 package.json        |   5 ++-
 public/app.js       |  49 ++++++++++++++--------
 public/index.html   |   1 +
 test/panel.spec.mjs | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 153 insertions(+), 19 deletions(-)

diff --git a/package.json b/package.json
index d6343c2..a7618ad 100644
--- a/package.json
+++ b/package.json
@@ -4,5 +4,8 @@
   "private": true,
   "description": "Dynamic Three.js viewer for the high -> dip -> high stock screen (Aug 2026)",
   "main": "server.js",
-  "scripts": { "start": "node server.js" }
+  "scripts": {
+    "start": "node server.js",
+    "test": "node test/panel.spec.mjs"
+  }
 }
diff --git a/public/app.js b/public/app.js
index c393a25..0518532 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',
+  tickers: 'vshape.tickers', filters: 'vshape.filters', saved: 'vshape.saved', tf: 'vshape.tf', volLayer: 'vshape.vollayer',
 };
 const TF = [
   { k: '1M', n: 21 }, { k: '3M', n: 63 }, { k: '6M', n: 126 },
@@ -17,6 +17,7 @@ const state = {
   filters: Object.assign({}, window.DEFAULT_FILTERS, load(LS.filters, {})),
   saved: load(LS.saved, []),
   tfN: load(LS.tf, 126),
+  volLayer: load(LS.volLayer, true),
   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 }
@@ -217,23 +218,6 @@ function drawPriceChart(D) {
   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); }
-  // volume-by-price profile (shares owned at each price) on the right ~28%
-  const prof = D.an.profile;
-  if (prof) {
-    const maxBarPx = (W - pad.l - pad.r) * 0.28, rightX = W - pad.r;
-    for (let b = 0; b < prof.bins.length; b++) {
-      const pLo = prof.lo + b * prof.bw, pHi = pLo + prof.bw;
-      const yTop = Y(pHi), yBot = Y(pLo);
-      const w = prof.maxVol ? (prof.bins[b] / prof.maxVol) * maxBarPx : 0;
-      const inVA = pHi > prof.vaLo && pLo < prof.vaHi;
-      ctx.fillStyle = inVA ? 'rgba(124,156,220,.34)' : 'rgba(124,156,220,.14)';
-      ctx.fillRect(rightX - w, yTop, w, Math.max(1, yBot - yTop - 1));
-    }
-    const yp = Y(prof.poc); // Point of Control (most shares held here)
-    ctx.strokeStyle = '#ffd54a'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
-    ctx.beginPath(); ctx.moveTo(pad.l, yp); ctx.lineTo(rightX, yp); ctx.stroke(); ctx.setLineDash([]);
-    ctx.fillStyle = '#ffd54a'; ctx.font = '9px sans-serif'; ctx.fillText('POC $' + prof.poc.toFixed(prof.poc < 10 ? 2 : 0), rightX - 66, yp - 3);
-  }
   const base = D.an.fits ? '57,217,138' : '255,92,122';
   // area
   const grad = ctx.createLinearGradient(0, pad.t, 0, H);
@@ -256,6 +240,26 @@ function drawPriceChart(D) {
     ctx.beginPath(); ctx.arc(X(pt.i), Y(pt.price), 5, 0, 7); ctx.fillStyle = c; ctx.fill();
     ctx.strokeStyle = '#070b14'; ctx.lineWidth = 2; ctx.stroke();
   }
+  // VOLUME-BY-PRICE LAYER — horizontal bars from the LEFT: shares traded at each price
+  const prof = D.an.profile;
+  if (prof && state.volLayer) {
+    const maxBarPx = (W - pad.l - pad.r) * 0.44; // layer spans up to ~44% out from the left axis
+    for (let b = 0; b < prof.bins.length; b++) {
+      if (!prof.bins[b]) continue;
+      const pLo = prof.lo + b * prof.bw, pHi = pLo + prof.bw;
+      const yTop = Y(pHi), yBot = Y(pLo);
+      const w = prof.maxVol ? (prof.bins[b] / prof.maxVol) * maxBarPx : 0;
+      const isPoc = prof.poc >= pLo && prof.poc < pHi;
+      const inVA = pHi > prof.vaLo && pLo < prof.vaHi;
+      ctx.fillStyle = isPoc ? 'rgba(255,213,74,.55)' : inVA ? 'rgba(92,140,255,.42)' : 'rgba(92,140,255,.22)';
+      ctx.fillRect(pad.l, yTop, w, Math.max(1, yBot - yTop - 1));
+    }
+    const yp = Y(prof.poc); // Point of Control
+    ctx.strokeStyle = '#ffd54a'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
+    ctx.beginPath(); ctx.moveTo(pad.l, yp); ctx.lineTo(W - pad.r, yp); ctx.stroke(); ctx.setLineDash([]);
+    ctx.fillStyle = '#ffd54a'; ctx.font = '9px sans-serif';
+    ctx.fillText('POC $' + prof.poc.toFixed(prof.poc < 10 ? 2 : 0), pad.l + 3, yp - 3);
+  }
 }
 function drawVolChart(D) {
   const cv = document.getElementById('vchart'), dpr = Math.min(devicePixelRatio, 2);
@@ -295,6 +299,7 @@ function renderDaily(D) {
 }
 const brush = { t: null, si: 0, ei: 0 };
 function renderDetailBody(t, D) {
+  state.detailD = D;
   const an = D.an;
   const zone = document.getElementById('d-zone'); zone.className = 'zone ' + an.zoneCls; zone.textContent = an.zoneLabel;
   document.getElementById('d-why').innerHTML =
@@ -670,6 +675,14 @@ function wireScanAndOptions() {
   });
   // lazy-load options when the detail toggle is clicked
   document.getElementById('d-opttoggle').onclick = () => { if (state.selected) loadOptions(state.selected); };
+  // volume-by-price layer toggle
+  const vlt = document.getElementById('vol-layer-toggle');
+  const syncVlt = () => { vlt.style.opacity = state.volLayer ? '1' : '0.4'; };
+  syncVlt();
+  vlt.onclick = () => {
+    state.volLayer = !state.volLayer; save(LS.volLayer, state.volLayer); syncVlt();
+    if (state.detailD) drawPriceChart(state.detailD);
+  };
   // reset buttons
   document.getElementById('reset-watchlist').onclick = () => {
     state.tickers = window.SEED.map(s => s.ticker); save(LS.tickers, state.tickers);
diff --git a/public/index.html b/public/index.html
index 7d5aa29..0303a18 100644
--- a/public/index.html
+++ b/public/index.html
@@ -257,6 +257,7 @@
     <span><i style="background:var(--high)"></i>high</span>
     <span><i style="background:var(--trough)"></i>trough</span>
     <span><i style="background:var(--newhigh)"></i>recent high</span>
+    <span id="vol-layer-toggle" style="cursor:pointer;margin-left:auto;color:#5c8fd6;font-weight:600" title="toggle the volume-by-price layer">◧ Vol layer</span>
   </div>
   <canvas id="vchart"></canvas>
   <div class="brushwrap"><canvas id="d-brush"></canvas>
diff --git a/test/panel.spec.mjs b/test/panel.spec.mjs
new file mode 100644
index 0000000..547b39f
--- /dev/null
+++ b/test/panel.spec.mjs
@@ -0,0 +1,117 @@
+// Panel behavior spec — opens the collapsed #left hamburger and exercises the controls
+// the /3x click-through can't reach. Run: node test/panel.spec.mjs [url]
+// Needs the server running (default http://localhost:9822/). Uses global Playwright.
+import { createRequire } from 'module';
+import os from 'os';
+import path from 'path';
+const require = createRequire(import.meta.url);
+
+const URL = process.argv[2] || 'http://localhost:9822/';
+const CREDS = { username: 'admin', password: 'DW2024!' };
+
+// resolve global Playwright
+let pw;
+for (const p of [
+  path.join(os.homedir(), '.npm-global/lib/node_modules/playwright'),
+  '/usr/local/lib/node_modules/playwright', 'playwright',
+]) { try { pw = require(p); break; } catch {} }
+if (!pw) { console.error('Playwright not found (npm i -g playwright)'); process.exit(2); }
+
+async function launch() {
+  for (const opt of [{ channel: 'chrome' }, {}, { executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }]) {
+    try { return await pw.chromium.launch(opt); } catch {}
+  }
+  throw new Error('could not launch a chromium/chrome');
+}
+
+let pass = 0, fail = 0;
+const ok = (name, cond, detail = '') => { (cond ? (pass++, console.log('  ✅', name, detail)) : (fail++, console.log('  ❌', name, detail))); };
+
+const run = async () => {
+  const browser = await launch();
+  const ctx = await browser.newContext({ httpCredentials: CREDS, viewport: { width: 1440, height: 1000 } });
+  await ctx.addInitScript(() => { try { localStorage.clear(); } catch {} }); // deterministic seed
+  const page = await ctx.newPage();
+
+  const jsErrors = [];
+  page.on('pageerror', e => jsErrors.push(String(e)));
+  page.on('console', m => { if (m.type() === 'error' && !/favicon/.test(m.text())) jsErrors.push(m.text()); });
+
+  console.log(`\n/panel-spec → ${URL}\n`);
+  await page.goto(URL);
+  await page.waitForFunction(() => document.querySelectorAll('#wl .item').length >= 6 && /live/.test(document.getElementById('live-txt').textContent), { timeout: 25000 });
+  ok('live data loads (seed watchlist)', true);
+
+  // open the hamburger panel
+  await page.click('#burger');
+  ok('panel opens', await page.evaluate(() => document.getElementById('left').classList.contains('open')));
+
+  // --- SCAN ---
+  const wlBefore = await page.evaluate(() => document.querySelectorAll('#wl .item').length);
+  await page.evaluate(() => { const s = document.getElementById('scan-max'); s.value = 120; s.dispatchEvent(new Event('input', { bubbles: true })); });
+  await page.click('#scan-btn');
+  await page.waitForFunction(() => document.getElementById('scan-prog').textContent.includes('loaded top'), { timeout: 45000 });
+  const scan = await page.evaluate(() => ({
+    matches: +((document.getElementById('scan-prog').textContent.match(/(\d+) matches/) || [])[1] || 0),
+    results: document.querySelectorAll('#scan-results .r').length,
+    wl: document.querySelectorAll('#wl .item').length,
+  }));
+  ok('scan → finds matches', scan.matches > 0, `${scan.matches} matches`);
+  ok('scan → renders results list', scan.results > 0, `${scan.results} rows`);
+  ok('scan → auto-loads board', scan.wl > wlBefore, `${wlBefore}→${scan.wl}`);
+
+  // --- ADD ---
+  await page.fill('#add-tk', 'AAPL');
+  await page.click('#add-btn');
+  await page.waitForFunction(() => [...document.querySelectorAll('#wl .tk')].some(e => e.textContent === 'AAPL'), { timeout: 15000 });
+  ok('add ticker → AAPL on watchlist', true);
+
+  // --- RESET WATCHLIST ---
+  await page.click('#reset-watchlist');
+  await page.waitForFunction(() => document.querySelectorAll('#wl .item').length === 6, { timeout: 15000 });
+  ok('reset watchlist → back to seed 6', true);
+
+  // --- RESET ALL ---
+  await page.evaluate(() => { const m = document.getElementById('f-minDip'); m.value = 25; m.dispatchEvent(new Event('input', { bubbles: true })); });
+  await page.click('#reset-all');
+  await page.waitForTimeout(600);
+  const ra = await page.evaluate(() => ({ minDip: document.getElementById('v-minDip').textContent, wl: document.querySelectorAll('#wl .item').length }));
+  ok('reset all → minDip default 10%', ra.minDip === '10%', ra.minDip);
+  ok('reset all → seed watchlist', ra.wl === 6, String(ra.wl));
+
+  // --- OPTIONS ---
+  await page.evaluate(() => { const n = [...document.querySelectorAll('#wl .item')].find(it => it.querySelector('.tk')?.textContent === 'NFLX'); n && n.click(); });
+  await page.waitForSelector('#detail.show', { timeout: 8000 });
+  await page.click('#d-opttoggle');
+  await page.waitForFunction(() => { const b = document.getElementById('d-opts'); return b && (b.querySelector('table') || /No contracts|no options/i.test(b.textContent)); }, { timeout: 20000 });
+  const opt = await page.evaluate(() => ({ hasTable: !!document.querySelector('#d-opts table'), rows: document.querySelectorAll('#d-opts table tr').length }));
+  ok('options → chain loads', opt.hasTable || true, opt.hasTable ? `${opt.rows} rows` : 'no qualifying contracts (valid)');
+  await page.evaluate(() => { const p = [...document.querySelectorAll('#seg-optType button')].find(b => b.dataset.v === 'puts'); p.click(); });
+  await page.waitForTimeout(1500);
+  ok('options → type toggle (Puts)', await page.evaluate(() => document.querySelector('#seg-optType button[data-v="puts"]').classList.contains('on')));
+
+  // --- PRESET ---
+  await page.evaluate(() => document.querySelector('#presets .p[data-i="0"]').click());
+  await page.waitForTimeout(600);
+  ok('preset → Deep V-Recovery sets dip≥20%', await page.evaluate(() => document.getElementById('crit-dip').textContent === '≥20%'));
+
+  // --- BRUSH (custom range) ---
+  await page.evaluate(() => { const n = [...document.querySelectorAll('#wl .item')].find(it => it.querySelector('.tk')?.textContent === 'NFLX'); n && n.click(); });
+  await page.waitForSelector('#detail.show', { timeout: 8000 });
+  const box = await page.evaluate(() => { const r = document.getElementById('d-brush').getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; });
+  await page.mouse.move(box.x + box.w * 0.5, box.y + box.h / 2);
+  await page.mouse.down();
+  await page.mouse.move(box.x + box.w * 0.85, box.y + box.h / 2, { steps: 6 });
+  await page.mouse.up();
+  await page.waitForTimeout(500);
+  ok('brush → sets custom range', await page.evaluate(() => /Custom range/.test(document.getElementById('d-vnote').textContent)));
+
+  // --- console cleanliness ---
+  ok('no JS/console errors', jsErrors.length === 0, jsErrors.slice(0, 3).join(' | '));
+
+  await browser.close();
+  console.log(`\n  ${pass} passed · ${fail} failed\n`);
+  process.exit(fail ? 1 : 0);
+};
+
+run().catch(e => { console.error('spec crashed:', e); process.exit(1); });

← fdcc20e 5x report: six-way core PASS all 3 browsers, 0 console error  ·  back to Stock Vshape Viewer  ·  3D volume-extruded ribbons: each layer's z-thickness swells/ 9b99f9e →