← 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
M package.jsonM public/app.jsM public/index.htmlA test/panel.spec.mjs
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 →