Skip to content

Commit be4504b

Browse files
seanhancaseanhancaclaude
authored
site(math): particles.html — best Glyph can do for particle fields (#87)
## Summary A dedicated page showing the best Glyph can do for particle-field visualizations — the hardest visualization paradigm for static SVG. Includes both static and animated versions, plus a 4-step recipe. ### Scenes - **`particle-static`** — 144 RK4-integrated trajectories through an Arnold-Beltrami-Childress-flavored vector field, colored by local flow angle - **`particle-animated`** — same field + SMIL `rotate-loop` (30s/revolution) ### Page `site/math/particles.html` — intentionally minimal styling so the SVG output is the focus. 4-step recipe + full copy-paste-ready JSON spec. ## Test plan - [x] Snapshots byte-locked - [x] All tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: seanhanca <infraservice@livepeer.org> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 09c1514 commit be4504b

9 files changed

Lines changed: 750 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
{
2+
"compose": {
3+
"viewBox": {
4+
"width": 900,
5+
"height": 900
6+
},
7+
"title": "Particle field — animated · the whole field rotates",
8+
"description": "144 RK4-integrated trajectories through an ABC-flavored vector field, rotated by a SMIL rotate-loop with periodMs=30000 (one slow revolution every 30 seconds).",
9+
"theme": {
10+
"background": "#020617",
11+
"foreground": "#fef3c7"
12+
},
13+
"defs": {
14+
"gradients": [
15+
{
16+
"id": "g-bg",
17+
"kind": "radial",
18+
"cx": "50%",
19+
"cy": "50%",
20+
"r": "80%",
21+
"stops": [
22+
{
23+
"offset": "0%",
24+
"color": "#1e1b4b",
25+
"opacity": 1
26+
},
27+
{
28+
"offset": "100%",
29+
"color": "#020617",
30+
"opacity": 1
31+
}
32+
]
33+
}
34+
]
35+
},
36+
"children": [
37+
{
38+
"at": {
39+
"x": 0,
40+
"y": 0
41+
},
42+
"mark": "silhouette-path",
43+
"silhouettePath": {
44+
"d": "M 0 0 L 900 0 L 900 900 L 0 900 Z",
45+
"fill": "url(#g-bg)",
46+
"stroke": "none"
47+
}
48+
},
49+
{
50+
"at": {
51+
"x": 0,
52+
"y": 0
53+
},
54+
"mark": "starfield",
55+
"starfield": {
56+
"count": 60,
57+
"seed": 13,
58+
"region": {
59+
"x": 0,
60+
"y": 0,
61+
"w": 900,
62+
"h": 900
63+
}
64+
}
65+
},
66+
{
67+
"at": {
68+
"x": 50,
69+
"y": 50
70+
},
71+
"size": {
72+
"w": 800,
73+
"h": 800
74+
},
75+
"mark": "chart",
76+
"chart": {
77+
"version": "glyph/0.1",
78+
"title": "Streamline field · 12×12 seeds · colorBy: angle",
79+
"data": {
80+
"source": "inline:particle-field"
81+
},
82+
"layers": [
83+
{
84+
"mark": "streamline",
85+
"encoding": {
86+
"x": {
87+
"field": "x",
88+
"type": "quantitative",
89+
"scale": {
90+
"domain": [
91+
-3.141592653589793,
92+
3.141592653589793
93+
]
94+
}
95+
},
96+
"y": {
97+
"field": "y",
98+
"type": "quantitative",
99+
"scale": {
100+
"domain": [
101+
-3.141592653589793,
102+
3.141592653589793
103+
]
104+
}
105+
}
106+
},
107+
"streamline": {
108+
"dxdt": "sin(2*y) + 0.4*cos(x)",
109+
"dydt": "-sin(2*x) + 0.4*sin(y)",
110+
"seeds": {
111+
"kind": "grid",
112+
"rows": 12,
113+
"cols": 12
114+
},
115+
"step": 0.04,
116+
"maxSteps": 100,
117+
"domain": {
118+
"x": [
119+
-3.141592653589793,
120+
3.141592653589793
121+
],
122+
"y": [
123+
-3.141592653589793,
124+
3.141592653589793
125+
]
126+
},
127+
"colorBy": "angle"
128+
}
129+
}
130+
]
131+
},
132+
"animation": {
133+
"kind": "rotate-loop",
134+
"periodMs": 30000,
135+
"direction": "ccw"
136+
}
137+
},
138+
{
139+
"at": {
140+
"x": 450,
141+
"y": 870
142+
},
143+
"mark": "text",
144+
"textMark": {
145+
"text": "Particle field · animated · rotate-loop 30s",
146+
"fontSize": 13,
147+
"fill": "#94a3b8",
148+
"italic": true,
149+
"anchor": "middle"
150+
}
151+
}
152+
]
153+
}
154+
}

packages/core/__fixtures__/compose/particle-animated.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Particle showcase — the best Glyph can do for "particle field"
3+
* visualizations. Two variants (static + animated) share the same
4+
* vector-field spec; the animated one carries a SMIL rotate-loop.
5+
*/
6+
import { readFileSync } from "node:fs";
7+
import { fileURLToPath } from "node:url";
8+
import { describe, expect, it } from "vitest";
9+
import { compileCompose } from "../../src/compiler/compose.js";
10+
import { renderSvg } from "../../src/render/svg.js";
11+
import { parseComposeSpec } from "../../src/spec/compose-schema.js";
12+
13+
interface Scene {
14+
label: string;
15+
file: string;
16+
expects: string[];
17+
}
18+
19+
const scenes: Scene[] = [
20+
{
21+
label: "particle showcase · static",
22+
file: "./particle-static.json",
23+
expects: ["Particle field", "static"],
24+
},
25+
{
26+
label: "particle showcase · animated",
27+
file: "./particle-animated.json",
28+
expects: [
29+
"Particle field",
30+
"animated",
31+
'<animateTransform attributeName="transform" type="rotate"',
32+
],
33+
},
34+
];
35+
36+
describe("compose — particle showcase (static + animated)", () => {
37+
for (const scene of scenes) {
38+
it(`renders ${scene.label} deterministically`, async () => {
39+
const url = new URL(scene.file, import.meta.url);
40+
const raw = JSON.parse(readFileSync(fileURLToPath(url), "utf8"));
41+
const spec = parseComposeSpec(raw);
42+
const svg = renderSvg(compileCompose(spec));
43+
const svg2 = renderSvg(compileCompose(parseComposeSpec(raw)));
44+
expect(svg2).toBe(svg);
45+
for (const needle of scene.expects) {
46+
expect(svg, `expected to contain: ${needle}`).toContain(needle);
47+
}
48+
const snapshotPath = scene.file.replace(/\.json$/, ".svg");
49+
await expect(svg).toMatchFileSnapshot(snapshotPath);
50+
});
51+
}
52+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
{
2+
"compose": {
3+
"viewBox": {
4+
"width": 900,
5+
"height": 900
6+
},
7+
"title": "Particle field — static · best of streamline mode",
8+
"description": "144 RK4-integrated trajectories through an ABC-flavored vector field. Each path is colored by the local flow angle. Static SVG — byte-identical across CI.",
9+
"theme": {
10+
"background": "#020617",
11+
"foreground": "#fef3c7"
12+
},
13+
"defs": {
14+
"gradients": [
15+
{
16+
"id": "g-bg",
17+
"kind": "radial",
18+
"cx": "50%",
19+
"cy": "50%",
20+
"r": "80%",
21+
"stops": [
22+
{
23+
"offset": "0%",
24+
"color": "#1e1b4b",
25+
"opacity": 1
26+
},
27+
{
28+
"offset": "100%",
29+
"color": "#020617",
30+
"opacity": 1
31+
}
32+
]
33+
}
34+
]
35+
},
36+
"children": [
37+
{
38+
"at": {
39+
"x": 0,
40+
"y": 0
41+
},
42+
"mark": "silhouette-path",
43+
"silhouettePath": {
44+
"d": "M 0 0 L 900 0 L 900 900 L 0 900 Z",
45+
"fill": "url(#g-bg)",
46+
"stroke": "none"
47+
}
48+
},
49+
{
50+
"at": {
51+
"x": 0,
52+
"y": 0
53+
},
54+
"mark": "starfield",
55+
"starfield": {
56+
"count": 60,
57+
"seed": 13,
58+
"region": {
59+
"x": 0,
60+
"y": 0,
61+
"w": 900,
62+
"h": 900
63+
}
64+
}
65+
},
66+
{
67+
"at": {
68+
"x": 50,
69+
"y": 50
70+
},
71+
"size": {
72+
"w": 800,
73+
"h": 800
74+
},
75+
"mark": "chart",
76+
"chart": {
77+
"version": "glyph/0.1",
78+
"title": "Streamline field · 12×12 seeds · colorBy: angle",
79+
"data": {
80+
"source": "inline:particle-field"
81+
},
82+
"layers": [
83+
{
84+
"mark": "streamline",
85+
"encoding": {
86+
"x": {
87+
"field": "x",
88+
"type": "quantitative",
89+
"scale": {
90+
"domain": [
91+
-3.141592653589793,
92+
3.141592653589793
93+
]
94+
}
95+
},
96+
"y": {
97+
"field": "y",
98+
"type": "quantitative",
99+
"scale": {
100+
"domain": [
101+
-3.141592653589793,
102+
3.141592653589793
103+
]
104+
}
105+
}
106+
},
107+
"streamline": {
108+
"dxdt": "sin(2*y) + 0.4*cos(x)",
109+
"dydt": "-sin(2*x) + 0.4*sin(y)",
110+
"seeds": {
111+
"kind": "grid",
112+
"rows": 12,
113+
"cols": 12
114+
},
115+
"step": 0.04,
116+
"maxSteps": 100,
117+
"domain": {
118+
"x": [
119+
-3.141592653589793,
120+
3.141592653589793
121+
],
122+
"y": [
123+
-3.141592653589793,
124+
3.141592653589793
125+
]
126+
},
127+
"colorBy": "angle"
128+
}
129+
}
130+
]
131+
}
132+
},
133+
{
134+
"at": {
135+
"x": 450,
136+
"y": 870
137+
},
138+
"mark": "text",
139+
"textMark": {
140+
"text": "Particle field · static · 144 trajectories · colorBy: angle",
141+
"fontSize": 13,
142+
"fill": "#94a3b8",
143+
"italic": true,
144+
"anchor": "middle"
145+
}
146+
}
147+
]
148+
}
149+
}

packages/core/__fixtures__/compose/particle-static.svg

Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)