feat: final graph

This commit is contained in:
2026-04-04 23:24:44 +02:00
parent 3132d40391
commit 3eec7f316e
9 changed files with 896 additions and 503 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import pointerSvg from "@/assets/pointer.min.svg";
import { useLazyEyes } from "@/hooks/useLazyEyes";
/**
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation.
@@ -16,13 +17,13 @@ export default function EditorPointer() {
const [clickKey, setClickKey] = useState(0);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cmRef = useRef<Element | null>(null);
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
const eyeTargetRef = useRef({ x: 0, y: 0 });
const eyeCurrentRef = useRef({ x: 0, y: 0 });
// Eye anchor tracks the pointer's viewport position
const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef });
useEffect(() => {
const ease = 0.09;
const eyeEase = 0.06;
const updateLeft = () => {
if (cmRef.current) {
@@ -30,21 +31,6 @@ export default function EditorPointer() {
}
};
const onMouseMove = (e: MouseEvent) => {
const pointerX = (cmRef.current?.getBoundingClientRect().left ?? 0) - 100;
const pointerY = currentRef.current;
const dx = e.clientX - pointerX;
const dy = e.clientY - pointerY;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const maxShift = 1.5;
eyeTargetRef.current = {
x: (dx / dist) * maxShift,
y: (dy / dist) * maxShift,
};
};
window.addEventListener("mousemove", onMouseMove);
const onEditorClick = () => {
clearTimeout(clickTimerRef.current);
setClickKey((k) => k + 1);
@@ -82,13 +68,11 @@ export default function EditorPointer() {
currentRef.current += diff * ease;
}
setY(currentRef.current);
// Update eye anchor to match pointer position
const left = cmRef.current?.getBoundingClientRect().left ?? 0;
eyeAnchorRef.current = { x: left - 100, y: currentRef.current };
}
// Lerp eyes toward target
const ec = eyeCurrentRef.current;
const et = eyeTargetRef.current;
ec.x += (et.x - ec.x) * eyeEase;
ec.y += (et.y - ec.y) * eyeEase;
setEyeOffset({ x: ec.x, y: ec.y });
rafRef.current = requestAnimationFrame(tick);
};
@@ -106,7 +90,6 @@ export default function EditorPointer() {
return () => {
window.removeEventListener("cm-cursor-move", onCursorMove);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", updateLeft);
cmRef.current?.removeEventListener("mousedown", onEditorClick);
cancelAnimationFrame(rafRef.current);

View File

@@ -3,7 +3,9 @@ import { useNavigate, useLocation } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import frameSvg from "@/assets/frame.themed.svg";
import browserSvg from "@/assets/browser.min.svg";
import NavMenu from "./NavMenu";
import LazyEyes from "./LazyEyes";
const TITLE = `
@@ -80,11 +82,47 @@ function RomanClock() {
);
}
// ---------------------------------------------------------------------------
// Per-frame layout configs
// ---------------------------------------------------------------------------
interface FrameLayout {
svg: string;
frameHeight: number;
containerMaxW: string;
containerMinW: string;
title: { paddingTop: number; height: number; paddingLeft: number; paddingRight: number; paddingBottom: number };
content: { marginLeft: number; marginTop: number; marginRight: number; width: number; height: number; paddingTop: number; paddingBottom: number };
nav: { top: number; left: number };
}
const defaultLayout: FrameLayout = {
svg: frameSvg,
frameHeight: 1315,
containerMaxW: "max-w-5xl",
containerMinW: "min-w-5xl",
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
content: { marginLeft: 12, marginRight: 68, width: 843, height: 1030, paddingTop: 8, paddingBottom: 20 },
nav: { top: 150, left: -210 },
};
const browseLayout: FrameLayout = {
svg: browserSvg,
frameHeight: 884,
containerMaxW: "max-w-5xl",
containerMinW: "min-w-5xl",
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
content: { marginLeft: 268, marginTop: 63, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 },
nav: { top: 150, left: -210 },
};
export default function AppShell({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const location = useLocation();
const [theme, setTheme] = useState<Theme>(getStoredTheme);
const isEditor = location.pathname.startsWith("/editor");
const isBrowse = location.pathname === "/browse";
const layout = isBrowse ? browseLayout : defaultLayout;
useEffect(() => {
const root = document.documentElement;
@@ -104,17 +142,17 @@ export default function AppShell({ children }: { children: ReactNode }) {
<TooltipProvider>
<div className="flex flex-col h-screen bg-background text-foreground">
<main className="flex-1 overflow-auto" data-scroll-root>
<div className="relative max-w-5xl min-w-5xl mx-auto min-h-full">
<div className={`relative ${layout.containerMaxW} ${layout.containerMinW} mx-auto min-h-full`}>
{/* Frame SVG — background */}
<div
aria-hidden
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
style={{
zIndex: 0,
height: "1315px",
height: `${layout.frameHeight}px`,
background: "var(--primary)",
WebkitMaskImage: `url(${frameSvg})`,
maskImage: `url(${frameSvg})`,
WebkitMaskImage: `url(${layout.svg})`,
maskImage: `url(${layout.svg})`,
WebkitMaskSize: "cover",
maskSize: "cover",
WebkitMaskRepeat: "no-repeat",
@@ -123,15 +161,26 @@ export default function AppShell({ children }: { children: ReactNode }) {
}}
/>
{/* Lazy eyes on the browse frame figure */}
{isBrowse && (
<LazyEyes
eyes={[
{ top: 81, left: 554, size: 5, irisSize: 2 },
{ top: 85, left: 562, size: 5, irisSize: 2 },
]}
maxShift={2}
/>
)}
{/* Top hole — ASCII title */}
<div
className="relative z-10 flex flex-col cursor-pointer overflow-hidden"
style={{ paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }}
onClick={() => navigate("/")}
style={layout.title}
>
<pre
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center"
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center bg-background"
style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }}
onClick={() => navigate("/")}
>
{TITLE}
</pre>
@@ -141,7 +190,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
{/* Bottom hole — main content */}
<div
className="relative z-10 overflow-auto"
style={{ marginLeft: 12, marginRight: 68, width: 843, height: 1030, paddingTop: 8, paddingBottom: 20 }}
style={layout.content}
>
{children}
</div>
@@ -150,7 +199,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
{!isEditor && (
<div
className="absolute z-20"
style={{ top: 150, left: -210 }}
style={layout.nav}
>
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
</div>
@@ -159,7 +208,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
</div>
</main>
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
<span>Becoming with hubris · MMXXVI</span>
<span>Created with hubris · MMXXVI</span>
</footer>
</div>
<Toaster />

View File

@@ -0,0 +1,79 @@
import { useRef, useEffect } from "react";
import { useLazyEyes } from "@/hooks/useLazyEyes";
interface EyeSpec {
top: number;
left: number;
size?: number;
irisSize?: number;
}
interface LazyEyesProps {
eyes: EyeSpec[];
/** Viewport-relative anchor the eyes "live" at. If omitted, uses the component's own position. */
anchor?: { x: number; y: number };
maxShift?: number;
ease?: number;
className?: string;
}
export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: LazyEyesProps) {
const containerRef = useRef<HTMLDivElement>(null);
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
// Keep anchorRef in sync with prop or auto-compute from DOM
useEffect(() => {
if (anchor) {
anchorRef.current = anchor;
return;
}
const update = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
anchorRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
};
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [anchor]);
const offset = useLazyEyes({ anchorRef, maxShift, ease });
return (
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
{eyes.map((eye, i) => {
const size = eye.size ?? 5;
const irisSize = eye.irisSize ?? 2;
// Clamp iris within eye circle
const maxR = (size - irisSize) / 2;
const dist = Math.sqrt(offset.x * offset.x + offset.y * offset.y) || 0;
const scale = dist > maxR && dist > 0 ? maxR / dist : 1;
const cx = offset.x * scale;
const cy = offset.y * scale;
return (
<div
key={i}
className="editor-pointer-eye"
style={{ top: eye.top, left: eye.left, width: size, height: size }}
>
<div
className="editor-pointer-iris"
style={{
width: irisSize,
height: irisSize,
marginTop: -(irisSize / 2) - 0.5,
marginLeft: -(irisSize / 2) - 0.5,
transform: `translate(${cx}px, ${cy}px)`,
}}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
interface LazyEyesOptions {
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
anchorRef: React.RefObject<{ x: number; y: number } | null>;
/** Max pixel shift for the iris (default 1.5) */
maxShift?: number;
/** Lerp ease factor 01 for slow drift (default 0.04) */
ease?: number;
/** Saccade threshold — when target jumps more than this, snap fast (default 0.8) */
saccadeThreshold?: number;
/** Fast ease for saccade snap (default 0.35) */
saccadeEase?: number;
}
/**
* Returns a smoothly-interpolated { x, y } offset for positioning irises
* that lazily track the mouse cursor relative to an anchor point.
*
* Movement model:
* - Small mouse moves → slow, lazy drift (ease)
* - Large jumps → quick saccade snap (saccadeEase), then settle
* - Tiny random micro-drift to avoid perfectly still eyes
*/
export function useLazyEyes({
anchorRef,
maxShift = 1.5,
ease = 0.04,
saccadeThreshold = 0.8,
saccadeEase = 0.35,
}: LazyEyesOptions) {
const [offset, setOffset] = useState({ x: 0, y: 0 });
const targetRef = useRef({ x: 0, y: 0 });
const currentRef = useRef({ x: 0, y: 0 });
const velocityRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
const anchor = anchorRef.current;
if (!anchor) return;
const dx = e.clientX - anchor.x;
const dy = e.clientY - anchor.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
targetRef.current = {
x: (dx / dist) * maxShift,
y: (dy / dist) * maxShift,
};
};
window.addEventListener("mousemove", onMouseMove);
let raf = 0;
const tick = () => {
const ec = currentRef.current;
const et = targetRef.current;
const vel = velocityRef.current;
// Distance to target
const dx = et.x - ec.x;
const dy = et.y - ec.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Saccade: snap fast when target jumps significantly
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
// Apply eased movement
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
ec.x += vel.x;
ec.y += vel.y;
// Micro-drift: tiny organic tremor when nearly still
if (dist < 0.1) {
ec.x += (Math.random() - 0.5) * 0.02;
ec.y += (Math.random() - 0.5) * 0.02;
}
setOffset({ x: ec.x, y: ec.y });
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(raf);
};
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
return offset;
}

File diff suppressed because it is too large Load Diff