PageSourceSearch

https://www.theghostflow.com/demo

html theghostflow.com collected 2026-09-24 14:54:14 UTC 1,961,025 bytes, 25,101 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>GhostFlow</title>
7<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=3">
8<script src="https://unpkg.com/[email protected]/dist/lightweight-charts.standalone.production.js"></script>
8
9<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
10<script>/* Theme test (admin picker sets localStorage gfTheme / gfNav; nothing set = classic look). Applied before CSS so there is no flash. */
11window.gfT=function(n,f){try{var v=getComputedStyle(document.documentElement).getPropertyValue(n).trim();return v||f;}catch(e){return f;}};
12try{var _gt=localStorage.getItem('gfTheme');if(_gt==='wraith'||_gt==='sunset'||_gt==='phantom'){localStorage.removeItem('gfTheme');_gt=null;}if(_gt&&_gt!=='default')document.documentElement.setAttribute('data-theme',_gt);}catch(e){}</script>
12
13<style>
14@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
15@keyframes fadeIn { from{opacity:0;background:rgba(var(--t-accent-rgb,59,130,246),0.1)} to{opacity:1;background:transparent} }
16* { margin: 0; padding: 0; box-sizing: border-box; }
17/* Tell the browser this is a DARK page so every UA-drawn control — the root scrollbar above all —
18   renders dark. Without it some browsers painted a white page scrollbar on the dashboard (member
19   report), while the CSS below only skins element scrollbars. */
20:root { color-scheme: dark; }
21/* Scrollbars: invisible until you hover the scrollable area, then subtle. */
22::-webkit-scrollbar { width: 10px; height: 10px; }
23::-webkit-scrollbar-track { background: transparent; }
24::-webkit-scrollbar-thumb { background: transparent; border-radius:calc(5px*var(--t-rk,1)); }
25:hover::-webkit-scrollbar-thumb { background: var(--t-border3,#334155); }
26::-webkit-scrollbar-thumb:hover { background: var(--t-dim2,#475569); }
27* { scrollbar-width: thin; scrollbar-color: transparent transparent; }
28*:hover { scrollbar-color: var(--t-border3,#334155) transparent; }
29:root {
30  --bg: var(--t-bg,#0a0e17); --card: var(--t-card,#111827); --border: var(--t-border,#1e293b);
31  --text: var(--t-text,#e2e8f0); --dim: var(--t-dim,#8b9cb5);
32  --green: #22c55e; --red: #ef4444; --yellow: #eab308;
33  --blue: var(--t-accent,#3b82f6); --purple: #a855f7; --cyan: var(--t-accent,#3b82f6);
34}
35body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; font-variant-numeric: tabular-nums; }
36.mono { font-family: 'Segoe UI', monospace; }
37/* ── New-layout flow rows (GF_NEW_LAYOUT) ── */
38.nf-row{display:flex;flex-direction:column;gap:5px;padding:8px 11px;border-bottom:1px solid var(--t-line2,#131c2a);border-left:3px solid transparent;font-family:'Segoe UI',sans-serif;font-size:11.5px}
39.nf-row:hover{background:var(--t-hover,#0f1826)}
40.nf-botC{border-left-color:var(--green)} .nf-botP{border-left-color:var(--red)}
41.nf-soldC,.nf-soldP{border-left-color:var(--yellow)}
42.nf-top{display:flex;align-items:center;gap:8px}
43.nf-side{font-size:9.5px;font-weight:800;letter-spacing:.03em;padding:2px 6px;border-radius:calc(4px*var(--t-rk,1));white-space:nowrap}
44.nf-side.botC{background:var(--green);color:#06210f} .nf-side.botP{background:var(--red);color:#280808}
45.nf-side.soldC,.nf-side.soldP{background:var(--yellow);color:#2a2200}
46.nf-contract{font-weight:700;font-size:12.5px;color:var(--t-text,#eef4fb);cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-decoration-color:rgba(255,255,255,.28)}
47.nf-time{margin-left:auto;font-size:10px;color:var(--dim);white-space:nowrap}
48.nf-trk{opacity:.8;font-size:9px;font-weight:700;color:var(--t-accentHi,#38bdf8);cursor:pointer;white-space:nowrap;border:1px solid var(--t-border3,#2f4a63);border-radius:calc(4px*var(--t-rk,1));padding:1px 6px;background:rgba(var(--t-accentHi-rgb,56,189,248),.08)}
49.nf-row:hover .nf-trk{opacity:1;border-color:var(--cyan)}
50.nf-trk.nf-tracked{opacity:1;color:#86efac;border-color:#2f6b45;background:rgba(34,197,94,.1)}
51.nf-mid{display:flex;align-items:center;gap:9px}
52.nf-sz{color:var(--t-text,#e6eef7);font-weight:600} .nf-perct{color:var(--t-dim,#9fb0c3)} .nf-prem{color:#fff;font-weight:700}
53.nf-tag{font-size:9px;font-weight:700;letter-spacing:.03em;padding:1px 6px;border-radius:calc(3px*var(--t-rk,1))}
54.nf-tag.sweep{background:rgba(249,115,22,.18);color:#fdba74} .nf-tag.block{background:var(--t-border,#1b2636);color:var(--t-dim,#9fb0c3)}
55.nf-acc{background:#1e3a5f;color:var(--t-accentHi,#93c5fd);border-radius:calc(3px*var(--t-rk,1));padding:0 4px;font-size:9px;font-weight:800;margin-left:3px}
56.nf-uw{margin-left:auto;font-size:9px;font-weight:700;color:#fb923c;border:1px solid #fb923c;border-radius:calc(3px*var(--t-rk,1));padding:1px 6px;text-decoration:none}
57.nf-uw:hover{background:rgba(249,115,22,.15)}
58.nf-voi{font-size:10.5px;color:var(--t-dim,#8497ab)}
59.nf-voi b{color:var(--t-muted,#cbd5e1);font-weight:600} .nf-voi b.hi{color:var(--green)}
60.nf-ba{display:flex;align-items:center;gap:8px;margin-top:1px}
61.nf-bl{font-size:10px;font-weight:600;color:#fca5a5;min-width:52px}
62.nf-al{font-size:10px;font-weight:600;color:#86efac;min-width:52px;text-align:right}
63.nf-conv{flex:1;height:7px;background:rgba(239,68,68,.30);border-radius:calc(4px*var(--t-rk,1));overflow:hidden}
64.nf-conv i{display:block;height:100%;background:var(--green);border-radius:calc(4px*var(--t-rk,1));float:right}
65/* Ultra new-layout: metrics strip + flow minimize */
66#ultraFlow{position:relative}
67.nf-flowmin-btn{position:absolute;top:6px;right:6px;z-index:6;width:22px;height:22px;border-radius:calc(5px*var(--t-rk,1));border:1px solid var(--t-border2,#26364a);background:var(--card);color:var(--dim);cursor:pointer;font-size:13px;line-height:1;padding:0}
68.nf-flow-min{overflow:hidden}
69.nf-flow-min > *:not(.nf-flowmin-btn){display:none !important}
70.nf-metrics{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--border);border-bottom:1px solid var(--border);flex-shrink:0}
71.nf-met{background:var(--t-hover,#0f1826);padding:7px 10px}
72.nf-met-k{font-size:9px;letter-spacing:.05em;text-transform:uppercase;color:var(--dim)}
73.nf-met-v{font-family:'Segoe UI',sans-serif;font-size:13px;font-weight:700;margin-top:2px}
74.nf-met-v.pos{color:var(--green)} .nf-met-v.neg{color:var(--red)}
75/* Net Call/Put GEX chips on the toolbar's top line (Ultra) */
76#ultraTopMetrics .nf-topmet{display:flex;flex-direction:column;line-height:1.15;white-space:nowrap}
77#ultraTopMetrics .nf-topmet .k{font-size:9px;letter-spacing:.05em;text-transform:uppercase;color:#e8eef7;font-weight:600}
78#ultraTopMetrics .nf-topmet .v{font-family:Segoe UI,monospace;font-size:14px;font-weight:700}
79#ultraTopMetrics .nf-topmet .v.pos{color:#5ef08a} #ultraTopMetrics .nf-topmet .v.neg{color:#fca5a5}
80/* Chart tab new-layout: hide ticker chips, GEX toggle, Trade Plan panel */
81body.gf-newlayout #chartHeaderChips{display:none}
82body.gf-newlayout #chartGexPanel{display:none}
83body.gf-newlayout.gf-gexon #chartGexPanel{display:flex}
84#chartGexToggle{display:none}
85/* New layout: the chart's GEX panel is always shown, so the toggle button is removed. */
86body.gf-newlayout #chartGexToggle{display:none !important}
87/* Consolidated layout: Ultra tab is retired — the Chart view is now the full workstation. */
88body.gf-newlayout #btn-ultra{display:none !important}
89/* Per-window TradingView-sync toggle (link / unlink). Only shown in the new layout. */
90#tvSyncBtn{display:none}
91body.gf-newlayout #tvSyncBtn{display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:calc(14px*var(--t-rk,1));
92  font-family:Segoe UI,monospace;font-size:11px;font-weight:700;letter-spacing:.02em;cursor:pointer;
93  border:1px solid var(--border,var(--t-border2,#26364a));background:transparent;color:var(--t-dim,#94a3b8);white-space:nowrap}
94#tvSyncBtn .tvchain{position:relative;width:15px;height:11px;flex:0 0 auto}
95#tvSyncBtn .tvchain i{position:absolute;top:1px;width:9px;height:9px;border:2px solid currentColor;border-radius:calc(4px*var(--t-rk,1))}
96#tvSyncBtn .tvchain i:first-child{left:0} #tvSyncBtn .tvchain i:last-child{right:0}
97#tvSyncBtn .tvchain b{position:absolute;top:4.5px;left:4px;width:7px;height:2px;background:currentColor}
98body.gf-newlayout #tvSyncBtn.linked{border-color:#22c55e;background:rgba(34,197,94,.14);color:#22c55e}
99body.gf-newlayout #tvSyncBtn.unlinked{color:var(--t-dim,#94a3b8);border-color:var(--border,var(--t-border2,#26364a));background:transparent}
100#tvSyncBtn.unlinked .tvchain b{background:transparent;border-top:2px dashed currentColor;height:0;top:5px;width:8px;left:3.5px}
101/* Nodes (King/CW/PW/VN) sit to the LEFT of the toolbar, not pushed to the far right. */
102body.gf-newlayout #chartHeaderChips{margin-left:16px !important}
103#chartTradePlan{display:none}
104body.gf-ws-on #ultraTPSec,body.gf-ws-on .nf-usec-resize{display:none!important}   /* Trade Plan is not part of the Ghost Panel in My HQ */
105body.gf-ws-on #ultraFlowSec{flex:1 1 0!important}
106body.gf-newlayout #chartTradePlan{display:flex;flex-direction:column;width:300px;flex-shrink:0;border-left:1px solid var(--border);background:var(--card);overflow:hidden;transition:width .2s}
107body.gf-newlayout #chartTradePlan.nf-tp-min{width:40px}
108.nf-tp-head{display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--border)}
109.nf-tp-head .t{font-size:11px;font-weight:700;letter-spacing:.06em;color:var(--text)}
110.nf-tp-min-btn{margin-left:auto;width:22px;height:22px;border-radius:calc(5px*var(--t-rk,1));border:1px solid var(--t-border2,#26364a);background:none;color:var(--dim);cursor:pointer;font-size:14px;line-height:1}
111body.gf-newlayout #chartTradePlan.nf-tp-min .nf-tp-body,body.gf-newlayout #chartTradePlan.nf-tp-min .nf-tp-head .t{display:none}
112.nf-tp-body{overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
113.nf-regime{border-radius:calc(9px*var(--t-rk,1));padding:11px 12px;border:1px solid transparent}
114.nf-regime.pos{border-color:rgba(34,197,94,.35);background:linear-gradient(180deg,rgba(34,197,94,.12),rgba(34,197,94,.03))}
115.nf-regime.neg{border-color:rgba(239,68,68,.35);background:linear-gradient(180deg,rgba(239,68,68,.12),rgba(239,68,68,.03))}
116.nf-regime-b{font-size:12px;font-weight:800;letter-spacing:.04em}
117.nf-regime.pos .nf-regime-b{color:#86efac} .nf-regime.neg .nf-regime-b{color:#fca5a5}
118.nf-regime p{margin:6px 0 0;font-size:11px;color:var(--dim);line-height:1.4}
119.nf-tp-sec{font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:-2px 0 -4px}
120.nf-lvl-list{display:flex;flex-direction:column;gap:1px;background:var(--border);border-radius:calc(8px*var(--t-rk,1));overflow:hidden}
121.nf-lvl{display:grid;grid-template-columns:12px 1fr auto auto;gap:9px;align-items:center;background:var(--t-hover,#0f1826);padding:7px 10px;font-size:11px}
122.nf-lvl i{width:8px;height:8px;border-radius:50%}
123.nf-lvl-nm{color:var(--dim)} .nf-lvl-pr{font-weight:700}
124.nf-lvl-ds{font-size:10px;width:56px;text-align:right}
125.nf-lvl-ds.pos{color:#5ef08a} .nf-lvl-ds.neg{color:#fca5a5}
126.nf-setup{border-radius:calc(9px*var(--t-rk,1));border:1px solid var(--t-border2,#26364a);overflow:hidden}
127.nf-setup-h{display:flex;align-items:center;padding:8px 11px;font-size:11px;font-weight:700}
128.nf-setup.long .nf-setup-h{background:rgba(34,197,94,.14);color:#86efac}
129.nf-setup.short .nf-setup-h{background:rgba(239,68,68,.14);color:#fca5a5}
130.nf-rr{margin-left:auto;font-size:10px;opacity:.85}
131.nf-setup-b{padding:8px 11px;display:flex;flex-direction:column;gap:6px;background:var(--t-hover,#0f1826)}
132.nf-kv{display:flex;justify-content:space-between;font-size:11px}
133.nf-kv span{color:var(--dim)}
134.nf-next{display:flex;gap:8px}
135.nf-next-c{flex:1;border:1px solid var(--t-border2,#26364a);border-radius:calc(8px*var(--t-rk,1));padding:8px 10px;background:var(--t-hover,#0f1826)}
136.nf-next-c .k{font-size:9px;color:var(--dim);text-transform:uppercase}
137.nf-next-c .v{font-weight:700;font-size:13px;margin-top:2px}
138.nf-next-c .v.pos{color:#5ef08a} .nf-next-c .v.neg{color:#fca5a5}
139/* New-layout: seamless panels — dividers are invisible until you hover the panel edge,
140   so the workspace reads as one surface, not stitched-together boxes. */
141body.gf-newlayout #ultraGex,
142body.gf-newlayout #ultraExpiry{border-right:1px solid transparent !important}
143body.gf-newlayout #ultraGex:hover,
144body.gf-newlayout #ultraExpiry:hover{border-right-color:rgba(var(--t-dim-rgb,148,163,184),.2) !important}
145body.gf-newlayout #chartTradePlan{border-left:1px solid transparent;background:var(--bg)}
146body.gf-newlayout #chartTradePlan:hover{border-left-color:rgba(var(--t-dim-rgb,148,163,184),.2)}
147/* Resize dividers: hidden at rest, appear only on hover/drag. */
148body.gf-newlayout .resize-handle{background:transparent}
149/* Floating restore arrow shown when the Ultra flow panel is hidden. */
150.nf-flowrestore-btn{position:absolute;top:8px;right:10px;z-index:30;padding:5px 11px;border-radius:calc(6px*var(--t-rk,1));border:1px solid var(--t-border2,#26364a);background:var(--card);color:var(--cyan);cursor:pointer;font-size:11px;font-weight:700;line-height:1;align-items:center}
151.nf-flowrestore-btn:hover{border-color:var(--cyan)}
152/* Ultra borrows the chart toolbar so its top bar matches Chart view EXACTLY: ticker + spot +
153   timeframes + draw tools + replay + indicators + nodes (King/CW/PW/VN/AN) all on one line.
154   The ONLY intended difference is the GEX toggle — Chart has it, Ultra doesn't (it has the
155   GEX ladder on the left instead). Ultra's separate info bar is redundant, so it's hidden. */
156body.gf-newlayout #ultraToolbar #chartGexToggle{display:none !important}
157body.gf-newlayout #ultraInfo{display:none !important}
158/* The full-width toolbar sits above the ladder + chart; keep it on one line like Chart view. */
159body.gf-newlayout #ultraToolbar #chartHeader{flex-wrap:nowrap;overflow-x:auto}
160body.gf-newlayout #ultraToolbar #chartHeader::-webkit-scrollbar{height:0}
161/* The docked GEX panel's "TICKER · GEX $price" header is redundant in the new layout —
162   the toolbar/timeframe line already shows the ticker + price. Hide it on both surfaces. */
163body.gf-newlayout #chartGexPanel .cgp-head{display:none}
164/* New-layout Ultra GEX ladder: a normal scroll list — wheel / two-finger pinch scrolls it
165   up & down (like the chart's GEX panel). Subtle auto-hiding scrollbar. */
166body.gf-newlayout #ultraGex{overflow-y:auto;scrollbar-width:thin;scrollbar-color:transparent transparent}
167body.gf-newlayout #ultraGex:hover{scrollbar-color:var(--t-border3,#334155) transparent}
168body.gf-newlayout #ultraGex::-webkit-scrollbar{width:8px}
169body.gf-newlayout #ultraGex::-webkit-scrollbar-thumb{background:transparent;border-radius:calc(4px*var(--t-rk,1))}
170body.gf-newlayout #ultraGex:hover::-webkit-scrollbar-thumb{background:var(--t-border3,#334155)}
171/* Ultra right panel: Flow (top) + Trade Plan (bottom), each collapsible to a bar in 1 click. */
172.nf-usec{flex:1 1 0;min-height:0;display:flex;flex-direction:column;overflow:hidden}
173.nf-usec + .nf-usec{border-top:1px solid var(--border)}
174.nf-usec-head{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;padding:7px 12px;background:var(--card);font-family:Segoe UI,monospace;font-size:11px;font-weight:700;letter-spacing:.05em;color:var(--cyan);cursor:pointer;user-select:none}
175.nf-usec-head:hover{color:var(--t-accentHi,#bde9ff)}
176.nf-usec-chev{font-size:10px;color:var(--dim);transition:transform .15s}
177.nf-usec-body{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
178.nf-usec.collapsed{flex:0 0 auto}
179.nf-usec.collapsed .nf-usec-body{display:none}
180.nf-usec.collapsed .nf-usec-chev{transform:rotate(-90deg)}
181/* Draggable divider between Flow and Trade Plan — thin & invisible at rest, shows only on hover/drag. */
182.nf-usec-resize{flex:0 0 auto;height:5px;cursor:ns-resize;background:transparent;transition:background .15s}
183.nf-usec-resize:hover,.nf-usec-resize.nf-dragging{background:var(--cyan)}
184/* Minimize the WHOLE Flow + Trade Plan panel to a thin right rail (chart takes the space). */
185body.gf-newlayout #ultraFlow{position:relative;transition:width .18s}
186.nf-panel-min-btn{position:absolute;top:2px;right:2px;z-index:8;width:15px;height:15px;font-size:11px;   /* top corner of the panel - it sat on the P $ value (user 2026-09-05) */border-radius:calc(4px*var(--t-rk,1));border:1px solid var(--t-border2,#26364a);background:var(--card);color:var(--dim);cursor:pointer;font-size:13px;line-height:1;padding:0}
187.nf-panel-min-btn:hover{color:var(--text);border-color:var(--cyan)}
188#ultraFlowCP{margin-right:16px}   /* keep the C/P premium clear of the corner button */
189#ultraFlow.nf-panel-min{width:30px !important;min-width:30px}
190#ultraFlow.nf-panel-min > .nf-usec,#ultraFlow.nf-panel-min > .nf-usec-resize,#ultraFlow.nf-panel-min > .nf-panel-min-btn{display:none !important}
191.nf-panel-rail{display:none}
192#ultraFlow.nf-panel-min .nf-panel-rail{display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 0;height:100%}
193.nf-panel-rail button{width:22px;height:22px;border-radius:calc(5px*var(--t-rk,1));border:1px solid var(--t-border2,#26364a);background:var(--card);color:var(--cyan);cursor:pointer;font-size:13px}
194.nf-panel-rail .nf-rail-txt{writing-mode:vertical-rl;font-family:Segoe UI,monospace;font-size:10px;letter-spacing:.14em;color:var(--dim);text-transform:uppercase}
195/* Tracking page */
196#btn-tracking{display:none}
197body.gf-newlayout #btn-tracking{display:inline-block}
198.trk-top{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
199.trk-top h2{margin:0;font-size:16px;font-weight:700}
200.trk-desc{font-size:11px;color:var(--dim)}
201.trk-add-btn{margin-left:auto;font-size:11px;font-weight:600;padding:7px 13px;border-radius:calc(6px*var(--t-rk,1));border:1px solid var(--cyan);background:rgba(var(--t-accentHi-rgb,56,189,248),.14);color:var(--t-accentHi,#bde9ff);cursor:pointer}
202.trk-addform{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;padding:10px 12px;background:var(--card);border:1px solid var(--border);border-radius:calc(9px*var(--t-rk,1))}
203.trk-addform input,.trk-addform select{background:var(--bg);color:var(--text);border:1px solid var(--t-border2,#26364a);border-radius:calc(6px*var(--t-rk,1));padding:6px 8px;font-size:12px;font-family:inherit}
204.trk-addform input#trkT{width:80px} .trk-addform input#trkK,.trk-addform input#trkEn{width:92px}
205.trk-addform button{background:rgba(var(--t-accentHi-rgb,56,189,248),.14);border:1px solid var(--cyan);color:var(--t-accentHi,#bde9ff);border-radius:calc(6px*var(--t-rk,1));padding:6px 14px;cursor:pointer;font-weight:600}
206.trk-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px}
207.trk-tile{background:var(--card);border:1px solid var(--border);border-radius:calc(10px*var(--t-rk,1));padding:12px 14px}
208.trk-tile .k{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim)}
209.trk-tile .v{font-size:22px;font-weight:800;margin-top:5px}
210.trk-tile .v.pos{color:var(--green)} .trk-tile .v.neg{color:var(--red)}
211.trk-tile .sub{font-size:11px;color:var(--dim);margin-top:2px}
212.trk-tile .sub.pos{color:#5ef08a} .trk-tile .sub.neg{color:#fca5a5}
213.trk-empty{padding:24px;text-align:center;color:var(--dim);font-size:13px;background:var(--card);border:1px solid var(--border);border-radius:calc(10px*var(--t-rk,1));margin-bottom:16px}
214.trk-sector{margin-bottom:16px}
215.trk-sechead{display:flex;align-items:center;gap:10px;padding:6px 2px}
216.trk-sechead .nm{font-size:11px;font-weight:700;letter-spacing:.06em;color:var(--dim)}
217.trk-sechead .ct{font-size:10px;color:var(--dim)}
218.trk-sechead .pl{margin-left:auto;font-weight:700;font-size:12px}
219.trk-sechead .pl.pos{color:#5ef08a} .trk-sechead .pl.neg{color:#fca5a5}
220.trk-tblwrap{overflow-x:auto}
221.trk-tbl{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--border);border-radius:calc(10px*var(--t-rk,1));overflow:hidden;font-size:12px}
222.trk-tbl th{text-align:left;font-size:9px;letter-spacing:.07em;text-transform:uppercase;color:var(--dim);font-weight:600;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--t-hover,#0f1826)}
223.trk-tbl.gf-bright th{color:var(--t-muted,#cbd5e1);font-weight:700}
224.trk-tbl td{padding:9px 12px;border-bottom:1px solid var(--t-line2,#131c2a)}
225.trk-tbl tr:last-child td{border-bottom:none}
226.trk-tbl .num{text-align:right;font-variant-numeric:tabular-nums}
227.trk-tbl .tk{font-weight:700}
228.trk-tbl .cp{font-weight:700} .trk-tbl .cp.c{color:var(--green)} .trk-tbl .cp.p{color:var(--red)}
229.trk-tbl td.pos{color:#5ef08a} .trk-tbl td.neg{color:#fca5a5}
230.trk-close{font-size:10px;font-weight:700;color:var(--dim);background:none;border:1px solid var(--t-border2,#26364a);border-radius:calc(5px*var(--t-rk,1));padding:3px 9px;cursor:pointer}
231.trk-close:hover{color:#fff;border-color:#3a4d63;background:#16202f}
232.trk-px{width:58px;font-size:10px;color:var(--text);background:#020617;border:1px solid var(--t-border2,#26364a);border-radius:calc(5px*var(--t-rk,1));padding:3px 6px;vertical-align:middle;-moz-appearance:textfield}
233.trk-px::-webkit-outer-spin-button,.trk-px::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
234.trk-px:focus{border-color:var(--cyan);outline:none}
235.trk-pill{font-size:9px;font-weight:700;padding:2px 6px;border-radius:calc(4px*var(--t-rk,1))}
236.trk-pill.win{background:rgba(34,197,94,.16);color:#86efac} .trk-pill.loss{background:rgba(239,68,68,.16);color:#fca5a5}
237@media(max-width:800px){.trk-tiles{grid-template-columns:1fr 1fr}}
238
239/* Header */
240.header {
241  border-bottom: 1px solid var(--border);
242  padding: 12px 20px;
243  display: flex; align-items: center; justify-content: space-between;
244  position: sticky; top: 0; background: var(--bg); z-index: 100;
245}
246.logo { font-family: 'Segoe UI', monospace; font-size: 16px; font-weight: 700; }
247.logo .c1 { color: var(--cyan); }
248.logo .c2 { color: var(--dim); }
249.header-right { display: flex; align-items: center; gap: 10px; }
250.refresh-btn {
251  padding: 5px 10px; background: transparent; border: 1px solid var(--border);
252  border-radius:calc(6px*var(--t-rk,1));
252 color: var(--dim); font-size: 11px; cursor: pointer;
253  font-family: 'Segoe UI', monospace;
254}
255.refresh-btn:hover { color: var(--cyan); border-color: var(--cyan); }
256
257/* Tabs */
258.tabs-bar { padding: 10px 20px; display: flex; gap: 6px; flex-wrap: wrap; border-bottom: 1px solid var(--border); position: sticky; top: var(--header-h, 56px); z-index: 90; background: var(--bg); }
259.tab {
260  padding: 5px 12px; border-radius:calc(5px*var(--t-rk,1)); border: none; cursor: pointer;
261  font-size: 11px; font-weight: 600; font-family: 'Segoe UI', monospace;
262  background: var(--card); color: var(--dim);
263}
264.tab.active { background: var(--blue); color: var(--t-onAccent,#fff); }
265
266/* Columns layout */
267.columns-container {
268  display: flex; overflow-x: auto; overflow-y: hidden; padding: 0;
269  height: calc(100dvh - 90px);
270}
271/* Always-visible horizontal scrollbar at the bottom */
272/* Uniform muted scrollbars everywhere — no bright accent so nothing looks odd. */
273*::-webkit-scrollbar { width: 10px; height: 10px; }
274*::-webkit-scrollbar-track { background: transparent; }
275*::-webkit-scrollbar-thumb { background: var(--t-border3,#334155); border-radius:calc(5px*var(--t-rk,1)); }
276*::-webkit-scrollbar-thumb:hover { background: var(--t-dim2,#475569); }
277.columns-container::-webkit-scrollbar { height: 12px; }
278.columns-container::-webkit-scrollbar-track { background: var(--card); }
279.columns-container::-webkit-scrollbar-thumb { background: var(--t-border3,#334155); border-radius:calc(6px*var(--t-rk,1)); }
280.columns-container::-webkit-scrollbar-thumb:hover { background: var(--t-dim2,#475569); }
281
282/* Single GEX column — exactly 5 fit on screen; 6+ overflow into the horizontal scrollbar */
283.gex-col {
284  flex: 1 0 calc(100% / 5);
285  min-width: calc(100% / 5);
286  /* grid v2 admin test: 7 columns per window (member layout stays 5) */
287}
288.gf-admin .gex-col {
289  flex: 1 0 calc(100% / 5);   /* admin: 5 per window (user 2026-09-05 "now it showing good"); premium lab grid stays as it is */
290  min-width: calc(100% / 5);
291  box-sizing: border-box;
292  border-right: 1px solid var(--border);
293  display: flex; flex-direction: column;
294}
295.gex-col:last-child { border-right: none; }
296
297/* Column header */
298.col-header {
299  padding: 10px 14px;
300  border-bottom: 1px solid var(--border);
301  display: flex; align-items: center; justify-content: space-between;
302  background: var(--card);
303  position: sticky; top: 0; z-index: 10;
304}
305.col-ticker {
306  font-family: 'Segoe UI', monospace; font-size: 14px; font-weight: 700;
307  display: flex; align-items: center; gap: 8px;
308}
309.col-ticker select {
310  background: var(--bg); color: var(--text); border: 1px solid var(--border);
311  border-radius:calc(4px*var(--t-rk,1)); padding: 2px 6px; font-family: 'Segoe UI', monospace;
312  font-size: 13px; font-weight: 700; cursor: pointer; outline: none;
313}
314.col-price { font-family: 'Segoe UI', monospace; font-size: 12px; }
315.col-change { font-size: 11px; padding: 2px 6px; border-radius:calc(4px*var(--t-rk,1)); font-weight: 600; }
316.col-change.up { background: rgba(34,197,94,0.15); color: var(--green); }
317.col-change.down { background: rgba(239,68,68,0.15); color: var(--red); }
318
319/* King badge */
320.cgp-gear{width:18px;height:18px;border-radius:5px;border:1px solid var(--t-border2,#2e323d);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--t-dim,#8f8a7c);flex:none;margin-right:22px}
321.cgp-gear:hover,.cgp-gear.on{border-color:var(--t-accent,#d4af37);color:var(--t-accentHi,#e9d38a)}
322#cgpGearPop{position:fixed;z-index:1300;display:none;width:250px;background:var(--t-card,#0f172a);border:1px solid var(--t-border2,#2e323d);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.55);padding:8px 10px;font-family:'Segoe UI',monospace;font-size:11px;color:var(--text)}
323#cgpGearPop .t{font-size:9.5px;letter-spacing:.14em;color:var(--t-accentHi,#e9d38a);font-weight:800;margin-bottom:6px}
324#cgpGearPop .r{display:flex;align-items:center;gap:6px;margin:5px 0}
325#cgpGearPop .n{width:40px;color:var(--t-dim,#8f8a7c);font-size:10px;font-weight:700}
326#cgpGearPop .gexp-btn{height:20px;padding:0 7px;font-size:10px}
327#cgpGearPop .rs{margin-left:auto;color:var(--cyan);font-size:10px;font-weight:700;cursor:pointer}
328.king-row {
329  padding: 6px 14px;
330  border-bottom: 1px solid var(--border);
331  font-family: 'Segoe UI', monospace; font-size: 11px;
332  display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; overflow: hidden; white-space: nowrap;
333  background: rgba(234,179,8,0.05);
334}
335.king-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--yellow); }
336.king-label { color: var(--dim); }
337.king-pct { font-weight: 600; }
338
339/* Close button */
340.col-close {
341  background: none; border: none; color: var(--dim); cursor: pointer;
342  font-size: 16px; padding: 0 4px; line-height: 1;
343}
344.col-close:hover { color: var(--red); }
345.col-refresh {
346  background: none; border: 1px solid var(--border); color: var(--dim); cursor: pointer;
347  font-size: 12px; padding: 2px 6px; border-radius:calc(4px*var(--t-rk,1)); font-family: 'Segoe UI', monospace;
348}
349.col-refresh:hover { color: var(--cyan); border-color: var(--cyan); }
350/* Drag-to-reorder */
351.col-drag {
352  cursor: grab; color: var(--dim); font-size: 12px; line-height: 1; letter-spacing: -2px;
353  padding: 0 4px 0 0; user-select: none; opacity: 0.55; flex: none;
354}
355.col-drag:hover { opacity: 1; color: var(--cyan); }
356.col-drag:active { cursor: grabbing; }
357.gf-dragging { opacity: 0.4; }
358.exp-ticker-btn.gf-dragging { outline: 1px dashed var(--cyan); }
359
360/* Strike rows */
361.strikes-list { flex: 1; overflow-y: auto; position: relative; }
362
363/* Complete GEX-view column docked on the GEX chart's left side — solid, full chart
364   height, and scrollable (its own strikes list scrolls). It's an absolute overlay,
365   so the chart never shrinks regardless. */
366#chartGexPanel {
367  position: absolute; top: 0; left: 0; bottom: 0; width: 290px; z-index: 5;
368  pointer-events: auto;
369  background: var(--bg, var(--t-bg,#0a0e17));
370  border-right: 1px solid var(--border);
371  display: flex; flex-direction: column; overflow: hidden;
372  font-family: 'Segoe UI', monospace;
373}
374/* ── Ghost Desk: 2–3 charts side by side ──────────────────────────────────────
375   The focused chart is the one carrying the ladder, drawings, modes and panels, so it gets a
376   cyan edge. Charts sit adjacent; the ladder and trade plan stay on the row's OUTER edges. */
377/* The active chart must be unmistakable — it is the one every top-bar control acts on. */
378body.gf-desk .mchart-focused { box-shadow: inset 0 0 0 2px var(--cyan); }
379/* (no per-pane opacity: opacity<1 makes each pane a stacking context, which painted pane 3 OVER the fixed Layers panel that lives inside pane 2 - user 2026-09-06) */
380.mchart-active-tag { display: none; font-size: 9px; font-weight: 800; letter-spacing: .6px; color: var(--t-bg,#0a0e17); background: var(--cyan); border-radius:calc(3px*var(--t-rk,1)); padding: 1px 5px; }
381.mchart-focused .mchart-active-tag { display: inline-block; }
382/* DESK in-chart head for chart 1 + the ticker dropdown (user 2026-09-22) */
383.gf-dhead{display:flex;align-items:center;gap:7px;padding:5px 8px;border-bottom:1px solid var(--border);background:var(--card);font-family:Segoe UI,monospace;overflow:hidden;white-space:nowrap;flex:0 0 auto;order:-1;height:30px;box-sizing:border-box;cursor:pointer}
384/* user 2026-09-22: "pull it down like chart 2" - chart 1 becomes a column: head bar, then the canvas; the two overlay
385   canvases and the floating toolbar sit below the bar so nothing is covered. Same column trick My HQ already uses. */
386body.gf-desk #chartContent{display:flex;flex-direction:column}
387body.gf-desk #chartContent > #tvChart{flex:1 1 0;min-height:0;height:auto!important}
388body.gf-desk #chartContent > #gexBubCanvas,body.gf-desk #chartContent > #drawOverlay{top:30px!important}
389body.gf-desk #gfHdrChartCtl:not(.gf-in-pane){top:36px}
390body.gf-desk #chartContent > .gf-pane-tb{top:36px}   /* chart 1 unfocused: its toolbar COPY also sits below the head bar (user 2026-09-22 screenshots) */
391.gf-dhead .mchart-name{font-size:13px;font-weight:800;color:var(--text);letter-spacing:.3px}
392.gf-dhead .mchart-tfl{font-size:10px;color:var(--dim)}
393body.gf-desk #chartTickerName,body.gf-desk #chartSpot,body.gf-desk #gfLvlMid{display:none!important}   /* chart 1 price too (user 2026-09-22) - it lives in the chart-1 head */   /* only the level PILLS leave the top line in desk mode - the expiry toggle (left) and regime/hide (right) stay (user 2026-09-22: "i dont see that expiry row") */
394.gf-tkdd-host{cursor:pointer;border-bottom:1px dotted var(--dim)}
395.gf-tkdd-host:hover{color:var(--cyan)!important}
396.gf-tkdd{position:fixed;z-index:10050;width:320px;overflow:auto;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:8px;box-shadow:0 12px 40px rgba(0,0,0,.6);font-family:Segoe UI,monospace}
397.gf-tkdd-q{width:100%;box-sizing:border-box;margin-bottom:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 8px;font-size:12px}
398.gf-tkdd-sec{font-size:10px;font-weight:800;letter-spacing:.8px;color:var(--dim);margin:6px 2px 3px;text-transform:uppercase}
399.gf-tkdd-grid{display:flex;flex-wrap:wrap;gap:4px}
400.gf-tkdd-it{padding:3px 8px;border:1px solid var(--border);border-radius:6px;font-size:12px;font-weight:700;color:var(--text);cursor:pointer;background:var(--bg)}
401.gf-tkdd-it:hover{border-color:var(--cyan);color:var(--cyan)}
402.gf-tkdd-it.on{background:var(--cyan);color:var(--t-bg,#0a0e17);border-color:var(--cyan)}
403body.gf-desk .mchart-lv b { font-weight: 700; }
404/* Both side panels belong on the row's OUTER edges. #ultraFlow is appended to #chartRow with no
405   order of its own, so without this it defaults to 0 and renders BETWEEN chart 1 and chart 2 —
406   including when minimized, which put its rail on the left with the GEX ladder's restore tab. */
407body.gf-desk #ultraFlow { order: 99; }
408body.gf-desk #chartTradePlan { order: 100; }
409body.gf-desk #chartTPRestoreBtn { display: none !important; }
410body.gf-desk #replayBtn { display: none !important; }   /* Replay is a single-chart tool (user 2026-09-06) */
411.gf-pane-tb .gf-badge:empty { display: none; }   /* Desk 2/3 = Ghost Mode, where the plan sits inside the Flow panel (user 2026-09-06) */
412body.gf-desk #chartContent { order: 0; }   /* pane order is set in _mBindSlot (slot 1, 2) */
413/* TV Sync pins ONE window to one ticker — meaningless with a multi-ticker desk (user request). */
414/* body.gf-desk #tvSyncBtn hidden rule removed 2026-09-22 - user wants TV Sync + the TV buttons in desk mode too */
415/* The desk is a wide layout — below this the charts are too narrow to read, so fall back to one. */
416@media (max-width: 900px) { body.gf-desk .mchart-pane { display: none !important; } }
417/* Minimize the GEX ladder to the left edge (collapse ↔ restore tab). */
418.gex-min-btn { position: absolute; top: 4px; right: 4px; z-index: 12; width: 18px; height: 18px; padding: 0; border: 1px solid var(--border); background: rgba(15,23,42,0.9); color: var(--t-dim,#94a3b8); border-radius:calc(4px*var(--t-rk,1)); cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; }
419.gex-min-btn:hover { color: var(--t-text,#e2e8f0); background: var(--t-border,#1e293b); }
420#gexRestoreTab { display: none; position: absolute; left: 0; top: 50%; transform: translateY(-50%); z-index: 6; width: 16px; height: 58px; padding: 0; background: rgba(15,23,42,0.92); color: var(--t-dim,#94a3b8); border: 1px solid var(--border); border-left: none; border-radius: 0 7px 7px 0; cursor: pointer; font-size: 14px; align-items: center; justify-content: center; box-shadow: 2px 0 8px rgba(0,0,0,.4); }
421#gexRestoreTab:hover { color: var(--t-text,#e2e8f0); background: var(--t-border,#1e293b); }
422/* ── PHONE layout (body.gf-phone, set by JS: coarse pointer + small screen) ──
423   The Ideas sub-views are two-column (list + fixed 340-430px right rail) — on a phone the rail
424   swallowed half the screen. Stack the rail BELOW the list, full width, one scroll surface.
425   !important is required: these fight inline styles. */
426body.gf-phone #ideasSetupWrap, body.gf-phone #watchlistWrap, body.gf-phone #ghostWrap, body.gf-phone #idxWrap { flex-direction: column !important; overflow-y: auto !important; }
427body.gf-phone #earningsWrap > div:nth-child(2) { flex-direction: column !important; overflow-y: auto !important; }
428body.gf-phone #ideasSetupWrap > div, body.gf-phone #watchlistWrap > div, body.gf-phone #ghostWrap > div, body.gf-phone #idxWrap > div, body.gf-phone #earningsWrap > div:nth-child(2) > div { width: 100% !important; max-width: none !important; flex: none !important; border-left: none !important; overflow: visible !important; }
429body.gf-gexmin #chartGexPanel { display: none !important; }
430body.gf-desk #chartRow > #chartGexPanel { position: relative; top: auto; left: auto; bottom: auto; flex: 0 0 auto; height: auto; order: -1; }   /* Desk 2/3: the ladder docks beside the charts instead of covering chart 1 (user 2026-09-06); its resize handle now resizes all charts */
431body.gf-gexmin #gexRestoreTab { display: flex; }
432/* Chart GEX panel: optional VOLUME column (1 expiry, call/put) + resizable width (user 2026-09-02) */
433.cgp-vol { display: none; align-items: center; justify-content: flex-end; gap: 5px; margin-left: 8px; padding-left: 8px; border-left: 1px solid rgba(var(--t-border-rgb,30,41,59),0.75); width: 84px; flex-shrink: 0; z-index: 2; }
434#chartGexPanel.gf-chartvol .cgp-vol { display: flex; }
435.cgp-vol-bar { display: inline-flex; align-items: center; height: 10px; }
436.cgp-vol-bar .cvb-c { display: inline-block; height: 10px; background: #34d399; border-radius: 0 2px 2px 0; }
437.cgp-vol-bar .cvb-p { display: inline-block; height: 10px; background: #f87171; border-radius: 2px 0 0 2px; }
438.cgp-vol-num { font-size: 9.5px; color: var(--t-cyan,#22d3ee); font-weight: 700; white-space: nowrap; min-width: 26px; text-align: right; }
439.cgp-volh { display: none; color: var(--t-cyan,#22d3ee); font-size: 10px; font-weight: 700; margin-left: 8px; }
440#chartGexPanel.gf-chartvol .cgp-volh { display: inline; }
441.cgp-resize { position: absolute; top: 0; right: -3px; width: 8px; bottom: 0; z-index: 14; cursor: ew-resize; }
442.cgp-resize:hover { background: linear-gradient(90deg, transparent, rgba(34,211,238,0.4)); }
443/* the right panel's own width grip - mirrored, so the gradient points the other way */
444.nf-wresize { position: absolute; top: 0; left: -3px; width: 8px; bottom: 0; z-index: 14; cursor: ew-resize; }
445.nf-wresize:hover, .nf-wresize.nf-dragging { background: linear-gradient(270deg, transparent, rgba(34,211,238,0.4)); }
446#ultraFlow.nf-panel-min > .nf-wresize { display: none; }   /* nothing to resize while it is a rail */
447/* Grid VOL column (user 2026-09-02): narrower than the chart ladder's; shown when the top-row VOL is on.
448   Extra left margin + a clearer divider so the volume bars don't crowd the gamma $ value (user 2026-09-03). */
449#columns .cgp-vol { width: 58px; margin-left: 16px; padding-left: 9px; border-left: 1px solid rgba(51,65,85,0.9); }
450#columns .gex-value { padding-right: 4px; }
451body.gf-gridvol #columns .cgp-vol { display: flex; }
452.cgp-head {
453  display: flex; align-items: center; justify-content: space-between;
454  padding: 6px 10px; border-bottom: 1px solid var(--border); background: var(--card); flex: 0 0 auto;
455}
456.cgp-tk { font-weight: 700; font-size: 13px; }
457.cgp-pr { font-family: 'Segoe UI', monospace; font-size: 12px; color: var(--green); font-weight: 600; }
458.cgp-body { flex: 1; overflow-y: auto; position: relative; touch-action: pan-y; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; cursor: grab; user-select: none; }
459.cgp-body.cgp-grabbing { cursor: grabbing; }
460.cgp-body::-webkit-scrollbar { width: 10px; }
461.cgp-body::-webkit-scrollbar-track { background: transparent; }
462.cgp-body::-webkit-scrollbar-thumb { background: var(--t-border3,#334155); border-radius:calc(5px*var(--t-rk,1)); }
463.cgp-body::-webkit-scrollbar-thumb:hover { background: var(--t-dim2,#475569); }
464/* GEX column strike lists — auto-hide scrollbar; reveal a slim black thumb with a grey
465   outline only when hovering or actively scrolling that specific ticker column. These
466   rules come after the global/cgp-body ones so they win on equal specificity. */
467.strikes-list::-webkit-scrollbar { width: 10px; }
468.strikes-list::-webkit-scrollbar-track { background: transparent; }
469.strikes-list::-webkit-scrollbar-thumb { background: transparent; border-radius:calc(5px*var(--t-rk,1)); }
470.strikes-list:hover::-webkit-scrollbar-thumb,
471.strikes-list.scrolling::-webkit-scrollbar-thumb { background: #000; border: 1px solid #555; background-clip: padding-box; }
472.strikes-list:hover::-webkit-scrollbar-thumb:hover { background: #1a1a1a; }
473/* Firefox (no hover-reveal of webkit thumb) — thin, dark thumb that appears on hover/scroll. */
474.strikes-list { scrollbar-width: thin; scrollbar-color: transparent transparent; }
475.strikes-list:hover, .strikes-list.scrolling { scrollbar-color: #000 transparent; }
476.strike-row {
477  display: flex; align-items: center; padding: 0 14px;
478  height: 28px; border-bottom: 1px solid rgba(var(--t-border-rgb,30,41,59),0.3);
479  position: relative; cursor: default;
480}
481.strike-row:hover { filter: brightness(1.15); }
482
483.strike-label {
484  font-family: 'Segoe UI', monospace; font-size: 12px;
485  width: 55px; flex-shrink: 0; z-index: 2; position: relative;
486  font-weight: 500;
487}
488.strike-label.spot { font-weight: 700; color: var(--purple); }
489.strike-label.king { font-weight: 700; color: var(--yellow); }
490
491/* GRID v2 (admin test): collapsible ticker strips - a thin fold handle on the
492   Grid / Heatmap / Ghost ticker lines, like the left/right panel collapses.
493   NOTE: the strip element itself gets position:relative INLINE from gfWireStripFold -
494   the main .expiry-tickers rule below declares position:static (iPad sticky fix) and
495   overrode a class rule here, which parked the absolute handle off-screen. */
496.gf-fold-btn { position: absolute; top: 4px; right: 8px; z-index: 30; width: 30px; height: 20px; border-radius:calc(6px*var(--t-rk,1)); border: 1px solid rgba(var(--t-accent-rgb,59,130,246),0.55); background: rgba(var(--t-border-rgb,30,41,59),0.95); color: var(--t-accentHi,#93c5fd); cursor: pointer; font-size: 12px; line-height: 1; padding: 0; }
497.gf-fold-btn:hover { border-color: var(--t-accent,#3b82f6); color: #dbeafe; background: rgba(var(--t-accent-rgb,59,130,246),0.25); }
498.gf-has-fold { padding-right: 46px !important; }
499@keyframes gvbuildpulse { 0%,100% { opacity: .45; } 50% { opacity: 1; } }
500.gv-pulse { animation: gvbuildpulse 1.5s infinite; }
501.gexp-btn.holding { background: rgba(239,68,68,.4) !important; border-color: rgba(239,68,68,.9) !important; color: #fff !important; }
502/* Folded strip = one 30px line, chips centred, scrolls sideways (wheel), like the Ghost Mode strip (user 2026-09-05: folding
503   used to blank the whole row and leave only the marker). Unfold = the full wrapped rows again. */
504.gf-strip-closed { flex-wrap: nowrap !important; overflow-x: auto !important; overflow-y: hidden !important; height: 30px !important; max-height: 30px !important; min-height: 30px; align-items: center !important; gap: 4px !important; padding-top: 0 !important; padding-bottom: 0 !important; scrollbar-width: none; }
505.gf-strip-closed::-webkit-scrollbar { display: none; }
506.gf-strip-closed .exp-ticker-btn { height: 20px; padding: 0 7px; line-height: 1; display: inline-flex; align-items: center; font-size: 10px; box-sizing: border-box; flex: 0 0 auto; }
507.gf-strip-closed .exp-ticker-btn:focus, .gf-strip-closed .exp-ticker-btn:focus-visible { outline: none; box-shadow: none; }
508.gf-strip-closed > span, .gf-strip-closed > b, .gf-strip-closed > div:not(.gf-fold-btn) { flex: 0 0 auto; white-space: nowrap; }
509.gf-strip-closed .gf-fold-btn { position: sticky; right: 0; top: auto; margin-left: auto; flex: 0 0 auto; z-index: 31; box-shadow: -14px 0 12px var(--bg); }
510.gf-strip-closed.gf-has-fold { padding-right: 0 !important; }   /* folded: no reserved gap - the sticky arrow sits on the edge and chips scroll under its fade */   /* folded strip scrolls sideways: 
510sticky keeps the arrow at the strip edge instead of riding over the chips (user 2026-09-06: arrow near the Defense chips) */
511/* .col-ticker select (13px bold) outranks a bare class - these must be MORE specific */
512.col-ticker select.col-cfg { font-size: 8.5px; font-weight: 600; background: rgba(var(--t-border-rgb,30,41,59),.45); color: var(--t-muted,#cbd5e1); border: 1px solid rgba(var(--t-border-rgb,30,41,59),.9); border-radius:calc(3px*var(--t-rk,1)); padding: 1px 1px; cursor: pointer; }
513.col-ticker select.col-cfg:hover { color: var(--t-text,#e2e8f0); border-color: var(--t-accent,#3b82f6); background: rgba(var(--t-border-rgb,30,41,59),.85); }
514
515/* GEX bar */
516.gex-bar-container {
517  flex: 1; min-width: 0; position: relative; height: 20px; overflow: hidden;
518}
519.gex-bar {
520  position: absolute; top: 0; height: 100%; border-radius:calc(3px*var(--t-rk,1));
521  transition: width 0.3s;
522}
523.gex-bar.positive { left: 50%; background: linear-gradient(90deg, rgba(34,197,94,0.15), rgba(34,197,94,0.6)); }
524.gex-bar.negative { right: 50%; background: linear-gradient(270deg, rgba(239,68,68,0.15), rgba(239,68,68,0.6)); }
525.gex-bar.king-bar { background: linear-gradient(90deg, rgba(234,179,8,0.2), rgba(234,179,8,0.7)) !important; }
526.gex-bar.star { box-shadow: 0 0 8px rgba(234,179,8,0.4); }
527
528/* Percentage badge on bar */
529.gex-pct {
530  position: absolute; top: 50%; transform: translateY(-50%);
531  font-family: 'Segoe UI', monospace; font-size: 9px; font-weight: 700;
532  z-index: 2; padding: 1px 5px; border-radius:calc(3px*var(--t-rk,1));
533  background: rgba(8,10,16,0.78); color: var(--text); text-shadow: 0 1px 2px rgba(0,0,0,0.95);
534}
535.gex-pct.right { left: min(52%, calc(100% - 44px)); white-space: nowrap; }   /* clamp inside the clipping bar box: narrow columns (VOL on / 7-wide) were cutting the % sign */
536.gex-pct.left { right: min(52%, calc(100% - 44px)); text-align: right; white-space: nowrap; }   /* same clamp on the put side: the minus and first digit were hidden */
537
538/* Value label */
539.gex-value {
540  font-family: 'Segoe UI', monospace; font-size: 11px;
541  width: auto; min-width: 64px; padding-left: 6px; text-align: right; flex-shrink: 0; z-index: 2; position: relative;
542}
543.gex-value.pos { color: var(--green); }
544.gex-value.neg { color: var(--red); }
545.gex-value.star-val { color: var(--yellow); }
546
547/* Star indicator for top GEX */
548.star-icon { color: var(--yellow); font-size: 10px; margin-left: 2px; }
549
550/* Add column button */
551.add-col {
552  min-width: 60px; display: flex; align-items: center; justify-content: center;
553  cursor: pointer; color: var(--dim); font-size: 24px; border: none; background: none;
554}
555.add-col:hover { color: var(--cyan); }
556
557/* View toggle */
558.view-toggle {
559  padding: 5px 12px; background: var(--card); border: 1px solid var(--cyan);
560  border-radius:calc(6px*var(--t-rk,1)); color: var(--cyan); font-size: 11px; cursor: pointer;
561  font-family: 'Segoe UI', monospace; font-weight: 600;
562}
563.view-toggle:hover { background: rgba(var(--t-cyan-rgb,6,182,212),0.1); }
564.view-toggle.active { background: var(--cyan); color: var(--bg); }
565
566/* GEX / VEX metric toggle */
567.metric-toggle { display: inline-flex; border: 1px solid var(--border); border-radius:calc(6px*var(--t-rk,1)); overflow: hidden; }
568.metric-btn {
569  padding: 5px 12px; border: none; background: var(--card); color: var(--dim);
570  font-size: 11px; font-weight: 700; font-family: 'Segoe UI', monospace; cursor: pointer;
571}
572.metric-btn:hover:not(.active) { color: var(--text); }
573.metric-btn.active { background: var(--blue); color: var(--t-onAccent,#fff); }
574.metric-btn.locked { opacity: 0.6; }
575.metric-btn.locked::before { content: '🔒'; font-size: 8px; margin-right: 2px; }
576/* GEX grid expiry-basis toggle (ALL / 0DTE / WEEKLY) — its own class so switchMetric's
577   .metric-btn active-state sweep doesn't clear it. */
578.gexp-toggle { display: inline-flex; border: 1px solid var(--border); border-radius:calc(6px*var(--t-rk,1)); overflow: hidden; }
579.gexp-btn { padding: 4px 11px; border: none; background: var(--card); color: var(--dim); font-size: 10px; font-weight: 700; font-family: 'Segoe UI', monospace; cursor: pointer; letter-spacing: 0.4px; }
580.gexp-btn:hover:not(.active) { color: var(--text); }
581.gexp-btn.active { background: var(--blue); color: var(--t-onAccent,#fff); }
582/* FLOW tab (MVP) */
583.flow-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 16px; border-bottom:1px solid var(--border); background:var(--card); flex-wrap:wrap; flex-shrink:0; }
584.flow-presets { display:flex; gap:6px; flex-wrap:wrap; }
585.flow-chip { padding:5px 12px; border:1px solid var(--border); border-radius:calc(6px*var(--t-rk,1)); background:var(--bg); color:var(--dim); font-size:11px; font-weight:700; font-family:'Segoe UI',monospace; cursor:pointer; }
586.flow-chip:hover { color:var(--text); }
587.flow-chip.active { background:var(--blue); color:var(--t-onAccent,#fff); border-color:var(--blue); }
588.flow-controls { display:flex; align-items:center; gap:10px; font-size:11px; color:var(--dim); flex-wrap:wrap; }
589.flow-controls input { width:110px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:calc(5px*var(--t-rk,1)); padding:4px 6px; font-size:11px; }
590.flow-tape { flex:1; overflow:auto; padding:0; }
591.flow-stat { font-size:10px; color:var(--t-dim,#8b9cb5); white-space:nowrap; }
592.flow-locked { display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:70vh; text-align:center; padding:40px; gap:14px; }
593.fl-icon { font-size:46px; }
594.fl-t { font-size:23px; font-weight:800; color:var(--t-text,#e2e8f0); }
595.fl-d { font-size:14px; color:var(--t-dim,#94a3b8); max-width:470px; line-height:1.55; }
596.fl-cta { margin-top:8px; background:var(--blue); color:var(--t-onAccent,#fff); padding:12px 26px; border-radius:calc(8px*var(--t-rk,1)); font-weight:800; text-decoration:none; }
597.fl-cta:hover { background:var(--t-accent2,#2563eb); }
598/* Flow data table (columns + rows, UW-style) */
599.flow-tbl { width:100%; border-collapse:collapse; font-family:'Segoe UI',monospace; font-size:12px; }
600.flow-tbl thead th { position:sticky; top:0; z-index:2; background:#0e1626; color:var(--dim); font-size:10px; font-weight:800; letter-spacing:0.3px; text-align:left; padding:6px 7px; border-bottom:1px solid var(--border); white-space:nowrap; }
601.flow-tbl th.num, .flow-tbl td.num { text-align:right; }
602.flow-tbl td { padding:4px 7px; border-bottom:1px solid rgba(255,255,255,0.04); white-space:nowrap; color:var(--t-muted,#cbd5e1); }
603.flow-tbl tbody tr:hover td { background:rgba(var(--t-accent-rgb,59,130,246),0.06); }
604.flow-tr.flow-whale td { background:rgba(234,179,8,0.05); }
605.flow-tr.flow-whale .flow-prem { color:#fde047; }
606.flow-prem { color:var(--t-text,#e2e8f0); font-weight:800; }
607.flow-tm { color:var(--t-dim,#8b9cb5); font-size:11px; }
608.flow-dte { color:var(--t-dim,#8b9cb5); font-size:10px; }
609.flow-cp.c { color:#22c55e; } .flow-cp.p { color:#ef4444; } .flow-cp { font-weight:800; }
610.flow-contract { white-space:nowrap; }
611.flow-contract .flow-c-strk { color:var(--t-muted,#cbd5e1); font-weight:700; }
612.flow-contract .flow-cp { margin:0 3px 0 1px; }
613.flow-contract .flow-c-exp { color:var(--t-dim,#94a3b8); }
614.flow-hits { background:#1e3a5f; color:var(--t-accentHi,#93c5fd); border-radius:calc(3px*var(--t-rk,1)); padding:0 4px; font-size:10px; font-weight:800; margin-left:3px; }
615.flow-hits.hot { background:#7c2d12; color:#fdba74; }
616.flow-side.buy { color:#22c55e; } .flow-side.sell { color:#ef4444; } .flow-side { font-weight:700; }
617.flow-hi { color:#fbbf24; font-weight:700; }
618/* UW-style bid/ask line: % on the left, then a red(bid)/green(ask) split bar */
619.flow-bacell { white-space:nowrap; }
620.flow-ba { position:relative; display:inline-flex; width:96px; height:18px; border-radius:calc(3px*var(--t-rk,1)); overflow:hidden; vertical-align:middle; }
621.flow-ba i { display:block; height:100%; }
622.flow-ba i.a { background:#16a34a; }
623.flow-ba i.b { background:#dc2626; }
624.flow-bapct { position:absolute; top:0; height:100%; display:flex; align-items:center; font-size:11px; font-weight:800; color:#000; padding:0 7px; }
625.flow-bapct.r { right:0; }   /* ask-dominant → show on the green (ask) side */
626.flow-bapct.l { left:0; }    /* bid-dominant → show on the red (bid) side */
627/* resizable right-panel grip */
628.flow-right-grip { position:absolute; left:0; top:0; bottom:0; width:8px; cursor:col-resize; z-index:10; background:rgba(var(--t-accent-rgb,59,130,246),0.12); }
629.flow-right-grip:hover { background:rgba(var(--t-accent-rgb,59,130,246),0.5); }
630/* MM exposure histogram + contract history */
631.flow-mme-strk { font-size:9px; fill:var(--t-dim,#94a3b8); }
632.flow-mme-nodes { display:flex; flex-wrap:wrap; gap:4px 12px; font-size:11px; font-weight:700; margin:4px 0 8px; }
633.flow-hist { width:100%; border-collapse:collapse; font-size:10px; margin-top:8px; }
634.flow-hist th { color:var(--t-dim,#8b9cb5); font-weight:700; text-align:right; padding:3px 5px; border-bottom:1px solid var(--border); }
635.flow-hist th:first-child { text-align:left; }
636.flow-hist td { padding:3px 5px; text-align:right; border-bottom:1px solid rgba(255,255,255,0.04); color:var(--t-muted,#cbd5e1); }
637.flow-hist td:first-child { text-align:left; color:var(--t-dim,#94a3b8); }
638/* row ticker: bright, no box */
639.flow-row-tkr { color:var(--t-accentHi,#38bdf8); font-weight:800; letter-spacing:0.4px; cursor:pointer; }
640.flow-row-tkr:hover { color:#7dd3fc; text-decoration:underline; }
641.flow-0dte { color:#fbbf24; font-weight:800; }
642.flow-tr.flow-sel td { background:rgba(var(--t-accent-rgb,59,130,246),0.16) !important; }
643/* right panel */
644.flow-right { position:relative; width:300px; flex-shrink:0; border-left:1px solid var(--border); background:var(--card); display:flex; flex-direction:column; overflow:hidden; }
645.flow-right-head { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border); }
646.flow-right-tabs { display:flex; }
647.flow-rtab { padding:9px 11px; border:none; background:none; color:var(--dim); font-size:11px; font-weight:700; font-family:'Segoe UI',monospace; cursor:pointer; border-bottom:2px solid transparent; }
648.flow-rtab:hover { color:var(--text); }
649.flow-rtab.active { color:var(--t-text,#e2e8f0); border-bottom-color:var(--blue); }
650.flow-right-x { background:none; border:none; color:var(--dim); font-size:18px; cursor:pointer; padding:0 10px; }
651.flow-right-title { font-size:12px; font-weight:800; color:var(--t-dim,#94a3b8); letter-spacing:0.5px; padding:9px 12px; }
652.flow-sec-h { font-size:10px; font-weight:800; letter-spacing:0.6px; color:var(--t-dim,#8b9cb5); margin:18px 0 4px; border-top:1px solid var(--border); padding-top:12px; }
653.flow-secbox { min-height:20px; }
654.flow-right-body { flex:1; overflow-y:auto; padding:14px; }
655.flow-rempty { color:var(--dim); font-size:12px; padding:24px 0; text-align:center; }
656.flow-clhead { font-size:16px; font-weight:800; color:var(--t-text,#e2e8f0); margin-bottom:4px; }
657.flow-cltk { color:var(--t-accentHi,#38bdf8); }
658.flow-clsub { font-size:11px; color:var(--t-dim,#94a3b8); margin-bottom:12px; }
659.flow-cldet1 { font-size:13px; font-weight:700; color:var(--t-text,#e2e8f0); margin:7px 0 14px; line-height:1.55; }
660.flow-clgrid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
661.flow-kv { background:var(--bg); border:1px solid var(--border); border-radius:calc(7px*var(--t-rk,1)); padding:7px 9px; }
662.flow-kv span { display:block; font-size:9px; color:var(--t-dim,#8b9cb5); font-weight:700; letter-spacing:0.4px; }
663.flow-kv b { font-size:14px; color:var(--t-text,#e2e8f0); }
664.flow-clsec { margin-top:12px; font-size:12px; color:var(--t-dim,#94a3b8); line-height:1.5; }
665.flow-cluw { display:inline-block; margin-top:14px; color:var(--t-accent,#3b82f6); font-size:12px; font-weight:700; text-decoration:none; }
666.flow-tide-bar { display:flex; height:20px; border-radius:calc(5px*var(--t-rk,1)); overflow:hidden; margin:12px 0 6px; }
667.flow-tide-bar i.c { background:#22c55e; } .flow-tide-bar i.p { background:#ef4444; }
668.flow-tide-lbl { display:flex; justify-content:space-between; font-size:11px; font-weight:700; }
669.flow-tag { font-size:9px; font-weight:800; padding:1px 6px; border-radius:calc(4px*var(--t-rk,1)); }
670.flow-tag.sweep { background:rgba(234,179,8,0.16); color:#fde047; }
671.flow-tag.block { background:rgba(var(--t-dim-rgb,148,163,184),0.12); color:var(--t-dim,#94a3b8); }
672.flow-tag.floor { background:rgba(168,85,247,0.16); color:#c4b5fd; }
673.flow-trk { background:none; border:1px solid var(--t-border2,#26364a); color:var(--dim); border-radius:calc(5px*var(--t-rk,1)); padding:2px 8px; font-size:10px; font-weight:700; cursor:pointer; }
674.flow-trk:hover { border-color:var(--blue); color:var(--t-text,#e2e8f0); }
675.flow-uw { color:var(--t-accent,#3b82f6); font-size:10px; text-decoration:none; margin-left:6px; }
676.flow-th { cursor:pointer; user-select:none; }
677.flow-th:hover { color:var(--t-text,#e2e8f0); }
678.flow-th.flow-sorted { color:var(--t-accentHi,#38bdf8); }
679/* ticker line */
680.flow-tickers { display:flex; flex-wrap:wrap; align-items:center; gap:4px; padding:6px 12px; border-bottom:1px solid var(--border); background:var(--card); max-height:66px; overflow-y:auto; flex-shrink:0; }
681.flow-tk { padding:2px 8px; border-radius:calc(5px*var(--t-rk,1)); font-size:11px; font-weight:700; font-family:'Segoe UI',monospace; color:var(--t-dim,#94a3b8); cursor:pointer; border:1px solid transparent; }
682.flow-tk:hover { color:var(--t-text,#e2e8f0); background:rgba(var(--t-accent-rgb,59,130,246),0.12); }
683.flow-tk.active { background:var(--blue); color:var(--t-onAccent,#fff); }
684.flow-tk-all { border-color:var(--t-border2,#26364a); }
685.flow-tk-sec { font-size:9px; font-weight:800; letter-spacing:0.5px; color:var(--t-dim2,#475569); padding:0 4px 0 8px; text-transform:uppercase; }
686/* main split: left filters + table */
687.flow-main { flex:1; display:flex; min-height:0; overflow:hidden; }
688.flow-filters { width:210px; flex-shrink:0; border-right:1px solid var(--border); background:var(--card); overflow-y:auto; padding:10px 12px 20px; }
689.flow-fgrp { margin-bottom:14px; }
690.flow-fh { font-size:10px; font-weight:800; letter-spacing:0.5px; color:var(--t-dim,#8b9cb5); margin-bottom:7px; }
691.flow-fseg { display:flex; gap:4px; flex-wrap:wrap; }
692.flow-fbtn { flex:1; min-width:0; padding:5px 6px; border:1px solid var(--border); border-radius:calc(5px*var(--t-rk,1)); background:var(--bg); color:var(--dim); font-size:10px; font-weight:700; font-family:'Segoe UI',monospace; cursor:pointer; white-space:nowrap; }
693.flow-fbtn:hover { color:var(--text); }
694.flow-fbtn.active { background:var(--blue); color:var(--t-onAccent,#fff); border-color:var(--blue); }
695.flow-chk { display:flex; align-items:center; gap:7px; font-size:12px; color:var(--t-muted,#cbd5e1); padding:3px 0; cursor:pointer; }
696.flow-chk input { accent-color:var(--blue); }
697.flow-nl { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:11px; color:var(--t-dim,#94a3b8); margin-bottom:6px; }
698.flow-nl input { width:96px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:calc(5px*var(--t-rk,1)); padding:4px 6px; font-size:11px; }
699.flow-datein { cursor:pointer; }
700.flow-datein::-webkit-calendar-picker-indicator { filter:invert(0.8); opacity:0.9; cursor:pointer; }
701.flow-datein::-webkit-datetime-edit { cursor:pointer; }
702.flow-freset { width:100%; padding:7px; border:1px solid var(--t-border2,#26364a); border-radius:calc(6px*var(--t-rk,1)); background:none; color:var(--t-dim,#94a3b8); font-size:11px; font-weight:700; cursor:pointer; }
703.flow-freset:hover { color:var(--t-text,#e2e8f0); border-color:var(--blue); }
704.flow-tkr-chip { cursor:pointer; background:rgba(var(--t-accent-rgb,59,130,246),0.16); color:var(--t-accentHi,#93c5fd); border:1px solid rgba(var(--t-accent-rgb,59,130,246),0.32); border-radius:calc(4px*var(--t-rk,1)); padding:1px 7px; font-weight:800; font-size:11px; letter-spacing:0.4px; }
705.flow-tkr-chip:hover { background:rgba(var(--t-accent-rgb,59,130,246),0.32); color:#fff; }
706
707/* Expiry view */
708.expiry-tickers {
709  padding: 6px 20px; display: flex; gap: 4px; flex-wrap: wrap;
710  border-bottom: 1px solid var(--border); background: var(--card);
711  /* Static, NOT sticky: this row sits at the top of a fixed-height view whose inner content
712     scrolls — making it sticky made it collide with the page header on iPad (covering the
713     first ticker / shoving the stock-name+nodes header around). */
714  position: static; flex-shrink: 0;
715  max-height: 58px; overflow-y: auto;
716}
717.exp-ticker-btn {
718  padding: 3px 8px; border-radius:calc(4px*var(--t-rk,1));
718 cursor: pointer;
719  font-size: 10px; font-weight: 600; font-family: 'Segoe UI', monospace;
720  background: var(--bg); color: var(--t-dim,#94a3b8); border: 1px solid var(--border);
721  transition: all 0.15s; flex-shrink: 0;
722}
723.exp-ticker-btn.active { background: rgba(var(--t-cyan-rgb,6,182,212),0.1); color: var(--cyan); border-color: var(--cyan); }
724.exp-ticker-btn:hover:not(.active) { color: var(--text); border-color: var(--dim); }
725/* Sector grouping — click a sector to highlight its ticker list */
726.exp-sector-group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; padding: 3px 6px; border-radius:calc(6px*var(--t-rk,1)); transition: background 0.15s; }
727.exp-sector-group.active { background: rgba(var(--t-cyan-rgb,6,182,212),0.12); box-shadow: inset 0 0 0 1px rgba(var(--t-cyan-rgb,6,182,212),0.25); }
728.exp-sector-label { font-size: 9px; color: var(--dim); font-family: 'Segoe UI', monospace; font-weight: 600; letter-spacing: 0.5px; padding: 3px 2px; cursor: pointer; user-select: none; }
729.exp-sector-group.active .exp-sector-label { color: var(--cyan); }
730.exp-sector-label:hover { color: var(--text); }
731
732/* Expiry heatmap table — fill the space left under the ticker row + info bar (flex, not a
733   hardcoded height) so it's correct whatever the header height is (e.g. taller on iPad). */
734.expiry-content { padding: 4px 20px; overflow-x: auto; overflow-y: auto; flex: 1 1 auto; min-height: 0; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
735
736/* Expiry info bar */
737.expiry-info {
738  padding: 8px 20px;
739  border-bottom: 1px solid var(--border);
740  background: var(--card);
741  font-family: 'Segoe UI', monospace;
742  display: flex; align-items: center; gap: 20px;
743  min-height: 36px;
744  
745}
746.expiry-header {
747  display: flex; align-items: center; gap: 12px; margin-bottom: 6px;
748  font-family: 'Segoe UI', monospace;
749}
750.expiry-header .ticker-name { font-size: 20px; font-weight: 700; }
751.expiry-header .spot-price { font-size: 14px; color: var(--dim); }
752.expiry-header .bias-badge {
753  font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius:calc(4px*var(--t-rk,1));
754}
755
756.exp-table { width: 100%; border-collapse: collapse; font-family: 'Segoe UI', monospace; font-size: 12px; }
757.exp-table th {
758  padding: 10px 8px; text-align: center; color: var(--t-muted,#cbd5e1);
759  font-size: 12px; letter-spacing: 0.5px; font-weight: 700;
760  border-bottom: 2px solid var(--border); position: sticky; top: 0;
761  background: var(--bg); z-index: 5;
762  border-right: 1px solid var(--border);
763}
764/* Pinned on BOTH axes: the cells below already stick to left:0, so without this the STRIKE
765   header alone slid away as soon as the table became wide enough to scroll sideways. */
766.exp-table th:first-child { text-align: left; min-width: 45px; width: 45px; padding-left: 4px; position: sticky; left: 0; z-index: 6; }
767.exp-table th:last-child { border-right: none; }
768.exp-table td {
769  padding: 4px 6px; text-align: center; border-bottom: 1px solid rgba(var(--t-border-rgb,30,41,59),0.3);
770  font-size: 11px; font-weight: 500;
771  border-right: 1px solid rgba(var(--t-border-rgb,30,41,59),0.4);
772}
773.exp-table td:last-child { border-right: none; }
774.exp-table td:first-child {
775  text-align: left; font-weight: 500; min-width: 45px; width: 45px; padding-left: 4px;
776  position: sticky; left: 0; background: var(--bg); z-index: 3;
777  border-right: 1px solid var(--border);
778}
779/* Expiry columns need a floor width or the table (width:100%) just squashes them to fit and
780   never overflows - which meant no horizontal scrollbar and unreadable cells once the render
781   cap went from 8 to 14. A header reads 'Fri 08/28', so 76px is about the narrowest that stays
782   legible. The strike column is already position:sticky/left:0, so it stays pinned while the
783   expiry columns scroll under it. */
784.exp-table th:not(:first-child), .exp-table td:not(:first-child) { min-width: 76px; }
785/* Same horizontal scrollbar as the GEX grid, so both views scroll the same way. */
786.expiry-content::-webkit-scrollbar { height: 12px; }
787.expiry-content::-webkit-scrollbar-track { background: var(--card); }
788.expiry-content::-webkit-scrollbar-thumb { background: var(--t-border3,#334155); border-radius:calc(6px*var(--t-rk,1)); }
789.expiry-content::-webkit-scrollbar-thumb:hover { background: var(--t-dim2,#475569); }
790.exp-table tr.spot-row td { border-top: 2px solid #fff; border-bottom: 2px solid #fff; background: rgba(255,255,255,0.15); }
791/* Charm Flip row boundary (CHARM metric): the flip sits just BELOW this row — dashed seam across
792   the whole grid. !important so it wins over the per-cell inline border-bottom:none. */
793.exp-table tr.ch-flip-row td { border-bottom: 2px dashed rgba(226,232,240,.85) !important; }
794.exp-table tr.spot-row td:first-child { color: #fff; font-weight: 700; background: rgba(var(--t-accent-rgb,59,130,246),0.25); border-left: 3px solid var(--cyan); }
795.exp-cell {
796  display: inline-block; width: 100%; padding: 3px 4px; border-radius:calc(3px*var(--t-rk,1));
797  font-weight: 600;
798}
799
800/* Loading — pulse so it reads as "working", not frozen */
801.col-loading {
802  flex: 1; display: flex; align-items: center; justify-content: center;
803  color: var(--dim); font-family: 'Segoe UI', monospace; font-size: 12px;
804  animation: pulse 1.6s ease-in-out infinite;
805}
806.col-error { color: var(--red); animation: none; }
807
808/* Keyboard focus — visible ring on inputs/selects (outline:none used to hide it) */
809input:focus, select:focus { border-color: var(--cyan) !important; box-shadow: 0 0 0 2px rgba(var(--t-accent-rgb,59,130,246),0.18); outline: none; }
810
811/* Toast — small transient notices (e.g. TradingView ticker sync) */
812#gfToast {
813  position: fixed; top: 64px; right: 16px; z-index: 500;
814  background: rgba(17,24,39,0.96); border: 1px solid rgba(var(--t-accent-rgb,59,130,246),0.5); border-radius:calc(8px*var(--t-rk,1));
815  color: var(--t-text,#e2e8f0); font-family: 'Segoe UI', monospace; font-size: 12px; font-weight: 600;
816  padding: 8px 14px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
817  opacity: 0; transform: translateY(-6px); transition: opacity .2s, transform .2s; pointer-events: none;
818}
819#gfToast.show { opacity: 1; transform: translateY(0); }
820
821/* Market status badge in the header */
822#mktBadge {
823  font-family: 'Segoe UI', monospace; font-size: 10px; font-weight: 700;
824  padding: 3px 8px; border-radius:calc(10px*var(--t-rk,1)); margin-left: 10px; letter-spacing: 0.5px;
825  background: rgba(255,255,255,0.04); border: 1px solid var(--border); color: var(--dim);
826  white-space: nowrap;
827}
828
829/* Resize handles */
830.resize-handle {
831  width: 5px;
832  cursor: col-resize;
833  background: var(--border);
834  flex-shrink: 0;
835  transition: background 0.15s;
836}
837.resize-handle:hover, .resize-handle.active {
838  background: var(--cyan);
839}
840
841/* Drawing tools */
842.draw-btn {
843  width: 32px; height: 32px; border: 1px solid var(--border); border-radius:calc(4px*var(--t-rk,1));
844  background: var(--bg); color: var(--dim); cursor: pointer; font-size: 14px;
845  display: flex; align-items: center; justify-content: center;
846}
847.draw-btn:hover { border-color: var(--cyan); color: var(--text); }
848.draw-btn.active { background: rgba(var(--t-cyan-rgb,6,182,212),0.15); border-color: var(--cyan); color: var(--cyan); }
849#drawToolMain.active { background: rgba(var(--t-cyan-rgb,6,182,212),0.15); border-color: var(--cyan); color: var(--cyan); }
850.draw-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; background: none; border: none; color: var(--dim); font-family: inherit; font-size: 12px; padding: 6px 10px; border-radius:calc(5px*var(--t-rk,1)); cursor: pointer; white-space: nowrap; }
851.draw-menu-item:hover { background: rgba(var(--t-cyan-rgb,6,182,212),0.12); color: var(--cyan); }
852.draw-menu-item.active { color: var(--cyan); background: rgba(var(--t-cyan-rgb,6,182,212),0.08); }
853.draw-menu-item .dmi-ic { width: 16px; text-align: center; font-size: 13px; flex: none; }
854.draw-swatch, .fib-zone-swatch { width: 15px; height: 15px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; box-shadow: 0 0 0 1px rgba(255,255,255,0.15); box-sizing: border-box; }
855.draw-swatch:hover, .fib-zone-swatch:hover { transform: scale(1.15); }
856.draw-swatch.active, .fib-zone-swatch.active { border-color: var(--cyan); box-shadow: 0 0 0 1px var(--cyan); }
857.node-feed-row:hover { background: rgba(var(--t-cyan-rgb,6,182,212),0.10); }
858
859/* Timeframe buttons */
860.tf-btn {
861  padding: 3px 8px; border: 1px solid var(--border); border-radius:calc(4px*var(--t-rk,1));
862  background: var(--bg); color: var(--t-dim,#94a3b8); cursor: pointer;
863  font-size: 11px; font-weight: 600; font-family: 'Segoe UI', monospace;
864}
865.tf-btn:hover { border-color: var(--cyan); color: var(--text); }
866.tf-btn.active { background: rgba(var(--t-cyan-rgb,6,182,212),0.15); border-color: var(--cyan); color: var(--cyan); }
867/* ── Ghost Mode header v2 (admin, 2026-09-05): two rows, five groups, one control language ── */
868.gf-hdr2 #chartHeader{flex-wrap:wrap;padding:6px 12px 0;gap:0;align-items:center;row-gap:0}
869.gf-hdr2 .gf-hgrp{display:flex;align-items:center;gap:4px;padding:0 10px;border-left:1px solid var(--border);min-height:28px;margin-bottom:6px}
870.gf-hdr2 .gf-hgrp:first-child{border-left:0;padding-left:0}
871.gf-hdr2 .gf-hpush{margin-left:auto;border-left:0;padding-right:0}
872.gf-hdr2 .gf-hlbl{font-size:9.5px;letter-spacing:1px;color:var(--t-muted,#cbd5e1);font-weight:800;margin-right:4px;white-space:nowrap}
873.gf-hdr2 #chartHeader .tf-btn{height:26px;padding:0 10px;border-radius:calc(6px*var(--t-rk,1));font-size:11px;font-weight:700;color:var(--t-muted,#b7c2d0);display:inline-flex;align-items:center;gap:5px;background:transparent}
874.gf-hdr2 #chartHeader .tf-btn.active{background:rgba(34,211,238,.14);color:var(--cyan);border-color:var(--cyan)}
875.gf-hdr2 #tfButtons{gap:0!important;border:1px solid var(--border);border-radius:calc(6px*var(--t-rk,1));overflow:hidden;margin-left:0!important}
876.gf-hdr2 #tfButtons .tf-btn{border:0;border-radius:0;border-right:1px solid var(--border);height:24px;padding:0 9px}
877.gf-hdr2 #tfButtons .tf-btn:last-child{border-right:0}
878.gf-hdr2 #tfButtons .tf-btn.active{background:var(--cyan);color:var(--t-onAccent,#06222b)}
879.gf-hdr2 #mchartToggle{margin-left:0!important;border-left:0!important;padding-left:0!important;gap:0!important;border:1px solid var(--border);border-radius:calc(6px*var(--t-rk,1));overflow:hidden}
880.gf-hdr2 #mchartToggle > span:first-child{display:none}
881.gf-hdr2 #mchartToggle .tf-btn{border:0;border-radius:0;border-right:1px solid var(--border);height:24px;padding:0 9px}
882.gf-hdr2 #mchartToggle .tf-btn:last-child{border-right:0}
883.gf-hdr2 #mchartToggle .tf-btn.active{background:var(--cyan);color:var(--t-onAccent,#06222b)}
884.gf-hdr2 #drawTools,.gf-hdr2 #replayBtn,.gf-hdr2 #tvLevelsBtn,.gf-hdr2 #indicatorWrap{margin-left:0!important;border-left:0!important;padding-left:0!important}
885.gf-hdr2 #replayBtn,.gf-hdr2 #tvLevelsBtn{padding-left:10px!important}
886.gf-hdr2 #chartTickerName{font-size:15px;font-weight:800;color:var(--t-text,#e2e8f0)}
887.gf-hdr2 #chartSpot{color:var(--t-text,#e2e8f0)!important;font-weight:700;margin-left:6px;font-size:12px!important}
888.gf-hdr2 .gf-badge{background:var(--cyan);color:var(--t-onAccent,#06222b);border-radius:calc(9px*var(--t-rk,1));padding:0 5px;font-size:9.5px;font-weight:800;line-height:14px}
889.gf-hdr2 .gf-badge:empty{display:none}
890.gf-hdr2 #gfGridLayerRow .tf-btn{height:24px;padding:0 10px;border-radius:calc(6px*var(--t-rk,1));font-size:11px;font-weight:700}
891.gf-hdr2 .gf-hrow2{flex-basis:100%;order:99;display:grid;grid-template-columns:var(--gf-ladder-w,290px) minmax(0,1fr) auto;align-items:center;gap:8px;margin:0 -12px;padding:4px 12px;border-top:1px solid var(--border);background:#0f1626;min-height:30px}
892.gf-hdr2 #gfLvlLeft{display:flex;align-items:center;gap:6px;min-width:0}
893.gf-hdr2 #gfLvlMid{display:flex;justify-content:center;min-width:0}
894.gf-hdr2 #gfLvlRight{display:flex;align-items:center;justify-content:flex-end;
894gap:6px;flex-wrap:nowrap}
895.gf-hdr2 #gfLvlRight > span:not(:empty){display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 9px;border-radius:calc(5px*var(--t-rk,1));border:1px solid var(--border);background:rgba(var(--t-dim-rgb,148,163,184),.07);font-size:11px!important;font-weight:700;white-space:nowrap}
896.gf-hdr2 #gfLvlRight > span:empty{display:none}
897.gf-hdr2 .gf-hrow2 #chartHeaderChips{margin-left:0!important;gap:6px!important;font-size:11px!important;flex-wrap:wrap;justify-content:center}
898.gf-hdr2 .gf-hrow2 #chartHeaderChips > span:not(:empty){display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 9px;border-radius:calc(5px*var(--t-rk,1));border:1px solid var(--border);background:rgba(var(--t-dim-rgb,148,163,184),.07);font-size:11px!important;font-weight:700;white-space:nowrap}
899.gf-hdr2 .gf-hrow2 #chartHeaderChips > span:empty{display:none}
900.gf-hdr2 .gf-hrow2 #chartRegime{margin-left:0!important;height:22px;display:inline-flex;align-items:center}
901.gf-hdr2 .gf-hrow2 #ultraTopMetrics{margin-left:0!important;padding-left:0!important;gap:6px!important}
902.gf-hdr2 .gf-hrow2 .gexp-toggle{height:22px}
903.gf-hdr2 .gf-hrow2 .gexp-btn{padding:0 9px;height:20px;color:var(--t-muted,#b7c2d0)}
904.gf-hdr2 .gf-hrow2 .gf-hide-strip{margin-left:6px;height:22px!important;font-size:10px!important;padding:0 8px!important}
905.gf-hdr2 .gf-hrow2.collapsed{display:flex;justify-content:flex-end;min-height:24px;padding:2px 12px}
906.gf-hdr2 .gf-hrow2.collapsed #gfLvlLeft,.gf-hdr2 .gf-hrow2.collapsed #gfLvlMid{display:none!important}
907.gf-hdr2 .gf-hrow2.collapsed #gfLvlRight > *:not(.gf-hide-strip){display:none!important}
908/* Right panel: the Movers sub-row drops its Node Alerts chip - the Node Alerts TAB after Flow owns that (user 2026-09-05) */
909#umScopeRow [data-umsig="node"]{display:none}
910/* Drawing rail (admin) - sits on the right edge of the GEX ladder */
911.gf-hdr2 #drawTools{display:none!important}
912.gf-drawrail:not(.placed){visibility:hidden}
913.gf-drawrail{position:absolute;left:calc(var(--gf-ladder-px,290px) + 6px);top:8px;z-index:14;display:flex;flex-direction:column;align-items:center;gap:2px;padding:4px 3px;background:rgba(15,23,42,.94);border:1px solid var(--border);border-radius:calc(8px*var(--t-rk,1));box-shadow:0 6px 18px rgba(0,0,0,.35)}
914.gf-drawrail .gf-drawrail-tog{width:26px;height:16px;border:0;background:transparent;color:var(--t-muted,#b7c2d0);cursor:pointer;font-size:14px;line-height:1;border-radius:calc(5px*var(--t-rk,1));padding:0}
915.gf-drawrail .gf-drawrail-tog:hover{color:var(--t-text,#e2e8f0);background:rgba(var(--t-dim-rgb,148,163,184),.12)}
916.gf-drawrail #gfDrawRailTools{display:flex;flex-direction:column;gap:2px}
917.gf-drawrail .draw-menu-item{width:28px;height:28px;padding:0;justify-content:center;border-radius:calc(6px*var(--t-rk,1));font-size:0;color:var(--t-muted,#b7c2d0);border:1px solid transparent;gap:0}
918.gf-drawrail .draw-menu-item .dmi-ic{font-size:14px;width:auto}
919.gf-drawrail .draw-menu-item:hover{color:var(--cyan)}
920.gf-drawrail .draw-menu-item.active{color:var(--cyan);border-color:var(--cyan);background:rgba(34,211,238,.12)}
921.gf-drawrail #gfDrawRailColors{display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:5px 2px 3px;border-top:1px solid var(--border);margin-top:3px}
922.gf-drawrail #gfDrawRailColors #drawColors{display:contents!important}
923.gf-drawrail #drawBtn-del{width:28px;height:24px;padding:0;border-radius:calc(6px*var(--t-rk,1));font-size:12px;color:#fca5a5;margin-top:2px;border:1px solid transparent;background:transparent}
924.gf-drawrail #drawBtn-del:hover{border-color:#fca5a5}
925.gf-drawrail .gf-fibfly{display:none;position:absolute;left:calc(100% + 6px);top:0;width:236px;background:var(--card);border:1px solid var(--border);border-radius:calc(8px*var(--t-rk,1));padding:8px;box-shadow:0 8px 24px rgba(0,0,0,.55);z-index:20}
926.gf-drawrail.collapsed #gfDrawRailTools .draw-menu-item:not(.active),.gf-drawrail.collapsed #gfDrawRailColors,.gf-drawrail.collapsed #drawBtn-del{display:none}
927/* Draw dropdown in the chart toolbar (admin test) */
928#gfHgDraw{position:relative}
929#gfHgDraw .gf-drawrail.gf-drawmenu{position:absolute;
929left:0;top:calc(100% + 6px);display:none;flex-direction:column;align-items:stretch;gap:1px;padding:6px;min-width:196px;z-index:30;visibility:visible;background:var(--card)}
930#gfHgDraw.open .gf-drawrail.gf-drawmenu{display:flex}
931#gfHgDraw .gf-drawmenu #gfDrawRailTools{gap:1px}
932#gfHgDraw .gf-drawmenu .draw-menu-item{width:100%;height:26px;justify-content:flex-start;gap:8px;padding:0 8px;font-size:11.5px;color:var(--t-muted,#cbd5e1);white-space:nowrap}
933#gfHgDraw .gf-drawmenu .draw-menu-item .dmi-ic{width:16px;text-align:center;font-size:13px}
934#gfHgDraw .gf-drawmenu .draw-menu-item .dmi-lbl{font-size:11.5px}
935#gfHgDraw .gf-drawmenu .draw-menu-item.active{border-color:transparent;background:rgba(var(--t-cyan-rgb,6,182,212),.14)}
936#gfHgDraw .gf-drawmenu #gfDrawRailColors{grid-template-columns:repeat(4,1fr);gap:6px;padding:8px 6px 4px}
937#gfHgDraw .gf-drawmenu #drawBtn-del{width:100%;height:26px;display:flex;align-items:center;gap:8px;padding:0 8px;font-size:11.5px;text-align:left;justify-content:flex-start;margin-top:4px;border-top:1px solid var(--border);border-radius:0}
938#gfHgDraw .gf-drawmenu .gf-fibfly{left:calc(100% + 6px)}
939#gfDrawBtn .gf-drawbtn-ic{display:inline-block;width:14px;text-align:center}
940#gfDrawBtn.active{color:var(--cyan);border-color:var(--cyan)}
941/* Desk 2/3: the real toolbar sits on the focused chart (no ladder offset there); other charts get a light copy */
942#gfHdrChartCtl.gf-in-pane{left:8px}
943.gf-pane-tb{position:absolute;top:6px;left:8px;z-index:11;   /* one below the real toolbar so the Layers panel (inside it) paints over the copies (user 2026-09-06) */display:flex;align-items:center;gap:0;padding:2px 8px;border:1px solid var(--t-border2,#2e323d);border-radius:8px;background:var(--t-card,#0f172a);box-shadow:0 4px 14px rgba(0,0,0,.45);opacity:1}
944.gf-pane-tb:hover{opacity:1}
945.gf-pane-tb .gf-hgrp{margin-bottom:0;min-height:24px;padding:0 8px}
946.gf-pane-tb .gf-hgrp:first-child{border-left:0;padding-left:0}
947/* Compact stack (admin, 2026-09-05): one-row ticker strip, 30px controls, 26px level strip */
948.gf-hdr2 #chartTickers,.gf-hdr2 #chainTickers{flex-wrap:nowrap!important;overflow-x:auto!important;overflow-y:hidden!important;height:30px!important;max-height:30px!important;padding:0 118px 0 12px!important;align-items:center!important;gap:4px!important;scrollbar-width:none}   /* fixed 30px row, chips centred, scrollbar hidden (wheel scrolls it) */
949.gf-hdr2 #chartTickers::-webkit-scrollbar,.gf-hdr2 #chainTickers::-webkit-scrollbar{display:none}
950.gf-hdr2 #chartTickers::-webkit-scrollbar,.gf-hdr2 #chainTickers::-webkit-scrollbar{height:4px}
951.gf-hdr2 #chartTickers .exp-ticker-btn,.gf-hdr2 #chainTickers .exp-ticker-btn{height:20px;padding:0 7px;line-height:1;display:inline-flex;align-items:center;font-size:10px;color:var(--t-muted,#b7c2d0);box-sizing:border-box}
952.gf-hdr2 #chartTickers .exp-ticker-btn.active,.gf-hdr2 #chainTickers .exp-ticker-btn.active{background:var(--cyan)!important;color:var(--t-onAccent,#06222b)!important;border-color:var(--cyan)!important;font-weight:800}   /* no outer glow: it was clipped by the 30px strip and read as a half-drawn chip */   /* selected ticker = solid cyan chip (user 2026-09-05: the outline-only state was hard to see) */
953.gf-hdr2 #chartTickers.gf-tk-expanded,.gf-hdr2 #chainTickers.gf-tk-expanded{flex-wrap:wrap!important;height:auto!important;min-height:30px!important;max-height:260px!important;overflow-y:auto!important;border-bottom:1px solid var(--cyan)}   /* expanded = in flow (pushes the rows down while open) - never covers the controls or the level strip */
954.gf-hdr2 #chartTickers .exp-ticker-btn:focus,.gf-hdr2 #chartTickers .exp-ticker-btn:focus-visible,.gf-hdr2 #chainTickers .exp-ticker-btn:focus,.gf-hdr2 #chainTickers .exp-ticker-btn:focus-visible{outline:none;box-shadow:none}   /* no browser focus ring after a click */
955.gf-hdr2 #chartTickers.gf-tk-hidden,.gf-hdr2 #chainTickers.gf-tk-hidden{display:none!important}
956.gf-hdr2 #chartTickers .gf-fold-btn,.gf-hdr2 #chainTickers .gf-fold-btn{display:none!important}   /* one hide control, not two */
957#gfTkCtl{display:none}
958/* The chain strip gets the same parked control Ghost Mode has. As a chip inside the strip it was
959   the 119th item in a 5300px scroller and sat off screen - the one place a "show everything" button
960   must never be (user 2026-09-12). The 118px right padding on the strip is what reserves its seat. */
961#chainTkCtl{display:none}
962/* ── WORKSPACE (user 2026-09-12) ─────────────────────────────────────────────
963   The tiles are absolutely positioned from a split tree, so there is no flex
964   rounding drift and a divider drag moves exactly two neighbours. .gf-ws-in is
965   what makes a parked view stop assuming it owns the screen: every one of them
966   is written as height:calc(100dvh - header), which inside a tile is a view
967   taller than the tile it sits in. */
968#wsView{display:none;flex-direction:column;height:calc(100dvh - var(--header-h,56px));overflow:hidden}
969#wsBar{flex:0 0 auto;display:flex;align-items:center;gap:7px;flex-wrap:wrap;padding:7px 12px;border-bottom:1px solid var(--border);background:var(--card)}
970#wsAdd{display:flex;gap:6px;flex-wrap:wrap}
971.gf-ws-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:transparent;color:var(--t-muted,#cbd5e1);border-radius:calc(7px*var(--t-rk,1));padding:4px 10px;font:700 11px Segoe UI;cursor:grab}
972.gf-ws-chip:hover{border-color:var(--t-accent,#d4af37);color:var(--t-text,#f5f0e6)}
973.gf-ws-chip.on{background:var(--t-accent,#d4af37);border-color:var(--t-accent,#d4af37);color:var(--t-onAccent,#1a1200)}
974.gf-ws-chip.used{opacity:.38;border-style:dashed;cursor:default}
975.gf-ws-chip.used:hover{border-color:var(--border);color:var(--t-muted,#cbd5e1)}
976.gf-ws-dot{width:7px;height:7px;border-radius:50%;flex:0 0 auto}
977#wsStage{flex:1;min-height:0;position:relative;padding:0;margin:8px}
978.gf-ws-leaf{position:absolute;display:flex;flex-direction:column;background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden}
979.gf-ws-leaf.foc{border-color:var(--t-accent,#d4af37);box-shadow:0 0 0 1px rgba(212,175,55,.32)}
980.gf-ws-tabs{flex:0 0 auto;height:28px;display:flex;align-items:stretch;background:#101219;border-bottom:1px solid var(--border)}
981.gf-ws-tab{display:flex;align-items:center;gap:6px;padding:0 10px;font:700 10.5px Segoe UI;color:var(--t-dim,#8f8a7c);cursor:grab;border-right:1px solid var(--border);white-space:nowrap}
982.gf-ws-tab.on{color:var(--t-text,#f5f0e6);background:var(--card);box-shadow:inset 0 -2px 0 var(--t-accent,#d4af37)}
983.gf-ws-tab .x{opacity:0;font-size:13px;line-height:1;padding:0 1px;color:var(--t-dim,#8f8a7c)}
984.gf-ws-tab:hover .x{opacity:1}
985.gf-ws-tab .x:hover{color:#ef4444}
986.gf-ws-fill{flex:1}
987.gf-ws-host{flex:1;min-height:0;display:flex;flex-direction:column;position:relative}
988.gf-ws-slot{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
989.gf-ws-slot.off{display:none}
990/* max-height:none belongs ONLY to the element that should fill the tile. Blanket-removing it made
991   the grid's ticker strip - flex-wrap:wrap, 18 chips - expand from its 58px cap to 471px and eat
992   the whole tile, leaving #columns at 0px. A parked strip keeps its own cap and its own size. */
993.gf-ws-in{min-height:0!important}
994.gf-ws-in:not(.gf-ws-main){flex:0 0 auto!important}
995.gf-ws-main{flex:1 1 auto!important;height:auto!important;min-height:0!important;max-height:none!important}
996.gf-ws-grip{position:absolute;z-index:21;background:transparent}
997.gf-ws-grip.bottom{left:0;right:0;bottom:0;height:4px;cursor:row-resize}
998.gf-ws-grip.right{top:0;bottom:0;right:0;width:9px;cursor:col-resize}
999.gf-ws-grip:hover,.gf-ws-grip.on{background:rgba(212,175,55,.35)}
1000.gf-ws-slot.gf-ws-hold{position:relative;display:flex;align-items:center;justify-content:center;text-align:center;color:#f5f0e6;font-size:13px;font-weight:700;border:1px dashed var(--border2,#2e323d);border-radius:8px;margin:8px}
1001.gf-ws-holdx{position:absolute;top:6px;right:10px;font-size:16px;line-height:1;color:#cbd5e1;cursor:pointer}
1002.gf-ws-holdx:hover{color:#f5f0e6}
1003.gf-tide{flex:0 0 auto;border-top:1px solid var(--border);background:var(--card);display:none;position:relative;z-index:9500}
1004.gf-tide-h{position:relative;z-index:2}
1005body.gf-ws-on #chartContent{display:flex;flex-direction:column}
1006body.gf-ws-on #tvChart{flex:1 1 0;min-height:0;height:auto!important}
1007body.gf-ws-on .gf-tide{display:block}
1008.gf-tide-h{display:flex;align-items:center;gap:8px;padding:4px 10px;font-size:10px;font-weight:800;letter-spacing:.5px;color:#f5f0e6;cursor:pointer;user-select:none}
1009.gf-tide-h .tk{color:#f5c518}
1010.gf-tide-h .chev{margin-left:auto;color:#cbd5e1;font-size:11px}
1011.gf-tide-b{padding:0 10px 6px;display:flex;flex-direction:column;min-height:0;height:150px;box-sizing:border-box}
1012.gf-tide-box{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
1013.gf-tide-box svg{flex:1 1 auto;min-height:0;width:100%;height:auto}
1014.gf-tide-grip{position:absolute;
1014left:0;right:0;top:-3px;height:7px;cursor:row-resize;z-index:2}
1015.gf-tide-grip:hover{background:rgba(212,175,55,.35)}
1016.gf-tide.min .gf-tide-b{display:none}
1017body.gf-ws-on [data-tptab="tide"]{display:none!important}   /* in HQ the tide rides under each chart */
1018.gf-ws-div{position:absolute;z-index:20}
1019.gf-ws-div:after{content:"";position:absolute;inset:0;margin:auto;background:var(--border2,#2e323d);border-radius:2px}
1020.gf-ws-div.row{cursor:col-resize;width:8px}
1021.gf-ws-div.row:after{width:2px;height:26px}
1022.gf-ws-div.col{cursor:row-resize;height:8px}
1023.gf-ws-div.col:after{height:2px;width:26px}
1024.gf-ws-div:hover:after{background:var(--t-accent,#d4af37)}
1025#wsDz{position:absolute;z-index:40;pointer-events:none;border-radius:8px;background:rgba(212,175,55,.18);border:1px solid var(--t-accent,#d4af37);display:none}
1026#wsGhost{position:fixed;z-index:9999;pointer-events:none;padding:5px 11px;border-radius:7px;background:var(--t-accent,#d4af37);color:var(--t-onAccent,#1a1200);font:800 11px Segoe UI;display:none;box-shadow:0 8px 24px rgba(0,0,0,.5)}
1027#wsGridBar{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border-bottom:1px solid var(--border);background:#101219}
1028#wsGridSector{background:var(--bg);border:1px solid var(--border);color:var(--t-text,#f5f0e6);border-radius:calc(5px*var(--t-rk,1));padding:2px 6px;font:700 10.5px Segoe UI;cursor:pointer}
1029#wsGridOneBtn{background:transparent;border:1px solid var(--border);color:var(--t-muted,#cbd5e1);border-radius:calc(5px*var(--t-rk,1));padding:2px 8px;font:700 10px Segoe UI;cursor:pointer}
1030#wsGridOneBtn.on{background:var(--t-accent,#d4af37);border-color:var(--t-accent,#d4af37);color:var(--t-onAccent,#1a1200)}
1031#wsGridFold{background:transparent;border:1px solid var(--border);color:var(--t-muted,#cbd5e1);border-radius:calc(5px*var(--t-rk,1));padding:2px 8px;font:700 10px Segoe UI;cursor:pointer}
1032#wsGridFold:hover{border-color:var(--t-accent,#d4af37);color:var(--t-text,#f5f0e6)}
1033.gf-ws-tkhide{display:none!important}
1034.gf-ws-flowctl{display:inline-flex;align-items:center;gap:5px;margin-right:8px}
1035.gf-ws-flowctl select{background:var(--bg);border:1px solid var(--border);color:var(--t-text,#f5f0e6);border-radius:calc(5px*var(--t-rk,1));padding:2px 6px;font:700 10.5px Segoe UI;cursor:pointer}
1036.gf-ws-flowctl input{width:78px;background:var(--bg);border:1px solid var(--border);color:var(--t-text,#f5f0e6);border-radius:calc(5px*var(--t-rk,1));padding:2px 7px;font:700 10.5px Segoe UI;text-transform:uppercase}
1037.gf-ws-flowctl select:focus,.gf-ws-flowctl input:focus{outline:none;border-color:var(--t-accent,#d4af37)}
1038/* ONE ticker line, one settings row. The per-panel strips are hidden rather than moved: they belong
1039   to their views, and hiding is a single CSS rule to undo instead of a second teardown path. */
1040/* the ticker line and the settings row are one row now; both stay in the DOM so the exit path is
1041   unchanged, they are simply not shown */
1042body.gf-ws-on #wsTkBar,body.gf-ws-on #wsSetBar{display:none!important}
1043body.gf-ws-on #btn-viewsync{display:none!important}   /* the row has its own Sync - two is one too many */
1044#wsBar{position:relative;flex-wrap:nowrap!important;overflow:visible}
1045.gf-ws-vr{width:1px;height:16px;background:var(--border2,#2e323d);flex:0 0 auto}
1046#wsTkSearch{width:86px;flex:0 0 auto;background:var(--bg);border:1px solid var(--border);color:var(--t-text,#f5f0e6);
1047  border-radius:calc(6px*var(--t-rk,1));padding:3px 8px;font:700 10.5px Segoe UI;text-transform:uppercase}
1048#wsTkSearch:focus{outline:none;border-color:var(--t-accent,#d4af37)}
1049#wsTkRecent{display:flex;align-items:center;gap:3px;flex:0 1 auto;overflow:hidden}
1050#wsTkRecent .exp-ticker-btn{padding:2px 7px;font-size:10px}
1051#wsQuick{display:flex;align-items:center;gap:5px;flex:0 0 auto}
1052.gf-ws-dd{position:relative;display:inline-flex;flex:0 0 auto}
1053.gf-ws-ddb{display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--border);
1054  color:var(--t-muted,#cbd5e1);border-radius:calc(6px*var(--t-rk,1));padding:3px 9px;font:700 10.5px Segoe UI;
1055  cursor:pointer;white-space:nowrap}
1056.gf-ws-ddb:hover,.gf-ws-dd.open .gf-ws-ddb{border-color:var(--t-accent,#d4af37);color:var(--t-text,#f5f0e6)}
1057.gf-ws-ddb .k{font:800 8.5px Segoe UI;letter-spacing:1px;color:var(--t-dim,#a9a294)}
1058.gf-ws-ddb .v{color:var(--t-accentHi,#e9d38a)}
1059.gf-ws-ddb .cv{font-size:9px;opacity:.85}
1060.gf-ws-ddp{display:none;position:absolute;top:100%;left:0;margin-top:5px;z-index:70;background:var(--card,#0e1017);
1061  border:1px solid var(--t-accent,#d4af37);border-radius:calc(9px*var(--t-rk,1));padding:8px;
1062  box-shadow:0 12px 34px rgba(0,0,0,.62);min-width:172px}
1063.gf-ws-dd.open .gf-ws-ddp{display:block}
1064.gf-ws-ddp::before{content:'';position:absolute;
1064left:0;right:0;top:-7px;height:7px}   /* hover bridge */
1065.gf-ws-ddh{font:800 8.5px Segoe UI;letter-spacing:1px;color:var(--t-dim,#a9a294);margin:0 0 6px 2px}
1066.gf-ws-ddcol{display:flex;flex-direction:column;gap:4px}
1067.gf-ws-layrow{display:flex;align-items:stretch;gap:3px}
1068.gf-ws-layrow .gf-ws-chip{flex:1 1 auto;justify-content:flex-start;text-align:left}
1069.gf-ws-layrow.on .gf-ws-chip{border-color:var(--t-accent,#d4af37);color:var(--t-accentHi,#e9d38a)}
1070.gf-ws-layx{flex:0 0 auto;background:transparent;border:1px solid var(--border);color:var(--t-dim,#8f8a7c);
1071  border-radius:calc(6px*var(--t-rk,1));padding:0 7px;font:700 12px Segoe UI;cursor:pointer;line-height:1}
1072.gf-ws-layx:hover{border-color:#ef4444;color:#ef4444}
1073/* a segmented control reads as a list once it is in a menu */
1074.gf-ws-ddp .gexp-toggle{display:flex;flex-direction:column;align-items:stretch;width:100%}
1075.gf-ws-ddp .gexp-btn{text-align:left;padding:4px 10px;border-bottom:1px solid var(--border)}
1076.gf-ws-ddp .gexp-btn:last-child{border-bottom:0}
1077.gf-ws-ddp #wsHeatGrp{display:flex;flex-direction:column;align-items:stretch;gap:4px;margin:0;padding:0;border:0}
1078.gf-ws-ddp #wsHeatGrp button{text-align:left}
1079#wsDdAdd #wsAdd{display:flex;flex-direction:column;gap:4px;min-width:168px}
1080#wsTkPop{width:min(620px,80vw)}
1081@media (max-width:1280px){ #wsBar .gf-ws-glbl{display:none} #wsBar .gf-ws-ddb .k{display:none} }
1082@media (max-width:1040px){ #wsTkRecent{display:none} }
1083#wsTkPop #wsTkStrip{display:flex!important;flex-wrap:wrap;gap:4px;height:auto;max-height:258px;
1084  overflow-x:hidden;overflow-y:auto;scrollbar-width:thin}
1085#wsTkBar{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:4px 8px;border-bottom:1px solid var(--border);background:var(--card)}
1086#wsTkStrip{flex:1;min-width:0;display:flex;gap:4px;overflow-x:auto;overflow-y:hidden;height:26px;align-items:center;scrollbar-width:none}
1087#wsTkStrip::-webkit-scrollbar{display:none}
1088#wsTkStrip .exp-ticker-btn{height:20px;padding:0 7px;font-size:10px;line-height:1;display:inline-flex;align-items:center;flex:0 0 auto}
1089#wsTkStrip .exp-ticker-btn.active{background:var(--t-accent,#d4af37)!important;border-color:var(--t-accent,#d4af37)!important;color:var(--t-onAccent,#1a1200)!important;font-weight:800}
1090#wsTkFold{flex:0 0 auto;background:transparent;border:1px solid var(--border);color:var(--t-muted,#cbd5e1);border-radius:calc(5px*var(--t-rk,1));padding:1px 7px;font:700 10px Segoe UI;cursor:pointer}
1091#wsTkBar.gf-ws-tkfold #wsTkStrip{display:none}
1092#wsSetBar{flex:0 0 auto;display:flex;align-items:flex-start;min-height:0;max-height:none;overflow:visible}
1093#wsSetBar:empty{display:none}
1094#wsSetBar #gridExpiryBar{width:100%;padding:4px 10px!important;flex-wrap:wrap;row-gap:4px;align-items:center;overflow:visible;height:auto!important;max-height:none!important}
1095#wsSetBar .gf-ws-glbl{align-self:center}
1096#wsHeatGrp{display:flex;align-items:center;gap:4px;margin-left:6px;padding-left:8px;border-left:1px solid var(--border)}
1097.gf-ws-glbl{font:800 8.5px Segoe UI;letter-spacing:.9px;color:var(--t-dim,#8f8a7c)}
1098/* every panel's own ticker strip and Flow's preset tabs are redundant now */
1099/* the heatmap info line in a tile: every part of it small, whether it drew before or after you came
1100   in here - the short WORDS are chosen at render time, the size is not */
1101body.gf-ws-on #expiryInfo{gap:7px!important;padding:3px 8px!important;row-gap:2px!important}
1102body.gf-ws-on #expiryInfo span{font-size:10px!important}
1103body.gf-ws-on #expiryInfo > span:first-child{font-size:11.5px!important}
1104/* in a tile a column keeps a readable width and the row scrolls, instead of five columns splitting
1105   whatever width there is and clipping the value off every one of them */
1106/* 300px is the width at which the whole row shows - VOL bar and number included. The panel's
1107   width decides how many of those fit; the rest scroll. Nothing is squeezed below it. */
1108body.gf-ws-on .gf-ws-slot .gex-col{flex:1 1 0!important;min-width:300px!important}
1109/* the per-column refresh is not needed in a tile - the panel refreshes itself - and at 300px the
1110   header needs the room for the price and the remove */
1111body.gf-ws-on .gf-ws-slot .col-refresh{display:none!important}
1112/* the in-grid + is a column's width of nothing in a tile; the bar has one instead */
1113body.gf-ws-on .gf-ws-slot #columns .add-col{display:none!important}
1114#wsGridAddBtn{background:transparent;border:1px solid var(--border);color:var(--t-muted,#cbd5e1);
1115  border-radius:calc(5px*var(--t-rk,1));padding:1px 8px;font:800 12px Segoe UI;line-height:1.3;cursor:pointer;margin-left:2px}
1116#wsGridAddBtn:hover{border-color:var(--t-accent,#d4af37);color:var(--t-text,#f5f0e6)}
1117/* the % badge shows for ONE ticker (a full-width column, room for it) and not for a sector
1118   (several 300px columns, none). In one-ticker mode nothing here touches it at all. */
1119body.gf-ws-on .gf-ws-slot #columns:not(.gf-ws-one) .gex-pct{display:none!important}
1120/* a sub-tab row scrolls rather than running off the edge of the tile */
1121body.gf-ws-on .gf-ws-slot #ideasTabRow,
1122body.gf-ws-on .gf-ws-slot #scanTabBar{min-width:0;overflow-x:auto;overflow-y:hidden;
1123  flex-wrap:nowrap!important;scrollbar-width:thin}
1124/* the Ideas row lives in a left-to-right header: growing takes leftover WIDTH */
1125body.gf-ws-on .gf-ws-slot #ideasTabRow{flex:1 1 auto}
1126/* #scanTabBar is a child of a COLUMN, where growing would take the whole HEIGHT */
1127body.gf-ws-on .gf-ws-slot #scanTabBar{flex:0 0 auto!important}
1128body.gf-ws-on .gf-ws-slot #ideasTabRow .ideas-tab,
1129body.gf-ws-on .gf-ws-slot #scanTabBar .scan-supertab{flex:0 0 auto}
1130body.gf-ws-on .gf-ws-slot #ideasTabRow::-webkit-scrollbar,
1131body.gf-ws-on .gf-ws-slot #scanTabBar::-webkit-scrollbar{height:6px}
1132body.gf-ws-on .gf-ws-slot #ideasView > div:first-child{padding:7px 10px!important;gap:7px!important}
1133/* the tab already says Ideas */
1134body.gf-ws-on .gf-ws-slot #ideasView > div:first-child > span:first-child{display:none}
1135/* every sub-tab's control bar: one line, no wrapping */
1136body.gf-ws-on .gf-ws-slot #ideasWatchBar,
1137body.gf-ws-on .gf-ws-slot #ideasEarnBar,
1138body.gf-ws-on .gf-ws-slot #ideasGhostBar,
1139body.gf-ws-on .gf-ws-slot #ideasIdxBar{flex-wrap:nowrap!important;gap:6px!important;min-width:0;overflow:hidden}
1140/* the explanatory sentence on each bar is documentation, not a control */
1141body.gf-ws-on .gf-ws-slot #ideasSub,
1142body.gf-ws-on .gf-ws-slot #ideasSetupBar > span:not([id]):not(:has(button)),
1143body.gf-ws-on .gf-ws-slot #ideasWatchBar > span:not([id]):not(:has(button)),
1144body.gf-ws-on .gf-ws-slot #ideasEarnBar > span:not([id]):not(:has(button)),
1145body.gf-ws-on .gf-ws-slot #ideasGhostBar > span:not([id]):not(:has(button)),
1146body.gf-ws-on .gf-ws-slot #ideasIdxBar > span:not([id]):not(:has(button)){display:none!important}
1147/* the last-run stamp is a fact that can truncate */
1148body.gf-ws-on .gf-ws-slot #ideasStamp,
1149body.gf-ws-on .gf-ws-slot #watchStamp,
1150body.gf-ws-on .gf-ws-slot #ghostStamp,
1151body.gf-ws-on .gf-ws-slot #idxStamp{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}
1152/* and the session / mode buttons take the tile's scale */
1153body.gf-ws-on .gf-ws-slot .wl-which,
1154body.gf-ws-on .gf-ws-slot .ideas-sess,
1155body.gf-ws-on .gf-ws-slot .ideas-mode{padding:3px 9px!important;font-size:10.5px!important}
1156body.gf-ws-on .gf-ws-slot #ideasView .refresh-btn{padding:3px 8px!important;font-size:10.5px!important;white-space:nowrap}
1157/* the heatmap: columns keep a width the value fits in, and the row scrolls sideways */
1158body.gf-ws-on .gf-ws-slot .exp-table{width:auto!important;min-width:100%}
1159body.gf-ws-on .gf-ws-slot .exp-table th,
1160body.gf-ws-on .gf-ws-slot .exp-table td{min-width:94px}
1161body.gf-ws-on .gf-ws-slot .exp-table th:first-child,
1162body.gf-ws-on .gf-ws-slot .exp-table td:first-child{min-width:54px!important;width:54px!important}
1163body.gf-ws-on .gf-ws-slot .expiry-content{padding:4px 8px}
1164body.gf-ws-on .gf-ws-slot #columns{height:100%!important;overflow-x:auto!important}
1165body.gf-ws-on #gexTickers,
1166body.gf-ws-on #chartTickers,
1167body.gf-ws-on #gfTkCtl,
1168body.gf-ws-on #expiryTickers,
1169body.gf-ws-on #chainTickers,
1170body.gf-ws-on #chainTkCtl,
1171body.gf-ws-on #heatSetRow,
1172body.gf-ws-on #flowTickers,
1173body.gf-ws-on #flowView .flow-presets{display:none!important}
1174body.gf-ws-on #flowView .flow-bar{padding:3px 8px!important}
1175/* a declared side column, collapsed by default in a tile */
1176.gf-ws-sidehide{display:none!important}
1177#wsNodes{flex:1 0 100%;display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:1px}
1178#wsNodes > span{font-size:10px!important;white-space:nowrap}
1179/* in the tab bar it is a title, not a row: no line of its own, and it truncates rather than wraps */
1180.gf-ws-fill{display:flex;align-items:center;min-width:0;overflow:hidden;cursor:grab}
1181.gf-ws-fill:active{cursor:grabbing}
1182.gf-ws-fill #wsNodes{flex:0 1 auto;flex-wrap:nowrap;margin:0 0 0 12px;gap:9px;overflow:hidden}
1183.gf-ws-fill #wsNodes > span{font-size:9.5px!important;overflow:hidden;text-overflow:ellipsis}
1184/* a vertical tab ON THE RIGHT EDGE - the side column is on the right, so its handle belongs there
1185   rather than floating at the bottom over the content */
1186.gf-ws-sidebtn{position:absolute;z-index:6;right:0;top:50%;transform:translateY(-50%);
1187  background:var(--card);border:1px solid var(--border);border-right:0;color:var(--t-muted,#cbd5e1);
1188  border-radius:calc(5px*var(--t-rk,1)) 0 0 calc(5px*var(--t-rk,1));padding:9px 2px;
1189  font:700 9px Segoe UI;cursor:pointer;opacity:.6;writing-mode:vertical-rl;letter-spacing:.6px}
1190.gf-ws-sidebtn:hover{opacity:1;border-color:var(--t-accent,#d4af37);color:var(--t-text,#f5f0e6)}
1191.gf-ws-sidecol{position:relative}
1192.gf-ws-sidegrip{position:absolute;
1192left:-3px;top:0;bottom:0;width:6px;cursor:col-resize;z-index:7}
1193.gf-ws-sidegrip:hover{background:var(--t-accent,#d4af37);opacity:.5}
1194/* GHOST MODE IN A TILE. Its header is built for a full screen: a 16px ticker name, a 94px row of
1195   LEVELS / CHARTS / node badges, and the vertical FLOW-PLAN rail down the right. In a 620px tile the
1196   badges landed on top of the VOL control and NET CALL GEX ran off the edge. Shrink the row, let it
1197   wrap instead of overlapping, and drop the side rail - the workspace is the place you put Flow in
1198   its own tile, so a second copy of it glued to the chart is just lost width. */
1199body.gf-ws-on #ultraFlow:not(.gf-ws-in),
1200body.gf-ws-on #chartWatchlist,
1201body.gf-ws-on #chartTPRestoreBtn{display:none!important}
1202/* as a tile of its own it fills the tile: the 380px and the drag grip belong to the docked version */
1203.gf-ws-slot > #ultraFlow{width:auto!important;min-width:0!important;flex:1 1 auto!important}
1204.gf-ws-slot > #ultraFlow > .nf-wresize{display:none!important}
1205/* the GEX ladder's restore arrow must survive: the panel header hides the ladder, THIS brings it
1206   back, and hiding both leaves no way to see the grid beside the chart again */
1207body.gf-ws-on #gexRestoreTab{display:none!important}
1208#wsGexTab{position:absolute;top:50%;transform:translateY(-50%);z-index:8;width:18px;height:64px;padding:0;
1209  display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;line-height:1;
1210  background:rgba(15,23,42,.94);color:var(--t-muted,#cbd5e1);border:1px solid var(--border);
1211  border-radius:0 7px 7px 0;box-shadow:2px 0 8px rgba(0,0,0,.45)}
1212#wsGexTab:hover{color:var(--t-text,#f5f0e6);border-color:var(--t-accent,#d4af37)}
1213#wsGexTab.gf-on{border-radius:7px 0 0 7px;border-left:0}
1214body.gf-ws-on #chartHeader{padding:4px 8px!important;gap:6px!important;flex-wrap:wrap!important;row-gap:3px!important}
1215body.gf-ws-on #chartTickerName{font-size:12px!important}
1216body.gf-ws-on #chartSpot{font-size:11px!important}
1217body.gf-ws-on #chartHeader .tf-btn,
1218body.gf-ws-on #chartHeader button{font-size:9.5px!important;padding:1px 6px!important;height:18px!important;line-height:1!important}
1219body.gf-ws-on #chartHeader .nodepill,
1220body.gf-ws-on #chartHeader [class*="node"]{font-size:9px!important}
1221.gf-ws-empty{position:absolute;inset:0;border:1px dashed var(--border2,#2e323d);border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--t-dim,#8f8a7c);font-size:12.5px}
1222/* EXPECTED MOVE band down the strike axis. The strike cell's own background moves here from an
1223   inline style so these rules can take it over without !important on every row. */
1224.gf-ch-sk{background:#131b2c}
1225.gf-ch-sk.em{background:linear-gradient(90deg,rgba(201,162,39,.05),rgba(201,162,39,.20) 50%,rgba(201,162,39,.05))}
1226.gf-ch-sk.em-hi{box-shadow:inset 0 2px 0 var(--t-accent2,#c9a227)}
1227.gf-ch-sk.em-lo{box-shadow:inset 0 -2px 0 var(--t-accent2,#c9a227)}
1228.gf-ch-sk.em-hi.em-lo{box-shadow:inset 0 2px 0 var(--t-accent2,#c9a227),inset 0 -2px 0 var(--t-accent2,#c9a227)}
1229.gf-hdr2 #chainView{position:relative}
1230.gf-hdr2 #chainTkCtl{display:flex;position:absolute;top:4px;right:12px;z-index:41;gap:4px;align-items:center;padding-left:14px;background:linear-gradient(90deg,rgba(17,24,39,0),var(--card) 14px)}
1231.gf-hdr2 #chainTkCtl .tf-btn{height:22px;padding:0 8px;font-size:10px;font-weight:700;border-radius:calc(5px*var(--t-rk,1));color:var(--t-muted,#b7c2d0);background:var(--card)}
1232.gf-hdr2 #chainTkCtl .tf-btn.on{color:var(--cyan);border-color:var(--cyan)}
1233/* OPTION CHAIN, design A (user 2026-09-12). Hover lights the WHOLE row so a bid is never read
1234   against the wrong strike, and spot draws as a hairline BETWEEN the two strikes it sits between -
1235   a zero-height row whose only job is to carry the line and its price tag. */
1236#chainBody tr:hover td{background-color:rgba(255,255,255,.055)}
1237#chainBody tr:hover td.gf-ch-sk{background-color:#1d2740!important}
1238#chainBody tr.gf-ch-spot td,#chainBody tr.gf-ch-spot:hover td{padding:0;height:0;position:relative;border:0;background:none}
1239#chainBody .gf-ch-line{position:absolute;left:0;right:0;top:-1px;height:0;border-top:2px dotted #d4af37;opacity:.9}
1240#chainBody .gf-ch-tag{position:absolute;top:-9px;background:#d4af37;color:#0b0f18;font:800 9px Segoe UI;padding:1px 6px;border-radius:9px;white-space:nowrap}
1241/* TRACK THIS CONTRACT (user 2026-09-12). A real button rather than a clickable side: a whole-side
1242   click target on a 150-row table is an accidental position waiting to happen. Invisible until the
1243   row is hovered, so 150 plus signs are not competing with the prices for attention. */
1244#chainBody .gf-ch-add{opacity:0;transition:opacity .12s;border:1px solid var(--border);background:var(--card);color:var(--t-accent,#d4af37);border-radius:calc(4px*var(--t-rk,1));width:18px;height:16px;line-height:1;font:800 12px Segoe UI;cursor:pointer;padding:0}
1245#chainBody tr:hover .gf-ch-add{opacity:1}
1246#chainBody .gf-ch-add:hover{background:var(--t-accent,#d4af37);color:var(--t-onAccent,#0b0f18);border-color:var(--t-accent,#d4af37)}
1247#chainBody .gf-ch-add:focus-visible{opacity:1;outline:1px solid var(--t-accent,#d4af37)}
1248.gf-hdr2 #gfTkCtl{display:flex;position:absolute;top:4px;right:12px;z-index:41;gap:4px;align-items:center;padding-left:14px;background:linear-gradient(90deg,rgba(17,24,39,0),var(--card) 14px)}   /* fade so a chip scrolling under the control does not collide with it */
1249.gf-hdr2 #gfTkCtl.inline{position:static;margin-right:4px}
1250.gf-hdr2 #gfTkCtl .tf-btn{height:22px;padding:0 8px;font-size:10px;font-weight:700;border-radius:calc(5px*var(--t-rk,1));color:var(--t-muted,#b7c2d0);background:var(--card)}
1251.gf-hdr2 #gfTkCtl .tf-btn.on{color:var(--cyan);border-color:var(--cyan)}
1252.gf-hdr2 #chartHeader{padding:4px 12px 0}
1253.gf-hdr2 .gf-hgrp{min-height:24px;margin-bottom:4px}
1254.gf-hdr2 #gfLvlLeft #gfHgDesk{margin-bottom:0;min-height:0;padding:0 8px;margin-left:4px}   /* DESK on the LEVELS row (user 2026-09-06) */
1255.gf-hdr2 #gfLvlLeft #gfHgDesk #chartVolToggle{margin-left:8px!important;height:22px;padding:0 8px;font-size:10px;font-weight:800;border-radius:calc(5px*var(--t-rk,1))}
1256.gf-hdr2 #gfLvlLeft #gfHgDesk #chartVolToggle.active{background:var(--cyan);color:var(--t-onAccent,#06222b);border-color:var(--cyan)}
1257#gfHdrChartCtl #gfHgChart #gfHgDraw{padding-left:8px;margin-right:4px}
1258/* One outer card only (user 2026-09-06): no inner boxes - the timeframe strip, the Desk copies and every button
1259   inside the floating toolbar are borderless text buttons; the active one is a solid colour pill. */
1260#gfHdrChartCtl .gf-hgrp,.gf-pane-tb .gf-hgrp{border-left:0!important;padding:0 4px!important}
1261#gfHdrChartCtl #tfButtons,.gf-pane-tb .gf-hgrp:first-child{border:0!important;border-radius:0;overflow:visible;gap:2px!important}
1262#gfHdrChartCtl .tf-btn,.gf-pane-tb .tf-btn{border:0!important;background:transparent!important;color:var(--t-muted,#b7c2d0);height:24px;padding:0 8px!important;border-radius:calc(5px*var(--t-rk,1))!important;font-weight:700;margin-left:0!important}
1263#gfHdrChartCtl .tf-btn:hover,.gf-pane-tb .tf-btn:hover{color:var(--text);background:rgba(var(--t-accent-rgb,59,130,246),.14)!important}
1264#gfHdrChartCtl .tf-btn.active,.gf-pane-tb .tf-btn.active{background:var(--cyan)!important;color:var(--t-onAccent,#06222b)}
1265#gfHdrChartCtl #replayBtn.active,#gfHdrChartCtl #replayBtn.on{background:var(--cyan)!important;color:var(--t-onAccent,#06222b)}
1266.gf-hdr2 #gfHgTime .gf-hlbl,.gf-hdr2 #gfHgChart .gf-hlbl{display:none}
1267.gf-hdr2 #chartHeader .tf-btn{height:22px;padding:0 8px}
1268.gf-hdr2 #tfButtons .tf-btn,.gf-hdr2 #mchartToggle .tf-btn{height:20px}
1269.gf-hdr2 #chartTickerName{font-size:14px}
1270.gf-hdr2 .gf-hrow2{min-height:26px;padding:2px 12px}
1271.gf-hdr2 .gf-hrow2 #chartHeaderChips > span:not(:empty),.gf-hdr2 #gfLvlRight > span:not(:empty){height:20px;padding:0 8px;font-size:10.5px!important}
1272.gf-hdr2 .gf-hrow2 .gexp-toggle{height:20px}
1273.gf-hdr2 .gf-hrow2 .gexp-btn{height:18px;padding:0 8px}
1274.gf-hdr2 #ultraTopMetrics .nf-topmet{display:inline-flex!important;flex-direction:row!important;align-items:center;gap:5px;height:20px;padding:0 8px;border:1px solid var(--border);border-radius:calc(5px*var(--t-rk,1));background:rgba(var(--t-dim-rgb,148,163,184),.07);margin:0}
1275.gf-hdr2 #ultraTopMetrics .nf-topmet .k{font-size:9px!important;letter-spacing:.5px;color:var(--t-muted,#cbd5e1)!important;font-weight:800;line-height:1}
1276.gf-hdr2 #ultraTopMetrics .nf-topmet .v{font-size:11px!important;font-weight:800;line-height:1}
1277.gf-hdr2 .gf-hrow2 .gf-hide-strip{height:20px!important;padding:0 6px!important}
1278
1279
1280
1281
1282/* Watchlist items */
1283.wl-item {
1284  display: flex; align-items: center; justify-content: space-between;
1285  padding: 8px 12px; border-bottom: 1px solid rgba(var(--t-border-rgb,30,41,59),0.3);
1286  cursor: pointer; font-family: 'Segoe UI', monospace;
1287  transition: background 0.1s;
1288}
1289.wl-item:hover { background: rgba(255,255,255,0.03); }
1290.wl-item.active { background: rgba(var(--t-cyan-rgb,6,182,212),0.08); border-left: 3px solid var(--cyan); }
1291.wl-ticker { font-size: 12px; font-weight: 600; color: var(--text); }
1292.wl-price { font-size: 11px; color: var(--dim); }
1293.wl-group-label {
1294  padding: 6px 12px; font-size: 9px; font-weight: 600; color: var(--dim);
1295  font-family: 'Segoe UI', monospace; letter-spacing: 1px;
1296  background: rgba(var(--t-border-rgb,30,41,59),0.3); border-bottom: 1px solid var(--border);
1297}
1298
1299/* Demo locked column */
1300.demo-lock-wrap { position: relative; height: 100%; overflow: hidden; }
1301.demo-lock { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 1px; background: rgba(10,14,23,0.55); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
1302.demo-lock-btn { background: var(--t-accent,#3b82f6); color: var(--t-onAccent,#fff); padding: 5px 14px; border-radius:calc(6px*var(--t-rk,1)); text-decoration: none; font-size: 11px; font-weight: 700; }
1303.demo-lock-btn:hover { background: var(--t-accent2,#2563eb); }
1304.demo-locked-btn { opacity: 0.78; pointer-events: none; }
1305.demo-locked-col { pointer-events: none; }
1306.demo-blur { filter: blur(3.5px); opacity: 0.6; user-select: none; }
1307.demo-tab-locked { opacity: 0.8; pointer-events: none; cursor: default; }
1308.demo-locked-panel label, .demo-locked-panel .mo-opts, .demo-locked-panel .modecat, .demo-locked-panel #gfGridLayerRow .tf-btn, .demo-locked-panel .ma-row, .demo-locked-panel #mode-gexbub-row, .demo-locked-panel #gexbub-opts, .demo-locked-panel #darkpool-opts, .demo-locked-panel #flowmarks-opts { pointer-events: none; opacity: .6; }   /* the GEX Visual row is a div, not a label - lock it explicitly */
1309.demo-locked-panel .demo-open, .demo-locked-panel .demo-open * { pointer-events: auto !important; opacity: 1 !important; }
1310
1311/* Scanner */
1312#scanFilters input, #scanFilters select, #scanWatchAdd input {
1313  background: var(--t-bg,#0a0e17); border: 1px solid var(--border); border-radius:calc(4px*var(--t-rk,1));
1314  color: var(--text); padding: 3px 6px; font-family: 'Segoe UI', monospace; font-size: 12px;
1315}
1316.scan-row:hover { background: rgba(var(--t-accent-rgb,59,130,246),0.08) !important; }
1317.scan-mode-tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--dim); font-family: 'Segoe UI', monospace; font-size: 12px; font-weight: 700; padding: 6px 10px; cursor: pointer; }
1318.scan-mode-tab.active { color: var(--cyan); border-bottom-color: var(--cyan); }
1319.scan-supertab { background: none; border: 1px solid var(--border); border-radius:calc(7px*var(--t-rk,1));
1319 color: var(--dim); font-family: 'Segoe UI', monospace; font-size: 12px; font-weight: 700; padding: 6px 14px; cursor: pointer; }
1320.scan-supertab.active { color: #fff; background: var(--t-accent,#3b82f6); border-color: var(--t-accent,#3b82f6); }
1321.node-chip { padding: 5px 12px; border-radius:calc(20px*var(--t-rk,1)); border: 1px solid var(--border); background: #0e0e11; color: var(--dim); font-size: 12px; font-weight: 700; cursor: pointer; font-family: 'Segoe UI', monospace; }
1322.node-chip[data-node=king].active { background: rgba(234,179,8,0.18); border-color: #eab308; color: #eab308; }
1323.node-chip[data-node=call].active { background: rgba(34,197,94,0.18); border-color: #22c55e; color: #22c55e; }
1324.node-chip[data-node=put].active  { background: rgba(239,68,68,0.18); border-color: #ef4444; color: #ef4444; }
1325.node-chip[data-node=vol].active  { background: rgba(var(--t-accent-rgb,59,130,246),0.18); border-color: var(--t-accent,#3b82f6); color: var(--t-accent,#3b82f6); }
1326.node-del:hover { color: var(--red) !important; }
1327/* Per-ticker alert arming: no bell by default — it fades in on row hover; armed rows keep
1328   it lit and get a green left accent + tint. Click the bell OR the ticker to toggle. */
1329.node-row .node-arm { opacity: 0; transition: opacity .12s; }
1330.node-row:hover .node-arm { opacity: .45; }
1331.node-row .node-arm:hover { opacity: 1; }
1332.node-row .node-arm.armed { opacity: 1; }
1333.node-row.armed { background: rgba(34,197,94,0.06); box-shadow: inset 2px 0 0 #22c55e; }
1334.node-row .node-tk { cursor: pointer; }
1335.node-row:hover .node-tk { text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(var(--t-dim-rgb,148,163,184),.5); }
1336.scan-del { color: var(--dim); cursor: pointer; padding: 0 6px; }
1337.scan-del:hover { color: var(--red); }
1338@keyframes demoFlash { 0%,100% { box-shadow: 0 0 0 0 rgba(var(--t-accent-rgb,59,130,246),0.7); } 50% { box-shadow: 0 0 14px 3px rgba(var(--t-accent-rgb,59,130,246),0.9); } }
1339/* Seamless right-to-left marquee (demo strip on mobile after the banner hides) */
1340@keyframes gfMarquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
1341#demoMarquee span { display: inline-block; white-space: nowrap; will-change: transform; animation: gfMarquee 24s linear infinite; }
1342
1343/* ═══════════════ Responsive: tablet + mobile ═══════════════ */
1344@media (max-width: 1024px) {
1345  .header-right { gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
1346  .view-toggle, .refresh-btn { padding: 5px 8px; font-size: 11px; }
1347  #chartGexPanel { width: 200px; }
1348  .gex-col { flex: 1 0 calc(100% / 4); min-width: calc(100% / 4); }   /* 4 columns on tablet */
1349  #scanAlertsFeed { width: 230px; }
1350}
1351@media (max-width: 760px) {
1352  #scanAlertsFeed { display: none !important; }   /* hide the alerts column on phones — table needs the room */
1353}
1354@media (max-width: 640px) {
1355  .header { padding: 8px 10px; gap: 6px; flex-wrap: wrap; }
1356  /* All toolbar controls (GEX/VEX + the 5 view toggles + refresh/discord/etc.) become ONE
1357     horizontal swipeable row instead of wrapping into many rows that eat the screen. */
1358  .header-right { width: 100%; gap: 8px; flex-wrap: nowrap !important; overflow-x: auto; overflow-y: hidden; white-space: nowrap; -webkit-overflow-scrolling: touch; justify-content: flex-start; padding-bottom: 4px; }
1359  .header-right > * { flex: 0 0 auto !important; }
1360  .header-right::-webkit-scrollbar { height: 4px; }
1361  #searchTicker, #customTicker { width: 120px !important; }
1362  .view-toggle { flex: 0 0 auto !important; text-align: center; padding: 7px 11px; font-size: 12px; }
1363  .refresh-btn { padding: 7px 9px; }
1364
1365  /* Views keep the DESKTOP horizontal layout on mobile — swipe sideways through columns/
1366     panels, scroll vertically inside each. A safety body scroll prevents anything being cut
1367     off if the header is taller than measured. */
1368  body { overflow-y: auto; -webkit-overflow-scrolling: touch; }
1369  /* Keep the toolbar (with the GEX / Heatmap / Charts / Ultra / Scanner tabs) pinned at
1370     the top on EVERY view so you never have to scroll up to find it again. The header's
1371     own top offset is managed in JS (sits below the demo banner when present). */
1372  .header { position: sticky !important; top: 0; z-index: 150; background: var(--bg); transition: top .25s ease; }
1373  /* Sector tabs = one horizontal swipeable row (no more 4-row wrap eating the screen). */
1374  .tabs-bar { position: static !important; top: auto !important; flex-wrap: nowrap !important; overflow-x: auto !important; overflow-y: hidden; -webkit-overflow-scrolling: touch; white-space: nowrap; padding: 8px 10px; }
1375  .tabs-bar > * { flex: 0 0 auto !important; }
1376  .tabs-bar::-webkit-scrollbar { height: 4px; }
1377
1378  /* Every view fills the screen below the compact header */
1379  .columns-container, #expiryView, #ultraView, #scannerView, #chartView { height: calc(100dvh - var(--header-h, 110px)) !important; transition: height .25s ease; }
1380
1381  /* GEX view: columns stay in a horizontal ROW — swipe sideways; strikes scroll vertically */
1382  .columns-container { flex-direction: row !important; overflow-x: auto !important; overflow-y: hidden !important; scroll-snap-type: x mandatory; }
1383  .gex-col { flex: 0 0 74vw !important; min-width: 74vw !important; max-width: 74vw !important; scroll-snap-align: start; border-right: 1px solid var(--border); }
1384  .strikes-list { overflow-y: auto !important; }
1385
1386  /* GEX charts: hide the docked GEX panel so candles fill the screen
1387     (King / Call Wall / Put Wall / Vol Node still draw as price lines). */
1388  #chartGexPanel { display: none; }
1389  #chartHeader { flex-wrap: wrap; gap: 6px; padding: 6px 8px; }
1390  #chartHeader .tf-btn { padding: 3px 6px; font-size: 10px; }
1391
1392  /* Ultra mode (phone): a horizontal strip — compact GEX → Heatmap (≈3-4 expiries, the
1393     rest scroll inside it) → Flow → News. GEX is narrow so the heatmap peeks alongside it,
1394     hinting you can swipe right. Snap is "proximity" so it doesn't fight the heatmap's own
1395     inner horizontal scroll. Desktop drag handles are hidden. */
1396  #ultraView { overflow: hidden !important; }
1397  #ultraView > div:last-child { overflow-x: auto !important; -webkit-overflow-scrolling: touch; scroll-snap-type: x proximity; }
1398  #ultraView .resize-handle { display: none !important; }
1399  #ultraGex { width: 50vw !important; min-width: 50vw !important; flex: 0 0 auto !important; scroll-snap-align: start; }
1400  #ultraExpiry { width: 80vw !important; min-width: 80vw !important; flex: 0 0 auto !important; overflow: auto !important; scroll-snap-align: start; }
1401  /* Give each expiry a real column width so ~3-4 are readable and the rest scroll right,
1402     instead of every expiry squishing to fit the panel (the table is width:100% normally). */
1403  #ultraExpiry .exp-table { width: max-content !important; }
1404  #ultraExpiry .exp-table th, #ultraExpiry .exp-table td { min-width: 56px; }
1405  #ultraExpiry .exp-table th:first-child, #ultraExpiry .exp-table td:first-child { min-width: 42px !important; width: 42px !important; }
1406  #ultraFlow { width: 90vw !important; min-width: 90vw !important; flex: 0 0 auto !important; scroll-snap-align: start; }
1407
1408  /* Wide cards / modals fit the screen */
1409  [style*="width:520px"], [style*="width: 520px"], [style*="width:380px"] { max-width: 92vw !important; }
1410  .tabs-bar { padding: 8px 10px; }
1411
1412  /* Header dropdowns (menu / bell / discord) live inside the horizontally-scrollable
1413     toolbar, whose overflow clips them on mobile. Switch them to fixed (viewport-anchored)
1414     so they float above everything instead of being hidden under the toolbar. */
1415  #userMenu, #alertPanel, #discordMenu {
1416    position: fixed !important;
1417    top: calc(var(--header-h, 110px) + 6px) !important;
1418    right: 8px !important;
1419    left: auto !important;
1420    max-width: 92vw !important;
1421    max-height: 72vh !important;
1422    overflow-y: auto !important;
1423    z-index: 100000 !important;
1424  }
1425}
1426
1427/* ═══════════ PHONE SHELL (user 2026-09-09) ═══════════
1428   THE BUG THIS FIXES: the block above has zero references to the left rail or the dock, so on a
1429   phone body[data-nav=left] kept its fixed padding-left:74px - 74px of a 375px screen - and
1430   #indicatorPanel, which is position:absolute;
1430left:0, opened anchored to a control that was already
1431   off the viewport. Users were not failing to FIND the modes; the modes were off screen.
1432   ("users not able to see left side modes")
1433
1434   The shape: the rail becomes a five-slot bottom dock - Grid, Heatmap, Ghost, Flow, More - and the
1435   Modes panel becomes a sheet that comes up from the bottom over the content. Nothing on a phone is
1436   anchored to a screen edge a thumb cannot reach any more.
1437
1438   Rail/dock neutralising below is belt-and-braces: gfNavApply refuses anything but 'top' on a phone,
1439   so the rail is never built on a phone load. These rules only matter to a desktop window dragged
1440   narrow, where the rail already exists. */
1441@media (max-width: 640px) {
1442  /* --gf-dock-h is divided by the zoom so the dock's PHYSICAL height stays constant while the rest of
1443     the app scales - the nav chrome must not shrink/grow with the zoom (user 2026-09-20). The view
1444     height calc below subtracts the same (layout-px) value, so views still stop exactly at the dock. */
1445  body.gf-phone { --gf-dock-h: calc((54px + env(safe-area-inset-bottom, 0px)) / var(--gfz, 1)); }
1446
1447  body.gf-phone[data-nav=left] { padding-left: 0 !important; }
1448  body.gf-phone #gfRailLogo, body.gf-phone #gfRailViews, body.gf-phone #gfRailTools,
1449  body.gf-phone #gfRailBottom, body.gf-phone #gfRailHot, body.gf-phone #gfRailEdge { display: none !important; }
1450  body.gf-phone[data-nav=dock] #gfNavGroup { position: static !important; transform: none !important; box-shadow: none !important; border: 0 !important; background: none !important; padding: 0 !important; }
1451  /* The nine view tabs live in the dock now. They stay in the DOM - setView still paints .active on
1452     them and the dock reads that back - they just stop eating the scrolling toolbar.
1453     CLIPPED, NOT display:none. The dock has to ask "is this view available to this user?", and the
1454     app answers that question in TWO ways: an inline display:none for the premium gates, and a
1455     stylesheet rule for the layout gates (body.gf-newlayout #btn-ultra). A computed display of none
1456     is the only test that sees both - and hiding these with display:none here would make every tab
1457     read as unavailable, which is exactly the bug this replaced: the dock picked btn-ultra for the
1458     Ghost slot while the live "Ghost Mode" button was btn-chart. */
1459  body.gf-phone .view-toggle {
1460    position: absolute !important; width: 1px !important; height: 1px !important; min-width: 0 !important;
1461    padding: 0 !important; margin: 0 !important; border: 0 !important; opacity: 0 !important;
1462    overflow: hidden !important; pointer-events: none !important;
1463  }
1464
1465  /* Every view stops short of the dock instead of running under it. */
1466  body.gf-phone .columns-container, body.gf-phone #expiryView, body.gf-phone #ultraView,
1467  body.gf-phone #scannerView, body.gf-phone #chartView, body.gf-phone #flowView,
1468  body.gf-phone #smartView, body.gf-phone #ideasView, body.gf-phone #trackingView {
1469    height: calc(100dvh - var(--header-h, 110px) - var(--gf-dock-h, 54px)) !important;
1470  }
1471
1472  /* Grid columns: a FIXED width (not 74vw) so the zoom actually changes HOW MANY columns fit -
1473     zoom out (wider layout viewport) fits more, zoom in fits fewer. 74vw stayed ~1.3 per screen at
1474     any zoom, so zooming only ever widened the same column. (user 2026-09-20) */
1475  body.gf-phone .gex-col {
1476    flex: 0 0 205px !important; min-width: 205px !important; max-width: 205px !important;
1477  }
1478
1479  /* The Grid + Heatmap expiry/ticker/settings bars scroll sideways instead of wrapping/clipping
1480     (user 2026-09-20). Grid: #gridExpiryBar, #gexTickers. Heatmap: #expiryTickers, #heatSetRow. */
1481  body.gf-phone #gridExpiryBar, body.gf-phone #gexTickers,
1482  body.gf-phone #expiryTickers, body.gf-phone #heatSetRow {
1483    flex-wrap: nowrap !important; overflow-x: auto !important; overflow-y: hidden !important;
1484    white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none;
1485  }
1486  body.gf-phone #gridExpiryBar::-webkit-scrollbar, body.gf-phone #gexTickers::-webkit-scrollbar,
1487  body.gf-phone #expiryTickers::-webkit-scrollbar, body.gf-phone #heatSetRow::-webkit-scrollbar { display: none; }
1488  body.gf-phone #gridExpiryBar > *, body.gf-phone #gexTickers > *,
1489  body.gf-phone #expiryTickers > *, body.gf-phone #heatSetRow > * { flex: 0 0 auto !important; }
1490
1491  /* Heatmap: keep the sticky left strike column SOLID and ABOVE the scrolling cells, so the expiry
1492     rows don't show through / beside it when swiped sideways (user 2026-09-20). The strike column was
1493     read-through because the colored cells stacked over its z-index:3, and the spot row's strike cell
1494     is a semi-transparent accent tint. */
1495  body.gf-phone .exp-table th:first-child,
1496  body.gf-phone .exp-table td:first-child {
1497    background-color: var(--bg, #07080c) !important; z-index: 8 !important;
1498  }
1499  body.gf-phone .exp-table tr.spot-row td:first-child {
1500    background-color: var(--bg, #07080c) !important;
1501    box-shadow: inset 0 0 0 999px rgba(var(--t-accent-rgb, 59,130,246), 0.28);   /* keep the accent tint over an opaque base */
1502  }
1503  /* The heatmap scroll box had 20px side padding, so the sticky 
1503strike column pinned 20px IN from the
1504     edge and cells scrolled through the gap on its left. Flush it to the edge on phone. (user 2026-09-20) */
1505  body.gf-phone .expiry-content { padding-left: 0 !important; padding-right: 0 !important; }
1506
1507  /* GHOST MODE phone (user 2026-09-20): the Flow panel must never eat the chart. The earlier
1508     phone rule (#ultraFlow{width:90vw}) is the swipe-carousel width for the OTHER views, but in
1509     Ghost Mode it also applied to the MINIMIZED flow panel, squeezing the candle chart to ~40px
1510     ("right flow panel still shows up when minimized, spx line won't scroll"). Minimized = a thin
1511     34px restore rail pinned to the row's right edge; the chart takes the rest. */
1512  body.gf-phone #chartRow > #ultraFlow.nf-panel-min {
1513    width: 34px !important; min-width: 34px !important; max-width: 34px !important; flex: 0 0 34px !important;
1514  }
1515  body.gf-phone #chartRow > #chartContent { flex: 1 1 auto !important; min-width: 0 !important; }
1516
1517  /* When the ticker strip is hidden on a phone, its restore control ("Tickers ▾") otherwise parks
1518     in the level-strip's right section, which runs off the right edge of a phone - so the user had
1519     no way back ("hide it n dont know where is back button", user 2026-09-20). Pin the parked
1520     control (.inline = hidden state) to a fixed, thumb-reachable pill above the dock. */
1521  body.gf-phone #gfTkCtl.inline {
1522    position: fixed !important; left: 8px !important; right: auto !important;
1523    bottom: calc(var(--gf-dock-h, 54px) + 10px) !important; top: auto !important;
1524    z-index: 9500 !important; background: var(--t-card, #0f172a) !important;
1525    border: 1px solid var(--t-accent, #d4af37) !important; border-radius: 8px !important;
1526    padding: 4px 6px !important; box-shadow: 0 3px 12px rgba(0, 0, 0, .5) !important;
1527  }
1528
1529  /* The Ghost Mode level strip is a 3-column grid locked to a desktop width (~1135px): symbol/price
1530     + King/Call-Wall/Put-Wall/Vol-Node chips + trend/NET-GEX badges. On a phone it ran far off the
1531     right edge, so scrolling dragged the whole level row (and the chart) sideways ("levels going
1532     left or right on scroll", user 2026-09-20). On a phone it becomes ONE tidy row that scrolls
1533     WITHIN ITSELF (overflow-x:auto, contained to the screen width) - swipe the strip to reach
1534     every level; the chart never moves. (user picked "compact single scroll".) */
1535  body.gf-phone #chartLevelsBar {
1536    display: flex !important; flex-wrap: nowrap !important; width: 100% !important; max-width: 100% !important;
1537    align-items: center !important; gap: 6px !important;
1538    overflow-x: auto !important; overflow-y: hidden !important; -webkit-overflow-scrolling: touch;
1539    scrollbar-width: none;
1540  }
1541  body.gf-phone #chartLevelsBar::-webkit-scrollbar { display: none; }
1542  body.gf-phone #chartLevelsBar > #gfLvlLeft,
1543  body.gf-phone #chartLevelsBar > #gfLvlMid,
1544  body.gf-phone #chartLevelsBar > #gfLvlRight {
1545    width: auto !important; flex: 0 0 auto !important; min-width: 0 !important;
1546  }
1547  body.gf-phone #gfLvlLeft, body.gf-phone #gfLvlMid, body.gf-phone #gfLvlRight {
1548    flex-wrap: nowrap !important; gap: 6px !important;
1549  }
1550
1551  /* ── the dock ── */
1552  body.gf-phone #gfPhoneDock {
1553    display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 9000;
1554    height: var(--gf-dock-h, 54px); padding-bottom: env(safe-area-inset-bottom, 0px);
1555    background: var(--t-card, #0f172a); border-top: 1px solid var(--border);
1556    box-shadow: 0 -6px 20px rgba(0, 0, 0, .45);
1557  }
1558  body.gf-phone #gfPhoneDock button {
1559    flex: 1 1 0; min-width: 0; position: relative; border: 0; background: none; cursor: pointer;
1560    font-weight: 700; font-family: 'Segoe UI', system-ui, sans-serif; line-height: 1; letter-spacing: .3px;
1561    font-size: calc(11px / var(--gfz, 1));   /* counter-scale so dock text stays a constant physical size */
1562    color: #cbd5e1;   /* bright, never dim grey */
1563    padding: 0 2px; display: flex; align-items: center; justify-content: center;
1564  }
1565  body.gf-phone #gfPhoneDock button.on { color: #ffffff; }
1566  /* the active slot is marked by a bar across the top of the tile, so no icon has to carry it */
1567  body.gf-phone #gfPhoneDock button.on:before {
1568    content: ""; position: absolute; top: 0; left: 14%; right: 14%; height: 3px; border-radius: 0 0 3px 3px;
1569    background: var(--t-accent, #d4af37);
1570  }
1571
1572  /* ── sheets: the Modes panel, and the More list ── */
1573  body.gf-phone #indicatorPanel {
1574    position: fixed !important; left: 0 !important; right: 0 !important; top: auto !important;
1575    bottom: var(--gf-dock-h, 54px) !important; width: auto !important;
1576    min-width: 0 !important; max-width: none !important; max-height: 64vh !important;
1577    border-radius: 16px 16px 0 0 !important; border-left: 0 !important; border-right: 0 !important;
1578    border-bottom: 0 !important; z-index: 9500 !important; padding: 12px 14px 16px !important;
1579    box-shadow: 0 -14px 34px rgba(0, 0, 0, .6) !important;
1580  }
1581  body.gf-phone #indicatorPanel:before {
1582    content: ""; display: block; width: 38px; height: 4px; border-radius: 3px;
1583    background: var(--t-border3, #3d4d6b); margin: 0 auto 10px;
1584  }
1585
1586  body.gf-phone #gfPhoneMore {
1587    display: none; position: fixed; left: 0; right: 0; bottom: var(--gf-dock-h, 54px); z-index: 9400;
1588    background: var(--t-card, #0f172a); border-top: 1px solid var(--border);
1589    border-radius: 16px 16px 0 0; padding: 12px 14px 16px; max-height: 64vh; overflow-y: auto;
1590    box-shadow: 0 -14px 34px rgba(0, 0, 0, .6);
1591  }
1592  body.gf-phone #gfPhoneMore.on { display: block; }
1593  body.gf-phone #gfPhoneMore .grab { width: 38px; height: 4px; border-radius: 3px; background: var(--t-border3, #3d4d6b); margin: 0 auto 12px; }
1594  body.gf-phone #gfPhoneMore .r {
1595    display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; cursor: pointer;
1596    padding: 12px; margin-bottom: 7px; border-radius: 10px;
1597    border: 1px solid var(--t-border2, #2e323d); background: var(--t-bg2, #0b1220);
1598    color: #e2e8f0; font: 700 13px/1.2 'Segoe UI', system-ui, sans-serif;
1599  }
1600  body.gf-phone #gfPhoneMore .r.on { border-color: var(--t-accent, #d4af37); color: #ffffff; }
1601  body.gf-phone #gfPhoneMore .r .sub { margin-left: auto; font-size: 10.5px; font-weight: 600; color: #94a3b8; }
1602  /* Zoom control row (user 2026-09-20): one persistent whole-app zoom, set here, works on every mode */
1603  body.gf-phone #gfPhoneMore .gfz-row {
1604    display: flex; align-items: center; gap: 10px; padding: 10px 12px; margin-bottom: 10px;
1605    border-radius: 10px; border: 1px solid var(--t-accent, #d4af37); background: var(--t-bg2, #0b1220);
1606  }
1607  body.gf-phone #gfPhoneMore .gfz-lbl { font: 800 13px/1 'Segoe UI', system-ui, sans-serif; color: #ffffff; }
1608  body.gf-phone #gfPhoneMore .gfz-b {
1609    flex: 0 0 auto; width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
1610    border: 1px solid var(--t-border2, #2e323d); background: var(--t-card, #0f172a);
1611    color: #ffffff; font: 800 18px/1 'Segoe UI', system-ui, sans-serif; display: flex; align-items: center; justify-content: center;
1612  }
1613  body.gf-phone #gfPhoneMore .gfz-slider { flex: 1 1 auto; min-width: 0; accent-color: var(--t-accent, #d4af37); height: 26px; }
1614  body.gf-phone #gfPhoneMore .gfz-val { flex: 0 0 auto; min-width: 42px; text-align: right; font: 800 13px/1 'Segoe UI', system-ui, sans-serif; color: var(--t-accent, #d4af37); }
1615  body.gf-phone #gfPhoneBackdrop { display: none; position: fixed; inset: 0; z-index: 9300; background: rgba(0, 0, 0, .45); }
1616  body.gf-phone #gfPhoneBackdrop.on { display: block; }
1617
1618  /* ── Phone side-panel drawers (Flow filters / Pulse alerts / Node alerts) ──
1619     Each side panel becomes a slide-in drawer OVER the main content so it never eats the screen
1620     width; an edge tab toggles it; minimized by default. (user 2026-09-20) */
1621  body.gf-phone .gf-sidewrap { position: relative; }
1622  body.gf-phone .gf-side-drawer {
1623    position: absolute !important; top: 0; bottom: 0; flex: none !important; z-index: 24;
1624    max-width: 86vw; box-shadow: 0 0 26px rgba(0, 0, 0, .55); transition: transform .2s ease;
1625  }
1626  body.gf-phone .gf-side-drawer.gf-side-left { left: 0; }
1627  body.gf-phone .gf-side-drawer.gf-side-right { right: 0; }
1628  body.gf-phone .gf-side-drawer.gf-sidemin.gf-side-left { transform: translateX(-101%); }
1629  body.gf-phone .gf-side-drawer.gf-sidemin.gf-side-right { transform: translateX(101%); }
1630  /* Pulse alerts feed is force-hidden <=760px by an older rule; as a drawer it must render (it is
1631     pushed off-screen by the transform when minimized, not display:none). */
1632  body.gf-phone #scanAlertsFeed.gf-side-drawer { display: flex !important; }
1633  /* the always-visible edge handle */
1634  body.gf-phone .gf-side-tab {
1635    position: absolute; top: 50%; transform: translateY(-50%); z-index: 25;
1636    min-height: 66px; width: 22px; padding: 8px 0; cursor: pointer;
1637    border: 1px solid var(--t-accent, #d4af37); background: var(--t-card, #0f172a); color: var(--t-accent, #d4af37);
1638    font: 800 10px/1 'Segoe UI', system-ui, sans-serif; letter-spacing: .5px;
1639    display: flex; align-items: center; justify-content: center;
1640    writing-mode: vertical-rl; text-orientation: mixed;
1641  }
1642  body.gf-phone .gf-side-tab.gf-side-left { border-left: 0; border-radius: 0 8px 8px 0; }
1643  body.gf-phone .gf-side-tab.gf-side-right { border-right: 0; border-radius: 8px 0 0 8px; }
1644  /* Open state = a solid accent CLOSE handle, so it can't be missed against the drawer behind it. */
1645  body.gf-phone .gf-side-tab.gf-side-tab-open { background: var(--t-accent, #d4af37); color: #1a1200; min-height: 74px; }
1646}
1647/* Hover "?" hint used in the Help guide — shows a how-to-trade tooltip on hover. */
1648.gf-hint { display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; margin-left:5px; border-radius:50%; border:1px solid var(--border); color:var(--dim); font-size:10px; font-weight:800; cursor:help; position:relative; vertical-align:middle; user-select:none; }
1649.gf-hint:hover { color:#fff; border-color:var(--t-accent,#3b82f6); }
1650.gf-hint::after { content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%); width:320px; max-width:78vw; background:var(--t-bg2,#0b1220); color:var(--t-muted,#cbd5e1); border:1px solid #24344d; border-radius:calc(8px*var(--t-rk,1)); padding:10px 12px; font-size:11.5px; font-weight:400; line-height:1.55; text-align:left; white-space:normal; box-shadow:0 10px 30px rgba(0,0,0,.55); opacity:0; visibility:hidden; transition:opacity .12s; z-index:10001; pointer-events:none; }
1651.gf-hint:hover::after { opacity:1; visibility:visible; }
1652.gf-hint.left::after { left:auto; right:0; transform:none; }
1653
1654/* ───────── Theme test (admin, 2026-09-05). No data-theme => every --t-* falls back to the classic color. ───────── */
1655html[data-theme=phantom]{--t-bg:#000000;--t-bg2:#050505;--t-card:#080808;--t-hover:#0f0f10;--t-line2:#151517;--t-border:#1c1c1f;--t-border-rgb:28,28,31;--t-border2:#262629;--t-border3:#333338;--t-text:#ffffff;--t-muted:#d4d4d8;--t-dim:#8a8a94;--t-dim-rgb:138,138,148;--t-dim2:#5c5c66;--t-accent:#e5e7eb;--t-accent-rgb:229,231,235;--t-accent2:#a1a1aa;--t-accentHi:#ffffff;--t-accentHi-rgb:255,255,255;--t-cyan:#e5e7eb;--t-cyan-rgb:229,231,235;--t-onAccent:#000000;--t-rk:1.1}
1656html[data-theme=wraith]{--t-bg:#03060a;--t-bg2:#060a11;--t-card:#080d15;--t-hover:#0d141f;--t-line2:#111a26;--t-border:#172230;--t-border-rgb:23,34,48;--t-border2:#1f2d40;--t-border3:#2a3a50;--t-text:#f0f9ff;--t-muted:#cfe3f3;--t-dim:#7f96ad;--t-dim-rgb:127,150,173;--t-dim2:#55677c;--t-accent:#7dd3fc;--t-accent-rgb:125,211,252;--t-accent2:#38bdf8;--t-accentHi:#bae6fd;--t-accentHi-rgb:186,230,253;--t-cyan:#bae6fd;--t-cyan-rgb:186,230,253;--t-onAccent:#04202e;--t-rk:1}
1657html[data-theme=sunset]{--t-bg:#0f0a14;--t-bg2:#150d1c;--t-card:#1a1022;--t-hover:#22152c;--t-line2:#2a1a35;--t-border:#33203f;--t-border-rgb:51,32,63;--t-border2:#422a50;--t-border3:#553868;--t-text:#fff7ed;--t-muted:#fde7d3;--t-dim:#b895a8;--t-dim-rgb:184,149,168;--t-dim2:#7d6275;--t-accent:#fb7185;--t-accent-rgb:251,113,133;--t-accent2:#f43f5e;--t-accentHi:#fb923c;--t-accentHi-rgb:251,146,60;--t-cyan:#fb923c;--t-cyan-rgb:251,146,60;--t-onAccent:#2a0a12;--t-rk:1.4}
1658html[data-theme=gold]{--t-bg:#07080c;--t-bg2:#0b0d13;--t-card:#0e1017;--t-hover:#14171f;--t-line2:#1a1d26;--t-border:#23262f;--t-border-rgb:35,38,47;--t-border2:#2e323d;--t-border3:#3c414e;--t-text:#f5f0e6;--t-muted:#d6cfc0;--t-dim:#8f8a7c;--t-dim-rgb:143,138,124;--t-dim2:#5f5b50;--t-accent:#d4af37;--t-accent-rgb:212,175,55;--t-accent2:#c9a227;--t-accentHi:#e9d38a;--t-accentHi-rgb:233,211,138;--t-cyan:#e9d38a;--t-cyan-rgb:233,211,138;--t-onAccent:#1a1200;--t-rk:0.9}
1659html[data-theme=phantom] .logo .c1{color:#fff;text-shadow:0 0 14px rgba(255,255,255,.55)}
1660html[data-theme=wraith] .logo .c1{color:#e0f2fe;text-shadow:0 0 12px rgba(186,230,253,.6)}
1661html[data-theme=sunset] .logo .c1{color:#fb7185}
1662html[data-theme=gold] .logo .c1{color:#e9d38a;letter-spacing:.8px}
1663html[data-theme=gold] .header{border-bottom-color:rgba(212,175,55,.35)}
1664html[data-theme] .view-toggle.active{color:var(--t-onAccent,#0a0e17)}
1665html[data-theme] .tab.active,html[data-theme] .metric-btn.active,html[data-theme] .gexp-btn.active,html[data-theme] .flow-chip.active,html[data-theme] .flow-tk.active,html[data-theme] .flow-fbtn.active,html[data-theme] .scan-supertab.active,html[data-theme] .fl-cta,html[data-theme] .demo-lock-btn{color:var(--t-onAccent,#fff)}   /* light accents (Phantom white, Wraith ice, Gold): dark text on the filled state, like the view tabs (user 2026-09-05) */
1666/* Menu-bar layouts (admin test): body[data-nav=center|left|dock]. #gfNavGroup holds the 9 view buttons. */
1667#gfNavGroup{display:contents}
1668body[data-nav=center] .header{position:sticky}
1669body[data-nav=center] #gfNavGroup{display:flex;align-items:center;gap:4px;margin:0 auto;flex-wrap:nowrap}
1670body[data-nav=left]{padding-left:74px;transition:padding-left .35s ease}
1671/* Auto-hide rail, option B (user 2026-09-07): slides off after 5 s idle, the page reclaims the width, a 4 px accent edge
1672   stays as the handle; the left 22 px of the window (or the rail itself) wakes it. Admin only until approved. */
1673body[data-nav=left].gf-rail-hid{padding-left:0}
1674body[data-nav=left] #gfNavGroup{transition:transform .35s ease}
1675body[data-nav=left].gf-rail-hid #gfNavGroup{transform:translateX(-76px)}
1676#gfRailHot,#gfRailEdge{display:none}
1677body[data-nav=left].gf-rail-auto #gfRailHot{display:block;position:fixed;left:0;top:0;bottom:0;width:22px;z-index:100}
1678body[data-nav=left].gf-rail-hid #gfRailEdge{display:block;position:fixed;left:0;top:0;bottom:0;width:4px;z-index:99;pointer-events:none;background:linear-gradient(180deg,transparent,rgba(var(--t-accent-rgb,212,175,55),.75) 30%,rgba(var(--t-accent-rgb,212,175,55),.75) 70%,transparent)}
1679body[data-nav=left] #gfNavGroup{position:fixed;left:0;top:0;bottom:0;width:74px;display:flex;flex-direction:column;align-items:center;gap:6px;padding:12px 0;background:var(--card);border-right:1px solid var(--border);z-index:101;overflow-y:auto;overflow-x:visible;scrollbar-width:none}
1680body[data-nav=left] #gfNavGroup::-webkit-scrollbar{display:none}
1681body[data-nav=left] #gfNavGroup{padding-top:0;padding-bottom:0}
1682#gfRailLogo,#gfRailBottom{display:none}
1683/* Hide the rail's view tiles + tools until gfRailTop has measured the real offset, so they appear
1684   already in the right place instead of painting at the fallback offset and jumping (user 2026-09-17).
1685   The logo stays visible (it never moves); a safety timeout adds gf-rail-ready even on views with no
1686   ticker bar so the rail can never stay hidden. */
1687body[data-nav=left]:not(.gf-rail-ready) #gfRailViews,
1688body[data-nav=left]:not(.gf-rail-ready) #gfRailTools{opacity:0}
1688
1689body[data-nav=left].gf-rail-ready #gfRailViews,
1690body[data-nav=left].gf-rail-ready #gfRailTools{opacity:1;transition:opacity .15s ease}
1691body[data-nav=left] #gfRailLogo{display:flex;align-items:center;justify-content:center;width:100%;height:58px;flex:none}   /* no divider under the logo (user 2026-09-06) */
1692body[data-nav=left] #gfRailLogo .logo{font-size:0;line-height:0}
1693body[data-nav=left] #gfRailWord{font-size:17px;letter-spacing:.6px;white-space:nowrap;margin-right:auto}
1694body[data-nav=left] .header-right #tzBadge{margin-left:0!important}
1695body[data-nav=left] #gfRailLogo .logo svg{width:30px;height:30px;margin:0!important;vertical-align:middle}
1696body[data-nav=left] #gfRailViews{display:flex;flex-direction:column;align-items:center;gap:6px;width:100%;margin-top:calc(var(--gf-rail-top,128px) - 58px);flex:none}
1697body[data-nav=left] #gfRailTools .metric-toggle{border:1px solid var(--border);border-radius:8px;padding:3px;background:var(--t-bg2,#0b1220);gap:2px}
1698body[data-nav=left] #gfRailTools .metric-btn{border:0;border-radius:5px;width:54px}
1699body[data-nav=left] #gfRailBottom{display:flex;flex-direction:column;align-items:center;gap:8px;width:100%;margin-top:auto;padding:10px 0 12px;border-top:1px solid var(--border);flex:none;position:sticky;bottom:0;background:var(--card);z-index:3}   /* sticky so the avatar + account menu stay visible even when the rail overflows a short window (user 2026-09-17) */
1700body[data-nav=left] #gfRailBottom > div{position:relative;display:flex;justify-content:center;width:100%;margin-left:0!important}
1701body[data-nav=left] #gfRailBottom > div > button{width:62px;font-size:14px;padding:4px 2px;margin-left:0!important}
1702#gfRailAvatar{width:34px;height:34px;border-radius:50%;border:0;background:linear-gradient(135deg,var(--cyan),var(--purple));color:#fff;font:800 14px 'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;cursor:pointer}
1703body[data-nav=left] #userMenu{position:fixed!important;left:80px!important;right:auto!important;top:auto!important;bottom:12px!important}
1704#gfRailTools{display:none}
1705body[data-nav=left] #gfRailTools{display:flex;flex-direction:column;align-items:center;gap:12px;width:100%;margin-top:8px;padding-top:12px;border-top:1px solid var(--border)}   /* user 2026-09-06: more air between the GEX box and the tool buttons */
1706body[data-nav=left] #gfRailTools .metric-toggle{display:flex;flex-direction:column;gap:5px;margin:0 0 6px}
1707body[data-nav=left] #gfRailTools .metric-btn{width:62px;font-size:9.5px;padding:4px 0;margin:0}
1708body[data-nav=left] #gfRailTools .refresh-btn{width:62px;font-size:9.5px;padding:4px 2px;white-space:normal;line-height:1.15;margin-left:0!important}
1709body[data-nav=left] #gfRailTools > div{position:relative;display:flex;justify-content:center;width:100%;margin-left:0!important}
1710body[data-nav=left] #gfRailTools > div > button{width:62px;font-size:9.5px;padding:4px 2px;white-space:normal;line-height:1.15;margin-left:0!important}
1711body[data-nav=left] #gfRailTools #alertPanel{left:68px;right:auto;top:0}
1712body[data-nav=left] #gfNavGroup .view-toggle{width:62px;height:46px;padding:4px 3px;font-size:9.5px;line-height:1.15;white-space:normal;text-align:center;display:flex;align-items:center;justify-content:center;border-radius:8px}
1713body[data-nav=left] #gfNavGroup .view-toggle:not(.active){border-color:transparent;color:var(--dim)}
1714body[data-nav=left] #gfNavGroup .view-toggle:not(.active):hover{border-color:var(--border);color:var(--text)}
1715/* dock: NO body padding - it made the document 58px taller than the viewport, the page sat scrolled by exactly the header height with scrolling locked, and every strip slid under the sticky header (user 2026-09-05). The fitted views subtract gfDockH() instead. */
1716body[data-nav=dock] #gfNavGroup{position:fixed;left:50%;bottom:12px;transform:translateX(-50%);display:flex;align-items:center;gap:4px;padding:6px;background:var(--card);border:1px solid var(--border);border-radius:12px;z-index:101;box-shadow:0 10px 30px rgba(0,0,0,.5)}
1717body[data-nav=dock] #gfNavGroup .view-toggle{height:28px}
1718body[data-nav=dock] #gfNavGroup.gf-dock-min .view-toggle:not(.active){display:none!important}   /* idle 15s -> only the current view tile; hover/click restores */
1719body[data-nav=dock] #gfNavGroup.gf-dock-min{opacity:.28}   /* collapsed to the one active tile: stays faded until hovered (hover exp
1719ands + restores) - user 2026-09-05 */
1720body[data-nav=dock] #gfNavGroup{transition:opacity .25s}
1721body[data-nav=dock] #gfNavGroup.gf-dock-fade{opacity:.12}   /* opacity only - the mouse must still reach it so hovering un-fades it at once */   /* scrolling under it: fade out, restore 700ms after the last scroll */
1722body[data-nav=dock] #gfNavGroup .view-toggle:not(.active){border-color:transparent;color:var(--dim)}
1723body[data-nav=dock] #gfNavGroup .view-toggle:not(.active):hover{border-color:var(--border);color:var(--text)}
1724/* account-menu Theme section (user 2026-09-06) */
1725#gfThemeMenu .gf-tm-h{font-size:10px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--t-accentHi,#7dd3fc);margin-bottom:8px}
1726#gfThemeMenu .gf-tm-sw,#gfThemeMenu .gf-tm-lay{display:flex;gap:6px}
1727#gfThemeMenu button{flex:1;border:1px solid var(--t-border2,#2e323d);border-radius:8px;padding:6px 4px;text-align:center;font-size:11px;font-weight:700;color:var(--t-muted,#cbd5e1);background:var(--t-card,#0f172a);cursor:pointer;font-family:inherit;position:relative}
1728#gfThemeMenu button:hover{border-color:var(--t-accent,#3b82f6);color:var(--text)}
1729#gfThemeMenu button.on{border-color:var(--t-accent,#d4af37);color:var(--text);box-shadow:0 0 0 1px var(--t-accent,#d4af37) inset}
1730#gfThemeMenu .gf-tm-s .d{display:block;height:18px;border-radius:5px;margin-bottom:6px;border:1px solid var(--t-border2,#2e323d)}
1731#gfThemeMenu .gf-tm-s.on:after{content:"";position:absolute;right:6px;top:6px;width:8px;height:8px;border-radius:50%;background:var(--t-accent,#d4af37)}
1732#gfThemeMenu .gf-tm-l .ic{display:block;height:22px;margin-bottom:5px;border-radius:4px;background:var(--t-bg,#0a0e17);border:1px solid var(--t-border,#23262f);position:relative;overflow:hidden}
1733#gfThemeMenu .gf-tm-l .ic:before{content:"";position:absolute;background:var(--t-accent,#d4af37);opacity:.9}
1734#gfThemeMenu .gf-tm-l.c .ic:before{left:30%;right:30%;top:3px;height:4px;border-radius:2px}
1735#gfThemeMenu .gf-tm-l.r .ic:before{left:3px;top:3px;bottom:3px;width:6px;border-radius:2px}
1736#gfThemeMenu .gf-tm-l.d .ic:before{left:30%;right:30%;bottom:3px;height:4px;border-radius:2px}
1737body[data-nav=left] #userMenu{min-width:280px}
1738/* Ghost Mode compact rows (admin test) */
1739#gfHdrChartCtl{position:absolute;top:6px;left:calc(var(--gf-ladder-px,0px) + 44px);   /* chartContent starts at the GEX ladder: offset by the ladder width, then clear the drawing rail */
1740z-index:12;display:flex;align-items:center;gap:0;padding:2px 8px;border:1px solid var(--t-border2,#2e323d);border-radius:8px;background:var(--t-card,#0f172a);box-shadow:0 4px 14px rgba(0,0,0,.45);visibility:hidden}
1741/* VISIBILITY LIVES ON THE BODY, NOT THE ELEMENT (user 2026-09-09: "our chart timeframe dialogue
1742   still fade away. thats so bad"). Four attempts set an INLINE visibility on #gfHdrChartCtl, and
1743   every one of them lost the same way: gfGhostCompact builds a BRAND NEW element on rebuild, so the
1744   inline style went with the old node and the toolbar came back invisible until some tick noticed.
1745   Patching the writers could not win - the style was on the thing being replaced.
1746   Keyed off <body> instead, the rule survives any number of rebuilds because it never lived on the
1747   element. The class is set once, after the first successful ladder measurement, so there is still
1748   no jump from the left on load - which is the only reason the hidden default exists.
1749   !important because the losing inline style may still be sitting on a node from an older page. */
1750body.gf-ctl-ready #gfHdrChartCtl{visibility:visible!important}   /* solid dark card, not a grey wash (user 2026-09-06) */   /* floating toolbar at the top of the chart window. NO backdrop-filter: it turned the toolbar into the containing block for the position:fixed Layers panel / timeframe menu, which then opened clipped inside it (user: "layers click not working"). visibility is set by gfLayersBadgeTick once the ladder width is known (no jump from the left on load). */
1751#gfHdrChartCtl .gf-hgrp{margin-bottom:0;min-height:24px;padding:0 8px}
1752body.gf-desk #gfHdrChartCtl{flex-wrap:nowrap;white-space:nowrap}
1753body.gf-desk #gfHdrChartCtl .gf-hgrp{padding:0 5px;flex-wrap:nowrap}   /* Desk 2/3: narrow chart 1 - the toolbar stays on one line instead of stacking (user 2026-09-06) */
1754#gfHdrChartCtl .gf-hgrp:first-child{border-left:0;padding-left:0}
1755body.gf-ghost-compact.gf-hdr2 #chartHeader{padding-top:0}
1756/* ladder minimised: --gf-ladder-w drops to 150px and the left zone (now ticker + price + LEVELS + ALL/0DTE/WK) got squeezed until the toggle vanished (user 2026-09-05). Left column = at least its content. */
1757body.gf-ghost-compact.gf-hdr2 .gf-hrow2{grid-template-columns:minmax(max-content,var(--gf-ladder-w,290px)) minmax(0,1fr) auto}
1758.gf-hdr2 #gfLvlLeft > *{flex-shrink:0}
1759body.gf-ghost-compact #gfLvlLeft #gfHgSym{border-left:0;padding-left:0;margin-bottom:0;padding-right:10px;margin-right:8px;border-right:1px solid var(--border)}
1760body.gf-ghost-compact #gfLvlRight .gf-hgrp{margin-bottom:0;min-height:24px;padding:0 8px}
1761</style>
1762<script>window.GF_ADMIN=false;window.GF_PAID=false;</script>
1762</head>
1763<body>
1764
1765<div class="header">
1766  <a href="/" style="text-decoration:none"><div class="logo"><svg viewBox="0 0 64 64" width="22" height="22" style="vertical-align:middle;margin-right:6px"><rect width="64" height="64" rx="14" style="fill:var(--t-bg,#0a0e17);stroke:var(--t-border,#1e293b)" stroke-width="2"/><path d="M32 12C22 12 16 20 16 28v20c0 0 3-4 6-4s3 4 6 4 3-4 6-4 3 4 6 4 3-4 6-4V28c0-8-6-16-16-16z" style="fill:var(--t-accent,#3b82f6)" opacity="0.9"/><circle cx="26" cy="26" r="3.5" style="fill:var(--t-bg,#0a0e17)"/><circle cx="38" cy="26" r="3.5" style="fill:var(--t-bg,#0a0e17)"/><circle cx="27" cy="25" r="1.2" style="fill:var(--t-text,#e2e8f0)"/><circle cx="39" cy="25" r="1.2" style="fill:var(--t-text,#e2e8f0)"/></svg><span class="c1">GHOST</span> <span class="c2">FLOW</span></div></a><span id="mktBadge" title="US equities market status (ET)"></span><span id="tzBadge" title="Timezone all event times + the intraday chart are shown in. Change it in the menu." style="margin-left:8px;font-size:10px;font-weight:700;letter-spacing:0.5px;color:var(--dim);border:1px solid var(--border);border-radius:6px;padding:2px 6px;cursor:pointer" onclick="document.getElementById('userMenu')&&(document.body.appendChild(document.getElementById('userMenu')),document.getElementById('userMenu').style.display='block')"></span>
1767  <div class="header-right">
1768    <div id="searchWrap" style="position:relative; display:inline-block">
1769    <input type="text" id="searchTicker" placeholder="🔍 Search ticker..." autocomplete="off" style="
1770      width:130px; padding:5px 8px; background:var(--card); border:1px solid var(--border);
1771      border-radius:6px; color:var(--text); font-size:11px; font-family:'Segoe UI',monospace;
1772      outline:none; text-transform:uppercase;
1773    " oninput="searchSuggest()" onfocus="searchSuggest()" onblur="setTimeout(hideSearchSuggest,150)" onkeydown="searchKeyNav(event)">
1774    <div id="searchSuggest" style="display:none; position:absolute; left:0; top:32px; width:210px; max-height:300px; overflow-y:auto; background:#111827; border:1px solid #1e293b; border-radius:8px; z-index:1000; box-shadow:0 8px 24px rgba(0,0,0,0.5); font-size:12px; text-align:left"></div>
1775    </div>
1776    <input type="text" id="customTicker" placeholder="+ Add ticker (Enter)" title="Type a ticker and press Enter to add it to all views" style="
1777      width:130px; padding:5px 8px; background:var(--card); border:1px solid var(--border);
1778      border-radius:6px; color:var(--text); font-size:11px; font-family:'Segoe UI',monospace;
1779      outline:none; text-transform:uppercase;
1780    " onkeydown="if(event.key==='Enter')addCustomTicker()">
1781    <div class="metric-toggle" title="Switch all views between Gamma (GEX), Vanna (VEX) and Charm (CHARM) exposure">
1782      <button class="metric-btn active" data-metric="gex" onclick="switchMetric('gex')">GEX</button>
1783      <button class="metric-btn" data-metric="vex" onclick="switchMetric('vex')">VEX</button>
1784      <button class="metric-btn" data-metric="charm" onclick="switchMetric('charm')" title="Charm = delta decay (dDelta/dTime)">CHARM</button>
1785    </div>
1786    <!-- My HQ first (user 2026-09-14: "move my HQ to top of grid on left rail or on modes list"). The rail
1787         and the modes list follow this DOM order. Still display:none until /api/me says hq. -->
1788    <button class="view-toggle" id="btn-workspace" onclick="setVie
1788w('workspace')" title="My HQ - one screen, any panels, docked; drag to arrange" style="display:none">My HQ</button>
1789    <button class="view-toggle active" id="btn-gex" onclick="setView('gex')">GEX view</button>
1790    <button class="view-toggle" id="btn-expiry" onclick="setView('expiry')">Heatmap</button>
1791    <button class="view-toggle" id="btn-chart" onclick="setView('chart')">GEX charts</button>
1792    <button class="view-toggle" id="btn-ultra" onclick="setView('ultra')">Ghost Mode</button>
1793    <button class="view-toggle" id="btn-flow" onclick="setView('flow')" title="Live options flow across the whole watchlist" style="display:none">Flow</button>
1794    <button class="view-toggle" id="btn-chain" onclick="setView('chain')" title="Option chain - every strike of an expiry, fully priced, with the dealer GEX each one carries (Premium)" style="display:none">Chain</button>
1795    <button class="view-toggle" id="btn-smart" onclick="setView('smart')" title="Smart Money - politician trades, insider buying, 13F position changes, FDA catalysts (Premium)" style="display:none">Smart Money</button>
1796    <button class="view-toggle" id="btn-ideas" onclick="setView('ideas')" title="Top scored trade setups — flow + GEX + catalysts (Premium)" style="display:none">Ideas</button>
1797    <button class="view-toggle" id="btn-scanner" onclick="setView('scanner')">Pulse</button>
1798    <button class="view-toggle" id="btn-tracking" onclick="setView('tracking')">Tracking</button>
1799    <button class="refresh-btn" id="btn-help" onclick="openHelp()" title="What am I looking at? — a plain-English guide for the view you're on" style="font-weight:700">Help ?</button>
1800    <div id="alertBellWrap" style="position:relative">
1801      <button class="refresh-btn" id="deskFloatBtn" onclick="gfDeskFloat()" title="Manual Desk - floating window (admin)" style="display:none;font-weight:800;letter-spacing:.4px">DESK</button>
1802      <button class="refresh-btn" id="alertBell" onclick="toggleAlertPanel()" title="Move alerts — stocks moving more than your threshold today">🔔<span id="alertBadge" style="display:none;position:absolute;top:-5px;right:-5px;background:#ef4444;color:#fff;font-size:9px;font-weight:800;border-radius:8px;padding:1px 4px;min-width:14px;text-align:center">0</span></button>
1803      <div id="alertPanel" style="display:none;position:absolute;right:0;top:34px;width:290px;max-height:400px;overflow-y:auto;background:#111827;border:1px solid #1e293b;border-radius:10px;z-index:999;box-shadow:0 8px 24px rgba(0,0,0,0.5)"></div>
1804    </div>
1805    <button class="refresh-btn" onclick="refreshAll()">↻ Refresh All</button>
1806    <div style="position:relative;display:inline-block;margin-left:4px">
1807      <button onclick="gfDiscordPop(this,'discordMenu')" title="Our Discord — free + premium channels" style="display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:var(--t-accent,#5865F2);color:var(--t-onAccent,#fff);border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap">Discord ▾</button>
1808      <div id="discordMenu" style="display:none;position:absolute;right:0;top:32px;width:248px;background:#111827;border:1px solid #1e293b;border-radius:10px;z-index:999;box-shadow:0 8px 24px rgba(0,0,0,0.5);overflow:hidden;font-size:12px;text-align:left">
1809        <div style="padding:10px 14px;border-bottom:1px solid #1e293b;font-weight:700;color:#e2e8f0">GhostFlow Discord</div>
1810        <div style="padding:10px 14px;color:#94a3b8">
1811          <div style="color:#22c55e;font-weight:700;margin-bottom:3px">✅ Free for everyone</div>
1812          <div style="margin-left:4px;line-height:1.7">📊 Live Flow · 📰 News · 💬 Community</div>
1813          <div style="color:#eab308;font-weight:700;margin:10px 0 3px">🔒 Pro members only</div>
1814          <div style="margin-left:4px;line-height:1.7">🔔 Alerts · 🏆 $1K Challenge</div>
1815        </div>
1816        <a href="https://discord.gg/xtbhm5pfqR" target="_blank" style="display:block;padding:10px 14px;background:#5865F2;color:#fff;text-align:center;font-weight:700;text-decoration:none">Join free Discord</a>
1817        <a id="discordUpgrade" href="/account" style="display:none;padding:9px 14px;color:#eab308;text-align:center;font-weight:700;text-decoration:none;border-top:1px solid #1e293b;background:rgba(234,179,8,0.06)">🔓 Upgrade to unlock Alerts + $1K Challenge</a>
1818      </div>
1819    </div>
1820    <button class="refresh-btn" id="screenshotBtn" onclick="captureScreenshot()" title="Screenshot this view (saved to Downloads)" style="margin-left:4px;display:none">📸</button>
1821    <div style="position:relative">
1822      <button class="refresh-btn" onclick="var m=document.getElementById('userMenu');if(m.style.display==='block'){m.style.display='none';}else{document.body.appendChild(m);m.style.display='block';}" title="Menu" style="margin-left:4px;font-size:16px">☰</button>
1823      <div id="userMenu" style="display:none;position:absolute;right:0;top:32px;background:#111827;border:1px solid #1e293b;border-radius:8px;
1823min-width:200px;z-index:999;box-shadow:0 8px 24px rgba(0,0,0,0.4);overflow:hidden">
1824        <div onclick="event.stopPropagation()" style="display:flex;align-items:center;gap:8px;padding:10px 16px;color:#e2e8f0;font-size:13px;border-bottom:1px solid #1e293b">
1825          <span>Timezone</span>
1826          <select id="tzSelect" onchange="setUserTZ(this.value)" title="Times shown on the dashboard + intraday chart use this zone. Market hours stay ET." style="margin-left:auto;background:#0e0e11;color:#e2e8f0;border:1px solid #1e293b;border-radius:6px;padding:4px 6px;font-size:12px;cursor:pointer">
1827            <option value="auto">Auto (local)</option>
1828            <option value="America/New_York">Eastern (ET)</option>
1829            <option value="America/Chicago">Central (CT)</option>
1830            <option value="America/Denver">Mountain (MT)</option>
1831            <option value="America/Los_Angeles">Pacific (PT)</option>
1832            <option value="UTC">UTC</option>
1833          </select>
1834        </div>
1835        <!-- Theme + menu layout (user 2026-09-06): Classic / Gold, Centered / Left rail / Bottom dock. Saved per browser. Admin first, premium next. -->
1836        <div id="gfThemeMenu" onclick="event.stopPropagation()" style="display:none;padding:10px 16px 12px;border-bottom:1px solid #1e293b;background:var(--t-bg2,#0b1220)">
1837          <div class="gf-tm-h">Theme</div>
1838          <div class="gf-tm-sw">
1839            <button type="button" class="gf-tm-s" data-theme="default" onclick="gfSetTheme('default')"><span class="d" style="background:linear-gradient(90deg,#0a0e17 60%,#3b82f6 60%)"></span>Classic</button>
1840            <button type="button" class="gf-tm-s" data-theme="gold" onclick="gfSetTheme('gold')"><span class="d" style="background:linear-gradient(90deg,#07080c 60%,#d4af37 60%)"></span>Gold</button>
1841          </div>
1842          <div class="gf-tm-h" style="margin-top:10px">Menu layout</div>
1843          <div class="gf-tm-lay">
1844            <button type="button" class="gf-tm-l c" data-nav="center" onclick="gfSetNav('center')"><span class="ic"></span>Centered</button>
1845            <button type="button" class="gf-tm-l r" data-nav="left" onclick="gfSetNav('left')"><span class="ic"></span>Left rail</button>
1846            <button type="button" class="gf-tm-l d" data-nav="dock" onclick="gfSetNav('dock')"><span class="ic"></span>Bottom dock</button>
1847          </div>
1848          <label id="gfRailHideRow" style="display:none;margin-top:10px;align-items:center;gap:8px;font-size:11px;color:var(--text);cursor:pointer"><input type="checkbox" id="gfRailHideSel" onchange="gfSetRailHide(this.checked)" style="accent-color:var(--t-accent,#d4af37)"> Auto-hide left rail <span style="color:var(--t-dim,#8f8a7c)">(slides away after 5 s, hover the left edge)</span></label>
1849          <div style="margin-top:8px;font-size:10px;color:var(--t-dim,#8f8a7c)">Saved on this browser.</div>
1850        </div>
1851        <a href="#" onclick="document.getElementById('userMenu').style.display='none';setView('indicators');return false;" style="display:flex;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>Indicators</span></a>
1852        <a href="/extension" style="display:flex;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>Chrome Extension</span></a>
1853        <a id="aiMenuLink" href="/ai" style="display:none;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>Ghost Flow AI</span><span style="margin-left:auto;font-size:10px;font-weight:700;color:#a855f7;background:rgba(168,85,247,0.14);padding:2px 6px;border-radius:5px">PREMIUM</span></a>
1854        <a href="/account" style="display:flex;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>Account</span></a>
1855        <a href="/contact" style="display:flex;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>Contact Support</span></a>
1856        <a href="/disclaimer" target="_blank" style="display:flex;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>Disclaimer</span></a>
1857        <a href="/privacy" target="_blank" style="display:flex;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>Privacy Policy</span></a>
1858        <a href="/admin" id="adminMenuBtn" style="display:none;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>Admin Panel</span></a>
1859        <a href="/studio" target="_blank" id="studioMenuBtn" style="display:none;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b"><span>GhostFlow Studio</span></a>
1860        <a href="/gamma-replay" target="_blank" id="gammaReplayMenuBtn" style="display:none;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b" title="Play the recorded 0DTE ladder back through the day - the same percentages the left grid prints, every 5 minutes"><span>Gamma Replay</span></a>
1861        <a href="/rh-sim" target="_blank" id="rhSimMenuBtn" style="display:none;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b" title="Robinhood execution simulation - every Setups trade with its real fill price, spread, drift and tracker-vs-real P/L"><span>Robinhood Simulation</span></a>
1862        <a href="#" id="announceMenuBtn" onclick="announceToDiscord();return false;" style="display:none;align-items:center;gap:10px;padding:10px 16px;color:#e2e8f0;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b" title="Post the product-update announcement to the Discord announcements channel"><span>Announce to Discord</span></a>
1863        <a href="#" id="layoutToggleBtn" onclick="toggleNewLayout();return false;" style="display:none;align-items:center;gap:10px;padding:10px 16px;color:#38bdf8;text-decoration:none;font-size:13px;border-bottom:1px solid #1e293b" title="Switch between the current layout and the new Ultra/Chart/Tracking redesign"><span id="layoutToggleLbl">New layout: OFF</span></a>
1864        <a href="/logout" style="display:flex;align-items:center;gap:10px;padding:10px 16px;color:#ef4444;text-decoration:none;font-size:13px"><span>Logout</span></a>
1865      </div>
1866    </div>
1867  </div>
1868</div>
1869
1870<div id="screenshotWatermark" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:9999;pointer-events:none;display:none;align-items:center;justify-content:center">
1871  <div style="transform:rotate(-25deg);font-family:-apple-system,sans-serif;font-size:80px;font-weight:800;color:rgba(59,130,246,0.08);letter-spacing:4px;white-space:nowrap;text-align:center;line-height:1.6">
1872    👻 GhostFlow<br>theghostflow.com<br>👻 GhostFlow<br>theghostflow.com<br>👻 GhostFlow
1873  </div>
1874</div>
1875
1876<div class="tabs-bar" id="tabs"></div>
1877
1878<!-- GEX grid expiry basis — recompute every column's levels from ALL expiries, 0DTE (nearest)
1879     or WEEKLY (nearest Friday). Global across all columns + sectors. Shown only in the GEX view. -->
1880<div id="gridExpiryBar" style="display:none;align-items:center;gap:8px;padding:6px 12px 0">
1881  <span style="font-size:10px;color:var(--dim);font-weight:700;letter-spacing:0.6px;font-family:'Segoe UI',monospace">EXPIRY</span>
1882  <div class="gexp-toggle" title="Compute the grid levels (King, Call/Put Wall, Vol Node) from all expiries, today's 0DTE, or this week's weekly (nearest Friday) expiry">
1883    <button class="gexp-btn active" data-exp="all" onclick="setGridExpiry('all')">ALL</button>
1884    <button class="gexp-btn" data-exp="0dte" onclick="setGridExpiry('0dte')">0DTE</button>
1885    <button class="gexp-btn" data-exp="weekly" onclick="setGridExpiry('weekly')">WEEKLY</button>
1886  </div>
1887  <div class="gexp-toggle" title="Show a VOL column per strike (call green / put red) for the selected expiry basis — 0DTE / ALL / WEEKLY follows the metric's expiry"><button class="gexp-btn" id="gridVolBtn" onclick="toggleGridVol()">VOL</button></div>
1888  <span id="gridStyleBar" style="display:none;align-items:center;gap:8px">
1889    <span style="font-size:10px;color:#cbd5e1;font-weight:700;letter-spacing:0.6px;font-family:'Segoe UI',monospace;margin-left:6px">BARS</span>
1890    <div class="gexp-toggle" title="Bar style for the grid rows (admin test)">
1891      <button class="gexp-btn" data-gbar="original" onclick="setGridBars('original')">ORIGINAL</button>
1892      <button class="gexp-btn" data-gbar="taper" onclick="setGridBars('taper')">TAPER</button>
1893      <button class="gexp-btn" data-gbar="glow" onclick="setGridBars('glow')">GLOW</button>
1894      <button class="gexp-btn" data-gbar="segments" onclick="setGridBars('segments')">SEGMENTS</button>
1895      <button class="gexp-btn" data-gbar="track" onclick="setGridBars('track')">TRACK</button>
1896      <button class="gexp-btn" data-gbar="split" onclick="setGridBars('split')" title="Green (positive) bars grow from the left, red (negative) from the right">SPLIT</button>
1897    </div>
1898    <span style="font-size:10px;color:#cbd5e1;font-weight:700;letter-spacing:0.6px;font-family:'Segoe UI',monospace">TINT</span>
1899    <div class="gexp-toggle" title="Row tint strength (admin test)">
1900      <button class="gexp-btn" data-gtint="off" onclick="setGridTint('off')">OFF</button>
1901      <button class="gexp-btn" data-gtint="soft" onclick="setGridTint('soft')">SOFT</button>
1902      <button class="gexp-btn" data-gtint="bold" onclick="setGridTint('bold')">BOLD</button>
1903    </div>
1904    <button class="gexp-btn" id="gridResetBtn" title="HOLD 1 second: reset THIS sector's grid - default tickers one per column, order restored, every per-column metric/expiry cleared (columns follow the top toggles again). Other sectors keep their setups.">RESET</button>
1905  </span>
1906  <span id="btn-viewsync" onclick="toggleViewSync()" title="Sync the ticker across Ghost Mode, Grid & Heatmap — change it in any and the others follow" style="display:inline-flex;align-items:center;font-size:11px;font-weight:700;color:var(--dim);cursor:pointer;padding:4px 12px;border:1px solid var(--border);border-radius:14px;user-select:none;white-space:nowrap;margin-left:8px">Sync</span>
1907</div>
1908
1909<!-- GEX view ticker line (sector-grouped, like the Heatmap). Click a ticker to load its whole sector. -->
1910<div class="expiry-tickers" id="gexTickers" style="display:none"></div>
1911
1912<!-- GEX Column View (default) -->
1913<div class="columns-container" id="columns"></div>
1914
1915<!-- Expiry Heatmap View (hidden) -->
1916<div id="expiryView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h, 56px));overflow:hidden">
1917  <div class="expiry-tickers" id="expiryTickers"></div>
1918  <!-- EXPIRY basis (user 2026-08-26). setGridExpiry already re-renders this view - it has a
1919       currentView==='expiry' branch calling loadExpiryGEX - and _syncGridExpiryButtons syncs every
1920       .gexp-btn on the page, so the heatmap only ever lacked the control, not the plumbing.
1921       Its own row rather than inside #expiryTickers, which is rebuilt by JS and would wipe it.
1922       Grid-v2 admin test (2026-08-27): for ADMIN the whole row is moved above the ticker strip
1923       at reveal time (insertBefore in the /api/me handler) so Grid and Heatmap keep the settings
1924       bar in the same place; members keep today's order. BARS/TINT stay hidden for members. -->
1925  <div id="heatSetRow" style="padding:6px 20px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);background:var(--card)">
1926    <span style="font-size:10px;color:#cbd5e1;font-weight:700;letter-spacing:0.6px;font-family:'Segoe UI',monospace">EXPIRY</span>
1927    <div class="gexp-toggle" title="Compute the heatmap's King, Call Wall and Put Wall from all expiries, today's 0DTE, or this week's weekly (nearest Friday) expiry">
1928      <button class="gexp-btn active" data-exp="all" onclick="setGridExpiry('all')">ALL</button>
1929      <button class="gexp-btn" data-exp="0dte" onclick="setGridExpiry('0dte')">0DTE</button>
1930      <button class="gexp-btn" data-exp="weekly" onclick="setGridExpiry('weekly')">WEEKLY</button>
1931    </div>
1932    <span id="heatStyleBar" style="display:none;align-items:center;gap:8px">
1933      <span style="font-size:10px;color:#cbd5e1;font-weight:700;letter-spacing:0.6px;font-family:'Segoe UI',monospace;margin-left:6px">BARS</span>
1934      <div class="gexp-toggle" title="Bar style for the grid rows (admin test)">
1935        <button class="gexp-btn" data-gbar="original" onclick="setGridBars('original')">ORIGINAL</button>
1936        <button class="gexp-btn" data-gbar="taper" onclick="setGridBars('taper')">TAPER</button>
1937        <button class="gexp-btn" data-gbar="glow" onclick="setGridBars('glow')">GLOW</button>
1938        <button class="gexp-btn" data-gbar="segments" onclick="setGridBars('segments')">SEGMENTS</button>
1939        <button class="gexp-btn" data-gbar="track" onclick="setGridBars('track')">TRACK</button>
1940      <button class="gexp-btn" data-gbar="split" onclick="setGridBars('split')" title="Green (positive) bars grow from the left, red (negative) from the right">SPLIT</button>
1941      </div>
1942      <span style="font-size:10px;color:#cbd5e1;font-weight:700;letter-spacing:0.6px;font-family:'Segoe UI',monospace">TINT</span>
1943      <div class="gexp-toggle" title="Row tint strength (admin test)">
1944        <button class="gexp-btn" data-gtint="off" onclick="setGridTint('off')">OFF</button>
1945        <button class="gexp-btn" data-gtint="soft" onclick="setGridTint('soft')">SOFT</button>
1946        <button class="gexp-btn" data-gtint="bold" onclick="setGridTint('bold')">BOLD</button>
1947      </div>
1948      <div class="gexp-toggle" title="Append each cell's share of its column's biggest print">
1949        <button class="gexp-btn" data-hpct="1" onclick="toggleHeatPct()">%</button><button class="gexp-btn" data-hvol="1" onclick="toggleHeatVol()" style="display:none" title="Call/Put volume traded today at each strike, front expiry">VOL</button><button class="gexp-btn" id="heatWaveBtn" data-hwave="1" onclick="toggleHeatWave()" style="display:none" title="Gamma Wave: a curve from spot through each expiry's strongest gamma strike - red into negative, green into positive">WAVE</button><button class="gexp-btn" id="heatBucketBtn" data-hbuck="1" onclick="toggleHeatBucket()" style="display:none" title="TERM - the gamma term structure: expiries summed into this week / next week / later, then the strongest strike of each. Where gamma concentrates as you go further out, instead of one point per expiry">TERM</button>
1950      </div>
1951    </span>
1952  </div>
1953
1954  <div class="expiry-info" id="expiryInfo"></div>
1955  <div class="expiry-content" id="expiryContent" style="flex:1;min-height:0;height:auto">
1956    <div class="col-loading">Select a ticker above</div>
1957  </div>
1958</div>
1959
1960<!-- Ultra Mode View -->
1961<div id="ultraView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h, 56px));overflow:hidden">
1962  <div class="expiry-tickers" id="ultraTickers" style="padding:6px 20px;display:flex;gap:4px;flex-wrap:wrap;max-height:58px;overflow-y:auto;border-bottom:1px solid var(--border);background:var(--card);position:static;z-index:50;flex-shrink:0"></div>
1963  <!-- Ghost Mode expiry basis — same ALL/0DTE/WEEKLY toggle as the grid; drives this view's ladder + levels. -->
1964  <div id="ultraExpiryBar" style="display:flex;align-items:center;gap:8px;padding:5px 20px 0;background:var(--card);flex-shrink:0">
1965    <span style="font-size:10px;color:var(--dim);font-weight:700;letter-spacing:0.6px;font-family:'Segoe UI',monospace">EXPIRY</span>
1966    <div class="gexp-toggle" title="Compute this view's levels from all expiries, today's 0DTE, or this week's weekly (nearest Friday) expiry">
1967      <button class="gexp-btn active" data-exp="all" onclick="setGridExpiry('all')">ALL</button>
1968      <button class="gexp-btn" data-exp="0dte" onclick="setGridExpiry('0dte')">0DTE</button>
1969      <button class="gexp-btn" data-exp="weekly" onclick="setGridExpiry('weekly')">WEEKLY</button>
1970    </div>
1971  </div>
1972  <div class="expiry-info" id="ultraInfo" style="padding:8px 20px;border-bottom:1px solid var(--border);background:var(--card);font-family:Segoe UI,monospace;display:flex;align-items:center;gap:20px;min-height:36px;flex-shrink:0"></div>
1973  <!-- Full-width chart toolbar (borrowed from the chart view) sits above BOTH the GEX ladder and the chart -->
1974  <div id="ultraToolbar" style="flex:0 0 auto"></div>
1975  <div style="display:flex;flex:1;min-height:0;overflow:hidden">
1976    <div id="ultraGex" style="width:240px;border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0;position:relative"></div>
1977    <div class="resize-handle" data-left="ultraGex" data-right="ultraExpiry"></div>
1978    <div id="ultraExpiry" style="flex:1;overflow:auto;padding:4px 12px;border-right:1px solid var(--border)"></div>
1979    <div class="resize-handle" data-left="ultraExpiry" data-right="ultraFlow"></div>
1980    <div id="ultraFlow" style="width:380px;flex-shrink:0;padding:0;display:flex;flex-direction:column;overflow:hidden">
1981      <div class="nf-wresize" title="Drag to resize the panel" onmousedown="_ufResizeStart(event)"></div>
1982      <!-- Panel toggle: the live flow tape, or the day's node-alert feed (same data as the Scanner).
1983           NOT .nf-usec — that class is a flex section (flex:1 1 0) and would make this bar expand.
1984           The new layout re-parents this into #ultraFlowSecBody; see the panel builder. -->
1985      <div id="ultraRightTabs" style="flex:0 0 auto;display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid var(--border);background:var(--card)">
1986        <button data-urtab="flow" class="tf-btn active" onclick="setUltraFlowTab('flow')" style="font-size:10px;padding:3px 10px">Flow</button>
1987        <button data-urtab="prints" class="tf-btn" onclick="setUltraFlowTab('prints')" style="font-size:10px;padding:3px 10px;color:#f59e0b" title="Admin: Whales - big single prints (UW flow alerts) with the tracked P&L">Whales</button>
1988        <button data-urtab="movers" class="tf-btn" onclick="setUltraFlowTab('movers')" style="font-size:10px;padding:3px 10px" title="Today's move alerts — market movers and your watchlist">Movers</button>
1989        <button data-urtab="nodes" class="tf-btn" onclick="setUltraFlowTab('nodes')" style="font-size:10px;padding:3px 10px">Node Alerts</button>
1990        <button data-urtab="gflip" class="tf-btn" onclick="setUltraFlowTab('gflip')" style="font-size:10px;padding:3px 10px;color:#22d3ee" title="Admin: watchlist names above / below their gamma flip, 0DTE and ALL">G-Flip</button>
1991        <span id="ultraFlowCP" style="margin-left:auto;display:flex;align-items:center;gap:8px;font-size:10px;font-weight:800" title="Net call vs net put premium for this ticker today"></span>
1992      </div>
1993      <div id="ultraFlowHeader" style="flex-shrink:0"></div>
1994      <div id="ultraFlowBody" style="flex:1;overflow-y:auto"></div>
1995      <!-- Node Alerts pane (hidden until toggled) — mirrors the Scanner feed via renderNodeFeed().
1996           No title bar: the tab already says Node Alerts; count + Clear live in the chips row. -->
1997      <div id="ultraNodePane" style="display:none;flex:1;min-height:0;flex-direction:column;overflow:hidden">
1998        <div id="ultraNodeFeedFilters" style="display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid var(--border);flex-wrap:wrap;align-items:center"></div>
1999        <div id="ultraNodeFeedList" style="flex:1;overflow-y:auto"></div>
2000      </div>
2001      <!-- Movers pane — the day's move alerts (same data as the bell / Scanner feed), scoped,
2002           plus the server-scanned EMA and Inside Bar signal feeds (dashboard ver
2002sions of the
2003           two TradingView indicators). -->
2004      <!-- G-Flip pane (admin) — compact above/below list for both bases; full table lives on Ideas > G-Flip. -->
2005      <div id="ultraGflipPane" style="display:none;flex:1;min-height:0;flex-direction:column;overflow:hidden">
2006        <div style="padding:6px 8px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:4px;font-size:11px;font-weight:700;color:#e2e8f0">
2007          <button data-ugbasis="dte" class="tf-btn active" onclick="setUltraGflipBasis('dte')" style="font-size:9px;padding:2px 7px" title="Nearest expiry">0DTE</button>
2008          <button data-ugbasis="all" class="tf-btn" onclick="setUltraGflipBasis('all')" style="font-size:9px;padding:2px 7px" title="All expiries">ALL</button>
2009          <span style="margin-left:auto;font-size:9px;color:#cbd5e1">spot vs gamma flip</span>
2010        </div>
2011        <div id="ultraGflipList" style="flex:1;overflow-y:auto"></div>
2012      </div>
2013      <!-- Prints pane (admin, 2026-09-04) - compact single-print feed; full table lives on Ideas > Prints. -->
2014      <div id="ultraPrintsPane" style="display:none;flex:1;min-height:0;flex-direction:column;overflow:hidden">
2015        <div style="padding:6px 8px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:4px;font-size:11px;font-weight:700;color:#e2e8f0">
2016          <span style="font-size:9px;color:#cbd5e1">whale prints · newest first</span>
2017        </div>
2018        <div id="ultraPrintsList" style="flex:1;overflow-y:auto"></div>
2019      </div>
2020      <div id="ultraMoversPane" style="display:none;flex:1;min-height:0;flex-direction:column;overflow:hidden">
2021        <div id="umScopeRow" style="padding:6px 10px;border-bottom:1px solid var(--border);font-family:'Segoe UI',monospace;display:flex;align-items:center;gap:4px;font-size:11px;font-weight:700;color:#e2e8f0;flex-wrap:wrap">
2022          <button data-umscope="all" class="tf-btn" onclick="setUltraMoversScope('all')" style="font-size:9px;padding:2px 7px">All</button>
2023          <button data-umscope="market" class="tf-btn" onclick="setUltraMoversScope('market')" style="font-size:9px;padding:2px 7px">Market</button>
2024          <button data-umscope="watch" class="tf-btn" onclick="setUltraMoversScope('watch')" style="font-size:9px;padding:2px 7px">Watchlist</button>
2025          <button data-umsig="tv" class="tf-btn" onclick="setUltraMoversSig('tv')" style="font-size:9px;padding:2px 7px" title="Indicator alerts from TradingView — 1H EMA crosses + inside bar rectangle breakouts">EMA &amp; Inside Bar</button>
2026          <button data-umsig="node" class="tf-btn" onclick="setUltraMoversSig('node')" style="font-size:9px;padding:2px 7px" title="Watchlist-wide King / Call Wall / Put Wall crossings — real-time price vs the current levels, server-scanned every minute">Node Alerts</button>
2027          <button data-umscope="custom" class="tf-btn" onclick="setUltraMoversScope('custom')" style="font-size:9px;padding:2px 7px">Custom</button>
2028          <span id="ultraMoversCount" style="margin-left:auto;font-size:10px;color:var(--dim);font-weight:400"></span>
2029        </div>
2030        <div id="ultraMoversList" style="flex:1;overflow-y:auto"></div>
2031      </div>
2032      <div id="ultraNewsHeader" style="flex-shrink:0;padding:4px 12px;background:var(--card);border-top:2px solid var(--border);display:flex;justify-content:space-between;align-items:center;cursor:ns-resize;user-select:none">
2033        <span style="font-family:Segoe UI,monospace;font-size:11px;font-weight:700;color:var(--cyan);letter-spacing:0.5px">📰 NEWS</span>
2034        <div style="display:flex;align-items:center;gap:8px">
2035          <span style="font-size:10px;color:var(--dim)">⋮⋮</span>
2036          <button onclick="toggleUltraNews(event)" title="Collapse / expand the news panel (drag the bar to resize)" style="background:none;border:none;color:var(--dim);font-size:14px;cursor:pointer;padding:0 4px;line-height:1" id="ultraNewsToggle">▼</button>
2037        </div>
2038      </div>
2039      <div id="ultraNewsBody" style="height:180px;overflow-y:auto;font-size:12px;background:rgba(0,0,0,0.15)"></div>
2040    </div>
2041  </div>
2042</div>
2043
2044<!-- Scanner View -->
2045<div id="scannerView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h,56px));overflow:hidden">
2046  <div id="scanTabBar" style="padding:8px 16px;border-bottom:1px solid var(--border);background:var(--card);display:flex;gap:8px;align-items:center">
2047    <button id="scanTab-movers" class="scan-supertab active" onclick="setScannerTab('movers')">📊 Movers</button>
2048    <button id="scanTab-overview" class="scan-supertab" onclick="setScannerTab('overview')" style="display:none">🌐 Market Overview</button>
2049    <button id="scanTab-nodes" class="scan-supertab" onclick="setScannerTab('nodes')" style="display:none">🎯 Node Alerts</button>
2050    <span id="scanTabNote" style="margin-left:auto;font-family:'Segoe UI',monospace;font-size:11px;color:var(--dim)"></span>
2051  </div>
2052  <!-- ── Market Overview (Radar sub-tab) — whole-market flow/darkpool/sector snapshot ── -->
2053  <div id="marketOverviewMain" style="display:none;flex:1;min-height:0;overflow-y:auto;background:var(--bg)">
2054    <div id="moBody" style="padding:14px 16px 40px"><div class="col-loading">Loading market overview…</div></div>
2055  </div>
2056  <div id="scanModeBar" style="padding:6px 16px 0;border-bottom:1px solid var(--border);background:var(--card);display:flex;gap:6px;align-items:center;flex-wrap:wrap">
2057    <button class="scan-mode-tab active" data-mode="market" onclick="setScanMode('market')">Market</button>
2058    <button class="scan-mode-tab" data-mode="watch" onclick="setScanMode('watch')">My Watchlist</button>
2059    <button class="scan-mode-tab" data-mode="custom" onclick="setScanMode('custom')">Custom List</button>
2060    <span id="scanWatchAdd" style="display:none;margin-left:auto;align-items:center;gap:6px;font-family:'Segoe UI',monospace;font-size:12px;color:var(--dim)">
2061      <input id="scanAddTicker" type="text" placeholder="+ Add ticker" maxlength="6" style="width:120px;text-transform:uppercase" onkeydown="if(event.key==='Enter')scanAddWatch()">
2062      <button class="refresh-btn" onclick="scanAddWatch()" style="color:var(--text)">Add</button>
2063    </span>
2064  </div>
2065  <!-- Per-list alert bar — binds to whichever tab is active (Market / My Watchlist / Custom) -->
2066  <div id="scanWatchAlertBar" style="display:none;padding:7px 16px;border-bottom:1px solid var(--border);background:rgba(59,130,246,0.05);align-items:center;gap:10px;flex-wrap:wrap;font-family:'Segoe UI',monospace;font-size:12px;color:var(--dim)">
2067    <label style="display:flex;align-items:center;gap:6px;color:#e2e8f0;font-weight:700;cursor:pointer"><input type="checkbox" id="scanAlOn"> 🔔 <span id="scanAlLabel">Alert me when a ticker moves ≥</span></label>
2068    <input id="scanAlPct" type="number" value="5" min="1" step="1" style="width:54px"> %
2069    <label id="scanAlBothWrap" style="display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="scanAlBoth"> both ways (up &amp; down)</label>
2070    <label id="scanAlVolWrap" style="display:none;align-items:center;gap:4px">with volume ≥ <input id="scanAlVol" type="number" value="10" min="0" step="1" style="width:56px">M</label>
2071    <button class="refresh-btn" id="scanAlSave" onclick="saveScanAlert()" style="color:var(--text)">Save alerts</button>
2072    <span id="scanAlNote" style="margin-left:auto;font-size:11px"></span>
2073  </div>
2074  <div id="scanFilters" style="padding:8px 16px;border-bottom:1px solid var(--border);background:var(--card);display:flex;gap:12px;flex-wrap:wrap;align-items:center;font-family:'Segoe UI',monospace;font-size:12px;color:var(--dim)">
2075    <label>Price $<input id="scanMinPrice" type="number" placeholder="min" value="1" style="width:64px"> – $<input id="scanMaxPrice" type="number" placeholder="max" style="width:64px"></label>
2076    <label>Move ≥ <input id="scanMinMove" type="number" placeholder="any" style="width:52px"> %</label>
2077    <label>Vol ≥ <input id="scanMinVol" type="number" placeholder="0" style="width:86px"></label>
2078    <label>Sort <select id="scanSort"><option value="perc_change">% Change</option><option value="volume">Volume</option><option value="marketcap">Market Cap</option></select></label>
2079    <button class="refresh-btn" onclick="loadScanner()" style="color:var(--text)">Apply</button>
2080    <span id="scanCount" style="margin-left:auto"></span>
2081  </div>
2082  <div id="scanMain" style="flex:1;display:flex;min-height:0;overflow:hidden">
2083    <div id="scanBody" style="flex:1;overflow-y:auto"><div class="col-loading">Loading scanner…</div></div>
2084    <div id="scanAlertsFeed" style="width:300px;flex:none;border-left:1px solid var(--border);background:var(--card);display:flex;flex-direction:column;overflow:hidden">
2085      <div style="padding:8px 10px;border-bottom:1px solid var(--border);font-family:'Segoe UI',monospace;display:flex;flex-direction:column;gap:7px">
2086        <div style="display:flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:#e2e8f0">🔔 Live Alerts <span id="scanFeedCount" style="margin-left:auto;font-size:10px;color:var(--dim);font-weight:400"></span></div>
2087        <div style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dim)">Show <select id="scanFeedScope" title="Filter alerts by source" style="flex:1;background:#0e0e11;color:#e2e8f0;border:1px solid var(--border);border-radius:5px;padding:3px 5px;font-size:11px;font-family:inherit;cursor:pointer"><option value="all">All alerts</option><option value="market">🌐 Market movers</option><option value="watch">⭐ My Watchlist</option><option value="custom">📋 Custom list</option></select></div>
2088        <div style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dim)">Show ≥ <input id="scanFeedMinPct" type="number" min="0" step="1" value="
20880" style="width:48px"> % <button id="scanFeedClear" class="refresh-btn" style="margin-left:auto;color:var(--text);padding:3px 10px;font-size:10px">Clear</button></div>
2089      </div>
2090      <div id="scanFeedList" style="flex:1;overflow-y:auto"></div>
2091    </div>
2092  </div>
2093  <div id="scanDemoNote" style="display:none;padding:8px 16px;border-top:1px solid var(--border);background:rgba(59,130,246,0.08);font-family:'Segoe UI',monospace;font-size:11px;color:#94a3b8;text-align:center">📺 Demo — scanner data is <b style="color:#e2e8f0">delayed ~15 min</b> and charts are limited to SPY &amp; TSLA. <a href="/register" style="color:#3b82f6;font-weight:700">Get Premium</a> for real-time scanning, every chart, and 🔔 custom move alerts.</div>
2094
2095  <!-- ── Node Alerts (Scanner sub-tab) ── -->
2096  <div id="nodeAlertsBar" style="display:none;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--card);align-items:center;gap:10px;flex-wrap:wrap;font-family:'Segoe UI',monospace;font-size:12px;color:var(--dim)">
2097    <span style="color:#e2e8f0;font-weight:700">Alert when price hits:</span>
2098    <button class="node-chip" data-node="king" onclick="toggleNodeType('king')">👑 King</button>
2099    <button class="node-chip" data-node="call" onclick="toggleNodeType('call')">📈 Call Wall</button>
2100    <button class="node-chip" data-node="put" onclick="toggleNodeType('put')">📉 Put Wall</button>
2101    <button class="node-chip" data-node="vol" onclick="toggleNodeType('vol')">🔵 Vol Node</button>
2102    <label style="margin-left:8px" title="0 = alert only on an actual hit (price crosses the level). Raise it for a near-touch window.">within <input id="nodeTol" type="number" value="0" step="0.05" min="0" style="width:58px" oninput="var _v=parseFloat(this.value);nodeTol=isNaN(_v)?0:Math.max(0,_v);saveNodePrefs();renderNodeAlerts()"> %</label>
2103    <button id="nodeSaveBtn" class="refresh-btn" title="Save your alert settings (selected nodes + tolerance) so they persist across tabs and reloads" onclick="saveNodeSettings()" style="color:#22c55e;border-color:rgba(34,197,94,0.4)">💾 Save settings</button>
2104    <span style="display:inline-flex;align-items:center;gap:6px;margin-left:12px;padding-left:12px;border-left:1px solid var(--border)">
2105      <button id="nodeNotifyBtn" class="refresh-btn" title="Desktop notifications for armed tickers when a node is touched" onclick="toggleNodeNotify()" style="color:var(--dim)">🔕 Alerts off</button>
2106      <button class="refresh-btn" title="Alert on every ticker" onclick="armAllVisible(true)" style="color:var(--text)">Alert all</button>
2107      <button class="refresh-btn" title="Clear all per-ticker alerts" onclick="armAllVisible(false)" style="color:var(--text)">Clear all</button>
2108      <span style="color:var(--dim);font-size:11px">— or hover a row &amp; click 🔔 to alert · click a ticker for its chart</span>
2109    </span>
2110    <span style="margin-left:auto;display:flex;align-items:center;gap:6px">
2111      <input id="nodeAddTicker" type="text" placeholder="+ Add ticker" maxlength="6" style="width:120px;text-transform:uppercase" onkeydown="if(event.key==='Enter')nodeAddTicker()">
2112      <button class="refresh-btn" onclick="nodeAddTicker()" style="color:var(--text)">Add</button>
2113    </span>
2114  </div>
2115  <div id="nodeAlertsMain" style="display:none;flex:1;min-height:0;overflow:hidden">
2116    <div id="nodeAlertsBody" style="flex:1;overflow-y:auto;padding:12px 16px"><div class="col-loading">Loading node alerts…</div></div>
2117    <div id="nodeAlertsFeed" style="width:300px;flex:none;border-left:1px solid var(--border);background:var(--card);display:flex;flex-direction:column;overflow:hidden">
2118      <div style="padding:8px 10px;border-bottom:1px solid var(--border);font-family:'Segoe UI',monospace;display:flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:#e2e8f0">⚡ Node Alerts <span id="nodeFeedCount" style="margin-left:auto;font-size:10px;color:var(--dim);font-weight:400"></span><button id="nodeFeedClear" class="refresh-btn" onclick="clearNodeFeed()" style="color:var(--text);padding:3px 10px;font-size:10px">Clear</button></div>
2119      <div id="nodeFeedFilters" style="display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid var(--border);flex-wrap:wrap"></div>
2120      <div id="nodeFeedList" style="flex:1;overflow-y:auto"></div>
2121    </div>
2122  </div>
2123</div>
2124
2125<!-- Indicators View -->
2126<div id="indicatorsView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h,56px));overflow-y:auto;padding:24px 20px">
2127  <div style="max-width:1000px;margin:0 auto;width:100%">
2128    <h2 style="font-size:18px;font-weight:800;color:#e2e8f0;margin:0 0 4px">GHOST FLOW Indicators</h2>
2129    <div style="font-size:13px;color:#94a3b8;margin-bottom:18px;line-height:1.5">Free TradingView indicators for every member, plus a premium invite-only tool. Add them to your charts and trade the same structure we track here.</div>
2130    <div id="indicatorsBody" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px"></div>
2131  </div>
2132</div>
2133
2134<!-- GEX Charts View -->
2135<div id="chartView" style="display:none;height:calc(100dvh - 90px);overflow:hidden">
2136  <div id="chartCol" style="display:flex;flex-direction:column;height:100%;overflow:hidden">
2137      <!-- Ticker bar (all tickers, like Ultra mode) -->
2138            <div class="expiry-tickers" id="chartTickers" style="padding:6px 20px;display:flex;gap:4px;flex-wrap:wrap;max-height:58px;overflow-y:auto;border-bottom:1px solid var(--border);background:var(--card)"></div>
2139      <!-- Chart header with ticker + timeframes -->
2140      <div id="chartHeader" style="padding:8px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;background:var(--card);font-family:Segoe UI,monospace">
2141        <span id="chartTickerName" style="font-size:16px;font-weight:700"></span>
2142        <span id="chartSpot" style="font-size:13px;color:var(--dim)"></span>
2143        <div id="tfButtons" style="display:flex;gap:3px;margin-left:8px">
2144          <button class="tf-btn" data-tf="1m" onclick="switchTimeframe('1m')">1m</button>
2145          <button class="tf-btn" data-tf="5m" onclick="switchTimeframe('5m')">5m</button>
2146          <button class="tf-btn" data-tf="15m" onclick="switchTimeframe('15m')">15m</button>
2147          <button class="tf-btn" data-tf="30m" onclick="switchTimeframe('30m')">30m</button>
2148          <button class="tf-btn active" data-tf="1h" onclick="switchTimeframe('1h')">1H</button>
2149          <button class="tf-btn" data-tf="4h" onclick="switchTimeframe('4h')">4H</button>
2150          <button class="tf-btn" data-tf="1d" onclick="switchTimeframe('1d')">1D</button>
2151          <button class="tf-btn" data-tf="w" onclick="switchTimeframe('w')">W</button>
2152          <button class="tf-btn" data-tf="mo" onclick="switchTimeframe('mo')">M</button>
2153        </div>
2154        <div id="drawTools" style="display:flex;gap:3px;margin-left:10px;border-left:1px solid var(--border);padding-left:10px;position:relative">
2155          <div id="drawToolWrap" style="position:relative;display:inline-flex" onmouseenter="showDrawMenu()" onmouseleave="hideDrawMenuSoon()">
2156            <button class="tf-btn" id="drawToolMain" onclick="toggleDrawMenu()" title="Drawing tools — hover or click to pick one"><span id="drawToolIcon">➤</span> <span style="font-size:8px;opacity:.6">▾</span></button>
2157            <div id="drawMenu" style="display:none;position:absolute;top:calc(100% + 4px);left:0;z-index:200;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:4px;min-width:210px;box-shadow:0 8px 24px rgba(0,0,0,.5)" onmouseenter="showDrawMenu()" onmouseleave="hideDrawMenuSoon()">
2158              <button class="draw-menu-item" data-tool="cursor" onclick="pickDrawTool('cursor')"><span class="dmi-ic">➤</span> Cursor</button>
2159              <button class="draw-menu-item" data-tool="hline" onclick="pickDrawTool('hline')"><span class="dmi-ic">─</span> Horizontal line</button>
2160              <button class="draw-menu-item" data-tool="trend" onclick="pickDrawTool('trend')"><span class="dmi-ic">╱</span> Trend line</button>
2161              <button class="draw-menu-item" data-tool="rect" onclick="pickDrawTool('rect')"><span class="dmi-ic">▭</span> Rectangle</button>
2162              <button class="draw-menu-item" data-tool="curve" onclick="pickDrawTool('curve')"><span class="dmi-ic">∿</span> Curve</button>
2163              <button class="draw-menu-item" data-tool="brush" onclick="pickDrawTool('brush')"><span class="dmi-ic">🖌</span> Brush (freehand)</button>
2164              <button class="draw-menu-item" data-tool="fib" onclick="pickDrawTool('fib')"><span class="dmi-ic">φ</span> Fib retracement</button>
2165              <button class="draw-menu-item" data-tool="avwap" onclick="pickDrawTool('avwap')"><span class="dmi-ic">Ⅴ</span> Anchored VWAP</button>
2166              <button class="draw-menu-item" data-tool="vprof" onclick="pickDrawTool('vprof')"><span class="dmi-ic">▐</span> Fixed Range Volume</button>
2167              <div style="border-top:1px solid var(--border);margin:5px 2px 2px;padding:7px 6px 3px;display:flex;align-items:center">
2168                <span style="font-size:10px;color:var(--dim)">Color</span>
2169                <div id="drawColors" style="display:flex;gap:6px;margin-left:auto">
2170                  <span class="draw-swatch active" data-col="#3b82f6" onclick="setDrawColor('#3b82f6')" style="background:#3b82f6" title="Blue"></span>
2171                  <span class="draw-swatch" data-col="#ef4444" onclick="setDrawColor('#ef4444')" style="background:#ef4444" title="Red"></span>
2172                  <span class="draw-swatch" data-col="#eab308" onclick="setDrawColor('#eab308')" style="background:#eab308" title="Yellow"></span>
2173                  <span class="draw-swatch" data-col="#ffffff" onclick="setDrawColor('#ffffff')" style="background:#ffffff" title="White"></span>
2174                </div>
2175              </div>
2176              <div style="margin:2px 2px;padding:6px 6px 3px;display:flex;align-items:center" title="Golden-zone (0.5–0.618) fill colour — Fib retracement only">
2177                <span style="font-size:10px;color:var(--dim)">Fib zone</span>
2178                <div id="fibZoneColors" style="display:flex;gap:6px;margin-left:auto">
2179                  <span class="fib-zone-swatch active" data-zcol="#eab308" onclick="setFibZoneColor('#eab308')" style="background:#eab308" title="Gold"></span>
2180                  <span class="fib-zone-swatch" data-zcol="#22c55e" onclick="setFibZoneColor('#22c55e')" style="background:#22c55e" title="Green"></span>
2181                  <span class="fib-zone-swatch" data-zcol="#a855f7" onclick="setFibZoneColor('#a855f7')" style="background:#a855f7" title="Purple"></span>
2182                  <span class="fib-zone-swatch" data-zcol="#f59e0b" onclick="setFibZoneColor('#f59e0b')" style="background:#f59e0b" title="Orange"></span>
2183                </div>
2184              </div>
2185              <div style="border-top:1px solid var(--border);margin:2px 2px;padding:6px 6px 4px">
2186                <div style="font-size:10px;color:var(--dim);margin-bottom:4px">Fib levels <span style="opacity:.7">— toggle, delete or add</span></div>
2187                <div id="fibLevelsList" style="max-height:148px;overflow-y:auto;display:flex;flex-direction:column;gap:2px"></div>
2188                <div style="display:flex;gap:4px;margin-top:6px">
2189                  <input id="fibAddInput" type="number" step="0.001" placeholder="e.g. 0.25" onclick="event.stopPropagation()" onkeydown="if(event.key==='Enter'){event.stopPropagation();addFibLevel();}" style="width:66px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:#e2e8f0;font-size:11px;padding:2px 5px">
2190                  <button onclick="event.stopPropagation();addFibLevel()" style="background:rgba(6,182,212,.15);border:1px solid var(--cyan);color:var(--cyan);border-radius:4px;font-size:10px;font-weight:700;padding:2px 8px;cursor:pointer">+ Add level</button>
2191                </div>
2192              </div>
2193            </div>
2194          </div>
2195          <button class="tf-btn" id="drawBtn-del" onclick="deleteSelectedDrawing()" title="Delete the selected drawing (or press Del) — click a drawing first to select it">🗑</button>
2196        </div>
2197        <button class="tf-btn" id="chartGexToggle" onclick="toggleChartGex()" title="Toggle the GEX strike ladder on the chart" style="margin-left:8px">GEX</button>
2198        <button class="tf-btn" id="chartVolToggle" onclick="toggleChartVol()" title="Show a VOLUME column (nearest expiry, call/put) beside the GEX ladder — drag the ladder's right edge to resize" style="margin-left:4px">VOL</button>
2199        <button class="tf-btn" id="tvLevelsBtn" onclick="gfCopyTvLevels()" title="Copy this ticker's live levels (King, walls, Vol Node, gamma flip, charm, EOD pin) as a string for the GhostFlow Levels TradingView indicator — paste it into the indicator's input on TV" style="display:none;margin-left:8px">TV</button>
2200        <button class="tf-btn" id="replayBtn" onclick="toggleReplay()" title="Replay the day — play today's price back with the GEX levels as they moved (1x–3x)" style="display:none;margin-left:10px;border-left:1px solid var(--border);padding-left:12px">▶ Replay</button>
2201        <span id="mchartToggle" style="margin-left:10px;display:none;align-items:center;gap:3px;border-left:1px solid var(--border);padding-left:10px" title="Ghost Desk — 1, 2 or 3 charts side by side. Each chart keeps its own ticker, timeframe and expiry basis; click a chart to give it the ladder, drawings and panels.">
2202          <span style="font-size:10px;color:var(--dim);margin-right:2px;letter-spacing:.5px">GHOST DESK</span>
2203          <button class="tf-btn mchart-n active" data-n="1" onclick="setChartLayout(1)">1</button>
2204          <button class="tf-btn mchart-n" data-n="2" onclick="setChartLayout(2)">2</button>
2205          <button class="tf-btn mchart-n" data-n="3" onclick="setChartLayout(3)">3</button>
2206        </span>
2207        <div id="indicatorWrap" style="position:relative;margin-left:6px;display:none">
2208          <button class="tf-btn" id="indicatorBtn" onclick="toggleIndicatorPanel()" title="Chart modes (admin)">📊 Modes ▾</button>
2209          <div id="indicatorPanel" style="display:none;position:absolute;top:30px;left:0;z-index:50;background:#0f172a;border:1px solid var(--border);border-radius:8px;padding:0 12px 10px;min-width:288px;box-shadow:0 8px 24px rgba(0,0,0,.55);font-size:12px;font-family:Segoe UI,monospace;max-height:calc(100vh - 110px);overflow-y:auto;overscroll-behavior:contain">
2210            <!-- The mode list outgrew the screen once Darkpool/Flow Marks brought their own settings
2211                 rows (user 2026-08-24: "our bottom modes are hiding"). The panel now scrolls, with an
2212                 exact max-height measured on open (see toggleIndicatorPanel) so the last category is
2213                 always reachable no matter how many modes get added, and the header + the collapse-
2213all
2214                 control stay pinned while the list moves under them. -->
2215            <div id="modePanelHead" style="position:sticky;top:0;background:#0f172a;z-index:3;padding:10px 0 8px;margin-bottom:0;display:flex;align-items:center;gap:8px;cursor:move;user-select:none" title="Drag to move · double-click to snap back under the button">
2216              <span style="color:#64748b;font-size:11px;letter-spacing:-1px;line-height:1" aria-hidden="true">â ¿</span>
2217              <span style="font-weight:700;color:#cbd5e1;letter-spacing:1px;font-size:10px">MODES</span>
2218              <button id="modeCollapseAll" onclick="toggleAllModeCats()" title="Collapse or expand every category" style="margin-left:auto;font:600 9px 'Segoe UI',sans-serif;padding:2px 8px;border:1px solid var(--border);background:#0b1220;color:#b7c2d0;border-radius:4px;cursor:pointer">COLLAPSE ALL</button>
2219              <button id="modePanelClose" onclick="toggleIndicatorPanel()" title="Close (or click Modes again)" style="font:700 12px 'Segoe UI',sans-serif;line-height:1;padding:2px 6px 3px;border:1px solid var(--border);background:#0b1220;color:#cbd5e1;border-radius:4px;cursor:pointer">×</button>
2220            </div>
2221            <div class="modecat" data-cat="ma" onclick="toggleModeCat(this)" style="font-weight:700;color:var(--dim);font-size:9px;letter-spacing:.5px;margin:0 0 5px;cursor:pointer;display:flex;align-items:center;gap:5px"><span class="cat-chev" style="font-size:8px">▾</span>MOVING AVERAGES <span style="font-weight:400;text-transform:none;color:#475569">— up to 3</span></div>
2222            <div id="maRows"></div>
2223            <div style="border-top:1px solid var(--border);margin:9px 0 6px"></div>
2224            <div class="modecat" data-cat="chart" onclick="toggleModeCat(this)" style="font-weight:700;color:var(--dim);font-size:9px;letter-spacing:.5px;margin:0 0 5px;cursor:pointer;display:flex;align-items:center;gap:5px"><span class="cat-chev" style="font-size:8px">▾</span>CHART LEVELS <span style="font-weight:400;text-transform:none;color:#475569">— GEX / Charm / VWAP</span></div>
2225            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Show the GEX levels on the chart — King (yellow), Call Wall (green), Put Wall (red), Vol Node (blue)"><input type="checkbox" id="mode-gexlines" onchange="setMode('gexlines',this.checked)"> GEX Levels <span style="margin-left:auto;font-size:10px"><span style="color:#eab308">King</span> <span style="color:#22c55e">CW</span> <span style="color:#ef4444">PW</span> <span style="color:#3b82f6">VN</span></span></label>
2226            <div class="mo-opts" style="display:flex"><div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="LINES draws the levels across the chart; AXIS keeps only the colored price-axis tags - a clean chart with the levels still marked.">Display</span><span class="mo-seg" data-mo="glStyle"><button data-v="lines" onclick="setModeOpt('glStyle','lines')">Lines</button><button data-v="axis" onclick="setModeOpt('glStyle','axis')">Axis only</button></span></div></div>
2227            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Highlight the most-positive (amber) and most-negative (cyan) net-charm strikes behind the candles"><input type="checkbox" id="mode-charm" onchange="setMode('charm',this.checked)"> Charm Levels <span style="margin-left:auto;font-size:10px"><span style="color:#22d3ee">▬ Positive</span> <span style="color:#f59e0b">▬ Negative</span></span></label>
2228            <div class="mo-opts" style="display:flex"><div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="LINES draws the charm levels across the chart; AXIS keeps only the price-axis tags.">Display</span><span class="mo-seg" data-mo="chStyle"><button data-v="lines" onclick="setModeOpt('chStyle','lines')">Lines</button><button data-v="axis" onclick="setModeOpt('chStyle','axis')">Axis only</button></span></div></div>
2229            <style>#indicatorPanel::-webkit-scrollbar{width:9px}#indicatorPanel::-webkit-scrollbar-track{background:transparent}#indicatorPanel::-webkit-scrollbar-thumb{background:var(--t-border3,#334660);border-radius:calc(5px*var(--t-rk,1));border:2px solid var(--t-card,#0f172a)}#indicatorPanel::-webkit-scrollbar-thumb:hover{background:var(--t-border3,#465c7d)}#indicatorPanel{scrollbar-width:thin;scrollbar-color:var(--t-border3,#334660) transparent}
2230            #indicatorPanel .modecat{color:var(--t-muted,#cbd5e1) !important}#indicatorPanel .modecat span{color:var(--t-muted,#cbd5e1) !important}#indicatorPanel .modecat .cat-chev{color:var(--t-muted,#cbd5e1) !important}
2231            .mo-seg{display:inline-flex;gap:2px}.mo-seg button{font:600 10px 'Segoe UI',sans-serif;padding:2px 8px;border:1px solid var(--border);background:var(--t-bg2,#0b1220);color:var(--t-muted,#b7c2d0);border-radius:calc(4px*var(--t-rk,1));cursor:pointer;line-height:1.4}.mo-seg button.on{background:var(--t-accent,#3b82f6);color:var(--t-onAccent,#04141c);border-color:var(--t-accent,#3b82f6)}.mo-opts{display:none;flex-direction:column;gap:5px;padding:2px 0 6px 20px}.mo-opts .mo-lbl{color:var(--t-muted,#cbd5e1);width:60px;font-size:10px}
2232            .gb-seg{display:inline-flex;gap:2px}.gb-seg button{font:600 10px 'Segoe UI',sans-serif;padding:2px 8px;border:1px solid var(--border);background:var(--t-bg2,#0b1220);color:var(--dim);border-radius:calc(4px*var(--t-rk,1));cursor:pointer;line-height:1.4}.gb-seg button.on{background:var(--t-accent,#3b82f6);color:var(--t-onAccent,#04141c);border-color:var(--t-accent,#3b82f6)}</style>
2233            <div id="mode-gexbub-row" style="display:none;flex-direction:column;gap:5px;padding:5px 0 3px;border-top:1px solid var(--border);margin-top:4px">
2234              <label style="display:flex;align-items:center;gap:7px;cursor:pointer" title="Live GEX visual behind the candles: bubbles or glowing lines at each strike, coloured by rank (purple = top-2 magnets, gold = positive, red = negative)"><input type="checkbox" id="mode-gexbub" onchange="setMode('gexbub',this.checked)"> GEX Visual</label>
2235              <div id="gexbub-opts" style="display:none;flex-direction:column;gap:5px;padding-left:20px">
2236                <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span style="color:var(--dim);width:46px">Style</span><span class="gb-seg" data-gb="style"><button data-v="bubbles" onclick="setGexbubOpt('style','bubbles')">Bubbles</button><button data-v="glow" onclick="setGexbubOpt('style','glow')" title="Glowing horizontal lines at each strike — hot core, soft falloff, same colours and brightness as the bubbles">Glow Lines</button></span></div>
2237                <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span style="color:var(--dim);width:46px" title="How many of the strongest strikes to show bubbles for TODAY (highest GEX first). The previous session always shows its full structure, so yesterday reads as context behind today's filtered view.">Strikes</span><span class="gb-seg" data-gb="strikes"><button data-v="2" onclick="setGexbubOpt('strikes','2')" title="Just the 2 strongest magnets (the purples)">Top 2</button><button data-v="5" onclick="setGexbubOpt('strikes','5')">Top 5</button><button data-v="10" onclick="setGexbubOpt('strikes','10')">Top 10</button><button data-v="all" onclick="setGexbubOpt('strikes','all')">All</button></span></div>
2238                <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span style="color:var(--dim);width:46px">Session</span><span class="gb-seg" data-gb="session"><button data-v="reg" onclick="setGexbubOpt('session','reg')">Regular</button><button data-v="ext" onclick="setGexbubOpt('session','ext')">Extended</button></span></div>
2239                <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span style="color:var(--dim);width:46px">Extend</span><span class="gb-seg" data-gb="extend"><button data-v="off" onclick="setGexbubOpt('extend','off')">Off</button><button data-v="left" onclick="setGexbubOpt('extend','left')" title="extend to the left edge">◄</button>
2239<button data-v="right" onclick="setGexbubOpt('extend','right')" title="extend to the right edge">►</button><button data-v="full" onclick="setGexbubOpt('extend','full')">Full</button></span></div>
2240                <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span style="color:var(--dim);width:46px" title="Also draw the PREVIOUS session's recorded trail (dimmed) behind today's bubbles — hours shown follow the Session setting. Bubbles style only.">History</span><span class="gb-seg" data-gb="prev"><button data-v="off" onclick="setGexbubOpt('prev','off')">Today</button><button data-v="on" onclick="setGexbubOpt('prev','on')" title="Keep yesterday's recorded bubbles on the chart, dimmed, while today builds to the right">+ Prev Day</button><button data-v="ext" onclick="setGexbubOpt('prev','ext')" title="Prev Day trail PLUS yesterday's closing bands carried across today as thin dim lines - today's tape against yesterday's gamma structure">+ Prev →</button></span></div>
2241                <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span style="color:var(--dim);width:46px" title="How bright the previous-day trail and its carried lines draw over today">Layer</span><span class="gb-seg" data-gb="prevbright"><button data-v="dim" onclick="setGexbubOpt('prevbright','dim')">Dim</button><button data-v="bright" onclick="setGexbubOpt('prevbright','bright')">Bright</button></span></div>
2242                <div id="gvtag-rows" style="display:none">
2243                <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span style="color:var(--dim);width:46px" title="Small tags at the LIVE edge of each band - strike, % strength, and the ~15-min trend delta. Any combination; applies to Bubbles AND Glow Lines.">Tags</span><span class="gb-seg" data-gb="gvtags"><button data-v="gvTagStrike" onclick="gvToggleTag('gvTagStrike')">Strike</button><button data-v="gvTagPct" onclick="gvToggleTag('gvTagPct')">%</button><button data-v="gvTagTrend" onclick="gvToggleTag('gvTagTrend')">Trend</button></span></div>
2244                <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span style="color:var(--dim);width:46px" title="BUILDING: the single fastest-strengthening band pulses with a cyan tag - gamma usually builds at a strike before price arrives. PULL: net gamma pull above vs below price, shown under the price tag.">Signals</span><span class="gb-seg" data-gb="gvsig"><button data-v="gvBuild" onclick="gvToggleTag('gvBuild')">Building</button><button data-v="gvPull" onclick="gvToggleTag('gvPull')">Pull</button></span></div>
2245                </div>
2246              </div>
2247            </div>
2248            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="EOD Pin — the most likely close, plus a SETTLEMENT BAND showing how much room is actually left. The band is measured, not guessed: recent realized movement and the gamma regime drive its width (quiet + positive gamma = tight, active + negative gamma = wide), and it shrinks into the bell. About 4 of 5 closes land inside it."><input type="checkbox" id="mode-eodpin" onchange="setMode('eodpin',this.checked)"> EOD Pin <span style="margin-left:auto;font-size:10px;color:#eab308">▬ close + band</span></label>
2249            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Session VWAP line (intraday timeframes only)"><input type="checkbox" id="mode-vwap" onchange="setMode('vwap',this.checked)"> VWAP <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-vwap" value="#a78bfa" onchange="setModeColor('vwap',this.value)" title="VWAP color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2250            <!-- 0DTE Flow bars removed from Modes per user (2026-07-21); pollFlowBars/render code kept dormant for a future rework. -->
2251            <div style="border-top:1px solid var(--border);margin:9px 0 6px"></div>
2252            <div class="modecat" data-cat="greek" onclick="toggleModeCat(this)" style="font-weight:700;color:var(--dim);font-size:9px;letter-spacing:.5px;margin:0 0 5px;cursor:pointer;display:flex;align-items:center;gap:5px"><span class="cat-chev" style="font-size:8px">▾</span>GREEK &amp; FLOW LEVELS <span style="font-weight:400;text-transform:none;color:#475569">— pinned to 0DTE · pick colors →</span></div>
2253            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Max Pain — the strike where the most option value expires worthless (EOD / OPEX magnet)"><input type="checkbox" id="mode-maxpain" onchange="setMode('maxpain',this.checked)"> Max Pain <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-maxpain" value="#e879f9" onchange="setModeColor('maxpain',this.value)" title="Max Pain color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2254            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Vanna walls (net call/put vanna) + vanna flip — drives the calm-tape drift and vol-spike selling"><input type="checkbox" id="mode-vanna" onchange="setMode('vanna',this.checked)"> Vanna Levels <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-vannaHi" value="#2dd4bf" onchange="setModeColor('vannaHi',this.value)" title="Vanna+ (high) color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"><input type="color" id="col-vannaLo" value="#fb923c" onchange="setModeColor('vannaLo',this.value)" title="Vanna- (low) color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"><input type="color" id="col-vannaFlip" value="#a855f7" onchange="setModeColor('vannaFlip',this.value)" title="Vanna Flip color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2255            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Call/Put walls re-weighted by TODAY's net ask-side flow premium — separate from the standard walls, which stay unchanged"><input type="checkbox" id="mode-flowwall" onchange="setMode('flowwall',this.checked)"> Flow-Weighted Walls <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-flowCall" value="#4ade80" onchange="setModeColor('flowCall',this.value)" title="Flow Call Wall color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"><input type="color" id="col-flowPut" value="#f87171" onchange="setModeColor('flowPut',this.value)" title="Flow Put Wall color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2256            <label id="mode-kingmagnet-row" style="display:none;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="King follows magnet. Below the gamma flip the KING line + purple band move to the dominant negative strike below spot; the true gamma strike shows TOP GEX. The cyan MAGNET FORMING line fires instantly on the flip break; the King move confirms after ~1 min holding past the flip. Chart display only - Setups / SPX plays / audits keep the real King."><input type="checkbox" id="mode-kingmagnet" onchange="setMode('kingmagnet',this.checked)"> King follows magnet</label>
2257            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Magnet Levels — top strikes by a blended score of gamma + charm + vanna + OI + today's flow (the strongest pulls)"><input type="checkbox" id="mode-magnet" onchange="setMode('magnet',this.checked)"> Magnet Levels <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-magnet" value="#fbbf24" onchange="setModeColor('magnet',this.value)" title="Magnet color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2258            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Expected Move — ±1σ band from the ATM 0DTE straddle (the market's own bet on today's range)"><input type="checkbox" id="mode-em" onchange="setMode('em',this.checked)">
2258 Expected Move ±1σ <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-em" value="#60a5fa" onchange="setModeColor('em',this.value)" title="Expected Move color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2259            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Darkpool Levels — today's dark-pool block prints aggregated per price; the biggest institutional prices draw as lines (line label = total premium at that price)"><input type="checkbox" id="mode-darkpool" onchange="setMode('darkpool',this.checked)"> Darkpool Levels <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-darkpool" value="#22d3ee" onchange="setModeColor('darkpool',this.value)" title="Darkpool color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2260            <div id="darkpool-opts" class="mo-opts">
2261              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="Ignore dark-pool prints smaller than this. Raising it keeps only the real institutional blocks.">Min prem</span><span class="mo-seg" data-mo="dpMinPrem"><button data-v="0" onclick="setModeOpt('dpMinPrem','0')">Any</button><button data-v="250000" onclick="setModeOpt('dpMinPrem','250000')">250K</button><button data-v="500000" onclick="setModeOpt('dpMinPrem','500000')">500K</button><button data-v="1000000" onclick="setModeOpt('dpMinPrem','1000000')">1M</button><button data-v="5000000" onclick="setModeOpt('dpMinPrem','5000000')">5M</button></span></div>
2262              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="How many price levels to draw for this ticker (biggest premium first).">Lines</span><span class="mo-seg" data-mo="dpMax"><button data-v="3" onclick="setModeOpt('dpMax','3')">3</button><button data-v="5" onclick="setModeOpt('dpMax','5')">5</button><button data-v="10" onclick="setModeOpt('dpMax','10')">10</button><button data-v="20" onclick="setModeOpt('dpMax','20')">20</button></span></div>
2263              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="Merge dark-pool levels sitting within this % of each other into one line, with a 20-cent floor so close prints always combine on mid-priced tickers. Prints a couple of cents apart are the same level to a trader but drew separately and crowded the price scale. The merged line sits at the biggest print's price; its label shows the combined premium and print count.">Merge</span><span class="mo-seg" data-mo="dpMerge"><button data-v="0" onclick="setModeOpt('dpMerge','0')">Off</button><button data-v="0.05" onclick="setModeOpt('dpMerge','0.05')">0.05%</button><button data-v="0.1" onclick="setModeOpt('dpMerge','0.1')">0.1%</button><button data-v="0.25" onclick="setModeOpt('dpMerge','0.25')">0.25%</button><button data-v="0.5" onclick="setModeOpt('dpMerge','0.5')">0.5%</button></span></div>
2264              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="Line style for dark-pool levels. The biggest price always draws at double width, so rank stays readable whichever style you pick.">Style</span><span class="mo-seg" data-mo="dpStyle"><button data-v="solid" onclick="setModeOpt('dpStyle','solid')">Solid</button><button data-v="dashed" onclick="setModeOpt('dpStyle','dashed')">Dash</button><button data-v="dotted" onclick="setModeOpt('dpStyle','dotted')">Dot</button></span></div>
2265            </div>
2266            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Flow Marks — significant options sweeps/blocks pinned to the candle where they printed. Green up arrow = bullish (calls bought at the ask / puts sold), red down arrow = bearish. Label = total premium + C/P, xN when several prints hit the same candle"><input type="checkbox" id="mode-flowmarks" onchange="setMode('flowmarks',this.checked)"> Flow Marks</label>
2267            <div id="flowmarks-opts" class="mo-opts">
2268              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="Ignore option prints below this total premium. Raise it on busy tickers so only the size shows.">Min prem</span><span class="mo-seg" data-mo="fmMinPrem"><button data-v="0" onclick="setModeOpt('fmMinPrem','0')" title="No floor - every print qualifies. Max marks still ranks the biggest in view, so raise it too if you want the small stuff drawn.">ALL</button><button data-v="50000" onclick="setModeOpt('fmMinPrem','50000')">50K</button><button data-v="100000" onclick="setModeOpt('fmMinPrem','100000')">100K</button><button data-v="250000" onclick="setModeOpt('fmMinPrem','250000')">250K</button><button data-v="500000" onclick="setModeOpt('fmMinPrem','500000')">500K</button><button data-v="1000000" onclick="setModeOpt('fmMinPrem','1000000')">1M</button></span></div>
2269              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="Which prints earn the arrows. BIGGEST ranks by premium within the visible range (Max marks applies). LAST 5 / LAST 10 shows only the most recent qualifying prints - a clean, quiet chart that always tracks the newest flow.">Show</span><span class="mo-seg" data-mo="fmShow"><button data-v="top" onclick="setModeOpt('fmShow','top')">Biggest</button><button data-v="r5" onclick="setModeOpt('fmShow','r5')">Last 5</button><button data-v="r10" onclick="setModeOpt('fmShow','r10')">Last 10</button></span></div>
2270              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="Which session to mark. TODAY drops yesterday's prints off yesterday's candles.">Range</span><span class="mo-seg" data-mo="fmDay"><button data-v="today" onclick="setModeOpt('fmDay','today')">Today</button><button data-v="all" onclick="setModeOpt('fmDay','all')">All loaded</button></span></div>
2271              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="What each arrow's label says. Pick any combination — these are independent toggles, not a single choice.">Label</span><span class="mo-seg" data-mo="fmLbl"><button data-v="fmLblPrem" onclick="toggleModeOpt('fmLblPrem')">$ PREM</button><button data-v="fmLblStrike" onclick="toggleModeOpt('fmLblStrike')">STRIKE</button><button data-v="fmLblExp" onclick="toggleModeOpt('fmLblExp')">EXPIRY</button></span></div>
2272              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="Put the strike/expiry detail on every mark, or only on the three biggest so a busy tape stays readable.">Detail on</span><span class="mo-seg" data-mo="fmDetail"><button data-v="all" onclick="setModeOpt('fmDetail','all')">All marks</button><button data-v="top3" onclick="setModeOpt('fmDetail','top3')">Top 3 only</button></span></div>
2273              <div style="display:flex;align-items:center;gap:6px;font-size:10px"><span class="mo-lbl" title="How many arrows to draw at once, ranked by premium WITHIN the time range on screen. Zoom in and the budget fills with recent flow; zoom out to the whole day and you get the day's biggest. Same-candle same-direction prints merge into one arrow.">Max marks</span><span class="mo-seg" data-mo="fmMax"><button data-v="10" onclick="setModeOpt('fmMax','10')">10</button><button data-v="25" onclick="setModeOpt('fmMax','25')">25</button><button data-v="50" onclick="setModeOpt('fmMax','50')">50</button><button data-v="100" onclick="setModeOpt('fmMax','100')">100</button></span></div>
2274            </div>
2275            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Politician Trades - congressional disclosures for this ticker, pinned to the candle where the filing became public. Congress reports a DATE with no time and the median lag from trade to filing is about 15 days, so these mark when the information reached the tape, not when the trade happened. The label carries the trade date and the lag. Pink down = sell, blue up = buy."><input type="checkbox" id="mode-polmarks" onchange="setMode('polmarks',this.checked)"> Politician Trades</label>
2276            <div style="border-top:1px solid var(--border);margin:9px 0 6px"></div>
2277            <div class="modecat" data-cat="session" onclick="toggleModeCat(this)" style="font-weight:700;color:var(--dim);font-size:9px;letter-spacing:.5px;margin:0 0 5px;cursor:pointer;display:flex;align-items:center;gap:5px"><span class="cat-chev" style="font-size:8px">▾</span>SESSION LEVELS <span style="font-weight:400;text-transform:none;color:#475569">— intraday · PD / ON / OR</span></div>
2278            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Prior-day regular-hours high/low (yesterday's 9:30–16:00 range)"><input type="checkbox" id="mode-sesPD" onchange="setMode('sesPD',this.checked)"> Prior Day High/Low <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-pd" value="#94a3b8" onchange="setModeColor('pd',this.value)" title="Prior Day color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2279            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Overnight high/low — extended-hours range from yesterday's 16:00 close to today's 9:30 open"><input type="checkbox" id="mode-sesON" onchange="setMode('sesON',this.checked)"> Overnight High/Low <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-on" value="#8b9cb5" onchange="setModeColor('on',this.value)" title="Overnight color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2280            <label style="display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer" title="Opening-range high/low — first 15 minutes after today's 9:30 open"><input type="checkbox" id="mode-sesOR" onchange="setMode('sesOR',this.checked)"> Opening Range High/Low <span style="margin-left:auto;display:inline-flex;gap:3px"><input type="color" id="col-or" value="#eab308" onchange="setModeColor('or',this.value)" title="Open Range color" style="width:22px;height:16px;padding:0;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer"></span></label>
2281          </div>
2282        </div>
2283        <div id="chartHeaderChips" style="margin-left:auto;display:flex;gap:16px;font-size:13px;font-weight:600;align-items:center">
2284          <div class="gexp-toggle" title="Compute the chart levels + trade plan from all expiries, today's 0DTE, or this week's weekly (nearest Friday) expiry">
2285            <button class="gexp-btn active" data-exp="all" onclick="setGridExpiry('all')">ALL</button>
2286            <button class="gexp-btn" data-exp="0dte" onclick="setGridExpiry('0dte')">0DTE</button>
2287            <button class="gexp-btn" data-exp="weekly" onclick="setGridExpiry('weekly')">WK</button>
2288          </div>
2289          <span id="chartKing" style="color:var(--yellow)"></span>
2290          <span id="chartCW" style="color:var(--green)"></span>
2291          <span id="chartPW" style="color:var(--red)"></span>
2292          <span id="chartVN" style="color:#3b82f6"></span>
2293          <span id="chartAN" style="color:#22d3ee" title="Active node — most-traded strike in the last ~30 min"></span>
2294          <span id="chartBias" style="font-size:14px"></span>
2295          <button id="tvSyncBtn" class="tvsync-btn linked" onclick="nfToggleTvSync()" title="Following TradingView — click to pin this window to its ticker"><span class="tvchain"><i></i><i></i><b></b></span> TV SYNC <span class="state">&middot; ON</span></button>
2296          <button class="refresh-btn" onclick="if(chartTicker) loadGEXChart(chartTicker)" style="margin-left:8px;font-size:11px;padding:3px 8px">↻</button>
2297        </div>
2298      </div>
2299      <!-- Chart + side panels row (toolbar/tickers stay full-width above, like Ultra) -->
2300      <div id="chartRow" style="display:flex;flex:1;min-height:0;overflow:hidden">
2301      <div id="chartContent" style="flex:1;padding:0;overflow:hidden;position:relative">
2302        <div class="col-loading">Select a ticker above</div>
2303      </div>
2304    <!-- Trade Plan panel (new layout only) -->
2305    <div id="chartTradePlan">
2306      <div class="nf-tp-head"><span class="t" style="display:flex;gap:4px;align-items:center"><button data-tptab="plan" class="tf-btn active" onclick="setTradePlanTab('plan')" style="font-size:10px;padding:2px 8px">TRADE PLAN</button><button data-tptab="tide" class="tf-btn" onclick="setTradePlanTab('tide')" style="font-size:10px;padding:2px 8px" title="Net call vs put premium flowing into this ticker, minute by minute">TICKER TIDE</button></span><button class="nf-tp-min-btn" onclick="nfSetTradePlanMin(true)" title="hide the trade plan">&rsaquo;</button></div>
2307      <div class="nf-tp-body"><div style="padding:14px;color:var(--dim);font-size:12px">Select a ticker to see the plan.</div></div>
2308    </div>
2309    <!-- Watchlist panel (hidden — tickers now show in the top bar) -->
2310    <div id="chartWatchlist" style="display:none;width:220px;border-left:1px solid var(--border);background:var(--card);overflow-y:auto;flex-shrink:0">
2311      <div style="padding:8px 12px;border-bottom:1px solid var(--border);font-family:Segoe UI,monospace;font-size:11px;font-weight:600;color:var(--dim);letter-spacing:1px">WATCHLIST</div>
2312      <div id="watchlistItems"></div>
2313    </div>
2314  </div>
2315  </div>
2316</div>
2317
2318<!-- FLOW View (MVP) — live options flow across the whole watchlist -->
2319<div id="chainView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h,56px));overflow:hidden">
2320  <div class="expiry-tickers" id="chainTickers" style="padding:6px 20px;display:flex;gap:4px;flex-wrap:wrap;max-height:58px;overflow-y:auto;border-bottom:1px solid var(--border);background:var(--card)"></div>
2321  <div id="chainBar" style="flex:0 0 auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--border);background:var(--card)"></div>
2322  <div id="chainSent" style="flex:0 0 auto;padding:0 14px;border-bottom:1px solid var(--border);background:var(--card)"></div>
2323  <div id="chainBody" style="flex:1;min-height:0;overflow:auto;padding:0 14px 20px"></div>
2324</div>
2325<!-- WORKSPACE: tiles hold the REAL view nodes, moved in and moved back out (see gfWsPark). -->
2326<div id="wsView">
2327  <div id="wsBar">
2328    <div class="gf-ws-dd" id="wsDdAdd">
2329      <button type="button" class="gf-ws-ddb" data-dd="wsDdAdd" title="Add a panel - drag one onto a tile, or click to drop it into the focused tile"><span class="v">ADD</span><span class="cv">&#9662;</span></button>
2330      <div class="gf-ws-ddp"><div class="gf-ws-ddh">ADD A PANEL</div><div id="wsAdd"></div></div>
2331    </div>
2332    <span class="gf-ws-vr"></span>
2333    <input type="text" id="wsTkSearch" placeholder="Ticker" autocomplete="off" spellcheck="false" title="Type a ticker and press Enter - the list below filters as you type">
2334    <span class="gf-ws-glbl">RECENT</span>
2335    <span id="wsTkRecent"></span>
2336    <div class="gf-ws-dd" id="wsDdTk">
2337      <button type="button" class="gf-ws-ddb" data-dd="wsDdTk" title="Every ticker"><span class="cv">&#9662;</span></button>
2338      <div class="gf-ws-ddp" id="wsTkPop"><div class="gf-ws-ddh">ALL TICKERS</div></div>
2339    </div>
2340    <span class="gf-ws-vr"></span>
2341    <div class="gf-ws-dd" id="wsDdExp">
2342      <button type="button" class="gf-ws-ddb" data-dd="wsDdExp" title="Which expiry the levels are computed from"><span class="k">EXPIRY</span><span class="v">ALL</span><span class="cv">&#9662;</span></button>
2343      <div class="gf-ws-ddp"><div class="gf-ws-ddh">EXPIRY</div></div>
2344    </div>
2345    <div class="gf-ws-dd" id="wsDdBars">
2346      <button type="button" class="gf-ws-ddb" data-dd="wsDdBars" title="Bar style for the grid and ladder rows"><span class="k">BARS</span><span class="v">-</span><span class="cv">&#9662;</span></button>
2347      <div class="gf-ws-ddp"><div class="gf-ws-ddh">BARS</div></div>
2348    </div>
2349    <div class="gf-ws-dd" id="wsDdTint">
2350      <button type="button" class="gf-ws-ddb" data-dd="wsDdTint" title="Row tint strength"><span class="k">TINT</span><span class="v">-</span><span class="cv">&#9662;</span></button>
2351      <div class="gf-ws-ddp"><div class="gf-ws-ddh">TINT</div></div>
2352    </div>
2353    <span id="wsQuick"></span>
2354    <div class="gf-ws-dd" id="wsDdHeat">
2355      <button type="button" class="gf-ws-ddb" data-dd="wsDdHeat" title="Heatmap display: percent, gamma wave, term structure"><span class="k">HEATMAP</span><span class="v">OFF</span><span class="cv">&#9662;</span></button>
2356      <div class="gf-ws-ddp"><div class="gf-ws-ddh">HEATMAP</div></div>
2357    </div>
2358    <span style="flex:1"></span>
2359    <button class="gf-ws-chip" id="wsSyncBtn" onclick="gfWsToggleSync()" title="Click a ticker in any panel and every other panel follows it">Sync</button>
2360    <span class="gf-ws-vr"></span>
2361    <div class="gf-ws-dd" id="wsDdPre">
2362      <button type="button" class="gf-ws-ddb" data-dd="wsDdPre" title="Saved layouts - open one, save this arrangement under a name, or start from a preset"><span class="k">LAYOUT</span><span class="v" id="wsLayoutName">Default</span><span class="cv">&#9662;</span></button>
2363      <div class="gf-ws-ddp" style="right:0;left:auto"><div class="gf-ws-ddh">MY LAYOUTS</div>
2364        <div class="gf-ws-ddcol" id="wsLayoutList"></div>
2365        <div class="gf-ws-ddh" style="margin-top:9px">START FROM</div>
2366        <div class="gf-ws-ddcol">
2367          <button class="gf-ws-chip" onclick="gfWsPreset('trader')">Trader</button>
2368          <button class="gf-ws-chip" onclick="gfWsPreset('scan')">Scan</button>
2369          <button class="gf-ws-chip" onclick="gfWsPreset('single')">Reset to one panel</button>
2370        </div>
2371      </div>
2372    </div>
2373  </div>
2374  <!-- ONE ticker line for the whole workspace (user 2026-09-12: "single ticker, expiry, bars, tint
2375       settings on top of page like we have on al modes. not on every window"). Every panel's own
2376       strip is hidden in here by CSS rather than moved, so leaving the workspace restores them
2377       without a second teardown path to get wrong. #gridExpiryBar IS moved - it is the app's own
2378       EXPIRY / BARS / TINT row, and one of those on top is the whole point. -->
2379  <div id="wsTkBar">
2380    <div id="wsTkStrip"></div>
2381    <button type="button" id="wsTkFold" title="Hide or show the ticker line">&#9652;</button>
2382  </div>
2383  <div id="wsSetBar"></div>
2384  <div id="wsStage"><div id="wsDz"></div></div>
2385  <!-- Panels wait here between repaints. Off-screen rather than display:none: hiding them would
2386       fire ResizeObservers with a zero box and let a chart tear its own canvas down. -->
2387  <div id="wsAttic" style="position:absolute;left:-99999px;top:0;width:1px;height:1px;overflow:hidden"></div>
2388</div>
2389<div id="wsGhost"></div>
2390<div id="flowView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h,56px));overflow:hidden">
2391  <div class="flow-bar">
2392    <div class="flow-presets">
2393      <button class="flow-chip active" data-fp="all" onclick="setFlowPreset('all')">All</button>
2394      <button class="flow-chip" data-fp="whales" onclick="setFlowPreset('whales')" title="Premium ≥ $500K · size ≥ 500 · ask-side">Whales</button>
2395      <button class="flow-chip" data-fp="calls" onclick="setFlowPreset('calls')">Calls</button>
2396      <button class="flow-chip" data-fp="puts" onclick="setFlowPreset('puts')">Puts</button>
2397      <button class="flow-chip" data-fp="sweeps" onclick="setFlowPreset('sweeps')">Sweeps</button>
2398      <button class="flow-chip" data-fp="0dte" onclick="setFlowPreset('0dte')">0DTE</button>
2399      <button class="flow-chip" data-fp="exec" onclick="setFlowPreset('exec')" title="Executive-size flow: premium ≥ $250K · size ≥ 100 · buy">Exec</button>
2400      <button class="flow-chip" data-fp="spx" onclick="setFlowPreset('spx')" title="SPX / SPXW / SPY index flow">SPX</button>
2401      <button class="flow-chip" data-fp="leaps" onclick="setFlowPreset('leaps')" title="LEAPS — expiry ≥ 1 year out">LEAPS</button>
2402      <button class="flow-chip" data-fp="semi" onclick="setFlowPreset('semi')" title="Semiconductors / AI names (matches Discord Semis/AI)">Semi/AI</button>
2403      <button class="flow-chip" data-fp="megacap" onclick="setFlowPreset('megacap')" title="Megacap names">Megacap</button>
2404      <button class="flow-chip" data-fp="darkpool" onclick="setFlowPreset('darkpool')" title="Darkpool / off-exchange block prints — filter by ticker, premium and size">Darkpool</button>
2405    </div>
2406    <div class="flow-controls">
2407      <span id="flowTickerChip" style="display:none"></span>
2408      <button id="flowAggBtn" class="flow-chip" onclick="toggleFlowAgg()" title="Show ONLY strikes hit 2+ times (accumulation). Off = all flow.">Accumulate</button>
2409      <select id="flowAccAlertSel" onchange="setFlowAccAlert(this.value)" title="Alert (bell + sound + desktop) when the same contract accumulates this many prints" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:11px;padding:3px 6px;cursor:pointer">
2410        <option value="0">🔔 Alert: Off</option>
2411        <option value="2">🔔 2x</option>
2412        <option value="3">🔔 3x</option>
2413        <option value="5">🔔 5x</option>
2414        <option value="10">🔔 10x</option>
2415      </select>
2416      <button id="flowPauseBtn" class="flow-chip" onclick="toggleFlowPause()" title="Freeze the tape to read it">Pause</button>
2417      <span id="flowStat" class="flow-stat"></span>
2418    </div>
2419  </div>
2420  <!-- ticker line — click a ticker to filter the tape (like the other views) -->
2421  <div id="flowTickers" class="flow-tickers"></div>
2422  <!-- sentiment strip — market bullish vs bearish over the shown flow (or the selected ticker's net call/put) -->
2423  <div id="flowSentiment" style="display:none;align-items:center;gap:12px;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--card);font-family:Segoe UI,monospace;font-size:12px"></div>
2424  <div class="flow-main">
2425    <div id="flowFilters" class="flow-filters"></div>
2426    <div id="flowTape" class="flow-tape"><div class="col-loading">Loading flow…</div></div>
2427    <div id="flowRight" class="flow-right" style="display:none">
2428      <div class="flow-right-grip" onmousedown="flowRightResizeStart(event)" title="drag to resize"></div>
2429      <div class="flow-right-head">
2430        <span class="flow-right-title">Contract Lookup</span>
2431        <button class="flow-right-x" onclick="closeFlowRight()" title="close">&times;</button>
2432      </div>
2433      <div id="flowRightBody" class="flow-right-body"></div>
2434    </div>
2435  </div>
2436</div>
2437
2438<!-- Trade Ideas View — top scored setups (flow + GEX + catalysts) -->
2439<div id="smartView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h,56px));overflow:hidden">
2440  <div style="display:flex;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px solid var(--border);background:var(--card);flex-wrap:wrap">
2441    <span style="font-size:15px;font-weight:800;color:#e2e8f0">Smart Money</span>
2442    <span style="display:inline-flex;gap:4px;margin-left:4px">
2443      <button class="smart-tab" data-tab="politicians" onclick="setSmartTab('politicians')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1">Politicians</button>
2444      <button class="smart-tab" data-tab="insiders" onclick="setSmartTab('insiders')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1">Insiders</button>
2445      <button class="smart-tab" data-tab="f13" onclick="setSmartTab('f13')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1">13F</button>
2446      <button class="smart-tab" data-tab="fda" onclick="setSmartTab('fda')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1">FDA</button>
2447    </span>
2448    <select id="smartWho" onchange="loadSmart(true)" style="display:none;background:#0a0e17;border:1px solid var(--border);border-radius:7px;padding:6px 9px;font-size:12px;font-weight:700;color:#e2e8f0"></select>
2449    <input id="smartFilter" placeholder="Filter name or ticker" oninput="_smartRender()" style="margin-left:6px;background:#0a0e17;border:1px solid var(--border);border-radius:7px;padding:6px 10px;font-size:12px;color:#e2e8f0;width:190px">
2450    <label id="smartBuysWrap" style="display:none;align-items:center;gap:5px;font-size:12px;font-weight:700;color:#cbd5e1;cursor:pointer"><input type="checkbox" id="smartBuys" checked onchange="loadSmart(true)">Buys only</label>
2451    <span id="smartSub" style="font-size:11px;color:#cbd5e1"></span>
2452    <button class="refresh-btn" onclick="loadSmart(true)" style="margin-left:auto">Refresh</button>
2453  </div>
2454  <div id="smartBody" style="flex:1;overflow:auto;padding:12px 18px"></div>
2455</div>
2456<script>
2457// === Smart Money tab =======================================================
2458// Four low-frequency feeds behind one view. Everything is cached server-side (30 min, 6 h for
2459// 13F), so switching tabs is free - we only refetch when the tab changes or Refresh is pressed.
2460var smartTab = "politicians", _smartData = {}, _smartLoading = false;
2461// Restore the sub-tab too - coming back to Smart Money and landing on politicians when you were
2462// reading FDA is the same annoyance one level down.
2463try { var _st0 = localStorage.getItem("gf_smart_tab"); if (["politicians","insiders","f13","fda"].indexOf(_st0) >= 0) smartTab = _st0; } catch (e) {}
2464var SMART_EP = { politicians: "/api/smart/politicians", insiders: "/api/smart/insiders", f13: "/api/smart/13f", fda: "/api/smart/fda" };
2465var SMART_SUB = {
2466  politicians: "Congressional disclosures - filed date, not trade date. Members have up to 45 days to report.",
2467  insiders: "Form 4 filings - officers, directors and 10% owners. Ranked by dollar value.",
2468  f13: "13F position CHANGES for tracked funds. Filed ~45 days after quarter end, so this is positioning, not a live signal.",
2469  fda: "Upcoming FDA catalysts. Many target dates are quarter/half-year estimates rather than exact days."
2470};
2471function _sNum(n, d) { return (n == null || !isFinite(n)) ? "-" : Number(n).toLocaleString(undefined, { maximumFractionDigits: d == null ? 0 : d }); }
2472function _sUsd(n) {
2473  if (!(n > 0)) return "-";
2474  if (n >= 1e9) return "$" + (n / 1e9).toFixed(1) + "B";
2475  if (n >= 1e6) return "$" + (n / 1e6).toFixed(1) + "M";
2476  if (n >= 1e3) return "$" + Math.round(n / 1e3) + "K";
2477  return "$" + Math.round(n);
2478}
2479function _sEsc(t) { return String(t == null ? "" : t).replace(/[&<>"]/g, function (c) { return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]; }); }
2480function _sTk(t) { return t ? '<span onclick="gfGoTicker(\'' + t + '\')" style="cursor:pointer;font-weight:800;color:#60a5fa">' + t + "</span>" : '<span style="color:#cbd5e1">-</span>'; }
2481function setSmartTab(t) {
2482  smartTab = t;
2483  try { localStorage.setItem("gf_smart_tab", t); } catch (e) {}
2484  document.querySelectorAll(".smart-tab").forEach(function (b) {
2485    var on = b.dataset.tab === t;
2486    b.style.background = on ? "rgba(96,165,250,.16)" : "transparent";
2487    b.style.borderColor = on ? "rgba(96,165,250,.55)" : "var(--border)";
2488    b.style.color = on ? "#e2e8f0" : "#cbd5e1";
2489  });
2490  var bw = document.getElementById("smartBuysWrap"); if (bw) bw.style.display = (t === "insiders") ? "flex" : "none";
2491  var wh = document.getElementById("smartWho"); if (wh) wh.style.display = (t === "politicians") ? "" : "none";
2492  var sub = document.getElementById("smartSub"); if (sub) sub.textContent = SMART_SUB[t] || "";
2493  loadSmart();
2494}
2495function loadSmart(force) {
2496  var tab = smartTab, body = document.getElementById("smartBody");
2497  if (!body) return;
2498  if (!force && _smartData[tab]) { _smartRender(); return; }
2499  if (_smartLoading) return;
2500  _smartLoading = true;
2501  body.innerHTML = '<div style="padding:24px;color:#cbd5e1;font-size:13px">Loading...</div>';
2502  var url = SMART_EP[tab];
2503  if (tab === "insiders") { var cb = document.getElementById("smartBuys"); url += "?side=" + ((cb && cb.checked) ? "buy" : "all"); }
2504  if (tab === "politicians") { var _wh = document.getElementById("smartWho"); var _v = _wh ? _wh.value : ""; if (_v) url += "?name=" + encodeURIComponent(_v); }
2505  fetch(url).then(function (r) { return r.json(); }).then(function (j) {
2506    _smartLoading = false;
2507    if (j && j.error) { body.innerHTML = '<div style="padding:24px;color:#fca5a5;font-size:13px">' + _sEsc(j.error) + "</div>"; return; }
2508    _smartData[tab] = j;
2509    var wh2 = document.getElementById("smartWho");
2510    if (wh2 && !wh2.options.length && j.tracked) {
2511      var oh = '<option value="">All members (recent)</option>';
2512      for (var oi = 0; oi < j.tracked.length; oi++) oh += '<option value="' + _sEsc(j.tracked[oi]) + '">' + _sEsc(j.tracked[oi]) + '</option>';
2513      wh2.innerHTML = oh;
2514    }
2515    _smartRender();
2516  }).catch(function (e) {
2517    _smartLoading = false;
2518    body.innerHTML = '<div style="padding:24px;color:#fca5a5;font-size:13px">Could not load: ' + _sEsc(e.message) + "</div>";
2519  });
2520}
2521// Column definitions per tab. Each column carries the label, whether it sorts numerically, a
2522// val() used for sorting, and html() for rendering - so the header, the sort and the cell can
2523// never drift out of step the way parallel arrays do.
2524// FDA row sentiment from the catalyst text. Keyword-based and deliberately conservative: rows
2525// with no decisive language stay Neutral rather than being forced onto a side - a dosing update
2526// or a conference presentation is progress, not an outcome. BEARISH IS CHECKED FIRST, because
2527// negative filings quote the positive vocabulary ("failed to gain approval", "did not meet the
2528// primary endpoint") and a bullish-first scan would misread every one of them.
2529function _fdaBias(r) {
2530  var t = ((r.event || "") + " " + (r.status || "") + " " + (r.description || "") + " " + (r.outcome || "")).toLowerCase();
2531  var bear = /complete response letter|\bcrl\b|refuse[sd]? to file|refusal|reject|clinical hold|did not meet|failed|fails to|missed the|discontinu|terminat|withdraw|negative (topline|results|data)|safety (concern|signal)|warning letter|adverse|halted|suspend/.test(t);
2532  if (bear) return -1;
2533  var bull = /\bapprov(al|ed|es)\b|authoriz(ed|ation)|clearance|cleared|met (the )?(primary|secondary|co-primary)|positive (topline|results|data|phase)|statistically significant|breakthrough (therapy|designation)|fast track|priority review|orphan drug designation|accepted for (review|filing)|granted|superiority|exceeded|successful/.test(t);
2534  if (bull) return 1;
2535  return 0;
2536}
2537var SMART_COLS = {
2538  politicians: [
2539    { k: "filed", label: "Filed", val: function (r) { return r.filedDate || ""; }, html: function (r) { return '<span style="color:#e2e8f0;font-weight:700">' + _sEsc(r.filedDate) + "</span>"; } },
2540    { k: "name", label: "Politician", val: function (r) { return String(r.name || "").toLowerCase(); }, html: function (r) { return '<span style="color:#e2e8f0;font-weight:' + (r.tracked ? "800" : "600") + '">' + _sEsc(r.name) + "</span>" + (r.tracked ? ' <span style="font-size:9px;color:#a78bfa;font-weight:800">TRACKED</span>' : ""); } },
2541    { k: "ticker", label: "Ticker", val: function (r) { return String(r.ticker || (r.notes || "").split("\n")[0] || ""); }, html: function (r) {
2542        if (r.ticker) return _sTk(r.ticker);
2543        // No ticker = a non-listed asset (McCormick's muni bonds, private funds). UW puts the
2544        // asset's NAME on the first line of notes - show that instead of a dash, full notes on hover.
2545        var nm = String(r.notes || "").split("\n")[0].trim();
2546        if (!nm) return '<span style="color:#cbd5e1">-</span>';
2547        var short = nm.length > 30 ? nm.slice(0, 28) + '…' : nm;
2548        return '<span title="' + _sEsc(String(r.notes)) + '" style="color:#e2e8f0;font-weight:600;font-size:10.5px">' + _sEsc(short) + '</span>';
2549      } },
2550    { k: "type", label: "Side", val: function (r) { return String(r.type || ""); }, html: function (r) { return r.type === "Buy" ? '<span style="color:#4ade80;font-weight:800">BUY</span>' : (r.type === "Sell" ? '<span style="color:#f87171;font-weight:800">SELL</span>' : '<span style="color:#cbd5e1">' + _sEsc(r.type) + "</span>"); } },
2551    { k: "amt", label: "Amount", num: true, val: function (r) { return r.amt || 0; }, html: function (r) { return '<span style="color:#e2e8f0">' + _sEsc(r.amounts) + "</span>"; } },
2552    { k: "opt", label: "Contract", val: function (r) { return r.option ? 1 : 0; }, html: function (r) {
2553        if (!r.option) {
2554          // Congress reports VALUE BANDS, not sizes - but when the member typed a share count into
2555          // the asset description (Pelosi filings do: 'purchased 5000 shares', '10,000 shares.'),
2556          // show the real number instead of the generic word. The digits requirement keeps
2557          // 'Ordinary Shares (ACN)' from matching. Full notes text goes on the hover.
2558          var m = String(r.notes || '').match(/([\d][\d,]*)\s*shares/i);
2559          var tt = r.notes ? ' title="' + _sEsc(String(r.notes)) + '"' : '';
2560          if (m && m[1] && /\d/.test(m[1])) return '<span' + tt + ' style="color:#e2e8f0;font-weight:700">' + _sEsc(m[1].replace(/^,|,$/g, '')) + ' sh</span>';
2561          // Not a stock at all: no ticker + coupon/maturity text = a bond, label it honestly.
2562          if (!r.ticker && /rate\/coupon|matures:|bond/i.test(String(r.notes || ''))) return '<span' + tt + ' style="color:#cbd5e1">bond</span>';
2563          return '<span' + tt + ' style="color:#cbd5e1">shares</span>';
2564        }
2565        var ob = ((r.option.qty ? r.option.qty + "x " : "") + (r.option.type || "") + (r.option.strike ? " $" + r.option.strike : "") + (r.option.exp ? " " + r.option.exp : "")).trim();
2566        return '<span style="color:#fbbf24;font-weight:700">' + _sEsc(ob || "option") + "</span>";
2567      } },
2568    { k: "txn", label: "Traded", val: function (r) { return r.txnDate || ""; }, html: function (r) { return '<span style="color:#cbd5e1">' + _sEsc(r.txnDate) + "</span>"; } },
2569    { k: "lag", label: "Lag", num: true, val: function (r) { return r.lagDays == null ? -1 : r.lagDays; }, html: function (r) { return r.lagDays == null ? '<span style="color:#cbd5e1">-</span>' : '<span style="color:' + (r.lagDays > 45 ? "#f87171" : "#cbd5e1") + '">' + r.lagDays + "d</span>"; } }
2570  ],
2571  insiders: [
2572    { k: "filed", label: "Filed", val: function (r) { return r.filedDate || ""; }, html: function (r) { return '<span style="color:#e2e8f0;font-weight:700">' + _sEsc(r.filedDate) + "</span>"; } },
2573    { k: "txn", label: "Traded", val: function (r) { return r.txnDate || ""; }, html: function (r) { return '<span style="color:#cbd5e1">' + _sEsc(r.txnDate) + "</span>"; } },
2574    { k: "ticker", label: "Ticker", val: function (r) { return String(r.ticker || ""); }, html: function (r) { return _sTk(r.ticker); } },
2575    { k: "owner", label: "Insider", val: function (r) { return String(r.owner || "").toLowerCase(); }, html: function (r) { return '<span style="color:#e2e8f0">' + _sEsc(r.owner) + "</span>"; } },
2576    { k: "role", label: "Role", val: function (r) { return String(r.role || ""); }, html: function (r) { return '<span style="color:#cbd5e1">' + _sEsc(r.role) + "</span>"; } },
2577    { k: "side", label: "Side", val: function (r) { return r.buy ? 1 : 0; }, html: function (r) { return r.buy ? '<span style="color:#4ade80;font-weight:800">BUY</span>' : '<span style="color:#f87171;font-weight:800">SELL</span>'; } },
2578    { k: "shares", label: "Shares", num: true, val: function (r) { return r.shares || 0; }, html: function (r) { return '<span style="color:#e2e8f0">' + _sNum(r.shares) + "</span>"; } },
2579    { k: "price", label: "Price", num: true, val: function (r) { return r.price || 0; }, html: function (r) { return "$" + _sNum(r.price, 2); } },
2580    { k: "value", label: "Value", num: true, val: function (r) { return r.value || 0; }, html: function (r) { return '<span style="color:#e2e8f0;font-weight:700">' + _sUsd(r.value) + "</span>"; } },
2581    { k: "sector", label: "Sector", val: function (r) { return String(r.sector || ""); }, html: function (r) { return '<span style="color:#cbd5e1">' + _sEsc(r.sector) + "</span>"; } }
2582  ],
2583  f13: [
2584    { k: "filed", label: "Filed", val: function (r) { return r.filingDate || ""; }, html: function (r) { return '<span style="color:#e2e8f0;font-weight:700">' + _sEsc(r.filingDate) + "</span>"; } },
2585    { k: "quarter", label: "Quarter", val: function (r) { return r.reportDate || ""; }, html: function (r) { return '<span style="color:#cbd5e1">' + _sEsc(r.reportDate) + "</span>"; } },
2586    { k: "fund", label: "Fund", val: function (r) { return String(r.fund || "").toLowerCase(); }, html: function (r) { return '<span style="color:#e2e8f0;font-weight:700">' + _sEsc(r.fund) + "</span>"; } },
2587    { k: "ticker", label: "Ticker", val: function (r) { return String(r.ticker || ""); }, html: function (r) { return _sTk(r.ticker); } },
2588    { k: "action", label: "Action", val: function (r) { return r.isNew ? 2 : (r.change > 0 ? 1 : 0); }, html: function (r) { return r.isNew ? '<span style="color:#a78bfa;font-weight:800">NEW</span>' : (r.change > 0 ? '<span style="color:#4ade80;font-weight:800">ADD</span>' : '<span style="color:#f87171;font-weight:800">TRIM</span>'); } },
2589    { k: "shares", label: "Shares", num: true, val: function (r) { return Math.abs(r.change || 0); }, html: function (r) { return '<span style="color:#e2e8f0">' + _sNum(Math.abs(r.change)) + "</span>"; } },
2590    { k: "value", label: "Value", num: true, val: function (r) { return r.value || 0; }, html: function (r) { return '<span style="color:#e2e8f0;font-weight:700">' + _sUsd(r.value) + "</span>"; } },
2591    { k: "pct", label: "Change", num: true, val: function (r) { return r.pctChange == null ? -1e18 : r.pctChange; }, html: function (r) { return (r.isNew || r.pctChange == null) ? '<span style="color:#cbd5e1">-</span>' : '<span style="color:' + (r.pctChange > 0 ? "#4ade80" : "#f87171") + '">' + (r.pctChange > 0 ? "+" : "") + _sNum(r.pctChange, 0) + "%</span>"; } },
2592    { k: "avg", label: "Avg cost", num: true, val: function (r) { return r.avgPrice || 0; }, html: function (r) { return r.avgPrice ? "$" + _sNum(r.avgPrice, 2) : '<span style="color:#cbd5e1">-</span>'; } }
2593  ],
2594  fda: [
2595    { k: "target", label: "Target", val: function (r) { return r.target || ""; }, html: function (r) { return '<span style="color:' + (r.exact ? "#e2e8f0" : "#fbbf24") + ';font-weight:700">' + _sEsc(r.target) + "</span>" + (r.exact ? "" : ' <span style="font-size:9px;color:#cbd5e1">
2595est</span>'); } },
2596    { k: "ticker", label: "Ticker", val: function (r) { return String(r.ticker || ""); }, html: function (r) { return _sTk(r.ticker); } },
2597    { k: "event", label: "Event", val: function (r) { return String(r.event || ""); }, html: function (r) { return '<span style="color:#e2e8f0">' + _sEsc(r.event) + "</span>"; } },
2598    { k: "status", label: "Stage", val: function (r) { return String(r.status || ""); }, html: function (r) { return '<span style="color:#cbd5e1">' + _sEsc(r.status) + "</span>"; } },
2599    { k: "detail", label: "Detail", val: function (r) { return String(r.description || r.outcome || ""); }, html: function (r) { return '<span style="color:#cbd5e1">' + _sEsc(String(r.description || r.outcome || "").slice(0, 120)) + "</span>"; } }
2600    ,{ k: "bias", label: "Bias", num: true, val: function (r) { return _fdaBias(r); }, html: function (r) {
2601        var b = _fdaBias(r);
2602        if (b > 0) return '<span style="color:#22c55e;font-weight:800">BULLISH</span>';
2603        if (b < 0) return '<span style="color:#ef4444;font-weight:800">BEARISH</span>';
2604        return '<span style="color:#cbd5e1">Neutral</span>';
2605      } }
2606  ]
2607};
2608// Per-tab sort state. col null = server order (which is already the sensible default for each
2609// feed), matching the Flow tape's behaviour.
2610var smartSort = { politicians: { col: null, dir: -1 }, insiders: { col: null, dir: -1 }, f13: { col: null, dir: -1 }, fda: { col: null, dir: -1 } };
2611function smartSortHeader(k) {
2612  var st = smartSort[smartTab];
2613  // 3-state cycle, same as the Flow tape: descending -> ascending -> OFF (server order).
2614  if (st.col !== k) smartSort[smartTab] = { col: k, dir: -1 };
2615  else if (st.dir === -1) smartSort[smartTab] = { col: k, dir: 1 };
2616  else smartSort[smartTab] = { col: null, dir: -1 };
2617  _smartRender();
2618}
2619function _smartRender() {
2620  var body = document.getElementById("smartBody"), j = _smartData[smartTab];
2621  if (!body || !j) return;
2622  var fEl = document.getElementById("smartFilter");
2623  var f = (fEl ? fEl.value : "").trim().toLowerCase();
2624  var rows = (j.rows || []).filter(function (r) {
2625    if (!f) return true;
2626    return String(r.ticker || "").toLowerCase().indexOf(f) >= 0
2627      || String(r.name || r.owner || r.fund || "").toLowerCase().indexOf(f) >= 0;
2628  });
2629  if (smartTab === "politicians") {
2630    var _sb = document.getElementById("smartSub");
2631    if (_sb) _sb.textContent = (j.mode === "person" && j.name)
2632      ? j.name + " - full disclosed history (" + j.total + " filings). Filed date, not trade date."
2633      : SMART_SUB.politicians;
2634  }
2635  if (!rows.length) { body.innerHTML = '<div style="padding:24px;color:#cbd5e1;font-size:13px">Nothing to show' + (f ? " for that filter" : "") + ".</div>"; return; }
2636  var cols = SMART_COLS[smartTab] || [];
2637  var st = smartSort[smartTab] || { col: null, dir: -1 };
2638  if (st.col) {
2639    var cd = null;
2640    for (var ci = 0; ci < cols.length; ci++) if (cols[ci].k === st.col) cd = cols[ci];
2641    if (cd) rows = rows.slice().sort(function (a, b) {
2642      var va = cd.val(a), vb = cd.val(b);
2643      if (cd.num) return st.dir * ((va || 0) - (vb || 0));
2644      return st.dir * String(va).localeCompare(String(vb));
2645    });
2646  }
2647  var h = '<table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr>';
2648  for (var i = 0; i < cols.length; i++) {
2649    var on = st.col === cols[i].k;
2650    var arrow = on ? (st.dir < 0 ? " \u25BC" : " \u25B2") : "";
2651    h += '<th onclick="smartSortHeader(\'' + cols[i].k + '\')" title="click to sort" style="text-align:left;padding:7px 9px;border-bottom:1px solid ' + (on ? "rgba(96,165,250,.65)" : "var(--border)") + ';color:' + (on ? "#e2e8f0" : "#cbd5e1") + ';font-weight:800;font-size:11px;white-space:nowrap;cursor:pointer;user-select:none">' + cols[i].label + arrow + "</th>";
2652  }
2653  h += "</tr></thead><tbody>";
2654  for (var k2 = 0; k2 < rows.length; k2++) {
2655    h += '<tr style="border-bottom:1px solid rgba(30,41,59,.6)">';
2656    for (var m = 0; m < cols.length; m++) h += '<td style="padding:7px 9px;color:#cbd5e1;white-space:nowrap">' + cols[m].html(rows[k2]) + "</td>";
2657    h += "</tr>";
2658  }
2659  h += "</tbody></table>";
2660  body.innerHTML = h;
2661}
2662</script>
2662
2663<div id="ideasView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h,56px));overflow:hidden">
2664  <div style="display:flex;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px solid var(--border);background:var(--card);flex-wrap:wrap">
2665    <span style="font-size:15px;font-weight:800;color:#e2e8f0">Trade Ideas</span>
2666    <span id="ideasTabRow" style="display:inline-flex;gap:4px;margin-left:4px">
2667      <button class="ideas-tab" data-tab="setups" onclick="setIdeasTab('setups')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1">Setups</button>
2668      <button class="ideas-tab" data-tab="earnings" onclick="setIdeasTab('earnings')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1">Earnings</button>
2669      <button class="ideas-tab" data-tab="watchlist" onclick="setIdeasTab('watchlist')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1">Watchlist</button>
2670      <!-- Ghost Trade tab retired 2026-08-08 - 44% record matched the plain Watchlist (same base engine). -->
2671      <button class="ideas-tab" data-tab="index" onclick="setIdeasTab('index')" style="border:1px solid rgba(244,114,182,.5);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#f472b6">SPX/SPY</button>
2672      <button class="ideas-tab" data-tab="eod" onclick="setIdeasTab('eod')" title="Admin: EOD POWER HOUR - the 12:00-12:55 PM PT runner / fade plays - entry, noon readings, exit, result" style="border:1px solid rgba(245,158,11,.5);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#f59e0b">EOD</button>
2673      <button class="ideas-tab" data-tab="gflip" onclick="setIdeasTab('gflip')" title="Admin: every watchlist name vs its gamma flip (0DTE + ALL), today's crosses, and the shadow plays they fired" style="border:1px solid rgba(34,211,238,.5);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#22d3ee">G-Flip</button>
2674      <button class="ideas-tab" data-tab="prints" onclick="setIdeasTab('prints')" title="Admin: Discovery-style single prints off our own firehose - line-numbered with time, size, value, score, flags, GF mark and the tracked entry / current premium / P&L" style="border:1px solid rgba(245,158,11,.5);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#f59e0b">Whales</button>
2675      <button class="ideas-tab" data-tab="account" onclick="setIdeasTab('account')" title="Admin: the $3,000 paper account that takes every Setup, one contract each - balance, today, the equity line by period, buying power, open positions and closed trades" style="border:1px solid rgba(0,200,5,.55);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#4ade80">Account</button>
2676      <button class="ideas-tab" data-tab="alltrades" onclick="setIdeasTab('alltrades')" title="Admin: every trade entered and exited across Setups, Watchlist, G-Flip, SPY/SPX and EOD in one list, with a P/L card for each" style="border:1px solid rgba(34,211,238,.5);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#22d3ee">All Trades</button>
2677      <button class="ideas-tab" data-tab="insider" onclick="setIdeasTab('insider')" title="Pre-close inside bar watch — stocks on track to confirm Daily/Weekly rectangle breakouts, scanned 12:30 PM PT" style="border:1px solid rgba(251,191,36,.5);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:800;cursor:pointer;background:transparent;color:#fbbf24">Insider</button>
2678    </span>
2679    <span id="ideasSetupBar" style="display:contents">
2680    <span id="ideasSub" style="font-size:11px;color:var(--dim)">Top scored setups — flow · GEX · momentum · catalysts</span>
2681    <span style="margin-left:14px;display:inline-flex;gap:4px">
2682      <button class="ideas-sess" data-sess="am" onclick="setIdeasSession('am')" style="border:1px solid var(--border);border-radius:7px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;background:transparent;color:#cbd5e1">Morning</button>
2683      <button class="ideas-sess" data-sess="ah" onclick="setIdeasSession('ah')" title="After-hours scan across the whole universe (ETFs included) - the setups that look good for tomorrow, same grade system" style="border:1px solid var(--border);border-radius:7px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;background:transparent;color:#cbd5e1">After Hours</button>
2684      <button class="ideas-sess" data-sess="pm" onclick="setIdeasSession('pm')" hidden style="border:1px solid var(--border);border-radius:7px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;background:transparent;color:#cbd5e1">Evening</button>
2685    </span>
2686    <span style="display:inline-flex;gap:4px" title="Snapshot = the held daily list · Live = re-scan current flow/GEX now">
2687      <button class="ideas-mode" data-mode="snapshot" onclick="setIdeasMode('snapshot')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:700;cursor:pointer;background:transparent;color:#cbd5e1">Snapshot</button>
2688<!-- Live mode removed on request. The server still accepts ?mode=live and the client paths below
2689           are intact, so restoring it is just putting this button back:
2690           <button class="ideas-mode" data-mode="live" onclick="setIdeasMode('live')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:700;cursor:pointer;background:transparent;color:#cbd5e1">Live</button> -->
2691    </span>
2692    <button class="refresh-btn" onclick="loadTradeIdeas(true)" style="color:var(--text)">↻ Refresh</button>
2693    <span style="font-size:10.5px;color:#cbd5e1;white-space:nowrap" title="When the held lists are captured. Setups fire once — 9:45 AM ET, 15 min after the open, held all day. The Watchlist re-reads AFTER the close (4:15 PM ET) on the full recorded day, building tomorrow's list with time to review.">Fires: Setups <b style="color:#e2e8f0">6:45 AM PT</b> · Watchlist <b style="color:#e2e8f0">6:45 AM PT + after close 1:15 PM PT</b></span>
2694    <span id="ideasStamp" style="margin-left:auto;font-size:11px;color:var(--dim)"></span>
2695    </span>
2696    <span id="ideasEarnBar" style="display:none;align-items:center;gap:10px;flex:1">
2697      <span style="font-size:11px;color:var(--dim)">Earnings this week — flow · GEX · expected move. Risk-defined: max loss = premium.</span>
2698      <button class="refresh-btn" onclick="loadEarnings(true)" style="color:var(--text);margin-left:auto">↻ Refresh</button>
2699    </span>
2700    <span id="ideasWatchBar" style="display:none;align-items:center;gap:10px;flex:1;flex-wrap:wrap">
2701      <button class="wl-which" data-w="am" onclick="setWlWhich('am')" style="border:1px solid var(--border);border-radius:7px;padding:5px 12px;font-size:11.5px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1" title="Today's list — built 6:45 AM PT on the morning's real opening flow">Morning</button>
2702      <button class="wl-which" data-w="pm" onclick="setWlWhich('pm')" style="border:1px solid var(--border);border-radius:7px;padding:5px 12px;font-size:11.5px;font-weight:800;cursor:pointer;background:transparent;color:#cbd5e1" title="Built after the close (~1:15 PM PT) on the full recorded day — for the next session">After Hours</button>
2703      <span style="font-size:11px;color:#cbd5e1">Fires <b style="color:#e2e8f0">6:45 AM PT</b> &amp; <b style="color:#e2e8f0">after close ~1:15 PM PT</b>.</span>
2704      <button id="wlPullBtn" class="refresh-btn" onclick="loadWatchlist(true)" style="color:var(--text);margin-left:auto">↻ Pull now</button>
2705      <span id="watchStamp" style="font-size:11px;color:var(--dim)"></span>
2706    </span>
2707    <span id="ideasGhostBar" style="display:none;align-items:center;gap:10px;flex:1;flex-wrap:wrap">
2708      <span style="font-size:11px;color:#c084fc">Every signal, one list — top 10. Fires <b>6:45 AM PT</b> &amp; <b>12:40 PM PT</b>.</span>
2709      <button class="refresh-btn" onclick="loadGhostTrades(true)" style="color:var(--text);margin-left:auto">↻ Pull now</button>
2710      <span id="ghostStamp" style="font-size:11px;color:var(--dim)"></span>
2711    </span>
2712    <span id="ideasIdxBar" style="display:none;align-items:center;gap:10px;flex:1;flex-wrap:wrap">
2713      <span style="font-size:11px;color:#f472b6">Two lanes in parallel — SPX/SPY lane + QQQ lane, one live play each, play after play. <b>6:45 AM – 1 PM PT</b>. Contracts under $8.</span>
2714      <button class="refresh-btn" onclick="loadIndexPlays(true)" style="color:var(--text);margin-left:auto">↻ Refresh</button>
2715      <span id="idxStamp" style="font-size:11px;color:var(--dim)"></span>
2716    </span>
2717  </div>
2718  <div id="ideasSetupWrap" style="flex:1;min-height:0;display:flex;overflow:hidden">
2719    <div style="flex:1;min-width:0;overflow-y:auto;display:flex;flex-direction:column">
2720      <div id="ideasBody" style="padding:16px 18px 6px"><div class="col-loading">Loading trade ideas…</div></div>
2721      <div id="ideasToday" style="padding:0 18px 40px"></div>
2722    </div>
2723    <div id="ideasSide" style="width:430px;flex:none;display:flex;flex-direction:column;min-height:0;overflow-y:auto;border-left:1px solid var(--border);background:var(--card)">
2724      <div id="ideaCoach" style="flex:none;margin:12px 14px 0;border:1px solid rgba(56,189,248,.35);border-radius:10px;padding:10px 12px;background:rgba(56,189,248,.05);display:none"></div>
2725      <div id="ideasTracker" style="flex:none;padding:14px 14px 40px"><div class="col-loading">Track record…</div></div>
2726    </div>
2727  </div>
2728  <div id="earningsWrap" style="display:none;flex:1;min-height:0;flex-direction:column;overflow:hidden">
2729    <div id="earningsDates" style="display:flex;gap:6px;padding:10px 18px;border-bottom:1px solid var(--border);overflow-x:auto;flex:none"></div>
2730    <div style="flex:1;min-height:0;display:flex;overflow:hidden">
2731      <div id="earningsBody" style="flex:1;min-width:0;overflow-y:auto;padding:16px 18px 40px"><div class="col-loading">Loading earnings…</div></div>
2732      <div id="earningsSide" style="width:370px;flex:none;border-left:1px solid var(--border);background:var(--card);display:flex;flex-direction:column;overflow:hidden">
2733        <div style="display:flex;gap:5px;padding:9px 12px;border-bottom:1px solid var(--border);flex:none">
2734          <button id="earnRTab-outcomes" class="tf-btn active" onclick="setEarnRightTab('outcomes')" style="font-size:10px;padding:3px 10px">Outcomes</button>
2735          <button id="earnRTab-cal" class="tf-btn" onclick="setEarnRightTab('cal')" style="font-size:10px;padding:3px 10px" title="Every company reporting this week — sun = before the open, moon = after the close">Calendar</button>
2736        </div>
2737        <div id="earningsOutcomes" style="flex:1;min-height:0;overflow-y:auto;padding:14px 14px 40px"><div class="col-loading">Outcomes…</div></div>
2738        <div id="earningsCal" style="display:none;flex:1;min-height:0;flex-direction:column;overflow:hidden">
2739          <div id="earnCalDays" style="display:flex;gap:4px;padding:8px 10px;border-bottom:1px solid var(--border);flex:none;flex-wrap:wrap"></div>
2740          <div id="earnCalBody" style="flex:1;min-height:0;overflow-y:auto;padding:0 0 40px"><div class="col-loading">Loading calendar…</div></div>
2741        </div>
2742      </div>
2743    </div>
2744  </div>
2745  <div id="watchlistWrap" style="display:none;flex:1;min-height:0;overflow:hidden">
2746    <div id="watchlistBody" style="flex:1;min-width:0;overflow-y:auto;padding:16px 18px 40px"><div class="col-loading">Loading watchlist…</div></div>
2747    <div id="watchlistSide" style="width:340px;flex:none;display:flex;flex-direction:column;min-height:0;overflow-y:auto;border-left:1px solid var(--border);background:var(--card)">
2748      <div id="wlCoach" style="flex:none;margin:12px 14px 0;border:1px solid rgba(56,189,248,.35);border-radius:10px;padding:10px 12px;background:rgba(56,189,248,.05);display:none"></div>
2749      <div id="wlTracker" style="flex:none;padding:14px 14px 40px"><div class="col-loading">P&amp;L…</div></div>
2750    </div>
2751  </div>
2752  <div id="ghostWrap" style="display:none;flex:1;min-height:0;overflow:hidden">
2753    <div id="ghostBody" style="flex:1;min-width:0;overflow-y:auto;padding:16px 18px 40px"><div class="col-loading">Loading Ghost Trades…</div></div>
2754    <div id="ghostTracker" style="width:340px;flex:none;border-left:1px solid var(--border);background:var(--card);overflow-y:auto;padding:14px 14px 40px"><div class="col-loading">P&amp;L…</div></div>
2755  </div>
2756  <div id="idxWrap" style="display:none;flex:1;min-height:0;overflow:hidden">
2757    <div id="idxBody" style="flex:1;min-width:0;overflow-y:auto;padding:16px 18px 40px"><div class="col-loading">Loading plays…</div></div>
2758    <div id="idxSide" style="width:430px;flex:none;display:flex;flex-direction:column;min-height:0;overflow-y:auto;border-left:1px solid var(--border);background:var(--card)">
2759      <div id="idxCoach" style="flex:none;margin:12px 14px 0;border:1px solid rgba(244,114,182,.35);border-radius:10px;padding:10px 12px;background:rgba(244,114,182,.05);display:none"></div>
2760      <div id="idxTracker" style="flex:none;padding:14px 14px 40px"><div class="col-loading">P&amp;L…</div></div>
2761    </div>
2762  </div>
2763  <div id="eodWrap" style="display:none;flex:1;min-height:0;overflow:hidden">
2764    <div id="eodBody" style="flex:1;min-width:0;overflow-y:auto;padding:16px 18px 40px"><div class="col-loading">Loading EOD plays…</div></div>
2765    <div id="eodTracker" style="width:400px;flex:none;border-left:1px solid var(--border);background:var(--card);overflow-y:auto;padding:14px 14px 40px"><div class="col-loading">P&amp;L…</div></div>
2766  </div>
2767  <div id="gflipWrap" style="display:none;flex:1;min-height:0;overflow:hidden">
2768    <div id="gflipBody" style="flex:1;min-width:0;overflow-y:auto;padding:16px 18px 40px"><div class="col-loading">Loading G-Flip…</div></div>
2769    <div id="gflipTracker" style="width:400px;flex:none;border-left:1px solid var(--border);background:var(--card);overflow-y:auto;padding:14px 14px 40px"><div class="col-loading">P&amp;L…</div></div>
2770  </div>
2771  <!-- Prints (admin, 2026-09-04) - Discovery-style single prints, line-numbered, with the tracked entry -> now P&L on the right. -->
2772  <!-- ALL TRADES (user 2026-09-09). One list across every book, newest exit first, with a P/L card
2773       per row. Admin only: gated in setIdeasTab, hidden in both non-admin sweeps, and the three
2774       endpoints behind it are requireAdminUser. -->
2775  <!-- ACCOUNT (admin, 2026-09-14): the $3,000 paper account as the broker's account screen. Black like the
2776       reference; the numbers come from /api/setups-account, the line is drawn here for the selected period. -->
2777  <div id="accountWrap" style="display:none;flex:1;min-height:0;overflow:auto;flex-direction:column;gap:10px">
2778    <div style="display:flex;gap:28px;align-items:stretch;flex-wrap:wrap;background:#fff;border-radius:12px;padding:26px 30px;min-height:420px;font-family:'Inter',-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">
2779      <div style="flex:1 1 560px;min-width:0;display:flex;flex-direction:column">
2780        <div style="display:flex;align-items:center;gap:6px"><span style="font-size:30px;font-weight:500;color:#1c1c1c;line-height:1.1">Agentic</span><svg width="17" height="17" viewBox="0 0 16 16" style="display:block;margin-top:3px"><path d="M3 5.5 L8 10.5 L13 5.5" fill="none" stroke="#1c1c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
2781        <div id="acctBal" style="font-size:32px;font-weight:500;color:#1c1c1c;margin-top:4px;line-height:1.1;letter-spacing:-.5px">-</div>
2782        <div id="acctChg" style="font-size:15px;font-weight:600;color:#00c805;margin-top:6px">-</div>
2783        <div id="acctOpen" style="display:none;font-size:15px;font-weight:600;color:#00c805;margin-top:2px"></div>
2784        <svg id="acctChart" viewBox="0 0 1000 240" preserveAspectRatio="none" style="width:100%;height:220px;margin:18px 0 10px;display:block"></svg>
2785        <div style="display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e6e6e6">
2786          <div id="acctTabs" style="display:flex;gap:26px;align-items:center;font-size:15px;font-weight:600;padding:2px 0 10px"></div>
2787          <svg width="18" height="18" viewBox="0 0 24 24" style="display:block"><path fill="#6b7280" d="M19.14 12.94a7.49 7.49 0 000-1.88l2.03-1.58a.5.5 0 00.12-.64l-1.92-3.32a.5.5 0 00-.61-.22l-2.39.96a7.3 7.3 0 00-1.62-.94l-.36-2.54a.5.5 0 00-.5-.42h-3.84a.5.5 0 00-.5.42l-.36 2.54c-.59.24-1.13.56-1.62.94l-2.39-.96a.5.5 0 00-.61.22L2.68 8.84a.5.5 0 00.12.64l2.03 1.58a7.49 7.49 0 000 1.88l-2.03 1.58a.5.5 0 00-.12.64l1.92 3.32a.5.5 0 00.61.22l2.39-.96c.49.38 1.03.7 1.62.94l.36 2.54a.5.5 0 00.5.42h3.84a.5.5 0 00.5-.42l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96
2787a.5.5 0 00.61-.22l1.92-3.32a.5.5 0 00-.12-.64l-2.03-1.58zM12 15.5A3.5 3.5 0 1112 8.5a3.5 3.5 0 010 7z"/></svg>
2788        </div>
2789        <div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e6e6e6">
2790          <span style="font-size:15px;font-weight:400;color:#1c1c1c" title="The balance less what the open contracts cost to open">Buying power <span style="display:inline-block;width:14px;height:14px;border:1.5px solid #9ca3af;border-radius:50%;font-size:9px;line-height:12px;text-align:center;color:#9ca3af;font-weight:700;vertical-align:1px">i</span></span>
2791          <span style="display:flex;align-items:center;gap:6px"><span id="acctBp" style="font-size:15px;font-weight:400;color:#1c1c1c">-</span><svg width="14" height="14" viewBox="0 0 16 16" style="display:block"><path d="M3 5.5 L8 10.5 L13 5.5" fill="none" stroke="#6b7280" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span></div>
2792      </div>
2793      <div style="flex:0 0 340px;max-width:100%;border:1px solid #e6e6e6;border-radius:10px;display:flex;flex-direction:column;max-height:560px;overflow:auto">
2794        <div style="padding:16px 18px;border-bottom:1px solid #f0f0f0;font-size:16px;font-weight:700;color:#1c1c1c">Options</div>
2795        <div id="acctOpenList"></div>
2796        <div style="padding:16px 18px;border-top:1px solid #f0f0f0;border-bottom:1px solid #f0f0f0;font-size:16px;font-weight:700;color:#1c1c1c">Closed</div>
2797        <div id="acctClosedList"></div>
2798      </div>
2799    </div>
2800  </div>
2801  <div id="allTradesWrap" style="display:none;flex:1;min-height:0;overflow:auto;flex-direction:column;gap:10px">
2802    <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:2px 0">
2803      <span style="font-size:12px;font-weight:800;color:#e2e8f0">ALL TRADES</span>
2804      <select id="atDays" onchange="loadAllTrades()" style="background:var(--t-bg2,#0b1220);color:#e2e8f0;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:11px;font-weight:700">
2805        <option value="1">Today</option><option value="3">3 days</option><option value="7" selected>7 days</option><option value="14">14 days</option><option value="30">30 days</option>
2806      </select>
2807      <button onclick="loadAllTrades()" style="background:transparent;color:#22d3ee;border:1px solid rgba(34,211,238,.5);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:800;cursor:pointer">Refresh</button>
2808
2809      <span id="atTotals" style="margin-left:auto;font-size:12px;font-weight:800;color:#cbd5e1"></span>
2810    </div>
2811    <div id="atBody" style="font-size:12px;color:#cbd5e1">Loading...</div>
2812  </div>
2813  <div id="printsWrap" style="display:none;flex:1;min-height:0;overflow:hidden">
2814    <div id="printsBody" style="flex:1;min-width:0;overflow-y:auto;padding:16px 18px 40px"><div class="col-loading">Loading whales…</div></div>
2815    <div id="printsTracker" style="width:400px;flex:none;border-left:1px solid var(--border);background:var(--card);overflow-y:auto;padding:14px 14px 40px"><div class="col-loading">P&amp;L…</div></div>
2816  </div>
2817  <!-- Insider (admin) — the 12:30 PM PT pre-close inside bar watch, full list per day. -->
2818  <div id="insiderWrap" style="display:none;flex:1;min-height:0;overflow:hidden">
2819    <div id="insiderBody" style="flex:1;min-width:0;overflow-y:auto;padding:16px 18px 40px"><div class="col-loading">Loading…</div></div>
2820  </div>
2821</div>
2822
2823<!-- Tracking View (new layout) -->
2824<div id="trackingView" style="display:none;flex-direction:column;height:calc(100dvh - var(--header-h,56px));overflow-y:auto;padding:16px 22px">
2825  <div id="trackingBody"><div class="col-loading">Loading positions…</div></div>
2826</div>
2827
2828<script>
2829// Check if user is admin
2830// Track the header's real height (it wraps on small screens) so mobile views can
2831// size themselves to the exact remaining space, and re-fit the chart on resize/rotate.
2832// ── Toast: small transient notice (TV sync, etc.) ──
2833let _toastTimer = null;
2834function showToast(msg) {
2835  let t = document.getElementById('gfToast');
2836  if (!t) { t = document.createElement('div'); t.id = 'gfToast'; document.body.appendChild(t); }
2837  t.textContent = msg;
2838  t.classList.add('show');
2839  clearTimeout(_toastTimer);
2840  _toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
2841}
2842
2843// ── User timezone preference ─────────────────────────────────────────────
2844// "Auto" = the viewer's own browser timezone; or they can pick ET/CT/MT/PT/UTC
2845// from the menu. Event times (flow, alerts) AND the intraday chart axis follow
2846// this. Market-structure checks (open/close, "resumes 9:30 ET") stay in ET on
2847// purpose — those describe the market, not the user.
2848// Resolving the timezone through Intl is slow enough to matter when every formatted time asks;
2849// the answer is remembered for a second, so a change in Settings still shows on the next paint.
2850let _userTZC = { t: 0, v: '' };
2851function getUserTZ() {
2852  const now = Date.now();
2853  if (_userTZC.v && now - _userTZC.t < 1000) return _userTZC.v;
2854  const v = _getUserTZNow(); _userTZC = { t: now, v }; return v;
2855}
2856function _getUserTZNow() {
2857  try { const v = localStorage.getItem('gexTimeZone'); if (v && v !== 'auto') return v; } catch (e) {}
2858  try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/New_York'; } catch (e) { return 'America/New_York'; }
2859}
2860// DST-aware UTC offset (in seconds) of a timezone at a given instant.
2861function tzOffsetSec(tz, dateMs) {
2862  try {
2863    const d = new Date(dateMs);
2864    const utc = new Date(d.toLocaleString('en-US', { timeZone: 'UTC' }));
2865    const tgt = new Date(d.toLocaleString('en-US', { timeZone: tz }));
2866    return Math.round((tgt.getTime() - utc.getTime()) / 1000);
2867  } catch (e) { return 0; }
2868}
2869// Format an instant (ms / ISO / Date) as a clock time in the user's timezone.
2870// Building an Intl formatter is the expensive part of formatting a time (measured: 0.16ms a call,
2871// 128ms for one flow tape). One per timezone+options, reused; the output is the same string.
2872const _fmtTimeF = new Map();
2873function fmtTime(t, opts) {
2874  const d = (t instanceof Date) ? t : new Date(t);
2875  if (isNaN(d)) return '';
2876  const o = Object.assign({ hour: '2-digit', minute: '2-digit', timeZone: getUserTZ() }, opts || {});
2877  const k = JSON.stringify(o);
2878  let f = _fmtTimeF.get(k);
2879  if (!f) {
2880    try { f = new Intl.DateTimeFormat('en-US', o); }
2881    catch (e) { try { f = new Intl.DateTimeFormat('en-US', opts || {}); } catch (e2) { return d.toLocaleTimeString('en-US', opts || {}); } }
2882    if (_fmtTimeF.size > 32) _fmtTimeF.clear();
2883    _fmtTimeF.set(k, f);
2884  }
2885  return f.format(d);
2886}
2887// Short timezone abbreviation (EDT, PST, …) for the user's tz at an instant.
2888function tzAbbr(dateMs) {
2889  try {
2890    const parts = new Intl.DateTimeFormat('en-US', { timeZone: getUserTZ(), timeZoneName: 'short' }).formatToParts(new Date(dateMs || Date.now()));
2891    const p = parts.find(x => x.type === 'timeZoneName');
2892    return p ? p.value : '';
2893  } catch (e) { return ''; }
2894}
2895// Change the preference and reload — simplest reliable way to re-render every
2896// time string and re-offset the chart axis in one shot.
2897function setUserTZ(v) { try { localStorage.setItem('gexTimeZone', v); } catch (e) {} location.reload(); }
2898// Reflect the saved choice in the menu dropdown (the <select> already exists in the DOM by now).
2899try { const _tzs = document.getElementById('tzSelect'); if (_tzs) _tzs.value = (function(){ try { return localStorage.getItem('gexTimeZone') || 'auto'; } catch(e){ return 'auto'; } })(); } catch (e) {}
2900
2901// ── Market status badge (header) — live / pre-market / after-hours / closed ──
2902function updateMarketBadge() {
2903  const el = document.getElementById('mktBadge');
2904  if (!el) return;
2905  let label = '', color = '';
2906  try {
2907    const et = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' }));
2908    const day = et.getDay();
2909    const mins = et.getHours() * 60 + et.getMinutes();
2910    if (day === 0 || day === 6) { label = '🔴 CLOSED · WEEKEND'; color = 'var(--dim)'; }
2911    else if (typeof gfIsMarketHoliday === 'function' && gfIsMarketHoliday()) { label = '🔴 CLOSED · HOLIDAY'; color = 'var(--dim)'; }
2912    else if (mins >= 570 && mins < 960) { label = '🟢 MARKET OPEN'; color = 'var(--green)'; }
2913    else if (mins >= 240 && mins < 570) { label = '🟠 PRE-MARKET'; color = '#f59e0b'; }
2914    else if (mins >= 960 && mins < 1200) { label = '🟡 AFTER HOURS'; color = '#eab308'; }
2915    else { label = '🔴 CLOSED'; color = 'var(--dim)'; }
2916  } catch (e) { return; }
2917  if (el.textContent !== label) el.textContent = label;
2918  el.style.color = color;
2919  // Timezone badge — shows the zone all event times + the chart are displayed in.
2920  const tzEl = document.getElementById('tzBadge');
2921  if (tzEl) { const ab = tzAbbr(); if (ab && tzEl.textContent !== ab) tzEl.textContent = ab; }
2922}
2923updateMarketBadge();
2924setInterval(updateMarketBadge, 30 * 1000);
2925
2926// ── 🔍 Scanner view — whole-market screener ──────────────────
2927function fmtVolNum(v) { if (v == null) return '—'; if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B'; if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'; if (v >= 1e3) return (v / 1e3).toFixed(0) + 'K'; return String(Math.round(v)); }
2928function fmtCapNum(v) { if (v == null) return '—'; if (v >= 1e12) return (v / 1e12).toFixed(2) + 'T'; if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B'; if (v >= 1e6) return (v / 1e6).toFixed(0) + 'M'; return String(Math.round(v)); }
2929// Sort state — driven by the dropdown AND by clicking ANY column header.
2930let scanSortKey = 'perc_change', scanSortDir = 'desc';
2931let scanRows = [];
2932const SCAN_NUMERIC = ['perc_change', 'volume', 'marketcap']; // server can sort these whole-market
2933function scanCmp(a, b) {
2934  if (scanSortKey === 'ticker') {
2935    const r = String(a.ticker).localeCompare(String(b.ticker));
2936    return scanSortDir === 'desc' ? -r : r;
2937  }
2938  let av, bv;
2939  if (scanSortKey === 'prevClose') { av = rowPrev(a); bv = rowPrev(b); }
2940  else if (scanSortKey === 'chg') { av = rowChg(a); bv = rowChg(b); }
2941  else { const f = { perc_change: 'pct', price: 'price', volume: 'volume', marketcap: 'marketcap' }[scanSortKey] || 'pct'; av = a[f]; bv = b[f]; }
2942  av = av == null ? -Infinity : av; bv = bv == null ? -Infinity : bv;
2943  return scanSortDir === 'desc' ? bv - av : av - bv;
2944}
2945// Prev-day close (from the screener, else derived from price + % move) and the $ change.
2946function rowPrev(r) { return (r.prevClose != null) ? r.prevClose : (r.price != null && r.pct != null ? r.price / (1 + r.pct / 100) : null); }
2947function rowChg(r) { const p = rowPrev(r); return (r.price != null && p != null) ? r.price - p : null; }
2948function scanSortBy(key) {
2949  if (scanSortKey === key) scanSortDir = scanSortDir === 'desc' ? 'asc' : 'desc';
2950  else { scanSortKey = key; scanSortDir = key === 'ticker' ? 'asc' : 'desc'; }
2951  const sel = document.getElementById('scanSort');
2952  if (sel && SCAN_NUMERIC.includes(key)) sel.value = key;
2953  // Market mode + a server-sortable numeric column → refetch (sorts the WHOLE market).
2954  // Everything else (ticker, price, or any column in watchlist mode) → instant client re-sort.
2955  if (scanMode === 'market' && SCAN_NUMERIC.includes(key)) loadScanner();
2956  else renderScanRows();
2957}
2958// Scanner mode: 'market' (whole market) | 'watch' (the user's full watchlist) |
2959// 'custom' (a separate user-built list with its own add/delete + alerts).
2960let scanMode = 'market';
2961let scanWatchList = [];        // the user's custom scanner watchlist (server-persisted)
2962let scanWatchLoaded = false;
2963let _ultraSwipeHinted = false; // phone: show the ultra "swipe" peek nudge once per session
2964// ═══════════════════════════════════════════════════════════════════════
2965// Scanner → NODE ALERTS (admin). Watches each ticker's price vs its GEX nodes
2966// (King / Call Wall / Put Wall / Vol Node) and fires an alert when price is
2967// within a tolerance % of a selected node. Grouped by sector; side feed.
2968// ═══════════════════════════════════════════════════════════════════════
2969const NODE_META = {
2970  king: { label: 'King Node', emoji: '👑', color: '#eab308', abbr: 'KN' },
2971  call: { label: 'Call Wall', emoji: '📈', color: '#22c55e', abbr: 'CW' },
2972  put:  { label: 'Put Wall',  emoji: '📉', color: '#ef4444', abbr: 'PW' },
2973  vol:  { label: 'Vol Node',  emoji: '🔵', color: '#3b82f6', abbr: 'VN' },
2974};
2975// Each sector renders as its own equal-sized column (see renderNodeAlerts). Sectors are
2976// NOT merged — the grid gives every sector a uniform card so the layout stays clean.
2977let nodeTypes = new Set(['king', 'call', 'put', 'vol']);   // Vol Node on by default (users can toggle in the node bar)
2978let nodeTol = 0;
2978                  // % window around a node that counts as "hit" (0 = only a real cross)
2979let nodeFeed = [];                // {ticker, node, strike, price, t}
2980let nodeFeedFilter = 'all';       // side-panel filter by node type (all/king/call/put/vol)
2981let nodeHit = {};                 // "TICKER|node" -> currently-hit (edge detect)
2982let nodeLevels = {};              // TICKER -> { spot, king, call, put, vol }
2983let nodeArmed = new Set();        // tickers armed for a desktop notification on a node touch
2984let nodeNotify = false;           // browser Notification permission granted + enabled
2985let scannerTab = (function(){ try { const v = localStorage.getItem('gexScanTab'); return (v === 'nodes' || v === 'movers') ? v : 'movers'; } catch (e) { return 'movers'; } })(); // restore sub-tab on reload
2986let _savedScanTab = scannerTab; // captured before boot can overwrite it (login resolves after the view restore)
2987let _nodePollTimer = null;
2988let _savedAlertBarDisplay = '';
2989try {
2990  const s = localStorage.getItem('gf_nodeTypes'); if (s) { const a = JSON.parse(s);
2990 if (Array.isArray(a) && a.length) nodeTypes = new Set(a); }
2991  const t = parseFloat(localStorage.getItem('gf_nodeTol')); if (isFinite(t) && t >= 0) nodeTol = t;
2992  const ar = localStorage.getItem('gf_nodeArmed'); if (ar) { const a = JSON.parse(ar); if (Array.isArray(a)) nodeArmed = new Set(a); }
2993} catch (e) {}
2994try { _loadNodeFeed(); } catch (e) {}   // restore today's node-alert log (cleared automatically on a new day)
2995// Desktop notifications only fire once permission is granted AND the user turned them on.
2996try { nodeNotify = (typeof Notification !== 'undefined' && Notification.permission === 'granted' && localStorage.getItem('gf_nodeNotify') === '1'); } catch (e) {}
2997function saveNodePrefs() { try { localStorage.setItem('gf_nodeTypes', JSON.stringify([...nodeTypes])); localStorage.setItem('gf_nodeTol', String(nodeTol)); localStorage.setItem('gf_nodeArmed', JSON.stringify([...nodeArmed])); localStorage.setItem('gf_nodeNotify', nodeNotify ? '1' : '0'); } catch (e) {} }
2998// Explicit save (also re-reads the tolerance box, in case it was edited without blur) + confirms.
2999function saveNodeSettings() {
3000  try { const el = document.getElementById('nodeTol'); if (el) { const v = parseFloat(el.value); if (!isNaN(v)) nodeTol = Math.max(0, v); } } catch (e) {}
3001  saveNodePrefs();
3002  const b = document.getElementById('nodeSaveBtn');
3003  if (b) { const o = b.innerHTML; b.innerHTML = '✓ Saved'; b.disabled = true; setTimeout(() => { b.innerHTML = o; b.disabled = false; }, 1400); }
3004}
3005function nodeHexRgba(hex, a) { const n = parseInt(hex.slice(1), 16); return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')'; }
3006function nodeTimeStr(t) { try { if (typeof fmtTime === 'function') return fmtTime(t); } catch (e) {} return new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }
3007// ── Setting alerts on nodes ──────────────────────────────────────────────────
3008// "Arm" a ticker so a node touch also raises a desktop notification (in addition to the
3009// in-page feed + toast). Fires while the tab is open OR backgrounded. Persisted per browser.
3010// NOTE: for alerts that fire when the dashboard is fully closed we'd move detection
3011// server-side and reuse the move-alert delivery (/api/alerts/config) — easy follow-up.
3012// Arming used to live ONLY in this browser's localStorage, so what you armed here and what you
3013// set on the account page were two unconnected lists — and the NODE feed reads the ACCOUNT one
3014// (user 2026-08-24: alerts nobody had set, then no alerts at all). Arming now writes through to
3015// the account, so one click covers the desktop notification here, the extension's NODE tab and
3016// Ghost Mode's Movers chip. Failures are non-fatal: the local arm still works for this browser.
3017// ARM THE LEVELS ACTUALLY SELECTED (user 2026-09-09: "i never set vol and call wall"). This used to
3018// send level:'all', which arms all four on the server no matter which node-type chips were on. The
3019// dashboard filtered locally so the right panel looked correct, but the extension reads the server
3020// list and reported crossings the account never asked for. nodeTypes is the selection; send it.
3021var _NODE_LVL_OUT = { king: 'king', call: 'call_wall', put: 'put_wall', vol: 'vol_node' };
3022function _nodeLevelsSelected(){
3023  return [...nodeTypes].map(function(n){ return _NODE_LVL_OUT[n]; }).filter(Boolean);
3024}
3025function _armServerMany(tickers, on){
3026  try{
3027    return fetch('/api/alerts/levels-set', { method:'POST', headers:{'content-type':'application/json'}, credentials:'include',
3028      body: JSON.stringify({ tickers: tickers, levels: on ? _nodeLevelsSelected() : [] }) })
3029      .then(function(r){ return r.json().catch(function(){ return null; }); })
3030      .then(function(j){ try{ refreshNodeArmed(); }catch(e){} return j; })
3031      .catch(function(){});
3032  }catch(e){ return Promise.resolve(); }
3033}
3034function _armServer(t, on) { return _armServerMany([t], on); }
3035function toggleNodeArm(t) {
3036  const on = !nodeArmed.has(t);
3037  if (on) nodeArmed.add(t); else nodeArmed.delete(t);
3038  _armServer(t, on);
3039  if (nodeArmed.size && !nodeNotify) enableNodeNotify(); // arming implies you want to be notified
3040  saveNodePrefs(); renderNodeAlerts();
3041}
3042// Seed the armed set from the ACCOUNT's watches on load, so alerts set anywhere — the account
3043// page, the Ghost AI log's clickable levels — also raise a desktop notification here.
3044function _seedArmedFromAccount() {
3045  try {
3046    fetch('/api/alerts/config').then(function (r) { return r.json(); }).then(function (j) {
3047      const w = (j && j.alerts && j.alerts.levels && Array.isArray(j.alerts.levels.watches)) ? j.alerts.levels.watches : [];
3048      if (w.length) {
3049        // Account -> browser: alerts set on the account page (or by clicking a level in the Ghost
3050        // AI log) also raise a desktop notification and show in the side-panel feed here.
3051        let added = 0;
3052        for (const x of w) { const tk = String(x.ticker || '').toUpperCase(); if (tk && !nodeArmed.has(tk)) { nodeArmed.add(tk); added++; } }
3053        if (added) { saveNodePrefs(); try { renderNodeAlerts(); } catch (e) {} }
3054        return;
3055      }
3056      // ONE-TIME MIGRATION (2026-08-25). Arming used to be localStorage-only, so when node alerts
3057      // became per-account every member who had armed tickets in their browser suddenly matched
3058      // NOTHING server-side and their alerts went silent — a member reported exactly this. If the
3059      // account has no watches but this browser does, push them up once so the selection they
3060      // already made keeps working, on the extension and the dashboard alike.
3061      if (!nodeArmed.size) return;
3062      try { if (localStorage.getItem('gf_nodeArmMigrated') === '1') return; } catch (e) {}
3063      const list = [...nodeArmed].slice(0, 40);   // server caps watches at 50 (4 levels each)
3064      Promise.all(list.map(function (t) { return _armServer(t, true); }))
3065        .catch(function () {})
3066        .then(function () { try { localStorage.setItem('gf_nodeArmMigrated', '1'); } catch (e) {} });
3067      console.log('[NodeAlerts] migrated ' + list.length + ' locally-armed tickers to the account');
3068    }).catch(function () {});
3069  } catch (e) {}
3070}
3071try { _seedArmedFromAccount(); } catch (e) {}
3072// "Alert all" fires one POST per ticker; coalesce the refresh so 118 arms cost one config read.
3073var _nodeArmedT=null;
3074function _nodeArmedRefreshSoon(){ clearTimeout(_nodeArmedT); _nodeArmedT=setTimeout(function(){ refreshNodeArmed(); }, 1200); }
3075function armAllVisible(on) {
3076  const all = [...new Set(Object.values(WATCHLIST).flat().map(function(t){ return String(t).toUpperCase(); }).filter(Boolean))];
3077  for (const t of all) { if (on) nodeArmed.add(t); else nodeArmed.delete(t); }
3078  // ONE request for the whole watchlist. It used to fire a POST per ticker - 118 of them - and each
3079  // one re-truncated the stored list server-side, which is why "Alert all" kept landing at 50 pairs
3080  // with a ticker half armed. One write also means it cannot half-apply.
3081  _armServerMany(all, on).then(function(j){
3082    try{
3083      if(j && j.ok) showToast(on ? ('Armed ' + j.tickers + ' tickers x ' + (j.levels||[]).length + ' levels (' + j.pairs + ' watches)' + (j.truncated ? ' - list full, some dropped' : '')) : 'Cleared all node alerts');
3084      else if(j && j.message) showToast(j.message);
3085    }catch(e){}
3086  });
3087  if (on && !nodeNotify) enableNodeNotify();
3088  saveNodePrefs(); renderNodeAlerts();
3089}
3090function enableNodeNotify() {
3091  if (typeof Notification === 'undefined') { try { showToast('This browser can\'t show desktop notifications'); } catch (e) {} return; }
3092  if (Notification.permission === 'granted') { nodeNotify = true; saveNodePrefs(); updateNodeNotifyBtn(); return; }
3093  if (Notification.permission === 'denied') { try { showToast('Notifications are blocked — enable them in your browser site settings'); } catch (e) {} return; }
3094  Notification.requestPermission().then(function (p) { nodeNotify = (p === 'granted'); saveNodePrefs(); updateNodeNotifyBtn(); if (nodeNotify) { try { showToast('🔔 Node alerts on — armed tickers will notify you'); } catch (e) {} } });
3095}
3096function toggleNodeNotify() {
3097  if (nodeNotify) { nodeNotify = false; saveNodePrefs(); updateNodeNotifyBtn(); }
3098  else enableNodeNotify();
3099}
3100function updateNodeNotifyBtn() {
3101  const b = document.getElementById('nodeNotifyBtn'); if (!b) return;
3102  b.textContent = nodeNotify ? '🔔 Alerts on' : '🔕 Alerts off';
3103  b.style.color = nodeNotify ? '#22c55e' : 'var(--dim)';
3104  b.style.borderColor = nodeNotify ? 'rgba(34,197,94,0.4)' : 'var(--border)';
3105}
3106function fireNodeNotification(ticker, node, strike, price) {
3107  if (!nodeNotify || !nodeArmed.has(ticker) || typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
3108  const m = NODE_META[node];
3109  try {
3110    const n = new Notification('⚡ ' + ticker + ' hit ' + m.label, { body: m.emoji + ' ' + m.label + ' ' + fmtStrike(strike) + (price != null ? '  ·  price ' + price.toFixed(2) : ''), tag: 'node-' + ticker + '-' + node, icon: '/favicon.svg' });
3111    setTimeout(function () { try { n.close(); } catch (e) {} }, 12000);
3112  } catch (e) {}
3113}
3114
3115function setScannerTab(tab) {
3116  try{ if(typeof gfWsReapplySide==='function') setTimeout(function(){ gfWsReapplySide('pulse'); }, 300); }catch(e){}
3117  if ((tab === 'nodes' || tab === 'overview') && !isLoggedInUser) tab = 'movers';
3118  scannerTab = tab;
3119  try { _dupTake('scanner', tab); } catch (e) {}   // picked here: the original takes the tab back from a copy
3120  try { localStorage.setItem('gexScanTab', tab); } catch (e) {}
3121  const showMovers = tab === 'movers';
3122  const showOverview = tab === 'overview';
3123  const showNodes = tab === 'nodes';
3124  try{ gfWsReapplySideSoon('pulse'); }catch(e){}   // same shape as Ideas: a sub-tab renders its own side column
3125  const mt = document.getElementById('scanTab-movers'), nt = document.getElementById('scanTab-nodes'), ot = document.getElementById('scanTab-overview');
3126  if (mt) mt.classList.toggle('active', showMovers);
3127  if (nt) nt.classList.toggle('active', showNodes);
3128  if (ot) ot.classList.toggle('active', showOverview);
3129  const show = (id, on) => { const el = document.getElementById(id); if (el) el.style.display = on ? 'flex' : 'none'; };
3130  ['scanModeBar', 'scanFilters', 'scanMain'].forEach(id => show(id, showMovers));
3131  const albar = document.getElementById('scanWatchAlertBar');
3132  if (albar) {
3133    if (!showMovers) { _savedAlertBarDisplay = albar.style.display; albar.style.display = 'none'; }
3134    else if (_savedAlertBarDisplay) { albar.style.display = _savedAlertBarDisplay; }
3135  }
3136  document.getElementById('nodeAlertsBar').style.display = showNodes ? 'flex' : 'none';
3137  document.getElementById('nodeAlertsMain').style.display = showNodes ? 'flex' : 'none';
3138  const _mo = document.getElementById('marketOverviewMain'); if (_mo) _mo.style.display = showOverview ? 'block' : 'none';
3139  const note = document.getElementById('scanTabNote');
3140  if (note) note.textContent = showNodes ? 'Live GEX node touches' : (showOverview ? 'Whole-market flow · darkpool · sectors' : '');
3141  if (showNodes) { renderNodeChips(); startNodePolling(); }   // polling also runs in the background (startNodeWatcher), so leaving the tab keeps alerts flowing
3142  if (showOverview && typeof loadMarketOverview === 'function') loadMarketOverview();
3143  try { _dupAssert('scanner'); } catch (e) {}
3144}
3145
3146// ── Market Overview (Radar sub-tab) ────────────────────────────────────────────
3147const MO_CARD = 'background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden';
3148const MO_HEAD = 'padding:10px 14px;border-bottom:1px solid var(--border);font-family:Segoe UI,monospace;font-size:12px;font-weight:800;letter-spacing:.03em;color:#e2e8f0;display:flex;align-items:center;gap:7px';
3149let _moLoaded = 0, _mag7Tf = '1D';
3150const MAG7 = ['AAPL','MSFT','NVDA','GOOGL','AMZN','META','TSLA'];
3151const MAG7_COL = { AAPL:'#a855f7', MSFT:'#3b82f6', NVDA:'#22c55e', GOOGL:'#eab308', AMZN:'#f59e0b', META:'#38bdf8', TSLA:'#ef4444' };
3152function _moPrem(v){ v=Math.abs(+v||0); if(v>=1e9) return '$'+(v/1e9).toFixed(1)+'B'; if(v>=1e6) return '$'+(v/1e6).toFixed(1)+'M'; if(v>=1e3) return '$'+(v/1e3).toFixed(0)+'K'; return '$'+Math.round(v); }
3153function _moPct(v){ if(v==null||!isFinite(v)) return '—'; return (v>=0?'+':'')+v.toFixed(2)+'%'; }
3154function _moHeatCol(p){ const a=Math.min(Math.abs(p)/4,1); return p>=0 ? 'rgba(34,197,94,'+(0.12+a*0.55)+')' : 'rgba(239,68,68,'+(0.12+a*0.55)+')'; }
3155// Legacy/panel values map to their tab group so old saved prefs + old callers still work.
3156const MO_GROUP={ heat:'heat', bull:'flow', bear:'flow', netprem:'flow', contracts:'flow', dp:'flow', flow:'flow', sect:'sect', sectbd:'sect', mag7:'mag7', all:'heat' };
3157let _moView='heat';
3158try{ const _v=localStorage.getItem('gf_mo_view'); if(_v && MO_GROUP[_v]) _moView=MO_GROUP[_v]; }catch(e){}
3159let _moBasis='all';
3160try{ const _b=localStorage.getItem('gf_mo_basis'); if(_b==='0dte'||_b==='all') _moBasis=_b; }catch(e){}
3161function _moSyncBasisBtns(){ document.querySelectorAll('#moBody .mo-basis').forEach(x=>{ const on=x.dataset.basis===_moBasis; x.style.background=on?'var(--cyan)':'transparent'; x.style.color=on?'var(--bg)':'#cbd5e1'; x.style.borderColor=on?'var(--cyan)':'var(--border)'; }); }
3162function _moSetBasis(b){ _moBasis=(b==='0dte'?'0dte':'all'); try{ localStorage.setItem('gf_mo_basis',_moBasis); }catch(e){} _moSyncBasisBtns(); loadMarketOverview(true); }
3163function _moSetView(v){
3164  v=MO_GROUP[v]||'heat';
3165  _moView=v; try{ localStorage.setItem('gf_mo_view',v); }catch(e){}
3166  const grid=document.getElementById('mo-grid');
3167  if(grid){
3168    // Flow = all 4 panels (Bullish · Bearish · Contracts · Dark Pool) in ONE row; Sectors = its 2
3169    // panels in ONE row; Heatmap / Mag 7 = single full-width panel.
3170    const cols = (v==='flow') ? 4 : (v==='sect') ? 2 : 1;
3171    grid.style.gridTemplateColumns = (cols>1) ? ('repeat('+cols+',minmax(0,1fr))') : 'repeat(auto-fit,minmax(320px,1fr))';
3172    grid.querySelectorAll('.mo-panel').forEach(p=>{ const on=p.dataset.group===v; p.style.display=on?'':'none'; p.style.gridColumn=(on && cols===1)?'1/-1':''; p.style.order=''; });
3173  }
3174  document.querySelectorAll('#moBody .mo-tab').forEach(b=>{ const on=b.dataset.tab===v; b.style.background=on?'var(--cyan)':'transparent'; b.style.color=on?'var(--bg)':'#cbd5e1'; b.style.borderColor=on?'var(--cyan)':'var(--border)'; });
3175  const cv=document.getElementById('mo-mag7'); if(cv) cv.style.height=(v==='mag7'?'520px':'340px');
3176  if(typeof _renderMoHeatmap==='function') _renderMoHeatmap();   // re-render to toggle the scroll cap (big vs grid)
3177  if(v==='sect' && typeof _moLoadSectors==='function') _moLoadSectors();   // lazy-load the sector table on first open
3178  if(v==='mag7' && typeof _moDrawMag7==='function') setTimeout(_moDrawMag7,40);
3179}
3180function _moUpgradeHtml(){ return '<div style="max-width:540px;margin:48px auto;text-align:center;background:var(--card);border:1px solid var(--border);border-radius:14px;padding:34px 28px;font-family:inherit">'
3181  + '<div style="font-size:21px;font-weight:800;color:#e2e8f0;margin-bottom:10px">Market Overview is a Premium feature</div>'
3182  + '<div style="color:#cbd5e1;font-size:14px;line-height:1.6;margin-bottom:20px">Whole-market heatmap, top bullish / bearish net premium, biggest contracts &amp; dark pool, sector flow and the Magnificent Seven — all live.</div>'
3183  + '<a href="'+ (typeof UPGRADE_URL!=='undefined'?UPGRADE_URL:'/register') +'" style="display:inline-block;background:var(--t-accent,#3b82f6);color:var(--t-onAccent,#fff);font-weight:700;padding:12px 24px;border-radius:8px;text-decoration:none">Upgrade to Premium</a>'
3184  + '</div>'; }
3185// ── Background pre-warm ─────────────────────────────────────────────────────
3186// Pulse and the Flow tape both did ALL their loading on first click, so the first visit to either
3187// sat on a spinner. Warm both once shortly after the dashboard settles — late enough not to compete
3188// with the initial chart paint — then let each view's own interval keep it fresh.
3189let _moPre=null;            // { basis, data, t } — warmed Market Overview payload, consumed once
3190let _gfWarmed=false;
3191async function _moWarm(){
3192  try{ const b=_moBasis; const j=await fetch('/api/market-overview?basis='+b).then(r=>r.json()); if(j&&!j.error) _moPre={basis:b,data:j,t:Date.now()}; }catch(e){}
3193}
3194function gfWarmViews(){
3195  if(_gfWarmed) return; _gfWarmed=true;
3196  // Run each warm in IDLE time, not on a fixed timer — a 15k-row flow backfill landing mid-pan is
3197  // exactly what makes the chart stutter. requestIdleCallback waits for a gap; the timeout is the
3198  // ceiling so it still happens on browsers/pages that never go properly idle.
3199  var idle = window.requestIdleCallback || function(fn){ return setTimeout(function(){ fn({didTimeout:true}); }, 4000); };
3200  setTimeout(function(){
3201    idle(function(){ try{ if(typeof isPaidUser!=='undefined' && isPaidUser && typeof _moWarm==='function') _moWarm(); }catch(e){} }, {timeout:8000});
3202    // Flow: DATA only (bg:true skips every render) — populating flowFeed is the whole point.
3203    idle(function(){ try{ if(typeof startFlowFeed==='function' && typeof currentView!=='undefined' && currentView!=='flow') startFlowFeed({bg:true}); }catch(e){} }, {timeout:15000});
3204    idle(function(){ try{ if(typeof isPaidUser!=='undefined' && isPaidUser) _earnWarm(); }catch(e){} }, {timeout:20000});
3205    idle(function(){ try{ _warmTopCharts(); }catch(e){} }, {timeout:25000});
3206  }, 3500);
3207}
3208// Pre-cache candles + GEX levels for the handful of tickers actually switched to most, so the first
3209// click on each paints from cache. Reuses prefetchChartTicker (the same path the hover prefetch uses,
3210// with its own 5-min candle / 1-min level TTLs and in-flight guard) — this just triggers it without
3211// waiting for a hover. Serialised with a gap so it never bursts a dozen requests at once.
3212const GF_WARM_CHARTS = ['SPY','QQQ','SPXW','NVDA','TSLA','AAPL'];
3213function _warmTopCharts(){
3214  if(typeof prefetchChartTicker!=='function') return;
3215  const list = GF_WARM_CHARTS.filter(t=>t!==(typeof chartTicker!=='undefined'?chartTicker:''));  // the open one is already loaded
3216  let i=0;
3217  (function step(){
3218    if(i>=list.length) return;
3219    if(document.hidden){ setTimeout(step, 5000); return; }   // don't warm a background tab
3220    try{ prefetchChartTicker(list[i]); }catch(e){}
3221    i++; setTimeout(step, 900);                              // one every 0.9s, not all at once
3222  })();
3223}
3224async function loadMarketOverview(force){
3225  const body=document.getElementById('moBody'); if(!body) return;
3226  // Premium-only: free/logged-out users get an upgrade prompt instead of the data.
3227  if(!(typeof isPaidUser!=='undefined' && isPaidUser)){ body.innerHTML=_moUpgradeHtml(); body.dataset.filled=''; return; }
3228  if(!force && Date.now()-_moLoaded < 4000 && body.dataset.filled) return;   // throttle re-entry
3229  if(!body.dataset.filled){
3230    const card=(panel,group,head,bodyId,pad)=>'<div class="mo-panel" data-panel="'+panel+'" data-group="'+group+'" style="'+MO_CARD+'">'+head+'<div id="'+bodyId+'" style="padding:'+pad+'"><div class="col-loading">Loading…</div></div></div>';
3231    body.innerHTML =
3232      '<div style="display:flex;align-items:center;gap:6px;margin-bottom:14px;flex-wrap:wrap;font-family:Segoe UI,monospace">'
3233        + [['heat','🌐 Heatmap'],['flow','💵 Smart Money'],['sect','🗂 Sectors'],['mag7','Mag 7']]
3234            .map(t=>'<button class="mo-tab" data-tab="'+t[0]+'" onclick="_moSetView(\''+t[0]+'\')" style="border:1px solid var(--border);border-radius:7px;padding:6px 12px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;background:transparent;color:#cbd5e1">'+t[1]+'</button>').join('')
3235        + '<span style="margin-left:10px;font-size:11px;color:var(--dim)" title="Today\'s 0DTE flow vs all expiries / captured prints">Flow basis</span>'
3236        + ['all','0dte'].map(b=>'<button class="mo-basis" data-basis="'+b+'" onclick="_moSetBasis(\''+b+'\')" style="border:1px solid var(--border);border-radius:6px;padding:5px 10px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;background:transparent;color:#cbd5e1">'+(b==='all'?'All':'0DTE')+'</button>').join('')
3237        + '<button class="refresh-btn" onclick="loadMarketOverview(true)" style="color:var(--text)">↻</button>'
3238        + '<span id="mo-stamp" style="margin-left:auto;font-size:11px;color:var(--dim)"></span>'
3239      + '</div>'
3240      + '<div id="mo-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px">'
3241        + card('heat','heat','<div style="'+MO_HEAD+'">🌐 Market Heatmap <span style="font-weight:400;color:var(--dim)">— daily %, biggest first</span></div>','mo-heat-b','12px')
3242        + card('bull','flow','<div style="'+MO_HEAD+'"><span style="color:#22c55e">▲</span>
3242 Top Bullish Net Premium</div>','mo-bull','12px 14px')
3243        + card('bear','flow','<div style="'+MO_HEAD+'"><span style="color:#ef4444">▼</span> Top Bearish Net Premium</div>','mo-bear','12px 14px')
3244        + card('sect','sect','<div style="'+MO_HEAD+'">🗂 Sector Performance</div>','mo-sect','8px 6px')
3245        + card('sectbd','sect','<div style="'+MO_HEAD+'"><span style="color:#a855f7">◆</span> Darkpool by Sector</div>','mo-sectbd','10px 12px')
3246        + card('contracts','flow','<div style="'+MO_HEAD+'">📄 Top Contracts <span style="font-weight:400;color:var(--dim);margin-left:auto">aggregated premium</span></div>','mo-contracts','10px 14px')
3247        + card('dp','flow','<div style="'+MO_HEAD+'"><span style="color:#a855f7">◆</span> Top Dark Pool <span style="font-weight:400;color:var(--dim);margin-left:auto">aggregated premium</span></div>','mo-dp','10px 14px')
3248        + '<div class="mo-panel" data-panel="mag7" data-group="mag7" style="'+MO_CARD+'"><div style="'+MO_HEAD+'">Magnificent Seven <span id="mo-mag7-tf" style="margin-left:auto;display:flex;gap:4px"></span></div><div style="padding:10px 12px"><canvas id="mo-mag7" style="width:100%;height:340px;display:block"></canvas><div id="mo-mag7-leg" style="display:flex;gap:14px;flex-wrap:wrap;justify-content:center;padding:8px 0 2px;font-family:Segoe UI,monospace;font-size:12px"></div></div></div>'
3249      + '</div>';
3250    body.dataset.filled='1';
3251    const tfWrap=document.getElementById('mo-mag7-tf');
3252    if(tfWrap) tfWrap.innerHTML = ['1D','5D','1M','6M'].map(t=>'<button class="tf-btn'+(t===_mag7Tf?' active':'')+'" style="padding:2px 8px;font-size:10px" onclick="_mag7Tf=\''+t+'\';document.querySelectorAll(\'#mo-mag7-tf .tf-btn\').forEach(b=>b.classList.toggle(\'active\',b.textContent===\''+t+'\'));_moDrawMag7()">'+t+'</button>').join('');
3253    _moSetView(_moView);   // apply the default/saved tab (Heatmap by default)
3254    _moSyncBasisBtns();
3255  }
3256  _moLoaded=Date.now();
3257  try{
3258    // Use the background-warmed copy if it's fresh, so the FIRST open paints instantly instead of
3259    // sitting on a spinner while the fetch runs. Consumed once; the 60s interval takes over after.
3260    let j=null;
3261    if(_moPre && _moPre.basis===_moBasis && Date.now()-_moPre.t<90000 && !force){ j=_moPre.data; _moPre=null; }
3262    if(!j) j=await fetch('/api/market-overview?basis='+_moBasis).then(r=>r.json());
3263    _renderMoHeatmap(j.heatmap||[]); _renderMoNet('mo-bull',j.bullish||[],true); _renderMoNet('mo-bear',j.bearish||[],false);
3264    _renderMoContracts(j.contracts||[]); _renderMoDarkpool(j.darkpool||[]); _renderMoSectorBd(j.sectorDp||[]);
3265    const st=document.getElementById('mo-stamp'); if(st){ const d=new Date(); st.textContent='Updated '+d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})+' · '+(j.heatmap||[]).length+' stocks'; }
3266  }catch(e){}
3267  if(_moView==='sect') _moLoadSectors();   // sectors + Mag7 load lazily only when their tab is open
3268  if(_moView==='mag7') _moDrawMag7();
3269}
3270async function _moLoadSectors(){ const el=document.getElementById('mo-sect'); if(el && el.dataset.loaded) return; try{ const s=await fetch('/api/market-overview/sectors').then(r=>r.json()); _renderMoSectors(s.data||[]); if(el) el.dataset.loaded='1'; }catch(e){} }
3271let _moHeat=[];
3272function _renderMoHeatmap(list){
3273  const el=document.getElementById('mo-heat-b'); if(!el) return;
3274  if(list && list.length) _moHeat=list; else list=_moHeat;
3275  if(!list.length){ el.innerHTML='<div style="color:var(--dim);font-size:12px">No data yet.</div>'; return; }
3276  // Wide, bounded box: fills the width with many columns, caps height so the page doesn't run long.
3277  el.innerHTML='<div style="max-height:66vh;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px">'
3278    + list.map(x=>'<div title="'+x.ticker+' '+_moPct(x.pct)+(x.price?(' · $'+(+x.price).toFixed(2)):'')+'" style="background:'+_moHeatCol(x.pct)+';border:1px solid var(--border);border-radius:7px;padding:9px 5px;text-align:center">'
3279      + '<div style="font-weight:800;font-size:13px;color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+x.ticker+'</div>'
3280      + '<div style="font-size:11px;font-weight:700;color:'+(x.pct>=0?'#bbf7d0':'#fecaca')+'">'+_moPct(x.pct)+'</div></div>').join('')
3281    + '</div>';
3282}
3283function _renderMoNet(id,list,bull){
3284  const el=document.getElementById(id); if(!el) return;
3285  if(!list.length){ el.innerHTML='<div style="color:var(--dim);font-size:13px">No flow captured yet.</div>'; return; }
3286  const mx=Math.max(...list.map(x=>Math.abs(x.net)),1), col=bull?'#22c55e':'#ef4444';
3287  el.innerHTML=list.map(x=>{ const w=Math.max(4,Math.abs(x.net)/mx*100);
3288    return '<div style="display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid rgba(148,163,184,.05)">'
3289      + '<span style="width:62px;font-weight:700;font-size:15px;color:#e2e8f0">'+x.ticker+'</span>'
3290      + '<div style="flex:1;height:18px;background:rgba(148,163,184,.08);border-radius:5px;overflow:hidden"><div style="width:'+w+'%;height:100%;background:linear-gradient(90deg,'+col+'44,'+col+')"></div></div>'
3291      + '<span style="width:78px;text-align:right;font-weight:800;font-size:15px;color:'+col+'">'+_moPrem(x.net)+'</span></div>';
3292  }).join('');
3293}
3294function _renderMoContracts(list){
3295  const el=document.getElementById('mo-contracts'); if(!el) return;
3296  if(!list.length){ el.innerHTML='<div style="color:var(--dim);font-size:13px">No contracts captured yet.</div>'; return; }
3297  el.innerHTML=list.map(c=>{ const tot=(c.ask+c.bid)||1, ap=Math.round(c.ask/tot*100), t=c.top||{}, isC=(t.type||'call')==='call';
3298    return '<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(148,163,184,.06)">'
3299      + '<span style="width:126px;font-size:14px;color:#e2e8f0"><b>'+c.ticker+'</b> <span style="color:#aeb9cf;font-size:12.5px;font-weight:600">'+(t.strike||'')+(isC?'c':'p')+' '+((t.expiry||'').slice(5))+'</span></span>'
3300      + '<div title="ask vs bid premium" style="flex:1;height:16px;border-radius:4px;overflow:hidden;display:flex;background:#1e293b"><div style="width:'+ap+'%;background:#22c55e"></div><div style="width:'+(100-ap)+'%;background:#ef4444"></div></div>'
3301      + '<span style="width:78px;text-align:right;font-weight:800;font-size:15px;color:#e2e8f0">'+_moPrem(c.prem)+'</span></div>';
3302  }).join('');
3303}
3304function _renderMoDarkpool(list){
3305  const el=document.getElementById('mo-dp'); if(!el) return;
3306  if(!list.length){ el.innerHTML='<div style="color:var(--dim);font-size:13px">No darkpool prints yet.</div>'; return; }
3307  const mx=Math.max(...list.map(x=>x.prem),1);
3308  el.innerHTML=list.map(x=>{ const w=Math.max(6,x.prem/mx*100);
3309    return '<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(148,163,184,.05)">'
3310      + '<span style="width:62px;font-weight:700;font-size:15px;color:#e2e8f0">'+x.ticker+'</span>'
3311      + '<span title="'+ (x.n||0) +' prints · VWAP" style="width:70px;font-size:13px;font-weight:600;color:#aeb9cf">$'+(x.price||0).toFixed(2)+'</span>'
3312      + '<div style="flex:1;height:17px;background:rgba(148,163,184,.08);border-radius:5px;overflow:hidden"><div style="width:'+w+'%;height:100%;background:linear-gradient(90deg,#a855f766,#a855f7)"></div></div>'
3313      + '<span style="width:78px;text-align:right;font-weight:800;font-size:15px;color:#c4b5fd">'+_moPrem(x.prem)+'</span></div>';
3314  }).join('');
3315}
3316function _renderMoSectors(list){
3317  const el=document.getElementById('mo-sect'); if(!el) return;
3318  if(!list.length){ el.innerHTML='<div style="color:var(--dim);font-size:12px;padding:0 8px">No data.</div>'; return; }
3319  const c=v=>v==null?'var(--dim)':(v>=0?'#22c55e':'#ef4444');
3320  el.innerHTML='<table style="width:100%;border-collapse:collapse;font-family:Segoe UI,monospace;font-size:14px">'
3321    + '<tr style="color:var(--dim);font-size:12px"><th style="text-align:left;padding:7px 10px;font-weight:600">Sector</th><th style="text-align:right;padding:7px 10px;font-weight:600">Daily</th><th style="text-align:right;padding:7px 10px;font-weight:600">1-Mo</th><th style="text-align:right;padding:7px 12px;font-weight:600">6-Mo</th></tr>'
3322    + list.map(s=>'<tr style="border-top:1px solid rgba(148,163,184,.07)"><td style="padding:8px 10px;color:#e2e8f0"><b>'+s.etf+'</b> <span style="color:#9fb0c9;font-size:12px">'+s.name+'</span></td>'
3323      + '<td style="text-align:right;padding:8px 10px;font-weight:700;color:'+c(s.daily)+'">'+_moPct(s.daily)+'</td>'
3324      + '<td style="text-align:right;padding:8px 10px;font-weight:700;color:'+c(s.m1)+'">'+_moPct(s.m1)+'</td>'
3325      + '<td style="text-align:right;padding:8px 12px;font-weight:700;color:'+c(s.m6)+'">'+_moPct(s.m6)+'</td></tr>').join('')
3326    + '</table>';
3327}
3328function _renderMoSectorBd(list){
3329  const el=document.getElementById('mo-sectbd'); if(!el) return;
3330  if(!list.length){ el.innerHTML='<div style="color:#9fb0c9;font-size:13px">No darkpool captured yet.</div>'; return; }
3331  el.innerHTML='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px">'
3332    + list.map(s=>'<div style="border:1px solid var(--border);border-radius:10px;padding:12px 14px;background:rgba(148,163,184,.04)">'
3333      + '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px;padding-bottom:8px;border-bottom:1px solid rgba(148,163,184,.14)"><b style="font-size:15px;color:#e2e8f0">'+s.sector+'</b><span style="font-weight:800;font-size:15px;color:#c4b5fd">'+_moPrem(s.prem)+'</span></div>'
3334      + s.tickers.map(t=>'<div style="display:flex;align-items:center;justify-content:space-between;font-size:14px;padding:5px 0;border-bottom:1px solid rgba(148,163,184,.05)"><span style="color:#e2e8f0"><b>'+t.ticker+'</b> <span style="color:#9fb0c9;font-size:12.5px">$'+(t.price||0).toFixed(2)+'</span></span><span style="color:#c4b5fd;font-weight:700">'+_moPrem(t.prem)+'</span></div>').join('')
3335      + '</div>').join('')
3336    + '</div>';
3337}
3338const _mag7Cache={};
3339async function _moDrawMag7(){
3340  const cv=document.getElementById('mo-mag7'); if(!cv) return;
3341  const tfMap={ '1D':['1d','5m'], '5D':['5d','30m'], '1M':['1mo','1d'], '6M':['6mo','1d'] };
3342  const [range,interval]=tfMap[_mag7Tf]||tfMap['1D'];
3343  const key=_mag7Tf, cached=_mag7Cache[key];
3344  let series;
3345  if(cached && Date.now()-cached.t<60000){ series=cached.series; }
3346  else{
3347    series={};
3348    await Promise.all(MAG7.map(async tk=>{
3349      try{ const j=await fetch('/api/candles/'+tk+'?range='+range+'&interval='+interval+'&prepost=false').then(r=>r.json());
3350        const r=j&&j.chart&&j.chart.result&&j.chart.result[0]; const cl=((r&&r.indicators&&r.indicators.quote&&r.indicators.quote[0]&&r.indicators.quote[0].close)||[]);
3351        const t=(r&&r.timestamp)||[]; const pts=[]; let base=null;
3352        for(let i=0;i<cl.length;i++){ if(cl[i]==null) continue; if(base==null) base=cl[i]; pts.push({t:t[i],p:(cl[i]-base)/base*100}); }
3353        series[tk]=pts;
3354      }catch(e){ series[tk]=[]; }
3355    }));
3356    _mag7Cache[key]={t:Date.now(),series};
3357  }
3358  _mag7Render(cv,series);
3359}
3360function _mag7Render(cv,series){
3361  const dpr=window.devicePixelRatio||1, W=cv.clientWidth||700, H=cv.clientHeight||300;
3362  cv.width=W*dpr; cv.height=H*dpr; const ctx=cv.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,W,H);
3363  const padL=44,padR=52,padT=12,padB=22;
3364  let lo=Infinity,hi=-Infinity,tMin=Infinity,tMax=-Infinity;
3365  for(const tk of MAG7){ for(const pt of (series[tk]||[])){ if(pt.p<lo)lo=pt.p; if(pt.p>hi)hi=pt.p; if(pt.t<tMin)tMin=pt.t; if(pt.t>tMax)tMax=pt.t; } }
3366  if(!isFinite(lo)){ ctx.fillStyle='#8b9cb5'; ctx.font='12px Segoe UI,monospace'; ctx.fillText('No data.',padL,H/2); return; }
3367  const pad=(hi-lo)*0.12||1; lo-=pad; hi+=pad;
3368  const x=t=>padL+(t-tMin)/((tMax-tMin)||1)*(W-padL-padR);
3369  const y=p=>padT+(hi-p)/((hi-lo)||1)*(H-padT-padB);
3370  // zero line + grid labels
3371  ctx.strokeStyle='rgba(148,163,184,.12)'; ctx.fillStyle='#8b9cb5'; ctx.font='10px Segoe UI,monospace'; ctx.textBaseline='middle';
3372  const steps=4; for(let i=0;i<=steps;i++){ const p=lo+(hi-lo)*i/steps, yy=y(p); ctx.strokeStyle=(Math.abs(p)<0.001)?'rgba(148,163,184,.35)':'rgba(148,163,184,.10)'; ctx.beginPath();
3372 ctx.moveTo(padL,yy); ctx.lineTo(W-padR,yy); ctx.stroke(); ctx.fillText((p>=0?'+':'')+p.toFixed(1)+'%',4,yy); }
3373  for(const tk of MAG7){ const pts=series[tk]||[]; if(pts.length<2) continue; ctx.strokeStyle=MAG7_COL[tk]; ctx.lineWidth=1.6; ctx.beginPath();
3374    for(let i=0;i<pts.length;i++){ const xx=x(pts[i].t),yy=y(pts[i].p); if(i===0)ctx.moveTo(xx,yy); else ctx.lineTo(xx,yy); } ctx.stroke();
3375    const last=pts[pts.length-1]; ctx.fillStyle=MAG7_COL[tk]; ctx.font='700 10px Segoe UI,monospace'; ctx.textBaseline='middle'; ctx.fillText(tk,W-padR+3,y(last.p));
3376  }
3377  const leg=document.getElementById('mo-mag7-leg');
3378  if(leg) leg.innerHTML=MAG7.map(tk=>{ const pts=series[tk]||[]; const lp=pts.length?pts[pts.length-1].p:null; return '<span style="display:flex;align-items:center;gap:4px"><span style="width:10px;height:3px;background:'+MAG7_COL[tk]+';border-radius:2px"></span>'+tk+' <b style="color:'+(lp==null?'var(--dim)':(lp>=0?'#22c55e':'#ef4444'))+'">'+(lp==null?'—':_moPct(lp))+'</b></span>'; }).join('');
3379}
3380function renderNodeChips() {
3381  document.querySelectorAll('.node-chip').forEach(c => c.classList.toggle('active', nodeTypes.has(c.dataset.node)));
3382  const tol = document.getElementById('nodeTol'); if (tol) tol.value = nodeTol;
3383  updateNodeNotifyBtn();
3384}
3385function toggleNodeType(n) {
3386  if (nodeTypes.has(n)) nodeTypes.delete(n); else nodeTypes.add(n);
3387  if (nodeTypes.size === 0) nodeTypes.add(n); // keep at least one
3388  saveNodePrefs(); renderNodeChips(); renderNodeAlerts();
3389  // Push the change to the server for every armed ticker (server set + local), so the extension and
3390  // My HQ - which read the account's armed levels - stay in step. Without this a chip toggle only
3391  // changed what the DASHBOARD drew and those surfaces kept the level they had been armed with.
3392  try{ _nodeReconcileServerLevels(true); }catch(e){}
3393}
3394function startNodePolling() { stopNodePolling(); try{ refreshNodeArmed().then(function(){ try{ _nodeReconcileServerLevels(false); }catch(e){} }); }catch(e){ try{ refreshNodeArmed(); }catch(e2){} } try{ loadUrNodes(); }catch(e){}   /* load the armed set before the first scan so nothing unarmed slips into the feed */ refreshNodeLevels(true); _nodePollTimer = setInterval(function(){ refreshNodeLevels(); try{ var p=document.getElementById('ultraNodePane'); if(p && getComputedStyle(p).display!=='none') loadUrNodes(); }catch(e){} }, 12000); }
3395function stopNodePolling() { if (_nodePollTimer) { clearInterval(_nodePollTimer); _nodePollTimer = null; } }
3396
3397// Compute + store a ticker's node levels (King/Call/Put/Vol + spot) from raw gex/spot arrays.
3398function _computeNodeLevel(ticker, raw, spotArr, volOverride) {
3399  if (!Array.isArray(raw)) return;
3400  let spot = 0;
3401  if (Array.isArray(spotArr)) for (const s of spotArr) { const p = parseFloat(s.price || s.underlying_price || 0); if (p > 0) { spot = p; break; } }
3402  const parsed = raw.map(s => {
3403    const strike = parseFloat(s.strike || 0);
3404    const callGex = parseFloat(s.call_gex || s.call_gamma_oi || 0);
3405    const putGex = parseFloat(s.put_gex || s.put_gamma_oi || 0);
3406    const v = parseFloat(s.volume || 0) || (parseFloat(s.call_volume || 0) + parseFloat(s.put_volume || 0)) || 0;
3407    const mult = spot > 0 ? spot * spot * 0.01 : 1;
3408    return { strike, netGex: (callGex + putGex) * mult, callGex: callGex * mult, putGex: putGex * mult, vol: v };
3409  }).filter(s => s.strike > 0 && (Math.abs(s.netGex) > 0 || s.vol > 0));
3410  if (!parsed.length) return;
3411  if (!spot) spot = [...parsed].sort((a, b) => Math.abs(b.netGex) - Math.abs(a.netGex))[0].strike;
3412  const above = parsed.filter(s => s.strike >= spot).sort((a, b) => a.strike - b.strike).slice(0, 30);
3413  const below = parsed.filter(s => s.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
3414  const filtered = [...above, ...below];
3415  const { callWall, putWall, kingNode } = pickLevels(filtered, spot);
3416  const volTop = [...filtered].filter(s => s.vol > 0).sort((a, b) => b.vol - a.vol)[0];
3417  nodeLevels[ticker] = {
3418    spot,
3419    prevSpot: (nodeLevels[ticker] && nodeLevels[ticker].spot != null) ? nodeLevels[ticker].spot : null, // for cross detection
3420    king: kingNode ? kingNode.strike : null,
3421    call: callWall ? callWall.strike : null,
3422    put: putWall ? putWall.strike : null,
3423    // Vol Node needs option VOLUME, which the greek-exposure data lacks — prefer the
3424    // server-computed value (from option-contracts); fall back to any volume in the raw data.
3425    vol: (volOverride != null ? volOverride : (volTop ? volTop.strike : null)),
3426  };
3427}
3428let _etDayC={t:0,v:''};
3429function _etDayStr(){ const now=Date.now(); if(now-_etDayC.t<1000 && _etDayC.v) return _etDayC.v; const v=_etDayStrNow(); _etDayC={t:now,v}; return v; }
3430function _etDayStrNow(){ const n=new Date(); const et=new Date(n.toLocaleString('en-US',{timeZone:'America/New_York'})); return et.getFullYear()+'-'+String(et.getMonth()+1).padStart(2,'0')+'-'+String(et.getDate()).padStart(2,'0'); }
3431// Node-alert SESSION day — rolls at 4:00 AM ET (= 1:00 AM PT, the premarket open), NOT at midnight.
3432// Keyed on _etDayStr() the feed wiped itself at midnight ET, losing the whole afternoon's alerts
3433// overnight. Now a session's alerts survive the close and the entire overnight, and are cleared only
3434// when the next premarket begins.
3435function _nodeSessionDay(){
3436  const et=new Date(new Date().toLocaleString('en-US',{timeZone:'America/New_York'}));
3437  if(et.getHours()<4) et.setDate(et.getDate()-1);   // before 4am ET still belongs to the prior session
3438  return et.getFullYear()+'-'+String(et.getMonth()+1).padStart(2,'0')+'-'+String(et.getDate()).padStart(2,'0');
3439}
3440function _saveNodeFeed(){ try{ localStorage.setItem('gf_nodeFeed', JSON.stringify({ date:_nodeSessionDay(), feed:nodeFeed })); }catch(e){} }
3441function _loadNodeFeed(){ try{ const s=JSON.parse(localStorage.getItem('gf_nodeFeed')||'null'); if(s && s.date===_nodeSessionDay() && Array.isArray(s.feed)) nodeFeed=s.feed; else nodeFeed=[]; }catch(e){} }
3442// Fire on ANY enabled node crossing for EVERY watchlist ticker (not just the nearest node), so
3443// price blowing through a node still alerts. Runs on every poll regardless of the current tab.
3444function detectNodeCrossings(){
3445  for(const t of Object.keys(nodeLevels)){
3446    // ONLY TICKERS THE USER ARMED (user 2026-08-25: "i havent selected all alerts but it still
3447    // sending all alerts on dashboard side panel"). This loop ran over EVERY polled ticker and
3448    // pushNodeAlert() had no arming check — only fireNodeNotification() did — so the desktop
3449    // popup respected the selection while the side-panel feed showed the whole watchlist.
3450    // nodeArmed is now seeded from the account's level watches too, so this matches what the
3451    // extension's NODE tab shows. The Nodes TAB grid is a browse view and still lists everything.
3452    if(!nodeArmed.has(t)) continue;
3453    const lv=nodeLevels[t]; if(!lv) continue;
3454    const price=lv.spot, prev=lv.prevSpot;
3455    if(price==null||prev==null) continue;
3456    for(const n of ['king','call','put','vol']){
3457      if(!nodeTypes.has(n)) continue;
3458      const strike=lv[n]; if(strike==null) continue;
3459      const key=t+'|'+n;
3460      const crossed=(prev<strike&&price>=strike)||(prev>strike&&price<=strike);
3461      const within=nodeTol>0 && (Math.abs(price-strike)/price*100)<=nodeTol;
3462      if(crossed||within){ if(!nodeHit[key]){ nodeHit[key]=true; pushNodeAlert(t,n,strike,price); fireNodeNotification(t,n,strike,price); } }
3463      else nodeHit[key]=false;
3464    }
3465  }
3466}
3467async function refreshNodeLevels(force) {
3468  try {
3469    // Node alerts poll only during the extended session (4am–8pm ET = 1am–5pm PT). Overnight the
3470    // GEX data is frozen and prices are stale, so skip the fetch (keep last-known + re-render).
3471    // `force` = the one-time populate when the tab opens, so it's never blank.
3472    if (!force && !isExtendedSession()) { if (scannerTab === 'nodes') renderNodeAlerts(); return; }
3473    const res = await fetch('/api/gex/all');
3474    if (!res.ok) return;
3475    const data = await res.json();
3476    const tk = data.tickers || {};
3477    for (const [ticker, info] of Object.entries(tk)) {
3478      _computeNodeLevel(ticker, (info.gex && info.gex.data) || info.gex || [], (info.spot && info.spot.data) || info.spot || [], info.volNode);
3479    }
3480    // Paint the polled tickers immediately (that's most of the watchlist) so the grid isn't blank
3481    // for the several seconds the custom-ticker fill + price overlay below take on first open.
3482    if (scannerTab === 'nodes') renderNodeAlerts();
3483    // /api/gex/all only returns the server's POLL_TICKERS — Custom (and any non-polled) w
3483atchlist
3484    // tickers aren't in it, so fetch their gex+spot individually (server-cached, so cheap; also
3485    // marks them "seen" so the server starts polling them). Without this the Custom tab is blank.
3486    try {
3487      const polled = new Set(Object.keys(tk));
3488      const custom = [...new Set(Object.values(WATCHLIST).flat())].map(t => String(t).toUpperCase()).filter(t => !polled.has(t)).slice(0, 40);
3489      const CONC = 6;
3490      for (let i = 0; i < custom.length; i += CONC) {
3491        await Promise.all(custom.slice(i, i + CONC).map(async t => {
3492          try {
3493            const [gj, sj, lj] = await Promise.all([
3494              fetch('/api/gex/strike/' + encodeURIComponent(t)).then(r => r.json()),
3495              fetch('/api/spot/' + encodeURIComponent(t)).then(r => r.json()),
3496              fetch('/api/levels/' + encodeURIComponent(t)).then(r => r.json()).catch(() => null),
3497            ]);
3498            _computeNodeLevel(t, (gj && (gj.data || gj)) || [], (sj && (sj.data || sj)) || [], (lj && lj.volNode != null) ? lj.volNode : undefined);
3499          } catch (e) {}
3500        }));
3501      }
3502    } catch (e) {}
3503    // Overlay ACCURATE live prices (real-time WS / Yahoo 1-min, correct for indexes) over
3504    // the 3-min poll spot — this is the price used for touch detection.
3505    const wanted = Object.keys(nodeLevels);
3506    if (wanted.length) {
3507      try {
3508        const pr = await fetch('/api/prices?tickers=' + encodeURIComponent(wanted.join(',')));
3509        if (pr.ok) { const pj = await pr.json(); const prices = pj.prices || {}; for (const t of wanted) { if (prices[t] > 0) nodeLevels[t].spot = prices[t]; } }
3510      } catch (e) {}
3511    }
3512    detectNodeCrossings();                        // fire + save alerts every poll, any tab
3513    if (scannerTab === 'nodes') renderNodeAlerts();
3514  } catch (e) {}
3515}
3516// Closest ENABLED node to spot for a ticker.
3517function nodeNearest(ticker) {
3518  const lv = nodeLevels[ticker]; if (!lv || !lv.spot) return null;
3519  let best = null;
3520  for (const n of ['king', 'call', 'put', 'vol']) {
3521    if (!nodeTypes.has(n)) continue;
3522    const strike = lv[n]; if (strike == null) continue;
3523    const dist = Math.abs(lv.spot - strike);
3524    const pct = (dist / lv.spot) * 100;
3525    if (!best || dist < best.dist) best = { node: n, strike, dist, pct };
3526  }
3527  return best;
3528}
3529function fmtStrike(s) { return s >= 1000 ? s.toFixed(0) : (Math.round(s * 100) / 100).toString(); }
3530function renderNodeAlerts() {
3531  const body = document.getElementById('nodeAlertsBody'); if (!body) return;
3532  const groups = Object.entries(WATCHLIST).filter(([g, t]) => t.length && !(g === 'Custom' && t.length === 0));
3533  // Every sector is its own equal-sized column: fixed height + internal scroll so a sector
3534  // with 12 tickers is the same size as one with 3 — a clean uniform grid, not a ragged one.
3535  let html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(268px,1fr));gap:12px;align-items:start">';
3536  for (const [sector, tickers] of groups) {
3537    html += '<div style="display:flex;flex-direction:column;height:300px;background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden">';
3538    // sector header (name + ticker count)
3539    html += '<div style="display:flex;align-items:center;gap:6px;padding:7px 10px;border-bottom:1px solid var(--border);font-family:Segoe UI,monospace;font-size:11px;font-weight:800;color:#3b82f6;letter-spacing:.4px">'
3540      + '<span>' + sector + '</span><span style="color:var(--dim);font-weight:600">' + tickers.length + '</span></div>';
3541    // column sub-header
3542    html += '<div style="display:flex;align-items:center;gap:8px;padding:4px 10px;border-bottom:1px solid rgba(30,41,59,0.6);font-family:Segoe UI,monospace;font-size:9px;color:var(--dim);letter-spacing:.4px;text-transform:uppercase">'
3543      + '<span style="width:16px"></span><span style="width:50px">Ticker</span><span style="width:60px">Price</span><span style="width:72px">Node</span><span style="flex:1;text-align:right">Status</span></div>';
3544    html += '<div style="flex:1;overflow-y:auto">';
3545    for (const t of tickers) {
3546      const lv = nodeLevels[t];
3547      const near = nodeNearest(t);
3548      const price = lv && lv.spot ? lv.spot : null;
3549      const armed = nodeArmed.has(t);
3550      let nearHtml = '<span style="color:var(--dim)">—</span>';
3551      let statusHtml = '<span style="background:#1e293b;color:var(--dim);padding:2px 7px;border-radius:10px;font-size:11px">Watching</span>';
3552      if (near) {
3553        const m = NODE_META[near.node];
3554        nearHtml = '<span style="color:' + m.color + ';font-weight:700">' + m.abbr + ' ' + fmtStrike(near.strike) + '</span>';
3555        const key = t + '|' + near.node;
3556        // "Hit" = price actually crossed/reached the node since the last poll, OR (if a tolerance is
3557        // set) is within that % window. With nodeTol=0 it fires only on a real cross — an actual hit.
3558        // Display-only status; alerts are fired centrally in detectNodeCrossings (all nodes, any tab).
3559        if (nodeHit[key] || near.pct <= nodeTol) {
3560          statusHtml = '<span style="background:' + nodeHexRgba(m.color, 0.18) + ';color:' + m.color + ';font-weight:700;padding:2px 7px;border-radius:10px;font-size:11px">' + m.label.split(' ')[0] + ' Hit</span>';
3561        }
3562      }
3563      html += '<div class="node-row' + (armed ? ' armed' : '') + '" style="display:flex;align-items:center;gap:6px;height:30px;padding:0 10px;box-sizing:border-box;white-space:nowrap;border-bottom:1px solid rgba(30,41,59,0.4);font-family:Segoe UI,monospace;font-size:12px">'
3564        + '<button class="node-arm' + (armed ? ' armed' : '') + '" title="' + (armed ? 'Alerting on ' + t + ' — click to turn off' : 'Alert me when ' + t + ' touches a node') + '" onclick="toggleNodeArm(\'' + t + '\')" style="flex:none;width:15px;background:none;border:none;cursor:pointer;font-size:12px;padding:0;line-height:1">🔔</button>'
3565        + '<span class="node-tk" title="Open ' + t + ' GEX chart" onclick="gfGoTicker(\'' + t + '\')" style="flex:none;width:44px;font-weight:700;color:#e2e8f0">' + t + '</span>'
3566        + '<span style="flex:none;width:56px;color:#e2e8f0">' + (price != null ? price.toFixed(2) : '—') + '</span>'
3567        + '<span style="flex:none;width:60px">' + nearHtml + '</span>'
3568        + '<span style="flex:1;min-width:0;text-align:right;display:flex;align-items:center;justify-content:flex-end;gap:4px">' + statusHtml
3569        + '<button class="node-del" title="Remove ' + t + '" onclick="nodeDeleteTicker(\'' + t + '\')" style="flex:none;background:none;border:none;color:var(--dim);cursor:pointer;font-size:14px;padding:0 2px;line-height:1">&times;</button></span>'
3570        + '</div>';
3571    }
3572    html += '</div></div>';
3573  }
3574  html += '</div>';
3575  body.innerHTML = html;
3576  renderNodeFeed();
3577}
3578// PER-USER, SAME AS THE EXTENSION (user 2026-09-09: "aplly right panel filter as well per user
3579// only"). The right panel computes its own crossings over the WHOLE watchlist and used to show all
3580// of them, while /api/tf-signals - what the extension reads - serves only the levels this account
3581// armed. Same event, two different answers: six alerts on the panel, four of them on tickers that
3582// were never armed, and none of those four in the extension.
3583//
3584// The armed set is fetched once and refreshed whenever arming changes. Until it has loaded nothing
3585// is filtered: a slow or failed config fetch must not silently empty the panel, which would look
3586// exactly like the bug this is fixing. Once loaded the rule matches the server's - ticker AND level
3587// must both be armed, and no watches means no rows.
3588var _nodeArmedKeys = null;   // Set of "TICKER|level", or null until loaded
3589var _NODE_LVL = { king: 'king', call: 'call_wall', put: 'put_wall', vol: 'vol_node' };
3590// RIGHT PANEL READS THE SERVER FEED (user 2026-09-09: "we need node alerts per 0dte for node alerts
3591// only on right panel and extension"). The panel used to compute its own crossings client-side from
3592// /api/gex/all, which is the ALL-expiry payload - so it watched a different level from the server
3593// sweep, and the two could not agree by construction. MSFT is the case in point: King 510 on ALL,
3594// 490 on 0DTE.
3595//
3596// Rather than teach the client to fetch 0DTE levels for the whole watchlist every 12 seconds - which
3597// would cost a call per ticker - the panel now renders the SAME rows the extension does, straight
3598// from /api/tf-signals. Those are already computed on 0DTE, already deduped so coincident levels
3599// speak once as the most important level, already filtered to this account's armed watches, and
3600// already worded "hit". One source, so the two surfaces cannot drift apart again.
3601//
3602// The Scanner tab's own node grid is untouched: it shows levels and distances rather than a crossing
3603// log, and its local computation is the right tool for that.
3604var _urNodes = null, _urNodesAt = 0;
3605function _urNodeLevelToType(lvl){ return { king:'king', call_wall:'call', put_wall:'put', vol_node:'vol' }[String(lvl||'')] || ''; }
3606function loadUrNodes(){
3607  return fetch('/api/tf-signals',{credentials:'include'}).then(function(r){ return r.json(); }).then(function(j){
3608    _urNodes = (j.items||[]).filter(function(x){ return x.sig==='node'; }).map(function(x){
3609      return { ticker:x.ticker, node:_urNodeLevelToType(x.lvl), label:x.label||'', price:x.price, t:x.time||Date.now() };
3610    });
3611    _urNodesAt = Date.now();
3612    renderUrNodes();
3613  }).catch(function(){});
3614}
3615function renderUrNodes(){
3616  var list=document.getElementById('ultraNodeFeedList'); if(!list) return;
3617  var box=document.getElementById('ultraNodeFeedFilters');
3618  var rows=_urNodes||[];
3619  if(box){
3620    var counts={all:rows.length,king:0,call:0,put:0,vol:0};
3621    rows.forEach(function(a){ if(counts[a.node]!=null) counts[a.node]++; });
3622    var chip=function(key,label,color){
3623      var on=(nodeFeedFilter===key);
3624      return '<button onclick="setNodeFeedFilter(\''+key+'\')" style="cursor:pointer;font-family:Segoe UI,monospace;font-size:10px;font-weight:700;padding:3px 8px;border-radius:10px;border:1px solid '
3625        +(on?(color||'#22d3ee'):'rgba(148,163,184,.3)')+';background:'+(on?(color||'#22d3ee'):'transparent')+';color:'+(on?'#0a0e17':(color||'#cbd5e1'))+'">'+label+' '+(counts[key]||0)+'</button>';
3626    };
3627    box.innerHTML = chip('all','All') + chip('king',NODE_META.king.abbr,NODE_META.king.color)
3628      + chip('call',NODE_META.call.abbr,NODE_META.call.color) + chip('put',NODE_META.put.abbr,NODE_META.put.color)
3629      + chip('vol',NODE_META.vol.abbr,NODE_META.vol.color)
3630      + '<span id="ultraNodeFeedCount" style="margin-left:auto;font-size:10px;color:var(--dim);font-weight:400">'+(rows.length?rows.length+' today':'')+'</span>';
3631  }
3632  var feed=(nodeFeedFilter==='all')?rows:rows.filter(function(a){ return a.node===nodeFeedFilter; });
3633  if(!feed.length){ list.innerHTML='<div style="padding:14px 10px;text-align:center;color:#94a3b8;font-size:10.5px;line-height:1.6">No node crossings yet today.<br>
3633The server scans your armed tickers every minute against their 0DTE levels.</div>'; return; }
3634  list.innerHTML=feed.map(function(a){
3635    var meta=NODE_META[a.node]||{color:'#cbd5e1',label:''};
3636    var tm=new Date(a.t).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'});
3637    // CLICK A ROW TO OPEN IT (user 2026-09-11: "node alerts that came on right panel ticker shold be
3638    // clickable to ghost mode for users and admin"). The SCANNER copy of this feed has been
3639    // clickable all along - this right-panel copy is a different renderer (it reads the SERVER feed,
3640    // the other reads the local one) and never got the handler. Same target, same call, so a row
3641    // behaves identically wherever it is shown. No gating: whoever can see the row can open it.
3642    var _tk=String(a.ticker||'').replace(/[^A-Z0-9.-]/gi,'');
3643    return '<div class="node-feed-row" onclick="gfGoTicker(\''+_tk+'\')" title="Open '+_tk+' in Ghost Mode" style="cursor:pointer;display:flex;gap:8px;align-items:center;padding:7px 10px;border-bottom:1px solid rgba(148,163,184,.12);font-size:11.5px">'
3644      +'<b style="color:#e2e8f0;min-width:52px">'+a.ticker+'</b>'
3645      +'<span style="color:'+meta.color+';font-weight:700;flex:1">'+String(a.label).replace(/[<>]/g,'')+'</span>'
3646      +'<span style="color:#94a3b8;font-size:10px;white-space:nowrap">'+(a.price!=null?('@ '+a.price+'  '):'')+tm+'</span></div>';
3647  }).join('');
3648}
3649// ══════════ OPTION CHAIN mode (user 2026-09-11) ══════════
3650// Every strike of one expiry, fully priced, with the dealer GEX each strike carries beside it -
3651// which is the column a broker's chain does not have and the reason this is worth building here.
3652// The server does the merging (see /api/chain/:ticker): it slices UW by expiry AND side, so a whole
3653// chain is two cached upstream calls, spent only when someone opens that expiry.
3654let _chTk = null, _chExp = null, _chRange = 0.15, _chData = null, _chBusy = false;
3655// LIVE WHILE YOU SIT ON IT (user 2026-09-12). The chain used to load once and then never move -
3656// open it, read it twenty minutes later, and you were still looking at the quotes from when you
3657// arrived. It now re-reads every 45s while the view is open and the tab is visible. Most of those
3658// cost nothing upstream: gamma comes off the socket, and the quote slices answer from a 4-minute
3659// cache, so a real UW call goes out at most once every four minutes per expiry you are watching.
3660let _chTimer = null, _chAt = 0, _chTick = 0, _chAnchor = '';
3661function _chAgo(){ const n = Math.max(0, Math.round((Date.now() - _chAt) / 1000)); return n < 60 ? n + 's' : Math.round(n / 60) + 'm'; }
3662function chainPollStop(){ if(_chTimer){ clearInterval(_chTimer); _chTimer = null; } }
3663function chainPollStart(){
3664  chainPollStop(); _chTick = 0;
3665  // One timer does both jobs: it ticks the "updated Ns ago" label every 5s and fires the reload
3666  // every 9th tick. A hidden tab is skipped entirely - refreshing a chain nobody is looking at
3667  // spends budget for nothing, and the label makes the staleness visible the moment you return.
3668  _chTimer = setInterval(function(){
3669    if(!gfLive('chain')){ chainPollStop(); return; }
3670    const el = document.getElementById('chainFresh');
3671    if(el && _chAt) el.textContent = 'updated ' + _chAgo() + ' ago';
3672    if(document.hidden) return;
3673    if(++_chTick >= 9){ _chTick = 0; loadChain(true); }
3674  }, 5000);
3675}
3676// VOL / OI / IV are OFF by default (user 2026-09-11: "hide vol oi iv like if user want they can see
3677// other wise keep hdie"). What the chain is for here is the price and the gamma beside it; volume,
3678// open interest and IV are the columns you go looking for, not the ones you read every time. Saved
3679// per browser so the choice sticks.
3680// EACH COLUMN ON ITS OWN SWITCH (user 2026-09-12: "vol, oi, iv all coming together. make it all
3681// seperate option to select"). One toggle for all three meant wanting open interest cost you two
3682// columns you did not ask for. Still off by default. The old saved value was the string '1' for
3683// all-on, so it is read once and carried over rather than silently resetting anyone's choice.
3684let _chCols = { vol:false, oi:false, iv:false, voi:false };
3685try {
3686  const _cc = localStorage.getItem('gf_chain_cols');
3687  if (_cc === '1') _chCols = { vol:true, oi:true, iv:true, voi:false };
3688  else if (_cc === '0' || _cc == null) { /* keep the default */ }
3689  else { const _p = JSON.parse(_cc); if (_p && typeof _p === 'object') _chCols = { vol:!!_p.vol, oi:!!_p.oi, iv:!!_p.iv, voi:!!_p.voi }; }
3690} catch (e) {}
3691function setChainCol(k){ if(!(k in _chCols)) return; _chCols[k] = !_chCols[k];
3692  try{ localStorage.setItem('gf_chain_cols', JSON.stringify(_chCols)); }catch(e){} renderChain(); }
3693// Today's volume against the open interest standing before it.
3694//   null     nothing traded at this strike today - say nothing, not 0.00x
3695//   Infinity traded with no open interest at all: every contract of it is new
3696function _chVoi(q){
3697  if(!q) return null;
3698  const v = +q.vol || 0, oi = +q.oi || 0;
3699  if(!v) return null;
3700  if(!oi) return Infinity;
3701  return v / oi;
3702}
3703function _chVoiTxt(r){
3704  if(r === null) return '\u00b7';
3705  if(r === Infinity) return 'NEW';
3706  return (r >= 10 ? String(Math.round(r)) : r.toFixed(r < 1 ? 2 : 1)) + 'x';
3707}
3708// Days to expiry from the EASTERN date - the board's day, not the browser's. A Pacific user at
3709// 9pm is already on tomorrow in UTC, which would have shown every expiry one day short.
3710function _chDte(e){
3711  try{ const t=new Date().toLocaleDateString('en-CA',{timeZone:'America/New_York'});
3712       return Math.max(0, Math.round((Date.parse(e+'T00:00:00Z')-Date.parse(t+'T00:00:00Z'))/86400000)); }
3713  catch(x){ return 0; }
3714}
3715// ADD A STRIKE TO TRACKING, from either side of the chain (user 2026-09-12: "user select call side
3716// or put side strike and can add to tracking"). Posts to the same /api/positions the Tracking view
3717// reads, so it appears there with everything else.
3718//
3719// Entry is the ASK, not the mid: buying the contract means paying it. Quoting the mid here would
3720// start every tracked position with a free half-spread of profit it never had - the same flattery
3721// the Robinhood simulation exists to strip out.
3722async function chainTrack(strike, type, entry){
3723  const d=_chData; if(!d) return;
3724  const lbl=d.ticker+' '+strike+type+' '+d.expiry.slice(5).replace('-','/');
3725  try{
3726    const r=await fetch('/api/positions',{method:'POST',credentials:'include',
3727      headers:{'Content-Type':'application/json'},
3728      body:JSON.stringify({ticker:d.ticker,strike:strike,expiry:d.expiry,type:type,entry:entry,size:1,source:'chain'})});
3729    const j=await r.json().catch(()=>null);
3730    if(r.ok && j && j.ok) chainToast(lbl+' tracked at $'+(+entry).toFixed(2));
3731    else if(r.status===401) chainToast('Sign in to track a contract', true);
3732    else chainToast((j&&j.error)||('Could not track '+lbl), true);
3733  }catch(e){ chainToast('Could not track '+lbl+': '+e.message, true); }
3734}
3735function chainToast(msg, bad){
3736  let el=document.getElementById('chainToast');
3737  if(!el){ el=document.createElement('div'); el.id='chainToast';
3738    el.style.cssText='position:fixed;right:18px;bottom:18px;z-index:9999;padding:9px 14px;border-radius:9px;font:700 12px Segoe UI;box-shadow:0 6px 22px rgba(0,0,0,.45);opacity:0;transition:opacity .18s;pointer-events:none';
3739    document.body.appendChild(el); }
3740  el.style.background=bad?'#3b1418':'var(--card)';
3741  el.style.border='1px solid '+(bad?'#ef4444':'var(--t-accent,#d4af37)');
3742  el.style.color=bad?'#fecaca':'#e8eef7';
3743  el.textContent=msg; el.style.opacity='1';
3744  clearTimeout(el._t); el._t=setTimeout(function(){ el.style.opacity='0'; }, 2800);
3745}
3746function _chNum(n){ n=+n||0; return n>=1000 ? (n/1000).toFixed(1)+'k' : String(Math.round(n)); }
3747function _chM(v){ const a=Math.abs(v); return a>=1e9?(v/1e9).toFixed(1)+'B':a>=1e6?Math.round(v/1e6)+'M':a>=1e3?Math.round(v/1e3)+'K':String(Math.round(v)); }
3748function setChainTicker(t){ t=String(t||'').toUpperCase().trim().replace(/[^A-Z0-9.\-]/g,''); if(!t) return; _chTk=t; _chExp=null; try{ buildChainTickers(); }catch(e){} loadChain(); }
3749function setChainExpiry(e){ _chExp=e; loadChain(); }
3750function setChainRange(r){ _chRange=+r; renderChain(); }
3751// The SAME ticker line Ghost Mode runs (user 2026-09-11: "we need same ticker names line like we
3752// have on ghost mode with same settings liike minimize dn alll"). Same markup, same .exp-ticker-btn
3753// chips, and the same two helpers wired by id - gfWireStripFold gives it the one-line/expand fold
3754// with its own saved state, enableButtonDrag gives it drag-to-reorder. Only the click differs: it
3755// retickers the chain rather than the chart.
3756function buildChainTickers(){
3757  const el=document.getElementById('chainTickers'); if(!el) return;
3758  const _base=[...new Set(Object.values(WATCHLIST).flat())].filter(t=>t!=='ES');
3759  let html='';
3760  for(const t of _base){
3761    const on = (t===_chTk);
3762    html+='<button class="exp-ticker-btn '+(on?'active':'')+(demoLocked(t)?' demo-locked-btn':'')+'" id="chain-btn-'+t+'" data-ticker="'+t+'"'+(IS_DEMO?'':' draggable="true"')+' title="'+(demoLocked(t)?'Locked - sign up to unlock':'Drag to reorder')+'">'+(demoLocked(t)?'\uD83D\uDD12 ':'')+t+'</button>';
3763  }
3764  el.innerHTML=html;
3765  for(const t of _base){
3766    const btn=document.getElementById('chain-btn-'+t); if(!btn) continue;
3767    const ticker=t;
3768    btn.addEventListener('click', function(){
3769      if(demoLocked(ticker)){ window.location.href='/register'; return; }
3770      _chTk=ticker; _chExp=null;            // a new name starts at its own front expiry
3771      syncActiveGroup(ticker);
3772      buildChainTickers();
3773      loadChain();
3774    });
3775  }
3776  try{ enableButtonDrag('chainTickers', buildChainTickers); }catch(e){}
3777  try{ gfWireStripFold('chainTickers'); }catch(e){}
3778  // The strip is one line of ~118 chips, 5254px of them against a 1920px window, so the selected
3779  // name is usually off screen when it is not near the alphabet's start. Every other mode's strip
3780  // brings the active chip into view; this one has to as well or "scrollable" reads as "lost".
3781  try{ gfStripScrollActive(document.getElementById('chainTickers')); }catch(e){}
3782  // ALL / less, parked at the END of the ticker line instead of inside it (user 2026-09-12: "show
3783  // the ALL button on end of ticker line"). As a chip it was the 119th item in a 5300px horizontal
3784  // scroller, so the control for showing every ticker was itself only reachable by scrolling to the
3785  // last ticker. Ghost Mode keeps its copy OUTSIDE the strip, absolutely positioned over the 118px
3786  // of right padding the strip reserves; this is the same control by the same rules.
3787  try{
3788    const _st=document.getElementById('chainTickers'), _vw=document.getElementById('chainView');
3789    if(_st && _vw){
3790      let _ctl=document.getElementById('chainTkCtl');
3791      if(!_ctl){
3792        _ctl=document.createElement('div'); _ctl.id='chainTkCtl';
3793        const _b=document.createElement('button'); _b.type='button'; _b.className='tf-btn';
3794        _b.onclick=function(e){ e.stopPropagation(); gfChainTkExpand(); };
3795        _ctl.appendChild(_b); _vw.insertBefore(_ctl, _st.nextSibling);
3796        try{ if(localStorage.getItem('gf_chain_tk_all')==='1') _st.classList.add('gf-tk-expanded'); }catch(e2){}
3797      }
3798      gfChainTkExpand(_st.classList.contains('gf-tk-expanded'));
3799    }
3800  }catch(e){}
3801}
3802function gfChainTkExpand(force){
3803  const t=document.getElementById('chainTickers'), c=document.getElementById('chainTkCtl'); if(!t||!c) return;
3804  const on=(typeof force==='boolean')?force:!t.classList.contains('gf-tk-expanded');
3805  t.classList.toggle('gf-tk-expanded',on);
3806  try{ localStorage.setItem('gf_chain_tk_all', on?'1':'0'); }catch(e){}
3807  const b=c.querySelector('.tf-btn');
3808  if(b){ b.classList.toggle('on',on);
3809         b.textContent=on?('Less '+String.fromCharCode(9652)):('All '+t.querySelectorAll('.exp-ticker-btn').length+' '+String.fromCharCode(9662));
3810         b.title=on?'Back to one line':'Show every ticker'; }
3811  if(!on){ try{ gfStripScrollActive(t); }catch(e){} }
3812}
3813async function loadChain(silent){
3814  const body=document.getElementById('chainBody'); if(!body) return;
3815  if(_chBusy) return;
3816  if(!_chTk){ try{ _chTk=(typeof chartTicker!=='undefined'&&chartTicker)||(typeof expiryTicker!=='undefined'&&expiryTicker)||'SPY'; }catch(e){ _chTk='SPY'; } }
3817  try{ buildChainTickers(); }catch(e){}
3818  _chBusy=true;
3819  // A silent refresh must not blank the table under you mid-read.
3820  if(!silent) body.innerHTML='<div style="padding:26px;text-align:center;color:#cbd5e1;font-size:12px">Loading '+_chTk+(_chExp?' '+_chExp:'')+' chain…</div>';
3821  try{
3822    const u='/api/chain/'+encodeURIComponent(_chTk)+(_chExp?('?expiry='+encodeURIComponent(_chExp)):'');
3823    const j=await fetch(u,{credentials:'include'}).then(r=>r.ok?r.json():({error:'HTTP '+r.status}));
3824    if(j.error){ if(!silent) body.innerHTML='<div style="padding:26px;text-align:center;color:#f87171;font-size:12px">'+j.error+'</div>'; _chBusy=false; return; }
3825    _chData=j; _chExp=j.expiry; _chAt=Date.now();
3826  }catch(e){ if(!silent) body.innerHTML='<div style="padding:26px;text-align:center;color:#f87171;font-size:12px">Chain failed: '+e.message+'</div>'; _chBusy=false; return; }
3827  _chBusy=false;
3828  renderChain();
3829}
3830function renderChain(){
3831  const bar=document.getElementById('chainBar'), body=document.getElementById('chainBody');
3832  if(!bar||!body||!_chData) return;
3833  const _keep=body.scrollTop;   // the innerHTML swap below resets this to 0
3834  const d=_chData, sp=+d.spot||0;
3835  // SELECTED PILLS FOLLOW THE THEME (user 2026-09-12: "make selection color of tabs as per our
3836  // color theme not purple or blue for 15% and vol oi"). The range pills were a hardcoded #22d3ee
3837  // and the column pills a hardcoded #a855f7 - neither colour exists in any of the four themes, so
3838  // they stayed blue and purple on gold, sunset, wraith and phantom alike. Each group now takes one
3839  // of the theme's own accent variables: accent for the expiries, accent2 for the range, accentHi
3840  // for the columns. Still three distinguishable groups, all of them inside the palette.
3841  //
3842  // Text on a selected pill is --t-onAccent, not a fixed dark. That is the variable that keeps the
3843  // label readable when the accent is near-white (phantom) as well as when it is gold.
3844  const pill=(lbl,on,fn,col)=>'<button onclick="'+fn+'" style="border:1px solid '+(on?(col||'var(--t-accent,#d4af37)'):'var(--border)')+';background:'+(on?(col||'var(--t-accent,#d4af37)'):'transparent')+';color:'+(on?'var(--t-onAccent,#0b0f18)':'var(--t-muted,#cbd5e1)')+';border-radius:calc(7px*var(--t-rk,1));padding:4px 10px;font:800 11px Segoe UI;cursor:pointer">'+lbl+'</button>';
3845  bar.innerHTML='<input id="chainTkIn" value="'+d.ticker+'" onkeydown="if(event.key===\'Enter\')setChainTicker(this.value)" title="Type a ticker and press Enter" style="width:90px;background:var(--bg);border:1px solid var(--border);border-radius:7px;padding:5px 9px;color:#fff;font:800 13px Segoe UI;text-transform:uppercase">'
3846    +'<span style="font:800 13px Segoe UI;color:#22c55e">'+(sp>0?'$'+sp.toFixed(2):'')+'</span>'
3847    +'<span style="color:#64748b">|</span>'
3848    +(d.expiries||[]).map(e=>{ const n=_chDte(e);
3849       return pill('<span style="display:block;line-height:1.15">'+e.slice(5).replace('-','/')+'</span>'
3850         +'<span style="display:block;line-height:1.15;font-size:8.5px;font-weight:700;opacity:.85">'+(n===0?'0DTE':n+'d')+'</span>',
3851         e===d.expiry, 'setChainExpiry(\''+e+'\')'); }).join('')
3852    +'<span style="color:#64748b">|</span>'
3853    +[0.08,0.15,0.30,1].map(r=>pill(r===1?'all':('±'+Math.round(r*100)+'%'), Math.abs(_chRange-r)<0.001, 'setChainRange('+r+')','var(--t-accent2,#c9a227)')).join('')
3854    +'<span style="color:#64748b">|</span>'
3855    +['vol','oi','iv','voi'].map(k=>pill(k==='voi'?'VOL/OI':k.toUpperCase(), _chCols[k], 'setChainCol(\''+k+'\')', 'var(--t-accentHi,#e9d38a)')).join('')
3856    +'<span style="color:#64748b">|</span>'
3857    +'<span id="chainFresh" style="font-size:10.5px;color:#cbd5e1">'+(_chAt?('updated '+_chAgo()+' ago'):'')+'</span>'
3858    +'<button onclick="loadChain(true)" title="Re-read this chain now" style="border:1px solid var(--border);background:transparent;color:#cbd5e1;border-radius:7px;padding:4px 10px;font:800 11px Segoe UI;cursor:pointer">Refresh</button>';
3859  let rows=(d.rows||[]);
3860  if(sp>0 && _chRange<1) rows=rows.filter(r=>
3860Math.abs(r.strike-sp)<=sp*_chRange);
3861  if(!rows.length){ body.innerHTML='<div style="padding:26px;text-align:center;color:#cbd5e1;font-size:12px">No strikes in range for '+d.ticker+' '+d.expiry+'.</div>'; return; }
3862  // 0DTE ONLY (user 2026-09-12: "show nodes on only 0dte expiry. on other dont show nodes for
3863  // now"). The server answers with the current day's levels whatever expiry is open; the tags only
3864  // appear when the expiry ON SCREEN is that one. Any other expiry shows no tags at all rather than
3865  // levels belonging to a different board.
3866  const _ar = d.rows || [];
3867  const _nd = (d.nodes && d.nodes.expiry === d.expiry) ? d.nodes : null;
3868  const _knK = _nd ? _nd.kn : null, _cwK = _nd ? _nd.cw : null, _pwK = _nd ? _nd.pw : null;
3869  // The single biggest positive and single biggest negative number on the board, ringed where they
3870  // sit rather than named off to one side - finding them by eye is the whole point. These stay on
3871  // the VIEWED expiry: they describe the chain you are reading.
3872  let _hiC=null,_hiP=null;
3873  for(const r of _ar){ if(r.cg>0&&(!_hiC||r.cg>_hiC.cg))_hiC=r; if(r.pg<0&&(!_hiP||r.pg<_hiP.pg))_hiP=r; }
3874  // THE TWO BUSIEST STRIKES EACH SIDE (user 2026-09-12: "make 2 highest call and put side vol as
3875  // well"). Today's contract volume, per side, on the expiry being viewed - where the day's trading
3876  // actually happened, which is a different question from where the open gamma sits.
3877  const _vRank=(pick)=>{ const m={};
3878    _ar.filter(r=>{const q=pick(r); return q&&q.vol>0;}).sort((a,b)=>pick(b).vol-pick(a).vol).slice(0,2)
3879       .forEach((r,i)=>{ m[r.strike]=i+1; }); return m; };
3880  const _cVol=_vRank(r=>r.call), _pVol=_vRank(r=>r.put);
3881  // ── EXPECTED MOVE, from this board ────────────────────────────────────────
3882  const _emMid=q=>q?(((+q.bid||0)+(+q.ask||0))/2):0;
3883  let _emAtm=null;
3884  for(const r of _ar){ if(!r.call||!r.put) continue;
3885    const dd=Math.abs(r.strike-sp); if(!_emAtm||dd<_emAtm.d) _emAtm={d:dd,r:r}; }
3886  let _em=0, _emSrc='';
3887  if(_emAtm && sp>0){
3888    const cm=_emMid(_emAtm.r.call), pm=_emMid(_emAtm.r.put);
3889    if(cm>0 && pm>0){ _em=cm+pm; _emSrc='the '+_emAtm.r.strike+' straddle'; }        // what the move costs
3890    else {                                                                            // no ATM market: fall back to IV
3891      const iv=((+(_emAtm.r.call&&_emAtm.r.call.iv)||0)+(+(_emAtm.r.put&&_emAtm.r.put.iv)||0))/2;
3892      const dte=Math.max(_chDte(d.expiry),0)+0.5;   // a 0DTE board still has hours left in it
3893      if(iv>0) { _em=sp*(iv/100)*Math.sqrt(dte/365); _emSrc='ATM implied volatility'; }
3894    }
3895  }
3896  const _emLo=_em>0?sp-_em:null, _emHi=_em>0?sp+_em:null;
3897  // the two strikes the band actually lands on, so the edge is drawn where a strike is
3898  let _emKLo=null,_emKHi=null;
3899  if(_em>0){ for(const r of _ar){ if(r.strike>=_emLo && (_emKLo===null || r.strike<_emKLo)) _emKLo=r.strike;
3900                                  if(r.strike<=_emHi && (_emKHi===null || r.strike>_emKHi)) _emKHi=r.strike; } }
3901  const _emCls=k=>{ if(!(_em>0)||k<_emLo||k>_emHi) return '';
3902    return ' em'+(k===_emKLo?' em-lo':'')+(k===_emKHi?' em-hi':''); };
3903  const _emTip=_em>0?('Expected move for '+d.expiry+': ±$'+_em.toFixed(2)+' ('+(_em/sp*100).toFixed(1)
3904    +'%) — '+_emLo.toFixed(2)+' to '+_emHi.toFixed(2)+', priced off '+_emSrc):'';
3905  const mx=Math.max(1,...rows.map(r=>Math.max(Math.abs(r.cg),Math.abs(r.pg))));
3906  // GAMMA PROFILE (user 2026-09-12, design A: "keep the gex values on side of strike on both sides
3907  // and show bars to left n right"). The strike column is the axis. The NUMBER stays against the
3908  // strike on both sides, and the bar grows away from it - left for calls, right for puts - so the
3909  // whole chain reads as one shape and the wall is visible without reading a single figure.
3910  const GW = 132;
3911  const lane=(v,call,hot)=>{
3912    if(!v) return '<td style="text-align:'+(call?'right':'left')+';color:#41506a;padding:2px 7px">·</td>';
3913    const w=Math.max(2,Math.round(Math.abs(v)/mx*(GW-52)));
3914    const c=v>0?'#22c55e':'#ef4444', g=v>0?'34,197,94':'239,68,68';
3915    const bar='<span style="display:inline-block;height:11px;width:'+w+'px;vertical-align:middle;border-radius:3px;background:linear-gradient('+(call?'270deg':'90deg')+',rgba('+g+',.85),rgba('+g+',.18))"></span>';
3916    const num='<span style="color:'+c+';font-weight:800;font-size:10px;'+(call?'margin-left:5px':'margin-right:5px')+'">'+_chM(v)+'</span>';
3917    return '<td style="text-align:'+(call?'right':'left')+';padding:2px 7px;white-space:nowrap'+(hot?';background:rgba('+g+',.14);box-shadow:inset 0 0 0 1px '+c:'')+'">'+(call?bar+num:num+bar)+'</td>'; };
3918  // One side of every row is intrinsic value. Washing the in-the-money side stops a 26.60 call bid
3919  // reading like a premium.
3920  const cell=(v,vr,tt,itm)=>'<td'+(tt?' title="'+tt+'"':'')+' style="text-align:right;padding:3px 7px;'
3921    +(vr?'color:#a5f3fc;font-weight:700;background:rgba(34,211,238,'+(vr===1?'.20':'.10')+')'
3922        :(itm?'color:#eef3fa;background:rgba(148,163,184,.09)':'color:#e2e8f0'))+'">'+v+'</td>';
3923  const voiCell=(q,vr,itm)=>{
3924    const r=_chVoi(q), hot=(r===Infinity || (r!=null && r>=1));
3925    const bg = vr ? 'background:rgba(34,211,238,'+(vr===1?'.20':'.10')+');' : (itm?'background:rgba(148,163,184,.09);':'');
3926    const col = hot ? 'color:#fbbf24;font-weight:800'
3927                    : (vr?'color:#a5f3fc;font-weight:700':(itm?'color:#eef3fa':'color:#e2e8f0'));
3928    const tip = (r===null) ? 'Nothing traded at this strike today'
3929              : (r===Infinity) ? 'Traded today with no open interest standing before it \u2014 all of it is new'
3930              : (Math.round(r*100)/100)+'x \u2014 today\u2019s volume against the open interest that was already there'
3931                + (r>=1 ? '. More has traded today than was open before the bell.' : '');
3932    return '<td title="'+tip+'" style="text-align:right;padding:3px 7px;'+bg+col+'">'+_chVoiTxt(r)+'</td>'; };
3933  const NCOL = 2 + (_chCols.vol?1:0) + (_chCols.oi?1:0) + (_chCols.iv?1:0) + (_chCols.voi?1:0);   // bid, ask + whichever extras are on
3934  const side=(q,vr,tt,itm)=> q
3935    ? cell((q.bid||0).toFixed(2),vr,tt,itm)+cell((q.ask||0).toFixed(2),vr,tt,itm)
3936      +(_chCols.vol?cell(_chNum(q.vol),vr,tt,itm):'')+(_chCols.oi?cell(_chNum(q.oi),vr,tt,itm):'')+(_chCols.iv?cell((q.iv||0)+'%',vr,tt,itm):'')
3937      +(_chCols.voi?voiCell(q,vr,itm):'')
3938    : '<td colspan="'+NCOL+'" style="text-align:center;color:#41506a;padding:3px 7px;font-size:10px">not listed</td>';
3939  const tag=(t,c)=>'<span style="display:inline-block;margin-left:3px;padding:0 3px;border-radius:3px;background:'+c+';color:#0b0f18;font:800 8.5px Segoe UI;vertical-align:middle">'+t+'</span>';
3940  const tags=r=>(_knK!=null&&r.strike===_knK?tag('KN','#eab308'):'')+(_cwK!=null&&r.strike===_cwK?tag('CW','#22c55e'):'')+(_pwK!=null&&r.strike===_pwK?tag('PW','#ef4444'):'');
3941  const vtt=(n,q)=>
3941(n===1?'Busiest':'2nd busiest')+' strike this side on '+d.expiry+': '+((q&&q.vol)||0)+' contracts traded';
3942  const W={BID:58,ASK:58,VOL:50,OI:50,IV:50,'VOL/OI':58,GEX:GW,ADD:22}, SW=118;
3943  const base = ['BID','ASK'].concat(_chCols.vol?['VOL']:[], _chCols.oi?['OI']:[], _chCols.iv?['IV']:[], _chCols.voi?['VOL/OI']:[]);
3944  // ADD sits on the OUTER edge of each side, furthest from the strike, so it never lands between
3945  // two numbers you are comparing.
3946  const colsC = ['ADD'].concat(base, ['GEX']), colsP = ['GEX'].concat(base, ['ADD']);
3947  const trk=(r,ty)=>{ const q=(ty==='C')?r.call:r.put;
3948    if(!q) return '<td></td>';
3949    const px=+((q.ask>0?q.ask:q.bid)||0); if(!(px>0)) return '<td></td>';
3950    return '<td style="padding:0;text-align:center"><button type="button" class="gf-ch-add" tabindex="-1"'
3951      +' title="Track '+d.ticker+' '+r.strike+ty+' '+d.expiry+' at the $'+px.toFixed(2)+' ask"'
3952      +' onclick="chainTrack('+r.strike+',\''+ty+'\','+px+')">+</button></td>'; };
3953  const CWPX = colsC.reduce((a,c)=>a+W[c],0), NC = colsC.length + 1 + colsP.length;
3954  let atm=false;
3955  const trs=rows.map(r=>{ const isAtm=!atm&&sp>0&&r.strike<=sp; if(isAtm) atm=true;
3956    const _cr=_cVol[r.strike]||0, _pr=_pVol[r.strike]||0;
3957    // SPOT IS A LINE BETWEEN TWO STRIKES, not a highlighted row - price is between them, and
3958    // painting the nearest strike gold said instead that the strike itself was special.
3959    const _sl = isAtm ? '<tr class="gf-ch-spot"><td colspan="'+NC+'"><span class="gf-ch-line"></span>'
3960      +'<span class="gf-ch-tag" style="left:'+Math.max(0,CWPX-22)+'px">'+sp.toFixed(2)+'</span></td></tr>' : '';
3961    // A strike with no contract either side is a row of nothing. Fading it lets the eye skip to the
3962    // strikes that actually trade instead of paying them the same attention.
3963    const _dead = !r.call && !r.put;
3964    return _sl+'<tr'+(isAtm?' data-atm="1"':'')+(_dead?' style="opacity:.42"':'')+'>'
3965      +trk(r,'C')+side(r.call,_cr,_cr?vtt(_cr,r.call):'',r.strike<sp)+lane(r.cg,true,!!(_hiC&&r.strike===_hiC.strike))
3966      +'<td class="gf-ch-sk'+_emCls(r.strike)+'"'+(_emTip&&_emCls(r.strike)?' title="'+_emTip+'"':'')
3967        +' style="text-align:center;font-weight:800;padding:3px 6px;white-space:nowrap;border-left:1px solid var(--border);border-right:1px solid var(--border);color:'+(r.strike%5===0?'#ffffff':'#b8c6da')+'">'+r.strike+tags(r)+'</td>'
3968      +lane(r.pg,false,!!(_hiP&&r.strike===_hiP.strike))+side(r.put,_pr,_pr?vtt(_pr,r.put):'',r.strike>sp)+trk(r,'P')+'</tr>'; }).join('');
3969  // BOTH HEADER ROWS PIN (user 2026-09-11: "the top colmn of gex, bid and calls puts shold stay
3970  // static on topp"). Row 1 sits at top:0 and row 2 directly under it at the row-1 height, which has
3971  // to be a fixed 26px - sticky offsets cannot be 'whatever the row above turned out to be'.
3972  const HD1 = 26;
3973  const th=(h,al)=>'<th style="background:#101827;color:#fff;font:800 9.5px Segoe UI;padding:5px 7px;text-align:'+(al||'right')+';position:sticky;top:'+HD1+'px;z-index:3;box-shadow:0 1px 0 var(--border)">'+h+'</th>';
3974  const grp=(lbl,bg,col,span)=>'<th colspan="'+span+'" style="background:'+bg+';color:'+col+';font:800 10px Segoe UI;height:'+HD1+'px;padding:0 7px;letter-spacing:.6px;position:sticky;top:0;z-index:4">'+lbl+'</th>';
3975  const cgrp='<colgroup>'+colsC.map(c=>'<col style="width:'+W[c]+'px">').join('')+'<col style="width:'+SW+'px">'+colsP.map(c=>'<col style="width:'+W[c]+'px">').join('')+'</colgroup>';
3976  const TW=colsC.concat(colsP).reduce((a,c)=>a+W[c],0)+SW;
3977  // premium = contracts traded x mid x 100. Mid rather than last, for the same reason the rest of
3978  // this chain prices off the mark: last is a stale print on anything that is not busy.
3979  const _mny=v=>{ v=Math.abs(v); return v>=1e9?'$'+(v/1e9).toFixed(1)+'B' : v>=1e6?'$'+(v/1e6).toFixed(1)+'M'
3980                                      : v>=1e3?'$'+Math.round(v/1e3)+'K' : '$'+Math.round(v); };
3981  const _mid=q=>q?(((+q.bid||0)+(+q.ask||0))/2):0;
3982  const _prem=q=>q?((+q.vol||0)*_mid(q)*100):0;
3983  let _cP=0,_pP=0; const _tops=[];
3984  for(const r of _ar){
3985    const cv=_prem(r.call), pv=_prem(r.put);
3986    _cP+=cv; _pP+=pv;
3987    if(cv>0) _tops.push({k:r.strike,cp:'C',prem:cv,px:_mid(r.call),vol:+r.call.vol||0});
3988    if(pv>0) _tops.push({k:r.strike,cp:'P',prem:pv,px:_mid(r.put),vol:+r.put.vol||0});
3989  }
3990  _tops.sort((a,b)=>b.prem-a.prem);
3991  const _tot=_cP+_pP;
3992  const _cpct=_tot>0?Math.round(_cP/_tot*100):50, _ppct=100-_cpct;
3993  const _bull=_cpct>=_ppct;
3994  const _topChip=(o,i)=>{
3995    const col=o.cp==='C'?'#22c55e':'#ef4444';
3996    return '<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap">'
3997      +'<span style="background:#1b2130;color:#9fb0c9;border-radius:4px;padding:1px 5px;font:800 8.5px Segoe UI;letter-spacing:.5px">TOP '+(i+1)+'</span>'
3998      +'<b style="color:'+col+'">'+o.k+o.cp+'</b>'
3999      +'<b style="color:#e8eef7">'+_mny(o.prem)+'</b>'
4000      +'<span style="color:#8b9cb5">'+_chNum(o.vol)+' @ $'+o.px.toFixed(2)+'</span></span>';
4001  };
4002  let _cV=0,_pV=0;
4003  for(const r of _ar){ _cV+=(r.call&&+r.call.vol)||0;
4003 _pV+=(r.put&&+r.put.vol)||0; }
4004  const _sent = ('<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:2px 0 9px;font:700 11px Segoe UI">'
4005      +'<span style="color:#d4af37;letter-spacing:.4px">'+d.ticker+' '+d.expiry.slice(5).replace('-','/')+'</span>'
4006      +'<span style="color:#22c55e">Calls '+_mny(_cP)+' <span style="color:#8b9cb5;font-weight:600">'+_chNum(_cV)+'</span></span>'
4007      +'<span style="flex:0 0 170px;height:9px;border-radius:5px;overflow:hidden;display:flex;background:#1b2130">'
4008        +'<i style="width:'+_cpct+'%;background:#22c55e"></i><i style="width:'+_ppct+'%;background:#ef4444"></i></span>'
4009      +'<span style="color:#ef4444">Puts '+_mny(_pP)+' <span style="color:#8b9cb5;font-weight:600">'+_chNum(_pV)+'</span></span>'
4010      +'<span style="border:1px solid '+(_bull?'rgba(34,197,94,.5)':'rgba(239,68,68,.5)')+';background:'+(_bull?'rgba(34,197,94,.12)':'rgba(239,68,68,.12)')
4011        +';color:'+(_bull?'#22c55e':'#ef4444')+';border-radius:6px;padding:1px 8px">'+(_bull?'\u25b2 Bullish ':'\u25bc Bearish ')+Math.max(_cpct,_ppct)+'%</span>'
4012      +(_em>0?'<span style="color:#334155">|</span>'
4013        +'<span title="'+_emTip+'" style="border:1px solid var(--t-accent2,#c9a227);border-radius:6px;padding:1px 8px;color:var(--t-accentHi,#e9d38a);white-space:nowrap">'
4014        +'EM ±$'+_em.toFixed(2)+' <span style="color:#cbd5e1;font-weight:700">'+_emLo.toFixed(2)+'–'+_emHi.toFixed(2)+'</span></span>':'')
4015      +(_tops.length?'<span style="color:#334155">|</span>':'')
4016      +(_tops.length?_tops.slice(0,2).map(_topChip).join('<span style="color:#334155">|</span>')
4017                    :'<span style="color:#8b9cb5;font-weight:600">no contracts traded on this expiry yet</span>')
4018      +'</div>');
4019  const _nx=_nd?((_nd.zero?'0DTE ':'front expiry ')+_nd.expiry):null;
4020  try{ const _se=document.getElementById('chainSent'); if(_se) _se.innerHTML=_sent; }catch(e){}
4021  body.innerHTML='<table style="table-layout:fixed;border-collapse:separate;border-spacing:0;font:11.5px Segoe UI,monospace;font-variant-numeric:tabular-nums;width:'+TW+'px;max-width:100%;margin:0;background:var(--card);border:1px solid var(--border);border-radius:9px">'+cgrp
4022    +'<tr>'+grp('CALLS','#0d1626','#22c55e',colsC.length)
4023    +grp(d.expiry.slice(5).replace('-','/'),'#151d2e','#d4af37',1)
4024    +grp('PUTS','#16100f','#f87171',colsP.length)+'</tr>'
4025    +'<tr>'+colsC.map(c=>th(c==='ADD'?'':c)).join('')
4026    +'<th style="background:#151d2e;color:#d4af37;font:800 9.5px Segoe UI;padding:5px 7px;position:sticky;top:'+HD1+'px;z-index:3;box-shadow:0 1px 0 var(--border)">STRIKE</th>'
4027    +colsP.map((c,i)=>th(c==='ADD'?'':c,i===0?'left':'right')).join('')+'</tr>'+trs+'</table>'
4028    +'<div style="max-width:'+TW+'px;margin:8px 0 0;font-size:10.5px;color:#cbd5e1;line-height:1.5">'
4029      +(_nx
4030        ? '<b style="color:#eab308">KN</b> King Node &nbsp;<b style="color:#22c55e">CW</b> Call Wall &nbsp;<b style="color:#ef4444">PW</b> Put Wall &nbsp;— '+_nx+', the same levels the grid reads.<br>'
4031        : 'Node tags show on the '+((d.nodes&&d.nodes.zero)?'0DTE':'front')+' expiry only'+((d.nodes&&d.nodes.expiry)?' — '+d.nodes.expiry:'')+'.<br>')
4032      +'Hover a row and the <b style="color:#e8eef7">+</b> on either edge tracks that side at its ask. Bars grow out from the strike: call gamma left, put gamma right. Ringed cells are the biggest positive and biggest negative gamma on this expiry. <b style="color:#a5f3fc">Cyan prices</b> are the two busiest strikes on that side today. Shaded prices are in the money; the gold line is spot.<br>'
4033      +rows.length+' strikes \u00b7 '+((d.priced&&d.priced.call)||0)+' call / '+((d.priced&&d.priced.put)||0)+' put quotes listed by the exchange for '+d.expiry+'. "not listed" means no contract exists at that strike, not a missing price.</div>';
4034  // OPEN AT THE MONEY (user 2026-09-11: "the stock that in option need to focus near current price
4035  // of stock"). Strikes run high to low, so the chain opened on the furthest out-of-the-money calls
4036  // - measured on SPXW, the ATM row sat 1681px down a 783px window, so every load started about
4037  // 1300px from anything worth reading. Centre the highlighted row instead; the sticky headers stay
4038  // put on their own because they are sticky rather than scrolled.
4039  // CENTRE ON THE MONEY ONLY WHEN THE BOARD CHANGES. Re-centring on every render would yank the
4040  // page back to spot every 45 seconds while you were reading a strike ten rows away, which is the
4041  // fastest way to make an auto-refresh worse than no refresh. Ticker, expiry and range each change
4042  // the row set, so each re-anchors; a silent reload of the same board keeps your place.
4043  try {
4044    const _an = d.ticker+'|'+d.expiry+'|'+_chRange;
4045    if (_an !== _chAnchor) {
4046      _chAnchor = _an;
4047      _chCenterAtm();   // centre on the money when the board changes (rAF-retries until the container has a measured height — fixes the Chain tab / My H
4047Q tile loading un-centred, user 2026-09-17)
4048    } else body.scrollTop = _keep;
4049  } catch (e) {}
4050}
4051// Centre the chain on the current price (the ATM row). On first load, on a tab switch, and inside a My
4052// HQ tile the scroll container often has no measured height yet (display:none, or the tile not sized),
4053// so the centre math lands on 0 and the board opens scrolled to the top — the user then has to scroll
4054// to find spot. Retry on animation frames until the container is actually measurable, then centre.
4055function _chCenterAtm(tries){
4056  try{
4057    tries = tries || 0;
4058    const body = document.getElementById('chainBody'); if(!body) return;
4059    if(body.clientHeight < 40){ if(tries < 30) requestAnimationFrame(function(){ _chCenterAtm(tries + 1); }); return; }
4060    const atmRow = body.querySelector('tr[data-atm="1"]'); if(!atmRow){ if(tries < 30) requestAnimationFrame(function(){ _chCenterAtm(tries + 1); }); return; }
4061    body.scrollTop = Math.max(0, atmRow.offsetTop - Math.round(body.clientHeight / 2) + 40);
4062  }catch(e){}
4063}
4064// ══════════ WORKSPACE — one screen, any panels, docked (user 2026-09-12) ══════════
4065// "one single dashboard where user can custom what they wnt ... everything that they droop in that
4066// single screen should work like it working on its own modes and tabs and panels. no failure."
4067//
4068// THE ARCHITECTURE, and why it is this one. Ghost Desk already fought this fight for charts and its
4069// notes record two rejected models: relocating the live node (re-render, zoom reset) and "only the
4070// focused pane is fully live" (rejected outright - every pane must show its own state). What it
4071// settled on was a per-pane PORT of every chart feature, which is now 66 audited items with a
4072// standing rule that each chart change ships its pane port in the same commit.
4073//
4074// A mosaic of eight views cannot pay that price eight times over. So this does the opposite of a
4075// port: it MOVES THE REAL VIEW NODE into the tile. #chainView in a tile is still #chainView, with
4076// its listeners, its globals and its render functions - renderChain() finds #chainBody because it
4077// IS #chainBody. There is no second implementation, so there is nothing to drift, and "no failure"
4078// is structural rather than a thing to chase.
4079//
4080// The price is exactly one rule: A PANEL EXISTS ONCE. You cannot have two Grids. That constraint is
4081// what buys the whole ID-collision and parity problem for free, and nobody needs two Grids.
4082//
4083// Everything moved is remembered and put back on the way out (gfWsPark / gfWsUnpark), so the normal
4084// modes are untouched the moment you leave.
4085const GF_WS_PANELS = {
4086  grid:    { name:'Grid',        col:'#22c55e', kind:'mode',
4087             // the only multi-element panel: the grid's strips are SIBLINGS of #columns, not children
4088             // #tabs stays home. The sector tab bar is 108px of a tile that may only be 300px tall,
4089             // and the sector it names now rides in front of the ticker line instead.
4090             // gridExpiryBar is NOT here: it belongs to the workspace's top row (gfWsSettingsBar).
4091             // Parking it here too meant two owners fighting over one node every repaint.
4092             ids:[{id:'gexTickers',d:'flex'},{id:'columns',d:'flex',main:1}],
4093             // reloadSlots only refreshes CUSTOM slots, so the ordinary tickers were never fetched
4094             // and sat on "Loading" forever. This is the same per-slot load the app does at boot.
4095             boot(){ try{ buildTabs(); buildGexTickers(); renderColumns();
4096                          activeColumns.forEach((t,i)=>setTimeout(()=>{ try{ loadGEXSlot(i); }catch(e){} }, i*120)); }catch(e){} },
4097             // NO renderColumns on resize. It rebuilds every column from cache, and any ticker whose
4098             // data had not arrived was thrown back to "Loading" - so dragging a divider next to the
4099             // grid restarted it. The columns are flex; they resize on their own.
4100             fit(){} },
4101  heatmap: { name:'Heatmap',     col:'#f59e0b', kind:'mode', ids:[{id:'expiryView',d:'flex',main:1}],
4102             // Same as Ghost Mode: the strip was only ever built as a side effect of already having
4103             // a ticker, so "select a ticker" appeared above nothing to select one with.
4104             boot(){ try{ if(typeof expiryTicker==='undefined'||!expiryTicker) expiryTicker=_wsLastTicker('expiry');
4105                          buildExpiryTickers(); loadExpiryGEX(expiryTicker,true); }catch(e){} } },
4106  ghost:   { name:'Ghost Mode',  col:'#d4af37', kind:'mode', ids:[{id:'chartView',d:'block',main:1}],
4107             // buildChartTickers UNCONDITIONALLY. It used to run only as a 
4107side effect of having a
4108             // ticker, so a workspace that opened with none showed "Select a ticker" above an empty
4109             // strip - the one control that could have selected one.
4110             boot(){ try{ if(typeof chartTicker==='undefined'||!chartTicker) chartTicker=_wsLastTicker('chart');
4111                          buildChartTickers(); loadGEXChart(chartTicker); }catch(e){} },
4112             fit(){ try{ if(typeof fitChartViewHeight==='function') fitChartViewHeight(); }catch(e){} } },
4113  flow:    { name:'Flow',        col:'#22d3ee', kind:'mode', ids:[{id:'flowView',d:'flex',main:1}],
4114             side:['flowFilters'],
4115             // a ticker chosen while this tile was on an inactive tab: do that work now
4116             fit(){ try{ if(_flowTkDirty){ _flowTkDirty=false; flowFilterTicker(flowTickerFilter); } }catch(e){} },
4117             boot(){ try{ if(typeof _renderFlowTickerChip==='function') _renderFlowTickerChip();
4118                          if(typeof startFlowFeed==='function') startFlowFeed(); }catch(e){} } },
4119  gpanel:  { name:'Ghost Panel', col:'#fbbf24', kind:'panel', ids:[{id:'ultraFlow',d:'flex',main:1}],
4120             // the same panel Ghost Mode docks on its right: Flow / Whales / Movers / Node Alerts /
4121             // G-Flip, with the filters and settings that belong to each, because it IS that panel
4122             boot(){
4123               try{ if(typeof nfBuildUltraPanel==='function') nfBuildUltraPanel(); }catch(e){}
4124               // it may have been left collapsed to a rail inside Ghost Mode; as a tile of its own
4125               // there is nothing to collapse it against
4126               try{ const p=document.getElementById('ultraFlow'); if(p) p.classList.remove('nf-panel-min'); }catch(e){}
4127               try{ setUltraFlowTab(typeof ultraRightTab==='string'?ultraRightTab:'flow'); }catch(e){}
4128               try{ if(typeof chartTicker!=='undefined' && chartTicker && typeof loadUltraFlow==='function') loadUltraFlow(chartTicker); }catch(e){}
4129             } },
4130  chain:   { name:'Chain',       col:'#a855f7', kind:'mode', ids:[{id:'chainView',d:'flex',main:1}],
4131             boot(){ try{ _chAnchor=''; loadChain(); chainPollStart(); }catch(e){} },   // _chAnchor='' -> re-centre on the money when the tile mounts (user 2026-09-17)
4132             fit(){ try{ if(_chData) renderChain(); }catch(e){} } },
4133  smart:   { name:'Smart Money', col:'#f472b6', kind:'mode', ids:[{id:'smartView',d:'flex',main:1}],
4134             // the filter may have been re-pointed while this tab was hidden; the render is cheap
4135             // and local, so just do it whenever the panel comes into view
4136             fit(){ try{ if(typeof _smartRender==='function') _smartRender(); }catch(e){} },
4137             boot(){ try{ if(typeof setSmartTab==='function') setSmartTab(typeof smartTab==='string'?smartTab:'politicians'); }catch(e){} } },
4138  ideas:   { name:'Ideas',       col:'#38bdf8', kind:'mode', ids:[{id:'ideasView',d:'flex',main:1}],
4139             // Every Ideas sub-tab's right column, by name. All eight are static markup present from
4140             // page load - a sub-tab that is not open has its wrap hidden, not its column missing -
4141             // so all of them can be collapsed at once, with nothing measured and nothing awaited.
4142             // No sideFind here on purpose: inference is what made this take four attempts.
4143             side:['ideasSide','earningsSide','watchlistSide','idxSide',
4144                   'ghostTracker','eodTracker','gflipTracker','printsTracker'],
4145             boot(){ try{ if(typeof setIdeasTab==='function') setIdeasTab(window._bootIdeasTab||_ideasTab||'setups'); }catch(e){} } },
4146  pulse:   { name:'Pulse',       col:'#818cf8', kind:'panel', ids:[{id:'scannerView',d:'flex',main:1}],
4147             side:['scanAlertsFeed','nodeAlertsFeed'],
4148             sideFind(slot){ const v=(slot&&slot.querySelector('#scannerView'))||document.getElementById('scannerView');
4149               if(!v) return [];
4150               return [...v.children].filter(w=>w.getBoundingClientRect().width>0)
4151                 .map(_wsLastColumn).filter(Boolean); },
4152             boot(){ try{ if(typeof loadScanner==='function' && !freeTrialOver()) loadScanner(); }catch(e){} } },
4153  track:   { name:'Tracking',    col:'#94a3b8', kind:'panel', ids:[{id:'trackingView',d:'flex',main:1}],
4154             boot(){ try{ loadTracking(); if(typeof startTrackingRefresh==='function') startTrackingRefresh(); }catch(e){} } },
4155};
4156
4157/* ── the layout: a binary split tree ──────────────────────────────────────────
4158   Every node is a LEAF (one tile, 1+ panels as tabs) or a SPLIT (a direction,
4159   children, fractional sizes). A tree cannot overlap or leave gaps, which is the
4160   entire reason to use one instead of free x/y/w/h boxes - those always drift
4161   into both, and a trader who loses a panel behind another never finds 
4161it. */
4162let _wsUid = 1, gfWsTree = null, gfWsFocus = null, _wsBooted = {}, _wsHome = {}, _wsRO = null, _wsOn = false;
4163const _wsLeaf  = (...tabs) => ({ id:'w'+(_wsUid++), t:'leaf', tabs, active:0 });
4164const _wsSplit = (dir,kids,sizes) => ({ id:'w'+(_wsUid++), t:'split', dir, kids, sizes: sizes||kids.map(()=>1/kids.length) });
4165const GF_WS_PRESETS = {
4166  single: () => _wsLeaf('grid'),
4167  trader: () => _wsSplit('row', [
4168      _wsSplit('col', [ _wsLeaf('ghost'), _wsLeaf('chain','track') ], [.62,.38]),
4169      _wsSplit('col', [ _wsLeaf('grid'),  _wsLeaf('ideas','smart') ], [.55,.45]),
4170    ], [.64,.36]),
4171  scan:   () => _wsSplit('col', [
4172      _wsSplit('row', [ _wsLeaf('flow'), _wsLeaf('ideas','smart') ], [.58,.42]),
4173      _wsSplit('row', [ _wsLeaf('heatmap'), _wsLeaf('grid') ], [.5,.5]),
4174    ], [.56,.44]),
4175};
4176
4177// localStorage is the instant copy so a reload never waits on the network; the account copy is the
4178// one that follows you to another machine. Written debounced because a divider drag calls paint on
4179// every mousemove and each of those would otherwise be a POST.
4180let _wsSaveT=null;
4181// Whatever each panel is showing, kept so a reload does not land on an empty prompt.
4182function _wsRememberTickers(){
4183  try{
4184    if(typeof chartTicker!=='undefined'  && chartTicker)  localStorage.setItem('gf_ws_tk_chart',  chartTicker);
4185    if(typeof expiryTicker!=='undefined' && expiryTicker) localStorage.setItem('gf_ws_tk_expiry', expiryTicker);
4186    if(typeof _chTk!=='undefined'        && _chTk)        localStorage.setItem('gf_ws_tk_chain',  _chTk);
4187  }catch(e){}
4188}
4189function _wsLastTicker(which){
4190  try{ const v=localStorage.getItem('gf_ws_tk_'+which); if(v) return v; }catch(e){}
4191  return 'SPXW';   // the board this dashboard exists for, and it always has data
4192}
4193// WHICH layout this window is showing. Per TAB, because that is the whole point of having more than
4194// one: sessionStorage is not shared between windows, so two monitors hold two different answers.
4195// A brand-new tab inherits the last one used in this browser, then goes its own way.
4196function gfWsActive(){
4197  let n=null;
4198  try{ n=sessionStorage.getItem('gf_ws_active'); }catch(e){}
4199  if(!n){ try{ n=localStorage.getItem('gf_ws_active_last'); }catch(e){} }
4200  return n || 'Default';
4201}
4202function gfWsSetActive(name){
4203  const n=String(name||'Default').trim().slice(0,40) || 'Default';
4204  try{ sessionStorage.setItem('gf_ws_active', n); }catch(e){}
4205  try{ localStorage.setItem('gf_ws_active_last', n); }catch(e){}
4206  return n;
4207}
4208// The local copies, by name. An instant open; the account is what makes them portable.
4209function gfWsLocal(){
4210  try{ const j=JSON.parse(localStorage.getItem('gf_ws_layouts')||'null');
4211       return (j && typeof j==='object' && !Array.isArray(j)) ? j : {}; }catch(e){ return {}; }
4212}
4213function gfWsLocalPut(name, rec){
4214  const all=gfWsLocal(); all[name]=rec;
4215  try{ localStorage.setItem('gf_ws_layouts', JSON.stringify(all)); }catch(e){}
4216}
4217function gfWsSave(){
4218  // never write from a hidden tab - it would clobber the window the user is actually using
4219  try{ if(typeof document!=='undefined' && document.visibilityState==='hidden') return; }catch(e){}
4220  _wsRememberTickers();
4221  const name=gfWsActive(), rec={ t:gfWsTree, u:_wsUid, at:Date.now() };
4222  gfWsLocalPut(name, rec);
4223  try{ localStorage.setItem('gf_ws_tree', JSON.stringify(rec)); }catch(e){}   // the pre-names key, still fed
4224  clearTimeout(_wsSaveT);
4225  _wsSaveT=setTimeout(function(){
4226    try{ fetch('/api/ws-layout',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},
4227      body:JSON.stringify({name:name, layout:{ t:gfWsTree, u:_wsUid }})}).catch(function(){}); }catch(e){}
4228  }, 1200);
4229}
4230// Save the arrangement on screen under a new name and switch to it. The old one keeps whatever it
4231// had: this is a copy, not a rename, which is what "save as" has to mean or you lose the original.
4232function gfWsSaveAs(){
4233  const raw=prompt('Save this layout as:', '');
4234  if(raw===null) return;
4235  const name=String(raw||'').trim().slice(0,40);
4236  if(!name) return;
4237  const all=gfWsLocal();
4238  if(all[name] && !confirm('"'+name+'" already exists. Overwrite it?')) return;
4239  // FORK: the layout this window started from gets back what it looked like then, because the
4240  // continuous save has already written every edit into it.
4241  const base=_wsLayoutBase;
4242  if(base && base.name && base.name!==name && base.t) _wsLayoutWrite(base.name, base.t, base.u);
4243  gfWsSetActive(name);
4244  gfWsSave();
4245  _wsLayoutMark();                                  // from here, this is the layout being edited
4246  try{ gfWsPalette(); }catch(e){}
4247  try{ showToast('Saved as '+name+(base&&base.name&&base.name!==name?' \u2014 '+base.name+' left as it was':'')); }catch(e){}
4248}
4249// Open a saved layout in THIS window. Nothing is written to the one being left - it was saved as it
4250// was edited - so switching back and forth costs nothing.
4251// What this window found when it opened the layout it is on. Save as forks from here, so the layout
4252// you started from keeps what it had rather than quietly becoming the new one.
4253let _wsLayoutBase = null;
4254function _wsLayoutMark(){
4255  try{ _wsLayoutBase = { name:gfWsActive(), t:JSON.parse(JSON.stringify(gfWsTree||null)), u:_wsUid }; }catch(e){ _wsLayoutBase=null; }
4256}
4257// Hand a layout back to the account and to this browser without disturbing the one on screen.
4258function _wsLayoutWrite(name, t, u){
4259  if(!name || !t) return;
4260  gfWsLocalPut(name, { t:t, u:u||60, at:Date.now() });
4261  try{ fetch('/api/ws-layout',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},
4262    body:JSON.stringify({name:name, layout:{ t:t, u:u||60 }})}).catch(function(){}); }catch(e){}
4263}
4264// A swap must KEEP NOTHING: the reuse in gfWsPaint matches tiles by id, and a layout forked from
4265// another shares ids, so the panels would be looked for inside shells they were just emptied from.
4266function _wsLayoutClear(want){
4267  try{
4268    _wsKeep = null;                       // nothing is keepable during a swap
4269    _wsEvacuate();                        // panels to the attic, where the next paint finds them
4270    const stage=document.getElementById('wsStage');
4271    if(stage) [...stage.querySelectorAll('.gf-ws-leaf,.gf-ws-div,.gf-ws-empty')].forEach(e=>e.remove());
4272    // panels the incoming layout does not want go properly home and forget they ever booted
4273    Object.keys(GF_WS_PANELS).forEach(function(k){
4274      if(want && want.indexOf(k)>=0) return;
4275      GF_WS_PANELS[k].ids.forEach(function(spec){ if(_wsHome[spec.id]) gfWsUnpark(spec); });
4276      delete _wsBooted[k];
4277    });
4278  }catch(e){}
4279}
4280function gfWsOpenLayout(name){
4281  const all=gfWsLocal(), rec=all[name];
4282  gfWsSetActive(name);
4283  if(rec && rec.t){
4284    gfWsTree=rec.t; _wsUid=rec.u||60;
4285    gfWsFocus=gfWsTree?_wsFirstLeaf(gfWsTree).id:null;
4286    _wsLayoutClear(_wsAllTabs(gfWsTree));
4287    gfWsPaint(); gfWsRefit(); _wsLayoutMark();
4288  }
4289  else { try{ gfWsPullSaved(true); }catch(e){} }   // not in this browser yet: ask the account for it
4290  try{ gfWsPalette(); }catch(e){}
4291}
4292function gfWsDeleteLayout(name){
4293  if(!confirm('Delete the layout "'+name+'"?')) return;
4294  const all=gfWsLocal(); delete all[name];
4295  try{ localStorage.setItem('gf_ws_layouts', JSON.stringify(all)); }catch(e){}
4296  try{ fetch('/api/ws-layout',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},
4297    body:JSON.stringify({name:name, layout:null})}).catch(function(){}); }catch(e){}
4298  if(gfWsActive()===name){ const left=Object.keys(all).sort()[0]; gfWsOpenLayout(left||'Default'); }
4299  try{ gfWsPalette(); }catch(e){}
4300}
4301function gfWsLoadSaved(){
4302  try{ const r=gfWsLocal()[gfWsActive()];
4303       if(r && r.t){ gfWsTree=r.t; _wsUid=r.u||50; return true; } }catch(e){}
4304  // nothing under that name yet: fall back to the single layout saved before names existed
4305  try{ const j=JSON.parse(localStorage.getItem('gf_ws_tree')||'null');
4306       if(j && j.t){ gfWsTree=j.t; _wsUid=j.u||50; return true; } }catch(e){}
4307  return false;
4308}
4309const _wsFindNode = (n,id)=>{ if(!n) return null; if(n.id===id) return n;
4310  if(n.t==='split') for(const k of n.kids){ const r=_wsFindNode(k,id); if(r) return r; } return null; };
4311const _wsFirstLeaf = n => n && (n.t==='leaf' ? n : _wsFirstLeaf(n.kids[0]));
4312function _wsAllTabs(n,out){ out=out||[]; if(!n) return out;
4313  if(n.t==='leaf') n.tabs.forEach(t=>out.push(t)); else n.kids.forEach(k=>_wsAllTabs(k,out)); return out; }
4314
4315/* ── park and unpark: the whole trick ────────────────────────────────────────
4316   Remember where a node lived and exactly what its inline style was, then move
4317   it. Putting it back has to be byte-identical or the normal mode comes back
4318   subtly broken - a lost height:calc() reads as "the chart shrank after I used
4319   the workspace once". */
4320// The heatmap picks its label lengths when it draws. Coming in or going out changes which set is
4321// right, and nothing else re-draws it - so ask it to, but only if it has actually drawn something.
4322function _wsHeatRelabel(){
4323  try{
4324    if(typeof expiryTicker==='undefined' || !expiryTicker) return;
4325    if(!document.querySelector('#expiryView .exp-table')) return;
4326    loadExpiryGEX(expiryTicker, true);
4327  }catch(e){}
4328}
4329// One answer to "are we in the dashboard", for the panels that render differently in a tile.
4330function _gfWsOn(){ try{ return document.body.classList.contains('gf-ws-on'); }catch(e){ return false; } }
4331function gfWsPark(spec, host){
4332  const el = document.getElementById(spec.id); if(!el) return;
4333  if(!_wsHome[spec.id]) _wsHome[spec.id] = { parent: el.parentNode, next: el.nextSibling, style: el.getAttribute('style') };
4334  el.classList.add('gf-ws-in'); if(spec.main) el.classList.add('gf-ws-main');
4335  el.style.display = (spec.d!=null ? spec.d : '');
4336  host.appendChild(el);
4337}
4338function gfWsUnpark(spec){
4339  if(spec && String(spec.id).indexOf('dupHost-')===0){ try{ _dupRelease(spec.id.slice(8)); }catch(e){} }   // a duplicate hands its tab back to the original
4340  const el = document.getElementById(spec.id), h = _wsHome[spec.id]; if(!el||!h) return;
4341  el.classList.remove('gf-ws-in','gf-ws-main');
4342  if(h.style==null) el.removeAttribute('style'); else el.setAttribute('style', h.style);
4343  // the old sibling can itself have moved since; falling back to append keeps the node in the right
4344  // PARENT, which is what every downstream selector actually depends on
4345  const anchor = (h.next && h.next.parentNode===h.parent) ? h.next : null;
4346  h.parent.insertBefore(el, anchor);
4347  delete _wsHome[spec.id];
4348}
4349function gfWsUnparkAll(){ Object.keys(_wsHome).slice().forEach(id=>{
4350  const k=Object.keys(GF_WS_PANELS).find(p=>GF_WS_PANELS[p].ids.some(s=>s.id===id));
4351  const spec=k && GF_WS_PANELS[k].ids.find(s=>s.id===id); if(spec) gfWsUnpark(spec); }); }
4352
4353/* ── paint ───────────────────────────────────────────────────────────────── */
4354const GF_WS_DIV = 8;
4355// EVACUATE FIRST. gfWsPaint rebuilds the tiles, and a parked panel lives INSIDE a tile - so
4356// removing the tiles took nine of the eleven real view nodes out of the document with them. They
4357// were not detached-and-reused, they were destroyed: the next gfWsPark asked getElementById for a
4358// node that no longer existed, got null, and returned quietly. Measured live after one repaint:
4359// every slot 0 children, #chartView #chainView #ideasView #columns all gone until a reload.
4360//
4361// So every panel is moved to the attic before the tiles come down, and re-homed from there. The
4362// nodes now genuinely survive a repaint, which is what the comment below always claimed.
4363// What a tile is holding, as one string. Two tiles with the same signature are interchangeable, so
4364// a paint that leaves it unchanged has nothing to rebuild.
4365function _wsSig(node){ return node.tabs.join(',')+'|'+node.active; }
4366// Tiles this paint is keeping, by wsid. Filled at the top of gfWsPaint, read by the evacuation and
4367// by _wsPaintLeaf; empty at every other moment, so nothing else can be fooled by a stale entry.
4368let _wsKeep = null;
4369function _wsKept(el){
4370  return !!(_wsKeep && el && el.closest && (function(){ const L=el.closest('.gf-ws-leaf');
4371    return L && L.dataset && _wsKeep[L.dataset.wsid]===L; })());
4372}
4373function _wsEvacuate(){
4374  const attic=document.getElementById('wsAttic'); if(!attic) return;
4375  // a panel inside a tile we are keeping stays where it is - that is the whole point
4376  const hoist=function(el){ if(el && el.closest && el.closest('.gf-ws-leaf') && !_wsKept(el)) attic.appendChild(el); };
4377  hoist(document.getElementById('wsGridBar'));
4378  document.querySelectorAll('.gf-ws-sidebtn').forEach(hoist);
4379  hoist(document.getElementById('wsNodes'));
4380  hoist(document.getElementById('wsFlowCtl'));
4381  hoist(document.getElementById('wsGexTab'));
4382  hoist(document.getElementById('gridResetBtn'));
4383  Object.keys(GF_WS_PANELS).forEach(k=>GF_WS_PANELS[k].ids.forEach(spec=>{
4384    hoist(document.getElementById(spec.id));
4385  }));
4386}
4387// TradingView drives a single chart well; in a workspace it silently re-tickers several panels at
4388// once from an outside app. Off in here unless the user asks for it, remembered so leaving restores
4389// what they had, and re-asserted rather than set once.
4390function _wsTvEnforce(){
4391  if(!_wsOn) return;
4392  let was = true;                                  // unreadable means the app's default, which is on
4393  try{ was = !!tvSyncLinked; }catch(e){}
4394  if(_wsTvWas===null) _wsTvWas = was;              // what Ghost Mode had, to hand back on the way out
4395  const want = _wsTvWant();
4396  if(was !== want){ try{ if(typeof nfSetTvSync==='function') nfSetTvSync(want); }catch(e){} }
4397  try{ if(typeof nfUpdateTvSyncBtn==='function') nfUpdateTvSyncBtn(); }catch(e){}
4398}
4399function gfWsPaint(){
4400  const stage = document.getElementById('wsStage'); if(!stage) return;
4401  // A tile whose tabs and active index are unchanged is kept whole, panels and all; only its
4402  // position is rewritten. Everything else is rebuilt, and its panels re-homed rather than destroyed.
4403  _wsKeep = {};
4404  try{ _dupEnsureAll(); }catch(e){}   // a saved layout can carry a copy's key: its host + manifest exist before the paint
4405  if(gfWsTree){ (function scan(n){ if(!n) return;
4406      if(n.t==='leaf'){ const el=stage.querySelector('.gf-ws-leaf[data-wsid="'+n.id+'"]');
4407                        if(el && el.dataset.sig===_wsSig(n)) _wsKeep[n.id]=el; return; }
4408      (n.kids||[]).forEach(scan); })(gfWsTree); }
4409  _wsEvacuate();
4410  [...stage.querySelectorAll('.gf-ws-leaf,.gf-ws-div,.gf-ws-empty,.gf-ws-grip')]
4411    .forEach(e=>{ if(!(e.dataset && _wsKeep[e.dataset.wsid]===e)) e.remove(); });
4412  const r = stage.getBoundingClientRect();
4413  if(!gfWsTree){
4414    const e=document.createElement('div'); e.className='gf-ws-empty';
4415    e.textContent='Empty HQ — add a panel from the bar above';
4416    stage.appendChild(e); gfWsPalette(); return;
4417  }
4418  _wsPlace(gfWsTree, 0, 0, r.width, r.height);
4419  try{ _wsGrips(stage); }catch(e){}
4420  gfWsPalette(); gfWsPaintSync();
4421  _wsKeep = null;                       // only meaningful during a paint
4422  try{ gfWsSettingsBar(); }catch(e){}   // the evacuation pulls it out; put it back every paint
4423  _wsTvEnforce();
4424  try{ gfWsSave(); }catch(e){}
4425}
4426// A split node's current pixel extent, by id. Kept beside the tree rather than on it: the tree is
4427// serialised to localStorage and to the account, and layout arithmetic is not part of a saved layout.
4428const _wsPx = {};
4429function _wsPlace(node,x,y,w,h){
4430  if(node.t==='leaf') return _wsPaintLeaf(node,x,y,w,h);
4431  const horiz = node.dir==='row', total = horiz?w:h;
4432  _wsPx[node.id]=total;
4433  const avail = total - GF_WS_DIV*(node.kids.length-1);
4434  let off=0;
4435  node.kids.forEach((k,i)=>{
4436    const size = avail*node.sizes[i];
4437    if(horiz) _wsPlace(k,x+off,y,size,h); else _wsPlace(k,x,y+off,w,size);
4438    off += size;
4439    if(i<node.kids.length-1){
4440      const d=document.createElement('div'); d.className='gf-ws-div '+node.dir;
4441      d.dataset.dvid=node.id+':'+i;                 // so a reflow can find it again
4442      if(horiz) d.style.cssText='left:'+(x+off)+'px;top:'+y+'px;height:'+h+'px';
4443      else      d.style.cssText='left:'+x+'px;top:'+(y+off)+'px;width:'+w+'px';
4444      d.onmousedown = ev => _wsResize(ev,node,i,horiz);
4445      document.getElementById('wsStage').appendChild(d);
4446      off += GF_WS_DIV;
4447    }
4448  });
4449}
4450// The stage's bottom and right edges pull inward to open an empty slot along them.
4451function _wsGrips(stage){
4452  // one grip along the bottom and one along the right edge of EVERY window, so any window can be
4453  // lifted on its own (the stage's outer edges are covered too - the outermost windows own them)
4454  stage.querySelectorAll('.gf-ws-leaf').forEach(function(leaf){
4455    ['bottom','right'].forEach(function(side){
4456      const g=document.createElement('div'); g.className='gf-ws-grip '+side;
4457      g.title='Drag inward to open an empty slot on this edge of this window';
4458      const L=parseFloat(leaf.style.left)||0, T=parseFloat(leaf.style.top)||0, W=parseFloat(leaf.style.width)||0, H=parseFloat(leaf.style.height)||0;
4459      if(side==='bottom') g.style.cssText='left:'+L+'px;top:'+(T+H-4)+'px;height:4px;width:'+W+'px;right:auto;bottom:auto';   // thin, at the edge: the scrollbar's upper part stays grabbable
4460      else                g.style.cssText='left:'+(L+W-9)+'px;top:'+T+'px;
4460height:'+H+'px;right:auto;bottom:auto';
4461      g.onmousedown=function(ev){ _wsEdgePull(ev, side, g); };
4462      stage.appendChild(g);
4463    });
4464  });
4465}
4466function _wsEdgePull(ev, side, grip){
4467  if(!gfWsTree) return;
4468  ev.preventDefault(); ev.stopPropagation();
4469  const stage=document.getElementById('wsStage'); if(!stage) return;
4470  // ONLY THE WINDOW UNDER THE GRIP LIFTS (user 2026-09-14: "this bottom lifting all window. its not
4471  // like that only lift up the window we want"): the slot opens inside that window's box - below it
4472  // for the bottom grip, beside it for the right one - so the windows around it stay put, and the
4473  // slot can then take a new panel or the neighbour dragged into it.
4474  const probe = document.elementsFromPoint(side==='bottom'?ev.clientX:ev.clientX-12, side==='bottom'?ev.clientY-12:ev.clientY).find(x=>x.classList&&x.classList.contains('gf-ws-leaf'));
4475  const node = probe ? _wsFindNode(gfWsTree, probe.dataset.wsid) : null;
4476  if(!node || node.t!=='leaf') return;
4477  const r=probe.getBoundingClientRect(), total=(side==='bottom')?r.height:r.width;
4478  if(!(total>0)) return;
4479  const fresh={ id:'w'+(_wsUid++), t:'leaf', tabs:[], active:0, hold:true };
4480  const clone={ id:'w'+(_wsUid++), t:'leaf', tabs:node.tabs.slice(), active:node.active, hold:node.hold };
4481  node.t='split'; node.dir=(side==='bottom')?'col':'row'; node.kids=[clone,fresh]; node.sizes=[1,0];
4482  delete node.tabs; delete node.active; delete node.hold;
4483  const split=node;
4484  gfWsPaint();                                    // the slot exists at zero size; the pull sizes it
4485  if(grip) grip.classList.add('on');
4486  const start=(side==='bottom')?ev.clientY:ev.clientX;
4487  let f=0;
4488  const move=function(e){
4489    const d=start-((side==='bottom')?e.clientY:e.clientX);
4490    f=Math.max(0, Math.min(.9, d/total));
4491    split.sizes=[1-f, f]; gfWsReflow();
4492  };
4493  const up=function(){
4494    document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
4495    if(f<0.06){ delete fresh.hold; fresh.tabs=[]; gfWsPrune(); gfWsPaint(); gfWsRefit(); return; }   // a nudge, not a pull: the slot goes, the split collapses back
4496    gfWsFocus=fresh.id; gfWsPrune(); gfWsPaint(); gfWsRefit(); try{ gfWsSave(); }catch(e){}
4497  };
4498  document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
4499}
4500// Geometry only: the same walk as _wsPlace, writing onto what is already on screen.
4501function _wsReflow(node,x,y,w,h){
4502  if(node.t==='leaf'){
4503    const el=document.querySelector('.gf-ws-leaf[data-wsid="'+node.id+'"]');
4504    if(el) el.style.cssText='left:'+x+'px;top:'+y+'px;width:'+w+'px;height:'+h+'px';
4505    return;
4506  }
4507  const horiz = node.dir==='row', total = horiz?w:h;
4508  _wsPx[node.id]=total;
4509  const avail = total - GF_WS_DIV*(node.kids.length-1);
4510  let off=0;
4511  node.kids.forEach((k,i)=>{
4512    const size = avail*node.sizes[i];
4513    if(horiz) _wsReflow(k,x+off,y,size,h); else _wsReflow(k,x,y+off,w,size);
4514    off += size;
4515    if(i<node.kids.length-1){
4516      const d=document.querySelector('.gf-ws-div[data-dvid="'+node.id+':'+i+'"]');
4517      if(d){ if(horiz) d.style.cssText='left:'+(x+off)+'px;top:'+y+'px;height:'+h+'px';
4518             else      d.style.cssText='left:'+x+'px;top:'+(y+off)+'px;width:'+w+'px'; }
4519      off += GF_WS_DIV;
4520    }
4521  });
4522}
4523function gfWsReflow(){
4524  const stage=document.getElementById('wsStage'); if(!stage||!gfWsTree) return;
4525  const r=stage.getBoundingClientRect();
4526  _wsReflow(gfWsTree,0,0,r.width,r.height);
4527  try{ stage.querySelectorAll('.gf-ws-grip').forEach(g=>g.remove()); _wsGrips(stage); }catch(e){}   // the grips ride their windows
4528}
4529function _wsPaintLeaf(node,x,y,w,h){
4530  const stage=document.getElementById('wsStage');
4531  // unchanged contents: move it, do not rebuild it. Its panels never leave the document.
4532  const keep = _wsKeep && _wsKeep[node.id];
4533  if(keep){
4534    keep.style.cssText='left:'+x+'px;top:'+y+'px;width:'+w+'px;height:'+h+'px';
4535    keep.classList.toggle('foc', gfWsFocus===node.id);
4536    try{ gfWsReapplySide(node.tabs[node.active]); }catch(e){}   // content may have arrived since
4537
4538    if(_wsRO) try{ _wsRO.observe(keep); }catch(e){}
4539    return;
4540  }
4541  const el=document.createElement('div');
4542  el.className='gf-ws-leaf'+(gfWsFocus===node.id?' foc':'');
4543  el.style.cssText='left:'+x+'px;top:'+y+'px;width:'+w+'px;height:'+h+'px';
4544  el.dataset.wsid=node.id; el.dataset.sig=_wsSig(node);
4545  el.addEventListener('mousedown',()=>{
4546    if(gfWsFocus===node.id) return;
4547    gfWsFocus=node.id;
4548    // NEVER repaint here. gfWsPaint tears the tiles down and rebuilds them, so the element this
4549    // click was about to reach stopped existing mid-gesture: the first click on an unfocused tile
4550    // did nothing and every action needed two. Focus is only a border - move the class.
4551    try{ document.querySelectorAll('.gf-ws-leaf').forEach(x=>x.classList.toggle('foc', x.dataset.wsid===node.id)); }catch(e){}
4552    try{ gfWsBuildTickers(); }catch(e){}   // the strip marks what the NEWLY focused panel is showing
4553    try{ _wsApplyFocusSettings(); }catch(e){}
4554    try{ gfWsSave(); }catch(e){}
4555  }, true);
4556
4557  const tabs=document.createElement('div'); tabs.className='gf-ws-tabs';
4558  const fill=document.createElement('div'); fill.className='gf-ws-fill';   // built first: the ghost panel parks its levels in here
4559  fill.title='Drag to move this whole window';
4560  // only the strip's own background starts a window drag - a tab, or anything parked in here, keeps
4561  // whatever it already does
4562  fill.addEventListener('mousedown', function(e){ if(e.target!==fill) return; _wsLeafDragStart(e,node); });
4563  node.tabs.forEach((k,i)=>{
4564    const P=GF_WS_PANELS[k]; if(!P) return;
4565    const t=document.createElement('div');
4566    t.className='gf-ws-tab'+(i===node.active?' on':'');
4567    t.dataset.ti=i;                      // matched by index, not DOM position: an unknown key is skipped
4568    t.innerHTML='<span class="gf-ws-dot" style="background:'+P.col+'"></span>'+P.name+'<span class="x">&times;</span>';
4569    t.onclick=e=>{ if(_wsAteDrag()) return;
4570                   if(e.target.classList.contains('x')){ gfWsCloseTab(node,i); return; }
4571                   if(node.active===i) return;
4572                   node.active=i; _wsLeafShow(el,node,fill); };
4573    t.onmousedown=e=>{ if(e.target.classList.contains('x')) return; _wsDragStart(e,k,node); };
4574    t.oncontextmenu=e=>_wsTabMenu(e,node,i);
4575    tabs.appendChild(t);
4576  });
4577  tabs.appendChild(fill);
4578  if(node.tabs.length) el.appendChild(tabs);   // an empty slot is space, not a window: no tab bar
4579
4580  // EVERY tab stays mounted; inactive ones are hidden, not unmounted. Unmounting would re-run each
4581  // panel's boot on every tab click - refetching a chain, resetting a chart's zoom - which is the
4582  // same "it re-rendered and lost my place" failure Ghost Desk rejected.
4583  const host=document.createElement('div'); host.className='gf-ws-host';
4584  if(!node.tabs.length){
4585    const ph=document.createElement('div'); ph.className='gf-ws-slot gf-ws-hold';
4586    ph.innerHTML='<div>Drop a panel here, or drag a window in to extend it over this space<div style="font-size:11px;font-weight:600;color:#cbd5e1;margin-top:6px">or drag the divider across to give the space to the window beside it</div></div>'
4587      +'<span class="gf-ws-holdx" title="Close this space">&times;</span>';
4588    ph.querySelector('.gf-ws-holdx').onclick=function(e){ e.stopPropagation(); delete node.hold; gfWsPrune(); gfWsPaint(); gfWsRefit(); };
4589    host.appendChild(ph);
4590  }
4591  const _sideLater=[];
4592  node.tabs.forEach((k,i)=>{
4593    const P=GF_WS_PANELS[k]; if(!P) return;
4594    const slot=document.createElement('div');
4595    slot.className='gf-ws-slot'+(i===node.active?'':' off');
4596    slot.dataset.panel=k; slot.dataset.ti=i;
4597    host.appendChild(slot);
4598    P.ids.forEach(spec=>gfWsPark(spec,slot));
4599    if(k==='grid'){ try{ _wsGridBar(slot); }catch(e){} }
4600    _sideLater.push([slot,k]);        // measured work: it has to wait for the tile to be in the document
4601    // the level chips live in the tab bar, which every tab in this tile shares - so only the tab
4602    // you are actually looking at may put them there
4603    if(k==='ghost'){ if(i===node.active){ try{ _wsChartNodes(slot, fill); }catch(e){} }
4604                      try{ _wsGexTab(slot); }catch(e){} }   // into the tab bar, beside the name
4605    if(k==='flow'){ try{ _wsFlowBar(slot); }catch(e){} }
4606    if(!_wsBooted[k]){ _wsBooted[k]=1; setTimeout(()=>{ try{ P.boot&&P.boot(); }catch(e){}
4607        try{ _wsRememberTickers(); }catch(e){} }, 30);
4608      // the ladders arrive with the data, not with the boot call
4609      setTimeout(_wsCenterSpot, 1200); setTimeout(_wsCenterSpot, 3000); }
4610  });
4611  el.appendChild(host);
4612  stage.appendChild(el);
4613  // NOW it has a box. Everything that decides by measuring goes here and nowhere earlier.
4614  _sideLater.forEach(function(p){ try{ _wsSideMin(p[0],p[1]); }catch(e){} });
4615  if(_wsRO) try{ _wsRO.observe(el); }catch(e){}
4616}
4617// Show tab node.active in THIS tile and nothing else. No evacuation, no tree rebuild: every panel
4618// stays exactly where it is in the document, which is why the other windows stop blinking.
4619function _wsLeafShow(el,node,fill){
4620  try{ el.dataset.sig=_wsSig(node); }catch(e){}   // keep the tile's stamp honest for the next paint
4621  try{
4622    const slots=[...el.querySelectorAll('.gf-ws-slot[data-ti]')];
4623    slots.forEach(function(sl){ sl.classList.toggle('off', +sl.dataset.ti !== node.active); });
4624    [...el.querySelectorAll('.gf-ws-tab[data-ti]')].forEach(function(t){
4625      t.classList.toggle('on', +t.dataset.ti === node.active); });
4626    const k=node.tabs[node.active];
4627    const slot=slots.find(function(sl){ return +sl.dataset.ti === node.active; });
4628    if(k==='ghost' && slot){ try{ _wsChartNodes(slot, fill); }catch(e){} }
4629    else { const wn=document.getElementById('wsNodes'), at=document.getElementById('wsAttic');
4630           if(wn && at && el.contains(wn)) at.appendChild(wn); }   // off the shared tab bar
4631    const P=GF_WS_PANELS[k];
4632    if(P && P.fit) try{ P.fit(); }catch(e){}       // it was hidden, so it never sized to this tile
4633    try{ _wsStaleCatch(_dupBase(k)); }catch(e){}             // and its ticker may have moved while it was
4634    setTimeout(_wsCenterSpot, 300);
4635  }catch(e){}
4636  try{ gfWsPaintSync(); }catch(e){}
4637  try{ gfWsSave(); }catch(e){}
4638}
4639function _wsResize(ev,node,i,horiz){
4640  ev.preventDefault(); ev.stopPropagation();
4641  // the CURRENT extent, not the one this divider was built with - an outer drag changes it
4642  const total = _wsPx[node.id];
4643  if(!total) return;
4644  const start = horiz?ev.clientX:ev.clientY;
4645  const a=node.sizes[i], sum=a+node.sizes[i+1];
4646  // an EMPTY slot on either side may be squeezed all the way shut (a window keeps its 8% floor)
4647  const isHold=n=>!!(n && n.t==='leaf' && !n.tabs.length && n.hold);
4648  const loA=isHold(node.kids[i])?0:.08, loB=isHold(node.kids[i+1])?0:.08;
4649  const move=e=>{ const d=((horiz?e.clientX:e.clientY)-start)/total;
4650    const na=Math.max(loA,Math.min(sum-loB,a+d));
4651    node.sizes[i]=na; node.sizes[i+1]=sum-na; gfWsReflow(); };   // move the boxes, do not rebuild them
4652  const up=()=>{ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
4653                 // squeezed under 6%: the empty slot is absorbed by the window beside it (user 2026-09-14: "extend")
4654                 let ate=false;
4655                 [i,i+1].forEach(j=>{ const k=node.kids[j]; if(isHold(k) && node.sizes[j]<0.06){ delete k.hold; ate=true; } });
4656                 if(ate){ gfWsPrune(); gfWsPaint(); }
4657                 gfWsRefit(); try{ gfWsSave(); }catch(e){} };   // the paint used to be what saved the sizes
4658  document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
4659}
4660
4661/* ── drag to dock ────────────────────────────────────────────────────────── */
4662let _wsDrag=null, _wsDragMoved=false;
4663// Swallow the click the browser fires after a real drag. Returns true when the caller should stop.
4664function _wsAteDrag(){ if(!_wsDragMoved) return false; _wsDragMoved=false; return true; }
4665function _wsDragStart(ev,key,fromLeaf){
4666  ev.preventDefault();
4667  _wsDragMoved=false;
4668  _wsDrag={ key, from:fromLeaf||null, target:null, zone:null, x0:ev.clientX, y0:ev.clientY };
4669  const g=document.getElementById('wsGhost'); g.textContent=GF_WS_PANELS[key].name; g.style.display='block';
4670  _wsGhostAt(ev);
4671  document.addEventListener('mousemove',_wsDragMove); document.addEventListener('mouseup',_wsDrop);
4672}
4673// The whole window, by the empty stretch of its tab bar. Same drag machinery, a different payload.
4674function _wsLeafDragStart(ev,node){
4675  if(!node || node.t!=='leaf' || !node.tabs.length) return;
4676  ev.preventDefault();
4677  _wsDragMoved=false;
4678  _wsDrag={ key:null, leaf:node, from:null, target:null, zone:null, x0:ev.clientX, y0:ev.clientY };
4679  const g=document.getElementById('wsGhost');
4680  g.textContent=node.tabs.map(function(k){ return (GF_WS_PANELS[k]||{name:k}).name; }).join(' + ');
4681  g.style.display='block';
4682  _wsGhostAt(ev);
4683  document.addEventListener('mousemove',_wsDragMove); document.addEventListener('mouseup',_wsDrop);
4684}
4685function _wsGhostAt(e){ const g=document.getElementById('wsGhost'); g.style.left=(e.clientX+14)+'px'; g.style.top=(e.clientY+14)+'px'; }
4686function _wsDragMove(e){
4687  if(_wsDrag && !_wsDragMoved){
4688    const dx=e.clientX-_wsDrag.x0, dy=e.clientY-_wsDrag.y0;
4689    if(Math.abs(dx)+Math.abs(dy) <= 4) return;      // jitter, not a drag - leave it as a click
4690    _wsDragMoved=true;
4691  }
4692  _wsGhostAt(e);
4693  const dz=document.getElementById('wsDz'), stage=document.getElementById('wsStage');
4694  const el=document.elementsFromPoint(e.clientX,e.clientY).find(x=>x.classList&&x.classList.contains('gf-ws-leaf'));
4695  const sr=stage.getBoundingClientRect();
4696  const _inStage = e.clientX>sr.left && e.clientX<sr.right && e.clientY>sr.top && e.clientY<sr.bottom;
4697  if(!el && !gfWsTree){
4698    if(_inStage){
4699      _wsDrag.target='ROOT'; _wsDrag.zone='center';
4700      dz.style.cssText='display:block;left:0;top:0;width:'+sr.width+'px;height:'+sr.height+'px'; return;
4701    }
4702    _wsDrag.target=null; dz.style.display='none'; return;
4703  }
4704  if(!el && !_inStage){ _wsDrag.target=null; dz.style.display='none'; return; }
4705  // THE OUTER EDGE OF THE STAGE beats any tile under it: this is the full-height / full-width drop.
4706  // Checked before the tab strip so the edge still works over a tile's own tab bar.
4707  const _overStrip = !!(e.target && e.target.closest && e.target.closest('.gf-ws-tabs'));
4708  const EDGE = _overStrip ? 10 : 34;   // a tab strip under the pointer keeps its reorder / join drop
4709  if(gfWsTree && e.clientX>=sr.left && e.clientX<=sr.right && e.clientY>=sr.top && e.clientY<=sr.bottom){
4710    const gl=e.clientX-sr.left, gr2=sr.right-e.clientX, gt=e.clientY-sr.top, gb=sr.bottom-e.clientY;
4711    const gm=Math.min(gl,gr2,gt,gb);
4712    if(gm<EDGE){
4713      const z = gm===gl?'left' : gm===gr2?'right' : gm===gt?'top' : 'bottom';
4714      _wsDrag.target='ROOT'; _wsDrag.zone=z; _wsDrag.at=-1;
4715      let x=0,y=0,w=sr.width,h=sr.height;
4716      if(z==='left')   w=sr.width/2;
4717      if(z==='right'){ x=sr.width/2; w=sr.width/2; }
4718      if(z==='top')    h=sr.height/2;
4719      if(z==='bottom'){ y=sr.height/2; h=sr.height/2; }
4720      dz.style.cssText='display:block;left:'+x+'px;top:'+y+'px;width:'+w+'px;height:'+h+'px';
4721      return;
4722    }
4723  }
4724  // over the tab strip itself: this is a reorder, not a dock
4725  const _tb=e.target && e.target.closest && e.target.closest('.gf-ws-tabs');
4726  if(_tb && _tb.parentElement===el){
4727    const _t=e.target.closest('.gf-ws-tab');
4728    _wsDrag.target=el.dataset.wsid; _wsDrag.zone='center';
4729    _wsDrag.at=_t ? [..._tb.querySelectorAll('.gf-ws-tab')].indexOf(_t) : -1;
4730    const tr=(_t||_tb).getBoundingClientRect();
4731    dz.style.cssText='display:block;left:'+(tr.left-sr.left)+'px;top:'+(tr.top-sr.top)+'px;width:'+Math.max(4,tr.width)+'px;height:'+tr.height+'px';
4732    return;
4733  }
4734  if(!el){ _wsDrag.target=null; dz.style.display='none'; return; }   // in the stage but not on a tile
4735  _wsDrag.at=-1;
4736  const r=el.getBoundingClientRect();
4737  const fx=(e.clientX-r.left)/r.width, fy=(e.clientY-r.top)/r.height;
4738  const dl=fx, dr=1-fx, dt=fy, db=1-fy, m=Math.min(dl,dr,dt,db);
4739  // the outer 28% of any edge splits that way; the middle joins as a tab
4740  let zone='center';
4741  if(m<0.28) zone = m===dl?'left' : m===dr?'right' : m===dt?'top' : 'bottom';
4742  try{ const _n=_wsFindNode(gfWsTree,el.dataset.wsid); if(_n && _n.t==='leaf' && !_n.tabs.length) zone='center'; }catch(e){}   // an empty slot takes the drop whole
4743  _wsDrag.target=el.dataset.wsid; _wsDrag.zone=zone;
4744  let x=r.left-sr.left, y=r.top-sr.top, w=r.width, h=r.height;
4745  if(zone==='left')   w=r.width/2;
4746  if(zone==='right'){ x+=r.width/2; w=r.width/2; }
4747  if(zone==='top')    h=r.height/2;
4748  if(zone==='bottom'){ y+=r.height/2; h=r.height/2; }
4749  dz.style.cssText='display:block;left:'+x+'px;top:'+y+'px;width:'+w+'px;height:'+h+'px';
4750}
4751function _wsDrop(){
4752  document.removeEventListener('mousemove',_wsDragMove); document.removeEventListener('mouseup',_wsDrop);
4753  document.getElementById('wsGhost').style.display='none';
4754  document.getElementById('wsDz').style.display='none';
4755  const d=_wsDrag; _wsDrag=null;
4756  if(!d||!d.target||!_wsDragMoved) return;
4757  if(d.leaf){                                       // a whole window travelling
4758    const tabs=d.leaf.tabs.slice();
4759    if(d.target==='ROOT'){
4760      // dragging the ONLY window against the edge of the stage would empty the tree and put it back
4761      if(gfWsTree===d.leaf) return;
4762      d.leaf.tabs=[]; d.leaf.active=0;
4763      gfWsPrune();                                  // drop the empty one BEFORE wrapping the root
4764      _wsInsertRoot(tabs,d.zone);
4765      gfWsPrune(); gfWsPaint(); gfWsRefit();
4766      return;
4767    }
4768    if(d.target===d.leaf.id) return;                // dropped on itself
4769    const tgt=_wsFindNode(gfWsTree,d.target);
4770    if(!tgt || tgt.t!=='leaf') return;
4771    // a window dropped into an empty space beside it EXTENDS over the space when the tree allows it
4772    if(!tgt.tabs.length && tgt.hold){ let ext=false; try{ ext=_wsExtendInto(d.leaf,tgt); }catch(e){} if(ext){ gfWsFocus=d.leaf.id; gfWsPrune(); gfWsPaint(); gfWsRefit(); return; } }
4773    d.leaf.tabs=[]; d.leaf.active=0;                // empty at the source; the prune removes it
4774    _wsInsertLeaf(tgt,tabs,tgt.tabs.length?d.zone:'center',d.at);
4775    gfWsPrune(); gfWsPaint(); gfWsRefit();
4776    return;
4777  }
4778  if(d.from && d.from.tabs.length===1 && d.from.tabs[0]===d.key && d.target && d.target!=='ROOT'){
4779    const tgt0=_wsFindNode(gfWsTree,d.target);
4780    if(tgt0 && tgt0.t==='leaf' && !tgt0.tabs.length && tgt0.hold){ let ext=false; try{ ext=_wsExtendInto(d.from,tgt0); }catch(e){} if(ext){ gfWsFocus=d.from.id; gfWsPrune(); gfWsPaint(); gfWsRefit(); return; } }
4781  }
4782  if(d.from) _wsRemoveTab(d.from,d.key);            // a panel exists once
4783  if(d.target==='ROOT' && d.zone && d.zone!=='center' && gfWsTree){ _wsInsertRoot([d.key],d.zone); }
4784  else if(d.target==='ROOT'||!gfWsTree){ gfWsTree=_wsLeaf(d.key); gfWsFocus=gfWsTree.id; }
4785  else {
4786    const tgt=_wsFindNode(gfWsTree,d.target);
4787    if(tgt && tgt.t==='leaf') _wsInsert(tgt,d.key,d.zone,d.at);
4788    else if(gfWsTree) _wsInsert(_wsFirstLeaf(gfWsTree),d.key,'center');
4789    else { gfWsTree=_wsLeaf(d.key); gfWsFocus=gfWsTree.id; }
4790  }
4791  gfWsPrune(); gfWsPaint(); gfWsRefit();
4792}
4793function _wsInsert(target,key,zone,at){
4794  delete target.hold;
4795  if(zone==='center'){
4796    // at >= 0 means the pointer was over that tab: land there. Otherwise append.
4797    const i=(at!=null && at>=0 && at<=target.tabs.length) ? at : target.tabs.length;
4798    target.tabs.splice(i,0,key); target.active=i; gfWsFocus=target.id; return; }
4799  const fresh=_wsLeaf(key);
4800  const clone={ id:'w'+(_wsUid++), t:'leaf', tabs:target.tabs.slice(), active:target.active };
4801  const before=(zone==='left'||zone==='top');
4802  // rewritten IN PLACE so the parent's sizes array stays aligned with its children
4803  target.t='split'; target.dir=(zone==='left'||zone==='right')?'row':'col';
4804  target.kids = before?[fresh,clone]:[clone,fresh]; target.sizes=[.5,.5];
4805  delete target.tabs; delete target.active;
4806  gfWsFocus=fresh.id;
4807}
4808// _wsInsert, but for a window's worth of tabs rather than one. Kept beside it, and deliberately the
4809// same shape: centre merges into the target, an edge rewrites the target IN PLACE into a split so
4810// the parent's sizes array stays aligned with its children.
4811// A whole-workspace split: the dropped window takes one half of the STAGE and everything that was
4812// there goes in the other. The root is replaced rather than rewritten in place, because there is no
4813// parent holding a sizes array that has to stay aligned with it.
4814function _wsParentOf(n, id, par, idx){
4815  if(!n) return null;
4816  if(n.id===id) return par ? [par, idx] : null;
4817  if(n.t==='split') for(let i=0;i<n.kids.length;i++){ const r=_wsParentOf(n.kids[i], id, n, i); if(r) return r; }
4818  return null;
4819}
4820function _wsExtendInto(W, E){
4821  // THE TREE IS BINARY (user 2026-09-14: "it just shift the flow panel under heatmap"): three columns are
4822  // stored as row[left, row[middle, right]], so the two stacks that touch are not always children of one
4823  // node. Walk up from both to their lowest common ancestor G (a row for column stacks, a column for row
4824  // stacks); the kid of G holding the window's stack must reach it through same-direction splits only
4825  // on the side facing the other kid, and likewise for the space's stack - then the two stacks share
4826  // the full extent across G and the union rectangle exists. Widths are resolved to absolute shares
4827  // of G so nothing on screen moves except the window growing over the space.
4828  const pw=_wsParentOf(gfWsTree,W.id), pe=_wsParentOf(gfWsTree,E.id); if(!pw||!pe) return false;
4829  const PW=pw[0], iW=pw[1], PE=pe[0], iE=pe[1];
4830  if(PW===PE){ delete E.hold; E.tabs=[]; return true; }          // siblings: the space simply goes to the window
4831  if(PW.dir!==PE.dir) return false;
4832  const d=PW.dir, perp=(d==='col')?'row':'col';
4833  const last = iW===PW.kids.length-1 && iE===PE.kids.length-1, first = iW===0 && iE===0;
4834  if(!last && !first) return false;                              // the window and the space sit on the same side of their stacks
4835  // ancestors, root first
4836  const chain=(id)=>{ const out=[]; (function walk(n,path){ if(!n) return false; if(n.id===id){ out.push.apply(out,path); return true; } if(n.t==='split') for(const k of n.kids) if(walk(k,path.concat([n]))) return true; return false; })(gfWsTree,[]); return out; };
4837  const cw=chain(PW.id), ce=chain(PE.id);
4838  let G=null; for(let i=Math.min(cw.length,ce.length)-1;i>=0;i--){ if(cw[i]===ce[i]){ G=cw[i]; break; } }
4839  if(!G || G.dir!==perp) return false;
4840  const under=(n,target)=>{ if(n===target) return true; if(n.t!=='split') return false; return n.kids.some(k=>under(k,target)); };
4841  const ja=G.kids.findIndex(k=>under(k,PW)), jb=G.kids.findIndex(k=>under(k,PE));
4842  if(ja<0||jb<0||Math.abs(ja-jb)!==1) return false;
4843  // the stack on the touching edge of a kid: through same-direction (perp) splits only, on that edge
4844  const edge=(n,wantLast)=>{ if(!n||n.t!=='split') return null; if(n.dir===d) return n; if(n.dir!==perp) return null; return edge(n.kids[wantLast?n.kids.length-1:0], wantLast); };
4845  const aFirst = ja<jb;                                          // the window's kid comes first in G
4846  if(edge(G.kids[ja], aFirst)!==PW || edge(G.kids[jb], !aFirst)!==PE) return false;
4847  // absolute share of G held by a stack reached through perp splits
4848  const share=(kid,j,target)=>{ let f=G.sizes[j], n=kid; while(n!==target){ const idx=n.kids.findIndex(k=>under(k,target)); f*=n.sizes[idx]; n=n.kids[idx]; } return f; };
4849  const fW=share(G.kids[ja],ja,PW), fE=share(G.kids[jb],jb,PE);
4850  // take the stacks out of their kids (a kid that IS the stack disappears; a nested one loses a child)
4851  const detach=(kid,target)=>{ if(kid===target) return null; let n=kid; while(true){ const idx=n.kids.findIndex(k=>under(k,target)); if(n.kids[idx]===target){ n.kids.splice(idx,1); n.sizes.splice(idx,1); return kid; } n=n.kids[idx]; } };
4852  const restA=detach(G.kids[ja],PW), restB=detach(G.kids[jb],PE);
4853  const sh=(PW.sizes[iW]+PE.sizes[iE])/2;                        // the window's share of the new stack
4854  const strip=(P,i)=>({ id:'w'+(_wsUid++), t:'split', dir:P.dir, kids:P.kids.filter((k,q)=>q!==i), sizes:P.sizes.filter((v,q)=>q!==i) });
4855  const rW=strip(PW,iW), rE=strip(PE,iE);
4856  const inner={ id:'w'+(_wsUid++), t:'split', dir:perp, kids: aFirst?[rW,rE]:[rE,rW], sizes: aFirst?[fW/(fW+fE), fE/(fW+fE)]:[fE/(fW+fE), fW/(fW+fE)] };
4857  const N={ id:'w'+(_wsUid++), t:'split', dir:d, kids: last?[inner,W]:[W,inner], sizes: last?[1-sh,sh]:[sh,1-sh] };
4858  const lo=Math.min(ja,jb), kids=[], sizes=[];
4859  const A=G.kids[lo], B=G.kids[lo+1], sA=G.sizes[lo], sB=G.sizes[lo+1];
4860  const restLo = aFirst?restA:restB, restHi = aFirst?restB:restA;
4861  const fLo = aFirst?fW:fE, fHi = aFirst?fE:fW;
4862  if(restLo){ kids.push(restLo); sizes.push(sA-fLo); }
4863  kids.push(N); sizes.push(fW+fE);
4864  if(restHi){ kids.push(restHi); sizes.push(sB-fHi); }
4865  G.kids.splice(lo,2,...kids); G.sizes.splice(lo,2,...sizes);
4866  delete E.hold; E.tabs=[];                                       // the space is gone; the prune folds what is left
4867  return true;
4868}
4869function _wsInsertRoot(tabs,zone){
4870  if(!tabs || !tabs.length) return;
4871  const fresh={ id:'w'+(_wsUid++), t:'leaf', tabs:tabs.slice(), active:0 };
4872  if(!gfWsTree){ gfWsTree=fresh; gfWsFocus=fresh.id; return; }
4873  const before=(zone==='left'||zone==='top'), old=gfWsTree;
4874  gfWsTree={ id:'w'+(_wsUid++), t:'split', dir:(zone==='left'||zone==='right')?'row':'col',
4875             kids: before?[fresh,old]:[old,fresh], sizes:[.5,.5] };
4876  gfWsFocus=fresh.id;
4877}
4878function _wsInsertLeaf(target,tabs,zone,at){
4879  if(!tabs || !tabs.length) return;
4880  delete target.hold;
4881  if(zone==='center'){
4882    const i=(at!=null && at>=0 && at<=target.tabs.length) ? at : target.tabs.length;
4883    target.tabs.splice.apply(target.tabs, [i,0].concat(tabs));
4884    target.active=i; gfWsFocus=target.id; return;
4885  }
4886  const fresh={ id:'w'+(_wsUid++), t:'leaf', tabs:tabs.slice(), active:0 };
4887  const clone={ id:'w'+(_wsUid++), t:'leaf', tabs:target.tabs.slice(), active:target.active };
4888  const before=(zone==='left'||zone==='top');
4889  target.t='split'; target.dir=(zone==='left'||zone==='right')?'row':'col';
4890  target.kids = before?[fresh,clone]:[clone,fresh]; target.sizes=[.5,.5];
4891  delete target.tabs; delete target.active;
4892  gfWsFocus=fresh.id;
4893}
4894function _wsRemoveTab(l,key){ const i=l.tabs.indexOf(key); if(i<0) return;
4895  l.tabs.splice(i,1); if(l.active>=l.tabs.length) l.active=Math.max(0,l.tabs.length-1); }
4896function gfWsCloseTab(l,i){
4897  const key=l.tabs[i];
4898  l.tabs.splice(i,1); if(l.active>=l.tabs.length) l.active=Math.max(0,l.tabs.length-1);
4899  const P=GF_WS_PANELS[key]; if(P) P.ids.forEach(gfWsUnpark);     // straight home, not orphaned
4900  delete _wsBooted[key];
4901  gfWsPrune(); gfWsPaint();
4902}
4903/* an empty leaf disappears and a split with one child becomes that child, so a
4904   few moves cannot leave the tree full of dead nodes with stale size arrays. */
4905function gfWsPrune(){
4906  const walk=n=>{
4907    if(!n) return null;
4908    if(n.t==='leaf') return (n.tabs.length||n.hold)?n:null;   // hold: an empty slot the user opened on purpose
4909    n.kids=n.kids.map(walk).filter(Boolean);
4910    if(!n.kids.length) return null;
4911    // an empty slot left alone in its row / column gives the space back to what is around it
4912    if(n.kids.length===1 && n.kids[0].t==='leaf' && !n.kids[0].tabs.length && n!==gfWsTree) return null;
4913    if(n.kids.length===1) return n.kids[0];
4914    if(n.sizes.length!==n.kids.length) n.sizes=n.kids.map(()=>1/n.kids.length);
4915    const tot=n.sizes.reduce((a,b)=>a+b,0)||1; n.sizes=n.sizes.map(v=>v/tot);
4916    return n;
4917  };
4918  gfWsTree=walk(gfWsTree);
4919  if(gfWsTree && !_wsFindNode(gfWsTree,gfWsFocus)) gfWsFocus=_wsFirstLeaf(gfWsTree).id;
4920}
4921
4922// The five level spans, moved into one row of their own. Their home is recorded exactly the way
4923// every parked node's is, so leaving the workspace puts them back among the header controls.
4924function _wsFind(id, slot){
4925  // a node being placed is detached from the document; the attic holds everything between paints
4926  return (slot && slot.querySelector('#'+id))
4927      || document.getElementById(id)
4928      || (function(){ const a=document.getElementById('wsAttic'); return a?a.querySelector('#'+id):null; })();
4929}
4930// Is the ladder actually on screen? Both gates answered at once, by measurement rather than by
4931// guessing which class is in charge.
4932function _wsGexShown(){
4933  const p=document.getElementById('chartGexPanel');
4934  try{ return !!(p && getComputedStyle(p).display!=='none'); }catch(e){ return false; }
4935}
4936function _wsGexToggle(){
4937  if(_wsGexShown()){
4938    try{ nfGexMinimize(true); }catch(e){}
4939  }else{
4940    try{ document.body.classList.add('gf-gexon'); }catch(e){}       // the new layout's "show it at all"
4941    try{ nfGexMinimize(false); }catch(e){}                          // and the "not collapsed"
4942    // the panel is built by the chart load; if it has not been built yet, build it from the last data
4943    try{ if(!document.getElementById('chartGexPanel') && window._cgpLast && typeof renderChartGexPanel==='function')
4944           renderChartGexPanel(window._cgpLast.content, window._cgpLast.parsed, window._cgpLast.spot,
4945                               window._cgpLast.ticker, window._cgpLast.volNode); }catch(e){}
4946  }
4947  try{ if(typeof fitChartViewHeight==='function') fitChartViewHeight(); }catch(e){}
4948  _wsGexPaintTab();
4949}
4950function _wsGexPaintTab(){
4951  const b=document.getElementById('wsGexTab'); if(!b) return;
4952  const on=_wsGexShown();
4953  b.textContent = on ? '‹' : '›';
4954  b.title = on ? 'Hide the GEX ladder' : 'Show the GEX ladder beside the chart';
4955  b.classList.toggle('gf-on', on);
4956  // when the ladder is open the handle rides its right edge, so it never sits on top of the strikes
4957  let w=0; try{ const p=document.getElementById('chartGexPanel'); if(on&&p) w=p.offsetWidth; }catch(e){}
4958  b.style.left = on ? (w+'px') : '2px';
4959}
4960// The ladder's handle, rebuilt into the tile on every paint like the rest of the furniture.
4961function _wsGexTab(slot){
4962  const row=(slot&&slot.querySelector('#chartRow'))||null; if(!row) return;
4963  row.style.position='relative';
4964  let b=_wsFind('wsGexTab', slot);
4965  if(!b){
4966    b=document.createElement('button'); b.id='wsGexTab';
4967    b.addEventListener('mousedown', e=>e.stopPropagation());        // the tile focus handler must not eat it
4968    b.addEventListener('click', e=>{ e.stopPropagation(); _wsGexToggle(); });
4969  }
4970  row.appendChild(b);
4971  _wsGexPaintTab();
4972  setTimeout(_wsGexPaintTab, 400);   // the ladder's width settles after its first render
4973}
4974function _wsChartNodes(slot, into){
4975  // into = the tile's tab-bar fill, passed in because during a paint the tile is detached and
4976  // slot.closest('.gf-ws-leaf') cannot reach it.
4977  const hdr=into||_wsFind('chartHeader', slot); if(!hdr) return;
4978  let box=_wsFind('wsNodes', slot);
4979  if(!box){ box=document.createElement('div'); box.id='wsNodes'; }
4980  ['chartKing','chartCW','chartPW','chartVN','chartAN'].forEach(function(id){
4981    const el=_wsFind(id, slot); if(!el) return;
4982    if(!_wsHome[id]) _wsHome[id]={ parent:el.parentNode, next:el.nextSibling, style:el.getAttribute('style') };
4983    box.appendChild(el);
4984  });
4985  hdr.appendChild(box);
4986}
4987
4988/* Any panel that declares a side column gets the same collapse control. Collapsed is the DEFAULT -
4989   a 300px feed beside a 400px tile is not a layout - and the choice is remembered per panel once
4990   touched, so a user who wants Flow's filters open keeps them open. */
4991// A side panel usually sits inside a wrapper column, and it is the WRAPPER that reserves the width.
4992// Hiding only the panel leaves an empty band the same size as the panel was.
4993function _wsSideWrap(el, slot){
4994  const par = el && el.parentElement;
4995  if(!par || par===slot || par.classList.contains('gf-ws-slot')) return null;
4996  return par;
4997}
4998const _wsSideApply={};
4999// Re-run a panel's collapse. Called after a sub-tab switch, which renders a side panel that has
5000// never been through apply() and would otherwise appear expanded.
5001function gfWsReapplySide(key){
5002  if(!_wsOn) return;
5003  const f=_wsSideApply[key];
5004  if(f) try{ f(); }catch(e){}
5005}
5006// A sub-tab's content arrives from a fetch, so the column to collapse does not exist at the moment
5007// of the click. Now, and twice more as the render lands. apply() is idempotent - it hides what is
5008// showing and remembers it - so running it again costs nothing and catches whatever turned up.
5009function gfWsReapplySideSoon(key){
5010  if(!_wsOn) return;
5011  gfWsReapplySide(key);
5012  setTimeout(function(){ gfWsReapplySide(key); }, 400);
5013  setTimeout(function(){ gfWsReapplySide(key); }, 1400);
5014}
5015// Vertical wheel over a sub-tab row scrolls it sideways. Delegated once, at the document, because
5016// these rows are rebuilt on every paint and on every sub-tab switch.
5017(function(){
5018  // ON the rows, not on the document: a non-passive wheel listener at document level stalls every
5019  // scroll on the page while the main thread answers it. These two rows are static markup.
5020  const wire = function(){
5021    ['ideasTabRow','scanTabBar'].forEach(function(id){
5022      const row = document.getElementById(id); if(!row || row.dataset.wheel) return;
5023      row.dataset.wheel = '1';
5024      row.addEventListener('wheel', function(e){
5025        if(row.scrollWidth <= row.clientWidth + 1) return;        // nothing hidden: leave the page alone
5026        if(Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;      // a real horizontal gesture needs no help
5027        row.scrollLeft += e.deltaY;
5028        e.preventDefault();
5029      }, {passive:false});
5030    });
5031  };
5032  if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); else wire();
5033})();
5034// Flow's preset chips, as a dropdown, with a ticker box. Built from the chips themselves so it
5035// cannot drift from them, and rebuilt on every paint like the rest of the workspace furniture.
5036function _wsFlowBar(slot){
5037  const host = (slot && (slot.querySelector('.flow-controls') || slot.querySelector('.flow-bar')));
5038  if(!host) return;
5039  let box = _wsFind('wsFlowCtl', slot);
5040  if(!box){
5041    box = document.createElement('span'); box.id = 'wsFlowCtl'; box.className = 'gf-ws-flowctl';
5042    const sel = document.createElement('select'); sel.id = 'wsFlowPreset'; sel.title = 'Flow preset';
5043    const inp = document.createElement('input'); inp.id = 'wsFlowTk'; inp.type = 'text';
5044    inp.placeholder = 'Ticker'; inp.title = 'Filter the tape to one ticker - Enter to apply, empty to clear';
5045    inp.autocomplete = 'off';
5046    sel.onmousedown = function(e){ e.stopPropagation(); };      // the tile focus handler must not eat it
5047    inp.onmousedown  = function(e){ e.stopPropagation(); };
5048    sel.onchange = function(e){ e.stopPropagation();
5049      try{ if(typeof setFlowPreset==='function') setFlowPreset(sel.value); }catch(e2){} };
5050    inp.onkeydown = function(e){
5051      e.stopPropagation();
5052      if(e.key !== 'Enter') return;
5053      const t = String(inp.value || '').trim().toUpperCase();
5054      try{ if(typeof flowFilterTicker==='function') flowFilterTicker(t); }catch(e2){}   // '' clears it
5055    };
5056    box.appendChild(sel); box.appendChild(inp);
5057  }
5058  // options come from the real chips, every paint, so a new preset shows up on its own
5059  const chips = [...((slot.querySelectorAll && slot.querySelectorAll('.flow-presets .flow-chip')) || [])];
5060  const sel = box.querySelector('select');
5061  if(sel && chips.length){
5062    const sig = chips.map(function(c){ return c.dataset.fp + ':' + c.textContent.trim(); }).join('|');
5063    if(sel.dataset.sig !== sig){
5064      sel.dataset.sig = sig;
5065      sel.innerHTML = chips.map(function(c){
5066        return '<option value="' + c.dataset.fp + '">' + c.textContent.trim() + '</option>'; }).join('');
5067    }
5068    try{ if(typeof flowPreset !== 'undefined' && flowPreset) sel.value = flowPreset; }catch(e){}
5069  }
5070  const inp = box.querySelector('input');
5071  try{ if(inp && typeof flowTickerFilter !== 'undefined') inp.value = flowTickerFilter || ''; }catch(e){}
5072  host.insertBefore(box, host.firstChild);
5073}
5074
5075// ══════════ SIDE COLUMNS: one mechanism, not a list of special cases ══════════
5076// Rewritten after three partial fixes. The failures all came from the same two mistakes:
5077//
5078//   1. The finder SKIPPED anything already carrying gf-ws-sidehide, so once a column was collapsed
5079//      it could never be found again - which is why SPX/SPY closed and would not reopen.
5080//   2. Hiding the panel is not the same as hiding the thing that RESERVES THE WIDTH. Earnings and
5081//      Watchlist keep their panel inside a wrapper column, so the panel vanished and the gap stayed.
5082//
5083// So: find the width owner, remember exactly what was hidden, and reverse exactly that.
5084const _wsSideHidden = {};   // panel key -> the elements this panel actually hid
5085
5086// Climb from a side panel to the element that actually reserves the width - the wrapper column, not
5087// the panel inside it. Stops as soon as the parent stops being narrow relative to ITS parent, so it
5088// can never walk out to the whole view.
5089function _wsWidthOwner(el){
5090  let best = el, p = el && el.parentElement;
5091  for (let i = 0; i < 3 && p; i++) {
5092    const gp = p.parentElement; if (!gp) break;
5093    const pr = p.getBoundingClientRect(), gr = gp.getBoundingClientRect();
5094    if (!(pr.width > 0 && gr.width > 0 && pr.width < gr.width * 0.75)) break;
5095    best = p; p = p.parentElement;
5096  }
5097  return best;
5098}
5099// A wrap laid out as [body | column]: the last child, when it is clearly the narrower one on the
5100// right. Anything squarer than that is content, not a sidebar.
5101function _wsLastColumn(wrap){
5102  if (!wrap || wrap.children.length < 2) return null;
5103  const kids = [...wrap.children].filter(function (c) {
5104    return c.tagName !== 'SCRIPT' && c.tagName !== 'STYLE' && !c.classList.contains('gf-ws-sidegrip')
5105        && !c.classList.contains('gf-ws-sidehide') && c.getBoundingClientRect().width > 0;
5106  });
5107  if (kids.length < 2) return null;
5108  const first = kids[0].getBoundingClientRect(), last = kids[kids.length - 1].getBoundingClientRect();
5109  if (!(last.width > 80 && last.height > 120)) return null;
5110  if (!(last.width < first.width * 0.75)) return null;   // a real sidebar, not a second content pane
5111  if (!(last.left > first.left)) return null;            // and it is on the right
5112  return kids[kids.length - 1];
5113}
5114// Everything this panel is showing as a side column RIGHT NOW, already resolved to its width owner.
5115function _wsSideTargets(P, slot){
5116  const raw = [];
5117  (P.side || []).forEach(function (id) {
5118    const el = _wsFind(id, slot);
5119    if (el && !el.classList.contains('gf-ws-sidehide')) raw.push(el);
5120  });
5121  if (typeof P.sideFind === 'function') {
5122    try { (P.sideFind(slot) || []).forEach(function (el) { if (el && raw.indexOf(el) < 0) raw.push(el); }); } catch (e) {}
5123  }
5124  const out = [];
5125  raw.forEach(function (el) { const w = _wsWidthOwner(el); if (w && out.indexOf(w) < 0) out.push(w); });
5126  return out;
5127}
5128function _wsSideMin(slot, key){
5129  const P = GF_WS_PANELS[key];
5130  if (!P || ((!P.side || !P.side.length) && typeof P.sideFind !== 'function')) return;
5131  const sk = 'gf_ws_side_' + key, wk = 'gf_ws_sidew_' + key;
5132  let hid = true; try { const v = localStorage.getItem(sk); if (v != null) hid = (v === '1'); } catch (e) {}
5133  let b = _wsFind('wsSide_' + key, slot);
5134  if (!b) { b = document.createElement('button'); b.type = 'button'; b.id = 'wsSide_' + key; b.className = 'gf-ws-sidebtn'; }
5135
5136  const apply = function () {
5137    if (hid) {
5138      // UNION, not replace: switching sub-tab surfaces a column this panel has not hidden yet, and
5139      // the ones hidden on other sub-tabs must stay hidden.
5140      const list = _wsSideHidden[key] || (_wsSideHidden[key] = []);
5141      _wsSideTargets(P, slot).forEach(function (el) { if (list.indexOf(el) < 0) list.push(el); });
5142      list.forEach(function (el) { el.classList.add('gf-ws-sidehide'); });
5143    } else {
5144      // reverse exactly what was hidden, then forget it - the finder cannot see a hidden element,
5145      // so this list is the only way back
5146      (_wsSideHidden[key] || []).forEach(function (el) {
5147        el.classList.remove('gf-ws-sidehide');
5148        el.classList.add('gf-ws-sidecol');
5149        let px = 0; try { px = parseInt(localStorage.getItem(wk) || '0', 10) || 0; } catch (e) {}
5150        if (px > 0) el.style.width = px + 'px';
5151        _wsSideGrip(el, wk);
5152      });
5153      _wsSideHidden[key] = [];
5154    }
5155    b.textContent = hid ? 'Panels ◂' : 'Panels ▸';
5156    b.title = hid ? 'Show this panel\'s side column' : 'Hide this panel\'s side column';
5157  };
5158
5159  b.onclick = function (e) {
5160    e.stopPropagation(); hid = !hid;
5161    try { localStorage.setItem(sk, hid ? '1' : '0'); } catch (e2) {}
5162    apply();
5163  };
5164  _wsSideApply[key] = apply;          // so a sub-tab switch can re-run it
5165  apply();
5166  slot.style.position = 'relative';
5167  slot.appendChild(b);
5168}
5169// Drag the column's inner edge to resize it. One grip per column, remembered per panel.
5170function _wsSideGrip(col, storeKey){
5171  if(col.querySelector(':scope > .gf-ws-sidegrip')) return;
5172  const g=document.createElement('div'); g.className='gf-ws-sidegrip';
5173  g.addEventListener('mousedown', function(ev){
5174    ev.preventDefault(); ev.stopPropagation();
5175    const x0=ev.clientX, w0=col.getBoundingClientRect().width;
5176    const mv=function(e){
5177      // the column sits on the RIGHT, so dragging left makes it wider
5178      const w=Math.max(120, Math.min(640, w0 - (e.clientX - x0)));
5179      col.style.width=w+'px';
5180    };
5181    const up=function(){
5182      document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
5183      try{ localStorage.setItem(storeKey, String(Math.round(col.getBoundingClientRect().width))); }catch(e){}
5184      try{ gfWsRefit(); }catch(e){}
5185    };
5186    document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
5187  });
5188  col.style.position=col.style.position||'relative';
5189  col.insertBefore(g, col.firstChild);
5190}
5191
5192/* The grid tile's own little header: which sector, and a switch to fold the ticker line away -
5193   the two things the sector bar used to provide, in one 26px row instead of 108px. */
5194let _wsGridOne=false; try{ _wsGridOne=localStorage.getItem('gf_ws_grid_one')==='1'; }catch(e){}
5195let _wsGridFull=null;
5196function gfWsGridOne(on, tk){
5197  const want=!!on;
5198  try{ localStorage.setItem('gf_ws_grid_one', want?'1':'0'); }catch(e){}
5199  _wsGridOne=want;
5200  try{
5201    if(want){
5202      if(!_wsGridFull) _wsGridFull=activeColumns.slice();
5203      const t=String(tk||activeColumns[0]||'').toUpperCase();
5204      if(!t) return;
5205      activeColumns=[t];
5206    } else if(_wsGridFull){
5207      activeColumns=_wsGridFull.slice(); _wsGridFull=null;
5208    } else return;
5209    buildGexTickers(); renderColumns();
5210    activeColumns.forEach((t,i)=>setTimeout(function(){ try{ loadGEXSlot(i); }catch(e){} }, i*120));
5211  }catch(e){}
5212  _wsGridPaintOne();
5213  setTimeout(_wsCenterSpot, 1200);
5214}
5215function _wsGridPaintOne(el){
5216  // the mode, on the columns themselves, so CSS can tell one ticker from a sector
5217  try{ const c=document.getElementById('columns'); if(c) c.classList.toggle('gf-ws-one', !!_wsGridOne); }catch(e){}
5218  const b=el||document.getElementById('wsGridOneBtn'); if(!b) return;
5219  b.classList.toggle('on', !!_wsGridOne);
5220  b.textContent=_wsGridOne?'1 ticker':'Sector';
5221  b.title=_wsGridOne?'Showing one ticker - click for the whole sector':'Showing the whole sector - click for one ticker';
5222}
5223// Any ticker change re-trims: switchGroupFocused rebuilds the full sector line-up every time, so
5224// without this a pick would silently undo the toggle.
5225function _wsGridReduce(tk){
5226  try{
5227    const t=String(tk||activeColumns[0]||'').toUpperCase(); if(!t) return;
5228    if(_wsGridOne){
5229      if(activeColumns.length===1 && activeColumns[0]===t) return;
5230      if(!_wsGridFull || _wsGridFull.indexOf(t)<0) _wsGridFull=activeColumns.slice();
5231      activeColumns=[t];
5232    } else {
5233      // the repeated name is the study; those columns move and the rest of the board does not
5234      const _st=_gridStudyTicker(activeColumns);
5235      if(!_st || _st===t) return;                                    // all different names, or already there
5236      activeColumns=_gridRetarget(activeColumns, _st, t);
5237      // remember the new line-up for this sector, exactly as any other line-up change does
5238      try{ if(typeof isPaidUser!=='undefined' && isPaidUser){ _colSets[activeGroup]=activeColumns.slice(); _saveColSets(); } }catch(e){}
5239    }
5240    buildGexTickers(); renderColumns();
5241    activeColumns.forEach(function(x,i){ setTimeout(function(){ try{ loadGEXSlot(i); }catch(e){} }, 60+i*120); });
5242    setTimeout(_wsCenterSpot, 1200);
5243  }catch(e){}
5244}
5245function _wsGridBar(slot){
5246  let bar=document.getElementById('wsGridBar');
5247  if(!bar){
5248    bar=document.createElement('div'); bar.id='wsGridBar';
5249    // just the sector. The Tickers fold belonged to the grid's own strip, which the shared line on
5250    // top replaced - the fold for that lives up there with it.
5251    bar.innerHTML='<select id="wsGridSector" title="Sector"></select>'
5252      +'<button type="button" id="wsGridOneBtn"></button>';
5253  }
5254  const sel=bar.querySelector('#wsGridSector');
5255  const groups=(typeof WATCHLIST!=='undefined')?Object.keys(WATCHLIST):[];
5256  const cur=(typeof activeGroup!=='undefined')?activeGroup:'';
5257  if(sel.dataset.sig!==groups.join(',')+'|'+cur){
5258    sel.dataset.sig=groups.join(',')+'|'+cur;
5259    sel.innerHTML=groups.map(g=>'<option'+(g===cur?' selected':'')+'>'+g+'</option>').join('');
5260  }
5261  sel.onchange=function(){ try{ switchGroup(sel.value); }catch(e){} };
5262  // the app's own RESET button, borrowed from the settings row and handed back on the way out.
5263  // The real element, so the hold-to-confirm and everything it does come with it.
5264  try{
5265    const rb=document.getElementById('gridResetBtn');
5266    if(rb){
5267      if(!_wsHome['gridResetBtn']) _wsHome['gridResetBtn']={ parent:rb.parentNode, next:rb.nextSibling, style:rb.getAttribute('style') };
5268      rb.style.marginLeft='2px';
5269      bar.appendChild(rb);
5270    }
5271  }catch(e){}
5272  // the add-column +, after RESET, so the columns get the width the in-grid + was holding
5273  try{
5274    let ab=bar.querySelector('#wsGridAddBtn');
5275    if(!ab){
5276      ab=document.createElement('button'); ab.type='button'; ab.id='wsGridAddBtn';
5277      ab.textContent='+'; ab.title='Add a column';
5278      ab.onclick=function(e){ e.stopPropagation(); try{ gfGridAddColumn(); }catch(e2){} };
5279    }
5280    bar.appendChild(ab);
5281  }catch(e){}
5282  const one=bar.querySelector('#wsGridOneBtn');
5283  if(one) one.onclick=function(e){ e.stopPropagation(); gfWsGridOne(!_wsGridOne); };
5284  _wsGridPaintOne(one);   // pass it: during a paint the tile is not in the document yet
5285  const fold=bar.querySelector('#wsGridFold');   // gone; kept null-safe for an older saved layout
5286  let hid=false; try{ hid=localStorage.getItem('gf_ws_grid_tk')==='1'; }catch(e){}
5287  /* nothing to fold here any more */
5288  if(fold) fold.remove();
5289  slot.insertBefore(bar, slot.firstChild);
5290}
5291
5292/* ── the bar: add a panel, presets ───────────────────────────────────────── */
5293function gfWsPalette(){
5294  const bar=document.getElementById('wsAdd'); if(!bar) return;
5295  const used=new Set(_wsAllTabs(gfWsTree));
5296  bar.innerHTML=Object.keys(GF_WS_PANELS).filter(k=>GF_WS_PANELS[k].kind!=='dup').map(k=>{   // copies come from a right-click, not from here
5297    const P=GF_WS_PANELS[k], on=used.has(k);
5298    return '<button class="gf-ws-chip'+(on?' used':'')+'" data-k="'+k+'" title="'+(on?'Already placed — drag its tab to move it':'Drag onto a tile, or click to add')+'">'
5299      +'<span class="gf-ws-dot" style="background:'+P.col+'"></span>'+P.name+'</button>';
5300  }).join('');
5301  try{ gfWsLayoutMenu(); }catch(e){}
5302  [...bar.querySelectorAll('.gf-ws-chip')].forEach(b=>{
5303    const k=b.dataset.k; if(used.has(k)) return;
5304    b.onmousedown=e=>_wsDragStart(e,k,null);
5305    b.onclick=()=>{ if(_wsAteDrag()) return;
5306      if(!gfWsTree){ gfWsTree=_wsLeaf(k); gfWsFocus=gfWsTree.id; }
5307      else { const f=_wsFindNode(gfWsTree,gfWsFocus)||_wsFirstLeaf(gfWsTree); _wsInsert(f,k,'center'); }
5308      gfWsPrune(); gfWsPaint(); gfWsRefit(); };
5309  });
5310}
5311// The saved layouts, listed. Built alongside the ADD palette because that already runs on every
5312// paint - one repaint hook rather than a second one to keep in step.
5313function gfWsLayoutMenu(){
5314  const host=document.getElementById('wsLayoutList'); if(!host) return;
5315  const all=gfWsLocal(), cur=gfWsActive();
5316  const names=Object.keys(all).sort();
5317  if(names.indexOf(cur)<0) names.unshift(cur);          // the one you are on is always listed
5318  host.innerHTML=names.map(function(n){
5319    const on=(n===cur);
5320    return '<span class="gf-ws-layrow'+(on?' on':'')+'">'
5321      +'<button class="gf-ws-chip" data-lay="'+n.replace(/"/g,'&quot;')+'" title="Open this layout in this window">'+n+'</button>'
5322      +'<button class="gf-ws-layx" data-laydel="'+n.replace(/"/g,'&quot;')+'" title="Delete this layout">&times;</button>'
5323      +'</span>';
5324  }).join('')
5325    +'<button class="gf-ws-chip" id="wsLayoutSaveAs" style="margin-top:5px" title="Save the arrangement on screen under a new name">+ Save as\u2026</button>';
5326  host.onclick=function(e){
5327    const t=e.target;
5328    if(t.id==='wsLayoutSaveAs'){ _wsDdCloseAll(); gfWsSaveAs(); return; }
5329    if(t.dataset && t.dataset.laydel){ gfWsDeleteLayout(t.dataset.laydel); return; }
5330    if(t.dataset && t.dataset.lay){ _wsDdCloseAll(); gfWsOpenLayout(t.dataset.lay); return; }
5331  };
5332}
5333function gfWsPreset(k){
5334  gfWsUnparkAll(); _wsBooted={}; _wsUid=1;
5335  gfWsTree=GF_WS_PRESETS[k]?GF_WS_PRESETS[k]():null;
5336  gfWsFocus=gfWsTree?_wsFirstLeaf(gfWsTree).id:null;
5337  _wsLayoutClear(_wsAllTabs(gfWsTree));
5338  gfWsPaint(); gfWsRefit();
5339}
5340/* Every panel already knows how to resize itself; the workspace just has to say when. Debounced
5341   because a divider drag fires this on every mousemove and renderColumns is not free. */
5342function _wsCenterSpot(){
5343  try{
5344    document.querySelectorAll('.gf-ws-slot:not(.off) [data-spot="1"]').forEach(function(row){
5345      // walk up to the nearest ancestor that actually scrolls
5346      let box=row.parentElement;
5347      while(box && box.scrollHeight<=box.clientHeight+1 && !box.classList.contains('gf-ws-slot')) box=box.parentElement;
5348      if(!box || box.scrollHeight<=box.clientHeight+1) return;
5349      // measured, not offsetTop: offsetTop is relative to the offsetParent, which on these ladders
5350      // is not the scroll box, and the difference is exactly the header height
5351      const d=row.getBoundingClientRect().top-box.getBoundingClientRect().top;
5352      box.scrollTop += d - (box.clientHeight/2 - row.offsetHeight/2);
5353    });
5354  }catch(e){}
5355}
5356let _wsFitT=null;
5357function gfWsRefit(){
5358  clearTimeout(_wsFitT);
5359  _wsFitT=setTimeout(()=>{ _wsAllTabs(gfWsTree).forEach(k=>{ const P=GF_WS_PANELS[k];
5360    if(P && P.fit) try{ P.fit(); }catch(e){} });
5361    _wsCenterSpot(); }, 140);
5362}
5363// REPAINT ON A STAGE-WIDTH CHANGE (user 2026-09-17: "when left rail hide the right side adds space
5364// instead of filling — My HQ"). Workspace panels are absolutely positioned in pixels computed from the
5365// stage width at paint time; gfWsRefit only re-fits panel INTERNALS, it never re-places them. Hiding the
5366// rail widens the body (padding-left 74->0) and dispatches a window resize, but nothing re-ran the
5367// placement — so the panels kept their old widths and the freed 74px opened as a gap on the right. On a
5368// debounced window resize, re-place the panels (gfWsPaint reads the fresh stage width) so they fill it.
5369let _wsWinRzT=null;
5370window.addEventListener('resize', function(){ if(!_wsOn) return; clearTimeout(_wsWinRzT); _wsWinRzT=setTimeout(function(){ try{ gfWsPaint(); gfWsRefit(); }catch(e){} }, 120); });
5371// The rail slides via a 0.35s padding-left transition, so the resize above fires BEFORE the body
5372// reaches full width — the repaint lands at the mid-animation width and the gap stays until you drag a
5373// divider (user 2026-09-17). Repaint again when the slide actually finishes, at the final width.
5374document.body.addEventListener('transitionend', function(e){ if(e && e.propertyName==='padding-left' && _wsOn){ clearTimeout(_wsWinRzT); try{ gfWsPaint(); gfWsRefit(); }catch(e2){} } });
5375
5376/* ── what is showing, and what a global control applies to ───────────────── */
5377const GF_WS_V2K = { gex:'grid', expiry:'heatmap', chart:'ghost', flow:'flow', chain:'chain', smart:'smart', ideas:'ideas', tracking:'track' };
5378const GF_WS_K2V = Object.keys(GF_WS_V2K).reduce((m,v)=>{ m[GF_WS_V2K[v]]=v; return m; },{});
5379function _wsLeafOf(key){
5380  let hit=null;
5381  (function walk(n){ if(!n||hit) return;
5382    if(n.t==='leaf'){ if(n.tabs.indexOf(key)>=0) hit=n; return; }
5383    n.kids.forEach(walk); })(gfWsTree);
5384  return hit;
5385}
5386// SHOWING, not merely placed. A panel sitting on a background tab is mounted but invisible, and
5387// polling it spends budget on a tape nobody is reading - which is the whole reason six live panels
5388// on one screen would otherwise cost six times what one view costs.
5389function gfWsShowing(key){ const l=_wsLeafOf(key); return !!l && l.tabs[l.active]===key; }
5390function gfLive(view){
5391  if(typeof currentView!=='undefined' && currentView===view) return true;
5392  if(!_wsOn) return false;
5393  const k=GF_WS_V2K[view]; return !!k && gfWsShowing(k);
5394}
5395// The view a global control should act on: normally the one you are in, in the workspace the
5396// focused tile's active panel. Ghost Desk's notes say the ticker-pick bug "bit twice" because three
5397// entry points each did their own dispatch; this is the one place that decides, for all of them.
5398function gfActiveView(){
5399  if(typeof currentView==='undefined') return null;
5400  if(currentView!=='workspace' || !_wsOn) return currentView;
5401  const f=_wsFindNode(gfWsTree,gfWsFocus);
5402  if(f && f.t==='leaf' && f.tabs.length) return GF_WS_K2V[f.tabs[f.active]] || currentView;
5403  return currentView;
5404}
5405
5406/* ── THE SHARED TICKER LINE ────────────────────────────────────────────────
5407   Same chip markup and the same ticker universe every other strip uses, so the
5408   delegated sync hook and the app's own CSS both already know what it is.
5409
5410   Sync ON  - a pick goes to every panel.
5411   Sync OFF - it goes to the FOCUSED tile only, which is what makes the gold
5412              border worth having: pick a window, then pick a name for it. */
5413let _wsVsKey=null;
5414function _wsApplyFocusSettings(){
5415  if(typeof _vsPerView!=='function' || !_vsPerView()) return;   // the app's own rule, unchanged
5416  const v=gfActiveView(); const k=(typeof _vsKey==='function')?_vsKey(v):null;
5417  if(!k || k===_wsVsKey) return;
5418  if(_wsVsKey){ try{ _vsBank(_wsVsKey); }catch(e){} }
5419  _wsVsKey=k;
5420  let changed=false; try{ changed=_vsApply(k); }catch(e){}
5421  try{ if(typeof _syncGridExpiryButtons==='function') _syncGridExpiryButtons();
5422       if(typeof _syncGridStyleButtons==='function') _syncGridStyleButtons();
5423       document.querySelectorAll('.metric-btn').forEach(b=>b.classList.toggle('active', b.dataset.metric===currentMetric)); }catch(e){}
5424  if(!changed) return;
5425  try{
5426    if(v==='gex'){ if(typeof _gv2Rerender==='function') _gv2Rerender(); else renderColumns(); }
5427    else if(v==='expiry' && typeof expiryTicker!=='undefined' && expiryTicker) loadExpiryGEX(expiryTicker,true);
5428  }catch(e){}
5429  setTimeout(_wsCenterSpot,600);
5430}
5431function gfWsTickerUniverse(){
5432  try{ return [...new Set(Object.values(WATCHLIST).flat())].filter(t=>t!=='ES'); }catch(e){ return []; }
5433}
5434// What the focused panel is showing, so the strip can mark it.
5435function _wsCurrentTicker(){
5436  try{
5437    const v=gfActiveView();
5438    if(v==='chart')  return (typeof chartTicker!=='undefined')  ? chartTicker  : null;
5439    if(v==='expiry') return (typeof expiryTicker!=='undefined') ? expiryTicker : null;
5440    if(v==='chain')  return (typeof _chTk!=='undefined')        ? _chTk        : null;
5441    if(v==='gex')    return (typeof activeColumns!=='undefined' && activeColumns[0]) || null;
5442  }catch(e){}
5443  return null;
5444}
5445function gfWsBuildTickers(){
5446  const el=document.getElementById('wsTkStrip'); if(!el) return;
5447  const cur=String(_wsCurrentTicker()||'').toUpperCase();
5448  const list=gfWsTickerUniverse();
5449  el.innerHTML=list.map(t=>'<button class="exp-ticker-btn'+(t===cur?' active':'')+'" data-ticker="'+t+'">'+t+'</button>').join('');
5450  el.onclick=function(e){
5451    const b=e.target && e.target.closest && e.target.closest('.exp-ticker-btn');
5452    if(!b) return;
5453    gfWsPickTicker(String(b.dataset.ticker||'').toUpperCase());
5454  };
5455  try{ gfStripScrollActive(el); }catch(e){}
5456  if(!el.dataset.wheel){
5457    el.dataset.wheel='1';
5458    el.addEventListener('wheel', function(e){
5459      if(Math.abs(e.deltaY)<=Math.abs(e.deltaX)) return;   // a real horizontal gesture needs no help
5460      if(el.scrollWidth<=el.clientWidth+1) return;          // wrapped in the ticker menu: let it scroll down
5461      el.scrollLeft += e.deltaY; e.preventDefault();
5462    }, {passive:false});
5463    let down=false, x0=0, l0=0, moved=0;
5464    el.addEventListener('mousedown', function(e){ down=true; x0=e.clientX; l0=el.scrollLeft; moved=0; });
5465    window.addEventListener('mousemove', function(e){ if(!down) return;
5466      const d=e.clientX-x0; if(Math.abs(d)>3) moved=1; el.scrollLeft=l0-d; });
5467    window.addEventListener('mouseup', function(){ down=false; });
5468    // a drag must not also count as picking whatever chip it started on
5469    el.addEventListener('click', function(e){ if(moved){ e.stopPropagation(); e.preventDefault(); moved=0; } }, true);
5470  }
5471}
5472// A ticker clicked in a feed row - Node Alerts, Movers, the flow tape. In My HQ there is no one
5473// chart to jump to and nothing to leave for: every window follows, the same broadcast the ticker
5474// line makes, whether or not TV sync is on. Tabs at the back catch up the moment they come forward
5475// (_wsStaleCatch). Outside HQ each row does what it always did: stay - Movers sits inside Ghost
5476// Mode, so the chart it is beside changes; flow - filter the tape; otherwise open Ghost Mode.
5477function gfGoTicker(tk, opts){
5478  tk=String(tk||'').toUpperCase().replace(/[^A-Z0-9.\-=^]/g,''); if(!tk) return;
5479  opts=opts||{};
5480  if(typeof _wsOn!=='undefined' && _wsOn){
5481    try{ _wsRecentAdd(tk); }catch(e){}
5482    try{ gfSyncTo(tk,true); }catch(e){}
5483    try{ _wsGridReduce(tk); }catch(e){}
5484    try{ gfWsBuildTickers(); }catch(e){}
5485    try{ _wsRememberTickers(); }catch(e){}
5486    try{ setTimeout(_wsDdPaint,0); }catch(e){}
5487    return;
5488  }
5489  if(opts.flow){ if(typeof flowFilterTicker==='function') flowFilterTicker(tk); return; }
5490  if(!opts.stay) setView('chart');
5491  setSyncedTicker(tk);
5492}
5493// Panels whose ticker moved while they sat on a background tab of a tile. gfSyncTo sets the state
5494// for every panel but only does the load for the ones on screen; these are the ones it owes.
5495const _wsStale = {};
5496// in the workspace, and somewhere in its tree (on a background tab, or a tile that is not the one
5497// in front) - a panel in no tile is not owed a load; boot() gives it a fresh one when it is added
5498function _wsHas(k){ try{ return !!(_wsOn && _wsLeafOf(k)); }catch(e){ return false; } }
5499function _wsStaleCatch(k){
5500  if(!_wsStale[k]) return;
5501  delete _wsStale[k];
5502  if(k==='ghost'){ if(typeof chartTicker!=='undefined' && chartTicker) loadGEXChart(chartTicker); }
5503  else if(k==='heatmap'){ if(typeof expiryTicker!=='undefined' && expiryTicker) loadExpiryGEX(expiryTicker); }
5504  else if(k==='chain'){ _chAnchor=''; if(typeof loadChain==='function') loadChain(); }   // re-centre on the money each time the Chain tab is opened (user 2026-09-17)
5505  else if(k==='gpanel'){ if(typeof loadUltraFlow==='function' && typeof chartTicker!=='undefined' && chartTicker) loadUltraFlow(chartTicker,true); }
5506}
5507function gfWsPickTicker(tk){
5508  if(!tk) return;
5509  try{ _wsRecentAdd(tk); }catch(e){}
5510  try{ setTimeout(_wsDdPaint, 0); }catch(e){}
5511  // Only a focused 2nd / 3rd chart PANE takes the pick alone (chart 1 = slot 0 does NOT count) -
5512  // those panes have no ticker strip of their own, so the top bar is how you retarget them.
5513  try{ var _fp=(typeof _mFocusedPaneId==='function')?_mFocusedPaneId():null; if(_fp!=null && _fp>0 && typeof _mRouteTicker==='function' && _mRouteTicker(tk)){ gfWsBuildTickers(); try{ _wsRememberTickers(); }catch(e2){} return; } }catch(e){}
5514  // TOP BAR = GLOBAL search (user 2026-09-15): always drive chart 1 AND every window that follows it
5515  // - heatmap, chain, grid, Ghost Panel, flow, smart money - whether or not the workspace Sync toggle
5516  // is on. It used to only change the focused chart when Sync was off, so nothing else followed.
5517  { try{ gfSyncTo(tk,true); }catch(e){} try{ _wsGridReduce(tk); }catch(e){}
5518    gfWsBuildTickers(); try{ _wsRememberTickers(); }catch(e){} return; }
5519  // Focused tile only. Each branch is the same work that panel's own strip would have done.
5520  const v=gfActiveView();
5521  try{
5522    if(v==='chart'){ if(!(typeof _mRouteTicker==='function' && _mRouteTicker(tk))){ chartTicker=tk; buildChartTickers(); loadGEXChart(tk); } }   // a focused 2nd / 3rd pane takes it
5523    else if(v==='expiry'){ expiryTicker=tk; buildExpiryTickers(); loadExpiryGEX(tk); }
5524    else if(v==='chain'){ _chTk=tk; _chExp=null; buildChainTickers(); loadChain(); }
5525    else if(v==='gex'){ const sec=(typeof sectorOfTicker==='function')?sectorOfTicker(tk):null;
5526                        if(sec && typeof switchGroupFocused==='function') switchGroupFocused(sec,tk);
5527                        _wsGridReduce(tk); }
5528    else if(v==='flow'){ if(typeof flowFilterTicker==='function') flowFilterTicker(tk); }
5529    else if(v==='smart'){ const sf=document.getElementById('smartFilter');
5530                          if(sf){ sf.value=tk; if(typeof _smartRender==='function') _smartRender(); } }
5531  }catch(e){}
5532  gfWsBuildTickers();
5533  try{ _wsRememberTickers(); }catch(e){}
5534  setTimeout(_wsCenterSpot, 1200);
5535}
5536// The app's own EXPIRY / BARS / TINT row, borrowed for the top of the workspace and handed back on
5537// the way out. It is the same element, so the same handlers keep working - no second copy to drift.
5538// heatVolBtn is NOT moved up: the single VOL button in the row drives the heatmap too, and two
5539// controls for one setting is what caused the confusion in the first place.
5540const GF_WS_HEAT_BTNS = ['heatPctBtn','heatWaveBtn','heatBucketBtn'];
5541// One VOL for both panels. Cycles the heatmap (off / 1 / 4) and keeps the grid on whenever the
5542// heatmap is showing any volume at all.
5543function gfWsToggleVolBoth(){
5544  let before = null;
5545  try{ before = (typeof gfHeatVolN !== 'undefined') ? gfHeatVolN : null; }catch(e){}
5546  try{ toggleHeatVol(); }catch(e){}
5547  let want;
5548  try{
5549    const now = (typeof gfHeatVolN !== 'undefined') ? gfHeatVolN : null;
5550    // if the heatmap refused (it is premium-gated) fall back to plainly flipping the grid
5551    want = (now === null || now === before) ? !gfGridVolOn : (now > 0);
5552  }catch(e){ want = true; }
5553  try{ if(typeof gfGridVolOn !== 'undefined' && gfGridVolOn !== want) toggleGridVol(); }catch(e){}
5554  try{ gfWsPaintVolBtn(); }catch(e){}
5555}
5556function gfWsPaintVolBtn(){
5557  const b = document.getElementById('gridVolBtn'); if(!b) return;
5558  let n = null; try{ n = (typeof gfHeatVolN !== 'undefined') ? gfHeatVolN : null; }catch(e){}
5559  const on = (typeof gfGridVolOn !== 'undefined' && gfGridVolOn) || (n != null && n > 0);
5560  b.classList.toggle('active', !!on);
5561  b.textContent = (n === 4) ? 'VOL 4' : 'VOL';
5562  b.title = on ? 'Volume on the grid and the heatmap — click to cycle 1 column, 4 columns, off'
5563               : 'Show volume on the grid and the heatmap';
5564}
5565// Which real control belongs in which menu. Found by the data attribute its buttons carry rather
5566// than by position, so re-ordering the settings row cannot silently empty a menu.
5567const GF_WS_DDS = {
5568  wsDdExp:  { find:'[data-exp]',   id:'gfExpToggle',  shut:1 },   // a choice: close on pick
5569  wsDdBars: { find:'[data-gbar]',  id:'gfBarsToggle', shut:0 },   // a dial: stay open to compare
5570  wsDdTint: { find:'[data-gtint]', id:'gfTintToggle', shut:0 },
5571};
5572const GF_WS_DD_IDS = ['gfExpToggle','gfBarsToggle','gfTintToggle','gfVolToggle','gridResetBtn'];
5573let _wsDdHoverT = null, _wsDdShutT = null;
5574function _wsDdTimers(){
5575  clearTimeout(_wsDdHoverT); _wsDdHoverT=null;   // unconditional: a timer handle is not a boolean
5576  clearTimeout(_wsDdShutT);  _wsDdShutT=null;
5577}
5578// Typing in the ticker box is the one time the pointer wandering off must not shut the list.
5579function _wsDdTyping(){
5580  try{ return !!(document.activeElement && document.activeElement.id==='wsTkSearch'); }catch(e){ return false; }
5581}
5582function _wsDdHover(d){
5583  _wsDdTimers();
5584  if(!d){ if(!_wsDdTyping()) _wsDdCloseAll(); return; }
5585  if(d.classList.contains('open')) return;
5586  // already in the menus: switch immediately. Coming in cold: wait for the pointer to settle.
5587  if(document.querySelector('#wsBar .gf-ws-dd.open')) { _wsDdOpen(d.id); return; }
5588  _wsDdHoverT=setTimeout(function(){ _wsDdHoverT=null; _wsDdOpen(d.id); }, 130);
5589}
5590function _wsDdCloseAll(except){
5591  clearTimeout(_wsDdHoverT); _wsDdHoverT=null;
5591   // a queued open must not outlive a close
5592  document.querySelectorAll('#wsBar .gf-ws-dd.open').forEach(function(d){ if(d!==except) d.classList.remove('open'); });
5593}
5594function _wsDdOpen(id){
5595  const d=document.getElementById(id); if(!d) return;
5596  _wsDdCloseAll(d); d.classList.add('open');
5597}
5598function _wsDdHoverInit(bar){
5599  if(bar.dataset.hov) return;
5600  bar.dataset.hov='1';
5601  bar.addEventListener('mouseover', function(e){
5602    _wsDdHover(e.target.closest ? e.target.closest('.gf-ws-dd') : null);
5603  });
5604  // a panel hangs below the row's box but IS a descendant, so this does not fire on the way into it
5605  bar.addEventListener('mouseleave', function(){
5606    _wsDdTimers();
5607    _wsDdShutT=setTimeout(function(){ _wsDdShutT=null; if(!_wsDdTyping()) _wsDdCloseAll(); }, 240);
5608  });
5609}
5610// Last five tickers you actually opened, newest first - the row shows these and the chevron has the rest.
5611function _wsRecent(){
5612  try{ const a=JSON.parse(localStorage.getItem('gf_ws_recent')||'[]');
5613       return Array.isArray(a) ? a.filter(function(x){ return typeof x==='string' && x; }).slice(0,5) : []; }
5614  catch(e){ return []; }
5615}
5616function _wsRecentAdd(tk){
5617  tk=String(tk||'').toUpperCase(); if(!tk) return;
5618  const a=[tk].concat(_wsRecent().filter(function(x){ return x!==tk; })).slice(0,5);
5619  try{ localStorage.setItem('gf_ws_recent', JSON.stringify(a)); }catch(e){}
5620}
5621function _wsPaintRecent(){
5622  const host=document.getElementById('wsTkRecent'); if(!host) return;
5623  const cur=String(_wsCurrentTicker()||'').toUpperCase();
5624  let list=_wsRecent();
5625  if(cur && list.indexOf(cur)<0) list=[cur].concat(list).slice(0,5);   // what you are looking at is always there
5626  host.innerHTML=list.map(function(t){
5627    return '<button class="exp-ticker-btn'+(t===cur?' active':'')+'" data-ticker="'+t+'">'+t+'</button>'; }).join('');
5628}
5629function _wsTkFilter(q){
5630  const f=String(q||'').trim().toUpperCase();
5631  document.querySelectorAll('#wsTkPop #wsTkStrip .exp-ticker-btn').forEach(function(b){
5632    const t=String(b.dataset.ticker||'');
5633    b.style.display = (!f || t.indexOf(f)===0) ? '' : 'none';
5634  });
5635}
5636// The label a toggle shows, so a rename shows up here without an edit; the fallback is for the
5637// moment before the button exists.
5638function _wsHeatName(id, dflt){
5639  const b=document.getElementById(id);
5640  const t=b?String(b.textContent||'').trim():'';
5641  return t||dflt;
5642}
5643// Each button carries its own current value, so the row still reads the state with every menu shut.
5644function _wsDdPaint(){
5645  const setv=function(dd,txt){ const e=document.querySelector('#'+dd+' .gf-ws-ddb .v'); if(e) e.textContent=txt; };
5646  const act=function(sel){ const b=document.querySelector('.gf-ws-ddp '+sel+'.active'); return b?b.textContent.trim():''; };
5647  setv('wsDdExp',  act('[data-exp]')   || 'ALL');
5648  setv('wsDdBars', act('[data-gbar]')  || 'DEFAULT');
5649  setv('wsDdTint', act('[data-gtint]') || 'OFF');
5650  // state first, class never: the classes are painted by an admin-gated sync that lands after this
5651  const _heatOn=[];
5652  try{ if(typeof gfHeatPct!=='undefined' && gfHeatPct) _heatOn.push(_wsHeatName('heatPctBtn','%')); }catch(e){}
5653  try{ if(typeof gfHeatWave!=='undefined' && gfHeatWave) _heatOn.push(_wsHeatName('heatWaveBtn','WAVE')); }catch(e){}
5654  try{ if(typeof gfHeatBucket!=='undefined' && gfHeatBucket) _heatOn.push(_wsHeatName('heatBucketBtn','TERM')); }catch(e){}
5655  setv('wsDdHeat', _heatOn.length===0 ? 'OFF' : (_heatOn.length===1 ? _heatOn[0] : _heatOn.length+' ON'));
5656  // and while we are here, make the toggles themselves look like what they are
5657  try{
5658    [['heatPctBtn',typeof gfHeatPct!=='undefined'&&gfHeatPct],
5659     ['heatWaveBtn',typeof gfHeatWave!=='undefined'&&gfHeatWave],
5660     ['heatBucketBtn',typeof gfHeatBucket!=='undefined'&&gfHeatBucket]].forEach(function(p){
5661      const b=document.getElementById(p[0]); if(b) b.classList.toggle('active', !!p[1]);
5662    });
5663  }catch(e){}
5664  _wsPaintRecent();
5665  try{ const ln=document.getElementById('wsLayoutName'); if(ln) ln.textContent=gfWsActive(); }catch(e){}
5666}
5667// Moves the real controls into their menus and wires the row. Runs on every paint, like the rest of
5668// the workspace furniture - appending a node that is already there is a no-op, so it is idempotent.
5669function gfWsHeader(){
5670  const bar=document.getElementById('gridExpiryBar'); if(!bar) return;
5671  Object.keys(GF_WS_DDS).forEach(function(dd){
5672    const D=GF_WS_DDS[dd];
5673    let t=document.getElementById(D.id);
5674    if(!t){ const b=bar.querySelector(D.find); t=b&&b.closest?b.closest('.gexp-toggle'):null; if(!t) return; t.id=D.id; }
5675    if(!_wsHome[D.id]) _wsHome[D.id]={ parent:t.parentNode, next:t.nextSibling, style:t.getAttribute('style') };
5676    const p=document.querySelector('#'+dd+' .gf-ws-ddp'); if(p) p.appendChild(t);
5677  });
5678  // RESET is a GRID action and lives in the grid's own bar, beside the sector it resets (_wsGridBar).
5679  // VOL stays in the row: it is a state you flick while reading, not a choice you make once
5680  try{
5681    const gv=document.getElementById('gridVolBtn'), q=document.getElementById('wsQuick');
5682    const vt=gv&&gv.closest?gv.closest('.gexp-toggle'):null;
5683    if(vt && q){ if(!vt.id) vt.id='gfVolToggle';
5684                 if(!_wsHome[vt.id]) _wsHome[vt.id]={ parent:vt.parentNode, next:vt.nextSibling, style:vt.getAttribute('style') };
5685                 q.appendChild(vt); }
5686  }catch(e){}
5687  // the heatmap trio, into its own menu (the group's own label would only repeat the menu title)
5688  try{
5689    const hg=document.getElementById('wsHeatGrp'), hp=document.querySelector('#wsDdHeat .gf-ws-ddp');
5690    if(hg && hp){ const l=hg.querySelector('.gf-ws-glbl'); if(l) l.remove(); hp.appendChild(hg); }
5691  }catch(e){}
5692  // the full list lives in the chevron menu, wrapped instead of a 5,000px horizontal scroll
5693  try{ const strip=document.getElementById('wsTkStrip'), tp=document.getElementById('wsTkPop');
5694       if(strip && tp) tp.appendChild(strip); }catch(e){}
5695  try{ _wsDdHoverInit(document.getElementById('wsBar')); }catch(e){}
5696  if(!document.getElementById('wsBar').dataset.hdr){
5697    document.getElementById('wsBar').dataset.hdr='1';
5698    document.getElementById('wsBar').addEventListener('click', function(e){
5699      const ob=e.target.closest && e.target.closest('.gf-ws-ddb');
5700      if(ob){ const d=ob.closest('.gf-ws-dd'); const was=d.classList.contains('open');
5701              _wsDdCloseAll(d); d.classList.toggle('open', !was); return; }
5702      const tk=e.target.closest && e.target.closest('#wsTkRecent .exp-ticker-btn');
5703      if(tk){ gfWsPickTicker(String(tk.dataset.ticker||'').toUpperCase()); return; }
5704      const chip=e.target.closest && e.target.closest('#wsTkPop .exp-ticker-btn');
5705      if(chip){ _wsDdCloseAll(); return; }                       // the strip's own handler does the picking
5706      const gb=e.target.closest && e.target.closest('.gf-ws-ddp .gexp-btn');
5707      if(gb){ setTimeout(_wsDdPaint,0);                          // after the app's own handler has run
5708              if(gb.hasAttribute('data-exp')) _wsDdCloseAll();   // a choice closes; bars and tint stay open
5709              return; }
5710      if(e.target.closest && e.target.closest('#wsDdHeat .gf-ws-ddp')){ setTimeout(_wsDdPaint,0); return; }
5711      if(e.target.closest && e.target.closest('#wsDdPre .gf-ws-ddp')){ _wsDdCloseAll(); return; }
5712      if(!(e.target.closest && e.target.closest('.gf-ws-ddp'))) _wsDdCloseAll();
5713    });
5714    // pressing a panel in the ADD menu starts a drag: get the menu out of the way so the tile is visible
5715    document.getElementById('wsBar').addEventListener('mousedown', function(e){
5716      if(e.target.closest && e.target.closest('#wsAdd .gf-ws-chip')) _wsDdCloseAll();
5717    });
5718    const inp=document.getElementById('wsTkSearch');
5719    if(inp){
5720      inp.addEventListener('mousedown', function(e){ e.stopPropagation(); });
5721      inp.addEventListener('input', function(){ _wsDdOpen('wsDdTk'); _wsTkFilter(inp.value); });
5722      inp.addEventListener('keydown', function(e){
5723        e.stopPropagation();
5724        if(e.key==='Escape'){ inp.value=''; _wsTkFilter(''); _wsDdCloseAll(); inp.blur(); return; }
5725        if(e.key!=='Enter') return;
5726        const f=String(inp.value||'').trim().toUpperCase(); if(!f) return;
5727        const uni=gfWsTickerUniverse();
5728        const hit=(uni.indexOf(f)>=0) ? f : (uni.find(function(t){ return t.indexOf(f)===0; })||null);
5729        if(!hit) return;
5730        gfWsPickTicker(hit); inp.value=''; _wsTkFilter(''); _wsDdCloseAll();
5731      });
5732    }
5733  }
5734  if(!window.__wsDdDoc){ window.__wsDdDoc=1;
5735    document.addEventListener('mousedown', function(e){
5736      if(!(e.target.closest && e.target.closest('#wsBar'))) _wsDdCloseAll();
5737    }, true);
5738    document.addEventListener('keydown', function(e){ if(e.key==='Escape') _wsDdCloseAll(); });
5739  }
5740  _wsDdPaint();
5741}
5742function gfWsSettingsBar(){
5743  const host=document.getElementById('wsSetBar'), bar=document.getElementById('gridExpiryBar');
5744  if(!host||!bar) return;
5745  if(!_wsHome['gridExpiryBar']) _wsHome['gridExpiryBar']={ parent:bar.parentNode, next:bar.nextSibling, style:bar.getAttribute('style') };
5746  bar.classList.add('gf-ws-in');
5747  bar.style.display='flex';
5748  host.appendChild(bar);
5749  // the heatmap-only toggles, by identity rather than by copying - a copy would need its own
5750  // active-state syncing and would drift from the real one the first time anything else set it
5751  // name the grid's own group too, or its VOL and the heatmap's VOL are indistinguishable
5752  // one VOL for both panels while we are in here; no GRID label needed any more, because there is
5753  // no second VOL to tell it apart from
5754  try{
5755    const gv=document.getElementById('gridVolBtn');
5756    if(gv){ gv.onclick=function(e){ if(e) e.stopPropagation(); gfWsToggleVolBoth(); }; gfWsPaintVolBtn(); }
5757    document.querySelectorAll('#wsSetBar .gf-ws-glbl').forEach(function(x){ if(x.textContent==='GRID') x.remove(); });
5758  }catch(e){}
5759  let grp=document.getElementById('wsHeatGrp');
5760  if(!grp){ grp=document.createElement('div'); grp.id='wsHeatGrp';
5761            grp.innerHTML='<span class="gf-ws-glbl">HEATMAP</span>'; }
5762  const src=document.getElementById('heatStyleBar');
5763  GF_WS_HEAT_BTNS.forEach(function(id){
5764    let el=document.getElementById(id) || (src?src.querySelector('#'+id):null);
5765    if(!el && src) el=[...src.querySelectorAll('button')].find(b=>({'heatPctBtn':'%','heatVolBtn':'VOL','heatWaveBtn':'WAVE','heatBucketBtn':'TERM'})[id]===b.textContent.trim());
5766    if(!el) return;
5767    if(!el.id) el.id=id;
5768    if(!_wsHome[id]) _wsHome[id]={ parent:el.parentNode, next:el.nextSibling, style:el.getAttribute('style') };
5769    grp.appendChild(el);
5770  });
5771  if(grp.querySelector('button')) bar.appendChild(grp);
5772  try{ gfWsHeader(); }catch(e){}   // and distribute it into the one-row header's menus
5773}
5774function gfWsTkFoldInit(){
5775  const b=document.getElementById('wsTkFold'), bar=document.getElementById('wsTkBar'); if(!b||!bar) return;
5776  let hid=false; try{ hid=localStorage.getItem('gf_ws_tk_hide')==='1'; }catch(e){}
5777  const paint=()=>{ bar.classList.toggle('gf-ws-tkfold',hid); b.innerHTML=hid?'\u25be':'\u25b4';
5778                    b.title=hid?'Show the ticker line':'Hide the ticker line'; };
5779  b.onclick=function(e){ e.stopPropagation(); hid=!hid;
5780    try{ localStorage.setItem('gf_ws_tk_hide', hid?'1':'0'); }catch(e2){} paint(); };
5781  paint();
5782}
5783
5784/* ── SYNC: click a ticker anywhere, every panel follows ────────────────────
5785   One delegated listener on the stage rather than a hook in each panel's strip
5786   builder. Every strip in the app renders its chips as .exp-ticker-btn carrying
5787   data-ticker - grid, heatmap, chart and chain all do - so a single capture-phase
5788   listener sees every ticker click in the workspace, including panels added later.
5789   Hooking each builder instead is how you end up with five that sync and one that
5790   quietly does not.
5791
5792   It reuses gfViewSync, the app's existing Sync setting, on purpose. A second
5793   workspace-only sync flag would be two switches for one idea, and the first
5794   support question would be why Sync is on but nothing syncs. */
5795let _wsSyncHook = false, _wsTvWas = null;
5796// HQ's own TradingView setting. Off unless you have turned it on in here.
5797function _wsTvWant(){ try{ return localStorage.getItem('gf_hq_tv')==='1'; }catch(e){ return false; } }
5798function _wsTvRemember(on){ try{ localStorage.setItem('gf_hq_tv', on?'1':'0'); }catch(e){} }
5799let gfWsSync = false; try{ gfWsSync = localStorage.getItem('gf_ws_sync')==='1'; }catch(e){}
5800function gfWsToggleSync(){
5801  gfWsSync = !gfWsSync;
5802  try{ localStorage.setItem('gf_ws_sync', gfWsSync?'1':'0'); }catch(e){}
5803  gfWsPaintSync();
5804  // Turning it ON should act immediately rather than waiting for the next click.
5805  if(gfWsSync){ const t=_wsSyncSeed(); if(t){ try{ gfSyncTo(t,true); }catch(e){} } }
5806}
5807// Whatever the focused panel is already showing is the sensible thing to sync everyone to.
5808function _wsSyncSeed(){
5809  try{
5810    const v=gfActiveView();
5811    if(v==='chart' && typeof chartTicker!=='undefined') return chartTicker;
5812    if(v==='expiry' && typeof expiryTicker!=='undefined') return expiryTicker;
5813    if(v==='chain' && typeof _chTk!=='undefined') return _chTk;
5814    if(v==='gex' && typeof activeColumns!=='undefined' && activeColumns.length) return activeColumns[0];
5815  }catch(e){}
5816  return null;
5817}
5818function gfWsPaintSync(){
5819  const b=document.getElementById('wsSyncBtn'); if(!b) return;
5820  const on=!!gfWsSync;
5821  b.classList.toggle('on', on);
5822  b.textContent = on ? 'Sync ON' : 'Sync';
5823  b.title = on ? 'Click a ticker in any panel and every other panel follows it. Panel settings stay separate.'
5824               : 'Off - each panel keeps its own ticker';
5825}
5826function _wsSyncInstall(){
5827  if(_wsSyncHook) return;
5828  const stage=document.getElementById('wsStage'); if(!stage) return;
5829  _wsSyncHook=true;
5830  stage.addEventListener('click', function(e){
5831    if(!gfWsSync) return;
5832    const b=e.target && e.target.closest && e.target.closest('.exp-ticker-btn');
5833    if(!b) return;
5834    const tk=String(b.dataset.ticker||'').toUpperCase(); if(!tk) return;
5835    // chart 2 / 3 focused and this is the chart's own strip: the pick is for that pane alone
5836    try{ if(b.closest('#chartTickers') && typeof _mFocusedPaneId==='function' && _mFocusedPaneId()!=null) return; }catch(e2){}
5837    // Capture phase, so this runs BEFORE the strip's own handler. Defer the broadcast by a tick so
5838    // the panel you clicked applies its own change first and gfSyncTo then sees it as already done.
5839    setTimeout(function(){ try{ gfSyncTo(tk,true); }catch(e2){} try{ _wsGridReduce(tk); }catch(e3){} }, 0);
5840  }, true);
5841}
5842
5843/* ── open / close ────────────────────────────────────────────────────────── */
5844// The account copy arrives after the first paint, so the workspace opens on the local copy and
5845// adopts the server's only if this browser has never saved one. Adopting a stale server layout over
5846// a newer local one would silently undo the arrangement you made thirty seconds ago on this screen.
5847function gfWsPullSaved(force){
5848  const name=gfWsActive();
5849  let localAt=0; try{ localAt=(gfWsLocal()[name]||{}).at||0; }catch(e){}
5850  fetch('/api/ws-layout',{credentials:'include'}).then(r=>r.ok?r.json():null).then(j=>{
5851    if(!j||!_wsOn) return;
5852    // every name the account knows, cached locally so the menu is complete on this machine too
5853    try{ if(j.layouts && typeof j.layouts==='object'){
5854      const all=gfWsLocal();
5855      Object.keys(j.layouts).forEach(function(n){
5856        const r=j.layouts[n]; if(!r||!r.layout||!r.layout.t) return;
5857        const mine=all[n];
5858        if(!mine || !(mine.at > (r.at||0))) all[n]={ t:r.layout.t, u:r.layout.u||60, at:r.at||0 };
5859      });
5860      localStorage.setItem('gf_ws_layouts', JSON.stringify(all));
5861      try{ gfWsPalette(); }catch(e){}
5862    } }catch(e){}
5863    const rec=(j.layouts && j.layouts[name]) || (name==='Default' ? { layout:j.layout, at:j.at } : null);
5864    if(!rec || !rec.layout || !rec.layout.t) return;
5865    // the account copy only wins when it is genuinely newer than this browser's - otherwise a
5866    // layout saved on another machine last week would undo what you arranged a minute ago
5867    if(!force && localAt && !(rec.at && rec.at > localAt + 2000)) return;
5868    gfWsTree=rec.layout.t; _wsUid=rec.layout.u||60;
5869    gfWsFocus=gfWsTree?_wsFirstLeaf(gfWsTree).id:null;
5870    _wsLayoutClear(_wsAllTabs(gfWsTree));           // same reason as a switch: nothing may be kept
5871    gfWsPaint(); gfWsRefit(); _wsLayoutMark();
5872  }).catch(()=>{});
5873}
5874function gfWsOpen(){
5875  const v=document.getElementById('wsView'); if(!v) return;
5876  _wsOn=true; v.style.display='flex'; document.body.classList.add('gf-ws-on');
5877  try{ if(typeof tradePlanTab!=='undefined' && tradePlanTab==='tide') setTradePlanTab('plan'); }catch(e){}   // in HQ the tide rides under each chart, not in the Ghost Panel
5878  [900,3000,8000].forEach(function(ms){ setTimeout(function(){ try{ gfTideSyncAll(); }catch(e){} }, ms); });   // the chart may not have its ticker yet at 900ms
5879  _wsHeatRelabel();                                  // long labels drawn in the full-screen tab -> short ones
5880  if(!gfWsTree && !gfWsLoadSaved()) gfWsTree=GF_WS_PRESETS.trader();
5881  try{ gfWsPullSaved(); }catch(e){}   // adopts the account copy only when it is newer
5882  if(!gfWsFocus||!_wsFindNode(gfWsTree,gfWsFocus)) gfWsFocus=gfWsTree?_wsFirstLeaf(gfWsTree).id:null;
5883  if(!_wsRO && typeof ResizeObserver!=='undefined') _wsRO=new ResizeObserver(()=>gfWsRefit());
5884  _wsSyncInstall();
5885  if(!window.__wsUnloadHook){ window.__wsUnloadHook=1;
5886    // a reload does not repaint first, so the last arrangement would otherwise never be written
5887    window.addEventListener('beforeunload', function(){ try{ if(_wsOn) gfWsSave(); }catch(e){} });
5888    document.addEventListener('visibilitychange', function(){ try{ if(_wsOn && !document.hidden) gfWsSave(); }catch(e){} });
5889  }
5890  _wsTvEnforce();                                  // and again on every paint - see the note there
5891  // the Ghost Mode header is rebuilt when /api/me answers, which can land after this
5892  setTimeout(_wsTvEnforce, 500); setTimeout(_wsTvEnforce, 2000);
5893  gfWsSettingsBar(); gfWsBuildTickers(); gfWsTkFoldInit();
5894  try{ _wsVsKey=(typeof _vsKey==='function')?_vsKey(gfActiveView()):null; }catch(e){ _wsVsKey=null; }
5895  if(_wsGridOne) setTimeout(function(){ try{ _wsGridReduce(); }catch(e){} }, 900);
5896  gfWsPaint(); gfWsRefit(); gfWsPaintSync();
5897  _wsLayoutMark();                                  // the arrangement this window opened with
5898}
5899function gfWsClose(){
5900  if(!_wsOn) return;
5901  _wsOn=false;
5902  // restore only if the user never switched it back on in here - otherwise their choice wins
5903  try{
5904    if(_wsTvWas===true && typeof tvSyncLinked!=='undefined' && !tvSyncLinked && typeof nfSetTvSync==='function') nfSetTvSync(true);
5905    _wsTvWas=null;
5906  }catch(e){}
5907  gfWsSave();
5908  try{ ['chartKing','chartCW','chartPW','chartVN','chartAN'].forEach(function(id){
5909        if(_wsHome[id]) gfWsUnpark({id:id}); }); }catch(e){}
5910  const _fc=document.getElementById('wsFlowCtl'); if(_fc) _fc.remove();
5911  const _gt=document.getElementById('wsGexTab'); if(_gt) _gt.remove();
5912  const _wn=document.getElementById('wsNodes'); if(_wn) _wn.remove();
5913  try{ if(_wsGridFull){ activeColumns=_wsGridFull.slice(); _wsGridFull=null;
5914                        buildGexTickers(); renderColumns();
5915                        activeColumns.forEach((t,i)=>setTimeout(function(){ try{ loadGEXSlot(i); }catch(e){} }, i*120)); } }catch(e){}
5916  document.querySelectorAll('.gf-ws-sidebtn').forEach(function(x){ x.remove(); });
5917  document.querySelectorAll('.gf-ws-sidehide').forEach(function(x){ x.classList.remove('gf-ws-sidehide'); });
5918  document.querySelectorAll('.gf-ws-sidecol').forEach(function(x){ x.classList.remove('gf-ws-sidecol'); x.style.width=''; });
5919  document.querySelectorAll('.gf-ws-sidegrip').forEach(function(x){ x.remove(); });
5920  try{ if(_wsHome['gridResetBtn']) gfWsUnpark({ id: 'gridResetBtn' }); }catch(e){}   // return the REAL RESET button home BEFORE removing wsGridBar — it was appended into the bar, so .remove() would take it with the bar and gfWsUnpark could no longer find it (RESET then missing on Grid until reload, user 2026-09-19)
5921  const _gb=document.getElementById('wsGridBar'); if(_gb) _gb.remove();   // workspace furniture, not a parked view
5922  try{ GF_WS_HEAT_BTNS.forEach(function(id){ if(_wsHome[id]) gfWsUnpark({id:id}); }); }catch(e){}
5923  // hand the grid's VOL button back its own plain handler
5924  try{ const _gv=document.getElementById('gridVolBtn');
5925       if(_gv){ _gv.onclick=null; _gv.setAttribute('onclick','toggleGridVol()'); _gv.textContent='VOL';
5926                _gv.classList.toggle('active', !!(typeof gfGridVolOn!=='undefined' && gfGridVolOn)); } }catch(e){}
5927  try{ const _hg=document.getElementById('wsHeatGrp'); if(_hg) _hg.remove(); }catch(e){}
5928  // the menus' contents go back INTO the settings row, so they must be unparked before it is
5929  try{ GF_WS_DD_IDS.forEach(function(id){ if(_wsHome[id]) gfWsUnpark({id:id}); }); }catch(e){}
5930  try{ _wsDdCloseAll(); }catch(e){}
5931  try{ const _sb=document.getElementById('gridExpiryBar');
5932       if(_sb && _wsHome['gridExpiryBar']) gfWsUnpark({id:'gridExpiryBar'}); }catch(e){}
5933  try{ const _t=document.getElementById('gexTickers'); if(_t) _t.classList.remove('gf-ws-tkhide'); }catch(e){}
5934  try{ const _c=document.getElementById('columns'); if(_c) _c.classList.remove('gf-ws-one'); }catch(e){}
5935  gfWsUnparkAll();                                  // everything home BEFORE the next view paints
5936  _wsBooted={};
5937  const stage=document.getElementById('wsStage'); if(stage) stage.innerHTML='';
5938  const v=document.getElementById('wsView'); if(v) v.style.display='none';
5939  document.body.classList.remove('gf-ws-on');
5940  _wsHeatRelabel();                                  // and back to the full words on the way out
5941}
5942// Keep the SERVER watches (what the extension + My HQ read via /api/tf-signals) in step with the
5943// Pulse chips (nodeTypes). The chips are a local display filter; the extension/HQ filter node rows
5944// to the account's armed TICKER|level pairs. So a ticker armed with King only never showed Put Wall
5945// on those surfaces even after the Put Wall chip was turned on (user 2026-09-15). Re-arm EVERY armed
5946// ticker - server set unioned with this browser's - to exactly the selected levels. force=true always
5947// writes (a deliberate chip change); force=false writes only when the server level set has drifted.
5948function _nodeReconcileServerLevels(force){
5949  try{
5950    var want=_nodeLevelsSelected(); if(!want.length) return;
5951    var tks=new Set([...nodeArmed]);
5952    var byTk={};
5953    if(_nodeArmedKeys) _nodeArmedKeys.forEach(function(k){ var p=String(k).split('|'); if(p[0]){ tks.add(p[0]); (byTk[p[0]]=byTk[p[0]]||new Set()).add(p[1]); } });
5954    if(!tks.size) return;
5955    if(!force){
5956      var wantSet=new Set(want);
5957      var drift=[...tks].some(function(t){ var have=byTk[t]||new Set(); if(have.size!==wantSet.size) return true; for(var i=0;i<want.length;i++) if(!have.has(want[i])) return true; return false; });
5958      if(!drift) return;
5959    }
5960    _armServerMany([...tks], true);
5961  }catch(e){}
5962}
5963function refreshNodeArmed(){
5964  return fetch('/api/alerts/config',{credentials:'include'}).then(function(r){ return r.json(); }).then(function(j){
5965    var lv=(j&&j.alerts&&j.alerts.levels)||{};
5966    var ws=(lv.on&&Array.isArray(lv.watches))?lv.watches:[];
5967    _nodeArmedKeys=new Set(ws.map(function(w){ return String(w.ticker||'').toUpperCase()+'|'+String(w.level||''); }));
5968    try{ if(typeof renderNodeFeed==='function') renderNodeFeed(); }catch(e){}
5969  }).catch(function(){ /* leave null - unfiltered - rather than blanking the panel */ });
5970}
5971function _nodeArmedFor(ticker, node){
5972  if(!_nodeArmedKeys) return true;                      // not loaded yet: do not filter
5973  return _nodeArmedKeys.has(String(ticker||'').toUpperCase()+'|'+(_NODE_LVL[node]||node));
5974}
5975function pushNodeAlert(ticker, node, strike, price) {
5976  if(!_nodeArmedFor(ticker, node)) return;              // not armed on this account - the extension would not show it either
5977  const now = Date.now();
5978  // De-dupe: same ticker/node/strike within 10 min counts as one hit (avoids re-firing on reload
5979  // or while price hovers on the level).
5980  if (nodeFeed.some(a => a.ticker === ticker && a.node === node && a.strike === strike && (now - a.t) < 600000)) return;
5981  nodeFeed.unshift({ ticker, node, strike, price, t: now });
5982  if (nodeFeed.length > 500) nodeFeed.length = 500;   // all-day log
5983  _saveNodeFeed();
5984  renderNodeFeed();
5985  try { showToast('⚡ ' + ticker + ' hit ' + NODE_META[node].label + ' ' + fmtStrike(strike)); } catch (e) {}
5986}
5987function clearNodeFeed() { nodeFeed = []; _saveNodeFeed(); renderNodeFeed(); }
5988function setNodeFeedFilter(n){ nodeFeedFilter = n; renderNodeFeed(); try{ renderUrNodes(); }catch(e){}   /* the chips drive both copies: Scanner (local) and right panel (server feed) */ }
5989// Renders into BOTH the Scanner's feed and the Ghost Mode right-panel copy (whichever exist), so the
5990// two views never drift apart — same data, same filter, one code path.
5991function renderNodeFeed() {
5992  // The right panel (ultraNodeFeedList / ultraNodeFeedFilters) is painted by renderUrNodes from the
5993  // SERVER feed now, so this local renderer owns the Scanner copy only - listing it here as well
5994  // would have the two fight over the same element every 12 seconds.
5995  const lists = ['nodeFeedList'].map(id => document.getElementById(id)).filter(Boolean);
5996  if (!lists.length) return;
5997  // FILTER ON THE WAY OUT TOO (user 2026-09-09). pushNodeAlert already refuses unarmed crossings,
5998  // but the feed is persisted to localStorage and survives a reload, so rows recorded BEFORE the
5999  // per-user rule existed - or before a ticker was disarmed - kept showing. Eleven of the twelve on
6000  // screen were unarmed. Filtering here as well means the panel always reflects the CURRENT arming,
6001  // and the counts in the chips agree with the rows underneath them.
6002  const _feedAll = nodeFeed.filter(a => _nodeArmedFor(a.ticker, a.node));
6003  // Filter chips (All + each node type), with a per-type count.
6004  for (const fbox of ['nodeFeedFilters'].map(id => document.getElementById(id)).filter(Boolean)) {
6005    const counts = { all: _feedAll.length, king:0, call:0, put:0, vol:0 };
6006    for (const a of _feedAll) if (counts[a.node] != null) counts[a.node]++;
6007    const chip = (key, label, color) => '<button onclick="setNodeFeedFilter(\'' + key + '\')" style="cursor:pointer;font-family:Segoe UI,monospace;font-size:10px;font-weight:700;padding:3px 8px;border-radius:10px;border:1px solid ' + (nodeFeedFilter === key ? (color || '#3b82f6') : 'var(--border)') + ';background:' + (nodeFeedFilter === key ? nodeHexRgba(color || '#3b82f6', 0.18) : 'transparent') + ';color:' + (nodeFeedFilter === key ? (color || '#e2e8f0') : 'var(--dim)') + '">' + label + ' ' + (counts[key] || 0) + '</button>';
6008    fbox.innerHTML = chip('all', 'All') + chip('king', NODE_META.king.abbr, NODE_META.king.color) + chip('call', NODE_META.call.abbr, NODE_META.call.color) + chip('put', NODE_META.put.abbr, NODE_META.put.color) + chip('vol', NODE_META.vol.abbr, NODE_META.vol.color)
6009      // Chart-panel copy has no title bar — count + Clear ride at the end of this chips row.
6010      + (fbox.id === 'ultraNodeFeedFilters' ? '<span id="ultraNodeFeedCount" style="margin-left:auto;font-size:10px;color:var(--dim);font-weight:400"></span><button class="refresh-btn" onclick="clearNodeFeed()" style="color:var(--text);padding:2px 8px;font-size:10px">Clear</button>' : '');
6011  }
6012  const feed = (nodeFeedFilter === 'all') ? _feedAll : _feedAll.filter(a => a.node === nodeFeedFilter);
6013  for (const cnt of ['nodeFeedCount'].map(id =>
6013 document.getElementById(id)).filter(Boolean)) {
6014    cnt.textContent = feed.length ? feed.length + (nodeFeedFilter === 'all' ? ' today' : ' shown') : '';
6015  }
6016  const html = !feed.length
6017    ? '<div style="padding:18px 12px;text-align:center;color:var(--dim);font-size:11px;font-family:Segoe UI,monospace;line-height:1.6">' + (nodeFeed.length ? 'No ' + (NODE_META[nodeFeedFilter] ? NODE_META[nodeFeedFilter].label : '') + ' alerts yet.' : 'Watching levels…<br>alerts appear here when price hits a node.') + '</div>'
6018    : feed.map(a => { const m = NODE_META[a.node]; return '<div class="node-feed-row" onclick="gfGoTicker(\'' + a.ticker + '\')" title="Open ' + a.ticker + ' chart" style="cursor:pointer;padding:8px 10px;border-bottom:1px solid rgba(30,41,59,0.5);font-family:Segoe UI,monospace;font-size:12px;display:flex;align-items:center;gap:6px"><span>' + m.emoji + '</span><span style="font-weight:700;color:#e2e8f0">' + a.ticker + '</span><span style="color:var(--dim)">hit</span><span style="color:' + m.color + ';font-weight:700">' + m.label + ' ' + fmtStrike(a.strike) + '</span>' + ((a.price != null && isFinite(a.price)) ? '<span style="color:#8b9cb5;font-size:11px">@ ' + fmtStrike(a.price) + '</span>' : '') + '<span style="margin-left:auto;color:#94a3b8;font-size:11px">' + nodeTimeStr(a.t) + '</span></div>'; }).join('');
6019  for (const list of lists) list.innerHTML = html;
6020}
6021// ── Ghost Mode right panel: Flow ⇄ Node Alerts ──────────────────────────────────────────────
6022// Node alerts are already collected all day by the background watcher (startNodePolling on load),
6023// so switching here only changes what's displayed — no extra polling, and no alerts are missed
6024// while the Flow tape is showing.
6025let ultraRightTab = 'flow';
6026// Declared up here, NOT beside their functions further down: nfBuildUltraPanel() runs at line ~2259
6027// and reads both when re-applying saved tab state. Declared later they'd be in the temporal dead
6028// zone at that point, throwing a ReferenceError that the surrounding try/catch would swallow.
6029let tradePlanTab = 'plan';
6030let _lastTradePlan = null;   // last computed plan, so toggling back from Tide repaints without a refetch
6031// ── DUPLICATES (user 2026-09-14) ── a copy of a tabbed panel hosts one of its inner tabs' real nodes.
6032// Per panel: the inner tabs, the nodes each one is made of (with the display each needs), which are
6033// admin-only, how to read and set the original's current tab, and how to load a tab's data.
6034const _DUP_TABS = {
6035  gpanel: { tabs: { flow: 'Flow', prints: 'Whales', movers: 'Movers', nodes: 'Node Alerts', gflip: 'G-Flip' },
6036            nodes: { flow: ['ultraFlowHeader', 'ultraFlowBody'], prints: ['ultraPrintsPane'], movers: ['ultraMoversPane'], nodes: ['ultraNodePane'], gflip: ['ultraGflipPane'] },
6037            admin: ['prints', 'gflip'], cur: function () { return ultraRightTab; }, sw: function (t) { setUltraFlowTab(t); },
6038            disp: function (id) { return (id === 'ultraFlowHeader' || id === 'ultraFlowBody') ? '' : 'flex'; },
6039            load: function (t) { if (t === 'nodes' && typeof renderNodeFeed === 'function') renderNodeFeed(); else if (t === 'movers') renderUltraMovers(); else if (t === 'gflip') renderUltraGflip(); else if (t === 'prints') renderUltraPrints(); else if (t === 'flow' && typeof loadUltraFlow === 'function' && chartTicker) loadUltraFlow(chartTicker, true); } },
6040  ideas:  { tabs: { setups: 'Setups', earnings: 'Earnings', watchlist: 'Watchlist', index: 'SPX/SPY', insider: 'Insider', gflip: 'G-Flip', eod: 'EOD', prints: 'Whales', alltrades: 'All Trades', account: 'Account' },
6041            nodes: { setups: ['ideasSetupBar', 'ideasSetupWrap'], earnings: ['ideasEarnBar', 'earningsWrap'], watchlist: ['ideasWatchBar', 'watchlistWrap'], index: ['ideasIdxBar', 'idxWrap'], insider: ['insiderWrap'], gflip: ['gflipWrap'], eod: ['eodWrap'], prints: ['printsWrap'], alltrades: ['allTradesWrap'], account: ['accountWrap'] },
6042            admin: ['index', 'insider', 'gflip', 'eod', 'prints', 'alltrades', 'account'], cur: function () { return _ideasTab; }, sw: function (t) { setIdeasTab(t); },
6043            disp: function (id) { return id === 'ideasSetupBar' ? 'contents' : 'flex'; },
6044            load: function (t) { const L = { setups: 'loadTradeIdeas', earnings: 'loadEarnings', watchlist: 'loadWatchlist', index: 'loadIndexPlays', insider: 'loadInsiderTab', gflip: 'loadGflip', eod: 'loadEod', prints: 'loadPrints', alltrades: 'loadAllTrades', account: 'loadAccountTab' }[t]; if (L && typeof window[L] === 'function') window[L](); } },
6045  scanner:{ tabs: { movers: 'Scanner', overview: 'Market', nodes: 'Node Alerts' },
6046            nodes: { movers: ['scanModeBar', 'scanFilters', 'scanMain'], overview: ['marketOverviewMain'], nodes: ['nodeAlertsBar', 'nodeAlertsMain'] },
6047            admin: [], cur: function () { return scannerTab; }, sw: function (t) { setScannerTab(t); },
6048            disp: function (id) { return id === 'marketOverviewMain' ? 'block' : 'flex'; },
6049            load: function (t) { if (t === 'overview' && typeof loadMarketOverview === 'function') loadMarketOverview(); if (t === 'nodes') { try { renderNodeChips(); startNodePolling(); } catch (e) {} } } },
6050};
6051let _dupState = {};            // key ->
6051 { base, tab, home: { id -> {parent, next, display} } }
6052function _dupBase(k) { return String(k || '').split('~')[0]; }
6053function _dupIsDup(k) { return String(k || '').indexOf('~') > 0; }
6054function _dupCan(base) { return !!_DUP_TABS[base]; }
6055function _dupHas(base) { try { return _wsAllTabs(gfWsTree).some(k => _dupIsDup(k) && _dupBase(k) === base); } catch (e) { return false; } }
6056function _dupShowing(base) { try { return _wsAllTabs(gfWsTree).some(k => _dupIsDup(k) && _dupBase(k) === base && gfWsShowing(k)); } catch (e) { return false; } }
6057function _dupOwner(base, tab) { for (const k in _dupState) { const d = _dupState[k]; if (d.base === base && d.tab === tab && Object.keys(d.home).length) return k; } return null; }
6058function _dupAllowed(base, tab) { const D = _DUP_TABS[base]; return !!D && !!D.tabs[tab] && !(D.admin.indexOf(tab) >= 0 && !(typeof isAdminUser !== 'undefined' && isAdminUser)); }
6059// the manifest entry + host element for a duplicate key, made on demand (a saved layout carries the key)
6060function _dupEnsure(key) {
6061  if (!_dupIsDup(key) || GF_WS_PANELS[key]) return !!GF_WS_PANELS[key];
6062  const base = _dupBase(key), B = GF_WS_PANELS[base]; if (!B || !_dupCan(base)) return false;
6063  const n = key.split('~')[1];
6064  let host = document.getElementById('dupHost-' + key);
6065  if (!host) {
6066    host = document.createElement('div'); host.id = 'dupHost-' + key;
6067    host.style.cssText = 'display:none;flex:1;min-height:0;flex-direction:column;overflow:hidden';
6068    host.innerHTML = '<div class="dup-tabs" style="flex:0 0 auto;display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid var(--border);background:var(--card);flex-wrap:wrap"></div><div class="dup-body" style="flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden"></div>';
6069    (document.getElementById('wsAttic') || document.body).appendChild(host);
6070  }
6071  GF_WS_PANELS[key] = { name: B.name + ' (' + n + ')', col: B.col, kind: 'dup', base: base, ids: [{ id: 'dupHost-' + key, d: 'flex', main: 1 }], side: B.side ? B.side.slice() : undefined, sideFind: B.sideFind,
6072    boot: function () { try { let t = null; try { t = localStorage.getItem('gf_dup_' + key); } catch (e) {} _dupShow(key, t || _dupFree(base)); } catch (e) {} } };
6073  if (!_dupState[key]) _dupState[key] = { base, tab: null, home: {} };
6074  _dupPaintBar(key);
6075  return true;
6076}
6077function _dupEnsureAll() { try { _wsAllTabs(gfWsTree).forEach(k => { if (_dupIsDup(k)) _dupEnsure(k); }); } catch (e) {} }
6078// the first inner tab neither the original nor another copy is showing
6079function _dupFree(base) { const D = _DUP_TABS[base]; if (!D) return null; const cur = D.cur(); for (const t in D.tabs) { if (t === cur) continue; if (!_dupAllowed(base, t)) continue; if (_dupOwner(base, t)) continue; return t; } return Object.keys(D.tabs)[0]; }
6080function _dupPaintBar(key) {
6081  const host = document.getElementById('dupHost-' + key), d = _dupState[key]; if (!host || !d) return;
6082  const D = _DUP_TABS[d.base], bar = host.querySelector('.dup-tabs'); if (!D || !bar) return;
6083  bar.innerHTML = Object.keys(D.tabs).filter(t => _dupAllowed(d.base, t)).map(t => '<button class="tf-btn' + (d.tab === t && Object.keys(d.home).length ? ' active' : '') + '" onclick="_dupShow(\'' + key + '\',\'' + t + '\')" style="font-size:10px;padding:3px 10px">' + D.tabs[t] + '</button>').join('');
6084}
6085// every node this copy holds goes back where it came from
6086function _dupRelease(key) {
6087  const d = _dupState[key]; if (!d) return;
6088  // in REVERSE order of taking: a node's recorded next sibling may itself be hosted here, so the
6089  // later sibling goes home first and the earlier one then finds it in place
6090  for (const id of Object.keys(d.home).reverse()) {
6091    const el = document.getElementById(id), h = d.home[id]; if (!el || !h || !h.parent) continue;
6092    const anchor = (h.next && h.next.parentNode === h.parent) ? h.next : null;
6093    h.parent.insertBefore(el, anchor); el.style.display = h.display; if (h.flex != null) el.style.flex = h.flex;
6094  }
6095  d.home = {};
6096  const host = document.getElementById('dupHost-' + key), body = host && host.querySelector('.dup-body');
6097  if (body) body.innerHTML = '<div class="col-loading" style="color:#f5f0e6">Pick a tab above</div>';
6098  _dupPaintBar(key);
6099}
6100function _dupShow(key, tab) {
6101  if (!_dupEnsure(key)) return;
6102  const d = _dupState[key], D = _DUP_TABS[d.base];
6103  if (!_dupAllowed(d.base, tab)) tab = _dupFree(d.base); if (!tab) return;
6104  const host = document.getElementById('dupHost-' + key), body = host && host.querySelector('.dup-body'); if (!body) return;
6105  _dupRelease(key);
6106  const other = _dupOwner(d.base, tab); if (other) _dupRelease(other);   // one place at a time
6107  body.innerHTML = '';
6108  // the original cannot show the same tab at the same time - it moves to a free one
6109  try { if (D.cur() === tab) { const alt = Object.keys(D.tabs).find(t => t !== tab && _dupAllowed(d.base, t) && !_dupOwner(d.base, t)); if (alt) D.sw(alt); } } catch (e) {}
6110  for (const id of D.nodes[tab]) {
6111    const el = document.getElementById(id); if (!el) continue;
6112    d.home[id] = { parent: el.parentNode, next: el.nextSibling, display: el.style.display, flex: el.style.flex };
6113    body.appendChild(el); el.style.display = D.disp(id);
6114    if (/Bar$|Filters$/.test(id)) el.style.flex = '0 0 auto';   // a bar is a row of controls, not a share of the height
6115  }
6116  d.tab = tab; try { localStorage.setItem('gf_dup_' + key, tab); } catch (e) {}
6117  _dupPaintBar(key);
6118  try { D.load(tab); } catch (e) {}
6119  try { if (typeof gfWsReapplySideSoon === 'function') gfWsReapplySideSoon(key); } catch (e) {}   // fold the side column like the original's tile does
6120}
6121// the original's own tab switch hides what it is not showing - a hosted node stays visible; and
6122// picking a hosted tab on the original takes it back from the copy
6123function _dupAssert(base) { for (const k in _dupState) { const d = _dupState[k]; if (base && d.base !== base) continue; const D = _DUP_TABS[d.base]; for (const id of Object.keys(d.home)) { const el = document.getElementById(id); if (el) el.style.display = D.disp(id); } } }
6124function _dupTake(base, tab) { const k = _dupOwner(base, tab); if (k) _dupRelease(k); }
6125// right-click on a window's tab
6126function _wsTabMenu(ev, node, i) {
6127  ev.preventDefault();
6128  node = _wsFindNode(gfWsTree, node.id) || node;   // the tile's closure may predate a layout adoption; act on the tree as it is now
6129  if (!node.tabs || i >= node.tabs.length) return;
6130  const old = document.getElementById('wsTabMenu'); if (old) old.remove();
6131  const key = node.tabs[i], base = _dupBase(key), can = _dupCan(base) && !_dupIsDup(key);
6132  const m = document.createElement('div'); m.id = 'wsTabMenu';
6133  m.style.cssText = 'position:fixed;left:' + ev.clientX + 'px;top:' + ev.clientY + 'px;z-index:100000;background:#0f172a;border:1px solid var(--t-border3,#334155);border-radius:8px;padding:4px;
6133min-width:200px;box-shadow:0 10px 30px rgba(0,0,0,.5);font-size:12px;font-weight:700;color:#f5f0e6';
6134  const item = (label, on, sub) => { const b = document.createElement('div'); b.style.cssText = 'padding:7px 10px;border-radius:6px;cursor:' + (on ? 'pointer' : 'default') + ';color:' + (on ? '#f5f0e6' : '#cbd5e1'); b.innerHTML = label + (sub ? '<div style="font-size:10.5px;font-weight:500;color:#cbd5e1;margin-top:2px">' + sub + '</div>' : ''); if (on) { b.onmouseenter = () => { b.style.background = 'rgba(212,175,55,.18)'; }; b.onmouseleave = () => { b.style.background = 'transparent'; }; } return b; };
6135  const dup = item('Duplicate', can, can ? 'A copy beside this one, with its own inner tab; drag it anywhere' : (_dupIsDup(key) ? 'Duplicate the original, not a copy' : 'This panel has one view and one state - a copy would show the same thing'));
6136  if (can) dup.onclick = () => { m.remove(); _wsDuplicate(node, i); };
6137  m.appendChild(dup);
6138  const cl = item('Close tab', true); cl.onclick = () => { m.remove(); gfWsCloseTab(node, i); }; m.appendChild(cl);
6139  document.body.appendChild(m);
6140  const off = e => { if (!m.contains(e.target)) { m.remove(); document.removeEventListener('mousedown', off, true); } };
6141  setTimeout(() => document.addEventListener('mousedown', off, true), 0);
6142}
6143function _wsDuplicate(node, i) {
6144  node = _wsFindNode(gfWsTree, node.id) || node;
6145  const base = _dupBase(node.tabs[i]); if (!_dupCan(base)) return;
6146  const used = new Set(_wsAllTabs(gfWsTree)); let n = 2; while (used.has(base + '~' + n)) n++;
6147  const key = base + '~' + n;
6148  if (!_dupEnsure(key)) return;
6149  _wsInsert(node, key, 'center', i + 1);
6150  gfWsPrune(); gfWsPaint(); gfWsRefit();
6151}
6152function setUltraFlowTab(tab) {
6153  if ((tab === 'gflip' || tab === 'prints') && !(typeof isAdminUser !== 'undefined' && isAdminUser)) tab = 'flow';   // G-Flip + Whales: admin until tested (user 2026-09-06)
6154  ultraRightTab = (tab === 'nodes') ? 'nodes' : (tab === 'movers') ? 'movers' : (tab === 'gflip') ? 'gflip' : (tab === 'prints') ? 'prints' : 'flow';
6155  try { _dupTake('gpanel', ultraRightTab); } catch (e) {}   // picked here: the original takes the tab back from a copy
6156  const showNodes = ultraRightTab === 'nodes', showMovers = ultraRightTab === 'movers', showGflip = ultraRightTab === 'gflip', showPrints = ultraRightTab === 'prints';
6157  const hdr = document.getElementById('ultraFlowHeader');
6158  const body = document.getElementById('ultraFlowBody');
6159  const pane = document.getElementById('ultraNodePane');
6160  const mpane = document.getElementById('ultraMoversPane');
6161  const gpane = document.getElementById('ultraGflipPane');
6162  const ppane = document.getElementById('ultraPrintsPane');
6163  if (hdr) hdr.style.display = (showNodes || showMovers || showGflip || showPrints) ? 'none' : '';
6164  if (body) body.style.display = (showNodes || showMovers || showGflip || showPrints) ? 'none' : '';
6165  if (pane) pane.style.display = showNodes ? 'flex' : 'none';
6166  if (mpane) mpane.style.display = showMovers ? 'flex' : 'none';
6167  if (gpane) gpane.style.display = showGflip ? 'flex' : 'none';
6168  if (ppane) ppane.style.display = showPrints ? 'flex' : 'none';
6169  if (showGflip) renderUltraGflip();
6170  if (showPrints) renderUltraPrints();
6171  // Toggle by data attribute, not id — the old and new layouts can each carry a copy of the tabs.
6172  document.querySelectorAll('[data-urtab]').forEach(b => b.classList.toggle('active', b.dataset.urtab === ultraRightTab));
6173  try { localStorage.setItem('gf_ultraRightTab', ultraRightTab); } catch (e) {}
6174  if (showNodes && typeof renderNodeFeed === 'function') renderNodeFeed();
6175  if (showMovers) renderUltraMovers();
6176  try { _dupAssert('gpanel'); } catch (e) {}   // a pane hosted by a copy is not this panel's to hide
6177}
6178try { const _urt = localStorage.getItem('gf_ultraRightTab'); if (_urt === 'nodes' || _urt === 'movers') setTimeout(() => setUltraFlowTab(_urt), 0); if (_urt === 'gflip' || _urt === 'prints') setTimeout(() => setUltraFlowTab(_urt), 1500); } catch (e) {}
6179// ── G-FLIP tab (admin, 2026-09-04): every watchlist name vs its gamma flip, 0DTE and ALL bases,
6180// today's crosses, and the shadow plays the crosses produced (session 'gflip', portal only). ──
6181let _gfBasis='dte', _gfView='list', _gfData=null, _gfTimer=null, _gfS
6181ort={col:'dist',dir:1};
6182function setGflipBasis(b){ _gfBasis=(b==='all')?'all':'dte'; try{ localStorage.setItem('gf_gflipBasis',_gfBasis); }catch(e){} renderGflip(); }
6183function setGflipView(v){ _gfView=(v==='crosses'||v==='plays')?v:'list'; renderGflip(); }
6184function gflipSort(col){ if(_gfSort.col===col) _gfSort.dir=-_gfSort.dir; else _gfSort={col:col,dir:1}; renderGflip(); }
6185try{ const _b=localStorage.getItem('gf_gflipBasis'); if(_b==='all'||_b==='dte') _gfBasis=_b; }catch(e){}
6186function loadGflip(){
6187  const el=document.getElementById('gflipBody'); if(!el) return;
6188  fetch('/api/gflip').then(r=>r.json()).then(j=>{
6189    if(j&&j.error){ el.innerHTML='<div class="col-loading col-error">'+j.error+'</div>'; return; }
6190    _gfData=j; renderGflip();
6191    _loadPnlPanel('gflipTracker','gflip','#22d3ee','G-Flip P&amp;L','Plays fire when a name crosses its gamma flip inside the play window. Portal only - nothing goes to Discord.');
6192  }).catch(()=>{ el.innerHTML='<div class="col-loading col-error">Could not load G-Flip.</div>'; });
6193  if(_gfTimer) clearInterval(_gfTimer);
6194  _gfTimer=setInterval(()=>{ if(gfLive('ideas')&&_ideasTab==='gflip') loadGflip(); else { clearInterval(_gfTimer); _gfTimer=null; } },30000);
6195}
6196function _gfT(ms){ return ms?new Date(ms).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}):'—'; }
6197function _gfN(v,d){ return (v==null||!isFinite(v))?'—':(+v).toFixed(d==null?2:d); }
6198function _gfPct(v){ if(v==null) return '—'; return '<span style="color:'+(v>=0?'#22c55e':'#ef4444')+';font-weight:700">'+(v>=0?'+':'')+(+v).toFixed(2)+'%</span>'; }
6199function renderGflip(){
6200  const el=document.getElementById('gflipBody'); if(!el||!_gfData) return;
6201  const j=_gfData, b=_gfBasis, day=j.day||{};
6202  const chip=(on,lbl,fn,title)=>'<button class="tf-btn'+(on?' active':'')+'" onclick="'+fn+'" style="font-size:11px;padding:4px 12px" title="'+(title||'')+'">'+lbl+'</button>';
6203  let h='<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:12px">'
6204    +'<span style="font-size:11px;color:#cbd5e1;font-weight:700;margin-right:4px">Basis</span>'
6205    +chip(b==='dte','0DTE',"setGflipBasis('dte')",'Nearest expiry - today when the name expires today')
6206    +chip(b==='all','ALL',"setGflipBasis('all')",'All expiries')
6207    +'<span style="width:14px"></span><span style="font-size:11px;color:#cbd5e1;font-weight:700;margin-right:4px">Show</span>'
6208    +chip(_gfView==='list','Above / Below',"setGflipView('list')")
6209    +chip(_gfView==='crosses','Crosses today',"setGflipView('crosses')")
6210    +chip(_gfView==='plays','Plays today',"setGflipView('plays')")
6211    +'<span style="margin-left:auto;font-size:11px;color:#cbd5e1">'+(j.universe||0)+' names · plays today '+(day.plays?('0DTE '+(day.plays.dte||0)+' / ALL '+(day.plays.all||0)):'0')+' · window '+_gfMins(j.window&&j.window.start)+'–'+_gfMins(j.window&&j.window.end)+' PT'+(j.window&&j.window.inWindow?' <b style="color:#22c55e">OPEN</b>':' <span style="color:#fbbf24">closed</span>')+'</span>'
6212    +'</div>';
6213  if(_gfView==='crosses'){ h+=_gfCrossesHtml(day); el.innerHTML=h; return; }
6214  if(_gfView==='plays'){ h+=_gfPlaysHtml(day); el.innerHTML=h; return; }
6215  const rows=(j.snapshot||[]).filter(r=>r[b]).map(r=>{ const L=r[b]; const sd=(day.side||{})[r.ticker+'|'+b]||null; return {tk:r.ticker,spot:r.spot,flip:L.flip,dist:L.distPct,side:L.side,king:L.king,cw:L.callWall,pw:L.putWall,up:L.up,down:L.down,net:L.netGex,exp:L.expiry,stale:L.stale,openSide:sd?sd.openSide:null,crossedAt:sd?sd.crossedAt:null}; });
6216  const none=(j.snapshot||[]).filter(r=>!r[b]).map(r=>r.ticker);
6217  const val=(x,c)=>{ switch(c){ case 'tk':return x.tk; case 'spot':return x.spot; case 'flip':return x.flip; case 'dist':return Math.abs(x.dist); case 'net':return x.net; case 'up':return x.up?x.up.distPct:999; case 'down':return x.down?Math.abs(x.down.distPct):999; case 'cross':return x.crossedAt||0; default:return 0; } };
6218  const srt=(arr)=>arr.sort((p,q)=>{ const a=val(p,_gfSort.col), c=val(q,_gfSort.col); if(a===c) return p.tk<q.tk?-1:1; return (a<c?-1:1)*_gfSort.dir; });
6219  const above=srt(rows.filter(r=>r.side==='above')), below=srt(rows.filter(r=>r.side==='below'));
6220  const th=(lbl,c,t)=>'<th class="'+(c==='tk'?'':'num')+'" style="cursor:pointer;white-space:nowrap" onclick="gflipSort(\''+c+'\')" title="'+(t||'sort')+'">'+lbl+(_gfSort.col===c?(_gfSort.dir>0?' ▲':' ▼'):'')+'</th>';
6221  const table=(list,lbl,col)=>{
6222    let t='<div style="margin-bottom:18px"><div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"><span style="font-size:13px;font-weight:800;color:'+col+'">'+lbl+'</span><span style="font-size:11px;color:#cbd5e1">'+list.length+' names</span></div>';
6223    if(!list.length){ return t+'<div class="col-loading">None.</div></div>'; }
6224    t+='<div class="trk-tblwrap"><table class="trk-tbl gf-bright"><thead><tr>'+th('Ticker','tk')+th('Spot','spot')+th('G-Flip','flip')+th('Dist','dist','Spot vs flip, percent')+th('King','king')+'<th class="num">Call Wall</th><th class="num">Put Wall</th>'+th('Next up','up','Nearest King / Call Wall / Put Wall above spot')+th('Next down','down','Nearest level below spot')+th('Net GEX','net')+'<th>Open side</th>'+th('Crossed','cross','Last side change today')+(b==='dte'?'<th>Expiry</th>':'')+'</tr></thead><tbody>';
6225    for(const r of list){
6226      const lvl=(n)=>n?('<span style="color:#e2e8f0;font-weight:700">'+_gfN(n.level,2)+'</span> <span style="color:#cbd5e1;font-size:10px">'+n.what+' '+(n.distPct>=0?'+':'')+n.distPct.toFixed(2)+'%</span>'):'—';
6227      const flipped=r.openSide&&r.openSide!==r.side;
6228      t+='<tr onclick="setSyncedTicker(\''+r.tk+'\')" style="cursor:pointer" title="Open '+r.tk+' on the chart">'
6229        +'<td class="tk">'+r.tk+(r.stale?' <span style="color:#fbbf24;font-size:9px" title="WS store not updating - last known state">stale</span>':'')+'</td>'
6230        +'<td class="num" style="color:#e2e8f0">'+_gfN(r.spot)+'</td>'
6231        +'<td class="num" style="color:#a855f7;font-weight:700">'+_gfN(r.flip)+'</td>'
6232        +'<td class="num">
6232'+_gfPct(r.dist)+'</td>'
6233        +'<td class="num" style="color:#fbbf24;font-weight:700">'+_gfN(r.king,2)+'</td>'
6234        +'<td class="num" style="color:#22c55e">'+_gfN(r.cw,2)+'</td>'
6235        +'<td class="num" style="color:#ef4444">'+_gfN(r.pw,2)+'</td>'
6236        +'<td class="num">'+lvl(r.up)+'</td><td class="num">'+lvl(r.down)+'</td>'
6237        +'<td class="num" style="color:'+(r.net>=0?'#22c55e':'#ef4444')+'">'+_gfBig(r.net)+'</td>'
6238        +'<td>'+(r.openSide?'<span style="color:'+(r.openSide==='above'?'#22c55e':'#ef4444')+';font-weight:700;font-size:10px">'+r.openSide.toUpperCase()+'</span>':'—')+'</td>'
6239        +'<td>'+(flipped?'<span style="color:#fbbf24;font-weight:800;font-size:10px">'+_gfT(r.crossedAt)+' '+(r.side==='above'?'▲':'▼')+'</span>':(r.crossedAt?'<span style="color:#cbd5e1;font-size:10px">'+_gfT(r.crossedAt)+'</span>':'—'))+'</td>'
6240        +(b==='dte'?'<td style="color:#cbd5e1;font-size:10px">'+(r.exp||'—')+'</td>':'')
6241        +'</tr>';
6242    }
6243    return t+'</tbody></table></div></div>';
6244  };
6245  h+=table(above,'ABOVE the gamma flip - positive gamma, dealers dampen, price drifts to the magnets','#22c55e');
6246  h+=table(below,'BELOW the gamma flip - negative gamma, moves accelerate','#ef4444');
6247  if(none.length) h+='<div style="font-size:11px;color:#cbd5e1">No '+(b==='dte'?'0DTE':'ALL')+' flip yet: '+none.join(', ')+'</div>';
6248  el.innerHTML=h;
6249}
6250function _gfMins(m){ if(m==null) return '—'; const pt=m-180; const hh=Math.floor(pt/60), mm=pt%60; return ((hh%12)||12)+':'+String(mm).padStart(2,'0')+(hh>=12?' PM':' AM'); }
6251function _gfBig(v){ if(v==null) return '—'; const a=Math.abs(v); const s=a>=1e9?(a/1e9).toFixed(2)+'B':a>=1e6?(a/1e6).toFixed(1)+'M':a>=1e3?(a/1e3).toFixed(0)+'K':String(a); return (v<0?'-':'')+'$'+s; }
6252// Plays today = the TRACKER rows (source of truth), joined to the cross log when the cross is still
6253// in it. The cross log is capped per day, so plays must never depend on it (user 2026-09-04: "no
6254// G-Flip plays yet today" while three were open).
6255function _gfPlaysHtml(day){
6256  const rows=(((_gfData||{}).tracker)||[]).slice().sort((a,b)=>(b.generated||0)-(a.generated||0));
6257  if(!rows.length) return '<div class="col-loading">No G-Flip plays yet today.</div>';
6258  const xs=((day&&day.crosses)||[]).filter(c=>c.played); const xby={}; xs.forEach(c=>{ xby[c.ticker]=c; });
6259  let t='<div class="trk-tblwrap"><table class="trk-tbl gf-bright"><thead><tr><th>Time</th><th>Ticker</th><th>Basis</th><th>Side</th><th class="num">Entry</th><th class="num">Flip</th><th class="num">Target</th><th class="num">Stop</th><th>Contract</th><th class="num" title="Option premium at the cross (entry basis)">Prem</th><th class="num" title="Live option mark now (exit premium once resolved)">Prem now</th><th>Status</th></tr></thead><tbody>';
6260  for(const r of rows){
6261    const c=xby[r.ticker]||null, cs=r.contract||{}, long=r.dir==='LONG';
6262    const ep=cs.prem!=null?+cs.prem:null, now=(r.status==='open')?r.optNow:(r.exitPrem!=null?r.exitPrem:r.optNow);
6263    const pl=(ep>0&&now>0)?((now-ep)/ep*100):null;
6264    const st=r.status==='void'?'<span style="color:#fbbf24;font-weight:700" title="'+(r.voidWhy||'')+'">VOID</span> <span style="color:#cbd5e1;font-size:10px">'+(r.voidWhy||'')+'</span>'
6265      :r.status==='open'?((r.pending?'<span style="color:#cbd5e1;font-weight:700">PENDING</span>':'<span style="color:#22d3ee;font-weight:700">OPEN</span>')+(r.plPct!=null?' <b style="color:'+(r.plPct>=0?'#22c55e':'#ef4444')+'">'+(r.plPct>=0?'+':'')+r.plPct+'%</b>':''))
6266      :('<span class="trk-pill '+(r.status==='win'?'win':'loss')+'" style="font-size:9px">'+String(r.status).toUpperCase()+'</span>'+(r.plPct!=null?' <b style="color:'+(r.plPct>=0?'#22c55e':'#ef4444')+'">'+(r.plPct>=0?'+':'')+r.plPct+'%</b>':''));
6267    t+='<tr onclick="setSyncedTicker(\''+r.ticker+'\')" style="cursor:pointer"><td style="color:#cbd5e1;font-size:11px">'+_gfT(r.generated)+'</td><td class="tk">'+r.ticker+'</td>'
6268      +'<td><span style="font-size:10px;font-weight:800;color:'+(r.build==='0DTE'?'#22d3ee':'#cbd5e1')+'">'+(r.build||'—')+'</span></td>'
6269      +'<td><span style="font-weight:800;color:'+(long?'#22c55e':'#ef4444')+'">'+(long?'▲ LONG':'▼ SHORT')+'</span></td>'
6270      +'<td class="num" style="color:#e2e8f0">'+_gfN(r.entry)+'</td><td class="num" style="color:#a855f7">'+(c?_gfN(c.flip):(r.gflip&&r.gflip.flip!=null?_gfN(r.gflip.flip):'—'))+'</td>'
6271      +'<td class="num" style="color:#22c55e">'+_gfN(r.target)+'</td><td class="num" style="color:#ef4444">'+_gfN(r.stop)+'</td>'
6272      +'<td style="font-size:11px;color:#e2e8f0">'+(cs.strike?('<span class="cp '+(cs.type==='CALL'?'c':'p')+'">'+(cs.type==='CALL'?'C':'P')+'</span> '+cs.strike+' · '+String(cs.expiry||'').slice(5).replace('-','/')):'—')+'</td>'
6273      +'<td class="num" style="color:#e2e8f0">'+(ep!=null?('$'+_gfN(ep)):'—')+'</td>'
6274      +'<td class="num">'+(now!=null?('<b style="color:'+(pl!=null&&pl<0?'#ef4444':'#22c55e')+'">$'+_gfN(now)+'</b>'+(pl!=null?(' <span style="font-size:10px;color:'+(pl>=0?'#22c55e':'#ef4444')+'">'+(pl>=0?'+':'')+pl.toFixed(0)+'%</span>'):'')):'—')+'</td>'
6275      +'<td style="font-size:11px;white-space:nowrap">'+st+'</td></tr>';
6276  }
6277  return t+'</tbody></table></div>';
6278}
6279function _gfCrossesHtml(day,playsOnly){
6280  const list=((day&&day.crosses)||[]).slice().reverse().filter(c=>!playsOnly||c.played);
6281  if(!list.length) return '<div class="col-loading">'+(playsOnly?'No G-Flip plays yet today.':'No gamma-flip crosses yet today.')+'</
6281div>';
6282  const trk={}; (((_gfData||{}).tracker)||[]).forEach(r=>{ trk[r.ticker]=r; });
6283  const stat=(c)=>{ if(!c.played) return ''; const r=trk[c.ticker]; if(!r) return '<span style="color:#fbbf24">not in tracker</span>';
6284    if(r.status==='void') return '<span style="color:#fbbf24;font-weight:700" title="'+(r.voidWhy||'')+'">VOID</span> <span style="color:#cbd5e1;font-size:10px">'+(r.voidWhy||'')+'</span>';
6285    if(r.status==='open') return (r.pending?'<span style="color:#cbd5e1;font-weight:700">PENDING</span>':'<span style="color:#22d3ee;font-weight:700">OPEN</span>')+(r.plPct!=null?' <b style="color:'+(r.plPct>=0?'#22c55e':'#ef4444')+'">'+(r.plPct>=0?'+':'')+r.plPct+'%</b>':'');
6286    return '<span class="trk-pill '+(r.status==='win'?'win':'loss')+'" style="font-size:9px">'+String(r.status).toUpperCase()+'</span>'+(r.plPct!=null?' <b style="color:'+(r.plPct>=0?'#22c55e':'#ef4444')+'">'+(r.plPct>=0?'+':'')+r.plPct+'%</b>':''); };
6287  let t='<div class="trk-tblwrap"><table class="trk-tbl gf-bright"><thead><tr><th>Time</th><th>Ticker</th><th>Basis</th><th>Cross</th><th class="num">Spot</th><th class="num">Flip</th><th class="num">Target</th><th class="num">Stop</th><th>Contract</th><th class="num" title="Option premium at the cross (entry basis)">Prem</th><th class="num" title="Live option mark now (exit premium once resolved)">Prem now</th><th>Play</th><th>Tracker</th></tr></thead><tbody>';
6288  for(const c of list){
6289    const up=c.dir==='up';
6290    t+='<tr onclick="setSyncedTicker(\''+c.ticker+'\')" style="cursor:pointer"><td style="color:#cbd5e1;font-size:11px">'+_gfT(c.t)+'</td><td class="tk">'+c.ticker+'</td>'
6291      +'<td><span style="font-size:10px;font-weight:800;color:'+(c.basis==='dte'?'#22d3ee':'#cbd5e1')+'">'+(c.basis==='dte'?'0DTE':'ALL')+'</span></td>'
6292      +'<td><span style="font-weight:800;color:'+(up?'#22c55e':'#ef4444')+'">'+(up?'▲ ABOVE':'▼ BELOW')+'</span></td>'
6293      +'<td class="num" style="color:#e2e8f0">'+_gfN(c.spot)+'</td><td class="num" style="color:#a855f7">'+_gfN(c.flip)+'</td>'
6294      +'<td class="num" style="color:#22c55e">'+_gfN(c.target)+'</td><td class="num" style="color:#ef4444">'+_gfN(c.stop)+'</td>'
6295      +'<td style="font-size:11px;color:#e2e8f0">'+(c.contract?('<span class="cp '+(c.contract.type==='CALL'?'c':'p')+'">'+(c.contract.type==='CALL'?'C':'P')+'</span> '+c.contract.strike+' · '+String(c.contract.expiry||'').slice(5).replace('-','/')+' · $'+_gfN(c.contract.prem)+(c.grade?' · '+c.grade+c.score:'')):'—')+'</td>'
6296      +(function(){ const r=trk[c.ticker]; const ep=r&&r.contract&&r.contract.prem!=null?r.contract.prem:(c.contract&&c.contract.prem); const now=r?(r.status==='open'?r.optNow:(r.exitPrem!=null?r.exitPrem:r.optNow)):null; const pl=(ep>0&&now>0)?((now-ep)/ep*100):null; return '<td class="num" style="color:#e2e8f0">'+(ep!=null?('$'+_gfN(ep)):'—')+'</td><td class="num">'+(now!=null?('<b style="color:'+(pl!=null&&pl<0?'#ef4444':'#22c55e')+'">$'+_gfN(now)+'</b>'+(pl!=null?(' <span style="font-size:10px;color:'+(pl>=0?'#22c55e':'#ef4444')+'">'+(pl>=0?'+':'')+pl.toFixed(0)+'%</span>'):'')):'—')+'</td>'; })()
6297      +'<td style="font-size:11px">'+(c.played?'<span style="color:#22c55e;font-weight:800">PLAY</span>':'<span style="color:#fbbf24">'+(c.why||'pending')+'</span>')+'</td>'
6298      +'<td style="font-size:11px;white-space:nowrap">'+stat(c)+'</td></tr>';
6299  }
6300  return t+'</tbody></table></div>';
6301}
6302// ── PRINTS tab (admin, 2026-09-04): Discovery-style single prints off our own firehose - line-numbered,
6303// ET time with seconds, size / value / score / flags, GF = also on our dashboard flow, and the tracked
6304// entry -> current premium, P&L and status. Right column = the Prints P&L panel (win rate, record, net). ──
6305let _prData=null, _prTimer=null, _prDay='today', _prFilter='all';
6306function setPrintsDay(d){ _prDay=d||'today'; renderPrints(); }
6307function setPrintsFilter(f){ _prFilter=(f==='trk'||f==='gf')?f:'all'; renderPrints(); }
6308function loadPrints(){
6309  const el=document.getElementById('printsBody'); if(!el) return;
6310  fetch('/api/prints').then(r=>r.json()).then(j=>{
6311    if(j&&j.error){ el.innerHTML='<div class="col-loading col-error">
6311'+j.error+'</div>'; return; }
6312    _prData=j; renderPrints();
6313    _loadPnlPanel('printsTracker','prints','#f59e0b','Whales P&amp;L','Prints scoring '+((j.cfg&&j.cfg.trackMinScore)||20)+'+ are tracked from the print ask with the Setups contract exits. Portal only - nothing goes to Discord or Robinhood.');
6314  }).catch(()=>{ el.innerHTML='<div class="col-loading col-error">Could not load prints.</div>'; });
6315  if(_prTimer) clearInterval(_prTimer);
6316  _prTimer=setInterval(()=>{ if(gfLive('ideas')&&_ideasTab==='prints') loadPrints(); else { clearInterval(_prTimer); _prTimer=null; } },20000);
6317}
6318// The Whales tab kept its own two time helpers pinned to Eastern while the rest of the app renders
6319// in the viewer's own zone (user 2026-09-08). One shared source now, so a print reads in your time.
6320function _prTZ(){ try{ return (typeof getUserTZ==='function')?getUserTZ():'America/New_York'; }catch(e){ return 'America/New_York'; } }
6321function _prTime(ms){ if(!ms) return '—'; const d=new Date(ms); return d.toLocaleDateString('en-US',{timeZone:_prTZ(),month:'numeric',day:'numeric',year:'numeric'})+', '+d.toLocaleTimeString('en-US',{timeZone:_prTZ(),hour:'numeric',minute:'2-digit',second:'2-digit'}); }
6322function _prClock(ms){ if(!ms) return '—'; return new Date(ms).toLocaleTimeString('en-US',{timeZone:_prTZ(),hour:'numeric',minute:'2-digit',second:'2-digit'}); }
6323function _prVal(v){ if(v==null||!isFinite(v)) return '—'; return v>=1e6?'$'+(v/1e6).toFixed(2)+' Million':'$'+(v/1e3).toFixed(1)+'K'; }
6324function _prPl(t){ if(!t) return {ep:null,now:null,pl:null,usd:null}; const ep=t.prem!=null?+t.prem:null; const now=(t.status==='open')?t.optNow:(t.exitPrem!=null?t.exitPrem:t.optNow); const pl=(ep>0&&now>0)?((now-ep)/ep*100):(t.plPct!=null?+t.plPct:null); const usd=(ep>0&&now>0)?Math.round((now-ep)*100):(t.plUsd!=null?+t.plUsd:null); return {ep,now,pl,usd}; }
6325function _prTrkHtml(p){
6326  const t=p.tracker;
6327  if(!t){ const why=p.trk==='cap'?'daily track cap reached':p.trk==='dup'?'contract already open':p.trk==='sold'?'sold, not bought (ask-side '+(p.askPct||0)+'%)':p.trk==='nospot'?'no spot on the print':p.trk==='skipped'?'tracker skipped it':'score below the track floor'; return '<span style="color:#cbd5e1;font-size:10px">'+why+'</span>'; }
6328  const x=_prPl(t);
6329  const st=t.status==='void'?'<span style="color:#fbbf24;font-weight:700" title="'+(t.voidWhy||'')+'">VOID</span>':t.status==='open'?'<span style="color:#f59e0b;font-weight:800">OPEN</span>':'<span class="trk-pill '+(t.status==='win'?'win':'loss')+'" style="font-size:9px">'+String(t.status).toUpperCase()+(t.booked?' · BOOK':'')+'</span>';
6330  return '<div style="white-space:nowrap">'+st+' <span style="color:#e2e8f0">$'+(x.ep!=null?x.ep.toFixed(2):'—')+' → $'+(x.now!=null?(+x.now).toFixed(2):'—')+'</span>'
6331    +(x.pl!=null?' <b style="color:'+(x.pl>=0?'#22c55e':'#ef4444')+'">'+(x.pl>=0?'+':'')+x.pl.toFixed(0)+'%'+(x.usd!=null?' ('+(x.usd>=0?'+$':'-$')+Math.abs(x.usd).toLocaleString()+')':'')+'</b>':'')+'</div>'
6332    +(t.plPeakPct!=null?'<div style="font-size:10px"><span style="color:#5ef08a">peak '+(t.plPeakPct>=0?'+':'')+t.plPeakPct+'%</span> <span style="color:#fca5a5;margin-left:6px">dip '+(t.plMinPct!=null?t.plMinPct:'—')+'%</span></div>':'');
6333}
6334function renderPrints(){
6335  const el=document.getElementById('printsBody'); if(!el||!_prData) return;
6336  const j=_prData, cfg=j.cfg||{};
6337  const days=[{date:j.date,prints:j.prints||[]}].concat(j.history||[]);
6338  const cur=(_prDay==='today')?days[0]:(days.find(d=>d.date===_prDay)||days[0]);
6339  const chip=(on,lbl,fn,title)=>'<button class="tf-btn'+(on?' active':'')+'" onclick="'+fn+'" style="font-size:11px;padding:4px 12px" title="'+(title||'')+'">'+lbl+'</button>';
6340  const all=(cur.prints||[]);
6341  let h='<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:10px">'
6342    +'<span style="font-size:11px;color:#cbd5e1;font-weight:700;margin-right:4px">Day</span>'
6343    +days.map((d,i)=>chip((_prDay==='today'&&i===0)||_prDay===d.date, (i===0?'Today · ':'')+(d.date||'—')+' ('+(d.prints||[]).length+')', "setPrintsDay('"+(i===0?'today':d.date)+"')")).join('')
6344    +'<span style="width:14px"></span><span style="font-size:11px;color:#cbd5e1;font-weight:700;margin-right:4px">Show</span>'
6345    +chip(_prFilter==='all','All',"setPrintsFilter('all')")+chip(_prFilter==='trk','Tracked',"setPrintsFilter('trk')")
6346    +'<span style="margin-left:auto;font-size:11px;color:#cbd5e1">'+all.length+' whales · '+all.filter(p=>p.tracker).length+' tracked'+(j.rth?' · <b style="color:#22c55e">LIVE</b>':' · <span style="color:#cbd5e1">market closed</span>')+'</span></div>';
6347  h+='<div style="font-size:10.5px;color:#cbd5e1;margin-bottom:12px;line-height:1.5">'+(cfg.source==='fills'?('Rule: one contract accumulating <b style="color:#e2e8f0">\u2265 '+_prVal(cfg.minValue)+'</b> of premium inside '+Math.round((cfg.windowMs||60000)/1000)+'s on our raw fill feed (spread legs dropped)'):('Source: <b style="color:#e2e8f0">UW flow alerts</b> (RepeatedHits sweeps / blocks - the same feed Discovery Prints posts from) with value <b style="color:#e2e8f0">\u2265 '+_prVal(cfg.minValue)+'</b>'))+' \u00b7 single-leg \u00b7 \u2264 '+cfg.maxDte+' DTE \u00b7 at/out of the money \u00b7 skips '+(cfg.skip||[]).join(', ')+'. Time = UW alert time (Discovery stamps its poll, ~1 min later). Tracked from the print ask when score \u2265 '+cfg.trackMinScore+' AND bought (\u2265 '+(cfg.trackMinAskPct||0)+'% at the ask), max '+cfg.maxTrack+'/day, Setups contract exits: book +100 \u00b7 trail 50\u219230 \u00b7 stop -40.</div>';
6348  let rows=all.slice();
6349  if(_prFilter==='trk') rows=rows.filter(p=>p.tracker); else if(_prFilter==='gf') rows=rows.filter(p=>p.gf);
6350  rows.sort((a,b)=>(b.n||0)-(a.n||0));
6351  if(!rows.length){ el.innerHTML=h+'<div class="col-loading">No whale prints '+(cur.date===j.date?'yet today':'on '+cur.date)+(_prFilter!=='all'?' for this filter':'')+'.</div>'; return; }
6352  h+='<div class="trk-tblwrap"><table class="trk-tbl gf-bright"><thead><tr><th class="num">#</th><th>Time (ET)</th><th>Ticker</th><th>Contract</th><th class="num" title="Last fill price · the ask on the book at that moment">@</th><th class="num">Size</th><th class="num">Value</th><th class="num" title="Underlying at the print · strike distance out of the money">Spot · OTM</th><th class="num">Score</th><th>Flags</th><th>Tracker · entry → now</th></tr></thead><tbody>';
6353  for(const p of rows){
6354    const cp=p.type==='CALL';
6355    h+='<tr onclick="setSyncedTicker(\''+p.ticker+'\')" style="cursor:pointer" title="Open '+p.ticker+' on the chart">'
6356      +'<td class="num" style="color:#cbd5e1;font-weight:800">'+p.n+'</td>'
6357      +'<td style="color:#e2e8f0;font-size:11px;white-space:nowrap">'+_prTime(p.at)+'</td>'
6358      +'<td class="tk">'+p.ticker+'</td>'
6359      +'<td style="font-size:11px;color:#e2e8f0;white-space:nowrap"><span class="cp '+(cp?'c':'p')+'">'+(cp?'C':'P')+'</span> '+p.strike+' · '+String(p.expiry||'').slice(5).replace('-','/')+' <span style="color:#cbd5e1;font-size:10px">'+p.dte+'d</span></td>'
6360      +'<td class="num" style="color:#e2e8f0">$'+(+p.px).toFixed(2)+(p.ask?'<div style="font-size:9px;color:#cbd5e1">ask '+(+p.ask).toFixed(2)+'</div>':'')+'</td>'
6361      +'<td class="num" style="color:#e2e8f0">
6361'+(+p.size).toLocaleString()+'x'+(p.fills>1?'<div style="font-size:9px;color:#cbd5e1">'+p.fills+' fills</div>':'')+'</td>'
6362      +'<td class="num" style="color:#f59e0b;font-weight:800">'+_prVal(p.value)+'</td>'
6363      +'<td class="num" style="color:#cbd5e1;font-size:11px">'+(p.spot?(+p.spot).toFixed(2):'—')+(p.otmPct!=null?'<div style="font-size:9px">'+(p.otmPct>=0?'+':'')+(+p.otmPct).toFixed(1)+'%</div>':'')+'</td>'
6364      +'<td class="num" style="font-weight:800;color:'+(p.score>=60?'#22c55e':p.score>=35?'#eab308':'#cbd5e1')+'">'+p.score+'</td>'
6365      +'<td style="font-size:10.5px;color:#cbd5e1"'+(p.rule?' title="UW rule: '+p.rule+(p.tradeCount?' \u00b7 '+p.tradeCount+' trades':'')+(p.via?' \u00b7 via '+p.via:'')+'"':'')+'>'+(p.flags||'none')+(p.askPct!=null?'<div style="font-size:9px">ask-side '+p.askPct+'%</div>':'')+'</td>'
6366      +'<td style="font-size:11px">'+_prTrkHtml(p)+'</td></tr>';
6367  }
6368  el.innerHTML=h+'</tbody></table></div>';
6369}
6370// Ghost Mode right panel - compact prints list, newest first; GF chip filters to our own flow.
6371let _upCache=null,_upAt=0,_upBusy=false,_upGfOnly=false;
6372function setUltraPrintsGf(v){ _upGfOnly=!!v; renderUltraPrints(); }
6373function renderUltraPrints(){
6374  const el=document.getElementById('ultraPrintsList'); if(!el) return;
6375  if(!(typeof isAdminUser!=='undefined'&&isAdminUser)){ el.innerHTML='<div class="col-loading">Admin only.</div>'; return; }
6376  const paint=()=>{
6377    const j=_upCache; if(!j){ el.innerHTML='<div class="col-loading">Loading…</div>'; return; }
6378    let rows=(j.prints||[]).slice().sort((a,b)=>(b.n||0)-(a.n||0)); if(_upGfOnly) rows=rows.filter(p=>p.gf);
6379    document.querySelectorAll('[data-upgf]').forEach(x=>x.classList.toggle('active',(x.dataset.upgf==='1')===_upGfOnly));
6380    if(!rows.length){ el.innerHTML='<div class="col-loading">No whale prints yet today.</div>'; return; }
6381    let h='';
6382    for(const p of rows){ const t=p.tracker, x=_prPl(t);
6383      h+='<div onclick="setSyncedTicker(\''+p.ticker+'\')" style="padding:4px 8px;border-bottom:1px solid rgba(148,163,184,.08);cursor:pointer;font-size:11px">'
6384        +'<div style="display:flex;align-items:center;gap:5px"><span style="color:#cbd5e1;font-size:9px;font-weight:800">#'+p.n+'</span><b style="color:#e2e8f0">'+p.ticker+'</b>'
6385        +'<span class="cp '+(p.type==='CALL'?'c':'p')+'">'+(p.type==='CALL'?'C':'P')+'</span><span style="color:#e2e8f0">'+p.strike+'</span><span style="color:#cbd5e1;font-size:10px">'+String(p.expiry||'').slice(5).replace('-','/')+'</span>'
6386        +'<span style="margin-left:auto;color:#f59e0b;font-weight:800">'+_prVal(p.value)+'</span></div>'
6387        +'<div style="display:flex;align-items:center;gap:6px;font-size:10px;color:#cbd5e1"><span>'+_prClock(p.at)+' ET</span><span>@'+(+p.px).toFixed(2)+' x'+(+p.size).toLocaleString()+'</span><span>sc '+p.score+'</span>'
6388        +(t?'<span style="margin-left:auto;font-weight:700;color:'+(x.pl!=null&&x.pl<0?'#ef4444':'#22c55e')+'">'+(t.status==='open'?'':String(t.status).toUpperCase()+' ')+(x.ep!=null?'$'+x.ep.toFixed(2):'')+(x.now!=null?'→$'+(+x.now).toFixed(2):'')+(x.pl!=null?' '+(x.pl>=0?'+':'')+x.pl.toFixed(0)+'%':'')+'</span>':'')+'</div></div>'; }
6389    el.innerHTML=h;
6390  };
6391  if(_upCache && Date.now()-_upAt<20000){ paint(); return; }
6392  if(_upBusy){ paint(); return; }
6393  _upBusy=true; paint();
6394  fetch('/api/prints').then(r=>r.json()).then(j=>{ if(!(j&&j.error)){ _upCache=j; _upAt=Date.now(); } paint(); }).catch(()=>{}).finally(()=>{ _upBusy=false; });
6395}
6396setInterval(()=>{ const p=document.getElementById('ultraPrintsPane'); if(ultraRightTab==='prints' && p && p.style.display!=='none') renderUltraPrints(); },20000);
6397// ── EOD POWER HOUR tab (admin, 2026-09-04): the 12:00-12:55 PT runner / fade plays - entry, noon readings, exit, result. ──
6398let _eodData=null, _eodTimer=null;
6399function loadEod(){
6400  const el=document.getElementById('eodBody'); if(!el) return;
6401  fetch('/api/eod').then(r=>r.json()).then(j=>{
6402    if(j&&j.error){ el.innerHTML='<div class="col-loading col-error">'+j.error+'</div>'; return; }
6403    _eodData=j; renderEod();
6404    _loadPnlPanel('eodTracker','eod','#f59e0b','POWER HOUR P&amp;L','Plays fire at 12:00 PM PT (runners + fades) and are flat by 12:55. Portal only - nothing goes to Discord.');
6405  }).catch(()=>{ el.innerHTML='<div class="col-loading col-error">Could not load power-hour plays.</div>'; });
6406  if(_eodTimer) clearInterval(_eodTimer);
6407  _eodTimer=setInterval(()=>{ if(gfLive('ideas')&&_ideasTab==='eod') loadEod(); else { clearInterval(_eodTimer); _eodTimer=null; } },20000);
6408}
6409function renderEod(){
6410  const el=document.getElementById('eodBody'); if(!el||!_eodData) return;
6411  const j=_eodData, w=j.window||{}, day=j.day||{};
6412  const pt=m=>{ if(m==null) return '—'; const x=m-180, hh=Math.floor(x/60), mm=x%60; return ((hh%12)||12)+':'+String(mm).padStart(2,'0')+(hh>=12?' PM':' AM'); };
6413  const st=(w.mins<w.entry)?'<span style="color:#cbd5e1">scan fires at '+pt(w.entry)+' PT</span>':(w.mins<w.exit)?'<b style="color:#22c55e">LIVE - flat at '+pt(w.exit)+' PT</b>':'<span style="color:#fbbf24">closed for today</span>';
6414  let h='<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;font-size:11px;color:#cbd5e1">'
6415    +'<span style="font-size:13px;font-weight:800;color:#f59e0b">EOD POWER HOUR</span><span>'+st+'</span>'
6416    +'<span style="margin-left:auto">'+(j.universe||0)+' names scanned · runner = up '+w.runDay+'%+ since open and within '+w.runNearHi+'% of the day high · fade = 11-12 move '+w.fadeMom+'%+ below the gamma flip · '+(w.dte?w.dte[0]+'-'+w.dte[1]:'1-7')+' DTE, $'+w.minPrem+'-$'+w.maxPrem+'</span></div>';
6417  h+='<div style="font-size:11px;color:#cbd5e1;margin-bottom:12px">Backtest (929 ticker-days, 7 sessions): runner 64% right avg +0.27%, fade 67% right avg +0.26%; chasing the 11-12 move 40%. Shadow only.</div>';
6418  if(day&&day.date){
6419    h+='<div style="margin-bottom:14px;font-size:11px;color:#cbd5e1">Today '+day.date+': '+(day.built?('scanned '+_gfT(day.builtAt)+' - '+(day.plays||[]).length+' plays, '+(day.skipped||[]).length+' skipped'):'not scanned yet')+(day.closedAt?(' - flattened '+_gfT(day.closedAt)):'')+'</div>';
6420    if((day.skipped||[]).length){
6421      h+='<details style="margin-bottom:14px"><summary style="cursor:pointer;font-size:11px;color:#e2e8f0;font-weight:700">Qualified but skipped ('+day.skipped.length+')</summary><div style="margin-top:6px;font-size:11px;color:#cbd5e1">'
6422        +day.skipped.map(s=>'<div><b style="color:#e2e8f0">'+s.ticker+'</b>'+(s.lane?' <span style="color:#f59e0b">'+s.lane+'</span>':'')+' - '+s.why+(s.dayMovePct!=null?(' <span style="color:#8fa0b8">day '+s.dayMovePct+'% · 11-12 '+s.momPct+'% · '+(s.regime||'')+'</span>'):'')+'</div>').join('')+'</div></details>';
6423    }
6424  }
6425  const rows=j.rows||[];
6426  if(!rows.length){ el.innerHTML=h+'<div class="col-loading">No power-hour plays on record yet - the scan fires at '+pt(w.entry)+' PT.</div>'; return; }
6427  const byDate={}; rows.forEach(r=>{ (byDate[r.date]||(byDate[r.date]=[])).push(r); });
6428  for(const d of Object.keys(byDate).sort().reverse()){
6429    const list=byDate[d]; let usd=0, w2=0, l2=0; list.forEach(r=>{ if(r.status==='win') w2++; else if(r.status==='loss') l2++; if(r.plUsd!=null&&r.status!=='void') usd+=+r.plUsd; });
6430    h+='<div style="display:flex;align-items:center;gap:10px;margin:14px 0 6px"><span style="font-size:13px;font-weight:800;color:#e2e8f0">'+d+'</span><span style="font-size:11px;color:#cbd5e1">'+list.length+' plays · '+w2+'W-'+l2+'L · <b style="color:'+(usd>=0?'#22c55e':'#ef4444')+'">'+(usd>=0?'+$':'-$')+Math.abs(usd)+'</b>/ct</span></div>';
6431    h+='<div class="trk-tblwrap"><table class="trk-tbl gf-bright"><thead><tr><th>Entry</th><th>Ticker</th><th>Lane</th><th>Side</th><th class="num">Spot</th><th class="num" title="Open to noon">Day</th><th class="num" title="11:00 to 12:00 PT move">11-12</th><th class="num" title="Below the day high at noon">Off hi</th><th title="Gamma regime at noon">Gamma</th><th class="num">Target</th><th class="num">Stop</th><th>Contract</th><th class="num">Prem</th><th class="num" title="Live mark while open, exit premium once closed">Prem now / Exit</th><th class="num">P&amp;L</th><th>Exit</th><th>Result</th></tr></thead><tbody>';
6432    for(const r of list){
6433      const cs=r.contract||{}, p=r.ph||{}, long=r.dir==='LONG', ep=cs.prem!=null?+cs.prem:null;
6434      const now=r.status==='open'?r.optNow:(r.exitPrem!=null?r.exitPrem:r.optNow);
6435      const pl=r.plPct!=null?r.plPct:((ep>0&&now>0)?Math.round((now-ep)/ep*100):null);
6436      const res=r.status==='open'?'<span style="color:#22d3ee;font-weight:800;font-size:10px">LIVE</span>'
6437        :r.status==='void'?'<span style="color:#fbbf24;font-weight:700" title="'+(r.voidWhy||'')+'">VOID</span>'
6438        :('<span class="trk-pill '+(r.status==='win'?'win':'loss')+'" style="font-size:9px">'+String(r.status).toUpperCase()+'</span>'+(r.eodFlat?' <span style="font-size:9px;color:#cbd5e1">12:55 flat</span>':(r.booked&&r.exitBasis==='peak')?' <span style="font-size:9px;color:#22c55e">+100% booked</span>':r.trailed?' <span style="font-size:9px;color:#22c55e">trail</span>':r.booked?' <span style="font-size:9px;color:#22c55e">target hit</span>':r.optStopped?' <span style="font-size:9px;color:#ef4444">-40%</span>':''));
6439      const exitSpot=((r.booked&&r.exitBasis==='peak')||r.trailed)&&r.markPrice!=null?r.markPrice:r.resolvePrice;
6440      const lane=p.lane||(r.build==='PH-FADE'?'fade':'runner');
6441      h+='<tr onclick="setSyncedTicker(\''+r.ticker+'\')" style="cursor:pointer"><td style="color:#cbd5e1;font-size:11px">'+_gfT(r.generated)+'</td><td class="tk">'+r.ticker+'</td>'
6442        +'<td><span style="font-size:10px;font-weight:800;color:'+(lane==='runner'?'#22c55e':'#f59e0b')+'">'+lane.toUpperCase()+'</span></td>'
6443        +'<td><span style="font-weight:800;color:'+(long?'#22c55e':'#ef4444')+'">'+(long?'▲ LONG':'▼ SHORT')+'</span></td>'
6444        +'<td class="num" style="color:#e2e8f0">'+_gfN(r.entry)+'</td>'
6445        +'<td class="num">'+(p.dayMovePct!=null?_gfPct(p.dayMovePct):'—')+'</td>'
6446        +'<td class="num">'+(p.momPct!=null?_gfPct(p.momPct):'—')+'</td>'
6447        +'<td class="num" style="color:#cbd5e1">'+(p.nearHiPct!=null?p.nearHiPct.toFixed(2)+'%':'—')+'</td>'
6448        +'<td style="font-size:10px;font-weight:700;color:'+(p.regime==='positive'?'#22c55e':p.regime==='negative'?'#ef4444':'#cbd5e1')+'">'+(p.regime||'—')+(p.flip!=null?' <span style="color:#a855f7">'+_gfN(p.flip)+'</span>':'')+'</td>'
6449        +'<td class="num" style="color:#22c55e">'+_gfN(r.target)+'</td><td class="num" style="color:#ef4444">'+_gfN(r.stop)+'</td>'
6450        +'<td style="font-size:11px;color:#e2e8f0">'+(cs.strike?('<span class="cp '+(cs.type==='CALL'?'c':'p')+'">'+(cs.type==='CALL'?'C':'P')+'</span> '+cs.strike+' · '+String(cs.expiry||'').slice(5).replace('-','/')+' <span style="color:#cbd5e1">('+(cs.dte!=null?cs.dte:'?')+'d)</span>'):'—')+'</td>'
6451        +'<td class="num" style="color:#e2e8f0">'+(ep!=null?('$'+_gfN(ep)):'—')+'</td>'
6452        +'<td class="num">'+(now!=null?('<b style="color:'+(pl!=null&&pl<0?'#ef4444':'#22c55e')+'">$'+_gfN(now)+'</b>'):'—')+'</td>'
6453        +'<td class="num" style="font-weight:800;color:'+(pl!=null&&pl<0?'#fca5a5':'#5ef08a')+'">'+(pl!=null?((pl>=0?'+':'')+pl+'%'):'—')+(r.plUsd!=null?'<div style="font-size:10px;opacity:.85">'+(r.plUsd>=0?'+$':'-$')+Math.abs(r.plUsd)+'</div>':(ep>0&&now>0?'<div style="font-size:10px;opacity:.85">'+((now-ep)>=0?'+$':'-$')+Math.abs(Math.round((now-ep)*100))+'</div>':''))+'</td>'
6454        +'<td style="color:#cbd5e1;font-size:11px">'+(r.resolved?_gfT(r.resolved)+(exitSpot!=null?' @ '+_gfN(exitSpot):''):(r.markPrice!=null?'now '+_gfN(r.markPrice):'—'))+'</td>'
6455        +'<td style="font-size:11px;white-space:nowrap">'+res+'</td></tr>';
6456    }
6457    h+='</tbody></table></div>';
6458  }
6459  el.innerHTML=h;
6460}
6461// ── Ghost Mode right panel: G-Flip tab (admin) - compact above/below list for both bases ──
6462let _ugBasis='dte', _ugCache=null, _ugAt=0, _ugBusy=false;
6463function setUltraGflipBasis(b){ _ugBasis=(b==='all')?'all':'dte'; renderUltraGflip(); }
6464function renderUltraGflip(){
6465  const el=document.getElementById('ultraGflipList'); if(!el) return;
6466  if(!(typeof isAdminUser!=='undefined'&&isAdminUser)){ el.innerHTML='<div class="col-loading">Admin only.</div>'; return; }
6467  const paint=()=>{
6468    const j=_ugCache; if(!j){ el.innerHTML='<div class="col-loading">Loading…</div>'; return; }
6469    const b=_ugBasis, day=j.day||{};
6470    const rows=(j.snapshot||[]).filter(r=>r[b]).map(r=>{ const L=r[b]; const sd=(day.side||{})[r.ticker+'|'+b]; return {tk:r.ticker,spot:r.spot,flip:L.flip,dist:L.distPct,side:L.side,flipped:!!(sd&&sd.openSide&&sd.openSide!==L.side),at:sd?sd.crossedAt:null}; });
6471    const grp=(list,lbl,col)=>{
6472      list.sort((p,q)=>Math.abs(p.dist)-Math.abs(q.dist));
6473      let h='<div style="padding:5px 8px 2px;font-size:10px;font-weight:800;color:'+col+';display:flex;justify-content:space-between"><span>'+lbl+'</span><span style="color:#cbd5e1">'+list.length+'</span></div>';
6474      if(!list.length) return h+'<div style="padding:2px 8px 6px;font-size:10px;color:#cbd5e1">none</div>';
6475      for(const r of list){ h+='<div onclick="setSyncedTicker(\''+r.tk+'\')" style="display:grid;grid-template-columns:52px 1fr 1fr 58px;gap:4px;padding:3px 8px;font-size:11px;cursor:pointer;border-bottom:1px solid rgba(148,163,184,.08);align-items:center">'
6476        +'<b style="color:#e2e8f0">'+r.tk+'</b><span style="color:#cbd5e1;text-align:right">'+_gfN(r.spot)+'</span><span style="color:#a855f7;text-align:right">'+_gfN(r.flip)+'</span>'
6477        +'<span style="text-align:right;font-weight:700;color:'+(r.dist>=0?'#22c55e':'#ef4444')+'">'+(r.dist>=0?'+':'')+r.dist.toFixed(2)+'%'+(r.flipped?' <span style="color:#fbbf24" title="Crossed today '+_gfT(r.at)+'">●</span>':'')+'</span></div>'; }
6478      return h;
6479    };
6480    el.innerHTML='<div style="display:grid;grid-template-columns:52px 1fr 1fr 58px;gap:4px;padding:4px 8px;font-size:9px;color:#cbd5e1;font-weight:700;border-bottom:1px solid var(--border)"><span>TICKER</span><span style="text-align:right">SPOT</span><span style="text-align:right">G-FLIP</span><span style="text-align:right">DIST</span></div>'
6481      +grp(rows.filter(r=>r.side==='above'),'ABOVE FLIP','#22c55e')+grp(rows.filter(r=>r.side==='below'),'BELOW FLIP','#ef4444')
6482      +'<div style="padding:6px 8px;font-size:9px;color:#cbd5e1">● = crossed today · plays '+(day.plays?('0DTE '+(day.plays.dte||0)+' / ALL '+(day.plays.all||0)):'0')+'</div>';
6483    document.querySelectorAll('[data-ugbasis]').forEach(x=>x.classList.toggle('active',x.dataset.ugbasis===b));
6484  };
6485  if(_ugCache && Date.now()-_ugAt<20000){ paint(); return; }
6486  if(_ugBusy){ paint(); return; }
6487  _ugBusy=true;
6487 paint();
6488  fetch('/api/gflip').then(r=>r.json()).then(j=>{ if(!(j&&j.error)){ _ugCache=j; _ugAt=Date.now(); } paint(); }).catch(()=>{}).finally(()=>{ _ugBusy=false; });
6489}
6490setInterval(()=>{ if(ultraRightTab==='gflip' && document.getElementById('ultraGflipPane') && document.getElementById('ultraGflipPane').style.display!=='none') renderUltraGflip(); },30000);
6491// ── Movers pane (chart/ultra right panel) — the day's move alerts, scoped Market / Watchlist / Both ──
6492let _umScope = (function(){ try { const v = localStorage.getItem('gf_umScope'); return (v==='market'||v==='watch'||v==='custom') ? v : 'all'; } catch (e) { return 'all'; } })();
6493function setUltraMoversScope(v){ _umSig = 'moves'; _umScope = (v==='market'||v==='watch'||v==='custom') ? v : 'all'; try { localStorage.setItem('gf_umScope', _umScope); } catch (e) {} renderUltraMovers(); }
6494// Movers pane: the scope chips show the classic move alerts; the EMA / Inside Bar chips (same
6495// row, before Custom) switch the list to the server-scanned signal feeds (/api/tf-signals).
6496let _umSig = 'moves';
6497let _tfSigCache = null, _tfSigAt = 0, _tfSigBusy = false;
6498function setUltraMoversSig(v){
6499  // 'tv' = the combined EMA & Inside Bar tab (user 2026-08-20; the two separate chips merged)
6500  // 'node' = watchlist-wide King/Call Wall/Put Wall crossings (user 2026-08-21)
6501  _umSig = (v==='tv'||v==='ema'||v==='ib'||v==='node') ? v : 'moves';
6502  renderUltraMovers();
6503}
6504function _tfSigLoad(force){
6505  if (_tfSigBusy) return;
6506  if (!force && _tfSigCache && Date.now()-_tfSigAt < 60000) return;
6507  _tfSigBusy = true;
6508  fetch('/api/tf-signals').then(r=>r.json()).then(j=>{
6509    _tfSigBusy = false;
6510    if (j && Array.isArray(j.items)) { _tfSigCache = j.items; _tfSigAt = Date.now(); if (typeof ultraRightTab!=='undefined' && ultraRightTab==='movers') renderUltraMovers(); }
6511  }).catch(()=>{ _tfSigBusy = false; });
6512}
6513// Refresh the signal feed once a minute while the Movers pane is on screen (the signals show in
6514// the merged movers list AND under their own chips).
6515setInterval(function(){ try { if (typeof ultraRightTab!=='undefined' && ultraRightTab==='movers') _tfSigLoad(); } catch(e){} }, 60000);
6516function _umSigLabel(s){
6517  if (s.label) return s.label;   // TradingView webhook alerts carry their own message text
6518  if (s.sig === 'ema') {
6519    const name = s.kind==='ema55' ? 'EMA 55 × 8/13' : s.kind==='ema200' ? 'EMA 200 × 8/13' : 'EMA 200 × 55';
6520    return name + ' · ' + ((s.tf || '1h').toUpperCase());   // scanner rows carry their own tf (3H = admin)
6521  }
6522  const tf = s.tf==='1d' ? 'Daily' : s.tf.toUpperCase();
6523  return s.kind==='form' ? ('IB formed · ' + tf + (s.hi!=null ? ' · ' + s.lo + '–' + s.hi : '')) : ('IB breakout · ' + tf);
6524}
6525function _umSigRowHtml(s){
6526  const bull = s.dir==='bull', bear = s.dir==='bear';
6527  const dcol = bull ? 'var(--green)' : bear ? 'var(--red)' : '#94a3b8';
6528  const arrow = bull ? '▲' : bear ? '▼' : '▪';
6529  const pt = new Date(new Date(s.time).toLocaleString('en-US',{timeZone:'America/Los_Angeles'}));
6530  const tstr = (pt.getMonth()+1)+'/'+pt.getDate()+' '+String(pt.getHours()).padStart(2,'0')+':'+String(pt.getMinutes()).padStart(2,'0');
6531  return '<div class="um-row" data-ticker="' + s.ticker + '" style="padding:8px 12px;border-bottom:1px solid rgba(30,41,59,0.4);cursor:pointer;font-family:Segoe UI,monospace;font-size:12px;display:flex;gap:7px;align-items:center">'
6532    + '<span style="color:' + dcol + ';font-weight:800">' + arrow + '</span>'
6533    + '<b>' + s.ticker + '</b>'
6534    + '<span style="color:' + dcol + ';font-size:11px;font-weight:700">' + _umSigLabel(s) + '</span>'
6535    + (s.price != null ? '<span style="color:#e2e8f0;font-weight:700">$' + (+s.price).toFixed(2) + '</span>' : '')
6536    + '<span style="color:#e2e8f0;margin-left:auto;font-size:11px;font-weight:700" title="Alert time (Pacific)">' + tstr + '</span></div>';
6537}
6538function _renderUmSignals(list){
6539  const cnt = document.getElementById('ultraMoversCount'); if (cnt) cnt.textContent = '';
6540  const items = (_tfSigCache||[]).filter(s => _umSig==='tv' ? (s.sig==='ema'||s.sig==='ib'||s.sig==='entry'||s.sig==='exit') : s.sig === _umSig).slice(0, 120);   // entry/exit = the Ideas engine's own fills (admin feed only)
6541  if (!_tfSigCache) { list.innerHTML = '<div style="padding:18px 12px;text-align:center;color:var(--dim);font-size:11px;font-family:Segoe UI,monospace">Loading signals…</div>'; _tfSigLoad(); return; }
6542  if (!items.length) { list.innerHTML = '<div style="padding:18px 12px;text-align:center;color:var(--dim);font-size:11px;font-family:Segoe UI,monospace;line-height:1.6">' + (_umSig==='node' ? 'No node crossings yet today — the server scans the whole watchlist every minute for King / Call Wall / Put Wall crosses during the session.' : 'No ' + (_umSig==='ema'?'EMA cross':_umSig==='ib'?'inside bar':'indicator') + ' alerts yet — TradingView alerts pointed at the dashboard webhook appear here the moment they fire.') + '</div>'; return; }
6543  list.innerHTML = items.map(_umSigRowHtml).join('');
6544  list.querySelectorAll('.um-row').forEach(el => el.addEventListener('click', () => { gfGoTicker(el.dataset.ticker, { stay: 1 }); }));
6545}
6546function renderUltraMovers(){
6547  const list = document.getElementById('ultraMoversList'); if (!list) return;
6548  // One chip row: EMA / Inside Bar light up when their feed is shown; the scope chips only
6549  // light up while the classic movers list is shown.
6550  document.querySelectorAll('[data-umsig]').forEach(b => b.classList.toggle('active', b.dataset.umsig === _umSig));
6551  document.querySelectorAll('[data-umscope]').forEach(b => b.classList.toggle('active', _umSig === 'moves' && b.dataset.umscope === _umScope));
6552  if (_umSig !== 'moves') return _renderUmSignals(list);
6553  const shown = moveAlerts.filter(x => {
6554    if (_umScope === 'all') return true;
6555    if (_umScope === 'market') return x.scope !== 'watch' && x.scope !== 'custom';
6556    return x.scope === _umScope;   // 'watch' | 'custom'
6557  });
6558  // ALL merges everything — movers of every scope + the EMA / Inside Bar alerts — into one
6559  // time-sorted stream. The scoped tabs (Market / Watchlist / Custom) show ONLY their own
6560  // movers, and indicator alerts live in their own EMA & Inside Bar tab (user 2026-08-20).
6561  if (!_tfSigCache) _tfSigLoad();
6562  // ALL merges the signal feed (ENTERED / EXIT rows for Setups + Watchlist + the TV alerts) into the
6563  // movers list. Load it on demand here too - it used to arrive only on the 60s timer, so the first
6564  // render of ALL (and every render before the timer fired) had no entry/exit rows (user 2026-09-04).
6565  if (_umScope === 'all') _tfSigLoad();
6566  const sigRows = _umScope === 'all' ? (_tfSigCache || []).slice(0, 60).map(s => ({ time: s.time || 0, html: _umSigRowHtml(s) })) : [];
6567  const cnt = document.getElementById('ultraMoversCount');
6568  const total = shown.length + sigRows.length;
6569  if (cnt) cnt.textContent = total ? total + (total === 1 ? ' alert' : ' alerts') : '';
6570  if (!total) {
6571    list.innerHTML = '<div style="padding:18px 12px;text-align:center;color:var(--dim);font-size:11px;font-family:Segoe UI,monospace;line-height:1.6">'
6572      + (moveAlerts.length ? 'No ' + (_umScope === 'all' ? '' : _umScope + ' ') + 'movers today yet.' : 'Watching live… movers appear here as tickers make their move. Configure thresholds under the 🔔 bell.') + '</div>';
6573    return;
6574  }
6575  const rows = [];
6576  for (const x of shown) {
6577    const up = x.pct >= 0;
6578    rows.push({ time: x.time || 0, html:
6579      '<div class="um-row" data-ticker="' + x.t + '" style="padding:8px 12px;border-bottom:1px solid rgba(30,41,59,0.4);cursor:pointer;font-family:Segoe UI,monospace;font-size:12px;display:flex;gap:7px;align-items:center">'
6580      + (x.scope === 'watch' ? '<span title="From your watchlist">⭐</span>' : x.scope === 'custom' ? '<span title="From your custom list">📋</span>' : '<span title="Market mover">🌐</span>')
6581      + '<b>' + x.t + '</b><span style="color:' + (up ? 'var(--green)' : 'var(--red)') + ';font-weight:700">' + (up ? '▲+' : '▼') + Math.abs(x.pct).toFixed(1) + '%</span>'
6582      + (x.price != null ? '<span style="color:var(--dim)">$' + x.price.toFixed(2) + '</span>' : '')
6583      + '<span style="color:#94a3b8;margin-left:auto;font-size:11px">' + fmtTime(x.time) + '</span></div>' });
6584  }
6585  const merged = rows.concat(sigRows).sort((a, b) => b.time - a.time);
6586  list.innerHTML = merged.map(r => r.html).join('');
6587  list.querySelectorAll('.um-row').forEach(el => el.addEventListener('click', () => { gfGoTicker(el.dataset.ticker, { stay: 1 }); }));
6588}
6589
6590function nodeAddTicker() {
6591  const input = document.getElementById('nodeAddTicker'); if (!input) return;
6592  const t = (input.value || '').trim().toUpperCase().replace(/[^A-Z.]/g, '');
6593  if (!t) return;
6594  input.value = '';
6595  const sec = sectorOfTicker(t) || 'Custom';
6596  if (!WATCHLIST[sec]) WATCHLIST[sec] = [];
6597  if (!WATCHLIST[sec].includes(t)) WATCHLIST[sec].push(t);
6598  if (typeof hiddenTickers !== 'undefined') hiddenTickers.delete(t);
6599  fetch('/api/custom-tickers/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticker: t, sector: sec }) }).catch(function () {});
6600  renderNodeAlerts();
6601  refreshNodeLevels();
6602  if (gfLive('flow') && typeof renderFlowTickers === 'function') renderFlowTickers();
6603  if (typeof _flowPullTicker === 'function') _flowPullTicker(t);   // pull its flow right away
6604}
6605function nodeDeleteTicker(t) {
6606  if (typeof deleteTickerEverywhere === 'function') deleteTickerEverywhere(t);
6607  delete nodeLevels[t];
6608  renderNodeAlerts();
6609}
6610
6611function setScanMode(mode) {
6612  scanMode = mode;
6613  document.querySelectorAll('.scan-mode-tab').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
6614  // + Add ticker shows on both watchlist tabs; every tab has its own alert bar.
6615  const add = document.getElementById('scanWatchAdd');
6616  if (add) add.style.display = (mode === 'watch' || mode === 'custom') ? 'flex' : 'none';
6617  const albar = document.getElementById('scanWatchAlertBar');
6618  if (albar) albar.style.display = 'flex';
6619  const addInp = document.getElementById('scanAddTicker');
6620  if (addInp) addInp.placeholder = mode === 'custom' ? '+ Add to custom list' : '+ Add ticker';
6621  const body = document.getElementById('scanBody'); if (body) delete body.dataset.loaded;
6622  // Every tab's alert bar reflects that list's own alert config.
6623  (alertCfg ? Promise.resolve() : loadAlertConfig()).then(populateScanAlertBar);
6624  if (mode === 'custom') loadScanWatchlist().then(loadScanner);
6625  else loadScanner();
6626}
6627// 'watch' tab = the user's full watchlist (all sectors + adds − hides) — the original behavior.
6628function userWatchlistTickers() {
6629  try { return [...new Set(Object.values(WATCHLIST).flat())].filter(t => !(typeof hiddenTickers !== 'undefined' && hiddenTickers.has(t))); }
6630  catch (e) { return []; }
6631}
6632// 'custom' tab = the user-built list (server-persisted, separate from the sector presets).
6633function customWatchlistTickers() { return scanWatchList.slice(); }
6634async function loadScanWatchlist() {
6635  try {
6636    const j = await (await fetch('/api/scan-watchlist')).json();
6637    scanWatchList = Array.isArray(j.tickers) ? j.tickers : [];
6638    scanWatchLoaded = true;
6639  } catch (e) { scanWatchList = scanWatchList || []; }
6640  return scanWatchList;
6641}
6642// The alert bar binds to the active tab's scope: 'market' | 'watch' | 'custom'.
6643function scanAlertScope() { return scanMode === 'market' ? 'market' : scanMode === 'custom' ? 'custom' : 'watch'; }
6644// Mirror the saved config for the active list into the inline alert bar controls.
6645function populateScanAlertBar() {
6646  const scope = scanAlertScope();
6647  const dflt = scope === 'market' ? { on: false, pct: 20, minVol: 10e6 } : { on: false, pct: 5, both: false };
6648  const a = (alertCfg && alertCfg.alerts && alertCfg.alerts[scope]) || dflt;
6649  const on = document.getElementById('scanAlOn'), pct = document.getElementById('scanAlPct'),
6650        both = document.getElementById('scanAlBoth'), vol = document.getElementById('scanAlVol'),
6651        bothWrap = document.getElementById('scanAlBothWrap'), volWrap = document.getElementById('scanAlVolWrap'),
6652        label = document.getElementById('scanAlLabel');
6653  if (on) on.checked = !!a.on;
6654  if (pct) pct.value = a.pct != null ? a.pct : (scope === 'market' ? 20 : 5);
6655  // Market uses a volume floor; the two watchlists use a "both ways" toggle.
6656  if (scope === 'market') {
6657    if (label) label.textContent = 'Alert me on a whole-market mover ≥';
6658    if (bothWrap) bothWrap.style.display = 'none';
6659    if (volWrap) volWrap.style.display = 'flex';
6660    if (vol) vol.value = Math.round((a.minVol || 10e6) / 1e6);
6661  } else {
6662    if (label) label.textContent = scope === 'custom'
6663      ? 'Alert me when a custom-list ticker moves ≥'
6664      : 'Alert me when a watchlist ticker moves ≥';
6665    if (bothWrap) bothWrap.style.display = 'flex';
6666    if (volWrap) volWrap.style.display = 'none';
6667    if (both) both.checked = !!a.both;
6668  }
6669  const note = document.getElementById('scanAlNote');
6670  if (note) note.textContent = (alertCfg && !alertCfg.paid) ? 'Alerts are a Pro feature' : '';
6671}
6672// Save the active list's alert into the shared config, preserving the other two scopes.
6673async function saveScanAlert() {
6674  if (alertCfg && !alertCfg.paid) { showToast('Move alerts are a Pro feature'); return; }
6675  const scope = scanAlertScope();
6676  const btn = document.getElementById('scanAlSave'); if (btn) btn.textContent = 'Saving…';
6677  const cur = (alertCfg && alertCfg.alerts) || {};
6678  const body = {
6679    market: cur.market || { on: false, pct: 20, minVol: 10e6 },
6680    watch: cur.watch || { on: false, pct: 5, both: false },
6681    custom: cur.custom || { on: false, pct: 5, both: false },
6682    email: cur.email !== false,
6683  };
6684  const on = document.getElementById('scanAlOn').checked;
6685  const pct = parseFloat(document.getElementById('scanAlPct').value) || (scope === 'market' ? 20 : 5);
6686  if (scope === 'market') {
6687    body.market = { on, pct, minVol: (parseFloat(document.getElementById('scanAlVol').value) || 0) * 1e6 };
6688  } else {
6689    body[scope] = { on, pct, both: document.getElementById('scanAlBoth').checked };
6690  }
6691  try {
6692    const r = await (await fetch('/api/alerts/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
6693    if (r.alerts) { if (!alertCfg) alertCfg = { paid: true, alerts: r.alerts }; else alertCfg.alerts = r.alerts; }
6694    showToast('🔔 ' + (scope === 'market' ? 'Market' : scope === 'custom' ? 'Custom-list' : 'Watchlist') + ' alerts saved');
6695  } catch (e) { showToast('Could not save alerts'); }
6696  if (btn) btn.textContent = 'Save alerts';
6697}
6698// Add a ticker by name by reusing the existing add flow (sector detect + per-user
6699// server save + view rebuild) via the hidden header input.
6700function addCustomTickerByName(tk) {
6701  const inp = document.getElementById('customTicker');
6702  if (!inp) return;
6703  inp.value = tk;
6704  try { addCustomTicker(); } catch (e) {} finally { inp.value = ''; }
6705}
6706async function scanAddWatch() {
6707  const inp = document.getElementById('scanAddTicker');
6708  const tk = (inp.value || '').trim().toUpperCase();
6709  if (!tk) return;
6710  inp.value = '';
6711  if (scanMode === 'custom') {
6712    // Custom list — add to the separate server-persisted list only.
6713    try {
6714      const j = await (await fetch('/api/scan-watchlist/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticker: tk }) })).json();
6715      if (j.tickers) scanWatchList = j.tickers; else if (!scanWatchList.includes(tk)) scanWatchList.push(tk);
6716    } catch (e) { if (!scanWatchList.includes(tk)) scanWatchList.push(tk); }
6717    loadScanner();
6718  } else {
6719    // My Watchlist — reuse the original add flow (sector detect + per-user save, all views).
6720    addCustomTickerByName(tk);
6721    setTimeout(loadScanner, 250);
6722  }
6723}
6724async function scanDeleteWatch(tk) {
6725  tk = String(tk || '').toUpperCase();
6726  if (scanMode === 'custom') {
6727    try {
6728      const j = await (await fetch('/api/scan-watchlist/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticker: tk }) })).json();
6729      if (j.tickers) scanWatchList = j.tickers; else scanWatchList = scanWatchList.filter(t => t !== tk);
6730    } catch (e) { scanWatchList = scanWatchList.filter(t => t !== tk); }
6731    loadScanner();
6732  } else {
6733    // My Watchlist — delete from every view (the original behavior).
6734    if (typeof deleteTickerEverywhere === 'function') deleteTickerEverywhere(tk);
6735    setTimeout(loadScanner, 150);
6736  }
6737}
6738async function loadScanner() {
6739  const body = document.getElementById('scanBody');
6740  if (!body) return;
6741  renderScanAlertsFeed(); // populate the right-side feed with any alerts already collected
6742  const isCustom = scanMode === 'custom';
6743  const isWatch = scanMode === 'watch' || isCustom; // both list modes use the watchlist path
6744  if (!body.dataset.loaded) body.innerHTML = '<div class="col-loading">' + (isWatch ? 'Loading your watchlist…' : 'Scanning the market…') + '</div>';
6745  const g = (id) => { const el = document.getElementById(id); return el ? el.value : ''; };
6746  const selVal = g('scanSort') || 'perc_change';
6747  if (selVal !== scanSortKey) { scanSortKey = selVal;
6747 scanSortDir = 'desc'; }
6748  const minPrice = parseFloat(g('scanMinPrice')), maxPrice = parseFloat(g('scanMaxPrice')),
6749        minMove = parseFloat(g('scanMinMove')), minVol = parseFloat(g('scanMinVol'));
6750  try {
6751    let rows;
6752    if (isWatch) {
6753      if (isCustom && !scanWatchLoaded) await loadScanWatchlist();
6754      const tks = isCustom ? customWatchlistTickers() : userWatchlistTickers();
6755      if (!tks.length) { body.innerHTML = '<div class="col-loading">' + (isCustom ? 'Your custom list is empty — add a ticker with <b>+ Add to custom list</b> above.' : 'Your watchlist is empty.') + '</div>'; if (document.getElementById('scanCount')) document.getElementById('scanCount').textContent = ''; return; }
6756      const j = await (await fetch('/api/scanner/watchlist?tickers=' + encodeURIComponent(tks.join(',')))).json();
6757      rows = j.data || [];
6758      // Client-side filters + sort (the watchlist endpoint returns the raw set).
6759      if (isFinite(minPrice)) rows = rows.filter(r => r.price != null && r.price >= minPrice);
6760      if (isFinite(maxPrice)) rows = rows.filter(r => r.price != null && r.price <= maxPrice);
6761      if (isFinite(minMove)) rows = rows.filter(r => r.pct != null && Math.abs(r.pct) >= Math.abs(minMove));
6762      if (isFinite(minVol)) rows = rows.filter(r => (r.volume || 0) >= minVol);
6763      const key = scanSortKey === 'perc_change' ? 'pct' : scanSortKey;
6764      rows.sort((a, b) => { const av = a[key] == null ? -Infinity : a[key], bv = b[key] == null ? -Infinity : b[key]; return scanSortDir === 'desc' ? bv - av : av - bv; });
6765    } else {
6766      const p = new URLSearchParams();
6767      if (g('scanMinPrice')) p.set('minPrice', g('scanMinPrice'));
6768      if (g('scanMaxPrice')) p.set('maxPrice', g('scanMaxPrice'));
6769      if (g('scanMinMove')) p.set('minMove', g('scanMinMove'));
6770      if (g('scanMinVol')) p.set('minVol', g('scanMinVol'));
6771      // Server can only sort the whole market by the numeric columns; for ticker/price
6772      // we fetch by % change and re-sort client-side.
6773      p.set('sort', SCAN_NUMERIC.includes(scanSortKey) ? scanSortKey : 'perc_change');
6774      p.set('dir', scanSortDir);
6775      const j = await (await fetch('/api/scanner?' + p.toString())).json();
6776      rows = j.data || [];
6777    }
6778    scanRows = rows;
6779    renderScanRows();
6780  } catch (e) {
6781    body.innerHTML = '<div class="col-loading col-error">Scanner error: ' + e.message + '</div>';
6782  }
6783}
6784// Renders scanRows (client-sorted by the active column) — every header is clickable.
6785function renderScanRows() {
6786  const body = document.getElementById('scanBody');
6787  if (!body) return;
6788  const isWatch = scanMode === 'watch' || scanMode === 'custom'; // both list modes show the × remove column
6789  const rows = scanRows.slice().sort(scanCmp);
6790  const cnt = document.getElementById('scanCount');
6791  if (cnt) cnt.textContent = rows.length + (scanMode === 'custom' ? ' in custom list' : isWatch ? ' in watchlist' : ' results');
6792  if (!rows.length) { body.innerHTML = '<div class="col-loading">No matches — loosen the filters.</div>'; body.dataset.loaded = '1'; return; }
6793  const thS = 'position:sticky;top:0;background:var(--bg);padding:8px 10px;';
6794  const arrow = (key) => scanSortKey === key ? (scanSortDir === 'desc' ? ' ▼' : ' ▲') : ' ⇅';
6795  // Fixed column widths keep the data columns packed just right of the ticker; the
6796  // trailing spacer column soaks up the leftover width instead of stretching the gaps.
6797  const th = (key, label, align, w, extra) => '<th class="scan-th" data-sort="' + key + '" style="text-align:' + align + ';width:' + w + ';cursor:pointer;' + thS + (extra || '') + '" title="Click to sort">' + label + arrow(key) + '</th>';
6798  let h = '<table style="width:100%;border-collapse:collapse;font-family:Segoe UI,monospace;font-size:12px">'
6799    + '<thead><tr style="color:var(--dim);font-size:10px;letter-spacing:1px">'
6800    + th('ticker', 'TICKER', 'left', '150px', 'padding-left:16px;')
6801    + th('price', 'PRICE', 'right', '92px')
6802    + th('prevClose', 'PREV CLOSE', 'right', '96px', '')
6803    + th('perc_change', 'TODAY', 'right', '92px')
6804    + th('chg', 'CHG $', 'right', '92px')
6805    + th('volume', 'VOLUME', 'right', '92px')
6806    + th('marketcap', 'MKT CAP', 'right', '96px')
6807    + '<th style="text-align:right;width:92px;padding:8px 10px;position:sticky;top:0;background:var(--bg)" title="Highest price it traded today">DAY HIGH</th>'
6808    + '<th style="text-align:right;width:88px;padding:8px 10px;position:sticky;top:0;background:var(--bg)" title="Peak % move from prev close today — the highest it hit, even if it has pulled back since">HIGH %</th>'
6809    + '<th style="text-align:right;width:64px;padding:8px 10px;position:sticky;top:0;background:var(--bg)" title="Position within the 52-week range">52W</th>'
6810    + (isWatch ? '<th style="width:30px;position:sticky;top:0;background:var(--bg)"></th>' : '')
6811    + '<th style="width:auto;position:sticky;top:0;background:var(--bg)"></th>'
6812    + '</tr></thead><tbody>';
6813  for (const r of rows) {
6814    const up = (r.pct || 0) >= 0;
6815    const pc = rowPrev(r);            // previous-day close (screener value or derived)
6816    const chg = rowChg(r);           // today's $ change from prev close
6817    const range = (r.low52 != null && r.high52 != null && r.high52 > r.low52 && r.price != null)
6818      ? Math.max(0, Math.min(100, Math.round(((r.price - r.low52) / (r.high52 - r.low52)) * 100))) : null;
6819    const dh = (r.dayHigh != null && r.dayHigh > 0) ? r.dayHigh : null;               // highest price today
6820    const hp = (dh != null && pc != null && pc > 0) ? ((dh - pc) / pc * 100) : null;  // peak % from prev close
6821    h += '<tr class="scan-row" data-ticker="' + r.ticker + '" style="border-bottom:1px solid rgba(30,41,59,0.5);cursor:pointer" title="Open ' + r.ticker + ' on the GEX chart">'
6822      + '<td style="padding:7px 10px 7px 16px;white-space:nowrap"><b>' + r.ticker + '</b>' + (r.name ? ' <span style="color:var(--dim);font-size:10px">' + String(r.name).slice(0, 14) + '</span>' : '') + '</td>'
6823      + '<td style="text-align:right;padding:7px 10px">$' + (r.price != null ? r.price.toFixed(2) : '—') + '</td>'
6824      + '<td style="text-align:right;padding:7px 10px;color:var(--dim)" title="Previous trading day close">$' + (pc != null ? pc.toFixed(2) : '—') + '</td>'
6825      + '<td style="text-align:right;padding:7px 10px;font-weight:700;color:' + (up ? 'var(--green)' : 'var(--red)') + '">' + (r.pct != null ? (up ? '▲ +' : '▼ ') + r.pct.toFixed(1) + '%' : '—') + '</td>'
6826      + '<td style="text-align:right;padding:7px 10px;font-weight:700;color:' + (up ? 'var(--green)' : 'var(--red)') + '" title="$ change from previous close">' + (chg != null ? (chg >= 0 ? '+$' : '−$') + Math.abs(chg).toFixed(2) : '—') + '</td>'
6827      + '<td style="text-align:right;padding:7px 10px">' + fmtVolNum(r.volume) + '</td>'
6828      + '<td style="text-align:right;padding:7px 10px">' + fmtCapNum(r.marketcap) + '</td>'
6829      + '<td style="text-align:right;padding:7px 10px;color:#cbd5e1">' + (dh != null ? '$' + dh.toFixed(2) : '—') + '</td>'
6830      + '<td style="text-align:right;padding:7px 10px;font-weight:700;color:' + (hp != null && hp >= 0 ? 'var(--green)' : 'var(--dim)') + '" title="Peak move from prev close today">' + (hp != null ? (hp >= 0 ? '+' : '') + hp.toFixed(1) + '%' : '—') + '</td>'
6831      + '<td style="text-align:right;padding:7px 10px;color:var(--dim)">' + (range != null ? range + '%' : '—') + '</td>'
6832      + (isWatch ? '<td style="text-align:center"><span class="scan-del" data-del="' + r.ticker + '" title="Remove ' + r.ticker + ' from watchlist">×</span></td>' : '')
6833      + '<td></td>'
6834      + '</tr>';
6835  }
6836  h += '</tbody></table>';
6837  body.innerHTML = h;
6838  body.dataset.loaded = '1';
6839  body.querySelectorAll('.scan-th').forEach(t => t.addEventListener('click', () => scanSortBy(t.dataset.sort)));
6840  body.querySelectorAll('.scan-del').forEach(d => d.addEventListener('click', (e) => { e.stopPropagation(); scanDeleteWatch(d.dataset.del); }));
6841  body.querySelectorAll('.scan-row').forEach(tr => tr.addEventListener('click', () => {
6842    const t = tr.dataset.ticker;
6843    if (typeof demoLocked === 'function' && demoLocked(t)) { window.location.href = UPGRADE_URL; return; }
6844    setView('chart'); setSyncedTicker(t);
6845  }));
6846}
6847setInterval(() => { if (currentView === 'scanner') { if (scannerTab === 'overview' && typeof loadMarketOverview === 'function') loadMarketOverview(); else loadScanner(); } }, 60 * 1000);
6848
6849// ── 🔔 Move alerts — server-driven (Market + Watchlist), pushed live via SSE ──
6850// Config lives on the server (per user). The server sweeps every ~20s and pushes each
6851// new hit straight to this dashboard over the live stream (near-instant), and emails it
6852// too (if email is on). The bell just shows what arrives + edits the config.
6853let alertCfg = null; // { paid, hasEmail, alerts: { market, watch, email } }
6854let moveAlerts = [];
6855let alertUnseen = 0;
6856// ── Alert persistence (survives reload/redeploy; auto-clears on a new ET day) ──
6857let _alertsDay = null;
6858let _scanFeedMinPct = 0; // right-feed % filter
6859let _scanFeedScope = 'all'; // right-feed source filter: all | market | watch | custom
6860function _alertsDateKey() { try { return new Date().toLocaleDateString('en-US', { timeZone: 'America/New_York' }); } catch (e) { return ''; } }
6861function saveMoveAlerts() { try { localStorage.setItem('gf_moveAlerts', JSON.stringify({ date: _alertsDateKey(), alerts: moveAlerts })); } catch (e) {} }
6862function loadMoveAlerts() {
6863  _alertsDay = _alertsDateKey();
6864  try {
6865    const raw = localStorage.getItem('gf_moveAlerts');
6866    if (raw) { const o = JSON.parse(raw); if (o && o.date === _alertsDay && Array.isArray(o.alerts)) moveAlerts = o.alerts; else localStorage.removeItem('gf_moveAlerts'); }
6867    const f = parseFloat(localStorage.getItem('gf_feedMinPct')); if (isFinite(f)) _scanFeedMinPct = f;
6868    const sc = localStorage.getItem('gf_feedScope'); if (sc) _scanFeedScope = sc;
6869  } catch (e) {}
6870}
6871loadMoveAlerts();
6872async function loadAlertConfig() {
6873  if (typeof IS_DEMO !== 'undefined' && IS_DEMO) return;
6874  try { alertCfg = await (await fetch('/api/alerts/config')).json(); } catch (e) {}
6875}
6876// Called from the SSE handler when the server pushes a move alert.
6877function onMoveAlert(d) {
6878  if (!d || !d.ticker) return;
6879  const today = _alertsDateKey();
6880  if (_alertsDay !== today) { moveAlerts = []; _alertsDay = today; } // new day → fresh list
6881  moveAlerts.unshift({ t: d.ticker, pct: d.pct, price: d.price, scope: d.scope, time: d.t || Date.now() });
6882  if (moveAlerts.length > 200) moveAlerts.pop();
6883  saveMoveAlerts();
6884  alertUnseen++;
6885  updateAlertBell();
6886  const up = (d.pct || 0) >= 0;
6887  showToast('🔔 ' + (d.scope === 'watch' ? '⭐ ' : d.scope === 'custom' ? '📋 ' : '') + d.ticker + ' ' + (up ? '▲ +' : '▼ ') + Math.abs(d.pct).toFixed(1) + '%');
6888  const op = document.getElementById('alertPanel');
6889  if (op && op.style.display === 'block') renderAlertPanel();
6890  renderScanAlertsFeed(); // live right-side feed in the scanner
6891  if (typeof renderUltraMovers === 'function') renderUltraMovers(); // Movers pane on the chart right panel
6892}
6893// Backfill today's alerts from the server so the feed matches the emails — even ones that fired
6894// while the dashboard was closed or the SSE was disconnected. Runs on load + reconnect + every
6895// 2 min. Pulls BOTH the user's own delivered alerts AND the global movers day-log, so the Movers
6896// panes (Ghost right panel + Pulse feed) fill with today's market movers even when this user's
6897// bell alerts are off (user request — the panes used to stay empty without an opt-in).
6898async function backfillAlerts() {
6899  if (typeof IS_DEMO !== 'undefined' && IS_DEMO) return;
6900  if (window._bfaBusy) return;   // AUDIT FIX: SSE reconnect + the 2-min interval overlapping duplicated mover rows into localStorage
6901  window._bfaBusy = true;
6902  try {
6903    let server = [];
6904    const [r, rg] = await Promise.all([fetch('/api/alerts/recent').catch(() => null), fetch('/api/movers/today').catch(() => null)]);
6905    try { if (r && r.ok) { const j = await r.json(); if (Array.isArray(j.alerts)) server = server.concat(j.alerts); } } catch (e) {}
6906    try { if (rg && rg.ok) { const jg = await rg.json(); if (Array.isArray(jg.alerts)) server = server.concat(jg.alerts); } } catch (e) {}
6907    if (!server.length) return;
6908    const today = _alertsDateKey();
6909    if (_alertsDay !== today) { moveAlerts = []; _alertsDay = today; }
6910    const keyOf = a => (a.scope || '') + ':' + (a.t || a.ticker) + ':' + (((a.pct || 0) >= 0) ? 'u' : 'd') + Math.floor(Math.abs(a.pct || 0) / 5);
6911    const seen = new Set(moveAlerts.map(keyOf));
6912    for (const s of server) {
6913      const item = { t: s.ticker, pct: s.pct, price: s.price, scope: s.scope, time: s.time || Date.now() };
6914      const k = keyOf(item); if (seen.has(k)) continue; seen.add(k); moveAlerts.push(item);
6915    }
6916    moveAlerts.sort((a, b) => (b.time || 0) - (a.time || 0));
6917    if (moveAlerts.length > 200) moveAlerts = moveAlerts.slice(0, 200);
6918    saveMoveAlerts(); updateAlertBell(); renderScanAlertsFeed();
6919    if (typeof renderUltraMovers === 'function') renderUltraMovers();
6920    const ap = document.getElementById('alertPanel'); if (ap && ap.style.display === 'block') renderAlertPanel();
6921  } catch (e) {} finally { window._bfaBusy = false; }
6922}
6923// Keep the day's movers current while the dashboard sits open (the global log grows server-side
6924// on its own 60s sweep; SSE only pushes the user's OWN opted-in alerts).
6925setInterval(function(){ try{ if(typeof isLoggedInUser!=='undefined' && isLoggedInUser) backfillAlerts(); }catch(e){} }, 2 * 60 * 1000);
6926// Persistent right-side alerts list in the Scanner view (same data as the bell panel).
6927function renderScanAlertsFeed() {
6928  const list = document.getElementById('scanFeedList'); if (!list) return;
6929  // Wire the filter + clear once (header is static, so listeners persist).
6930  if (!window._scanFeedWired) {
6931    const mp = document.getElementById('scanFeedMinPct'), cb = document.getElementById('scanFeedClear');
6932    if (mp && cb) {
6933      window._scanFeedWired = true;
6934      mp.value = _scanFeedMinPct || 0;
6935      mp.addEventListener('input', () => { _scanFeedMinPct = parseFloat(mp.value) || 0; try { localStorage.setItem('gf_feedMinPct', String(_scanFeedMinPct)); } catch (e) {} renderScanAlertsFeed(); });
6936      const scp = document.getElementById('scanFeedScope');
6937      if (scp) { scp.value = _scanFeedScope || 'all'; scp.addEventListener('change', () => { _scanFeedScope = scp.value || 'all'; try { localStorage.setItem('gf_feedScope', _scanFeedScope); } catch (e) {} renderScanAlertsFeed(); }); }
6938      cb.addEventListener('click', () => {
6939        moveAlerts = []; alertUnseen = 0; saveMoveAlerts(); updateAlertBell(); renderScanAlertsFeed();
6940        const ap = document.getElementById('alertPanel'); if (ap && ap.style.display === 'block') renderAlertPanel();
6941      });
6942    }
6943  }
6944  const shown = moveAlerts.filter(x => {
6945    if (Math.abs(x.pct || 0) < (_scanFeedMinPct || 0)) return false;
6946    const s = _scanFeedScope || 'all';
6947    if (s === 'all') return true;
6948    if (s === 'market') return x.scope !== 'watch' && x.scope !== 'custom'; // market movers
6949    return x.scope === s; // 'watch' | 'custom'
6950  });
6951  const cnt = document.getElementById('scanFeedCount');
6952  if (cnt) cnt.textContent = shown.length ? shown.length + (shown.length === 1 ? ' alert' : ' alerts') : '';
6953  if (!shown.length) {
6954    list.innerHTML = '<div style="padding:18px 12px;text-align:center;color:var(--dim);font-size:11px;font-family:Segoe UI,monospace;line-height:1.6">' + (moveAlerts.length ? 'No alerts ≥ ' + (_scanFeedMinPct || 0) + '%' : 'Watching live…<br>alerts appear here as tickers move.') + '</div>';
6955    return;
6956  }
6957  let h = '';
6958  for (const x of shown) {
6959    const up = x.pct >= 0;
6960    h += '<div class="scan-feed-row" data-ticker="' + x.t + '" style="padding:8px 12px;border-bottom:1px solid rgba(30,41,59,0.4);cursor:pointer;font-family:Segoe UI,monospace;font-size:12px;display:flex;gap:7px;align-items:center">'
6961      + (x.scope === 'watch' ? '<span title="From your watchlist">⭐</span>' : x.scope === 'custom' ? '<span title="From your custom list">📋</span>' : '<span title="Market mover">🌐</span>')
6962      + '<b>' + x.t + '</b><span style="color:' + (up ? 'var(--green)' : 'var(--red)') + ';font-weight:700">' + (up ? '▲+' : '▼') + Math.abs(x.pct).toFixed(1) + '%</span>'
6963      + (x.price != null ? '<span style="color:var(--dim)">$' + x.price.toFixed(2) + '</span>' : '')
6964      + '<span style="color:#94a3b8;margin-left:auto;font-size:11px">' + fmtTime(x.time) + '</span></div>';
6965  }
6966  list.innerHTML = h;
6967  list.querySelectorAll('.scan-feed-row').forEach(el => el.addEventListener('click', () =>
6967 { setView('chart'); setSyncedTicker(el.dataset.ticker); }));
6968}
6969function updateAlertBell() {
6970  const b = document.getElementById('alertBadge');
6971  if (!b) return;
6972  b.style.display = alertUnseen > 0 ? 'block' : 'none';
6973  b.textContent = alertUnseen > 99 ? '99+' : String(alertUnseen);
6974}
6975function renderAlertPanel() {
6976  const p = document.getElementById('alertPanel');
6977  if (!p) return;
6978  if (!alertCfg) { p.innerHTML = '<div style="padding:16px;color:var(--dim);font-size:12px;font-family:Segoe UI,monospace;text-align:center">Loading…</div>'; return; }
6979  if (!alertCfg.paid) {
6980    p.innerHTML = '<div style="padding:18px;text-align:center;font-family:Segoe UI,monospace;font-size:12px;color:var(--dim)">🔒 Move alerts are a <b style="color:#e2e8f0">Pro</b> feature.<br><a href="/subscribe" style="color:#3b82f6;font-weight:700">Upgrade to Pro</a></div>';
6981    return;
6982  }
6983  const a = alertCfg.alerts || {};
6984  const mk = a.market || { on: false, pct: 20, minVol: 10e6 };
6985  const wt = a.watch || { on: false, pct: 5, both: false };
6986  const ct = a.custom || { on: false, pct: 5, both: false };
6987  const sel = (id, opts, cur) => '<select id="' + id + '" style="background:var(--t-bg,#0a0e17);border:1px solid var(--border);border-radius:4px;color:var(--text);padding:3px 5px;font-family:inherit;font-size:11px">'
6988    + opts.map(o => '<option value="' + o.v + '"' + (o.v === cur ? ' selected' : '') + '>' + o.l + '</option>').join('') + '</select>';
6989  const moveOpts = [3, 5, 10, 15, 20, 30, 50].map(v => ({ v, l: '+' + v + '%' }));
6990  const volOpts = [[0, 'Any vol'], [1e6, '≥ 1M'], [5e6, '≥ 5M'], [10e6, '≥ 10M'], [25e6, '≥ 25M'], [50e6, '≥ 50M'], [100e6, '≥ 100M']].map(x => ({ v: x[0], l: x[1] }));
6991  const anyOn = mk.on || wt.on || ct.on;
6992  let h = '<div style="padding:10px 12px;border-bottom:1px solid var(--border);font-family:Segoe UI,monospace;font-size:11px">'
6993    + '<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px"><b>🔔 Move alerts</b><span style="margin-left:auto;font-size:10px;font-weight:700;color:' + (anyOn ? 'var(--green)' : 'var(--dim)') + '">' + (anyOn ? '● ON' : '○ OFF') + '</span></div>'
6994    // Market section
6995    + '<div style="margin-bottom:10px"><label style="display:flex;align-items:center;gap:6px;font-weight:700;color:#e2e8f0"><input type="checkbox" id="alM" ' + (mk.on ? 'checked' : '') + '> 🌐 Market movers</label>'
6996    + '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:6px 0 0 20px"><span style="color:var(--dim)">Gain</span>' + sel('alMpct', moveOpts, mk.pct) + '<span style="color:var(--dim)">Vol</span>' + sel('alMvol', volOpts, mk.minVol != null ? mk.minVol : 10e6) + '</div></div>'
6997    // Watchlist section (the user's full watchlist)
6998    + '<div style="margin-bottom:10px"><label style="display:flex;align-items:center;gap:6px;font-weight:700;color:#e2e8f0"><input type="checkbox" id="alW" ' + (wt.on ? 'checked' : '') + '> ⭐ My watchlist</label>'
6999    + '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:6px 0 0 20px"><span style="color:var(--dim)">Move</span>' + sel('alWpct', moveOpts, wt.pct) + '<label style="display:flex;align-items:center;gap:4px;color:var(--dim)"><input type="checkbox" id="alWboth" ' + (wt.both ? 'checked' : '') + '> both ways</label></div></div>'
7000    // Custom-list section (the user-built scanner list)
7001    + '<div style="margin-bottom:10px"><label style="display:flex;align-items:center;gap:6px;font-weight:700;color:#e2e8f0"><input type="checkbox" id="alC" ' + (ct.on ? 'checked' : '') + '> 📋 Custom list</label>'
7002    + '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:6px 0 0 20px"><span style="color:var(--dim)">Move</span>' + sel('alCpct', moveOpts, ct.pct) + '<label style="display:flex;align-items:center;gap:4px;color:var(--dim)"><input type="checkbox" id="alCboth" ' + (ct.both ? 'checked' : '') + '> both ways</label></div></div>'
7003    + '<label style="display:flex;align-items:center;gap:6px;color:var(--dim);margin-bottom:10px"><input type="checkbox" id="alEmail" ' + (a.email !== false ? 'checked' : '') + '> 📧 Also email me' + (alertCfg.hasEmail ? '' : ' <span style="color:#ef4444">(add an email in Account)</span>') + '</label>'
7004    + '<div style="display:flex;gap:6px"><button id="alSave" class="refresh-btn" style="color:var(--text);padding:4px 12px;font-size:11px">Save</button>'
7005    + (moveAlerts.length ? '<button id="alClear" class="refresh-btn" style="padding:4px 10px;font-size:11px">Clear list</button>' : '') + '</div></div>';
7006  if (!anyOn) h += '<div style="padding:16px;text-align:center;color:var(--dim);font-size:12px;font-family:Segoe UI,monospace">Turn on Market, Watchlist, and/or Custom list, then <b>Save</b>.</div>';
7007  else if (!moveAlerts.length) h += '<div style="padding:16px;text-align:center;color:var(--dim);font-size:12px;font-family:Segoe UI,monospace">Watching live… alerts will appear here instantly.</div>';
7008  for (const x of moveAlerts) {
7009    const up = x.pct >= 0;
7010    h += '<div class="alert-row" data-ticker="' + x.t + '" style="padding:8px 12px;border-bottom:1px solid rgba(30,41,59,0.4);cursor:pointer;font-family:Segoe UI,monospace;font-size:12px;display:flex;gap:8px;align-items:center">'
7011      + (x.scope === 'watch' ? '<span title="From your watchlist">⭐</span>' : x.scope === 'custom' ? '<span title="From your custom list">📋</span>' : '<span title="Market mover">🌐</span>')
7012      + '<b>' + x.t + '</b><span style="color:' + (up ? 'var(--green)' : 'var(--red)') + ';font-weight:700">' + (up ? '▲ +' : '▼ ') + Math.abs(x.pct).toFixed(1) + '%</span>'
7013      + (x.price != null ? '<span style="color:var(--dim)">$' + x.price.toFixed(2) + '</span>' : '')
7014      + '<span style="color:#94a3b8;margin-left:auto;font-size:11px">' + fmtTime(x.time) + '</span></div>';
7015  }
7016  p.innerHTML = h;
7017  const save = document.getElementById('alSave');
7018  if (save) save.addEventListener('click', async () => {
7019    const body = {
7020      market: { on: document.getElementById('alM').checked, pct: parseFloat(document.getElementById('alMpct').value), minVol: parseFloat(document.getElementById('alMvol').value) },
7021      watch: { on: document.getElementById('alW').checked, pct: parseFloat(document.getElementById('alWpct').value), both: document.getElementById('alWboth').checked },
7022      custom: { on: document.getElementById('alC').checked, pct: parseFloat(document.getElementById('alCpct').value), both: document.getElementById('alCboth').checked },
7023      email: document.getElementById('alEmail').checked,
7024    };
7025    save.textContent = 'Saving…';
7026    try { const r = await (await fetch('/api/alerts/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); if (r.alerts) alertCfg.alerts = r.alerts; } catch (e) {}
7027    renderAlertPanel();
7028    showToast('🔔 Alert settings saved');
7029  });
7030  const cl = document.getElementById('alClear');
7031  if (cl) cl.addEventListener('click', () => { moveAlerts = []; alertUnseen = 0; saveMoveAlerts(); updateAlertBell(); renderAlertPanel(); renderScanAlertsFeed(); });
7032  p.querySelectorAll('.alert-row').forEach(el => el.addEventListener('click', () =>
7032 { setView('chart'); setSyncedTicker(el.dataset.ticker); p.style.display = 'none'; }));
7033}
7034function toggleAlertPanel() {
7035  const p = document.getElementById('alertPanel');
7036  if (!p) return;
7037  const open = p.style.display !== 'block';
7038  if (open) document.body.appendChild(p);
7039  // Desktop: pin the panel directly under the bell wherever the bell lives (it moved next to the status pill in the
7040  // left-rail layout, so the old right:0 anchor put it at the far right of the screen - user 2026-09-06). Mobile keeps
7041  // the fixed viewport-anchored CSS.
7042  if (open) { try { const bell = document.getElementById('alertBell'); if (bell && window.innerWidth > 640) { const r = bell.getBoundingClientRect(), w = 290; p.style.position = 'fixed'; p.style.top = Math.round(r.bottom + 6) + 'px'; p.style.left = Math.max(8, Math.min(window.innerWidth - w - 8, Math.round(r.left))) + 'px'; p.style.right = 'auto'; } } catch (e) {} }
7043  p.style.display = open ? 'block' : 'none';
7044  if (!open) return;
7045  alertUnseen = 0; updateAlertBell();
7046  if (!alertCfg) { renderAlertPanel(); loadAlertConfig().then(renderAlertPanel); } else renderAlertPanel();
7047}
7048document.addEventListener('click', (e) => {
7049  const w = document.getElementById('alertBellWrap'), p = document.getElementById('alertPanel');
7050  if (p && p.style.display === 'block' && w && !w.contains(e.target) && !p.contains(e.target)) p.style.display = 'none';
7051});
7052// NOTE: don't read IS_DEMO at top level here — it's declared further down (TDZ).
7053// loadAlertConfig() checks IS_DEMO itself, and by the time this fires it's defined.
7054setTimeout(loadAlertConfig, 4000);
7055
7056// Escape closes the user menu (keyboard parity with outside-click)
7057document.addEventListener('keydown', function (e) {
7058  if (e.key !== 'Escape') return;
7059  const menu = document.getElementById('userMenu');
7060  if (menu && menu.style.display !== 'none' && menu.style.display !== '') menu.style.display = 'none';
7061});
7062
7063function updateHeaderHeightVar() {
7064  const h = document.querySelector('.header');
7065  // In demo mode a sticky banner sits above the (also sticky) header. Pin the header just
7066  // below the banner so the banner is NEVER covered, and fold the banner height into
7067  // --header-h so the sector tabs + fixed-height views still line up.
7068  // The top chrome is EITHER the full demo banner OR (after it auto-collapses on phones)
7069  // the thin running marquee — both are sticky at top:0, so the header must pin below
7070  // whichever is currently visible and fold its height into --header-h. Missing the
7071  // marquee here is what let it cover the chart/ultra ticker bars on the demo phone view.
7072  const bar = document.getElementById('demoBar');
7073  const mq = document.getElementById('demoMarquee');
7074  const topChrome = (bar && bar.offsetParent !== null) ? bar
7075                  : (mq && mq.offsetParent !== null) ? mq : null;
7076  // The launch-pricing bar is fixed at the very top — its height stacks with the demo chrome so
7077  // the header pins below BOTH and every view keeps its true remaining height (nothing covered).
7078  const lb = document.getElementById('gfLaunchBar');
7079  const lbH = (lb && lb.offsetParent !== null) ? lb.offsetHeight : 0;
7080  const barH = (topChrome ? topChrome.offsetHeight : 0) + lbH;
7081  if (h) {
7082    h.style.top = barH + 'px';
7083    document.documentElement.style.setProperty('--header-h', (h.offsetHeight + barH) + 'px');
7084  }
7085  if (topChrome) topChrome.style.top = lbH + 'px';   // demo bar slides under the launch bar
7086}
7087updateHeaderHeightVar();
7088window.addEventListener('resize', () => { updateHeaderHeightVar(); try { fitChartViewHeight(); } catch (e) {} });
7089window.addEventListener('orientationchange', () => setTimeout(() => { updateHeaderHeightVar(); try { fitChartViewHeight(); } catch (e) {} }, 250));
7090
7091// The header and sector tabs change height as they populate/wrap. Re-fit the active
7092// view whenever that happens so the GEX columns row (and its on-screen horizontal
7093// scrollbar) and the chart are always sized to the real remaining space — independent
7094// of when the tabs finish rendering.
7095try {
7096  const _chromeRO = new ResizeObserver(() => {
7097    updateHeaderHeightVar();
7098    try { fitColumnsHeight(); } catch (e) {}
7099    try { fitChartViewHeight(); } catch (e) {}
7100  });
7101  const _hdr = document.querySelector('.header');
7102  const _tbs = document.getElementById('tabs');
7103  if (_hdr) _chromeRO.observe(_hdr);
7104  if (_tbs) _chromeRO.observe(_tbs);
7105} catch (e) {}
7106
7107// Reveal a GEX column's scrollbar while it's actively being scrolled (covers touch /
7108// trackpad where :hover doesn't apply), then fade it back out shortly after. Delegated
7109// in the capture phase so it works for every column, including ones rendered later.
7110// The last moment anything on the page scrolled or was wheeled. Repaints that can wait, wait.
7111let _gfScrollAt = 0;
7112function _gfScrollBusy() { return (Date.now() - _gfScrollAt) < 350; }
7113(function () {
7114  const mark = function () { _gfScrollAt = Date.now(); };
7115  document.addEventListener('scroll', mark, { capture: true, passive: true });
7116  document.addEventListener('wheel', mark, { capture: true, passive: true });
7117})();
7118(function () {
7119  const scrollTimers = new WeakMap();
7120  document.addEventListener('scroll', function (e) {
7121    const el = e.target;
7122    if (!el || !el.classList || !el.classList.contains('strikes-list')) return;
7123    el.classList.add('scrolling');
7124    clearTimeout(scrollTimers.get(el));
7125    scrollTimers.set(el, setTimeout(function () { el.classList.remove('scrolling'); }, 900));
7126  }, true);
7127})();
7128
7129let isAdminUser = false;
7130
7131let isHqUser = false;   // My HQ: admin, or a paid account switched on from /admin
7132let isPaidUser = false;
7133let isLoggedInUser = false; // any account (admin, paid, or trial) — gates features shown to all users
7134// Layout selection. ALL non-admin users get the new Ghost Mode layout (no toggle, no way
7135// back to classic). ADMINS keep both — default to new, but can switch to classic via the
7136// admin toggle. window.GF_ADMIN is injected by the server so we know this synchronously,
7137// before the layout is applied.
7138var GF_IS_ADMIN_HINT = (typeof window !== 'undefined' && window.GF_ADMIN === true);
7139// Same synchronous server hint for premium members (admins count as paid) — lets paid-gated
7140// panels render at boot without waiting on /api/me.
7141var GF_IS_PAID_HINT = (typeof window !== 'undefined' && (window.GF_PAID === true || window.GF_ADMIN === true));
7142// GEX Visual (gexbub) is a premium mode — one shared gate for every sample/seed/render path.
7143// (The off-hours synthetic PREVIEW painter stays admin-only inside _gexBubSeedPreview.)
7144function _gexBubAllowed(){ return (typeof isPaidUser !== 'undefined' && isPaidUser) || (typeof isAdminUser !== 'undefined' && isAdminUser) || GF_IS_PAID_HINT; }
7145let GF_NEW_LAYOUT = true;
7146if (GF_IS_ADMIN_HINT) { try { GF_NEW_LAYOUT = (localStorage.getItem('gfLayout') !== 'classic'); } catch (e) { GF_NEW_LAYOUT = true; } }
7147try { if (document.body) document.body.classList.toggle('gf-newlayout', GF_NEW_LAYOUT); } catch (e) {}
7148// Build the merged Ultra panel (Flow + Trade Plan) up front so Trade Plan is removed from
7149// the Chart view immediately, not only after the first Ultra visit.
7150try { if (GF_NEW_LAYOUT) nfBuildUltraPanel(); } catch (e) {}
7151// Trade Plan is for paid members (premium + admin) — hide it at boot for everyone the server
7152// didn't flag, so free/demo users never see even a flash of it.
7153try { if (!GF_IS_PAID_HINT) { var _tpEl = document.getElementById('chartTradePlan'); if (_tpEl) _tpEl.style.display = 'none'; } } catch (e) {}
7154if (GF_NEW_LAYOUT) { try { var _bg = document.getElementById('btn-gex'); if (_bg) _bg.textContent = 'Grid'; var _bc = document.getElementById('btn-chart'); if (_bc) _bc.textContent = 'Ghost Mode'; } catch (e) {} }
7155function toggleNewLayout(){ try { localStorage.setItem('gfLayout', GF_NEW_LAYOUT ? 'classic' : 'new'); } catch (e) {} location.reload(); }
7156// ── DRAWING RAIL (admin, 2026-09-05): the drawing tools move out of the header dropdown into a vertical
7157// rail on the RIGHT edge of the GEX ladder (left = live ladder width). Icon-only tool buttons (the same
7158// .draw-menu-item elements, re-parented, so pickDrawTool / setChartDrawTool keep working), colour
7159// swatches, delete. Fib zone + Fib levels settings live in a flyout that opens only while hovering the
7160// Fib tool. Collapsed = only the ACTIVE tool shows; clicking it expands the rail again.
7161function gfDrawRailInit(){
7162  try{
7163    const row=document.getElementById('chartRow'), menu=document.getElementById('drawMenu'); if(!row||!menu||document.getElementById('gfDrawRail')) return;
7164    row.style.position='relative';
7165    const rail=document.createElement('div'); rail.id='gfDrawRail'; rail.className='gf-drawrail';
7166    const tog=document.createElement('button'); tog.id='gfDrawRailTog'; tog.className='gf-drawrail-tog'; tog.title='Collapse / expand the drawing tools'; tog.onclick=function(e){ e.stopPropagation(); gfDrawRailCollapse(); }; rail.appendChild(tog);
7167    const tools=document.createElement('div'); tools.id='gfDrawRailTools'; rail.appendChild(tools);
7168    menu.querySelectorAll('.draw-menu-item').forEach(b=>{ const lbl=(b.textContent||'').replace(/^\s*\S+\s*/,'').trim(); b.title=lbl; b.dataset.lbl=lbl; tools.appendChild(b); });
7169    if(!tools.querySelector('.draw-menu-item.active')){ const c=tools.querySelector('[data-tool="cursor"]'); if(c) c.classList.add('active'); }
7170    const fly=document.createElement('div'); fly.id='gfFibFly'; fly.className='gf-fibfly';
7171    Array.from(menu.children).filter(el=>el.querySelector&&(el.querySelector('#fibZoneColors')||el.querySelector('#fibLevelsList'))).forEach(el=>{ el.style.borderTop='0'; el.style.margin='0'; fly.appendChild(el); });
7172    rail.appendChild(fly);
7173    const colRow=Array.from(menu.children).find(el=>el.querySelector&&el.querySelector('#drawColors'));
7174    if(colRow){ const dc=colRow.querySelector('#drawColors'); const cw=document.createElement('div'); cw.id='gfDrawRailColors'; cw.title='Drawing colour'; cw.appendChild(dc); rail.appendChild(cw); }
7175    const del=document.getElementById('drawBtn-del'); if(del){ del.textContent='\u2715'; del.title='Delete the selected drawing (Del)'; rail.appendChild(del); }
7176    row.appendChild(rail);
7177    const fib=tools.querySelector('[data-tool="fib"]'); let ft=null;
7178    const show=()=>{ clearTimeout(ft); if(rail.classList.contains('collapsed')||!fib) return; fly.style.display='block'; fly.style.top=Math.max(0,fib.offsetTop-6)+'px'; try{ _syncFibMenu(); }catch(e){}
7179      // Auto-side (user 2026-09-07): open to the RIGHT of the menu, but flip to the LEFT when that would run off the window.
7180      try{ fly.style.left='calc(100% + 6px)'; fly.style.right='auto'; const r=fly.getBoundingClientRect(); if(r.right>window.innerWidth-8){ fly.style.left='auto'; fly.style.right='calc(100% + 6px)'; } }catch(e){} };
7181    const hide=()=>{ clearTimeout(ft); ft=setTimeout(()=>{ fly.style.display='none'; },260); };
7182    if(fib){ fib.addEventListener('mouseenter',show); fib.addEventListener('mouseleave',hide); }
7183    fly.addEventListener('mouseenter',()=>clearTimeout(ft)); fly.addEventListener('mouseleave',hide);
7184    // collapsed: the single visible (active) tool expands the rail instead of firing pickDrawTool
7185    tools.addEventListener('click',function(e){ if(!rail.classList.contains('collapsed')) return; e.stopPropagation(); e.preventDefault(); gfDrawRailCollapse(false); },true);
7186    const dt=document.getElementById('drawTools'); if(dt) dt.style.display='none';
7187    gfDrawRailCollapse(localStorage.getItem('gfDrawRail')==='collapsed');
7188    // measure the ladder fast for the first ~15s so the rail is placed as soon as the ladder renders
7189    try{ gfLayersBadgeTick(); let _n=0; const _fast=setInterval(()=>{ gfLayersBadgeTick(); if(++_n>60) clearInterval(_fast); },250); }catch(e){}
7190  }catch(e){ try{ console.warn('[DrawRail]',e); }catch(_){} }
7191}
7192// DRAW MENU (admin test, user 2026-09-05: "add Drawing after the timeframe; hover opens the list, hover a tool for its
7193// settings"). The rail element is re-parented into a `Draw` group in the floating chart toolbar and styled as a
7194// dropdown: hover the button = list of tools with labels; hover Fib = its settings flyout; click a tool = arm it and
7195// close; the button shows the armed tool. Same buttons, same pickDrawTool - nothing about drawings changes.
7196function gfDrawMenuInit(){
7197  try{
7198    const rail=document.getElementById('gfDrawRail'), ctl=document.getElementById('gfHdrChartCtl'); if(!rail||!ctl||document.getElementById('gfHgDraw')) return;
7199    const g=document.createElement('div'); g.className='gf-hgrp'; g.id='gfHgDraw';
7200    const btn=document.createElement('button'); btn.id='gfDrawBtn'; btn.className='tf-btn'; btn.type='button'; btn.title='Drawing tools - hover for the list, hover Fib for its settings'; btn.onclick=function(e){ e.stopPropagation(); g.classList.toggle('open'); };
7201    g.appendChild(btn); g.appendChild(rail);
7202    // order: timeframes | Layers | Draw | Replay (user 2026-09-06) - Draw slots in before the Replay button
7203    const _rb=document.getElementById('replayBtn'); if(_rb && _rb.parentElement && _rb.closest('#gfHdrChartCtl')) _rb.parentElement.insertBefore(g, _rb); else ctl.appendChild(g);
7204    rail.classList.add('gf-drawmenu','placed'); rail.classList.remove('collapsed');
7205    const tog=document.getElementById('gfDrawRailTog'); if(tog) tog.style.display='none';
7206    // the buttons still carry their label text (the rail only hid it with font-size:0) - the menu CSS shows it again
7207    const del=document.getElementById('drawBtn-del'); if(del){ del.innerHTML='<span class="dmi-ic">\u2715</span><span class="dmi-lbl">Delete selected</span>'; }
7208    let t=null; const open=()=>{ clearTimeout(t); g.classList.add('open'); }; const close=()=>{ clearTimeout(t); t=setTimeout(()=>g.classList.remove('open'),260); };
7209    g.addEventListener('mouseenter',open); g.addEventListener('mouseleave',close);
7210    // Stuck-open guard (user 2026-09-07): the toolbar is re-parented between charts while the menu is open (Desk focus
7211    // changes, hover on a pane copy), so mouseleave never fires. Any pointer move OUTSIDE the menu arms the close timer.
7212    if(!window._gfDrawMenuWatch){ window._gfDrawMenuWatch=true; document.addEventListener('mousemove',function(e){ try{ const gg=document.getElementById('gfHgDraw'); if(!gg||!gg.classList.contains('open')) return; const inside=gg.contains(e.target); if(inside){ clearTimeout(t); return; } if(!t) t=setTimeout(function(){ t=null; gg.classList.remove('open'); },320); }catch(err){} }, true); }
7213    rail.addEventListener('click',function(e){ const b=e.target.closest('.draw-menu-item'); if(b){ setTimeout(function(){ g.classList.remove('open'); gfDrawBtnSync(); },0); } });
7214    gfDrawBtnSync(); setInterval(gfDrawBtnSync, 1000);
7215  }catch(e){ try{ console.warn('[DrawMenu]',e); }catch(_){} }
7216}
7217function gfDrawBtnSync(){ try{ const btn=document.getElementById('gfDrawBtn'); if(!btn) return; const a=document.querySelector('#gfDrawRailTools .draw-menu-item.active'); const ic=a?a.querySelector('.dmi-ic'):null; const lbl=a?(a.dataset.lbl||''):''; const isCursor=!a||a.dataset.tool==='cursor'; const html='<span class="gf-drawbtn-ic">'+(ic?ic.textContent:'\u270e')+'</span> Draw'+(isCursor?'':' \u00b7 '+lbl);   /* no caret (user 2026-09-06) */ if(btn.innerHTML!==html) btn.innerHTML=html; btn.classList.toggle('active',!isCursor); }catch(e){} }
7218function gfDrawRailCollapse(force){
7219  const rail=document.getElementById('gfDrawRail'), tog=document.getElementById('gfDrawRailTog'); if(!rail) return;
7220  const c=(typeof force==='boolean')?force:!rail.classList.contains('collapsed');
7221  rail.classList.toggle('collapsed',c); if(tog) tog.textContent=c?'\u203a':'\u2039';
7222  const fly=document.getElementById('gfFibFly'); if(fly&&c) fly.style.display='none';
7223  try{ localStorage.setItem('gfDrawRail',c?'collapsed':'open'); }catch(e){}
7224}
7225// ── COMPACT TICKER STRIP (admin, 2026-09-05): one scrolling row instead of two wrapped rows. "All N" opens
7226// the full grouped strip as an overlay (no chart push-down); the caret hides the strip and parks a
7227// "Tickers" restore button in the header actions. Search still opens any ticker. Persisted.
7228function gfTickerStripInit(){
7229  try{
7230    const tk=document.getElementById('chartTickers'), col=document.getElementById('chartCol'); if(!tk||!col||document.getElementById('gfTkCtl')) return;
7231    col.style.position='relative';
7232    tk.classList.remove('gf-strip-closed');   // the old fold handle is replaced by the hide toggle below - a saved fold must not blank the strip
7233    const ctl=document.createElement('div'); ctl.id='gfTkCtl';
7234    const all=document.createElement('button'); all.id='gfTkAll'; all.className='tf-btn'; all.title='Show every watchlist ticker without pushing the chart down'; all.onclick=function(e){ e.stopPropagation(); gfTkExpand(); };
7235    const hide=document.createElement('button'); hide.id='gfTkHide'; hide.className='tf-btn'; hide.title='Hide the ticker strip - the search box still opens any ticker'; hide.onclick=function(e){ e.stopPropagation(); gfTkHide(); };
7236    ctl.appendChild(all); ctl.appendChild(hide); col.insertBefore(ctl, tk.nextSibling);
7237    // Expanded stays expanded while you pick tickers (user 2026-09-05) - only the Less button folds it back.
7238    tk.addEventListener('wheel',function(e){ if(tk.classList.contains('gf-tk-expanded')) return; if(Math.abs(e.deltaY)>Math.abs(e.deltaX)){ tk.scrollLeft+=e.deltaY; e.preventDefault(); } },{passive:false});
7239    gfTkHide(localStorage.getItem('gfTkStrip')==='hidden'); gfTkExpand(false);
7240    let _lastAct=''; setInterval(()=>{ if(!tk.classList.contains('gf-tk-expanded')){ const a=document.getElementById('gfTkAll'); if(a) a.textContent='All '+tk.querySelectorAll('.exp-ticker-btn').length+' \u25be'; const act=tk.querySelector('.exp-ticker-btn.active'); const id=act?act.dataset.ticker:''; if(act&&id!==_lastAct){ _lastAct=id; try{ act.scrollIntoView({block:'nearest',inline:'center'}); }catch(e){} } } },700);
7241  }catch(e){ try{ console.warn('[TkStrip]',e); }catch(_){} }
7242}
7243function gfTkExpand(force){
7244  const t=document.getElementById('chartTickers'), a=document.getElementById('gfTkAll'); if(!t) return;
7245  const on=(typeof force==='boolean')?force:!t.classList.contains('gf-tk-expanded');
7246  t.classList.toggle('gf-tk-expanded',on);
7247  if(a){ a.classList.toggle('on',on); a.textContent=on?'Less \u25b4':'All '+t.querySelectorAll('.exp-ticker-btn').length+' \u25be'; }
7248}
7249function gfTkHide(force){
7250  const t=document.getElementById('chartTickers'), ctl=document.getElementById('gfTkCtl'), h=document.getElementById('gfTkHide'), a=document.getElementById('gfTkAll'), col=document.getElementById('chartCol'); if(!t||!ctl) return;
7251  const hid=(typeof force==='boolean')?force:!t.classList.contains('gf-tk-hidden');
7252  t.classList.toggle('gf-tk-hidden',hid); if(hid) t.classList.remove('gf-tk-expanded');
7253  if(h){ h.textContent=hid?'Tickers \u25be':'\u25b4'; h.classList.toggle('on',hid); h.title=hid?'Show the ticker strip':'Hide the ticker strip - the search box still opens any ticker'; }
7254  if(a) a.style.display=hid?'none':'';
7255  // hidden: the control parks inline in the header actions; shown: it floats at the strip's right edge
7256  const act=document.getElementById('gfHgAct');
7257  if(hid&&act){ act.insertBefore(ctl, act.firstChild); ctl.classList.add('inline'); }
7258  else if(col){ col.insertBefore(ctl, t.nextSibling);
7258 ctl.classList.remove('inline'); }
7259  try{ localStorage.setItem('gfTkStrip',hid?'hidden':'shown'); }catch(e){}
7260  try{ if(typeof updateHeaderHeightVar==='function') updateHeaderHeightVar(); if(typeof fitChartViewHeight==='function') fitChartViewHeight(); }catch(e){}
7261}
7262// ── DEMO: premium surfaces stay VISIBLE but LOCKED (user 2026-09-05: "update the demo with these but
7263// make sure things are locked for premium"). Smart Money + Ideas open as blurred sample tables with a
7264// lock card; the Layers panel opens read-only with a lock note; the Trade Plan / Ticker Tide panel
7265// shows a readable SAMPLE plan built from the delayed levels; Whales / G-Flip (admin engines) are
7266// hidden for every non-admin, demo included. Nothing here fires a premium fetch.
7267function gfHideAdminTabs(){ try{ document.querySelectorAll('.ideas-sess[data-sess="ah"]').forEach(function(b){ b.style.display='none'; }); }catch(e){}
7268  try{ document.querySelectorAll('#ideasView .ideas-tab[data-tab="ghost"],#ideasView .ideas-tab[data-tab="index"],#ideasView .ideas-tab[data-tab="insider"],#ideasView .ideas-tab[data-tab="gflip"],#ideasView .ideas-tab[data-tab="eod"],#ideasView .ideas-tab[data-tab="prints"],#ideasView .ideas-tab[data-tab="alltrades"],#ideasView .ideas-tab[data-tab="account"],[data-urtab="gflip"],[data-urtab="prints"]').forEach(function(b){ b.style.display='none'; }); }catch(e){} }
7269function gfHidePremiumTabs(){ try{ document.querySelectorAll('#ideasView .ideas-tab[data-tab="gflip"],#ideasView .ideas-tab[data-tab="prints"],#ideasView .ideas-tab[data-tab="alltrades"],#ideasView .ideas-tab[data-tab="account"],[data-urtab="gflip"],[data-urtab="prints"]').forEach(function(b){ b.style.display='none'; }); }catch(e){} }   /* G-Flip + Whales: premium (user 2026-09-06) */
7270function _demoLockCard(title, lines){
7271  var up=(typeof UPGRADE_URL!=='undefined')?UPGRADE_URL:'/register';
7272  return '<div class="demo-lock" style="position:absolute;inset:0"><span style="font-size:10px;font-weight:800;letter-spacing:2px;color:#f59e0b;border:1px solid rgba(245,158,11,.5);border-radius:4px;padding:2px 8px">PREMIUM</span><div style="font-weight:800;font-size:16px;color:#e2e8f0;margin-top:8px">'+title+'</div>'+(lines?'<div style="font-size:12.5px;color:#cbd5e1;max-width:520px;margin:6px 0 14px;line-height:1.5">'+lines+'</div>':'')+'<a class="demo-lock-btn" href="'+up+'">Get Premium</a></div>';
7273}
7274function demoLockView(target){
7275  if(target==='smart'){
7276    var body=document.getElementById('smartBody'); if(!body) return;
7277    document.querySelectorAll('.smart-tab').forEach(function(b){ b.classList.add('demo-tab-locked'); });
7278    var sf=document.getElementById('smartFilter'); if(sf) sf.disabled=true;
7279    var rows=[['Nancy Pelosi','NVDA','Purchase','$1,000,001 - $5,000,000','08-28','call options'],['Tim Moore','MSFT','Purchase','$50,001 - $100,000','09-01',''],['Cleo Fields','TSLA','Sale','$15,001 - $50,000','09-02',''],['Michael Rulli','GOOGL','Sale','$50,001 - $100,000','08-25',''],['Josh Gottheimer','AAPL','Purchase','$15,001 - $50,000','08-27',''],['Marjorie Taylor Greene','PLTR','Purchase','$15,001 - $50,000','09-01','']];
7280    var h='<div class="demo-lock-wrap" style="min-height:540px"><div class="demo-blur" style="filter:blur(1.6px);opacity:.85"><table class="trk-tbl gf-bright" style="width:100%"><thead><tr><th>Member</th><th>Ticker</th><th>Type</th><th>
7280Amount</th><th>Filed</th><th>Notes</th></tr></thead><tbody>';
7281    rows.forEach(function(r){ h+='<tr><td style="color:#e2e8f0;font-weight:700">'+r[0]+'</td><td class="tk">'+r[1]+'</td><td style="color:'+(r[2]==='Purchase'?'#22c55e':'#ef4444')+';font-weight:700">'+r[2]+'</td><td>'+r[3]+'</td><td>'+r[4]+'</td><td>'+r[5]+'</td></tr>'; });
7282    h+='</tbody></table><div style="margin-top:16px;font-size:12px;color:#cbd5e1">Insider buying clusters · 13F position changes · FDA catalyst calendar</div></div>'
7283      + _demoLockCard('Smart Money is a Premium feature','Congressional trades as they are filed, insider buying clusters, 13F position changes from the funds we track, and the FDA catalyst calendar. Sample rows shown.')+'</div>';
7284    body.innerHTML=h;
7285    return;
7286  }
7287  if(target==='ideas'){
7288    var iv=document.getElementById('ideasView'); if(!iv) return;
7289    Array.prototype.forEach.call(iv.children,function(c){ if(c.id!=='demoIdeasLock') c.style.display='none'; });
7290    var ex=document.getElementById('demoIdeasLock'); if(ex){ ex.style.display='flex'; return; }
7291    var d=document.createElement('div'); d.id='demoIdeasLock'; d.className='demo-lock-wrap'; d.style.cssText='flex:1;display:flex;flex-direction:column;padding:18px;min-height:0';
7292    var tile=function(k,v,c){ return '<div style="flex:1;background:rgba(148,163,184,.06);border-radius:8px;padding:10px 12px"><div style="font-size:9px;color:#cbd5e1;letter-spacing:.5px">'+k+'</div><div style="font-size:22px;font-weight:800;color:'+c+'">'+v+'</div></div>'; };
7293    var setups=[['GOOGL','LONG','C 240 · 09/12','$4.10 → $5.74','+40%','WIN'],['NKE','SHORT','P 72 · 09/12','$1.85 → $2.59','+40%','WIN'],['IREN','LONG','C 42 · 09/12','$2.62 → $3.25','+24%','WIN'],['QCOM','LONG','C 165 · 09/12','$3.80 → $3.70','-3%','LOSS'],['MSFT','LONG','C 505 · 09/09','$3.15 → $4.41','+40%','WIN']];
7294    var h='<div class="demo-blur" style="filter:blur(1.6px);opacity:.85"><div style="display:flex;gap:12px;margin-bottom:14px">'+tile('WIN RATE','67%','#22c55e')+tile('RECORD','24W-12L','#e2e8f0')+tile('TOTAL / CONTRACT','+$2,705','#22c55e')+'</div>'
7295      +'<table class="trk-tbl gf-bright" style="width:100%"><thead><tr><th>Ticker</th><th>Side</th><th>Contract</th><th>Entry → Exit</th><th class="num">P/L</th><th>Result</th></tr></thead><tbody>';
7296    setups.forEach(function(r){ h+='<tr><td class="tk">'+r[0]+'</td><td style="color:'+(r[1]==='LONG'?'#22c55e':'#ef4444')+';font-weight:700">'+r[1]+'</td><td>'+r[2]+'</td><td style="color:#e2e8f0">'+r[3]+'</td><td class="num" style="color:'+(r[4].charAt(0)==='+'?'#22c55e':'#ef4444')+';font-weight:700">'+r[4]+'</td><td><span class="trk-pill '+(r[5]==='WIN'?'win':'loss')+'">'+r[5]+'</span></td></tr>'; });
7297    h+='</tbody></table><div style="margin-top:16px;font-size:12px;color:#cbd5e1">Setups at 6:45 AM PT · Watchlist after the close · Earnings plays · every idea tracked to its exit</div></div>'
7298      + _demoLockCard('Trade Ideas are a Premium feature','Scored setups every morning with the exact contract, tracked from entry to exit with the record, win rate and net P&amp;L shown to members. Sample rows; the tiles are the real Setups record as of early September 2026.');
7299    d.innerHTML=h; iv.appendChild(d);
7300    return;
7301  }
7302}
7303// Trade Plan panel for the demo: a readable SAMPLE plan from the delayed levels on screen (King / Call
7304// Wall / Put Wall / Vol Node chips) plus a Ticker Tide sample from the demo flow. Members get the
7305// SYSTEM plan here (today's picks that include the ticker + an on-demand idea).
7306function demoTradePlanSample(ticker){
7307  var el=document.getElementById('chartTradePlan'); if(!el) return; el.style.display='';
7308  var body=el.querySelector('.nf-tp-body'); if(!body) return;
7309  var up=(typeof UPGRADE_URL!=='undefined')?UPGRADE_URL:'/register';
7310  var num=function(id){ var e=document.getElementById(id); var m=e?(e.textContent||'').replace(/[^0-9.]/g,''):''; var v=parseFloat(m); return isFinite(v)?v:null; };
7311  var king=num('chartKing'), cw=num('chartCW'), pw=num('chartPW'), vn=num('chartVN');
7312  var spot=(typeof chartCurrentPrice==='number'&&chartCurrentPrice>0)?chartCurrentPrice:null;
7313  var f=function(v){ return v==null?'—':(+v).toFixed(2); };
7314  var pos=(spot!=null&&king!=null)?(spot>=king):null;
7315  var tab=(typeof tradePlanTab!=='undefined'&&tradePlanTab==='tide')?'tide':'plan';
7316  var h='<div style="padding:10px 12px"><div style="display:flex;align-items:center;gap:8px;margin-bottom:10px"><span style="font-size:9px;font-weight:800;letter-spacing:1px;color:#f59e0b;border:1px solid rgba(245,158,11,.5);border-radius:4px;padding:1px 6px">PREMIUM · SAMPLE</span><span style="font-size:11px;color:#cbd5e1">'+(ticker||'')+' from today\'s delayed levels</span></div>';
7317  if(tab==='tide'){
7318    var fl=[];
7318 try{ fl=(typeof demoFlows==='function')?(demoFlows(ticker, spot||undefined)||[]):[]; }catch(e){}
7319    var c=0,p=0; fl.forEach(function(x){ var pm=+(x.total_premium||x.premium||0); if(String(x.type||x.option_type||'').toUpperCase().charAt(0)==='C') c+=pm; else p+=pm; });
7320    var tot=(c+p)||1;
7321    h+='<div class="flow-sec-h">TICKER TIDE</div><div style="padding:8px 0"><div style="display:flex;justify-content:space-between;font-size:11px;font-weight:700"><span style="color:#22c55e">Calls $'+(c/1e3).toFixed(0)+'K</span><span style="color:#ef4444">Puts $'+(p/1e3).toFixed(0)+'K</span></div><div style="display:flex;height:8px;border-radius:4px;overflow:hidden;margin:6px 0 10px;background:rgba(148,163,184,.12)"><div style="width:'+Math.round(c/tot*100)+'%;background:#22c55e"></div><div style="flex:1;background:#ef4444"></div></div>';
7322    var bars=[0.35,0.62,0.48,0.8,0.55,0.7,0.42,0.66,0.58,0.74]; h+='<div style="display:flex;gap:3px;align-items:flex-end;height:54px">'; bars.forEach(function(b,i){ var upb=(i%3)!==2; h+='<div style="flex:1;height:'+Math.round(b*100)+'%;background:'+(upb?'rgba(34,197,94,.7)':'rgba(239,68,68,.7)')+';border-radius:2px 2px 0 0"></div>'; }); h+='</div><div style="font-size:10.5px;color:#cbd5e1;margin-top:6px;line-height:1.45">Net call vs put premium flowing into '+(ticker||'the ticker')+', minute by minute. Members see the live tide and the MM gamma-by-strike exposure under it.</div></div>';
7323  } else {
7324    h+='<div style="border:1px solid '+(pos===false?'rgba(239,68,68,.35)':'rgba(34,197,94,.35)')+';background:'+(pos===false?'rgba(239,68,68,.06)':'rgba(34,197,94,.06)')+';border-radius:8px;padding:8px 10px;margin-bottom:10px"><div style="font-weight:800;font-size:11px;color:'+(pos===false?'#fca5a5':'#86efac')+'">'+(pos===false?'BELOW THE KING':'ABOVE THE KING')+'</div><div style="font-size:11px;color:#cbd5e1;margin-top:2px;line-height:1.4">'+(pos===false?'Price trades under the strongest gamma strike. Moves can extend; the King is the magnet overhead.':'Price holds above the strongest gamma strike. Dealers dampen moves; fade extremes toward the walls.')+'</div></div>';
7325    var box=function(side,entry,target,stop){ var g=side==='CALL'; var rr=(entry!=null&&target!=null&&stop!=null&&Math.abs(entry-stop)>0)?(Math.abs(target-entry)/Math.abs(entry-stop)).toFixed(1):'—'; return '<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:8px"><div style="background:'+(g?'rgba(34,197,94,.14)':'rgba(239,68,68,.14)')+';color:'+(g?'#86efac':'#fca5a5')+';font-weight:800;font-size:11px;padding:5px 10px;display:flex;justify-content:space-between"><span>'+(g?'CALL ENTRY':'PUT ENTRY')+'</span><span>R:R '+rr+'</span></div><div style="padding:6px 10px;font-size:11px;color:#cbd5e1;display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px"><div>Entry<br><b style="color:#e2e8f0">'+f(entry)+'</b></div><div>Target<br><b style="color:#22c55e">'+f(target)+'</b></div><div>Stop<br><b style="color:#ef4444">'+f(stop)+'</b></div></div></div>'; };
7326    // Entry = the current price; target / stop = the nearest DISTINCT levels in each direction (on SPY the
7327    // King, Call Wall and Vol Node can all sit on one strike, which collapsed the boxes to R:R 0.0).
7328    var ent=spot!=null?spot:(king!=null?king:null);
7329    var above=[cw,king,vn].filter(function(v){ return v!=null && ent!=null && v>ent+0.01; }).sort(function(a,b){ return a-b; });
7330    var below=[pw,king,vn].filter(function(v){ return v!=null && ent!=null && v<ent-0.01; }
7330).sort(function(a,b){ return b-a; });
7331    var upT=above.length?above[0]:(ent!=null?ent*1.006:null), dnT=below.length?below[0]:(ent!=null?ent*0.994:null);
7332    h+=box('CALL', ent, upT, below.length?below[0]:(ent!=null?ent*0.996:null));
7333    h+=box('PUT', ent, dnT, above.length?above[0]:(ent!=null?ent*1.004:null));
7334    h+='<div style="font-size:10.5px;color:#cbd5e1;line-height:1.5">Members get the SYSTEM plan here: today\'s Setups, Watchlist and SPX/SPY picks that include this ticker, plus a fresh idea built for it on demand.</div>';
7335  }
7336  h+='<a class="demo-lock-btn" href="'+up+'" style="display:inline-block;margin-top:10px">Get Premium</a></div>';
7337  body.innerHTML=h;
7338}
7339// ── GHOST MODE HEADER V2 (admin only, 2026-09-05, user: "make sample 2 only for admin now") ──
7340// Re-parents the EXISTING toolbar controls into two rows and five labelled groups. Nothing is
7341// recreated - every element keeps its id and handlers, so all the existing JS keeps working:
7342//   Row 1 (controls): SYMBOL | TIME | CHART (Layers = the Modes panel with the GEX/VOL grid toggles
7343//          moved inside it, Draw, Replay) | DESK 1/2/3 | TV levels export | ... TV Sync, refresh.
7344//   Row 2 (readouts): basis ALL/0DTE/WK + King / Call Wall / Put Wall / Vol Node / Active Node /
7345//          bias / PIN DAY chip + Net Call/Put GEX, styled as pills, with a Hide-strip toggle.
7346// Off switch: localStorage gfHdr2 = 'off' (reload) restores the classic single row.
7347// ── Theme + menu-bar layout test (ADMIN picker, per browser). Reload on change so the lightweight-charts
7348//    canvases (which cannot read CSS variables) are recreated with gfT() colors. Revert: pick Classic / Top bar,
7349//    or clear localStorage gfTheme + gfNav. Snapshot before this work: git tag pre-theme-2026-09-05.
7350function gfDockH(){
7351  // The desktop dock FLOATS over the rows - nothing is shortened for it (user 2026-09-05: "dont push
7352  // those up"). The PHONE dock is solid and sits on the bottom edge, so the fitted views have to stop
7353  // above it or their last row is unreachable. The phone CSS already carries this with !important;
7354  // this keeps the JS-measured heights (fitChartViewHeight) agreeing with it rather than fighting it.
7355  try{ if(document.body.classList.contains('gf-phone')) return 54; }catch(e){}
7356  return 0;
7357}
7358function gfSetTheme(v){ try{ if(!v||v==='default') localStorage.removeItem('gfTheme'); else localStorage.setItem('gfTheme',v); }catch(e){} location.reload(); }
7359function gfSetNav(v){ try{ if(!v||v==='top') localStorage.removeItem('gfNav'); else localStorage.setItem('gfNav',v); }catch(e){} location.reload(); }
7360// Account-menu Theme section (user 2026-09-06). Highlights the saved choice; reveal = admin now, premium next.
7361function gfThemeMenuSync(){ try{ const t=localStorage.getItem('gfTheme')||'default', n=localStorage.getItem('gfNav')||'top'; document.querySelectorAll('#gfThemeMenu [data-theme]').forEach(function(b){ b.classList.toggle('on', b.dataset.theme===t); }); document.querySelectorAll('#gfThemeMenu [data-nav]').forEach(function(b){ b.classList.toggle('on', b.dataset.nav===n); }); }catch(e){} }
7362// Welcome card (sample B, user 2026-09-06): once per browser for premium/trial, never on the demo.
7363function gfWelcomeMaybe(){ try{ if(localStorage.getItem('gfWelcome2609')==='1') return; const w=document.getElementById('gfWelcome'); if(!w) return; w.style.display='flex'; }catch(e){} }
7364function gfWelcomeClose(keepClassic){ try{ localStorage.setItem('gfWelcome2609','1'); const w=document.getElementById('gfWelcome'); if(w) w.style.display='none'; if(keepClassic){ localStorage.removeItem('gfTheme'); localStorage.setItem('gfNav','center'); location.reload(); } }catch(e){} }
7365// Left rail auto-hide (option B, user 2026-09-07). localStorage gfRailHide: '0' = off, anything else = on (default on).
7366function gfSetRailHide(on){ try{ localStorage.setItem('gfRailHide', on ? '1' : '0'); }catch(e){} location.reload(); }
7367function gfRailAutoHideInit(){
7368  try{
7369    if(document.body.getAttribute('data-nav')!=='left') return;
7370    const row=document.getElementById('gfRailHideRow'), sel=document.getElementById('gfRailHideSel');
7371    const on=(localStorage.getItem('gfRailHide')||'1')!=='0';
7372    if(row){ row.style.display='flex'; }
7372 if(sel) sel.checked=on;
7373    if(!on || document.body._gfRailAuto) return; document.body._gfRailAuto=true;
7374    const g=document.getElementById('gfNavGroup'); if(!g) return;
7375    const hot=document.createElement('div'); hot.id='gfRailHot'; document.body.appendChild(hot);
7376    const edge=document.createElement('div'); edge.id='gfRailEdge'; document.body.appendChild(edge);
7377    document.body.classList.add('gf-rail-auto');
7378    let t=null, over=false;
7379    const menuOpen=()=>{ const m=document.getElementById('userMenu'); return !!(m && m.style.display==='block'); };
7380    const wake=()=>{ clearTimeout(t); t=null; document.body.classList.remove('gf-rail-hid'); };
7381    const arm=()=>{ clearTimeout(t); t=setTimeout(function(){ if(over || menuOpen()){ arm(); return; } document.body.classList.add('gf-rail-hid'); try{ window.dispatchEvent(new Event('resize')); }catch(e){} }, 5000); };
7382    g.addEventListener('mouseenter', function(){ over=true; wake(); });
7383    g.addEventListener('mouseleave', function(){ over=false; arm(); });
7384    hot.addEventListener('mouseenter', function(){ wake(); });
7385    hot.addEventListener('mouseleave', function(){ if(!over) arm(); });
7386    g.addEventListener('click', function(){ wake(); arm(); });
7387    // TYPING IS NOT NAVIGATION (user 2026-09-08: "when we type something into search or add box it
7388    // pop the left panel too when its faded"). This listener exists so a keyboard user can bring the
7389    // rail back, but it fired on EVERY keystroke - so searching for a ticker or adding one popped
7390    // the rail open on the first letter and again on every letter after it. Keys typed into a field
7391    // are ignored; keys pressed anywhere else still wake it.
7392    document.addEventListener('keydown', function(e){
7393      const el = e && e.target;
7394      if(el && (el.tagName==='INPUT' || el.tagName==='TEXTAREA' || el.tagName==='SELECT' || el.isContentEditable)) return;
7395      if(document.body.classList.contains('gf-rail-hid')){ wake(); arm(); }
7396    });
7397    arm();
7398  }catch(e){}
7399}
7400function gfThemeMenuReveal(){ try{ const m=document.getElementById('gfThemeMenu'); if(m) m.style.display='block'; gfThemeMenuSync(); }catch(e){} }
7401// One-time default = Gold + Left rail for anyone who never picked (user 2026-09-06). A later "Classic" / other layout
7402// choice is respected because the init flag is set first. Reloads once so the boot theme script applies it.
7403function gfThemeDefaults(){ try{ if(localStorage.getItem('gfThemeInit')==='1') return; localStorage.setItem('gfThemeInit','1'); let ch=false; if(!localStorage.getItem('gfTheme')){ localStorage.setItem('gfTheme','gold'); ch=true; } if(!localStorage.getItem('gfNav')){ localStorage.setItem('gfNav','left'); ch=true; } if(ch) location.reload(); }catch(e){} }
7404// Dock: floating. 15s without a click/hover -> only the ACTIVE view tile stays; hover or click brings all tiles back
7405// for another 15s (user 2026-09-05).
7406function gfDockAutoHide(g){
7407  let t=null;
7408  const arm=()=>{ if(t) clearTimeout(t); t=setTimeout(()=>{ g.classList.add('gf-dock-min'); }, 5000);   /* 5s idle (user 2026-09-05) */ };
7409  let hovering=false, lastClick=0;
7410  const open=()=>{ g.classList.remove('gf-dock-min'); g.classList.remove('gf-dock-fade'); arm(); };
7411  g.addEventListener('mouseenter', function(){ hovering=true; open(); });
7412  g.addEventListener('click', function(){ lastClick=Date.now(); open(); }, true);
7413  g.addEventListener('mouseleave', function(){ hovering=false; arm(); });
7414  // Fades while the user scrolls anything under it (grid columns, heatmap, flow list); back 700ms after the last
7415  // scroll/wheel event (user 2026-09-05: "the dock should also fade when we scroll the grid").
7416  let ft=null;
7417  // No fade while the mouse is on the dock or within 1.5s of a dock click: switching a view scrolls the page
7418  // programmatically and that scroll used to fade the dock mid-click (user 2026-09-05: "it just stays faded").
7419  const fade=function(e){ if(hovering || (Date.now()-lastClick)<1500) return; if(e && e.target && e.target.nodeType===1 && g.contains(e.target)) return; g.classList.add('gf-dock-fade'); if(ft) clearTimeout(ft); ft=setTimeout(function(){ g.classList.remove('gf-dock-fade'); }, 700); };
7420  document.addEventListener('scroll', fade, { capture:true, passive:true });
7421  document.addEventListener('wheel', fade, { capture:true, passive:true });
7422  arm();
7423}
7424// Rail helpers: the view tiles start at the bottom of whatever strip is visible (ticker line / expiry bar), so they sit
7425// next to the ticker-name row like the user asked; the avatar shows the first letter of the account name.
7426function gfRailTop(){ try{ if(document.body.getAttribute('data-nav')!=='left') return;
7427  if(document.body.classList.contains('gf-ws-on')){
7428    const _h=document.querySelector('.header');
7429    const _b=_h?_h.getBoundingClientRect().bottom:56;
7430    document.documentElement.style.setProperty('--gf-rail-top', Math.round(_b+4)+'px');
7431    document.body.classList.add('gf-rail-ready');   // My HQ header is stable — reveal right away
7432    return;
7433  } let bottom=0, fromBar=false; ['gridExpiryBar','gexTickers','tabs','expiryTickers','gfTkCtl','chartTickers','chainT
7433ickers'].forEach(function(id){ const e=document.getElementById(id); if(!e) return; const r=e.getBoundingClientRect(); if(r.height>0 && r.bottom>bottom){ bottom=r.bottom; fromBar=true; } }); if(!bottom){ const h=document.querySelector('.header'); if(h) bottom=h.getBoundingClientRect().bottom; } document.documentElement.style.setProperty('--gf-rail-top', Math.round(bottom+4)+'px'); if(fromBar) document.body.classList.add('gf-rail-ready'); }catch(e){} }
7434function gfRailAvatar(){ try{ const a=document.getElementById('gfRailAvatar'); if(!a) return; const u=window.gfUser||{}; const n=String(u.username||u.name||u.email||'').trim(); a.textContent=(n[0]||'\u2022').toUpperCase(); a.title=n?('Account \u00b7 '+n):'Account'; }catch(e){} }
7435function gfNavApply(mode){
7436  try{
7437    // A PHONE ONLY EVER GETS THE PLAIN TOP TOOLBAR (user 2026-09-09). The rail costs 74px of a
7438    // 375px screen and every panel it anchors opens past the left edge; the desktop dock floats at
7439    // a fixed 50% with hover-to-reveal, which a finger has no way to trigger. The five-slot phone
7440    // dock built by gfPhoneShell replaces both. This is the one choke point - the boot call and the
7441    // account-menu layout picker both come through here - so neither can build a rail on a phone.
7442    try{ if(window.matchMedia && window.matchMedia('(max-width:640px)').matches) mode='top'; }catch(e){}
7443    mode=mode||'top'; if(mode==='top') return;
7444    const hdr=document.querySelector('.header'); const hr=hdr&&hdr.querySelector('.header-right'); const first=document.getElementById('btn-gex'); if(!hdr||!hr||!first) return;
7445    const g=document.createElement('div'); g.id='gfNavGroup';
7446    hr.insertBefore(g, first);
7447    hr.querySelectorAll('.view-toggle').forEach(b=>g.appendChild(b));   /* keeps ids + inline display toggles (btn-flow/smart/ideas) intact */
7448    if(mode==='center') hdr.insertBefore(g, hr);
7449    if(mode==='left'){
7450      // Rail v3 (user 2026-09-05): logo on top, the view tiles start next to the ticker-name row, then the rest of the
7451      // top row (GEX/VEX/CHARM boxed, Help, bell, Refresh, Discord, screenshot); avatar + account menu pinned to the bottom.
7452      // Search, add-ticker and the theme/menu pickers stay in the top row.
7453      const logoA=hdr.querySelector('a[href="/"]');
7454      if(logoA){ const lw=document.createElement('div'); lw.id='gfRailLogo'; lw.appendChild(logoA); g.insertBefore(lw, g.firstChild); }
7455      // Top row (user 2026-09-05): the GHOST FLOW wordmark (theme-coloured) on the left where the status pill was; market status + clock move to the right, ahead of search.
7456      const word=document.createElement('div'); word.className='logo'; word.id='gfRailWord'; word.innerHTML='<span class="c1">GHOST</span> <span class="c2">FLOW</span>'; hdr.insertBefore(word, hdr.firstChild);
7457      const sw=document.getElementById('searchWrap'); ['alertBellWrap','mktBadge','tzBadge'].forEach(function(id){ const el=document.getElementById(id); if(el && sw) hr.insertBefore(el, sw); });
7458      const views=document.createElement('div'); views.id='gfRailViews'; g.appendChild(views);
7459      Array.from(g.querySelectorAll('.view-toggle')).forEach(b=>views.appendChild(b));
7460      const tools=document.createElement('div'); tools.id='gfRailTools'; g.appendChild(tools);
7461      const bot=document.createElement('div'); bot.id='gfRailBottom';
7462      const av=document.createElement('button'); av.id='gfRailAvatar'; av.type='button'; av.title='Account'; av.textContent='\u2022';
7463      av.setAttribute('onclick', "var m=document.getElementById('userMenu');if(m){if(m.style.display==='block'){m.style.display='none';}else{document.body.appendChild(m);m.style.display='block';}}");   /* inline attribute on purpose: the outside-click closer (line ~4075) only spares [onclick*=userMenu] */
7464      bot.appendChild(av);
7465      const keep={searchWrap:1,customTicker:1,gfThemeSel:1,gfNavSel:1,gfNavGroup:1,mktBadge:1,tzBadge:1,alertBellWrap:1};   /* alerts bell stays in the top row too (user 2026-09-06) */   /* status + clock were just moved into the top row - do not sweep them into the rail */
7466      Array.from(hr.children).forEach(el=>{ if(keep[el.id]) return; if(el.querySelector && el.querySelector('#userMenu')) bot.appendChild(el); else tools.appendChild(el); });
7467      g.appendChild(bot);
7468      gfRailAvatar(); gfRailTop(); setInterval(gfRailTop, 1000);
7469      // The ticker bars gfRailTop measures aren't laid out yet on the first call, so the rail starts at
7470      // the fallback offset and the 1s interval later snaps it into place — a visible jump on load
7471      // (user 2026-09-17). Re-measure on the next frames and shortly after so it settles before it's seen.
7472      try{ requestAnimationFrame(gfRailTop); [60,180,400,800].forEach(function(ms){ setTimeout(gfRailTop, ms); }); setTimeout(function(){ document.body.classList.add('gf-rail-ready'); }, 900); }catch(e){}
7473    }
7474    if(mode==='dock') gfDockAutoHide(g);
7475    document.body.setAttribute('data-nav', mode);
7476    try{ updateHeaderHeightVar(); }catch(e){}
7477  }catch(e){}
7478}
7479try{ gfNavApply(localStorage.getItem('gfNav')); }catch(e){}
7480// ═══════════ PHONE SHELL (user 2026-09-09) ═══════════
7481// Five slots, chosen by the user: Grid, Heatmap, Ghost Mode, Flow, More. Nine views do not fit five
7482// slots, so the four with the most daily use get one and the rest sit one tap deep in a sheet that
7483// is still entirely thumb-reachable.
7484//
7485// The dock does not own navigation - it CLICKS the real .view-toggle buttons, which keeps every gate
7486// (premium checks, demo downgrades, the boot-view re-apply after /api/me) in the one place that
7487// already handles them. It reads .active back off those same buttons, so it cannot drift out of step
7488// with the view no matter who called setView.
7489function gfPhoneIs(){ try{ return !!(window.matchMedia && window.matchMedia('(max-width:640px)').matches); }catch(e){ return false; } }
7490// A view is available when its tab is not hidden. Both gates matter: the premium reveal writes an
7491// INLINE display, and the layout gate is a stylesheet rule (body.gf-newlayout #btn-ultra), so only
7492// the computed value sees both. The phone CSS clips the tabs rather than hiding them for this reason.
7493function gfPhoneBtnLive(id){
7494  var b=document.getElementById(id); if(!b) return null;
7495  try{ if(getComputedStyle(b).display==='none') return null; }catch(e){ if(b.style.display==='none') return null; }
7496  return b;
7497}
7498// WHICH BUTTON IS "GHOST MODE" DEPENDS ON THE LAYOUT. Under the new layout btn-chart is renamed to
7499// Ghost Mode and btn-ultra is hidden; under the classic one btn-ultra keeps the name and btn-chart is
7500// GEX charts. Resolving by the label the user can actually read is right in both, and stays right if
7501// the names move again.
7502function gfPhoneGhostIds(){
7503  var named=[];
7504  ['btn-chart','btn-ultra'].forEach(function(id){ var b=gfPhoneBtnLive(id); if(b && (b.textContent||'').trim()==='Ghost Mode') named.push(id); });
7505  return named.concat(['btn-ultra','btn-chart']);
7506}
7507// The four slots the user chose, resolved against what this account can actually open. A slot never
7508// goes dead: if its first choice is gated it falls through, and if nothing is live it keeps the id so
7509// the slot starts working the moment /api/me opens the gate.
7510function gfPhoneSlots(){
7511  var used={};
7512  function pick(ids){
7513    for(var i=0;i<ids.length;i++){ if(!used[ids[i]] && gfPhoneBtnLive(ids[i])){ used[ids[i]]=1; return ids[i]; } }
7514    used[ids[0]]=1; return ids[0];
7515  }
7516  var g=pick(['btn-gex']), h=pick(['btn-expiry']), gh=pick(gfPhoneGhostIds()), fl=pick(['btn-flow','btn-scanner']);
7517  function lbl(id, want){ if(id===want[0]) return want[1]; var b=document.getElementById(id); return (b&&(b.textContent||'').trim())||want[1]; }
7518  return [[g,'Grid'],[h,'Heatmap'],[gh,'Ghost'],[fl, lbl(fl,['btn-flow','Flow'])]];
7519}
7520function gfPhoneGo(id){ var b=document.getElementById(id); if(b) b.click(); gfPhoneSheet(false); }
7521function gfPhoneSheet(open){
7522  var m=document.getElementById('gfPhoneMore'), bd=document.getElementById('gfPhoneBackdrop');
7523  if(open===undefined) open=!(m && m.classList.contains('on'));
7524  if(open) gfPhoneMoreFill();
7525  if(m) m.classList.toggle('on', !!open);
7526  if(bd) bd.classList.toggle('on', !!open);
7527  gfPhoneDockSync();
7528}
7529// Rebuilt on every open rather than once: /api/me answers after boot and reveals Ideas and Smart
7530// Money, and updateFlowNav can move Flow, so a list built at load time would be stale by the time
7531// anyone opened it.
7532function gfPhoneMoreFill(){
7533  var m=document.getElementById('gfPhoneMore'); if(!m) return;
7534  var slot={}, lbl={};
7535  gfPhoneSlots().forEach(function(p){ slot[p[0]]=1; var b=document.getElementById(p[0]); if(b) lbl[(b.textContent||'').trim()]=1; });
7536  var h='<div class="grab"></div>';
7537  Array.prototype.forEach.call(document.querySelectorAll('.view-toggle'), function(b){
7538    // Skip by id AND by label: two buttons can carry the same name (btn-chart and btn-ultra are both
7539    // "Ghost Mode" if a layout leaves them both live), and listing the twin reads as a second view.
7540    if(!b.id || !gfPhoneBtnLive(b.id) || slot[b.id] || lbl[(b.textContent||'').trim()]) return;
7541    h+='<button class="r'+(b.classList.contains('active')?' on':'')+'" onclick="gfPhoneGo(this.getAttribute(\'data-go\'))" data-go="'+b.id+'">'+(b.textContent||'').trim()+'</button>';
7542  });
7543  // (Layers removed from this sheet per user 2026-09-20 - it is reachable from the Ghost Mode header.)
7544  m.innerHTML=h;
7545}
7546function gfPhoneLayers(){
7547  gfPhoneSheet(false);
7548  var b=document.getElementById('indicatorBtn'); if(b) b.click();
7549}
7550function gfPhoneDockSync(){
7551  var d=document.getElementById('gfPhoneDock'); if(!d) return;
7552  var ms=document.getElementById('gfPhoneMore');
7553  var moreOpen=!!(ms && ms.classList.contains('on'));
7554  // Re-resolve every tick rather than only at build: Flow is hidden until updateFlowNav runs, and
7555  // /api/me reveals Ideas and Smart Money well after the dock is built, so a slot decided at load
7556  // time can be pointing at the wrong view by the time anyone taps it.
7557  var sl=gfPhoneSlots(), tiles=d.querySelectorAll('button[data-btn]');
7558  for(var i=0;i<tiles.length && i<sl.length;i++){
7559    if(tiles[i].getAttribute('data-btn')!==sl[i][0]) tiles[i].setAttribute('data-btn', sl[i][0]);
7560    if(tiles[i].textContent!==sl[i][1]) tiles[i].textContent=sl[i][1];
7561  }
7562  var hit=false;
7563  Array.prototype.forEach.call(d.querySelectorAll('button[data-btn]'), function(t){
7564    var b=document.getElementById(t.getAttribute('data-btn'));
7565    var on=!moreOpen && !!(b && b.classList.contains('active'));
7566    if(on) hit=true;
7567    t.classList.toggle('on', on);
7568  });
7569  // Nothing in the dock is the current view (Pulse, Ideas, Tracking, Charts, Smart Money): light
7570  // More instead, so the dock never reads as if the user is somewhere they are not.
7571  var mb=d.querySelector('button[data-more]'); if(mb) mb.classList.toggle('on', moreOpen || !hit);
7572}
7573/* Phone zoom (user 2026-09-20): the interactive zoom slider was REMOVED - changing the layout scale
7574   mid-session misbehaved (Grid black gap on the right, dock buttons hiding, needed a reload). Instead
7575   the phone renders at a FIXED, maximally-zoomed-out scale set ONCE at load (the clean "after reload"
7576   state) so nothing shifts. The scale gives a ~630px layout on any phone (just under the 640px phone
7577   breakpoint), scaled to fit the screen. Pinch-zoom stays on so the user can zoom into any part. The
7578   dock counter-scales by --gfz so the nav chrome stays a constant physical size. */
7579function gfPhoneZoomMin(){
7580  try{ var w=Math.min(screen.width||390, screen.height||800)||390; return Math.max(0.5, Math.min(0.9, Math.ceil((w/632)*100)/100)); }catch(e){ return 0.65; }
7581}
7582/* The layout width is pinned EXPLICITLY (width = deviceWidth/scale), not width=device-width: the
7583   latter with initial-scale<1 is contradictory and leaves part of the screen black. This makes the
7584   page fill the screen exactly at the fixed scale (layoutWidth * scale = deviceWidth). */
7585function gfPhoneVpWidth(scale){
7586  try{ var w=Math.min(screen.width||390, screen.height||800)||390; return Math.max(300, Math.round(w/scale)); }catch(e){ return 520; }
7587}
7588function gfPhoneViewport(on){
7589  try{
7590    var mv=document.querySelector('meta[name=viewport]'); if(!mv) return;
7591    if(on){
7592      if(window._gfVpOrig==null) window._gfVpOrig=mv.getAttribute('content')||'width=device-width, initial-scale=1.0';
7593      var z=gfPhoneZoomMin();   /* fixed, maximally zoomed-out - no interactive slider (user 2026-09-20) */
7594      var want='width='+gfPhoneVpWidth(z)+', initial-scale='+z+', minimum-scale=0.4, maximum-scale=5, user-scalable=yes';
7595      if(mv.getAttribute('content')!==want) mv.setAttribute('content',w
7595ant);
7596      try{ document.body.style.setProperty('--gfz', z); }catch(e){}   /* the nav chrome counter-scales by this so it stays a constant physical size */
7597    } else if(window._gfVpOrig!=null && mv.getAttribute('content')!==window._gfVpOrig){
7598      mv.setAttribute('content',window._gfVpOrig);
7599      try{ document.body.style.removeProperty('--gfz'); }catch(e){}
7600    }
7601  }catch(e){}
7602}
7603/* Phone side-panel drawers (user 2026-09-20): every mode's left/right side panel (Flow filters,
7604   Pulse alerts, Node alerts) becomes a slide-in drawer OVER the main content instead of eating the
7605   screen width. One edge tab per panel toggles it; it defaults minimized so the main content is
7606   full width, and the open/closed choice persists. The panels are plain fixed-width flex children
7607   (flex:none, 210-300px) inside a flex row whose other child is flex:1 - making the panel
7608   position:absolute drops it out of the row so the main child fills the width. */
7609function gfPhoneSideCfg(){ return [
7610  {panel:'flowFilters',    side:'left',  label:'FILTERS'},
7611  {panel:'scanAlertsFeed', side:'right', label:'ALERTS'},
7612  {panel:'nodeAlertsFeed', side:'right', label:'ALERTS'}
7613]; }
7614function gfSideMinGet(id){ try{ var v=localStorage.getItem('gfSideMin_'+id); return v===null? true : v==='1'; }catch(e){ return true; } }
7615function gfSideMinSet(id,on){ try{ localStorage.setItem('gfSideMin_'+id, on?'1':'0'); }catch(e){} }
7616function gfSideTabPaint(tab,cfg,collapsed,p){
7617  // Always show the label + arrow in BOTH states so the CLOSE handle is as obvious as the OPEN one
7618  // (user 2026-09-20: "window to open the panel is coming but to close them is not coming" - the old
7619  // open-state tab was a bare arrow and read as invisible). Arrow points the way a tap moves the panel.
7620  var arrow = collapsed ? (cfg.side==='left'?'›':'‹') : (cfg.side==='left'?'‹':'›');
7621  tab.textContent = collapsed ? (cfg.label+' '+arrow) : ('CLOSE '+arrow);
7622  tab.classList.toggle('gf-side-tab-open', !collapsed);
7623  var off = collapsed ? 0 : ((p&&p.offsetWidth)||0);
7624  tab.style[cfg.side] = off+'px';
7625  tab.style[cfg.side==='left'?'right':'left'] = 'auto';
7626}
7627function gfPhoneSideToggle(id){
7628  var cfg=null, arr=gfPhoneSideCfg(); for(var i=0;i<arr.length;i++){ if(arr[i].panel===id){ cfg=arr[i]; break; } }
7629  if(!cfg) return;
7630  var p=document.getElementById(id); if(!p) return;
7631  var on=!p.classList.contains('gf-sidemin');
7632  p.classList.toggle('gf-sidemin', on);
7633  gfSideMinSet(id,on);
7634  var tab=document.getElementById('gfSideTab_'+id); if(tab) gfSideTabPaint(tab,cfg,on,p);
7635}
7636function gfPhoneSideApply(cfg){
7637  var p=document.getElementById(cfg.panel); if(!p) return;
7638  var par=p.parentElement; if(!par) return;
7639  par.classList.add('gf-sidewrap');
7640  p.classList.add('gf-side-drawer','gf-side-'+cfg.side);
7641  var on=gfSideMinGet(cfg.panel);
7642  p.classList.toggle('gf-sidemin', on);
7643  var tabId='gfSideTab_'+cfg.panel, tab=document.getElementById(tabId);
7644  if(!tab){ tab=document.createElement('button'); tab.id=tabId; tab.type='button';
7645    tab.className='gf-side-tab gf-side-'+cfg.side;
7646    tab.onclick=(function(pid){ return function(e){ e.stopPropagation(); gfPhoneSideToggle(pid); }; })(cfg.panel);
7647    par.appendChild(tab); }
7648  gfSideTabPaint(tab,cfg,on,p);
7649}
7650function gfPhoneSidePanels(){ if(!gfPhoneIs()) return; try{ gfPhoneSideCfg().forEach(gfPhoneSideApply); }catch(e){} }
7651function gfPhoneShell(){
7652  try{
7653    var on=gfPhoneIs();
7654    document.body.classList.toggle('gf-phone', on);
7655    gfPhoneViewport(on);
7656    if(!on) return;
7657    if(!document.getElementById('gfPhoneDock')){
7658      var bd=document.createElement('div'); bd.id='gfPhoneBackdrop';
7659      bd.onclick=function(){ gfPhoneSheet(false); };
7660      document.body.appendChild(bd);
7661      var m=document.createElement('div'); m.id='gfPhoneMore'; document.body.appendChild(m);
7662      var d=document.createElement('div'); d.id='gfPhoneDock';
7663      var h='';
7664      gfPhoneSlots().forEach(function(p){ h+='<button data-btn="'+p[0]+'" onclick="gfPhoneGo(this.getAttribute(\'data-btn\'))">'+p[1]+'</button>'; });
7665      h+='<button data-more="1" onclick="gfPhoneSheet()">More</button>';
7666      d.innerHTML=h; document.body.appendChild(d);
7667      // Cheap and unbreakable: read .active off the real buttons on a timer instead of wrapping
7668      // setView, which is called from a dozen places and from inline onclick attributes.
7669      setInterval(gfPhoneDockSync, 900);
7670    }
7671    gfPhoneDockSync();
7672    gfPhoneSidePanels();
7673  }catch(e){}
7674}
7675try{ gfPhoneShell(); }catch(e){}
7676window.addEventListener('resize', function(){ try{ gfPhoneShell(); }catch(e){} });
7677// Ghost Mode compact rows (ADMIN test, user 2026-09-05): Timeframes + Layers + Replay + Desk move up into the site's top
7678// row (shown only on the chart view); the ticker name + price join the LEVELS line on the left, TV + TV Sync join it on the
7679// right. Ghost Mode loses its own first header row.
7680function gfGhostCompact(){
7681  try{
7682    if(document.getElementById('gfHdrChartCtl')) return;
7683    const hdr=document.querySelector('.header'); if(!hdr) return;
7684    const ctl=document.createElement('div'); ctl.id='gfHdrChartCtl';
7685    // Hold REFERENCES to the two groups. A node removed from the document keeps its children,
7686    // listeners and state - only getElementById stops finding it - so these let the toolbar be
7687    // rebuilt even when the whole subtree has been taken out, which is the state the healer could
7688    // not recover from: it required the groups to be in the DOM, and they had gone with the parent.
7689    window._gfCtlGroups = window._gfCtlGroups || {};
7690    ['gfHgTime','gfHgChart'].forEach(function(id){ const g=document.getElementById(id)||window._gfCtlGroups[id]; if(g){ window._gfCtlGroups[id]=g; ctl.appendChild(g); } });
7691    // DESK 1/2/3 sits on the LEVELS row (after ALL / 0DTE / WK), not on the chart (user 2026-09-06)
7692    try{ const gd=document.getElementById('gfHgDesk'), Ld=document.getElementById('gfLvlLeft'); if(gd && Ld) Ld.appendChild(gd); }catch(e){}
7693    // VOL column toggle sits right after DESK 1/2/3 on the LEVELS row instead of inside the Layers panel (user 2026-09-06)
7694    try{ const vt=document.getElementById('chartVolToggle'), gd2=document.getElementById('gfHgDesk'); if(vt && gd2){ vt.style.marginLeft='0'; gd2.appendChild(vt); } }catch(e){}
7695    // User 2026-09-05: "it needs to be on chart window top" - a floating toolbar at the top-left of the chart pane
7696    // (right of the drawing rail), not in the site header. loadGEXChart keeps it when it rebuilds the pane.
7697    const content=document.getElementById('chartContent'); if(content) content.appendChild(ctl); else hdr.appendChild(ctl);
7698    // Show it at once when the ladder has already been measured this session, so a rebuild does not
7699    // blink out for up to a tick (2s). Before the first measurement it stays hidden as designed.
7700    try{ if(window._gfCtlPlaced) document.body.classList.add('gf-ctl-ready'); }catch(e){}   /* the CSS rule does the showing; nothing inline to lose on the next rebuild */
7701    const L=document.getElementById('gfLvlLeft'), R=document.getElementById('gfLvlRight'), hide=document.getElementById('gfHideStrip');
7702    const sym=document.getElementById('gfHgSym'); if(L && sym) L.insertBefore(sym, L.firstChild);
7703    const tv=document.getElementById('gfHgTv'), act=document.getElementById('gfHgAct');
7704    if(R){ if(tv) R.insertBefore(tv, hide||null); if(act){ act.classList.remove('gf-hpush'); R.insertBefore(act, hide||null); } }
7705    document.body.classList.add('gf-ghost-compact');
7706    try{ updateHeaderHeightVar(); }catch(e){} try{ fitChartViewHeight(); }catch(e){}
7707    try{ if(!window._gfPaneTbT) window._gfPaneTbT=setInterval(gfPaneToolbars, 600); }catch(e){}
7708  }catch(e){}
7709}
7710// DESK 2/3 toolbars (admin test, user 2026-09-05: "timeframe, layers and draw on all charts, working for the chart
7711// we select"). The REAL toolbar (#gfHdrChartCtl) moves onto the FOCUSED chart; every other visible chart gets a light
7712// copy with the timeframes, Layers and Draw. Clicking the copy focuses that chart first, then does the action - the
7713// existing controls already drive the focused chart (switchTimeframe / Layers / drawings are per pane).
7714function gfPaneToolbars(){
7715  try{
7716    const content=document.getElementById('chartContent'); if(!content) return;
7717    let ctl=document.getElementById('gfHdrChartCtl');
7718    // Rebuild if it went missing, but only when the groups it is built FROM still exist - an empty
7719    // toolbar would look more broken than none.
7720    if(!ctl && (document.getElementById('gfHgTime')||document.getElementById('gfHgChart'))){
7721      try{ if(typeof gfGhostCompact==='function') gfGhostCompact(); }catch(e){}
7722      ctl=document.getElementById('gfHdrChartCtl');
7723    }
7724    if(!ctl) return;
7725    const multi=(typeof _mLayout!=='undefined' && _mLayout>=2);
7726    const fid=(multi && typeof _mFocusedPaneId==='function')?_mFocusedPaneId():null;
7727    const host=(fid!=null && _mCharts[fid] && _mCharts[fid].canvas && document.getElementById('mchart-pane-'+fid)) ? _mCharts[fid].canvas : content;
7728    if(ctl.parentElement!==host) host.appendChild(ctl);
7729    ctl.classList.toggle('gf-in-pane', host!==content);
7730    // the real toolbar highlights the timeframe of the chart it sits on (renderTfBar only knows chart 1)
7731    try{ const tfReal = fid!=null ? ((((_mSlots||[])[(_mCharts[fid]||{}).slot])||{}).tf||'') : (typeof currentTimeframe!=='undefined'?currentTimeframe:''); if(tfReal) document.querySelectorAll('#tfButtons .tf-btn[data-tf]').forEach(function(b){ b.classList.toggle('active', b.dataset.tf===tfReal); }); }catch(e){}
7732    const hosts=[{key:0, el:content}];
7733    [2,3].forEach(function(id){ const c=_mCharts[id]; if(multi && c && c.slot!=null && c.canvas && document.getElementById('mchart-pane-'+id)) hosts.push({key:id, el:c.canvas}); });
7734    hosts.forEach(function(h){
7735      let gh=null; for(const ch of h.el.children){ if(ch.classList && ch.classList.contains('gf-pane-tb')){ gh=ch; break; } }
7736      const focused=(h.key===0) ? (fid==null) : (fid===h.key);
7737      if(!multi || focused){ if(gh) gh.style.display='none'; return; }
7738      if(!gh){
7739        gh=document.createElement('div'); gh.className='gf-pane-tb'; h.el.appendChild(gh);
7740        gh.addEventListener('click', function(e){
7741          e.stopPropagation(); e.preventDefault();
7742          const b=e.target.closest('button'); if(!b) return;
7743          const slot=(h.key===0)?0:((_mCharts[h.key]||{}).slot);
7744          try{ if(slot!=null) _mSetFocus(slot); }catch(_){}
7745          const act=b.dataset.act, tf=b.dataset.tf;
7746          setTimeout(function(){
7747            try{ gfPaneToolbars(); }catch(_){}
7748            if(tf){ try{ switchTimeframe(tf); }catch(_){} }
7749            else if(act==='layers'){ try{ toggleIndicatorPanel(); }catch(_){} }
7750            else if(act==='tfmenu'){ try{ toggleTfMenu(true); }catch(_){} }
7751            else if(act==='draw'){ const g=document.getElementById('gfHgDraw'); if(g) g.classList.add('open'); }
7752          },0);
7753        });
7754        gh.addEventListener('mousedown', function(e){ e.stopPropagation(); }, true);
7755        // Hover on a copy's Draw = focus that chart and open the menu right there (user 2026-09-07: had to click the chart first)
7756        gh.addEventListener('mouseover', function(e){
7757          const b=e.target.closest('button'); if(!b || b.dataset.act!=='draw') return;
7758          const slot=(h.key===0)?0:((_mCharts[h.key]||{}).slot);
7759          try{ if(slot!=null) _mSetFocus(slot); }catch(_){}
7760          setTimeout(function(){ try{ gfPaneToolbars(); }catch(_){} const g=document.getElementById('gfHgDraw'); if(g) g.classList.add('open'); },0);
7761        });
7762      }
7763      const tfNow=(h.key===0) ? (typeof currentTimeframe!=='undefined'?currentTimeframe:'') : (((_mSlots||[])[(_mCharts[h.key]||{}).slot]||{}).tf||'');
7764      const tfs=Array.from(document.querySelectorAll('#tfButtons .tf-btn[data-tf]')).map(function(b){ return '<button class="tf-btn'+(b.dataset.tf===tfNow?' active':'')+'" data-tf="'+b.dataset.tf+'">'+b.textContent+'</button>'; }).join('');
7765      // Copies show the SAME info as the real toolbar (user 2026-09-06): this chart's Layers count and the selected Draw tool.
7766      let cnt=0;
7767      try{
7768        if(h.key===0){ cnt=Object.keys(indState||{}).filter(function(k){ return indState[k]===true && document.getElementById('mode-'+k); }).length; const gx=document.getElementById('chartGexToggle'), vl=document.getElementById('chartVolToggle'); if(gx&&gx.classList.contains('active')) cnt++; if(vl&&vl.classList.contains('active')) cnt++; }
7769        else { const sl=(_mCharts[h.key]||{}).slot; cnt=_MPANE_MODES.filter(function(k){ return _mModeOn(sl,k); }).length; }
7770      }catch(e){}
7771      const realDraw=document.getElementById('gfDrawBtn');
7772      const drawHtml=(realDraw&&realDraw.innerHTML)||'\u270e Draw', drawOn=!!(realDraw&&realDraw.classList.contains('active'));
7773      // The ▾ more-timeframes button rides on every copy too (user 2026-09-22): clicking it focuses that chart (the real
7774      // toolbar moves there) and opens the full timeframe menu on it.
7775      const html='<div class="gf-hgrp">'+tfs+'<button class="tf-btn" data-act="tfmenu" title="More timeframes" style="padding-left:6px;padding-right:6px">▾</button></div><div class="gf-hgrp"><button class="tf-btn" data-act="layers">Layers <span class="gf-badge">'+(cnt||'')+'</span></button></div><div class="gf-hgrp"><button class="tf-btn'+(drawOn?' active':'')+'" data-act="draw">'+drawHtml+'</button></div>';
7776      if(gh.dataset.html!==html){ gh.innerHTML=html; gh.dataset.html=html; }
7777      gh.style.display='';
7778    });
7779  }catch(e){ try{ console.warn('[PaneTB]',e); }catch(_){} }
7780}
7781function gfHeaderV2(){
7782  try{
7783    if(localStorage.getItem('gfHdr2')==='off') return;
7784    const hd=document.getElementById('chartHeader'); if(!hd||hd._v2) return; hd._v2=true;
7785    const $=id=>document.getElementById(id);
7786    const grp=(id,label,els)=>{ const g=document.createElement('div'); g.className='gf-hgrp'; g.id=id; if(label){ const l=document.createElement('span'); l.className='gf-hlbl'; l.textContent=label; g.appendChild(l); }
7786 els.forEach(e=>{ if(e) g.appendChild(e); }); return g; };
7787    // Layers = Modes panel + the GEX ladder / VOL column toggles moved into a GRID row at its top.
7788    const iw=$('indicatorWrap'), ib=$('indicatorBtn'), ip=$('indicatorPanel');
7789    if(ib){ ib.innerHTML='Layers <span id="gfLayersBadge" class="gf-badge"></span>';   /* no caret (user 2026-09-06) */ ib.title='Chart layers: GEX ladder, VOL column, levels, GEX Visual, indicators, session levels, flow marks'; }
7790    if(ip && !$('gfGridLayerRow')){
7791      const head=$('modePanelHead'); const r=document.createElement('div'); r.id='gfGridLayerRow';
7792      r.style.cssText='display:flex;align-items:center;gap:6px;padding:6px 0 8px;border-bottom:1px solid var(--border);margin-bottom:6px';
7793      r.innerHTML='<span class="gf-hlbl" style="margin-right:2px">GRID</span>';
7794      const gx=$('chartGexToggle'), vl=$('chartVolToggle');
7795      if(gx){ gx.style.marginLeft='0'; r.appendChild(gx); } if(vl){ vl.style.marginLeft='0'; r.appendChild(vl); }
7796      if(head&&head.parentNode===ip) ip.insertBefore(r, head.nextSibling); else ip.insertBefore(r, ip.firstChild);
7797      try{ if(head) head.querySelectorAll('span').forEach(x=>{ if(x.textContent.trim()==='MODES') x.textContent='LAYERS'; }); }catch(e){}
7798    }
7799    const chips=$('chartHeaderChips'); const tvs=$('tvSyncBtn'); const refresh=chips?chips.querySelector('button.refresh-btn'):null;
7800    const gSym=grp('gfHgSym','',[ $('chartTickerName'), $('chartSpot'), $('chartProxyBadge') ]);
7801    const gTime=grp('gfHgTime','TIME',[ $('tfButtons') ]);
7802    const gChart=grp('gfHgChart','CHART',[ iw, $('drawTools'), $('replayBtn') ]);
7803    const gDesk=grp('gfHgDesk','CHARTS',[ $('mchartToggle') ]);   /* renamed from DESK (user 2026-09-07) */
7804    const gTv=grp('gfHgTv','',[ $('tvLevelsBtn') ]);
7805    const gAct=grp('gfHgAct','',[ tvs, refresh ]); gAct.classList.add('gf-hpush');
7806    // Row 2 - readouts. The chips container moves as a unit so the JS that writes King/CW/PW/VN/
7807    // bias/PIN DAY into it keeps working; Net GEX (ultraTopMetrics) follows it.
7808    const row2=document.createElement('div'); row2.id='chartLevelsBar'; row2.className='gf-hrow2';
7809    // Three zones (user 2026-09-05): LEFT sits over the GEX ladder (label + basis), MID is centred over the
7810    // chart (the level pills), RIGHT holds the day regime chip + Net GEX next to the Hide button.
7811    const L=document.createElement('div'); L.id='gfLvlLeft'; const M=document.createElement('div'); M.id='gfLvlMid'; const R=document.createElement('div'); R.id='gfLvlRight';
7812    const lbl=document.createElement('span'); lbl.className='gf-hlbl'; lbl.textContent='LEVELS'; L.appendChild(lbl);
7813    if(chips){ const tg=chips.querySelector('.gexp-toggle'); if(tg) L.appendChild(tg); M.appendChild(chips); const cb=$('chartBias'); if(cb) R.appendChild(cb); const cr=$('chartRegime'); if(cr) R.appendChild(cr); }
7814    const tm=$('ultraTopMetrics'); if(tm) R.appendChild(tm);
7815    const hb=document.createElement('button'); hb.id='gfHideStrip'; hb.className='tf-btn gf-hide-strip'; hb.onclick=function(){ gfToggleLevelsStrip(); }; R.appendChild(hb);
7816    row2.appendChild(L); row2.appendChild(M); row2.appendChild(R);
7817    // Anything this function does not know about stays in row 1, before the actions.
7818    const rest=Array.from(hd.childNodes).filter(n=>n.nodeType===1);
7819    Array.from(hd.childNodes).forEach(n=>{ if(n.nodeType!==1) hd.removeChild(n); });
7820    const gMisc=grp('gfHgMisc','',rest); gMisc.style.display=rest.length?'':'none';
7821    [gSym,gTime,gChart,gDesk,gTv,gMisc,gAct,row2].forEach(g=>hd.appendChild(g));
7822    try{ if(typeof isPaidUser!=='undefined' && isPaidUser) gfGhostCompact(); }catch(e){}   // premium (user 2026-09-06): one Ghost Mode row less
7823    [gChart,gDesk,gTv].forEach(g=>{ try{ const vis=Array.from(g.children).filter(c=>!c.classList.contains('gf-hlbl')&&getComputedStyle(c).display!=='none'); if(!vis.length) g.style.display='none'; }catch(e){} });   // demo / members without those controls: no empty labelled groups
7824    document.body.classList.add('gf-hdr2');
7825    // First activation: the Layers button moved, so a Modes panel dragged to a saved FIXED spot on the old
7826    // layout can open off-screen - snap it back under the button once.
7827    try{ if(localStorage.getItem('gfHdr2Init')!=='1'){ localStorage.setItem('gfHdr2Init','1'); if(typeof _modePanelResetPos==='function') _modePanelResetPos(); } }catch(e){}
7828    gfToggleLevelsStrip(localStorage.getItem('gfLevelsStrip')==='hide');
7829    try{ gfDrawRailInit(); }catch(e){}
7830    try{ if(typeof isPaidUser!=='undefined' && isPaidUser) gfDrawMenuInit(); }catch(e){}   // premium (user 2026-09-06): the rail becomes a Draw dropdown in the chart toolbar
7831    try{ gfTickerStripInit(); }catch(e){}
7832    if(!window._gfLayersBadgeT) window._gfLayersBadgeT=setInterval(gfL
7832ayersBadgeTick,2000); gfLayersBadgeTick();
7833    try{ if(typeof updateHeaderHeightVar==='function') updateHeaderHeightVar(); if(typeof fitChartViewHeight==='function') fitChartViewHeight(); }catch(e){}
7834  }catch(e){ try{ console.warn('[Hdr2]',e); }catch(_){} }
7835}
7836function gfToggleLevelsStrip(force){
7837  const r=document.getElementById('chartLevelsBar'), b=document.getElementById('gfHideStrip'); if(!r) return;
7838  const hide=(typeof force==='boolean')?force:!r.classList.contains('collapsed');
7839  r.classList.toggle('collapsed',hide);
7840  if(b){ b.innerHTML=hide?'Levels ▾':'▴'; b.title=hide?'Show the King / walls / Vol Node / Net GEX strip':'Collapse the level strip for a taller chart'; }
7841  try{ localStorage.setItem('gfLevelsStrip',hide?'hide':'show'); }catch(e){}
7842  try{ if(typeof fitChartViewHeight==='function') fitChartViewHeight(); }catch(e){}
7843}
7844// SELF-HEALING CHART TOOLBAR (user 2026-09-08, three reports in a row: "it again fades away",
7845// "i clicked 5 min again and it again went away", "its gone entirely"). Four separate code paths can
7846// take this toolbar down - the chart rebuild inside loadGEXChart, the Desk re-parenting in
7847// gfPaneToolbars, gfGhostCompact building a brand new element, and the ladder measurement moving it
7848// off its spot - and each fix so far only closed the one path in front of it. It could not be
7849// reproduced in an automated browser across scripted clicks, real mouse events, resize and the rail
7850// auto-hide, so rather than keep chasing paths one at a time, watch the ELEMENT and put it back the
7851// moment anything takes it away.
7852//
7853// Cheap on purpose: a childList observer on #chartContent (not the whole document) plus an
7854// attribute observer on the toolbar itself, both coalesced into one rAF, and the 2s badge tick as a
7855// backstop. Every repair logs a [gfCtl] line naming what was wrong - the next time this happens the
7856// console says which path did it, which is the thing three rounds of fixes have been missing.
7857var _gfCtlFixes = 0, _gfCtlRaf = 0, _gfCtlObs = null, _gfCtlAttrObs = null;
7858function gfCtlHeal(){
7859  try{
7860    // NO VIEW GUARD. The first cut of this read `view !== 'gex'`, but the chart view sets
7861    // data-view="chart" - so the guard matched every single call and the healer never ran once,
7862    // while also replacing the plain visibility assertion it was meant to supersede. The guard was
7863    // never needed: the toolbar lives inside #chartContent, which is itself hidden off the chart
7864    // view, so healing it on another view costs nothing and hides nothing.
7865    var c = document.getElementById('gfHdrChartCtl');
7866    if(!c){
7867      // Only rebuild when the groups it is assembled FROM survive - an empty shell looks worse than none.
7868      const _cg = window._gfCtlGroups || {};
7869      if((document.getElementById('gfHgTime') || document.getElementById('gfHgChart') || _cg.gfHgTime || _cg.gfHgChart) && typeof gfGhostCompact === 'function'){
7870        gfGhostCompact();
7871        c = document.getElementById('gfHdrChartCtl');
7872        if(c) console.log('[gfCtl] rebuilt - the element had been removed (#' + (++_gfCtlFixes) + ')');
7873      }
7874      if(!c) return;
7875    }
7876    if(!c.parentElement || !document.contains(c)){
7877      var cc = document.getElementById('chartContent');
7878      if(cc){ cc.appendChild(c); console.log('[gfCtl] re-attached - it had been detached from the page (#' + (++_gfCtlFixes) + ')'); }
7879    }
7880    // Computed, not inline: with the body-class rule the inline value is empty on a healthy
7881    // toolbar, so testing c.style.visibility reported "hidden" forever and healed on every tick.
7882    if(window._gfCtlPlaced && getComputedStyle(c).visibility !== 'visible'){
7883      c.style.visibility = '';                                  // drop any stale inline value so the body rule wins
7884      try{ document.body.classList.add('gf-ctl-ready'); }catch(e){}
7885      console.log('[gfCtl] re-shown via the body class (#' + (++_gfCtlFixes) + ')');
7886    }
7887    const _cg2 = window._gfCtlGroups || {};
7888    if(!c.children.length && (document.getElementById('gfHgTime') || document.getElementById('gfHgChart') || _cg2.gfHgTime || _cg2.gfHgChart)){
7889      ['gfHgTime','gfHgChart'].forEach(function(id){ var g = document.getElementById(id) || _cg2[id]; if(g && g.parentElement !== c) c.appendChild(g); });
7890      console.log('[gfCtl] re-filled - the button groups had been moved out (#' + (++_gfCtlFixes) + ')');
7891    }
7892    gfCtlWatch(c);
7893  }catch(e){}
7894}
7895function gfCtlPoke(){ if(_gfCtlRaf) return; _gfCtlRaf = requestAnimationFrame(function(){ _gfCtlRaf = 0; gfCtlHeal(); }); }
7896function gfCtlWatch(c){
7897  try{
7898    if(!_gfCtlObs){
7899      var cc = document.getElementById('chartContent');
7900      if(cc){ _gfCtlObs = new MutationObserver(gfCtlPoke); _gfCtlObs.observe(cc, { childList: true }); }
7901    }
7902    if(c && c !== _gfCtlAttrObs_el){
7903      if(_gfCtlAttrObs) try{ _gfCtlAttrObs.disconnect(); }catch(e){}
7904      _gfCtlAttrObs = new MutationObserver(gfCtlPoke);
7905      _gfCtlAttrObs.observe(c, { attributes: true, attributeFilter: ['style','class'] });
7906      _gfCtlAttrObs_el = c;
7907    }
7908  }catch(e){}
7909}
7910var _gfCtlAttrObs_el = null;
7911function gfLayersBadgeTick(){
7912  // HEAL FIRST, BEFORE ANYTHING CAN RETURN (user 2026-09-09, fifth report). This function bails at
7913  // `if(!gfLayersBadge) return` a few lines down, and that badge lives INSIDE the Layers button,
7914  // inside #gfHgChart, inside the toolbar. So the moment the toolbar subtree was removed the badge
7915  // went with it, the tick returned early, and gfCtlHeal - sitting at the bottom - never ran. The
7916  // watchdog was gated behind the exact thing it exists to repair, which is why four fixes looked
7917  // right in isolation and none of them ever brought the toolbar back.
7918  try{ gfCtlHeal(); }catch(e){}
7919  // VOL toggle belongs after DESK 1/2/3 on the LEVELS row in compact Ghost Mode (user 2026-09-06). The one-shot move in
7920  // gfGhostCompact ran before the Layers grid row claimed the button at boot, so keep asserting it here.
7921  try{ if(document.body.classList.contains('gf-ghost-compact')){ const vt=document.getElementById('chartVolToggle'), gd=document.getElementById('gfHgDesk'); if(vt && gd && vt.parentElement!==gd){ vt.style.marginLeft='0'; gd.appendChild(vt); } } }catch(e){}
7922  // Ghost Mode: the trade plan inside the Flow panel must never stay display:none (boot-order race between the
7923  // gf_tp_min restore, the desk restore and the reparent - user 2026-09-06 "trade plan tab still showing").
7924  try{ const _tp=document.getElementById('chartTradePlan'); if(_tp && _tp.closest('#ultraTPSecBody')){ if(_tp.style.display==='none') _tp.style.display='block';
7924 const _rb=document.getElementById('chartTPRestoreBtn'); if(_rb && _rb.style.display!=='none') _rb.style.display='none'; } }catch(e){}
7925  try{
7926    const b=document.getElementById('gfLayersBadge'); if(!b) return;
7927    let n=document.querySelectorAll('#indicatorPanel input[type=checkbox]:checked').length;
7928    const gx=document.getElementById('chartGexToggle'), vl=document.getElementById('chartVolToggle');
7929    if(gx&&gx.classList.contains('active')) n++; if(vl&&vl.classList.contains('active')) n++;
7930    b.textContent=n?String(n):'';
7931  }catch(e){}
7932  try{ const R=document.getElementById('gfLvlRight'), tm=document.getElementById('ultraTopMetrics'); if(R){ ['chartBias','chartRegime'].forEach(id=>{ const el=document.getElementById(id); if(el&&el.parentElement!==R){ if(tm&&tm.parentElement===R) R.insertBefore(el,tm); else R.insertBefore(el,R.firstChild); } }); } }catch(e){}   /* PIN DAY / bias chips get re-inserted next to the pills by their own renderers - keep them in the right zone */
7933  try{ const r2=document.getElementById('chartLevelsBar'), gp=document.getElementById('chartGexPanel'); if(r2){ const w=(gp&&getComputedStyle(gp).display!=='none')?gp.offsetWidth:0; r2.style.setProperty('--gf-ladder-w',Math.max(150,w-12)+'px'); if(gp){ /* HOLD THE LAST GOOD WIDTH (user 2026-09-08: "i clicked 5 mins time frame n it again fades away"). switchTimeframe -> loadGEXChart tears the chart pane down, and mid-rebuild #chartGexPanel is still in the DOM but measures 0 - so this fell to the 18px fallback and the toolbar jumped from x=416 to x=62, onto the opaque ladder, until a later tick re-measured. A transient 0 is not a real measurement: keep the width we last measured properly and the toolbar never moves. 18 stays the fallback for the genuine first paint, when nothing has been measured yet. */ var _shown=getComputedStyle(gp).display!=='none'; var _lp=(w>0)?((gp.parentElement&&gp.parentElement.id==='chartRow')?0:w):(_shown&&window._gfLadderLast!=null?window._gfLadderLast:18); if(w>0) window._gfLadderLast=_lp;   /* a MINIMISED ladder is a real 0 and 18px is correct; only a transient 0 on a still-displayed panel holds the last width */ document.body.style.setProperty('--gf-ladder-px',_lp+'px');   /* ladder docked in #chartRow (Desk 2/3) - chart 1 starts at 0 */ window._gfCtlPlaced=true; try{ document.body.classList.add('gf-ctl-ready'); }catch(e){}   /* measured once - from here the body-class rule keeps the toolbar visible through any rebuild */ const rl=document.getElementById('gfDrawRail'); if(rl) rl.classList.add('placed'); } } }catch(e){}   /* rail stays hidden until the ladder has been measured - no jump over the grid on reload */
7934  // KEEP THE CHART TOOLBAR VISIBLE (user 2026-09-08: "the chart timeframe thing it fades away").
7935  // The toolbar is born hidden - the CSS default is visibility:hidden so it cannot flash at the
7936  // wrong x while the ladder width is still unknown - and gfGhostCompact builds a BRAND NEW element
7937  // every time it rebuilds, which starts hidden again. Until now the only thing that ever revealed
7938  // it lived inside 'if(#chartLevelsBar){ if(#chartGexPanel){ ... } }', so a rebuild stayed invisible
7939  // until a tick found BOTH of those in the DOM. During a ticker change the chart pane is torn down
7940  // and #chartGexPanel is briefly gone, and in Desk teardown it can be gone for good - so the
7941  // toolbar faded out and, on those paths, never came back.
7942  //
7943  // The measurement decides WHERE the toolbar sits, never WHETHER it is shown. Once we have measured
7944  // once, assert visible on every tick, outside both guards: a rebuilt toolbar recovers on the next
7945  // tick even if the ladder and the panel have both vanished.
7946}
7947fetch('/api/me').then(r=>r.json()).then(d=>{ try{ window.gfUser=d; if(typeof gfRailAvatar==='function') gfRailAvatar(); }catch(e){} isAdminUser = (d.role==='admin'); isHqUser = !!d.hq; try{ if(isHqUser && !isAdminUser){ var _bwh=document.getElementById('btn-workspace'); if(_bwh)_bwh.style.display=''; } }catch(e){} isPaidUser = !!d.paid || isAdminUser; isLoggedInUser = !!(d && d.username) || isAdminUser; if(!isAdminUser){ try{ gfHideAdminTabs(); }catch(e){} } if(isPaidUser){ try{ _gfLabReveal(); }catch(e){} try{ if(!IS_DEMO){ gfThemeDefaults(); gfThemeMenuReveal(); gfWelcomeMaybe(); gfRailAutoHideInit(); } }catch(e){} /* rail auto-hide: premium (user 2026-09-07) */ /* themes + layouts + welcome card: premium (user 2026-09-06) */ try{ var _mt=document.getElementById('mchartToggle'); if(_mt)_mt.style.display='inline-flex'; if(typeof _mBootLayout==='function') _mBootLayout(); }catch(e){} /* Ghost Desk 1/2/3: premium (user 2026-09-06) */ var _bi=document.getElementById('btn-ideas'); if(_bi)_bi.style.display=''; var _bs=document.getElementById('btn-smart'); if(_bs)_bs.style.display=''; var _bch=document.getElementById('btn-chain'); if(_bch)_bch.style.display=''; try{ var _bv=window._bootView; if((_bv==='ideas'||_bv==='smart'||_bv==='flow'||_bv==='chain'||_bv==='workspace') && typeof currentView!=='undefined' && currentView!==_bv && typeof setVie
7947w==='function') setView(_bv); }catch(e){} try{ var _bt=window._bootIdeasTab; if(_bt && typeof currentView!=='undefined' && currentView==='ideas' && typeof _ideasTab!=='undefined' && _ideasTab!==_bt && typeof setIdeasTab==='function') setIdeasTab(_bt); }catch(e){} /* the admin sub-tabs (SPX/SPY, EOD, G-Flip, Whales, All Trades, Insider) bounce to Setups pre-auth exactly like the gated views do */ /* gated views boot BEFORE /api/me answers, so setView downgrades them to gex; re-apply once auth is known - the gate re-runs with real isPaidUser, so an unauthorized user still lands on gex */ if(!isAdminUser){ try{ document.querySelectorAll('#ideasView .ideas-tab[data-tab="ghost"],#ideasView .ideas-tab[data-tab="index"],#ideasView .ideas-tab[data-tab="insider"],#ideasView .ideas-tab[data-tab="gflip"],#ideasView .ideas-tab[data-tab="eod"],#ideasView .ideas-tab[data-tab="prints"],#ideasView .ideas-tab[data-tab="alltrades"],[data-urtab="gflip"],[data-urtab="prints"]').forEach(function(b){ b.style.display='none'; }); }catch(e){} } var _aiL=document.getElementById('aiMenuLink'); if(_aiL)_aiL.style.display='flex'; var _gaL=document.getElementById('gfAiLauncher'); if(_gaL)_gaL.style.display='flex'; var _iw=document.getElementById('indicatorWrap'); if(_iw)_iw.style.display='block'; try{ var _tvb=document.getElementById('tvLevelsBtn'); if(_tvb)_tvb.style.display=''; }catch(e){} /* TV levels export: premium + admin */ var _gbr=document.getElementById('mode-gexbub-row'); if(_gbr)_gbr.style.display='flex'; try{ var _kmr2=document.getElementById('mode-kingmagnet-row'); if(_kmr2)_kmr2.style.display='flex'; if(indState.kingmagnet){ var _kcb=document.getElementById('mode-kingmagnet'); if(_kcb)_kcb.checked=true; } }catch(e){} /* King-follows-magnet now premium (user 2026-09-02) */ try{ if(typeof _syncGexbubOpts==='function')_syncGexbubOpts(); if(indState.gexbub){ var _gcb=document.getElementById('mode-gexbub'); if(_gcb)_gcb.checked=true; var _go=document.getElementById('gexbub-opts'); if(_go)_go.style.display='flex'; if(typeof _gexBubStart==='function')_gexBubStart(); if(typeof _gexBubSeedWhenReady==='function') _gexBubSeedWhenReady(0); if(typeof renderGexBubbles==='function') renderGexBubbles(); } }catch(e){} try{ if(typeof computeIndicators==='function'){ computeIndicators(); redrawDrawings(); } }catch(e){} try{ gfHeaderV2(); }catch(e){} /* Ghost Mode header v2 for PREMIUM too (user 2026-09-05: "update these to premium users as well") */ }  /* chart Modes are live for paid members */ try { var _rpb=document.getElementById('replayBtn'); if(_rpb && isLoggedInUser) _rpb.style.display=''; var _snt=document.getElementById('scanTab-nodes'); if(_snt && isLoggedInUser) _snt.style.display=''; var _sot=document.getElementById('scanTab-overview'); if(_sot && isLoggedInUser) _sot.style.display=''; var _cb=document.querySelector('.metric-btn[data-metric="charm"]'); if(_cb){ _cb.classList.toggle('locked', !isPaidUser); _cb.title = isPaidUser ? 'Charm = delta decay (dDelta/dTime)' : 'CHARM is a Premium feature — upgrade to unlock'; } if(isLoggedInUser && typeof currentView!=='undefined' && currentView==='scanner' && typeof setScannerTab==='function' && _savedScanTab==='nodes'){ setScannerTab('nodes'); } } catch(e){} if(typeof currentView!=='undefined'&&currentView==='indicators'&&typeof buildIndicatorsView==='function'){buildIndicatorsView();} if(isAdminUser){ /* Ideas button + bootView redirect moved to the isPaidUser block (Ideas is premium), GEX Visual row/restore too. */ try{ var _ssb=document.getElementById('screenshotBtn'); if(_ssb)_ssb.style.display=''; /* theme menu + Desk moved to the premium block (user 2026-09-06) */ /* rail auto-hide moved to the premium block (user 2026-09-07) */ }catch(e){} /* theme + menu-bar pickers: ADMIN ONLY test (user 2026-09-05) */ /* screenshot button: ADMIN ONLY (user 2026-09-04) */ /* Ghost Desk reveal now in the premium block */ /* Ghost Desk: was ADMIN ONLY until it's finished (user request 2026-08-07) */ var el=document.getElementById('adminMenuBtn'); if(el)el.style.display='flex'; var sm=document.getElementById('studioMenuBtn'); if(sm)sm.style.display='flex'; var an=document.getElementById('announceMenuBtn'); if(an)an.style.display='flex'; var rsim=document.getElementById('rhSimMenuBtn'); if(rsim)rsim.style.display='flex'; var grp=document.getElementById('gammaReplayMenuBtn'); if(grp)grp.style.display='flex'; var _bws=document.getElementById('btn-workspace'); if(_bws)_bws.style.display='';   /* My HQ: admin always; beta accounts are revealed below */   /* Gamma Replay: admin only (user 2026-09-09) */ try{ _syncHeatWaveBtn(); if(gfHeatWave || gfHeatBucket) _hwDraw(); }catch(e){}   /* Gamma Wave + Bucket buttons: admin only - the heatmap may already have rendered before /api/me answered */ try{ var kmr=document.getElementById('mode-kingmagnet-row'); if(kmr)kmr.style.display='flex'; /* King-follows-magnet stays ADMIN-only; the rest of the lab reveal moved to _gfLabReveal (premium+comped, user 2026-08-29) */ }catch(e){} /* lab reveal for paid users now lives in _gfLabReveal */ var aiL=document.getElementById('aiMenuLink'); if(aiL)aiL.style.display='flex'; var gaL=document.getElementById('gfAiLauncher'); if(gaL)gaL.style.display='flex'; var lt=document.getElementById('layoutToggleBtn'); if(lt){lt.style.display='flex'; var ll=document.getElementById('layoutToggleLbl'); if(ll)ll.textContent='New layout: '+(GF_NEW_LAYOUT?'ON':'OFF');} var iw=document.getElementById('indicatorWrap'); if(iw)iw.style.display='block'; if(typeof computeIndicators==='function'){ computeIndicators(); redrawDrawings(); } if(typeof indState!=='undefined' && indState.flow && typeof startFlowPolling==='function'){ startFlowPolling(); } try{ gfHeaderV2(); }catch(e){} /* Ghost Mode header v2 - admin only (2026-09-05) */ } try{ if(typeof updateFlowNav==='function') updateFlowNav(); }catch(e){} try{ if(isLoggedInUser && typeof startNodePolling==='function' && !_nodePollTimer) startNodePolling(); }catch(e){} /* background node-alert watcher, all day, any view */ var du=document.getElementById('discordUpgrade'); if(du&&!isPaidUser){ du.style.display='block'; du.href = d.username ? '/account' : '/register'; } try{maybeAutoOpenHelp();}catch(e){} try{ if(isLoggedInUser) gfWarmViews(); }catch(e){} }).catch(()=>{ var du=document.getElementById('discordUpgrade'); if(du){ du.style.display='block'; du.href='/register'; } try{maybeAutoOpenHelp();}catch(e){} });
7948// Drag-cursor cleanup (user 2026-08-31, three rounds). Capture phase + dragenter, the
7949// spec-correct combo: (1) ghost drags of text/labels/links never start; (2) during a REAL
7950// drag (ticker/column/flow-header, draggable=true) every point on the page is a valid
7951// move target, so the OS no-drop badge cannot appear;
7951 dropping off-target is a no-op.
7952document.addEventListener('dragstart', function(e){
7953  var t = e.target, el = t && t.nodeType === 1 ? t : (t && t.parentElement);
7954  if (el && el.closest && el.closest('[draggable="true"]')) { window._gfDragOn = true; return; }
7955  e.preventDefault();
7956}, true);
7957document.addEventListener('dragenter', function(e){ if (window._gfDragOn) e.preventDefault(); }, true);
7958document.addEventListener('dragover', function(e){ if (window._gfDragOn) { e.preventDefault(); try { e.dataTransfer.dropEffect = 'move'; } catch (err) {} } }, true);
7959document.addEventListener('drop', function(e){ if (window._gfDragOn) { window._gfDragOn = false; e.preventDefault(); } }, true);
7960document.addEventListener('dragend', function(){ window._gfDragOn = false; }, true);
7961document.addEventListener('click',function(e){var m=document.getElementById('userMenu');if(m&&m.style.display==='block'&&!e.target.closest('#userMenu')&&!e.target.closest('[onclick*=userMenu]'))m.style.display='none'});
7962// Same click-outside-to-close for the Discord dropdown (clicking the button still toggles it).
7963// Discord popup (user 2026-09-07: "on left rail the discord button doesnt open anything to right"). The left rail is
7964// overflow-y:auto, and a scroll container clips an absolutely positioned child on BOTH axes, so the menu opened inside
7965// the 74px rail and was never visible. Pin it to the body and place it beside the button instead - the same trick the
7966// account menu uses. Works in every layout: beside the button on the rail, under it in the top/centre/dock bars.
7967function gfDiscordPop(btn){
7968  try{
7969    if(window.matchMedia && window.matchMedia('(max-width:640px)').matches){ window.open('https://discord.gg/xtbhm5pfqR','_blank'); return; }
7970    var m=document.getElementById('discordMenu'); if(!m) return;
7971    if(m.style.display==='block'){ m.style.display='none'; return; }
7972    var r=btn.getBoundingClientRect(), rail=document.body.getAttribute('data-nav')==='left';
7973    document.body.appendChild(m);
7974    m.style.position='fixed'; m.style.right='auto'; m.style.zIndex='2000';
7975    var L = rail ? r.right+8 : r.right-248;
7976    m.style.left = Math.max(8, Math.min(L, window.innerWidth-256))+'px';
7977    var T = rail ? r.top : r.bottom+6;
7978    m.style.top  = Math.max(8, Math.min(T, window.innerHeight-280))+'px';
7979    m.style.display='block';
7980  }catch(e){}
7981}
7982document.addEventListener('click',function(e){var m=document.getElementById('discordMenu');if(m&&m.style.display==='block'&&!e.target.closest('#discordMenu')&&!e.target.closest('[onclick*=discordMenu]'))m.style.display='none'});
7983
7984// Active exposure metric — applies to ALL views. 'gex' = gamma, 'vex' = vanna.
7985// Both come from the same UW greek-exposure response (call_gex/put_gex and
7986// call_vanna/put_vanna), so switching is a pure re-parse — no extra fetch needed.
7987let currentMetric = 'gex';
7988// How many expiry columns the heatmap renders. Was 8. During market hours gex_strike_expiry
7989// streams every expiry at no REST cost, so the old cap was throwing away data we already had;
7990// off-hours the server fan-out supplies 8. The table scrolls horizontally past the viewport.
7991const HEATMAP_MAX_EXP = 14;
7992// Per-column metric override (grid v2 stage 2): set ONLY around loadGEX's synchronous parse
7993// so a column can read VEX/CHARM while the global toggle stays on GEX. Null = global.
7994let _metricOv = null;
7995function metricMult(spot) {
7996  // Gamma exposure scales with spot²; vanna & charm scale with spot.
7997  if (spot <= 0) return 1;
7998  return (_metricOv || currentMetric) === 'gex' ? spot * spot * 0.01 : spot * 0.01;
7999}
8000function metricCall(s) {
8001  const _cm = _metricOv || currentMetric;
8002  if (_cm === 'vex') return parseFloat(s.call_vanna || 0);
8003  if (_cm === 'charm') return parseFloat(s.call_charm || 0);
8004  return parseFloat(s.call_gex || s.call_gamma_oi || 0);
8005}
8006function metricPut(s) {
8007  const _cm2 = _metricOv || currentMetric;
8008  // REST CONVENTION (settled 2026-08-31 after a REST-vs-WS shootout): UW's REST rows send
8009  // put_vanna/put_charm with the SAME sign as the call side, so net = call − put. The WS
8010  // gex_strike feed pre-negates those puts (opposite sign, and vanna at a different scale) —
8011  // server _wsGexRow now normalizes WS rows to REST, so ONE convention holds 24/7. Netting
8012  // validated against an external SPY charm ladder: positive band just above spot, negative
8013  // below, flip at spot. put_gex is pre-negated in both sources (GEX stays call + put).
8014  if (_cm2 === 'vex') return -parseFloat(s.put_vanna || 0);
8015  if (_cm2 === 'charm') return -parseFloat(s.put_charm || 0);
8016  return parseFloat(s.put_gex || s.put_gamma_oi || 0);
8017}
8018// Uppercase label of the active metric — used in headers/tags (GEX / VEX / CHARM).
8019function metricLabel() { return currentMetric.toUpperCase(); }
8020function metricRaw(s) { return metricCall(s) + metricPut(s); }
8021
8022// King = biggest NET exposure (gamma/vanna). Call Wall = biggest CALL-only exposure
8023// above spot; Put Wall = biggest PUT-only exposure below spot — so the three levels
8024// are distinct (King won't double as the Call Wall). Uses |value| so it works for
8025// both gamma (puts negative) and vanna (both positive).
8026function pickLevels(filtered, spot) {
8027  const aboveSpot = filtered.filter(s => s.strike > spot);
8028  const belowSpot = filtered.filter(s => s.strike < spot);
8029  const callWall = aboveSpot.slice().sort((a, b) => Math.abs(b.callGex) - Math.abs(a.callGex))[0] || null;
8030  const putWall  = belowSpot.slice().sort((a, b) => Math.abs(b.putGex)  - Math.abs(a.putGex))[0]  || null;
8031  const kingNode = [...filtered].sort((a, b) => Math.abs(b.netGex) - Math.abs(a.netGex))[0] || null;
8032  const totalGex = filtered.reduce((s, d) => s + d.netGex, 0);
8033  return { callWall, putWall, kingNode, totalGex };
8034}
8035
8036// Gamma flip (zero-gamma level): the price where cumulative net GEX (bottom-up)
8037// crosses zero. Below it dealers are net-short gamma → moves amplify / trend; above,
8038// net-long → moves dampen / range. Interpolated; null if the book never crosses zero.
8039function gammaFlip(filtered, spot) {
8040  if (!Array.isArray(filtered) || filtered.length < 2) return null;
8041  const s = [...filtered].sort((a, b) => a.strike - b.strike);
8042  let cum = 0, prevStrike = null, prevCum = 0;
8043  for (const d of s) {
8044    prevCum = cum; cum += d.netGex;
8045    if (prevStrike !== null && ((prevCum < 0 && cum >= 0) || (prevCum > 0 && cum <= 0))) {
8046      const denom = cum - prevCum;
8047      const t = denom === 0 ? 0 : (0 - prevCum) / denom;
8048      return prevStrike + t * (d.strike - prevStrike);
8049    }
8050    prevStrike = d.strike;
8051  }
8052  return null;
8053}
8054
8055// Turn the GEX levels into a readable, rule-based trade plan. This is a structural
8056// read of dealer positioning — NOT a prediction. Entries/targets/stops are derived
8057// purely from the King / Call Wall / Put Wall / Gamma Flip / Vol Node already shown.
8058function buildTradePlan(levels, spot, filtered) {
8059  if (!levels || !(spot > 0)) return null;
8060  const cw = levels.callWall ? levels.callWall.strike : null;
8061  const pw = levels.putWall ? levels.putWall.strike : null;
8062  const king = levels.kingNode ? levels.kingNode.strike : null;
8063  const vn = (levels.volNode && levels.volNode.strike) ? levels.volNode.strike : null;
8064  const flip = (levels.gammaFlip != null) ? levels.gammaFlip : gammaFlip(filtered, spot);
8065  const regime = (flip != null) ? (spot >= flip ? 'positive' : 'negative')
8066                                : (levels.totalGex >= 0 ? 'positive' : 'negative');
8067  const all = [];
8068  const add = (name, price, color) => {
8069    if (price != null && isFinite(price)) all.push({ name, price, color, dist: price - spot, distPct: ((price - spot) / spot) * 100 });
8070  };
8071  add('Call Wall', cw, 'green'); add('Vol Node', vn, 'blue'); add('King', king, 'yellow');
8072  add('Gamma Flip', flip, 'cyan'); add('Put Wall', pw, 'red');
8073  const supports = all.filter(l => l.price < spot).sort((a, b) => b.price - a.price); // nearest first
8074  const resist   = all.filter(l => l.price > spot).sort((a, b) => a.price - b.price); // nearest first
8075  const nearestSupport = supports[0] || null, nearestResist = resist[0] || null;
8076  const below = (px) => all.filter(l => l.price < px).sort((a, b) => b.price - a.price)[0] || null;
8077  const above = (px) => all.filter(l => l.price > px).sort((a, b) => a.price - b.price)[0] || null;
8078
8079  const longEntry = nearestSupport ? nearestSupport.price : (flip != null ? flip : spot);
8080  const longTarget = (cw != null && cw > spot) ? cw : (nearestResist ? nearestResist.price : null);
8081  const longStopL = below(longEntry);
8082  const longStop = longStopL ? longStopL.price : longEntry * 0.985;
8083
8084  const shortEntry = nearestResist ? nearestResist.price : (flip != null ? flip : spot);
8085  const shortTarget = (pw != null && pw < spot) ? pw : (nearestSupport ? nearestSupport.price : null);
8086  const shortStopL = above(shortEntry);
8087  const shortStop = shortStopL ? shortStopL.price : shortEntry * 1.015;
8088
8089  const rr = (entry, target, stop) => {
8090    if (target == null || stop == null) return null;
8091    const reward = Math.abs(target - entry), risk = Math.abs(entry - stop);
8092    return risk > 0 ? reward / risk : null;
8093  };
8094  // ── Charm read (into-expiry pin) — independent of the active metric ──
8095  let charm = null;
8096  const cs = (filtered || []).filter(s => s.charmNet != null && Math.abs(s.charmNet) > 0).map(s => ({ k: s.strike, c: s.charmNet }));
8097  if (cs.length >= 3) {
8098    const sorted = [...cs].sort((a, b) => a.k - b.k);
8099    const maxAbs = Math.max(...cs.map(s => Math.abs(s.c)));
8100    const floor = maxAbs * 0.03;   // drop only true noise (cyan can be far smaller than the amber King)
8101    // Pin = the cyan↔amber flip nearest spot — where price gravitates.
8102    let pin = null;
8103    for (let i = 1; i < sorted.length; i++) {
8104      if ((sorted[i - 1].c < 0) !== (sorted[i].c < 0)) {
8105        const mid = (sorted[i - 1].k + sorted[i].k) / 2;
8106        if (pin == null || Math.abs(mid - spot) < Math.abs(pin - spot)) pin = mid;
8107      }
8108    }
8109    // Nearest meaningful node of a sign to spot; fall back to the STRONGEST of that sign so a
8110    // small-but-real cyan pocket still shows even when a big amber King dominates the scale.
8111    const pickSide = (positive) => {
8112      const side = cs.filter(s => positive ? s.c > 0 : s.c < 0);
8113      if (!side.length) return null;
8114      const near = side.filter(s => Math.abs(s.c) >= floor).sort((a, b) => Math.abs(a.k - spot) - Math.abs(b.k - spot))[0];
8115      return near || side.slice().sort((a, b) => Math.abs(b.c) - Math.abs(a.c))[0];
8116    };
8117    const cyan = pickSide(true), amber = pickSide(false);
8118    const king = [...cs].sort((a, b) => Math.abs(b.c) - Math.abs(a.c))[0];  // biggest = bias
8119    const bias = king ? (king.c > 0 ? 'bull' : 'bear') : null;
8120    // Target = the NEAREST *strong* magnet in the bias direction (reachable, not the biggest-but-far
8121    // outlier and not a trivial adjacent node). "Strong" = >=40% of the strongest magnet in-band.
8122    const band = spot * 0.04;
8123    const dir = (bias === 'bear') ? cs.filter(s => s.k < spot && s.k >= spot - band)
8124              : (bias === 'bull') ? cs.filter(s => s.k > spot && s.k <= spot + band) : [];
8125    let tgt = null;
8126    if (dir.length) {
8127      const strongest = Math.max(...dir.map(s => Math.abs(s.c)));
8128      const strong = dir.filter(s => Math.abs(s.c) >= strongest * 0.4);
8129      tgt = strong.sort((a, b) => Math.abs(a.k - spot) - Math.abs(b.k - spot))[0];   // nearest strong one
8130    }
8131    charm = { pin: (pin != null ? pin : (king ? king.k : spot)),
8132              cyan: cyan ? cyan.k : null, amber: amber ? amber.k : null,
8133              bias, target: tgt ? tgt.k : null };
8134  }
8135  // ── EOD pin — gamma magnet + charm flip + gamma-pain blend (into the close) ──
8136  let eod = null;
8137  const gg = (filtered || []).filter(s => s.gexC != null).map(s => ({ k: s.strike, g: (s.gexC || 0) + (s.gexP || 0), cw: Math.abs(s.gexC || 0), pw: Math.abs(s.gexP || 0) }));
8138  if (gg.length >= 3) {
8139    const gsorted = [...gg].sort((a, b) => a.k - b.k);
8140    const gammaPin = [...gg].sort((a, b) => Math.abs(b.g) - Math.abs(a.g))[0].k;   // biggest gamma magnet
8141    let gammaPain = gsorted[0].k, minPay = Infinity;                               // max-pain proxy (|gex| as OI)
8142    for (const { k: K } of gsorted) {
8143      let pay = 0;
8144      for (const o of gsorted) { if (o.k < K) pay += (K - o.k) * o.cw; else if (o.k > K) pay += (o.k - K) * o.pw; }
8145      if (pay < minPay) { minPay = pay; gammaPain = K; }
8146    }
8147    const charmPin = charm ? charm.pin : null;
8148    const cand = [{ v: gammaPin, w: 1 }, { v: charmPin, w: 2 }, { v: gammaPain, w: 1 }].filter(c => c.v != null && Math.abs(c.v - spot) < spot * 0.05);
8149    const raw = cand.length ? cand.reduce((a, c) => a + c.v * c.w, 0) / cand.reduce((a, c) => a + c.w, 0) : gammaPin;
8150    const snapped = gsorted.reduce((best, s) => Math.abs(s.k - raw) < Math.abs(best - raw) ? s.k : best, gsorted[0].k);
8151    // Confidence reflects the GAMMA regime specifically (a pin only holds in positive gamma —
8152    // dealers dampen; negative gamma amplifies and price runs). Computed from RAW gamma so it's
8153    // identical whether you're viewing GEX, VEX or CHARM (it must not follow the active metric).
8154    const gStrikes = gg.map(s => ({ strike: s.k, netGex: s.g }));
8155    const gFlip = (typeof gammaFlip === 'function') ? gammaFlip(gStrikes, spot) : null;
8156    const gTot = gg.reduce((a, s) => a + s.g, 0);
8157    const gReg = (gFlip != null) ? (spot >= gFlip ? 'positive' : 'negative') : (gTot >= 0 ? 'positive' : 'negative');
8158    eod = { pin: snapped, gammaPin, gammaPain, charmPin, conf: (gReg === 'positive' ? 'strong' : 'weak') };
8159  }
8160  // Agreement: does the charm bias point the same way as the EOD pin? Aligned = high conviction;
8161  // conflicted = the forces fight (chop, no clean pin); pinned = the pin sits at spot (rangebound).
8162  if (eod && charm && charm.bias) {
8163    const eps = spot * 0.0015;
8164    const pinSide = eod.pin > spot + eps ? 'up' : eod.pin < spot - eps ? 'down' : 'flat';
8165    const biasDir = charm.bias === 'bull' ? 'up' : 'down';
8166    eod.agree = pinSide === 'flat' ? 'pinned' : (pinSide === biasDir ? 'aligned' : 'conflicted');
8167  }
8168  return {
8169    regime, flip, spot, charm, eod,
8170    levels: all.slice().sort((a, b) => b.price - a.price),
8171    nextUp: nearestResist, nextDown: nearestSupport,
8172    long:  { entry: longEntry,  target: longTarget,  stop: longStop,  rr: rr(longEntry, longTarget, longStop) },
8173    short: { entry: shortEntry, target: shortTarget, stop: shortStop, rr: rr(shortEntry, shortTarget, shortStop) }
8174  };
8175}
8176
8177const WATCHLIST = {
8178  "Index": ["SPXW", "NDX", "SPY", "QQQ", "IWM", "VIX"],
8179  "Megacap": ["AAPL", "MSFT", "GOOG", "AMZN", "META", "TSLA"],
8180  "Semis/AI": ["NVDA", "AMD", "AVGO", "ARM", "MRVL", "QCOM", "MU", "INTC", "SMCI", "ALAB", "NBIS"],
8181  "Quantum/AI": ["IONQ", "QBTS", "RGTI", "QUBT", "SOUN", "RBLX"],
8182  "Nuclear/Energy": ["VST", "OKLO"],
8183  "Defense": ["AVAV", "MRCY", "AXON"],
8184  "Space": ["RKLB", "ASTS", "LUNR", "FLY", "RDW", "SATS"],
8185  "Software": ["CRM", "ADBE", "ORCL", "NOW", "PLTR", "SNOW", "DDOG", "CRWD", "PANW", "ZS", "WDAY", "TEAM"],
8186  "Fintech/Crypto": ["HOOD", "AFRM", "PYPL", "SOFI", "COIN", "MSTR", "MARA", "RIOT", "CLSK", "HUT", "IREN", "WULF"],
8187  "Consumer": ["CHWY", "BOOT", "CAVA", "HIMS", "WMT", "TGT"],
8188  "EV/China": ["NIO", "XPEV", "BABA", "BIDU"],
8189  "Energy": ["XOM", "CVX", "SLB", "OXY"],
8190  "Healthcare": ["MRNA", "PFE"],
8191  "Squeeze": ["GME", "AMC", "CVNA", "GRPN", "BYND", "OPEN"],
8192  "Industrial": ["CAT"],
8193  "Custom": [],
8194  "Crypto": ["BTC", "ETH", "SOL", "XRP", "IBIT", "ETHA", "BITO"],   // last tab (user 2026-09-06); BTC/ETH/SOL/XRP = Deribit-native 24/7, ETFs via UW
8195};
8196
8197// ── Public demo (/demo): real dashboard, no login. ALL sectors/stocks are shown,
8198// but only SPY + TSLA are live — everything else renders LOCKED to entice signup.
8199// Free logged-in users get the real dashboard in "free mode": same locked-ticker UX as
8200// the public demo, but with their identity + a 2-day live-data trial (server-injected).
8201const IS_FREE = (typeof window !== 'undefined' && window.GF_FREE === true);
8202const FREE_TRIAL_ACTIVE = (typeof window !== 'undefined' && window.GF_TRIAL_ACTIVE === true);
8203const FREE_TRIAL_ENDS = (typeof window !== 'undefined' && Number(window.GF_TRIAL_ENDS)) || 0;
8204const IS_DEMO = location.pathname.replace(/\/$/, '') === '/demo' || IS_FREE;
8205const DEMO_FREE = ['SPY', 'TSLA', 'BTC'];   // BTC: Deribit-native crypto, 24/7 - the demo's always-live ticker (user 2026-09-06)
8206// Where "Upgrade" sends people: logged-in free users → straight to Stripe checkout;
8207// anonymous demo → register (no account yet).
8208const _GF_USER = (typeof window !== 'undefined' && window.GF_USER) ? window.GF_USER : '';
8209const UPGRADE_URL = IS_FREE
8210  ? (_GF_USER ? ('/api/create-checkout?user=' + encodeURIComponent(_GF_USER)) : '/account')
8211  : '/register';
8212// In free mode the 2 free tickers are live ONLY during the trial; once it ends, lock all.
8213function demoLocked(t) {
8214  if (IS_FREE && !FREE_TRIAL_ACTIVE) return true;            // trial over → everything locked
8215  return IS_DEMO && !DEMO_FREE.includes((t || '').toUpperCase());
8216}
8217function freeTrialOver() { return IS_FREE && !FREE_TRIAL_ACTIVE; }
8218// Full-content lock shown on premium views once a free trial ends (instead of data errors).
8219// z-index sits BELOW the header (100) so header dropdowns (Discord / menu) stay clickable
8220// above it, but above the content views. Also hides the demo guide card while it's up.
8221function showTrialGate(show) {
8222  const guide = document.getElementById('demoGuide');
8223  const guideTog = document.getElementById('demoGuideToggle');
8224  let el = document.getElementById('trialGate');
8225  if (!show) {
8226    if (el) el.style.display = 'none';
8227    if (guideTog) guideTog.style.display = '';
8228    return;
8229  }
8230  if (guide) guide.style.display = 'none';
8231  if (guideTog) guideTog.style.display = 'none';
8232  if (!el) {
8233    el = document.createElement('div');
8234    el.id = 'trialGate';
8235    el.style.cssText = 'position:fixed;left:0;right:0;bottom:0;top:var(--header-h,56px);z-index:95;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:14px;background:rgba(10,14,23,0.96);backdrop-filter:blur(4px);padding:24px';
8236    el.innerHTML = '<div style="font-size:42px">🔒</div>'
8237      + '<div style="font-size:24px;font-weight:800;color:#fff">Your free trial has ended</div>'
8238      + '<div style="font-size:14px;color:#94a3b8;max-width:400px;line-height:1.6">Upgrade to Pro to unlock live data, all tickers, the heatmap, GEX charts, Ghost Mode, the scanner, options flow and move alerts.</div>'
8239      + '<a href="' + UPGRADE_URL + '" style="margin-top:4px;background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;font-weight:800;font-size:15px;padding:13px 32px;border-radius:8px;text-decoration:none">Get Premium — from $' + (Date.now() < 1786345200000 ? 29 : 49) + '/mo →</a>';
8240    document.body.appendChild(el);
8241  }
8242  el.style.display = 'flex';
8243}
8244
8245// Tickers this user has deleted (hidden from every view). Per-user, server-backed.
8246let hiddenTickers = new Set();
8247
8248// Drag-to-reorder state (per-user, server-backed). tickerOrder = preferred order
8249// within each sector (Heatmap/Chart/Ultra button rows); gexColOrder = the order of
8250// the GEX-view columns.
8251let tickerOrder = [];
8252let gexColOrder = [];
8253
8254// Stable sort: order `arr` by the position of each item in `list`; items not in
8255// the list keep their existing relative order and go last.
8256function sortByOrderList(arr, list) {
8257  if (!Array.isArray(list) || !list.length) return arr;
8258  const rank = new Map(list.map((t, i) => [t, i]));
8259  return arr.map((v, i) => [v, i]).sort((a, b) => {
8260    const ra = rank.has(a[0]) ? rank.get(a[0]) : Infinity;
8261    const rb = rank.has(b[0]) ? rank.get(b[0]) : Infinity;
8262    return ra !== rb ? ra - rb : a[1] - b[1];
8263  }).map(p => p[0]);
8264}
8265
8266// Re-apply the saved order to the GEX columns and every sector list.
8267function applyTickerOrder() {
8268  if (gexColOrder && gexColOrder.length) activeColumns = sortByOrderList(activeColumns, gexColOrder);
8269  if (tickerOrder && tickerOrder.length) {
8270    for (const g of Object.keys(WATCHLIST)) WATCHLIST[g] = sortByOrderList(WATCHLIST[g], tickerOrder);
8271  }
8272}
8273
8274// Move `dragged` to sit right before/after `target` inside an array.
8275function moveAdjacent(arr, dragged, target, before) {
8276  const fi = arr.indexOf(dragged);
8277  if (fi < 0) return;
8278  arr.splice(fi, 1);
8279  let ti = arr.indexOf(target);
8280  if (ti < 0) { arr.push(dragged); return; }
8281  if (!before) ti += 1;
8282  arr.splice(ti, 0, dragged);
8283}
8284
8285// Persist both orders to the user's account.
8286function saveTickerOrder() {
8287  if (typeof IS_DEMO !== 'undefined' && IS_DEMO) return;
8288  fetch('/api/custom-tickers/order', {
8289    method: 'POST', headers: { 'Content-Type': 'application/json' },
8290    body: JSON.stringify({ order: tickerOrder, gexOrder: gexColOrder })
8291  }).catch(function () {});
8292}
8293
8294// GEX view: reorder the columns.
8295function reorderGexColumn(draggedKey, targetKey, before) {
8296  // Drag hands over slot KEYS (col ids like 'SPXW#s1'), not tickers - resolve to slot
8297  // indices so duplicates move the RIGHT column, and carry the per-slot cfg along so a
8298  // column's metric/expiry travels with it (user 2026-08-29: drag went dead post-slots).
8299  if (draggedKey === targetKey) return;
8300  const di = _slotIdxFromKey(draggedKey), tiO = _slotIdxFromKey(targetKey);
8301  if (di < 0 || tiO < 0 || di === tiO) return;
8302  const C = colCfg[activeGroup] || {};
8303  const cfgArr = activeColumns.map((t, j) => C[j] || null);
8304  const [tk] = activeColumns.splice(di, 1);
8305  const [cf] = cfgArr.splice(di, 1);
8306  const ti = tiO - (di < tiO ? 1 : 0);
8307  const at = before ? ti : ti + 1;
8308  activeColumns.splice(at, 0, tk);
8309  cfgArr.splice(at, 0, cf);
8310  const NC = {}; cfgArr.forEach((c, j) => { if (c) NC[j] = c; });
8311  if (Object.keys(NC).length) colCfg[activeGroup] = NC; else delete colCfg[activeGroup];
8312  _saveColCfg();
8313  if (typeof isPaidUser !== 'undefined' && isPaidUser) { _colSets[activeGroup] = activeColumns.slice(); _saveColSets(); }
8314  gexColOrder = activeColumns.slice();
8315  renderColumns();
8316  reloadSlots();   // slot-aware: custom columns load with their own key/basis/metric
8317  saveTickerOrder();
8318}
8319
8320// Heatmap/Chart/Ultra: reorder within a sector, then rebuild that view's ticker row.
8321// ES (futures proxy) isn't in a WATCHLIST sector, so it floats via a saved index in the chart bar.
8322let esChartPos = 0; try { const _p = parseInt(localStorage.getItem('gf_es_pos')); if (!isNaN(_p)) esChartPos = _p; } catch (e) {}
8323function reorderSectorTicker(dragged, target, before, rebuildFn) {
8324  if (dragged === target) return;
8325  // Dragging ES → just remember where it should sit among the (ES-excluded) ticker list.
8326  if (dragged === 'ES') {
8327    const base = [...new Set(Object.values(WATCHLIST).flat())].filter(t => t !== 'ES');
8328    let ti = base.indexOf(target); if (ti < 0) ti = base.length;
8329    esChartPos = before ? ti : ti + 1;
8330    try { localStorage.setItem('gf_es_pos', String(esChartPos)); } catch (e) {}
8331    if (typeof rebuildFn === 'function') rebuildFn();
8332    return;
8333  }
8334  const fromSec = sectorOfTicker(dragged);
8335  const toSec = sectorOfTicker(target);
8336  if (!fromSec || !toSec) return;
8337  if (fromSec === toSec) {
8338    // same sector → just reorder within it
8339    moveAdjacent(WATCHLIST[fromSec], dragged, target, before);
8340  } else {
8341    // cross-sector → move the ticker into the target's sector, next to it, and persist
8342    // the new sector so it sticks across reloads.
8343    WATCHLIST[fromSec] = (WATCHLIST[fromSec] || []).filter(t => t !== dragged);
8344    if (!WATCHLIST[toSec]) WATCHLIST[toSec] = [];
8345    let ti = WATCHLIST[toSec].indexOf(target);
8346    if (ti < 0) WATCHLIST[toSec].push(dragged);
8347    else { if (!before) ti += 1; WATCHLIST[toSec].splice(ti, 0, dragged); }
8348    saveTickerSector(dragged, toSec);
8349  }
8350  tickerOrder = Object.values(WATCHLIST).flat();
8351  if (typeof rebuildFn === 'function') rebuildFn();
8352  saveTickerOrder();
8353}
8354// Persist a ticker's sector (used when it's dragged into a different sector). Reuses the
8355// add endpoint, which records the sector override and un-hides the ticker.
8356function saveTickerSector(ticker, sector) {
8357  if (typeof IS_DEMO !== 'undefined' && IS_DEMO) return;
8358  fetch('/api/custom-tickers/add', {
8359    method: 'POST', headers: { 'Content-Type': 'application/json' },
8360    body: JSON.stringify({ ticker: ticker, sector: sector })
8361  }).catch(function () {});
8362}
8363
8364let _dragTicker = null;
8365function _clearDrag() { document.querySelectorAll('.gf-dragging').forEach(el => el.classList.remove('gf-dragging')); _dragTicker = null; }
8366
8367// Wire drag on the GEX columns container (delegated; the grip in each header starts it).
8368function enableColumnDrag() {
8369  if (typeof IS_DEMO !== 'undefined' && IS_DEMO) return;
8370  const c = document.getElementById('columns');
8371  if (!c || c._dragBound) return;
8372  c._dragBound = true;
8373  c.addEventListener('dragstart', function (e) {
8374    const grip = e.target.closest && e.target.closest('.col-drag');
8375    if (!grip) { e.preventDefault(); return; }
8376    const col = grip.closest('.gex-col');
8377    _dragTicker = col ? col.id.replace('col-', '') : null;
8378    if (!_dragTicker) { e.preventDefault(); return; }
8379    e.dataTransfer.effectAllowed = 'move';
8380    try { e.dataTransfer.setData('text/plain', _dragTicker); } catch (_) {}
8381    col.classList.add('gf-dragging');
8382  });
8383  c.addEventListener('dragover', function (e) { if (_dragTicker) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } });
8384  c.addEventListener('drop', function (e) {
8385    if (!_dragTicker) return;
8386    e.preventDefault();
8387    const col = e.target.closest && e.target.closest('.gex-col');
8388    if (col) {
8389      const target = col.id.replace('col-', '');
8390      const r = col.getBoundingClientRect();
8391      reorderGexColumn(_dragTicker, target, e.clientX < r.left + r.width / 2);
8392    }
8393    _clearDrag();
8394  });
8395  c.addEventListener('dragend', _clearDrag);
8396}
8397
8398// Wire drag on a sector button row (Heatmap/Chart/Ultra). Buttons carry draggable="true".
8399function enableButtonDrag(containerId, rebuildFn) {
8400  if (typeof IS_DEMO !== 'undefined' && IS_DEMO) return;
8401  const c = document.getElementById(containerId);
8402  if (!c || c._dragBound) return;
8403  c._dragBound = true;
8404  c.addEventListener('dragstart', function (e) {
8405    const btn = e.target.closest && e.target.closest('button[data-ticker]');
8406    if (!btn) { e.preventDefault(); return; }
8407    _dragTicker = btn.getAttribute('data-ticker');
8408    e.dataTransfer.effectAllowed = 'move';
8409    try { e.dataTransfer.setData('text/plain', _dragTicker); } catch (_) {}
8410    btn.classList.add('gf-dragging');
8411  });
8412  c.addEventListener('dragover', function (e) { if (_dragTicker) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } });
8413  c.addEventListener('drop', function (e) {
8414    if (!_dragTicker) return;
8415    e.preventDefault();
8416    const btn = e.target.closest && e.target.closest('button[data-ticker]');
8417    if (btn) {
8418      const target = btn.getAttribute('data-ticker');
8419      const r = btn.getBoundingClientRect();
8420      reorderSectorTicker(_dragTicker, target, e.clientX < r.left + r.width / 2, rebuildFn);
8421    }
8422    _clearDrag();
8423  });
8424  c.addEventListener('dragend', _clearDrag);
8425}
8426
8427// Strip the user's hidden tickers from every sector + the GEX columns.
8428function applyHiddenTickers() {
8429  for (const grp of Object.keys(WATCHLIST)) WATCHLIST[grp] = WATCHLIST[grp].filter(t => !hiddenTickers.has(t));
8430  activeColumns = activeColumns.filter(t => !hiddenTickers.has(t));
8431  if (activeColumns.length === 0) {
8432    const t0 = Object.values(WATCHLIST).flat()[0];
8433    if (t0) activeColumns = [t0];
8434  }
8435}
8436
8437// Load this user's added + hidden tickers and apply across all views.
8438async function loadCustomTickers() {
8439  try {
8440    const res = await fetch('/api/custom-tickers');
8441    if (!res.ok) return; // ignore 401/429/redirects
8442    const data = await res.json();
8443    if (!data || typeof data !== 'object' || data.error) return;
8444    const added = (data.added && typeof data.added === 'object') ? data.added : {};
8445    const hidden = Array.isArray(data.hidden) ? data.hidden : [];
8446    for (const [ticker, sector] of Object.entries(added)) {
8447      if (typeof ticker !== 'string' || !/^[A-Z][A-Z.]{0,5}$/.test(ticker)) continue;
8448      const grp = (typeof sector === 'string' && WATCHLIST[sector]) ? sector : 'Custom';
8449      // An override wins over the ticker's default sector — remove it from every other
8450      // sector first so a cross-sector drag doesn't leave a duplicate in its old home.
8451      for (const g of Object.keys(WATCHLIST)) { if (g !== grp) WATCHLIST[g] = WATCHLIST[g].filter(t => t !== ticker); }
8452      if (!WATCHLIST[grp].includes(ticker)) WATCHLIST[grp].push(ticker);
8453    }
8454    hiddenTickers = new Set(hidden.map(t => String(t).toUpperCase()));
8455    applyHiddenTickers();
8456    tickerOrder = Array.isArray(data.order) ? data.order.map(t => String(t).toUpperCase()) : [];
8457    gexColOrder = Array.isArray(data.gexOrder) ? data.gexOrder.map(t => String(t).toUpperCase()) : [];
8458    applyTickerOrder();
8459    buildTabs();
8460    buildGexTickers();
8461    renderColumns();
8462    reloadSlots();   // slot-aware: custom columns load with their own key/basis/metric
8463    if (currentView === 'expiry') buildExpiryTickers();
8464    if (currentView === 'chart') buildChartTickers();
8465    if (currentView === 'ultra') buildUltraTickers();
8466  } catch {}
8467}
8468if (!IS_DEMO) loadCustomTickers();
8469
8470// ── Demo mode UI: banner + sign-up CTA, hide login-only controls ──
8471function setupDemoMode() {
8472  // TV sync is locked on demo/free (SPY/TSLA only) — hide the toggle so it's not misleading.
8473  try { var _tvb = document.getElementById('tvSyncBtn'); if (_tvb) _tvb.style.display = 'none'; } catch (e) {}
8474  const bar = document.createElement('div');
8475  bar.id = 'demoBar';
8476  bar.style.cssText = 'position:sticky;top:0;z-index:200;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;padding:8px 14px;background:linear-gradient(90deg,#3b82f6,#8b5cf6);color:#fff;font-size:13px;font-weight:600';
8477  if (IS_FREE) {
8478    const cta = '<a href="' + UPGRADE_URL + '" style="background:#fff;color:#1e293b;padding:5px 14px;border-radius:6px;text-decoration:none;font-weight:800">Upgrade to Pro →</a>';
8479    if (FREE_TRIAL_ACTIVE) {
8480      const hrsLeft = Math.max(0, Math.ceil((FREE_TRIAL_ENDS - Date.now()) / 3600000));
8481      const left = hrsLeft >= 24 ? (Math.ceil(hrsLeft / 24) + ' day' + (Math.ceil(hrsLeft / 24) > 1 ? 's' : '')) : (hrsLeft + 'h');
8482      bar.innerHTML = '👋 <b>' + (window.GF_USER || 'Welcome') + '</b> — Free plan. Live <b>SPY, TSLA &amp; BTC</b> for your trial (<b>' + left + ' left</b>). 🔒 Upgrade for <b>all tickers, live flow + alerts, the scanner</b>. ' + cta;
8483    } else {
8484      bar.style.background = 'linear-gradient(90deg,#ef4444,#b91c1c)';
8485      bar.innerHTML = '⏳ Your <b>free trial ended</b>. 🔒 Upgrade to unlock <b>live data, all tickers, flow + alerts</b>. ' + cta;
8486    }
8487  } else {
8488    bar.innerHTML = '📺 <b>Live Demo</b> — exploring <b>SPY, TSLA &amp; BTC</b> <span style="background:rgba(0,0,0,0.25);padding:2px 8px;border-radius:10px;font-size:11px;font-weight:700;white-space:nowrap">⏱ data delayed 15 min</span>. 🔒 Get Premium for <b>real-time data, all tickers, the market scanner, flow &amp; move alerts</b>. <a href="/register" style="background:#fff;color:#1e293b;padding:5px 14px;border-radius:6px;text-decoration:none;font-weight:800">Get Premium →</a>';
8489  }
8490  document.body.insertBefore(bar, document.body.firstChild);
8491  // The banner changes the top chrome height — re-measure so the header pins below it and
8492  // the views/tabs size correctly (also re-fit after layout settles).
8493  updateHeaderHeightVar();
8494  requestAnimationFrame(() => { try { updateHeaderHeightVar(); fitColumnsHeight(); } catch (e) {} });
8495  // On phones, auto-hide the demo banner after a few seconds so it doesn't eat the screen.
8496  if (window.matchMedia && window.matchMedia('(max-width: 640px)').matches) {
8497    bar.style.overflow = 'hidden';
8498    bar.style.transition = 'opacity .25s ease, max-height .25s ease, padding .25s ease';
8499    bar.style.maxHeight = '200px';
8500    setTimeout(() => {
8501      // Collapse the banner AND retarget the header + view heights to the no-banner layout
8502      // in the SAME frame, so the tabs and the data animate UP TOGETHER (via the CSS
8503      // transitions on .header top + view height) instead of the header freezing at the old
8504      // offset while only the data rises, then snapping at the end.
8505      bar.style.opacity = '0'; bar.style.maxHeight = '0'; bar.style.paddingTop = '0'; bar.style.paddingBottom = '0';
8506      // Recompute through the shared helper so the launch bar (if showing) stays accounted for.
8507      try { updateHeaderHeightVar(); } catch (e) { const _h = document.querySelector('.header'); if (_h) { _h.style.top = '0px'; document.documentElement.style.setProperty('--header-h', _h.offsetHeight + 'px'); } }
8508      try { fitColumnsHeight(); } catch (e) {}
8509      setTimeout(() => {
8510        bar.style.display = 'none';
8511        // Replace the full banner with a thin continuous right-to-left marquee at the top.
8512        if (!document.getElementById('demoMarquee')) {
8513          const mq = document.createElement('div');
8514          mq.id = 'demoMarquee';
8515          mq.style.cssText = 'position:sticky;top:0;z-index:200;overflow:hidden;white-space:nowrap;background:linear-gradient(90deg,#3b82f6,#8b5cf6);color:#fff;font-size:12px;font-weight:600;padding:5px 0;cursor:pointer';
8516          const t = IS_FREE
8517            ? (FREE_TRIAL_ACTIVE
8518                ? '👻 GHOST FLOW — Free plan · Live SPY, TSLA & BTC during your 2-day trial · 🔒 Upgrade for all tickers, live flow + move alerts → theghostflow.com'
8519                : '⏳ Your free trial ended · 🔒 Upgrade to unlock live data, all tickers, flow + move alerts → theghostflow.com')
8520            : '📺 LIVE DEMO — exploring SPY, TSLA & BTC · ⏱ data delayed 15 min · 🔒 Get Premium for real-time data, all tickers, the market scanner, flow & move alerts → theghostflow.com';
8521          const sep = '     •     ';
8522          // Two copies so the -50% loop is seamless (no gap).
8523          mq.innerHTML = '<span>' + t + sep + t + sep + '</span>';
8524          mq.onclick = () => { location.href = UPGRADE_URL; };
8525          document.body.insertBefore(mq, document.body.firstChild);
8526        }
8527        try { updateHeaderHeightVar(); fitColumnsHeight(); fitChartViewHeight(); } catch (e) {}
8528      }, 260);
8529    }, 6000);
8530  }
8531  const hide = id => { const e = document.getElementById(id); if (e) e.style.display = 'none'; };
8532  hide('screenshotBtn');
8533  // Search + Add stay VISIBLE but locked in the demo (user 2026-09-05: "demo users can see it, not use") - read-only,
8534  // premium placeholder, and a tap explains instead of doing nothing.
8535  [['searchTicker','Search · Premium'],['customTicker','+ Add ticker · Premium']].forEach(function(p){ var e=document.getElementById(p[0]); if(!e) return; e.readOnly=true; e.value=''; e.placeholder=p[1]; e.title='Premium: search or add any US stock or ETF with listed options. The demo is limited to SPY and TSLA.'; e.style.cursor='not-allowed'; e.style.opacity='.85'; e.setAttribute('aria-disabled','true'); e.addEventListener('mousedown',function(ev){ ev.preventDefault(); e.blur(); try{ showToast('Search and Add any ticker are Premium features. The demo is limited to SPY and TSLA.'); }catch(x){} }); e.addEventListener('keydown',function(ev){ ev.preventDefault(); }); });
8536  hide('alertBellWrap'); // move alerts are members-only; the scanner IS in the demo (delayed)
8537  hide('scanModeBar');   // watchlist tab is members-only; demo just gets the market scanner
8538  if (IS_DEMO) {   // premium surfaces stay VISIBLE but locked (user 2026-09-05)
8539    try { var _bs2 = document.getElementById('btn-smart'); if (_bs2) _bs2.style.display = ''; var _bi2 = document.getElementById('btn-ideas'); if (_bi2) _bi2.style.display = ''; } catch (e) {}
8540    try { gfHideAdminTabs(); } catch (e) {}
8541    try {
8542      var _iw2 = document.getElementById('indicatorWrap'); if (_iw2) _iw2.style.display = 'block';
8543      var _ip2 = document.getElementById('indicatorPanel');
8544      if (_ip2 && !document.getElementById('demoLayersLock')) {
8545        _ip2.classList.add('demo-locked-panel');
8546        // Open a few layers for the demo (user 2026-09-05): EMA, GEX Levels, Charm Levels - rows + their option lines
8547        try { ['mode-gexlines','mode-charm'].forEach(function(id){ var cb=document.getElementById(id); var lb=cb&&cb.closest('label'); if(lb){ lb.classList.add('demo-open'); var nx=lb.nextElementSibling; if(nx&&nx.classList.contains('mo-opts')) nx.classList.add('demo-open'); } }); var mc=_ip2.querySelector('.modecat[data-cat="ma"]'); if(mc) mc.classList.add('demo-open'); var mr=document.getElementById('maRows'); if(mr) mr.classList.add('demo-open'); } catch (e) {}
8548        var _gbr2 = document.getElementById('mode-gexbub-row'); if (_gbr2) _gbr2.style.display = 'flex';   // GEX Visual row visible (locked) in the demo list too (user 2026-09-05)
8549        var _lk = document.createElement('div'); _lk.id = 'demoLayersLock';
8550        _lk.style.cssText = 'margin:8px 0 6px;padding:8px 10px;border:1px solid rgba(245,158,11,.5);border-radius:8px;background:rgba(245,158,11,.08);font-size:11px;color:#e2e8f0;line-height:1.45';
8551        _lk.innerHTML = '<b style="color:#f59e0b">Try EMA, GEX Levels and Charm Levels here.</b> GEX Visual, VWAP, session levels, dark-pool prints, flow marks and politician trades draw on the chart for members. <a class="demo-lock-btn" href="' + UPGRADE_URL + '" style="display:inline-block;margin-top:6px">Get Premium</a>';
8552        var _hd2 = document.getElementById('modePanelHead'); if (_hd2 && _hd2.parentNode === _ip2) _ip2.insertBefore(_lk, _hd2.nextSibling); else _ip2.insertBefore(_lk, _ip2.firstChild);
8553      }
8554    } catch (e) {}
8555    try { if (typeof gfHeaderV2 === 'function') gfHeaderV2(); } catch (e) {}
8555   // two-row header, level strip, Layers, drawing rail, compact ticker strip
8556  }
8557  const _dn = document.getElementById('scanDemoNote'); if (_dn) _dn.style.display = 'block';
8558  // Free users are logged in — keep their menu (Account / Indicators / Extension / Discord).
8559  // Only the anonymous public demo hides it.
8560  if (!IS_FREE) {
8561    const menu = document.getElementById('userMenu');
8562    if (menu && menu.parentElement) menu.parentElement.style.display = 'none';
8563  }
8564  const hr = document.querySelector('.header-right');
8565  if (hr) {
8566    const a = document.createElement('a');
8567    a.href = UPGRADE_URL; // free users → Stripe checkout; anonymous demo → register
8568    a.textContent = 'Get Premium';
8569    a.style.cssText = 'background:var(--t-accent,#3b82f6);color:var(--t-onAccent,#fff);padding:6px 14px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:700;margin-left:4px';
8570    hr.appendChild(a);
8571  }
8572}
8573// ── Demo guide: a dismissible per-view "what is this" card (demo only) ──
8574// Explains each view in plain English so a first-time visitor knows what they're
8575// looking at. Updates as they switch views; closing it (×) hides it for every view,
8576// leaving a small "ⓘ How it works" pill to bring it back. Choice is remembered.
8577const DEMO_NOTES = {
8578  gex:    { t: '📊 Grid', b: 'Each column is a ticker. Bars show dealer <b>gamma exposure</b> at every strike. <b style="color:#eab308">King Node</b> is the strongest magnet price gets pulled toward; <b style="color:#22c55e">Call Wall</b> caps upside, <b style="color:#ef4444">Put Wall</b> supports downside.' },
8579  expiry: { t: '📅 Heatmap', b: 'Gamma by strike across <b>expiration dates</b>. Brighter cells = heavier dealer positioning. See which expiry is pinning price and where the big walls line up.' },
8580  // In the current layout the Chart tab IS Ghost Mode — the full all-in-one workstation.
8581  chart:  { t: '⚡ Ghost Mode', b: 'The full workstation — <b>GEX ladder</b>, <b>price chart</b> with levels + drawing tools + replay, <b>live options flow</b> and a <b>Trade Plan</b> panel with the system&#39;s picks, all on one screen. Install our free Chrome extension and it <b>syncs to TradingView</b>, auto-following whatever ticker you load on your chart. Open several windows on different tickers with the per-window TV Sync toggle.' },
8582  ultra:  { t: '⚡ Ghost Mode', b: 'The full workstation — GEX ladder, chart, live flow and trade plan on one screen, synced to TradingView.' },
8583  scanner: { t: '🔍 Scanner', b: 'Scan the <b>whole market</b> — filter by price, % move and volume, click the <b>Today / Volume / Mkt Cap</b> headers to sort, and click any row to open its GEX chart. Premium adds 🔔 custom move alerts.' },
8584  tracking: { t: '📋 Tracking', b: 'Track the trades you like. Hit <b>Track</b> on any flow row or <b>+ Add position</b> manually, then watch live mark vs your entry in <b>$ and %</b>. Close a position to lock its P&amp;L into your <b>Realized</b> total, grouped by sector.' },
8585  indicators: { t: 'Indicators', b: 'Our <b>TradingView indicators</b>. <b style="color:#22c55e">Golden Zone Fibs</b> and <b style="color:#22c55e">EMA Suite</b> are free for everyone; <b style="color:#eab308">GhostFlow Levels</b> (GEX levels on TV) and <b style="color:#eab308">Inside Bar Rectangle</b> are premium, invite-only. Add them to your charts to trade the same structure shown here.' },
8586};
8587// ── Contextual Help panel (the "? Help" button) — a plain-English guide per view ──
8588const HELP_VIEW = {
8589  gex: { title: 'GEX Grid', body:
8590    '<p>Each column is a ticker. The bars show <b>dealer gamma exposure (GEX)</b> at every strike — the hedging pressure sitting at that price. It is a <b>live snapshot of all open options across every expiration</b>, summed per strike (not a multi-day average).</p>'
8591    + '<ul style="margin:10px 0;padding-left:18px">'
8592    + '<li><b style="color:#eab308">King Node</b> — the single strongest level; the biggest magnet price gets pulled toward.</li>'
8593    + '<li><b style="color:#22c55e">Call Wall</b> — the biggest call level above spot; tends to cap rallies.</li>'
8594    + '<li><b style="color:#ef4444">Put Wall</b> — the biggest put level below spot; tends to support dips.</li>'
8595    + '<li><b style="color:#3b82f6">Vol Node</b> — the strike with the most volume today; an active battleground.</li>'
8596    + '<li><b>Per-column controls</b> — each column has its own ticker, metric (GEX / VEX / CHARM) and expiry (ALL / 0DTE / WEEKLY) dropdowns, so the same ticker can run side-by-side on different reads. A displaced ticker re-appears as a new column at the end.</li>'
8597    + '<li><b>Settings bar</b> — EXPIRY basis plus <b>BARS</b> (Original / Taper / Glow / Segments / Track) and <b>TINT</b> (Off / Soft / Bold) restyle every column live.</li>'
8598    + '<li><b>RESET</b> — hold 1 second to restore this sector to its default layout; your added tickers and the top toggles are kept.</li>'
8599    + '<li>Drag columns to reorder, use the fold arrow to collapse the ticker strip, and every sector remembers its own layout.</li>'
8600    + '</ul><p>Green bars = positive exposure (dampens moves), red = negative (amplifies them).</p>' },
8601  expiry: { title: 'Heatmap', body:
8602    '<p>The same gamma exposure as the Grid, but <b>broken out by expiration date</b> — each column is one expiry. Brighter cells mean heavier dealer positioning.</p>'
8603    + '<ul style="margin:10px 0;padding-left:18px">'
8604    + '<li>See <b>which expiry is pinning price</b> and where the big walls line up across dates.</li>'
8605    + '<li><b>&#9733;</b> marks the 3 biggest positive and 3 biggest negative cells across the <b>whole grid</b> - the heaviest positioning on the board.</li>'
8606    + '<li><b style="color:#4ade80">&#9650;</b> / <b style="color:#f87171">&#9660;</b> mark each <b>column\'s own</b> biggest positive and negative cell. Far-dated expiries are smaller in absolute terms and never win a star, so these keep every column readable. A cell that already has a star does not also get a triangle.</li>'
8607    + '<li>Every other cell fades with its size, so the shape of a column reads at a glance.</li>'
8608    + '<li>King / Call Wall / Put Wall are flagged in the left column; the King line is the gold one.</li>'
8609    + '<li><b>Colors by metric</b> — GEX and VEX cells are <b style="color:#22c55e">green</b> (positive) / <b style="color:#ef4444">red</b> (negative). <b style="color:#22d3ee">CHARM switches to cyan / amber</b>: <b style="color:#22d3ee">cyan = positive charm</b> — time-decay hedging BUYS there, price drifts UP toward it into the close; <b style="color:#f59e0b">amber = negative charm</b> — decay hedging SELLS, price gets dragged DOWN toward it. Gold is always the King cell.</li>'
8610    + '<li><b>Trading CHARM on the heatmap</b> — read the <b>first (nearest-expiry) column</b>; that is where charm lives, and its cells brighten smoothly with size. The cyan block just above spot is the afternoon magnet — lean long toward it while price holds above the <b>Charm Flip</b>, marked for you as the <b>dashed white seam</b> across the grid (plus a <b>FLIP</b> chip with the exact price on the info line). Below the flip, the biggest amber cell is the downside magnet — decay selling accelerates into it. Strongest in the last 2 hours and on 0DTE/OPEX; the whole map resets at the close, so never carry today\'s charm levels overnight.</li>'
8611    + '<li><b>Uniform wash</b> — every cell carries the same faint green/red (cyan/amber on CHARM) wash and the number\'s brightness scales with its size;
8611 strikes with no exposure stay black. Same-sign neighbours merge into continuous bands.</li>'
8612    + '<li><b>%</b> button — appends each cell\'s share of its column\'s biggest print. <b>BARS</b> and <b>TINT</b> restyle the cells exactly like the Grid.</li>'
8613    + '<li><b style="color:#f5f0e6">WAVE</b> (Premium) — the <b>Gamma Wave</b>: one curve from spot through each expiry\'s strongest gamma strike, left to right across the board. <b style="color:#22c55e">Green</b> where it runs into positive gamma (price gets held there), <b style="color:#ef4444">red</b> into negative (price gets pushed through). Read it as the path the hedging is drawing: a wave that bends up into green above spot says the near expiries pin higher; one that dives into red below says there is nothing to catch a drop until the strike it turns at. An expiry carrying under 4% of the board\'s biggest print is skipped, so a dead column never bends the line. The line runs <b>beneath</b> the values, and the small dot in a cell\'s top-right corner marks the strike the wave picked for that expiry - the cell itself says how much.</li>'
8614    + '<li><b style="color:#f5f0e6">TERM</b> (Premium) — the <b>gamma term structure</b>: the same idea with expiries summed into <b>this week / next week / later</b> and the strongest strike of each bucket, drawn wider and dimmer <b>behind</b> WAVE, with a diamond in the corner of each bucket\'s strongest cell - left to right: this week, next week, later. Where gamma concentrates as you go further out. When WAVE and TERM point at the same strike, that level is the whole board\'s magnet; when they split, the near-term line is the intraday read and the bucketed one is where positioning wants to settle over the coming weeks.</li>'
8615    + '<li><b>How to follow it</b> — pick the ticker, set the basis (ALL / 0DTE / WEEK) and turn on WAVE and TERM; both stay on across tickers and reload. Hover a header for the expiry, a cell for the size. In My HQ the same three toggles live under the HEATMAP menu on the top row.</li>'
8616    + '</ul>' },
8617  chain: { title: 'Option Chain', body:
8618    '<p>Every strike of one expiry, fully priced, with the <b>dealer gamma each strike carries</b> down the middle. Calls on the left, puts on the right; the strike column is the axis.</p>'
8619    + '<ul style="margin:10px 0;padding-left:18px">'
8620    + '<li><b>Gamma profile</b> — call gamma grows to the left of the strike, put gamma to the right, so the whole chain reads as one shape. The wall is where the bars are longest; you see it without reading a number.</li>'
8621    + '<li><b style="color:#eab308">KN</b> / <b style="color:#22c55e">CW</b> / <b style="color:#ef4444">PW</b> tags on the strike — King Node, Call Wall and Put Wall, the same levels the Grid reads. Shown on the current day\'s expiry.</li>'
8622    + '<li><b>Expected move</b> — priced off the ATM straddle, not a formula, so it cannot disagree with the prices beside it. The strike column is shaded between the bounds, the edges are the solid gold lines, and the number sits on the strip (EM +/- and the range). The <b>dotted</b> gold line is the current price.</li>'
8623    + '<li><b style="color:#22d3ee">Busiest strikes</b> — the two most-traded strikes on each side today, lit in cyan. Where the trading actually happened, which is a different question from where the gamma sits.</li>'
8624    + '<li><b>Columns</b> — VOL, OI, IV and <b>VOL/OI</b>: today\'s volume against the open interest that was already there. Over 1x lights up (more traded today than was open before the bell); a strike with volume and no open interest reads <b>NEW</b>. Toggle any of them on the strip.</li>'
8625    + '<li><b>Ranges</b> — +/- 8, 15 or 30 percent around spot, or the full chain. Expiries run across the top; a new ticker opens at its front expiry.</li>'
8626    + '<li><b>Bid / ask</b> on both sides, in-the-money shaded so a deep call bid never reads like a premium. The per-ticker call/put split and the top contracts by premium are pinned above the board.</li>'
8627    + '<li><b>Track from the row</b> — hover a strike and the + on either edge tracks that side at its ask, into the Tracking view.</li>'
8628    + '<li><b>How to follow it</b> — pick the expiry you trade, find the longest bars either side of spot (the walls), check whether the busiest strikes agree with them, and keep the expected-move band in view: a target outside the band is asking for more than the straddle is pricing. Gamma streams live over the socket; quotes refresh in the background; the board opens at the money.</li>'
8629    + '</ul>' },
8630  workspace: { title: 'My HQ', body:
8631    '<p>Every mode - Grid, Heatmap, Ghost Mode, Flow, Chain, Smart Money, Ideas, Pulse, Tracking - on one screen, in the arrangement you want. Every window is the real panel, not a copy: same data, same settings, same live feed as its own tab.</p>'
8632    + '<ul style="margin:10px 0;padding-left:18px">'
8633    + '<li><b>ADD</b> — pull a panel from the ADD menu onto a window\'s <b>edge</b> to split it, onto the <b>centre</b> to add it as a tab, or to the <b>edge of the screen</b> for a full-height column of its own. Drag a whole window by its tab bar. Resize with the dividers. Close a tab with its x.</li>'
8634    + '<li><b>Make room on an edge.</b> Pull the bottom or the right edge of any window inward and an empty slot opens on that side of that window, sized by the pull, with the other windows untouched; drop a panel or a window into it, or drag the divider across so the window beside it takes the space. Drag a window by its tab bar to the screen\'s bottom edge to make it a full-width row.</li>'
8635    + '<li><b>One ticker line</b> — the ticker on the top row drives every window: chart, heatmap, chain, flow, smart money, grid. Type to search, RECENT keeps your last five. Clicking a ticker inside <b>Node Alerts</b>, <b>Movers</b>, the flow tape or Smart Money does the same - every window follows, and a window on a background tab catches up the moment you click its tab.</li>'
8636    + '<li><b>TV SYNC</b> — with the Chrome extension, whatever ticker you load on TradingView loads in every window. Off by default; your choice is remembered.</li>'
8637    + '<li><b>Top row menus</b> — EXPIRY, BARS, TINT, HEATMAP (%, WAVE, TERM) and VOL open on hover and apply to every window that uses them.</li>'
8638    + '<li><b>LAYOUT</b> — save the arrangement under a name (<b>Save as</b>), open another, delete one. Layouts live on your account, so they open on any machine, and two monitors can hold two different layouts at the same time. Save-as never changes the layout you started from.</li>'
8639    + '<li><b>Grid in HQ</b> — set two or three columns to the same ticker with different reads (GEX ALL beside 0DTE beside CHARM) and picking a new ticker moves all of them, settings intact; the rest of the window fills from that ticker\'s sector. <b>RESET</b> and <b>+</b> sit on the grid bar; a column\'s x removes it here without touching any watchlist.</li>'
8640    + '<li><b>Ticker Tide under every chart</b> — in HQ each chart pane carries its own tide strip (net call vs put premium, minute by minute) keyed to that pane\'s ticker; it follows ticker changes and refreshes in place. Click its header to fold it.</li>'
8641    + '<li><b>Ghost Panel</b> — Ghost Mode\'s right-hand panel (Flow, Movers, Node Alerts) as a window of its own, anywhere you want it. <b>Right-click a window\'s tab and pick Duplicate</b> for a second copy of a panel with inner tabs - Ghost Panel, Ideas, Pulse - beside the first, with its own tab bar: the original shows one tab, the copy another, and you can drag the copy anyw
8641here. A tab lives in one place at a time. Chain and Grid have one view and one state, so they cannot be split this way.</li>'
8642    + '<li><b>Side panels</b> — filters and trackers collapse to the edge by default and come back on a click, so a small window is all content.</li>'
8643    + '<li><b>How to follow it</b> — start from a preset in LAYOUT, put the Ghost Mode chart where you look most, the Chain or Heatmap beside it and Flow below, then pick one ticker on the top row and read all of them at once. Save it under a name before you experiment with another.</li>'
8644    + '</ul>' },
8645  chart: { title: 'Ghost Mode', body:
8646    '<p>The all-in-one workstation for one ticker at a time.</p>'
8647    + '<ul style="margin:10px 0;padding-left:18px">'
8648    + '<li><b>GEX ladder</b> (left) — the live levels for the active ticker.</li>'
8649    + '<li><b>Chart</b> — candles with the levels drawn on, plus <b>drawing tools</b> (trend, curve, brush + colors), <b>Replay</b> (any of the last 7 days) and <b>Node Alerts</b>.</li>'
8650    + '<li><b>Metric toggle</b> — GEX (gamma), VEX (vanna), CHARM (delta decay); switches every view.</li>'
8651    + '<li><b>Flow</b> — live options trades hitting the tape.</li>'
8652    + '<li><b>Trade Plan</b> — the system&#39;s read of the charted ticker: every Ideas list that picked it today (Setups, Watchlist) with live status, a fresh idea built on demand for it by the same engine, and <b>Ticker Tide</b> — minute-by-minute net call vs put premium.</li>'
8653    + '<li><b style="color:#a855f7">GEX Visual</b> — live dealer gamma drawn behind the candles (bubbles or glow lines); turn it on in the <b>Modes</b> menu and see the full guide below.</li>'
8654    + '<li><b>Tags &amp; Signals</b> (Modes &gt; GEX Visual) — Strike / % / Trend labels at each band\'s live edge. <b style="color:#22d3ee">BUILDING</b> pulses on the single fastest-strengthening band — gamma builds at a strike before price arrives, so that is the early direction — and <b style="color:#22d3ee">PULL</b> shows the net gamma pull above vs below price.</li>'
8655    + '<li><b>Using them together</b> — PULL is your direction filter: trade with the arrow, expect chop while it keeps flipping. BUILDING names the destination: a building band overhead is a take-profit magnet, one below price is an early warning. The strongest read is when they AGREE (PULL pointing at the same strike that is pulsing BUILDING); when they disagree the tape is in transition and the building band usually wins the next leg. The small ▲/▼ trend delta on every tag tells you whether a level is firming or bleeding as price approaches it.</li>'
8656    + '<li><b>History</b> — keep yesterday\'s trail dimmed behind today\'s, or project its closing bands across today with <b>+ Prev</b>.</li>'
8657    + '<li><b>Per-view settings</b> — metric, expiry, BARS and TINT belong to each view (Grid / Heatmap / Ghost Mode) unless Sync is on.</li>'
8658    + '<li><b>TV Sync</b> — with our free Chrome extension the chart auto-follows whatever ticker you load on TradingView.</li>'
8659    + '</ul>' },
8660  ideas: { title: 'Trade Ideas', body:
8661    '<p>Three lists built by the same scoring engine — flow conviction, GEX structure (walls, flip, King), charm, momentum and risk/reward, with every contract kept under <b>$800/lot</b>.</p>'
8662    + '<ul style="margin:10px 0;padding-left:18px">'
8663    + '<li><b>Setups</b> — the top 5 scored contract setups, once a day: the <b>morning read at 6:45 AM PT</b>, held all day. Each card gives the contract, entry zone, target with the estimated premium at target, stop, score/grade and the reasoning — and the <b>Track Record</b> panel shows how every pick resolved, live.</li>'
8664    + '<li><b>Earnings</b> — this week&#39;s reporters scored on live flow, <b>expected move vs breakeven</b> (the IV-crush guard) and dealer positioning. Trades are <b>risk-defined</b>: max loss is the premium paid, no stop. The outcomes panel shows what each name actually did on the print.</li>'
8665    + '<li><b>Watchlist</b> — the top 10 tickers for the coming session, built <b>6:45 AM PT</b> and rebuilt <b>after the close (~1:15 PM PT)</b> on the full recorded day — the list for tomorrow, with live Spot and premium Now columns and flags on names reporting earnings today or tomorrow.</li>'
8666    + '</ul><p>Hit <b>+ Track</b> on any card to follow it in the Tracking view. The same picks also appear in the chart&#39;s <b>Trade Plan</b> panel whenever you load a ticker they include.</p>' },
8667  scanner: { title: 'Scanner', body:
8668    '<p>Scan the <b>whole market</b>.</p>'
8669    + '<ul style="margin:10px 0;padding-left:18px">'
8670    + '<li>Filter by <b>price, % move and volume</b>; sort by the <b>Today / Volume / Mkt Cap</b> headers; click any row to open its GEX chart.</li>'
8671    + '<li><b>Node Alerts</b> fires when a ticker&#39;s price <b>touches a GEX level</b>
8671 — pick which levels and save your settings.</li>'
8672    + '</ul>' },
8673  tracking: { title: 'Tracking', body:
8674    '<p>Track the trades you like.</p>'
8675    + '<ul style="margin:10px 0;padding-left:18px">'
8676    + '<li>Hit <b>Track</b> on any flow row, or <b>+ Add position</b> manually.</li>'
8677    + '<li>Watch <b>live mark vs your entry</b> in $ and % — it refreshes in the background.</li>'
8678    + '<li><b>Close</b> a position to lock its P&amp;L into your <b>Realized</b> total, grouped by sector.</li>'
8679    + '</ul>' },
8680  indicators: { title: 'Indicators', body:
8681    '<p>Our <b>TradingView indicators</b>. <b style="color:#22c55e">Golden Zone Fibs</b> and <b style="color:#22c55e">EMA Suite</b> are free; <b style="color:#eab308">GhostFlow Levels</b> and <b style="color:#eab308">Inside Bar Rectangle</b> are premium, invite-only. Add them to your own charts to trade the same structure shown here.</p>' },
8682};
8683HELP_VIEW.ultra = HELP_VIEW.chart;
8684// A hover "?" hint with a how-to-trade tooltip. `left` right-aligns the tooltip for items near the edge.
8685function _hint(t, left) { return ' <span class="gf-hint' + (left ? ' left' : '') + '" data-tip="' + String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;') + '">?</span>'; }
8686const HELP_GLOSSARY =
8687  '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border)">'
8688  + '<div style="font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin-bottom:8px">Glossary</div>'
8689  + '<ul style="margin:0;padding-left:18px">'
8690  + '<li><b>GEX (Gamma Exposure)</b> — dealer hedging pressure. Strong positive GEX pins/dampens price; negative GEX amplifies moves.' + _hint('GEX is the backbone of the King/Call Wall/Put Wall. HOW TO TRADE: positive net GEX (Bullish header) = a range/pin day, so fade extremes and sell premium toward the King; negative net GEX = a trend/volatile day, so follow momentum and never fade. Check the bias chip first — it tells you which playbook to run.') + '</li>'
8691  + '<li><b>VEX (Vanna)</b> — how the levels shift as implied volatility changes.' + _hint('Vanna exposure explains the quiet grind higher when vol bleeds and the fast flush when vol spikes. HOW TO TRADE: on a calm, vol-declining tape lean long toward the vanna high; if vol spikes, expect dealer selling and get defensive. View it via the VEX metric toggle or the Vanna Levels chart mode.') + '</li>'
8692  + '<li><b style="color:#22d3ee">CHARM</b> (delta decay) — where time-decay drags price; strongest into expiry (the pin).' + _hint('Charm is the rate delta decays with time — dealer hedges that unwind as expiration nears, driving directional drift into the close (strongest on 0DTE and OPEX). HOW TO TRADE: enter near the charm flip and target the charm level price holds toward; positive charm pulls up, negative charm pulls down. See the full Charm playbook section below.') + '</li>'
8693  + '<li><b>Gamma Flip (G-Flip)</b> — the price where the cumulative net dealer gamma crosses zero, i.e. flips from net-positive above to net-negative below. It shows as the purple dashed seam on the grid and the purple dotted line on the chart.' + _hint('The single most important regime line — it tells you WHAT KIND of day it is before you pick a strategy. ABOVE the flip = positive gamma: dealers are long gamma and hedge by SELLING rallies / BUYING dips, so moves get dampened, price pins toward the King and mean-reverts between the walls — fade the extremes, sell premium, do not chase breakouts. BELOW the flip = negative gamma: dealers are short gamma and hedge the OTHER way (sell weakness, buy strength), so moves FEED on themselves — selloffs snowball, rallies extend, volatility expands — go with momentum and never fade (this is where the violent 0DTE moves happen). THE BREAK IS THE SIGNAL: price crossing from above to below flips the whole day from calm/pinning to trending — the moment ranges break and trends start (it is exactly what King-follows-magnet watches for). Distance matters too: sitting right on the flip is whippy/coin-flip; well above = confidently pinned; well below = confidently trending. NO FLIP LINE SHOWING = the whole visible book is one-sided (usually strongly long gamma, e.g. SPY/QQQ on a calm day) — there is no nearby regime change, so just run the fade/pin playbook and do not wait for a flip that is not there.') + '</li>'
8694  + '<li>Levels: <b style="color:#eab308">King Node</b> (strongest magnet), <b style="color:#22c55e">Call Wall</b> (upside cap), <b style="color:#ef4444">Put Wall</b> (downside support), <b style="color:#3b82f6">Vol Node</b> (most-traded strike).' + _hint('HOW TO TRADE: King = the magnet/pin price gravitates to (fade toward it in positive gamma). Call Wall = fade rallies into it; a break and hold above removes the cap and can run. Put Wall = look for bounces off it; a break below removes support and downside accelerates. Vol Node = a battleground where price often consolidates or reacts.', true) + '</li>'
8695  + '</ul></div>';
8696const HELP_CHARM =
8697  '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border)">'
8698  + '<div style="font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin-bottom:8px">How to trade Charm</div>'
8699  + '<p style="margin-bottom:8px"><b>Charm = delta decay</b> — where time-decay drags price. It pins price into expiry (0DTE / Fridays), overnight, and in calm/rangebound tape (in a strong trend, GEX/VEX take over).</p>'
8700  + '<ul style="margin:0 0 8px;padding-left:18px">'
8701  + '<li><b style="color:#22d3ee">Charm Positive</b> pulls price <b>UP</b> — above spot it is an upside target, below spot it is support.</li>'
8702  + '<li><b style="color:#f59e0b">Charm Negative</b> pulls price <b>DOWN</b> — above spot it is resistance/a ceiling, below spot it is a downside target.</li>'
8703  + '<li><b>Pin</b> = the flip between positive and negative — price gravitates here into the close.</li>'
8704  + '<li><b>Bias</b> = the biggest node (the King): a negative King is bearish, a positive King is bullish.</li>'
8705  + '</ul>'
8706  + '<p style="margin:0 0 4px"><b>Play:</b> enter near the pin — long toward the positive level if price holds above, short toward the negative level if it breaks below. Strongest into the nearest expiry. The <b>EOD Pin</b> chart mode draws the pin on the chart for you.</p>'
8707  // (The "EOD pin — Confidence + Signal" table was removed 2026-08-07 at the user's request. The
8708  // Aligned/Pinned/Conflicted signal it described is no longer part of the pin, which now ships a
8709  // measured settlement band instead — see the EOD Pin mode tooltip.)
8710  // Short 0DTE playbook: where the two charm levels sit RELATIVE TO PRICE is the whole setup.
8711  + '<div style="font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin:16px 0 6px">0DTE — reading the two levels</div>'
8712  + '<p style="margin:0 0 8px">Positive charm pulls <b>up</b>, negative charm pulls <b>down</b>. Where the two sit <i>relative to price</i> is the setup:</p>'
8713  + '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px;line-height:1.4">'
8714  + '<tr><td style="padding:5px 6px;font-weight:700;white-space:nowrap;color:#22d3ee">Positive above, negative below</td><td style="padding:5px 6px;color:var(--dim)"><b>Bullish cage.</b> Buy dips toward the flip or the negative level, target the positive level. The negative level is support — below it the setup is dead.</td></tr>'
8715  + '<tr style="border-top:1px solid var(--border)"><td style="padding:5px 6px;font-weight:700;white-space:nowrap;color:#22c55e">Both above price</td><td style="padding:5px 6px;color:var(--dim)"><b>Squeeze.</b> Everything pulls up — most aggressive long. Buy pullbacks that hold, trail to the far level.</td></tr>'
8716  + '<tr style="border-top:1px solid var(--border)"><td style="padding:5px 6px;font-weight:700;white-space:nowrap;color:#f59e0b">Negative above, positive below</td><td style="padding:5px 6px;color:var(--dim)"><b>Bearish cage.</b> Sell rips into the negative level, target the flip then the positive level. Rallies into it usually fail. Invalid on a close above the negative level.</td></tr>'
8717  + '<tr style="border-top:1px solid var(--border)"><td style="padding:5px 6px;font-weight:700;white-space:nowrap;color:#ef4444">Both below price</td><td style="padding:5px 6px;color:var(--dim)"><b>Bleed.</b> Nothing overhead pulls up — slow grind lower. Puts work but it drifts, so size for time, not a spike.</td></tr>'
8718  + '</table></div>'
8719  + '<p style="margin:8px 0 0;color:var(--dim)"><b>Applies to all four:</b> charm is weak before 11am ET and strongest after 2pm; the <i>nearest</i> level does the work (2% away pulls almost nothing); and in negative gamma treat every level as breakable — charm is a drift force, not a barrier. When charm lines up with the King or a wall, that is the level to trade.</p>'
8720  // CHARM on the Heatmap — the color key + the intraday routine (user 2026-08-31).
8721  + '<div style="font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:#cbd5e1;margin:16px 0 6px">Charm on the Heatmap — following it live</div>'
8722  + '<p style="margin:0 0 8px;color:#cbd5e1">Switch the metric to <b style="color:#22d3ee">CHARM</b> and read the <b>first (nearest-expiry) column</b> — charm lives there; far columns are context. The colors:</p>'
8723  + '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px;line-height:1.45">'
8724  + '<tr><td style="padding:5px 6px;font-weight:800;white-space:nowrap;color:#22d3ee">Cyan cell</td><td style="padding:5px 6px;color:#cbd5e1">Positive charm — decay hedging <b>buys</b> there. Price <b>drifts UP toward it</b> into the close. The cyan block just above spot is the afternoon magnet.</td></tr>'
8725  + '<tr style="border-top:1px solid var(--border)"><td style="padding:5px 6px;font-weight:800;white-space:nowrap;color:#f59e0b">Amber cell</td><td style="padding:5px 6px;color:#cbd5e1">Negative charm — decay hedging <b>sells</b>. Price gets <b>dragged DOWN toward it</b>. The biggest amber cell below spot is the downside magnet if the flip breaks.</td></tr>'
8726  + '<tr style="border-top:1px solid var(--border)"><td style="padding:5px 6px;font-weight:800;white-space:nowrap;color:#e2e8f0">Dashed seam + FLIP chip</td><td style="padding:5px 6px;color:#cbd5e1">The <b>Charm Flip</b> — front-expiry charm crosses zero here (exact price on the info line). Above it the cyan block drives; below it the amber shelf drives. This is your line in the sand.</td></tr>'
8727  + '<tr style="border-top:1px solid var(--border)"><td style="padding:5px 6px;font-weight:800;white-space:nowrap;color:#eab308">Gold cell</td><td style="padding:5px 6px;color:#cbd5e1">The King (GEX-based structural magnet). A monster amber cell ON the King doubles the downside pull.</td></tr>'
8728  + '<tr style="border-top:1px solid var(--border)"><td style="padding:5px 6px;font-weight:800;white-space:nowrap;color:#e2e8f0">Bright pill &#9733; / tint &#9650;&#9660;</td><td style="padding:5px 6px;color:#cbd5e1">The grid\'s 3 biggest prints each way / each column\'s own high &amp; low. Concentration = a tradeable magnet; an even smear = charm has no opinion today, use GEX instead.</td></tr>'
8729  + '</table></div>'
8730  + '<p style="margin:8px 0 4px;color:#cbd5e1"><b>The routine:</b> <b>(1) Premarket</b> — mark the flip, the cyan magnet and the amber shelf; no trade, charm is weak early. <b>(2) Morning</b> — just track which side of the flip price accepts, and check the GEX regime. <b>(3) From ~11am ET</b> — trade toward the block on your side: above the flip buy dips near it, target the cyan block; below the flip sell rips, target the big amber cell. Stop just across the flip. <b>(4) Power hour</b> — charm peaks; let it work, take profit INTO the block (the top of the cyan block is a ceiling, not a breakout). <b>(5) Close</b> — flat; the map dies at the bell and rebuilds fresh tomorrow.</p>'
8731  + '<p style="margin:6px 0 0;color:#cbd5e1"><b>How fresh is it:</b> during market hours the cells come off the real-time feed — the heatmap re-pulls within <b>~5&#8211;10 seconds</b> of a change (the chart\'s Charm Levels lines refresh every 30s). Off-hours the board is frozen at the last session.</p>'
8732  + '</div>';
8733// DAY REGIME help (user 2026-08-25) — shown with the chart help so the header chip is explained
8734// where people look for it.
8735const HELP_REGIME =
8736  '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border)">'
8737  + '<div style="font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:#cbd5e1;margin-bottom:8px">Day Regime</div>'
8738  + '<p style="margin-bottom:8px">The chip beside the ticker calls <b>how the day trades</b> — one call per ticker, <b>locked at 10:00 ET</b> so it does not move around on you.</p>'
8739  + '<ul style="margin:8px 0;padding-left:18px">'
8740  + '<li><b style="color:#22d3ee">PIN DAY</b> — dealers are <b>long gamma</b>, so they sell rallies and buy dips to stay hedged. That <b>dampens</b> price: expect a tight range. Fade the extremes, respect the walls, do not chase breakouts.</li>'
8741  + '<li><b style="color:#f5c518">TREND DAY</b> — dealers are <b>short gamma</b>, so they buy strength and sell weakness. That <b>amplifies</b>
8741 price: expect a wider range. Trade with the move; fading is how you get run over.</li>'
8742  + '<li><b>MIXED</b> — net gamma is near balanced. Trade the levels, not the regime.</li>'
8743  + '</ul>'
8744  + '<p style="margin-bottom:8px"><b>Positive vs negative gamma</b> is the whole idea. <b>Positive</b> net gamma means dealer hedging works <i>against</i> the move — it pins and chops. <b>Negative</b> means hedging works <i>with</i> the move — it trends and gaps. The chip is just that, measured: net gamma divided by gross gamma across the top strikes, so it compares across tickers. Above +0.15 is PIN, below -0.15 is TREND.</p>'
8745  + '<p style="color:#cbd5e1;font-size:11px">Hover the chip for the numbers behind it, the plan, and the live dealer read underneath it. It deliberately does <b>not</b> call direction — the backtest could not support that.</p>'
8746  + '</div>';
8747const HELP_MODES =
8748  '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border)">'
8749  + '<div style="font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin-bottom:8px">Chart Modes</div>'
8750  + '<p style="margin-bottom:8px">The <b>Modes</b> dropdown (top of the chart) overlays dealer-positioning levels on price. Each is an independent toggle with its own <b>color picker</b>, and none of them change your core GEX or charm lines.</p>'
8751  + '<p style="margin:-4px 0 8px;color:var(--faint);font-size:11px">Hover the <b>?</b> on any level for how to trade it, how to use it, and what to watch for.</p>'
8752  + '<ul style="margin:0;padding-left:18px">'
8753  + '<li><b>GEX Levels</b>' + _hint('The four core dealer-gamma levels. King = the biggest gamma strike, the day\'s magnet/pin. Call Wall = upside resistance where dealer selling caps rallies. Put Wall = downside support where dealer buying cushions selloffs. Vol Node = most-traded strike, a battleground. HOW TO TRADE: in positive gamma (Bullish/pinning) fade moves into the Call/Put walls and treat the King as the magnet; a decisive break and hold past a wall removes the cap and price accelerates. WATCH: confluence — when two levels stack, it is a stronger line.') + ' — King, Call Wall, Put Wall, Vol Node.</li>'
8754  + '<li><b style="color:#f59e0b">Charm Levels</b>' + _hint('Charm = delta decay (how dealer hedges unwind as time passes). The amber/cyan lines mark the most-positive and most-negative net-charm strikes for the nearest expiry, plus the charm flip (zero-cross). HOW TO TRADE: charm levels act as intraday magnets/pins, strongest on 0DTE and into the afternoon and on OPEX; enter near the flip and target the charm level price holds toward. WATCH: strongest in calm/rangebound tape — in a strong trend, gamma/vanna take over.') + ' — net-charm walls + flip (0DTE).</li>'
8755  + '<li><b style="color:#a78bfa">VWAP</b>' + _hint('Session volume-weighted average price — the average price the day traded at, a fair-value line institutions lean on. Resets each day, intraday timeframes only. HOW TO TRADE: above VWAP = buyers in control (dips to it are buy opportunities), below = sellers (rallies to it are shorts); reclaims and rejections of VWAP are classic intraday triggers. WATCH: works best on liquid names and trend days.') + ' — session fair-value line.</li>'
8756  + '<li><b>Moving Averages</b>' + _hint('Up to 3 configurable EMAs/SMAs. HOW TO TRADE: standard trend context — price above a rising MA is bullish; pullbacks to a key MA (21 EMA intraday, 200 SMA for the bigger trend) are common entries, and crossovers signal trend shifts. WATCH: use as confirmation with the dealer levels, not on their own.') + ' — up to 3, EMA/SMA.</li>'
8757  + '<li><b style="color:#e879f9">Max Pain</b>' + _hint('The strike where the most option value expires worthless — where option buyers hurt most and writers profit most (from call/put open interest). HOW TO TRADE: price tends to gravitate toward max pain into expiration, especially OPEX Fridays and into the close, so use it as an end-of-day / end-of-week magnet and target. WATCH: strongest near expiry; weak early in a cycle.') + ' — the EOD/OPEX magnet strike.</li>'
8758  + '<li><b style="color:#eab308">EOD Pin</b>' + _hint('Where price is most likely to close, drawn with a SETTLEMENT BAND — the shaded zone about 4 of 5 closes land inside. The band width is measured from recorded sessions, not guessed: it widens with recent realized movement (the quietest tape closed within 0.15%, the most active within 0.78%) and with negative gamma (~28% wider than positive), and it tightens into the bell. Honest caveat from that same study: no dealer-structure signal predicts the DIRECTION of the close — so use the pin as a centre with real tolerance, not a forecast. Always 0DTE, interpolated so it can land between strikes like a real close. HOW TO TRADE: in POSITIVE gamma price gravitates to the pin into the last hour — fade extensions back toward it and use it as your end-of-day target; it tightens as the close nears. WATCH: trust it ONLY in positive gamma — the panel\'s Confidence flag reads "Strong (pos gamma)" vs "Weak (neg gamma)"; in negative gamma the pin does not hold and price runs. It updates live all day, so judge it in the final 30-60 min.') + ' — the 0DTE close magnet.</li>'
8759  + '<li><b style="color:#a855f7">GEX Visual</b>' + _hint('A live view of dealer gamma drawn BEHIND the candles: a glowing bubble — or a glowing line — at each strike, sized by its GEX % (the same % on the left panel), building across the 6:30am-1pm session. COLOUR = rank: the two strongest magnets are PURPLE, other positive strikes GOLD, negative RED; a level dims a little once price has broken through it, and a strike that stops getting gamma simply stops drawing until it returns. A white ring marks the nearest strong level price is heading toward NEXT. HOW TO READ: the biggest, brightest bands are where dealer gamma concentrates — the levels most likely to pin or repel price; watch the purple magnets and the white target ring. Style (Bubbles / Glow Lines), Session, Extend and History (keep the PREVIOUS session\'s trail on the chart, dimmed) are in the Modes panel.') + ' — live gamma by strike, behind price.</li>'
8760  + '<li><b style="color:#2dd4bf">Vanna Levels</b>' + _hint('Vanna = how dealer delta shifts when implied volatility moves. Shows net-vanna walls (high/low) + the vanna flip. HOW TO TRADE: vanna drives the slow grind higher when vol bleeds (calm tape) and the sharp selloff when vol spikes; the flip marks where that hedging pressure changes sign. Pair with Charm to explain the AM drift and the 2-3pm ramp. WATCH: most powerful around OPEX and on big vol moves.') + ' — net-vanna walls + flip.</li>'
8761  + '<li><b style="color:#4ade80">Flow-Weighted Walls</b>' + _hint('The Call/Put walls re-weighted by TODAY\'s net ask-side (buy) flow premium, drawn as separate lines so your standard OI walls stay put. Standard walls = slow accumulated positioning; flow walls lean toward where fresh aggressive money is hitting now. HOW TO TRADE: when the flow wall diverges from the OI wall, a NEW intraday level is forming where price often reacts — trade toward/against it like a wall. WATCH: needs real 0DTE flow; quiet names show little divergence.') + ' — walls re-weighted by today\'s flow.</li>'
8762  + '<li><b style="color:#fbbf24">Magnet Levels</b>' + _hint('The top strikes by a blended score of gamma + charm + vanna + open interest + today\'s flow — the prices dealer positioning pulls hardest toward (brightest = strongest). HOW TO TRADE: treat the #1 magnet as the pin/target; in a positive-gamma tape fade extensions back toward it, and use magnets 2 and 3 as the next reaction levels. Highest conviction when a magnet stacks on a King/wall. WATCH: in negative gamma the pull weakens and price can blow through — trade momentum instead.') + ' — the strongest composite pulls.</li>'
8763  + '<li><b style="color:#60a5fa">Expected Move &#177;1&#963;</b>' + _hint('The market\'s own bet on today\'s range: a plus/minus 1-sigma band from the ATM 0DTE straddle price. HOW TO TRADE: most sessions stay inside the band, so the edges are natural fade/target zones; a decisive break of the band signals a trend day and continuation. WATCH: the band is for the day — best used intraday, and it tightens as the day decays.') + ' — the ATM 0DTE straddle band.</li>'
8764  + '<li><b style="color:#eab308">Session Levels</b>' + _hint('Three independent lines: Prior Day high/low (yesterday\'s regular-hours range), Overnight high/low (extended-hours from 4pm close to 9:30 open), and Opening Range high/low (first 15 min after the open
8764). HOW TO TRADE: breaks and holds above/below the prior-day or overnight extremes signal continuation; the opening range confirms the session\'s early lean (break above OR high = long bias, below OR low = short). WATCH: the most-respected intraday S/R — identical on every timeframe.', true) + ' — Prior Day / Overnight / Open Range.</li>'
8765  + '</ul>'
8766  + '<p style="margin:8px 0 0;color:var(--faint)">Tip: you can also ask <b>Ghost AI</b> "how do I trade [any level]" for the same playbook.</p>'
8767  + '</div>';
8768function openHelp() {
8769  const v = (typeof currentView !== 'undefined' && currentView) || 'gex';
8770  const h = HELP_VIEW[v] || HELP_VIEW.gex;
8771  let ov = document.getElementById('helpOverlay');
8772  if (!ov) {
8773    ov = document.createElement('div');
8774    ov.id = 'helpOverlay';
8775    ov.style.cssText = 'display:none;position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.62)';
8776    ov.onclick = (e) => { if (e.target === ov) closeHelp(); };
8777    ov.innerHTML = '<div style="max-width:660px;margin:5vh auto;max-height:88vh;overflow-y:auto;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,0.6)">'
8778      + '<div style="display:flex;align-items:center;justify-content:space-between;padding:15px 20px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--card);z-index:1">'
8779      + '<div id="helpTitle" style="font-size:16px;font-weight:800;color:var(--text)"></div>'
8780      + '<button onclick="closeHelp()" style="background:none;border:none;color:var(--dim);font-size:22px;cursor:pointer;line-height:1">&times;</button></div>'
8781      + '<div id="helpBody" style="padding:18px 20px 24px;font-size:13px;line-height:1.65;color:var(--dim)"></div></div>';
8782    document.body.appendChild(ov);
8783    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeHelp(); });
8784  }
8785  document.getElementById('helpTitle').textContent = 'Guide — ' + h.title;
8786  const showModes = (v === 'chart' || v === 'ultra');   // Modes live on the chart / ultra screens
8787  document.getElementById('helpBody').innerHTML = h.body + (showModes ? HELP_REGIME + HELP_MODES : '') + HELP_GLOSSARY + HELP_CHARM;
8788  ov.style.display = 'block';
8789}
8790function closeHelp() { const ov = document.getElementById('helpOverlay'); if (ov) ov.style.display = 'none'; }
8791// Admin: preview then post the product-update announcement to the Discord announcements channel.
8792// The post that is ready to go. Discord markdown; under the 2,000-character webhook limit.
8793const GF_ANNOUNCE_DRAFT = [
8794  '**Two big ones today**',
8795  '',
8796  '**Option Chain.** Every strike priced, with the gamma it carries - call gamma grows left, put gamma right, so the wall is a shape, not a number you go looking for. King / Call Wall / Put Wall tagged on the strike. Expected move priced off the ATM straddle, so it cannot disagree with the prices beside it. VOL, OI, IV, and VOL/OI - today\'s volume against the open interest that was already there. The two busiest strikes each side lit. Track a contract from its row.',
8797  '',
8798  '**My HQ.** Every mode - Grid, Heatmap, Ghost Mode, Flow, Chain, Smart Money, Ideas, Pulse - on one screen, in the arrangement you want. Drag a panel onto a window\'s edge to split it, onto the centre to add it as a tab, or to the edge of the screen for a full column of its own. Every window is the real panel, not a copy. One ticker line drives all of them, or let TradingView drive them. Layouts save by name to your account, so two monitors can hold two layouts.',
8799  '',
8800  'Both in the left rail. Both premium. Reload if you do not see them.'
8801].join('\n');
8802function announceToDiscord() {
8803  fetch('/api/admin/announce').then(r => r.json()).then(j => {
8804    if (j && j.error) { alert('Cannot announce: ' + j.error); return; }
8805    if (!j || !j.hasWebhook) { alert('ANNOUNCE_WEBHOOK (or DISCORD_WEBHOOK) is not set on the server.'); return; }
8806    const old = document.getElementById('gfAnnBox'); if (old) old.remove();
8807    const m = document.createElement('div'); m.id = 'gfAnnBox';
8808    m.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(2,6,23,.78);display:flex;align-items:center;justify-content:center;padding:16px';
8809    m.innerHTML = '<div style="background:#0f172a;border:1px solid var(--t-border3,#334155);border-radius:12px;max-width:720px;width:100%;padding:16px;display:flex;flex-direction:column;gap:10px">'
8810      + '<div style="display:flex;align-items:center;gap:10px"><b style="font-size:13px;color:#f5f0e6">Post to the Discord announcements channel</b>'
8811      + '<span id="gfAnnCount" style="margin-left:auto;font:700 11px Segoe UI;color:#cbd5e1"></span></div>'
8812      + '<textarea id="gfAnnText" spellcheck="false" style="width:100%;min-height:340px;resize:vertical;background:#0b1220;color:#f5f0e6;border:1px solid #334155;border-radius:8px;padding:10px 12px;font:12.5px/1.5 Segoe UI,monospace"></textarea>'
8813      + '<div style="font-size:11px;color:#a9a294">Discord markdown: **bold**, blank line between paragraphs. Leave the box empty to post the built-in welcome announcement instead.</div>'
8814      + '<div style="display:flex;gap:8px;justify-content:flex-end"><button id="gfAnnCancel" class="refresh-btn">Cancel</button><button id="gfAnnPost" class="refresh-btn" style="color:#0b0f18;background:var(--t-accent,#d4af37);border-color:var(--t-accent,#d4af37);font-weight:800">Post</button></div></div>';
8815    document.body.appendChild(m);
8816    const ta = m.querySelector('#gfAnnText'), cnt = m.querySelector('#gfAnnCount'), post = m.querySelector('#gfAnnPost');
8817    ta.value = GF_ANNOUNCE_DRAFT;
8818    const paint = () => { const n = ta.value.length; cnt.textContent = n + ' / 2000'; cnt.style.color = n > 2000 ? '#f87171' : '#cbd5e1'; post.disabled = n > 2000; };
8819    ta.addEventListener('input', paint); paint(); ta.focus();
8820    m.querySelector('#gfAnnCancel').onclick = () => m.remove();
8821    post.onclick = () => {
8822      const message = ta.value.trim();
8823      if (message.length > 2000) return;
8824      if (!confirm(message ? 'Post this to the announcements channel? (' + message.length + ' characters)' : 'The box is empty - post the built-in welcome announcement instead?')) return;
8825      post.disabled = true;
8826      fetch('/api/admin/announce', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(message ? { message } : {}) })
8827        .then(r => r.json()).then(k => { m.remove(); alert(k && k.ok ? ('Announcement posted to Discord (' + k.chars + ' characters).') : ('Failed: ' + ((k && k.error) || 'unknown'))); })
8828        .catch(() => { post.disabled = false; alert('Failed to post announcement.'); });
8829    };
8830  }).catch(() => alert('Failed to load the announcement.'));
8831}
8832// First-visit nudge: auto-open the guide once for demo + free users so newcomers know
8833// what they're looking at. Paid/admin skip it; shown only once (remembered).
8834function maybeAutoOpenHelp() {
8835  try {
8836    if (localStorage.getItem('gfHelpSeen')) return;
8837    if (typeof isPaidUser !== 'undefined' && isPaidUser) return; // nudge is for demo + free only
8838    localStorage.setItem('gfHelpSeen', '1');
8839    setTimeout(() => { try { openHelp(); } catch (e) {} }, 1500);
8840  } catch (e) {}
8841}
8842function buildDemoGuide() {
8843  if (document.getElementById('demoGuide')) return;
8844  const panel = document.createElement('div');
8845  panel.id = 'demoGuide';
8846  panel.style.cssText = 'position:fixed;right:16px;bottom:18px;width:300px;max-width:calc(100vw - 32px);z-index:150;background:rgba(17,24,39,0.97);border:1px solid rgba(59,130,246,0.5);border-radius:12px;box-shadow:0 10px 36px rgba(0,0,0,0.55);padding:14px 16px;backdrop-filter:blur(8px)';
8847  panel.innerHTML =
8848    '<button id="demoGuideClose" aria-label="Close" style="position:absolute;top:6px;right:10px;background:none;border:none;color:#94a3b8;font-size:20px;cursor:pointer;line-height:1">&times;</button>' +
8849    '<div id="demoGuideTitle" style="font-size:14px;font-weight:800;color:#fff;margin-bottom:6px;padding-right:18px"></div>' +
8850    '<div id="demoGuideBody" style="font-size:12.5px;color:#cbd5e1;line-height:1.55"></div>' +
8851    '<div style="margin-top:10px"><a href="' + UPGRADE_URL + '" style="display:inline-block;background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;text-decoration:none;font-weight:700;font-size:12px;padding:6px 12px;border-radius:6px">' + (IS_FREE ? 'Get Premium →' : 'Unlock all tickers →') + '</a></div>';
8852  document.body.appendChild(panel);
8853  const toggle = document.createElement('button');
8854  toggle.id = 'demoGuideToggle';
8855  toggle.textContent = 'ⓘ How it works';
8856  toggle.style.cssText = 'position:fixed;right:16px;bottom:18px;z-index:150;display:none;background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;border:none;border-radius:20px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;box-shadow:0 6px 20px rgba(59,130,246,0.4)';
8857  document.body.appendChild(toggle);
8858  const setOpen = (open) => {
8859    panel.style.display = open ? 'block' : 'none';
8860    toggle.style.display = open ? 'none' : 'block';
8861  };
8862  document.getElementById('demoGuideClose').onclick = () => setOpen(false);
8863  toggle.onclick = () => setOpen(true);
8864  // Auto-open ONCE for a first-time visitor; on later visits start collapsed (pill only).
8865  let seen = false; try { seen = localStorage.getItem('demoGuideSeen') === '1'; } catch (e) {}
8866  setOpen(!seen);
8867  try { localStorage.setItem('demoGuideSeen', '1'); } catch (e) {}
8868}
8869function updateDemoGuide(view) {
8870  if (!IS_DEMO) return;
8871  try { if (!DEMO_NOTES.smart) { DEMO_NOTES.smart = { t: 'Smart Money', b: 'Politician trades as they are filed, insider buying, 13F changes from the funds we track and the FDA calendar. Premium members see the live feeds; the demo shows sample rows.' }; DEMO_NOTES.ideas = { t: 'Trade Ideas', b: 'Scored setups every morning with the exact contract, a next-session 
8871watchlist and earnings plays, each tracked from entry to exit. Premium members see the live lists and the full record.' }; } } catch (e) {}
8872  const n = DEMO_NOTES[view] || DEMO_NOTES.gex;
8873  const tEl = document.getElementById('demoGuideTitle'), bEl = document.getElementById('demoGuideBody');
8874  if (!tEl || !bEl) return;
8875  tEl.textContent = n.t;
8876  bEl.innerHTML = '<div style="color:#94a3b8;margin-bottom:8px">A <b style="color:#fff">live demo</b> of GhostFlow — SPY &amp; TSLA only, 15-min delayed.</div>' + n.b;
8877}
8878
8879// Defer to the next tick so all later const/let (activeGroup, columnData, …) are
8880// initialized before we render — otherwise the demo init crashes the page.
8881if (IS_DEMO) setTimeout(function() {
8882  try { setupDemoMode(); buildTabs(); renderColumns(); activeColumns.forEach(t => loadGEX(t)); buildDemoGuide(); updateDemoGuide(currentView || 'gex'); }
8883  catch (e) { console.error('demo init', e); }
8884}, 0);
8885
8886// Delete a ticker from EVERY view (per-user) — right-click any ticker, or the GEX
8887// column ×. Persists to the user's hidden list and re-syncs all views.
8888// The old PROTECTED_TICKERS hard-lock (SPX/SPXW/NDX unremovable) is GONE (user 2026-09-01:
8889// member couldn't delete NDX from his grid). Every ticker deletes now; the ONE confirm below
8890// guards every path (column ×, right-click, node list, scanner) — per-account and reversible,
8891// any ticker comes back via search.
8892async function deleteTickerEverywhere(ticker) {
8893  if (IS_DEMO || !ticker) return;
8894  ticker = ticker.toUpperCase();
8895  if (!confirm('Delete ' + ticker + ' from all views? (only for your account)')) return;
8896  hiddenTickers.add(ticker);
8897  try {
8898    await fetch('/api/custom-tickers/remove', {
8899      method: 'POST', headers: { 'Content-Type': 'application/json' },
8900      body: JSON.stringify({ ticker })
8901    });
8902  } catch {}
8903  applyHiddenTickers();
8904  const fallback = () => (WATCHLIST[activeGroup] || []).find(Boolean) || Object.values(WATCHLIST).flat()[0] || null;
8905  if (typeof expiryTicker !== 'undefined' && expiryTicker === ticker) expiryTicker = fallback();
8906  if (typeof chartTicker !== 'undefined' && chartTicker === ticker) chartTicker = fallback();
8907  if (typeof ultraTicker !== 'undefined' && ultraTicker === ticker) ultraTicker = fallback();
8908  buildTabs();
8909  renderColumns();
8910  reloadSlots();   // slot-aware: custom columns load with their own key/basis/metric
8911  if (gfLive('expiry')) { buildExpiryTickers(); if (expiryTicker) loadExpiryGEX(expiryTicker); }
8912  if (currentView === 'chart') { buildChartTickers(); if (chartTicker) loadGEXChart(chartTicker); }
8913  if (currentView === 'ultra') { buildUltraTickers(); if (ultraTicker) loadUltraView(ultraTicker); }
8914  if (currentView === 'flow') {
8915    if (typeof flowTickerFilter !== 'undefined' && flowTickerFilter === ticker) { flowTickerFilter = null; if (typeof _renderFlowTickerChip === 'function') _renderFlowTickerChip(); }
8916    if (typeof _flowScopeSig !== 'undefined') _flowScopeSig = '';   // force scope rebuild (ticker gone → its rows drop)
8917    if (typeof renderFlowTickers === 'function') renderFlowTickers();
8918    if (typeof renderFlowTape === 'function') renderFlowTape();
8919  }
8920}
8921
8922// Right-click any element tagged with data-ticker → delete it from all views.
8923document.addEventListener('contextmenu', function(e) {
8924  const el = e.target.closest('[data-ticker]');
8925  if (!el) return;
8926  const t = el.getAttribute('data-ticker');
8927  if (!t) return;
8928  e.preventDefault();
8929  deleteTickerEverywhere(t);   // it confirms internally (single confirm for every delete path)
8930});
8931
8932// ── Background pre-loader: fetch ALL cached GEX data in one request ──
8933async function preloadAllTickers() {
8934  await new Promise(r => setTimeout(r, 3000));
8935  
8936  try {
8937    console.log('[Preload] Fetching all cached tickers...');
8938    const res = await fetch('/api/gex/all');
8939    const data = await res.json();
8940    
8941    for (const [ticker, info] of Object.entries(data.tickers || {})) {
8942      if (columnData[ticker]) continue;
8943      
8944      try {
8945        let strikes = info.gex?.data || info.gex || [];
8946        const spotArr = info.spot?.data || info.spot || [];
8947        if (!Array.isArray(strikes)) continue;
8948        
8949        // Get spot price
8950        let spot = 0;
8951        if (Array.isArray(spotArr)) {
8952          for (const s of spotArr) {
8953            const p = parseFloat(s.price || s.underlying_price || 0);
8954            if (p > 0) { spot = p; break; }
8955          }
8956        }
8957        
8958        // Parse strikes — same as loadGEX
8959        const parsed = strikes.map(s => {
8960          const strike = parseFloat(s.strike || 0);
8961          const callGex = parseFloat(s.call_gex || s.call_gamma_oi || 0);
8962          const putGex = parseFloat(s.put_gex || s.put_gamma_oi || 0);
8963          const mult = spot > 0 ? spot * spot * 0.01 : 1;
8964          return { strike, netGex: (callGex + putGex) * mult, callGex: callGex * mult, putGex: putGex * mult };
8965        }).filter(s => s.strike > 0 && Math.abs(s.netGex) > 0);
8966        
8967        if (parsed.length === 0) continue;
8968        
8969        if (!spot && parsed.length > 0) {
8970          spot = [...parsed].sort((a, b) => Math.abs(b.netGex) - Math.abs(a.netGex))[0].strike;
8971        }
8972        
8973        // Filter 30 above/below spot
8974        let filtered = parsed;
8975        if (spot > 0) {
8976          const above = parsed.filter(s => s.strike >= spot).sort((a, b) => a.strike - b.strike).slice(0, 30);
8977          const below = parsed.filter(s => s.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
8978          filtered = [...above, ...below];
8979        }
8980        
8981        // Remove noise
8982        if (filtered.length > 0) {
8983          const maxGex = Math.max(...filtered.map(s => Math.abs(s.netGex)));
8984          filtered = filtered.filter(s => Math.abs(s.netGex) > maxGex * 0.001);
8985        }
8986        
8987        filtered.sort((a, b) => b.strike - a.strike);
8988
8989        const { callWall, putWall, kingNode, totalGex } = pickLevels(filtered, spot);
8990
8991        columnData[ticker] = { strikes: filtered, spot, callWall, putWall, kingNode, totalGex, basis: 'all' };
8992      } catch (e) {}
8993    }
8994    console.log('[Preload] Done — ' + Object.keys(columnData).length + ' tickers ready');
8995  } catch (e) {
8996    console.log('[Preload] Error:', e.message);
8997  }
8998  
8999  setTimeout(preloadAllTickers, 60000);
9000}
9001preloadAllTickers();
9002
9003let activeGroup = "Index";
9004let activeColumns = IS_DEMO ? ["SPY", "TSLA", "BTC", "NVDA", "AAPL", "MSFT", "META", "AMD", "QQQ", "AMZN", "GOOG"] : ["SPXW", "NDX", "SPY", "QQQ", "IWM"]; // start with up to 6
9005
9006// When switching tabs, load up to 6 tickers
9007function switchGroup(group) {
9008  const currentView = gfActiveView();   // workspace: the focused tile decides
9009  activeGroup = group;
9010  try { localStorage.setItem('gf_active_group', group); } catch (e) {}
9011  const tickers = WATCHLIST[group] || [];
9012  activeColumns = [...tickers];
9013  // Restore this sector's saved custom line-up (admin test - duplicates allowed).
9014  if (typeof isPaidUser !== 'undefined' && isPaidUser && _colSets[group] && _colSets[group].length) activeColumns = _colSets[group].slice();
9015  buildTabs();
9016  buildGexTickers();
9017
9018  if (currentView === 'gex') {
9019    renderColumns();
9020    // Reset scroll flag for new sector
9021    activeColumns.forEach(t => { if (columnData[t]) columnData[t]._scrolled = false; });
9022    activeColumns.forEach((t, _ri) => { const _rk = _slotKey(_ri); if (columnData[_rk]) renderColumn(_rk); });
9023    // Only fetch tickers not already cached
9024    const uncached = activeColumns.filter(t => !columnData[t]);
9025    uncached.forEach((t, i) => {
9026      setTimeout(() => loadGEX(t), i * 150);
9027    });
9028    // Refresh cached ones in background (slower, non-blocking)
9029    const cached = activeColumns.filter(t => columnData[t]);
9030    cached.forEach((t, i) => {
9031      setTimeout(() => loadGEX(t), (uncached.length + i) * 2000);
9032    });
9033    // Custom slots (per-column metric/expiry or duplicate tickers) are invisible to the
9034    // ticker-keyed lists above - load them on their own keys right away.
9035    activeColumns.forEach((t, _ci) => { if (_slotCustom(_ci)) setTimeout(() => loadGEXSlot(_ci), 300 + _ci * 150); });
9036  } else if (currentView === 'expiry') {
9037    expiryTicker = IS_DEMO ? defaultViewTicker() : (tickers[0] || null);
9038    buildExpiryTickers();
9039    if (expiryTicker) loadExpiryGEX(expiryTicker);
9040  } else if (currentView === 'chart') {
9041    chartTicker = IS_DEMO ? defaultViewTicker() : (tickers[0] || null);
9042    buildChartTickers();
9043    if (chartTicker) loadGEXChart(chartTicker);
9044  } else if (currentView === 'ultra') {
9045    ultraTicker = IS_DEMO ? defaultViewTicker() : (tickers[0] || null);
9046    buildUltraTickers();
9047    if (ultraTicker) loadUltraView(ultraTicker);
9048  }
9049}
9050// Switch to a sector with one ticker focused: it loads first and shows its GEX,
9051// while every other column in the sector shows "Loading…" then loads in.
9052// WHICH ticker is this board studying? The one that appears in more than one column - those columns
9053// are one name read several ways (GEX ALL beside 0DTE beside CHARM) and they move together. A board
9054// of all-different names is a watchlist and has no study, so nothing moves. Ties go to the name that
9055// appears first, so the answer never depends on object key order.
9056function _gridStudyTicker(list){
9057  if(!list || list.length < 2) return null;
9058  const n = {}; let best = null, bestN = 0;
9059  for(let i=0;i<list.length;i++){
9060    const t = String(list[i]||'').toUpperCase(); if(!t) continue;
9061    n[t] = (n[t]||0) + 1;
9062    if(n[t] > bestN){ bestN = n[t]; best = t; }
9063  }
9064  return bestN >= 2 ? best : null;
9065}
9066// The board with that study pointed at a new name. The study's columns keep their slots - and so
9067// their metric and expiry - and everything after them becomes the new ticker's own sector, in order,
9068// because those columns only ever meant "and the rest of this list".
9069//   rest: the sector to fill the non-study columns from. Omitted (the workspace path, which does not
9070//   change sector) they keep the tickers they had.
9071function _gridRetarget(list, study, tk, rest){
9072  const fill = (rest || []).filter(function(t){ return String(t||'').toUpperCase() !== String(tk||'').toUpperCase(); });
9073  let fi = 0;
9074  return list.map(function(x){
9075    if(String(x||'').toUpperCase() === study) return tk;
9076    if(!rest) return x;
9077    return fill[fi++] || x;
9078  });
9079}
9080function switchGroupFocused(group, focusTicker) {
9081  // Read the board BEFORE anything is reassigned: which name is it studying?
9082  const _fromGroup = activeGroup;
9083  const _studyTk = focusTicker ? _gridStudyTicker(activeColumns) : null;
9084  const _studyCols = (_studyTk && _studyTk !== String(focusTicker||'').toUpperCase())
9085    ? _gridRetarget(activeColumns, _studyTk, focusTicker, (WATCHLIST[group] || [])) : null;
9086  activeGroup = group;
9087  try { localStorage.setItem('gf_active_group', group); } catch (e) {}
9088  let tickers = [...(WATCHLIST[group] || [])];
9089  if (focusTicker) tickers = [focusTicker, ...tickers.filter(t => t !== focusTicker)];
9090  activeColumns = tickers;
9091  const _saved = (typeof isPaidUser !== 'undefined' && isPaidUser && _colSets[group] && _colSets[group].length) ? _colSets[group] : null;
9092  // THE BOARD YOU ARE LOOKING AT WINS. Three columns of one name is a study of that name, so a new
9093  // ticker arrives as three columns of it - whatever sector either of them belongs to.
9094  if (_studyCols) {
9095    activeColumns = _studyCols;
9096    if (typeof isPaidUser !== 'undefined' && isPaidUser) {
9097      // colCfg is keyed by SECTOR and slot, so crossing sectors would leave every per-column basis
9098      // behind. The board keeps its shape, so carry the whole set of slots it uses.
9099      if (_fromGroup && _fromGroup !== group) {
9100        const _from = colCfg[_fromGroup] || {}, _to = (colCfg[group] = colCfg[group] || {});
9101        for (let _i = 0; _i < activeColumns.length; _i++) {
9102          if (_from[_i]) _to[_i] = Object.assign({}, _from[_i]); else delete _to[_i];
9103        }
9104        _saveColCfg();
9105      }
9106      _colSets[group] = activeColumns.slice(); _saveColSets();   // so a reload finds the board, not the sector
9107    }
9108  }
9109  // Otherwise the saved custom line-up wins IF it already contains the focused ticker - clicking a
9110  // ticker chip must always end with that ticker on screen.
9111  else if (_saved && (!focusTicker || _saved.includes(focusTicker))) activeColumns = _saved.slice();
9112  // ...and a line-up SAVED with a study in it is moved the same way, for a reload that lands
9113  // mid-sector with nothing useful on screen yet.
9114  else if (_saved && focusTicker && _gridStudyTicker(_saved)) {
9115    activeColumns = _gridRetarget(_saved, _gridStudyTicker(_saved), focusTicker, (WATCHLIST[group] || []));
9116    if (typeof isPaidUser !== 'undefined' && isPaidUser) { _colSets[group] = activeColumns.slice(); _saveColSets(); }
9117  }
9118  buildTabs();
9119  buildGexTickers();
9120  renderColumns(); // every column shows "Loading…"
9121  // Searched ticker loads first; the rest stay on "Loading…" until they load.
9122  if (focusTicker) loadGEX(focusTicker);
9123  activeColumns.forEach((t, _fi) => { if (t === focusTicker && !_slotCustom(_fi)) return; setTimeout(() => loadGEXSlot(_fi), (_fi + 1) * 400); });
9124  if (focusTicker && typeof gfSyncTo === 'function') gfSyncTo(focusTicker);
9125}
9126
9127const columnData = {}; // ticker -> { strikes, spot, callWall, putWall, kingNode, totalGex }
9128
9129function fmtGex(v) {
9130  if (v == null) return "—";
9131  const abs = Math.abs(v);
9132  const sign = v >= 0 ? "" : "-";
9133  if (abs >= 1e9) return sign + "$" + (abs / 1e9).toFixed(1) + "B";
9134  if (abs >= 1e6) return sign + "$" + (abs / 1e6).toFixed(1) + "M";
9135  if (abs >= 1e3) return sign + "$" + (abs / 1e3).toFixed(1) + "K";
9136  return sign + "$" + abs.toFixed(0);
9137}
9138
9139// ── Build tabs (static — only rebuild when the sector set changes) ──
9140function buildTabs() {
9141  const el = document.getElementById("tabs");
9142  const groups = Object.keys(WATCHLIST).filter(g => !(g === 'Custom' && WATCHLIST[g].length === 0));
9143  const existing = [...el.querySelectorAll('.tab')].map(b => b.dataset.group);
9144  const sameSet = existing.length === groups.length && existing.every((g, i) => g === groups[i]);
9145
9146  if (sameSet) {
9147    // Sectors unchanged — just move the active highlight (no flicker/reorder).
9148    for (const btn of el.querySelectorAll('.tab')) {
9149      btn.classList.toggle('active', btn.dataset.group === activeGroup);
9150    }
9151    return;
9152  }
9153
9154  el.innerHTML = "";
9155  for (const g of groups) {
9156    const btn = document.createElement("button");
9157    btn.className = "tab" + (g === activeGroup ? " active" : "") + (IS_DEMO ? " demo-tab-locked" : "");
9158    btn.dataset.group = g;
9159    btn.textContent = g;
9160    if (!IS_DEMO) btn.onclick = () => switchGroup(g);   // demo: sectors shown but not clickable (BTC is a fixed demo column instead)
9161    el.appendChild(btn);
9162  }
9163}
9164
9165// In demo, locked tickers show a blurred "locked" column instead of fetching.
9166function renderDemoLockedColumn(ticker) {
9167  const body = document.getElementById('col-body-' + ticker);
9168  if (body) {
9169    let rows = '';
9170    for (let i = 0; i < 16; i++) {
9171      const w = 18 + Math.round(Math.abs(Math.sin(i * 1.7 + ticker.length)) * 72);
9172      rows += '<div class="strike-row" style="pointer-events:none"><span class="strike-label" style="opacity:.4">•••</span><div class="gex-bar-container"><div class="gex-bar ' + (i % 2 ? 'negative' : 'positive') + '" style="width:' + w + '%;opacity:.4"></div></div></div>';
9173    }
9174    body.innerHTML = '<div class="demo-lock-wrap">' + rows +
9175      '<div class="demo-lock"><div style="font-size:24px">🔒</div><div style="font-weight:800;margin-top:2px">' + ticker + '</div>' +
9176      '<div style="font-size:10px;color:#94a3b8;margin:4px 0 8px">Pro unlocks this</div>' +
9177      '<a href="/register" class="demo-lock-btn">Unlock with Pro →</a></div></div>';
9178  }
9179  const king = document.getElementById('king-' + ticker);
9180  if (king) king.innerHTML = '<span class="king-dot" style="opacity:.4"></span><span class="king-label">🔒 King</span>';
9181  const price = document.getElementById('price-' + ticker);
9182  if (price) price.textContent = '🔒';
9183}
9184
9185// ── Fetch GEX ──
9186// ── GEX grid expiry basis (ALL / 0DTE / WEEKLY) ──────────────────────────────
9187// ALL    = greek exposure aggregated across every expiry (the classic grid).
9188// 0DTE   = the nearest listed expiry (today, on daily-expiry names).
9189// WEEKLY = the nearest Friday expiry (this week's weekly).
9190// Global: every column in every sector recomputes its King / Call Wall / Put Wall from
9191// the selected basis. loadGEX reads gridExpiryMode at fetch time.
9192let gridExpiryMode = 'all';
9193try { const _gm = localStorage.getItem('gf_grid_expiry'); if (['all', '0dte', 'weekly'].includes(_gm)) gridExpiryMode = _gm; } catch (e) {}
9194// When the GLOBAL toggles change, untouched columns (no explicit override) resync their
9195// per-column selects to display the new global value.
9196function _syncColCfgLabels() {
9197  try {
9198    document.querySelectorAll('select.col-cfg[data-si]').forEach(sel => {
9199      const si = parseInt(sel.dataset.si, 10); if (!isFinite(si)) return;
9200      const cfg = (typeof _slotCfg === 'function') ? _slotCfg(si) : {};
9201      if (sel.dataset.kind === 'metric') { if (!cfg.metric) sel.value = currentMetric || 'gex'; }
9202      else { if (!cfg.exp) sel.value = gridExpiryMode || 'all'; }
9203    });
9204  } catch (e) {}
9205}
9206function _syncGridExpiryButtons() {
9207  try { _syncColCfgLabels(); } catch (e) {}
9208  // Sync every ALL/0DTE/WEEKLY toggle on the page (GEX-view bar + chart/ghost header).
9209  // Scoped to [data-exp] so it never strips 'active' off the grid-v2 BARS/TINT buttons,
9210  // which share the .gexp-btn look but carry data-gbar/data-gtint instead.
9211  document.querySelectorAll('.gexp-btn[data-exp]').forEach(b =>
9211 b.classList.toggle('active', b.dataset.exp === gridExpiryMode));
9212}
9213
9214// ── GRID v2 (ADMIN TEST, 2026-08-27): bar style + row tint ──────────────────
9215// 5 bar styles (original + taper / glow / segments / track from the approved Desktop
9216// samples) and a strength-scaled row tint. Admin-only while testing: the settings bars
9217// stay display:none for members and the renderer falls through to the original look
9218// unless isAdminUser is set, so members see exactly today's grid.
9219let gfGridBars = 'original', gfGridTint = 'off';
9220try { const _gb = localStorage.getItem('gf_grid_bars'); if (['original', 'taper', 'glow', 'segments', 'track'].includes(_gb)) gfGridBars = _gb; } catch (e) {}
9221try { const _gt = localStorage.getItem('gf_grid_tint'); if (['off', 'soft', 'bold'].includes(_gt)) gfGridTint = _gt; } catch (e) {}
9222let gfHeatPct = false; try { gfHeatPct = localStorage.getItem('gf_heat_pct') === '1'; } catch (e) {}
9223function toggleHeatPct() {
9224  gfHeatPct = !gfHeatPct;
9225  try { localStorage.setItem('gf_heat_pct', gfHeatPct ? '1' : '0'); } catch (e) {}
9226  _syncGridStyleButtons();
9227  try { if (gfLive('expiry') && typeof expiryTicker !== 'undefined' && expiryTicker) loadExpiryGEX(expiryTicker, true); } catch (e) {}
9228}
9229// ── Heatmap VOLUME column (admin-only, 2026-08-31): call/put volume bars for the FRONT
9230// expiry, inserted between it and the next expiry column. Data = /api/contracts (server
9231// cache + live-volume overlay, SPX resolves to SPXW) aggregated per strike client-side.
9232// VOL mode (user 2026-09-02): the button cycles OFF -> front expiry -> all 4 -> OFF, so each user
9233// picks how many VOL columns to see. gfHeatVolN = 0 | 1 | 4. gfHeatVol (bool) kept as a live alias
9234// so the many `if (gfHeatVol)` gates keep working unchanged.
9235let gfHeatVolN = 0; try { const _v = localStorage.getItem('gf_heat_voln'); if (_v != null) gfHeatVolN = parseInt(_v) || 0; else if (localStorage.getItem('gf_heat_vol') === '1') gfHeatVolN = 1; } catch (e) {}
9236let gfHeatVol = gfHeatVolN > 0;
9237let _hvCache = { key: '', at: 0, data: null };
9238// VOL columns for the first N expiries (user 2026-09-02: was front-only). One /api/contracts
9239// fetch, aggregated per strike per expiry; each column carries its own bar-scale max.
9240const HV_EXPIRIES = 4;
9241async function _hvLoad(tk, exps) {
9242  const want = (Array.isArray(exps) ? exps : [exps]).filter(Boolean).slice(0, HV_EXPIRIES);
9243  if (!want.length) { window._hvData = null; return; }
9244  const key = tk + '|' + want.join(',');
9245  if (_hvCache.key === key && Date.now() - _hvCache.at < 120000) { window._hvData = _hvCache.data; return; }
9246  const j = await (await fetch('/api/contracts/' + tk)).json();
9247  const arr = (j && (j.data || j)) || [];
9248  const cols = want.map(exp => ({ exp, m: {}, max: 0 }));
9249  const byExp = {}; cols.forEach(c => byExp[c.exp] = c);
9250  for (const c of (Array.isArray(arr) ? arr : [])) {
9251    const oc = _occParts(c);
9252    if (!oc) continue;
9253    const col = byExp[oc.e]; if (!col) continue;
9254    const vol = parseInt(c.volume) || 0; if (!vol) continue;
9255    const strike = oc.strike;
9256    const r = col.m[strike] = col.m[strike] || { c: 0, p: 0 };
9257    if (oc.cp === 'C') r.c += vol; else r.p += vol;
9258    if (r.c > col.max) col.max = r.c; if (r.p > col.max) col.max = r.p;
9259  }
9260  cols.forEach(c => { if (!c.max) c.max = 1; });
9261  window._hvData = { tk, exps: want, cols };
9262  _hvCache = { key, at: Date.now(), data: window._hvData };
9263}
9264function _hvFmt(v) { return v >= 1e6 ? (v / 1e6).toFixed(1) + 'M' : v >= 1e3 ? (v / 1e3).toFixed(1) + 'K' : String(v); }
9265// Per-expiry lookups (user 2026-09-02: VOL sits right after each matching GEX column, not grouped).
9266function _hvColFor(d, exp) { return (d && d.cols || []).find(c => c.exp === exp) || null; }
9267function _hvThFor(d, exp) { const c = _hvColFor(d, exp); return c ? '<th style="color:#22d3ee;white-space:nowrap">VOL ' + c.exp.slice(5).replace('-', '/') + '</th>' : ''; }
9268function _hvCellFor(d, exp, strike) { const c = _hvColFor(d, exp); return c ? _hvCol(c, strike) : ''; }
9269function _hvCol(col, strike) {
9270  const base = '<td style="border-bottom:none;border-right:none;padding:0 6px;min-width:150px;box-shadow:inset 1px 0 0 rgba(30,41,59,.8)">';
9271  const r = col.m[strike];
9272  const mid = '<span style="position:absolute;
9272left:50%;top:-1px;bottom:-1px;width:1px;background:#334155"></span>';
9273  if (!r || (!r.c && !r.p)) return base + '<div style="position:relative;height:16px">' + mid + '</div></td>';
9274  const cw = r.c ? Math.max(2, Math.round(r.c / col.max * 54)) : 0;
9275  const pw = r.p ? Math.max(2, Math.round(r.p / col.max * 54)) : 0;
9276  return base + '<div style="position:relative;height:16px">'
9277    + (pw ? '<span style="position:absolute;right:50%;top:2px;height:12px;width:' + pw + 'px;background:#f87171;border-radius:2px 0 0 2px"></span>' : '')
9278    + mid
9279    + (cw ? '<span style="position:absolute;left:50%;top:2px;height:12px;width:' + cw + 'px;background:#34d399;border-radius:0 2px 2px 0"></span>' : '')
9280    + (pw ? '<span style="position:absolute;right:calc(50% + ' + (pw + 4) + 'px);top:0;line-height:16px;font-size:9.5px;color:#cbd5e1">' + _hvFmt(r.p) + '</span>' : '')
9281    + (cw ? '<span style="position:absolute;left:calc(50% + ' + (cw + 4) + 'px);top:0;line-height:16px;font-size:9.5px;color:#cbd5e1">' + _hvFmt(r.c) + '</span>' : '')
9282    + '</div></td>';
9283}
9284// ═══════════ GAMMA WAVE (ADMIN, user 2026-09-10) ═══════════
9285// "i want to make a wave which draw from current price to highest postive n negative according to
9286// sequence. if its red negative the curve become red until that negative gamma and then postive
9287// green if its gng to postiive" - the DOMINANT variant from the sample they picked.
9288//
9289// One point per expiry column: the strike carrying the biggest ABSOLUTE net gamma in that column.
9290// The path starts at spot and each segment takes the colour of the node it is ARRIVING at, so it
9291// runs red while price is being pulled into negative gamma and turns green the moment the structure
9292// ahead flips positive. On the NVDA board it was drawn against, that flip lands between 09/11 and
9293// 09/14 - the point where the map stops pressing down and starts supporting.
9294//
9295// A DEAD COLUMN BREAKS THE LINE. An expiry carrying under 4% of the board's biggest print is not
9296// structure, and drawing through it invents a level that is not there - which matters most on thin
9297// single stocks, where often only the front two expiries carry anything at all.
9298let gfHeatWave = false;
9299try { gfHeatWave = localStorage.getItem('gf_heat_wave') === '1'; } catch (e) {}
9300// Gamma Wave / TERM: premium (admin, or any paid account) since 2026-09-14; admin-only before.
9301function _hwAllowed() { return (typeof isAdminUser !== 'undefined' && isAdminUser) || (typeof isPaidUser !== 'undefined' && isPaidUser); }
9302function toggleHeatWave() {
9303  if (!_hwAllowed()) return;
9304  gfHeatWave = !gfHeatWave;
9305  try { localStorage.setItem('gf_heat_wave', gfHeatWave ? '1' : '0'); } catch (e) {}
9306  _syncHeatWaveBtn();
9307  // ALWAYS REDRAW, never hand-remove (user 2026-09-11: "when both waves are on and i turn off wave 1
9308  // it also remove bucket as well"). Both waves share ONE overlay element, so tearing it out here
9309  // took the other one with it - and nothing redrew it until the next table render. _hwDraw already
9310  // removes the old overlay first and returns early when neither toggle is on, so calling it
9311  // unconditionally is both the redraw and the clear.
9312  try { _hwDraw(); } catch (e) {}
9313}
9314// ── BUCKETED WAVE (user 2026-09-11) ────────────────────────────────────────────────────────────
9315// "that wave is cmonig per column why its not comng for actual high negative n positive data. can we
9316//  add another toggle to see overall direction on heatmap instead of each expiry"
9317//
9318// The per-expiry wave takes the strongest strike of EACH column, so a Wednesday carrying 1M gets the
9319// same say as a monthly carrying 582M. On the TSLA board it was drawn against, that produced
9320// 370, 370, 370, 350, 380, 380, 385, 380, 400 - the tail set entirely by columns of 1M or less.
9321//
9322// This one sums the expiries into three buckets FIRST and then takes each bucket's strongest strike,
9323// so a thin column can no longer own a point: it is averaged in with its neighbours instead. Same
9324// TSLA board reads 370 across all three buckets - the wall that is actually there.
9325//
9326// Separate toggle, not a replacement: both can be on, and the bucketed line is drawn wider, dimmer
9327// and BEHIND so it reads as the structure the sharper per-expiry line is moving through.
9328let gfHeatBucket = false;
9329try { gfHeatBucket = localStorage.getItem('gf_heat_bucket') === '1'; } catch (e) {}
9330function toggleHeatBucket() {
9331  if (!_hwAllowed()) return;
9332  gfHeatBucket = !gfHeatBucket;
9333  try { localStorage.setItem('gf_heat_bucket', gfHeatBucket ? '1' : '0'); } catch (e) {}
9334  _syncHeatWaveBtn();
9335  try { _hwDraw(); } catch (e) {}
9336}
9337function _syncHeatWaveBtn() {
9338  const admin = _hwAllowed();   // "admin" by history: premium since 2026-09-14
9339  const b = document.getElementById('heatWaveBtn');
9340  if (b) { b.style.display = admin ? '' : 'none'; b.classList.toggle('active', !!gfHeatWave); }
9341  const b2 = document.getElementById('heatBucketBtn');
9342  if (b2) { b2.style.display = admin ? '' : 'none'; b2.classList.toggle('active', !!gfHeatBucket); }
9343}
9344// Geometry comes off the rendered table, not off the loop that built it: the row carries interleaved
9345// VOL cells and the level/King rows use a different <td> template, so counting columns would be
9346// wrong in exactly the cases that matter. The expiry headers and the strike column are tagged
9347// (data-hx / data-hy) and the wave reads x from one and y from the other.
9348function _hwDraw() {
9349  try {
9350    const host = document.getElementById('expiryContent');
9351    const old = document.getElementById('hwOverlay'); if (old) old.remove();
9352    if ((!gfHeatWave && !gfHeatBucket) || !host) return;
9353    if (!_hwAllowed()) return;
9354    const d = window._hwData; if (!d || !d.expiries || !d.expiries.length) return;
9355    const tbl = host.querySelector('table'); if (!tbl) return;
9356    const hostBox = host.getBoundingClientRect();
9357    const ox = host.scrollLeft - hostBox.left, oy = host.scrollTop - hostBox.top;
9358    const xOf = e => { const th = tbl.querySelector('th[data-hx="' + e + '"]'); if (!th) return null; const r = th.getBoundingClientRect(); return r.left + r.width / 2 + ox; };
9359    const yOf = k => { const td = tbl.querySelector('td[data-hy="' + k + '"]'); if (!td) return null; const r = td.getBoundingClientRect(); return r.top + r.height / 2 + oy; };
9360    // the top-right corner of the (expiry, strike) cell, inset - where a node marker sits so it
9361    // never covers the value printed in the cell's centre
9362    const cornerOf = (e, k, inset) => { const th = tbl.querySelector('th[data-hx="' + e + '"]'), td = tbl.querySelector('td[data-hy="' + k + '"]'); if (!th || !td) return null;
9363      const a = th.getBoundingClientRect(), b = td.getBoundingClientRect(); return { x: a.right - inset + ox, y: b.top + inset + oy }; };
9364
9365    // JUDGE A COLUMN AGAINST THE TYPICAL COLUMN, NOT THE BIGGEST ONE (user 2026-09-10: "why this
9366    // wave is missing some parts like not continuoys"). The first cut measured every expiry against
9367    // the board's largest print, so one monthly set the bar for all the dailies around it. On QQQ the
9368    // 09/18 monthly at -582M put the bar at 23M and deleted 09/16 and 09/17 - both carrying -9M,
9369    // which genuinely IS that day's magnet - for being 1.5% of a number they have nothing to do with.
9370    // The median column was 34M, so measured against their peers those two are perfectly ordinary.
9371    //
9372    // Two bars now, and a thin column DIMS rather than disappearing (option 2 of the sample):
9373    //   >= 25% of the median column   solid - real structure
9374    //   >= 15%                        dashed and dimmed - there, but do not lean on it
9375    //   below that                    the line breaks, as it always did
9376    //
9377    // The board term is a floor, not the yardstick: on a board where most expiries are dead the
9378    // median itself collapses toward zero and a pure-median bar would start drawing noise. Keeping
9379    // max(median share, small board share) means the rule cannot fall below something sane for the
9380    // instrument's own scale. On QQQ the two agree almost exactly (8.5M vs 8.7M), which is what the
9381    // sample was approved on; the floor only ever bites on a degenerate board.
9382    let board = 0;
9383    for (const k of d.strikes) for (const e of d.expiries) { const v = Math.abs((d.map[k] || {})[e] || 0); if (v > board) board = v; }
9384    if (!(board > 0)) return;
9385    const _best = d.expiries.map(e => {
9386      let best = null;
9387      for (const k of d.strikes) { const v = (d.map[k] || {})[e]; if (v == null) continue; if (!best || Math.abs(v) > Math.abs(best.v)) best = { k, v, e }; }
9388      return best;
9389    });
9390    const _colMax = _best.map(b => b ? Math.abs(b.v) : 0);
9391    const _srt = _colMax.slice().sort((a, b) => a - b);
9392    const _med = _srt.length ? (_srt.length % 2 ? _srt[(_srt.length - 1) / 2] : (_srt[_srt.length / 2 - 1] + _srt[_srt.length / 2]) / 2) : 0;
9393    const _cutSolid = Math.max(_med * 0.25, board * 0.015);
9394    const _cutThin = Math.max(_med * 0.15, board * 0.008);
9395    const nodes = _best.map((b, i) => {
9396      if (!b) return null;
9397      const m = _colMax[i];
9398      if (m >= _cutSolid) return b;
9399      if (m >= _cutThin) { b.dash = true; return b; }
9400      return null;
9401    });
9402
9403    // spot sits between two strikes: interpolate rather than snapping to a row
9404    const asc = d.strikes.slice().sort((a, b) => a - b);
9405    let spotY = null;
9406    for (let i = 0; i < asc.length - 1; i++) {
9407      if (d.spot >= asc[i] && d.spot <= asc[i + 1]) {
9408        const y1 = yOf(asc[i]), y2 = yOf(asc[i + 1]);
9409        if (y1 != null && y2 != null) { const f = (d.spot - asc[i]) / (asc[i + 1] - asc[i]); spotY = y1 + (y2 - y1) * f; }
9410        break;
9411      }
9412    }
9413    if (spotY == null) { const nk = asc.reduce((a, b) => Math.abs(b - d.spot) < Math.abs(a - d.spot) ? b : a, asc[0]); spotY = yOf(nk); }
9414    if (spotY == null) return;
9415
9416    const NS = 'http://www.w3.org/2000/svg';
9417    const svg = document.createElementNS(NS, 'svg');
9418    svg.id = 'hwOverlay';
9419    svg.setAttribute('width', tbl.scrollWidth); svg.setAttribute('height', tbl.scrollHeight);
9420    svg.style.cssText = 'position:absolute;
9420left:0;top:0;pointer-events:none;z-index:1;overflow:visible';   // under the value spans (z 2), over the wash and meters
9421    const add = (t, a) => { const el = document.createElementNS(NS, t); for (const q in a) el.setAttribute(q, a[q]); svg.appendChild(el); return el; };
9422    const f = add('filter', { id: 'hwGlow', x: '-50%', y: '-50%', width: '200%', height: '200%' });
9423    const fb = document.createElementNS(NS, 'feGaussianBlur'); fb.setAttribute('stdDeviation', '3'); fb.setAttribute('result', 'b'); f.appendChild(fb);
9424    const fm = document.createElementNS(NS, 'feMerge');
9425    ['b', 'SourceGraphic'].forEach(n => { const m = document.createElementNS(NS, 'feMergeNode'); m.setAttribute('in', n); fm.appendChild(m); });
9426    f.appendChild(fm);
9427
9428    const seg = (p, i) => {
9429      const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2;
9430      const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
9431      const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
9432      return 'M ' + p1.x.toFixed(1) + ' ' + p1.y.toFixed(1) + ' C ' + c1x.toFixed(1) + ' ' + c1y.toFixed(1) + ', ' + c2x.toFixed(1) + ' ' + c2y.toFixed(1) + ', ' + p2.x.toFixed(1) + ' ' + p2.y.toFixed(1);
9433    };
9434
9435    // ── BUCKETED WAVE ────────────────────────────────────────────────────────────────────────
9436    // Drawn FIRST so the sharper per-expiry line sits on top of it when both are on.
9437    // Buckets are calendar-based, matching how the expiries are actually read: everything up to
9438    // the coming Friday, then the following week, then the rest. A bucket with no expiries on this
9439    // board is skipped rather than drawn empty.
9440    if (gfHeatBucket) {
9441      const _fri = (function () {
9442        const t = new Date(); const g = t.getDay();          // 0 Sun .. 6 Sat
9443        const add = (5 - g + 7) % 7;                          // days to the coming Friday (0 if today is Friday)
9444        const f = new Date(t.getTime() + add * 86400000);
9445        return f.getFullYear() + '-' + String(f.getMonth() + 1).padStart(2, '0') + '-' + String(f.getDate()).padStart(2, '0');
9446      })();
9447      const _nextFri = (function () {
9448        const p = _fri.split('-').map(Number); const f = new Date(p[0], p[1] - 1, p[2] + 7);
9449        return f.getFullYear() + '-' + String(f.getMonth() + 1).padStart(2, '0') + '-' + String(f.getDate()).padStart(2, '0');
9450      })();
9451      const BK = [
9452        { n: 'this week', has: e => e <= _fri },
9453        { n: 'next week', has: e => e > _fri && e <= _nextFri },
9454        { n: 'later', has: e => e > _nextFri },
9455      ];
9456      const bpts = [];
9457      for (const bk of BK) {
9458        const cols = d.expiries.filter(bk.has);
9459        if (!cols.length) continue;
9460        // sum each strike ACROSS the bucket's expiries first, then take the strongest
9461        let best = null;
9462        for (const k of d.strikes) {
9463          let sum = 0, any = false;
9464          for (const e of cols) { const v = (d.map[k] || {})[e]; if (v != null) { sum += v; any = true; } }
9465          if (!any) continue;
9466          if (!best || Math.abs(sum) > Math.abs(best.v)) best = { k, v: sum };
9467        }
9468        if (!best || !(Math.abs(best.v) >
9468 0)) continue;
9469        // x = the middle of the bucket's columns
9470        let xs = cols.map(xOf).filter(v => v != null);
9471        const y = yOf(best.k);
9472        if (!xs.length || y == null) continue;
9473        const mx = xs.reduce((a, b2) => a + b2, 0) / xs.length;
9474        // the column whose centre is nearest the bucket's middle hosts the marker
9475        let ce = cols[0], cd = Infinity; cols.forEach(e => { const cx = xOf(e); if (cx != null && Math.abs(cx - mx) < cd) { cd = Math.abs(cx - mx); ce = e; } });
9476        bpts.push({ x: mx, y, v: best.v, lbl: bk.n, e: ce, k: best.k });
9477      }
9478      if (bpts.length) {
9479        const fx = xOf(d.expiries[0]);
9480        if (fx != null) bpts.unshift({ x: Math.max(0, fx - 62), y: spotY, v: null, anchor: true });
9481        for (let i = 0; i < bpts.length - 1; i++) {
9482          const dest = bpts[i + 1];
9483          add('path', { d: seg(bpts, i), fill: 'none', stroke: dest.v > 0 ? '#22c55e' : '#ef4444', 'stroke-width': 7, 'stroke-linecap': 'round', opacity: 0.30, filter: 'url(#hwGlow)' });
9484        }
9485        bpts.forEach(p => {
9486          if (p.anchor) return;                               // the NOW dot belongs to the per-expiry wave
9487          // diamond, not a circle - tells the two waves apart at a glance when both are on. In the
9488          // cell's top-right corner like the wave's dots, and without the bucket text: the overlay
9489          // now paints under the values, so a label under the diamond would sit beneath the next
9490          // row's numbers. The buckets read left to right - this week, next week, later (Help).
9491          const c = cornerOf(p.e, p.k, 7) || p;
9492          add('path', { d: 'M ' + c.x + ' ' + (c.y - 5) + ' L ' + (c.x + 5) + ' ' + c.y + ' L ' + c.x + ' ' + (c.y + 5) + ' L ' + (c.x - 5) + ' ' + c.y + ' Z',
9493            fill: p.v > 0 ? '#22c55e' : '#ef4444', opacity: 0.7, stroke: '#0a0e17', 'stroke-width': 1.5 });
9494        });
9495      }
9496    }
9497    if (!gfHeatWave) { host.style.position = host.style.position || 'relative'; host.appendChild(svg); return; }
9498
9499    // contiguous runs only - a null column ends the run instead of being bridged
9500    const firstX = xOf(d.expiries[0]);
9501    const runs = []; let cur = [];
9502    nodes.forEach((n, i) => { if (n) { const x = xOf(n.e), y = yOf(n.k); if (x != null && y != null) cur.push({ x, y, v: n.v, dash: !!n.dash, first: i === 0, e: n.e, k: n.k }); } else { if (cur.length) runs.push(cur); cur = []; } });
9503    if (cur.length) runs.push(cur);
9504    if (!runs.length) return;
9505    // the wave leaves spot only if the FIRST expiry is real; otherwise it starts at its first live node
9506    if (runs[0][0].first && firstX != null) runs[0].unshift({ x: Math.max(0, firstX - 62), y: spotY, v: null, anchor: true });
9507
9508    for (const pts of runs) {
9509      if (pts.length < 2) { const p = pts[0]; if (p && !p.anchor) { const c = cornerOf(p.e, p.k, 6) || p; add('circle', { cx: c.x, cy: c.y, r: 3.5, fill: p.v > 0 ? '#22c55e' : '#ef4444', stroke: '#0a0e17', 'stroke-width': 1.5 }); } continue; }
9510      for (let i = 0; i < pts.length - 1; i++) {
9511        // COLOUR IS THE DESTINATION'S, not the origin's - the segment says where price is being
9512        // pulled TO, which is the whole point of reading it.
9513        // The SEGMENT wears the destination's confidence as well as its colour: running into a thin
9514        // column is drawn dashed and dimmed, so the wave stays continuous without claiming the same
9515        // weight everywhere.
9516        const dest = pts[i + 1];
9517        add('path', { d: seg(pts, i), fill: 'none', stroke: dest.v > 0 ? '#22c55e' : '#ef4444', 'stroke-width': dest.dash ? 2.2 : 3.4, 'stroke-linecap': 'round', opacity: dest.dash ? 0.55 : 0.95, 'stroke-dasharray': dest.dash ? '7 5' : '', filter: 'url(#hwGlow)' });
9518      }
9519      pts.forEach(p => {
9520        if (p.anchor) { add('circle', { cx: p.x, cy: p.y, r: 4.5, fill: '#ffffff' }); add('text', { x: p.x, y: p.y - 9, fill: '#ffffff', 'font-size': 10, 'font-weight': 800, 'text-anchor': 'middle', 'font-family': 'Segoe UI' }).textContent = 'NOW'; return; }
9521        // NO VALUE LABEL (user 2026-09-10: "wave also showing that strike gamma on it. we dont need
9522        // that ... coz strike gamma is alrdy there"). Every node sits ON its own cell and that cell
9523        // already prints the number, so the label was the same figure twice - and being the larger,
9524        // outlined one it read as the wave's own reading rather than the cell's. The dot marks which
9525        // strike the wave picked; the cell says how much.
9526        // IN THE CORNER, not on the value (user 2026-09-14): the line still lands on the cell's centre,
9527        // the dot marks the cell from its top-right corner.
9528        const c = cornerOf(p.e, p.k, 6) || p;
9529        add('circle', { cx: c.x, cy: c.y, r: p.dash ? 2.8 : 3.5, fill: p.v > 0 ? '#22c55e' : '#ef4444', stroke: '#0a0e17', 'stroke-width': 1.5, opacity: p.dash ? 0.7 : 1 });
9530      });
9531    }
9532    host.style.position = host.style.position || 'relative';
9533    host.appendChild(svg);
9534  }
9534 catch (e) { try { console.warn('[GammaWave]', e); } catch (x) {} }
9535}
9536// Cycle OFF -> front (1) -> all 4 -> OFF. Label shows the mode; premium-gated.
9537function toggleHeatVol() {
9538  if (typeof isPaidUser === 'undefined' || !isPaidUser) return;
9539  gfHeatVolN = gfHeatVolN === 0 ? 1 : (gfHeatVolN === 1 ? 4 : 0);
9540  gfHeatVol = gfHeatVolN > 0;
9541  try { localStorage.setItem('gf_heat_voln', String(gfHeatVolN)); } catch (e) {}
9542  _syncHeatVolBtn();
9543  try { if (typeof gfWsPaintVolBtn === 'function' && _wsOn) gfWsPaintVolBtn(); } catch (e) {}
9544  try { if (typeof _wsDdPaint === 'function' && _wsOn) _wsDdPaint(); } catch (e) {}   // HEATMAP label follows
9545  try { if (gfLive('expiry') && typeof expiryTicker !== 'undefined' && expiryTicker) loadExpiryGEX(expiryTicker, true); } catch (e) {}
9546}
9547// Button label + active state for the current VOL mode.
9548function _syncHeatVolBtn() {
9549  document.querySelectorAll('.gexp-btn[data-hvol]').forEach(b => {
9550    b.style.display = (typeof isPaidUser !== 'undefined' && isPaidUser) ? '' : 'none';
9551    b.classList.toggle('active', gfHeatVolN > 0);
9552    b.textContent = gfHeatVolN === 0 ? 'VOL' : gfHeatVolN === 1 ? 'VOL 1' : 'VOL 4';
9553    b.title = gfHeatVolN === 0 ? 'Click: show call/put volume at each strike (front expiry)'
9554      : gfHeatVolN === 1 ? 'Front-expiry volume shown. Click for all 4 expiries.'
9555      : 'Volume for the first 4 expiries shown. Click to turn off.';
9556  });
9557}
9558function _gv2On() { return typeof isPaidUser !== 'undefined' && isPaidUser && (gfGridBars !== 'original' || gfGridTint !== 'off'); }
9559function _syncGridStyleButtons() {
9560  document.querySelectorAll('.gexp-btn[data-gbar]').forEach(b => b.classList.toggle('active', b.dataset.gbar === gfGridBars));
9561  document.querySelectorAll('.gexp-btn[data-gtint]').forEach(b => b.classList.toggle('active', b.dataset.gtint === gfGridTint));
9562  document.querySelectorAll('.gexp-btn[data-hpct]').forEach(b => b.classList.toggle('active', !!gfHeatPct));
9563  _syncHeatVolBtn();
9564}
9565// Restyle live from cached data - no refetch, renderColumn reads columnData.
9566// On the heatmap the cells re-render through loadExpiryGEX (silent - no spinner);
9567// the fetch is served from the server-side cache so it's cheap.
9568function _gv2Rerender() {
9569  // the Ghost Mode ladder restyles from its cached data too (gear popover, user 2026-09-07)
9570  // Hold the ladder's scroll across the restyle (user 2026-09-07: strikes jumped down then back up on every gear click):
9571  // renderChartGexPanel only restores scrollTop while cgpUserScrolled is set, and alignChartPanelToPrice re-centres otherwise.
9572  try { if (window._cgpLast && typeof renderChartGexPanel === 'function') { const _was = cgpUserScrolled; cgpUserScrolled = true; renderChartGexPanel(window._cgpLast.content, window._cgpLast.parsed, window._cgpLast.spot, window._cgpLast.ticker, window._cgpLast.volNode); setTimeout(function () { cgpUserScrolled = _was; }, 500); } } catch (e) {}
9573  try { activeColumns.forEach((t, _gi) => { const _gk = _slotKey(_gi); if (columnData[_gk]) renderColumn(_gk); }); } catch (e) {}
9574  try { if (gfLive('expiry') && typeof expiryTicker !== 'undefined' && expiryTicker) loadExpiryGEX(expiryTicker, true); } catch (e) {}
9575}
9576// Watchdog: several code paths rebuild the ticker strips (drag re-orders, view switches,
9577// sector loads) and any of them wipes the fold handle. Cheap to just re-wire every 2.5s.
9578setInterval(function () {
9579  if (typeof isPaidUser === 'undefined' || !isPaidUser) return;
9580  try { gfWireStripFold('gexTickers'); gfWireStripFold('expiryTickers'); gfWireStripFold('chartTickers'); gfWireStripFold('chainTickers'); } catch (e) {}
9581}, 2500);
9582// Ladder bar-style gear popover (user 2026-09-07). Same setters as the Grid style bar; _syncGridStyleButtons lights the
9583// active choice because the buttons carry the same data-gbar / data-gtint attributes.
9584function gfLadderGear(el){
9585  try{
9586    let p=document.getElementById('cgpGearPop');
9587    if(!p){
9588      p=document.createElement('div'); p.id='cgpGearPop';
9589      const seg=(attr,vals)=>vals.map(v=>'<button class="gexp-btn" data-'+attr+'="'+v[0]+'" onclick="'+v[2]+'">'+v[1]+'</button>').join('');
9590      p.innerHTML='<div class="t">LADDER BARS</div>'
9591        +'<div class="r"><span class="n">Bars</span>'+seg('gbar',[['original','Original',"setGridBars('original')"],['taper','Taper',"setGridBars('taper')"],['glow','Glow',"
9591setGridBars('glow')"],['segments','Segments',"setGridBars('segments')"]])+'</div>'
9592        +'<div class="r"><span class="n">Side</span>'+seg('gbar',[['track','Track',"setGridBars('track')"],['split','Split',"setGridBars('split')"]])+'</div>'
9593        +'<div class="r"><span class="n">Tint</span>'+seg('gtint',[['off','Off',"setGridTint('off')"],['soft','Soft',"setGridTint('soft')"],['bold','Bold',"setGridTint('bold')"]])+'</div>'
9594        +'<div class="r"><span class="rs" onclick="setGridBars(\'original\');setGridTint(\'off\')">Reset to defaults</span></div>';
9595      document.body.appendChild(p);
9596      p.addEventListener('mousedown',e=>e.stopPropagation());
9597      document.addEventListener('mousedown',function(e){ if(p.style.display!=='none' && !p.contains(e.target) && !(e.target.closest && e.target.closest('.cgp-gear'))){ p.style.display='none'; document.querySelectorAll('.cgp-gear.on').forEach(g=>g.classList.remove('on')); } },true);
9598    }
9599    if(p.style.display==='block' && p._anchor===el){ p.style.display='none'; el.classList.remove('on'); return; }
9600    p._anchor=el; p.style.display='block'; _syncGridStyleButtons(); el.classList.add('on');
9601    const r=el.getBoundingClientRect(); let left=r.right+8, top=r.top-4;
9602    if(left+p.offsetWidth>window.innerWidth-8) left=r.left-p.offsetWidth-8;
9603    if(top+p.offsetHeight>window.innerHeight-8) top=window.innerHeight-p.offsetHeight-8;
9604    p.style.left=Math.max(4,left)+'px'; p.style.top=Math.max(4,top)+'px';
9605  }catch(e){}
9606}
9607function setGridBars(v) { if (!['original', 'taper', 'glow', 'segments', 'track', 'split'].includes(v)) return; gfGridBars = v; try { localStorage.setItem('gf_grid_bars', v); } catch (e) {} _syncGridStyleButtons(); _gv2Rerender(); try { if (_vsPerView()) _vsBank(_vsKey(currentView)); } catch (e) {} }
9608function setGridTint(v) { if (!['off', 'soft', 'bold'].includes(v)) return; gfGridTint = v; try { localStorage.setItem('gf_grid_tint', v); } catch (e) {} _syncGridStyleButtons(); _gv2Rerender(); try { if (_vsPerView()) _vsBank(_vsKey(currentView)); } catch (e) {}
9609}
9610// ── PER-VIEW SETTINGS (ADMIN TEST, user 2026-08-29) ─────────────────────────
9611// Metric / expiry / bars / tint belong to the VIEW (Grid, Heatmap, Ghost Mode) unless
9612// view-sync is ON. Implemented as a swap: the existing globals stay the single source
9613// every consumer reads; setView banks the outgoing view's values and loads the incoming
9614// view's, and every setter re-banks the current view. Members (and sync-on) keep the
9615// old shared-global behaviour exactly.
9616let gfViewSettings = {};
9617try { const _vs0 = JSON.parse(localStorage.getItem('gf_view_settings') || '{}'); if (_vs0 && typeof _vs0 === 'object') gfViewSettings = _vs0; } catch (e) {}
9618function _vsKey(v) { return (v === 'gex' || v === 'expiry' || v === 'chart') ? v : null; }
9619function _vsPerView() { return typeof isPaidUser !== 'undefined' && isPaidUser && !(typeof gfViewSync !== 'undefined' && gfViewSync); }
9620function _vsBank(viewKey) {
9621  if (!viewKey) return;
9622  gfViewSettings[viewKey] = { metric: currentMetric, exp: gridExpiryMo
9622de, bars: gfGridBars, tint: gfGridTint };
9623  try { localStorage.setItem('gf_view_settings', JSON.stringify(gfViewSettings)); } catch (e) {}
9624}
9625function _vsApply(viewKey) {
9626  if (!viewKey) return false;
9627  const v = gfViewSettings[viewKey]; if (!v) return false;
9628  let changed = false;
9629  if (v.metric && ['gex', 'vex', 'charm'].includes(v.metric) && v.metric !== currentMetric) { currentMetric = v.metric; changed = true; }
9630  if (v.exp && ['all', '0dte', 'weekly'].includes(v.exp) && v.exp !== gridExpiryMode) { gridExpiryMode = v.exp; changed = true; }
9631  if (v.bars && v.bars !== gfGridBars) { gfGridBars = v.bars; changed = true; }
9632  if (v.tint && v.tint !== gfGridTint) { gfGridTint = v.tint; changed = true; }
9633  return changed;
9634}
9635// Collapsible ticker strip (admin test): a fold handle on the Grid / Heatmap / Ghost ticker
9636// lines, like the left/right panel collapses. The strip builders wipe innerHTML on every
9637// rebuild, so each builder re-calls this after rendering to re-attach the handle + state.
9638// LAB REVEAL for premium + comped (user 2026-08-29: "enable the new grid, heatmap and
9639// ghost mode settings to premium and comped users"): settings bars, strip folds, heatmap
9640// row move, tags rows, 6-wide grid, per-sector boot restore. isPaidUser includes admin.
9641function _gfLabReveal(){
9642  try{
9643    var gsb=document.getElementById('gridStyleBar'); if(gsb)gsb.style.display='inline-flex';
9644    var hsb=document.getElementById('heatStyleBar'); if(hsb)hsb.style.display='inline-flex';
9645    var hr=document.getElementById('heatSetRow'); var et=document.getElementById('expiryTickers');
9646    if(hr&&et&&et.parentNode) et.parentNode.insertBefore(hr,et);
9647    _syncGridStyleButtons();
9648    gfWireStripFold('gexTickers'); gfWireStripFold('expiryTickers'); gfWireStripFold('chartTickers'); gfWireStripFold('chainTickers');
9649    var gvr=document.getElementById('gvtag-rows'); if(gvr)gvr.style.display='block';
9650    document.body.classList.add('gf-admin');
9651    /* Boot restore now that admin is known: last-used sector + this sector's saved custom ticker line-up (a reload used to fall back to the Index defaults - user 2026-08-29), then re-render for the cfg selects + 7-wide grid. */ try{ var _sg=null; try{ _sg=localStorage.getItem('gf_active_group'); }catch(e){} if(_sg && WATCHLIST[_sg] && _sg!==activeGroup){ activeGroup=_sg; activeColumns=[...(WATCHLIST[_sg]||[])]; } if(_colSets[activeGroup] && _colSets[activeGroup].length) activeColumns=_colSets[activeGroup].slice(); buildTabs(); if(typeof buildGexTickers==='function') buildGexTickers(); if(typeof currentView!=='undefined'&&currentView==='gex'){ renderColumns(); reloadSlots(); } if(typeof currentView!=='undefined'&&currentView==='expiry'&&typeof expiryTicker!=='undefined'&&expiryTicker){ loadExpiryGEX(expiryTicker,true); } /* heatmap rendered pre-auth shows the member look - repaint with the admin renderer */ }catch(e){}
9652    if(_gv2On()) _gv2Rerender();
9653  }catch(e){}
9654}
9655function gfWireStripFold(id) {
9656  if (typeof isPaidUser === 'undefined' || !isPaidUser) return;
9657  const el = document.getElementById(id); if (!el) return;
9658  el.classList.add('gf-has-fold');   // keeps chips clear of the handle
9659  // Inline because .expiry-tickers declares position:static (iPad sticky fix) which beat
9660  // the class rule - without this the absolute handle anchored to the page, not the strip.
9661  el.style.position = 'relative';
9662  let btn = el.querySelector('.gf-fold-btn');
9663  if (!btn) {
9664    btn = document.createElement('button');
9665    btn.className = 'gf-fold-btn'; btn.type = 'button';
9666    btn.onclick = function () {
9667      const closed = el.classList.toggle('gf-strip-closed');
9668      btn.innerHTML = closed ? '&#9662;' : '&#9652;';
9669      btn.title = closed ? 'Show all tickers' : 'One-line tickers';
9670      try { localStorage.setItem('gf_fold_' + id, closed ? '1' : '0'); } catch (e) {}
9671      if (closed) gfStripScrollActive(el);
9672      try { fitColumnsHeight(); } catch (e) {} try { fitChartViewHeight(); } catch (e) {}
9673    };
9674    el.appendChild(btn);
9675    // folded = one line: the wheel scrolls it sideways (the row has no visible scrollbar)
9676    if (!el._gfWheel) { el._gfWheel = true; el.addEventListener('wheel', function (e) { if (!el.classList.contains('gf-strip-closed')) return; if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { el.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false }); }   // once per element - the button is recreated after every strip rebuild
9677  }
9678  let closed = false; try { closed = localStorage.getItem('gf_fold_' + id) === '1'; } catch (e) {}
9679  el.classList.toggle('gf-strip-closed', closed);
9680  btn.innerHTML = closed ? '&#9662;' : '&#9652;';
9681  btn.title = closed ? 'Show all tickers' : 'One-line tickers';
9682  if (closed && !el._gfFoldInit) setTimeout(function () { gfStripScrollActive(el); }, 300);   // only on the FIRST wire: the strip is re-wired after every rebuild and this kept snapping the row back to the active chip (user 2026-09-06)
9683  el._gfFoldInit = true;
9684}
9685function gfStripScrollActive(el) { try { const a = el.querySelector('.exp-ticker-btn.active'); if (a) a.scrollIntoView({ block: 'nearest', inline: 'center' }); } catch (e) {} }
9686function setGridExpiry(mode) {
9687  if (!['all', '0dte', 'weekly'].includes(mode)) return;
9688  // Ghost Desk: the ALL/0DTE/WEEK basis belongs to the focused chart.
9689  try { const _p = (typeof _mFocusedPaneId === 'function') ? _mFocusedPaneId() : null; if (_p != null && gfLive('chart')) { _mSetBasis(_p, mode); _mSyncModeUI(); return; } } catch (e) {}
9690  if (mode === gridExpiryMode) return;
9691  gridExpiryMode = mode;
9692  try { localStorage.setItem('gf_grid_expiry', mode); } catch (e) {}
9693  try { if (_vsPerView()) _vsBank(_vsKey(typeof gfActiveView === 'function' ? gfActiveView() : currentView)); } catch (e) {}
9694  // Ghost Desk: the basis belongs to the FOCUSED slot, so each chart keeps its own ALL/0DTE/WEEK.
9695  try { if (typeof _mSyncFocusedSlot === 'function') _mSyncFocusedSlot(); } catch (e) {}
9696  _syncGridExpiryButtons();
9697  // Recompute whatever view is showing from the new basis.
9698  // In the workspace several of these can be true at once, so they are no longer exclusive: every
9699  // panel that is SHOWING gets recomputed from the new basis, not just the first branch that matches.
9700  let _hit = false;
9701  if (gfLive('chart') && typeof chartTicker !== 'undefined' && chartTicker) {
9702    _hit = true;
9703    try { delete chartLevelCache[chartTicker]; } catch (e) {}
9704    loadGEXChart(chartTicker, true, { keepFlow: true });   // expiry basis is chart-only — don't reload flow
9705  }
9706  if (gfLive('ultra') && typeof ultraTicker !== 'undefined' && ultraTicker) { _hit = true; loadUltraView(ultraTicker, true, { keepFlow: true }); }
9707  if (gfLive('expiry') && typeof expiryTicker !== 'undefined' && expiryTicker) { _hit = true; loadExpiryGEX(expiryTicker, true); }
9708  if (gfLive('gex') || !_hit) { activeColumns.forEach((t, i) => setTimeout(() => loadGEXSlot(i), i * 90)); }
9709  _gexBubRefreshSoon();   // switch bubbles to this expiry's trail once columnData rebuilds
9710}
9711// Fetch GEX strike rows for the current expiry basis, shaped like the aggregate endpoint
9712// ({data:[...]}) so callers parse it unchanged. ALL → aggregate; 0DTE/WEEKLY → the per-expiry
9713// endpoint filtered to the target expiry (falls back to the aggregate if that expiry has none).
9714async function _basisGexJson(ticker, mode) {
9715  mode = mode || gridExpiryMode;
9716  // CHARM is pinned to the NEAREST expiry no matter the ALL/0DTE/WEEK toggle (user 2026-08-31):
9717  // charm is a front-expiry force — the ALL aggregate buries today's pin under far-dated OI.
9718  if ((_metricOv || currentMetric) === 'charm') mode = '0dte';
9719  if (mode === 'all') return fetch('/api/gex/strike/' + ticker).then(r => r.json());
9720  const j = await fetch('/api/gex/strike-expiry/' + ticker).then(r => r.json()).catch(() => null);
9721  let rows = (j && (j.data || j)) || [];
9722  if (!Array.isArray(rows)) rows = [];
9723  const targetExp = pickGridExpiry(rows, mode);
9724  const sel = rows.filter(r => (r.expiry || r.date || '') === targetExp);
9725  if (sel.length) return { data: sel };
9726  return fetch('/api/gex/strike/' + ticker).then(r => r.json());
9727}
9728// Pick the target expiry (YYYY-MM-DD) from strike-expiry rows for 0DTE / WEEKLY.
9729function pickGridExpiry(rows, mode) {
9730  // 0DTE HOLDS TODAY'S EXPIRY until the next session (user 2026-09-02). "today" is the ET calendar
9731  // day, NOT UTC: UTC rolls at 8pm ET, so evenings used to drop today's 0DTE and jump to the next
9732  // tradeable expiry — the grid + chart King read the next session's strike (MSFT 510 / SPY 760)
9733  // while the recorded 0DTE bubbles still showed today's (495 / 765). ET keeps the levels on today's
9734  // expiry all evening so they stay in lockstep with the bubbles. Ghost Mode only (TV/ext untouched).
9735  const today = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
9736  const maxDate = new Date(Date.now() + 45 * 86400000).toISOString().slice(0, 10);
9737  const exps = [...new Set(rows.map(r => r.expiry || r.date || '').filter(e => e && e >= today && e <= maxDate))].sort();
9738  if (!exps.length) return '';
9739  if (mode === '0dte') return exps[0];
9740  // WEEK = the last expiry on or before THIS WEEK'S FRIDAY (ET) - Monday through Friday it is this
9741  // Friday, and it only rolls to next week on the next Monday (user 2026-09-05: "monday-friday 5 days;
9742  // on friday 0dte and weekly can show the same, it's the end of the week"). Sat/Sun roll to the coming
9743  // Friday. Same rule the server's levelsFor has used since 2026-08-26, so dashboard, extension and
9744  // /api/levels?expiry=weekly agree again (the old client rule was a rolling 7 days, which from a
9745  // Monday reached NEXT Monday on daily-expiry names). No expiry by Friday -> '' -> caller falls back
9746  // to the ALL ladder, matching the server fallback.
9747  const _et = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' }));
9748  const _dow = _et.getDay(), _add = _dow === 6 ? 6 : (5 - _dow + 7) % 7;
9749  const _fri = new Date(_et.getFullYear(), _et.getMonth(), _et.getDate() + _add);
9750  const wk = _fri.getFullYear() + '-' + String(_fri.getMonth() + 1).padStart(2, '0') + '-' + String(_fri.getDate()).padStart(2, '0');
9751  const within = exps.filter(e => e <= wk);
9752  return within.length ? within[within.length - 1] : '';
9753}
9754
9755async function loadGEX(ticker, opts) {
9756  if (demoLocked(ticker)) return;   // demo: locked columns are blurred, no fetch
9757  // opts (grid v2 stage 2): { key, basis, metric } - slot-scoped store/DOM key plus
9758  // per-column expiry basis + metric. Without opts this is EXACTLY the old loadGEX.
9759  const _key = (opts && opts.key) || ticker;
9760  console.log('loadGEX called for:', ticker, _key !== ticker ? '(' + _key + ')' : '');
9761  const colEl = document.getElementById("col-body-" + _key);
9762  // Only show loading if no cached data (first load)
9763  if (colEl && !columnData[_key]) {
9764    colEl.innerHTML = '<div class="col-loading">Loading...</div>';
9765  }
9766
9767  try {
9768    // CHARM columns pin to the NEAREST expiry regardless of the basis toggle (matches _basisGexJson).
9769    const _mode = (((opts && opts.metric) || currentMetric) === 'charm') ? '0dte' : ((opts && opts.basis) || gridExpiryMo
9769de);
9770    // ALL → aggregate per-strike endpoint; 0DTE/WEEKLY → per-expiry endpoint (same one the Heatmap uses).
9771    const _gexUrl = (_mode === 'all') ? ("/api/gex/strike/" + ticker) : ("/api/gex/strike-expiry/" + ticker);
9772    console.log('Fetching', _gexUrl, '(' + _mode + ')');
9773    const [gexRes, spotRes, contractsRes] = await Promise.all([
9774      fetch(_gexUrl),
9775      fetch("/api/spot/" + ticker),
9776      fetch("/api/contracts/" + ticker),
9777    ]);
9778    const gexJson = await gexRes.json();
9779    const spotJson = await spotRes.json();
9780    let contractsArr = [];
9781    try { const cj = await contractsRes.json(); contractsArr = cj.data || cj || []; } catch (e) {}
9782    let rows = gexJson.data || gexJson || [];
9783    const spotArr = spotJson.data || spotJson || [];
9784
9785    if (!Array.isArray(rows)) rows = [];
9786
9787    let spot = 0;
9788    if (Array.isArray(spotArr)) {
9789      for (const s of spotArr) {
9790        const p = parseFloat(s.price || s.underlying_price || 0);
9791        if (p > 0) { spot = p; break; }
9792      }
9793    }
9794
9795    _metricOv = (opts && opts.metric) || null;   // per-column metric: applied for this synchronous parse only
9796    const mult = metricMult(spot);
9797    const _toRow = s => { const strike = parseFloat(s.strike || 0); return { strike, netGex: metricRaw(s) * mult, callGex: metricCall(s) * mult, putGex: metricPut(s) * mult }; };
9798    let parsed;
9799    if (_mode === 'all') {
9800      parsed = rows.map(_toRow).filter(s => s.strike > 0 && Math.abs(s.netGex) > 0);
9801    } else {
9802      // 0DTE / WEEKLY: keep only the target expiry's rows (already one row per strike).
9803      const targetExp = pickGridExpiry(rows, _mode);
9804      parsed = rows.filter(r => (r.expiry || r.date || '') === targetExp).map(_toRow).filter(s => s.strike > 0 && Math.abs(s.netGex) > 0);
9805      // No rows for that expiry (e.g. no weekly listed) → aggregate across expiries by strike
9806      // so the column never goes blank.
9807      if (!parsed.length) {
9808        const byStrike = {};
9809        for (const r of rows) {
9810          const k = parseFloat(r.strike || 0); if (!k) continue;
9811          if (!byStrike[k]) byStrike[k] = { strike: k, netGex: 0, callGex: 0, putGex: 0 };
9812          const pr = _toRow(r);
9813          byStrike[k].netGex += pr.netGex; byStrike[k].callGex += pr.callGex; byStrike[k].putGex += pr.putGex;
9814        }
9815        parsed = Object.values(byStrike).filter(s => Math.abs(s.netGex) > 0);
9816      }
9817    }
9818
9819    _metricOv = null;
9820    if (parsed.length === 0) {
9821      // Empty/degenerate response (common for a few seconds right after a server
9822      // restart, before its cache re-warms). NEVER wipe a column that already has good
9823      // data on screen — just keep the last render. Only show the message on first load.
9824      if (!columnData[_key]) {
9825        if (colEl) colEl.innerHTML = '<div class="col-loading col-error">No GEX data for ' + ticker + '</div>';
9826      }
9827      return;
9828    }
9829
9830    if (!spot && parsed.length > 0) {
9831      spot = [...parsed].sort((a, b) => Math.abs(b.netGex) - Math.abs(a.netGex))[0].strike;
9832    }
9833
9834    // Filter: 30 strikes above and below spot
9835    let filtered = parsed;
9836    if (spot > 0) {
9837      const above = parsed.filter(s => s.strike >= spot).sort((a, b) => a.strike - b.strike).slice(0, 30);
9838      const below = parsed.filter(s => s.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
9839      filtered = [...above, ...below];
9840    }
9841
9842    // Remove noise — only very tiny values
9843    if (filtered.length > 0) {
9844      const maxGex = Math.max(...filtered.map(s => Math.abs(s.netGex)));
9845      filtered = filtered.filter(s => Math.abs(s.netGex) > maxGex * 0.001);
9846    }
9847
9848    filtered.sort((a, b) => b.strike - a.strike); // high to low
9849
9850    const { callWall, putWall, kingNode, totalGex } = pickLevels(filtered, spot);
9851    const volNode = computeVolNode(contractsArr, spot);
9852
9853    columnData[_key] = { strikes: filtered, spot, callWall, putWall, kingNode, totalGex, volNode, metricUsed: (opts && opts.metric) || currentMetric, basis: _mode, volMap: _gridVolMap(contractsArr, _mode), ticker };
9854    renderColumn(_key);
9855    // Active node (⚡, last ~30 min of volume): fetch async and re-render the column
9856    // once it arrives so the marker shows without waiting for the next refresh.
9857    fetchActiveNode(ticker).then(an => { if (an && columnData[_key]) renderColumn(_key); });
9858  } catch (e) {
9859    _metricOv = null;
9860    const colEl2 = document.getElementById("col-body-" + _key);
9861    if (!columnData[_key] && colEl2) {
9862      colEl2.innerHTML = '<div class="col-loading col-error">' + e.message + '</div>';
9863    }
9864    console.error('GEX error for', ticker, e.message);
9865  }
9866}
9867
9868// ── GRID V2 STAGE 2 (ADMIN TEST, 2026-08-29): per-column metric/expiry + duplicate tickers ──
9869// Columns were addressed purely by TICKER (DOM ids, columnData key), so two columns on the
9870// same ticker collided - the second sat on 'Loading...' forever (user screenshot 8/29).
9871// SLOT layer: a column whose slot has an override, or whose ticker already appears in an
9872// earlier slot, gets a slot-scoped key 'TICKER#sN' used for its DOM ids + columnData entry;
9873// default columns keep the plain ticker key, so every existing flow (sector loads, sync,
9874// auto-refresh, chart reads of columnData[ticker]) behaves exactly as before.
9875// Per-SECTOR config + per-sector column sets (user 2026-08-29: settings bled across
9876// sectors, and a customized ticker line-up vanished on sector switch and came back wrong).
9877// colCfg[sector][slot] = {metric, exp}; _colSets[sector] = the exact ticker line-up
9878// (duplicates allowed) restored when that sector is reopened.
9879let colCfg = {};
9880try { const _cc = JSON.parse(localStorage.getItem('gf_col_cfg2') || '{}'); if (_cc && typeof _cc === 'object' && !Array.isArray(_cc)) colCfg = _cc; } catch (e) {}
9881function _saveColCfg() { try { localStorage.setItem('gf_col_cfg2', JSON.stringify(colCfg)); } catch (e) {} }
9882let _colSets = {};
9883try { const _cs = JSON.parse(localStorage.getItem('gf_col_sets') || '{}'); if (_cs && typeof _cs === 'object' && !Array.isArray(_cs)) _colSets = _cs; } catch (e) {}
9884function _saveColSets() { try { localStorage.setItem('gf_col_sets', JSON.stringify(_colSets)); } catch (e) {} }
9885function _slotCfg(si) { return (typeof isPaidUser !== 'undefined' && isPaidUser && colCfg[activeGroup] && colCfg[activeGroup][si]) || {}; }
9886function _slotCustom(si) {
9887  const t = activeColumns[si], cfg = _slotCfg(si);
9888  if (cfg.metric || cfg.exp) return true;
9889  for (let j = 0; j < si; j++) if (activeColumns[j] === t) return true;   // duplicate ticker
9890  return false;
9891}
9892function _slotKey(si) { return _slotCustom(si) ? activeColumns[si] + '#s' + si : activeColumns[si]; }
9893// Rename a slot's DOM ids in place when its key changes (custom <-> default, ticker
9894// change) - the grid is NOT rebuilt, so nothing blinks (user 2026-08-29).
9895function _slotDomRename(oldKey, newKey) {
9896  if (oldKey === newKey) return;
9897  for (const p of ['col-', 'col-body-', 'king-', 'price-', 'refresh-', 'close-']) {
9898    const el = document.getElementById(p + oldKey); if (el) el.id = p + newKey;
9899  }
9900}
9901function _slotKeysSnapshot() { return activeColumns.map((t, j) => _slotKey(j)); }
9902function _slotIdxFromKey(key) { for (let j = 0; j < activeColumns.length; j++) if (_slotKey(j) === key) return j; return -1; }
9903// HOLD-TO-RESET (user 2026-08-29): wipes THIS sector's saved line-up + per-column
9904// overrides and restores the default one-ticker-per-column view. Global metric/expiry
9905// toggles are untouched - reset columns simply follow them again. Hold ~1s to fire so
9906// a stray click cannot wipe a built-up layout.
9907function gridResetSector() {
9908  // Resets the COLUMN LAYOUT only (user 2026-08-29): WATCHLIST[activeGroup] already
9909  // contains every ticker the user ADDED via + ADD TICKER, so additions survive a reset.
9910  // The server-side added/hidden ticker lists are never touched here - only the x button
9911  // (deleteTickerEverywhere) removes a ticker for real.
9912  delete _colSets[activeGroup]; _saveColSets();
9913  delete colCfg[activeGroup]; _saveColCfg();
9914  activeColumns = [...(WATCHLIST[activeGroup] || [])];
9915  gexColOrder = activeColumns.slice();
9916  renderColumns(); reloadSlots();
9917  try { showToast('Grid reset to default for ' + activeGroup); } catch (e) {}
9918}
9919(function () {
9920  var b = document.getElementById('gridResetBtn'); if (!b) return;
9921  var t = null;
9922  var arm = function (e) { try { e.preventDefault(); } catch (e2) {} b.classList.add('holding'); t = setTimeout(function () { b.classList.remove('holding'); t = null; gridResetSector(); }, 1000); };
9923  var dis = function () { b.classList.remove('holding'); if (t) { clearTimeout(t); t = null; } };
9924  b.addEventListener('mousedown', arm); b.addEventListener('touchstart', arm);
9925  ['mouseup', 'mouseleave', 'touchend', 'touchcancel'].forEach(function (ev) { b.addEventListener(ev, dis); });
9926})();
9927// A ticker/cfg change can flip OTHER slots' duplicate status too - rekey them all.
9928function _slotRekey(prev) {
9929  activeColumns.forEach((t, j) => {
9930    const nk = _slotKey(j);
9931    if (prev[j] !== nk) { _slotDomRename(prev[j], nk); if (columnData[nk]) renderColumn(nk); else loadGEXSlot(j); }
9932  });
9933}
9934function setColCfg(si, k, v) {
9935  const prev = _slotKeysSnapshot();
9936  // Picking the value the GLOBAL toggle already shows = follow the global from now on.
9937  if (k === 'metric' && v === currentMetric) v = '';
9938  if (k === 'exp' && v === gridExpiryMode) v = '';
9939  const C = (colCfg[activeGroup] = colCfg[activeGroup] || {});
9940  const c = (C[si] = C[si] || {});
9941  if (v) c[k] = v; else delete c[k];
9942  if (!c.metric && !c.exp) delete C[si];
9943  if (!Object.keys(C).length) delete colCfg[activeGroup];
9944  _saveColCfg();
9945  _slotRekey(prev);
9946  loadGEXSlot(si);   // repaint just this column - no grid rebuild
9947}
9948function switchTickerSlot(si, t) {
9949  const prev = _slotKeysSnapshot();
9950  const displaced = activeColumns[si];
9951  activeColumns[si] = t;
9952  // EXTEND TO END (user 2026-08-29): if this change made the old ticker vanish from the
9953  // grid entirely, append it as a fresh column at the end - duplicating a ticker across
9954  // columns never silently drops another one. Close it with x if unwanted.
9955  const _appended = (displaced && displaced !== t && activeColumns.indexOf(displaced) < 0);
9956  if (_appended) activeColumns.push(displaced);
9957  if (typeof isPaidUser !== 'undefined' && isPaidUser) { _colSets[activeGroup] = activeColumns.slice(); _saveColSets(); }
9958  if (_appended) { renderColumns(); reloadSlots(); return; }   // structural change -> full rebuild
9959  _slotRekey(prev);
9960  const nk = _slotKey(si);
9961  const b = document.getElementById('col-body-' + nk); if (b) b.innerHTML = '<div class="col-loading">Loading...</div>';
9962  const p = document.getElementById('price-' + nk); if (p) p.textContent = '';
9963  loadGEXSlot(si);
9964}
9965function loadGEXSlot(si) {
9966  const t = activeColumns[si]; if (!t) return;
9967  if (!_slotCustom(si)) return loadGEX(t);
9968  const cfg = _slotCfg(si);
9969  return loadGEX(t, { key: _slotKey(si), basis: cfg.exp || null, metric: cfg.metric || null });
9970}
9971function reloadSlots() {
9972  activeColumns.forEach((t, si) => { const k = _slotKey(si); if (columnData[k]) renderColumn(k); else loadGEXSlot(si); });
9973}
9974// Custom slots are invisible to the ticker-keyed refresh paths - keep them fresh here.
9975setInterval(function () {
9976  try { if (gfLive('gex')) activeColumns.forEach((t, si) => { if (_slotCustom(si)) loadGEXSlot(si); }); } catch (e) {}
9977}, 60000);
9978// ── Render all columns ──
9979function renderColumns() {
9980  const container = document.getElementById("columns");
9981  container.innerHTML = "";
9982
9983  activeColumns.forEach((ticker, _si) => {
9984    const _key = _slotKey(_si);
9985    const col = document.createElement("div");
9986    col.className = "gex-col";
9987    col.id = "col-" + _key;
9988
9989    // Demo: locked tickers show the name + blurred fake data, no fetch, not clickable.
9990    if (demoLocked(ticker)) {
9991      col.classList.add('demo-locked-col');
9992      let _r = '';
9993      for (let _i = 0; _i < 16; _i++) {
9994        const _w = 18 + Math.round(Math.abs(Math.sin(_i * 1.7 + ticker.length)) * 72);
9995        _r += '<div class="strike-row"><span class="strike-label">•••</span><div class="gex-bar-container"><div class="gex-bar ' + (_i % 2 ? 'negative' : 'positive') + '" style="width:' + _w + '%"></div></div></div>';
9996      }
9997      col.innerHTML = '<div class="col-header"><div class="col-ticker" style="font-weight:700">' + ticker + '</div><span style="font-size:12px">🔒</span></div>' +
9998        '<div class="king-row demo-blur"><span class="king-dot"></span><span class="king-label">King</span></div>' +
9999        '<div class="strikes-list demo-blur">' + _r + '</div>';
10000      container.appendChild(col);
10001      return;
10002    }
10003
10004    // Header (right-click it to delete this ticker from all views)
10005    const header = document.createElement("div");
10006    header.className = "col-header";
10007    header.setAttribute("data-ticker", ticker);
10008    header.title = "Right-click to delete " + ticker + " from all views";
10009
10010    // Ticker dropdown
10011    const allTickers = Object.values(WATCHLIST).flat();
10012    const unique = [...new Set(allTickers)];
10013    let selectHtml = `<select onchange="switchTickerSlot(${_si}, this.value)">`;
10014    for (const t of unique) {
10015      selectHtml += `<option value="${t}" ${t === ticker ? 'selected' : ''}>${t}</option>`;
10016    }
10017    selectHtml += "</select>";
10018    // Per-column metric + expiry (admin test): G* = follow the global toggles (default -
10019    // members and untouched columns render exactly as before).
10020    if (typeof isPaidUser !== 'undefined' && isPaidUser) {
10021      const _cfg = _slotCfg(_si);
10022      const _mEff = _cfg.metric || currentMetric || 'gex';
10023      const _xEff = _cfg.exp || gridExpiryMode || 'all';
10024      const _opt = (list, sel) => list.map(o => '<option value="' + o[0] + '"' + (sel === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('');
10025      // ONE entry per value (user 2026-08-29: 'gex gex' - the separate inherit row doubled
10026      // the global's value). The select shows the column's EFFECTIVE setting; picking the
10027      // value the global toggle already shows means 'follow the global' (see setColCfg).
10028      // data-si lets a global toggle change resync untouched columns' displays.
10029      selectHtml += '<select class="col-cfg" data-kind="metric" data-si="' + _si + '" onchange="setColCfg(' + _si + ',\'metric\',this.value)" title="Metric for THIS column only">'
10030        + _opt([['gex', 'GEX'], ['vex', 'VEX'], ['charm', 'CHARM']], _mEff) + '</select>'
10031        + '<select class="col-cfg" data-kind="exp" data-si="' + _si + '" onchange="setColCfg(' + _si + ',\'exp\',this.value)" title="Expiry basis for THIS column only">'
10032        + _opt([['all', 'ALL'], ['0dte', '0DTE'], ['weekly', 'WEEKLY']], _xEff) + '</select>';
10033    }
10034
10035    header.innerHTML = `
10036      <div class="col-ticker">${IS_DEMO ? '<span style="font-weight:700">' + ticker + '</span>' : '<span class="col-drag" draggable="true" title="Drag to reorder">&#8942;&#8942;</span>' + selectHtml}</div>
10037      <div style="display:flex;align-items:center;gap:6px">
10038        <span class="col-price" id="price-${_key}"></span>
10039        ${IS_DEMO ? '' : '<button class="col-refresh" id="refresh-' + _key + '" title="Refresh ' + ticker + '">↻</button><button class="col-close" id="close-' + _key + '" title="Delete this column\'s ticker from all views">&times;</button>'}
10040      </div>
10041    `;
10042    col.appendChild(header);
10043
10044    // Attach event listeners after adding to DOM
10045    setTimeout(() => {
10046      const refreshBtn = document.getElementById("refresh-" + _key);
10047      const closeBtn = document.getElementById("close-" + _key);
10048      if (refreshBtn) refreshBtn.addEventListener("click", () => loadGEXSlot(_si));
10049      if (closeBtn) closeBtn.addEventListener("click", () => {
10050        // In HQ: this column goes, by slot, and nothing else changes - no watchlist, no ticker line.
10051        if (typeof _gfWsOn === 'function' && _gfWsOn()) { gfGridRemoveSlot(_si); return; }
10052        // The column's ticker can change via its dropdown after build — delete what the column
10053        // SHOWS now, not the ticker it was born with (the old closure deleted the wrong one).
10054        const _sel = header.querySelector('.col-ticker select');
10055        deleteTickerEverywhere((_sel && _sel.value) || ticker);
10056      });
10057      if (closeBtn && typeof _gfWsOn === 'function' && _gfWsOn()) closeBtn.title = 'Remove this column - watchlists and ticker lines are not changed';
10058      // NOTE: no duplicate change listener on the ticker <select> - its inline onchange
10059      // (switchTickerSlot) is the single handler; the old addEventListener double-fired.
10060    }, 0);
10061
10062    // King row
10063    const king = document.createElement("div");
10064    king.className = "king-row";
10065    king.id = "king-" + _key;
10066    king.innerHTML = '<span class="king-dot"></span><span class="king-label">King</span>';
10067    col.appendChild(king);
10068
10069    // Strikes body
10070    const body = document.createElement("div");
10071    body.className = "strikes-list";
10072    body.id = "col-body-" + _key;
10073    body.innerHTML = '<div class="col-loading">Loading...</div>';
10074    col.appendChild(body);
10075
10076    container.appendChild(col);
10077  });
10078
10079  // Add column button - the in-grid one. In HQ it is hidden and the grid bar carries a + instead;
10080  // both call gfGridAddColumn so there is one behaviour, not two.
10081  const addBtn = document.createElement("button");
10082  addBtn.className = "add-col";
10083  addBtn.textContent = "+";
10084  addBtn.onclick = gfGridAddColumn;
10085  container.appendChild(addBtn);
10086  enableColumnDrag();
10087}
10088// ALWAYS adds a column (user 2026-08-29: '+' went dead once every sector ticker was on screen):
10089// when nothing is left to add, duplicate the first ticker - duplicates are first-class via slot
10090// keys, and the per-column selects make them useful.
10091function gfGridAddColumn() {
10092  const allTickers = WATCHLIST[activeGroup] || [];
10093  const next = allTickers.find(t => !activeColumns.includes(t)) || allTickers[0] || activeColumns[0];
10094  if (!next) return;
10095  activeColumns.push(next);
10096  if (typeof isPaidUser !== 'undefined' && isPaidUser) { _colSets[activeGroup] = activeColumns.slice(); _saveColSets(); }
10097  renderColumns();
10098  reloadSlots();
10099}
10100
10101// Build the GEX heatmap strike rows (shared by the GEX-view columns and the
10102// floating panel on the GEX chart, so they look identical).
10103// Vol Node: the strike with the most option volume traded today (near spot).
10104// ── Active volume node (server-computed): most-traded strike in the last ~30 min ──
10105// Complements the day-POC Vol Node (🔵), which lags price by design. Shown as ⚡.
10106const activeNodes = {}; // ticker -> { strike, volume, windowMin, t }
10107async function fetchActiveNode(ticker) {
10108  const c = activeNodes[ticker];
10109  if (c && Date.now() - c.t < 10000) return c.strike ? c : null; // ~10s so it refreshes live in step with the levels
10110  try {
10111    const j = await (await fetch('/api/active-node/' + ticker)).json();
10112    activeNodes[ticker] = Object.assign({ t: Date.now() }, j);
10113    return j && j.strike ? activeNodes[ticker] : null;
10114  } catch (e) { return null; }
10115}
10116
10117function computeVolNode(contracts, spot) {
10118  if (!Array.isArray(contracts) || contracts.length === 0) return null;
10119  const byStrike = {};
10120  for (const c of contracts) {
10121    const sym = c.option_symbol || c.option_chain_id || '';
10122    const m = sym.match(/^[A-Z]+\d{6}[CP](\d{8})$/);
10123    if (!m) continue;
10124    const strike = parseInt(m[1]) / 1000;
10125    const vol = parseInt(c.volume) || 0;
10126    if (spot > 0 && (strike < spot * 0.7 || strike > spot * 1.3)) continue; // ignore far-OTM noise
10127    if (strike > 0 && vol > 0) byStrike[strike] = (byStrike[strike] || 0) + vol;
10128  }
10129  let best = null, max = 0;
10130  for (const [s, v] of Object.entries(byStrike)) {
10131    if (v > max) { max = v; best = { strike: parseFloat(s), volume: v }; }
10132  }
10133  return best;
10134}
10135
10136function buildGexRowsHtml(strikes, spot, callWall, putWall, kingNode, volNode, activeNode, metricOv, volMap) {
10137  if (!Array.isArray(strikes) || strikes.length === 0) return '';
10138
10139  let closestToSpot = null;
10140  if (spot > 0 && strikes.length > 0) {
10141    let minDist = Infinity;
10142    for (const s of strikes) {
10143      const d = Math.abs(s.strike - spot);
10144      if (d < minDist) { minDist = d; closestToSpot = s.strike; }
10145    }
10146  }
10147
10148  const maxGex = Math.max(...strikes.map(s => Math.abs(s.netGex)), 1);
10149  const topStrikes = [...strikes]
10150    .sort((a, b) => Math.abs(b.netGex) - Math.abs(a.netGex))
10151    .slice(0, 3)
10152    .map(s => s.strike);
10153
10154  // Gamma flip on the grid (user 2026-09-02): same zero-cross the chart's G-Flip line uses, drawn
10155  // as a thin purple dashed seam BETWEEN the two strikes it falls between. strikes run high->low,
10156  // so the seam is inserted the moment we step from a strike >= flip down to one < flip.
10157  const gFlip = (typeof gammaFlip === 'function') ? gammaFlip(strikes, spot) : null;
10158  let _prevK = null;
10159
10160  let html = "";
10161  for (const s of strikes) {
10162    if (gFlip != null && _prevK != null && _prevK >= gFlip && s.strike < gFlip) {
10163      html += `
10164      <div class="gflip-seam" style="display:flex;align-items:center;gap:4px;padding:0 6px;height:12px;position:relative;z-index:2">
10165        <div style="flex:1;border-top:1.5px dashed #a855f7"></div>
10166        <span style="font:700 8px 'Segoe UI',monospace;color:#c084fc;letter-spacing:.3px;white-space:nowrap;text-shadow:0 0 6px rgba(168,85,247,.6)">⚡ G-Flip ${gFlip.toFixed(2)}</span>
10167        <div style="width:8px;border-top:1.5px dashed #a855f7"></div>
10168      </div>`;
10169    }
10170    _prevK = s.strike;
10171    const isSpot = spot > 0 && s.strike === closestToSpot;
10172    const isKing = kingNode && s.strike === kingNode.strike;
10173    const isCallWall = callWall && s.strike === callWall.strike;
10174    const isPutWall = putWall && s.strike === putWall.strike;
10175    const isVolNode = volNode && s.strike === volNode.strike;
10176    const isActiveNode = activeNode && activeNode.strike && s.strike === activeNode.strike;
10177    const pct = Math.min((Math.abs(s.netGex) / maxGex) * 50, 50);
10178    const isPos = s.netGex >= 0;
10179    const isStar = isKing || isCallWall || isPutWall;
10180
10181    // Charm uses its OWN palette (cyan = positive, amber = negative) so it's not confused with
10182    // GEX/VEX green/red. GEX & VEX keep the exact green/red look.
10183    const isCharm = ((metricOv || currentMetric) === 'charm');
10184    let rowClass = "strike-row";
10185    let rowBg;
10186    const intensity = Math.min(Math.abs(s.netGex) / maxGex, 1);
10187    const isBigRow = topStrikes.includes(s.strike);
10188    // Charm: brightness scales smoothly with the bar's magnitude (biggest = brightest cyan/amber,
10189    // small levels fade out) so the shifting levels are easy to rank at a glance.
10190    if (isKing) {
10191      rowBg = `rgba(234,179,8,${0.15 + intensity * 0.35})`;
10192    } else if (isSpot) {
10193      rowBg = `rgba(168,85,247,0.15)`;
10194    } else if (isPos) {
10195      if (isCharm) rowBg = `rgba(34,211,238,${0.03 + intensity * 0.6})`; // cyan — gradient by magnitude
10196      else if (isBigRow) rowBg = `rgba(34,255,94,${0.2 + intensity * 0.3})`;
10197      else { const g = Math.round(80 + intensity * 120); rowBg = `rgba(34,${g},54,${0.05 + intensity * 0.35})`; }
10198    } else {
10199      if (isCharm) rowBg = `rgba(245,158,11,${0.03 + intensity * 0.6})`; // amber — gradient by magnitude
10200      else if (isBigRow) rowBg = `rgba(255,50,50,${0.2 + intensity * 0.3})`;
10201      else { const r = Math.round(120 + intensity * 100); const g = Math.round(40 + intensity * 30); rowBg = `rgba(${r},${g},40,${0.05 + intensity * 0.3})`; }
10202    }
10203
10204    let labelClass = "strike-label";
10205    if (isSpot) labelClass += " spot";
10206    if (isKing) labelClass += " king";
10207
10208    let barColor;
10209    if (isKing) {
10210      barColor = isPos
10211        ? "linear-gradient(90deg, rgba(234,179,8,0.1), rgba(234,179,8,0.7))"
10212        : "linear-gradient(270deg, rgba(234,179,8,0.1), rgba(234,179,8,0.7))";
10213    } else if (isPos) {
10214      const intn = pct / 50;
10215      if (isCharm) barColor = `linear-gradient(90deg, rgba(34,211,238,0.05), rgba(34,211,238,${0.15 + intn * 0.6}))`;
10216      else { const g = Math.round(100 + intn * 155); barColor = `linear-gradient(90deg, rgba(34,${g},94,0.05), rgba(34,${g},94,${0.2 + intn * 0.5}))`; }
10217    } else {
10218      const intn = pct / 50;
10219      if (isCharm) barColor = `linear-gradient(270deg, rgba(245,158,11,0.05), rgba(245,158,11,${0.15 + intn * 0.6}))`;
10220      else { const r = Math.round(100 + intn * 139); barColor = `linear-gradient(270deg, rgba(${r},68,68,0.05), rgba(${r},68,68,${0.2 + intn * 0.5}))`; }
10221    }
10222
10223    let pctBadge = "";
10224    if (pct > 15) {
10225      const pctVal = Math.round((Math.abs(s.netGex) / maxGex) * 100);
10226      if (isPos) pctBadge = `<span class="gex-pct right" style="color:${isCharm ? '#67e8f9' : '#5ef08a'}">+${pctVal}%</span>`;
10227      else pctBadge = `<span class="gex-pct left" style="color:${isCharm ? '#fbbf24' : '#ff8a8a'}">-${pctVal}%</span>`;
10228    }
10229
10230    const isBigValue = topStrikes.includes(s.strike);
10231    const valueClass = isStar ? "gex-value star-val" : (isPos ? "gex-value pos" : "gex-value neg");
10232    let valueStyle = isBigValue ? 'font-weight:700;font-size:12px;text-shadow:0 0 6px currentColor' : '';
10233    if (isCharm && !isStar) valueStyle += (valueStyle ? ';' : '') + 'color:' + (isPos ? '#67e8f9' : '#fbbf24'); // cyan/amber
10234    const starIcon = isStar ? '<span class="star-icon">★</span>' : '';
10235
10236    // GRID v2 (admin test): alternate bar styles + strength-scaled row tint, straight from
10237    // the approved Desktop samples. Members always fall through to the original bar above.
10238    // pct runs 0-50 (each side is half the container in the original center-split design);
10239    // the new styles span the full container, so scale to 0-100.
10240    let _v2Bar = null, _v2Row = '';
10241    if (_gv2On()) {
10242      const _w = Math.min(100, pct * 2);
10243      const _col = isKing ? '#eab308' : (isCharm ? (isPos ? '#22d3ee' : '#f59e0b') : (isPos ? '#22c55e' : '#ef4444'));
10244      const _rg = a => { const h = _col.slice(1); return `rgba(${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)},${a})`; };
10245      if (gfGridTint !== 'off') {
10246        const _boost = gfGridTint === 'bold' ? 1.6 : 1;
10247        const _a = Math.min((isKing ? 0.30 : 0.10 + (_w / 100) * 0.24) * _boost, 0.5);
10248        _v2Row = `background:linear-gradient(90deg,${_rg(_a)},${_rg(Math.min(_a * 0.4, 0.3))} 60%,transparent 94%);border-left:3px solid ${_rg(0.85)};`;
10249      }
10250      if (gfGridBars === 'taper') {
10251        _v2Bar = `<div style="position:absolute;right:0;top:1px;bottom:1px;width:${(_w * 0.72).toFixed(1)}%;border-radius:6px 0 0 6px;background:linear-gradient(270deg,${_rg(0.62)}
10251,${_rg(0.08)})${isKing ? ';box-shadow:0 0 12px rgba(234,179,8,0.35)' : ''}"></div>`;
10252      } else if (gfGridBars === 'glow') {
10253        _v2Bar = `<div style="position:absolute;left:0;top:50%;height:4px;transform:translateY(-50%);border-radius:2px;width:${(_w * 0.8).toFixed(1)}%;background:${_col};opacity:${(0.35 + _w / 160).toFixed(2)};box-shadow:0 0 ${Math.round(4 + _w / 6)}px ${_rg(0.8)}"></div>`;
10254      } else if (gfGridBars === 'segments') {
10255        const _n = Math.max(1, Math.round(_w / 7)); let _sg = '';
10256        for (let _i = 0; _i < 14; _i++) _sg += `<i style="flex:1;border-radius:2px;background:${_i < _n ? _rg(0.25 + 0.55 * (_i / 13)) : 'rgba(148,163,184,.13)'}"></i>`;
10257        _v2Bar = `<div style="position:absolute;left:0;right:0;top:4px;bottom:4px;display:flex;gap:3px">${_sg}</div>`;
10258      } else if (gfGridBars === 'track') {
10259        _v2Bar = `<div style="position:absolute;left:0;right:0;top:50%;height:7px;transform:translateY(-50%);background:rgba(148,163,184,.10);border-radius:4px"><div style="position:absolute;left:0;top:0;bottom:0;border-radius:4px;width:${_w.toFixed(1)}%;background:linear-gradient(90deg,${_rg(0.25)},${_rg(0.85)})"></div><div style="position:absolute;top:50%;left:${_w.toFixed(1)}%;width:6px;height:6px;border-radius:50%;transform:translate(-50%,-50%);background:${_col};box-shadow:0 0 7px ${_rg(0.9)}"></div></div>`;
10260      }
10261    }
10262
10263    html += `
10264      <div class="${rowClass}" style="background:${rowBg};${_v2Row}" ${isSpot ? 'data-spot="1"' : ''}>
10265        <span class="${labelClass}">${isKing ? '👑 ' : ''}${isCallWall ? '📈 ' : ''}${isPutWall ? '📉 ' : ''}${isVolNode ? '🔵 ' : ''}${isActiveNode ? '⚡ ' : ''}${isSpot ? '► ' : ''}${_dispStrike(s.strike)}</span>
10266        ${isSpot && spot >
10266 0 ? `<span style="font-family:Segoe UI,monospace;font-size:9px;color:var(--purple);font-weight:700;margin-right:4px;flex-shrink:0">$${spot.toFixed(2)}</span>` : ''}
10267        <div class="gex-bar-container">
10268          ${_v2Bar !== null ? _v2Bar : `<div class="gex-bar ${isPos ? 'positive' : 'negative'} ${isKing ? 'king-bar' : ''} ${isStar ? 'star' : ''}" style="width:${pct}%; background:${barColor}"></div>`}
10269          ${pctBadge}
10270        </div>
10271        <span class="${valueClass}" style="${valueStyle}">${fmtGex(s.netGex)}${starIcon}</span>
10272        ${volMap ? _chartVolCell(volMap, s.strike) : ''}
10273      </div>
10274    `;
10275  }
10276  return html;
10277}
10278// One VOLUME cell for the chart ladder's optional VOL column — call (green) / put (red) split bar for
10279// the nearest expiry, sized against the range's busiest strike, with the total volume beside it.
10280function _chartVolCell(vm, strike) {
10281  if (!vm || !vm.m) return '';
10282  const r = vm.m[strike]; const tot = r ? (r.c + r.p) : 0;
10283  if (!tot) return '<span class="cgp-vol"><span class="cgp-vol-num" style="color:#475569">\u00b7</span></span>';
10284  const W = Math.max(3, Math.round(tot / (vm.tmax || 1) * 46));
10285  const gw = Math.round((r.c / tot) * W), pw = W - gw;
10286  // the split stays on hover, where it costs no width: both counts, the total, and the basis
10287  const basis = vm.basis === '0dte' ? '0DTE' : vm.basis === 'weekly' ? 'WEEKLY' : 'ALL expiries';
10288  const tip = 'C ' + _hvFmt(r.c) + ' \u00b7 P ' + _hvFmt(r.p) + ' \u00b7 total ' + _hvFmt(tot) + ' \u00b7 ' + basis + (vm.exp ? ' (' + vm.exp + ')' : '');
10289  return '<span class="cgp-vol" title="' + tip + '"><span class="cgp-vol-bar">'
10290    + (gw ? '<i class="cvb-c" style="width:' + gw + 'px"></i>' : '')
10291    + (pw ? '<i class="cvb-p" style="width:' + pw + 'px"></i>' : '')
10292    + '</span><span class="cgp-vol-num">' + _hvFmt(tot) + '</span></span>';
10293}
10294
10295// ── Render single column ──
10296function renderColumn(ticker) {
10297  const data = columnData[ticker];
10298  if (!data) return;
10299
10300  const { strikes, spot, callWall, putWall, kingNode, totalGex, volNode } = data;
10301
10302  // Safety check
10303  if (!Array.isArray(strikes) || strikes.length === 0) {
10304    const body = document.getElementById("col-body-" + ticker);
10305    if (body) body.innerHTML = '<div class="col-loading col-error">No strike data</div>';
10306    return;
10307  }
10308
10309  // Update price
10310  const priceEl = document.getElementById("price-" + ticker);
10311  if (priceEl && spot > 0) {
10312    priceEl.innerHTML = `<span style="font-weight:600">$${spot.toFixed(2)}</span>`;
10313  }
10314
10315  // Update king
10316  const kingEl = document.getElementById("king-" + ticker);
10317  if (kingEl) {
10318    const bias = totalGex >= 0;
10319    const biasPct = kingNode ? ((Math.abs(kingNode.netGex) / Math.max(Math.abs(totalGex), 1)) * 100).toFixed(1) : "0";
10320    const kingStrike = kingNode ? kingNode.strike.toFixed(0) : "—";
10321    const kingGex = kingNode ? fmtGex(kingNode.netGex) : "";
10322    kingEl.innerHTML = `
10323      <span class="king-dot"></span>
10324      <span class="king-label">King</span>
10325      <span style="color:var(--yellow);font-weight:700;font-size:13px">${kingStrike}</span>
10326      <span style="color:var(--dim);font-size:10px">${kingGex}</span>
10327      <span class="king-pct" style="color:${bias ? 'var(--green)' : 'var(--red)'}">${biasPct}%</span>
10328      <span style="color:${bias ? 'var(--green)' : 'var(--red)'}; font-size:10px">${bias ? '↑' : '↓'}</span>
10329      ${volNode ? `<span style="color:#3b82f6;font-weight:700;font-size:11px;margin-left:4px">🔵 ${volNode.strike.toFixed(0)}</span>` : ''}
10330      ${(activeNodes[ticker] && activeNodes[ticker].strike) ? `<span title="Active node — most-traded strike in the last ~30 min" style="color:#22d3ee;font-weight:700;font-size:11px;margin-left:4px">⚡ ${activeNodes[ticker].strike.toFixed(0)}</span>` : ''}
10331    `;
10332  }
10333
10334  // The chart ticker's GEX data just re-parsed (ticker/metric/expiry/live change) → resync the
10335  // bubbles from this exact fresh data, so they always match the panel's %. MUST run BEFORE the
10336  // col-body guard below: the grid only holds the SELECTED SECTOR's columns, so charting any
10337  // other ticker returned early here, the reseed lock never cleared, and every seeder stayed
10338  // blocked — no bubbles on a ticker switch until a full reload (user report).
10339  if (ticker === chartTicker && _gexBubAllowed() && typeof indState !== 'undefined' && indState.gexbub && !(typeof _replayActive !== 'undefined' && _replayActive)) {
10340    try {
10341      if (_gexBubReseedKey && _gexBubReseedKey === _gexBubKey()) { _gexBubReseedKey = ''; try { delete _gexBubTrail[_gexBubKey()]; } catch (e) {} }
10342      // Defer the history-seed FETCHES past the switch burst (candles/levels/contracts are in
10343      // flight right now and share the per-IP queue) — the preview/live render below is instant,
10344      // the recorded trail fills in ~1s later via the seed + self-heal.
10345      if (typeof _gexBubSeedHistory === 'function') { setTimeout(function(){ try { _gexBubSeedHistory(); } catch (e) {} }, 900); }
10346      if (typeof _gexBubSeedPreview === 'function') _gexBubSeedPreview();
10347      try { if (_gexBubOn && typeof _gexBubSample === 'function') _gexBubSample(); } catch (e) {}   // live column from THIS fresh data now, not on the next 20s tick
10348      if (typeof renderGexBubbles === 'function') renderGexBubbles();
10349    } catch (e) {}
10350  } else if (ticker === chartTicker && _gexBubAllowed() && typeof indState !== 'undefined' && indState.gexbub && typeof renderGexBubbles === 'function') {
10351    try { renderGexBubbles(); } catch (e) {}   // replay: the panel just scrubbed → repaint bubbles from the recorded trail, in lockstep
10352  }
10353
10354  const body = document.getElementById("col-body-" + ticker);
10355  if (!body) return;
10356  // Preserve where the user scrolled to — the live refresh rebuilds columnData (losing
10357  // any flag on it), so we track "already centered" on the body element, which survives
10358  // refreshes. Replacing innerHTML resets scrollTop, so capture + restore it.
10359  const prevScroll = body.scrollTop;
10360  body.innerHTML = buildGexRowsHtml(strikes, spot, callWall, putWall, kingNode, volNode, activeNodes[ticker], data.metricUsed, data.volMap);
10361
10362  if (body.dataset.scrolled === '1') {
10363    // Refresh — keep the user's scroll position; don't yank them back to spot.
10364    body.scrollTop = prevScroll;
10365  } else {
10366    // First load of this column — center the spot price row.
10367    setTimeout(() => {
10368      const spotRow = body.querySelector('.strike-row[data-spot="1"]');
10369      if (spotRow) {
10370        // Center the spot row vertically WITHOUT scrollIntoView — that would also scroll
10371        // the horizontal columns-container sideways (dragging the view to the right).
10372        body.scrollTop = spotRow.offsetTop - body.clientHeight / 2 + spotRow.offsetHeight / 2;
10373      }
10374      body.dataset.scrolled = '1';
10375    }, 100);
10376  }
10377  // (Bubble resync hook moved ABOVE the col-body guard — see the note at the top of this function.)
10378}
10379
10380// ── Actions ──
10381function switchTicker(oldTicker, newTicker) {
10382  const idx = activeColumns.indexOf(oldTicker);
10383  if (idx >= 0) {
10384    activeColumns[idx] = newTicker;
10385    renderColumns();
10386    activeColumns.forEach((t, _ri) => { const _rk = _slotKey(_ri); if (columnData[_rk]) renderColumn(_rk); });
10387    // Always fetch fresh data for the new ticker
10388    loadGEX(newTicker);
10389  }
10390}
10391
10392// HQ: drop one column by SLOT. Per-slot settings are keyed by index, so the ones after it shift
10393// down with their columns - the same carry reorderGexColumn does. Watchlists are not touched.
10394function gfGridRemoveSlot(si) {
10395  if (si < 0 || si >= activeColumns.length) return;
10396  const C = colCfg[activeGroup] || {};
10397  const cfgArr = activeColumns.map((t, j) => C[j] || null);
10398  activeColumns.splice(si, 1);
10399  cfgArr.splice(si, 1);
10400  const NC = {}; cfgArr.forEach((c, j) => { if (c) NC[j] = c; });
10401  if (Object.keys(NC).length) colCfg[activeGroup] = NC; else delete colCfg[activeGroup];
10402  _saveColCfg();
10403  if (activeColumns.length === 0) activeColumns = [WATCHLIST[activeGroup][0]];
10404  if (typeof isPaidUser !== 'undefined' && isPaidUser) { _colSets[activeGroup] = activeColumns.slice(); _saveColSets(); }
10405  renderColumns();
10406  reloadSlots();
10407}
10408function removeColumn(ticker) {
10409  activeColumns = activeColumns.filter(t => t !== ticker);
10410  if (activeColumns.length === 0) activeColumns = [WATCHLIST[activeGroup][0]];
10411  renderColumns();
10412  reloadSlots();
10413}
10414
10415function refreshAll() {
10416  console.log('refreshAll called, columns:', activeColumns);
10417  activeColumns.forEach((t, i) => {
10418    setTimeout(() => loadGEXSlot(i), i * 120);
10419  });
10420}
10421
10422async function copyGEXString() {
10423  try {
10424    const btn = document.querySelector('[onclick="copyGEXString()"]');
10425    if (btn) btn.textContent = '⏳ Loading...';
10426    const res = await fetch('/api/gex/all-levels');
10427    const json = await res.json();
10428    if (json.pineString) {
10429      await navigator.clipboard.writeText(json.pineString);
10430      if (btn) btn.textContent = '✅ Copied!';
10431      setTimeout(() => { if (btn) btn.textContent = '📋 Pine'; }, 3000);
10432      alert('GEX string copied! (' + json.count + ' tickers)\n\nPaste into GhostFlow GEX Levels indicator settings on TradingView.\n\nUpdated: ' + json.updated);
10433    }
10434  } catch (e) {
10435    alert('Error: ' + e.message);
10436  }
10437}
10438
10439// Screenshot — one click captures the current view (DOM only, no browser chrome) to a PNG
10440// that downloads to Downloads. Uses html2canvas (self-hosted, lazy-loaded on first use).
10441let _h2cLoading = null;
10442function _loadH2C() {
10443  if (window.html2canvas) return Promise.resolve();
10444  if (_h2cLoading) return _h2cLoading;
10445  _h2cLoading = new Promise((res, rej) => {
10446    const s = document.createElement('script');
10447    s.src = '/html2canvas.min.js';
10448    s.onload = () => res(); s.onerror = () => rej(new Error('could not load the capture library'));
10449    document.head.appendChild(s);
10450  });
10451  return _h2cLoading;
10452}
10453async function captureScreenshot() {
10454  if (!(isAdminUser || (typeof GF_IS_ADMIN_HINT !== 'undefined' && GF_IS_ADMIN_HINT))) return; // admin-only (user 2026-09-04)
10455  const btn = document.getElementById('screenshotBtn'); if (!btn) return;
10456  const prev = btn.textContent;
10457  try {
10458    btn.disabled = true; btn.textContent = '…';
10459    try { showToast('📸 Capturing…'); } catch (e) {}
10460    await _loadH2C();
10461    btn.style.visibility = 'hidden'; // keep the button itself out of the shot
10462    const _toastEl = document.getElementById('gfToast'); const _toastVis = _toastEl ? _toastEl.style.visibility : '';
10463    if (_toastEl) _toastEl.style.visibility = 'hidden'; // don't capture the toast
10464    const canvas = await html2canvas(document.body, {
10465      backgroundColor: '#0a0e17', useCORS: true, logging: false,
10466      scale: Math.min(2, window.devicePixelRatio || 1),
10467      width: window.innerWidth, height: window.innerHeight,
10468      windowWidth: window.innerWidth, windowHeight: window.innerHeight,
10469      scrollX: 0, scrollY: -window.scrollY,
10470      // Only skip zero-size canvases (safe). Do NOT skip hidden elements — that collapsed
10471      // their layout space and degraded the working views.
10472      ignoreElements: (el) => { try { return el.tagName === 'CANVAS' && (!el.offsetWidth || !el.offsetHeight); } catch (e) { return false; } },
10473      onclone: (doc) => {
10474        // The GEX ladder bars use a linear-gradient html2canvas rasterizes to a 0-size pattern
10475        // and crashes on (grid + chart). Swap the gradient for its solid colour — no crash, and
10476        // the bars still show. Nothing is removed, so layout is untouched.
10477        try { doc.querySelectorAll('.gex-bar').forEach(el => { const m = (getComputedStyle(el).backgroundImage || '').match(/rgba?\([^)]*\)/); el.style.backgroundImage = 'none'; el.style.backgroundColor = m ? m[0] : 'transparent'; }); } catch (e) {}
10478        // html2canvas clips the header search inputs' placeholder — render it as centered text.
10479        try { doc.querySelectorAll('#searchTicker, #customTicker').forEach(el => { if (!el.value && el.placeholder) { el.setAttribute('value', el.placeholder); el.style.color = '#8b9cb5'; } el.style.lineHeight = 'normal'; el.style.paddingTop = '0'; el.style.paddingBottom = '0'; }); } catch (e) {}
10480      },
10481    });
10482    btn.style.visibility = '';
10483    if (_toastEl) _toastEl.style.visibility = _toastVis;
10484    // html2canvas renders the lightweight-charts canvas blank — composite the real chart back in.
10485    try {
10486      const chEl = document.getElementById('tvChart');
10487      if (typeof chartObj !== 'undefined' && chartObj && typeof chartObj.takeScreenshot === 'function' && chEl && chEl.offsetWidth && chEl.offsetHeight) {
10488        const rect = chEl.getBoundingClientRect();
10489        if (rect.width > 0 && rect.bottom > 0 && rect.top < window.innerHeight) {
10490          const shot = chartObj.takeScreenshot();
10491          const sx = canvas.width / window.innerWidth;
10492          // The GEX ladder panel is absolute-positioned over the chart's left edge — don't draw
10493          // the chart over it, or the ladder (rendered by html2canvas) disappears in ghost mode.
10494          const gp = document.getElementById('chartGexPanel');
10495          const cropL = (gp && getComputedStyle(gp).display !== 'none' && gp.offsetWidth > 0) ? gp.offsetWidth : 0;
10496          const ss = shot.width / rect.width;
10497          const srcX = cropL * ss;
10498          canvas.getContext('2d').drawImage(shot, srcX, 0, shot.width - srcX, shot.height,
10499            Math.round((rect.left + cropL) * sx), Math.round(rect.top * sx), Math.round((rect.width - cropL) * sx), Math.round(rect.height * sx));
10500        }
10501      }
10502    } catch (e) {}
10503    const view = (typeof currentView !== 'undefined' ? currentView : 'view');
10504    const d = new Date(), p = n => String(n).padStart(2, '0');
10505    const stamp = d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
10506    const a = document.createElement('a');
10507    a.download = 'ghostflow-' + view + '-' + stamp + '.png';
10508    a.href = canvas.toDataURL('image/png');
10509    document.body.appendChild(a); a.click(); a.remove();
10510    try { showToast('✅ Screenshot taken — saved to Downloads'); } catch (e) {}
10511  } catch (e) {
10512    btn.style.visibility = '';
10513    try { showToast('Screenshot failed: ' + ((e && e.message) || e)); } catch (_) {}
10514  } finally { btn.disabled = false; btn.textContent = prev; }
10515}
10516
10517// Search any ticker — loads in current view without adding to watchlist
10518// ── Ticker search autocomplete: as the user types, show matching tickers from the
10519// watchlist (symbol prefix first, then substring), each clickable to jump straight there.
10520let _searchSel = -1; // keyboard-highlighted row index (-1 = none)
10521function searchSuggest() {
10522  const input = document.getElementById('searchTicker');
10523  const box = document.getElementById('searchSuggest');
10524  if (!input || !box) return;
10525  const q = (input.value || '').trim().toUpperCase();
10526  if (!q) { hideSearchSuggest(); return; }
10527  const all = [...new Set(Object.values(WATCHLIST).flat())].filter(Boolean);
10528  const starts = all.filter(t => t.startsWith(q));
10529  const contains = all.filter(t => !t.startsWith(q) && t.includes(q));
10530  const matches = starts.concat(contains).slice(0, 8);
10531  if (!matches.length) { hideSearchSuggest(); return; }
10532  _searchSel = -1;
10533  box.innerHTML = matches.map(t =>
10534    '<div class="search-opt" data-t="' + t + '" onmousedown="pickSearchTicker(\'' + t + '\')"'
10535    + ' onmouseover="this.style.background=\'#1e293b\'" onmouseout="this.style.background=\'\'"'
10536    + ' style="padding:7px 12px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:10px;border-bottom:1px solid var(--t-border,#1e293b)">'
10537    + '<span style="font-weight:700;color:#e2e8f0;letter-spacing:0.5px">' + t + '</span>'
10538    + '<span style="font-size:10px;color:#8b9cb5;white-space:nowrap">' + (sectorOfTicker(t) || 'Custom') + '</span>'
10539    + '</div>'
10540  ).join('');
10541  box.style.display = 'block';
10542  // On phone the dropdown was trapped in the sticky header's stacking layer, so the expiry row below
10543  // painted over it ("search text shows under the expiry row", user 2026-09-20). position:fixed alone
10544  // doesn't escape a sticky ancestor on mobile browsers, so MOVE the box out to <body> and float it
10545  // under the input with a z-index above everything.
10546  try{
10547    if (typeof gfPhoneIs==='function' && gfPhoneIs()) {
10548      if (box.parentElement !== document.body) document.body.appendChild(box);
10549      const r = input.getBoundingClientRect();
10550      box.style.position = 'fixed';
10551      box.style.top = Math.round(r.bottom + 2) + 'px';
10552      box.style.left = Math.round(r.left) + 'px';
10553      box.style.width = Math.min(260, Math.max(180, Math.round(window.innerWidth - r.left - 8))) + 'px';
10554      box.style.zIndex = '2147483000';
10555    }
10556  }catch(e){}
10557}
10558function hideSearchSuggest() {
10559  const box = document.getElementById('searchSuggest');
10560  if (box) box.style.display = 'none';
10561  _searchSel = -1;
10562}
10563function pickSearchTicker(t) {
10564  const input = document.getElementById('searchTicker');
10565  if (input) input.value = t;
10566  hideSearchSuggest();
10567  searchTickerFn();
10568}
10569function _paintSearchSel(opts) {
10570  opts.forEach((o, i) => { o.style.background = (i === _searchSel) ? '#1e293b' : ''; });
10571}
10572function searchKeyNav(e) {
10573  const box = document.getElementById('searchSuggest');
10574  const open = box && box.style.display === 'block';
10575  const opts = open ? Array.from(box.querySelectorAll('.search-opt')) : [];
10576  if (e.key === 'ArrowDown' && opts.length) {
10577    e.preventDefault(); _searchSel = Math.min(_searchSel + 1, opts.length - 1); _paintSearchSel(opts);
10578  } else if (e.key === 'ArrowUp' && opts.length) {
10579    e.preventDefault(); _searchSel = Math.max(_searchSel - 1, 0); _paintSearchSel(opts);
10580  } else if (e.key === 'Enter') {
10581    if (open && _searchSel >= 0 && opts[_searchSel]) pickSearchTicker(opts[_searchSel].getAttribute('data-t'));
10582    else searchTickerFn();
10583  } else if (e.key === 'Escape') {
10584    hideSearchSuggest();
10585  }
10586}
10587
10588function searchTickerFn() {
10589  const currentView = gfActiveView();   // workspace: the focused tile decides
10590  const input = document.getElementById('searchTicker');
10591  const ticker = (input.value || '').trim().toUpperCase();
10592  if (!ticker || ticker.length < 1 || ticker.length > 6) return;
10593  input.value = '';
10594  hideSearchSuggest();
10595
10596  if (currentView === 'gex') {
10597    // Find the ticker's sector. If it's brand-new, show it under Custom for THIS SESSION only
10598    // (view-only) — do NOT persist it. The "+ Add Ticker" button is what saves permanently.
10599    let sector = sectorOfTicker(ticker);
10600    if (!sector) {
10601      sector = 'Custom';
10602      if (!WATCHLIST.Custom) WATCHLIST.Custom = [];
10603      if (!WATCHLIST.Custom.includes(ticker)) WATCHLIST.Custom.push(ticker);
10604    }
10605    // Jump to that sector with the searched ticker focused (loads first; rest load in).
10606    switchGroupFocused(sector, ticker);
10607  } else if (currentView === 'expiry') {
10608    expiryTicker = ticker;
10609    buildExpiryTickers();
10610    loadExpiryGEX(ticker);
10611  } else if (currentView === 'chart') {
10612    if (typeof _mRouteTicker === 'function' && _mRouteTicker(ticker)) return;   // Ghost Desk: fill the focused chart
10613    chartTicker = ticker;
10614    buildChartTickers();
10615    loadGEXChart(ticker);
10616  } else if (currentView === 'ultra') {
10617    ultraTicker = ticker;
10618    buildUltraTickers();
10619    loadUltraView(ticker);
10620  } else if (currentView === 'flow') {
10621    // Ensure the searched ticker is in scope (add to Custom for this session if new), then filter the tape to it.
10622    const inList = Object.values(WATCHLIST).flat().map(x => String(x).toUpperCase()).includes(ticker);
10623    if (!inList) {
10624      if (!WATCHLIST.Custom) WATCHLIST.Custom = [];
10625      WATCHLIST.Custom.push(ticker);
10626      if (typeof _flowScopeSig !== 'undefined') _flowScopeSig = '';   // force scope rebuild so its flow shows
10627    }
10628    if (typeof flowFilterTicker === 'function') flowFilterTicker(ticker);
10629  }
10630}
10631
10632function addCustomTicker() {
10633  const currentView = gfActiveView();   // workspace: the focused tile decides
10634  const input = document.getElementById('customTicker');
10635  const ticker = (input.value || '').trim().toUpperCase();
10636  if (!ticker) return;
10637
10638  // Check if already exists
10639  const allTickers = Object.values(WATCHLIST).flat();
10640  if (allTickers.includes(ticker)) {
10641    input.value = '';
10642    alert(ticker + ' already in watchlist');
10643    return;
10644  }
10645
10646  // Auto-detect sector
10647  const sectorMap = {
10648    // Semis/AI
10649    'NVDA':'Semis/AI','AMD':'Semis/AI','AVGO':'Semis/AI','ARM':'Semis/AI','MRVL':'Semis/AI','QCOM':'Semis/AI','MU':'Semis/AI','INTC':'Semis/AI','SMCI':'Semis/AI','ALAB':'Semis/AI','NBIS':'Semis/AI','TSM':'Semis/AI','CRDO':'Semis/AI','ASML':'Semis/AI','KLAC':'Semis/AI','LRCX':'Semis/AI','AMAT':'Semis/AI','MCHP':'Semis/AI','ON':'Semis/AI','TXN':'Semis/AI',
10650    // Megacap
10651    'AAPL':'Megacap','MSFT':'Megacap','GOOG':'Megacap','GOOGL':'Megacap','AMZN':'Megacap','META':'Megacap','TSLA':'Megacap','BRK.B':'Megacap','JPM':'Megacap','V':'Megacap','UNH':'Megacap','MA':'Megacap','LLY':'Megacap','NFLX':'Megacap',
10652    // Quantum/AI
10653    'IONQ':'Quantum/AI','QBTS':'Quantum/AI','RGTI':'Quantum/AI','QUBT':'Quantum/AI','SOUN':'Quantum/AI','RBLX':'Quantum/AI','AI':'Quantum/AI','BBAI':'Quantum/AI',
10654    // Nuclear/Energy
10655    'VST':'Nuclear/Energy','OKLO':'Nuclear/Energy','SMR':'Nuclear/Energy','UEC':'Nuclear/Energy','CCJ':'Nuclear/Energy','LEU':'Nuclear/Energy','NNE':'Nuclear/Energy',
10656    // Defense
10657    'AVAV':'Defense','MRCY':'Defense','AXON':'Defense','LMT':'Defense','NOC':'Defense','RTX':'Defense','BA':'Defense','KTOS':'Defense','LDOS':'Defense','HEI':'Defense',
10658    // Space
10659    'RKLB':'Space','ASTS':'Space','LUNR':'Space','FLY':'Space','RDW':'Space','SATS':'Space','MNTS':'Space','BKSY':'Space',
10660    // Software
10661    'CRM':'Software','ADBE':'Software','ORCL':'Software','NOW':'Software','PLTR':'Software','SNOW':'Software','DDOG':'Software','CRWD':'Software','PANW':'Software','ZS':'Software','WDAY':'Software','TEAM':'Software','RDDT':'Software','NET':'Software','SHOP':'Software','SQ':'Software','UBER':'Software','LYFT':'Software','DASH':'Software','ABNB':'Software','U':'Software',
10662    // Fintech/Crypto
10663    'HOOD':'Fintech/Crypto','AFRM':'Fintech/Crypto','PYPL':'Fintech/Crypto','SOFI':'Fintech/Crypto','COIN':'Fintech/Crypto','MSTR':'Fintech/Crypto','MARA':'Fintech/Crypto','RIOT':'Fintech/Crypto','CLSK':'Fintech/Crypto','HUT':'Fintech/Crypto','IREN':'Fintech/Crypto','WULF':'Fintech/Crypto',
10664    // Consumer
10665    'CHWY':'Consumer','BOOT':'Consumer','CAVA':'Consumer','HIMS':'Consumer','WMT':'Consumer','TGT':'Consumer','COST':'Consumer','NKE':'Consumer','DIS':'Consumer','SBUX':'Consumer','MCD':'Consumer','LULU':'Consumer','RVLV':'Consumer','DKNG':'Consumer',
10666    // EV/China
10667    'NIO':'EV/China','XPEV':'EV/China','BABA':'EV/China','BIDU':'EV/China','PDD':'EV/China','LI':'EV/China','JD':'EV/China','RIVN':'EV/China','LCID':'EV/China',
10668    // Energy
10669    'XOM':'Energy','CVX':'Energy','SLB':'Energy','OXY':'Energy','COP':'Energy','HAL':'Energy','DVN':'Energy',
10670    // Healthcare
10671    'MRNA':'Healthcare','PFE':'Healthcare','JNJ':'Healthcare','ABBV':'Healthcare','BMY':'Healthcare','GILD':'Healthcare','BIIB':'Healthcare',
10672    // Squeeze
10673    'GME':'Squeeze','AMC':'Squeeze','CVNA':'Squeeze','GRPN':'Squeeze','BYND':'Squeeze','OPEN':'Squeeze','BBBY':'Squeeze',
10674    // Industrial
10675    'CAT':'Industrial','DE':'Industrial','HON':'Industrial','GE':'Industrial','MMM':'Industrial',
10676    // Index
10677    'SPY':'Index','QQQ':'Index','SPXW':'Index','SPX':'Index','NDX':'Index','IWM':'Index','DIA':'Index',
10678  };
10679
10680  // Unmapped tickers land in the sector you're currently viewing (so "add to Index" works),
10681  // falling back to Custom off the grid.
10682  let sector = sectorMap[ticker] || ((currentView === 'gex' && typeof activeGroup !== 'undefined' && activeGroup) ? activeGroup : 'Custom');
10683
10684  // Add to watchlist (un-hide if it was previously deleted by this user)
10685  hiddenTickers.delete(ticker);
10686  if (!WATCHLIST[sector]) WATCHLIST[sector] = [];
10687  if (!WATCHLIST[sector].includes(ticker)) WATCHLIST[sector].push(ticker);
10688
10689  // Save to server
10690  fetch('/api/custom-tickers/add', {
10691    method: 'POST',
10692    headers: { 'Content-Type': 'application/json' },
10693    body: JSON.stringify({ ticker, sector }),
10694  }).catch(function(){});
10695  if (typeof _flowPullTicker === 'function') _flowPullTicker(ticker);   // pull its flow right away
10696
10697  input.value = '';
10698  buildTabs();
10699
10700  if (currentView === 'gex') {
10701    if (!activeColumns.includes(ticker)) {
10702      activeColumns.push(ticker);
10703      renderColumns();
10704      activeColumns.forEach((t, _ri) => { const _rk = _slotKey(_ri); if (columnData[_rk]) renderColumn(_rk); });
10705      if (typeof buildGexTickers === 'function') buildGexTickers();   // reflect it in the ticker line
10706      requestAnimationFrame(fitColumnsHeight);                        // keep the bottom scrollbar on-screen
10707    }
10708    loadGEX(ticker);
10709  } else if (currentView === 'expiry') {
10710    expiryTicker = ticker;
10711    buildExpiryTickers();
10712    loadExpiryGEX(ticker);
10713  } else if (currentView === 'chart') {
10714    if (typeof _mRouteTicker === 'function' && _mRouteTicker(ticker)) return;   // Ghost Desk: fill the focused chart
10715    chartTicker = ticker;
10716    buildChartTickers();
10717    loadGEXChart(ticker);
10718  } else if (currentView === 'ultra') {
10719    ultraTicker = ticker;
10720    buildUltraTickers();
10721    loadUltraView(ticker);
10722  } else if (currentView === 'flow') {
10723    if (typeof renderFlowTickers === 'function') renderFlowTickers();
10724  }
10725}
10726
10727// ── Init ──
10728buildTabs();
10729renderColumns();
10730activeColumns.forEach((t, i) => {
10731  setTimeout(() => loadGEXSlot(i), i * 120);
10732});
10733
10734// Restore the last-used view on reload (default GEX). Deferred so every view
10735// var/function is initialized before setView() runs. Capture the saved view SYNCHRONOUSLY first —
10736// setView() below can overwrite gexView (e.g. the premium-gated 'ideas' bounces to 'gex' before
10737// /api/me confirms a paid member), and the /api/me handler re-applies from window._bootView.
10738// PER-BROWSER-TAB view memory: sessionStorage survives a reload of THIS tab only, so several open
10739// GhostFlow tabs each come back to their own view — localStorage (shared across tabs) is only the
10740// fallback a brand-new tab inherits. Without this, reloading made every open tab show the same view.
10741try { window._bootView = sessionStorage.getItem('gexView') || localStorage.getItem('gexView'); } catch (e) {}
10742// Same memory for the Ideas sub-tab (user 2026-09-12: "in ideas tab need to fix for all small tabs
10743// like spx, earning, watchist etc"). Captured synchronously for the same reason as the view: the
10744// admin-only tabs bounce to Setups before /api/me answers, and the re-apply below needs the
10745// original choice to still exist after that bounce.
10746try { window._bootIdeasTab = sessionStorage.getItem('gfIdeasTab') || localStorage.getItem('gfIdeasTab'); } catch (e) {}
10747setTimeout(() => {
10748  try {
10749    const saved = window._bootView;
10750    // 'smart' was missing from this list, so a reload on Smart Money bounced to GEX (user).
10751    // 'chain' was missing here the same way 'smart' once was, so a reload on the option chain
10752    // dropped you back on the grid (user 2026-09-12: "reload on any tab sould stay on that tab").
10753    if (saved && ['gex', 'expiry', 'chart', 'ultra', 'scanner', 'tracking', 'indicators', 'flow', 'ideas', 'smart', 'chain', 'workspace'].includes(saved)) setView(saved);
10754    else setView('gex'); // default — also fits the columns + on-screen horizontal scrollbar
10755  } catch (e) {}
10756}, 0);
10757
10758// ── Manual refresh only — no auto-refresh ──
10759
10760// ── View Toggle ──
10761let currentView = 'gex';
10762let expiryTicker = null;
10763let activeExpirySector = null; // highlighted sector in the heatmap view
10764let chartTicker = null;
10765
10766// Cross-tab ticker sync intentionally DISABLED — each browser tab keeps its own ticker,
10767// so you can open several tabs on different symbols. (Was: mirror activeTicker across tabs.)
10768
10769// Default ticker for the single-ticker views (heatmap/chart/ultra). In demo, SPX/NDX/etc.
10770// aren't free, so fall back to the first demo column (SPY) instead of the Index group's SPXW.
10771function defaultViewTicker() {
10772  if (IS_DEMO) return (activeColumns && activeColumns[0]) || 'SPY';
10773  return (WATCHLIST[activeGroup] || [])[0] || (activeColumns && activeColumns[0]) || 'SPY';
10774}
10775// ── GHOST DESK (Ghost Mode, 1/2/3 charts side by side) ─────────────────────────
10776// Design note — why the full chart MOVES instead of being cloned:
10777// All the heavy machinery (GEX ladder overlay, bubbles, drawings, modes, replay, trade plan,
10778// flow) is bound to ONE chart, #chartContent, through module-level globals. Cloning that per
10779// pane would mean making every one of those globals per-instance. Instead each SCREEN POSITION
10780// is a "slot" holding its own {ticker, tf, basis}, and the focused slot is the one rendered by
10781// the full chart. CSS `order` puts each chart at its slot's screen position, so clicking a chart
10782// focuses it WITHOUT the charts appearing to swap places — every panel, overlay and drawing
10783// follows focus for free, and each slot keeps its own settings across focus changes and reloads.
10784// Unfocused panes are lightweight: candles + level lines + a compact level str
10784ip in the header,
10785// so the King/Call/Put read for all three tickers is visible at once with no panel per chart.
10786let _mLayout = 1;
10787let _mFocus = 0;                    // focused SLOT index = 0-based screen position
10788let _mSlots = [];                   // per screen position: { ticker, tf, basis }
10789let _mDeskOpened = false;           // panels are auto-minimized once, on first entry
10790let _mPanelWas = null;              // panel state before the desk touched it, restored on exit
10791const _mCharts = {};                // lightweight instances for the unfocused slots, by pane id
10792const _MTFS = ['5m', '15m', '1h', '4h', '1d'];
10793const _MBASES = [['all', 'ALL'], ['0dte', '0DTE'], ['weekly', 'WEEK']];
10794const _MDEFAULT_TK = ['SPY', 'QQQ', 'IWM'];
10795
10796function _mLoadSlots(){
10797  let s = null; try { s = JSON.parse(localStorage.getItem('gf_desk_slots')); } catch (e) {}
10798  if (!Array.isArray(s)) s = [];
10799  _mSlots = [0, 1, 2].map(i => {
10800    const v = (s[i] && typeof s[i] === 'object') ? s[i] : {};
10801    return {
10802      ticker: String(v.ticker || _MDEFAULT_TK[i]).toUpperCase(),
10803      tf: _MTFS.includes(v.tf) ? v.tf : '1h',
10804      basis: ['all', '0dte', 'weekly'].includes(v.basis) ? v.basis : 'all'
10805    };
10806  });
10807}
10808function _mSaveSlots(){ try { localStorage.setItem('gf_desk_slots', JSON.stringify(_mSlots)); } catch (e) {} }
10809// The FOCUSED slot is the full chart, so keep it mirroring whatever that chart currently shows —
10810// this is what makes a ticker/timeframe/basis picked from the top bar stick to the clicked chart.
10811function _mSyncFocusedSlot(){
10812  if (_mLayout < 2 || !_mSlots[_mFocus]) return;
10813  const s = _mSlots[_mFocus];
10814  try { if (chartTicker) s.ticker = String(chartTicker).toUpperCase(); } catch (e) {}
10815  try { if (currentTimeframe) s.tf = currentTimeframe; } catch (e) {}
10816  try { if (gridExpiryMode) s.basis = gridExpiryMode; } catch (e) {}
10817  _mSaveSlots();
10818}
10819function setChartLayout(n){
10820  // Desk toolbar test: the floating toolbar may be sitting inside a pane that is about to be removed - park it on chart 1 first
10821  try{ const _c=document.getElementById('gfHdrChartCtl'), _cc=document.getElementById('chartContent'); if(_c&&_cc&&_c.parentElement!==_cc){ _cc.appendChild(_c); _c.classList.remove('gf-in-pane'); } }catch(e){}
10822  n = (n === 2 || n === 3) ? n : 1;
10823  if (!_mSlots.length) _mLoadSlots();
10824  if (_mLayout > 1) _mSyncFocusedSlot();      // leaving//resizing: keep the current slot's settings
10825  _mLayout = n;
10826  try { localStorage.setItem('gf_chart_layout', n); } catch (e) {}
10827  if (_mFocus >= n) _mFocus = 0;
10828  // The slot under focus always mirrors the single chart, so opening the desk keeps your ticker.
10829  try { if (chartTicker) { _mSlots[_mFocus].ticker = String(chartTicker).toUpperCase(); _mSlots[_mFocus].tf = currentTimeframe || '1h'; } } catch (e) {}
10830  document.body.classList.toggle('gf-desk', n > 1);
10831  document.querySelectorAll('#mchartToggle .mchart-n').forEach(b => b.classList.toggle('active', +b.dataset.n === n));
10832  // Both side panels minimize EVERY time the desk is switched on, not just the first time — with
10833  // 2-3 charts the row has no width to spare and the flow panel kept popping back open (user
10834  // report). The pre-desk state is captured once so a return to 1 chart restores it exactly.
10835  const _inHq = (typeof _wsOn !== 'undefined' && _wsOn);   // HQ: the panels are windows of their own - leave them be
10836  if (n > 1 && !_inHq) {
10837    if (!_mDeskOpened) {
10838      _mDeskOpened = true;
10839      try {
10840        const _tp = document.getElementById('chartTradePlan'), _fl = document.getElementById('ultraFlow');
10841        _mPanelWas = {
10842          gex: document.body.classList.contains('gf-gexmin'),
10843          tp: !!(_tp && _tp.style.display === 'none'),
10844          flow: !!(_fl && _fl.classList.contains('nf-panel-min'))
10845        };
10846      } catch (e) { _mPanelWas = null; }
10847    }
10848    try { nfGexMinimize(true); } catch (e) {}
10849    try { nfSetTradePlanMin(true); } catch (e) {}
10850    try { nfTogglePanel(true); } catch (e) {}     // the flow panel is the one that kept re-opening
10851  }
10852  if (n === 1 && _mDeskOpened && !_inHq) {
10853    _mDeskOpened = false;
10854    try { if (_mPanelWas) { nfGexMinimize(_mPanelWas.gex); nfSetTradePlanMin(_mPanelWas.tp); nfTogglePanel(_mPanelWas.flow); } } catch (e) {}
10855    _mPanelWas = null;
10856  }
10857  _mApply();
10858  _mSaveSlots();
10859  if (n > 1) _mLiveStart(); else { _mLiveStop(); _mFocus = 0; _mPaintFocus(); }
10860  try { if (typeof gfTideSyncAll === 'function') { gfTideSyncAll(); setTimeout(gfTideSyncAll, 400); } } catch (e) {}   // a new pane's tide strip fills at once, not on the 60s timer
10861}
10862// Slot → chart is a FIXED mapping: slot 0 is the full chart (#chartContent), slots 1-2 are panes 2
10863// and 3. Nothing is ever relocated. Moving the full chart to the focused slot would mean only ONE
10864// chart could ever show its overlays — clicking chart 2 would strip chart 1's GEX visual, which is
10865// not what a multi-chart desk is for (user report). Every chart therefore renders its OWN modes,
10866// all at once, and focus decides only which chart the top bar's controls edit.
10867// Desk 2/3 (user 2026-09-06): the GEX ladder moves out of #chartContent to the head of #chartRow so the
10868// three charts sit to its right and share the remaining width; back inside chart 1 for the single chart.
10869function _gfLadderPlace(){
10870  try {
10871    const p = document.getElementById('chartGexPanel'), row = document.getElementById('chartRow'), content = document.getElementById('chartContent');
10872    if (!p || !row || !content) return;
10873    const desk = (typeof _mLayout !== 'undefined' && _mLayout > 1);
10874    if (desk) { if (p.parentElement !== row) row.insertBefore(p, row.firstChild); }
10875    else if (p.parentElement !== content) content.appendChild(p);
10876  } catch (e) {}
10877}
10878// Chart 1's Layers edits that are NOT plain on/off toggles (MA rows, colours, Lines/Axis display) used to
10879// leave panes 2-3 stale until their next reload (user 2026-09-06: "only changes desk 1").
10880function _mRefreshPanes(full){
10881  try {
10882    if (typeof _mLayout === 'undefined' || _mLayout < 2) return;
10883    for (const id of [2, 3]) { const c = _mCharts[id]; if (!c || c.slot == null) continue; if (full) _mLoad(id); else { _mApplyModes(id); _mRenderOverlay(id); } }
10884  } catch (e) {}
10885}
10886function _mApply(){
10887  const n = _mLayout;
10888  const content = document.getElementById('chartContent');
10889  const ids = [2, 3];
10890  ids.forEach((id, k) => {
10891    const slot = k + 1;
10892    if (slot < n) { _mEnsurePane(id); const c = _mCharts[id]; if (c) { c.slot = slot; _mBindSlot(id); } }
10893    else _mRemovePane(id);
10894  });
10895  if (content) {
10896    content.style.flex = '1'; content.style.minWidth = '0'; content.style.order = 0;
10897    // Chart 1 must be able to TAKE FOCUS BACK. The panes each have a mousedown-to-focus handler,
10898    // but the full chart never did — so once a pane was focused, clicking chart 1 changed nothing
10899    // and every ticker pick kept landing on the pane (user report: "click 1st, it changes 2nd").
10900    if (!content._mFocusHook) {
10901      content._mFocusHook = true;
10902      content.addEventListener('mousedown', () => { try { _mSetFocus(0); } catch (e) {} }, true);
10903    }
10904  }
10905  _gfLadderPlace();
10906  _mPaintFocus();
10907  const refit = () => {
10908    try { if (typeof chartObj !== 'undefined' && chartObj && typeof chartEl !== 'undefined' && chartEl) chartObj.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight }); } catch (e) {}
10909    for (const id of ids) _mResize(id);
10910  };
10911  refit(); setTimeout(refit, 70);
10912}
10913// The cyan ring marks which chart the TOP BAR's ticker / timeframe / ALL-0DTE-WEEK controls act on.
10914function _mPaintFocus(){
10915  const on = _mLayout > 1;
10916  const content = document.getElementById('chartContent');
10917  if (content) content.classList.toggle('mchart-focused', on && _mFocus === 0);
10918  for (const id of [2, 3]) {
10919    const p = document.getElementById('mchart-pane-' + id); const c = _mCharts[id];
10920    if (p) p.classList.toggle('mchart-focused', on && !!c && c.slot === _mFocus);
10921  }
10922  try { _mDeskHeadSync(); } catch (e) {}
10923}
10924// ── FLOATING MANUAL DESK (admin only, user 2026-09-22): the /desk page in a draggable, resizable window that
10925// lives on every view (fixed in <body>, survives setView), with Minimize (collapses to its title bar), Pop out
10926// (Chrome's always-on-top Document Picture-in-Picture window, so it can be dragged anyw
10926here on the screen;
10927// plain popup where PiP is unavailable), a link back to the simulation page, and Close. Position, size and
10928// open/minimized state persist in localStorage. The desk itself runs inside a same-origin iframe, so nothing
10929// in here can collide with dashboard state; every request it makes is admin-gated server-side.
10930let _dfEl = null, _dfPip = null;
10931function _dfState(){ try { return JSON.parse(localStorage.getItem('gf_desk_float') || '{}') || {}; } catch (e) { return {}; } }
10932function _dfSave(p){ try { localStorage.setItem('gf_desk_float', JSON.stringify(Object.assign(_dfState(), p))); } catch (e) {} }
10933function gfDeskFloat(force){
10934  if (!(typeof isAdminUser !== 'undefined' && isAdminUser)) return;
10935  const st = _dfState();
10936  if (!_dfEl) {
10937    const el = document.createElement('div'); el.id = 'gfDeskFloat';
10938    const w = Math.max(320, Math.min(window.innerWidth - 20, +st.w || 420)), h = Math.max(260, Math.min(window.innerHeight - 20, +st.h || 640));
10939    const x = Math.max(0, Math.min(window.innerWidth - w, st.x != null ? +st.x : window.innerWidth - w - 16)), y = Math.max(0, Math.min(window.innerHeight - 40, st.y != null ? +st.y : 70));
10940    el.style.cssText = 'position:fixed;left:' + x + 'px;top:' + y + 'px;width:' + w + 'px;height:' + h + 'px;z-index:100000;background:#0b1220;border:1px solid #1e293b;border-radius:10px;box-shadow:0 18px 60px rgba(0,0,0,.7);display:flex;flex-direction:column;overflow:visible;min-width:320px;min-height:44px';
10941    el.innerHTML = '<div id="gfDeskFloatHd" style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:#0f172a;border-bottom:1px solid #1e293b;cursor:move;user-select:none;flex:0 0 auto;font-family:Segoe UI,system-ui,sans-serif">'
10942      + '<span style="font-size:12px;font-weight:800;color:#e2e8f0;letter-spacing:.5px">MANUAL DESK</span><span style="font-size:10px;color:#cbd5e1">admin</span>'
10943      + '<span style="margin-left:auto;display:flex;gap:4px">'
10944      + '<button onclick="gfDeskPop()" title="Pop out into an always-on-top window you can drag anywhere on screen" style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:5px;padding:2px 8px;font-size:11px;font-weight:700;cursor:pointer">Pop out</button>'
10945      + '<a href="/rh-sim" target="_blank" title="Open the simulation page" style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:5px;padding:2px 8px;font-size:11px;font-weight:700;text-decoration:none">Sim page</a>'
10946      + '<button onclick="gfDeskMin()" title="Minimize to the title bar" style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:5px;padding:2px 9px;font-size:12px;font-weight:800;cursor:pointer">&#8211;</button>'
10947      + '<button onclick="gfDeskFloat(false)" title="Close" style="background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:5px;padding:2px 9px;font-size:12px;font-weight:800;cursor:pointer">&times;</button>'
10948      + '</span></div>'
10949      + '<iframe id="gfDeskFrame" src="/desk" style="flex:1;min-height:0;border:0;width:100%;background:#0b1220"></iframe>'
10950      // RESIZE FROM EVERY SIDE (user 2026-09-22): eight grips - four edges, four corners.
10951      + ['n','s','e','w','ne','nw','se','sw'].map(function(d){
10952          const c = d.length === 2 ? 'width:14px;height:14px;' : (d === 'n' || d === 's' ? 'left:14px;right:14px;height:7px;' : 'top:14px;bottom:14px;width:7px;');
10953          const pos = (d.indexOf('n') >= 0 ? 'top:-4px;' : '') + (d.indexOf('s') >= 0 ? 'bottom:-4px;' : '') + (d.indexOf('e') >= 0 ? 'right:-4px;' : '') + (d.indexOf('w') >= 0 ? 'left:-4px;' : '');
10954          return '<div class="gf-dfrz" data-d="' + d + '" style="position:absolute;z-index:3;' + c + pos + 'cursor:' + d + '-resize"></div>';
10955        }).join('');
10956    document.body.appendChild(el); _dfEl = el;
10957    let rz = null;
10958    el.querySelectorAll('.gf-dfrz').forEach(function(g){
10959      g.addEventListener('mousedown', function(ev){
10960        if (el.dataset.min) return; ev.preventDefault(); ev.stopPropagation();
10961        rz = { d: g.dataset.d, x: ev.clientX, y: ev.clientY, l: el.offsetLeft, t: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight };
10962        const fr = el.querySelector('iframe'); if (fr) fr.style.pointerEvents = 'none';
10963      });
10964    });
10965    window.addEventListener('mousemove', function(ev){
10966      if (!rz) return;
10967      const dx = ev.clientX - rz.x, dy = ev.clientY - rz.y; let l = rz.l, t = rz.t, w = rz.w, h = rz.h;
10968      if (rz.d.indexOf('e') >= 0) w = rz.w + dx;
10969      if (rz.d.indexOf('s') >= 0) h = rz.h + dy;
10970      if (rz.d.indexOf('w') >= 0) { w = rz.w - dx; l = rz.l + dx; }
10971      if (rz.d.indexOf('n') >= 0) { h = rz.h - dy; t = rz.t + dy; }
10972      if (w < 320) { if (rz.d.indexOf('w') >= 0) l = rz.l + (rz.w - 320); w = 320; }
10973      if (h < 200) { if (rz.d.indexOf('n') >= 0) t = rz.t + (rz.h - 200); h = 200; }
10974      el.style.left = Math.max(0, l) + 'px'; el.style.top = Math.max(0, t) + 'px'; el.style.width = w + 'px'; el.style.height = h + 'px';
10975    });
10976    window.addEventListener('mouseup', function(){
10977      if (!rz) return; rz = null;
10978      const fr = el.querySelector('iframe'); if (fr) fr.style.pointerEvents = '';
10979      _dfSave({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
10980    });
10981    // drag by the title bar
10982    const hd = el.querySelector('#gfDeskFloatHd'); let drag = null;
10983    hd.addEventListener('mousedown', function(ev){ if (ev.target.closest('button,a')) return; drag = { dx: ev.clientX - el.offsetLeft, dy: ev.clientY - el.offsetTop }; ev.preventDefault(); const fr = el.querySelector('iframe'); if (fr) fr.style.pointerEvents = 'none'; });
10984    window.addEventListener('mousemove', function(ev){ if (!drag) return; el.style.left = Math.max(0, Math.min(window.innerWidth - 60, ev.clientX - drag.dx)) + 'px'; el.style.top = Math.max(0, Math.min(window.innerHeight - 30, ev.clientY - drag.dy)) + 'px'; });
10985    window.addEventListener('mouseup', function(){ if (!drag) return; drag = null; const fr = el.querySelector('iframe'); if (fr) fr.style.pointerEvents = ''; _dfSave({ x: el.offsetLeft, y: el.offsetTop }); });
10986    try { new ResizeObserver(function(){ if (el.style.display !== 'none' && !el.dataset.min) _dfSave({ w: el.offsetWidth, h: el.offsetHeight }); }).observe(el); } catch (e) {}
10987    if (st.min) gfDeskMin(true);
10988  }
10989  const show = (force === true || force === false) ? force : (_dfEl.style.display === 'none');
10990  _dfEl.style.display = show ? 'flex' : 'none';
10991  _dfSave({ open: show });
10992  const b = document.getElementById('deskFloatBtn'); if (b) b.classList.toggle('active', show);
10993}
10994function gfDeskMin(force){
10995  if (!_dfEl) return;
10996  const min = (force === true || force === false) ? force : !_dfEl.dataset.min;
10997  const fr = _dfEl.querySelector('iframe');
10998  if (min) { _dfEl.dataset.fullH = _dfEl.offsetHeight; _dfEl.dataset.min = '1'; if (fr) fr.style.display = 'none'; _dfEl.style.height = '34px'; _dfEl.querySelectorAll('.gf-dfrz').forEach(g => g.style.display = 'none'); }
10999  else { delete _dfEl.dataset.min; if (fr) fr.style.display = ''; _dfEl.style.height = (+_dfEl.dataset.fullH || _dfState().h || 640) + 'px'; _dfEl.querySelectorAll('.gf-dfrz').forEach(g => g.style.display = ''); }
11000  _dfSave({ min });
11001}
11002async function gfDeskPop(){
11003  if (!(typeof isAdminUser !== 'undefined' && isAdminUser)) return;
11004  const w = 380, h = Math.min(820, Math.max(520, window.innerHeight - 80));
11005  try {
11006    if ('documentPictureInPicture' in window) {
11007      if (_dfPip && !_dfPip.closed) { try { _dfPip.focus(); } catch (e) {} return; }
11008      const pip = await window.documentPictureInPicture.requestWindow({ width: w, height: h });
11009      pip.document.body.style.cssText = 'margin:0;background:#0b1220;overflow:hidden';
11010      const f = pip.document.createElement('iframe'); f.src = '/desk'; f.style.cssText = 'border:0;width:100vw;height:100vh;display:block;background:#0b1220';
11011      pip.document.body.appendChild(f); _dfPip = pip;
11012      pip.addEventListener('pagehide', function(){ _dfPip = null; });
11013      try { gfDeskFloat(false); } catch (e) {}   // the in-page window steps aside while the PiP one is up
11014      return;
11015    }
11016  } catch (e) { try { console.warn('[Desk] PiP unavailable:', e.message); } catch (_) {} }
11017  const pop = window.open('/desk', 'gfdesk', 'width=' + w + ',height=' + h + ',resizable=yes,scrollbars=yes');
11018  try { if (pop) pop.focus(); } catch (e) {}
11019}
11020// admin: reveal the header button and re-open the float where it was left
11021setTimeout(function(){ try { if (typeof isAdminUser !== 'undefined' && isAdminUser) { const b = document.getElementById('deskFloatBtn'); if (b) b.style.display = ''; if (_dfState().open) gfDeskFloat(true); } } catch (e) {} }, 2500);
11022
11023// ── DESK: IN-CHART HEAD FOR CHART 1 + TICKER DROPDOWN ON EVERY CHART (user 2026-09-22) ──
11024// "when we have 2 or 3 charts i want chart 1 ticker name, nodes to show like it shows on chart 2 and
11025// chart 3 on chart window side not on top line" + "user should be able to change ticker from chart
11026// itself where it says ticker name - make it clickable, little dropdown on hover with the full
11027// watchlist, for all charts". Chart 1 gets a floating head pinned to the top-left of #chartContent
11028// (no layout change - the chart canvas sizing is untouched); panes 2/3 keep their own head. Every
11029// head's ticker name hosts the dropdown; picking a name retickers THAT chart via the slot.
11030function _mPaneIdForSlot(slot){ for (const id of [2, 3]) { const c = _mCharts[id]; if (c && c.slot === slot) return id; } return null; }
11031function _mDeskPick(slot, tk){
11032  tk = String(tk || '').toUpperCase(); if (!tk) return;
11033  try { _mTkddClose(true); } catch (e) {}
11034  if (slot === 0) {
11035    if (tk !== chartTicker) { chartTicker = tk; try { buildChartTickers(); } catch (e) {} loadGEXChart(tk); }
11036  } else { const id = _mPaneIdForSlot(slot); if (id != null) _mSetTicker(id, tk); }
11037  try { _mSetFocus(slot); } catch (e) {}
11038  try { _mSyncModeUI(); } catch (e) {}
11039  _mDeskHeadSync();
11040}
11041let _mDeskLv = { tk: '', basis: '', at: 0, L: null, busy: false };
11042function _mDeskHeadCells(L){
11043  if (!L) return '';
11044  const cell = (lbl, v, col) => (v == null || !isFinite(+v)) ? '' : '<span><span style="color:var(--dim)">' + lbl + '</span> <b style="color:' + col + '">' + (+v).toFixed(2) + '</b></span>';
11045  return cell('K', L.king, '#eab308') + cell('CW', L.callWall, '#22c55e') + cell('PW', L.putWall, '#ef4444') + cell('VN', L.volNode, '#3b82f6');
11046}
11047function _mDeskHeadSync(){
11048  const content = document.getElementById('chartContent'); if (!content) return;
11049  let h = document.getElementById('gfDeskHead1');
11050  if (!(_mLayout > 1)) { if (h) { h.remove(); try { if (typeof updateHeaderHeightVar === 'function') updateHeaderHeightVar(); if (typeof fitChartViewHeight === 'function') fitChartViewHeight(); } catch (e) {} } return; }
11051  if (!h) {
11052    h = document.createElement('div'); h.id = 'gfDeskHead1'; h.className = 'gf-dhead';
11053    h.innerHTML = '<span class="mchart-name gf-tkdd-host" data-slot="0" title="Click or hover: pick a ticker for this chart"></span>'
11054      + '<span class="mchart-tfl"></span><span class="mchart-active-tag">ACTIVE</span>'
11055      + '<span class="mchart-lv" style="display:inline-flex;gap:9px;align-items:center;margin-left:8px;font-size:10px;color:var(--dim);white-space:nowrap"></span>'
11056      + '<span class="mchart-spot" style="margin-left:6px;font-size:12px;font-weight:700;color:var(--text)"></span>';
11057    content.appendChild(h);
11058    try { if (typeof updateHeaderHeightVar === 'function') updateHeaderHeightVar(); if (typeof fitChartViewHeight === 'function') fitChartViewHeight(); } catch (e) {}   // the top-line strip is hidden in desk mode - refit the chart height
11059    h.addEventListener('mousedown', function(ev){ if (ev.target.closest('.gf-tkdd-host')) return; try { _mSetFocus(0); } catch (e) {} }, true);
11060  }
11061  const tk = String(chartTicker || '').toUpperCase();
11062  const nm = h.querySelector('.mchart-name'); if (nm && nm.textContent !== tk) nm.textContent = tk;
11063  const bs = (typeof gridExpiryMode !== 'undefined') ? gridExpiryMode : 'all';
11064  const tl = h.querySelector('.mchart-tfl'); if (tl) tl.textContent = (typeof currentTimeframe !== 'undefined' ? currentTimeframe : '') + ' · ' + (bs === '0dte' ? '0DTE' : bs === 'weekly' ? 'WEEK' : 'ALL');
11065  const sp = h.querySelector('.mchart-spot'); const src = document.getElementById('chartSpot'); if (sp && src) sp.textContent = (src.textContent || '').trim();
11066  h.classList.toggle('mchart-focused', _mFocus === 0);
11067  // K / CW / PW / VN for chart 1 on its own basis - one targeted fetch per ticker/basis per minute.
11068  const q = bs === '0dte' ? '?expiry=0dte' : bs === 'weekly' ? '?expiry=weekly' : '';
11069  const stale = _mDeskLv.tk !== tk || _mDeskLv.basis !== bs || (Date.now() - _mDeskLv.at) > 60000;
11070  if (tk && stale && !_mDeskLv.busy) {
11071    _mDeskLv.busy = true;
11072    fetch('/api/levels/' + encodeURIComponent(tk) + q).then(r => r.json()).then(L => {
11073      _mDeskLv = { tk, basis: bs, at: Date.now(), L: (L && !L.error) ? L : null, busy: false };
11074      const el = document.getElementById('gfDeskHead1'); const lv = el && el.querySelector('.mchart-lv'); if (lv) lv.innerHTML = _mDeskHeadCells(_mDeskLv.L);
11075    }).catch(() => { _mDeskLv.busy = false; _mDeskLv.at = Date.now(); });
11076  } else if (!stale) { const lv = h.querySelector('.mchart-lv'); if (lv && !lv.innerHTML) lv.innerHTML = _mDeskHeadCells(_mDeskLv.L); }
11077}
11078setInterval(function(){ try { if (_mLayout > 1) _mDeskHeadSync(); } catch (e) {} }, 3000);
11079// ── the dropdown (one shared element, opened on hover or click of any .gf-tkdd-host) ──
11080let _mTkdd = null, _mTkddSlot = null, _mTkddTimer = null;
11081function _mTkddEl(){
11082  if (_mTkdd) return _mTkdd;
11083  const d = document.createElement('div'); d.id = 'gfTkdd'; d.className = 'gf-tkdd'; d.style.display = 'none';
11084  d.addEventListener('mouseenter', function(){ clearTimeout(_mTkddTimer); });
11085  d.addEventListener('mouseleave', function(){ _mTkddClose(); });
11086  document.body.appendChild(d); _mTkdd = d; return d;
11087}
11088function _mTkddOpen(host, slot){
11089  const d = _mTkddEl(); clearTimeout(_mTkddTimer); _mTkddSlot = slot;
11090  const cur = slot === 0 ? String(chartTicker || '').toUpperCase() : String(((_mSlots[slot] || {}).ticker) || '').toUpperCase();
11091  let html = '<input class="gf-tkdd-q" placeholder="type a ticker, Enter to load" oninput="_mTkddFilter(this.value)" onkeydown="if(event.key===\x27Enter\x27){event.preventDefault();_mTkddEnter(this.value);}" onmousedown="event.stopPropagation()">';
11092  const seen = new Set();
11093  const groups = Object.entries(WATCHLIST || {});
11094  for (const [sec, arr] of groups) {
11095    const items = (arr || []).filter(t => t && !seen.has(t) && !(typeof hiddenTickers !== 'undefined' && hiddenTickers.has(t)));
11096    if (!items.length) continue;
11097    html += '<div class="gf-tkdd-sec">' + sec + '</div><div class="gf-tkdd-grid">';
11098    for (const t of items) { seen.add(t); html += '<div class="gf-tkdd-it' + (t === cur ? ' on' : '') + '" data-tk="' + t + '" onmousedown="event.preventDefault();event.stopPropagation();_mDeskPick(' + slot + ',this.dataset.tk)">' + t + '</div>'; }
11099    html += '</div>';
11100  }
11101  d.innerHTML = html;
11102  const r = host.getBoundingClientRect();
11103  d.style.left = Math.max(6, Math.min(window.innerWidth - 330, r.left)) + 'px';
11104  d.style.top = (r.bottom + 4) + 'px';
11105  d.style.display = 'block';
11106  d.style.maxHeight = Math.max(160, window.innerHeight - r.bottom - 16) + 'px';
11107  try { const q = d.querySelector('.gf-tkdd-q'); if (q) setTimeout(() => q.focus(), 30); } catch (e) {}
11108}
11109// ENTER (user 2026-09-22): the first visible match, else whatever was typed - any ticker, not only the watchlist.
11110function _mTkddEnter(v){
11111  const slot = _mTkddSlot; if (slot == null) return;
11112  const d = _mTkddEl(); const first = [...d.querySelectorAll('.gf-tkdd-it')].find(it => it.style.display !== 'none');
11113  const typed = String(v || '').toUpperCase().replace(/[^A-Z.]/g, '').slice(0, 6);
11114  const tk = (first && (!typed || first.dataset.tk === typed || first.dataset.tk.indexOf(typed) === 0)) ? first.dataset.tk : typed;
11115  if (tk) _mDeskPick(slot, tk);
11116}
11117function _mTkddFilter(v){
11118  v = String(v || '').toUpperCase().trim();
11119  const d = _mTkddEl();
11120  d.querySelectorAll('.gf-tkdd-it').forEach(it => { it.style.display = (!v || it.dataset.tk.indexOf(v) === 0) ? '' : 'none'; });
11121  d.querySelectorAll('.gf-tkdd-sec').forEach(s => { const g = s.nextElementSibling; const any = g && [...g.children].some(c => c.style.display !== 'none'); s.style.display = any ? '' : 'none'; if (g) g.style.display = any ? '' : 'none'; });
11122}
11123function _mTkddClose(now){
11124  clearTimeout(_mTkddTimer);
11125  const f = () => { if (_mTkdd) _mTkdd.style.display = 'none'; _mTkddSlot = null; };
11126  if (now) f(); else _mTkddTimer = setTimeout(f, 260);
11127}
11128document.addEventListener('mouseover', function(ev){
11129  const host = ev.target.closest && ev.target.closest('.gf-tkdd-host'); if (!host) return;
11130  if (!(_mLayout > 1)) return;   // single chart: the top ticker strip does this job
11131  let slot = host.dataset.slot != null ? +host.dataset.slot : null;
11132  if (slot == null) { const pane = host.closest('.mchart-pane'); const id = pane && +String(pane.id).replace('mchart-pane-', ''); const c = id && _mCharts[id]; slot = c ? c.slot : null; }
11133  if (slot == null) return;
11134  clearTimeout(_mTkddTimer); _mTkddTimer = setTimeout(function(){ _mTkddOpen(host, slot); }, 120);
11135}, true);
11136document.addEventListener('mouseout', function(ev){
11137  const host = ev.target.closest && ev.target.closest('.gf-tkdd-host'); if (!host) return;
11138  const to = ev.relatedTarget; if (to && _mTkdd && (_mTkdd === to || _mTkdd.contains(to))) return;
11139  _mTkddClose();
11140}, true);
11141document.addEventListener('mousedown', function(ev){ if (_mTkdd && _mTkdd.style.display !== 'none' && !_mTkdd.contains(ev.target) && !(ev.target.closest && ev.target.closest('.gf-tkdd-host'))) _mTkddClose(true); }, true);
11142
11143// Which chart do the top-bar controls drive? Returns null for the full chart (slot 0).
11144function _mFocusedPaneId(){
11145  if (_mLayout < 2 || _mFocus === 0) return null;
11146  for (const id of [2, 3]) { const c = _mCharts[id]; if (c && c.slot === _mFocus) return id; }
11147  return null;
11148}
11149// Single choke point for "the user picked a ticker". Returns true when it was routed into a
11150// focused pane. Several places set chartTicker + loadGEXChart directly rather than going through
11151// selectTicker, and each one that skips this silently retickers chart 1 instead of the chart the
11152// user clicked — which is exactly the bug this replaced. Use it at EVERY user-initiated pick.
11153function _mRouteTicker(ticker){
11154  try {
11155    const p = _mFocusedPaneId();
11156    if (p == null) return false;
11157    _mSetTicker(p, ticker);
11158    _mSyncModeUI();
11159    return true;
11160  } catch (e) { return false; }
11161}
11162// Point a lightweight pane at the slot it now owns and repaint its header + data.
11163function _mBindSlot(id){
11164  const c = _mCharts[id]; if (!c) return;
11165  const s = _mSlots[c.slot]; if (!s) return;
11166  const pane = document.getElementById('mchart-pane-' + id);
11167  if (pane) {
11168    pane.style.order = c.slot;
11169    const nm = pane.querySelector('.mchart-name'); if (nm) { nm.textContent = s.ticker; nm.dataset.slot = c.slot; }
11170    // Basis ALWAYS shown (user 2026-09-06): ALL used to print nothing, so the tag looked like it vanished after a switch.
11171    const tl = pane.querySelector('.mchart-tfl'); if (tl) tl.textContent = (s.tf || '') + ' · ' + (s.basis === '0dte' ? '0DTE' : s.basis === 'weekly' ? 'WEEK' : 'ALL');
11172  }
11173  const changed = (c.ticker !== s.ticker || c.tf !== s.tf || c.basis !== s.basis);
11174  c.ticker = s.ticker; c.tf = s.tf; c.basis = s.basis;
11175  if (changed || !c.loaded) { c.fresh = true; _mLoad(id); }
11176  try { gfTideSync(id, s.ticker); } catch (e) {}   // the tide strip under this pane follows (My HQ)
11177}
11178function _mRemovePane(id){
11179  const p = document.getElementById('mchart-pane-' + id); if (p) return _mRemovePaneEl(id, p);
11180  if (_mCharts[id]) { try { _mCharts[id].obj.remove(); } catch (e) {} delete _mCharts[id]; }
11181}
11182// The tf / Layers / Draw toolbar is RE-PARENTED into whichever pane has focus (gfPaneToolbars), so
11183// removing that pane used to delete the toolbar with it - and nothing rebuilds it until a view
11184// switch, which is why it vanished after picking a ticker (user 2026-09-08). Park it back on chart 1
11185// before the pane goes. setChartLayout already did exactly this for the layout button.
11186function _mRemovePaneEl(id, p){
11187  try {
11188    const c = document.getElementById('gfHdrChartCtl'), cc = document.getElementById('chartContent');
11189    if (c && cc && p.contains(c)) { cc.appendChild(c); c.classList.remove('gf-in-pane'); }
11190  } catch (e) {}
11191  p.remove();
11192  if (_mCharts[id]) { try { _mCharts[id].obj.remove(); } catch (e) {} delete _mCharts[id]; }
11193}
11194function _mResize(id){ const c = _mCharts[id]; if (c && c.canvas) { try { c.obj.applyOptions({ width: c.canvas.clientWidth, height: c.canvas.clientHeight }); } catch (e) {} try { if (typeof _mRenderOverlay === 'function') _mRenderOverlay(id); } catch (e) {} } }
11195// Focus follows click: the full chart takes over the clicked slot, and the pane that was showing
11196// it inherits the slot the full chart just left. Each screen position keeps its own ticker, so the
11197// charts do not appear to swap — but the complete chart (modes, GEX visual, drawings, ladder) is
11198// always the one you clicked, and the top bar drives it directly.
11199function _mSetFocus(slot){
11200  if (_mLayout < 2 || slot === _mFocus || slot < 0 || slot >= _mLayout) return;
11201  _mFocus = slot;   // not persisted — a reload always starts aimed at chart 1
11202  _mPaintFocus();
11203  _mSyncModeUI();
11204}
11205// Repaint the top bar's Modes checkboxes, timeframe and basis to read the FOCUSED chart's own
11206// settings, so the controls always describe the chart they are about to edit.
11207function _mSyncModeUI(){
11208  const setChk = (k, v) => { const e = document.getElementById('mode-' + k); if (e) e.checked = !!v; };
11209  const ALL = ['gexlines', 'charm', 'vwap', 'flow', 'gexbub', 'eodpin', 'maxpain', 'vanna', 'flowwall', 'magnet', 'em', 'sesPD', 'sesON', 'sesOR', 'darkpool', 'flowmarks', 'polmarks', 'kingmagnet'];
11210  const id = _mFocusedPaneId();
11211  if (id == null) {                       // chart 1 — the checkboxes read the real global state
11212    try { for (const k of ALL) setChk(k, indState[k]); } catch (e) {}
11213    try { renderTfBar(); } catch (e) {}
11214    try { _syncGridExpiryButtons(); } catch (e) {}
11215    try { _syncModeOpts(); } catch (e) {}
11216    return;
11217  }
11218  const c = _mCharts[id], s = _mSlots[c.slot] || {};
11219  // A pane can only render the level/series modes; the canvas- and feed-bound ones read unchecked
11220  // so the menu never claims a pane is showing something it cannot draw. Pane-capable keys show
11221  // the EFFECTIVE state (chart 1's setting unless this pane overrides it).
11222  for (const k of ALL) setChk(k, _MPANE_MODES.includes(k) ? _mModeOn(c.slot, k) : false);
11223  try { document.querySelectorAll('.gexp-btn').forEach(b => b.classList.toggle('active', b.dataset.exp === s.basis)); } catch (e) {}
11224  try { document.querySelectorAll('#tfBar .tf-btn').forEach(b => b.classList.toggle('active', b.dataset.tf === s.tf)); } catch (e) {}
11225  try { _syncModeOpts(); } catch (e) {}
11226}
11227function _mEnsurePane(id){
11228  if (document.getElementById('mchart-pane-' + id)) { _mResize(id); return; }
11229  const row = document.getElementById('chartRow'); if (!row || typeof LightweightCharts === 'undefined') return;
11230  const pane = document.createElement('div'); pane.className = 'mchart-pane'; pane.id = 'mchart-pane-' + id;
11231  pane.style.cssText = 'flex:1;min-width:0;border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden';
11232  // An unfocused pane carries NO controls (user request) — just its ticker and its nodes. Click it
11233  // and it becomes the focused chart, at which point the top bar drives everything for it.
11234  pane.innerHTML = '<div class="mchart-head" style="display:flex;align-items:center;gap:7px;padding:5px 8px;border-bottom:1px solid var(--border);background:var(--card);font-family:Segoe UI,monospace;flex-shrink:0;cursor:pointer" title="Click this chart to control it from the top bar">'
11235    + '<span class="mchart-name gf-tkdd-host" title="Click or hover: pick a ticker for this chart" style="font-size:13px;font-weight:800;color:var(--text);letter-spacing:.3px"></span>'
11236    + '<span class="mchart-tfl" style="font-size:10px;color:var(--dim)"></span>'
11237    + '<span class="mchart-active-tag">ACTIVE</span>'
11238    // Nodes strip lives ON the ticker line, after the ACTIVE tag (user request) — one row, not two.
11239    + '<span class="mchart-lv" style="display:inline-flex;gap:9px;align-items:center;margin-left:8px;flex:1;min-width:0;font-size:10px;color:var(--dim);white-space:nowrap;overflow:hidden"></span>'
11240    + '<span class="mchart-spot" style="margin-left:auto;font-size:12px;font-weight:700;color:var(--text)"></span>'
11241    + '</div>'
11242    + '<div class="mchart-canvas" style="flex:1;min-height:0;position:relative;background:var(--t-bg,#0a0e17)"></div>';
11243  const tp = document.getElementById('chartTradePlan');
11244  if (tp && tp.parentNode === row) row.insertBefore(pane, tp); else row.appendChild(pane);
11245  // Clicking anywhere in the pane focuses it. Capture phase so it still registers when the
11246  // click lands on the chart canvas, and it never blocks the chart's own pan/zoom handling.
11247  pane.addEventListener('mousedown', () => { const c = _mCharts[id]; if (c) _mSetFocus(c.slot); }, true);
11248  const canvas = pane.querySelector('.mchart-canvas');
11249  const obj = LightweightCharts.createChart(canvas, { width: canvas.clientWidth, height: canvas.clientHeight, layout: { background: { type: 'solid', color: gfT('--t-bg','#0a0e17') }, textColor: gfT('--t-dim','#8b9cb5'), fontFamily: 'Segoe UI, monospace', fontSize: 11 }, grid: { vertLines: { color: '#1e293b' }, horzLines: { color: '#1e293b' } }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, rightPriceScale: { borderColor: '#1e293b', scaleMargins: { top: 0.1, bottom: 0.1 } }, timeScale: { borderColor: '#1e293b', timeVisible: true, secondsVisible: false },
11250    // Drag anywhere on the pane, both axes, like chart 1 — not just by grabbing the price axis.
11251    handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
11252    handleScale: { mouseWheel: true, pinch: true, axisPressedMouseMove: { time: true, price: true }, axisDoubleClickReset: true } });
11253  const series = obj.addCandlestickSeries({ upColor: '#22c55e', downColor: '#ef4444', borderUpColor: '#22c55e', borderDownColor: '#ef4444', wickUpColor: '#22c55e', wickDownColor: '#ef4444', priceLineVisible: false, lastValueVisible: false });
11254  try { new ResizeObserver(() => { _mResize(id); _mRenderOverlay(id); }).observe(canvas); } catch (e) {}
11255  // Remember this pane's zoom/scroll so a refresh, a timeframe change or a new ticker all land in
11256  // the window the user set, instead of snapping back to fit-everything. The overlay (GEX visual +
11257  // drawings) is repainted on every range change so it stays glued to the candles while panning.
11258  try {
11259    obj.timeScale().subscribeVisibleLogicalRangeChange(r => {
11260      const cc = _mCharts[id];
11261      if (cc && r && isFinite(r.from) && isFinite(r.to)) cc.range = { from: r.from, to: r.to };
11262      // SYNCHRONOUS paint (chart 1's fix): the rAF-deferred render left the overlay one frame
11263      // behind the candles during a horizontal drag — bubbles looked stuck, snapped on release.
11264      try { _mRenderOverlayNow(id); } catch (e) {}
11265    });
11266  } catch (e) {}
11267  _mCharts[id] = { obj, series, canvas, lines: [], bars: [], slot: null, ticker: '', tf: '1h', basis: 'all', loaded: false, range: null };
11268  try { obj.subscribeCrosshairMove(p => { try { _mDpHover(id, p); } catch (e) {} }); } catch (e) {}   // dark pool hover card (parity 2026-09-07)
11269  _mDrawWire(id, canvas);
11270}
11271// Per-pane setters write through to the SLOT, so the setting survives focus changes and reloads.
11272// ── Per-chart modes ───────────────────────────────────────────────────────────
11273// Every chart renders its OWN overlays, all at the same time: GEX visual on chart 1, GEX levels on
11274// chart 2 and charm on chart 3 all stay up together, and survive loading a new ticker into a
11275// window. The full chart's overlays are painted on a canvas welded to it, so the panes use
11276// lightweight-charts series/price-lines with the same maths and the same mode KEYS as the global
11277// menu — that way the top bar's Modes checkboxes drive whichever chart is focused.
11278// Keys a PANE can render. Anything outside this (gexbub, vanna, maxpain, magnet, flowwall, em)
11279// is canvas- or feed-bound and stays exclusive to chart 1.
11280const _MPANE_MODES = ['gexlines', 'gexbub', 'charm', 'eodpin', 'vwap', 'sesPD', 'sesON', 'sesOR', 'maxpain', 'vanna', 'flowwall', 'magnet', 'em', 'darkpool', 'flowmarks', 'polmarks', 'kingmagnet'];   // + darkpool / flow marks / politicians / king magnet (parity 2026-09-07)
11281// Mode resolution (user request 2026-08-08): charts 2-3 FOLLOW chart 1's Modes menu by default —
11282// turn Charm or EOD Pin or Session levels on for chart 1 and every chart shows them. A pane only
11283// diverges when the user toggles a mode WHILE THAT PANE IS FOCUSED, which records a per-slot
11284// override (stored as modes2; the old always-set `modes` blob is deliberately ignored so charts
11285// saved under the previous scheme start following chart 1 again).
11286function _mModes(slot){
11287  const s = _mSlots[slot]; if (!s) return {};
11288  if (!s.modes2 || typeof s.modes2 !== 'object') s.modes2 = {};
11289  return s.modes2;
11290}
11291// Non-boolean layer options a pane may override (Lines / Axis only for GEX levels and Charm levels).
11292function _mOpt(slot, key){
11293  const ov = _mModes(slot);
11294  if (key in ov) return ov[key];
11295  return (typeof indState !== 'undefined' && indState[key]) || 'lines';
11296}
11297function _mModeOn(slot, key){
11298  const ov = _mModes(slot);
11299  if (key in ov) return !!ov[key];
11300  if (key === 'gexlines') return (typeof indState === 'undefined') || indState.gexlines !== false;   // nodes on unless chart 1 turned them off
11301  return !!(typeof indState !== 'undefined' && indState[key]);
11302}
11303// Chart 1's Modes menu changed → panes that don't override that key follow it.
11304function _mFollowMode(key){
11305  for (const id of [2, 3]) {
11306    const c = _mCharts[id]; if (!c || c.slot == null) continue;
11307    if (key in _mModes(c.slot)) continue;            // this pane holds its own setting for the key
11308    if (key === 'gexlines') _mLoad(id);
11309    else if (key === 'gexbub') { c.bubStamp = ''; _mRenderOverlay(id); }
11310    else { _mApplyModes(id); _mRenderOverlay(id); }
11311  }
11312}
11313function _mSetTicker(id, v){
11314  v = (v || '').trim().toUpperCase(); const c = _mCharts[id]; if (!c || c.slot == null) return;
11315  const s = _mSlots[c.slot]; if (!s || !v || v === s.ticker) return;
11316  // A new ticker keeps the user's zoom WIDTH but re-anchors to the LATEST candles — reusing the
11317  // old scroll position left the new ticker parked on past dates (user report).
11318  c.gotoLatest = true;
11319  s.ticker = v; _mSaveSlots(); _mBindSlot(id);           // modes live on the slot, so they persist
11320}
11321function _mSetTf(id, tf){
11322  const c = _mCharts[id]; if (!c || c.slot == null) return;
11323  const s = _mSlots[c.slot]; if (!s || s.tf === tf) return;
11324  // Hold the same TIME window across the change: a logical range indexes the OLD bars, so reusing
11325  // it on another timeframe scrolls the chart off to the side.
11326  try { const vr = c.obj.timeScale().getVisibleRange(); if (vr && vr.from && vr.to) c.keepTime = { from: vr.from, to: vr.to }; } catch (e) {}
11327  s.tf = tf; _mSaveSlots(); _mBindSlot(id);
11328}
11329function _mSetBasis(id, b){
11330  const c = _mCharts[id]; if (!c || c.slot == null) return;
11331  const s = _mSlots[c.slot]; if (!s || s.basis === b) return;
11332  s.basis = b; _mSaveSlots(); _mBindSlot(id);
11333}
11334function _mSetMode(id, key, on){
11335  const c = _mCharts[id]; if (!c || c.slot == null) return;
11336  _mModes(c.slot)[key] = !!on; _mSaveSlots();
11337  if (key === 'gexlines') _mLoad(id);
11338  else if (key === 'gexbub') _mRenderOverlay(id);
11339  else if (key === 'kingmagnet') { c.km = null; _mLoad(id); }
11340  else _mApplyModes(id);
11341}
11342function _mApplyModes(id){
11343  const c = _mCharts[id]; if (!c || c.slot == null) return;
11344  const m = _mModes(c.slot);
11345  if (!c.overlays) c.overlays = {};
11346  const drop = k => { if (c.overlays[k]) { try { c.obj.removeSeries(c.overlays[k]); } catch (e) {} delete c.overlays[k]; } };
11347  const line = (k, col, w) => { if (!c.overlays[k]) { try { c.overlays[k] = c.obj.addLineSeries({ color: col, lineWidth: w || 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false }); } catch (e) {} } return c.overlays[k]; };
11348  const bars = c.bars || [], closes = bars.map(b => b.close);
11349  const pts = arr => bars.map((b, i) => (arr[i] == null || !isFinite(arr[i])) ? null : { time: b.time, value: arr[i] }).filter(Boolean);
11350  // Moving averages follow the SAME definitions the global panel configures.
11351  try {
11352    const defs = (typeof indState !== 'undefined' && Array.isArray(indState.mas)) ? indState.mas : [];
11353    defs.forEach((d, i) => {
11354      const k = 'ma' + i;
11355      const masOn = ('mas' in m) ? !!m.mas : true;   // follow chart 1 MA defs unless overridden
11356      if (masOn && d && d.on && bars.length > d.len) {
11357        const s = line(k, d.color || '#22d3ee', d.width || 1); if (!s) return;
11358        // colour / width / style follow the Layers panel on every refresh (user 2026-09-07: width only applied to chart 1)
11359        try { s.applyOptions({ color: d.color || '#22d3ee', lineWidth: Math.max(1, Math.min(4, Math.round(d.width || 1))), lineStyle: (d.style === 'dotted') ? LightweightCharts.LineStyle.Dotted : LightweightCharts.LineStyle.Solid }); } catch (e) {}
11360        s.setData(pts(d.type === 'sma' ? smaArr(closes, d.len) : emaArr(closes, d.len)));
11361      } else drop(k);
11362    });
11363  } catch (e) {}
11364  _mComputeDataModes(id);   // Max Pain / Vanna / Flow Walls / Magnet / EM (async, cached, applies on landing)
11365  if (_mModeOn(c.slot, 'vwap') && INTRADAY_TFS.includes(c.tf) && bars.length > 2) {
11366    // Centre line ONLY, like chart 1 (bands were removed there at the user's request; user 2026-09-07: "chart 2 3 have 3 lines").
11367    const vcol = (indState.modeColors && indState.modeColors.vwap) || '#a78bfa';
11368    const s = line('vwap', vcol, 2);
11369    const vw = computeVWAP(bars);
11370    if (s) { try { s.applyOptions({ color: vcol, lineWidth: 2 }); s.setData(pts(vw.map(o => (o && isFinite(o.vwap)) ? o.vwap : null))); } catch (e) {} }
11371    drop('vwapUp'); drop('vwapLo');
11372  } else { drop('vwap'); drop('vwapUp'); drop('vwapLo'); }
11373  _mDarkpool(id); _mFlowMarks(id); _mPolBadges(id);   // parity 2026-09-07
11374  _mApplyLevelModes(id);
11375}
11376// ── Desk pane parity layers (2026-09-07): dark pool levels, flow marks, politician badges, king magnet ──
11377const _mFmtPrem = v => v >= 1e6 ? (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M' : Math.round(v / 1e3) + 'K';
11378async function _mDarkpool(id){
11379  const c = _mCharts[id]; if (!c || c.slot == null || !c.ticker) return;
11380  if (!_mModeOn(c.slot, 'darkpool') || !(typeof isPaidUser !== 'undefined' && isPaidUser)) { if (c.dp) { c.dp = null; _mApplyLevelModes(id); } return; }
11381  try {
11382    if (!c.dpCache || c.dpCache.tk !== c.ticker || Date.now() - c.dpCache.t > 120000) {
11383      if (c.dpBusy) return; c.dpBusy = true;
11384      const j = await fetch('/api/darkpool/' + encodeURIComponent(c.ticker)).then(r => r.json()).catch(() => null);
11385      c.dpBusy = false;
11386      const arr = (j && (j.data || j)) || [];
11387      if (!_mCharts[id] || _mCharts[id] !== c) return;
11388      c.dpCache = { tk: c.ticker, t: Date.now(), arr: Array.isArray(arr) ? arr : [] };
11389    }
11390    const tops = c.dpCache.arr.length ? _dpLevelsFrom(c.dpCache.arr) : [];
11391    c.dp = tops.length ? tops : null;
11392    _mApplyLevelModes(id);
11393  } catch (e) { c.dpBusy = false; }
11394}
11395async function _mFlowMarks(id){
11396  const c = _mCharts[id]; if (!c || c.slot == null || !c.ticker || !c.series) return;
11397  if (!_mModeOn(c.slot, 'flowmarks') || !(typeof isPaidUser !== 'undefined' && isPaidUser)) { if (c.fmOn) { c.fmOn = false; try { c.series.setMarkers([]); } catch (e) {} } return; }
11398  try {
11399    if (!c.fmCache || c.fmCache.tk !== c.ticker || Date.now() - c.fmCache.t > 120000) {
11400      if (c.fmBusy) return; c.fmBusy = true;
11401      const j = await fetch('/api/flow/history/' + encodeURIComponent(c.ticker)).then(r => r.json()).catch(() => null);
11402      c.fmBusy = false;
11403      const arr = (j && (j.data || j)) || [];
11404      if (!_mCharts[id] || _mCharts[id] !== c) return;
11405      c.fmCache = { tk: c.ticker, t: Date.now(), arr: Array.isArray(arr) ? arr : [] };
11406    }
11407    const bars = c.bars || []; if (!bars.length) return;
11408    const _raw = parseFloat(indState.fmMinPrem); const fmMin = (isFinite(_raw) && _raw >= 0) ? _raw : 100000;
11409    const fmMaxN = Math.max(1, parseInt(indState.fmMax || 25, 10) || 25);
11410    let t0 = bars[0]._ts || 0;
11411    if ((indState.fmDay || 'today') === 'today') { try { const etDay = ts => new Date(ts * 1000).toLocaleDateString('en-US', { timeZone: 'America/New_York' }); const day = etDay(bars[bars.length - 1]._ts); let i = bars.length - 1; while (i > 0 && etDay(bars[i - 1]._ts) === day) i--; t0 = Math.max(t0, bars[i]._ts); } catch (e) {} }
11412    const rows = [];
11413    for (const f of c.fmCache.arr) { const ms = new Date(f.created_at || 0).getTime(); if (!ms) continue; const ts = ms / 1000; if (ts < t0) continue; const prem = parseFloat(f.total_premium) || 0; if (prem < fmMin) continue; rows.push({ ts, prem, f }); }
11414    const showMode = indState.fmShow || 'top', recentN = showMode === 'r5' ? 5 : showMode === 'r10' ? 10 : 0;
11415    const walk = recentN ? rows.sort((a, b) => b.ts - a.ts) : rows.sort((a, b) => b.prem - a.prem);
11416    const cap = recentN || fmMaxN, groups = new Map();
11417    for (const r of walk) {
11418      let i = bars.length - 1; while (i > 0 && bars[i]._ts > r.ts) i--;
11419      const f = r.f, askP = parseFloat(f.total_ask_side_prem || 0), bidP = parseFloat(f.total_bid_side_prem || 0);
11420      const isBuy = askP >= bidP, isCall = String(f.type || '').toLowerCase() === 'call', bull = isCall === isBuy;
11421      const k = i + '|' + (bull ? 1 : 0); const g = groups.get(k);
11422      if (!g && groups.size >= cap) continue;
11423      const gg = g || { i, bull, prem: 0, n: 0, premC: 0, premP: 0 };
11424      gg.prem += r.prem; gg.n++; if (isCall) gg.premC += r.prem; else gg.premP += r.prem; groups.set(k, gg);
11425    }
11426    const marks = [...groups.values()].map(g => ({ time: bars[g.i].time, position: g.bull ? 'belowBar' : 'aboveBar', color: g.bull ? '#22c55e' : '#ef4444', shape: g.bull ? 'arrowUp' : 'arrowDown', text: _mFmtPrem(g.prem) + ' ' + (g.premC >= g.premP ? 'C' : 'P') + (g.n > 1 ? ' x' + g.n : '') }));
11427    marks.sort((a, b) => (typeof a.time === 'number' ? a.time : 0) - (typeof b.time === 'number' ? b.time : 0));
11428    c.series.setMarkers(marks); c.fmOn = true;
11429  } catch (e) { c.fmBusy = false; }
11430}
11431function _mPolHost(id){
11432  const c = _mCharts[id]; if (!c || !c.canvas) return null;
11433  let h = c.canvas.querySelector('.mchart-pol');
11434  if (!h) { h = document.createElement('div'); h.className = 'mchart-pol'; h.style.cssText = 'position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:7'; c.canvas.appendChild(h); }
11435  return h;
11436}
11437async function _mPolBadges(id){
11438  const c = _mCharts[id]; if (!c || c.slot == null || !c.ticker) return;
11439  if (!_mModeOn(c.slot, 'polmarks') || !(typeof isPaidUser !== 'undefined' && isPaidUser)) { if (c.polBadges && c.polBadges.length) { c.polBadges = []; const h = _mPolHost(id); if (h) h.innerHTML = ''; } return; }
11440  try {
11441    if (!_polCache.rows || Date.now() - _polCache.t > 5 * 60 * 1000) {
11442      if (window._mPolBusy) return; window._mPolBusy = true;
11443      const j = await fetch('/api/smart/politicians').then(r => r.json()).catch(() => null);
11444      window._mPolBusy = false;
11445      let rows = (j && (j.data || j.rows)) || []; if (!Array.isArray(rows)) rows = [];
11446      _polCache = { t: Date.now(), rows };
11447    }
11448    if (!_mCharts[id] || _mCharts[id] !== c) return;
11449    const bars = c.bars || [];
11450    const timeFor = dateStr => { let first = null; for (const b of bars) { const ts = b._ts; if (ts == null) continue; const pt = new Date(new Date(ts * 1000).toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })); const day = pt.getFullYear() + '-' + String(pt.getMonth() + 1).padStart(2, '0') + '-' + String(pt.getDate()).padStart(2, '0'); if (day !== dateStr) continue; if (first == null) first = b.time; if (pt.getHours() * 60 + pt.getMinutes() >= 390) return b.time; } return first; };
11451    const mine = (_polCache.rows || []).filter(r => r && r.filedDate && r.txnDate && r.filedDate >= r.txnDate && _polTickerMatch(r.ticker, c.ticker));
11452    const out = [];
11453    for (const r of mine) { const t = timeFor(r.filedDate); if (t == null) continue; const sell = String(r.type || '').toLowerCase().indexOf('sell') === 0; const who = String(r.name || '').split(' ').slice(-1)[0]; const amt = (r.amt >= 1e6) ? ('$' + (r.amt / 1e6).toFixed(r.amt >= 5e6 ? 0 : 1) + 'M+') : (r.amt >= 1000) ? ('$' + Math.round(r.amt / 1000) + 'K+') : (r.amt > 0 ? ('$' + r.amt) : ''); out.push({ t, sell, who, amt, txn: String(r.txnDate).slice(5), lag: (r.lagDays != null) ? (r.lagDays + 'd') : '' }); }
11454    c.polBadges = out; _mPolRender(id);
11455  } catch (e) { window._mPolBusy = false; }
11456}
11457function _mPolRender(id){
11458  const c = _mCharts[id]; if (!c) return; const host = _mPolHost(id); if (!host) return;
11459  host.innerHTML = '';
11460  const list = c.polBadges || []; if (!list.length || !_mModeOn(c.slot, 'polmarks')) return;
11461  let ts; try { ts = c.obj.timeScale(); } catch (e) { return; }
11462  const seen = {};
11463  for (const b of list) {
11464    let x = null; try { x = ts.timeToCoordinate(b.t); } catch (e) {}
11465    if (x == null || x < 10 || x > host.clientWidth - 10) continue;
11466    let hi = null; for (const bar of c.bars) { if (bar.time === b.t) { hi = bar.high; break; } }
11467    let y = null; if (hi != null) { try { y = c.series.priceToCoordinate(hi); } catch (e) {} }
11468    if (y == null || !isFinite(y)) y = 64;
11469    const slot = (seen[b.t] = (seen[b.t] || 0) + 1) - 1, col = b.sell ? '#ef4444' : '#22c55e';
11470    let top = y - 52 - slot * 40; if (top < 4) top = 4 + slot * 40;
11471    const w = document.createElement('div');
11472    w.style.cssText = 'position:absolute;
11472left:' + x + 'px;top:' + top + 'px;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;pointer-events:none';
11473    w.innerHTML = '<div style="background:rgba(13,20,36,.95);border:1px solid ' + col + ';border-radius:8px;padding:3px 8px;white-space:nowrap;box-shadow:0 0 12px ' + (b.sell ? 'rgba(239,68,68,.4)' : 'rgba(34,197,94,.4)') + ';text-align:center"><div style="font-size:10.5px;font-weight:800;letter-spacing:.3px"><span style="color:#f8fafc">' + _esc2(b.who) + '</span> <span style="color:' + col + '">' + (b.sell ? 'SELL' : 'BUY') + '</span>' + (b.amt ? ' <span style="color:#fbbf24">' + _esc2(b.amt) + '</span>' : '') + '</div><div style="font-size:9px;color:#cbd5e1;margin-top:1px">traded ' + _esc2(b.txn) + (b.lag ? ' · ' + _esc2(b.lag) + ' lag' : '') + '</div></div><div style="width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:7px solid ' + col + '"></div>';
11474    host.appendChild(w);
11475  }
11476}
11477// King follows magnet, per pane: same rule as chart 1 (_kmTick) on the pane's own spot / levels / grid rows.
11478function _mKmTick(id){
11479  const c = _mCharts[id]; if (!c || c.slot == null) return;
11480  const on = (typeof isPaidUser !== 'undefined' && isPaidUser) && _mModeOn(c.slot, 'kingmagnet') && !(typeof _replayActive !== 'undefined' && _replayActive);
11481  if (!on) { if (c.km) { c.km = null; _mLoad(id); } return; }
11482  const L = c.lv || {}; const spot = (c.bars && c.bars.length) ? +c.bars[c.bars.length - 1].close : 0;
11483  const rows = (typeof columnData !== 'undefined' && columnData[c.ticker] && columnData[c.ticker].strikes) || [];
11484  if (!spot || L.king == null || rows.length < 2) return;
11485  let flip = null; try { const srt = rows.slice().sort((a, b) => a.strike - b.strike); let cum = 0, prevC = 0, prevK = null; for (const d of srt) { prevC = cum; cum += (d.netGex || 0); if (prevK != null && ((prevC < 0 && cum >= 0) || (prevC > 0 && cum <= 0))) { const dn = cum - prevC; const t = dn === 0 ? 0 : (0 - prevC) / dn; flip = prevK + (d.strike - prevK) * t; } prevK = d.strike; } } catch (e) {}
11486  if (flip == null && L.gflip != null) flip = L.gflip;
11487  if (flip == null) return;
11488  const km = c.km || (c.km = { phase: 'idle', crossedAt: 0, reclaimAt: 0, target: null, swap: null, form: null });
11489  const below = spot < flip, buffer = Math.max(spot * 0.0005, 0.01);
11490  let target = null, bestNeg = null, bestAny = null;
11491  for (const d of rows) { if (!(d.strike < spot)) continue; if (d.netGex < 0 && (!bestNeg || Math.abs(d.netGex) > Math.abs(bestNeg.netGex))) bestNeg = d; if (!bestAny || Math.abs(d.netGex) > Math.abs(bestAny.netGex)) bestAny = d; }
11492  target = bestNeg ? bestNeg.strike : (bestAny ? bestAny.strike : null);
11493  const hadSwap = !!km.swap;
11494  if (!below || target == null || target === L.king) {
11495    if (km.swap) { if (!km.reclaimAt) km.reclaimAt = Date.now(); if (spot >= flip + buffer || Date.now() - km.reclaimAt >= 60000) { c.km = null; } }
11496    else if (km.phase !== 'idle' || km.form) c.km = null;
11497  } else {
11498    km.reclaimAt = 0;
11499    if (km.phase !== 'forming' && km.phase !== 'active') { km.phase = 'forming'; km.crossedAt = Date.now(); }
11500    km.target = target;
11501    if (km.phase === 'forming' && Date.now() - km.crossedAt >= 8000 && spot <= flip - buffer) km.phase = 'active';
11502    km.form = (km.phase === 'forming') ? { target } : null;
11503    km.swap = (km.phase === 'active') ? { target, trueKing: L.king } : null;
11504  }
11505  const hasSwap = !!(c.km && c.km.swap);
11506  if (hasSwap !== hadSwap || (hasSwap && c.km.swap.target !== (c._kmDrawn || null))) { c._kmDrawn = hasSwap ? c.km.swap.target : null; _mLoad(id); c.bubStamp = ''; _mRenderOverlay(id); }
11507}
11508function _mKmTickAll(){ if (typeof _mLayout === 'undefined' || _mLayout < 2) return; for (const id of [2, 3]) { try { _mKmTick(id); } catch (e) {} } }
11509// Horizontal-level modes, all off the pane's own /api/levels payload (cached in c.lv) so they cost
11510// nothing extra and match what the full chart draws for the same ticker.
11511function _mApplyLevelModes(id){
11512  const c = _mCharts[id]; if (!c || c.slot == null || !c.series) return;
11513  const m = _mModes(c.slot), L = c.lv || {};
11514  if (!c.mlines) c.mlines = [];
11515  for (const pl of c.mlines) { try { c.series.removePriceLine(pl); } catch (e) {} }
11516  c.mlines = [];
11517  const LS = LightweightCharts.LineStyle;
11518  const add = (p, col, ttl, style, vis, width) => {
11519    if (p == null || !isFinite(+p)) return;
11520    try { c.mlines.push(c.series.createPriceLine({ price: +p, color: col, lineWidth: width || 1, lineStyle: style == null ? LS.Dashed : style, axisLabelVisible: true, lineVisible: vis !== false, title: ttl })); } catch (e) {}
11521  };
11522  const _chv = _mOpt(c.slot, 'chStyle') !== 'axis';   // per-pane Charm Lines / Axis only (user 2026-09-06)
11523  // Same colour set the chart-1 Modes menu configures.
11524  const MC = (typeof indState !== 'undefined' && indState.modeColors) || (typeof MODE_COLOR_DEFAULTS !== 'undefined' ? MODE_COLOR_DEFAULTS : {});
11525  // Charm wording stays positive/negative, never colour names (user request).
11526  if (_mModeOn(c.slot, 'charm')) { add(L.charmPos, '#22d3ee', 'Charm+', LS.Solid, _chv); add(L.charmNeg, '#f59e0b', 'Charm-', LS.Solid, _chv); add(L.charmFlip, '#a855f7', 'Charm Flip', LS.Dotted, _chv); }
11527  if (_mModeOn(c.slot, 'eodpin')) add(L.eodPin, '#eab308', 'EOD Pin', LS.Dotted);
11528  // Dark pool block-print levels - biggest solid/2px, the rest per the dpStyle setting (parity 2026-09-07)
11529  if (_mModeOn(c.slot, 'darkpool') && Array.isArray(c.dp)) { const dpLS = ({ solid: LS.Solid, dashed: LS.Dashed, dotted: LS.Dotted })[indState.dpStyle || 'solid'] || LS.Solid; c.dp.forEach((d, i) => add(d.price, MC.darkpool || '#22d3ee', 'DP ' + _mFmtPrem(d.prem) + (d.n > 1 ? ' · ' + d.n + 'x' : ''), dpLS, true, i === 0 ? 2 : 1)); }
11530  // Greeks-and-flow levels — same computation chart 1 runs (computeModeLevels on the 0DTE basis).
11531  const MD = c.dataModes;
11532  if (MD) {
11533    if (_mModeOn(c.slot, 'maxpain')) add(MD.maxPain, MC.maxpain || '#e879f9', 'Max Pain', LS.Dashed);
11534    if (_mModeOn(c.slot, 'vanna')) { add(MD.vannaHi, MC.vannaHi || '#2dd4bf', 'Vanna+', LS.Dashed); add(MD.vannaLo, MC.vannaLo || '#fb923c', 'Vanna-', LS.Dashed); add(MD.vannaFlip, MC.vannaFlip || '#a855f7', 'V-Flip', LS.Dotted); }
11535    if (_mModeOn(c.slot, 'flowwall')) { add(MD.flowCall, MC.flowCall || '#4ade80', 'Flow CW', LS.Solid); add(MD.flowPut, MC.flowPut || '#f87171', 'Flow PW', LS.Solid); }
11536    if (_mModeOn(c.slot, 'magnet') && Array.isArray(MD.magnet)) for (const g of MD.magnet) add(g.strike, MC.magnet || '#fbbf24', 'Magnet', LS.Dotted);
11537    if (_mModeOn(c.slot, 'em') && MD.em) { add(MD.em.up, MC.em || '#60a5fa', '+1σ', LS.Dashed); add(MD.em.dn, MC.em || '#60a5fa', '-1σ', LS.Dashed); }
11538  }
11539  // Session ranges come off the pane's own bars. Sessions are ET whatever the viewer's timezone.
11540  const intra = Array.isArray(c.bars) && c.bars.length > 2 && c.bars[0]._ts != null && INTRADAY_TFS.includes(c.tf);
11541  const _pd = _mModeOn(c.slot, 'sesPD'), _or = _mModeOn(c.slot, 'sesOR'), _on = _mModeOn(c.slot, 'sesON');
11542  if ((_pd || _or || _on) && intra) {
11543    const et = t =>
11543 new Date((t + tzOffsetSec('America/New_York', t * 1000)) * 1000);
11544    const day = t => et(t).toISOString().slice(0, 10);
11545    const mins = t => { const d = et(t); return d.getUTCHours() * 60 + d.getUTCMinutes(); };
11546    const byDay = new Map();
11547    for (const b of c.bars) { const k = day(b._ts); if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(b); }
11548    const days = [...byDay.keys()].sort();
11549    if (_pd && days.length > 1) {
11550      const prev = byDay.get(days[days.length - 2]).filter(b => { const q = mins(b._ts); return q >= 570 && q < 960; });
11551      if (prev.length) { add(Math.max(...prev.map(b => b.high)), MC.pd || '#94a3b8', 'PDH', LS.Dashed); add(Math.min(...prev.map(b => b.low)), MC.pd || '#94a3b8', 'PDL', LS.Dashed); }
11552    }
11553    // Overnight = extended hours from yesterday's 16:00 close to today's 9:30 open (whatever
11554    // extended-hours bars the feed actually has for this ticker).
11555    if (_on && days.length > 1) {
11556      const prevD = days[days.length - 2], curD = days[days.length - 1];
11557      const on = c.bars.filter(b => { const dk = day(b._ts), q = mins(b._ts); return (dk === prevD && q >= 960) || (dk === curD && q < 570); });
11558      if (on.length) { add(Math.max(...on.map(b => b.high)), MC.on || '#8b9cb5', 'ONH', LS.Dashed); add(Math.min(...on.map(b => b.low)), MC.on || '#8b9cb5', 'ONL', LS.Dashed); }
11559    }
11560    if (_or && days.length) {
11561      const or = byDay.get(days[days.length - 1]).filter(b => { const q = mins(b._ts); return q >= 570 && q < 585; });
11562      if (or.length) { add(Math.max(...or.map(b => b.high)), MC.or || '#eab308', 'ORH', LS.Dotted); add(Math.min(...or.map(b => b.low)), MC.or || '#eab308', 'ORL', LS.Dotted); }
11563    }
11564  }
11565}
11566// ── Pane data modes: Max Pain / Vanna / Flow Walls / Magnet / Expected Move ──
11567// Same inputs and the SAME pure computation chart 1 uses (computeModeLevels): 0DTE per-strike
11568// greeks via _basisGexJson, today's ask-side flow premium, and the contracts chain for the
11569// straddle. Cached 2 min per ticker+mode-set; recomputed on the pane's 60s refresh cycle.
11570const _mDataModesBusy = {};
11571async function _mComputeDataModes(id){
11572  const c = _mCharts[id]; if (!c || c.slot == null || !c.ticker) return;
11573  const want = ['maxpain', 'vanna', 'flowwall', 'magnet', 'em'].filter(k => _mModeOn(c.slot, k));
11574  if (!want.length) { if (c.dataModes) { c.dataModes = null; c.dataModesKey = ''; _mApplyLevelModes(id); } return; }
11575  const spot = (c.lv && +c.lv.spot > 0) ? +c.lv.spot : (c.bars && c.bars.length ? c.bars[c.bars.length - 1].close : 0);
11576  if (!(spot > 0)) return;
11577  const key = c.ticker + '|' + want.join(',');
11578  if (c.dataModesKey === key && c.dataModesAt && Date.now() - c.dataModesAt < 120000) return;
11579  if (_mDataModesBusy[id]) return;
11580  _mDataModesBusy[id] = true;
11581  try {
11582    const j = await _basisGexJson(c.ticker, '0dte');
11583    let rows = (j && (j.data || j)) || []; if (!Array.isArray(rows)) rows = [];
11584    const strikes = rows.map(s => ({
11585      strike: parseFloat(s.strike || 0),
11586      gNet: (parseFloat(s.call_gex || s.call_gamma_oi || 0) + parseFloat(s.put_gex || s.put_gamma_oi || 0)),
11587      // REST convention (2026-08-31): put vanna/charm arrive SAME-sign-as-call → net = call − put.
11588      // WS rows are normalized to this in server _wsGexRow, so subtraction is right 24/7.
11589      vNet: (parseFloat(s.call_vanna || 0) - parseFloat(s.put_vanna || 0)),
11590      chNet: (parseFloat(s.call_charm || 0) - parseFloat(s.put_charm || 0)),
11591      callOi: parseFloat(s.call_oi || 0), putOi: parseFloat(s.put_oi || 0),
11592      oi: parseFloat(s.open_interest || 0) || (parseFloat(s.call_oi || 0) + parseFloat(s.put_oi || 0)),
11593    })).filter(s => s.strike > 0);
11594    let flowByStrike = null;
11595    if (_mModeOn(c.slot, 'flowwall') || _mModeOn(c.slot, 'magnet')) flowByStrike = await _flowPremByStrike(c.ticker);
11596    // Contracts for the Expected Move straddle — shared cache with chart 1, fetched on miss.
11597    let contracts = (chartContractsCache[c.ticker] && chartContractsCache[c.ticker].arr) || [];
11598    if (!contracts.length && _mModeOn(c.slot, 'em')) {
11599      try {
11600        const cj = await fetch('/api/contracts/' + encodeURIComponent(c.ticker)).then(r => r.json());
11601        contracts = (cj && (cj.data || cj)) || [];
11602        if (Array.isArray(contracts) && contracts.length) chartContractsCache[c.ticker] = { arr: contracts, t: Date.now() };
11603      } catch (e) {}
11604    }
11605    if (!_mCharts[id] || _mCharts[id] !== c) return;
11606    const today = _todayET().slice(2).replace(/-/g, '');
11607    c.dataModes = computeModeLevels(strikes, spot, flowByStrike, contracts, today);
11608    c.dataModesKey = key; c.dataModesAt = Date.now();
11609    _mApplyLevelModes(id);
11610  } catch (e) {} finally { _mDataModesBusy[id] = false; }
11611}
11612function _mLevelStrip(id, L){
11613  const el = document.querySelector('#mchart-pane-' + id + ' .mchart-lv'); if (!el) return;
11614  if (!L) { el.innerHTML = ''; return; }
11615  const cell = (lbl, v, col) => (v == null || !isFinite(+v)) ? ''
11616    : '<span><span style="color:var(--dim)">' + lbl + '</span> <b style="color:' + col + '">' + (+v).toFixed(2) + '</b></span>';
11617  el.innerHTML = cell('K', L.king, '#eab308') + cell('CW', L.callWall, '#22c55e') + cell('PW', L.putWall, '#ef4444') + cell('VN', L.volNode, '#3b82f6');
11618}
11619// ── Pane overlay: GEX VISUAL + DRAWINGS on every chart ────────────────────────
11620// One canvas per pane, painted bubbles-then-drawings, so charts 2-3 carry the same visual language
11621// as chart 1. The trail comes from the SAME raw replay cache chart 1 seeds its history from
11622// (_gexBubHistRaw, shared both ways), bucketed against the PANE's candles via _gexBubDayCols.
11623// Drawings share chart 1's per-ticker localStorage store (gexDraw:<TK>), so a line drawn on any
11624// chart shows on every chart with that ticker.
11625const _mBubFreshAt = {};   // 'TK|day' -> last fetch ms (freshness; raw arrays live in _gexBubHistRaw)
11626function _mBubFetch(tk){
11627  const day = _gexBubPtDay(), rk = tk + '|' + day, rkP = 'prev|' + rk;
11628  const fresh = (Date.now() - (_mBubFreshAt[rk] || 0)) < 300000;
11629  const _prevNeed = _gexBubPrevOn() && !(_gexBubHistRaw[rkP] && _gexBubHistRaw[rkP].length);   // prev day wanted but not cached
11630  if (_gexBubHistRaw[rk] && _gexBubHistRaw[rk].length && fresh && !_prevNeed) return;
11631  if (_mBubFreshAt[rk] && Date.now() - _mBubFreshAt[rk] < 110000 && !_prevNeed) return;   // in flight / backoff - bypass when the prev day is still missing (user 2026-09-15)
11632  _mBubFreshAt[rk] = Date.now();
11633  fetch('/api/replay/' + encodeURIComponent(tk)).then(r => r.json()).then(j => {
11634    const snaps = (j && j.snaps) || [], srvDate = (j && j.date) || '';
11635    if (srvDate && srvDate !== day && snaps.length) { _gexBubHistRaw[rkP] = snaps; _mBubRepaintTk(tk); return; }   // pre-open: last session = prev layer
11636    if (!snaps.length) {
11637      // Market CLOSED + empty: the session rolled and today has nothing recorded yet - load the
11638      // session day's file (then the newest archived) so the pane steps instead of the flat preview.
11639      if (typeof _gexBubMarketNow === 'function' && !_gexBubMarketNow()) {
11640        fetch('/api/replay/' + encodeURIComponent(tk) + '?date=' + day).then(r => r.json()).then(aj => {
11641          const as = (aj && aj.snaps) || [];
11642          if (as.length) { _gexBubHistRaw[rk] = as; _mBubRepaintTk(tk); return; }
11643          fetch('/api/replay-dates').then(r => r.json()).then(dj => {
11644            const ds = (dj && dj.dates) || []; const d0 = ds.filter(d => d <= day)[0] || ds[0]; if (!d0) return;
11645            fetch('/api/replay/' + encodeURIComponent(tk) + '?date=' + d0).then(r => r.json()).then(bj => {
11646              const bs = (bj && bj.snaps) || []; if (bs.length) { _gexBubHistRaw[rk] = bs; _mBubRepaintTk(tk); }
11647            }).catch(() => {});
11648          }).catch(() => {});
11649        }).catch(() => {});
11650      }
11651      return;
11652    }
11653    _gexBubHistRaw[rk] = snaps;
11654    if (!_gexBubPrevOn() || (_gexBubHistRaw[rkP] && _gexBubHistRaw[rkP].length)) { _mBubRepaintTk(tk); return; }
11655    fetch('/api/replay-dates').then(r => r.json()).then(dj => {
11656      const ds = (dj && dj.dates) || []; let prevD = null;
11657      for (let i = 0; i < ds.length; i++) { if (ds[i] < srvDate) { prevD = ds[i]; break; } }
11658      if (!prevD) { _mBubRepaintTk(tk); return; }
11659      fetch('/api/replay/' + encodeURIComponent(tk) + '?date=' + prevD).then(r => r.json()).then(pj => {
11660        _gexBubHistRaw[rkP] = (pj && pj.snaps) || []; _mBubRepaintTk(tk);
11661      }).catch(() => _mBubRepaintTk(tk));
11662    }).catch(() => _mBubRepaintTk(tk));
11663  }).catch(() => {});
11664}
11665// Per-pane strike fetch for the preview trail (basis-correct), 2-min cache; repaints the pane the moment it lands.
11666async function _mBubPreviewFetch(id){
11667  const c = _mCharts[id]; if (!c || !c.ticker || c.pvBusy) return;
11668  if (c.pvRows && c.pvRows.tk === c.ticker && c.pvRows.basis === (c.basis || 'all') && Date.now() - c.pvRows.t < 120000) return;
11669  c.pvBusy = true;
11670  try {
11671    const tk = c.ticker, basis = c.basis || 'all';
11672    const j = await _basisGexJson(tk, basis === '0dte' ? '0dte' : basis === 'weekly' ? 'weekly' : 'all');
11673    let rows = (j && (j.data || j)) || []; if (!Array.isArray(rows)) rows = [];
11674    const strikes = rows.map(r => ({ strike: parseFloat(r.strike || 0), netGex: parseFloat(r.call_gex || r.call_gamma_oi || 0) + parseFloat(r.put_gex || r.put_gamma_oi || 0) })).filter(r => r.strike > 0 && isFinite(r.netGex));
11675    if (!_mCharts[id] || _mCharts[id] !== c) return;
11676    c.pvRows = { tk, basis, rows: strikes, t: Date.now() };
11677    c.bubStamp = ''; _mRenderOverlay(id);
11678  } catch (e) {} finally { c.pvBusy = false; }
11679}
11680function _mBubRepaintTk(tk){
11681  for (const id of [2, 3]) { const c = _mCharts[id]; if (c && c.ticker === tk) { c.bubStamp = ''; _mRenderOverlay(id); } }
11682}
11683function _mBubTrailFor(id){
11684  const c = _mCharts[id]; if (!c || !c.bars || !c.bars.length) return null;
11685  const tk = c.ticker, day = _gexBubPtDay(), rk = tk + '|' + day;
11686  const raw = _gexBubHistRaw[rk], rawP = _gexBubPrevOn() ? _gexBubHistRaw['prev|' + rk] : null;
11687  // Settings that shape the trail are part of the stamp, so changing Strikes / Session / Prev Day
11688  // in the Visual menu rebuilds every pane's trail (they only read these at build time).
11689  const opt = _gexBubMaxRows() + '|' + (_gexBubPrevOn() ? 1 : 0) + '|' + ((typeof indState !== 'undefined' && indState.gexbubSession) || 'reg');
11690  const noHist = (!raw || !raw.length) && (!rawP || !rawP.length);
11691  // No recording (weekend / holiday / unrecorded ticker): the pane draws the same PREVIEW chart 1 draws, from the grid's
11692  // strike data when it has this ticker, else from its OWN strike fetch (user 2026-09-07: panes showed nothing or
11693  // took 10-15 s - the old stamp only re-checked once a minute and never fetched for tickers the grid had not loaded).
11694  let pvRows = null, pvSig = 'none';
11695  if (noHist) {
11696    const cd = (typeof columnData !== 'undefined') ? columnData[tk] : null;
11697    if (cd && cd.strikes && cd.strikes.length && (cd.basis || 'all') === (c.basis || 'all')) pvRows = cd.strikes;
11698    else if (c.pvRows && c.pvRows.tk === tk && c.pvRows.basis === (c.basis || 'all') && Date.now() - c.pvRows.t < 120000) pvRows = c.pvRows.rows;
11699    else { pvRows = (cd && cd.strikes && cd.strikes.length) ? cd.strikes : null; _mBubPreviewFetch(id); }
11700    if (pvRows && pvRows.length) { let mxg = 0, sum = 0; for (const r of pvRows) { const a = Math.abs(r.netGex || 0); sum += a; if (a > mxg) mxg = a; } pvSig = pvRows.length + ':' + Math.round(mxg) + ':' + Math.round(sum); }
11701  }
11702  const pvKey = noHist ? ('|pv' + pvSig) : '';
11703  const stamp = tk + '|' + c.tf + '|' + c.basis + '|' + c.bars.length + '|' + ((raw && raw.length) || 0) + '|' + ((rawP && rawP.length) || 0) + '|' + opt + pvKey;
11704  if (c.bubStamp === stamp) return c.bubTrail;
11705  _mBubFetch(tk);
11706  if (noHist) {
11707    let pv = null;
11708    try { if (_gexBubAllowed() && (typeof _replayActive === 'undefined' || !_replayActive) && pvRows && pvRows.length) pv = _gexBubPreviewSnaps(tk, c.bars, _gexBubMaxRows(), pvRows); } catch (e) {}
11709    c.bubStamp = stamp; c.bubTrail = (pv && pv.length) ? { snaps: pv, preview: true } : null; return c.bubTrail;
11710  }
11711  const is0 = (c.basis === '0dte'), mx = _gexBubMaxRows();
11712  // _gexBubDayCols reads the global currentTimeframe for its gap threshold — swap in the pane's
11713  // for the synchronous call, restore after.
11714  const keepTf = currentTimeframe; let out = [];
11715  try {
11716    currentTimeframe = c.tf;
11717    if (rawP && rawP.length) out = out.concat(_gexBubDayCols(rawP, c.bars, is0, mx, true));
11718    if (raw && raw.length) out = out.concat(_gexBubDayCols(raw, c.bars, is0, mx, false));
11719  } catch (e) {} finally { currentTimeframe = keepTf; }
11720  out.sort((a, b) => _gexBubTKey(a.t) - _gexBubTKey(b.t));
11721  c.bubStamp = stamp; c.bubTrail = out.length ? { snaps: out } : null;
11722  return c.bubTrail;
11723}
11724// LIVE forming column (user request: bubbles must move in real time like the candles). The
11725// recorded trail runs ~5 min behind; this samples the SAME live per-strike data chart 1's 20s
11726// sampler reads (columnData — populated for every polled watchlist ticker) and pins a live column
11727// to the pane's forming candle, overwritten in place until the candle rolls. Trail columns that
11728// catch up (recorder lands) replace it via the t>lastHist filter at render.
11729function _mBubLiveSample(id){
11730  const c = _mCharts[id]; if (!c || !c.ticker || !c.bars || !c.bars.length) return;
11731  if (!_mModeOn(c.slot || 0, 'gexbub')) return;
11732  if (typeof _gexBubMarketNow === 'function' && !_gexBubMarketNow()) return;
11733  const now = Date.now();
11734  if (c.lastBubSample && now - c.lastBubSample < 20000) return;   // chart 1's cadence
11735  c.lastBubSample = now;
11736  const cd = (typeof columnData !== 'undefined') ? columnData[c.ticker] : null;
11737  if (!cd || !cd.strikes || !cd.strikes.length) return;
11738  let maxG = 0;
11739  for (const s of cd.strikes) { const a = Math.abs(s.netGex || 0); if (a > maxG) maxG = a; }
11740  if (maxG <= 0) return;
11741  const rows = [];
11742  for (const s of cd.strikes) { const pct = Math.abs(s.netGex || 0) / maxG; if (pct < 0.30) continue; rows.push({ k: s.strike, pct, pos: (s.netGex || 0) >= 0 }); }
11743  if (!rows.length) return;
11744  rows.sort((a, b) => b.pct - a.pct);
11745  const mx = _gexBubMaxRows(); if (rows.length > mx) rows.length = mx;
11746  const t = c.bars[c.bars.length - 1].time;   // forming candle = the live column's anchor
11747  if (!c.liveCols) c.liveCols = [];
11748  const last = c.liveCols[c.liveCols.length - 1];
11749  if (last && last.t === t) last.rows = rows;          // same candle → overwrite in place
11750  else { c.liveCols.push({ t, rows }); if (c.liveCols.length > 40) c.liveCols.shift(); }
11751  _mRenderOverlay(id);
11752}
11753function _mOverlayCanvas(id){
11754  const pane = document.getElementById('mchart-pane-' + id); if (!pane) return null;
11755  const host = pane.querySelector('.mchart-canvas'); if (!host) return null;
11756  let cv = host.querySelector('.mchart-ov');
11757  if (!cv) { cv = document.createElement('canvas'); cv.className = 'mchart-ov'; cv.style.cssText = 'position:absolute;
11757left:0;top:0;z-index:6;pointer-events:none'; host.appendChild(cv); }
11758  return cv;
11759}
11760function _mRenderOverlay(id){
11761  const c = _mCharts[id]; if (!c || c.slot == null) return;
11762  if (c._ovRaf) return; c._ovRaf = requestAnimationFrame(() => { c._ovRaf = null; _mRenderOverlayNow(id); });
11763}
11764function _mRenderOverlayNow(id){
11765  const c = _mCharts[id]; if (!c || c.slot == null) return;
11766  const cv = _mOverlayCanvas(id); if (!cv) return;
11767  const host = cv.parentElement, w = host.clientWidth, h = host.clientHeight, dpr = window.devicePixelRatio || 1;
11768  if (!w || !h) return;
11769  if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); cv.style.width = w + 'px'; cv.style.height = h + 'px'; }
11770  const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h);
11771  // Clip to the chart pane (left of the price axis, above the time axis) like chart 1 does.
11772  let axW = 0, axH = 0;
11773  try { axW = c.obj.priceScale('right').width() || 0; } catch (e) {}
11774  try { axH = c.obj.timeScale().height() || 0; } catch (e) {}
11775  ctx.save(); ctx.beginPath(); ctx.rect(0, 0, Math.max(0, w - axW), Math.max(0, h - axH)); ctx.clip();
11776  const m = _mModes(c.slot);
11777  if (_mModeOn(c.slot, 'gexbub') && typeof _gexBubAllowed === 'function' && _gexBubAllowed()) { try { _mDrawBubbles(id, ctx, w, h); } catch (e) {} }
11778  try { _mDrawDrawings(id, ctx, w, h); } catch (e) {}
11779  try { if (c.polBadges && c.polBadges.length) _mPolRender(id); } catch (e) {}
11780  try { if (typeof _mGvTagsLayout === 'function') _mGvTagsLayout(id); } catch (e) {}
11781  ctx.restore();
11782}
11783// Faithful port of chart 1's GEX Visual, honouring the SAME global settings menu: Style (bubbles /
11784// glow lines), Strikes (5/10/all — applied at trail build), Extend, Session window and Prev Day.
11785// Radius fixed by RANK, strength = BRIGHTNESS, top-2 purple with pale core, gold/red, prev dimmed.
11786function _mDrawBubbles(id, ctx, w, h){
11787  const c = _mCharts[id];
11788  const tr = _mBubTrailFor(id);
11789  // Recorded trail + the LIVE forming columns ahead of it (pruned once the recorder catches up).
11790  let snaps = (tr && tr.snaps) ? tr.snaps : [];
11791  if (c.liveCols && c.liveCols.length) {
11792    const lastT = snaps.length ? _gexBubTKey(snaps[snaps.length - 1].t) : -1;
11793    c.liveCols = c.liveCols.filter(s => _gexBubTKey(s.t) > lastT);
11794    if (c.liveCols.length) snaps = snaps.concat(c.liveCols);
11795  }
11796  if (!snaps.length) return;
11797  let ts; try { ts = c.obj.timeScale(); } catch (e) { return; }
11798  const TAU = 6.2831853;
11799  const _rad = j => (j === 0) ? 9 : (j === 1) ? 7.5 : (j < 4) ? 6.5 : 5;
11800  // Brightness parity with chart 1 (user 2026-09-06: "brightness is different as compare to desk 1"): same alpha curve,
11801  // King +0.04 / 2nd -0.08 / cap 0.92, rank-1 deeper violet, per-colour rank step-down, passed levels at half.
11802  const _bars = c.bars || [], price = _bars.length ? +_bars[_bars.length - 1].close : 0;
11803  let dir = 0; try { if (_bars.length >= 15) { const pa = _bars[_bars.length - 15].close, pb = _bars[_bars.length - 1].close; if (pb > pa * 1.001) dir = 1; else if (pb < pa * 0.999) dir = -1; } } catch (e) {}
11804  const _alpha = (pct, j, passed) => { let a = 0.24 + pct * 0.6; if (j === 0) a += 0.04; else if (j === 1) a -= 0.08; if (passed) a *= 0.5; return Math.max(0.16, Math.min(0.92, a)); };
11805  const _kms = c.km && c.km.swap;
11806  const _col = (j, rw) => { if (_kms && _kms.target != null) { if (rw.k === _kms.target) return '#a855f7'; if (j === 0) return '#f5c518'; } return (j === 0) ? '#a855f7' : (j === 1) ? '#7c3aed' : (rw.pos ? '#f5c518' : '#ef4444'); };   // king magnet colours (parity 2026-09-07)
11807  const _cdim = (a, cord) => cord > 0 ? Math.max(0.14, a - Math.min(0.42, cord * 0.16)) : a;
11808  const _passed = rw => price > 0 && ((dir > 0 && rw.k < price * 0.999) || (dir < 0 && rw.k > price * 1.001));
11809  const style = (typeof indState !== 'undefined' && (indState.gexbubStyle === 'glow' || indState.gexbubStyle === 'rect')) ? 'glow' : 'bubbles';
11810  const extend = (typeof indState !== 'undefined' && indState.gexbubExtend) || 'off';
11811  const _yc = {}; const _pyc = k => { if (k in _yc) return _yc[k]; let y; try { y = c.series.priceToCoordinate(k); } catch (e) { y = null; } _yc[k] = y; return y; };
11812  // Column x positions, culled to the visible range before the costly transform (chart 1's perf fix).
11813  let vFrom = null, vTo = null;
11814  try { const vr = ts.getVisibleRange(); if (vr && typeof vr.from === 'number' && typeof vr.to === 'number') { vFrom = vr.from; vTo = vr.to; } } catch (e) {}
11815  const vM = (vFrom != null) ? Math.max(120, (vTo - vFrom) * 0.03) : 0;
11816  const xArr = []; let xFirst = null, xLast = null;
11817  for (let i = 0; i < snaps.length; i++) {
11818    const t = snaps[i].t;
11819    if (vFrom != null && typeof t === 'number' && (t < vFrom - vM || t > vTo + vM)) { xArr[i] = null; continue; }
11820    let x; try { x = ts.timeToCoordinate(t); } catch (e) { x = null; }
11821    xArr[i] = x; if (x == null) continue;
11822    if (xFirst == null || x < xFirst) xFirst = x;
11823    if (xLast == null || x > xLast) xLast = x;
11824  }
11825  if (xFirst == null) return;
11826  let xL = xFirst, xR = xLast;
11827  if (extend === 'left' || extend === 'full') xL = 2;
11828  if (extend === 'right' || extend === 'full') xR = w - 2;
11829  const last = snaps[snaps.length - 1];
11830  // LIVE RANK (parity 2026-09-07, chart 1's ext-parity rule): the CURRENT bands wear each strike's live rank from the grid
11831  // data when it is on this pane's basis, else from the newest drawn column re-sorted by its own gamma. History keeps
11832  // the rank it had at the time.
11833  const _curRk = {};
11834  try {
11835    const cd = (typeof columnData !== 'undefined') ? columnData[c.ticker] : null; let rankRows = null;
11836    if (cd && cd.strikes && cd.strikes.length && (cd.basis || 'all') === (c.basis || 'all')) rankRows = cd.strikes;
11837    if (!rankRows) { for (let q = snaps.length - 1; q >= 0; q--) { const sq = snaps[q]; if (sq && !sq.prev && sq.rows && sq.rows.length) { rankRows = sq.rows.map(r => ({ strike: r.k, netGex: (r.a || 0) })); break; } } }
11838    if (rankRows && rankRows.length) { const ls = rankRows.filter(r => Math.abs(r.netGex || 0) > 0).sort((a, b) => Math.abs(b.netGex || 0) - Math.abs(a.netGex || 0)); const mxr = _gexBubMaxRows(); for (let li = 0; li < Math.min(ls.length, mxr); li++) _curRk[ls[li].strike] = li; }
11839  } catch (e) {}
11840  // FADE (parity 2026-09-07): a level appearing eases in over ~450 ms, a level dropping out eases away. Per-pane map keyed
11841  // by ticker|basis; the pane re-paints itself on rAF only while something is still moving.
11842  const _fkey = c.ticker + '|' + (c.basis || 'all'); if (c.fadeKey !== _fkey) { c.fade = {}; c.fadeKey = _fkey; c.fadeT = 0; }
11843  const _now = Date.now(), _dt = c.fadeT ? Math.min(400, _now - c.fadeT) : 16; c.fadeT = _now; const _step = _dt / 450;
11844  const _cur = {}; { let nG = 0, nR = 0; if (last && last.rows) last.rows.forEach((rw0, j) => { const rk = (_curRk[rw0.k] != null) ? _curRk[rw0.k] : j; _cur[rw0.k] = { row: rw0, rank: rk, cord: (rk < 2) ? 0 : (rw0.pos ? nG++ : nR++) }; }); }
11845  for (const k in _cur) { const f = c.fade[k] || (c.fade[k] = { a: 0 }); f.a = Math.min(1, f.a + _step); f.row = _cur[k].row; f.rank = _cur[k].rank; f.cord = _cur[k].cord; }
11846  for (const k in c.fade) { if (!_cur[k]) { c.fade[k].a -= _step; if (c.fade[k].a <= 0.02) delete c.fade[k]; } }
11847  let _animating = false; for (const k in c.fade) { if (c.fade[k].a < 1) { _animating = true; break; } }
11848  const _bandRows = Object.keys(c.fade).map(k => c.fade[k]).sort((p, q) => p.rank - q.rank);
11849  // '+ Prev ->' (parity 2026-09-07): yesterday's CLOSING bands carried across today as thin dim lines, drawn first.
11850  if ((typeof indState !== 'undefined' && (indState.gexbubPrev || '') === 'ext')) {
11851    let pv = null; for (let q = 0; q < snaps.length; q++) { if (snaps[q].prev) pv = snaps[q]; }
11852    if (pv && pv.rows && pv.rows.length) {
11853      let px0 = null; try { px0 = ts.timeToCoordinate(pv.t); } catch (e) {}
11854      const pxl = (px0 != null && px0 > 2) ? px0 : 2, pxr = w - 2;
11855      if (pxr > pxl + 4) {
11856        for (let j = 0; j < pv.rows.length; j++) { const prw = pv.rows[j]; const py = _pyc(prw.k); if (py == null || py < -4 || py > h + 4) continue; const pcl = _col(j, prw), paA = _alpha(prw.pct, j, false) * 0.32 * _gexBubPrevBright(); ctx.shadowBlur = 0; ctx.globalAlpha = Math.max(0.05, paA * 0.4); ctx.fillStyle = pcl; ctx.fillRect(pxl, py - 3, pxr - pxl, 6); ctx.globalAlpha = Math.max(0.10, paA); ctx.fillRect(pxl, py - 1, pxr - pxl, 2); }
11857        ctx.globalAlpha = 1;
11858      }
11859    }
11860  }
11861  if (_animating && !c._fadeRaf) { c._fadeRaf = requestAnimationFrame(() => { c._fadeRaf = null; try { _mRenderOverlayNow(id); } catch (e) {} }); }
11862  if (style === 'glow') {
11863    // THE SAME RIBBON CHART 1 DRAWS (user 2026-09-14). The pane used to draw one flat band per current level across the
11864    // whole trail, so nothing ever stepped or breathed here. Its alpha helper takes (pct, rank, passed); the painter
11865    // asks (pct, passed, rank). The pane has no GEX ladder panel, so panelW is 0; its bars carry .time/.close.
11866    if (!c.glowFade) c.glowFade = { map: {}, key: '', t: 0 };
11867    const fading = _gexGlowPaint({ ctx, w, h, panelW: 0, tr: { snaps }, xArr, xFirst, xLast, xL, xR, extend, last,
11868      pyc: _pyc, colKM: (j, rw) =>
11868 _col(j, rw), alpha: (pct, passed, j) => _alpha(pct, j, passed), cdim: _cdim, passed: _passed,
11869      candles: c.bars || [], fade: c.glowFade, fkey: c.ticker + '|' + (c.basis || 'all') + '|' + c.tf });
11870    if (fading && !c._fadeRaf) { c._fadeRaf = requestAnimationFrame(() => { c._fadeRaf = null; try { _mRenderOverlayNow(id); } catch (e) {} }); }
11871  } else if (extend !== 'off') {
11872    // Extended bubbles = the CURRENT levels as a dotted band across [xL, xR] (chart 1 behaviour), with the fade-in.
11873    for (const f of _bandRows) {
11874      const rw = f.row, j = f.rank, cord = f.cord; const y = _pyc(rw.k); if (y == null || y < -4 || y > h + 4) continue;
11875      const col = _col(j, rw), a = _cdim(_alpha(rw.pct, j, _passed(rw)), cord) * f.a, r = _rad(j);
11876      if (rw.pct >= 0.5) { ctx.shadowColor = col; ctx.shadowBlur = 8 * (j === 0 ? 1.5 : 1); } else ctx.shadowBlur = 0;
11877      ctx.globalAlpha = a; ctx.fillStyle = col;
11878      for (let bx = xL; bx <= xR; bx += 7) { ctx.beginPath(); ctx.arc(bx, y, r, 0, TAU); ctx.fill(); }
11879    }
11880  } else {
11881    // Historical trail dots (default).
11882    let lastX = -999; const minGap = 3;
11883    for (let i = 0; i < snaps.length; i++) {
11884      const x = xArr[i]; if (x == null || x < -12 || x > w + 12) continue;
11885      if (x - lastX < minGap) continue;
11886      const sn = snaps[i];
11887      const pf = sn.prev ? Math.min(1,0.55*_gexBubPrevBright()) : 1; let cg = 0, cr = 0;
11888      for (let j = 0; j < sn.rows.length; j++) {
11889        const rw = sn.rows[j]; const cord = (j < 2) ? 0 : (rw.pos ? cg++ : cr++); const y = _pyc(rw.k); if (y == null || y < -4 || y > h + 4) continue;
11890        const col = _col(j, rw), a = _cdim(_alpha(rw.pct, j, _passed(rw)), cord) * pf, r = _rad(j);
11891        if (rw.pct >= 0.5 && !sn.prev) { ctx.shadowColor = col; ctx.shadowBlur = 8 * (j === 0 ? 1.5 : 1); } else ctx.shadowBlur = 0;
11892        ctx.globalAlpha = a; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
11893        if (j < 2) { ctx.shadowBlur = 0; ctx.globalAlpha = Math.min(1, a + (j === 0 ? 0.2 : 0.12) * pf); ctx.fillStyle = '#ecd9ff'; ctx.beginPath(); ctx.arc(x, y, r * (j === 0 ? 0.45 : 0.38), 0, TAU); ctx.fill(); }
11894      }
11895      lastX = x;
11896    }
11897  }
11898  ctx.shadowBlur = 0; ctx.globalAlpha = 1;
11899}
11900// ── Pane drawings: same per-ticker store as chart 1 (gexDraw:<TK>) ──
11901function _mBarSec(c){ const a = c.bars, n = a.length; if (n >= 2) return Math.max(1, (a[n - 1]._ts - a[0]._ts) / (n - 1)); return 3600; }
11902function _mLogicalForTs(c, tsv){
11903  const a = c.bars, n = a.length; if (!n || a[0]._ts == null) return null;
11904  const bs = _mBarSec(c);
11905  if (tsv <= a[0]._ts) return 0 - (a[0]._ts - tsv) / bs;
11906  if (tsv >= a[n - 1]._ts) return (n - 1) + (tsv - a[n - 1]._ts) / bs;
11907  let lo = 0, hi = n - 1;
11908  while (lo < hi) { const md = (lo + hi) >> 1; if (a[md]._ts <= tsv) lo = md + 1; else hi = md; }
11909  const i = Math.max(1, lo), t0 = a[i - 1]._ts, t1 = a[i]._ts;
11910  return (i - 1) + (t1 > t0 ? (tsv - t0) / (t1 - t0) : 0);
11911}
11912// Fractional logical index -> x for a PANE. The native logicalToCoordinate() only maps integer bar positions and hands
11913// back null/0 for anything between bars, so a chart-1 trendline anchor that lands between two 15m bars collapsed to
11914// x=0 and the line drew mirrored (user 2026-09-06 "shows opposite on desk 2 and 3"). Same linear fit chart 1 uses.
11915function _mCoordFromLogical(c, lg){
11916  if (lg == null || !isFinite(lg)) return null;
11917  const ts = c.obj.timeScale(), n = c.bars.length;
11918  if (n < 2) { try { const x = ts.logicalToCoordinate(Math.round(lg)); return (x == null || !isFinite(x)) ? null : x; } catch (e) { return null; } }
11919  let i0 = 0, i1 = n - 1;
11920  try { const r = ts.getVisibleLogicalRange(); if (r) { const a = Math.ceil(r.from), b = Math.floor(r.to); if (b - a >= 1) { i0 = Math.max(0, Math.min(n - 1, a)); i1 = Math.max(0, Math.min(n - 1, b)); } } } catch (e) {}
11921  if (i1 <= i0) { i0 = 0; i1 = n - 1; }
11922  let x0 = null, x1 = null;
11923  try { x0 = ts.logicalToCoordinate(i0); x1 = ts.logicalToCoordinate(i1); } catch (e) {}
11924  if (x0 == null || x1 == null || !isFinite(x0) || !isFinite(x1)) return null;
11925  return x0 + (lg - i0) * (x1 - x0) / (i1 - i0);
11926}
11927function _mXForTs(id, tsv){
11928  const c = _mCharts[id]; const lg = _mLogicalForTs(c, tsv); if (lg == null) return null;
11929  return _mCoordFromLogical(c, lg);
11930}
11931function _mTsForX(id, x){
11932  const c = _mCharts[id]; const a = c.bars, n = a.length; if (!n) return 0;
11933  let lg = null; try { lg = c.obj.timeScale().coordinateToLogical(x); } catch (e) {}
11934  if (lg == null || !isFinite(lg)) return a[n - 1]._ts;
11935  const bs = _mBarSec(c);
11936  if (lg <= 0) return Math.round(a[0]._ts + lg * bs);
11937  if (lg >= n - 1) return Math.round(a[n - 1]._ts + (lg - (n - 1)) * bs);
11938  const i = Math.floor(lg), fr = lg - i;
11939  return Math.round(a[i]._ts + fr * (((a[i + 1] ? a[i + 1]._ts : a[i]._ts + bs)) - a[i]._ts));
11940}
11941function _mDrawStore(tk){ let arr = []; try { arr = JSON.parse(localStorage.getItem(drawKey(tk)) || '[]'); } catch (e) {} return Array.isArray(arr) ? arr : []; }
11942function _mDrawSave(tk, arr){
11943  try { localStorage.setItem(drawKey(tk), JSON.stringify(arr)); } catch (e) {}
11944  // Chart 1 holds its drawings in memory — if it is showing this ticker, hand it the new list.
11945  // loadDrawingsForTicker() assigns chartDrawings ITSELF and returns undefined - assigning its result wiped chart 1's
11946  // list the moment a pane selected / dragged a shared drawing (user 2026-09-06: "1st desk drawing fades n gone").
11947  try { if (typeof chartTicker !== 'undefined' && chartTicker === tk && typeof loadDrawingsForTicker === 'function') { loadDrawingsForTicker(tk); if (typeof redrawDrawings === 'function') redrawDrawings(); } } catch (e) {}
11948}
11949function _mDrawDrawings(id, ctx, w, h){
11950  const c = _mCharts[id]; if (!c || !c.bars || !c.bars.length) return;
11951  // Cached parse (a pan fires this every frame; JSON.parse each frame is waste).
11952  const now = Date.now();
11953  if (!c.drawCache || c.drawCache.tk !== c.ticker || now - c.drawCache.at > 1000) c.drawCache = { tk: c.ticker, at: now, arr: _mDrawStore(c.ticker) };
11954  const ds = c.drawCache.arr;
11955  const XY = a => { let y = null; try { y = c.series.priceToCoordinate(a.price); } catch (e) {} return { x: _mXForTs(id, a.ts), y: y }; };
11956  const _hnd = (hx, hy) => {   // selection handle, same language as chart 1's
11957    ctx.save(); ctx.setLineDash([]); ctx.globalAlpha = 1;
11958    ctx.fillStyle = '#0a0e17'; ctx.strokeStyle = '#e2e8f0'; ctx.lineWidth = 1.5;
11959    ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, 6.283); ctx.fill(); ctx.stroke();
11959 ctx.restore();
11960  };
11961  ctx.globalAlpha = 1;
11962  for (const d of ds) {
11963    const col = d.color || (typeof DRAW_COLOR !== 'undefined' ? DRAW_COLOR : '#22d3ee');
11964    const sel = (d.id === c.drawSel) || (d.id === c.drawHover);   // selected OR hovered (parity 2026-09-07)
11965    ctx.strokeStyle = col; ctx.lineWidth = sel ? 2.5 : 2;
11966    if (d.type === 'hline') {
11967      let y; try { y = c.series.priceToCoordinate(d.price); } catch (e) { y = null; }
11968      if (y == null) continue;
11969      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
11970      { const txt = d.price.toFixed(2); ctx.font = '10px Segoe UI, monospace'; const tw = ctx.measureText(txt).width + 8; let axW = 0; try { axW = c.obj.priceScale('right').width() || 0; } catch (e) {} const xr = w - axW - 4; ctx.fillStyle = col; ctx.fillRect(xr - tw, y - 8, tw, 16); ctx.fillStyle = '#0a0e17'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; ctx.fillText(txt, xr - tw + 4, y); }   // price tag (parity 2026-09-07)
11971      if (sel) _hnd(w / 2, y);
11972    } else if (d.type === 'trend') {
11973      const A = XY(d.a), B = XY(d.b); if (A.x == null || B.x == null || A.y == null || B.y == null) continue;
11974      ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.stroke();
11975      if (sel) { _hnd(A.x, A.y); _hnd(B.x, B.y); }
11976    } else if (d.type === 'rect') {
11977      const A = XY(d.a), B = XY(d.b); if (A.x == null || B.x == null || A.y == null || B.y == null) continue;
11978      const rx = Math.min(A.x, B.x), ry = Math.min(A.y, B.y), rw = Math.abs(B.x - A.x), rh = Math.abs(B.y - A.y);
11979      ctx.save(); ctx.globalAlpha = 0.13; ctx.fillStyle = col; ctx.fillRect(rx, ry, rw, rh); ctx.restore();
11980      ctx.strokeRect(rx, ry, rw, rh);
11981      if (sel) { _hnd(A.x, A.y); _hnd(B.x, B.y); }
11982    } else if (d.type === 'fib') {
11983      const A = XY(d.a), B = XY(d.b); if (A.x == null || A.y == null || B.x == null || B.y == null) continue;
11984      const leftX = Math.min(A.x, B.x), zcol = d.zoneColor || col;
11985      const yFor = p => { try { return c.series.priceToCoordinate(p); } catch (e) { return null; } };
11986      const gz1 = yFor(d.a.price + (d.b.price - d.a.price) * 0.5), gz2 = yFor(d.a.price + (d.b.price - d.a.price) * 0.618);
11987      if (gz1 != null && gz2 != null) { ctx.save(); ctx.globalAlpha = 0.16; ctx.fillStyle = zcol; ctx.fillRect(leftX, Math.min(gz1, gz2), Math.max(2, w - leftX), Math.abs(gz2 - gz1)); ctx.restore(); }
11988      ctx.font = '10px Segoe UI, monospace'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
11989      const lv = (typeof gfFibLevels !== 'undefined') ? gfFibLevels : [{ r: 0, on: true }, { r: 0.382, on: true }, { r: 0.5, on: true }, { r: 0.618, on: true }, { r: 1, on: true }];
11990      for (const l of lv) {
11991        if (!l.on) continue;
11992        const r = l.r, price = d.a.price + (d.b.price - d.a.price) * r, ly = yFor(price); if (ly == null) continue;
11993        const main = (r === 0 || r === 1);
11994        ctx.strokeStyle = col; ctx.globalAlpha = main ? 1 : 0.72; ctx.lineWidth = main ? (sel ? 2.5 : 2) : 1.2; ctx.setLineDash(main ? [] : [4, 3]);
11995        ctx.beginPath(); ctx.moveTo(leftX, ly); ctx.lineTo(w, ly); ctx.stroke();
11996        ctx.setLineDash([]); ctx.globalAlpha = 1; ctx.fillStyle = col; ctx.fillText((+r).toString() + '  ' + price.toFixed(2), leftX + 4, ly - 6);
11997      }
11998      if (sel) { _hnd(A.x, A.y); _hnd(B.x, B.y); }
11999    } else if (d.type === 'avwap') {
12000      const bars = c.bars, n = bars.length; if (!n) continue;
12001      const si = Math.max(0, Math.min(n - 1, Math.round(_mLogicalForTs(c, d.a.ts))));
12002      let tpv = 0, vol = 0, started = false, lx = null, ly = null, lastV = null;
12003      ctx.strokeStyle = col; ctx.lineWidth = sel ? 2.5 : 2; ctx.beginPath();
12004      for (let i = si; i < n; i++) { const b = bars[i]; const tp = (b.high + b.low + b.close) / 3, v = (b.volume || 0); tpv += tp * v; vol += v; const val = vol > 0 ? tpv / vol : b.close; lastV = val; const px = _mCoordFromLogical(c, i); let py = null; try { py = c.series.priceToCoordinate(val); } catch (e) {} if (px == null || py == null) continue; if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py); lx = px; ly = py; }
12005      ctx.stroke();
12006      if (lx != null && ly != null && lastV != null) { ctx.fillStyle = col; ctx.font = '10px Segoe UI, monospace'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; ctx.fillText('AVWAP ' + lastV.toFixed(2), Math.min(lx + 4, w - 82), ly - 6); }
12007      if (sel) { const sx = _mCoordFromLogical(c, si); let sy = null; try { sy = c.series.priceToCoordinate((bars[si].high + bars[si].low + bars[si].close) / 3); } catch (e) {} if (sx != null && sy != null) _hnd(sx, sy); }
12008    } else if (d.type === 'vprof') {
12009      const A = XY(d.a), B = XY(d.b);
12009 if (A.x == null || A.y == null || B.x == null || B.y == null) continue;
12010      const bars = c.bars, n = bars.length; let i0 = Math.round(_mLogicalForTs(c, d.a.ts)), i1 = Math.round(_mLogicalForTs(c, d.b.ts));
12011      i0 = Math.max(0, Math.min(n - 1, i0)); i1 = Math.max(0, Math.min(n - 1, i1)); if (i1 < i0) { const t = i0; i0 = i1; i1 = t; }
12012      let lo = Infinity, hi = -Infinity; for (let i = i0; i <= i1; i++) { const b = bars[i]; if (!b) continue; if (b.low < lo) lo = b.low; if (b.high > hi) hi = b.high; }
12013      if (isFinite(lo) && isFinite(hi) && hi > lo) {
12014        const bins = 24, arr = new Array(bins).fill(0), rng = hi - lo; let mx = 0, poc = 0;
12015        for (let i = i0; i <= i1; i++) { const b = bars[i]; if (!b) continue; const tp = (b.high + b.low + b.close) / 3; const bi = Math.min(bins - 1, Math.max(0, Math.floor((tp - lo) / rng * bins))); arr[bi] += (b.volume || 0); }
12016        for (let bi = 0; bi < bins; bi++) { if (arr[bi] > mx) { mx = arr[bi]; poc = bi; } }
12017        const lX = Math.min(A.x, B.x), rX = Math.max(A.x, B.x), boxW = Math.max(30, rX - lX), barMax = boxW * 0.9;
12018        let yLo = null, yHi = null; try { yLo = c.series.priceToCoordinate(lo); yHi = c.series.priceToCoordinate(hi); } catch (e) {}
12019        if (yLo != null && yHi != null) {
12020          const top = Math.min(yLo, yHi), binH = Math.abs(yLo - yHi) / bins;
12021          ctx.save(); for (let bi = 0; bi < bins; bi++) { const fr = mx > 0 ? arr[bi] / mx : 0; if (fr <= 0) continue; const by = top + (bins - 1 - bi) * binH; ctx.fillStyle = col; ctx.globalAlpha = (bi === poc) ? 0.55 : 0.26; ctx.fillRect(lX, by + 1, fr * barMax, Math.max(1, binH - 1)); } ctx.restore();
12022          ctx.strokeStyle = col; ctx.globalAlpha = 0.5; ctx.lineWidth = 1; ctx.setLineDash([4, 3]); ctx.strokeRect(lX, top, boxW, Math.abs(yLo - yHi)); ctx.setLineDash([]); ctx.globalAlpha = 1;
12023          const pocY = top + (bins - 1 - poc) * binH + binH / 2, pocP = lo + (poc + 0.5) / bins * (hi - lo);
12024          ctx.fillStyle = col; ctx.font = '10px Segoe UI, monospace'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; ctx.fillText('POC ' + pocP.toFixed(2), lX + barMax + 4, pocY);
12025        }
12026      }
12027      if (sel) { _hnd(A.x, A.y); _hnd(B.x, B.y); }
12028    } else if (d.type === 'curve') {
12029      const A = XY(d.a), B = XY(d.b), C = XY(d.c); if (A.x == null || B.x == null || C.x == null) continue;
12030      ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.quadraticCurveTo(C.x, C.y, B.x, B.y); ctx.stroke();
12031      if (sel) { _hnd(A.x, A.y); _hnd(B.x, B.y); _hnd(0.25 * A.x + 0.5 * C.x + 0.25 * B.x, 0.25 * A.y + 0.5 * C.y + 0.25 * B.y); }   // mid handle like chart 1 (user 2026-09-07: "curve only shows 2 points")
12032    } else if (d.type === 'brush') {
12033      const pts = (d.points || []).map(XY).filter(p => p.x != null && p.y != null); if (pts.length < 2) continue;
12034      ctx.lineJoin = 'round'; ctx.lineCap = 'round';
12035      ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
12036      for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
12037      ctx.stroke();
12038      if (sel) { _hnd(pts[0].x, pts[0].y); _hnd(pts[pts.length - 1].x, pts[pts.length - 1].y); }
12039    }
12040  }
12041  ctx.lineWidth = 2;
12042  // In-progress previews: anchor dot + rubber-band line to the cursor, live brush stroke.
12043  const pv = (typeof DRAW_COLOR !== 'undefined' ? DRAW_COLOR : '#22d3ee');
12044  if (c.pendingDraw && c.pendingXY) {
12045    ctx.strokeStyle = pv; ctx.setLineDash([4, 4]);
12046    ctx.beginPath(); ctx.arc(c.pendingXY.x, c.pendingXY.y, 3, 0, 6.283); ctx.stroke();
12047    if (c.previewXY) {
12048      const _t = (typeof drawTool !== 'undefined') ? drawTool : 'cursor';
12049      if (_t === 'rect' || _t === 'fib' || _t === 'vprof') {
12050        const rx = Math.min(c.pendingXY.x, c.previewXY.x), ry = Math.min(c.pendingXY.y, c.previewXY.y);
12051        ctx.strokeRect(rx, ry, Math.abs(c.previewXY.x - c.pendingXY.x), Math.abs(c.previewXY.y - c.pendingXY.y));
12052      } else {
12053        ctx.beginPath(); ctx.moveTo(c.pendingXY.x, c.pendingXY.y); ctx.lineTo(c.previewXY.x, c.previewXY.y); ctx.stroke();
12054      }
12055    }
12056    ctx.setLineDash([]);
12057  }
12058  if (c.brushPts && c.brushPts.length > 1) { ctx.strokeStyle = pv; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(c.brushPts[0].x, c.brushPts[0].y); for (let i = 1; i < c.brushPts.length; i++) ctx.lineTo(c.brushPts[i].x, c.brushPts[i].y); ctx.stroke(); }
12059}
12060// Wire the global toolbar's draw tools (hline / trend / curve / brush) onto a pane. Capture phase
12061// with stopPropagation so an armed tool draws instead of panning the chart; cursor mode is
12062// untouched, so normal drag/zoom still works.
12063// Drawing object menu for panes (Lock / Unlock / Delete), same look as chart 1's (parity 2026-09-07).
12064function _mHideObjMenu(){ const m = document.getElementById('mDrawObjMenu'); if (m) m.style.display = 'none'; }
12065function _mShowObjMenu(id, d, e){
12066  let m = document.getElementById('mDrawObjMenu');
12067  if (!m) {
12068    m = document.createElement('div'); m.id = 'mDrawObjMenu';
12069    m.style.cssText = 'position:fixed;z-index:300;display:none;flex-direction:column;gap:1px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.55);font-size:12px;font-family:Segoe UI,monospace';
12070    document.body.appendChild(m);
12071    document.addEventListener('mousedown', function(ev){ if (m.style.display !== 'none' && !m.contains(ev.target)) { const p = ev.target.closest && ev.target.closest('.mchart-canvas'); if (!p) m.style.display = 'none'; } }, true);
12072  }
12073  const item = (label, fn) => '<div onclick="' + fn + '" style="padding:7px 14px;cursor:pointer;color:#cbd5e1;white-space:nowrap;border-radius:5px" onmouseover="this.style.background=\'rgba(6,182,212,0.14)\'" onmouseout="this.style.background=\'none\'">' + label + '</div>';
12074  m.innerHTML = item(d.locked ? 'Unlock' : 'Lock', '_mToggleLock(' + id + ',' + d.id + ')') + item('Delete', '_mDeleteDrawing(' + id + ',' + d.id + ');_mHideObjMenu()');
12075  m.style.display = 'flex';
12076  const px = (e && e.clientX != null) ? e.clientX : 0, py = (e && e.clientY != null) ? e.clientY : 0;
12077  const mw = m.offsetWidth || 100, mh = m.offsetHeight || 74;
12078  let left = px + 18; if (left + mw > window.innerWidth - 6) left = px - mw - 18;
12079  let top = py - mh - 46; if (top < 6) top = py + 40;
12080  m.style.left = Math.max(4, left) + 'px'; m.style.top = top + 'px';
12081}
12082function _mToggleLock(id, did){
12083  const c = _mCharts[id]; if (!c) return;
12084  const arr = _mDrawStore(c.ticker); const d = arr.find(x => x.id === did); if (!d) return;
12085  d.locked = !d.locked; _mDrawSave(c.ticker, arr); c.drawCache = null; _mRenderOverlay(id);
12086  const m = document.getElementById('mDrawObjMenu'); if (m && m.style.display !== 'none') { const item = (label, fn) => '<div onclick="' + fn + '" style="padding:7px 14px;cursor:pointer;color:#cbd5e1;white-space:nowrap;border-radius:5px">' + label + '</div>'; m.innerHTML = item(d.locked ? 'Unlock' : 'Lock', '_mToggleLock(' + id + ',' + did + ')') + item('Delete', '_mDeleteDrawing(' + id + ',' + did + ');_mHideObjMenu()'); }
12087}
12088// Dark pool hover card for panes - same rules as chart 1's _dpHover (over the LABEL only, buy/sell/mid split, biggest prints).
12089function _mDpHover(id, param){
12090  const c = _mCharts[id]; if (!c || !c.canvas) return;
12091  let tip = c.canvas.querySelector('.mchart-dptip');
12092  if (!tip) { tip = document.createElement('div'); tip.className = 'mchart-dptip'; tip.style.cssText = 'position:absolute;z-index:9;display:none;pointer-events:none;background:rgba(10,14,23,.97);border:1px solid var(--t-border,#1e293b);border-radius:8px;padding:8px 10px;font:11px/1.55 Segoe UI,monospace;color:#e2e8f0;white-space:nowrap;box-shadow:0 6px 20px rgba(0,0,0,.5)'; c.canvas.appendChild(tip); }
12093  const DP = c.dp;
12094  if (!DP || !DP.length || !_mModeOn(c.slot, 'darkpool') || !param || !param.point || !c.series) { tip.style.display = 'none'; return; }
12095  const y = param.point.y; let best = null, bestDy = 1e9;
12096  for (const d of DP) { let cy = null; try { cy = c.series.priceToCoordinate(d.price); } catch (e) {} if (cy == null) continue; const dy = Math.abs(cy - y); if (dy < bestDy) { bestDy = dy; best = d; } }
12097  if (!best || bestDy > 8) { tip.style.display = 'none'; return; }
12098  const fmtP = v => v >= 1e9 ? '$' + (v / 1e9).toFixed(1) + 'B' : v >= 1e6 ? '$' + (v / 1e6).toFixed(1) + 'M' : '$' + Math.round(v / 1e3) + 'K';
12099  const lbl = 'DP ' + fmtP(best.prem) + (best.n > 1 ? ' · ' + best.n + 'x' : '');
12100  let paneW = 0; try { paneW = c.obj.timeScale().width() || 0; } catch (e) {}
12101  if (paneW && param.point.x < paneW - (lbl.length * 6.4 + 14)) { tip.style.display = 'none'; return; }
12102  const lo = best.lo != null ? best.lo : best.price, hi = best.hi != null ? best.hi : best.price;
12103  const C2 = ((typeof indState !== 'undefined' && indState.modeColors) || MODE_COLOR_DEFAULTS).darkpool || '#22d3ee';
12104  let h = '<div style="color:' + C2 + ';font-weight:800;margin-bottom:2px">Darkpool ' + best.price.toFixed(2) + '</div>';
12105  h += '<div><b>' + fmtP(best.prem) + '</b> · ' + best.n + (best.n === 1 ? ' print' : ' prints') + '</div>';
12106  if (hi > lo) h += '<div style="color:#cbd5e1">merged ' + lo.toFixed(2) + ' - ' + hi.toFixed(2) + '</div>';
12107  if (best.first) h += '<div style="color:#cbd5e1">' + (best.last > best.first ? 'first ' + fmtTime(best.first) + ' · last ' + fmtTime(best.last) : 'at ' + fmtTime(best.first)) + ' ' + tzAbbr(best.last || best.first) + '</div>';
12108  const _b = best.buyPrem || 0, _s = best.sellPrem || 0, _m = best.midPrem || 0;
12109  if (_b || _s || _m) { const row = (l, v, col) => v > 0 ? '<div><span style="display:inline-block;width:34px;color:#cbd5e1">' + l + '</span><span style="color:' + col + ';font-weight:700">' + fmtP(v) + '</span> <span style="color:#cbd5e1">' + Math.round(v / best.prem * 100) + '%</span></div>' : ''; h += '<div style="margin-top:4px">' + row('buy', _b, '#22c55e') + row('sell', _s, '#ef4444') + row('mid', _m, '#e2e8f0') + '</div>'; const lean = best.leanPrem || 0; if (_m > 0 && Math.abs(lean) / _m > 0.6) h += '<div style="color:#cbd5e1">mid leaning ' + (lean > 0 ? 'buy' : 'sell') + '</div>'; }
12110  const pr = best.prints || [];
12111  if (pr.length > 1) { h += '<div style="color:#cbd5e1;margin-top:4px">biggest prints</div>'; for (const p of pr.slice(0, 5)) h += '<div>' + fmtP(p.prem) + '  <span style="color:#cbd5e1">' + p.price.toFixed(2) + '  ' + fmtTime(p.t) + '</span>' + (p.side === 'buy' ? ' <span style="color:#22c55e">B</span>' : p.side === 'sell' ? ' <span style="color:#ef4444">S</span>' : '') + '</div>'; }
12112  tip.innerHTML = h; tip.style.display = 'block';
12113  const host = c.canvas, w = tip.offsetWidth, hh = tip.offsetHeight; let L = param.point.x + 14, T = y + 14;
12114  if (L + w > host.clientWidth - 8) L = param.point.x - w - 14; if (T + hh > host.clientHeight - 8) T = y - hh - 14;
12115  tip.style.left = Math.max(4, L) + 'px'; tip.style.top = Math.max(4, T) + 'px';
12116}
12117function _mDrawWire(id, host){
12118  // VERTICAL price pans (drag up/down), pinch, and the axis wheel zoom fire NO lightweight-charts
12119  // event — the range-change hook only covers horizontal moves — so the pane's GEX visual froze
12120  // mid-gesture and snapped into place on release, the exact bug chart 1 had. Same fix: a rAF
12121  // repaint loop scoped to the GESTURE (pointerdown → pointerup, or ~300ms past the last wheel
12122  // tick). Zero cost while idle. The window pointerup listeners register ONCE globally so
12123  // re-created panes can't accumulate them.
12124  if (!window._mOvGestureWired) {
12125    window._mOvGestureWired = true;
12126    window._mOvDrag = {};
12127    window.addEventListener('pointerup', () => { window._mOvDrag = {}; }, { passive: true });
12128    window.addEventListener('pointercancel', () => { window._mOvDrag = {}; }, { passive: true });
12129  }
12130  let _wheelUntil = 0, _looping = false;
12131  const _loop = () => {
12132    if (!window._mOvDrag[id] && Date.now() > _wheelUntil) { _looping = false; return; }
12133    try { _mRenderOverlayNow(id); } catch (e) {}
12134    requestAnimationFrame(_loop);
12135  };
12136  const _kick = () => { if (!_looping) { _looping = true; requestAnimationFrame(_loop); } };
12137  host.addEventListener('pointerdown', () => { window._mOvDrag[id] = true; _kick(); }, { passive: true });
12138  host.addEventListener('wheel', () => { _wheelUntil = Date.now() + 300; _kick(); }, { passive: true });
12139  host.addEventListener('mousedown', function(e){
12140    if (e.button !== 0) return;
12141    const c = _mCharts[id]; if (!c || !c.bars || !c.bars.length) return;
12142    const tool = (typeof drawTool !== 'undefined') ? drawTool : 'cursor';
12143    if (tool !== 'hline' && tool !== 'trend' && tool !== 'rect' && tool !== 'curve' && tool !== 'brush' && tool !== 'fib' && tool !== 'avwap' && tool !== 'vprof') {
12144      // CURSOR mode: select / drag drawings, exactly like chart 1. Only a HIT blocks the pan.
12145      const r0 = host.getBoundingClientRect(), x0 = e.clientX - r0.left, y0 = e.clientY - r0.top;
12146      const hit = _mHitTest(id, x0, y0);
12147      if (!hit) { _mHideObjMenu(); if (c.drawSel != null) { c.drawSel = null; _mRenderOverlay(id); } return; }
12148      e.preventDefault(); e.stopPropagation();
12149      c.drawSel = hit.d.id;
12150      _mShowObjMenu(id, hit.d, e);   // Lock / Delete menu like chart 1 (parity 2026-09-07)
12151      if (!hit.d.locked) c.dragInfo = { did: hit.d.id, part: hit.part, lastX: x0, lastY: y0 };   // locked = select + menu, never drag
12152      _mRenderOverlay(id);
12153      return;
12154    }
12155    const r = host.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
12156    let price = null; try { price = c.series.coordinateToPrice(y); } catch (e2) {}
12157    if (price == null || !isFinite(price)) return;
12158    e.preventDefault(); e.stopPropagation();
12159    const col = (typeof DRAW_COLOR !== 'undefined' ? DRAW_COLOR : '#22d3ee');
12160    if (tool === 'hline') {
12161      const arr = _mDrawStore(c.ticker);
12162      arr.push({ id: Date.now(), type: 'hline', price: +price, color: col });
12163      _mDrawSave(c.ticker, arr); c.drawCache = null;
12164      try { setChartDrawTool('cursor'); } catch (e2) {}
12165      _mRenderOverlay(id);
12166      return;
12167    }
12168    if (tool === 'brush') { c.brushPts = [{ x, y }]; return; }
12169    const pt = { ts: _mTsForX(id, x), price: +price };
12170    if (tool === 'avwap') {   // anchored VWAP: one click sets the anchor bar (user 2026-09-07: panes had trend/hline/brush only)
12171      const arr = _mDrawStore(c.ticker);
12172      arr.push({ id: Date.now(), type: 'avwap', a: pt, color: col });
12173      _mDrawSave(c.ticker, arr); c.drawCache = null;
12174      try { setChartDrawTool('cursor'); } catch (e2) {}
12175      _mRenderOverlay(id);
12176      return;
12177    }
12178    // trend / curve / fib / vprof: two clicks
12179    if (!c.pendingDraw) { c.pendingDraw = pt; c.pendingXY = { x, y }; _mRenderOverlay(id); return; }
12180    const arr = _mDrawStore(c.ticker);
12181    if (tool === 'trend') arr.push({ id: Date.now(), type: 'trend', a: c.pendingDraw, b: pt, color: col });
12182    else if (tool === 'rect') arr.push({ id: Date.now(), type: 'rect', a: c.pendingDraw, b: pt, color: col });
12183    else if (tool === 'fib') arr.push({ id: Date.now(), type: 'fib', a: c.pendingDraw, b: pt, color: col, zoneColor: (typeof gfFibZoneColor !== 'undefined' ? gfFibZoneColor : '#eab308') });
12184    else if (tool === 'vprof') arr.push({ id: Date.now(), type: 'vprof', a: c.pendingDraw, b: pt, color: col });
12185    else {
12186      // control point = midpoint offset perpendicular — same construction as chart 1.
12187      const A = c.pendingXY, mx2 = (A.x + x) / 2, my2 = (A.y + y) / 2;
12188      const dx = x - A.x, dy = y - A.y, len = Math.hypot(dx, dy) || 1, off = Math.min(70, len * 0.2);
12189      let cp = null;
12190      try { cp = { ts: _mTsForX(id, mx2 + (-dy / len) * off), price: +c.series.coordinateToPrice(my2 + (dx / len) * off) }; } catch (e2) {}
12191      arr.push({ id: Date.now(), type: 'curve', a: c.pendingDraw, b: pt, c: (cp && cp.price != null && isFinite(cp.price)) ? cp : pt, color: col });
12192    }
12193    _mDrawSave(c.ticker, arr); c.drawCache = null;
12194    c.pendingDraw = null; c.pendingXY = null; c.previewXY = null;
12195    try { setChartDrawTool('cursor'); } catch (e2) {}
12196    _mRenderOverlay(id);
12197  }, true);
12198  host.addEventListener('mousemove', function(e){
12199    const c = _mCharts[id]; if (!c) return;
12200    const r = host.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
12201    // Rubber-band preview while placing the second point of a trend/curve (user report: no line
12202    // showed while drawing on a pane — only the anchor dot).
12203    if (c.pendingDraw) { c.previewXY = { x, y }; _mRenderOverlay(id); }
12204    if (c.brushPts) { c.brushPts.push({ x, y }); _mRenderOverlay(id); return; }
12205    // Hover highlight in cursor mode (parity 2026-09-07): thicker line + handles + pointer cursor over a drawing.
12206    if (!c.dragInfo && !c.pendingDraw && ((typeof drawTool === 'undefined') || drawTool === 'cursor') && c.bars && c.bars.length) {
12207      const hv = _mHitTest(id, x, y); const hid = hv ? hv.d.id : null;
12208      if (hid !== (c.drawHover == null ? null : c.drawHover)) { c.drawHover = hid; try { host.style.cursor = hv ? (hv.d.locked ? 'not-allowed' : (hv.part === 'body' ? 'move' : 'pointer')) : ''; } catch (e2) {} _mRenderOverlay(id); }
12209    }
12210    // Dragging a selected drawing (or one of its endpoints): apply the ts/price delta.
12211    if (c.dragInfo) {
12212      const di = c.dragInfo;
12213      const arr = (c.drawCache && c.drawCache.tk === c.ticker) ? c.drawCache.arr : (c.drawCache = { tk: c.ticker, at: Date.now(), arr: _mDrawStore(c.ticker) }).arr;
12214      const d = arr.find(dd => dd.id === di.did); if (!d) { c.dragInfo = null; return; }
12215      let p1 = null, p0 = null;
12216      try { p1 = c.series.coordinateToPrice(y); p0 = c.series.coordinateToPrice(di.lastY); } catch (e2) {}
12217      if (p1 == null || p0 == null) return;
12218      const dP = p1 - p0, dT = _mTsForX(id, x) - _mTsForX(id, di.lastX);
12219      const mv = a => { a.ts += dT; a.price += dP; };
12220      if (d.type === 'hline') d.price += dP;
12221      else if (di.part === 'c' && d.type === 'curve') {
12222        // keep the mid handle under the cursor: M = A/4 + C/2 + B/4  ->  C = 2M - A/2 - B/2
12223        const ax = _mXForTs(id, d.a.ts), bx = _mXForTs(id, d.b.ts); let ay = null, by = null; try { ay = c.series.priceToCoordinate(d.a.price); by = c.series.priceToCoordinate(d.b.price); } catch (e2) {}
12224        if (ax != null && bx != null && ay != null && by != null) { const cx = 2 * x - 0.5 * ax - 0.5 * bx, cy = 2 * y - 0.5 * ay - 0.5 * by; let cp = null; try { cp = c.series.coordinateToPrice(cy); } catch (e2) {} if (cp != null && isFinite(cp)) d.c = { ts: _mTsForX(id, cx), price: +cp }; }
12225      }
12226      else if (di.part === 'a') mv(d.a);
12227      else if (di.part === 'b') mv(d.b);
12228      else if (d.type === 'trend') { mv(d.a); mv(d.b); }
12229      else if (d.type === 'curve') { mv(d.a); mv(d.b); if (d.c) mv(d.c); }
12230      else if (d.type === 'fib' || d.type === 'vprof' || d.type === 'rect') { mv(d.a); mv(d.b); }
12231      else if (d.type === 'avwap') mv(d.a);
12232      else if (d.type === 'brush') { for (const p of (d.points || [])) mv(p); }
12233      di.lastX = x; di.lastY = y;
12234      c.drawCache.at = Date.now();   // hold the cache so a 1s re-read can't clobber the drag
12235      _mRenderOverlay(id);
12236    }
12237  });
12238  // Right-click a drawing → delete it (panes have no selection menu; this + the Delete key cover it).
12239  host.addEventListener('contextmenu', function(e){
12240    const c = _mCharts[id]; if (!c || !c.bars || !c.bars.length) return;
12241    const r = host.getBoundingClientRect();
12242    const hit = _mHitTest(id, e.clientX - r.left, e.clientY - r.top);
12243    if (!hit) return;
12244    e.preventDefault(); e.stopPropagation();
12245    _mDeleteDrawing(id, hit.d.id);
12246  });
12247  // Delete/Backspace removes the selected drawing on whichever pane holds a 
12247selection. One global
12248  // listener (guarded) so re-created panes can't stack them.
12249  if (!window._mOvKeyWired) {
12250    window._mOvKeyWired = true;
12251    window.addEventListener('keydown', function(e){
12252      if (e.key !== 'Delete' && e.key !== 'Backspace') return;
12253      const tag = (e.target && e.target.tagName) || '';
12254      if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
12255      for (const pid of [2, 3]) {
12256        const pc = _mCharts[pid];
12257        if (pc && pc.drawSel != null) { _mDeleteDrawing(pid, pc.drawSel); e.preventDefault(); return; }
12258      }
12259    });
12260  }
12261  window.addEventListener('mouseup', function(){
12262    const c = _mCharts[id];
12263    if (c && c.dragInfo) {   // finish a drawing drag → persist it
12264      if (c.drawCache && c.drawCache.tk === c.ticker) _mDrawSave(c.ticker, c.drawCache.arr);
12265      c.dragInfo = null;
12266      _mRenderOverlay(id);
12267    }
12268    if (!c || !c.brushPts) return;
12269    if (c.brushPts.length >= 2) {
12270      const pts = c.brushPts.map(p => {
12271        let pr = null; try { pr = c.series.coordinateToPrice(p.y); } catch (e2) {}
12272        return (pr == null || !isFinite(pr)) ? null : { ts: _mTsForX(id, p.x), price: +pr };
12273      }).filter(Boolean);
12274      if (pts.length >= 2) {
12275        const arr = _mDrawStore(c.ticker);
12276        arr.push({ id: Date.now(), type: 'brush', points: pts, color: (typeof DRAW_COLOR !== 'undefined' ? DRAW_COLOR : '#22d3ee') });
12277        _mDrawSave(c.ticker, arr); c.drawCache = null;
12278      }
12279    }
12280    c.brushPts = null;
12281    try { setChartDrawTool('cursor'); } catch (e2) {}
12282    _mRenderOverlay(id);
12283  });
12284}
12285// Chart 1 saved a drawing → repaint any pane showing the same ticker (store is shared).
12286function _mRepaintTicker(tk){
12287  for (const id of [2, 3]) { const c = _mCharts[id]; if (c && c.ticker === tk) { c.drawCache = null; _mRenderOverlay(id); } }
12288}
12289// ── Pane drawing EDITING: select / drag / endpoint-drag / delete ──────────────
12290// Cursor-mode parity with chart 1: click a drawing to select it (thicker + handles), drag it or
12291// its endpoints, right-click it to delete, or press Delete with it selected. Only a successful
12292// hit blocks the chart's own pan, so empty-chart drags still pan normally.
12293function _mDistSeg(px, py, x1, y1, x2, y2){
12294  const dx = x2 - x1, dy = y2 - y1, L2 = dx * dx + dy * dy;
12295  if (L2 <= 0) return Math.hypot(px - x1, py - y1);
12296  let t = ((px - x1) * dx + (py - y1) * dy) / L2; t = Math.max(0, Math.min(1, t));
12297  return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
12298}
12299function _mHitTest(id, x, y){
12300  const c = _mCharts[id]; if (!c || !c.bars || !c.bars.length) return null;
12301  const now = Date.now();
12302  if (!c.drawCache || c.drawCache.tk !== c.ticker) c.drawCache = { tk: c.ticker, at: now, arr: _mDrawStore(c.ticker) };
12303  const XY = a => { let yy = null; try { yy = c.series.priceToCoordinate(a.price); } catch (e) {} return { x: _mXForTs(id, a.ts), y: yy }; };
12304  const TOL = 7, HND = 9;
12305  // Walk topmost-first so overlapping drawings pick the one drawn last.
12306  for (let i = c.drawCache.arr.length - 1; i >= 0; i--) {
12307    const d = c.drawCache.arr[i];
12308    if (d.type === 'hline') {
12309      let yy; try { yy = c.series.priceToCoordinate(d.price); } catch (e) { yy = null; }
12310      if (yy != null && Math.abs(y - yy) <= TOL) return { d, part: 'body' };
12311    } else if (d.type === 'fib' || d.type === 'vprof') {
12312      const A = XY(d.a), B = XY(d.b); if (A.x == null || B.x == null || A.y == null || B.y == null) continue;
12313      if (d.id === c.drawSel) { if (Math.hypot(x - A.x, y - A.y) <= HND) return { d, part: 'a' }; if (Math.hypot(x - B.x, y - B.y) <= HND) return { d, part: 'b' }; }
12314      if (d.type === 'fib') { const ys = [A.y, B.y, A.y + (B.y - A.y) * 0.5, A.y + (B.y - A.y) * 0.618]; if (x >= Math.min(A.x, B.x) - TOL && ys.some(yy => Math.abs(y - yy) <= TOL)) return { d, part: 'body' }; }
12315      else { if (x >= Math.min(A.x, B.x) - TOL && x <= Math.max(A.x, B.x) + TOL && y >= Math.min(A.y, B.y) - TOL && y <= Math.max(A.y, B.y) + TOL) return { d, part: 'body' }; }
12316    } else if (d.type === 'avwap') {
12317      const bars = c.bars, n = bars.length; if (!n) continue;
12318      const si = Math.max(0, Math.min(n - 1, Math.round(_mLogicalForTs(c, d.a.ts)))); const sx = _mCoordFromLogical(c, si); if (sx == null) continue;
12319      // near the running line: recompute the value at the hovered bar
12320      let lg = null; try { lg = c.obj.timeScale().coordinateToLogical(x); } catch (e) {} if (lg == null) continue; const hi2 = Math.round(lg); if (hi2 < si || hi2 > n - 1) continue;
12321      let tpv = 0, vol = 0, val = null; for (let i = si; i <= hi2; i++) { const b = bars[i]; const tp = (b.high + b.low + b.close) / 3, v = (b.volume || 0); tpv += tp * v; vol += v; val = vol > 0 ? tpv / vol : b.close; }
12322      let yy = null; try { yy = c.series.priceToCoordinate(val); } catch (e) {} if (yy != null && Math.abs(y - yy) <= TOL) return { d, part: 'body' };
12323    } else if (d.type === 'rect') {
12324      const A = XY(d.a), B = XY(d.b);
12325      if (A.x == null || B.x == null || A.y == null || B.y == null) continue;
12326      if (d.id === c.drawSel) {
12327        if (Math.hypot(x - A.x, y - A.y) <= HND) return { d, part: 'a' };
12328        if (Math.hypot(x - B.x, y - B.y) <= HND) return { d, part: 'b' };
12329      }
12330      // Edges only - a filled box must not swallow clicks on the candles behind it.
12331      const x0 = Math.min(A.x, B.x), x1 = Math.max(A.x, B.x), y0 = Math.min(A.y, B.y), y1 = Math.max(A.y, B.y);
12332      if (_mDistSeg(x, y, x0, y0, x1, y0) <= TOL || _mDistSeg(x, y, x0, y1, x1, y1) <= TOL
12333       || _mDistSeg(x, y, x0, y0, x0, y1) <= TOL || _mDistSeg(x, y, x1, y0, x1, y1) <= TOL) return { d, part: 'body' };
12334    } else if (d.type === 'trend' || d.type === 'curve') {
12335      const A = XY(d.a), B = XY(d.b);
12336      if (A.x == null || B.x == null || A.y == null || B.y == null) continue;
12337      if (d.id === c.drawSel) {   // endpoint handles only bite on the SELECTED drawing
12338        if (Math.hypot(x - A.x, y - A.y) <= HND) return { d, part: 'a' };
12339        if (Math.hypot(x - B.x, y - B.y) <= HND) return { d, part: 'b' };
12340        if (d.type === 'curve' && d.c) {   // mid handle = bend the curve (user 2026-09-07: it moved the whole curve)
12341          const C = XY(d.c); if (C.x != null && C.y != null) { const M = { x: 0.25 * A.x + 0.5 * C.x + 0.25 * B.x, y: 0.25 * A.y + 0.5 * C.y + 0.25 * B.y }; if (Math.hypot(x - M.x, y - M.y) <= HND) return { d, part: 'c' }; }
12342        }
12343      }
12344      if (d.type === 'trend') { if (_mDistSeg(x, y, A.x, A.y, B.x, B.y) <= TOL) return { d, part: 'body' }; }
12345      else {
12346        const C = XY(d.c); if (C.x == null) continue;
12347        // Sample the quadratic — cheap and exact enough for a 7px tolerance.
12348        let px = A.x, py = A.y;
12349        for (let t = 0.1; t <= 1.001; t += 0.1) {
12350          const u = 1 - t;
12351          const qx = u * u * A.x + 2 * u * t * C.x + t * t * B.x;
12352          const qy = u * u * A.y + 2 * u * t * C.y + t * t * B.y;
12353          if (_mDistSeg(x, y, px, py, qx, qy) <= TOL) return { d, part: 'body' };
12354          px = qx; py = qy;
12355        }
12356      }
12357    } else if (d.type === 'brush') {
12358      const pts = (d.points || []).map(XY).filter(p => p.x != null && p.y != null);
12359      for (let k = 1; k < pts.length; k++) if (_mDistSeg(x, y, pts[k-1].x, pts[k-1].y, pts[k].x, pts[k].y) <= TOL) return { d, part: 'body' };
12360    }
12361  }
12362  return null;
12363}
12364function _mDeleteDrawing(id, did){
12365  const c = _mCharts[id]; if (!c) return;
12366  const arr = _mDrawStore(c.ticker).filter(d => d.id !== did);
12367  _mDrawSave(c.ticker, arr); c.drawCache = null;
12368  if (c.drawSel === did) c.drawSel = null;
12369  _mRenderOverlay(id);
12370}
12371async function _mLoad(id){
12372  const c = _mCharts[id]; if (!c || !c.ticker) return;
12373  c.loaded = true;
12374  const stamp = c.ticker + '|' + c.tf + '|' + c.basis;
12375  c.stamp = stamp;
12376  try {
12377    const bars = await fetchTfCandles(c.ticker, c.tf);
12378    if (!_mCharts[id] || _mCharts[id] !== c || c.stamp !== stamp) return;   // ticker changed mid-flight
12379    // Same time basis as the main chart: lightweight-charts renders a time value as if it were
12380    // UTC, so an intraday bar must be shifted by the viewer's tz offset or the axis reads UTC —
12381    // which is why the panes ran to "9pm" instead of ending at the 1pm/5pm PT session close.
12382    // _ts keeps the TRUE unix so the live tick can still match its bar.
12383    const _intra = INTRADAY_TFS.includes(c.tf);
12384    c.bars = (bars || []).map(b => {
12385      const rt = Math.floor(b._ts != null ? b._ts : b.time);
12386      let tv;
12387      if (_intra) tv = rt + tzOffsetSec(getUserTZ(), rt * 1000);
12388      else { const d = new Date(rt * 1000); tv = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0'); }
12389      return { time: tv, _ts: rt, open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume };
12390    });
12391    // An unknown/unsupported ticker used to fail silently and leave the previous chart on screen,
12392    // which read as "the ticker box does nothing". Say so instead.
12393    if (!c.bars.length) {
12394      const lv = document.querySelector('#mchart-pane-' + id + ' .mchart-lv');
12395      if (lv) lv.innerHTML = '<span style="color:#ef4444">no candle data for ' + c.ticker + '</span>';
12396      try { c.series.setData([]); } catch (e) {}
12397      return;
12398    }
12399    c.series.setData(c.bars.map(b => ({ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close })));
12400    // TICKER CHANGE: flush every remnant of the previous ticker BEFORE the price scale auto-fits.
12401    // The MA/VWAP overlay SERIES still held the old ticker's values, and series data is what
12402    // autoScale fits to — so a new ticker's candles were squeezed into an axis stretched across
12403    // BOTH tickers' price ranges and then frozen there (user report: "candles collapsing").
12404    // Cached levels/data-modes go too, so the old ticker's lines can't paint on the new one.
12405    if (c.loadedTk !== c.ticker) {
12406      c.loadedTk = c.ticker;
12407      c.lv = null; c.dataModes = null; c.dataModesKey = ''; c.bubStamp = ''; c.liveCols = null; c.drawCache = null; c.drawSel = null;
12408    }
12409    _mApplyModes(id);   // overlays now hold THIS ticker's values — safe to auto-fit below
12410    // Keep the user's zoom, but a logical range is an index into the OLD bar array — restoring it
12411    // blindly after a ticker/timeframe change can land entirely past the end of the new data, which
12412    // renders an empty chart (user report: middle chart blank). Only reuse a range that still
12413    // overlaps the data; otherwise fall back to the default recent window.
12414    try {
12415      const n = c.bars.length;
12416      if (c.gotoLatest) {
12417        // New ticker: same zoom width, anchored to the right edge (the current candles).
12418        c.gotoLatest = false; c.keepTime = null;
12419        const r = c.range;
12420        const width = (r && isFinite(r.from) && isFinite(r.to) && (r.to - r.from) >= 5) ? Math.min(r.to - r.from, n + 5) : Math.min(n, 140);
12421        c.obj.timeScale().setVisibleLogicalRange({ from: n - width, to: n + 3 });
12422      } else if (c.keepTime) {
12423        // Re-derive indices for the SAME time window on the new timeframe's bars.
12424        const kt = c.keepTime; c.keepTime = null;
12425        let a = c.bars.findIndex(b => b.time >= kt.from);
12426        let z = c.bars.findIndex(b => b.time >= kt.to);
12427        if (a < 0) a = 0; if (z < 0) z = n - 1;
12428        if (z - a < 2) { a = Math.max(0, n - 60); z = n - 1; }
12429        c.obj.timeScale().setVisibleLogicalRange({ from: a, to: z + 2 });
12430      } else {
12431        const r = c.range;
12432        const usable = r && isFinite(r.from) && isFinite(r.to) && r.to > 0 && r.from < n && (r.to - r.from) >= 2;
12433        if (usable) c.obj.timeScale().setVisibleLogicalRange({ from: Math.max(r.from, -5), to: Math.min(r.to, n + 5) });
12434        else _mZoomOut(id);
12435      }
12436    } catch (e) { try { c.obj.timeScale().fitContent(); } catch (e2) {} }
12437    // Let the price scale auto-fit ONCE on a fresh load, then turn autoScale OFF so the pane can
12438    // be dragged up/down straight away. While autoScale is on lightweight-charts ignores vertical
12439    // mouse-drag and you have to grab the price axis first — the exact fix chart 1 already uses.
12440    // Not re-applied on the 60s refresh, or it would yank the scale back every minute.
12441    if (c.fresh) {
12442      c.fresh = false;
12443      try {
12444        const ps = c.obj.priceScale('right');
12445        ps.applyOptions({ autoScale: true });
12446        requestAnimationFrame(() => { try { ps.applyOptions({ autoScale: false }); } catch (e) {} });
12447      } catch (e) {}
12448    }
12449    const q = c.basis && c.basis !== 'all' ? ('?expiry=' + encodeURIComponent(c.basis)) : '';
12450    const L = await fetch('/api/levels/' + encodeURIComponent(c.ticker) + q).then(r => r.json()).catch(() => null);
12451    if (!_mCharts[id] || _mCharts[id] !== c || c.stamp !== stamp) return;
12452    for (const pl of c.lines) { try { c.series.removePriceLine(pl); } catch (e) {} } c.lines = [];
12453    c.lv = L || null;                                    // cached for the level-based modes
12454    if (L) {
12455      const LS = LightweightCharts.LineStyle;
12456      // Nodes are ON by default for every chart, per its own ticker (user request).
12457      const showNodes = _mModeOn(c.slot, 'gexlines');
12458      const _glv = _mOpt(c.slot, 'glStyle') !== 'axis';   // per-pane Lines / Axis only (user 2026-09-06)
12459      const add = (p, col, ttl, style) => { if (!showNodes || p == null || !isFinite(+p)) return;
12459 try { c.lines.push(c.series.createPriceLine({ price: +p, color: col, lineWidth: 1, lineStyle: style, axisLabelVisible: true, lineVisible: _glv, title: ttl })); } catch (e) {} };
12460      add(L.spot, '#ffffff', '', LS.Dotted);
12461      if (c.km && c.km.swap && c.km.swap.target != null) { add(c.km.swap.target, '#eab308', 'King', LS.Dashed); add(L.king, '#a16207', 'Top GEX', LS.Dotted); } else add(L.king, '#eab308', 'King', LS.Dashed);   /* king magnet swap (parity 2026-09-07) */ add(L.callWall, '#22c55e', 'Call', LS.Solid); add(L.putWall, '#ef4444', 'Put', LS.Solid); add(L.volNode, '#3b82f6', 'Vol', LS.Dashed);
12462      const sp = document.querySelector('#mchart-pane-' + id + ' .mchart-spot'); if (sp) sp.textContent = L.spot ? '$' + (+L.spot).toFixed(2) : '';
12463      _mLevelStrip(id, L);
12464    }
12465    _mApplyLevelModes(id);     // levels landed — repaint charm/EOD/data-mode lines from c.lv
12466    _mRenderOverlay(id);       // GEX visual + drawings for this pane's ticker
12467  } catch (e) {}
12468}
12469// A fresh pane opens showing the recent action rather than the entire history crammed edge to
12470// edge, so the candles are readable without having to drag/resize first.
12471function _mZoomOut(id){
12472  const c = _mCharts[id]; if (!c || !c.bars || !c.bars.length) return;
12473  try {
12474    const n = c.bars.length, show = Math.min(n, 140);
12475    c.obj.timeScale().setVisibleLogicalRange({ from: n - show, to: n + 3 });
12476  } catch (e) {}
12477}
12478// ── Live desk: every pane refreshes on its own, whichever chart is focused (user request) ──
12479// Forming candle every 3s (cheap — /api/price is served from memory), full candles + levels every
12480// 60s so a rolled-over bar and moved GEX levels land without a page reload.
12481let _mLiveTimer = null;
12482function _mLiveStart(){
12483  if (_mLiveTimer) return;
12484  _mLiveTimer = setInterval(() => {
12485    if (_mLayout < 2 || (typeof currentView !== 'undefined' && currentView !== 'chart')) return;
12486    if (document.hidden) return;                       // no polling for a tab nobody is looking at
12487    for (const id of [2, 3]) _mTick(id);
12488  }, 3000);
12489}
12490function _mLiveStop(){ if (_mLiveTimer) { clearInterval(_mLiveTimer); _mLiveTimer = null; } }
12491async function _mTick(id){
12492  const c = _mCharts[id]; if (!c || !c.ticker || !c.bars || !c.bars.length || c.ticking) return;
12493  c.ticking = true;
12494  try {
12495    const now = Date.now();
12496    // Periodic full refresh: picks up a new bar after rollover and re-reads the GEX levels.
12497    if (!c.fullAt || now - c.fullAt > 60000) { c.fullAt = now; c.ticking = false; return _mLoad(id); }
12498    const last = c.bars[c.bars.length - 1];
12499    const since = last && last._ts ? ('?since=' + (last._ts * 1000)) : '';
12500    const j = await fetch('/api/price/' + encodeURIComponent(c.ticker) + since).then(r => r.json()).catch(() => null);
12501    if (!_mCharts[id] || _mCharts[id] !== c) return;
12502    const px = j && +j.price;
12503    if (!px || !isFinite(px)) return;
12504    last.close = px;
12505    if (j.high != null && +j.high > last.high) last.high = +j.high;
12506    if (j.low != null && +j.low < last.low) last.low = +j.low;
12507    if (px > last.high) last.high = px;
12508    if (px < last.low) last.low = px;
12509    c.series.update({ time: last.time, open: last.open, high: last.high, low: last.low, close: last.close });
12510    try { _mBubLiveSample(id); } catch (e) {}   // live GEX column rides the forming candle
12511    const sp = document.querySelector('#mchart-pane-' + id + ' .mchart-spot');
12512    if (sp) sp.textContent = '$' + px.toFixed(2);
12513  } catch (e) {} finally { c.ticking = false; }
12514}
12515// Restore the saved desk on boot (chart view only — called once the chart is up).
12516function _mBootLayout(){
12517  if (_mLayout > 1) return;
12518  let n = 1; try { n = parseInt(localStorage.getItem('gf_chart_layout') || '1', 10); } catch (e) {}
12519  if (n === 2 || n === 3) { _mDeskOpened = true; setChartLayout(n); }   // don't re-minimize panels on every reload
12520}
12521
12522// ── Trade Ideas ────────────────────────────────────────────────────────────────
12523// The session DEFAULT follows the clock, not a stored choice (user request): Evening from the
12524// 12:40 PM PT build until the next morning's 6:45 AM build (incl. overnight), Morning in between.
12525// A stored preference from a previous day would show the wrong list half the time.
12526function _ideasClockSession(){ try { const p = new Date(new Date().toLocaleStr
12526ing('en-US', { timeZone: 'America/Los_Angeles' })); const m = p.getHours() * 60 + p.getMinutes(); return (m >= 760 || m < 405) ? 'pm' : 'am'; } catch (e) { return 'am'; } }
12527// ── PT → viewer-timezone labels (user request) ─────────────────────────────────
12528// Every fire-time in the UI is written in PT ("6:45 AM PT", "6:45 AM – 1 PM PT"). These rewrite
12529// such strings into the VIEWER's timezone (an Eastern user sees "9:45 AM EDT"); viewers already
12530// in PT are left untouched. gfPtifyTree converts every text node + title under a root, and is
12531// idempotent (converted text no longer says PT, so re-runs are no-ops).
12532// REGRESSION FIXED 2026-08-25: both of these read the BROWSER's zone, while fmtTime/tzAbbr read
12533// getUserTZ() - the zone actually picked in the menu. So a PT machine measured an offset of 0 no
12534// matter what the viewer had selected, gfPtifyTree early-returned, and every fire-time on Setups
12535// and Watchlist stayed in PT while the rest of the dashboard converted. Both now go through the
12536// same getUserTZ()/tzOffsetSec pair as everything else, so one setting drives every time in the UI.
12537function _gfPtOffMin(){ try { const now = Date.now(); return Math.round((tzOffsetSec(getUserTZ(), now) - tzOffsetSec('America/Los_Angeles', now)) / 60); } catch (e) { return 0; } }
12538function _gfTzAbbr(){ try { return tzAbbr() || ''; } catch (e) { return ''; } }
12539function gfLocalizePt(str){
12540  try {
12541    const off = _gfPtOffMin(); if (!off || typeof str !== 'string') return str;
12542    const abbr = _gfTzAbbr();
12543    const fmt = mins => { mins = ((mins % 1440) + 1440) % 1440; let h = Math.floor(mins / 60); const m = mins % 60, ap = h >= 12 ? 'PM' : 'AM'; h = h % 12 || 12; return h + (m ? (':' + String(m).padStart(2, '0')) : '') + ' ' + ap; };
12544    const to24 = (h, mm, ap) => { h = (+h) % 12; if (/p/i.test(ap || '')) h += 12; return h * 60 + (+mm || 0); };
12545    str = str.replace(/(\d{1,2})(?::(\d{2}))?\s*(AM|PM)?\s*([–-])\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*P[DS]?T\b/gi,
12546      (s, h1, m1, ap1, dash, h2, m2, ap2) => fmt(to24(h1, m1, ap1 || ap2) + off) + ' ' + dash + ' ' + fmt(to24(h2, m2, ap2) + off) + ' ' + abbr);
12547    str = str.replace(/(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*P[DS]?T\b/gi, (s, h, m, ap) => fmt(to24(h, m, ap) + off) + ' ' + abbr);
12548    return str;
12549  } catch (e) { return str; }
12550}
12551function gfPtifyTree(root){
12552  try {
12553    if (!root || !_gfPtOffMin()) return;
12554    const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const list = [];
12555    while (w.nextNode()) list.push(w.currentNode);
12556    for (const n of list) { const v = n.nodeValue; if (v && /P[DS]?T\b/.test(v)) { const nv = gfLocalizePt(v); if (nv !== v) n.nodeValue = nv; } }
12557    root.querySelectorAll('[title]').forEach(el => { const t = el.getAttribute('title'); if (t && /P[DS]?T\b/.test(t)) { const nt = gfLocalizePt(t); if (nt !== t) el.setAttribute('title', nt); } });
12558  } catch (e) {}
12559}
12560// Boot pass on the static bars, then keep dynamic renders converted while the views are open.
12561setTimeout(function(){ gfPtifyTree(document.getElementById('ideasView')); }, 500);
12562setInterval(function(){
12563  try {
12564    if (typeof currentView === 'undefined') return;
12565    if (currentView === 'ideas') gfPtifyTree(document.getElementById('ideasView'));
12566    else if (currentView === 'chart' || currentView === 'ultra') gfPtifyTree(document.getElementById('chartTradePlan'));
12567  } catch (e) {}
12568}, 3000);
12569let _ideasSession = 'am', _ideasMode = 'snapshot', _ideasLoaded = 0;   // pinned: evening setups retired, the Morning/Evening toggle is gone
12570// Live mode is removed from the UI, so a stored 'live' is ignored — otherwise anyone who last used
12571// Live would load into it with no button left to switch back out.
12572try { const _m = localStorage.getItem('gf_ideas_mode'); if (_m === 'snapshot') _ideasMode = _m; else if (_m) localStorage.removeItem('gf_ideas_mode'); } catch (e) {}
12573function _ideasSyncBtns(){ document.querySelectorAll('#ideasView .ideas-sess').forEach(b=>{ const on=b.dataset.sess===_ideasSession; b.style.background=on?'var(--cyan)':'transparent'; b.style.color=on?'var(--bg)':'#cbd5e1'; b.style.borderColor=on?'var(--cyan)':'var(--border)'; }); document.querySelectorAll('#ideasView .ideas-mode').forEach(b=>{ const on=b.dataset.mode===_ideasMode; b.style.background=on?'var(--cyan)':'transparent'; b.style.color=on?'var(--bg)':'#cbd5e1'; b.style.borderColor=on?'var(--cyan)':'var(--border)'; }); }
12574function setIdeasSession(s){ if(s==='ah' && !(typeof isAdminUser!=='undefined' && isAdminUser)) s='am'; _ideasSession=(s==='pm'?'pm':s==='ah'?'ah':'am'); try{ localStorage.setItem('gf_ideas_sess',_ideasSession); }catch(e){} _ideasSyncBtns(); loadTradeIdeas(true); }
12575function setIdeasMode(m){ _ideasMode=(m==='live'?'live':'snapshot'); try{ localStorage.setItem('gf_ideas_mode',_ideasMode); }catch(e){} _ideasSyncBtns(); loadTradeIdeas(true); }
12576// ── 12:40 PM PT evening rollover — no click needed (user request) ──────────────
12577// The server re-scans on its own schedule; this makes an ALREADY-OPEN dashboard show the evening
12578// lists the moment they land: poll for today's PM Setups snapshot from 12:40, and when it exists
12579// flip the session toggle to Evening and refetch whichever Ideas tab is open (held lists only —
12580// never a forced pull). One-shot per day; switching back to Morning afterwards is respected.
12581let _pmRollDay=null, _pmRollTries=0;
12582setInterval(function(){
12583  try{
12584    if(!(typeof isPaidUser!=='undefined' && isPaidUser)) return;
12585    const p=new Date(new Date().toLocaleString('en-US',{timeZone:'America/Los_Angeles'}));
12586    const day=p.getFullYear()+'-'+String(p.getMonth()+1).padStart(2,'0')+'-'+String(p.getDate()).padStart(2,'0');
12587    const mins=p.getHours()*60+p.getMinutes(), dow=p.getDay();
12588    if(dow===0||dow===6) return;
12589    // Evening Setups are retired (2026-08-08): the after-close scan (1:15 PM PT) rebuilds the
12590    // WATCHLIST + Ghost for tomorrow, so the roll now keys on the watchlist's PM snapshot.
12591    if(mins<795){ if(_pmRollDay!==day){ _pmRollTries=0; } return; }    // before 1:15 PM PT
12592    if(mins>=840){ _pmRollDay=day; return; }                           // past 2 PM PT — a page opened later already loads the PM lists; no late toast
12593    if(_pmRollDay===day) return;                                       // already rolled today
12594    if(++_pmRollTries>30){ _pmRollDay=day; return; }                   // stop retrying after ~30 min
12595    fetch('/api/watchlist').then(r=>r.json()).then(function(j){
12596      if(_pmRollDay===day) return;                                     // AUDIT FIX: a slow response overlapping the next tick's must not double-fire the payload
12597      if(!(j && j.wlKind==='pm' && (j.snapDate||'').slice(0,10)===day)) return;   // after-close scan hasn't landed — retry next minute
12598      _pmRollDay=day;
12599      // AUDIT FIX: only drive the full session-switch (which itself fetches) when the Ideas view
12600      // is open; otherwise just flip the state so the next visit lands on Evening. Also: the
12601      // in-flight flags are respected instead of force-cleared (that allowed overlapping paints).
12602      try{
12603        if(typeof currentView!=='undefined' && currentView==='ideas'){
12604          if(_ideasTab==='earnings'){ loadEarnings(true); }
12605          else if(_ideasTab==='watchlist'){ loadWatchlist(); }
12606        }
12607      }catch(e){}
12608      try{ showToast(gfLocalizePt('Tomorrow\'s list is in — the Watchlist rebuilt on the full day\'s flow (after-close 1:15 PM PT scan).')); }catch(e){}
12609    }).catch(function(){});
12610  }catch(e){}
12611}, 60000);
12612// ── Earnings tab ──
12613let _ideasTab='setups', _earnDate=null, _earnLoaded=0;
12614// ALL TRADES tab (user 2026-09-09). Every book in one list. The card is rendered SERVER side and
12615// fetched as a PNG, so what you preview is byte-identical to what posts to Discord - no second
12616// renderer to drift out of step with the first.
12617function _atFmtUsd(v){ if(v==null) return '-'; const a=Math.abs(v); return (v<0?'-$':'+$')+a.toLocaleString('en-US'); }
12618function _atCol(v){ return v==null?'#64748b':(v>=0?'#22c55e':'#ef4444'); }
12619function _atContract(c){ if(!c||c.strike==null) return '-'; const e=String(c.expiry||'').slice(5).replace('-','/'); return '$'+c.strike+' '+String(c.type||'')[0]+' '+e; }
12620// SORTABLE COLUMNS (user 2026-09-09: "all trades tab make column sortable"). The fetch and the
12621// render are split so a header click re-sorts what is already in hand instead of re-querying the
12622// server - the rows do not change, only their order. Click a header to sort, click the same one
12623// again to flip, and the arrow shows which column is driving it. Default is the server's own order,
12624// newest exit first, and the Date header returns to it.
12625var _atRows=[], _atTotals={}, _atSortKey='', _atSortDir=-1;
12626var _AT_COLS=[
12627  {k:'date',    t:'Date',     get:function(r){ return r.date||''; }},
12628  {k:'lane',    t:'Book',     get:function(r){ return r.lane||''; }},
12629  {k:'ticker',  t:'Ticker',   get:function(r){ return r.ticker||''; }},
12630  {k:'strike',  t:'Contract', get:function(r){ return (r.contract&&r.contract.strike!=null)?+r.contract.strike:null; }},
12631  {k:'entry',   t:'Entry',    num:1, get:function(r){ return r.entryPrem; }},
12632  {k:'exit',    t:'Exit',     num:1, get:function(r){ return r.exitPrem; }},
12633  {k:'plPct',   t:'P/L %',    num:1, get:function(r){ return r.plPct; }},
12634  {k:'plUsd',   t:'P/L $',    num:1, get:function(r){ return r.plUsd; }},
12635  {k:'status',  t:'Status',   get:function(r){ return String(r.status||''); }}
12636];
12637function atSort(k){
12638  if(_atSortKey===k){ _atSortDir=-_atSortDir; }
12639  else { _atSortKey=k; _atSortDir=(k==='ticker'||k==='lane'||k==='status')?1:-1; }
12640  _atRender();
12641}
12642function _atSorted(){
12643  if(!_atSortKey) return _atRows.slice();
12644  var col=null;
12644 for(var i=0;i<_AT_COLS.length;i++){ if(_AT_COLS[i].k===_atSortKey){ col=_AT_COLS[i]; break; } }
12645  if(!col) return _atRows.slice();
12646  // Nulls always sink, whichever way the sort is pointing - a row with no exit price is not
12647  // "the smallest", it is missing, and floating it to the top on every flip just hides the data.
12648  return _atRows.slice().sort(function(a,b){
12649    var x=col.get(a), y=col.get(b);
12650    var xn=(x==null||x===''), yn=(y==null||y==='');
12651    if(xn&&yn) return 0; if(xn) return 1; if(yn) return -1;
12652    if(typeof x==='number'&&typeof y==='number') return (x-y)*_atSortDir;
12653    return String(x).localeCompare(String(y))*_atSortDir;
12654  });
12655}
12656function loadAllTrades(){
12657  const body=document.getElementById('atBody'); if(!body) return;
12658  const d=(document.getElementById('atDays')||{}).value||'7';
12659  body.innerHTML='Loading...';
12660  fetch('/api/all-trades?days='+encodeURIComponent(d),{credentials:'include'}).then(r=>r.json()).then(function(j){
12661    if(j.error){ body.innerHTML='<span style="color:#ef4444">'+j.error+'</span>'; return; }
12662    _atRows=j.rows||[]; _atTotals=j.totals||{};
12663    _atRender();
12664  }).catch(function(e){ body.innerHTML='<span style="color:#ef4444">'+e+'</span>'; });
12665}
12666function _atRender(){
12667  const body=document.getElementById('atBody'); if(!body) return;
12668  const t=_atTotals||{};
12669  const tt=document.getElementById('atTotals');
12670  if(tt) tt.innerHTML=(t.trades||0)+' closed \u00b7 <span style="color:#22c55e">'+(t.wins||0)+'W</span> / <span style="color:#ef4444">'+(t.losses||0)+'L</span> \u00b7 <span style="color:'+_atCol(t.usd)+'">'+_atFmtUsd(t.usd)+'</span>';
12671  const rows=_atSorted();
12672  if(!rows.length){ body.innerHTML='<span style="color:#64748b">No trades in this window.</span>'; return; }
12673  let h='<table style="width:100%;border-collapse:collapse"><thead><tr style="color:#94a3b8;font-size:10.5px;text-align:left">';
12674  _AT_COLS.forEach(function(c){
12675    const on=_atSortKey===c.k;
12676    const arrow=on?(_atSortDir>0?' \u25b2':' \u25bc'):'';
12677    h+='<th onclick="atSort(\''+c.k+'\')" title="Sort by '+c.t+'" style="padding:6px 8px;cursor:pointer;user-select:none;white-space:nowrap;'
12678      +(c.num?'text-align:right;':'')+(on?'color:#22d3ee':'')+'">'+c.t+arrow+'</th>';
12679  });
12680  h+='<th style="padding:6px 8px;text-align:right">Card</th></tr></thead><tbody>';
12681  rows.forEach(function(r){
12682    const st=String(r.status||'').toUpperCase();
12683    const stCol=st==='WIN'?'#22c55e':st==='LOSS'?'#ef4444':st==='VOID'?'#64748b':'#eab308';
12684    const k=String(r.key).replace(/'/g,'');
12685    h+='<tr style="border-top:1px solid rgba(148,163,184,.12)">'
12686      +'<td style="padding:6px 8px;color:#94a3b8">'+(r.date||'')+'</td>'
12687      +'<td style="padding:6px 8px;color:#22d3ee;font-weight:700">'+(r.lane||'')+(r.build?' <span style="color:#64748b;font-weight:400">'+r.build+'</span>':'')+'</td>'
12688      +'<td style="padding:6px 8px;color:#e2e8f0;font-weight:800">'+(r.ticker||'')+'</td>'
12689      +'<td style="padding:6px 8px;color:#cbd5e1">'+_atContract(r.contract)+'</td>'
12690      +'<td style="padding:6px 8px;text-align:right;color:#cbd5e1">'+(r.entryPrem!=null?('$'+r.entryPrem.toFixed(2)):'-')+'</td>'
12691      +'<td style="padding:6px 8px;text-align:right;color:#cbd5e1">'+(r.exitPrem!=null?('$'+r.exitPrem.toFixed(2)):'-')+'</td>'
12692      +'<td style="padding:6px 8px;text-align:right;font-weight:800;color:'+_atCol(r.plPct)+'">'+(r.plPct!=null?((r.plPct>=0?'+':'')+r.plPct+'%'):'-')+'</td>'
12693      +'<td style="padding:6px 8px;text-align:right;font-weight:800;color:'+_atCol(r.plUsd)+'">'+_atFmtUsd(r.plUsd)+'</td>'
12694      +'<td style="padding:6px 8px;font-weight:800;color:'+stCol+'"'+(r.voidWhy?(' title="'+String(r.voidWhy).replace(/"/g,'')+'"'):'')+'>'+st+(r.voidWhy&&st==='VOID'?'<div style="font-weight:400;color:#fbbf24;font-size:10px;margin-top:2px;white-space:normal;max-width:240px;line-height:1.3">⚠ '+String(r.voidWhy).replace(/</g,'&lt;')+'</div>':'')+'</td>'
12695      +'<td style="padding:6px 8px;text-align:right;white-space:nowrap">'
12696      +((String(r.status||'')==='open' && !r.pending)
12697          ? '<button onclick="gfExitPos(this,\''+k+'\')" title="Close this position now at the live mark 
12697- books the profit or loss" style="font-size:10px;font-weight:800;color:#fca5a5;background:transparent;border:1px solid rgba(239,68,68,.5);border-radius:5px;padding:2px 8px;cursor:pointer;margin-right:4px">Exit</button>'
12698          : '')
12699      +(r.runner
12700          ? '<span title="'+(r.runner.on?'Runner still riding from the same entry':'Runner ended')+'" style="font-size:10px;font-weight:800;color:'+_atCol(r.runner.pct)+';margin-right:6px">RUN '+(r.runner.pct!=null?((r.runner.pct>=0?'+':'')+Math.round(r.runner.pct)+'%'):'-')+(r.runner.on?'':' \u00b7 ended')+'</span>'
12701            +'<button onclick="atCard(\''+k+'\',1)" title="Preview the runner card: one contract from the same entry, where it is now" style="font-size:10px;font-weight:800;color:#67e8f9;background:transparent;border:1px solid rgba(103,232,249,.5);border-radius:5px;padding:2px 8px;cursor:pointer">Runner</button>'
12702            +' <button onclick="atPost(this,\''+k+'\',1)" title="Post the runner card into the GHOST alert channel" style="font-size:10px;font-weight:800;color:#c4b5fd;background:transparent;border:1px solid rgba(196,181,253,.5);border-radius:5px;padding:2px 8px;cursor:pointer;margin-right:8px">Post</button>'
12703          : '')
12704      +(r.cardable
12705          ? '<button onclick="atCard(\''+k+'\')" title="Preview the P/L card" style="font-size:10px;font-weight:800;color:#22d3ee;background:transparent;border:1px solid rgba(34,211,238,.5);border-radius:5px;padding:2px 8px;cursor:pointer">Card</button>'
12706            +' <button onclick="atPost(this,\''+k+'\')" title="Post this card into the GHOST alert channel" style="font-size:10px;font-weight:800;color:#a78bfa;background:transparent;border:1px solid rgba(167,139,250,.5);border-radius:5px;padding:2px 8px;cursor:pointer">Post</button>'
12707          : '<span style="color:#475569;font-size:10px">no card</span>')
12708      +'</td></tr>';
12709  });
12710  body.innerHTML=h+'</tbody></table>';
12711}
12712// Preview: the same endpoint Discord gets, shown in the existing lightbox.
12713function atCard(key, runner){
12714  const u='/api/exit-card?key='+encodeURIComponent(key)+(runner?'&runner=1':'')+'&t='+Date.now();
12715  try{ if(typeof gfLb==='function'){ gfLb(u); return; } }catch(e){}
12716  window.open(u,'_blank');
12717}
12718// ── ACCOUNT TAB ──
12719let _acctData=null, _acctPeriod='1D';
12720const _ACCT_PERIODS=['1D','1W','1M','3M','YTD','1Y','ALL'];
12721const _acctUsd=v=>(v<0?'-':'')+'$'+Math.abs(v).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2});
12722function loadAccountTab(){
12723  fetch('/api/setups-account',{credentials:'include'}).then(r=>r.json()).then(function(j){
12724    if(!j || j.error){ const b=document.getElementById('acctBal'); if(b) b.textContent=(j&&j.error)||'unavailable'; return; }
12725    _acctData=j; _acctRender();
12726  }).catch(function(e){ const b=document.getElementById('acctBal'); if(b) b.textContent=String(e); });
12727}
12728function acctSetPeriod(p){ if(_ACCT_PERIODS.indexOf(p)<0) return; _acctPeriod=p; _acctRender(); }
12729function _acctRender(){
12730  const j=_acctData; if(!j) return;
12731  const S=j.series && j.series[_acctPeriod]; if(!S) return;
12732  const up=S.chg>=0, col=up?'#00c805':'#ff5000';
12733  const bal=document.getElementById('acctBal'); if(bal) bal.textContent=_acctUsd(j.balance);
12734  const chg=document.getElementById('acctChg'); if(chg){ chg.style.color=col; chg.innerHTML=(up?'&#9650; ':'&#9660; ')+_acctUsd(S.chg).replace('-','')+' ('+(up?'':'-')+Math.abs(S.chgPct).toFixed(2)+'%) <span style="color:#6b7280;font-weight:400">'+(S.label||'')+'</span>'; }
12735  const op=document.getElementById('acctOpen'); if(op){ const ou=j.openPl>=0; op.style.color=ou?'#00c805':'#ff5000'; op.innerHTML=j.openCount?((ou?'&#9650; ':'&#9660; ')+_acctUsd(j.openPl).replace('-','')+' ('+(ou?'':'-')+Math.abs(j.openPlPct).toFixed(2)+'%) <span style="color:#6b7280;font-weight:400">Open</span>'):''; }
12736  const bp=document.getElementById('acctBp'); if(bp) bp.textContent=_acctUsd(j.buyingPower);
12737  // the line
12738  const svg=document.getElementById('acctChart');
12739  if(svg){
12740    const W=1000,H=240,pts=S.pts||[]; const vals=pts.map(p=>p.bal).concat([S.base]);
12741    let lo=Math.min.apply(null,vals), hi=Math.max.apply(null,vals); if(hi-lo<1){ lo-=1; hi+=1; } const pad=(hi-lo)*0.18; lo-=pad; hi+=pad;
12742    const X=x=>(x*W).toFixed(1), Y=b=>(H-(b-lo)/(hi-lo)*H).toFixed(1);
12743    let d=''; pts.forEach((p,i)=>{ d+=(i?' L ':'M ')+X(p.x)+' '+Y(p.bal); });
12744    let h='<line x1="0" y1="'+Y(S.base)+'" x2="'+W+'" y2="'+Y(S.base)+'" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="2 4" vector-effect="non-scaling-stroke"/>';
12745    h+='<path d="'+d+'" fill="none" stroke="'+col+'" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>';
12746    if(_acctPeriod==='1D' && S.nowX<1 && pts.length){ const yl=Y(pts[pts.length-1].bal); h+='<line x1="'+X(S.nowX)+'" y1="'+yl+'" x2="'+W+'" y2="'+yl+'" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="2 4" vector-effect="non-scaling-stroke"/><circle cx="'+X(S.nowX)+'" cy="'+yl+'" r="4" fill="'+col+'"/>'; }
12747    svg.innerHTML=h;
12748  }
12749  const tabs=document.getElementById('acctTabs');
12750  if(tabs) tabs.innerHTML=_ACCT_PERIODS.map(p=>'<span onclick="acctSetPeriod(\''+p+'\')" style="cursor:pointer;padding-bottom:8px;font-weight:'+(p===_acctPeriod?'700':'600')+';color:'+(p===_acctPeriod?col:'#1c1c1c')+';border-bottom:2px solid '+(p===_acctPeriod?col:'transparent')+'">'+p+'</span>').join('');
12751  const row=function(t,closed){
12752    const pu=t.pl>=0, pc=pu?'#00c805':'#ff5000';
12753    const exp=String(t.expiry||'').slice(5).replace('-','/').replace(/^0/,'');
12754    const nm=t.ticker+' $'+(+t.strike).toLocaleString('en-US')+' '+(String(t.type||'').toUpperCase()==='PUT'?'Put':'Call');
12755    return '<div style="display:flex;justify-content:space-between;align-items:center;padding:13px 18px;border-bottom:1px solid #f0f0f0">'
12756      +'<div><div style="font-size:15px;font-weight:700;color:#1c1c1c">'+nm+'</div><div style="font-size:13px;color:#6b7280;margin-top:3px">'+exp+' &middot; 1 Buy'+(closed?' &middot; closed '+String(t.day||'').slice(5).replace('-','/'):'')+'</div></div>'
12757      +'<div style="text-align:right"><div style="font-size:15px;font-weight:400;color:#1c1c1c">'+(pu?'+':'-')+_acctUsd(Math.abs(t.pl)).replace('-','')+'</div><div style="font-size:13px;font-weight:600;color:'+pc+'">'+(pu?'+':'')+(+t.plPct).toFixed(2)+'%</div></div></div>';
12758  };
12759  const ol=document.getElementById('acctOpenList'); if(ol) ol.innerHTML=(j.open&&j.open.length)?j.open.map(t=>row(t,false)).join(''):'<div style="padding:12px 18px;font-size:13px;color:#6b7280">No open positions</div>';
12760  const cl=document.getElementById('acctClosedList'); if(cl) cl.innerHTML=(j.closed&&j.closed.length)?j.closed.map(t=>row(t,true)).join(''):'<div style="padding:12px 18px;font-size:13px;color:#6b7280">No closed trades yet</div>';
12761}
12762// The account card: preview in the lightbox, post with the same tw
12762o-step arm as the exit card.
12763function _atAcctPeriod(){ return (typeof _acctPeriod==='string' && _acctPeriod) ? _acctPeriod : '1D'; }   // the Account tab's selected period
12764function atAcctCard(){
12765  const u='/api/setups-account/card?period='+encodeURIComponent(_atAcctPeriod())+'&t='+Date.now();
12766  try{ if(typeof gfLb==='function'){ gfLb(u); return; } }catch(e){}
12767  window.open(u,'_blank');
12768}
12769var _atAcctArmed=null;
12770function atAcctPost(btn){
12771  if(!_atAcctArmed){
12772    _atAcctArmed=setTimeout(function(){ _atAcctArmed=null; try{ btn.textContent='Post'; btn.style.background='transparent'; }catch(e){} },4000);
12773    btn.textContent='Sure?'; btn.style.background='rgba(167,139,250,.22)'; return;
12774  }
12775  clearTimeout(_atAcctArmed); _atAcctArmed=null;
12776  btn.textContent='...'; btn.disabled=true;
12777  fetch('/api/setups-account/post',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({period:_atAcctPeriod()})})
12778    .then(r=>r.json()).then(function(j){
12779      if(j && j.ok){ btn.textContent='Posted'; btn.style.color='#22c55e'; btn.style.borderColor='rgba(34,197,94,.5)'; try{ showToast('Posted the '+j.period+' account card to GHOST'); }catch(e){} setTimeout(function(){ btn.textContent='Post'; btn.disabled=false; btn.style.color='#c4b5fd'; btn.style.borderColor='rgba(196,181,253,.5)'; btn.style.background='transparent'; }, 4000); }
12780      else { btn.textContent='Post'; btn.disabled=false; btn.style.background='transparent'; try{ showToast('Post failed: '+((j&&j.error)||'unknown')); }catch(e){} }
12781    })
12782    .catch(function(e){ btn.textContent='Post'; btn.disabled=false; btn.style.background='transparent'; try{ showToast('Post failed: '+e); }catch(e2){} });
12783}
12784// Post: two-step, because it publishes to the channel and cannot be taken back.
12785var _atArmed={};
12786function atPost(btn,key,runner){
12787  const ak=key+(runner?'|runner':'');   // the exit card and the runner card arm separately
12788  if(!_atArmed[ak]){
12789    _atArmed[ak]=setTimeout(function(){ delete _atArmed[ak]; try{ btn.textContent='Post'; btn.style.background='transparent'; }catch(e){} },4000);
12790    btn.textContent='Sure?'; btn.style.background='rgba(167,139,250,.22)'; return;
12791  }
12792  clearTimeout(_atArmed[ak]); delete _atArmed[ak];
12793  btn.textContent='...'; btn.disabled=true;
12794  fetch('/api/exit-card/post',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(runner?{key:key,runner:true}:{key:key})})
12795    .then(r=>r.json()).then(function(j){
12796      if(j && j.ok){ btn.textContent='Posted'; btn.style.color='#22c55e'; btn.style.borderColor='rgba(34,197,94,.5)'; try{ showToast('Posted '+j.ticker+(runner?' runner':'')+' card to GHOST'); }catch(e){} }
12797      else { btn.textContent='Post'; btn.disabled=false; btn.style.background='transparent'; try{ showToast('Post failed: '+((j&&j.error)||'unknown')); }catch(e){} }
12798    })
12799    .catch(function(e){ btn.textContent='Post'; btn.disabled=false; btn.style.background='transparent'; try{ showToast('Post failed: '+e); }catch(e2){} });
12800}
12801function _ideasTabSync(){ try{ _dupTake('ideas', _ideasTab); }catch(e){} document.querySelectorAll('#ideasView .ideas-tab').forEach(b=>{ const on=b.dataset.tab===_ideasTab; const ghostBtn=b.dataset.tab==='ghost';
12802  b.style.background=on?(ghostBtn?'#a855f7':'var(--cyan)'):'transparent'; b.style.color=on?(ghostBtn?'#fff':'var(--bg)'):(ghostBtn?'#c084fc':'#cbd5e1'); b.style.borderColor=on?(ghostBtn?'#a855f7':'var(--cyan)'):(ghostBtn?'rgba(168,85,247,.5)':'var(--border)'); });
12803  const earn=_ideasTab==='earnings', watch=_ideasTab==='watchlist', ghost=_ideasTab==='ghost', idx=_ideasTab==='index', ins=_ideasTab==='insider', gfl=_ideasTab==='gflip', eod=_ideasTab==='eod', prn=_ideasTab==='prints', alt=_ideasTab==='alltrades', acc=_ideasTab==='account', off=earn||watch||ghost||idx||ins||gfl||eod||prn||alt||acc;
12804  try{ gfWsReapplySideSoon('ideas'); }catch(e){}   // this sub-tab's side column has never been collapsed
12805  // the tab you just picked can be off the edge of a narrow tile; bring it into view
12806  try{ const _at=document.querySelector('#ideasTabRow .ideas-tab[data-tab="'+_ideasTab+'"]');
12807       if(_at && _at.scrollIntoView) _at.scrollIntoView({block:'nearest',inline:'nearest'}); }catch(e){}
12808  var nodeW=document.getElementById('ultraNodePane'); if(nodeW && getComputedStyle(nodeW).display!=='none'){ try{ loadUrNodes(); }catch(e){} }
12809  var atW=document.getElementById('allTradesWrap'); if(atW) atW.style.display=alt?'flex':'none';
12810  var acW=document.getElementById('accountWrap'); if(acW) acW.style.display=acc?'flex':'none';
12811  var insW=document.getElementById('insiderWrap'); if(insW) insW.style.display=ins?'flex':'none';
12812  var gfW=document.getElementById('gflipWrap'); if(gfW) gfW.style.display=gfl?'flex':'none';
12813  var eoW=document.getElementById('eodWrap'); if(eoW) eoW.style.display=eod?'flex':'none';
12814  var prW=document.getElementById('printsWrap'); if(prW) prW.style.display=prn?'flex':'none';
12815  // Setups are MORNING-ONLY (evening retired 2026-08-08 — 27% vs 56% win rate): on the Setups tab
12816  // the Morning/Evening toggle hides and the session pins to am. Other tabs keep both sessions
12817  // (the Evening watchlist/ghost are now the after-close "for tomorrow" builds).
12818  var sb=document.getElementById('ideasSetupBar'), eb=document.getElementById('ideasEarnBar'), wb=document.getElementById('ideasWatchBar'), gb=document.getElementById('ideasGhostBar'), ib=document.getElementById('ideasIdxBar'),
12819      sw=document.getElementById('ideasSetupWrap'), ew=document.getElementById('earningsWrap'), ww=document.getElementById('watchlistWrap'), gw=document.getElementById('ghostWrap'), iw=document.getElementById('idxWrap');
12820  if(sb) sb.style.display=off?'none':'contents'; if(eb) eb.style.display=earn?'flex':'none'; if(wb) wb.style.display=watch?'flex':'none'; if(gb) gb.style.display=ghost?'flex':'none'; if(ib) ib.style.display=idx?'flex':'none';
12821  if(sw) sw.style.display=off?'none':'flex'; if(ew) ew.style.display=earn?'flex':'none'; if(ww) ww.style.display=watch?'flex':'none'; if(gw) gw.style.display=ghost?'flex':'none'; if(iw) iw.style.display=idx?'flex':'none';
12822  try{ _dupAssert('ideas'); }catch(e){} }
12823function setIdeasTab(t){ if(t==='ghost') t='setups'; const _wantTab=String(t||'');   /* Ghost Trade retired 2026-08-08 */ if((t==='index'||t==='insider'||t==='gflip'||t==='eod'||t==='prints'||t==='alltrades'||t==='account') && !(typeof isAdminUser!=='undefined' && isAdminUser)) t='setups';   /* All Trades is admin-only (user 2026-09-09) */   /* G-Flip + Whales back to admin until tested (user 2026-09-06) */   /* SPX/SPY + Insider stay admin-only */ _ideasTab=(t==='earnings'?'earnings':t==='watchlist'?'watchlist':t==='ghost'?'ghost':t==='index'?'index':t==='insider'?'insider':t==='gflip'?'gflip':t==='eod'?'eod':t==='prints'?'prints':t==='alltrades'?'alltrades':t==='account'?'account':'setups'); _ideasTabSync(); if(_ideasTab==='earnings') loadEarnings(); else if(_ideasTab==='watchlist') loadWatchlist(); else if(_ideasTab==='index') loadIndexPlays(); else if(_ideasTab==='insider') loadInsiderTab(); else if(_ideasTab==='gflip') loadGflip(); else if(_ideasTab==='eod') loadEod(); else if(_ideasTab==='prints') loadPrints(); else if(_ideasTab==='alltrades') loadAllTrades(); else if(_ideasTab==='account') loadAccountTab(); else loadTradeIdeas();
12824  // REMEMBER IT. Saved only when the tab asked for is the tab we landed on: at boot, before
12825  // /api/me answers, isAdminUser is false and a restored 'prints' resolves to 'setups' - writing
12826  // that down would erase the choice we are in the middle of restoring.
12827  try{ if(_ideasTab===_wantTab){ sessionStorage.setItem('gfIdeasTab',_ideasTab); localStorage.setItem('gfIdeasTab',_ideasTab); window._bootIdeasTab=_ideasTab; } }catch(e){}
12828  try{ setTimeout(function(){ gfPtifyTree(document.getElementById('ideasView')); }, 600); }catch(e){}
12829  // the new sub-tab renders its own tracker, which has never been collapsed
12830  try{ if(typeof gfWsReapplySide==='function'){ gfWsReapplySide('ideas'); setTimeout(function(){ gfWsReapplySide('ideas'); }, 400); } }catch(e){} }
12831// ── INSIDER tab (admin): the 12:30 PM PT pre-close inside bar watch — full per-day lists. ──
12832// Columns are sortable: click a header to sort every day's table by it, click again to flip.
12833// Default = STATUS (ON TRACK first). Sort state survives re-renders/refreshes of the tab.
12834let _insDays=null, _insSort={col:'st',dir:1};
12835function insSort(col){ if(_insSort.col===col) _insSort.dir=-_insSort.dir; else _insSort={col:col,dir:1}; _renderInsider(); }
12836function _insVal(x,c){
12837  switch(c){
12838    case 'tk': return x.tk||'';
12839    case 'tf': return x.tf||'';
12840    case 'dir': return x.dir||'';
12841    case 'rect': return +x.lo||0;
12842    case 'px': return +x.px||0;
12843    default: return x.st==='broke'?2:x.st==='retest'?1:0;   // 'st' — ON TRACK first
12844  }
12845}
12846const _INS_TF_SHORT={ '1H':'1H', '4H':'4H', 'Daily':'D', 'Weekly':'W' };
12847const _INS_TF_ORD={ '1H':0, '4H':1, 'Daily':2, 'Weekly':3 };
12848// Which timeframes the table shows — 1H/4H exist in the scan but default OFF (they confirm at the
12849// next hourly/4H close, not the session close, so they're a different-tempo signal).
12850let _insTf=(function(){ try{ const s=JSON.parse(localStorage.getItem('gf_ins_tf')||'null'); if(s&&typeof s==='object') return s; }catch(e){} return { '1H':false,'4H':false,'Daily':true,'Weekly':true }; })();
12851// TV ONLY (user 2026-08-25): show only boxes a real TradingView alert confirmed (src 'tv'),
12852// hiding the scan's own anticipated rectangles. The server now merges the two per BOX so a ticker
12853// can't list twice with opposite directions; this narrows the list further to what actually fired.
12854let _insTvOnly=(function(){ try{ return localStorage.getItem('gf_ins_tvonly')==='1'; }catch(e){ return false; } })();
12855function insTvToggle(){ _insTvOnly=!_insTvOnly; try{ localStorage.setItem('gf_ins_tvonly',_insTvOnly?'1':'0'); }catch(e){} _renderInsider(); }
12856function insTfToggle(tf){ _insTf[tf]=!_insTf[tf]; try{ localStorage.setItem('gf_ins_tf',JSON.stringify(_insTf)); }catch(e){} _renderInsider(); }
12857let _insPulling=false;
12858function insPullNow(){
12859  if(_insPulling) return; _insPulling=true; _renderInsider();
12860  fetch('/api/ib-preclose/run').then(r=>r.json()).then(function(){ _insPulling=false; loadInsiderTab(); })
12861    .catch(function(){ _insPulling=false; _renderInsider(); });
12862}
12863// LIVE STATE TAG (user 2026-08-25) — the status chip reports the last BREAK EVENT, which can be
12864// stale: CRWD read "CONFIRMED ABOVE" on a 191.78-192.77 box while price sat at 191.59, under it.
12865// This appends where price is RIGHT NOW relative to the rectangle, so a failed break is obvious.
12866function _insNowTag(t){
12867  if(!t || !t.now) return '';
12868  if(t.holding) return '<span style="color:#22c55e;font-size:9.5px;font-weight:700;margin-left:6px" title="Price is still beyond the rectangle on the break side — the break is holding.">HOLDING</span>';
12869  if(t.now==='inside') return '<span style="color:#fbbf24;font-size:9.5px;font-weight:700;margin-left:6px" title="Price came back INSIDE the rectangle — the break failed and the box has re-armed.">
12869BACK INSIDE</span>';
12870  return '<span style="color:#fbbf24;font-size:9.5px;font-weight:700;margin-left:6px" title="Price is now beyond the OPPOSITE side of the rectangle — the break reversed.">NOW '+String(t.now).toUpperCase()+'</span>';
12871}
12872function _insStChip(t){
12873  const dc = t.dir==='bull' ? 'var(--green)' : 'var(--red)';
12874  const pre = t.both ? (_INS_TF_SHORT[t.tf]||t.tf)+': ' : '';
12875  if(t.st==='confirmed'){ const _nt=_insNowTag(t); const cd=t.brokeT?new Date(t.brokeT*1000):null; const cl=cd?((cd.getMonth()+1)+'/'+cd.getDate()):''; const tv=t.src==='tv'; return '<span style="color:'+dc+';font-weight:800;font-size:11px" title="'+(tv?'Confirmed by the REAL TradingView alert delivered to the dashboard webhook — not computed from our candles.':'The breakout candle has CLOSED — this break is confirmed (the TradingView alert fires on this close). Stays visible until a newer candle closes.')+'">'+pre+'CONFIRMED ✓'+(tv?' TV':'')+(cl?' ('+cl+')':'')+'</span>'+_nt; }
12876  if(t.st==='recent'){ const rd=t.brokeT?new Date(t.brokeT*1000):null; const rl=rd?((rd.getMonth()+1)+'/'+rd.getDate()):''; return '<span style="color:'+dc+';font-size:11px;font-weight:700" title="Broke on the previous candle (yesterday for Daily) — confirmed then; still fresh enough to act on">broke '+(rl||'prev')+'</span>'; }
12877  if(t.st==='broke'){ const bd=t.brokeT?new Date(t.brokeT*1000):null; const bl=bd?((bd.getMonth()+1)+'/'+bd.getDate()):''; return '<span style="color:#94a3b8;font-size:11px" title="A candle already CLOSED through this rectangle — the TradingView alert fired then; price is still beyond the box">'+pre+'broke'+(bl?' ('+bl+')':'')+'</span>'; }
12878  if(t.st==='retest'){ return '<span style="color:#fbbf24;font-size:11px" title="Price is beyond the box but the last close was already outside the other side — needs a close back inside before it can alert again">'+pre+'re-test</span>'; }
12879  return '<span style="color:'+dc+';font-weight:800;font-size:11px" title="Last close was still inside the rectangle — a close out here fires the real alert">'+pre+'ON TRACK</span>';
12880}
12881function _renderInsider(){
12882  const el=document.getElementById('insiderBody'); if(!el || !_insDays) return;
12883  // TODAY ONLY (user 2026-08-19): the newest scan, no previous-day history sections.
12884  const d=_insDays[0];
12885  if(!d){ el.innerHTML='<div class="col-loading">No scan yet today — the pre-close inside bar watch runs weekdays at 12:30 PM PT.</div>'; return; }
12886  let h='<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px;font-family:Segoe UI,monospace">'
12887    +['1H','4H','Daily','Weekly'].map(function(tf){ return '<button class="tf-btn'+(_insTf[tf]?' active':'')+'" onclick="insTfToggle(\''+tf+'\')" style="font-size:10px;padding:3px 10px">'+tf+'</button>'; }).join('')
12888    +'<button class="tf-btn'+(_insTvOnly?' active':'')+'" onclick="insTvToggle()" title="Show only boxes a real TradingView alert confirmed" style="font-size:10px;padding:3px 10px">TV only</button>'
12889    +'<button onclick="insPullNow()" '+(_insPulling?'disabled':'')+' style="margin-left:auto;border:1px solid rgba(251,191,36,.5);background:transparent;color:#fbbf24;border-radius:7px;padding:4px 12px;font-size:11px;font-weight:800;cursor:pointer">'+(_insPulling?'Scanning…':'↻ Pull now')+'</button></div>'
12890    +'<div style="font-size:11px;color:var(--dim);margin-bottom:12px;font-family:Segoe UI,monospace">Stocks trading beyond their active inside-bar rectangle — Daily confirms at the session close, Weekly at Friday\'s close, 1H/4H at their next candle close. A close back inside the range = no confirmation. Click a column header to sort.</div>';
12891  const _th=function(col,label){ const on=_insSort.col===col; return '<th style="padding:4px 8px;cursor:pointer;user-select:none'+(on?';color:#e2e8f0':'')+'" title="Click to sort" onclick="insSort(\''+col+'\')">'+label+(on?(_insSort.dir>0?' ▲':' ▼'):'')+'</th>'; };
12892  const at=fmtTime(d.at,{hour:'2-digit',minute:'2-digit',hour12:false});   // viewer's zone, not hardcoded PT
12893  // MERGE (user 2026-08-19): a ticker beyond several boxes in the same direction is O
12893NE row —
12894  // TF reads e.g. "4H + Daily + Weekly", rectangle and status shown per timeframe. The TF toggle
12895  // chips above filter which timeframes participate.
12896  const byKey={};
12897  for(const x of (d.hits||[])){ if(!_insTf[x.tf]) continue; if(_insTvOnly && x.src!=='tv') continue; const k=x.tk+'|'+x.dir; (byKey[k]||(byKey[k]={tk:x.tk,dir:x.dir,px:x.px,tfs:[]})).tfs.push(x); }
12898  let merged=Object.values(byKey);
12899  for(const m of merged){ m.tfs.sort(function(a,b){ return (_INS_TF_ORD[a.tf]||0)-(_INS_TF_ORD[b.tf]||0); }); const both=m.tfs.length>1; m.tfs.forEach(function(t){ t.both=both; t.dir=m.dir; }); }
12900  // ACTIONABLE ONLY (user 2026-08-19): a row must have at least ONE timeframe ON TRACK or a
12901  // just-CONFIRMED break (the breakout candle closed on the most recent bar) — pure broke-earlier /
12902  // re-test rows are dropped. A kept row still shows its other timeframes' states inline.
12903  merged=merged.filter(function(m){ return m.tfs.some(function(t){ return t.st==='fresh' || t.st==='confirmed' || t.st==='recent' || !t.st; }); });
12904  h+='<div style="font-size:11px;letter-spacing:1px;color:#fbbf24;font-weight:800;margin:14px 0 6px">'+d.date+' <span style="color:var(--dim);font-weight:400">· scanned '+at+' '+tzAbbr(d.at)+' · '+merged.length+(merged.length===1?' name':' names')+'</span></div>';
12905  if(!merged.length){ h+='<div style="color:var(--dim);font-size:12px">Nothing beyond a rectangle at scan time.</div>'; el.innerHTML=h; return; }
12906  h+='<table style="width:100%;border-collapse:collapse;font-family:Segoe UI,monospace;font-size:12.5px">'
12907    +'<thead><tr style="color:var(--dim);font-size:10px;letter-spacing:1px;text-align:left">'+_th('tk','TICKER')+_th('tf','TF')+_th('dir','SIDE')+_th('rect','RECTANGLE')+_th('px','PRICE @ SCAN')+_th('st','STATUS')+'</tr></thead><tbody>';
12908  const _mv=function(m,c){
12909    switch(c){
12910      case 'tk': return m.tk;
12911      case 'tf': return m.tfs.map(function(t){return t.tf;}).join('+');
12912      case 'dir': return m.dir;
12913      case 'rect': return +m.tfs[0].lo||0;
12914      case 'px': return +m.px||0;
12915      default: return Math.min.apply(null, m.tfs.map(function(t){ return t.st==='broke'?4:t.st==='retest'?3:t.st==='recent'?2:t.st==='confirmed'?1:0; }));   // best status wins the sort: ON TRACK, CONFIRMED, broke-prev, re-test, broke
12916    }
12917  };
12918  const rows=merged.slice().sort(function(a,b){ const va=_mv(a,_insSort.col), vb=_mv(b,_insSort.col); return (va<vb?-1:va>vb?1:0)*_insSort.dir; });
12919  for(const m of rows){
12920    const bull=m.dir==='bull', dc=bull?'var(--green)':'var(--red)';
12921    const allBroke=m.tfs.every(function(t){return t.st==='broke';});
12922    const tfCell=m.tfs.map(function(t){ return t.tf; }).join(' + ')+(m.tfs.some(function(t){return t.tf==='Weekly';})?' <span style="color:var(--dim);font-size:10px">(W: Fri close)</span>':'');
12923    const rectCell=m.tfs.map(function(t){ const lbl=(m.tfs.length>1?((_INS_TF_SHORT[t.tf]||t.tf)+' '):''); return t.lo==null?lbl+'<span style="color:var(--dim)" title="Confirmed by the real TradingView alert — TV does not send the box levels">TV alert</span>':lbl+(+t.lo).toFixed(2)+'–'+(+t.hi).toFixed(2); }).join(' · ');
12924    const stCell=m.tfs.map(function(t){ return _insStChip(t); }).join(' · ');
12925    h+='<tr style="border-top:1px solid rgba(30,41,59,.5)'+(allBroke?';opacity:.6':'')+'">'
12926      +'<td style="padding:6px 8px"><b style="color:#e2e8f0;cursor:pointer" onclick="setSyncedTicker(\''+m.tk+'\')" title="Open on the chart">'+m.tk+'</b></td>'
12927      +'<td style="padding:6px 8px;color:#cbd5e1">'+tfCell+'</td>'
12928      +'<td style="padding:6px 8px;color:'+dc+';font-weight:800">'+(bull?'▲ ABOVE':'▼ BELOW')+'</td>'
12929      +'<td style="padding:6px 8px;color:#cbd5e1">'+rectCell+'</td>'
12930      +'<td style="padding:6px 8px;color:#e2e8f0;font-weight:700">$'+(+m.px).toFixed(2)+'</td>'
12931      +'<td style="padding:6px 8px">'+stCell+'</td></tr>';
12932  }
12933  h+='</tbody></table>';
12934  el.innerHTML=h;
12935}
12936function loadInsiderTab(){
12937  const el=document.getElementById('insiderBody'); if(!el) return;
12938  fetch('/api/ib-preclose').then(r=>r.json()).then(j=>{
12939    if(j && j.error){ el.innerHTML='<div class="col-loading col-error">'+j.error+'</div>'; return; }
12940    _insDays=(j&&j.days)||[];
12941    _renderInsider();
12942  }).catch(()=>{ el.innerHTML='<div class="col-loading col-error">Could not load the pre-close list.</div>'; });
12943}
12944// ── SPX/SPY sequential plays tab — one live play + today's log, auto-refreshing ──
12945let _idxTimer=null;
12946function loadIndexPlays(force){
12947  const el=document.getElementById('idxBody'); if(!el) return;
12948  fetch('/api/index-plays').then(r=>r.json()).then(j=>{
12949    if(j && j.error){ el.innerHTML='<div class="col-loading col-error">'+j.error+'</div>'; return; }
12950    renderIndexPlays(j); _loadPnlPanel('idxTracker','spx','#f472b6','SPX/SPY P&amp;L','Plays fire 6:45 AM – 1 PM PT, one after another — the first enters here Monday morning.');
12951    _idxCoachLoad();   // live read on the selected (default: live) play
12952  }).catch(()=>{ el.innerHTML='<div class="col-loading col-error">Could not load plays.</div>'; });
12953  // Keep it live while the tab is open — plays resolve minute-to-minute.
12954  if(_idxTimer) clearInterval(_idxTimer);
12955  // Keep the SPX/SPY tab live on its own (user 2026-09-17). The old gate cleared the timer whenever
12956  // gfLive('ideas') was false, so the live mark went stale (a reopened SPY sat at its old premium).
12957  // Now it polls every 30s while you're on the tab, pausing only when the browser tab is backgrounded,
12958  // and stops only when you navigate to another Ideas tab.
12959  _idxTimer=setInterval(()=>{ if(_ideasTab!=='index'){ clearInterval(_idxTimer); _idxTimer=null; return; } if(!document.hidden) loadIndexPlays(); },30000);
12960}
12961// ── PLAY COACH (user request 2026-08-08): click a play row → a live read on the given contract in
12962// the right panel — premium vs entry, next resistance/support, the 30-min tape, charm/gamma bias,
12963// and a HOLD / TRIM / BOOK / CUT verdict. SPX/SPY tab only for now.
12964let _idxCoachId=null;
12965function _idxCoachPick(id){ _idxCoachId=id; _idxCoachLoad(); }
12966function _idxCoachLoad(){
12967  const card=document.getElementById('idxCoach'); if(!card) return;   // fixed slot ABOVE the P&L panel (the panel repaints wipe anything inside it)
12968  if(!(typeof isAdminUser!=='undefined'&&isAdminUser)) return;
12969  fetch('/api/index-plays/coach'+(_idxCoachId?('?id='+encodeURIComponent(_idxCoachId)):'')).then(r=>r.json()).then(j=>_coachPaint(card,j,'#f472b6')).catch(()=>{});
12970}
12971// Shared coach painter — index plays and morning setups use the same card.
12972function _coachPaint(card,j,accent){
12973    card.style.display='block';
12974    if(!j || j.error){ card.innerHTML='<div style="font-size:11px;color:var(--dim)">'+((j&&j.error)||'Coach unavailable.')+'</div>'; return; }
12975    // SLIM CARD (user request 2026-08-08): contract, verdict, reason, spot/target/stop, and a
12976    // STRONG live flow read — net call vs put premium (30m ask-side) with a dominance bar.
12977    const long=j.dir==='LONG', c=j.contract||{};
12978    const vcol=j.verdict==='BOOK'?'#22c55e':j.verdict==='TRIM'?'#fbbf24':j.verdict==='CUT'?'#ef4444':j.verdict==='CLOSED'?'#94a3b8':'#38bdf8';
12979    const pl=(j.plPct!=null)?((j.plPct>=0?'+':'')+j.plPct+'%'):'—';
12980    const _mv=v=>{ v=+v||0; return v>=1e6?('$'+(v/1e6).toFixed(1)+'M'):v>=1e3?('$'+Math.round(v/1e3)+'K'):('$'+Math.round(v)); };
12981    const fl=j.flow||{}, ftot=(fl.call||0)+(fl.put||0);
12982    let flowHtml='';
12983    if(ftot>0){
12984      const cSh=Math.round((fl.call||0)/ftot*100), ln=fl.lean||0;
12985      const lbl= ln>=0.25?'CALLS IN CONTROL': ln<=-0.25?'PUTS IN CONTROL': ln>=0.1?'calls leading': ln<=-0.1?'puts leading':'balanced tape';
12986      const lcol= ln>=0.25?'#22c55e': ln<=-0.25?'#ef4444': ln>=0.1?'#5ef08a': ln<=-0.1?'#fca5a5':'#9fb0c9';
12987      flowHtml='<div style="margin-top:7px">'
12988        +'<div style="display:flex;justify-content:space-between;font-size:10.5px;margin-bottom:3px">'
12989        +'<span style="color:#22c55e;font-weight:700">Calls '+_mv(fl.call)+'</span>'
12990        +'<b style="color:'+lcol+';font-size:10px;letter-spacing:.5px">'+lbl+'</b>'
12991        +'<span style="color:#ef4444;font-weight:700">Puts '+_mv(fl.put)+'</span></div>'
12992        +'<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;background:rgba(148,163,184,.12)">'
12993        +'<div style="width:'+cSh+'%;background:#22c55e"></div><div style="width:'+(100-cSh)+'%;background:#ef4444"></div></div>'
12994        +'<div style="font-size:9.5px;color:var(--dim);margin-top:2px">net ask-side premium · last 30 min</div></div>';
12995    } else {
12996      flowHtml='<div style="margin-top:7px;font-size:10.5px;color:var(--dim)">No fresh flow in the last 30 min — quiet tape.</div>';
12997    }
12998    card.innerHTML=
12999      '<div style="display:flex;align-items:center;gap:8px">'
13000      +'<span style="font-size:10px;font-weight:800;letter-spacing:.6px;color:'+accent+'">LIVE READ</span>'
13001      +'<b style="color:#e2e8f0;font-size:12.5px">'+(j.n!=null?('#'+j.n+' '):'')+j.ticker+'</b>'
13002      +'<span style="font-size:10px;font-weight:800;color:'+(long?'var(--green)':'var(--red)')+'">'+j.dir+'</span>'
13003      +'<span style="margin-left:auto;font-size:15px;font-weight:800;color:'+vcol+'">'+j.verdict+'</span></div>'
13004      +'<div style="font-size:11.5px;color:#9fb0c9;margin-top:6px"><b style="color:'+(c.type==='CALL'?'#22c55e':'#ef4444')+'">$'+c.strike+(c.type==='CALL'?'C':'P')+'</b> '
13005      +'$'+(+(c.prem||0)).toFixed(2)+' <span style="color:var(--dim)">&rarr;</span> <b style="color:'+((j.plPct||0)>=0?'#22c55e':'#ef4444')+'">'+(j.premNow!=null?'$'+(+j.premNow).toFixed(2):'—')+' ('+pl+')</b></div>'
13006      +'<div style="font-size:11px;color:var(--dim);margin-top:4px">Spot <b style="color:#e2e8f0">'+j.spot+'</b> · Target <b style="color:#22c55e">'+j.target+'</b> · Stop <b style="color:#ef4444">'+j.stop+'</b></div>'
13007      +(j.dealer&&j.dealer.mode?('<div style="font-size:10.5px;margin-top:5px" title="'+String(j.dealer.plan||'').replace(/"/g,'&quot;')+'"><span style="color:var(--dim)">Dealer mode:</span> <b style="color:'+(j.dealer.tone==='bull'?'#22c55e':j.dealer.tone==='bear'?'#ef4444':'#eab308')+';cursor:help">'+j.dealer.mode+'</b></div>'):'')
13008      +flowHtml
13009      +'<div style="font-size:11px;color:#cbd5e1;margin-top:7px;line-height:1.5">'+(j.why||[]).slice(0,2).map(w=>'• '+w).join('<br>')+'</div>';
13010}
13011// ── TradingView levels export (user request 2026-08-08) ────────────────────────
13012// Pine Script cannot fetch external data, so the bridge is a one-click COPY: this builds the
13013// levels string the GhostFlow Levels TV indicator parses (tradingview/ghostflow-levels.pine).
13014// Snapshot semantics — levels move all session here; re-copy + re-paste on TV to refresh.
13015async function gfCopyTvLevels(){
13016  let j=null;
13017  try{
13018    showToast('Building TV levels for the whole watchlist…');
13019    // Tell the server which chart this is for. It hoists that ticker to the TOP of the paste, so
13020    // if TradingView truncates the text input - which it does silently, and the copy is now 31k+
13021    // characters across 123 tickers - the charted symbol is the one guaranteed to survive.
13022    const _tvTk = (typeof chartTicker !== 'undefined' && chartTicker) ? chartTicker : '';
13023    j=await fetch('/api/tv-levels' + (_tvTk ? ('?t=' + encodeURIComponent(_tvTk)) : '')).then(r=>r.json());
13024  }catch(e){}
13025  if(!j || j.error || !j.s){ showToast('Could not build the TV levels list.'); return; }
13026  const n=[...new Set(j.s.split('\n').map(x=>
13026x.split('|')[0].split(':')[1]).filter(Boolean))].length;
13027  try{
13028    await navigator.clipboard.writeText(j.s);
13029    showToast('TV levels copied — '+n+' tickers, '+((typeof chartTicker!=='undefined'&&chartTicker)?chartTicker+' first. ':'')+'Paste at the TOP of the indicator box; it auto-shows the ticker matching your TV chart.');
13030  }catch(e){
13031    // Phones refuse a clipboard write once the fetch has burned the tap gesture — hand the
13032    // string over in a panel instead; the Copy button tap is a FRESH gesture, which is allowed.
13033    _tvLevelsModal(j.s,n);
13034  }
13035}
13036function _tvLevelsModal(s,n){
13037  const old=document.getElementById('tvLvlModal'); if(old) old.remove();
13038  const m=document.createElement('div');
13039  m.id='tvLvlModal';
13040  m.style.cssText='position:fixed;inset:0;z-index:99999;background:rgba(2,6,23,.78);display:flex;align-items:center;justify-content:center;padding:16px';
13041  m.innerHTML='<div style="background:#0f172a;border:1px solid var(--t-border3,#334155);border-radius:12px;max-width:520px;width:100%;padding:16px">'
13042    +'<div style="font-weight:700;color:#e2e8f0;margin-bottom:6px">TV levels — '+n+' tickers</div>'
13043    +'<div style="font-size:12px;color:#8b9cb5;margin-bottom:8px;line-height:1.5">Tap Copy, then paste into the GhostFlow Levels indicator on TradingView. If Copy still fails, long-press inside the box, Select All, then Copy.</div>'
13044    +'<textarea id="tvLvlTa" readonly style="width:100%;height:150px;background:#020617;color:#cbd5e1;border:1px solid var(--t-border3,#334155);border-radius:8px;padding:8px;font-size:11px;font-family:monospace;-webkit-user-select:text;user-select:text"></textarea>'
13045    +'<div style="display:flex;gap:8px;margin-top:10px;justify-content:flex-end">'
13046    +'<button class="tf-btn" onclick="document.getElementById(&quot;tvLvlModal&quot;).remove()">Close</button>'
13047    +'<button class="tf-btn" id="tvLvlCopyBtn" style="background:#22d3ee;color:#04121a;font-weight:700">Copy</button>'
13048    +'</div></div>';
13049  document.body.appendChild(m);
13050  const ta=document.getElementById('tvLvlTa'); ta.value=s;
13051  document.getElementById('tvLvlCopyBtn').onclick=function(){
13052    ta.focus(); ta.select(); try{ ta.setSelectionRange(0,s.length); }catch(e){}
13053    let ok=false;
13054    try{ ok=document.execCommand('copy'); }catch(e){}
13055    if(ok){ showToast('TV levels copied — paste into the indicator on TV.'); m.remove(); return; }
13056    if(navigator.clipboard&&navigator.clipboard.writeText){
13057      navigator.clipboard.writeText(s).then(function(){ showToast('TV levels copied — paste into the indicator on TV.'); m.remove(); })
13058        .catch(function(){ showToast('Long-press inside the box, Select All, then Copy.'); });
13059    } else showToast('Long-press inside the box, Select All, then Copy.');
13060  };
13061}
13062// Morning Setups coach — same card, keyed to the tracked setup you click (default: newest open).
13063let _ideaCoachKey=null, _ideaCoachTimer=null;
13064function _ideaCoachPick(k){ _ideaCoachKey=k; _ideaCoachLoad(); }
13065// The Live Read must match the SESSION on screen (user 2026-09-15). On the After Hours tab it shows
13066// ONLY an AH pick's read (an ah| key), never the morning one; leaving AH drops any stale AH key so
13067// the morning read resumes. Returns false (and hides the card) when there is nothing valid to show.
13068function _coachSessOK(card){
13069  try{
13070    if(typeof _ideasSession==='undefined') return true;
13071    if(_ideasSession==='ah'){ if(!_ideaCoachKey || String(_ideaCoachKey).slice(0,3)!=='ah|'){ if(card) card.style.display='none'; return false; } return true; }
13072    if(_ideaCoachKey && String(_ideaCoachKey).slice(0,3)==='ah|') _ideaCoachKey=null;
13073    return true;
13074  }catch(e){ return true; }
13075}
13076function _ideaCoachLoad(){
13077  const card=document.getElementById('ideaCoach'); if(!card) return;
13078  if(!(typeof isPaidUser!=='undefined'&&isPaidUser)) return;   // premium (user 2026-09-07); was admin-only
13079  if(!_coachSessOK(card)) return;
13080  fetch('/api/idea-coach'+(_ideaCoachKey?('?key='+encodeURIComponent(_ideaCoachKey)):'')).then(r=>r.json()).then(j=>_coachPaint(card,j,'#38bdf8')).catch(()=>{});
13081  if(!_ideaCoachTimer) _ideaCoachTimer=setInterval(function(){
13082    if(currentView==='ideas'&&_ideasTab==='setups') _ideaCoachLoadOnce();
13083    else { clearInterval(_ideaCoachTimer); _ideaCoachTimer=null; }
13084  },30000);
13085}
13086function _ideaCoachLoadOnce(){
13087  const card=document.getElementById('ideaCoach'); if(!card) return;
13088  if(!_coachSessOK(card)) return;
13089  fetch('/api/idea-coach'+(_ideaCoachKey?('?key='+encodeURIComponent(_ideaCoachKey)):'')).then(r=>r.json()).then(j=>_coachPaint(card,j,'#38bdf8')).catch(()=>{});
13090}
13091// WATCHLIST LIVE READ (user 2026-09-07: "this type of window is not coming for watchlist"). Same
13092// card, same painter, same 30s cadence as the Setups coach — it just asks for the Watchlist book.
13093// Defaults to the newest open watchlist pick; clicking a row in the P&L panel switches to it.
13094let _wlCoachKey=null, _wlCoachTimer=null;
13095function _wlCoachPick(k){ _wlCoachKey=k; _wlCoachLoad(); }
13096function _wlCoachFetch(){
13097  const card=document.getElementById('wlCoach'); if(!card) return;
13098  fetch('/api/idea-coach?session=wl'+(_wlCoachKey?('&key='+encodeURIComponent(_wlCoachKey)):'')).then(r=>r.json()).then(j=>_coachPaint(card,j,'#38bdf8')).catch(()=>{});
13099}
13100function _wlCoachLoad(){
13101  const card=document.getElementById('wlCoach'); if(!card) return;
13102  if(!(typeof isPaidUser!=='undefined'&&isPaidUser)) return;   // premium (user 2026-09-07); was admin-only
13103  _wlCoachFetch();
13104  if(!_wlCoachTimer) _wlCoachTimer=setInterval(function(){
13105    if(typeof currentView!=='undefined'&&currentView==='ideas'&&_ideasTab==='watchlist') _wlCoachFetch();
13106    else { clearInterval(_wlCoachTimer); _wlCoachTimer=null; }
13107  },30000);
13108}
13109// Resolved-plays P&L for a set of plays: summed $/contract, summed % (equal one-contract stake
13110// per play), and the W-L record. Open plays are excluded.
13111function _idxTotals(plays){
13112  let usd=0,pct=0,wins=0,losses=0,n=0;
13113  for(const p of (plays||[])){ if(!p || p.status==='open') continue; n++;
13114    if(p.status==='win') wins++; else if(p.status==='loss') losses++;
13115    if(p.plUsd!=null) usd+=+p.plUsd; if(p.plPct!=null) pct+=+p.plPct; }
13116  return {usd:Math.round(usd),pct:Math.round(pct),wins,losses,n};
13117}
13118function _idxTotChip(t){
13119  if(!t||!t.n) return '';
13120  const c=t.usd>=0?'#5ef08a':'#fca5a5';
13121  return ' <span style="margin-left:8px;font-weight:800;color:'+c+'" title="Summed per one contract per play">'+(t.usd>=0?'+':'-')+'$'+Math.abs(t.usd)+'/ct · '+(t.pct>=0?'+':'')+t.pct+'%</span> <span style="margin-left:6px;color:#9fb0c9;font-weight:700">'+t.wins+'W-'+t.losses+'L</span>';
13122}
13123// CARD + DRIFT tag (user 2026-09-03): c.sigPrem is the premium the play was published at, c.prem the
13124// confirm-time entry. Amber once the gap passes the $20/contract card-gap cap (server discards those
13125// from 9/3 on, so amber rows are pre-cap history). Bright text only.
13126function _idxCardTag(c, big){
13127  if(!(c && +c.sigPrem>0 && +c.prem>0)) return '';
13128  const card=+c.sigPrem, ent=+c.prem, gap=Math.round((ent-card)*100), pct=Math.round((ent-card)/card*100);
13129  const col=gap>20?'#fbbf24':'#cbd5e1';
13130  return '<span style="color:'+col+';font-size:'+(big?'11':'10')+'px;font-weight:700;white-space:nowrap" title="Card premium when the signal was published vs the confirm-time entry. Drift = entry over card; the server now discards plays more than $20/contract over the card.">card $'+card.toFixed(2)+' · '+(pct>=0?'+':'')+pct+'% ($'+(gap>=0?'+':'')+gap+')</span>';
13131}
13132// Admin: strike a play from the record everywhere (user 2026-09-04, SPY 770C phantom $45 fill).
13133async function idxRemovePlay(id, at, tk){
13134  if(!confirm('Remove play '+id+' ('+tk+') from the SPX/SPY record, the archive, the tracker and the paper sim? Real Robinhood orders are not touched. Discord posts must be deleted by hand.')) return;
13135  try{ const r=await fetch('/api/index-plays/remove',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,at:at})}); const j=await r.json(); if(j&&j.error) alert(j.error); else alert('Removed - day '+j.day+', archive '+j.archive+', tracker '+j.tracker+', paper sim '+j.bridge); }catch(e){ alert('Remove failed: '+e.message); }
13136  loadIndexPlays(true);
13137}
13138// Reopen a resolved play on the SIMULATION only — resumes sim tracking, never touches Robinhood
13139// (the server marks it simOnly so the RH bridge is skipped). User 2026-09-17.
13140async function idxReopenPlay(id, lane){
13141  if(!confirm('Reopen this play on the SIMULATION only? It resumes sim tracking and never touches Robinhood.')) return;
13142  try{ const r=await fetch('/api/index-plays/reopen',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,lane:lane})}); const j=await r.json(); if(j&&j.error) alert(j.error); else { alert('Reopened (sim only): '+(j.ticker||'')); loadIndexPlays(true); } }catch(e){ alert('Reopen failed: '+e.message); }
13143}
13144function _idxTableHtml(plays){
13145  let h='<div class="trk-tblwrap"><table class="trk-tbl gf-bright"><thead><tr><th>
13145#</th><th>Time</th><th>Ticker</th><th>Side</th><th>Contract</th><th class="num">Buy Prem</th><th class="num">Entry</th><th class="num">Target</th><th class="num">Stop</th><th class="num">Exit</th><th class="num">P&amp;L</th><th>Result</th></tr></thead><tbody>';
13146  for(const p of plays){
13147    const long=p.dir==='LONG', c=p.contract||{};
13148    const res=p.status==='open'?'<span style="color:#f472b6;font-weight:800;font-size:10px">LIVE</span>'
13149      :('<span class="trk-pill '+(p.status==='win'?'win':'loss')+'" style="font-size:9px">'+p.status.toUpperCase()+'</span>'+(p.booked?' <span style="font-size:8px;font-weight:800;color:#22c55e;border:1px solid rgba(34,197,94,.45);border-radius:4px;padding:0 4px">BOOK</span>':'')+(p.eod?' <span style="font-size:8px;color:#94a3b8">EOD</span>':''));
13150    h+='<tr onclick="_idxCoachPick(\''+p.id+'\')" style="cursor:pointer" title="Show this play in the Live Read panel"><td class="num" style="color:#8b9cb5;font-weight:800">'+p.n+((typeof isAdminUser!=='undefined'&&isAdminUser)?' <span onclick="event.stopPropagation();idxRemovePlay(\''+p.id+'\','+(+p.at||0)+',\''+p.ticker+'\')" title="Admin: remove this play from the record (tab, archive, tracker, paper sim). Real Robinhood orders are never touched." style="color:#fca5a5;font-size:9px;font-weight:700;cursor:pointer;margin-left:4px">remove</span>':'')+'</td>'
13151      +'<td style="color:#9fb0c9;font-size:11px">'+new Date(p.at).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'})+'</td>'
13152      +'<td class="tk">'+p.ticker+'</td>'
13153      +'<td><span style="font-size:10px;font-weight:800;color:'+(long?'var(--green)':'var(--red)')+'">'+p.dir+'</span></td>'
13154      +'<td><span class="cp '+(c.type==='CALL'?'c':'p')+'">'+(c.type==='CALL'?'C':'P')+'</span> '+c.strike+' · '+String(c.expiry||'').slice(5).replace('-','/')+' <span style="color:#8b9cb5">('+c.dte+'d)</span></td>'
13155      +'<td class="num" style="color:var(--green);font-weight:700">$'+(+c.prem).toFixed(2)+(c.sigPrem>0?'<div>'+_idxCardTag(c,false)+'</div>':'')+'</td>'
13156      +'<td class="num">'+p.entrySpot+'</td>'
13157      +'<td class="num" style="color:var(--green)">'+p.target+(p.premAtTarget!=null?'<div style="font-size:10px;font-weight:700;color:#5ef08a;white-space:nowrap" title="Book the option here — estimated premium at the target">≈$'+(+p.premAtTarget).toFixed(2)+' (+'+p.targetPct+'%)</div>':'')+'</td>'
13158      +'<td class="num" style="color:var(--red)">'+p.stop+'</td>'
13159      +'<td class="num">'+(p.exitSpot!=null?p.exitSpot:(p.last!=null?p.last:'—'))+(p.exitPrem!=null?'<div style="font-size:10px;color:#9fb0c9;white-space:nowrap">opt $'+(+p.exitPrem).toFixed(2)+'</div>':'')+'</td>'
13160      +'<td class="num" style="font-weight:800;color:'+((p.plPct||0)>=0?'#5ef08a':'#fca5a5')+'">'+(p.plUsd!=null?((p.plUsd>=0?'+':'-')+'$'+Math.abs(p.plUsd)):(p.plPct!=null?((p.plPct>=0?'+':'')+p.plPct+'%'):'—'))+(p.plUsd!=null&&p.plPct!=null?'<div style="font-size:10px;font-weight:700;opacity:.85">'+((p.plPct>=0?'+':'')+p.plPct+'%')+'</div>':'')
13161        +((p.status==='open'&&p.premNow!=null&&c.prem>0)?(function(){ var usd=Math.round((+p.premNow-(+c.prem))*100); return '<div style="font-size:10px;font-weight:700;color:#e2e8f0" title="Live option mark and unrealized $ per contract">$'+(+p.premNow).toFixed(2)+' <span style="color:'+(usd>=0?'#5ef08a':'#fca5a5')+'">'+(usd>=0?'+$':'-$')+Math.abs(usd)+'</span></div>'; })():'')+'</td>'
13162      +'<td>'+res+((typeof isAdminUser!=='undefined'&&isAdminUser&&p.status!=='open')?' <span onclick="event.stopPropagation();idxReopenPlay(\''+p.id+'\',\''+(p.lane||'sp')+'\')" title="Admin: reopen on the SIMULATION only — resumes sim tracking, never touches Robinhood" style="color:#5eead4;font-size:9px;font-weight:700;cursor:pointer;margin-left:6px">reopen (sim)</span>':'')+'</td></tr>';
13163  }
13164  return h+'</tbody></table></div>';
13165}
13166function renderIndexPlays(j){
13167  const el=document.getElementById('idxBody'); if(!el) return;
13168  const stamp=document.getElementById('idxStamp');
13169  if(stamp) stamp.textContent=(j.date||'')+' · updated '+new Date().toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
13170  const plays=(j&&j.plays)||[];
13171  const actives=plays.filter(p=>p.status==='open');
13172  let h='';
13173  for(const act of actives){
13174    const long=act.dir==='LONG', dc=long?'#22c55e':'#ef4444', c=act.contract||{};
13175    const pl=act.plPct;
13176    h+='<div style="background:var(--card);border:1px solid rgba(244,114,182,.4);border-left:3px solid '+dc+';border-radius:12px;padding:14px 16px;margin-bottom:16px;max-width:760px">'
13177      +'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">'
13178        +'<span style="font-size:10px;font-weight:800;letter-spacing:1px;color:#f472b6">PLAY #'+act.n+' · LIVE</span>'
13179        +'<span style="font-size:19px;font-weight:800;color:#e2e8f0">'+act.ticker+'</span>'
13180        +'<span style="background:'+(long?'rgba(34,197,94,.15)':'rgba(239,68,68,.15)')+';color:'+dc+';border-radius:6px;padding:2px 9px;font-size:11px;font-weight:800">'+act.dir+'</span>'
13181        +'<span style="color:#e2e8f0;font-weight:700">$'+c.strike+' '+(c.type==='CALL'?'C':'P')+' · '+String(c.expiry||'').slice(5).replace('-','/')+' ('+c.dte+'DTE)</span>'
13182        +'<span style="background:rgba(34,197,94,.14);color:#22c55e;border-radius:6px;padding:2px 9px;font-size:12px;font-weight:800">$'+(+c.prem).toFixed(2)+'</span>'
13183        +_idxCardTag(c,true)
13184        +(pl!=null?'<span style="margin-left:auto;text-align:right"><span style="font-size:18px;font-weight:800;color:'+(pl>=0?'#5ef08a':'#fca5a5')+'">'+(pl>=0?'+':'')+pl+'%</span>'
13185          +((act.premNow!=null&&c.prem>0)?(function(){ var usd=Math.round((+act.premNow-(+c.prem))*100); return '<div style="font-size:11px;font-weight:700;color:#e2e8f0">$'+(+c.prem).toFixed(2)+' → $'+(+act.premNow).toFixed(2)+' <span style="color:'+(usd>=0?'#5ef08a':'#fca5a5')+'">'+(usd>=0?'+$':'-$')+Math.abs(usd)+'/ct</span></div>'; })():'')
13186          +'</span>':'')
13187      +'</div>'
13188      +'<div style="display:flex;gap:18px;margin-top:10px;font-size:13px;flex-wrap:wrap">'
13189        +'<span style="color:#9fb0c9">Entry <b style="color:#e2e8f0">'+act.entrySpot+'</b></span>'
13190        +'<span style="color:#9fb0c9">Now <b style="color:#e2e8f0">'+(act.last!=null?act.last:'—')+'</b>'+(act.premNow!=null?' <span style="color:#8fa0b8">(opt $'+(+act.premNow).toFixed(2)+')</span>':'')+'</span>'
13191        +'<span style="color:#9fb0c9">Target <b style="color:#22c55e">'+act.target+'</b>'+(act.premAtTarget!=null?' <span style="color:#5ef08a">→ book ≈$'+(+act.premAtTarget).toFixed(2)+' (+'+act.targetPct+'% · ~$'+act.profitAtTarget+'/ct)</span>':'')+'</span>'
13192        +'<span style="color:#9fb0c9">Stop <b style="color:#ef4444">'+act.stop+'</b></span>'
13193        +((act.peakPct!=null||act.dipPct!=null)?'<span style="color:#9fb0c9">peak <b style="color:#5ef08a">'+(act.peakPct!=null?((act.peakPct>=0?'+':'')+act.peakPct+'%'):'—')+'</b> · dip <b style="color:#fca5a5">'+(act.dipPct!=null?(act.dipPct+'%'):'—')+'</b></span>':'')
13194      +'</div>'
13195      +'<div style="margin-top:8px;font-size:11.5px;color:#9fb0c9;line-height:1.5">'+String(act.why||'').replace(/</g,'&lt;
13195')+' <span style="color:#8b9cb5">('+(act.votes||'')+' signals)</span></div>'
13196    +'</div>';
13197  }
13198  if(!actives.length){
13199    h+='<div style="border:1px dashed rgba(244,114,182,.35);border-radius:12px;padding:13px 16px;margin-bottom:16px;max-width:760px;font-size:12.5px;color:#9fb0c9">No live play right now — '
13200      +(plays.length?'last one resolved; each lane re-arms within ~3 minutes when signals align (until 12:30 PM PT).':'the engine starts issuing plays at 6:45 AM PT on trading days — SPX/SPY lane + QQQ lane in parallel.')+'</div>';
13201  }
13202  if(plays.length){
13203    h+='<div style="font-size:10px;letter-spacing:1px;color:var(--dim);margin-bottom:6px">TODAY\'S PLAYS ('+plays.length+')'+_idxTotChip(_idxTotals(plays))+'</div>';
13204    h+=_idxTableHtml(plays);
13205  }
13206  // Previous days from the archive — full rows + per-day P&L, and an all-days total up top.
13207  const hist=(j&&j.history)||[];
13208  if(hist.length){
13209    const allPlays=plays.concat.apply(plays.slice(),hist.map(d=>d.plays||[]));
13210    const allT=_idxTotals(allPlays);
13211    if(allT.n) h+='<div style="font-size:10px;letter-spacing:1px;color:#f472b6;margin:18px 0 10px">ALL DAYS SHOWN — '+allT.n+' plays'+_idxTotChip(allT)+'</div>';
13212    for(const d of hist){
13213      const dp=d.plays||[]; if(!dp.length) continue;
13214      h+='<div style="font-size:10px;letter-spacing:1px;color:var(--dim);margin:16px 0 6px">'+d.date+' ('+dp.length+')'+_idxTotChip(_idxTotals(dp))+'</div>';
13215      h+=_idxTableHtml(dp);
13216    }
13217  }
13218  // Same silent-refresh rule as the P&L panel: repaint only on real change, keep scroll.
13219  if(el._idxHtml===h) return;
13220  const st=el.scrollTop; el._idxHtml=h; el.innerHTML=h; el.scrollTop=st;
13221}
13222// ── Ghost Trade tab — the everything engine, top 10 + P&L panel ──
13223let _ghostLoading=false;
13224function loadGhostTrades(force){
13225  const el=document.getElementById('ghostBody'); if(!el) return;
13226  if(_ghostLoading) return; _ghostLoading=true;
13227  if(force) el.innerHTML='<div class="col-loading">Running the full-signal scan…</div>';
13228  fetch('/api/ghost-trades'+(force?'?fresh=1':'')).then(r=>r.json()).then(j=>{ _ghostLoading=false;
13229    if(j && j.error){ el.innerHTML='<div class="col-loading col-error">'+j.error+'</div>'; return; }
13230    renderGhostTrades(j); _ghostLoadTracker();
13231  }).catch(()=>{ _ghostLoading=false; el.innerHTML='<div class="col-loading col-error">Could not load Ghost Trades.</div>'; });
13232}
13233// Per-row GIVEN cell: when this name was first handed out today + which builds gave it. Both
13234// AM/PM badges removed (user request 2026-08-08) — the second build is the after-close read for
13235// tomorrow now, so the tags read wrong. The cell shows the first-given TIME; a PULL badge stays.
13236function _givenCell(it){
13237  const ks=(it.givenKinds||[]), at=it.givenAt||null;
13238  if(!at && !ks.length) return '<td class="num" style="color:#8b9cb5">—</td>';
13239  const t=at?new Date(at).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}):'—';
13240  const badge=(lbl,col,bc)=>'<span style="display:inline-block;font-size:8px;font-weight:800;letter-spacing:.4px;color:'+col+';border:1px solid '+bc+';border-radius:3px;padding:0 3px;margin-right:3px">'+lbl+'</span>';
13241  let tags='';
13242  if(ks.indexOf('pull')>=0 && ks.indexOf('am')<0 && ks.indexOf('pm')<0) tags+=badge('PULL','#22d3ee','rgba(34,211,238,.45)');
13243  const both=(ks.indexOf('am')>=0 && ks.indexOf('pm')>=0);
13244  return '<td class="num" title="'+(both?'Given in BOTH reads today — first at '+t:'First given today at '+t)+'" style="white-space:nowrap">'
13245    + '<div style="font-weight:700;color:'+(both?'#5ef08a':'#e2e8f0')+'">'+t+'</div>'
13246    + '<div style="margin-top:2px">'+(tags||'')+'</div></td>';
13247}
13248function renderGhostTrades(j){
13249  const el=document.getElementById('ghostBody'); if(!el) return;
13250  const stamp=document.getElementById('ghostStamp');
13251  if(stamp){ const d=new Date(j.generated||j.t||Date.now());
13252    const kind=j.wlKind||(j.pulled?'pull':'am');
13253    const lbl=j.snapshot?((kind==='pull'?'Pulled ':kind==='pm'?(gfLocalizePt('12:40 PM PT')+' list · '):(gfLocalizePt('6:45 AM PT')+' list · '))+(j.snapDate||'')+' · '):'Fresh pull · ';
13254    stamp.textContent=lbl+d.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}); }
13255  let h='';
13256  if((j.macro||[]).length){
13257    h+='<div style="background:rgba(234,179,8,.07);border:1px solid rgba(234,179,8,.28);border-radius:10px;padding:9px 13px;margin-bottom:12px;font-size:12px;color:#eab308"><b>High-impact macro today:</b> '
13258      +j.macro.map(m=>{ let t=''; try{ t=new Date(m.time).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}); }catch(e){}
13259        return m.event+(t?' ('+t+')':''); }).join(' · ')
13260      +' <span style="color:#9fb0c9">— GEX levels can break on the print.</span></div>';
13261  }
13262  const rows=(j&&j.ideas)||[];
13263  if(!rows.length){ el.innerHTML=h+'<div class="trk-empty">Nothing clears the bar right now — pull again during market hours.</div>'; return; }
13264  h+='<div class="trk-tblwrap"><table class="trk-tbl gf-bright"><thead><tr>'+
13265    '<th>#</th><th>Ticker</th><th class="num" title="When this name was first given today — AM = 6:45 read, PM = 12:40 read, both = given twice">Given</th><th>Side</th><th>Contract</th><th class="num">Spot</th><th class="num" title="Contract premium when the list was built">Buy Prem</th><th class="num" title="Current premium on the contract (live mark) vs the buy premium">Current Premium</th><th class="num">Entry zone</th><th class="num">Target</th><th class="num">Stop</th><th class="num">R:R</th><th class="num">Score</th><th style="min-width:280px">Why</th><th></th></tr></thead><tbody>';
13266  rows.forEach((it,i)=>{
13267    const c=it.contract||{}, long=it.dir==='LONG';
13268    const ty=(c.type==='PUT')?'P':'C';
13269    const tp=_ideaPremAtTarget(it);
13270    const chip=(txt,col,bcol)=>'<span style="display:inline-block;font-size:9px;font-weight:800;letter-spacing:.4px;color:'+col+';border:1px solid '+bcol+';border-radius:4px;padding:0 5px;margin:0 5px 3px 0;vertical-align:1px">'+txt+'</span>';
13271    let why='';
13272    if(it.earnings) why+=chip(String(it.earnings).toUpperCase(),'#f59e0b','rgba(245,158,11,.45)');
13273    (it.ghostNotes||[]).forEach(n=>{ why+=chip(String(n).toUpperCase().replace(/</g,'&lt;'),'#c084fc','rgba(168,85,247,.45)'); });
13274    why+='<span style="color:#9fb0c9">'+String(it.rationale||'').replace(/</g,'&lt;')+'</span>';
13275    h+='<tr><td class="num" style="color:#8b9cb5;font-weight:800">'+(i+1)+'</td>'+
13276      '<td class="tk">'+it.ticker+'</td>'+
13277      _givenCell(it)+
13278      '<td><span style="font-size:10px;font-weight:800;letter-spacing:.5px;color:'+(long?'var(--green)':'var(--red)')+'">'+(long?'LONG':'SHORT')+'</span></td>'+
13279      '<td><span class="cp '+(ty==='C'?'c':'p')+'">'+ty+'</span> '+c.strike+' · '+String(c.expiry||'').slice(5).replace('-','/')+(c.dte!=null?' <span style="color:#8b9cb5">('+c.dte+'d)</span>':'')+'</td>'+
13280      (function(){ const sp=(it.spotNow!=null?it.spotNow:it.spot);
13281        if(sp==null) return '<td class="num" style="color:#8b9cb5">—</td>';
13282        const chg=(it.spotNow!=null&&it.spot>0)?((it.spotNow-it.spot)/it.spot*100):null;
13283        return '<td class="num" title="'+(it.spot!=null?('spot when the list was built: '+it.spot):'')+'">'+(+sp).toFixed(2)
13284          +(chg!=null&&Math.abs(chg)>=0.05?'<div style="font-size:10px;font-weight:700;color:'+(chg>=0?'#5ef08a':'#fca5a5')+'">'+(chg>=0?'+':'')+chg.toFixed(1)+'%</div>':'')+'</td>'; })()+
13285      '<td class="num" style="color:var(--green);font-weight:700">'+(c.prem!=null?('$'+(+c.prem).toFixed(2)):'—')+'</td>'+
13286      (function(){ const now=it.premNow, base=c.prem;
13287        if(now==null||!(base>0)) return '<td class="num" style="color:#8b9cb5">—</td>';
13288        const chg=Math.round((now-base)/base*100), up=chg>=0;
13289        return '<td class="num" style="color:'+(up?'#5ef08a':'#fca5a5')+';font-weight:700" title="Live mark vs the buy premium">$'+(+now).toFixed(2)+'<div style="font-size:10px;font-weight:700;opacity:.85">'+(up?'+':'')+chg+'%</div></td>'; })()+
13290      '<td class="num">'+it.entryLo+'–'+it.entryHi+'</td>'+
13291      '<td class="num" style="color:var(--green)">'+it.target+(tp?'<div style="font-size:10px;font-weight:700;color:#5ef08a;white-space:nowrap">≈$'+tp.prem.toFixed(2)+' (+'+tp.pct+'%)</div>':'')+'</td>'+
13292      '<td class="num" style="color:var(--red)">'+it.stop+'</td>'+
13293      '<td class="num">'+(it.rr!=null?it.rr+':1':'—')+'</td>'+
13294      '<td class="num"><b>'+it.score+'</b> <span style="color:#8b9cb5">'+it.grade+'</span></td>'+
13295      '<td style="font-size:11px;line-height:1.45;max-width:440px;white-space:normal">'+why+'</td>'+
13296      '<td style="white-space:nowrap"><button class="flow-trk" onclick="event.stopPropagation();trackFlow(this)" data-ticker="'+it.ticker+'" data-strike="'+c.strike+'" data-expiry="'+c.expiry+'" data-type="'+ty+'" data-side="buy" data-entry="'+(c.prem!=null?(+c.prem).toFixed(2):'')+'" title="Add this contract to Tracking">+ Track</button></td></tr>';
13297  });
13298  h+='</tbody></table></div>';
13299  el.innerHTML=h;
13300}
13301// Shared right-side P&L panel — win rate + live picks + resolved history for one tracker session.
13302// Ghost passes 'ghost', the watchlist passes 'wl'. Peak/dip show "—" until the first
13303// regular-session sample lands (they latch on RTH prices only).
13304// SHARED STATUS BADGE (user 2026-09-07: "watchlist ... not showing like its pending or waiting like
13305// setups ... same like if its void or pending or entered"). Setups rendered this inline; the
13306// Watchlist / G-Flip / EOD panels showed no status at all, so a pick still waiting on its 0.35%
13307// confirmation looked exactly like one already in the trade. One function now, used by both, so the
13308// two lists cannot drift apart again.
13309// MANUAL EXIT (user 2026-09-08: "give exit button on it too"). /api/tracker/close already existed
13310// and handles both the index plays and the ideas tracker, but nothing in the UI ever called it, so
13311// an open position could only ever close on its own rules. Two-step so a stray click cannot close a
13312// live trade: the first press arms the button, the second sends it, and it disarms after 4 seconds.
13313var _gfExitArmed={};
13314function gfExitPos(btn, key){
13315  try{
13316    if(!key) return;
13317    if(!_gfExitArmed[key]){
13318      _gfExitArmed[key]=setTimeout(function(){ delete _gfExitArmed[key]; try{ btn.textContent='Exit'; btn.style.background='transparent'; }catch(e){} }, 4000);
13319      btn.textContent='Sure?'; btn.style.background='rgba(239,68,68,.22)';
13320      return;
13321    }
13322    clearTimeout(_gfExitArmed[key]); delete _gfExitArmed[key];
13323    btn.textContent='...'; btn.disabled=true;
13324    fetch('/api/tracker/close',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({key:key})})
13325      .then(function(r){ return r.json(); })
13326      .then(function(j){
13327        if(j && j.ok){
13328          try{ showToast('Closed '+j.ticker+' \u2014 '+String(j.status||'').toUpperCase()+(j.plPct!=null?(' '+(j.plPct>=0?'+':'')+j.plPct+'%'):'')); }catch(e){}
13329          try{ if(typeof loadIdeasTracker==='function') loadIdeasTracker(); }catch(e){}
13330          try{ if(typeof _ideasSession!=='undefined' && _ideasSession==='ah' && typeof loadTradeIdeas==='function') loadTradeIdeas(true); }catch(e){}   /* AH book refresh (user 2026-09-15) */
13331          try{ if(typeof _wlLoadPanel==='function') _wlLoadPanel(); }catch(e){}
13332          try{ if(typeof loadAllTrades==='function' && document.getElementById('allTradesWrap') && getComputedStyle(document.getElementById('allTradesWrap')).display!=='none') loadAllTrades(); }catch(e){}   /* All Trades reflects the close immediately (user 2026-09-09) */
13333        } else {
13334          try{ showToast('Could not close: '+((j&&j.error)||'unknown')); }catch(e){}
13335          btn.textContent='Exit'; btn.disabled=false; btn.style.background='transparent';
13336        }
13337      })
13338      .catch(function(e){ try{ showToast('Could not close: '+e); }catch(e2){} btn.textContent='Exit'; btn.disabled=false; btn.style.background='transparent'; });
13339  }catch(e){}
13340}
13341function _gfExitBtn(o){
13342  if(!o || !o.key) return '';
13343  if(!(typeof isAdminUser!=='undefined' && isAdminUser)) return '';   // the close route is admin-only
13344  return '<button onclick="gfExitPos(this,\''+String(o.key).replace(/'/g,'')+'\')" title="Close this position now at the live mark" '
13345    + 'style="font-size:9.5px;font-weight:800;letter-spacing:.4px;color:#fca5a5;background:transparent;'
13346    + 'border:1px solid rgba(239,68,68,.5);border-radius:5px;padding:2px 8px;cursor:pointer">Exit</button>';
13347}
13348function _voidReasonHtml(o, sz){ if(!o||o.status!=='void') return ''; var w=String(o.voidWhy||'Entry never confirmed — no trade was taken').replace(/</g,'&lt;').replace(/"/g,'&quot;'); return '<div style="color:#fbbf24;font-size:'+(sz||10)+'px;margin-top:3px;line-height:1.35">⚠ '+w+'</div>'; }
13349// Live book abandoned this entry (ask ran past the gap cap) — the sim shows what it would have done,
13350// but it is NOT counted in the record or the Total P&L (user 2026-09-16). Flag it so the row is clear.
13351function _abandLiveHtml(o, sz){ if(!o||!o.abandonedLive) return ''; var why=o.abandonWhy?(' — '+String(o.abandonWhy).replace(/</g,'&lt;')):''; return '<div style="color:#f59e0b;font-size:'+(sz||10)+'px;margin-top:3px;line-height:1.35" title="The live book could not fill this. Shown for reference; excluded from the record and Total P&L.">⊘ not filled live — not counted'+why+'</div>'; }
13352function _gfPendBadge(o){
13353  if(!o) return '';
13354  const box=(col,rgba,title,txt)=>'<span style="font-size:9px;font-weight:800;color:'+col+';border:1px solid '+rgba+';border-radius:4px;padding:0 4px" title="'+title+'">'+txt+'</span>';
13355  if(o.status==='void') return box('#94a3b8','rgba(148,163,184,.45)',(o.voidWhy?String(o.voidWhy).replace(/"/g,'&quot;'):'Entry never confirmed - no trade was taken'),'VOID');
13356  if(o.pending){
13357    const trig=(o.entry!=null&&isFinite(+o.entry))?(+(o.entry*(o.dir==='LONG'?1.0035:0.9965))).toFixed(2):null;
13358    return box('#eab308','rgba(234,179,8,.45)','Waiting for the stock to move 0.35% in the trade direction before the position counts - voids if it never confirms',
13359      'PENDING'+(trig?(' \u00b7 in '+(o.dir==='LONG'?'&ge;':'&le;')+' $'+trig):''));
13360  }
13361  if(o.confirmedAt) return box('#22c55e','rgba(34,197,94,.45)','Entry confirmed - the stock crossed the 0.35% trigger at this time/price; the contract basis was re-priced to a live quote at that moment',
13362    'in $'+(+o.entry).toFixed(2)+' \u00b7 '+fmtTime(o.confirmedAt,{hour:'2-digit',minute:'2-digit',hour12:false})+' '+tzAbbr(o.confirmedAt));
13363  return box('#22d3ee','rgba(34,211,238,.45)','Entered - the position is live','ENTERED');
13364}
13365function _loadPnlPanel(elId, session, accent, title, emptyMsg){
13366  const el=document.getElementById(elId); if(!el) return;
13367  fetch('/api/trade-ideas/history').then(r=>r.json()).then(hh=>{
13368    const open=((hh&&hh.open)||[]).filter(o=>o.session===session);
13369    const res=((hh&&hh.resolved)||[]).filter(o=>o.session===session).sort((a,b)=>(b.resolved||0)-(a.resolved||0));
13370    const voids=((hh&&hh.voids)||[]).filter(o=>o.session===session).sort((a,b)=>(b.resolved||0)-(a.resolved||0));
13371    const w=res.filter(o=>o.status==='win').length, l=res.filter(o=>o.status==='loss').length, x=res.filter(o=>o.status==='expired').length;
13372    const wr=(w+l)?Math.round(w/(w+l)*100):0;
13373    // NET P/L (user 2026-08-31): realized dollars across all CLOSED plays — 1 contract per play.
13374    // Use the server-computed plUsd (exitPrem − contract.prem, ×100) when present; fall back to
13375    // contract.prem × plPct. NEVER o.entry — that is the STOCK entry level, and using it read
13376    // $537K on SPX-sized underlyings (user caught it live).
13377    let net=0, netN=0;
13378    for(const o of res){
13379      const _prem=o.contract&&isFinite(+o.contract.prem)?+o.contract.prem:null;
13380      if(o.plUsd!=null&&isFinite(+o.plUsd)){ net+=+o.plUsd; netN++; }
13381      else if(o.plPct!=null&&_prem!=null){ net+=_prem*100*(o.plPct/100); netN++; }
13382    }
13383    const pkTip='Best gain and worst drawdown the CONTRACT has printed since entry — sampled during market hours only';
13384    const pkDip=o=>'<div style="font-size:10px;margin-top:2px" title="'+pkTip+'">'
13385      +'<span style="color:#5ef08a">peak '+(o.plPeakPct!=null?((o.plPeakPct>=0?'+':'')+o.plPeakPct+'%'):'—')+'</span>'
13386      +'<span style="color:#fca5a5;margin-left:8px">dip '+(o.plMinPct!=null?(o.plMinPct+'%'):'—')+'</span></div>';
13387    let h='<div style="font-size:10px;letter-spacing:1px;color:'+accent+';font-weight:800;margin-bottom:10px">'+title+'</div>';
13388    h+='<div style="display:flex;gap:12px;margin-bottom:14px">'
13389      +'<div style="flex:1;background:rgba(148,163,184,.06);border-radius:8px;padding:8px 10px"><div style="font-size:9px;color:var(--dim);letter-spacing:.5px">WIN RATE</div><div style="font-size:18px;font-weight:800;color:'+((w+l)===0?'#94a3b8':wr>=50?'#22c55e':'#ef4444')+'">'+((w+l)?wr+'%':'—')+'</div></div>'
13390      +'<div style="flex:1;background:rgba(148,163,184,.06);border-radius:8px;padding:8px 10px"><div style="font-size:9px;color:var(--dim);letter-spacing:.5px">RECORD</div><div style="font-size:18px;font-weight:800;color:#e2e8f0">
13390'+w+'W-'+l+'L'+(x?'-'+x+'E':'')+'</div></div>'
13391      +'<div style="flex:1;background:rgba(148,163,184,.06);border-radius:8px;padding:8px 10px" title="Realized net across all closed plays — 1 contract per play, entry premium basis (entry × 100 × final %)"><div style="font-size:9px;color:var(--dim);letter-spacing:.5px">NET P/L</div><div style="font-size:18px;font-weight:800;color:'+(netN===0?'#cbd5e1':net>=0?'#22c55e':'#ef4444')+'">'+(netN?((net>=0?'+$':'-$')+Math.abs(Math.round(net)).toLocaleString()):'—')+'</div></div>'
13392    +'</div>';
13393    h+='<div style="font-size:10px;letter-spacing:1px;color:var(--dim);margin-bottom:6px">TRACKING NOW ('+open.length+')</div>';
13394    if(!open.length) h+='<div style="color:var(--dim);font-size:11.5px;margin-bottom:12px">'+emptyMsg+'</div>';
13395    for(const o of open){
13396      const cs=o.contract||{}, pl=o.plPct;
13397      h+='<div style="border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:7px">'
13398        +'<div'+((session==='wl'&&o.key&&typeof isPaidUser!=='undefined'&&isPaidUser)?(' onclick="_wlCoachPick(\''+String(o.key).replace(/'/g,'')+'\')" title="Show the live read for this pick" style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;cursor:pointer"'):' style="display:flex;align-items:center;gap:7px;flex-wrap:wrap"')+'><b style="color:#e2e8f0">'+o.ticker+'</b>'+_gfPendBadge(o)+(o.build?'<span style="color:#fbbf24;font-size:9px;font-weight:800;border:1px solid rgba(251,191,36,.5);border-radius:4px;padding:0 4px" title="Intraday rebuild pick - portal only">'+o.build+'</span>':'')
13399        +'<span style="font-size:9px;font-weight:800;color:'+(o.dir==='LONG'?'#22c55e':'#ef4444')+'">'+o.dir+'</span>'
13400        +'<span style="font-size:10.5px;color:#9fb0c9">'+(cs.strike?('$'+cs.strike+' '+(cs.type||'')+' '+String(cs.expiry||'').slice(5).replace('-','/')):'')+'</span>'
13401        +_gfExitBtn(o)
13402        +(pl!=null?'<span style="margin-left:auto;text-align:right"><span style="font-weight:800;color:'+(pl>=0?'#5ef08a':'#fca5a5')+'">'+(pl>=0?'+':'')+pl+'%</span>'
13403          +((o.optNow!=null&&cs.prem>0)?(function(){ var usd=Math.round((+o.optNow-(+cs.prem))*100); return '<div style="font-size:10px;font-weight:700;color:#e2e8f0">$'+(+cs.prem).toFixed(2)+' → $'+(+o.optNow).toFixed(2)+' <span style="color:'+(usd>=0?'#5ef08a':'#fca5a5')+'">'+(usd>=0?'+$':'-$')+Math.abs(usd)+'</span></div>'; })():'')
13404          +'</span>':'')+'</div>'
13405        +'<div style="font-size:10px;color:#8fa0b8;margin-top:3px">entry '+(o.entry!=null?(+o.entry).toFixed(2):'—')+' · tgt <span style="color:#22c55e">'+o.target+'</span> · stop <span style="color:#ef4444">'+o.stop+'</span>'+(o.markPrice?' · now '+(+o.markPrice).toFixed(2):'')+'</div>'
13406        +pkDip(o)
13407      +'</div>';
13408    }
13409    if(voids.length){
13410      h+='<div style="font-size:10px;letter-spacing:1px;color:var(--dim);margin:12px 0 6px" title="Picks whose entry never confirmed. No trade was taken, so they are kept out of the win rate, the record and the net P/L.">NEVER CONFIRMED ('+voids.length+')</div>';
13411      for(const o of voids.slice(0,8)){
13412        const cv=o.contract||{};
13413        h+='<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:11px;color:#8fa0b8;padding:3px 0">'
13414          +'<b style="color:#cbd5e1">'+o.ticker+'</b>'+_gfPendBadge(o)
13415          +'<span>'+(cv.strike?('$'+cv.strike+' '+(cv.type||'')):'')+'</span>'
13416          +'<span style="margin-left:auto">'+(o.voidWhy?String(o.voidWhy).slice(0,42):'never confirmed')+'</span></div>';
13417      }
13418    }
13419    if(res.length){
13420      h+='<div style="font-size:10px;letter-spacing:1px;color:var(--dim);margin:12px 0 6px">HISTORY</div>';
13421      for(const o of res.slice(0,30)){
13422        const win=o.status==='win';
13423        h+='<div style="display:flex;align-items:center;gap:7px;padding:5px 2px;border-bottom:1px solid rgba(148,163,184,.08);font-size:11.5px;flex-wrap:wrap">'
13424          +'<span class="trk-pill '+(win?'win':'loss')+'" style="font-size:9px">'+(o.status==='expired'?'EXP':win?'WIN':'LOSS')+'</span>'
13425          +(o.booked?'<span style="font-size:8px;font-weight:800;letter-spacing:.5px;color:#22c55e;border:1px solid rgba(34,197,94,.45);border-radius:4px;padding:0 4px" title="Booked at +100% contract gain — win taken even if the underlying later reversed">BOOK</span>':'')
13426          +'<b style="color:#e2e8f0">'+o.ticker+'</b>'+(o.build?'<span style="color:#fbbf24;font-size:9px;font-weight:800;border:1px solid rgba(251,191,36,.5);border-radius:4px;padding:0 4px" title="Intraday rebuild pick - portal only">'+o.build+'</span>':'')+'<span style="color:#8fa0b8">'+(o.date||'')+'</span>'
13427          +'<span style="margin-left:auto">'
13428          +(o.plPeakPct!=null?'<span style="color:'+(win?'#5ef08a':'#94a3b8')+'">peak '+(o.plPeakPct>=0?'+':'')+o.plPeakPct+'%</span>':'')
13429          +(o.plMinPct!=null?'<span style="color:#fca5a5;margin-left:7px">dip '+o.plMinPct+'%</span>':'')
13430          +'</span>'
13431          +((typeof _idRunnerLine==='function')?_idRunnerLine(o).replace('margin:2px 0 0 50px','margin:2px 0 0 2px;flex-basis:100%'):'')
13432        +'</div>';
13433      }
13434    }
13435    // Silent background refresh: repaint ONLY when the content actually changed, and keep the
13436    // scroll position — no flash, no jump on the periodic reloads.
13437    if(el._pnlHtml===h) return;
13438    const st=el.scrollTop; el._pnlHtml=h; el.innerHTML=h; el.scrollTop=st;
13439  }).catch(()=>{ if(!el._pnlHtml) el.innerHTML='<div class="col-loading col-error">Could not load the P&amp;L record.</div>'; });
13440}
13441function _ghostLoadTracker(){ _loadPnlPanel('ghostTracker','ghost','#c084fc','GHOST TRADE P&amp;L','No open Ghost picks yet — they enter here on each 6:45 / 12:40 build.'); }
13442function _wlLoadPanel(){ try{ _wlCoachLoad(); }catch(e){}   /* coach card sits in its own slot above this panel */
13443  _loadPnlPanel('wlTracker','wl','#38bdf8','WATCHLIST P&amp;L','No open watchlist picks yet — they enter here on each 6:45 AM / after-close build or pull.'); }
13444// ── Daily Watchlist tab — top 10 for the day as a table, no side panel ──
13445let _wlLoading=false;
13446// Morning | Tomorrow sub-tabs (user request 2026-08-08): the two builds are held separately on the
13447// server, so the after-close list no longer replaces what the morning said. Default follows the
13448// clock — after the ~1:15 PM PT build lands, Tomorrow is the natural first look.
13449let _wlWhich=(function(){ try{ const p=new Date(new Date().toLocaleString('en-US',{timeZone:'America/Los_Angeles'})); return (p.getHours()*60+p.getMinutes())>=795 ? 'pm' : 'am'; }catch(e){ return 'am'; } })();
13450function setWlWhich(w){ _wlWhich=(w==='pm')?'pm':'am'; _wlSyncWhich(); loadWatchlist(); }
13451function _wlSyncWhich(){
13452  document.querySelectorAll('#ideasWatchBar .wl-which').forEach(b=>{ const on=b.dataset.w===_wlWhich; b.style.background=on?'var(--cyan)':'transparent'; b.style.color=on?'var(--bg)':'#cbd5e1'; b.style.borderColor=on?'var(--cyan)':'var(--border)'; });
13453  // Pull now is a MORNING-tab feature only (user request) — a pull rebuilds today's list and must
13454  // never touch the held after-close list for tomorrow.
13455  const pb=document.getElementById('wlPullBtn'); if(pb) pb.style.display=(_wlWhich==='am')?'':'none';
13456}
13457// Drag-to-resize the right panel of a two-column view (user 2026-09-15). Inserts a thin col-resize
13458// divider before the fixed-width side panel and persists the chosen width. Idempotent per view.
13459function _gfSideResizer(wrapId, sideId, storeKey){
13460  try{
13461    var wrap=document.getElementById(wrapId), side=document.getElementById(sideId);
13462    if(!wrap||!side) return;
13463    try{ var sv=parseInt(localStorage.getItem(storeKey)||'',10); if(sv>=260 && sv<=800) side.style.width=sv+'px'; }catch(e){}
13464    var prev=side.previousElementSibling;
13465    if(prev && prev.classList && prev.classList.contains('gf-side-resizer')) return;   // already wired
13466    var h=document.createElement('div');
13467    h.className='gf-side-resizer'; h.title='Drag to resize';
13468    h.style.cssText='flex:none;width:6px;cursor:col-resize;background:transparent;align-self:stretch;z-index:5';
13469    h.onmouseenter=function(){ if(!h.dataset.drag) h.style.background='rgba(148,163,184,.25)'; };
13470    h.onmouseleave=function(){ if(!h.dataset.drag) h.style.background='transparent'; };
13471    wrap.insertBefore(h, side);
13472    var startX=0, startW=0;
13473    h.addEventListener('pointerdown', function(e){ e.preventDefault(); h.dataset.drag='1'; try{ h.setPointerCapture(e.pointerId); }catch(e2){}
13474      startX=e.clientX; startW=side.getBoundingClientRect().width; h.style.background='rgba(56,189,248,.55)'; document.body.style.userSelect='none'; });
13475    h.addEventListener('pointermove', function(e){ if(!h.dataset.drag) return; var w=Math.max(260, Math.min(800, startW-(e.clientX-startX))); side.style.width=w+'px'; });
13476    var end=function(){ if(!h.dataset.drag) return; delete h.dataset.drag; h.style.background='transparent'; document.body.style.userSelect='';
13477      try{ localStorage.setItem(storeKey, String(Math.round(side.getBoundingClientRect().width))); }catch(e){} };
13478    h.addEventListener('pointerup', end); h.addEventListener('pointercancel', end);
13479  }catch(e){}
13480}
13481function loadWatchlist(force){
13482  const el=document.getElementById('watchlistBody'); if(!el) return;
13483  try{ _gfSideResizer('watchlistWrap','watchlistSide','gf_wlSideW'); }catch(e){}
13484  _wlSyncWhich();
13485  if(_wlLoading) return;
13485 _wlLoading=true;
13486  if(force && _wlWhich!=='am'){ _wlLoading=false; return; }   // pulls only exist on the Morning tab
13487  if(force) el.innerHTML='<div class="col-loading">Scanning flow + GEX for a fresh list…</div>';
13488  fetch('/api/watchlist?which='+_wlWhich+(force?'&fresh=1':'')).then(r=>r.json()).then(j=>{ _wlLoading=false;
13489    if(j && j.error){ el.innerHTML='<div class="col-loading col-error">'+j.error+'</div>'; return; }
13490    renderWatchlist(j);
13491  }).catch(()=>{ _wlLoading=false; el.innerHTML='<div class="col-loading col-error">Could not load the watchlist.</div>'; });
13492}
13493function renderWatchlist(j){
13494  const el=document.getElementById('watchlistBody'); if(!el) return;
13495  const stamp=document.getElementById('watchStamp');
13496  if(stamp){ const d=new Date(j.generated||j.t||Date.now());
13497    const kind=j.wlKind||(j.pulled?'pull':'am');
13498    var _fd=''; try{ if(j&&j.forDate){ var _p=String(j.forDate).split('-'); _fd=new Date(+_p[0],+_p[1]-1,+_p[2]).toLocaleDateString([],{weekday:'short',month:'numeric',day:'numeric'}); } }catch(e){}
13499    // Both builds scan the whole liquid market now (user 2026-09-07), so both say how wide it was.
13500    var _scan=(j&&j.poolSize)?(' · scanned '+j.poolSize+' names'):'';
13501    var _pmLbl=gfLocalizePt('1:15 PM PT')+' after-close · for '+(_fd||'tomorrow')+_scan+' · ';
13502    var _amLbl=gfLocalizePt('6:45 AM PT')+' list'+_scan+' · ';
13503    const lbl=j.empty?'':(j.snapshot?(((j.pulled&&kind!=='pm')?'Pulled ':kind==='pm'?_pmLbl:_amLbl)+(j.snapDate||'')+' · '):'Fresh pull · ');
13504    stamp.textContent=lbl?(lbl+d.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'})):''; }
13505  const rows=(j&&j.ideas)||[];
13506  if(!rows.length){
13507    const ranEmpty=j&&j.empty&&j.generated;   // the build RAN and found nothing (vs not built yet)
13508    el.innerHTML='<div class="trk-empty">'+(j&&j.wlKind==='pm'
13509      ? (ranEmpty
13510        ? 'The after-close read ran — <b>no A-grade setups qualified for tomorrow</b> (score 75+ only). Thin days can be empty by design: on a day where everything already made its move, the anti-chase and quality gates drop the whole pool rather than pad the list with filler.'
13511        : gfLocalizePt('Tomorrow\'s list lands after the close (~1:15 PM PT) — built on the full recorded day.'))
13512      : (ranEmpty
13513        ? 'The morning read ran — <b>no A-grade setups qualified</b> (score 75+ only). An empty day is a valid read, not an error.'
13514        : 'No qualifying names right now — flow may still be thin. Hit <b>↻ Pull now</b> closer to the open.'))+'</div>';
13515    return;
13516  }
13517  let h='<div class="trk-tblwrap"><table class="trk-tbl gf-bright"><thead><tr>'+
13518    '<th>#</th><th>Ticker</th><th class="num" title="When this name was first given today (6:45 AM read or the after-close read)">Given</th><th>Side</th><th>Contract</th><th class="num" title="Current stock price (live) — hover for the price when the list was built">Spot</th><th class="num" title="Contract premium when the list was built">Buy Prem</th><th class="num" title="Current premium on the contract (live mark) vs the buy premium">Current Premium</th><th class="num">Entry zone</th><th class="num">Target</th><th class="num">Stop</th><th class="num">R:R</th><th class="num">Score</th><th style="min-width:260px">Why</th><th></th></tr></thead><tbody>';
13519  rows.forEach((it,i)=>{
13520    const c=it.contract||{}, long=it.dir==='LONG';
13521    const ty=(c.type==='PUT')?'P':'C';
13522    const tp=_ideaPremAtTarget(it);
13523    const why=(it.earnings?'<span style="display:inline-block;font-size:9px;font-weight:800;letter-spacing:.5px;color:#f59e0b;border:1px solid rgba(245,158,11,.45);border-radius:4px;padding:0 5px;margin-right:6px;vertical-align:1px">'+String(it.earnings).toUpperCase()+'</span>':'')
13524      +'<span style="color:#9fb0c9">'+String(it.rationale||'').replace(/</g,'&lt;')+'</span>';
13525    // ROW CLICK -> live read on the right panel (user 2026-09-07: "i tried clicking watchlist
13526    // tickers those not popping on right panel"). Only the P&L panel rows were clickable; the table
13527    // had no handler at all, so the card sat on whatever it defaulted to. Key matches
13528    // recordIdeasForTracking('wl', day, ...): session, build date, ticker.
13529    const _wk=(typeof isPaidUser!=='undefined'&&isPaidUser&&j&&j.snapDate)?('wl|'+j.snapDate+'|'+it.ticker):null;
13530    const _wkAttr=_wk?(' onclick="_wlCoachPick(\x27'+_wk.replace(/'/g,'')+'\x27)" title="Show the live read for this pick in the right panel" style="cursor:pointer"'):'';
13531    h+='<tr'+_wkAttr+'><td class="num" style="color:#8b9cb5;font-weight:800">'+(i+1)+'</td>'+
13532      '<td class="tk">'+it.ticker+'</td>'+
13533      _givenCell(it)+
13534      '<td><span style="font-size:10px;font-weight:800;letter-spacing:.5px;color:'+(long?'var(--green)':'var(--red)')+'">'+(long?'LONG':'SHORT')+'</span></td>'+
13535      '<td><span class="cp '+(ty==='C'?'c':'p')+'">'+ty+'</span> '+c.strike+' · '+String(c.expiry||'').slice(5).replace('-','/')+(c.dte!=null?' <span style="color:#8b9cb5">('+c.dte+'d)</span>':'')+'</td>'+
13536      (function(){ const sp=(it.spotNow!=null?it.spotNow:it.spot);
13537        if(sp==null) return '<td class="num" style="color:#8b9cb5">—</td>';
13538        const chg=(it.spotNow!=null&&it.spot>0)?((it.spotNow-it.spot)/it.spot*100):null;
13539        return '<td class="num" title="'+(it.spot!=null?('spot when the list was built: '+it.spot):'')+'">'+(+sp).toFixed(2)
13540          +(chg!=null&&Math.abs(chg)>=0.05?'<div style="font-size:10px;font-weight:700;color:'+(chg>=0?'#5ef08a':'#fca5a5')+'">'+(chg>=0?'+':'')+chg.toFixed(1)+'%</div>':'')+'</td>'; })()+
13541      '<td class="num" style="color:var(--green);font-weight:700">'+(c.prem!=null?('$'+(+c.prem).toFixed(2)):'—')+'</td>'+
13542      (function(){ const now=it.premNow, base=c.prem;
13543        if(now==null||!(base>0)) return '<td class="num" style="color:#8b9cb5">—</td>';
13544        const chg=Math.round((now-base)/base*100), up=chg>=0;
13545        return '<td class="num" style="color:'+(up?'#5ef08a':'#fca5a5')+';font-weight:700" title="Live mark vs the buy premium">$'+(+now).toFixed(2)+'<div style="font-size:10px;font-weight:700;opacity:.85">'+(up?'+':'')+chg+'%</div></td>'; })()+
13546      '<td class="num">'+it.entryLo+'–'+it.entryHi+'</td>'+
13547      '<td class="num" style="color:var(--green)">'+it.target+(tp?'<div style="font-size:10px;font-weight:700;color:#5ef08a;white-space:nowrap" title="Estimated option premium if the stock reaches the target (0.5-delta leverage estimate — decay and IV move the real number)">≈$'+tp.prem.toFixed(2)+' (+'+tp.pct+'%)</div>':'')+'</td>'+
13548      '<td class="num" style="color:var(--red)">'+it.stop+'</td>'+
13549      '<td class="num">'+(it.rr!=null?it.rr+':1':'—')+'</td>'+
13550      '<td class="num"><b>'+it.score+'</b> <span style="color:#8b9cb5">'+it.grade+'</span></td>'+
13551      '<td style="font-size:11px;line-height:1.45;max-width:420px;white-space:normal">'+why+'</td>'+
13552      '<td style="white-space:nowrap"><button class="flow-trk" onclick="event.stopPropagation();trackFlow(this)" data-ticker="'+it.ticker+'" data-strike="'+c.strike+'" data-expiry="'+c.expiry+'" data-type="'+ty+'" data-side="buy" data-entry="'+(c.prem!=null?(+c.prem).toFixed(2):'')+'" title="Add this contract to Tracking">+ Track</button></td></tr>';
13553  });
13554  h+='</tbody></table></div>';
13555  el.innerHTML=h;
13556  _wlLoadPanel();
13557}
13558// (The old inline WATCHLIST RECORD strip lived here — replaced by the right-side P&L panel.)
13559// Background warm for the Earnings tab: the week calendar + today's scored ideas, cached client-side
13560// so the first open paints instead of spinning through a 9-candidate scan.
13561let _earnPre=null;   // { week, date, ideas, t }
13562async function _earnWarm(){
13563  try{
13564    const wk=await fetch('/api/earnings-week').then(r=>r.json());
13565    if(!wk||wk.error) return;
13566    const dates=(wk.dates)||[], today=wk.today||'';
13567    const d=(dates.find(x=>x.date===today && (x.bmo+x.amc)>0) ? today : ((dates.find(x=>(x.bmo+x.amc)>0)||dates[0]||{}).date));
13568    if(!d){ _earnPre={week:wk,date:null,ideas:null,t:Date.now()}; return; }
13569    const ideas=await fetch('/api/earnings-ideas?date='+encodeURIComponent(d)).then(r=>r.json()).catch(()=>null);
13570    _earnPre={ week:wk, date:d, ideas:(ideas&&!ideas.error)?ideas:null, t:Date.now() };
13571  }catch(e){}
13572}
13573async function loadEarnings(force){
13574  _ideasTabSync();
13575  const dwrap=document.getElementById('earningsDates'), body=document.getElementById('earningsBody'); if(!body) return;
13576  if(!force && Date.now()-_earnLoaded<4000 && dwrap && dwrap.dataset.loaded) { if(_earnDate) loadEarningsDate(_earnDate, force); return; }
13577  _earnLoaded=Date.now();
13578  try{
13579    // Use the warmed calendar if it's fresh — the first open then costs nothing.
13580    let wk=null;
13581    if(_earnPre && !force && Date.now()-_earnPre.t<5*60*1000) wk=_earnPre.week;
13582    if(!wk) wk=await fetch('/api/earnings-week').then(r=>r.json());
13583    const dates=(wk&&wk.dates)||[], today=(wk&&wk.today)||'';
13584    if(!_earnDate){ _earnDate = dates.find(d=>d.date===today && (d.bmo+d.amc)>0) ? today : (dates.find(d=>(d.bmo+d.amc)>0)||dates[0]||{}).date; }
13585    if(dwrap){ dwrap.dataset.loaded='1';
13586      dwrap.innerHTML=dates.map(d=>{ const on=d.date===_earnDate, n=d.bmo+d.amc, lbl=_earnDayLbl(d.date), tod=d.date===today;
13587        return '<button onclick="setEarningsDate(\''+d.date+'\')" style="border:1px solid '+(on?'var(--cyan)':'var(--border)')+';background:'+(on?'var(--cyan)':'transparent')+';color:'+(on?'var(--bg)':(n?'#e2e8f0':'#8b9cb5'))+';border-radius:8px;padding:6px 12px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex:none">'+lbl+(tod?' • today':'')+' <span style="opacity:.7;font-weight:500">'+(n||'—')+'</span></button>'; }).join('')
13588        + '<button id="earnRescoreBtn" onclick="refreshEarningsNow(event)" title="Re-score the sessions that have not locked yet, on current flow + GEX levels (same as the 12:40 PM PT auto pass)" style="margin-left:auto;border:1px solid var(--border);background:transparent;color:#9fb0c9;border-radius:8px;padding:6px 12px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex:none">↻ Re-score</button>'; }
13589    if(_earnDate) loadEarningsDate(_earnDate, force); else body.innerHTML='<div class="col-loading">No earnings this week.</div>';
13590  }catch(e){ body.innerHTML='<div class="col-loading">Couldn\'t load the earnings calendar.</div>'; }
13591}
13592function setEarningsDate(d){ _earnDate=d; loadEarnings(true); }
13593async function loadEarningsDate(date, force){
13594  const body=document.getElementById('earningsBody'); if(!body) return;
13595  body.innerHTML='<div class="col-loading">'+(force?'Re-scoring ':'Scoring ')+_earnDayLbl(date)+' earnings on current flow…</div>';
13596  loadEarningsOutcomes(date, force);
13597  // force → &fresh=1 rebuilds past the 20-min cache on live flow + levels (what the 12:40 PT
13598  // auto re-score does), so you're never stuck waiting for the schedule.
13599  const _etok=(window._earnDateTok=(window._earnDateTok||0)+1);   // AUDIT FIX: latest-wins — two quick date clicks could paint the slower (older) date's list under the newer date's highlighted button
13600  try{
13601    let j=null;
13602    if(!force && _earnPre && _earnPre.ideas && _earnPre.date===date && Date.now()-_earnPre.t<5*60*1000){ j=_earnPre.ideas; _earnPre.ideas=null; }   // warmed copy, once
13603    if(!j) j=await fetch('/api/earnings-ideas?date='+encodeURIComponent(date)+(force?'&fresh=1&t='+Date.now():'')).then(r=>r.json());
13604    if(_etok!==window._earnDateTok) return;
13605    renderEarnings(j);
13606  }
13607  catch(e){ if(_etok===window._earnDateTok) body.innerHTML='<div class="col-loading">Couldn\'t score that day.</div>'; }
13608}
13609function refreshEarningsNow(ev){ if(ev){ev.stopPropagation();} if(_earnDate) loadEarningsDate(_earnDate, true); }
13610// ── Earnings CALENDAR panel (right side, second tab) ────────────────────────────────────────────
13611// Every company reporting this week — not just the optionable large-caps the scoring engine
13612// trades. Sun = before the open, moon = after the close (marked after the ticker, user request).
13613// Day filter (each weekday + All week) and every column sortable: Name / session / market cap /
13614// street mean EPS estimate.
13615let _earnRightTab='outcomes', _earnCalData=null, _earnCalDay='today', _earnCalSort={ key:'mc', dir:-1 }, _earnCalAt=0;
13616function setEarnRightTab(t){
13617  _earnRightTab=(t==='cal')?'cal':'outcomes';
13618  const o=document.getElementById('earningsOutcomes'), c=document.getElementById('earningsCal');
13619  if(o) o.style.display=(_earnRightTab==='outcomes')?'':'none';
13620  if(c) c.style.display=(_earnRightTab==='cal')?'flex':'none';
13621  const bo=document.getElementById('earnRTab-outcomes'), bc=document.getElementById('earnRTab-cal');
13622  if(bo) bo.classList.toggle('active', _earnRightTab==='outcomes');
13623  if(bc) bc.classList.toggle('active', _earnRightTab==='cal');
13624  if(_earnRightTab==='cal') loadEarningsCalendar();
13625}
13626async function loadEarningsCalendar(force){
13627  const body=document.getElementById('earnCalBody'); if(!body) return;
13628  if(!force && _earnCalData && Date.now()-_earnCalAt<10*60*1000){ renderEarningsCalendar(
13628); return; }
13629  if(!_earnCalData) body.innerHTML='<div class="col-loading">Loading calendar…</div>';
13630  try{
13631    const j=await fetch('/api/earnings-calendar').then(r=>r.json());
13632    if(j && j.error){ body.innerHTML='<div class="col-loading col-error">'+j.error+'</div>'; return; }
13633    _earnCalData=j; _earnCalAt=Date.now();
13634    renderEarningsCalendar();
13635  }catch(e){ if(!_earnCalData) body.innerHTML='<div class="col-loading col-error">Could not load the calendar.</div>'; }
13636}
13637function setEarnCalDay(d){ _earnCalDay=d; renderEarningsCalendar(); }
13638function setEarnCalSort(k){
13639  if(_earnCalSort.key===k) _earnCalSort.dir*=-1;
13640  else _earnCalSort={ key:k, dir:(k==='ticker'||k==='session')?1:-1 };   // text ascends, numbers descend by default
13641  renderEarningsCalendar();
13642}
13643function _earnMc(v){ if(!(v>0)) return '—'; if(v>=1e12) return '$'+(v/1e12).toFixed(1)+'T'; if(v>=1e9) return '$'+(v/1e9).toFixed(1)+'B'; if(v>=1e6) return '$'+(v/1e6).toFixed(0)+'M'; return '$'+Math.round(v); }
13644function renderEarningsCalendar(){
13645  const body=document.getElementById('earnCalBody'), daysEl=document.getElementById('earnCalDays');
13646  if(!body || !_earnCalData) return;
13647  const days=(_earnCalData.days)||[], today=_earnCalData.today||'';
13648  // Day chips: Today (when this week contains it) + each weekday + All week.
13649  if(daysEl){
13650    const chip=(k,lbl,ttl)=>'<button class="tf-btn '+(_earnCalDay===k?'active':'')+'" onclick="setEarnCalDay(\''+k+'\')" style="font-size:9px;padding:2px 7px"'+(ttl?' title="'+ttl+'"':'')+'>'+lbl+'</button>';
13651    let h='';
13652    if(days.some(d=>d.date===today)) h+=chip('today','Today');
13653    for(const d of days){ const p=d.date.split('-'); const dt=new Date(+p[0],+p[1]-1,+p[2]);
13654      h+=chip(d.date, ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][dt.getDay()]+' '+(+p[1])+'/'+(+p[2]), d.rows.length+' reporting'); }
13655    h+=chip('all','All week');
13656    if(daysEl._h!==h){ daysEl._h=h; daysEl.innerHTML=h; }
13657  }
13658  // Collect the rows for the selected scope.
13659  let rows=[];
13660  if(_earnCalDay==='all'){ for(const d of days) for(const r of d.rows) rows.push(Object.assign({ date:d.date }, r)); }
13661  else { const want=(_earnCalDay==='today')?today:_earnCalDay; const d=days.find(x=>x.date===want); if(d) rows=d.rows.map(r=>Object.assign({ date:d.date }, r)); }
13662  if(!rows.length){ body.innerHTML='<div style="padding:16px 12px;color:var(--dim);font-size:11.5px;text-align:center">No companies reporting'+(_earnCalDay==='all'?' this week':' that day')+'.</div>'; return; }
13663  const S=_earnCalSort, dir=S.dir;
13664  rows.sort(function(a,b){
13665    if(S.key==='ticker') return dir*String(a.ticker).localeCompare(String(b.ticker));
13666    if(S.key==='session'){ const c=dir*String(a.session).localeCompare(String(b.session)); return c || (b.mc||0)-(a.mc||0); }   // bmo before amc, then biggest first
13667    if(S.key==='eps'){ const av=(a.eps==null)?-Infinity:a.eps, bv=(b.eps==null)?-Infinity:b.eps; return dir*(av-bv) || String(a.ticker).localeCompare(String(b.ticker)); }
13668    return dir*((a.mc||0)-(b.mc||0)) || String(a.ticker).localeCompare(String(b.ticker));
13669  });
13670  const bmoN=rows.filter(r=>r.session==='bmo').length, amcN=rows.length-bmoN;
13671  const arrow=k=>S.key===k?(dir>0?' ▲':' ▼'):'';
13672  const th=(k,lbl,ttl,align)=>'<th onclick="setEarnCalSort(\''+k+'\')" title="'+(ttl||'')+'" style="cursor:pointer;user-select:none;text-align:'+(align||'left')+';position:sticky;top:0;background:var(--card);z-index:1;padding:6px 7px;font-size:9px;letter-spacing:.5px;color:'+(S.key===k?'#e2e8f0':'var(--dim)')+';border-bottom:1px solid var(--border);white-space:nowrap">'+lbl+arrow(k)+'</th>';
13673  let h='<div style="padding:7px 11px;font-size:10px;color:var(--dim);display:flex;gap:10px;align-items:center;flex-wrap:wrap">'
13674    + '<span><b style="color:#e2e8f0">'+rows.length+'</b> reporting</span>'
13675    + '<span title="Before the open">☀ <b style="color:#fbbf24">'+bmoN+'</b></span>'
13676    + '<span title="After the close">🌙 <b style="color:#818cf8">'+amcN+'</b></span></div>';
13677  h+='<table style="width:100%;border-collapse:collapse;font-size:11px"><thead><tr>'
13678    + th('ticker','NAME','Sort by ticker')
13679    + th('session','☀ / 🌙','Sort by session — sun = before the open, moon = after the close','center')
13680    + th('mc','MKT CAP','Sort by market cap','right')
13681    + th('eps','EST EPS','Sort by the street\'s mean EPS estimate','right')
13682    + '</tr></thead><tbody>';
13683  for(const r of rows){
13684    const isB=r.session==='bmo';
13685    const dstr=(_earnCalDay==='all' && r.date)?('<span style="color:#8b9cb5;font-size:9px;margin-left:4px">'+r.date.slice(5).replace('-','/')+'</span>'):'';
13686    h+='<tr style="border-bottom:1px solid rgba(148,163,184,.07)">'
13687      + '<td style="padding:5px 7px"><span style="font-weight:800;color:#e2e8f0;cursor:pointer" onclick="chartTicker=\''+r.ticker+'\';setView(\'chart\')" title="'+String(r.name||'').replace(/"/g,'')+' — open the chart">'+r.ticker+'</span> <span title="'+(isB?'Before the open':'After the close')+'">'+(isB?'☀':'🌙')+'</span>'+dstr+(r.sp500?'<span style="color:#38bdf8;font-size:8px;font-weight:800;margin-left:4px" title="S&amp;P 500">SPX</span>':'')+'</td>'
13688      + '<td style="padding:5px 7px;text-align:center;
13688color:'+(isB?'#fbbf24':'#818cf8')+';font-weight:700;font-size:9px">'+(isB?'AM':'PM')+'</td>'
13689      + '<td style="padding:5px 7px;text-align:right;color:#9fb0c9">'+_earnMc(r.mc)+'</td>'
13690      + '<td style="padding:5px 7px;text-align:right;font-weight:700;color:'+(r.eps==null?'#8b9cb5':(r.eps>=0?'#5ef08a':'#fca5a5'))+'">'+(r.eps==null?'—':(r.eps>=0?'':'-')+'$'+Math.abs(r.eps).toFixed(2))+'</td>'
13691      + '</tr>';
13692  }
13693  h+='</tbody></table>';
13694  if(body._h!==h){ body._h=h; body.innerHTML=h; }
13695}
13696// Right-side panel: what each name actually DID on the print (BMO = that day's close vs the prior
13697// close; AMC = the next session's close vs that day's close).
13698let _earnOutToken=0;
13699async function loadEarningsOutcomes(date, force, attempt){
13700  const el=document.getElementById('earningsOutcomes'); if(!el) return;
13701  const tok=++_earnOutToken;
13702  if(!attempt) el.innerHTML='<div class="col-loading">Loading outcomes…</div>';
13703  try{
13704    const j=await fetch('/api/earnings-outcomes?date='+encodeURIComponent(date)+(force?'&fresh=1&t='+Date.now():'')).then(r=>r.json());
13705    if(tok!==_earnOutToken) return;   // a newer date was selected while this was in flight
13706    if(j && j.error) throw new Error(j.error);
13707    renderEarningsOutcomes(j);
13708  }catch(e){
13709    if(tok!==_earnOutToken) return;
13710    // A one-off failure (a build still warming right after a deploy/restart) parked the panel on a
13711    // dead error message for the rest of the visit. Retry twice with a growing wait; only give up
13712    // after the third miss — and even then, say what to do about it.
13713    const a=(attempt||0)+1;
13714    if(a<3){ el.innerHTML='<div class="col-loading">Marking the given contracts… (retry '+a+')</div>'; setTimeout(function(){ if(tok===_earnOutToken) loadEarningsOutcomes(date, false, a); }, 8000*a); }
13715    else el.innerHTML='<div class="col-loading">Couldn\'t load outcomes — switch dates and back, or hit Refresh.</div>';
13716  }
13717}
13718// Right panel = how the trades we GAVE actually did. Only names that got strikes appear; a name that
13719// was on the calendar but never produced a contract has no trade to be right or wrong about, and
13720// mixing those rows in made the panel unreadable. P/L is on the option (entry premium → current
13721// mark, or intrinsic once expired), with the stock's move on the print as context underneath.
13722function renderEarningsOutcomes(j){
13723  const el=document.getElementById('earningsOutcomes'); if(!el) return;
13724  const rows=(j&&j.rows)||[], st=(j&&j.stats)||{};
13725  const hdr='<div style="font-size:12px;font-weight:800;color:#e2e8f0;letter-spacing:.4px;margin-bottom:3px">EARNINGS P/L</div>'
13726    + '<div style="font-size:10.5px;color:#9fb0c9;margin-bottom:12px">on the strikes we gave · '+_earnDayLbl(j&&j.date)+'</div>';
13727  if(!rows.length){ el.innerHTML=hdr+'<div style="color:var(--dim);font-size:12px">No tradeable contract was given for that day.</div>'; return; }
13728  const pill=(lbl,val,col)=>'<div style="flex:1;text-align:center"><div style="font-size:17px;font-weight:800;color:'+col+';line-height:1">'+val+'</div><div style="font-size:9px;color:#94a3b8;letter-spacing:.6px;margin-top:2px">'+lbl+'</div></div>';
13729  const _sg=v=>(v>0?'+':'')+v;
13730  const summary='<div style="display:flex;gap:4px;background:rgba(148,163,184,.06);border:1px solid var(--border);border-radius:9px;padding:10px 6px;margin-bottom:12px">'
13731    + pill('WINS', st.wins||0, '#22c55e') + pill('LOSSES', st.losses||0, '#ef4444')
13732    + pill('AVG P/L', st.avgPl!=null?(_sg(st.avgPl)+'%'):'—', st.avgPl>0?'#22c55e':(st.avgPl<0?'#ef4444':'#e2e8f0'))
13733    + pill('NET', st.netUsd!=null?(_sg(Math.round(st.netUsd))+''):'—', st.netUsd>0?'#22c55e':(st.netUsd<0?'#ef4444':'#e2e8f0')) + '</div>';
13734  const _leg=l=>l.strike+(String(l.type).toUpperCase()==='CALL'?'C':'P');
13735  const row=o=>{
13736    const has=o.plPct!=null, win=o.plPct>0, col=!has?'#94a3b8':(win?'#22c55e':'#ef4444');
13737    const sess=o.session==='bmo'?'BMO':'AMC';
13738    const contract=(o.legs||[]).map(_leg).join(' + ')+((o.legs&&o.legs[0]&&o.legs[0].expiry)?' · '+o.legs[0].expiry.slice(5):'');
13739    const mv=(o.movePct!=null)?((o.movePct>0?'▲':'▼')+' '+Math.abs(o.movePct).toFixed(2)+'% stock'):(o.pending?'awaiting print':'');
13740    return '<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid rgba(148,163,184,.07)">'
13741      + '<span style="flex:1;min-width:0">'
13742        + '<span style="display:flex;align-items:center;gap:6px"><span style="font-weight:800;color:#e2e8f0;font-size:13px">'+o.ticker+'</span>'
13743        + '<span style="font-size:9.5px;color:#94a3b8;border:1px solid var(--border);border-radius:4px;padding:1px 5px;flex:none">'+sess+'</span>'
13744        + (o.state==='target'?'<span title="Reached its target premium" style="font-size:9px;color:#22c55e;font-weight:700">
13744TARGET</span>'
13745           :o.state==='expired'?'<span title="Expired — settled at intrinsic" style="font-size:9px;color:#94a3b8">expired</span>'
13746           :'<span title="Still running — marked against the live premium" style="font-size:9px;color:#38bdf8">open</span>')+'</span>'
13747        + '<div style="font-size:10.5px;color:#a8b8cf;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+contract+'</div>'
13748      + '</span>'
13749      + '<span style="text-align:right;flex:none">'
13750        + '<div style="font-size:14px;font-weight:800;color:'+col+';line-height:1.1">'+(has?(_sg(o.plPct)+'%'):'—')+'</div>'
13751        + '<div style="font-size:10px;color:#a8b8cf">'+(has?(o.entryPrem.toFixed(2)+' → '+o.nowPrem.toFixed(2)):'no mark')+'</div>'
13752      + '</span>'
13753      + '<span title="Moved more than the options market priced in" style="flex:none;width:14px;text-align:center;font-size:11px;color:'+(o.beatEm?'#38bdf8':'transparent')+'">✦</span>'
13754    + '</div>'
13755    + (mv?'<div style="font-size:10px;color:#94a3b8;margin:-4px 0 4px 0">'+mv+(o.before!=null?' · '+o.before.toFixed(2)+' → '+o.after.toFixed(2):'')+'</div>':''); };
13756  el.innerHTML=hdr+summary
13757    + '<div style="font-size:10px;color:#94a3b8;margin-bottom:2px">'+(st.resolved||0)+' of '+(st.total||0)+' priced · ✦ beat the expected move</div>'
13758    + rows.map(row).join('');
13759}
13760function _earnDayLbl(d){ try{ const p=String(d).split('-'); const dt=new Date(+p[0],+p[1]-1,+p[2]); return dt.toLocaleDateString([], {weekday:'short',month:'short',day:'numeric'}); }catch(e){ return d; } }
13761function renderEarnings(j){
13762  const body=document.getElementById('earningsBody'); if(!body) return;
13763  const bmo=(j&&j.bmo)||[], amc=(j&&j.amc)||[], counts=(j&&j.counts)||{};
13764  if(!bmo.length && !amc.length){
13765    // Future date whose scoring windows haven't opened yet — say WHEN each session's plays are
13766    // given instead of showing a blank (or worse, a premature score off this week's flow).
13767    if(j && (j.bmoPendingAt || j.amcPendingAt)){
13768      body.innerHTML='<div class="col-loading" style="line-height:1.7">'+_earnDayLbl(j&&j.date)+' — plays are given on schedule, not in advance (flow and GEX shift daily):<br><br>'
13769        +(counts.bmo?('<b style="color:#e2e8f0">Before open · '+counts.bmo+' reporting</b><br><span style="color:#38bdf8">'+(j.bmoPendingAt||'')+'</span><br><br>'):'')
13770        +(counts.amc?('<b style="color:#e2e8f0">After close · '+counts.amc+' reporting</b><br><span style="color:#38bdf8">'+(j.amcPendingAt||'')+'</span>'):'')
13771      +'</div>'; return;
13772    }
13773    // Distinguish "nothing reported that day" from "the plays weren't saved". A locked session with
13774    // no stored strikes returns empty on purpose (re-scoring it now would invent contracts off the
13775    // wrong day's flow) — but the old message blamed the calendar, which reads as data loss.
13776    if(j && (j.bmoUnrecorded || j.amcUnrecorded)){
13777      body.innerHTML='<div class="col-loading">'+_earnDayLbl(j&&j.date)+' — '+(counts.bmo+counts.amc||'these')+' names reported, but the strikes given that day were never saved to disk (permanent saving of the pre-print scores started Jul 30). Showing a re-score built on today\'s flow would invent contracts that were never given, so this day stays blank. Every day from Jul 30 on keeps its exact given strikes.</div>'; return;
13778    }
13779    body.innerHTML='<div class="col-loading">No optionable large-cap earnings for '+_earnDayLbl(j&&j.date)+'.'+((counts.bmo+counts.amc)?' ('+(counts.bmo+counts.amc)+' reporting, none with tradeable options.)':'')+'</div>'; return;
13780  }
13781  // emptyText: an unrecorded session must NOT say "Nothing reporting" right under a "49 reporting"
13782  // count — names reported, we just have no saved strikes for them.
13783  const sec=(title, sub, list, total, note, emptyText)=>'<div style="margin-bottom:22px"><div style="display:flex;align-items:baseline;gap:8px;margin-bottom:10px;flex-wrap:wrap"><span style="font-size:13px;font-weight:800;color:#e2e8f0;letter-spacing:.3px">'+title+'</span><span style="font-size:11px;color:var(--dim)">'+sub+(total?' · '+total+' reporting':'')+(list.length?' · top '+list.length+' by conviction':'')+'</span>'+(note?'<span style="font-size:10.5px;width:100%;margin-top:2px">'+note+'</span>
13783':'')+'</div>'
13784    + (list.length? '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px;max-width:1180px">'+list.map((it,i)=>_earnCard(it,i+1)).join('')+'</div>' : '<div style="color:var(--dim);font-size:12px">'+(emptyText||'Nothing reporting this session.')+'</div>')+'</div>';
13785  // When the set was scored matters for the after-close names: their contract pick is built from
13786  // flow + GEX levels that only develop through the day, so a morning score is a morning read.
13787  // A session is LOCKED once it has been scored for the last time before its print (12:40 PM PT).
13788  // From then on it serves those exact strikes forever — looking back at an old day shows what you
13789  // were actually given, not a fresh score off today's flow.
13790  const _hm=ms=>{ try{ return new Date(ms).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); }catch(e){ return ''; } };
13791  const bt=(j&&j.builtAt)?new Date(j.builtAt):null;
13792  const stamp=bt?('scored '+bt.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})+' · '):'';
13793  const lock=at=>'<span style="color:#22c55e">locked'+(at?' · scored '+_hm(at):'')+' — these are the strikes that were given</span>';
13794  const pend='<span style="color:#eab308">'+stamp+'scored on current flow — locks at 12:40 PM PT</span>';
13795  // A session that locked without ever being recorded is NOT "nothing reported" — re-scoring it now
13796  // would invent strikes off the wrong session's flow and present them as the plays that were given.
13797  const unrec='<span style="color:#94a3b8">not recorded — this session had already reported by the time locking started, so there are no given strikes to show</span>';
13798  const notyet=t=>'<span style="color:#38bdf8">'+t+'</span>';
13799  const amcNote=(j&&j.amcPendingAt)?notyet(j.amcPendingAt):((j&&j.amcUnrecorded)?unrec:((j&&j.amcFinal)?lock(j.amcFrozenAt):((j&&j.isToday)?pend:'')));
13800  const bmoNote=(j&&j.bmoPendingAt)?notyet(j.bmoPendingAt):((j&&j.bmoUnrecorded)?unrec:((j&&j.bmoFinal)?lock(j.bmoFrozenAt):((j&&j.isNext)?pend:'')));
13801  body.innerHTML = sec('BEFORE OPEN','reports pre-market — enter the day before / at the open', bmo, counts.bmo, bmoNote, (j&&j.bmoPendingAt)?'Plays not given yet — see the schedule above.':((j&&j.bmoUnrecorded)?'No saved strikes for this session.':null))
13802                 + sec('AFTER CLOSE','reports after the close — enter into the close', amc, counts.amc, amcNote, (j&&j.amcPendingAt)?'Plays not given yet — see the schedule above.':((j&&j.amcUnrecorded)?'No saved strikes for this session.':null));
13803  // Re-score can only ever touch a session that hasn't locked yet — say so rather than letting the
13804  // button imply a locked day will change.
13805  try{ const rb=document.getElementById('earnRescoreBtn');
13806    if(rb){ const frozen=!!(j&&j.frozen); rb.style.opacity=frozen?'.4':'1'; rb.style.cursor=frozen?'not-allowed':'pointer';
13807      rb.title=frozen?'This day is locked — the strikes it was scored with are final and will not change':'Re-score the sessions that have not locked yet, on current flow + GEX levels (same as the 12:40 PM PT auto pass)'; }
13808  }catch(e){}
13809}
13810// Pack every leg into one data-attribute (URI-encoded so quotes can't break out of the HTML).
13811function _trkLegs(legs){
13812  try{ return encodeURIComponent(JSON.stringify((legs||[]).map(l=>({s:l.strike,t:l.type==='CALL'?'C':'P',e:l.expiry,p:l.prem})))); }
13813  catch(e){ return ''; }
13814}
13815function _earnCard(it, rank){
13816  const gradeCol={A:'#22c55e',B:'#38bdf8',C:'#eab308',D:'#94a3b8'};
13817  const gc=gradeCol[it.grade]||'#94a3b8';
13818  const isStrad=it.dir==='STRADDLE', dc=isStrad?'#a855f7':(it.dir==='CALLS'?'#22c55e':'#ef4444');
13819  // Legs are picked independently by nearest-to-spot, so a two-leg trade is a STRANGLE whenever the
13820  // strikes differ — the server labels it; fall back to dir for older cached payloads.
13821  const struct=it.structure||it.dir;
13822  const noTrade=it.tradeable===false;
13823  const legLine=(it.legs||[]).map(l=>'$'+l.strike+(l.type==='CALL'?'C':'P')).join(' + ') + (it.legs&&it.legs[0]?' · '+_expShort(it.legs[0].expiry):'');
13824  const emCol=it.emClears?'#22c55e':'#eab308';
13825  const leanLbl=isStrad?'MIXED FLOW':(it.dir==='CALLS'?'CALL LEAN':'PUT LEAN');
13826  // Net call vs put premium going into the print — the raw flow behind the direction call.
13827  const cp=+it.callPrem||0, pp=+it.putPrem||0, np=+it.netPrem||0, tot=cp+pp;
13828  const cw=tot>0?Math.round(cp/tot*100):50;
13829  const flowRow=tot>0?('<div style="margin-bottom:9px">'
13830    + '<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:4px">'
13831      + '<span style="color:#22c55e;font-weight:700">Calls '+_moPrem(cp)+'</span>'
13832      + '<span style="color:#9fb0c9">Net <b style="color:'+(np>=0?'#22c55e':'#ef4444')+'">'+(np>=0?'+':'-')+_moPrem(np)+'</b></span>'
13833      + '<span style="color:#ef4444;font-weight:700">Puts '+_moPrem(pp)+'</span>'
13834    + '</div>'
13835    + '<div style="display:flex;height:5px;border-radius:3px;overflow:hidden;background:rgba(148,163,184,.15)" title="'+cw+'% of premium is calls">'
13836      + '<div style="width:'+cw+'%;background:#22c55e"></div><div style="flex:1;background:#ef4444"></div>'
13837    + '</div></div>'):'';
13838  // Headlines going into the print — context for why the flow looks the way it does.
13839  const cats=(it.catalysts||[]).slice(0,2);
13840  const catHtml=cats.length?('<div style="margin-top:9px;padding-top:8px;border-top:1px solid rgba(148,163,184,.09)">'
13841    + cats.map(c=>'<div style="font-size:11px;color:#9fb0c9;line-height:1.45;margin-bottom:3px">• '+_esc2(c)+'</div>').join('')
13842  + '</div>'):'';
13843  // Shared bottom chip row — levels/spot read the same whether or not there's a contract to trade.
13844  // King chip carries the magnet direction: ahead pulls toward the trade, behind fights it (-4).
13845  const kingCol=it.kingBehind?'#ef4444':(it.kingAhead?'#22c55e':'#eab308');
13846  const kingTip=it.kingBehind?'King magnet is BEHIND price — it pulls against this trade (-4)':(it.kingAhead?'King magnet is ahead — it pulls toward the trade (+8)':'Biggest gamma magnet');
13847  // Stock-price breakevens at expiry — the concrete levels the trade pays past. For a strangle/
13848  // straddle: call strike + total premium on the upside, put strike − total premium below.
13849  let beChip='';
13850  const _lgs=it.legs||[];
13851  if(_lgs.length===2 && it.entryPrem>0){
13852    const _cl=_lgs.find(l=>l.type==='CALL'), _pl=_lgs.find(l=>l.type==='PUT');
13853    if(_cl&&_pl) beChip='<span style="border:1px solid var(--border);border-radius:6px;padding:2px 8px;color:#cbd5e1" title="Stock prices where this trade breaks even at expiry — it pays beyond either level">Pays &gt;<b style="color:#22c55e">'+(+_cl.strike + +it.entryPrem).toFixed(2)+'</b> or &lt;<b style="color:#ef4444">'+(+_pl.strike - +it.entryPrem).toFixed(2)+'</b></span>';
13854  } else if(_lgs.length===1 && it.entryPrem>0){
13855    const _l=_lgs[0], _be=_l.type==='CALL'?(+_l.strike + +it.entryPrem):(+_l.strike - +it.entryPrem);
13856    beChip='<span style="border:1px solid var(--border);border-radius:6px;padding:2px 8px;color:#cbd5e1" title="Stock price where this contract breaks even at expiry">BE <b style="color:#eab308">'+_be.toFixed(2)+'</b></span>';
13857  }
13858  const chips=(it.kingAll!=null?'<span style="border:1px solid var(--border);border-radius:6px;padding:2px 8px;color:#cbd5e1" title="'+kingTip+'">King ALL <b style="color:'+kingCol+'">'+it.kingAll+'</b>'+(it.kingBehind?' <span style="color:#ef4444;font-size:9px;font-weight:800">AGAINST</span>':(it.kingAhead?' <span style="color:#22c55e;font-size:9px;font-weight:800">AHEAD</span>':''))+'</span>':'')
13859    + (it.frontKing!=null?'<span style="border:1px solid var(--border);border-radius:6px;padding:2px 8px;color:#cbd5e1" title="King on the '+(it.frontExp||'')+' expiry">King 0DTE <b style="color:#eab308">'+it.frontKing+'</b></span>':'')
13860    + (it.spot?'<span style="border:1px solid var(--border);border-radius:6px;padding:2px 8px;color:#9fb0c9">spot '+(+it.spot).toFixed(2)+'</span>':'');
13861  const head='<div style="display:flex;align-items:center;gap:9px;padding:12px 14px 10px;border-bottom:1px solid rgba(148,163,184,.10)">'
13862      + '<span style="width:24px;height:24px;border-radius:7px;background:rgba(6,182,212,.15);color:var(--cyan);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:12px">'+rank+'</span>'
13863      + '<span style="font-size:18px;font-weight:800;color:#e2e8f0">'+it.ticker+'</span>'
13864      + '<span style="background:'+(isStrad?'rgba(168,85,247,.15)':(it.dir==='CALLS'?'rgba(34,197,94,.15)':'rgba(239,68,68,.15)'))+';color:'+dc+';border-radius:6px;padding:3px 9px;font-size:11px;font-weight:800">'+(noTrade?leanLbl:struct)+'</span>'
13865      + '<span style="margin-left:auto;text-align:right"><div style="font-size:20px;font-weight:800;color:'+gc+';line-height:1">'+it.score+'</div><div style="font-size:9px;color:var(--dim);letter-spacing:1px">CONVICTION '+it.grade+'</div></span>'
13866    + '</div>';
13867  // No tradeable contract — still show the name, its conviction read and what an ATM lot would cost.
13868  if(noTrade) return '<div style="background:var(--card);border:1px solid var(--border);border-left:3px solid rgba(148,163,184,.45);border-radius:12px;overflow:hidden;opacity:.9">'+head
13869    + '<div style="padding:11px 14px">'
13870      + '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px">'
13871        + '<span style="color:#94a3b8;font-size:13px;font-weight:700">No contract — '+(it.why||'nothing tradeable')+'</span>'
13872        + (it.atmLot?'<span style="background:rgba(148,163,184,.12);color:#cbd5e1;border-radius:6px;padding:2px 9px;font-size:12px;font-weight:800;white-space:nowrap" title="Cheapest near-the-money lot seen in flow, ignoring the $800 budget">ATM ≈ $'+_ideaNum(it.atmLot)+'</span>':'')
13873      + '</div>'
13874      + flowRow
13875      + '<div style="display:flex;flex-wrap:wrap;gap:6px;font-size:11px">'
13876        + '<span style="border:1px solid var(--border);border-radius:6px;padding:2px 8px;color:#cbd5e1" title="Expected move priced by the options market">Exp move <b style="color:#eab308">±'+it.em+'%</b></span>'
13877        + chips
13878      + '</div>'
13879      + catHtml
13880    + '</div></div>';
13881  return '<div style="background:var(--card);border:1px solid var(--border);border-left:3px solid '+dc+';border-radius:12px;overflow:hidden">'+head
13882    + '<div style="padding:11px 14px">'
13883      + '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px">'
13884        + '<span style="color:#e2e8f0;font-size:14px;font-weight:700">'+legLine+'</span>'
13885        + '<span style="background:'+(it.overBudget?'rgba(245,158,11,.14)':'rgba(34,197,94,.14)')+';color:'+(it.overBudget?'#f59e0b':'#22c55e')+';border-radius:6px;padding:2px 9px;font-size:12px;font-weight:800;white-space:nowrap" title="'+(it.overBudget?'Nothing trades under $800/lot on this name — this is the CHEAPEST suitable contract (next available premium)':(isStrad?'Combined cost of both legs':'Cost per contract'))+'">$'+_ideaNum(it.entryLot)+' / '+(isStrad?struct.toLowerCase():'contract')+(it.overBudget?' · OVER BUDGET':'')+'</span>'
13886      + '</div>'
13887      + '<div style="display:flex;gap:14px;font-size:12px;margin-bottom:8px">'
13888        + '<span style="color:#9fb0c9">Entry <b style="color:#e2e8f0">$'+(+it.entryPrem).toFixed(2)+'</b></span>'
13889        + (it.targetPct > 0
13890          ? '<span style="color:#9fb0c9">Target <b style="color:#22c55e">$'+(+it.target).toFixed(2)+'</b> <span style="color:#22c55e">(+'+it.targetPct+'%)</span></span>'
13891          // Expected move under the breakeven → the model prices ZERO profit at the expected move.
13892          // A green "target = entry (+0%)" read as a bug; say what it means instead.
13893          : '<span style="color:#9fb0c9" title="The options-priced expected move (±'+it.em+'%) is smaller than the move this trade needs to break even ('+it.beMovePct+'%), so the model shows no profit at the expected move. It only pays if the print BEATS what options have priced in.">Target <b style="color:#eab308">needs &gt;±'+it.beMovePct+'% move</b></span>')
13894        + '<span style="color:#9fb0c9" title="Earnings are binary — the whole premium is the risk. No stop.">Risk <b style="color:#ef4444">full premium</b></span>'
13895      + '</div>'
13896      + flowRow
13897      + '<div style="display:flex;flex-wrap:wrap;gap:6px;font-size:11px">'
13898        + '<span style="border:1px solid var(--border);border-radius:6px;padding:2px 8px;color:#cbd5e1" title="Expected move priced by options vs the move needed to break even">Exp move <b style="color:'+emCol+'">±'+it.em+'%</b> <span style="color:var(--dim)">(BE '+it.beMovePct+'%)</span></span>'
13899        + beChip
13900        + chips
13901        + '<button class="flow-trk" style="margin-left:auto" onclick="event.stopPropagation();
13901trackFlow(this)" data-ticker="'+it.ticker+'" data-legs="'+_trkLegs(it.legs)+'" data-grpkind="'+(isStrad?struct:'')+'" data-side="buy" title="Track '+(isStrad?'BOTH legs of the '+struct.toLowerCase()+' as one combined position':'this contract')+'">+ Track'+(isStrad?' both':'')+'</button>'
13902      + '</div>'
13903      + catHtml
13904    + '</div></div>';
13905}
13906async function loadTradeIdeas(force){
13907  const body=document.getElementById('ideasBody'); if(!body) return;
13908  try{ _gfSideResizer('ideasSetupWrap','ideasSide','gf_ideasSideW'); }catch(e){}   // resizable right panel - covers morning + after-hours (same panel)
13909  if(_ideasSession==='pm' && _ideasTab==='setups'){ _ideasSession='am'; try{ localStorage.setItem('gf_ideas_sess','am'); }catch(e){} }   // Setups are morning-only (evening retired 2026-08-08); other tabs keep their Evening session
13910  _ideasSyncBtns();
13911  if(!force && Date.now()-_ideasLoaded<4000 && body.dataset.loaded) return;
13912  if(!body.dataset.loaded) body.innerHTML='<div class="col-loading">Loading trade ideas…</div>';
13913  _ideasLoaded=Date.now();
13914  const _tok=(window._ideasLoadTok=(window._ideasLoadTok||0)+1);   // AUDIT FIX: latest-wins — rapid AM/PM toggles raced and the slower response painted under the wrong button
13915  try{
13916    let url;
13917    if(_ideasSession==='ah'){ url='/api/trade-ideas/ah'; if(force && (typeof isAdminUser!=='undefined' && isAdminUser)) url+='?run=1'; }
13918    else { url='/api/trade-ideas?session='+_ideasSession; if(_ideasMode==='live'){ url+='&mode=live'; if(force) url+='&fresh=1'; } }
13919    const j=await fetch(url).then(r=>r.json());
13920    if(_tok!==window._ideasLoadTok) return;
13921    if(_ideasSession==='ah') window._ahLast=j;
13922    renderTradeIdeas(j); body.dataset.loaded='1';
13923    // "Today's ideas" running log — only in Live mode (snapshot has its own single-time view).
13924    const _td=document.getElementById('ideasToday');
13925    if(_ideasMode==='live'){
13926      const liveKeys=((j&&j.ideas)||[]).map(it=>it.ticker+'|'+it.dir+'|'+(it.contract||{}).strike+'|'+(it.contract||{}).expiry+'|'+(it.contract||{}).type);
13927      try{ const tj=await fetch('/api/trade-ideas/today').then(r=>r.json()); renderIdeasToday((tj&&tj.items)||[], liveKeys); }catch(e){ if(_td) _td.innerHTML=''; }
13928    } else if(_td){ _td.innerHTML=''; }
13929  }catch(e){ if(!body.dataset.loaded) body.innerHTML='<div class="col-loading">Could not load trade ideas — try again shortly.</div>'; }
13930  if(_ideasSession==='ah'){
13931    // Live-refresh the AH book so open positions show running P&L, same as morning setups (user 2026-09-15).
13932    if(window._ahTimer) clearInterval(window._ahTimer);
13933    window._ahTimer=setInterval(()=>{ if(gfLive('ideas') && _ideasSession==='ah' && _ideasTab==='setups') loadTradeIdeas(); else { clearInterval(window._ahTimer); window._ahTimer=null; } },30000);
13934    try{ if(!_ideaCoachKey || String(_ideaCoachKey).slice(0,3)!=='ah|'){ var _cc=document.getElementById('ideaCoach'); if(_cc) _cc.style.display='none'; } }catch(e){}   // no morning Live Read on the AH tab (user 2026-09-15)
13935    try{ _renderAhTracker(window._ahLast); }catch(e){} return;
13936  }
13937  if(false){ const _trk=document.getElementById('ideasTracker'); if(_trk) _trk.innerHTML="<div style=\"padding:14px 6px;font-size:12px;color:#cbd5e1;line-height:1.6\"><b style=\"color:#f5f0e6\">After-hours forecast</b><br>Setups that score well right now for the next session — the same grade system as the morning list, scanned across the whole universe (ETFs included).<br><br>A read, not tracked trades: entry, confirmation and the P&amp;L record begin on the Morning list when the session opens.</div>"; return; }
13938  loadIdeasTracker();
13939}
13940// AH's own P&L book, shown in the right panel on the After Hours tab. Pending until the zone is hit
13941// tomorrow, then live-marked, then win/loss - never Discord/RH/sim, admin-only.
13942function _renderAhTracker(j){
13943  const el=document.getElementById('ideasTracker'); if(!el) return;
13944  const rows=(j&&j.track)||[];
13945  const usd=v=>(v==null?'':((v>=0?'+':'-')+'$'+Math.abs(v).toLocaleString('en-US')));
13946  const pct=v=>(v==null?'':((v>=0?'+':'')+v+'%'));
13947  const done=rows.filter(r=>r.status==='win'||r.status==='loss');
13948  const openRows=rows.filter(r=>r.status==='live');
13949  const wins=done.filter(r=>r.plUsd>0).length, losses=done.length-wins;
13950  const closedUsd=done.reduce((a,r)=>a+(r.plUsd||0),0);
13951  const openUsd=openRows.reduce((a,r)=>a+(r.plUsd||0),0);
13952  const totUsd=closedUsd+openUsd;   // TOTAL P&L across ALL AH trades: realized (closed) + live (open) - user 2026-09-15
13953  const withPct=rows.filter(r=>r.plPct!=null && r.status!=='pending' && r.status!=='void');
13954  const avgPct=withPct.length?Math.round(withPct.reduce((a,r)=>a+(r.plPct||0),0)/withPct.length):null;
13955  const winRate=done.length?Math.round(wins/done.length*100):null;
13956  const wrCol=winRate==null?'#94a3b8':winRate>=55?'#22c55e':winRate>=45?'#eab308':'#ef4444';
13957  let h='<div style="padding:12px 6px 6px">'
13958    +'<div style="display:flex;align-items:baseline;gap:7px;margin-bottom:10px"><span style="font-size:13px;font-weight:800;color:#f5f0e6">After-Hours book</span><span style="font-size:10px;color:#8f8a7c;border:1px solid var(--border);border-radius:5px;padding:1px 6px">admin</span></div>';
13959  if(!rows.length){ h+='<div style="font-size:11px;color:#8f8a7c;padding:4px 0">No AH picks yet — run the scan on the After Hours tab.</div></div>'; el.innerHTML=h; return; }
13960  h+='<div style="display:flex;align-items:center;gap:12px;background:rgba(148,163,184,.05);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:12px">'
13961    +'<div style="text-align:center"><div style="font-size:24px;font-weight:800;color:'+wrCol+';line-height:1">'+(winRate!=null?winRate+'%':'—')+'</div><div style="font-size:9px;color:#8f8a7c;letter-spacing:1px">WIN RATE</div></div>'
13962    +'<div style="flex:1;font-size:12px;color:#cbd5e1;line-height:1.6"><div><span style="color:#22c55e;font-weight:700">'+wins+'W</span> · <span style="color:#ef4444;font-weight:700">'+losses+'L</span></div><div style="color:#8f8a7c;font-size:11px">'+done.length+' resolved · '+openRows.length+' open</div></div>'
13963    +'<div style="text-align:right;white-space:nowrap" title="Total P&L across all After-Hours trades: realized (closed) plus live (open), 1 contract each"><div style="font-size:20px;font-weight:800;color:'+(totUsd>=0?'#22c55e':'#ef4444')+';line-height:1">'+(totUsd>=0?'+$':'-$')+Math.abs(totUsd).toLocaleString('en-US')+'</div><div style="font-size:9px;color:#8f8a7c;letter-spacing:1px">TOTAL P&amp;L</div>'+(avgPct!=null?'<div style="font-size:11px;color:'+(avgPct>=0?'#22c55e':'#ef4444')+';font-weight:700;margin-top:2px">'+(avgPct>=0?'+':'')+avgPct+'% <span style="color:#8f8a7c;font-weight:400">avg</span></div>':'')+'</div>'
13964  +'</div>';
13965  // Panel order (user 2026-09-21): LIVE trades on top, PENDING under them, then the closed WIN/LOSS
13966  // book, voids last. Stable within each group so the server order (newest first) still holds.
13967  const _grp=r=>{ const st=r.status||'pending'; return st==='live'?0:st==='pending'?1:(st==='win'||st==='loss')?2:3; };
13968  const _grpLabel=['LIVE','PENDING','CLOSED','VOID'];
13969  const sorted=rows.map((r,i)=>({r,i,g:_grp(r)})).sort((a,b)=>a.g-b.g||a.i-b.i);
13970  let _lastG=-1;
13971  h+=sorted.map(function(o){
13972    const r=o.r; let _hdr='';
13973    if(o.g!==_lastG){ _lastG=o.g; _hdr='<div style="font-size:9px;font-weight:800;letter-spacing:1.2px;color:#8f8a7c;padding:8px 4px 2px">'+_grpLabel[o.g]+'</div>'; }
13974    const c=r.contract||{}, st=r.status||'pending';
13975    const stc = st==='win'?'#22c55e':st==='loss'?'#ef4444':st==='void'?'#8f8a7c':st==='pending'?'#eab308':'#38bdf8';
13976    const stl = st==='live'?'LIVE':st.toUpperCase();
13977    const pl = (st==='pending'||st==='void')?'' : ('<span style="color:'+((r.plPct||0)>=0?'#22c55e':'#ef4444')+';font-weight:800">'+pct(r.plPct)+(r.plUsd!=null?' · '+usd(r.plUsd):'')+'</span>');
13978    // Personal exit (user 2026-09-15): admin can close a LIVE AH pick at the live mark, or Cancel a
13979    // PENDING one before it enters. Two-step arm handled by gfExitPos; the AH route never touches Discord/RH/sim.
13980    const _canX = (typeof isAdminUser!=='undefined' && isAdminUser) && r.key && (st==='live'||st==='pending');
13981    const _ahx = _canX ? ('<button onclick="gfExitPos(this,\''+String(r.key).replace(/\'/g,'')+'\')" title="'+(st==='pending'?'Cancel this pick before it enters its zone':'Close this AH position now at the live mark')+'" style="font-size:9px;font-weight:800;letter-spacing:.4px;color:#fca5a5;background:transparent;border:1px solid rgba(239,68,68,.5);border-radius:5px;padding:1px 7px;cursor:pointer;margin-left:8px;flex:none">'+(st==='pending'?'Cancel':'Exit')+'</button>') : '';
13982    return _hdr+'<div style="border-top:1px solid rgba(148,163,184,.12)"><div style="display:flex;align-items:center;gap:8px;padding:7px 4px;font-size:11.5px">'
13983      +'<b style="color:#e2e8f0;min-width:44px">'+r.ticker+'</b>'
13984      +'<span style="color:#cbd5e1;min-width:96px">
13984$'+(+c.strike).toLocaleString('en-US')+(String(c.type||'').toUpperCase()==='PUT'?'P':'C')+' '+String(c.expiry||'').slice(5).replace("-","/")+'</span>'
13985      +'<span style="color:'+stc+';font-weight:800;min-width:52px">'+stl+'</span>'
13986      +'<span style="margin-left:auto;text-align:right">'+pl+'</span>'+_ahx+'</div>'+(st==='void'&&r.voidWhy?'<div style="color:#fbbf24;font-size:10px;padding:0 4px 6px;line-height:1.3">⚠ '+String(r.voidWhy).replace(/</g,'&lt;')+'</div>':'')+'</div>';
13987  }).join('')+'</div>';
13988  el.innerHTML=h;
13989}
13990// Running list of every idea that surfaced today (kept after it drops off the live top-5).
13991function renderIdeasToday(items, liveKeys){
13992  const el=document.getElementById('ideasToday'); if(!el) return;
13993  if(!items||!items.length){ el.innerHTML=''; return; }
13994  items.sort((a,b)=>b.firstSeen-a.firstSeen);
13995  const live=new Set(liveKeys||[]);
13996  let h='<div style="font-size:12px;font-weight:800;color:#e2e8f0;margin:10px 0 8px;letter-spacing:.4px">TODAY’S IDEAS <span style="color:var(--dim);font-weight:600">('+items.length+')</span> <span style="color:var(--dim);font-weight:400;font-size:11px">— every setup that came in today, kept after it drops off</span></div>';
13997  h+='<div style="border:1px solid var(--border);border-radius:10px;overflow:hidden">';
13998  h+=items.map(function(it,i){ const cs=it.contract||{}, long=it.dir==='LONG', dc=long?'#22c55e':'#ef4444', isLive=live.has(it.key);
13999    return '<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:12px;'+(i?'border-top:1px solid rgba(148,163,184,.08);':'')+(isLive?'background:rgba(34,197,94,.05)':'')+'">'
14000      + '<span style="color:#8fa0b8;width:50px;flex:none;font-variant-numeric:tabular-nums" title="Came in">'+_ideaTime(it.firstSeen)+'</span>'
14001      + '<span style="font-weight:800;color:#e2e8f0;width:50px;flex:none">'+it.ticker+'</span>'
14002      + '<span style="color:'+dc+';font-weight:700;width:48px;flex:none">'+_dirLbl(it.dir)+'</span>'
14003      + '<span style="color:#aeb9cf;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">$'+cs.strike+' '+cs.type+' '+_expShort(cs.expiry)+'</span>'
14004      + '<span style="color:#cbd5e1;width:22px;flex:none;text-align:center;font-weight:800">'+it.grade+'</span>'
14005      + (isLive
14006          ? '<span style="color:#22c55e;font-size:10px;font-weight:800;width:64px;flex:none;text-align:right;letter-spacing:.5px">LIVE</span>'
14007          : '<span style="color:var(--dim);font-size:10px;width:64px;flex:none;text-align:right" title="Last shown">dropped '+_ideaTime(it.lastSeen)+'</span>')
14008    + '</div>';
14009  }).join('')+'</div>';
14010  el.innerHTML=h;
14011}
14012async function clearIdeasLog(ev){ if(ev){ ev.stopPropagation(); ev.preventDefault(); }
14013  if(!confirm('Clear YOUR resolved trade-idea history? This only clears your own view — other members keep theirs. Ideas still being tracked will stay.')) return;
14014  try{ await fetch('/api/trade-ideas/clear?scope=resolved',{method:'POST'}); }catch(e){}
14015  try{ loadIdeasTracker(); }catch(e){}
14016}
14017async function loadIdeasTracker(){
14018  const el=document.getElementById('ideasTracker'); if(!el) return;
14019  try{ const j=await fetch('/api/trade-ideas/history').then(r=>r.json()); renderIdeasTracker(j); }catch(e){}
14020}
14021function _idAgo(ts){ if(!ts) return ''; return new Date(ts).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); }
14022function renderIdeasTracker(j){
14023  const el=document.getElementById('ideasTracker'); if(!el) return;
14024  let s=(j&&j.stats)||{}, open=(j&&j.open)||[], res=(j&&j.resolved)||[];
14025  // Match the panel to the tab you're on: Morning shows morning trades, Evening shows evening
14026  // trades, Live spans both. Every tracked idea carries its `session`, so filter on that — and
14027  // recompute the headline stats for the filtered set, or the win rate would describe a different
14028  // population than the rows underneath it.
14029  const wantSess=(_ideasMode==='live')?null:_ideasSession;
14030  let sessLbl='All sessions';
14031  if(wantSess){
14032    open=open.filter(o=>o.session===wantSess); res=res.filter(o=>o.session===wantSess);
14033    sessLbl=(wantSess==='pm'?'Evening':'Morning')+' only';
14034    // Grade + signal breakdowns still come from the returned rows (the row list is now capped at
14035    // 400, well above the record), but the HEADLINE W-L/win-rate uses the server's per-session
14036    // stats computed over ALL rows — the 60-row slice used to truncate Setups wins once index
14037    // plays flooded the resolved list (user 2026-09-02: page showed 14W-11L, record was 19W-11L).
14038    const dec=res.filter(o=>(o.status==='win'||o.status==='loss')&&!o.abandonedLive);   // abandoned-live rows never count in the record (server already drops them from srv stats)
14039    const byGrade={};
14040    for(const o of dec){ const g=o.grade||'?'; byGrade[g]=byGrade[g]||{w:0,l:0,rate:0}; if(o.status==='win')byGrade[g].w++; else byGrade[g].l++; }
14041    Object.values(byGrade).forEach(v=>{ v.rate=(v.w+v.l)?Math.round(v.w/(v.w+v.l)*100):0; });
14042    const bySig=pred=>{ const gs=dec.filter(o=>o.sig&&pred(o.sig)); const w=gs.filter(o=>o.status==='win').length; return gs.length?{w,n:gs.length,rate:Math.round(w/gs.length*100)}:null; };
14043    const srv=(j&&j.statsBySession&&j.statsBySession[wantSess])||null;
14044    const wins=srv?srv.wins:res.filter(o=>o.status==='win').length, losses=srv?srv.losses:res.filter(o=>o.status==='loss').length;
14045    s={ wins, losses, expired:srv?srv.expired:res.filter(o=>o.status==='expired').length, decided:srv?srv.decided:dec.length, plUsd:srv?srv.plUsd:null, avgPct:srv?srv.avgPct:null,
14046        winRate: srv?srv.winRate:(dec.length?Math.round(wins/dec.length*100):null), byGrade,
14047        signals:{ charmConfirm:bySig(x=>x.charmConfirm===true), charmAgainst:bySig(x=>x.charmConfirm===false), kingAhead:bySig(x=>x.kingAhead===true), kingBehind:bySig(x=>x.kingAhead===false) } };
14048  }
14049  const wr = s.winRate!=null ? s.winRate+'%' : '—';
14050  const wrCol = s.winRate==null?'#94a3b8': s.winRate>=55?'#22c55e': s.winRate>=45?'#eab308':'#ef4444';
14051  let h='<div style="display:flex;align-items:baseline;gap:7px;margin-bottom:10px"><span style="font-size:13px;font-weight:800;color:#e2e8f0">Track Record</span>'
14052    + '<span style="font-size:10px;color:var(--dim);border:1px solid var(--border);border-radius:5px;padding:1px 6px">'+sessLbl+'</span></div>';
14053  h+='<div style="display:flex;align-items:center;gap:12px;background:rgba(148,163,184,.05);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:12px">'
14054    + '<div style="text-align:center"><div style="font-size:26px;font-weight:800;color:'+wrCol+';line-height:1">'+wr+'</div><div style="font-size:9px;color:var(--dim);letter-spacing:1px">WIN RATE</div></div>'
14055    + '<div style="flex:1;font-size:12px;color:#cbd5e1;line-height:1.6"><div><span style="color:#22c55e;font-weight:700">'+(s.wins||0)+'W</span> · <span style="color:#ef4444;font-weight:700">'+(s.losses||0)+'L</span>'+(s.expired?' · <span style="color:var(--dim)">'+s.expired+' exp</span>':'')+'</div><div style="color:var(--dim);font-size:11px">'+(s.decided||0)+' resolved · '+open.length+' open</div></div>'
14056    + _idTotalHtml(s)
14057  +'</div>';
14058  if(s.byGrade && Object.keys(s.byGrade).length){ h+='<div style="display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap">'+Object.entries(s.byGrade).map(function(e){var g=e[0],v=e[1];return '<span style="border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:11px;color:#cbd5e1">'+g+': <b style="color:'+(v.rate>=55?'#22c55e':v.rate>=45?'#eab308':'#ef4444')+'">'+v.rate+'%</b> <span style="color:var(--dim)">('+v.w+'/'+(v.w+v.l)+')</span></span>';}).join('')+'</div>'; }
14059  if(s.signals){ var sg=s.signals; var rowSig=function(lbl,v){ return v?('<div style="display:flex;justify-content:space-between;font-size:11px;color:#cbd5e1;padding:2px 0"><span>'+lbl+'</span><b style="color:'+(v.rate>=55?'#22c55e':v.rate>=45?'#eab308':'#ef4444')+'">'+v.rate+'% <span style="color:var(--dim);font-weight:400">('+v.w+'/'+v.n+')</span></b></div>'):''; };
14060    var sigHtml=[rowSig('Charm confirmed',sg.charmConfirm),rowSig('Charm against',sg.charmAgainst),rowSig('King ahead',sg.kingAhead),rowSig('King behind',sg.kingBehind)].filter(Boolean).join('');
14061    if(sigHtml) h+='<div style="margin-bottom:12px"><div style="font-size:10px;letter-spacing:1px;color:var(--dim);margin-bottom:4px">WHAT\'S WORKING</div>'+sigHtml+'</div>'; }
14062  try{ _ideaCoachLoad(); }catch(e){}   // coach card sits in its own slot above this panel
14063  h+='<div style="font-size:10px;letter-spacing:1px;color:var(--dim);margin:6px 0 6px">TRACKING NOW ('+open.length+')</div>';
14064  if(!open.length) h+='<div style="color:var(--dim);font-size:12px;margin-bottom:10px">'+(wantSess?('Nothing open from the '+(wantSess==='pm'?'evening':'morning')+' session.'):'Nothing open yet — ideas start tracking when the morning/evening snapshots run.')+'</div>';
14065  // Newest day first (then PM before AM within a day) so today's ideas head the list instead of
14066  // being buried under carried-over ones.
14067  else h+=open.slice().sort(function(a,b){
14068    const d=String(b.date||'').localeCompare(String(a.date||'')); if(d) return d;
14069    return (a.session===b.session)?0:(a.session==='pm'?-1:1);
14070  }).map(_idOpenRow).join('');
14071  h+='<div style="display:flex;align-items:center;justify-content:space-between;margin:14px 0 6px">'
14072    + '<span style="font-size:10px;letter-spacing:1px;color:var(--dim)">HISTORY</span>'
14073    + (res.length?'<button onclick="clearIdeasLog(event)" style="border:1px solid var(--border);background:transparent;color:#9fb0c9;border-radius:6px;padding:2px 9px;font-size:10px;cursor:pointer" title="Clear the resolved history log (ideas still being tracked stay)">Clear log</button>':'')
14074  + '</div>';
14075  if(!res.length) h+='<div style="color:var(--dim);font-size:12px">
14075No resolved ideas yet.</div>';
14076  else h+=res.map(_idResRow).join('');
14077  el.innerHTML=h;
14078}
14079// TOTAL P&L tile (user 2026-09-04): net $ per contract across the decided rows in view, plus the
14080// average contract move. Same filtered set as the W-L so the two always agree.
14081function _idTotalHtml(s){
14082  if(!s || s.plUsd==null) return '';
14083  var up=s.plUsd>=0, c=up?'#22c55e':'#ef4444';
14084  return '<div style="text-align:right;white-space:nowrap" title="Net P&L per contract across the '+(s.decided||0)+' decided ideas in view (wins + losses, 1 contract each). Average = mean contract move entry to exit.">'
14085    + '<div style="font-size:20px;font-weight:800;color:'+c+';line-height:1">'+(up?'+$':'-$')+Math.abs(s.plUsd).toLocaleString()+'</div>'
14086    + '<div style="font-size:9px;color:var(--dim);letter-spacing:1px">TOTAL / CONTRACT</div>'
14087    + (s.avgPct!=null?'<div style="font-size:11px;color:'+(s.avgPct>=0?'#22c55e':'#ef4444')+';font-weight:700;margin-top:2px">'+(s.avgPct>=0?'+':'')+s.avgPct+'% <span style="color:var(--dim);font-weight:400">avg</span></div>':'')
14088    + '</div>';
14089}
14090// Open ideas stay tracked for up to 7 days, so the list mixes days. Without the date, yesterday's
14091// morning idea sits next to today's card looking like the same trade (a 07-28 INTC 75P reading as
14092// today's 80P). Label anything not from today.
14093function _idDayLbl(d){
14094  try{ if(d === new Date().toLocaleDateString('en-CA',{timeZone:'America/New_York'})) return 'today'; }catch(e){}
14095  return _expShort(d) || d || '';
14096}
14097function _idOpenRow(o){ const long=o.dir==='LONG', dc=long?'#22c55e':'#ef4444', cs=o.contract||{};
14098  const _ck=(typeof isPaidUser!=='undefined'&&isPaidUser&&o.key)?' onclick="_ideaCoachPick(\''+String(o.key).replace(/'/g,'')+'\')" style="cursor:pointer" title="Show this setup in the Live Read panel"':'';
14099  if(_ck) return '<div'+_ck+'>'+_idOpenRowInner(o,long,dc,cs)+'</div>';
14100  return _idOpenRowInner(o,long,dc,cs);
14101}
14102function _idOpenRowInner(o,long,dc,cs){
14103  const dayL=_idDayLbl(o.date), isOld=(dayL!=='today');
14104  var plHtml='';
14105  if(o.optNow!=null && cs.prem){ var up=(o.plPct||0)>=0, pc=up?'#22c55e':'#ef4444';
14106    plHtml='<div style="display:flex;align-items:center;justify-content:space-between;margin-top:4px;font-size:12px">'
14107      +'<span style="color:#9fb0c9">$'+(+cs.prem).toFixed(2)+' → <b style="color:#e2e8f0">$'+(+o.optNow).toFixed(2)+'</b>'+(o.optEst?' <span style="color:var(--dim);font-size:9px">est</span>':'')
14108      +(o.snapPrem?' <span style="color:var(--dim);font-size:9px" title="The card premium at pick time — the basis was re-priced to the moment the 0.35% entry confirmation hit, so P&L reflects a fill you could actually get">pick $'+(+o.snapPrem).toFixed(2)+'</span>':'')+'</span>'
14109      +'<b style="color:'+pc+'">'+(up?'+':'')+o.plPct+'% <span style="font-weight:400;color:'+pc+'">('+(o.plUsd>=0?'+$':'−$')+Math.abs(o.plUsd).toLocaleString()+')</span></b>'
14110    +'</div>';
14111  }
14112  // Contract peak/dip is what actually matters (what the OPTION did), not the underlying's small % move.
14113  const exc=(o.plPeakPct!=null)
14114    ? '<div style="font-size:10px;margin-top:2px"><span style="color:#22c55e">contract peak +'+o.plPeakPct+'%</span>'+(o.plMinPct!=null?' · <span style="color:#ef4444">dip '+o.plMinPct+'%</span>':'')+'</div>'
14115    : ((o.mfe!=null||o.mae!=null)?'<div style="font-size:10px;margin-top:2px"><span style="color:#22c55e">stock peak +'+(o.mfe||0)+'%</span> · <span style="color:#ef4444">dip '+(o.mae||0)+'%</span></div>':'');
14116  return '<div style="border:1px solid var(--border);border-left:3px solid '+dc+';border-radius:8px;padding:8px 10px;margin-bottom:7px">'
14117  + '<div style="display:flex;align-items:center;gap:6px"><b style="color:#e2e8f0;font-size:13px">'+o.ticker+'</b><span style="color:'+dc+';font-size:10px;font-weight:800">'+_dirLbl(o.dir)+'</span>'
14118    + _gfPendBadge(o)
14119    + '<span style="font-size:10.5px;font-weight:700;'+(isOld?'color:#eab308':'color:#cbd5e1')+'" title="'+(isOld?'Carried over from a previous session — not today\'s idea':'Recorded today')+'">'+(o.session==='am'?'AM':'PM')+' · '+dayL+'</span>'
14120    + '<span style="margin-left:auto;color:#9fb0c9;font-size:11px">'+(cs.strike||'')+(cs.type==='CALL'?'C':'P')+'</span>' + _gfExitBtn(o) + '</div>'
14121  + plHtml
14122  + '<div style="color:#9fb0c9;font-size:11px;margin-top:3px">stock '+(+o.entry).toFixed(2)+' → tgt <span style="color:#22c55e">'+(+o.target).toFixed(2)+'</span> · stop <span style="color:#ef4444">'+(+o.stop).toFixed(2)+'</span> · now '+(o.markPrice!=null?(+o.markPrice).toFixed(2):'—')+'</div>'+_voidReasonHtml(o)+_abandLiveHtml(o)+exc+'</div>'; }
14123function _idResRow(o){ const win=o.status==='win', loss=o.status==='loss'; const col=win?'#22c55e':loss?'#ef4444':'#94a3b8', lbl=win?(o.booked?'BOOK':'WIN'):loss?'LOSS':'EXP';
14124  // Show the CONTRACT's peak/dip (what the option did), not the underlying's small move.
14125  const peak=(o.plPeakPct!=null)?o.plPeakPct:null, dip=(o.plMinPct!=null)?o.plMinPct:null;
14126  let exc='', ecol='var(--dim)';
14127  // Sign the number rather than hardcoding '+' — a negative peak was rendering as "+-10%".
14128  const _pc=v=>(v>=0?'+':'')+v+'%';
14129  if(peak!=null && dip!=null){ exc='contract peak <b style="color:'+(peak>=0?'#22c55e':'#ef4444')+'">'+_pc(peak)+'</b> · dip <b style="color:#ef4444">'+_pc(dip)+'</b>'; }
14130  else if(loss && o.mfe!=null && o.mfe>0.2){ exc='stock was up +'+o.mfe+'% before stopping'; ecol='#22c55e'; }
14131  else if(win && o.mae!=null && o.mae<-0.2){ exc='stock down '+o.mae+'% before target'; ecol='#ef4444'; }
14132  // Headline % = what the CONTRACT actually did, entry → exit. It used to show the contract PEAK on
14133  // a win, which then sat directly next to an entry → exit pair saying something completely
14134  // different ("WIN -10%" above "$6.50 → $5.50", which is -15%). One trade, one number.
14135  const _entryPrem=(o.contract&&o.contract.prem)||0;
14136  const headPct = (_entryPrem>0 && o.exitPrem!=null) ? Math.round((o.exitPrem/_entryPrem-1)*100)
14137                : (o.plPct!=null ? o.plPct : (peak!=null?peak:null));
14138  return '<div style="padding:6px 0;border-bottom:1px solid rgba(148,163,184,.06)">'
14139    + '<div style="display:flex;align-items:center;gap:8px">'
14140      + '<span style="width:42px;text-align:center;background:'+(win?'rgba(34,197,94,.15)':loss?'rgba(239,68,68,.15)':'rgba(148,163,184,.12)')+';color:'+col+';border-radius:5px;font-size:10px;font-weight:800;padding:2px 0" title="'+(o.booked?'Booked — the contract hit a big profit':(win?'Underlying reached target':loss?(o.optStopped?'Contract lost most of its value':'Underlying hit stop'):'Expired without hitting target'))+'">'+lbl+'</span>'
14141      + '<span style="color:#e2e8f0;font-size:12px;font-weight:700">'+o.ticker+'</span><span style="color:'+(o.dir==='LONG'?'#22c55e':'#ef4444')+';font-size:10px;font-weight:700">'+_dirLbl(o.dir)+'</span>'
14142      + (headPct!=null?'<span style="color:'+((headPct>=0)?'#22c55e':'#ef4444')+';font-size:11px;font-weight:700" title="contract P/L, entry → exit">'+(headPct>=0?'+':'')+headPct+'%</span>':'')
14143      // The date that matters is when the idea was GIVEN, not when it happened to close — that's what
14144      // you match against the card you acted on. Close time moves to the tooltip.
14145      + '<span style="margin-left:auto;color:#e2e8f0;font-size:10.5px;font-weight:700;text-align:right;white-space:nowrap" title="Given '+(o.date||'')+' '+(o.session==='am'?'morning':'evening')+' · closed '+_idAgo(o.resolved)+'">'+(o.session==='am'?'AM':'PM')+' · '+_idDayLbl(o.date)+'</span></div>'
14146    // WHICH contract it was, and what it went in and out at — a % on its own doesn't tell you the trade.
14147    + (function(){ const c=o.contract||{}; if(c.strike==null) return '';
14148        const cc=c.type==='CALL'?'#22c55e':'#ef4444';
14149        const exitTip = o.exitBasis==='peak' ? 'Exited at the contract peak — that gain is what booked the win'
14150                      : o.exitBasis==='stop' ? 'Exited at the contract low that triggered the stop'
14151                      : 'Last mark seen before it resolved';
14152        const ex = (o.exitPrem!=null)
14153          ? (' <span style="color:var(--dim)">&rarr;</span> <b style="color:'+((o.exitPrem>=c.prem)?'#22c55e':'#ef4444')+'" title="'+exitTip+'">$'+(+o.exitPrem).toFixed(2)+'</b>')
14154          : '';
14155        // PER-TRADE $ (user 2026-09-04): entry -> exit = profit, one contract. Stored plUsd at
14156        // resolution; older rows fall back to (exit - entry) x 100.
14157        const usd = (o.plUsd!=null) ? +o.plUsd : ((o.exitPrem!=null && c.prem>0) ? Math.round((o.exitPrem-c.prem)*100) : null);
14158        const pl = (usd!=null) ? (' <span style="color:var(--dim)">=</span> <b style="color:'+(usd>=0?'#22c55e':'#ef4444')+'" title="P&L per contract, entry to exit">'+(usd>=0?'+$':'-$')+Math.abs(usd).toLocaleString()+'</b>') : '';
14159        return '<div style="font-size:10.5px;margin:2px 0 0 50px;color:#9fb0c9">'
14160          + '<b style="color:'+cc+'">$'+c.strike+(c.type==='CALL'?'C':'P')+'</b> '+_expShort(c.expiry)
14161          + ' <span style="color:var(--dim)">·</span> $'+(+c.prem).toFixed(2)+ex+pl
14162        + '</div>'; })()
14163    + (exc?'<div style="color:'+ecol+';font-size:10px;margin:2px 0 0 50px">'+exc+'</div>':'')
14164    + (o.abandonedLive?'<div style="margin:2px 0 0 50px">'+_abandLiveHtml(o)+'</div>':'')
14165    + _idRunnerLine(o)
14166    + '</div>'; }
14167// RUNNER line — the simulated 1-contract that keeps riding after the result was booked.
14168// WIN runner: rides with a breakeven stop until expiry ("how high does it actually go?").
14169// LOSS runner: watches until expiry whether the trade would EVER have reached the target.
14170function _idRunnerLine(o){
14171  const R=o.runner; if(!R) return '';
14172  const _pc=v=>((v>=0?'+':'')+Math.round(v)+'%');
14173  if(R.on){
14174    const cur=(R.lastPct!=null)?R.lastPct:R.startPct, up=cur>=0;
14175    return '<div style="font-size:10px;margin:2px 0 0 50px">'
14176      + '<span style="background:rgba(6,182,212,.15);color:var(--cyan);border-radius:4px;padding:1px 6px;font-weight:800" title="'
14177      + (R.kind==='win'?'One simulated contract kept riding after the booked win — breakeven stop, runs to expiry':'Watching until expiry whether this would ever have reached the target — the loss is already recorded')
14178      + '">RUNNER</span> <b style="color:'+(up?'#22c55e':'#ef4444')+'">'+_pc(cur)+'</b>'
14179      + ' <span style="color:var(--dim)">peak '+_pc(R.peakPct)+' · '+(R.kind==='win'?'BE stop armed':'would it have hit target?')+'</span></div>';
14180  }
14181  const tag = R.end==='be' ? 'runner BE-stopped at entry price' :
14182              R.end==='target' ? 'runner: it DID reach the target later' :
14183              'runner expired at '+_pc(R.endPct!=null?R.endPct:R.lastPct);
14184  const col = R.end==='target' ? '#f59e0b' : 'var(--dim)';
14185  return '<div style="font-size:10px;margin:2px 0 0 50px;color:'+col+'" title="Simulated post-result contract — analytics only, never counted in the record">'
14186    + tag+' <span style="color:var(--dim)">(peak '+_pc(R.peakPct)+')</span></div>';
14187}
14188function _ideaNum(v){ if(v==null||!isFinite(v)) return '—'; return (+v).toLocaleString(undefined,{maximumFractionDigits:2}); }
14189function _dirLbl(d){ return d==='LONG'?'CALLS':'PUTS'; }   // LONG=buy calls, SHORT=buy puts
14190function _etsCell(label,val,col){ return '<div><div style="color:var(--dim);font-size:9px;letter-spacing:1px;margin-bottom:3px">'+label+'</div><div style="font-size:18px;font-weight:800;color:'+col+'">'+val+'</div></div>'; }
14191function _etsCell2(label,val,col,fw){ return '<div style="flex:'+(fw||1)+';min-width:0"><div style="color:var(--dim);font-size:9px;letter-spacing:1px;margin-bottom:4px">'+label+'</div><div style="font-size:15px;font-weight:800;color:'+col+';white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+val+'</div></div>'; }
14192// Estimated option premium if the stock reaches the target — the same 0.5-delta leverage estimate
14193// the earnings engine and outcome tracker use (option gains ≈ half the stock's move via spot/prem
14194// leverage, floor 3x, runaway cap 8x). Computed from the idea's own frozen numbers so it works on
14195// old snapshots too. An estimate, not a quote — decay and IV move the real premium.
14196function _ideaPremAtTarget(it){
14197  const c=(it&&it.contract)||{};
14198  if(!(c.prem>0)||!(it.target>0)) return null;
14199  const ref=(it.dir==='LONG'?(it.entryHi||it.spot):(it.entryLo||it.spot));
14200  if(!(ref>0)) return null;
14201  const mf=Math.abs(it.target-ref)/ref;
14202  const lev=Math.max(3,0.5*((it.spot||ref)/c.prem));
14203  const p=Math.min(c.prem*(1+mf*lev),c.prem*8);
14204  return { prem:+p.toFixed(2), pct:Math.round((p/c.prem-1)*100) };
14205}
14206function _expShort(e){ if(!e) return '';
14206 const p=String(e).split('-'); if(p.length===3){ const mo=['','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][+p[1]]||p[1]; return mo+' '+(+p[2]); } return e; }
14207function _esc2(s){ return String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
14208function _ideaTime(ms){ try{ return new Date(ms).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); }catch(e){ return ''; } }
14209function renderTradeIdeas(j){
14210  const body=document.getElementById('ideasBody'); if(!body) return;
14211  const ideas=(j&&j.ideas)||[];
14212  const st=document.getElementById('ideasStamp');
14213  if(st){
14214    if(_ideasSession==='ah'){ const g=(j&&j.built)?new Date(j.built):null; st.textContent='After-hours scan for tomorrow'+(g?' · '+g.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}):'')+' · whole universe, ETFs included'; }
14215    else if(j&&j.snapshot){ const g=j.generated?new Date(j.generated):null; st.textContent=(_ideasSession==='am'?'Morning snapshot':'Afternoon snapshot (into the close)')+(g?' · '+g.toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):''); }
14216    else { const d=new Date(); st.textContent='Live · updated '+d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); }
14217  }
14218  const sub=document.getElementById('ideasSub'); if(sub) sub.textContent=(_ideasSession==='pm'?"Afternoon — captured 15 min before the close to enter into the close / hold overnight":'Morning — held setup for today · flow · GEX · momentum');
14219  if(!ideas.length){ body.innerHTML='<div class="col-loading">No high-conviction setups right now — flow is thin. Check back during market hours.</div>'; return; }
14220  // The held snapshot carries over from the LAST session that fired, so on the evening tab before
14221  // 12:40 PM PT you're looking at yesterday's set. Say so, and date it, instead of passing it off
14222  // as today's.
14223  let staleHdr='';
14224  if(j&&j.snapshot&&j.snapDate){
14225    const today=new Date().toLocaleDateString('en-CA');
14226    if(j.snapDate!==today){
14227      const fireLbl=_ideasSession==='pm'?'12:40 PM PT':'6:45 AM PT';
14228      staleHdr='<div style="background:rgba(234,179,8,.07);border:1px solid rgba(234,179,8,.28);border-radius:10px;padding:11px 14px;margin-bottom:14px">'
14229        + '<div style="font-size:12.5px;font-weight:800;color:#eab308;margin-bottom:2px">Today\'s '+(_ideasSession==='pm'?'evening':'morning')+' ideas update at '+fireLbl+'</div>'
14230        + '<div style="font-size:11.5px;color:#9fb0c9">Showing the last set below — from <b style="color:#cbd5e1">'+_earnDayLbl(j.snapDate)+'</b>. These are dated, not live for today.</div>'
14231      + '</div>'
14232      + '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:10px"><span style="font-size:12px;font-weight:800;color:#94a3b8;letter-spacing:.4px">PREVIOUS SESSION</span><span style="font-size:11px;color:var(--dim)">'+_earnDayLbl(j.snapDate)+'</span></div>';
14233    }
14234  }
14235  const gradeCol={A:'#22c55e',B:'#38bdf8',C:'#eab308',D:'#94a3b8'};
14236  body.innerHTML=staleHdr+'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(430px,1fr));gap:16px;max-width:1480px">'
14237    + ideas.map((it,i)=>{ const long=it.dir==='LONG', gc=gradeCol[it.grade]||'#94a3b8', dc=long?'#22c55e':'#ef4444', barW=Math.max(4,Math.min(100,it.score)), cs=it.contract||{}, cat=(it.catalysts||[]).slice(0,3);
14238      const entryStr = long ? (_ideaNum(it.entryLo)+(it.entryHi&&it.entryHi!==it.entryLo?'–'+_ideaNum(it.entryHi):'')) : (_ideaNum(it.entryLo)+'–'+_ideaNum(it.entryHi));
14239      const tp=_ideaPremAtTarget(it);
14240      const targetVal=_ideaNum(it.target)+(tp?'<div style="font-size:10.5px;font-weight:700;color:#5ef08a;margin-top:2px" title="Estimated option premium if the stock reaches the target (0.5-delta leverage estimate — decay and IV move the real number)">≈$'+tp.prem.toFixed(2)+' <span style="opacity:.8">(+'+tp.pct+'%)</span></div>':'');
14241      // Card click → live read on the side panel (admin): key matches recordIdeasForTracking.
14242      const _rkSess=(_ideasSession==='ah'?'ah':_ideasSession==='pm'?'pm':'am');   // key matches the SESSION shown, not always 'am' (user 2026-09-15: AH cards updated the morning read)
14243      const _rkDate=(_ideasSession==='ah')?(j&&(j.forDay||j.date)):(j&&j.snapDate);
14244      const _rk=(typeof isPaidUser!=='undefined'&&isPaidUser&&_rkDate)?(_rkSess+'|'+_rkDate+'|'+it.ticker):null;
14245      const _rkAttr=_rk?' onclick="_ideaCoachPick(\''+_rk.replace(/'/g,'')+'\')" title="Show the live read for this setup in the right panel"':'';
14246      return '<div'+_rkAttr+' style="background:var(--card);border:1px solid var(--border);border-left:3px solid '+dc+';border-radius:12px;overflow:hidden;font-family:inherit'+(_rk?';cursor:pointer':'')+'">'
14247        + '<div style="display:flex;align-items:center;gap:10px;padding:14px 16px 12px;border-bottom:1px solid rgba(148,163,184,.10)">'
14248          + '<span style="width:26px;height:26px;border-radius:7px;background:rgba(6,182,212,.15);color:var(--cyan);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px">'+(i+1)+'</span>'
14249          + '<span style="font-size:20px;font-weight:800;color:#e2e8f0">'+it.ticker+'</span>'
14250          + '<span style="background:'+(long?'rgba(34,197,94,.15)':'rgba(239,68,68,.15)')+';color:'+dc+';border-radius:6px;padding:3px 10px;font-size:11px;font-weight:800;letter-spacing:.5px">'+_dirLbl(it.dir)+'</span>'
14251          + '<span style="color:var(--dim);font-size:12px">$'+_ideaNum(it.spot)+'</span>'
14252          + ((!(j&&j.snapshot) && it.firstSeen) ? '<span style="color:#8fa0b8;font-size:11px;
14252white-space:nowrap" title="When this setup first came in today">@ '+_ideaTime(it.firstSeen)+'</span>' : '')
14253          + (it.build ? '<span style="color:#fbbf24;font-size:10px;font-weight:800;border:1px solid rgba(251,191,36,.5);border-radius:4px;padding:1px 6px;white-space:nowrap" title="Added by the '+it.build+' AM PT rebuild. Tracked and managed like the 6:45 list, but portal only: not posted to Discord until the rebuilds prove out.">'+it.build+' REBUILD</span>' : '')
14254          // A blocked pick used to render identically to a tracked one - same score, entry, target,
14255          // contract and catalysts - with nothing saying it would never enter the tracker. Three of
14256          // five went unnoticed that way. The server sets notTracked with the reason.
14257          + (it.notTracked ? '<span style="background:rgba(245,158,11,.14);color:#fbbf24;border:1px solid rgba(245,158,11,.45);border-radius:6px;padding:3px 9px;font-size:10.5px;font-weight:800;white-space:nowrap" title="Not in the tracker: one open position per ticker. It will not appear under Tracking Now and no entry is recorded for it.">NOT TRACKED · '+_esc2(it.notTracked)+'</span>' : '')
14258          + '<span style="margin-left:auto;text-align:right"><div style="font-size:22px;font-weight:800;color:'+gc+';line-height:1">'+it.score+'</div><div style="font-size:10px;color:var(--dim);letter-spacing:1px">GRADE '+it.grade+'</div></span>'
14259        + '</div>'
14260        + '<div style="display:flex;padding:12px 16px">'
14261          + _etsCell2('ENTRY', entryStr, '#e2e8f0', 1.7)
14262          + '<div style="width:1px;background:rgba(148,163,184,.14);margin:0 9px"></div>'+_etsCell2('TARGET', targetVal, '#22c55e', 1)
14263          + '<div style="width:1px;background:rgba(148,163,184,.14);margin:0 9px"></div>'+_etsCell2('STOP', _ideaNum(it.stop), '#ef4444', 1)
14264          + '<div style="width:1px;background:rgba(148,163,184,.14);margin:0 9px"></div>'+_etsCell2('R:R', it.rr+':1', '#38bdf8', 0.9)
14265        + '</div>'
14266        + '<div style="margin:0 16px 12px;padding:11px 13px;background:rgba(6,182,212,.06);border:1px solid rgba(6,182,212,.18);border-radius:9px">'
14267          + '<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">'
14268            + '<span style="color:#e2e8f0;font-size:14px;font-weight:700">'+it.ticker+' $'+cs.strike+' '+cs.type+' · '+_expShort(cs.expiry)+(cs.dte!=null?' ('+cs.dte+'DTE)':'')+'</span>'
14269            + '<span style="background:rgba(34,197,94,.15);color:#22c55e;border-radius:6px;padding:3px 10px;font-size:12px;font-weight:800;white-space:nowrap">$'+_ideaNum(cs.lot)+' / contract</span>'
14270          + '</div>'
14271          + '<div style="margin-top:6px;display:flex;align-items:center;justify-content:space-between;gap:8px">'
14272            + '<span style="color:#aeb9cf;font-size:12px">Entry premium ~$'+_ideaNum(cs.prem)
14273              // IV is what decides whether the premium survives being right — colour it so a rich
14274              // contract reads as rich at a glance. Ideas are capped at 100% server-side.
14275              + (cs.iv?'  ·  IV <b style="color:'+(cs.iv>=85?'#ef4444':cs.iv>=60?'#eab308':'#22c55e')+'" title="'+(cs.iv>=85?'Rich — vol crush can eat the premium even if direction is right':cs.iv>=60?'Elevated':'Calm')+'">'+cs.iv+'%</b>':'')+'</span>'
14276            + '<button class="flow-trk" onclick="event.stopPropagation();trackFlow(this)" data-ticker="'+it.ticker+'" data-strike="'+cs.strike+'" data-expiry="'+cs.expiry+'" data-type="'+(cs.type==='CALL'?'C':'P')+'" data-side="buy" data-entry="'+cs.prem+'" title="Add this contract to Tracking">+ Track</button>'
14277          + '</div>'
14278        + '</div>'
14279        + '<div style="padding:0 16px 12px"><div style="height:6px;border-radius:4px;background:rgba(148,163,184,.12);overflow:hidden"><div style="width:'+barW+'%;height:100%;background:'+gc+'"></div></div></div>'
14280        + '<div style="padding:0 16px 12px;color:#cbd5e1;font-size:12.5px;line-height:1.55">'+_esc2(it.rationale)+'</div>'
14281        + (cat.length?'<div style="padding:0 16px 14px"><div style="color:var(--dim);font-size:10px;letter-spacing:1px;margin-bottom:4px">CATALYSTS</div>'+cat.map(c=>'<div style="color:#dbe4ee;font-size:12px;line-height:1.5;margin-bottom:2px">• '+_esc2(c)+'</div>').join('')+'</div>':'')
14282        + '</div>';
14283    }).join('')
14284    + '</div>';
14285}
14286function setView(target) {
14287  // Consolidated new layout: Ultra is retired — its full workstation now lives in the Chart
14288  // view. Any request for 'ultra' (button, saved state) redirects to 'chart'.
14289  if (target === 'ultra' && (typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT)) target = 'chart';
14290  // Trade Ideas is admin-only while we test it — anyone else (incl. a saved 'ideas' view) → Grid.
14291  if (target === 'ideas' && !(typeof isPaidUser !== 'undefined' && isPaidUser) && !IS_DEMO) target = 'gex';   // demo: opens LOCKED (demoLockView)
14292  // Smart Money is premium, same rule as Ideas (isPaidUser already includes admins).
14293  if (target === 'smart' && !(typeof isPaidUser !== 'undefined' && isPaidUser) && !IS_DEMO) target = 'gex';   // premium (isPaidUser already includes admins); demo opens LOCKED
14294  // Chain is premium, same rule - it spends a real UW call per expiry opened.
14295  if (target === 'chain' && !(typeof isPaidUser !== 'undefined' && isPaidUser)) target = 'gex';
14296  // Flow tab is gated: admins (full/live) or the public demo (SPY/TSLA, delayed). Others → Grid.
14297  if (target === 'flow' && !(typeof _flowEnabled === 'function' && _flowEnabled())) target = 'gex';
14298  // LEAVING THE WORKSPACE PUTS EVERY PARKED VIEW HOME FIRST. The sweep below hides views by id and
14299  // the target branch then shows its own - both would be operating on nodes still sitting inside a
14300  // tile, so the mode you switched to would come up empty. Restore, then carry on as normal.
14301  if (currentView === 'workspace' && target !== 'workspace') { try { gfWsClose(); } catch (e) {} }
14302  // Workspace is admin-only while it is being finished, exactly as Ghost Desk was.
14303  if (target === 'workspace' && !((typeof isAdminUser !== 'undefined' && isAdminUser) || (typeof isHqUser !== 'undefined' && isHqUser))) target = 'gex';
14304  // PER-VIEW settings swap (admin test, user 2026-08-29): metric/expiry/bars/tint belong to
14305  // the view unless view-sync is ON - bank the outgoing view's values, load the incoming's.
14306  const _vsPrev = currentView;
14307  currentView = target;
14308  try {
14309    if (typeof _vsPerView === 'function' && _vsPerView() && _vsPrev !== target) {
14310      _vsBank(_vsKey(_vsPrev));
14311      if (_vsApply(_vsKey(target))) {
14312        try { _syncGridExpiryButtons(); } catch (e) {}
14313        try { _syncGridStyleButtons(); } catch (e) {}
14314        try { document.querySelectorAll('.metric-btn').forEach(b => b.classList.toggle('active', b.dataset.metric === currentMetric)); } catch (e) {}
14315        try {
14316          if (target === 'gex') activeColumns.forEach((t, i) => setTimeout(() => loadGEXSlot(i), i * 60));
14317          else if (target === 'expiry' && typeof expiryTicker !== 'undefined' && expiryTicker) loadExpiryGEX(expiryTicker, true);
14318          else if (target === 'chart' && typeof chartTicker !== 'undefined' && chartTicker) loadGEXChart(chartTicker);
14319        } catch (e) {}
14320      }
14321    }
14322  } catch (e) {}
14323  try { sessionStorage.setItem('gexView', target); localStorage.setItem('gexView', target); } catch (e) {}
14324
14325  // Hide every view
14326  document.getElementById('columns').style.display = 'none';
14327  document.getElementById('expiryView').style.display = 'none';
14328  document.getElementById('chartView').style.display = 'none';
14329  document.getElementById('ultraView').style.display = 'none';
14330  const _sv = document.getElementById('scannerView'); if (_sv) _sv.style.display = 'none';
14331  const _iv = document.getElementById('indicatorsView'); if (_iv) _iv.style.display = 'none';
14332  const _tv = document.getElementById('trackingView'); if (_tv) _tv.style.display = 'none';
14333  const _fv = document.getElementById('flowView'); if (_fv) _fv.style.display = 'none';
14334  const _iv2 = document.getElementById('ideasView'); if (_iv2) _iv2.style.display = 'none';
14335  const _smv = document.getElementById('smartView'); if (_smv) _smv.style.display = 'none';
14336  const _chv = document.getElementById('chainView'); if (_chv) _chv.style.display = 'none';
14337  const _wsv = document.getElementById('wsView'); if (_wsv && target !== 'workspace') _wsv.style.display = 'none';
14338  const _scv = document.getElementById('scannerView'); if (_scv && target !== 'scanner' && target !== 'workspace') _scv.style.display = 'none';
14339  if (typeof stopFlowFeed === 'function') stopFlowFeed(); // stop the demo poll when leaving Flow
14340  if (typeof stopDarkpoolPolling === 'function') stopDarkpoolPolling(); // stop darkpool poll when leaving Flow (restarts on return if it's the active tab)
14341  // Node-alert polling keeps running in the background (startNodeWatcher) so alerts are captured all day, on any view.
14342  if (typeof stopTrackingRefresh === 'function') stopTrackingRefresh(); // pause tracking re-pricing when not on Tracking
14343  document.body.style.overflow = ''; // restore page scroll; chart view re-locks it below
14344  // Sector tabs are shown by default; the chart view hides them for more vertical space.
14345  // GEX view now navigates via its own sector-grouped ticker line, so the top sector
14346  // tabs are hidden there (kept for the other views). The chart view hides them too.
14347  const _tabsBar = document.getElementById('tabs');
14348  if (_tabsBar) _tabsBar.style.display = (target === 'gex') ? 'none' : '';
14349  // GEX ticker line (sector-grouped) shows only in the GEX view.
14350  const _gexTk = document.getElementById('gexTickers');
14351  if (_gexTk) { _gexTk.style.display = (target === 'gex') ? 'flex' : 'none'; if (target === 'gex') buildGexTickers(); }
14352  // Expiry-basis toggle lives with the GEX grid only.
14353  const _gxExp = document.getElementById('gridExpiryBar');
14354  if (_gxExp) _gxExp.style.display = (target === 'gex') ? 'flex' : 'none';
14355  if (typeof _syncGridExpiryButtons === 'function') _syncGridExpiryButtons();
14356
14357  try { document.body.setAttribute('data-view', target); } catch (e) {}   // header-level Ghost Mode controls (#gfHdrChartCtl) show only on the chart view
14358  // Highlight only the active button
14359  document.querySelectorAll('.view-toggle[id^="btn-"]').forEach(b => {
14360    b.classList.toggle('active', b.id.slice(4) === target);
14361  });
14362
14363  // Free trial ended → cover premium views with an upgrade gate instead of data errors.
14364  // (GEX columns + Indicators stay accessible; GEX just renders blurred/locked.)
14365  showTrialGate(freeTrialOver() && (target === 'expiry' || target === 'chart' || target === 'ultra' || target === 'scanner'));
14366
14367  // Show + load the target view
14368  if (target === 'gex') {
14369    document.getElementById('columns').style.display = 'flex';
14370    // Fit the columns row to the viewport so its bottom horizontal scrollbar is on-screen;
14371    // lock page scroll on desktop/tablet (phones keep their swipe + page behavior).
14372    if (window.innerWidth > 640) document.body.style.overflow = 'hidden';
14373    fitColumnsHeight();
14374    requestAnimationFrame(fitColumnsHeight);
14375  } else if (target === 'expiry') {
14376    document.getElementById('expiryView').style.display = 'flex';
14377    document.body.style.overflow = 'hidden';
14378    if (_tabsBar) _tabsBar.style.display = 'none'; // drop sector tabs for more space
14379    if (!expiryTicker) expiryTicker = defaultViewTicker();
14380    buildExpiryTickers();
14381    if (expiryTicker && !freeTrialOver()) loadExpiryGEX(expiryTicker);
14382  } else if (target === 'chart') {
14383    document.getElementById('chartView').style.display = 'block';
14384    try { _ufApplyW(); } catch (e) {}
14385    // Lock body scroll so the fixed-height chart can't scroll up under the sticky tabs
14386    // (which hid the chart's bottom/time axis). Restored when leaving the chart view.
14387    document.body.style.overflow = 'hidden';
14388    if (_tabsBar) _tabsBar.style.display = 'none'; // drop sector tabs for more chart space
14389    fitChartViewHeight(); // size the chart to the real remaining space (no page scroll)
14390    // Restore this tab's own ticker: if the window is pinned (unlinked), prefer its saved ticker.
14391    if (!chartTicker) { const _pin = (typeof nfPinnedTicker === 'function') ? nfPinnedTicker() : ''; chartTicker = ((!tvSyncLinked && _pin) ? _pin : defaultViewTicker()); }
14392    if (typeof nfUpdateTvSyncBtn === 'function') nfUpdateTvSyncBtn();
14393    buildChartTickers();
14394    requestAnimationFrame(fitChartViewHeight); // re-fit after the ticker bar renders
14395    if (typeof nfPlaceChart === 'function') nfPlaceChart('chart'); // new-layout: bring the chart back home
14396    if (chartTicker && !freeTrialOver()) loadGEXChart(chartTicker);
14397    /* multichart apply paused — single chart for everyone; see _mLayout note */
14398  } else if (target === 'workspace') {
14399    document.body.style.overflow = 'hidden';
14400    if (_tabsBar) _tabsBar.style.display = 'none';
14401    // gfWsOpen re-shows the parked nodes, which the sweep above just hid by id.
14402    try { gfWsOpen(); } catch (e) {}
14403  } else if (target === 'tracking') {
14404    document.getElementById('trackingView').style.display = 'flex';
14405    document.body.style.overflow = 'hidden';
14406    if (_tabsBar) _tabsBar.style.display = 'none';
14407    if (typeof loadTracking === 'function') loadTracking();
14408    if (typeof startTrackingRefresh === 'function') startTrackingRefresh(); // keep open-position prices live
14409  } else if (target === 'flow') {
14410    document.getElementById('flowView').style.display = 'flex';
14411    document.body.style.overflow = 'hidden';
14412    if (_tabsBar) _tabsBar.style.display = 'none';
14413    if (typeof _renderFlowTickerChip === 'function') _renderFlowTickerChip();
14414    if (typeof startFlowFeed === 'function') startFlowFeed();
14415    if (typeof flowPreset !== 'undefined' && flowPreset === 'darkpool' && typeof startDarkpoolPolling === 'function') startDarkpoolPolling(); // resume darkpool if it was the active tab
14416  } else if (target === 'ultra') {
14417    document.getElementById('ultraView').style.display = 'flex';
14418    document.body.style.overflow = 'hidden';
14419    if (_tabsBar) _tabsBar.style.display = 'none'; // drop sector tabs for more space
14420    if (!ultraTicker) ultraTicker = defaultViewTicker();   // independent per-view ticker
14421    buildUltraTickers();
14422    if (typeof nfPlaceChart === 'function') nfPlaceChart('ultra'); // new-layout: host the chart in Ultra
14423    if (ultraTicker && !freeTrialOver()) loadUltraView(ultraTicker);
14424    // Rest on GEX + the heatmap peeking beside it (scrollLeft 0). On phones, do a one-time
14425    // "peek" nudge — scroll right a touch and back — so it's obvious the panels (Heatmap →
14426    // Flow → News) swipe sideways.
14427    requestAnimationFrame(() => {
14428      const _uc = document.querySelector('#ultraView > div:last-child');
14429      if (!_uc) return;
14430      _uc.scrollLeft = 0;
14431      if (window.matchMedia && window.matchMedia('(max-width: 640px)').matches && !_ultraSwipeHinted) {
14432        _ultraSwipeHinted = true;
14433        setTimeout(() => {
14434          try {
14435            _uc.scrollTo({ left: Math.round(window.innerWidth * 0.34), behavior: 'smooth' });
14436            setTimeout(() => { try { _uc.scrollTo({ left: 0, behavior: 'smooth' }); } catch (e) {} }, 700);
14437          } catch (e) {}
14438        }, 500);
14439      }
14440    });
14441  } else if (target === 'scanner') {
14442    const sv = document.getElementById('scannerView');
14443    if (sv) sv.style.display = 'flex';
14444    document.body.style.overflow = 'hidden';
14445    if (_tabsBar) _tabsBar.style.display = 'none'; // sector tabs don't apply here
14446    if (!freeTrialOver()) loadScanner();
14447    // Restore the Movers/Node-Alerts sub-tab (Node Alerts is admin-only).
14448    if (typeof setScannerTab === 'function') setScannerTab(isLoggedInUser ? scannerTab : 'movers');
14449  } else if (target === 'indicators') {
14450    const iv = document.getElementById('indicatorsView');
14451    if (iv) iv.style.display = 'flex';
14452    document.body.style.overflow = 'hidden'; // view scrolls internally
14453    if (_tabsBar) _tabsBar.style.display = 'none'; // sector tabs don't apply here
14454    buildIndicatorsView();
14455  } else if (target === 'chain') {
14456    const chv = document.getElementById('chainView');
14457    if (chv) chv.style.display = 'flex';
14458    document.body.style.overflow = 'hidden';
14459    if (_tabsBar) _tabsBar.style.display = 'none';
14460    try { loadChain(); } catch (e) {}
14461    try { chainPollStart(); } catch (e) {}
14462  } else if (target === 'smart') {
14463    const smv = document.getElementById('smartView');
14464    if (smv) smv.style.display = 'flex';
14465    document.body.style.overflow = 'hidden';
14466    if (_tabsBar) _tabsBar.style.display = 'none';
14467    // First entry paints the tab chrome and pulls the active feed; later entries reuse the cache.
14468    if (IS_DEMO && !(typeof isPaidUser !== 'undefined' && isPaidUser)) { try { demoLockView('smart'); } catch (e) {} }
14469    else if (typeof setSmartTab === 'function') setSmartTab(typeof smartTab === 'string' ? smartTab : 'politicians');
14470  } else if (target === 'ideas') {
14471    const iv2 = document.getElementById('ideasView');
14472    if (iv2) iv2.style.display = 'flex';
14473    document.body.style.overflow = 'hidden';
14474    if (_tabsBar) _tabsBar.style.display = 'none';
14475    if (typeof _ideasTabSync === 'function') _ideasTabSync();
14476    if (IS_DEMO && !(typeof isPaidUser !== 'undefined' && isPaidUser)) { try { demoLockView('ideas'); } catch (e) {} }
14477    // Route through setIdeasTab rather than a second, shorter dispatch. This one only knew
14478    // earnings / watchlist / index and sent everything else to loadTradeIdeas, so reopening Ideas
14479    // on G-Flip, EOD, Whales, All Trades or Insider quietly loaded Setups' data under the other
14480    // tab's header. setIdeasTab carries the admin gate and the right loader for all of them.
14481    else if (!freeTrialOver()) {
14482      const _wantTab = window._bootIdeasTab || _ideasTab;
14483      if (typeof setIdeasTab === 'function') setIdeasTab(_wantTab);
14484      else if (typeof loadTradeIdeas === 'function') loadTradeIdeas();
14485    }
14486  }
14487
14488  // Demo: keep the per-view help card in sync with the active view.
14489  if (IS_DEMO) { try { updateDemoGuide(target); } catch (e) {} }
14490
14491  // Phone: the view tabs live in a horizontally-scrollable toolbar, so the one you just
14492  // tapped can end up off-screen. Scroll it back into the center so the active view is
14493  // always visible (pairs with the sticky header so the tabs are reachable everywhere).
14494  if (window.innerWidth <= 640) {
14495    try {
14496      const ab = document.getElementById('btn-' + target);
14497      if (ab && ab.scrollIntoView) ab.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
14498    } catch (e) {}
14499  }
14500}
14501// Back-compat alias
14502function toggleView(target) { setView(target); }
14503
14504// Render the Indicators view — free indicators open to all, premium locked unless paid.
14505function buildIndicatorsView() {
14506  const wrap = document.getElementById('indicatorsBody');
14507  if (!wrap) return;
14508  const FREE_URL = 'https://www.tradingview.com/u/arvinder_singh158/#published-scripts';
14509  const PREMIUM_URL = 'https://www.tradingview.com/v/YnzkRbF6/';
14510  const freeBadge = 'background:rgba(34,197,94,0.12);color:#22c55e;border:1px solid rgba(34,197,94,0.3)';
14511  const premBadge = 'background:rgba(234,179,8,0.1);color:#eab308;border:1px solid rgba(234,179,8,0.25)';
14512  const card = (title, badge, badgeStyle, desc, cta, note) => `
14513    <div style="background:var(--card,#111827);border:1px solid var(--border,#1e293b);border-radius:12px;padding:18px;display:flex;flex-direction:column;gap:10px">
14514      <div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
14515        <span style="font-size:15px;font-weight:800;color:#e2e8f0">${title}</span>
14516        <span style="font-size:10px;font-weight:800;padding:3px 9px;border-radius:20px;letter-spacing:.5px;white-space:nowrap;${badgeStyle}">${badge}</span>
14517      </div>
14518      <div style="font-size:12.5px;color:#94a3b8;line-height:1.55;flex:1">${desc}</div>
14519      ${cta}
14520      ${note ? `<div style="font-size:11px;color:#8b9cb5;line-height:1.5">${note}</div>` : ''}
14521    </div>`;
14522  const openBtn = (url, label) => `<a href="${url}" target="_blank" rel="noopener" style="display:block;text-align:center;padding:9px;background:var(--t-accent,#3b82f6);color:var(--t-onAccent,#fff);border-radius:8px;font-size:12.5px;font-weight:700;text-decoration:none">${label}</a>`;
14523  // Signed-up free users → straight to Stripe checkout; not-yet-signed-up visitors keep /account.
14524  const _lockHref = IS_FREE ? UPGRADE_URL : '/account';
14525  const lockBtn = `<a href="${_lockHref}" style="display:block;text-align:center;padding:9px;background:rgba(234,179,8,0.12);color:#eab308;border:1px solid rgba(234,179,8,0.3);border-radius:8px;font-size:12.5px;font-weight:700;text-decoration:none">Upgrade to unlock</a>`;
14526  let html = '';
14527  html += card('Golden Zone Fibs', 'FREE', freeBadge,
14528    'Auto-plots the key Fibonacci golden-zone retracement so you can spot high-probability entries fast.',
14529    openBtn(FREE_URL, 'Open on TradingView'), '');
14530  html += card('EMA Suite', 'FREE', freeBadge,
14531    'A full multi-EMA stack in one indicator for trend, pullbacks, and dynamic support and resistance.',
14532    openBtn(FREE_URL, 'Open on TradingView'), '');
14533  const GF_LEVELS_URL = 'https://www.tradingview.com/script/Dxhu1Iu5-GEX-Levels/';   // republished as GEX Levels 2026-08-30 (old GhostFlow Levels script was removed by TV)
14534  if (isPaidUser) {
14535    html += card('GhostFlow Levels', 'PREMIUM', premBadge,
14536      'The full GhostFlow structure read on any TV chart: King Node, walls, Vol Node, gamma flip, charm levels, GEX bubbles, prior day and opening range. One paste from the TV button covers your whole watchlist.',
14537      openBtn(GF_LEVELS_URL, 'Open on TradingView'),
14538      'Invite-only: post your TradingView username in Discord to be granted access, then load it under Indicators &gt; Invite-only scripts. Press the TV button on the chart here to copy your levels.');
14539    html += card('Inside Bar Rectangle', 'PREMIUM', premBadge,
14540      'Boxes inside-bar consolidations and breakout zones for clean continuation setups.',
14541      openBtn(PREMIUM_URL, 'Open on TradingView'),
14542      'Invite-only: post your TradingView username in Discord to be granted access, then load it under Indicators &gt; Invite-only scripts.');
14543  } else {
14544    html += card('GhostFlow Levels', 'PREMIUM · LOCKED', premBadge,
14545      'The full GhostFlow structure read on any TV chart: King Node, walls, Vol Node, gamma flip, charm levels, GEX bubbles, prior day and opening range. One paste covers your whole watchlist.',
14546      lockBtn,
14547      'Premium members only. Upgrade, then post your TradingView username in Discord for invite-only access.');
14548    html += card('Inside Bar Rectangle', 'PREMIUM · LOCKED', premBadge,
14549      'Boxes inside-bar consolidations and breakout zones for clean continuation setups.',
14550      lockBtn,
14551      'Premium members only. Upgrade, then post your TradingView username in Discord for invite-only access.');
14552  }
14553  wrap.innerHTML = html;
14554}
14555
14556// ── Shared ticker across GEX chart + Ultra (+ TradingView), Option A ──
14557// Manual picks pause TV auto-follow (until TV sends a new symbol); fromTV picks keep
14558// following. Keeps chartTicker + ultraTicker equal. Guard avoids redundant reloads.
14559let _syncTarget = null;
14560function setSyncedTicker(ticker, opts) {
14561  opts = opts || {};
14562  ticker = (ticker || '').toUpperCase();
14563  if (!ticker) return;
14564  // Demo: TV sync is locked. Ignore any TradingView-driven sync, and any switch to a
14565  // non-free ticker — the demo stays on SPY/TSLA so it can't be used to pull live charts.
14566  if (typeof IS_DEMO !== 'undefined' && IS_DEMO && (opts.fromTV || !DEMO_FREE.includes(ticker))) return;
14567  if (!opts.fromTV && (typeof gfViewSync === 'undefined' || !gfViewSync)) { if (typeof nfUnlinkTvSync === 'function') nfUnlinkTvSync(); else tvSyncPaused = true; } // manual pin — skipped when view-sync is on
14568  // From TV: skip if we already kicked this exact ticker (prevents churn).
14569  if (opts.fromTV && ticker === _syncTarget) return;
14570  _syncTarget = ticker;
14571  if (opts.fromTV) { try { showToast('⚡ Synced from TradingView: ' + ticker); } catch (e) {} }
14572  // HQ: there is no single "active view" to route to - every tile is a view. Broadcast instead.
14573  if (typeof _wsOn !== 'undefined' && _wsOn && opts.fromTV) {
14574    try { gfSyncTo(ticker, true); } catch (e) {}
14575    try { if (typeof _wsGridReduce === 'function') _wsGridReduce(ticker); } catch (e) {}
14576    try { if (typeof gfWsBuildTickers === 'function') gfWsBuildTickers(); } catch (e) {}
14577    try { if (typeof _wsDdPaint === 'function') _wsDdPaint(); } catch (e) {}   // the row's ticker + recents
14578    return;
14579  }
14580  // Only the ACTIVE view follows — Chart and Ultra keep independent tickers.
14581  // Ghost Desk: the top ticker bar fills the chart you clicked. The pane keeps its own modes,
14582  // timeframe and basis across the change — they live on the slot, not on the ticker.
14583  if (currentView === 'chart') {
14584    let _routed = (typeof _mRouteTicker === 'function') && _mRouteTicker(ticker);
14585    if (!_routed) { chartTicker = ticker; try { if (typeof _mSyncFocusedSlot === 'function') _mSyncFocusedSlot(); } catch (e) {} buildChartTickers(); loadGEXChart(ticker); }
14586  }
14587  else if (currentView === 'ultra') { ultraTicker = ticker; buildUltraTickers(); loadUltraView(ticker); }
14588  // If view-sync is on, TradingView/Chart selection drives Ghost Mode + Grid + Heatmap too.
14589  if (typeof gfSyncTo === 'function') gfSyncTo(ticker);
14590}
14591
14592// Switch the exposure metric (GEX = gamma, VEX = vanna) across ALL views. Both come
14593// from the same UW response, so this just re-parses + re-renders — no extra fetch.
14594function switchMetric(m) {
14595  if (m === 'charm' && typeof isPaidUser !== 'undefined' && !isPaidUser) {   // CHARM is Premium-only
14596    try { showToast('🔒 CHARM is a Premium feature — upgrade to unlock delta-decay pins'); } catch (e) {}
14597    return;
14598  }
14599  if (!['gex', 'vex', 'charm'].includes(m) || currentMetric === m) return;
14600  currentMetric = m;
14601  try { _syncColCfgLabels(); } catch (e) {}
14602  try { if (_vsPerView()) _vsBank(_vsKey(currentView)); } catch (e) {}
14603  document.querySelectorAll('.metric-btn').forEach(b => b.classList.toggle('active', b.dataset.metric === m));
14604  // Re-render GEX columns in place (even when hidden) so they're correct on navigation.
14605  activeColumns.forEach((t, i) => setTimeout(() => loadGEXSlot(i), i * 120));
14606  if (currentView === 'expiry' && expiryTicker) loadExpiryGEX(expiryTicker, true);
14607  else if (currentView === 'chart' && chartTicker) loadGEXChart(chartTicker);
14608  else if (currentView === 'ultra' && ultraTicker) loadUltraView(ultraTicker, true);
14609  _gexBubRefreshSoon();   // switch bubbles to this metric's trail once columnData rebuilds
14610}
14611
14612// Size the GEX chart view to exactly the space left below the header + sector tabs,
14613// so the chart's time axis (bottom) is always visible without scrolling the page.
14614function fitChartViewHeight() {
14615  const cv = document.getElementById('chartView');
14616  if (!cv || cv.style.display === 'none') return;
14617  // Measure the sticky chrome (header + sector tabs) directly instead of via
14618  // getBoundingClientRect().top — that value changes when the page is scrolled, which
14619  // made the view too tall and let the chart's bottom (time axis) scroll out of sight.
14620  const header = document.querySelector('.header');
14621  const tabs = document.getElementById('tabs');
14622  const bar = document.getElementById('demoBar');
14623  const chrome = (header ? header.offsetHeight : 0) + (tabs ? tabs.offsetHeight : 0) + (bar ? bar.offsetHeight : 0);
14624  cv.style.height = Math.max(300, window.innerHeight - chrome - (typeof gfDockH==='function'?gfDockH():0)) + 'px';
14625  // Nudge the chart canvas to the new height immediately.
14626  try {
14627    const el = document.getElementById('tvChart');
14628    if (chartObj && el) chartObj.applyOptions({ width: el.clientWidth, height: el.clientHeight });
14629  } catch (e) {}
14630}
14631window.addEventListener('resize', fitChartViewHeight);
14632
14633// Size the GEX columns row to the exact space below the header + sector tabs so its
14634// bottom horizontal scrollbar sits on-screen (no more scrolling the whole page down to
14635// find it). Desktop/tablet only; phones keep their swipe layout. Mobile CSS uses
14636// !important so this inline height is ignored there.
14637function fitColumnsHeight() {
14638  const cols = document.getElementById('columns');
14639  if (!cols || cols.style.display === 'none') return;
14640  if (window.innerWidth <= 640) { cols.style.height = ''; return; }
14641  const header = document.querySelector('.header');
14642  const tabs = document.getElementById('tabs');
14643  const gexTk = document.getElementById('gexTickers');
14644  const gxExp = document.getElementById('gridExpiryBar');
14645  const bar = document.getElementById('demoBar');
14646  const chrome = (header ? header.offsetHeight : 0) + (tabs && tabs.style.display !== 'none' ? tabs.offsetHeight : 0) + (gexTk && gexTk.style.display !== 'none' ? gexTk.offsetHeight : 0) + (gxExp && gxExp.style.display !== 'none' ? gxExp.offsetHeight : 0) + (bar ? bar.offsetHeight : 0);
14647  cols.style.height = Math.max(300, window.innerHeight - chrome - (typeof gfDockH==='function'?gfDockH():0)) + 'px';
14648}
14649window.addEventListener('resize', fitColumnsHeight);
14650// The wave is absolutely positioned INSIDE the scrolling table container, so it scrolls with the
14651// cells for free and needs no scroll handler - but a resize moves every column, so its x positions
14652// have to be measured again.
14653let _hwRz = null;
14654window.addEventListener('resize', function(){ if(_hwRz) clearTimeout(_hwRz); _hwRz = setTimeout(function(){ try{ if(typeof gfHeatWave!=='undefined' && (gfHeatWave || gfHeatBucket)) _hwDraw(); }catch(e){} }, 160); });
14655
14656// Which sector a ticker belongs to (first match in the watchlist)
14657function sectorOfTicker(ticker) {
14658  for (const [group, tickers] of Object.entries(WATCHLIST)) {
14659    if (tickers.includes(ticker)) return group;
14660  }
14661  return null;
14662}
14663
14664// Keep the top sector tab in sync with the selected ticker
14665function syncActiveGroup(ticker) {
14666  const sec = sectorOfTicker(ticker);
14667  if (sec && sec !== activeGroup) {
14668    activeGroup = sec;
14669    buildTabs();
14670  }
14671}
14672
14673// ── View sync (user-toggled): Ghost Mode + Grid + Heatmap follow one ticker. Any view
14674// drives; the Grid shows that ticker's SECTOR. Off by default; toggle persists per browser. ──
14675var gfViewSync = false; try { gfViewSync = localStorage.getItem('gf_viewSync') === '1'; } catch (e) {}
14676var _gfSyncing = false;
14677function gfSyncTo(ticker, force) {
14678  // force: the workspace's own ticker sync, which must not depend on gfViewSync - that flag also
14679  // governs whether grid bars / tint / metric are per-view, and those must stay per-view.
14680  if ((!gfViewSync && !force) || _gfSyncing || !ticker) return;
14681  _gfSyncing = true;
14682  try {
14683    ticker = String(ticker).toUpperCase();
14684    // Ghost Mode (ultra) — and the shared chart it hosts (chartTicker in the new layout)
14685    // the broadcast always lands on chart 1; panes 2 / 3 are never synced (user 2026-09-14)
14686    if (typeof chartTicker !== 'undefined' && chartTicker !== ticker) {
14687      chartTicker = ticker;
14688      if (typeof buildChartTickers === 'function') { try { buildChartTickers(); } catch (e) {} }
14689      if (gfLive('chart')) loadGEXChart(ticker); else if (_wsHas('ghost')) _wsStale.ghost = 1;
14690    }
14691    if (typeof ultraTicker !== 'undefined' && ultraTicker !== ticker) {
14692      ultraTicker = ticker;
14693      if (typeof buildUltraTickers === 'function') { try { buildUltraTickers(); } catch (e) {} }
14694      if (gfLive('ultra')) loadUltraView(ticker);
14695    }
14696    // Heatmap (expiry)
14697    if (typeof expiryTicker !== 'undefined' && expiryTicker !== ticker) {
14698      expiryTicker = ticker;
14699      if (typeof buildExpiryTickers === 'function') { try { buildExpiryTickers(); } catch (e) {} }
14700      if (gfLive('expiry')) loadExpiryGEX(ticker); else if (_wsHas('heatmap')) _wsStale.heatmap = 1;
14701    }
14702    // Grid (gex) — show the ticker's sector, focused first. Rebuild the columns even when the
14703    // grid is hidden (switchGroupFocused renders + loads), so switching to it shows the right
14704    // sector instead of stale columns.
14705    const sec = (typeof sectorOfTicker === 'function') ? sectorOfTicker(ticker) : null;
14706    if (sec && typeof switchGroupFocused === 'function' && (activeGroup !== sec || !(activeColumns && activeColumns[0] === ticker))) {
14707      switchGroupFocused(sec, ticker);
14708    }
14709    // Ghost Mode's side panel, when it is a tile of its own: its tape is per-ticker like the chart'
14709s
14710    if (typeof gfWsShowing === 'function' && (gfWsShowing('gpanel') || _dupShowing('gpanel')) && typeof loadUltraFlow === 'function') {
14711      try { loadUltraFlow(ticker, true); } catch (e) {}
14712    } else if (_wsHas('gpanel') || _dupHas('gpanel')) _wsStale.gpanel = 1;
14713    // Chain — a new name starts at its own front expiry, same as clicking its own strip.
14714    if (typeof _chTk !== 'undefined' && _chTk !== ticker) {
14715      _chTk = ticker; _chExp = null;
14716      if (typeof buildChainTickers === 'function') { try { buildChainTickers(); } catch (e) {} }
14717      if (gfLive('chain') && typeof loadChain === 'function') loadChain(); else if (_wsHas('chain')) _wsStale.chain = 1;
14718    }
14719    // Flow — filter the tape to this name. Hidden: remember the name now, do the work on the way in.
14720    if (typeof flowFilterTicker === 'function') {
14721      if (gfLive('flow')) { try { flowFilterTicker(ticker); } catch (e) {} }
14722      else { try { flowTickerFilter = ticker; _flowTkDirty = true; } catch (e) {} }
14723    }
14724    // Smart Money has no ticker of its own - it is four feeds of filings - but it does have a
14725    // name/ticker filter, so following the ticker means typing it into that.
14726    {
14727      // the input IS the state, and _smartRender reads it when it runs - so set it either way and
14728      // only pay for the render when someone is looking
14729      const sf = document.getElementById('smartFilter');
14730      if (sf && String(sf.value || '').toUpperCase() !== ticker) {
14731        sf.value = ticker;
14732        if (gfLive('smart') && typeof _smartRender === 'function') { try { _smartRender(); } catch (e) {} }
14733      }
14734    }
14735  } catch (e) {} finally { _gfSyncing = false; }
14736}
14737function gfPaintSyncBtn() {
14738  const b = document.getElementById('btn-viewsync'); if (!b) return;
14739  b.textContent = gfViewSync ? 'Sync ON' : 'Sync';
14740  b.style.color = gfViewSync ? '#22c55e' : 'var(--dim)';
14741  b.style.borderColor = gfViewSync ? '#22c55e' : 'var(--border)';
14742  b.style.background = gfViewSync ? 'rgba(34,197,94,0.12)' : 'transparent';
14743}
14744function toggleViewSync() {
14745  gfViewSync = !gfViewSync;
14746  try { localStorage.setItem('gf_viewSync', gfViewSync ? '1' : '0'); } catch (e) {}
14747  gfPaintSyncBtn();
14748  if (gfViewSync) { const tk = (currentView === 'ultra' && ultraTicker) || (currentView === 'expiry' && expiryTicker) || (currentView === 'gex' && activeColumns && activeColumns[0]) || null; if (tk) gfSyncTo(tk); }
14749}
14750try { gfPaintSyncBtn(); } catch (e) {}
14751
14752function buildExpiryTickers() {
14753  const el = document.getElementById('expiryTickers');
14754  const _sl = el ? el.scrollLeft : 0;   // keep the folded one-line strip where the user scrolled it across re-renders
14755
14756  // Visible (non-empty) sectors
14757  const groups = Object.entries(WATCHLIST).filter(([g, t]) => !(g === 'Custom' && t.length === 0));
14758
14759  // Highlight ALWAYS follows the selected ticker's sector — no matter how the
14760  // ticker changed (top tab, TradingView sync, sector click, or ticker click).
14761  if (expiryTicker && sectorOfTicker(expiryTicker)) {
14762    activeExpirySector = sectorOfTicker(expiryTicker);
14763  } else if (!activeExpirySector || !groups.some(([g]) => g === activeExpirySector)) {
14764    activeExpirySector = (groups[0] && groups[0][0]) || null;
14765  }
14766
14767  let html = '';
14768  for (const [group, tickers] of groups) {
14769    const groupActive = group === activeExpirySector;
14770    html += `<span class="exp-sector-group ${groupActive ? 'active' : ''}" data-sector="${group}">`;
14771    html += `<span class="exp-sector-label" data-sector="${group}">${group}:</span>`;
14772    for (const t of tickers) {
14773      const isActive = t === expiryTicker;
14774      html += `<button class="exp-ticker-btn ${isActive ? 'active' : ''}${demoLocked(t) ? ' demo-locked-btn' : ''}" id="exp-btn-${t}" data-ticker="${t}"${IS_DEMO ? '' : ' draggable="true"'} title="${demoLocked(t) ? 'Locked — sign up to unlock' : 'Drag to reorder · right-click to delete'}">${demoLocked(t) ? '🔒 ' : ''}${t}</button>`;
14775    }
14776    html += `</span>`;
14777  }
14778  el.innerHTML = html;
14779  if (_sl) el.scrollLeft = _sl;
14780
14781  // Click a sector label → select its first ticker (highlight follows the selection).
14782  // Demo: a sector label must NOT reveal a locked ticker — if its first ticker isn't free
14783  // (SPY/TSLA), make the label non-clickable (and brighter-but-disabled via demo-tab-locked).
14784  el.querySelectorAll('.exp-sector-label').forEach(function(label) {
14785    const list = WATCHLIST[label.dataset.sector] || [];
14786    if (IS_DEMO && (!list.length || demoLocked(list[0]))) { label.classList.add('demo-tab-locked'); return; }
14787    label.addEventListener('click', function() {
14788      if (!list.length) return;
14789      expiryTicker = list[0];
14790      syncActiveGroup(expiryTicker);
14791      buildExpiryTickers();
14792      loadExpiryGEX(expiryTicker);
14793    });
14794  });
14795
14796  // Click a ticker → select it and load its heatmap (highlight + top tab follow its sector)
14797  const unique = [...new Set(Object.values(WATCHLIST).flat())];
14798  for (const t of unique) {
14799    const btn = document.getElementById('exp-btn-' + t);
14800    if (btn) {
14801      const ticker = t;
14802      btn.addEventListener('click', function() {
14803        if (demoLocked(ticker)) { window.location.href = '/register'; return; }
14804        expiryTicker = ticker;
14805        syncActiveGroup(ticker);
14806        buildExpiryTickers();
14807        loadExpiryGEX(ticker);
14808      });
14809    }
14810  }
14811  enableButtonDrag('expiryTickers', buildExpiryTickers);
14812  gfWireStripFold('expiryTickers');
14813}
14814
14815// GEX view ticker line — same sector-grouped layout as the Heatmap. Clicking a ticker
14816// loads that ticker's whole sector as GEX columns (focused on the clicked one).
14817function buildGexTickers() {
14818  const el = document.getElementById('gexTickers');
14819  const _sl = el ? el.scrollLeft : 0;   // keep the folded one-line strip where the user scrolled it across re-renders
14820  if (!el) return;
14821  const groups = Object.entries(WATCHLIST).filter(([g, t]) => !(g === 'Custom' && t.length === 0));
14822  let html = '';
14823  for (const [group, tickers] of groups) {
14824    const groupActive = group === activeGroup;
14825    html += `<span class="exp-sector-group ${groupActive ? 'active' : ''}" data-sector="${group}">`;
14826    html += `<span class="exp-sector-label" data-sector="${group}">${group}:</span>`;
14827    for (const t of tickers) {
14828      const isActive = groupActive && activeColumns.includes(t);
14829      html += `<button class="exp-ticker-btn ${isActive ? 'active' : ''}${demoLocked(t) ? ' demo-locked-btn' : ''}" id="gex-btn-${t}" data-ticker="${t}"${IS_DEMO ? '' : ' draggable="true"'} title="${demoLocked(t) ? 'Locked — sign up to unlock' : 'Click to load this sector · drag to reorder · right-click to delete'}">${demoLocked(t) ? '🔒 ' : ''}${t}</button>`;
14830    }
14831    html += `</span>`;
14832  }
14833  el.innerHTML = html;
14834  if (_sl) el.scrollLeft = _sl;
14835
14836  el.querySelectorAll('.exp-sector-label').forEach(function(label) {
14837    const list = WATCHLIST[label.dataset.sector] || [];
14838    if (IS_DEMO && (!list.length || demoLocked(list[0]))) { label.classList.add('demo-tab-locked'); return; }
14839    label.addEventListener('click', function() { if (list.length) switchGroup(label.dataset.sector); });
14840  });
14841
14842  const unique = [...new Set(Object.values(WATCHLIST).flat())];
14843  for (const t of unique) {
14844    const btn = document.getElementById('gex-btn-' + t);
14845    if (btn) {
14846      const ticker = t;
14847      btn.addEventListener('click', function() {
14848        if (demoLocked(ticker)) { window.location.href = '/register'; return; }
14849        // With view-sync ON, keep TradingView linked (don't turn its toggle off); the pick
14850        // just propagates across views. With sync OFF, pin this window as before.
14851        if (typeof gfViewSync === 'undefined' || !gfViewSync) { if (typeof nfUnlinkTvSync === 'function') nfUnlinkTvSync(); else tvSyncPaused = true; }
14852        const sec = sectorOfTicker(ticker);
14853        if (sec) switchGroupFocused(sec, ticker);
14854      });
14855    }
14856  }
14857  enableButtonDrag('gexTickers', buildGexTickers);
14858  gfWireStripFold('gexTickers');
14859}
14860
14861// Canonical King / Call Wall / Put Wall from aggregate per-strike net GEX — the EXACT
14862// logic the GEX view + Ultra use, so every view reports the same levels.
14863function computeGexLevels(parsed, spot) {
14864  if (!spot && parsed.length) spot = [...parsed].sort((a, b) => Math.abs(b.netGex) - Math.abs(a.netGex))[0].strike;
14865  let filtered = parsed;
14866  if (spot > 0) {
14867    const above = parsed.filter(s => s.strike >= spot).sort((a, b) => a.strike - b.strike).slice(0, 30);
14868    const below = parsed.filter(s => s.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
14869    filtered = [...above, ...below];
14870  }
14871  if (filtered.length) {
14872    const maxGex = Math.max(...filtered.map(s => Math.abs(s.netGex)));
14873    filtered = filtered.filter(s => Math.abs(s.netGex) > maxGex * 0.001);
14874  }
14875  filtered.sort((a, b) => b.strike - a.strike);
14876  const { callWall, putWall, kingNode, totalGex } = pickLevels(filtered, spot);
14877  return { spot, filtered, callWall, putWall, kingNode, totalGex };
14878}
14879
14880async function loadExpiryGEX(ticker, silent) {
14881  // Reflect the CURRENT basis on this view's own toggle. Arriving from the grid or a chart with
14882  // 0DTE selected would otherwise show ALL lit here while the data underneath was 0DTE.
14883  try { _syncGridExpiryButtons(); } catch (e) {}
14884  if (!silent && typeof gfSyncTo === 'function') gfSyncTo(ticker);
14885  const content = document.getElementById('expiryContent');
14886  // silent keeps a table that is on screen; a placeholder ("Select a ticker above") is not one
14887  if (!silent || content.querySelector('.col-loading')) content.innerHTML = '<div class="col-loading">Loading expiry GEX for ' + ticker + '...</div>';
14888
14889  try {
14890    // Try strike-expiry GEX first, fallback to contracts. Also pull the aggregate
14891    // per-strike GEX (same endpoint as the GEX view) for canonical King/walls.
14892    const [gexExpRes, spotRes, gexAggJson, contractsRes] = await Promise.all([
14893      fetch('/api/gex/strike-expiry/' + ticker),
14894      fetch('/api/spot/' + ticker),
14895      // Levels follow the ALL / 0DTE / WEEK basis, same as the Grid, Chart and Ultra. This used to
14896      // be a hard-coded /api/gex/strike/ call, so the heatmap always reported ALL-expiry levels no
14897      // matter what the toggle said. _basisGexJson returns PARSED json (not a Response).
14898      _basisGexJson(ticker).catch(() => null),
14899      fetch('/api/contracts/' + ticker),
14900    ]);
14901
14902    const gexExpJson = await gexExpRes.json();
14903    const spotJson = await spotRes.json();
14904    const gexData = gexExpJson.data || gexExpJson || [];
14905    const spotArr = spotJson.data || spotJson || [];
14906    let expContracts = [];
14907    try { const cj = await contractsRes.json(); expContracts = cj.data || cj || []; } catch (e) {}
14908
14909    // Get spot
14910    let spot = 0;
14911    if (Array.isArray(spotArr)) {
14912      for (const s of spotArr) {
14913        const p = parseFloat(s.price || s.underlying_price || 0);
14914        if (p > 0) { spot = p; break; }
14915      }
14916    }
14917
14918    // Canonical King / Call Wall / Put Wall from the SAME aggregate strike data the
14919    // GEX view + Ultra use — so the heatmap reports identical levels.
14920    let levels = null;
14921    try {
14922      const aggJson = gexAggJson;   // already parsed by _basisGexJson
14923      const aggData = (aggJson && (aggJson.data || aggJson)) || [];
14924      const aggMult = metricMult(spot);
14925      const aggParsed = (Array.isArray(aggData) ? aggData : []).map(s => {
14926        const strike = parseFloat(s.strike || 0);
14927        return { strike, netGex: metricRaw(s) * aggMult, callGex: metricCall(s) * aggMult, putGex: metricPut(s) * aggMult };
14928      }).filter(s => s.strike > 0 && Math.abs(s.netGex) > 0);
14929      if (aggParsed.length) levels = computeGexLevels(aggParsed, spot);
14930    } catch (e) {}
14931    // Vol Node (highest-volume strike today) — attach to levels for the info bar/grid.
14932    const volNode = computeVolNode(expContracts, spot);
14933    if (!levels) levels = {};
14934    levels.volNode = volNode;
14935
14936    // Check if we got strike-expiry GEX data
14937    if (Array.isArray(gexData) && gexData.length > 0 && gexData[0].expiry) {
14938      // Real GEX by strike × expiry
14939      console.log('Got strike-expiry GEX:', gexData.length, 'rows');
14940      console.log('Sample keys:', Object.keys(gexData[0]).join(', '));
14941
14942      const today = new Date().toISOString().slice(0, 10);
14943      const maxDate = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
14944      const mult = metricMult(spot);
14945
14946      const map = {};
14947      const expirySet = new Set();
14948      const strikeTotal = {};
14949
14950      for (const row of gexData) {
14951        const strike = parseFloat(row.strike || 0);
14952        const expiry = row.expiry || row.date || '';
14953        if (!strike || !expiry || expiry < today || expiry > maxDate) continue;
14954
14955        const netGex = metricRaw(row) * mult;
14956        if (Math.abs(netGex) < 0.01) continue;
14957
14958        expirySet.add(expiry);
14959        if (!map[strike]) map[strike] = {};
14960        map[strike][expiry] = netGex;
14961        strikeTotal[strike] = (strikeTotal[strike] || 0) + Math.abs(netGex);
14962      }
14963
14964      const expiries = [...expirySet].sort().slice(0, HEATMAP_MAX_EXP);
14965      if (expiries.length === 0) {
14966        // Fallback to contracts method
14967        await loadExpiryFromContracts(ticker, spot, content, levels);
14968        return;
14969      }
14970
14971      // Real spot only: if the spot feed was empty (common for index tickers like SPX),
14972      // get the true price from the price endpoint. NEVER use the busiest (King) strike as
14973      // the price — that mislabels it as the current price (e.g. showing 7440 vs 7402).
14974      if (!spot) {
14975        try { const pj = await (await fetch('/api/price/' + ticker)).json(); const p = parseFloat(pj.price || 0); if (p > 0) spot = p; } catch (e) {}
14976      }
14977      // Center the displayed strike window on the real spot, or the busiest strike only as a
14978      // layout fallback (used for which strikes to show — NOT for the current-price highlight).
14979      let center = spot;
14980      if (!center) { const best = Object.entries(strikeTotal).sort((a, b) => b[1] - a[1])[0]; if (best) center = parseFloat(best[0]); }
14981
14982      // Filter: 30 strikes above and below the center
14983      let allStrikes = Object.keys(map).map(Number);
14984      if (center > 0) {
14985        const above = allStrikes.filter(s => s >= center).sort((a, b) => a - b).slice(0, 30);
14986        const below = allStrikes.filter(s => s < center).sort((a, b) => b - a).slice(0, 30);
14987        allStrikes = [...above.reverse(), ...below];
14988      } else {
14989        allStrikes.sort((a, b) => b - a);
14990        allStrikes = allStrikes.slice(0, 60);
14991      }
14992      allStrikes = allStrikes.filter(s => expiries.some(e => map[s]?.[e]));
14993
14994      if (gfHeatVolN > 0 && typeof isPaidUser !== 'undefined' && isPaidUser) { try { await _hvLoad(ticker, expiries.slice(0, gfHeatVolN)); } catch (e) { window._hvData = null; } } else { window._hvData = null; }   // VOL: 1 or 4 expiries per the toggle (user 2026-09-02)
14995      renderExpiryTable(ticker, spot, allStrikes, expiries, map, 'gex', levels);
14996
14997    } else {
14998      // Fallback: use option-contracts for Vol/OI based view
14999      await loadExpiryFromContracts(ticker, spot, content, levels);
15000    }
15001
15002  } catch (e) {
15003    content.innerHTML = '<div class="col-loading col-error">' + e.message + '</div>';
15004  }
15005}
15006
15007// Fallback: load from option-contracts endpoint
15008async function loadExpiryFromContracts(ticker, spot, content, levels) {
15009  try {
15010    const contractsRes = await fetch('/api/contracts/' + ticker);
15011    const contractsJson = await contractsRes.json();
15012    const contracts = contractsJson.data || contractsJson || [];
15013
15014    if (!Array.isArray(contracts) || contracts.length === 0) {
15015      content.innerHTML = '<div class="col-loading col-error">No options data for ' + ticker + ' — it may not trade options.
15015<br>Pick another ticker above.</div>';
15016      return;
15017    }
15018
15019    function parseOpt(sym) {
15020      if (!sym) return null;
15021      const m = sym.match(/^[A-Z]+(\d{6})([CP])(\d{8})$/);
15022      if (!m) return null;
15023      return {
15024        expiry: '20' + m[1].slice(0,2) + '-' + m[1].slice(2,4) + '-' + m[1].slice(4,6),
15025        type: m[2] === 'C' ? 'CALL' : 'PUT',
15026        strike: parseInt(m[3]) / 1000,
15027      };
15028    }
15029
15030    const today = new Date().toISOString().slice(0, 10);
15031    const maxDate = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
15032
15033    const map = {};
15034    const expirySet = new Set();
15035    const strikeTotal = {};
15036
15037    for (const c of contracts) {
15038      const sym = c.option_symbol || c.option_chain_id || '';
15039      const p = parseOpt(sym);
15040      if (!p || p.expiry < today || p.expiry > maxDate) continue;
15041      const vol = parseInt(c.volume) || 0;
15042      if (vol === 0) continue;
15043
15044      expirySet.add(p.expiry);
15045      const sign = p.type === 'CALL' ? 1 : -1;
15046      if (!map[p.strike]) map[p.strike] = {};
15047      map[p.strike][p.expiry] = (map[p.strike][p.expiry] || 0) + sign * vol;
15048      strikeTotal[p.strike] = (strikeTotal[p.strike] || 0) + vol;
15049    }
15050
15051    const expiries = [...expirySet].sort().slice(0, 8);
15052
15053    // Real spot only (never the busiest strike — that mislabels it as the current price).
15054    if (!spot) {
15055      try { const pj = await (await fetch('/api/price/' + ticker)).json(); const p = parseFloat(pj.price || 0); if (p > 0) spot = p; } catch (e) {}
15056    }
15057    let center = spot;
15058    if (!center) { const best = Object.entries(strikeTotal).sort((a, b) => b[1] - a[1])[0]; if (best) center = parseFloat(best[0]); }
15059
15060    let allStrikes = Object.keys(map).map(Number).sort((a, b) => b - a);
15061    if (center > 0) {
15062      const above = allStrikes.filter(s => s >= center).sort((a, b) => a - b).slice(0, 30);
15063      const below = allStrikes.filter(s => s < center).sort((a, b) => b - a).slice(0, 30);
15064      allStrikes = [...above.reverse(), ...below];
15065    }
15066    allStrikes = allStrikes.filter(s => expiries.some(e => map[s]?.[e]));
15067
15068    renderExpiryTable(ticker, spot, allStrikes, expiries, map, 'vol', levels);
15069
15070  } catch (e) {
15071    document.getElementById('expiryContent').innerHTML = '<div class="col-loading col-error">' + e.message + '</div>';
15072  }
15073}
15074
15075// Render the expiry heatmap table (works for both GEX and Vol data)
15076function renderExpiryTable(ticker, spot, allStrikes, expiries, map, mode, levels) {
15077  const content = document.getElementById('expiryContent');
15078  const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
15079
15080  // Volume column payload - only when the toggle is on, we are the admin, and the data
15081  // matches this exact ticker + front expiry (never stale bars against the wrong ladder).
15082  const _hv = (mode === 'gex' && gfHeatVolN > 0 && typeof isPaidUser !== 'undefined' && isPaidUser && window._hvData && window._hvData.tk === ticker && (window._hvData.exps || [])[0] === expiries[0]) ? window._hvData : null;   // VOL 1 or 4 expiries (user 2026-09-02)
15083  try { _syncHeatVolBtn(); } catch (e) {}   // VOL button label + premium visibility (user 2026-09-02)
15084  // Find max absolute value for scaling
15085  // Per-expiry column max for independent scaling
15086  const maxPerExpiry = {};
15087  for (const e of expiries) {
15088    maxPerExpiry[e] = 0;
15089    for (const s of allStrikes) {
15090      if (map[s]?.[e]) maxPerExpiry[e] = Math.max(maxPerExpiry[e], Math.abs(map[s][e]));
15091    }
15092  }
15093
15094  const fmtVal = (v) => {
15095    const abs = Math.abs(v);
15096    const sign = v >= 0 ? '+' : '-';
15097    if (mode === 'gex') {
15098      if (abs >= 1e6) return sign + '$' + (abs / 1e6).toFixed(1) + 'M';
15099      if (abs >= 1e3) return sign + '$' + (abs / 1e3).toFixed(1) + 'K';
15100      return sign + '$' + abs.toFixed(0);
15101    } else {
15102      if (abs >= 1e6) return sign + (abs / 1e6).toFixed(1) + 'M';
15103      if (abs >= 1e3) return sign + (abs / 1e3).toFixed(0) + 'K';
15104      return sign + abs.toFixed(0);
15105    }
15106  };
15107
15108  // On a 0DTE / WEEK basis the King is the King of ONE expiry, so painting the whole strike row
15109  // across every column claimed far more than the data supports. Resolve that expiry here and mark
15110  // just its cell; on ALL there is no single expiry to point at, so the row treatment stays.
15111  const _bMode = (typeof gridExpiryMode !== 'undefined') ? gridExpiryMode : 'all';
15112  let basisExp = '';
15113  if (_bMode !== 'all' && expiries.length) {
15114    try { basisExp = pickGridExpiry(expiries.map(e => ({ expiry: e })), _bMode) || ''; } catch (e) { basisExp = ''; }
15115    if (basisExp && expiries.indexOf(basisExp) < 0) basisExp = '';   // levels fell back to ALL
15116  }
15117  let kingStrike = null, callWallStrike = null, putWallStrike = null, bias = true;
15118  const volNodeStrike = (levels && levels.volNode) ? levels.volNode.strike : null;
15119
15120  if (levels && levels.kingNode) {
15121    // Use the canonical aggregate GEX levels so King / Call Wall / Put Wall match
15122    // the GEX view + Ultra mode exactly.
15123    kingStrike = levels.kingNode.strike;
15124    callWallStrike = levels.callWall ? levels.callWall.strike : null;
15125    putWallStrike = levels.putWall ? levels.putWall.strike : null;
15126    bias = levels.totalGex >= 0;
15127  } else {
15128    // Fallback (no aggregate available): derive from the heatmap's own per-expiry sums.
15129    let kingVal = 0;
15130    for (const s of allStrikes) {
15131      let total = 0;
15132      for (const e of expiries) { if (map[s]?.[e]) total += Math.abs(map[s][e]); }
15133      if (total > kingVal) { kingVal = total; kingStrike = s; }
15134    }
15135    let totalNet = 0;
15136    for (const s of allStrikes) {
15137      for (const e of expiries) { if (map[s]?.[e]) totalNet += map[s][e]; }
15138    }
15139    bias = totalNet >= 0;
15140    if (spot > 0) {
15141      let bestCallVal = 0, bestPutVal = 0;
15142      for (const s of allStrikes) {
15143        let total = 0;
15144        for (const e of expiries) { if (map[s]?.[e]) total += map[s][e]; }
15145        if (s > spot && total > bestCallVal) { bestCallVal = total; callWallStrike = s; }
15146        if (s < spot && total < bestPutVal) { bestPutVal = total; putWallStrike = s; }
15147      }
15148    }
15149  }
15150
15151  // Update static info bar
15152  const _wsC = _gfWsOn();   // short labels in a tile; the type size is CSS, see body.gf-ws-on #expiryInfo
15153  const infoEl = document.getElementById('expiryInfo');
15154  if (infoEl) {
15155    let infoHtml = '';
15156    infoHtml += `<span style="font-size:18px;font-weight:700;color:var(--text)">${ticker}</span>`;
15157    if (spot > 0) infoHtml += `<span style="font-size:14px;color:var(--dim)">$${spot.toFixed(2)}</span>`;
15158    if (kingStrike) {
15159      infoHtml += `<span style="display:flex;align-items:center;gap:6px">`;
15160      infoHtml += `<span style="width:8px;height:8px;border-radius:50%;background:var(--yellow)"></span>`;
15161      infoHtml += `<span style="font-size:12px;color:var(--dim)">${_wsC ? 'KING' : 'King'}</span>`;
15162      infoHtml += `<span style="font-size:15px;font-weight:700;color:var(--yellow)">${kingStrike}</span>`;
15163      infoHtml += `</span>`;
15164    }
15165    if (callWallStrike) {
15166      infoHtml += `<span style="display:flex;align-items:center;gap:4px">`;
15167      infoHtml += `<span style="font-size:11px;color:var(--dim)">${_wsC ? 'CW' : 'Call Wall'}</span>`;
15168      infoHtml += `<span style="font-size:13px;font-weight:700;color:var(--green)">${callWallStrike}</span>`;
15169      infoHtml += `</span>`;
15170    }
15171    if (putWallStrike) {
15172      infoHtml += `<span style="display:flex;align-items:center;gap:4px">`;
15173      infoHtml += `<span style="font-size:11px;color:var(--dim)">${_wsC ? 'PW' : 'Put Wall'}</span>`;
15174      infoHtml += `<span style="font-size:13px;font-weight:700;color:var(--red)">${putWallStrike}</span>`;
15175      infoHtml += `</span>`;
15176    }
15177    if (volNodeStrike) {
15178      infoHtml += `<span style="display:flex;align-items:center;gap:4px">`;
15179      infoHtml += `<span style="width:8px;height:8px;border-radius:50%;background:#3b82f6"></span>`;
15180      infoHtml += `<span style="font-size:11px;color:var(--dim)">${_wsC ? 'VN' : 'Vol Node'}</span>`;
15181      infoHtml += `<span style="font-size:13px;font-weight:700;color:#3b82f6">${volNodeStrike}</span>`;
15182      infoHtml += `</span>`;
15183    }
15184    infoHtml += `<span style="font-size:13px;font-weight:700;color:${bias ? 'var(--green)' : 'var(--red)'}">${bias ? (_wsC ? '↑ BULL' : '↑ Bullish') : (_wsC ? '↓ BEAR' : '↓ Bearish')}</span>`;
15185    infoHtml += `<span style="font-size:10px;color:var(--dim);margin-left:auto">${mode === 'gex' ? '$ ' + currentMetric.toUpperCase() : 'Net Vol'}</span>`;
15186    // Marker legend. The star/triangles were added with the per-expiry highlight tiers and were
15187    // undiscoverable without this - Arvin had to ask what the arrows meant.
15188    // In a tile this is ~190px explaining two triangles that the HI / LO chips below already label
15189    // with real numbers. The titles still explain them on hover in the full-screen heatmap.
15190    if (!_wsC) infoHtml += '<span style="display:flex;align-items:center;gap:10px;margin-left:auto;font-size:11px;color:#cbd5e1">'
15191      + '<span title="One of the 3 biggest positive or 3 biggest negative cells across the WHOLE grid"><b style="color:#e2e8f0">\u2605</b> grid top 3</span>'
15192      + '<span title="The biggest POSITIVE value in this expiry column. Shown per column so far-dated expiries - which are smaller in absolute terms and never win a grid star - still read."><b style="color:#4ade80">\u25B2</b> column high</span>'
15193      + '<span title="The most NEGATIVE value in this expiry column."><b style="color:#f87171">\u25BC</b> column low</span>'
15194      + '</span>';
15195    infoEl.innerHTML = infoHtml;
15196  }
15197
15198  // Table
15199  let html = '';
15200  html += '<table class="exp-table"><thead><tr><th>STRIKE</th>';
15201  for (const e of expiries) {
15202    const d = new Date(e + 'T12:00:00');
15203    html += `<th data-hx="${e}">${dayNames[d.getDay()]} ${e.slice(5).replace('-','/')}</th>`;   /* data-hx: the Gamma Wave reads column x off these (see _hwDraw) */
15204    if (_hv) html += _hvThFor(_hv, e);   // VOL column right after THIS expiry's GEX
15205  }
15206  html += '</tr></thead><tbody>';
15207
15208  // Find single closest strike to spot
15209  let closestExpSpot = null;
15210  if (spot > 0 && allStrikes.length > 0) {
15211    let minDist = Infinity;
15212    for (const s of allStrikes) {
15213      const d = Math.abs(s - spot);
15214      if (d < minDist) { minDist = d; closestExpSpot = s; }
15215    }
15216  }
15217
15218  // Only the top 3 highest-positive and top 3 most-negative cells across ALL expiries
15219  // get a bright color — everything else stays muted so the highs are easy to spot.
15220  const _cells = [];
15221  for (const s of allStrikes) {
15222    for (const e of expiries) {
15223      const v = map[s]?.[e];
15224      if (v != null) _cells.push({ key: s + '|' + e, v });
15225    }
15226  }
15227  const topGreen = new Set(_cells.filter(c => c.v > 0).sort((a, b) => b.v - a.v).slice(0, 3).map(c => c.key));
15228  const topRed = new Set(_cells.filter(c => c.v < 0).sort((a, b) => a.v - b.v).slice(0, 3).map(c => c.key));
15229  // Per-expiry leaders. With only 3+3 globally-bright cells, adding expiry columns left most of
15230  // them with nothing readable - the far-dated columns are smaller in absolute terms and never
15231  // win a global slot. Each column now marks its OWN biggest positive and negative cell at
15232  // medium brightness, so every expiry says something without competing with the global tops.
15233  const expTopGreen = new Set(), expTopRed = new Set();
15234  for (const e of expiries) {
15235    let bg = null, br = null;
15236    for (const s of allStrikes) {
15237      const v = map[s] && map[s][e];
15238      if (v == null) continue;
15239      if (v > 0 && (bg === null || v > bg.v)) bg = { k: s + '|' + e, v };
15240      if (v < 0 && (br === null || v < br.v)) br = { k: s + '|' + e, v };
15241    }
15242    if (bg) expTopGreen.add(bg.k);
15243    if (br) expTopRed.add(br.k);
15244  }
15245
15246  // FLIP on the heatmap (charm 2026-08-31, extended to GEX 2026-09-01 'only see flip on charm'):
15247  // interpolated zero-cross of the FRONT-expiry column nearest spot — the map[] values are already
15248  // the current metric's, so the same math marks the Charm Flip on CHARM and the front-expiry
15249  // Gamma Flip on GEX. Dashed seam under the last row above it + a FLIP chip on the info line.
15250  let _chFlip = null, _flipUnder = null;
15251  const _flipMetric = (typeof currentMetric !== 'undefined') ? currentMetric : 'gex';
15252  if (_flipMetric === 'charm' && spot > 0 && expiries.length) {
15253    // CHARM flip = per-strike net-charm zero-cross nearest spot (charm runs +above / −below spot).
15254    try {
15255      const e0 = expiries[0];
15256      const cr = allStrikes.map(s => ({ k: s, v: (map[s] && map[s][e0]) || 0 })).filter(r => Math.abs(r.v) > 0.5).sort((a, b) => a.k - b.k);
15257      let bd = Infinity;
15258      for (let i = 1; i < cr.length; i++) {
15259        const a = cr[i - 1], b = cr[i];
15260        const cross = (a.v < 0 && b.v >= 0) || (a.v > 0 && b.v <= 0); if (!cross || a.v === b.v) continue;
15261        const t = (0 - a.v) / (b.v - a.v); const x = a.k + t * (b.k - a.k); const d = Math.abs(x - spot);
15262        if (d < bd) { bd = d; _chFlip = Math.round(x * 100) / 100; }
15263      }
15264      if (_chFlip != null) { const abv = allStrikes.filter(s => s > _chFlip); if (abv.length) _flipUnder = Math.min(...abv); }
15265    } catch (e) {}
15266  } else if (_flipMetric === 'gex' && spot > 0 && expiries.length) {
15267    // GEX flip = CUMULATIVE net-GEX zero-cross — the true gamma flip, IDENTICAL to the grid + chart
15268    // (gammaFlip). The per-strike method above (right for charm) computed a different level, so the
15269    // heatmap FLIP disagreed with the grid/chart on GEX (user 2026-09-02: heatmap 500.5 vs grid
15270    // 481.45). Same ±30 window + noise floor the grid applies, so both land on the exact same price.
15271    try {
15272      const e0 = expiries[0];
15273      let rows = allStrikes.map(s => ({ strike: s, netGex: (map[s] && map[s][e0]) || 0 })).filter(r => Math.abs(r.netGex) > 0);
15274      const ab = rows.filter(r => r.strike >= spot).sort((a, b) =>
15274 a.strike - b.strike).slice(0, 30);
15275      const be = rows.filter(r => r.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
15276      rows = [...ab, ...be];
15277      if (rows.length) { const mx = Math.max(...rows.map(r => Math.abs(r.netGex))); rows = rows.filter(r => Math.abs(r.netGex) > mx * 0.001); }
15278      const gf = (typeof gammaFlip === 'function') ? gammaFlip(rows, spot) : null;
15279      if (gf != null) { _chFlip = Math.round(gf * 100) / 100; const abv = allStrikes.filter(s => s > _chFlip); if (abv.length) _flipUnder = Math.min(...abv); }
15280    } catch (e) {}
15281  }
15282  // Info-line extremes (premium+comped, 2026-08-31): the single highest-positive and most-negative
15283  // cell of the CURRENT metric across the whole grid - strike, expiry, value - so the
15284  // ticker's peak concentration reads at a glance without scanning the table.
15285  try {
15286    if (infoEl && typeof isPaidUser !== 'undefined' && isPaidUser && _cells.length) {
15287      const _tg = _cells.filter(c => c.v > 0).sort((a, b) => b.v - a.v)[0];
15288      const _tr = _cells.filter(c => c.v < 0).sort((a, b) => a.v - b.v)[0];
15289      const _cx2 = (typeof currentMetric !== 'undefined' && currentMetric === 'charm');
15290      const _fx = k => { const q = k.split('|'); let d = q[1].slice(5).replace('-', '/');
15291                         if (_wsC) d = d.replace(/^0/, '');            // 09/18 -> 9/18
15292                         return q[0] + ' ' + d; };
15293      // in a tile: one fewer significant figure, and a B rather than four digits of M
15294      const _sv = v => { if (!_wsC) return fmtVal(v);
15295        const a = Math.abs(v), sg = v < 0 ? '-' : '+';
15296        return sg + (a >= 1e9 ? '$' + (a / 1e9).toFixed(1) + 'B'
15297                   : a >= 1e6 ? '$' + Math.round(a / 1e6) + 'M'
15298                              : '$' + Math.round(a / 1e3) + 'K'); };
15299      const _sz = _wsC ? ';font-size:9.5px' : '';
15300      const _mg = _wsC ? '8px' : '14px', _mg2 = _wsC ? '7px' : '12px';
15301      let _ex = '';
15302      if (_tg) _ex += '<span style="margin-left:' + _mg + ';font-weight:700;white-space:nowrap' + _sz + ';color:' + (_cx2 ? '#22d3ee' : '#4ade80') + '" title="Biggest positive cell on the grid">\u25B2 ' + (_wsC ? 'HI ' : 'HIGH ') + _sv(_tg.v) + ' @ ' + _fx(_tg.key) + '</span>';
15303      if (_tr) _ex += '<span style="margin-left:' + _mg2 + ';font-weight:700;white-space:nowrap' + _sz + ';color:' + (_cx2 ? '#f59e0b' : '#f87171') + '" title="Biggest negative cell on the grid">\u25BC ' + (_wsC ? 'LO ' : 'LOW ') + _sv(_tr.v) + ' @ ' + _fx(_tr.key) + '</span>';
15304      if (_chFlip != null) _ex += '<span title="' + (_cx2
15305        ? 'Charm Flip \u2014 front-expiry net charm crosses zero here (the dashed seam in the grid). Above it the cyan block pulls price up; below it the amber shelf drags it down.'
15306        : 'Gamma Flip (front expiry) \u2014 net GEX crosses zero at the dashed seam. Price ABOVE it = dealers dampen moves (fade extremes); BELOW it = dealers amplify (respect momentum).')
15307        + '" style="margin-left:' + _mg2 + ';font-weight:800;white-space:nowrap' + _sz + ';color:#e2e8f0;border:1px dashed rgba(226,232,240,.7);border-radius:5px;padding:' + (_wsC ? '0 5px' : '1px 7px') + '">FLIP ' + _chFlip + '</span>';
15308      if (_ex) infoEl.innerHTML += '<span style="color:#64748b;margin-left:' + _mg + '">|</span>' + _ex;
15309    }
15310  } catch (e) {}
15311
15312  for (const strike of allStrikes) {
15313    const isSpot = spot > 0 && strike === closestExpSpot;
15314    const isKing = kingStrike && strike === kingStrike;
15315    const isCallWall = callWallStrike && strike === callWallStrike;
15316    const isPutWall = putWallStrike && strike === putWallStrike;
15317    const isVolNode = volNodeStrike && strike === volNodeStrike;
15318
15319    let rowClass = '';
15320    if (isSpot) rowClass = 'spot-row';
15321    const _isFlipRow = (_flipUnder != null && strike === _flipUnder);
15322    if (_isFlipRow) rowClass += (rowClass ? ' ' : '') + 'ch-flip-row';
15323
15324    let tdStyle = '';
15325    let rowBg = '';
15326    let label = '';
15327
15328    if (isKing) {
15329      // The King line was barely distinguishable from the walls at 0.1/0.05. Lift it in its own
15330      // colour and add a gold rail so it reads as THE level, not just another highlighted row.
15331      tdStyle = 'color:#fde047;font-weight:800;background:rgba(234,179,8,0.30);box-shadow:inset 3px 0 0 #facc15;text-shadow:0 0 10px rgba(250,204,21,.45)';
15332      // Row wash only on the ALL basis. On 0DTE/WEEK the gold lands on the one expiry cell below.
15333      rowBg = basisExp ? '' : 'background:rgba(234,179,8,0.14);box-shadow:inset 0 1px 0 rgba(250,204,21,.35),inset 0 -1px 0 rgba(250,204,21,.35)';
15334      label = '👑 ';
15335    } else if (isSpot) {
15336      tdStyle = 'color:#fff;font-weight:700;background:rgba(255,255,255,0.12)';
15337      label = '► ';
15338    } else if (isCallWall) {
15339      const _cx = (currentMetric === 'charm');
15340      tdStyle = _cx ? 'color:#22d3ee;font-weight:700;background:rgba(34,211,238,0.1);box-shadow:inset 3px 0 0 #22d3ee' : 'color:var(--green);font-weight:700;background:rgba(34,197,94,0.1);box-shadow:inset 3px 0 0 #22c55e';
15341      rowBg = basisExp ? '' : (_cx ? 'background:rgba(34,211,238,0.04)' : 'background:rgba(34,197,94,0.04)');
15342      label = '📈 ';
15343    } else if (isPutWall) {
15344      const _cx = (currentMetric === 'charm');
15345      tdStyle = _cx ? 'color:#f59e0b;font-weight:700;background:rgba(245,158,11,0.1);box-shadow:inset 3px 0 0 #f59e0b' : 'color:var(--red);font-weight:700;background:rgba(239,68,68,0.1);box-shadow:inset 3px 0 0 #ef4444';
15346      rowBg = basisExp ? '' : (_cx ? 'background:rgba(245,158,11,0.04)' : 'background:rgba(239,68,68,0.04)');
15347      label = '📉 ';
15348    } else if (isVolNode) {
15349      tdStyle = 'color:#3b82f6;font-weight:700;background:rgba(59,130,246,0.12);box-shadow:inset 3px 0 0 #3b82f6';
15350      rowBg = 'background:rgba(59,130,246,0.04)';
15351      label = '🔵 ';
15352    }
15353
15354    html += `<tr class="${rowClass}" style="${rowBg}">`;
15355    html += `<td data-hy="${strike}" style="${tdStyle}">${label}${strike}${_isFlipRow ? '<span title="' + (currentMetric === 'charm' ? 'Charm' : 'Gamma') + ' Flip ' + _chFlip + ' sits at the dashed line below this row" style="font-size:9px;font-weight:800;color:#e2e8f0;margin-left:4px;letter-spacing:.5px">FLIP</span>' : ''}</td>`;   /* data-hy: the Gamma Wave reads row y off these */
15356
15357    for (const e of expiries) {
15358      const val = map[strike]?.[e];
15359      if (!val || Math.abs(val) < 0.5) {
15360        // No gamma/vex/charm here -> stays BLACK on purpose (user 2026-08-29); only the
15361        // seam borders drop in admin mode so the black is clean, not gridded.
15362        html += (typeof isPaidUser !== 'undefined' && isPaidUser) ? '<td style="border-bottom:none;border-right:none"></td>' : '<td></td>';
15363        if (_hv) html += _hvCellFor(_hv, e, strike);   // VOL right after this expiry's GEX cell
15364        continue;
15365      }
15366
15367      const colMax = maxPerExpiry[e] || 1;
15368      const intensity = Math.abs(val) / colMax;
15369      const isPos = val >= 0;
15370      const barWidth = Math.max(intensity * 100, 8);
15371
15372      // Only the two global extremes are bright; every other cell is a muted, uniform
15373      // tint (magnitude still readable via bar width) so the highs are easy to spot.
15374      const key = strike + '|' + e;
15375      const isTopGreen = topGreen.has(key);
15376      const isTopRed = topRed.has(key);
15377      let barBg, textColor = '#cbd5e1', weight = 500, star = '';
15378      const _cx = (currentMetric === 'charm'); // charm → cyan/amber, else green/red
15379      // Charm nearest-expiry (0DTE) column: brightness scales smoothly with magnitude so the
15380      // shifting levels are easy to rank — instead of only the top-3 being bright.
15381      const isDteCol = _cx && (e === expiries[0]);
15382      const isKingCell = basisExp && isKing && e === basisExp;
15383      const isCWCell = basisExp && isCallWall && e === basisExp;
15384      const isPWCell = basisExp && isPutWall && e === basisExp;
15385      if (isKingCell) {
15386        // The King, on the exact expiry the level was computed from.
15387        barBg = 'rgba(234,179,8,0.88)'; textColor = '#1a1204'; weight = 800;
15388      } else if (isCWCell) {
15389        barBg = _cx ? 'rgba(34,211,238,0.88)' : 'rgba(34,197,94,0.88)'; textColor = _cx ? '#03141a' : '#04140a'; weight = 800;
15390      } else if (isPWCell) {
15391        barBg = _cx ? 'rgba(245,158,11,0.88)' : 'rgba(239,68,68,0.88)'; textColor = _cx ? '#1a1204' : '#1a0404'; weight = 800;
15392      } else if (isDteCol) {
15393        const op = 0.10 + intensity * 0.85;   // biggest → bright, small → faint
15394        barBg = isPos ? `rgba(34,211,238,${op})` : `rgba(245,158,11,${op})`;
15395        if (intensity > 0.55) { textColor = isPos ? '#03141a' : '#1a1204'; weight = 800; }
15396        if (isTopGreen || isTopRed) star = ' ★';
15397      } else if (isTopGreen) {
15398        barBg = _cx ? 'rgba(34,211,238,0.95)' : 'rgba(34,197,94,0.95)'; textColor = _cx ? '#03141a' : '#04140a'; weight = 800; star = ' ★';
15399      } else if (isTopRed) {
15400        barBg = _cx ? 'rgba(245,158,11,0.95)' : 'rgba(239,68,68,0.95)'; textColor = _cx ? '#1a1204' : '#1a0404'; weight = 800; star = ' ★';
15401      } else if (expTopGreen.has(key)) {
15402        // This column's own high. Bright enough to find at a glance, clearly below a global top.
15403        barBg = _cx ? 'rgba(34,211,238,0.50)' : 'rgba(34,197,94,0.50)'; textColor = '#f1f5f9'; weight = 700; star = ' ▲';
15404      } else if (expTopRed.has(key)) {
15405        barBg = _cx ? 'rgba(245,158,11,0.50)' : 'rgba(239,68,68,0.50)'; textColor = '#f1f5f9'; weight = 700; star = ' ▼';
15406      } else {
15407        // Everything else ramps with magnitude instead of sitting at a flat 0.13, so the shape of
15408        // each column is readable rather than uniform.
15409        const op = 0.09 + intensity * 0.28;
15410        barBg = isPos
15411          ? (_cx ? 'rgba(34,211,238,' + op.toFixed(3) + ')' : 'rgba(34,197,94,' + op.toFixed(3) + ')')
15412          : (_cx ? 'rgba(245,158,11,' + op.toFixed(3) + ')' : 'rgba(239,68,68,' + op.toFixed(3) + ')');
15413        if (intensity > 0.6) { textColor = '#e2e8f0'; weight = 600; }
15414      }
15415
15416      // UNIFORM WASH heatmap (user-approved sample A2/C, 2026-08-29 - ADMIN TEST).
15417      // Every data cell wears the same faint sign wash; the NUMBER's ink scales with
15418      // size; only the grid top-3 (glowing pills + star), each column's high/low
15419      // (outlined tint + arrow) and the King/wall level cells get real color. TINT
15420      // scales the wash, BARS layers its meter over it, the % toggle appends each
15421      // cell's share of its column's biggest print. Members keep the classic renderer
15422      // in the else-branch, byte-identical.
15423      let _v2Html = null;
15424      if (typeof isPaidUser !== 'undefined' && isPaidUser) {
15425        const _tb2 = gfGridTint === 'bold' ? 1.8 : (gfGridTint === 'soft' ? 1.4 : 1);
15426        const _rgb = _cx ? (isPos ? '34,211,238' : '245,158,11') : (isPos ? '34,197,94' : '239,68,68');
15427        const _mag = Math.min(1, intensity);
15428        const _isTop = isTopGreen || isTopRed;
15429        const _isCol2 = expTopGreen.has(key) || expTopRed.has(key);
15430        const _isLvl = isKingCell || isCWCell || isPWCell;
15431        const _washBase = 'rgba(' + _rgb + ',' + (0.075 * _tb2).toFixed(3) + ')';
15432        let _bg2 = _washBase;
15433        let _ink = _cx
15434          ? (isPos ? 'rgba(150,235,250,' : 'rgba(253,215,160,') + (0.62 + _mag * 0.38).toFixed(2) + ')'
15435          : (isPos ? 'rgba(160,240,190,' : 'rgba(252,180,180,') + (0.62 + _mag * 0.38).toFixed(2) + ')';
15436        let _w2 = _mag > 0.45 ? 700 : 500, _mk = '', _extra = '', _meter = '';
15437        const _mA = _isCol2 ? 0.55 : 0.30;
15438        const _wp = (Math.max(0.05, _mag) * 100).toFixed(0);
15439        if (_isLvl) { _bg2 = barBg; _ink = textColor; _w2 = 800; }
15440        else if (_isTop) {
15441          _bg2 = _cx ? (isPos ? 'linear-gradient(90deg,#0891b2,#22d3ee)' : 'linear-gradient(90deg,#d97706,#f59e0b)')
15442                     : (isPos ? 'linear-gradient(90deg,#16a34a,#22c55e)' : 'linear-gradient(90deg,#dc2626,#ef4444)');
15443          _ink = isPos ? '#03170a' : '#1c0505'; _w2 = 800; _mk = ' ★';
15444          _extra = 'border-radius:5px;margin:2px 3px;box-shadow:0 0 12px rgba(' + _rgb + ',.45);';
15445        } else if (_isCol2) {
15446          _bg2 = 'rgba(' + _rgb + ',' + Math.min(0.45, 0.30 * _tb2).toFixed(2) + ')';
15447          _ink = isPos ? '#d9fbe5' : '#ffe1e1'; _w2 = 700; _mk = isPos ? ' ▲' : ' ▼';
15448          _extra = 'box-shadow:inset 0 0 0 1px rgba(' + _rgb + ',.35);';
15449        }
15450        if (!_isTop && !_isLvl && gfGridBars !== 'original') {
15451          if (gfGridBars === 'taper') _meter = '<div style="position:absolute;
15451left:0;top:2px;bottom:2px;width:' + _wp + '%;border-radius:0 5px 5px 0;background:linear-gradient(90deg,rgba(' + _rgb + ',.04),rgba(' + _rgb + ',' + _mA + '))"></div>';
15452          else if (gfGridBars === 'glow') _meter = '<div style="position:absolute;left:4px;top:50%;height:3px;transform:translateY(-50%);width:calc(' + _wp + '% - 8px);border-radius:2px;background:rgb(' + _rgb + ');opacity:' + (0.3 + _mag * 0.6).toFixed(2) + ';box-shadow:0 0 ' + Math.round(3 + _mag * 9) + 'px rgba(' + _rgb + ',.8)"></div>';
15453          else if (gfGridBars === 'segments') { let _sg2 = ''; const _n2 = Math.max(1, Math.round(_mag * 10)); for (let _i2 = 0; _i2 < 10; _i2++) _sg2 += '<i style="flex:1;border-radius:2px;background:' + (_i2 < _n2 ? 'rgba(' + _rgb + ',' + (0.22 + 0.55 * (_i2 / 9)).toFixed(2) + ')' : 'rgba(148,163,184,.05)') + '"></i>'; _meter = '<div style="position:absolute;left:4px;right:4px;top:6px;bottom:6px;display:flex;gap:2px">' + _sg2 + '</div>'; }
15454          else if (gfGridBars === 'track') _meter = '<div style="position:absolute;left:4px;right:4px;top:50%;height:6px;transform:translateY(-50%);background:rgba(148,163,184,.07);border-radius:3px"><div style="position:absolute;left:0;top:0;bottom:0;width:' + _wp + '%;border-radius:3px;background:rgba(' + _rgb + ',' + _mA + ')"></div></div>';
15455          // SPLIT (user 2026-09-02, landing-page look): green (positive) fills from the LEFT edge,
15456          // red (negative) from the RIGHT edge. Bold, solid-ish bar on a NEUTRAL cell (the wash is
15457          // suppressed below) so it clearly reads as starting from the edge. Full cell width.
15458          else if (gfGridBars === 'split') _meter = '<div style="position:absolute;' + (isPos ? 'left:0;border-radius:0 3px 3px 0' : 'right:0;border-radius:3px 0 0 3px') + ';top:2px;bottom:2px;width:' + (Math.max(4, _mag * 100)).toFixed(0) + '%;background:rgba(' + _rgb + ',' + Math.min(0.9, 0.3 + _mag * 0.6).toFixed(2) + ')"></div>';
15459        }
15460        // SPLIT: strip the full-cell wash on regular + column-high/low cells so the diverging bar
15461        // is the only fill and clearly starts from its edge (level/top cells keep their treatment).
15462        if (gfGridBars === 'split' && !_isLvl && !_isTop) { _bg2 = 'transparent'; _ink = isPos ? '#a7f3c4' : '#fca5a5'; _extra = ''; }
15463        const _pctH = gfHeatPct ? '<span style="opacity:.8;font-size:9.5px;margin-left:5px;font-weight:600">' + Math.round(_mag * 100) + '%</span>' : '';
15464        // Crisp ink on pills / level cells - NO text-shadow there (dark ink over a dark
15465        // shadow reads blurry, user caught it in the sample); shadow only over meters.
15466        const _ts2 = (_isTop || _isLvl) ? '' : 'text-shadow:0 1px 3px rgba(0,0,0,.85);';
15467        _v2Html = '<td style="position:relative;padding:0;background:' + (_isTop ? _washBase : _bg2) + ';border-bottom:none;border-right:none"><div style="position:relative;height:100%;display:flex;align-items:center;padding:0 8px;background:' + (_isTop ? _bg2 : 'transparent') + ';color:' + _ink + ';font-weight:' + _w2 + ';font-size:11px;font-family:\'Segoe UI\',monospace;white-space:nowrap;' + _extra + '">' + _meter + '<span style="position:relative;z-index:2;' + _ts2 + '">' + fmtVal(val) + _mk + _pctH + '</span></div></td>';
15468      }
15469      if (_v2Html !== null) { html += _v2Html; }
15470      else {
15471      html += `<td style="position:relative;padding:0">
15472        <div style="
15473          background:${barBg};
15474          width:${barWidth}%;
15475          min-width:fit-content;
15476          height:100%;
15477          padding:4px 8px;
15478          display:flex;
15479          align-items:center;
15480          font-size:11px;
15481          font-weight:${weight};
15482          color:${textColor};
15483          font-family:'Segoe UI',monospace;
15484          white-space:nowrap;
15485          border-radius:2px;
15486        "><span style="position:relative;z-index:2">${fmtVal(val)}${star}</span></div>
15487      </td>`;
15488      }
15489      if (_hv) html += _hvCellFor(_hv, e, strike);   // VOL right after this expiry's GEX cell
15490    }
15491    html += '</tr>';
15492  }
15493
15494  html += '</tbody></table>';
15495  content.innerHTML = html;
15496  // Everything the wave needs, off the same inputs the table was built from - so it can never
15497  // disagree with the cells it is drawn over.
15498  try { window._hwData = { tk: ticker, spot, strikes: allStrikes, expiries, map, mode }; } catch (e) {}
15499  try { _syncHeatWaveBtn(); } catch (e) {}
15500  // DIRECT, NOT requestAnimationFrame. content.innerHTML above wipes the overlay along with the old
15501  // table, and rAF does not run in a BACKGROUND TAB - so with the dashboard left open behind another
15502  // tab (which is how it is normally used) every silent refresh erased the wave and nothing brought
15503  // it back until the tab was looked at again. _hwDraw measures with getBoundingClientRect, which
15504  // forces layout synchronously, so there was never anything to wait a frame for.
15505  try { if ((gfHeatWave || gfHeatBucket) && mode === 'gex') _hwDraw(); } catch (e) {}
15506
15507  // Scroll to spot price row (skip during silent background refresh)
15508  if (!window._gexSilentRefresh) setTimeout(() => {
15509    const spotRow = content.querySelector('tr.spot-row');
15510    if (spotRow) {
15511      spotRow.scrollIntoView({ block: 'center', behavior: 'smooth' });
15512    }
15513  }, 100);
15514}
15515
15516// ══════════════════════════════════════════════
15517// ── GEX CHARTS VIEW — Candlestick + GEX Lines
15518// ══════════════════════════════════════════════
15519
15520// Proxy-ticker badge (ES → SPX): shows that the chart is candles for the future with the SPX cash
15521// GEX levels drawn on, shifted by the live basis. Injected into the chart header next to the spot.
15522const PROXY_UNDER = { ES: 'SPX' };
15523function _updateProxyBadge(ticker) {
15524  const hdr = document.getElementById('chartHeader'); if (!hdr) return;
15525  let b = document.getElementById('chartProxyBadge');
15526  const under = PROXY_UNDER[(ticker || '').toUpperCase()];
15527  if (!under) { if (b) b.style.display = 'none'; return; }
15528  if (!b) {
15529    b = document.createElement('span'); b.id = 'chartProxyBadge';
15530    b.style.cssText = 'font-size:11px;font-weight:700;padding:2px 8px;border-radius:5px;background:rgba(56,189,248,.12);border:1px solid #38bdf8;color:#7dd3fc;white-space:nowrap';
15531    const spot = document.getElementById('chartSpot');
15532    if (spot && spot.parentNode) spot.parentNode.insertBefore(b, spot.nextSibling); else hdr.appendChild(b);
15533  }
15534  b.style.display = '';
15535  b.textContent = under + ' levels · basis —';
15536  b.title = 'Futures chart: ' + ticker + ' candles with ' + under + ' cash GEX levels drawn on, shifted by the ES→' + under + ' basis (measured at the last common bar).';
15537  fetch('/api/proxy-basis/' + encodeURIComponent(ticker)).then(r => r.json()).then(j => {
15538    if (!j || !j.proxy) return;
15539    if (document.getElementById('chartProxyBadge') !== b) return;
15540    const sign = j.basis >= 0 ? '+' : '';
15541    b.textContent = under + ' levels · basis ' + sign + (Number(j.basis) || 0).toFixed(1);
15542  }).catch(() => {});
15543}
15544function buildChartTickers() {
15545  const el = document.getElementById('chartTickers');
15546  if (!el) return;
15547  // ES (E-mini S&P 500 future) is chart-only: it borrows SPX's GEX levels (basis-shifted) and trades
15548  // ~24/5. Not in WATCHLIST (grid/heatmap stay equities); it floats in the bar at a draggable saved index.
15549  const _base = [...new Set(Object.values(WATCHLIST).flat())].filter(t => t !== 'ES');
15550  const _pos = Math.max(0, Math.min(esChartPos, _base.length));
15551  const allTickers = [..._base.slice(0, _pos), 'ES', ..._base.slice(_pos)];
15552  let html = '';
15553  for (const t of allTickers) {
15554    const isActive = t === chartTicker;
15555    html += '<button class="exp-ticker-btn ' + (isActive ? 'active' : '') + (demoLocked(t) ? ' demo-locked-btn' : '') + '" id="chart-btn-' + t + '" data-ticker="' + t + '"' + (IS_DEMO ? '' : ' draggable="true"') + ' title="' + (demoLocked(t) ? 'Locked — sign up to unlock' : 'Drag to reorder · right-click to delete') + '">' + (demoLocked(t) ? '🔒 ' : '') + t + '</button>';
15556  }
15557  el.innerHTML = html;
15558
15559  for (const t of allTickers) {
15560    const btn = document.getElementById('chart-btn-' + t);
15561    if (btn) {
15562      const ticker = t;
15563      btn.addEventListener('click', function() {
15564        if (demoLocked(ticker)) { window.location.href = '/register'; return; }
15565        // Ghost Desk: these buttons bypass selectTicker and set chartTicker directly, so the
15566        // focus routing HAS to be repeated here — without it, picking a ticker while chart 2 is
15567        // focused silently retickered chart 1 instead (user report 2026-08-07).
15568        if (typeof _mRouteTicker === 'function' && _mRouteTicker(ticker)) return;
15569        // Independent per-view ticker — manual pick pins this window (unlinks from TradingView).
15570        chartTicker = ticker;
15571        if (typeof nfUnlinkTvSync === 'function') nfUnlinkTvSync(); else tvSyncPaused = true;
15572        syncActiveGroup(ticker);
15573        buildChartTickers();
15574        loadGEXChart(ticker);
15575      });
15576      // Warm this ticker's candles + levels on hover so the click paints instantly.
15577      btn.addEventListener('mouseenter', function() {
15578        clearTimeout(_chartHoverPrefetchTimer);
15579        _chartHoverPrefetchTimer = setTimeout(function() { prefetchChartTicker(ticker); }, 120);
15580      });
15581      btn.addEventListener('mouseleave', function() { clearTimeout(_chartHoverPrefetchTimer); });
15582    }
15583  }
15584  enableButtonDrag('chartTickers', buildChartTickers);
15585  gfWireStripFold('chartTickers');
15586}
15587
15588// Persistent chart instance so background refresh keeps your pan/zoom position.
15589let chartObj = null, chartSeries = null, chartPriceLines = [], charmPriceLines = [], modePriceLines = [], gexLevelLines = [], chartKey = '';
15590let chartCurrentPrice = 0;
15590 // current price the GEX panel aligns its spot row to
15591let _chartPriceLine = null; // ref to the white current-price line (for live updates)
15592// Caches so switching timeframe is fast: GEX/spot levels don't change with timeframe
15593// (cache per ticker), and recently-loaded candles are reused (cache per ticker|tf).
15594const chartLevelCache = {};  // ticker -> { gexJson, spotJson, t }
15595const chartCandleCache = {}; // ticker|tf -> { candleJson, t }
15596// Intraday charts need fresh candles fast so a new bar loads right after the period closes
15597// (5-min client cache made the next 15m/5m candle lag). Daily+ barely change intraday.
15598function _candleCacheTtl(){ return (typeof INTRADAY_TFS !== 'undefined' && INTRADAY_TFS.includes(currentTimeframe)) ? 20000 : 300000; }
15599const chartContractsCache = {}; // ticker -> { arr, t } — Vol Node, survives level-cache refreshes
15600let _lastChartLiveRefresh = 0; // throttle for live (SSE) GEX-level refresh on the chart
15601let _lastHeatmapLiveRefresh = 0; // throttle for live (SSE) refresh on the Heatmap view
15602
15603// ── Hover prefetch: warm a ticker's candles + GEX/spot on mouse-over so the CLICK renders
15604// instantly (candles are the only cold, blocking fetch on a first switch). Debounced so a
15605// quick sweep across buttons doesn't fire, guarded so we never double-fetch or hit locked tickers.
15606let _chartHoverPrefetchTimer = null;
15607const _chartPrefetchInflight = new Set();
15608function prefetchChartTicker(t) {
15609  if (!t || (typeof demoLocked === 'function' && demoLocked(t))) return;
15610  const now = Date.now();
15611  const tfKey = t + '|' + currentTimeframe;
15612  const cc = chartCandleCache[tfKey];
15613  if ((!cc || now - cc.t > 300000) && !_chartPrefetchInflight.has('c:' + tfKey)) {
15614    _chartPrefetchInflight.add('c:' + tfKey);
15615    fetch('/api/candles/' + t + '?range=' + (tfMap[currentTimeframe]?.range || '6mo') + '&interval=' + (tfMap[currentTimeframe]?.interval || '1d'))
15616      .then(r => r.json()).then(j => { if (j && j.chart && j.chart.result && j.chart.result[0] && j.chart.result[0].timestamp) chartCandleCache[tfKey] = { candleJson: j, t: Date.now() }; })
15617      .catch(() => {}).finally(() => _chartPrefetchInflight.delete('c:' + tfKey));
15618  }
15619  const lc = chartLevelCache[t];
15620  if ((!lc || now - lc.t > 60000) && !_chartPrefetchInflight.has('l:' + t)) {
15621    _chartPrefetchInflight.add('l:' + t);
15622    Promise.all([
15623      fetch('/api/gex/strike/' + t).then(r => r.json()),
15624      fetch('/api/spot/' + t).then(r => r.json()),
15625    ]).then(([gexJson, spotJson]) => { chartLevelCache[t] = { gexJson, spotJson, t: Date.now() }; })
15626      .catch(() => {}).finally(() => _chartPrefetchInflight.delete('l:' + t));
15627  }
15628  // Also warm the Vol Node (contracts) + Active Node so they render WITH the ladder on click
15629  // instead of popping in a moment later.
15630  const ctc = chartContractsCache[t];
15631  if ((!ctc || now - ctc.t > 25000) && !_chartPrefetchInflight.has('ct:' + t)) {
15632    _chartPrefetchInflight.add('ct:' + t);
15633    fetch('/api/contracts/' + t).then(r => r.json()).then(cj => { const arr = (cj && (cj.data || cj)) || []; if (arr && arr.length) chartContractsCache[t] = { arr, t: Date.now() }; })
15634      .catch(() => {}).finally(() => _chartPrefetchInflight.delete('ct:' + t));
15635  }
15636  if (typeof fetchActiveNode === 'function' && (!activeNodes[t] || now - (activeNodes[t].t || 0) > 10000)) { fetchActiveNode(t).catch(() => {}); }
15637}
15638
15639// Vertical control over the chart's price scale via the wheel:
15640//   • plain wheel  → PAN the view up / down (move it, keeping zoom the same)
15641//   • Shift+wheel or pinch (Ctrl+wheel) → ZOOM the price scale in / out
15642// Works anywhere over the chart (not just the right axis), so you can scroll to move
15643// up/down without having to grab the price axis. Dragging the axis still works too.
15644function attachPriceAxisZoom(chartEl, chart) {
15645  let top = 0.1, bottom = 0.1;
15646  chartEl.addEventListener('wheel', function(e) {
15647    const rect = chartEl.getBoundingClientRect();
15648    if (rect.right - e.clientX > 64) return; // only act over the ~right 64px (price axis)
15649    e.preventDefault();
15650    e.stopPropagation();
15651    const step = e.deltaY > 0 ? 0.03 : -0.03; // scroll down = zoom out, up = zoom in
15652    top = Math.min(0.45, Math.max(0, top + step));
15653    bottom = Math.min(0.45, Math.max(0, bottom + step));
15654    try { chart.priceScale('right').applyOptions({ scaleMargins: { top: top, bottom: bottom } }); } catch (err) {}
15655    requestAnimationFrame(() => alignChartPanelToPrice(chartCurrentPrice)); // keep panel aligned
15656  }, { passive: false });
15657}
15658
15659// Aggregate intraday candles (unix-second times) into larger OHLC buckets.
15660function aggregateCandles(candles, bucketSec, offsetSec) {
15661  offsetSec = offsetSec || 0;
15662  const out = [];
15663  let cur = null;
15664  for (const c of candles) {
15665    const t = typeof c.time === 'number' ? c.time : 0;
15666    const bucket = Math.floor((t - offsetSec) / bucketSec) * bucketSec + offsetSec;
15667    if (!cur || cur.time !== bucket) {
15668      if (cur) out.push(cur);
15669      cur = { time: bucket, _ts: c._ts, open: c.open, high: c.high, low: c.low, close: c.close };
15670    } else {
15671      cur.high = Math.max(cur.high, c.high);
15672      cur.low = Math.min(cur.low, c.low);
15673      cur.close = c.close;
15674    }
15675  }
15676  if (cur) out.push(cur);
15677  return out;
15678}
15679
15680// Kill bad-tick spikes (thin extended-hours single prints) WITHOUT touching normal
15681// bars: only bars whose total range is an outlier (> factor× median range) get their
15682// wicks-beyond-body trimmed down tight (~1.5× median). A legit big move has a big
15683// BODY and small wick, so its (small) wick-beyond-body isn't trimmed — only true
15684// spikes (tiny body, huge wick) are flattened, matching TradingView.
15685function clampWicks(candles, factor) {
15686  if (candles.length <= 5) return candles;
15687  const ranges = candles.map(c => c.high - c.low).filter(r => r > 0).sort((a, b) => a - b);
15688  const medRange = ranges[Math.floor(ranges.length / 2)] || 0;
15689  if (medRange <= 0) return candles;
15690  const outlier = medRange * factor; // a bar wider than this is a suspected bad tick
15691  const tight = medRange * 1.5;       // trim the spike's wick down to about this
15692  for (const c of candles) {
15693    if ((c.high - c.low) <= outlier) continue; // normal bar — leave its wicks alone
15694    const hiBody = Math.max(c.open, c.close), loBody = Math.min(c.open, c.close);
15695    if (c.high - hiBody > tight) c.high = hiBody + tight;
15696    if (loBody - c.low > tight) c.low = loBody - tight;
15697  }
15698  return candles;
15699}
15700
15701// Build/update the GEX panel docked on the chart's left side — a COMPLETE duplicate
15702// of a GEX view column (ticker/price header, King node row, scrollable strikes),
15703// computed identically to loadGEX so it matches the GEX view 1:1.
15704let chartPanelScrolledTicker = '';
15705// User-scroll latch: once the user scrolls/touches the docked GEX panel, STOP
15706// auto-aligning it to the price line and PRESERVE their position across background
15707// refreshes and live-price ticks. Cleared on a ticker switch → re-centers on spot.
15708let cgpUserScrolled = false;
15709let cgpLastTicker = '';
15710// Proxy tickers (ES) carry `sb` = the original SPX strike. The ladder/header show the round SPX
15711// strike (via _dispStrike), while the chart LINES stay at the shifted ES price so they align with
15712// the candles. _chartLevelUnshift = the basis (shifted − original), 0 for normal tickers.
15713window._chartLevelUnshift = 0;
15714function _dispStrike(v) { if (v == null || !isFinite(v)) return '—'; const u = window._chartLevelUnshift || 0; if (u) return String(Math.round(v - u)); return Number.isInteger(v) ? String(v) : (Math.round(v * 100) / 100).toString(); }   // decimals kept (crypto XRP 1.42, half strikes); the ES proxy still shows the round SPX strike
15715function renderChartGexPanel(content, parsed, spot, ticker, volNode) {
15716  // Detect the proxy basis from the data (a row's shifted strike minus its original SPX strike).
15717  try { const r = (parsed || []).find(s => s && s.sb != null); window._chartLevelUnshift = r ? +(r.strike - r.sb).toFixed(2) : 0; } catch (e) { window._chartLevelUnshift = 0; }
15718  let panel = document.getElementById('chartGexPanel');
15719  if (!panel) {
15720    panel = document.createElement('div');
15721    panel.id = 'chartGexPanel';
15722    content.appendChild(panel);
15723    try { if (typeof _gfLadderPlace === 'function') _gfLadderPlace(); } catch (e) {}   // Desk 2/3: dock it beside the charts
15724    // Restore tab lives on #chartRow (outside the panel) so the panel-body rebuild can't remove it.
15725    if (!document.getElementById('gexRestoreTab')) {
15726      const row = document.getElementById('chartRow');
15727      if (row) {
15728        row.style.position = 'relative';
15729        const tab = document.createElement('button');
15730        tab.id = 'gexRestoreTab'; tab.title = 'Show the GEX ladder'; tab.textContent = '›';
15731        tab.addEventListener('click', e => { e.stopPropagation(); nfGexMinimize(false); });
15732        row.appendChild(tab);
15733      }
15734    }
15735    // Make the strikes list scroll up/down by wheel AND touch/pinch, independently of the
15736    // chart underneath. Listeners are delegated from the panel (the body is rebuilt on every
15737    // refresh). Scrolling here latches cgpUserScrolled so it won't snap back to the price line.
15738    panel.addEventListener('wheel', (e) => {
15739      const body = panel.querySelector('.cgp-body');
15740      if (!body) return;
15741      cgpUserScrolled = true;
15742      body.scrollTop += e.deltaY;          // scroll the panel, not the chart
15743      e.preventDefault();
15744      e.stopPropagation();
15745    }, { passive: false });
15746    // Touch swipe / two-finger (pinch) drag scrolls the panel; stop the gesture from
15747    // reaching the chart's drawing/zoom handlers (touch-action:pan-y does the scrolling).
15748    panel.addEventListener('touchstart', (e) => { if (e.target.closest && e.target.closest('.cgp-body')) e.stopPropagation(); }, { passive: true });
15749    panel.addEventListener('touchmove', (e) => { if (e.target.closest && e.target.closest('.cgp-body')) { cgpUserScrolled = true; e.stopPropagation(); } }, { passive: true });
15750
15751    // Left-click and drag up/down to scroll the panel (grab-to-pan), in addition to the wheel.
15752    let dragBody = null, dragStartY = 0, dragStartScroll = 0;
15753    panel.addEventListener('mousedown', (e) => {
15754      if (e.button !== 0) return;
15755      const body = e.target.closest && e.target.closest('.cgp-body');
15756      if (!body) return;
15757      dragBody = body; dragStartY = e.clientY; dragStartScroll = body.scrollTop;
15758      cgpUserScrolled = true;
15759      body.classList.add('cgp-grabbing');
15760      e.preventDefault(); e.stopPropagation(); // don't let the chart start a selection/drawing
15761    });
15762    document.addEventListener('mousemove', (e) => {
15763      if (!dragBody) return;
15764      dragBody.scrollTop = dragStartScroll - (e.clientY - dragStartY); // drag down → see higher strikes
15765      e.preventDefault();
15766    });
15767    document.addEventListener('mouseup', () => {
15768      if (dragBody) { dragBody.classList.remove('cgp-grabbing'); dragBody = null; }
15769    });
15770  }
15771  // New ticker → release the latch so the panel re-centers on the new spot.
15772  if (ticker !== cgpLastTicker) { cgpUserScrolled = false; cgpLastTicker = ticker; }
15773  // Empty/degenerate data (common for a moment after a server restart, or a hiccup on a
15774  // background refresh). NEVER wipe a panel that already shows good levels — keep the last
15775  // render. Only clear on the very first paint when there's genuinely nothing to show.
15776  if (!parsed || !parsed.length || !spot) {
15777    if (!panel.dataset.filled) panel.innerHTML = '';
15778    return;
15779  }
15780  panel.dataset.filled = '1';
15781
15782  // VOL column (nearest expiry, from cached contracts) + resizable width (user 2026-09-02).
15783  const volMap = gfChartVolOn ? _buildChartVolMap(ticker) : null;
15784  window._chartVolMap = volMap;
15785  window._cgpLast = { content, parsed, spot, ticker, volNode };
15786  panel.style.width = _cgpWidth() + 'px';
15787  panel.classList.toggle('gf-chartvol', !!(gfChartVolOn && volMap));
15788
15789  // Same filtering as the GEX view columns: 30 strikes each side of spot, drop noise.
15790  const above = parsed.filter(s => s.strike >= spot).sort((a, b) => a.strike - b.strike).slice(0, 30);
15791  const below = parsed.filter(s => s.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
15792  let filtered = [...above, ...below];
15793  if (filtered.length) {
15794    const mx = Math.max(...filtered.map(s => Math.abs(s.netGex)));
15795    filtered = filtered.filter(s => Math.abs(s.netGex) > mx * 0.001);
15796  }
15797  filtered.sort((a, b) => b.strike - a.strike); // high to low
15798
15799  // King / walls / total computed from the SAME filtered set as the GEX view.
15800  const { callWall, putWall, kingNode, totalGex } = pickLevels(filtered, spot);
15801  const bias = totalGex >= 0;
15802  const biasPct = kingNode ? ((Math.abs(kingNode.netGex) / Math.max(Math.abs(totalGex), 1)) * 100).toFixed(1) : '0';
15803
15804  const kingHtml = `
15805    <span class="king-dot"></span>
15806    <span class="king-label">King</span>
15807    <span style="color:var(--yellow);font-weight:700;font-size:13px">
15807${kingNode ? _dispStrike(kingNode.strike) : '—'}</span>
15808    <span style="color:var(--dim);font-size:10px">${kingNode ? fmtGex(kingNode.netGex) : ''}</span>
15809    <span class="king-pct" style="color:${bias ? 'var(--green)' : 'var(--red)'}">${biasPct}%</span>
15810    <span style="color:${bias ? 'var(--green)' : 'var(--red)'};font-size:10px">${bias ? '↑' : '↓'}</span>`;
15811
15812  // Rebuilding innerHTML resets the strikes list to scrollTop 0 — if the user has
15813  // scrolled it themselves, capture and restore their exact position.
15814  const oldBody = panel.querySelector('.cgp-body');
15815  const prevScroll = oldBody ? oldBody.scrollTop : 0;
15816  panel.innerHTML =
15817    '<div class="cgp-head"><span class="cgp-tk">' + ticker + ' · ' + currentMetric.toUpperCase() + (volMap ? '<span class="cgp-volh">· VOL ' + volMap.exp.slice(5).replace('-', '/') + '</span>' : '') + '</span>'
15818    + '<span class="cgp-pr">$' + spot.toFixed(2) + '</span></div>'
15819    + '<div class="king-row">' + kingHtml
15820      + (volMap ? '<span style="color:#cbd5e1;font-weight:700;font-size:11px;margin-left:auto;margin-right:0" title="VOL column is on - per-strike call/put volume beside the ladder">Volume</span>' : '')   /* VN label removed here (user 2026-09-05) - the Vol Node still shows in the level strip and on the ladder row */
15821      + ((activeNodes[ticker] && activeNodes[ticker].strike) ? '<span title="Active node — most-traded strike in the last ~30 min" style="color:#22d3ee;font-weight:700;font-size:11px;margin-left:' + (volMap ? '6px' : 'auto') + '">⚡ ' + _dispStrike(activeNodes[ticker].strike) + '</span>' : '')
15822      // Ladder bar style gear (user 2026-09-07): Bars / Tint / Track-Split right on the ladder, same setters as the Grid bar.
15823      + '<span class="cgp-gear" title="Ladder bar style: bars, tint, track / split" style="margin-left:' + ((volMap || (activeNodes[ticker] && activeNodes[ticker].strike)) ? '6px' : 'auto') + '" onclick="event.stopPropagation();gfLadderGear(this)" onmousedown="event.stopPropagation()"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></span>'
15824    + '</div>'
15825    + '<div class="strikes-list cgp-body">' + buildGexRowsHtml(filtered, spot, callWall, putWall, kingNode, volNode, activeNodes[ticker], undefined, volMap) + '</div>';
15826  if (cgpUserScrolled) {
15827    const newBody = panel.querySelector('.cgp-body');
15828    if (newBody) newBody.scrollTop = prevScroll;
15829  }
15830  // Re-attach the minimize (‹) button — panel.innerHTML above wipes it each render.
15831  const minBtn = document.createElement('button');
15832  minBtn.id = 'gexMinBtn'; minBtn.className = 'gex-min-btn'; minBtn.title = 'Minimize the GEX ladder'; minBtn.textContent = '‹';
15833  minBtn.addEventListener('mousedown', e => e.stopPropagation());
15834  minBtn.addEventListener('click', e => { e.stopPropagation(); nfGexMinimize(true); });
15835  panel.appendChild(minBtn);
15836  // Re-attach the right-edge resize handle (panel.innerHTML wiped it too).
15837  const _rz = document.createElement('div'); _rz.className = 'cgp-resize'; _rz.title = 'Drag to resize the ladder';
15838  _rz.addEventListener('mousedown', _cgpResizeStart);
15839  panel.appendChild(_rz);
15840}
15841
15842// Scroll the GEX panel so its spot (current-price) row lines up vertically with the
15843// chart's current-price line — and keep it aligned as the chart pans/zooms.
15844function alignChartPanelToPrice(price) {
15845  if (!chartSeries || !price) return;
15846  // The user parked the panel where they want it — don't yank it back to the price
15847  // line on refresh/pan/zoom/live ticks. (Released when the ticker changes.)
15848  if (cgpUserScrolled) return;
15849  const panel = document.getElementById('chartGexPanel');
15850  if (!panel) return;
15851  const body = panel.querySelector('.cgp-body');
15852  const spotRow = body && body.querySelector('.strike-row[data-spot="1"]');
15853  if (!body || !spotRow) return;
15854  let y;
15855  try { y = chartSeries.priceToCoordinate(price); } catch (e) { return; }
15856  if (y == null) return;
15857  // y = pixels from the chart top to the price (chart top == panel top == body? no:
15858  // body starts below the header+king row, so subtract body.offsetTop).
15859  const target = spotRow.offsetTop + spotRow.offsetHeight / 2 - (y - body.offsetTop);
15860  const maxScroll = Math.max(0, body.scrollHeight - body.clientHeight);
15861  body.scrollTop = Math.max(0, Math.min(maxScroll, target));
15862}
15863
15864let _chartLoadSeq = 0;
15865async function loadGEXChart(ticker, silent, opts) {
15866  // Latest-wins guard: a newer chart load (user clicked another ticker) supersedes this one,
15867  // so a slow candle response can't render a stale ticker over the new chart. Works in both
15868  // Chart and Ultra (which don't share the same ticker var anymore).
15869  const _chartSeq = silent ? _chartLoadSeq : ++_chartLoadSeq;
15870  // On a real ticker switch, clear the previous ticker's spot so nothing (e.g. sample flow
15871  // strike anchoring) can read a stale price for the new ticker before the chart reloads.
15872  const _tkChanged = !silent && window.chartLoadedTicker && window.chartLoadedTicker !== ticker;
15873  if (_tkChanged) { chartCurrentPrice = 0; }
15874  if (!silent) window.chartLoadedTicker = ticker;
15875  if (!silent) { try { gfTideSync(1, ticker); } catch (e) {} }   // the tide strip under chart 1 follows (My HQ)
15876  // GEX Visual: on a real ticker switch, force a fresh reseed for the NEW ticker's current basis
15877  // (0DTE/ALL/WEEKLY) — otherwise the self-heal seeder can grab the old/partly-loaded columnData and
15878  // paint the wrong (or missing) % bubbles. Same reseed path metric/expiry switches already use.
15879  if (_tkChanged && _gexBubAllowed() && typeof indState !== 'undefined' && indState.gexbub && typeof _gexBubRefreshSoon === 'function') {
15880    try { _gexBubRefreshSoon(); } catch (e) {}
15881  }
15882  // Ghost Mode → sync the other views (grid/heatmap/ultra) to this ticker, like loadExpiryGEX does.
15883  // chartTicker is already set to `ticker` by the caller, so gfSyncTo skips re-loading the chart
15884  // (no double-load); it only updates the other views' tickers + the grid sector. No-op if sync off.
15885  if (!silent && typeof gfSyncTo === 'function') gfSyncTo(ticker);
15886  if (_replayActive && silent) return; // don't let live refresh clobber the replay
15887  // Replay is per-ticker: switching to a DIFFERENT ticker drops out of replay so the new ticker
15888  // loads LIVE (you click Replay again to replay it). Without this, the old ticker's replay
15889  // timer/candles keep driving — and blanking — the newly selected ticker's chart.
15890  if (_replayActive && !silent && ticker !== _replayTicker) exitReplay(true);
15891  const content = document.getElementById('chartContent');
15892  if (typeof _updateProxyBadge === 'function') _updateProxyBadge(ticker);
15893  const _tfKey = ticker + '|' + currentTimeframe;
15894  const _cc = chartCandleCache[_tfKey];
15895  const _haveCached = _cc && Date.now() - _cc.t < 300000;
15896  // Only show the loading flash when we have NO cached candles for this timeframe —
15897  // prefetched timeframes render instantly with no "Loading…".
15898  if (!silent && !_haveCached && chartKey !== _tfKey) {
15899    // Progressive render: keep the GEX ladder panel (painted the moment levels resolve) and show
15900    // the loading text only in the chart area, so the left ladder isn't blanked while candles load.
15901    [...content.children].forEach(c => { if (c.id !== 'chartGexPanel') c.remove(); });
15902    const _ld = document.createElement('div'); _ld.className = 'col-loading'; _ld.textContent = 'Loading chart for ' + ticker + '...';
15903    content.appendChild(_ld);
15904  }
15905
15906  try {
15907    const now = Date.now();
15908    const tfKey = ticker + '|' + currentTimeframe;
15909
15910    // Candles depend on the timeframe; GEX/spot levels don't. Fetch only what's stale.
15911    const candleCached = chartCandleCache[tfKey];
15912    const candleP = (candleCached && now - candleCached.t < _candleCacheTtl())
15913      ? Promise.resolve(candleCached.candleJson)
15914      : fetch('/api/candles/' + ticker + '?range=' + (tfMap[currentTimeframe]?.range || '6mo') + '&interval=' + (tfMap[currentTimeframe]?.interval || '1d'))
15915          .then(r => r.json()).then(j => {
15916            if (j && j.chart && j.chart.result && j.chart.result[0] && j.chart.result[0].timestamp) chartCandleCache[tfKey] = { candleJson: j, t: now };
15917            return j;
15918          });
15919
15920    // ALL / 0DTE / WEEKLY basis — drives the ladder AND the trade plan. CHARM always reads the
15921    // nearest expiry; folding that into _cmode keeps the 60s chartLevelCache keyed correctly
15922    // when the metric flips between GEX/VEX and CHARM.
15923    const _cmode = (currentMetric === 'charm') ? '0dte' : gridExpiryMo
15923de;
15924    const levelCached = chartLevelCache[ticker];
15925    // Only GEX + spot block the chart (King/Call Wall/Put Wall). The Vol-Node
15926    // contracts call is the slowest and isn't pre-warmed, so fetch it in the
15927    // background and reuse it once cached — it just appears a moment later.
15928    // Cache is invalidated when the expiry basis changes (mode mismatch → refetch).
15929    const levelP = (levelCached && levelCached.mode === _cmode && now - levelCached.t < 60000)
15930      ? Promise.resolve(levelCached)
15931      : Promise.all([
15932          _basisGexJson(ticker, _cmode),
15933          fetch('/api/spot/' + ticker).then(r => r.json()),
15934        ]).then(([gexJson, spotJson]) => { const v = { gexJson, spotJson, t: now, mode: _cmode }; chartLevelCache[ticker] = v; return v; });
15935
15936    // ── Progressive render ── GEX/spot come back in ~ms (cached); candles are the slow, blocking
15937    // fetch. Paint the ladder + level labels the instant levels resolve so the left panel and
15938    // King/Call Wall/Put Wall show immediately; the chart canvas fills in when candles arrive.
15939    if (!silent) levelP.then(lv => {
15940      try {
15941        if (_chartSeq !== _chartLoadSeq || chartTicker !== ticker) return; // superseded / switched away
15942        const sArr = (lv.spotJson && (lv.spotJson.data || lv.spotJson)) || [];
15943        let spotE = 0;
15944        if (Array.isArray(sArr)) for (const s of sArr) { const p = parseFloat(s.price || s.underlying_price || 0); if (p > 0) { spotE = p; break; } }
15945        if (!spotE) return; // no spot yet → let the full render (with candle fallback) handle it
15946        const multE = metricMult(spotE);
15947        const strikesE = (lv.gexJson && (lv.gexJson.data || lv.gexJson)) || [];
15948        const parsedE = (Array.isArray(strikesE) ? strikesE : []).map(s => { const st = parseFloat(s.strike || 0); return { strike: st, sb: (s.sb != null ? +s.sb : null), netGex: metricRaw(s) * multE, callGex: metricCall(s) * multE, putGex: metricPut(s) * multE }; }).filter(s => s.strike > 0 && Math.abs(s.netGex) > 0);
15949        if (!parsedE.length) return;
15950        const cArr = (chartContractsCache[ticker] && chartContractsCache[ticker].arr) || [];
15951        const volNodeE = cArr.length ? computeVolNode(cArr, spotE) : null;
15952        renderChartGexPanel(content, parsedE, spotE, ticker, volNodeE);
15953        const aE = parsedE.filter(s => s.strike >= spotE).sort((a, b) => a.strike - b.strike).slice(0, 30);
15954        const bE = parsedE.filter(s => s.strike < spotE).sort((a, b) => b.strike - a.strike).slice(0, 30);
15955        const { callWall, putWall, kingNode, totalGex } = pickLevels([...aE, ...bE], spotE);
15956        const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
15957        setTxt('chartTickerName', ticker); setTxt('chartSpot', '$' + spotE.toFixed(2));
15958        setTxt('chartKing', kingNode ? '👑 King ' + _dispStrike(kingNode.strike) : '');
15959        setTxt('chartCW', callWall ? '📈 Call Wall ' + _dispStrike(callWall.strike) : '');
15960        setTxt('chartPW', putWall ? '📉 Put Wall ' + _dispStrike(putWall.strike) : '');
15961        setTxt('chartVN', volNodeE ? '🔵 Vol Node ' + _dispStrike(volNodeE.strike) 
15961: '');
15962        const anC = activeNodes[ticker]; if (anC && anC.strike) setTxt('chartAN', '⚡ Active ' + anC.strike.toFixed(0)); // cached (prefetched) → shows with the ladder
15963        const cb = document.getElementById('chartBias'); if (cb) cb.style.display='none';   /* regime chip owns the header - no Bullish/Bearish flash on load */
15964        if (false) { cb.textContent = totalGex >= 0 ? '↑ Bullish' : '↓ Bearish'; cb.style.color = totalGex >= 0 ? 'var(--green)' : 'var(--red)'; }
15965      } catch (e) {}
15966    });
15967
15968    const [candleJson, levels] = await Promise.all([candleP, levelP]);
15969    // If the user switched tickers while the above was fetching, this data belongs to the OLD
15970    // ticker — rendering it now would flash a wrong-ticker chart / phantom bar over the new
15971    // one. A fresh loadGEXChart for the current ticker is already running, so just bail.
15972    // AUDIT FIX 2026-08-06: this guard now covers SILENT loads too — a background refresh in
15973    // flight across a ticker switch used to resume and rebuild the chart with the OLD ticker's
15974    // candles (any new user-initiated load bumps _chartLoadSeq, so the stale silent seq fails).
15975    if (_chartSeq !== _chartLoadSeq) return; // a newer chart load superseded this one
15976    // Same for replay: a silent load that was already past its entry guard when replay started
15977    // must not setData live candles over the replay slice.
15978    if (silent && typeof _replayActive !== 'undefined' && _replayActive) return;
15979    if (silent && currentView === 'chart' && typeof chartTicker !== 'undefined' && chartTicker && ticker !== chartTicker) return;
15980    const gexJson = levels.gexJson;
15981    const spotJson = levels.spotJson;
15982
15983    // Vol-Node contracts live in their OWN cache so they survive the gex/spot and live
15984    // refreshes (which delete chartLevelCache). Render the last-known immediately; refresh
15985    // in the background and re-render once if it's the first time, so it never blanks.
15986    let contractsArr = (chartContractsCache[ticker] && chartContractsCache[ticker].arr) || [];
15987    const _cc = chartContractsCache[ticker];
15988    if (!_cc || now - _cc.t > 10000) { // ~10s so Vol Node refreshes live in step with the King/Call/Put nudge
15989      const _first = !_cc;
15990      fetch('/api/contracts/' + ticker).then(r => r.json()).then(cj => {
15991        const arr = (cj && (cj.data || cj)) || [];
15992        if (arr && arr.length) {
15993          chartContractsCache[ticker] = { arr, t: Date.now() };
15994          if (_first && currentView === 'chart' && chartTicker === ticker) loadGEXChart(ticker, true);
15995        }
15996      }).catch(() => {});
15997    }
15998
15999    // Parse candles
16000    let candles = [];
16001    const result = candleJson?.chart?.result?.[0];
16002    if (result && result.timestamp) {
16003      const timestamps = result.timestamp;
16004      const quote = result.indicators?.quote?.[0];
16005      // Yahoo appends a LIVE/forming bar stamped at the current time (e.g. 02:05:25 on a 15m
16006      // chart) in addition to the proper grid bar (02:00) — that shows as a phantom extra
16007      // candle. Snap intraday timestamps to the fetch-interval boundary so the live bar merges
16008      // into its correct slot.
16009      const _fetchInt = (typeof tfMap !== 'undefined' && tfMap[currentTimeframe] && tfMap[currentTimeframe].interval) || '1d';
16010      const _intSecMap = { '1m':60,'2m':120,'5m':300,'15m':900,'30m':1800,'60m':3600,'90m':5400,'1h':3600 };
16011      const _snapSec = _intSecMap[_fetchInt] || 0;
16012      // SPX / NDX 1h: snap to the :30 grid (9:30 ET / 6:30am PT session open) instead of the top
16013      // of the hour, so their bars run 6:30-7:30 like TradingView. Everything else snaps to :00.
16014      const _snapOff = (currentTimeframe === '1h' && ['SPX', 'SPXW', 'NDX', 'NDXP'].includes(String(ticker || '').toUpperCase())) ? 1800 : 0;
16015      if (quote) {
16016        for (let i = 0; i < timestamps.length; i++) {
16017          const o = quote.open?.[i], h = quote.high?.[i], l = quote.low?.[i], c = quote.close?.[i];
16018          const vol = quote.volume?.[i];
16019          if (o != null && h != null && l != null && c != null) {
16020            const isIntraday = INTRADAY_TFS.includes(currentTimeframe);
16021            const rawT = (isIntraday && _snapSec) ? Math.floor((Math.floor(timestamps[i]) - _snapOff) / _snapSec) * _snapSec + _snapOff : Math.floor(timestamps[i]);
16022            const d = new Date(rawT * 1000);
16023            let timeVal;
16024            if (isIntraday) {
16025              // Shift each bar by the user's timezone offset (DST-aware) so the axis
16026              // reads in their tz. lightweight-charts renders the time value as if UTC,
16027              // so adding the offset makes the displayed clock match the chosen zone.
16028              timeVal = rawT + tzOffsetSec(getUserTZ(), rawT * 1000);
16029            } else if (currentTimeframe === 'w') {
16030              // Label each weekly candle with the Monday of its week (UTC-based so the
16031              // local timezone can't shift it a day; current in-progress week snaps back too).
16032              const day = d.getUTCDay();              // 0=Sun .. 6=Sat
16033              const back = day === 0 ? 6 : day - 1;   // days since Monday
16034              const mon = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - back));
16035              timeVal = mon.toISOString().slice(0, 10);
16036            } else if (currentTimeframe === 'mo') {
16037              // Snap each monthly candle to the 1st of its month. Yahoo stamps the
16038              // current in-progress month at the latest date (and emits a duplicate
16039              // "live" bar) — this dates every month at the 1st so they merge cleanly.
16040              timeVal = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-01';
16041            } else {
16042              // daily: use UTC components so the date doesn't shift a day in local tz.
16043              timeVal = d.getUTCFullYear() + '-' + String(d.getUTCMonth()+1).padStart(2,'0') + '-' + String(d.getUTCDate()).padStart(2,'0');
16044            }
16045            // _ts = TRUE UTC unix of the bar — a single time basis shared by ALL timeframes
16046            // so drawings (trendlines) map to the same moment whatever TF you switch to.
16047            candles.push({ time: timeVal, _ts: rawT, open: o, high: h, low: l, close: c, volume: (vol != null ? +vol : 0) });
16048          }
16049        }
16050      }
16051    }
16052    // UW fallback
16053    if (candles.length === 0) {
16054      const uwData = candleJson.data || candleJson || [];
16055      if (Array.isArray(uwData)) {
16056        for (const d of uwData) {
16057          const o = parseFloat(d.open), h = parseFloat(d.high), l = parseFloat(d.low), c = parseFloat(d.close);
16058          const t = d.date || d.timestamp;
16059          if (o && h && l && c && t) {
16060            const _tsv = (typeof t === 'number') ? Math.floor(t) : Math.floor(Date.parse(('' + t).slice(0,10) + 'T00:00:00Z') / 1000);
16061            candles.push({ time: t.slice(0,10), _ts: _tsv, open: o, high: h, low: l, close: c });
16062          }
16063        }
16064        candles.sort((a, b) => a.time.localeCompare(b.time));
16065      }
16066    }
16067
16068    if (candles.length === 0) {
16069      // AUDIT FIX: never blank a LIVE chart from a background refresh — one transient empty
16070      // response used to wipe the whole chart DOM mid-session for up to 60s. Silent refreshes
16071      // keep the existing chart and just skip this cycle.
16072      if (!silent) content.innerHTML = '<div class="col-loading col-error">No chart data for ' + ticker + '</div>';
16073      return;
16074    }
16075
16076    // Merge candles sharing a time key (Yahoo emits a duplicate "live" bar for the
16077    // current week/month, and weekly Monday-snapping can collapse two into one).
16078    // setData() requires strictly unique, ascending times.
16079    if (candles.length > 1) {
16080      const merged = [], idx = new Map();
16081      for (const c of candles) {
16082        if (idx.has(c.time)) {
16083          const m = merged[idx.get(c.time)];
16084          m.high = Math.max(m.high, c.high);
16085          m.low = Math.min(m.low, c.low);
16086          m.close = c.close; // later bar's close wins
16087        } else {
16088          idx.set(c.time, merged.length);
16089          merged.push({ ...c });
16090        }
16091      }
16092      candles = merged;
16093    }
16094
16095    // Yahoo has no native 4h interval — aggregate hourly into true 4h bars.
16096    // Offset by 1h so buckets start at 1am / 5am / 9am / 1pm (display = PT).
16097    // Clean bad-tick spikes on the HOURLY bars FIRST, so a single bad print can't
16098    // blow out the 4h bucket's high/low (that's what caused the long 4h wicks).
16099    if (currentTimeframe === '1h') {
16100      candles = clampWicks(candles, 3);
16101      // SPX/NDX were already snapped to the :30 session grid above (bars run 6:30-7:30) — don't
16102      // re-bucket them back to :00. Every other ticker gets clock-hour (6-7am PT) buckets.
16103      const _sess1h = ['SPX', 'SPXW', 'NDX', 'NDXP'].includes(String(ticker || '').toUpperCase());
16104      if (!_sess1h) candles = aggregateCandles(candles, 3600, 0);
16105    }
16106    if (currentTimeframe === '4h') {
16107      candles = clampWicks(candles, 3);
16108      candles = aggregateCandles(candles, 4 * 3600, 3600);
16109    }
16110    if (currentTimeframe === '10m') {
16111      // Yahoo has no native 10m — 5m bars fetched above, aggregate to true 10m buckets.
16112      candles = clampWicks(candles, 3);
16113      candles = aggregateCandles(candles, 10 * 60, 0);
16114    }
16115
16116    // Final bad-tick clean — tighter on intraday (thin extended-hours prints spike more).
16117    candles = clampWicks(candles, INTRADAY_TFS.includes(currentTimeframe) ? 3 : 4);
16118
16119    // Parse GEX
16120    const strikes = gexJson.data || gexJson || [];
16121    let spot = 0;
16122    const spotArr = spotJson.data || spotJson || [];
16123    if (Array.isArray(spotArr)) {
16124      for (const s of spotArr) {
16125        const p = parseFloat(s.price || s.underlying_price || 0);
16126        if (p > 0) { spot = p; break; }
16127      }
16128    }
16129    if (!spot && candles.length > 0) spot = candles[candles.length - 1].close;
16130
16131    const mult = metricMult(spot);
16132    const parsed = (Array.isArray(strikes) ? strikes : []).map(s => {
16133      const strike = parseFloat(s.strike || 0);
16134      // charmNet (dealer convention) + raw call/put gamma kept alongside, so the Trade Plan can
16135      // compute the charm pin AND the EOD gamma pin even when the active metric is GEX/VEX/charm.
16136      return { strike, sb: (s.sb != null ? +s.sb : null), netGex: metricRaw(s) * mult, callGex: metricCall(s) * mult, putGex: metricPut(s) * mult,
16137               charmNet: (parseFloat(s.call_charm || 0) - parseFloat(s.put_charm || 0)),   // REST convention: net = call − put (WS normalized server-side)
16138               gexC: parseFloat(s.call_gex || s.call_gamma_oi || 0), gexP: parseFloat(s.put_gex || s.put_gamma_oi || 0) };
16139    }).filter(s => s.strike > 0 && Math.abs(s.netGex) > 0);
16140
16141    // Use the SAME filtered set (30 strikes each side of spot + noise drop) as the
16142    // GEX view / Ultra / heatmap, so the chart's King/CW/PW/VN match them exactly.
16143    let filtered = parsed;
16144    if (spot > 0) {
16145      const above = parsed.filter(s => s.strike >= spot).sort((a, b) => a.strike - b.strike).slice(0, 30);
16146      const below = parsed.filter(s => s.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
16147      filtered = [...above, ...below];
16148    }
16149    if (filtered.length > 0) {
16150      const maxGex = Math.max(...filtered.map(s => Math.abs(s.netGex)));
16151      filtered = filtered.filter(s => Math.abs(s.netGex) > maxGex * 0.001);
16152    }
16153
16154    const { callWall, putWall, kingNode, totalGex } = pickLevels(filtered, spot);
16155    const bias = totalGex >= 0;
16156    const volNode = computeVolNode(contractsArr, spot);
16157
16158    // Charm levels are pinned to the NEAREST expiry (0DTE), independent of the GEX ALL/0DTE/WK
16159    // toggle — charm lives in the near expiry, so this keeps them from shifting with the toggle.
16160    // computeCharmLevels() fetches the 0DTE greek exposure separately and repaints.
16161    window._chartSpot = spot;
16162
16163    // Update chart header
16164    const tn = document.getElementById('chartTickerName');
16165    const sp = document.getElementById('chartSpot');
16166    const ck = document.getElementById('chartKing');
16167    const ccw = document.getElementById('chartCW');
16168    const cpw = document.getElementById('chartPW');
16169    const cvn = document.getElementById('chartVN');
16170    const cb = document.getElementById('chartBias');
16171    if (tn) tn.textContent = ticker;
16172    if (sp) sp.textContent = '$' + spot.toFixed(2);
16173    if (ck) ck.textContent = kingNode ? '\ud83d\udc51 King ' + kingNode.strike.toFixed(0) : '';
16174    if (ccw) ccw.textContent = callWall ? '\ud83d\udcc8 Call Wall ' + callWall.strike.toFixed(0) : '';
16175    if (cpw) cpw.textContent = putWall ? '\ud83d\udcc9 Put Wall ' + putWall.strike.toFixed(0) : '';
16176    if (cvn) cvn.textContent = volNode ? '\ud83d\udd35 Vol Node ' + volNode.strike.toFixed(0) : '';
16177    if (cb && !applyDealerBiasChip()) { cb.textContent = bias ? '\u2191 Bullish' : '\u2193 Bearish'; cb.style.color = bias ? 'var(--green)' : 'var(--red)'; }
16178    // Active node (last ~30 min of volume) \u2014 async, fills in a moment later. Drawn on
16179    // the chart as a dashed cyan price line, alongside the header label.
16180    fetchActiveNode(ticker).then(an => {
16181      const el = document.getElementById('chartAN');
16182      if (el && chartTicker === ticker) el.textContent = an ? '\u26a1 Active ' + an.strike.toFixed(0) : '';
16183      if (an && chartSeries && chartTicker === ticker) {
16184        try {
16185          const pl = chartSeries.createPriceLine({ price: an.strike, color: '#22d3ee', lineWidth: 1, lineStyle: LightweightCharts.LineStyle.Dashed, axisLabelVisible: true, title: '\u26a1' });
16186          chartPriceLines.push(pl); // cleared+rebuilt with the others on each refresh
16187        } catch (e) {}
16188      }
16189    });
16190
16191    // Update watchlist price
16192    const wlPrice = document.getElementById('wl-price-' + ticker);
16193    if (wlPrice) wlPrice.textContent = '$' + spot.toFixed(2);
16194
16195    // ── Lightweight Charts — reuse the instance so a refresh keeps your position ──
16196    const chartKeyNew = ticker + '|' + currentTimeframe;
16197    let chartEl = document.getElementById('tvChart');
16198    const reuse = chartObj && chartSeries && chartEl && chartKey === chartKeyNew;
16199
16200    if (!reuse) {
16201      if (chartObj) { try { chartObj.remove(); } catch (e) {} }
16202      chartObj = null; chartSeries = null; chartPriceLines = []; charmPriceLines = []; modePriceLines = []; gexLevelLines = [];
16203      // Keep #chartGexPanel (painted progressively before candles) — replace only the chart element.
16204      [...content.children].forEach(c => { if (c.id !== 'chartGexPanel' && c.id !== 'gfHdrChartCtl' && c.id !== 'gfTide-1') c.remove(); });   // keep the floating Ghost Mode toolbar and the HQ tide strip
16205      chartEl = document.createElement('div'); chartEl.id = 'tvChart'; chartEl.style.cssText = 'width:100%;height:100%;position:relative;z-index:2';
16206      content.appendChild(chartEl);
16207      try { if (typeof _wsOn !== 'undefined' && _wsOn && typeof gfTideStrip === 'function') gfTideStrip(1); } catch (e) {}   // the HQ tide strip sits under the new canvas
16208      try { content.style.background = gfT('--t-bg','#0a0e17'); } catch (e) {}   // chart bg is transparent now (bubbles sit behind) — content provides the dark ground
16209      chartObj = LightweightCharts.createChart(chartEl, {
16210        width: chartEl.clientWidth,
16211        height: chartEl.clientHeight,
16212        layout: { background: { type: 'solid', color: 'rgba(0,0,0,0)' }, textColor: gfT('--t-dim','#8b9cb5'), fontFamily: 'Segoe UI, monospace', fontSize: 11 },
16213        grid: { vertLines: { color: gfT('--t-border','#1e293b') }, horzLines: { color: gfT('--t-border','#1e293b') } },
16214        crosshair: { mode: LightweightCharts.CrosshairMode.Normal, vertLine: { color: gfT('--t-dim','#8b9cb5'), width: 1, style: 2 }, horzLine: { color: gfT('--t-dim','#8b9cb5'), width: 1, style: 2 } },
16215        rightPriceScale: { borderColor: gfT('--t-border','#1e293b'), scaleMargins: { top: 0.1, bottom: 0.1 } },
16216        timeScale: { borderColor: gfT('--t-border','#1e293b'), timeVisible: INTRADAY_TFS.includes(currentTimeframe), secondsVisible: false },
16217      });
16218      chartSeries = chartObj.addCandlestickSeries({
16219        upColor: '#22c55e', downColor: '#ef4444',
16220        borderUpColor: '#22c55e', borderDownColor: '#ef4444',
16221        wickUpColor: '#22c55e', wickDownColor: '#ef4444',
16222        // Current price is drawn as a manual white dotted price line below (with a
16223        // white axis label); disable the built-in bar-colored price line + badge.
16224        priceLineVisible: false,
16225        lastValueVisible: false,
16226      });
16227      // AUDIT FIX: disconnect the previous chart's observer — one accumulated per ticker/timeframe
16228      // switch and each retained its detached chart element.
16229      try { if (window._chartRO) window._chartRO.disconnect(); } catch (e) {}
16230      window._chartRO = new ResizeObserver(() => { try { chartObj.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight }); alignChartPanelToPrice(chartCurrentPrice); redrawDrawings(); if (typeof renderGexBubbles === 'function') renderGexBubbles(); } catch (e) {} });
16231      window._chartRO.observe(chartEl);
16232      attachPriceAxisZoom(chartEl, chartObj);
16233      // Keep the GEX panel's spot row aligned with the chart price as you pan/zoom. Paint the bubbles
16234      // SYNCHRONOUSLY here (like the drawings) — the rAF-deferred renderGexBubbles left them one frame
16235      // behind the candles during a drag, so they looked stuck and only snapped into place on release.
16236      chartObj.timeScale().subscribeVisibleLogicalRangeChange(() => { _chFollow(1); });
16237      // Drawing overlay (horizontal lines / trendlines) sits on top of the chart.
16238      ensureDrawOverlay();
16239      // NOTE: do NOT repaint the GEX Visual on crosshair move — a mouse move doesn't change the chart's
16240      // price/time scale, so the bubbles don't move; re-rendering them on every mouse-over was a major
16241      // source of the "chart hangs with GEX Visual" jank. They repaint on resize / pan / zoom / live tick.
16242      chartObj.subscribeCrosshairMove((param) => {
16243        if (chartDrawings.length || drawPending || drawHover || drawTool !== 'cursor') _chFollow(2);   // the overlay has something that tracks the mouse
16244        try { _dpHover(param); } catch (e) {}
16245      });
16246      // VERTICAL price-scale pans (mouse/touch drag up-down, pinch) and the right-axis wheel zoom
16247      // fire NO lightweight-charts event — the time-scale hook above only covers horizontal moves —
16248      // so the GEX Visual froze mid-gesture and snapped into place on release (user report).
16249      // Repaint on a rAF loop scoped to the GESTURE: starts on pointerdown/wheel over the chart,
16250      // stops when the pointer lifts (or ~300ms after the last wheel tick). Zero cost while idle,
16251      // and unlike a crosshair-move repaint it can't run on a plain mouse-over (the old jank).
16252      (function(){
16253        // AUDIT FIX: window listeners register ONCE globally (they used to accumulate one pair
16254        // per chart creation — a leak on every ticker/timeframe switch). Per-chart listeners stay
16255        // on chartEl and die with it.
16256        if (!window._gfBubGestureWired) {
16257          window._gfBubGestureWired = true;
16258          window._gfBubDrag = false;
16259          window.addEventListener('pointerup', () => { window._gfBubDrag = false; }, { passive: true });
16260          window.addEventListener('pointercancel', () => { window._gfBubDrag = false; }, { passive: true });
16261        }
16262        let _wheelUntil = 0, _looping = false;
16263        const _loop = () => {
16264          if (!window._gfBubDrag && Date.now() > _wheelUntil) { _looping = false; return; }
16265          try { if (typeof indState !== 'undefined' && indState.gexbub && typeof _renderGexBubblesNow === 'function') _renderGexBubblesNow(); } catch (e) {}
16266          // VWAP / MAs / drawings are canvas overlays painted by redrawDrawings(). It ran on horizontal pans (time-scale
16267          // event) and crosshair moves only, so a vertical price drag or an axis / wheel zoom left them one gesture
16268          // behind the candles (user 2026-09-06: "vwap not sticking, same as the bubbles"). Ride the same loop.
16269          try { if (typeof redrawDrawings === 'function') redrawDrawings(); } catch (e) {}
16270          // Politician badges ride the same gesture loop. Vertical pans and axis zooms fire no
16271          // time-scale event, so without this the badges sat on the 1s heartbeat and visibly
16272          // stuck-then-snapped during a drag (user video 2026-08-26) - the exact gap this loop
16273          // was built to close for the GEX Visual.
16274          try { if (typeof _polBadges !== 'undefined' && _polBadges.length && typeof _polRenderBadges === 'function') _polRenderBadges(); } catch (e) {}
16275          try { if (typeof _gvTagsLayout === 'function') _gvTagsLayout(); } catch (e) {}
16276          requestAnimationFrame(_loop);
16277        };
16278        const _kick = () => { if (!_looping) { _looping = true; requestAnimationFrame(_loop); } };
16279        chartEl.addEventListener('pointerdown', () => { window._gfBubDrag = true; _kick(); }, { passive: true });
16280        chartEl.addEventListener('wheel', () => { _wheelUntil = Date.now() + 300; _kick(); }, { passive: true });
16281      })();
16282      chartKey = chartKeyNew;
16283    }
16284
16285    // Capture the user's scroll position BEFORE replacing the data. Save it by TIME, not by
16286    // logical index: a refresh can return a different number of candles (Yahoo's intraday
16287    // window grows through the day / rolls at boundaries), and index-based ranges then get
16288    // clamped against the new data — which snaps the view to the right edge (current price).
16289    // Time anchors survive bars being added or the window shifting. Keep the logical range
16290    // as a fallback for when the visible area is entirely in the whitespace (no bars → null).
16291    const oldTotal = chartCandles.length;
16292    const savedTimeRange = reuse ? (() => { try { return chartObj.timeScale().getVisibleRange(); } catch (e) { return null; } })() : null;
16293    const savedRange = reuse ? chartObj.timeScale().getVisibleLogicalRange() : null;
16294    // If the user is parked at the right edge (showing the last bar / whitespace), they're
16295    // following live price — keep the default padded view so new bars stay visible. Only when
16296    // they've scrolled back into history do we pin their exact position across the refresh.
16297    const wasAtRightEdge = reuse && (!savedRange || savedRange.to >= oldTotal - 1.5);
16298    chartSeries.setData(candles);
16299    chartReplayCandles = candles; // expose for the replay player (has .time + ._ts)
16300    // Timeframe switch → force the GEX Visual trail to re-space to the NEW candle grid on the next paint
16301    // (chartReplayCandles is now current, so the rebucket uses the right candles). Without this the
16302    // bubbles could vanish after a tf change until you re-toggle 0DTE/ALL or refresh. Tracked with a
16303    // dedicated flag so plain same-tf refreshes don't force a needless rebucket.
16304    try { if (_gexBubAllowed() && typeof indState !== 'undefined' && indState.gexbub) { if (window._gexBubLoadTf !== currentTimeframe) { window._gexBubLoadTf = currentTimeframe; _gexBubTf = ''; } if (typeof renderGexBubbles === 'function') renderGexBubbles(); } } catch (e) {}
16305
16306    // Drawings: keep candle/time arrays for pixel<->price/time mapping, load this
16307    // ticker's saved drawings (reset selection when the ticker changes).
16308    chartCandles = candles;
16309    // TRUE UTC unix per bar (one basis for every timeframe) → drawings stay anchored to the
16310    // same moment when you switch TF. Fall back to the display time only if _ts is missing.
16311    chartCandleTs = candles.map(c => (c._ts != null ? c._ts : tsForCandleTime(c.time)));
16312    if (ticker !== lastDrawTicker) { drawSelectedId = null; lastDrawTicker = ticker; }
16313    loadDrawingsForTicker(ticker);
16314    computeIndicators();
16315
16316    for (const pl of chartPriceLines) { try { chartSeries.removePriceLine(pl); } catch (e) {} }
16317    chartPriceLines = [];
16318    // Current price — white dotted line + white axis label.
16319    const curPrice = spot || candles[candles.length - 1].close;
16320    chartCurrentPrice = curPrice;
16320 // GEX panel aligns its spot row to this
16321    _chartPriceLine = null;
16322    if (curPrice) { _chartPriceLine = chartSeries.createPriceLine({ price: curPrice, color: '#ffffff', lineWidth: 1, lineStyle: LightweightCharts.LineStyle.Dotted, axisLabelVisible: true, title: '' }); chartPriceLines.push(_chartPriceLine); }
16323    window._chartGexLevels = { king: kingNode ? kingNode.strike : null, cw: callWall ? callWall.strike : null, pw: putWall ? putWall.strike : null, vn: volNode ? volNode.strike : null };
16324    // Gamma flip from THIS load's own rows - it used to wait for the grid's columnData to
16325    // fill, so the G-Flip line appeared seconds after the other levels (user 2026-08-30).
16326    try {
16327      const _gsrt = parsed.slice().sort((a, b) => a.strike - b.strike);
16328      let _gcum = 0, _gpc = 0, _gpk = null, _ggf = null;
16329      for (const d of _gsrt) {
16330        _gpc = _gcum; _gcum += (d.netGex || 0);
16331        if (_gpk != null && ((_gpc < 0 && _gcum >= 0) || (_gpc > 0 && _gcum <= 0))) { const _gdn = _gcum - _gpc; const _gt = _gdn === 0 ? 0 : (0 - _gpc) / _gdn; _ggf = _gpk + (d.strike - _gpk) * _gt; }
16332        _gpk = d.strike;
16333      }
16334      if (_ggf != null) window._chartGexLevels.gflip = +_ggf.toFixed(2);
16335    } catch (e) {}
16336    applyGexLevelLines();   // King / Call Wall / Put Wall / Vol Node — gated by the "GEX Levels" mode toggle
16337    computeCharmLevels();   // Charm High/Low/Flip pinned to the nearest expiry (0DTE), drawn as price lines
16338    if (typeof computeChartModes === 'function') computeChartModes();                          // greek/flow level modes
16339    if (typeof computeSessionLevels === 'function') { computeSessionLevels(); applyModePriceLines(); }  // session levels (sync, from candles)
16340    if (typeof computeDarkpoolLevels === 'function') computeDarkpoolLevels();                 // darkpool block-print levels
16341    if (typeof computeFlowMarks === 'function') computeFlowMarks();                           // sweep/block candle marks
16342    if (typeof computePoliticianMarks === 'function') computePoliticianMarks();                 // congressional disclosure marks
16343    if (indState.flow && isAdminUser && typeof pollFlowBars === 'function') pollFlowBars();   // load flow bars with the charm levels
16344
16345    // Full GEX-view column docked on the left of the chart (does not shrink it).
16346    renderChartGexPanel(content, parsed, spot, ticker, volNode);
16347    if (typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT && typeof renderTradePlan === 'function') {
16348      try { renderTradePlan(ticker, { filtered, spot, callWall, putWall, kingNode, volNode, totalGex }); } catch (e) {}
16349      // Consolidated view: fill the Net Call/Put GEX metrics + the flow feed here too.
16350      try { nfBuildUltraPanel(); } catch (e) {}
16351      try { const _tm = document.getElementById('ultraTopMetrics'); if (_tm) _tm.innerHTML = nfTopMetricsHtml({ filtered }); } catch (e) {}
16352      // Only load flow if THIS chart load is still the latest (a superseded default-ticker load
16353      // must not kick off a flow fetch that could paint over the ticker you just picked). On a
16354      // real ticker switch, clear the old flow immediately so a stale panel is never shown.
16355      try {
16356        // keepFlow (timeframe change) → don't touch the flow panel; it's independent of the chart TF.
16357        if (typeof loadUltraFlow === 'function' && !(opts && opts.keepFlow) && (silent || _chartSeq === _chartLoadSeq)) {
16358          if (!silent) { const _fb = document.getElementById('ultraFlowBody'); if (_fb && _fb.dataset.ticker && _fb.dataset.ticker !== ticker) _fb.innerHTML = '<div class="col-loading">Loading flow…</div>'; }
16359          loadUltraFlow(ticker, silent, curPrice);
16360        }
16361      } catch (e) {}
16362    }
16363
16364    const ts = chartObj.timeScale();
16365    const total = candles.length;
16366    // Default recent view: last N candles, right-aligned, with a small per-timeframe gap
16367    // before the price axis (so W/M aren't shoved left by a 6-bar gap worth 6 weeks/months).
16368    const VIEW = {
16369      '1m': { bars: 130, pad: 6 }, '5m': { bars: 120, pad: 6 }, '10m': { bars: 120, pad: 6 }, '15m': { bars: 120, pad: 6 },
16370      '30m': { bars: 120, pad: 6 }, '1h': { bars: 130, pad: 6 }, '4h': { bars: 120, pad: 4 },
16371      '1d': { bars: 64,  pad: 5 }, 'w':  { bars: 52,  pad: 3 }, 'mo':  { bars: 36, pad: 2 },
16372    };
16373    const v = VIEW[currentTimeframe] || { bars: 64, pad: 5 };
16374    const defaultView = () => {
16375      let fromIdx = Math.max(0, total - v.bars);
16376      // TODAY-dominant intraday default (user 2026-08-31, 'yesterday in zoom, today around the
16377      // corner'): open at today's first candle minus a small carry-in whenever that gives
16378      // today more width than the fixed window would. Prior sessions stay one scroll away.
16379      try {
16380        if (['1m','5m','10m','15m','30m','1h'].indexOf(currentTimeframe) >= 0 && total > 1) {
16381          const _d = t => new Date(t * 1000).toLocaleDateString('en-US', { timeZone: 'America/New_York' });
16382          const _lastDay = _d(candles[total - 1].time);
16383          let _ss = total - 1;
16384          while (_ss > 0 && _d(candles[_ss - 1].time) === _lastDay) _ss--;
16385          if (_ss > fromIdx) fromIdx = Math.max(0, _ss - 10);
16386        }
16387      } catch (e) {}
16388      ts.setVisibleLogicalRange({ from: fromIdx, to: total - 1 + v.pad });
16389    };
16390
16391    if (reuse && !wasAtRightEdge) {
16392      // User scrolled back into history on a background/live refresh — pin their EXACT
16393      // position. Restore by TIME (stable across candle-count changes); only fall back to the
16394      // logical range if there's no time range (visible area was entirely whitespace).
16395      if (savedTimeRange) { try { ts.setVisibleRange(savedTimeRange); } catch (e) { if (savedRange) { try { ts.setVisibleLogicalRange(savedRange); } catch (e2) {} } } }
16396      else if (savedRange) { try { ts.setVisibleLogicalRange(savedRange); } catch (e) {} }
16397    } else if (reuse && savedRange) {
16398      // At the right edge following live price — KEEP the user's zoom (window width) and
16399      // whitespace offset, anchored to the new last bar. Previously this reset to the
16400      // default 120-bar view, wiping any pinch-zoom/pan on every refresh.
16401      const width = savedRange.to - savedRange.from;
16402      const offsetFromEnd = (oldTotal - 1) - savedRange.to; // negative = into the right-side whitespace
16403      const to = (total - 1) - offsetFromEnd;
16404      try { ts.setVisibleLogicalRange({ from: to - width, to }); } catch (e) { defaultView(); }
16405    } else {
16406      // New ticker or timeframe → the default recent view (current price on the right).
16407      defaultView();
16408    }
16409
16410    // Let the price scale auto-fit ONCE on a fresh load (new ticker/timeframe/chart), then
16411    // turn autoScale OFF so the chart can be dragged up/down right away. While autoScale is
16412    // on, lightweight-charts ignores vertical mouse-drag — you'd have to grab the price axis
16413    // first. On in-place refreshes we leave the scale exactly where the user dragged it.
16414    // (Double-clicking the price axis still re-fits — that's the built-in reset.)
16415    if (!reuse) {
16416      try {
16417        const ps = chartObj.priceScale('right');
16418        ps.applyOptions({ autoScale: true });
16419        requestAnimationFrame(() => { try { ps.applyOptions({ autoScale: false }); } catch (e) {} });
16420      } catch (e) {}
16421    }
16422
16423    // Align the GEX panel's spot row with the chart's current-price line.
16424    requestAnimationFrame(() => { alignChartPanelToPrice(chartCurrentPrice); redrawDrawings(); if (_gexBubAllowed() && indState.gexbub && typeof _gexBubSeedPreview === 'function') setTimeout(_gexBubSeedPreview, 400); if (typeof renderGexBubbles === 'function') renderGexBubbles(); });
16425
16426    // After a user-initiated load, warm the cache for ALL other timeframes in the
16427    // background so switching timeframes is instant (no loading). DEFERRED 3s past the switch
16428    // burst — firing 4-5 extra candle fetches the same instant as the visible chart's own
16429    // requests made every ticker switch feel laggy (they share the per-IP queue).
16430    if (!silent) { const _pftk = ticker; setTimeout(function(){ try { if (chartTicker === _pftk) prefetchChartTimeframes(_pftk); } catch (e) {} }, 3000); }
16431
16432  } catch (e) {
16433    // AUDIT FIX: a silent background refresh must never replace the live chart with an error div.
16434    if (!silent) content.innerHTML = '<div class="col-loading col-error">' + e.message + '</div>';
16435    console.error('Chart error:', e);
16436  }
16437}
16438
16439// ═══════════════════════════════════════════════════════════════
16440//  ✏️  Chart drawing tools — horizontal lines + trendlines
16441//  TradingView-style: place, drag endpoints or whole line, persist
16442//  per ticker in localStorage. Rendered on an overlay <canvas> that
16443//  maps price<->y via the series and time<->x via the time scale, so
16444//  drawings stay glued to the candles as you pan/zoom.
16445// ═══════════════════════════════════════════════════════════════
16446let drawTool = 'cursor';        // 'cursor' | 'hline' | 'trend'
16447let chartDrawings = [];         // active ticker's drawings
16448let chartCandles = [];          // current candle array (set in loadGEXChart)
16449let chartCandleTs = [];         // normalized epoch-seconds per candle index
16450let chartReplayCandles = [];    // last-loaded candles (for replay mode)
16451let _replayActive = false, _replayTimer = null, _replayIdx = 0, _replaySpeed = 1, _replaySnaps = [], _replayCandles = [], _replayFallbackLevels = null, _replayActiveNode = null, _replayRange = null, _replayTicker = null;
16452let _replayDate = null, _replayDates = [], _replayToday = '', _replayStartIdx = 0, _replayEndIdx = 0; // 7-day replay: null date = latest/today; playhead runs the RTH session [_replayStartIdx.._replayEndIdx]
16453let _replayPrevTf = null, _replayPrevRange = null; // chart view before replay — restored on exit
16454let drawOverlay = null, drawCtx = null;
16455let drawPending = null;         // trendline first point {ts,price} while placing
16456let drawPreview = null;         // live mouse {x,y} while placing a trendline
16457let drawHover = null;           // { id, part } currently hovered (cursor mode)
16458let drawDrag = null;            // active drag state
16459let drawSelectedId = null;
16460let drawSetupDone = false;
16461let drawIdSeq = 1;
16462let lastDrawTicker = '';
16463let DRAW_COLOR = '#3b82f6';       // active draw color (settable via the color picker)
16464let gfFibZoneColor = '#eab308';   // Fib golden-zone (0.5–0.618) fill colour — independent of the line colour
16465try { const _fz = localStorage.getItem('gf_fib_zone_color'); if (_fz && /^#[0-9a-fA-F]{6}$/.test(_fz)) gfFibZoneColor = _fz; } catch (e) {}
16466// Fib retracement levels — the global template every Fib renders. Toggle on/off, delete, or add custom ratios.
16467let gfFibLevels = [{ r: 0, on: true }, { r: 0.236, on: true }, { r: 0.382, on: true }, { r: 0.5, on: true }, { r: 0.618, on: true }, { r: 0.786, on: true }, { r: 1, on: true }, { r: 1.618, on: true }];
16468try { const _fl = JSON.parse(localStorage.getItem('gf_fib_levels') || 'null'); if (Array.isArray(_fl) && _fl.length && _fl.every(x => x && isFinite(x.r))) gfFibLevels = _fl.map(x => ({ r: +x.r, on: x.on !== false })); } catch (e) {}
16469function _fibLabel(r) { return (+r).toString(); }
16470function _fibRatios() { return gfFibLevels.filter(l => l.on).map(l => l.r); }
16471function _fibSave() { try { localStorage.setItem('gf_fib_levels', JSON.stringify(gfFibLevels)); } catch (e) {} try { redrawDrawings(); } catch (e) {} }
16472function _renderFibLevelsUI() {
16473  const box = document.getElementById('fibLevelsList'); if (!box) return;
16474  box.innerHTML = gfFibLevels.map((l, i) =>
16475    '<div style="display:flex;align-items:center;gap:6px;font-size:11px;padding:1px 2px">' +
16476    '<input type="checkbox" ' + (l.on ? 'checked' : '') + ' onclick="event.stopPropagation();toggleFibLevel(' + i + ')" style="cursor:pointer;accent-color:var(--cyan)">' +
16477    '<span style="color:#e2e8f0;flex:1;font-family:Segoe UI,monospace;font-weight:600">' + _fibLabel(l.r) + '</span>' +
16478    '<span onclick="event.stopPropagation();deleteFibLevel(' + i + ')" title="Delete this level" style="color:#f87171;cursor:pointer;font-weight:800;padding:0 5px;font-size:13px">×</span>' +
16479    '</div>'
16480  ).join('') || '<div style="font-size:10px;color:var(--dim);padding:3px">No levels — add one below.</div>';
16481}
16482function toggleFibLevel(i) { if (gfFibLevels[i]) { gfFibLevels[i].on = !gfFibLevels[i].on; _fibSave(); _renderFibLevelsUI(); } }
16483function deleteFibLevel(i) { gfFibLevels.splice(i, 1); _fibSave(); _renderFibLevelsUI(); }
16484function addFibLevel() {
16485  const inp = document.getElementById('fibAddInput'); if (!inp) return;
16486  const v = parseFloat(inp.value);
16487  if (!isFinite(v)) { inp.focus(); return; }
16488  const r = Math.round(v * 1000) / 1000;
16489  if (!gfFibLevels.some(l => l.r === r)) { gfFibLevels.push({ r, on: true }); gfFibLevels.sort((a, b) => a.r - b.r); }
16490  inp.value = ''; _fibSave(); _renderFibLevelsUI();
16491}
16492let _brushPts = null;             // in-progress freehand stroke (pixel points)
16493const DRAW_HANDLE = 8;          // px hit radius for endpoints
16494const DRAW_NEAR = 6;            // px hit tolerance for a line
16495
16496// 'YYYY-MM-DD' or intraday unix-seconds -> normalized epoch seconds
16497function tsForCandleTime(t) {
16498  if (typeof t === 'number') return t;
16499  const ms = Date.parse(t + 'T00:00:00Z');
16500  return isNaN(ms) ? 0 : Math.floor(ms / 1000);
16501}
16502
16503function drawKey(ticker) { return 'gexDraw:' + (ticker || '').toUpperCase(); }
16504function loadDrawingsForTicker(ticker) {
16505  let arr = [];
16506  try { arr = JSON.parse(localStorage.getItem(drawKey(ticker)) || '[]'); } catch (e) {}
16507  chartDrawings = Array.isArray(arr) ? arr : [];
16508  for (const d of chartDrawings) { if (d.id >= drawIdSeq) drawIdSeq = d.id + 1; }
16509}
16510function saveDrawings() { try { localStorage.setItem(drawKey(lastDrawTicker), JSON.stringify(chartDrawings)); } catch (e) {} try { if (typeof _mRepaintTicker === 'function') _mRepaintTicker(lastDrawTicker); } catch (e) {} /* Ghost Desk panes share the store */ }
16511
16512// TRUE seconds per bar (the chart's real cadence) — used to extrapolate logical index
16513// <-> time in the whitespace beyond the candles. MUST be the median consecutive delta,
16514// NOT the average: on intraday charts the average is wildly inflated by overnight/weekend
16515// gaps (a 15m bar is 900s but an overnight jump is ~16h). Using the average made a point
16516// placed N bars into the free space store as a timestamp many times further ahead, so the
16517// line shot off to the right ("infinite extend"). The median ignores those gap outliers.
16518let _barSpacing = { key: '', v: 60 };
16519function barSpacingSec() {
16520  const a = chartCandleTs; const n = a.length;
16521  if (n < 2) return 60;
16522  const key = n + ':' + a[n - 1];
16523  if (_barSpacing.key === key) return _barSpacing.v;
16524  const d = [];
16525  for (let i = 1; i < n; i++) { const x = a[i] - a[i - 1]; if (x > 0) d.push(x); }
16526  d.sort((p, q) => p - q);
16527  const v = d.length ? (d[d.length >> 1] || 60) : 60;
16528  _barSpacing = { key, v };
16529  return v;
16530}
16531// normalized ts -> FRACTIONAL logical index. Critically, this EXTRAPOLATES beyond the
16532// data range instead of clamping to the last candle, so a drawing anchored in the future
16533// (right of the current candle) maps to a coordinate out there in the whitespace — that's
16534// what lets a trendline extend to the right past price (TradingView-style).
16535function indexForTs(ts) {
16536  const a = chartCandleTs; const n = a.length; if (!n) return 0;
16537  const bs = barSpacingSec();
16538  if (ts <= a[0]) return bs ? (ts - a[0]) / bs : 0;            // extrapolate left (negative)
16539  if (ts >= a[n - 1]) return bs ? (n - 1) + (ts - a[n - 1]) / bs : n - 1; // extrapolate right
16540  let lo = 0, hi = n - 1;
16541  while (lo <= hi) {
16542    const mid = (lo + hi) >> 1;
16543    if (a[mid] === ts) return mid;
16544    if (a[mid] < ts) lo = mid + 1; else hi = mid - 1;
16545  }
16546  // ts sits between a[hi] and a[lo] (hi == lo-1) — return a fractional index.
16547  const t0 = a[hi], t1 = a[lo];
16548  return hi + (t1 > t0 ? (ts - t0) / (t1 - t0) : 0);
16549}
16550// Inverse of indexForTs: FRACTIONAL logical index -> normalized ts, also extrapolating
16551// past both ends so dragging an endpoint into the whitespace keeps it there.
16552function tsForIndex(logical) {
16553  const a = chartCandleTs; const n = a.length; if (!n) return 0;
16554  // Never collapse a bad mapping to the left edge — keep it on the right where drawing happens.
16555  if (logical == null || !isFinite(logical)) return a[n - 1];
16556  const bs = barSpacingSec();
16557  if (logical <= 0) return Math.round(a[0] + logical * bs);
16558  if (logical >= n - 1) return Math.round(a[n - 1] + (logical - (n - 1)) * bs);
16559  const i = Math.floor(logical), frac = logical - i;
16560  const t0 = a[i], t1 = (a[i + 1] != null) ? a[i + 1] : (t0 + bs);
16561  return Math.round(t0 + frac * (t1 - t0));
16562}
16563// FRACTIONAL logical index -> pixel x. The native logicalToCoordinate() returns null/0 for
16564// NON-integer indices (it only maps real bar positions), which made trendline anchors that
16565// fall BETWEEN bars on a higher TF collapse to x=0 — the cross-TF "flip". Bar spacing is
16566// uniform, so we map any fractional/whitespace index by a linear fit through two integer,
16567// in-data reference bars (which logicalToCoordinate maps reliably).
16568function coordFromLogical(logical) {
16569  const tscale = chartObj.timeScale();
16570  const n = chartCandleTs.length;
16571  if (!isFinite(logical)) return null;
16572  if (n < 2) { try { const c = tscale.logicalToCoordinate(Math.round(logical)); return (c == null) ? null : c; } catch (e) { return null; } }
16573  let i0 = 0, i1 = n - 1;
16574  try { const r = tscale.getVisibleLogicalRange(); if (r) { const a = Math.ceil(r.from), b = Math.floor(r.to); if (b - a >= 1) { i0 = Math.max(0, Math.min(n - 1, a)); i1 = Math.max(0, Math.min(n - 1, b)); } } } catch (e) {}
16575  if (i1 <= i0) { i0 = 0; i1 = n - 1; }
16576  let x0 = null, x1 = null;
16577  try { x0 = tscale.logicalToCoordinate(i0); x1 = tscale.logicalToCoordinate(i1); } catch (e) {}
16578  if (x0 == null || x1 == null) return null;
16579  return x0 + (logical - i0) * (x1 - x0) / (i1 - i0);
16580}
16581// {ts,price} anchor -> chart pixel {x,y} (x may be off-screen; that's fine)
16582function anchorToXY(a) {
16583  let x = null, y = null;
16584  try { y = chartSeries.priceToCoordinate(a.price); } catch (e) {}
16585  try { x = coordFromLogical(indexForTs(a.ts)); } catch (e) {}
16586  return { x, y };
16587}
16588function priceFromY(y) { try { return chartSeries.coordinateToPrice(y); } catch (e) { return null; } }
16589// Pixel x -> FRACTIONAL logical index, robust in the right-side whitespace. The native
16590// coordinateToLogical() can return null out there (it only maps over real bars / the
16591// gutter), which previously collapsed the anchor to the first candle — the "jumps to the
16592// opposite side" bug. When native fails, fall back to a linear fit through two real
16593// candle coordinates so the point stays where the user actually clicked.
16594function logicalFromX(x) {
16595  let lg = null;
16596  try { lg = chartObj.timeScale().coordinateToLogical(x); } catch (e) {}
16597  if (lg != null && isFinite(lg)) return lg;
16598  const ts = chartObj.timeScale(); const n = chartCandleTs.length;
16599  if (n >= 2) {
16600    let x0 = null, x1 = null;
16601    try { x0 = ts.logicalToCoordinate(0); x1 = ts.logicalToCoordinate(n - 1); } catch (e) {}
16602    if (x0 != null && x1 != null && x1 !== x0) return (x - x0) * (n - 1) / (x1 - x0);
16603  }
16604  return n ? n - 1 : 0;
16605}
16606function tsFromX(x) {
16607  // Do NOT clamp to the last candle — extrapolate so an endpoint can be placed/dragged
16608  // into the empty space to the right of the current candle.
16609  return tsForIndex(logicalFromX(x));
16610}
16611function gexPanelWidth() { const p = document.getElementById('chartGexPanel'); if (!p) return 0; if (p.parentElement && p.parentElement.id === 'chartRow') return 0; return p.offsetWidth; }   // Desk 2/3 docks the ladder OUTSIDE chart 1 - nothing to clear (user 2026-09-07: bubbles/drawings shifted on chart 1)
16612
16613// ── Overlay canvas ───────────────────────────────────────────
16614function ensureDrawOverlay() {
16615  const content = document.getElementById('chartContent');
16616  if (!content) return;
16617  let c = document.getElementById('drawOverlay');
16618  if (!c) {
16619    c = document.createElement('canvas');
16620    c.id = 'drawOverlay';
16621    c.style.cssText = 'position:absolute;
16621left:0;top:0;z-index:4;pointer-events:none;';
16622    content.appendChild(c);
16623  }
16624  drawOverlay = c; drawCtx = c.getContext('2d');
16625  setupChartDrawingsOnce();
16626}
16627function resizeOverlay() {
16628  const content = document.getElementById('chartContent');
16629  if (!drawOverlay || !content) return;
16630  const w = content.clientWidth, h = content.clientHeight;
16631  const dpr = window.devicePixelRatio || 1;
16632  if (drawOverlay.width !== Math.round(w * dpr) || drawOverlay.height !== Math.round(h * dpr)) {
16633    drawOverlay.width = Math.round(w * dpr); drawOverlay.height = Math.round(h * dpr);
16634    drawOverlay.style.width = w + 'px'; drawOverlay.style.height = h + 'px';
16635  }
16636  drawCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
16637  return { w, h };
16638}
16639
16640// EOD pin — a gold dashed line at the blended close-magnet level + a floating "Pin N" rectangle set a
16641// little in from the right edge (deliberately NOT on the price scale), with the line running through
16642// it to the current candle. Value comes from window._chartEodPin (stashed by renderTradePlan).
16643function renderEodPin(ctx, w, h, panelW){
16644  if(!isPaidUser || typeof indState==='undefined' || !indState.eodpin) return;
16645  var pin = (typeof window!=='undefined') ? window._chartEodPin : null;
16646  if(pin==null || !isFinite(pin) || !chartSeries || !chartObj) return;
16647  var pinY; try{ pinY=chartSeries.priceToCoordinate(pin); }catch(e){ pinY=null; }
16648  if(pinY==null) return;
16649  var GOLD='#eab308';
16650  // The settlement band is NOT drawn on the chart (user request 2026-08-07 — the shaded zone was
16651  // visual clutter). It still exists as a number: the ± figure in the pin's label, and it's
16652  // recorded per snapshot for replay. Chart stays a clean curved leader + pin box.
16653  var band=(typeof window!=='undefined')?window._chartEodBand:null;
16654  // Anchor the leader at the latest candle (its close), then curve out to a floating box near the
16655  // right edge at the pin's price — a leader line to the value, not a full-width horizontal line.
16656  var ts; try{ ts=chartObj.timeScale(); }catch(e){ return; }
16657  var cs=(typeof chartReplayCandles!=='undefined' && chartReplayCandles && chartReplayCandles.length)?chartReplayCandles:[];
16658  var last=cs.length?cs[cs.length-1]:null, xc=null, yc=null;
16659  if(last){ try{ xc=ts.timeToCoordinate(last.time); }catch(e){} var pv=(typeof chartCurrentPrice!=='undefined'&&chartCurrentPrice)?chartCurrentPrice:last.close; try{ yc=chartSeries.priceToCoordinate(pv); }catch(e){} }
16660  if(xc==null) xc=w-160; if(yc==null) yc=pinY;
16661  ctx.save();
16662  // Label shows the round SPX pin (pin − basis) for proxy tickers; the line stays at the ES price.
16663  var _pinLbl = Math.round(pin - ((typeof window!=='undefined' && window._chartLevelUnshift) ? window._chartLevelUnshift : 0));
16664  // Label carries the band width — "Pin 7710 ±0.18%" reads as a target with its real tolerance.
16665  var txt='Pin '+_pinLbl.toLocaleString()+((band&&band.pct!=null)?('  ±'+band.pct.toFixed(2)+'%'):'');
16666  ctx.font='700 12px Inter, system-ui, "Segoe UI", sans-serif'; ctx.textBaseline='middle'; ctx.textAlign='left';
16667  var padX=8, bh=19, bw=ctx.measureText(txt).width+padX*2, rightMargin=128;   // keep well clear of the price scale + its labels
16668  var bx=Math.max((panelW||0)+6, w-bw-rightMargin), by=Math.max(2, Math.min(h-bh-2, Math.round(pinY-bh/2)));
16669  var boxMidY=by+bh/2, boxLeft=bx;
16670  // curly leader: candle close → box (smooth S-curve)
16671  ctx.strokeStyle=GOLD; ctx.lineWidth=2; ctx.globalAlpha=0.95; ctx.setLineDash([]);
16672  var mx=xc+(boxLeft-xc)*0.5;
16673  ctx.beginPath(); ctx.moveTo(xc,yc); ctx.bezierCurveTo(mx,yc, mx,boxMidY, boxLeft,boxMidY); ctx.stroke();
16674  ctx.fillStyle=GOLD; ctx.beginPath(); ctx.arc(xc,yc,3,0,6.2831853); ctx.fill();   // anchor dot on the candle
16675  ctx.fillRect(bx,by,bw,bh);   // value box
16676  ctx.fillStyle='#1a1400'; ctx.fillText(txt, bx+padX, boxMidY+0.5);
16677  ctx.restore();
16678}
16679// ── Render ───────────────────────────────────────────────────
16680// Anchored VWAP series from a start bar index to the last candle → [{i, v}] (VWAP value per bar).
16681function _avwapSeries(startIdx) {
16682  var out = [], tpv = 0, vol = 0, n = chartCandles.length;
16683  for (var i = Math.max(0, startIdx); i < n; i++) { var c = chartCandles[i]; if (!c) continue; var tp = (c.high + c.low + c.close) / 3, v = (c.volume || 0); tpv += tp * v; vol += v;
16683 out.push({ i: i, v: vol > 0 ? tpv / vol : c.close }); }
16684  return out;
16685}
16686// Fixed-range volume-by-price histogram over bars [i0..i1] → {lo,hi,bins,arr,max,poc}.
16687function _vprofBins(i0, i1, bins) {
16688  var n = chartCandles.length; i0 = Math.max(0, Math.min(n - 1, i0)); i1 = Math.max(0, Math.min(n - 1, i1));
16689  if (i1 < i0) { var t = i0; i0 = i1; i1 = t; }
16690  var lo = Infinity, hi = -Infinity, i;
16691  for (i = i0; i <= i1; i++) { var c = chartCandles[i]; if (!c) continue; if (c.low < lo) lo = c.low; if (c.high > hi) hi = c.high; }
16692  if (!isFinite(lo) || !isFinite(hi) || hi <= lo) return null;
16693  bins = bins || 24; var arr = new Array(bins).fill(0), rng = hi - lo;
16694  for (i = i0; i <= i1; i++) { var c = chartCandles[i]; if (!c) continue; var tp = (c.high + c.low + c.close) / 3; var b = Math.min(bins - 1, Math.max(0, Math.floor((tp - lo) / rng * bins))); arr[b] += (c.volume || 0); }
16695  var mx = 0, poc = 0; for (var b = 0; b < bins; b++) { if (arr[b] > mx) { mx = arr[b]; poc = b; } }
16696  return { lo: lo, hi: hi, bins: bins, arr: arr, max: mx, poc: poc };
16697}
16698// One pass per task for everything that follows the chart's scale (bit 1) or its crosshair (bit 2).
16699// A microtask, not a frame: it runs before the browser paints, so it never trails the candles.
16700let _chFollowQ = 0;
16701function _chFollow(bits) {
16702  _chFollowQ |= bits;
16703  if (_chFollowQ & 4) return;                       // a pass is already queued for this task
16704  _chFollowQ |= 4;
16705  Promise.resolve().then(function () {
16706    const q = _chFollowQ; _chFollowQ = 0;
16707    if (q & 1) { try { alignChartPanelToPrice(chartCurrentPrice); } catch (e) {} }
16708    try { redrawDrawings(); } catch (e) {}
16709    if (q & 1) {
16710      if (typeof _renderGexBubblesNow === 'function') { try { _renderGexBubblesNow(); } catch (e) {} }
16711      if (typeof _fmDrawSoon === 'function') { try { _fmDrawSoon(); } catch (e) {} }
16712      if (typeof chartObj !== 'undefined' && chartObj && chartObj._gvSub && typeof _gvTagsLayout === 'function') { try { _gvTagsLayout(); } catch (e) {} }
16713    }
16714  });
16715}
16716function redrawDrawings() {
16717  if (!drawOverlay || !drawCtx || !chartSeries) return;
16718  const dim = resizeOverlay(); if (!dim) return;
16719  const { w, h } = dim;
16720  drawCtx.clearRect(0, 0, w, h);
16721  const panelW = gexPanelWidth();
16722  // Clip to the chart PANE only: right of the GEX panel, left of the price axis, above the time
16723  // axis — drawings pan with the candles but never paint over either axis (user request).
16724  let _axW = 0, _axH = 0;
16725  try { _axW = chartObj.priceScale('right').width() || 0; } catch (e) {}
16726  try { _axH = chartObj.timeScale().height() || 0; } catch (e) {}
16727  drawCtx.save();
16728  drawCtx.beginPath();
16729  drawCtx.rect(panelW, 0, Math.max(0, w - panelW - _axW), Math.max(0, h - _axH));
16730  drawCtx.clip();
16731  window._gfDrawAxW = _axW;   // hline price tags hug the axis edge instead of sitting on it
16732
16733  renderIndicators(drawCtx, w, h, panelW);   // EMA / Inside Bar / Fib (admin)
16734  if (typeof renderEodPin === 'function') renderEodPin(drawCtx, w, h, panelW);   // EOD pin line + floating label
16735
16736  for (const d of chartDrawings) {
16737    const sel = d.id === drawSelectedId;
16738    const hov = drawHover && drawHover.id === d.id;
16739    drawCtx.lineWidth = sel ? 2.5 : 2;
16740    const dcol = d.color || DRAW_COLOR;
16741    drawCtx.strokeStyle = dcol;
16742    if (d.type === 'hline') {
16743      let y; try { y = chartSeries.priceToCoordinate(d.price); } catch (e) { y = null; }
16744      if (y == null) continue;
16745      drawCtx.beginPath(); drawCtx.moveTo(panelW, y); drawCtx.lineTo(w, y); drawCtx.stroke();
16746      // price tag on the right
16747      drawDrawTag(d.price.toFixed(2), w - (window._gfDrawAxW || 0) - 4, y, dcol);
16748      if (sel || hov) drawHandle((panelW + w) / 2, y, sel);
16749    } else if (d.type === 'trend') {
16750      const A = anchorToXY(d.a), B = anchorToXY(d.b);
16751      if (A.x == null || A.y == null || B.x == null || B.y == null) continue;
16752      drawCtx.beginPath();
16753      if (Math.abs(B.x - A.x) < 1) { drawCtx.moveTo(A.x, A.y); drawCtx.lineTo(B.x, B.y); }   // vertical → no ray
16754      else {
16755        // Extend the line to the right edge of the chart (ray), TradingView-style.
16756        const slope = (B.y - A.y) / (B.x - A.x);
16757        const left = (A.x <= B.x) ? A : B;
16758        drawCtx.moveTo(left.x, left.y); drawCtx.lineTo(w, A.y + slope * (w - A.x));
16759      }
16760      drawCtx.stroke();
16761      if (sel || hov) { drawHandle(A.x, A.y, sel); drawHandle(B.x, B.y, sel); }
16762    } else if (d.type === 'rect') {
16763      // Two opposite corners, like the Fib's two anchors - a zone you mark and price walks through.
16764      // Translucent fill so candles stay readable underneath, solid outline so the edges are exact.
16765      const A = anchorToXY(d.a), B = anchorToXY(d.b);
16766      if (A.x == null || A.y == null || B.x == null || B.y == null) continue;
16767      const rx = Math.min(A.x, B.x), ry = Math.min(A.y, B.y), rw = Math.abs(B.x - A.x), rh = Math.abs(B.y - A.y);
16768      drawCtx.save();
16769      drawCtx.globalAlpha = 0.13; drawCtx.fillStyle = dcol; drawCtx.fillRect(rx, ry, rw, rh);
16770      drawCtx.restore();
16771      drawCtx.strokeRect(rx, ry, rw, rh);
16772      if (sel || hov) { drawHandle(A.x, A.y, sel); drawHandle(B.x, B.y, sel); }
16773    } else if (d.type === 'fib') {
16774      const A = anchorToXY(d.a), B = anchorToXY(d.b);
16775      if (A.x == null || A.y == null || B.x == null || B.y == null) continue;
16776      const leftX = Math.min(A.x, B.x);
16777      const _zcol = d.zoneColor || dcol;
16778      const yFor = p => { try { return chartSeries.priceToCoordinate(p); } catch (e) { return null; } };
16779      // golden-zone shade — 0.5 → 0.618 ONLY (user 2026-09-02), its own fill colour, drawn under the lines
16780      const gz1 = yFor(d.a.price + (d.b.price - d.a.price) * 0.5), gz2 = yFor(d.a.price + (d.b.price - d.a.price) * 0.618);
16781      if (gz1 != null && gz2 != null) { drawCtx.save(); drawCtx.globalAlpha = 0.16; drawCtx.fillStyle = _zcol; drawCtx.fillRect(leftX, Math.min(gz1, gz2), Math.max(2, w - leftX), Math.abs(gz2 - gz1)); drawCtx.restore(); }
16782      drawCtx.font = '10px Segoe UI, monospace'; drawCtx.textBaseline = 'middle'; drawCtx.textAlign = 'left';
16783      for (const l of gfFibLevels) {
16784        if (!l.on) continue;
16785        const r = l.r, price = d.a.price + (d.b.price - d.a.price) * r;
16786        const ly = yFor(price); if (ly == null) continue;
16787        const main = (r === 0 || r === 1);
16788        drawCtx.strokeStyle = dcol; drawCtx.globalAlpha = main ? 1 : 0.72; drawCtx.lineWidth = main ? (sel ? 2.5 : 2) : 1.2;
16789        drawCtx.setLineDash(main ? [] : [4, 3]);
16790        drawCtx.beginPath(); drawCtx.moveTo(leftX, ly); drawCtx.lineTo(w, ly); drawCtx.stroke();
16791        drawCtx.setLineDash([]); drawCtx.globalAlpha = 1;
16792        drawCtx.fillStyle = dcol; drawCtx.fillText(_fibLabel(r) + '  ' + price.toFixed(2), leftX + 4, ly - 6);
16793      }
16794      if (sel || hov) { drawHandle(A.x, A.y, sel); drawHandle(B.x, B.y, sel); }
16795    } else if (d.type === 'avwap') {
16796      var _n = chartCandles.length; if (!_n) continue;
16797      var _si = Math.max(0, Math.min(_n - 1, Math.round(indexForTs(d.a.ts))));
16798      var _ser = _avwapSeries(_si); if (!_ser.length) continue;
16799      drawCtx.strokeStyle = dcol; drawCtx.lineWidth = sel ? 2.5 : 2;
16800      drawCtx.beginPath(); var _st = false, _lx = null, _ly = null;
16801      for (var _q = 0; _q < _ser.length; _q++) { var _px = coordFromLogical(_ser[_q].i), _py = chartSeries.priceToCoordinate(_ser[_q].v); if (_px == null || _py == null) continue; if (!_st) { drawCtx.moveTo(_px, _py); _st = true; } else drawCtx.lineTo(_px, _py); _lx = _px; _ly = _py; }
16802      drawCtx.stroke();
16803      var _sx = coordFromLogical(_si), _sy = chartSeries.priceToCoordinate(_ser[0].v);
16804      if (_lx != null && _ly != null) { drawCtx.fillStyle = dcol; drawCtx.font = '10px Segoe UI, monospace'; drawCtx.textBaseline = 'middle'; drawCtx.textAlign = 'left'; drawCtx.fillText('AVWAP ' + _ser[_ser.length - 1].v.toFixed(2), Math.min(_lx + 4, w - 82), _ly - 6); }
16805      if ((sel || hov) && _sx != null && _sy != null) drawHandle(_sx, _sy, sel);
16806    } else if (d.type === 'vprof') {
16807      var _A = anchorToXY(d.a), _B = anchorToXY(d.b);
16808      if (_A.x == null || _A.y == null || _B.x == null || _B.y == null) continue;
16809      var _vp = _vprofBins(Math.round(indexForTs(d.a.ts)), Math.round(indexForTs(d.b.ts)), 24);
16810      if (_vp) {
16811        var _lX = Math.min(_A.x, _B.x), _rX = Math.max(_A.x, _B.x), _boxW = Math.max(30, _rX - _lX), _barMax = _boxW * 0.9;
16812        var _yLo = chartSeries.priceToCoordinate(_vp.lo), _yHi = chartSeries.priceToCoordinate(_vp.hi);
16813        if (_yLo != null && _yHi != null) {
16814          var _top = Math.min(_yLo, _yHi), _binH = Math.abs(_yLo - _yHi) / _vp.bins;
16815          drawCtx.save();
16816          for (var _b = 0; _b < _vp.bins; _b++) { var _fr = _vp.max > 0 ? _vp.arr[_b] / _vp.max : 0; if (_fr <= 0) continue; var _by = _top + (_vp.bins - 1 - _b) * _binH; drawCtx.fillStyle = dcol; drawCtx.globalAlpha = (_b === _vp.poc) ? 0.55 : 0.26; drawCtx.fillRect(_lX, _by + 1, _fr * _barMax, Math.max(1, _binH - 1)); }
16817          drawCtx.restore();
16818          drawCtx.strokeStyle = dcol; drawCtx.globalAlpha = 0.5; drawCtx.lineWidth = 1; drawCtx.setLineDash([4, 3]); drawCtx.strokeRect(_lX, _top, _boxW, Math.abs(_yLo - _yHi)); drawCtx.setLineDash([]); drawCtx.globalAlpha = 1;
16819          var _pocY = _top + (_vp.bins - 1 - _vp.poc) * _binH + _binH / 2, _pocP = _vp.lo + (_vp.poc + 0.5) / _vp.bins * (_vp.hi - _vp.lo);
16820          drawCtx.fillStyle = dcol; drawCtx.font = '10px Segoe UI, monospace'; drawCtx.textBaseline = 'middle'; drawCtx.textAlign = 'left'; drawCtx.fillText('POC ' + _pocP.toFixed(2), _lX + _barMax + 4, _pocY);
16821        }
16822      }
16823      if (sel || hov) { drawHandle(_A.x, _A.y, sel); drawHandle(_B.x, _B.y, sel); }
16824    } else if (d.type === 'curve') {
16825      const A = anchorToXY(d.a), B = anchorToXY(d.b), C = anchorToXY(d.c);
16826      if (A.x == null || B.x == null || C.x == null) continue;
16827      drawCtx.beginPath(); drawCtx.moveTo(A.x, A.y); drawCtx.quadraticCurveTo(C.x, C.y, B.x, B.y); drawCtx.stroke();
16828      if (sel || hov) { drawHandle(A.x, A.y, sel); drawHandle(B.x, B.y, sel); const M = curveMidXY(A, B, C); drawHandle(M.x, M.y, sel); }
16829    } else if (d.type === 'brush') {
16830      const pts = (d.points || []).map(anchorToXY).filter(pt => pt.x != null && pt.y != null);
16831      if (pts.length < 2) continue;
16832      drawCtx.lineJoin = 'round'; drawCtx.lineCap = 'round';
16833      drawCtx.beginPath(); drawCtx.moveTo(pts[0].x, pts[0].y);
16834      for (let i = 1; i < pts.length; i++) drawCtx.lineTo(pts[i].x, pts[i].y);
16835      drawCtx.stroke();
16836      if (sel || hov) { drawHandle(pts[0].x, pts[0].y, sel); drawHandle(pts[pts.length - 1].x, pts[pts.length - 1].y, sel); }
16837    }
16838  }
16839  // live preview while free-hand brushing — draw the in-progress stroke
16840  if (drawTool === 'brush' && _brushPts && _brushPts.length > 1) {
16841    drawCtx.strokeStyle = DRAW_COLOR; drawCtx.lineWidth = 2; drawCtx.lineJoin = 'round'; drawCtx.lineCap = 'round';
16842    drawCtx.beginPath(); drawCtx.moveTo(_brushPts[0].x, _brushPts[0].y);
16843    for (let i = 1; i < _brushPts.length; i++) drawCtx.lineTo(_brushPts[i].x, _brushPts[i].y);
16844    drawCtx.stroke();
16845  }
16846  // live preview for the horizontal-line tool — dashed line + price tag at the cursor
16847  if (drawTool === 'hline' && drawPreview) {
16848    const py = drawPreview.y;
16849    drawCtx.setLineDash([5, 4]); drawCtx.lineWidth = 1.5; drawCtx.strokeStyle = DRAW_COLOR;
16850    drawCtx.beginPath(); drawCtx.moveTo(panelW, py); drawCtx.lineTo(w, py); drawCtx.stroke();
16851    drawCtx.setLineDash([]);
16852    const pv = priceFromY(py);
16853    if (pv != null) drawDrawTag(pv.toFixed(2), w - (window._gfDrawAxW || 0) - 4, py, DRAW_COLOR);
16854  }
16855  // live preview while placing a rectangle (dashed box from the first corner to the cursor)
16856  if (drawTool === 'rect' && drawPending && drawPreview) {
16857    const A = anchorToXY(drawPending);
16858    if (A.x != null && A.y != null) {
16859      drawCtx.setLineDash([5, 4]); drawCtx.lineWidth = 2; drawCtx.strokeStyle = DRAW_COLOR;
16860      drawCtx.strokeRect(Math.min(A.x, drawPreview.x), Math.min(A.y, drawPreview.y), Math.abs(drawPreview.x - A.x), Math.abs(drawPreview.y - A.y));
16861      drawCtx.setLineDash([]);
16862      drawHandle(A.x, A.y, true);
16863    }
16864  }
16865  // live preview while placing a trendline or a curve (dashed A → cursor)
16866  if ((drawTool === 'trend' || drawTool === 'curve') && drawPending && drawPreview) {
16867    const A = anchorToXY(drawPending);
16868    if (A.x != null && A.y != null) {
16869      drawCtx.setLineDash([5, 4]); drawCtx.lineWidth = 2; drawCtx.strokeStyle = DRAW_COLOR;
16870      drawCtx.beginPath(); drawCtx.moveTo(A.x, A.y); drawCtx.lineTo(drawPreview.x, drawPreview.y); drawCtx.stroke();
16871      drawCtx.setLineDash([]);
16872      drawHandle(A.x, A.y, true);
16873    }
16874  }
16875  // live preview while placing a fib — the levels between the first anchor and the cursor
16876  if (drawTool === 'fib' && drawPending && drawPreview) {
16877    const A = anchorToXY(drawPending), aPrice = drawPending.price, bPrice = priceFromY(drawPreview.y);
16878    if (A.x != null && aPrice != null && bPrice != null) {
16879      const leftX = Math.min(A.x, drawPreview.x);
16880      for (const r of _fibRatios()) {
16881        const price = aPrice + (bPrice - aPrice) * r;
16882        let ly; try { ly = chartSeries.priceToCoordinate(price); } catch (e) { ly = null; }
16883        if (ly == null) continue;
16884        const main = (r === 0 || r === 1);
16885        drawCtx.strokeStyle = DRAW_COLOR; drawCtx.globalAlpha = main ? 0.9 : 0.45; drawCtx.lineWidth = main ? 2 : 1;
16886        drawCtx.setLineDash(main ? [] : [4, 3]);
16887        drawCtx.beginPath(); drawCtx.moveTo(leftX, ly); drawCtx.lineTo(w, ly); drawCtx.stroke();
16888      }
16889      drawCtx.setLineDash([]); drawCtx.globalAlpha = 1;
16890      drawHandle(A.x, A.y, true);
16891    }
16892  }
16893  // live preview while placing a fixed-range volume box — dashed rectangle A → cursor
16894  if (drawTool === 'vprof' && drawPending && drawPreview) {
16895    const A = anchorToXY(drawPending);
16896    if (A.x != null && A.y != null) {
16897      const lx = Math.min(A.x, drawPreview.x), rx = Math.max(A.x, drawPreview.x), ty = Math.min(A.y, drawPreview.y), by = Math.max(A.y, drawPreview.y);
16898      drawCtx.setLineDash([5, 4]); drawCtx.lineWidth = 1.5; drawCtx.strokeStyle = DRAW_COLOR; drawCtx.globalAlpha = 0.85;
16899      drawCtx.strokeRect(lx, ty, Math.max(1, rx - lx), Math.max(1, by - ty));
16900      drawCtx.setLineDash([]); drawCtx.globalAlpha = 1;
16901      drawHandle(A.x, A.y, true);
16902    }
16903  }
16904  // Crosshair preview for the trend / curve / brush tools — shows exactly where the next
16905  // point will land (with its price), like the horizontal-line preview.
16906  if ((drawTool === 'trend' || drawTool === 'rect' || drawTool === 'curve' || drawTool === 'brush' || drawTool === 'fib' || drawTool === 'avwap' || drawTool === 'vprof') && drawPreview) {
16907    const px = drawPreview.x, py = drawPreview.y;
16908    drawCtx.setLineDash([4, 4]); drawCtx.lineWidth = 1; drawCtx.strokeStyle = 'rgba(148,163,184,0.55)';
16909    drawCtx.beginPath();
16909 drawCtx.moveTo(panelW, py); drawCtx.lineTo(w, py); drawCtx.moveTo(px, 0); drawCtx.lineTo(px, h); drawCtx.stroke();
16910    drawCtx.setLineDash([]);
16911    const pv = priceFromY(py);
16912    if (pv != null) drawDrawTag(pv.toFixed(2), w - (window._gfDrawAxW || 0) - 4, py, DRAW_COLOR);
16913  }
16914  drawCtx.restore();
16915}
16916// The point ON a quadratic bezier at t=0.5 — where the middle handle sits (on the curve, like TV).
16917function curveMidXY(A, B, C) { return { x: 0.25 * A.x + 0.5 * C.x + 0.25 * B.x, y: 0.25 * A.y + 0.5 * C.y + 0.25 * B.y }; }
16918function drawHandle(x, y, filled) {
16919  drawCtx.beginPath(); drawCtx.arc(x, y, 4.5, 0, Math.PI * 2);
16920  drawCtx.fillStyle = filled ? '#3b82f6' : '#0a0e17';
16921  drawCtx.strokeStyle = '#3b82f6'; drawCtx.lineWidth = 1.5;
16922  drawCtx.fill(); drawCtx.stroke();
16923}
16924function drawDrawTag(text, xRight, y, color) {
16925  drawCtx.font = '10px Segoe UI, monospace';
16926  const tw = drawCtx.measureText(text).width + 8;
16927  drawCtx.fillStyle = color;
16928  drawCtx.fillRect(xRight - tw, y - 8, tw, 16);
16929  drawCtx.fillStyle = '#0a0e17'; drawCtx.textBaseline = 'middle'; drawCtx.textAlign = 'left';
16930  drawCtx.fillText(text, xRight - tw + 4, y);
16931}
16932
16933// ── Hit testing ──────────────────────────────────────────────
16934function distToSeg(px, py, ax, ay, bx, by) {
16935  const dx = bx - ax, dy = by - ay; const len2 = dx * dx + dy * dy;
16936  let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
16937  t = Math.max(0, Math.min(1, t));
16938  const cx = ax + t * dx, cy = ay + t * dy;
16939  return Math.hypot(px - cx, py - cy);
16940}
16941function hitTest(x, y) {
16942  const panelW = gexPanelWidth();
16943  if (x < panelW) return null;
16944  for (let i = chartDrawings.length - 1; i >= 0; i--) {   // top-most first
16945    const d = chartDrawings[i];
16946    if (d.type === 'hline') {
16947      let ly; try { ly = chartSeries.priceToCoordinate(d.price); } catch (e) { ly = null; }
16948      if (ly != null && Math.abs(y - ly) <= DRAW_NEAR) return { id: d.id, part: 'line' };
16949    } else if (d.type === 'trend') {
16950      const A = anchorToXY(d.a), B = anchorToXY(d.b);
16951      if (A.x == null || B.x == null) continue;
16952      if (Math.hypot(x - A.x, y - A.y) <= DRAW_HANDLE) return { id: d.id, part: 'a' };
16953      if (Math.hypot(x - B.x, y - B.y) <= DRAW_HANDLE) return { id: d.id, part: 'b' };
16954      // Hit-test the whole ray (extended to the right edge), matching what's drawn.
16955      let ex = B.x, ey = B.y;
16956      if (Math.abs(B.x - A.x) >= 1) { const _s = (B.y - A.y) / (B.x - A.x); const _rw = (drawOverlay ? drawOverlay.clientWidth : B.x); ex = _rw; ey = A.y + _s * (_rw - A.x); }
16957      const _lx = (A.x <= B.x) ? A.x : B.x, _ly = (A.x <= B.x) ? A.y : B.y;
16958      if (distToSeg(x, y, _lx, _ly, ex, ey) <= DRAW_NEAR) return { id: d.id, part: 'line' };
16959    } else if (d.type === 'rect') {
16960      const A = anchorToXY(d.a), B = anchorToXY(d.b);
16961      if (A.x == null || B.x == null || A.y == null || B.y == null) continue;
16962      if (Math.hypot(x - A.x, y - A.y) <= DRAW_HANDLE) return { id: d.id, part: 'a' };
16963      if (Math.hypot(x - B.x, y - B.y) <= DRAW_HANDLE) return { id: d.id, part: 'b' };
16964      // The EDGES pick it up, not the interior: a filled box that swallowed every click inside it
16965      // would make the candles it is drawn over unselectable.
16966      const x0 = Math.min(A.x, B.x), x1 = Math.max(A.x, B.x), y0 = Math.min(A.y, B.y), y1 = Math.max(A.y, B.y);
16967      if (distToSeg(x, y, x0, y0, x1, y0) <= DRAW_NEAR || distToSeg(x, y, x0, y1, x1, y1) <= DRAW_NEAR
16968       || distToSeg(x, y, x0, y0, x0, y1) <= DRAW_NEAR || distToSeg(x, y, x1, y0, x1, y1) <= DRAW_NEAR) return { id: d.id, part: 'line' };
16969    } else if (d.type === 'fib') {
16970      const A = anchorToXY(d.a), B = anchorToXY(d.b);
16971      if (A.x == null || B.x == null) continue;
16972      if (Math.hypot(x - A.x, y - A.y) <= DRAW_HANDLE) return { id: d.id, part: 'a' };
16973      if (Math.hypot(x - B.x, y - B.y) <= DRAW_HANDLE) return { id: d.id, part: 'b' };
16974      const leftX = Math.min(A.x, B.x);
16975      if (x >= leftX - 2) {
16976        for (const r of _fibRatios()) {
16977          const price = d.a.price + (d.b.price - d.a.price) * r;
16978          let ly; try { ly = chartSeries.priceToCoordinate(price); } catch (e) { ly = null; }
16979          if (ly != null && Math.abs(y - ly) <= DRAW_NEAR) return { id: d.id, part: 'line' };
16980        }
16981      }
16982    } else if (d.type === 'avwap') {
16983      var _n2 = chartCandles.length; if (!_n2) continue;
16984      var _si2 = Math.max(0, Math.min(_n2 - 1, Math.round(indexForTs(d.a.ts))));
16985      var _ser2 = _avwapSeries(_si2);
16986      var _sx2 = coordFromLogical(_si2), _sy2 = _ser2.length ? chartSeries.priceToCoordinate(_ser2[0].v) : null;
16987      if (_sx2 != null && _sy2 != null && Math.hypot(x - _sx2, y - _sy2) <= DRAW_HANDLE) return { id: d.id, part: 'a' };
16988      var _pxA = null, _pyA = null;
16989      for (var _q2 = 0; _q2 < _ser2.length; _q2++) { var _cx2 = coordFromLogical(_ser2[_q2].i), _cy2 = chartSeries.priceToCoordinate(_ser2[_q2].v); if (_cx2 == null || _cy2 == null) continue; if (_pxA != null && distToSeg(x, y, _pxA, _pyA, _cx2, _cy2) <= DRAW_NEAR) return { id: d.id, part: 'line' }; _pxA = _cx2; _pyA = _cy2; }
16990    } else if (d.type === 'vprof') {
16991      var _Ah = anchorToXY(d.a), _Bh = anchorToXY(d.b);
16992      if (_Ah.x == null || _Bh.x == null) continue;
16993      if (Math.hypot(x - _Ah.x, y - _Ah.y) <= DRAW_HANDLE) return { id: d.id, part: 'a' };
16994      if (Math.hypot(x - _Bh.x, y - _Bh.y) <= DRAW_HANDLE) return { id: d.id, part: 'b' };
16995      var _lxh = Math.min(_Ah.x, _Bh.x), _rxh = Math.max(_Ah.x, _Bh.x), _tyh = Math.min(_Ah.y, _Bh.y), _byh = Math.max(_Ah.y, _Bh.y);
16996      if (x >= _lxh - 4 && x <= _rxh + 4 && y >= _tyh - 4 && y <= _byh + 4) return { id: d.id, part: 'line' };
16997    } else if (d.type === 'curve') {
16998      const A = anchorToXY(d.a), B = anchorToXY(d.b), C = anchorToXY(d.c);
16999      if (A.x == null || B.x == null || C.x == null) continue;
17000      if (Math.hypot(x - A.x, y - A.y) <= DRAW_HANDLE) return { id: d.id, part: 'a' };
17001      if (Math.hypot(x - B.x, y - B.y) <= DRAW_HANDLE) return { id: d.id, part: 'b' };
17002      const M = curveMidXY(A, B, C);
17003      if (Math.hypot(x - M.x, y - M.y) <= DRAW_HANDLE) return { id: d.id, part: 'c' };
17004      let px = A.x, py = A.y; // walk the quadratic bezier in small segments
17005      for (let t = 0.05; t <= 1.0001; t += 0.05) {
17006        const it = 1 - t, bx = it * it * A.x + 2 * it * t * C.x + t * t * B.x, by = it * it * A.y + 2 * it * t * C.y + t * t * B.y;
17007        if (distToSeg(x, y, px, py, bx, by) <= DRAW_NEAR) return { id: d.id, part: 'line' };
17008        px = bx; py = by;
17009      }
17010    } else if (d.type === 'brush') {
17011      const pts = (d.points || []).map(anchorToXY);
17012      for (let k = 1; k < pts.length; k++) {
17013        const a = pts[k - 1], b = pts[k];
17014        if (a.x == null || b.x == null) continue;
17015        if (distToSeg(x, y, a.x, a.y, b.x, b.y) <= DRAW_NEAR) return { id: d.id, part: 'line' };
17016      }
17017    }
17018  }
17019  return null;
17020}
17021function findDrawing(id) { return chartDrawings.find(d => d.id === id); }
17022
17023// ── Toolbar ──────────────────────────────────────────────────
17024function setChartDrawTool(tool) {
17025  // clicking the active draw tool again returns to cursor
17026  if (tool !== 'cursor' && tool === drawTool) tool = 'cursor';
17027  drawTool = tool; drawPending = null; drawPreview = null; _brushPts = null;
17028  const ICON = { cursor: '➤', hline: '─', trend: '╱', rect: '▭', curve: '∿', brush: '🖌', fib: 'φ', avwap: 'Ⅴ', vprof: '▐' };
17029  const mi = document.getElementById('drawToolIcon'); if (mi) mi.textContent = ICON[tool] || '➤';
17030  const mm = document.getElementById('drawToolMain'); if (mm) mm.classList.toggle('active', tool !== 'cursor'); // highlight when a draw tool is armed
17031  document.querySelectorAll('.draw-menu-item').forEach(b => b.classList.toggle('active', b.dataset.tool === tool));
17032  if (drawOverlay) {
17033    drawOverlay.style.pointerEvents = (drawTool === 'cursor') ? 'none' : 'auto';
17034    drawOverlay.style.cursor = (drawTool === 'cursor') ? 'default' : 'crosshair';
17035  }
17036  redrawDrawings();
17037}
17038// ── Drawing-tools dropdown (one icon → hover/click to pick a tool) ──
17039let _drawMenuTimer = null;
17040function _syncFibMenu() { try { _renderFibLevelsUI(); document.querySelectorAll('.fib-zone-swatch').forEach(s => s.classList.toggle('active', s.dataset.zcol === gfFibZoneColor)); document.querySelectorAll('.draw-swatch').forEach(s => s.classList.toggle('active', s.dataset.col === DRAW_COLOR)); } catch (e) {} }
17041function showDrawMenu() { clearTimeout(_drawMenuTimer); const m = document.getElementById('drawMenu'); if (m) m.style.display = 'block'; _syncFibMenu(); }
17042function hideDrawMenuSoon() { clearTimeout(_drawMenuTimer); _drawMenuTimer = setTimeout(() => { const m = document.getElementById('drawMenu'); if (m) m.style.display = 'none'; }, 220); }
17043function toggleDrawMenu() { const m = document.getElementById('drawMenu'); if (m) { const open = m.style.display !== 'block'; m.style.display = open ? 'block' : 'none'; if (open) _syncFibMenu(); } }
17044function pickDrawTool(tool) { setChartDrawTool(tool); const m = document.getElementById('drawMenu'); if (m) m.style.display = 'none'; }
17045// Pick the active draw color (used by new drawings; also recolors the selected drawing).
17046function setDrawColor(col) {
17047  DRAW_COLOR = col;
17048  document.querySelectorAll('.draw-swatch').forEach(s => s.classList.toggle('active', s.dataset.col === col));
17049  if (drawSelectedId != null) { const d = findDrawing(drawSelectedId); if (d) { d.color = col; saveDrawings(); redrawDrawings(); } }
17050}
17051// Fib golden-zone fill colour (0.5–0.618) — separate from the line colour. Recolours the selected Fib.
17052function setFibZoneColor(col) {
17053  gfFibZoneColor = col;
17054  try { localStorage.setItem('gf_fib_zone_color', col); } catch (e) {}
17055  document.querySelectorAll('.fib-zone-swatch').forEach(s => s.classList.toggle('active', s.dataset.zcol === col));
17056  if (drawSelectedId != null) { const d = findDrawing(drawSelectedId); if (d && d.type === 'fib') { d.zoneColor = col; saveDrawings(); redrawDrawings(); } }
17057}
17058// ── Per-drawing click menu: Lock/Unlock + Delete (a small vertical list) ──
17059let _drawMenuObjId = null;
17060function _drawObjMenuHtml(d) {
17061  const item = (label, fn) => '<div onclick="' + fn + '" style="padding:7px 14px;cursor:pointer;color:#cbd5e1;white-space:nowrap;border-radius:5px" onmouseover="this.style.background=\'rgba(6,182,212,0.14)\'" onmouseout="this.style.background=\'none\'">' + label + '</div>';
17062  return item(d.locked ? 'Unlock' : 'Lock', 'toggleDrawLock(' + d.id + ')') + item('Delete', 'deleteDrawing(' + d.id + ')');
17063}
17064function showDrawObjMenu(id, e) {
17065  const d = findDrawing(id); if (!d) return;
17066  let m = document.getElementById('drawObjMenu');
17067  if (!m) {
17068    m = document.createElement('div'); m.id = 'drawObjMenu';
17069    m.style.cssText = 'position:fixed;z-index:300;display:none;flex-direction:column;gap:1px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.55);font-size:12px;font-family:Segoe UI,monospace;min-width:96px';
17070    document.body.appendChild(m);
17071  }
17072  _drawMenuObjId = id;
17073  m.innerHTML = _drawObjMenuHtml(d);
17074  m.style.display = 'flex';
17075  const px = (e && e.clientX != null) ? e.clientX : 0, py = (e && e.clientY != null) ? e.clientY : 0;
17076  const mw = m.offsetWidth || 100, mh = m.offsetHeight || 74;
17077  // Show the menu ABOVE the click point (offset up) so it doesn't sit on the drawing line and hide
17078  // what's under it; drop below only if there's no room above (near the top edge).
17079  // Lifted further off the cursor (user 2026-09-05: it sat right on the line and got in the way of
17080  // dragging) - ~46px above the click point, drop ~40px below only when there is no room above.
17081  let left = px + 18; if (left + mw > window.innerWidth - 6) left = px - mw - 18;
17082  let top = py - mh - 46; if (top < 6) top = py + 40;
17083  m.style.left = Math.max(4, left) + 'px';
17084  m.style.top = top + 'px';
17085}
17086function hideDrawObjMenu() { const m = document.getElementById('drawObjMenu'); if (m) m.style.display = 'none'; _drawMenuObjId = null; }
17087function toggleDrawLock(id) {
17088  const d = findDrawing(id); if (!d) return;
17089  d.locked = !d.locked; saveDrawings(); redrawDrawings();
17090  const m = document.getElementById('drawObjMenu'); if (m && _drawMenuObjId === id) m.innerHTML = _drawObjMenuHtml(d);
17091}
17092function deleteDrawing(id) {
17093  chartDrawings = chartDrawings.filter(d => d.id !== id);
17094  if (drawSelectedId === id) drawSelectedId = null;
17095  saveDrawings(); redrawDrawings(); hideDrawObjMenu();
17096}
17097function deleteSelectedDrawing() {
17098  if (drawSelectedId == null) return;
17099  chartDrawings = chartDrawings.filter(d => d.id !== drawSelectedId);
17100  drawSelectedId = null; saveDrawings(); redrawDrawings();
17101}
17102
17103// ── Interaction (listeners attached once to #chartContent) ───
17104function evtXY(e) {
17105  const content = document.getElementById('chartContent');
17106  const r = content.getBoundingClientRect();
17107  return { x: e.clientX - r.left, y: e.clientY - r.top };
17108}
17109function setupChartDrawingsOnce() {
17110  if (drawSetupDone) return;
17111  const content = document.getElementById('chartContent');
17112  if (!content) return;
17113  drawSetupDone = true;
17114
17115  content.addEventListener('mousedown', (e) => {
17116    if (e.button !== 0) return;
17117    const { x, y } = evtXY(e);
17118    // Ignore clicks over the docked GEX panel (left side) — draw on the chart only.
17119    if ((drawTool === 'hline' || drawTool === 'trend' || drawTool === 'rect' || drawTool === 'curve' || drawTool === 'brush' || drawTool === 'fib' || drawTool === 'avwap' || drawTool === 'vprof') && x < gexPanelWidth()) return;
17120    if (drawTool === 'brush') {   // start a freehand stroke; points collected on mousemove
17121      _brushPts = [{ x, y }];
17122      e.preventDefault(); return;
17123    }
17124    if (drawTool === 'hline') {
17125      const price = priceFromY(y); if (price == null) return;
17126      const d = { id: drawIdSeq++, type: 'hline', price, color: DRAW_COLOR };
17127      chartDrawings.push(d); drawSelectedId = d.id; saveDrawings();
17128      setChartDrawTool('cursor'); return;
17129    }
17130    if (drawTool === 'trend') {
17131      const pt = { ts: tsFromX(x), price: priceFromY(y) };
17132      if (pt.price == null) return;
17133      if (!drawPending) { drawPending = pt; drawPreview = { x, y }; redrawDrawings(); }
17134      else {
17135        const d = { id: drawIdSeq++, type: 'trend', a: drawPending, b: pt, color: DRAW_COLOR };
17136        chartDrawings.push(d); drawSelectedId = d.id; saveDrawings();
17137        setChartDrawTool('cursor');
17138      }
17139      return;
17140    }
17141    if (drawTool === 'rect') {   // Rectangle — 2 opposite corners
17142      const pt = { ts: tsFromX(x), price: priceFromY(y) };
17143      if (pt.price == null) return;
17144      if (!drawPending) { drawPending = pt; drawPreview = { x, y }; redrawDrawings(); }
17145      else {
17146        const d = { id: drawIdSeq++, type: 'rect', a: drawPending, b: pt, color: DRAW_COLOR };
17147        chartDrawings.push(d); drawSelectedId = d.id; saveDrawings();
17148        setChartDrawTool('cursor');
17149      }
17150      return;
17151    }
17152    if (drawTool === 'fib') {   // Fibonacci retracement — 2 anchors (swing hi/lo), levels drawn between
17153      const pt = { ts: tsFromX(x), price: priceFromY(y) };
17154      if (pt.price == null) return;
17155      if (!drawPending) { drawPending = pt; drawPreview = { x, y }; redrawDrawings(); }
17156      else {
17157        const d = { id: drawIdSeq++, type: 'fib', a: drawPending, b: pt, color: DRAW_COLOR, zoneColor: gfFibZoneColor };
17158        chartDrawings.push(d); drawSelectedId = d.id; saveDrawings();
17159        setChartDrawTool('cursor');
17160      }
17161      return;
17162    }
17163    if (drawTool === 'avwap') {   // Anchored VWAP — single click sets the anch
17163or bar
17164      const pt = { ts: tsFromX(x), price: priceFromY(y) };
17165      if (pt.price == null) return;
17166      const d = { id: drawIdSeq++, type: 'avwap', a: pt, color: DRAW_COLOR };
17167      chartDrawings.push(d); drawSelectedId = d.id; saveDrawings();
17168      setChartDrawTool('cursor'); return;
17169    }
17170    if (drawTool === 'vprof') {   // Fixed Range Volume — two clicks define the bar range
17171      const pt = { ts: tsFromX(x), price: priceFromY(y) };
17172      if (pt.price == null) return;
17173      if (!drawPending) { drawPending = pt; drawPreview = { x, y }; redrawDrawings(); }
17174      else {
17175        const d = { id: drawIdSeq++, type: 'vprof', a: drawPending, b: pt, color: DRAW_COLOR };
17176        chartDrawings.push(d); drawSelectedId = d.id; saveDrawings();
17177        setChartDrawTool('cursor');
17178      }
17179      return;
17180    }
17181    if (drawTool === 'curve') {
17182      const pt = { ts: tsFromX(x), price: priceFromY(y) };
17183      if (pt.price == null) return;
17184      if (!drawPending) { drawPending = pt; drawPreview = { x, y }; redrawDrawings(); }
17185      else {
17186        // control point = midpoint of A→B offset perpendicular, so the curve starts visibly bent
17187        const A = anchorToXY(drawPending);
17188        const mx = (A.x + x) / 2, my = (A.y + y) / 2;
17189        const dx = x - A.x, dy = y - A.y, len = Math.hypot(dx, dy) || 1, off = Math.min(70, len * 0.2);
17190        const c = { ts: tsFromX(mx + (-dy / len) * off), price: priceFromY(my + (dx / len) * off) };
17191        const d = { id: drawIdSeq++, type: 'curve', a: drawPending, b: pt, c, color: DRAW_COLOR };
17192        chartDrawings.push(d); drawSelectedId = d.id; saveDrawings();
17193        setChartDrawTool('cursor');
17194      }
17195      return;
17196    }
17197    // cursor mode: click a drawing to select it + open its menu; drag if not locked
17198    const hit = hitTest(x, y);
17199    drawSelectedId = hit ? hit.id : null;
17200    if (hit) {
17201      const d = findDrawing(hit.id);
17202      showDrawObjMenu(hit.id, e);   // click → the drawing's Lock/Delete menu
17203      if (!(d && d.locked)) {       // locked → select + menu but never drag
17204        drawDrag = {
17205          id: hit.id, part: hit.part,
17206          startLogical: logicalFromX(x),
17207          startPrice: priceFromY(y),
17208          orig: JSON.parse(JSON.stringify(d)),
17209        };
17210      }
17211      e.preventDefault();
17212    } else {
17213      hideDrawObjMenu();   // clicked empty space → close the menu + deselect
17214    }
17215    redrawDrawings();
17216  });
17217
17218  content.addEventListener('mousemove', (e) => {
17219    const { x, y } = evtXY(e);
17220    if (drawDrag) {
17221      const d = findDrawing(drawDrag.id); if (!d) { drawDrag = null; return; }
17222      if (d.type === 'hline') {
17223        const p = priceFromY(y); if (p != null) d.price = p;
17224      } else if (d.type === 'trend') {
17225        if (drawDrag.part === 'a') { d.a = { ts: tsFromX(x), price: priceFromY(y) }; }
17226        else if (drawDrag.part === 'b') { d.b = { ts: tsFromX(x), price: priceFromY(y) }; }
17227        else { // move whole line by logical + price delta
17228          const curLogical = logicalFromX(x);
17229          const dLog = Math.round(curLogical - drawDrag.startLogical);
17230          const dPrice = (priceFromY(y) - drawDrag.startPrice) || 0;
17231          const moveEnd = (origEnd) => {
17232            // Shift by logical bars and extrapolate back to ts (no clamping) so the whole
17233            // line can be dragged into the whitespace right of the current candle.
17234            return { ts: tsForIndex(indexForTs(origEnd.ts) + dLog), price: origEnd.price + dPrice };
17235          };
17236          d.a = moveEnd(drawDrag.orig.a); d.b = moveEnd(drawDrag.orig.b);
17237        }
17238      } else if (d.type === 'fib') {
17239        if (drawDrag.part === 'a') { d.a = { ts: tsFromX(x), price: priceFromY(y) }; }
17240        else if (drawDrag.part === 'b') { d.b = { ts: tsFromX(x), price: priceFromY(y) }; }
17241        else {
17242          const curLogical = logicalFromX(x);
17243          const dLog = Math.round(curLogical - drawDrag.startLogical);
17244          const dPrice = (priceFromY(y) - drawDrag.startPrice) || 0;
17245          const moveEnd = (o) => ({ ts: tsForIndex(indexForTs(o.ts) + dLog), price: o.price + dPrice });
17246          d.a = moveEnd(drawDrag.orig.a); d.b = moveEnd(drawDrag.orig.b);
17247        }
17248      } else if (d.type === 'rect') {
17249        // Same two-anchor drag as the Fib: a corner moves alone, the body moves both together.
17250        if (drawDrag.part === 'a') { d.a = { ts: tsFromX(x), price: priceFromY(y) }; }
17251        else if (drawDrag.part === 'b') { d.b = { ts: tsFromX(x), price: priceFromY(y) }; }
17252        else {
17253          const curLogical = logicalFromX(x);
17254          const dLog = Math.round(curLogical - drawDrag.startLogical);
17255          const dPrice = (priceFromY(y) - drawDrag.startPrice) || 0;
17256          const moveEnd = (o) => ({ ts: tsForIndex(indexForTs(o.ts) + dLog), price: o.price + dPrice });
17257          d.a = moveEnd(drawDrag.orig.a); d.b = moveEnd(drawDrag.orig.b);
17258        }
17259      } else if (d.type === 'avwap') {
17260        d.a = { ts: tsFromX(x), price: priceFromY(y) };   // 1-anchor: any drag re-anchors
17261      } else if (d.type === 'vprof') {
17262        if (drawDrag.part === 'a') { d.a = { ts: tsFromX(x), price: priceFromY(y) }; }
17263        else if (drawDrag.part === 'b') { d.b = { ts: tsFromX(x), price: priceFromY(y) }; }
17264        else {
17265          const curLogical = logicalFromX(x);
17266          const dLog = Math.round(curLogical - drawDrag.startLogical);
17267          const dPrice = (priceFromY(y) - drawDrag.startPrice) || 0;
17268          const moveEnd = (o) => ({ ts: tsForIndex(indexForTs(o.ts) + dLog), price: o.price + dPrice });
17269          d.a = moveEnd(drawDrag.orig.a); d.b = moveEnd(drawDrag.orig.b);
17270        }
17271      } else if (d.type === 'curve') {
17272        if (drawDrag.part === 'a') { d.a = { ts: tsFromX(x), price: priceFromY(y) }; }
17273        else if (drawDrag.part === 'b') { d.b = { ts: tsFromX(x), price: priceFromY(y) }; }
17274        else if (drawDrag.part === 'c') { const A = anchorToXY(d.a), B = anchorToXY(d.b); if (A.x != null && B.x != null) { d.c = { ts: tsFromX(2 * x - 0.5 * A.x - 0.5 * B.x), price: priceFromY(2 * y - 0.5 * A.y - 0.5 * B.y) }; } }
17275        else {
17276          const curLogical = logicalFromX(x);
17277          const dLog = Math.round(curLogical - drawDrag.startLogical);
17278          const dPrice = (priceFromY(y) - drawDrag.startPrice) || 0;
17279          const moveEnd = (o) => ({ ts: tsForIndex(indexForTs(o.ts) + dLog), price: o.price + dPrice });
17280          d.a = moveEnd(drawDrag.orig.a); d.b = moveEnd(drawDrag.orig.b); d.c = moveEnd(drawDrag.orig.c);
17281        }
17282      } else if (d.type === 'brush') {   // move the whole stroke by logical + price delta
17283        const dLog = Math.round(logicalFromX(x) - drawDrag.startLogical);
17284        const dPrice = (priceFromY(y) - drawDrag.startPrice) || 0;
17285        d.points = (drawDrag.orig.points || []).map(o => ({ ts: tsForIndex(indexForTs(o.ts) + dLog), price: o.price + dPrice }));
17286      }
17287      redrawDrawings(); return;
17288    }
17289    // Horizontal-line tool: show a live dashed preview at the cursor BEFORE placing,
17290    // so you can see exactly where (and at what price) the line will land.
17291    if (drawTool === 'hline') {
17292      drawPreview = (x >= gexPanelWidth()) ? { x, y } : null;
17293      redrawDrawings();
17294      return;
17295    }
17296    if (drawTool === 'brush' && _brushPts) {   // collect the freehand stroke as the mouse moves
17297      const lp = _brushPts[_brushPts.length - 1];
17298      if (!lp || Math.hypot(x - lp.x, y - lp.y) >= 2) _brushPts.push({ x, y }); // thin out near-duplicate points
17299      redrawDrawings(); return;
17300    }
17301    if (drawTool === 'trend' || drawTool === 'rect' || drawTool === 'curve' || drawTool === 'brush' || drawTool === 'fib' || drawTool === 'avwap' || drawTool === 'vprof') { drawPreview = (x >= gexPanelWidth()) ? { x, y } : null; redrawDrawings(); return; }
17302    if (drawTool === 'cursor') {
17303      const hit = hitTest(x, y);
17304      drawHover = hit;
17305      const hd = hit ? findDrawing(hit.id) : null;
17306      if (drawOverlay) {
17307        drawOverlay.style.pointerEvents = hit ? 'auto' : 'none';
17308        drawOverlay.style.cursor = hit ? ((hd && hd.locked) ? 'not-allowed' : (hit.part === 'line' ? 'move' : 'pointer')) : 'default';
17309      }
17310      redrawDrawings();
17311    }
17312  });
17313
17314  window.addEventListener('mouseup', () => {
17315    if (drawDrag) { saveDrawings(); drawDrag = null; }
17316    // finish a freehand brush stroke → persist as ts/price anchors so it moves with the chart
17317    if (_brushPts) {
17318      if (_brushPts.length >= 2) {
17319        const points = _brushPts.map(p => ({ ts: tsFromX(p.x), price: priceFromY(p.y) })).filter(p => p.price != null);
17320        if (points.length >= 2) {
17321          const d = { id: drawIdSeq++, type: 'brush', points, color: DRAW_COLOR };
17322          chartDrawings.push(d); drawSelectedId = d.id; saveDrawings();
17323        }
17324      }
17325      _brushPts = null; redrawDrawings();
17326    }
17327  });
17328
17329  // Leaving the chart area clears the hline placement preview so it doesn't linger.
17330  content.addEventListener('mouseleave', () => {
17331    if (drawPreview && (drawTool === 'hline' || drawTool === 'trend' || drawTool === 'rect' || drawTool === 'curve' || drawTool === 'brush' || drawTool === 'fib' || drawTool === 'avwap' || drawTool === 'vprof')) { drawPreview = null; redrawDrawings(); }
17332  });
17333
17334  content.addEventListener('contextmenu', (e) => {
17335    if (!gfLive('chart')) return;
17336    const { x, y } = evtXY(e);
17337    const hit = hitTest(x, y);
17338    if (hit) { e.preventDefault(); chartDrawings = chartDrawings.filter(d => d.id !== hit.id); if (drawSelectedId === hit.id) drawSelectedId = null; saveDrawings(); redrawDrawings(); }
17339  });
17340
17341  document.addEventListener('keydown', (e) => {
17342    if (!gfLive('chart')) return;
17343    const tag = (document.activeElement && document.activeElement.tagName) || '';
17344    if (tag === 'INPUT' || tag === 'TEXTAREA') return;
17345    if ((e.key === 'Delete' || e.key === 'Backspace') && drawSelectedId != null) { e.preventDefault(); deleteSelectedDrawing(); }
17346    else if (e.key === 'Escape') { drawPending = null; drawPreview = null; setChartDrawTool('cursor'); }
17347  });
17348}
17349
17350// ═══════════════════════════════════════════════════════════════
17351//  📊  Chart indicators (admin only) — EMA Suite, Inside Bar
17352//  Rectangle, Golden Zone Fibs. Ported from the TradingView Pine
17353//  scripts; computed from the chart's own candles for the displayed
17354//  timeframe and rendered on the drawing overlay canvas.
17355//  (MTF stacking + alert firing come next.)
17356// ═══════════════════════════════════════════════════════════════
17357const INDICATOR_KEY = 'gexIndicators';
17358const MA_WIDTHS = [1, 1.5, 2, 3];
17359const MA_DEFAULTS = [
17360  { on:false, type:'ema', len:8,   color:'#E91E8C', width:1.5, style:'solid' },
17361  { on:false, type:'ema', len:21,  color:'#4CAF50', width:1.5, style:'solid' },
17362  { on:false, type:'sma', len:200, color:'#9C27B0', width:1.5, style:'solid' },
17363];
17364// Per-line default colors for the greek/flow/session modes — user-overridable via the color pickers
17365// in the Modes panel (stored in indState.modeColors, keyed the same).
17366const MODE_COLOR_DEFAULTS = {
17367  vwap: '#a78bfa',
17368  maxpain: '#e879f9',
17369  vannaHi: '#2dd4bf', vannaLo: '#fb923c', vannaFlip: '#a855f7',
17370  flowCall: '#4ade80', flowPut: '#f87171',
17371  magnet: '#fbbf24', em: '#60a5fa', darkpool: '#22d3ee',
17372  regimeUp: '#22c55e', regimeDn: '#ef4444',
17373  gexPos: '#22c55e', gexNeg: '#ef4444',
17374  pd: '#94a3b8', on: '#8b9cb5', or: '#eab308',
17375};
17376let indState = loadIndicatorState();   // { mas:[...], charm, vwap, flow }
17377// MULTI-TAB SYNC (user 2026-08-26: Flow Marks label toggles kept resurrecting on reload).
17378// saveIndicatorState() writes the WHOLE object, and this dashboard runs on several monitors at
17379// once - so a tab still holding the old indState in memory would, on its next save of anything,
17380// resurrect settings another tab had just changed, and whichever tab wrote last is what a reload
17381// restored. The 'storage' event fires in every OTHER tab when one saves; adopting the new state
17382// there means no tab ever holds a stale copy to clobber with.
17383// Checkboxes and MA rows refresh on the next panel open (that sync lives in toggleIndicatorPanel);
17384// the option chips and every drawn layer refresh here immediately.
17385window.addEventListener('storage', function (e) {
17386  try {
17387    if (e.key !== INDICATOR_KEY || !e.newValue) return;
17388    indState = loadIndicatorState();
17389    try { if (typeof _syncModeOpts === 'function') _syncModeOpts(); } catch (x) {}
17390    try { if (typeof computeFlowMarks === 'function') computeFlowMarks(); } catch (x) {}
17391    try { if (typeof computePoliticianMarks === 'function') computePoliticianMarks(); } catch (x) {}
17392    try { if (typeof computeDarkpoolLevels === 'function') computeDarkpoolLevels(); } catch (x) {}
17393    try { if (typeof computeIndicators === 'function') computeIndicators(); } catch (x) {}
17394    try { if (typeof applyModePriceLines === 'function') applyModePriceLines(); } catch (x) {}
17395  } catch (x) {}
17396});
17397let indMAs = [];     // [{ arr, color, width, style }] — the enabled moving averages
17398let indVWAP = [];    // [{ vwap, up, lo }] aligned to chartCandles (intraday only)
17399const TF_MINS = { '1m':1, '5m':5, '10m':10, '15m':15, '30m':30, '1h':60, '4h':240, '1d':1440, 'w':10080, 'mo':43200 };
17400
17401function loadIndicatorState() {
17402  let s = {};
17403  try { s = JSON.parse(localStorage.getItem(INDICATOR_KEY) || '{}'); } catch (e) {}
17404  const mas = MA_DEFAULTS.map((d, i) => Object.assign({}, d, (s.mas && s.mas[i]) || {}));
17405  return { mas, gexlines: s.gexlines != null ? !!s.gexlines : true,   // King/CW/PW/VN toggle (default ON = current behavior)
17406    charm: !!s.charm, vwap: !!s.vwap, flow: false,   // 0DTE Flow bars removed from Modes (2026-07-21); forced off regardless of any saved state
17407    maxpain: !!s.maxpain, vanna: !!s.vanna, flowwall: !!s.flowwall, magnet: !!s.magnet, em: !!s.em,
17408    // GEX Visual (premium) + EOD pin — persist the toggle AND the Style/Session/Extend selections.
17409    gexbub: !!s.gexbub, gvTagStrike: !!s.gvTagStrike, gvTagPct: !!s.gvTagPct, gvTagTrend: !!s.gvTagTrend, gvBuild: !!s.gvBuild, gvPull: !!s.gvPull, gexbubStyle: s.gexbubStyle || 'bubbles', gexbubSession: s.gexbubSession || 'reg', gexbubExtend: s.gexbubExtend || 'off', gexbubStrikes: s.gexbubStrikes || '10', gexbubPrev: s.gexbubPrev || 'on', gexbubPrevbright: s.gexbubPrevbright || 'dim', eodpin: !!s.eodpin,
17410    kingmagnet: !!s.kingmagnet,   // King-follows-magnet (admin) — was saved but DROPPED here on reload (user 2026-09-01); the whitelist strikes again
17411    // Session levels split into 3 independent toggles (old single `session` flag seeds all three).
17412    darkpool: !!s.darkpool,
17413    flowmarks: !!s.flowmarks, polmarks: !!s.polmarks,
17414    // Per-mode filters (user request 2026-08-24): premium floor + how many to draw.
17415    dpMinPrem: s.dpMinPrem || '0', dpMax: s.dpMax || '5',
17416    // Merge tolerance, % of price. Defaults ON - four lines inside a 5-cent range is the problem
17417    // this exists to solve, so off-by-default would ship the bug as the default.
17418    dpMerge: s.dpMerge != null ? s.dpMerge : '0.05',
17419    dpStyle: s.dpStyle || 'solid',
17420    fmMinPrem: s.fmMinPrem || '100000', fmShow: s.fmShow || 'top', fmMax: s.fmMax || '25', glStyle: s.glStyle || 'lines', chStyle: s.chStyle || 'lines',
17421    // Flow Marks label + range (user 2026-08-25). Label parts are independent toggles so any
17422    // combination works; fmDetail decides whether the contract detail goes on every mark or only
17423    // the three biggest; fmDay drops yesterday's prints off yesterday's candles.
17424    fmDay: s.fmDay || 'today',
17425    fmDetail: s.fmDetail || 'all',
17426    fmLblPrem: s.fmLblPrem != null ? !!s.fmLblPrem : true,
17427    fmLblStrike: !!s.fmLblStrike,
17428    fmLblExp: !!s.fmLblExp,
17429    sesPD: s.sesPD != null ? !!s.sesPD : !!s.session,
17430    sesON: s.sesON != null ? !!s.sesON : !!s.session,
17431    sesOR: s.sesOR != null ? !!s.sesOR : !!s.session,
17432    modeColors: Object.assign({}, MODE_COLOR_DEFAULTS, s.modeColors || {}) };
17433}
17434function _anySession() { return indState.sesPD || indState.sesON || indState.sesOR; }
17435// #rrggbb ->
17435 rgba(r,g,b,a) for the translucent band/shading fills.
17436function _hexA(hex, a) {
17437  const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || '');
17438  if (!m) return 'rgba(148,163,184,' + a + ')';
17439  return 'rgba(' + parseInt(m[1], 16) + ',' + parseInt(m[2], 16) + ',' + parseInt(m[3], 16) + ',' + a + ')';
17440}
17441// Update one mode line color and repaint (price lines + canvas overlays).
17442function setModeColor(key, val) {
17443  if (!indState.modeColors) indState.modeColors = Object.assign({}, MODE_COLOR_DEFAULTS);
17444  indState.modeColors[key] = val; saveIndicatorState();
17445  applyModePriceLines(); redrawDrawings();
17446  try { _mRefreshPanes(false); } catch (e) {}
17447}
17448function saveIndicatorState() { try { localStorage.setItem(INDICATOR_KEY, JSON.stringify(indState)); } catch (e) {} }
17449
17450// Collapse/expand every mode category at once — with this many modes, hunting for one category
17451// beat scrolling past all of them. Mirrors what clicking each .modecat header does.
17452function toggleAllModeCats() {
17453  const cats = [...document.querySelectorAll('#indicatorPanel .modecat')];
17454  if (!cats.length) return;
17455  const anyOpen = cats.some(c => !c.classList.contains('collapsed'));
17456  for (const c of cats) if (!c.classList.contains('collapsed') === anyOpen) { try { toggleModeCat(c); } catch (e) {} }
17457  _syncCollapseAllLbl();
17458}
17459function _syncCollapseAllLbl() {
17460  const b = document.getElementById('modeCollapseAll'); if (!b) return;
17461  const cats = [...document.querySelectorAll('#indicatorPanel .modecat')];
17462  b.textContent = cats.some(c => !c.classList.contains('collapsed')) ? 'COLLAPSE ALL' : 'EXPAND ALL';
17463}
17464// How much room the panel has depends on where the Modes button sits — and once it's been
17465// dragged, on where the user put it. Measure it rather than guessing a constant, so a short
17466// window still gets a usable list instead of a clipped one.
17467function _sizeIndicatorPanel() {
17468  const p = document.getElementById('indicatorPanel');
17469  if (!p || p.style.display !== 'block') return;
17470  try {
17471    _modePanelClamp();
17472    const top = p.getBoundingClientRect().top;
17473    p.style.maxHeight = Math.max(220, window.innerHeight - top - 14) + 'px';
17474  } catch (e) {}
17475}
17476window.addEventListener('resize', _sizeIndicatorPanel);
17477
17478// ── Draggable Modes panel (user 2026-08-24) ────────────────────────────────────────────────
17479// Dragging switches it from absolute (anchored under the button, inside #indicatorWrap) to FIXED
17480// viewport coordinates, so it can sit anywhere on screen instead of being trapped in the
17481// toolbar's stacking context. Position persists; double-clicking the header snaps it back.
17482const MODEPOS_KEY = 'gf_modePanelPos';
17483function _modePanelSaved() {
17484  try { const v = JSON.parse(localStorage.getItem(MODEPOS_KEY) || 'null');
17485        return (v && isFinite(v.x) && isFinite(v.y)) ? v : null; } catch (e) { return null; }
17486}
17487// Keep it reachable: a panel dragged off-screen (or left there when the window shrank) would be
17488// unrecoverable, so always keep a grabbable strip of the header inside the viewport.
17489function _modePanelClamp() {
17490  const p = document.getElementById('indicatorPanel');
17491  if (!p || p.style.position !== 'fixed') return;
17492  const w = p.offsetWidth || 300;
17493  const x = Math.max(4, Math.min(window.innerWidth - w - 4, parseFloat(p.style.left) || 0));
17494  const y = Math.max(4, Math.min(window.innerHeight - 46, parseFloat(p.style.top) || 0));
17495  p.style.left = x + 'px'; p.style.top = y + 'px';
17496}
17497function _modePanelApplyPos() {
17498  const p = document.getElementById('indicatorPanel'); if (!p) return;
17499  const sv = _modePanelSaved();
17500  // Anchored it sits inside #indicatorWrap and z-index 50 is enough; once FIXED it competes at
17501  // the root, so lift it above the chart chrome but stay under the modals (9999+).
17502  if (!sv) { p.style.position = 'absolute'; p.style.left = '0px'; p.style.top = '30px'; p.style.zIndex = '50'; return; }
17503  p.style.position = 'fixed'; p.style.left = sv.x + 'px'; p.style.top = sv.y + 'px'; p.style.zIndex = '1200';
17504  _modePanelClamp();
17505}
17506function _modePanelResetPos() {
17507  try { localStorage.removeItem(MODEPOS_KEY); } catch (e) {}
17508  _modePanelApplyPos(); _sizeIndicatorPanel();
17509}
17510(function initModePanelDrag() {
17511  const start = (ev) => {
17512    const head = document.getElementById('modePanelHead');
17513    const p = document.getElementById('indicatorPanel');
17514    if (!head || !p || !head.contains(ev.target)) return;
17515    if (ev.target.closest('button')) return;            // COLLAPSE ALL / close keep working
17516    const t = ev.touches ? ev.touches[0] : ev;
17517    const r = p.getBoundingClientRect();
17518    // Freeze the CURRENT on-screen box before switching coordinate systems, so it doesn't jump.
17519    p.style.position = 'fixed'; p.style.left = r.left + 'px'; p.style.top = r.top + 'px'; p.style.zIndex = '1200';
17520    const dx = t.clientX - r.left, dy = t.clientY - r.top;
17521    const move = (e2) => {
17522      const m = e2.touches ? e2.touches[0] : e2;
17523      p.style.left = (m.clientX - dx) + 'px';
17524      p.style.top = (m.clientY - dy) + 'px';
17525      if (e2.cancelable) e2.preventDefault();
17526    };
17527    const end = () => {
17528      document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', end);
17529      document.removeEventListener('touchmove', move); document.removeEventListener('touchend', end);
17530      _modePanelClamp();
17531      try { localStorage.setItem(MODEPOS_KEY, JSON.stringify({ x: parseFloat(p.style.left) || 0, y: parseFloat(p.style.top) || 0 })); } catch (e) {}
17532      _sizeIndicatorPanel();
17533    };
17534    document.addEventListener('mousemove', move); document.addEventListener('mouseup', end);
17535    document.addEventListener('touchmove', move, { passive: false }); document.addEventListener('touchend', end);
17536    ev.preventDefault();
17537  };
17538  document.addEventListener('mousedown', start);
17539  document.addEventListener('touchstart', start, { passive: false });
17540  document.addEventListener('dblclick', (e) => {
17541    const head = document.getElementById('modePanelHead');
17542    if (head && head.contains(e.target) && !e.target.closest('button')) _modePanelResetPos();
17543  });
17544})();
17545function toggleIndicatorPanel() {
17546  const p = document.getElementById('indicatorPanel');
17547  if (!p) return;
17548  p.style.display = (p.style.display === 'none' || !p.style.display) ? 'block' : 'none';
17549  if (p.style.display !== 'block') return;
17550  _modePanelApplyPos();   // reopen where the user left it
17551  _sizeIndicatorPanel();
17552  renderMaRows();
17553  const setChk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; };
17554  setChk('mode-gexlines', indState.gexlines); setChk('mode-charm', indState.charm); setChk('mode-vwap', indState.vwap); setChk('mode-flow', indState.flow); setChk('mode-gexbub', indState.gexbub); setChk('mode-eodpin', indState.eodpin);
17555  setChk('mode-maxpain', indState.maxpain); setChk('mode-vanna', indState.vanna); setChk('mode-flowwall', indState.flowwall);
17556  setChk('mode-kingmagnet', indState.kingmagnet); setChk('mode-magnet', indState.magnet); setChk('mode-em', indState.em); setChk('mode-darkpool', indState.darkpool); setChk('mode-flowmarks', indState.flowmarks); setChk('mode-polmarks', indState.polmarks);
17557  setChk('mode-sesPD', indState.sesPD); setChk('mode-sesON', indState.sesON); setChk('mode-sesOR', indState.sesOR);
17558  try{ ['darkpool','flowmarks'].forEach(function(k){ var r=document.getElementById(k+'-opts'); if(r) r.style.display = indState[k] ? 'flex' : 'none'; }); if(typeof _syncModeOpts==='function') _syncModeOpts(); }catch(e){}
17559  try{ if(typeof _initModeCats==='function') _initModeCats(); if(typeof _syncCollapseAllLbl==='function') _syncCollapseAllLbl(); }catch(e){}   // restore collapsed categories
17560  const C = indState.modeColors || MODE_COLOR_DEFAULTS;
17561  const setCol = (k) => { const e = document.getElementById('col-' + k); if (e) e.value = C[k] || MODE_COLOR_DEFAULTS[k]; };
17562  ['vwap', 'maxpain', 'vannaHi', 'vannaLo', 'vannaFlip', 'flowCall', 'flowPut', 'magnet', 'em', 'pd', 'on', 'or'].forEach(setCol);
17563}
17564// Toggle a boolean chart mode (charm levels, VWAP, 0DTE flow bars).
17565function setMode(key, on) {
17566  // Ghost Desk: the Modes menu edits the FOCUSED chart. Chart 1 keeps the full global behaviour
17567  // below; a focused pane gets its own copy of the mode so all three charts' overlays coexist.
17568  try {
17569    const _p = (typeof _mFocusedPaneId === 'function') ? _mFocusedPaneId() : null;
17570    if (_p != null) {
17571      if (_MPANE_MODES.includes(key) || key === 'mas') { _mSetMode(_p, key, on); }
17572      else { const el = document.getElementById('mode-' + key); if (el) el.checked = false; }   // chart-1 only
17573      return;
17574    }
17575  } catch (e) {}
17576  indState[key] = on; saveIndicatorState();
17577  if (key === 'flow') { on ? startFlowPolling() : stopFlowPolling(); }
17578  if (key === 'gexlines' && typeof applyGexLevelLines === 'function') applyGexLevelLines();
17579  if (key === 'kingmagnet') { try { _kmReset(); _kmTick(); applyGexLevelLines(); } catch (e) {} }
17580  if (key === 'gexbub') { var _op=document.getElementById('gexbub-opts'); if(_op) _op.style.display = on?'flex':'none'; if(typeof _syncGexbubOpts==='function') _syncGexbubOpts(); if (on && _gexBubAllowed()) { _gexBubStart(); if (typeof _gexBubSeedPreview === 'function') _gexBubSeedPreview(); } else { _gexBubStop(); } if (typeof renderGexBubbles === 'function') renderGexBubbles(); }
17581  if (key === 'eodpin') { if (on && typeof _lastTradePlan !== 'undefined' && _lastTradePlan) { try { var _ep = buildTradePlan({ callWall:_lastTradePlan.d.callWall, putWall:_lastTradePlan.d.putWall, kingNode:_lastTradePlan.d.kingNode, volNode:_lastTradePlan.d.volNode, totalGex:_lastTradePlan.d.totalGex }, _lastTradePlan.d.spot, _lastTradePlan.d.filtered); window._chartEodPin = (_ep && _ep.eod) ? _ep.eod.pin : null; } catch (e) {} } redrawDrawings(); }
17582  if (key === 'charm' && typeof computeCharmLevels === 'function') computeCharmLevels();
17583  if (_DATA_MODES.includes(key) && typeof computeChartModes === 'function') computeChartModes();
17584  if ((key === 'sesPD' || key === 'sesON' || key === 'sesOR') && typeof computeSessionLevels === 'function') { computeSessionLevels(); applyModePriceLines(); }
17585  if (key === 'darkpool' || key === 'flowmarks') { var _mo = document.getElementById(key + '-opts'); if (_mo) _mo.style.display = on ? 'flex' : 'none'; if (typeof _syncModeOpts === 'function') _syncModeOpts(); }
17586  if (key === 'darkpool' && typeof computeDarkpoolLevels === 'function') computeDarkpoolLevels();
17587  if (key === 'flowmarks' && typeof computeFlowMarks === 'function') computeFlowMarks();
17588  if (key === 'polmarks' && typeof computePoliticianMarks === 'function') computePoliticianMarks();
17589  computeIndicators(); redrawDrawings();
17590  // Ghost Desk: charts 2-3 follow chart 1's modes unless a pane holds its own override.
17591  try { if (typeof _mFollowMode === 'function') _mFollowMode(key); } catch (e) {}
17592}
17593// Render the up-to-3 moving-average config rows from state.
17594function renderMaRows() {
17595  const wrap = document.getElementById('maRows'); if (!wrap) return;
17596  const S = 'font-size:11px;padding:1px 2px;background:var(--t-bg2,#0b1220);color:#e2e8f0;border:1px solid var(--border);border-radius:4px';
17597  wrap.innerHTML = indState.mas.map((m, i) =>
17598    '<div style="display:flex;align-items:center;gap:4px;padding:3px 0">' +
17599      '<input type="checkbox" ' + (m.on ? 'checked' : '') + ' onchange="setMA(' + i + ',\'on\',this.checked)" title="Show" style="cursor:pointer">' +
17600      '<select onchange="setMA(' + i + ',\'type\',this.value)" style="' + S + '"><option value="ema"' + (m.type === 'ema' ? ' selected' : '') + '>EMA</option><option value="sma"' + (m.type === 'sma' ? ' selected' : '') + '>SMA</option></select>' +
17601      '<input type="number" min="1" max="400" value="' + m.len + '" onchange="setMA(' + i + ',\'len\',this.value)" title="Period" style="width:44px;' + S + '">' +
17602      '<input type="color" value="' + m.color + '" onchange="setMA(' + i + ',\'color\',this.value)" title="Color" style="width:26px;height:20px;padding:0;border:1px solid var(--border);border-radius:4px;background:none;cursor:pointer">' +
17603      '<select onchange="setMA(' + i + ',\'width\',parseFloat(this.value))" title="Line width" style="' + S + '">' + MA_WIDTHS.map(w => '<option value="' + w + '"' + (m.width === w ? ' selected' : '') + '>' + w + 'px</option>').join('') + '</select>' +
17604      '<select onchange="setMA(' + i + ',\'style\',this.value)" title="Line style" style="' + S + '"><option value="solid"' + (m.style === 'solid' ? ' selected' : '') + '>solid</option><option value="dotted"' + (m.style === 'dotted' ? ' selected' : '') + '>dotted</option></select>' +
17605    '</div>'
17606  ).join('');
17607}
17608function setMA(i, field, val) {
17609  if (!indState.mas[i]) return;
17610  if (field === 'len') val = Math.max(1, Math.min(400, parseInt(val, 10) || 1));
17611  indState.mas[i][field] = val; saveIndicatorState();
17612  computeIndicators(); redrawDrawings();
17613  try { _mRefreshPanes(false); } catch (e) {}
17614}
17615// The Modes panel used to close on any outside click, which made it useless while actually
17616// working on the chart — every toggle meant reopening it (user 2026-08-24). It now stays open
17617// until you close it deliberately: the Modes button, the × in its header, or Escape.
17618document.addEventListener('keydown', (e) => {
17619  if (e.key !== 'Escape') return;
17620  const panel = document.getElementById('indicatorPanel');
17621  if (panel && panel.style.display === 'block') { panel.style.display = 'none'; }
17622});
17623
17624// Exponential moving average over a close[] series (nulls carried forward).
17625function emaArr(closes, len) {
17626  const out = new Array(closes.length).fill(null);
17627  const k = 2 / (len + 1);
17628  let prev = null;
17629  for (let i = 0; i < closes.length; i++) {
17630    const c = closes[i];
17631    if (c == null) { out[i] = prev; continue; }
17632    prev = (prev == null) ? c : c * k + prev * (1 - k);
17633    out[i] = prev;
17634  }
17635  return out;
17636}
17637// Session VWAP + ±1σ bands. Resets each ET trading day; volume-weighted (falls back to unit
17638// weight if a bar has no volume). Returns [{ vwap, up, lo }] aligned to candles, null per bar
17639// until a session has data. Intraday only — callers gate on the timeframe.
17640const _VWAP_ETDAY = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Ne
17640w_York', year: 'numeric', month: '2-digit', day: '2-digit' });
17641function computeVWAP(candles) {
17642  const out = new Array(candles.length).fill(null);
17643  let day = null, cV = 0, cPV = 0, cP2V = 0;
17644  for (let i = 0; i < candles.length; i++) {
17645    const c = candles[i]; if (c.high == null || c.low == null || c.close == null) continue;
17646    const sec = c._ts || 0;
17647    const d = sec ? _VWAP_ETDAY.format(new Date(sec * 1000)) : String(c.time).slice(0, 10);
17648    if (d !== day) { day = d; cV = cPV = cP2V = 0; }
17649    const tp = (c.high + c.low + c.close) / 3;
17650    const v = (c.volume > 0) ? c.volume : 1;
17651    cV += v; cPV += tp * v; cP2V += tp * tp * v;
17652    const vwap = cPV / cV;
17653    const sd = Math.sqrt(Math.max(0, cP2V / cV - vwap * vwap));
17654    out[i] = { vwap, up: vwap + sd, lo: vwap - sd };
17655  }
17656  return out;
17657}
17658// Charm Flip: the strike (interpolated) where net charm crosses zero nearest spot — the pivot
17659// where dealer time-decay hedging reverses sign. rows = [{strike, charmNet}].
17660function computeCharmFlip(rows, spot) {
17661  const arr = (rows || []).filter(r => r.charmNet != null && isFinite(r.charmNet)).slice().sort((a, b) => a.strike - b.strike);
17662  if (arr.length < 3) return null;
17663  let best = null, bestDist = Infinity;
17664  for (let i = 1; i < arr.length; i++) {
17665    const a = arr[i - 1], b = arr[i];
17666    const crosses = (a.charmNet < 0 && b.charmNet >= 0) || (a.charmNet > 0 && b.charmNet <= 0);
17667    if (!crosses || a.charmNet === b.charmNet) continue;
17668    const t = (0 - a.charmNet) / (b.charmNet - a.charmNet);         // interpolate the zero
17669    const k = a.strike + t * (b.strike - a.strike);
17670    const dist = spot ? Math.abs(k - spot) : 0;
17671    if (dist < bestDist) { bestDist = dist; best = k; }
17672  }
17673  return best;
17674}
17675// Charm High/Low/Flip as Lightweight-Charts price lines — the label sits on the chart (title) and
17676// the price gets a colored tag on the axis, instead of hand-drawn text overlapping the price scale.
17677// The translucent bands behind the candles are still painted separately in renderIndicators.
17678function applyCharmPriceLines() {
17679  if (typeof chartSeries === 'undefined' || !chartSeries) return;
17680  for (const pl of charmPriceLines) { try { chartSeries.removePriceLine(pl); } catch (e) {} }
17681  charmPriceLines = [];
17682  if (!(isPaidUser || (typeof IS_DEMO !== 'undefined' && IS_DEMO)) || !indState.charm || !window.chartCharmLevels) return;   // demo may draw Charm Levels (user 2026-09-05)
17683  const CL = window.chartCharmLevels, LS = LightweightCharts.LineStyle;
17684  const add = (price, color, title, style) => { if (price == null) return; try { charmPriceLines.push(chartSeries.createPriceLine({ price, color, lineWidth: 1, lineStyle: style, axisLabelVisible: true, lineVisible: (indState.chStyle || 'lines') !== 'axis', title })); } catch (e) {} };
17685  add(CL.hi.strike, '#22d3ee', 'Charm Positive', LS.Solid);   // positive charm = cyan (up-pull)
17686  add(CL.lo.strike, '#f59e0b', 'Charm Negative', LS.Solid);   // negative charm = amber (down-pull)
17687  add(CL.flip, '#a855f7', 'Charm Flip', LS.Dashed);
17688}
17689// Compute the charm levels pinned to the nearest expiry (0DTE if present, else the closest weekly),
17690// independent of the GEX ALL/0DTE/WK toggle. Fetches the 0DTE greek exposure separately, computes
17691// spot-weighted High/Low walls + the flip, then repaints the price lines + bands.
17692let _charmToken = 0;
17693let _charmCache = { ticker: null, levels: null, t: 0 };
17694let _chartAutoTimer = null;
17695async function computeCharmLevels(force) {
17696  const ticker = chartTicker;
17697  // Compute for any paid user (so the Trade Plan can read the SAME 0DTE charm the chart uses); the
17698  // chart LINES are still gated on indState.charm inside applyCharmPriceLines.
17699  if (!(isPaidUser || (typeof IS_DEMO !== 'undefined' && IS_DEMO)) || !ticker) { window.chartCharmLevels = null; _charmCache = { ticker: null, levels: null, t: 0 }; applyCharmPriceLines(); redrawDrawings(); return; }   // demo may use Charm Levels (user 2026-09-05)
17700  const sameTk = (_charmCache.ticker === ticker);
17701  // Same ticker + recent cache and not a forced refresh → reuse. Charm is 0DTE-pinned, so the
17702  // ALL/0DTE/WK toggle doesn't change it — reusing avoids the refetch/clear that made it BLINK.
17703  if (!force && sameTk && _charmCache.levels && (Date.now() - _charmCache.t) < 45000) {
17704    window.chartCharmLevels = _charmCache.levels; applyCharmPriceLines(); return;
17705  }
17706  if (!sameTk) { window.chartCharmLevels = null; window._chartEod = null; window._chartEodPin = null; applyCharmPriceLines(); }   // clear ONLY when the ticker changes (AUDIT FIX: EOD pin used to survive the switch and draw the OLD ticker's price)
17707  const spot = window._chartSpot || (chartCandles.length ? chartCandles[chartCandles.length - 1].close : 0);
17708  const token = ++_charmToken;
17709  try {
17710    const j = await _basisGexJson(ticker, '0dte');   // nearest expiry (0DTE if present, else closest)
17711    let rows = (j && (j.data || j)) || [];
17712    if (!Array.isArray(rows)) rows = [];
17713    const strikes = rows.map(s => ({ strike: parseFloat(s.strike || 0), charmNet: (parseFloat(s.call_charm || 0) - parseFloat(s.put_charm || 0)) }))   // REST convention: net = call − put (WS normalized server-side)
17714                        .filter(s => s.strike > 0 && isFinite(s.charmNet));
17715    // Restrict to a 0DTE-realistic band around spot (±2.5%) — for indices like SPX, huge OI at far
17716    // round strikes (7700/7800/…) otherwise pulls the walls/flip to prices that won't trade today.
17717    // Fall back to all strikes if the band is too sparse (low-priced tickers with wide strikes).
17718    const BAND = spot * 0.025;
17719    let src = (spot > 0) ? strikes.filter(s => Math.abs(s.strike - spot) <= BAND) : strikes;
17720    if (src.length < 3) src = strikes;
17721    // Weight toward spot within the band (see memory to revert to raw max/min).
17722    const sig = Math.max(spot * 0.03, 1e-6);
17723    const W = (spot > 0) ? (k) => Math.exp(-0.5 * Math.pow((k - spot) / sig, 2)) : () => 1;
17724    let hi = null, lo = null;
17725    for (const s of src) { const wv = s.charmNet * W(s.strike); if (!hi || wv > hi.w) hi = { strike: s.strike, val: s.charmNet, w: wv }; if (!lo || wv < lo.w) lo = { strike: s.strike, val: s.charmNet, w: wv }; }
17726    const flip = computeCharmFlip(src, spot);
17727    if (token !== _charmToken || ticker !== chartTicker) return;   // superseded by a newer load/toggle
17728    const levels = (hi && lo && hi.strike !== lo.strike) ? { hi, lo, flip } : null;
17729    _charmCache = { ticker, levels, t: Date.now() };
17730    window.chartCharmLevels = levels;
17731    // ── EOD pin (from this SAME 0DTE data) — gamma centre-of-mass near spot + 0DTE max-pain, only
17732    // lightly nudged by the charm flip. Interpolated (NOT snapped) so it can land between strikes like
17733    // the real close. This replaces the ALL-basis, single-strike, charm-heavy pin for indices.
17734    try {
17735      const gR = rows.map(s => { const cg = parseFloat(s.call_gex || s.call_gamma_oi || 0), pg = parseFloat(s.put_gex || s.put_gamma_oi || 0); return { k: parseFloat(s.strike || 0), g: cg + pg, cwv: Math.abs(cg), pwv: Math.abs(pg) }; }).filter(s => s.k > 0 && isFinite(s.g));
17736      if (gR.length >= 3 && spot > 0) {
17737        // RETUNED 2026-08-04 — backtested on 7 recorded sessions × 8 tickers (5-min snapshots vs
17738        // the actual close). The old 3-way blend (centroid×1.6 + max-pain×1 + charm-flip×0.5)
17739        // missed the close by ~0.73–0.88% on average; the close is a near-martingale, so the
17740        // CURRENT PRICE alone missed by only ~0.27–0.37%. Best tested estimator: spot with a 10%
17741        // tilt toward the TIGHT (±1.5%) |gamma| centre of mass — ties spot on mean error and
17742        // beats it on late-day median (0.09% vs 0.11% at 12:30 PT) while keeping a real
17743        // dealer-structure lean in the line. Max pain and the charm flip are kept as CONTEXT
17744        // fields only — folding them into the pin value measurably hurt accuracy.
17745        const band = spot * 0.015; let wsum = 0, ksum = 0;
17746        for (const s of gR) { if (Math.abs(s.k - spot) > band) continue; const w = Math.abs(s.g); wsum += w; ksum += s.k * w; }
17747        const centroid = wsum > 0 ? ksum / wsum : null;                 // |gamma|-weighted centre of mass near spot
17748        const gs = [...gR].sort((a, b) => a.k - b.k); let pain = null, minPay = Infinity;
17749        for (const o1 of gs) { let pay = 0; for (const o of gs) { if (o.k < o1.k) pay += (o1.k - o.k) * o.cwv; else if (o.k > o1.k) pay += (o.k - o1.k) * o.pwv; } if (pay < minPay) { minPay = pay; pain = o1.k; } }
17750        const cflip = (flip != null && Math.abs(flip - spot) < spot * 0.05) ? flip : null;
17751        let pinV = centroid != null ? spot + 0.10 * (centroid - spot) : spot;
17752        // SNAP TO A REAL STRIKE (user 2026-08-14): pinning happens AT str
17752ikes. Gamma-heaviest
17753        // strike within 0.6% of the estimate, else the nearest strike.
17754        try {
17755          const nb = spot * 0.006; let best = null;
17756          for (const s of gR) { if (Math.abs(s.k - pinV) <= nb) { if (!best || Math.abs(s.g) > Math.abs(best.g)) best = s; } }
17757          if (!best) { let bd = Infinity; for (const s of gR) { const d = Math.abs(s.k - pinV); if (d < bd) { bd = d; best = s; } } }
17758          if (best) pinV = best.k;
17759        } catch (e) {}
17760        const gtot = gR.reduce((a, s) => a + s.g, 0);
17761        const gflipK = (typeof gammaFlip === 'function') ? gammaFlip(gR.map(s => ({ strike: s.k, netGex: s.g })), spot) : null;
17762        const conf = (gflipK != null ? (spot >= gflipK ? 'strong' : 'weak') : (gtot >= 0 ? 'strong' : 'weak'));
17763        // ── SETTLEMENT BAND (2026-08-07) — the honest part of the pin ────────────────────────
17764        // Measured on 2,260 recorded samples: NO signal we have (gamma centroid, King, charm
17765        // flip, Vol Node, momentum) predicts the DIRECTION of the close — every correlation came
17766        // back inside ±0.07. What IS strongly predictable is the DISPERSION, from two things:
17767        //   • recent realized movement — quietest quartile closed within 0.15%, most active 0.78%
17768        //   • gamma regime — negative-gamma closes run ~28% wider than positive-gamma ones
17769        // and it all shrinks as the bell approaches. So the pin now ships with a band that says
17770        // how much room is actually left, instead of pretending to a precision it doesn't have.
17771        const _bandPct = (function(){
17772          try {
17773            // Realized move over the last ~30 min of candles (%).
17774            let rv = 0;
17775            const _cs = (typeof chartReplayCandles !== 'undefined' && chartReplayCandles) ? chartReplayCandles : [];
17776            if (_cs.length > 2) {
17777              const tf = (_cs.length > 1) ? Math.max(60, (_cs[_cs.length - 1]._ts || 0) - (_cs[_cs.length - 2]._ts || 0)) : 300;
17778              const back = Math.max(1, Math.min(_cs.length - 1, Math.round(1800 / tf)));
17779              const a = _cs[_cs.length - 1 - back], b = _cs[_cs.length - 1];
17780              if (a && b && a.close > 0) rv = Math.abs((b.close - a.close) / a.close) * 100;
17781            }
17782            // Fitted to the measured quartiles (0.02%→0.15, 0.33%→0.47, 1.0%→0.78 at 3:30 ET).
17783            let disp = 0.046 + 0.737 * Math.sqrt(Math.max(0, rv));
17784            // Regime: negative gamma closes measurably wider.
17785            if (conf !== 'strong') disp *= 1.28;
17786            // Time decay toward the bell — random-walk scaling on the minutes remaining.
17787            const _et = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' }));
17788            const _mins = _et.getHours() * 60 + _et.getMinutes();
17789            const _left = Math.max(0, Math.min(390, 960 - _mins));
17790            disp *= Math.max(0.30, Math.min(1.8, Math.sqrt(_left / 30)));
17791            // p80 band (~4 of 5 closes land inside) — measured p80/mean ratio is ~1.7.
17792            return Math.max(0.05, Math.min(3, disp * 1.7));
17793          } catch (e) { return null; }
17794        })();
17795        window._chartEod = { pin: pinV != null ? Math.round(pinV * 100) / 100 : null, conf, centroid: centroid != null ? Math.round(centroid * 100) / 100 : null, pain, charmPin: cflip, bandPct: _bandPct };
17796        window._chartEodPin = window._chartEod.pin;
17797        // EOD PIN LOCK (user 2026-08-13): from 15:00 ET the server freezes the pin at one level —
17798        // the chart's live estimate stops bouncing and draws the locked prediction instead.
17799        try { var _sl = window._eodPinSrv; if (_sl && _sl.tk === ticker && _sl.pin != null) { window._chartEodPin = _sl.pin; window._chartEod.pin = _sl.pin; } } catch (e) {}
17800        window._chartEodBand = (_bandPct != null && pinV != null) ? { lo: pinV * (1 - _bandPct / 100), hi: pinV * (1 + _bandPct / 100), pct: _bandPct } : null;
17801      }
17802    } catch (e) {}
17803    applyCharmPriceLines(); redrawDrawings();
17804    // Refresh the Trade Plan so its Charm Positive/Negative reflects this fresh 0DTE read.
17805    try { if (typeof _lastTradePlan !== 'undefined' && _lastTradePlan && typeof tradePlanTab !== 'undefined' && tradePlanTab !== 'tide' && typeof renderTradePlan === 'function') renderTradePlan(_lastTradePlan.ticker, _lastTradePlan.d); } catch (e) {}
17806  } catch (e) { if (token === _charmToken) { applyCharmPriceLines(); redrawDrawings(); } }
17807}
17808
17809// ── Chart Modes: additive greek / flow / price level overlays (admin) ─────────────────────────
17810// Each is an independent toggle in the Modes panel. They READ the same 0DTE greek data + flow the
17811// charm levels use and NEVER modify King / Call Wall / Put Wall / Vol Node / charm. Price-line modes
17812// live in their own managed array (modePriceLines);
17812 band/shading/histogram modes draw on the overlay
17813// canvas in renderIndicators. Data is spot-weighted (same Gaussian idea as the charm walls) so a
17814// far-OTM OI spike can't hijack a level.
17815const _DATA_MODES = ['maxpain', 'vanna', 'flowwall', 'magnet', 'em'];
17816function _anyDataMode() { return _DATA_MODES.some(k => indState[k]); }
17817window.chartModes = null;          // greek/flow levels {maxPain,vannaHi/Lo/Flip,flowCall/Put,magnet[],em,gexProfile[],gammaFlip}
17818window.chartSessionLevels = null;  // {pdH,pdL,onH,onL,orH,orL} from candles
17819window._chartGammaFlip = null;     // gamma-flip price for the regime shading
17820window._chartRegimeSign = null;    // +1/-1 whole-range regime when there's no flip crossing
17821
17822// Interpolated zero-cross of val(strike), the crossing nearest spot.
17823function _zeroCrossNearSpot(pts, spot) {
17824  const s = pts.filter(p => isFinite(p.val)).sort((a, b) => a.strike - b.strike);
17825  let best = null, bestD = Infinity;
17826  for (let i = 1; i < s.length; i++) {
17827    const a = s[i - 1], b = s[i];
17828    if ((a.val <= 0 && b.val >= 0) || (a.val >= 0 && b.val <= 0)) {
17829      const d = b.val - a.val, t = d === 0 ? 0 : (0 - a.val) / d;
17830      const k = a.strike + t * (b.strike - a.strike), dist = Math.abs(k - spot);
17831      if (dist < bestD) { bestD = dist; best = k; }
17832    }
17833  }
17834  return best;
17835}
17836// Expected move from the ATM 0DTE straddle (fallback: ATM IV daily move).
17837function _expectedMove(contracts, spot, today) {
17838  if (!Array.isArray(contracts) || !(spot > 0)) return null;
17839  const byK = {};
17840  for (const c of contracts) {
17841    const sym = c.option_symbol || c.option_chain_id || ''; const m = sym.match(/(\d{6})([CP])(\d{8})/); if (!m) continue;
17842    if (today && m[1] !== today) continue;                                     // 0DTE only
17843    const k = parseInt(m[3]) / 1000, cp = m[2];
17844    const mark = (parseFloat(c.last_price || c.mark || 0)) || ((parseFloat(c.nbbo_ask || 0) + parseFloat(c.nbbo_bid || 0)) / 2) || 0;
17845    const iv = parseFloat(c.implied_volatility || c.iv || 0) || 0;
17846    const o = byK[k] || (byK[k] = {});
17847    if (cp === 'C') { o.c = mark; o.civ = iv; } else { o.p = mark; o.piv = iv; }
17848  }
17849  const ks = Object.keys(byK).map(parseFloat).sort((a, b) => Math.abs(a - spot) - Math.abs(b - spot));
17850  for (const k of ks) { const o = byK[k]; if (o.c > 0 && o.p > 0) { const strad = o.c + o.p; return { up: spot + strad, dn: spot - strad, abs: strad, pct: strad / spot * 100, basis: 'straddle' }; } }
17851  for (const k of ks) { const o = byK[k]; const iv = Math.max(o.civ || 0, o.piv || 0); if (iv > 0) { const abs = spot * iv / Math.sqrt(252); return { up: spot + abs, dn: spot - abs, abs, pct: abs / spot * 100, basis: 'iv' }; } }
17852  return null;
17853}
17854// Pure: derive every data-driven mode level from the parsed 0DTE strike rows + flow + contracts.
17855function computeModeLevels(strikes, spot, flowByStrike, contracts, today) {
17856  if (!strikes.length || !(spot > 0)) return null;
17857  const BAND = spot * 0.05;
17858  const near = strikes.filter(s => Math.abs(s.strike - spot) <= BAND);
17859  const src = near.length >= 3 ? near : strikes;
17860  const sig = Math.max(spot * 0.025, 1e-6);
17861  const W = k => Math.exp(-0.5 * Math.pow((k - spot) / sig, 2));   // spot-weight
17862  const out = {};
17863  // Max Pain — strike minimizing total ITM option value (option writers' max profit).
17864  { let best = null, min = Infinity;
17865    for (const cand of src) { let pain = 0;
17866      for (const s of strikes) { if (s.strike < cand.strike) pain += (s.callOi || 0) * (cand.strike - s.strike); else if (s.strike > cand.strike) pain += (s.putOi || 0) * (s.strike - cand.strike); }
17867      if (pain < min) { min = pain; best = cand.strike; } }
17868    out.maxPain = best; }
17869  // Vanna walls (dealer net vanna = call_vanna − put_vanna, REST convention) + vanna flip.
17870  { let hi = null, lo = null;
17871    for (const s of src) { const wv = s.vNet * W(s.strike); if (!hi || wv > hi.w) hi = { strike: s.strike, w: wv }; if (!lo || wv < lo.w) lo = { strike: s.strike, w: wv }; }
17872    out.vannaHi = hi ? hi.strike : null; out.vannaLo = lo ? lo.strike : null;
17873    out.vannaFlip = _zeroCrossNearSpot(strikes.map(s => ({ strike: s.strike, val: s.vNet })), spot); }
17874  // Flow-weighted walls — |gamma| boosted by today's net ask-side flow premium at the strike.
17875  { const fp = (k, cp) => { const o = flowByStrike && flowByStrike.get(k + cp); return o ? o : 0; };
17876    let maxFp = 1; if (flowByStrike) for (const v of flowByStrike.values()) if (v > maxFp) maxFp = v;
17877    const wt = s => { const f = (fp(s.strike, 'C') + fp(s.strike, 'P')) / maxFp; return Math.abs(s.gNet) * (1 + 2 * f) * W(s.strike); };
17878    let cw = null, pw = null;
17879    for (const s of src) { const w = wt(s); if (s.strike >= spot) { if (!cw || w > cw.w) cw = { strike: s.strike, w }; } else { if (!pw || w > pw.w) pw = { strike: s.strike, w }; } }
17880    out.flowCall = cw ? cw.strike : null; out.flowPut = pw ? pw.strike : null; }
17881  // Magnet score — normalized blend of |gamma| + |charm| + |vanna| + OI + today's flow, spot-weighted.
17882  { const nz = arr => { let m = 0; for (const v of arr) if (Math.abs(v) > m) m = Math.abs(v); return m || 1; };
17883    const gM = nz(src.map(s => s.gNet)), cM = nz(src.map(s => s.chNet)), vM = nz(src.map(s => s.vNet)), oM = nz(src.map(s => s.oi));
17884    let fM = 1; if (flowByStrike) for (const v of flowByStrike.values()) if (v > fM) fM = v;
17885    const scored = src.map(s => { const f = flowByStrike ? ((flowByStrike.get(s.strike + 'C') || 0) + (flowByStrike.get(s.strike + 'P') || 0)) : 0;
17886      const raw = Math.abs(s.gNet) / gM + Math.abs(s.chNet) / cM + Math.abs(s.vNet) / vM + (s.oi || 0) / oM + f / fM;
17887      return { strike: s.strike, score: raw * W(s.strike) }; }).sort((a, b) => b.score - a.score).slice(0, 3);
17888    out.magnet = scored; }
17889  out.em = _expectedMove(contracts, spot, today);
17890  // GEX profile (net gamma per strike) for the right-axis histogram.
17891  { const prof = src.map(s => ({ strike: s.strike, net: s.gNet })); let mx = 0; for (const p of prof) if (Math.abs(p.net) > mx) mx = Math.abs(p.net); out.gexProfile = prof; out.gexProfileMax = mx || 1; }
17892  // Gamma flip = cumulative net-gamma zero-cross (same convention as gammaFlip()).
17893  { const s = [...strikes].sort((a, b) => a.strike - b.strike); let cum = 0, prevK = null, prevC = 0, flip = null;
17894    for (const d of s) { prevC = cum; cum += d.gNet; if (prevK !== null && ((prevC < 0 && cum >= 0) || (prevC > 0 && cum <= 0))) { const dn = cum - prevC, t = dn === 0 ? 0 : (0 - prevC) / dn; flip = prevK + t * (d.strike - prevK); break; } prevK = d.strike; }
17895    out.gammaFlip = flip;
17896    // Total net gamma sign — the fallback regime when there's no zero-cross in the loaded strikes.
17897    out.gammaTotal = strikes.reduce((a, d) => a + d.gNet, 0); }
17898  return out;
17899}
17900// Today's net ask-side (buy) flow premium per 0DTE strike -> Map('<strike><C|P>' -> premium).
17901async function _flowPremByStrike(tk) {
17902  try {
17903    const j = await fetch('/api/flow/' + encodeURIComponent(tk) + '?min_prem=20000').then(r => r.json());
17904    let rows = (j && (j.data || j)) || []; if (!Array.isArray(rows)) rows = [];
17905    const today = _todayET(), m = new Map();
17906    for (const f of rows) {
17907      const exp = String(f.expiry || f.expires || f.expiry_date || '').slice(0, 10); if (exp !== today) continue;
17908      const strike = parseFloat(f.strike || 0); if (!strike) continue;
17909      const isC = String(f.type || f.option_type || '').toUpperCase().includes('CALL');
17910      const askP = parseFloat(f.total_ask_side_prem || 0), bidP = parseFloat(f.total_bid_side_prem || 0);
17911      if (!(askP >= bidP)) continue;                                            // net buys only
17912      const prem = parseFloat(f.total_premium || f.premium || 0) || 0;
17913      const key = strike + (isC ? 'C' : 'P'); m.set(key, (m.get(key) || 0) + prem);
17914    }
17915    return m;
17916  } catch (e) { return new Map(); }
17917}
17918// Gather the data (0DTE greeks + flow + contracts), compute the levels, repaint the price lines.
17919let _modesToken = 0;
17920async function computeChartModes() {
17921  const ticker = chartTicker;
17922  if (!isPaidUser || !ticker || !_anyDataMode()) { window.chartModes = null; window._chartGammaFlip = null; window._chartRegimeSign = null; applyModePriceLines(); redrawDrawings(); return; }
17923  const spot = window._chartSpot || (chartCandles.length ? chartCandles[chartCandles.length - 1].close : 0);
17924  const token = ++_modesToken;
17925  try {
17926    const j = await _basisGexJson(ticker, '0dte');   // nearest expiry, like the charm levels
17927    let rows = (j && (j.data || j)) || []; if (!Array.isArray(rows)) rows = [];
17928    const strikes = rows.map(s => ({
17929      strike: parseFloat(s.strike || 0),
17930      gNet: (parseFloat(s.call_gex || s.call_gamma_oi || 0) + parseFloat(s.put_gex || s.put_gamma_oi || 0)),
17931      // REST convention (2026-08-31): put vanna/charm arrive SAME-sign-as-call → net = call − put.
17932      // WS rows are normalized to this in server _wsGexRow, so subtraction is right 24/7.
17933      vNet: (parseFloat(s.call_vanna || 0) - parseFloat(s.put_vanna || 0)),
17934      chNet: (parseFloat(s.call_charm || 0) - parseFloat(s.put_charm || 0)),
17935      callOi: parseFloat(s.call_oi || 0), putOi: parseFloat(s.put_oi || 0),
17936      oi: parseFloat(s.open_interest || 0) || (parseFloat(s.call_oi || 0) + parseFloat(s.put_oi || 0)),
17937    })).filter(s => s.strike > 0);
17938    let flowByStrike = null;
17939    if (indState.flowwall || indState.magnet) flowByStrike = await _flowPremByStrike(ticker);
17940    const contracts = (chartContractsCache[ticker] && chartContractsCache[ticker].arr) || [];
17941    const today = _todayET().slice(2).replace(/-/g, '');                        // YYMMDD for option_symbol match
17942    const res = computeModeLevels(strikes, spot, flowByStrike, contracts, today);
17943    if (token !== _modesToken || ticker !== chartTicker) return;                // superseded
17944    window.chartModes = res;
17945    window._chartGammaFlip = res ? res.gammaFlip : null;
17946    // No zero-cross → whole visible range is one regime; sign from total net gamma (for the shading fallback).
17947    window._chartRegimeSign = (res && res.gammaFlip == null) ? (res.gammaTotal >= 0 ? 1 : -1) : null;
17948    applyModePriceLines(); redrawDrawings();
17949  } catch (e) {}
17950}
17951// ET date + minute-of-day for a unix-seconds timestamp (for the session levels).
17952function _etParts(ts) {
17953  const f = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
17954  const parts = {}; for (const p of f.formatToParts(new Date(ts * 1000))) parts[p.type] = p.value;
17955  let hh = parseInt(parts.hour); if (hh === 24) hh = 0;
17956  return { date: parts.year + '-' + parts.month + '-' + parts.day, min: hh * 60 + parseInt(parts.minute) };
17957}
17958// Prior-day RTH high/low, overnight high/low, and opening-range high/low from the loaded candles.
17959// Prior-day / overnight / opening-range levels. Computed from a FIXED 5-minute feed (not the displayed
17960// candles) so the values are identical on every timeframe — deriving them from the on-screen candles
17961// made them shift, because each timeframe stamps bars differently and wide bars (1h/4h/1d) span or
17962// straddle the 9:30–16:00 window, mis-bucketing the session boundaries.
17963let _sessionCache = { ticker: null, arr: null, t: 0 };
17964async function computeSessionLevels() {
17965  if (!_anySession() || !isPaidUser) { window.chartSessionLevels = null; applyModePriceLines(); return; }
17966  const ticker = chartTicker; if (!ticker) return;
17967  try {
17968    let arr;
17969    if (_sessionCache.ticker === ticker && _sessionCache.arr && (Date.now() - _sessionCache.t) < 300000) {
17970      arr = _sessionCache.arr;                                                   // 5-min cache to avoid refetching every tick
17971    } else {
17972      const j = await fetch('/api/candles/' + ticker + '?range=5d&interval=5m').then(r => r.json());
17973      arr = (typeof rawCandlesFromJson === 'function') ? rawCandlesFromJson(j) : [];
17974      if (arr && arr.length) _sessionCache = { ticker, arr, t: Date.now() };
17975    }
17976    if (ticker !== chartTicker) return;                                          // switched away mid-fetch
17977    if (!arr || arr.length < 3) { window.chartSessionLevels = null; applyModePriceLines(); return; }
17978    const cs = arr.map(c => ({ c, p: _etParts(c.time) }));                        // c.time is UTC seconds (Yahoo)
17979    const dates = [...new Set(cs.map(x => x.p.date))].sort();
17980    const today = dates[dates.length - 1], prev = dates[dates.length - 2];       // last two trading days present
17981    const RTH = x => x.p.min >= 570 && x.p.min < 960;                            // 9:30–16:00 ET
17982    let pdH = -Infinity, pdL = Infinity, onH = -Infinity, onL = Infinity, orH = -Infinity, orL = Infinity;
17983    for (const x of cs) {
17984      const { c, p } = x;
17985      if (prev && p.date === prev && RTH(x)) { pdH = Math.max(pdH, c.high); pdL = Math.min(pdL, c.low); }
17986      if ((prev && p.date === prev && p.min >= 960) || (p.date === today && p.min < 570)) { onH = Math.max(onH, c.high); onL = Math.min(onL, c.low); }
17987      if (p.date === today && p.min >= 570 && p.min < 585) { orH = Math.max(orH, c.high); orL = Math.min(orL, c.low); }
17988    }
17989    const fin = v => isFinite(v) ? v : null;
17990    window.chartSessionLevels = { pdH: fin(pdH), pdL: fin(pdL), onH: fin(onH), onL: fin(onL), orH: fin(orH), orL: fin(orL) };
17991    applyModePriceLines();
17992  } catch (e) {}
17993}
17994// ── Darkpool Levels mode (user 2026-08-23): today's dark-p
17994ool block prints aggregated per exact
17995// PRICE — institutions print blocks at the prices they are accumulating/distributing, and repeat
17996// prints at one price mark a level they are defending. The top 5 by total premium draw as price
17997// lines: solid for the biggest block price, dashed for the rest, label = total premium + print
17998// count. Data: /api/darkpool/:ticker (2-3 day accumulated history, newest first).
17999let _dpModeCache = { ticker: null, data: null, t: 0 };
18000async function computeDarkpoolLevels() {
18001  if (!indState.darkpool || !isPaidUser) { window.chartDarkpoolLevels = null; applyModePriceLines(); return; }
18002  const ticker = chartTicker; if (!ticker) return;
18003  try {
18004    let arr;
18005    if (_dpModeCache.ticker === ticker && _dpModeCache.data && (Date.now() - _dpModeCache.t) < 120000) {
18006      arr = _dpModeCache.data;   // 2-min cache — the 30s level tick must not refetch every pass
18007    } else {
18008      const j = await fetch('/api/darkpool/' + ticker).then(r => r.json());
18009      arr = (j && (j.data || j)) || [];
18010      if (Array.isArray(arr) && arr.length) _dpModeCache = { ticker, data: arr, t: Date.now() };
18011    }
18012    if (ticker !== chartTicker) return;                                          // switched away mid-fetch
18013    if (!Array.isArray(arr) || !arr.length) { window.chartDarkpoolLevels = null; applyModePriceLines(); return; }
18014    const tops = _dpLevelsFrom(arr);
18015    window.chartDarkpoolLevels = tops.length ? tops : null;
18016    applyModePriceLines();
18017  } catch (e) {}
18018}
18019// Pure level builder (Min prem / Lines / Merge settings applied) - shared by chart 1 and the Desk panes (parity 2026-09-07).
18020function _dpLevelsFrom(arr) {
18021    const dpMin = parseFloat(indState.dpMinPrem || 0) || 0;                  // Min prem setting — per-print floor
18022    const dpMaxN = Math.max(1, parseInt(indState.dpMax || 5, 10) || 5);       // Lines setting — how many price levels
18023    const tsOf = p => new Date(p.executed_at || p.tape_time || p.created_at || 0).getTime() || 0;
18024    const dayOf = t => { try { return new Date(t).toLocaleDateString('en-US', { timeZone: 'America/New_York' }); } catch (e) { return ''; } };
18025    // "Recent" = the newest session present in the feed (today during the day; Friday on a weekend).
18026    let maxTs = 0; for (const p of arr) { const t = tsOf(p); if (t > maxTs) maxTs = t; }
18027    const keyDay = dayOf(maxTs);
18028    const by = new Map();
18029    for (const p of arr) {
18030      const t = tsOf(p);
18031      if (!t || dayOf(t) !== keyDay) continue;
18032      const price = parseFloat(p.price || 0); if (!(price > 0)) continue;
18033      const prem = parseFloat(p.premium || 0) || (price * (parseInt(p.size || p.volume || 0, 10) || 0));
18034      if (!(prem > 0) || prem < dpMin) continue;
18035      // BUY / SELL / MID per print (user 2026-08-26). The feed carries the NBBO at execution, so
18036      // each print can be placed against the book it actually traded into: at or above the offer is
18037      // a buyer lifting, at or below the bid is a seller hitting. Everything between is a genuine
18038      // mid print - and in a dark pool that is MOST of them by design, which is why the mid bucket
18039      // is reported rather than forced into one side. Its lean (above or below the midpoint) is
18040      // tracked separately and shown only when it is decisive.
18041      const _nb = parseFloat(p.nbbo_bid || 0), _na = parseFloat(p.nbbo_ask || 0);
18042      let _side = 'mid', _lean = 0;
18043      if (_nb > 0 && _na > 0 && _na >= _nb) {
18044        if (price >= _na) _side = 'buy';
18045        else if (price <= _nb) _side = 'sell';
18046        else { const _m = (_nb + _na) / 2; _lean = price > _m ? 1 : price < _m ? -1 : 0; }
18047      }
18048      const k = price.toFixed(2);
18049      // first/last and the individual prints are kept for the hover card - the feed already
18050      // carries execution times, they were simply being dropped on the floor.
18051      const o = by.get(k) || { price: +k, prem: 0, n: 0, last: 0, first: 0, prints: [], buyPrem: 0, sellPrem: 0, midPrem: 0, leanPrem: 0 };
18052      o.prem += prem; o.n++; if (t > o.last) o.last = t;
18053      if (!o.first || t < o.first) o.first = t;
18054      if (_side === 'buy') o.buyPrem += prem; else if (_side === 'sell') o.sellPrem += prem;
18055      else { o.midPrem += prem;
18055 o.leanPrem += _lean * prem; }
18056      o.prints.push({ prem, t, price: +k, side: _side, lean: _lean });
18057      by.set(k, o);
18058    }
18059    // Merge nearby levels (user 2026-08-26). Two prints 2 cents apart are one level to a trader,
18060    // but drew as two lines - and four inside a 5-cent range (139.05 / .03 / .02 / .00) buried the
18061    // price scale. Tolerance is a PERCENTAGE so one setting travels across tickers: 0.05% is 7c on
18062    // a $139 stock and 33c on SPY, which is the same thing in practice.
18063    //
18064    // ANCHORED, not chained. Each cluster starts from the biggest remaining level and absorbs only
18065    // what is within tolerance OF THAT ANCHOR. Chaining A->B->C would let a dense run of prints
18066    // merge across a range far wider than the tolerance and put the line somewhere nobody traded.
18067    // The cluster keeps the anchor's price for the same reason - it is a real print price, and the
18068    // one where the size actually went through, rather than an average of nothing.
18069    const dpMergePct = parseFloat(indState.dpMerge != null ? indState.dpMerge : '0.05') || 0;
18070    // Floor of 20 cents (user 2026-08-26). The percentage alone is too tight where it matters most:
18071    // 0.05% of a $139 stock is 7c, so 139.05 and 139.03 merged but a 15c gap would not have. The
18072    // percentage still governs expensive tickers - 0.05% of SPY is 33c, wider than the floor - so
18073    // whichever is LARGER wins. Note this is proportionally aggressive on cheap stock: 20c is 4% of
18074    // a $5 name, so if sub-$20 tickers ever join the watchlist this wants a ceiling.
18075    const DP_MERGE_MIN = 0.20;
18076    let levels = [...by.values()].sort((a, b) => b.prem - a.prem);
18077    if (dpMergePct > 0 && levels.length > 1) {
18078      const out = [], used = new Set();
18079      for (const anchor of levels) {
18080        if (used.has(anchor)) continue;
18081        used.add(anchor);
18082        const tol = Math.max(anchor.price * dpMergePct / 100, DP_MERGE_MIN);
18083        const cl = { price: anchor.price, prem: anchor.prem, n: anchor.n, last: anchor.last, first: anchor.first, lo: anchor.price, hi: anchor.price, prints: (anchor.prints || []).slice(),
18084          buyPrem: anchor.buyPrem || 0, sellPrem: anchor.sellPrem || 0, midPrem: anchor.midPrem || 0, leanPrem: anchor.leanPrem || 0 };
18085        for (const o of levels) {
18086          if (used.has(o) || Math.abs(o.price - anchor.price) > tol) continue;
18087          used.add(o);
18088          cl.prem += o.prem; cl.n += o.n;
18089          if (o.last > cl.last) cl.last = o.last;
18090          if (o.first && (!cl.first || o.first < cl.first)) cl.first = o.first;
18091          cl.buyPrem += (o.buyPrem || 0); cl.sellPrem += (o.sellPrem || 0);
18092          cl.midPrem += (o.midPrem || 0); cl.leanPrem += (o.leanPrem || 0);
18093          if (o.prints && o.prints.length) cl.prints = cl.prints.concat(o.prints);
18094          if (o.price < cl.lo) cl.lo = o.price;
18095          if (o.price > cl.hi) cl.hi = o.price;
18096        }
18097        cl.prints.sort((a, b) => b.prem - a.prem);
18098        if (cl.prints.length > 8) cl.prints = cl.prints.slice(0, 8);   // the card shows 5; 8 is headroom
18099        out.push(cl);
18100      }
18101      levels = out.sort((a, b) => b.prem - a.prem);
18102    }
18103    return levels.slice(0, dpMaxN);
18104}
18105
18106// ── Flow Marks mode (user request 2026-08-23): significant sweeps/blocks pinned to the candle
18107// where they printed. Green up arrow below the bar = bullish (calls bought at the ask / puts hit
18108// on the bid), red down arrow above = bearish. Same-bar same-direction prints aggregate into one
18109// marker (total premium + xN). Reads the flow history store — zero extra UW calls.
18110let _fmModeCache = { ticker: null, data: null, t: 0 };
18111let _fmApplied = false;
18112let _fmRows = null;   // premium-filtered prints for the loaded candles, biggest first (see _fmDraw)
18113// SHARED MARKER REGISTRY. chartSeries.setMarkers() REPLACES the whole set, so two layers each
18114// calling it means whichever ran last wins and the other silently vanishes. Each layer writes
18115// its own bucket and this merges them in time order - the only ordering setMarkers accepts.
18116var _chartMarks = { flow: [], pol: [] };
18117function _applyChartMarks() {
18118  try {
18119    if (!chartSeries) return;
18120    var all = (_chartMarks.flow || []).concat(_chartMarks.pol || []);
18121    all.sort(function (a, b) { return (typeof a.time === "number" ? a.time : 0) 
18121- (typeof b.time === "number" ? b.time : 0); });
18122    chartSeries.setMarkers(all);
18123  } catch (e) {}
18124}
18125
18126// ── Politician disclosure marks (user 2026-08-26) ───────────────────────────────────────────
18127// Congress reports a transaction DATE, never a time, and the median lag from trade to filing is
18128// 15 days - so pinning these at the trade date would put a mark on a candle that had no idea the
18129// trade existed. They are anchored to the FILED date instead, at the market open, because that is
18130// the moment the information actually reached the tape. Rulli's GOOGL sell filed this morning
18131// against a GOOG open that dropped five dollars is the whole point of drawing them.
18132//
18133// The label carries the trade date and lag so the mark can never be mistaken for a same-day trade.
18134var _polCache = { t: 0, rows: null };
18135// Dual-class tickers file under one symbol and are charted under the other - the disclosure said
18136// GOOGL while the chart was GOOG. Explicit pairs only; guessing by stripping a letter would match
18137// unrelated symbols.
18138var _POL_ALIAS = { GOOGL: ["GOOG"], GOOG: ["GOOGL"], FOXA: ["FOX"], FOX: ["FOXA"], NWSA: ["NWS"], NWS: ["NWSA"] };
18139function _polTickerMatch(rowTk, chartTk) {
18140  if (!rowTk || !chartTk) return false;
18141  var a = String(rowTk).toUpperCase(), b = String(chartTk).toUpperCase();
18142  if (a === b) return true;
18143  var al = _POL_ALIAS[a] || [];
18144  for (var i = 0; i < al.length; i++) if (al[i] === b) return true;
18145  return false;
18146}
18147// The candle a filed-date belongs on: the first bar at or after 6:30am PT that day. On a daily
18148// chart that is the day's own bar; on an intraday chart it is the opening bar.
18149function _polCandleTimeFor(dateStr) {
18150  try {
18151    if (!Array.isArray(chartCandles) || !chartCandles.length) return null;
18152    var best = null;
18153    for (var i = 0; i < chartCandles.length; i++) {
18154      var c = chartCandles[i];
18155      var ts = (c._ts != null) ? c._ts : (typeof c.time === "number" ? c.time : null);
18156      if (ts == null) continue;
18157      var d = new Date(ts * 1000);
18158      var pt = new Date(d.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }));
18159      var day = pt.getFullYear() + "-" + String(pt.getMonth() + 1).padStart(2, "0") + "-" + String(pt.getDate()).padStart(2, "0");
18160      if (day !== dateStr) continue;
18161      var mins = pt.getHours() * 60 + pt.getMinutes();
18162      if (mins >= 390 || best == null) { best = c.time; if (mins >= 390) break; }   // 390 = 6:30am PT
18163    }
18164    return best;
18165  } catch (e) { return null; }
18166}
18167async function computePoliticianMarks() {
18168  try {
18169    if (!indState.polmarks || !isPaidUser || !chartSeries || !chartTicker) { _chartMarks.pol = []; _applyChartMarks(); if (typeof _polClearBadges === 'function') _polClearBadges(); return; }
18170    if (!_polCache.rows || Date.now() - _polCache.t > 5 * 60 * 1000) {
18171      var j = await fetch("/api/smart/politicians").then(function (r) { return r.json(); }).catch(function () { return null; });
18172      var rows = (j && (j.data || j.rows)) || [];
18173      if (!Array.isArray(rows)) rows = [];
18174      _polCache = { t: Date.now(), rows: rows };
18175    }
18176    var mine = (_polCache.rows || []).filter(function (r) {
18177      // Bad source data: a filing dated BEFORE its own trade produces a nonsense lag and would
18178      // sort to the top of anything freshness-ordered. Drop it rather than draw it.
18179      if (!r || !r.filedDate || !r.txnDate || r.filedDate < r.txnDate) return false;
18180      return _polTickerMatch(r.ticker, chartTicker);
18181    });
18182    // BADGE CARDS, not series markers (user 2026-08-26: "rectangle box a little above the
18183    // candles, arrow pointing at the candle, bright text"). lightweight-charts markers are
18184    // plain glyph+text with no styling surface, so the marks moved to a DOM layer - same
18185    // pattern as the darkpool hover card, but persistent and repositioned on pan/zoom.
18186    _polBadges = [];
18187    for (var i = 0; i < mine.length; i++) {
18188      var r = mine[i];
18189      var t = _polCandleTimeFor(r.filedDate);
18190      if (t == null) continue;                    // filed date not in the loaded range
18191      var sell = String(r.type || "").toLowerCase().indexOf("sell") === 0;
18192      var who = String(r.name || "").split(" ").slice(-1)[0];
18193      // r.amt is the disclosure band's lower bound - "$50K+" reads better on a chart than
18194      // the raw "$50,001 - $100,000" band string.
18195      var amtTxt = (r.amt >= 1e6) ? ("$" + (r.amt / 1e6).toFixed(r.amt >= 5e6 ? 0 : 1) + "M+")
18196        : (r.amt >= 1000) ? ("$" + Math.round(r.amt / 1000) + "K+") : (r.amt > 0 ? ("$" + r.amt) : "");
18197      _polBadges.push({ t: t, sell: sell, who: who, amt: amtTxt,
18198        txn: String(r.txnDate).slice(5), lag: (r.lagDays != null) ? (r.lagDays + "d") : "" });
18199    }
18200    _chartMarks.pol = [];   // the old marker layer stays empty - badges own this now
18201    _applyChartMarks();
18202    _polRenderBadges();
18203    // Reposition with the chart. The subscription hangs off the chart object itself, so a chart
18204    // rebuild (new object) naturally re-subscribes and the old one is garbage-collected with it.
18205    try {
18206      if (chartObj && !chartObj._polSub) {
18207        chartObj._polSub = true;
18208        chartObj.timeScale().subscribeVisibleLogicalRangeChange(function () { try { _polRenderBadges(); } catch (e) {} });
18209      }
18210    } catch (e) {}
18211  } catch (e) {}
18212}
18213// The badge layer. Host div lives inside #tvChart (position:relative), wiped and rebuilt on
18214// every render - a handful of small nodes, so rebuild beats bookkeeping.
18215var _polBadges = [];
18216var _polBadgeHostEl = null;
18217function _polBadgeHost() {
18218  if (_polBadgeHostEl && _polBadgeHostEl.isConnected) return _polBadgeHostEl;
18219  var host = document.getElementById('tvChart'); if (!host) return null;
18220  _polBadgeHostEl = document.createElement('div');
18221  _polBadgeHostEl.id = 'polBadgeLayer';
18222  _polBadgeHostEl.style.cssText = 'position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:6';
18223  host.appendChild(_polBadgeHostEl);
18224  return _polBadgeHostEl;
18225}
18226function _polClearBadges() { _polBadges = []; if (_polBadgeHostEl) _polBadgeHostEl.innerHTML = ''; }
18227function _polRenderBadges() {
18228  var host = _polBadgeHost(); if (!host) return;
18229  host.innerHTML = '';
18230  if (!_polBadges.length || !chartObj || !chartSeries || !indState.polmarks) return;
18231  var ts; try { ts = chartObj.timeScale(); } catch (e) { return; }
18232  var seen = {};
18233  for (var i = 0; i < _polBadges.length; i++) {
18234    var b = _polBadges[i];
18235    var x = null; try { x = ts.timeToCoordinate(b.t); } catch (e) {}
18236    if (x == null || x < 10 || x > host.clientWidth - 10) continue;
18237    // Anchor to the candle's HIGH so the card floats just above the wick it points at.
18238    var hi = null;
18239    try { for (var c = 0; c < chartCandles.length; c++) { if (chartCandles[c].time === b.t) { hi = chartCandles[c].high; break; } } } catch (e) {}
18240    var y = null; if (hi != null) { try { y = chartSeries.priceToCoordinate(hi); } catch (e) {} }
18241    if (y == null || !isFinite(y)) y = 64;
18242    var slot = (seen[b.t] = (seen[b.t] || 0) + 1) - 1;   // several filings on one day stack upward
18243    // Red sell / green buy (user 2026-08-26 - originally pink/blue to avoid reading as flow marks,
18244    // but the badge shape already separates the two layers).
18245    var col = b.sell ? '#ef4444' : '#22c55e';
18246    var top = y - 52 - slot * 40;
18247    if (top < 4) top = 4 + slot * 40;
18248    var w = document.createElement('div');
18249    w.style.cssText = 'position:absolute;left:' + x + 'px;top:' + top + 'px;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;pointer-events:none';
18250    w.innerHTML =
18251      '<div style="background:rgba(13,20,36,.95);border:1px solid ' + col + ';border-radius:8px;padding:4px 10px;white-space:nowrap;box-shadow:0 0 12px ' + (b.sell ? 'rgba(239,68,68,.4)' : 'rgba(34,197,94,.4)') + ';text-align:center">'
18252      + '<div style="font-size:11.5px;font-weight:800;letter-spacing:.3px"><span style="color:#f8fafc">' + _esc2(b.who) + '</span> <span style="color:' + col + '">' + (b.sell ? 'SELL' : 'BUY') + '</span>' + (b.amt ? ' <span style="color:#fbbf24">' + _esc2(b.amt) + '</span>' : '') + '</div>'
18253      + '<div style="font-size:9.5px;color:#cbd5e1;margin-top:1px">traded ' + _esc2(b.txn) + (b.lag ? ' · ' + _esc2(b.lag) + ' lag' : '') + '</div>'
18254      + '</div>'
18255      + '<div style="width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:7px solid ' + col + '"></div>';
18256    host.appendChild(w);
18257  }
18258}
18259// Vertical pans and axis zooms fire no time-scale event (same gap the GEX Visual hit), so a slow
18260// heartbeat keeps the badges glued; cheap - a handful of nodes, and only while any exist.
18261setInterval(function () { try { if (_polBadges.length) _polRenderBadges(); } catch (e) {} }, 1000);
18262async function computeFlowMarks() {
18263  if (typeof chartSeries === 'undefined' || !chartSeries) return;
18264  if (!indState.flowmarks || !isPaidUser) { if (_fmApplied) { try { _chartMarks.flow = []; _applyChartMarks(); } catch (e) {} _fmApplied = false; } _fmRows = null; return; }
18265  if (typeof _replayActive !== 'undefined' && _replayActive) return;   // replay owns the candle set
18266  const ticker = chartTicker; if (!ticker) return;
18267  try {
18268    let arr;
18269    if (_fmModeCache.ticker === ticker && _fmModeCache.data && (Date.now() - _fmModeCache.t) < 120000) {
18270      arr = _fmModeCache.data;   // 2-min cache — the 30s level tick must not refetch every pass
18271    } else {
18272      const j = await fetch('/api/flow/history/' + ticker).then(r => r.json());
18273      arr = (j && (j.data || j)) || [];
18274      if (Array.isArray(arr) && arr.length) _fmModeCache = { ticker, data: arr, t: Date.now() };
18275    }
18276    if (ticker !== chartTicker) return;                                          // switched away mid-fetch
18277    if (!Array.isArray(arr) || !arr.length || !chartCandles || !chartCandles.length) return;
18278    // Min prem setting — per-print floor. '0' = the ALL chip (no floor), which the old
18279    // `parseFloat(x) || 100000` parse would have silently snapped back to 100K (0 is falsy).
18280    const _fmRaw = parseFloat(indState.fmMinPrem);
18281    const fmMin = (isFinite(_fmRaw) && _fmRaw >= 0) ? _fmRaw : 100000;
18282    let t0 = chartCandleTs[0] || 0;
18283    // TODAY ONLY (user 2026-08-25: "remove previous day trail of flow marks on yesterday candles").
18284    // chartCandleTs is TRUE UTC (see the two-time-bases note in _fmDraw), so the ET day boundary is
18285    // found by walking back from the newest candle while its ET date still matches.
18286    if ((indState.fmDay || 'today') === 'today' && chartCandleTs.length) {
18287      try {
18288        const etDay = ts => new Date(ts * 1000).toLocaleDateString('en-US', { timeZone: 'America/New_York' });
18289        const last = chartCandleTs[chartCandleTs.length - 1], day = etDay(last);
18290        let i = chartCandleTs.length - 1;
18291        while (i > 0 && etDay(chartCandleTs[i - 1]) === day) i--;
18292        t0 = Math.max(t0, chartCandleTs[i]);
18293      } catch (e) {}
18294    }
18295    const rows = [];
18296    for (const f of arr) {
18297      const ms = new Date(f.created_at || 0).getTime(); if (!ms) continue;
18298      const ts = ms / 1000; if (ts < t0) continue;                               // before the loaded candles
18299      const prem = parseFloat(f.total_premium) || 0; if (prem < fmMin) continue;
18300      rows.push({ ts, prem, f });
18301    }
18302    rows.sort((a, b) => b.prem - a.prem);   // biggest first — the cap keeps the strongest in view
18303    _fmRows = { ticker, rows };
18304    _fmDraw();
18305  } catch (e) {}
18306}
18307// Draw the cached prints for the range currently ON SCREEN.
18308//
18309// The cap used to be "top N of the whole day", which meant the chart went SILENT once N prints had
18310// cleared the premium floor: a new print only drew if it outranked the current #N, and the bar kept
18311// rising all session (user 2026-08-24 — at Min $100K, 476 prints qualified, the top 10 ended at
18312// 08:57, and 42 later prints never appeared; raising Max marks to 100 "fixed" it only because the
18313// cap stopped binding). Ranking within the VISIBLE range instead makes the number mean what it looks
18314// like it means — how many marks on screen at once. Watching the live edge spends the budget on
18315// recent flow so new prints always draw; zoom out to the whole day and you get the day's biggest.
18316// No pinning of the day's whales is needed: off-screen they can't be drawn anyway, and on-screen
18317// they are the largest thing in range, so they win the ranking on their own.
18318function _fmDraw() {
18319  if (typeof chartSeries === 'undefined' || !chartSeries) return;
18320  if (!indState.flowmarks || !isPaidUser || !_fmRows || _fmRows.ticker !== chartTicker) return;
18321  if (typeof _replayActive !== 'undefined' && _replayActive) return;
18322  if (!chartCandles || !chartCandles.length) return;
18323  try {
18324    const fmMaxN = Math.max(1, parseInt(indState.fmMax || 25, 10) || 25);   // Max marks — ARROWS drawn, not prints considered
18325    let from = -Infinity, to = Infinity;
18326    try {
18327      const vr = chartObj && chartObj.timeScale().getVisibleRange();
18328      // TWO TIME BASES (this bit cost a regression on 2026-08-24 — every mark vanished).
18329      // Intraday candles are built as  time = _ts + tzOffsetSec(userTZ)  so the axis reads in the
18330      // user's clock, while chartCandleTs (and r.ts) are TRUE UTC. getVisibleRange() speaks the
18331      // AXIS basis, so comparing it against r.ts put every print hours past 'to' and filtered the
18332      // whole list away. Derive the shift from the loaded candles and undo it. On daily/weekly the
18333      // axis time is a 'YYYY-MM-DD' string, +vr.from is NaN, and the guard below leaves the range
18334      // wide open — which is the right behaviour there anyway.
18335      if (vr && isFinite(+vr.from) && isFinite(+vr.to)) {
18336        const c0 = chartCandles[0];
18337        const off = (c0 && typeof c0.time === 'number' && chartCandleTs[0]) ? (c0.time - chartCandleTs[0]) : 0;
18338        // Pad by one bar: 'to' is the last VISIBLE bar's stamp, but a print inside that still-forming
18339        // bar carries a later timestamp and belongs on it.
18340        const barSec = (chartCandleTs.length > 1) ? Math.max(1, chartCandleTs[1] - chartCandleTs[0]) : 300;
18341        from = +vr.from - off; to = +vr.to - off + barSec;
18342      }
18343    } catch (e) {}
18344    // Walk biggest-first and stop once the screen holds fmMaxN ARROWS. A print that lands on a bar
18345    // and direction already drawn joins that arrow for free, so the cap counts what you actually
18346    // see — the old code capped PRINTS, and 25 of those merged down to ~18 arrows.
18347    // SHOW mode (user 2026-08-28): 'top' = biggest-first within the visible range (Max marks
18348    // caps the arrows). LAST 5 / LAST 10 = ONLY the newest qualifying prints - walk newest-first
18349    // with a hard cap of 5/10 arrows, so the chart stays quiet and always tracks the live tape.
18350    const _fmShowMode = (typeof indState !== 'undefined' && indState.fmShow) || 'top';
18351    const _recentN = _fmShowMode === 'r5' ? 5 : _fmShowMode === 'r10' ? 10 : 0;
18352    const _walk = _recentN ? _fmRows.rows.slice().sort((a, b) => b.ts - a.ts) : _fmRows.rows;
18353    const _cap = _recentN || fmMaxN;
18354    const groups = new Map();
18355    for (const r of _walk) {
18356      if (r.ts < from || r.ts > to) continue;
18357      let i = chartCandleTs.length - 1;
18358      while (i > 0 && chartCandleTs[i] > r.ts) i--;
18359      const f = r.f;
18360      const askP = parseFloat(f.total_ask_side_prem || 0), bidP = parseFloat(f.total_bid_side_prem || 0);
18361      const isBuy = askP >= bidP;
18362      const isCall = String(f.type || '').toLowerCase() === 'call';
18363      const bull = isCall === isBuy;   // buy call / sell put = bullish; buy put / sell call = bearish
18364      const k = i + '|' + (bull ? 1 : 0);
18365      const g = groups.get(k);
18366      if (!g && groups.size >= _cap) continue;   // screen is full — only merges into existing arrows now
18367      const gg = g || { i, bull, prem: 0, n: 0, premC: 0, premP: 0, top: 0, tStrike: 0, tType: '', tExp: '' };
18368      gg.prem += r.prem; gg.n++; if (isCall) gg.premC += r.prem; else gg.premP += r.prem;
18369      // Remember the LARGEST leg — that is the contract the label names when prints merge.
18370      if (r.prem > gg.top) { gg.top = r.prem; gg.tStrike = parseFloat(f.strike) || 0; gg.tType = isCall ? 'C' : 'P'; gg.tExp = String(f.expiry || ''); }
18371      groups.set(k, gg);
18372    }
18373    const fm = v => v >= 1e6 ? (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M' : Math.round(v / 1e3) + 'K';
18374    // LABEL PARTS (user 2026-08-25) — premium / strike / expiry are independent toggles, and
18375    // fmDetail decides whether strike+expiry go on every mark or only the three biggest (on a busy
18376    // tape the full contract on every arrow is unreadable). With nothing selected the label falls
18377    // back to premium + side so a mark is never blank.
18378    const wantPrem = indState.fmLblPrem !== false, wantStrike = !!indState.fmLblStrike, wantExp = !!indState.fmLblExp;
18379    const top3Only = (indState.fmDetail || 'all') === 'top3';
18380    const _all = [...groups.values()];
18381    const _top3 = new Set([..._all].sort((a, b) => b.prem - a.prem).slice(0, 3));
18382    const expShort = e => { const p = String(e).split('-'); return p.length > 2 ? (+p[1]) + '/' + p[2] : ''; };
18383    const label = g => {
18384      const detail = (!top3Only || _top3.has(g)) && (wantStrike || wantExp);
18385      const parts = [];
18386      if (wantPrem) parts.push(fm(g.prem));
18387      if (detail && wantStrike && g.tStrike) parts.push(g.tStrike + g.tType);
18388      if (detail && wantExp && g.tExp) parts.push(expShort(g.tExp));
18389      if (!parts.length) parts.push(fm(g.prem));
18390      if (!detail || !wantStrike) parts.push(g.premC >= g.premP ? 'C' : 'P');
18391      const more = g.n > 1 ? ((detail && wantStrike) ? ' +' + (g.n - 1) : ' x' + g.n) : '';
18392      return parts.join(' ') + more;
18393    };
18394    const marks = _all.sort((a, b) => a.i - b.i).map(g => ({
18395      time: chartCandles[g.i].time,
18396      position: g.bull ? 'belowBar' : 'aboveBar',
18397      shape: g.bull ? 'arrowUp' : 'arrowDown',
18398      color: g.bull ? '#22c55e' : '#ef4444',
18399      // Smaller arrows (user 2026-08-25) — 2/1 crowded the candles. The $1M+ step is kept so
18400      // the big prints still read as bigger, just at a size that sits beside price instead of over it.
18401      size: g.prem >= 1000000 ? 1 : 0.6,
18402      text: label(g),
18403    }));
18404    _chartMarks.flow = marks; _applyChartMarks(); _fmApplied = true;
18405  } catch (e) {}
18406}
18407// Re-rank on pan/zoom. Debounced so a drag doesn't rebuild the marker set on every frame — the
18408// data is already in memory, so this never touches the network.
18409let _fmDrawTimer = null;
18410function _fmDrawSoon() {
18411  if (!indState.flowmarks) return;
18412  clearTimeout(_fmDrawTimer);
18413  _fmDrawTimer = setTimeout(() => { try { _fmDraw(); } catch (e) {} }, 140);
18414}
18415
18416
18417// ── PHONE DEFAULTS (user request 2026-08-13): on small touch screens the side panels swallow
18418// the whole chart — start Ghost Mode with the GEX ladder, Trade Plan and flow panel COLLAPSED.
18419// The existing restore tabs/rails bring any of them back; this only sets the default per load.
18420(function () {
18421  try {
18422    var phone = window.matchMedia && matchMedia('(pointer: coarse)').matches && Math.min(screen.width, screen.height) < 820;
18423    if (!phone) return;
18424    document.body ? document.body.classList.add('gf-phone') : document.addEventListener('DOMContentLoaded', function () { document.body.classList.add('gf-phone'); });
18425    var applied = false;
18426    var apply = function () {
18427      try { if (typeof nfGexMinimize === 'function') nfGexMinimize(true); } catch (e) {}
18428      try { if (typeof nfSetTradePlanMin === 'function') nfSetTradePlanMin(true); } catch (e) {}
18429      try { if (typeof nfTogglePanel === 'function') nfTogglePanel(true); } catch (e) {}
18430      applied = true;
18431    };
18432    var boot = function () {
18433      // The chart panels build LATE (and rebuild on view entry) — one early shot missed them
18434      // ("half shows", user 2026-08-13). Re-assert a few times, then once per chart-view entry.
18435      setTimeout(apply, 600); setTimeout(apply, 1800); setTimeout(apply, 3500); setTimeout(apply, 6000);
18436      try {
18437        var sv = window.setView;
18438        if (typeof sv === 'function') {
18439          window.setView = function (t) { var r = sv.apply(this, arguments); if (t === 'chart' || t === 'ultra') { setTimeout(apply, 300); setTimeout(apply, 1200); } return r; };
18440        }
18441      } catch (e) {}
18442    };
18443    if (document.readyState === 'complete') boot();
18444    else window.addEventListener('load', boot);
18445  } catch (e) {}
18446})();
18447// ── Dealer Bias Matrix chip (GEX/DEX/CEX regime — user request 2026-08-11): the top-bar bias
18448// shows the dealer MODE ("Bull Trend Day", "Bearish Pin Day"...) instead of plain Bullish/Bearish,
18449// with the action plan on hover. Falls back to the plain text until the matrix loads.
18450window._dealerBias = null;
18451function applyDealerBiasChip(){
18452  try{
18453    var cb=document.getElementById('chartBias'); if(!cb) return false;
18454    var d=window._dealerBias; if(!d || !d.mode || typeof chartTicker==='undefined' || d.tk!==chartTicker) return false;
18455    // The dealer matrix no longer paints its own chip - it lives in the regime tooltip now,
18456    // so the header carries ONE label. Kept as a function so the plain Bullish/Bearish setters
18457    // elsewhere stay suppressed, and so window._dealerBias keeps feeding that tooltip.
18458    cb.style.display='none';
18459    return true;
18460  }catch(e){ return false; }
18461}
18462// DAY REGIME chip (user 2026-08-25) — deliberately separate from the dealer-bias chip above.
18463// That one is a LIVE read and moves through the session; this is one call per day, locked at
18464// 10:00 ET server-side, so it can be traded off. It says HOW the day trades, never which way:
18465// the backtest could not support a direction claim (15 of 18 sampled days closed down, so any
18466// direction rule fitted the downtrend rather than the gamma).
18467// Plain-language copy for the one header chip (user 2026-08-25: "put a good text to understand").
18468// Each entry: t = the label, one = what the regime IS, how = what to actually do about it.
18469var REGIME_TEXT={
18470  PIN:{ t:'PIN DAY', one:'Dealers are DAMPENING moves - expect a tight range.',
18471        how:'Fade the extremes. Sell into the Call Wall, buy toward the Put Wall, and expect price to get pulled back to the King. Do not chase breakouts - in this regime they usually fail.' },
18472  TREND:{ t:'TREND DAY', one:'Dealer hedging is AMPLIFYING moves - expect a wider range.',
18473        how:'Trade with the move, not against it. Breaks of the opening range and VWAP tend to follow through, and fading the extremes is how you get run over. Give trades more room.' },
18474  MIXED:{ t:'MIXED', one:'Net gamma is near balanced - no clean regime today.',
18475        how:'Trade the levels rather than the regime. King, walls and Vol Node still work - the dealer backdrop just is not tilting the day either way.' },
18476};
18477function applyDayRegimeChip(){
18478  try{
18479    var host=document.getElementById('chartBias'); if(!host||!host.parentNode) return false;
18480    var d=window._dayRegime;
18481    var el=document.getElementById('chartRegime');
18482    if(!d || typeof chartTicker==='undefined' || d.tk!==chartTicker){ if(el) el.style.display='none'; return false; }
18483    if(!el){
18484      el=document.createElement('span'); el.id='chartRegime';
18485      el.style.cssText='margin-left:8px;font-size:10px;font-weight:800;letter-spacing:.4px;padding:2px 8px;border-radius:999px;cursor:help;white-space:nowrap';
18486      host.parentNode.insertBefore(el, host.nextSibling);
18487    }
18488    var COL={PIN:'#22d3ee',TREND:'#f5c518',MIXED:'#cbd5e1'};
18489    var c=COL[d.mode]||'#cbd5e1';
18490    var T=REGIME_TEXT[d.mode]||REGIME_TEXT.MIXED;
18491    el.style.display='';
18492    el.style.color=c; el.style.border='1px solid '+c+'66'; el.style.background=c+'14';
18493    el.textContent=T.t+(d.locked?'':' *');
18494    var db=window._dealerBias, gs=window._gexSplit, gtxt='';
18495    if(gs && gs.tk===chartTicker && gs.net!=null){
18496      var fmtB=function(v){ var q=Math.abs(v); return (v<0?'-':'')+(q>=1e9?(q/1e9).toFixed(1)+'B':q>=1e6?(q/1e6).toFixed(0)+'M':Math.round(q)); };
18497      // Same split the header shows (NET CALL GEX / NET PUT GEX) so the two always agree - the
18498      // positive/negative-by-strike split nets to the same number but looked like a different
18499      // measurement sitting next to it (user 2026-08-25).
18500      gtxt='\n\nGAMMA TODAY: net call '+fmtB(gs.call)+', net put '+fmtB(gs.put)+', net '+(gs.net>=0?'+':'')+fmtB(gs.net)+'.'
18501        + '\n' + (gs.net>=0
18502          ? 'POSITIVE net gamma means dealers are LONG gamma - they sell into rallies and buy dips to stay hedged, which DAMPENS the move. That is why positive-gamma days pin and chop.'
18503          : 'NEGATIVE net gamma means dealers are SHORT gamma - they buy strength and sell weakness to stay hedged, which AMPLIFIES the move. That is why negative-gamma days trend and gap.');
18504    }
18505    // MIXED is not a disagreement with the live dealer read - say which way it leans and why the
18506    // day call still refuses to commit, so the two stop looking like they contradict each other.
18507    var lean='';
18508    if(d.mode==='MIXED') lean='\n\nLEANS '+(d.norm>=0?'PIN (gamma is positive, so dealers do dampen)':'TREND (gamma is negative, so dealers do amplify)')
18509      + ', but only '+(d.norm>=0?'+':'')+d.norm+' of gross - inside the +/-0.15 dead zone, so it is not decisive enough to trade as a regime. The live dealer read below only looks at the SIGN, which is why it still names one.';
18510    el.title = T.t + ' - ' + T.one + '\n\n' + T.how
18511      + '\n\nHOW IT IS SET: net gamma / gross gamma across the top strikes = ' + (d.norm>=0?'+':'') + d.norm
18512      + '. Above +0.15 is PIN, below -0.15 is TREND, in between is MIXED.'
18513      + '\n' + (d.locked ? 'LOCKED for the session at 10:00 ET - it will not change again today.' : 'Not locked yet - it settles at 10:00 ET.')
18514      + gtxt + lean
18515      + (db && db.mode ? '\n\nLive dealer read right now: ' + db.mode + (db.plan ? ' - ' + db.plan : '') : '');
18516    return true;
18517  }catch(e){ return false; }
18518}
18519let _dealerPollAt=0;
18520async function _dealerBiasPoll(){
18521  try{
18522    if(!isPaidUser || typeof chartTicker==='undefined' || !chartTicker) return;
18523    var tk=chartTicker; _dealerPollAt=Date.now();
18524      // Follow the chart's expiry basis - ALL / 0DTE / WEEK are different chains, and asking
18525      // without the param always returned all-expiry numbers whatever was selected.
18526      var _bs=(typeof gridExpiryMode!=='undefined')?gridExpiryMode:'all';
18527      var _q=_bs==='0dte'?'?expiry=0dte':_bs==='weekly'?'?expiry=weekly':'';
18528      var j=await fetch('/api/levels/'+tk+_q).then(function(r){return r.json();});
18529    if(j && tk===chartTicker){
18530      window._dealerBias=j.dealer?{tk:tk,mode:j.dealer.mode,tone:j.dealer.tone,plan:j.dealer.plan,g:j.dealer.g,d:j.dealer.d,c:j.dealer.c}:null;
18531      // Server EOD pin lock (from 15:00 ET the pin is frozen server-side) — the chart line follows it.
18532      window._eodPinSrv=(j.eodPinLocked&&j.eodPin!=null)?{tk:tk,pin:+j.eodPin}:null;
18533      window._dayRegime=j.dayRegime?Object.assign({tk:tk},j.dayRegime):null;
18534      window._gexSplit={tk:tk,call:j.netCallGex,put:j.netPutGex,net:j.totalNetGex};
18535      applyDealerBiasChip(); applyDayRegimeChip();
18536    }
18537  }catch(e){}
18538}
18539setInterval(_dealerBiasPoll, 45000);
18540setTimeout(_dealerBiasPoll, 5000);
18541// Re-assert over the plain Bullish/Bearish setters, and refetch promptly after a ticker switch.
18542setInterval(function(){ if(!applyDealerBiasChip() && Date.now()-_dealerPollAt>8000) _dealerBiasPoll(); applyDayRegimeChip(); }, 5000);
18543// King / Call Wall / Put Wall / Vol Node price lines — gated by the "GEX Levels" mode toggle (default on).
18544// ── KING FOLLOWS MAGNET (ADMIN TEST, 2026-08-28, user-approved spec) ─────────
18545// Below the gamma flip the KING identity moves to the dominant negative strike below spot
18546// (the level actually pulling price); the true gamma strike shows TOP GEX; the old level
18547// reverts to its natural self. Display only - Setups / SPX plays / audit / alerts keep the
18548// real King. Phases: FORMING fires on the FIRST tick across the flip (instant cyan cue,
18549// zero debounce - the early direction call); ACTIVE (the actual King move) only after the
18550// cross holds ~60s AND spot sits a buffer (~0.05%) past the flip, so chop cannot strobe the
18551// crown. Reclaims unwind on the mirrored rule. No ranked strike below spot -> no swap ever.
18552function _chartGammaFlipVal() {
18553  try {
18554    const rows = (typeof columnData !== 'undefined' && chartTicker && columnData[chartTicker] && columnData[chartTicker].strikes) || [];
18555    if (rows.length < 2) return null;
18556    const srt = rows.slice().sort((a, b) => a.strike - b.strike);
18557    let cum = 0, prevC = 0, prevK = null, gf = null;
18558    for (const d of srt) {
18559      prevC = cum; cum += (d.netGex || 0);
18560      if (prevK != null && ((prevC < 0 && cum >= 0) || (prevC > 0 && cum <= 0))) { const dn = cum - prevC; const t = dn === 0 ? 0 : (0 - prevC) / dn; gf = prevK + (d.strike - prevK) * t; }
18561      prevK = d.strike;
18562    }
18563    if (gf != null) return +gf.toFixed(2);
18564    const _L2 = window._chartGexLevels;
18565    return (_L2 && _L2.gflip != null) ? _L2.gflip : null;
18566  } catch (e) { return null; }
18567}
18568let _km = { phase: 'idle', crossedAt: 0, reclaimAt: 0, target: null };
18569function _kmReset() { _km = { phase: 'idle', crossedAt: 0, reclaimAt: 0, target: null }; window._kmSwap = null; window._kmForm = null; }
18570function _kmEnabled() { return typeof isPaidUser !== 'undefined' && isPaidUser && indState.kingmagnet === true && gfLive('chart') && !(typeof _replayActive !== 'undefined' && _replayActive); }   // premium (user 2026-09-02, was admin-only)
18571function _kmTick() {
18572  const prevPhase = _km.phase, prevTarget = _km.target;
18573  if (!_kmEnabled()) {
18574    if (window._kmSwap || window._kmForm) { _kmReset(); try { applyGexLevelLines(); } catch (e) {} try { if (typeof renderGexBubbles === 'function') renderGexBubbles(); } catch (e) {} }
18575    else _kmReset();
18576    return;
18577  }
18578  const spot = (typeof chartCurrentPrice !== 'undefined' && chartCurrentPrice) || 0;
18579  const flip = _chartGammaFlipVal();
18580  const L = window._chartGexLevels || {};
18581  // Transient data gap (levels mid-refresh, price poll between ticks): KEEP the state and
18582  // the 60s clock - resetting here re-entered FORMING every 5s and the swap could never
18583  // confirm (user 2026-08-28: SPX sat 45 pts below the flip and the King never moved).
18584  if (!spot || flip == null || L.king == null) return;
18585  const below = spot < flip;
18586  const buffer = Math.max(spot * 0.0005, 0.01);
18587  // Target: biggest NEGATIVE net-GEX strike below spot; fallback biggest of any sign below.
18588  let target = null;
18589  try {
18590    const rows = (columnData[chartTicker] && columnData[chartTicker].strikes) || [];
18591    let bestNeg = null, bestAny = null;
18592    for (const d of rows) {
18593      if (!(d.strike < spot)) continue;
18594      if (d.netGex < 0 && (!bestNeg || Math.abs(d.netGex) > Math.abs(bestNeg.netGex))) bestNeg = d;
18595      if (!bestAny || Math.abs(d.netGex) > Math.abs(bestAny.netGex)) bestAny = d;
18596    }
18597    target = bestNeg ? bestNeg.strike : (bestAny ? bestAny.strike : null);
18598  } catch (e) {}
18599  if (!below || target == null || target === L.king) {
18600    if (window._kmSwap) {
18601      // Reclaim hold: an ACTIVE swap unwinds when spot is a buffer back above the flip,
18602      // or after 60s back on the other side - one pop through the flip cannot strobe it.
18603      if (!_km.reclaimAt) _km.reclaimAt = Date.now();
18604      if (spot >= flip + buffer || Date.now() - _km.reclaimAt >= 60000) _kmReset();
18605    } else if (_km.phase !== 'idle' || window._kmForm) _kmReset();
18606  } else {
18607    _km.reclaimAt = 0;
18608    if (_km.phase !== 'forming' && _km.phase !== 'active') { _km.phase = 'forming'; _km.crossedAt = Date.now(); }
18609    _km.target = target;
18610    // Confirm hold shortened 60s -> 8s (user 2026-09-02): the minute-long forming clock made the
18611    // King "hang" on the normal strike after every reload / ticker switch. 8s (~2 ticks) still
18612    // filters a single-tick blip through the flip but engages the magnet almost immediately.
18613    if (_km.phase === 'forming' && Date.now() - _km.crossedAt >= 8000 && spot <= flip - buffer) _km.phase = 'active';
18614    window._kmForm = (_km.phase === 'forming') ? { target } : null;
18615    window._kmSwap = (_km.phase === 'active') ? { target, trueKing: L.king } : null;
18616  }
18617  if (_km.phase !== prevPhase || _km.target !== prevTarget) {
18618    try { console.log('[KingMagnet]', prevPhase, '->', _km.phase, '| target', _km.target, '| spot', spot, '| flip', flip); } catch (e) {}
18619    try { applyGexLevelLines(); } catch (e) {}
18620    try { if (typeof renderGexBubbles === 'function') renderGexBubbles(); } catch (e) {}
18621  }
18622}
18623setInterval(function () { try { _kmTick(); } catch (e) {} try { if (typeof _mKmTickAll === 'function') _mKmTickAll(); } catch (e) {} }, 5000);
18624function applyGexLevelLines() {
18625  if (typeof chartSeries === 'undefined' || !chartSeries) return;
18626  for (const pl of gexLevelLines) { try { chartSeries.removePriceLine(pl); } catch (e) {} }
18627  gexLevelLines = [];
18628  if (_replayActive) return;   // during replay, renderReplayFrame draws the (recorded) King/Call/Put/Vol lines
18629  if (!indState.gexlines || !window._chartGexLevels) return;
18630  const L = window._chartGexLevels, LS = LightweightCharts.LineStyle;
18631  const add = (price, color, style, title) => { if (price == null || !isFinite(price)) return; try { gexLevelLines.push(chartSeries.createPriceLine({ price, color, lineWidth: 2, lineStyle: style, axisLabelVisible: true, lineVisible: (indState.glStyle || 'lines') !== 'axis', title: title || '' })); } catch (e) {} };
18632  // KING FOLLOWS MAGNET (admin test): ACTIVE -> King line draws at the magnet strike and
18633  // the true gamma strike gets a dimmer TOP GEX line; FORMING -> instant cyan cue at the
18634  // target while the King has not moved yet. Off / members -> exactly the original line.
18635  const _kms = window._kmSwap, _kmf = window._kmForm;
18636  if (_kms && _kms.target != null) {
18637    add(_kms.target, '#eab308', LS.Dashed, 'KING');
18638    add(L.king, '#d4a017', LS.Dotted, 'TOP GEX');
18639  } else {
18640    add(L.king, '#eab308', LS.Dashed);
18641    // FORMING cue removed (user 2026-08-28): no cyan pre-line - the chart changes only
18642    // when the King actually moves. The forming PHASE stays internally: it is the 60s
18643    // clock that arms the confirmed swap.
18644  }
18645  add(L.cw, '#22c55e', LS.Solid);
18646  add(L.pw, '#ef4444', LS.Solid);
18647  add(L.vn, '#3b82f6', LS.Dashed);
18648  // GAMMA FLIP (user request 2026-08-09 — it was on the TV overlay, the Pine indicator and the
18649  // Ghost Desk panes but never on chart 1 itself): cumulative net-gamma zero-cross, derived from
18650  // the same per-strike data the levels above use, purple dotted like everywhere else.
18651  try {
18652    const rows = (typeof columnData !== 'undefined' && chartTicker && columnData[chartTicker] && columnData[chartTicker].strikes) || [];
18653    if (rows.length > 1) {
18654      const srt = rows.slice().sort((a, b) => a.strike - b.strike);
18655      let cum = 0, prevC = 0, prevK = null, gf = null;
18656      for (const d of srt) {
18657        prevC = cum; cum += (d.netGex || 0);
18658        if (prevK != null && ((prevC < 0 && cum >= 0) || (prevC > 0 && cum <= 0))) { const dn = cum - prevC; const t = dn === 0 ? 0 : (0 - prevC) / dn; gf = prevK + t * (d.strike - prevK); break; }
18659        prevK = d.strike;
18660      }
18661      if (gf != null) add(+gf.toFixed(2), '#a855f7', LS.Dotted, 'G-Flip');
18662      else if (L.gflip != null) add(L.gflip, '#a855f7', LS.Dotted, 'G-Flip');
18663    } else if (L.gflip != null) add(L.gflip, '#a855f7', LS.Dotted, 'G-Flip');
18664  } catch (e) {}
18665}
18666// Rebuild every price-line-based mode (max pain, vanna, flow walls, magnet, expected move, session).
18667// ── Darkpool hover card (user 2026-08-26) ────────────────────────────────────────────────────
18668// "When did the darkpool come in?" - the feed knows, the chart was not saying. Price lines in
18669// lightweight-charts carry NO hover events of their own: a price line is a line plus an axis
18670// label and nothing else, with no hit area and no mouse callbacks. So proximity is worked out
18671// against the crosshair's own y coordinate and the card is our element, not the library's.
18672//
18673// Kept deliberately cheap because of the warning on the crosshair subscription above: at most a
18674// handful of priceToCoordinate calls and a show/hide. No repaint, no layout thrash.
18675let _dpTip = null;
18676function _dpTipEnsure() {
18677  if (_dpTip && _dpTip.isConnected) return _dpTip;
18678  const host = document.getElementById('tvChart'); if (!host) return null;
18679  _dpTip = document.createElement('div');
18680  _dpTip.id = 'dpHoverTip';
18681  // pointer-events:none so the card can never eat the crosshair that summoned it.
18682  _dpTip.style.cssText = 'position:absolute;z-index:9;display:none;pointer-events:none;background:rgba(10,14,23,.97);border:1px solid var(--t-border,#1e293b);border-radius:8px;padding:8px 10px;font:11px/1.55 Segoe UI,monospace;color:#e2e8f0;white-space:nowrap;box-shadow:0 6px 20px rgba(0,0,0,.5)';
18683  host.appendChild(_dpTip);
18684  return _dpTip;
18685}
18686function _dpHover(param) {
18687  const tip = _dpTipEnsure(); if (!tip) return;
18688  const DP = window.chartDarkpoolLevels;
18689  if (!DP || !DP.length || !indState.darkpool || !param || !param.point || !chartSeries) { tip.style.display = 'none'; return; }
18690  const y = param.point.y;
18691  let best = null, bestDy = 1e9;
18692  for (const d of DP) {
18693    let cy = null; try { cy = chartSeries.priceToCoordinate(d.price); } catch (e) {}
18694    if (cy == null) continue;
18695    const dy = Math.abs(cy - y);
18696    if (dy < bestDy) { bestDy = dy; best = d; }
18697  }
18698  // Vertical band is the LABEL height (about 16px tall), not the line thickness.
18699  if (!best || bestDy > 8) { tip.style.display = 'none'; return; }
18700  const fmtP = v => v >= 1e9 ? '$' + (v / 1e9).toFixed(1) + 'B' : v >= 1e6 ? '$' + (v / 1e6).toFixed(1) + 'M' : '$' + Math.round(v / 1e3) + 'K';
18701  // Over the LABEL only, not the whole line (user 2026-08-26). A price line spans the entire chart
18702  // width, so a band on y alone popped the card almost anywhere the cursor went. Lightweight-charts
18703  // draws the title badge right-aligned at the pane's right edge, and timeScale().width() IS that
18704  // pane width, so the hit area is the last labelWidth pixels of the row. The width is estimated
18705  // from the text because the library exposes no geometry for a price-line label - deliberately a
18706  // little generous, since a card that will not open is worse than one that opens slightly early.
18707  const _lbl = 'DP ' + fmtP(best.prem) + (best.n > 1 ? ' · ' + best.n + 'x' : '');
18708  let _paneW = 0; try { _paneW = chartObj.timeScale().width() || 0; } catch (e) {}
18709  if (_paneW && param.point.x < _paneW - (_lbl.length * 6.4 + 14)) { tip.style.display = 'none'; return; }
18710  const lo = best.lo != null ? best.lo : best.price, hi = best.hi != null ? best.hi : best.price;
18711  const C2 = (indState.modeColors || MODE_COLOR_DEFAULTS).darkpool || '#22d3ee';
18712  let h = '<div style="color:' + C2 + ';font-weight:800;margin-bottom:2px">Darkpool ' + best.price.toFixed(2) + '</div>';
18713  h += '<div><b>' + fmtP(best.prem) + '</b> · ' + best.n + (best.n === 1 ? ' print' : ' prints') + '</div>';
18714  if (hi > lo) h += '<div style="color:#cbd5e1">merged ' + lo.toFixed(2) + ' - ' + hi.toFixed(2) + '</div>';
18715  // Times follow the viewer's timezone like every other clock in the UI.
18716  if (best.first) h += '<div style="color:#cbd5e1">' + (best.last > best.first ? 'first ' + fmtTime(best.first) + ' · last ' + fmtTime(best.last) : 'at ' + fmtTime(best.first)) + ' ' + tzAbbr(best.last || best.first) + '</div>';
18717  // Buy / sell / mid split. Dark-pool prints are mid-executed by design, so the mid bucket is
18718  // usually the biggest one - reporting it honestly is more useful than forcing every print onto
18719  // a side. The lean is shown only when it is decisive (over 60% of mid premium one way).
18720  const _b = best.buyPrem || 0, _s = best.sellPrem || 0, _m = best.midPrem || 0;
18721  if (_b || _s || _m) {
18722    const row = (lbl, v, col) => v > 0 ? '<div><span style="display:inline-block;width:34px;color:#cbd5e1">' + lbl + '</span><span style="color:' + col + ';font-weight:700">' + fmtP(v) + '</span> <span style="color:#cbd5e1">' + Math.round(v / best.prem * 100) + '%</span></div>' : '';
18723    h += '<div style="margin-top:4px">' + row('buy', _b, '#22c55e') + row('sell', _s, '#ef4444') + row('mid', _m, '#e2e8f0') + '</div>';
18724    const lean = best.leanPrem || 0;
18725    if (_m > 0 && Math.abs(lean) / _m > 0.6) h += '<div style="color:#cbd5e1">mid leaning ' + (lean > 0 ? 'buy' : 'sell') + '</div>';
18726  }
18727  const pr = best.prints || [];
18728  if (pr.length > 1) {
18729    h += '<div style="color:#cbd5e1;margin-top:4px">biggest prints</div>';
18730    for (const p of pr.slice(0, 5)) h += '<div>' + fmtP(p.prem) + '  <span style="color:#cbd5e1">' + p.price.toFixed(2) + '  ' + fmtTime(p.t) + '</span>' + (p.side === 'buy' ? ' <span style="color:#22c55e">B</span>' : p.side === 'sell' ? ' <span style="color:#ef4444">S</span>' : '') + '</div>';
18731  }
18732  tip.innerHTML = h;
18733  tip.style.display = 'block';
18734  // Flip the card rather than let it run off the chart.
18735  const host = tip.parentElement, w = tip.offsetWidth, hh = tip.offsetHeight;
18736  let L = param.point.x + 14, T = y + 14;
18737  if (L + w > host.clientWidth - 8) L = param.point.x - w - 14;
18738  if (T + hh > host.clientHeight - 8) T = y - hh - 14;
18739  tip.style.left = Math.max(4, L) + 'px';
18740  tip.style.top = Math.max(4, T) + 'px';
18741}
18742function applyModePriceLines() {
18743  if (typeof chartSeries === 'undefined' || !chartSeries) return;
18744  for (const pl of modePriceLines) { try { chartSeries.removePriceLine(pl); } catch (e) {} }
18745  modePriceLines = [];
18746  if (!isPaidUser) return;
18747  const LS = LightweightCharts.LineStyle;
18748  const C = indState.modeColors || MODE_COLOR_DEFAULTS;
18749  const add = (price, color, title, style, width) => { if (price == null || !isFinite(price)) return;
18749 try { modePriceLines.push(chartSeries.createPriceLine({ price, color, lineWidth: width || 1, lineStyle: style, axisLabelVisible: true, title })); } catch (e) {} };
18750  const M = window.chartModes;
18751  if (M) {
18752    if (indState.maxpain) add(M.maxPain, C.maxpain, 'Max Pain', LS.Dashed, 1);
18753    if (indState.vanna) { add(M.vannaHi, C.vannaHi, 'Vanna+', LS.Solid, 1); add(M.vannaLo, C.vannaLo, 'Vanna-', LS.Solid, 1); add(M.vannaFlip, C.vannaFlip, 'Vanna Flip', LS.Dotted, 1); }
18754    if (indState.flowwall) { add(M.flowCall, C.flowCall, 'Flow CW', LS.Dashed, 2); add(M.flowPut, C.flowPut, 'Flow PW', LS.Dashed, 2); }
18755    if (indState.magnet && M.magnet) M.magnet.forEach((mg, i) => add(mg.strike, C.magnet, 'Magnet ' + (i + 1), i === 0 ? LS.Solid : LS.Dotted, i === 0 ? 2 : 1));
18756    if (indState.em && M.em) { add(M.em.up, C.em, 'EM +' + M.em.pct.toFixed(1) + '%', LS.Dashed, 1); add(M.em.dn, C.em, 'EM -' + M.em.pct.toFixed(1) + '%', LS.Dashed, 1); }
18757  }
18758  const S = window.chartSessionLevels;
18759  if (S) {
18760    if (indState.sesPD) { add(S.pdH, C.pd, 'PD High', LS.Dotted, 1); add(S.pdL, C.pd, 'PD Low', LS.Dotted, 1); }
18761    if (indState.sesON) { add(S.onH, C.on, 'ON High', LS.Dotted, 1); add(S.onL, C.on, 'ON Low', LS.Dotted, 1); }
18762    if (indState.sesOR) { add(S.orH, C.or, 'OR High', LS.Dotted, 1); add(S.orL, C.or, 'OR Low', LS.Dotted, 1); }
18763  }
18764  // Darkpool block-print levels (user 2026-08-23) — biggest price solid, the rest dashed.
18765  const DP = window.chartDarkpoolLevels;
18766  if (DP && indState.darkpool) {
18767    const fmtP = v => v >= 1e9 ? '$' + (v / 1e9).toFixed(1) + 'B' : v >= 1e6 ? '$' + (v / 1e6).toFixed(1) + 'M' : '$' + Math.round(v / 1e3) + 'K';
18768    // Style is a setting now, applied to every darkpool line (user 2026-08-26 - solid by default).
18769    // Rank has NOT been lost with it: the biggest price still draws at double width, which reads
18770    // just as clearly as a style change did and survives whichever style is chosen.
18771    const dpLS = ({ solid: LS.Solid, dashed: LS.Dashed, dotted: LS.Dotted })[indState.dpStyle || 'solid'] || LS.Solid;
18772    DP.forEach((d, i) => add(d.price, C.darkpool || '#22d3ee', 'DP ' + fmtP(d.prem) + (d.n > 1 ? ' · ' + d.n + 'x' : ''), dpLS, i === 0 ? 2 : 1));
18773  }
18774}
18775// Auto-refresh the chart's GEX levels (King/CW/PW/VN + header + bias) AND charm every 30s during the
18776// extended session — WITHOUT reloading candles (those tick live via the 5s price poll). Fetches fresh
18777// GEX/spot, recomputes the levels, and swaps the price lines in place after the data arrives (old
18778// lines stay until then → no blink, no candle jump).
18779async function refreshChartLevels() {
18780  const ticker = chartTicker;
18781  if (!ticker || !chartSeries || !gfLive('chart')) return;
18782  if (_replayActive) return;   // replay owns the price lines — don't let the live refresh clobber them
18783  if (!(isPaidUser || isAdminUser)) return;
18784  try {
18785    try { delete chartLevelCache[ticker]; } catch (e) {}                      // force a fresh GEX pull
18786    const [gexJson, spotJson] = await Promise.all([
18787      _basisGexJson(ticker, gridExpiryMode),
18788      fetch('/api/spot/' + ticker).then(r => r.json()).catch(() => null),
18789    ]);
18790    if (ticker !== chartTicker || !chartSeries) return;                        // switched away mid-fetch
18791    const sArr = (spotJson && (spotJson.data || spotJson)) || [];
18792    let spot = 0;
18793    if (Array.isArray(sArr)) for (const s of sArr) { const p = parseFloat(s.price || s.underlying_price || 0); if (p > 0) { spot = p; break; } }
18794    if (!spot) spot = chartCurrentPrice || window._chartSpot || (chartCandles.length ? chartCandles[chartCandles.length - 1].close : 0);
18795    if (!spot) return;
18796    window._chartSpot = spot;
18797    const mult = metricMult(spot);
18798    const strikesArr = (gexJson && (gexJson.data || gexJson)) || [];
18799    const parsed = (Array.isArray(strikesArr) ? strikesArr : []).map(s => { const st = parseFloat(s.strike || 0); return { strike: st, sb: (s.sb != null ? +s.sb : null), netGex: metricRaw(s) * mult, callGex: metricCall(s) * mult, putGex: metricPut(s) * mult }; }).filter(s => s.strike > 0 && Math.abs(s.netGex) > 0);
18800    if (parsed.length) {
18801      try { const r0 = parsed.find(s => s && s.sb != null); window._chartLevelUnshift = r0 ? +(r0.strike - r0.sb).toFixed(2) : 0; } catch (e) { window._chartLevelUnshift = 0; }
18802      const cArr = (chartContractsCache[ticker] && chartContractsCache[ticker].arr) || [];
18803      const volNode = cArr.length ? computeVolNode(cArr, spot) : null;
18804      const aE = parsed.filter(s => s.strike >= spot).sort((a, b) => a.strike - b.strike).slice(0, 30);
18805      const bE = parsed.filter(s => s.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
18806      const { callWall, putWall, kingNode, totalGex } = pickLevels([...aE, ...bE], spot);
18807      // Keep columnData fresh from THIS live chart pull (basis-correct) so the GEX Visual bubbles
18808      // sample the CURRENT % — not a stale grid load. Makes the trail's below-30% drop-off and the
18809      // rising/fading trend actually reflect live GEX.
18810      try { columnData[ticker] = { strikes: parsed, spot, callWall, putWall, kingNode, totalGex, volNode, basis: ((typeof currentMetric !== 'undefined' && currentMetric === 'charm') ? '0dte' : (typeof gridExpiryMo
18810de !== 'undefined' ? gridExpiryMode : 'all')) }; } catch (e) {}
18811      const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
18812      setTxt('chartKing', kingNode ? '👑 King ' + _dispStrike(kingNode.strike) : '');
18813      setTxt('chartCW', callWall ? '📈 Call Wall ' + _dispStrike(callWall.strike) : '');
18814      setTxt('chartPW', putWall ? '📉 Put Wall ' + _dispStrike(putWall.strike) : '');
18815      setTxt('chartVN', volNode ? '🔵 Vol Node ' + _dispStrike(volNode.strike) : '');
18816      const cb = document.getElementById('chartBias'); if (cb) cb.style.display='none';   /* regime chip owns the header - no Bullish/Bearish flash on load */
18817        if (false) { cb.textContent = totalGex >= 0 ? '↑ Bullish' : '↓ Bearish'; cb.style.color = totalGex >= 0 ? 'var(--green)' : 'var(--red)'; }
18818      const LS = LightweightCharts.LineStyle;
18819      for (const pl of chartPriceLines) { try { chartSeries.removePriceLine(pl); } catch (e) {} }
18820      chartPriceLines = []; _chartPriceLine = null;
18821      if (chartCurrentPrice) { _chartPriceLine = chartSeries.createPriceLine({ price: chartCurrentPrice, color: '#ffffff', lineWidth: 1, lineStyle: LS.Dotted, axisLabelVisible: true, title: '' }); chartPriceLines.push(_chartPriceLine); }
18822      window._chartGexLevels = { king: kingNode ? kingNode.strike : null, cw: callWall ? callWall.strike : null, pw: putWall ? putWall.strike : null, vn: volNode ? volNode.strike : null };
18823      applyGexLevelLines();   // King / Call Wall / Put Wall / Vol Node — gated by the "GEX Levels" mode toggle
18824    }
18825    if (typeof computeCharmLevels === 'function') computeCharmLevels(true);    // refresh charm on the same tick
18826    if (indState.darkpool && typeof computeDarkpoolLevels === 'function') computeDarkpoolLevels();   // new block prints (2-min client cache)
18827    if (indState.flowmarks && typeof computeFlowMarks === 'function') computeFlowMarks();            // new sweep/block marks (2-min client cache)
18828    if (typeof computeChartModes === 'function') computeChartModes();          // refresh greek/flow level modes
18829    if (typeof computeSessionLevels === 'function') { computeSessionLevels(); applyModePriceLines(); }  // refresh session levels
18830    if (indState.flow && isAdminUser && typeof pollFlowBars === 'function') pollFlowBars();   // refresh flow bars on the same tick
18831  } catch (e) {}
18832}
18833// ── GEX Bubbles (admin-only, in build) ──────────────────────────────────────────────────────────
18834// A live glowing trail of dots: at each strike, a bubble whose size = that strike's GEX % (|netGex| /
18835// maxGex, the same % shown on the left GEX panel). Gold = positive gamma, red = negative. Sampled
18836// every 20s during the 6:30am–1pm PT session and anchored to time+price, so the bands build across
18837// the day and pan/zoom with the chart. Per-ticker, resets each morning, in-memory only.
18838var _gexBubOn = true;       // GEX Visual kill switch (kept for quick disable if needed).
18839var _gexBubReseedKey = '';  // when set, the next columnData rebuild for this combo forces a fresh reseed (fixes 0DTE/metric switch seeding from stale ALL data)
18840var _gexBubReseedAt = 0;    // when the lock was set — the self-heal force-clears it after 8s in case no render hook ever fired
18841var _gexBubTrail = {};      // ticker -> { day, snaps:[{ t, rows:[{k,pct,pos}] }] }
18842var _gexBubTimer = null, _gexBubRaf = null;
18843var _gexBubPaintKey = '';  // key of the trail in the last painted frame - a frame from a DIFFERENT key is never kept as a placeholder
18844var _gexRectFade = {}, _gexRectFadeKey = '', _gexRectFadeT = 0, _gexBubFading = false;   // glow-band fade in/out state
18845// '#rrggbb' + alpha → 'rgba(...)'. Needed for the glow gradient's colour stops (canvas gradients
18846// take colour strings, not globalAlpha). Cached — this runs per stop, per band, per frame.
18847var _gexRgbCache = {};
18848function _gexRgba(hex, a){
18849  var c=_gexRgbCache[hex];
18850  if(!c){ c=_gexRgbCache[hex]=[parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)]; }
18851  return 'rgba('+c[0]+','+c[1]+','+c[2]+','+(a<0?0:a>1?1:a).toFixed(3)+')';
18852}
18853function _gexBubPtDay(){ try{ var p=new Date(new Date().toLocaleString('en-US',{timeZone:'America/Los_Angeles'})); if(p.getHours()<1) p.setDate(p.getDate()-1);   /* roll at 1am PT = premarket open (user 2026-09-14) */ return p.getFullYear()+'-'+String(p.getMonth()+1).padStart(2,'0')+'-'+String(p.getDate()).padStart(2,'0'); }catch(e){ return ''; } }
18854// Session window in PT minutes: Regular = 6:30am–1pm, Extended = 1am–5pm (matches the user's live window).
18855function _gexBubWin(){ return (typeof indState!=='undefined' && indState.gexbubSession==='ext') ? [60,1020] : [390,780]; }
18856// NYSE full-day holidays - mirrors MARKET_HOLIDAYS in server.js (extend yearly). The live bubble sampler, the market badge and
18857// isMarketOpen used to know weekends only, so on Labor Day 2026-09-07 every chart kept appending one static live column to
18858// Friday's band (user 2026-09-07: "single bubbles at end").
18859const GF_MARKET_HOLIDAYS = new Set(['2026-01-01','2026-01-19','2026-02-16','2026-04-03','2026-05-25','2026-06-19','2026-07-03','2026-09-07','2026-11-26','2026-12-25','2027-01-01','2027-01-18','2027-02-15','2027-03-26','2027-05-31','2027-06-18','2027-07-05','2027-09-06','2027-11-25','2027-12-24']);
18860function gfIsMarketHoliday(){ try{ const et=new Date(new Date().toLocaleString('en-US',{timeZone:'America/New_York'})); return GF_MARKET_HOLIDAYS.has(et.getFullYear()+'-'+String(et.getMonth()+1).padStart(2,'0')+'-'+String(et.getDate()).padStart(2,'0')); }catch(e){ return false; } }
18861function _gexBubMarketNow(){ try{ if(typeof chartTicker!=='undefined' && isCryptoTk(chartTicker)) return true; if(gfIsMarketHoliday()) return false; var p=new Date(new Date().toLocaleString('en-US',{timeZone:'America/Los_Angeles'})); var d=p.getDay(); if(d===0||d===6) return false; var m=p.getHours()*60+p.getMinutes(); var wn=_gexBubWin(); return m>=wn[0] && m<wn[1]; }catch(e){ return false; } }
18862function _gexBubTsInRth(ts){ try{ var p=new Date(new Date(ts*1000).toLocaleString('en-US',{timeZone:'America/Los_Angeles'})); var m=p.getHours()*60+p.getMinutes(); var wn=_gexBubWin(); return m>=wn[0] && m<wn[1]; }catch(e){ return true; } }
18863// Trail is keyed by ticker + metric + expiry so GEX/VEX/CHARM and ALL/0DTE/WEEKLY each keep their own.
18864function _gexBubKey(){ return (typeof chartTicker!=='undefined'?(chartTicker||''):'') + '|' + (typeof currentMetric!=='undefined'?currentMetric:'gex') + '|' + (typeof gridExpiryMode!=='undefined'?gridExpiryMode:'all'); }
18865// How many of the strongest strikes to draw bubbles for — user setting (Modes → Strikes): Top 5 / Top
18866// 10 / All. This is the main lever to declutter: with a 30% threshold many strikes qualify, so capping
18867// to the top N (highest |GEX| first) keeps only the meaningful lines. 'all' → effectively uncapped.
18868function _gexBubMaxRows(){ var v=(typeof indState!=='undefined' && indState.gexbubStrikes)?indState.gexbubStrikes:'10'; if(v==='2') return 2; if(v==='5') return 5; if(v==='all') return 999; return 10; }
18869// Every trail column's `t` is a candle's `.time` — the CHART-AXIS basis that timeToCoordinate()
18870// expects. Intraday `.time` is tz-SHIFTED off true unix (see the candle builder), so a column
18871// stamped with raw unix (`_ts`) lands hours away and draws off-screen. `_gexBubTKey` gives a
18872// numeric sort/compare key for a `.time` that may be a 'YYYY-MM-DD' string on daily+ timeframes.
18873function _gexBubTKey(t){ return (typeof t==='number') ? t : ((typeof tsForCandleTime==='function') ? tsForCandleTime(t) : 0); }
18874function _gexBubCandleTs(c){ return (c._ts!=null) ? c._ts : _gexBubTKey(c.time); }   // true unix of a candle
18875// Chart-axis time of the current (last) candle — the bucket a live sample belongs to, so the
18876// trail is spaced ONE COLUMN PER CANDLE at the chart's timeframe (1m/5m/15m…), not per 20s sample.
18877function _gexBubCurCandleT(){
18878  try{ var cs=(typeof chartReplayCandles!=='undefined'&&chartReplayCandles)?chartReplayCandles:[]; for(var i=cs.length-1;i>=0;i--){ if(cs[i].time!=null) return cs[i].time; } }catch(e){}
18879  return null;
18880}
18881// When the timeframe changes, re-bucket every trail's snaps into the NEW candle grid (keep the latest
18882// per bucket) so the existing history re-spaces to the new timeframe instead of staying at the old gaps.
18883var _gexBubTf='';
18884function _gexBubMaybeRebucket(){
18885  var tf=(typeof currentTimeframe!=='undefined')?currentTimeframe:'';
18886  if(tf===_gexBubTf) return;
18887  var cs=(typeof chartReplayCandles!=='undefined'&&chartReplayCandles)?chartReplayCandles:[];
18888  var times=[]; for(var i=0;i<cs.length;i++){ if(cs[i].time!=null) times.push(cs[i].time); }
18889  times.sort(function(a,b){return _gexBubTKey(a)-_gexBubTKey(b);});
18890  if(times.length<2) return;   // wait until candles at the new tf are loaded
18891  var bucketOf=function(t){ var tv=_gexBubTKey(t), lo=0,hi=times.length-1,ans=times[0]; while(lo<=hi){ var m=(lo+hi)>>1; if(_gexBubTKey(times[m])<=tv){ans=times[m];lo=m+1;}else hi=m-1;} return ans; };
18892  for(var key in _gexBubTrail){ var tr=_gexBubTrail[key]; if(!tr||!tr.snaps||!tr.snaps.length) continue;
18893    // A history-seeded trail rebuilds from the RAW recorded snaps instead — re-bucketing would
18894    // collapse 5-min detail onto whatever coarse grid it was first built at (and can't add detail
18895    // back going 5m→1m). Falls through to re-bucketing if the raw snaps aren't cached.
18896    if(tr.hist && key===_gexBubKey() && _gexBubApplyHistory(key)) continue;   // raw snaps are the CURRENT ticker's — only its own key may rebuild
18897    var seen={}, out=[];
18898    for(var s=0;s<tr.snaps.length;s++){ var b=bucketOf(tr.snaps[s].t), sk=String(b);
18899      if(seen[sk]!=null) out[seen[sk]].rows=tr.snaps[s].rows;   // latest snap wins for a shared bucket
18900      else { seen[sk]=out.length; out.push({t:b, rows:tr.snaps[s].rows}); } }
18901    tr.snaps=out.sort(function(a,b){return _gexBubTKey(a.t)-_gexBubTKey(b.t);});
18902  }
18903  _gexBubTf=tf;
18904}
18905function _gexBubSample(){
18906  if(typeof _replayActive !== 'undefined' && _replayActive) return;   // don't sample recorded frames as live
18907  if(!_gexBubAllowed() || !indState.gexbub) return;
18908  if(!_gexBubMarketNow()){
18909    // GEX on ALL / 0DTE is RECORDED - its trail comes from history, never from an off-hours sample (that sample landed first on a
18910    // VEX->GEX round trip and the history tail-merge kept it: a lone column on the last candle, gone on reload - user 2026-09-05).
18911    if((typeof currentMetric==='undefined' || currentMetric==='gex') && (typeof gridExpiryMo
18911de==='undefined' || gridExpiryMode==='all' || gridExpiryMode==='0dte')) return;
18912    var _exT=_gexBubTrail[_gexBubKey()]; if(_exT && _exT.snaps && _exT.snaps.length) return;
18913  }   // market closed: still seed ONE current-state column when this combo has no trail at all (VEX / CHARM / WEEK are live-only), so a metric switch shows this metric instead of nothing
18914  if(_gexBubReseedKey && _gexBubReseedKey===_gexBubKey()) return;     // reseed pending — don't seed from stale columnData
18915  var tk = (typeof chartTicker!=='undefined') ? chartTicker : null; if(!tk) return;
18916  var cd = (typeof columnData!=='undefined') ? columnData[tk] : null;
18917  if(!cd || !cd.strikes || !cd.strikes.length) return;
18918  var maxG=0, i; for(i=0;i<cd.strikes.length;i++){ var a=Math.abs(cd.strikes[i].netGex||0); if(a>maxG) maxG=a; }
18919  if(maxG<=0) return;
18920  var rows=[];
18921  for(i=0;i<cd.strikes.length;i++){ var s=cd.strikes[i]; var pct=Math.abs(s.netGex||0)/maxG; if(pct<0.30) continue;   /* match the GEX panel: it only shows a % badge above 30% of the King */ rows.push({k:s.strike, pct:pct, a:Math.abs(s.netGex||0), pos:(s.netGex||0)>=0}); }
18922  if(!rows.length) return;
18923  // PROXIMITY RANKING for the index complex (user 2026-08-25) — must mirror proxRankScore() in
18924  // server.js, because the recorded trail is ranked there and the live bubbles here; if the two
18925  // disagree the bands flip at the newest snapshot (the marching-seam bug from 08-21).
18926  // Rank 1-2 draw purple, so ordering is what decides where purple sits. Raw |netGex| favours the
18927  // bigger open-interest strikes slightly further out, which barely move intraday — on SPXW at
18928  // spot 7674 that parked purple on 7800/7750 while 7690, the strike AT price, ranked 5th.
18929  // Weighting by distance re-centres on spot every sample, so purple tracks price on a 0DTE name.
18930  // The >=30% floor above still runs on RAW gamma, so nothing weak gets promoted for being close.
18931  var _PROX_TK = { SPX:1, SPXW:1, SPY:1, QQQ:1, NDX:1, XSP:1, RUT:1, DJX:1, IWM:1 };
18932  var _proxOn = !!_PROX_TK[String(tk||'').toUpperCase()];
18933  var _spotNow = (cd && cd.spot) ? +cd.spot : 0;
18934  if (_proxOn && _spotNow > 0) {
18935    for (i=0;i<rows.length;i++){ var _d=Math.abs(rows[i].k-_spotNow)/_spotNow; rows[i]._w = rows[i].pct/(1+_d/0.01); }
18936    rows.sort(function(a,b){return b._w-a._w;});
18937  } else rows.sort(function(a,b){return b.pct-a.pct;});
18938  var _mx=_gexBubMaxRows(); if(rows.length>_mx) rows.length=_mx;
18939  var day=_gexBubPtDay(), key=_gexBubKey(), tr=_gexBubTrail[key];
18940  if(!tr || tr.day!==day){
18941    // Fresh trail (page reload / new day): seed the session BACKDROP first — recorded history,
18942    // else the admin preview — so this first live sample EXTENDS a full day's trail. Without
18943    // this the sampler won the race against the seeders, created a lone-column trail, and every
18944    // seeder then saw "trail exists" and bailed — the "bubbles gone after reload until you click
18945    // 0DTE/ALL" bug. (History lands async on its first call; the tail-merge keeps this column.)
18946    try{ _gexBubSeedHistory(); }catch(e){}
18947    try{ if(typeof _gexBubSeedPreview==='function') _gexBubSeedPreview(); }catch(e){}
18948    tr=_gexBubTrail[key];
18949    if(!tr || tr.day!==day){ tr={day:day, snaps:[]}; _gexBubTrail[key]=tr; }
18950  }
18951  if(tr.preview){ tr.preview=false; }   // promote the preview to a live trail — KEEP its snaps as the backdrop so the Visual doesn't collapse to one column when the first live sample lands
18952  if(_gexBubTf==='' && typeof currentTimeframe!=='undefined') _gexBubTf=currentTimeframe;
18953  // Bucket to the current candle so the trail spacing follows the chart timeframe. Within one candle,
18954  // repeated 20s samples OVERWRITE that column (the forming candle updates in place); a new candle
18955  // pushes a new column, freezing the prior one. Fix for "bubbles show at 15-min gaps not per-timeframe".
18956  var snapT=_gexBubCurCandleT(); if(snapT==null) return;   // no candles yet → nothing to anchor the column to
18957  var last=tr.snaps.length ? tr.snaps[tr.snaps.length-1] : null;
18958  // Trend for the fade: compare each strike's % to the PREVIOUS candle (not the forming one). rising =
18959  // % increased → show bright; flat/declining → fade (but the column stays, preserving history); a
18960  // level that starts climbing again lands a fresh bright column.
18961  var prevSnap=(last && last.t===snapT) ? (tr.snaps.length>1?tr.snaps[tr.snaps.length-2]:null) : last;
18962  var prevPct={}; if(prevSnap) for(var _pi=0;_pi<prevSnap.rows.length;_pi++){ prevPct[prevSnap.rows[_pi].k]=prevSnap.rows[_pi].pct; }
18963  for(var _ri=0;_ri<rows.length;_ri++){ var _pp=prevPct[rows[_ri].k]; rows[_ri].rising=(_pp==null)?true:(rows[_ri].pct>_pp+0.004); }
18964  if(last && last.t===snapT){ last.rows=rows; }   // same candle → overwrite the forming column
18965  else { tr.snaps.push({ t:snapT, rows:rows }); }
18966  if(tr.snaps.length>2500) tr.snaps.splice(0, tr.snaps.length-2500);
18967  renderGexBubbles();
18968}
18969function _gexBubStart(){ if(!_gexBubOn || _gexBubTimer) return; _gexBubReloadAt=Date.now(); try{ _gexBubSeedHistory(); }catch(e){}   // kick the recorded-history fetch immediately (it needs no columnData) so the real trail is cached before the first live sample — kills the "single bubble on reload" flash
18970  try{ _gexBubSample(); }catch(e){} _gexBubTimer=setInterval(function(){ try{ _gexBubSample(); }catch(e){} }, 20000); }
18971function _gexBubStop(){ if(_gexBubTimer){ clearInterval(_gexBubTimer); _gexBubTimer=null; } }
18972// On load the chart/GEX panel data may arrive AFTER /api/me confirms admin, so keep trying to seed
18973// until the data exists (up to ~20s), then draw. Stops as soon as a trail is built.
18974function _gexBubSeedWhenReady(tries){
18975  tries=tries||0;
18976  if(!_gexBubOn || !_gexBubAllowed() || typeof indState==='undefined' || !indState.gexbub) return;
18977  var ex=_gexBubTrail[_gexBubKey()];
18978  if(ex && ex.snaps && ex.snaps.length){ renderGexBubbles(); return; }   // trail exists → done
18979  var tk=(typeof chartTicker!=='undefined')?chartTicker:null;
18980  var cd=(tk && typeof columnData!=='undefined')?columnData[tk]:null;
18981  if(!cd || !cd.strikes || !cd.strikes.length){
18982    // The GEX panel data isn't built yet (fresh ghost load) — kick a load; its renderColumn re-seeds.
18983    if(tries===0 && tk && typeof loadGEX==='function'){ try{ loadGEX(tk); }catch(e){} }
18984  } else {
18985    try{ if(_gexBubSeedHistory()) { renderGexBubbles(); return; } }catch(e){}   // recorded day first, preview only as fallback
18986    try{ if(typeof _gexBubSeedPreview==='function') _gexBubSeedPreview(); }catch(e){}
18987    var ex2=_gexBubTrail[_gexBubKey()];
18988    if(ex2 && ex2.snaps && ex2.snaps.length){ renderGexBubbles(); return; }
18989  }
18990  if(tries<40) setTimeout(function(){ _gexBubSeedWhenReady(tries+1); }, 500);
18991}
18992// Self-healing: every 3s, if GEX Visual is on (premium) and the current combo has no trail yet, seed it.
18993// Covers page load, ticker switches, and metric/expiry switches without depending on any single hook.
18994setInterval(function(){
18995  try{
18996    if(!_gexBubOn || !_gexBubAllowed() || typeof indState==='undefined' || !indState.gexbub) return;
18997    if(typeof _replayActive!=='undefined' && _replayActive) return;   // replay owns the trail — don't seed a live preview under it
18998    if(_gexBubReseedKey && _gexBubReseedKey===_gexBubKey()){
18999      // Normally the renderColumn hook clears this when fresh columnData lands. If no hook fired
19000      // within 8s (a failed/queued loadGEX, or any render path that skipped it), force-clear so
19001      // the seeders can't stay locked out until a page reload.
19002      if(Date.now()-_gexBubReseedAt < 8000) return;
19003      _gexBubReseedKey='';
19004    }
19005    var ex=_gexBubTrail[_gexBubKey()];
19006    if(ex && ex.snaps && ex.snaps.length && !ex.preview) return;
19007    // Recorded history is the real trail; the flat preview only stands in for tickers/metrics that
19008    // aren't recorded (and paints instantly while the history fetch is in flight).
19009    if(_gexBubSeedHistory()){ renderGexBubbles(); return; }
19010    if(ex && ex.snaps && ex.snaps.length) return;                     // preview already up — wait for history
19011    if(typeof _gexBubSeedPreview==='function'){ _gexBubSeedPreview(); renderGexBubbles(); }
19012  }catch(e){}
19013}, 1000);
19014// Per-mode filter settings (Darkpool Levels / Flow Marks). Both compute functions cache the RAW
19015// feed for 2 min and filter on every pass, so a change re-renders instantly with no extra fetch.
19016function setModeOpt(key, val){
19017  // Ghost Desk: Lines / Axis edits made while a pane is focused belong to THAT pane (user 2026-09-06).
19018  try {
19019    const _p = (typeof _mFocusedPaneId === 'function') ? _mFocusedPaneId() : null;
19020    if (_p != null && (key === 'glStyle' || key === 'chStyle')) {
19021      const _c = _mCharts[_p];
19022      if (_c && _c.slot != null) { _mModes(_c.slot)[key] = val; _mSaveSlots(); if (key === 'glStyle') _mLoad(_p); else _mApplyLevelModes(_p); }
19023      _syncModeOpts(); return;
19024    }
19025  } catch (e) {}
19026  indState[key] = val;
19027  try{ saveIndicatorState(); }catch(e){}
19028  _syncModeOpts();
19029  if (key.indexOf('dp') === 0 && typeof computeDarkpoolLevels === 'function') computeDarkpoolLevels();
19030  if (key.indexOf('fm') === 0 && typeof computeFlowMarks === 'function') computeFlowMarks();
19031  if (key === 'glStyle' && typeof applyGexLevelLines === 'function') applyGexLevelLines();
19032  if (key === 'chStyle' && typeof applyCharmPriceLines === 'function') applyCharmPriceLines();
19033  try { _mRefreshPanes(key === 'glStyle' || key === 'chStyle'); } catch (e) {}
19034}
19035// Multi-select label parts: independent booleans, unlike the single-choice segments.
19036function toggleModeOpt(key){
19037  indState[key] = (key === 'fmLblPrem') ? !(indState.fmLblPrem !== false) : !indState[key];
19038  try{ saveIndicatorState(); }catch(e){}
19039  _syncModeOpts();
19040  if (typeof computeFlowMarks === 'function') computeFlowMarks();
19041}
19042function _syncModeOpts(){
19043  var map = { dpMinPrem: indState.dpMinPrem || '0', dpMax: indState.dpMax || '5', dpMerge: (indState.dpMerge != null ? indState.dpMerge : '0.05'), dpStyle: indState.dpStyle || 'solid', fmMinPrem: indState.fmMinPrem || '100000', fmShow: indState.fmShow || 'top', fmMax: indState.fmMax || '25', glStyle: indState.glStyle || 'lines', chStyle: indState.chStyle || 'lines',
19044              fmDay: indState.fmDay || 'today', fmDetail: indState.fmDetail || 'all' };
19045  try { const _p = (typeof _mFocusedPaneId === 'function') ? _mFocusedPaneId() : null; if (_p != null) { const _c = _mCharts[_p]; if (_c && _c.slot != null) { map.glStyle = _mOpt(_c.slot, 'glStyle'); map.chStyle = _mOpt(_c.slot, 'chStyle'); } } } catch (e) {}   // focused pane: show ITS Lines/Axis
19046  document.querySelectorAll('.mo-seg').forEach(function(seg){
19047    var k = seg.dataset.mo;
19048    // fmLbl is the one MULTI-select group — each button lights from its own flag (fmLblPrem
19049    // defaults ON) rather than matching a single current value.
19050    if (k === 'fmLbl') { seg.querySelectorAll('button').forEach(function(b){
19051      var v = b.dataset.v, on = (v === 'fmLblPrem') ? (indState.fmLblPrem !== false) : !!indState[v];
19052      b.classList.toggle('on', on); }); return; }
19053    seg.querySelectorAll('button').forEach(function(b){ b.classList.toggle('on', b.dataset.v === map[k]); });
19054  });
19055}
19056// Style / Session / Extend selectors for the GEX Visual (premium). Persisted in indState.
19057function setGexbubOpt(key, val){
19058  indState['gexbub'+key.charAt(0).toUpperCase()+key.slice(1)] = val;   // gexbubStyle / gexbubSession / gexbubExtend
19059  try{ saveIndicatorState(); }catch(e){}
19060  _syncGexbubOpts();
19061  if(key==='session'){ _gexBubStop(); if(indState.gexbub && _gexBubAllowed()) _gexBubStart(); }   // re-arm the sample window
19062  // Both of these invalidate the columns already on screen, so the trail has to be rebuilt:
19063  //   strikes — rows were capped to the old N when they were stored.
19064  //   session — the window decides WHICH columns belong to the day. It only gated sampling and
19065  //     seeding, never the render, so going Regular -> Extended widened the trail (new columns got
19066  //     collected outside 6:30-1) but going back to Regular left every one of them on screen. The
19067  //     window looked stuck on Extended until the ticker changed.
19068  // Replay rebuilds from the recorded snapshots every frame, so there it's just a repaint.
19069  if(key==='strikes' || key==='session' || key==='prev'){ try{ if(!(typeof _replayActive!=='undefined' && _replayActive)) delete _gexBubTrail[_gexBubKey()]; }catch(e){} }
19070  // Re-seed from the recorded day first (it re-applies the window), preview only as the fallback.
19071  try{ if(typeof _gexBubSeedHistory==='function') _gexBubSeedHistory(); }catch(e){}
19072  try{ if(typeof _gexBubSeedPreview==='function') _gexBubSeedPreview(); }catch(e){}
19073  renderGexBubbles();
19074  // Ghost Desk: every pane's Visual follows the SAME settings menu — whatever is set here shows on
19075  // chart 2 and 3 too. Force the trail rebuild (bubStamp='') rather than trusting stamp diffing.
19076  try{ for (const _id of [2,3]) { const _c=_mCharts[_id]; if (_c) { _c.bubStamp=''; if(_c.ticker && typeof _mBubFetch==='function') _mBubFetch(_c.ticker); _mRenderOverlay(_id); } } }catch(e){}
19077}
19078function _syncGexbubOpts(){
19079  var _st=indState.gexbubStyle||'bubbles'; if(_st==='rect') _st='glow';   // the old Rectangles setting resolves to Glow Lines
19080  var map={ style: _st, strikes: indState.gexbubStrikes||'10', session: indState.gexbubSession||'reg', extend: indState.gexbubExtend||'off', prev: indState.gexbubPrev||'on', prevbright: indState.gexbubPrevbright||'dim' };
19081  document.querySelectorAll('.gb-seg').forEach(function(seg){ var k=seg.dataset.gb; seg.querySelectorAll('button').forEach(function(b){ b.classList.toggle('on', (k==='gvtags'||k==='gvsig') ? !!indState[b.dataset.v] : b.dataset.v===map[k]); }); });
19082}
19083// Collapsible Modes categories: clicking a category header hides every sibling up to the next header.
19084function toggleModeCat(hd){
19085  var collapsed=hd.classList.toggle('collapsed'), el=hd.nextElementSibling;
19086  while(el && !el.classList.contains('modecat')){
19087    if(collapsed){ if(el.dataset._d===undefined) el.dataset._d = el.style.display||''; el.style.display='none'; }
19088    else { el.style.display = (el.dataset._d!==undefined ? el.dataset._d : ''); }
19089    el=el.nextElementSibling;
19090  }
19091  var chev=hd.querySelector('.cat-chev'); if(chev) chev.textContent = collapsed?'▸':'▾';
19092  try{ var st=JSON.parse(localStorage.getItem('gf_modecat')||'{}'); st[hd.dataset.cat]=collapsed; localStorage.setItem('gf_modecat',JSON.stringify(st)); }catch(e){}
19093}
19094// Restore collapsed state + wire chevrons on load.
19095function _initModeCats(){
19096  var st={}; try{ st=JSON.parse(localStorage.getItem('gf_modecat')||'{}'); }catch(e){}
19097  document.querySelectorAll('.modecat').forEach(function(hd){ var want=!!st[hd.dataset.cat], is=hd.classList.contains('collapsed'); if(want!==is) toggleModeCat(hd); });   // idempotent
19098}
19099// Called when the metric/expiry toggle changes: clear the old combo's bubbles right away, then re-seed
19100// the new combo once columnData has rebuilt (two attempts, since the panel reload is async).
19101function _gexBubRefreshSoon(){
19102  if(!_gexBubAllowed() || typeof indState==='undefined' || !indState.gexbub) return;
19103  var key=_gexBubKey();
19104  _gexBubReseedKey=key;                 // this combo must reseed FRESH once its columnData rebuilds
19105  _gexBubReseedAt=Date.now();           // the self-heal force-clears a lock that outlives its loadGEX (safety net)
19106  try{ delete _gexBubTrail[key]; }catch(e){}   // drop any stale trail so nothing shows the old basis
19107  try{ renderGexBubbles(); }catch(e){}
19108  // Force columnData[chartTicker] to rebuild for the NEW metric/expiry; its renderColumn reseeds
19109  // the bubbles from the fresh % (see renderColumn's reseed hook). loadGEX reads currentMetric +
19110  // gridExpiryMode. Deferred 600ms: the expiry/metric toggle also reloads the chart itself at
19111  // this instant, and firing this duplicate ladder fetch in the same burst lagged the toggle
19112  // (user report) — the 8s reseed-lock failsafe covers the rare case where this never lands.
19113  try{ if(typeof chartTicker!=='undefined' && chartTicker && typeof loadGEX==='function'){ const _rtk=chartTicker; setTimeout(function(){ try{ if(chartTicker===_rtk) loadGEX(_rtk); }catch(e){} }, 600); } }catch(e){}
19114}
19115// PREVIEW (admin, off-hours): paint a full trail across the latest session's candles using the
19116// CURRENT GEX % spread, with a growth ramp, so the look can be judged before a live session builds it.
19117// Marked .preview so the first real 6:30am sample wipes it. No effect once a live trail exists.
19118// Preview trail for ANY chart: every >=30% strike of the ticker's live GEX ladder drawn at its current %
19119// across the newest session that has candles inside the window. Extracted from _gexBubSeedPreview so the
19120// Desk panes get the same fallback when nothing is recorded (weekend / unrecorded ticker - user 2026-09-06).
19121function _gexBubPreviewSnaps(tk, cs, mx, rowsOverride){
19122  try{
19123    var cd=rowsOverride?{strikes:rowsOverride}:((typeof columnData!=='undefined')?columnData[tk]:null);
19124    if(!cd||!cd.strikes||!cd.strikes.length||!cs||!cs.length) return null;
19125    var maxG=0,i; for(i=0;i<cd.strikes.length;i++){ var a=Math.abs(cd.strikes[i].netGex||0); if(a>maxG)maxG=a; }
19126    if(maxG<=0) return null;
19127    var base=[];
19128    for(i=0;i<cd.strikes.length;i++){ var st=cd.strikes[i]; var pct=Math.abs(st.netGex||0)/maxG; if(pct<0.30) continue; base.push({k:st.strike,pct:pct,pos:(st.netGex||0)>=0}); }
19129    if(!base.length) return null;
19130    base.sort(function(a,b){return b.pct-a.pct;}); if(base.length>mx) base.length=mx;
19131    var ptDateOf=function(ts){ try{ var d=new Date(ts*1000); var p=new Date(d.toLocaleString('en-US',{timeZone:'America/Los_Angeles'})); return p.getFullYear()+'-'+String(p.getMonth()+1).padStart(2,'0')+'-'+String(p.getDate()).padStart(2,'0'); }catch(e){ return ''; } };
19132    var byDate={};
19133    for(i=0;i<cs.length;i++){ var ts1=(cs[i]._ts!=null?cs[i]._ts:cs[i].time); if(cs[i].time==null || !_gexBubTsInRth(ts1)) continue; var pd=ptDateOf(ts1); (byDate[pd]=byDate[pd]||[]).push(cs[i].time); }
19134    var days=Object.keys(byDate).sort(); if(!days.length) return null;
19135    var times=byDate[days[days.length-1]], snaps=[];
19136    for(i=0;i<times.length;i++){ snaps.push({t:times[i], rows:base.map(function(r){ return {k:r.k,pct:r.pct,pos:r.pos}; })}); }
19137    return snaps;
19138  }catch(e){ return null; }
19139}
19140function _gexBubSeedPreview(){
19141  if(!_gexBubAllowed() || !chartTicker) return;   // premium (user 2026-09-06)
19142  if(typeof _replayActive!=='undefined' && _replayActive) return;   // replay draws its own recorded trail — don't build a live preview over it
19143  var key=_gexBubKey(), ex=_gexBubTrail[key];
19144  if(ex && ex.snaps && ex.snaps.length && !ex.preview) return;   // don't clobber a real trail
19145  var cd=(typeof columnData!=='undefined')?columnData[chartTicker]:null;
19146  if(!cd || !cd.strikes || !cd.strikes.length) return;
19147  var maxG=0,i; for(i=0;i<cd.strikes.length;i++){ var a=Math.abs(cd.strikes[i].netGex||0); if(a>maxG)maxG=a; }
19148  if(maxG<=0) return;
19149  var base=[];
19150  for(i=0;i<cd.strikes.length;i++){ var s=cd.strikes[i]; var pct=Math.abs(s.netGex||0)/maxG; if(pct<0.30) continue;   /* match the GEX panel: it only shows a % badge above 30% of the King */ base.push({k:s.strike,pct:pct,pos:(s.netGex||0)>=0}); }
19151  if(!base.length) return;
19152  base.sort(function(a,b){return b.pct-a.pct;}); var _mxb=_gexBubMaxRows(); if(base.length>_mxb) base.length=_mxb;
19153  var cs=(typeof chartReplayCandles!=='undefined' && chartReplayCandles)?chartReplayCandles:[];
19154  if(!cs.length) return;
19155  var ptDateOf=function(ts){ try{ var d=new Date(ts*1000); var p=new Date(d.toLocaleString('en-US',{timeZone:'America/Los_Angeles'})); return p.getFullYear()+'-'+String(p.getMonth()+1).padStart(2,'0')+'-'+String(p.getDate()).padStart(2,'0'); }catch(e){ return ''; } };
19156  // Use the newest session that actually HAS candles inside the window — not simply the newest DATE
19157  // present. Overnight, the first pre-market bar stamps a new date whose candles all sit outside
19158  // 6:30–1, so keying on the newest date alone produced an empty set and the Visual drew nothing at
19159  // all. It hit liquid names first (they print pre-market earliest), which is why it looked like
19160  // "bubbles missing on many tickers" rather than a clean on/off.
19161  var byDate={};
19162  for(i=0;i<cs.length;i++){ var ts1=(cs[i]._ts!=null?cs[i]._ts:cs[i].time);
19163    if(cs[i].time==null || !_gexBubTsInRth(ts1)) continue;
19164    var pd=ptDateOf(ts1); (byDate[pd]=byDate[pd]||[]).push(cs[i].time); }
19165  var days=Object.keys(byDate).sort(); if(!days.length) return;
19166  var times=byDate[days[days.length-1]];
19167  var snaps=[], n=times.length;
19168  for(i=0;i<n;i++){
19169    // HONEST preview: draw each strike at its CURRENT % across the session (no fake uniform ramp).
19170    // Live, each strike's bubble only grows/shrinks with ITS OWN % at each GEX refresh.
19171    var rows=base.map(function(r){ return {k:r.k, pct:r.pct, pos:r.pos}; });
19172    snaps.push({t:times[i], rows:rows});
19173  }
19174  _gexBubTrail[key]={ day:_gexBubPtDay(), snaps:snaps, preview:true };
19175  if(typeof currentTimeframe!=='undefined') _gexBubTf=currentTimeframe;   // preview is already per-candle at this tf
19176  renderGexBubbles();
19177}
19178// Extract the ≥30% rows for ONE recorded snapshot — shared by replay playback and the live history
19179// seed, so both draw an identical trail. is0 → use the recorded 0DTE per-strike set (s.g0).
19180// Returns null when the snapshot has no usable near-spot gamma (older/glitched recordings).
19181function _gexBubRowsFromSnap(s, is0, maxRows){
19182  // 0DTE shows the recorded 0DTE basis (g0) or NOTHING — silently falling back to the ALL-basis
19183  // g made the 0DTE toggle draw the exact same bubbles as ALL on tickers without a recorded g0
19184  // (user report: each basis must show its own bubbles). Live in-session sampling always has the
19185  // true basis for all three toggles; this only affects recorded-history seeds and replay.
19186  var gArr = is0 ? ((s.g0 && s.g0.length) ? s.g0 : null) : s.g;
19187  if(!gArr || !gArr.length) return null;
19188  // Only consider strikes NEAR spot (±4%). Some recorded snapshots have a data glitch where a far-OTM
19189  // strike carries an anomalously large gamma and becomes the "King" — that both hijacks the 30%
19190  // threshold AND draws bubbles off the visible price range (looked like a mid-session gap). Real
19191  // gamma walls sit within a few % of spot, so this band keeps them and drops the glitches.
19192  var sp=+s.s||0, band=sp>0?sp*0.04:Infinity, near=function(k){ return sp<=0 || Math.abs(k-sp)<=band; };
19193  var maxG=0,j; for(j=0;j<gArr.length;j++){ if(!near(+gArr[j][0])) continue; var a=Math.abs(gArr[j][1]||0); if(a>maxG) maxG=a; }
19194  if(maxG<=0) return null;
19195  var rows=[]; for(j=0;j<gArr.length;j++){ var k=+gArr[j][0]; if(!near(k)) continue; var g=gArr[j][1]||0, pct=Math.abs(g)/maxG; if(pct<0.30) continue; rows.push({k:k, pct:pct, a:Math.abs(g), pos:g>=0}); }
19196  if(!rows.length) return null;
19197  rows.sort(function(a,b){return b.pct-a.pct;}); if(rows.length>maxRows) rows.length=maxRows;
19198  return rows;
19199}
19200// ── Live history seed ───────────────────────────────────────────────────────────────────────────
19201// The live trail used to hold ONLY what this browser tab sampled since it was opened — so a chart
19202// opened at 1pm (or after the close) showed the flat "preview" instead of the day the bubbles
19203// actually built. The server has been recording per-strike gamma every ~5 min all along (that's what
19204// Replay plays back), so seed the live trail from THAT and let the 20s samples extend it. Result:
19205// the live chart shows the same skipping/reforming bands you see in replay, right up to now.
19206// GEX metric + ALL/0DTE only (that's all the recorder stores), and only for REPLAY_TICKERS.
19207// ON (2026-08-03) — with the gap-fill in _gexBubApplyHistory. It was OFF because the recorder
19208// samples every ~5 MINUTES while the trail draws one column per candle, so a seeded trail left
19209// every in-between candle empty (whole-Visual flicker). The apply step now carries each recorded
19210// column forward into the candles between recordings, so a reload mid-session rebuilds the full
19211// day's trail from REAL recorded data — for members too (the synthetic preview stays admin-only).
19212var _gexBubUseHistory=true;
19213var _gexBubHistRaw={};    // 'TICKER|day' -> raw recorded snaps (fetched once per ticker per day)
19214var _gexBubHistAt={};     // 'TICKER|day' -> next allowed fetch (ms): 20s while in flight, 5 min after a miss
19215var _gexBubReloadAt=0;    // ms of the last (re)start — while the recorded trail is still in flight we hold the last frame instead of painting the lone live column as a full band (the "single bubble on reload" flash)
19216// Build ONE recorded day's columns (mapped to candles, glitch-held, gap-filled). prevFlag stamps
19217// each column so the render dims the previous session's bubbles.
19218function _gexBubDayCols(raw, cs, is0, mx, prevFlag, capPrev){
19219  // PREVIOUS DAY = full structure (user request 2026-08-07). Yesterday is context, so it shows
19220  // every strike that clears the 30%-of-King threshold regardless of the Strikes setting; the
19221  // Top 5 / Top 10 / All filter applies to TODAY only, where clarity matters.
19222  // ...EXCEPT when the previous session is ALL there is (weekend / pre-open, no recording for today yet): then it is the
19223  // whole Visual and the Strikes buttons looked dead - Top 2 / 5 / 10 changed nothing (user 2026-09-05). capPrev applies the cap.
19224  if(prevFlag && !capPrev) mx = 999;
19225  var candFor=function(sec){ var lo=0,hi=cs.length-1,ans=-1; while(lo<=hi){ var m=(lo+hi)>>1; if(_gexBubCandleTs(cs[m])<=sec){ans=m;lo=m+1;}else hi=m-1; } return ans<0?null:cs[ans]; };
19226  var _maxGap=(typeof INTRADAY_TFS!=='undefined' && typeof currentTimeframe!=='undefined' && INTRADAY_TFS.indexOf(currentTimeframe)>=0) ? 6*3600 : 26*3600;
19227  var seen={}, out=[], lastGood=null, started=false;
19228  for(var i=0;i<raw.length;i++){ var s=raw[i], sec=Math.floor(s.t/1000);
19229    if(!_gexBubTsInRth(sec)) continue;                     // session window only (follows the Session toggle)
19230    var rows=_gexBubRowsFromSnap(s, is0, mx);
19231    if(rows){ lastGood=rows; started=true; }
19232    else if(started && lastGood){ rows=lastGood; }         // recording glitch → hold the last good structure
19233    else continue;
19234    var c=candFor(sec); if(!c || c.time==null) continue;
19235    if(sec-_gexBubCandleTs(c) > _maxGap) continue;         // snapshot past the last loaded candle → drop
19236    var sk=String(c.time);
19237    if(seen[sk]!=null) out[seen[sk]].rows=rows;            // latest snapshot wins for a shared candle
19238    else { seen[sk]=out.length; out.push(prevFlag?{t:c.time, rows:rows, prev:true}:{t:c.time, rows:rows}); }
19239  }
19240  if(!out.length) return out;
19241  out.sort(function(a,b){return _gexBubTKey(a.t)-_gexBubTKey(b.t);});
19242  // GAP-FILL within THIS day only (filling across the overnight gap would paint structure through
19243  // hours nothing was recorded for): carry each recorded column into the in-between candles.
19244  if(out.length>1){
19245    var _have={}; for(var _f=0;_f<out.length;_f++) _have[String(out[_f].t)]=true;
19246    var _lo=_gexBubTKey(out[0].t), _hi=_gexBubTKey(out[out.length-1].t), _fill=[], _oi=0;
19247    for(var _ci=0;_ci<cs.length;_ci++){ var _ct=_gexBubTKey(cs[_ci].time);
19248      if(_ct<=_lo || _ct>=_hi) continue;
19249      if(_have[String(cs[_ci].time)]) continue;
19250      if(!_gexBubTsInRth(_gexBubCandleTs(cs[_ci]))) continue;
19251      while(_oi<out.length-1 && _gexBubTKey(out[_oi+1].t)<=_ct) _oi++;
19252      // CARRY 'a' (user 2026-09-10). A filled column dropped the absolute gamma, and _reachOf reads
19253      // rw.a==null as "an old trail with no absolute recorded" and falls back to the pre-2026-09-08
19254      // curve - roughly 30px - so on a 5-minute recording against 1-minute candles about one column
19255      // in five drew six times taller than its neighbours. Measured on SPXW: 17 of 80 columns.
19256      var _cl=(out[_oi].rows||[]).map(function(r){ return {k:r.k, pct:r.pct, pos:r.pos, a:r.a}; });
19257      _fill.push(prevFlag?{t:cs[_ci].time, rows:_cl, prev:true}:{t:cs[_ci].time, rows:_cl});
19258    }
19259    if(_fill.length) out=out.concat(_fill).sort(function(a,b){return _gexBubTKey(a.t)-_gexBubTKey(b.t);});
19260  }
19261  return out;
19262}
19263// ── GEX VISUAL TAGS + SIGNALS (ADMIN TEST, user-approved sample 2026-08-29) ──
19264// Small DOM tags at the LIVE edge of each band (strike / % strength / ~15-min trend
19265// delta, any combination), a cyan BUILDING pulse on the single fastest-strengthening
19266// band (gamma builds at a strike before price arrives - the early direction), and a
19267// PULL chip: net gamma pull above vs below price. Works over BOTH bubble styles since
19268// it reads the trail's last snapshot, not the canvas. DOM elements, so the pulse is a
19269// CSS animation - zero canvas repaints. Updated on a cheap 500ms timer (<=12 nodes).
19270function _gvTagsOn(){ return typeof isPaidUser!=='undefined' && isPaidUser && typeof indState!=='undefined' && indState.gexbub && (indState.gvTagStrike||indState.gvTagPct||indState.gvTagTrend||indState.gvBuild||indState.gvPull); }
19271// X anchor for the tags: just AFTER the last candle (the bands' live edge), not par
19271ked
19272// at the price-axis corner (user 2026-08-29). Falls back near the axis when the last
19273// candle is scrolled off-screen.
19274function _gvAnchorX(){
19275  try{
19276    if(typeof chartObj==='undefined'||!chartObj||typeof chartCandles==='undefined'||!chartCandles.length) return null;
19277    var x=chartObj.timeScale().timeToCoordinate(chartCandles[chartCandles.length-1].time);
19278    return (x!=null&&isFinite(x))?x:null;
19279  }catch(e){ return null; }
19280}
19281// Pure LAYOUT pass (positions only, no rebuild): runs every frame of the gesture rAF loop
19282// and on visible-range changes, so the tags track a drag instead of stick-then-snap -
19283// the exact fix the politician badges needed (user video 2026-08-26, again 2026-08-29).
19284function _gvTagsLayout(){
19285  try{
19286    var h=document.getElementById('gvTags'); if(!h||!h.children.length) return;
19287    var ct=document.getElementById('chartContent'); if(!ct||typeof chartSeries==='undefined'||!chartSeries) return;
19288    var w=ct.clientWidth, ax=_gvAnchorX();
19289    var lx=(ax!=null)?Math.max(60,Math.min(ax+46,w-250)):Math.max(60,w-300);   // +46 (was +14): clear of the live-edge bubbles (user 2026-08-31)
19290    var nodes=[].slice.call(h.children), pull=null, arr=[];
19291    nodes.forEach(function(nd){
19292      if(nd.getAttribute('data-pull')){ pull=nd; return; }
19293      var k=parseFloat(nd.getAttribute('data-k')); var y=null;
19294      try{ y=chartSeries.priceToCoordinate(k); }catch(e){}
19295      if(y==null||y<4||y>ct.clientHeight-10){ nd.style.display='none'; return; }
19296      nd.style.display=''; arr.push({nd:nd,y:y});
19297    });
19298    arr.sort(function(a,b){return a.y-b.y;});
19299    var lastY=-99;
19300    arr.forEach(function(o){ var y2=Math.max(o.y-9,lastY+15); lastY=y2; o.nd.style.top=y2+'px'; o.nd.style.left=lx+'px'; o.nd.style.right='auto'; });
19301    if(pull){
19302      var px=(typeof chartCurrentPrice!=='undefined'&&chartCurrentPrice)||0, yp=null;
19303      try{ yp=px>0?chartSeries.priceToCoordinate(px):null; }catch(e){}
19304      if(yp==null){ pull.style.display='none'; }
19305      else { pull.style.display=''; pull.style.top=(yp+10)+'px'; pull.style.left=lx+'px'; pull.style.right='auto'; }
19306    }
19307  }catch(e){}
19308}
19309function _gvTagsUpdate(){
19310  try{
19311    var h=document.getElementById('gvTags');
19312    var ok=_gvTagsOn() && !(typeof _replayActive!=='undefined'&&_replayActive) && gfLive('chart') && typeof chartSeries!=='undefined' && chartSeries;
19313    if(!ok){ if(h) h.innerHTML=''; return; }
19314    var ct=document.getElementById('chartContent'); if(!ct) return;
19315    if(!h){ h=document.createElement('div'); h.id='gvTags'; h.style.cssText='position:absolute;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:14;overflow:hidden'; ct.appendChild(h); }
19316    var tk=(typeof _gexBubKey==='function')?_gexBubKey():null;
19317    var tr=(tk && typeof _gexBubTrail!=='undefined')?_gexBubTrail[tk]:null;
19318    var all=(tr&&tr.snaps)||[];
19319    var snaps=all.filter(function(x){return !x.prev;});
19320    if(!snaps.length) snaps=all;   // weekend/off-hours: describe the prev-session trail on screen
19321    var rows=null, prevMapT=null, prevMapA=null, src='trail';
19322    if(snaps.length){
19323      var last=snaps[snaps.length-1];
19324      var prevS=snaps.length>3?snaps[snaps.length-4]:null;   // ~15 min back at the 5-min cadence
19325      rows=(last.rows||[]).slice(0,10);
19326      if(prevS&&prevS.rows){ prevMapT={}; prevS.rows.forEach(function(r){ prevMapT[r.k]=r.pct; if(r.a>0){ prevMapA=prevMapA||{}; prevMapA[r.k]=r.a; } }); }
19327    }
19328    if((!rows||!rows.length) && typeof columnData!=='undefined' && typeof chartTicker!=='undefined' && chartTicker && columnData[chartTicker] && columnData[chartTicker].strikes){
19329      // No trail at all (fresh weekend session): read the LIVE per-strike data the chart
19330      // already holds so the tags are testable right now (user 2026-08-29). A rolling
19331      // in-page baseline (5-min steps) makes TREND work ~15 min after the page opens.
19332      src='live';
19333      var st=columnData[chartTicker].strikes, mx=0;
19334      st.forEach(function(r){ mx=Math.max(mx,Math.abs(r.netGex||0)); });
19335      if(mx>0) rows=st.slice().sort(function(a2,b2){ return Math.abs(b2.netGex||0)-Math.abs(a2.netGex||0); }).slice(0,10).map(function(r){ return { k:r.strike, pct:Math.abs(r.netGex||0)/mx, a:Math.abs(r.netGex||0), pos:(r.netGex||0)>=0 }; });
19336    }
19337    // TREND — REBUILT 2026-09-02 (after ~6 rounds, the honest fix). The old baseline read the
19338    // TRAIL snapshot for "now" (updates only every ~5 min) and sampled that same stale trail for
19339    // the baseline -> stale-vs-stale -> ~0. AND prevSpot was declared 0 and never assigned, so the
19340    // board spot-cancel was always a no-op. Now BOTH sides read LIVE columnData: a dedicated
19341    // history samples the live per-strike gamma every 30s (t + spot + abs gamma), the baseline is
19342    // the oldest sample >=150s old (>=40s cold-start), and each strike's delta is nowLiveA vs that
19343    // baseline with an exact per-sample (spotNow/spotBase)^2 cancel. Independent of the trail.
19344    var _liveA={};   // strike -> live |netGex|, the current truth
19345    try{ if(typeof columnData!=='undefined'&&columnData[chartTicker]&&columnData[chartTicker].strikes){ columnData[chartTicker].strikes.forEac
19345h(function(r){ var a=Math.abs(r.netGex||0); if(r.strike&&a>0) _liveA[r.strike]=a; }); } }catch(e){}
19346    // Fallback to the trail rows' abs only if live columnData isn't loaded (weekend/off-hours).
19347    if(!Object.keys(_liveA).length && rows){ rows.forEach(function(r){ if(r.a>0) _liveA[r.k]=r.a; }); }
19348    var _sNow=((typeof window._chartSpot!=='undefined'&&window._chartSpot)||(typeof chartCurrentPrice!=='undefined'&&chartCurrentPrice)||0);
19349    var _tBaseMap=null, _tBaseSpot=0;
19350    try{
19351      if(Object.keys(_liveA).length){
19352        // Per-ticker baseline (was a single shared array wiped on ticker change): an interleaved
19353        // render for another ticker — the index proxy, or multi-chart Ghost Desk panes — used to wipe
19354        // this ticker's history before it aged past 40s, leaving SPX/SPY perpetually "NO BASELINE"
19355        // (matches the extension fix, user 2026-09-03). Each ticker now keeps its own array.
19356        window._gvHistBy=window._gvHistBy||{}; if(!window._gvHistBy[tk]) window._gvHistBy[tk]=[];
19357        window._gvHist=window._gvHistBy[tk]; window._gvHistTk=tk;
19358        var nowT=Date.now();
19359        var lastH=window._gvHist[window._gvHist.length-1];
19360        if(!lastH||nowT-lastH.t>=30*1000){ window._gvHist.push({t:nowT,s:_sNow,map:Object.assign({},_liveA)}); if(window._gvHist.length>40) window._gvHist.shift(); }
19361        // Oldest sample >=150s old; else oldest >=40s (cold-start right after load).
19362        for(var hj=0;hj<window._gvHist.length;hj++){ if(nowT-window._gvHist[hj].t>=150*1000){ _tBaseMap=window._gvHist[hj].map; _tBaseSpot=window._gvHist[hj].s||0; break; } }
19363        if(!_tBaseMap){ for(var hj2=0;hj2<window._gvHist.length;hj2++){ if(nowT-window._gvHist[hj2].t>=40*1000){ _tBaseMap=window._gvHist[hj2].map; _tBaseSpot=window._gvHist[hj2].s||0; break; } } }
19364      }
19365    }catch(e){}
19366    var _sAdj=(_tBaseSpot>0&&_sNow>0)?(_sNow*_sNow)/(_tBaseSpot*_tBaseSpot):1;   // shared spot^2 cancel (all strikes share one baseline sample)
19367    if(!rows||!rows.length){
19368      if(window._gvWhy!=='no-data'){ window._gvWhy='no-data'; try{ console.log('[GVTags] no trail and no live strikes for', typeof chartTicker!=='undefined'?chartTicker:'?'); }catch(e){} }
19369      h.innerHTML=''; return;
19370    }
19371    if(window._gvWhy!=='drawing-'+src){ window._gvWhy='drawing-'+src; try{ console.log('[GVTags] drawing from', src, '-', rows.length, 'rows'); }catch(e){} }
19372    var items=[], bestB=null;
19373    rows.forEach(function(rw,j){
19374      var yy; try{ yy=chartSeries.priceToCoordinate(rw.k); }catch(e){ yy=null; }
19375      if(yy==null||yy<4||yy>ct.clientHeight-10) return;
19376      var col=(j===0)?'#a855f7':(j===1)?'#7c3aed':(rw.pos?'#f5c518':'#ef4444');
19377      var dl=null;
19378      // Each strike's OWN gamma growth: live now vs the baseline sample, spot^2-cancelled.
19379      var isAbs=false;
19380      var _nowA=_liveA[rw.k], _baseA=_tBaseMap?_tBaseMap[rw.k]:null;
19381      if(_nowA>0&&_baseA>0){ var _r9=Math.max(-300,Math.min(300,(_nowA*_sAdj-_baseA)/_baseA*100)); dl=Math.abs(_r9)<9.95?Math.round(_r9*10)/10:Math.round(_r9); isAbs=true; }
19382      var it={k:rw.k,pct:Math.round(rw.pct*100),dl:dl,dlRaw:dl,ab:isAbs,col:col,y:yy};
19383      items.push(it);
19384    });
19385    // TREND = raw own-growth, spot^2-cancelled (reverted from median subtraction 2026-09-02 after
19386    // 5 rounds - median made exact zeros). Debug: print the real per-strike deltas so a 'zero'
19387    // report is diagnosable from data. (dlRaw already holds the raw value; nothing to overwrite.)
19388    try{
19389      if(isAdminUser && (window.__gfTrDbgTk!==tk || Date.now()-(window.__gfTrDbgAt||0)>60000)){
19390        window.__gfTrDbgTk=tk; window.__gfTrDbgAt=Date.now();
19391        console.log('[GF TREND]', tk, 'live strikes:', Object.keys(_liveA).length, 'baseline:', _tBaseMap?Object.keys(_tBaseMap).length:0, 'baseAge(s):', (window._gvHist&&window._gvHist.length)?Math.round((Date.now()-window._gvHist[0].t)/1000):0, '| top:', items.map(function(x){return x.k+':'+(x.dl==null?'∅':x.dl);}).join(' '));
19392      }
19393    }catch(e){}
19394    items.forEach(function(it){
19395      var _bth=it.ab?5:6;   // >=5% per ~5 min - SPX-size strikes build slowly (user 2026-08-31)
19396      if(indState.gvBuild&&it.dl!=null&&it.dl>=_bth&&(!bestB||it.dl>bestB.dl)) bestB=it;
19397    });
19398    try{
19399      var bs=window._gvBldSt||(window._gvBldSt={k:0,t:0}); var now4=Date.now();
19400      var cur=null;
19400 for(var bi=0;bi<items.length;bi++){ if(items[bi].k===bs.k){ cur=items[bi]; break; } }
19401      var curOk=cur&&cur.dl!=null&&cur.dl>=(cur.ab?3:4);   // the holder keeps its badge on a slightly lower bar
19402      if(bs.k&&now4-bs.t<60000&&curOk&&(!bestB||bestB.dl<(cur.dl+10))) bestB=cur;
19403      else if(bestB){ if(bs.k!==bestB.k){ bs.k=bestB.k; bs.t=now4; } }
19404      else bs.k=0;
19405    }catch(e){}
19406    items.sort(function(a,b){return a.y-b.y;});
19407    var lastY=-99, out='';
19408    items.forEach(function(it){
19409      var parts='';
19410      if(indState.gvTagStrike) parts+='<span style="color:'+it.col+'">'+(typeof _dispStrike==='function'?_dispStrike(it.k):it.k)+'</span>';
19411      if(indState.gvTagPct) parts+=(parts?' ':'')+'<span style="color:#e2e8f0;font-weight:600;font-size:9px">'+it.pct+'%</span>';
19412      if(indState.gvTagTrend&&it.dl!=null) parts+=(parts?' ':'')+'<span style="font-size:9px;font-weight:800;color:'+(it.dl>=0?'#4ade80':'#f87171')+'">'+(it.dl>=0?'▲ +':'▼ ')+it.dl+'</span>';
19413      var build=(bestB&&bestB.k===it.k);
19414      if(build) parts+=' <span style="color:#22d3ee;font-weight:800;font-size:9px">BUILDING</span>';
19415      if(!parts) return;
19416      var y2=Math.max(it.y-9,lastY+15); lastY=y2;
19417      out+='<div class="'+(build?'gv-pulse':'')+'" data-k="'+it.k+'" style="position:absolute;left:-9999px;top:'+y2+'px;font-size:9.5px;font-weight:700;white-space:nowrap;padding:1px 6px;border-radius:4px;background:rgba(8,12,20,.9);border:1px solid '+it.col+'55">'+parts+'</div>';
19418    });
19419    if(indState.gvPull){
19420      var px=(typeof chartCurrentPrice!=='undefined'&&chartCurrentPrice)||0;
19421      if(px>0&&items.length){
19422        var up=0,dn=0,tU=null,tD=null,minD=px*0.002;
19423        items.forEach(function(it){ var w=it.pct/(1+Math.abs(it.k-px)/(px*0.02)); if(it.k>px){ up+=w; if(it.k-px>=minD&&(!tU||w>tU.w)) tU={k:it.k,w:w}; } else if(it.k<px){ dn+=w; if(px-it.k>=minD&&(!tD||w>tD.w)) tD={k:it.k,w:w}; } });
19424        var winUp=up>=dn, tgt=winUp?tU:tD;
19425        // 60s hold (user 2026-08-31, 'changing too often n very close to price'): the target
19426        // must sit >=0.2% away, and the arrow flips early only on clear 1.5x dominance.
19427        var ps=window._gvPullSt||(window._gvPullSt={dir:0,k:0,t:0}); var now5=Date.now();
19428        var nd=winUp?1:-1, lead=winUp?(dn>0?up/dn:9):(up>0?dn/up:9);
19429        if(tgt){
19430          if(ps.dir===0){ ps.dir=nd; ps.k=tgt.k; ps.t=now5; }
19431          else if(nd===ps.dir){ if(ps.k!==tgt.k&&now5-ps.t>=60000){ ps.k=tgt.k; ps.t=now5; } }
19432          else if(lead>=1.5||now5-ps.t>=60000){ ps.dir=nd; ps.k=tgt.k; ps.t=now5; }
19433        }
19434        if(ps.dir!==0&&ps.k){ winUp=ps.dir>0; tgt={k:ps.k}; }
19435        var yp; try{ yp=chartSeries.priceToCoordinate(px); }catch(e){ yp=null; }
19436        if(yp!=null&&tgt) out+='<div data-pull="1" style="position:absolute;left:-9999px;top:'+(yp+10)+'px;font-size:9px;font-weight:800;color:#22d3ee;background:rgba(8,12,20,.94);border:1px solid rgba(34,211,238,.6);border-radius:4px;padding:1px 6px">PULL '+(winUp?'▲':'▼')+' '+(typeof _dispStrike==='function'?_dispStrike(tgt.k):tgt.k)+'</div>';
19437      }
19438    }
19439    h.innerHTML=out;
19440    try{ if(typeof chartObj!=='undefined'&&chartObj&&!chartObj._gvSub){ chartObj._gvSub=true; } }catch(e){}   // _chFollow lays the tags out with the rest of the chart's followers
19441    _gvTagsLayout();
19442  }catch(e){}
19443}
19444function gvToggleTag(key){ indState[key]=!indState[key]; try{ saveIndicatorState(); }catch(e){} try{ _syncGexbubOpts(); }catch(e){} _gvTagsUpdate(); }
19445setInterval(function(){ try{ _gvTagsUpdate(); }catch(e){} try{ if(typeof _mGvTagsAll==='function') _mGvTagsAll(); }catch(e){} }, 500);
19446// ── Desk pane tags + signals (parity 2026-09-07): Strike / % / Trend tags, BUILDING and PULL, same rules as _gvTagsUpdate ──
19447function _mGvHost(id){ const c=_mCharts[id]; if(!c||!c.canvas) return null; let h=c.canvas.querySelector('.mchart-gvtags'); if(!h){ h=document.createElement('div'); h.className='mchart-gvtags'; h.style.cssText='position:absolute;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:14;overflow:hidden'; c.canvas.appendChild(h); } return h; }
19448function _mGvTagsAll(){ if(typeof _mLayout==='undefined'||_mLayout<2) return; for(const id of [2,3]){ try{ _mGvTags(id); }catch(e){} } }
19449function _mGvTags(id){
19450  const c=_mCharts[id]; if(!c||c.slot==null||!c.series) return;
19451  const h=_mGvHost(id); if(!h) return;
19452  const ok=_gvTagsOn() && _mModeOn(c.slot,'gexbub') && !(typeof _replayActive!=='undefined'&&_replayActive);
19453  if(!ok){ h.innerHTML=''; return; }
19454  const tr=_mBubTrailFor(id); const all=(tr&&tr.snaps)||[]; let snaps=all.filter(x=>!x.prev); if(!snaps.length) snaps=all;
19455  let rows=null, src='trail';
19456  if(snaps.length){ rows=(snaps[snaps.length-1].rows||[]).slice(0,10); }
19457  const cd=(typeof columnData!=='undefined')?columnData[c.ticker]:null;
19458  if((!rows||!rows.length) && cd && cd.strikes){ src='live'; let mx=0;
19458 cd.strikes.forEach(r=>{ mx=Math.max(mx,Math.abs(r.netGex||0)); }); if(mx>0) rows=cd.strikes.slice().sort((a,b)=>Math.abs(b.netGex||0)-Math.abs(a.netGex||0)).slice(0,10).map(r=>({k:r.strike,pct:Math.abs(r.netGex||0)/mx,a:Math.abs(r.netGex||0),pos:(r.netGex||0)>=0})); }
19459  const liveA={}; try{ if(cd&&cd.strikes) cd.strikes.forEach(r=>{ const a=Math.abs(r.netGex||0); if(r.strike&&a>0) liveA[r.strike]=a; }); }catch(e){}
19460  if(!Object.keys(liveA).length && rows) rows.forEach(r=>{ if(r.a>0) liveA[r.k]=r.a; });
19461  const sNow=(c.bars&&c.bars.length)?+c.bars[c.bars.length-1].close:0;
19462  let baseMap=null, baseSpot=0;
19463  try{ if(Object.keys(liveA).length){ const tk=c.ticker+'|gex|'+(c.basis||'all'); window._gvHistBy=window._gvHistBy||{}; const hist=window._gvHistBy[tk]=window._gvHistBy[tk]||[]; const nowT=Date.now(); const lastH=hist[hist.length-1]; if(!lastH||nowT-lastH.t>=30000){ hist.push({t:nowT,s:sNow,map:Object.assign({},liveA)}); if(hist.length>40) hist.shift(); } for(let i=0;i<hist.length;i++){ if(nowT-hist[i].t>=150000){ baseMap=hist[i].map; baseSpot=hist[i].s||0; break; } } if(!baseMap){ for(let i=0;i<hist.length;i++){ if(nowT-hist[i].t>=40000){ baseMap=hist[i].map; baseSpot=hist[i].s||0; break; } } } } }catch(e){}
19464  const sAdj=(baseSpot>0&&sNow>0)?(sNow*sNow)/(baseSpot*baseSpot):1;
19465  if(!rows||!rows.length){ h.innerHTML=''; return; }
19466  const items=[]; let bestB=null;
19467  rows.forEach((rw,j)=>{ let yy; try{ yy=c.series.priceToCoordinate(rw.k); }catch(e){ yy=null; } if(yy==null||yy<4||yy>c.canvas.clientHeight-10) return; const col=(j===0)?'#a855f7':(j===1)?'#7c3aed':(rw.pos?'#f5c518':'#ef4444'); let dl=null, isAbs=false; const nowA=liveA[rw.k], baseA=baseMap?baseMap[rw.k]:null; if(nowA>0&&baseA>0){ const r9=Math.max(-300,Math.min(300,(nowA*sAdj-baseA)/baseA*100)); dl=Math.abs(r9)<9.95?Math.round(r9*10)/10:Math.round(r9); isAbs=true; } items.push({k:rw.k,pct:Math.round(rw.pct*100),dl,ab:isAbs,col,y:yy}); });
19468  items.forEach(it=>{ const bth=it.ab?5:6; if(indState.gvBuild&&it.dl!=null&&it.dl>=bth&&(!bestB||it.dl>bestB.dl)) bestB=it; });
19469  try{ const bs=c._gvBldSt||(c._gvBldSt={k:0,t:0}); const now4=Date.now(); let cur=null; for(const it of items){ if(it.k===bs.k){ cur=it; break; } } const curOk=cur&&cur.dl!=null&&cur.dl>=(cur.ab?3:4); if(bs.k&&now4-bs.t<60000&&curOk&&(!bestB||bestB.dl<(cur.dl+10))) bestB=cur; else if(bestB){ if(bs.k!==bestB.k){ bs.k=bestB.k; bs.t=now4; } } else bs.k=0; }catch(e){}
19470  items.sort((a,b)=>a.y-b.y);
19471  let lastY=-99, out='';
19472  items.forEach(it=>{ let parts=''; if(indState.gvTagStrike) parts+='<span style="color:'+it.col+'">'+(typeof _dispStrike==='function'?_dispStrike(it.k):it.k)+'</span>'; if(indState.gvTagPct) parts+=(parts?' ':'')+'<span style="color:#e2e8f0;font-weight:600;font-size:9px">'+it.pct+'%</span>'; if(indState.gvTagTrend&&it.dl!=null) parts+=(parts?' ':'')+'<span style="font-size:9px;font-weight:800;color:'+(it.dl>=0?'#4ade80':'#f87171')+'">'+(it.dl>=0?'▲ +':'▼ ')+it.dl+'</span>'; const build=(bestB&&bestB.k===it.k); if(build) parts+=' <span style="color:#22d3ee;font-weight:800;font-size:9px">BUILDING</span>'; if(!parts) return; const y2=Math.max(it.y-9,lastY+15); lastY=y2; out+='<div class="'+(build?'gv-pulse':'')+'" data-k="'+it.k+'" style="position:absolute;left:-9999px;top:'+y2+'px;font-size:9.5px;font-weight:700;white-space:nowrap;padding:1px 6px;border-radius:4px;background:rgba(8,12,20,.9);border:1px solid '+it.col+'55">'+parts+'</div>'; });
19473  if(indState.gvPull){ const px=sNow; if(px>0&&items.length){ let up=0,dn=0,tU=null,tD=null; const minD=px*0.002; items.forEach(it=>{ const w=it.pct/(1+Math.abs(it.k-px)/(px*0.02)); if(it.k>px){ up+=w; if(it.k-px>=minD&&(!tU||w>tU.w)) tU={k:it.k,w}; } else if(it.k<px){ dn+=w; if(px-it.k>=minD&&(!tD||w>tD.w)) tD={k:it.k,w}; } }); let winUp=up>=dn, tgt=winUp?tU:tD; const ps=c._gvPullSt||(c._gvPullSt={dir:0,k:0,t:0}); const now5=Date.now(); const nd=winUp?1:-1, lead=winUp?(dn>0?up/dn:9):(up>0?dn/up:9); if(tgt){ if(ps.dir===0){ ps.dir=nd; ps.k=tgt.k; ps.t=now5; } else if(nd===ps.dir){ if(ps.k!==tgt.k&&now5-ps.t>=60000){ ps.k=tgt.k; ps.t=now5; } } else if(lead>=1.5||now5-ps.t>=60000){ ps.dir=nd; ps.k=tgt.k; ps.t=now5; } } if(ps.dir!==0&&ps.k){ winUp=ps.dir>0; tgt={k:ps.k}; } let yp; try{ yp=c.series.priceToCoordinate(px); }catch(e){ yp=null; }
19473 if(yp!=null&&tgt) out+='<div data-pull="1" style="position:absolute;left:-9999px;top:'+(yp+10)+'px;font-size:9px;font-weight:800;color:#22d3ee;background:rgba(8,12,20,.94);border:1px solid rgba(34,211,238,.6);border-radius:4px;padding:1px 6px">PULL '+(winUp?'▲':'▼')+' '+(typeof _dispStrike==='function'?_dispStrike(tgt.k):tgt.k)+'</div>'; } }
19474  h.innerHTML=out; _mGvTagsLayout(id);
19475}
19476function _mGvTagsLayout(id){
19477  const c=_mCharts[id]; if(!c||!c.canvas||!c.series) return; const h=c.canvas.querySelector('.mchart-gvtags'); if(!h||!h.children.length) return;
19478  const w=c.canvas.clientWidth; let ax=null; try{ ax=c.obj.timeScale().timeToCoordinate(c.bars[c.bars.length-1].time); }catch(e){}
19479  const lx=(ax!=null&&isFinite(ax))?Math.max(60,Math.min(ax+46,w-250)):Math.max(60,w-300);
19480  const nodes=[].slice.call(h.children); let pull=null; const arr=[];
19481  nodes.forEach(nd=>{ if(nd.getAttribute('data-pull')){ pull=nd; return; } const k=parseFloat(nd.getAttribute('data-k')); let y=null; try{ y=c.series.priceToCoordinate(k); }catch(e){} if(y==null||y<4||y>c.canvas.clientHeight-10){ nd.style.display='none'; return; } nd.style.display=''; arr.push({nd,y}); });
19482  arr.sort((a,b)=>a.y-b.y); let lastY=-99; arr.forEach(o=>{ const y2=Math.max(o.y-9,lastY+15); lastY=y2; o.nd.style.top=y2+'px'; o.nd.style.left=lx+'px'; o.nd.style.right='auto'; });
19483  if(pull){ const px=(c.bars&&c.bars.length)?+c.bars[c.bars.length-1].close:0; let yp=null; try{ yp=px>0?c.series.priceToCoordinate(px):null; }catch(e){} if(yp==null){ pull.style.display='none'; } else { pull.style.display=''; pull.style.top=(yp+10)+'px'; pull.style.left=lx+'px'; pull.style.right='auto'; } }
19484}
19485function _gexBubPrevBright(){ if(typeof indState==='undefined') return 1; return (indState.gexbubPrevbright==='bright') ? 1.9 : 1; }   // History layer brightness (user 2026-09-15)
19486function _gexBubPrevOn(){ if(typeof indState==='undefined') return false; var v=indState.gexbubPrev||'on'; return v==='on'||v==='ext'; }
19487// Rebuild + install the trail for `key` from cached raw snaps (current day + optional previous
19488// session, dimmed). Returns false when nothing was applied.
19489function _gexBubApplyHistory(key){
19490  // 0DTE UNLOCKED (2026-08-19, user request): the recorder now stamps a real 0DTE per-strike set
19491  // (snap.g0, nearest-expiry basis) on every ticker, and _gexBubRowsFromSnap draws 0DTE from g0
19492  // ONLY (never the ALL fallback that caused the TSLA 350-vs-340 mismatch). WEEK stays live-only —
19493  // nothing records that basis.
19494  if(typeof gridExpiryMode!=='undefined' && gridExpiryMode!=='all' && gridExpiryMode!=='0dte') return false;
19495  var tk=(typeof chartTicker!=='undefined')?chartTicker:null; if(!tk) return false;
19496  var rk=tk+'|'+_gexBubPtDay();
19497  var raw=_gexBubHistRaw[rk];
19498  var rawP=_gexBubPrevOn()?_gexBubHistRaw['prev|'+rk]:null;
19499  if((!raw||!raw.length) && (!rawP||!rawP.length)) return false;
19500  var cs=(typeof chartReplayCandles!=='undefined'&&chartReplayCandles)?chartReplayCandles:[];
19501  if(!cs.length) return false;
19502  var is0=(typeof gridExpiryMode!=='undefined' && gridExpiryMode==='0dte'), mx=_gexBubMaxRows();
19503  var out=[];
19504  if(rawP&&rawP.length) out=out.concat(_gexBubDayCols(rawP, cs, is0, mx, true, !(raw&&raw.length)));   // cap the prev layer only when today has nothing
19505  if(raw&&raw.length) out=out.concat(_gexBubDayCols(raw, cs, is0, mx, false));
19506  if(!out.length) return false;
19507  out.sort(function(a,b){return _gexBubTKey(a.t)-_gexBubTKey(b.t);});
19508  // Keep any LIVE columns that run past the last recorded one (the recorder is ~5 min behind).
19509  var cur=_gexBubTrail[key], tail=_gexBubTKey(out[out.length-1].t);
19510  if(cur && !cur.preview && cur.snaps && cur.snaps.length){
19511    for(var q=0;q<cur.snaps.length;q++){ if(_gexBubTKey(cur.snaps[q].t)>tail) out.push(cur.snaps[q]); }
19512  }
19513  _gexBubTrail[key]={ day:_gexBubPtDay(), snaps:out, hist:true };
19514  if(typeof currentTimeframe!=='undefined') _gexBubTf=currentTimeframe;   // already per-candle at this tf
19515  return true;
19516}
19517function _gexBubSeedHistory(){
19518  if(!_gexBubUseHistory) return false;                                     // see the flag — 5-min columns gap every candle
19519  if(!_gexBubAllowed() || typeof indState==='undefined' || !indState.gexbub) return false;
19520  if(typeof _replayActive!=='undefined' && _replayActive) return false;     // replay builds its own trail
19521  if(typeof currentMetric!=='undefined' && currentMetric!=='gex') return false;         // only GEX is recorded
19522  // ALL + 0DTE seed from recordings (0DTE unlocked 2026-08-19 — snaps carry g0 for every ticker
19523  // and the extractor never falls back to ALL data, so the 8/14 TSLA 350-vs-340 mismatch can't
19524  // recur). WEEK stays live-only — that basis isn't recorded.
19525  if(typeof gridExpiryMode!=='undefined' && gridExpiryMode!=='all' && gridExpiryMode!=='0dte') return false;
19526  var tk=(typeof chartTicker!=='undefined')?chartTicker:null; if(!tk) return false;
19527  var key=_gexBubKey(), ex=_gexBubTrail[key];
19528  if(ex && ex.hist && ex.snaps && ex.snaps.length) return true;             // already seeded
19529  var rk=tk+'|'+_gexBubPtDay(), rkP='prev|'+rk, wantPrev=_gexBubPrevOn();
19530  // Apply whatever is already cached (user 2026-09-05: after ALL->0DTE->ALL the trail fell back to the flat
19531  // PREVIEW - one rank per strike, so every row was a single colour). On a weekend / pre-open only the
19532  // PREVIOUS session is cached (today has no recording yet) and this gate demanded today's raw too, so the
19533  // re-seed after a basis switch could never re-apply the history it already had. _gexBubApplyHistory
19534  // copes with raw missing + rawP present.
19535  var _prevNeed = wantPrev && !(_gexBubHistRaw[rkP] && _gexBubHistRaw[rkP].length);   // prev day wanted but not cached yet
19536  if(_gexBubHistRaw[rk] || (wantPrev && _gexBubHistRaw[rkP])){
19537    if(_gexBubApplyHistory(key)) renderGexBubbles();
19538    if(!_prevNeed) return true;   // have the prev day (or don't want it) - done; else fall through to FETCH it (user 2026-09-15: toggling +Prev when today was already cached never fetched the prev day - History looked dead in My HQ)
19539  }
19540  if(!_prevNeed && Date.now() < (_gexBubHistAt[rk]||0)) return false;       // in flight / backed off after a miss - but bypass when the prev day is still missing
19541  _gexBubHistAt[rk]=Date.now()+20000;
19542  fetch('/api/replay/'+encodeURIComponent(tk)).then(function(r){ return r.json(); }).then(function(j){
19543    var snaps=(j&&j.snaps)||[], srvDate=(j&&j.date)||'';
19544    // OVERNIGHT/premarket: the no-date response is the LAST session (server memory holds it until
19545    // today's first snapshot). With Prev Day on, that data IS the previous session — use it as the
19546    // dimmed layer and let today build live on top; without the option it stays the main layer.
19547    if(wantPrev && srvDate && srvDate !== _gexBubPtDay() && snaps.length){
19548      // AUDIT FIX: don't permanently cache an empty array under TODAY's key — that blocked the
19549      // recorded-history backfill for the whole session when the tab opened pre-open (a later
19550      // Strikes/Session toggle rebuilt only yesterday's dim layer). Back off 10 min instead so
19551      // today's recording is picked up once the recorder starts.
19552      _gexBubHistRaw[rkP]=snaps;
19553      _gexBubHistAt[rk]=Date.now()+10*60*1000;
19554      if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles();
19555      return;
19556    }
19557    if(!snaps.length){
19558      // Market open + empty = a ticker with no recording yet: preview stands, re-check in 5 min.
19559      // Market CLOSED + empty = the session rolled and today has nothing; pull the newest archived
19560      // day so the trail steps instead of falling back to the flat preview (user 2026-09-14).
19561      var _closed = (typeof _gexBubMarketNow==='function') && !_gexBubMarketNow();
19562      if(_closed){
19563        var _sd=_gexBubPtDay();
19564        fetch('/api/replay/'+encodeURIComponent(tk)+'?date='+_sd).then(function(r){ return r.json(); }).then(function(aj){
19565          var as=(aj&&aj.snaps)||[];
19566          if(as.length){ _gexBubHistRaw[rk]=as; if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles(); return; }
19567          // the session day has no file yet: fall back to the newest archived day
19568          fetch('/api/replay-dates').then(function(r){ return r.json(); }).then(function(dj){
19569            var ds=(dj&&dj.dates)||[]; var d0=ds.filter(function(d){ return d<=_sd; })[0]||ds[0]; if(!d0){ _gexBubHistAt[rk]=Date.now()+300000; return; }
19570            fetch('/api/replay/'+encodeURIComponent(tk)+'?date='+d0).then(function(r){ return r.json(); }).then(function(bj){
19571              var bs=(bj&&bj.snaps)||[];
19572              if(bs.length){ _gexBubHistRaw[rk]=bs; if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles(); }
19573              else _gexBubHistAt[rk]=Date.now()+300000;
19574            }).catch(function(){ _gexBubHistAt[rk]=Date.now()+300000; });
19575          }).catch(function(){ _gexBubHistAt[rk]=Date.now()+300000; });
19576        }).catch(function(){ _gexBubHistAt[rk]=Date.now()+300000; });
19577        return;
19578      }
19579      _gexBubHistAt[rk]=Date.now()+300000; return;       // not a recorded ticker → preview stands; re-check in 5 min
19580    }
19581    _gexBubHistRaw[rk]=snaps;
19582    if(!wantPrev){ if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles(); return; }
19583    // Previous session date = the newest archive day OLDER than the current data's date.
19584    fetch('/api/replay-dates').then(function(r){ return r.json(); }).then(function(dj){
19585      var ds=(dj&&dj.dates)||[], prevD=null;
19586      for(var i=0;i<ds.length;i++){ if(ds[i]<srvDate){ prevD=ds[i]; break; } }
19587      if(!prevD){ _gexBubHistRaw[rkP]=[]; if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles(); return; }
19588      fetch('/api/replay/'+encodeURIComponent(tk)+'?date='+prevD).then(function(r){ return r.json(); }).then(function(pj){
19589        var _ps=(pj&&pj.snaps)||[];
19590        // A transient failure (redeploy, blip) must NOT permanently cache an empty prev-day layer —
19591        // that wiped yesterday's bubbles for the whole session (user report 2026-08-19). Only cache
19592        // a real result; on empty, clear the backoff so the next render retries.
19593        if(_ps.length){ _gexBubHistRaw[rkP]=_ps; if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles(); }
19594        else { delete _gexBubHistRaw[rkP]; _gexBubHistAt[rk]=Date.now()+60000; if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles(); }
19595      }).catch(function(){ delete _gexBubHistRaw[rkP]; _gexBubHistAt[rk]=Date.now()+60000; if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles(); });
19596    }).catch(function(){ delete _gexBubHistRaw[rkP]; _gexBubHistAt[rk]=Date.now()+60000; if(_gexBubKey()===key && _gexBubApplyHistory(key)) renderGexBubbles(); });
19597  }).catch(function(){});
19598  return false;
19599}
19600// Build the GEX Visual trail from the RECORDED replay snapshots (each _replaySnaps[i].g = per-strike
19601// [strike, netGex]) up to the current playhead, clipped to 6:30am–1pm. Lets the bubbles/glow lines
19602// scrub in replay exactly like the left panel does. GEX metric only (that's what replay records).
19603function _gexBubBuildReplayTrail(){
19604  if(typeof _replayActive==='undefined' || !_replayActive) return null;
19605  if(!_replaySnaps || !_replaySnaps.length || !_replayCandles) return null;
19606  var cur=_replayCandles[_replayIdx-1]; if(!cur) return null;
19607  var curTs=(cur._ts!=null?cur._ts:(typeof tsForCandleTime==='function'?tsForCandleTime(cur.time):0))*1000;   // ms
19608  var curSec=Math.floor(curTs/1000);
19609  var is0=(typeof gridExpiryMode!=='undefined' && gridExpiryMode==='0dte');
19610  var _mxr=_gexBubMaxRows();
19611  // The chart's time AXIS uses each candle's `.time` (which is tz-SHIFTED from true unix, see the candle
19612  // builder). timeToCoordinate() needs that shifted value — passing raw unix put the bubbles hours off /
19613  // off-screen in replay. Map each snapshot's unix time to the `.time` of the replay candle it falls in
19614  // (binary search over _replayCandles by _ts) so timeToCoordinate lands it exactly on that candle.
19615  var _ctArr=_replayCandles;
19616  var candTimeFor=function(tsec){
19617    var n=_ctArr.length; if(!n) return null; var lo=0,hi=n-1,ans=0;
19618    while(lo<=hi){ var m=(lo+hi)>>1; var c=_ctArr[m]; var cts=(c._ts!=null?c._ts:(typeof tsForCandleTime==='function'?tsForCandleTime(c.time):0)); if(cts<=tsec){ans=m;lo=m+1;}else hi=m-1; }
19619    return _ctArr[ans].time;
19620  };
19621  // Rows per recorded snapshot — same extractor the live history seed uses (0DTE set when on that basis).
19622  var rowsFor=function(s){ return _gexBubRowsFromSnap(s, is0, _mxr); };
19623  // One column per recorded snapshot up to the playhead, deduped to its candle (multiple 5-min snaps
19624  // can fall in one candle at coarse TFs → keep the latest). NO synthetic column at the front or the
19625  // playhead — just the recorded trail. When a snapshot's near-spot data is bad/missing (a UW recording
19626  // glitch — e.g. today's 06:39–07:50 where only far-OTM garbage was stored), HOLD the last good column
19627  // so the bubbles stay continuous instead of dropping out mid-session, until valid data resumes.
19628  var byT={}, lastGood=null, started=false;
19629  for(var i=0;i<_replaySnaps.length;i++){ var s=_replaySnaps[i];
19630    if(!_gexBubTsInRth(s.t/1000)) continue;                // 6:30am–1pm PT only
19631    if(s.t>curTs) continue;                                // only up to the playhead
19632    var rows=rowsFor(s);
19633    if(rows){ lastGood=rows; started=true; }
19634    else if(started && lastGood){ rows=lastGood; }         // glitch → carry the last good structure forward
19635    else continue;                                         // no good data yet (pre-first) → nothing to hold
19636    var mt=candTimeFor(Math.floor(s.t/1000)); if(mt==null) continue;
19637    byT[mt]=rows;                                          // latest snapshot wins for a shared candle
19638  }
19639  var keys=Object.keys(byT).map(Number).sort(function(a,b){return a-b;});
19640  var snaps=keys.map(function(t){ return { t:t, rows:byT[t] }; });   // .time (chart-axis basis) for timeToCoordinate
19641  // PREVIOUS-DAY LAYER IN REPLAY (user request 2026-08-06): with the Modes History option on,
19642  // the day BEFORE the replayed day shows as a full dimmed trail behind it — same look as the
19643  // live chart — while the selected day builds with the playhead. Fetched once per replay day
19644  // from the archive and cached; frames before the fetch lands just show the replayed day.
19645  try{
19646    if(_gexBubPrevOn() && typeof _replayDates!=='undefined' && Array.isArray(_replayDates)){
19647      var rd=(typeof _replayDate!=='undefined' && _replayDate) || (typeof _replayToday!=='undefined' && _replayToday) || '';
19648      var prevD=null; for(var pi=0;pi<_replayDates.length;pi++){ if(_replayDates[pi]<rd){ prevD=_replayDates[pi]; break; } }
19649      var rtk2=(typeof _replayTicker!=='undefined' && _replayTicker) || (typeof chartTicker!=='undefined' && chartTicker) || '';
19650      if(prevD && rtk2){
19651        var ck=rtk2+'|'+prevD+'|'+(is0?'0':'a')+'|'+_mxr+'|'+(typeof currentTimeframe!=='undefined'?currentTimeframe:'');
19652        if(_gexBubRpPrev.key===ck && _gexBubRpPrev.cols){
19653          // AUDIT FIX: an EMPTY cached result counts as cached — requiring cols.length made every
19654          // render frame refetch the archive for tickers with no prev-day recording.
19655          if(_gexBubRpPrev.cols.length) snaps=_gexBubRpPrev.cols.concat(snaps);
19656        } else if(_gexBubRpPrev.fetching!==ck){
19657          _gexBubRpPrev.fetching=ck;
19658          fetch('/api/replay/'+encodeURIComponent(rtk2)+'?date='+prevD).then(function(r){ return r.json(); }).then(function(pj){
19659            var ps=(pj&&pj.snaps)||[];
19660            _gexBubRpPrev={ key:ck, cols:ps.length?_gexBubDayCols(ps, _replayCandles||[], is0, _mxr, true):[], fetching:'' };
19661            if(typeof renderGexBubbles==='function') renderGexBubbles();
19662          }).catch(function(){ _gexBubRpPrev={ key:ck, cols:[], fetching:'' }; });
19663        }
19664      }
19665    }
19666  }catch(e){}
19667  return snaps.length ? { day:'replay', snaps:snaps } : null;
19668}
19669var _gexBubRpPrev={ key:'', cols:null, fetching:'' };   // replay's previous-day trail cache (per ticker+date+basis+tf)
19670function _gexBubCanvas(){
19671  var content=document.getElementById('chartContent'); if(!content) return null;
19672  var c=document.getElementById('gexBubCanvas');
19673  // z-index 3 = ABOVE the chart (2) so the bubbles sit on top of the candles, but still BELOW the
19674  // drawing overlay (4) so trendlines/levels you draw stay on top of everything.
19675  if(!c){ c=document.createElement('canvas'); c.id='gexBubCanvas'; c.style.cssText='position:absolute;
19675left:0;top:0;z-index:3;pointer-events:none'; content.appendChild(c); }
19676  else if(c.style.zIndex!=='3') c.style.zIndex='3';   // re-layer a canvas mounted before this change
19677  return c;
19678}
19679// Throttle redraws to one per frame (range-change fires rapidly on pan/zoom).
19680// ── THE GLOW TRAIL, SHARED (2026-09-14) ─────────────────────────────────────────────────────
19681// Chart 1 and the desk panes paint the same ribbon with this. E carries what the painter used to
19682// read from chart 1's scope: ctx/w/h/panelW, tr {snaps}, xArr/xFirst/xLast/xL/xR, extend, last,
19683// pyc(k)->y, colKM(rank,row), alpha(pct,passed,rank), cdim(a,cord), passed(row), candles (with
19684// .time/.close, the per-column price source), fade {map,key,t} (persistent per surface) and fkey
19685// (the fade store resets when it changes). Returns true while a band is still fading, so the
19686// caller keeps repainting.
19687function _gexGlowPaint(E){
19688  var ctx=E.ctx, w=E.w, h=E.h, panelW=E.panelW||0, tr=E.tr, xArr=E.xArr, xFirst=E.xFirst, xLast=E.xLast, xL=E.xL, xR=E.xR, extend=E.extend||'off', last=E.last;
19689  var _pyc=E.pyc, _colKM=E.colKM, _alpha=E.alpha, _cdim=E.cdim, _passed=E.passed;
19690  var chartCandles=E.candles||[];
19691  var _fadeS=E.fade||(E.fade={map:{},key:'',t:0});
19692  var _gexRectFade=_fadeS.map||{}, _gexRectFadeKey=_fadeS.key||'', _gexRectFadeT=_fadeS.t||0;
19693  var _gexBubKey=function(){ return E.fkey||''; };
19694  var _gexBubFading=false;
19695  var i, j;
19696    // GLOW LINES — a hot core at the strike with a soft symmetric falloff above and below, drawn as a
19697    // vertical gradient (cheap; canvas shadowBlur across a full-width band is not). Same language as
19698    // the bubbles: colour = rank (top-2 purple, positive gold, negative red), BRIGHTNESS = strength,
19699    // dimmed once price has broken through. Reach and core thickness grow with rank + %, so the King
19700    // reads as the widest, hottest band. Replaces the old flat rectangles.
19701    var _fkey=_gexBubKey(); if(_fkey!==_gexRectFadeKey){ _gexRectFade={}; _gexRectFadeKey=_fkey; }
19702    var _now=Date.now(), _dt=_gexRectFadeT?Math.min(400,_now-_gexRectFadeT):16; _gexRectFadeT=_now; var _step=_dt/450;
19703    var _cur={};
19704    var _nG=0, _nR=0;   // how many golds / reds already seen, strongest first
19705    if(last && last.rows) for(j=0;j<last.rows.length;j++){ var rw0=last.rows[j];
19706      _cur[rw0.k]={row:rw0, isTop:(j<2), rank:j, cord:(j<2)?0:(rw0.pos?_nG++:_nR++)}; }
19707    for(var _ck in _cur){ var _f=_gexRectFade[_ck]||(_gexRectFade[_ck]={a:0}); _f.a=Math.min(1,_f.a+_step); _f.row=_cur[_ck].row; _f.isTop=_cur[_ck].isTop; _f.rank=_cur[_ck].rank; _f.cord=_cur[_ck].cord; }
19708    for(var _ok in _gexRectFade){ if(!_cur[_ok]){ _gexRectFade[_ok].a-=_step; if(_gexRectFade[_ok].a<=0.02) delete _gexRectFade[_ok]; } }
19709    var _fading=false;   // _bw is gone: the trail sizes each segment to the next column, and the
19710                         // extend pass sizes its own to the gap it is filling.
19711    // Bell falloff. Offsets are fractions of the band height, alphas are fractions of the band's own
19712    // brightness. The half-brightness point sits at ±0.25 of the height, which is what sets the
19713    // APPARENT thickness: measured off the reference heat-zone chart, a top band is ~16px wide at
19714    // half brightness and still visible out to ~28px. The first cut put half-brightness at ±0.09,
19715    // giving a 4px line with a faint halo — technically a glow, but nothing like the reference.
19716    var _stops=[[0,0],[0.08,0.06],[0.15,0.15],[0.22,0.35],[0.25,0.50],[0.32,0.72],[0.40,0.90],[0.50,1],[0.60,0.90],[0.68,0.72],[0.75,0.50],[0.78,0.35],[0.85,0.15],[0.92,0.06],[1,0]];
19717    // Gradient cache. A band's gradient depends only on colour, its y span and its alpha, and the
19718    // same level repeats across hundreds of trail columns at an identical y - so without this we
19719    // would build thousands of identical gradients every frame. Alpha is quantised to 1/20th to
19720    // keep the key count small; the banding that introduces is far below what the eye resolves.
19721    var _gcache={};
19722    var _grad=function(col,y0,reach,a){
19723      var aq=Math.round(a*20)/20;
19724      var key=col+'|'+Math.round(y0)+'|'+Math.round(reach)+'|'+aq;
19725      var g=_gcache[key];
19726      if(!g){ g=ctx.createLinearGradient(0,y0,0,y0+reach);
19727        for(var _si=0;_si<_stops.length;
19727_si++) g.addColorStop(_stops[_si][0], _gexRgba(col, aq*_stops[_si][1]));
19728        _gcache[key]=g; }
19729      return g;
19730    };
19731    // BAND THICKNESS CARRIES THE GAMMA (user 2026-09-07, from a reference clip: "become bigger n
19732    // thinner again"). The old curve was 0.85 + pct*0.15 — a 12% swing, which is why a band read as a
19733    // flat rail. Two things had to change for it to breathe:
19734    //   1. amplitude: the height now spans roughly a third to full width across the strength range.
19735    //   2. what it is measured against. pct is each column's OWN max, so the top level is 1.0 in every
19736    //      column by definition and the King could never move. Normalising against the largest
19737    //      absolute gamma anywhere in the drawn window lets the King swell and thin like the rest.
19738    // Rows recorded before the absolute value was stored fall back to pct, so old trails still draw.
19739    // AMPLITUDE, SECOND PASS (2026-09-08). The first cut scaled height by a/aMax across every level
19740    // at once. That is faithful but almost invisible: the King sits at or near the window maximum by
19741    // definition, so its own ratio barely leaves 1.0 — measured live it moved 13% in the first
19742    // quarter hour and the band still read as a rail. Yesterday's demo only looked dramatic because
19743    // the injected series swung ten to one, which real gamma does not do.
19744    // Two scales instead of one, which is what the reference is really showing:
19745    //   SIZE     — how big this level is against everything else, so the King is the fattest band.
19746    //   BREATHE  — how this level's own gamma is doing against its OWN peak in the window, so every
19747    //              band swells and thins on its own history rather than against a shared maximum.
19748    // PERCENTILES, NOT MIN-MAX (user 2026-09-10: "just double lines straight not making like wide n
19749    // small"). Min-to-max loses both ways, and SPXW showed both failures in one session:
19750    //   · 7600 spiked 2.6x in the last three columns, so the day's range was owned by that spike and
19751    //     the other 77 columns squashed into the bottom 4% of it - a flat 4.8-5.1px all day.
19752    //   · 7625 moved 6.7% peak-to-trough, under the old 35%-of-peak span floor, so it only ever
19753    //     traversed the TOP fifth of the curve - a flat 12.3-12.9px all day.
19754    // Either way: a constant height, which with a 1.6-2.4px rail top and bottom is literally two
19755    // parallel lines. A level's own p10..p90 is immune to a single spike and uses the range the level
19756    // actually lives in.
19757    // PER DAY, NOT POOLED (user 2026-09-10: "it still showing same"). The first cut of this built one
19758    // distribution per level across the WHOLE trail - and the trail carries yesterday's dim layer as
19759    // well as today's. The two sessions sit in different regimes, so p10..p90 spanned the gap BETWEEN
19760    // them and every column inside a day landed at one end of it. Measured on SPXW right after it
19761    // shipped: 7600 drew 9.0-9.5px across all 77 of yesterday's columns and 19.8-21.9px across all 12
19762    // of today's. Still two straight lines - a different pair of straight lines. A session breathes
19763    // against its own session, which is also how the trail already keys its bands ('k|p' / 'k|l').
19764    var _aMax={}, _aVals={};
19765    for(var _am=0; _am<tr.snaps.length; _am++){ var _amsn=tr.snaps[_am], _amr=_amsn && _amsn.rows; if(!_amr) continue;
19766      var _amd=_amsn.prev?'p':'l';
19767      for(var _amj=0; _amj<_amr.length; _amj++){ var _amrow=_amr[_amj], _amv=Math.abs(+_amrow.a||0);
19768        if(!_amv) continue;
19769        if(_amv>(_aMax[_amd]||0)) _aMax[_amd]=_amv;
19770        var _amk=_amrow.k+'|'+_amd;
19771        (_aVals[_amk] || (_aVals[_amk]=[])).push(_amv); } }
19772    var _aBand={};
19773    for(var _bk in _aVals){
19774      var _ba=_aVals[_bk].sort(function(x,y){ return x-y; });
19775      var _q=function(p){ return _ba[Math.max(0, Math.min(_ba.length-1, Math.round(p*(_ba.length-1))))]; };
19776      var _p10=_q(0.10), _p50=_q(0.50), _p90=_q(0.90);
19777      // AMPLITUDE. Percentiles always spread a level across the full height, which would turn a
19778      // level that is not moving into a band swinging on measurement noise - so the swing is scaled
19779      // by how much this level ACTUALLY breathes. A p10-to-p90 spread of 4% of its median earns the
19780      // full range; a frozen level earns none and sits mid-height, which reads as "steady", not
19781      // "weak". Live SPXW moves on ~11 of 46 significant strikes every 20s, so a real level clears
19782      // 4% over a session comfortably.
19783      _aBand[_bk]={ lo:_p10, span:Math.max(_p90-_p10, _p50*0.001), pk:_ba[_ba.length-1],
19784                    amp: Math.max(0, Math.min(1, _p50>0 ? ((_p90-_p10)/_p50)/0.015 : 0)) };   // _bk is 'strike|p' or 'strike|l' — 1.5% p10-p90 spread earns full breathing (was 4%, which flattened ALL/WEEK: those levels are huge numbers that swing only ~1-2%, while 0DTE swings hard — user 2026-09-16)
19785    }
19786    // EACH BAND BREATHES ON ITS OWN HISTORY (user 2026-09-08: "these are staying same size ... those
19787    // become smaller, fade, again come n start increasing width"). Measuring against the WINDOW
19788    // maximum was the mistake: a mid-sized level living at 700-800M inside a 1644M window moved
19789    // between 25 and 27 pixels all session, so every band held its own constant size. What varies
19790    // for a level is its OWN gamma against its OWN recent range, which is what the reference shows.
19791    //
19792    //   own   where this level sits inside its own p10..p90 range - drives the width and the fade,
19793    //         so a level at its low is thin and dim and fattens and brightens as gamma rebuilds,
19794    //         damped by its own amplitude so a level that is not moving does not fake a swell.
19795    //   scale how big this level is against everything else, so the King is still the widest band.
19796    // A missing 'a' now centres rather than maxing out: an old trail with no absolute recorded has
19797    // no opinion about this level's breathing, and drawing it at full height stated one loudly.
19798    var _ownOf=function(rw, pv){
19799      if(!rw || rw.a==null) return 0.5;
19800      var b=_aBand[rw.k+'|'+(pv?'p':'l')]; if(!b || !(b.span>0)) return 0.5;
19801      var o=Math.max(0, Math.min(1, (Math.abs(rw.a)-b.lo)/b.span));
19802      return 0.5 + (o-0.5)*b.amp;
19803    };
19804    // pv marks the PREVIOUS session's dim layer. The extend-to-edge pass never passes it: it draws
19805    // from _gexRectFade, which is built off the newest column and is always today.
19806    var _reachOf=function(rk,rw,pv){
19807      var _dm=pv?'p':'l', _mx=_aMax[_dm]||0;
19808      if(!(_mx>0)) return ((rk===0)?33:(rk===1)?31:(rk<4)?30:28) * (0.85 + ((rw&&rw.pct)||0)*0.15);   // nothing in this day records an absolute: old curve
19809      var _b=_aBand[(rw && rw.k)+'|'+_dm], pk=(_b && _b.pk) || Math.abs((rw&&rw.a)||0) || _mx;
19810      var scale=0.70 + 0.30*Math.max(0, Math.min(1, pk/_mx));   // 0.70 .. 1.0
19811      // TALLER THAN ITS OWN RAILS (user 2026-09-10). 10..44px was too big and drew castellations;
19812      // 5..14px fixed that and introduced this one - the band is stroked with a 1.6px rail (2.4px for
19813      // the King) along the TOP and the BOTTOM, so a 5px band is two rails with about a pixel of body
19814      // between them. That is the "double lines" exactly. 9px minimum leaves ~6px of visible body at
19815      // the thinnest, and 22px at a level's own peak is still half the old maximum, so the swell
19816      // stays a swell. Smoothing across columns below does the rest.
19817      return (9 + 13*_ownOf(rw, pv)) * scale;                     // 9px at its own low, 22px at its own peak
19818    };
19819    var _coreOf=function(rk){ return (rk===0)?2.6:(rk===1)?2.2:(rk<4)?1.8:1.3; };
19820    // Draw one band segment. Shared by the trail and by the extend-to-edge pass below.
19821    // Extend-to-edge segment. Same hollow body + bright edge as the ribbon above, so the carried-out
19822    // ends match the trail instead of reverting to the old gradient bell (user 2026-09-08).
19823    var _band=function(x0,bw,rk,rw,a){
19824      var y=_pyc(rw.k); if(y==null) return;
19825      var reach=_reachOf(rk,rw); if(y<-reach || y>h+reach) return;
19826      var y0=y-reach/2, col=_colKM(rk,rw);
19827      ctx.shadowBlur=0; ctx.globalAlpha=1;
19828      ctx.fillStyle=_gexRgba(col, a*((rk===0)?0.50:(rk===1)?0.38:0.42)); ctx.fillRect(x0, y0, bw, reach);
19829      ctx.strokeStyle=_gexRgba(col, Math.max(0.42, Math.min(0.98, a*((rk===0)?1.06:1.0)))); ctx.lineWidth=(rk===0)?2.4:(rk===1)?1.9:1.6;
19830      ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x0+bw,y0); ctx.moveTo(x0,y0+reach); ctx.lineTo(x0+bw,y0+reach); ctx.stroke();
19831      return { y:y, a:a };
19832    };
19833    // TRAIL — one segment per snapshot column at THAT snapshot's own strike, which is what makes
19834    // the band step as a level moves. Previously the glow drew a single flat band at the newest
19835    // strike across the whole width, so it showed no history at all and no previous session.
19836    // Same column geometry and the same 0.55 previous-day dimming the bubbles use, so switching
19837    // style changes the look and nothing else.
19838    // RIBBON, NOT RECTANGLES (user 2026-09-08: "that was holo inside and corners are glowing").
19839    // Measured off the reference clip, one pixel column straight through a band: the background sits
19840    // at luminance 22, the interior is a FLAT 52-70, and the top and bottom edges spike to 119 over
19841    // about two pixels. A band is a HOLLOW translucent body with a bright stroke around it - not our
19842    // gradient bell with a hot core at the strike, which is why fixing the thickness alone still did
19843    // not look like the reference. Each level is now ONE path across the whole trail: collect its
19844    // per-column centre and half-height, trace the top edge left to right and the bottom edge back,
19845    // fill the body once, then stroke the outline.
19846    // ── BRACKET BANDS (user 2026-09-09) ─────────────────────────────────────────────────────────
19847    // "we only gonna show levels which are increasing gamma close to price ... it break 655 we start
19848    // seeing next strike ... it should also stop the 2nd last strike from moving further ... only if
19849    // its not purple". Three rules, glow bands only - the bubbles are untouched:
19850    //
19851    //   START  a band begins at the column where price first BROKE that strike, never before. A
19852    //          level price has not reached today draws nothing at all.
19853    //   GROW   it keeps extending only while it is one of the three levels around price: the two
19854    //          nearest below and the one above. Fall outside that and it freezes - what it already
19855    //          drew stays as a record of where price has been, it simply stops moving.
19856    //   PURPLE ranks 0 and 1 are exempt from both the freeze and the window; once broken they run to
19857    //          the live edge wherever price goes.
19858    //
19859    // Price per column comes from the chart's own candles rather than the trail, which stores only
19860    // {t, rows} - no spot. That also means the rule applies to the WHOLE trail immediately instead
19861    // of only to columns recorded after this shipped.
19862    // Session-wide union: only the break detector uses this now - it has to notice price crossing a
19863    // level even in a column where that level is not carried. The bracket uses the column's own.
19864    var _ladder = (function(){
19865      var set = {};
19866      for (var _li = 0; _li < tr.snaps.length; _li++) {
19867        var _lr = tr.snaps[_li].rows; if (!_lr) continue;
19868        for (var _lj = 0; _lj < _lr.length; _lj++) set[+_lr[_lj].k] = 1;
19869      }
19870      return Object.keys(set).map(Number).sort(function(a, b){ return a - b; });
19871    })();
19872    // Close of the candle covering this snapshot. Candles are ascending, so walk a cursor rather
19873    // than searching per column - this runs on every frame.
19874    var _pxCur = 0;
19875    var _pxAt = (function(){
19876      var cs = (typeof chartCandles !== 'undefined' && chartCandles) ? chartCandles : [];
19877      var idx = 0;
19878      // WHICH CLOCK (user 2026-09-09: "i dont see at 7650"). Measured on a live SPXW trail:
19879      //
19880      //   snap.t       1788958500   seconds
19881      //   candle.time  1788958800   seconds, 300s later - one 5m bar
19882      //   candle._ts   1788984000   seconds, but a DIFFERENT origin: +25200s, exactly 7 hours
19883      //
19884      // Snapshots share a clock with candle.time. The lookup preferred _ts, so every column was
19885      // priced from a candle seven hours earlier - 7673.52 while price was 7636.36 - and at that
19886      // phantom price the bracket correctly picked 7660/7665/7675, which is what was on screen. The
19887      // selection rule was never wrong; it was being handed the wrong price, which is also why the
19888      // bubbles looked fine while only the bands misbehaved.
19889      //
19890      // Use .time, and normalise the scale rather than assume it - seconds are ~1.7e9, milliseconds
19891      // ~1.7e12, so 1e11 separates them either way round.
19892      var _sec = function(v){ return (v > 1e11) ? Math.floor(v / 1000) : v; };
19893      var _ct = function(c){ return _sec(c.time != null ? c.time : c._ts); };
19894      return function(t){
19895        if (!cs.length) return null;
19896        var ts = _sec(t);
19897        if (ts < _ct(cs[0])) return null;
19898        while (idx + 1 < cs.length && _ct(cs[idx + 1]) <= ts) idx++;
19899        while (idx > 0 && _ct(cs[idx]) > ts) idx--;
19900        var c = cs[idx];
19901        return (c && c.close != null) ? +c.close : null;
19902      };
19903    })();
19904    // Two below + one above. Ties on an exact strike count as "below", so standing on a level keeps
19905    // it lit along with the one under it and the one over it.
19906    // THE LADDER IS PER COLUMN (user 2026-09-09: "spx has some bubbles at 7650 where it get rejected
19907    // instead of showing those it showing 7665"). It used to be the union of every strike seen all
19908    // session, but a column only carries the strikes that are live in it. At 7636 the union still
19909    // held 7640 from earlier in the day, so the bracket picked 7640 - a strike no longer in the
19910    // column - and NOTHING drew above price, while 7650, which is live and is where price actually
19911    // got rejected, was excluded for sitting one rung further out.
19912    //
19913    // Using the column's own strikes, the bracket can only ever name levels that exist to be drawn.
19914    var _liveSet = function(px, lad){
19915      var keep = {}, iA = -1;
19916      if (!lad || !lad.length) return keep;
19917      for (var q = 0; q < lad.length; q++) { if (lad[q] > px) { iA = q; break; } }
19918      if (iA >= 0) keep[lad[iA]] = 1;
19919      var st = (iA < 0 ? lad.length : iA) - 1;
19920      for (var n2 = 0; n2 < 2; n2++) { var jj = st - n2; if (jj >= 0) keep[lad[jj]] = 1; }
19921      return keep;
19922    };
19923    // Broken once price has been on both sides. The obvious (a-k)*(b-k) < 0 gives ZERO when a bar
19924    // lands exactly ON the strike, which swallows real breaks - strikes are round numbers and price
19925    // sits on them all day. Sign change with equality on the far side instead.
19926    // NOW FOR EVERYONE (user 2026-09-12: "the recent glow bands settings that we made admin make it
19927    // for users as well"). It shipped admin-only on 2026-09-09 while the break rule was being
19928    // judged - "keep those glowband change only on admin side. for users keep that same which was
19929    // earlier" - and it has run since. Members get the bracket, the break gate and the top-two
19930    // purples on the same terms.
19931    //
19932    // The flag stays rather than being inlined: it is read in several places below, and one named
19933    // switch is what makes the next gate change one line instead of six.
19934    var _gbBracket = true;
19935    // THE PURPLES ARE THE CURRENT TOP TWO (user 2026-09-09: "both purple will always stay on").
19936    // Rank is the ROW INDEX inside each column, so a level that slips out of the top two mid-session
19937    // stops being purple and freezes - which is why one purple band ended at 13:30 while the other
19938    // ran to the edge. Identity is taken from the NEWEST column and applied across the whole trail,
19939    // so the two levels that are purple NOW are purple for their entire length.
19940    var _gbPurple = {};
19941    if (_gbBracket) {
19942      for (var _pi = tr.snaps.length - 1; _pi >= 0; _pi--) {
19943        var _pr = tr.snaps[_pi]; if (!_pr || _pr.prev || !_pr.rows || !_pr.rows.length) continue;
19944        if (_pr.rows[0]) _gbPurple[+_pr.rows[0].k] = 1;
19945        if (_pr.rows[1]) _gbPurple[+_pr.rows[1].k] = 1;
19946        break;
19947      }
19948    }
19949    var _broke = {}, _prevPx = null;
19950    var _series={};   // strike -> { pts:[{x,xe,y,half,i}], col, a, rank }
19951    for(i=0;i<tr.snaps.length;i++){
19952      var xg=xArr[i]; if(xg==null||xg<panelW-12||xg>w+12) continue;
19953      var _xn=null; for(var _q=i+1;_q<xArr.length;_q++){ if(xArr[_q]!=null && xArr[_q]>xg){ _xn=xArr[_q]; break; } }
19954      var segW=Math.max(2, ((_xn!=null)?_xn:xg+6) - xg);
19955      var sng=tr.snaps[i], pfg=sng.prev?Math.min(1,0.55*_gexBubPrevBright()):1, _cgg=0, _crg=0;
19956      var _cpx = _gbBracket ? _pxAt(sng.t) : null;
19957      if (_cpx != null) {
19958        if (_prevPx != null) {
19959          for (var _bi = 0; _bi < _ladder.length; _bi++) {
19960            var _bk = _ladder[_bi];
19961            if (_broke[_bk]) continue;
19962            if ((_prevPx < _bk && _cpx >= _bk) || (_prevPx > _bk && _cpx <= _bk)) _broke[_bk] = 1;
19963          }
19964        }
19965        _prevPx = _cpx;
19966      }
19967      var _liveNow = null;
19968      if (_cpx != null && sng.rows && sng.rows.length) {
19969        var _colLad = sng.rows.map(function(r){ return +r.k; }).filter(function(v){ return isFinite(v); }).sort(function(a,b){ return a-b; });
19970        _liveNow = _liveSet(_cpx, _colLad);
19971      }
19972      if(sng.rows) for(j=0;j<sng.rows.length;j++){
19973        var rwg=sng.rows[j];
19974        var cordg=(j<2)?0:(rwg.pos?_cgg++:_crg++);
19975        var pg=_passed(rwg), _cjg=j;   // per-column rank: each column wears the rank it had AT THAT TIME
19976        // BRIGHTNESS IS THE BUBBLES' (user 2026-09-08: "make sure glow bands color brightness dim
19977        // is also same like bubbles"). Byte-for-byte the same expression the bubble trail uses -
19978        // same _alpha curve off pct, same rank step-down, same passed-level halving, same
19979        // previous-day factor - so switching style changes the SHAPE and nothing about the colour.
19980        // The extra own-gamma fade that used to sit here made every band dimmer than its bubble.
19981        var ag=_cdim(_alpha(rwg.pct, pg, _cjg), cordg) * pfg;
19982        var yg=_pyc(rwg.k); if(yg==null) continue;
19983        var reachG=_reachOf(_cjg, rwg, !!sng.prev); if(yg<-reachG || yg>h+reachG) continue;
19984        // The gate. No price for this column (candles not loaded, or a snapshot older than the
19985        // first candle) falls through to the old always-draw behaviour rather than blanking the
19986        // chart - a missing candle is not evidence that price never went there.
19987        if (_liveNow) {
19988          // A band draws while it is one of the three levels around price, or is one of the two
19989          // purples. Nothing else is required.
19990          //
19991          // The BREAK gate that used to sit here was wrong. The spec - "price is at 646 so we gonna
19992          // show 645 650" - has 650 ABOVE price and never broken, yet it must show: it is the level
19993          // price is heading INTO. Requiring a break first hid exactly the levels worth watching,
19994          // which is why the glow lines came up nearly empty. Breaking a level is what MOVES the
19995          // bracket to the next one; it was never meant to be a condition for drawing.
19996          //
19997          // The band still does not run from the session start: it begins at the column where the
19998          // level entered this set, which is what "only start showing when price breaks one strike"
19999          // asks for, and it freezes when price leaves it behind.
20000          var _isPurple = !!_gbPurple[+rwg.k];
20001          if (!_isPurple && !_liveNow[+rwg.k]) continue;
20002        }
20003        var sk=rwg.k+'|'+(sng.prev?'p':'l');
20004        var se=_series[sk] || (_series[sk]={pts:[], col:_colKM(_cjg,rwg), a:ag, rank:_cjg});
20005        se.col=_colKM(_cjg,rwg); se.a=ag; se.rank=_cjg;   // newest column wins the colour and brightness
20006        // The column index is what lets a frozen level split into separate bands. Members never
20007        // freeze, so they never get an index - and _runsOf below is skipped, leaving them the one
20008        // continuous path they had before.
20009        se.pts.push(_gbBracket ? {x:xg, xe:xg+segW, y:yg, half:reachG/2, i:i} : {x:xg, xe:xg+segW, y:yg, half:reachG/2});
20010      }
20011    }
20012    // A frozen level leaves GAPS in its columns, and joining across one would draw a band through
20013    // territory price never revisited. Split into contiguous runs and draw each as its own band, so
20014    // a level that goes quiet and later comes back reads as two separate stretches.
20015    var _runsOf = function(pts){
20016      var out = [], cur = [pts[0]];
20017      for (var q = 1; q < pts.length; q++) {
20018        if (pts[q].i === pts[q-1].i + 1) cur.push(pts[q]);
20019        else { out.push(cur); cur = [pts[q]]; }
20020      }
20021      out.push(cur); return out;
20022    };
20023    // SMOOTH ACROSS COLUMNS. Each column carries its own half-height, so any step between
20024    // neighbours draws as a square notch - which is what made the bands look crenellated rather
20025    // than alive. A small moving average over the half-heights turns those steps into a swell.
20026    // Deliberately on the HEIGHT only: colours, alpha and the y-centre are untouched, so a band
20027    // still says exactly what it said, just without the staircase.
20028    var _smoothHalf = function(pts){
20029      if (pts.length < 3) return;
20030      var raw = pts.map(function(p){ return p.half; });
20031      var W = 2;                                   // +/- 2 columns, a 5-wide window: on a real swell it
20032                                                   // keeps 6.4px of the 7.2px variation while cutting the
20033                                                   // column-to-column jump in half. Wider flattens the
20034                                                   // breathing the bands are supposed to show.
20035      for (var q = 0; q < pts.length; q++) {
20036        var a = 0, n2 = 0;
20037        for (var d = -W; d <= W; d++) { var k2 = q + d; if (k2 < 0 || k2 >= raw.length) continue; a += raw[k2]; n2++; }
20038        pts[q].half = a / n2;
20039      }
20040    };
20041    var _seriesRuns = [];
20042    for (var _skA in _series) {
20043      var _seA = _series[_skA]; if (!_seA.pts.length) continue;
20044      _smoothHalf(_seA.pts);
20045      var _rr = (_seA.pts[0].i == null) ? [_seA.pts] : _runsOf(_seA.pts);
20046      for (var _ri = 0; _ri < _rr.length; _ri++) _seriesRuns.push({ pts: _rr[_ri], col: _seA.col, a: _seA.a, rank: _seA.rank });
20047    }
20048    for(var _sk2 = 0; _sk2 < _seriesRuns.length; _sk2++){
20049      var se2=_seriesRuns[_sk2], pts=se2.pts; if(!pts.length) continue;
20050      ctx.shadowBlur=0; ctx.globalAlpha=1;
20051      ctx.beginPath();
20052      ctx.moveTo(pts[0].x, pts[0].y-pts[0].half);
20053      for(var _p=0;_p<pts.length;_p++){ ctx.lineTo(pts[_p].x, pts[_p].y-pts[_p].half); ctx.lineTo(pts[_p].xe, pts[_p].y-pts[_p].half); }
20054      for(var _p2=pts.length-1;_p2>=0;_p2--){ ctx.lineTo(pts[_p2].xe, pts[_p2].y+pts[_p2].half); ctx.lineTo(pts[_p2].x, pts[_p2].y+pts[_p2].half); }
20055      ctx.closePath();
20056      // HOLLOW BODY - flat translucent fill, no gradient, no core. Lifted from a flat 0.26 and made
20057      // rank-aware (user 2026-09-08: "increase each gamma band brightness ... hard to see some dimmed
20058      // red n gold ... for two purple make sure there is diff in each of those"). The King carries
20059      // the densest body, the second purple sits a step under it, and everything else lands between,
20060      // so the two violets separate by WEIGHT as well as by hue (#a855f7 against #7c3aed).
20061      ctx.fillStyle=_gexRgba(se2.col, se2.a*((se2.rank===0)?0.50:(se2.rank===1)?0.38:0.42)); ctx.fill();
20062      // GLOWING EDGE - the TOP and BOTTOM rails only, stroked as two OPEN polylines (user
20063      // 2026-09-08: "those needs to be open from both ends. not closed"). Stroking the closed path
20064      // drew vertical caps at the start and end, which turned every band into a boxed rectangle;
20065      // the reference has no caps - the rails just run off the ends of the trail.
20066      // A floor on the rail so a weak red or gold still has a readable outline - those were the ones
20067      // disappearing. The King keeps the thickest rail.
20068      ctx.strokeStyle=_gexRgba(se2.col, Math.max(0.42, Math.min(0.98, se2.a*((se2.rank===0)?1.06:1.0))));
20069      ctx.lineWidth=(se2.rank===0)?2.4:(se2.rank===1)?1.9:1.6;
20069 ctx.lineJoin='round'; ctx.lineCap='butt';
20070      ctx.beginPath();
20071      ctx.moveTo(pts[0].x, pts[0].y-pts[0].half);
20072      for(var _t=0;_t<pts.length;_t++){ ctx.lineTo(pts[_t].x, pts[_t].y-pts[_t].half); ctx.lineTo(pts[_t].xe, pts[_t].y-pts[_t].half); }
20073      ctx.stroke();
20074      ctx.beginPath();
20075      ctx.moveTo(pts[0].x, pts[0].y+pts[0].half);
20076      for(var _b2=0;_b2<pts.length;_b2++){ ctx.lineTo(pts[_b2].x, pts[_b2].y+pts[_b2].half); ctx.lineTo(pts[_b2].xe, pts[_b2].y+pts[_b2].half); }
20077      ctx.stroke();
20078    }
20079    // EXTEND — the current levels carried out to the chart edges. Only the regions the trail does
20080    // not already cover, so the two never paint over each other. This is also where the fade
20081    // machinery still earns its keep: a level appearing or dropping out eases in and out here.
20082    var _exL=(extend==='left'||extend==='full')?(panelW+2):null;
20083    var _exR=(extend==='right'||extend==='full')?(w-2):null;
20084    for(var _dk in _gexRectFade){ var _fe=_gexRectFade[_dk], rw=_fe.row; if(!rw) continue; if(_fe.a<0.999||!_cur[_dk]) _fading=true;
20085      if(_exL==null && _exR==null) continue;
20086      var passed=_passed(rw);
20087      var a=_cdim(_alpha(rw.pct, passed, _fe.rank), _fe.cord||0) * _fe.a;
20088      if(_exL!=null && xFirst!=null && xFirst>_exL) _band(_exL, xFirst-_exL, _fe.rank, rw, a);
20089      if(_exR!=null && xLast!=null && _exR>xLast) _band(xLast, _exR-xLast, _fe.rank, rw, a);
20090    }
20091    _gexBubFading = _fading;   // pumped at the end so the target ring still draws
20092  _fadeS.map=_gexRectFade; _fadeS.key=_gexRectFadeKey; _fadeS.t=_gexRectFadeT;
20093  return !!_gexBubFading;
20094}
20095var _gexGlowFade1={map:{},key:'',t:0};   // chart 1's glow fade state (was three module globals)
20096function renderGexBubbles(){ if(_gexBubRaf) return; _gexBubRaf=requestAnimationFrame(function(){ _gexBubRaf=null; _renderGexBubblesNow(); }); }
20097function _renderGexBubblesNow(){
20098  var c=_gexBubCanvas(); if(!c) return;
20099  var content=document.getElementById('chartContent'); if(!content) return;
20100  var w=content.clientWidth, h=content.clientHeight, dpr=window.devicePixelRatio||1;
20101  if(c.width!==Math.round(w*dpr)||c.height!==Math.round(h*dpr)){ c.width=Math.round(w*dpr); c.height=Math.round(h*dpr); c.style.width=w+'px'; c.style.height=h+'px'; }
20102  var ctx=c.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
20103  if(!_gexBubOn){ ctx.clearRect(0,0,w,h); return; }     // GEX Visual disabled for now
20104  // Only clear when the Visual is intentionally OFF. On TRANSIENT states (chart rebuilding, or the
20105  // trail momentarily empty while a metric/expiry switch re-seeds) keep the last frame so the bubbles
20106  // don't flash off — the self-heal seeder repaints within ~1s. (Clearing every call caused the
20107  // "coming and going" flicker, incl. when opening Modes briefly repainted the chart.)
20108  if(!_gexBubAllowed() || !indState.gexbub){ ctx.clearRect(0,0,w,h); return; }
20109  if(!chartObj || !chartSeries) return;                 // chart momentarily gone → keep last frame
20110  var _rp=(typeof _replayActive!=='undefined' && _replayActive);
20111  if(!_rp){ try{ _gexBubMaybeRebucket(); }catch(e){} }   // timeframe changed → re-space the trail to the new candles
20112  var tr = _rp ? _gexBubBuildReplayTrail() : _gexBubTrail[_gexBubKey()];
20113  if(!tr || !tr.snaps.length){ if(_rp || _gexBubPaintKey!==_gexBubKey()) ctx.clearRect(0,0,w,h); return; }   // replay before the first snap → clear stale live bubbles; live SAME key → keep last frame while self-heal seeds; a frame from ANOTHER metric/expiry/ticker is cleared (VEX/CHARM switch off-hours left the GEX bubbles frozen on the chart, not even panning - user 2026-09-05)
20114  // RELOAD FLASH GUARD: on a fresh reload the first live sample lands a lone column that the glow-band
20115  // style stretches into a full-width band + single bubble — before the recorded history fetch returns.
20116  // While that fetch is genuinely in flight (recorded basis, raw not yet cached, within ~2.6s of start),
20117  // hold the last frame instead of painting the misleading single-bubble state. Bounded so a non-recorded
20118  // ticker still draws after the window. (user report 2026-09-03)
20119  if(!_rp && !tr.hist && !tr.preview && tr.snaps.length<=2 && _gexBubUseHistory
20120     && (typeof currentMetric==='undefined' || currentMetric==='gex')
20121     && (typeof gridExpiryMode==='undefined' || gridExpiryMode==='all' || gridExpiryMode==='0dte')
20122     && (Date.now()-_gexBubReloadAt) < 2600){
20123    var _rkG=((typeof chartTicker!=='undefined'?chartTicker:'')||'')+'|'+_gexBubPtDay();
20124    if(!(_gexBubHistRaw[_rkG] && _gexBubHistRaw[_rkG].length)) return;   // history not cached yet → keep last frame, don't flash the lone band
20125  }
20126  ctx.clearRect(0,0,w,h);                                // have data → clear + redraw
20127  _gexBubPaintKey=_gexBubKey();
20128  var panelW=(typeof gexPanelWidth==='function')?gexPanelWidth():0;
20129  var ts; try{ ts=chartObj.timeScale(); }catch(e){ return; }
20130  // Clip to the chart PANE only: left of the price axis, above the time axis (user report —
20131  // extended bubble trails were painting over both axes when the chart was panned).
20132  var _axW=0,_axH=0;
20133  try{ _axW=chartObj.priceScale('right').width()||0; }catch(e){}
20134  try{ _axH=ts.height()||0; }catch(e){}
20135  ctx.save(); ctx.beginPath(); ctx.rect(panelW,0,Math.max(0,w-panelW-_axW),Math.max(0,h-_axH)); ctx.clip();
20136  _gexBubFading = false;
20137  // PERF: a strike's y is the SAME across every snapshot column (horizontal trail), so priceToCoordinate
20138  // per-row-per-snap meant thousands of identical calls each frame — and this render fires on every
20139  // crosshair move / pan / zoom. Cache y by strike for this frame (chart scale is constant within it).
20140  var _yc={}, _pyc=function(kk){ if(kk in _yc) return _yc[kk]; var yy; try{ yy=chartSeries.priceToCoordinate(kk); }catch(e){ yy=null; } _yc[kk]=yy; return yy; };
20141  // Current price + recent direction drive "broken vs ahead" dimming (replay uses the played candles).
20142  var _cs = _rp ? ((typeof _replayCandles!=='undefined'?_replayCandles:[])||[]).slice(0,_replayIdx) : ((typeof chartReplayCandles!=='undefined' && chartReplayCandles)?chartReplayCandles:[]);
20143  var price=(typeof chartCurrentPrice!=='undefined' && chartCurrentPrice)?chartCurrentPrice:0, dir=0;
20144  if(_rp && _cs.length) price=_cs[_cs.length-1].close;
20145  try{ if(_cs.length>=15){ var _pa=_cs[_cs.length-15].close, _pb=_cs[_cs.length-1].close; if(_pb>_pa*1.001) dir=1; else if(_pb<_pa*0.999) dir=-1; } }catch(e){}
20146  var minR=2.6, maxR=15, TAU=6.2831853, i, j;
20147  var FR=5;   // normal bubble radius — strength shown by BRIGHTNESS (dim→bright), not size
20148  // Size tiers by RANK (strongest first): King (rank 0, purple) biggest, 2nd purple a bit smaller,
20149  // next-2 (gold/red) big, everyone else normal.
20150  var _rad=function(jj){ return (jj===0)?9 : (jj===1)?7.5 : (jj<4)?6.5 : FR; };
20151  var _alpha=function(pct,passed,jj){ var a=0.24+pct*0.6; if(jj===0) a+=0.04; else if(jj===1) a-=0.08; if(passed) a*=0.5; return Math.max(0.16,Math.min(0.92,a)); };   // King brighter, 2nd pulled back; overall toned down (user 2026-09-02 'top gex too bright')
20152  var style=(indState.gexbubStyle==='glow'||indState.gexbubStyle==='rect')?'glow':'bubbles';   // 'rect' = the old flat bands, kept so a saved setting still resolves
20153  var extend=indState.gexbubExtend||'off';
20154  // Rank 0 keeps the bright violet; rank 1 drops to a deeper one so the King reads as THE level
20155  // rather than one of a matching pair. Below that it is gold/red by sign as before.
20156  var _col=function(jj,rw){ return (jj===0)?'#a855f7':(jj===1)?'#7c3aed':(rw.pos?'#f5c518':'#ef4444'); };
20157  // KING FOLLOWS MAGNET (admin test): LIVE-edge colours only - the magnet strike wears the
20158  // purple, the true King demotes to gold. Historical trail keeps its recorded colours, so
20159  // only the newest column / current bands consult this variant.
20160  var _kmsC = window._kmSwap;
20161  var _colKM=function(jj,rw){
20162    if (_kmsC && _kmsC.target != null) {
20163      if (rw.k === _kmsC.target) return '#a855f7';
20164      if (jj === 0) return '#f5c518';
20165    }
20166    return _col(jj,rw);
20167  };
20168  // EXT-PARITY LIVE COLOURS (user 2026-09-01, 'ghost mode chart is not changing like that'):
20169  // today's dots/bands wear each strike's CURRENT rank colour, so when the King moves the whole
20170  // row flips at once - exactly how the extension's live bands behave. A strike that dropped out
20171  // of the current top set keeps its recorded colour (it is history); the prev-day layer stays
20172  // recorded + dimmed as before. Map built from the newest non-prev column.
20173  // EXT-PARITY LIVE RANK (user 2026-09-02, 'change colors like the extension where gamma is
20174  // increasing'): rank from the LIVE per-strike gamma (columnData, updated every refresh) so a
20175  // strike climbing the ranks recolors the whole today-trail INSTANTLY - the extension reads live
20176  // topStrikes every 15s; the recorded-snap rank below lagged minutes behind. Falls back to the
20177  // newest recorded snap when live data isn't loaded (weekend/off-hours).
20178  // BASIS-CORRECT RANK (user 2026-09-02, 'bubbles purple at 765 but King line 760 on 0DTE'):
20179  // columnData[chartTicker] holds the GRID's basis (usually ALL), so ranking by it painted the
20180  // ALL-basis King (765) while the 0DTE levels+bubbles are 760. Rank from the newest DRAWN trail
20181  // column instead - it is basis-correct (0DTE uses g0) and matches what's on screen - re-sorted
20182  // by its own current |gamma| so a builder still climbs. columnData is only the ALL-basis path
20183  // (chart bubbles on ALL == grid basis, so it's safe there and stays live-fast).
20184  var _curRk={};
20185  try{
20186    // LIVE recolour source. Prefer columnData (updates every refresh → the whole trail recolours as
20187    // gamma shifts, the behaviour the user wants on ALL *and* 0DTE) BUT only when it is on the SAME
20188    // basis as the bubbles — columnData now carries `.basis`. If it isn't (e.g. a charted ticker
20189    // still on the ALL preload while the chart is 0DTE), fall back to the newest DRAWN trail column,
20190    // which is basis-correct (0DTE uses g0) — that was 9bfad6b's fix and it froze the 0DTE recolour.
20191    var _curBasis=(typeof gridExpiryMode!=='undefined')?(gridExpiryMode||'all'):'all';
20192    var _cData=(typeof columnData!=='undefined'&&typeof chartTicker!=='undefined')?columnData[chartTicker]:null;
20193    var _rankRows=null;
20194    if(_cData && _cData.strikes && _cData.strikes.length && (_cData.basis||'all')===_curBasis){ _rankRows=_cData.strikes; }   // basis-correct + LIVE
20195    if(!_rankRows){ for(var _ci9=tr.snaps.length-1;_ci9>=0;_ci9--){ var _cs9=tr.snaps[_ci9]; if(_cs9 && !_cs9.prev && _cs9.rows && _cs9.rows.length){ _rankRows=_cs9.rows.map(function(r){ return {strike:r.k, netGex:(r.a||0)}; }); break; } } }
20196    if(!_rankRows && _cData && _cData.strikes && _cData.strikes.length){ _rankRows=_cData.strikes; }   // last resort
20197    if(_rankRows&&_rankRows.length){
20198      var _ls=_rankRows.slice().filter(function(r){return Math.abs(r.netGex||0)>0;}).sort(function(a,b){return Math.abs(b.netGex||0)-Math.abs(a.netGex||0);});
20199      var _mxr=(typeof _gexBubMaxRows==='function')?_gexBubMaxRows():10;
20200      for(var _li=0;_li<Math.min(_ls.length,_mxr);_li++) _curRk[_ls[_li].strike]=_li;
20201    }
20202  }catch(e){}
20203  // Same-colour step-down. Two or three gold bands at pct ~0.95 differ by a couple of alpha points
20204  // under _alpha alone, which is why they looked identical on SPX. Rank within the colour instead:
20205  // the strongest gold (and the strongest red) keeps full brightness, each one below it steps down.
20206  var _cdim=function(a, cord){ return cord>0 ? Math.max(0.14, a - Math.min(0.42, cord*0.16)) : a; };
20207  var _passed=function(rw){ return price>0 && ((dir>0 && rw.k<price*0.999)||(dir<0 && rw.k>price*1.001)); };
20208  // snapshot x-range + optional extend to the chart edges.
20209  // PERF: a full session's trail is ~1000-2500 snaps; timeToCoordinate per snap on every frame
20210  // (pan/zoom/crosshair/live) is what froze the chart on ticker-click. Skip the transform for
20211  // snaps clearly outside the visible time range — on a zoomed-in intraday view that's most of them.
20212  var xArr=[], xFirst=null, xLast=null;
20213  var _vFrom=null, _vTo=null; try{ var _vr=ts.getVisibleRange(); if(_vr && typeof _vr.from==='number' && typeof _vr.to==='number'){ _vFrom=_vr.from; _vTo=_vr.to; } }catch(e){}
20214  var _vMargin=(_vFrom!=null)?Math.max(120,(_vTo-_vFrom)*0.03):0;
20215  // Replay trails are small (5-min snaps, ≤~80/day) AND the playhead advances a candle at a time —
20216  // getVisibleRange() can lag setData() by a frame, so culling by it drops the NEWEST columns and the
20217  // bubbles vanish as you play. Skip the cull in replay; draw every snap so they stay glued to the playhead.
20218  for(i=0;i<tr.snaps.length;i++){ var _st=tr.snaps[i].t;
20219    // Replay skips the cull for the PLAYED day (getVisibleRange lags the playhead) but the static
20220    // previous-day layer culls normally — it can be hundreds of columns.
20221    if(_vFrom!=null && (!_rp || tr.snaps[i].prev) && (_st<_vFrom-_vMargin || _st>_vTo+_vMargin)){ xArr[i]=null; continue; }   // off-screen → skip the costly transform
20222    var xx; try{ xx=ts.timeToCoordinate(_st); }catch(e){ xx=null; } xArr[i]=xx; if(xx==null) continue; if(xFirst==null||xx<xFirst) xFirst=xx; if(xLast==null||xx>xLast) xLast=xx; }
20223  if(xFirst==null){ ctx.restore(); return; }
20224  var xL=xFirst, xR=xLast;
20225  if(extend==='left'||extend==='full') xL=panelW+2;
20226  if(extend==='right'||extend==='full') xR=w-2;
20227  var last=tr.snaps[tr.snaps.length-1];
20228
20229  // '+ Prev →' (user 2026-08-28): yesterday's CLOSING bands carried across today as thin dim
20230  // lines, so the live tape trades against yesterday's gamma structure. Uses the LAST prev-day
20231  // snapshot (the 16:00 record). Drawn first, so today's bubbles/glow paint over it.
20232  if((indState.gexbubPrev||'')==='ext'){
20233    var _pvLast=null; for(i=0;i<tr.snaps.length;i++){ if(tr.snaps[i].prev) _pvLast=tr.snaps[i]; }
20234    if(_pvLast && _pvLast.rows && _pvLast.rows.length){
20235      var _px0=null; try{ _px0=ts.timeToCoordinate(_pvLast.t); }catch(e){}
20236      var _pxl=(_px0!=null && _px0>panelW)?_px0:panelW+2, _pxr=w-2;
20237      if(_pxr>_pxl+4){
20238        for(j=0;j<_pvLast.rows.length;j++){ var _prw=_pvLast.rows[j];
20239          var _pyv; try{ _pyv=chartSeries.priceToCoordinate(_prw.k); }catch(e){ _pyv=null; }
20240          if(_pyv==null||_pyv<-4||_pyv>h+4) continue;
20241          var _pcl=_col(j,_prw), _paA=_alpha(_prw.pct,false,j)*0.32*_gexBubPrevBright();
20242          ctx.shadowBlur=0;
20243          ctx.globalAlpha=Math.max(0.05,_paA*0.4); ctx.fillStyle=_pcl; ctx.fillRect(_pxl,_pyv-3,_pxr-_pxl,6);
20244          ctx.globalAlpha=Math.max(0.10,_paA); ctx.fillRect(_pxl,_pyv-1,_pxr-_pxl,2);
20245        }
20246        ctx.globalAlpha=1;
20247      }
20248    }
20249  }
20250
20251  if(style==='glow'){
20252    _gexBubFading = _gexGlowPaint({ ctx:ctx, w:w, h:h, panelW:panelW, tr:tr, xArr:xArr, xFirst:xFirst, xLast:
20252xLast, xL:xL, xR:xR, extend:extend, last:last,
20253      pyc:_pyc, colKM:_colKM, alpha:_alpha, cdim:_cdim, passed:_passed,
20254      candles:(typeof chartCandles!=='undefined'&&chartCandles)?chartCandles:[], fade:_gexGlowFade1, fkey:_gexBubKey() });
20255  } else if(extend!=='off'){
20256    // Extended bubbles = current levels as a dotted band across [xL,xR].
20257    if(last && last.rows) for(j=0;j<last.rows.length;j++){ var rw2=last.rows[j], y2=_pyc(rw2.k); if(y2==null||y2<-4||y2>h+4) continue;
20258      var passed2=_passed(rw2), col2=_colKM(j,rw2), a2=_alpha(rw2.pct, passed2, j), r2=_rad(j);
20259      if(rw2.pct>=0.5){ ctx.shadowColor=col2; ctx.shadowBlur=(passed2?3:6)*(j===0?1.15:1); } else ctx.shadowBlur=0;   // top-gex glow toned down (user 2026-09-02)
20260      ctx.globalAlpha=a2; ctx.fillStyle=col2;
20261      for(var bx=xL; bx<=xR; bx+=7){ ctx.beginPath(); ctx.arc(bx,y2,r2,0,TAU); ctx.fill(); }
20262    }
20263  } else {
20264    // Historical trail dots (default). FIXED radius for every bubble — strength shown by BRIGHTNESS
20265    // (dim→bright with %), not size. Colors unchanged: top-2 purple, positive gold, negative red.
20266    // TYPICAL sample spacing in PIXELS, used below to refuse tiling across a hole.
20267    // Pixels rather than time on purpose: it is the one measure that is already correct for every
20268    // timeframe AND for whether the chart hides off-hours. If off-hours are collapsed, the overnight
20269    // step is the same width as any other and tiling it is right; if after-hours candles are drawn,
20270    // that step is enormous and tiling it would paint a band over a night when nothing was sampled.
20271    var _gaps=[]; for(var _gi=0;_gi+1<xArr.length;_gi++){ var _a=xArr[_gi], _b2=xArr[_gi+1]; if(_a!=null&&_b2!=null&&_b2>_a) _gaps.push(_b2-_a); }
20272    _gaps.sort(function(p,q){return p-q;});
20273    var _medGap=_gaps.length?_gaps[Math.floor(_gaps.length/2)]:0;
20274    var _maxFillGap=_medGap>0?_medGap*2.5:0;
20275    var lastX=-999, minGap=3;
20276    for(i=0;i<tr.snaps.length;i++){ var x=xArr[i]; if(x==null||x<panelW-12||x>w+12) continue; if(x-lastX<minGap) continue; var sn=tr.snaps[i];
20277      var _pf=sn.prev?Math.min(1,0.55*_gexBubPrevBright()):1;   // PREVIOUS session's columns render dimmed so today's structure pops (brightness = Layer setting)
20278      // CONTINUOUS BANDS (user 2026-08-26). One dot per SAMPLE leaves visible space
20278s whenever the
20279      // chart shows more pixels per sample than a dot is wide - zoomed in, or on the coarse 5-min
20280      // history trail - so a level that held all session rendered as a dotted line of fragments.
20281      // Tiling dots across to the NEXT sample closes it. That is a step function, which is exactly
20282      // what the data is: the strike held that rank from this sample until the next one was taken.
20283      // Nothing is invented - the same samples are drawn, just not left with holes between them.
20284      var _xn=(i+1<tr.snaps.length)?xArr[i+1]:null;
20285      var _gapX=(_xn!=null && _xn>x) ? (_xn-x) : 0;
20286      // Never tile across a SESSION BOUNDARY or an unusually wide step. The previous session and
20287      // today are separate runs of samples; bridging them would draw yesterday levels straight
20288      // across the overnight and after-hours candles, where nothing was ever sampled. Same guard
20289      // catches a halt, a data outage, or any other hole - it fills the normal cadence and nothing
20290      // else, leaving a genuine gap looking like the gap it is.
20291      if(_gapX>0){
20292        var _nsn=tr.snaps[i+1];
20293        if(_nsn && !!_nsn.prev !== !!tr.snaps[i].prev) _gapX=0;
20294        else if(_maxFillGap>0 && _gapX>_maxFillGap) _gapX=0;
20295      }
20296      var _cg=0, _cr=0;   // per-column colour ordinals; counted before the off-screen skip so a
20297                          // scrolled-out level cannot shuffle the brightness of the ones on screen
20298      for(j=0;j<sn.rows.length;j++){ var rw3=sn.rows[j];
20299        var cord3=(j<2)?0:(rw3.pos?_cg++:_cr++);
20300        var y3=_pyc(rw3.k); if(y3==null||y3<-4||y3>h+4) continue;
20301        // FULL current-rank recolor of today's history (user 2026-09-02): colour AND size AND
20302        // King glow all use the CURRENT rank _cj3, so a past bubble at a now-King strike gets the
20303        // full King treatment (purple + bigger + glow), not just the colour. Recorded rank j stays
20304        // only for the previous-session (dimmed) layer.
20305        var _cj3=j;   // per-column recorded rank (see _cjg)
20306        var _rk3=sn.prev?j:_cj3;
20307        var passed3=_passed(rw3), col3=(!sn.prev?_colKM(_cj3,rw3):_col(j,rw3)), a3=_cdim(_alpha(rw3.pct, passed3, _rk3), cord3)*_pf, r3=_rad(_rk3);
20308        if(rw3.pct>=0.5 && !sn.prev){ ctx.shadowColor=col3; ctx.shadowBlur=(passed3?3:6)*(_rk3===0?1.15:1); } else ctx.shadowBlur=0;   // King/top-gex glow toned down (user 2026-09-02)
20309        ctx.globalAlpha=a3; ctx.fillStyle=col3;
20310        // Spacing is the dot DIAMETER plus a small gap, so the row reads as separate bubbles.
20311        // The first pass used r*1.15, which overlaps each dot with the next and fused the row into
20312        // a solid glow band - it closed the holes by destroying the thing that made it a bubble
20313        // trail. 2r+2.5 keeps them distinct and still leaves no gap worth calling a gap.
20314        //
20315        // One path, one fill, however many dots - moveTo before each arc so they do not chain into
20316        // a line. Capped at 30 per row so a deep zoom cannot turn one sample into a thousand arcs;
20317        // the GEX Visual has a history of costing frames.
20318        var _st3=r3*2+2.5, _nf=(_gapX>_st3) ? Math.min(30, Math.floor(_gapX/_st3)) : 0;
20319        ctx.beginPath(); ctx.moveTo(x+r3,y3); ctx.arc(x,y3,r3,0,TAU);
20320        for(var _q=1;_q<=_nf;_q++){ var _bx=x+_q*_st3; ctx.moveTo(_bx+r3,y3); ctx.arc(_bx,y3,r3,0,TAU); }
20321        ctx.fill();
20322        if((!sn.prev?_cj3:j)===0){ ctx.shadowBlur=0; ctx.globalAlpha=Math.min(0.9,a3+0.08*_pf); ctx.fillStyle='#ecd9ff'; ctx.beginPath(); ctx.arc(x,y3,r3*0.4,0,TAU); ctx.fill(); }   // King/top highlight core (dimmed 2026-09-02)
20323      }
20324      lastX=x;
20325    }
20326    // (The 1-2 projected forward columns that used to draw here were removed on user request
20327    // 2026-08-27 - the trail now ends at the newest real sample, nothing ahead of price.)
20328  }
20329  // "Next target" white ring — the nearest strong level AHEAD of price. Shown for BOTH styles: on
20330  // bubbles at the newest column, on glow lines near the right end of the target band.
20331  try {
20332    if(price>0 && last && last.rows && last.rows.length){
20333      var ax=(style==='glow') ? (xR-9) : ((extend==='right'||extend==='full')?xR:xLast), tgt=null, td=Infinity, tgtRank=0;
20334      for(var kk=0;kk<last.rows.length;kk++){ var rr=last.rows[kk]; var ahead=(dir>0)?(rr.k>price*1.0006):(dir<0)?(rr.k<price*0.9994):(Math.abs(rr.k-price)>price*0.0012); if(!ahead) continue; var d2=Math.abs(rr.k-price); if(d2<td){ td=d2; tgt=rr; tgtRank=kk; } }
20335      if(tgt){ var ty=_pyc(tgt.k); if(ty!=null){
20336        var rr2 = (style==='glow') ? ((tgtRank<2?16:tgtRank<4?13:10)/2 + 5) : (_rad(tgtRank)+4.5);   // hug the target's tier size
20337        ctx.globalAlpha=0.95; ctx.shadowColor='#ffffff'; ctx.shadowBlur=10; ctx.strokeStyle='#ffffff'; ctx.lineWidth=2;
20338        ctx.beginPath(); ctx.arc(ax,ty,rr2,0,TAU); ctx.stroke(); ctx.shadowBlur=0;
20339      } }
20340    }
20341  } catch(e){}
20342  ctx.shadowBlur=0; ctx.globalAlpha=1;
20342 ctx.restore();
20343  if(_gexBubFading){ setTimeout(function(){ if(typeof renderGexBubbles==='function') renderGexBubbles(); }, 45); }   // keep the glow-band fade animating
20344}
20345function startChartAutoRefresh() {
20346  if (_chartAutoTimer) return;
20347  _chartAutoTimer = setInterval(() => {
20348    try { const _ok = (typeof chartTicker !== 'undefined' && chartTicker === 'ES') ? (typeof isFuturesSession === 'function' && isFuturesSession()) : (typeof isExtendedSession === 'function' && isExtendedSession()); if (!_ok) return; refreshChartLevels(); } catch (e) {}
20349  }, 30000);
20350}
20351startChartAutoRefresh();
20352// Simple moving average over a close[] series (needs `len` non-null closes to emit a value).
20353function smaArr(closes, len) {
20354  const out = new Array(closes.length).fill(null);
20355  for (let i = 0; i < closes.length; i++) {
20356    if (closes[i] == null) continue;
20357    let sum = 0, cnt = 0;
20358    for (let j = i; j >= 0 && cnt < len; j--) { if (closes[j] != null) { sum += closes[j]; cnt++; } }
20359    if (cnt === len) out[i] = sum / len;
20360  }
20361  return out;
20362}
20363// Inside-bar box color/label mapped from the Pine palette by timeframe.
20364function ibColorForTf(tf) {
20365  if (tf === '1m' || tf === '5m' || tf === '15m' || tf === '30m') return { c: '#F44336', l: 'IB ' + tf.toUpperCase() };
20366  if (tf === '1h') return { c: '#E91E8C', l: 'IB 1H' };
20367  if (tf === '4h') return { c: '#2196F3', l: 'IB 4H' };
20368  if (tf === '1d') return { c: '#009688', l: 'IB D' };
20369  if (tf === 'w')  return { c: '#FF9800', l: 'IB W' };
20370  return { c: '#cbd5e1', l: 'IB M' };   // monthly (black -> light so it shows on dark bg)
20371}
20372// Most recent confirmed symmetric pivot (returns {val, idx} or null).
20373function lastPivot(values, len, type) {
20374  for (let i = values.length - 1 - len; i >= len; i--) {
20375    const v = values[i]; if (v == null) continue;
20376    let ok = true;
20377    for (let j = 1; j <= len; j++) {
20378      const a = values[i - j], b = values[i + j];
20379      if (a == null || b == null) { ok = false; break; }
20380      if (type === 'high' ? (a > v || b > v) : (a < v || b < v)) { ok = false; break; }
20381    }
20382    if (ok) return { val: v, idx: i };
20383  }
20384  return null;
20385}
20386
20387function computeIndicators() {
20388  indMAs = []; indVWAP = [];
20389  if (!(isPaidUser || (typeof IS_DEMO !== 'undefined' && IS_DEMO)) || !chartCandles || chartCandles.length < 5) return;   // demo may use EMA (user 2026-09-05)
20390  const closes = chartCandles.map(c => c.close);
20391  for (const m of indState.mas) {
20392    if (!m.on) continue;
20393    const arr = (m.type === 'sma') ? smaArr(closes, m.len) : emaArr(closes, m.len);
20394    indMAs.push({ arr, color: m.color, width: m.width, style: m.style });
20395  }
20396  // VWAP only makes sense intraday (it resets each session); skip on daily+ timeframes.
20397  if (indState.vwap && INTRADAY_TFS.includes(currentTimeframe)) indVWAP = computeVWAP(chartCandles);
20398}
20399
20400// ── 0DTE Flow bars (admin) ──────────────────────────────────────────────────────────────────
20401// Poll the live flow feed for the current chart ticker. A big 0DTE BUY print (ask-side) opens a band
20402// behind the candles at its strike, STARTING at the print's candle. A SELL (bid-side) print FREEZES
20403// the bar at the sell time (stops it extending); the frozen bar stays until the day rollover, then
20404// clears. Only GOOD/filtered buys (>= FLOW_MIN_PREM) open a bar. Prints applied oldest→newest.
20405// window.flowBars: `strike|C/P` -> { strike, cp, prem, accPrem, size, hits, seen, startTs, soldTs, exp, label }.
20406const FLOW_FETCH_MIN = 20000;   // server fetch floor — low enough to also catch closing SELL prints
20407const FLOW_MIN_PREM = 100000;   // a BUY must clear this to open a bar (good/filtered flow only) — tunable
20408const FLOW_SELL_PREM = 20000;   // a SELL (bid-side) at/above this freezes the bar at that time
20409let _flowTimer = null;
20410window.flowBars = new Map();
20411window._flowTk = null;
20412function _todayET() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); }
20413function _flowMoney(n) { return n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + Math.round(n / 1e3) + 'k'; }
20414// Print time -> UTC seconds, robust to ISO strings, unix-ms and unix-seconds numbers.
20415function _flowTsSec(f) {
20416  let t = f.created_at || f.executed_at || f.timestamp;
20417  if (t == null || t === '') return Math.floor(Date.now() / 1000);
20418  if (typeof t === 'number') return t < 1e12 ? Math.floor(t) : Math.floor(t / 1000); // <1e12 = seconds
20419  const p = Date.parse(t);
20420  return isNaN(p) ? Math.floor(Date.now() / 1000) : Math.floor(p / 1000);
20421}
20422async function pollFlowBars() {
20423  if (!indState.flow || !isAdminUser || !chartTicker) return;
20424  const tk = chartTicker, now = Date.now();
20425  if (window._flowTk !== tk) { window.flowBars.clear(); window._flowTk = tk; }   // ticker switched
20426  try {
20427    const j = await fetch('/api/flow/' + encodeURIComponent(tk) + '?min_prem=' + FLOW_FETCH_MIN).then(r => r.json());
20428    let rows = (j && (j.data || j)) || [];
20429    const today = _todayET();
20430    rows = rows.slice().sort((a, b) =>
20430 (Date.parse(a.created_at || a.executed_at || 0) || 0) - (Date.parse(b.created_at || b.executed_at || 0) || 0)); // oldest→newest
20431    for (const f of rows) {
20432      const exp = String(f.expiry || f.expires || f.expiry_date || '').slice(0, 10);
20433      if (exp !== today) continue;                                    // 0DTE only
20434      const strike = parseFloat(f.strike || 0); if (!strike) continue;
20435      const isC = String(f.type || f.option_type || '').toUpperCase().includes('CALL');
20436      const key = strike + (isC ? 'C' : 'P');
20437      const prem = parseFloat(f.total_premium || f.premium || 0) || 0;
20438      const askP = parseFloat(f.total_ask_side_prem || 0), bidP = parseFloat(f.total_bid_side_prem || 0);
20439      const isSell = String(f.side || '').toLowerCase() === 'bid' || (bidP > askP && (askP > 0 || bidP > 0));
20440      const pTs = _flowTsSec(f);   // print time (UTC seconds)
20441      if (isSell) {
20442        // Sold → FREEZE the bar at the sell time (stop extending it). Keep it until the day rollover.
20443        const b = window.flowBars.get(key);
20444        if (b && b.soldTs == null && prem >= FLOW_SELL_PREM) b.soldTs = pTs;
20445        continue;
20446      }
20447      if (prem < FLOW_MIN_PREM) continue;                             // only GOOD/filtered buys open a bar
20448      const size = parseInt(f.total_size || f.size || 0) || 0;
20449      const pid = String(f.id || (pTs + '|' + prem + '|' + size));
20450      let cur = window.flowBars.get(key);
20451      if (!cur || cur.soldTs != null) {
20452        // fresh position (or a re-buy after a sell) → new bar anchored at this print
20453        cur = { strike, cp: isC ? 'C' : 'P', prem: prem, accPrem: prem, size: size, hits: 1, seen: new Set([pid]), startTs: pTs, soldTs: null, exp };
20454      } else if (!cur.seen.has(pid)) {
20455        // same open position keeps printing = accumulation (bar thickens)
20456        cur.seen.add(pid); cur.hits++; cur.accPrem += prem;
20457        cur.prem = Math.max(cur.prem, prem); cur.size = Math.max(cur.size, size);
20458        if (pTs < cur.startTs) cur.startTs = pTs;
20459      }
20460      cur.exp = exp;
20461      cur.label = tk + ' ' + strike.toFixed(0) + (isC ? 'C' : 'P') + ' 0DTE · ' + _flowMoney(cur.accPrem) + (cur.hits > 1 ? ' · ' + cur.hits + '×' : '') + ' · x' + cur.size;
20462      window.flowBars.set(key, cur);
20463    }
20464  } catch (e) {}
20465  if (chartTicker !== tk) { window.flowBars.clear(); return; }        // moved on mid-fetch
20466  const today2 = _todayET();
20467  for (const [k, fb] of window.flowBars) { if (fb.exp && fb.exp !== today2) window.flowBars.delete(k); } // clear at day rollover
20468  redrawDrawings();
20469}
20470function startFlowPolling() {
20471  window.flowBars.clear(); window._flowTk = chartTicker || null;
20472  pollFlowBars();
20473  if (_flowTimer) clearInterval(_flowTimer);
20474  _flowTimer = setInterval(pollFlowBars, 15000);
20475}
20476function stopFlowPolling() {
20477  if (_flowTimer) { clearInterval(_flowTimer); _flowTimer = null; }
20478  window.flowBars.clear(); redrawDrawings();
20479}
20480
20481// Convert a raw unix-seconds timestamp into the same normalized space as
20482// chartCandleTs, so a higher-timeframe box can be anchored on the current chart.
20483function normalizeUnixToChartTs(u) {
20484  // chartCandleTs is now TRUE UTC unix for every timeframe, so this is an identity.
20485  return Math.floor(u);
20486}
20487function rawCandlesFromJson(json) {
20488  const out = [];
20489  // Yahoo shape
20490  const r = json && json.chart && json.chart.result && json.chart.result[0];
20491  if (r && r.timestamp) {
20492    const q = r.indicators && r.indicators.quote && r.indicators.quote[0];
20493    if (q) {
20494      for (let i = 0; i < r.timestamp.length; i++) {
20495        const o = q.open && q.open[i], h = q.high && q.high[i], l = q.low && q.low[i], c = q.close && q.close[i];
20496        if (o != null && h != null && l != null && c != null) out.push({ time: Math.floor(r.timestamp[i]), open: o, high: h, low: l, close: c });
20497      }
20498    }
20499  }
20500  // UW fallback shape (same as loadGEXChart): array of {date|timestamp, open, high, low, close}
20501  if (out.length === 0) {
20502    const uw = json && (json.data || (Array.isArray(json) ? json : null));
20503    if (Array.isArray(uw)) {
20504      for (const d of uw) {
20505        const o = parseFloat(d.open), h = parseFloat(d.high), l = parseFloat(d.low), c = parseFloat(d.close);
20506        const t = d.date || d.timestamp;
20507        if (o && h && l && c && t) {
20508          const sec = (typeof t === 'number') ? Math.floor(t) : Math.floor(Date.parse(('' + t).slice(0, 10) + 'T00:00:00Z') / 1000);
20509          if (sec) out.push({ time: sec, open: o, high: h, low: l, close: c });
20510        }
20511      }
20512      out.sort((a, b) => a.time - b.time);
20513    }
20514  }
20515  return out;
20516}
20517async function fetchTfCandles(ticker, tf) {
20518  const key = ticker + '|' + tf;
20519  const cached = chartCandleCache[key];
20520  let json;
20521  if (cached && Date.now() - cached.t < 300000) json = cached.candleJson;
20522  else {
20523    const map = tfMap[tf] || { interval: '1d', range: '6mo' };
20524    json = await fetch('/api/candles/' + ticker + '?range=' + map.range + '&interval=' + map.interval).then(r => r.json());
20525    if (json && json.chart && json.chart.result && json.chart.result[0] && json.chart.result[0].timestamp) chartCandleCache[key] = { candleJson: json, t: Date.now() };
20526  }
20527  let bars = rawCandlesFromJson(json);
20528  if (tf === '1h') { bars = clampWicks(bars, 3); bars = aggregateCandles(bars, 3600, 0); }
20529  if (tf === '4h') { bars = clampWicks(bars, 3); bars = aggregateCandles(bars, 4 * 3600, 3600); }
20530  if (tf === '10m') { bars = clampWicks(bars, 3); bars = aggregateCandles(bars, 10 * 60, 0); }
20531  // Same final bad-tick clean as the main chart (loadGEXChart): Desk panes 2/3 skipped it, so SPY showed the
20532  // long extended-hours wicks the main pane had already trimmed (user 2026-09-05).
20533  bars = clampWicks(bars, (typeof INTRADAY_TFS !== 'undefined' && INTRADAY_TFS.includes(tf)) ? 3 : 4);
20534  return bars;
20535}
20536// Most recent inside bar (bar strictly inside its predecessor); box = mother hi/lo.
20537function latestInsideBarBox(bars) {
20538  for (let k = bars.length - 1; k >= 1; k--) {
20539    if (bars[k].high < bars[k - 1].high && bars[k].low > bars[k - 1].low) {
20540      const m = bars[k - 1];
20541      return { hi: m.high, lo: m.low, rawT: m.time };
20542    }
20543  }
20544  return null;
20545}
20546// Multi-timeframe inside-bar boxes. Which TFs appear depends on the current chart
20547// TF (matches the Pine show5M/show1H/show4H/showD/showW/showM matrix) AND the
20548// per-TF toggles. Each box = that TF's latest inside-bar mother candle, extended right.
20549async function computeInsideBarBoxes() {
20550  if (!isAdminUser || !indState.ib || !chartCandles.length) { indIBoxes = []; redrawDrawings(); return; }
20551  const mins = TF_MINS[currentTimeframe] || 0;
20552  const tog = indState.ibTfs;
20553  const plan = [];
20554  if (mins > 3 && mins <= 5     && tog['5m']) plan.push(['5m', '#F44336', 'IB 5M']);
20555  if (mins > 5 && mins <= 60    && tog['1h']) plan.push(['1h', '#E91E8C', 'IB 1H']);
20556  if (mins > 5 && mins <= 240   && tog['4h']) plan.push(['4h', '#2196F3', 'IB 4H']);
20557  if (mins > 5 && mins <= 1440  && tog['1d']) plan.push(['1d', '#009688', 'IB D']);
20558  if (mins > 5 && mins <= 10080 && tog['w'])  plan.push(['w',  '#FF9800', 'IB W']);
20559  if (mins >= 240               && tog['mo']) plan.push(['mo', '#cbd5e1', 'IB M']);
20560  const token = ++ibComputeToken;
20561  const tk = chartTicker;
20562  const boxes = await Promise.all(plan.map(async ([tf, color, label]) => {
20563    try {
20564      const bars = await fetchTfCandles(tk, tf);
20565      const b = latestInsideBarBox(bars);
20566      return b ? { hi: b.hi, lo: b.lo, ts: normalizeUnixToChartTs(b.rawT), color, label } : null;
20567    } catch (e) { return null; }
20568  }));
20569  if (token !== ibComputeToken || tk !== chartTicker) return;   // superseded
20570  indIBoxes = boxes.filter(Boolean);
20571  redrawDrawings();
20572}
20573
20574// Painted inside redrawDrawings (within the clipped chart area).
20575function renderIndicators(ctx, w, h, panelW) {
20576  if (!(isPaidUser || (typeof IS_DEMO !== 'undefined' && IS_DEMO))) return;   // demo draws EMA / VWAP overlays too (user 2026-09-05)
20577  const ts = chartObj ? chartObj.timeScale() : null;
20578  const X = (i) => { try { return ts.logicalToCoordinate(i); } catch (e) { return null; } };
20579  const Y = (p) => { try { return chartSeries.priceToCoordinate(p); } catch (e) { return null; } };
20580
20581  // ── Moving averages (EMA/SMA; draw only the visible logical range for speed) ──
20582  if (indMAs && indMAs.length) {
20583    let from = 0, to = chartCandles.length - 1;
20584    try { const r = ts.getVisibleLogicalRange(); if (r) { from = Math.max(0, Math.floor(r.from)); to = Math.min(chartCandles.length - 1, Math.ceil(r.to)); } } catch (e) {}
20585    for (const m of indMAs) {
20586      const arr = m.arr; if (!arr) continue;
20587      ctx.beginPath(); ctx.strokeStyle = m.color; ctx.lineWidth = m.width || 1.5;
20588      ctx.setLineDash(m.style === 'dotted' ? [2, 3] : []);
20589      let started = false;
20590      for (let i = from; i <= to; i++) {
20591        const v = arr[i]; if (v == null) continue;
20592        const x = X(i), y = Y(v); if (x == null || y == null) continue;
20593        if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
20594      }
20595      ctx.stroke(); ctx.setLineDash([]);
20596    }
20597  }
20598
20599  // ── Charm Levels — translucent bands behind the candles (amber = charm high, cyan = charm low).
20600  // The lines, labels and colored axis price tags are drawn as price lines (applyCharmPriceLines).
20601  if (indState.charm && window.chartCharmLevels) {
20602    const CL = window.chartCharmLevels, bh = 6;
20603    ctx.setLineDash([]);
20604    const fillBand = (strike, fill) => { const y = Y(strike); if (y == null) return; ctx.fillStyle = fill; ctx.fillRect(panelW, y - bh, w - panelW, bh * 2); };
20605    fillBand(CL.hi.strike, 'rgba(34,211,238,0.14)');   // positive = cyan
20606    fillBand(CL.lo.strike, 'rgba(245,158,11,0.14)');   // negative = amber
20607  }
20608
20609  // ── VWAP (intraday; purple center line only — bands removed per user) ──
20610  if (indVWAP && indVWAP.length) {
20611    let from = 0, to = chartCandles.length - 1;
20612    try { const r = ts.getVisibleLogicalRange(); if (r) { from = Math.max(0, Math.floor(r.from)); to = Math.min(chartCandles.length - 1, Math.ceil(r.to)); } } catch (e) {}
20613    const vcol = (indState.modeColors && indState.modeColors.vwap) || '#a78bfa';
20614    const line = (key, width, dash) => {
20615      ctx.beginPath(); ctx.strokeStyle = vcol; ctx.lineWidth = width; ctx.setLineDash(dash);
20616      let st = false;
20617      for (let i = from; i <= to; i++) { const o = indVWAP[i]; if (!o) continue; const x = X(i), y = Y(o[key]); if (x == null || y == null) continue; if (!st) { ctx.moveTo(x, y); st = true; } else ctx.lineTo(x, y); }
20618      ctx.stroke(); ctx.setLineDash([]);
20619    };
20620    line('vwap', 1.8, []);
20621  }
20622
20623  // ── 0DTE Flow bars — glowing highlight bar at the strike (green call / red put); the bar THICKENS
20624  // as the same strike keeps printing (accumulation). Starts at the print's candle, ends at now.
20625  if (indState.flow && window.flowBars && window.flowBars.size) {
20626    let labelY = h - 6;
20627    ctx.font = '10px Segoe UI, monospace';
20628    const bars = [...window.flowBars.values()].sort((a, b) => (b.accPrem || b.prem) - (a.accPrem || a.prem));
20629    for (const fb of bars) {
20630      const isC = fb.cp === 'C', rgb = isC ? '34,197,94' : '239,68,68', col = isC ? '#22c55e' : '#ef4444';
20631      const y = Y(fb.strike);
20632      if (y != null) {
20633        // xForTs: a print time → x, clamped into the data range so it can't return null / span the chart.
20634        const xForTs = (ts) => { try { let li = indexForTs(ts); if (!isFinite(li)) return null; li = Math.max(0, Math.min(chartCandles.length - 1, li)); const x = X(li); return (x == null) ? null : Math.max(panelW, Math.min(w, x)); } catch (e) { return null; } };
20635        // Start at the print's candle; end at the SELL candle if frozen, else the current bar (now).
20636        let left = (fb.startTs != null ? xForTs(fb.startTs) : null); if (left == null) left = w - 60;
20637        let right = (fb.soldTs != null ? xForTs(fb.soldTs) : null);
20638        if (right == null) { try { const xr = X(chartCandles.length - 1); right = (xr == null) ? w : Math.min(w, xr); } catch (e) { right = w; } }
20639        if (right <= left) right = left + 2;
20640        // Half-height grows with accumulation: 4px base + 2px per extra print, capped at 16px.
20641        // Accumulation tiers: 1–2 prints = thin subtle glow; 3+ prints = progressively WIDER + brighter glow.
20642        const hits = fb.hits || 1, acc = hits >= 3;
20643        const half = acc ? Math.min(6 + (hits - 3) * 2.5, 20) : 4;
20644        const centerOp = acc ? Math.min(0.42 + (hits - 3) * 0.05, 0.62) : 0.28;
20645        const blur = acc ? Math.min(11 + (hits - 3) * 2.5, 24) : 5;
20646        ctx.save();
20647        ctx.shadowColor = 'rgba(' + rgb + ',' + (acc ? 0.85 : 0.4) + ')'; ctx.shadowBlur = blur;
20648        const g = ctx.createLinearGradient(0, y - half, 0, y + half);
20649        g.addColorStop(0, 'rgba(' + rgb + ',0)');
20650        g.addColorStop(0.5, 'rgba(' + rgb + ',' + centerOp + ')');
20651        g.addColorStop(1, 'rgba(' + rgb + ',0)');
20652        ctx.fillStyle = g;
20653        ctx.fillRect(left, y - half, right - left, half * 2);
20654        // bright core line — thicker/brighter as accumulation grows
20655        ctx.shadowBlur = acc ? blur * 0.5 : 2;
20656        ctx.fillStyle = 'rgba(' + rgb + ',' + (acc ? 0.9 : 0.55) + ')';
20657        const cw = acc ? 2.5 : 1.5;
20658        ctx.fillRect(left, y - cw / 2, right - left, cw);
20659        ctx.restore();
20660      }
20661      ctx.fillStyle = col; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
20662      ctx.fillText(fb.label, panelW + 6, labelY);
20663      labelY -= 13; if (labelY < h * 0.55) break;   // cap the label stack
20664    }
20665  }
20666
20667  // ── Gamma Regime shading — faint green above the gamma flip (dealers pin / fade extremes),
20668  //    faint red below (dealers amplify / trend); dashed line + label at the flip.
20669  const _MC = indState.modeColors || MODE_COLOR_DEFAULTS;
20670  // ── Expected Move — shaded ±1σ band from the ATM 0DTE straddle (lines drawn as price lines).
20671  if (indState.em && window.chartModes && window.chartModes.em) {
20672    const em = window.chartModes.em, yu = Y(em.up), yd = Y(em.dn);
20673    if (yu != null && yd != null) { ctx.save(); ctx.fillStyle = _hexA(_MC.em, 0.06); ctx.fillRect(panelW, Math.min(yu, yd), w - panelW, Math.abs(yd - yu)); ctx.restore(); }
20674  }
20675  // ── Magnet bands — translucent highlight behind the top strikes, brighter/thicker by rank.
20676  if (indState.magnet && window.chartModes && window.chartModes.magnet) {
20677    ctx.save();
20678    window.chartModes.magnet.forEach((mg, i) => { const y = Y(mg.strike); if (y == null) return; const op = [0.13, 0.08, 0.05][i] || 0.05, hh = [7, 5, 4][i] || 4; ctx.fillStyle = _hexA(_MC.magnet, op); ctx.fillRect(panelW, y - hh, w - panelW, hh * 2); });
20679    ctx.restore();
20680  }
20681}
20682
20683// Background-fetch candles for every timeframe of a ticker into chartCandleCache,
20684// so when the user switches timeframe the data is already there. Skips the current
20685// timeframe (just loaded) and any timeframe still fresh in cache.
20686let _prefetchInFlight = '';
20687async function prefetchChartTimeframes(ticker) {
20688  if (!ticker || _prefetchInFlight === ticker) return;
20689  _prefetchInFlight = ticker;
20690  try {
20691    for (const tf of Object.keys(tfMap)) {
20692      if (ticker !== chartTicker) break; // user moved on — stop
20693      const key = ticker + '|' + tf;
20694      const c = chartCandleCache[key];
20695      if (tf === currentTimeframe || (c && Date.now() - c.t < 60000)) continue;
20696      const m = tfMap[tf];
20697      try {
20698        // bg=1: this is a background warm of a timeframe you're NOT looking at. The server uses it
20699        // to keep these out of the UW fallback path during a Yahoo-stale window (2026-08-07: the
20700        // prefetches stampeded UW OHLC and took the site down).
20701        const j = await fetch('/api/candles/' + ticker + '?range=' + m.range + '&interval=' + m.interval + '&bg=1').then(r => r.json());
20702        // Only cache real candle data — never cache an error/fallback response.
20703        if (j && j.chart && j.chart.result && j.chart.result[0] && j.chart.result[0].timestamp) {
20704          chartCandleCache[key] = { candleJson: j, t: Date.now() };
20705        }
20706      } catch (e) {}
20707      await new Promise(r => setTimeout(r, 250)); // space out requests so Yahoo doesn't rate-limit
20708    }
20709  } finally {
20710    _prefetchInFlight = '';
20711  }
20712}
20713
20714// ── Timeframe switching ──
20715let currentTimeframe = '1h';
20716const tfMap = {
20717  '1m':  { interval: '1m',  range: '5d' },
20718  '5m':  { interval: '5m',  range: '1mo' },
20719  '10m': { interval: '5m',  range: '1mo' },  // Yahoo has no native 10m → fetch 5m, aggregate to 10m
20720  '15m': { interval: '15m', range: '1mo' },  // Yahoo caps 15m at ~60 days
20721  '30m': { interval: '30m', range: '1mo' },
20722  '1h':  { interval: '60m', range: '1y' },    // 60m fetched (Yahoo allows ~2y on 60m; 30m is capped at ~1mo so it can't give history) and normalized to top-of-hour bars
20723  '4h':  { interval: '60m', range: '1y' },    // 60m fetched then aggregated to 4h; 1 year of history (lighter load than 2y)
20724  '1d':  { interval: '1d',  range: '2y' },
20725  'w':   { interval: '1wk', range: '10y' },  // 'max' makes Yahoo downsample weekly -> monthly
20726  'mo':  { interval: '1mo', range: '25y' },  // 'max' makes Yahoo downsample 1mo -> quarterly for long-history tickers
20727};
20728// Timeframes that use intraday (unix) timestamps vs daily date strings.
20729const INTRADAY_TFS = ['1m', '5m', '10m', '15m', '30m', '1h', '4h'];
20730
20731// ── Timeframe toolbar: favourites shown inline, the rest behind a ▾ dropdown ──
20732// Canonical order + display labels. Users star/unstar to pin a TF to the toolbar.
20733const TF_ALL = [['1m','1m'],['5m','5m'],['10m','10m'],['15m','15m'],['30m','30m'],['1h','1H'],['4h','4H'],['1d','1D'],['w','W'],['mo','M']];
20734const TF_ORDER = TF_ALL.map(t => t[0]);
20735function _tfLabel(tf){ const r = TF_ALL.find(t => t[0] === tf); return r ? r[1] : tf; }
20736function _tfFavs(){ try { const s = JSON.parse(localStorage.getItem('gf_tf_favs')); if (Array.isArray(s) && s.length) return TF_ORDER.filter(t => s.includes(t)); } catch (e) {} return ['5m','15m','1h','4h']; }
20737function _setTfFavs(f){ try { localStorage.setItem('gf_tf_favs', JSON.stringify(TF_ORDER.filter(t => f.includes(t)))); } catch (e) {} }
20738function toggleTfFav(tf){ let f = _tfFavs(); f = f.includes(tf) ? f.filter(x => x !== tf) : f.concat(tf); if (!f.length) f = [tf]; _setTfFavs(f); renderTfBar(); }
20739function toggleTfMenu(force){ const m = document.getElementById('tfMenu'); if (!m) return; const show = (force === undefined) ? (m.style.display === 'none') : !!force; m.style.display = show ? 'block' : 'none'; if (show) { try { const _b = document.getElementById('tfMenuBtn'); if (_b) { const _r = _b.getBoundingClientRect(); m.style.position = 'fixed'; m.style.top = Math.round(_r.bottom + 4) + 'px'; m.style.left = Math.round(_r.left) + 'px'; m.style.zIndex = '1000'; } } catch (e) {} }   /* fixed to the screen like the Layers panel: as an absolute child of the header row it painted UNDER the levels line + ladder (user 2026-09-05: "timeframe not working on user end") */ if (show) setTimeout(() => document.addEventListener('click', _tfMenuClose), 0); else document.removeEventListener('click', _tfMenuClose); }
20740function _tfMenuClose(e){ const w = document.getElementById('tfMenuWrap'); if (w && !w.contains(e.target)) toggleTfMenu(false); }
20741function renderTfBar(){
20742  const host = document.getElementById('tfButtons'); if (!host) return;
20743  const favs = _tfFavs();
20744  // Inline = favourites, plus the active TF if it isn't a favourite (so the selection is always visible).
20745  const inline = TF_ORDER.filter(tf => favs.includes(tf) || tf === currentTimeframe);
20746  const btns = inline.map(tf => '<button class="tf-btn' + (tf === currentTimeframe ? ' active' : '') + '" data-tf="' + tf + '" onclick="switchTimeframe(\'' + tf + '\')">' + _tfLabel(tf) + '</button>').join('');
20747  const rows = TF_ALL.map(function(t){ const tf = t[0], on = favs.includes(tf); return '<div class="tfmenu-row" style="display:flex;align-items:center;gap:9px;padding:4px 6px;border-radius:6px">'
20748    + '<span onclick="event.stopPropagation();toggleTfFav(\'' + tf + '\')" title="' + (on ? 'Remove from toolbar' : 'Pin to toolbar') + '" style="cursor:pointer;font-size:13px;line-height:1;color:' + (on ? '#eab308' : '#475569') + '">' + (on ? '★' : '☆') + '</span>'
20749    + '<span onclick="switchTimeframe(\'' + tf + '\');toggleTfMenu(false)" style="flex:1;cursor:pointer;' + (tf === currentTimeframe ? 'color:var(--cyan);font-weight:700' : 'color:#cbd5e1') + '">' + t[1] + '</span></div>'; }).join('');
20750  host.innerHTML = btns
20751    + '<div id="tfMenuWrap" style="position:relative;display:inline-flex">'
20752    + '<button class="tf-btn" id="tfMenuBtn" onclick="event.stopPropagation();toggleTfMenu()" title="More timeframes — ★ pin your favourites to the toolbar" style="padding-left:6px;padding-right:6px">▾</button>'
20753    + '<div id="tfMenu" style="display:none;position:absolute;top:calc(100% + 4px);left:0;z-index:200;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px;min-width:118px;box-shadow:0 8px 24px rgba(0,0,0,.5)">'
20754    + '<div style="font-size:9px;color:var(--dim);letter-spacing:.5px;padding:2px 6px 5px">TIMEFRAMES · ★ pin</div>'
20755    + rows + '</div></div>';
20756}
20757// Restore the user's last-used chart timeframe (default 1h for first-time users). The tf-btn
20758// active state + chart both read currentTimeframe, so set it before either renders.
20759try { const _stf = localStorage.getItem('gf_timeframe'); if (_stf && tfMap[_stf]) { currentTimeframe = _stf; } } catch (e) {}
20760renderTfBar();
20761
20762function switchTimeframe(tf) {
20763  // Ghost Desk: drives the focused chart, so clicking chart 2 then a timeframe changes chart 2.
20764  try { const _p = (typeof _mFocusedPaneId === 'function') ? _mFocusedPaneId() : null; if (_p != null) { _mSetTf(_p, tf); _mSyncModeUI(); return; } } catch (e) {}
20765  if (_replayActive) exitReplay();
20766  currentTimeframe = tf;
20767  try { localStorage.setItem('gf_timeframe', tf); } catch (e) {}   // remember across reloads
20768  // Ghost Desk: a timeframe picked here belongs to the FOCUSED slot, not to the desk as a whole.
20769  try { if (typeof _mSyncFocusedSlot === 'function') _mSyncFocusedSlot(); } catch (e) {}
20770  renderTfBar();
20771  // Timeframe only changes the CHART — leave the flow panel untouched (it keeps updating live).
20772  if (chartTicker) loadGEXChart(chartTicker, false, { keepFlow: true });
20773}
20774
20775// ── GEX chart REPLAY — play the day's candles back with the levels as they moved ──
20776let _replayTf = '5m';
20777function _replaySessionCandles(arr, dayStr) {
20778  // Keep ONE trading day's regular-hours session (9:30–16:00 ET). dayStr (YYYY-
20778MM-DD) selects a
20779  // specific past day for 7-day replay; without it, the LATEST day in the data. Falls back to
20780  // the whole day if the RTH filter is too sparse.
20781  const info = (arr || []).map(c => {
20782    const et = new Date(new Date(((c._ts || 0)) * 1000).toLocaleString('en-US', { timeZone: 'America/New_York' }));
20783    return { c, day: et.getFullYear() * 10000 + (et.getMonth() + 1) * 100 + et.getDate(), mins: et.getHours() * 60 + et.getMinutes() };
20784  });
20785  let targetDay = 0;
20786  if (dayStr && /^\d{4}-\d{2}-\d{2}$/.test(dayStr)) { const p = dayStr.split('-').map(Number); targetDay = p[0] * 10000 + p[1] * 100 + p[2]; }
20787  else { for (const i of info) if (i.day > targetDay) targetDay = i.day; }
20788  let s = info.filter(i => i.day === targetDay && i.mins >= 240 && i.mins < 1200).map(i => i.c);
20789  if (s.length < 5) s = info.filter(i => i.day === targetDay).map(i => i.c);
20790  return s;
20791}
20792// Set the full-day view ONCE (so it doesn't snap back as you pan) + a fixed price range
20793// covering every level that occurs all day, so all lines stay visible.
20794function _replaySetupView() {
20795  if (!chartObj || !_replayCandles.length) return;
20796  // Frame the RTH session only (6:30am–1pm PT), with a couple of bars of padding on each side.
20797  const li = Math.min(_replayCandles.length - 1, _replayEndIdx + 2);
20798  const fromIdx = Math.max(0, _replayStartIdx - 2);
20799  // Fit the CANDLES first (visible window), then widen only for levels that are NEARBY — so a far
20800  // Active/King node can't stretch the axis and squash the candles into a thin band. Far level
20801  // lines still draw; they just sit off the current view (user can zoom out).
20802  try { chartObj.priceScale('right').applyOptions({ autoScale: true }); } catch (e) {} // replay may inherit a fixed scale from live wheel-zoom
20803  const cvals = [];
20804  for (let k = fromIdx; k <= li; k++) { const c = _replayCandles[k]; if (c && c.low != null) cvals.push(c.low); if (c && c.high != null) cvals.push(c.high); }
20805  const cf = cvals.filter(v => v != null && isFinite(v));
20806  if (cf.length) {
20807    let mn = Math.min(...cf), mx = Math.max(...cf);
20808    // Zoom out vertically to show ALL recorded GEX levels (King/walls/vol/active), not just the
20809    // nearby ones (user request). Only a ±8%-of-price sanity cap remains, so a glitched far-OTM
20810    // level in an old recording (the 06-39 SPY garbage-King case) can't flatten the candles.
20811    const mid = (mn + mx) / 2, cap = mid * 0.08;
20812    const lvls = [];
20813    for (const s of _replaySnaps) ['k', 'cw', 'pw', 'vn', 'an'].forEach(k => { if (s[k] != null) lvls.push(s[k]); });
20814    const fb = _replayFallbackLevels || {}; ['k', 'cw', 'pw', 'vn', 'an'].forEach(k => { if (fb[k] != null) lvls.push(fb[k]); });
20815    for (const v of lvls) { if (isFinite(v) && v >= mid - cap && v <= mid + cap) { mn = Math.min(mn, v); mx = Math.max(mx, v); } }
20816    const pad = (mx - mn) * 0.08 || 1;
20817    try {
20818      if (!_replayRange) _replayRange = chartObj.addLineSeries({ color: 'rgba(0,0,0,0)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false });
20819      _replayRange.setData([{ time: _replayCandles[fromIdx].time, value: mn - pad }, { time: _replayCandles[li].time, value: mx + pad }]);
20820    } catch (e) {}
20821  }
20822  // Frame a TRAILING WINDOW around the playhead, not the whole session: framing all ~390 session
20823  // bars at once put one tiny candle at the far left of a huge empty runway (user had to pinch to
20824  // focus). Start zoomed on the open — ~20 bars of context behind, ~60 ahead — and let
20825  // renderReplayFrame slide the window with the playhead as the day plays.
20826  const vFrom = Math.max(0, _replayStartIdx - 20);
20827  const vTo = Math.min(_replayEndIdx + 2, _replayStartIdx + 60);
20828  const applyView = () => { try { chartObj.timeScale().setVisibleLogicalRange({ from: vFrom, to: vTo }); } catch (e) { try { chartObj.timeScale().fitContent(); } catch (_) {} } };
20829  requestAnimationFrame(() => { applyView(); requestAnimationFrame(applyView); });
20830}
20831function _replayAggregate(cs, mins) {
20832  const sec = mins * 60, out = [], idx = new Map();
20833  for (const c of cs) {
20834    const ts = (c._ts || 0), bucket = Math.floor(ts / sec) * sec;
20835    if (idx.has(bucket)) { const m = out[idx.get(bucket)]; m.high = Math.max(m.high, c.high); m.low = Math.min(m.low, c.low); m.close = c.close; }
20836    else { idx.set(bucket, out.length); out.push({ ...c }); }
20837  }
20838  return out;
20839}
20840// Build replay candles: ALL extended-hours candles (4am–8pm ET pre-market → after-hours = 1am–5pm
20841// PT) up to & including the target day, so the
20842// prior days stay on the chart as context. Returns { candles, startIdx } where startIdx is the
20843// first candle of the target day (the replay playhead starts there).
20844function _replayMultiDay(arr, dayStr) {
20845  const info = (arr || []).map(c => {
20846    const et = new Date(new Date(((c._ts || 0)) * 1000).toLocaleStr
20846ing('en-US', { timeZone: 'America/New_York' }));
20847    return { c, day: et.getFullYear() * 10000 + (et.getMonth() + 1) * 100 + et.getDate(), mins: et.getHours() * 60 + et.getMinutes() };
20848  });
20849  let targetDay = 0;
20850  if (dayStr && /^\d{4}-\d{2}-\d{2}$/.test(dayStr)) { const p = dayStr.split('-').map(Number); targetDay = p[0] * 10000 + p[1] * 100 + p[2]; }
20851  else { for (const i of info) if (i.day > targetDay) targetDay = i.day; }
20852  let rows = info.filter(i => i.day <= targetDay && i.mins >= 240 && i.mins < 1200);
20853  if (rows.filter(r => r.day === targetDay).length < 5) rows = info.filter(i => i.day <= targetDay); // sparse RTH → whole days
20854  const candles = rows.map(r => r.c);
20855  let startIdx = candles.length; for (let k = 0; k < rows.length; k++) { if (rows[k].day === targetDay) { startIdx = k; break; } }
20856  return { candles, startIdx };
20857}
20858async function _loadReplayCandles() {
20859  const base = (_replayTf === '10m' || _replayTf === '15m') ? '5m' : _replayTf;   // Yahoo has no 10m/15m → build from 5m
20860  if (currentTimeframe !== base) { currentTimeframe = base; renderTfBar(); }
20861  await loadGEXChart(chartTicker);
20862  await new Promise(r => setTimeout(r, 200));
20863  const md = _replayMultiDay(chartReplayCandles || [], (_replayDate && _replayDate !== _replayToday) ? _replayDate : null);
20864  let cs = md.candles, startIdx = md.startIdx;
20865  if (_replayTf === '10m' || _replayTf === '15m') {
20866    const startTs = cs[startIdx] ? (cs[startIdx]._ts || 0) : Infinity; // remember the day's open, indices shift on aggregate
20867    cs = _replayAggregate(cs, _replayTf === '10m' ? 10 : 15);
20868    startIdx = cs.findIndex(c => (c._ts || 0) >= startTs); if (startIdx < 0) startIdx = Math.max(0, cs.length - 1);
20869  }
20870  _replayCandles = cs;
20871  // Bound the playhead to the target day's REGULAR session only: 9:30–16:00 ET = 6:30am–1pm PT.
20872  // Start at the RTH open (not the 4am pre-market first candle) and stop at the RTH close.
20873  const _etMin = c => { try { const e = new Date(new Date((c._ts || 0) * 1000).toLocaleString('en-US', { timeZone: 'America/New_York' })); return e.getHours() * 60 + e.getMinutes(); } catch (e) { return -1; } };
20874  const _etDay = c => { try { const e = new Date(new Date((c._ts || 0) * 1000).toLocaleString('en-US', { timeZone: 'America/New_York' })); return e.getFullYear() * 10000 + (e.getMonth() + 1) * 100 + e.getDate(); } catch (e) { return 0; } };
20875  const _tgtDay = cs[startIdx] ? _etDay(cs[startIdx]) : 0;
20876  let _open = -1, _close = -1;
20877  for (let k = 0; k < cs.length; k++) { if (_etDay(cs[k]) !== _tgtDay) continue; const m = _etMin(cs[k]); if (m >= 570 && m < 960) { if (_open < 0) _open = k; _close = k; } }   // 570=9:30ET open, 960=16:00ET close
20878  if (_open >= 0) { startIdx = _open; _replayEndIdx = _close; }
20879  else { _replayEndIdx = cs.length - 1; }   // sparse/odd day → whole thing
20880  _replayStartIdx = Math.max(0, Math.min(startIdx, Math.max(0, cs.length - 1)));
20881  _replayEndIdx = Math.max(_replayStartIdx, Math.min(_replayEndIdx, cs.length - 1));
20882}
20883function toggleReplay() { _replayActive ? exitReplay() : startReplay(); }
20884async function startReplay() {
20885  if (!chartTicker) return;
20886  // Remember the live chart's timeframe + scroll/zoom so exiting replay restores this exact view.
20887  _replayPrevTf = currentTimeframe;
20888  try { _replayPrevRange = (chartObj && chartObj.timeScale) ? chartObj.timeScale().getVisibleLogicalRange() : null; } catch (e) { _replayPrevRange = null; }
20889  showToast('⏳ Loading replay…');
20890  // Which days are available (last 7)? Default to the latest.
20891  try { const dj = await (await fetch('/api/replay-dates')).json(); _replayDates = (dj && dj.dates) || []; _replayToday = (dj && dj.today) || ''; if (!_replayDate) _replayDate = _replayDates[0] || null; } catch (e) { _replayDates = []; }
20892  await _loadReplayCandles();
20893  if (_replayCandles.length < 5) { showToast('Not enough recorded to replay that day'); return; }
20894  const _dq = (_replayDate && _replayDate !== _replayToday) ? ('?date=' + encodeURIComponent(_replayDate)) : '';
20895  try { const j = await (await fetch('/api/replay/' + encodeURIComponent(chartTicker) + _dq)).json(); _replaySnaps = (j && j.snaps) || []; } catch (e) { _replaySnaps = []; }
20896  // If today wasn't recorded (or only partially), fall back to the current live levels so the
20897  // King / Call / Put lines still show during replay instead of disappearing.
20898  _replayFallbackLevels = null; _replayActiveNode = null;
20899  try { const lv = await (await fetch('/api/levels/' + encodeURIComponent(chartTicker))).json(); if (lv && !lv.error) _replayFallbackLevels = { k: lv.king, cw: lv.callWall, pw: lv.putWall, vn: lv.volNode, s: lv.spot }; } catch (e) {}
20900  try { const an = await (await fetch('/api/active-node/' + encodeURIComponent(chartTicker))).json(); if (an && an.strike) _replayActiveNode = an.strike; } catch (e) {}
20901  _replayActive = true;
20902  try { _chartMarks.flow = []; _chartMarks.pol = []; _applyChartMarks(); _fmApplied = false; if (typeof _polClearBadges === 'function') _polClearBadges(); } catch (e) {}   // flow marks off the recorded candle set
20903  _replayTicker = chartTicker;   // bind this replay to the ticker it was started on
20904  const _rb = document.getElementById('replayBtn'); if (_rb) _rb.classList.add('active');
20905  _replayIdx = Math.min(_replayEndIdx + 1, _replayStartIdx + 1);   // begin AT the 6:30am open candle
20906  buildReplayBar();
20907  renderReplayFrame();   // draw candles first so setVisibleRange has data to frame
20908  _replaySetupView();
20909  showToast('▶ Press play to start the replay');   // paused — user starts it
20910}
20911async function setReplayTf(tf) {
20912  _replayTf = tf;
20913  document.querySelectorAll('.replayTfBtn').forEach(x => { const a = x.dataset.tf === tf; x.classList.toggle('on', a); x.style.background = a ? 'rgba(59,130,246,.18)' : '#0e1320'; });
20914  const wasPlaying = !!_replayTimer; pauseReplay();
20915  await _loadReplayCandles();
20916  _replayIdx = Math.min(_replayEndIdx + 1, _replayStartIdx + 1);   // begin AT the 6:30am open candle
20917  renderReplayFrame();
20918  _replaySetupView();
20919  if (wasPlaying) playReplay();
20920}
20921function exitReplay(skipReload) {
20922  pauseReplay(); _replayActive = false; _replayTicker = null; _replayDate = null; // re-entry defaults to the latest day
20923  const bar = document.getElementById('replayBar'); if (bar) bar.remove();
20924  const rb = document.getElementById('replayBtn'); if (rb) rb.classList.remove('active');
20925  if (_replayRange) { try { chartObj.removeSeries(_replayRange); } catch (e) {} _replayRange = null; }
20926  // Replay left window.chartCharmLevels (and the EOD pin/band) holding a RECORDED frame — pull the
20927  // live ones back so those modes don't stay parked at whatever minute the playhead stopped on.
20928  try { window._chartEodPin = null; window._chartEodBand = null; window._chartEod = null; } catch (e) {}
20929  try { if (typeof computeCharmLevels === 'function') computeCharmLevels(true); } catch (e) {}
20930  // skipReload = the caller (a ticker switch) is about to load the chart itself — don't double-load.
20931  if (!skipReload && chartTicker) {
20932    // Restore the timeframe + scroll/zoom the chart had when replay was opened.
20933    const prevTf = _replayPrevTf, prevRange = _replayPrevRange;
20934    if (prevTf && currentTimeframe !== prevTf) { currentTimeframe = prevTf; renderTfBar(); }
20935    loadGEXChart(chartTicker); // restore the live chart
20936    if (prevRange) { setTimeout(() => { try { chartObj.timeScale().setVisibleLogicalRange(prevRange); } catch (e) {} }, 450); }
20937  }
20938  _replayPrevTf = null; _replayPrevRange = null;
20939}
20940function renderReplayFrame() {
20941  if (!_replayActive || !chartSeries) return;
20942  const slice = _replayCandles.slice(0, _replayIdx);
20943  try { chartSeries.setData(slice); } catch (e) {}
20944  // Follow the playhead while PLAYING: when it nears the right edge, slide the window forward at
20945  // the user's current zoom width (pinching sticks). Paused = no auto-slide, pan freely.
20946  if (_replayTimer) {
20947    try {
20948      const ts = chartObj.timeScale(), vr = ts.getVisibleLogicalRange();
20949      const head = _replayIdx - 1;
20950      if (vr && head > vr.to - 8) { const w = vr.to - vr.from; ts.setVisibleLogicalRange({ from: head + 8 - w, to: head + 8 }); }
20951    } catch (e) {}
20952  }
20953  const cur = _replayCandles[_replayIdx - 1]; if (!cur) return;
20954  const curTs = (cur._ts != null ? cur._ts : (typeof tsForCandleTime === 'function' ? tsForCandleTime(cur.time) : 0));
20955  const ctMs = curTs * 1000;
20956  let snap = null; for (const s of _replaySnaps) { if (s.t <= ctMs) snap = s; else break; }
20957  if (!snap) snap = _replaySnaps[0] || _replayFallbackLevels; // before first snapshot → earliest/live levels
20958  // Replay the LEFT GEX PANEL too — rebuild it from the snapshot's recorded per-strike gamma so the
20959  // panel scrubs in sync with the chart. Guarded + only if the column element exists; exitReplay's
20960  // loadGEXChart restores the live panel afterward. (Older snapshots have no .g → panel just holds.)
20961  const _gsrc = snap ? ((typeof gridExpiryMode !== 'undefined' && gridExpiryMode === '0dte' && snap.g0 && snap.g0.length) ? snap.g0 : snap.g) : null;
20962  if (_gsrc && Array.isArray(_gsrc) && _gsrc.length && typeof columnData !== 'undefined' && typeof renderColumn === 'function') {
20963    try {
20964      const _rtk = _replayTicker || chartTicker;
20965      // Was gated on the scanner column existing, which meant chart view never reached any of this.
20966      // Both renderers are individually guarded below, so run whenever either surface is present.
20967      if (_rtk && (document.getElementById('col-body-' + _rtk) || document.getElementById('chartGexPanel'))) {
20968        const _rs = _gsrc.map(p => { const g = +p[1]; return { strike: +p[0], netGex: g, callGex: g >= 0 ? g : 0, putGex: g < 0 ? g : 0 }; }).filter(s => s.strike > 0);
20969        if (_rs.length) {
20970          let _tot = 0, _king = null;
20971          for (const s of _rs) { _tot += s.netGex; if (!_king || Math.abs(s.netGex) > Math.abs(_king.netGex)) _king = s; }
20972          const _findK = pr => { if (pr == null) return null; let b = null, bd = Infinity; for (const s of _rs) { const d = Math.abs(s.strike - pr); if (d < bd) { bd = d; b = s; } } return b; };
20973          // CHART-SIDE LADDER TOO (user 2026-09-09: "can we do this replay right on that left panel
20974          // grid in actual view with chart replay as well"). The block below already replayed the
20975          // SCANNER column - it is guarded on col-body-<ticker>, which only exists in the grid view -
20976          // so in chart view the left ladder beside the chart kept showing LIVE numbers while the
20977          // candles scrubbed through the day. Same recorded rows, second renderer.
20978          //
20979          // renderChartGexPanel wants {strike, netGex} and reads .sb only for the ES/SPX proxy shift,
20980          // which recorded rows do not carry, so the plain shape is enough. _cgpLast holds the
20981          // content element the live path last painted into; without it there is nothing to paint on
20982          // and the ladder simply holds, which is the same behaviour as a snapshot with no .g.
20983          columnData[_rtk] = { strikes: _rs, spot: snap.s || 0, kingNode: (snap.k != null ? _findK(snap.k) : _king), callWall: _findK(snap.cw), putWall: _findK(snap.pw), volNode: (snap.vn != null ? { strike: snap.vn } : null), totalGex: _tot };
20984          // the data is in; the paint is coalesced and waits for any scroll to settle
20985          _gexTickPaint(_rtk, { rs: _rs, spot: snap.s || window._cgpLast && window._cgpLast.spot, vn: (snap.vn != null ? snap.vn : (window._cgpLast ? window._cgpLast.volNode : null)) });
20986        }
20987      }
20988    } catch (e) {}
20989  }
20990  for (const pl of chartPriceLines) { try { chartSeries.removePriceLine(pl); } catch (e) {} }
20991  chartPriceLines = [];
20992  // Clear the live "GEX Levels" mode lines too — replay draws its own titled King/Call/Put/Vol below,
20993  // and leaving the live ones up double-marks every node on the price scale.
20994  for (const pl of gexLevelLines) { try { chartSeries.removePriceLine(pl); } catch (e) {} }
20995  gexLevelLines = [];
20996  const LS = LightweightCharts.LineStyle;
20997  const fb = _replayFallbackLevels || {};
20998  // On the CHARM metric, replay the recorded charm levels (cyan/amber) instead of gamma. Days
20999  // recorded before charm-replay shipped have no charm levels → fall back to gamma.
21000  const _cx = (currentMetric === 'charm');
21001  _updateReplayMetricTag();
21002  if (_cx) {
21003    // CHARM metric → mirror the live Charm Levels mode exactly: Charm Positive (cyan),
21004    // Charm Negative (amber), Charm Flip (purple). No gamma King/Vol/Active here — those are
21005    // gamma-only levels and don't belong on a charm read. (ccf is recorded going forward;
21006    // older days have no flip → that line is just omitted.)
21007    const cwv = snap ? (snap.ccw != null ? snap.ccw : snap.cw) : fb.cw;
21008    const pwv = snap ? (snap.cpw != null ? snap.cpw : snap.pw) : fb.pw;
21009    const flv = snap ? snap.ccf : null;
21010    if (cwv) chartPriceLines.push(chartSeries.createPriceLine({ price: cwv, color: '#22d3ee', lineWidth: 2, lineStyle: LS.Solid, axisLabelVisible: true, title: 'Charm Positive' }));
21011    if (pwv) chartPriceLines.push(chartSeries.createPriceLine({ price: pwv, color: '#f59e0b', lineWidth: 2, lineStyle: LS.Solid, axisLabelVisible: true, title: 'Charm Negative' }));
21012    if (flv) chartPriceLines.push(chartSeries.createPriceLine({ price: flv, color: '#a855f7', lineWidth: 2, lineStyle: LS.Dashed, axisLabelVisible: true, title: 'Charm Flip' }));
21013  } else {
21014    // GEX metric → gamma King / Call Wall / Put Wall / Vol / Active.
21015    const lvl = {
21016      k: snap ? snap.k : fb.k, cw: snap ? snap.cw : fb.cw, pw: snap ? snap.pw : fb.pw,
21017      vn: (snap && snap.vn != null) ? snap.vn : fb.vn,
21018      an: (snap && snap.an != null) ? snap.an : _replayActiveNode,   // recorded going forward; live as fallback
21019    };
21020    if (lvl.k) chartPriceLines.push(chartSeries.createPriceLine({ price: lvl.k, color: '#eab308', lineWidth: 2, lineStyle: LS.Dashed, axisLabelVisible: true, title: 'King' }));
21021    if (lvl.cw) chartPriceLines.push(chartSeries.createPriceLine({ price: lvl.cw, color: '#22c55e', lineWidth: 2, lineStyle: LS.Solid, axisLabelVisible: true, title: 'Call' }));
21022    if (lvl.pw) chartPriceLines.push(chartSeries.createPriceLine({ price: lvl.pw, color: '#ef4444', lineWidth: 2, lineStyle: LS.Solid, axisLabelVisible: true, title: 'Put' }));
21023    if (lvl.vn) chartPriceLines.push(chartSeries.createPriceLine({ price: lvl.vn, color: '#3b82f6', lineWidth: 2, lineStyle: LS.Dashed, axisLabelVisible: true, title: 'Vol' }));
21024    if (lvl.an) chartPriceLines.push(chartSeries.createPriceLine({ price: lvl.an, color: '#22d3ee', lineWidth: 2, lineStyle: LS.Dotted, axisLabelVisible: true, title: 'Active' }));
21025  }
21026  // Charm Levels MODE scrubs with the playhead too. It used to keep drawing the LIVE charm levels —
21027  // frozen at wherever they are right now — straight through the replay, which read as "charm never
21028  // moved today". The recorded ccw/cpw/ccf drive it instead (they've been recorded all along), so the
21029  // cyan/amber/purple lines AND the bands behind the candles walk the day like everything else.
21030  // On the CHARM metric the block above already draws these lines, so there we only feed the bands.
21031  try {
21032    if (typeof indState !== 'undefined' && indState.charm && snap && snap.ccw != null && snap.cpw != null) {
21033      window.chartCharmLevels = { hi: { strike: snap.ccw }
21033, lo: { strike: snap.cpw }, flip: (snap.ccf != null ? snap.ccf : null) };
21034      if (_cx) { for (const pl of charmPriceLines) { try { chartSeries.removePriceLine(pl); } catch (e) {} } charmPriceLines = []; }
21035      else if (typeof applyCharmPriceLines === 'function') applyCharmPriceLines();
21036      if (typeof redrawDrawings === 'function') redrawDrawings();   // repaint the charm bands at this frame
21037    }
21038  } catch (e) {}
21039  // EOD PIN scrubs with the playhead too — the recorded pin/band for THIS moment (snap.ep/eb),
21040  // so replay shows exactly what the chart showed then and you can judge it against the close.
21041  // Days recorded before the pin was added have no .ep → the pin is simply hidden in replay.
21042  try {
21043    if (snap && snap.ep != null) {
21044      window._chartEodPin = snap.ep;
21045      window._chartEodBand = (snap.eb != null) ? { lo: snap.ep * (1 - snap.eb / 100), hi: snap.ep * (1 + snap.eb / 100), pct: snap.eb } : null;
21046      window._chartEod = { pin: snap.ep, conf: snap.epc || null, bandPct: snap.eb != null ? snap.eb : null };
21047    } else if (_replayActive) { window._chartEodPin = null; window._chartEodBand = null; }
21048  } catch (e) {}
21049  // Slider tracks only the SELECTED day (open→close), not the multi-day history behind it.
21050  const sl = document.getElementById('replaySlider'); if (sl) { const dayLen = Math.max(1, _replayEndIdx + 1 - _replayStartIdx); sl.value = Math.round(Math.max(0, Math.min(100, (_replayIdx - _replayStartIdx) / dayLen * 100))); }
21051  const tl = document.getElementById('replayTime');
21052  if (tl) tl.textContent = fmtTime(ctMs) + ' ' + tzAbbr(ctMs) + (snap ? '' : '  · no level data');
21053  if (_gexBubAllowed() && typeof indState !== 'undefined' && indState.gexbub && typeof renderGexBubbles === 'function') renderGexBubbles();   // scrub the GEX Visual trail with the playhead
21054}
21055function stepReplay() { if (_replayIdx > _replayEndIdx) { pauseReplay(); return; } _replayIdx++; renderReplayFrame(); }   // stop at the RTH close (1pm PT)
21056function playReplay() { pauseReplay(); _replayTimer = setInterval(stepReplay, Math.max(40, 220 / _replaySpeed)); const b = document.getElementById('replayPlay'); if (b) b.textContent = '⏸'; }
21057function pauseReplay() { if (_replayTimer) { clearInterval(_replayTimer); _replayTimer = null; } const b = document.getElementById('replayPlay'); if (b) b.textContent = '▶'; }
21058function replayPlayPause() { _replayTimer ? pauseReplay() : (_replayIdx > _replayEndIdx ? (_replayIdx = _replayStartIdx, playReplay()) : playReplay()); }
21059function setReplaySpeed(s) { _replaySpeed = s; document.querySelectorAll('.replaySpd').forEach(x => { const a = parseFloat(x.dataset.s) === s; x.classList.toggle('on', a); x.style.background = a ? 'rgba(59,130,246,.18)' : '#0e1320'; }); if (_replayTimer) playReplay(); }
21060function replaySeek(pct) { const dayLen = Math.max(1, _replayEndIdx + 1 - _replayStartIdx); _replayIdx = Math.min(_replayEndIdx + 1, Math.max(_replayStartIdx, _replayStartIdx + Math.round(dayLen * pct / 100))); renderReplayFrame();
21061  // Seek can land the playhead far off-view — recenter it (70/30 behind/ahead) at the current zoom width.
21062  try { const ts = chartObj.timeScale(), vr = ts.getVisibleLogicalRange(); const head = _replayIdx - 1;
21063    if (vr && (head < vr.from || head > vr.to)) { const w = vr.to - vr.from; ts.setVisibleLogicalRange({ from: head - w * 0.7, to: head + w * 0.3 }); } } catch (e) {}
21064}
21065// Switch the replay to a different recorded day (last 7). Reloads that day's candles + snaps.
21066async function setReplayDate(date) {
21067  if (!date || date === _replayDate) return;
21068  _replayDate = date;
21069  const wasPlaying = !!_replayTimer; pauseReplay();
21070  await _loadReplayCandles();
21071  const _dq = (_replayDate && _replayDate !== _replayToday) ? ('?date=' + encodeURIComponent(_replayDate)) : '';
21072  try { const j = await (await fetch('/api/replay/' + encodeURIComponent(chartTicker) + _dq)).json(); _replaySnaps = (j && j.snaps) || []; } catch (e) { _replaySnaps = []; }
21073  _replayIdx = Math.min(_replayEndIdx + 1, _replayStartIdx + 1);   // begin AT the 6:30am open candle
21074  if (_replayCandles.length < 5) showToast('No candles for that day at this timeframe');
21075  renderReplayFrame(); _replaySetupView();
21076  if (wasPlaying && _replayCandles.length >= 5) playReplay();
21077}
21078function _replayDateLabel(d) {
21079  if (!d) return 'Latest';
21080  if (d === _replayToday) return 'Today';
21081  const p = d.split('-').map(Number); const dt = new Date(p[0], p[1] - 1, p[2]);
21082  return dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
21083}
21084function _updateReplayMetricTag() {
21085  const t = document.getElementById('replayMetricTag'); if (!t) return;
21086  const m = (typeof currentMetric !== 'undefined') ? currentMetric : 'gex';
21087  const map = { charm: ['CHARM', '#22d3ee'], vex: ['VEX', '#a78bfa'], gex: ['GEX', '#22c55e'] };
21088  const spec = map[m] || map.gex;
21089  t.textContent = spec[0]; t.style.color = spec[1]; t.style.border = '1px solid ' + spec[1];
21090  t.style.background = spec[1].replace('#', 'rgba(') ? ('rgba(' + parseInt(spec[1].slice(1,3),16) + ',' + parseInt(spec[1].slice(3,5),16) + ',' + parseInt(spec[1].slice(5,7),16) + ',0.14)') : 'transparent';
21091  // Expiry-basis badge: whether the replay is scrubbing 0DTE snapshots (snap.g0) or ALL expiries (snap.g).
21092  const et = document.getElementById('replayExpiryTag'); if (et) {
21093    const is0 = (typeof gridExpiryMode !== 'undefined' && gridExpiryMode === '0dte');
21094    const ec = is0 ? '#f59e0b' : '#38bdf8';
21095    et.textContent = is0 ? '0DTE' : 'ALL';
21096    et.style.color = ec; et.style.border = '1px solid ' + ec;
21097    et.style.background = 'rgba(' + parseInt(ec.slice(1,3),16) + ',' + parseInt(ec.slice(3,5),16) + ',' + parseInt(ec.slice(5,7),16) + ',0.14)';
21098  }
21099}
21100// Toggle the replay exposure between GEX and CHARM, then re-scrub the current frame.
21101// (VEX has no recorded ladder yet, so the toggle is GEX <-> CHARM only.)
21102function toggleReplayMetric() {
21103  if (typeof currentMetric === 'undefined') return;
21104  const next = (currentMetric === 'charm') ? 'gex' : 'charm';
21105  if (next === 'charm' && typeof isPaidUser !== 'undefined' && !isPaidUser) { try { showToast('🔒 CHARM is a Premium feature'); } catch (e) {} return; }
21106  currentMetric = next;
21107  try { _syncColCfgLabels(); } catch (e) {}
21108  try { if (_vsPerView()) _vsBank(_vsKey(currentView)); } catch (e) {}
21109  try { document.querySelectorAll('.metric-btn').forEach(b => b.classList.toggle('active', b.dataset.metric === next)); } catch (e) {}
21110  if (typeof _gexBubRefreshSoon === 'function') _gexBubRefreshSoon();   // switch the GEX Visual trail to this metric
21111  if (typeof renderReplayFrame === 'function') renderReplayFrame();
21112  _updateReplayMetricTag();
21113}
21114// Toggle the replay basis between ALL expiries and 0DTE, then re-scrub the current frame.
21115function toggleReplayExpiry() {
21116  if (typeof gridExpiryMode === 'undefined') return;
21117  gridExpiryMode = (gridExpiryMode === '0dte') ? 'all' : '0dte';
21118  try { localStorage.setItem('gf_grid_expiry', gridExpiryMode); } catch (e) {}
21119  try { document.querySelectorAll('.gexp-btn').forEach(b => b.classList.toggle('active', b.dataset.exp === gridExpiryMode)); } catch (e) {}
21120  if (typeof _gexBubTrail !== 'undefined') { try { _gexBubTrail = {}; } catch (e) {} }   // basis changed → rebuild the Visual trail
21121  if (typeof renderReplayFrame === 'function') renderReplayFrame();
21122  _updateReplayMetricTag();
21123}
21124function buildReplayBar() {
21125  // Attach to whichever view is visible — in Ultra the chart is hosted in #ultraView, so
21126  // appending to #chartView (display:none) would hide the replay controls.
21127  const host = ((typeof currentView !== 'undefined' && currentView === 'ultra')
21128    ? document.getElementById('ultraView') : document.getElementById('chartView')) || document.body;
21129  let bar = document.getElementById('replayBar'); if (bar) bar.remove();
21130  bar = document.createElement('div'); bar.id = 'replayBar';
21131  bar.style.cssText = 'position:absolute;left:50%;top:54px;transform:translateX(-50%);z-index:60;display:flex;align-items:center;gap:10px;background:rgba(10,14,22,.95);border:1px solid var(--border);border-radius:10px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.5);font-family:Segoe UI,monospace;font-size:12px';
21132  bar.innerHTML =
21133    '<button id="replayPlay" onclick="replayPlayPause()" style="background:var(--t-accent,#3b82f6);border:none;color:var(--t-onAccent,#fff);border-radius:6px;width:30px;height:28px;cursor:pointer;font-size:13px">▶</button>'
21134    + '<span id="replayMetricTag" onclick="toggleReplayMetric()" title="Replay exposure — click to switch between GEX and CHARM" style="cursor:pointer;font-weight:800;letter-spacing:.05em;padding:3px 8px;border-radius:5px;font-size:11px"></span>'
21135    + '<span id="replayExpiryTag" onclick="toggleReplayExpiry()" title="Replay basis — click to switch between ALL expiries and 0DTE" style="cursor:pointer;font-weight:800;letter-spacing:.05em;padding:3px 8px;border-radius:5px;font-size:11px"></span>'
21136    + (_replayDates.length ? '<select id="replayDateSel" onchange="setReplayDate(this.value)" title="Replay a past day (last 7)" style="background:var(--t-bg2,#0e1320);border:1px solid var(--border);color:#cbd5e1;border-radius:6px;padding:4px 6px;cursor:pointer;font-family:inherit;font-size:12px;max-width:120px">'
21137        + _replayDates.map(function(d){return '<option value="'+d+'"'+(d===_replayDate?' selected':'')+'>'+_replayDateLabel(d)+'</option>';}).join('')
21138        + '</select>' : '')
21139    + '<span style="color:#8b9cb5">TF</span>'
21140    + ['1m','5m','10m','15m'].map(tf=>'<button class="replayTfBtn'+(tf===_replayTf?' on':'')+'" data-tf="'+tf+'" onclick="setReplayTf(\''+tf+'\')" style="background:'+(tf===_replayTf?'rgba(59,130,246,.18)':'#0e1320')+';border:1px solid var(--border);color:#cbd5e1;border-radius:6px;padding:4px 8px;cursor:pointer">'+tf+'</button>').join('')
21141    + '<span style="color:#8b9cb5;margin-left:4px">Speed</span>'
21142    + [0.5,1,1.5,2,3].map(s=>'<button class="replaySpd'+(s===1?' on':'')+'" data-s="'+s+'" onclick="setReplaySpeed('+s+')" style="background:'+(s===1?'rgba(59,130,246,.18)':'#0e1320')+';border:1px solid var(--border);color:#cbd5e1;border-radius:6px;padding:4px 8px;cursor:pointer">'+s+'x</button>').join('')
21143    + '<input id="replaySlider" type="range" min="0" max="100" value="0" oninput="replaySeek(this.value)" style="width:200px">'
21144    + '<span id="replayTime" style="color:#e2e8f0;min-width:96px;text-align:center"></span>'
21145    + '<button onclick="exitReplay()" title="Exit replay" style="background:none;border:1px solid var(--border);color:#94a3b8;border-radius:6px;padding:4px 8px;cursor:pointer">✕</button>';
21146  if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
21147  host.appendChild(bar);
21148  _updateReplayMetricTag();
21149  // Sit just below the chart's toolbar header (measured, so it never overlaps it).
21150  try { const hdr = document.getElementById('chartHeader'); if (hdr) { const hostR = host.getBoundingClientRect(), hdrR = hdr.getBoundingClientRect(); bar.style.top = Math.max(12, Math.round(hdrR.bottom - hostR.top) + 8) + 'px'; } } catch (e) {}
21151  // Ghost Mode compact: the floating tf / Layers / Draw / Replay toolbar sits top-left of the chart - the centred replay bar
21152  // landed on top of it (user 2026-09-06). Line the replay bar up on the SAME row, just to the right of that toolbar.
21153  try {
21154    const ctl = document.getElementById('gfHdrChartCtl');
21155    if (ctl && ctl.offsetParent && getComputedStyle(ctl).visibility !== 'hidden') {
21156      const hostR = host.getBoundingClientRect(), cR = ctl.getBoundingClientRect(), bw = bar.offsetWidth || 700;
21157      // Below the toolbar, left-aligned with it (user 2026-09-07: to the right it still covered the timeframes on narrow layouts)
21158      const left = Math.max(12, Math.min(Math.round(cR.left - hostR.left), Math.round(hostR.width - bw - 12)));
21159      bar.style.left = left + 'px'; bar.style.transform = 'none'; bar.style.top = Math.round(cR.bottom - hostR.top) + 8 + 'px';
21160    }
21161  } catch (e) {}
21162}
21163
21164// ── Drawing tools ──
21165let drawMode = 'none';
21166
21167function setDrawMode(mode) {
21168  drawMode = mode;
21169  document.querySelectorAll('.draw-btn').forEach(b => b.classList.remove('active'));
21170  const btnId = 'draw' + mode.charAt(0).toUpperCase() + mode.slice(1);
21171  const btn = document.getElementById(btnId);
21172  if (btn) btn.classList.add('active');
21173  const chartEl = document.getElementById('tvChart');
21174  if (chartEl) chartEl.style.cursor = mode === 'none' ? 'default' : 'crosshair';
21175}
21176
21177function clearDrawings() {
21178  setDrawMode('none');
21179  if (chartTicker) loadGEXChart(chartTicker);
21180}
21181
21182// ══════════════════════════════════════════════
21183// ── ULTRA MODE — GEX Column + Expiry side by side
21184// ══════════════════════════════════════════════
21185let ultraTicker = null;
21186// FACETED FLOW FILTER (user 2026-08-25). Chips used to be a single value, so picking one replaced
21187// the last and combinations were impossible. Now it is a SET, grouped by what each chip measures:
21188// AND across groups, OR inside a group. Calls + OTM = OTM calls only; Calls + Puts = either type,
21189// which is the same as no type filter. One selection for every ticker, persisted per browser.
21190const FLOW_GROUPS = { type: ['calls','puts'], money: ['otm','itm'], expiry: ['0dte'], size: ['filtered','custom'] };
21191let ultraFlowSel = new Set();
21192try { const _s = JSON.parse(localStorage.getItem('gf_flowSel') || '[]'); if (Array.isArray(_s)) ultraFlowSel = new Set(_s); } catch (e) {}
21193function saveFlowSel(){ try { localStorage.setItem('gf_flowSel', JSON.stringify([...ultraFlowSel])); } catch (e) {} }
21194// 'all' is not a chip - it is the reset. Clicking any other chip toggles it.
21195function toggleFlowChip(k){
21196  if (k === 'all') ultraFlowSel.clear();
21197  else if (ultraFlowSel.has(k)) ultraFlowSel.delete(k);
21198  else ultraFlowSel.add(k);
21199  saveFlowSel();
21200  try { loadUltraFlow(chartTicker); } catch (e) {}
21201}
21202function nfFlowTest(k, f, spot, ticker){
21203  const T = String(f.type || f.option_type || '').toUpperCase();
21204  const isCall = T.includes('CALL');
21205  if (k === 'calls') return isCall;
21206  if (k === 'puts') return T.includes('PUT');
21207  if (k === 'otm' || k === 'itm') { if (!(spot > 0)) return true; return nfFlowMoneyness(f, spot) === k; }
21208  if (k === '0dte') return _isZeroDte(f);
21209  if (k === 'filtered') {
21210    const sz = parseInt(f.total_size || f.size || 0), pm = parseFloat(f.total_premium || f.premium || 0);
21211    const ap = parseFloat(f.total_ask_side_prem || 0), bp = parseFloat(f.total_bid_side_prem || 0);
21212    const tp = ap + bp, askP = tp > 0 ? (ap / tp) * 100 : 0;
21213    const th = nfFlowFilterThresh(ticker);
21214    return pm >= th.prem && sz >= th.size && askP >= 50;
21215  }
21216  if (k === 'custom') {
21217    const sz = parseInt(f.total_size || f.size || 0), pm = parseFloat(f.total_premium || f.premium || 0);
21218    return pm >= ultraFlowCustomPrem && sz >= ultraFlowCustomSize;
21219  }
21220  return true;
21221}
21222// A group with nothing selected imposes no constraint; otherwise the row must match ONE of its chips.
21223function nfFlowPass(f, spot, ticker){
21224  for (const g of Object.keys(FLOW_GROUPS)) {
21225    const on = FLOW_GROUPS[g].filter(k => ultraFlowSel.has(k));
21226    if (!on.length) continue;
21227    if (!on.some(k => nfFlowTest(k, f, spot, ticker))) return false;
21228  }
21229  return true;
21230}
21231let ultraFlowFilter = 'all';   // legacy: still used for the cross-ticker live-feed check below
21232// Custom flow filter thresholds (user-set): min premium ($) and min contract size.
21233let ultraFlowCustomPrem = 200000;
21234let ultraFlowCustomSize = 250;
21235// Per-ticker floor for the "Filtered" view — cheap-underlying ETFs (IWM) have small option
21236// premiums, so the default $200k/250 bar hides their flow. Give them a lower floor.
21237function nfFlowFilterThresh(tk){
21238  const T=(tk||'').toUpperCase();
21239  if(T==='IWM') return { prem:40000, size:100 };
21240  // Index options are huge per contract, so a $500k trade is only a few contracts — a 250-lot floor
21241  // rejects ALL index flow. Filter these by PREMIUM only (matches the Discord index channels).
21242  if(['SPX','SPXW','NDX','NDXP','RUT','VIX'].includes(T)) return { prem:150000, size:0 };
21243  return { prem:100000, size:250 };
21244}
21245function applyFlowCustom(){
21246  const p = document.getElementById('flowCustPrem'), s = document.getElementById('flowCustSize');
21247  if (p) { const v = parseFloat(String(p.value).replace(/[^0-9.]/g,'')); if (v >= 0) ultraFlowCustomPrem = v; }
21248  if (s) { const v = parseInt(s.value); if (v >= 0) ultraFlowCustomSize = v; }
21249  ultraFlowFilter = 'custom';
21250  loadUltraFlow(chartTicker);
21251}
21252// OTM / ITM test for a flow trade relative to spot.
21253function nfFlowMoneyness(f, spot){
21254  // MONEYNESS AT THE TIME OF THE TRADE (user 2026-08-25: the Flow tab and this panel disagreed).
21255  // Each print carries the underlying price it executed at; judging a 10am trade against the 4pm
21256  // price mislabels it. The Flow tab already used f.underlying_price, so matching it makes the two
21257  // agree AND is the more honest read - it is what the trader was buying at the time. On SPY today
21258  // the two methods disagreed on 543 of 8000 rows (6.8%), and SPY only ranged 763-767.
21259  const _at = parseFloat(f.underlying_price || 0);
21260  if (_at > 0) spot = _at;
21261  if (!(spot > 0)) return 'na';
21262  const strike = parseFloat(f.strike || f.strike_price || 0);
21263  const isCall = (String(f.type || f.option_type || '').toUpperCase().includes('CALL'));
21264  if (!(strike > 0)) return 'na';
21265  const itm = isCall ? (strike < spot) : (strike > spot);
21266  return itm ? 'itm' : 'otm';
21267}
21268
21269// ── Live WebSocket stream via SSE ──
21270let sseSource = null;
21271let wsLive = false;
21272
21273function connectLiveStream() {
21274  if (sseSource) { sseSource.close(); }
21275  sseSource = new EventSource('/api/live-stream');
21276  // On (re)connect, pull any alerts that fired while we were away so the feed matches the emails.
21277  sseSource.onopen = function() { try { gfCheckVersion(); } catch (e) {} try { backfillAlerts(); } catch (e) {} try { backfillFlowOnReconnect(); } catch (e) {} };
21278
21279  sseSource.onmessage = function(event) {
21280    try {
21281      const data = JSON.parse(event.data);
21282      
21283      if (data.type === 'status') {
21284        wsLive = data.connected;
21285        updateLiveIndicator();
21286        return;
21287      }
21288      
21289      if (data.type === 'movealert') { try { onMoveAlert(data); } catch (e) {} return; }
21290
21291      if (data.type === 'flow' && data.trade) {
21292        // FLOW tab (admin, live): the SSE firehose isn't demo-delayed, so only feed it for
21293        // admins. Demo's Flow tab polls the delayed REST endpoints instead (see _flowPollDemo).
21294        if (gfLive('flow') && !IS_DEMO && typeof flowIngest === 'function' && _flowIsPaid()) flowIngest(data.trade);
21295        // If on Ultra Mode and trade matches current ticker, add it live
21296        if (currentView === 'ultra' && ultraTicker) {
21297          const tradeTicker = (data.trade.ticker_symbol || data.trade.ticker || '').toUpperCase();
21298          if (ultraFlowFilter === 'all' || tradeTicker === ultraTicker) {
21299            addLiveTradeToFlow(data.trade);
21300          }
21301        }
21302      }
21303      
21304      if (data.type === 'gex' && data.ticker) {
21305        lastViewRefresh = Date.now(); // a live nudge arrived → WS healthy, no fallback needed
21306        // Live GEX update — silently refresh if viewing this ticker
21307        if (currentView === 'ultra' && ultraTicker === data.ticker) {
21308          loadUltraView(data.ticker, true);
21309        }
21310        // Also refresh GEX view column if visible
21311        if (currentView === 'gex' && activeColumns.includes(data.ticker)) {
21312          loadGEX(data.ticker);
21313        }
21314        // GEX charts — live update of the King/CW/PW/VN levels (throttled; keeps pan/zoom).
21315        if (currentView === 'chart' && chartTicker === data.ticker) {
21316          const _now = Date.now();
21317          if (_now - _lastChartLiveRefresh > 5000) {
21318            _lastChartLiveRefresh = _now;
21319            delete chartLevelCache[data.ticker];   // fresh GEX levels; candles stay cached
21320            loadGEXChart(data.ticker, true);
21321          }
21322        }
21323        // Heatmap — live update on the same stream as the other views (throttled).
21324        if (gfLive('expiry') && expiryTicker === data.ticker) {   // heatmap visible on its own tab OR as a My HQ tile (user 2026-09-16)
21325          const _now = Date.now();
21326          if (_now - _lastHeatmapLiveRefresh > 5000) { _lastHeatmapLiveRefresh = _now; loadExpiryGEX(data.ticker, true); }
21327        }
21328      }
21329    } catch (e) {}
21330  };
21331  
21332  sseSource.onerror = function() {
21333    wsLive = false;
21334    updateLiveIndicator();
21335  };
21336}
21337
21338// Market is "live" only Mon–Fri 9:30am–4:00pm ET (handles DST via toLocaleString).
21339// Deribit-native crypto tickers: 24/7, no equity session — every market-hours gate exempts them.
21340const CRYPTO_NATIVE_TICKERS = ['BTC', 'ETH', 'SOL', 'XRP', 'DOGE', 'LTC'];
21341function _gfDeribitUrl(tk){ tk = String(tk || '').toUpperCase(); return 'https://www.deribit.com/options/' + (/^(BTC|ETH)$/.test(tk) ? tk : tk + '_USDC'); }   // Deribit chain page for a crypto ticker (2026-09-07)
21342function isCryptoTk(t) { return CRYPTO_NATIVE_TICKERS.includes(String(t || '').toUpperCase()); }
21343function cryptoViewLive() { try { return (currentView === 'chart' && isCryptoTk(chartTicker)) || (currentView === 'gex' && activeGroup === 'Crypto') || (currentView === 'expiry' && typeof expiryTicker !== 'undefined' && isCryptoTk(expiryTicker)) || (currentView === 'ultra' && typeof ultraTicker !== 'undefined' && isCryptoTk(ultraTicker)); } catch (e) { return false; } }
21344// Contract identity: explicit strike / expiry / type when the row carries them (Deribit crypto), else the OCC symbol tail.
21345function _occParts(c) {
21346  if (c && c.strike != null && c.expiry && c.type) { return { e: String(c.expiry).slice(0, 10), cp: (String(c.type)[0] || '').toUpperCase() === 'C' ? 'C' : 'P', strike: +c.strike }; }
21347  const mt = String((c && c.option_symbol) || '').match(/(\d{6})([CP])(\d{8})$/); if (!mt) return null;
21348  return { e: '20' + mt[1].slice(0, 2) + '-' + mt[1].slice(2, 4) + '-' + mt[1].slice(4, 6), cp: mt[2], strike: parseInt(mt[3], 10) / 1000 };
21349}
21350function isMarketOpen() {
21351  try {
21352    const et = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' }));
21353    const day = et.getDay();
21354    if (day === 0 || day === 6) return false; // weekend
21355    if (typeof gfIsMarketHoliday === 'function' && gfIsMarketHoliday()) return false; // NYSE holiday
21356    const mins = et.getHours() * 60 + et.getMinutes();
21357    return mins >= 570 && mins < 960; // 570 = 9:30, 960 = 16:00
21358  } catch (e) { return false; }
21359}
21360// Extended session: weekdays 4am–8pm ET (= 1am–5pm PT) — pre-market through after-hours.
21361// Node alerts poll only in this window; overnight the GEX data is frozen and prices are stale.
21362function isExtendedSession() {
21363  try {
21364    const et = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' }));
21365    const day = et.getDay();
21366    if (day === 0 || day === 6) return false; // weekend
21367    if (typeof gfIsMarketHoliday === 'function' && gfIsMarketHoliday()) return false; // NYSE holiday
21368    const mins = et.getHours() * 60 + et.getMinutes();
21369    return mins >= 240 && mins < 1200; // 240 = 4:00am, 1200 = 8:00pm
21370  } catch (e) { return false; }
21371}
21372// CME equity-index futures (ES) session: Sun 6pm ET → Fri 5pm ET, with a daily ~5–6pm ET halt.
21373// Used so the ES chart keeps refreshing candles overnight/Sunday when equity market is closed.
21374function isFuturesSession() {
21375  try {
21376    const et = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' }));
21377    const day = et.getDay(); // 0 Sun … 6 Sat
21378    const mins = et.getHours() * 60 + et.getMinutes();
21379    if (day === 6) return false;               // Saturday: closed
21380    if (day === 0) return mins >= 1080;        // Sunday: opens 6:00pm ET (1080)
21381    if (day === 5) return mins < 1020;         // Friday: closes 5:00pm ET (1020)
21382    return !(mins >= 1020 && mins < 1080);     // Mon–Thu: open except the 5–6pm ET maintenance halt
21383  } catch (e) { return false; }
21384}
21385
21386function updateLiveIndicator() {
21387  const el = document.getElementById('liveIndicator');
21388  if (!el) return;
21389  el.innerHTML = isMarketOpen()
21390    ? '<span style="color:#22c55e;font-size:9px;display:flex;align-items:center;gap:3px"><span style="width:5px;height:5px;border-radius:50%;background:#22c55e;display:inline-block;animation:pulse 2s infinite"></span>LIVE</span>'
21391    : '<span style="color:#8b9cb5;font-size:9px">OFFLINE</span>';
21392}
21393// Flip the indicator at market open/close even without other events.
21394setInterval(updateLiveIndicator, 30000);
21395
21396// 0DTE = the CONTRACT EXPIRES TODAY (ET) — keyed off the expiry date, not the execution time, so it
21397// means "today's expiry", not merely "traded today". Compared against _todayET() rather than the
21398// browser's local date so it stays correct for users outside Eastern.
21399function _isZeroDte(f) {
21400  const e = String((f && (f.expires || f.expiry || f.expiration || f.expiry_date)) || '').slice(0, 10);
21401  return !!e && e === _todayET();
21402}
21403
21404function addLiveTradeToFlow(trade) {
21405  const bodyEl = document.getElementById('ultraFlowBody');
21406  if (!bodyEl) return;
21407
21408  // KEEP THE DAY (user 2026-08-25): merge the live trade into the session store BEFORE any
21409  // filter runs, so turning a chip off brings back everything that arrived while it was on.
21410  // Filtering only ever HIDES rows, it never discards them.
21411  try { _ghostFlowMerge(ultraTicker, [trade]); } catch (e) {}
21412  // Same predicate the rendered list uses, so a live row can never disagree with a refresh.
21413  { let _sp = 0; try { _sp = (columnData[ultraTicker] && columnData[ultraTicker].spot) || 0; } catch (e) {}
21414    if (!nfFlowPass(trade, _sp, ultraTicker)) return; }
21415  
21416  const row = buildFlowRow(trade);
21417  if (!row) return;
21418  
21419  // Flash green/red for new trade
21420  const wrapper = document.createElement('div');
21421  wrapper.style.cssText = 'animation:fadeIn 0.5s ease;';
21422  wrapper.innerHTML = row;
21423  
21424  // Remove "no flow" message if present
21425  const noFlow = bodyEl.querySelector('.col-loading, [style*="text-align:center"]');
21426  if (noFlow && bodyEl.children.length <= 1) bodyEl.innerHTML = '';
21427  
21428  bodyEl.insertBefore(wrapper, bodyEl.firstChild);
21429  
21430  // Keep max 50 rows visible
21431  while (bodyEl.children.length > 50) bodyEl.removeChild(bodyEl.lastChild);
21432  
21433  // Update count in header
21434  const countEl = document.getElementById('flowCount');
21435  if (countEl) countEl.textContent = bodyEl.children.length;
21436}
21437
21438function buildFlowRow(f) {
21439  if (typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT) return buildFlowRowNew(f);
21440  const strike = f.strike || '?';
21441  const expiry = (f.expires || f.expiry || '').slice(5).replace('-', '/');
21442  const optType = (f.type || f.option_type || '').toUpperCase().includes('CALL') ? 'C' : 'P';
21443  const size = parseInt(f.total_size || f.size || 0);
21444  const prem = parseFloat(f.total_premium || f.premium || 0);
21445  const price = f.price ? parseFloat(f.price).toFixed(2) : '?';
21446  const askP = parseFloat(f.total_ask_side_prem || 0);
21447  const bidP = parseFloat(f.total_bid_side_prem || 0);
21448  const totalP = askP + bidP;
21449  const askPct = totalP > 0 ? Math.round((askP / totalP) * 100) : 0;
21450  const isBuy = askP > bidP;
21451  const isSweep = f.has_sweep === true || f.has_sweep === 'true';
21452  const premStr = prem >= 1000000 ? '$' + (prem/1000000).toFixed(1) + 'M' : prem >= 1000 ? '$' + (prem/1000).toFixed(0) + 'K' : '$' + prem.toFixed(0);
21453  const color = optType === 'C' ? (isBuy ? '#22c55e' : '#ef4444') : (isBuy ? '#ef4444' : '#22c55e');
21454  const ticker = f.ticker_symbol || f.ticker || '';
21455  const _tsA = f.created_at || f.timestamp || f.executed_at || ''; const _dA = _tsA ? new Date(_tsA) : null;
21456  const time = (_dA && !isNaN(_dA)) ? fmtTime(_dA, { hour: '2-digit', minute: '2-digit', hour12: true }) : String(_tsA).slice(11, 16); // user timezone, not raw UTC
21457  
21458  return `<div style="padding:4px 10px;border-bottom:1px solid rgba(255,255,255,0.03);font-family:Segoe UI,monospace;font-size:10px;display:flex;justify-content:space-between;align-items:center;background:${isBuy ? 'rgba(34,197,94,0.03)' : 'rgba(239,68,68,0.03)'}">
21459    <span style="color:${color};font-weight:700;width:50px">${ticker || ultraTicker}</span>
21460    <span style="color:#94a3b8;width:65px">${strike}${optType} ${expiry}</span>
21461    <span style="color:#e2e8f0;width:40px">${premStr}</span>
21462    <span style="color:${askPct > 50 ? '#22c55e' : '#ef4444'};width:35px">${askPct}%</span>
21463    <span style="color:#8b9cb5;width:30px;font-size:9px">${isSweep ? '⚡' : ''} ${time}</span>
21464  </div>`;
21465}
21466
21467// ── New-layout flow row (GF_NEW_LAYOUT): BUY/SELL CALL/PUT, per-contract price,
21468// sweep/block, Vol/OI/V-OI, TV + UW links, bid/ask bar, and a + track button. ──
21469function nfPrem(v){ return v>=1e6?'$'+(v/1e6).toFixed(1)+'M':v>=1e3?'$'+(v/1e3).toFixed(0)+'K':'$'+(v||0).toFixed(0); }
21470function nfK(v){ return v>=1e3?(v/1e3).toFixed(1)+'K':''+(v||0); }
21471function buildFlowRowNew(f, tk, showTicker, accCount){
21472  const strike = f.strike || '';
21473  const rawExp = (f.expires || f.expiry || f.expiry_date || '');
21474  const expShort = String(rawExp).slice(5,10).replace('-','/');
21475  const expFull = String(rawExp).slice(0,10);
21476  const optType = (f.type || f.option_type || '').toUpperCase().includes('CALL') ? 'C' : 'P';
21477  const size = isCryptoTk(tk) ? (parseFloat(f.total_size || f.size || 0) || 0) : (parseInt(f.total_size || f.size || 0) || 0);   // crypto: coins, fractional
21478  const prem = parseFloat(f.total_premium || f.premium || 0) || 0;
21479  let price = f.price ? parseFloat(f.price) : (size>0 ? prem/(size*100) : 0);
21480  const askP = parseFloat(f.total_ask_side_prem || 0), bidP = parseFloat(f.total_bid_side_prem || 0);
21481  const totalP = askP + bidP;
21482  const sideStr = String(f.side||'').toLowerCase();
21483  const askPct = totalP > 0 ? Math.round((askP/totalP)*100) : (sideStr==='ask'?100:(sideStr==='bid'?0:50));
21484  const isBuy = totalP > 0 ? (askP >= bidP) : (sideStr!=='bid');
21485  const dir = optType==='C' ? (isBuy?'botC':'soldC') : (isBuy?'botP':'soldP');
21486  const lbl = dir==='botC'?'BUY CALL':dir==='botP'?'BUY PUT':dir==='soldC'?'SELL CALL':'SELL PUT';
21487  const isSweep = f.has_sweep===true || f.has_sweep==='true';
21488  const vol = parseInt(f.volume||0)||0, oi = parseInt(f.open_interest||0)||0, voi = oi>0?vol/oi:0;
21489  const ticker = String(f.ticker_symbol || f.ticker || tk || (typeof ultraTicker!=='undefined'?ultraTicker:'') || '').toUpperCase();
21490  const _tsB = f.created_at || f.timestamp || f.executed_at || ''; const _dB = _tsB ? new Date(_tsB) : null;
21491  const time = (_dB && !isNaN(_dB)) ? fmtTime(_dB, { hour: '2-digit', minute: '2-digit', hour12: true }) : String(_tsB).slice(11,16); // user timezone, not raw UTC
21492  let dte='';
21493  if(expFull){ const ed=new Date(expFull+'T16:00:00'); if(!isNaN(ed)){ const dd=Math.ceil((ed-new Date())/86400000); dte = dd<=0?'0DTE':dd+'DTE'; } }
21494  let tvUrl='https://www.tradingview.com/chart/?symbol='+ticker, uwUrl='https://unusualwhales.com/stock/'+ticker+'/flow-overview';
21495  if(expFull && strike){
21496    const ed=new Date(expFull+'T12:00:00');
21497    if(!isNaN(ed)){
21498      const yy=String(ed.getFullYear()).slice(2), mm=String(ed.getMonth()+1).padStart(2,'0'), dd=String(ed.getDate()).padStart(2,'0');
21499      const sn=parseFloat(strike), sStr=sn%1===0?sn.toFixed(1):String(sn);
21500      tvUrl='https://www.tradingview.com/chart/?symbol='+ticker+yy+mm+dd+optType+sStr;
21501      const uwSym=ticker+yy+mm+dd+optType+String(Math.round(sn*1000)).padStart(8,'0');
21502      uwUrl='https://unusualwhales.com/flow/option_chains?chain='+encodeURIComponent(uwSym)+'&days=7&mins=30';
21503    }
21504  }
21505  const priceStr = price>0 ? price.toFixed(2) : '?';
21506  // Accumulation: how many prints on this exact contract are in the current feed (repeat flow).
21507  let _hits = 0; if (accCount) { _hits = accCount[ticker+'|'+strike+'|'+String(rawExp).slice(0,10)+'|'+optType] || 0; }
21508  const accBadge = _hits > 1 ? '<span class="nf-acc" title="'+_hits+' prints on this contract today">x'+_hits+'</span>' : '';
21509  return '<div class="nf-row nf-'+dir+'">'+
21510    '<div class="nf-top">'+
21511      (showTicker ? '<span class="flow-tkr-chip" onclick="flowFilterTicker(\''+ticker+'\')" title="filter the tape to '+ticker+'">'+ticker+'</span>' : '')+
21512      '<span class="nf-side '+dir+'">'+lbl+'</span>'+
21513      '<span class="nf-contract" onclick="window.open(\''+tvUrl+'\',\'_blank\')" title="open '+strike+optType+' on TradingView">'+strike+optType+' &middot; '+expShort+'</span>'+accBadge+
21514      '<span class="nf-time">'+(time?time+' &middot; ':'')+dte+'</span>'+
21515      '<button class="nf-trk" onclick="trackFlow(this)" data-ticker="'+ticker+'" data-strike="'+strike+'" data-expiry="'+expFull+'" data-type="'+optType+'" data-side="'+(isBuy?'buy':'sell')+'" data-entry="'+(price>0?price.toFixed(2):'')+'" title="add to Tracking">Track</button>'+
21516    '</div>'+
21517    '<div class="nf-mid">'+
21518      '<span class="nf-sz">'+(isCryptoTk(tk) ? (size.toLocaleString(undefined, { maximumFractionDigits: 2 })+' '+tk) : ('x'+size.toLocaleString()))+'</span>'+
21519      '<span class="nf-perct">@ $'+priceStr+'</span>'+
21520      '<span class="nf-prem">'+nfPrem(prem)+'</span>'+
21521      ((f.source==='deribit' || isCryptoTk(tk))
21522        ? '<a class="nf-uw" href="'+_gfDeribitUrl(tk)+'" target="_blank" rel="noopener" title="view the chain on Deribit">Deribit</a>'   /* crypto: Deribit does not tag sweeps/blocks (user 2026-09-07) */
21523        : '<span class="nf-tag '+(isSweep?'sweep':'block')+'">'+(isSweep?'Sweep':'Block')+'</span>'+'<a class="nf-uw" href="'+uwUrl+'" target="_blank" rel="noopener" title="view on Unusual Whales">UW</a>')+
21524    '</div>'+
21525    '<div class="nf-voi">Vol <b>'+nfK(vol)+'</b> &middot; OI <b>'+nfK(oi)+'</b> &middot; V/OI <b class="'+(voi>=2?'hi':'')+'">'+voi.toFixed(1)+'x</b></div>'+
21526    '<div class="nf-ba">'+
21527      '<span class="nf-bl">Bid '+(100-askPct)+'%</span>'+
21528      '<span class="nf-conv"><i style="width:'+askPct+'%"></i></span>'+
21529      '<span class="nf-al">Ask '+askPct+'%</span>'+
21530    '</div>'+
21531  '</div>';
21532}
21533// ═══════════════════════════════════════════════════════════════════════════
21534// FLOW tab (MVP) — full-screen live options flow across the whole watchlist.
21535// Admin-only for now (live, all tickers via the SSE firehose); the public demo
21536// gets SPY/TSLA on the ~15-min delayed REST feed. V2/V3 add leaderboard, net-
21537// premium bias, and the "AT THE WALL" GEX tagging.
21538// ═══════════════════════════════════════════════════════════════════════════
21539const FLOW_MAX = 15000;             // rolling cap — holds a full open→close session of size>=50 flow across the whole watchlist (was 8000)
21540let flowFeed = [];                  // newest-first
21541let flowSeen = new Set();           // dedupe keys (bounded)
21542let flowPreset = 'all';
21543let flowTickerFilter = null;
21544// Set when the tape was re-pointed while Flow was not on screen: the renders and the history pull
21545// are owed, and they are paid by the panel's fit() the moment it becomes visible.
21546let _flowTkDirty = false;
21547let flowPaused = false;
21548let flowAggOn = false;  // OFF = show all prints (per-print, filters apply per-print). ON = show ONLY strikes hit 2+ times (accumulation).
21549function _flowContractKey(f){ return (String(f.ticker_symbol||f.ticker||'').toUpperCase())+'|'+(f.strike||'')+'|'+(String(f.expiry||f.expires||f.expiry_date||'').slice(0,10))+'|'+((String(f.type||f.option_type||'').toUpperCase().includes('CALL'))?'C':'P'); }
21550// Accumulation ALERTS: notify when the same contract accumulates N prints (user-chosen threshold).
21551let flowAccAlert = 0;          // 0 = off, else threshold (2/3/5/10)
21552let _flowAccCount = {};        // contractKey -> prints seen this session
21553let _flowAccFired = new Set(); // contracts already alerted (so it fires once at the threshold)
21554function setFlowAccAlert(v){ flowAccAlert=parseInt(v)||0; _flowAccFired=new Set(); if(flowAccAlert>0) _enableFlowNotify(); _saveFlowSettings(); }
21555function _enableFlowNotify(){ try{ if(typeof Notification!=='undefined' && Notification.permission==='default') Notification.requestPermission(); }catch(e){} }
21556function _flowRing(){ try{ const C=window.AudioContext||window.webkitAudioContext; if(!C) return; const ctx=new C(); const o=ctx.createOscillator(), g=ctx.createGain(); o.type='sine'; o.frequency.value=880; g.gain.setValueAtTime(0.0001,ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.12,ctx.currentTime+0.02); g.gain.exponentialRampToValueAtTime(0.0001,ctx.currentTime+0.25); o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime+0.26); setTimeout(()=>{try{ctx.close();}catch(e){}},400); }catch(e){} }
21557function fireFlowAccAlert(trade, hits){
21558  const tk=String(trade.ticker_symbol||trade.ticker||'').toUpperCase();
21559  const cp=(String(trade.type||trade.option_type||'').toUpperCase().includes('CALL'))?'C':'P';
21560  const exp=String(trade.expiry||trade.expires||trade.expiry_date||'').slice(0,10);
21561  const msg=tk+' '+(trade.strike||'')+cp+' '+(exp?exp.slice(5).replace('-','/'):'')+' — '+hits+'x accumulation';
21562  try{ showToast('⚡ Flow accumulation: '+msg); }catch(e){}
21563  try{ _flowRing(); }catch(e){}
21564  try{ if(typeof Notification!=='undefined' && Notification.permission==='granted') new Notification('GHOST FLOW — Accumulation', { body: msg, tag: _flowContractKey(trade) }); }catch(e){}
21565}
21566let _flowDirty = false;
21567let _flowDemoTimer = null;
21568let _flowScopeSet = null;
21569function _flowDefaults(){ return { minPrem:0, minSize:0, minPrice:'', maxPrice:'', side:'all', type:'all', money:'all', sweep:false, block:false, floor:false, multileg:false, single:false, volGtOi:false, askGtBid:false, opening:false, repeat:false, minDte:'', maxDte:'', minIv:'', ticker:'', strike:'', dateFrom:'', dateTo:'' }; }
21570// Per-tab filters: "All" is the base every preset inherits. Each other preset stores only the
21571// fields you changed ON THAT tab (flowOv) + its own built-in defaults (FLOW_PRESET_DEFAULTS), so
21572// e.g. setting 0DTE's premium to $50k sticks to 0DTE while the others still use All's $100k.
21573let flowBase = _flowDefaults();          // the "All" tab (base for all presets)
21574let flowOv = {};                          // { whales:{...}, calls:{...}, ... } — per-preset overrides
21575const FLOW_PRESET_DEFAULTS = {
21576  whales: { minPrem:500000, minSize:500, side:'ask' },   // premium ≥ $500k · size ≥ 500 · buy-side
21577  calls:  { type:'calls' },
21578  puts:   { type:'puts' },
21579  sweeps: { sweep:true },
21580  '0dte': { maxDte:'0' },
21581  // Match the Discord bot's channels exactly.
21582  exec:   { minPrem:400000, minSize:2000, side:'ask', maxDte:'7', single:true },   // SPY/QQQ exec trades
21583  leaps:  { minDte:'180', minPrem:500000, minSize:500 },                            // Discord LEAPS filter
21584  spx:    { maxPrice:'15' },                                                         // Discord SPX-NDX maxPrice
21585};
21586// Channel presets that filter by a ticker set — mirror the Discord bot's groups exactly.
21587const FLOW_PRESET_TICKERS = {
21588  spx:     ['SPX','SPXW','NDX'],
21589  exec:    ['SPY','QQQ'],
21590  semi:    ['NVDA','AMD','AVGO','TSM','ARM','MRVL','QCOM','INTC','SMCI','MU','ALAB','AMAT','KLAC','NBIS'],
21591  megacap: ['AAPL','MSFT','GOOG','GOOGL','AMZN','META','TSLA'],
21592};
21593// Effective filter for the current preset = base ← preset defaults ← this preset's overrides.
21594function _flowEff(){ return (flowPreset==='all') ? flowBase : Object.assign({}, flowBase, FLOW_PRESET_DEFAULTS[flowPreset]||{}, flowOv[flowPreset]||{}); }
21595let flowF = _flowEff();                    // what flowPassesFilter / renderFlowFilters read
21596function _flowSyncF(){ flowF = _flowEff(); }
21597// Persist base + per-preset overrides + preset + sort so a refresh keeps everything.
21598function _saveFlowSettings(){ try{ localStorage.setItem('gf_flow_set', JSON.stringify({ base: flowBase, ov: flowOv, preset
21598: flowPreset, sort: flowSort, agg: flowAggOn, accAlert: flowAccAlert })); }catch(_){}}
21599function _loadFlowSettings(){
21600  try{
21601    const s = JSON.parse(localStorage.getItem('gf_flow_set')||'null');
21602    if(s){
21603      const d=_flowDefaults();
21604      const src = s.base || s.f;   // s.f = legacy single-filter shape
21605      if(src && typeof src==='object'){ for(const k in d){ if(src[k]!==undefined) d[k]=src[k]; } }
21606      flowBase=d;
21607      if(s.ov && typeof s.ov==='object') flowOv=s.ov;
21608      if(typeof s.preset==='string') flowPreset=s.preset;
21609      if(s.sort && typeof s.sort==='object' && ('col' in s.sort)) flowSort={ col:s.sort.col, dir:(s.sort.dir===1?1:-1) };
21610      // Accumulate is an on-demand filter — always default OFF on load (show all flow), don't restore it.
21611      if(typeof s.accAlert==='number') flowAccAlert=s.accAlert;
21612    }
21613  }catch(_){}
21614  _flowSyncF();
21615}
21616// Draggable columns — order persists in localStorage; user drags, we bake in the result later.
21617const FLOW_COL_DEFS = {
21618  ticker:{label:'Ticker'}, contract:{label:'Contract'}, cp:{label:'C/P'}, side:{label:'Side'}, strike:{label:'Strike',num:1},
21619  exp:{label:'Exp'}, dte:{label:'DTE',num:1}, spot:{label:'Spot',num:1}, bid:{label:'Bid',num:1},
21620  ask:{label:'Ask',num:1}, fill:{label:'Fill',num:1}, bidask:{label:'Bid/Ask'}, size:{label:'Size',num:1},
21621  premium:{label:'Premium',num:1}, vol:{label:'Vol',num:1}, oi:{label:'OI',num:1}, iv:{label:'IV',num:1},
21622  type:{label:'Type'}, actions:{label:''}, time:{label:'Time'}
21623};
21624// Combined Contract column (strike + C/P + expiry) then Side — easier to read than separate cols.
21625const FLOW_COL_DEFAULT = ['ticker','contract','side','dte','spot','bid','ask','fill','bidask','size','premium','vol','oi','iv','type','actions','t
21625ime'];
21626let flowColOrder = FLOW_COL_DEFAULT.slice();
21627try { const s = JSON.parse(localStorage.getItem('gf_flow_cols3')||'null'); if (Array.isArray(s) && s.length) { flowColOrder = s.filter(c=>FLOW_COL_DEFS[c]); FLOW_COL_DEFAULT.forEach(c=>{ if(!flowColOrder.includes(c)) flowColOrder.push(c); }); } } catch(e){}
21628let _flowDragCol = null;
21629function _flowIsAdmin(){ return (typeof window!=='undefined' && window.GF_ADMIN===true) || (typeof isAdminUser!=='undefined' && isAdminUser); }
21630function _flowIsPaid(){ return _flowIsAdmin() || (typeof isPaidUser!=='undefined' && isPaidUser); }
21631function _flowIsTrial(){ return (typeof IS_FREE!=='undefined' && IS_FREE) && (typeof FREE_TRIAL_ACTIVE!=='undefined' && FREE_TRIAL_ACTIVE); }
21632// Access tiers: paid/admin → full flow (all tickers). Free trial → SPY/TSLA only.
21633// Demo + free-trial-ended → locked (Upgrade to Pro gate).
21634function _flowLocked(){ return !_flowIsPaid() && !_flowIsTrial(); }
21635function _flowEnabled(){ return true; } // button shows on the dashboard; content is gated on open
21636// Tickers in scope: paid = whole watchlist; free trial = SPY/TSLA only.
21637let _flowScopeSig='';
21638let _flowScopeAt=0;
21639function flowScopeSet(){
21640  // asked once per row of the feed: the signature below is rebuilt at most once a second
21641  const now=Date.now(); if(_flowScopeSet && now-_flowScopeAt<1000) return _flowScopeSet; _flowScopeAt=now;
21642  const paid=_flowIsPaid(), all=Object.values(WATCHLIST).flat();
21643  const sig=(paid?'P':'F')+all.join(',');   // rebuild when paid-state or watchlist CONTENT changes (length alone missed an add+remove pair, leaving the new ticker out of scope)
21644  if(sig!==_flowScopeSig){
21645    if(paid){ const s=new Set(all.map(t=>String(t).toUpperCase())); for(const k in FLOW_PRESET_TICKERS) for(const t of FLOW_PRESET_TICKERS[k]) s.add(t); _flowScopeSet=s; }  // + channel tickers (SPX/NDX, TSM, etc.) so their flow is captured
21646    else _flowScopeSet=new Set(DEMO_FREE);
21647    _flowScopeSig=sig;
21648  }
21649  return _flowScopeSet;
21650}
21651// Which tickers show: paid → whatever is on the watchlist (add a ticker → its flow appears); trial → SPY/TSLA only.
21652function flowInScope(tk){ return flowScopeSet().has(String(tk||'').toUpperCase()); }
21653
21654function _flowKey(f){ return f.id || ((f.ticker_symbol||f.ticker||'')+'|'+(f.strike||'')+'|'+(f.expiry||f.expires||'')+'|'+(f.created_at||f.executed_at||f.timestamp||'')+'|'+(f.total_size||f.size||'')+'|'+(f.total_premium||f.premium||'')); }
21655// Calendar-day DTE: today's expiry = 0 (0DTE) all day, not 1. (Old code used ceil on a time diff,
21656// so a same-day contract read as 1 until 4pm and the 0DTE filter hid every one.)
21657function _flowDte(f){ const e=(f.expires||f.expiry||f.expiry_date||''); if(!e) return 99; const p=String(e).slice(0,10).split('-'); if(p.length<3) return 99; const ed=new Date(+p[0], +p[1]-1, +p[2]); if(isNaN(ed)) return 99; const n=new Date(); const t=new Date(n.getFullYear(), n.getMonth(), n.getDate()); return Math.round((ed-t)/86400000); }
21658// Trade's execution date (YYYY-MM-DD, market/ET) for the date-range filter.
21659const _flowTdCache = new WeakMap();
21660function _flowTradeDate(f){ if(f && typeof f==='object'){ const h=_flowTdCache.get(f); if(h!==undefined) return h; const v=_flowTradeDateNow(f); try{ _flowTdCache.set(f,v); }catch(e){} return v; } return _flowTradeDateNow(f); }
21661function _flowTradeDateNow(f){ const ts=f.created_at||f.timestamp||f.executed_at; if(!ts) return ''; const d=new Date(ts); if(isNaN(d)) return ''; const et=new Date(d.toLocaleString('en-US',{timeZone:'America/New_York'})); return et.getFullYear()+'-'+String(et.getMonth()+1).padStart(2,'0')+'-'+String(et.getDate()).padStart(2,'0'); }
21662
21663// Everything flowPassesFilter reads that is not the row itself, as one string. A pass over the
21664// feed computes it once; a row's verdict under that signature is remembered on the row.
21665function _flowFilterSig(skipSize){
21666  return JSON.stringify(flowF||{})+'|'+String(flowTickerFilter||'')+'|'+flowPreset+'|'+_flowScopeSig+'|'+_etDayStr()+'|'+(skipSize?1:0);
21667}
21668const _flowPassCache = new WeakMap();
21669function _flowPass(f, sig, skipSize){
21670  const h=_flowPassCache.get(f); if(h && h.s===sig) return h.p;
21671  const p=flowPassesFilter(f, skipSize); try{ _flowPassCache.set(f,{s:sig,p}); }catch(e){}
21672  return p;
21673}
21674// the row's time as a number, parsed once - the default sort compares it n log n times
21675const _flowTsCache = new WeakMap();
21676function _flowTs(f){ let v=_flowTsCache.get(f); if(v===undefined){ v=new Date(f.created_at||f.timestamp||f.executed_at||0).getTime()||0; try{ _flowTsCache.set(f,v); }catch(e){} } return v; }
21677function flowPassesFilter(f, skipSize){
21678  const tk=String(f.ticker_symbol||f.ticker||'').toUpperCase();
21679  if(!flowInScope(tk)) return false;
21680  if(flowTickerFilter && tk!==flowTickerFilter) return false;
21681  if(flowF.ticker && tk!==flowF.ticker) return false;                       // ticker filter (panel)
21682  if(FLOW_PRESET_TICKERS[flowPreset] && !FLOW_PRESET_TICKERS[flowPreset].includes(tk)) return false;  // channel ticker set (SPX/Semi/Megacap)
21683  if(flowF.strike!=='' && parseFloat(f.strike||0)!==parseFloat(flowF.strike)) return false;  // exact strike
21684  if(flowF.dateFrom || flowF.dateTo){ const ex=String(f.expires||f.expiry||f.expiry_date||'').slice(0,10); if(ex){ if(flowF.dateFrom && ex<flowF.dateFrom) return false; if(flowF.dateTo && ex>flowF.dateTo) return false; } }  // filter by option EXPIRY date
21685  // Expired-expiry rows are KEPT (2026-08-18): hiding DTE<0 wiped the whole 0DTE tape at midnight,
21686  // so overnight the feed looked dead ("Market closed"). Old rows carry the amber M/D date label.
21687
21688  const prem=parseFloat(f.total_premium||f.premium||0)||0;
21689  const size=parseFloat(f.total_size||f.size||0)||0;   // parseFloat: crypto sizes are fractional coins (integers unchanged)
21690  // With accumulation on, size/premium are checked on the AGGREGATED totals (skipSize) instead.
21691  if(!skipSize){
21692    if(prem < (parseFloat(flowF.minPrem)||0)) return false;
21693    if(size < (parseInt(flowF.minSize)||0)) return false;
21694  }
21695  if(flowF.minPrice!=='' || flowF.maxPrice!==''){
21696    const price=parseFloat(f.price||0)||(size>0?prem/(size*100):0);
21697    if(flowF.minPrice!=='' && price < parseFloat(flowF.minPrice)) return false;
21698    if(flowF.maxPrice!=='' && price > parseFloat(flowF.maxPrice)) return false;
21699  }
21700  const isCall=(f.type||f.option_type||'').toUpperCase().includes('CALL');
21701  if(flowF.type==='calls' && !isCall) return false;
21702  if(flowF.type==='puts' && isCall) return false;
21703  const askP=parseFloat(f.total_ask_side_prem||0), bidP=parseFloat(f.total_bid_side_prem||0), tp=askP+bidP;
21704  const isBuy=tp>0?(askP>=bidP):true;
21705  if(flowF.side==='ask' && !isBuy) return false;
21706  if(flowF.side==='bid' && isBuy) return false;
21707  if(flowF.askGtBid && !(askP>bidP)) return false;
21708  if(flowF.money!=='all'){
21709    const spot=parseFloat(f.underlying_price||0), strike=parseFloat(f.strike||0);
21710    if(spot>0 && strike>0){ const itm = isCall ? strike<spot : strike>spot; if(flowF.money==='itm' && !itm) return false; if(flowF.money==='otm' && itm) return false; }
21711  }
21712  const sweep=f.has_sweep===true||f.has_sweep==='true';
21713  const floor=f.has_floor===true||f.has_floor==='true';
21714  const multileg=f.has_multileg===true||f.has_multileg==='true';
21715  const single=f.has_singleleg===true||f.has_singleleg==='true';
21716  if(flowF.sweep||flowF.block||flowF.floor||flowF.multileg||flowF.single){
21717    let ok=false;
21718    if(flowF.sweep&&sweep) ok=true;
21719    if(flowF.block&&!sweep&&!floor) ok=true;
21720    if(flowF.floor&&floor) ok=true;
21721    if(flowF.multileg&&multileg) ok=true;
21722    if(flowF.single&&single) ok=true;
21723    if(!ok) return false;
21724  }
21725  const vol=parseInt(f.volume||0)||0, oi=parseInt(f.open_interest||0)||0;
21726  const voi=parseFloat(f.volume_oi_ratio||0)||(oi>0?vol/oi:0);
21727  if(flowF.volGtOi && !(voi>1)) return false;
21728  if(flowF.opening && !(f.all_opening_trades===true||f.all_opening_trades==='true')) return false;
21729  if(flowF.repeat && String(f.alert_rule||'').toLowerCase()!=='repeatedhits') return false;
21730  const dte=_flowDte(f);
21731  if(flowF.minDte!=='' && dte < parseInt(flowF.minDte)) return false;
21732  if(flowF.maxDte!=='' && dte > parseInt(flowF.maxDte)) return false;
21733  if(flowF.minIv!==''){ const iv=parseFloat(f.iv_end||f.iv_start||0)*100; if(iv < parseFloat(flowF.minIv)) return false; }
21734  // (Preset built-ins like whales' 500k/500/buy are baked into flowF via FLOW_PRESET_DEFAULTS.)
21735  return true;
21736}
21737
21738// fromHist===true (strict — .forEach would pass an index here) = row comes from a server history/
21739// REST-alerts payload, which is already premium-curated: skip the size floor so thin tickers whose
21740// whole day trades in <50-lot prints still show SOMETHING instead of a "Market closed" empty tape.
21741// The floor only guards the raw live firehose (no server-side floors there).
21742function flowIngest(trade, fromHist){
21743  const tk=String(trade.ticker_symbol||trade.ticker||'').toUpperCase();
21744  if(!flowInScope(tk)) return;                        // paid = all tickers, trial = SPY/TSLA
21745  const _sz=parseInt(trade.total_size||trade.size||0)||0;
21746  if(_sz < 50 && fromHist!==true) return;             // skip 1–2 lot retail noise so real flow isn't evicted from the cap
21747  const k=_flowKey(trade);
21748  if(flowSeen.has(k)) return;
21749  flowSeen.add(k);
21750  if(flowSeen.size>3000){ flowSeen=new Set(Array.from(flowSeen).slice(-1500)); }
21751  flowFeed.unshift(trade);
21752  if(flowFeed.length>FLOW_MAX) flowFeed.length=FLOW_MAX;
21753  // Accumulation alert: count distinct prints per contract; fire once when it reaches the threshold.
21754  if(flowAccAlert>0){
21755    const ck=_flowContractKey(trade);
21756    _flowAccCount[ck]=(_flowAccCount[ck]||0)+1;
21757    if(_flowAccCount[ck]>=flowAccAlert && !_flowAccFired.has(ck)){ _flowAccFired.add(ck); fireFlowAccAlert(trade, _flowAccCount[ck]); }
21758  }
21759  _flowDirty=true;
21760}
21761
21762let flowSort = { col:null, dir:-1 };   // col null → feed order (newest first)
21763_loadFlowSettings();                   // restore the user's saved filters/preset/sort on load
21764let _flowJustDragged = false;
21765function _flowSortVal(f, col){
21766  const askP=parseFloat(f.total_ask_side_prem||0), bidP=parseFloat(f.total_bid_side_prem||0), tp=askP+bidP;
21767  switch(col){
21768    case 'ticker': return String(f.ticker_symbol||f.ticker||'');
21769    case 'cp': return (f.type||f.option_type||'').toUpperCase().includes('CALL')?1:0;
21770    case 'side': return tp>0?(askP>=bidP?1:0):1;
21771    case 'strike': case 'contract': return parseFloat(f.strike||0)||0;
21772    case 'exp': case 'dte': return _flowDte(f);
21773    case 'spot': return parseFloat(f.underlying_price||0)||0;
21774    case 'bid': return parseFloat(f.bid||0)||0;
21775    case 'ask': return parseFloat(f.ask||0)||0;
21776    case 'fill': return parseFloat(f.price||0)||0;
21777    case 'bidask': return tp>0?askP/tp:0.5;
21778    case 'size': return parseInt(f.total_size||f.size||0)||0;
21779    case 'premium': return parseFloat(f.total_premium||f.premium||0)||0;
21780    case 'vol': return parseInt(f.volume||0)||0;
21781    case 'oi': return parseInt(f.open_interest||0)||0;
21782    case 'iv': return parseFloat(f.iv_end||f.iv_start||0)||0;
21783    case 'type': return (f.has_sweep===true||f.has_sweep==='true')?1:0;
21784    case 'time': return new Date(f.created_at||f.timestamp||f.executed_at||0).getTime()||0;
21785    default: return 0;
21786  }
21787}
21788function flowSortHeader(col){
21789  if(_flowJustDragged || col==='actions') return;
21790  // 3-state cycle: descending → ascending → OFF (back to live newest-first feed order).
21791  if(flowSort.col!==col) flowSort={col:col, dir:-1};
21792  else if(flowSort.dir===-1) flowSort={col:col, dir:1};
21793  else flowSort={col:null, dir:-1};
21794  _saveFlowSettings(); renderFlowTape();
21795}
21796function renderFlowTape(){
21797  const tape=document.getElementById('flowTape'); if(!tape) return;
21798  if(flowPreset==='darkpool'){ return renderDarkpoolTape(); }   // darkpool has its own data source + render
21799  // Anchor the scroll: if you're at the top, follow the newest prints; if you've scrolled
21800  // down to study rows, hold them in place instead of yanking back to top on every live tick.
21801  const wasTop = tape.scrollTop < 6, prevTop = tape.scrollTop, prevH = tape.scrollHeight;
21802  _flowRenderMap={};
21803  let rows;
21804  // Accumulation: fold repeated hits on the SAME contract into one row (summed size/premium +
21805  // hit count), like Discord's "4th hit · Total 5,900". Aggregate FIRST (ignoring size/premium),
21806  // then apply the size/premium filters to the accumulated totals — so many small prints that add
21807  // up (or one big block) pass a size≥N filter even if individual prints don't.
21808  if(flowAggOn){
21809    const m=new Map(); const _sig=_flowFilterSig(true);
21810    for(const f of flowFeed){
21811      if(!_flowPass(f, _sig, true)) continue;
21812      const k=_flowContractKey(f);
21813      const ts=new Date(f.created_at||f.timestamp||f.executed_at||0).getTime()||0;
21814      const sz=parseInt(f.total_size||f.size||0)||0, pm=parseFloat(f.total_premium||f.premium||0)||0;
21815      const ap=parseFloat(f.total_ask_side_prem||0)||0, bp=parseFloat(f.total_bid_side_prem||0)||0;
21816      let a=m.get(k);
21817      if(!a){ a={f, hits:1, size:sz, prem:pm, ap, bp, ts}; m.set(k,a); }
21818      else { a.hits++; a.size+=sz; a.prem+=pm; a.ap+=ap; a.bp+=bp; if(ts>=a.ts){ a.ts=ts; a.f=f; } }
21819    }
21820    const mp=parseFloat(flowF.minPrem)||0, ms=parseInt(flowF.minSize)||0;
21821    rows=[...m.values()].map(a=>Object.assign({}, a.f, { total_size:a.size, total_premium:a.prem, total_ask_side_prem:a.ap, total_bid_side_prem:a.bp, _hits:a.hits }))
21822      .filter(r=>(r._hits>=2)&&(r.total_premium>=mp)&&(r.total_size>=ms));   // Accumulate = only strikes hit 2+ times
21823  } else {
21824    const _sig=_flowFilterSig(false); rows=flowFeed.filter(f=>_flowPass(f, _sig, false));   // verdicts remembered per row under this signature
21825  }
21826  if(flowSort.col){ const c=flowSort.col, d=flowSort.dir; rows=rows.slice().sort((a,b)=>{ const va=_flowSortVal(a,c), vb=_flowSortVal(b,c); return va<vb?-1*d:(va>vb?1*d:0); }); }
21827  else { rows=rows.slice().sort((a,b)=> _flowTs(b)-_flowTs(a)); }  // default: newest on top (history below)
21828  rows=rows.slice(0,800);
21829  if(!rows.length){
21830    // Say WHY it's empty. With a ticker filter on, blame the ticker (thin names print little
21831    // significant flow), not the clock — "Market closed" made people think the feed was broken.
21832    const _msg = flowTickerFilter
21833      ? ('No stored flow for '+flowTickerFilter+' yet — thin tickers may only print a few significant trades a day. New prints appear as they hit.')
21834      : (isMarketOpen()?'Waiting for flow…':'Market closed — showing the last session\'s flow as it loads.');
21835    tape.innerHTML='<div class="col-loading">'+_msg+'</div>';
21836  }
21837  else {
21838    const head='<thead><tr>'+flowColOrder.map(k=>{ const d=FLOW_COL_DEFS[k]||{label:k};
21839      const arrow=(flowSort.col===k)?(flowSort.dir<0?' ▼':' ▲'):'';
21840      return '<th draggable="true" data-col="'+k+'" class="flow-th'+(d.num?' num':'')+(flowSort.col===k?' flow-sorted':'')+'" title="click to sort · drag to reorder" onclick="flowSortHeader(\''+k+'\')" ondragstart="flowColDragStart(event,\''+k+'\')" ondragover="flowColDragOver(event)" ondrop="flowColDrop(event,\''+k+'\')" ondragend="flowColDragEnd()">'+(d.label||'')+arrow+'</th>';
21841    }).join('')+'</tr></thead>';
21842    _flowVRows=rows;
21843    tape.innerHTML='<table class="flow-tbl">'+head+'<tbody>'+_flowVBody(tape, rows)+'</tbody></table>';
21844    _flowVWire(tape);
21845    _flowVMeasure(tape);
21846  }
21847  if(!rows.length) _flowVRows=[];
21848  if(!wasTop){ tape.scrollTop = Math.max(0, prevTop + (tape.scrollHeight - prevH)); _flowVCheck(tape); }
21849  const stat=document.getElementById('flowStat'); if(stat) stat.textContent = rows.length+' shown · '+flowFeed.length+' cached';
21850  // The strip walks the whole feed. Filters changed: now. Otherwise at most every 3s.
21851  const sentSig=String(flowTickerFilter||'')+'|'+flowPreset+'|'+(flowAggOn?1:0)+'|'+JSON.stringify(flowF||{});
21852  if(sentSig!==_flowSentSig || Date.now()-_flowSentAt>3000){ _flowSentSig=sentSig; _flowSentAt=Date.now(); renderFlowSentiment(); }
21853}
21854let _flowSentSig='', _flowSentAt=0;
21855// ── Live mark for the TOP contracts on the flow strip ──
21856// The strip re-renders on every poll, so marks are cached per contract with a TTL and fetched at
21857// most once per contract per window — only ever for the 2 shown, never the whole tape.
21858const _flowMarks={};                       // key -> { mark, t, inflight }
21859let _flowTopLast=[];                       // last render's TOP FLOW keys, for order hysteresis
21860const _FLOW_MARK_TTL=60000;                // 60s — a mark this old is still useful, and it caps the calls
21861// '.' -> 'p' before stripping, so 6412.5 and 641.25 don't collapse to the same element id.
21862function _flowMkKey(o){ return (o.tk+'_'+String(o.strike).replace('.','p')+'_'+(o.exp||'').replace(/-/g,'')+'_'+o.cp).replace(/[^A-Za-z0-9_]/g,''); }
21863function _flowPaintMark(o, avg){
21864  const k=_flowMkKey(o), c=_flowMarks[k], el=document.getElementById('fmk_'+k);
21865  if(!el||!c||c.mark==null){ if(el&&c&&c.mark===null) el.textContent='→ —'; return; }
21866  // Round BEFORE picking the sign, or a mark a hair under entry renders as "-0%".
21867  const r=Math.round(avg>0?((c.mark-avg)/avg*100):0);
21868  const col=r>0?'#22c55e':r<0?'#ef4444':'#9fb0c9';
21869  el.innerHTML='→ <b style="color:'+col+'">$'+c.mark.toFixed(2)+'</b> '
21870    + '<span style="color:'+col+';font-weight:700">'+(r>0?'+':'')+r+'%</span>';
21871}
21872function _flowQueueMark(o, avg){
21873  const k=_flowMkKey(o), now=Date.now(), c=_flowMarks[k];
21874  if(c&&c.inflight) return;
21875  if(c&&now-c.t<_FLOW_MARK_TTL){ setTimeout(()=>_flowPaintMark(o,avg),0); return; }   // cached → paint, no call
21876  _flowMarks[k]=Object.assign({mark:null,t:0},c||{},{inflight:true});
21877  fetch('/api/option-mark/'+encodeURIComponent(o.tk)+'?strike='+encodeURIComponent(o.strike)+'&expiry='+encodeURIComponent(o.exp)+'&type='+o.cp)
21878    .then(r=>r.json()).then(j=>{ _flowMarks[k]={ mark:(j&&typeof j.mark==='number')?j.mark:null, t:Date.now(), inflight:false }; _flowPaintMark(o,avg); })
21879    .catch(()=>{ _flowMarks[k]={ mark:null, t:Date.now(), inflight:false }; });
21880}
21881// Sentiment strip: total call vs put premium over the shown flow → market Bullish/Bearish.
21882// When a ticker is selected it scopes to that ticker (its net call vs net put premium).
21883function renderFlowSentiment(){
21884  const el=document.getElementById('flowSentiment'); if(!el) return;
21885  if(flowPreset==='darkpool'){ el.style.display='none'; return; }   // darkpool has no call/put
21886  let callP=0, putP=0, n=0; const cm=new Map();
21887  const _sig=_flowFilterSig(false);
21888  for(const f of flowFeed){ if(!_flowPass(f, _sig, false)) continue; const pm=parseFloat(f.total_premium||f.premium||0)||0; if(!pm) continue;
21889    const isC=String(f.type||f.option_type||'').toUpperCase().includes('CALL'); if(isC) callP+=pm; else putP+=pm; n++;
21890    // Aggregate by contract for the top-2 leaders (per ticker when a ticker is selected).
21891    const tk=String(f.ticker_symbol||f.ticker||'').toUpperCase(), strike=parseFloat(f.strike||0), exp=String(f.expiry||f.expires||f.expiry_date||'').slice(0,10);
21892    const key=tk+'|'+strike+'|'+exp+'|'+(isC?'C':'P'); const o=cm.get(key)||{tk,strike,cp:isC?'C':'P',exp,prem:0,sz:0,pxw:0};
21893    o.prem+=pm;
21894    // Size-weighted average fill price across every print on this contract = what it was bought at
21895    // per contract. Weighting by size stops one tiny odd-lot print from skewing the entry.
21896    const _px=parseFloat(f.price||0)||((parseFloat(f.ask||0)+parseFloat(f.bid||0))/2)||0;
21897    const _sz=parseInt(f.total_size||f.size||0)||0;
21898    if(_px>0&&_sz>0){ o.sz+=_sz; o.pxw+=_px*_sz; }
21899    cm.set(key,o);
21900  }
21901  if(!n){ el.style.display='none'; return; }
21902  el.style.display='flex';
21903  const tot=callP+putP||1, cp=Math.round(callP/tot*100), pp=100-cp, bull=cp>=pp;
21904  const M=v=>{ v=Math.abs(v); if(v>=1e9)return '$'+(v/1e9).toFixed(1)+'B'; if(v>=1e6)return '$'+(v/1e6).toFixed(1)+'M'; if(v>=1e3)return '$'+(v/1e3).toFixed(0)+'K'; return '$'+Math.round(v); };
21905  const label = flowTickerFilter ? (flowTickerFilter+' flow') : 'Market sentiment';
21906  const top=[...cm.values()].sort((a,b)=>b.prem-a.prem).slice(0,2);
21907  // Order hysteresis: with two near-equal leaders, fresh prints flip which one sorts first on
21908  // every poll, so TOP FLOW 1 and 2 traded places each render — half the flicker. Keep the
21909  // standing order unless the challenger leads by more than 5%.
21910  if(top.length===2 && _flowTopLast.length===2 && _flowMkKey(top[0])===_flowTopLast[1] && _flowMkKey(top[1])===_flowTopLast[0] && top[0].prem < top[1].prem*1.05){ top.reverse(); }
21911  _flowTopLast = top.map(_flowMkKey);
21912  const fmtC=o=>{ const col=o.cp==='C'?'#22c55e':'#ef4444'; const ex=o.exp?o.exp.slice(5).replace('-','/'):''; const pre=flowTickerFilter?'':(o.tk+' ');
21913    const avg=(o.sz>0)?(o.pxw/o.sz):0;
21914    // Entry (size-weighted fill) → live mark, with the move since it printed. The strip is rebuilt
21915    // by innerHTML on every poll, so a cached mark must render INLINE — emitting the "→ …"
21916    // placeholder and patching it a tick later flashed the placeholder once per poll (the other
21917    // half of the flicker). The placeholder now only ever shows before the first fetch resolves.
21918    const mkId='fmk_'+_flowMkKey(o);
21919    const paid=avg>0?('<span style="color:#8b9cb5">paid</span> <b style="color:#cbd5e1">$'+avg.toFixed(2)+'</b>'):'';
21920    let markHtml='<span id="'+mkId+'" style="color:#8b9cb5">→ …</span>';
21921    const _mc=_flowMarks[_flowMkKey(o)];
21922    if(_mc && _mc.mark!=null && avg>0){ const _r=Math.round((_mc.mark-avg)/avg*100), _cl=_r>0?'#22c55e':_r<0?'#ef4444':'#9fb0c9';
21923      markHtml='<span id="'+mkId+'">→ <b style="color:'+_cl+'">$'+_mc.mark.toFixed(2)+'</b> <span style="color:'+_cl+';font-weight:700">'+(_r>0?'+':'')+_r+'%</span></span>'; }
21924    else if(_mc && _mc.mark===null && !_mc.inflight){ markHtml='<span id="'+mkId+'" style="color:#8b9cb5">→ —</span>'; }
21925    if(avg>0) _flowQueueMark(o, avg);
21926    return '<span onclick="flowOpenTop(\''+o.tk+'\','+o.strike+',\''+o.exp+'\',\''+o.cp+'\')" title="Open this contract" style="cursor:pointer;border-bottom:1px dashed rgba(148,163,184,.35)">'
21927      + '<span style="color:'+col+';font-weight:700">'+pre+o.strike+o.cp+'</span> <span style="color:#9fb0c9">'+ex+'</span> <b style="color:#e2e8f0">'+M(o.prem)+'</b>'
21928      + (paid?' <span style="font-size:11px">'+paid+' '+markHtml+'</span>':'')
21929      + '</span>'; };
21930  el.innerHTML = '<span style="font-weight:800;color:#e2e8f0;min-width:104px">'+label+'</span>'
21931    + '<span style="color:#22c55e;font-weight:700;white-space:nowrap">Calls '+M(callP)+'</span>'
21932    + '<div style="flex:0 0 240px;display:flex;height:16px;border-radius:5px;overflow:hidden;border:1px solid var(--border)"><div style="width:'+cp+'%;background:#22c55e"></div><div style="width:'+pp+'%;background:#ef4444"></div></div>'
21933    + '<span style="color:#ef4444;font-weight:700;white-space:nowrap">Puts '+M(putP)+'</span>'
21934    + '<span style="padding:3px 10px;border-radius:5px;font-weight:800;white-space:nowrap;background:'+(bull?'rgba(34,197,94,.15)':'rgba(239,68,68,.15)')+';color:'+(bull?'#22c55e':'#ef4444')+'">'+(bull?'▲ Bullish '+cp+'%':'▼ Bearish '+pp+'%')+'</span>'
21935    // Each leader gets its own "TOP FLOW n" tag and a wide gap — with a single shared TOP label and
21936    // 12px between them, the two contracts ran together as one string of numbers.
21937    + (top.length ? '<span style="margin-left:22px;margin-right:auto;display:flex;align-items:center;gap:26px;white-space:nowrap;overflow:hidden">'
21938        + top.map(function(o,ix){ return '<span style="display:flex;align-items:center;gap:7px">'
21939            + '<span style="font-size:9px;font-weight:800;letter-spacing:.6px;color:var(--dim);border:1px solid var(--border);border-radius:4px;padding:1px 5px;flex:none">TOP FLOW '+(ix+1)+'</span>'
21940            + fmtC(o)+'</span>'; }).join('')
21941      + '</span>' : '');
21942}
21943// ── Darkpool tab — off-exchange block prints (separate data source + render from the options flow). ──
21944let darkpoolFeed = [];
21945let _dpTimer = null, _dpLoading = false;
21946async function pollDarkpool(){
21947  if(!gfLive('flow') || flowPreset!=='darkpool') return;
21948  if(_dpLoading) return; _dpLoading=true;
21949  try{
21950    const t = flowTickerFilter || (flowF && flowF.ticker) || '';
21951    const url = t ? ('/api/darkpool/'+encodeURIComponent(t)+'?limit=200') : '/api/darkpool/recent?limit=200';
21952    const j = await fetch(url).then(r=>r.json());
21953    let arr=(j&&(j.data||j))||[]; if(!Array.isArray(arr)) arr=[];
21954    darkpoolFeed = arr;
21955    renderDarkpoolTape();
21956  }catch(e){} finally{ _dpLoading=false; }
21957}
21958function startDarkpoolPolling(){ stopDarkpoolPolling(); pollDarkpool(); _dpTimer=setInterval(pollDarkpool, 15000); }
21959function stopDarkpoolPolling(){ if(_dpTimer){ clearInterval(_dpTimer); _dpTimer=null; } }
21960// Darkpool prints only carry ticker / price / size / premium — apply the ticker + premium + size +
21961// price filters (the option-specific filters don't apply to shares).
21962function _dpPass(d){
21963  const tk=String(d.ticker||d.ticker_symbol||'').toUpperCase();
21964  if(flowTickerFilter && tk!==flowTickerFilter) return false;
21965  if(flowF.ticker && tk!==flowF.ticker) return false;
21966  const price=parseFloat(d.price||0)||0, size=parseInt(d.size||d.volume||0)||0;
21967  const prem=parseFloat(d.premium||0)||price*size;
21968  if(prem < (parseFloat(flowF.minPrem)||0)) return false;
21969  if(size < (parseInt(flowF.minSize)||0)) return false;
21970  if(flowF.minPrice!=='' && price < parseFloat(flowF.minPrice)) return false;
21971  if(flowF.maxPrice!=='' && price > parseFloat(flowF.maxPrice)) return false;
21972  return true;
21973}
21974function renderDarkpoolTape(){
21975  const tape=document.getElementById('flowTape'); if(!tape) return;
21976  let rows=darkpoolFeed.filter(_dpPass).map(d=>{ const price=parseFloat(d.price||0)||0, size=parseInt(d.size||d.volume||0)||0;
21977    const bid=parseFloat(d.nbbo_bid||0)||0, ask=parseFloat(d.nbbo_ask||0)||0;
21978    // Off-exchange prints carry NO disclosed aggressor, so we infer a LEAN from where the print
21979    // landed inside the NBBO at execution: at/above the ask = a buyer lifted the offer, at/below the
21980    // bid = a seller hit the bid, in between = at/near mid (a true block cross — genuinely neutral).
21981    let side='mid'; if(ask>bid && price>0){ const pos=(price-bid)/(ask-bid); side = pos>=0.66?'buy' : pos<=0.34?'sell' : 'mid'; }
21982    return { tk:String(d.ticker||d.ticker_symbol||'').toUpperCase(), price, size, bid, ask, side, prem:(parseFloat(d.premium||0)||price*size), at:d.executed_at||d.timestamp||d.market_time, mc:d.market_center||d.exchange||'' }; });
21983  if(flowSort.col==='size') rows.sort((a,b)=>flowSort.dir*(a.size-b.size));
21984  else if(flowSort.col==='prem') rows.sort((a,b)=>flowSort.dir*(a.prem-b.prem));
21985  else rows.sort((a,b)=> ((new Date(b.at).getTime())||0)-((new Date(a.at).getTime())||0));
21986  // Adaptive "not normal" cutoff: a print is UNUSUAL if its dollar size is >= $5M OR >= 4x the
21987  // median print in the current feed — so it flags real outliers whether the tape is quiet or busy.
21988  const _prems=rows.map(r=>r.prem).filter(x=>x>0).sort((a,b)=>a-b);
21989  const _med=_prems.length?_prems[Math.floor(_prems.length/2)]:0;
21990  const _bigCut=Math.max(5e6, _med*4);
21991  let _bigN=0; for(const r of rows){ r.big=r.prem>=_bigCut; if(r.big)_bigN++; }
21992  rows=rows.slice(0,800);
21993  const who=(flowTickerFilter||flowF.ticker)?(' for '+(flowTickerFilter||flowF.ticker)):'';
21994  if(!rows.length){ tape.innerHTML='<div class="col-loading">No darkpool prints'+who+' right now.</div>'; }
21995  else {
21996    // Darkpool now spans up to ~3 days — prefix an amber M/D on any print not from today's ET session
21997    // so older days are unmistakable; today's rows stay time-only.
21998    const _dpToday=(function(){ try{ const e=new Date(new Date().toLocaleString('en-US',{timeZone:'America/New_York'})); return e.getFullYear()+'-'+String(e.getMonth()+1).padStart(2,'0')+'-'+String(e.getDate()).padStart(2,'0'); }catch(e){ return ''; } })();
21999    const fmtT=at=>{ try{ const d=new Date(at); let s=d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
22000      const et=new Date(d.toLocaleString('en-US',{timeZone:'America/New_York'})); const ed=et.getFullYear()+'-'+String(et.getMonth()+1).padStart(2,'0')+'-'+String(et.getDate()).padStart(2,'0');
22001      if(ed && _dpToday && ed!==_dpToday) s='<span style="color:#eab308">'+(et.getMonth()+1)+'/'+et.getDate()+'</span> '+s; return s; }catch(e){ return ''; } };
22002    const M=n=> n>=1e6?('$'+(n/1e6).toFixed(1)+'M'):(n>=1e3?('$'+(n/1e3).toFixed(0)+'K'):('$'+Math.round(n)));
22003    const sarrow=c=>(flowSort.col===c)?(flowSort.dir<0?' ▼':' ▲'):'';
22004    const head='<thead><tr><th class="flow-th">Time</th><th class="flow-th">Ticker</th><th class="flow-th num" onclick="flowSortHeader(\'size\')" style="cursor:pointer" title="sort">Size'+sarrow('size')+'</th><th class="flow-th num">Price</th><th class="flow-th" title="Buy/Sell LEAN inferred from where the print hit the NBBO (at/above ask = buy, at/below bid = sell, mid = neutral block cross). Not an exchange-reported side.">Side</th><th class="flow-th num" onclick="flowSortHeader(\'prem\')" style="cursor:pointer" title="sort">Premium'+sarrow('prem')+'</th><th class="flow-th">Exch</th></tr></thead>';
22005    const sideCell=r=>{ const c=r.side==='buy'?'#22c55e':r.side==='sell'?'#ef4444':'#94a3b8'; const l=r.side==='buy'?'Buy':r.side==='sell'?'Sell':'Mid'; return '<td style="color:'+c+';font-weight:700;font-size:11px" title="'+(r.bid?('NBBO '+r.bid.toFixed(2)+' / '+r.ask.toFixed(2)):'no quote')+'">'+l+'</td>'; };
22006    const body=rows.map(r=>'<tr class="flow-tr"'+(r.big?' style="background:rgba(251,191,36,0.10)"':'')+'><td>'+fmtT(r.at)+'</td><td><b>'+r.tk+'</b></td><td class="num">
22006'+r.size.toLocaleString()+'</td><td class="num">$'+r.price.toFixed(2)+'</td>'+sideCell(r)+'<td class="num"'+(r.big?' style="color:#fbbf24;font-weight:800"':'')+' title="'+(r.big?'Unusually large block':'')+'">'+(r.big?'⚡ ':'')+M(r.prem)+'</td><td style="color:var(--dim);font-size:11px">'+String(r.mc||'').slice(0,12)+'</td></tr>').join('');
22007    tape.innerHTML='<table class="flow-tbl">'+head+'<tbody>'+body+'</tbody></table>';
22008  }
22009  const stat=document.getElementById('flowStat'); if(stat) stat.textContent=rows.length+' darkpool prints'+(_bigN?(' · ⚡ '+_bigN+' unusual'):'')+(darkpoolFeed.length?(' · '+darkpoolFeed.length+' cached'):'');
22010}
22011// Compute every cell (keyed by column id) for one trade, so the row honors flowColOrder.
22012function _flowCells(f){
22013  const ticker=String(f.ticker_symbol||f.ticker||'').toUpperCase();
22014  const strike=parseFloat(f.strike||0);
22015  const rawExp=String(f.expires||f.expiry||f.expiry_date||'').slice(0,10);
22016  const expFull=rawExp?(rawExp.slice(5,7)+'/'+rawExp.slice(8,10)+'/'+rawExp.slice(0,4)):'—';
22017  const isCall=(f.type||f.option_type||'').toUpperCase().includes('CALL');
22018  const size=parseFloat(f.total_size||f.size||0)||0;   // parseFloat: crypto sizes are fractional coins (integers unchanged)
22019  const prem=parseFloat(f.total_premium||f.premium||0)||0;
22020  const price=f.price?parseFloat(f.price):(size>0?prem/(size*100):0);
22021  const bid=parseFloat(f.bid||0), ask=parseFloat(f.ask||0);
22022  const spot=parseFloat(f.underlying_price||0);
22023  const askP=parseFloat(f.total_ask_side_prem||0), bidP=parseFloat(f.total_bid_side_prem||0), tp=askP+bidP;
22024  const askPct=tp>0?Math.round(askP/tp*100):(String(f.side||'').toLowerCase()==='ask'?100:50);
22025  const isBuy=tp>0?(askP>=bidP):true;
22026  const sweep=f.has_sweep===true||f.has_sweep==='true';
22027  const floor=f.has_floor===true||f.has_floor==='true';
22028  const multileg=f.has_multileg===true||f.has_multileg==='true';
22029  const vol=parseInt(f.volume||0)||0, oi=parseInt(f.open_interest||0)||0;
22030  const voi=parseFloat(f.volume_oi_ratio||0)||(oi>0?vol/oi:0);
22031  const iv=parseFloat(f.iv_end||f.iv_start||0);
22032  const _ts=f.created_at||f.timestamp||f.executed_at||''; const _d=_ts?new Date(_ts):null;
22033  let time=(_d&&!isNaN(_d))?fmtTime(_d,{hour:'2-digit',minute:'2-digit',hour12:true}):String(_ts).slice(11,16);
22034  // Prefix the date for anything NOT from today's ET session so a multi-day feed (store keeps 3 days)
22035  // never shows yesterday's clock time as if it were today. Today's rows stay clean (time only).
22036  try { const _ftd=_flowTradeDate(f); if(_ftd && typeof _etDayStr==='function' && _ftd!==_etDayStr()){ const _p=_ftd.split('-'); time='<span style="color:#fbbf24">'+(parseInt(_p[1],10)+'/'+parseInt(_p[2],10))+'</span> '+time; } } catch(e){}
22037  let dteNum = rawExp ? Math.max(0, _flowDte(f)) : '';
22038  const tagCls=floor?'floor':(multileg?'floor':(sweep?'sweep':'block')), tagLbl=floor?'Floor':(sweep?'Sweep':(multileg?'Multi':'Block'));
22039  const _crF=(f.source==='deribit')||(typeof isCryptoTk==='function'&&isCryptoTk(ticker));   // crypto rows: no print tag, Deribit link (2026-09-07)
22040  // UW link → the exact CONTRACT (option_chains lookup), not the ticker's flow overview.
22041  let uwUrl='https://unusualwhales.com/stock/'+ticker+'/flow-overview';
22042  if(f.option_chain){ uwUrl='https://unusualwhales.com/flow/option_chains?chain='+encodeURIComponent(f.option_chain)+'&days=7&mins=30'; }
22043  if(_crF) uwUrl=_gfDeribitUrl(ticker);
22044  else if(rawExp && strike){ const ed=new Date(rawExp+'T12:00:00'); if(!isNaN(ed)){ const yy=String(ed.getFullYear()).slice(2),mm=String(ed.getMonth()+1).padStart(2,'0'),dd=String(ed.getDate()).padStart(2,'0'); uwUrl='https://unusualwhales.com/flow/option_chains?chain='+encodeURIComponent(ticker+yy+mm+dd+(isCall?'C':'P')+String(Math.round(strike*1000)).padStart(8,'0'))+'&days=7&mins=30'; } }
22045  const askCol = askPct>=50?'#22c55e':'#ef4444';
22046  return {
22047    time:'<td class="flow-tm">'+time+'</td>',
22048    ticker:'<td><span class="flow-row-tkr" onclick="gfGoTicker(\''+ticker+'\',{flow:1})">'+ticker+'</span></td>',
22049    contract:'<td class="flow-contract"><b class="flow-c-strk">'+(strike||'—')+'</b><span class="flow-cp '+(isCall?'c':'p')+'">'+(isCall?'C':'P')+'</span>'+(rawExp?' <span class="flow-c-exp">'+(+rawExp.slice(5,7))+'/'+(+rawExp.slice(8,10))+'</span>':'')+((f._hits>1)?' <span class="flow-hits'+(f._hits>=3?' hot':'')+'" title="'+f._hits+' hits accumulated">'+f._hits+'x</span>':'')+'</td>',
22050    cp:'<td class="flow-cp '+(isCall?'c':'p')+'">'+(isCall?'CALL':'PUT')+'</td>',
22051    strike:'<td class="num">'+(strike||'—')+'</td>',
22052    exp:'<td>'+expFull+'</td>',
22053    dte:'<td class="num'+(dteNum===0?' flow-0dte':'')+'">'+(dteNum===''?'—':dteNum)+'</td>',
22054    spot:'<td class="num">'+(spot>0?spot.toFixed(2):'—')+'</td>',
22055    side:'<td class="flow-side '+(isBuy?'buy':'sell')+'">'+(isBuy?'BUY':'SELL')+'</td>',
22056    bid:'<td class="num">'+(bid>0?bid.toFixed(2):'—')+'</td>',
22057    ask:'<td class="num">'+(ask>0?ask.toFixed(2):'—')+'</td>',
22058    fill:'<td class="num">'+(price>0?price.toFixed(2):'—')+'</td>',
22059    bidask:'<td class="flow-bacell"><span class="flow-ba"><i class="b" style="width:'+(100-askPct)+'%"></i><i class="a" style="width:'+askPct+'%"></i><span class="flow-bapct '+(askPct>=50?'r':'l')+'">'+(askPct>=50?askPct:(100-askPct))+'%</span></span></td>',
22060    size:'<td class="num">
22060'+size.toLocaleString()+'</td>',
22061    premium:'<td class="num flow-prem">'+nfPrem(prem)+'</td>',
22062    type:'<td>'+(_crF?'':'<span class="flow-tag '+tagCls+'">'+tagLbl+'</span>')+'</td>',
22063    vol:'<td class="num">'+nfK(vol)+'</td>',
22064    oi:'<td class="num">'+nfK(oi)+'</td>',
22065    iv:'<td class="num">'+(iv>0?(iv*100).toFixed(0)+'%':'—')+'</td>',
22066    actions:'<td style="white-space:nowrap"><button class="flow-trk" onclick="event.stopPropagation();trackFlow(this)" data-ticker="'+ticker+'" data-strike="'+strike+'" data-expiry="'+rawExp+'" data-type="'+(isCall?'C':'P')+'" data-side="'+(isBuy?'buy':'sell')+'" data-entry="'+(price>0?price.toFixed(2):'')+'">Track</button><a class="flow-uw" href="'+uwUrl+'" target="_blank" rel="noopener" onclick="event.stopPropagation()">UW</a></td>',
22067    _whale: prem>=1000000
22068  };
22069}
22070let _flowRenderMap = {};
22071// Virtual window over the sorted rows. _flowVRows is the full sorted list of the last render;
22072// the tbody holds rows [from, to) plus two spacer rows whose heights stand in for the rest.
22073let _flowVRows = [], _flowRowH = 28, _flowVFrom = 0, _flowVTo = 0, _flowVRaf = 0;
22074const FLOW_V_BUF = 30;      // rows rendered beyond each edge of the viewport
22075const FLOW_V_EDGE = 10;     // re-fill when the viewport comes within this many rows of the window's edge
22076function _flowVRange(tape, n){
22077  const h = tape.clientHeight || (_flowRowH * 40), top = tape.scrollTop || 0;
22078  const from = Math.max(0, Math.floor(top / _flowRowH) - FLOW_V_BUF);
22079  const to = Math.min(n, Math.ceil((top + h) / _flowRowH) + FLOW_V_BUF);
22080  return [from, Math.max(to, Math.min(n, from + 1))];
22081}
22082function _flowVBody(tape, rows){
22083  const n = rows.length, r = _flowVRange(tape, n); _flowVFrom = r[0]; _flowVTo = r[1];
22084  const cols = flowColOrder.length;
22085  const sp = px => px > 0 ? '<tr class="flow-vsp" style="height:' + px + 'px"><td colspan="' + cols + '" style="padding:0;border:0"></td></tr>' : '';
22086  return sp(_flowVFrom * _flowRowH) + rows.slice(_flowVFrom, _flowVTo).map(buildFlowTableRow).join('') + sp((n - _flowVTo) * _flowRowH);
22087}
22088// after a paint: learn the real row height, and if it moved, size the spacers to it
22089function _flowVMeasure(tape){
22090  const tr = tape.querySelector('tr.flow-tr'); if(!tr) return;
22091  const h = tr.offsetHeight; if(!(h > 0) || Math.abs(h - _flowRowH) < 0.5) return;
22092  _flowRowH = h;
22093  const tb = tape.querySelector('tbody'); if(tb) tb.innerHTML = _flowVBody(tape, _flowVRows);
22094}
22095// scrolling near the edge of the window: fill a new window from the same sorted list - no filter,
22096// no sort, and the cells of every row already seen come from the cache
22097function _flowVCheck(tape){
22098  const n = _flowVRows.length; if(!n) return;
22099  const h = tape.clientHeight || 0, top = tape.scrollTop || 0;
22100  const visFrom = Math.floor(top / _flowRowH), visTo = Math.ceil((top + h) / _flowRowH);
22101  if((visFrom < _flowVFrom + FLOW_V_EDGE && _flowVFrom > 0) || (visTo > _flowVTo - FLOW_V_EDGE && _flowVTo < n)){
22102    const tb = tape.querySelector('tbody'); if(tb) tb.innerHTML = _flowVBody(tape, _flowVRows);
22103  }
22104}
22105function _flowVWire(tape){
22106  if(tape.dataset.vwire) return; tape.dataset.vwire = '1';
22107  tape.addEventListener('scroll', function(){
22108    if(_flowVRaf) return;
22109    _flowVRaf = requestAnimationFrame(function(){ _flowVRaf = 0; try{ _flowVCheck(tape); }catch(e){} });
22110  }, { passive: true });
22111}
22112// A feed object's cells never change (the feed only unshifts new objects), so format each once.
22113// Keyed on the ET day and the user's timezone, the two things outside the row that the cells read.
22114const _flowCellCache = new WeakMap();
22115function _flowCellsCached(f){
22116  const k = _etDayStr() + '|' + (typeof getUserTZ === 'function' ? getUserTZ() : '');
22117  const hit = _flowCellCache.get(f);
22118  if(hit && hit.k === k) return hit.c;
22119  const c = _flowCells(f);
22120  try{ _flowCellCache.set(f, { k, c }); }catch(e){}
22121  return c;
22122}
22123function buildFlowTableRow(f){ const c=_flowCellsCached(f); const key=String(_flowKey(f)).replace(/[\\']/g,''); _flowRenderMap[key]=f; const sel=(_flowSelKey===key)?' flow-sel':''; return '<tr class="flow-tr'+(c._whale?' flow-whale':'')+sel+'" onclick="flowOpenContract(\''+key+'\')" style="cursor:pointer">'+flowColOrder.map(k=>c[k]||'<td></td>').join('')+'</tr>'; }
22124// ── Column drag-reorder ──
22125function flowColDragStart(e,col){ _flowDragCol=col; try{e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain',col);}catch(_){}}
22126function flowColDragOver(e){ e.preventDefault(); try{e.dataTransfer.dropEffect='move';}catch(_){}}
22127function flowColDrop(e,targetCol){ e.preventDefault(); if(!_flowDragCol||_flowDragCol===targetCol){ _flowDragCol=null; return; } const from=flowColOrder.indexOf(_flowDragCol), to=flowColOrder.indexOf(targetCol); if(from<0||to<0){ _flowDragCol=null; return; } flowColOrder.splice(from,1); flowColOrder.splice(to,0,_flowDragCol); _flowDragCol=null; _flowJustDragged=true; setTimeout(()=>{_flowJustDragged=false;},60); try{localStorage.setItem('gf_flow_cols3',JSON.stringify(flowColOrder));}catch(_){} renderFlowTape(); }
22128function flowColDragEnd(){ _flowDragCol=null; }
22129// ── Right panel: click a row → look up the contract (Contract / Market Tide / MM Exposure) ──
22130let _flowSel=null, _flowSelKey=null, _flowRightTab='contract';
22131// Open a top-of-strip contract: find its biggest matching print in the feed, then open the panel.
22132function flowOpenTop(tk, strike, exp, cp){
22133  strike=parseFloat(strike);
22134  const cand=flowFeed.filter(f=>{
22135    if(String(f.ticker_symbol||f.ticker||'').toUpperCase()!==tk) return false;
22136    if(parseFloat(f.strike||0)!==strike) return false;
22137    if(String(f.expiry||f.expires||f.expiry_date||'').slice(0,10)!==exp) return false;
22138    const isC=String(f.type||f.option_type||'').toUpperCase().includes('CALL'); return (cp==='C')===isC;
22139  });
22140  if(!cand.length) return;
22141  cand.sort((a,b)=>(parseFloat(b.total_premium||b.premium||0)||0)-(parseFloat(a.total_premium||a.premium||0)||0));
22142  flowOpenContract(String(_flowKey(cand[0])).replace(/[\\']/g,''));
22143}
22144function flowOpenContract(key){
22145  const f=_flowRenderMap[key]||flowFeed.find(t=>String(_flowKey(t)).replace(/[\\']/g,'')===key);
22146  if(!f) return;
22147  _flowSel=f; _flowSelKey=key;
22148  const rp=document.getElementById('flowRight'); if(rp){ rp.style.display='flex'; try{const w=localStorage.getItem('gf_flow_rw'); if(w) rp.style.width=w;}catch(_){} }
22149  renderFlowRight();
22150  document.querySelectorAll('#flowTape tbody tr').forEach(tr=>tr.classList.remove('flow-sel'));
22151  try{ event && event.currentTarget && event.currentTarget.classList.add('flow-sel'); }catch(_){}
22152}
22153function closeFlowRight(){ const rp=document.getElementById('flowRight'); if(rp) rp.style.display='none'; _flowSel=null; _flowSelKey=null; document.querySelectorAll('#flowTape tbody tr.flow-sel').forEach(tr=>tr.classList.remove('flow-sel')); }
22154let _flowResizing=false;
22155function flowRightResizeStart(e){
22156  e.preventDefault(); _flowResizing=true;
22157  const panel=document.getElementById('flowRight'); const startX=e.clientX, startW=panel.offsetWidth;
22158  const onMove=(ev)=>{ if(!_flowResizing) return; let w=startW+(startX-ev.clientX); w=Math.max(240,Math.min(820,w)); panel.style.width=w+'px'; };
22159  const onUp=()=>{ _flowResizing=false; document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); try{localStorage.setItem('gf_flow_rw',panel.style.width);}catch(_){} };
22160  document.addEventListener('mousemove',onMove); document.addEventListener('mouseup',onUp);
22161}
22162function _flowRightW(){ const rp=document.getElementById('flowRight'); return Math.max(240,((rp&&rp.offsetWidth)||300)-30); }
22163// Width to draw a tide/MME SVG at — the ACTUAL container width (works in the narrow Ghost Mode trade
22164// panel too), falling back to the flow-tab width when the box isn't laid out yet.
22165function _boxW(box){ const w=(box&&box.clientWidth)?box.clientWidth-4:0; return Math.max(240, w||_flowRightW()); }
22166function renderFlowRight(){
22167  const body=document.getElementById('flowRightBody'); if(!body) return;
22168  if(!_flowSel){ body.innerHTML='<div class="flow-rempty">Click a flow row to look it up.</div>'; return; }
22169  const f=_flowSel;
22170  const ticker=String(f.ticker_symbol||f.ticker||'').toUpperCase();
22171  const isCall=(f.type||f.option_type||'').toUpperCase().includes('CALL');
22172  const strike=parseFloat(f.strike||0);
22173  const rawExp=String(f.expires||f.expiry||f.expiry_date||'').slice(0,10);
22174  const price=parseFloat(f.price||0)||0, bid=parseFloat(f.bid||0), ask=parseFloat(f.ask||0);
22175  const prem=parseFloat(f.total_premium||f.premium||0)||0, size=parseInt(f.total_size||f.size||0)||0;
22176  const _crC=(f.source==='deribit')||(typeof isCryptoTk==='function'&&isCryptoTk(ticker));
22177  const uwUrl = _crC ? _gfDeribitUrl(ticker) : (f.option_chain ? ('https://unusualwhales.com/flow/option_chains?chain='+encodeURIComponent(f.option_chain)+'&days=7&mins=30') : ('https://unusualwhales.com/stock/'+ticker+'/flow-overview'));
22178  const dte=Math.max(0,_flowDte(f));
22179  const expFull=rawExp?(rawExp.slice(5,7)+'/'+rawExp.slice(8,10)+'/'+rawExp.slice(0,4)):'—';
22180  // Top line: MU 1000P @ $fill · 7/10/2026 - 0DTE  |  Bottom: Bid/Ask · total value · size
22181  let html='<div class="flow-clhead"><span class="flow-cltk">'+ticker+'</span> <span class="flow-cp '+(isCall?'c':'p')+'">'+strike+(isCall?'C':'P')+'</span> @ $'+(price>0?price.toFixed(2):'—')+' · '+expFull+' - '+dte+'DTE <a class="flow-cluw" href="'+uwUrl+'" target="_blank" rel="noopener" style="margin-left:4px;font-size:11px">'+(_crC?'Deribit':'UW')+'</a></div>';
22182  html+='<div class="flow-cldet1">Bid '+(bid>0?bid.toFixed(2):'—')+' / Ask '+(ask>0?ask.toFixed(2):'—')+' · '+nfPrem(prem)+' · '+size.toLocaleStr
22182ing()+'x</div>';
22183  html+='<div class="flow-sec-h">TICKER TIDE</div><div id="flowTideBox" class="flow-secbox"><div class="flow-rempty">Loading tide…</div></div>';
22184  html+='<div class="flow-sec-h">MM EXPOSURE — GAMMA BY STRIKE</div><div id="flowMmeBox" class="flow-secbox"><div class="flow-rempty">Loading gamma…</div></div>';
22185  body.innerHTML=html;
22186  renderTideInto(document.getElementById('flowTideBox'), ticker);
22187  renderMmeInto(document.getElementById('flowMmeBox'), ticker);
22188}
22189// ── TICKER TIDE STRIPS (My HQ only) ── one under each chart pane, keyed to that pane's ticker.
22190try{ if(!localStorage.getItem('gf_tide_reset1')){ [1,2,3].forEach(function(i){ localStorage.setItem('gf_tide_min_'+i,'1'); }); localStorage.setItem('gf_tide_reset1','1'); } }catch(e){}   // folded once for everyone (2026-09-14)
22191function _tideHostFor(id){
22192  if(id===1){ const col=document.getElementById('chartContent'), cv=document.getElementById('tvChart'); return (col&&cv)?{col:col,after:cv}:null; }   // under chart 1's own canvas, pane-wide
22193  const pane=document.getElementById('mchart-pane-'+id); if(!pane) return null;
22194  const cv=pane.querySelector('.mchart-canvas'); return cv?{col:pane,after:cv}:null;
22195}
22196function gfTideStrip(id){
22197  const h=_tideHostFor(id); if(!h) return null;
22198  let el=document.getElementById('gfTide-'+id);
22199  if(!el){
22200    el=document.createElement('div'); el.id='gfTide-'+id; el.className='gf-tide';
22201    let min=true; try{ const _v=localStorage.getItem('gf_tide_min_'+id); if(_v!=null) min=(_v==='1'); }catch(e){}   // folded until opened; the choice is remembered
22202    if(min) el.classList.add('min');
22203    el.innerHTML='<div class="gf-tide-grip" title="Drag to resize"></div><div class="gf-tide-h" title="Net call vs put premium flowing into this chart\'s ticker, minute by minute. Click to fold.">TICKER TIDE <span class="tk"></span><span class="chev">'+(min?'&#9656;':'&#9662;')+'</span></div>'
22204      +'<div class="gf-tide-b"><div class="gf-tide-box flow-secbox"></div></div>';
22205    // height, remembered per pane; the grip on the top edge drags it (user 2026-09-14: "resizeable to top n bottom")
22206    const bd=el.querySelector('.gf-tide-b');
22207    try{ const hh=parseInt(localStorage.getItem('gf_tide_h_'+id)||'0',10); if(hh>0) bd.style.height=hh+'px'; }catch(e){}
22208    const grip=el.querySelector('.gf-tide-grip');
22209    grip.onmousedown=function(ev){
22210      ev.preventDefault(); ev.stopPropagation();
22211      if(el.classList.contains('min')) return;
22212      const y0=ev.clientY, h0=bd.getBoundingClientRect().height;
22213      const move=function(e){ const h=Math.max(60, Math.min(520, h0+(y0-e.clientY))); bd.style.height=h+'px'; };
22214      const up=function(){ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
22215        try{ localStorage.setItem('gf_tide_h_'+id, String(Math.round(bd.getBoundingClientRect().height))); }catch(e){}
22216        try{ if(id===1 && typeof fitChartViewHeight==='function') fitChartViewHeight(); }catch(e){} };
22217      document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
22218    };
22219    const hd=el.querySelector('.gf-tide-h');
22220    hd.onmousedown=function(e){ e.stopPropagation(); };
22221    hd.onclick=function(e){ e.stopPropagation();
22222      const m=el.classList.toggle('min'); try{ localStorage.setItem('gf_tide_min_'+id, m?'1':'0'); }catch(e2){}
22223      el.querySelector('.chev').innerHTML=m?'&#9656;':'&#9662;';
22224      if(!m){ const b=el.querySelector('.gf-tide-box'); if(b && b.dataset.tideTk) renderTideInto(b, b.dataset.tideTk); }
22225      try{ if(id===1 && typeof fitChartViewHeight==='function') fitChartViewHeight(); }catch(e3){} };
22226  }
22227  if(el.parentNode!==h.col || el.previousElementSibling!==h.after) h.after.insertAdjacentElement('afterend', el);
22228  // header text stays left; the strip is raised above the corner rail button so its own click wins
22229  return el;
22230}
22231function gfTideSync(id, tk){
22232  if(!(typeof _wsOn!=='undefined' && _wsOn)) return;
22233  tk=String(tk||'').toUpperCase(); if(!tk) return;
22234  const el=gfTideStrip(id); if(!el) return;
22235  el.querySelector('.tk').textContent=tk;
22236  const box=el.querySelector('.gf-tide-box');
22237  if(el.classList.contains('min')){ box.dataset.tideTk=tk; box.innerHTML=''; return; }          // folded: remember, draw when opened
22238  if(box.dataset.tideTk===tk && box.querySelector('svg')){ _tideRefresh(box, tk); return; }     // same ticker: swap in the new data in one paint
22239  renderTideInto(box, tk);                                                                        // new ticker: fresh draw
22240}
22241// refresh without clearing: the old picture stays until the new one is ready
22242function _tideRefresh(box, tk){
22243  fetch('/api/net-prem-ticks/'+encodeURIComponent(tk)).then(r=>r.json()).then(j=>{
22244    if(!box.isConnected || box.dataset.tideTk!==tk) return;
22245    const arr=(j&&(j.data||j))||[];
22246    if(Array.isArray(arr)&&arr.length&&(arr[0].net_call_premium!=null||arr[0].net_put_premium!=null)) _tideTicksInto(box, arr);
22247  }).catch(()=>{});
22248}
22249function gfTideSyncAll(){
22250  if(!(typeof _wsOn!=='undefined' && _wsOn)) return;
22251  try{ if(typeof chartTicker!=='undefined' && chartTicker) gfTideSync(1, chartTicker); }catch(e){}
22252  try{ if(typeof _mLayout!=='undefined' && _mLayout>1) [2,3].forEach(function(id){ const c=(typeof _mCharts!=='undefined')&&_mCharts[id]; if(c && c.ticker && document.getElementById('mchart-pane-'+id)) gfTideSync(id, c.ticker); }); }catch(e){}
22253}
22254setInterval(function(){ try{ gfTideSyncAll(); }catch(e){} }, 60000);
22255function renderTideInto(box, ticker){
22256  if(!box) return;
22257  box.dataset.tideTk = ticker;   // stamp the box so a stale fetch for an old ticker can't overwrite it
22258  _tideFlowInto(box, ticker);   // instant fallback from our flow feed
22259  fetch('/api/net-prem-ticks/'+encodeURIComponent(ticker)).then(r=>r.json()).then(j=>{
22260    // Guard on the box's own ticker, NOT _flowSel — _flowSel is the Flow-tab selection and is null in
22261    // Ghost Mode, which was silently blocking the real NCP/NPP dual-line tide from ever rendering here.
22262    if(!box.isConnected || box.dataset.tideTk!==ticker) return;
22263    const arr=(j&&(j.data||j))||[];
22264    if(Array.isArray(arr)&&arr.length&&(arr[0].net_call_premium!=null||arr[0].net_put_premium!=null)) _tideTicksInto(box, arr);
22265  }).catch(()=>{});
22266}
22267var _tidePxCache = {};   // ticker -> { t, arr:[{t,c}] } intraday closes, 2-min cache, for the tide price overlay
22268// Overlay the underlying's intraday PRICE on a tide SVG (user 2026-09-02) — a light line on its OWN
22269// scale so you can read flow vs price at a glance. Async (candle fetch); a tideRev token stamped by
22270// the caller stops a stale fetch from drawing onto a newer render (the ticks vs flow-fallback race).
22271function _tideAddPrice(box, ticker, rev, times, geo){
22272  if(!box || !Array.isArray(times) || times.length<2 || !geo) return;
22273  var tk=String(ticker||'').toUpperCase();
22274  var draw=function(cn){
22275    if(!box.isConnected || box.dataset.tideTk!==ticker || box.dataset.tideRev!==rev) return;
22276    var svg=box.querySelector('svg'); if(!svg || !cn || cn.length<2) return;
22277    var px=times.map(function(ts){ var T=+new Date(ts); var v=cn[0].c; for(var i=0;i<cn.length;i++){ if(cn[i].t<=T) v=cn[i].c; else break; } return v; });
22278    var lo=Math.min.apply(null,px), hi=Math.max.apply(null,px), rng=(hi-lo)||1;
22279    var W=geo.W, H=geo.H, pad=geo.pad, m=Math.max(3,(H-2*pad)*0.10);   // inset so the price line clears the flow extremes
22280    var Y=function(v){ return pad+m+(1-(v-lo)/rng)*(H-2*pad-2*m); };
22281    var NS='http://www.w3.org/2000/svg', d='';
22282    for(var i=0;i<px.length;i++){ d+=(i?' L':'M')+geo.x(i).toFixed(1)+','+Y(px[i]).toFixed(1); }
22283    var p=document.createElementNS(NS,'path'); p.setAttribute('d',d); p.setAttribute('fill','none'); p.setAttribute('stroke','#e5e7eb'); p.setAttribute('stroke-width','1.5'); p.setAttribute('opacity','0.92'); svg.appendChild(p);
22284    var last=px[px.length-1], ly=Math.min(H-4, Math.max(11, Y(last)-4));
22285    var t=document.createElementNS(NS,'text'); t.setAttribute('x',geo.x(px.length-1).toFixed(1)); t.setAttribute('y',ly.toFixed(1)); t.setAttribute('fill','#e5e7eb'); t.setAttribute('font-size','10'); t.setAttribute('font-weight','700'); t.setAttribute('font-family','monospace'); t.setAttribute('text-anchor','end'); t.textContent='$'+last.toFixed(2); svg.appendChild(t);
22286  };
22287  var c=_tidePxCache[tk];
22288  if(c && Date.now()-c.t<120000){ draw(c.arr); return; }
22289  fetch('/api/candles/'+encodeURIComponent(tk)+'?range=1d&interval=5m&prepost=false').then(function(r){return r.json();}).then(function(j){
22290    var r=j&&j.chart&&j.chart.result&&j.chart.result[0];
22291    var ts=(r&&r.timestamp)||[], cl=(r&&r.indicators&&r.indicators.quote&&r.indicators.quote[0]&&r.indicators.quote[0].close)||[];
22292    var arr=[]; for(var i=0;i<ts.length;i++){ if(cl[i]!=null&&isFinite(cl[i])) arr.push({t:ts[i]*1000, c:+cl[i]}); }
22293    _tidePxCache[tk]={t:Date.now(),arr:arr};
22294    draw(arr);
22295  }).catch(function(){});
22296}
22297function _tideTicksInto(box, arr){
22298  const ncp=[], npp=[]; let cn=0, cp=0;
22299  for(const d of arr){ cn+=parseFloat(d.net_call_premium||0)||0; cp+=parseFloat(d.net_put_premium||0)||0; ncp.push(cn); npp.push(cp); }
22300  const min=Math.min(0,...ncp,...npp), max=Math.max(0,...ncp,...npp), rng=(max-min)||1;
22301  const W=_boxW(box), H=130, pad=6;
22302  const Y=v=>(pad+(1-(v-min)/rng)*(H-2*pad));
22303  const zeroY=Y(0).toFixed(1);
22304  const line=(vals,col)=>{ let d='';
22304 vals.forEach((v,i)=>{ const x=(pad+(i/Math.max(1,vals.length-1))*(W-2*pad)).toFixed(1); d+=(i?' L':'M')+x+','+Y(v).toFixed(1); }); return '<path d="'+d+'" fill="none" stroke="'+col+'" stroke-width="1.6"/>'; };
22305  const fmtM=v=>{const a=Math.abs(v); return (v<0?'-':'')+(a>=1e9?'$'+(a/1e9).toFixed(1)+'B':a>=1e6?'$'+(a/1e6).toFixed(0)+'M':'$'+(a/1e3).toFixed(0)+'K');};
22306  const lastNcp=ncp[ncp.length-1]||0, lastNpp=npp[npp.length-1]||0;
22307  box.innerHTML='<svg width="'+W+'" height="'+H+'" viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="none" style="display:block">'
22308    +'<line x1="'+pad+'" y1="'+zeroY+'" x2="'+(W-pad)+'" y2="'+zeroY+'" stroke="#334155" stroke-width="1" stroke-dasharray="3 3"/>'
22309    +line(ncp,'#22c55e')+line(npp,'#ef4444')+'</svg>'
22310    +'<div class="flow-tide-lbl"><span style="color:#22c55e">NCP '+fmtM(lastNcp)+'</span><span style="color:#e5e7eb">Price</span><span style="color:#ef4444">NPP '+fmtM(lastNpp)+'</span></div>';
22311  var _rev=box.dataset.tideRev=String(Date.now())+'_'+Math.random();
22312  _tideAddPrice(box, box.dataset.tideTk||'', _rev, arr.map(function(d){return d.tape_time;}), {W:W,H:H,pad:pad,x:function(i){return pad+(i/Math.max(1,arr.length-1))*(W-2*pad);}});
22313}
22314function _tideFlowInto(box, ticker){
22315  const rows=flowFeed.filter(t=>String(t.ticker_symbol||t.ticker||'').toUpperCase()===ticker)
22316    .slice().sort((a,b)=> new Date(a.created_at||a.timestamp||a.executed_at||0) - new Date(b.created_at||b.timestamp||b.executed_at||0));
22317  let cum=0, callP=0, putP=0; const pts=[];
22318  for(const t of rows){ const p=parseFloat(t.total_premium||t.premium||0)||0; if((t.type||t.option_type||'').toUpperCase().includes('CALL')){cum+=p; callP+=p;} else {cum-=p; putP+=p;} pts.push(cum); }
22319  const net=callP-putP, W=_boxW(box), H=110, pad=5;
22320  if(pts.length<2){ box.innerHTML='<div class="flow-rempty">Not enough prints for a tide yet.</div>'; return; }
22321  const min=Math.min(0,...pts), max=Math.max(0,...pts), rng=(max-min)||1;
22322  const X=i=>(pad+(i/(pts.length-1))*(W-2*pad)), Y=v=>(pad+(1-(v-min)/rng)*(H-2*pad));
22323  const zeroY=Y(0).toFixed(1);
22324  let ln='M'+X(0).toFixed(1)+','+Y(pts[0]).toFixed(1); for(let i=1;i<pts.length;i++) ln+=' L'+X(i).toFixed(1)+','+Y(pts[i]).toFixed(1);
22325  const area=ln+' L'+X(pts.length-1).toFixed(1)+','+zeroY+' L'+X(0).toFixed(1)+','+zeroY+' Z';
22326  const col=net>=0?'#22c55e':'#ef4444';
22327  box.innerHTML='<svg width="'+W+'" height="'+H+'" viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="none" style="display:block">'
22328    +'<line x1="'+pad+'" y1="'+zeroY+'" x2="'+(W-pad)+'" y2="'+zeroY+'" stroke="#334155" stroke-width="1" stroke-dasharray="3 3"/>'
22329    +'<path d="'+area+'" fill="'+col+'" fill-opacity="0.15"/><path d="'+ln+'" fill="none" stroke="'+col+'" stroke-width="2"/></svg>'
22330    +'<div class="flow-tide-lbl"><span style="color:#22c55e">Calls '+nfPrem(callP)+'</span><span style="color:#e5e7eb">Price</span><span style="color:#ef4444">Puts '+nfPrem(putP)+'</span></div>';
22331  var _rev=box.dataset.tideRev=String(Date.now())+'_'+Math.random();
22332  _tideAddPrice(box, ticker, _rev, rows.map(function(t){return t.created_at||t.timestamp||t.executed_at;}), {W:W,H:H,pad:pad,x:function(i){return pad+(i/(pts.length-1))*(W-2*pad);}});
22333}
22334function renderMmeInto(box, ticker){
22335  if(!box) return;
22336  box.dataset.mmeTk = ticker;   // stamp so a stale response for an old ticker can't overwrite this box
22337  Promise.all([
22338    fetch('/api/gex/strike/'+encodeURIComponent(ticker)).then(r=>r.json()).catch(()=>null),
22339    fetch('/api/levels/'+encodeURIComponent(ticker)).then(r=>r.json()).catch(()=>null)
22340  ]).then(([gx,lv])=>{
22341    // Box-ticker guard (not _flowSel — null in Ghost Mode, which errored the whole render here).
22342    if(!box.isConnected || box.dataset.mmeTk!==ticker) return;
22343    const arr=(gx&&(gx.data||gx))||[];
22344    const spot=(lv&&lv.spot)||parseFloat((_flowSel&&_flowSel.underlying_price)||0)||0;
22345    let rows=arr.map(s=>({k:parseFloat(s.strike||0), g:(parseFloat(s.call_gex||s.call_gamma_oi||0)+parseFloat(s.put_gex||s.put_gamma_oi||0))})).filter(s=>s.k>0&&isFinite(s.g));
22346    if(!rows.length){ box.innerHTML='<div class="flow-rempty">No gamma data.</div>'; return; }
22347    const N=14; if(spot>0) rows=rows.slice().sort((a,b)=>
22347Math.abs(a.k-spot)-Math.abs(b.k-spot)).slice(0,N*2);
22348    rows.sort((a,b)=>b.k-a.k);
22349    const maxAbs=Math.max(1,...rows.map(s=>Math.abs(s.g)));
22350    const W=_boxW(box), rowH=15, H=rows.length*rowH+14, cx=W*0.46, half=W*0.4;
22351    const king=lv&&lv.king, cw=lv&&lv.callWall, pw=lv&&lv.putWall, vn=lv&&lv.volNode;
22352    const lvlOf=(k)=>{ if(king&&Math.abs(k-king)<0.01) return {a:'K',c:'#eab308'}; if(cw&&Math.abs(k-cw)<0.01) return {a:'CW',c:'#22c55e'}; if(pw&&Math.abs(k-pw)<0.01) return {a:'PW',c:'#ef4444'}; if(vn&&Math.abs(k-vn)<0.01) return {a:'VN',c:'#3b82f6'}; return null; };
22353    // Node chips on top of the histogram.
22354    const chip=(lbl,v,c)=>'<span style="color:'+c+'">'+lbl+' '+(v!=null&&isFinite(v)?(+v).toFixed(0):'—')+'</span>';
22355    const nodes='<div class="flow-mme-nodes">'+chip('King',king,'#eab308')+chip('Call Wall',cw,'#22c55e')+chip('Put Wall',pw,'#ef4444')+chip('Vol Node',vn,'#3b82f6')+'</div>';
22356    let svg='<svg width="'+W+'" height="'+H+'" viewBox="0 0 '+W+' '+H+'" style="display:block">';
22357    svg+='<line x1="'+cx.toFixed(1)+'" y1="6" x2="'+cx.toFixed(1)+'" y2="'+(H-8)+'" stroke="#334155" stroke-width="1"/>';
22358    rows.forEach((s,i)=>{
22359      const y=10+i*rowH, len=(Math.abs(s.g)/maxAbs)*half, pos=s.g>=0, x=pos?cx:cx-len, col=pos?'#22c55e':'#ef4444';
22360      const lvl=lvlOf(s.k), isLvl=!!lvl;
22361      // Node rows (King / Call Wall / Put Wall / Vol Node) get a tinted full-width band + a colored
22362      // marker dot in the node's color, and the gamma bar is outlined in that color, so K/CW/PW/VN
22363      // pop out of the histogram at a glance.
22364      if(isLvl){ svg+='<rect x="0" y="'+(y-6).toFixed(1)+'" width="'+W+'" height="'+rowH+'" fill="'+lvl.c+'" opacity="0.12"/>'; svg+='<circle cx="4" cy="'+y.toFixed(1)+'" r="3" fill="'+lvl.c+'"/>'; }
22365      svg+='<rect x="'+x.toFixed(1)+'" y="'+(y-5)+'" width="'+Math.max(1,len).toFixed(1)+'" height="9" fill="'+col+'" opacity="'+(isLvl?1:0.7)+'" rx="2"'+(isLvl?(' stroke="'+lvl.c+'" stroke-width="1.5"'):'')+'/>';
22366      svg+='<text class="flow-mme-strk" x="'+(W-2)+'" y="'+(y+3)+'" text-anchor="end"'+(isLvl?(' fill="'+lvl.c+'" font-weight="800"'):'')+'>'+(isLvl?(lvl.a+' '):'')+s.k+'</text>';
22367      if(spot>0 && i<rows.length-1 && rows[i].k>=spot && rows[i+1].k<spot){ const sy=(y+rowH/2).toFixed(1); svg+='<line x1="6" y1="'+sy+'" x2="'+(W-56)+'" y2="'+sy+'" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="2 2"/><text x="8" y="'+(y+rowH/2-3).toFixed(1)+'" font-size="9" fill="#e2e8f0">spot '+spot.toFixed(0)+'</text>'; }
22368    });
22369    svg+='</svg>';
22370    box.innerHTML=nodes+svg;
22371  }).catch(()=>{ if(box.isConnected) box.innerHTML='<div class="flow-rempty">Could not load gamma.</div>'; });
22372}
22373function resetFlowCols(){ flowColOrder=FLOW_COL_DEFAULT.slice(); flowSort={col:null,dir:-1}; try{localStorage.removeItem('gf_flow_cols3');}catch(_){} _saveFlowSettings(); renderFlowTape(); }
22374
22375// Throttled repaint — only while the Flow tab is visible and not paused.
22376setInterval(function(){ if(gfLive('flow') && _flowDirty && !flowPaused && !_gfScrollBusy()){ _flowDirty=false; renderFlowTape(); } }, 600);
22377
22378function setFlowPreset(p){
22379  // Switch tabs: the panel + tape now reflect THIS preset's effective filters (base + its
22380  // defaults + its own overrides). Each tab keeps its own settings.
22381  flowPreset=p;
22382  document.querySelectorAll('#flowView .flow-chip[data-fp]').forEach(b=>b.classList.toggle('active', b.dataset.fp===p));
22383  if(p==='darkpool'){ if(typeof startDarkpoolPolling==='function') startDarkpoolPolling(); }
22384  else { if(typeof stopDarkpoolPolling==='function') stopDarkpoolPolling(); }
22385  _flowSyncF(); _saveFlowSettings(); renderFlowFilters(); renderFlowTape();
22386}
22387function flowSet(field,val){
22388  let v;
22389  if(typeof val==='boolean') v=val;
22390  else if(field==='ticker') v=String(val||'').toUpperCase().replace(/[^A-Z0-9.]/g,'');
22391  else if(['minPrem','minSize','minDte','maxDte','minIv','minPrice','maxPrice','strike'].includes(field)) v=(val===''||val==null)?'':String(val).replace(/[^0-9.]/g,'');
22392  else v=val;   // side / type / money / dateFrom / dateTo
22393  // Write to the current tab: All → the base; any preset → that preset's overrides only.
22394  if(flowPreset==='all') flowBase[field]=v;
22395  else (flowOv[flowPreset]||(flowOv[flowPreset]={}))[field]=v;
22396  _flowSyncF(); _saveFlowSettings(); renderFlowFilters(); renderFlowTape();
22397}
22398function resetFlowFilters(){
22399  if(flowPreset==='all') flowBase=_flowDefaults();   // reset the current tab
22400  else delete flowOv[flowPreset];
22401  _flowSyncF(); _saveFlowSettings(); renderFlowFilters(); renderFlowTape();
22402}
22403function renderFlowFilters(){
22404  const el=document.getElementById('flowFilters'); if(!el) return;
22405  const seg=(field,opts)=>'<div class="flow-fseg">'+opts.map(o=>'<button class="flow-fbtn'+(flowF[field]===o.v?' active':'')+'" onclick="flowSet(\''+field+'\',\''+o.v+'\')">'+o.l+'</button>').join('')+'</div>';
22406  const chk=(field,label)=>'<label class="flow-chk"><input type="checkbox" '+(flowF[field]?'checked':'')+' onchange="flowSet(\''+field+'\',this.checked)"><span>'+label+'</span></label>';
22407  const numf=(field,label,step)=>'<label class="flow-nl"><span>'+label+'</span><input type="number" step="'+(step||1)+'" value="'+(flowF[field]===''?'':flowF[field])+'" onchange="flowSet(\''+field+'\',this.value)"></label>';
22408  const txtf=(field,label,ph)=>'<label class="flow-nl"><span>'+label+'</span><input type="text" value="'+(flowF[field]||'')+'" placeholder="'+(ph||'')+'" onchange="flowSet(\''+field+'\',this.value)"></label>';
22409  const datef=(field,label)=>'<label class="flow-nl"><span>'+label+'</span><input type="date" class="flow-datein" value="'+(flowF[field]||'')+'" onclick="try{this.showPicker()}catch(e){}" onchange="flowSet(\''+field+'\',this.value)"></label>';
22410  el.innerHTML=
22411    '<div class="flow-fgrp"><div class="flow-fh">TICKER &amp; STRIKE</div>'+txtf('ticker','Ticker','e.g. SPY')+numf('strike','Strike',1)+'</div>'+
22412    '<div class="flow-fgrp"><div class="flow-fh">EXPIRY DATE</div>'+datef('dateFrom','From')+datef('dateTo','To')+'</div>'+
22413    '<div class="flow-fgrp"><div class="flow-fh">PREMIUM &amp; SIZE</div>'+numf('minPrem','Min premium $',25000)+numf('minSize','Min size',50)+'</div>'+
22414    '<div class="flow-fgrp"><div class="flow-fh">CONTRACT PRICE</div>'+numf('minPrice','Min $',0.5)+numf('maxPrice','Max $',0.5)+'</div>'+
22415    '<div class="flow-fgrp"><div class="flow-fh">SIDE</div>'+seg('side',[{v:'all',l:'All'},{v:'ask',l:'Ask · Buy'},{v:'bid',l:'Bid · Sell'}])+'</div>'+
22416    '<div class="flow-fgrp"><div class="flow-fh">TYPE</div>'+seg('type',[{v:'all',l:'All'},{v:'calls',l:'Calls'},{v:'puts',l:'Puts'}])+'</div>'+
22417    '<div class="flow-fgrp"><div class="flow-fh">MONEYNESS</div>'+seg('money',[{v:'all',l:'All'},{v:'itm',l:'ITM'},{v:'otm',l:'OTM'}])+'</div>'+
22418    '<div class="flow-fgrp"><div class="flow-fh">CONTRACT</div>'+chk('sweep','Sweep')+chk('block','Block')+chk('floor','Floor')+chk('multileg','Multi-leg')+chk('single','Single-leg')+'</div>'+
22419    '<div class="flow-fgrp"><div class="flow-fh">UNUSUAL</div>'+chk('volGtOi','Vol &gt; OI')+chk('askGtBid','Ask &gt; Bid')+chk('opening','Opening only')+chk('repeat','Repeat hits')+'</div>'+
22420    '<div class="flow-fgrp"><div class="flow-fh">DTE</div>'+numf('minDte','Min DTE',1)+numf('maxDte','Max DTE',1)+'</div>'+
22421    '<div class="flow-fgrp"><div class="flow-fh">IV</div>'+numf('minIv','Min IV %',1)+'</div>'+
22422    '<button class="flow-freset" onclick="resetFlowFilters()">Reset filters</button>'+
22423    '<button class="flow-freset" onclick="resetFlowCols()" style="margin-top:6px">Reset columns</button>';
22424}
22425function renderFlowTickers(){
22426  const el=document.getElementById('flowTickers'); if(!el) return;
22427  const scope=flowScopeSet();
22428  // Flat list — ALL first, then every watchlist ticker (no sector labels).
22429  const seen=new Set(), tks=[];
22430  for(const sec of Object.keys(WATCHLIST)){ for(const t of (WATCHLIST[sec]||[])){ const T=String(t).toUpperCase(); if(scope.has(T)&&!seen.has(T)){ seen.add(T); tks.push(T); } } }
22431  let html='<span class="flow-tk flow-tk-all'+(!flowTickerFilter?' active':'')+'" onclick="clearFlowTicker()">ALL</span>';
22432  // data-ticker → right-click removes it (global contextmenu handler). No inline x.
22433  html+=tks.map(t=>'<span class="flow-tk'+(flowTickerFilter===t?' active':'')+'" data-ticker="'+t+'" onclick="flowFilterTicker(\''+t+'\')" title="click to filter · right-click to remove">'+t+'</span>').join('');
22434  el.innerHTML=html;
22435}
22436let _flowHistLoaded=new Set();
22437// Pull one ticker's flow history into the feed (once). Used when a ticker chip is clicked AND the
22438// moment a ticker is added to the watchlist — the server seeds never-tracked tickers from UW REST,
22439// so a brand-new ticker shows flow immediately instead of an empty tape.
22440function _flowPullTicker(tk){
22441  const T=String(tk||'').toUpperCase();
22442  if(!T || _flowLocked() || _flowHistLoaded.has(T)) return;
22443  _flowHistLoaded.add(T);
22444  _flowScopeSig=''; flowScopeSet();   // watchlist just changed → rebuild scope so flowIngest accepts it
22445  fetch('/api/flow/history/'+encodeURIComponent(T)).then(r=>r.json()).then(j=>{
22446    const arr=(j&&(j.data||j))||[];
22447    if(Array.isArray(arr)&&arr.length){ arr.forEach(f=>flowIngest(f,true)); if(gfLive('flow')){ renderFlowTape(); renderFlowTickers(); } }
22448  }).catch(()=>{ _flowHistLoaded.delete(T); });   // failed pull → allow a retry next time
22449}
22450function flowFilterTicker(tk){
22451  const T=(String(tk||'').toUpperCase())||null;
22452  flowTickerFilter=T; _renderFlowTickerChip(); renderFlowTickers(); renderFlowTape();
22453  if(flowPreset==='darkpool' && typeof pollDarkpool==='function') pollDarkpool();   // refetch darkpool for this ticker
22454  // On selecting a ticker, pull its full couple-days history once.
22455  if(T) _flowPullTicker(T);
22456}
22457function clearFlowTicker(){ flowTickerFilter=null; _renderFlowTickerChip(); renderFlowTickers(); renderFlowTape(); if(flowPreset==='darkpool' && typeof pollDarkpool==='function') pollDarkpool(); }
22458function _renderFlowTickerChip(){ const el=document.getElementById('flowTickerChip'); if(!el) return; if(flowTickerFilter){ el.style.display='inline-flex'; el.innerHTML='<span class="flow-tkr-chip" title="clear ticker filter">'+flowTickerFilter+' &times;</span>'; el.onclick=clearFlowTicker; } else { el.style.display='none'; el.onclick=null; } }
22459function toggleFlowPause(){ flowPaused=!flowPaused; const b=document.getElementById('flowPauseBtn'); if(b){ b.textContent=flowPaused?'Resume':'Pause'; b.classList.toggle('active', flowPaused); } if(!flowPaused) renderFlowTape(); }
22460function toggleFlowAgg(){ flowAggOn=!flowAggOn; const b=document.getElementById('flowAggBtn'); if(b) b.classList.toggle('active', flowAggOn); _saveFlowSettings(); renderFlowTape(); }
22461
22462async function _flowPollDemo(){
22463  for(const tk of DEMO_FREE){
22464    try{ const j=await fetch('/api/flow/'+tk).then(r=>r.json()); const arr=(j&&(j.data||j))||[]; if(Array.isArray(arr)) arr.forEach(f=>flowIngest(f,true)); }catch(e){}
22465  }
22466  _flowDirty=true;
22467}
22468// Trial tier: pull SPY/TSLA multi-day history once on open.
22469async function _flowLoadTrialHistory(){
22470  for(const tk of DEMO_FREE){
22471    try{ const j=await fetch('/api/flow/history/'+tk).then(r=>r.json()); const arr=(j&&(j.data||j))||[]; if(Array.isArray(arr)) arr.forEach(f=>flowIngest(f,true)); }catch(e){}
22472  }
22473}
22474let _flowFeedReady=false, _flowFeedAt=0, _flowDeepAt=0;
22475// Full-depth history backfill, run in the background AFTER the tape is already painted from the
22476// quick first page. Guarded so a boot warm + a tab open don't both download the ~5MB payload.
22477function _flowDeepFetch(){
22478  if(Date.now()-_flowDeepAt<60000) return;
22479  _flowDeepAt=Date.now();
22480  fetch('/api/flow/history?limit=15000').then(r=>r.json()).then(j=>{
22481    const arr=(j&&(j.data||j))||[];
22482    if(Array.isArray(arr)&&arr.length){ arr.forEach(f=>flowIngest(f,true)); if(gfLive('flow')&&!flowPaused){ renderFlowTape(); renderFlowTickers(); } }
22483  }).catch(()=>{ _flowDeepAt=0; });
22484}
22485// opts.bg = background warm/refresh: fill flowFeed without the visible first-load path.
22486async function startFlowFeed(opts){
22487  const bg=!!(opts&&opts.bg);
22488  // AUDIT FIX: the boot warm and a user opening Flow in the same window each ran the 15k-row
22489  // history backfill concurrently — the heaviest payload in the app, downloaded twice.
22490  if(window._flowFeedBusy) return; window._flowFeedBusy=true; try{
22491  _flowScopeSig=''; flowScopeSet(); // force scope rebuild
22492  const fBar=document.querySelector('#flowView .flow-bar');
22493  const fFilters=document.getElementById('flowFilters'), fTickers=document.getElementById('flowTickers'), fRight=document.getElementById('flowRight');
22494  // Demo / free-trial-ended → locked upgrade gate.
22495  if(_flowLocked()){
22496    if(fBar) fBar.style.display='none';
22497    if(fFilters) fFilters.style.display='none';
22498    if(fTickers) fTickers.style.display='none';
22499    if(fRight) fRight.style.display='none';
22500    const tape=document.getElementById('flowTape');
22501    if(tape) tape.innerHTML='<div class="flow-locked"><div class="fl-icon">🔒</div><div class="fl-t">Live Options Flow is a Pro feature</div><div class="fl-d">See the full options-flow firehose across every ticker — sweeps, blocks, whale prints, with filters, market tide and dealer gamma exposure. Free trial includes SPY & TSLA.</div><a class="fl-cta" href="'+UPGRADE_URL+'">Upgrade to Pro &rarr;</a></div>';
22502    return;
22503  }
22504  if(fBar) fBar.style.display='';
22505  if(fFilters) fFilters.style.display='';
22506  if(fTickers) fTickers.style.display='';
22507  // In bg warm we only want the DATA. Rendering the filters/ticker 
22507chips/tape into a hidden view was
22508  // pure waste and it ran while the user was on the chart — the tape alone builds up to 800 table
22509  // rows of innerHTML. All of it happens on the real open instead, which is instant by then.
22510  if(!bg){ renderFlowFilters(); renderFlowTickers(); }
22511  document.querySelectorAll('#flowView .flow-chip[data-fp]').forEach(b=>b.classList.toggle('active', b.dataset.fp===flowPreset)); // reflect saved preset
22512  { const _ab=document.getElementById('flowAggBtn'); if(_ab) _ab.classList.toggle('active', flowAggOn); const _as=document.getElementById('flowAccAlertSel'); if(_as) _as.value=String(flowAccAlert); }
22513  const paid=_flowIsPaid();   // paid → live SSE firehose (all tickers). Trial → poll SPY/TSLA (live for them).
22514  // The backfill below pulls up to 15k rows and used to run on EVERY tab open — that's what made
22515  // Flow slow to appear. Once the feed is populated, paint the tape immediately and refresh behind
22516  // the scenes instead of awaiting it.
22517  if(!bg && _flowFeedReady && flowFeed.length){
22518    renderFlowTape(); renderFlowTickers();
22519    if(!paid && !_flowDemoTimer) _flowDemoTimer=setInterval(_flowPollDemo, 15000);
22520    if(Date.now()-_flowFeedAt>45000) startFlowFeed({bg:true});   // silent top-up, tape already on screen
22521    return;
22522  }
22523  try{
22524    if(paid){
22525      // Two-stage backfill: a small newest-first page paints the tape fast (awaiting the full
22526      // 15k-row / ~5MB history here is what made the tab slow to load), then the full depth
22527      // streams in behind the visible tape via _flowDeepFetch. Live SSE rides on top.
22528      const j=await fetch('/api/flow/history?limit=1200').then(r=>r.json()).catch(()=>null);
22529      let arr=(j&&(j.data||j))||[];
22530      if(!Array.isArray(arr)||!arr.length){ const j2=await fetch('/api/flow').then(r=>r.json()).catch(()=>null); arr=(j2&&(j2.data||j2))||[]; }
22531      if(Array.isArray(arr)) arr.forEach(f=>flowIngest(f,true));
22532      _flowDeepFetch();
22533    } else {
22534      await _flowLoadTrialHistory();  // SPY/TSLA couple-days history first…
22535      await _flowPollDemo();          // …then keep polling live.
22536    }
22537  }catch(e){}
22538  _flowFeedReady=true; _flowFeedAt=Date.now();
22539  if(!bg){ renderFlowTape(); renderFlowTickers(); }
22540  if(!paid && !bg){ if(_flowDemoTimer) clearInterval(_flowDemoTimer); _flowDemoTimer=setInterval(_flowPollDemo, 15000); }   // don't start a 15s poll from a background warm
22541  } finally { window._flowFeedBusy=false; }
22542}
22543function stopFlowFeed(){ if(_flowDemoTimer){ clearInterval(_flowDemoTimer); _flowDemoTimer=null; } }
22544// Auto-reload when a new version deploys: the server's bootId changes on every restart. We record
22545// it on the first connect and reload the page if a later reconnect reports a different one — so a
22546// redeploy pushes the fresh JS to open tabs instead of leaving them on stale code.
22547let _gfBootId = null;
22548function gfCheckVersion(){
22549  fetch('/api/ws-status').then(r=>r.json()).then(d=>{
22550    if(!d || !d.bootId) return;
22551    if(_gfBootId && _gfBootId !== d.bootId){ try{ location.reload(); }catch(e){} return; }
22552    _gfBootId = d.bootId;
22553  }).catch(()=>{});
22554}
22555try{ gfCheckVersion(); }catch(e){}   // record the current version on first load
22556// After the SSE stream drops+reconnects (e.g. a server redeploy), re-fetch the flow history so the
22557// tape + filtered section refill from the server instead of sitting empty. flowIngest dedups, so
22558// re-ingesting is safe. Retries once if the server is still warming right after a restart.
22559async function backfillFlowOnReconnect(retry){
22560  if(!gfLive('flow') || IS_DEMO || typeof flowIngest!=='function' || !_flowIsPaid()) return;
22561  try{
22562    const j=await fetch('/api/flow/history?limit=15000').then(r=>r.json()).catch(()=>null);
22563    let arr=(j&&(j.data||j))||[];
22564    if(!Array.isArray(arr)||!arr.length){ const j2=await fetch('/api/flow').then(r=>r.json()).catch(()=>null); arr=(j2&&(j2.data||j2))||[]; }
22565    if(Array.isArray(arr)&&arr.length){ arr.forEach(f=>flowIngest(f,true)); renderFlowTape(); if(typeof renderFlowTickers==='function') renderFlowTickers(); }
22566    else if(!retry){ setTimeout(()=>{ try{ backfillFlowOnReconnect(true); }catch(e){} }, 4000); }  // server may still be warming
22567  }catch(e){}
22568}
22569// Reveal the Flow nav button for admins / demo.
22570function updateFlowNav(){ const b=document.getElementById('btn-flow'); if(b) b.style.display=_flowEnabled()?'inline-block':'none'; }
22571try{ updateFlowNav(); }catch(e){}
22572
22573// Track a flow trade → POST to the positions API (admin-only). Reads data-* off the button.
22574function trackFlow(btn){
22575  const d = btn.dataset;
22576  // Multi-leg (earnings straddle/strangle): data-legs carries every leg, so BOTH sides get tracked
22577  // instead of silently recording only legs[0] (the call). Single-leg buttons have no data-legs and
22578  // fall through to the original path below unchanged.
22579  if(d.legs){
22580    let legs=[]; try{ legs=JSON.parse(decodeURIComponent(d.legs))||[]; }catch(e){ legs=[]; }
22581    legs=legs.filter(l=>l&&l.p>0);
22582    if(!legs.length){ btn.textContent='no price'; return; }
22583    btn.textContent='...'; btn.disabled=true;
22584    // One shared group id across the legs so Tracking can show them as a single combined trade.
22585    const grp=(legs.length>1)?('g'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36)):'';
22586    const kind=d.grpkind||'';
22587    const mk=l=>({ ticker:d.ticker, strike:parseFloat(l.s), expiry:l.e, type:l.t, side:d.side||'buy', entry:parseFloat(l.p), source:'flow', grp, grpKind:kind });
22588    if(nfIsDemoTracking()){
22589      legs.forEach((l,i)=>window._demoPositions.unshift(Object.assign({ id:'demo'+Date.now().toString(36)+i, status:'open', mark:null }, mk(l))));
22590      btn.textContent='tracked '+legs.length+' legs'; btn.classList.add('nf-tracked');
22591      try{ if(typeof loadTracking==='function' && currentView==='tracking') loadTracking(); }catch(e){}
22592      return;
22593    }
22594    Promise.all(legs.map(l=>fetch('/api/positions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(mk(l))})
22595      .then(r=>r.json()).catch(()=>null)))
22596      .then(rs=>{ const ok=rs.filter(r=>r&&r.ok).length;
22597        if(ok===legs.length){ btn.textContent='tracked '+ok+' legs'; btn.classList.add('nf-tracked'); }
22598        else if(ok){ btn.textContent=ok+'/'+legs.length+' tracked'; btn.classList.add('nf-tracked'); }
22599        else { btn.textContent='err'; btn.disabled=false; } });
22600    return;
22601  }
22602  if(!d.entry){ btn.textContent='no price'; return; }
22603  if (nfIsDemoTracking()) {
22604    window._demoPositions.unshift({ id:'demo'+Date.now().toString(36), ticker:d.ticker, type:d.type, strike:parseFloat(d.strike), expiry:d.expiry, side:d.side, entry:parseFloat(d.entry), source:'flow', status:'open', mark:null });
22605    btn.textContent='tracked'; btn.classList.add('nf-tracked'); btn.disabled = true;
22606    try { if (typeof loadTracking === 'function' && currentView==='tracking') loadTracking(); } catch(e){}
22607    return;
22608  }
22609  btn.textContent='...'; btn.disabled = true;
22610  fetch('/api/positions', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({
22611    ticker:d.ticker, strike:parseFloat(d.strike), expiry:d.expiry, type:d.type, side:d.side, entry:parseFloat(d.entry), source:'flow'
22612  })}).then(r=>r.json()).then(res=>{
22613    if(res && res.ok){ btn.textContent='tracked'; btn.classList.add('nf-tracked'); }
22614    else { btn.textContent = 'err'; btn.disabled = false; }
22615  }).catch(()=>{ btn.textContent='err'; btn.disabled = false; });
22616}
22617
22618// ── New-layout Ultra: host the chart in the middle panel (reparent #chartContent),
22619// build the metrics strip, and add flow minimize-to-rail. ──
22620function nfPlaceChart(target){
22621  if (typeof GF_NEW_LAYOUT === 'undefined' || !GF_NEW_LAYOUT) return;
22622  const cc = document.getElementById('chartContent'); if (!cc) return;
22623  const hd = document.getElementById('chartHeader');   // the tf / replay / draw-tools / indicators toolbar
22624  const col = document.getElementById('chartCol');
22625  const row = document.getElementById('chartRow');
22626  if (target === 'ultra') {
22627    const host = document.getElementById('ultraExpiry');
22628    const bar = document.getElementById('ultraToolbar');
22629    // Toolbar (tf / nodes / tools) goes full-width above BOTH the ladder and the chart.
22630    if (bar && hd && hd.parentNode !== bar) bar.appendChild(hd);
22631    if (host) {
22632      host.style.padding = '0'; host.style.overflow = 'hidden'; host.style.position = 'relative';
22633      host.style.display = 'flex'; host.style.flexDirection = 'column';
22634      if (cc.parentNode !== host) host.appendChild(cc);          // chart fills the panel
22635      cc.style.height = ''; cc.style.flex = '1'; cc.style.minHeight = '0';
22636      document.body.classList.remove('gf-gexon'); // Ultra uses #ultraGex for the grid; hide the chart's GEX overlay
22637    }
22638  } else {
22639    if (col && row) {
22640      if (hd && hd.parentNode !== col) col.insertBefore(hd, row);        // toolbar back above the chart row
22641      if (cc.parentNode !== row) row.insertBefore(cc, row.firstChild);   // chart back as the first cell
22642      cc.style.height = ''; cc.style.flex = '1'; cc.style.minHeight = '0';
22643    }
22644    // Chart view always shows the GEX ladder panel now (the on/off toggle is removed).
22645    document.body.classList.add('gf-gexon');
22646    try { if (typeof alignChartPanelToPrice === 'function') alignChartPanelToPrice(chartCurrentPrice); } catch (e) {}
22647  }
22648}
22649function nfMetricsHtml(d){
22650  let netCall = 0, netPut = 0;
22651  for (const s of (d.filtered || [])) { netCall += s.callGex || 0; netPut += s.putGex || 0; }
22652  let cVol = 0, pVol = 0, cOI = 0, pOI = 0;
22653  for (const c of (d.contracts || [])) {
22654    const sym = c.option_symbol || c.option_chain_id || '';
22655    const m = sym.match(/^[A-Z]+\d{6}([CP])\d{8}$/); if (!m) continue;
22656    const v = parseInt(c.volume) || 0, oi = parseInt(c.open_interest) || 0;
22657    if (m[1] === 'C') { cVol += v; cOI += oi; } else { pVol += v; pOI += oi; }
22658  }
22659  const b = x => { const a = Math.abs(x); return (x < 0 ? '-' : '') + (a >= 1e9 ? (a/1e9).toFixed(1)+'B' : a >
22659= 1e6 ? (a/1e6).toFixed(1)+'M' : a >= 1e3 ? (a/1e3).toFixed(0)+'K' : a.toFixed(0)); };
22660  const k = x => x >= 1e6 ? (x/1e6).toFixed(2)+'M' : x >= 1e3 ? (x/1e3).toFixed(0)+'K' : ''+x;
22661  const cell = (lbl, val, cls) => '<div class="nf-met"><div class="nf-met-k">'+lbl+'</div><div class="nf-met-v '+(cls||'')+'">'+val+'</div></div>';
22662  return '<div class="nf-metrics">'+
22663    cell('Net Call GEX', b(netCall), netCall>=0?'pos':'neg')+
22664    cell('Net Put GEX', b(netPut), netPut>=0?'pos':'neg')+
22665    cell('Call Vol', k(cVol), '')+
22666    cell('Put Vol', k(pVol), '')+
22667  '</div>';
22668}
22669function nfEnsureFlowMinimize(){
22670  const flow = document.getElementById('ultraFlow');
22671  if (!flow) return;
22672  // Hide button lives at the flow panel's top-right.
22673  if (!document.getElementById('ultraFlowMinBtn')) {
22674    const btn = document.createElement('button');
22675    btn.id = 'ultraFlowMinBtn'; btn.className = 'nf-flowmin-btn'; btn.title = 'hide the flow panel';
22676    btn.textContent = '›';
22677    btn.onclick = function(){ nfSetFlowMin(true); };
22678    flow.appendChild(btn);
22679  }
22680  // Restore arrow floats at the chart's top-right, shown only while the panel is hidden.
22681  const host = document.getElementById('ultraExpiry');
22682  if (host && !document.getElementById('ultraFlowRestoreBtn')) {
22683    const rb = document.createElement('button');
22684    rb.id = 'ultraFlowRestoreBtn'; rb.className = 'nf-flowrestore-btn'; rb.title = 'show the flow panel';
22685    rb.innerHTML = '&lsaquo; Flow'; rb.style.display = 'none';
22686    rb.onclick = function(){ nfSetFlowMin(false); };
22687    host.appendChild(rb);
22688  }
22689}
22690// Fully collapse the flow panel (chart expands to fill — no empty rail) or bring it back.
22691function nfSetFlowMin(min){
22692  const flow = document.getElementById('ultraFlow'); if (!flow) return;
22693  const rb = document.getElementById('ultraFlowRestoreBtn');
22694  const prev = flow.previousElementSibling;
22695  const handle = (prev && prev.classList && prev.classList.contains('resize-handle')) ? prev : null;
22696  flow.style.display = min ? 'none' : 'flex';
22697  if (handle) handle.style.display = min ? 'none' : '';
22698  if (rb) rb.style.display = min ? 'inline-flex' : 'none';
22699  requestAnimationFrame(function(){ try { nfGexApplyFit(); } catch (e) {} });
22700}
22701// Chart tab: collapse the Trade Plan the same way as Ultra's flow — chart fills the
22702// space, a floating "‹ Trade Plan" arrow at the chart's top-right brings it back.
22703function nfEnsureTradePlanMin(){
22704  const row = document.getElementById('chartRow'); if (!row) return;
22705  if (!document.getElementById('chartTPRestoreBtn')) {
22706    row.style.position = 'relative';
22707    const rb = document.createElement('button');
22708    rb.id = 'chartTPRestoreBtn'; rb.className = 'nf-flowrestore-btn'; rb.title = 'show the trade plan';
22709    rb.innerHTML = '&lsaquo; Trade Plan'; rb.style.display = 'none';
22710    rb.onclick = function(){ nfSetTradePlanMin(false); };
22711    row.appendChild(rb);
22712  }
22713}
22714function nfSetTradePlanMin(min){
22715  const tp = document.getElementById('chartTradePlan'); if (!tp) return;
22716  nfEnsureTradePlanMin();
22717  const rb = document.getElementById('chartTPRestoreBtn');
22718  // Ghost Mode reparents the trade plan INTO the right Flow panel (its own FLOW - PLAN restore arrow). There the
22719  // corner "< Trade Plan" tab only un-hid an invisible section and then vanished (Desk 2/3, user 2026-09-06):
22720  // leave the panel alone and never show the tab while it lives inside the Flow panel.
22721  const inUltra = !!(tp.closest && tp.closest('#ultraTPSecBody'));
22722  if (inUltra) { if (rb) rb.style.display = 'none'; tp.style.display = 'flex'; return; }   // a stale gf_tp_min=1 (saved before the reparent) left the TRADE PLAN section empty
22723  tp.style.display = min ? 'none' : 'flex';
22724  if (rb) rb.style.display = (min && !inUltra) ? 'inline-flex' : 'none';
22725  // Remember the choice - a minimized Trade Plan came back on every reload (user 2026-08-27).
22726  try { localStorage.setItem('gf_tp_min', min ? '1' : '0'); } catch (e) {}
22727}
22728// Restore on load, once the panel exists. Deferred so it runs after the newlayout boot wiring.
22729setTimeout(function(){ try { if (localStorage.getItem('gf_tp_min') === '1') nfSetTradePlanMin(true); } catch (e) {} }, 800);
22730// ── Ultra right panel: Flow (top) + Trade Plan (bottom), each collapsible ──
22731// Runs once. Wraps the flow feed in a collapsible section and reparents #chartTradePlan
22732// (removing it from the Chart view) into a second collapsible section below it. News is
22733// hidden here — it moves to its own tab.
22734function nfToggleUSec(which){
22735  const el = document.getElementById(which === 'flow' ? 'ultraFlowSec' : 'ultraTPSec');
22736  if (!el) return;
22737  el.classList.toggle('collapsed');
22738  // Persist per section - the second half of the "minimized panel comes back on reload" report:
22739  // the first fix covered the OLD chart-layout control, but the new layout collapses these
22740  // sections and nothing remembered them.
22741  try { localStorage.setItem('gf_usec_' + which, el.classList.contains('collapsed') ? '1' : '0'); } catch (e) {}
22742}
22743// Drag the divider up/down to resize Flow (top) vs Trade Plan (bottom).
22744function nfWireUSecResize(divider, flowSec, tpSec, container){
22745  function apply(clientY){
22746    const rect = container.getBoundingClientRect();
22747    let tpH = rect.bottom - clientY;                         // Trade Plan grows as you drag up
22748    tpH = Math.max(46, Math.min(rect.height - 90, tpH));     // keep both usable
22749    flowSec.classList.remove('collapsed'); tpSec.classList.remove('collapsed');
22750    tpSec.style.flex = '0 0 ' + tpH + 'px';
22751    flowSec.style.flex = '1 1 0';
22752  }
22753  function onMove(e){ apply(e.clientY); e.preventDefault(); }
22754  function onUp(){ divider.classList.remove('nf-dragging'); document.body.style.userSelect='';
22755    document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
22756  divider.addEventListener('mousedown', function(e){ divider.classList.add('nf-dragging'); document.body.style.userSelect='none';
22757    document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); });
22758  // touch
22759  divider.addEventListener('touchmove', function(e){ if(e.touches[0]) apply(e.touches[0].clientY); }, { passive:true });
22760}
22761// Scroll the Ultra GEX ladder on wheel AND pinch (ctrl+wheel). preventDefault stops the
22762// browser from page-zooming on pinch, so two-finger pinch scrolls up/down — exactly like
22763// the chart's GEX panel (which native overflow-y scroll can't do for ctrl+wheel).
22764function nfEnsureGexScroll(){
22765  const g = document.getElementById('ultraGex');
22766  if (!g || g._nfScrollHooked) return;
22767  g._nfScrollHooked = true;
22768  g.addEventListener('wheel', function(e){ e.preventDefault(); g.scrollTop += e.deltaY; }, { passive: false });
22769}
22770function nfBuildUltraPanel(){
22771  const flow = document.getElementById('ultraFlow');
22772  if (!flow || flow._nfBuilt) return;
22773  flow._nfBuilt = true;
22774  const flowHeader = document.getElementById('ultraFlowHeader');
22775  const flowBody = document.getElementById('ultraFlowBody');
22776  const newsHeader = document.getElementById('ultraNewsHeader');
22777  const newsBody = document.getElementById('ultraNewsBody');
22778  const tp = document.getElementById('chartTradePlan');
22779  const chartHdr = document.getElementById('chartHeader');
22780  if (newsHeader) newsHeader.style.display = 'none';
22781  if (newsBody) newsBody.style.display = 'none';
22782  // Net Call/Put GEX live on the toolbar's top line (far right), not in the flow panel.
22783  if (chartHdr && !document.getElementById('ultraTopMetrics')) {
22784    const tm = document.createElement('div'); tm.id = 'ultraTopMetrics';
22785    tm.style.cssText = 'margin-left:auto;display:flex;align-items:center;gap:18px;padding-left:16px';
22786    chartHdr.appendChild(tm);
22787  }
22788  // FLOW section (top) — no header (metrics moved to the toolbar); flow feed sits right under.
22789  const flowSec = document.createElement('div');
22790  flowSec.id = 'ultraFlowSec'; flowSec.className = 'nf-usec';
22791  flowSec.innerHTML = '<div class="nf-usec-body" id="ultraFlowSecBody"></div>';
22792  flow.insertBefore(flowSec, flow.firstChild);
22793  const fb = document.getElementById('ultraFlowSecBody');
22794  // Carry the Flow ⇄ Node Alerts tabs and the node pane into the section body too — they're static
22795  // children of #ultraFlow, and without this they'd be stranded below the Trade Plan section.
22796  const _urTabs = document.getElementById('ultraRightTabs'); if (_urTabs) fb.appendChild(_urTabs);
22797  if (flowHeader) fb.appendChild(flowHeader);
22798  if (flowBody) fb.appendChild(flowBody);
22799  const _urNodes = document.getElementById('ultraNodePane'); if (_urNodes) fb.appendChild(_urNodes);
22800  const _urMovers = document.getElementById('ultraMoversPane'); if (_urMovers) fb.appendChild(_urMovers);
22801  const _urGflip = document.getElementById('ultraGflipPane'); if (_urGflip) fb.appendChild(_urGflip);
22802  const _urPrints = document.getElementById('ultraPrintsPane'); if (_urPrints) fb.appendChild(_urPrints);   // Prints pane (2026-09-05) - same fix as G-Flip: otherwise it renders stranded outside the section body   // G-Flip pane (2026-09-04) - stranded below Trade Plan otherwise
22803  // Draggable divider between Flow and Trade Plan
22804  const divider = document.createElement('div');
22805  divider.className = 'nf-usec-resize';
22806  flow.appendChild(divider);
22807  // TRADE PLAN section (bottom) — reparent #chartTradePlan out of the Chart view
22808  const tpSec = document.createElement('div');
22809  tpSec.id = 'ultraTPSec'; tpSec.className = 'nf-usec';
22810  // This section header REPLACES the (hidden) .nf-tp-head below, so the Trade Plan ⇄ Ticker Tide
22811  // toggle has to live here or it's invisible in the new layout. stopPropagation keeps a tab click
22812  // from also collapsing the section via the header's own onclick.
22813  tpSec.innerHTML = '<div class="nf-usec-head" onclick="nfToggleUSec(\'tp\')">'
22814    + '<span style="display:flex;gap:4px;align-items:center">'
22815    + '<button data-tptab="plan" class="tf-btn active" onclick="event.stopPropagation();setTradePlanTab(\'plan\')" style="font-size:10px;padding:2px 8px">📋 TRADE PLAN</button>'
22816    + '<button data-tptab="tide" class="tf-btn" onclick="event.stopPropagation();setTradePlanTab(\'tide\')" style="font-size:10px;padding:2px 8px" title="Net call vs put premium flowing into this ticker, minute by minute">TICKER TIDE</button>'
22817    + '</span><span class="nf-usec-chev">▾</span></div>'
22818    + '<div class="nf-usec-body" id="ultraTPSecBody" style="overflow:auto"></div>';
22819  flow.appendChild(tpSec);
22820  nfWireUSecResize(divider, flowSec, tpSec, flow);
22821  if (tp) {
22822    const tph = tp.querySelector('.nf-tp-head'); if (tph) tph.style.display = 'none'; // section has its own header
22823    tp.style.width = 'auto'; tp.style.border = 'none'; tp.style.background = 'transparent';
22824    tp.style.overflow = 'visible'; tp.style.display = 'block'; // let the section body scroll it
22825    document.getElementById('ultraTPSecBody').appendChild(tp);
22826    // The boot-time desk restore / gf_tp_min ran BEFORE this reparent and showed the Chart-view "< Trade Plan" corner
22827    // tab; the plan now lives in this panel, so retire the tab (user 2026-09-06: "trade plan tab still showing").
22828    try { const _rb = document.getElementById('chartTPRestoreBtn'); if (_rb) _rb.style.display = 'none'; } catch (e) {}
22829  }
22830  // Whole-panel minimize: collapse Flow + Trade Plan together to a thin right rail so the
22831  // chart takes the full width; a rail arrow brings it back.
22832  if (!document.getElementById('ultraPanelMinBtn')) {
22833    const mb = document.createElement('button');
22834    mb.id = 'ultraPanelMinBtn'; mb.className = 'nf-panel-min-btn'; mb.title = 'minimize the flow + trade plan panel';
22835    mb.innerHTML = '&rsaquo;'; mb.onclick = function(){ nfTogglePanel(true); };
22836    flow.appendChild(mb);
22837    const rail = document.createElement('div');
22838    rail.className = 'nf-panel-rail';
22839    rail.innerHTML = '<button title="expand the flow + trade plan panel" onclick="nfTogglePanel(false)">&lsaquo;</button><span class="nf-rail-txt">FLOW · PLAN</span>';
22840    flow.appendChild(rail);
22841  }
22842  // Consolidated layout: the Flow + Trade Plan panel lives in the CHART view now (Ultra retired).
22843  // Chart row becomes [ GEX panel (in chart) │ chart │ Flow + Trade Plan ].
22844  const chartRow = document.getElementById('chartRow');
22845  if (chartRow && flow.parentNode !== chartRow) { flow.style.borderLeft = '1px solid var(--border)'; chartRow.appendChild(flow); }
22846  // Re-apply the saved tab state LAST. The tpSec header above is built with 'active' hardcoded on
22847  // the plan tab, and the localStorage restore may already have run before this builder — so without
22848  // this, a saved Ticker Tide / Node Alerts preference is silently reset to the defaults.
22849  try { if (typeof setUltraFlowTab === 'function') setUltraFlowTab(ultraRightTab); } catch (e) {}
22850  try { if (typeof setTradePlanTab === 'function') setTradePlanTab(tradePlanTab); } catch (e) {}
22851  // Saved collapse states, applied AFTER the DOM above exists. A setTimeout restore loses to
22852  // this builder every time - it recreates the sections and forces tp display:block - so the
22853  // builder itself is the only safe place to re-apply what the user chose.
22854  try {
22855    if (localStorage.getItem('gf_usec_flow') === '1') { const fs2 = document.getElementById('ultraFlowSec'); if (fs2) fs2.classList.add('collapsed'); }
22856    if (localStorage.getItem('gf_usec_tp') === '1') { const ts2 = document.getElementById('ultraTPSec'); if (ts2) ts2.classList.add('collapsed'); }
22857    if (localStorage.getItem('gf_panel_min') === '1') nfTogglePanel(true);
22858  } catch (e) {}
22859}
22860function nfTogglePanel(min){
22861  const flow = document.getElementById('ultraFlow');
22862  if (flow) flow.classList.toggle('nf-panel-min', min);
22863  try { localStorage.setItem('gf_panel_min', min ? '1' : '0'); } catch (e) {}
22864}
22865// ── Ultra GEX ladder: fit-to-height + pinch/wheel zoom (no free scroll) ──
22866// nfGexVisible = how many strikes fill the panel; a spot-centered window of that many
22867// rows is sized to exactly fit the panel height, the rest are hidden. Pinch or wheel
22868// changes the count (density), like zooming a chart's price axis.
22869let nfGexVisible = 22;
22870function nfGexApplyFit(){
22871  const g = document.getElementById('ultraGex');
22872  if (!g || !(typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT)) return;
22873  const rows = g.querySelectorAll('.strike-row');
22874  const total = rows.length; if (!total) return;
22875  const head = g.querySelector('.nf-gex-head');
22876  const H = g.clientHeight - (head ? head.offsetHeight : 0); // leave room for the ticker header
22877  if (H < 40) { requestAnimationFrame(nfGexApplyFit); return; } // panel not laid out yet
22878  const vis = Math.max(6, Math.min(total, Math.round(nfGexVisible)));
22879  const rowH = H / vis;
22880  let spotIdx = -1;
22881  for (let i = 0; i < total; i++){ if (rows[i].querySelector('.strike-label.spot')) { spotIdx = i; break; } }
22882  if (spotIdx < 0) spotIdx = Math.floor(total / 2);
22883  let start = Math.round(spotIdx - vis / 2);
22884  start = Math.max(0, Math.min(total - vis, start));
22885  for (let i = 0; i < total; i++){
22886    const r = rows[i], inWin = i >= start && i < start + vis;
22887    r.style.display = inWin ? 'flex' : 'none';
22888    if (!inWin) continue;
22889    r.style.height = rowH + 'px';
22890    const bar = r.querySelector('.gex-bar-container'); if (bar) bar.style.height = Math.max(4, Math.min(20, rowH - 6)) + 'px';
22891    const lbl = r.querySelector('.strike-label'); if (lbl) lbl.style.fontSize = Math.max(8.5, Math.min(12, rowH * 0.48)) + 'px';
22892  }
22893}
22894function nfGexZoom(delta){ // delta<0 → fewer strikes / bigger (zoom in); delta>0 → more / smaller
22895  nfGexVisible = Math.max(6, Math.min(60, nfGexVisible + delta));
22896  nfGexApplyFit();
22897}
22898function nfEnsureGexZoom(){
22899  const g = document.getElementById('ultraGex');
22900  if (!g || g._nfZoomHooked) return;
22901  g._nfZoomHooked = true;
22902  g.addEventListener('wheel', function(e){ e.preventDefault(); nfGexZoom(e.deltaY > 0 ? 2 : -2); }, { passive: false });
22903  let pinchDist = 0;
22904  g.addEventListener('touchmove', function(e){
22905    if (e.touches.length !== 2) return;
22906    const dx = e.touches[0].clientX - e.touches[1].clientX, dy = e.touches[0].clientY - e.touches[1].clientY;
22907    const d = Math.hypot(dx, dy);
22908    if (pinchDist) nfGexZoom(d > pinchDist ? -1 : 1); // fingers apart → zoom in
22909    pinchDist = d; e.preventDefault();
22910  }, { passive: false });
22911  g.addEventListener('touchend', function(){ pinchDist = 0; });
22912  window.addEventListener('resize', function(){ nfGexApplyFit(); });
22913}
22914// Net Call GEX / Net Put GEX as two compact chips for the toolbar's top line.
22915function nfTopMetricsHtml(d){
22916  let netCall = 0, netPut = 0;
22917  for (const s of (d.filtered || [])) { netCall += s.callGex || 0; netPut += s.putGex || 0; }
22918  const b = x => { const a = Math.abs(x); return (x < 0 ? '-' : '') + (a >= 1e9 ? (a/1e9).toFixed(1)+'B' : a >
22918= 1e6 ? (a/1e6).toFixed(1)+'M' : a >= 1e3 ? (a/1e3).toFixed(0)+'K' : a.toFixed(0)); };
22919  const _isCharm = currentMetric === 'charm';
22920  const chip = (lbl, val, pos) => { const st = _isCharm ? (' style="color:'+(pos?'#67e8f9':'#fbbf24')+'"') : ''; return '<div class="nf-topmet"><span class="k">'+lbl+'</span><span class="v '+(pos?'pos':'neg')+'"'+st+'>'+val+'</span></div>'; };
22921  return chip('NET CALL ' + metricLabel(), b(netCall), netCall>=0) + chip('NET PUT ' + metricLabel(), b(netPut), netPut>=0);
22922}
22923function nfUltraRender(ticker, d, silent){
22924  nfBuildUltraPanel();
22925  nfPlaceChart('ultra');
22926  try { if (typeof loadGEXChart === 'function') loadGEXChart(ticker, silent); } catch (e) {}
22927  const tm = document.getElementById('ultraTopMetrics');
22928  if (tm) tm.innerHTML = nfTopMetricsHtml(d);
22929}
22930// Toggle the chart's GEX strike ladder (#chartGexPanel) on the Chart tab.
22931// Remember the user's choice so it survives leaving/returning to the Chart tab
22932// (Ultra mode borrows the chart and strips gf-gexon; we restore it in nfPlaceChart).
22933let nfChartGexWanted = false;
22934function toggleChartGex(){
22935  document.body.classList.toggle('gf-gexon');
22936  nfChartGexWanted = document.body.classList.contains('gf-gexon');
22937  if (nfChartGexWanted) document.body.classList.remove('gf-gexmin');   // GEX on = full panel (not minimized)
22938  const b = document.getElementById('chartGexToggle');
22939  if (b) b.classList.toggle('active', nfChartGexWanted);
22940  try { if (typeof alignChartPanelToPrice === 'function') alignChartPanelToPrice(chartCurrentPrice); } catch (e) {}
22941}
22942// Collapse the GEX ladder to the left edge (restore via the › tab). Persists across reloads.
22943function nfGexMinimize(min){
22944  document.body.classList.toggle('gf-gexmin', !!min);
22945  try { localStorage.setItem('gf_gexmin', min ? '1' : '0'); } catch (e) {}
22946  try { if (typeof alignChartPanelToPrice === 'function') alignChartPanelToPrice(chartCurrentPrice); } catch (e) {}
22947  try { if (typeof redrawDrawings === 'function') redrawDrawings(); } catch (e) {}
22948}
22949try { if (localStorage.getItem('gf_gexmin') === '1') document.body.classList.add('gf-gexmin'); } catch (e) {}
22950// ── Chart ladder VOL column + resizable width (user 2026-09-02) ──
22951let gfChartVolOn = false; try { gfChartVolOn = localStorage.getItem('gf_chartvol') === '1'; } catch (e) {}
22952let _cgpW = 290; try { const _w = parseInt(localStorage.getItem('gf_cgp_w') || '', 10); if (_w >= 200 && _w <= 620) _cgpW = _w; } catch (e) {}
22953function _cgpWidth() { return _cgpW; }
22954// {tk, exp, m:{strike:{c,p}}, tmax} for the NEAREST expiry, built from the ALREADY-cached contracts
22955// (no extra fetch — reuses chartContractsCache that the Vol Node loads).
22956function _buildChartVolMap(tk) {
22957  const cc = (typeof chartContractsCache !== 'undefined') ? chartContractsCache[tk] : null;
22958  const arr = (cc && cc.arr) || [];
22959  if (!arr.length) return null;
22960  const today = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
22961  const exps = new Set();
22962  for (const c of arr) { const oc = _occParts(c); if (oc && oc.e >= today) exps.add(oc.e); }
22963  const exp = [...exps].sort()[0]; if (!exp) return null;
22964  const m = {}; let tmax = 0;
22965  for (const c of arr) { const oc = _occParts(c); if (!oc) continue; if (oc.e !== exp) continue; const vol = parseInt(c.volume) || 0; if (!vol) continue; const strike = oc.strike; const r = m[strike] = m[strike] || { c: 0, p: 0 }; if (oc.cp === 'C') r.c += vol; else r.p += vol; const t = r.c + r.p; if (t > tmax) tmax = t; }
22966  return { tk, exp, m, tmax: tmax || 1 };
22967}
22968let _cgpWPreVol = null; try { const _pv = parseInt(localStorage.getItem('gf_cgp_w_prevol') || '', 10); if (_pv >= 200 && _pv <= 620) _cgpWPreVol = _pv; } catch (e) {}
22969function toggleChartVol() {
22970  gfChartVolOn = !gfChartVolOn;
22971  if (!gfChartVolOn && _cgpWPreVol != null) {   // VOL off (user 2026-09-05): give the ladder back the width it had before VOL widened it
22972    _cgpW = _cgpWPreVol; _cgpWPreVol = null;
22973    try { localStorage.setItem('gf_cgp_w', String(_cgpW)); localStorage.removeItem('gf_cgp_w_prevol'); } catch (e) {}
22974    const _p = document.getElementById('chartGexPanel'); if (_p) _p.style.width = _cgpW + 'px';
22975  }
22976  if (gfChartVolOn) {   // vol needs the ladder visible + room for both columns
22977    try { document.body.classList.add('gf-gexon'); document.body.classList.remove('gf-gexmin'); localStorage.setItem('gf_gexmin', '0'); } catch (e) {}
22978    const gb = document.getElementById('chartGexToggle'); if (gb) gb.classList.add('active');
22979    if (_cgpW < 372) { _cgpWPreVol = _cgpW; _cgpW = 372; try { localStorage.setItem('gf_cgp_w', String(_cgpW)); localStorage.setItem('gf_cgp_w_prevol', String(_cgpWPreVol)); } catch (e) {} }
22980  }
22981  try { localStorage.setItem('gf_chartvol', gfChartVolOn ? '1' : '0'); } catch (e) {}
22982  const b = document.getElementById('chartVolToggle'); if (b) b.classList.toggle('active', gfChartVolOn);
22983  try { if (window._cgpLast && typeof renderChartGexPanel === 'function') renderChartGexPanel(window._cgpLast.content, window._cgpLast.parsed, window._cgpLast.spot, window._cgpLast.ticker, window._cgpLast.volNode); } catch (e) {}
22984  try { if (typeof alignChartPanelToPrice === 'function') alignChartPanelToPrice(chartCurrentPrice); redrawDrawings(); } catch (e) {}
22985}
22986try { const _vb = document.getElementById('chartVolToggle'); if (_vb) _vb.classList.toggle('active', gfChartVolOn); } catch (e) {}
22987// ── Grid VOL column (user 2026-09-02): per-strike call/put volume for each column's expiry basis ──
22988let gfGridVolOn = false; try { gfGridVolOn = localStorage.getItem('gf_grid_vol') === '1'; } catch (e) {}
22989// Build {basis, exp, m:{strike:{c,p}}, tmax} from a column's contracts, honouring its GEX/VEX/CHARM
22990// basis: ALL sums every expiry per strike; 0DTE / WEEKLY use the same pickGridExpiry target the grid uses.
22991// One paint per ticker per 350ms, never while a strikes list is being scrolled. The data has
22992// already been written by the caller; this only decides WHEN the DOM catches up with it.
22993const _gexTickT = {}, _gexTickArg = {};
22994function _gexTickPaint(tk, arg) {
22995  _gexTickArg[tk] = arg;                                   // latest wins; a burst paints once
22996  if (_gexTickT[tk]) return;
22997  _gexTickT[tk] = setTimeout(function paint() {
22998    // a moving scroll anywhere in a ladder or column: come back when it has settled
22999    if (_gfScrollBusy() || document.querySelector('.strikes-list.scrolling')) { _gexTickT[tk] = setTimeout(paint, 250); return; }
23000    _gexTickT[tk] = null;
23001    const a = _gexTickArg[tk]; delete _gexTickArg[tk];
23002    if (!a) return;
23003    try {
23004      if (window._cgpLast && typeof renderChartGexPanel === 'function' && document.getElementById('chartGexPanel')) {
23005        var _wasScroll = (typeof cgpUserScrolled !== 'undefined') ? cgpUserScrolled : false;
23006        if (typeof cgpUserScrolled !== 'undefined') cgpUserScrolled = true;   // keep the reader's scroll position across frames
23007        renderChartGexPanel(window._cgpLast.content, a.rs, a.spot || window._cgpLast.spot, tk, a.vn);
23008        if (typeof cgpUserScrolled !== 'undefined') cgpUserScrolled = _wasScroll;
23009      }
23010    } catch (e) {}
23011    try { if (document.getElementById('col-body-' + tk)) renderColumn(tk); } catch (e) {}
23012  }, 350);
23013}
23014function _gridVolMap(arr, basis) {
23015  if (!arr || !arr.length) return null;
23016  const rx = /(\d{6})([CP])(\d{8})$/;
23017  let target = null;
23018  if (basis === '0dte' || basis === 'weekly') {
23019    const seen = {}, rows = [];
23020    for (const c of arr) { const mt = String(c.option_symbol || '').match(rx); if (!mt) continue; const e = '20' + mt[1].slice(0, 2) + '-' + mt[1].slice(2, 4) + '-' + mt[1].slice(4, 6); if (!seen[e]) { seen[e] = 1; rows.push({ expiry: e }); } }
23021    target = (typeof pickGridExpiry === 'function') ? pickGridExpiry(rows, basis) : null;
23022  }
23023  const m = {}; let tmax = 0;
23024  for (const c of arr) { const mt = String(c.option_symbol || '').match(rx); if (!mt) continue; const e = '20' + mt[1].slice(0, 2) + '-' + mt[1].slice(2, 4) + '-' + mt[1].slice(4, 6); if (target && e !== target) continue; const vol = parseInt(c.volume) || 0; if (!vol) continue; const strike = parseInt(mt[3], 10) / 1000; const r = m[strike] = m[strike] || { c: 0, p: 0 }; if (mt[2] === 'C') r.c += vol; else r.p += vol; const t = r.c + r.p; if (t > tmax) tmax = t; }
23025  if (!tmax) return null;
23026  return { basis, exp: target, m, tmax };
23027}
23028function toggleGridVol() {
23029  gfGridVolOn = !gfGridVolOn;
23030  try { localStorage.setItem('gf_grid_vol', gfGridVolOn ? '1' : '0'); } catch (e) {}
23031  document.body.classList.toggle('gf-gridvol', gfGridVolOn);
23032  const b = document.getElementById('gridVolBtn'); if (b) b.classList.toggle('active', gfGridVolOn);
23033}
23034try { if (gfGridVolOn) document.body.classList.add('gf-gridvol'); const _gvb = document.getElementById('gridVolBtn'); if (_gvb) _gvb.classList.toggle('active', gfGridVolOn); } catch (e) {}
23035// Drag the ladder's right edge to resize it (persisted). Handle is appended in renderChartGexPanel.
23036// Width of the Ghost Mode right panel. Mirrors _cgpResizeStart: same clamp shape, same save, but
23037// the grip is on the LEFT edge so the sign of the drag is flipped.
23038let _ufW = 380; try { const _w = parseInt(localStorage.getItem('gf_uf_w') || '0', 10); if (_w >= 260 && _w <= 760) _ufW = _w; } catch (e) {}
23039function _ufApplyW() {
23040  const p = document.getElementById('ultraFlow');
23041  if (p && !p.classList.contains('nf-panel-min')) p.style.width = _ufW + 'px';
23042}
23043function _ufResizeStart(e) {
23044  if (e.button !== 0) return;
23045  e.preventDefault(); e.stopPropagation();
23046  const p0 = document.getElementById('ultraFlow');
23047  if (!p0 || p0.classList.contains('nf-panel-min')) return;
23048  const grip = p0.querySelector(':scope > .nf-wresize'); if (grip) grip.classList.add('nf-dragging');
23049  const startX = e.clientX, startW = p0.offsetWidth;
23050  const move = (ev) => {
23051    _ufW = Math.max(260, Math.min(760, Math.round(startW - (ev.clientX - startX))));   // drag left = wider
23052    p0.style.width = _ufW + 'px';
23053    try { redrawDrawings(); } catch (e2) {}
23054  };
23055  const up = () => {
23056    document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up);
23057    if (grip) grip.classList.remove('nf-dragging');
23058    try { localStorage.setItem('gf_uf_w', String(_ufW)); } catch (e2) {}
23059    try { if (typeof fitChartViewHeight === 'function') fitChartViewHeight(); } catch (e2) {}
23060  };
23061  document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
23062}
23063function _cgpResizeStart(e) {
23064  if (e.button !== 0) return;
23065  e.preventDefault(); e.stopPropagation();
23066  const p0 = document.getElementById('chartGexPanel');
23067  const startX = e.clientX, startW = p0 ? p0.offsetWidth : _cgpW;
23068  const move = (ev) => {
23069    _cgpW = Math.max(200, Math.min(620, Math.round(startW + (ev.clientX - startX))));
23070    const p = document.getElementById('chartGexPanel'); if (p) p.style.width = _cgpW + 'px';
23071    try { alignChartPanelToPrice(chartCurrentPrice); redrawDrawings(); } catch (e2) {}
23072  };
23073  const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); try { localStorage.setItem('gf_cgp_w', String(_cgpW)); } catch (e2) {} };
23074  document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
23075}
23076function nfLvlColor(c){ return c==='green'?'#22c55e':c==='red'?'#ef4444':c==='yellow'?'#eab308':c==='cyan'?'#22d3ee':c==='blue'?'#3b82f6':'#94a3b8'; }
23077// Render the Trade Plan side panel from the chart's current GEX levels.
23078// Panel toggle: the rule-based plan, or Ticker Tide (this ticker's net call/put premium per minute —
23079// the same feed the Flow tab shows, scoped to one ticker, which is why it's "Ticker" not "Market").
23080function setTradePlanTab(tab) {
23081  tradePlanTab = (tab === 'tide') ? 'tide' : 'plan';
23082  document.querySelectorAll('[data-tptab]').forEach(b => b.classList.toggle('active', b.dataset.tptab === tradePlanTab));
23083  try { localStorage.setItem('gf_tradePlanTab', tradePlanTab); } catch (e) {}
23084  const el = document.getElementById('chartTradePlan');
23085  const body = el && el.querySelector('.nf-tp-body');
23086  if (!body) return;
23087  if (IS_DEMO && !(typeof isPaidUser !== 'undefined' && isPaidUser) && typeof demoTradePlanSample === 'function') { try { demoTradePlanSample((typeof chartTicker !== 'undefined' && chartTicker) || ''); } catch (e) {} return; }
23088  if (tradePlanTab === 'tide') {
23089    const tk = (typeof chartTicker !== 'undefined' && chartTicker) || '';
23090    if (!tk) { body.innerHTML = '<div style="padding:14px;color:var(--dim);font-size:12px">Select a ticker to see the tide.</div>'; return; }
23091    body.innerHTML = '<div class="flow-sec-h">TICKER TIDE</div><div id="tpTideBox" class="flow-secbox"><div style="padding:14px;color:var(--dim);font-size:12px">Loading tide…</div></div>'
23092      + '<div class="flow-sec-h">MM EXPOSURE — GAMMA BY STRIKE</div><div id="tpMmeBox" class="flow-secbox"><div style="padding:14px;color:var(--dim);font-size:12px">Loading gamma…</div></div>';
23093    if (typeof renderTideInto === 'function') renderTideInto(document.getElementById('tpTideBox'), tk);
23094    if (typeof renderMmeInto === 'function') renderMmeInto(document.getElementById('tpMmeBox'), tk);
23095  } else if (typeof _lastTradePlan !== 'undefined' && _lastTradePlan) {
23096    renderTradePlan(_lastTradePlan.ticker, _lastTradePlan.d);   // repaint the plan we last computed
23097  } else {
23098    body.innerHTML = '<div style="padding:14px;color:var(--dim);font-size:12px">Select a ticker to see the plan.</div>';
23099  }
23100}
23101try { const _tpt = localStorage.getItem('gf_tradePlanTab'); if (_tpt === 'tide') setTimeout(() => setTradePlanTab('tide'), 0); } catch (e) {}
23102
23103function renderTradePlan(ticker, d){
23104  const el = document.getElementById('chartTradePlan'); if (!el) return;
23105  // PREMIUM + ADMIN — hidden for free/demo users, and no fetches fire for them (the endpoints
23106  // are premium-gated anyway). GF_IS_PAID_HINT is the server-injected synchronous hint, so
23107  // members never see it flash out at boot.
23108  const _planOk = (typeof isPaidUser !== 'undefined' && isPaidUser) || (typeof GF_IS_PAID_HINT !== 'undefined' && GF_IS_PAID_HINT);
23109  if (!_planOk) { if (IS_DEMO && typeof demoTradePlanSample === 'function') { try { demoTradePlanSample(ticker); } catch (e) {} } else el.style.display = 'none'; return; }
23110  // Respect the user's minimize (gf_tp_min). This repaint runs on every chart load and 30s level
23111  // tick, and its unconditional un-hide was why a minimized panel came back within seconds of any
23112  // reload - the boot restore worked and THIS line then undid it (user 2026-08-27, second report).
23113  try { if (localStorage.getItem('gf_tp_min') === '1') { el.style.display = 'none'; const _rb = document.getElementById('chartTPRestoreBtn'); if (_rb) _rb.style.display = 'inline-flex'; return; } } catch (e) {}
23114  el.style.display = '';
23115  const body = el.querySelector('.nf-tp-body'); if (!body) return;
23116  _lastTradePlan = { ticker, d };
23117  // (EOD pin is now computed from the 0DTE data in computeCharmLevels → window._chartEod / _chartEodPin.)
23118  // Tide is showing — keep its contents; refresh it for the (possibly new) ticker instead.
23119  if (tradePlanTab === 'tide') {
23120    if (!body.querySelector('#tpTideBox')) body.innerHTML = '<div class="flow-sec-h">TICKER TIDE</div><div id="tpTideBox" class="flow-secbox"></div><div class="flow-sec-h">MM EXPOSURE — GAMMA BY STRIKE</div><div id="tpMmeBox" class="flow-secbox"></div>';
23121    if (typeof renderTideInto === 'function') renderTideInto(document.getElementById('tpTideBox'), ticker);
23122    if (typeof renderMmeInto === 'function' && document.getElementById('tpMmeBox')) renderMmeInto(document.getElementById('tpMmeBox'), ticker);
23123    return;
23124  }
23125  // SYSTEM PLAN — replaced the generic long/short level plan (user request): show what the engines
23126  // actually say about THIS ticker — today's picks that include it (SPX/SPY plays, Setups AM/PM,
23127  // Watchlist, Ghost) plus one on-demand idea built for it by the same Setups pipeline.
23128  _tpRenderSystem(ticker, body);
23129}
23130// (The old generic long/short plan — buildTradePlan levels/charm/EOD sections — was removed from
23131// this panel on request. The chart top bar still shows the levels; Ticker Tide keeps the flow read.)
23132// ── Trade Plan = the SYSTEM's read of the charted ticker ──
23133// Today's picks that include it (SPX/SPY plays, Setups AM/PM, Watchlist, Ghost — held snapshots,
23134// cheap to fetch) + one on-demand idea for it from the Setups pipeline (/api/ticker-idea, 5-min
23135// server cache). Re-invoked on every chart refresh, so it throttles per ticker and repaints only
23136// on change.
23137let _tpTok = 0, _tpSysCache = { t: 0, data: null }, _tpLastTk = null, _tpLastAt = 0;
23138async function _tpFetchAll(){
23139  if (_tpSysCache.data && Date.now() - _tpSysCache.t < 60000) return _tpSysCache.data;
23140  const get = u => fetch(u).then(r => r.json()).catch(() => null);
23141  // SPX/SPY plays + Ghost Trade are admin-only — don't even fire those fetches for members.
23142  const _adm = (typeof isAdminUser !== 'undefined' && isAdminUser) || (typeof GF_IS_ADMIN_HINT !== 'undefined' && GF_IS_ADMIN_HINT);
23143  const [idx, am, pm, wl, gh, hist] = await Promise.all([
23144    _adm ? get('/api/index-plays') : null, get('/api/trade-ideas?session=am'), get('/api/trade-ideas?session=pm'), get('/api/watchlist'), _adm ? get('/api/ghost-trades') : null, get('/api/trade-ideas/history')
23145  ]);
23146  _tpSysCache = { t: Date.now(), data: { idx, am, pm, wl, gh, hist } };
23147  return _tpSysCache.data;
23148}
23149async function _tpRenderSystem(ticker, body){
23150  const tk = String(ticker || '').toUpperCase();
23151  // AUDIT FIX: bump the token BEFORE the throttle early-return — a quick A→B→A switch used to
23152  // return early on A (matching the throttle) without invalidating B's deferred run, which then
23153  // rendered B's picks under A's chart.
23154  const token = ++_tpTok;
23155  if (_tpLastTk === tk && Date.now() - _tpLastAt < 30000 && body.querySelector('#tpSys')) return;
23156  if (!body.querySelector('#tpSys') || _tpLastTk !== tk) body.innerHTML = '<div id="tpSys"><div style="padding:14px;color:var(--dim);font-size:12px">Reading the system for ' + tk + '…</div></div>';
23157  // LET THE CHART WIN THE QUEUE: a ticker switch fires the chart'
23157s candles/levels/contracts/flow
23158  // burst at the same moment — this panel's 7 fetches (incl. the heavy on-demand /api/ticker-idea
23159  // build) were racing them through the same per-IP limiter and made switches feel laggy (user
23160  // report). Wait 1.2s after a switch so the chart paints first; a repaint of the SAME ticker
23161  // (30s cadence) skips the delay.
23162  if (_tpLastTk !== tk) { await new Promise(r => setTimeout(r, 1200)); if (token !== _tpTok) return; }
23163  const [all, ti] = await Promise.all([_tpFetchAll(), fetch('/api/ticker-idea/' + encodeURIComponent(tk)).then(r => r.json()).catch(() => null)]);
23164  if (token !== _tpTok) return;                          // ticker switched mid-flight
23165  const box = body.querySelector('#tpSys'); if (!box) return;
23166  _tpLastTk = tk; _tpLastAt = Date.now();
23167  const alias = tk === 'SPXW' ? 'SPX' : tk;
23168  // Outcome status for each pick from the shared tracker: open entry → live up/down %, resolved
23169  // today → WIN / LOSS / EXP (+ BOOK when the +100% rule fired).
23170  const _today = new Date().toLocaleDateString('en-CA');
23171  const findTrk = sess => {
23172    const open = ((all.hist && all.hist.open) || []).find(o => o.session === sess && o.ticker === alias);
23173    if (open) return { live: open.plPct };
23174    const res = ((all.hist && all.hist.resolved) || []).filter(o => o.session === sess && o.ticker === alias && o.date === _today).sort((a, b) => (b.resolved || 0) - (a.resolved || 0))[0];
23175    return res ? { status: res.status, booked: !!res.booked } : null;
23176  };
23177  // Per-card give-time: WHEN that list handed out the pick (snapshot build / play open / idea
23178  // build) — the corner stamp is just the panel refresh.
23179  const fmtAt = ms => { if (!ms) return ''; const d = new Date(ms); const sameDay = d.toDateString() === new Date().toDateString();
23180    return (sameDay ? '' : d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ') + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); };
23181  const rows = [];
23182  for (const p of ((all.idx && all.idx.plays) || [])) if (p.ticker === alias) rows.push({ src: 'SPX/SPY PLAY #' + p.n + (p.status === 'open' ? ' · LIVE' : ''), col: '#f472b6', at: p.at, pl: p.plPct, score: p.score, grade: p.grade, trk: p.status !== 'open' ? { status: p.status, booked: !!p.booked } : null, it: { dir: p.dir, contract: p.contract, entryLo: p.entrySpot, entryHi: p.entrySpot, target: p.target, stop: p.stop, spot: p.entrySpot, premAtTarget: p.premAtTarget, targetPct: p.targetPct } });
23183  const pull = (j, src, col, sess) => { const at = (j && (j.generated || j.t)) || 0; for (const it of ((j && j.ideas) || [])) if (it.ticker === alias || it.ticker === tk) rows.push({ src, col, at, score: it.score, grade: it.grade, trk: findTrk(sess), it }); };
23184  pull(all.am, 'SETUPS · MORNING', '#38bdf8', 'am'); pull(all.pm, 'SETUPS · EVENING', '#38bdf8', 'pm');
23185  pull(all.wl, 'WATCHLIST', '#22d3ee', 'wl'); pull(all.gh, 'GHOST TRADE', '#c084fc', 'ghost');
23186  const card = r => {
23187    const it = r.it, c = it.contract || {}, long = it.dir === 'LONG';
23188    const tp = (it.premAtTarget != null) ? { prem: it.premAtTarget, pct: it.targetPct } : ((typeof _ideaPremAtTarget === 'function') ? _ideaPremAtTarget(it) : null);
23189    let right = '';
23190    if (r.trk && r.trk.status) right += '<span class="trk-pill ' + (r.trk.status === 'win' ? 'win' : 'loss') + '" style="font-size:9px">' + (r.trk.status === 'expired' ? 'EXP' : String(r.trk.status).toUpperCase()) + '</span>' + (r.trk.booked ? '<span style="font-size:8px;font-weight:800;color:#22c55e;border:1px solid rgba(34,197,94,.45);border-radius:4px;padding:0 4px">BOOK</span>' : '');
23191    else if (r.trk && r.trk.live != null) right += '<span style="font-weight:800;color:' + (r.trk.live >= 0 ? '#5ef08a' : '#fca5a5') + '" title="live contract P/L vs the listed premium">' + (r.trk.live >= 0 ? '+' : '') + r.trk.live + '%</span>';
23192    else if (r.pl != null) right += '<span style="font-weight:800;color:' + (r.pl >= 0 ? '#5ef08a' : '#fca5a5') + '">' + (r.pl >= 0 ? '+' : '') + r.pl + '%</span>';
23193    if (r.score != null) right += '<span style="font-size:11px;font-weight:800;color:#e2e8f0">
23193' + r.score + (r.grade ? ' <span style="color:#94a3b8">' + r.grade + '</span>' : '') + '</span>';
23194    return '<div style="border:1px solid var(--border);border-left:3px solid ' + (long ? '#22c55e' : '#ef4444') + ';border-radius:9px;padding:9px 11px;margin:0 0 9px">'
23195      + '<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap"><span style="font-size:9px;font-weight:800;letter-spacing:.5px;color:' + r.col + '">' + r.src + '</span>'
23196      + (r.at ? '<span style="font-size:9px;color:#9fb0c9;font-weight:700" title="when this pick was given">' + fmtAt(r.at) + '</span>' : '')
23197      + '<span style="margin-left:auto;display:flex;align-items:center;gap:5px">' + right + '</span></div>'
23198      + '<div style="margin-top:4px;font-size:13px;color:#e2e8f0;font-weight:700"><span style="color:' + (long ? '#22c55e' : '#ef4444') + '">' + (long ? 'BUY CALL' : 'BUY PUT') + '</span> $' + c.strike + ' · ' + String(c.expiry || '').slice(5).replace('-', '/') + (c.prem != null ? ' @ $' + (+c.prem).toFixed(2) : '') + '</div>'
23199      + '<div style="margin-top:4px;font-size:11px;color:#9fb0c9">Entry ' + (it.entryLo === it.entryHi ? it.entryLo : (it.entryLo + '–' + it.entryHi)) + ' · Target <b style="color:#22c55e">' + it.target + '</b>' + (tp && tp.prem != null ? ' <span style="color:#5ef08a">≈$' + (+tp.prem).toFixed(2) + '</span>' : '') + ' · Stop <b style="color:#ef4444">' + it.stop + '</b></div>'
23200      + '</div>';
23201  };
23202  const _now = new Date();
23203  let h = '<div class="nf-tp-sec" style="display:flex;align-items:baseline;gap:8px">SYSTEM PICKS — ' + tk
23204    + '<span style="margin-left:auto;font-size:10px;font-weight:800;color:#e2e8f0;letter-spacing:.3px" title="when this panel last refreshed — each card shows its own give-time">updated ' + _now.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + _now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + '</span></div><div style="padding:4px 12px 6px">';
23205  h += rows.length ? rows.map(card).join('') : '<div style="font-size:11.5px;color:var(--dim);padding:4px 0 8px">Not on any of today\'s lists (' + (((typeof isAdminUser !== 'undefined' && isAdminUser) || (typeof GF_IS_ADMIN_HINT !== 'undefined' && GF_IS_ADMIN_HINT)) ? 'Setups · Watchlist · Ghost · SPX/SPY' : 'Setups · Watchlist') + ').</div>';
23206  h += '</div><div class="nf-tp-sec">FRESH IDEA — THIS TICKER</div><div style="padding:4px 12px 12px">';
23207  if (ti && ti.idea) {
23208    const it = ti.idea, c = it.contract || {}, long = it.dir === 'LONG';
23209    h += card({ src: (long ? 'LONG' : 'SHORT') + ' · SCORE ' + it.score + ' (' + it.grade + ')', col: long ? '#22c55e' : '#ef4444', at: ti.t, it });
23210    h += '<div style="font-size:10.5px;color:#9fb0c9;line-height:1.5;margin-top:2px">' + String(it.rationale || '').replace(/</g, '&lt;') + '</div>';
23211    h += '<button class="flow-trk" style="margin-top:7px" onclick="event.stopPropagation();trackFlow(this)" data-ticker="' + it.ticker + '" data-strike="' + c.strike + '" data-expiry="' + c.expiry + '" data-type="' + ((c.type === 'PUT') ? 'P' : 'C') + '" data-side="buy" data-entry="' + (c.prem != null ? (+c.prem).toFixed(2) : '') + '">+ Track</button>';
23212  } else {
23213    h += '<div style="font-size:11.5px;color:var(--dim)">' + ((ti && ti.error) ? String(ti.error).replace(/</g, '&lt;') : 'Could not build an idea right now.') + '</div>';
23214  }
23215  // EARLIER READS TODAY — how this ticker's fresh read evolved through the day. The CURRENT
23216  // trade (same direction + contract, regardless of target/stop drift) is dropped everywhere in
23217  // the list, not just the newest row — a refresh must never re-show the live trade as "earlier".
23218  let earlier = (ti && Array.isArray(ti.earlier)) ? ti.earlier.slice() : [];
23219  if (ti && ti.idea && earlier.length) {
23220    const c2 = ti.idea.contract || {};
23221    earlier = earlier.filter(e => !(e.dir === ti.idea.dir && e.strike === c2.strike && e.type === c2.type && e.expiry === c2.expiry));
23222  }
23223  if (earlier.length) {
23224    earlier.reverse();
23225    h += '<div style="font-size:9px;letter-spacing:1px;color:var(--dim);font-weight:700;margin:12px 0 5px">EARLIER READS TODAY</div>';
23226    for (const e of earlier) {
23227      const lg = e.dir === 'LONG', tm = new Date(e.at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
23228      h += '<div style="display:flex;align-items:center;gap:6px;padding:4px 0;border-bottom:1px solid rgba(148,163,184,.07);font-size:10.5px;flex-wrap:wrap">'
23229        + '<span style="color:#8fa0b8">' + tm + '</span>'
23230        + '<span style="font-weight:800;color:' + (lg ? '#22c55e' : '#ef4444') + '">' + (lg ? 'CALL' : 'PUT') + '</span>'
23231        + '<span style="color:#e2e8f0;font-weight:700">$' + e.strike + ' · ' + String(e.expiry || '').slice(5).replace('-', '/') + (e.prem != null ? ' @ $' + (+e.prem).toFixed(2) : '') + '</span>'
23232        + '<span style="color:#9fb0c9;margin-left:auto">tgt <b style="color:#22c55e">' + e.target + '</b>
23232 · stop <b style="color:#ef4444">' + e.stop + '</b> · ' + e.score + '</span>'
23233      + '</div>';
23234    }
23235  }
23236  h += '</div>';
23237  if (box._html !== h) { box._html = h; box.innerHTML = h; }
23238}
23239
23240// ── Tracking page ──────────────────────────────────────────────────────────
23241const NF_SECTOR_MAP = (function(){ const m={}; try { for (const sec in WATCHLIST) for (const t of WATCHLIST[sec]) m[t] = sec; } catch (e) {} return m; })();
23242function nfSector(p){ if (p.sector) return p.sector; return NF_SECTOR_MAP[(p.ticker||'').toUpperCase()] || 'Other'; }
23243function nfDTE(expiry){ if (!expiry) return ''; const ed = new Date(String(expiry).slice(0,10)+'T16:00:00'); if (isNaN(ed)) return ''; const d = Math.ceil((ed - new Date())/86400000); return d<=0 ? '0DTE' : d+'DTE'; }
23244function nfMoney(v){ const s = v>=0 ? '+' : '-'; const a = Math.abs(v); return s+'$'+(a>=1000 ? a.toLocaleString(undefined,{maximumFractionDigits:0}) : a.toFixed(0)); }
23245// Demo tracking is fully client-side (no backend) so anonymous demo visitors can try the
23246// Add / Track / Close / Delete flow without an account.
23247window._demoPositions = window._demoPositions || [];
23248function nfIsDemoTracking(){ return (typeof IS_DEMO !== 'undefined' && IS_DEMO); }
23249// Background refresh: while the Tracking tab is open, re-price the open positions every 20s
23250// (the server recomputes each option's live mark on every /api/positions call).
23251let _trackingTimer = null;
23252function startTrackingRefresh(){
23253  stopTrackingRefresh();
23254  _trackingTimer = setInterval(() => {
23255    if (currentView === 'tracking' && typeof loadTracking === 'function') {
23256      // Don't rebuild the body (which resets the add/edit form) while the user is filling it —
23257      // that was the "form blinks and hides again" bug. Skip this refresh until it's closed.
23258      const f = document.getElementById('trkAddForm');
23259      if (f && f.style.display !== 'none') return;
23260      loadTracking();
23261    } else stopTrackingRefresh();
23262  }, 8000);
23263}
23264function stopTrackingRefresh(){ if (_trackingTimer){ clearInterval(_trackingTimer); _trackingTimer = null; } }
23265function loadTracking(){
23266  const body = document.getElementById('trackingBody'); if (!body) return;
23267  if (nfIsDemoTracking()) { renderTracking(window._demoPositions.slice()); return; }
23268  fetch('/api/positions').then(r=>r.json()).then(res=>{
23269    const positions = (res && res.positions) || [];
23270    // Admin demo seed: if an admin opens Tracking with NO positions, drop in a few sample
23271    // trades once (so the page is populated for a reveal). Guarded per-browser so it won't
23272    // come back after you delete them. Real positions via the API — close/delete/total all work.
23273    if (!positions.length && typeof isAdminUser !== 'undefined' && isAdminUser) {
23274      let seeded = true; try { seeded = localStorage.getItem('ghostSampleSeeded') === '1'; } catch (e) {}
23275      if (!seeded) { try { localStorage.setItem('ghostSampleSeeded','1'); } catch (e) {} nfSeedSamplePositions(); return; }
23276    }
23277    renderTracking(positions);
23278  }).catch(()=>{ body.innerHTML = '<div class="col-loading col-error">Could not load positions.</div>'; });
23279}
23280// One-time sample positions for an admin demo. Posts a handful of realistic trades (three
23281// open, one already closed as a win) then re-renders. Delete them anytime; they won't reseed.
23282// Valid options expiry: a FRIDAY roughly `weeksOut` weeks from now (local date, no UTC shift).
23283function nfNextFriday(weeksOut){
23284  const d = new Date();
23285  d.setDate(d.getDate() + (weeksOut||0)*7);
23286  d.setDate(d.getDate() + ((5 - d.getDay() + 7) % 7)); // advance to Friday (day 5)
23287  return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
23288}
23289function nfSeedSamplePositions(){
23290  const strikeInc = s => s>=1000?25 : s>=200?5 : s>=50?2.5 : 1;
23291  const roundStrike = (s, dir) => { const inc=strikeInc(s); const base=Math.round(s/inc)*inc; return +((dir>0?base+inc:dir<0?base-inc:base).toFixed(2)); };
23292  const est = s => Math.max(0.5, +(s*0.015).toFixed(2)); // rough ~2-week ATM premium ≈ 1.5% of spot
23293  const FALLBACK = { NVDA:200, TSLA:390, SPY:600, AAPL:230 };
23294  // Anchor each sample to the ticker's REAL spot so strikes/entries look right.
23295  const build = async (t, type, wk) => {
23296    let spot = 0; try { const lv = await fetch('/api/levels/'+t).then(r=>r.json()); spot = (lv && lv.spot) || 0; } catch(e){}
23297    if (!spot) spot = FALLBACK[t] || 100;
23298    const strike = roundStrike(spot, type==='C'?1:-1); // slightly OTM for the option type
23299    return { ticker:t, type, strike, expiry:nfNextFriday(wk), entry:est(spot), side:'buy', source:'sample' };
23300  };
23301  const post = p => fetch('/api/positions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}).then(r=>r.json());
23302  Promise.all([ build('NVDA','C',2), build('TSLA','C',2), build('SPY','P',1) ])
23303    .then(list => Promise.all(list.map(post)))
23304    .then(async () => {
23305      const cp = await build('AAPL','C',2);
23306      const res = await post(cp);
23307      const id = res && res.position && res.position.id;
23308      if (id) await fetch('/api/positions/'+encodeURIComponent(id)+'/close',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({closePrice:+(cp.entry*1.4).toFixed(2)})});
23309    })
23310    .then(()=>loadTracking()).catch(()=>loadTracking());
23311}
23312function renderTracking(positions){
23313  const body = document.getElementById('trackingBody'); if (!body) return;
23314  window._nfPos = positions; // stash for edit lookup
23315  const open = positions.filter(p=>p.status!=='closed'), closed = positions.filter(p=>p.status==='closed');
23316  const pc = v => v>=0 ? 'pos' : 'neg';
23317  let totPnl=0, sumPct=0, pnlCount=0;
23318  for (const p of open){ if (typeof p.pnlDollar==='number') totPnl+=p.pnlDollar; if (typeof p.pnlPct==='number'){ sumPct+=p.pnlPct; pnlCount++; } }
23319  const avgPct = pnlCount ? sumPct/pnlCount : 0;
23320  const wins = closed.filter(p=>(p.realizedDollar||0)>0).length, winRate = closed.length ? Math.round(wins/closed.length*100) : 0;
23321  const realized = closed.reduce((s,p)=>s+(p.realizedDollar||0),0);   // sum of closed P&L — grows on close, shrinks on delete
23322  const totalPnl = totPnl + realized;                                  // unrealized (open) + realized (closed)
23323  let best=null; for (const p of open){ if (typeof p.pnlPct==='number' && (!best || p.pnlPct>best.pnlPct)) best=p; }
23324  const bySec={}; for (const p of open){ (bySec[nfSector(p)] || (bySec[nfSector(p)]=[])).push(p); }
23325  const contract = p => '<span class="cp '+(p.type==='C'?'c':'p')+'">'+p.type+'</span> '+p.strike+' · '+String(p.expiry).slice(5).replace('-','/')+((p.size&&p.size>1)?' <span style="color:#8b9cb5;font-weight:700">&times;'+p.size+'</span>':'');
23326  let html = '<div class="trk-top"><div><h2>Position Tracking</h2><div class="trk-desc">Flows you tracked and custom positions — live mark vs entry, in $ and %.</div></div>'+
23327    '<button class="trk-add-btn" onclick="nfToggleAddForm()">+ Add position</button></div>'+
23328    '<div id="trkAddForm" class="trk-addform" style="display:none">'+
23329      '<input id="trkT" placeholder="Ticker" maxlength="6" style="text-transform:uppercase" onchange="nfTrkTickerChanged()">'+
23330      '<select id="trkTy" onchange="nfTrkFillEntry()"><option value="C">Call</option><option value="P">Put</option></select>'+
23331      '<select id="trkK" onchange="nfTrkFillEntry()" title="strikes around current price"><option value="">Strike</option></select>'+
23332      '<input id="trkE" type="date" title="click for calendar" onclick="try{this.showPicker()}catch(e){}" onchange="nfTrkFillEntry()">'+
23333      '<input id="trkEn" type="number" placeholder="Entry $" step="0.01" title="auto-fills to the live mark — edit to override" oninput="nfTrkEntryDirty=true">'+
23334      '<input id="trkQty" type="number" placeholder="Contracts" min="1" step="1" value="1" title="number of contracts" style="width:92px">'+
23335      '<button id="trkSaveBtn" onclick="saveTrkForm()">Save</button>'+
23336      '<button onclick="nfToggleAddForm(true)" style="background:none;border:1px solid var(--t-border2,#26364a);color:var(--dim)">Cancel</button></div>'+
23337    '<div class="trk-tiles">'+
23338      '<div class="trk-tile"><div class="k">Open positions</div><div class="v">'+open.length+'</div><div class="sub">'+Object.keys(bySec).length+' sectors</div></div>'+
23339      '<div class="trk-tile"><div class="k">Unrealized P&amp;L</div><div class="v '+pc(totPnl)+'">'+nfMoney(totPnl)+'</div><div class="sub '+pc(avgPct)+'">'+(avgPct>=0?'+':'')+avgPct.toFixed(1)+'% avg &middot; open</div></div>'+
23340      '<div class="trk-tile"><div class="k">Realized P&amp;L</div><div class="v '+pc(realized)+'">'+nfMoney(realized)+'</div><div class="sub">'+wins+'W / '+(closed.length-wins)+'L &middot; '+winRate+'%</div></div>'+
23341      '<div class="trk-tile"><div class="k">Total P&amp;L</div><div class="v '+pc(totalPnl)+'">'+nfMoney(totalPnl)+'</div><div class="sub">unrealized + realized</div></div>'+
23342    '</div>';
23343  if (!open.length) html += '<div class="trk-empty">No open positions yet. Hit <b>Track</b> on a flow row, or add one above.</div>';
23344  // Action bar for combining legs that were tracked separately (e.g. a strangle added one leg at a
23345  // time, or tracked before multi-leg support existed). Hidden until 2+ are selected.
23346  html += '<div id="trkGrpBar" style="display:none;position:sticky;bottom:0;z-index:5;margin:10px 0 0;padding:10px 14px;background:var(--card);border:1px solid rgba(168,85,247,.4);border-radius:10px;align-items:center;gap:12px">'
23347    + '<span id="trkGrpMsg" style="font-size:12px;color:#cbd5e1"></span>'
23348    + '<button class="trk-close" style="margin-left:auto" onclick="groupSelected()">Combine into one trade</button>'
23349    + '<button class="trk-close" onclick="nfGrpClear()">Cancel</button></div>';
23350  for (const sec of Object.keys(bySec)){
23351    const rows = bySec[sec], secPnl = rows.reduce((s,p)=>s+(p.pnlDollar||0),0);
23352    html += '<div class="trk-sector"><div class="trk-sechead"><span class="nm">'+sec.toUpperCase()+'</span><span class="ct">'+rows.length+' position'+(rows.length>1?'s':'')+'</span><span class="pl '+pc(secPnl)+'">'+nfMoney(secPnl)+'</span></div>'+
23353      '<div class="trk-tblwrap"><table class="trk-tbl"><thead><tr><th>
23353Ticker</th><th>Contract</th><th class="num">Entry</th><th class="num">Now</th><th class="num">P&amp;L $</th><th class="num">P&amp;L %</th><th class="num">DTE</th><th></th></tr></thead><tbody>';
23354    // Multi-leg trades (earnings straddle/strangle) are stored as one position per leg so each marks
23355    // independently — but they're ONE trade. Fold legs sharing a `grp` into a single combined row:
23356    // summed premium in/out and the P&L of the pair. Sector + tile totals already summed both legs,
23357    // so only the row presentation changes here.
23358    const groups=[], _byGrp={};
23359    for (const p of rows){
23360      if (p.grp && _byGrp[p.grp]) { _byGrp[p.grp].push(p); continue; }
23361      const g=[p]; if (p.grp) _byGrp[p.grp]=g; groups.push(g);
23362    }
23363    for (const g of groups){
23364      if (g.length < 2){
23365        const p=g[0];
23366        const cls = typeof p.pnlDollar==='number' ? pc(p.pnlDollar) : '';
23367        html += '<tr><td class="tk">'
23368            + '<input type="checkbox" class="trk-grp-cb" value="'+p.id+'" data-ticker="'+p.ticker+'" onchange="nfGrpSel()" title="select to combine with another leg into one trade" style="margin-right:6px;vertical-align:-1px;cursor:pointer">'
23369            + p.ticker+'</td><td>'+contract(p)+'</td>'+
23370          '<td class="num">'+(+p.entry).toFixed(2)+'</td>'+
23371          '<td class="num '+cls+'">'+(typeof p.mark==='number'?p.mark.toFixed(2):'—')+'</td>'+
23372          '<td class="num '+cls+'">'+(typeof p.pnlDollar==='number'?nfMoney(p.pnlDollar):'—')+'</td>'+
23373          '<td class="num '+cls+'">'+(typeof p.pnlPct==='number'?((p.pnlPct>=0?'+':'')+p.pnlPct.toFixed(0)+'%'):'—')+'</td>'+
23374          '<td class="num">'+nfDTE(p.expiry)+'</td>'+
23375          '<td style="white-space:nowrap"><button class="trk-close" onclick="nfEditPosition(\''+p.id+'\')" title="edit ticker / strike / expiry / entry">Edit</button> <input id="trkPx_'+p.id+'" class="trk-px" type="number" step="0.01" min="0" inputmode="decimal" placeholder="'+(typeof p.mark==='number'?p.mark.toFixed(2):'price')+'" title="type your exit price, then hit Close"> <button class="trk-close" onclick="closePosition(\''+p.id+'\')" title="close at the typed price (empty = market)">Close</button> <button class="trk-close" onclick="_doClosePosition(\''+p.id+'\',null)" title="close at the live market mark">Mkt</button></td></tr>';
23376        continue;
23377      }
23378      const p0=g[0], kind=p0.grpKind||'COMBO';
23379      const entrySum=g.reduce((s,p)=>s+(+p.entry||0),0);
23380      const allMarked=g.every(p=>typeof p.mark==='number');
23381      const markSum=allMarked?g.reduce((s,p)=>s+p.mark,0):null;
23382      const allPnl=g.every(p=>typeof p.pnlDollar==='number');
23383      const pnlSum=allPnl?g.reduce((s,p)=>s+p.pnlDollar,0):null;
23384      // % off the true dollar cost basis, so it stays exact even if the legs have different sizes.
23385      const basis=g.reduce((s,p)=>s+(+p.entry||0)*100*(p.size||1),0);
23386      const pctSum=(pnlSum!=null&&basis>0)?(pnlSum/basis*100):null;
23387      const cls = pnlSum!=null ? pc(pnlSum) : '';
23388      const legTxt=g.map(p=>'<span class="cp '+(p.type==='C'?'c':'p')+'">'+p.type+'</span> '+p.strike).join(' + ');
23389      const ids=g.map(p=>p.id).join(',');
23390      const gk=p0.grp||('g'+p0.id), openLegs=nfLegsOpen(gk);
23391      html += '<tr><td class="tk">'+p0.ticker+'</td>'+
23392        '<td><span style="display:inline-block;font-size:9px;font-weight:800;letter-spacing:.5px;color:#a855f7;border:1px solid rgba(168,85,247,.4);border-radius:4px;padding:0 5px;margin-right:6px;vertical-align:1px">'+kind+'</span>'
23393          + legTxt + ' · ' + String(p0.expiry).slice(5).replace('-','/')
23394          + '<div onclick="nfToggleLegs(\''+gk+'\')" title="show each leg to edit or close it on its own" style="font-size:10px;color:#8b9cb5;margin-top:2px;cursor:pointer;user-select:none">'
23395            + '<span id="cav_'+gk+'" style="display:inline-block;transition:transform .12s;transform:rotate('+(openLegs?90:0)+'deg)">▸</span> '
23396            + g.length+' legs · combined</div></td>'+
23397        '<td class="num" title="both legs">'+entrySum.toFixed(2)+'</td>'+
23398        '<td class="num '+cls+'">'+(markSum!=null?markSum.toFixed(2):'—')+'</td>'+
23399        '<td class="num '+cls+'">'+(pnlSum!=null?nfMoney(pnlSum):'—')+'</td>'+
23400        '<td class="num '+cls+'">'+(pctSum!=null?((pctSum>=0?'+':'')+pctSum.toFixed(0)+'%'):'—')+'</td>'+
23401        '<td class="num">'+nfDTE(p0.expiry)+'</td>'+
23402        '<td style="white-space:nowrap"><button class="trk-close" onclick="ungroupPositions(\''+ids+'\')" title="split back into separate legs">Ungroup</button> <button class="trk-close" onclick="closeGroup(\''+ids+'\')" title="close every leg of this '+kind.toLowerCase()+'">Close all</button></td></tr>';
23403      // Per-leg detail — edit or close one side without breaking the group a
23403part first.
23404      for (const p of g){
23405        const lc = typeof p.pnlDollar==='number' ? pc(p.pnlDollar) : '';
23406        html += '<tr class="trk-leg trk-leg-'+gk+'" style="display:'+(openLegs?'table-row':'none')+';background:rgba(148,163,184,.03)">'
23407          + '<td class="tk" style="color:#8b9cb5;font-weight:600;padding-left:22px">↳</td>'
23408          + '<td style="color:#9fb0c9">'+contract(p)+'</td>'
23409          + '<td class="num">'+(+p.entry).toFixed(2)+'</td>'
23410          + '<td class="num '+lc+'">'+(typeof p.mark==='number'?p.mark.toFixed(2):'—')+'</td>'
23411          + '<td class="num '+lc+'">'+(typeof p.pnlDollar==='number'?nfMoney(p.pnlDollar):'—')+'</td>'
23412          + '<td class="num '+lc+'">'+(typeof p.pnlPct==='number'?((p.pnlPct>=0?'+':'')+p.pnlPct.toFixed(0)+'%'):'—')+'</td>'
23413          + '<td class="num">'+nfDTE(p.expiry)+'</td>'
23414          + '<td style="white-space:nowrap"><button class="trk-close" onclick="nfEditPosition(\''+p.id+'\')" title="edit this leg only">Edit</button> <input id="trkPx_'+p.id+'" class="trk-px" type="number" step="0.01" min="0" inputmode="decimal" placeholder="'+(typeof p.mark==='number'?p.mark.toFixed(2):'price')+'" title="type your exit price, then hit Close"> <button class="trk-close" onclick="closePosition(\''+p.id+'\')" title="close this leg at the typed price (empty = market)">Close</button> <button class="trk-close" onclick="_doClosePosition(\''+p.id+'\',null)" title="close this leg at the live market mark">Mkt</button></td></tr>';
23415      }
23416    }
23417    html += '</tbody></table></div></div>';
23418  }
23419  if (closed.length){
23420    html += '<div class="trk-sector"><div class="trk-sechead"><span class="nm">CLOSED — RECORD</span><span class="ct">'+closed.length+' closed</span><span class="pl '+pc(realized)+'">'+nfMoney(realized)+' realized</span></div>'+
23421      '<div class="trk-tblwrap"><table class="trk-tbl"><thead><tr><th>Ticker</th><th>Contract</th><th class="num">Entry</th><th class="num">Close</th><th class="num">P&amp;L $</th><th class="num">P&amp;L %</th><th>Result</th><th></th></tr></thead><tbody>';
23422    for (const p of closed){
23423      const win = (p.realizedDollar||0)>0;
23424      html += '<tr><td class="tk">'+p.ticker+'</td><td>'+contract(p)+'</td>'+
23425        '<td class="num">'+(+p.entry).toFixed(2)+'</td><td class="num">'+(typeof p.closePrice==='number'?p.closePrice.toFixed(2):'—')+'</td>'+
23426        '<td class="num '+(win?'pos':'neg')+'">'+(typeof p.realizedDollar==='number'?nfMoney(p.realizedDollar):'—')+'</td>'+
23427        '<td class="num '+(win?'pos':'neg')+'">'+(typeof p.realizedPct==='number'?((p.realizedPct>=0?'+':'')+p.realizedPct.toFixed(0)+'%'):'—')+'</td>'+
23428        '<td><span class="trk-pill '+(win?'win':'loss')+'">'+(win?'WIN':'LOSS')+'</span></td>'+
23429        '<td><button class="trk-close" onclick="deletePosition(\''+p.id+'\')" title="remove from record">✕</button></td></tr>';
23430    }
23431    html += '</tbody></table></div></div>';
23432  }
23433  body.innerHTML = html;
23434}
23435// ── Add / edit position form ──
23436let nfTrkEditId = null;      // id of the position being edited (null = create mode)
23437let nfTrkEntryDirty = false; // true once the user types their own entry price → stop auto-filling
23438function nfTrkResetForm(){
23439  nfTrkEditId = null; nfTrkEntryDirty = false;
23440  const t=document.getElementById('trkT'), ty=document.getElementById('trkTy'), k=document.getElementById('trkK'),
23441        e=document.getElementById('trkE'), en=document.getElementById('trkEn'), sb=document.getElementById('trkSaveBtn');
23442  if(t)t.value=''; if(ty)ty.value='C'; if(k)k.innerHTML='<option value="">Strike</option>'; if(e)e.value=''; if(en)en.value='';
23443  var _q=document.getElementById('trkQty'); if(_q)_q.value='1';
23444  if(sb)sb.textContent='Save';
23445}
23446function nfToggleAddForm(forceClose){
23447  const f = document.getElementById('trkAddForm'); if (!f) return;
23448  const open = f.style.display !== 'none';
23449  if (forceClose || open) { f.style.display='none'; nfTrkResetForm(); }
23450  else { nfTrkResetForm(); f.style.display='flex'; }
23451}
23452// Populate the strike dropdown with ~20 strikes above and below the current price.
23453async function nfTrkFillStrikes(ticker, selectStrike){
23454  const sel = document.getElementById('trkK'); if (!sel || !ticker) return;
23455  sel.innerHTML = '<option value="">…</option>';
23456  let spot = 0, strikes = [];
23457  try {
23458    const [lv, gx] = await Promise.all([
23459      fetch('/api/levels/'+encodeURIComponent(ticker)).then(r=>r.json()).catch(()=>null),
23460      fetch('/api/gex/strike/'+encodeURIComponent(ticker)).then(r=>r.json()).catch(()=>null)
23461    ]);
23462    if (lv && lv.spot) spot = +lv.spot;
23463    const arr = (gx && (gx.data||gx)) || [];
23464    strikes = arr.map(s=>parseFloat(s.strike)).filter(v=>v>0);
23465  } catch(e){}
23466  strikes = Array.from(new Set(strikes)).sort((a,b)=>a-b);
23467  // Fallback: synthesize strikes around spot if the chain didn't return any.
23468  if (!strikes.length && spot>0){ const inc = spot>=500?5:spot>=100?1:spot>=25?0.5:0.5; for(let i=-20;i<=20;i++) strikes.push(+(Math.round(spot/inc)*inc + i*inc).toFixed(2)); }
23469  // Window to ±20 strikes around spot.
23470  if (spot>0 && strikes.length>40){
23471    let ni=0, best=Infinity; strikes.forEach((v,i)=>{const d=Math.abs(v-spot); if(d<best){best=d;ni=i;}});
23472    strikes = strikes.slice(Math.max(0,ni-20), ni+21);
23473  }
23474  const want = selectStrike!=null ? +selectStrike : (spot>0 ? strikes.reduce((a,b)=>Math.abs(b-spot)<Math.abs(a-spot)?b:a, strikes[0]) : null);
23475  if (selectStrike!=null && !strikes.includes(+selectStrike)) strikes.push(+selectStrike);
23476  strikes.sort((a,b)=>a-b);
23477  sel.innerHTML = '<option value="">Strike</option>' + strikes.map(v=>'<option value="'+v+'"'+(want!=null && v===want?' selected':'')+'>'+v+'</option>').join('');
23478}
23479function nfTrkTickerChanged(){
23480  const t = (document.getElementById('trkT').value||'').toUpperCase().trim();
23481  if (!t) return;
23482  nfTrkFillStrikes(t).then(()=>nfTrkFillEntry());
23483}
23484// Auto-fill the entry price with the option's live mark (unless the user typed their own).
23485function nfTrkFillEntry(){
23486  if (nfTrkEntryDirty) return;
23487  const t=(document.getElementById('trkT').value||'').toUpperCase().trim();
23488  const ty=document.getElementById('trkTy').value, k=document.getElementById('trkK').value, e=document.getElementById('trkE').value;
23489  const en=document.getElementById('trkEn'); if(!en) return;
23490  if(!t || !(parseFloat(k)>0) || !e){ return; }
23491  fetch('/api/option-mark/'+encodeURIComponent(t)+'?strike='+encodeURIComponent(k)+'&expiry='+encodeURIComponent(e)+'&type='+ty)
23492    .then(r=>r.json()).then(res=>{ if(!nfTrkEntryDirty && res && res.mark!=null) en.value = (+res.mark).toFixed(2); }).catch(()=>{});
23493}
23494function nfEditPosition(id){
23495  const p = (window._nfPos||[]).find(x=>x.id===id); if(!p) return;
23496  const f=document.getElementById('trkAddForm'); if(!f) return;
23497  nfTrkEditId = id; nfTrkEntryDirty = true; // keep the existing entry unless the user changes it
23498  document.getElementById('trkT').value = p.ticker;
23499  document.getElementById('trkTy').value = p.type;
23500  document.getElementById('trkE').value = String(p.expiry).slice(0,10);
23501  document.getElementById('trkEn').value = (+p.entry).toFixed(2);
23502  var _q=document.getElementById('trkQty'); if(_q) _q.value = p.size||1;
23503  document.getElementById('trkSaveBtn').textContent = 'Update';
23504  f.style.display='flex';
23505  nfTrkFillStrikes(p.ticker, p.strike);
23506}
23507function saveTrkForm(){
23508  const t = (document.getElementById('trkT').value||'').toUpperCase().trim();
23509  const ty = document.getElementById('trkTy').value, k = parseFloat(document.getElementById('trkK').value);
23510  const e = document.getElementById('trkE').value, en = parseFloat(document.getElementById('trkEn').value);
23511  const qtyEl = document.getElementById('trkQty'); const qty = Math.max(1, parseInt(qtyEl && qtyEl.value) || 1);
23512  if (!t || !(k>0) || !e || !(en>=0)) { alert('Enter ticker, strike, expiry and entry price.'); return; }
23513  const editing = !!nfTrkEditId;
23514  if (nfIsDemoTracking()) {
23515    if (editing) { const p = window._demoPositions.find(x=>x.id===nfTrkEditId); if (p) { p.ticker=t; p.type=ty; p.strike=k; p.expiry=e; p.entry=en; p.size=qty; } }
23516    else { window._demoPositions.unshift({ id:'demo'+Date.now().toString(36), ticker:t, type:ty, strike:k, expiry:e, entry:en, size:qty, side:'buy', source:'custom', status:'open', mark:null }); }
23517    nfToggleAddForm(true); loadTracking(); return;
23518  }
23519  const url = editing ? '/api/positions/'+encodeURIComponent(nfTrkEditId) : '/api/positions';
23520  const method = editing ? 'PUT' : 'POST';
23521  const payload = editing ? {ticker:t,type:ty,strike:k,expiry:e,entry:en,size:qty}
23522                          : {ticker:t,type:ty,strike:k,expiry:e,entry:en,size:qty,side:'buy',source:'custom'};
23523  fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
23524    .then(r=>r.json()).then(res=>{ if (res && res.ok){ nfToggleAddForm(true); loadTracking(); } else alert((res&&res.error)||'Could not save.'); }).catch(()=>alert('Could not save.'));
23525}
23526// Close flow: each open row has a small price box next to Close. Close uses the typed price
23527// (empty box = market); Mkt always closes at the live mark. Server accepts closePrice directly.
23528function closePosition(id){
23529  const inp = document.getElementById('trkPx_'+id);
23530  const raw = inp ? String(inp.value).trim() : '';
23531  if (!raw) { _doClosePosition(id, null); return; }
23532  const v = parseFloat(raw);
23533  if (!isFinite(v) || v < 0) { if (inp){ inp.style.borderColor='#f87171'; inp.focus(); } return; }
23534  _doClosePosition(id, v);
23535}
23536function _doClosePosition(id, price){
23537  if (nfIsDemoTracking()) { const p = window._demoPositions.find(x=>x.id===id); if (p){ p.status='closed'; p.closePrice=(price!=null)?+(+price).toFixed(2):+(p.entry*1.35).toFixed(2); p.realizedDollar=(p.closePrice-p.entry)*100*(p.size||1); p.realizedPct=((p.closePrice-p.entry)/p.entry)*100; } loadTracking(); return; }
23538  const body = (price!=null) ? JSON.stringify({closePrice:+price}) : '{}';
23539  fetch('/api/positions/'+encodeURIComponent(id)+'/close',{method:'POST',headers:{'Content-Type':'application/json'},body:body}).then(r=>r.json()).then(()=>loadTracking()).catch(()=>{});
23540}
23541// Which combined rows are expanded to show their individual legs. Kept outside the render so the
23542// expansion survives loadTracking() — otherwise editing a leg would collapse the row you're working in.
23543window._nfOpenLegs = window._nfOpenLegs || {};
23544function nfLegsOpen(gk){ return !!window._nfOpenLegs[gk]; }
23545function nfToggleLegs(gk){
23546  const on = !window._nfOpenLegs[gk];
23547  window._nfOpenLegs[gk] = on;
23548  // Flip the DOM directly — no refetch just to expand a row.
23549  document.querySelectorAll('.trk-leg-'+gk).forEach(tr=>{ tr.style.display = on ? 'table-row' : 'none'; });
23550  const cav = document.getElementById('cav_'+gk);
23551  if (cav) cav.style.transform = 'rotate('+(on?90:0)+'deg)';
23552}
23553// ── Combine already-tracked legs into one trade ──
23554function _grpChecked(){ return Array.prototype.slice.call(document.querySelectorAll('.trk-grp-cb:checked')); }
23555function nfGrpSel(){
23556  const sel=_grpChecked(), bar=document.getElementById('trkGrpBar'), msg=document.getElementById('trkGrpMsg');
23557  if(!bar) return;
23558  // Lock the selection to ONE ticker. Once a leg is picked, every other ticker's checkbox is disabled
23559  // so a cross-ticker combination can't even be attempted — a combined row only makes sense as legs of
23560  // the same underlying, and rejecting it after the click is a worse experience than not offering it.
23561  const lock = sel.length ? sel[0].dataset.ticker : null;
23562  document.querySelectorAll('.trk-grp-cb').forEach(function(cb){
23563    const off = !!lock && cb.dataset.ticker!==lock;
23564    cb.disabled = off;
23565    cb.title = off ? ('Legs must be the same ticker — clear the selection to pick '+cb.dataset.ticker)
23566                   : 'select to combine with another leg of the same ticker into one trade';
23567    const tr = cb.closest('tr'); if(tr) tr.style.opacity = off ? '0.4' : '';
23568  });
23569  if(sel.length<2){ bar.style.display='none'; return; }
23570  bar.style.display='flex';
23571  if(msg) msg.innerHTML = '<b>'+sel.length+' '+lock+' legs</b> selected — combine into one position with a single P&amp;L.';
23572}
23573function nfGrpClear(){ _grpChecked().forEach(c=>{c.checked=false;}); nfGrpSel(); }
23574function groupSelected(){
23575  const sel=_grpChecked(); if(sel.length<2) return;
23576  const tks=new Set(sel.map(c=>c.dataset.ticker));
23577  if(tks.size>1){ alert('All legs must be the same ticker.'); return; }
23578  if(nfIsDemoTracking()){
23579    const grp='g'+Date.now().toString(36); const ids=sel.map(c=>c.value);
23580    const legs=window._demoPositions.filter(p=>ids.includes(p.id));
23581    const kind=(legs.length===2 && legs.filter(p=>p.type==='C').length===1 && legs.filter(p=>p.type==='P').length===1 && new Set(legs.map(p=>p.expiry)).size===1)
23582      ? (new Set(legs.map(p=>p.strike)).size===1?'STRADDLE':'STRANGLE') : 'COMBO';
23583    legs.forEach(p=>{p.grp=grp;p.grpKind=kind;}); loadTracking(); return;
23584  }
23585  fetch('/api/positions/group',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:sel.map(c=>c.value)})})
23586    .then(r=>r.json()).then(res=>{ if(!res||!res.ok) alert((res&&res.error)||'Could not combine those.'); loadTracking(); })
23587    .catch(()=>loadTracking());
23588}
23589function ungroupPositions(ids){
23590  const list=String(ids||'').split(',').filter(Boolean); if(!list.length) return;
23591  if(nfIsDemoTracking()){ window._demoPositions.forEach(p=>{ if(list.includes(p.id)){p.grp='';p.grpKind='';} }); loadTracking(); return; }
23592  fetch('/api/positions/group',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:list,ungroup:true})})
23593    .then(r=>r.json()).then(()=>loadTracking()).catch(()=>loadTracking());
23594}
23595// Close every leg of a multi-leg trade at once. Legs are closed sequentially (each locks its own
23596// realized P&L at its own mark) and the view refreshes once at the end, not per leg.
23597function closeGroup(ids){
23598  const list=String(ids||'').split(',').filter(Boolean); if(!list.length) return;
23599  if (nfIsDemoTracking()){
23600    list.forEach(id=>{ const p=window._demoPositions.find(x=>x.id===id);
23601      if(p){ p.status='closed'; p.closePrice=+(p.entry*1.35).toFixed(2); p.realizedDollar=(p.closePrice-p.entry)*100*(p.size||1); p.realizedPct=((p.closePrice-p.entry)/p.entry)*100; } });
23602    loadTracking(); return;
23603  }
23604  Promise.all(list.map(id=>fetch('/api/positions/'+encodeURIComponent(id)+'/close',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json()).catch(()=>null)))
23605    .then(()=>loadTracking()).catch(()=>loadTracking());
23606}
23607function deletePosition(id){
23608  if (nfIsDemoTracking()) { window._demoPositions = window._demoPositions.filter(x=>x.id!==id); loadTracking(); return; }
23609  fetch('/api/positions/'+encodeURIComponent(id),{method:'DELETE'}).then(r=>r.json()).then(()=>loadTracking()).catch(()=>{});
23610}
23611
23612// Demo: realistic sample flow so the Ultra flow panel looks populated off-hours.
23613function demoFlows(ticker, spotHint) {
23614  const isTSLA = (ticker || '').toUpperCase() === 'TSLA';
23615  const step = isTSLA ? 5 : 2;
23616  // Anchor sample-flow strikes to the ticker's CURRENT spot. spotHint (passed straight from the
23617  // chart load for THIS ticker) is race-proof — no dependency on a shared global that another
23618  // ticker's in-flight load could overwrite. Then the GEX-view cache; then a per-ticker fallback.
23619  let spot = 0;
23620  if (typeof spotHint === 'number' && spotHint > 0) spot = spotHint;
23621  try { if (!spot && (typeof chartTicker !== 'undefined') && chartTicker === ticker && typeof chartCurrentPrice === 'number' && chartCurrentPrice > 0) spot = chartCurrentPrice; } catch (e) {}
23622  try { if (!spot) spot = (columnData[ticker] && columnData[ticker].spot) || 0; } catch (e) {}
23623  if (!spot) spot = isTSLA ? 340 : 600;
23624  const base = Math.round(spot / step) * step;
23625  const exps = ['2026-06-12', '2026-06-19', '2026-07-17'];
23626  const specs = [
23627    { d: 2, t: 'CALL', s: 4, prem: 2400000, sz: 5200, ask: 91, sweep: true, tm: '06:32' },
23628    { d: 1, t: 'CALL', s: 2, prem: 1850000, sz: 4200, ask: 88, sweep: true, tm: '06:41' },
23629    { d: 1, t: 'PUT', s: -2, prem: 1340000, sz: 3100, ask: 81, sweep: true, tm: '06:53' },
23630    { d: 0, t: 'CALL', s: 0, prem: 920000, sz: 2600, ask: 74, sweep: false, tm: '07:06' },
23631    { d: 0, t: 'PUT', s: -3, prem: 1100000, sz: 2900, ask: 84, sweep: true, tm: '07:19' },
23632    { d: 1, t: 'CALL', s: 3, prem: 780000, sz: 2100, ask: 77, sweep: false, tm: '07:34' },
23633    { d: 0, t: 'CALL', s: 1, prem: 610000, sz: 1800, ask: 69, sweep: false, tm: '07:48' },
23634    { d: 2, t: 'CALL', s: 6, prem: 560000, sz: 1500, ask: 66, sweep: false, tm: '08:03' },
23635    { d: 1, t: 'PUT', s: -2, prem: 690000, sz: 1700, ask: 72, sweep: false, tm: '08:22' },
23636    { d: 0, t: 'PUT', s: -1, prem: 430000, sz: 1200, ask: 58, sweep: false, tm: '08:41' },
23637  ];
23638  // Use the PST calendar date (same zone the flow "today" filter uses), NOT toISOString()'s
23639  // UTC date — after ~4–5pm PST UTC rolls to the next day, which gave these sample flows
23640  // tomorrow's date and the today-filter wiped them, so the demo flow panel went blank
23641  // after-hours. With the PST date they keep showing the day's earlier flow off-hours.
23642  let today;
23643  try {
23644    const p = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }));
23645    today = p.getFullYear() + '-' + String(p.getMonth() + 1).padStart(2, '0') + '-' + String(p.getDate()).padStart(2, '0');
23646  } catch (e) { today = '2026-06-09'; }
23647  return specs.map(function(sp) {
23648    const askPrem = Math.round(sp.prem * sp.ask / 100);
23649    return {
23650      ticker_symbol: ticker, strike: base + sp.s * step, type: sp.t, expires: exps[sp.d],
23651      total_premium: sp.prem, total_size: sp.sz, price: (sp.prem / sp.sz / 100).toFixed(2),
23652      total_ask_side_prem: askPrem, total_bid_side_prem: sp.prem - askPrem,
23653      has_sweep: sp.sweep, created_at: today + 'T' + sp.tm + ':00'
23654    };
23655  });
23656}
23657
23658// Start live stream on page load
23659connectLiveStream();
23660
23661// Safety-net auto-refresh: during market hours, keep the current view updating even if
23662// the WebSocket nudges stop (WS dropped). Fires only when no live nudge has refreshed in
23663// the last 25s, so it never double-refreshes while the stream is healthy. Server SWR
23664// makes each refresh a cheap warm-cache read.
23665let lastViewRefresh = 0;
23666setInterval(function () {
23667  try {
23668    // ES chart keeps refreshing during the 24/5 futures session even when the equity market is closed.
23669    const _chartFut = (currentView === 'chart' && typeof chartTicker !== 'undefined' && chartTicker === 'ES' && typeof isFuturesSession === 'function' && isFuturesSession());
23670    if (IS_DEMO || (!isMarketOpen() && !_chartFut && !cryptoViewLive())) return;   // crypto views refresh 24/7
23671    const _throttle = _chartFut ? 10000 : 25000;      // ES futures chart refreshes faster (~10s)
23672    if (Date.now() - lastViewRefresh < _throttle) return; // a nudge already refreshed recently
23673    lastViewRefresh = Date.now();
23674    if (currentView === 'gex') {
23675      activeColumns.forEach(function (t, _wi) { if (!demoLocked(t)) loadGEXSlot(_wi); });
23676    } else if (currentView === 'ultra' && ultraTicker) {
23677      loadUltraView(ultraTicker, true);
23678    } else if (currentView === 'chart' && chartTicker) {
23679      delete chartLevelCache[chartTicker];
23680      loadGEXChart(chartTicker, true);
23681    } else if (currentView === 'expiry' && expiryTicker) {
23682      loadExpiryGEX(expiryTicker, true);
23683    }
23684  } catch (e) {}
23685}, 10000);
23686
23687// Quick render GEX from pre-cached data (instant, no fetch)
23688function renderUltraGex(gexEl, infoEl, data, ticker) {
23689  const spot = data.spot || 0;
23690  const king = data.kingNode;
23691  const cw = data.callWall;
23692  const pw = data.putWall;
23693  const vn = data.volNode;
23694  const bias = spot && king ? (spot > king.strike ? '↓ Bearish' : '↑ Bullish') : '';
23695  const biasColor = bias.includes('Bull') ? '#22c55e' : '#ef4444';
23696
23697  infoEl.innerHTML = '<div style="padding:6px 10px;font-family:Segoe UI,monospace;font-size:11px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;background:var(--card)">' +
23698    '<span><strong>' + ticker + '</strong> $' + (spot ? spot.toFixed(2) : '—') + '</span>' +
23699    '<span style="color:' + biasColor + ';font-weight:700">' + bias + '</span></div>' +
23700    '<div style="padding:4px 10px;font-size:10px;font-family:Segoe UI,monospace;display:flex;gap:12px;border-bottom:1px solid var(--border);background:var(--card)">' +
23701    '<span>👑 King <strong style="color:#eab308">' + (king ? king.strike.toFixed(0) : '—') + '</strong></span>' +
23702    '<span>📈 CW <strong style="color:#22c55e">' + (cw ? cw.strike.toFixed(0) : '—') + '</strong></span>' +
23703    '<span>📉 PW <strong style="color:#ef4444">' + (pw ? pw.strike.toFixed(0) : '—') + '</strong></span>' +
23704    (vn ? '<span>🔵 VN <strong style="color:#3b82f6">' + vn.strike.toFixed(0) + '</strong></span>' : '') + '</div>';
23705}
23706
23707// (Cross-tab activeTicker sync handled once, view-agnostically, near setVie
23707w.)
23708
23709// Poll server for TradingView ticker changes (cross-domain sync)
23710
23711// Auto-refresh Ultra Mode data every 60 seconds
23712setInterval(async function() {
23713  if (currentView !== 'ultra' || !ultraTicker) return;
23714  lastViewRefresh = Date.now();   // coordinate with the safety-net (see chart interval note)
23715  try {
23716    console.log('[Ultra] Auto-refreshing', ultraTicker);
23717    await loadUltraView(ultraTicker, true);
23718  } catch {}
23719}, 60 * 1000);
23720
23721// Auto-refresh the GEX view's visible columns in the background (no flicker —
23722// loadGEX updates in place for already-loaded columns).
23723setInterval(function() {
23724  if (currentView !== 'gex' || !activeColumns.length) return;
23725  activeColumns.forEach((t, i) => setTimeout(() => loadGEXSlot(i), i * 300));
23726}, 60 * 1000);
23727
23728// Auto-refresh the Heatmap view (silent — no "Loading…" flash, keeps scroll position).
23729setInterval(async function() {
23730  if (!gfLive('expiry') || !expiryTicker) return;   // also refresh the heatmap tile in My HQ, not only the standalone tab (user 2026-09-16)
23731  window._gexSilentRefresh = true;
23732  try { await loadExpiryGEX(expiryTicker, true); } catch {}
23733  window._gexSilentRefresh = false;
23734}, 60 * 1000);
23735
23736// Auto-refresh the GEX Charts view (silent). Force the VISIBLE timeframe fresh by
23737// dropping its cache entry first; other timeframes stay cached for instant switching.
23738setInterval(async function() {
23739  if (!gfLive('chart') || !chartTicker) return;
23740  lastViewRefresh = Date.now();   // AUDIT FIX: tell the safety-net this counted — the two timers used to stack back-to-back full reloads
23741  delete chartCandleCache[chartTicker + '|' + currentTimeframe];
23742  delete chartLevelCache[chartTicker];
23743  try { await loadGEXChart(chartTicker, true); } catch {}
23744}, 60 * 1000);
23745
23746// Keep all OTHER timeframes warm in the background so switching is always instant.
23747setInterval(function() {
23748  if (!gfLive('chart') || !chartTicker) return;
23749  prefetchChartTimeframes(chartTicker);
23750}, 45 * 1000);
23751
23752// Live current price — during market hours, poll the live price every ~5s and move
23753// the white price line + the in-progress (last) candle, without a full chart reload.
23754setInterval(async function() {
23755  if (!gfLive('chart') || !chartTicker || !chartSeries) return;
23756  if (!isMarketOpen() && !isCryptoTk(chartTicker)) return;   // crypto price line moves 24/7
23757  const tk = chartTicker;               // capture — the user may switch tickers mid-fetch
23758  try {
23759    // Tell the server when the forming bar opened, so it can replay its ~1/sec index tick trail
23760    // and return the bar's TRUE high/low — the 5s poll alone misses spikes between samples.
23761    let _since = 0;
23762    try { const _n = chartCandles.length, _l = _n ? chartCandles[_n - 1] : null; if (_l && _l._ts) _since = _l._ts * 1000; } catch (e) {}
23763    const res = await fetch('/api/price/' + tk + (_since ? ('?since=' + _since) : ''));
23764    const j = await res.json();
23765    // If the chart switched to another ticker while this request was in flight, the price
23766    // belongs to the OLD ticker — applying it stretches the NEW ticker's live candle into a
23767    // phantom green/red spike. Drop stale results.
23768    if (tk !== chartTicker || !chartSeries || !chartCandles || !chartCandles.length) return;
23769    const spot = parseFloat(j && j.price) || 0;
23770    if (spot > 0) {
23771      chartCurrentPrice = spot;
23772      if (_chartPriceLine) { try { _chartPriceLine.applyOptions({ price: spot }); } catch (e) {} }
23773      // Move the in-progress candle to the live price — and when the bar period rolls over,
23774      // OPEN A NEW candle in real time (like TradingView) instead of waiting for the 60s reload.
23775      const n = chartCandles.length;
23776      const last = n ? chartCandles[n - 1] : null;
23777      if (last) {
23778        const tfSec = (n >= 2) ? (last.time - chartCandles[n - 2].time) : 60; // bar spacing from the data
23779        // AUDIT FIX: candle .time is tz-SHIFTED (rawT + tzOffset) while Date.now() is true unix,
23780        // so this rollover check could never fire for non-UTC viewers — price after a bar
23781        // boundary was jammed into the previous candle until the 60s reload. Compare in the
23782        // candles' own basis by shifting "now" the same way.
23783        const _tzShift = (last._ts != null) ? (last.time - last._ts) : 0;
23784        const nowSec = Math.floor(Date.now() / 1000) + _tzShift;
23785        if (tfSec > 0 && nowSec >= last.time + tfSec && nowSec < last.time + tfSec * 3) {
23786          // Carry _ts forward so the new bar knows its TRUE unix start (needed for the tick
23787          // replay below, and for the tz-shift maths on the next rollover).
23788          const nc = { time: last.time + tfSec, _ts: (last._ts != null ? last._ts + tfSec : null), open: spot, high: spot, low: spot, close: spot };
23789          chartCandles.push(nc);
23790          try { chartSeries.update(nc); } catch (e) {}
23791        } else {
23792          last.close = spot;
23793          // Server-side tick replay (indices, admin): j.high/j.low are the TRUE extremes since
23794          // this bar opened, from the ~1/sec Polygon trail — so a spike that happened between
23795          // two 5s polls still draws its wick. Falls back to sample-tracking when absent.
23796          if (j && j.high != null && j.high > last.high) last.high = j.high;
23797          if (j && j.low != null && j.low < last.low) last.low = j.low;
23798          if (spot > last.high) last.high = spot;
23799          if (spot < last.low) last.low = spot;
23800          try { chartSeries.update(last); } catch (e) {}
23801        }
23802      }
23803      alignChartPanelToPrice(spot);
23804    }
23805  } catch (e) {}
23806}, 2000);   // 2s (was 5s): the index feed updates ~1/sec, and a 5s cadence visibly lagged
23807            // TradingView on the forming bar. Every response is served from memory (WS price or
23808            // a 4s price cache), so this adds no upstream load.
23809
23810// ── TradingView Sync — poll active ticker every 2 seconds ──
23811// ── Per-window TradingView sync (link / unlink) ──────────────────────────────
23812// Each browser tab decides for itself whether it follows TradingView. Linked = follows
23813// the TV symbol; unlinked = pinned to the ticker chosen in this window. Manual ticker
23814// picks auto-unlink. State + the pinned ticker persist per-tab via sessionStorage, so
23815// you can run several windows on different tickers without them fighting each other.
23816let tvSyncPaused = false; // legacy alias kept for older call-sites (mirrors !tvSyncLinked)
23817let lastTvTicker = '';
23818let tvSyncLinked = true;
23819try { const _s = sessionStorage.getItem('ghostTvLinked'); if (_s !== null) tvSyncLinked = (_s === '1'); } catch (e) {}
23820function nfSaveTabState(){ try { sessionStorage.setItem('ghostTvLinked', tvSyncLinked ? '1' : '0'); if (chartTicker) sessionStorage.setItem('ghostTicker', chartTicker); } catch (e) {} }
23821function nfPinnedTicker(){ try { return sessionStorage.getItem('ghostTicker') || ''; } catch (e) { return ''; } }
23822function nfUpdateTvSyncBtn(){
23823  const b = document.getElementById('tvSyncBtn'); if (!b) return;
23824  b.classList.toggle('linked', tvSyncLinked);
23825  b.classList.toggle('unlinked', !tvSyncLinked);
23826  const st = b.querySelector('.state'); if (st) st.textContent = tvSyncLinked ? '· ON' : '· OFF';
23827  b.title = tvSyncLinked
23828    ? 'Following TradingView — click to pin this window to its ticker'
23829    : 'Pinned to this ticker — click to follow TradingView';
23830}
23831// Called when the user manually picks a ticker in this window → pin it (unlink from TV).
23832function nfUnlinkTvSync(){ tvSyncLinked = false; tvSyncPaused = true; nfUpdateTvSyncBtn(); nfSaveTabState(); }
23833function nfSetTvSync(linked){
23834  // in HQ this toggle is HQ's own setting, and it is remembered - that is what survives a reload
23835  try{ if(typeof _wsOn!=='undefined' && _wsOn) _wsTvRemember(linked); }catch(e){}
23836  tvSyncLinked = linked; tvSyncPaused = !linked;
23837  nfUpdateTvSyncBtn(); nfSaveTabState();
23838  if (linked) { // re-linking → jump to the current TradingView symbol immediately
23839    fetch('/api/active-ticker').then(r => r.json()).then(d => {
23840      const tk = (d && d.ticker || '').toUpperCase();
23841      // in HQ the chart is one tile among several; chartTicker alone cannot say whether the
23842      // workspace has already been told about this symbol
23843      const _hq = (typeof _wsOn !== 'undefined' && _wsOn);
23844      if (tk && (tk !== chartTicker || (_hq && tk !== lastTvTicker))) { lastTvTicker = tk; setSyncedTicker(tk, { fromTV: true }); }
23845    }).catch(() => {});
23846  }
23847}
23848function nfToggleTvSync(){ nfSetTvSync(!tvSyncLinked); }
23849setInterval(async function() {
23850  // TradingView sync drives the consolidated Ghost Mode view (internally the chart view) - and
23851  // My HQ, where there is no single view to test for: every tile follows.
23852  if (currentView !== 'chart' && !(typeof _wsOn !== 'undefined' && _wsOn)) return;
23853  if (!(isPaidUser || isAdminUser)) return;
23854  if (!tvSyncLinked) return; // this window is pinned — ignore TradingView entirely
23855  try {
23856    const res = await fetch('/api/active-ticker');
23857    const data = await res.json();
23858    if (!data.ticker) return;
23859    const tk = data.ticker.toUpperCase();
23860    if (tk !== chartTicker || ((typeof _wsOn !== 'undefined' && _wsOn) && tk !== lastTvTicker)) setSyncedTicker(tk, { fromTV: true });
23861    lastTvTicker = tk;
23862  } catch {}
23863}, 2000);
23864
23865function buildUltraTickers() {
23866  const el = document.getElementById('ultraTickers');
23867  const allTickers = [...new Set(Object.values(WATCHLIST).flat())];
23868  let html = '';
23869  for (const t of allTickers) {
23870    const isActive = t === ultraTicker;
23871    html += '<button class="exp-ticker-btn ' + (isActive ? 'active' : '') + (demoLocked(t) ? ' demo-locked-btn' : '') + '" id="ultra-btn-' + t + '" data-ticker="' + t + '"' + (IS_DEMO ? '' : ' draggable="true"') + ' title="' + (demoLocked(t) ? 'Locked — sign up to unlock' : 'Drag to reorder · right-click to delete') + '">' + (demoLocked(t) ? '🔒 ' : '') + t + '</button>';
23872  }
23873  el.innerHTML = html;
23874
23875  for (const t of allTickers) {
23876    const btn = document.getElementById('ultra-btn-' + t);
23877    if (btn) {
23878      const ticker = t;
23879      btn.addEventListener('click', function() {
23880        if (demoLocked(ticker)) { window.location.href = '/register'; return; }
23881        // Independent per-view ticker — Ultra keeps its own selection, loads like the Chart tab.
23882        ultraTicker = ticker;
23883        if (typeof gfViewSync === 'undefined' || !gfViewSync) tvSyncPaused = true; // sync ON → keep TV linked
23884        syncActiveGroup(ticker);
23885        buildUltraTickers();
23886        loadUltraView(ticker);
23887      });
23888    }
23889  }
23890  enableButtonDrag('ultraTickers', buildUltraTickers);
23891}
23892
23893async function loadUltraView(ticker, silent, opts) {
23894  if (!silent && typeof gfSyncTo === 'function') gfSyncTo(ticker);
23895  const gexEl = document.getElementById('ultraGex');
23896  const expiryEl = document.getElementById('ultraExpiry');
23897  const infoEl = document.getElementById('ultraInfo');
23898
23899  // Initial load / ticker switch: show cached data or a loading state.
23900  // Silent background refresh: render nothing now — swap in fresh data in place
23901  // when it arrives (below), so there is no flash or blink.
23902  if (!silent) {
23903    if (columnData[ticker] && columnData[ticker].strikes) {
23904      renderUltraGex(gexEl, infoEl, columnData[ticker], ticker);
23905      if (!(typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT)) expiryEl.innerHTML = '<div class="col-loading">Loading Heatmap...</div>';
23906    } else {
23907      gexEl.innerHTML = '<div class="col-loading">Loading GEX...</div>';
23908      if (!(typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT)) expiryEl.innerHTML = '<div class="col-loading">Loading Heatmap...</div>';
23909    }
23910  }
23911
23912  try {
23913    // gex + spot are cached (instant) and drive the ladder + chart. contracts + strike-expiry
23914    // may hit LIVE UW and be slow to fail (timeout) — cap them so a slow/failing secondary feed
23915    // can't stall the whole load. That stall is what made ticker clicks feel dead. Each feed is
23916    // parsed defensively (null on any error), so nothing here can abort the render.
23917    const _json = (url, ms) => {
23918      const ac = new AbortController();
23919      const to = setTimeout(() => ac.abort(), ms);
23920      return fetch(url, { signal: ac.signal }).then(r => r.json()).catch(() => null).finally(() => clearTimeout(to));
23921    };
23922    const _isNew = (typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT);
23923    // New layout needs only gex + spot (both cached → instant) for the ladder + chart. The
23924    // contracts / strike-expiry feeds are unused there (info bar hidden, no heatmap) and hit
23925    // live UW — fetching them just piles up slow requests and stalls the click. Skip them.
23926    // GEX honors the ALL/0DTE/WEEKLY basis (aggregate, or per-expiry filtered) — same as the grid.
23927    const [gexJson, spotJson, contractsJson, gexExpJson] = await Promise.all([
23928      _basisGexJson(ticker, gridExpiryMode).catch(() => null),
23929      _json('/api/spot/' + ticker, 8000),
23930      _isNew ? Promise.resolve(null) : _json('/api/contracts/' + ticker, 4000),
23931      _isNew ? Promise.resolve(null) : _json('/api/gex/strike-expiry/' + ticker, 4000),
23932    ]);
23933
23934    // Stale-guard: if the user clicked a different ticker while this was fetching, drop this
23935    // render so a slow response can't overwrite the newer selection (fixes lagging/blank chart).
23936    if (!silent && typeof ultraTicker !== 'undefined' && ultraTicker !== ticker) return;
23937
23938    let strikes = (gexJson && (gexJson.data || gexJson)) || [];
23939    const spotArr = (spotJson && (spotJson.data || spotJson)) || [];
23940    const contracts = (contractsJson && (contractsJson.data || contractsJson)) || [];
23941
23942    if (!Array.isArray(strikes)) strikes = [];
23943
23944    let spot = 0;
23945    if (Array.isArray(spotArr)) {
23946      for (const s of spotArr) {
23947        const p = parseFloat(s.price || s.underlying_price || 0);
23948        if (p > 0) { spot = p; break; }
23949      }
23950    }
23951
23952    // ── GEX/VEX Column (left side) ──
23953    const mult = metricMult(spot);
23954    const parsed = strikes.map(s => {
23955      const strike = parseFloat(s.strike || 0);
23956      return { strike, netGex: metricRaw(s) * mult, callGex: metricCall(s) * mult, putGex: metricPut(s) * mult };
23957    }).filter(s => s.strike > 0 && Math.abs(s.netGex) > 0);
23958
23959    if (!spot && parsed.length > 0) {
23960      spot = [...parsed].sort((a, b) => Math.abs(b.netGex) - Math.abs(a.netGex))[0].strike;
23961    }
23962
23963    // Filter 30 above/below
23964    let filtered = parsed;
23965    if (spot > 0) {
23966      const above = parsed.filter(s => s.strike >= spot).sort((a, b) => a.strike - b.strike).slice(0, 30);
23967      const below = parsed.filter(s => s.strike < spot).sort((a, b) => b.strike - a.strike).slice(0, 30);
23968      filtered = [...above, ...below];
23969    }
23970    filtered.sort((a, b) => b.strike - a.strike);
23971
23972    // Key levels — calls-only Call Wall, puts-only Put Wall, net King.
23973    const { callWall, putWall, kingNode, totalGex } = pickLevels(filtered, spot);
23974    const bias = totalGex >= 0;
23975    const volNode = computeVolNode(contracts, spot);
23976
23977    // Update info bar — patch text in place so the line never rebuilds (no flash)
23978    if (infoEl) {
23979      if (!infoEl.querySelector('#uinfo-name')) {
23980        infoEl.innerHTML =
23981          '<span id="uinfo-name" style="font-size:18px;font-weight:700"></span>' +
23982          '<span id="uinfo-price" style="font-size:14px;color:var(--dim)"></span>' +
23983          '<span id="uinfo-king" style="color:var(--yellow);font-weight:700"></span>' +
23984          '<span id="uinfo-cw" style="color:var(--green);font-weight:700"></span>' +
23985          '<span id="uinfo-pw" style="color:var(--red);font-weight:700"></span>' +
23986          '<span id="uinfo-vn" style="color:#3b82f6;font-weight:700"></span>' +
23987          '<span id="uinfo-an" style="color:#22d3ee;font-weight:700" title="Active node — most-traded strike in the last ~30 min"></span>' +
23988          '<span id="uinfo-bias" style="font-weight:700"></span>';
23989      }
23990      const setInfo = function(sel, txt) {
23991        const el = infoEl.querySelector(sel);
23992        if (!el) return;
23993        const t = txt || '';
23994        if (el.textContent !== t) el.textContent = t;
23995        el.style.display = t ? '' : 'none';
23996      };
23997      setInfo('#uinfo-name', ticker);
23998      setInfo('#uinfo-price', spot > 0 ? '$' + spot.toFixed(2) : '');
23999      setInfo('#uinfo-king', kingNode ? '👑 ' + kingNode.strike.toFixed(0) : '');
24000      setInfo('#uinfo-cw', callWall ? '📈 ' + callWall.strike.toFixed(0) : '');
24001      setInfo('#uinfo-pw', putWall ? '📉 ' + putWall.strike.toFixed(0) : '');
24002      setInfo('#uinfo-vn', volNode ? '🔵 ' + volNode.strike.toFixed(0) : '');
24003      fetchActiveNode(ticker).then(an => { try { setInfo('#uinfo-an', an && ultraTicker === ticker ? '⚡ ' + an.strike.toFixed(0) : ''); } catch (e) {} });
24004      const biasEl = infoEl.querySelector('#uinfo-bias');
24005      if (biasEl) {
24006        const biasTxt = bias ? '↑ Bullish' : '↓ Bearish';
24007        if (biasEl.textContent !== biasTxt) biasEl.textContent = biasTxt;
24008        biasEl.style.color = bias ? 'var(--green)' : 'var(--red)';
24009      }
24010    }
24011
24012    // Cache so the cached-render path (renderUltraGex) also has the levels + vol node.
24013    columnData[ticker] = { strikes: filtered, spot, callWall, putWall, kingNode, totalGex, volNode };
24014
24015    // Build GEX column — identical heatmap rows to the GEX view + chart panel.
24016    const gexScrollTop = gexEl.scrollTop;
24017    const _nfNew = (typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT);
24018    // Ladder header is intentionally omitted — the full-width toolbar above shows the ticker.
24019    gexEl.innerHTML = buildGexRowsHtml(filtered, spot, callWall, putWall, kingNode, volNode, activeNodes[ticker]);
24020    // Ladder scrolls on wheel / two-finger pinch (see nfEnsureGexScroll).
24021    if (_nfNew) nfEnsureGexScroll();
24022    if (silent) {
24023      // Background refresh — keep scroll position, no jump/flash
24024      gexEl.scrollTop = gexScrollTop;
24025    } else {
24026      // Ticker switch / first load — center on spot (vertical only)
24027      setTimeout(function() {
24028        const spotRow = gexEl.querySelector('.strike-row[data-spot="1"]');
24029        if (spotRow) gexEl.scrollTop = spotRow.offsetTop - gexEl.clientHeight / 2 + spotRow.offsetHeight / 2;
24030      }, 100);
24031    }
24032
24033    // New layout: chart replaces the heatmap panel + a metrics strip above the flow.
24034    if (typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT) {
24035      nfUltraRender(ticker, { filtered, spot, callWall, putWall, kingNode, volNode, totalGex, contracts }, silent);
24036      // Flow/news are secondary and hit live UW — fire-and-forget so a slow/failing feed can
24037      // never delay or wipe the chart + GEX ladder. keepFlow (expiry-basis change) → leave flow alone.
24038      if (!(opts && opts.keepFlow)) { try { loadUltraFlow(ticker, silent).catch(function(){}); } catch (e) {} }
24039      try { loadUltraNews(ticker); } catch (e) {}
24040      return;
24041    }
24042
24043    // ── Expiry Heatmap (right side) ──
24044    // Built from the SAME strike×expiry GEX feed the Heatmap view renders, so both
24045    // grids show identical values (real gamma $, not contract volume). Contract
24046    // volume is only the fallback when the GEX-by-expiry feed has nothing.
24047    const today = new Date().toISOString().slice(0, 10);
24048    const maxDate = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
24049    const map = {};
24050    const expirySet = new Set();
24051    {
24052      const expRows = (gexExpJson && (gexExpJson.data || gexExpJson)) || [];
24053      if (Array.isArray(expRows) && expRows.length && expRows[0] && expRows[0].expiry) {
24054        for (const row of expRows) {
24055          const strike = parseFloat(row.strike || 0);
24056          const expiry = row.expiry || row.date || '';
24057          if (!strike || !expiry || expiry < today || expiry > maxDate) continue;
24058          const v = metricRaw(row) * mult;
24059          if (Math.abs(v) < 0.01) continue;
24060          expirySet.add(expiry);
24061          if (!map[strike]) map[strike] = {};
24062          map[strike][expiry] = (map[strike][expiry] || 0) + v;
24063        }
24064      }
24065    }
24066    if (!expirySet.size && Array.isArray(contracts) && contracts.length > 0) {
24067      const parseOpt = function (sym) {
24068        if (!sym) return null;
24069        const m = sym.match(/^[A-Z]+(\d{6})([CP])(\d{8})$/);
24070        if (!m) return null;
24071        return { expiry: '20' + m[1].slice(0,2) + '-' + m[1].slice(2,4) + '-' + m[1].slice(4,6), type: m[2] === 'C' ? 'CALL' : 'PUT', strike: parseInt(m[3]) / 1000 };
24072      };
24073      for (const c of contracts) {
24074        const sym = c.option_symbol || c.option_chain_id || '';
24075        const p = parseOpt(sym);
24076        if (!p || p.expiry < today || p.expiry > maxDate) continue;
24077        const vol = parseInt(c.volume) || 0;
24078        if (vol === 0) continue;
24079        expirySet.add(p.expiry);
24080        const sign = p.type === 'CALL' ? 1 : -1;
24081        if (!map[p.strike]) map[p.strike] = {};
24082        map[p.strike][p.expiry] = (map[p.strike][p.expiry] || 0) + sign * vol;
24083      }
24084    }
24085    if (expirySet.size) {
24086      const expiries = [...expirySet].sort().slice(0, HEATMAP_MAX_EXP);
24087      let allStrikes = Object.keys(map).map(Number);
24088      if (spot > 0) {
24089        const above = allStrikes.filter(s => s >= spot).sort((a, b) => a - b).slice(0, 25);
24090        const below = allStrikes.filter(s => s < spot).sort((a, b) => b - a).slice(0, 25);
24091        allStrikes = [...above.reverse(), ...below];
24092      }
24093      allStrikes = allStrikes.filter(s => expiries.some(e => map[s] && map[s][e]));
24094
24095      const maxPerExpiry = {};
24096      for (const e of expiries) {
24097        maxPerExpiry[e] = 0;
24098        for (const s of allStrikes) {
24099          if (map[s] && map[s][e]) maxPerExpiry[e] = Math.max(maxPerExpiry[e], Math.abs(map[s][e]));
24100        }
24101      }
24102
24103      const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
24104      const fmtN = function(n) {
24105        const abs = Math.abs(n); const sign = n >= 0 ? '+' : '-';
24106        if (abs >= 1e6) return sign + (abs / 1e6).toFixed(1) + 'M';
24107        if (abs >= 1e3) return sign + (abs / 1e3).toFixed(0) + 'K';
24108        return sign + abs.toFixed(0);
24109      };
24110
24111      let closestExpSpot = null;
24112      if (spot > 0) {
24113        let minD = Infinity;
24114        for (const s of allStrikes) { const d = Math.abs(s - spot); if (d < minD) { minD = d; closestExpSpot = s; } }
24115      }
24116
24117      // Top 3 highest-green + top 3 most-negative-red across all expiries get bright; rest muted.
24118      const _ucells = [];
24119      for (const s of allStrikes) {
24120        for (const e of expiries) {
24121          const v = map[s] && map[s][e];
24122          if (v != null) _ucells.push({ key: s + '|' + e, v });
24123        }
24124      }
24125      const uTopGreen = new Set(_ucells.filter(c => c.v > 0).sort((a, b) => b.v - a.v).slice(0, 3).map(c => c.key));
24126      const uTopRed = new Set(_ucells.filter(c => c.v < 0).sort((a, b) => a.v - b.v).slice(0, 3).map(c => c.key));
24127
24128      let eHtml = '<table class="exp-table" style="font-size:11px"><thead><tr><th style="min-width:45px;width:45px;padding-left:4px">STR</th>';
24129      for (const e of expiries) {
24130        const d = new Date(e + 'T12:00:00');
24131        eHtml += '<th style="border-right:1px solid var(--border)">' + dayNames[d.getDay()] + ' ' + e.slice(5).replace('-','/') + '</th>';
24132      }
24133      eHtml += '</tr></thead><tbody>';
24134
24135      for (const strike of allStrikes) {
24136        const isKingE = kingNode && strike === kingNode.strike;
24137        const isCWE = callWall && strike === callWall.strike;
24138        const isPWE = putWall && strike === putWall.strike;
24139        const isSpotE = strike === closestExpSpot;
24140        const isVolE = volNode && strike === volNode.strike;
24141        let strCellStyle = ';min-width:45px;width:45px;padding-left:4px;border-right:1px solid var(--border)';
24142        let strLabel = '';
24143        // Same precedence + styling as the standalone heatmap: King > Spot > CW > PW > VolNode.
24144        if (isKingE) { strCellStyle = 'color:var(--yellow);font-weight:700;background:rgba(234,179,8,0.12)' + strCellStyle; strLabel = '👑 '; }
24145        else if (isSpotE) { strCellStyle = 'color:#fff;font-weight:700;background:rgba(255,255,255,0.12)' + strCellStyle; strLabel = '► '; }
24146        else if (isCWE) { strCellStyle = 'color:var(--green);font-weight:700;background:rgba(34,197,94,0.12)' + strCellStyle; strLabel = '📈 '; }
24147        else if (isPWE) { strCellStyle = 'color:var(--red);font-weight:700;background:rgba(239,68,68,0.12)' + strCellStyle; strLabel = '📉 '; }
24148        else if (isVolE) { strCellStyle = 'color:#3b82f6;font-weight:700;background:rgba(59,130,246,0.12)' + strCellStyle; strLabel = '🔵 '; }
24149        eHtml += '<tr class="' + (isSpotE ? 'spot-row' : '') + '">';
24150        eHtml += '<td style="' + strCellStyle + '">' + strLabel + strike + '</td>';
24151
24152        for (const e of expiries) {
24153          const val = map[strike] && map[strike][e];
24154          if (!val || Math.abs(val) < 0.5) { eHtml += '<td style="border-right:1px solid rgba(30,41,59,0.4)"></td>'; continue; }
24155          const colMax = maxPerExpiry[e] || 1;
24156          const intensity = Math.abs(val) / colMax;
24157          const barW = Math.max(intensity * 100, 8);
24158          const ukey = strike + '|' + e;
24159          const isTopG = uTopGreen.has(ukey), isTopR = uTopRed.has(ukey);
24160          let barBg, txtC = '#94a3b8', wt = 500, st = '';
24161          if (isTopG) { barBg = 'rgba(34,197,94,0.95)'; txtC = '#04140a'; wt = 800; st = ' ★'; }
24162          else if (isTopR) { barBg = 'rgba(239,68,68,0.95)'; txtC = '#1a0404'; wt = 800; st = ' ★'; }
24163          else if (val >= 0) barBg = 'rgba(34,197,94,0.13)';
24164          else barBg = 'rgba(239,68,68,0.13)';
24165
24166          eHtml += '<td style="position:relative;padding:0;border-right:1px solid rgba(30,41,59,0.4)">';
24167          eHtml += '<div style="background:' + barBg + ';width:' + barW + '%;min-width:fit-content;height:100%;padding:2px 6px;font-size:10px;font-weight:' + wt + ';color:' + txtC + ';font-family:Segoe UI,monospace;white-space:nowrap;border-radius:2px">' + fmtN(val) + st + '</div></td>';
24168        }
24169        eHtml += '</tr>';
24170      }
24171      eHtml += '</tbody></table>';
24172      const expScrollTop = expiryEl.scrollTop, expScrollLeft = expiryEl.scrollLeft;
24173      expiryEl.innerHTML = eHtml;
24174      if (silent) {
24175        // Background refresh — keep scroll position, no jump/flash
24176        expiryEl.scrollTop = expScrollTop;
24177        expiryEl.scrollLeft = expScrollLeft;
24178      } else {
24179        // Ticker switch / first load — center on spot
24180        setTimeout(function() {
24181          const spotRow = expiryEl.querySelector('tr.spot-row');
24182          if (spotRow) spotRow.scrollIntoView({ block: 'center' });
24183        }, 100);
24184      }
24185
24186    } else if (!silent) {
24187      expiryEl.innerHTML = '<div class="col-loading">No expiry data</div>';
24188    }
24189
24190    // ── Flow Feed (right panel) ──
24191    await loadUltraFlow(ticker, silent);
24192    loadUltraNews(ticker);
24193
24194  } catch (e) {
24195    // On a background refresh, keep the existing data instead of wiping to an error
24196    if (!silent) {
24197      gexEl.innerHTML = '<div class="col-loading col-error">' + e.message + '</div>';
24198      expiryEl.innerHTML = '';
24199    }
24200  }
24201}
24202
24203let _flowLoadSeq = 0;
24204// Ghost Mode flow accumulates PER TICKER so leaving a ticker and coming back (or a momentarily thin
24205// server response — e.g. SPX falling to the UW REST snapshot instead of the firehose store) never
24206// wipes what you've already seen. Every mode (All / Filtered / Calls / …) still slices this ONE list,
24207// so the tabs can't diverge. Capped + deduped so it stays bounded.
24208var _ghostFlowStore = {};
24209var _ghostFlowSaveT = null;
24210function _ghostFlowKey(f){ return f.id || ((f.ticker_symbol||f.ticker||'')+'|'+(f.strike||'')+'|'+(f.expiry||f.expires||f.expiry_date||'')+'|'+(f.created_at||f.executed_at||f.timestamp||'')+'|'+(f.total_size||f.size||'')+'|'+(f.total_premium||f.premium||'')); }
24211// Persist the accumulated flow across page reloads (keyed to today's PST session so a new day starts
24212// clean). localStorage only — same single source, just saved/restored; never changes what's shown.
24213function _ghostFlowPstDay(){ var p=new Date(new Date().toLocaleString('en-US',{timeZone:'America/Los_Angeles'})); return p.getFullYear()+'-'+String(p.getMonth()+1).padStart(2,'0')+'-'+String(p.getDate()).padStart(2,'0'); }
24214function _ghostFlowSave(){
24215  if (_ghostFlowSaveT) return;   // debounce: at most one write every 4s
24216  _ghostFlowSaveT = setTimeout(function(){
24217    _ghostFlowSaveT = null;
24218    try {
24219      var out = {};
24220      for (var tk in _ghostFlowStore){ if (_ghostFlowStore[tk] && _ghostFlowStore[tk].length) out[tk] = _ghostFlowStore[tk].slice(0, 200); }
24221      localStorage.setItem('gf_ghostFlow_v1', JSON.stringify({ day: _ghostFlowPstDay(), store: out }));
24222    } catch (e) {}   // quota exceeded / storage disabled → skip silently, in-memory store still works
24223  }, 4000);
24224}
24225function _ghostFlowLoad(){
24226  try {
24227    var s = JSON.parse(localStorage.getItem('gf_ghostFlow_v1') || 'null');
24228    if (s && s.day === _ghostFlowPstDay() && s.store && typeof s.store === 'object') _ghostFlowStore = s.store;
24229  } catch (e) {}
24230}
24231_ghostFlowLoad();
24232function _ghostFlowMerge(ticker, incoming){
24233  var arr = _ghostFlowStore[ticker] || [];
24234  var seen = {}; for (var i=0;i<arr.length;i++) seen[_ghostFlowKey(arr[i])] = 1;
24235  for (var j=0;j<incoming.length;j++){ var k=_ghostFlowKey(incoming[j]); if(!seen[k]){ seen[k]=1; arr.push(incoming[j]); } }
24236  arr.sort(function(a,b){ return (new Date(b.created_at||b.timestamp||b.executed_at||0)) - (new Date(a.created_at||a.timestamp||a.executed_at||0)); });
24237  if (arr.length > 600) arr.length = 600;   // bound memory (newest 600 kept)
24238  _ghostFlowStore[ticker] = arr;
24239  _ghostFlowSave();
24240  return arr;
24241}
24242async function loadUltraFlow(ticker, silent, spotHint) {
24243  // Latest-wins guard: if the user switches tickers while a flow load is in flight, don't let
24244  // the older ticker's response overwrite the newer one (fixes the first-switch stale render).
24245  const _seq = silent ? _flowLoadSeq : ++_flowLoadSeq;
24246  const headerEl = document.getElementById('ultraFlowHeader');
24247  const bodyEl = document.getElementById('ultraFlowBody');
24248  const flowScrollTop = bodyEl.scrollTop; // preserve scroll across background refresh
24249  if (!bodyEl.children.length) bodyEl.innerHTML = '<div class="col-loading">Loading flow...</div>';
24250  try {
24251    // Every mode reads ONE source — the whale flow-alerts feed (premium ≥ $25k), the same
24252    // aggregated feed the Discord bot posts. Toggling All/Filtered/Calls just re-slices this one
24253    // dataset (no more different flow per tab), it has real whales so Filtered works, and there's
24254    // no 1–2 lot noise. Falls back to the plain last-100 feed if the whale feed is empty.
24255    async function _ghostFlow() {
24256      let r = [];
24257      // SAME SOURCE AS THE FLOW TAB (user 2026-08-25: the panel showed far less flow for the same
24258      // selection). /api/flow/:ticker returns only the ~200 most RECENT UW alerts - a recency
24259      // window, not the session - so on SPY it held 200 rows against the history store's 8,000,
24260      // and its biggest print was $0.6M while the real day had one at $8.2M. The whales scrolled
24261      // straight off it. /api/flow/history is our own firehose capture: the whole session, no UW
24262      // cost. The old endpoints stay as fallbacks for a ticker the firehose has not captured.
24263      try { const j0 = await fetch('/api/flow/history/' + ticker).then(x => x.json()); r = (j0 && (j0.data || j0)) || []; } catch (e) {}
24264      if (!Array.isArray(r) || !r.length) { try { const j = await fetch('/api/flow/' + ticker + '?min_prem=25000').then(x => x.json()); r = (j && (j.data || j)) || []; } catch (e) {} }
24265      if (!Array.isArray(r) || !r.length) { try { const j2 = await fetch('/api/flow/' + ticker).then(x => x.json()); r = (j2 && (j2.data || j2)) || []; } catch (e) {} }
24266      return Array.isArray(r) ? r : [];
24267    }
24268    let allFlows;
24269    const real = await _ghostFlow();
24270    if (real.length) {
24271      // Merge this fetch into the ticker's running set — accumulates over the session and survives
24272      // ticker switches, so Filtered/All never collapse to 1–2 just because a refetch came back thin.
24273      allFlows = _ghostFlowMerge(ticker, real);
24274    } else if (_ghostFlowStore[ticker] && _ghostFlowStore[ticker].length) {
24275      // Nothing new from the server this time — keep what we've already accumulated.
24276      allFlows = _ghostFlowStore[ticker];
24277    } else {
24278      // Truly nothing yet: sample flow only on demo, or admin/localhost, so the panel is never blank.
24279      const _isLocal = (location.hostname === 'localhost' || location.hostname === '127.0.0.1');
24280      const _isAdmin = (typeof window !== 'undefined' && window.GF_ADMIN === true) || (typeof isAdminUser !== 'undefined' && isAdminUser);
24281      allFlows = (IS_DEMO || _isLocal || _isAdmin) ? demoFlows(ticker, spotHint) : [];
24282    }
24283
24284    const now = new Date();
24285    const pstNow = new Date(now.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }));
24286    const todayStr = pstNow.getFullYear() + '-' + String(pstNow.getMonth()+1).padStart(2,'0') + '-' + String(pstNow.getDate()).padStart(2,'0');
24287    const pstHour = pstNow.getHours();
24288    const pstMin = pstNow.getMinutes();
24289
24290    // Show the most recent trading SESSION present in the feed — today's flow while the
24291    // market is live, or the last trading day's flow on nights/weekends (so the panel never
24292    // goes blank). As soon as a new session's trades arrive next morning, the latest date
24293    // flips forward and the prior day's flow drops off on its own.
24294    const _flowPstDate = function(f) {
24295      const ft = f.created_at || f.timestamp || f.executed_at || f.date || '';
24296      if (!ft) return '';
24297      const fd = new Date(ft); if (isNaN(fd.getTime())) return '';
24298      const fp = new Date(fd.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }));
24299      return fp.getFullYear() + '-' + String(fp.getMonth()+1).padStart(2,'0') + '-' + String(fp.getDate()).padStart(2,'0');
24300    };
24301    const _dstamped = (Array.isArray(allFlows) ? allFlows : [])
24302      .map(function(f) { return { f: f, d: _flowPstDate(f) }; })
24303      .filter(function(x) { return x.d; });
24304    let _sessionDate = todayStr;
24305    if (_dstamped.length) _sessionDate = _dstamped.reduce(function(m, x) { return x.d > m ? x.d : m; }, _dstamped[0].d);
24306    // Floor at size >= 50 — never show 1–2 lot buys/sells in any mode.
24307    const flows = _dstamped.filter(function(x) { return x.d === _sessionDate; }).map(function(x) { return x.f; })
24308      .filter(function(f) { return isCryptoTk(ticker) ? ((parseFloat(f.total_premium || f.premium || 0) || 0) >= 500) : ((parseInt(f.total_size || f.size || 0) || 0) >= 50); });   // crypto: sizes are coins, gate on $ premium instead
24309
24310    // Net call vs net put premium for this ticker's session flow — shown right of the Node Alerts toggle.
24311    try {
24312      var _cP = 0, _pP = 0;
24313      for (var _fi = 0; _fi < flows.length; _fi++) { var _f = flows[_fi]; var _pm = parseFloat(_f.total_premium || _f.premium || 0) || 0; if (String(_f.type || _f.option_type || '').toUpperCase().indexOf('CALL') >= 0) _cP += _pm; else _pP += _pm; }
24314      var _cpEl = document.getElementById('ultraFlowCP');
24315      if (_cpEl) { var _M = function(v){ v = Math.abs(v); if (v >= 1e9) return '$' + (v/1e9).toFixed(1) + 'B'; if (v >= 1e6) return '$' + (v/1e6).toFixed(1) + 'M'; if (v >= 1e3) return '$' + (v/1e3).toFixed(0) + 'K'; return '$' + Math.round(v); };
24316        _cpEl.innerHTML = '<span style="color:#22c55e">C ' + _M(_cP) + '</span><span style="color:#ef4444">P ' + _M(_pP) + '</span>'; }
24317    } catch (e) {}
24318
24319    const marketOpen = isCryptoTk(ticker) || pstHour > 6 || (pstHour === 6 && pstMin >= 30);   // crypto trades 24/7
24320    const marketClose = !isCryptoTk(ticker) && pstHour >= 13;
24321    let marketStatus = '';
24322    if (!marketOpen) marketStatus = '🔴 Pre-market';
24323    else if (marketClose) marketStatus = '🟡 Market closed';
24324    else marketStatus = '🟢 Market open';
24325
24326    let displayFlows = flows;
24327    // Non-destructive: always filters the FULL accumulated list, so unticking a chip restores the
24328    // whole session instantly - nothing about today is thrown away.
24329    if (ultraFlowSel.size) {
24330      let _sp = 0; try { _sp = (columnData[ticker] && columnData[ticker].spot) || 0; } catch (e) {}
24331      displayFlows = flows.filter(function(f) { return nfFlowPass(f, _sp, ticker); });
24332    } else if (false) {
24333      displayFlows = flows.filter(_isZeroDte);
24334    } else if (ultraFlowFilter === 'filtered') {
24335      displayFlows = flows.filter(function(f) {
24336        const sz = parseInt(f.total_size || f.size || 0);
24337        const pm = parseFloat(f.total_premium || f.premium || 0);
24338        const ap = parseFloat(f.total_ask_side_prem || 0);
24339        const bp = parseFloat(f.total_bid_side_prem || 0);
24340        const tp = ap + bp;
24341        const askP = tp > 0 ? (ap / tp) * 100 : 0;
24342        const _th = nfFlowFilterThresh(ticker);
24343        return pm >= _th.prem && sz >= _th.size && askP >= 50;
24344      });
24345    } else if (ultraFlowFilter === 'calls') {
24346      displayFlows = flows.filter(function(f) {
24347        const t = (f.type || f.option_type || '').toUpperCase();
24348        return t.includes('CALL');
24349      });
24350    } else if (ultraFlowFilter === 'puts') {
24351      displayFlows = flows.filter(function(f) {
24352        const t = (f.type || f.option_type || '').toUpperCase();
24353        return t.includes('PUT');
24354      });
24355    } else if (ultraFlowFilter === 'otm' || ultraFlowFilter === 'itm') {
24356      let _spot = 0; try { _spot = (columnData[ticker] && columnData[ticker].spot) || 0; } catch (e) {}
24357      displayFlows = flows.filter(function(f) { return nfFlowMoneyness(f, _spot) === ultraFlowFilter; });
24358    } else if (ultraFlowFilter === 'custom') {
24359      displayFlows = flows.filter(function(f) {
24360        const sz = parseInt(f.total_size || f.size || 0);
24361        const pm = parseFloat(f.total_premium || f.premium || 0);
24362        return pm >= ultraFlowCustomPrem && sz >= ultraFlowCustomSize;
24363      });
24364    }
24365
24366    // Demo: only surface the last 10 flows (most recent first), not the full feed.
24367    if (IS_DEMO && displayFlows.length > 10) {
24368      displayFlows = displayFlows.slice().sort(function(a, b) {
24369        return new Date(b.created_at || b.timestamp || b.executed_at || b.date || 0) -
24370               new Date(a.created_at || a.timestamp || a.executed_at || a.date || 0);
24371      }).slice(0, 10);
24372    }
24373
24374    // Build sticky header
24375    let headerHtml = '<div style="padding:6px 10px;border-bottom:1px solid var(--border);font-family:Segoe UI,monospace;font-size:11px;font-weight:700;color:var(--dim);letter-spacing:1px;background:var(--card)">';
24376    headerHtml += '<div style="display:flex;justify-content:space-between;align-items:center">';
24377    // When we're showing a prior session's frozen flow (nights/weekends), label it so it's
24378    // clear this is the last trading day's flow, not a stalled live feed.
24379    let _sessTag = '';
24380    if (_sessionDate && _sessionDate !== todayStr && flows.length) {
24381      const _sp = _sessionDate.split('-');
24382      _sessTag = ' <span style="color:#eab308;font-weight:700" title="Last trading session — live flow resumes at the next market open">· ' + _sp[1] + '/' + _sp[2] + '</span>';
24383    }
24384    headerHtml += '<span>FLOW — ' + ticker + ' (<span id="flowCount">' + displayFlows.length + '</span>/<span id="flowTotal">' + flows.length + '</span>)' + _sessTag + '</span>';
24385    headerHtml += '<span id="liveIndicator"></span>';
24386    headerHtml += '</div>';
24387    headerHtml += '<div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap">';
24388    const _fbtn = (key, label, title) => '<button class="tf-btn ' + ((key === 'all' ? ultraFlowSel.size === 0 : ultraFlowSel.has(key)) ? 'active' : '') + '"' + (title ? ' title="'+title+'"' : '') + ' onclick="toggleFlowChip(\'' + key + '\')" style="font-size:9px;padding:2px 6px">' + label + '</button>';
24389    headerHtml += _fbtn('all', 'All');
24390    headerHtml += _fbtn('filtered', 'Filtered', 'Whale filter: premium ≥ $200K · size ≥ 250 · ask-side ≥ 50%');
24391    headerHtml += _fbtn('calls', 'Calls');
24392    headerHtml += _fbtn('puts', 'Puts');
24393    headerHtml += _fbtn('otm', 'OTM', 'Out-of-the-money (calls above spot, puts below)');
24394    headerHtml += _fbtn('itm', 'ITM', 'In-the-money (calls below spot, puts above)');
24395    headerHtml += _fbtn('0dte', '0DTE', 'Only contracts expiring TODAY');
24396    headerHtml += _fbtn('custom', '⚙ Custom', 'Set your own premium & size thresholds');
24397    headerHtml += '</div>';
24398    // Custom threshold inputs — shown only when the Custom filter is active.
24399    if (ultraFlowSel.has('custom')) {
24400      headerHtml += '<div style="display:flex;gap:6px;align-items:center;margin-top:5px;font-size:10px;color:var(--dim);flex-wrap:wrap">'
24401        + 'Premium ≥ $<input id="flowCustPrem" type="number" min="0" step="10000" value="' + ultraFlowCustomPrem + '" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--t-border2,#26364a);border-radius:5px;padding:2px 5px;font-size:10px">'
24402        + 'Size ≥ <input id="flowCustSize" type="number" min="0" step="50" value="' + ultraFlowCustomSize + '" style="width:64px;background:var(--bg);color:var(--text);border:1px solid var(--t-border2,#26364a);border-radius:5px;padding:2px 5px;font-size:10px">'
24403        + '<button class="tf-btn" onclick="applyFlowCustom()" style="font-size:9px;padding:2px 8px;color:var(--cyan);border-color:var(--cyan)">Apply</button>'
24404        + '</div>';
24405    }
24406    headerHtml += '</div>';
24407    // Only paint if this is still the latest flow load AND its ticker is the one shown. Both
24408    // guards together close the first-load race (a slow default-ticker load can't overwrite the
24409    // ticker you just picked).
24410    // AUDIT FIX: these guards now apply to SILENT refreshes too — a background flow refresh in
24411    // flight across a ticker switch used to resolve late and paint the OLD ticker's flow over
24412    // the new panel (silent calls captured the current seq, so any user-initiated load bumping
24413    // the seq invalidates them; the ticker check catches TV-sync switches between paints).
24414    if (_seq !== _flowLoadSeq) return;
24415    if (typeof chartTicker !== 'undefined' && chartTicker && ticker !== chartTicker) return;
24416    // Rebuild the header ONLY when its structure changes (ticker/filter/session/custom values) —
24417    // rebuilding it on every refresh destroyed the filter buttons mid-click, so taps on
24418    // Filtered/All sometimes silently did nothing (user report). Steady refreshes just update
24419    // the counters inside the existing DOM.
24420    const _shellKey = ticker + '|' + [...ultraFlowSel].sort().join(',') + '|' + _sessTag + '|' + (ultraFlowSel.has('custom') ? (ultraFlowCustomPrem + '-' + ultraFlowCustomSize) : '');
24421    if (headerEl._shellKey === _shellKey && headerEl.querySelector('#flowCount')) {
24422      const _fc2 = headerEl.querySelector('#flowCount'); if (_fc2) _fc2.textContent = displayFlows.length;
24423      const _ft2 = headerEl.querySelector('#flowTotal'); if (_ft2) _ft2.textContent = flows.length;
24424    } else {
24425      headerEl.innerHTML = headerHtml;
24426      headerEl._shellKey = _shellKey;
24427    }
24428    updateLiveIndicator();
24429
24430    if (displayFlows.length === 0) {
24431      // Context-aware empty state: say WHY there's nothing, not just that there's nothing.
24432      let emptyMsg;
24433      if (!marketOpen || marketClose) emptyMsg = '🌙 Market closed — flow resumes at 9:30 AM ET' + (flows.length === 0 ? '' : ' (no ' + ultraFlowFilter + ' matches today)');
24434      else if (ultraFlowFilter !== 'all' && flows.length > 0) emptyMsg = 'No trades match the <b>' + ultraFlowFilter + '</b> filter — try <b>All</b>';
24435      else emptyMsg = 'No flow yet today for ' + ticker;
24436      bodyEl.innerHTML = '<div style="padding:12px;font-family:Segoe UI,monospace;font-size:11px;color:var(--dim);text-align:center">' + emptyMsg + '</div>';
24437      return;
24438    }
24439
24440    // RENDER CAP: the panel now reads the whole session (8k+ rows on SPY) and used to draw every
24441    // one - that many DOM nodes would lock the panel. Filter over the FULL set so the count in the
24442    // header is honest, then draw only the newest slice. The x/y in the header shows both.
24443    const GHOST_FLOW_MAX = 300;
24444    const _fullCount = displayFlows.length;
24445    if (displayFlows.length > GHOST_FLOW_MAX) displayFlows = displayFlows.slice(0, GHOST_FLOW_MAX);
24446
24447    let fHtml = '';
24448
24449    // Accumulation counts — how many prints share the same contract in this feed (so repeats show a xN badge).
24450    const _accCount = {};
24451    for (const f of displayFlows) { const _t=String(f.ticker_symbol||f.ticker||ticker).toUpperCase(); const _k=_t+'|'+(f.strike||'')+'|'+String(f.expires||f.expiry||f.expiry_date||'').slice(0,10)+'|'+((f.type||f.option_type||'').toUpperCase().includes('CALL')?'C':'P'); _accCount[_k]=(_accCount[_k]||0)+1; }
24452
24453        for (const f of displayFlows) {
24454          if (typeof GF_NEW_LAYOUT !== 'undefined' && GF_NEW_LAYOUT) { fHtml += buildFlowRowNew(f, ticker, false, _accCount); continue; }
24455          const strike = f.strike || '?';
24456          const expiry = (f.expires || f.expiry || '').slice(5).replace('-', '/');
24457          const optType = (f.type || f.option_type || '').toUpperCase().includes('CALL') ? 'C' : 'P';
24458          const optLabel = optType === 'C' ? 'Call' : 'Put';
24459          const size = parseInt(f.total_size || f.size || 0);
24460          const prem = parseFloat(f.total_premium || f.premium || 0);
24461          const price = f.price ? parseFloat(f.price).toFixed(2) : '?';
24462          const bid = f.bid ? parseFloat(f.bid).toFixed(2) : null;
24463          const ask = f.ask ? parseFloat(f.ask).toFixed(2) : null;
24464          const askP = parseFloat(f.total_ask_side_prem || 0);
24465          const bidP = parseFloat(f.total_bid_side_prem || 0);
24466          const totalP = askP + bidP;
24467          const askPct = totalP > 0 ? Math.round((askP / totalP) * 100) : 0;
24468          const bidPct = 100 - askPct;
24469          const isBuy = askP > bidP;
24470          const isSweep = f.has_sweep === true || f.has_sweep === 'true';
24471          const vol = f.volume || '';
24472          const oi = f.open_interest || '';
24473          const volOI = parseFloat(f.volume_oi_ratio || 0);
24474          const underlying = f.underlying_price ? parseFloat(f.underlying_price).toFixed(2) : '';
24475          const expiryDate = f.expires || f.expiry || f.expiry_date || '';
24476          let dte = '';
24477          if (expiryDate) {
24478            const expD = new Date(expiryDate + (expiryDate.includes('T') ? '' : 'T16:00:00'));
24479            const nowD = new Date();
24480            const diffDays = Math.ceil((expD - nowD) / 86400000);
24481            dte = diffDays <= 0 ? '0DTE' : diffDays + 'DTE';
24482          }
24483
24484          // Parse time
24485          const flowTime = f.created_at || f.timestamp || f.executed_at || f.date || '';
24486          let timeStr = '';
24487          if (flowTime) {
24488            const d = new Date(flowTime);
24489            if (!isNaN(d)) {
24490              timeStr = fmtTime(d, { hour: '2-digit', minute: '2-digit', hour12: true });
24491            }
24492          }
24493
24494          // Colors
24495          let emoji, bgColor, borderColor;
24496          if (isBuy && optType === 'C') { emoji = '🟢'; bgColor = 'rgba(34,197,94,0.06)'; borderColor = '#22c55e'; }
24497          else if (isBuy && optType === 'P') { emoji = '🔴'; bgColor = 'rgba(239,68,68,0.06)'; borderColor = '#ef4444'; }
24498          else { emoji = '🟡'; bgColor = 'rgba(234,179,8,0.04)'; borderColor = '#eab308'; }
24499
24500          const premFmt = prem >= 1e6 ? '$' + (prem / 1e6).toFixed(1) + 'M' : prem >= 1e3 ? '$' + (prem / 1e3).toFixed(0) + 'K' : '$' + prem.toFixed(0);
24501          const tag = isSweep ? '🔥 Sweep' : '🧱 Block';
24502          const action = isBuy ? (optType === 'C' ? 'Calls Bought' : 'Puts Bought') : (optType === 'C' ? 'Calls Sold' : 'Puts Sold');
24503
24504          // Whale tag
24505          let whaleTag = '';
24506          if (prem >= 1000000) whaleTag = ' 🐋';
24507
24508          fHtml += '<div style="padding:10px 12px;border-bottom:1px solid rgba(30,41,59,0.4);font-family:Segoe UI,monospace;font-size:12px;background:' + bgColor + ';border-left:3px solid ' + borderColor + '">';
24509
24510          // Row 1: Action title + time
24511          const sweepBlock = isSweep ? ' · Sweep' : ' · Block';
24512          fHtml += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">';
24513          fHtml += '<span style="font-weight:700;font-size:13px">' + emoji + ' ' + action + whaleTag + '</span>';
24514          fHtml += '<span style="color:#94a3b8;font-size:11px">' + (timeStr ? timeStr + ' · ' : '') + dte + '</span>';
24515          fHtml += '</div>';
24516
24517          // Build TradingView option symbol
24518          const rawExpiry = f.expires || f.expiry || f.expiry_date || f.expiration_date || '';
24519          let tvOptionSymbol = '';
24520          let tvUrl = 'https://www.tradingview.com/chart/?symbol=' + ticker;
24521          if (rawExpiry && strike !== '?' && strike) {
24522            try {
24523              const cleanExpiry = rawExpiry.slice(0, 10);
24524              const ed = new Date(cleanExpiry + 'T12:00:00');
24525              if (!isNaN(ed)) {
24526                const yy = String(ed.getFullYear()).slice(2);
24527                const mm = String(ed.getMonth()+1).padStart(2,'0');
24528                const dd = String(ed.getDate()).padStart(2,'0');
24529                const strikeNum = parseFloat(strike);
24530                const strikeStr = strikeNum % 1 === 0 ? strikeNum.toFixed(1) : String(strikeNum);
24531                tvOptionSymbol = ticker + yy + mm + dd + optType + strikeStr;
24532                tvUrl = 'https://www.tradingview.com/chart/?symbol=' + tvOptionSymbol;
24533              }
24534            } catch {}
24535          }
24536
24537          // Build UW option chain symbol: ORCL260605C00203000
24538          let uwSymbol = '';
24539          if (rawExpiry && strike !== '?' && strike) {
24540            try {
24541              const cleanExp = rawExpiry.slice(0, 10);
24542              const edt = new Date(cleanExp + 'T12:00:00');
24543              if (!isNaN(edt)) {
24544                const ec = String(edt.getFullYear()).slice(2) + String(edt.getMonth()+1).padStart(2,'0') + String(edt.getDate()).padStart(2,'0');
24545                const sn = Math.round(parseFloat(strike) * 1000);
24546                uwSymbol = ticker + ec + optType + String(sn).padStart(8, '0');
24547              }
24548            } catch {}
24549          }
24550          const uwUrl = uwSymbol 
24551            ? 'https://unusualwhales.com/flow/option_chains?chain=' + encodeURIComponent(uwSymbol) + '&days=7&mins=30'
24552            : 'https://unusualwhales.com/stock/' + ticker + '/flow-overview';
24553
24554          // Row 2: Contract details (click opens TradingView) + UW link + sweep/block
24555          fHtml += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">';
24556          fHtml += '<span class="flow-contract" data-url="' + tvUrl + '" style="font-weight:700;font-size:13px;cursor:pointer;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.2)" title="Open ' + (tvOptionSymbol || ticker) + ' on TradingView">' + strike + optType + ' ' + expiry + ' @ $' + price + '</span>';
24557          fHtml += '<span style="display:flex;align-items:center;gap:6px">';
24558          if (typeof isCryptoTk === 'function' && isCryptoTk(ticker)) {   // crypto: Deribit link, no sweep/block tag (2026-09-07)
24559            fHtml += '<a href="' + _gfDeribitUrl(ticker) + '" target="_blank" style="font-size:10px;font-weight:700;color:#f97316;text-decoration:none;padding:1px 5px;border:1px solid #f97316;border-radius:3px;
24559cursor:pointer" title="View the chain on Deribit">Deribit</a>';
24560          } else {
24561            fHtml += '<a href="' + uwUrl + '" target="_blank" style="font-size:10px;font-weight:700;color:#f97316;text-decoration:none;padding:1px 5px;border:1px solid #f97316;border-radius:3px;cursor:pointer" title="View on Unusual Whales">UW</a>';
24562            fHtml += '<span style="color:#94a3b8;font-size:10px">' + (isSweep ? '🔥' : '🧱') + sweepBlock + '</span>';
24563          }
24564          fHtml += '</span>';
24565          fHtml += '</div>';
24566
24567          // Row 3: Size + Value
24568          fHtml += '<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:12px">';
24569          fHtml += '<span style="font-weight:700">' + size.toLocaleString() + 'x</span>';
24570          fHtml += '<span style="font-weight:700">' + premFmt + '</span>';
24571          fHtml += '</div>';
24572
24573          // Row 4: Vol/OI
24574          let voiLine = '';
24575          if (vol) voiLine += '<span style="color:#94a3b8">Vol:</span> <span style="color:#e2e8f0;font-weight:600">' + Number(vol).toLocaleString() + '</span>';
24576          if (oi) voiLine += ' <span style="color:#94a3b8">OI:</span> <span style="color:#e2e8f0;font-weight:600">' + Number(oi).toLocaleString() + '</span>';
24577          if (volOI > 0) voiLine += ' <span style="color:#94a3b8">V/OI:</span> <span style="color:' + (volOI >= 2 ? '#22c55e' : '#e2e8f0') + ';font-weight:600">' + volOI.toFixed(1) + 'x</span>';
24578          if (voiLine) {
24579            fHtml += '<div style="margin-bottom:3px;font-size:11px">' + voiLine + '</div>';
24580          }
24581
24582          // Row 5: Fill + Bid/Ask
24583          if (bid && ask) {
24584            fHtml += '<div style="margin-bottom:3px;font-size:11px"><span style="color:#94a3b8">Fill:</span> <span style="color:#e2e8f0;font-weight:600">$' + price + '</span> <span style="color:#94a3b8">Bid:</span> <span style="color:#e2e8f0">$' + bid + '</span> <span style="color:#94a3b8">Ask:</span> <span style="color:#e2e8f0">$' + ask + '</span></div>';
24585          }
24586
24587          // Row 6: Trade Bid/Ask %
24588          if (totalP > 0) {
24589            const askBar = askPct;
24590            fHtml += '<div style="display:flex;align-items:center;gap:6px;margin-top:2px">';
24591            fHtml += '<span style="color:#ef4444;font-size:11px;font-weight:600;min-width:45px">Bid ' + bidPct + '%</span>';
24592            fHtml += '<div style="flex:1;height:8px;background:rgba(239,68,68,0.3);border-radius:4px;overflow:hidden"><div style="width:' + askBar + '%;height:100%;background:#22c55e;border-radius:4px;float:right"></div></div>';
24593            fHtml += '<span style="color:#22c55e;font-size:11px;font-weight:600;min-width:45px;text-align:right">Ask ' + askPct + '%</span>';
24594            fHtml += '</div>';
24595          }
24596
24597          fHtml += '</div>';
24598        }
24599
24600        bodyEl.innerHTML = fHtml;
24601        bodyEl.dataset.ticker = ticker; // record which ticker this feed belongs to (stale-clear guard)
24602        // Background refresh — keep scroll position so the feed doesn't jump/blink
24603        if (silent) bodyEl.scrollTop = flowScrollTop;
24604
24605        // Attach click handlers to open TradingView option charts
24606        bodyEl.querySelectorAll('.flow-contract').forEach(function(el) {
24607          el.addEventListener('click', function() {
24608            const url = el.dataset.url;
24609            if (url) window.open(url, '_blank');
24610          });
24611        });
24612
24613  } catch (e) {
24614    if (!silent) bodyEl.innerHTML = '<div class="col-loading col-error">Flow error</div>';
24615  }
24616}
24617
24618// ── Ultra Mode News Panel (resizable + minimize) ──
24619let newsLastHeight = 180;
24620function toggleUltraNews(e) {
24621  if (e) e.stopPropagation();
24622  var body = document.getElementById('ultraNewsBody');
24623  var btn = document.getElementById('ultraNewsToggle');
24624  if (body.style.display === 'none') {
24625    body.style.display = 'block';
24626    body.style.height = newsLastHeight + 'px';
24627    btn.textContent = '▼';
24628  } else {
24629    newsLastHeight = body.offsetHeight;
24630    body.style.display = 'none';
24631    btn.textContent = '▲';
24632  }
24633}
24634
24635(function() {
24636  var handle = document.getElementById('ultraNewsHeader');
24637  var newsBody = document.getElementById('ultraNewsBody');
24638  var isDragging = false;
24639  var startY, startH;
24640  if (handle && newsBody) {
24641    handle.addEventListener('mousedown', function(e) {
24642      isDragging = true;
24643      startY = e.clientY;
24644      startH = newsBody.offsetHeight;
24645      document.body.style.cursor = 'ns-resize';
24646      document.body.style.userSelect = 'none';
24647      e.preventDefault();
24648    });
24649    document.addEventListener('mousemove', function(e) {
24650      if (!isDragging) return;
24651      var delta = startY - e.clientY;
24652      var newH = Math.max(40, Math.min(500, startH + delta));
24653      newsBody.style.height = newH + 'px';
24654    });
24655    document.addEventListener('mouseup', function() {
24656      if (isDragging) {
24657        isDragging = false;
24658        document.body.style.cursor = '';
24659        document.body.style.userSelect = '';
24660      }
24661    });
24662    // Touch support for iPad
24663    handle.addEventListener('touchstart', function(e) {
24664      isDragging = true;
24665      startY = e.touches[0].clientY;
24666      startH = newsBody.offsetHeight;
24667      e.preventDefault();
24668    }, { passive: false });
24669    document.addEventListener('touchmove', function(e) {
24670      if (!isDragging) return;
24671      var delta = startY - e.touches[0].clientY;
24672      var newH = Math.max(40, Math.min(500, startH + delta));
24673      newsBody.style.height = newH + 'px';
24674    });
24675    document.addEventListener('touchend', function() { isDragging = false; });
24676  }
24677})();
24678
24679// Demo: cached sample headlines so the news panel never reads empty off-hours.
24680function demoNews(ticker) {
24681  var T = (ticker || '').toUpperCase();
24682  var now = Date.now();
24683  var H = 3600000, D = 86400000;
24684  var sets = {
24685    SPY: [
24686      { h: 'S&P 500 edges to fresh record as megacap tech leads broad rally', s: 'Reuters', a: 2 * H },
24687      { h: 'Traders pile into SPY calls ahead of Fed rate decision', s: 'Bloomberg', a: 6 * H },
24688      { h: 'Wall Street eyes soft-landing as inflation data cools', s: 'CNBC', a: 1 * D },
24689      { h: 'Options desks flag elevated 0DTE volume in S&P index products', s: 'MarketWatch', a: 2 * D },
24690      { h: 'Dealer gamma positioning keeps SPX pinned near key strike', s: 'Barron’s', a: 3 * D },
24691      { h: 'Goldman lifts year-end S&P 500 target on earnings strength', s: 'Reuters', a: 5 * D },
24692    ],
24693    TSLA: [
24694      { h: 'Tesla shares surge on stronger-than-expected delivery numbers', s: 'CNBC', a: 3 * H },
24695      { h: 'Tesla call buyers active as stock reclaims key moving average', s: 'Bloomberg', a: 7 * H },
24696      { h: 'Analysts split on Tesla after robotaxi event details emerge', s: 'Reuters', a: 1 * D },
24697      { h: 'Tesla energy storage deployments hit record in latest quarter', s: 'MarketWatch', a: 2 * D },
24698      { h: 'Unusual options flow spotted in TSLA weekly contracts', s: 'Benzinga', a: 4 * D },
24699      { h: 'Tesla expands FSD rollout, bulls eye next leg higher', s: 'Barron’s', a: 6 * D },
24700    ]
24701  };
24702  var generic = [
24703    { h: T + ' sees heavy options activity as volatility picks up', s: 'Bloomberg', a: 4 * H },
24704    { h: 'Traders watch ' + T + ' for breakout above resistance', s: 'MarketWatch', a: 9 * H },
24705    { h: 'Analysts weigh in on ' + T + ' after recent run', s: 'Reuters', a: 2 * D },
24706    { h: 'Notable call flow lifts ' + T + ' into the close', s: 'Benzinga', a: 3 * D },
24707    { h: T + ' options skew flattens as sentiment improves', s: 'CNBC', a: 5 * D },
24708  ];
24709  var src = sets[T] || generic;
24710  return src.map(function(n) {
24711    return { headline: n.h, source: n.s, url: '#', published_at: new Date(now - n.a).toISOString() };
24712  });
24713}
24714
24715async function loadUltraNews(ticker) {
24716  var body = document.getElementById('ultraNewsBody');
24717  var newsScrollTop = body.scrollTop; // preserve scroll across background refresh
24718  if (!body.children.length) body.innerHTML = '<div style="padding:10px;color:#475569;text-align:center;font-size:11px">Loading news...</div>';
24719  try {
24720    var articles;
24721    if (IS_DEMO) {
24722      articles = demoNews(ticker);
24723    } else {
24724      const res = await fetch('/api/news/' + ticker);
24725      const json = await res.json();
24726      articles = json.data || [];
24727    }
24728    if (!articles.length) {
24729      body.innerHTML = '<div style="padding:10px;color:#475569;text-align:center;font-size:11px">No recent news for ' + ticker + '</div>';
24730      return;
24731    }
24732    // Filter last 2 weeks
24733    const twoWeeksAgo = Date.now() - (14 * 24 * 60 * 60 * 1000);
24734    const recent = articles.filter(function(a) {
24735      const d = new Date(a.published_at || a.created_at || a.date || 0);
24736      return d.getTime() > twoWeeksAgo;
24737    });
24738    if (!recent.length) {
24739      body.innerHTML = '<div style="padding:10px;color:#475569;text-align:center;font-size:11px">No news in last 2 weeks</div>';
24740      return;
24741    }
24742    var html = '';
24743    for (var i = 0; i < Math.min(recent.length, 20); i++) {
24744      var a = recent[i];
24745      var headline = a.headline || a.title || '';
24746      var source = a.source || a.source_name || '';
24747      var url = a.url || a.news_url || '#';
24748      var pubDate = a.published_at || a.created_at || '';
24749      var timeAgo = '';
24750      if (pubDate) {
24751        var diff = Date.now() - new Date(pubDate).getTime();
24752        if (diff < 3600000) timeAgo = Math.floor(diff / 60000) + 'm ago';
24753        else if (diff < 86400000) timeAgo = Math.floor(diff / 3600000) + 'h ago';
24754        else timeAgo = Math.floor(diff / 86400000) + 'd ago';
24755      }
24756      html += '<a href="' + url + '" target="_blank" style="display:block;padding:8px 12px;border-bottom:1px solid rgba(30,41,59,0.3);text-decoration:none;color:#e2e8f0">';
24757      html += '<div style="font-size:12px;font-weight:600;line-height:1.4;margin-bottom:3px">' + headline + '</div>';
24758      html += '<div style="font-size:10px;color:#8b9cb5">' + [source, timeAgo].filter(Boolean).join(' · ') + '</div>';
24759      html += '</a>';
24760    }
24761    body.innerHTML = html;
24762    body.scrollTop = newsScrollTop; // keep scroll so the news list doesn't jump
24763  } catch (e) {
24764    body.innerHTML = '<div style="padding:10px;color:#475569;text-align:center;font-size:11px">News unavailable</div>';
24765  }
24766}
24767
24768// ── Resizable columns ──
24769// Draggable width for the Trade Ideas right panels (Setups / Earnings / Watchlist / Ghost / SPX-SPY)
24770// — one shared width so every tab's panel matches; persisted. Drag the slim strip on the panel's
24771// left edge; the body flexes to fill.
24772(function () {
24773  const PANELS = ['ideasTracker', 'earningsOutcomes', 'wlTracker', 'ghostTracker', 'idxTracker'];
24774  // Setups + SPX/SPY trackers now live INSIDE a wrapper column (Live Read card above them) — the
24775  // wrapper owns the width there. Sizing the inner tracker left a dead strip between it and the
24776  // wrapper's edge (user report), and a saved narrow width from before the wrapper made it worse.
24777  const WRAPPED = { ideasTracker: 1, idxTracker: 1 };
24778  const tgt = id => { const el = document.getElementById(id); if (!el) return null; return WRAPPED[id] ? el.parentNode : el; };
24779  const apply = px => { for (const id of PANELS) { const el = document.getElementById(id); const t = tgt(id); if (t) t.style.width = px + 'px'; if (el && WRAPPED[id]) el.style.width = ''; } };
24780  let w = 0; try { w = parseInt(localStorage.getItem('gf_ideasPanelW')) || 0; } catch (e) {}
24781  if (w >= 240 && w <= 700) apply(w);
24782  for (const id of PANELS) {
24783    const t = tgt(id); if (!t || !t.parentNode) continue;
24784    const h = document.createElement('div');
24785    h.style.cssText = 'width:6px;flex:none;cursor:col-resize;background:transparent;transition:background .12s';
24786    h.title = 'drag to resize the panel';
24787    h.addEventListener('mouseenter', () => h.style.background = 'rgba(148,163,184,.28)');
24788    h.addEventListener('mouseleave', () => h.style.background = 'transparent');
24789    t.parentNode.insertBefore(h, t);
24790    h.addEventListener('pointerdown', ev => {
24791      ev.preventDefault();
24792      try { h.setPointerCapture(ev.pointerId); } catch (e) {}
24793      const sx = ev.clientX, sw = t.offsetWidth;
24794      document.body.style.userSelect = 'none';
24795      const mv = e2 => { const nw = Math.max(240, Math.min(700, sw + (sx - e2.clientX))); apply(nw); try { localStorage.setItem('gf_ideasPanelW', String(nw)); } catch (e) {} };
24796      const up = () => { h.removeEventListener('pointermove', mv); h.removeEventListener('pointerup', up); document.body.style.userSelect = ''; };
24797      h.addEventListener('pointermove', mv); h.addEventListener('pointerup', up);
24798    });
24799  }
24800})();
24801document.querySelectorAll('.resize-handle').forEach(function(handle) {
24802  let isDragging = false;
24803  let startX, leftEl, rightEl, startLeftW, startRightW;
24804
24805  handle.addEventListener('mousedown', function(e) {
24806    e.preventDefault();
24807    isDragging = true;
24808    handle.classList.add('active');
24809    startX = e.clientX;
24810    leftEl = document.getElementById(handle.dataset.left);
24811    rightEl = document.getElementById(handle.dataset.right);
24812    startLeftW = leftEl.offsetWidth;
24813    startRightW = rightEl.offsetWidth;
24814    document.body.style.cursor = 'col-resize';
24815    document.body.style.userSelect = 'none';
24816  });
24817
24818  document.addEventListener('mousemove', function(e) {
24819    if (!isDragging) return;
24820    const dx = e.clientX - startX;
24821    const newLeftW = Math.max(120, startLeftW + dx);
24822    const newRightW = Math.max(120, startRightW - dx);
24823    leftEl.style.width = newLeftW + 'px';
24824    leftEl.style.flex = 'none';
24825    rightEl.style.width = newRightW + 'px';
24826    rightEl.style.flex = 'none';
24827  });
24828
24829  document.addEventListener('mouseup', function() {
24830    if (isDragging) {
24831      isDragging = false;
24832      handle.classList.remove('active');
24833      document.body.style.cursor = '';
24834      document.body.style.userSelect = '';
24835    }
24836  });
24837});
24838
24839</script>
24839
24840<!-- Extension Install Modal -->
24841<div id="extModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);z-index:9999;align-items:center;justify-content:center;backdrop-filter:blur(4px)">
24842  <div style="background:#111827;border:1px solid #1e293b;border-radius:16px;padding:32px;max-width:520px;width:90%;max-height:90vh;overflow-y:auto;position:relative">
24843    <button onclick="document.getElementById('extModal').style.display='none'" style="position:absolute;top:12px;right:16px;background:none;border:none;color:#8b9cb5;font-size:20px;cursor:pointer">&times;</button>
24844    <h2 style="font-size:20px;font-weight:800;margin-bottom:4px;color:#e2e8f0">🧩 GhostFlow Chrome Extension</h2>
24845    <p style="font-size:13px;color:#8b9cb5;margin-bottom:20px">Sync your TradingView chart with the GhostFlow dashboard in real-time</p>
24846    
24847    <div style="background:rgba(59,130,246,0.05);border:1px solid rgba(59,130,246,0.15);border-radius:10px;padding:16px;margin-bottom:20px">
24848      <h3 style="font-size:14px;font-weight:700;color:#3b82f6;margin-bottom:8px">What it does</h3>
24849      <p style="font-size:12px;color:#94a3b8;line-height:1.6;margin-bottom:6px">• Detects the ticker you're viewing on TradingView</p>
24850      <p style="font-size:12px;color:#94a3b8;line-height:1.6;margin-bottom:6px">• Shows GEX levels (King, Call Wall, Put Wall) as a draggable overlay</p>
24851      <p style="font-size:12px;color:#94a3b8;line-height:1.6;margin-bottom:6px">• Syncs ticker to GhostFlow dashboard — switch on TV, dashboard follows</p>
24852      <p style="font-size:12px;color:#94a3b8;line-height:1.6">• Auto-refreshes GEX data every 5 minutes</p>
24853    </div>
24854
24855    <a href="/ext-download" target="_blank" rel="noopener" class="tf-btn active" style="display:block;text-align:center;padding:14px;font-size:15px;font-weight:700;border-radius:8px;margin-bottom:20px;text-decoration:none;color:#fff;background:#3b82f6">➕ Add to Chrome — Free</a>
24856
24857    <h3 style="font-size:14px;font-weight:700;color:#e2e8f0;margin-bottom:12px">Installation</h3>
24858
24859    <div style="margin-bottom:12px">
24860      <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
24861        <span style="background:rgba(59,130,246,0.15);color:#3b82f6;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;flex-shrink:0">1</span>
24862        <span style="font-size:13px;font-weight:600;color:#e2e8f0">Add to Chrome</span>
24863      </div>
24864      <p style="font-size:12px;color:#94a3b8;margin-left:34px">Click the button above to open the Chrome Web Store, then <strong style="color:#e2e8f0">Add to Chrome</strong> → <strong style="color:#e2e8f0">Add extension</strong>.</p>
24865    </div>
24866
24867    <div style="margin-bottom:12px">
24868      <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
24869        <span style="background:rgba(59,130,246,0.15);color:#3b82f6;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;flex-shrink:0">2</span>
24870        <span style="font-size:13px;font-weight:600;color:#e2e8f0">Sign in</span>
24871      </div>
24872      <p style="font-size:12px;color:#94a3b8;margin-left:34px">Click the GhostFlow extension icon in Chrome and <strong style="color:#e2e8f0">sign in</strong> with your GhostFlow username &amp; password.</p>
24873    </div>
24874
24875    <div style="margin-bottom:16px">
24876      <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
24877        <span style="background:rgba(34,197,94,0.15);color:#22c55e;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;flex-shrink:0">✓</span>
24878        <span style="font-size:13px;font-weight:600;color:#22c55e">Done!</span>
24879      </div>
24880      <p style="font-size:12px;color:#94a3b8;margin-left:34px">Open TradingView → you'll see the GhostFlow GEX overlay. Switch tickers and the dashboard follows automatically.</p>
24881    </div>
24882  </div>
24883</div>
24884<!-- ═══ Ghost AI floating assistant (live for premium + admin) ═══ -->
24885<style>
24886#gfAiLauncher{display:none;position:fixed;bottom:22px;right:22px;z-index:99998;touch-action:none}
24887#gfAiLauncher button{display:flex;align-items:center;gap:8px;background:var(--t-card,#111827);border:1px solid var(--t-border,#1e293b);border-radius:calc(26px*var(--t-rk,1));padding:8px 15px 8px 10px;cursor:grab;box-shadow:0 6px 22px rgba(0,0,0,.45);opacity:.9;color:var(--t-text,#e2e8f0);font-size:13px;font-weight:800;font-family:inherit;white-space:nowrap}
24888#gfAiLauncher button:hover{border-color:var(--t-accent,#3b82f6);opacity:1}
24889#gfAiLauncher button:active{cursor:grabbing}
24890#gfAiLauncher .lbl .g{color:var(--t-accent,#3b82f6)}
24891#gfAiLauncher.mini button{width:50px;height:50px;border-radius:50%;padding:0;gap:0;justify-content:center}
24892#gfAiLauncher.mini button .lbl{display:none}
24893#gfAiLauncher.mini button:hover{width:auto;height:auto;border-radius:calc(26px*var(--t-rk,1));padding:8px 15px 8px 10px;gap:8px}
24894#gfAiLauncher.mini button:hover .lbl{display:inline}
24895#gfAiPanel{display:none;position:fixed;top:0;right:0;bottom:0;width:min(440px,100vw);z-index:99999;background:var(--t-bg,#0a0e17);border-left:1px solid var(--t-border,#1e293b);box-shadow:-8px 0 34px rgba(0,0,0,.55);flex-direction:column}
24896#gfAiResize{position:absolute;
24896left:-1px;top:0;bottom:0;width:8px;cursor:ew-resize;z-index:3}
24897#gfAiResize:hover,#gfAiResize.on{background:rgba(var(--t-accent-rgb,59,130,246),.35)}
24898#gfAiHead{padding:14px 16px;border-bottom:1px solid var(--t-border,#1e293b);display:flex;align-items:center;gap:10px}
24899#gfAiHead .t{font-size:14px;font-weight:800}
24900#gfAiHead .s{font-size:11px;color:var(--t-dim,#8b9cb5)}
24901#gfAiHead button{margin-left:auto;background:none;border:1px solid var(--t-border,#1e293b);color:var(--t-dim,#94a3b8);border-radius:calc(8px*var(--t-rk,1));padding:6px 10px;cursor:pointer;font-size:12px;font-family:inherit}
24902#gfAiHead button:hover{border-color:var(--t-accent,#3b82f6);color:var(--t-text,#e2e8f0)}
24903#gfAiLog{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px}
24904#gfAiChips{padding:0 16px 8px;display:flex;gap:6px;flex-wrap:wrap}
24905#gfAiChips button{background:none;border:1px solid var(--t-border,#1e293b);color:var(--t-dim,#94a3b8);border-radius:calc(8px*var(--t-rk,1));padding:6px 10px;font-size:12px;cursor:pointer;font-family:inherit}
24906#gfAiChips button:hover{border-color:var(--t-accent,#3b82f6);color:var(--t-text,#e2e8f0)}
24907#gfAiInput{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--t-border,#1e293b)}
24908#gfAiInput input{flex:1;padding:10px;background:var(--t-card,#111827);border:1px solid var(--t-border,#1e293b);border-radius:calc(8px*var(--t-rk,1));color:var(--t-text,#e2e8f0);font-size:13px;font-family:inherit}
24909#gfAiInput input:focus{outline:none;border-color:var(--t-accent,#3b82f6)}
24910#gfAiInput button{background:var(--t-accent,#3b82f6);color:var(--t-onAccent,#fff);border:none;border-radius:calc(8px*var(--t-rk,1));padding:10px 16px;font-weight:700;cursor:pointer;font-family:inherit}
24911#gfAiLog .gbub{max-width:96%;padding:9px 13px;border-radius:calc(12px*var(--t-rk,1));font-size:13px;line-height:1.55;white-space:pre-wrap}
24912#gfAiLog .gbub.me{align-self:flex-end;background:var(--t-accent,#3b82f6);color:var(--t-onAccent,#fff);border-bottom-right-radius:3px}
24913#gfAiLog .gbub.ai{align-self:flex-start;background:var(--t-card,#111827);border:1px solid var(--t-border,#1e293b);color:var(--t-text,#e2e8f0);border-bottom-left-radius:3px;width:100%}
24914#gfAiLog .brf{display:flex;flex-direction:column;gap:10px}
24915#gfAiLog .brf-h{font-size:15px;font-weight:800}
24916#gfAiLog .brf-h .pill{font-size:13px;font-weight:800;margin-left:6px}
24917#gfAiLog .pill.bull{color:#22c55e}
24918#gfAiLog .pill.bear{color:#ef4444}
24919#gfAiLog .brf-sec{background:rgba(255,255,255,.02);border:1px solid var(--t-border,#1e293b);border-radius:calc(10px*var(--t-rk,1));padding:10px 12px}
24920#gfAiLog .brf-sec.plan{background:rgba(var(--t-accent-rgb,59,130,246),.06);border-color:rgba(var(--t-accent-rgb,59,130,246),.25)}
24921#gfAiLog .brf-lbl{font-size:10px;font-weight:800;letter-spacing:.06em;color:var(--t-dim,#8b9cb5);margin-bottom:6px}
24922#gfAiLog .brf-sec.plan .brf-lbl{color:var(--t-accent,#3b82f6)}
24923#gfAiLog .lvl{display:flex;align-items:center;gap:8px;font-size:13px;padding:3px 0}
24924#gfAiLog .lvl .dot{width:9px;height:9px;border-radius:2px;flex:0 0 auto}
24925#gfAiLog .lvl .mut{color:var(--t-dim,#8b9cb5);font-size:12px}
24926#gfAiLog .gexbar{position:relative;height:12px;background:var(--t-bg,#0a0e17);border:1px solid var(--t-border,#1e293b);border-radius:calc(6px*var(--t-rk,1));overflow:hidden;margin:2px 0 8px}
24927#gfAiLog .gexbar .gxc{position:absolute;left:50%;top:0;bottom:0;width:1px;background:var(--t-border3,#334155)}
24928#gfAiLog .gexbar .gxf{position:absolute;top:1px;bottom:1px;border-radius:calc(4px*var(--t-rk,1))}
24929#gfAiLog .brf-row{font-size:13px;line-height:1.5}
24930#gfAiLog .brf-row b{color:var(--t-text,#e2e8f0)}
24931#gfAiLog .brf-tbl{font-family:ui-monospace,monospace;font-size:12px;line-height:1.7;color:var(--t-muted,#cbd5e1);white-space:pre;overflow-x:auto}
24932#gfAiLog .gtbl{display:flex;flex-direction:column;font-size:13px}
24933#gfAiLog .gtbl .tr{display:grid;gap:10px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,.04)}
24934#gfAiLog .gtbl .tr:last-child{border-bottom:none}
24935#gfAiLog .gtbl .th{color:var(--t-dim,#8b9cb5);font-size:10px;font-weight:800;letter-spacing:.04em;border-bottom:1px solid var(--t-border,#1e293b);padding-bottom:5px}
24936#gfAiLog .gtbl span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
24937#gfAiLog .gtbl .r{text-align:right}
24938#gfAiLog .gtbl.strikes .tr{grid-template-columns:1fr 1.5fr 1.1fr}
24939#gfAiLog .gtbl.flow .tr{grid-template-columns:.9fr 1.4fr .9fr .7fr}
24940#gfAiLog .gtbl.g2 .tr{grid-template-columns:1.2fr 1fr}
24941#gfAiLog .gtbl.g3 .tr{grid-template-columns:1.2fr 1fr 1fr}
24942#gfAiLog .gtbl.g4 .tr{grid-template-columns:1.2fr 1fr 1fr 1fr}
24943#gfAiLog .gtbl .tr:nth-of-type(even){background:rgba(255,255,255,.025)}
24944#gfAiLog .gtbl .tr[data-card]{cursor:zoom-in;border-radius:calc(5px*var(--t-rk,1));padding-left:6px;padding-right:6px}
24945#gfAiLog .gtbl .tr[data-card]:hover{background:rgba(var(--t-accent-rgb,59,130,246),.12)}
24946#gfAiLog .lvl[data-armlevel]{cursor:pointer;border-radius:calc(6px*var(--t-rk,1));padding:3px 4px;margin:0 -4px}
24947#gfAiLog .lvl[data-armlevel]:hover{background:rgba(var(--t-accent-rgb,59,130,246),.12)}
24948</style>
24949
24950<!-- One-time welcome card for the 2026-09 redesign (premium + trial, never demo). Sample B. -->
24951<div id="gfWelcome" style="display:none;position:fixed;inset:0;z-index:100050;background:rgba(0,0,0,.62);align-items:center;justify-content:center">
24952  <div class="gf-wc">
24953    <div class="gf-wc-top"><div class="gf-wc-lg"><svg viewBox="0 0 64 64" width="38" height="38" fill="none"><rect width="64" height="64" rx="14" fill="#0e1017" stroke="#d4af37" stroke-width="2"/><path d="M32 12C22 12 16 20 16 28v20c0 0 3-4 6-4s3 4 6 4 3-4 6-4 3 4 6 4 3-4 6-4V28c0-8-6-16-16-16z" fill="#e9d38a"/><circle cx="26" cy="26" r="3.5" fill="#0e1017"/><circle cx="38" cy="26" r="3.5" fill="#0e1017"/><circle cx="27" cy="25" r="1.2" fill="#fff2c4"/><circle cx="39" cy="25" r="1.2" fill="#fff2c4"/></svg></div>
24954      <div><h3>Meet the new <b>GHOST FLOW</b></h3><p>Same data, same levels. A cleaner look and a menu that stays out of your way.</p></div></div>
24955    <div class="gf-wc-body">
24956      <ul>
24957        <li><b>Gold theme</b> is now the default. Classic is one click away.</li>
24958        <li><b>Left rail menu</b>: views on the left, more room for the grid and charts. Centered and Bottom dock are also available.</li>
24959        <li><b>Ghost Mode toolbar</b> floats on the chart: timeframes, Layers, Draw and Replay. Ticker, TV and TV Sync sit on the LEVELS line.</li>
24960        <li><b>Ghost Desk</b>: 1, 2 or 3 charts side by side, each with its own ticker, timeframe and layers.</li>
24961        <li><b>Crypto</b> sector: BTC, ETH, SOL and XRP with native GEX, Charm and options flow, 24/7.</li>
24962      </ul>
24963      <p>Set it the way you like under <b>Account menu &gt; Theme</b>. Your choice is remembered on this browser.</p>
24964    </div>
24965    <div class="gf-wc-acts"><button type="button" class="b1" onclick="gfWelcomeClose(false)">Try the new look</button><button type="button" class="b2" onclick="gfWelcomeClose(true)">Keep Classic</button><span class="b3">Shown once</span></div>
24966  </div>
24967</div>
24968<style>
24969.gf-wc{width:min(540px,94vw);background:var(--t-card,#0f172a);border:1px solid var(--t-accent,#d4af37);border-radius:14px;box-shadow:0 0 0 1px rgba(212,175,55,.25),0 24px 60px rgba(0,0,0,.7);overflow:hidden;color:var(--text,#f5f0e6);font-family:'Segoe UI',Inter,system-ui,sans-serif}
24970.gf-wc-top{padding:18px 22px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px}
24971.gf-wc-top h3{font-size:18px;margin:0;color:var(--text,#f5f0e6)}.gf-wc-top h3 b{color:var(--t-accentHi,#e9d38a)}
24972.gf-wc-top p{font-size:11.5px;color:var(--t-muted,#d6cfc0);margin:3px 0 0}
24973.gf-wc-body{padding:14px 22px;font-size:12.5px;line-height:1.55}
24974.gf-wc-body ul{margin:0;padding:0}.gf-wc-body li{margin:4px 0 4px 16px}.gf-wc-body b{color:var(--t-accentHi,#e9d38a)}
24975.gf-wc-body p{margin:8px 0 0}
24976.gf-wc-acts{display:flex;gap:10px;padding:0 22px 18px;align-items:center}
24977.gf-wc-acts .b1{background:var(--t-accent,#d4af37);color:var(--t-onAccent,#1a1200);font-weight:900;padding:9px 16px;border-radius:8px;font-size:12px;border:0;cursor:pointer;font-family:inherit}
24978.gf-wc-acts .b2{border:1px solid var(--t-border2,#2e323d);color:var(--text,#f5f0e6);font-weight:700;padding:9px 16px;border-radius:8px;font-size:12px;background:transparent;cursor:pointer;font-family:inherit}
24979.gf-wc-acts .b3{margin-left:auto;color:var(--t-dim,#8f8a7c);font-size:11px}
24980</style>
24981<div id="gfAiLauncher">
24982  <button id="gfAiLaunchBtn" title="Ghost AI — drag to move, click to open">
24983    <svg viewBox="0 0 64 64" width="28" height="28"><rect width="64" height="64" rx="14" fill="#0a0e17" stroke="#1e293b" stroke-width="2"/><path d="M32 12C22 12 16 20 16 28v20c0 0 3-4 6-4s3 4 6 4 3-4 6-4 3 4 6 4 3-4 6-4V28c0-8-6-16-16-16z" fill="#3b82f6" opacity="0.9"/><circle cx="26" cy="26" r="3.5" fill="#0a0e17"/><circle cx="38" cy="26" r="3.5" fill="#0a0e17"/></svg>
24984    <span class="lbl"><span class="g">Ghost</span> AI</span>
24985  </button>
24986</div>
24987<div id="gfAiPanel">
24988  <div id="gfAiResize" title="Drag to resize"></div>
24989  <div id="gfAiHead">
24990    <svg viewBox="0 0 64 64" width="24" height="24"><rect width="64" height="64" rx="14" fill="#0a0e17" stroke="#1e293b" stroke-width="2"/><path d="M32 12C22 12 16 20 16 28v20c0 0 3-4 6-4s3 4 6 4 3-4 6-4 3 4 6 4 3-4 6-4V28c0-8-6-16-16-16z" fill="#3b82f6" opacity="0.9"/><circle cx="26" cy="26" r="3.5" fill="#0a0e17"/><circle cx="38" cy="26" r="3.5" fill="#0a0e17"/></svg>
24991    <div><div class="t"><span style="color:#3b82f6">Ghost</span> AI</div><div class="s">live GEX, flow &amp; alerts</div></div>
24992    <button onclick="gfAiToggle(false)">Minimize</button>
24993  </div>
24994  <div id="gfAiLog"></div>
24995  <div id="gfAiChips"></div>
24996  <div id="gfAiInput"><input id="gfAiMsg" placeholder="Ask… NVDA, is NVDA 210c good, which semis are bearish" autocomplete="off"><button id="gfAiMic" title="Voice input" style="background:none;border:1px solid #1e293b;color:#94a3b8;border-radius:8px;padding:0 12px;cursor:pointer">
24996<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg></button><button id="gfAiSend">Send</button></div>
24997</div>
24998<script>
24999function gfAiToggle(open){ var p=document.getElementById('gfAiPanel'), l=document.getElementById('gfAiLauncher'); if(!p)return; if(open){ p.style.display='flex'; if(l)l.style.display='none'; var m=document.getElementById('gfAiMsg'); if(m)setTimeout(function(){m.focus();},60); } else { p.style.display='none'; if(l)l.style.display='flex'; } }
25000// Auto-minimize the Ghost AI panel when clicking anywhere outside it (but not the launcher,
25001// the panel itself, or an open card lightbox).
25002document.addEventListener('mousedown', function(e){
25003  var p=document.getElementById('gfAiPanel'); if(!p || p.style.display==='none') return;
25004  if(p.contains(e.target)) return;
25005  var l=document.getElementById('gfAiLauncher'); if(l && l.contains(e.target)) return;
25006  if(e.target.closest && e.target.closest('.gf-lightbox')) return;
25007  gfAiToggle(false);
25008});
25009(function(){
25010  var log=document.getElementById('gfAiLog'), inp=document.getElementById('gfAiMsg'), btn=document.getElementById('gfAiSend'), chips=document.getElementById('gfAiChips');
25011  if(!log) return;
25012  function bubble(text, who){ var d=document.createElement('div'); d.className='gbub '+(who==='me'?'me':'ai'); d.textContent=text; log.appendChild(d); log.scrollTop=log.scrollHeight; return d; }
25013  function gfLb(src){ var o=document.createElement('div'); o.className='gf-lightbox'; o.style.cssText='position:fixed;inset:0;z-index:100001;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;cursor:zoom-out;padding:20px'; var i=new Image(); i.src=src; i.style.cssText='max-width:96vw;max-height:96vh;border-radius:10px;box-shadow:0 10px 50px rgba(0,0,0,.6)'; o.appendChild(i); o.onclick=function(){ o.remove(); }; document.body.appendChild(o); }
25014  function paint(t, j){ if(j.html){ t.style.whiteSpace='normal'; t.innerHTML=j.html; } else { t.textContent=j.reply||'(no reply)'; } if(j.card){ var im=new Image(); im.src=j.card; im.style.cssText='display:block;margin-top:10px;max-width:100%;border-radius:8px;border:1px solid var(--t-border,#1e293b);cursor:zoom-in'; im.title='Click to enlarge'; im.onclick=function(){ gfLb(im.src); }; im.onerror=function(){ try{im.remove();}catch(e){} }; t.appendChild(im); } var act=document.createElement('div'); act.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;align-items:center'; (j.chips||[]).forEach(function(c){ var b=document.createElement('button'); b.textContent=c; b.style.cssText='background:none;border:1px solid var(--t-border,#1e293b);color:#94a3b8;border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;font-family:inherit'; b.onclick=function(){ send(c); }; act.appendChild(b); }); if(j.reply){ var cp=document.createElement('button'); cp.textContent='Copy'; cp.style.cssText='background:none;border:1px solid var(--t-border,#1e293b);color:#94a3b8;border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;font-family:inherit;margin-left:auto'; cp.onclick=function(){ navigator.clipboard.writeText(j.reply).then(function(){ cp.textContent='Copied'; setTimeout(function(){cp.textContent='Copy';},1200); }); }; act.appendChild(cp); } if(act.children.length) t.appendChild(act); log.scrollTop=log.scrollHeight; }
25015  function toast(msg, ok){ var d=document.createElement('div'); d.textContent=msg; d.style.cssText='position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--t-card,#111827);border:1px solid '+(ok===false?'#ef4444':'#22c55e')+';color:'+(ok===false?'#ef4444':'#22c55e')+';padding:9px 15px;border-radius:8px;font-size:13px;font-weight:600;z-index:100002;box-shadow:0 6px 20px rgba(0,0,0,.4)'; document.body.appendChild(d); setTimeout(function(){ try{d.remove();}catch(e){} },2400); }
25016  var LVLN={king:'King',call_wall:'Call Wall',put_wall:'Put Wall',vol_node:'Vol Node'};
25017  log.addEventListener('click', function(e){
25018    var lv=e.target.closest&&e.target.closest('[data-armlevel]');
25019    if(lv){ var tk=lv.getAttribute('data-tk'), level=lv.getAttribute('data-armlevel'); fetch('/api/alerts/level',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ticker:tk,level:level})}).then(function(r){return r.json();}).then(function(j){ toast(j&&j.ok?('Alert armed: '+tk+' '+(LVLN[level]||level)):((j&&(j.message||j.error))||'Failed'), !(j&&(j.message||j.error))); }).catch(function(){ toast('Failed',false); }); return; }
25020    var cd=e.target.closest&&e.target.closest('[data-card]'); if(cd){ var u=cd.getAttribute('data-card'); if(u) gfLb(u); }
25021  });
25022  function showPh(){ if(document.getElementById('gfAiPh'))return; var ph=document.createElement('div'); ph.id='gfAiPh'; ph.style.cssText='margin:auto;text-align:center;color:#334155;padding:20px'; ph.innerHTML='<svg viewBox="0 0 64 64" width="60" height="60" style="opacity:0.5"><rect width="64" height="64" rx="16" fill="#0a0e17" stroke="#1e293b" stroke-width="2"/><path d="M32 12C22 12 16 20 16 28v20c0 0 3-4 6-4s3 4 6 4 3-4 6-4 3 4 6 4 3-4 6-4V28c0-8-6-16-16-16z" fill="#3b82f6" opacity="0.7"/><circle cx="26" cy="26" r="3.5" fill="#0a0e17"/><circle cx="38" cy="26" r="3.5" fill="#0a0e17"/></svg><div style="margin-top:12px;font-size:14px;color:#475569">Ask me anything about the tape</div><div style="margin-top:4px;font-size:12px;color:#334155">Levels, net GEX, flow — or many tickers at once</div>'; log.appendChild(ph); }
25023  function nodeAlertsHtml(){
25024    var feed=(typeof nodeFeed!=='undefined'&&Array.isArray(nodeFeed))?nodeFeed:[];
25025    var NM={king:'King',call:'Call Wall',put:'Put Wall',vol:'Vol Node'}, NC={king:'#eab308',call:'#22c55e',put:'#ef4444',vol:'#3b82f6'};
25026    if(!feed.length) return '<div class="brf"><div class="brf-sec"><div class="brf-lbl">NODE ALERTS · TODAY</div><div class="brf-row" style="color:#8b9cb5">No node alerts fired today yet. They fire as price crosses a King / Call Wall / Put Wall / Vol Node on your watchlist.</div></div></div>';
25027    var rows=feed.slice(0,50).map(function(a){ var tm=new Date(a.t); var hh=tm.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); return '<div class="tr"><span style="font-weight:700">'+a.ticker+'</span><span style="color:'+(NC[a.node]||'#e2e8f0')+'">'+(NM[a.node]||a.node)+'</span><span class="r">'+a.strike+'</span><span class="r">'+hh+'</span></div>'; }).join('');
25028    return '<div class="brf"><div class="brf-sec"><div class="brf-lbl">NODE ALERTS · TODAY ('+feed.length+')</div><div class="gtbl g4"><div class="tr th"><span>
25028Ticker</span><span>Node</span><span class="r">Strike</span><span class="r">Time</span></div>'+rows+'</div></div></div>';
25029  }
25030  var busy=false;
25031  function send(text){ text=(text||inp.value||'').trim(); if(!text||busy)return; busy=true; var ph=document.getElementById('gfAiPh'); if(ph)ph.remove(); inp.value=''; bubble(text,'me');
25032    if(/\bnode ?alerts?\b/i.test(text)){ var tn=bubble('','ai'); paint(tn,{html:nodeAlertsHtml()}); busy=false; inp.focus(); return; }
25033    var t=bubble('…','ai'); btn.disabled=true;
25034    fetch('/api/ai/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message:text})}).then(function(r){return r.json();}).then(function(j){ paint(t,j); }).catch(function(){ t.textContent='Sorry — something went wrong. Try again.'; }).finally(function(){ busy=false; btn.disabled=false; log.scrollTop=log.scrollHeight; inp.focus(); });
25035  }
25036  btn.onclick=function(){ send(); };
25037  inp.addEventListener('keydown',function(e){ if(e.key==='Enter'){ e.preventDefault(); send(); } });
25038  ['NVDA',"Where's the risk today?",'which semis are bearish','is NVDA 210c good','morning briefing'].forEach(function(s){ var c=document.createElement('button'); c.textContent=s; c.onclick=function(){ send(s); }; chips.appendChild(c); });
25039  (function(){ var mic=document.getElementById('gfAiMic'); if(!mic)return; var SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){ mic.style.display='none'; return; } var rec=new SR(); rec.lang='en-US'; rec.interimResults=false; var on=false; mic.onclick=function(){ if(on){ try{rec.stop();}catch(e){} return; } try{ rec.start(); on=true; mic.style.color='#ef4444'; }catch(e){} }; rec.onresult=function(e){ var txt=e.results[0][0].transcript; if(txt) send(txt); }; rec.onend=function(){ on=false; mic.style.color=''; }; rec.onerror=function(){ on=false; mic.style.color=''; }; })();
25040  var clr=document.createElement('button'); clr.textContent='Clear'; clr.onclick=function(){ if(!confirm('Clear chat history?'))return; fetch('/api/ai/history',{method:'DELETE'}).then(function(){ log.innerHTML=''; showPh(); }); }; chips.appendChild(clr);
25041  function renderHistory(list){ list.forEach(function(it){ bubble(it.q,'me'); var t=bubble('','ai'); paint(t,it); }); }
25042  fetch('/api/ai/history').then(function(r){return r.json();}).then(function(j){ var h=(j&&j.history)||[]; if(h.length){ renderHistory(h); } else { showPh(); } }).catch(function(){ showPh(); });
25043})();
25044// Draggable launcher — move it off any data it covers; position persists. Click (no drag) opens.
25045(function(){
25046  var l=document.getElementById('gfAiLauncher'), btn=document.getElementById('gfAiLaunchBtn'); if(!l||!btn) return;
25047  function clamp(){ var r=l.getBoundingClientRect(); if(r.left<0||r.top<0||r.right>innerWidth||r.bottom>innerHeight){ var nl=Math.max(6,Math.min(innerWidth-r.width-6,r.left)), nt=Math.max(6,Math.min(innerHeight-r.height-6,r.top)); l.style.left=nl+'px'; l.style.top=nt+'px'; l.style.right='auto'; l.style.bottom='auto'; } }
25048  try{ var pos=JSON.parse(localStorage.getItem('gfAiPos')||'null'); if(pos&&pos.left!=null){ l.style.left=pos.left+'px'; l.style.top=pos.top+'px'; l.style.right='auto'; l.style.bottom='auto'; setTimeout(clamp,0); } }catch(e){}
25049  var dragging=false, moved=false, sx=0, sy=0, ox=0, oy=0;
25050  function move(e){ if(!dragging)return; var p=e.touches?e.touches[0]:e, dx=p.clientX-sx, dy=p.clientY-sy; if(Math.abs(dx)+Math.abs(dy)>4) moved=true; var w=l.offsetWidth, h=l.offsetHeight; l.style.left=Math.max(6,Math.min(innerWidth-w-6, ox+dx))+'px'; l.style.top=Math.max(6,Math.min(innerHeight-h-6, oy+dy))+'px'; l.style.right='auto'; l.style.bottom='auto'; if(e.cancelable)e.preventDefault(); }
25051  function up(){ if(!dragging)return; dragging=false; document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); document.removeEventListener('touchmove',move); document.removeEventListener('touchend',up); if(moved){ var r=l.getBoundingClientRect(); try{ localStorage.setItem('gfAiPos',JSON.stringify({left:Math.round(r.left),top:Math.round(r.top)})); }catch(e){} } }
25052  function down(e){ dragging=true; moved=false; var p=e.touches?e.touches[0]:e; sx=p.clientX; sy=p.clientY; var r=l.getBoundingClientRect(); ox=r.left; oy=r.top; document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up); }
25053  btn.addEventListener('mousedown',down); btn.addEventListener('touchstart',down,{passive:true});
25054  btn.onclick=function(e){ if(moved){ e.preventDefault(); e.stopPropagation(); moved=false; return; } gfAiToggle(true); };
25055  addEventListener('resize',clamp);
25056  // Show the "Ghost AI" label for the first minute, then collapse to the icon (hover re-sh
25056ows it).
25057  setTimeout(function(){ l.classList.add('mini'); setTimeout(clamp,0); }, 60000);
25058})();
25059// Resizable panel — drag the left edge to make the window wider (size persists).
25060(function(){
25061  var p=document.getElementById('gfAiPanel'), rz=document.getElementById('gfAiResize'); if(!p||!rz) return;
25062  try{ var w=parseInt(localStorage.getItem('gfAiW')||'0'); if(w>=340) p.style.width=Math.min(w,Math.round(innerWidth*0.96))+'px'; }catch(e){}
25063  var dragging=false;
25064  function move(e){ if(!dragging)return; var x=(e.touches?e.touches[0]:e).clientX; p.style.width=Math.max(340,Math.min(Math.round(innerWidth*0.96), innerWidth-x))+'px'; if(e.cancelable)e.preventDefault(); }
25065  function up(){ if(!dragging)return; dragging=false; rz.classList.remove('on'); document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); document.removeEventListener('touchmove',move); document.removeEventListener('touchend',up); try{ localStorage.setItem('gfAiW', parseInt(p.style.width)||440); }catch(e){} }
25066  function down(e){ dragging=true; rz.classList.add('on'); document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up); if(e.cancelable)e.preventDefault(); }
25067  rz.addEventListener('mousedown',down); rz.addEventListener('touchstart',down,{passive:false});
25068})();
25069</script>
vendor: 1 bytes, line 25069
25069
25070<script>
25071// Launch-pricing week banner — shown to EVERYONE (members see their lock + the upgrade offers,
25072// free users see the last-chance rates). Auto-disappears at the Mon Aug 10 00:00 PT cutover.
25073// Dismiss lasts for the browser session only, so it returns next visit during the week.
25074(function(){
25075  var CUTOVER = 1786345200000; // 2026-08-10T07:00:00Z
25076  if (Date.now() >= CUTOVER) return;
25077  try { if (sessionStorage.getItem('gfLaunchBarHidden')) return; } catch (e) {}
25078  var bar = document.createElement('div');
25079  bar.id = 'gfLaunchBar';
25080  // FIXED at the very top — the header pins below it and every view's height accounts for it via
25081  // updateHeaderHeightVar (same mechanism as the demo bar), so it pushes the app down instead of
25082  // covering anything. Bottom placement hid the flow/trade-plan footers (user report).
25083  bar.style.cssText = 'position:fixed;left:0;right:0;top:0;z-index:200;background:linear-gradient(90deg,#1a1206,#0d1526);border-bottom:1px solid rgba(245,158,11,.5);padding:7px 40px 7px 14px;font-size:12px;color:#e2e8f0;text-align:center;font-family:-apple-system,sans-serif;line-height:1.5';
25084  function relayout(){ try { updateHeaderHeightVar(); } catch (e) {} try { if (typeof fitChartViewHeight === 'function') fitChartViewHeight(); } catch (e) {} try { if (typeof fitColumnsHeight === 'function') fitColumnsHeight(); } catch (e) {} }
25085  function txt(seats){
25086    var s = Math.max(0, Math.floor((CUTOVER - Date.now()) / 1000));
25087    var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600);
25088    return '<b style="color:#fbbf24">LAST WEEK OF LAUNCH PRICING</b> — lock <b>$29/mo forever</b>, Yearly <b>$299</b>, Lifetime <b>$999</b>' + (seats != null ? ' <span style="color:#f59e0b">(' + seats + ' seats left)' + '</span>' : '') + '. From Aug 10: $49 / $449 / $1,249. <b style="color:#fbbf24">' + d + 'd ' + h + 'h left</b> · <a href="/subscribe" style="color:#38bdf8;font-weight:700">Lock yours →</a>'
25089      + '<span id="gfLaunchBarX" style="position:absolute;right:12px;top:5px;cursor:pointer;color:#94a3b8;font-weight:700">✕</span>';
25090  }
25091  function wireX(){ var x = document.getElementById('gfLaunchBarX'); if (x) x.onclick = function(){ try { sessionStorage.setItem('gfLaunchBarHidden', '1'); } catch (e) {} bar.remove(); relayout(); }; }
25092  bar.innerHTML = txt(null);
25093  document.body.insertBefore(bar, document.body.firstChild);
25094  wireX(); relayout();
25095  setTimeout(relayout, 300);   // fonts/wrap can change the bar height a beat later
25096  fetch('/api/pricing').then(function(r){ return r.json(); }).then(function(p){ if (p && p.launch && document.getElementById('gfLaunchBar')) { bar.innerHTML = txt(p.lifetimeSeatsLeft); wireX(); relayout(); } }).catch(function(){});
25097})();
25098</script>
25098
25099<!-- Cloudflare Web Analytics -->
vendor: 99 bytes, line 25099
25099<script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "
2509992e3d0566b914ba093bb0b5840b2aa79
vendor: 13 bytes, line 25099
25099"}'></script>
25099<!-- End Cloudflare Web Analytics -->
25100</body>
25101</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.