# Afficher 10 000 points à 60fps — Graphes temps réel en Canvas pour l'acquisition scientifique > Retour d'expérience sur la construction d'un moteur de graphes Canvas pour une app d'acquisition scientifique. Pourquoi D3.js et les librairies SVG ne tenaient pas la charge en temps réel, architecture modulaire du Grapher, scaling adaptatif, et le sous-échantillonnage intelligent pour rester à 60fps. Date : 25/08/2020 Auteur : Aurélien N. Tags : Canvas, JavaScript, Visualisation, Performance, Ionic --- L'[app hybride pour l'acquisition scientifique](/blog/eudoxe-app-hybride-educative-cordova-electron-architecture) doit afficher des courbes en temps réel pendant que les capteurs envoient des données. Température, pH, fréquence cardiaque, CO2 — les valeurs arrivent à des intervalles configurables (de 1ms à plusieurs secondes), s'accumulent pendant toute la durée de l'expérience, et l'utilisateur doit pouvoir zoomer, scroller et mesurer sur le graphe pendant l'acquisition. La première question a été : est-ce qu'on utilise une librairie existante ? La réponse, après avoir testé, est non. ## Pourquoi pas D3.js, Chart.js ou Highcharts Le projet embarque déjà D3.js v4 et C3.js (un wrapper de charting au-dessus de D3). Pour des graphes statiques — afficher les résultats d'une expérience terminée — ça fonctionne. Mais pour du temps réel, le SVG est un problème structurel. En SVG, chaque point de données est un noeud DOM. Un ``, un ``, un segment de ``. Quand le capteur envoie 100 points par seconde et que l'expérience dure 5 minutes, on accumule 30 000 noeuds. Le navigateur doit gérer le layout, le hit-testing, et le repainting de 30 000 éléments DOM à chaque frame. Sur un iPad, ça tient jusqu'à 1 000-2 000 points. Au-delà, le framerate s'effondre. Le problème n'est pas D3.js en soi — D3 est un toolkit de bas niveau, il peut cibler Canvas autant que SVG. Mais C3.js, Chart.js, et la plupart des wrappers de charting de 2020 ciblent le SVG par défaut. Highcharts a un mode boost qui rend en Canvas, mais c'est un produit commercial avec une licence par développeur. Et aucune de ces librairies ne gère nativement le cas d'un flux de données continu avec un buffer circulaire qui écrase les anciennes valeurs. La conclusion s'impose : pour du temps réel avec des volumes de données scientifiques, il faut du Canvas. On construit notre propre moteur de rendu. ## L'architecture du Grapher Le moteur est découpé en modules fonctionnels, chacun responsable d'un aspect du rendu. Le `Grapher` orchestre, les modules dessinent. ``` GraphView/ ├── Grapher.js # Orchestrateur principal └── Grapher/ ├── Plot.js # Rendu des courbes (line, dots, histogram, verticalLines) ├── Axis.js # Rendu des axes Y et X (temps ou valeurs) ├── Scaler.js # Conversion valeurs ↔ pixels, gestion zoom/scroll ├── Marker.js # Marqueurs expérimentaux ├── Margins.js # Système de fenêtres imbriquées avec marges ├── Range.js # Calcul des plages de valeurs ├── AxisConfig.js # Configuration des axes ├── NumberFormatter.js # Formatage des nombres (notation scientifique, etc.) ├── UnitScaler.js # Mise à l'échelle des unités (mV→V, ms→s, etc.) ├── PlotWindow.js # Abstraction de la zone de dessin ├── Arrows.js # Flèches des axes └── constants.js # Marges globales, couleurs ``` ### Le principe stateless Le `Grapher` ne conserve aucun état entre deux frames. Chaque appel à `draw()` reçoit toutes les informations nécessaires (données, configuration, état du zoom/scroll) et redessine le canvas entier. C'est un choix délibéré : ```javascript constructor(canvas) draw(graphInfo, touchOptions) { // 1. Effacer tout let rootWindow = getRootWindow(this.canvas, this.ctx); clear(rootWindow); // 2. Calculer les configurations (marges, plages, formatteurs) const numberFormatter = getNumberFormatter(graphInfo.captureSettings); const = getAxisConfigs(graphInfo, rootWindow, numberFormatter); const timeRange = getTimeRange(graphInfo); // 3. Dessiner dans des fenêtres imbriquées avec clipping withMargins(rootWindow, GlobalMargin, axisWindow => { // Axes Y (multi-sources) drawMultipleYAxis(axisWindow, details, ranges, ...); // Courbes withMargins(axisWindow, plotMargins, plotWindow => { for (const graphDetails of graphInfo.details) }, ); // Axe X (temps ou valeurs) drawXTimeAxis(xAxisWindow, graphInfo, timeRange, ...); }); return ; } } ``` Le pattern `withMargins(window, margins, callback, )` est le coeur du système de layout. Il crée une sous-fenêtre avec des marges, applique un `ctx.clip()` pour empêcher le dessin de déborder, et appelle le callback avec la nouvelle fenêtre. Les fenêtres s'imbriquent : la fenêtre racine contient la fenêtre des axes, qui contient la fenêtre du plot, qui contient chaque courbe. Le clipping est essentiel : quand l'utilisateur zoome et que des points sortent de la zone visible, `clip()` garantit qu'ils ne débordent pas sur les axes ou les légendes. Sans clipping, un zoom ×10 sur une courbe peindrait des pixels sur toute la surface du canvas. ### Le Scaler : des valeurs physiques aux pixels La classe `Scaler` est le pont entre le monde des données (des valeurs en degrés Celsius, en mV, en secondes) et le monde de l'affichage (des positions en pixels). Elle gère aussi le zoom et le scroll interactifs. ```javascript constructor() { this.plotWindow = plotWindow; this.range = range; this.touchOptions = touchOptions || ; // ... } // Valeur → position Y en pixels yPosForValue = value => { const = this.range; const = this.plotWindow; const posFromBottom = ((value - this.valueMin) / step) * this.stepHeight + this.marginBottom - (this.deltaYPx % this.stepHeight); return height - posFromBottom; }; // Temps → position X en pixels xPosForTime = time => { const = this.touchOptions; const = this._timeRange; const pixPerSec = (scale.scaleX * this.drawWidth) / ((maxTime || 10) - (minTime || 0)); return (time - minTime) * pixPerSec + this.deltaXPx + this.marginLeft; }; // Position X en pixels → temps (pour le réticule) timeForXPos = pos => ; } ``` Le `deltaXPx` et `deltaYPx` sont les décalages de scroll en pixels, maintenus par le contrôleur de gestures (Hammer.js pour le tactile). Le `scale.scaleX` et `scale.scaleY` sont les facteurs de zoom, pilotés par le pinch. Tout est recalculé à chaque frame — pas d'état accumulé qui pourrait dériver. L'axe temporel est adaptatif. Le `Scaler` calcule automatiquement l'espacement des graduations en fonction de la durée visible : au-delà de 14 minutes, les graduations sont en minutes. Entre 2 et 14 minutes, en quarts de minute. Entre 14 secondes et 2 minutes, en secondes. En dessous d'une seconde, en millisecondes. Le choix de l'échelle se fait par une cascade de seuils : ```javascript get timeRange() { const span = max - min; let step; if (span > 60 * 14) step = Math.ceil(span / (10 * 60)) * 60; // minutes else if (span > 60 * 2) step = Math.ceil(span / (10 * 15)) * 15; // 1/4 minutes else if (span > 14) step = Math.ceil(span / 10); // secondes else if (span > 1) step = Math.ceil((span * 4) / 10) / 4; // 1/4 secondes else // ... } ``` ### L'auto-scaling des unités Le `UnitScaler` convertit automatiquement les unités pour l'affichage. Si les valeurs sont en millivolts mais dépassent 1000, l'axe affiche en volts. Si elles sont en millisecondes et dépassent 60000, l'affichage passe en minutes. C'est le genre de détail invisible qui fait la différence entre un graphe lisible et un axe couvert de chiffres à 6 décimales. ## Le rendu des courbes : sous-échantillonnage intelligent Le `Plot.js` dessine les données. Quatre modes de visualisation : lignes continues, points (scatter), histogrammes, et lignes verticales (pour les spectres FFT). Le mode ligne est le plus utilisé et le plus critique en performance. Le problème : un capteur qui échantillonne à 100Hz pendant 5 minutes produit 30 000 points. Dessiner 30 000 `lineTo()` à chaque frame, c'est 30 000 appels de dessin Canvas. Sur un iPad, c'est jouable mais serré à 60fps. Sur un téléphone Android d'entrée de gamme, c'est trop. La solution : le sous-échantillonnage adaptatif. On ne dessine pas tous les points — on en dessine un sous-ensemble en fonction de la résolution disponible. ```javascript // Calculer le pas d'échantillonnage en fonction du nombre de points let step = Math.floor(data.length / 3000) + 1; ``` Si le dataset contient 30 000 points, `step = 11` : on dessine un point sur onze. On trace au maximum ~3 000 segments, ce qui est largement en dessous de la capacité Canvas à 60fps. Quand l'utilisateur zoome, le nombre de points visibles diminue, le step diminue aussi (souvent à 1), et tous les détails apparaissent. C'est un LOD (Level of Detail) appliqué au rendu de courbes. Le dessin lui-même est un `beginPath()` / `moveTo()` / `lineTo()` classique : ```javascript function drawLinePlot(ctx, data, scaleValue, scaleIndex, style, step, overwriteIndex) { if (data.length < 2) return; ctx.lineWidth = 2; ctx.strokeStyle = style; if (overwriteIndex > 0 && overwriteIndex < data.length) { // Buffer circulaire : deux segments séparés ctx.beginPath(); ctx.moveTo(scaleIndex(0), scaleValue(data[0])); for (let index = 1; index < overwriteIndex; index += step) ctx.stroke(); ctx.beginPath(); ctx.moveTo(scaleIndex(overwriteIndex), scaleValue(data[overwriteIndex])); for (let index = overwriteIndex + 1; index < data.length; index += step) ctx.stroke(); } else { ctx.beginPath(); ctx.moveTo(scaleIndex(0), scaleValue(data[0])); for (let index = 1; index < data.length; index += step) ctx.stroke(); } } ``` Le `overwriteIndex` gère le cas du buffer circulaire : quand l'acquisition continue et que le buffer est plein, les nouvelles données écrasent les anciennes. La courbe se dessine en deux morceaux — l'ancien segment (qui va être écrasé) et le nouveau (qui est en train d'écrire). La séparation visuelle entre les deux est une rupture de la ligne, pas un artefact. ### Multi-axes et multi-sources Un graphe peut afficher plusieurs sources de données simultanément, chacune avec son propre axe Y. Température à gauche (°C, plage 0-100), pH à droite (sans unité, plage 0-14), les deux sur le même axe temporel. Le `drawMultipleYAxis()` dessine les axes de chaque source avec sa couleur, son unité, et son échelle. Le `drawPlot()` est appelé en boucle pour chaque source, dans la même fenêtre de dessin. Chaque source a son propre `Scaler` (avec sa propre plage Y) mais partage le même axe X (temps ou valeurs d'une source de référence). ## Les interactions tactiles Le `GraphView.controller` gère les gestes via Hammer.js : pinch pour zoomer, pan pour scroller, double-tap pour réinitialiser la vue. La détection d'axe est importante : un pinch horizontal zoome le temps, un pinch vertical zoome les valeurs, un pinch diagonal zoome les deux. Le réticule (crosshair) permet de mesurer des valeurs précises. En mode simple, un doigt posé sur le graphe affiche les coordonnées (temps, valeur) du point le plus proche. En mode double, deux doigts définissent un intervalle et affichent la différence (delta temps, delta valeur). C'est l'outil principal pour les mesures en TP de physique. Toutes les interactions passent par les `touchOptions` que le `Grapher` reçoit à chaque appel de `draw()`. Le scroll delta, le scale factor, la position du réticule — tout est calculé par le contrôleur et passé au `Grapher` comme paramètre. Le `Grapher` lui-même ne gère aucun événement, ne conserve aucun état. C'est un rendeur pur. ## Les performances Sur iPad Pro (2018), avec 10 000 points, 2 axes Y, un axe X temporel, et des interactions tactiles actives : le rendu complet d'une frame prend 4-6ms. À 60fps, le budget est de 16ms. On est dans les clous avec de la marge pour le reste de l'app (mise à jour des données, calculs, UI Angular). Sur un tablet Android milieu de gamme (Samsung Galaxy Tab A, Chromium WebView), les mêmes 10 000 points prennent 8-12ms. C'est plus serré mais ça passe. Le sous-échantillonnage fait son travail : au-delà de 3 000 points affichés simultanément, le step augmente et le coût de rendu reste constant. Le point critique n'est pas le Canvas lui-même, c'est la fréquence de redraw. En acquisition continue, chaque nouveau paquet de données déclenche un digest Angular, qui déclenche un redraw. À 100 paquets/seconde, c'est 100 redraws/seconde. La solution est un `requestAnimationFrame` qui découple la réception des données du rendu : les données s'accumulent dans le buffer, et le rendu se fait au rythme du display (60fps max), pas au rythme du capteur. ## Ce qu'on en retient **Le Canvas 2D est le bon outil pour le rendu temps réel de données scientifiques.** Le SVG a ses mérites (interactivité native, accessibilité, sérialisation), mais au-delà de quelques milliers de points mis à jour en continu, il ne tient pas. Le Canvas est un bitmap : le coût de rendu dépend de la résolution de l'écran, pas du volume de données. **Le sous-échantillonnage adaptatif est la clé de la scalabilité.** 30 000 points dans le buffer, 3 000 points dessinés : l'utilisateur ne voit pas la différence en vue dézoomée, et quand il zoome les détails apparaissent naturellement. C'est le même principe que le mipmapping en 3D, appliqué aux courbes 2D. **Un Grapher stateless est plus facile à maintenir.** Pas d'état interne qui dérive, pas de synchronisation à gérer entre le rendu et les données, pas de fuites mémoire par accumulation. Chaque frame est un dessin complet à partir de zéro. C'est plus coûteux en CPU qu'un rendu incrémental (ne redessiner que ce qui a changé), mais la simplicité du code compense largement. **L'architecture modulaire permet l'évolution.** Ajouter un nouveau type de plot (on pense au bar chart empilé pour les spectres), c'est ajouter une fonction dans `Plot.js` et un cas dans le switch. Ajouter un nouveau mode d'interaction (sélection de zone pour le zoom), c'est ajouter un champ dans `touchOptions`. Le `Grapher` n'a pas besoin de changer. _Voir aussi : l'[architecture générale de l'app hybride](/blog/eudoxe-app-hybride-educative-cordova-electron-architecture) et la [communication avec les capteurs C++](/blog/eudoxe-capteurs-custom-cpp-cordova-electron-protocole)._