Skip to content

Commit c89ebb3

Browse files
abidlabsgradio-pr-botclaude
authored
Improve rendering of curves (#464)
Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9160b78 commit c89ebb3

7 files changed

Lines changed: 230 additions & 64 deletions

File tree

.changeset/crazy-bikes-switch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trackio": patch
3+
---
4+
5+
feat:Improve rendering of curves

tests/ui/test_ui_display.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,8 @@ def test_multiple_runs_display_multiple_plots(temp_dir):
180180
plots = page.locator(".vega-embed")
181181
expect(plots).to_have_count(3)
182182

183-
line_marks = page.locator(".vega-embed .mark-line.role-mark")
184-
expect(line_marks.first).to_be_visible()
183+
canvases = page.locator(".vega-embed canvas")
184+
expect(canvases.first).to_be_visible()
185185

186186
bar_plots = page.locator(".bar-plot")
187187
expect(bar_plots).to_have_count(1)

trackio/frontend/src/components/BarPlot.svelte

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script>
22
import { onMount, tick } from "svelte";
33
import embed from "vega-embed";
4+
import { buildColorSpecKey } from "../lib/dataProcessing.js";
45
56
let {
67
data = [],
@@ -34,6 +35,8 @@
3435
return entries;
3536
});
3637
38+
let colorSpecKey = $derived(buildColorSpecKey(data, colorField, colorMap));
39+
3740
function getBarData() {
3841
const runValues = new Map();
3942
for (const d of data) {
@@ -134,7 +137,7 @@
134137
}
135138
const result = await embed(container, spec, {
136139
actions: false,
137-
renderer: "svg",
140+
renderer: "canvas",
138141
});
139142
view = result.view;
140143
requestAnimationFrame(() => {
@@ -172,7 +175,7 @@
172175
async function downloadImage() {
173176
if (!view) return;
174177
try {
175-
const url = await view.toImageURL("png", 2);
178+
const url = await view.toImageURL("png", 4);
176179
const a = document.createElement("a");
177180
a.href = url;
178181
a.download = `${(y || "chart").replace(/\//g, "_")}.png`;
@@ -261,7 +264,7 @@
261264
$effect(() => {
262265
data;
263266
y;
264-
colorMap;
267+
colorSpecKey;
265268
title;
266269
fullscreen;
267270
container;

trackio/frontend/src/components/LinePlot.svelte

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script>
22
import { onMount, tick } from "svelte";
33
import embed from "vega-embed";
4+
import * as vega from "vega";
5+
import { buildColorSpecKey } from "../lib/dataProcessing.js";
46
57
let {
68
data = [],
@@ -10,6 +12,7 @@
1012
colorMap = {},
1113
title = "",
1214
xLim = null,
15+
yExtent = undefined,
1316
onSelect = null,
1417
onResetZoom = null,
1518
draggable = false,
@@ -24,6 +27,9 @@
2427
let view = $state(null);
2528
let fullscreen = $state(false);
2629
30+
let lastStructuralKey = null;
31+
let lastHasSmoothed = false;
32+
2733
let legendEntries = $derived.by(() => {
2834
if (!colorField || !data || data.length === 0) return [];
2935
const seen = new Set();
@@ -38,6 +44,8 @@
3844
return entries;
3945
});
4046
47+
let colorSpecKey = $derived(buildColorSpecKey(data, colorField, colorMap));
48+
4149
function cssVar(name, fallback) {
4250
return (
4351
getComputedStyle(document.documentElement)
@@ -46,6 +54,21 @@
4654
);
4755
}
4856
57+
function splitData() {
58+
const originalData = data.filter(
59+
(d) => d.data_type === "original" || !d.data_type,
60+
);
61+
const smoothedData = data.filter((d) => d.data_type === "smoothed");
62+
return { originalData, smoothedData, hasSmoothed: smoothedData.length > 0 };
63+
}
64+
65+
function computeXDomain(originalData) {
66+
const xVals = originalData.map((d) => d[x]).filter((v) => v != null);
67+
if (xLim) return [xLim[0], xLim[1]];
68+
if (xVals.length > 0) return [Math.min(...xVals), Math.max(...xVals)];
69+
return undefined;
70+
}
71+
4972
function buildSpec() {
5073
const hasColor =
5174
colorField && data.length > 0 && Object.hasOwn(data[0], colorField);
@@ -58,18 +81,20 @@
5881
(r) => colorMap[r] || "#999",
5982
);
6083
61-
const originalData = data.filter(
62-
(d) => d.data_type === "original" || !d.data_type,
63-
);
64-
const smoothedData = data.filter((d) => d.data_type === "smoothed");
65-
const hasSmoothed = smoothedData.length > 0;
84+
const { originalData, smoothedData, hasSmoothed } = splitData();
85+
lastHasSmoothed = hasSmoothed;
86+
const xDomain = computeXDomain(originalData);
6687
6788
const xEnc = {
6889
field: x,
6990
type: "quantitative",
70-
scale: { zero: false, ...(xLim ? { domain: [xLim[0], xLim[1]] } : {}) },
91+
scale: { zero: false, ...(xDomain ? { domain: xDomain } : {}) },
92+
};
93+
const yEnc = {
94+
field: y,
95+
type: "quantitative",
96+
...(yExtent ? { scale: { domain: yExtent } } : {}),
7197
};
72-
const yEnc = { field: y, type: "quantitative" };
7398
const colorEnc = hasColor
7499
? {
75100
color: {
@@ -83,23 +108,30 @@
83108
84109
const layers = [];
85110
111+
const lineMark = (extra = {}) => ({
112+
type: "line",
113+
clip: true,
114+
strokeWidth: 2,
115+
...extra,
116+
});
117+
86118
if (hasSmoothed) {
87119
layers.push({
88-
data: { values: originalData },
89-
mark: { type: "line", clip: true, strokeWidth: 1, opacity: 0.3, point: { size: 20, opacity: 0.3 } },
120+
data: { name: "data_original", values: originalData },
121+
mark: lineMark({ strokeWidth: 1, opacity: 0.3 }),
90122
encoding: { x: xEnc, y: yEnc, ...colorEnc },
91123
name: "original",
92124
});
93125
layers.push({
94-
data: { values: smoothedData },
95-
mark: { type: "line", clip: true, strokeWidth: 2, point: { size: 20 } },
126+
data: { name: "data_smoothed", values: smoothedData },
127+
mark: lineMark(),
96128
encoding: { x: xEnc, y: yEnc, ...colorEnc },
97129
name: "plot",
98130
});
99131
} else {
100132
layers.push({
101-
data: { values: data },
102-
mark: { type: "line", clip: true, strokeWidth: 2, point: { size: 20 } },
133+
data: { name: "data_plot", values: data },
134+
mark: lineMark(),
103135
encoding: { x: xEnc, y: yEnc, ...colorEnc },
104136
name: "plot",
105137
});
@@ -153,7 +185,43 @@
153185
};
154186
}
155187
156-
async function render() {
188+
function getStructuralKey() {
189+
const { originalData } = splitData();
190+
const xDomain = computeXDomain(originalData);
191+
const xKey = xDomain ? `${xDomain[0]},${xDomain[1]}` : "auto";
192+
const yKey = yExtent ? `${yExtent[0]},${yExtent[1]}` : "auto";
193+
return `${y}\0${x}\0${colorSpecKey}\0${title}\0${fullscreen}\0${!!onSelect}\0${xKey}\0${yKey}`;
194+
}
195+
196+
function replaceDataset(v, name, newData) {
197+
const cs = vega.changeset().remove(vega.truthy).insert(newData);
198+
v.change(name, cs);
199+
}
200+
201+
function tryIncrementalUpdate() {
202+
if (!view) return false;
203+
204+
const { originalData, smoothedData, hasSmoothed } = splitData();
205+
206+
if (hasSmoothed !== lastHasSmoothed) return false;
207+
208+
try {
209+
if (hasSmoothed) {
210+
replaceDataset(view, "data_original", originalData);
211+
replaceDataset(view, "data_smoothed", smoothedData);
212+
} else {
213+
replaceDataset(view, "data_plot", data);
214+
}
215+
216+
view.run();
217+
lastHasSmoothed = hasSmoothed;
218+
return true;
219+
} catch {
220+
return false;
221+
}
222+
}
223+
224+
async function fullRender() {
157225
await tick();
158226
if (!container || !data || data.length === 0 || !y) return;
159227
@@ -166,9 +234,10 @@
166234
}
167235
const result = await embed(container, spec, {
168236
actions: false,
169-
renderer: "svg",
237+
renderer: "canvas",
170238
});
171239
view = result.view;
240+
lastStructuralKey = getStructuralKey();
172241
requestAnimationFrame(() => {
173242
result.view.resize();
174243
});
@@ -193,6 +262,17 @@
193262
}
194263
}
195264
265+
async function render() {
266+
if (!container || !data || data.length === 0 || !y) return;
267+
268+
const structuralKey = getStructuralKey();
269+
if (view && structuralKey === lastStructuralKey) {
270+
if (tryIncrementalUpdate()) return;
271+
}
272+
273+
await fullRender();
274+
}
275+
196276
function downloadCSV() {
197277
if (!data || data.length === 0) return;
198278
const originals = data.filter((d) => d.data_type === "original" || !d.data_type);
@@ -224,7 +304,7 @@
224304
async function downloadImage() {
225305
if (!view) return;
226306
try {
227-
const url = await view.toImageURL("png", 2);
307+
const url = await view.toImageURL("png", 4);
228308
const a = document.createElement("a");
229309
a.href = url;
230310
a.download = `${(y || "chart").replace(/\//g, "_")}.png`;
@@ -314,8 +394,9 @@
314394
data;
315395
y;
316396
x;
317-
colorMap;
397+
colorSpecKey;
318398
xLim;
399+
yExtent;
319400
title;
320401
fullscreen;
321402
container;

trackio/frontend/src/lib/dataProcessing.js

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,40 @@ export function getMetricColumns(rows) {
116116
return getNumericColumns(rows).filter((c) => !RESERVED_KEYS.includes(c));
117117
}
118118

119-
export function downsample(data, x, y, colorField, xLim) {
120-
if (!data || data.length === 0) return { data, xLim };
119+
export function computeMetricPlotData(masterData, xColumn, metric, xLim) {
120+
let relevant = masterData.filter(
121+
(r) => r[metric] != null && r[metric] !== undefined,
122+
);
123+
if (xLim) {
124+
const sorted = relevant.sort((a, b) => a[xColumn] - b[xColumn]);
125+
let lo = 0;
126+
let hi = sorted.length - 1;
127+
while (lo < sorted.length && sorted[lo][xColumn] < xLim[0]) lo++;
128+
while (hi >= 0 && sorted[hi][xColumn] > xLim[1]) hi--;
129+
lo = Math.max(0, lo - 1);
130+
hi = Math.min(sorted.length - 1, hi + 1);
131+
relevant = sorted.slice(lo, hi + 1);
132+
}
133+
const originals = relevant.filter(
134+
(r) => r.data_type === "original" || !r.data_type,
135+
);
136+
let yExtent = undefined;
137+
if (originals.length > 0) {
138+
let yMin = Infinity;
139+
let yMax = -Infinity;
140+
for (const r of originals) {
141+
const v = r[metric];
142+
if (v != null) {
143+
if (v < yMin) yMin = v;
144+
if (v > yMax) yMax = v;
145+
}
146+
}
147+
if (yMin !== Infinity) yExtent = [yMin, yMax];
148+
}
149+
return { data: downsample(relevant, xColumn, metric, "run", xLim).data, yExtent };
150+
}
121151

152+
function downsampleImpl(data, x, y, colorField, xLim) {
122153
const columns = [x, y];
123154
if (colorField && Object.hasOwn(data[0], colorField)) {
124155
columns.push(colorField);
@@ -202,6 +233,36 @@ export function downsample(data, x, y, colorField, xLim) {
202233
return { data: result, xLim: updatedXLim };
203234
}
204235

236+
export function downsample(data, x, y, colorField, xLim) {
237+
if (!data || data.length === 0) return { data, xLim };
238+
239+
const splitByDataType =
240+
data.some((r) => r.data_type) &&
241+
colorField &&
242+
data[0] &&
243+
Object.hasOwn(data[0], colorField);
244+
245+
if (splitByDataType) {
246+
const chunks = new Map();
247+
for (const r of data) {
248+
const key = `${r[colorField] ?? "__default"}\0${r.data_type ?? "original"}`;
249+
if (!chunks.has(key)) chunks.set(key, []);
250+
chunks.get(key).push(r);
251+
}
252+
const merged = [];
253+
let mergedXLim = xLim;
254+
for (const chunk of chunks.values()) {
255+
const out = downsampleImpl(chunk, x, y, colorField, xLim);
256+
merged.push(...out.data);
257+
mergedXLim = out.xLim;
258+
}
259+
merged.sort((a, b) => (a[x] || 0) - (b[x] || 0));
260+
return { data: merged, xLim: mergedXLim };
261+
}
262+
263+
return downsampleImpl(data, x, y, colorField, xLim);
264+
}
265+
205266
export function groupMetricsByPrefix(metrics, plotOrder = []) {
206267
const noPrefix = [];
207268
const withPrefix = {};
@@ -300,6 +361,21 @@ export function groupMetricsByPrefix(metrics, plotOrder = []) {
300361
return groups;
301362
}
302363

364+
export function buildColorSpecKey(data, colorField, colorMap) {
365+
if (!colorField || !data || data.length === 0) return "";
366+
const seen = new Set();
367+
const parts = [];
368+
for (const d of data) {
369+
const name = d[colorField];
370+
if (name && !seen.has(name)) {
371+
seen.add(name);
372+
parts.push(`${name}:${colorMap[name] ?? "#999"}`);
373+
}
374+
}
375+
parts.sort();
376+
return parts.join("|");
377+
}
378+
303379
export function filterMetricsByRegex(metrics, pattern) {
304380
if (!pattern || !pattern.trim()) return metrics;
305381
try {

0 commit comments

Comments
 (0)