PageSourceSearch

https://turtle.val.run/frontend/components/Canvas.tsx

js turtle.val.run collected 2026-10-03 15:07:20 UTC 9,526 bytes, 256 lines download raw bytes

1/* Open in Val Town: https://www.val.town/x/stevekrouse/logo/code/frontend/components/Canvas.tsx */
2import { jsx as _jsx, jsxs as _jsxs } from "https://esm.sh/[email protected]/jsx-runtime";
3/** @jsxImportSource https://esm.sh/[email protected] */
4import { useEffect, useRef } from "https://esm.sh/[email protected]";
5const CSS_NAMED_OK = /^[a-z]+$/i;
6function normalizeColor(c) {
7    // Allow hex or simple named colors; fall back to a default otherwise.
8    if (c.startsWith("#"))
9        return c;
10    if (CSS_NAMED_OK.test(c))
11        return c;
12    return "#111827";
13}
14function lerp(a, b, t) {
15    return a + (b - a) * t;
16}
17/**
18 * Walk the timeline of steps and compute what the canvas should look like at
19 * `elapsed` seconds: which segments are fully drawn, the partially-drawn move,
20 * and where/how the turtle is oriented right now.
21 */
22function frameAt(result, elapsed) {
23    const segments = [];
24    let partial = null;
25    let turtle = {
26        pos: { x: 0, y: 0 },
27        heading: 0,
28        visible: true,
29    };
30    let t = elapsed;
31    let done = true;
32    for (const step of result.steps) {
33        if (step.kind === "instant") {
34            if (step.clear) {
35                segments.length = 0;
36                partial = null;
37            }
38            continue;
39        }
40        if (t >= step.duration) {
41            // This step has fully completed.
42            applyCompleted(step, segments);
43            turtle = turtleAfter(step, turtle);
44            t -= step.duration;
45            continue;
46        }
47        // We're mid-step: render its partial progress and stop.
48        const frac = step.duration > 0 ? t / step.duration : 1;
49        done = false;
50        if (step.kind === "move") {
51            const here = {
52                x: lerp(step.from.x, step.to.x, frac),
53                y: lerp(step.from.y, step.to.y, frac),
54            };
55            if (step.penDown && (step.from.x !== step.to.x || step.from.y !== step.to.y)) {
56                partial = {
57                    from: step.from,
58                    to: here,
59                    color: step.color,
60                    width: step.width,
61                };
62            }
63            turtle = { pos: here, heading: turtle?.heading ?? 0, visible: true };
64        }
65        else if (step.kind === "turn") {
66            turtle = {
67                pos: step.at,
68                heading: lerp(step.fromHeading, step.toHeading, frac),
69                visible: true,
70            };
71        }
72        return { segments, partial, turtle, done, line: step.line };
73    }
74    // Ran past the end of the timeline: everything is drawn.
75    turtle = {
76        pos: { x: result.turtle.x, y: result.turtle.y },
77        heading: result.turtle.heading,
78        visible: result.turtle.visible,
79    };
80    return { segments, partial: null, turtle, done, line: null };
81}
82function applyCompleted(step, segments) {
83    if (step.kind === "move" && step.penDown
84        && (step.from.x !== step.to.x || step.from.y !== step.to.y)) {
85        segments.push({
86            from: step.from,
87            to: step.to,
88            color: step.color,
89            width: step.width,
90        });
91    }
92}
93function turtleAfter(step, prev) {
94    const heading = prev?.heading ?? 0;
95    if (step.kind === "move") {
96        // A move doesn't change heading; carry the previous one forward.
97        return { pos: step.to, heading, visible: true };
98    }
99    if (step.kind === "turn") {
100        return { pos: step.at, heading: step.toHeading, visible: true };
101    }
102    return prev;
103}
104/** Two steps are equal when they'd draw the same thing (source line ignored). */
105function stepsEqual(a, b) {
106    if (a.kind !== b.kind || a.duration !== b.duration)
107        return false;
108    if (a.kind === "move" && b.kind === "move") {
109        return a.from.x === b.from.x && a.from.y === b.from.y &&
110            a.to.x === b.to.x && a.to.y === b.to.y &&
111            a.penDown === b.penDown && a.color === b.color && a.width === b.width;
112    }
113    if (a.kind === "turn" && b.kind === "turn") {
114        return a.at.x === b.at.x && a.at.y === b.at.y &&
115            a.fromHeading === b.fromHeading && a.toHeading === b.toHeading;
116    }
117    if (a.kind === "instant" && b.kind === "instant")
118        return !!a.clear === !!b.clear;
119    return false;
120}
121/** Seconds of leading animation two timelines share before they first diverge. */
122function matchedPrefixDuration(prev, next) {
123    let dur = 0;
124    const n = Math.min(prev.length, next.length);
125    for (let i = 0; i < n; i++) {
126        if (!stepsEqual(prev[i], next[i]))
127            break;
128        dur += next[i].duration;
129    }
130    return dur;
131}
132/**
133 * The right-hand canvas. The turtle starts in the dead center of the canvas
134 * and animates through the drawing at the speed set in the code.
135 */
136export function Canvas({ result, error, onActiveLine }) {
137    const canvasRef = useRef(null);
138    const wrapRef = useRef(null);
139    const resultRef = useRef(result);
140    resultRef.current = result;
141    // Latest callback + last reported line, so the RAF loop reports the running
142    // line to the editor only when it actually changes.
143    const onActiveLineRef = useRef(onActiveLine);
144    onActiveLineRef.current = onActiveLine;
145    const lastLineRef = useRef(null);
146    // Animation clock. When an edit only changes the tail of the program, keep the
147    // shared prefix "already drawn" and animate just from the first change — so the
148    // turtle continues from where it was instead of replaying from the start.
149    const startRef = useRef(performance.now());
150    const prevStepsRef = useRef([]);
151    useEffect(() => {
152        const matched = matchedPrefixDuration(prevStepsRef.current, result.steps);
153        startRef.current = performance.now() - matched * 1000;
154        prevStepsRef.current = result.steps;
155    }, [result]);
156    useEffect(() => {
157        const canvas = canvasRef.current;
158        const wrap = wrapRef.current;
159        if (!canvas || !wrap)
160            return;
161        const ctx = canvas.getContext("2d");
162        if (!ctx)
163            return;
164        let raf = 0;
165        const render = () => {
166            const dpr = window.devicePixelRatio || 1;
167            const { clientWidth: w, clientHeight: h } = wrap;
168            if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
169                canvas.width = w * dpr;
170                canvas.height = h * dpr;
171                canvas.style.width = `${w}px`;
172                canvas.style.height = `${h}px`;
173            }
174            ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
175            ctx.clearRect(0, 0, w, h);
176            const cx = w / 2;
177            const cy = h / 2;
178            drawGrid(ctx, w, h, cx, cy);
179            const r = resultRef.current;
180            const elapsed = (performance.now() - startRef.current) / 1000;
181            const frame = frameAt(r, elapsed);
182            // Report the running source line to the editor (only on change).
183            const cb = onActiveLineRef.current;
184            if (cb && frame.line !== lastLineRef.current) {
185                lastLineRef.current = frame.line;
186                cb(frame.line);
187            }
188            for (const seg of frame.segments)
189                strokeSeg(ctx, seg, cx, cy);
190            if (frame.partial)
191                strokeSeg(ctx, frame.partial, cx, cy);
192            if (frame.turtle && frame.turtle.visible) {
193                drawTurtle(ctx, cx + frame.turtle.pos.x, cy + frame.turtle.pos.y, frame.turtle.heading);
194            }
195            raf = requestAnimationFrame(render);
196        };
197        raf = requestAnimationFrame(render);
198        return () => cancelAnimationFrame(raf);
199    }, []);
200    return (_jsxs("div", { ref: wrapRef, class: "relative w-full h-full bg-white", children: [_jsx("canvas", { ref: canvasRef, class: "block" }), error && (_jsx("div", { class: "absolute bottom-4 left-1/2 -translate-x-1/2 max-w-[90%] max-h-32 overflow-auto bg-red-50 border border-red-300 text-red-700 text-sm px-3 py-2 rounded-lg shadow whitespace-pre-wrap break-words", children: error }))] }));
201}
202function strokeSeg(ctx, seg, cx, cy) {
203    ctx.beginPath();
204    ctx.strokeStyle = normalizeColor(seg.color);
205    ctx.lineWidth = seg.width;
206    ctx.lineCap = "round";
207    ctx.moveTo(cx + seg.from.x, cy + seg.from.y);
208    ctx.lineTo(cx + seg.to.x, cy + seg.to.y);
209    ctx.stroke();
210}
211function drawGrid(ctx, w, h, cx, cy) {
212    const step = 40;
213    ctx.save();
214    ctx.strokeStyle = "#f1f5f9";
215    ctx.lineWidth = 1;
216    for (let x = cx % step; x < w; x += step) {
217        ctx.beginPath();
218        ctx.moveTo(x, 0);
219        ctx.lineTo(x, h);
220        ctx.stroke();
221    }
222    for (let y = cy % step; y < h; y += step) {
223        ctx.beginPath();
224        ctx.moveTo(0, y);
225        ctx.lineTo(w, y);
226        ctx.stroke();
227    }
228    // Center axes, slightly darker.
229    ctx.strokeStyle = "#e2e8f0";
230    ctx.beginPath();
231    ctx.moveTo(cx, 0);
232    ctx.lineTo(cx, h);
233    ctx.moveTo(0, cy);
234    ctx.lineTo(w, cy);
235    ctx.stroke();
236    ctx.restore();
237}
238/** A little triangular turtle pointing in its heading direction. */
239function drawTurtle(ctx, x, y, headingDeg) {
240    ctx.save();
241    ctx.translate(x, y);
242    // heading 0 = up; canvas rotation is clockwise-positive from +x axis.
243    ctx.rotate((headingDeg * Math.PI) / 180);
244    ctx.beginPath();
245    ctx.moveTo(0, -12); // nose
246    ctx.lineTo(8, 9);
247    ctx.lineTo(0, 5);
248    ctx.lineTo(-8, 9);
249    ctx.closePath();
250    ctx.fillStyle = "#16a34a";
251    ctx.fill();
252    ctx.strokeStyle = "#15803d";
253    ctx.lineWidth = 1.5;
254    ctx.stroke();
255    ctx.restore();
256}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.