|
|
<script> |
|
|
import { onMount, onDestroy } from "svelte"; |
|
|
import * as d3 from "d3"; |
|
|
import { SVGManager } from "./core/svg-manager.js"; |
|
|
import { GridRenderer } from "./core/grid-renderer.js"; |
|
|
import { PathRenderer } from "./core/path-renderer.js"; |
|
|
import { InteractionManager } from "./core/interaction-manager.js"; |
|
|
import { ZoomManager } from "./core/zoom-manager.js"; |
|
|
import { ChartTransforms } from "./utils/chart-transforms.js"; |
|
|
import { trackioSampler } from "../core/adaptive-sampler.js"; |
|
|
|
|
|
|
|
|
export let metricData = {}; |
|
|
export let rawMetricData = {}; |
|
|
export let colorForRun = (name) => "#999"; |
|
|
export let variant = "classic"; |
|
|
export let logScaleX = false; |
|
|
export let smoothing = false; |
|
|
export let normalizeLoss = true; |
|
|
export let metricKey = ""; |
|
|
export let titleText = ""; |
|
|
export let hostEl = null; |
|
|
export let width = 800; |
|
|
export let height = 150; |
|
|
export let margin = { top: 10, right: 12, bottom: 46, left: 44 }; |
|
|
export let onHover = null; |
|
|
export let onLeave = null; |
|
|
export let enableZoom = true; |
|
|
export let onZoomChange = null; |
|
|
|
|
|
|
|
|
let container; |
|
|
let svgManager; |
|
|
let gridRenderer; |
|
|
let pathRenderer; |
|
|
let interactionManager; |
|
|
let zoomManager; |
|
|
let cleanup; |
|
|
|
|
|
|
|
|
let sampledData = {}; |
|
|
let samplingInfo = {}; |
|
|
let needsSampling = false; |
|
|
|
|
|
|
|
|
$: innerHeight = height - margin.top - margin.bottom; |
|
|
|
|
|
|
|
|
$: { |
|
|
if (container && svgManager) { |
|
|
// List all dependencies to trigger render when any change |
|
|
void metricData; |
|
|
void metricKey; |
|
|
void variant; |
|
|
void logScaleX; |
|
|
void normalizeLoss; |
|
|
void smoothing; |
|
|
render(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function initializeManagers() { |
|
|
if (!container) return; |
|
|
|
|
|
// Create SVG manager with configuration |
|
|
svgManager = new SVGManager(container, { width, height, margin }); |
|
|
svgManager.ensureSvg(); |
|
|
svgManager.initializeScales(logScaleX); |
|
|
|
|
|
|
|
|
gridRenderer = new GridRenderer(svgManager); |
|
|
pathRenderer = new PathRenderer(svgManager); |
|
|
interactionManager = new InteractionManager(svgManager, pathRenderer); |
|
|
|
|
|
|
|
|
if (enableZoom) { |
|
|
zoomManager = new ZoomManager(svgManager, { |
|
|
zoomExtent: [1.0, 8.0], |
|
|
enableX: true, |
|
|
enableY: true, |
|
|
}); |
|
|
|
|
|
zoomManager.initialize(); |
|
|
|
|
|
|
|
|
interactionManager.setExternalOverlay(zoomManager.getOverlay()); |
|
|
|
|
|
|
|
|
zoomManager.on("zoom", ({ xScale, yScale, hasMoved }) => { |
|
|
renderWithZoomedScales(xScale, yScale); |
|
|
|
|
|
if (onZoomChange) { |
|
|
onZoomChange({ hasMoved, state: zoomManager.getState() }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
zoomManager.on("zoomStart", () => { |
|
|
if (interactionManager) { |
|
|
interactionManager.hideHoverLine(); |
|
|
} |
|
|
if (onLeave) { |
|
|
onLeave(); |
|
|
} |
|
|
}); |
|
|
|
|
|
console.log("🔍 ZoomManager initialized"); |
|
|
} |
|
|
|
|
|
console.log("📊 Chart managers initialized"); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function applySampling() { |
|
|
// Check if any run has more than 400 points |
|
|
const runSizes = Object.keys(metricData).map( |
|
|
(run) => (metricData[run] || []).length, |
|
|
); |
|
|
const maxSize = Math.max(0, ...runSizes); |
|
|
needsSampling = maxSize > 400; |
|
|
|
|
|
if (needsSampling) { |
|
|
console.log( |
|
|
`🎯 Large dataset detected (${maxSize} points), applying adaptive sampling`, |
|
|
); |
|
|
const result = trackioSampler.sampleMetricData(metricData, "smart"); |
|
|
sampledData = result.sampledData; |
|
|
samplingInfo = result.samplingInfo; |
|
|
|
|
|
// Log sampling stats |
|
|
Object.keys(samplingInfo).forEach((run) => { |
|
|
const info = samplingInfo[run]; |
|
|
console.log( |
|
|
`📊 ${run}: ${info.originalLength} → ${info.sampledLength} points (${(info.compressionRatio * 100).toFixed(1)}% retained)`, |
|
|
); |
|
|
}); |
|
|
} else { |
|
|
sampledData = metricData; |
|
|
samplingInfo = {}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function renderWithZoomedScales(zoomedXScale, zoomedYScale) { |
|
|
if (!svgManager || !gridRenderer || !pathRenderer) return; |
|
|
|
|
|
const dataToRender = needsSampling ? sampledData : metricData; |
|
|
const cleanedData = ChartTransforms.validateData(dataToRender); |
|
|
const processedData = ChartTransforms.processMetricData( |
|
|
cleanedData, |
|
|
metricKey, |
|
|
normalizeLoss, |
|
|
); |
|
|
|
|
|
if (!processedData.hasData) return; |
|
|
|
|
|
const { stepIndex } = ChartTransforms.setupScales( |
|
|
svgManager, |
|
|
processedData, |
|
|
logScaleX, |
|
|
); |
|
|
const normalizeY = ChartTransforms.createNormalizeFunction( |
|
|
processedData, |
|
|
normalizeLoss, |
|
|
); |
|
|
|
|
|
|
|
|
const { x: originalXScale, y: originalYScale } = svgManager.getScales(); |
|
|
const { innerWidth } = svgManager.calculateDimensions(); |
|
|
|
|
|
|
|
|
const { axes: gAxes, grid: gGrid } = svgManager.getGroups(); |
|
|
const xTicksForced = zoomedXScale.ticks(Math.min(6, 10)); |
|
|
const yTicksForced = zoomedYScale.ticks(Math.min(6, 10)); |
|
|
|
|
|
|
|
|
gGrid |
|
|
.selectAll("line") |
|
|
.data(yTicksForced) |
|
|
.join("line") |
|
|
.attr("x1", 0) |
|
|
.attr("x2", innerWidth) |
|
|
.attr("y1", (d) => zoomedYScale(d)) |
|
|
.attr("y2", (d) => zoomedYScale(d)) |
|
|
.attr("stroke", "var(--trackio-chart-grid-stroke)") |
|
|
.attr("stroke-opacity", "var(--trackio-chart-grid-opacity)"); |
|
|
|
|
|
|
|
|
const formatAbbrev = (v) => { |
|
|
if (Math.abs(v) >= 1e9) return (v / 1e9).toFixed(1) + "B"; |
|
|
if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(1) + "M"; |
|
|
if (Math.abs(v) >= 1e3) return (v / 1e3).toFixed(1) + "k"; |
|
|
return v.toFixed(2); |
|
|
}; |
|
|
|
|
|
gAxes |
|
|
.select(".x-axis") |
|
|
.call( |
|
|
d3 |
|
|
.axisBottom(zoomedXScale) |
|
|
.tickValues(xTicksForced) |
|
|
.tickFormat(formatAbbrev), |
|
|
); |
|
|
|
|
|
gAxes |
|
|
.select(".y-axis") |
|
|
.call( |
|
|
d3 |
|
|
.axisLeft(zoomedYScale) |
|
|
.tickValues(yTicksForced) |
|
|
.tickFormat(formatAbbrev), |
|
|
); |
|
|
|
|
|
|
|
|
pathRenderer.renderSeriesWithCustomScales( |
|
|
processedData.runs, |
|
|
cleanedData, |
|
|
rawMetricData, |
|
|
colorForRun, |
|
|
smoothing, |
|
|
logScaleX, |
|
|
stepIndex, |
|
|
normalizeY, |
|
|
zoomedXScale, |
|
|
zoomedYScale, |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function render() { |
|
|
if (!svgManager) return; |
|
|
|
|
|
// Apply sampling if needed |
|
|
applySampling(); |
|
|
|
|
|
// Use sampled data for rendering |
|
|
const dataToRender = needsSampling ? sampledData : metricData; |
|
|
|
|
|
// Validate and clean data |
|
|
const cleanedData = ChartTransforms.validateData(dataToRender); |
|
|
const processedData = ChartTransforms.processMetricData( |
|
|
cleanedData, |
|
|
metricKey, |
|
|
normalizeLoss, |
|
|
); |
|
|
|
|
|
if (!processedData.hasData) { |
|
|
const { root } = svgManager.getGroups(); |
|
|
root.style("display", "none"); |
|
|
return; |
|
|
} |
|
|
|
|
|
const { root } = svgManager.getGroups(); |
|
|
root.style("display", null); |
|
|
|
|
|
|
|
|
svgManager.initializeScales(logScaleX); |
|
|
|
|
|
|
|
|
const { stepIndex } = ChartTransforms.setupScales( |
|
|
svgManager, |
|
|
processedData, |
|
|
logScaleX, |
|
|
); |
|
|
const normalizeY = ChartTransforms.createNormalizeFunction( |
|
|
processedData, |
|
|
normalizeLoss, |
|
|
); |
|
|
|
|
|
|
|
|
const { line: lineGen, y: yScale } = svgManager.getScales(); |
|
|
lineGen.y((d) => yScale(normalizeY(d.value))); |
|
|
|
|
|
|
|
|
const { innerWidth, xTicksForced, yTicksForced } = svgManager.updateLayout( |
|
|
processedData.hoverSteps, |
|
|
logScaleX, |
|
|
); |
|
|
|
|
|
|
|
|
if (zoomManager) { |
|
|
const { innerHeight } = svgManager.calculateDimensions(); |
|
|
zoomManager.updateLayout(innerWidth, innerHeight); |
|
|
} |
|
|
|
|
|
|
|
|
gridRenderer.renderGrid( |
|
|
xTicksForced, |
|
|
yTicksForced, |
|
|
processedData.hoverSteps, |
|
|
variant, |
|
|
); |
|
|
|
|
|
|
|
|
pathRenderer.renderSeries( |
|
|
processedData.runs, |
|
|
cleanedData, |
|
|
rawMetricData, |
|
|
colorForRun, |
|
|
smoothing, |
|
|
logScaleX, |
|
|
stepIndex, |
|
|
normalizeY, |
|
|
); |
|
|
|
|
|
|
|
|
interactionManager.setupHoverInteractions( |
|
|
processedData.hoverSteps, |
|
|
stepIndex, |
|
|
processedData.runs.map((r) => ({ |
|
|
run: r, |
|
|
color: colorForRun(r), |
|
|
values: (cleanedData[r] || []).slice().sort((a, b) => a.step - b.step), |
|
|
})), |
|
|
normalizeY, |
|
|
processedData.isAccuracy, |
|
|
innerWidth, |
|
|
logScaleX, |
|
|
onHover, |
|
|
onLeave, |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function showHoverLine(step) { |
|
|
if (!interactionManager) return; |
|
|
|
|
|
// Use sampled data for interactions as well |
|
|
const dataToRender = needsSampling ? sampledData : metricData; |
|
|
const cleanedData = ChartTransforms.validateData(dataToRender); |
|
|
const processedData = ChartTransforms.processMetricData( |
|
|
cleanedData, |
|
|
metricKey, |
|
|
normalizeLoss, |
|
|
); |
|
|
const { stepIndex } = ChartTransforms.setupScales( |
|
|
svgManager, |
|
|
processedData, |
|
|
logScaleX, |
|
|
); |
|
|
|
|
|
interactionManager.showHoverLine( |
|
|
step, |
|
|
processedData.hoverSteps, |
|
|
stepIndex, |
|
|
logScaleX, |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function hideHoverLine() { |
|
|
if (interactionManager) { |
|
|
interactionManager.hideHoverLine(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function resetZoom(animated = true) { |
|
|
if (zoomManager) { |
|
|
zoomManager.reset(animated); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getZoomState() { |
|
|
return zoomManager ? zoomManager.getState() : null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
onMount(() => { |
|
|
initializeManagers(); |
|
|
render(); |
|
|
|
|
|
// Debounced resize handling for better mobile performance |
|
|
let resizeTimeout; |
|
|
const debouncedRender = () => { |
|
|
if (resizeTimeout) clearTimeout(resizeTimeout); |
|
|
resizeTimeout = setTimeout(() => { |
|
|
render(); |
|
|
}, 100); |
|
|
}; |
|
|
|
|
|
const ro = window.ResizeObserver |
|
|
? new ResizeObserver(debouncedRender) |
|
|
: null; |
|
|
if (ro && container) ro.observe(container); |
|
|
|
|
|
|
|
|
const handleOrientationChange = () => { |
|
|
setTimeout(() => { |
|
|
render(); |
|
|
}, 300); |
|
|
}; |
|
|
|
|
|
window.addEventListener("orientationchange", handleOrientationChange); |
|
|
window.addEventListener("resize", debouncedRender); |
|
|
|
|
|
cleanup = () => { |
|
|
if (ro) ro.disconnect(); |
|
|
if (resizeTimeout) clearTimeout(resizeTimeout); |
|
|
window.removeEventListener("orientationchange", handleOrientationChange); |
|
|
window.removeEventListener("resize", debouncedRender); |
|
|
if (svgManager) svgManager.destroy(); |
|
|
if (interactionManager) interactionManager.destroy(); |
|
|
if (zoomManager) zoomManager.destroy(); |
|
|
}; |
|
|
}); |
|
|
|
|
|
onDestroy(() => { |
|
|
cleanup && cleanup(); |
|
|
}); |
|
|
</script> |
|
|
|
|
|
<div |
|
|
bind:this={container} |
|
|
style="width: 100%; height: 100%; min-width: 200px; overflow: hidden;" |
|
|
></div> |
|
|
|