| <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> |
|
|