Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 100 additions & 5 deletions app/styles/toc.css
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,8 @@
Shared sidebar positioning for new variants (staffline + vim)
────────────────────────────────────────────────────────────── */
.toc-variant-staffline,
.toc-variant-vim {
.toc-variant-vim,
.toc-variant-constellation {
position: fixed;
left: 20px;
top: 120px;
Expand All @@ -309,13 +310,15 @@
}

.toc-variant-staffline.mounted,
.toc-variant-vim.mounted {
.toc-variant-vim.mounted,
.toc-variant-constellation.mounted {
animation: tocFadeIn 0.7s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
}

@media (min-width: 1024px) {
.toc-variant-staffline,
.toc-variant-vim {
.toc-variant-vim,
.toc-variant-constellation {
display: flex;
flex-direction: column;
width: 17ch;
Expand All @@ -324,13 +327,15 @@

@media (min-width: 1280px) {
.toc-variant-staffline,
.toc-variant-vim {
.toc-variant-vim,
.toc-variant-constellation {
width: 25ch;
}
}

.toc-variant-staffline .toc-title,
.toc-variant-vim .toc-title {
.toc-variant-vim .toc-title,
.toc-variant-constellation .toc-title {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.16em;
Expand Down Expand Up @@ -544,3 +549,93 @@
color: var(--amber);
font-weight: 400;
}

/* ──────────────────────────────────────────────────────────────
Constellation variant — star map nav
────────────────────────────────────────────────────────────── */
.toc-variant-constellation {
font-size: 11.5px;
position: fixed;
}

.toc-variant-constellation .map {
position: relative;
height: 240px;
width: 100%;
}

.toc-variant-constellation svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}

.toc-variant-constellation ul {
position: absolute;
inset: 0;
list-style: none;
margin: 0;
padding: 0;
}

.toc-variant-constellation .node {
position: absolute;
width: 0;
height: 0;
}

.toc-variant-constellation .node > a {
position: absolute;
top: 0;
left: 0;
cursor: pointer;
text-decoration: none;
border: none;
color: inherit;
}

.toc-variant-constellation .node .dot {
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 6px;
border-radius: 50%;
transform: translate(-50%, -50%);
background: var(--fg-light);
opacity: 0.5;
transition: all 0.3s;
}

.toc-variant-constellation .node .lbl {
position: absolute;
top: 6px;
left: 6px;
font-family: var(--font-serif);
font-size: 12px;
color: var(--fg-light);
opacity: 0.55;
white-space: nowrap;
transition: opacity 0.2s, color 0.2s;
}

.toc-variant-constellation .node > a:hover .dot,
.toc-variant-constellation .node > a:focus-visible .dot,
.toc-variant-constellation .node.active .dot {
opacity: 1;
background: var(--amber);
box-shadow: 0 0 12px 2px rgba(186, 149, 94, 0.25);
}

.toc-variant-constellation .node > a:hover .lbl,
.toc-variant-constellation .node > a:focus-visible .lbl,
.toc-variant-constellation .node.active .lbl {
opacity: 1;
color: var(--fg);
}

.toc-variant-constellation .node.active .lbl {
color: var(--amber);
}
2 changes: 1 addition & 1 deletion app/writing/exploring-a-recurrent-neural-network/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readingTime: '14 min'
tags: ['Machine Learning', 'RNNs']
---

<TOC title="Exploring a Recurrent Neural Network" variant="vim" />
<TOC title="Exploring a Recurrent Neural Network" variant="constellation" />

These notes walk through Andrej Karpathy's minimal character-level **Recurrent Neural Network (RNN)** implementation. [^1]

Expand Down
128 changes: 124 additions & 4 deletions components/ui/toc.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
'use client'
import GithubSlugger from 'github-slugger'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'

import { BLOG_POSTS } from '@/app/data'
import '@/app/styles/toc.css'
import { layoutStars } from '@/lib/toc-starmap'

interface TocItem {
level: number
text: string
slug: string
}

export type TocVariant = 'classic' | 'staffline' | 'vim'
export type TocVariant = 'classic' | 'staffline' | 'vim' | 'constellation'

const CONSTELLATION_MAX_NODES = 12

const variantByTag: Record<string, TocVariant> = {
jazz: 'staffline',
Expand Down Expand Up @@ -47,11 +50,20 @@ export function TableOfContents({
const [minLevel, setMinLevel] = useState(1)
const [activeId, setActiveId] = useState<string>('')
const [scrollProgress, setScrollProgress] = useState(0)
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)

const post = BLOG_POSTS.find((p) => p.link === pathname)
const resolvedVariant: TocVariant =
const requestedVariant: TocVariant =
variant ?? resolveVariantFromTags(post?.tags ?? [])

const shouldFallbackFromConstellation =
requestedVariant === 'constellation' &&
(headings.length > CONSTELLATION_MAX_NODES || prefersReducedMotion)

const resolvedVariant: TocVariant = shouldFallbackFromConstellation
? 'staffline'
: requestedVariant

const totalMinutes = post?.readingTime
? parseInt(post.readingTime, 10) || 0
: 0
Expand Down Expand Up @@ -119,6 +131,16 @@ export function TableOfContents({
return () => observer.disconnect()
}, [headings])

// Detect prefers-reduced-motion so constellation can fall back
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = () => setPrefersReducedMotion(mq.matches)
update()
mq.addEventListener('change', update)
return () => mq.removeEventListener('change', update)
}, [])

// Track scroll progress for the vim status bar
useEffect(() => {
if (resolvedVariant !== 'vim') return
Expand Down Expand Up @@ -195,7 +217,22 @@ export function TableOfContents({
? ':TOC'
: resolvedVariant === 'staffline'
? '§ contents'
: title
: resolvedVariant === 'constellation'
? 'index'
: title

if (resolvedVariant === 'constellation') {
return (
<ConstellationToc
headings={headings}
activeId={activeId}
activeIndex={activeIndex}
title={variantTitle}
seed={pathname ?? 'default'}
onNav={scrollToHeading}
/>
)
}

return (
<nav
Expand Down Expand Up @@ -227,3 +264,86 @@ export function TableOfContents({
</nav>
)
}

function ConstellationToc({
headings,
activeId,
activeIndex,
title,
seed,
onNav,
}: {
headings: TocItem[]
activeId: string
activeIndex: number
title?: string
seed: string
onNav: (slug: string) => void
}) {
const points = useMemo(
() => layoutStars(headings.length, seed),
[headings.length, seed],
)

return (
<nav
className="table-of-contents toc-variant-constellation toc-always-on mounted ml-6"
data-next-scroll-boundary
>
{title && (
<div className="flex items-center pt-2">
<span className="toc-title mb-2 text-sm font-bold">{title}</span>
</div>
)}
<div className="map">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
{points.slice(0, -1).map((p, i) => {
const next = points[i + 1]
const isActiveEdge = i === activeIndex
return (
<line
key={i}
x1={p.x}
y1={p.y}
x2={next.x}
y2={next.y}
stroke="var(--amber)"
strokeOpacity={isActiveEdge ? 0.5 : 0.2}
strokeWidth={0.4}
vectorEffect="non-scaling-stroke"
/>
)
})}
</svg>
<ul>
{headings.map((heading, i) => {
const p = points[i]
const isActive = activeId === heading.slug
return (
<li
key={heading.slug}
className={`node${isActive ? ' active' : ''}`}
style={{ left: `${p.x}%`, top: `${p.y}%` }}
>
<a
href={`#${heading.slug}`}
onClick={(e) => {
e.preventDefault()
onNav(heading.slug)
}}
>
<span className="dot" aria-hidden="true" />
<span className="lbl">{heading.text}</span>
</a>
</li>
)
})}
</ul>
</div>
</nav>
)
}
46 changes: 46 additions & 0 deletions lib/toc-starmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export interface StarPoint {
x: number
y: number
}

function hashStr(s: string): number {
let h = 2166136261
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}

function mulberry32(seed: number): () => number {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}

function dist(a: StarPoint, b: StarPoint): number {
const dx = a.x - b.x
const dy = a.y - b.y
return Math.sqrt(dx * dx + dy * dy)
}

export function layoutStars(count: number, seed: string): StarPoint[] {
const rng = mulberry32(hashStr(seed))
const pts: StarPoint[] = []
for (let i = 0; i < count; i++) {
let p: StarPoint = { x: 0, y: 0 }
let tries = 0
do {
p = { x: 5 + rng() * 90, y: 5 + rng() * 90 }
tries++
} while (pts.some((q) => dist(p, q) < 18) && tries < 30)
pts.push(p)
}
return pts
}