evOo-local/packages/ui-web/src/oo-figure.tsx
Cursor Agent 71613064ea
Align Oo avatar eye spacing with evoo-logo overlap ratio
Derive small-eye placement from evoo-logo.svg center spacing (~0.94 of
r_big + r_small) for the shared rig, EvooMascot brand lock, and face crops.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 14:12:59 +00:00

323 lines
9.7 KiB
TypeScript

import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core";
import {
buildOoRig,
createOoLife,
OO_LIST_MOMENTS,
OO_RIG_FACE_VIEWBOX,
OO_RIG_INSET,
OO_RIG_SIZE,
OO_STILL_TIME,
ooBoop,
ooFrame,
ooLodForSize,
ooSeededRandom,
} from "@rakazo/core";
import type { PointerEvent, ReactNode } from "react";
import { useEffect, useId, useMemo, useRef } from "react";
export interface OoFigureProps {
avatar: OoAvatar;
/** Resolved body colour (`#RRGGBB`). */
color: string;
/** Classic silhouette when the avatar names none. */
fallbackShape: number;
moment: OoMoment;
size: number;
/** Draw one still frame (thumbnails, pickers). Reduced motion always draws still. */
still?: boolean;
/** Eyes follow the pointer and a tap boops the Oo. */
interactive?: boolean;
/** `face` crops to the eyes, for pickers of eye styles and eyewear. */
focus?: "body" | "face";
className?: string;
}
/** The body fills the avatar box; ears, hats and effects may reach past it. */
const OO_VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
const OO_FACE_VIEWBOX = OO_RIG_FACE_VIEWBOX;
/** Above this size an Oo always moves; smaller ones only for list moments. */
const LIVELY_SIZE = 56;
/* ---------- shared ticker: one animation frame loop for every visible Oo ---------- */
type Tick = (now: number) => void;
const ticks = new Set<Tick>();
let frameRequest = 0;
function loop(now: number) {
for (const tick of ticks) tick(now / 1000);
frameRequest = ticks.size ? requestAnimationFrame(loop) : 0;
}
function subscribe(tick: Tick): () => void {
ticks.add(tick);
if (!frameRequest) frameRequest = requestAnimationFrame(loop);
return () => {
ticks.delete(tick);
};
}
function prefersReducedMotion(): boolean {
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/* ---------- static drawing ---------- */
function paintProps(paint: OoPaint, gradient: string) {
const fill =
paint.fill === "$body"
? `url(#${gradient}-body)`
: paint.fill === "$shine"
? `url(#${gradient}-shine)`
: paint.fill;
return {
fill: fill ?? (paint.stroke ? "none" : undefined),
stroke: paint.stroke,
strokeWidth: paint.strokeWidth,
opacity: paint.opacity,
fillOpacity: paint.fillOpacity,
strokeOpacity: paint.strokeOpacity,
strokeLinecap: paint.round ? ("round" as const) : undefined,
strokeLinejoin: paint.round ? ("round" as const) : undefined,
strokeDasharray: paint.dash,
};
}
function draw(
shape: OoShape,
key: number,
gradient: string,
frame: OoFrame,
mouthColor: string,
): ReactNode {
switch (shape.type) {
case "path":
return <path key={key} d={shape.d} {...paintProps(shape, gradient)} />;
case "circle":
return (
<circle
key={key}
cx={shape.cx}
cy={shape.cy}
r={shape.r}
{...paintProps(shape, gradient)}
/>
);
case "ellipse":
return (
<ellipse
key={key}
cx={shape.cx}
cy={shape.cy}
rx={shape.rx}
ry={shape.ry}
{...paintProps(shape, gradient)}
/>
);
case "rect":
return (
<rect
key={key}
x={shape.x}
y={shape.y}
width={shape.width}
height={shape.height}
rx={shape.rx}
{...paintProps(shape, gradient)}
/>
);
case "group": {
const { part, fx } = shape;
if (part === "mouth") {
return (
<g key={key} data-part="mouth">
<path
d={frame.mouth.d}
fill={frame.mouth.filled ? mouthColor : "none"}
stroke={mouthColor}
strokeWidth={3.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
);
}
if (part === "tongue") {
return (
<g key={key} data-part="tongue">
<path d={frame.tongue} fill="#FF7FA6" />
</g>
);
}
const state = fx
? frame.fx(fx.kind, fx.index)
: part && part !== "fx"
? frame.parts[part]
: undefined;
const hidden = state?.hidden ?? shape.hidden;
return (
<g
key={key}
data-part={part}
data-fx={fx?.kind}
data-fx-index={fx?.index}
transform={state?.transform ?? shape.transform}
opacity={state?.opacity}
display={hidden ? "none" : undefined}
>
{shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))}
</g>
);
}
}
}
/* ---------- live updates ---------- */
function applyFrame(svg: SVGSVGElement, frame: OoFrame, mouthColor: string) {
for (const node of svg.querySelectorAll<SVGGElement>("g[data-part]")) {
const part = node.dataset.part;
if (part === "mouth") {
const path = node.firstElementChild;
path?.setAttribute("d", frame.mouth.d);
path?.setAttribute("fill", frame.mouth.filled ? mouthColor : "none");
continue;
}
if (part === "tongue") {
node.firstElementChild?.setAttribute("d", frame.tongue);
continue;
}
const fxKind = node.dataset.fx;
const state =
part === "fx" && fxKind
? frame.fx(fxKind as Parameters<OoFrame["fx"]>[0], Number(node.dataset.fxIndex))
: frame.parts[part as keyof OoFrame["parts"]];
if (!state) continue;
if (state.transform !== undefined) node.setAttribute("transform", state.transform);
if (state.opacity !== undefined) node.setAttribute("opacity", String(state.opacity));
if (state.hidden !== undefined) {
if (state.hidden) node.setAttribute("display", "none");
else node.removeAttribute("display");
}
}
}
/** One Oo, drawn from the shared rig and kept alive by the shared ticker while visible. */
export function OoFigure({
avatar,
color,
fallbackShape,
moment,
size,
still = false,
interactive = false,
focus = "body",
className,
}: OoFigureProps) {
const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
const lod = ooLodForSize(size);
const rig = useMemo(
() => buildOoRig({ avatar, color, fallbackShape, moment, lod }),
[avatar, color, fallbackShape, moment, lod],
);
// The first paint is a still frame, so server renders and screenshots are stable.
const stillFrame = useMemo(
() =>
ooFrame({
rig,
avatar,
moment,
t: OO_STILL_TIME,
life: createOoLife(ooSeededRandom(1)),
still: true,
}),
[rig, avatar, moment],
);
const svgRef = useRef<SVGSVGElement>(null);
const lifeRef = useRef<OoLife | null>(null);
const startRef = useRef(0);
// Small Oos in lists only move for the moments people watch for.
const lively = !still && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment));
useEffect(() => {
const svg = svgRef.current;
if (!svg || !lively || prefersReducedMotion() || typeof requestAnimationFrame !== "function")
return;
if (!lifeRef.current) {
lifeRef.current = createOoLife();
startRef.current = performance.now() / 1000 - Math.random() * 10;
}
const life = lifeRef.current;
const start = startRef.current;
const tick = (now: number) =>
applyFrame(svg, ooFrame({ rig, avatar, moment, t: now - start, life }), rig.mouthColor);
let unsubscribe: (() => void) | null = null;
if (typeof IntersectionObserver !== "function") {
unsubscribe = subscribe(tick);
return () => unsubscribe?.();
}
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) unsubscribe ??= subscribe(tick);
else {
unsubscribe?.();
unsubscribe = null;
}
});
observer.observe(svg);
return () => {
observer.disconnect();
unsubscribe?.();
};
}, [rig, avatar, moment, lively]);
const pointerHandlers = interactive
? {
onPointerMove: (event: PointerEvent<SVGSVGElement>) => {
const life = lifeRef.current;
if (!life || lod !== "full") return;
const box = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - box.left) / box.width) * 2 - 1;
const y = ((event.clientY - box.top) / box.height) * 2 - 1;
life.pointer = { x: Math.max(-1, Math.min(1, x)), y: Math.max(-1, Math.min(1, y)) };
},
onPointerLeave: () => {
if (lifeRef.current) lifeRef.current.pointer = null;
},
onPointerDown: () => {
if (lifeRef.current) ooBoop(lifeRef.current, performance.now() / 1000 - startRef.current);
},
}
: {};
const { colors } = rig;
return (
<svg
ref={svgRef}
viewBox={focus === "face" ? OO_FACE_VIEWBOX : OO_VIEWBOX}
width={size}
height={size}
aria-hidden="true"
className={className}
data-oo-species={avatar.species}
data-oo-moment={moment}
overflow={focus === "face" ? "hidden" : "visible"}
{...pointerHandlers}
>
<defs>
<linearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor={colors.light} />
<stop offset="0.45" stopColor={colors.base} />
<stop offset="1" stopColor={colors.dark} />
</linearGradient>
<radialGradient id={`${gradient}-shine`} cx="0.3" cy="0.22" r="0.55">
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.55" />
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
</radialGradient>
</defs>
{rig.shapes.map((shape, index) => draw(shape, index, gradient, stillFrame, rig.mouthColor))}
</svg>
);
}