thibaud frere
refactor trackio redesign
4398633
// 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;
}