lerobot/robot-learning-tutorial
508
1// Chart utilities for axis formatting and tick generation2 3export const formatAbbrev = (value) => {4 const num = Number(value);5 if (!Number.isFinite(num)) return String(value);6 const abs = Math.abs(num);7 const trim2 = (n) => Number(n).toFixed(2).replace(/\.?0+$/, '');8 if (abs >= 1e9) return `${trim2(num / 1e9)}B`;9 if (abs >= 1e6) return `${trim2(num / 1e6)}M`;10 if (abs >= 1e3) return `${trim2(num / 1e3)}K`;11 return trim2(num);12};13 14/**15 * Enhanced formatting for logarithmic scale ticks16 * @param {number} value - The tick value17 * @param {boolean} isLogScale - Whether this is for a log scale18 * @returns {string} Formatted tick label19 */20export const formatLogTick = (value, isLogScale = false) => {21 if (!isLogScale) return formatAbbrev(value);22 23 const num = Number(value);24 if (!Number.isFinite(num)) return String(value);25 26 // Check if it's a power of 1027 const log10 = Math.log10(Math.abs(num));28 const isPowerOf10 = Math.abs(log10 % 1) < 0.01;29 30 if (isPowerOf10) {31 // Format powers of 10 more prominently32 const power = Math.round(log10);33 if (power >= 0 && power <= 6) {34 // For small powers, show the actual number35 return formatAbbrev(value);36 } else {37 // For very large/small powers, use scientific notation38 return `10^${power}`;39 }40 }41 42 // For non-powers of 10, use regular formatting43 return formatAbbrev(value);44};45 46/**47 * Generates optimized tick positions for logarithmic scales48 * @param {Array} stepValues - Array of actual step values (not indices)49 * @param {number} minTicks - Minimum number of ticks desired50 * @param {number} maxTicks - Maximum number of ticks allowed51 * @param {number} width - Chart width in pixels52 * @param {Function} scale - D3 log scale function53 * @returns {Object} Object with major and minor tick positions54 */55export function generateLogTicks(stepValues, minTicks, maxTicks, width, scale) {56 if (!stepValues || stepValues.length === 0 || !scale) return { major: [], minor: [] };57 58 const minPixelSpacing = 50; // Reduced for better density59 const minorPixelSpacing = 25; // Spacing for minor ticks60 const maxTicksFromWidth = Math.max(4, Math.floor(width / minPixelSpacing));61 const targetMaxTicks = Math.min(maxTicks, maxTicksFromWidth);62 63 // Debug logging64 console.log('๐ฏ generateLogTicks called:', {65 stepCount: stepValues.length,66 stepRange: [Math.min(...stepValues), Math.max(...stepValues)],67 targetTicks: [minTicks, targetMaxTicks],68 width69 });70 71 const domain = scale.domain();72 const [minVal, maxVal] = domain;73 74 // Calculate the range in log space75 const logMin = Math.log10(minVal);76 const logMax = Math.log10(maxVal);77 const logRange = logMax - logMin;78 79 // Generate major ticks (powers of 10)80 const majorCandidates = new Set();81 const minorCandidates = new Set();82 83 // Always add domain boundaries84 majorCandidates.add(minVal);85 majorCandidates.add(maxVal);86 87 const startPower = Math.floor(logMin);88 const endPower = Math.ceil(logMax);89 90 // Major ticks: powers of 1091 for (let power = startPower; power <= endPower; power++) {92 const value = Math.pow(10, power);93 if (value >= minVal && value <= maxVal) {94 majorCandidates.add(value);95 }96 }97 98 // If we have space, add more major ticks (2x, 5x)99 if (logRange > 0.7) {100 for (let power = startPower; power <= endPower; power++) {101 const base = Math.pow(10, power);102 [2, 5].forEach(multiplier => {103 const value = base * multiplier;104 if (value >= minVal && value <= maxVal) {105 majorCandidates.add(value);106 }107 });108 }109 }110 111 // Minor ticks: intermediate values to show log progression112 for (let power = startPower; power <= endPower; power++) {113 const base = Math.pow(10, power);114 // Add 3x, 4x, 6x, 7x, 8x, 9x for visual density115 [3, 4, 6, 7, 8, 9].forEach(multiplier => {116 const value = base * multiplier;117 if (value >= minVal && value <= maxVal && !majorCandidates.has(value)) {118 minorCandidates.add(value);119 }120 });121 }122 123 // Match candidates to actual step values124 const matchToStepValues = (candidates) => {125 return Array.from(candidates).map(candidate => {126 let closest = stepValues[0];127 let minRelativeDistance = Math.abs(stepValues[0] - candidate) / Math.max(stepValues[0], candidate);128 129 stepValues.forEach(step => {130 const relativeDistance = Math.abs(step - candidate) / Math.max(step, candidate);131 if (relativeDistance < minRelativeDistance) {132 minRelativeDistance = relativeDistance;133 closest = step;134 }135 });136 137 // Only include if reasonable match (20% tolerance for minor, 15% for major)138 const isPowerOf10 = Math.abs(Math.log10(candidate) % 1) < 0.01;139 const tolerance = majorCandidates.has(candidate) ? 0.15 : 0.20;140 141 if (minRelativeDistance < tolerance || isPowerOf10) {142 return closest;143 }144 return null;145 }).filter(v => v !== null);146 };147 148 let majorTicks = Array.from(new Set(matchToStepValues(majorCandidates))).sort((a, b) => a - b);149 let minorTicks = Array.from(new Set(matchToStepValues(minorCandidates))).sort((a, b) => a - b);150 151 // Filter major ticks by pixel spacing152 const filteredMajorTicks = [];153 majorTicks.forEach(tick => {154 if (filteredMajorTicks.length === 0) {155 filteredMajorTicks.push(tick);156 } else {157 const prevTick = filteredMajorTicks[filteredMajorTicks.length - 1];158 const pixelDistance = Math.abs(scale(tick) - scale(prevTick));159 if (pixelDistance >= minPixelSpacing) {160 filteredMajorTicks.push(tick);161 }162 }163 });164 165 // Filter minor ticks by pixel spacing and ensure they don't conflict with major ticks166 const filteredMinorTicks = [];167 minorTicks.forEach(tick => {168 // Skip if too close to any major tick169 const tooCloseToMajor = filteredMajorTicks.some(majorTick => {170 const distance = Math.abs(scale(tick) - scale(majorTick));171 return distance < minorPixelSpacing;172 });173 174 if (!tooCloseToMajor) {175 // Check spacing with previous minor tick176 if (filteredMinorTicks.length === 0) {177 filteredMinorTicks.push(tick);178 } else {179 const prevTick = filteredMinorTicks[filteredMinorTicks.length - 1];180 const pixelDistance = Math.abs(scale(tick) - scale(prevTick));181 if (pixelDistance >= minorPixelSpacing) {182 filteredMinorTicks.push(tick);183 }184 }185 }186 });187 188 const result = {189 major: filteredMajorTicks.length >= 2 ? filteredMajorTicks : [minVal, maxVal],190 minor: filteredMinorTicks191 };192 193 // Debug logging194 console.log('๐ฏ generateLogTicks result:', {195 logRange: logRange.toFixed(2),196 majorCount: result.major.length,197 minorCount: result.minor.length,198 majorTicks: result.major,199 minorTicks: result.minor200 });201 202 return result;203}204 205/**206 * Generates intelligent tick positions for X-axis with nice intervals207 * @param {Array} steps - Array of step values (e.g., [1, 2, 3, ..., 100])208 * @param {number} minTicks - Minimum number of ticks desired209 * @param {number} maxTicks - Maximum number of ticks allowed210 * @param {number} width - Chart width in pixels211 * @returns {Array} Array of step indices for tick positions212 */213export function generateSmartTicks(steps, minTicks, maxTicks, width) {214 if (!steps || steps.length === 0) return [];215 216 const totalSteps = steps.length;217 const minPixelSpacing = 75; // Slightly reduced minimum spacing to allow more ticks218 const maxTicksFromWidth = Math.max(3, Math.floor(width / minPixelSpacing));219 220 // Function to check if ticks would be too close (minimum step difference)221 const getMinStepDifference = (totalSteps, width) => {222 const pixelsPerStep = width / (totalSteps - 1);223 return Math.ceil(minPixelSpacing / pixelsPerStep);224 };225 226 const minStepDiff = getMinStepDifference(totalSteps, width);227 const maxPossibleTicks = Math.floor((totalSteps - 1) / minStepDiff) + 1;228 229 // Ensure we aim for at least 5 ticks if space permits230 const targetMinTicks = Math.min(Math.max(minTicks, 5), maxPossibleTicks, totalSteps);231 const targetMaxTicks = Math.min(maxTicks, maxTicksFromWidth, maxPossibleTicks);232 233 // Start with first and last234 if (targetMinTicks <= 2 || totalSteps <= 2) {235 return [0, totalSteps - 1];236 }237 238 // Helper to validate spacing239 const hasValidSpacing = (ticks) => {240 for (let i = 1; i < ticks.length; i++) {241 if (ticks[i] - ticks[i-1] < minStepDiff) return false;242 }243 return true;244 };245 246 // Try nice intervals first247 const candidateIntervals = [];248 const niceIntervals = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000];249 250 for (const interval of niceIntervals) {251 if (interval >= totalSteps) continue;252 253 const candidateTicks = [0];254 const firstStepValue = steps[0];255 const lastStepValue = steps[totalSteps - 1];256 const firstNiceValue = Math.ceil(firstStepValue / interval) * interval;257 258 // Add ticks for nice step values259 for (let niceValue = firstNiceValue; niceValue < lastStepValue; niceValue += interval) {260 let closestIndex = 0;261 let minDist = Infinity;262 for (let i = 0; i < steps.length; i++) {263 const dist = Math.abs(steps[i] - niceValue);264 if (dist < minDist) {265 minDist = dist;266 closestIndex = i;267 }268 }269 270 // Be more permissive with matching (15% instead of 10%)271 if (minDist <= interval * 0.15 && closestIndex > 0 && closestIndex < totalSteps - 1) {272 candidateTicks.push(closestIndex);273 }274 }275 276 candidateTicks.push(totalSteps - 1);277 const uniqueTicks = [...new Set(candidateTicks)].sort((a,b) => a-b);278 279 if (hasValidSpacing(uniqueTicks) && uniqueTicks.length >= 3) {280 candidateIntervals.push({281 interval: interval,282 ticks: uniqueTicks,283 count: uniqueTicks.length,284 niceness: (interval <= 10 ? 100 : (interval <= 50 ? 50 : (interval <= 100 ? 25 : 10)))285 });286 }287 }288 289 // Force generation of ticks if we don't have enough nice ones290 if (candidateIntervals.length === 0 || candidateIntervals.every(c => c.count < targetMinTicks)) {291 // Try multiple approaches to get targetMinTicks292 for (let targetCount = Math.min(targetMinTicks, maxPossibleTicks); targetCount >= 3; targetCount--) {293 // Approach 1: Even distribution294 const evenSpacing = Math.floor((totalSteps - 1) / (targetCount - 1));295 const evenTicks = [];296 for (let i = 0; i < targetCount - 1; i++) {297 evenTicks.push(i * evenSpacing);298 }299 evenTicks.push(totalSteps - 1);300 301 if (hasValidSpacing(evenTicks)) {302 candidateIntervals.push({303 interval: evenSpacing,304 ticks: evenTicks,305 count: evenTicks.length,306 niceness: 5 // Medium priority307 });308 break; // Found a good solution309 }310 311 // Approach 2: Try to fit exactly targetCount ticks with optimal spacing312 if (targetCount <= maxPossibleTicks) {313 const optimalSpacing = Math.max(minStepDiff, Math.floor((totalSteps - 1) / (targetCount - 1)));314 const spacedTicks = [0];315 let currentPos = 0;316 317 for (let i = 1; i < targetCount - 1; i++) {318 currentPos += optimalSpacing;319 if (currentPos < totalSteps - 1) {320 spacedTicks.push(Math.min(currentPos, totalSteps - 1 - minStepDiff));321 }322 }323 spacedTicks.push(totalSteps - 1);324 325 const uniqueSpacedTicks = [...new Set(spacedTicks)].sort((a,b) => a-b);326 if (hasValidSpacing(uniqueSpacedTicks) && uniqueSpacedTicks.length >= targetCount - 1) {327 candidateIntervals.push({328 interval: optimalSpacing,329 ticks: uniqueSpacedTicks,330 count: uniqueSpacedTicks.length,331 niceness: 3 // Lower priority than nice intervals332 });333 break;334 }335 }336 }337 }338 339 // Absolute fallback340 if (candidateIntervals.length === 0) {341 const middle = Math.floor(totalSteps / 2);342 if (middle !== 0 && middle !== totalSteps - 1 && 343 middle - 0 >= minStepDiff && totalSteps - 1 - middle >= minStepDiff) {344 return [0, middle, totalSteps - 1];345 }346 return [0, totalSteps - 1];347 }348 349 // Sort: prioritize having enough ticks, then niceness350 candidateIntervals.sort((a, b) => {351 const aHasEnoughTicks = a.count >= targetMinTicks;352 const bHasEnoughTicks = b.count >= targetMinTicks;353 354 // First: prefer solutions with enough ticks355 if (aHasEnoughTicks !== bHasEnoughTicks) {356 return bHasEnoughTicks ? 1 : -1;357 }358 359 // Second: prefer nicer intervals360 if (a.niceness !== b.niceness) {361 return b.niceness - a.niceness;362 }363 364 // Third: prefer more ticks if both are nice365 return b.count - a.count;366 });367 368 return candidateIntervals[0].ticks;369}370 371/**372 * Applies smoothing to data series using moving average373 * @param {Array} data - Array of {step, value} objects374 * @param {number} windowSize - Size of the smoothing window (default: 5)375 * @returns {Array} Smoothed data series376 */377export function smoothData(data, windowSize = 5) {378 if (!data || data.length === 0) return data;379 if (data.length < windowSize) return data; // Not enough data to smooth380 381 const smoothed = [];382 const halfWindow = Math.floor(windowSize / 2);383 384 for (let i = 0; i < data.length; i++) {385 const start = Math.max(0, i - halfWindow);386 const end = Math.min(data.length - 1, i + halfWindow);387 388 let sum = 0;389 let count = 0;390 391 // Calculate weighted average with more weight to center point392 for (let j = start; j <= end; j++) {393 const distance = Math.abs(j - i);394 const weight = distance === 0 ? 2 : (distance === 1 ? 1.5 : 1); // Center gets more weight395 sum += data[j].value * weight;396 count += weight;397 }398 399 smoothed.push({400 step: data[i].step,401 value: sum / count402 });403 }404 405 return smoothed;406}407 408/**409 * Applies smoothing to all runs in metric data410 * @param {Object} metricData - Object with run names as keys and data arrays as values411 * @param {number} windowSize - Size of the smoothing window412 * @returns {Object} Smoothed metric data413 */414export function smoothMetricData(metricData, windowSize = 5) {415 if (!metricData) return metricData;416 417 const smoothedData = {};418 Object.keys(metricData).forEach(runName => {419 smoothedData[runName] = smoothData(metricData[runName], windowSize);420 });421 422 return smoothedData;423}424 