--- /** * 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(' '); ---