// Adaptive Sampling System for Large Datasets // Inspired by Weights & Biases approach to handle massive time series /** * Adaptive Sampler - Intelligently reduces data points while preserving visual fidelity */ export class AdaptiveSampler { constructor(options = {}) { this.options = { maxPoints: 400, // Threshold to trigger sampling targetPoints: 200, // Target number of points after sampling preserveFeatures: true, // Preserve important peaks/valleys adaptiveStrategy: 'smart', // 'uniform', 'smart', 'lod' smoothingWindow: 3, // Window for feature detection ...options }; } /** * Determine if sampling is necessary */ needsSampling(dataLength) { return dataLength > this.options.maxPoints; } /** * Main entry point for sampling */ sampleSeries(data, strategy = null) { if (!Array.isArray(data) || data.length === 0) { return { data: [], sampledIndices: [], compressionRatio: 1 }; } const actualStrategy = strategy || this.options.adaptiveStrategy; if (!this.needsSampling(data.length)) { return { data: data.slice(), sampledIndices: data.map((_, i) => i), compressionRatio: 1, strategy: 'none' }; } console.log(`🎯 Sampling ${data.length} points with strategy: ${actualStrategy}`); switch (actualStrategy) { case 'uniform': return this.uniformSampling(data); case 'smart': return this.smartSampling(data); case 'lod': return this.lodSampling(data); default: return this.smartSampling(data); } } /** * Sampling uniforme - simple mais pas optimal */ uniformSampling(data) { const step = Math.ceil(data.length / this.options.targetPoints); const sampledData = []; const sampledIndices = []; // Toujours inclure le premier et dernier point sampledData.push(data[0]); sampledIndices.push(0); for (let i = step; i < data.length - 1; i += step) { sampledData.push(data[i]); sampledIndices.push(i); } // Toujours inclure le dernier point if (data.length > 1) { sampledData.push(data[data.length - 1]); sampledIndices.push(data.length - 1); } return { data: sampledData, sampledIndices, compressionRatio: sampledData.length / data.length, strategy: 'uniform' }; } /** * Smart sampling - préserve les features importantes * Inspiré de l'algorithme de Douglas-Peucker adapté pour les time series */ smartSampling(data) { const targetPoints = this.options.targetPoints; const features = this.detectFeatures(data); // Étape 1: Points critiques (début, fin, features importantes) const criticalPoints = new Set([0, data.length - 1]); // Ajouter les features détectés features.peaks.forEach(idx => criticalPoints.add(idx)); features.valleys.forEach(idx => criticalPoints.add(idx)); features.inflectionPoints.forEach(idx => criticalPoints.add(idx)); // Étape 2: Répartition logarithmique pour préserver la densité const remaining = targetPoints - criticalPoints.size; if (remaining > 0) { const logSamples = this.generateLogSpacing(data.length, remaining); logSamples.forEach(idx => criticalPoints.add(idx)); } // Étape 3: Densité adaptive dans les zones de changement if (criticalPoints.size < targetPoints) { const variationSamples = this.sampleByVariation(data, targetPoints - criticalPoints.size); variationSamples.forEach(idx => criticalPoints.add(idx)); } const sampledIndices = Array.from(criticalPoints).sort((a, b) => a - b); const sampledData = sampledIndices.map(idx => data[idx]); return { data: sampledData, sampledIndices, compressionRatio: sampledData.length / data.length, strategy: 'smart', features }; } /** * Level-of-Detail sampling - adaptatif selon le zoom/contexte */ lodSampling(data, viewportStart = 0, viewportEnd = 1, zoomLevel = 1) { const viewStart = Math.floor(viewportStart * data.length); const viewEnd = Math.ceil(viewportEnd * data.length); const viewData = data.slice(viewStart, viewEnd); // Plus de détails dans la zone visible const visibleTargetPoints = Math.floor(this.options.targetPoints * 0.7); const contextTargetPoints = this.options.targetPoints - visibleTargetPoints; // Sampling dense dans la zone visible const visibleSample = this.smartSampling(viewData); // Sampling sparse dans le contexte const beforeContext = data.slice(0, viewStart); const afterContext = data.slice(viewEnd); const beforeSample = beforeContext.length > 0 ? this.uniformSampling(beforeContext) : { data: [], sampledIndices: [] }; const afterSample = afterContext.length > 0 ? this.uniformSampling(afterContext) : { data: [], sampledIndices: [] }; // Combiner les résultats const combinedData = [ ...beforeSample.data, ...visibleSample.data, ...afterSample.data ]; const combinedIndices = [ ...beforeSample.sampledIndices, ...visibleSample.sampledIndices.map(idx => idx + viewStart), ...afterSample.sampledIndices.map(idx => idx + viewEnd) ]; return { data: combinedData, sampledIndices: combinedIndices, compressionRatio: combinedData.length / data.length, strategy: 'lod' }; } /** * Détection des features importantes dans la série */ detectFeatures(data) { const peaks = []; const valleys = []; const inflectionPoints = []; const window = this.options.smoothingWindow; for (let i = window; i < data.length - window; i++) { const current = data[i].value; const prev = data[i - 1].value; const next = data[i + 1].value; // Détection des pics locaux if (current > prev && current > next) { // Vérifier si c'est un pic significatif const localMax = Math.max( ...data.slice(i - window, i + window + 1).map(d => d.value) ); if (current === localMax) { peaks.push(i); } } // Détection des vallées locales if (current < prev && current < next) { const localMin = Math.min( ...data.slice(i - window, i + window + 1).map(d => d.value) ); if (current === localMin) { valleys.push(i); } } // Détection des points d'inflection (changement de courbure) if (i >= 2 && i < data.length - 2) { const trend1 = data[i].value - data[i - 2].value; const trend2 = data[i + 2].value - data[i].value; if (Math.sign(trend1) !== Math.sign(trend2) && Math.abs(trend1) > 0.01 && Math.abs(trend2) > 0.01) { inflectionPoints.push(i); } } } return { peaks, valleys, inflectionPoints }; } /** * Génère des indices avec espacement logarithmique */ generateLogSpacing(totalLength, count) { const indices = []; for (let i = 1; i <= count; i++) { const progress = i / (count + 1); // Fonction logarithmique pour plus de densité au début const logProgress = Math.log(1 + progress * (Math.E - 1)) / Math.log(Math.E); const index = Math.floor(logProgress * (totalLength - 1)); indices.push(Math.max(1, Math.min(totalLength - 2, index))); } return [...new Set(indices)]; // Remove duplicates } /** * Sampling based on local variation */ sampleByVariation(data, targetPoints) { const variations = []; // Calculer la variation locale pour chaque point for (let i = 1; i < data.length - 1; i++) { const prev = data[i - 1].value; const curr = data[i].value; const next = data[i + 1].value; // Variation = différence avec la moyenne des voisins const avgNeighbors = (prev + next) / 2; const variation = Math.abs(curr - avgNeighbors); variations.push({ index: i, variation }); } // Trier par variation décroissante et prendre les plus importantes variations.sort((a, b) => b.variation - a.variation); return variations.slice(0, targetPoints).map(v => v.index); } /** * Applique le sampling sur un objet de données complètes (multi-run) */ sampleMetricData(metricData, strategy = null) { const sampledData = {}; const samplingInfo = {}; Object.keys(metricData).forEach(runName => { const runData = metricData[runName] || []; const result = this.sampleSeries(runData, strategy); sampledData[runName] = result.data; samplingInfo[runName] = { originalLength: runData.length, sampledLength: result.data.length, compressionRatio: result.compressionRatio, strategy: result.strategy, sampledIndices: result.sampledIndices }; }); return { sampledData, samplingInfo }; } /** * Reconstruit les données complètes pour une zone spécifique (pour le zoom) */ getFullDataForRange(originalData, samplingInfo, startStep, endStep) { // This method would allow recovering more details // quand l'utilisateur zoom sur une zone spécifique const startIdx = originalData.findIndex(d => d.step >= startStep); const endIdx = originalData.findIndex(d => d.step > endStep); return originalData.slice(startIdx, endIdx === -1 ? undefined : endIdx); } } /** * Instance globale configurée pour TrackIO */ export const trackioSampler = new AdaptiveSampler({ maxPoints: 400, targetPoints: 200, preserveFeatures: true, adaptiveStrategy: 'smart' }); /** * Fonction utilitaire pour usage direct */ export function sampleLargeDataset(metricData, options = {}) { const sampler = new AdaptiveSampler(options); return sampler.sampleMetricData(metricData); }