1// A small single-series line-plot renderer for the demo runner's plot() API.
2// Everything is themed with the --vscode-* variables (including VS Code's own
3// charts.* colors), so plots follow the active color theme.
5export type PlotColor = 'blue' | 'orange' | 'green' | 'purple' | 'red' | 'yellow';
7export interface PlotSpec {
8 readonly y: number[];
9 readonly x?: number[];
10 readonly title?: string;
11 readonly color?: PlotColor;
12}
14const SVG_NS = 'http://www.w3.org/2000/svg';
16function svgEl<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Record<string, string | number> = {}): SVGElementTagNameMap[K] {
17 const el = document.createElementNS(SVG_NS, tag);
18 for (const [key, value] of Object.entries(attrs)) {
19 el.setAttribute(key, String(value));
20 }
21 return el;
22}
24function niceTicks(min: number, max: number, count = 4): number[] {
25 const span = (max - min) || 1;
26 const rough = span / count;
27 const magnitude = 10 ** Math.floor(Math.log10(rough));
28 const step = [1, 2, 5, 10].map(m => m * magnitude).find(s => span / s <= count) ?? magnitude * 10;
29 const ticks: number[] = [];
30 for (let v = Math.ceil(min / step) * step; v <= max + 1e-9; v += step) {
31 ticks.push(Number(v.toFixed(10)));
32 }
33 return ticks;
34}
36function fmt(value: number): string {
37 if (Number.isInteger(value) && Math.abs(value) < 1e6) {
38 return String(value);
39 }
40 return String(Number(value.toPrecision(4)));
41}
43/** Renders one plot card into the container and keeps it sized to fit. */
44export function renderPlot(container: HTMLElement, spec: PlotSpec): void {
45 const card = document.createElement('div');
46 card.className = 'demo-plot';
47 if (spec.title) {
48 const title = document.createElement('div');
49 title.className = 'demo-plot-title';
50 title.textContent = spec.title;
51 card.appendChild(title);
52 }
53 const plotArea = document.createElement('div');
54 plotArea.className = 'demo-plot-area';
55 card.appendChild(plotArea);
56 container.appendChild(card);
58 const draw = () => {
59 plotArea.textContent = '';
60 const width = Math.max(200, plotArea.clientWidth || 280);
61 drawChart(plotArea, spec, width);
62 };
63 draw();
64 let lastWidth = plotArea.clientWidth;
65 const observer = new ResizeObserver(() => {
66 if (plotArea.isConnected && plotArea.clientWidth !== lastWidth && plotArea.clientWidth > 0) {
67 lastWidth = plotArea.clientWidth;
68 requestAnimationFrame(draw);
69 }
70 });
71 observer.observe(plotArea);
72}
74function drawChart(host: HTMLElement, spec: PlotSpec, width: number): void {
75 const height = 200;
76 const margin = { top: 8, right: 12, bottom: 26, left: 46 };
77 const innerWidth = width - margin.left - margin.right;
78 const innerHeight = height - margin.top - margin.bottom;
79 const color = `var(--vscode-charts-${spec.color ?? 'blue'})`;
81 const ys = spec.y;
82 const xs = spec.x ?? ys.map((_, i) => i);
83 const n = Math.min(xs.length, ys.length);
84 if (n === 0) {
85 return;
86 }
88 const xMin = Math.min(...xs), xMax = Math.max(...xs);
89 let yMin = Math.min(...ys), yMax = Math.max(...ys);
90 const yPad = (yMax - yMin || 1) * 0.05;
91 yMin -= yPad;
92 yMax += yPad;
94 const px = (x: number) => margin.left + ((x - xMin) / (xMax - xMin || 1)) * innerWidth;
95 const py = (y: number) => margin.top + (1 - (y - yMin) / (yMax - yMin || 1)) * innerHeight;
97 const svg = svgEl('svg', { width, height });
99 // recessive horizontal grid + y tick labels
100 for (const tick of niceTicks(yMin, yMax)) {
101 const y = py(tick);
102 const grid = svgEl('line', { x1: margin.left, x2: width - margin.right, y1: y, y2: y });
103 grid.style.stroke = 'var(--vscode-panel-border)';
104 grid.style.opacity = '0.5';
105 svg.appendChild(grid);
106 const label = svgEl('text', { x: margin.left - 6, y: y + 3, 'text-anchor': 'end', 'font-size': 10 });
107 label.style.fill = 'var(--vscode-descriptionForeground)';
108 label.textContent = fmt(tick);
109 svg.appendChild(label);
110 }
111 // x axis labels
112 for (const tick of niceTicks(xMin, xMax, 5)) {
113 const label = svgEl('text', { x: px(tick), y: height - margin.bottom + 14, 'text-anchor': 'middle', 'font-size': 10 });
114 label.style.fill = 'var(--vscode-descriptionForeground)';
115 label.textContent = fmt(tick);
116 svg.appendChild(label);
117 }
118 const axis = svgEl('line', { x1: margin.left, x2: width - margin.right, y1: py(yMin) , y2: py(yMin) });
119 axis.style.stroke = 'var(--vscode-panel-border)';
120 svg.appendChild(axis);
122 // the series: a thin 2px line
123 const path = svgEl('path', {
124 d: Array.from({ length: n }, (_, i) => `${i === 0 ? 'M' : 'L'}${px(xs[i]).toFixed(1)},${py(ys[i]).toFixed(1)}`).join(''),
125 fill: 'none', 'stroke-width': 2, 'stroke-linejoin': 'round',
126 });
127 path.style.stroke = color;
128 svg.appendChild(path);
130 // hover layer: crosshair + ringed marker + tooltip on nearest point
131 const crosshair = svgEl('line', { y1: margin.top, y2: height - margin.bottom, 'stroke-width': 1 });
132 crosshair.style.stroke = 'var(--vscode-panel-border)';
133 crosshair.style.display = 'none';
134 svg.appendChild(crosshair);
135 const marker = svgEl('circle', { r: 4, 'stroke-width': 2 });
136 marker.style.fill = color;
137 marker.style.stroke = 'var(--vscode-sideBar-background)';
138 marker.style.display = 'none';
139 svg.appendChild(marker);
140 const tooltip = document.createElement('div');
141 tooltip.className = 'demo-plot-tooltip';
142 tooltip.style.display = 'none';
143 host.appendChild(tooltip);
145 svg.addEventListener('pointermove', (e) => {
146 const rect = svg.getBoundingClientRect();
147 const mouseX = e.clientX - rect.left;
148 let best = 0;
149 for (let i = 1; i < n; i++) {
150 if (Math.abs(px(xs[i]) - mouseX) < Math.abs(px(xs[best]) - mouseX)) {
151 best = i;
152 }
153 }
154 const cx = px(xs[best]), cy = py(ys[best]);
155 crosshair.setAttribute('x1', String(cx));
156 crosshair.setAttribute('x2', String(cx));
157 crosshair.style.display = '';
158 marker.setAttribute('cx', String(cx));
159 marker.setAttribute('cy', String(cy));
160 marker.style.display = '';
161 tooltip.textContent = `${fmt(xs[best])}, ${fmt(ys[best])}`;
162 tooltip.style.display = '';
163 tooltip.style.left = `${Math.min(cx + 8, width - 90)}px`;
164 tooltip.style.top = `${Math.max(cy - 26, 2)}px`;
165 });
166 svg.addEventListener('pointerleave', () => {
167 crosshair.style.display = 'none';
168 marker.style.display = 'none';
169 tooltip.style.display = 'none';
170 });
172 host.appendChild(svg);
173}