Files
m3e-canvas/web/vendor/components/Loading.tsx
T
pastilhasandClaude Fable 5 240d63940d vendor m3e-canvas 5c83ac6, with the import rewrite scripted
upstream imports through a @/ alias, and this platform already binds
@/* to src/apps/officer-web and @/components/* to its own shared
components — so an unrewritten import resolves to the WRONG module
rather than failing. the rewrite is mechanical and lives in
scripts/vendor.sh so a re-vendor produces a readable diff.

lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them
meant editing vendored code, and Canvas.tsx references them throughout.
a faithful copy carrying an unused module beats a doctored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:00:17 +01:00

301 lines
9.4 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { LoadingAnimator, morphedShape } from "../lib/shapes";
/* ---------- shared helpers ---------- */
/** Cubic bezier easing (same parametrisation as CSS / Compose CubicBezierEasing). */
function bezier(x1: number, y1: number, x2: number, y2: number) {
const ax = 3 * x1 - 3 * x2 + 1;
const bx = 3 * x2 - 6 * x1;
const cx = 3 * x1;
const ay = 3 * y1 - 3 * y2 + 1;
const by = 3 * y2 - 6 * y1;
const cy = 3 * y1;
const sx = (t: number) => ((ax * t + bx) * t + cx) * t;
const sy = (t: number) => ((ay * t + by) * t + cy) * t;
return (x: number) => {
if (x <= 0) return 0;
if (x >= 1) return 1;
let lo = 0;
let hi = 1;
let t = x;
for (let i = 0; i < 24; i++) {
const v = sx(t);
if (Math.abs(v - x) < 1e-4) break;
if (v < x) lo = t;
else hi = t;
t = (lo + hi) / 2;
}
return sy(t);
};
}
const seg = (t: number, from: number, to: number, ease: (x: number) => number) =>
ease(Math.min(1, Math.max(0, (t - from) / (to - from))));
function useFrame(cb: (ts: number) => void) {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
let raf = 0;
const loop = (ts: number) => {
ref.current(ts);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
}
/* ---------- loading indicator (shape morph) ---------- */
export function LoadingIndicator({
size,
color,
contained,
containerColor,
}: {
size: number;
color: string;
contained?: boolean;
containerColor?: string;
}) {
const pathRef = useRef<SVGPathElement>(null);
const anim = useRef<LoadingAnimator | null>(null);
if (!anim.current) anim.current = new LoadingAnimator();
const scale = (size * 0.79) / 2;
const c = size / 2;
useFrame((ts) => {
const a = anim.current!;
a.update(ts);
const pts = morphedShape(a.morph);
let d = "";
for (let i = 0; i < pts.length; i++) {
d += (i === 0 ? "M" : "L") + (pts[i][0] * scale).toFixed(2) + " " + (pts[i][1] * scale).toFixed(2);
}
const el = pathRef.current;
if (el) {
el.setAttribute("d", d + "Z");
el.setAttribute("transform", `translate(${c} ${c}) rotate(${a.rotation.toFixed(2)})`);
}
});
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: "block" }}>
{contained && <circle cx={c} cy={c} r={c} fill={containerColor ?? "rgba(0,0,0,0.08)"} />}
<path ref={pathRef} fill={color} />
</svg>
);
}
/* ---------- linear progress (flat / wavy) ---------- */
const LINEAR_CYCLE = 1800;
const l1Head = bezier(0.2, 0, 0.8, 1);
const l1Tail = bezier(0.4, 0, 1, 1);
const l2Head = bezier(0, 0, 0.65, 1);
const l2Tail = bezier(0.1, 0, 0.45, 1);
const STROKE = 4;
const TRACK_GAP = 4;
const STOP_SIZE = 4;
const LINEAR_WAVELENGTH = 40;
const LINEAR_AMPLITUDE = 3;
const WAVE_SPEED = 40; // px per second
function wavePath(x0: number, x1: number, mid: number, amp: number, phase: number, wl: number) {
if (x1 - x0 < 0.5) return "";
const step = amp > 0 ? 2 : Math.max(2, x1 - x0);
let d = "";
for (let x = x0; ; x += step) {
const xx = Math.min(x, x1);
const y = mid + amp * Math.sin((2 * Math.PI * xx) / wl + phase);
d += (d ? "L" : "M") + xx.toFixed(2) + " " + y.toFixed(2);
if (xx >= x1) break;
}
return d;
}
export function LinearProgress({
width,
color,
trackColor,
wavy,
value,
}: {
width: number;
color: string;
trackColor: string;
wavy?: boolean;
/** 0..1, undefined = indeterminate */
value?: number;
}) {
const height = wavy ? STROKE + LINEAR_AMPLITUDE * 2 + 2 : STROKE + 2;
const mid = height / 2;
const inset = STROKE / 2;
const w = width - STROKE;
const activeRef = useRef<SVGPathElement>(null);
const active2Ref = useRef<SVGPathElement>(null);
const trackRef = useRef<SVGPathElement>(null);
const start = useRef<number>(0);
useFrame((ts) => {
if (!start.current) start.current = ts;
const ms = ts - start.current;
const phase = wavy ? (-(ms / 1000) * WAVE_SPEED * 2 * Math.PI) / LINEAR_WAVELENGTH : 0;
const ampBase = wavy ? LINEAR_AMPLITUDE : 0;
const a = activeRef.current;
const b = active2Ref.current;
const t = trackRef.current;
if (!a || !b || !t) return;
if (value !== undefined) {
const v = Math.min(1, Math.max(0, value));
const amp = v < 0.1 || v > 0.95 ? 0 : ampBase;
const end = inset + w * v;
a.setAttribute("d", wavePath(inset, end, mid, amp, phase, LINEAR_WAVELENGTH));
b.setAttribute("d", "");
const trackStart = Math.min(inset + w - STOP_SIZE, end + TRACK_GAP + STROKE / 2);
t.setAttribute("d", wavePath(v <= 0 ? inset : trackStart, inset + w, mid, 0, 0, 1));
return;
}
const c = ms % LINEAR_CYCLE;
const h1 = seg(c, 0, 750, l1Head);
const t1 = seg(c, 333, 1183, l1Tail);
const h2 = seg(c, 1000, 1567, l2Head);
const t2 = seg(c, 1267, 1800, l2Tail);
const s1 = [inset + w * t1, inset + w * h1];
const s2 = [inset + w * t2, inset + w * h2];
a.setAttribute("d", wavePath(s1[0], s1[1], mid, ampBase, phase, LINEAR_WAVELENGTH));
b.setAttribute("d", wavePath(s2[0], s2[1], mid, ampBase, phase, LINEAR_WAVELENGTH));
const segs = [s1, s2].filter((s) => s[1] - s[0] > 0.5).sort((p, q) => p[0] - q[0]);
let cursor = inset;
let d = "";
for (const s of segs) {
const to = s[0] - TRACK_GAP - STROKE / 2;
if (to - cursor > 0.5) d += wavePath(cursor, to, mid, 0, 0, 1);
cursor = Math.max(cursor, s[1] + TRACK_GAP + STROKE / 2);
}
if (inset + w - cursor > 0.5) d += wavePath(cursor, inset + w, mid, 0, 0, 1);
t.setAttribute("d", d);
});
const stroke = { fill: "none", strokeWidth: STROKE, strokeLinecap: "round" as const };
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: "block" }}>
<path ref={trackRef} stroke={trackColor} {...stroke} />
<path ref={activeRef} stroke={color} {...stroke} />
<path ref={active2Ref} stroke={color} {...stroke} />
{value !== undefined && (
<circle cx={width - inset - STOP_SIZE / 2} cy={mid} r={STOP_SIZE / 2} fill={color} />
)}
</svg>
);
}
/* ---------- circular progress (flat / wavy) ---------- */
const ROTATION_MS = 1332;
const ROTATIONS_PER_CYCLE = 5;
const BASE_ROTATION = 286;
const JUMP_ROTATION = 290;
const ROTATION_OFFSET = (BASE_ROTATION + JUMP_ROTATION) % 360;
const circEase = bezier(0.4, 0, 0.2, 1);
const CIRC_WAVELENGTH = 20;
const CIRC_AMPLITUDE = 1.5;
function arcPath(
cx: number,
cy: number,
r: number,
a0: number,
a1: number,
amp: number,
k: number,
phase: number,
) {
if (a1 - a0 < 0.05) return "";
const step = amp > 0 ? 2 : 6;
let d = "";
for (let a = a0; ; a += step) {
const aa = Math.min(a, a1);
const rad = (aa * Math.PI) / 180;
const rr = r + amp * Math.sin(k * rad + phase);
d += (d ? "L" : "M") + (cx + rr * Math.cos(rad)).toFixed(2) + " " + (cy + rr * Math.sin(rad)).toFixed(2);
if (aa >= a1) break;
}
return d;
}
export function CircularProgress({
size,
color,
trackColor,
wavy,
value,
}: {
size: number;
color: string;
trackColor: string;
wavy?: boolean;
value?: number;
}) {
const amp = wavy ? CIRC_AMPLITUDE : 0;
const r = size / 2 - STROKE / 2 - amp;
const c = size / 2;
const k = Math.max(3, Math.round((2 * Math.PI * r) / CIRC_WAVELENGTH));
const gapDeg = ((TRACK_GAP + STROKE) / (2 * Math.PI * r)) * 360;
const activeRef = useRef<SVGPathElement>(null);
const trackRef = useRef<SVGPathElement>(null);
const start = useRef(0);
useFrame((ts) => {
if (!start.current) start.current = ts;
const ms = ts - start.current;
const phase = wavy ? -(ms / 1000) * 2 * Math.PI : 0;
const a = activeRef.current;
const t = trackRef.current;
if (!a || !t) return;
if (value !== undefined) {
const v = Math.min(1, Math.max(0, value));
const sweep = 360 * v;
const useAmp = v < 0.05 || v > 0.95 ? 0 : amp;
a.setAttribute("d", arcPath(c, c, r, -90, -90 + sweep, useAmp, k, phase));
const trackFrom = -90 + sweep + (v > 0 ? gapDeg : 0);
const trackTo = 270 - (v < 1 ? gapDeg : 0);
t.setAttribute("d", v >= 1 ? "" : arcPath(c, c, r, trackFrom, trackTo, 0, k, 0));
return;
}
const cycle = ROTATION_MS * ROTATIONS_PER_CYCLE;
const inCycle = ms % cycle;
const rotIndex = Math.floor(inCycle / ROTATION_MS);
const inRot = inCycle % ROTATION_MS;
const baseRotation = (BASE_ROTATION * inCycle) / cycle * ROTATIONS_PER_CYCLE;
const head = seg(inRot, 0, 666, circEase) * JUMP_ROTATION;
const tail = seg(inRot, 666, 1332, circEase) * JUMP_ROTATION;
const offset = (rotIndex * ROTATION_OFFSET) % 360;
const startAngle = tail + offset + baseRotation - 90;
const sweep = Math.max(0.1, head - tail);
a.setAttribute("d", arcPath(c, c, r, startAngle, startAngle + sweep, amp, k, phase));
const trackFrom = startAngle + sweep + gapDeg;
const trackTo = startAngle + 360 - gapDeg;
t.setAttribute("d", trackTo - trackFrom > 1 ? arcPath(c, c, r, trackFrom, trackTo, 0, k, 0) : "");
});
const stroke = { fill: "none", strokeWidth: STROKE, strokeLinecap: "round" as const };
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: "block" }}>
<path ref={trackRef} stroke={trackColor} {...stroke} />
<path ref={activeRef} stroke={color} {...stroke} />
</svg>
);
}