How this site is built
I wanted a place for things that don’t fit into a README: longer write-ups of my research on distributed collision avoidance, and notes on the Rust and compiler projects I keep starting. This post covers how the site itself works.
Static, and mostly HTML
The site is built with Astro 7 and deployed to GitHub
Pages with the official withastro/action. Every page is plain HTML generated
at build time. Posts are Markdown (or MDX, when a post needs a component) files
in a content collection, with a small schema for the frontmatter:
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
}),
});
Drafts show up when I run the dev server and are left out of the production build, so half-finished posts can live in the repository without being published.
The dots on the home page
The animation on the home page is a small nod to my research. A ring of agents swaps to the opposite side of the circle, and every agent avoids the others on its own, without a central planner.
It’s a sampling-based version of reciprocal velocity obstacles. Each frame, every agent tries a few dozen random velocities and scores each one by how far it is from the velocity it would like to have and how soon it would hit someone. The core is a time-to-collision check between two discs:
function timeToCollision(d: Vec, u: Vec, rad: number) {
const dd = d.x * d.x + d.y * d.y - rad * rad;
if (dd <= 0) return 0;
const du = d.x * u.x + d.y * u.y;
const uu = u.x * u.x + u.y * u.y;
const disc = du * du - uu * dd;
if (du <= 0 || disc < 0) return Infinity;
return (du - Math.sqrt(disc)) / uu;
}
That’s solving for the smallest , where is the relative position, the relative velocity and the combined radius. “Reciprocal” means each agent assumes the other one takes half of the evasive action, so the candidate velocity enters as rather than .
It’s a few dozen lines of TypeScript and not the real thing. The proper algorithm, ORCA, solves a linear program instead of sampling. I have a Rust implementation from my bachelor thesis, and compiling it to WebAssembly for this page is on my list. If your system asks for reduced motion, the dots stay still.
Math without a math script
Since I’ll write about algorithms, I want formulas like
to render properly without loading a JavaScript library in the reader’s browser. KaTeX can render to HTML at build time, so that part was easy to decide.
What surprised me: Astro 7 switched its default Markdown processor to
Sätteri. It parses $…$ and $$…$$ just fine, but only into
<code class="math-inline"> elements, and the usual remark-math /
rehype-katex plugins only work with the old unified-based processor. Rather
than switch back, I wrote a small Sätteri plugin that hands those nodes to
KaTeX:
const renderMath = {
name: 'katex',
element: {
filter: ['code'],
visit(node, ctx) {
const cls = node.properties?.className ?? [];
if (!cls.includes('language-math')) return;
const displayMode = cls.includes('math-display');
const html = katex.renderToString(ctx.textContent(node), { displayMode, throwOnError: false });
ctx.replaceNode(displayMode ? ctx.parent(node) : node, { type: 'raw', value: html });
},
},
};
Code blocks are highlighted by Shiki at build time as well, with a light and a dark theme that follow your system setting.
No tracking
There are no cookies, no analytics and no third-party requests. Fonts are your system’s own, and KaTeX’s stylesheet and fonts are served from this domain. The only party that sees your visit is GitHub, because they host the files.
If I want to know whether anyone reads this, I’ll find out the old-fashioned way: someone sends me an email.
What’s next
Posts about my research (ORCA and noise, and later Gaussian Belief Propagation from my master thesis) and about the language projects I keep coming back to, like y-lang and the lexer generator lachs. There’s an RSS feed if you want to follow along.