Files
homepage_relaunch/src/components/home/GridObject.astro
T
2026-09-20 12:45:33 +02:00

209 lines
9.2 KiB
Plaintext

---
/**
* Grid objects: wireframe solids drawn as technical line drawings.
* Single stroke in currentColor, even width, no fills. The geometry is computed
* at build time so the markup stays small and the stroke stays exact.
* Kinds: sphere (a model seen from every side), cube (a built structure),
* plane (one element lifted out for inspection), wave (a signal over time),
* steps (a ranked row of columns), nested (a structure inside its own walls),
* stack (layers kept one on top of the other).
*/
interface Props { kind: 'sphere' | 'cube' | 'plane' | 'wave' | 'steps' | 'nested' | 'stack'; class?: string }
const { kind, class: className } = Astro.props;
const S = 400;
const f = (n: number) => Math.round(n * 10) / 10;
/* Every drawing is laid out by hand, so the box is derived from the geometry rather than
assumed: each point is recorded as it is emitted and the viewBox follows at the end. That
way a drawing can never be clipped, whatever a new kind does. */
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const track = (x: number, y: number) => {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
};
const poly = (pts: [number, number][]) => {
for (const [x, y] of pts) track(x, y);
return pts.map(([x, y]) => `${f(x)},${f(y)}`).join(' ');
};
/* Isometric projection of a 3D point (x right, y up, z towards the viewer's left). */
const ISO = { cx: S / 2, cy: S / 2, c30: Math.cos(Math.PI / 6), s30: Math.sin(Math.PI / 6) };
const iso = (x: number, y: number, z: number, cy = ISO.cy, cx = ISO.cx): [number, number] =>
[cx + (x - z) * ISO.c30, cy + (x + z) * ISO.s30 - y];
/* The twelve edges of a cuboid with its near-bottom corner at (x0, y0, z0). */
function cuboid(x0: number, y0: number, z0: number, L: number, H: number, D: number, cy: number, cx = ISO.cx): string[] {
const p = (x: number, y: number, z: number) => iso(x, y, z, cy, cx);
const c = [
p(x0, y0, z0), p(x0 + L, y0, z0), p(x0 + L, y0, z0 + D), p(x0, y0, z0 + D),
p(x0, y0 + H, z0), p(x0 + L, y0 + H, z0), p(x0 + L, y0 + H, z0 + D), p(x0, y0 + H, z0 + D),
];
const e: [number, number][] = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
return e.map(([a, b]) => poly([c[a], c[b]]));
}
/* A square grid plane of n cells at height y. */
function plane(n: number, u: number, y: number, cy: number, cx = ISO.cx): string[] {
const L = n * u, out: string[] = [];
for (let i = 0; i <= n; i++) {
const t = i * u;
out.push(poly([iso(t, y, 0, cy, cx), iso(t, y, L, cy, cx)]));
out.push(poly([iso(0, y, t, cy, cx), iso(L, y, t, cy, cx)]));
}
return out;
}
/* Each drawing is split in two: the lines that carry its idea, and the grid around them. A
hover lets the grid recede so the idea stands on its own. */
const base: string[] = [];
const accent: string[] = [];
if (kind === 'sphere') {
const R = 150, tilt = (22 * Math.PI) / 180, N = 48;
const p3 = (lat: number, lon: number): [number, number] => {
const x = R * Math.cos(lat) * Math.sin(lon);
const y = R * Math.sin(lat);
const z = R * Math.cos(lat) * Math.cos(lon);
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt);
return [ISO.cx + x, ISO.cy - y2];
};
for (let d = -80; d <= 80; d += 20) {
const lat = (d * Math.PI) / 180;
const pts: [number, number][] = [];
for (let i = 0; i <= N; i++) pts.push(p3(lat, (i / N) * 2 * Math.PI));
(d === 0 ? accent : base).push(poly(pts));
}
for (let m = 0; m < 8; m++) {
const lon = (m * Math.PI) / 8;
const pts: [number, number][] = [];
for (let i = 0; i <= N; i++) pts.push(p3((i / N) * 2 * Math.PI - Math.PI, lon));
(m === 0 ? accent : base).push(poly(pts));
}
accent.push(`circle:${R}`);
track(ISO.cx - R, ISO.cy - R);
track(ISO.cx + R, ISO.cy + R);
}
if (kind === 'cube') {
const n = 4, u = 46, L = n * u, cy = ISO.cy + L * 0.5;
for (let i = 0; i <= n; i++) {
const t = i * u;
/* The first and last line of each face are the cube's own edges. */
const target = i === 0 || i === n ? accent : base;
target.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)]));
target.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)]));
target.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)]));
target.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)]));
target.push(poly([iso(0, 0, t, cy), iso(0, L, t, cy)]));
target.push(poly([iso(0, t, 0, cy), iso(0, t, L, cy)]));
}
}
if (kind === 'plane') {
const n = 6, u = 34, L = n * u, cy = ISO.cy + 20, h = 96, tx = 2, tz = 2;
for (let i = 0; i <= n; i++) {
const t = i * u;
base.push(poly([iso(t, 0, 0, cy), iso(t, 0, L, cy)]));
base.push(poly([iso(0, 0, t, cy), iso(L, 0, t, cy)]));
}
/* The tile that has been lifted out for inspection. */
const c: [number, number][] = [[tx, tz], [tx + 1, tz], [tx + 1, tz + 1], [tx, tz + 1]];
const top = c.map(([x, z]) => iso(x * u, h, z * u, cy));
accent.push(poly([...top, top[0]]));
c.forEach(([x, z], i) => accent.push(poly([iso(x * u, 0, z * u, cy), top[i]])));
}
if (kind === 'wave') {
const nx = 12, nz = 8, u = 26, cy = ISO.cy + 10, A = 26;
const yAt = (x: number, z: number) => A * Math.sin((x / nx) * Math.PI * 2 - 0.6) * Math.cos((z / nz) * Math.PI * 0.9);
const P = (x: number, z: number) => iso((x - nx / 2) * u, yAt(x, z), (z - nz / 2) * u, cy);
/* The lines along x carry the wave itself; the cross lines are the grid it is read against. */
for (let z = 0; z <= nz; z++) {
const pts: [number, number][] = [];
for (let x = 0; x <= nx; x++) pts.push(P(x, z));
accent.push(poly(pts));
}
for (let x = 0; x <= nx; x++) {
const pts: [number, number][] = [];
for (let z = 0; z <= nz; z++) pts.push(P(x, z));
base.push(poly(pts));
}
}
if (kind === 'steps') {
/* Five columns in a row, tallest first: a ranked list seen as a skyline. */
const heights = [168, 128, 96, 68, 44], w = 40, gap = 14, depth = 40, cy = ISO.cy + 78;
const total = heights.length * w + (heights.length - 1) * gap;
heights.forEach((h, i) => {
const x0 = -total / 2 + i * (w + gap);
/* The first column is the one to fix first. */
cuboid(x0, 0, -depth / 2, w, h, depth, cy).forEach((l) => (i === 0 ? accent : base).push(l));
});
const L = total + 48;
base.push(poly([iso(-L / 2, 0, -depth / 2 - 24, cy), iso(L / 2, 0, -depth / 2 - 24, cy)]));
}
if (kind === 'nested') {
/* A structure inside its own walls: a cube inside a cube, corners tied together. */
const O = 200, I = 96, cy = ISO.cy + 84;
cuboid(-O / 2, 0, -O / 2, O, O, O, cy).forEach((l) => base.push(l));
const off = (O - I) / 2;
cuboid(-I / 2, off, -I / 2, I, I, I, cy).forEach((l) => accent.push(l));
const outer = [[-O / 2, 0, -O / 2], [O / 2, 0, -O / 2], [O / 2, 0, O / 2], [-O / 2, 0, O / 2], [-O / 2, O, -O / 2], [O / 2, O, -O / 2], [O / 2, O, O / 2], [-O / 2, O, O / 2]];
const inner = [[-I / 2, off, -I / 2], [I / 2, off, -I / 2], [I / 2, off, I / 2], [-I / 2, off, I / 2], [-I / 2, off + I, -I / 2], [I / 2, off + I, -I / 2], [I / 2, off + I, I / 2], [-I / 2, off + I, I / 2]];
outer.forEach(([x, y, z], k) => base.push(poly([iso(x, y, z, cy), iso(inner[k][0], inner[k][1], inner[k][2], cy)])));
}
if (kind === 'stack') {
/* Layers one on top of the other, each a record kept whole. */
const n = 4, u = 38, L = n * u, gapY = 46, layers = 4, cy = ISO.cy + 60;
/* The newest layer sits on top of the ones already kept. */
for (let k = 0; k < layers; k++) {
plane(n, u, k * gapY, cy).forEach((l) => (k === layers - 1 ? accent : base).push(l));
}
const corners: [number, number][] = [[0, 0], [L, 0], [L, L], [0, L]];
corners.forEach(([x, z]) => base.push(poly([iso(x, 0, z, cy), iso(x, (layers - 1) * gapY, z, cy)])));
}
/* A square box centred on the drawing: wide drawings still fill the width, and every object
keeps the same footprint, so the four cards of a row stay even. The padding covers the
stroke, which sits half outside the geometry. */
const PAD = 8;
const width = maxX - minX;
const height = maxY - minY;
const side = Math.max(width, height) + PAD * 2;
const viewBox = [minX + width / 2 - side / 2, minY + height / 2 - side / 2, side, side].map(f).join(' ');
---
<svg class:list={['grid-object', className]} viewBox={viewBox} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<g class="grid-object__lines" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke">
<g class="grid-object__base">
{base.map((l) => l.startsWith('circle:')
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
: <polyline points={l} />)}
</g>
<g class="grid-object__accent">
{accent.map((l) => l.startsWith('circle:')
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
: <polyline points={l} />)}
</g>
</g>
</svg>
<style>
.grid-object {
width: 100%;
height: auto;
color: var(--ink);
/* Both are driven by the card around it, so the card owns the interaction and the drawing
owns how it answers. */
transform: scale(var(--object-scale, 1));
transform-origin: 50% 50%;
transition: transform var(--dur) var(--ease);
}
.grid-object__base {
opacity: var(--object-grid, 1);
transition: opacity var(--dur) var(--ease);
}
</style>