Add the Three.js constellation centerpiece and redesign the homepage

Data model: nodes are real content entries (writings, portfolio,
bookshelf), edges are shared-tag connections — the same relationship
"related posts" already uses, drawn instead of listed. No decorative
or fake data.

- src/lib/constellation-data.ts: builds the node/edge graph from the
  actual collections at build time.
- src/lib/constellation-scene.ts: vanilla Three.js scene (no
  framework) — fibonacci-sphere layout, auto-rotation, pointer
  raycasting with a title tooltip, click-through to the entry's page.
  Honors prefers-reduced-motion (skips rotation and the spring-eased
  entrance, which reuses src/lib/spring.ts) and checks WebGL support,
  falling back to a plain count for browsers/settings that lack it.
- src/components/Constellation.astro: wires it up, serializing the
  graph as inline JSON for the client script to consume.
- Homepage rebuilt around it: hero constellation above a 3-column
  dashboard (latest writings/portfolio/bookshelf), using a new `wide`
  layout variant on Base.astro (dashboard needs more than the 42rem
  article width).

Also: removes "life and beliefs" from all site copy (site.config.ts,
the llms.txt generator + its output) per request — description is
now "Computer science and mathematics."

Verified in-browser: real WebGL scene renders with correctly
positioned/colored nodes and edges, hover shows the right title via
raycasting, layout holds at the new wide container width.
This commit is contained in:
2026-08-31 08:12:35 +03:00
parent 9cd92d623d
commit f5cc3a2144
10 changed files with 440 additions and 30 deletions
+15 -7
View File
@@ -69,18 +69,26 @@ approved plan for full rationale on each decision referenced below.
header/nav with active-link state, theme toggle, footer with social
links. Verified light + dark in-browser across home, a writing
detail page (TOC/headings), bookshelf, and 404
- [ ] Homepage redesign around the Three.js centerpiece as hero — next up
- [x] Homepage redesign around the Three.js centerpiece as hero — the
constellation sits above a 3-column dashboard (latest writings,
portfolio, bookshelf), verified rendering and hover/click working
in-browser
- [x] New 404 page concept (terminal easter egg retired) — "Page Fault",
now fully styled with the rest of the design system
## Three.js centerpiece
- [ ] Decide the constellation/node-graph data model (what nodes/edges
represent — writings ↔ portfolio ↔ beliefs connections)
- [ ] Build as an isolated Astro island
- [ ] Static image/CSS fallback for `prefers-reduced-motion` and low-end
devices
- [ ] Perf budget check on a low-end/mobile profile
- [x] Constellation data model: nodes are real content entries (writings,
portfolio, bookshelf), edges are shared-tag connections — the same
relationship "related posts" already uses, drawn instead of listed.
No decorative/fake data. `src/lib/constellation-data.ts`
- [x] Built as an isolated component (`Constellation.astro` + a plain
client `<script>` module, no framework), fibonacci-sphere layout,
hover raycasting with a title tooltip, click-through to the entry
- [x] Fallback for `prefers-reduced-motion` (skips rotation/entrance
animation) and no-WebGL (`supportsWebGL()` check hides the canvas
entirely, falls back to a `<noscript>`-style plain count)
- [ ] Perf budget check on a low-end/mobile profile — not yet measured
## Content features carried over from the Zola site
+1 -1
View File
@@ -1,6 +1,6 @@
# Thoughts and Musings
> Ian Andwati's personal digital-legacy site covering computer science, mathematics, life, and beliefs — writings, portfolio, and a curated bookshelf.
> Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.
## Main pages
@@ -0,0 +1,70 @@
---
import { buildConstellationGraph } from "../lib/constellation-data";
const graph = await buildConstellationGraph();
---
<div class="constellation" data-constellation>
<div class="constellation-canvas" data-canvas aria-hidden="true"></div>
<div class="constellation-tooltip" data-tooltip></div>
<noscript>
<p class="meta">
{graph.nodes.length} entries, connected by shared tags — enable JavaScript to explore the constellation.
</p>
</noscript>
</div>
<script type="application/json" id="constellation-data" is:inline set:html={JSON.stringify(graph)} />
<style>
.constellation {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
max-height: 60vh;
margin-bottom: var(--space-6);
}
.constellation-canvas {
width: 100%;
height: 100%;
}
.constellation-canvas canvas {
display: block;
width: 100%;
height: 100%;
}
.constellation-tooltip {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-3);
opacity: 0;
transition: opacity 0.15s var(--ease-out);
white-space: nowrap;
}
</style>
<script>
import type { ConstellationGraph } from "../lib/constellation-data";
import { mountConstellation, supportsWebGL } from "../lib/constellation-scene";
const root = document.querySelector<HTMLElement>("[data-constellation]");
const canvas = root?.querySelector<HTMLElement>("[data-canvas]");
const tooltip = root?.querySelector<HTMLElement>("[data-tooltip]");
const dataEl = document.getElementById("constellation-data");
if (root && canvas && tooltip && dataEl?.textContent && supportsWebGL()) {
const graph: ConstellationGraph = JSON.parse(dataEl.textContent);
if (graph.nodes.length > 0) {
mountConstellation(canvas, tooltip, graph);
}
} else if (root) {
root.style.display = "none";
}
</script>
+4 -2
View File
@@ -8,8 +8,10 @@ import { siteConfig } from "../site.config";
interface Props {
title: string;
description?: string;
/** Widens .container for dashboard-style layouts (currently just the homepage). */
wide?: boolean;
}
const { title, description } = Astro.props;
const { title, description, wide = false } = Astro.props;
const metaDescription = description ?? siteConfig.description;
const currentPath = Astro.url.pathname;
---
@@ -109,7 +111,7 @@ const currentPath = Astro.url.pathname;
</style>
</head>
<body>
<div class="container">
<div class:list={["container", { "container--wide": wide }]}>
<header>
<a class="site-name" href="/">{siteConfig.author.toUpperCase()}</a>
<nav aria-label="Primary">
+58
View File
@@ -0,0 +1,58 @@
import { getCollection } from "astro:content";
export interface ConstellationNode {
title: string;
type: "writing" | "portfolio" | "bookshelf";
url: string;
}
export interface ConstellationGraph {
nodes: ConstellationNode[];
edges: [number, number][];
}
/**
* Nodes are real content entries; an edge means two entries share at least
* one tag — the same relationship "related posts" already uses, just drawn
* instead of listed. No decorative/fake data.
*/
export async function buildConstellationGraph(): Promise<ConstellationGraph> {
const writings = await getCollection("writings", ({ data }) => !data.draft);
const portfolio = await getCollection("portfolio", ({ data }) => !data.draft);
const bookshelf = await getCollection("bookshelf");
const withTags = [
...writings.map((e) => ({
title: e.data.title,
type: "writing" as const,
url: `/writings/${e.id}/`,
tags: e.data.taxonomies?.tags ?? [],
})),
...portfolio.map((e) => ({
title: e.data.title,
type: "portfolio" as const,
url: "/portfolio/",
tags: e.data.taxonomies?.tags ?? [],
})),
...bookshelf.map((e) => ({
title: e.data.title,
type: "bookshelf" as const,
url: "/bookshelf/",
tags: e.data.taxonomies?.tags ?? [],
})),
];
const nodes: ConstellationNode[] = withTags.map(
({ tags: _tags, ...node }) => node,
);
const edges: [number, number][] = [];
for (let i = 0; i < withTags.length; i++) {
for (let j = i + 1; j < withTags.length; j++) {
if (withTags[i].tags.some((t) => withTags[j].tags.includes(t))) {
edges.push([i, j]);
}
}
}
return { nodes, edges };
}
+200
View File
@@ -0,0 +1,200 @@
import * as THREE from "three";
import type { ConstellationGraph } from "./constellation-data";
import { animateSpring } from "./spring";
const TYPE_COLOR_VAR: Record<string, string> = {
writing: "--accent",
portfolio: "--text",
bookshelf: "--text-muted",
};
function cssColor(varName: string): THREE.Color {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return new THREE.Color(value || "#ffffff");
}
/** Deterministic, dependency-free layout: points spread evenly over a sphere. */
function fibonacciSphere(count: number, radius: number): THREE.Vector3[] {
const points: THREE.Vector3[] = [];
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < count; i++) {
const y = 1 - (i / Math.max(count - 1, 1)) * 2;
const r = Math.sqrt(Math.max(0, 1 - y * y));
const theta = goldenAngle * i;
points.push(
new THREE.Vector3(
Math.cos(theta) * r * radius,
y * radius,
Math.sin(theta) * r * radius,
),
);
}
return points;
}
export function supportsWebGL(): boolean {
try {
const canvas = document.createElement("canvas");
return !!(
window.WebGLRenderingContext &&
(canvas.getContext("webgl") || canvas.getContext("experimental-webgl"))
);
} catch {
return false;
}
}
export function mountConstellation(
container: HTMLElement,
tooltip: HTMLElement,
graph: ConstellationGraph,
): () => void {
const { nodes, edges } = graph;
const radius = 6.5;
const positions = fibonacciSphere(nodes.length, radius);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
50,
container.clientWidth / container.clientHeight,
0.1,
100,
);
camera.position.z = 13;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
const group = new THREE.Group();
scene.add(group);
// Edges: shared-tag connections between content entries.
const edgeGeometry = new THREE.BufferGeometry();
const edgePositions = new Float32Array(edges.length * 6);
edges.forEach(([a, b], i) => {
const pa = positions[a];
const pb = positions[b];
edgePositions.set([pa.x, pa.y, pa.z, pb.x, pb.y, pb.z], i * 6);
});
edgeGeometry.setAttribute(
"position",
new THREE.BufferAttribute(edgePositions, 3),
);
const edgeMaterial = new THREE.LineBasicMaterial({
color: cssColor("--border"),
transparent: true,
opacity: 0.5,
});
group.add(new THREE.LineSegments(edgeGeometry, edgeMaterial));
// Nodes: one small sphere per content entry, colored by type.
const nodeMeshes: THREE.Mesh[] = nodes.map((node, i) => {
const geometry = new THREE.SphereGeometry(0.12, 12, 12);
const material = new THREE.MeshBasicMaterial({
color: cssColor(TYPE_COLOR_VAR[node.type]),
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(positions[i]);
mesh.userData.node = node;
group.add(mesh);
return mesh;
});
const raycaster = new THREE.Raycaster();
raycaster.params.Points = { threshold: 0.3 };
const pointer = new THREE.Vector2(-2, -2);
let hovered: THREE.Mesh | null = null;
function onPointerMove(event: PointerEvent) {
const rect = container.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}
function onClick() {
if (hovered)
window.location.href = (hovered.userData.node as { url: string }).url;
}
container.addEventListener("pointermove", onPointerMove);
container.addEventListener("click", onClick);
function onResize() {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
window.addEventListener("resize", onResize);
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
// Object3D isn't a DOM element animateSpring can target directly, so drive
// the group's scale from a throwaway element's spring-animated keyframes.
group.scale.setScalar(reduceMotion ? 1 : 0.001);
if (!reduceMotion) {
const proxy = document.createElement("div");
animateSpring(proxy, {
from: 0,
to: 1,
stiffness: 120,
damping: 14,
toKeyframe: (v) => {
group.scale.setScalar(Math.max(v, 0.001));
return { opacity: "1" };
},
});
}
let frame = 0;
function animate() {
frame = requestAnimationFrame(animate);
if (!reduceMotion) group.rotation.y += 0.0009;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(nodeMeshes);
const hit = hits[0]?.object as THREE.Mesh | undefined;
if (hit !== hovered) {
if (hovered)
(hovered.material as THREE.MeshBasicMaterial).color.copy(
cssColor(TYPE_COLOR_VAR[hovered.userData.node.type]),
);
hovered = hit ?? null;
if (hovered) {
(hovered.material as THREE.MeshBasicMaterial).color.copy(
cssColor("--accent"),
);
const node = hovered.userData.node as { title: string };
tooltip.textContent = node.title;
tooltip.style.opacity = "1";
container.style.cursor = "pointer";
} else {
tooltip.style.opacity = "0";
container.style.cursor = "default";
}
}
if (hovered) {
const vector = hovered.position.clone().project(camera);
const x = ((vector.x + 1) / 2) * container.clientWidth;
const y = ((1 - vector.y) / 2) * container.clientHeight;
tooltip.style.transform = `translate(${x}px, ${y - 16}px)`;
}
renderer.render(scene, camera);
}
animate();
return function destroy() {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("click", onClick);
renderer.dispose();
container.removeChild(renderer.domElement);
};
}
+85 -17
View File
@@ -1,28 +1,96 @@
---
import { getCollection } from "astro:content";
import Constellation from "../components/Constellation.astro";
import Base from "../layouts/Base.astro";
import { siteConfig } from "../site.config";
const writings = (await getCollection("writings", ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 3);
const portfolio = (
await getCollection("portfolio", ({ data }) => !data.draft)
).slice(0, 3);
const bookshelf = (await getCollection("bookshelf")).slice(0, 3);
---
<Base title="Home">
<h1>Ian Andwati</h1>
<p>Computer science, mathematics, life, and beliefs.</p>
<Base title="Home" wide>
<h1>{siteConfig.author}</h1>
<p class="eyebrow">{siteConfig.description}</p>
<h2>Latest writings</h2>
{
writings.length === 0 ? (
<p class="meta">No writings published yet.</p>
) : (
writings.map((entry) => (
<div class="card">
<h3><a href={`/writings/${entry.id}/`}>{entry.data.title}</a></h3>
<p class="meta">{entry.data.date.toDateString()}</p>
{entry.data.description && <p>{entry.data.description}</p>}
</div>
))
)
}
<Constellation />
<div class="columns">
<section>
<p class="eyebrow">Writings</p>
{
writings.length === 0 ? (
<p class="meta">Nothing published yet.</p>
) : (
writings.map((entry) => (
<div class="card">
<h3>
<a href={`/writings/${entry.id}/`}>{entry.data.title}</a>
</h3>
<p class="meta">{entry.data.date.toDateString()}</p>
</div>
))
)
}
<a class="button" href="/writings/">All writings →</a>
</section>
<section>
<p class="eyebrow">Portfolio</p>
{
portfolio.length === 0 ? (
<p class="meta">Nothing published yet.</p>
) : (
portfolio.map((entry) => (
<div class="card">
<h3>{entry.data.title}</h3>
<p class="meta">{entry.data.role}</p>
</div>
))
)
}
<a class="button" href="/portfolio/">All projects →</a>
</section>
<section>
<p class="eyebrow">Bookshelf</p>
{
bookshelf.length === 0 ? (
<p class="meta">Nothing on the shelf yet.</p>
) : (
bookshelf.map((entry) => (
<div class="card">
<h3>{entry.data.title}</h3>
<p class="meta">{entry.data.authors.join(", ")}</p>
</div>
))
)
}
<a class="button" href="/bookshelf/">Full shelf →</a>
</section>
</div>
</Base>
<style>
.columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
margin-top: var(--space-6);
}
.columns .card {
padding-block: var(--space-3);
}
.columns .button {
margin-top: var(--space-3);
}
@media (max-width: 40rem) {
.columns {
grid-template-columns: 1fr;
}
}
</style>
+2 -2
View File
@@ -5,11 +5,11 @@
*/
export const siteConfig = {
title: "Thoughts and Musings",
description: "Computer science, mathematics, life, and beliefs.",
description: "Computer science and mathematics.",
baseUrl: "https://andwati.com",
author: "andwati",
llmsDescription:
"Ian Andwati's personal digital-legacy site covering computer science, mathematics, life, and beliefs — writings, portfolio, and a curated bookshelf.",
"Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.",
nav: [
{ name: "Writings", url: "/writings/" },
{ name: "Portfolio", url: "/portfolio/" },
+4
View File
@@ -99,6 +99,10 @@ pre {
padding-inline: var(--space-5);
}
.container--wide {
max-width: 64rem;
}
.eyebrow {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
+1 -1
View File
@@ -14,7 +14,7 @@ import { parse as parseToml } from "smol-toml";
const baseUrl = "https://andwati.com";
const siteTitle = "Thoughts and Musings";
const summary =
"Ian Andwati's personal digital-legacy site covering computer science, mathematics, life, and beliefs — writings, portfolio, and a curated bookshelf.";
"Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.";
// Resolved relative to this file, not process.cwd() — this script runs as
// part of apps/site's build script, whose cwd is apps/site, not the repo