Files
Anelia-Wolf/src/components/brand/DrawMark.tsx
2026-09-06 17:21:52 +02:00

221 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gsap, DUR, MM } from "@/lib/gsap";
import { STROKE_IN, WIPE_IN, playSheen, primeMark } from "@/components/motion/Draw";
export interface DrawMarkProps {
/** `load` = on mount (hero), `view` = at 80 % in view (once), `scrub` = scroll-tied (Prinzip). */
play?: "load" | "view" | "scrub";
/** One-time champagne sweep over the wolf stroke after drawing. */
sheen?: boolean;
/** Continue the neck stroke downward into the first hairline (hero only). */
handoff?: boolean;
/** Length of the handoff hairline in px when the SVG has no `#handoff` path (2448). */
handoffLength?: number;
delay?: number;
className?: string;
onComplete?: () => void;
/** Receives the handoff x in px from the wrapper's left edge (for `<Rule origin={x}/>`). */
onHandoff?: (x: number) => void;
/** `<BrandMark/>` (Server Component, passed as children). */
children: ReactNode;
}
/** Timing of the draw sequence in seconds (spec §5.5). */
const T = { vLeft: [0, 0.35], vRight: [0.25, 0.35], wolf: [0.5, 0.7], detail: [1.0, 0.3], handoff: [1.2, 0.2], sheen: 1.5 } as const;
/**
* Client shell around <BrandMark/>: V-left → V-right → wolf → (handoff) → sheen.
* Paths are ordered by the classes `.wolfmark-v` / `.wolfmark-wolf`; an inline
* client SVG without those classes is drawn in DOM order.
*/
export function DrawMark({
play = "load",
sheen = false,
handoff = false,
handoffLength = 40,
delay = 0,
className,
onComplete,
onHandoff,
children,
}: DrawMarkProps) {
const wrap = useRef<HTMLSpanElement>(null);
const line = useRef<HTMLSpanElement>(null);
const onCompleteRef = useRef(onComplete);
const onHandoffRef = useRef(onHandoff);
/* Read imperatively, never a dependency: the hero measures the gap and pushes a new
length on every resize, and re-running the effect would `mm.revert()` the whole
matchMedia scope and replay the monogram draw mid-interaction. */
const handoffLengthRef = useRef(handoffLength);
useLayoutEffect(() => {
onCompleteRef.current = onComplete;
onHandoffRef.current = onHandoff;
});
useLayoutEffect(() => {
handoffLengthRef.current = handoffLength;
const el = line.current;
if (el) el.style.height = `${handoffLength}px`;
}, [handoffLength]);
useLayoutEffect(() => {
const el = wrap.current;
const svg = el?.querySelector("svg");
if (!el || !svg) return;
const all = Array.from(svg.querySelectorAll<SVGPathElement>("path"));
const handoffPath = all.find((p) => p.classList.contains("wolfmark-handoff")) ?? null;
let vs = all.filter((p) => p.classList.contains("wolfmark-v"));
let wolves = all.filter((p) => p.classList.contains("wolfmark-wolf") && p !== handoffPath);
if (!vs.length && !wolves.length) {
// Unknown SVG: everything in DOM order first two strokes as "V", the rest as "wolf".
const drawable = all.filter((p) => p.hasAttribute("data-draw"));
const seq = drawable.length ? drawable : all;
vs = seq.slice(0, 2);
wolves = seq.slice(2);
}
const mainWolf = wolves[0] ?? null;
const details = wolves.slice(1);
const sequence = [...vs, ...wolves, ...(handoffPath ? [handoffPath] : [])];
const lineEl = line.current;
const useLine = handoff && !handoffPath && !!lineEl;
/* The path geometry is fixed in SVG user space only the CTM changes on resize.
Sample once (2 × 121 `getPointAtLength` calls), then re-project the cached point. */
let anchor: { x: number; y: number } | null | undefined;
const sampleAnchor = (): { x: number; y: number } | null => {
if (!mainWolf) return null;
const end = mainWolf.getPointAtLength(mainWolf.getTotalLength());
const pts: { x: number; y: number }[] = [];
let maxY = -Infinity;
vs.forEach((v) => {
const len = v.getTotalLength();
for (let i = 0; i <= 120; i++) {
const p = v.getPointAtLength((len * i) / 120);
pts.push({ x: p.x, y: p.y });
if (p.y > maxY) maxY = p.y;
}
});
const pool = pts.filter((p) => p.y >= maxY - 2);
return (pool.length ? pool : [{ x: end.x, y: end.y }]).reduce((a, b) =>
Math.abs(b.x - end.x) < Math.abs(a.x - end.x) ? b : a,
);
};
/** Position of the handoff hairline: the lowest V point nearest the wolf's end. */
const placeLine = (): number | null => {
if (!mainWolf || !lineEl) return null;
try {
const ctm = svg.getScreenCTM();
if (!ctm) return null;
if (anchor === undefined) anchor = sampleAnchor();
if (!anchor) return null;
const rect = el.getBoundingClientRect();
const sp = new DOMPoint(anchor.x, anchor.y).matrixTransform(ctm);
const x = sp.x - rect.left;
const y = sp.y - rect.top;
lineEl.style.left = `${x}px`;
lineEl.style.top = `${y}px`;
lineEl.style.height = `${handoffLengthRef.current}px`;
el.style.setProperty("--handoff-x", `${x}px`);
return x;
} catch {
/* A measurement failure must never strand the mark the reveal below runs anyway. */
return null;
}
};
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
if (ctx.conditions?.reduce) {
gsap.set(el, { opacity: 1 });
if (useLine) {
const x = placeLine();
gsap.set(lineEl, { scaleY: 1, opacity: 1 });
if (x !== null) onHandoffRef.current?.(x);
}
onCompleteRef.current?.();
return;
}
const mode = primeMark(svg, sequence);
let hx: number | null = null;
if (useLine) {
hx = placeLine();
gsap.set(lineEl, { scaleY: 0, transformOrigin: "50% 0%", opacity: 1 });
}
gsap.set(el, { opacity: 1 });
const tl = gsap.timeline({
paused: play !== "load",
delay: play === "load" ? delay : 0,
onComplete: () => {
if (hx !== null) onHandoffRef.current?.(hx);
if (sheen && play !== "scrub" && mainWolf) playSheen(mainWolf, DUR.sheen);
onCompleteRef.current?.();
},
});
const ease = "power2.inOut";
if (mode === "wipe") {
/* Interlocked filled artwork: one slice down the whole mark (see Draw.tsx). */
tl.to(svg, { ...WIPE_IN, duration: T.wolf[0] + T.wolf[1], ease }, T.vLeft[0]);
} else {
if (vs[0]) tl.to(vs[0], { ...STROKE_IN, duration: T.vLeft[1], ease }, T.vLeft[0]);
if (vs[1]) tl.to(vs[1], { ...STROKE_IN, duration: T.vRight[1], ease }, T.vRight[0]);
vs.slice(2).forEach((v, i) => tl.to(v, { ...STROKE_IN, duration: 0.3, ease }, T.vRight[0] + 0.1 * (i + 1)));
if (mainWolf) tl.to(mainWolf, { ...STROKE_IN, duration: T.wolf[1], ease }, T.wolf[0]);
if (details.length) tl.to(details, { ...STROKE_IN, duration: T.detail[1], ease, stagger: 0.08 }, T.detail[0]);
if (handoffPath) tl.to(handoffPath, { ...STROKE_IN, duration: T.handoff[1], ease: "power2.out" }, T.handoff[0]);
}
/* The hairline lives outside the svg, so it follows either reveal. */
if (useLine) tl.to(lineEl, { scaleY: 1, duration: T.handoff[1] + 0.1, ease: "power2.out" }, T.handoff[0]);
if (play === "view") {
gsap.timeline({ scrollTrigger: { trigger: el, start: "top 80%", once: true, onEnter: () => tl.play() } });
} else if (play === "scrub") {
tl.eventCallback("onComplete", null);
gsap.to(tl, {
progress: 1,
ease: "none",
scrollTrigger: { trigger: el, start: "top 80%", end: "bottom 40%", scrub: 0.6 },
});
}
/* Keep the hairline attached to the mark when the viewport changes one rAF per
frame, not one full re-projection per resize event. */
let raf = 0;
const onResize = () => {
if (!useLine || raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
placeLine();
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
if (raf) cancelAnimationFrame(raf);
};
});
return () => mm.revert();
}, [play, sheen, handoff, delay]);
return (
<span ref={wrap} className={cn("relative inline-block [.js_&]:opacity-0", className)}>
{children}
{handoff && (
<span
ref={line}
aria-hidden="true"
className="pointer-events-none absolute w-px bg-gold opacity-0"
style={{ height: handoffLength, left: 0, top: "100%" }}
/>
)}
</span>
);
}
export default DrawMark;