Spaces:
Running
Running
Deploy: flow/flowchart chart kind (layered, arrowed); richer charts (on-chart value labels, rounded bars, gradient areas, stronger hover) across kinds; baseline iframe auto-resizes to chart's natural height via postMessage + fill normalization; baseline visual sizing prompt
Browse files- vivek/backend/combined_prompt.py +1 -1
- vivek/backend/server.py +6 -2
- vivek/frontend-vue/dist/assets/{ActionRow-C5K-GzW5.js → ActionRow-B1R5m79B.js} +1 -1
- vivek/frontend-vue/dist/assets/{ChartBlock-CGlwF_DK.js → ChartBlock-2l17cTFi.js} +0 -0
- vivek/frontend-vue/dist/assets/{TextBlock-D7ydM5dr.js → TextBlock-HD2UY_tu.js} +1 -1
- vivek/frontend-vue/dist/assets/{index-BZhxAMIz.css → index-CA-wuNor.css} +0 -0
- vivek/frontend-vue/dist/assets/{index-oLTRTdCR.js → index-nrc-cgMW.js} +0 -0
- vivek/frontend-vue/dist/index.html +2 -2
- vivek/frontend-vue/src/lib/echartsOption.ts +168 -13
- vivek/frontend-vue/src/pages/Chat.vue +53 -3
- vivek/frontend-vue/src/widget-registry.json +3 -0
vivek/backend/combined_prompt.py
CHANGED
|
@@ -188,7 +188,7 @@ def _chart_has_data(chart: dict[str, Any]) -> bool:
|
|
| 188 |
return _nonempty_list(chart.get("candles"))
|
| 189 |
if kind == "boxplot":
|
| 190 |
return _nonempty_list(chart.get("boxes"))
|
| 191 |
-
if kind in {"sankey", "graph"}:
|
| 192 |
return _nonempty_list(chart.get("links"))
|
| 193 |
if kind in {"tree", "mindmap", "org", "orgchart"}:
|
| 194 |
# Accept the same variants the renderer reads: root | tree | data | top-level node | items.
|
|
|
|
| 188 |
return _nonempty_list(chart.get("candles"))
|
| 189 |
if kind == "boxplot":
|
| 190 |
return _nonempty_list(chart.get("boxes"))
|
| 191 |
+
if kind in {"sankey", "graph", "flow", "flowchart", "process"}:
|
| 192 |
return _nonempty_list(chart.get("links"))
|
| 193 |
if kind in {"tree", "mindmap", "org", "orgchart"}:
|
| 194 |
# Accept the same variants the renderer reads: root | tree | data | top-level node | items.
|
vivek/backend/server.py
CHANGED
|
@@ -797,8 +797,12 @@ _BASELINE_SYSTEM = (
|
|
| 797 |
"- It MUST be a complete standalone HTML document (<!doctype html><html>...</html>) that renders on its own.\n"
|
| 798 |
"- You MAY load ONE charting library from a CDN via a <script src=\"https://...\"> tag "
|
| 799 |
"(e.g. Chart.js, Apache ECharts, or Plotly). Inline all of your OWN CSS and JS.\n"
|
| 800 |
-
"-
|
| 801 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 802 |
"- Use real, concrete data from your knowledge or the user's message — never placeholder 0,1,2,3.\n"
|
| 803 |
"- The iframe is sandboxed (scripts only, no same-origin): do not use cookies, localStorage, or call "
|
| 804 |
"any origin other than the single CDN you reference.\n"
|
|
|
|
| 797 |
"- It MUST be a complete standalone HTML document (<!doctype html><html>...</html>) that renders on its own.\n"
|
| 798 |
"- You MAY load ONE charting library from a CDN via a <script src=\"https://...\"> tag "
|
| 799 |
"(e.g. Chart.js, Apache ECharts, or Plotly). Inline all of your OWN CSS and JS.\n"
|
| 800 |
+
"- The HTML renders in an <iframe> that AUTO-RESIZES to your content's height — so give the chart a "
|
| 801 |
+
"CLEAR, defined size. Set html,body{margin:0;padding:0;width:100%;overflow:hidden;background:#fff} and "
|
| 802 |
+
"give the chart container width:100% and a sensible height of about 400px (a fixed height or an "
|
| 803 |
+
"aspect-ratio box — not 0). For Chart.js wrap the canvas in a height:400px div with maintainAspectRatio:false; "
|
| 804 |
+
"for Plotly use {responsive:true} with a 400px tall div; for ECharts give the container an explicit height "
|
| 805 |
+
"(~400px) and call chart.resize() on window resize. Clean modern look, readable labels, no page scrollbars.\n"
|
| 806 |
"- Use real, concrete data from your knowledge or the user's message — never placeholder 0,1,2,3.\n"
|
| 807 |
"- The iframe is sandboxed (scripts only, no same-origin): do not use cookies, localStorage, or call "
|
| 808 |
"any origin other than the single CDN you reference.\n"
|
vivek/frontend-vue/dist/assets/{ActionRow-C5K-GzW5.js → ActionRow-B1R5m79B.js}
RENAMED
|
@@ -1 +1 @@
|
|
| 1 |
-
import{G as e,M as t,P as n,f as r,h as i,i as a,jt as o,u as s,v as c}from"./runtime-core.esm-bundler-olIhRSij.js";import{nr as l}from"./index-
|
|
|
|
| 1 |
+
import{G as e,M as t,P as n,f as r,h as i,i as a,jt as o,u as s,v as c}from"./runtime-core.esm-bundler-olIhRSij.js";import{nr as l}from"./index-nrc-cgMW.js";var u={class:`flex flex-wrap gap-2`},d=c({__name:`ActionRow`,props:{block:{}},emits:[`action`],setup(c,{emit:d}){let f=d;function p(e){let t=String(e.label||e.intent||``).trim();t&&f(`action`,t)}return(d,f)=>(t(),r(`div`,u,[(t(!0),r(a,null,n(c.block.buttons||[],(n,r)=>(t(),s(l,{key:r,type:`button`,variant:`outline`,size:`sm`,title:n.intent?`Ask: ${n.label||n.intent}`:void 0,onClick:e=>p(n)},{default:e(()=>[i(o(n.label||`Action`),1)]),_:2},1032,[`title`,`onClick`]))),128))]))}});export{d as default};
|
vivek/frontend-vue/dist/assets/{ChartBlock-CGlwF_DK.js → ChartBlock-2l17cTFi.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vivek/frontend-vue/dist/assets/{TextBlock-D7ydM5dr.js → TextBlock-HD2UY_tu.js}
RENAMED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
-
import{M as e,c as t,f as n,v as r}from"./runtime-core.esm-bundler-olIhRSij.js";import{r as i}from"./index-
|
| 2 |
`).trim();if(!e)return``;let t=e.split(`
|
| 3 |
`),n=[],r=null,a=()=>{r&&=(n.push(`</${r}>`),null)};for(let e of t){let t=e.match(/^\s*#{1,3}\s+(.*)$/),i=e.match(/^\s*(\d+)[.)]\s+(.*)$/),o=e.match(/^\s*[-*•]\s+(.*)$/);t?(a(),n.push(`<div class="ws-h">${s(t[1])}</div>`)):i?(r!==`ol`&&(a(),n.push(`<ol class="ws-ol">`),r=`ol`),n.push(`<li>${s(i[2])}</li>`)):o?(r!==`ul`&&(a(),n.push(`<ul class="ws-bullets">`),r=`ul`),n.push(`<li>${s(o[1])}</li>`)):(a(),e.trim()&&n.push(`<p>${s(e)}</p>`))}return a(),n.join(``)});return(t,r)=>(e(),n(`div`,{class:`ws-text leading-relaxed motion-safe:transition-opacity motion-safe:duration-300`,innerHTML:c.value},null,8,a))}}),[[`__scopeId`,`data-v-51d1042a`]]);export{o as default};
|
|
|
|
| 1 |
+
import{M as e,c as t,f as n,v as r}from"./runtime-core.esm-bundler-olIhRSij.js";import{r as i}from"./index-nrc-cgMW.js";var a=[`innerHTML`],o=i(r({__name:`TextBlock`,props:{block:{}},setup(r){let i=r;function o(e){return e.replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`)}function s(e){let t=o(e);return t=t.replace(/\*\*(.+?)\*\*/g,`<strong class="ws-accent">$1</strong>`),t=t.replace(/__(.+?)__/g,`<strong class="ws-accent">$1</strong>`),t=t.replace(/`([^`]+?)`/g,`<code class="ws-code">$1</code>`),t}let c=t(()=>{let e=String(i.block.content||``).replace(/\r\n/g,`
|
| 2 |
`).trim();if(!e)return``;let t=e.split(`
|
| 3 |
`),n=[],r=null,a=()=>{r&&=(n.push(`</${r}>`),null)};for(let e of t){let t=e.match(/^\s*#{1,3}\s+(.*)$/),i=e.match(/^\s*(\d+)[.)]\s+(.*)$/),o=e.match(/^\s*[-*•]\s+(.*)$/);t?(a(),n.push(`<div class="ws-h">${s(t[1])}</div>`)):i?(r!==`ol`&&(a(),n.push(`<ol class="ws-ol">`),r=`ol`),n.push(`<li>${s(i[2])}</li>`)):o?(r!==`ul`&&(a(),n.push(`<ul class="ws-bullets">`),r=`ul`),n.push(`<li>${s(o[1])}</li>`)):(a(),e.trim()&&n.push(`<p>${s(e)}</p>`))}return a(),n.join(``)});return(t,r)=>(e(),n(`div`,{class:`ws-text leading-relaxed motion-safe:transition-opacity motion-safe:duration-300`,innerHTML:c.value},null,8,a))}}),[[`__scopeId`,`data-v-51d1042a`]]);export{o as default};
|
vivek/frontend-vue/dist/assets/{index-BZhxAMIz.css → index-CA-wuNor.css}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vivek/frontend-vue/dist/assets/{index-oLTRTdCR.js → index-nrc-cgMW.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vivek/frontend-vue/dist/index.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>frontend-vue</title>
|
| 8 |
-
<script type="module" crossorigin src="/assets/index-
|
| 9 |
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-olIhRSij.js">
|
| 10 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<div id="app"></div>
|
|
|
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>frontend-vue</title>
|
| 8 |
+
<script type="module" crossorigin src="/assets/index-nrc-cgMW.js"></script>
|
| 9 |
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-olIhRSij.js">
|
| 10 |
+
<link rel="stylesheet" crossorigin href="/assets/index-CA-wuNor.css">
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<div id="app"></div>
|
vivek/frontend-vue/src/lib/echartsOption.ts
CHANGED
|
@@ -32,8 +32,8 @@ export type ChartSpec = {
|
|
| 32 |
root?: TreeNodeSpec
|
| 33 |
candles?: (number | string)[][]
|
| 34 |
boxes?: (number | string)[][]
|
| 35 |
-
nodes?: ({ name?: string } | string)[]
|
| 36 |
-
links?: { source?: string | number; target?: string | number; value?: number }[]
|
| 37 |
}
|
| 38 |
|
| 39 |
// Fixed enterprise palette — the SINGLE source of chart colors. The LLM never
|
|
@@ -86,6 +86,45 @@ function colorAt(i: number, total = 0): string {
|
|
| 86 |
// in the widget JSON is ignored — colorAt() assigns deterministically by index,
|
| 87 |
// so the look is identical on every render and in the HTML export.
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
function toNum(v: unknown): number | null {
|
| 90 |
if (typeof v === 'number') return Number.isFinite(v) ? v : null
|
| 91 |
if (typeof v === 'string') {
|
|
@@ -250,6 +289,7 @@ export function chartHasRenderableData(chart: ChartSpec): boolean {
|
|
| 250 |
if (k === 'candlestick') return Array.isArray(c.candles) && c.candles.length > 0
|
| 251 |
if (k === 'boxplot') return Array.isArray(c.boxes) && c.boxes.length > 0
|
| 252 |
if (k === 'sankey' || k === 'graph') return Array.isArray(c.links) && c.links.length > 0
|
|
|
|
| 253 |
if (k === 'treemap' || k === 'sunburst') return (c.items?.length || 0) > 0 || pieData(chart).length > 0
|
| 254 |
if (k === 'pie' || k === 'donut' || k === 'funnel' || k === 'waterfall' || k === 'rose') return pieData(chart).length > 0
|
| 255 |
if (k === 'tree' || k === 'mindmap' || k === 'org' || k === 'orgchart') return treeRoot(chart) != null
|
|
@@ -324,7 +364,7 @@ function buildEChartsOptionRaw(chart: ChartSpec, title = '', opts: { dark?: bool
|
|
| 324 |
radius: k === 'donut' ? ['42%', '70%'] : '65%',
|
| 325 |
center: ['50%', '46%'],
|
| 326 |
data: d.map((x, i) => ({ ...x, itemStyle: { color: colorAt(i, d.length) } })),
|
| 327 |
-
label: { fontSize: 10, color: tc },
|
| 328 |
},
|
| 329 |
],
|
| 330 |
}
|
|
@@ -347,7 +387,7 @@ function buildEChartsOptionRaw(chart: ChartSpec, title = '', opts: { dark?: bool
|
|
| 347 |
bottom: 40,
|
| 348 |
sort: 'descending',
|
| 349 |
gap: 2,
|
| 350 |
-
label: { show: true, position: 'inside', fontSize: 10 },
|
| 351 |
data: d.map((x, i) => ({ ...x, itemStyle: { color: colorAt(i, d.length) } })),
|
| 352 |
},
|
| 353 |
],
|
|
@@ -445,7 +485,7 @@ function buildEChartsOptionRaw(chart: ChartSpec, title = '', opts: { dark?: bool
|
|
| 445 |
series: [
|
| 446 |
k === 'sunburst'
|
| 447 |
? { type: 'sunburst', data: d, radius: [0, '92%'], label: { fontSize: 10 } }
|
| 448 |
-
: { type: 'treemap', data: d, breadcrumb: { show: false }, roam: false, label: { fontSize: 11 } },
|
| 449 |
],
|
| 450 |
}
|
| 451 |
}
|
|
@@ -478,6 +518,99 @@ function buildEChartsOptionRaw(chart: ChartSpec, title = '', opts: { dark?: bool
|
|
| 478 |
}
|
| 479 |
}
|
| 480 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
if (k === 'waterfall') {
|
| 482 |
const pts = pieData(chart)
|
| 483 |
const cats = pts.map((p) => p.name)
|
|
@@ -565,7 +698,7 @@ function buildEChartsOptionRaw(chart: ChartSpec, title = '', opts: { dark?: bool
|
|
| 565 |
roseType: 'area',
|
| 566 |
itemStyle: { borderRadius: 4 },
|
| 567 |
data: d.map((x, i) => ({ ...x, itemStyle: { color: colorAt(i, d.length) } })),
|
| 568 |
-
label: { fontSize: 10, color: tc },
|
| 569 |
},
|
| 570 |
],
|
| 571 |
}
|
|
@@ -712,6 +845,11 @@ function buildEChartsOptionRaw(chart: ChartSpec, title = '', opts: { dark?: bool
|
|
| 712 |
const cats = cartesianCategories(chart)
|
| 713 |
const useCat = Array.isArray(cats) && cats.length > 0
|
| 714 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
const eSeries = series.map((s, i) => {
|
| 716 |
const seriesType = (
|
| 717 |
isCombo ? (s.kind === 'line' ? 'line' : s.kind === 'bar' ? 'bar' : i === 0 ? 'bar' : 'line')
|
|
@@ -720,18 +858,35 @@ function buildEChartsOptionRaw(chart: ChartSpec, title = '', opts: { dark?: bool
|
|
| 720 |
: 'line'
|
| 721 |
) as 'bar' | 'scatter' | 'line'
|
| 722 |
const asLine = seriesType === 'line'
|
|
|
|
| 723 |
return {
|
| 724 |
name: s.name,
|
| 725 |
type: seriesType,
|
| 726 |
data: useCat ? s.points.map((p) => p[1]) : s.points,
|
| 727 |
stack: isStacked ? 'total' : undefined,
|
| 728 |
-
smooth: asLine,
|
| 729 |
-
showSymbol: seriesType === 'scatter' || asLine,
|
| 730 |
-
symbolSize: seriesType === 'scatter' ? 10 :
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
animationDuration: 900,
|
| 736 |
animationEasing: 'cubicOut' as const,
|
| 737 |
}
|
|
|
|
| 32 |
root?: TreeNodeSpec
|
| 33 |
candles?: (number | string)[][]
|
| 34 |
boxes?: (number | string)[][]
|
| 35 |
+
nodes?: ({ name?: string; level?: number } | string)[]
|
| 36 |
+
links?: { source?: string | number; target?: string | number; value?: number; label?: string }[]
|
| 37 |
}
|
| 38 |
|
| 39 |
// Fixed enterprise palette — the SINGLE source of chart colors. The LLM never
|
|
|
|
| 86 |
// in the widget JSON is ignored — colorAt() assigns deterministically by index,
|
| 87 |
// so the look is identical on every render and in the HTML export.
|
| 88 |
|
| 89 |
+
/** Compact human number for on-chart value labels: 47.5, 1.2k, 3.4M, 2.1B. */
|
| 90 |
+
function fmtCompact(v: unknown): string {
|
| 91 |
+
const n = Number(v)
|
| 92 |
+
if (!Number.isFinite(n)) return ''
|
| 93 |
+
const a = Math.abs(n)
|
| 94 |
+
if (a >= 1e9) return (n / 1e9).toFixed(a >= 1e10 ? 0 : 1).replace(/\.0$/, '') + 'B'
|
| 95 |
+
if (a >= 1e6) return (n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, '') + 'M'
|
| 96 |
+
if (a >= 1e3) return (n / 1e3).toFixed(a >= 1e4 ? 0 : 1).replace(/\.0$/, '') + 'k'
|
| 97 |
+
return String(Math.round(n * 100) / 100)
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/** Add alpha to a hex or hsl() color (used for gradient area fills). */
|
| 101 |
+
function withAlpha(color: string, a: number): string {
|
| 102 |
+
if (!color) return color
|
| 103 |
+
if (color.startsWith('#')) {
|
| 104 |
+
let h = color.slice(1)
|
| 105 |
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
| 106 |
+
const n = parseInt(h, 16)
|
| 107 |
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`
|
| 108 |
+
}
|
| 109 |
+
if (color.startsWith('hsl(')) return color.replace('hsl(', 'hsla(').replace(')', `, ${a})`)
|
| 110 |
+
return color
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/** Vertical (or horizontal) gradient object ECharts accepts directly in options. */
|
| 114 |
+
function areaGradient(color: string, horizontal = false): Record<string, unknown> {
|
| 115 |
+
return {
|
| 116 |
+
type: 'linear',
|
| 117 |
+
x: 0,
|
| 118 |
+
y: 0,
|
| 119 |
+
x2: horizontal ? 1 : 0,
|
| 120 |
+
y2: horizontal ? 0 : 1,
|
| 121 |
+
colorStops: [
|
| 122 |
+
{ offset: 0, color: withAlpha(color, 0.38) },
|
| 123 |
+
{ offset: 1, color: withAlpha(color, 0.03) },
|
| 124 |
+
],
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
function toNum(v: unknown): number | null {
|
| 129 |
if (typeof v === 'number') return Number.isFinite(v) ? v : null
|
| 130 |
if (typeof v === 'string') {
|
|
|
|
| 289 |
if (k === 'candlestick') return Array.isArray(c.candles) && c.candles.length > 0
|
| 290 |
if (k === 'boxplot') return Array.isArray(c.boxes) && c.boxes.length > 0
|
| 291 |
if (k === 'sankey' || k === 'graph') return Array.isArray(c.links) && c.links.length > 0
|
| 292 |
+
if (k === 'flow' || k === 'flowchart' || k === 'process') return Array.isArray(c.links) && c.links.length > 0
|
| 293 |
if (k === 'treemap' || k === 'sunburst') return (c.items?.length || 0) > 0 || pieData(chart).length > 0
|
| 294 |
if (k === 'pie' || k === 'donut' || k === 'funnel' || k === 'waterfall' || k === 'rose') return pieData(chart).length > 0
|
| 295 |
if (k === 'tree' || k === 'mindmap' || k === 'org' || k === 'orgchart') return treeRoot(chart) != null
|
|
|
|
| 364 |
radius: k === 'donut' ? ['42%', '70%'] : '65%',
|
| 365 |
center: ['50%', '46%'],
|
| 366 |
data: d.map((x, i) => ({ ...x, itemStyle: { color: colorAt(i, d.length) } })),
|
| 367 |
+
label: { fontSize: 10, color: tc, formatter: '{b}: {d}%' },
|
| 368 |
},
|
| 369 |
],
|
| 370 |
}
|
|
|
|
| 387 |
bottom: 40,
|
| 388 |
sort: 'descending',
|
| 389 |
gap: 2,
|
| 390 |
+
label: { show: true, position: 'inside', fontSize: 10, color: '#fff', formatter: '{b}: {c}' },
|
| 391 |
data: d.map((x, i) => ({ ...x, itemStyle: { color: colorAt(i, d.length) } })),
|
| 392 |
},
|
| 393 |
],
|
|
|
|
| 485 |
series: [
|
| 486 |
k === 'sunburst'
|
| 487 |
? { type: 'sunburst', data: d, radius: [0, '92%'], label: { fontSize: 10 } }
|
| 488 |
+
: { type: 'treemap', data: d, breadcrumb: { show: false }, roam: false, label: { fontSize: 11, formatter: '{b}\n{c}' } },
|
| 489 |
],
|
| 490 |
}
|
| 491 |
}
|
|
|
|
| 518 |
}
|
| 519 |
}
|
| 520 |
|
| 521 |
+
if (k === 'flow' || k === 'flowchart' || k === 'process') {
|
| 522 |
+
// Flowchart: directed graph laid out in LEFT→RIGHT layers (process steps),
|
| 523 |
+
// with arrowed edges. Nodes are boxes; the model supplies nodes + links
|
| 524 |
+
// (optionally a per-node `level` and per-link `label`).
|
| 525 |
+
type FlowNode = { name: string; level?: number }
|
| 526 |
+
const nodes: FlowNode[] = (chart?.nodes || []).map((n) =>
|
| 527 |
+
typeof n === 'string' ? { name: n } : { name: n.name || '', level: n.level },
|
| 528 |
+
)
|
| 529 |
+
const links = (chart?.links || []).map((l) => ({
|
| 530 |
+
source: String(l.source ?? ''),
|
| 531 |
+
target: String(l.target ?? ''),
|
| 532 |
+
label: l.label,
|
| 533 |
+
}))
|
| 534 |
+
const byName = new Map(nodes.map((n) => [n.name, n]))
|
| 535 |
+
for (const l of links) {
|
| 536 |
+
for (const e of [l.source, l.target]) {
|
| 537 |
+
if (e && !byName.has(e)) {
|
| 538 |
+
const nn = { name: e }
|
| 539 |
+
nodes.push(nn)
|
| 540 |
+
byName.set(e, nn)
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
// Depth per node: explicit `level`, else longest path from a source (relaxation).
|
| 546 |
+
const depth = new Map<string, number>()
|
| 547 |
+
const incoming = new Map<string, number>()
|
| 548 |
+
nodes.forEach((n) => incoming.set(n.name, 0))
|
| 549 |
+
links.forEach((l) => incoming.set(l.target, (incoming.get(l.target) || 0) + 1))
|
| 550 |
+
nodes.forEach((n) => depth.set(n.name, typeof n.level === 'number' ? n.level : incoming.get(n.name) === 0 ? 0 : 0))
|
| 551 |
+
for (let pass = 0; pass < nodes.length; pass++) {
|
| 552 |
+
let changed = false
|
| 553 |
+
for (const l of links) {
|
| 554 |
+
const d = Math.max(depth.get(l.target) || 0, (depth.get(l.source) || 0) + 1)
|
| 555 |
+
if (d !== depth.get(l.target)) {
|
| 556 |
+
depth.set(l.target, d)
|
| 557 |
+
changed = true
|
| 558 |
+
}
|
| 559 |
+
}
|
| 560 |
+
if (!changed) break
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
// Lay out: x by depth (column), y spread evenly within each column.
|
| 564 |
+
const cols = new Map<number, string[]>()
|
| 565 |
+
nodes.forEach((n) => {
|
| 566 |
+
const d = depth.get(n.name) || 0
|
| 567 |
+
if (!cols.has(d)) cols.set(d, [])
|
| 568 |
+
cols.get(d)!.push(n.name)
|
| 569 |
+
})
|
| 570 |
+
const maxDepth = Math.max(0, ...Array.from(cols.keys()))
|
| 571 |
+
const placed = nodes.map((n, i) => {
|
| 572 |
+
const d = depth.get(n.name) || 0
|
| 573 |
+
const col = cols.get(d)!
|
| 574 |
+
const row = col.indexOf(n.name)
|
| 575 |
+
const x = maxDepth > 0 ? (d / maxDepth) * 100 : 50
|
| 576 |
+
const y = col.length > 1 ? (row / (col.length - 1)) * 100 : 50
|
| 577 |
+
return {
|
| 578 |
+
name: n.name,
|
| 579 |
+
x,
|
| 580 |
+
y,
|
| 581 |
+
value: 1,
|
| 582 |
+
symbol: 'roundRect',
|
| 583 |
+
symbolSize: [Math.min(120, Math.max(54, n.name.length * 7)), 34] as [number, number],
|
| 584 |
+
itemStyle: { color: colorAt(i, nodes.length), borderColor: 'rgba(255,255,255,0.65)', borderWidth: 1 },
|
| 585 |
+
label: { show: true, color: '#fff', fontSize: 11, fontWeight: 600 as const, overflow: 'truncate' as const, width: 110 },
|
| 586 |
+
}
|
| 587 |
+
})
|
| 588 |
+
|
| 589 |
+
return {
|
| 590 |
+
backgroundColor: 'transparent',
|
| 591 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 592 |
+
tooltip: { trigger: 'item' },
|
| 593 |
+
series: [
|
| 594 |
+
{
|
| 595 |
+
type: 'graph',
|
| 596 |
+
layout: 'none',
|
| 597 |
+
roam: true,
|
| 598 |
+
data: placed,
|
| 599 |
+
links: links.map((l) => ({
|
| 600 |
+
source: l.source,
|
| 601 |
+
target: l.target,
|
| 602 |
+
label: l.label ? { show: true, formatter: l.label, fontSize: 9, color: tc } : undefined,
|
| 603 |
+
lineStyle: { color: dark ? 'rgba(255,255,255,0.45)' : 'rgba(71,85,105,0.6)', width: 1.5, curveness: 0.05 },
|
| 604 |
+
})),
|
| 605 |
+
edgeSymbol: ['none', 'arrow'],
|
| 606 |
+
edgeSymbolSize: [0, 9],
|
| 607 |
+
emphasis: { focus: 'adjacency' },
|
| 608 |
+
label: { position: 'inside' },
|
| 609 |
+
},
|
| 610 |
+
],
|
| 611 |
+
} as EChartsOption
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
if (k === 'waterfall') {
|
| 615 |
const pts = pieData(chart)
|
| 616 |
const cats = pts.map((p) => p.name)
|
|
|
|
| 698 |
roseType: 'area',
|
| 699 |
itemStyle: { borderRadius: 4 },
|
| 700 |
data: d.map((x, i) => ({ ...x, itemStyle: { color: colorAt(i, d.length) } })),
|
| 701 |
+
label: { fontSize: 10, color: tc, formatter: '{b}: {d}%' },
|
| 702 |
},
|
| 703 |
],
|
| 704 |
}
|
|
|
|
| 845 |
const cats = cartesianCategories(chart)
|
| 846 |
const useCat = Array.isArray(cats) && cats.length > 0
|
| 847 |
|
| 848 |
+
// Show value labels on the chart when it won't get cluttered.
|
| 849 |
+
const catCount = cats?.length ?? series[0]?.points.length ?? 0
|
| 850 |
+
const fewBars = catCount > 0 && catCount <= 12 && series.length <= 4
|
| 851 |
+
const fewPts = catCount > 0 && catCount <= 8 && series.length <= 2
|
| 852 |
+
|
| 853 |
const eSeries = series.map((s, i) => {
|
| 854 |
const seriesType = (
|
| 855 |
isCombo ? (s.kind === 'line' ? 'line' : s.kind === 'bar' ? 'bar' : i === 0 ? 'bar' : 'line')
|
|
|
|
| 858 |
: 'line'
|
| 859 |
) as 'bar' | 'scatter' | 'line'
|
| 860 |
const asLine = seriesType === 'line'
|
| 861 |
+
const isBarSeries = seriesType === 'bar'
|
| 862 |
return {
|
| 863 |
name: s.name,
|
| 864 |
type: seriesType,
|
| 865 |
data: useCat ? s.points.map((p) => p[1]) : s.points,
|
| 866 |
stack: isStacked ? 'total' : undefined,
|
| 867 |
+
smooth: asLine ? 0.35 : undefined,
|
| 868 |
+
showSymbol: seriesType === 'scatter' || (asLine && fewPts),
|
| 869 |
+
symbolSize: seriesType === 'scatter' ? 10 : 7,
|
| 870 |
+
// Numbers ON the chart so values are always visible (not only on hover).
|
| 871 |
+
label: isBarSeries
|
| 872 |
+
? {
|
| 873 |
+
show: isStacked ? catCount <= 8 : fewBars,
|
| 874 |
+
position: (isStacked ? 'inside' : isHBar ? 'right' : 'top') as 'inside' | 'right' | 'top',
|
| 875 |
+
fontSize: 10,
|
| 876 |
+
fontWeight: 600 as const,
|
| 877 |
+
color: isStacked ? '#fff' : tc,
|
| 878 |
+
formatter: (p: any) => fmtCompact(p.value),
|
| 879 |
+
}
|
| 880 |
+
: asLine
|
| 881 |
+
? { show: fewPts, position: 'top' as const, fontSize: 10, color: tc, formatter: (p: any) => fmtCompact(p.value) }
|
| 882 |
+
: undefined,
|
| 883 |
+
itemStyle: {
|
| 884 |
+
color: s.color,
|
| 885 |
+
borderRadius: isBarSeries ? (isHBar ? [0, 4, 4, 0] : [4, 4, 0, 0]) : undefined,
|
| 886 |
+
},
|
| 887 |
+
areaStyle: isArea && asLine ? { color: areaGradient(s.color) } : undefined,
|
| 888 |
+
lineStyle: asLine ? { width: 2.5 } : undefined,
|
| 889 |
+
emphasis: { focus: 'series' as const, scale: seriesType === 'scatter', itemStyle: { shadowBlur: 10, shadowColor: withAlpha(s.color, 0.5) } },
|
| 890 |
animationDuration: 900,
|
| 891 |
animationEasing: 'cubicOut' as const,
|
| 892 |
}
|
vivek/frontend-vue/src/pages/Chat.vue
CHANGED
|
@@ -102,6 +102,7 @@ type PlainMsg = {
|
|
| 102 |
visualHtml?: string
|
| 103 |
streaming?: boolean
|
| 104 |
codeStream?: string
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
type ChatMessage = {
|
|
@@ -153,6 +154,17 @@ function closeTechPanels() {
|
|
| 153 |
|
| 154 |
const baselineScrollEl = ref<HTMLDivElement | null>(null)
|
| 155 |
const adaptiveScrollEl = ref<HTMLDivElement | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
const baselineStickToBottom = ref(true)
|
| 157 |
const adaptiveStickToBottom = ref(true)
|
| 158 |
const streamPulseEl = ref<HTMLDivElement | null>(null)
|
|
@@ -377,6 +389,40 @@ async function submitReward(m: ChatMessage, reward: 0 | 1) {
|
|
| 377 |
}
|
| 378 |
}
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
/** Split the raw baseline stream into the answer text and the (possibly partial) HTML being typed. */
|
| 381 |
function splitBaselineRaw(raw: string): { text: string; code: string } {
|
| 382 |
const open = raw.indexOf('<VISUAL>')
|
|
@@ -445,7 +491,9 @@ async function runChatPlain(text: string): Promise<void> {
|
|
| 445 |
msg().error = e.error || 'Baseline error'
|
| 446 |
} else if (e.type === 'done') {
|
| 447 |
msg().content = typeof e.response === 'string' ? e.response : msg().content
|
| 448 |
-
|
|
|
|
|
|
|
| 449 |
msg().elapsed = typeof e.elapsed === 'number' ? e.elapsed : undefined
|
| 450 |
msg().codeStream = ''
|
| 451 |
msg().streaming = false
|
|
@@ -888,6 +936,7 @@ onMounted(async () => {
|
|
| 888 |
|
| 889 |
window.addEventListener('keydown', onGlobalKeydown)
|
| 890 |
window.addEventListener('chat:control', onShellControl as EventListener)
|
|
|
|
| 891 |
})
|
| 892 |
|
| 893 |
onUnmounted(() => {
|
|
@@ -895,6 +944,7 @@ onUnmounted(() => {
|
|
| 895 |
killAnimationsOf(streamPulseEl.value)
|
| 896 |
window.removeEventListener('keydown', onGlobalKeydown)
|
| 897 |
window.removeEventListener('chat:control', onShellControl as EventListener)
|
|
|
|
| 898 |
})
|
| 899 |
|
| 900 |
function onShellControl(e: Event) {
|
|
@@ -1175,13 +1225,13 @@ function downloadLiveWidgetDraft(m: ChatMessage, idx: number) {
|
|
| 1175 |
</div>
|
| 1176 |
<iframe
|
| 1177 |
v-if="!m.streaming && m.visualHtml"
|
| 1178 |
-
:srcdoc="m.visualHtml"
|
| 1179 |
sandbox="allow-scripts"
|
| 1180 |
referrerpolicy="no-referrer"
|
| 1181 |
loading="lazy"
|
| 1182 |
title="Baseline visualization"
|
| 1183 |
class="w-full mt-2 rounded-xl border bg-white"
|
| 1184 |
-
style="height:
|
| 1185 |
/>
|
| 1186 |
<div v-if="m.elapsed != null" class="text-[10px] text-muted-foreground mt-1">{{ m.elapsed }}s</div>
|
| 1187 |
</div>
|
|
|
|
| 102 |
visualHtml?: string
|
| 103 |
streaming?: boolean
|
| 104 |
codeStream?: string
|
| 105 |
+
vizId?: string
|
| 106 |
}
|
| 107 |
|
| 108 |
type ChatMessage = {
|
|
|
|
| 154 |
|
| 155 |
const baselineScrollEl = ref<HTMLDivElement | null>(null)
|
| 156 |
const adaptiveScrollEl = ref<HTMLDivElement | null>(null)
|
| 157 |
+
|
| 158 |
+
// Baseline iframe auto-resize: each visual posts its content height (by id) and
|
| 159 |
+
// we grow/shrink that iframe to match.
|
| 160 |
+
const vizHeights = ref<Record<string, number>>({})
|
| 161 |
+
let vizCounter = 0
|
| 162 |
+
function onVizMessage(e: MessageEvent) {
|
| 163 |
+
const d = e.data
|
| 164 |
+
if (d && typeof d === 'object' && typeof d.__vizId === 'string' && typeof d.__vizHeight === 'number') {
|
| 165 |
+
vizHeights.value[d.__vizId] = Math.max(160, Math.min(1600, Math.round(d.__vizHeight)))
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
const baselineStickToBottom = ref(true)
|
| 169 |
const adaptiveStickToBottom = ref(true)
|
| 170 |
const streamPulseEl = ref<HTMLDivElement | null>(null)
|
|
|
|
| 389 |
}
|
| 390 |
}
|
| 391 |
|
| 392 |
+
/**
|
| 393 |
+
* Prepare the LLM's HTML for a self-sizing iframe: normalize margins/scrollbars,
|
| 394 |
+
* and inject a small reporter that postMessages the content height (tagged with
|
| 395 |
+
* `id`) so the parent can grow/shrink the iframe to the chart's natural height.
|
| 396 |
+
*/
|
| 397 |
+
function normalizeBaselineHtml(html?: string, id?: string): string {
|
| 398 |
+
if (!html) return ''
|
| 399 |
+
const css =
|
| 400 |
+
'<style id="__fit">' +
|
| 401 |
+
'html,body{margin:0!important;padding:0!important;width:100%!important;background:#fff!important;overflow:hidden!important}' +
|
| 402 |
+
'*{box-sizing:border-box}body>*{max-width:100%}canvas{max-width:100%!important}' +
|
| 403 |
+
'.js-plotly-plot,.plot-container{width:100%!important}' +
|
| 404 |
+
'</style>'
|
| 405 |
+
const vid = (id || '').replace(/[^a-zA-Z0-9_-]/g, '')
|
| 406 |
+
const reporter =
|
| 407 |
+
'<script>(function(){var ID=' +
|
| 408 |
+
JSON.stringify(vid) +
|
| 409 |
+
';function post(){try{var h=Math.max(document.documentElement.scrollHeight,(document.body?document.body.scrollHeight:0));' +
|
| 410 |
+
'parent.postMessage({__vizId:ID,__vizHeight:h},"*");}catch(e){}}' +
|
| 411 |
+
'window.addEventListener("load",function(){post();setTimeout(post,150);setTimeout(post,600);setTimeout(post,1500);});' +
|
| 412 |
+
'window.addEventListener("resize",post);' +
|
| 413 |
+
'if(window.ResizeObserver){try{new ResizeObserver(post).observe(document.documentElement);}catch(e){}}' +
|
| 414 |
+
'setTimeout(post,300);})();<\/script>'
|
| 415 |
+
let out = html
|
| 416 |
+
if (/<\/head>/i.test(out)) out = out.replace(/<\/head>/i, css + '</head>')
|
| 417 |
+
else if (/<head[^>]*>/i.test(out)) out = out.replace(/<head[^>]*>/i, (m) => m + css)
|
| 418 |
+
else if (/<html[^>]*>/i.test(out)) out = out.replace(/<html[^>]*>/i, (m) => m + '<head>' + css + '</head>')
|
| 419 |
+
else if (/<body[^>]*>/i.test(out)) out = out.replace(/<body[^>]*>/i, (m) => m + css)
|
| 420 |
+
else out = css + out
|
| 421 |
+
if (/<\/body>/i.test(out)) out = out.replace(/<\/body>/i, reporter + '</body>')
|
| 422 |
+
else out = out + reporter
|
| 423 |
+
return out
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
/** Split the raw baseline stream into the answer text and the (possibly partial) HTML being typed. */
|
| 427 |
function splitBaselineRaw(raw: string): { text: string; code: string } {
|
| 428 |
const open = raw.indexOf('<VISUAL>')
|
|
|
|
| 491 |
msg().error = e.error || 'Baseline error'
|
| 492 |
} else if (e.type === 'done') {
|
| 493 |
msg().content = typeof e.response === 'string' ? e.response : msg().content
|
| 494 |
+
const html = typeof e.visual_html === 'string' && e.visual_html ? e.visual_html : undefined
|
| 495 |
+
msg().visualHtml = html
|
| 496 |
+
if (html) msg().vizId = 'viz' + ++vizCounter
|
| 497 |
msg().elapsed = typeof e.elapsed === 'number' ? e.elapsed : undefined
|
| 498 |
msg().codeStream = ''
|
| 499 |
msg().streaming = false
|
|
|
|
| 936 |
|
| 937 |
window.addEventListener('keydown', onGlobalKeydown)
|
| 938 |
window.addEventListener('chat:control', onShellControl as EventListener)
|
| 939 |
+
window.addEventListener('message', onVizMessage)
|
| 940 |
})
|
| 941 |
|
| 942 |
onUnmounted(() => {
|
|
|
|
| 944 |
killAnimationsOf(streamPulseEl.value)
|
| 945 |
window.removeEventListener('keydown', onGlobalKeydown)
|
| 946 |
window.removeEventListener('chat:control', onShellControl as EventListener)
|
| 947 |
+
window.removeEventListener('message', onVizMessage)
|
| 948 |
})
|
| 949 |
|
| 950 |
function onShellControl(e: Event) {
|
|
|
|
| 1225 |
</div>
|
| 1226 |
<iframe
|
| 1227 |
v-if="!m.streaming && m.visualHtml"
|
| 1228 |
+
:srcdoc="normalizeBaselineHtml(m.visualHtml, m.vizId)"
|
| 1229 |
sandbox="allow-scripts"
|
| 1230 |
referrerpolicy="no-referrer"
|
| 1231 |
loading="lazy"
|
| 1232 |
title="Baseline visualization"
|
| 1233 |
class="w-full mt-2 rounded-xl border bg-white"
|
| 1234 |
+
:style="{ height: (vizHeights[m.vizId || ''] || 420) + 'px', transition: 'height 0.2s ease' }"
|
| 1235 |
/>
|
| 1236 |
<div v-if="m.elapsed != null" class="text-[10px] text-muted-foreground mt-1">{{ m.elapsed }}s</div>
|
| 1237 |
</div>
|
vivek/frontend-vue/src/widget-registry.json
CHANGED
|
@@ -52,6 +52,7 @@
|
|
| 52 |
"sunburst",
|
| 53 |
"sankey",
|
| 54 |
"graph",
|
|
|
|
| 55 |
"boxplot",
|
| 56 |
"bubble",
|
| 57 |
"histogram",
|
|
@@ -89,6 +90,8 @@
|
|
| 89 |
" { \"type\": \"chart\", \"chart\": { \"kind\": \"bubble\", \"x_label\": \"Risk\", \"y_label\": \"Return\", \"series\": [ { \"name\": \"Funds\", \"values\": [ [8,12,500], [14,18,1200] ] } ] } }",
|
| 90 |
"- sankey | graph (flows / network) -> \"nodes\" + \"links\":",
|
| 91 |
" { \"type\": \"chart\", \"chart\": { \"kind\": \"sankey\", \"nodes\": [ {\"name\":\"Revenue\"}, {\"name\":\"COGS\"}, {\"name\":\"Gross Profit\"} ], \"links\": [ {\"source\":\"Revenue\",\"target\":\"COGS\",\"value\":40}, {\"source\":\"Revenue\",\"target\":\"Gross Profit\",\"value\":60} ] } }",
|
|
|
|
|
|
|
| 92 |
"- histogram (frequency) -> like bar: \"x_categories\" (bins) + one series of counts.",
|
| 93 |
"- timeseries (interactive time trend with zoom/pan) -> like line: \"x_categories\" (dates) + series of numbers. Best for many points over time.",
|
| 94 |
"- rose (nightingale / 'pizza' chart — share with variable-radius slices) -> use \"items\": [ { \"label\", \"value\" } ].",
|
|
|
|
| 52 |
"sunburst",
|
| 53 |
"sankey",
|
| 54 |
"graph",
|
| 55 |
+
"flow",
|
| 56 |
"boxplot",
|
| 57 |
"bubble",
|
| 58 |
"histogram",
|
|
|
|
| 90 |
" { \"type\": \"chart\", \"chart\": { \"kind\": \"bubble\", \"x_label\": \"Risk\", \"y_label\": \"Return\", \"series\": [ { \"name\": \"Funds\", \"values\": [ [8,12,500], [14,18,1200] ] } ] } }",
|
| 91 |
"- sankey | graph (flows / network) -> \"nodes\" + \"links\":",
|
| 92 |
" { \"type\": \"chart\", \"chart\": { \"kind\": \"sankey\", \"nodes\": [ {\"name\":\"Revenue\"}, {\"name\":\"COGS\"}, {\"name\":\"Gross Profit\"} ], \"links\": [ {\"source\":\"Revenue\",\"target\":\"COGS\",\"value\":40}, {\"source\":\"Revenue\",\"target\":\"Gross Profit\",\"value\":60} ] } }",
|
| 93 |
+
"- flow (a process / decision flowchart, laid out left-to-right with arrows) -> \"nodes\" + directed \"links\". Optionally give each node a \"level\" (0,1,2…) to force its column, and each link a \"label\" (e.g. \"Yes\"/\"No\"):",
|
| 94 |
+
" { \"type\": \"chart\", \"chart\": { \"kind\": \"flow\", \"nodes\": [ {\"name\":\"Start\"}, {\"name\":\"Review\"}, {\"name\":\"Approve\"}, {\"name\":\"Reject\"} ], \"links\": [ {\"source\":\"Start\",\"target\":\"Review\"}, {\"source\":\"Review\",\"target\":\"Approve\",\"label\":\"Yes\"}, {\"source\":\"Review\",\"target\":\"Reject\",\"label\":\"No\"} ] } }",
|
| 95 |
"- histogram (frequency) -> like bar: \"x_categories\" (bins) + one series of counts.",
|
| 96 |
"- timeseries (interactive time trend with zoom/pan) -> like line: \"x_categories\" (dates) + series of numbers. Best for many points over time.",
|
| 97 |
"- rose (nightingale / 'pizza' chart — share with variable-radius slices) -> use \"items\": [ { \"label\", \"value\" } ].",
|