From b62a0fd8db2e79c87ff7c34249f92d31d04b3f0e Mon Sep 17 00:00:00 2001
From: Kohaku-Blueleaf <59680068+KohakuBlueleaf@users.noreply.github.com>
Date: Mon, 27 Oct 2025 17:26:45 +0800
Subject: [PATCH] hover sync, NaN/inf handling, better layout
---
src/kohaku-board-ui/src/components.d.ts | 2 +
.../src/components/ConfigurableChartCard.vue | 37 ++-
.../src/components/LinePlot.vue | 309 ++++++++++++++----
.../src/components/layout/TheHeader.vue | 26 +-
.../src/composables/useHoverSync.js | 4 +-
.../src/pages/projects/[project]/[id].vue | 173 ++++++----
6 files changed, 408 insertions(+), 143 deletions(-)
diff --git a/src/kohaku-board-ui/src/components.d.ts b/src/kohaku-board-ui/src/components.d.ts
index 36068bf..efb833d 100644
--- a/src/kohaku-board-ui/src/components.d.ts
+++ b/src/kohaku-board-ui/src/components.d.ts
@@ -14,6 +14,8 @@ declare module 'vue' {
ConfigurableChartCard: typeof import('./components/ConfigurableChartCard.vue')['default']
DataTable: typeof import('./components/DataTable.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
+ ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
+ ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
diff --git a/src/kohaku-board-ui/src/components/ConfigurableChartCard.vue b/src/kohaku-board-ui/src/components/ConfigurableChartCard.vue
index 6e9a895..b3a9890 100644
--- a/src/kohaku-board-ui/src/components/ConfigurableChartCard.vue
+++ b/src/kohaku-board-ui/src/components/ConfigurableChartCard.vue
@@ -126,10 +126,14 @@ const hasDataError = computed(() => {
const xData = props.sparseData[config.xMetric];
if (!xData || xData.length === 0) return true;
- // Check if all y metrics are empty or missing
+ // Check if all y metrics have no valid data (including NaN/inf as valid!)
const allEmpty = config.yMetrics.every((yMetric) => {
const yData = props.sparseData[yMetric];
- return !yData || yData.length === 0;
+ if (!yData || yData.length === 0) return true;
+
+ // Check if there's at least ONE non-null value (including NaN/inf)
+ const hasAnyData = yData.some((val) => val !== null);
+ return !hasAnyData;
});
return allEmpty;
@@ -156,6 +160,9 @@ const processedChartData = computed(() => {
const x = [];
const y = [];
let lastXValue = null;
+ let nanCount = 0;
+ let infCount = 0;
+ let negInfCount = 0;
for (let i = 0; i < xData.length; i++) {
let xVal = xData[i];
@@ -171,12 +178,38 @@ const processedChartData = computed(() => {
}
if (xVal !== null) lastXValue = xVal;
+
+ // Include NaN/inf values (they are not null!)
+ // Only skip if yVal is actually null (missing data)
if (yVal !== null && lastXValue !== null) {
x.push(lastXValue);
y.push(yVal);
+
+ // Count special values for logging
+ if (isNaN(yVal)) {
+ nanCount++;
+ } else if (yVal === Infinity) {
+ infCount++;
+ } else if (yVal === -Infinity) {
+ negInfCount++;
+ }
}
}
+ const specialValuesLog = [];
+ if (nanCount > 0) specialValuesLog.push(`NaN=${nanCount}`);
+ if (infCount > 0) specialValuesLog.push(`+inf=${infCount}`);
+ if (negInfCount > 0) specialValuesLog.push(`-inf=${negInfCount}`);
+
+ console.log(
+ `[${props.cardId}] ${yMetric}: collected ${y.length} points` +
+ (specialValuesLog.length > 0
+ ? ` (${specialValuesLog.join(", ")})`
+ : ""),
+ );
+
+ // Return series even if all values are NaN/inf (x/y might be empty but we still need the series)
+ // The LinePlot component will handle special values
return { name: yMetric, x, y };
})
.filter((d) => d !== null);
diff --git a/src/kohaku-board-ui/src/components/LinePlot.vue b/src/kohaku-board-ui/src/components/LinePlot.vue
index 25b7bba..2ddf3cd 100644
--- a/src/kohaku-board-ui/src/components/LinePlot.vue
+++ b/src/kohaku-board-ui/src/components/LinePlot.vue
@@ -282,24 +282,95 @@ const processedData = computed(() => {
const rate = config.downsampleRate;
return props.data.map((series) => {
- const smoothedY = applySmoothing(series.y, mode, value);
- const downsampledSmoothed = downsampleData(series.x, smoothedY, rate);
+ console.log(
+ `[LinePlot] Processing series: ${series.name}, data length: ${series.y.length}`,
+ );
+
+ // Build line segments separated by NaN/inf breaks
+ // If we have: [1, 2, NaN, 3, 4], we create 2 segments: [[1,2], [3,4]]
+ const segments = []; // Each segment: {x: [], y: []}
+ const nanX = [];
+ const infX = [];
+ const negInfX = [];
+
+ let currentSegmentX = [];
+ let currentSegmentY = [];
+
+ for (let i = 0; i < series.y.length; i++) {
+ const yVal = series.y[i];
+ const xVal = series.x[i];
+
+ if (Number.isNaN(yVal)) {
+ // NaN breaks the line - finish current segment
+ if (currentSegmentX.length > 0) {
+ segments.push({ x: currentSegmentX, y: currentSegmentY });
+ currentSegmentX = [];
+ currentSegmentY = [];
+ }
+ nanX.push(xVal);
+ console.log(
+ `[LinePlot] ${series.name}[${i}]: NaN at x=${xVal} - breaking line`,
+ );
+ } else if (yVal === Infinity) {
+ // +inf breaks the line
+ if (currentSegmentX.length > 0) {
+ segments.push({ x: currentSegmentX, y: currentSegmentY });
+ currentSegmentX = [];
+ currentSegmentY = [];
+ }
+ infX.push(xVal);
+ console.log(
+ `[LinePlot] ${series.name}[${i}]: +inf at x=${xVal} - breaking line`,
+ );
+ } else if (yVal === -Infinity) {
+ // -inf breaks the line
+ if (currentSegmentX.length > 0) {
+ segments.push({ x: currentSegmentX, y: currentSegmentY });
+ currentSegmentX = [];
+ currentSegmentY = [];
+ }
+ negInfX.push(xVal);
+ console.log(
+ `[LinePlot] ${series.name}[${i}]: -inf at x=${xVal} - breaking line`,
+ );
+ } else {
+ // Normal finite value - add to current segment
+ currentSegmentX.push(xVal);
+ currentSegmentY.push(yVal);
+ }
+ }
+
+ // Don't forget the last segment
+ if (currentSegmentX.length > 0) {
+ segments.push({ x: currentSegmentX, y: currentSegmentY });
+ }
+
+ console.log(
+ `[LinePlot] ${series.name}: ${segments.length} segments, NaN=${nanX.length}, +inf=${infX.length}, -inf=${negInfX.length}`,
+ );
+
+ // Process each segment separately (smoothing + downsampling)
+ const processedSegments = segments.map((seg) => {
+ const smoothedY = applySmoothing(seg.y, mode, value);
+ return downsampleData(seg.x, smoothedY, rate);
+ });
const result = {
name: series.name,
- x: downsampledSmoothed.x,
- y: downsampledSmoothed.y,
+ segments: processedSegments, // Multiple line segments
original: null,
- originalY: null,
+ originalSegments: null,
+ // Store special value x-positions for marker rendering
+ nanX,
+ infX,
+ negInfX,
};
if (mode !== "disabled" && config.showOriginal) {
- const downsampledOriginal = downsampleData(series.x, series.y, rate);
- result.original = {
- x: downsampledOriginal.x,
- y: downsampledOriginal.y,
- };
- result.originalY = downsampledOriginal.y;
+ const originalSegments = segments.map((seg) =>
+ downsampleData(seg.x, seg.y, rate),
+ );
+ result.originalSegments = originalSegments;
}
return result;
@@ -341,9 +412,14 @@ function createPlot() {
const traces = [];
data.forEach((series, index) => {
+ console.log(
+ `[LinePlot] createPlot - processing series ${series.name}: ${series.segments?.length || 0} segments, nanX=${series.nanX?.length || 0}, infX=${series.infX?.length || 0}, negInfX=${series.negInfX?.length || 0}`,
+ );
+
const baseColor = plotlyColors[index % plotlyColors.length];
- if (series.original) {
+ // Render original (smoothed) segments if enabled
+ if (series.originalSegments && series.originalSegments.length > 0) {
// Convert to dimmer color (not opacity) - blend with gray
let dimmerColor = baseColor;
if (baseColor.startsWith("#")) {
@@ -358,68 +434,155 @@ function createPlot() {
dimmerColor = `rgb(${dimR}, ${dimG}, ${dimB})`;
}
- traces.push({
- type: "scattergl",
- mode: "lines",
- name: `${series.name} (original)`,
- x: series.original.x,
- y: series.original.y,
- line: {
- width: config.lineWidth * 0.5,
- color: dimmerColor,
- },
- showlegend: false,
- legendgroup: series.name,
- hoverinfo: "skip",
+ // Add each original segment as a separate trace
+ series.originalSegments.forEach((seg, segIdx) => {
+ traces.push({
+ type: "scattergl",
+ mode: "lines",
+ name: `${series.name} (original)`,
+ x: seg.x,
+ y: seg.y,
+ line: {
+ width: config.lineWidth * 0.5,
+ color: dimmerColor,
+ },
+ showlegend: segIdx === 0, // Only first segment shows in legend
+ legendgroup: series.name + "_original",
+ hoverinfo: "skip",
+ });
});
}
- const trace = {
- type: "scattergl",
- mode: config.showMarkers ? "lines+markers" : "lines",
- name: series.name,
- x: series.x,
- y: series.y,
- line: {
- width: config.lineWidth,
- color: baseColor,
- },
- marker: {
- size: 3,
- color: baseColor,
- },
- legendgroup: series.name,
- };
+ // Render main line segments
+ if (series.segments && series.segments.length > 0) {
+ series.segments.forEach((seg, segIdx) => {
+ const trace = {
+ type: "scattergl",
+ mode: config.showMarkers ? "lines+markers" : "lines",
+ name: series.name,
+ x: seg.x,
+ y: seg.y,
+ line: {
+ width: config.lineWidth,
+ color: baseColor,
+ },
+ marker: {
+ size: 3,
+ color: baseColor,
+ },
+ showlegend: segIdx === 0, // Only first segment shows in legend
+ legendgroup: series.name,
+ };
- // Custom hover template for relative_walltime
- if (props.xaxis === "relative_walltime") {
- trace.customdata = series.x.map((xVal, idx) => ({
- duration: formatDuration(xVal),
- original: series.originalY ? series.originalY[idx] : null,
- }));
- if (series.originalY) {
- trace.hovertemplate =
- "%{fullData.name}
" +
- "Time: %{customdata.duration}
" +
- "Value: %{y:.4f} (%{customdata.original:.4f})
" +
- "Time: %{customdata.duration}
" +
- "Value: %{y:.4f}
" +
+ "Time: %{customdata.duration}
" +
+ "Value: %{y:.4f}
- Project: {{ route.params.project }} -
-- Project: {{ route.params.project }} -
-