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 & 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 & 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 & 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 & 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 & 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">· 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">›</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">▾</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">▾</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">▾</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">▾</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">▾</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">▾</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">▾</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">▴</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">×</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 ({ "&": "&", "<": "<", ">": ">", '"': """ })[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> & <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> & <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&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&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&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&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&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&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 & 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">×</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 <b style="color:#22c55e">CW</b> Call Wall <b style="color:#ef4444">PW</b> Put Wall â '+_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">×</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">×</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,'"')+'" title="Open this layout in this window">'+n+'</button>' 5322 +'<button class="gf-ws-layx" data-laydel="'+n.replace(/"/g,'"')+'" title="Delete this layout">×</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&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&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&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&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&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'&¤tView==='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 & 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 & 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 & 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'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&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>★</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">▲</b> / <b style="color:#f87171">▼</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'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 & Signals</b> (Modes > 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'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'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'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&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, '&').replace(/"/g, '"').replace(/</g, '<') + '">?</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 ★ / tint ▲▼</td><td style="padding:5px 6px;color:#cbd5e1">The grid\'s 3 biggest prints each way / each column\'s own high & 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–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 ±1σ</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">×</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">×</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 & 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'&¤tView==='gex'){ renderColumns(); reloadSlots(); } if(typeof currentView!=='undefined'&¤tView==='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 ? '▾' : '▴'; 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 ? '▾' : '▴'; 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">⋮⋮</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">×</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">–</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">×</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,'<')+'</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?'▲ ':'▼ ')+_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?'▲ ':'▼ ')+_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+' · 1 Buy'+(closed?' · 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&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)">→</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,'"')+'"><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("tvLvlModal").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'&¤tView==='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&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,'<
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,'<'),'#c084fc','rgba(168,85,247,.45)'); }); 13274 why+='<span style="color:#9fb0c9">'+String(it.rationale||'').replace(/</g,'<')+'</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,'<').replace(/"/g,'"'); 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,'<')):''; 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,'"'):'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'?'≥':'≤')+' $'+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&L record.</div>'; }); 13440} 13441function _ghostLoadTracker(){ _loadPnlPanel('ghostTracker','ghost','#c084fc','GHOST TRADE P&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&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,'<')+'</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&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 ><b style="color:#22c55e">'+(+_cl.strike + +it.entryPrem).toFixed(2)+'</b> or <<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 >±'+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&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&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,'<')+'</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)">→</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=>({'&':'&','<':'<','>':'>'}[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 > 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 > 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+' · '+expShort+'</span>'+accBadge+ 21514 '<span class="nf-time">'+(time?time+' · ':'')+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> · OI <b>'+nfK(oi)+'</b> · 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?'▸':'▾')+'</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?'▸':'▾'; 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 & 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 & 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 > OI')+chk('askGtBid','Ask > 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+' ×</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 →</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 = '‹ 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 = '‹ 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 = '›'; 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)">‹</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, '<') + '</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, '<') : '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">×'+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&L</div><div class="v '+pc(totPnl)+'">'+nfMoney(totPnl)+'</div><div class="sub '+pc(avgPct)+'">'+(avgPct>=0?'+':'')+avgPct.toFixed(1)+'% avg · open</div></div>'+ 23340 '<div class="trk-tile"><div class="k">Realized P&L</div><div class="v '+pc(realized)+'">'+nfMoney(realized)+'</div><div class="sub">'+wins+'W / '+(closed.length-wins)+'L · '+winRate+'%</div></div>'+ 23341 '<div class="trk-tile"><div class="k">Total P&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&L $</th><th class="num">P&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&L $</th><th class="num">P&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&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">×</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 & 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 > 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 & 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.