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.
- 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 showcase | How it's built |
|---|---|
| Six moving targets at once | Six hotspots in one scene, each with its own keyframes. |
| Boxes that turn with the cars | Each 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 picture | The first keyframe sets from, and to removes a hotspot once its car is gone. |
| Feedback that follows the pickup | Both 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"),
},
};