| <script> |
| import * as d3 from 'd3'; |
| import { onMount, onDestroy } from 'svelte'; |
| import Tooltip from './Tooltip.svelte'; |
| import { formatAbbrev, formatLogTick, generateSmartTicks, generateLogTicks } from './chart-utils.js'; |
| export let metricKey; |
| export let titleText; |
| export let wide = false; |
| export let variant = 'classic'; |
| export let normalizeLoss = true; |
| export let logScaleX = false; |
| export let smoothing = false; |
| export let metricData = {}; { run -> [{step,value}] } - smoothed data |
| export let rawMetricData = {}; { run -> [{step,value}] } - original data for background when smoothing |
| export let colorForRun = (name)=> '#999'; |
| export let hostEl = null; |
| |
| let root; let body; |
| let svg, gRoot, gGrid, gGridDots, gAxes, gAreas, gLines, gPoints, gHover; |
| let xScale, yScale, lineGen; |
| let tooltip, tipTarget; |
| let cleanup; |
| const MARGIN = { top: 10, right: 20, bottom: 46, left: 44 }; |
| |
| |
| $: { |
| if (variant || metricData) { |
| // Add a small delay to ensure CSS variables are updated |
| setTimeout(() => render(), 10); |
| } |
| } |
| |
| |
| function getTransitionDuration(normalDuration = 160) { |
| const fullscreenOverlay = root?.closest('.trackio-fullscreen-overlay'); |
| return (fullscreenOverlay && fullscreenOverlay.classList.contains('transitioning')) ? 0 : normalDuration; |
| } |
| |
| function ensureSvg(){ |
| if (svg || !body) return; |
| const d3body = d3.select(body); |
| svg = d3body.append('svg').attr('width','100%').style('display','block'); |
| gRoot = svg.append('g'); |
| gGrid = gRoot.append('g').attr('class','grid'); |
| gGridDots = gRoot.append('g').attr('class','grid-dots'); |
| gAxes = gRoot.append('g').attr('class','axes'); |
| gAreas = gRoot.append('g').attr('class','areas'); |
| gLines = gRoot.append('g').attr('class','lines'); |
| gPoints = gRoot.append('g').attr('class','points'); |
| gHover = gRoot.append('g').attr('class','hover'); |
| // Initialize scales - X scale will be updated based on logScaleX prop |
| xScale = logScaleX ? d3.scaleLog() : d3.scaleLinear(); |
| yScale = d3.scaleLinear(); |
| lineGen = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value)); |
| // Create tooltip container in the trackio parent to inherit CSS variables |
| const trackioEl = root.closest('.trackio') || root; |
| tipTarget = document.createElement('div'); |
| tipTarget.className = 'tip-host'; |
| trackioEl.appendChild(tipTarget); |
| tooltip = new Tooltip({ target: tipTarget, props: { visible:false, x:-9999, y:-9999, title:'', subtitle:'', entries:[] } }); |
| } |
| |
| |
| function updateLayout(hoverSteps, themeVars = {}){ |
| const { axisStroke = 'var(--trackio-chart-axis-stroke)', axisText = 'var(--trackio-chart-axis-text)', gridStroke = 'var(--trackio-chart-grid-stroke)' } = themeVars; |
| |
| |
| const computedStyle = getComputedStyle(root); |
| const fontFamily = computedStyle.getPropertyValue('--trackio-font-family').trim() || 'ui-monospace, SFMono-Regular, Menlo, monospace'; |
| const rect = root.getBoundingClientRect(); |
| let width = Math.max(1, Math.round(rect && rect.width ? rect.width : (root.clientWidth || 800))); |
| |
| |
| const isFullscreen = root.closest('.trackio-fullscreen-modal'); |
| let height; |
| |
| if (isFullscreen) { |
| // Use available height minus header in fullscreen |
| // Use the body element's actual size |
| const bodyElement = body.parentElement; // cell-body |
| if (bodyElement) { |
| const bodyRect = bodyElement.getBoundingClientRect(); |
| height = Math.max(400, Math.floor(bodyRect.height - 20)); // Small margin |
| } else { |
| height = 600; // Fallback |
| } |
| } else { |
| height = Number(root.getAttribute('data-height')) || 180; |
| } |
| if (isFullscreen) { |
| // In fullscreen, stretch to fill completely (no aspect ratio preservation) |
| svg.attr('width', '100%').attr('height', '100%').attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio','none'); |
| } else { |
| // Normal mode, preserve aspect ratio |
| svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio','xMidYMid meet'); |
| } |
| const innerWidth = width - MARGIN.left - MARGIN.right; const innerHeight = height - MARGIN.top - MARGIN.bottom; |
| gRoot.attr('transform', `translate(${MARGIN.left},${MARGIN.top})`); |
| xScale.range([0, innerWidth]); yScale.range([innerHeight, 0]); |
| |
| gAxes.selectAll('*').remove(); |
| |
| const minXTicks = 5; |
| const maxXTicks = Math.max(minXTicks, Math.min(12, Math.floor(innerWidth / 70))); |
| let xTicksForced = []; |
| |
| let logTickData = null; |
| if (logScaleX) { |
| // Use improved logarithmic tick generation |
| logTickData = generateLogTicks(hoverSteps, minXTicks, maxXTicks, innerWidth, xScale); |
| xTicksForced = logTickData.major; |
| } else if (Array.isArray(hoverSteps) && hoverSteps.length) { |
| const tickIndices = generateSmartTicks(hoverSteps, minXTicks, maxXTicks, innerWidth); |
| xTicksForced = tickIndices; |
| } else { |
| // Fallback for continuous scales |
| const makeTicks = (scale, approx) => { |
| const arr = scale.ticks(approx); |
| const dom = scale.domain(); |
| if (arr.length === 0 || arr[0] !== dom[0]) arr.unshift(dom[0]); |
| if (arr[arr.length-1] !== dom[dom.length-1]) arr.push(dom[dom.length-1]); |
| return Array.from(new Set(arr)); |
| }; |
| xTicksForced = makeTicks(xScale, maxXTicks); |
| } |
| const maxYTicks = Math.max(5, Math.min(6, Math.floor(innerHeight / 60))); |
| const yCount = maxYTicks; const yDom = yScale.domain(); |
| const yTicksForced = (yCount <= 2) ? [yDom[0], yDom[1]] : Array.from({length:yCount}, (_,i)=> yDom[0] + ((yDom[1]-yDom[0])*(i/(yCount-1)))); |
| |
| gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(xScale).tickValues(xTicksForced).tickFormat((val)=>{ |
| // For log scale, val is the actual step value; for linear scale, val might be an index |
| const displayVal = logScaleX ? val : (Array.isArray(hoverSteps) && hoverSteps[val] != null ? hoverSteps[val] : val); |
| return logScaleX ? formatLogTick(displayVal, true) : formatAbbrev(displayVal); |
| })) |
| .call(g=>{ |
| g.selectAll('path, line').style('stroke', 'var(--trackio-chart-axis-stroke)'); |
| g.selectAll('text').style('fill', 'var(--trackio-chart-axis-text)').style('font-size','11px').style('font-family', fontFamily) |
| .style('font-weight', d => { |
| // Make powers of 10 bolder in log scale |
| if (!logScaleX) return 'normal'; |
| const log10 = Math.log10(Math.abs(d)); |
| const isPowerOf10 = Math.abs(log10 % 1) < 0.01; |
| return isPowerOf10 ? '600' : 'normal'; |
| }); |
| }); |
| gAxes.append('g').call(d3.axisLeft(yScale).tickValues(yTicksForced).tickFormat((v)=>formatAbbrev(v))) |
| .call(g=>{ |
| g.selectAll('path, line').style('stroke', 'var(--trackio-chart-axis-stroke)'); |
| g.selectAll('text').style('fill', 'var(--trackio-chart-axis-text)').style('font-size','11px').style('font-family', fontFamily); |
| }); |
| |
| |
| if (logScaleX && logTickData && logTickData.minor.length > 0) { |
| gAxes.append('g').attr('class', 'minor-ticks').attr('transform', `translate(0,${innerHeight})`) |
| .selectAll('line.minor-tick') |
| .data(logTickData.minor) |
| .join('line') |
| .attr('class', 'minor-tick') |
| .attr('x1', d => xScale(d)) |
| .attr('x2', d => xScale(d)) |
| .attr('y1', 0) |
| .attr('y2', 4) // Smaller than major ticks |
| .style('stroke', 'var(--trackio-chart-axis-stroke)') |
| .style('stroke-opacity', 0.4) |
| .style('stroke-width', 0.5); |
| } |
| |
| |
| // Grid rendering is now handled in render() function based on theme |
| const labelY = innerHeight + Math.max(20, Math.min(36, MARGIN.bottom - 12)); |
| |
| // Main "Steps" label (normal text) |
| // Add (log) to Steps text when log scale is enabled |
| const stepsText = logScaleX ? 'Steps (log)' : 'Steps'; |
| |
| gAxes.append('text') |
| .attr('class','x-axis-label') |
| .attr('x', innerWidth/2) |
| .attr('y', labelY) |
| .style('fill', 'var(--trackio-chart-axis-text)') |
| .attr('text-anchor','middle') |
| .style('font-size','9px') |
| .style('opacity','.9') |
| .style('letter-spacing','.5px') |
| .style('text-transform','uppercase') |
| .style('font-weight','500') |
| .style('font-family', fontFamily) |
| .text(stepsText); |
| |
| return { innerWidth, innerHeight, xTicksForced, yTicksForced }; |
| } |
| |
| function render(){ |
| ensureSvg(); |
| if (!svg || !gRoot) return; // Wait for SVG to be ready |
| const runs = Object.keys(metricData||{}); |
| const hasAny = runs.some(r => (metricData[r]||[]).length > 0); |
| if (!hasAny) { gRoot.style('display','none'); return; } gRoot.style('display', null); |
| |
| |
| let computedStyle = getComputedStyle(root); |
| let gridType = computedStyle.getPropertyValue('--trackio-chart-grid-type').trim().replace(/['"]/g, ''); |
| |
| |
| if (!gridType) { |
| const trackioEl = root?.closest('.trackio'); |
| if (trackioEl) { |
| computedStyle = getComputedStyle(trackioEl); |
| gridType = computedStyle.getPropertyValue('--trackio-chart-grid-type').trim().replace(/['"]/g, ''); |
| } |
| } |
| |
| const axisStroke = computedStyle.getPropertyValue('--trackio-chart-axis-stroke').trim(); |
| const axisText = computedStyle.getPropertyValue('--trackio-chart-axis-text').trim(); |
| const gridStroke = computedStyle.getPropertyValue('--trackio-chart-grid-stroke').trim(); |
| const gridOpacity = computedStyle.getPropertyValue('--trackio-chart-grid-opacity').trim(); |
| |
| console.log('🎯 Cell render - variant:', variant, 'gridType:', gridType, 'trackio classes:', root?.closest('.trackio')?.className); |
| |
| let minStep=Infinity, maxStep=-Infinity, minVal=Infinity, maxVal=-Infinity; |
| runs.forEach(r => { (metricData[r]||[]).forEach(pt => { minStep=Math.min(minStep, pt.step); maxStep=Math.max(maxStep, pt.step); minVal=Math.min(minVal, pt.value); maxVal=Math.max(maxVal, pt.value); }); }); |
| const isAccuracy = /accuracy/i.test(metricKey); const isLoss = /loss/i.test(metricKey); |
| if (isAccuracy) yScale.domain([0,1]).nice(); else if (isLoss && normalizeLoss) yScale.domain([0,1]).nice(); else yScale.domain([minVal, maxVal]).nice(); |
| const stepSet = new Set(); runs.forEach(r => (metricData[r]||[]).forEach(v => stepSet.add(v.step))); |
| const hoverSteps = Array.from(stepSet).sort((a,b)=>a-b); |
| |
| |
| xScale = logScaleX ? d3.scaleLog() : d3.scaleLinear(); |
| |
| let stepIndex = null; |
| |
| if (logScaleX) { |
| // For log scale, use actual step values (must be > 0) |
| const minStep = Math.max(1, Math.min(...hoverSteps)); |
| const maxStep = Math.max(...hoverSteps); |
| xScale.domain([minStep, maxStep]); |
| lineGen.x(d => xScale(d.step)); |
| } else { |
| // For linear scale, use indices as before |
| stepIndex = new Map(hoverSteps.map((s,i)=>[s,i])); |
| xScale.domain([0, Math.max(0, hoverSteps.length - 1)]); |
| lineGen.x(d => xScale(stepIndex.get(d.step))); |
| } |
| const normalizeY = (v) => (isLoss && normalizeLoss ? ((maxVal > minVal) ? (v - minVal) / (maxVal - minVal) : 0) : v); lineGen.y(d => yScale(normalizeY(d.value))); |
| const { innerWidth, innerHeight, xTicksForced, yTicksForced } = updateLayout(hoverSteps, { axisStroke, axisText, gridStroke }); |
| |
| |
| |
| const shouldUseDots = (gridType === 'dots') || (variant === 'oblivion'); |
| |
| if (shouldUseDots) { |
| // Oblivion-style: Grid as dots at intersections |
| gGrid.selectAll('*').remove(); // Clear line grid |
| gGridDots.selectAll('*').remove(); |
| const gridPoints = []; |
| const yMin = yScale.domain()[0]; |
| const desiredCols = 24; |
| const xGridStride = Math.max(1, Math.ceil(hoverSteps.length/desiredCols)); |
| const xGridIdx = []; |
| for (let idx = 0; idx < hoverSteps.length; idx += xGridStride) xGridIdx.push(idx); |
| if (xGridIdx[xGridIdx.length-1] !== hoverSteps.length-1) xGridIdx.push(hoverSteps.length-1); |
| xGridIdx.forEach(i => { |
| yTicksForced.forEach(t => { |
| if (i !== 0 && (yMin == null || t !== yMin)) gridPoints.push({ sx: i, ty: t }); |
| }); |
| }); |
| gGridDots.selectAll('circle.grid-dot') |
| .data(gridPoints) |
| .join('circle') |
| .attr('class', 'grid-dot') |
| .attr('cx', d => xScale(d.sx)) |
| .attr('cy', d => yScale(d.ty)) |
| .attr('r', 1.25) |
| .style('fill', 'var(--trackio-chart-grid-stroke)') |
| .style('fill-opacity', 'var(--trackio-chart-grid-opacity)'); |
| } else { |
| // Classic-style: Grid as lines |
| gGridDots.selectAll('*').remove(); // Clear dot grid |
| gGrid.selectAll('*').remove(); |
| // Horizontal grid lines |
| gGrid.selectAll('line.horizontal') |
| .data(yTicksForced) |
| .join('line') |
| .attr('class', 'horizontal') |
| .attr('x1', 0).attr('x2', innerWidth) |
| .attr('y1', d => yScale(d)).attr('y2', d => yScale(d)) |
| .style('stroke', 'var(--trackio-chart-grid-stroke)') |
| .style('stroke-opacity', 'var(--trackio-chart-grid-opacity)') |
| .attr('stroke-width', 1); |
| // Vertical grid lines |
| gGrid.selectAll('line.vertical') |
| .data(xTicksForced) |
| .join('line') |
| .attr('class', 'vertical') |
| .attr('x1', d => xScale(d)).attr('x2', d => xScale(d)) |
| .attr('y1', 0).attr('y2', innerHeight) |
| .style('stroke', 'var(--trackio-chart-grid-stroke)') |
| .style('stroke-opacity', 'var(--trackio-chart-grid-opacity)') |
| .attr('stroke-width', 1); |
| } |
| |
| const series = runs.map(r => ({ run:r, color: colorForRun(r), values: (metricData[r]||[]).slice().sort((a,b)=>a.step-b.step) })); |
| |
| |
| if (smoothing && rawMetricData && Object.keys(rawMetricData).length > 0) { |
| const rawSeries = runs.map(r => ({ run:r, color: colorForRun(r), values: (rawMetricData[r]||[]).slice().sort((a,b)=>a.step-b.step) })); |
| const rawPaths = gLines.selectAll('path.raw-line').data(rawSeries, d=>d.run + '-raw'); |
| rawPaths.enter().append('path').attr('class','raw-line').attr('data-run', d=>d.run).attr('fill','none').attr('stroke-width',1).attr('opacity',0.9).attr('stroke', d=>d.color).style('pointer-events','none').attr('d', d=> lineGen(d.values)); |
| rawPaths.transition().duration(getTransitionDuration(160)).attr('stroke', d=>d.color).attr('opacity',0.4).attr('d', d=> lineGen(d.values)); |
| rawPaths.exit().remove(); |
| } else { |
| // Remove raw lines when smoothing is disabled |
| gLines.selectAll('path.raw-line').remove(); |
| } |
| |
| |
| const paths = gLines.selectAll('path.run-line').data(series, d=>d.run); |
| paths.enter().append('path').attr('class','run-line').attr('data-run', d=>d.run).attr('fill','none').attr('stroke-width',1.5).attr('opacity',0.9).attr('stroke', d=>d.color).style('pointer-events','none').attr('d', d=> lineGen(d.values)); |
| paths.transition().duration(getTransitionDuration(160)).attr('stroke', d=>d.color).attr('opacity',0.9).attr('d', d=> lineGen(d.values)); |
| paths.exit().remove(); |
| const allPoints = series.flatMap(s => s.values.map(v => ({ run:s.run, color:s.color, step:v.step, value:v.value }))); |
| const ptsSel = gPoints.selectAll('circle.pt').data(allPoints, d=> `${d.run}-${d.step}`); |
| ptsSel.enter().append('circle').attr('class','pt').attr('data-run', d=>d.run).attr('r',0).attr('fill', d=>d.color).attr('fill-opacity',0.6).attr('stroke','none').style('pointer-events','none') |
| .attr('cx', d=> logScaleX ? xScale(d.step) : xScale(stepIndex.get(d.step))) |
| .attr('cy', d=> yScale(normalizeY(d.value))) |
| .merge(ptsSel) |
| .attr('cx', d=> logScaleX ? xScale(d.step) : xScale(stepIndex.get(d.step))) |
| .attr('cy', d=> yScale(normalizeY(d.value))); |
| ptsSel.exit().remove(); |
| |
| gHover.selectAll('*').remove(); const overlay = gHover.append('rect').attr('fill','transparent').style('cursor','crosshair').attr('x',0).attr('y',0).attr('width', innerWidth).attr('height', innerHeight).style('pointer-events','all'); const hoverLine = gHover.append('line').style('stroke','var(--text-color)').attr('stroke-opacity',0.25).attr('stroke-width',1).attr('y1',0).attr('y2',innerHeight).style('display','none').style('pointer-events','none'); let hideTipTimer=null; |
| function onMove(ev){ |
| if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer=null; } |
| const [mx,my]=d3.pointer(ev, overlay.node()); |
| |
| let nearest, xpx; |
| if (logScaleX) { |
| // For log scale, find the closest actual step value |
| const mouseStepValue = xScale.invert(mx); |
| let minDist = Infinity; |
| let closestStep = hoverSteps[0]; |
| hoverSteps.forEach(step => { |
| const dist = Math.abs(Math.log(step) - Math.log(mouseStepValue)); |
| if (dist < minDist) { |
| minDist = dist; |
| closestStep = step; |
| } |
| }); |
| nearest = closestStep; |
| xpx = xScale(nearest); |
| } else { |
| // For linear scale, use index-based approach as before |
| const idx = Math.round(Math.max(0, Math.min(hoverSteps.length-1, xScale.invert(mx)))); |
| nearest = hoverSteps[idx]; |
| xpx = xScale(idx); |
| } |
| |
| hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null); |
| try { hostEl && hostEl.dispatchEvent(new CustomEvent('trackio-hover-step', { detail: { step: nearest } })); } catch(_) {} |
| const entries = series.map(s=>{ const m = new Map(s.values.map(v=>[v.step, v])); const pt = m.get(nearest); return { run:s.run, color:s.color, pt }; }).filter(e => e.pt && e.pt.value!=null).sort((a,b)=> a.pt.value - b.pt.value); |
| const fmt=(vv)=> (isAccuracy? (+vv).toFixed(4) : (+vv).toFixed(4)); |
| |
| |
| const trackioEl = root.closest('.trackio') || root; |
| const rootRect = root.getBoundingClientRect(); |
| const trackioRect = trackioEl.getBoundingClientRect(); |
| const relativeX = rootRect.left - trackioRect.left + mx + 12 + MARGIN.left; |
| const relativeY = rootRect.top - trackioRect.top + my + 12 + MARGIN.top; |
| tooltip.$set({ visible:true, x: Math.round(relativeX), y: Math.round(relativeY), title:`Step ${formatAbbrev(nearest)}`, subtitle: titleText, entries: entries.map(e=> ({ color:e.color, name:e.run, valueText: fmt(e.pt.value) })) }); |
| try { gPoints.selectAll('circle.pt').transition().duration(getTransitionDuration(120)).ease(d3.easeCubicOut).attr('r', d => (d && d.step === nearest ? 4 : 0)); } catch(_) {} |
| } |
| function onLeave(){ hideTipTimer = setTimeout(()=>{ tooltip.$set({ visible:false, x:-9999, y:-9999 }); hoverLine.style('display','none'); try { hostEl && hostEl.dispatchEvent(new CustomEvent('trackio-hover-clear')); } catch(_) {} try { gPoints.selectAll('circle.pt').transition().duration(getTransitionDuration(120)).ease(d3.easeCubicOut).attr('r', 0); } catch(_) {} }, 80); } |
| overlay.on('mousemove', onMove).on('mouseleave', onLeave); |
| |
| |
| root.__showExternalStep = (stepVal) => { |
| if (stepVal==null) { |
| hoverLine.style('display','none'); |
| try { gPoints.selectAll('circle.pt').attr('r',0); } catch(_) {} |
| return; |
| } |
| let xpx; |
| if (logScaleX) { |
| xpx = xScale(stepVal); |
| } else { |
| const idx = stepIndex ? stepIndex.get(stepVal) : null; |
| if (idx == null) { hoverLine.style('display','none'); return; } |
| xpx = xScale(idx); |
| } |
| hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null); |
| try { gPoints.selectAll('circle.pt').attr('r', d => (d && d.step === stepVal ? 4 : 0)); } catch(_) {} |
| }; |
| root.__clearExternalStep = () => { hoverLine.style('display','none'); try { gPoints.selectAll('circle.pt').attr('r',0); } catch(_) {} }; |
| if (!root.__syncAttached && hostEl) { hostEl.addEventListener('trackio-hover-step', (ev)=>{ const d=ev&&ev.detail; if (!d) return; root.__showExternalStep && root.__showExternalStep(d.step); }); hostEl.addEventListener('trackio-hover-clear', ()=>{ root.__clearExternalStep && root.__clearExternalStep(); }); root.__syncAttached = true; } |
| } |
| |
| function schedule(){ try { render(); } catch(_) {} } |
| onMount(()=>{ schedule(); const ro = (window.ResizeObserver ? new ResizeObserver(()=> schedule()) : null); ro && ro.observe(root); cleanup = ()=>{ ro && ro.disconnect(); }; }); |
| onDestroy(()=>{ cleanup && cleanup(); }); |
| |
| |
| $: { |
| metricData; |
| rawMetricData; |
| normalizeLoss; |
| variant; |
| logScaleX; |
| smoothing; |
| colorForRun; |
| // Debug log |
| if (typeof window !== 'undefined') { |
| console.log(`Cell re-rendering for ${metricKey}, variant: ${variant}, logScaleX: ${logScaleX}, smoothing: ${smoothing}`); |
| } |
| schedule(); |
| } |
| |
| // Fullscreen functionality |
| function openFullscreen() { |
| if (!root) return; |
| |
| // Create overlay if it doesn't exist |
| let overlay = document.querySelector('.trackio-fullscreen-overlay'); |
| if (!overlay) { |
| overlay = document.createElement('div'); |
| overlay.className = 'trackio-fullscreen-overlay'; |
| |
| const modal = document.createElement('div'); |
| modal.className = 'trackio-fullscreen-modal'; |
| |
| const closeBtn = document.createElement('button'); |
| closeBtn.className = 'trackio-fullscreen-close'; |
| closeBtn.innerHTML = '×'; |
| closeBtn.title = 'Fermer'; |
| |
| overlay.appendChild(modal); |
| overlay.appendChild(closeBtn); |
| document.body.appendChild(overlay); |
| |
| // Close handlers |
| const closeModal = () => { |
| const cellInModal = modal.querySelector('.cell'); |
| if (!cellInModal || !cellInModal.__originalParent) { |
| overlay.classList.remove('is-open'); |
| return; |
| } |
| |
| |
| const currentRect = cellInModal.getBoundingClientRect(); |
| const targetRect = cellInModal.__placeholder.getBoundingClientRect(); |
| |
| const deltaX = targetRect.left - currentRect.left; |
| const deltaY = targetRect.top - currentRect.top; |
| const scaleX = targetRect.width / currentRect.width; |
| const scaleY = targetRect.height / currentRect.height; |
| |
| |
| overlay.classList.add('transitioning'); |
| cellInModal.style.transformOrigin = 'top left'; |
| cellInModal.style.transition = 'transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)'; |
| cellInModal.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`; |
| |
| overlay.classList.remove('is-open'); |
| |
| setTimeout(() => { |
| // Move cell back to original position |
| if (cellInModal.__placeholder && cellInModal.__originalParent) { |
| cellInModal.__originalParent.insertBefore(cellInModal, cellInModal.__placeholder); |
| cellInModal.__placeholder.remove(); |
| } |
| |
| |
| cellInModal.style.transform = ''; |
| cellInModal.style.transition = ''; |
| cellInModal.style.transformOrigin = ''; |
| |
| |
| delete cellInModal.__originalParent; |
| delete cellInModal.__placeholder; |
| |
| |
| overlay.classList.remove('transitioning'); |
| schedule(); |
| }, 300); |
| }; |
| |
| closeBtn.addEventListener('click', closeModal); |
| overlay.addEventListener('click', (e) => { |
| if (e.target === overlay) closeModal(); |
| }); |
| |
| |
| const handleEsc = (e) => { |
| if (e.key === 'Escape' && overlay.classList.contains('is-open')) { |
| closeModal(); |
| } |
| }; |
| document.addEventListener('keydown', handleEsc); |
| |
| overlay.__closeModal = closeModal; |
| overlay.__handleEsc = handleEsc; |
| } |
| |
| const modal = overlay.querySelector('.trackio-fullscreen-modal'); |
| |
| |
| if (overlay.classList.contains('is-open')) { |
| overlay.__closeModal(); |
| return; |
| } |
| |
| |
| const initialRect = root.getBoundingClientRect(); |
| |
| |
| const placeholder = document.createElement('div'); |
| placeholder.style.width = root.offsetWidth + 'px'; |
| placeholder.style.height = root.offsetHeight + 'px'; |
| placeholder.style.visibility = 'hidden'; |
| |
| |
| root.__originalParent = root.parentNode; |
| root.__placeholder = placeholder; |
| |
| |
| root.parentNode.insertBefore(placeholder, root); |
| modal.appendChild(root); |
| |
| |
| overlay.classList.add('is-open'); |
| const finalRect = root.getBoundingClientRect(); |
| |
| |
| const deltaX = initialRect.left - finalRect.left; |
| const deltaY = initialRect.top - finalRect.top; |
| const scaleX = initialRect.width / finalRect.width; |
| const scaleY = initialRect.height / finalRect.height; |
| |
| |
| root.style.transformOrigin = 'top left'; |
| root.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`; |
| |
| // Play - animate to final position |
| overlay.classList.add('transitioning'); // Add class to suppress animations |
| requestAnimationFrame(() => { |
| root.style.transition = 'transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)'; |
| root.style.transform = 'translate(0, 0) scale(1, 1)'; |
| |
| setTimeout(() => { |
| root.style.transition = ''; |
| root.style.transform = ''; |
| root.style.transformOrigin = ''; |
| |
| // Re-render chart at new size with a small delay to ensure layout is settled |
| setTimeout(() => { |
| overlay.classList.remove('transitioning'); // Re-enable animations |
| schedule(); |
| }, 50); |
| }, 300); |
| }); |
| } |
| </script> |
|
|
| <style> |
| |
| |
| |
| |
| :global(.trackio .cell) { |
| border: 1px solid var(--trackio-cell-border); |
| border-radius: 10px; |
| background: var(--trackio-cell-background); |
| display: flex; |
| flex-direction: column; |
| position: relative; |
| } |
| |
| |
| :global(.trackio .cell-bg) { |
| position: absolute; |
| inset: 10px; |
| pointer-events: none; |
| z-index: 1; |
| border-radius: 4px; |
| display: none; |
| } |
| |
| |
| :global(.trackio .cell-corners) { |
| position: absolute; |
| inset: 6px; |
| pointer-events: none; |
| z-index: 3; |
| display: none; |
| opacity: 0.85; |
| } |
| |
| :global(.trackio .cell-inner) { |
| position: relative; |
| z-index: 2; |
| padding: 8px 12px 10px 10px; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| |
| :global(.trackio.theme--oblivion .cell-inner) { |
| padding: var(--trackio-oblivion-hud-corner-size, 8px) 12px 10px var(--trackio-oblivion-hud-gap, 10px); |
| } |
| |
| |
| :global(.trackio.theme--oblivion .cell-bg) { |
| display: block !important; |
| background: |
| radial-gradient(1200px 200px at 20% -10%, rgba(0,0,0,.05), transparent 80%), |
| radial-gradient(900px 200px at 80% 110%, rgba(0,0,0,.05), transparent 80%); |
| } |
| |
| |
| :global([data-theme="dark"]) :global(.trackio.theme--oblivion .cell-bg) { |
| background: |
| radial-gradient(1400px 260px at 20% -10%, color-mix(in srgb, #ffffff 6.5%, transparent), transparent 80%), |
| radial-gradient(1100px 240px at 80% 110%, color-mix(in srgb, #ffffff 6%, transparent), transparent 80%), |
| linear-gradient(180deg, color-mix(in srgb, #ffffff 3.5%, transparent), transparent 45%); |
| } |
| |
| :global(.trackio.theme--oblivion .cell-corners) { |
| display: block !important; |
| inset: 6px; |
| background: |
| linear-gradient(#000000, #000000) top left / 8px 1px no-repeat, |
| linear-gradient(#000000, #000000) top left / 1px 8px no-repeat, |
| linear-gradient(#000000, #000000) top right / 8px 1px no-repeat, |
| linear-gradient(#000000, #000000) top right / 1px 8px no-repeat, |
| linear-gradient(#000000, #000000) bottom left / 8px 1px no-repeat, |
| linear-gradient(#000000, #000000) bottom left / 1px 8px no-repeat, |
| linear-gradient(#000000, #000000) bottom right / 8px 1px no-repeat, |
| linear-gradient(#000000, #000000) bottom right / 1px 8px no-repeat; |
| opacity: 1; |
| z-index: 3; |
| } |
| |
| |
| :global([data-theme="dark"]) :global(.trackio.theme--oblivion .cell-corners) { |
| background: |
| linear-gradient(#ffffff, #ffffff) top left / 8px 1px no-repeat, |
| linear-gradient(#ffffff, #ffffff) top left / 1px 8px no-repeat, |
| linear-gradient(#ffffff, #ffffff) top right / 8px 1px no-repeat, |
| linear-gradient(#ffffff, #ffffff) top right / 1px 8px no-repeat, |
| linear-gradient(#ffffff, #ffffff) bottom left / 8px 1px no-repeat, |
| linear-gradient(#ffffff, #ffffff) bottom left / 1px 8px no-repeat, |
| linear-gradient(#ffffff, #ffffff) bottom right / 8px 1px no-repeat, |
| linear-gradient(#ffffff, #ffffff) bottom right / 1px 8px no-repeat; |
| } |
| |
| |
| |
| |
| :global(.trackio.theme--oblivion .cell-title) { |
| font-size: 12px; |
| font-weight: 800; |
| letter-spacing: 0.12em; |
| text-transform: uppercase; |
| color: var(--trackio-oblivion-primary); |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .cell-indicator { |
| width: 6px; |
| height: 6px; |
| background: var(--trackio-chart-axis-text); |
| border: 1px solid var(--trackio-chart-axis-stroke); |
| opacity: 0.6; |
| flex-shrink: 0; |
| } |
| |
| |
| :global(.trackio.theme--oblivion .cell) { |
| border: none !important; |
| background: transparent !important; |
| } |
| |
| |
| :global(.trackio.theme--classic .cell) { |
| border: 1px solid var(--trackio-cell-border) !important; |
| background: var(--trackio-cell-background) !important; |
| border-radius: 10px !important; |
| } |
| |
| :global(.trackio .cell-header) { |
| padding: 8px 10px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 8px; |
| } |
| |
| |
| :global(.trackio.theme--oblivion .cell-header) { |
| padding: 5px 0px 18px 12px; |
| } |
| |
| :global(.trackio .cell-title) { |
| font-size: 13px; |
| font-weight: 700; |
| color: var(--trackio-text-primary); |
| font-family: var(--trackio-font-family); |
| } |
| |
| :global(.trackio .cell-body) { |
| position: relative; |
| width: 100%; |
| overflow: hidden; |
| } |
| |
| :global(.trackio .cell-body svg) { |
| max-width: 100%; |
| height: auto; |
| display: block; |
| } |
| |
| |
| |
| |
| :global(.trackio.theme--oblivion .cell-title) { |
| font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important; |
| letter-spacing: 0.12em !important; |
| text-transform: uppercase !important; |
| font-weight: 800 !important; |
| font-size: 12px !important; |
| position: relative; |
| padding-left: 0; |
| } |
| |
| |
| :global(.trackio.theme--oblivion .cell-title)::before { |
| content: ""; |
| position: absolute; |
| left: 0; |
| top: 50%; |
| transform: translateY(-50%); |
| width: 6px; |
| height: 6px; |
| background: var(--trackio-oblivion-primary); |
| border: 1px solid var(--trackio-oblivion-dim); |
| box-shadow: 0 0 10px color-mix(in srgb, var(--trackio-oblivion-base) 25%, transparent) inset; |
| opacity: 0.5; |
| } |
| |
| |
| :global(.trackio.hovering .ghost) { |
| opacity: 0.2; |
| transition: opacity 0.15s ease; |
| } |
| |
| |
| :global(.trackio__grid .cell--wide) { |
| grid-column: 1 / -1; |
| } |
| |
| |
| .cell-fullscreen-btn { |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| width: 32px; |
| height: 32px; |
| border: 0; |
| background: transparent; |
| color: var(--trackio-chart-axis-text); |
| opacity: 0.6; |
| cursor: pointer; |
| border-radius: 6px; |
| transition: opacity 0.15s ease; |
| } |
| .cell-fullscreen-btn:hover { |
| opacity: 1; |
| /* No background on hover - just opacity change */ |
| } |
| .cell-fullscreen-btn svg { |
| width: 18px; |
| height: 18px; |
| fill: var(--trackio-chart-axis-text); |
| } |
| |
| |
| :global(.trackio-fullscreen-overlay) { |
| position: fixed; |
| inset: 0; |
| background: rgba(0, 0, 0, 0.85); |
| backdrop-filter: blur(8px); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 9999; |
| opacity: 0; |
| pointer-events: none; |
| transition: opacity 0.3s ease; |
| } |
| :global(.trackio-fullscreen-overlay.is-open) { |
| opacity: 1; |
| pointer-events: auto; |
| } |
| :global(.trackio-fullscreen-modal) { |
| position: relative; |
| width: min(95vw, 1400px); |
| height: min(95vh, 900px); |
| background: var(--surface-bg); |
| border-radius: 12px; |
| overflow: hidden; |
| box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); |
| } |
| :global(.trackio-fullscreen-close) { |
| position: absolute; |
| top: 16px; |
| right: 16px; |
| width: 40px; |
| height: 40px; |
| border: 0; |
| background: rgba(0, 0, 0, 0.5); |
| color: white; |
| border-radius: 50%; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 10; |
| font-size: 20px; |
| transition: background-color 0.15s ease; |
| } |
| :global(.trackio-fullscreen-close:hover) { |
| background: rgba(0, 0, 0, 0.7); |
| } |
| |
| |
| :global(.trackio-fullscreen-modal .cell-fullscreen-btn) { |
| display: none; |
| } |
| |
| |
| :global(.trackio .minor-ticks line.minor-tick) { |
| stroke: var(--trackio-chart-axis-stroke); |
| stroke-opacity: 0.4; |
| stroke-width: 0.5px; |
| } |
| |
| |
| :global(.trackio.theme--oblivion .minor-ticks line.minor-tick) { |
| stroke: var(--trackio-oblivion-dim); |
| stroke-opacity: 0.6; |
| stroke-width: 0.8px; |
| } |
| |
| |
| |
| :global(.trackio-fullscreen-modal .cell) { |
| width: 100%; |
| height: 100%; |
| border: none; |
| border-radius: 0; |
| } |
| |
| :global(.trackio-fullscreen-modal .cell-inner) { |
| height: 100%; |
| } |
| |
| :global(.trackio-fullscreen-modal .cell-body) { |
| flex: 1; |
| height: calc(100% - 50px); /* Minus header height */ |
| } |
| |
| :global(.trackio-fullscreen-modal .cell-body svg) { |
| width: 100% !important; |
| height: 100% !important; |
| max-width: none !important; |
| max-height: none !important; |
| } |
| </style> |
|
|
| <div class="cell {wide ? 'cell--wide' : ''}" bind:this={root} data-metric={metricKey} data-title={titleText} data-variant={variant}> |
| <div class="cell-bg"></div> |
| <div class="cell-corners"></div> |
| <div class="cell-inner"> |
| <div class="cell-header"> |
| <div class="cell-title"> |
| {#if variant === 'oblivion'} |
| <span class="cell-indicator"></span> |
| {/if} |
| {titleText} |
| </div> |
| <button class="cell-fullscreen-btn" type="button" on:click={openFullscreen} title="Fullscreen"> |
| <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> |
| <path d="M4 9V4h5v2H6v3H4zm10-5h5v5h-2V6h-3V4zM6 18h3v2H4v-5h2v3zm12-3h2v5h-5v-2h3v-3z"/> |
| </svg> |
| </button> |
| </div> |
| <div class="cell-body"><div bind:this={body}></div></div> |
| </div> |
| </div> |
|
|
|
|
|
|