atelier

Catalog / Recipe

Draw on

The strokes of an inline SVG are drawn in once, on first view, at a plotter's even pace, and nothing is left behind.

Family
generative mark
Status
lab
Used for
Section introFigureHeroPage load
Size
1,093 of 1,200 bytes, gzipped
Frame
Skin
Version
mode
trigger
mount(element)

A stroke can be given a dash as long as itself and that dash slid out of view; animating the offset back to zero draws the line. This recipe does that to every stroke inside an element’s inline SVGs, once, at a linear pace, and only inside the animation: nothing is written to an element before, during or after. Mounted on a mark from flow-mark or contour-mark it draws every line at one pen speed; mounted on a hand-drawn diagram it draws the diagram.

Use

import { mount } from './draw-on';

mount(document.querySelector('.figure'));

Every path, line, polyline, polygon, circle, ellipse and rect inside every svg in the element is a stroke. One drawing a page: a listing of drawn-on marks is a dozen animations at once, and a diagram that is evidence is wanted at once, not drawn.

How it moves

The dash. A generated mark is one path of many lines, and a dash pattern restarts at every subpath. The generator writes the length of its longest line into data-longest; the dash is that long, so every line draws at the same speed and the short ones finish first, which is what a pen does. Any other stroke is measured with getTotalLength(). A stroke with neither is skipped.

The pace. Linear, and slow: a plotter does not ease. mode 0, pen, gives each stroke a duration in proportion to its length; mode 1, together, gives every stroke the whole duration. stagger sets each stroke’s start after the one before it, in document order.

When. trigger 0 starts the first time the element is on screen, through an intersection observer at a fifth visible, or at once where there is none; trigger 1 starts on mount. The animation is filled backwards, so during delay the strokes are blank; it is not filled forwards, so when it ends the stroke is as it is styled.

Options

Option Default Meaning
duration 1600 How long the longest stroke takes, in ms
delay 0 A pause before the first stroke, in ms
stagger 120 Between one stroke’s start and the next, in ms
mode 0 Pen (one speed), or together
trigger 0 In view, or on mount

Versions

Version What it is
Pen The defaults
Together Every stroke takes the whole duration
Quick 700 ms, a short stagger
Staggered 400 ms between strokes
On load Starts on mount

Where it must not go

  • A listing. Many marks at once are many animations at once.
  • A diagram the reader came for. Evidence is shown, not drawn.
  • A second time. Once, on first view; never a loop.
  • A stroke with a pathLength attribute. The dash is measured in that attribute’s units and will not match.

What it does when things are missing

The rest state is the finished drawing, and every failure path is the rest state: without animate, under reduced motion (decided through matchMedia, once, at mount), without script, and if animate throws. destroy() cancels what is running, which shows the drawing finished, and disconnects the observer. The dash is never written to the element, so nothing can be left behind.

Learned while building it

  • Backwards fill, never forwards. A forwards fill would hold the dash on the element for ever; without it, the end of the animation is the element’s own style, which is the drawing.
  • The generator knows the lengths. Measuring a path of a hundred subpaths gives their sum, which is the wrong dash for every one of them. The number has to come from where the lines were made.
draw-on.ts186 lines · 1,093 bytes gzipped · no imports
/**
 * draw-on
 *
 * The strokes of an inline SVG drawn in, once, on first view, and
 * nothing left behind. Each stroke is given a dash as long as itself,
 * slid out of view, and the offset is animated back to zero at a
 * linear pace: the plotter's motion. The dash exists only inside the
 * animation. Before it, during a delay, and after it, the element is
 * as it is styled, so a browser without `animate`, a reader with
 * reduced motion, and a page without script all see the finished
 * drawing from the first frame.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './draw-on';
 *     mount(document.querySelector('.figure'));
 *
 * Every `path`, `line`, `polyline`, `polygon`, `circle`, `ellipse` and
 * `rect` inside every `svg` in the element is a stroke. A mark made by
 * `flow-mark` or `contour-mark` is one path of many lines and carries
 * `data-longest`, the length of its longest line, which sets the dash
 * so every line draws at one pen speed and the short ones finish
 * first. Any other stroke is measured with `getTotalLength()`. One
 * drawing a page: a listing of drawn-on marks is a dozen animations.
 */

export interface Options {
  /** How long the longest stroke takes to draw, in ms. Under 600 is a flick; over 4,000 the reader waits for a picture. */
  duration: number;
  /** A pause before the first stroke, in ms. The drawing is blank during it. */
  delay: number;
  /** Between one stroke's start and the next, in document order, in ms. */
  stagger: number;
  /** 0 pen: every stroke draws at one speed and short ones finish first. 1 together: every stroke takes the whole duration. */
  mode: number;
  /** 0 when the element first comes on screen; 1 on mount. */
  trigger: number;
}

export const DEFAULTS: Options = {
  duration: 1600,
  delay: 0,
  stagger: 120,
  mode: 0,
  trigger: 0,
};

/**
 * Named ways of drawing. The first is the defaults. `mount(element, PRESETS.Quick)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Pen: {},
  Together: { mode: 1 },
  Quick: { duration: 700, stagger: 60 },
  Staggered: { stagger: 400 },
  'On load': { trigger: 1 },
};

export interface Handle {
  update(options: Partial<Options>): void;
  destroy(): void;
}

export interface Step {
  duration: number;
  delay: number;
}

const NOOP: Handle = { update() {}, destroy() {} };
const mounted = new WeakMap<HTMLElement, Handle>();
const STROKES = 'path,line,polyline,polygon,circle,ellipse,rect';
const LIMITS: Record<string, [number, number]> = { duration: [100, 10000], delay: [0, 10000], stagger: [0, 5000], mode: [0, 1], trigger: [0, 1] };

/** Options clamped to their limits; anything that is not a finite number falls back to the base. */
export function mergeOptions(base: Options, partial: Partial<Options>): Options {
  const out: Record<string, number> = {};
  for (const [key, [low, high]] of Object.entries(LIMITS)) {
    const given = partial[key as keyof Options];
    const value = Math.min(high, Math.max(low, typeof given === 'number' && Number.isFinite(given) ? given : base[key as keyof Options]));
    out[key] = high === 1 ? Math.round(value) : value;
  }
  return out as unknown as Options;
}

/** Every stroke inside every svg in the element, or in the element itself if it is one, in document order. */
export function strokesOf(root: Element): Element[] {
  const svgs = root.matches('svg') ? [root] : [...root.querySelectorAll('svg')];
  return svgs.flatMap((svg) => [...svg.querySelectorAll(STROKES)]);
}

/** The dash a stroke needs: the generator's longest line where it wrote one, else the stroke's own length, else nothing. */
export function lengthOf(stroke: Element): number | null {
  const longest = Number(stroke.getAttribute('data-longest'));
  if (Number.isFinite(longest) && longest > 0) return longest;
  const measure = (stroke as { getTotalLength?: () => number }).getTotalLength;
  if (typeof measure !== 'function') return null;
  try {
    const length = measure.call(stroke);
    return Number.isFinite(length) && length > 0 ? length : null;
  } catch {
    return null;
  }
}

/** How long each stroke takes and when it starts: one pen speed, or all together, and a stagger in document order. */
export function planFor(lengths: number[], options: Options): Step[] {
  const longest = Math.max(0, ...lengths);
  return lengths.map((length, index) => ({
    duration: options.mode === 1 || longest === 0 ? options.duration : Math.max(1, (options.duration * length) / longest),
    delay: options.delay + index * options.stagger,
  }));
}

/**
 * Draws the strokes of the element's svgs in, once. Nothing is written
 * to any element: the dash lives in the animation, with a backwards
 * fill for the delay, and `destroy()` cancels what is running, which
 * shows the drawing finished.
 */
export function mount(element: HTMLElement, options: Partial<Options> = {}): Handle {
  if (!element || typeof element.querySelectorAll !== 'function') return NOOP;
  mounted.get(element)?.destroy();
  let current = mergeOptions(DEFAULTS, options);
  const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
  const strokes = strokesOf(element)
    .map((stroke) => ({ stroke, length: lengthOf(stroke) }))
    .filter((entry): entry is { stroke: Element; length: number } => entry.length !== null);
  let animations: Animation[] = [];
  let observer: IntersectionObserver | null = null;
  let started = false;
  const start = (): void => {
    if (started || reduced) return;
    started = true;
    const plan = planFor(
      strokes.map((entry) => entry.length),
      current,
    );
    strokes.forEach(({ stroke, length }, index) => {
      const step = plan[index] as Step;
      try {
        const animation = stroke.animate(
          { strokeDasharray: [`${length}px ${length}px`, `${length}px ${length}px`], strokeDashoffset: [`${length}px`, '0px'] },
          { duration: step.duration, delay: step.delay, fill: 'backwards', easing: 'linear' },
        );
        animations.push(animation);
        animation.finished.then(
          () => {
            animations = animations.filter((a) => a !== animation);
          },
          () => {},
        );
      } catch {
        /* a stroke that cannot animate is simply there, finished */
      }
    });
  };
  const handle: Handle = {
    update(next) {
      current = mergeOptions(current, next);
    },
    destroy() {
      observer?.disconnect();
      observer = null;
      for (const animation of animations) animation.cancel();
      animations = [];
      mounted.delete(element);
    },
  };
  mounted.set(element, handle);
  if (reduced || strokes.length === 0) return handle;
  if (current.trigger === 0 && typeof IntersectionObserver === 'function') {
    observer = new IntersectionObserver(
      (entries) => {
        if (!entries.some((entry) => entry.isIntersecting)) return;
        observer?.disconnect();
        observer = null;
        start();
      },
      { threshold: 0.2 },
    );
    observer.observe(element);
  } else {
    start();
  }
  return handle;
}