hover sync, NaN/inf handling, better layout

This commit is contained in:
Kohaku-Blueleaf
2025-10-27 17:26:45 +08:00
parent e89abeaae4
commit b62a0fd8db
6 changed files with 408 additions and 143 deletions

View File

@@ -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']

View File

@@ -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);

View File

@@ -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 =
"<b>%{fullData.name}</b><br>" +
"Time: %{customdata.duration}<br>" +
"Value: %{y:.4f} (%{customdata.original:.4f})<extra></extra>";
} else {
trace.hovertemplate =
"<b>%{fullData.name}</b><br>" +
"Time: %{customdata.duration}<br>" +
"Value: %{y:.4f}<extra></extra>";
}
} else if (series.originalY) {
trace.customdata = series.originalY.map((origY, idx) => ({
original: origY,
smoothed: series.y[idx],
}));
trace.hovertemplate =
"<b>%{fullData.name}</b>: %{y:.4f} (%{customdata.original:.4f})<extra></extra>";
} else {
trace.hovertemplate = "<b>%{fullData.name}</b>: %{y:.4f}<extra></extra>";
// Custom hover template for relative_walltime
if (props.xaxis === "relative_walltime") {
trace.customdata = seg.x.map((xVal, idx) => ({
duration: formatDuration(xVal),
}));
trace.hovertemplate =
"<b>%{fullData.name}</b><br>" +
"Time: %{customdata.duration}<br>" +
"Value: %{y:.4f}<extra></extra>";
} else {
trace.hovertemplate =
"<b>%{fullData.name}</b>: %{y:.4f}<extra></extra>";
}
traces.push(trace);
});
}
traces.push(trace);
// Add special marker traces for NaN/inf values
// These render as dots at top/bottom without affecting axis range
const hasSpecialValues =
series.nanX.length > 0 ||
series.infX.length > 0 ||
series.negInfX.length > 0;
console.log(
`[LinePlot] ${series.name} special values: NaN=${series.nanX.length}, +inf=${series.infX.length}, -inf=${series.negInfX.length}`,
);
if (hasSpecialValues) {
// NaN markers (circle at top of chart)
if (series.nanX.length > 0) {
console.log(
`[LinePlot] Adding NaN markers for ${series.name}:`,
series.nanX,
);
traces.push({
type: "scattergl",
mode: "markers",
name: `${series.name} (NaN)`,
x: series.nanX,
y: series.nanX.map(() => 1), // Will be positioned at top via yaxis2
marker: {
symbol: "circle",
size: 8,
color: baseColor,
line: { color: "white", width: 1.5 },
},
yaxis: "y2", // Use secondary y-axis fixed at [0, 1]
showlegend: false,
legendgroup: series.name,
hoverinfo: "skip", // CRITICAL: Don't show in unified hover tooltip
// Individual hover still works when directly hovering the marker
text: series.nanX.map(() => `${series.name}: NaN`),
});
}
// +Infinity markers (triangle-up at top of chart)
if (series.infX.length > 0) {
console.log(
`[LinePlot] Adding +inf markers for ${series.name}:`,
series.infX,
);
traces.push({
type: "scattergl",
mode: "markers",
name: `${series.name} (+inf)`,
x: series.infX,
y: series.infX.map(() => 0.95), // Slightly below top
marker: {
symbol: "triangle-up",
size: 10,
color: baseColor,
line: { color: "white", width: 1.5 },
},
yaxis: "y2",
showlegend: false,
legendgroup: series.name,
hoverinfo: "skip", // CRITICAL: Don't show in unified hover tooltip
text: series.infX.map(() => `${series.name}: +inf`),
});
}
// -Infinity markers (triangle-down at bottom of chart)
if (series.negInfX.length > 0) {
console.log(
`[LinePlot] Adding -inf markers for ${series.name}:`,
series.negInfX,
);
traces.push({
type: "scattergl",
mode: "markers",
name: `${series.name} (-inf)`,
x: series.negInfX,
y: series.negInfX.map(() => 0.05), // Slightly above bottom
marker: {
symbol: "triangle-down",
size: 10,
color: baseColor,
line: { color: "white", width: 1.5 },
},
yaxis: "y2",
showlegend: false,
legendgroup: series.name,
hoverinfo: "skip", // CRITICAL: Don't show in unified hover tooltip
text: series.negInfX.map(() => `${series.name}: -inf`),
});
}
}
});
const xAxisConfig = config.xRange.auto
@@ -457,6 +620,16 @@ function createPlot() {
showline: false,
...yAxisConfig,
},
// Secondary y-axis for NaN/inf markers (fixed [0,1] range, overlaid)
yaxis2: {
overlaying: "y",
side: "right",
showgrid: false,
showticklabels: false,
showline: false,
range: [0, 1],
fixedrange: true,
},
dragmode: "zoom",
height: props.height,
paper_bgcolor: "rgba(0,0,0,0)",

View File

@@ -16,14 +16,21 @@
KohakuBoard
</h1>
</router-link>
<div class="flex items-center gap-1">
<router-link
to="/"
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400"
<!-- Dynamic breadcrumb navigation -->
<el-breadcrumb separator="/" class="text-sm">
<el-breadcrumb-item :to="{ path: '/projects' }"
>Projects</el-breadcrumb-item
>
Projects
</router-link>
</div>
<el-breadcrumb-item
v-if="currentProject"
:to="{ path: `/projects/${currentProject}` }"
>
{{ currentProject }}
</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentRun">
{{ currentRun }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="flex items-center gap-2">
<!-- Auth UI (only in remote mode) -->
@@ -99,8 +106,13 @@ import { ElMessage } from "element-plus";
const { animationsEnabled, toggleAnimations } = useAnimationPreference();
const authStore = useAuthStore();
const router = useRouter();
const route = useRoute();
const systemInfo = ref(null);
// Extract current project and run from route for breadcrumb
const currentProject = computed(() => route.params.project || null);
const currentRun = computed(() => route.params.id || null);
const props = defineProps({
darkMode: {
type: Boolean,

View File

@@ -296,7 +296,7 @@ export function useHoverSync() {
const xRange = xaxis.range || [0, 1];
const yRange = yaxis.range || [0, 1];
// Create vertical spike line shape at xValue
// Create vertical spike line (x-axis spike only)
const spikeShape = {
type: "line",
x0: xValue,
@@ -327,7 +327,7 @@ export function useHoverSync() {
});
console.log(
`${LOG_PREFIX} Drew spike line at x=${xValue} for chart:`,
`${LOG_PREFIX} Drew x-axis spike line at x=${xValue} for chart:`,
chartInfo.id,
);
} else {

View File

@@ -18,6 +18,7 @@ const isUpdating = ref(false);
const showAddTabDialog = ref(false);
const newTabName = ref("");
const showGlobalSettings = ref(false);
const isInitializing = ref(true); // Prevent watch triggers during init
// Pagination for WebGL context limit
const currentPage = ref(0);
@@ -51,11 +52,19 @@ const storageKey = computed(
() => `experiment-layout-${route.params.project}-${route.params.id}`,
);
onMounted(async () => {
// Extracted initialization logic for reuse
async function initializeExperiment() {
try {
isInitializing.value = true;
const projectName = route.params.project;
const runId = route.params.id;
console.log(`[Init] Loading experiment: ${projectName}/${runId}`);
// Clear previous data
metricDataCache.value = {};
// Fetch summary using runs API
const summaryResponse = await fetch(
`/api/projects/${projectName}/runs/${runId}/summary`,
@@ -298,9 +307,18 @@ onMounted(async () => {
// Determine default x-axis (prefer global_step if it's used, otherwise step)
await determineDefaultXAxis();
// Initialization complete - allow watch to fire on user tab changes
isInitializing.value = false;
console.log("[Init] Initialization complete");
} catch (error) {
console.error("Failed to load experiment:", error);
isInitializing.value = false;
}
}
onMounted(() => {
initializeExperiment();
});
async function determineDefaultXAxis() {
@@ -313,9 +331,10 @@ async function determineDefaultXAxis() {
);
const result = await response.json();
// New columnar format: {values: []}
// Check if any global_step value is non-zero
const hasNonZeroGlobalStep = result.data.some(
(item) => item.value !== 0 && item.value !== null,
const hasNonZeroGlobalStep = result.values.some(
(value) => value !== 0 && value !== null,
);
// Update all cards to use global_step if it's being used
@@ -338,8 +357,14 @@ async function determineDefaultXAxis() {
async function fetchMetricsForTab() {
try {
console.log(
`[fetchMetricsForTab] Starting fetch for tab: ${activeTab.value}`,
);
const tab = tabs.value.find((t) => t.name === activeTab.value);
if (!tab) return;
if (!tab) {
console.warn(`[fetchMetricsForTab] Tab not found: ${activeTab.value}`);
return;
}
// Metrics that are computed on frontend (don't fetch from API)
const computedMetrics = new Set(["walltime", "relative_walltime"]);
@@ -368,35 +393,65 @@ async function fetchMetricsForTab() {
for (const metric of neededMetrics) {
if (!metricDataCache.value[metric]) {
try {
console.log(`[fetchMetricsForTab] Fetching metric: ${metric}`);
// Don't URL-encode - FastAPI :path parameter handles it
const response = await fetch(
`/api/projects/${projectName}/runs/${runId}/scalars/${metric}`,
);
if (!response.ok) {
console.warn(`Failed to fetch ${metric}: ${response.status}`);
console.warn(
`[fetchMetricsForTab] Failed to fetch ${metric}: ${response.status}`,
);
// Set empty array so card can still render (just shows "no data")
metricDataCache.value[metric] = [];
continue;
}
const result = await response.json();
console.log(
`[fetchMetricsForTab] Fetched ${metric}: ${result.values?.length || 0} values`,
);
// Convert step-value pairs to sparse array
const sparseArray = new Array(
result.data[result.data.length - 1]?.step + 1 || 0,
).fill(null);
for (const item of result.data) {
sparseArray[item.step] = item.value;
// New columnar format: {steps: [], global_steps: [], timestamps: [], values: []}
// Convert to sparse array (indexed by step number)
const maxStep = Math.max(...result.steps);
const sparseArray = new Array(maxStep + 1).fill(null);
for (let i = 0; i < result.steps.length; i++) {
const step = result.steps[i];
let value = result.values[i];
// Convert special string markers back to numeric NaN/inf
// Backend sends these as strings because JSON doesn't support Infinity
if (value === "NaN") {
value = NaN;
} else if (value === "Infinity") {
value = Infinity;
} else if (value === "-Infinity") {
value = -Infinity;
}
sparseArray[step] = value;
}
metricDataCache.value[metric] = sparseArray;
console.log(
`[fetchMetricsForTab] Stored ${metric} in cache: ${sparseArray.length} slots, ${sparseArray.filter((v) => v !== null).length} non-null values`,
);
// Also store timestamp data if available
if (result.data[0]?.timestamp) {
// Also store timestamp data if available (as Unix seconds)
if (result.timestamps && result.timestamps.length > 0) {
const timestampArray = new Array(sparseArray.length).fill(null);
for (const item of result.data) {
timestampArray[item.step] = item.timestamp;
for (let i = 0; i < result.steps.length; i++) {
const step = result.steps[i];
const unixSeconds = result.timestamps[i];
if (unixSeconds !== null) {
// Convert Unix seconds to ISO string for consistency
timestampArray[step] = new Date(
unixSeconds * 1000,
).toISOString();
}
}
metricDataCache.value[`${metric}_timestamp`] = timestampArray;
}
@@ -435,8 +490,13 @@ async function fetchMetricsForTab() {
metricDataCache.value.walltime = walltime;
metricDataCache.value.relative_walltime = relativeWalltime;
}
console.log(
`[fetchMetricsForTab] Completed. Cache keys:`,
Object.keys(metricDataCache.value),
);
} catch (error) {
console.error("Error in fetchMetricsForTab:", error);
console.error("[fetchMetricsForTab] Error:", error);
// Don't throw - allow page to render with partial data
}
}
@@ -446,6 +506,7 @@ const sparseData = computed(() => {
for (const [key, values] of Object.entries(metricDataCache.value)) {
data[key] = values;
}
console.log(`[sparseData] Computed with ${Object.keys(data).length} metrics`);
return data;
});
@@ -509,11 +570,30 @@ const visibleCards = computed(() => {
});
watch(activeTab, () => {
// Don't fetch during initialization (prevents race condition)
if (isInitializing.value) {
console.log("[Watch] activeTab changed during init, skipping fetch");
return;
}
console.log("[Watch] activeTab changed to:", activeTab.value);
currentPage.value = 0;
fetchMetricsForTab();
saveLayout();
});
// Watch route params to reinitialize when navigating between runs
watch(
() => [route.params.project, route.params.id],
([newProject, newId], [oldProject, oldId]) => {
if (oldId && (newId !== oldId || newProject !== oldProject)) {
console.log("[Watch] Route changed - reinitializing");
currentPage.value = 0;
initializeExperiment();
}
},
);
function saveLayout() {
const layout = {
tabs: tabs.value,
@@ -833,54 +913,19 @@ function onDragEnd(evt) {
<template>
<div class="container-main">
<div class="mb-6">
<!-- Desktop layout -->
<div v-if="!isMobile" class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Run: {{ route.params.id }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Project: {{ route.params.project }}
</p>
</div>
<div class="flex gap-2">
<router-link
:to="`/projects/${route.params.project}`"
class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm"
>
Back
</router-link>
<el-button type="primary" size="small" @click="addCard"
>Add Chart</el-button
>
<el-button size="small" @click="addTab">Add Tab</el-button>
<el-button size="small" @click="isEditingTabs = !isEditingTabs">
{{ isEditingTabs ? "Done Editing" : "Edit Tabs" }}
</el-button>
</div>
</div>
<!-- Mobile layout -->
<div v-else>
<h1 class="text-xl font-bold mb-1">Run: {{ route.params.id }}</h1>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Project: {{ route.params.project }}
</p>
<div class="flex gap-2">
<router-link
:to="`/projects/${route.params.project}`"
class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm"
>
Back
</router-link>
<el-button type="primary" size="small" @click="addCard" class="flex-1"
>Add Chart</el-button
>
<el-button size="small" @click="addTab">Add Tab</el-button>
<el-button size="small" @click="isEditingTabs = !isEditingTabs">
{{ isEditingTabs ? "Done" : "Edit" }}
</el-button>
</div>
</div>
<!-- Action buttons -->
<div class="mb-6 flex items-center justify-end gap-2">
<el-button type="primary" size="small" @click="addCard">
<span v-if="!isMobile">Add Chart</span>
<span v-else>Add</span>
</el-button>
<el-button size="small" @click="addTab">
<span v-if="!isMobile">Add Tab</span>
<span v-else>Tab</span>
</el-button>
<el-button size="small" @click="isEditingTabs = !isEditingTabs">
{{ isEditingTabs ? "Done" : "Edit" }}
</el-button>
</div>
<el-tabs v-model="activeTab" type="card">