// Chart utilities for axis formatting and tick generation export const formatAbbrev = (value) => { const num = Number(value); if (!Number.isFinite(num)) return String(value); const abs = Math.abs(num); const trim2 = (n) => Number(n).toFixed(2).replace(/\.?0+$/, ''); if (abs >= 1e9) return `${trim2(num / 1e9)}B`; if (abs >= 1e6) return `${trim2(num / 1e6)}M`; if (abs >= 1e3) return `${trim2(num / 1e3)}K`; return trim2(num); }; /** * Enhanced formatting for logarithmic scale ticks * @param {number} value - The tick value * @param {boolean} isLogScale - Whether this is for a log scale * @returns {string} Formatted tick label */ export const formatLogTick = (value, isLogScale = false) => { if (!isLogScale) return formatAbbrev(value); const num = Number(value); if (!Number.isFinite(num)) return String(value); // Check if it's a power of 10 const log10 = Math.log10(Math.abs(num)); const isPowerOf10 = Math.abs(log10 % 1) < 0.01; if (isPowerOf10) { // Format powers of 10 more prominently const power = Math.round(log10); if (power >= 0 && power <= 6) { // For small powers, show the actual number return formatAbbrev(value); } else { // For very large/small powers, use scientific notation return `10^${power}`; } } // For non-powers of 10, use regular formatting return formatAbbrev(value); }; /** * Generates optimized tick positions for logarithmic scales * @param {Array} stepValues - Array of actual step values (not indices) * @param {number} minTicks - Minimum number of ticks desired * @param {number} maxTicks - Maximum number of ticks allowed * @param {number} width - Chart width in pixels * @param {Function} scale - D3 log scale function * @returns {Object} Object with major and minor tick positions */ export function generateLogTicks(stepValues, minTicks, maxTicks, width, scale) { if (!stepValues || stepValues.length === 0 || !scale) return { major: [], minor: [] }; const minPixelSpacing = 50; // Reduced for better density const minorPixelSpacing = 25; // Spacing for minor ticks const maxTicksFromWidth = Math.max(4, Math.floor(width / minPixelSpacing)); const targetMaxTicks = Math.min(maxTicks, maxTicksFromWidth); // Debug logging console.log('🎯 generateLogTicks called:', { stepCount: stepValues.length, stepRange: [Math.min(...stepValues), Math.max(...stepValues)], targetTicks: [minTicks, targetMaxTicks], width }); const domain = scale.domain(); const [minVal, maxVal] = domain; // Calculate the range in log space const logMin = Math.log10(minVal); const logMax = Math.log10(maxVal); const logRange = logMax - logMin; // Generate major ticks (powers of 10) const majorCandidates = new Set(); const minorCandidates = new Set(); // Always add domain boundaries majorCandidates.add(minVal); majorCandidates.add(maxVal); const startPower = Math.floor(logMin); const endPower = Math.ceil(logMax); // Major ticks: powers of 10 for (let power = startPower; power <= endPower; power++) { const value = Math.pow(10, power); if (value >= minVal && value <= maxVal) { majorCandidates.add(value); } } // If we have space, add more major ticks (2x, 5x) if (logRange > 0.7) { for (let power = startPower; power <= endPower; power++) { const base = Math.pow(10, power); [2, 5].forEach(multiplier => { const value = base * multiplier; if (value >= minVal && value <= maxVal) { majorCandidates.add(value); } }); } } // Minor ticks: intermediate values to show log progression for (let power = startPower; power <= endPower; power++) { const base = Math.pow(10, power); // Add 3x, 4x, 6x, 7x, 8x, 9x for visual density [3, 4, 6, 7, 8, 9].forEach(multiplier => { const value = base * multiplier; if (value >= minVal && value <= maxVal && !majorCandidates.has(value)) { minorCandidates.add(value); } }); } // Match candidates to actual step values const matchToStepValues = (candidates) => { return Array.from(candidates).map(candidate => { let closest = stepValues[0]; let minRelativeDistance = Math.abs(stepValues[0] - candidate) / Math.max(stepValues[0], candidate); stepValues.forEach(step => { const relativeDistance = Math.abs(step - candidate) / Math.max(step, candidate); if (relativeDistance < minRelativeDistance) { minRelativeDistance = relativeDistance; closest = step; } }); // Only include if reasonable match (20% tolerance for minor, 15% for major) const isPowerOf10 = Math.abs(Math.log10(candidate) % 1) < 0.01; const tolerance = majorCandidates.has(candidate) ? 0.15 : 0.20; if (minRelativeDistance < tolerance || isPowerOf10) { return closest; } return null; }).filter(v => v !== null); }; let majorTicks = Array.from(new Set(matchToStepValues(majorCandidates))).sort((a, b) => a - b); let minorTicks = Array.from(new Set(matchToStepValues(minorCandidates))).sort((a, b) => a - b); // Filter major ticks by pixel spacing const filteredMajorTicks = []; majorTicks.forEach(tick => { if (filteredMajorTicks.length === 0) { filteredMajorTicks.push(tick); } else { const prevTick = filteredMajorTicks[filteredMajorTicks.length - 1]; const pixelDistance = Math.abs(scale(tick) - scale(prevTick)); if (pixelDistance >= minPixelSpacing) { filteredMajorTicks.push(tick); } } }); // Filter minor ticks by pixel spacing and ensure they don't conflict with major ticks const filteredMinorTicks = []; minorTicks.forEach(tick => { // Skip if too close to any major tick const tooCloseToMajor = filteredMajorTicks.some(majorTick => { const distance = Math.abs(scale(tick) - scale(majorTick)); return distance < minorPixelSpacing; }); if (!tooCloseToMajor) { // Check spacing with previous minor tick if (filteredMinorTicks.length === 0) { filteredMinorTicks.push(tick); } else { const prevTick = filteredMinorTicks[filteredMinorTicks.length - 1]; const pixelDistance = Math.abs(scale(tick) - scale(prevTick)); if (pixelDistance >= minorPixelSpacing) { filteredMinorTicks.push(tick); } } } }); const result = { major: filteredMajorTicks.length >= 2 ? filteredMajorTicks : [minVal, maxVal], minor: filteredMinorTicks }; // Debug logging console.log('🎯 generateLogTicks result:', { logRange: logRange.toFixed(2), majorCount: result.major.length, minorCount: result.minor.length, majorTicks: result.major, minorTicks: result.minor }); return result; } /** * Generates intelligent tick positions for X-axis with nice intervals * @param {Array} steps - Array of step values (e.g., [1, 2, 3, ..., 100]) * @param {number} minTicks - Minimum number of ticks desired * @param {number} maxTicks - Maximum number of ticks allowed * @param {number} width - Chart width in pixels * @returns {Array} Array of step indices for tick positions */ export function generateSmartTicks(steps, minTicks, maxTicks, width) { if (!steps || steps.length === 0) return []; const totalSteps = steps.length; const minPixelSpacing = 75; // Slightly reduced minimum spacing to allow more ticks const maxTicksFromWidth = Math.max(3, Math.floor(width / minPixelSpacing)); // Function to check if ticks would be too close (minimum step difference) const getMinStepDifference = (totalSteps, width) => { const pixelsPerStep = width / (totalSteps - 1); return Math.ceil(minPixelSpacing / pixelsPerStep); }; const minStepDiff = getMinStepDifference(totalSteps, width); const maxPossibleTicks = Math.floor((totalSteps - 1) / minStepDiff) + 1; // Ensure we aim for at least 5 ticks if space permits const targetMinTicks = Math.min(Math.max(minTicks, 5), maxPossibleTicks, totalSteps); const targetMaxTicks = Math.min(maxTicks, maxTicksFromWidth, maxPossibleTicks); // Start with first and last if (targetMinTicks <= 2 || totalSteps <= 2) { return [0, totalSteps - 1]; } // Helper to validate spacing const hasValidSpacing = (ticks) => { for (let i = 1; i < ticks.length; i++) { if (ticks[i] - ticks[i-1] < minStepDiff) return false; } return true; }; // Try nice intervals first const candidateIntervals = []; const niceIntervals = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000]; for (const interval of niceIntervals) { if (interval >= totalSteps) continue; const candidateTicks = [0]; const firstStepValue = steps[0]; const lastStepValue = steps[totalSteps - 1]; const firstNiceValue = Math.ceil(firstStepValue / interval) * interval; // Add ticks for nice step values for (let niceValue = firstNiceValue; niceValue < lastStepValue; niceValue += interval) { let closestIndex = 0; let minDist = Infinity; for (let i = 0; i < steps.length; i++) { const dist = Math.abs(steps[i] - niceValue); if (dist < minDist) { minDist = dist; closestIndex = i; } } // Be more permissive with matching (15% instead of 10%) if (minDist <= interval * 0.15 && closestIndex > 0 && closestIndex < totalSteps - 1) { candidateTicks.push(closestIndex); } } candidateTicks.push(totalSteps - 1); const uniqueTicks = [...new Set(candidateTicks)].sort((a,b) => a-b); if (hasValidSpacing(uniqueTicks) && uniqueTicks.length >= 3) { candidateIntervals.push({ interval: interval, ticks: uniqueTicks, count: uniqueTicks.length, niceness: (interval <= 10 ? 100 : (interval <= 50 ? 50 : (interval <= 100 ? 25 : 10))) }); } } // Force generation of ticks if we don't have enough nice ones if (candidateIntervals.length === 0 || candidateIntervals.every(c => c.count < targetMinTicks)) { // Try multiple approaches to get targetMinTicks for (let targetCount = Math.min(targetMinTicks, maxPossibleTicks); targetCount >= 3; targetCount--) { // Approach 1: Even distribution const evenSpacing = Math.floor((totalSteps - 1) / (targetCount - 1)); const evenTicks = []; for (let i = 0; i < targetCount - 1; i++) { evenTicks.push(i * evenSpacing); } evenTicks.push(totalSteps - 1); if (hasValidSpacing(evenTicks)) { candidateIntervals.push({ interval: evenSpacing, ticks: evenTicks, count: evenTicks.length, niceness: 5 // Medium priority }); break; // Found a good solution } // Approach 2: Try to fit exactly targetCount ticks with optimal spacing if (targetCount <= maxPossibleTicks) { const optimalSpacing = Math.max(minStepDiff, Math.floor((totalSteps - 1) / (targetCount - 1))); const spacedTicks = [0]; let currentPos = 0; for (let i = 1; i < targetCount - 1; i++) { currentPos += optimalSpacing; if (currentPos < totalSteps - 1) { spacedTicks.push(Math.min(currentPos, totalSteps - 1 - minStepDiff)); } } spacedTicks.push(totalSteps - 1); const uniqueSpacedTicks = [...new Set(spacedTicks)].sort((a,b) => a-b); if (hasValidSpacing(uniqueSpacedTicks) && uniqueSpacedTicks.length >= targetCount - 1) { candidateIntervals.push({ interval: optimalSpacing, ticks: uniqueSpacedTicks, count: uniqueSpacedTicks.length, niceness: 3 // Lower priority than nice intervals }); break; } } } } // Absolute fallback if (candidateIntervals.length === 0) { const middle = Math.floor(totalSteps / 2); if (middle !== 0 && middle !== totalSteps - 1 && middle - 0 >= minStepDiff && totalSteps - 1 - middle >= minStepDiff) { return [0, middle, totalSteps - 1]; } return [0, totalSteps - 1]; } // Sort: prioritize having enough ticks, then niceness candidateIntervals.sort((a, b) => { const aHasEnoughTicks = a.count >= targetMinTicks; const bHasEnoughTicks = b.count >= targetMinTicks; // First: prefer solutions with enough ticks if (aHasEnoughTicks !== bHasEnoughTicks) { return bHasEnoughTicks ? 1 : -1; } // Second: prefer nicer intervals if (a.niceness !== b.niceness) { return b.niceness - a.niceness; } // Third: prefer more ticks if both are nice return b.count - a.count; }); return candidateIntervals[0].ticks; } /** * Applies smoothing to data series using moving average * @param {Array} data - Array of {step, value} objects * @param {number} windowSize - Size of the smoothing window (default: 5) * @returns {Array} Smoothed data series */ export function smoothData(data, windowSize = 5) { if (!data || data.length === 0) return data; if (data.length < windowSize) return data; // Not enough data to smooth const smoothed = []; const halfWindow = Math.floor(windowSize / 2); for (let i = 0; i < data.length; i++) { const start = Math.max(0, i - halfWindow); const end = Math.min(data.length - 1, i + halfWindow); let sum = 0; let count = 0; // Calculate weighted average with more weight to center point for (let j = start; j <= end; j++) { const distance = Math.abs(j - i); const weight = distance === 0 ? 2 : (distance === 1 ? 1.5 : 1); // Center gets more weight sum += data[j].value * weight; count += weight; } smoothed.push({ step: data[i].step, value: sum / count }); } return smoothed; } /** * Applies smoothing to all runs in metric data * @param {Object} metricData - Object with run names as keys and data arrays as values * @param {number} windowSize - Size of the smoothing window * @returns {Object} Smoothed metric data */ export function smoothMetricData(metricData, windowSize = 5) { if (!metricData) return metricData; const smoothedData = {}; Object.keys(metricData).forEach(runName => { smoothedData[runName] = smoothData(metricData[runName], windowSize); }); return smoothedData; }