209 lines
9.2 KiB
Plaintext
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>
|