|
1 | 1 | // Atlas graph renderer — runs inside the panel iframe |
2 | 2 | // Expects: window.__ATLAS_DATA__ = { nodes: [...], edges: [...] } |
3 | 3 | // window.__ATLAS_DARK__ = boolean |
| 4 | +// window.__ATLAS_OPTIONS__ = { showOrphans: boolean } |
4 | 5 | (function () { |
5 | 6 | "use strict"; |
6 | 7 |
|
7 | 8 | const data = window.__ATLAS_DATA__; |
8 | 9 | if (!data || !data.nodes.length) return; |
9 | 10 |
|
| 11 | + const options = window.__ATLAS_OPTIONS__ || { showOrphans: false }; |
| 12 | + |
10 | 13 | const container = document.getElementById("atlas-container"); |
11 | 14 | if (!container) return; |
12 | 15 |
|
13 | | - // --- Color palette (reads CSS custom properties from atlas-style.css) --- |
14 | | - // SB's panel.tsx already sets data-theme on <html> via postMessage before our script runs. |
15 | | - // Fall back to __ATLAS_DARK__ (from getUiOption) or prefers-color-scheme if somehow missing. |
| 16 | + // --- Theme detection --- |
16 | 17 | const sbTheme = document.documentElement.getAttribute("data-theme"); |
17 | 18 | const isDark = sbTheme |
18 | 19 | ? sbTheme === "dark" |
|
29 | 30 | bg: v("--atlas-bg"), |
30 | 31 | currentNode: v("--atlas-node-current"), |
31 | 32 | neighborNode: v("--atlas-node-neighbor"), |
| 33 | + orphanNode: v("--atlas-node-orphan"), |
32 | 34 | edge: v("--atlas-edge"), |
33 | 35 | edgeHighlight: v("--atlas-edge-highlight"), |
34 | 36 | label: v("--atlas-label"), |
|
38 | 40 | dimLabel: v("--atlas-label-dim"), |
39 | 41 | }; |
40 | 42 |
|
| 43 | + // --- Toolbar --- |
| 44 | + const toolbar = document.getElementById("atlas-toolbar"); |
| 45 | + if (toolbar) { |
| 46 | + const orphanBtn = document.createElement("button"); |
| 47 | + orphanBtn.className = "atlas-btn" + (options.showOrphans ? " active" : ""); |
| 48 | + orphanBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"> |
| 49 | + <circle cx="8" cy="8" r="6" stroke-dasharray="3,2"/> |
| 50 | + </svg><span>Orphans</span>`; |
| 51 | + orphanBtn.addEventListener("click", () => { |
| 52 | + options.showOrphans = !options.showOrphans; |
| 53 | + orphanBtn.classList.toggle("active", options.showOrphans); |
| 54 | + toggleOrphanVisibility(options.showOrphans); |
| 55 | + // Fire-and-forget persist to worker |
| 56 | + syscall("system.invokeFunction", "atlas.setOption", "showOrphans", options.showOrphans); |
| 57 | + }); |
| 58 | + toolbar.appendChild(orphanBtn); |
| 59 | + } |
| 60 | + |
41 | 61 | // --- Dimensions --- |
42 | 62 | const width = container.clientWidth || 300; |
43 | 63 | const height = container.clientHeight || 400; |
|
118 | 138 | .selectAll("g") |
119 | 139 | .data(data.nodes) |
120 | 140 | .join("g") |
121 | | - .attr("class", "node-group") |
| 141 | + .attr("class", (d) => "node-group" + (d.isOrphan ? " orphan" : "")) |
122 | 142 | .style("cursor", "pointer"); |
123 | 143 |
|
124 | | - // Circles — radius scales with connection count |
| 144 | + // Circles — radius scales with connection count; orphans are smaller |
125 | 145 | node |
126 | 146 | .append("circle") |
127 | 147 | .attr("r", (d) => { |
| 148 | + if (d.isOrphan) return 3; |
128 | 149 | const deg = degree.get(d.id) || 0; |
129 | 150 | const base = d.isCurrent ? 7 : 4; |
130 | 151 | return base + Math.min(deg, 10) * 0.4; |
131 | 152 | }) |
132 | | - .attr("fill", (d) => (d.isCurrent ? palette.currentNode : palette.neighborNode)); |
| 153 | + .attr("fill", (d) => { |
| 154 | + if (d.isOrphan) return palette.orphanNode; |
| 155 | + return d.isCurrent ? palette.currentNode : palette.neighborNode; |
| 156 | + }) |
| 157 | + .attr("fill-opacity", (d) => d.isOrphan ? 0.4 : 1) |
| 158 | + .attr("stroke", (d) => d.isOrphan ? palette.orphanNode : "none") |
| 159 | + .attr("stroke-width", (d) => d.isOrphan ? 1.5 : 0) |
| 160 | + .attr("stroke-dasharray", (d) => d.isOrphan ? "2,2" : "none"); |
133 | 161 |
|
134 | 162 | // Labels — always outside the circle |
135 | 163 | node |
|
138 | 166 | .attr("dx", 10) |
139 | 167 | .attr("dy", 4) |
140 | 168 | .attr("text-anchor", "start") |
141 | | - .attr("fill", (d) => (d.isCurrent ? palette.currentNode : palette.label)) |
| 169 | + .attr("fill", (d) => { |
| 170 | + if (d.isOrphan) return palette.orphanNode; |
| 171 | + return d.isCurrent ? palette.currentNode : palette.label; |
| 172 | + }) |
| 173 | + .attr("fill-opacity", (d) => d.isOrphan ? 0.6 : 1) |
142 | 174 | .attr("font-size", "10px") |
143 | 175 | .attr("font-weight", (d) => (d.isCurrent ? "600" : "400")) |
144 | 176 | .attr("font-family", "system-ui, -apple-system, sans-serif") |
145 | 177 | .attr("paint-order", "stroke") |
146 | 178 | .attr("stroke", palette.bg) |
147 | 179 | .attr("stroke-width", 3); |
148 | 180 |
|
| 181 | + // --- Orphan visibility --- |
| 182 | + function toggleOrphanVisibility(show) { |
| 183 | + const orphans = node.filter((d) => d.isOrphan); |
| 184 | + orphans |
| 185 | + .transition() |
| 186 | + .duration(300) |
| 187 | + .style("opacity", show ? 1 : 0) |
| 188 | + .on("end", function () { |
| 189 | + d3.select(this).style("pointer-events", show ? "auto" : "none"); |
| 190 | + }); |
| 191 | + // Reheat simulation so orphans settle naturally when shown |
| 192 | + if (show) { |
| 193 | + simulation.alpha(0.3).restart(); |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + // Apply initial orphan visibility |
| 198 | + toggleOrphanVisibility(options.showOrphans); |
| 199 | + |
149 | 200 | // --- Drag behavior --- |
150 | 201 | const drag = d3 |
151 | 202 | .drag() |
|
177 | 228 | const neighbors = adjacency.get(d.id) || new Set(); |
178 | 229 |
|
179 | 230 | node.select("circle").attr("fill", (n) => { |
180 | | - if (n.id === d.id) return palette.currentNode; |
| 231 | + if (n.id === d.id) return n.isOrphan ? palette.orphanNode : palette.currentNode; |
181 | 232 | if (neighbors.has(n.id)) { |
182 | 233 | return n.isCurrent ? palette.currentNode : palette.neighborNode; |
183 | 234 | } |
184 | 235 | return palette.dimNode; |
185 | 236 | }); |
186 | 237 |
|
187 | 238 | node.select("text").attr("fill", (n) => { |
188 | | - if (n.id === d.id) return n.isCurrent ? palette.labelCurrent : palette.label; |
| 239 | + if (n.id === d.id) return n.isCurrent ? palette.labelCurrent : (n.isOrphan ? palette.orphanNode : palette.label); |
189 | 240 | if (neighbors.has(n.id)) { |
190 | 241 | return n.isCurrent ? palette.labelCurrent : palette.label; |
191 | 242 | } |
|
209 | 260 | .on("mouseleave", () => { |
210 | 261 | node |
211 | 262 | .select("circle") |
212 | | - .attr("fill", (n) => |
213 | | - n.isCurrent ? palette.currentNode : palette.neighborNode |
214 | | - ); |
| 263 | + .attr("fill", (n) => { |
| 264 | + if (n.isOrphan) return palette.orphanNode; |
| 265 | + return n.isCurrent ? palette.currentNode : palette.neighborNode; |
| 266 | + }); |
215 | 267 |
|
216 | 268 | node |
217 | 269 | .select("text") |
218 | | - .attr("fill", (n) => |
219 | | - n.isCurrent ? palette.labelCurrent : palette.label |
220 | | - ); |
| 270 | + .attr("fill", (n) => { |
| 271 | + if (n.isOrphan) return palette.orphanNode; |
| 272 | + return n.isCurrent ? palette.labelCurrent : palette.label; |
| 273 | + }); |
221 | 274 |
|
222 | 275 | link.attr("stroke", palette.edge).attr("stroke-width", 1.5); |
223 | 276 | }); |
|
0 commit comments