forked from jflournoy/for-funsies
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgarden.ts
More file actions
167 lines (149 loc) · 6.45 KB
/
Copy pathgarden.ts
File metadata and controls
167 lines (149 loc) · 6.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
/**
* Generative garden for the for-funsies board.
*
* At build time the site produces a deterministic "constellation" that
* accumulates as the repository does: every commit adds a star, every build
* adds a growth ring, and the whole field is seeded from the repository's own
* history (commit hashes, authors, dates) plus the ledger's contributor list.
* Two consecutive builds are never identical because main only moves when a
* new commit lands — so the build count, the latest hash, and the star field
* all change together.
*
* This module is pure and dependency-free (node: builtins only). It is used
* by scripts/build_site.js at build time and is never shipped to the browser,
* so it holds no DOM references and poses no XSS surface.
*/
import { createHash } from "node:crypto";
export interface CommitInfo {
hash: string;
author: string;
date: string;
}
export interface GardenSnapshot {
/** Number of commits in the repository — the build counter. */
build: number;
/** Short hash of the most recent commit. */
latestHash: string;
/** ISO date of the most recent commit. */
latestDate: string;
/** De-duplicated contributor handles from the ledger. */
contributors: string[];
/** Commit history, newest first, capped for a bounded field. */
commits: CommitInfo[];
}
/** Deterministic 32-bit hash of a string (FNV-1a). */
function hash32(input: string): number {
let h = 2166136261;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/** Convert a 32-bit hash into a number in [0, 1). */
function unit(hashValue: number): number {
return (hashValue >>> 0) / 4294967296;
}
/** A small deterministic PRNG so sequences are reproducible per build. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
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;
};
}
/** A soft, deterministic accent palette tuned for the site's light/dark themes. */
function hueFor(author: string): number {
return Math.floor(unit(hash32(`author:${author}`)) * 360);
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Render the generative constellation as an inline SVG string.
*
* Layout is stable per commit: each commit's star sits at a position derived
* solely from its own hash, so as new commits land the existing stars stay put
* and the field *grows* rather than reshuffling. This gives the "accumulates
* over time" quality the brief asks for.
*/
export function renderGarden(snap: GardenSnapshot): string {
const W = 800;
const H = 360;
const CX = W / 2;
const CY = H / 2;
// Seed the ambient field from the latest commit so its density/ambient
// colours drift with every build.
const rng = mulberry32(hash32(`garden:${snap.latestHash}:${snap.build}`));
// Concentric growth rings: one per build, capped so the field stays readable.
const ringCount = Math.min(snap.build, 24);
let rings = "";
for (let r = 1; r <= ringCount; r++) {
const radius = 18 + r * 6.5;
const opacity = 0.05 + (r / ringCount) * 0.12;
rings += `<circle cx="${CX}" cy="${CY}" r="${radius.toFixed(1)}" fill="none" stroke="currentColor" stroke-opacity="${opacity.toFixed(3)}" stroke-width="1"/>`;
}
// One star per commit, position fixed by the commit's own hash.
const maxStars = Math.min(snap.commits.length, 160);
let stars = "";
for (let i = 0; i < maxStars; i++) {
const c = snap.commits[i];
if (!c) continue;
const angle = unit(hash32(`angle:${c.hash}`)) * Math.PI * 2;
const radius = 26 + unit(hash32(`radius:${c.hash}`)) * 120;
const x = CX + Math.cos(angle) * radius;
const y = CY + Math.sin(angle) * radius * 0.72;
const hue = hueFor(c.author);
const size = 1.3 + unit(hash32(`size:${c.hash}`)) * 2.4;
const age = snap.build > 0 ? i / Math.max(snap.build, 1) : 0.5;
// Connect every star back to the core so the constellation reads as a web.
stars += `<line x1="${CX}" y1="${CY}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="hsl(${hue} 70% 55%)" stroke-opacity="${(0.08 + age * 0.12).toFixed(3)}" stroke-width="0.8"/>`;
stars += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${size.toFixed(2)}" fill="hsl(${hue} 80% ${45 + age * 25}%)" fill-opacity="${(0.55 + age * 0.4).toFixed(3)}"/>`;
}
// Ambient sprinkle — deterministic but seeded from history, so it shifts.
let ambient = "";
for (let i = 0; i < 40; i++) {
const x = rng() * W;
const y = rng() * H;
const r = 0.4 + rng() * 0.9;
const o = 0.12 + rng() * 0.3;
ambient += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(2)}" fill="currentColor" fill-opacity="${o.toFixed(3)}"/>`;
}
// The core: colour and pulse derive from the latest commit.
const coreHue = hueFor(snap.latestHash);
const pulse = snap.build % 60;
// Caption — build count, repo age, contributor count. All real repo data.
const contributorCount = snap.contributors.length;
const latest = snap.latestDate.slice(0, 10);
const caption = `build ${snap.build} · ${snap.commits.length} commits · ${contributorCount} ${
contributorCount === 1 ? "contributor" : "contributors"
} · latest ${latest}`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="${escapeXml(caption)}">
<rect width="${W}" height="${H}" fill="none"/>
<text x="${CX}" y="${H - 14}" text-anchor="middle" font-family="ui-monospace,'SF Mono',Menlo,Consolas,monospace" font-size="11" fill="currentColor" fill-opacity="0.55">${escapeXml(caption)}</text>
${ambient}
${rings}
${stars}
<circle cx="${CX}" cy="${CY}" r="9" fill="hsl(${coreHue} 85% 55%)" fill-opacity="0.9"/>
<circle cx="${CX}" cy="${CY}" r="${(9 + (pulse % 5)).toFixed(1)}" fill="none" stroke="hsl(${coreHue} 85% 60%)" stroke-opacity="0.35" stroke-width="1.5"/>
</svg>`;
}
/** Build a snapshot from raw git + ledger inputs. Pure, no I/O. */
export function buildSnapshot(commits: CommitInfo[], contributors: string[]): GardenSnapshot {
const latest = commits[0];
return {
build: Math.max(commits.length, 1),
latestHash: latest?.hash ?? "none",
latestDate: latest?.date ?? "",
contributors,
commits,
};
}