origin

Petite rétrospective d’une expérimentation graphique de près de 20 ans en 15 étapes.


2002

Un jour, je me suis mis à dessiner des fléches…

origin

beaucoup de flèches 😀 … Du volume, du mouvement,…

old_arrows

2004

Je les ai ensuite colorié avec Photoshop ou Illustrator.

arf

La Fabrick breakz

trajectoires

2008

Je développe en ActionScript, des applications Flash puis Flex. C’est à cette époque que je découvre l’art génératif; j’ai la chance de voir Joshua Davis à FITC 2008 à Amsterdam. Je découvre aussi Erik Natzke , ou encore les travaux de Môssieur Nicoptère… Une grosse dose d’inspiration!

Si Flash était un outil tout à fait approprié pour générer ce genre de graphisme… je ne voyais pas encore comment coder mes flèches. Mes expérimentations s’étaient arrêtées à une vague tentative d’interpolation de formes ( les vrais savent !).

Flash interpolation

2010

Thoughts on Flash… Flash entame sa retraite anticipée.

2015

je continue à développer des applications Flex, mais, forcé et contraint, je commence à m’intéresser à d’autres technologies. Le JS bien sûr :|…, mais Dart aussi 🥰. Durant ces explorations, je suis intrigué puis séduit par le principe des Observables, avec RXJS, mais aussi avec les streams de Dart. Et c’est finallement en appliquant ces concepts à l’utilisation du canvas, que j’ai eu une idée d’implémentation pour générer quelque chose ressemblant à mes flèches 🎉 !

L’idée était finalement simple : un canvas interactif transformant une suite de positions du curseur en polygones “flèchés” : curseur => points => polygones => fleches

Avec ses streams, Dart semblait parfaitement adapté. j’avais en tête quelque chose de ce genre :

window
    ..onMouseMove.map(mouseToPoint).map(pointToPolygon).listen(onNewPolygon);

2016

J’arrive à boucler une 1ère version de Algraphr en Dart 1 “vanilla”.

algraphr

Avec le recul, je suis content de m’être lancé dans cette expérimentation. Dart 1 était déjà un language très confortable pour le développement web. J’ai rapidement pu :

  • dessiner dynamiquement du SVG,
  • transformer le SVG en bitmap,
  • l’afficher instantanèment dans un canvas,
  • l’exporter dans un PNG

Rien d’impossible à implémenter en JS, mais l’expérience avec Dart fut très plaisante. J’avais été frappé de la simplicité et l’efficacité de Dart pour ce projet : aucune dépendance, aucun outil à configurer 👍.

Un mot sur cette première implémentation : les formes “dynamiques” sont dessinées en SVG, et lorsqu’on les “freeze”, et elles sont redessinées sur un canvas. A dire vrai, je n’imaginais pas réellement au départ qu’une telle implémentation pourrait fonctionner de manière fluide… et pourtant ! ( Cette bonne surprise m’a aidé à poursuivre mon deuil de Flash :) ! )

L’histoire de Algraphr s’est longtemps arrêtée là.

2017

Je découvre Flutter 💙. Tellement de chose à explorer… En quelques semaines je porte une de mes laborieuses applications Flex mobile en Flutter et depuis : 🤩 !

2019

Adobe se sépare de la technologie Adobe AIR, et annonce le retrait définitif de Flash Player.

Apparitions des 1ères expérimentations “génératives” avec Flutter…, puis Flutter web et enfin

Flutter Create

avec, je ne sais toujours pas pourquoi, ma tête au milieu 🤯…

Il était plus que temps d’implémenter une version Flutter : Algrafx. Ce fut bien plus simple, et cela m’a permis de rapidement enrichir les options proposées.

algrafx

2020

Codepen intégre un éditeur Flutter, et on voit fleurir nombre de démonstrations des capacités de Flutter web. Je m’y essaye en intégrant d’abord algrafx dans Codepen… avant de bidouiller une suite de petites animations.

Cf. Codepen/rx-labz

Tout cela nous amène à l’été 2020, et c’est l’occasion de poursuivre mes fléches !


Au programme donc : dessin et animation de flèches dans un canvas Flutter ! Cela nous permettra de voir comment utiliser le Canvas de Flutter, pour opérations simples ( dessin de forme ) et plus évoluées ( dégradés, flou ).

➡ Pour commencer

Nous allons créer une application Flutter contenant un widget CustomPaint.

CustomPaint nous donne accès à la couche de painting, et va nous permettre de manipuler un Canvas, offrant les méthodes habituelles de dessins : moveTo, lineTo, drawRect, drawCircle

La responsabilité de manipuler le canvas est déléguée à une instance de CustomPainter, classe qu’il nous faut étendre pour “peindre” nos propres instructions.

 import 'dart:ui';
  import 'package:flutter/material.dart';
 
 void main() {
   runApp(MaterialApp(
     home: Scaffold(body: Board()),
     debugShowCheckedModeBanner: false,
   ));
 }
 
// on récupère la taille de la fenêtre pour dimensionner le dessin dans le canvas
 final size = window.physicalSize / window.devicePixelRatio;
 
 class Board extends StatelessWidget {
   @override
   Widget build(BuildContext context) => CustomPaint(
         size: size,
         painter: Painter(),
       );
 }
 
 class Painter extends CustomPainter {
   static final fill = Paint()..color = Colors.red;
 
   @override
   void paint(Canvas canvas, Size size) {
     // TODO
   }
 
   @override
   bool shouldRepaint(CustomPainter oldDelegate) => false;
 }


Etape 0 : L’origine

Au commencement, il y eut un point

Les plus grands voyages débutent par un 1er pas, ici nos lignes débuteront par un 1er point, ou plus exactement un rond, placé au centre de la fenêtre.

Le dessin sur le canvas étant, pour l’instant, fixe, shouldRepaint renvoie false.

class Painter extends CustomPainter {
  static const radius = 10.0;

  static final fill = Paint()..color = Colors.red;

  @override
  void paint(Canvas canvas, Size size) {
      // dessine un cercle de 10 pixels de rayon au milieu de l'écran 
      canvas.drawCircle(size.center(Offset.zero), radius, fill);
    }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

👀 codepen.io/rx-labz/pen/MWKXONp

step0b


Etape 1 : Follow this mouse

L’idée est d’obtenir une série de points, et pour cela nous allons suivre le curseur. Nous pouvons utiliser un widget MouseRegion, qui nous permet de récupérer sa position, et l’injecter ensuite dans le painter.

class Board extends StatefulWidget {
  @override
  _BoardState createState() => _BoardState();
}

class _BoardState extends State<Board> {
  Offset mouse = Offset.zero;

  @override
  Widget build(BuildContext context) => MouseRegion(
        // met à jour la position "mouse" à chaque mouvement de la souris
        onHover: (details) => setState(() => mouse = details.localPosition),
        // construit un CustomPaint basé sur un Painter utilisant la position enregistrée 
        child: CustomPaint(size: size, painter: Painter(mouse)),
      );
}

Nous injectons la position dans le painter, afin de re-dessiner le cercle à la position du curseur. A présent, le canvas sera re-dessiné pour chaque position du curseur, et shouldRepaint devra renvoyer true si la position change.

class Painter extends CustomPainter {
  static const radius = 10.0;

  static final fill = Paint()..color = Colors.red;

  final Offset position;

  const Painter(this.position);

  @override
  void paint(Canvas canvas, Size size) {
    // dessine un cercle à la position reçue
    canvas.drawCircle(position, radius, fill);
  }

  @override
  bool shouldRepaint(Painter oldDelegate) => position != oldDelegate.position;
}

step1

Stream

L’objectif étant de générer un graphisme en transformant un flux d’entrées en formes géometriques, on peut utiliser un stream et y ajouter les positions successives du curseur. Le Painter sera re-dessiné pour chaque valeur émise.

// cf. 
class _BoardState extends State<Board> {
  
  // on crée un streamController : un émetteur de positions
  final StreamController<Offset> _streamer = StreamController<Offset>();

  // le flux de positions
  Stream<Offset> get point$ => _streamer.stream;

  @override
  Widget build(BuildContext context) => MouseRegion(
        // ajoute les positions successives au flux
        onHover: (details) => _streamer.add(details.localPosition),
        // reconstruit le CustomPaint à chaque nouvelle position émise par le flux point$
        child: StreamBuilder<Offset>(
          initialData: Offset.zero,
          stream: point$,
          builder: (context, snapshot) =>
              CustomPaint(size: size, painter: Painter(snapshot.data)),
        ),
      );

  @override
  void dispose() {
    _streamer.close();
    super.dispose();
  }
}

👀 codepen.io/rx-labz/pen/VwedroV


Etape 2 : Petit Poucet

step2

Pour dessiner, nous allons laisser des traces du passage du curseur. Pour cela, au lieu d’émettre seulement un point, nous pouvons cumuler les positions successives du curseur dans une liste _points.

class _BoardState extends State<Board> {
  // liste des points accumulés
  final List<Offset> _points = [];
  
  // émetteur de listes de points 
  final StreamController<List<Offset>> _streamer =
      StreamController<List<Offset>>();

  Stream<List<Offset>> get _point$ => _streamer.stream;

  @override
  Widget build(BuildContext context) => MouseRegion(
        // ajoute chaque nouvelle position à la liste, 
        // et émet le nouveau contenu de la liste
        onHover: (details) => _streamer.add(_points..add(details.localPosition)),
        child: StreamBuilder<List<Offset>>(
          initialData: _points,
          stream: _point$,
          builder: (context, snapshot) =>
              CustomPaint(size: size, painter: Painter(_points)),
        ),
      );
}

Ne reste plus qu’à dessiner l’ensemble des points listés.

class Painter extends CustomPainter {
  // ...

  final List<Offset> points;

  const Painter(this.points);

  @override
  void paint(Canvas canvas, Size size) {
    // dessine un cercle pour à chaque position de la liste
    for (final point in points) canvas.drawCircle(point, 10, fill);
  }

  @override
  bool shouldRepaint(Painter oldDelegate) => true;
}

👀 codepen.io/rx-labz/pen/NWxzXKG


Etape 3 : le chemin

step3

Une fois que l’on a une série de points, on peut les relier. Pour cela on peut :

class Painter extends CustomPainter {
  static final fill = Paint()..color = Colors.red;

  static final stroke = Paint()
    ..color = Colors.grey
    ..style = PaintingStyle.stroke;

  final List<Offset> points;

  const Painter(this.points);

  @override
  void paint(Canvas canvas, Size size) {
    if (points.isEmpty) return;
    for (final point in points) canvas.drawCircle(point, 2, fill);

    // on relie chaque point au point suivant
    for (int i = 0; i < points.length - 1; i++) {
      canvas.drawLine(points[i], points[i + 1], stroke);
    }
  }

  @override
  bool shouldRepaint(Painter oldDelegate) => true;
}

👀 codepen.io/rx-labz/pen/BajVJNy


Etape 4 : Lignes ephemères

step4

Pour ne pas surcharger le canvas, nous allons limiter le nombre de points visibles.

const maxPoints = 29;

// ..

class _BoardState extends State<Board> {
  // ..

  @override
    Widget build(BuildContext context) => MouseRegion(
      onHover: (details) =>
          _streamer.add(Board._points..add(details.position)),
      child: StreamBuilder<List<Offset>>(
        initialData: Board._points,
        stream: point$.map(
          // garde les 29 derniers points 
          (points) => points.skip(max(0, points.length - maxPoints)).toList(),
        ),
        builder: (context, snapshot) =>
            CustomPaint(size: size, painter: Painter(snapshot.data)),
      ),
    );

  // ..
}

👀 codepen.io/rx-labz/pen/eYJKyNG


Etape 5 : moving point

step5

Pour animer le tracé, nous allons appliquer une pseudo-force à ses points.

Pour cela, plutôt que de manipuler des Offset, on peut créer une entité Point, sur laquelle nous appliquerons un déplacement proportionel à la force appliquée et subissant une légère accélération.

/// pseudo gravité d'1px vertical
const force = Offset(0, 1);

// facteur d'accéleration de la gravité
const acceleration = 1.1;

class Point {
  
  // position
  final Offset offset;

  // gravité
  final Offset force;

  // visible ou hors champs
  final bool active;

  const Point(this.offset, this.force, [this.active = true]);

  // application de la force à la position et de l'accélération à la force
  Point update() => active
      ? Point(offset + force, force * acceleration, offset.dy < size.height)
      : Point(Offset.zero, Offset.zero, false);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Point &&
          runtimeType == other.runtimeType &&
          offset == other.offset &&
          force == other.force &&
          active == other.active;

  @override
  int get hashCode => offset.hashCode ^ force.hashCode ^ active.hashCode;
}

Pour “rafraîchir” le dessin nous utilisons un controller d’animation “infinie” ( AnimationController.unbounded ). Cela nous permet de mettre à jour la position et la force de chaque point à intervalle régulier.

Pour pouvoir utiliser une animation, on ajoute le mixin SingleTickerProviderStateMixin à notre _BoardState.

A chaque tick, on filtre les points visibles, et on leur applique la force de manière à calculer leur nouvelle position.

class _BoardState extends State<Board> with SingleTickerProviderStateMixin {
  List<Point> _points = [];

  final StreamController<List<Point>> _streamer =
      StreamController<List<Point>>.broadcast()..add(<Point>[]);

  Stream<List<Point>> get _point$ => _streamer.stream;

  @override
  void initState() {
    // démarre une animation en boucle et ajoute un écouteur `_updatePoints`
    AnimationController.unbounded(vsync: this, duration: Duration(seconds: 1))
      ..repeat()
      ..addListener(_updatePoints);

    super.initState();
  }

  @override
  Widget build(BuildContext context) => MouseRegion(
        onHover: (details) =>
            _streamer.add(_points..add(Point(details.position, force))),
        child: StreamBuilder(
          initialData: _points,
          stream: _point$.map(
            (points) => points.skip(max(0, points.length - maxPoints)).toList(),
          ),
          builder: (_, stream) =>
              CustomPaint(size: size, painter: Painter(stream.data)),
        ),
      );
  
  // met à jour les points et les ajoute au flux 
  void _updatePoints() {
    _points = _points
        // filtre les points actifs, 
        .where((element) => element.active)
        // met à jour leur position et leur force 
        .map((element) => element.update())
        .toList();
    _streamer.add(_points);
  }
}

👀 codepen.io/rx-labz/pen/ZEQRREq


Etape 6 : Points => Segment => Polygone

Maintenant que nous avons notre liste de points, nous allons la transformer.

✴ ➡ |

points to vertical segment

Pour commencer nous allons tracer une ligne verticale au niveau de chaque point.

@override
void paint(Canvas canvas, Size size) {
  print('Painter.paint...');
  for (final point in points) {
    canvas.drawCircle(point.offset, 2, fill);
    
    // dessine un segment vertical au niveau de chaque point
    canvas.drawLine(
      point.offset - Offset(0, -50),
      point.offset - Offset(0, 50),
      stroke,
    );
  }
}

👀 codepen.io/rx-labz/pen/rNxKKxP

step6

✴ ➡ ▱

step6b

Nous dessinons à présent un parallèlogramme reliant deux points successifs. Nous allons pour cela créer une classe Segment, qui contiendra deux Points. Les segments posséderont également une couleur de remplissage et de contour. Les parralèlogrammes seront obtenus par transformation de ce segment.

class Segment {
  final Point point1;

  final Point point2;

  final Color strokeColor;

  final Color fillColor;

  Offset get offset1 => point1.offset;

  Offset get offset2 => point2.offset;

  bool get active => point1.active && point2.active;

  const Segment(this.point1, this.point2, {this.strokeColor, this.fillColor});

  // renvoie un segment avec les points mis à jour
  Segment update() => Segment(
        point1.update(),
        point2.update(),
        strokeColor: strokeColor,
        fillColor: fillColor,
      );
}

Nous passons donc d’une liste de Points à une liste de Segments.

  @override
  void initState() {
    _streamer = StreamController<List<Segment>>()..add(<Segment>[]);
    AnimationController.unbounded(vsync: this, duration: Duration(seconds: 1))
      ..repeat()
      ..addListener(_updateSegments);
    super.initState();
  }

  @override
  Widget build(BuildContext context) => MouseRegion(
        // ajoute un segment à chaque nouvelle position du curseur
        onHover: (details) => _addSegment(details.position),
        child: StreamBuilder<List<Segment>>(
          initialData: <Segment>[],
          stream: _segment$,
          builder: (_, stream) =>
              CustomPaint(size: size, painter: Painter(stream.data)),
        ),
      );

  /// ajoute un segment entre le dernier point du segment précédent et la nouvelle position
  void _addSegment(Offset offset) {
    _segments
      ..add(
        Segment(
          _segments.isEmpty ? Point(offset, force) : _segments.last.point2,
          Point(offset, force),
          strokeColor: strokeColor,
          fillColor: fillColor,
        ),
      );
  }
  
  // filtre le segments inactives et met à jour les segments et les ajoute au flux
  void _updateSegments() {
    _segments = _segments
        .where((element) => element.active)
        .map((element) => element.update())
        .toList();
    _streamer.add(_segments);
  }

Ensuite dans le Painter, nous déterminons les arrêtes du parallélogramme à partir des points du segments et nous les relions.

points to vertical segment

class Painter extends CustomPainter {
  static const radius = 2.0;

  static const offsetTop = Offset(0, -50);

  static const offsetBottom = Offset(0, 50);

  static final fill = Paint()..color = fillColor;

  static final stroke = Paint()
    ..color = Colors.grey
    ..style = PaintingStyle.stroke;

  final List<Segment> segments;

  const Painter(this.segments);

  @override
  void paint(Canvas canvas, Size size) {
    if (segments.isEmpty) return;

    for (final segment in segments.where((element) => element.active)) {
      canvas.drawCircle(segment.point1.offset, radius, fill);

      canvas.drawLine(
        segment.point1.offset - offsetTop,
        segment.point1.offset - offsetBottom,
        stroke,
      );
      canvas.drawLine(
        segment.point1.offset - offsetTop,
        segment.point2.offset - offsetTop,
        stroke,
      );
      canvas.drawLine(
        segment.point1.offset - offsetBottom,
        segment.point2.offset - offsetBottom,
        stroke,
      );
      canvas.drawLine(
        segment.point2.offset - offsetTop,
        segment.point2.offset - offsetBottom,
        stroke,
      );
    }
    
    for (int i = 0; i < segments.length; i++) {
      canvas.drawLine( segments[i].offset1, segments[i].offset2, stroke );
    }
  }

  @override
  bool shouldRepaint(Painter oldDelegate) => true;
}

Une meilleure API

Le résultat est bien celui recherché, mais simplifions un peu l’API.

Point.up(double) & Point.down(double)

class Point {
  final Offset offset;
  final Offset force;

  final bool active;

  static const zero = Point(Offset.zero, Offset.zero, false);

  const Point(this.offset, this.force, [this.active = true]);

  Point update() => active
      ? Point(offset + force, force * acceleration, offset.dy < size.height)
      : zero;

  Offset up(double value) => offset + Offset(0, -value);

  Offset down(double value) => offset + Offset(0, value);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Point &&
          runtimeType == other.runtimeType &&
          offset == other.offset &&
          force == other.force &&
          active == other.active;

  @override
  int get hashCode => offset.hashCode ^ force.hashCode ^ active.hashCode;
}

Segment.corners

class Segment {
  final Point point1;

  final Point point2;

  final Color strokeColor;

  final Color fillColor;

  Offset get offset1 => point1.offset;

  Offset get offset2 => point2.offset;

  bool get active => point1.active && point2.active;

  /// renvoie les coins du parallèlogramme correspondant au segment
  List<Offset> get corners => [
        point1.up(50),
        point2.up(50),
        point2.down(50),
        point1.down(50),
      ];

  const Segment(this.point1, this.point2, {this.strokeColor, this.fillColor});

  Segment update() {
    return Segment(
      point1.update(),
      point2.update(),
      strokeColor: strokeColor,
      fillColor: fillColor,
    );
  }
}

Pour finir, redessinons les paralèlogrammes à l’aide de Path.

class Painter extends CustomPainter {
  static const radius = 2.0;

  static final fill = Paint()..color = fillColor;

  static final stroke = Paint()
    ..color = Colors.grey
    ..style = PaintingStyle.stroke;

  final List<Segment> segments;

  const Painter(this.segments);

  @override
  void paint(Canvas canvas, Size size) {
    if (segments.isEmpty) return;

    for (final segment in segments) {
    
      // instanciation d'un path entre les quatres corners
      final path = Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
        ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close();
      
      // dessin de la forme
      canvas.drawPath(path, Paint()..color = segment.fillColor);

      // dessin du contour
      canvas.drawPath(
        path,
        Paint()
          ..color = segment.strokeColor
          ..style = PaintingStyle.stroke,
      );

      canvas.drawCircle(segment.offset1, radius, fill);
    }

    for (int i = 0; i < segments.length; i++) {
      canvas.drawLine( segments[i].offset1,segments[i].offset2,stroke );
    }
  }

  @override
  bool shouldRepaint(Painter oldDelegate) =>
      segments.isNotEmpty && !listEquals(segments, oldDelegate.segments);
}

👀 codepen.io/rx-labz/pen/abdKKNg __

Etape 7 : Couleurs

step7b

Pour animer la couleur, nous allons progressivement assombrir les couleurs appliquées à chaque segment. Pour cela nous pouvons convertir la couleur en HSLColor et baisser la luminosité. L’utilisation d’une extension permet de simplifier l’écriture de cette opération.

extension on Color {
  /// renvoie la HSLColor correspondante
  HSLColor get hsl => HSLColor.fromColor(this);
  
  /// renvoie la luminosité de la couleur
  double get lightness => hsl.lightness;

  /// renvoie la couleur après modification de la luminosité
  Color withLightness(double value) =>
      hsl.withLightness(min(1, hsl.lightness * .98)).toColor();
}

class Segment {
  //..

  Segment update() {
    // assombrit la couleur de remplissage
    final newFillColor = fillColor.lightness > 0
        ? fillColor.withLightness(min(1, fillColor.lightness * .98))
        : fillColor;

    return Segment(
      point1.update(),
      point2.update(),
      strokeColor: strokeColor,
      fillColor: newFillColor,
    );
  }
}

👀 codepen.io/rx-labz/pen/oNbyMYG

See the Pen Algfx_7 by rxlabz (@rx-labz) on CodePen.


Etape 8 : Vitesse et épaisseur

L’étape suivante va consister à faire varier l’épaisseur du bandeau en fonction de la vitesse de déplacement du curseur. Plus le curseur se déplace rapidement plus la ligne est fine.

a. Épaisseur de segment

Pour cela nous allons ajouter une épaisseur width aux segments, et la faire varier en fonction de la distance entre les 2 points. Le parallèlogramme dessiné sera défini par l’épaisseur.


/// largeur maximale
const segmentMaxWidth = 100.0;

const segmentMinWidth = 2.0;

/// longueur de référence
const segmentMaxLength = 200.0;

class Segment {
  final Point point1;

  final Point point2;

  final Color strokeColor;

  final Color fillColor;

  Offset get offset1 => point1.offset;

  Offset get offset2 => point2.offset;

  bool get active => point1.active && point2.active;

  List<Offset> get corners {
    final width = segmentWidth;
    return [
      point1.up(width),
      point2.up(width),
      point2.down(width),
      point1.down(width),
    ];
  }

  /// renvoie l'épaisseur du segment en fonction de la distance entre ses points
  double get segmentWidth => max(
        segmentMinWidth,
        segmentMaxWidth -
            (Rect.fromPoints(point1.offset, point2.offset).longestSide /
                    segmentMaxLength) *
                (segmentMaxWidth - segmentMinWidth),
      );

  const Segment(this.point1, this.point2, {this.strokeColor, this.fillColor});

  Segment update() {
    final newFillColor = fillColor.lightness > 0
            ? fillColor.withLightness(min(1, fillColor.lightness * .98))
            : fillColor;

    return Segment(
      point1.update(),
      point2.update(),
      strokeColor: strokeColor,
      fillColor: newFillColor,
    );
  }
}

👀 codepen.io/rx-labz/pen/zYraLoK

b. Chainage des segments

Pour “harmoniser” la ligne, nous allons transformer les parallélogrammes en trapèzes. Chaque trapèze aura une épaisseur “en entrée” et une épaisseur en sortie.

points to vertical segment

class Segment {
  final Point point1;

  final Point point2;

  final Color strokeColor;

  final Color fillColor;

  Offset get offset1 => point1.offset;

  Offset get offset2 => point2.offset;

  final Segment previous;

  const Segment(
    this.point1,
    this.point2, {
    @required this.previous, // segment précédent
    this.strokeColor,
    this.fillColor,
  });

  bool get active => point1.active && point2.active;

  /// renvoie les coins du trapèze basés sur l'épaisseur du segment précédent 
  /// et celle du segment lui même
  List<Offset> get corners {
    final previousWidth =
        previous != null ? previous.segmentWidth : segmentWidth;
    final width = segmentWidth;
    return [
      point1.up(previousWidth),
      point2.up(width),
      point2.down(width),
      point1.down(previousWidth),
    ];
  }

  double get segmentWidth => max(
        segmentMinWidth,
        segmentMaxWidth -
            (Rect.fromPoints(point1.offset, point2.offset).longestSide /
                    segmentMaxLength) *
                (segmentMaxWidth - segmentMinWidth),
      );

  Segment update() {
    final hslColor = HSLColor.fromColor(fillColor);
    final newFillColor = hslColor.lightness > 0
        ? hslColor.withLightness(min(1, hslColor.lightness * .98)).toColor()
        : fillColor;
    return Segment(
      point1.update(),
      point2.update(),
      previous: previous,
      strokeColor: strokeColor,
      fillColor: newFillColor,
    );
  }
}

👀 https://codepen.io/rx-labz/pen/mdVKjOK

Chaque segment contient le segment précedent, et peut donc obtenir l’épaisseur de l’extremité connexe.


Etape 9 : snapshot

Pour le moment tous les polygones disparaissent, nous allons ensuite maintenant les “fixer”.

La fixation pourrait être déclenchée manuellement ( au clic ou via la barre d’espace ), comme c’est le cas avec algrafx, mais dans cet exemple nous allons les figer automatiquement, à intervalle de temps régulier.


class Point {
  // ...

  /// chaque point peut être figé, en annulant la force qui lui est appliquée
  /// et en le désactivant
  Point freeze() => Point(offset, Offset.zero, false);

  // ...
}

class Segment {
  // ...
  
  /// les segments peuvent également être figés
  Segment freeze() => Segment(
    point1.freeze(),
    point2.freeze(),
    previous: previous,
    strokeColor: strokeColor,
    fillColor: fillColor,
  );
}

class _BoardState extends State<Board> with SingleTickerProviderStateMixin {
  // ...

  final List<List<Segment>> _freezedLines = [];
  StreamController<List<List<Segment>>> _freezedStreamer;
  Stream<List<List<Segment>>> get freezedShape$ => _freezedStreamer.stream;

  @override
  void initState() {
    //...

    // fige les segments visible à intervalle régulier
    Timer.periodic(Duration(seconds: 2), (timer) {
    final freezables = _segments
      .where((segment) => segment.active)
      .map((segment) => segment.freeze())
      .toList();
    _freezedLines.add([...freezables]);
    _freezedStreamer.add(_freezedLines);
    });
    
    //...
  }

  // ...
}

Pour ne pas redessiner plus que nécessaire les segments figés, nous allons créer un deuxième CustomPaint, sorte de calque, qui sera utilisé pour dessiner les polygones figés, et qui ne sera rafraîchit que lorsque la liste de segments change.

class _BoardState extends State<Board> with SingleTickerProviderStateMixin {
  // ...
  @override
  Widget build(BuildContext context) => MouseRegion(
        onHover: (details) => _addSegment(details.position),
        child: Stack(
          children: [
            StreamBuilder<List<List<Segment>>>(
              stream: freezedShape$,
              builder: (context, snapshot) => CustomPaint(
                size: size,
                painter: BackgroundPainter(snapshot.data ?? []),
              ),
            ),
            RepaintBoundary(
              child: StreamBuilder<List<Segment>>(
                initialData: <Segment>[],
                stream: _segment$,
                builder: (_, stream) => CustomPaint(
                    size: size, painter: ForegroundPainter(stream.data)),
              ),
            ),
          ],
        ),
      );
  // ...
}

Nous utilisons maintenant 2 CustomPainters :

  • ForegroundPainter dessine les segments “dynamiques”
class ForegroundPainter extends CustomPainter {
  static final fill = Paint()..color = fillColor;
  static final stroke = Paint()
    ..color = Colors.grey
    ..style = PaintingStyle.stroke;

  final List<Segment> segments;

  const ForegroundPainter(this.segments);

  @override
  void paint(Canvas canvas, Size size) {
    if (segments.isEmpty) return;
    for (final segment in segments)
      drawSegment(canvas, segment);
  }

  @override
  bool shouldRepaint(ForegroundPainter oldDelegate) =>
      segments.isNotEmpty && !listEquals(segments, oldDelegate.segments);
}

  • BackgroundPainter dessine les segments figés
class BackgroundPainter extends CustomPainter {
  final List<List<Segment>> lines;

  BackgroundPainter(this.lines);

  @override
  void paint(Canvas canvas, Size size) {
    for (final segments in lines) {
      for (final segment in segments) drawSegment(canvas, segment);
    }
  }

  @override
  bool shouldRepaint(BackgroundPainter oldDelegate) =>
      lines.isNotEmpty && !listEquals(lines, oldDelegate.lines);
}

les deux painters utilise une méthode drawSegment

void drawSegment(Canvas canvas, Segment segment) {
  final path = Path()
    ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
    ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
    ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
    ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
    ..close();
  canvas.drawPath(path, Paint()..color = segment.fillColor);
  canvas.drawPath(
    path,
    Paint()
      ..color = segment.strokeColor
      ..style = PaintingStyle.stroke,
  );
}

👀 codepen.io/rx-labz/pen/QWyxBGX


Etape 10 : autografx

Pour finir, nous pouvons remplacer le tracking du mouvement du curseur, et générer des positions aléatoires.

Le nombre de lignes figées est ici limité à 5.


// ...

const maxNumLines = 5;

class _BoardState extends State<Board> with SingleTickerProviderStateMixin {
  // ...

  Offset cursor;
    
  @override
  void initState() {
    cursor = size.bottomRight(Offset.zero) * random.nextDouble();

    _streamer = StreamController<List<Segment>>()..add(<Segment>[]);
    _freezedStreamer = StreamController<List<List<Segment>>>()..add([]);

    Timer.periodic(Duration(seconds: 2), (timer) {
      final freezables = _segments
          .where((element) => element.active)
          .map((element) => element.freeze())
          .toList();
      _freezedLines.add([...freezables]);
      _freezedStreamer.add(_freezedLines);
      if (_freezedLines.length == maxNumLines) _anim.reset();
    });

    _anim = AnimationController.unbounded(
        vsync: this, duration: Duration(seconds: 1))
      ..repeat()
      ..addListener(_onTick);
    super.initState();
  }

  void _onTick() {
    _moveCursor();
    _updateSegments();
  }
  
  // deplacement aléatoire 
  void _moveCursor() {
    double nextX = (random.nextDouble() * 300) - 150;
    if ((cursor.dx + nextX > size.width) || (cursor.dx + nextX < 0))
      nextX = nextX * -1;

    double nextY = (random.nextDouble() * 300) - 150;
    if (cursor.dy + nextY > size.height || cursor.dy + nextY < 0)
      nextY = nextY * -1;

    cursor = cursor + Offset(nextX, nextY);
    _addSegment(cursor);
  }
  
  // ...

}

👀 codepen.io/rx-labz/pen/GRoGXQJ

Voilà pour le principe, mais il manque encore quelques finitions :

  • ajouter un peu de volume et de lumière à l’aide de dégradés
  • ajouter la pointe des flèches
  • jouer avec le flou et l’opacité
  • entrelacer les fléches via un pseudo “z-ordering

Etape 11 : Dégradés

Pour ajouter un dégradé aux trapèzes, déclinons la couleur du segment : une version plus claire, et une plus sombre.

extension on Color {
  // ...

  Color darker(double factor) {
    final hslColor = HSLColor.fromColor(this);
    return hslColor
        .withLightness(max(0, hslColor.lightness * (1 - factor)))
        .toColor();
  }

  Color lighter(double factor) {
    final hslColor = HSLColor.fromColor(this);
    return hslColor
        .withLightness(min(1, hslColor.lightness * (1 + factor)))
        .toColor();
  }
}

Ensuite, ajoutons un dégradé entre la couleur claire, la couleur réelle et la couleur sombre. Pour créer un dégradé dans le canvas, on ajouter un shader de type Gradient.


void drawSegment(Canvas canvas, Segment segment) {
  final path = Path()
    ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
    ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
    ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
    ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
    ..close();
  canvas.drawPath(
    path,
    Paint()
      // définition d'un dégradé sur une diagonale du polygone,
      // entre un intervalle de couleur autoir de la couleur du segment
      ..shader = ui.Gradient.linear(
        segment.corners[0],
        segment.corners[2],
        [
          segment.fillColor.lighter(darkerFactor).withOpacity(globalOpacity),
          segment.fillColor.withOpacity(globalOpacity),
          segment.fillColor.darker(darkerFactor).withOpacity(globalOpacity),
        ],
        [.0, .3, .8],
      ),
  );
}

👀 codepen.io/rx-labz/pen/zYraJRL


Etape 12 : Pointes

Il est temps de dessiner la pointe de nos fléches. Le moyen le plus simple sera de transformer le premier segment non pas en parallèlogramme, mais en triangle.

step0b

void drawSegment(Canvas canvas, Segment segment, {bool isLast = false}) {
  
  final path = isLast
      ? (Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[0].dx, segment.corners[0].dy - 25)
        ..lineTo(
          segment.offset2.dx,
          min(segment.corners[1].dy, segment.corners[3].dy) +
              max(segment.corners[1].dy, segment.corners[3].dy) -
              min(segment.corners[1].dy, segment.corners[3].dy),
        )
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy + 25)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close())
      : (Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
        ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close());

  canvas.drawPath(
    path,
    Paint()
      ..shader = ui.Gradient.linear(
        segment.corners[0],
        segment.corners[2],
        [
          segment.fillColor.lighter(darkerFactor).withOpacity(globalOpacity),
          segment.fillColor.withOpacity(globalOpacity),
          segment.fillColor.darker(darkerFactor).withOpacity(globalOpacity),
        ],
        [.0, .3, .8],
      ),
  );
}

👀 codepen.io/rx-labz/pen/YzwvOem __

Etape 13 : Flou et opacité

Pour adoucir les tracés, nous pouvons superposer une version floutée des polygones. Cela produit un effet “lueur“ qui associée à une variation de l’opacité peut produire un effet graphique intéressant.

Pour cela nous allons utiliser un Paint.maskFilter. Les polygones seront progressivement floutés.

void drawSegment(
  Canvas canvas,
  Segment segment, {
  bool isLast = false,
  int count,
  int total,
}) {
  final path = isLast
      ? (Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[0].dx, segment.corners[0].dy - 25)
        ..lineTo(
          segment.offset2.dx,
          min(segment.corners[1].dy, segment.corners[3].dy) +
              max(segment.corners[1].dy, segment.corners[3].dy) -
              min(segment.corners[1].dy, segment.corners[3].dy),
        )
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy + 25)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close())
      : (Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
        ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close());

  // dessin net
  canvas.drawPath(
    path,
    Paint()
      ..shader = ui.Gradient.linear(
        segment.corners[0],
        segment.corners[2],
        [
          segment.fillColor
              .lighter(darkerFactor) ,
          segment.fillColor ,
          segment.fillColor
              .darker(darkerFactor) ,
        ],
        [.0, .3, .8],
      ),
  );

  // dessin flouté
  canvas.drawPath(
    path,
    Paint()
      ..shader = ui.Gradient.linear(
        segment.corners[0],
        segment.corners[2],
        [
          segment.fillColor.lighter(darkerFactor).withOpacity(globalOpacity),
          segment.fillColor.withOpacity(globalOpacity),
          segment.fillColor.darker(darkerFactor).withOpacity(globalOpacity),
        ],
        [.0, .2, .8],
      )
      // on applique le flou
      ..maskFilter = MaskFilter.blur(
          BlurStyle.normal, (total - count) / total * blurFactor),
  );
}

pour l’opacité, nous modifions la couleur de remplissage au moment de la mise à jour des segments.

class Segment{
  // ...
  
  Segment update() {
    final newFillColor = fillColor
        .withLightness(max(0.05, fillColor.lightness * lightnessFactor))
        // applique un facteur de transparence
        .withOpacity(fillColor.opacity * opacityFactor);
    return Segment(
      point1.update(),
      point2.update(),
      previous: previous,
      strokeColor: strokeColor,
      fillColor: newFillColor,
    );
  }
}

👀 codepen.io/rx-labz/pen/MWKXqVr


Etape 14 : Pseudo Z order

Pour cette dernière étape, l’objectif est d’entremêler les polygones successifs. Pour cela nous allons ré-ordonner les segments figés en fonctions de leur opacité.

class BackgroundPainter extends CustomPainter {
  final List<List<Segment>> lines;

  BackgroundPainter(this.lines);

  @override
  void paint(Canvas canvas, Size size) {
    final allSegments = <Segment>[];
    
    // liste l'ensemble des segments 
    for (final segments in lines) {
      for (final segment in segments) {
        allSegments.add(segment == segments.last ? segment.lastified : segment);
      }
    }

    // trie les segments en fonctions de leur opacité
    allSegments.sort((s1, s2) {
      if (s1.opacity > s2.opacity) return 1;
      if (s1.opacity < s2.opacity) return -1;
      return 0;
    });

    int count = 0;
    for (final segment in allSegments) {
      drawSegment(
        canvas,
        segment,
        isLast: segment.isLast,
        count: count,
        total: allSegments.length,
      );
      count++;
    }
  }

  @override
  bool shouldRepaint(BackgroundPainter oldDelegate) => true;
}

👀 codepen.io/rx-labz/pen/JjGZaLw

See the Pen Algfx_14 by rxlabz (@rx-labz) on CodePen.


Et voilà ! Nous sommes arrivés à un rendu relativement proche des flèches initiales, et nous avons fait ample connaissances avec CustomPaint.

A partir de là, à vous de jouer !