la dataviz pour les hipsters

Alexis Jacomy

(twitter|github|slides).com/jacomyal

de la dataviz

Tout le monde veut

dataviz,

c'est :

et une

Un design

Un algorithme

Un moteur de rendu

d3.js

SVG

rendu graphique

on a plein de solutions de

Et pourtant

Les démos sont sur github

La heatmap

Exemple 1

Petite confession

Adobe FLASH

J'ai appris À développer sous

Peu d'outils

Une API de tracé riche et variée

AUCUNE bonne pratique

Plein de méthodes innovantes et variées !

Les meatballs

Comment on fait

metaballs

Metaballs :

Approche théorique

approche théorique :

(x - x_0)^2 + (y - y_0)^2 \le r_0^2
(xx0)2+(yy0)2r02(x - x_0)^2 + (y - y_0)^2 \le r_0^2
\dfrac{r_0^2}{(x - x_0)^2 + (y - y_0)^2} \ge 1
r02(xx0)2+(yy0)21\dfrac{r_0^2}{(x - x_0)^2 + (y - y_0)^2} \ge 1

équation pour un disque :

approche théorique :

\displaystyle\max_{i=0}^n\dfrac{r_i^2}{(x - x_i)^2 + (y - y_i)^2} \ge 1
maxi=0nri2(xxi)2+(yyi)21\displaystyle\max_{i=0}^n\dfrac{r_i^2}{(x - x_i)^2 + (y - y_i)^2} \ge 1
\displaystyle\sum_{i=0}^n\dfrac{r_i^2}{(x - x_i)^2 + (y - y_i)^2} \ge 1
i=0nri2(xxi)2+(yyi)21\displaystyle\sum_{i=0}^n\dfrac{r_i^2}{(x - x_i)^2 + (y - y_i)^2} \ge 1

équation pour Plusieurs disques :

équation pour les metaballs :

approche théorique :

APPROCHE THÉORIQUE :

Trop peu performant

O (pixels x balls)

Pas d'antialiasing

"marching cubes"

(excellent tuto ici)

approche théorique :

Optimisation avec les

APPROCHE THÉORIQUE :

Trop peu performant

O (blocks x balls)

Excellent résultat

MAIS toujours

Le

"Gooey effect"

Approche pratique :

APPROCHE PRATIQUE :

Flou

Contraste

APPROCHE PRATIQUE :

FILTER: blur()

filter: contrast()

APPROCHE PRATIQUE :

Les heatmaps, donc

Retour à la dataviz :

Étape 1 : nuage de points

import DATA from '../assets/data.json';

const MAX_X = Math.max(...DATA.map(([vx, vy]) => vx));
const MAX_Y = Math.max(...DATA.map(([vx, vy]) => vy));
const POINTS = document.getElementById('points');

// Créé un point pour chaque individu :
DATA.forEach(([ vx, vy ]) => {
  const point = document.createElement('div');
  point.classList.add('point');
  point.style.left = (vx / MAX_X * 100) + '%';
  point.style.bottom = (vy / MAX_Y * 100) + '%';

  POINTS.appendChild(point);
});

Étape 2 : Filtres

<!DOCTYPE html>
<html>
<head>
  <style>
    #points {
      filter: url('#posterize');
    }
  </style>
</head>
<body>
  <div style="visibility:hidden;">
    <svg>
      <filter id="posterize">
        <feComponentTransfer>
          <feFuncR type="discrete" tableValues="0 0.25 0.5 0.75 1" />
          <feFuncG type="discrete" tableValues="0 0.25 0.5 0.75 1" />
          <feFuncB type="discrete" tableValues="0 0.25 0.5 0.75 1" />
        </feComponentTransfer>
      </filter>
    </svg>
  </div>
</body>
</html>

Étape 3 : Le reste

Résultat :

Le CAMEMBERT

Exemple 2

3D !

C'est compliqué

Le WebGL,

Problème :

déjà

low-levels structures

low-levels functions

TypedArray

glsl

et aussi

Les vertex shaders

Enfin

Les Fragment shaders

par exemple

Bref...

Le WebGL n'a rien à voir avec tout ce qu'on connait dans le dev web

THREE.js

à la rescousse !

Une doc

plutôt bonne (ici)

Des tonnes d'exemples de code ()

Mais surtout

Google

Et comment on teste une nouvelle techno ?

Driven

Development !

Étape 1 : Le camembert

const total = DATA.map(a => a.value).reduce((a, b) => a + b);

DATA.forEach(({ value, color, label }) => {
  const angle = 2 * Math.PI * value / total;

  const material = new THREE.MeshPhongMaterial({ color });

  // Forme en 2D :
  const geometry = new THREE.Shape();
  geometry.moveTo(0, 0);
  geometry.arc(0, 0, SIZE, acc, acc + angle, false);
  geometry.lineTo(0, 0);

  // Forme en 3D :
  const extruded = new THREE.ExtrudeGeometry(
    geometry,
    { amount: value * SIZE / 2 }
  );

  const slice = new THREE.Mesh(extruded, material);
  scene.add(slice);

  acc += angle;
});

Étape 1 : Le camembert

Étape 2 : Les labels

function makeTextSprite(message) {
  // On créé la texture :
  //   1. On créé un canvas
  const canvas = document.createElement('canvas');
  const context = canvas.getContext('2d');

  context.fillStyle = '#000';
  context.font = '30px sans-serif';

  //   2. On dessine le texte
  context.fillText(message, 0, 30);

  //   3. On génère une texture
  const map = new THREE.Texture(canvas);
  map.needsUpdate = true;
  const material = new THREE.SpriteMaterial({ map });

  //   4. On génère une Sprite
  const sprite = new THREE.Sprite(material);
  sprite.scale.set(100, 50, 1);

  return sprite;
}

Étape 2 : Les labels

Étape 3 : LE swag

// On créé d'abord 2 caméras, puis on merge les images
// grace au fragment shader suivant :

uniform sampler2D mapLeft;
uniform sampler2D mapRight;
varying vec2 vUv;

void main() {
  vec4 colorL, colorR;
  vec2 uv = vUv;

  colorL = texture2D(mapLeft, uv);
  colorR = texture2D(mapRight, uv);

  gl_FragColor = vec4(
    colorL.g * 0.7 + colorL.b * 0.3,
    colorR.g,
    colorR.b,
    colorL.a + colorR.a
  );
}

Étape 3 : Le SWAG

// On veut que la caméra alterne entre deux positions très proches,
// pour donner l'effet stéréoscopique :

const angle = (
  // Angle de base :
  -Math.PI / 3
  // On alterne toutes les 50ms :
  + Math.floor(Date.now() / 50) % 2 ?
    0 :
    Math.PI / 200
  );

// FOCAL : Distance entre le centre de l'objet et la caméra :
const direction = new Vector3(
  FOCAL * Math.cos(angle),
  -FOCAL * Math.sin(angle),
  FOCAL
);
const position = CENTER.clone().add(direction);

camera.position.set(...position.toArray());
camera.lookAt(CENTER);
renderer.render(scene, camera);

Étape 4 : La Fallback

Les Graphiques "atomiques"

Exemple 3

Un billet, C'est TROP fin pour être mesuré

mAIS ON PEUT MESURER un TAS DE BILLETS

Et donc comparer des tas de billets

Pareil avec des groupes de gens

On peut construire des viz sur ce principe

Sexperience

disparu du Web :(

Les données :

Dataviz survey

@elijah_meeks, 2017

On veut observer les répartitions des réponses aux questions...

...en observant toujours TOUS les individus

Problème :

Animer fluidement ~1000 éléments graphiques

Moteur de rendu 2D

basée sur Webgl

avec fallback canvas

Sprites

vraiment

très

performant

Une bonne doc (ici)

Pleins d'exemples ()

Retour à notre graphique...

Étape 1 : Le state

const _STATE = {};
const _CALLBACKS = [];

export function setState(key, value) {
  if (value === _STATE[key]) return;

  _STATE[key] = value;
  _CALLBACKS.forEach(({ fn, keys }) => {
    if (!keys || keys.includes(key)) {
      fn(_STATE);
    }
  });
}

export function onStateChange(keys, fn) {
  _CALLBACKS.push({ fn, keys });
}

Étape 2 : boucle de rendu

lines.forEach((line, i) => {
  // On génère le sprite :
  const sprite = drawSprite(line);

  sprite.targetX = sprite.x = 0;
  sprite.targetY = sprite.y = 0;

  // On ajoute le sprite dans la scène :
  scene.stage.addChild(sprite);

  // On ajoute un listener, et on fait en sorte que
  // le sprite se dirige constamment vers sa cible :
  scene.ticker.add(() => {
    sprite.x = (sprite.x + sprite.targetX) / 2;
    sprite.y = (sprite.y + sprite.targetY) / 2;
  });
});

Étape 3 : Le layout

Étape 3 : Le layout

const SPARE_HEIGHT = HEIGHT - 2 * BORDER_MARGIN;
const SPARE_WIDTH = (
  WIDTH - 2 * BORDER_MARGIN
  - (values.length - 1) * BARS_MARGIN
);
const BAR_WIDTH = SPARE_WIDTH / values.length;

// `values` de la forme { value, count } :
const max = _.maxBy(values, 'count');
const bars = values.map(({ value, count }, i) => {
  // La fameuse règle de 3 :
  const barHeight = SPARE_HEIGHT * count / max;

  return {
    top: SPARE_HEIGHT - barHeight,
    left: (BAR_WIDTH + BARS_MARGIN) * i,
    width: BAR_WIDTH,
    height: barHeight,
  };
});

Étape 4 : Les sprites

Problème

Étape 4 : Les sprites

W

H

h

w

w'

h'

rows = H / h'

cols = W / w'

h' / w' = h / w

N x h' x w' = W x H

x N

Étape 4 : Les sprites

// On factorize les constantes :
const factor = SPRITE_HEIGHT * BAR_WIDTH / SPRITE_WIDTH;

const bars = values.map(({ value, count }, i) => {
  // ...
  const rows = Math.ceil(Math.sqrt(count * barHeight / factor));
  const cols = Math.ceil(Math.sqrt(count * factor / barHeight));
  
  return {
    // ...
    rows,
    cols,
    colWidth: BAR_WIDTH / cols,
    rowHeight: barHeight / rows,
  };
});

Étape 4 : Les sprites

const spentValues = {};

lines.forEach((line, i) => {
  const value = line[field];
  const bar = barsDict[value];

  // On détermine la case où on doit placer le sprite :
  const index = spentValues[value];
  const total = dict[value].count;
  const col = index % bar.cols;
  const row = Math.floor(index / bar.cols);

  // On détermine les positions X et Y cible du sprite :
  const sprite = sprites[i];
  sprite.targetX = BORDER_MARGIN + bar.left + col * bar.colWidth;
  sprite.targetY = sceneHeight - BORDER_MARGIN - row * bar.rowHeight;

  // On incrémente le nombre de cases de la barre déjà remplie :
  spentValues[value] = (spentValues[value] || 0) + 1;
});

Étape 5 : Bonus

lines.forEach((line, i) => {
  // ...
  // On génère deux nombres aléatoire entre 1 et 21 :
  const xRatio = Math.random() * 20 + 1;
  const yRatio = Math.random() * 20 + 1;

  scene.ticker.add(() => {
    // 1. On interpole vers la cible :
    sprite.x = (sprite.x * xRatio + sprite.targetX) / (xRatio + 1);
    sprite.y = (sprite.y * yRatio + sprite.targetY) / (yRatio + 1);

    // 2. On ajoute une petite vibration :
    sprite.x += (Math.random() * 0.6) - 0.3;
    sprite.y += (Math.random() * 0.6) - 0.3;
    sprite.rotation = Math.random() / 30 - 1 / 60;
  });
});

Étape 6 : Bonus 2

const SKINS = [
  '#ffdcb1', '#e5c298', '#e4b98e',
  '#e2b98f', '#e3a173', '#d99164',
  '#cc8443', '#c77a58', '#a53900',
  '#880400', '#710200', '#440000',
];

function _drawBody(ctx, person) {
  ctx.fillStyle =
    SKINS[Math.floor(Math.random() * SKINS.length)];

  // Tête :
  ctx.fillRect(1, 0, 4, 1);
  ctx.fillRect(0, 1, 6, 3);

  // Corps et bras :
  ctx.fillRect(1, 4, 4, 1);
  ctx.fillRect(0, 5, 6, 5);

  // Jambes :
  ctx.fillRect(1, 10, 1, 2);
  ctx.fillRect(4, 10, 1, 2);
  ctx.fillRect(5, 11, 1, 1);
}
const TSHIRTS = ['#eaeaea', '#242424', '#bababa'];
const SHIRTS = ['#6fd1ec', '#de96e3', '#bee89a'];
const VESTS = ['#5a4728', '#2f3045', '#562c2c'];

function _drawClothes(ctx, person) {
  // Les jeunes sont en T-Shirts
  if (person.age < 28) {
    // T-Shirt :
    ctx.fillStyle =
      TSHIRTS[Math.floor(Math.random() * TSHIRTS.length)];
    ctx.fillRect(0, 5, 6, 2);
    ctx.fillRect(1, 7, 4, 2);

  // Les vieux sont en veste + chemise
  } else {
    // Shirt :
    ctx.fillStyle =
      SHIRTS[Math.floor(Math.random() * SHIRTS.length)];
    ctx.fillRect(0, 5, 6, 4);

    // Vest :
    ctx.fillStyle =
      VESTS[Math.floor(Math.random() * VESTS.length)];
    ctx.fillRect(0, 5, 1, 3);
    ctx.fillRect(1, 5, 1, 4);
    ctx.fillRect(4, 5, 1, 4);
    ctx.fillRect(5, 5, 1, 3);
  }

  // ...
}

Résultat final...

Y'en a plein !

Et des 

exemples du genre 

"Ascii experiment" 

à la main 

CONCLUSION

MAIS

d3.js + SVG, c'est la valeur sûre

Il y pleins de solutions plus fun

PROFITONS-EN !

Merci beaucoup !

La dataviz pour les hipsters - v2, 50 minutes

By Alexis Jacomy

La dataviz pour les hipsters - v2, 50 minutes

Nantes.js Septembre 2017, Nantes

  • 2,269