Skip to content

Following traffic ​

Six cars driving through a roundabout, each followed by its own rectangular hotspot. The rectangles turn with the cars through the curves, appear when a car drives into the picture and disappear when it leaves. Click a car while it moves, and the log under the player shows which one.

  1. Events will show up here.

Video: Roundabout 14 61 by Wikideas1, a roundabout on US 14/61 in La Crosse, Wisconsin, released under CC0.

What's in it ​

Part of the showcaseHow it's built
Six moving targets at onceSix hotspots in one scene, each with its own keyframes.
Boxes that turn with the carsEach keyframe has its own area: wider than tall while a car drives across, taller than wide while it drives up or down.
Cars that enter and leave the pictureThe first keyframe sets from, and to removes a hotspot once its car is gone.
Feedback that follows the pickupBoth answers replay the same video in a new scene. One hotspot pulses on the pickup, and a second one, on the same path moved up, shows the feedback with the label variant.

Pause with Space or seek with ← →: every box is computed from the video's current time, so they stay on their cars at any frame, not only while playing.

Story ​

ts
import type { Hotspot, HotspotKeyframe, Story } from "@ivkit/core";
import { video } from "./video";

/** Keyframes from rows of [second, x, y, width, height], one every half second. */
const path = (rows: [number, number, number, number, number][]): HotspotKeyframe[] =>
  rows.map(([at, x, y, width, height]) => ({ at, area: { x, y, width, height } }));

const pickup = path([
  [0, 0.601, 0.633, 0.036, 0.079],
  [0.5, 0.599, 0.62, 0.033, 0.078],
  [1, 0.6, 0.61, 0.03, 0.075],
  [1.5, 0.6, 0.601, 0.027, 0.073],
  [2, 0.601, 0.588, 0.026, 0.072],
  [2.5, 0.6, 0.568, 0.025, 0.072],
  [3, 0.6, 0.542, 0.025, 0.071],
  [3.5, 0.6, 0.51, 0.027, 0.073],
  [4, 0.602, 0.476, 0.027, 0.073],
  [4.5, 0.604, 0.444, 0.025, 0.071],
  [5, 0.599, 0.408, 0.031, 0.077],
  [5.5, 0.59, 0.373, 0.037, 0.079],
  [6, 0.577, 0.34, 0.041, 0.079],
  [6.5, 0.56, 0.312, 0.044, 0.076],
  [7, 0.54, 0.286, 0.045, 0.072],
  [7.5, 0.516, 0.262, 0.045, 0.069],
  [8, 0.489, 0.24, 0.045, 0.065],
  [8.5, 0.458, 0.219, 0.044, 0.063],
  [9, 0.427, 0.199, 0.044, 0.062],
  [9.5, 0.393, 0.177, 0.044, 0.061],
  [10, 0.358, 0.157, 0.044, 0.06],
  [10.5, 0.323, 0.139, 0.044, 0.059],
  [11, 0.285, 0.121, 0.044, 0.058],
  [11.5, 0.248, 0.103, 0.043, 0.057],
  [12, 0.211, 0.087, 0.043, 0.056],
]);

const northEast = path([
  [0, 0.491, 0.575, 0.043, 0.054],
  [0.5, 0.524, 0.562, 0.043, 0.057],
  [1, 0.557, 0.541, 0.044, 0.062],
  [1.5, 0.587, 0.516, 0.045, 0.067],
  [2, 0.615, 0.485, 0.045, 0.07],
  [2.5, 0.641, 0.455, 0.045, 0.072],
  [3, 0.666, 0.421, 0.044, 0.073],
  [3.5, 0.691, 0.388, 0.044, 0.073],
  [4, 0.718, 0.355, 0.045, 0.072],
  [4.5, 0.745, 0.322, 0.045, 0.071],
  [5, 0.775, 0.291, 0.045, 0.069],
  [5.5, 0.806, 0.262, 0.045, 0.068],
  [6, 0.837, 0.233, 0.045, 0.067],
  [6.5, 0.87, 0.206, 0.045, 0.066],
  [7, 0.903, 0.18, 0.045, 0.065],
  [7.5, 0.936, 0.154, 0.045, 0.065],
]);

const firstInQueue = path([
  [0, 0.047, 0.092, 0.044, 0.061],
  [0.5, 0.075, 0.109, 0.044, 0.06],
  [1, 0.102, 0.124, 0.044, 0.06],
  [1.5, 0.128, 0.138, 0.044, 0.06],
  [2, 0.153, 0.153, 0.044, 0.061],
  [2.5, 0.176, 0.167, 0.044, 0.062],
  [3, 0.195, 0.179, 0.044, 0.064],
  [3.5, 0.213, 0.191, 0.045, 0.069],
  [4, 0.229, 0.208, 0.044, 0.073],
  [4.5, 0.243, 0.226, 0.043, 0.077],
  [5, 0.255, 0.247, 0.04, 0.079],
  [5.5, 0.264, 0.273, 0.036, 0.079],
  [6, 0.272, 0.304, 0.032, 0.077],
  [6.5, 0.276, 0.338, 0.029, 0.075],
  [7, 0.279, 0.373, 0.03, 0.075],
  [7.5, 0.282, 0.411, 0.032, 0.077],
  [8, 0.288, 0.453, 0.035, 0.079],
  [8.5, 0.297, 0.497, 0.039, 0.08],
  [9, 0.314, 0.546, 0.041, 0.079],
  [9.5, 0.337, 0.597, 0.043, 0.077],
  [10, 0.366, 0.647, 0.044, 0.076],
  [10.5, 0.401, 0.702, 0.044, 0.074],
  [11, 0.442, 0.759, 0.044, 0.074],
  [11.5, 0.488, 0.824, 0.044, 0.074],
  [12, 0.54, 0.898, 0.044, 0.074],
]);

const secondInQueue = path([
  [1.5, 0.018, 0.075, 0.044, 0.061],
  [2, 0.047, 0.092, 0.044, 0.06],
  [2.5, 0.076, 0.109, 0.044, 0.059],
  [3, 0.102, 0.123, 0.044, 0.059],
  [3.5, 0.127, 0.135, 0.044, 0.059],
  [4, 0.153, 0.15, 0.044, 0.06],
  [4.5, 0.177, 0.162, 0.044, 0.062],
  [5, 0.198, 0.176, 0.045, 0.067],
  [5.5, 0.217, 0.193, 0.045, 0.07],
  [6, 0.234, 0.211, 0.044, 0.075],
  [6.5, 0.248, 0.231, 0.042, 0.078],
  [7, 0.26, 0.257, 0.038, 0.079],
  [7.5, 0.269, 0.286, 0.03, 0.076],
  [8, 0.273, 0.318, 0.025, 0.071],
  [8.5, 0.273, 0.352, 0.024, 0.07],
  [9, 0.272, 0.387, 0.027, 0.073],
  [9.5, 0.274, 0.426, 0.032, 0.077],
  [10, 0.279, 0.466, 0.037, 0.079],
  [10.5, 0.29, 0.508, 0.04, 0.079],
  [11, 0.308, 0.554, 0.043, 0.077],
  [11.5, 0.335, 0.6, 0.044, 0.075],
  [12, 0.371, 0.65, 0.044, 0.074],
]);

const rightLane = path([
  [0, 0.093, 0.095, 0.044, 0.059],
  [0.5, 0.118, 0.108, 0.044, 0.059],
  [1, 0.143, 0.121, 0.044, 0.058],
  [1.5, 0.166, 0.131, 0.044, 0.06],
  [2, 0.189, 0.145, 0.044, 0.061],
  [2.5, 0.21, 0.158, 0.044, 0.062],
  [3, 0.23, 0.17, 0.044, 0.064],
  [3.5, 0.248, 0.183, 0.045, 0.068],
  [4, 0.264, 0.197, 0.044, 0.073],
  [4.5, 0.278, 0.216, 0.043, 0.076],
  [5, 0.29, 0.235, 0.042, 0.079],
  [5.5, 0.3, 0.257, 0.038, 0.08],
  [6, 0.306, 0.279, 0.035, 0.079],
  [6.5, 0.313, 0.303, 0.03, 0.076],
  [7, 0.317, 0.331, 0.025, 0.072],
  [7.5, 0.317, 0.357, 0.024, 0.07],
  [8, 0.317, 0.385, 0.025, 0.072],
  [8.5, 0.316, 0.409, 0.029, 0.075],
  [9, 0.317, 0.435, 0.032, 0.077],
  [9.5, 0.32, 0.462, 0.037, 0.079],
  [10, 0.326, 0.487, 0.042, 0.079],
  [10.5, 0.337, 0.511, 0.044, 0.073],
  [11, 0.355, 0.534, 0.045, 0.066],
  [11.5, 0.375, 0.55, 0.044, 0.06],
  [12, 0.399, 0.563, 0.043, 0.055],
]);

const lastInQueue = path([
  [2, 0.008, 0.046, 0.044, 0.06],
  [2.5, 0.036, 0.063, 0.044, 0.06],
  [3, 0.065, 0.078, 0.044, 0.058],
  [3.5, 0.092, 0.091, 0.044, 0.059],
  [4, 0.119, 0.106, 0.044, 0.059],
  [4.5, 0.144, 0.119, 0.044, 0.058],
  [5, 0.168, 0.13, 0.044, 0.059],
  [5.5, 0.191, 0.143, 0.044, 0.062],
  [6, 0.213, 0.157, 0.044, 0.063],
  [6.5, 0.232, 0.169, 0.045, 0.065],
  [7, 0.248, 0.183, 0.045, 0.068],
  [7.5, 0.263, 0.196, 0.045, 0.072],
  [8, 0.275, 0.21, 0.043, 0.076],
  [8.5, 0.287, 0.229, 0.041, 0.079],
  [9, 0.295, 0.251, 0.036, 0.079],
  [9.5, 0.302, 0.278, 0.033, 0.077],
  [10, 0.308, 0.307, 0.028, 0.074],
  [10.5, 0.31, 0.338, 0.025, 0.071],
  [11, 0.31, 0.367, 0.026, 0.072],
  [11.5, 0.31, 0.395, 0.031, 0.076],
  [12, 0.313, 0.426, 0.034, 0.078],
]);

/** A car to follow, from its first keyframe until `to` (or the end of the scene). */
const car = (id: string, label: string, keyframes: HotspotKeyframe[], to?: number): Hotspot => ({
  type: "hotspot",
  id,
  from: keyframes[0]!.at,
  ...(to !== undefined && { to }),
  label,
  keyframes,
});

/**
 * The same path, moved up so a label rides above the car instead of covering
 * it. The pickup stays low enough for it to fit, even as it leaves at the top.
 */
const above = (keyframes: HotspotKeyframe[]): HotspotKeyframe[] =>
  keyframes.map(({ at, area }) => {
    const { x, y, width } = area!;
    return {
      at,
      area: { x: x - 0.05, y: Math.max(0, y - 0.07), width: width + 0.1, height: 0.06 },
    };
  });

/** The pickup again, on the replay, with the feedback riding along above it. */
const replay = (id: string, label: string): Story["scenes"][string] => ({
  src: video("traffic"),
  events: [
    { ...car(id, "White pickup", pickup), variant: "pulse" },
    { ...car(`${id}-label`, label, above(pickup)), variant: "label" },
  ],
});

export const traffic: Story = {
  version: "0.1",
  start: "traffic",
  scenes: {
    traffic: {
      src: video("traffic"),
      events: [
        car("pickup", "White pickup", pickup),
        car("north-east", "Car heading north-east", northEast, 7.8),
        car("first-in-queue", "First car in the queue", firstInQueue, 12.2),
        car("second-in-queue", "Second car in the queue", secondInQueue),
        car("right-lane", "Car in the right-hand lane", rightLane),
        car("last-in-queue", "Last car in the queue", lastInQueue),
        {
          type: "question",
          id: "exit",
          at: 12.3,
          text: "Which exit did the white pickup take?",
          options: [
            { id: "north-west", label: "North-west, top left", goto: "replay-right" },
            { id: "north-east", label: "North-east, top right", goto: "replay-wrong" },
            { id: "south", label: "South, where it came from", goto: "replay-wrong" },
          ],
        },
      ],
    },
    "replay-right": replay("pickup-right", "Correct: north-west"),
    "replay-wrong": replay("pickup-wrong", "Watch it go north-west"),
  },
};