Skip to content

Commit d1ee4dc

Browse files
committed
use grid view
1 parent 959a73c commit d1ee4dc

11 files changed

Lines changed: 321 additions & 98 deletions

File tree

app/components/subject_graph_component.html.erb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
data-controller="subject-graph"
33
data-subject-graph-nodes-value="<%= nodes.to_json %>"
44
data-subject-graph-edges-value="<%= edges.to_json %>"
5-
class="subject-graph-container border border-gray-200 rounded-lg w-[100vw] relative left-[50%] -translate-x-1/2 flex-1 min-h-0">
5+
data-subject-graph-semester-labels-value="<%= semester_labels.to_json %>"
6+
class="subject-graph-container border border-gray-200 rounded-lg w-[100vw] relative left-[50%] -translate-x-1/2 flex-1 min-h-0 overflow-hidden">
67
</div>

app/components/subject_graph_component.rb

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
class SubjectGraphComponent < ViewComponent::Base
2-
def initialize(subjects:, current_student:)
2+
def initialize(subjects:, current_student:, semester_map: nil)
33
@subjects = subjects
44
@current_student = current_student
5+
@semester_map = semester_map
56
end
67

78
def nodes
@@ -12,7 +13,8 @@ def nodes
1213
name: subject.short_name || subject.name,
1314
url: helpers.subject_path(subject),
1415
available: @current_student.available?(subject),
15-
completed: @current_student.approved?(subject)
16+
completed: @current_student.approved?(subject),
17+
semester: semester_for(subject)
1618
}
1719
end
1820
end
@@ -28,8 +30,36 @@ def edges
2830
edges.uniq
2931
end
3032

33+
def semester_labels
34+
semesters = @subjects.map { |s| semester_for(s) }.uniq.sort
35+
36+
semesters.index_with do |sem|
37+
semester_display_label(sem)
38+
end
39+
end
40+
3141
private
3242

43+
def semester_for(subject)
44+
if @semester_map
45+
@semester_map[subject.id] || 0
46+
else
47+
index = Subject::CATEGORIES.index(subject.category&.to_sym)
48+
index ? index + 1 : 0
49+
end
50+
end
51+
52+
def semester_display_label(semester)
53+
return "Otras" if semester == 0
54+
55+
if @semester_map
56+
"Semestre #{semester}"
57+
else
58+
category = Subject::CATEGORIES[semester - 1]
59+
category ? helpers.formatted_category(category.to_s) : "Semestre #{semester}"
60+
end
61+
end
62+
3363
def collect_prerequisite_edges(prerequisite, target_subject_id, subject_ids, edges)
3464
return unless prerequisite
3565

app/controllers/planner/graphs_controller.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ class GraphsController < ApplicationController
33
before_action :authenticate_user!
44

55
def show
6-
planned_subjects = current_user.subject_plans
7-
.includes(subject: [:course, :exam])
8-
.map(&:subject)
6+
subject_plans = current_user.subject_plans
7+
.includes(subject: [:course, :exam])
98

10-
TreePreloader.preload(planned_subjects)
9+
@semester_map = subject_plans.to_h do |subject_plan|
10+
[subject_plan.subject_id, subject_plan.semester]
11+
end
1112

12-
@subjects = planned_subjects
13+
@subjects = subject_plans.map(&:subject)
14+
TreePreloader.preload(@subjects)
1315
end
1416
end
1517
end
Lines changed: 210 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,56 @@
11
import { Controller } from "@hotwired/stimulus"
22
import 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

104
export 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

Comments
 (0)