Colours move into theme tokens (light values in :root, dark in [data-theme=dark]); the closing contact band inverts on each theme. The choice is stored in localStorage and applied by a head script before the first paint. Voxel covers and image shadows follow the theme. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
103 lines
4.1 KiB
TypeScript
103 lines
4.1 KiB
TypeScript
import { useMemo } from 'react'
|
|
|
|
// Deterministic isometric "sculpture" for a project without a demo image: the repo name seeds a small voxel
|
|
// stack, so every project gets its own picture that stays the same between visits.
|
|
|
|
function hash(text: string): number {
|
|
let h = 2166136261
|
|
for (let i = 0; i < text.length; i++) {
|
|
h ^= text.charCodeAt(i)
|
|
h = Math.imul(h, 16777619)
|
|
}
|
|
return h >>> 0
|
|
}
|
|
|
|
function random(seed: number) {
|
|
let a = seed
|
|
return () => {
|
|
a = (a + 0x6d2b79f5) | 0
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
}
|
|
}
|
|
|
|
const GRID = 5
|
|
const TILE = 54
|
|
const RISE = 30
|
|
const ACCENTS = ['#e0384b', '#5aa300', '#2c63e0']
|
|
const NEUTRAL = { top: 'var(--cube-top)', left: 'var(--cube-left)', right: 'var(--cube-right)' }
|
|
|
|
function mix(hex: string, other: number, amount: number): string {
|
|
const channel = (i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
|
|
const to = (i: number) => Math.round(channel(i) * (1 - amount) + other * amount).toString(16).padStart(2, '0')
|
|
return `#${to(0)}${to(1)}${to(2)}`
|
|
}
|
|
|
|
type Cube = { i: number; j: number; k: number; top: string; left: string; right: string }
|
|
|
|
function build(name: string): Cube[] {
|
|
const next = random(hash(name))
|
|
const cubes: Cube[] = []
|
|
const accent = ACCENTS[Math.floor(next() * ACCENTS.length)]
|
|
const second = ACCENTS[Math.floor(next() * ACCENTS.length)]
|
|
for (let i = 0; i < GRID; i++) {
|
|
for (let j = 0; j < GRID; j++) {
|
|
const edge = Math.abs(i - 2) + Math.abs(j - 2)
|
|
const height = Math.max(0, Math.round(next() ** 1.3 * 4 - edge * 0.35))
|
|
for (let k = 0; k < height; k++) {
|
|
const isTop = k === height - 1
|
|
const pick = next()
|
|
const color = isTop && pick < 0.3 ? accent : isTop && pick < 0.4 ? second : null
|
|
cubes.push(
|
|
color
|
|
? { i, j, k, top: mix(color, 255, 0.22), left: color, right: mix(color, 0, 0.24) }
|
|
: { i, j, k, ...NEUTRAL },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if (cubes.length < 5) cubes.push({ i: 2, j: 2, k: 0, ...NEUTRAL }, { i: 2, j: 2, k: 1, top: mix(accent, 255, 0.22), left: accent, right: mix(accent, 0, 0.24) })
|
|
return cubes.sort((a, b) => a.i + a.j - (b.i + b.j) || a.k - b.k)
|
|
}
|
|
|
|
const face = (points: [number, number][]) => points.map((p) => p.join(',')).join(' ')
|
|
|
|
export default function Cover({ name }: Readonly<{ name: string }>) {
|
|
const cubes = useMemo(() => build(name), [name])
|
|
const originX = 220
|
|
const originY = 150
|
|
const point = (i: number, j: number, z: number, dx: number, dy: number): [number, number] => [
|
|
+(originX + (i - j) * (TILE / 2) + dx).toFixed(1),
|
|
+(originY + (i + j) * (TILE / 4) + dy - z).toFixed(1),
|
|
]
|
|
|
|
return (
|
|
<svg viewBox="0 0 440 320" preserveAspectRatio="xMidYMid meet" role="presentation" aria-hidden="true">
|
|
{Array.from({ length: GRID * GRID }, (_, n) => {
|
|
const i = Math.floor(n / GRID)
|
|
const j = n % GRID
|
|
return (
|
|
<polygon
|
|
key={n}
|
|
points={face([point(i, j, 0, 0, 0), point(i, j, 0, TILE / 2, TILE / 4), point(i, j, 0, 0, TILE / 2), point(i, j, 0, -TILE / 2, TILE / 4)])}
|
|
fill="none"
|
|
style={{ stroke: 'var(--cover-grid)' }}
|
|
/>
|
|
)
|
|
})}
|
|
{cubes.map(({ i, j, k, top, left, right }) => {
|
|
const z = (k + 1) * RISE
|
|
const base = k * RISE
|
|
return (
|
|
<g key={`${i}-${j}-${k}`} strokeLinejoin="round" strokeWidth="0.75" style={{ stroke: 'var(--cube-edge)' }}>
|
|
<polygon points={face([point(i, j, z, -TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, base, 0, TILE / 2), point(i, j, base, -TILE / 2, TILE / 4)])} style={{ fill: left }} />
|
|
<polygon points={face([point(i, j, z, 0, TILE / 2), point(i, j, z, TILE / 2, TILE / 4), point(i, j, base, TILE / 2, TILE / 4), point(i, j, base, 0, TILE / 2)])} style={{ fill: right }} />
|
|
<polygon points={face([point(i, j, z, 0, 0), point(i, j, z, TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, z, -TILE / 2, TILE / 4)])} style={{ fill: top }} />
|
|
</g>
|
|
)
|
|
})}
|
|
</svg>
|
|
)
|
|
}
|