New design: studio backdrop with a perspective floor and X/Z axis lines, an orientation gizmo that orbits on scroll, Bricolage Grotesque and Instrument Sans (bundled, no build-time network). Projects are laid out as plates by a row planner that keeps every row full for any count; a demo image from the repo root is shown when present, otherwise a generated voxel sculpture. Text and projects still come live from Gitea. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
60 lines
2.0 KiB
TypeScript
60 lines
2.0 KiB
TypeScript
'use client'
|
|
|
|
import { useScrollProgress } from '@/lib/useScroll'
|
|
import styles from './gizmo.module.css'
|
|
|
|
const AXES = [
|
|
{ label: 'X', vector: [1, 0, 0], color: 'var(--x)' },
|
|
{ label: 'Y', vector: [0, 1, 0], color: 'var(--y)' },
|
|
{ label: 'Z', vector: [0, 0, 1], color: 'var(--z)' },
|
|
] as const
|
|
|
|
const PITCH = 0.46
|
|
const LENGTH = 29
|
|
const CENTER = 48
|
|
|
|
function project([x, y, z]: readonly [number, number, number], yaw: number) {
|
|
const x1 = x * Math.cos(yaw) + z * Math.sin(yaw)
|
|
const z1 = -x * Math.sin(yaw) + z * Math.cos(yaw)
|
|
const y2 = y * Math.cos(PITCH) - z1 * Math.sin(PITCH)
|
|
const depth = y * Math.sin(PITCH) + z1 * Math.cos(PITCH)
|
|
return { x: CENTER + x1 * LENGTH, y: CENTER - y2 * LENGTH, depth }
|
|
}
|
|
|
|
/** Orientation gizmo of a 3D viewport. It orbits as the page scrolls and returns to the top when pressed. */
|
|
export default function Gizmo() {
|
|
const progress = useScrollProgress()
|
|
const yaw = 0.62 + progress * Math.PI * 1.35
|
|
|
|
const ends = AXES.flatMap((axis) =>
|
|
([1, -1] as const).map((sign) => ({
|
|
axis,
|
|
positive: sign === 1,
|
|
...project([axis.vector[0] * sign, axis.vector[1] * sign, axis.vector[2] * sign], yaw),
|
|
})),
|
|
).sort((a, b) => a.depth - b.depth)
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={styles.gizmo}
|
|
aria-label="Back to top"
|
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
>
|
|
<svg viewBox="0 0 96 96" aria-hidden="true">
|
|
{ends.map(({ axis, positive, x, y }) =>
|
|
positive ? (
|
|
<g key={axis.label + '+'}>
|
|
<line x1={CENTER} y1={CENTER} x2={x} y2={y} stroke={axis.color} strokeWidth="3.2" strokeLinecap="round" />
|
|
<circle cx={x} cy={y} r="9.5" fill={axis.color} />
|
|
<text x={x} y={y + 3.6} textAnchor="middle" className={styles.letter}>{axis.label}</text>
|
|
</g>
|
|
) : (
|
|
<circle key={axis.label + '-'} cx={x} cy={y} r="6" fill={axis.color} opacity="0.32" />
|
|
),
|
|
)}
|
|
</svg>
|
|
</button>
|
|
)
|
|
}
|