1// Generated by scripts/sync-session-dashboard.mjs. Do not edit. 2(function(){ 3// Session dashboard charts: shared by React and the sandbox/offline runtime. 4// No browser or framework dependencies. The platform's chart defaults are separate. 5const THEMES = { 6 signal: ['#FF4B5E', '#FF3F64', '#FF8367'], tide: ['#2E9BFF', '#0C6CD8', '#69C1FF'], 7 mint: ['#1FBF6B', '#0FA85A', '#6CE0A0'], iris: ['#8B6CF5', '#6A4DF0', '#C08CFF'], 8 amber: ['#FFB02E', '#FF9500', '#FFD466'], 9} 10const TYPES = ['line', 'area', 'sparkline', 'combo', 'bar', 'stacked-bar', 'pie', 'donut', 'ring', 'concentric', 'gauge', 'heatmap', 'scatter', 'radar', 'range', 'boxplot', 'before-after', 'network', 'bubble', 'sankey', 'funnel', 'geo'] 11const fail = message => { throw new Error(message) } 12const finite = n => typeof n === 'number' && Number.isFinite(n) 13const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) 14const fade = (hex, opacity) => `rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},${opacity})` 15const UI = 'Session Dashboard Inter, Inter, system-ui, sans-serif', MONO = 'Session Dashboard JetBrains Mono, JetBrains Mono, monospace' 16const DIRS = { down: { x: 0, y: 0, x2: 0, y2: 1 }, up: { x: 0, y: 1, x2: 0, y2: 0 }, right: { x: 0, y: 0, x2: 1, y2: 0 } } 17const gradient = (a, b, along = 'down') => ({ type: 'linear', ...DIRS[along], colorStops: [{ offset: 0, color: a }, { offset: 1, color: b }] }) 18 19function dashboardTheme(canvas = 'light', theme = 'signal') { 20 const dark = canvas === 'dark', [accent, accentA, accentB] = THEMES[theme] || THEMES.signal 21 return { 22 dark, accent, accentA, accentB, accentSoft: fade(accent, .14), 23 palette: ['#6A6EF3', '#22C04A', '#FF5668', '#69C1FF', '#F0A35E', '#FA78F0', '#FCCC00', '#D7F205'], 24 seq: ['#BFE3FF', '#8FCBFF', '#69C1FF', '#2E9BFF', '#0C6CD8', '#08498F'], 25 pos: '#22C04A', neg: '#FF4B5E', warn: '#FCCC00', info: '#1E90FF', neutral: '#8A8C92', 26 surface: dark ? '#0B0C0E' : '#FFFFFF', surface2: dark ? '#141517' : '#F4F6F9', surface3: dark ? '#1B1D20' : '#ECEFF3', 27 ink: dark ? '#FFFFFF' : '#0E1620', inkMuted: dark ? '#9A9CA3' : '#5C6470', muted: dark ? '#9A9CA3' : '#5C6470', 28 dim: dark ? '#9A9CA3' : '#5C6470', grid: dark ? 'rgba(255,255,255,.14)' : 'rgba(16,22,30,.12)', 29 tooltip: { backgroundColor: '#23272B', borderWidth: 0, padding: [8, 12], textStyle: { color: '#F
29FFFFF', fontFamily: MONO, fontSize: 12 }, extraCssText: 'border-radius:9px;box-shadow:0 8px 24px rgba(0,0,0,.30)' }, 30 } 31} 32 33// The HTML contract extends existing data-chart specs; doc/deck ChartSpec stays intact. 34function validateDashboardChart(s) { 35 if (!s || !TYPES.includes(s.type)) fail('Unsupported dashboard chart type') 36 for (const key of ['title', 'unit']) if (s[key] !== undefined && (typeof s[key] !== 'string' || s[key].length > 500)) fail(`Invalid ${key}`) 37 for (const key of ['x', 'y', 'series', 'items', 'nodes', 'links', 'points']) { 38 if (s[key] !== undefined && (!Array.isArray(s[key]) || s[key].length > 500)) fail(`Invalid or oversized ${key}`) 39 } 40 const series = s.series || [], items = s.items || [] 41 if ([...series, ...items, ...(s.points || []), ...(s.links || []), ...(s.nodes || [])].some(it => !it || typeof it !== 'object' || Array.isArray(it))) fail('Invalid data entry') 42 if (s.x?.some(v => typeof v !== 'string') || s.y?.some(v => typeof v !== 'string')) fail('Category labels must be text') 43 if (s.type === 'heatmap' && s.y && s.y.length !== series.length) fail('Heatmap labels must match rows') 44 if (s.type === 'radar' && (s.x?.length || 0) < 3) fail('Radar requires at least three categories') 45 if (series.length > 20) fail('Use at most 20 series') 46 if (['ring', 'gauge'].includes(s.type)) { 47 if (!finite(s.value) || !finite(s.max ?? 100) || (s.max ?? 100) <= 0 || s.value < 0 || s.value > (s.max ?? 100)) fail('Value must be between zero and max') 48 } else if (['pie', 'donut', 'funnel', 'concentric'].includes(s.type)) { 49 if (!series.length) fail('No data available') 50 if (!series.every(it => finite(it.value) && it.value >= 0)) fail('Values must be non-negative numbers') 51 if (s.type === 'concentric' && !series.every(it => finite(it.max ?? 100) && (it.max ?? 100) > 0 && it.value <= (it.max ?? 100))) fail('Ring values must be between zero and max') 52 } else if (['network', 'sankey'].includes(s.type)) { 53 if (!s.links?.length) fail('No data available') 54 if (!s.links.every(l => typeof l.source === 'string' && l.source && typeof l.target === 'string' && l.target && finite(l.value) && l.value >= 0)) fail('Links require source, target and non-negative value') 55 const names = new Set(s.nodes?.map(n => n.name) || s.links.flatMap(l => [l.source, l.target])) 56 if ([...names].some(n => typeof n !== 'string' || !n) || (s.nodes && names.size !== s.nodes.length)) fail('Node names must be unique non-empty text') 57 if (names.size > 500 || s.links.some(l => !names.has(l.source) || !names.has(l.target))) fail('Link references an unknown node') 58 if (s.type === 'sankey') { 59 const edges = new Map([...names].map(n => [n, []])), visited = new Set(), pending = new Set() 60 s.links.forEach(l => edges.get(l.source).push(l.target)) 61 const visit = n => { if (pending.has(n)) fail('Sankey links must not contain cycles'); if (visited.has(n)) return; pending.add(n); edges.get(n).forEach(visit); pending.delete(n); visited.add(n) } 62 names.forEach(visit) 63 } 64 } else if (['range', 'boxplot', 'before-after'].includes(s.type)) { 65 if (!items.length) fail('No data available') 66 for (const it of items) { 67 if (s.type === 'range' && (!finite(it.low) || !finite(it.high) || it.low > it.high)) fail('Range requires low <= high') 68 if (s.type === 'before-after' && (!finite(it.before) || !finite(it.after))) fail('Before/after requires numeric values') 69 if (s.type === 'boxplot' && (!Array.isArray(it.values) || !it.values.length || it.values.length > 500 || !it.values.every(finite))) fail('Box plot requires numeric observations') 70 } 71 } else if (['geo', 'bubble'].includes(s.type)) { 72 if (!s.points?.length) fail('No data available') 73 if (!s.points.every(p => finite(p.value) && p.value >= 0 && (s.type === 'bubble' || (finite(p.lng) && finite(p.lat) && Math.abs(p.lng) <= 180 && Math.abs(p.lat) <= 90)))) fail('Invalid point value or longitude/latitude') 74 } else { 75 if (!series.length || !series.some(it => Array.isArray(it.data) && it.data.some(v =>
75 s.type === 'scatter' ? Array.isArray(v) : finite(v)))) fail('No data available') 76 for (const it of series) { 77 if (!Array.isArray(it.data) || it.data.length > 500) fail('Invalid series data') 78 if (s.type === 'scatter') { 79 if (!it.data.every(p => Array.isArray(p) && p.length === 2 && p.every(finite))) fail('Scatter requires numeric x/y pairs') 80 } else { 81 if (!s.x?.length || it.data.length !== s.x.length || !it.data.every(v => v === null || finite(v))) fail('Series values must match categories') 82 if (s.type === 'combo' && !['line', 'bar'].includes(it.type)) fail('Combo series requires type line or bar') 83 } 84 } 85 } 86 return s 87} 88 89function buildDashboardOption(spec, t = dashboardTheme()) { 90 const s = validateDashboardChart(spec), type = s.type, series = s.series || [], unit = s.unit ? ` ${s.unit}` : '' 91 const tone = v => ({ pos: t.pos, neg: t.neg, warn: t.warn, info: t.info, neutral: t.neutral, accent: t.accent })[v] 92 const ink = { color: t.inkMuted, fontFamily: MONO, fontSize: 10 } 93 // Inter for words, JetBrains Mono for numbers (the reference rule). 94 const axis = (categories) => ({ type: categories ? 'category' : 'value', ...(categories ? { data: categories } : {}), axisLabel: { ...(categories ? { ...ink, fontFamily: UI, fontSize: 11 } : ink), hideOverlap: true }, axisLine: { show: !!categories, lineStyle: { color: t.grid } }, axisTick: { show: false }, splitLine: { show: !categories, lineStyle: { color: t.grid, type: 'dashed' } } }) 95 const base = { 96 color: t.palette, backgroundColor: 'transparent', animation: false, 97 textStyle: { fontFamily: UI, color: t.ink }, 98 title: s.title ? { text: String(s.title), left: 0, top: 0, textStyle: { fontSize: 13, fontWeight: 600, color: t.inkMuted } } : undefined, 99 tooltip: { ...t.tooltip, trigger: 'item', valueFormatter: v => `${v ?? 'â'}${unit}`, confine: true }, 100 legend: { show: series.length > 1, bottom: 0, icon: 'circle', itemWidth: 9, itemHeight: 9, textStyle: ink }, 101 grid: { left: 8, right: 16, top: s.title ? 42 : 18, bottom: series.length > 1 ? 32 : 8, containLabel: true }, 102 } 103 if (['line', 'area', 'sparkline', 'combo', 'bar', 'stacked-bar'].includes(type)) { 104 const horizontal = s.orientation === 'horizontal', spark = type === 'sparkline' 105 base.xAxis = axis(horizontal ? undefined : s.x); base.yAxis = axis(horizontal ? s.x : undefined) 106 base.tooltip.trigger = 'axis' 107 const along = horizontal ? 'right' : 'up', suffix = s.unit && s.unit.length <= 3 ? s.unit : '' 108 if (horizontal) { base.yAxis.inverse = true; base.yAxis.axisLabel.width = 120; base.yAxis.axisLabel.overflow = 'truncate' } 109 base.series = series.map((it, i) => { 110 const bar = ['bar', 'stacked-bar'].includes(type) || (type === 'combo' && it.type === 'bar') 111 const col = tone(it.tone) || (series.length === 1 ? t.accent : t.palette[i % 8]) 112 const data = it.data.map((v, j) => bar && s.highlight !== undefined ? { value: v, itemStyle: { color: j === s.highlight ? col : t.surface3 } } : v) 113 // Lone category series: accent gradient along the bar + value labels; ramp/highlight/tone stay opt-in. 114 const plain = bar && series.length === 1 && type !== 'stacked-bar' && !tone(it.tone) && s.highlight === undefined && s.variant !== 'ramp' 115 if (plain && it.data.length <= 12 && !spark) (horizontal ? base.xAxis : base.yAxis).boundaryGap = [0, horizontal ? '18%' : '15%'] 116 return { name: it.name, type: bar ? 'bar' : 'line', data, stack: type === 'stacked-bar' ? 'total' : undefined, 117 barMaxWidth: 48, showSymbol: it.data.length === 1, symbolSize: 7, connectNulls: false, 118 label: plain && it.data.length <= 12 && !spark ? { show: true, position: horizontal ? 'right' : 'top', fontFamily: MONO, fontSize: 11, color: t.ink, formatter: p => `${p.value}${suffix}` } : undefined, 119 lineStyle: { width: 2.4, color: col }, itemStyle: { color: bar && s.variant === 'ramp' ? gradient(t.info, t.pos, along) : plain ? gradient(t.accentA, t.accentB, along) : col, borderRadius: bar ? (horizontal ? [0, 5, 5, 0] : [5, 5, 0, 0]) : undefined }, 120 areaStyle: ['area', 'sparkline'].includes(type) ? { color: gradient(fade(col, .28), fade(col, 0)) } : undefined, 121 markPoint: s.peak && !bar ? { data: [{ type: 'max' }], symbol: 'circle', symbolSize: 8, label: { show: true, position: 'top', color: '#F
121FFFFF', backgroundColor: '#23272B', borderRadius: 6, padding: [5, 8], formatter: p => `${p.value}${unit}` } } : undefined, 122 } 123 }) 124 if (spark) { base.xAxis.show = false; base.yAxis.show = false; base.grid = { left: 0, right: 0, top: 4, bottom: 4 }; base.legend.show = false; base.title = undefined } 125 } else if (['pie', 'donut', 'funnel'].includes(type)) { 126 const total = series.reduce((sum, it) => sum + it.value, 0) 127 base.series = [{ type: type === 'funnel' ? 'funnel' : 'pie', radius: type === 'donut' ? ['58%', '82%'] : '75%', center: ['50%', '48%'], top: s.title ? 40 : 10, bottom: 40, sort: 'none', gap: 4, 128 data: series.map((it, i) => ({ name: it.name, value: it.value, itemStyle: { color: tone(it.tone) || t.palette[i % 8] } })), 129 itemStyle: { borderWidth: 2, borderColor: t.surface, borderRadius: 4 }, label: { show: type !== 'donut', color: t.inkMuted, formatter: '{b}: {c}' } }] 130 base.legend.show = true 131 if (type === 'donut') { 132 // Percent shares sum to 100 â meaningless as a centre; show the leading share instead (reference heroDonut: number + caption). 133 const top = series.reduce((a, b) => b.value > a.value ? b : a), pct = s.unit === '%' 134 base.graphic = [ 135 { type: 'text', left: 'center', top: s.title ? '40%' : '36%', style: { text: pct ? `${top.value}%` : String(total), fill: t.ink, fontSize: 28, fontWeight: 800, fontFamily: MONO } }, 136 { type: 'text', left: 'center', top: s.title ? '53%' : '49%', style: { text: pct ? top.name : 'total', fill: t.inkMuted, fontSize: 12, fontFamily: UI } }, 137 ] 138 } 139 } else if (['ring', 'concentric', 'gauge'].includes(type)) { 140 const rings = type === 'concentric' ? series : [{ name: s.title, value: s.value, max: s.max }] 141 base.title = undefined; base.legend.show = false 142 base.series = rings.map((it, i) => ({ type: 'gauge', min: 0, max: it.max ?? 100, radius: `${Math.max(15, 88 - i * (65 / rings.length))}%`, 143 startAngle: type === 'gauge' ? 180 : 90, endAngle: type === 'gauge' ? 0 : -270, 144 axisLine: { roundCap: true, lineStyle: { width: type === 'gauge' ? 16 : 8, color: type === 'gauge' ? [[.33, t.pos], [.66, t.warn], [1, t.neg]] : [[1, t.surface3]] } }, 145 progress: { show: type !== 'gauge', roundCap: true, width: 8, itemStyle: { color: tone(it.tone) || (rings.length === 1 ? t.accent : t.palette[i % 8]) } }, 146 pointer: { show: type === 'gauge', length: '65%', width: 4, itemStyle: { color: t.ink } }, axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, 147 detail: { show: rings.length === 1, color: t.ink, fontSize: 24, offsetCenter: [0, type === 'gauge' ? '30%' : '0%'], formatter: `{value}${unit}` }, 148 title: { show: rings.length === 1, color: t.inkMuted, fontSize: 12, offsetCenter: [0, '60%'] }, data: [{ value: it.value, name: it.name }], 149 })) 150 } else if (type === 'heatmap') { 151 const values = series.flatMap(it => it.data).filter(finite) 152 base.xAxis = axis(s.x); base.yAxis = axis(s.y || series.map(it => it.name)); base.legend.show = false; base.grid.bottom = 46 153 base.visualMap = { min: Math.min(0, ...values), max: Math.max(1, ...values), orient: 'horizontal', left: 'center', bottom: 0, inRange: { color: t.seq }, textStyle: ink } 154 base.series = [{ type: 'heatmap', data: series.flatMap((it, y) => it.data.flatMap((v, x) => v === null ? [] : [[x, y, v]])), itemStyle: { borderColor: t.surface, borderWidth: 2, borderRadius: 3 }, label: { show: false } }] 155 } else if (type === 'scatter') { 156 base.xAxis = axis(); base.yAxis = axis(); base.series = series.map(it => ({ ...it, type: 'scatter', symbolSize: 10, itemStyle: { opacity: .85, borderColor: t.surface, borderWidth: 1 } })) 157 } else if (type === 'radar') { 158 base.radar = { indicator: s.x.map((name, i) => ({ name, min: Math.min(0, ...series.map(it => it.data[i] ?? 0)), max: Math.max(1, ...series.map(it => it.data[i] ?? 0)) * 1.2 })), axisName: ink, splitLine: { lineStyle: { color: t.grid } }, splitArea: { show: false } } 159 base.series = [{ type: 'radar', data: series.map(it => ({ name: it.name, value: it.data })), areaStyle: { opacity: .12 } }] 160 } else if (['range', 'before-after', 'boxplot'].includes(type)) { 161 base.xAxis = axis(s.items.map(it => it.label)); base.yAxis = axis(); base.tooltip.trigger = 'axis' 162 if (type === 'range') { 163 // Custom rectangles handle negative and zero-crossing intervals correctly. 164 base.series = [{ type: 'custom', encode: { x: 0, y: [1, 2] }, data: s.items.map((it, i) => [i, it.low, it.high]), renderItem: (_params, api) => { const a = api.coord([api.value(0), api.value(1)]), b = api.coord([api.value(0), api.value(2)]); return { type: 'rect', shape: { x: a[0] - 12, y: b[1], width: 24, height: Math.max(1, a[1] - b[1]), r: 5 }, style: { fill: t.accent } } } }] 165 base.tooltip.formatter = p => { const it = s.items[p[0]?.dataIndex ?? 0]; return `${esc(it.label)}: ${it.low} â ${it.high}${esc(unit)}` } 166 } else if (type === 'before-after') { 167 base.series = ['before', 'after'].map((key, i) => ({ name: key, type: 'bar', data: s.items.map(it => it[key]), barMaxWidth: 28, itemStyle: { color: i ? t.accent : t.info, borderRadius: [4, 4, 0, 0] } })); base.legend.show = true; base.grid.bottom = 32 168 } else { 169 base.series = [{ type: 'boxplot', data: s.items.map(it => { const a = [...it.values].sort((a, b) => a - b); const q = p => { const n = (a.length - 1) * p, lo = Math.floor(n); return a[lo] + (a[Math.ceil(n)] - a[lo]) * (n - lo) }; return [a[0], q(.25), q(.5), q(.75), a.at(-1)] }), itemStyle: { color: fade(t.accent, .15), borderColor: t.accent } }] 170 } 171 } else if (['network', 'sankey'].includes(type)) { 172 const nodes = s.nodes || [...new Set(s.links.flatMap(l => [l.source, l.target]))].map(name => ({ name })) 173 base.series = [{ type: type === 'network' ? 'graph' : 'sankey', layout: 'circular', data: nodes.map((n, i) => ({ name: n.name, itemStyle: { color: t.palette[i % 8] } })), links: s.links, roam: false, symbolSize: 26, nodeWidth: 14, nodeGap: 18, label: { show: true, color: t.ink, fontSize: 11 }, lineStyle: { color: type === 'sankey' ? 'gradient' : t.info, opacity: .4, curveness: .3 }, emphasis: { focus: 'adjacency' } }] 174 } else if (type === 'bubble') { 175 const max = Math.max(1, ...s.points.map(p => p.value)) 176 base.xAxis = { ...axis(), show: false, min: -1, max: Math.max(1, s.points.length) }; base.yAxis = { ...axis(), show: false, min: -1, max: 1 } 177 base.series = [{ type: 'scatter', data: s.points.map((p, i) => ({ name: p.name, value: [i, 0, p.value], itemStyle: { color: s.variant === 'volume' ? 'transparent' : { type: 'radial', x: .35, y: .3, r: .8, colorStops: [{ offset: 0, color: '#F
177FE03D' }, { offset: .6, color: '#3DCB5B' }, { offset: 1, color: '#FF7A3D' }] }, borderColor: t.ink, borderWidth: 1 } })), symbolSize: v => Math.sqrt(v[2] / max) * 94, label: { show: true, position: 'bottom', color: t.ink, formatter: p => `${p.name}\n${p.value[2]}` } }] 178 base.tooltip.formatter = p => `${esc(p.name)}: ${p.value[2]}${esc(unit)}` 179 } else if (type === 'geo') { 180 base.geo = { map: 'session-dashboard-world', roam: false, itemStyle: { areaColor: t.surface3, borderColor: t.grid }, emphasis: { disabled: true } } 181 base.series = [{ type: 'scatter', coordinateSystem: 'geo', data: s.points.map(p => ({ name: p.name, value: [p.lng, p.lat, p.value], itemStyle: { color: tone(p.tone) || t.info } })), symbolSize: 12 }] 182 base.tooltip.formatter = p => `${esc(p.name)}: ${p.value[2]}${esc(unit)}` 183 } 184 return base 185} 186 187function chartRows(s) { 188 if (s.items) return s.items.map(it => [it.label, ...(it.values || (s.type === 'range' ? [it.low, it.high] : [it.before, it.after]))]) 189 if (s.links) return s.links.map(l => [l.source, l.target, l.value]) 190 if (s.points) return s.points.map(p => [p.name, ...(s.type === 'geo' ? [p.lng, p.lat] : []), p.value]) 191 if (s.value !== undefined) return [[s.title || 'Value', s.value, s.max ?? 100]] 192 return (s.series || []).flatMap(it => it.value !== undefined ? [[it.name, it.value, ...(it.max !== undefined ? [it.max] : [])]] : (it.data || []).map((v, i) => [it.name, s.x?.[i] ?? i, ...(Array.isArray(v) ? v : [v])])) 193} 194 195/* ââ Board layout: a 12-column packed grid ââââââââââââââââââââââââââââââââââ 196 * Moved here from lib/panel-layout.ts so the server renderer and the React 197 * Data pane pack identically. Pure data + functions; panel-layout.ts re-exports. 198 * Every panel has a natural SPAN (columns à 40px rows), the template only 199 * decides role ORDER, and a 1D packer fills rows to exactly `cols`. */ 200const KPI_TYPES = new Set(['kpi', 'stats', 'hero', 'ring', 'split', 'segments', 'badges', 'bullet', 'gauge', 'progress']) 201const KPI_VISUALS = new Set(['stat', 'multistat', 'kpi-strip', 'progress', 'progress-multi', 'radial', 'ring-kpi', 'gauge', 'hero', 'ring', 'split', 'segments', 'badges', 'bullet']) 202const CHART_TYPES = new Set(['line', 'area', 'bar', 'vertical-bar', 'stacked-bar', 'pie', 'donut', 'scatter', 'radar', 'treemap', 'funnel', 'heatmap', 'sankey', 'boxplot', 'range', 'before-after', 'network', 'bubble', 'combo', 'leaderboard', 'sparkline', 'concentric', 'geo']) 203const CHART_VISUALS = new Set(['sparkline', 'area', 'combo', 'bars', 'vbars', 'donut', 'pie', 'heatmap', 'scatter', 'boxplot', 'range', 'before-after', 'radar', 'treemap', 'funnel', 'stacked-bar', 'line', 'step-line', 'stacked-area', 'pct-stacked', 'waterfall', 'pareto', 'histogram', 'pictorial-bar', 'lollipop', 'dumbbell', 'slope', 'sunburst', 'rose', 'tree', 'candlestick', 'parallel', 'themeriver', 'effect-scatter', 'sankey', 'network', 'bubble', 'choropleth', 'pin-map', 'leaderboard']) 204const TABLE_TYPES = new Set(['table', 'comparison', 'master-detail', 'datafeeds', 'document', 'kanban']) 205const TABLE_VISUALS = new Set(['table', 'dense-table', 'master-detail', 'comparison', 'datafeeds', 'document', 'kanban', 'list-rich']) 206const TAKEAWAY_TYPES = new Set(['alert']) 207 208function panelRole(panel) { 209 if (panel.type === 'po') { 210 const v = panel.visual 211 if (KPI_VISUALS.has(v)) return 'kpi' 212 if (TABLE_VISUALS.has(v)) return 'table' 213 if (CHART_VISUALS.has(v)) return 'chart' 214 return 'narrative' 215 } 216 if (KPI_TYPES.has(panel.type)) return 'kpi' 217 if (TABLE_TYPES.has(panel.type)) return 'table' 218 if (CHART_TYPES.has(panel.type)) return 'chart' 219 if (TAKEAWAY_TYPES.has(panel.type)) return 'takeaway' 220 return 'narrative' 221} 222 223const LAYOUTS = { 224 analytical: ['kpi', 'chart', 'table', 'narrative', 'takeaway'], 225 executive: ['kpi', 'chart', 'narrative', 'table', 'takeaway'], 226 comparison: ['table', 'chart', 'kpi', 'narrative', 'takeaway'], 227} 228const DEFAULT_LAYOUT = 'analytical' 229const isLayoutName = v => typeof v === 'string' && v in LAYOUTS 230/** Studio templates carry the role order: executive/comparison by n
230ame, the rest analytical. */ 231const layoutForTemplate = templateId => templateId === 'dashboard-executive' ? 'executive' : templateId === 'dashboard-comparison' ? 'comparison' : DEFAULT_LAYOUT 232 233// A tile strip may shrink to two thirds so it sits beside the hero (the reference's hero + ghost stats row). 234const STRIP = { gw: 12, gh: 3, minGw: 6, flow: true } 235const wide = { gw: 7, gh: 9, minGw: 5 }, half = { gw: 5, gh: 9, minGw: 4 }, broad = { gw: 8, gh: 9, minGw: 6 }, words = { gw: 6, gh: 9, minGw: 4 } 236const SPANS = { 237 stats: STRIP, kpi: STRIP, multistat: STRIP, 'kpi-strip': STRIP, 'progress-multi': STRIP, 238 hero: { gw: 12, gh: 5 }, 239 ring: { gw: 3, gh: 4 }, 'ring-kpi': { gw: 3, gh: 4 }, gauge: { gw: 3, gh: 4 }, radial: { gw: 3, gh: 4 }, state: { gw: 3, gh: 4 }, stat: { gw: 3, gh: 4 }, 240 split: { gw: 4, gh: 4 }, segments: { gw: 4, gh: 4 }, badges: { gw: 4, gh: 4 }, progress: { gw: 4, gh: 4 }, 241 bullet: { gw: 6, gh: 5 }, 242 line: wide, area: wide, bar: wide, bars: wide, 'vertical-bar': wide, vbars: wide, 'stacked-bar': wide, combo: wide, 'step-line': wide, 243 'stacked-area': wide, 'pct-stacked': wide, waterfall: wide, pareto: wide, histogram: wide, lollipop: wide, dumbbell: wide, candlestick: wide, 'pictorial-bar': wide, 244 pie: half, donut: half, funnel: half, radar: half, slope: half, rose: half, sparkline: half, 'before-after': half, leaderboard: half, concentric: half, 245 heatmap: broad, treemap: broad, scatter: broad, sankey: broad, network: broad, bubble: broad, sunburst: broad, tree: broad, boxplot: broad, range: broad, parallel: broad, 246 themeriver: broad, 'effect-scatter': broad, choropleth: broad, 'pin-map': broad, geo: broad, 247 table: broad, 'dense-table': broad, comparison: broad, 248 'master-detail': { gw: 12, gh: 10 }, kanban: { gw: 12, gh: 10 }, columns: { gw: 12, gh: 10 }, colcards: { gw: 12, gh: 10 }, 249 feed: { gw: 4, gh: 9, minGw: 3 }, list: { gw: 4, gh: 9, minGw: 3 }, 'list-rich': { gw: 4, gh: 9, minGw: 3 }, datafeeds: { gw: 4, gh: 9, minGw: 3 }, 250 timeline: words, document: words, approval: words, image: words, markdown: words, summary: words, code: words, text: words, 251 alert: { gw: 12, gh: 2, flow: true }, 252} 253const DEFAULT_SPAN = { gw: 6, gh: 6, minGw: 4 } 254const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)) 255 256function panelSpan(panel) { 257 if (panel.type === 'table') { 258 if (!Array.isArray(panel.rows)) return { gw: 12, gh: 9 } // bound `{{table:ref}}`: rows unknown until refresh 259 const wideTable = panel.headers.length >= 5 260 return { gw: wideTable ? 12 : 8, gh: clamp(panel.rows.length + 2, 6, 14), minGw: 6 } 261 } 262 if (panel.type === 'po' && (panel.visual === 'table' || panel.visual === 'dense-table')) { 263 const data = panel.data || {} 264 const rows = Array.isArray(data.rows) ? data.rows.length : 0, cols = Array.isArray(data.columns) ? data.columns.length : 0 265 return { gw: cols >= 5 ? 12 : 8, gh: rows ? clamp(rows + 2, 6, 14) : 9, minGw: 6 } 266 } 267 const key = panel.type === 'po' ? panel.visual : panel.type 268 return SPANS[key] || DEFAULT_SPAN 269} 270 271const kpiRank = panel => { const key = panel.type === 'po' ? panel.visual : panel.type; return key === 'hero' ? 0 : SPANS[key]?.flow ? 2 : 1 } 272 273function layoutGrid(panels, layout, cols = 12, session = false) { 274 if (session) return sessionGrid(panels, layout, cols) 275 const name = isLayoutName(layout) ? layout : DEFAULT_LAYOUT 276 const order = LAYOUTS[name] 277 const byRole = new Map(order.map(r => [r, []])) 278 for (const panel of panels) byRole.get(panelRole(panel)).push(panel) 279 const kpis = byRole.get('kpi') 280 kpis.sort((a, b) => kpiRank(a) - kpiRank(b)) 281 const heroShared = kpis.some(p => kpiRank(p) === 0) && kpis.length > 1 282 const ordered = order.flatMap(r => byRole.get(r)) 283 const scale = cols / 12 284 const cells = [] 285 let row = [], used = 0, firstChartDone = false 286 const spanOf = panel => { 287 const s = panelSpan(panel), key = panel.type === 'po' ? panel.visual : panel.type 288 if (key === 'hero' && heroShared) return { ...s, gw: 4 } 289 if (name === 'executive' && panelRole(panel) === 'chart' && !firstChartDone) return { ...s, gw: 12 } 290 return s 291 } 292 const closeRow = () => { 293 if (!row.length) return 294 let spare = cols - used 295 for (let i = 0; spare > 0; i = (i + 1) % row.length, spare--) row[i].gw += 1 296 const flowAlone = row.length === 1 && Boolean(spanOf(row[0].panel).flow) 297 const gh = flowAlone ? null : Math.max(...row.map(c => c.gh ?? 0)) || null 298 for (const c of row) c.gh = gh 299 cells.push(...row); row = []; used = 0 300 } 301 for (const panel of ordered) { 302 const span = spanOf(panel) 303 if (panelRole(panel) === 'chart') firstChartDone = true 304 let gw = clamp(Math.round(span.gw * scale), 1, cols) 305 let minGw = clamp(Math.round((span.minGw ?? span.gw) * scale), 1, gw) 306 // Two-up board: halves pair, wide things and tables (columns need the width) span. 307 if (cols === 6) { gw = span.flow || span.gw >= 9 || panelRole(panel) === 'table' ? 6 : 3; minGw = gw } 308 if (used + gw > cols) { if (used + minGw <= cols) gw = cols - used; else closeRow() } 309 row.push({ panel, gw, gh: span.gh }); used += gw 310 if (used >= cols) closeRow() 311 } 312 closeRow() 313 return cells 314} 315 316function gridRows(cells, cols = 12) { 317 const rows = []; let row = [], used = 0 318 for (const cell of cells) { row.push(cell); used += cell.gw; if (used >= cols) { rows.push(row); row = []; used = 0 } } 319 if (row.length) rows.push(row) 320 return rows 321} 322const gridColsFor = width => width >= 1000 ? 12 : width >= 620 ? 6 : 1 323const sessionGridColsFor = width => width >= 1000 ? 12 : width >= 660 ? 6 : 1 324 325// Session boards have content bands, not desktop widget coordinates. Keep the
326// older packer above for non-session callers. Both HTML and React use this one. 327function sessionGrid(panels, layout, cols) { 328 const headline = p => ['hero', 'stats', 'kpi'].includes(p.type) 329 const groups = { headline: [], visual: [], table: [], narrative: [], takeaway: [] } 330 for (const p of panels) { 331 const role = panelRole(p) 332 groups[headline(p) ? 'headline' : role === 'kpi' || role === 'chart' ? 'visual' : role].push(p) 333 } 334 groups.headline.sort((a, b) => Number(b.type === 'hero') - Number(a.type === 'hero')) 335 const order = layout === 'comparison' ? ['table', 'headline', 'visual', 'narrative', 'takeaway'] 336 : layout === 'executive' ? ['headline', 'visual', 'narrative', 'table', 'takeaway'] 337 : ['headline', 'visual', 'table', 'narrative', 'takeaway'] 338 const cells = [] 339 for (const band of order) { 340 const items = groups[band] 341 for (let i = 0; i < items.length;) { 342 const heroPair = band === 'headline' && items[i].type === 'hero' && ['stats', 'kpi'].includes(items[i + 1]?.type) && cols === 12 343 const pair = cols > 1 && ['visual', 'narrative'].includes(band) && i + 1 < items.length 344 && !(layout === 'executive' && band === 'visual' && i === 0) 345 && !(band === 'narrative' && [items[i], items[i + 1]].some(p => ['timeline', 'approval'].includes(p.type))) 346 const row = items.slice(i, i + (heroPair || pair ? 2 : 1)) 347 row.forEach((panel, j) => cells.push({ panel, gw: heroPair ? (j ? 8 : 4) : cols / row.length, gh: null })) 348 i += row.length 349 } 350 } 351 return cells 352} 353 354/* ââ Chat panel â chart spec (moved from panel-spec.ts; React re-exports) ââ */ 355const num = v => Number(String(v).replace(/,/g, '')) 356function dashboardPanelSpec(p) { 357 const base = { title: undefined, ...(p.unit !== undefined ? { unit: p.unit } : {}) } 358 switch (p.type) { 359 case 'bar': case 'vertical-bar': case 'radar': 360 return { ...base, type: p.type === 'radar' ? 'radar' : 'bar', orientation: p.type === 'bar' ? 'horizontal' : 'vertical', x: p.items.map(i => i.label), series: [{ name: p.title, data: p.items.map(i => num(i.value)) }] } 361 case 'line': case 'area': case 'stacked-bar': { 362 const x = p.categories || p.series?.[0]?.data.map(d => d.category) || (p.items ? p.items.map(i => i.label) : []) || [] 363 const series = p.series?.map(s => ({ name: s.name, data: x.map(c => s.data.find(d => d.category === c)?.value ?? null) })) || [{ name: p.title, data: p.items ? p.items.map(i => i.value) : [] }] 364 return { ...base, type: p.type, x, series } 365 } 366 case 'pie': case 'donut': case 'funnel': return { ...base, type: p.type, series: p.items.map(i => ({ name: i.label, value: i.value })) } 367 case 'gauge': return { ...base, type: 'gauge', title: p.label, value: p.value, max: p.target } 368 case 'scatter': return { ...base, type: 'scatter', series: [{ data: p.points.map(pt => [pt.x, pt.y]) }] } 369 case 'range': case 'boxplot': case 'before-after': return { ...base, type: p.type, items: p.items } 370 case 'sankey': case 'network': return { ...base, type: p.type, links: p.links.map(l => ({ ...l, value: l.value ?? 1 })) } 371 case 'bubble': return { ...base, type: 'bubble', points: p.items.map(i => ({ name: i.label, value: i.value })) } 372 case 'combo': return { ...base, type: 'combo', x: p.categories, series: [{ ...p.bar, type: 'bar' }, { ...p.line, type: 'line' }] } 373 case 'heatmap': { 374 if (!p.rows.every(r => r.slice(1).every(v => String(v).trim() !== '' && Number.isFinite(Number(v))))) return null 375 return { ...base, type: 'heatmap', x: p.headers.slice(1), y: p.rows.map(r => r[0]), series: p.rows.map(r => ({ name: r[0], data: r.slice(1).map(Number) })) } 376 } 377 default: return null 378 } 379} 380/** A panel already written in the data-chart vocabulary (x/series/items/links/points/value). */ 381const isChartPanel = p => Boolean(p) && TYPES.includes(p.type) && ['x', 'series', 'items', 'links', 'points', 'value', 'ref'].some(k => p[k] !== undefined) && !(p.items && p.items[0] && 'label' in p.items[0] && !('low' in p.items[0] || 'before' in p.items[0] || 'values' in p.items[0])) 382/** Chart spec for any panel: pass-through for chart-vocabulary panels, else the chat mapping. */ 383function chartSpecFor(p) { 384 if (isChartPanel(p)) { const { title: _t, ...spec } = p; return spec } 385 return dashboardPanelSpec(p) 386} 387 388/* ââ Structured dashboard model: validation + server HTML âââââââââââââââââââ */ 389const BOARD_CARD_TYPES = ['hero', 'stats', 'kpi', 'split', 'segments', 'badges', 'state', 'columns', 'leaderboard', 'timeline', 'feed', 'table', 'alert', 'list', 'summary', 'master-detail', 'datafeeds', 'document', 'approval'] 390const BOARD_TEMPLATES = ['dashboard-overview', 'dashboard-analysis', 'dashboard-compact', 'dashboard-executive', 'dashboard-comparison'] 391const TONES = ['pos', 'neg', 'warn', 'info', 'neutral', 'accent'] 392const TEXT_MAX = 2000 393 394function walkStrings(value, fn, path = '') { 395 if (typeof value === 'string') fn(value, path) 396 else if (Array.isArray(value)) value.forEach((v, i) => walkStrings(v, fn, `${path}[${i}]`)) 397 else if (value && typeof value === 'object') for (const [k, v] of Object.entries(value)) walkStrings(v, fn, path ? `${path}.${k}` : k) 398} 399const str = (v, where, max = TEXT_MAX, required = true) => { 400 if (v === undefined || v === null) { if (required) fail(`${where} is required`); return } 401 if (typeof v !== 'string' || (required && !v.trim())) fail(`${where} must be non-empty text`) 402 if (v.length > max) fail(`${where} exceeds ${max} characters`) 403} 404const list = (v, where, min, max) => { if (!Array.isArray(v) || v.length < min || v.length > max) fail(`${where} needs ${min}-${max} entries`); if (v.some(it => !it || typeof it !== 'object' || Array.isArray(it))) fail(`${where} entries must be objects`); return v } 405const number = (v, where) => { if (!finite(v)) fail(`${where} must be a number`) } 406const oneOf = (v, where, allowed, required = false) => { if (v === undefined) { if (required) fail(`${where} is required`); return } if (!allowed.includes(v)) fail(`${where} must be one of ${allowed.join('|')}`) } 407 408function validateCard(p, at) { 409 for (const it of p.items || []) { 410 if (it.spark !== undefined && (!Array.isArray(it.spark) || it.spark.length < 2 || it.spark.length > 100 || !it.spark.every(finite))) fail(`${at}: spark needs 2-100 finite observations`) 411 if (it.progress !== undefined && (!finite(it.progress) || it.progress < 0 || it.progress > 100)) fail(`${at}: progress must be between 0 and 100`) 412 } 413 switch (p.type) { 414 case 'hero': str(p.value, `${at}.value`, 40); str(p.caption, `${at}.caption`, 200, false); str(p.delta, `${at}.delta`, 80, false); oneOf(p.trend, `${at}.trend`, ['up', 'down', 'flat']); break 415 case 'stats': case 'kpi': for (const [i, it] of list(p.items, `${at}.items`, 1, 12).entries()) { str(it.label, `${at}.items[${i}].label`, 80); str(it.value, `${at}.items[${i}].value`, 40); str(it.delta, `${at}.items[${i}].delta`, 80, false); oneOf(it.trend, `${at}.items[${i}].trend`, ['up', 'down', 'flat']) } break 416 case 'split': for (const [i, it] of list(p.items, `${at}.items`, 2, 4).entries()) { str(it.label, `${at}.items[${i}].label`, 80); str(it.value, `${at}.items[${i}].value`, 40); oneOf(it.tone, `${at}.items[${i}].tone`, TONES) } break 417 case 'segments': for (const [i, it] of list(p.items, `${at}.items`, 1, 8).entries()) { str(it.label, `${at}.items[${i}].label`, 80); number(it.value, `${at}.items[${i}].value`); if (it.value < 0) fail(`${at}.items[${i}].value must be non-negative`); oneOf(it.tone, `${at}.items[${i}].tone`, TONES) } break 418 case 'badges': for (const [i, it] of list(p.items, `${at}.items`, 1, 20).entries()) { str(it.label, `${at}.items[${i}].label`, 80); oneOf(it.tone, `${at}.items[${i}].tone`, TONES) } break 419 case 'state': oneOf(p.tone, `${at}.tone`, ['gold', 'coral', 'green'], true); str(p.heading, `${at}.heading`, 120); if (p.tags !== undefined) { if (!Array.isArray(p.tags) || p.tags.length > 6) fail(`${at}.tags needs 0-6 entries`);
419 p.tags.forEach((t, i) => str(t, `${at}.tags[${i}]`, 40)) } for (const [i, it] of list(p.items || [], `${at}.items`, 0, 4).entries()) { str(it.value, `${at}.items[${i}].value`, 40); str(it.caption, `${at}.items[${i}].caption`, 80) } break 420 case 'columns': case 'leaderboard': for (const [i, it] of list(p.items, `${at}.items`, 1, 20).entries()) { str(it.label, `${at}.items[${i}].label`, 120); number(it.value, `${at}.items[${i}].value`); if (p.type === 'columns' && it.value < 0) fail(`${at}: column-card values must be non-negative`); str(it.note, `${at}.items[${i}].note`, 200, false) } break 421 case 'timeline': for (const [i, it] of list(p.events, `${at}.events`, 1, 20).entries()) { str(it.date, `${at}.events[${i}].date`, 40); str(it.title, `${at}.events[${i}].title`, 160); str(it.description, `${at}.events[${i}].description`, 600, false) } break 422 case 'feed': for (const [i, it] of list(p.items, `${at}.items`, 1, 20).entries()) { str(it.title, `${at}.items[${i}].title`, 200); str(it.meta, `${at}.items[${i}].meta`, 200, false); str(it.action, `${at}.items[${i}].action`, 120, false); oneOf(it.tone, `${at}.items[${i}].tone`, TONES) } break 423 case 'table': 424 if (p.ref !== undefined) { str(p.ref, `${at}.ref`, 80); if (p.rows !== undefined || p.headers !== undefined) fail(`${at} is bound to data["${p.ref}"]; omit headers and rows`); break } 425 if (!Array.isArray(p.headers) || p.headers.length < 1 || p.headers.length > 12) fail(`${at}.headers needs 1-12 columns`) 426 p.headers.forEach((h, i) => str(h, `${at}.headers[${i}]`, 80)) 427 if (!Array.isArray(p.rows) || p.rows.length < 1 || p.rows.length > 50) fail(`${at}.rows needs 1-50 rows`) 428 p.rows.forEach((r, i) => { if (!Array.isArray(r) || r.length !== p.headers.length) fail(`${at}.rows[${i}] must have ${p.headers.length} cells`); r.forEach((c, j) => { 429 if (typeof c === 'string' || finite(c)) return 430 const cell = `${at}.rows[${i}][${j}]` 431 if (!c || typeof c !== 'object' || Array.isArray(c)) fail(`${cell} must be text, a number or a formatted cell`) 432 str(c.text, `${cell}.text`, 600) 433 oneOf(c.tone, `${cell}.tone`, TONES) 434 if (c.href !== undefined && (typeof c.href !== 'string' || !/^https?:\/\/[^\s]+$/i.test(c.href))) fail(`${cell}.href requires an HTTP(S) URL`) 435 if (c.spark !== undefined && (!Array.isArray(c.spark) || c.spark.length < 2 || c.spark.length > 100 || !c.spark.every(finite))) fail(`${cell}.spark needs 2-100 observations`) 436 }) }) 437 break 438 case 'alert': str(p.message, `${at}.message`); oneOf(p.variant, `${at}.variant`, ['info', 'warning', 'error', 'success']); break 439 case 'list': for (const [i, it] of list(p.items, `${at}.items`, 1, 20).entries()) { str(it.title, `${at}.items[${i}].title`, 200); str(it.description, `${at}.items[${i}].description`, 600, false); str(it.badge, `${at}.items[${i}].badge`, 40, false) } break 440 case 'summary': for (const [i, it] of list(p.items, `${at}.items`, 1, 12).entries()) { str(it.label, `${at}.items[${i}].label`, 80); str(it.value, `${at}.items[${i}].value`, 80); str(it.description, `${at}.items[${i}].description`, 400, false) } break 441 case 'datafeeds': for (const it of list(p.items, `${at}.items`, 1, 20)) { str(it.label, `${at}.label`, 120); str(it.value, `${at}.value`, 80) } break 442 case 'document': for (const it of list(p.items, `${at}.items`, 1, 20)) { str(it.name, `${at}.name`, 160); str(it.meta, `${at}.meta`, 200, false); str(it.badge, `${at}.badge`, 40, false); oneOf(it.tone, `${at}.tone`, TONES) } break 443 case 'master-detail': for (const it of list(p.items, `${at}.items`, 1, 20)) { str(it.name, `${at}.name`, 160); str(it.subtitle, `${at}.subtitle`, 200, false); for (const field of list(it.fields, `${at}.fields`, 1, 20)) { str(field.key, `${at}.key`, 80); str(field.value, `${at}.value`, 600) } } break 444 case 'approval': str(p.heading, `${at}.heading`, 160); str(p.subtitle, `${at}.subtitle`, 200, false); str(p.draft, `${at}.draft`, TEXT_MAX, false); for (const it of list(p.meta, `${at}.meta`, 0, 12)) { str(it.label, `${at}.label`, 80); str(it.value, `${at}.value`, 200) } break 445 default: fail(`${at}
445.type "${p.type}" is not supported; use one of ${BOARD_CARD_TYPES.join('|')} or a chart type ${TYPES.join('|')}`) 446 } 447} 448 449function validateBoard(model) { 450 if (!model || typeof model !== 'object' || Array.isArray(model)) fail('Dashboard model must be an object') 451 if (model.version !== 1) fail('version must be 1') 452 if (model.family !== 'dashboard') fail('family must be "dashboard"') 453 oneOf(model.templateId, 'templateId', BOARD_TEMPLATES, true) 454 str(model.title, 'title', 120); str(model.subtitle, 'subtitle', 160, false) 455 oneOf(model.canvas, 'canvas', ['light', 'dark']); oneOf(model.theme, 'theme', Object.keys(THEMES)) 456 if (JSON.stringify(model).length > 400_000) fail('Dashboard model exceeds 400 KB') 457 const panels = list(model.panels, 'panels', 2, 12) 458 panels.forEach((p, i) => { 459 const at = `panels[${i}]` 460 if (typeof p.type !== 'string') fail(`${at}.type is required`) 461 str(p.title, `${at}.title`, 160, false) 462 walkStrings(p, (s, path) => { if (s.length > TEXT_MAX) fail(`${at}.${path} exceeds ${TEXT_MAX} characters`) }) 463 if (TYPES.includes(p.type)) { 464 if (JSON.stringify(p).includes('{{')) fail(`${at} (${p.type}): charts take bound data through "ref", never {{â¦}} placeholders`) 465 if (p.ref !== undefined) { 466 str(p.ref, `${at}.ref`, 80) 467 if (['x', 'y', 'series', 'items', 'value', 'points', 'links', 'nodes'].some(k => p[k] !== undefined)) fail(`${at}: bound charts must not override their dataset`) 468 return 469 } 470 try { validateDashboardChart(chartSpecFor(p)) } catch (error) { fail(`${at} (${p.type}): ${error.message}`) } 471 return 472 } 473 validateCard(p, `${at} (${p.type})`) 474 }) 475 if (panels.filter(p => p.type === 'hero').length > 1) fail('At most one hero panel') 476 return model 477} 478 479const TONE_VAR = { pos: '--pos', neg: '--neg', warn: '--warn', info: '--info', neutral: '--neutral', accent: '--accent' } 480const BADGE_CLASS = { pos: 'pos', neg: 'neg', warn: 'warn', info: 'info', neutral: 'neu', accent: 'accent' } 481const ALERT_TONE = { info: 'info', warning: 'warn', error: 'neg', success: 'pos' } 482const tri = trend => trend === 'down' ? 'â¼' : trend === 'up' ? 'â²' : '' 483const chip = (trend, delta) => delta ? `<span class="chip-trend ${trend === 'down' ? 'down' : trend === 'up' ? 'up' : ''}">${tri(trend)} ${esc(delta)}</span>` : '' 484const ptitle = title => title ? `<h2 class="ptitle">${esc(title)}</h2>` : '' 485const numeric = v => typeof v === 'number' || /^[-+]?[$â¬Â£Â¥â©]?\s?[\d,.]+\s?[%kKmMbBtT]?$/.test(String(v).trim()) 486const chartAttr = spec => JSON.stringify(spec).replace(/'/g, ''') 487 488function sparkHtml(values) { 489 if (!Array.isArray(values) || values.length < 2 || !values.every(finite)) return '' 490 const low = Math.min(...values), span = Math.max(...values) - low || 1 491 const points = values.map((v, i) => `${i * 240 / (values.length - 1)},${44 - (v - low) / span * 40}`).join(' ') 492 return `<svg class="sd-spark" viewBox="0 0 240 48" role="img" aria-label="Trend: ${esc(values.join(', '))}"><polyline points="${points}" fill="none" stroke="var(--info)" stroke-width="2" vector-effect="non-scaling-stroke"/></svg>` 493} 494 495function statHtml(it) { 496 const value = `<div class="v num" data-fit-value style="--sd-value-width:${statValueWidth(it.value)}">${esc(it.value)}${it.unit ? `<small class="muted"> ${esc(it.unit)}</small>` : ''}</div><div class="l">${esc(it.label)}</div>` 497 if (finite(it.progress)) return `<div class="ringkpi"><svg viewBox="0 0 64 64" class="sd-ring" role="img" aria-label="${esc(it.label)}: ${it.progress}%"><circle cx="32" cy="32" r="26" fill="none" stroke="var(--surface-3)" stroke-width="6"/><circle cx="32" cy="32" r="26" fill="none" stroke="var(--info)" stroke-width="6" pathLength="100" stroke-dasharray="${Math.max(0, Math.min(100, it.progress))} 100" transform="rotate(-90 32 32)"/></svg><div class="meta">${value}${chip(it.trend, it.delta)}</div></div>` 498 return it.spark ? `<div class="spark"><div class="top"><div>${value}</div>${chip(it.trend, it.delta)}</div>${sparkHtml(it.spark)}</div>` : `<div class="kpi">${value}${chip(it.trend, it.delta)}</div>` 499} 500 501// Conservative text width in ems; cqi handles pane resizing without observers. 502// The iframe recalculates this after server data bindings resolve. 503function statValueWidth(value) { 504 return Math.max(1, [...String(value ?? '')].reduce((sum, c) => sum + (/[.,\s]/.test(c) ? .35 : /[^\x00-\x7f]|[MW]/.test(c) ? 1.05 : .68), 0)).toFixed(2) 505} 506 507function tableHtml(p) { 508 if (p.ref) return `${ptitle(p.title)}<div class="table-scroll">{{table:${esc(p.ref)}}}</div>` 509 const right = p.headers.map((_, j) => p.rows.length > 0 && p.rows.every(r => r[j] === '' || numeric(r[j]?.text ?? r[j]))) 510 const head = p.headers.map((h, j) =>
510 `<th${right[j] ? ' class="r"' : ''}>${esc(h)}</th>`).join('') 511 const cell = c => { 512 if (c && typeof c === 'object') { 513 const text = c.tone ? `<span class="badge ${BADGE_CLASS[c.tone] || 'neu'}">${esc(c.text)}</span>` : esc(c.text) 514 return `${/^https?:\/\/[^\s]+$/i.test(c.href || '') ? `<a class="link" href="${esc(c.href)}" target="_blank" rel="noopener noreferrer">${text}</a>` : text}${c.spark ? sparkHtml(c.spark) : ''}` 515 } 516 return /^https?:\/\/[^\s]+$/i.test(String(c)) ? `<a class="link" href="${esc(c)}" target="_blank" rel="noopener noreferrer">${esc(c)}</a>` : esc(c) 517 } 518 const body = p.rows.map(r => `<tr>${r.map((c, j) => `<td${right[j] ? ' class="r num"' : ''}>${cell(c)}</td>`).join('')}</tr>`).join('') 519 return `${ptitle(p.title)}<div class="table-scroll"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>` 520} 521 522function cardHtml(p) { 523 switch (p.type) { 524 case 'hero': return `<div class="hero"><div class="cap">${esc(p.caption || p.title || '')}</div><div class="v num">${esc(p.value)}</div>${p.delta ? `<div style="margin-top:8px;font-size:12px;opacity:.9">${tri(p.trend)} ${esc(p.delta)}</div>` : ''}</div>` 525 // Tiles ARE cards (reference `panel > kpi`): no outer panel, the cell is bare. 526 case 'stats': case 'kpi': return `${ptitle(p.title)}<div class="sd-kpis">${p.items.map(it => `<div class="panel" style="container-type:inline-size">${statHtml(it)}</div>`).join('')}</div>` 527 case 'split': return `${ptitle(p.title)}<div class="cmp">${p.items.map(it => `<div class="half"><div class="v num"${it.tone ? ` style="color:var(${TONE_VAR[it.tone]})"` : ''}>${esc(it.value)}</div><div class="l">${esc(it.label)}</div></div>`).join('')}</div>` 528 case 'segments': { 529 const total = p.items.reduce((s, it) => s + it.value, 0) || 1 530 return `${ptitle(p.title)}<div class="segbar">${p.items.map((it, i) => `<i style="width:${(it.value / total * 100).toFixed(2)}%;background:var(${TONE_VAR[it.tone] || `--cat-${['indigo', 'green', 'coral', 'sky', 'orange', 'magenta', 'gold', 'acid'][i % 8]}`})"></i>`).join('')}</div><div class="legend">${p.items.map((it, i) => `<span><i class="dot" style="background:var(${TONE_VAR[it.tone] || `--cat-${['indigo', 'green', 'coral', 'sky', 'orange', 'magenta', 'gold', 'acid'][i % 8]}`})"></i>${esc(it.label)} <b class="num">${esc(it.value)}</b></span>`).join('')}</div>` 531 } 532 case 'badges': return `${ptitle(p.title)}<div style="display:flex;flex-wrap:wrap;gap:8px">${p.items.map(it => `<span class="badge ${BADGE_CLASS[it.tone] || 'neu'}">${esc(it.label)}</span>`).join('')}</div>` 533 case 'state': return `<div class="state ${esc(p.tone)}"><h4>${esc(p.heading)}</h4>${p.tags?.length ? `<div class="tags">${p.tags.map(t => `<span>${esc(t)}</span>`).join('')}</div>` : ''}${p.items?.length ? `<div class="nums">${p.items.map(it => `<div><div class="n num">${esc(it.value)}</div><div class="c">${esc(it.caption)}</div></div>`).join('')}</div>` : ''}</div>` 534 case 'columns': { 535 const max = Math.max(1, ...p.items.map(it => it.value)) 536 return `${ptitle(p.title)}<div class="sd-columns">${p.items.map(it => `<div class="sd-column"><span class="num">${esc(it.value)}</span><span>${esc(it.label)}</span><i style="height:${Math.max(0, it.value) / max * 100}%"></i></div>`).join('')}</div>` 537 } 538 case 'master-detail': return `${ptitle(p.title)}<div class="sd-details">${p.items.map((it, i) => `<details${i === 0 ? ' open' : ''}><summary><b>${esc(it.name)}</b>${it.subtitle ? `<span class="muted"> ${esc(it.subtitle)}</span>` : ''}</summary><dl>${it.fields.map(f => `<div><dt>${esc(f.key)}</dt><dd>${esc(f.value)}</dd></div>`).join('')}</dl></details>`).join('')}</div>` 539 case 'document': return `${ptitle(p.title)}<div class="feeds">${p.items.map(it => `<div class="row"><span class="fi" aria-hidden="true">â¤</span><span class="fn"><b>${esc(it.name)}</b>${it.meta ? `<br><small class="muted">${esc(it.meta)}</small>` : ''}</span>${it.badge ? `<span class="badge ${BADGE_CLASS[it.tone] || 'neu'}">${esc(it.badge)}</span>` : ''}</div>`).join('')}</div>` 540 case 'datafeeds': return cardHtml({ ...p, type: 'summary' }) 541 case 'approval': return `${ptitle(p.title)}<div class="sd-approval"><h3>${esc(p.heading)}</h3>${p.subtitle ? `<p class="muted">${esc(p.subtitle)}</p>` : ''}${p.draft ? `<blockquote>${esc(p.draft)}</blockquote>` : ''}${cardHtml({ type: 'summary', items: p.meta })}</div>` 542 case 'leaderboard': { 543 const max = Math.max(1, ...p.items.map(it => Math.abs(it.value))) 544 return `${ptitle(p.title)}<table class="lead"><tbody>${p.items.map((it, i) => `<tr><td style="width:30px"><span class="rank">${i + 1}</span></td><td>${esc(it.label)}${it.note ? `<br><small class="muted">${esc(it.note)}</small>` : ''}</td><td style="width:34%"><div class="bar"><i style="width:${(Math.abs(it.value) / max * 100).toFixed(2)}%"></i></div></td><td class="r mono" style="white-space:nowrap;padding-left:12px"><b>${esc(it.value)}</b></td></tr>`).join('')}</tbody></table>` 545 } 546 case 'timeline': return `${ptitle(p.title)}<div class="feed">${p.events.map(e => `<div class="alert"><div class="ameta"><span class="mono">${esc(e.date)}</span></div><div class="atitle">${esc(e.title)}</div>${e.description ? `<p class="muted" style="font-size:13px;margin-top:4px">${esc(e.description)}</p>` : ''}</div>`).join('')}</div>` 547 case 'feed': return `${ptitle(p.title)}<div class="feed">${p.items.map(it => `<div class="alert"><div class="atitle"><i class="dot ${BADGE_CLASS[it.tone] || 'neu'}"></i>${esc(it.title)}</div>${it.meta || it.action ? `<div class="ameta">${it.meta ? `<span>${esc(it.meta)}</span>` : ''}${it.meta && it.action ? '<span class="sep"></span>' : ''}${it.action ? `<span class="badge accent">${esc(it.action)}</span>` : ''}</div>` : ''}</div>`).join('')}</div>` 548 case 'table': return tableHtml(p) 549 case 'alert': return `${ptitle(p.title)}<div style="display:flex;gap:12px;align-items:flex-start"><span class="badge ${ALERT_TONE[p.variant] || 'info'}" style="flex:none;margin-top:2px">${esc(p.variant || 'info')}</span>
549<p style="font-size:14px">${esc(p.message)}</p></div>` 550 case 'list': return `${ptitle(p.title)}<div class="feeds">${p.items.map(it => `<div class="row"><span class="fn"><b>${esc(it.title)}</b>${it.description ? ` <span class="muted">${esc(it.description)}</span>` : ''}</span>${it.badge ? `<span class="badge neu">${esc(it.badge)}</span>` : ''}</div>`).join('')}</div>` 551 case 'summary': return `${ptitle(p.title)}<div class="feeds">${p.items.map(it => `<div class="row"><span class="fn">${esc(it.label)}${it.description ? `<br><small class="muted">${esc(it.description)}</small>` : ''}</span><span class="fv">${esc(it.value)}</span></div>`).join('')}</div>` 552 default: return '' 553 } 554} 555 556const BARE = new Set(['hero', 'state', 'stats', 'kpi']) 557const isBoardCard = p => Boolean(p) && BOARD_CARD_TYPES.includes(p.type) 558/** Inner catalogue markup for one card panel + whether it carries its own frame (no .panel chrome). 559 * The React Data pane renders chat panels through this so pane, preview and PDF share one widget kit. */ 560function panelCardHtml(p) { 561 if (!isBoardCard(p)) return null 562 const html = cardHtml(p) 563 return html ? { html, bare: BARE.has(p.type) } : null 564} 565/** Server-side board: the same packer as the React Data pane, catalogue markup, charts drawn by the runtime. */ 566function renderBoardHtml(model) { 567 const m = validateBoard(model) 568 const layout = layoutForTemplate(m.templateId) 569 const cells = layoutGrid(m.panels, layout, 12, true), six = layoutGrid(m.panels, layout, 6, true) 570 const gw6 = new Map(six.map(c => [c.panel, c.gw])) 571 const body = cells.map(c => { 572 const p = c.panel, isChart = TYPES.includes(p.type) 573 const inner = isChart 574 ? `${ptitle(p.title)}${p.unit ? `<p class="sd-chart-unit">${esc(p.unit)}</p>` : ''}<div data-chart='${chartAttr(chartSpecFor(p))}' style="height:300px"></div>` 575 : cardHtml(p) 576 const cls = BARE.has(p.type) ? 'board-bare' : 'panel' 577 // Content-driven heights: the chart div is a fixed 300px, neighbours stretch to the row (CSS grid). 578 return `<section class="${cls}" data-gw="${c.gw}" data-gw6="${gw6.get(p) ?? Math.min(6, Math.ceil(c.gw / 2))}">${inner}</section>` 579 }).join('\n') 580 return `<!doctype html> 581<html lang="en"> 582<head> 583<meta charset="utf-8"> 584<meta name="viewport" content="width=device-width,initial-scale=1"> 585<title>${esc(m.title)}</title> 586<meta name="palette-dashboard" content="${esc(m.templateId)}"> 587</head> 588<body class="session-dashboard" data-session-dashboard="nocturne-v1" data-canvas="${esc(m.canvas || 'light')}" data-theme="${esc(m.theme || 'signal')}"> 589<main> 590<header class="dhead">${m.subtitle ? `<div class="eyebrow">${esc(m.subtitle)}</div>` : ''}<h1>${esc(m.title)}</h1></header> 591<div class="board"> 592${body} 593</div> 594</main> 595</body> 596</html> 597` 598} 599 600function start(){ 601/* Included after core.mjs in the generated dashboard runtime. */ 602const roots = document.querySelectorAll('[data-session-dashboard="nocturne-v1"]'); 603for (const root of roots) { 604 root.querySelectorAll('[data-fit-value]').forEach(node => node.style.setProperty('--sd-value-width', statValueWidth(node.textContent))); 605 const charts = new Map(); 606 const theme = () => dashboardTheme(root.getAttribute('data-canvas'), root.getAttribute('data-theme')); 607 function render(node) { 608 let chart; 609 try { 610 const spec = JSON.parse(node.getAttribute('data-chart')); 611 validateDashboardChart(spec); 612 if (spec.type === 'geo') { 613 if (!window.__SESSION_DASHBOARD_WORLD__) throw new Error('Map asset unavailable'); 614 echarts.registerMap('session-dashboard-world', window.__SESSION_DASHBOARD_WORLD__); 615 } 616 if (typeof echarts === 'undefined') throw new Error('Chart runtime unavailable'); 617 node.style.height ||= spec.type === 'sparkline' ? '44px' : spec.type === 'ring' ? '90px' : '300px'; 618 node.setAttribute('role', 'img'); 619 node.setAttribute('aria-label', spec.title || spec.type + ' chart'); 620 node.textContent = ''; 621 // A panel that already carries its own title (.ptitle/h2) must not repeat it inside the chart. 622 const titled = node.parentElement && node.parentElement.querySelector(':scope > :is(.ptitle,h2,h3,.dbPanelHead)'); 623 const drawn = titled && spec.title ? { ...spec, title: undefined } : spec; 624 chart = echarts.init(node, null, { renderer: 'svg' }); 625 chart.setOption(buildDashboardOption(drawn, theme())); 626 node.setAttribute('data-chart-rendered', '1'); 627 node.removeAttribute('data-chart-error'); 628 const details = document.createElement('details'); 629 details.className = 'sd-chart-data'; 630 const summary = document.createElement('summary'); 631 summary.textContent = 'View chart data'; 632 details.appendChild(summary); 633 const table = document.createElement('table'); 634 const caption = document.createElement('caption'); 635 caption.textContent = spec.title || spec.type + ' data'; table.appendChild(caption); 636 for (const row of chartRows(spec)) { 637 const tr = document.createElement('tr'); 638 row.forEach((value, i) => { const cell = document.createElement(i ? 'td' : 'th'); if (!i) cell.setAttribute('scope', 'row'); cell.textContent = value == null ? 'â' : String(value); tr.appendChild(cell); }); 639 table.appendChild(tr); 640 } 641 details.appendChild(table); (node.closest('.ringkpi') || node).insertAdjacentElement('afterend', details); 642 const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => chart.resize()) : null; 643 observer?.observe(node); 644 charts.set(node, { chart, spec: drawn, observer, details }); 645 } catch (error) { 646 chart?.dispose(); 647 node.setAttribute('data-chart-error', '1'); 648 node.setAttribute('data-chart-rendered', 'error'); 649 node.setAttribute('role', 'status'); 650 node.textContent = 'Chart unavailable: ' + error.message; 651 } 652 } 653 root.querySelectorAll('[data-chart]').forEach(render); 654 const updateControls = () => { 655 for (const attr of ['canvas', 'theme']) root.querySelectorAll(`[data-${attr}-btn]`).forEach(button => { 656 button.setAttribute('aria-pressed', String(button.getAttribute(`data-${attr}-btn`) === root.getAttribute(`data-${attr}`))); 657 }); 658 }; 659 updateControls(); 660 const observer = new MutationObserver(records => { 661 if (records.some(r => r.target === root)) { 662 for (const { chart, spec } of charts.values()) chart.setOption(buildDashboardOption(spec, theme()), true); 663 updateControls(); 664 } 665 for (const record of records) if (record.attributeName === 'data-chart') { 666 const old = charts.get(record.target); 667 old?.observer?.disconnect();
667 old?.chart.dispose(); old?.details.remove(); charts.delete(record.target); 668 render(record.target); 669 } 670 }); 671 observer.observe(root, { attributes: true, subtree: true, attributeFilter: ['data-canvas', 'data-theme', 'data-chart'] }); 672 // Native buttons/selects work with keyboard and touch; filter whole rows or 673 // panels, preserving their data and rescaling charts after they become visible. 674 const filter = control => { 675 const group = control.getAttribute('data-filter'); 676 if (!group) return; 677 const value = control.value || control.getAttribute('data-value') || 'all'; 678 root.querySelectorAll('[data-filter-group]').forEach(item => { 679 if (item.getAttribute('data-filter-group') === group) item.hidden = value !== 'all' && item.getAttribute('data-filter-value') !== value; 680 }); 681 root.querySelectorAll('button[data-filter]').forEach(button => { 682 if (button.getAttribute('data-filter') === group) button.setAttribute('aria-pressed', String((button.value || button.getAttribute('data-value')) === value)); 683 }); 684 for (const { chart } of charts.values()) chart.resize(); 685 }; 686 root.querySelectorAll('select[data-filter],button[data-filter][aria-pressed="true"],button[data-filter][value="all"]').forEach(filter); 687 root.addEventListener('click', event => { 688 const button = event.target.closest('button'); 689 if (!button) return; 690 if (button.hasAttribute('data-canvas-btn')) root.setAttribute('data-canvas', button.getAttribute('data-canvas-btn')); 691 if (button.hasAttribute('data-theme-btn')) root.setAttribute('data-theme', button.getAttribute('data-theme-btn')); 692 filter(button); 693 }); 694 root.addEventListener('change', event => { if (event.target.matches('select[data-filter]')) filter(event.target); }); 695 window.addEventListener('pagehide', () => { observer.disconnect(); for (const { chart, observer: resize } of charts.values()) { resize?.disconnect(); chart.dispose(); } }, { once: true }); 696} 697 698} 699if(document.readyState === "loading") document.addEventListener("DOMContentLoaded",start); else start(); 700})();
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.