// Display control for the LED ticker (spec v2). Mobile-first: big targets, high contrast, // live preview, auto-send with "applied on the panel" confirmation. // Used inside ProfileModal (Display tab) and standalone at /display (display.html). (function () { const DC = window.DisplaySpec; const DC_CSS = ` .dc{--dc-bg:#0b0d12;--dc-card:#151923;--dc-line:#2a3142;--dc-fg:#f4f6fb;--dc-mute:#a9b3c7;--dc-ok:#22c55e;--dc-warn:#fbbf24;--dc-bad:#f87171;--dc-acc:#7cc4ff; color:var(--dc-fg);background:var(--dc-bg);font:16px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%} .dc *{box-sizing:border-box} .dc{max-width:100%;min-width:0;overflow-wrap:break-word} @supports (overflow:clip){.dc{overflow-x:clip}} .dc img,.dc canvas,.dc video{max-width:100%} .dc img:not(.dc-canvas),.dc video{height:auto} .dc input,.dc select,.dc textarea,.dc button{max-width:100%;min-width:0} .dc-status{position:sticky;top:0;z-index:5;padding:10px 12px;border-bottom:2px solid var(--dc-line);background:#05070b;display:grid;grid-template-columns:minmax(0,1fr);gap:4px} .dc-status>*{min-width:0} .dc-status-row{display:flex;align-items:center;gap:8px;font-weight:700;font-size:17px} .dc-dot{width:14px;height:14px;border-radius:50%;flex:0 0 14px} .dc-sub{font-size:14px;color:var(--dc-mute)} .dc-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:20px;line-height:20px} .dc-status-row{min-height:24px}.dc-status-row>.dc-line{flex:1;min-width:0;line-height:24px} .dc-err{color:#fff;background:#7f1d1d;border:2px solid var(--dc-bad);border-radius:10px;padding:8px 10px;font-size:15px;font-weight:600} .dc-okbox{color:#04130a;background:var(--dc-ok);border-radius:10px;padding:8px 10px;font-size:16px;font-weight:800} .dc-body{padding:12px;display:grid;grid-template-columns:minmax(0,1fr);gap:14px;max-width:640px;margin:0 auto} .dc-card{background:var(--dc-card);border:1px solid var(--dc-line);border-radius:14px;padding:12px;display:grid;grid-template-columns:minmax(0,1fr);gap:10px} .dc-h{font-size:18px;font-weight:800;margin:0} .dc-row{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px} .dc-row>*{min-width:0} .dc-btn{min-height:48px;padding:10px 14px;border-radius:12px;border:2px solid var(--dc-line);background:#222838;color:var(--dc-fg);font-size:16px;font-weight:700;cursor:pointer;touch-action:manipulation} .dc-btn:active{transform:scale(.98)} .dc-btn[disabled]{opacity:.45} .dc-btn.big{width:100%;min-height:56px;font-size:19px} .dc-btn.go{background:#16a34a;border-color:#22c55e;color:#fff} .dc-btn.on{background:#1d4ed8;border-color:#60a5fa;color:#fff} .dc-btn.danger{background:#3b0d0d;border-color:#b91c1c;color:#fecaca} .dc-btn.sm{min-height:44px;min-width:44px;padding:6px 10px;font-size:15px} .dc-tabs{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px}.dc-tabs>*{min-width:0} .dc-tabs5{grid-template-columns:repeat(5,minmax(0,1fr));gap:6px}.dc-tabs5 .dc-btn{padding:10px 4px;min-height:48px} .dc-tabs5 .dc-lp-inherit{grid-column:1/-1} .dc-lp{display:grid;gap:6px;font-weight:700;font-size:15px}.dc-lp .dc-sub{font-weight:400}.dc-lp.gated>span{opacity:.7} .dc-lp-wrap{display:grid;gap:4px} .dc-today{display:grid;gap:4px;min-width:0}.dc-today.gated .dc-check{opacity:.6} .dc-check{display:flex;align-items:flex-start;gap:10px;min-height:48px;padding:10px 12px;border-radius:12px;border:2px solid var(--dc-line);background:#1b2030;font-weight:700;font-size:16px;cursor:pointer;min-width:0} .dc-check input{width:22px;height:22px;flex:0 0 auto;margin:1px 0 0}.dc-check>span{display:grid;gap:2px;min-width:0}.dc-check .dc-sub{font-weight:500} .dc-anim{display:grid;gap:8px;min-width:0}.dc-anim.gated .dc-toggle{opacity:.6;cursor:not-allowed}.dc-anim select{min-width:0;max-width:100%} .dc-toggle{display:flex;align-items:center;justify-content:space-between;width:100%;min-height:52px;padding:10px 12px;border-radius:12px;border:2px solid var(--dc-line);background:#1b2030;color:var(--dc-fg);font-size:16px;font-weight:700;text-align:left;min-width:0;gap:8px}.dc-toggle>*{min-width:0}.dc-toggle>.dc-pill{flex:0 0 auto} .dc-auto{border-radius:12px;padding:10px 12px;border:2px solid var(--dc-line);background:#141a28;display:grid;gap:6px} .dc-auto.on{border-color:var(--dc-ok)} .dc-auto-h{font-size:15px;line-height:1.35} .dc-auto-chips{display:flex;flex-wrap:wrap;gap:6px} .dc-chip{padding:4px 10px;border-radius:999px;background:#25304a;font-size:13px;font-weight:700} .dc-dim{opacity:.55;transition:opacity .15s}.dc-dim:focus-within,.dc-dim:hover{opacity:.85} .dc-pill{min-width:64px;text-align:center;padding:6px 10px;border-radius:999px;font-weight:800;font-size:15px} .dc-pill.on{background:var(--dc-ok);color:#04130a}.dc-pill.off{background:#3a4152;color:#e5e7eb} .dc input[type=text],.dc input[type=time],.dc input[type=number],.dc input[type=password],.dc select,.dc textarea{width:100%;min-height:48px;padding:10px 12px;border-radius:10px;border:2px solid var(--dc-line);background:#0d1018;color:var(--dc-fg);font-size:16px} .dc textarea{font-family:ui-monospace,Menlo,monospace;font-size:16px} .dc input[type=range]{width:100%;height:36px;accent-color:#60a5fa} .dc input[type=color]{width:52px;height:44px;border:2px solid var(--dc-line);border-radius:10px;background:none;padding:2px} .dc div.dc-l{min-width:0} .dc label.dc-l{display:grid;grid-template-columns:minmax(0,1fr);gap:6px;font-weight:700;font-size:15px} .dc-presets{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} .dc-preset{min-height:64px;border-radius:12px;border:3px solid transparent;font-weight:800;font-size:16px;cursor:pointer;padding:6px} .dc-colors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} .dc-color{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;min-width:0}.dc-color>*{min-width:0} .dc-page{border:2px solid var(--dc-line);border-radius:12px;padding:10px;display:grid;grid-template-columns:minmax(0,1fr);gap:10px;background:#11151f} .dc-page.offp{opacity:.6} .dc-page-head{display:flex;align-items:center;gap:6px;min-width:0}.dc-page-head>.dc-btn{flex:0 0 auto} .dc-page-name{flex:1;font-weight:800;font-size:16px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dc-canvas-wrap{background:#000;border-radius:12px;padding:8px;border:2px solid #333} .dc-canvas{width:100%;max-width:100%;image-rendering:pixelated;image-rendering:crisp-edges;display:block;border-radius:4px} .dc-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;min-width:0} .dc-chip{max-width:100%;min-height:44px;padding:8px 14px;border-radius:22px;border:2px solid var(--dc-line);background:#11151f;color:#cfd6e4;font-weight:800;font-size:15px;cursor:pointer} .dc-chip.on{background:#1f7a4d;border-color:#5cffac;color:#fff} .dc-mono{font-family:ui-monospace,Menlo,monospace} .dc-grid2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px} /* two ON/OFF toggles side by side leave too little room for the label on a phone (Sora is wide), which broke "Reactio/ns": stack them */ @media (max-width:520px){.dc-grid2:has(>.dc-toggle){grid-template-columns:minmax(0,1fr)}} .dc-panel-grid{display:grid;gap:6px} .dc-panel-cell{display:grid;gap:4px;padding:6px;border:2px dashed var(--dc-line);border-radius:10px;background:#0d1018;min-width:0} `; // PANEL_W x PANEL_H = the logical screen drawPanel draws: 128x64 for one panel, wider/taller for a multi-panel // wall (DC.panelsLogical: wide*128/scale x tall*64/scale, the same canvas the Pi's renderer lays out). let PANEL_W = 128, PANEL_H = 64; const HEADER_H = 12, FOOTER_H = 11; const MOCK = { wagers: [["ROOSRER", "KC -3.5", "$50"], ["BILL", "NYY ML", "$20"], ["JAY", "O 47.5", "$25"], ["MIKE", "LAL +4", "$40"], ["SAM", "DAL ML", "$10"]], games: [{ head: "Bills @ Chiefs", bets: [["ROOSRER", "KC -3.5", "$50"], ["JAY", "O 47.5", "$25"]] }, { head: "Red Sox @ Yankees", bets: [["BILL", "NYY ML", "$20"]] }, { head: "Lakers @ Celtics", bets: [["MIKE", "LAL +4", "$40"]] }], balances: [["ROOSRER", "+420", 1, "T +45 | 1 OPEN $50 | Y +1.1k", 1, 0], ["BILL", "-85", -1, "T -20 | Y -300", -1, 1], ["JAY", "+1.2k", 1, "T +0 | Y +2.0k", 0, 2], ["MIKE", "-300", -1, "T +0 | 1 OPEN $40 | Y -650", 0, 3]], results: [["KC 27 BUF 24", "ROOSRER WON +45", 1], ["NYY 3 BOS 5", "BILL LOST -20", -1], ["DAL 20 PHI 20", "JAY PUSH", 0]], reactions: ["BILL: NICE HIT", "JAY: LOL", "MIKE: PAY UP"], movers: ["ROOSRER +45 ▲"], live: false, }; function dcApiBase(explicit) { // On a Blackbook SPA host (kinjar or app.bragwire.com) always use the same-origin proxy; else the explicit base. if (typeof window !== "undefined" && window.BBHosts) { const via = window.BBHosts.defaultApiBase(typeof location !== "undefined" ? location : null, typeof localStorage !== "undefined" ? localStorage : null); if (via === "/api/bb") return via; } else { const h = (typeof location !== "undefined" && location.hostname) || ""; if (/blackbook/i.test(h) || h === "app.bragwire.com") return "/api/bb"; } if (explicit) return String(explicit).replace(/\/$/, ""); const saved = (typeof localStorage !== "undefined" && localStorage.getItem("bb_api_base")) || ""; const fallback = (typeof window !== "undefined" && window.BBHosts && window.BBHosts.DEFAULT_REMOTE_API) || "https://api.blackbook.kinjar.com"; return (saved || fallback).replace(/\/$/, ""); } // ------------------------------------------------------------------ preview function drawPanel(ctx, spec, defaults, t, now, popup, data) { const D = data || MOCK; const lsz = DC.panelsLogical(spec); PANEL_W = lsz.w; PANEL_H = lsz.h; ctx.imageSmoothingEnabled = false; const pages = DC.enabledPages(spec); const hdr = spec.header || {}, ftr = spec.footer || {}; // same geometry as the Pi renderer's _layout(): ticker / title bar each top or bottom; on a shared edge the // title bar sits nearest the edge and the ticker next to the content const bands = DC.layoutBands(spec, PANEL_H); const hOn = !!bands.title, fOn = !!bands.ticker; const top = bands.top, bot = bands.bot; const mh = bot - top + 1; const font = (px) => `bold ${px}px "DejaVu Sans", Verdana, sans-serif`; const theme = DC.themeColors(spec, defaults); const off = DC.sleeping(spec, now); ctx.clearRect(0, 0, PANEL_W, PANEL_H); if (off || !pages.length) { ctx.fillStyle = "#000"; ctx.fillRect(0, 0, PANEL_W, PANEL_H); ctx.fillStyle = "#444"; ctx.font = font(9); ctx.textAlign = "center"; ctx.fillText(spec.enabled === false ? "DISPLAY OFF" : "SLEEPING", PANEL_W / 2, Math.round(PANEL_H / 2) + 4); ctx.textAlign = "left"; return { page: null, label: spec.enabled === false ? "Display off" : "Sleeping (schedule)" }; } if (popup) return drawGamePopup(ctx, popup, theme, font, now); // per-page content size (px) for timing const gamesH = () => (D.games || []).reduce((a, g, i) => a + (i ? 2 : 0) + 13 + g.bets.length * 10, 0); const graph = (p) => p.type === "trend" ? DC.trendSeries(D.board || MOCK_BOARD, p.days || 30, p.limit || 6) : p.type === "pnl" ? DC.pnlRows(D.board || MOCK_BOARD, p.metric || "ytd", p.limit || 12) : null; const rowsOf = (p) => p.type === "wagers" ? (D.games ? gamesH() : D.wagers.length * 10) : p.type === "ticker" ? D.wagers.length * 10 : p.type === "trend" ? mh + 2 + graph(p).series.length * 10 : p.type === "pnl" ? graph(p).length * 10 + 1 : p.type === "balances" ? D.balances.length * 20 : (p.type === "results" || p.type === "scores") ? D.results.length * 18 : p.type === "reactions" ? Math.min(p.limit || 8, D.reactions.length) * 10 : 0; ctx.font = font(Number(13)); const marqW = (p) => { ctx.font = font(Math.min(24, p.size || 16)); return ctx.measureText(DC.renderTemplate(p.text || p.title || "", fieldsFor(spec, p, now))).width; }; // trend style "each" (2.0.22, renderer.build_trend): one still page per bettor, each held for `hold`, never scrolled const trendEach = (p) => p.type === "trend" && DC.trendStyleOf(p) === "each"; const durs = pages.map((p) => p.type === "marquee" ? DC.pageDuration(p, marqW(p), PANEL_W) : trendEach(p) ? Math.max(1, graph(p).series.length) * (Number(p.hold) || 2.2) : DC.pageDuration(p, rowsOf(p), mh)); const { index, elapsed } = DC.pageAt(durs, t); const page = pages[index]; const c = DC.pageColors(spec, page, defaults); ctx.fillStyle = c.background; ctx.fillRect(0, 0, PANEL_W, PANEL_H); // content ctx.save(); ctx.beginPath(); ctx.rect(0, top, PANEL_W, mh); ctx.clip(); const hold = Number(page.hold) || 2.2; const scrollY = (contentPx) => { const over = Math.max(0, contentPx - mh); if (!over) return 0; const s = elapsed - hold; if (s <= 0) return 0; return Math.min(over, s * DC.scrollPxPerSec(page.speed)); }; const f = fieldsFor(spec, page, now); ctx.textBaseline = "alphabetic"; const empty = (msg) => { ctx.font = font(9); ctx.fillStyle = c.dim; ctx.fillText(msg, 6, top + 20); }; const emptyMsg = { wagers: "NO OPEN WAGERS", ticker: "NO OPEN WAGERS", balances: "NO ACTIVE PLAYERS", results: "NO RESULTS YET", scores: "NO RESULTS YET", reactions: "NO REACTIONS" }[page.type]; const list = { wagers: D.wagers, ticker: D.wagers, balances: D.balances, results: D.results, scores: D.results, reactions: D.reactions }[page.type]; if (emptyMsg && !(list && list.length)) empty(emptyMsg); if (page.type === "wagers" && D.games) { // team-name row per game (colors.group_bg / group_text; unset = the stock dark row), bets below, no stripes let y = top - scrollY(rowsOf(page)); D.games.forEach((g, gi) => { if (gi) y += 2; ctx.fillStyle = c.group_bg || DC.OPTIONAL_COLOR_STOCK.group_bg; ctx.fillRect(0, y, PANEL_W, 13); ctx.font = font(8); ctx.fillStyle = c.group_text || c.text; ctx.fillText(g.head, 4, y + 10); y += 13; g.bets.forEach((r) => { ctx.font = font(8); ctx.fillStyle = c.accent; ctx.fillText(r[0].slice(0, 9), 2, y + 8); ctx.fillStyle = c.pending; ctx.textAlign = "right"; ctx.fillText(r[2], PANEL_W - 2, y + 8); const sw = ctx.measureText(r[2]).width; ctx.fillStyle = c.text; ctx.fillText(r[1], PANEL_W - 6 - sw, y + 8); ctx.textAlign = "left"; y += 10; }); }); } else if (page.type === "trend") { const { series } = graph(page); const y0 = top - scrollY(rowsOf(page)); if (!series.length) empty("NO TREND DATA"); else if (trendEach(page)) { // like the Pi: name (bettor colour) left, "D change" (dim) + balance right on top, the line on its own scale below const { days } = graph(page); const sr = series[Math.min(series.length - 1, Math.floor(elapsed / hold))]; const col = USER_COLORS[sr.index % USER_COLORS.length]; ctx.font = font(8); ctx.textAlign = "right"; const bs = DC.piCompact(sr.balance), bw = ctx.measureText(bs).width; ctx.fillStyle = sr.balance >= 0 ? c.win : c.loss; ctx.fillText(bs, PANEL_W - 2, top + 8); ctx.font = font(7); ctx.fillStyle = c.dim; const ch = `${days.length}D ${DC.piCompact(sr.change)}`; ctx.fillText(ch, PANEL_W - bw - 6, top + 8); const cw = ctx.measureText(ch).width; ctx.textAlign = "left"; ctx.font = font(8); ctx.fillStyle = col; let name = String(sr.handle).toUpperCase(); while (name.length > 1 && ctx.measureText(name).width > PANEL_W - bw - cw - 12) name = name.slice(0, -1); ctx.fillText(name, 2, top + 8); let lo = Math.min(...sr.values), hi = Math.max(...sr.values); if (hi - lo < 1) { lo -= 1; hi += 1; } const gt = top + 11, gb = top + mh - 2; const yOf = (v) => Math.max(gt, Math.min(gb, Math.round(gb - (v - lo) / (hi - lo) * (gb - gt)))); if (lo < 0 && hi > 0) { ctx.fillStyle = c.dim; for (let x = 2; x <= PANEL_W - 3; x += 3) ctx.fillRect(x, yOf(0), 1, 1); } ctx.strokeStyle = col; ctx.lineWidth = 1; ctx.beginPath(); const vs = sr.values.length > 1 ? sr.values : [sr.values[0], sr.values[0]]; vs.forEach((v, k) => { const x = 2 + (PANEL_W - 5) * k / (vs.length - 1); k ? ctx.lineTo(x, yOf(v)) : ctx.moveTo(x, yOf(v)); }); ctx.stroke(); } else { const all = [].concat(...series.map((x) => x.values)); const lo = Math.min(0, ...all), hi = Math.max(0, ...all), span = (hi - lo) || 1; const yOf = (v) => y0 + 2 + (mh - 5) * (1 - (v - lo) / span); ctx.fillStyle = c.dim; ctx.fillRect(0, Math.round(yOf(0)), PANEL_W, 1); series.forEach((sr) => { ctx.strokeStyle = USER_COLORS[sr.index % USER_COLORS.length]; ctx.lineWidth = 1; ctx.beginPath(); sr.values.forEach((v, k) => { const x = 2 + (PANEL_W - 4) * (sr.values.length > 1 ? k / (sr.values.length - 1) : 1); k ? ctx.lineTo(x, yOf(v)) : ctx.moveTo(x, yOf(v)); }); ctx.stroke(); }); series.forEach((sr, k) => { const y = y0 + mh + 2 + k * 10; ctx.font = font(8); ctx.fillStyle = USER_COLORS[sr.index % USER_COLORS.length]; ctx.fillText(String(sr.handle).toUpperCase().slice(0, 12), 2, y + 8); ctx.fillStyle = sr.balance >= 0 ? c.win : c.loss; ctx.textAlign = "right"; ctx.fillText(DC.piCompact(sr.balance), PANEL_W - 2, y + 8); ctx.textAlign = "left"; }); } } else if (page.type === "pnl") { const rows = graph(page); const y0 = top - scrollY(rowsOf(page)); if (!rows.length) empty("NO P&L YET"); const lo = Math.min(0, ...rows.map((r) => r.value)), hi = Math.max(0, ...rows.map((r) => r.value)), span = (hi - lo) || 1; const bx0 = 42, bx1 = PANEL_W - 29, zx = bx0 + Math.round((0 - lo) / span * (bx1 - bx0)); rows.forEach((r, k) => { const y = y0 + 1 + k * 10, vx = bx0 + Math.round((r.value - lo) / span * (bx1 - bx0)); ctx.font = font(8); ctx.fillStyle = USER_COLORS[r.index % USER_COLORS.length]; ctx.fillText(r.handle.toUpperCase().slice(0, 6), 1, y + 8); ctx.fillStyle = r.value >= 0 ? c.win : c.loss; ctx.fillRect(Math.min(zx, vx), y + 1, Math.max(1, Math.abs(vx - zx)), 7); ctx.textAlign = "right"; ctx.fillText(DC.piCompact(r.value), PANEL_W - 1, y + 8); ctx.textAlign = "left"; }); } else if (page.type === "wagers" || page.type === "ticker") { const y0 = top - scrollY(rowsOf(page)); D.wagers.forEach((r, i) => { const y = y0 + i * 10; if (i % 2) { ctx.fillStyle = c.row_tint; ctx.fillRect(0, y, PANEL_W, 10); } ctx.font = font(8); ctx.fillStyle = c.accent; ctx.fillText(r[0].slice(0, 7), 2, y + 8); ctx.fillStyle = c.text; ctx.fillText(r[1], 50, y + 8); ctx.fillStyle = c.pending; ctx.textAlign = "right"; ctx.fillText(r[2], PANEL_W - 2, y + 8); ctx.textAlign = "left"; }); } else if (page.type === "balances") { const y0 = top - scrollY(rowsOf(page)); // same rows as the Pi's build_board: name + balance, then "T today | n OPEN $x | Y ytd" D.balances.forEach((r, i) => { const y = y0 + i * 20; if (i % 2) { ctx.fillStyle = c.row_tint; ctx.fillRect(0, y, PANEL_W, 20); } ctx.font = font(9); ctx.fillStyle = r[4] > 0 ? c.win : r[4] < 0 ? c.loss : USER_COLORS[(r[5] || i) % USER_COLORS.length]; ctx.fillText(r[0], 2, y + 9); ctx.fillStyle = r[2] > 0 ? c.win : r[2] < 0 ? c.loss : c.dim; ctx.textAlign = "right"; ctx.fillText(r[1], PANEL_W - 2, y + 9); ctx.textAlign = "left"; if (r[3]) { ctx.font = `${7}px "DejaVu Sans", Verdana, sans-serif`; ctx.fillStyle = c.dim; ctx.fillText(r[3], 3, y + 18); } }); } else if (page.type === "results" || page.type === "scores") { const y0 = top - scrollY(rowsOf(page)); D.results.forEach((r, i) => { const y = y0 + i * 18; ctx.font = font(8); ctx.fillStyle = c.text; ctx.fillText(r[0], 2, y + 8); ctx.fillStyle = r[2] > 0 ? c.win : r[2] < 0 ? c.loss : c.dim; ctx.fillText(r[1], 2, y + 17); }); } else if (page.type === "reactions") { const n = Math.min(page.limit || 8, D.reactions.length); const y0 = top - scrollY(n * 10); D.reactions.slice(0, n).forEach((r, i) => { ctx.font = font(8); ctx.fillStyle = c.text; ctx.fillText(r, 2, y0 + i * 10 + 8); }); } else if (page.type === "text" || page.type === "clock") { const lines = page.type === "clock" ? [DC.strftime(page.format, tzNow(page.tz, now))] : (page.lines && page.lines.length ? page.lines : String(page.text || "").split("\n")).map((l) => DC.renderTemplate(l, f)); const size = page.size || (page.type === "clock" ? 16 : Math.max(8, Math.min(16, Math.floor(mh / Math.max(1, lines.length)) - 2))); ctx.font = font(size); ctx.fillStyle = c.text; const align = page.align || "center"; ctx.textAlign = align === "left" ? "left" : align === "right" ? "right" : "center"; const x = align === "left" ? 2 : align === "right" ? PANEL_W - 2 : PANEL_W / 2; const lh = size + 2, total = lh * lines.length; lines.forEach((ln, i) => ctx.fillText(ln, x, top + (mh - total) / 2 + lh * i + size)); ctx.textAlign = "left"; } else if (page.type === "marquee") { const size = Math.min(24, page.size || 16); const txt = DC.renderTemplate(page.text || page.title || "", f); ctx.font = font(size); ctx.fillStyle = c.text; const x = PANEL_W - elapsed * DC.scrollPxPerSec(page.speed); ctx.fillText(txt, x, top + (mh + size) / 2 - 2); } else if (page.type === "live_games") { // placeholder: the board draws the live games itself (agent 2.0.54); the preview only marks the page ctx.font = font(9); ctx.fillStyle = c.dim; ctx.textAlign = "center"; ctx.fillText("LIVE GAMES", PANEL_W / 2, top + mh / 2 + 3); ctx.textAlign = "left"; } else if (page.type === "image") { const img = imageCache(page.image); if (img && img.complete && img.naturalWidth) { const k = Math.min(PANEL_W / img.naturalWidth, mh / img.naturalHeight); const w = img.naturalWidth * k, h = img.naturalHeight * k; ctx.drawImage(img, (PANEL_W - w) / 2, top + (mh - h) / 2, w, h); } else { ctx.font = font(8); ctx.fillStyle = c.dim; ctx.fillText("(no picture)", 30, top + mh / 2); } } ctx.restore(); // title bar (layout title_position / ticker_on_top); header.content: both / title / page if (hOn) { const hy = bands.title.y0; ctx.fillStyle = c.header_bg; ctx.fillRect(0, hy, PANEL_W, HEADER_H); ctx.font = font(9); const content = hdr.content || (hdr.show_page_title !== false ? "both" : "title"); const brandTxt = DC.headerBrand(spec); const brand = content === "page" ? "" : brandTxt; const title = content === "title" ? "" : (page.title || page.type.toUpperCase()); const tw = title ? ctx.measureText(title).width : 0; if (brand) { ctx.save(); ctx.beginPath(); ctx.rect(0, hy, PANEL_W - tw - 6, HEADER_H); ctx.clip(); ctx.fillStyle = c.accent; ctx.fillText(brand, 2, hy + 9); ctx.restore(); if (title) { ctx.fillStyle = c.page_title; ctx.fillText(title, PANEL_W - tw - 3, hy + 9); } } else if (title) { ctx.fillStyle = c.page_title; ctx.textAlign = "center"; ctx.fillText(title, PANEL_W / 2, hy + 9); ctx.textAlign = "left"; } ctx.fillStyle = c.separator; ctx.fillRect(0, bands.title.div, PANEL_W, 1); } // ticker (layout ticker_position / ticker_on_top); colors.footer_* follow it if (fOn) { const fy = bands.ticker.y0, fdiv = bands.ticker.div; ctx.fillStyle = c.footer_bg; ctx.fillRect(0, Math.min(fy, fdiv), PANEL_W, FOOTER_H); ctx.fillStyle = c.footer_separator || c.separator; ctx.fillRect(0, fdiv, PANEL_W, 1); ctx.font = font(8); const txt = ftr.text ? DC.renderTemplate(ftr.text, fieldsFor(spec, null, now)) : [ftr.reactions !== false ? (D.reactions || [])[0] || "" : "", ftr.movers !== false ? (D.movers || []).join(" ") : "", "BLACKBOOK"].filter(Boolean).join(" • "); const w = ctx.measureText(txt + " ").width; const mx = (t * DC.scrollPxPerSec(ftr.speed || 12)) % w; ctx.fillStyle = c.footer_text || c.accent; const by = fy + 8; ctx.fillText(txt, -mx, by); ctx.fillText(txt, -mx + w, by); } return { page, index, label: `Page ${index + 1}/${pages.length}: ${page.title || DC.PAGE_LABELS[page.type]}` }; } const USER_COLORS = ["#78d2ff", "#ff9ad5", "#a0ff8c", "#ffcd5a", "#c8a0ff", "#5affe6", "#ff8c5a", "#e6e6ff"]; const MOCK_BOARD = { balances: [{ handle: "ROOSRER", balance: 420, today_pnl: 45, ytd_pnl: 1100 }, { handle: "BILL", balance: -85, today_pnl: -20, ytd_pnl: -300 }, { handle: "JAY", balance: 1200, today_pnl: 0, ytd_pnl: 2000 }], trend: { days: ["d1", "d2", "d3", "d4", "d5"], pnl: { ROOSRER: [20, -40, 60, 0, 45], BILL: [-10, 30, -50, 0, -20], JAY: [100, 0, -80, 200, 0] } } }; /** Score/final popup like the Pi (2.0.6): header with both teams + score, then the bettors 3 full rows a * page, each page held popups.page_seconds, with a 1/N indicator. */ function drawGamePopup(ctx, pop, theme, font, now) { const W = PANEL_W, band = pop.final ? theme.win : theme.accent; ctx.fillStyle = "#040814"; ctx.fillRect(0, 0, W, PANEL_H); ctx.fillStyle = band; ctx.fillRect(0, 0, W, 10); const n = DC.popupPages(pop.bettors.length), per = DC.POPUP_ROWS_PER_PAGE; const pg = n > 1 ? Math.floor((now.getTime() - pop.t0) / 1000 / (pop.pageSeconds || 3)) % n : 0; ctx.font = font(7); ctx.fillStyle = "#060a18"; ctx.textAlign = "center"; ctx.fillText(`${pop.final ? "FINAL" : "SCORE"} ${pop.league || ""}`, W / 2, 8); if (n > 1) { ctx.textAlign = "right"; ctx.fillText(`${pg + 1}/${n}`, W - 2, 8); } ctx.textAlign = "left"; [[pop.away, pop.as, pop.hs], [pop.home, pop.hs, pop.as]].forEach(([name, sc, other], k) => { const y = 11 + k * 11, lead = sc > other; ctx.font = font(8); ctx.fillStyle = lead || sc === other ? theme.text : theme.dim; ctx.fillText(name, 3, y + 8); ctx.font = font(9); ctx.fillStyle = lead ? theme.win : theme.text; ctx.textAlign = "right"; ctx.fillText(String(sc), W - 2, y + 9); ctx.textAlign = "left"; }); ctx.fillStyle = band; ctx.fillRect(0, 33, W, 1); pop.bettors.slice(pg * per, pg * per + per).forEach((b, k) => { const y = 35 + k * 9; ctx.font = font(8); ctx.fillStyle = USER_COLORS[(pg * per + k) % USER_COLORS.length]; ctx.fillText(b[0], 1, y + 8); ctx.fillStyle = b[2] > 0 ? theme.win : b[2] < 0 ? theme.loss : theme.pending; ctx.textAlign = "right"; ctx.fillText(b[1], W - 1, y + 8); ctx.textAlign = "left"; }); return { page: null, label: n > 1 ? `Popup preview · page ${pg + 1}/${n}` : "Popup preview" }; } const _imgs = {}; function imageCache(src) { if (!src || !/^data:image\//.test(src)) return null; if (!_imgs[src]) { const im = new Image(); im.src = src; _imgs[src] = im; } return _imgs[src]; } function tzNow(tz, now) { if (!tz) return now; try { return new Date(now.toLocaleString("en-US", { timeZone: tz })); } catch (e) { return now; } } function fieldsFor(spec, page, now) { const user = (window.__dcHandle || "you"); return { user, user_upper: user.toUpperCase(), group: "", date: DC.strftime("%-m/%-d", now), time: DC.strftime("%-I:%M %p", now), title: (page && page.title) || "" }; } // One save per burst of edits: each change restarts this timer (slider drags, typing, colour picks). // The live preview draws the local draft, so it stays instant; revision numbers stay internal. const SAVE_DEBOUNCE_MS = 800; /** The whole wall: one panel draws straight onto the canvas (unchanged); a multi-panel wall draws the logical screen * off-screen, scales it up (nearest, centred, like panels.to_wall) and marks the seams + each panel's chain number. */ let _wallBuf = null; function drawWall(ctx, spec, defaults, t, now, popup, data) { const sz = DC.panelsLogical(spec); if (sz.single) return drawPanel(ctx, spec, defaults, t, now, popup, data); if (typeof document === "undefined") return drawPanel(ctx, spec, defaults, t, now, popup, data); if (!_wallBuf) _wallBuf = document.createElement("canvas"); if (_wallBuf.width !== sz.w || _wallBuf.height !== sz.h) { _wallBuf.width = sz.w; _wallBuf.height = sz.h; } const r = drawPanel(_wallBuf.getContext("2d"), spec, defaults, t, now, popup, data); const p = DC.panelsOf(spec); ctx.imageSmoothingEnabled = false; ctx.fillStyle = "#000"; ctx.fillRect(0, 0, sz.wallW, sz.wallH); const dw = sz.w * sz.scale, dh = sz.h * sz.scale; ctx.drawImage(_wallBuf, Math.floor((sz.wallW - dw) / 2), Math.floor((sz.wallH - dh) / 2), dw, dh); ctx.fillStyle = "rgba(255,255,255,0.35)"; for (let c = 1; c < p.wide; c++) ctx.fillRect(c * 128, 0, 1, sz.wallH); for (let rr = 1; rr < p.tall; rr++) ctx.fillRect(0, rr * 64, sz.wallW, 1); ctx.font = 'bold 8px "DejaVu Sans", Verdana, sans-serif'; ctx.textAlign = "left"; for (const m of p.map) { const tag = `#${m.chain_index}${m.rotate === 180 ? " ↻" : ""}`; ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(m.col * 128 + 1, m.row * 64 + 1, 6 + tag.length * 5, 10); ctx.fillStyle = "#ffd75a"; ctx.fillText(tag, m.col * 128 + 3, m.row * 64 + 9); } return Object.assign({}, r, { label: `${p.wide}×${p.tall} wall · ${r.label}` }); } // The preview is the REAL panel renderer: POST /display/v2/preview runs display_renderer on the server with // this draft + the signed-in user's live data and answers a few seconds of animation (WebP). Clips chain via // `next` so it plays continuously; a spec edit re-renders (debounced). The JS drawing below is only the // fallback when the server preview fails, and the box keeps the wall's size either way (no layout jumps). function preloadImage(src) { return new Promise((res, rej) => { const im = new Image(); im.onload = () => (im.decode ? im.decode().catch(() => {}) : Promise.resolve()).then(() => res(src)); im.onerror = () => rej(new Error("preview image failed")); im.src = src; }); } function PanelPreview({ spec, defaults, popupAt, live, api }) { const ref = React.useRef(null); const [label, setLabel] = React.useState(""); const [clip, setClip] = React.useState(null); // last server clip on screen const [mode, setMode] = React.useState(api ? "loading" : "fallback"); // loading | server | fallback const [panelType, setPanelType] = React.useState(() => DC.previewPanelType(localStorage.getItem("bb_dc_preview_panel"))); const specRef = React.useRef(spec); specRef.current = spec; const popRef = React.useRef(popupAt); popRef.current = popupAt; const dataRef = React.useRef(null); dataRef.current = live && live.data; const modeRef = React.useRef(mode); modeRef.current = mode; const clipRef = React.useRef(null); const genRef = React.useRef(0); const firstRef = React.useRef(true); const specKey = React.useMemo(() => JSON.stringify(spec || null), [spec]); React.useEffect(() => { localStorage.setItem("bb_dc_preview_panel", panelType); }, [panelType]); // server clips: debounced on spec / panel type changes, then chained clip after clip React.useEffect(() => { if (!api || !spec) return undefined; const gen = ++genRef.current; const live_ = () => gen === genRef.current; let timer = null; const later = (fn, ms) => { clearTimeout(timer); timer = setTimeout(fn, ms); }; const fetchClip = async (page, t0) => { let res; try { res = await api("POST", "/display/v2/preview", DC.previewRequest(specRef.current, panelType, page, t0)); } catch (e) { e.status = 0; throw e; } const { ok, status, j } = res; if (!ok || !j || !j.ok || !j.image) { const e = new Error((j && j.error) || "preview failed"); e.status = status; throw e; } await preloadImage(j.image); return j; }; let fails = 0; const play = async (pending, page, t0) => { let c; try { c = await pending; } catch (e) { if (!live_()) return; // 0 = network / 5xx / 429 = the API is restarting (a deploy) or busy: retry soon with backoff, keep the // last real clip looping meanwhile. Anything else (422 bad draft, 401) = the JS drawing until the next edit. const transient = !e.status || e.status >= 500 || e.status === 429; fails += 1; if (!transient) { clipRef.current = null; setClip(null); } if (!clipRef.current) setMode("fallback"); if (transient) later(() => play(fetchClip(page, t0), page, t0), DC.previewRetryMs(fails)); return; } fails = 0; if (!live_()) return; clipRef.current = c; setClip(c); setMode("server"); if (c.blank || !c.next) { later(() => play(fetchClip(page, 0), page, 0), 30000); return; } const nx = c.next, pre = fetchClip(nx.page, nx.t0); pre.catch(() => {}); const advance = () => { if (!live_()) return; if (document.hidden) { later(advance, 1000); return; } play(pre, nx.page, nx.t0); }; later(advance, Math.max(500, c.duration_ms || DC.PREVIEW_SECONDS * 1000)); }; const onVis = () => { if (!document.hidden && modeRef.current === "fallback" && live_()) { fails = 0; later(() => play(fetchClip(0, 0), 0, 0), 0); } }; document.addEventListener("visibilitychange", onVis); const startPage = clipRef.current && !clipRef.current.blank ? clipRef.current.page || 0 : 0; const delay = firstRef.current ? 0 : DC.PREVIEW_DEBOUNCE_MS; firstRef.current = false; later(() => play(fetchClip(startPage, 0), startPage, 0), delay); return () => { clearTimeout(timer); genRef.current++; document.removeEventListener("visibilitychange", onVis); }; }, [api, specKey, panelType]); // JS fallback drawing (also used for the test-alert popup, which the server clip doesn't include) React.useEffect(() => { let raf, t0 = performance.now(), lastLabel = ""; const loop = (ts) => { const pop = popRef.current; const showPop = pop && pop.until > Date.now() ? pop : null; const ctx = (modeRef.current !== "server" || showPop) && ref.current && ref.current.getContext("2d"); if (ctx) { const r = drawWall(ctx, specRef.current, defaults, (ts - t0) / 1000, new Date(), showPop, dataRef.current); if (modeRef.current !== "server" && r.label !== lastLabel) { lastLabel = r.label; setLabel(r.label); } } if (modeRef.current === "server") lastLabel = ""; raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf); }, [defaults]); const wall = DC.panelsLogical(spec); const box = DC.previewWall(spec, panelType); // the selected panel's wall in LED pixels: the box's aspect const pop = popupAt && popupAt.until > Date.now(); const showImg = mode === "server" && !!clip && !pop; const shownLabel = mode === "server" ? DC.previewLabel(spec, clip) : label; const fill = { position: "absolute", inset: 0, width: "100%", height: "100%" }; return (
{clip && {showImg}
{mode === "server" ? <>Real panel renderer · your live data : mode === "loading" ? "Rendering on the server…" : live && live.data ? <>Approximate preview · your live data{live.at ? ` · ${live.at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}` : ""} : live && live.err ? "Approximate preview · sample data" : "Approximate preview · sample data (loading live…)"} {shownLabel}
{api &&
{DC.PREVIEW_PANEL_TYPES.map(([v, l]) => ( ))}
}
); } // ------------------------------------------------------------------ small controls function Toggle({ label, sub, on, onChange }) { return ( ); } function Segmented({ label, value, options, onChange, disabled }) { // options: [[value, label]]; disabled(value) -> true greys that choice out return (
{label}
{options.map(([v, l]) => { const off = !!(disabled && disabled(v)); return ; })}
); } // "Pause on each line": Off / 1s / 2s / 3s / 5s (plus "Use display setting" on a page). Greyed out with // "(needs )" while a linked display is on an older agent (same rule as the theme picker). function LinePauseControl({ label, value, onChange, devs, inherit, testid }) { const sup = DC.linePauseSupport(devs); const opts = (inherit ? [[null, inherit]] : []).concat(DC.LINE_PAUSE_CHOICES); return (
{label}{sup.ok ? "" : (needs {sup.min})}
{opts.map(([v, l]) => { const on = value === v, off = !sup.ok; return ; })}
); } function Slider({ label, value, min, max, step, onChange, fmt }) { return ( ); } function ColorField({ label, value, onChange, onClear }) { const v = /^#[0-9a-fA-F]{6}$/.test(value || "") ? value : "#000000"; return (
onChange(e.target.value)} aria-label={label} /> {label}{onClear && value ? : null}
); } function TextField({ label, value, onChange, max, placeholder }) { return ( ); } // 2.0.52 per-page "Animate" (+ optional style). Shows the effective value while the page has no key (graph pages // follow the display's animate_graphs, others are off); writes page.animate / page.animate_style only when touched. // Greyed out with "(needs PAGE_ANIM_MIN)" while a linked display is on an older agent (same rule as line pause). function PageAnimateControl({ spec, page, i, edit, devs }) { const sup = DC.pageAnimSupport(devs); const { on, set } = DC.pageAnimateOf(spec, page); const style = DC.pageAnimStyleOf(page); const off = !sup.ok; const sub = (set ? "" : "Default for this page · ") + (DC.isGraphPage(page) ? "the graph draws in before its hold" : "plays an entrance as the page appears"); return (
{on && ( )} {off &&
Page animations: coming in the next display update ({sup.min}){sup.old.length ? ` · ${sup.old.map((d) => `${d.name} is on ${d.release_version || "an older version"}`).join(", ")}` : ""}
}
); } // spec.team_accents (agent 2.0.54): team-colour stripes + dividers on any theme. Unset = off and nothing is written // until the user flips it; the team_colors background implies it (shown ON, locked). Greyed out until every linked // display is on 2.0.54 and the server schema lists the key (unticking a saved true while gated removes the key). function TeamAccentsControl({ spec, edit, devs, schema }) { const sup = DC.teamAccentsSupport(devs, schema); const { on, set, implied } = DC.teamAccentsOf(spec); const locked = implied || (!sup.ok && !on); const sub = implied ? "Always on with the Team colors background" : "Game header stripes and divider lines in the teams' colors"; return (
{!sup.ok && !implied &&
Coming in the next display update ({sup.min}){!sup.schema ? " · the server doesn't accept it yet" : ""}{sup.old.length ? ` · ${sup.old.map((d) => `${d.name} is on ${d.release_version || "an older version"}`).join(", ")}` : ""}
}
); } // ------------------------------------------------------------------ page editor function PageEditor({ page, i, n, edit, open, setOpen, devs, linePause, spec, schema }) { const set = (k, v) => edit((s) => DC.setIn(s, ["pages", i, k], v)); const t = page.type; const scrolls = ["wagers", "ticker", "balances", "results", "scores", "reactions", "marquee", "trend", "pnl", "text", "scroll_text", "balance_graph", "record_graph", "live_games"].indexOf(t) >= 0; const onImage = (file) => { if (!file) return; const fr = new FileReader(); fr.onload = () => { const im = new Image(); im.onload = () => { const cv = document.createElement("canvas"); const k = Math.min(128 / im.width, 40 / im.height, 1); cv.width = Math.max(1, Math.round(im.width * k)); cv.height = Math.max(1, Math.round(im.height * k)); cv.getContext("2d").drawImage(im, 0, 0, cv.width, cv.height); let url = cv.toDataURL("image/png"); if (url.length > 60000) url = cv.toDataURL("image/jpeg", 0.8); if (url.length > 60000) { alert("That picture is too big; try a smaller one."); return; } set("image", url); }; im.src = fr.result; }; fr.readAsDataURL(file); }; return (
setOpen(open ? null : i)}>{i + 1}. {page.title || DC.PAGE_LABELS[t]} · {DC.PAGE_LABELS[t]} · {page.hold || 2.2}s
{open && (
set("enabled", v ? undefined : false)} /> set("title", v || undefined)} /> `${v}s`} onChange={(v) => set("hold", v)} /> {scrolls && set("speed", v)} />} {t === "text" && ( <>