Skip to content

Commit d763a31

Browse files
committed
graph.html
1 parent 7795080 commit d763a31

File tree

3 files changed

+342
-1
lines changed

3 files changed

+342
-1
lines changed

2025/Day11/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ Visit the website for the full story and [full puzzle](https://adventofcode.com/
1010
We got an easy dynamic programming exercise for today, a refresher after the last two days of madness. Here is my input
1111
rendered to an image.
1212

13-
![graph.png](graph.png)
13+
![graph.png](graph.png)
14+
15+
I created a small [renderer](graph.html) for this. (Well, ChatGPT created the render, with my input.)

2025/Day11/graph.html

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
<!doctype html>
2+
<html>
3+
4+
<head>
5+
<meta charset="utf-8" />
6+
<title>Force Graph from Text</title>
7+
<style>
8+
html,
9+
body {
10+
margin: 0;
11+
height: 100%;
12+
background: #000;
13+
overflow: hidden;
14+
font-family: system-ui, sans-serif;
15+
}
16+
17+
#wrap {
18+
display: grid;
19+
grid-template-columns: 360px 1fr;
20+
height: 100%;
21+
}
22+
23+
#sidebar {
24+
background: rgba(0, 0, 0, 0.65);
25+
border-right: 1px solid rgba(255, 255, 255, 0.08);
26+
padding: 12px;
27+
color: #ddd;
28+
overflow: auto;
29+
}
30+
31+
#graph {
32+
width: 100%;
33+
height: 100%;
34+
}
35+
36+
textarea {
37+
width: 100%;
38+
height: 52vh;
39+
resize: vertical;
40+
background: rgba(20, 20, 20, 0.9);
41+
color: #eee;
42+
border: 1px solid rgba(255, 255, 255, 0.12);
43+
border-radius: 10px;
44+
padding: 10px;
45+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
46+
font-size: 12px;
47+
line-height: 1.4;
48+
outline: none;
49+
}
50+
51+
.row {
52+
display: flex;
53+
gap: 8px;
54+
margin-top: 10px;
55+
flex-wrap: wrap;
56+
align-items: center;
57+
}
58+
59+
input[type="text"] {
60+
width: 100%;
61+
background: rgba(20, 20, 20, 0.9);
62+
color: #eee;
63+
border: 1px solid rgba(255, 255, 255, 0.12);
64+
border-radius: 10px;
65+
padding: 8px 10px;
66+
outline: none;
67+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
68+
font-size: 12px;
69+
}
70+
71+
button {
72+
background: rgba(255, 255, 255, 0.10);
73+
color: #eee;
74+
border: 1px solid rgba(255, 255, 255, 0.14);
75+
border-radius: 10px;
76+
padding: 8px 10px;
77+
cursor: pointer;
78+
}
79+
80+
button:hover {
81+
background: rgba(255, 255, 255, 0.16);
82+
}
83+
84+
.hint {
85+
color: #aaa;
86+
font-size: 12px;
87+
margin-top: 10px;
88+
}
89+
90+
.err {
91+
color: #ff8080;
92+
font-size: 12px;
93+
white-space: pre-wrap;
94+
margin-top: 10px;
95+
}
96+
97+
label {
98+
font-size: 12px;
99+
color: #bbb;
100+
display: block;
101+
margin-top: 10px;
102+
margin-bottom: 6px;
103+
}
104+
</style>
105+
</head>
106+
107+
<body>
108+
<div id="wrap">
109+
<div id="sidebar">
110+
<div style="font-weight: 600; margin-bottom: 8px;">Graph input</div>
111+
112+
<textarea id="input">
113+
svr: aaa bbb
114+
aaa: fft
115+
fft: ccc
116+
bbb: tty
117+
tty: ccc
118+
ccc: ddd eee
119+
ddd: hub
120+
hub: fff
121+
eee: dac
122+
dac: fff
123+
fff: ggg hhh
124+
ggg: out
125+
hhh: out
126+
</textarea>
127+
128+
<div class="row">
129+
<button id="btnRender">Render</button>
130+
<button id="btnFit">Fit</button>
131+
</div>
132+
133+
<label for="root">Root for flow highlight</label>
134+
<input id="root" type="text" value="svr" />
135+
136+
<label for="marked">Marked nodes (space-separated)</label>
137+
<input id="marked" type="text" value="svr fft dac out" />
138+
139+
<div class="hint">
140+
Format: <code>node: neighbor1 neighbor2 ...</code><br />
141+
Lines starting with <code>#</code> are ignored.
142+
</div>
143+
144+
<div id="err" class="err"></div>
145+
</div>
146+
147+
<div id="graph"></div>
148+
</div>
149+
150+
<script type="module">
151+
import ForceGraph3D from "https://esm.sh/[email protected]";
152+
import SpriteText from "https://esm.sh/[email protected]";
153+
154+
const el = document.getElementById("graph");
155+
const ta = document.getElementById("input");
156+
const errEl = document.getElementById("err");
157+
const markedEl = document.getElementById("marked");
158+
const rootEl = document.getElementById("root"); // add <input id="root" ...>
159+
const btnRender = document.getElementById("btnRender");
160+
const btnFit = document.getElementById("btnFit");
161+
162+
163+
function computeBig(nodeCount) {
164+
const K = 25000; // tune this once, then forget it
165+
const min = 10; // never smaller than this
166+
const max = 8000; // never larger than this
167+
return Math.max(min, Math.min(max, K / Math.sqrt(nodeCount)));
168+
}
169+
170+
function getMarkedSet() {
171+
return new Set(markedEl.value.split(/\s+/).map(s => s.trim()).filter(Boolean));
172+
}
173+
174+
function parseAdjacency(text) {
175+
const adjacency = new Map(); // src -> [dst...]
176+
const nodes = new Set();
177+
178+
const lines = text.split(/\r?\n/);
179+
for (let i = 0; i < lines.length; i++) {
180+
const line = lines[i].trim();
181+
if (line === "" || line.startsWith("#")) continue;
182+
183+
const idx = line.indexOf(":");
184+
if (idx === -1) throw new Error(`Line ${i + 1}: missing ":"`);
185+
186+
const src = line.slice(0, idx).trim();
187+
if (src === "") throw new Error(`Line ${i + 1}: empty source node`);
188+
189+
const dsts = line.slice(idx + 1).trim().split(/\s+/).filter(Boolean);
190+
191+
if (!adjacency.has(src)) adjacency.set(src, []);
192+
adjacency.get(src).push(...dsts);
193+
194+
nodes.add(src);
195+
for (const d of dsts) nodes.add(d);
196+
}
197+
198+
return { adjacency, nodes };
199+
}
200+
201+
function buildData(parsed) {
202+
const nodes = Array.from(parsed.nodes).sort().map(id => ({ id }));
203+
const links = [];
204+
205+
for (const [src, dsts] of parsed.adjacency.entries()) {
206+
for (const dst of dsts) {
207+
links.push({ source: src, target: dst, highlight: false });
208+
}
209+
}
210+
211+
return { nodes, links, adjacency: parsed.adjacency };
212+
}
213+
214+
function reachableFrom(adjacency, root) {
215+
const seen = new Set();
216+
if (!root || !adjacency) return seen;
217+
218+
const q = [];
219+
seen.add(root);
220+
q.push(root);
221+
222+
while (q.length > 0) {
223+
const cur = q.shift();
224+
const nxts = adjacency.get(cur) || [];
225+
for (const n of nxts) {
226+
if (!seen.has(n)) {
227+
seen.add(n);
228+
q.push(n);
229+
}
230+
}
231+
}
232+
return seen;
233+
}
234+
235+
function applyHighlights(data, adjacency, root) {
236+
const reach = reachableFrom(adjacency, root);
237+
238+
// highlight links where both ends are reachable (simple + looks good)
239+
for (const l of data.links) {
240+
const s = (typeof l.source === "object") ? l.source.id : l.source;
241+
const t = (typeof l.target === "object") ? l.target.id : l.target;
242+
l.highlight = reach.has(s) && reach.has(t);
243+
}
244+
}
245+
246+
let marked = getMarkedSet();
247+
let data = { nodes: [], links: [] };
248+
let adjacency = new Map();
249+
250+
251+
const SMALL = 1.2;
252+
const BIG = computeBig(data.nodes.length);
253+
254+
// IMPORTANT: avoid TDZ by declaring Graph first, then assigning
255+
let Graph;
256+
Graph = ForceGraph3D()(el)
257+
.backgroundColor("#000")
258+
.graphData(data)
259+
260+
.nodeRelSize(1) // important: keep this at 1
261+
.nodeVal(n => marked.has(n.id) ? BIG : SMALL)
262+
.nodeColor(n => marked.has(n.id) ? "#ff9800" : "#ffeb3b")
263+
.nodeOpacity(1.0)
264+
.nodeLabel(n => marked.has(n.id) ? n.id : "")
265+
266+
.linkOpacity(0.18)
267+
.linkWidth(l => l.highlight ? 1.2 : 0.6)
268+
.linkColor(l => l.highlight ? "#49d7ff" : "rgba(180,180,180,0.35)")
269+
.linkDirectionalParticles(l => l.highlight ? 3 : 0)
270+
.linkDirectionalParticleWidth(l => l.highlight ? 2.5 : 0)
271+
.linkDirectionalParticleSpeed(l => l.highlight ? 0.01 : 0)
272+
.onEngineStop(() => {
273+
// combine your two handlers into one (so we don't overwrite)
274+
Graph.zoomToFit(600, 60);
275+
setTimeout(() => Graph.zoomToFit(800, 120), 50);
276+
});
277+
278+
Graph.nodeThreeObjectExtend(true);
279+
280+
Graph.nodeThreeObject(node => {
281+
if (!marked.has(node.id)) return undefined;
282+
283+
const label = new SpriteText(node.id);
284+
label.color = "#ffcc80";
285+
label.textHeight = BIG * 0.015;
286+
label.backgroundColor = "rgba(0,0,0,0)";
287+
288+
const margin = BIG * 0.015;
289+
label.position.set(0, margin, 0);
290+
291+
return label;
292+
});
293+
294+
function refreshStyles() {
295+
marked = getMarkedSet();
296+
Graph
297+
.nodeVal(n => marked.has(n.id) ? BIG : SMALL)
298+
.nodeColor(n => marked.has(n.id) ? "#ff9800" : "#ffeb3b")
299+
.nodeLabel(n => marked.has(n.id) ? n.id : "");
300+
301+
// force rebuild of custom node objects so labels update
302+
Graph.nodeThreeObject(Graph.nodeThreeObject());
303+
}
304+
305+
function renderFromTextarea() {
306+
errEl.textContent = "";
307+
308+
try {
309+
const parsed = parseAdjacency(ta.value);
310+
const built = buildData(parsed);
311+
312+
data = { nodes: built.nodes, links: built.links };
313+
adjacency = built.adjacency;
314+
315+
const rootId = (rootEl?.value || "").trim(); // e.g. "svr"
316+
applyHighlights(data, adjacency, rootId);
317+
318+
Graph.graphData(data);
319+
refreshStyles();
320+
321+
// extra fit safety
322+
setTimeout(() => Graph.zoomToFit(800, 120), 300);
323+
} catch (e) {
324+
errEl.textContent = String(e?.message ?? e);
325+
}
326+
}
327+
328+
btnRender.addEventListener("click", renderFromTextarea);
329+
btnFit.addEventListener("click", () => Graph.zoomToFit(800, 120));
330+
markedEl.addEventListener("change", () => { refreshStyles(); });
331+
rootEl?.addEventListener("change", renderFromTextarea);
332+
333+
renderFromTextarea();
334+
</script>
335+
336+
</body>
337+
338+
</html>

docs/build.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ function media(dir){
88
return files.filter(file =>
99
path.extname(file).toLowerCase() === '.gif' ||
1010
path.extname(file).toLowerCase() === '.png' ||
11+
path.extname(file).toLowerCase() === '.html' ||
1112
path.extname(file).toLowerCase() === '.py'
1213
).map(file => path.join(dir, file));
1314
}

0 commit comments

Comments
 (0)