L’app hybride pour l’acquisition scientifique 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 <circle>, un <line>, un segment de <path>. 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.

svg vs canvas — coût par point de donnéesSVG (D3/C3/Chart.js)1 point = 1 noeud DOMcoût : O(n) DOM mutations par frame1K points → OK · 5K → lent · 10K → inutilisablehit-testing natif, accessibilité, mais pas temps réelCanvas 2D1 point = 1 appel lineTo()coût : O(pixels) par frame, pas O(data)10K → fluide · 100K → fluide avec sous-échantillonnagepas de hit-testing natif, mais O(1) en complexité mémoire

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é :

export default class Grapher {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = this.canvas.getContext("2d");
  }

  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 { ranges, plotMargins, xAxisMargins, xAxisRange } =
      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) {
          drawPlot(plotWindow, graphDetails, range, ...);
        }
      }, { clipped: true });
      // Axe X (temps ou valeurs)
      drawXTimeAxis(xAxisWindow, graphInfo, timeRange, ...);
    });

    return { GlobalMargin, plotMargins };
  }
}

Le pattern withMargins(window, margins, callback, { clipped }) 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.

export default class Scaler {
  constructor({ plotWindow, timeRange, range, xAxisRange, touchOptions, margins }) {
    this.plotWindow = plotWindow;
    this.range = range;
    this.touchOptions = touchOptions || {};
    // ...
  }

  // Valeur → position Y en pixels
  yPosForValue = value => {
    const { step } = this.range;
    const { height } = 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 { scale } = this.touchOptions;
    const { minTime, maxTime } = 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 => {
    const minPos = this.xPosForTime(this.minTimeScaled);
    const maxPos = this.xPosForTime(this.maxTimeScaled);
    const span = maxPos - minPos;
    return this.minTimeScaled +
      ((pos - minPos) / span) * (this.maxTimeScaled - this.minTimeScaled);
  };
}

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 :

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 {
    const exp = Math.pow(10, Math.floor(Math.log10(span))) / 10;
    step = Math.floor(span / (exp * 10)) * exp;                         // millisecondes
  }
  // ...
}

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.

// 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 :

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.lineTo(scaleIndex(index), scaleValue(data[index]));
    }
    ctx.stroke();

    ctx.beginPath();
    ctx.moveTo(scaleIndex(overwriteIndex), scaleValue(data[overwriteIndex]));
    for (let index = overwriteIndex + 1; index < data.length; index += step) {
      ctx.lineTo(scaleIndex(index), scaleValue(data[index]));
    }
    ctx.stroke();
  } else {
    ctx.beginPath();
    ctx.moveTo(scaleIndex(0), scaleValue(data[0]));
    for (let index = 1; index < data.length; index += step) {
      ctx.lineTo(scaleIndex(index), scaleValue(data[index]));
    }
    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 et la communication avec les capteurs C++.