first publish
This commit is contained in:
220
src/components/brand/DrawMark.tsx
Normal file
220
src/components/brand/DrawMark.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"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 (24–48). */
|
||||
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;
|
||||
Reference in New Issue
Block a user