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 >
0 commit comments