11import { Controller } from "@hotwired/stimulus"
22import cytoscape from "cytoscape"
3- import cytoscapeDagre from "cytoscape-dagre"
4-
5- // Register the dagre layout extension
6- if ( typeof cytoscape . use === "function" ) {
7- cytoscape . use ( cytoscapeDagre )
8- }
93
104export default class extends Controller {
115 static values = {
126 nodes : Array ,
13- edges : Array
7+ edges : Array ,
8+ semesterLabels : Object
9+ }
10+
11+ get isMobile ( ) {
12+ return window . innerWidth < 640
1413 }
1514
1615 connect ( ) {
16+ const mobile = this . isMobile
17+ const groups = this . groupBySemester ( )
18+ const positions = mobile
19+ ? this . calculateMobilePositions ( groups )
20+ : this . calculateDesktopPositions ( groups )
21+
1722 this . cy = cytoscape ( {
1823 container : this . element ,
1924 elements : this . buildElements ( ) ,
2025 layout : {
21- name : "dagre" ,
22- rankDir : "TB" ,
23- nodeSep : 50 ,
24- rankSep : 80
26+ name : "preset" ,
27+ positions : ( node ) => positions [ node . id ( ) ] || { x : 0 , y : 0 } ,
28+ fit : false
2529 } ,
26- style : [
27- {
28- selector : "node" ,
29- style : {
30- "label" : "data(label)" ,
31- "text-wrap" : "wrap" ,
32- "text-max-width" : "100px" ,
33- "background-color" : "#6b7280" ,
34- "color" : "#fff" ,
35- "text-valign" : "center" ,
36- "text-halign" : "center" ,
37- "padding" : "10px" ,
38- "shape" : "round-rectangle" ,
39- "width" : "label" ,
40- "height" : "label" ,
41- "font-size" : "12px"
42- }
43- } ,
44- {
45- // Blue for available (can take now)
46- selector : "node[?available]" ,
47- style : { "background-color" : "#3b82f6" }
48- } ,
49- {
50- // Green for completed (all approvables approved)
51- selector : "node[?completed]" ,
52- style : { "background-color" : "#22c55e" }
53- } ,
54- {
55- selector : "edge" ,
56- style : {
57- "width" : 2 ,
58- "line-color" : "#d1d5db" ,
59- "target-arrow-color" : "#d1d5db" ,
60- "target-arrow-shape" : "triangle" ,
61- "curve-style" : "bezier"
62- }
63- }
64- ]
30+ minZoom : 0.3 ,
31+ maxZoom : 3 ,
32+ autoungrabify : true ,
33+ style : this . buildStyles ( mobile )
6534 } )
6635
36+ this . headerEls = [ ]
37+
38+ if ( mobile ) {
39+ this . cy . zoom ( 1 )
40+ // Pan so graph starts at the top of the container
41+ const bb = this . cy . elements ( ) . boundingBox ( )
42+ this . cy . pan ( { x : - bb . x1 + 16 , y : - bb . y1 + 10 } )
43+ this . cy . userPanningEnabled ( true )
44+ this . cy . userZoomingEnabled ( false )
45+ this . cy . boxSelectionEnabled ( false )
46+ } else {
47+ this . cy . fit ( undefined , 40 )
48+ }
49+
50+ this . buildSemesterNodeGroups ( )
51+ this . createHeaders ( mobile )
52+ this . cy . on ( "pan zoom" , ( ) => this . positionHeaders ( ) )
53+
6754 this . cy . on ( "tap" , "node" , ( evt ) => {
6855 const url = evt . target . data ( "url" )
6956 if ( url ) Turbo . visit ( url )
@@ -73,14 +60,183 @@ export default class extends Controller {
7360 this . element . __cytoscape = this . cy
7461 }
7562
63+ groupBySemester ( ) {
64+ const groups = new Map ( )
65+ for ( const n of this . nodesValue ) {
66+ const sem = n . semester ?? 0
67+ if ( ! groups . has ( sem ) ) groups . set ( sem , [ ] )
68+ groups . get ( sem ) . push ( n )
69+ }
70+ // Return sorted by semester number
71+ return new Map ( [ ...groups . entries ( ) ] . sort ( ( a , b ) => a [ 0 ] - b [ 0 ] ) )
72+ }
73+
74+ calculateDesktopPositions ( groups ) {
75+ const colWidth = 160
76+ const nodeHeight = 45
77+ const nodeGapY = 15
78+ const topPadding = 40 // space for semester headers
79+
80+ const positions = { }
81+ let col = 0
82+
83+ for ( const [ , nodes ] of groups ) {
84+ const x = col * colWidth + colWidth / 2
85+ for ( let i = 0 ; i < nodes . length ; i ++ ) {
86+ const y = topPadding + i * ( nodeHeight + nodeGapY ) + nodeHeight / 2
87+ positions [ nodes [ i ] . id . toString ( ) ] = { x, y }
88+ }
89+ col ++
90+ }
91+
92+ return positions
93+ }
94+
95+ calculateMobilePositions ( groups ) {
96+ const nodesPerRow = 2
97+ const containerW = window . innerWidth
98+ const padding = 16
99+ const gapX = 14
100+ const nodeW = Math . floor ( ( containerW - padding * 2 - gapX ) / nodesPerRow )
101+ const nodeH = 42
102+ const rowGap = 14
103+ const semGap = 44 // gap between semester sections (includes header space)
104+
105+ const positions = { }
106+ let y = semGap // start with space for first header
107+
108+ for ( const [ , nodes ] of groups ) {
109+ for ( let i = 0 ; i < nodes . length ; i ++ ) {
110+ const col = i % nodesPerRow
111+ const row = Math . floor ( i / nodesPerRow )
112+ positions [ nodes [ i ] . id . toString ( ) ] = {
113+ x : padding + col * ( nodeW + gapX ) + nodeW / 2 ,
114+ y : y + row * ( nodeH + rowGap ) + nodeH / 2
115+ }
116+ }
117+ const rowsInSemester = Math . ceil ( nodes . length / nodesPerRow )
118+ y += rowsInSemester * ( nodeH + rowGap ) + semGap
119+ }
120+
121+ return positions
122+ }
123+
124+ buildStyles ( mobile ) {
125+ const fontSize = mobile ? "11px" : "12px"
126+ const nodeWidth = mobile ? Math . floor ( ( window . innerWidth - 32 - 14 ) / 2 ) : 130
127+ const nodeHeight = mobile ? 42 : 45
128+ const textMaxWidth = mobile ? `${ nodeWidth - 10 } px` : "120px"
129+
130+ return [
131+ {
132+ selector : "node" ,
133+ style : {
134+ "label" : "data(label)" ,
135+ "text-wrap" : "wrap" ,
136+ "text-max-width" : textMaxWidth ,
137+ "background-color" : "#6b7280" ,
138+ "color" : "#fff" ,
139+ "text-valign" : "center" ,
140+ "text-halign" : "center" ,
141+ "padding" : "0px" ,
142+ "shape" : "round-rectangle" ,
143+ "width" : nodeWidth ,
144+ "height" : nodeHeight ,
145+ "font-size" : fontSize ,
146+ "cursor" : "pointer"
147+ }
148+ } ,
149+ {
150+ selector : "node[?available]" ,
151+ style : { "background-color" : "#3b82f6" }
152+ } ,
153+ {
154+ selector : "node[?completed]" ,
155+ style : { "background-color" : "#22c55e" }
156+ } ,
157+ {
158+ selector : "edge" ,
159+ style : {
160+ "width" : 1.5 ,
161+ "line-color" : "#cbd5e1" ,
162+ "target-arrow-color" : "#cbd5e1" ,
163+ "target-arrow-shape" : "triangle" ,
164+ "arrow-scale" : 0.8 ,
165+ "curve-style" : "bezier" ,
166+ "opacity" : 0.6
167+ }
168+ }
169+ ]
170+ }
171+
172+ buildSemesterNodeGroups ( ) {
173+ this . semesterNodeGroups = new Map ( )
174+ this . cy . nodes ( ) . forEach ( node => {
175+ const sem = node . data ( "semester" )
176+ if ( ! this . semesterNodeGroups . has ( sem ) ) this . semesterNodeGroups . set ( sem , [ ] )
177+ this . semesterNodeGroups . get ( sem ) . push ( node )
178+ } )
179+ }
180+
181+ createHeaders ( mobile ) {
182+ for ( const [ sem ] of [ ...this . semesterNodeGroups . entries ( ) ] . sort ( ( a , b ) => a [ 0 ] - b [ 0 ] ) ) {
183+ const label = this . semesterLabelsValue [ sem . toString ( ) ] || `Semestre ${ sem } `
184+
185+ const header = document . createElement ( "div" )
186+ header . textContent = label
187+ header . style . position = "absolute"
188+ header . style . pointerEvents = "none"
189+ header . style . fontWeight = "600"
190+ header . style . whiteSpace = "nowrap"
191+ header . style . zIndex = "10"
192+ header . style . color = mobile ? "#374151" : "#6b7280"
193+ header . dataset . semester = sem . toString ( )
194+
195+ this . element . appendChild ( header )
196+ this . headerEls . push ( header )
197+ }
198+
199+ this . positionHeaders ( )
200+ }
201+
202+ positionHeaders ( ) {
203+ if ( ! this . cy || ! this . semesterNodeGroups ) return
204+
205+ const zoom = this . cy . zoom ( )
206+
207+ for ( const header of this . headerEls ) {
208+ const sem = parseInt ( header . dataset . semester )
209+ const nodes = this . semesterNodeGroups . get ( sem )
210+ if ( ! nodes || nodes . length === 0 ) continue
211+
212+ // Use cytoscape's rendered bounding boxes for accurate positioning
213+ let minY = Infinity
214+ let sumX = 0
215+
216+ for ( const node of nodes ) {
217+ const rbb = node . renderedBoundingBox ( )
218+ if ( rbb . y1 < minY ) minY = rbb . y1
219+ sumX += ( rbb . x1 + rbb . x2 ) / 2
220+ }
221+
222+ const avgX = sumX / nodes . length
223+
224+ header . style . left = `${ avgX } px`
225+ header . style . top = `${ minY - 4 } px`
226+ header . style . transform = "translate(-50%, -100%)"
227+ header . style . fontSize = `${ Math . max ( 10 , 13 * zoom ) } px`
228+ }
229+ }
230+
76231 buildElements ( ) {
77232 const nodes = this . nodesValue . map ( n => ( {
78233 data : {
79234 id : n . id . toString ( ) ,
80235 label : `${ n . code } \n${ n . name } ` ,
81236 url : n . url ,
82237 available : n . available ,
83- completed : n . completed
238+ completed : n . completed ,
239+ semester : n . semester ?? 0
84240 }
85241 } ) )
86242
@@ -95,6 +251,9 @@ export default class extends Controller {
95251 }
96252
97253 disconnect ( ) {
254+ if ( this . headerEls ) {
255+ this . headerEls . forEach ( el => el . remove ( ) )
256+ }
98257 this . cy ?. destroy ( )
99258 }
100259}
0 commit comments