@openvidstudio/mcp-server 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/canvasExplainer.d.ts +198 -0
- package/dist/canvasExplainer.js +661 -0
- package/dist/canvasValidate.d.ts +76 -0
- package/dist/canvasValidate.js +263 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +2 -0
- package/dist/install.d.ts +63 -0
- package/dist/install.js +225 -0
- package/dist/lessonArc.d.ts +68 -0
- package/dist/lessonArc.js +188 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/stdio.js +12 -4
- package/dist/tools/checkScript.d.ts +31 -1
- package/dist/tools/checkScript.js +183 -6
- package/dist/tools/fitBeats.d.ts +69 -0
- package/dist/tools/fitBeats.js +229 -0
- package/dist/tools/generateNarration.d.ts +19 -0
- package/dist/tools/generateNarration.js +48 -5
- package/dist/tools/index.js +6 -0
- package/dist/tools/initProject.js +6 -1
- package/dist/tools/preflight.js +20 -1
- package/dist/tools/renderCanvasExplainer.d.ts +2 -0
- package/dist/tools/renderCanvasExplainer.js +56 -0
- package/dist/tools/renderVideo.d.ts +3 -0
- package/dist/tools/renderVideo.js +13 -4
- package/dist/tools/validateBeats.d.ts +2 -0
- package/dist/tools/validateBeats.js +5 -5
- package/dist/tools/validateCanvasExplainer.d.ts +2 -0
- package/dist/tools/validateCanvasExplainer.js +35 -0
- package/dist/tools/validateScenes.d.ts +6 -0
- package/dist/tools/validateScenes.js +27 -6
- package/dist/tools/validateScenesRender.d.ts +8 -0
- package/dist/tools/validateScenesRender.js +31 -0
- package/dist/tools/writeDescription.d.ts +6 -0
- package/dist/tools/writeDescription.js +64 -2
- package/docs/API.md +291 -0
- package/docs/NARRATION.md +12 -5
- package/docs/PIPELINE.md +46 -0
- package/docs/SCRIPT.md +63 -3
- package/docs/SUPERNOVA.md +53 -15
- package/docs/TOOLS.md +4 -1
- package/package.json +4 -4
- package/templates/default/openvidstudio-core/package.json +9 -9
- package/templates/default/openvidstudio-stem/package.json +5 -5
- package/templates/default/openvidstudio-stem/src/ComplexityCurve.tsx +108 -0
- package/templates/default/openvidstudio-stem/src/Derivation.tsx +334 -0
- package/templates/default/openvidstudio-stem/src/Equation.tsx +3 -3
- package/templates/default/openvidstudio-stem/src/GraphViz.tsx +249 -0
- package/templates/default/openvidstudio-stem/src/LessonCaptions.tsx +135 -0
- package/templates/default/openvidstudio-stem/src/MatrixGrid.tsx +187 -0
- package/templates/default/openvidstudio-stem/src/Orbit.tsx +241 -0
- package/templates/default/openvidstudio-stem/src/TreeViz.tsx +241 -0
- package/templates/default/openvidstudio-stem/src/VectorField.tsx +231 -0
- package/templates/default/openvidstudio-stem/src/Wave.tsx +234 -0
- package/templates/default/openvidstudio-stem/src/index.ts +21 -0
- package/templates/default/package.json +10 -10
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { E, tween } from "@openvidstudio/core";
|
|
4
|
+
import { SUBJECT_ACCENT, VOID, bodyStack, displayStack } from "./designSystem";
|
|
5
|
+
import { lineWeight } from "./graph";
|
|
6
|
+
import { Spark } from "./Spark";
|
|
7
|
+
import type { Path, Point } from "./paths";
|
|
8
|
+
|
|
9
|
+
export interface OrbitProps {
|
|
10
|
+
/** Semi-major axis in px (the radius, for a circle). Default 34% of the shorter side. */
|
|
11
|
+
semiMajor?: number;
|
|
12
|
+
/** 0 is a circle; up to 0.95. The central mass sits at a focus. Default 0. */
|
|
13
|
+
eccentricity?: number;
|
|
14
|
+
/** Frames for one full orbit. Exactly this: the body is back where it started after `period` frames. */
|
|
15
|
+
period: number;
|
|
16
|
+
/** Where the central mass is (the focus the body orbits). Default: placed so the whole ellipse is centred in the frame. */
|
|
17
|
+
centre?: Point;
|
|
18
|
+
/** Direction of periapsis (the closest point) as seen from the centre, degrees counter-clockwise from the right. The body starts there. Default 0. */
|
|
19
|
+
periapsisAngle?: number;
|
|
20
|
+
/** Orbit the other way. Default false (counter-clockwise on screen). */
|
|
21
|
+
clockwise?: boolean;
|
|
22
|
+
/** The speed at a distance of `semiMajor` from the centre, in the caller's units (m/s, say). On a circle this is the speed all the way round. */
|
|
23
|
+
v?: number;
|
|
24
|
+
/** The force at a distance of `semiMajor` from the centre, in the caller's units (N, say). On a circle this is the force all the way round. */
|
|
25
|
+
F?: number;
|
|
26
|
+
/** Override: speed as a function of distance from the centre in px. Default: vis-viva, v * sqrt(2a/r - 1). */
|
|
27
|
+
speedAt?: (r: number) => number;
|
|
28
|
+
/** Override: force as a function of distance from the centre in px. Default: inverse square, F * (a/r)^2. */
|
|
29
|
+
forceAt?: (r: number) => number;
|
|
30
|
+
/** Pixels per unit of magnitude for the two arrows. An arrow is exactly `magnitude * scale` px from tail to tip. */
|
|
31
|
+
scale: { velocity: number; force: number };
|
|
32
|
+
/** Units on the scale bars. Defaults "m/s" and "N". */
|
|
33
|
+
units?: { velocity?: string; force?: string };
|
|
34
|
+
/** The length each scale bar stands for, in the same units as the magnitudes. Default: a round value near half the largest. */
|
|
35
|
+
scaleValue?: { velocity?: number; force?: number };
|
|
36
|
+
/** Draw the velocity and force arrows. Default true. */
|
|
37
|
+
arrows?: boolean;
|
|
38
|
+
/** Composition frame the orbit starts and the body is released. Default 0. */
|
|
39
|
+
at?: number;
|
|
40
|
+
/** Composition frame the arrows start to grow. Default `at + 12`. */
|
|
41
|
+
arrowsAt?: number;
|
|
42
|
+
/** Radius of the central mass in px. Default 3.2% of the shorter side. */
|
|
43
|
+
massRadius?: number;
|
|
44
|
+
/** Shown on the central mass. */
|
|
45
|
+
centreLabel?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Frames an arrow takes to grow to full length. */
|
|
49
|
+
export const ORBIT_ARROW_FRAMES = 14;
|
|
50
|
+
const MAX_ECCENTRICITY = 0.95;
|
|
51
|
+
|
|
52
|
+
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
|
53
|
+
|
|
54
|
+
export interface OrbitShape {
|
|
55
|
+
semiMajor: number;
|
|
56
|
+
eccentricity: number;
|
|
57
|
+
period: number;
|
|
58
|
+
periapsisAngle?: number;
|
|
59
|
+
clockwise?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface OrbitState {
|
|
63
|
+
/** Eccentric anomaly, radians. */
|
|
64
|
+
anomaly: number;
|
|
65
|
+
/** Distance from the central mass, px: a (1 - e cos E). */
|
|
66
|
+
r: number;
|
|
67
|
+
/** Offset of the body from the central mass, in screen px. */
|
|
68
|
+
offset: Point;
|
|
69
|
+
/** Unit vector of the direction of motion, in screen coordinates (y down). Tangent to the path. */
|
|
70
|
+
tangent: Point;
|
|
71
|
+
/** Unit vector from the body to the central mass. */
|
|
72
|
+
towardCentre: Point;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Solves Kepler's equation M = E - e sin E by Newton's method, to better than 1e-12. */
|
|
76
|
+
export function eccentricAnomaly(meanAnomaly: number, e: number): number {
|
|
77
|
+
const m = meanAnomaly;
|
|
78
|
+
let x = e < 0.8 ? m : Math.PI;
|
|
79
|
+
for (let i = 0; i < 60; i++) {
|
|
80
|
+
const dx = (x - e * Math.sin(x) - m) / (1 - e * Math.cos(x));
|
|
81
|
+
x -= dx;
|
|
82
|
+
if (Math.abs(dx) < 1e-14) break;
|
|
83
|
+
}
|
|
84
|
+
return x;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The body's place on its orbit `t` frames after release, with no drawing. Time runs by Kepler's
|
|
89
|
+
* equation, so the body is at periapsis at t = 0 and every `period`, and at apoapsis half a period
|
|
90
|
+
* in. Distances are a (1 - e) and a (1 + e) there.
|
|
91
|
+
*/
|
|
92
|
+
export function orbitState(shape: OrbitShape, t: number): OrbitState {
|
|
93
|
+
const a = shape.semiMajor;
|
|
94
|
+
const e = Math.max(0, Math.min(MAX_ECCENTRICITY, shape.eccentricity));
|
|
95
|
+
const b = a * Math.sqrt(1 - e * e);
|
|
96
|
+
const dir = shape.clockwise ? -1 : 1;
|
|
97
|
+
const pa = toRad(shape.periapsisAngle ?? 0);
|
|
98
|
+
const E_ = eccentricAnomaly((2 * Math.PI * t) / shape.period, e);
|
|
99
|
+
// Perifocal frame: x toward periapsis, y a quarter turn on. Then onto the screen (y down).
|
|
100
|
+
const toScreen = (px: number, py: number): Point => ({
|
|
101
|
+
x: px * Math.cos(pa) - dir * py * Math.sin(pa),
|
|
102
|
+
y: -px * Math.sin(pa) - dir * py * Math.cos(pa),
|
|
103
|
+
});
|
|
104
|
+
const offset = toScreen(a * (Math.cos(E_) - e), b * Math.sin(E_));
|
|
105
|
+
const tv = toScreen(-a * Math.sin(E_), b * Math.cos(E_));
|
|
106
|
+
const tl = Math.hypot(tv.x, tv.y);
|
|
107
|
+
const r = a * (1 - e * Math.cos(E_));
|
|
108
|
+
return { anomaly: E_, r, offset, tangent: { x: tv.x / tl, y: tv.y / tl }, towardCentre: { x: -offset.x / r, y: -offset.y / r } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Speed at distance r in the caller's units: vis-viva, v sqrt(2a/r - 1), where v is the speed at r = a. */
|
|
112
|
+
export const visViva = (v: number, a: number, r: number): number => v * Math.sqrt(Math.max(0, 2 * a / r - 1));
|
|
113
|
+
/** Force at distance r: inverse square, F (a / r)^2, where F is the force at r = a. */
|
|
114
|
+
export const inverseSquare = (F: number, a: number, r: number): number => F * (a / r) ** 2;
|
|
115
|
+
|
|
116
|
+
function roundValue(v: number): number {
|
|
117
|
+
const pow = 10 ** Math.floor(Math.log10(v));
|
|
118
|
+
const f = v / pow;
|
|
119
|
+
return (f >= 5 ? 5 : f >= 2 ? 2 : 1) * pow;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const Arrow: React.FC<{ from: Point; dir: Point; length: number; color: string; weight: number; dataset: Record<string, string> }> = ({ from, dir, length, color, weight, dataset }) => {
|
|
123
|
+
if (length <= 0) return null;
|
|
124
|
+
const head = Math.min(weight * 5, length * 0.6);
|
|
125
|
+
const tip = { x: from.x + dir.x * length, y: from.y + dir.y * length };
|
|
126
|
+
const base = { x: from.x + dir.x * (length - head), y: from.y + dir.y * (length - head) };
|
|
127
|
+
const half = head * 0.55;
|
|
128
|
+
const left = { x: base.x - dir.y * half, y: base.y + dir.x * half };
|
|
129
|
+
const right = { x: base.x + dir.y * half, y: base.y - dir.x * half };
|
|
130
|
+
return (
|
|
131
|
+
<g {...dataset}>
|
|
132
|
+
<line x1={from.x} y1={from.y} x2={base.x} y2={base.y} stroke={color} strokeWidth={weight} />
|
|
133
|
+
<polygon points={`${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`} fill={color} />
|
|
134
|
+
</g>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A body on a circular or elliptical orbit with its velocity and force arrows drawn to scale: each is
|
|
140
|
+
* exactly `magnitude * scale` px from tail to tip, and a scale bar for each says how much that is.
|
|
141
|
+
*
|
|
142
|
+
* What is exact: the position (Kepler's equation, so the body is at periapsis at t = 0 and every
|
|
143
|
+
* `period`, apoapsis half way, at distances a(1 - e) and a(1 + e)); the force arrow always points at
|
|
144
|
+
* the central mass; the velocity arrow is tangent to the path (perpendicular to the radius on a
|
|
145
|
+
* circle); the magnitudes follow vis-viva for speed and the inverse square law for force, both
|
|
146
|
+
* measured from `v` and `F` at r = a. What is not: `v` and `F` are the caller's numbers in the
|
|
147
|
+
* caller's units, so the picture is not checked against a real gravitational constant, and the
|
|
148
|
+
* arrow scale is separate from the scale of the drawn orbit. The body is the one Spark; the central
|
|
149
|
+
* mass is a plain disc. A pure function of the frame.
|
|
150
|
+
*/
|
|
151
|
+
export const Orbit: React.FC<OrbitProps> = (props) => {
|
|
152
|
+
const frame = useCurrentFrame();
|
|
153
|
+
const { width, height } = useVideoConfig();
|
|
154
|
+
const S = Math.min(width, height);
|
|
155
|
+
const at = props.at ?? 0;
|
|
156
|
+
const t = frame - at;
|
|
157
|
+
const weight = lineWeight(width, height);
|
|
158
|
+
const labelPx = Math.round(S * 0.03);
|
|
159
|
+
const accent = SUBJECT_ACCENT.physics;
|
|
160
|
+
const a = props.semiMajor ?? Math.round(S * 0.34);
|
|
161
|
+
const e = Math.max(0, Math.min(MAX_ECCENTRICITY, props.eccentricity ?? 0));
|
|
162
|
+
const b = a * Math.sqrt(1 - e * e);
|
|
163
|
+
const pa = props.periapsisAngle ?? 0;
|
|
164
|
+
const shape: OrbitShape = { semiMajor: a, eccentricity: e, period: props.period, periapsisAngle: pa, clockwise: props.clockwise };
|
|
165
|
+
// The ellipse's own centre is a e from the focus, away from periapsis.
|
|
166
|
+
const focusToCentre = { x: -a * e * Math.cos(toRad(pa)), y: a * e * Math.sin(toRad(pa)) };
|
|
167
|
+
const frameCentre = { x: width / 2, y: height * 0.46 };
|
|
168
|
+
const centre = props.centre ?? { x: frameCentre.x - focusToCentre.x, y: frameCentre.y - focusToCentre.y };
|
|
169
|
+
const ellipseCentre = { x: centre.x + focusToCentre.x, y: centre.y + focusToCentre.y };
|
|
170
|
+
const massRadius = props.massRadius ?? Math.round(S * 0.032);
|
|
171
|
+
|
|
172
|
+
const speedAt = props.speedAt ?? ((r: number) => visViva(props.v ?? 0, a, r));
|
|
173
|
+
const forceAt = props.forceAt ?? ((r: number) => inverseSquare(props.F ?? 0, a, r));
|
|
174
|
+
const bodyAt = (local: number): Point => {
|
|
175
|
+
const s = orbitState(shape, local);
|
|
176
|
+
return { x: centre.x + s.offset.x, y: centre.y + s.offset.y };
|
|
177
|
+
};
|
|
178
|
+
const pathFn: Path = bodyAt;
|
|
179
|
+
|
|
180
|
+
const arrowsAt = props.arrowsAt ?? at + 12;
|
|
181
|
+
const grow = tween(frame, [arrowsAt, arrowsAt + ORBIT_ARROW_FRAMES], [0, 1], E.cinematic);
|
|
182
|
+
const pathOpacity = tween(frame, [at, at + 12], [0, 1], E.cinematic);
|
|
183
|
+
|
|
184
|
+
if (t < 0) return null;
|
|
185
|
+
const st = orbitState(shape, t);
|
|
186
|
+
const body = { x: centre.x + st.offset.x, y: centre.y + st.offset.y };
|
|
187
|
+
const speed = speedAt(st.r);
|
|
188
|
+
const force = forceAt(st.r);
|
|
189
|
+
const vLen = speed * props.scale.velocity;
|
|
190
|
+
const fLen = force * props.scale.force;
|
|
191
|
+
const showArrows = props.arrows !== false && grow > 0;
|
|
192
|
+
const colorV = "#5CC8FF";
|
|
193
|
+
const colorF = accent.accentAlt;
|
|
194
|
+
|
|
195
|
+
// Scale bars: the largest values are at periapsis.
|
|
196
|
+
const vMax = speedAt(a * (1 - e));
|
|
197
|
+
const fMax = forceAt(a * (1 - e));
|
|
198
|
+
const bars = [
|
|
199
|
+
{ key: "velocity", value: props.scaleValue?.velocity ?? roundValue(Math.max(vMax, 1e-9) / 2), scale: props.scale.velocity, unit: props.units?.velocity ?? "m/s", color: colorV },
|
|
200
|
+
{ key: "force", value: props.scaleValue?.force ?? roundValue(Math.max(fMax, 1e-9) / 2), scale: props.scale.force, unit: props.units?.force ?? "N", color: colorF },
|
|
201
|
+
];
|
|
202
|
+
const barX = Math.round(width * 0.12);
|
|
203
|
+
const barY0 = Math.round(height * 0.72);
|
|
204
|
+
const fmt = (v: number) => String(Number(v.toFixed(2)));
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} data-ovs-orbit="">
|
|
208
|
+
<svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0, willChange: "transform" }}>
|
|
209
|
+
<ellipse data-ovs-orbit-path="" data-ovs-semi-major={a} data-ovs-semi-minor={b} data-ovs-eccentricity={e} cx={ellipseCentre.x} cy={ellipseCentre.y} rx={a} ry={b} transform={pa === 0 ? undefined : `rotate(${-pa} ${ellipseCentre.x} ${ellipseCentre.y})`} fill="none" stroke={`rgba(140,135,153,${(pathOpacity * 0.7).toFixed(4)})`} strokeWidth={weight} />
|
|
210
|
+
<circle data-ovs-mass="" cx={centre.x} cy={centre.y} r={massRadius} fill={VOID.textDim} />
|
|
211
|
+
{props.centreLabel && <text x={centre.x} y={centre.y + labelPx * 0.35} textAnchor="middle" fontSize={labelPx} fill={VOID.base} fontFamily={displayStack()} fontWeight={700}>{props.centreLabel}</text>}
|
|
212
|
+
<circle data-ovs-body="" data-ovs-r={st.r.toFixed(4)} cx={body.x} cy={body.y} r={1} fill="none" />
|
|
213
|
+
{showArrows && (
|
|
214
|
+
<>
|
|
215
|
+
<Arrow from={body} dir={st.tangent} length={vLen * grow} color={colorV} weight={weight * 1.6} dataset={{ "data-ovs-arrow": "velocity", "data-ovs-magnitude": String(speed), "data-ovs-length": String(vLen * grow), "data-ovs-dx": String(st.tangent.x), "data-ovs-dy": String(st.tangent.y) }} />
|
|
216
|
+
<Arrow from={body} dir={st.towardCentre} length={fLen * grow} color={colorF} weight={weight * 1.6} dataset={{ "data-ovs-arrow": "force", "data-ovs-magnitude": String(force), "data-ovs-length": String(fLen * grow), "data-ovs-dx": String(st.towardCentre.x), "data-ovs-dy": String(st.towardCentre.y) }} />
|
|
217
|
+
{grow >= 0.6 && (
|
|
218
|
+
<>
|
|
219
|
+
<text x={body.x + st.tangent.x * (vLen + labelPx * 1.1)} y={body.y + st.tangent.y * (vLen + labelPx * 1.1) + labelPx * 0.35} textAnchor="middle" fontSize={labelPx} fill={colorV} fontFamily={displayStack()} fontWeight={700}>v</text>
|
|
220
|
+
<text x={body.x + st.towardCentre.x * (fLen + labelPx * 1.1)} y={body.y + st.towardCentre.y * (fLen + labelPx * 1.1) + labelPx * 0.35} textAnchor="middle" fontSize={labelPx} fill={colorF} fontFamily={displayStack()} fontWeight={700}>F</text>
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
{props.arrows !== false && bars.map((bar, i) => {
|
|
226
|
+
const y = barY0 + i * labelPx * 1.9;
|
|
227
|
+
const len = bar.value * bar.scale;
|
|
228
|
+
return (
|
|
229
|
+
<g key={bar.key} data-ovs-scale={bar.key} data-ovs-scale-length={len}>
|
|
230
|
+
<line x1={barX} y1={y} x2={barX + len} y2={y} stroke={bar.color} strokeWidth={weight} />
|
|
231
|
+
<line x1={barX} y1={y - weight * 3} x2={barX} y2={y + weight * 3} stroke={bar.color} strokeWidth={weight} />
|
|
232
|
+
<line x1={barX + len} y1={y - weight * 3} x2={barX + len} y2={y + weight * 3} stroke={bar.color} strokeWidth={weight} />
|
|
233
|
+
<text x={barX + len + weight * 5} y={y + labelPx * 0.35} fontSize={labelPx} fill={VOID.textDim} fontFamily={bodyStack()}>{`${fmt(bar.value)} ${bar.unit}`}</text>
|
|
234
|
+
</g>
|
|
235
|
+
);
|
|
236
|
+
})}
|
|
237
|
+
</svg>
|
|
238
|
+
<Spark path={pathFn} at={at} trail={18} subject="physics" />
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { E, tween } from "@openvidstudio/core";
|
|
4
|
+
import { SUBJECT_ACCENT, VOID, bodyStack, displayStack, type Subject } from "./designSystem";
|
|
5
|
+
import { lineWeight } from "./graph";
|
|
6
|
+
import { Spark } from "./Spark";
|
|
7
|
+
import { walk, type Point } from "./paths";
|
|
8
|
+
|
|
9
|
+
export type Traversal = "inorder" | "preorder" | "postorder" | "level";
|
|
10
|
+
|
|
11
|
+
export interface TreeVizProps {
|
|
12
|
+
/** Inserted in this order into a binary search tree (smaller to the left, larger to the right). A repeated value is skipped. */
|
|
13
|
+
values: number[];
|
|
14
|
+
/** Composition frame the first insert starts. Default 0. */
|
|
15
|
+
at?: number;
|
|
16
|
+
/** Frames each insert takes (a walked insert takes its walk as well). Default 24. */
|
|
17
|
+
insertAt?: number;
|
|
18
|
+
/** A Spark walks from the root to the place of this value as it is inserted. Default: the last value. */
|
|
19
|
+
walkValue?: number;
|
|
20
|
+
/** Turn the walking Spark off. Default true. */
|
|
21
|
+
spark?: boolean;
|
|
22
|
+
/** Frames the Spark takes for one hop down the tree, and how long it rests on each node. Default 14 and 8. */
|
|
23
|
+
walkHop?: number;
|
|
24
|
+
walkHold?: number;
|
|
25
|
+
/** Light the nodes in this order, one every `hop` frames, and list the visited values under the tree. */
|
|
26
|
+
traversal?: Traversal;
|
|
27
|
+
/** Composition frame the first node lights. Default: 20 frames after the last insert. */
|
|
28
|
+
traverseAt?: number;
|
|
29
|
+
/** Frames between one node lighting and the next. Default 18. */
|
|
30
|
+
hop?: number;
|
|
31
|
+
/** Mark the node with this value (a ring). */
|
|
32
|
+
highlight?: number;
|
|
33
|
+
/** Picks the accent. Default "cs". */
|
|
34
|
+
subject?: Subject;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TreeNodeLayout {
|
|
38
|
+
value: number;
|
|
39
|
+
/** Position in the insertion order, 0 is the root. */
|
|
40
|
+
order: number;
|
|
41
|
+
parent: number | null;
|
|
42
|
+
side: "left" | "right" | null;
|
|
43
|
+
depth: number;
|
|
44
|
+
/** Centre in composition pixels. */
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
/** Composition frame the node (and its edge) starts to appear. */
|
|
48
|
+
appearFrame: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TreeLayout {
|
|
52
|
+
nodes: TreeNodeLayout[];
|
|
53
|
+
radius: number;
|
|
54
|
+
/** Values in visit order for each traversal. */
|
|
55
|
+
orders: Record<Traversal, number[]>;
|
|
56
|
+
/** Centres from the root to the walked value's place, or empty. */
|
|
57
|
+
walkPoints: Point[];
|
|
58
|
+
walkValue: number | null;
|
|
59
|
+
/** Frame offset, from the walked insert's start, at which the Spark arrives and the node appears. */
|
|
60
|
+
walkFrames: number;
|
|
61
|
+
/** Composition frame the last node has appeared. */
|
|
62
|
+
insertsEnd: number;
|
|
63
|
+
traverseAt: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Bst {
|
|
67
|
+
value: number;
|
|
68
|
+
left: Bst | null;
|
|
69
|
+
right: Bst | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The tree for `values` and where it is drawn: a tidy layered layout (x is the in-order rank, y the
|
|
74
|
+
* depth, so a left child is always left of its parent and a right child right of it, each one level
|
|
75
|
+
* lower), sized from the frame. Also the visit order of every traversal and the timeline of inserts.
|
|
76
|
+
* Pure, so a test can check the picture against numbers it works out itself.
|
|
77
|
+
*/
|
|
78
|
+
export function treeLayout(values: number[], opts: { width: number; height: number; at?: number; insertAt?: number; walkValue?: number; spark?: boolean; walkHop?: number; walkHold?: number; traverseAt?: number }): TreeLayout {
|
|
79
|
+
const { width, height } = opts;
|
|
80
|
+
const at = opts.at ?? 0;
|
|
81
|
+
const insertAt = opts.insertAt ?? 24;
|
|
82
|
+
const minSide = Math.min(width, height);
|
|
83
|
+
let root: Bst | null = null;
|
|
84
|
+
const meta = new Map<number, { order: number; parent: number | null; side: "left" | "right" | null; depth: number }>();
|
|
85
|
+
const inserted: number[] = [];
|
|
86
|
+
for (const v of values) {
|
|
87
|
+
if (meta.has(v)) continue;
|
|
88
|
+
const node: Bst = { value: v, left: null, right: null };
|
|
89
|
+
if (!root) {
|
|
90
|
+
root = node;
|
|
91
|
+
meta.set(v, { order: inserted.length, parent: null, side: null, depth: 0 });
|
|
92
|
+
} else {
|
|
93
|
+
let cur = root;
|
|
94
|
+
let depth = 0;
|
|
95
|
+
for (;;) {
|
|
96
|
+
depth++;
|
|
97
|
+
const side = v < cur.value ? "left" : "right";
|
|
98
|
+
const next: Bst | null = cur[side];
|
|
99
|
+
if (next) cur = next;
|
|
100
|
+
else {
|
|
101
|
+
cur[side] = node;
|
|
102
|
+
meta.set(v, { order: inserted.length, parent: cur.value, side, depth });
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
inserted.push(v);
|
|
108
|
+
}
|
|
109
|
+
const inorder: number[] = [];
|
|
110
|
+
const preorder: number[] = [];
|
|
111
|
+
const postorder: number[] = [];
|
|
112
|
+
const level: number[] = [];
|
|
113
|
+
const visit = (n: Bst | null) => {
|
|
114
|
+
if (!n) return;
|
|
115
|
+
preorder.push(n.value);
|
|
116
|
+
visit(n.left);
|
|
117
|
+
inorder.push(n.value);
|
|
118
|
+
visit(n.right);
|
|
119
|
+
postorder.push(n.value);
|
|
120
|
+
};
|
|
121
|
+
visit(root);
|
|
122
|
+
const queue: Bst[] = root ? [root] : [];
|
|
123
|
+
for (let i = 0; i < queue.length; i++) {
|
|
124
|
+
level.push(queue[i].value);
|
|
125
|
+
if (queue[i].left) queue.push(queue[i].left as Bst);
|
|
126
|
+
if (queue[i].right) queue.push(queue[i].right as Bst);
|
|
127
|
+
}
|
|
128
|
+
const count = inserted.length;
|
|
129
|
+
const maxDepth = Math.max(0, ...Array.from(meta.values()).map((m) => m.depth));
|
|
130
|
+
const portrait = height > width;
|
|
131
|
+
const left = width * 0.1;
|
|
132
|
+
const right = width * 0.9;
|
|
133
|
+
const top = height * (portrait ? 0.2 : 0.16);
|
|
134
|
+
const bottom = height * (portrait ? 0.56 : 0.6);
|
|
135
|
+
const gapX = count > 1 ? (right - left) / (count - 1) : 0;
|
|
136
|
+
const radius = Math.max(minSide * 0.034, Math.min(minSide * 0.055, gapX * 0.42));
|
|
137
|
+
const gapY = maxDepth > 0 ? Math.min(minSide * 0.16, (bottom - top) / maxDepth) : 0;
|
|
138
|
+
const x0 = count > 1 ? left : width / 2;
|
|
139
|
+
const centre = (v: number): Point => {
|
|
140
|
+
const m = meta.get(v) as { depth: number };
|
|
141
|
+
return { x: x0 + inorder.indexOf(v) * gapX, y: top + radius + m.depth * gapY };
|
|
142
|
+
};
|
|
143
|
+
// The Spark's walk: from the root to the parent, then on to the place the new node will sit.
|
|
144
|
+
const walkValue = opts.spark === false || count === 0 ? null : inserted.includes(opts.walkValue ?? inserted[count - 1]) ? (opts.walkValue ?? inserted[count - 1]) : null;
|
|
145
|
+
const walkPoints: Point[] = [];
|
|
146
|
+
if (walkValue !== null) {
|
|
147
|
+
const chain: number[] = [];
|
|
148
|
+
for (let v: number | null = walkValue; v !== null; v = (meta.get(v) as { parent: number | null }).parent) chain.unshift(v);
|
|
149
|
+
for (const v of chain) walkPoints.push(centre(v));
|
|
150
|
+
}
|
|
151
|
+
const step = (opts.walkHop ?? 14) + (opts.walkHold ?? 8);
|
|
152
|
+
const walkFrames = walkPoints.length > 1 ? (walkPoints.length - 1) * step : 0;
|
|
153
|
+
let cursor = at;
|
|
154
|
+
const nodes: TreeNodeLayout[] = inserted.map((v) => {
|
|
155
|
+
const m = meta.get(v) as { order: number; parent: number | null; side: "left" | "right" | null; depth: number };
|
|
156
|
+
const walked = v === walkValue;
|
|
157
|
+
const appearFrame = cursor + (walked ? walkFrames : 0);
|
|
158
|
+
cursor = appearFrame + insertAt;
|
|
159
|
+
const c = centre(v);
|
|
160
|
+
return { value: v, order: m.order, parent: m.parent, side: m.side, depth: m.depth, x: c.x, y: c.y, appearFrame };
|
|
161
|
+
});
|
|
162
|
+
return { nodes, radius, orders: { inorder, preorder, postorder, level }, walkPoints, walkValue, walkFrames, insertsEnd: cursor, traverseAt: opts.traverseAt ?? cursor + 20 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const TRAVERSAL_NAME: Record<Traversal, string> = { inorder: "in-order", preorder: "pre-order", postorder: "post-order", level: "level order" };
|
|
166
|
+
const APPEAR_FRAMES = 10;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A binary search tree built one insert at a time, with its traversals. The component builds the
|
|
170
|
+
* BST from `values` itself, in order, and lays it out from the frame size (`treeLayout`). Each node
|
|
171
|
+
* arrives with its edge, a Spark can walk from the root to its place, and a traversal lights the
|
|
172
|
+
* nodes in visit order, listing the visited values under the tree. At most one thing glows: the
|
|
173
|
+
* Spark. A pure function of the frame.
|
|
174
|
+
*/
|
|
175
|
+
export const TreeViz: React.FC<TreeVizProps> = ({ values, at = 0, insertAt = 24, walkValue, spark = true, walkHop = 14, walkHold = 8, traversal, traverseAt, hop = 18, highlight, subject = "cs" }) => {
|
|
176
|
+
const frame = useCurrentFrame();
|
|
177
|
+
const { width, height } = useVideoConfig();
|
|
178
|
+
const weight = lineWeight(width, height);
|
|
179
|
+
const accent = SUBJECT_ACCENT[subject].accent;
|
|
180
|
+
const layout = treeLayout(values, { width, height, at, insertAt, walkValue, spark, walkHop, walkHold, traverseAt });
|
|
181
|
+
if (frame < at) return null;
|
|
182
|
+
const { nodes, radius } = layout;
|
|
183
|
+
const byValue = new Map(nodes.map((n) => [n.value, n]));
|
|
184
|
+
const fontPx = Math.round(radius * 0.9);
|
|
185
|
+
const labelPx = Math.round(Math.min(width, height) * 0.03);
|
|
186
|
+
const visitOrder = traversal ? layout.orders[traversal] : [];
|
|
187
|
+
const lit = (v: number): number => (traversal ? visitOrder.indexOf(v) : -1);
|
|
188
|
+
const litAt = (k: number) => layout.traverseAt + k * hop;
|
|
189
|
+
const visited = visitOrder.filter((_, k) => frame >= litAt(k));
|
|
190
|
+
const current = visited.length - 1;
|
|
191
|
+
const portrait = height > width;
|
|
192
|
+
const listY = Math.round(height * (portrait ? 0.66 : 0.74));
|
|
193
|
+
const itemPx = Math.round(Math.min(width, height) * 0.04);
|
|
194
|
+
const itemGap = Math.min(itemPx * 2.4, (width * 0.84) / Math.max(1, visitOrder.length));
|
|
195
|
+
const listLeft = width / 2 - (visitOrder.length * itemGap) / 2;
|
|
196
|
+
const walked = layout.walkValue === null ? null : byValue.get(layout.walkValue);
|
|
197
|
+
const walkStart = walked ? walked.appearFrame - layout.walkFrames : 0;
|
|
198
|
+
const showSpark = walked !== null && layout.walkFrames > 0 && frame >= walkStart && frame < (walked?.appearFrame ?? 0) + APPEAR_FRAMES;
|
|
199
|
+
return (
|
|
200
|
+
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} data-ovs-treeviz="" data-ovs-traversal={traversal ?? ""}>
|
|
201
|
+
<svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }}>
|
|
202
|
+
{nodes.map((n) => {
|
|
203
|
+
if (frame < n.appearFrame || n.parent === null) return null;
|
|
204
|
+
const p = byValue.get(n.parent) as TreeNodeLayout;
|
|
205
|
+
const k = tween(frame, [n.appearFrame, n.appearFrame + APPEAR_FRAMES], [0, 1], E.cinematic);
|
|
206
|
+
const dx = n.x - p.x;
|
|
207
|
+
const dy = n.y - p.y;
|
|
208
|
+
const d = Math.hypot(dx, dy);
|
|
209
|
+
const ux = dx / d;
|
|
210
|
+
const uy = dy / d;
|
|
211
|
+
const a = { x: p.x + ux * radius, y: p.y + uy * radius };
|
|
212
|
+
const b = { x: n.x - ux * radius, y: n.y - uy * radius };
|
|
213
|
+
return <line key={`e${n.value}`} data-ovs-edge={`${n.parent}-${n.value}`} x1={a.x} y1={a.y} x2={a.x + (b.x - a.x) * k} y2={a.y + (b.y - a.y) * k} stroke={VOID.textDim} strokeWidth={weight} />;
|
|
214
|
+
})}
|
|
215
|
+
{nodes.map((n) => {
|
|
216
|
+
if (frame < n.appearFrame) return null;
|
|
217
|
+
const k = tween(frame, [n.appearFrame, n.appearFrame + APPEAR_FRAMES], [0, 1], E.cinematic);
|
|
218
|
+
const idx = lit(n.value);
|
|
219
|
+
const on = idx >= 0 && frame >= litAt(idx);
|
|
220
|
+
const isCurrent = on && idx === current;
|
|
221
|
+
return (
|
|
222
|
+
<g key={n.value} data-ovs-node={n.value} data-ovs-x={n.x} data-ovs-y={n.y} data-ovs-r={radius} data-ovs-depth={n.depth} data-ovs-lit={on ? "true" : "false"} opacity={k < 1 ? k : undefined}>
|
|
223
|
+
<circle cx={n.x} cy={n.y} r={radius} fill={isCurrent ? accent : on ? accent : VOID.panel} fillOpacity={isCurrent ? 1 : on ? 0.35 : 1} stroke={on ? accent : VOID.textDim} strokeWidth={weight} />
|
|
224
|
+
{highlight === n.value && <circle data-ovs-highlight={n.value} cx={n.x} cy={n.y} r={radius + weight * 2.5} fill="none" stroke={accent} strokeWidth={weight} strokeDasharray={`${weight * 2.5} ${weight * 2}`} />}
|
|
225
|
+
<text x={n.x} y={n.y + fontPx * 0.35} textAnchor="middle" fontSize={fontPx} fill={isCurrent ? VOID.base : VOID.text} fontFamily={displayStack()} fontWeight={600}>{n.value}</text>
|
|
226
|
+
</g>
|
|
227
|
+
);
|
|
228
|
+
})}
|
|
229
|
+
{traversal && visited.length > 0 && (
|
|
230
|
+
<g data-ovs-visited-list="">
|
|
231
|
+
<text x={width / 2} y={listY - itemPx * 1.4} textAnchor="middle" fontSize={labelPx} fill={VOID.textDim} fontFamily={bodyStack()}>{TRAVERSAL_NAME[traversal]}</text>
|
|
232
|
+
{visited.map((v, k) => (
|
|
233
|
+
<text key={v} data-ovs-visited={v} data-ovs-visit-index={k} x={listLeft + (k + 0.5) * itemGap} y={listY} textAnchor="middle" fontSize={itemPx} fill={k === current ? accent : VOID.text} fontFamily={displayStack()} fontWeight={600}>{v}</text>
|
|
234
|
+
))}
|
|
235
|
+
</g>
|
|
236
|
+
)}
|
|
237
|
+
</svg>
|
|
238
|
+
{showSpark && <Spark path={walk(layout.walkPoints, { hop: walkHop, hold: walkHold })} at={walkStart} trail={14} color={accent} size={Math.max(4, Math.round(radius * 0.22))} />}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
};
|