Si vous avez déjà eu l'occasion de passer sur mon blog, vous savez peut-être que je suis un dev' qui aime faire des choses jolies et interactives (je n'ai pas dit que c'était réussi :D). Du coup, le SVG, c'est rigolo. On peut faire les deux avec.

Vous avez peut-être aussi compris que j'aime bien React. Non pas parce que je pense que c'est le seul moyen de faire une application web, mais parce que c'est une bibliothèque avec laquelle je suis efficace, qui rend mes applications maintenables et avec laquelle je prend du plaisir à coder.

C'est donc naturellement que j'aime lier les deux.

Faire des schémas en SVG

C'est ainsi que je me suis retrouvé à écrire une bibliothèque qui génère des schemas en SVG représentant des Observables (au sens des bibliothèques de programmation reactive, ex : RxJS). Voici un exemple de ce à quoi ça peut ressembler :

sendMessageRequest$req1req2sendMessageResponse$$rep1rep2sentMessage$rep1rep2sentMessageList$[][.][..]
Un tas de lignes avec des bulles dessus, quoi.

Pour représenter un tel schéma, j'écris des lignes de code qui ressemblent à ça :

const Schema = () => (
  <Viz>
    <Line legend="sendMessageRequest$">
      <Element
        color={firstColor}
        preview="req1"
        value={{
          action: "send_message",
          message: { content: "Bonjour !" }
        }}
      />
    </Line>
    <Line legend="sendMessageResponse$$">
      <ObservableElement color={firstColor}>
        <Element
          preview="req1"
          value={{
            action: "send_message",
            message: { content: "Bonjour !" }
          }}
        />
      </ObservableElement>
    </Line>
  </Viz>
)

Je trouve ça assez agréable à utiliser et je génère rapidement des schémas à l'aide de cet outil.

– C'est du XML quoi..., dit-iel, levant les yeux au ciel.

Oui, mais le but de cet article est plutôt de se concentrer sur le placement des éléments SVG plutôt que sur la partie React. Ainsi, pour mieux comprendre comment j'en suis arrivé à ça, on va tout réécrire pour voir comment j'ai pu en arriver à cette solution.

Construisons un arbre

Nous n'allons pas refaire la bibliothèque sur les Observables car ce serait trop complexe à faire tenir dans un seul article. Nous allons plutôt représenter l'arbre suivant :

CamilleBobRaymondeJulesPierreAliceAlphonseGertrude
Un tas de lignes avec des bulles dessus, quoi.

C'est la version simplifiée d'un outil que je développe qui représente l'arbre généalogique des personnages d'un roman.

Préambule

Avant d'attaquer, posons nous un petit instant sur le fonctionnement du SVG. En effet, celui-ci a une différence majeure par rapport au combo HTML&CSS : on est obligé de gérer nous-même le placement des éléments les uns par rapport aux autres. Si on ne le fait pas, les éléments se superposent.

Carré rouge sur carré jaune, oeuvre déposée
<svg viewBox="0 0 15 15">
  <rect fill="#FFC107" width="15" height="15" />
  <rect fill="#EF5350" width="10" height="10" />
</svg>

Devoir placer des éléments à la main quand un ordinateur pourrait le faire à ma place, ça ne me fait pas rêver. Surtout quand je fais un schéma : si j'ajoute un élément quelque part, je n'ai pas envie de décaler tous les autres éléments un par un. Sinon, je le ferai avec Gimp/Inkscape/Illustrator.

C'est pour cette raison que pour représenter cet arbre, je préfère écrire le bout de code suivant, en laissant le soin à mon ordi' de tout placer convenablement :

<Tree>
  <Node name="Camille">
    <Node name="Bob">
      <Node name="Raymonde">
        <Node name="Jules" />
      </Node>
      <Node name="Pierre" />
    </Node>
    <Node name="Alice">
      <Node name="Alphonse" />
      <Node name="Gertrude" />
    </Node>
  </Node>
</Tree>

Ainsi, dans un arbre (Tree) j'ai un noeud (Node) qui lui même peut avoir des enfants qui sont à leur tour des noeuds (Node).

Représentation d'un noeud

Ok. Commençons par le commencement et représentons un noeud unique. Celui-ci est constitué d'une bordure et d'un texte. Nous pouvons l'écrire de la façon suivante :

const width = 80;
const height = 30;

const Node = props => (
  <g>
    <rect
      width={width}
      height={height}
      strokeWidth={1}
      stroke="#484848"
      fill="white"
      rx={5}
      ry={5}
    />
    <text
      x={width / 2}
      y={height / 2}
      textAnchor="middle"
      alignmentBaseline="middle"
      fill="#484848"
      fontSize={14}
    >
      {props.name}
    </text>
  </g>
);

const Tree = props => <svg>{props.children}</svg>;

const Demo = () => (
  <Tree>
    <Node name="Camille" />
  </Tree>
);

Ce qui donne :

Camille

C'est déjà bien. On affiche ce qu'on veut.

Par contre, on a notre premier problème qui est à l'origine de tous nos maux : Tree, le parent qui affiche Node, ne connait pas la taille de son enfant. Pourtant il en a besoin pour renseigner la propriété viewBox dans la balise svg et ainsi afficher les choses à la bonne échelle. Comment s'en sortir ?

Récupération de la taille des noeuds

La solution consiste à faire en sorte que le composant parent (Tree) puisse récupérer la taille des composants qu'il affiche.

const Node = /* ne change pas */;

const Tree = (props) => {
  // props.children est le noeud
  // racine de l'arbre qu'on veut
  // afficher
  const nodeElement = props.children

  // De plus, on a accès au composant
  // React utilisé par l'enfant via
  // nodeElement.type
  // (Je ne suis pas sûr que ce soit
  // légal, mais ça permet de savoir
  // à quel noeud on a affaire)

  let viewBox
  if (nodeElement.type === Node) {
    // Si c'est un Node, on sait
    // qu'il fait 80 x 30
    viewBox = "0 0 80 30"
  } else {
    // Sinon, on ne connait pas le
    // composant enfant donc on
    // n'affiche rien
    return null
  }

  return <svg viewBox={viewBox}>
    {props.children}
  </svg>
}

const Demo = /* ne change pas */
Camille

Le schéma prend toute la place qui lui est mis à disposition.

Voilà qui est mieux. Si on a un Node dans notre arbre, on sait que le SVG fera 80 × 30. Cela dit, est-on bien sûr qu'il fasse 80 × 30 ? Après tout, si le noeud a des enfants qui eux même ont des enfants, il sera bien plus grand que 80 × 30.

Nous allons donc devoir déléguer le calcul de cette taille à l'enfant. On pourrait donc remplacer viewBox = "0 0 80 30" par quelque chose du style : Node.getViewBox(nodeElement.props). Cela permet de mettre le calcul de la viewBox au bon endroit.

Cependant cela pose un dernier problème. Si Node fait des calculs complexes pour savoir quoi afficher, il risque faire le boulot deux fois. Une première fois au moment du getViewBox et une autre fois au moment du render. L'idée est donc de tout faire d'un coup : le render et le calcul de la viewBox.

// Le composant n'affiche plus rien
// étant donné qu'on fait tout le
// boulot dans `getContext`
// C'est aussi pour cette raison
// que ça pourrait être du XML,
// et ce serait pareil.
const Node = () => null;

// On recapitule ce que Node est
// censé afficher et comment.
Node.getContext = (props) => {
  return {
    width: 80,
    height: 30,
    element: <g>
      <rect ... />
      <text ... />
    </g>
  }
};

const Tree = (props) => {
  const nodeElement = props.children

  let context
  if (nodeElement.type === Node) {
    // Plutôt que de deviner la taille du
    // composant, on lui demande toutes
    // les informations nécessaires
    context = Node.getContext(
      nodeElement.props
    )
  } else {
    return null
  }

  // Et on affiche le SVG avec les
  // informations qu'on a pu récupérer
  return <svg viewBox={`0 0 ${context.width} ${context.height}`}>
    {context.element}
  </svg>
}

const Demo = /* ne change pas */
Camille

Affichage des enfants des noeuds

Maintenant qu'un noeud peut avoir une taille dynamique sans poser problème au parent, rendons le vraiment dynamique. Pour cela, nous allons utiliser exactement la même technique mais à l'intérieur de chaque noeud :

  1. on construit le contexte qui contient tous les enfants pour savoir comment les afficher
  2. on calcule la taille globale du noeud en incluant les enfants (width, height)
  3. on reforme le noeud à afficher en incluant les enfants (element)
Node.getContext = props => {
  // Contexte du noeud sans ses enfants
  const parentContext = {
    width: 80,
    height: 30,
    element: <g>
      <rect ... />
      <text ... />
    </g>
  };

  // Si le noeud n'a pas d'enfant, le
  // contexte ne bouge pas
  if (!props.children) {
    return parentContext;
  }

  // Calcul du contexte des enfants
  let childrenContext = {
    width: 0,
    height: 0,
    children: []
  };

  // On récupère les enfants du noeud
  // et on s'assure que ce soit un tableau
  // Ex : s'il n'y a qu'un seul enfant,
  // props.children n'est pas un tableau
  const children = Array.isArray(props.children)
    ? props.children :
    [props.children]
  
  // Chaque enfant vient ajouter sa
  // largeur et sa hauteur au contexte
  // global et ajoute les éléments
  // utils à l'affichage
  children.forEach((child, index) => {
    if (child.type === Node) {
      // L'enfant du noeud est un noeud.
      // On récupère donc son contexte
      // pour savoir comment il va
      // influencer les autres enfants
      const childContext =
        Node.getContext(child.props);

      // On décale l'enfant vers la
      // droite en fonction des enfants
      // qui le précèdent.
      // Sinon, les enfants se superposent.
      const childElement = (
        <g
          key={index}
          transform={`translate(${childrenContext.width}, 0)`}
        >
          {childContext.element}
        </g>
      );

      // Puis on ajoute l'enfant transformé
      // à la liste des enfants à afficher.
      childrenContext.children.push(
        childElement
      );

      // Maintenant qu'il y a un nouvel
      // enfant, on indique que la somme
      // des enfants est un peu plus
      // large qu'avant.
      childrenContext.width +=
        childContext.width;

      // Idem pour la hauteur :
      // la hauteur de tous les enfants
      // est égale à la hauteur du plus
      // grand des enfants
      childrenContext.height = Math.max(
        childrenContext.height,
        childContext.height
      );
    } else {
      // Comme pour Tree, on ignore les
      // enfants qu'on ne sait pas gérer.
      console.warn(
        "Type d'enfant inconnu",
        child
      );
    }
  });

  // Maintenant qu'on connait le contexte
  // du parent et des enfants, on
  // fusionne les deux pour créer le
  // contexte final

  const parentHorizontalMargin =
    childrenContext.width / 2
    - parentContext.width / 2;

  const childrenVerticalMargin =
    parentContext.height;

  return {
    // Les enfants sont en dessous du
    // parent, donc la largeur totale est
    // le max entre la largeur du parent
    // et celle des enfants
    width: Math.max(
      parentContext.width,
      childrenContext.width
    ),
    // Les enfants sont en dessous du
    // parent, donc les hauteurs
    // s'additionnent.
    height: parentContext.height
      + childrenContext.height,
    // On reconstruit l'élément complet
    // qui inclut le parent et les enfants.
    // On n'oublie pas des transformations
    // pour centrer le parent et placer
    // les enfants sous le parent
    element: (
      <g>
        <g transform={`translate(${parentHorizontalMargin}, 0)`}>
          {parentContext.element}
        </g>
        <g transform={`translate(0, ${childrenVerticalMargin})`}>
          {childrenContext.children}
        </g>
      </g>
    )
  };
};

Et voilà !

CamilleBobAlice

Nous avons donc affiché un arbre qui contient des noeuds qui eux-même peuvent contenir des noeuds. Pour éviter de trop surcharger les exemples de code, j'ai retiré certaines parties telles que les lignes entre les noeuds, l'espacement entre les noeuds, etc.

Mais fondamentallement, il y a tout ce qu'il faut pour faire le reste :

  • Gestion d'un contexte pour savoir ce qu'un noeud doit afficher (Node.getContext)
  • Possibilité de mettre l'information que l'on veut dans un contexte (width, height, element, children, ... et anchor pour afficher les lignes entre les noeuds ?)
  • Séparation des responsabilités : un noeud doit savoir comment s'afficher mais ne doit pas être responsable de son influence sur les autres noeuds. Il va plutôt remonter des informations au parent (ex : width, height et element) et laisse ce dernier gérer le reste (ex :dans le noeud parent, on entoure chaque enfant par <g transform="...">...</g>)

Pour voir comment gérer les lignes et les espacements, vous pouvez lire le code sur github.

Conclusion

Avec cette technique de contexte, on a réussi à afficher le SVG qu'on voulait. On a même pu l'appliquer à différents cas d'utilisation (observables et arbres de données). Mais quand même, ça fait bizarre d'écrire tout ça. Est-ce que c'est vraiment la bonne solution ? Honnêtement, je ne sais pas. Mais j'aime bien cette notion de contexte d'affichage/execution même si elle n'a rien de révolutionnaire en soi.

Il reste tout de même quelques points douteux à éclaircir :

  • Pourquoi avoir utilisé React ? En effet, si on y réfléchit, ce n'est pas très utile d'avoir utilisé React ici. En ayant des composants qui retournent tous null, cela veut dire qu'on utilise React uniquement pour définir de quoi est composé notre arbre. Autant utiliser du JSON. Ou du XML.

    <Tree definition={{
      type: "node",
      name: "John",
      children: [
        { type: "node", name: "Alice" },
        { type: "node", name: "Bob" }
      ]
    }} />
    
    /* vs */
    
    <Tree>
      <Node name="John">
        <Node name="John" />
        <Node name="John" />
      </Node>
    </Tree>
    

    Si les données viennent d'une API, c'est d'ailleurs très certainement ce qu'il faudra privilégier plutôt que de refaire les noeuds à la main.

    Cependant, là où React reste utile, c'est dans le calcul des éléments à afficher pour chaque noeud (la clé element dans Node.getContext). En effet, il est pratique ici de rajouter des event listeners et donc des interactions sur les noeuds que vous voulez. Cela permettrait par exemple d'afficher des informations supplémentaires au survol (la date de naissance, une citation, etc.). J'en ai d'ailleurs eu besoin sur mes schémas d'observables (attention, le code est moins propre).

  • Si on a appelé tout ça un contexte, est-ce que ce ne serait pas plus simple de passer par l'API des contextes en React quitte à y être ? Je pense que ça peut se faire mais que ça amenerait quelques complications :

    • Le SVG ne serait pas affiché du premier coup. Il faudrait attendre que tous les composants soient montés avant de pouvoir afficher quelque chose, entrainant par là même des complications pour faire du SSR.
    • Ce serait compliqué de faire en sorte qu'un composant soit monté pour appeler le contexte alors qu'on ne veut pas vraiment afficher ses enfants mais plutôt la représentation de ses enfants.
    • Cela risquerait nous coincer dans l'utilisation de l'API qui d'ailleurs est toujours considérée instable.
    • Mais ça se tente. :) Si vous voulez expérimenter sur le sujet, n'hésitez pas à m'en faire part !
  • Un inconvénient cependant est qu'on est obligé de connaître à l'avance tous les noeuds enfants. Adieu la magie de la composition des éléments React. Cela dit, on pourrait adapter cette technique à l'API des returns/calls qui est entrain d'être étudiée par la core team (voici un tuto en anglais qui en parle plus en détail).

En tout cas, c'est agréable de faire ce genre d'expérimentations. Je suis sûr d'approcher le problème différemment la prochaine fois que je dois afficher des choses sur un écran. Cette notion de contexte pour calculer la position des enfants me plait bien. Une corde de plus à mon arc en quelque sorte.

Et vous, qu'en pensez-vous ? Ca vous paraît fou/bête/intéressant ? N'hésitez pas à me faire part de vos avis sur github, twitter ou autre.

Au plaisir !