mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 09:22:53 +00:00
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:
@@ -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,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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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/" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user