• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Courseography / courseography / d8cc203d-629c-4fba-924f-e1c48253dfbb

21 Jul 2026 03:38PM UTC coverage: 58.113% (-0.5%) from 58.617%
d8cc203d-629c-4fba-924f-e1c48253dfbb

Pull #1764

circleci

r-weng
Replace return with continue in for loop; remove width/height calculations
Pull Request #1764: Fix dynamic graph centering in Generate page

517 of 984 branches covered (52.54%)

Branch coverage included in aggregate %.

7 of 37 new or added lines in 1 file covered. (18.92%)

1 existing line in 1 file now uncovered.

2520 of 4242 relevant lines covered (59.41%)

150.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.53
/js/components/graph/Graph.js
1
import React from "react"
2
import PropTypes from "prop-types"
3
import { CourseModal } from "../common/react_modal.js.jsx"
4
import { ExportModal } from "../common/export.js.jsx"
5
import { getProgram } from "../common/utils.js"
6
import Bool from "./Bool"
7
import Edge from "./Edge"
8
import Node from "./Node"
9
import Button from "./Button"
10
import InfoBox from "./InfoBox"
11
import Sidebar from "./Sidebar"
12
import { parseAnd } from "../../util/util.js"
13
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
14
import {
15
  faMagnifyingGlassMinus,
16
  faMagnifyingGlassPlus,
17
} from "@fortawesome/free-solid-svg-icons"
18

19
const ZOOM_INCREMENT = 0.01
15✔
20
const KEYBOARD_PANNING_INCREMENT = 10
15✔
21
const ZOOM_ENUM = {
15✔
22
  ZOOM_OUT: -1,
23
  ZOOM_IN: 1,
24
}
25

26
export class Graph extends React.Component {
27
  constructor(props) {
28
    super(props)
123✔
29
    this.state = {
122✔
30
      labelsJSON: {},
31
      regionsJSON: {},
32
      nodesJSON: {},
33
      nodesStatus: {},
34
      hybridsJSON: {},
35
      boolsJSON: {},
36
      edgesJSON: {},
37
      edgesStatus: {},
38
      boolsStatus: {},
39
      highlightedNodesFocus: [],
40
      highlightedNodesDeps: [],
41
      infoboxTimeouts: [],
42
      width: window.innerWidth,
43
      height: window.innerHeight,
44
      zoomFactor: 1,
45
      horizontalPanFactor: 0,
46
      verticalPanFactor: 0,
47
      buttonHover: false,
48
      onDraw: this.props.edit,
49
      drawMode: this.props.initialDrawMode,
50
      drawNodeID: 0,
51
      draggingNode: null,
52
      focusCourses: {},
53
      graphName: null,
54
      connections: null,
55
      showInfoBox: false,
56
      infoBoxXPos: 0,
57
      infoBoxYPos: 0,
58
      infoBoxNodeId: "",
59
      isMouseOnInfoBox: false,
60
      isMouseOnNode: false,
61
      panning: false,
62
      panStartX: 0,
63
      panStartY: 0,
64
      showCourseModal: false,
65
      selectedNodes: new Set(),
66
    }
67
    this.exportModal = React.createRef()
122✔
68
    this.nodeDropshadowFilter = "dropshadow"
122✔
69
  }
70

71
  componentDidMount() {
72
    if (!this.props.start_blank) {
117✔
73
      this.getGraph()
101✔
74
    }
75

76
    // can't detect keydown event when adding event listener to react-graph
77
    document.body.addEventListener("keydown", this.onKeyDown)
117✔
78

79
    if (document.querySelector(".sidebar")) {
117!
80
      document
×
81
        .querySelector(".sidebar")
82
        .addEventListener("wheel", event => event.stopPropagation())
×
83
    }
84
  }
85

86
  UNSAFE_componentWillUpdate(prevProps) {
87
    if (!!this.state.graphName && this.state.graphName !== prevProps.graphName) {
1,048✔
88
      this.getGraph()
3✔
89
    }
90
  }
91

92
  componentWillUnmount() {
93
    this.state.infoboxTimeouts.forEach(timeout => clearTimeout(timeout))
118✔
94
    document.body.removeEventListener("keydown", this.onKeyDown)
118✔
95

96
    if (document.getElementById("nav-export")) {
118✔
97
      document
24✔
98
        .getElementById("nav-export")
99
        .removeEventListener("click", this.exportModal.current.openModal)
100
    }
101
  }
102

103
  getGraph = () => {
122✔
104
    const graphName = this.props.graphName.replace("-", " ")
104✔
105
    const url = new URL("/get-json-data", document.location)
104✔
106
    const params = { graphName: graphName }
104✔
107
    Object.keys(params).forEach(key => url.searchParams.append(key, params[key]))
104✔
108

109
    fetch(url)
104✔
110
      .then(headers => {
111
        if (!headers.ok) {
104!
112
          // can't just return res
113
          const headerInfo = {
×
114
            status: headers.status,
115
            statusText: headers.statusText,
116
            type: headers.type,
117
            url: headers.url,
118
          }
119
          throw new Error(
×
120
            "When fetching from the url with info " + JSON.stringify(headerInfo)
121
          )
122
        }
123
        return headers.json() // only received headers, waiting for data
104✔
124
      })
125
      .then(data => {
126
        localStorage.setItem("active-graph", graphName)
103✔
127
        const labelsJSON = {}
103✔
128
        const regionsJSON = {}
103✔
129
        const nodesJSON = {}
103✔
130
        const nodesStatus = {}
103✔
131
        const hybridsJSON = {}
103✔
132
        const boolsJSON = {}
103✔
133
        const boolsStatusObj = {}
103✔
134
        const edgesJSON = {}
103✔
135
        const parentsObj = {}
103✔
136
        const inEdgesObj = {}
103✔
137
        const childrenObj = {}
103✔
138
        const outEdgesObj = {}
103✔
139
        const storedNodes = new Set()
103✔
140

141
        data.texts.forEach(entry => {
103✔
142
          if (entry.rId.startsWith("tspan")) {
824!
143
            labelsJSON[entry.rId] = entry
×
144
          }
145
        })
146

147
        data.shapes.forEach(function (entry) {
103✔
148
          if (entry.type_ === "Node") {
1,331✔
149
            nodesJSON[entry.id_] = entry
1,022✔
150
          } else if (entry.type_ === "Hybrid") {
309✔
151
            hybridsJSON[entry.id_] = entry
103✔
152
          } else if (entry.type_ === "BoolNode") {
206!
153
            boolsStatusObj[entry.id_] = localStorage.getItem(entry.id_) || "inactive"
206✔
154
            boolsJSON[entry.id_] = entry
206✔
155
          }
156
        })
157

158
        data.paths.forEach(function (entry) {
103✔
159
          if (entry.isRegion) {
721!
160
            regionsJSON[entry.id_] = entry
×
161
          } else {
162
            edgesJSON[entry.id_] = entry
721✔
163
          }
164
        })
165

166
        // The duplicate filter is a temporary fix, as previously there were two nodes, Hybrid and Node,
167
        // that were placed in the same spot on some graphs where there should be only a Hybrid node.
168
        const noDuplicatesNodesJSON = {}
103✔
169
        Object.values(nodesJSON).forEach(node => {
103✔
170
          if (!(node.id_ in parentsObj)) {
1,022!
171
            parentsObj[node.id_] = []
1,022✔
172
            inEdgesObj[node.id_] = []
1,022✔
173
            childrenObj[node.id_] = []
1,022✔
174
            outEdgesObj[node.id_] = []
1,022✔
175
            // Quickly adding any active nodes from local storage into the selected nodes
176
            if (localStorage.getItem(node.id_) === "active") {
1,022✔
177
              storedNodes.add(node.text[node.text.length - 1].text)
2✔
178
            }
179

180
            noDuplicatesNodesJSON[node.id_] = node
1,022✔
181
          }
182
        })
183

184
        Object.values(hybridsJSON).forEach(hybrid => {
103✔
185
          childrenObj[hybrid.id_] = []
103✔
186
          outEdgesObj[hybrid.id_] = []
103✔
187
          const nodesList = Object.values(nodesJSON)
103✔
188
          populateHybridRelatives(hybrid, nodesList, parentsObj, childrenObj)
103✔
189
          let state = localStorage.getItem(hybrid.id_)
103✔
190
          if (state === null) {
103!
191
            state = parentsObj[hybrid.id_].length === 0 ? "takeable" : "inactive"
103!
192
          }
193
          nodesStatus[hybrid.id_] = {
103✔
194
            status: state,
195
            selected: ["active", "overridden"].indexOf(state) >= 0,
196
          }
197
        })
198

199
        Object.values(edgesJSON).forEach(edge => {
103✔
200
          if (edge.target in parentsObj && edge.target in inEdgesObj) {
721✔
201
            parentsObj[edge.target].push(edge.source)
309✔
202
            inEdgesObj[edge.target].push(edge.id_)
309✔
203
          }
204

205
          if (edge.source in childrenObj && edge.source in outEdgesObj) {
721✔
206
            childrenObj[edge.source].push(edge.target)
515✔
207
            outEdgesObj[edge.source].push(edge.id_)
515✔
208
          }
209
        })
210

211
        Object.keys(boolsJSON).forEach(boolId => {
103✔
212
          const parents = []
206✔
213
          const childs = []
206✔
214
          const outEdges = []
206✔
215
          const inEdges = []
206✔
216
          Object.values(edgesJSON).forEach(edge => {
206✔
217
            if (boolId === edge.target) {
1,442✔
218
              parents.push(edge.source)
412✔
219
              inEdges.push(edge.id_)
412✔
220
            } else if (boolId === edge.source) {
1,030✔
221
              childs.push(edge.target)
206✔
222
              outEdges.push(edge.id_)
206✔
223
            }
224
          })
225
          parentsObj[boolId] = parents
206✔
226
          childrenObj[boolId] = childs
206✔
227
          outEdgesObj[boolId] = outEdges
206✔
228
          inEdgesObj[boolId] = inEdges
206✔
229
        })
230

231
        Object.keys(noDuplicatesNodesJSON).forEach(nodeId => {
103✔
232
          let state = localStorage.getItem(nodeId)
1,022✔
233
          if (state === null) {
1,022✔
234
            state = parentsObj[nodeId].length === 0 ? "takeable" : "inactive"
1,020✔
235
          }
236
          nodesStatus[nodeId] = {
1,022✔
237
            status: state,
238
            selected: ["active", "overridden"].indexOf(state) >= 0,
239
          }
240
        })
241

242
        const edgesStatus = Object.values(edgesJSON).reduce((acc, curr) => {
103✔
243
          const source = curr.source
721✔
244
          const target = curr.target
721✔
245
          let status
246
          const isSourceSelected =
247
            nodesStatus[source]?.selected || boolsStatusObj[source] === "active"
721✔
248

249
          const isTargetSelected =
250
            nodesStatus[target]?.selected || boolsStatusObj[target] === "active"
721✔
251

252
          const targetStatus = nodesStatus[target]?.status || boolsStatusObj[target]
721✔
253

254
          if (!isSourceSelected && targetStatus === "missing") {
721!
255
            status = "missing"
×
256
          } else if (!isSourceSelected) {
721!
257
            status = "inactive"
721✔
258
          } else if (!isTargetSelected) {
×
259
            status = "takeable"
×
260
          } else {
261
            status = "active"
×
262
          }
263
          acc[curr.id_] = status
721✔
264
          return acc
721✔
265
        }, {})
266

267
        this.setState({
103✔
268
          labelsJSON: labelsJSON,
269
          regionsJSON: regionsJSON,
270
          nodesJSON: noDuplicatesNodesJSON,
271
          hybridsJSON: hybridsJSON,
272
          boolsJSON: boolsJSON,
273
          boolsStatus: boolsStatusObj,
274
          edgesJSON: edgesJSON,
275
          width: data.width,
276
          height: data.height,
277
          zoomFactor: 1,
278
          horizontalPanFactor: 0,
279
          verticalPanFactor: 0,
280
          graphName: graphName,
281
          edgesStatus: edgesStatus,
282
          connections: {
283
            parents: parentsObj,
284
            inEdges: inEdgesObj,
285
            children: childrenObj,
286
            outEdges: outEdgesObj,
287
          },
288
          nodesStatus: nodesStatus,
289
          selectedNodes: storedNodes,
290
        })
291
      })
292
      .catch(err => {
293
        console.error("Fetch API failed. Here are the headers: ")
×
294
        console.error(err)
×
295
      })
296
  }
297

298
  componentDidUpdate(prevProps, prevState) {
299
    if (prevState.nodesJSON !== this.state.nodesJSON) {
1,048✔
300
      let totalFCEs = 0
100✔
301
      Object.values(this.state.nodesJSON).forEach(nodeJSON => {
100✔
302
        if (
1,000✔
303
          !this.state.hybridsJSON[nodeJSON.id_] &&
2,000✔
304
          this.state.nodesStatus[nodeJSON.id_].selected
305
        ) {
306
          totalFCEs += this.numCredits(nodeJSON.id_)
2✔
307
        }
308
      })
309
      if (this.props.setFCECount) {
100!
310
        this.props.setFCECount(totalFCEs)
100✔
311
      }
312
    }
313

314
    if (this.props.currFocus !== prevProps.currFocus && this.props.currFocus === null) {
1,048✔
315
      this.highlightFocuses([])
2✔
316
    } else if (this.props.currFocus !== prevProps.currFocus) {
1,046✔
317
      const currFocusCourses = this.state.focusCourses[this.props.currFocus]
5✔
318
      getProgram(
5✔
319
        this.props.currFocus,
320
        currFocusCourses?.modifiedTime || new Date(0).toUTCString()
10✔
321
      ).then(focusData => {
322
        if (!focusData.modified) {
5!
323
          this.highlightFocuses(currFocusCourses.list)
×
324
        } else {
325
          const focusCourses = this.state.focusCourses
5✔
326
          focusCourses[this.props.currFocus] = {
5✔
327
            list: focusData.courseList,
328
            modifiedTime: focusData.modifiedTime,
329
          }
330
          this.setState({ focusCourses }, () =>
5✔
331
            this.highlightFocuses(this.state.focusCourses[this.props.currFocus].list)
5✔
332
          )
333
        }
334
      })
335
    }
336
  }
337

338
  /**
339
   * Update the status of the Edge, based on the status of the Node/Bool it points from/to.
340
   */
341
  updateEdgeStatus = (status, edgeID, source, target) => {
122✔
342
    const isSourceSelected = this.isSelected(source)
730✔
343
    const isTargetSelected = this.isSelected(target)
730✔
344
    const targetStatus = this.state.nodesStatus[target]
730✔
345
      ? this.state.nodesStatus[target].status
346
      : this.state.boolsStatus[target]
347

348
    if (!status) {
730✔
349
      if (!isSourceSelected && targetStatus === "missing") {
636✔
350
        status = "missing"
12✔
351
      } else if (!isSourceSelected) {
624✔
352
        status = "inactive"
409✔
353
      } else if (!isTargetSelected) {
215✔
354
        status = "takeable"
156✔
355
      } else {
356
        status = "active"
59✔
357
      }
358
    }
359
    this.setState(state => {
730✔
360
      const edgesStatus = { ...state.edgesStatus }
730✔
361
      edgesStatus[edgeID] = status
730✔
362
      return {
730✔
363
        edgesStatus: edgesStatus,
364
      }
365
    })
366
  }
367

368
  nodeClick = event => {
122✔
369
    let courseId
370
    if (event.currentTarget.tagName === "g") {
74✔
371
      courseId = event.currentTarget.id
73✔
372
    } else if (event.currentTarget.tagName === "LI") {
1!
373
      courseId = event.currentTarget.getAttribute("data-node-id")
1✔
374
    } else {
375
      throw new Error("Invalid Element Type!")
×
376
    }
377
    const courseLabelArray = this.state.nodesJSON[courseId].text
74✔
378
    const courseLabel = courseLabelArray[courseLabelArray.length - 1].text
74✔
379
    const wasSelected = this.state.nodesStatus[courseId].selected
74✔
380
    const temp = [...this.state.selectedNodes]
74✔
381
    const credits = this.numCredits(courseId)
74✔
382

383
    this.toggleSelection(courseId)
74✔
384
    if (typeof this.props.incrementFCECount === "function") {
74!
385
      if (wasSelected) {
74✔
386
        this.props.incrementFCECount(-credits)
7✔
387
        this.setState({ selectedNodes: new Set(temp.filter(e => e !== courseLabel)) })
10✔
388
      } else {
389
        this.props.incrementFCECount(credits)
67✔
390
        this.setState({ selectedNodes: new Set([...temp, courseLabel]) })
67✔
391
      }
392
    }
393
  }
394

395
  nodeUnselect = courseId => {
122✔
396
    const courseLabelArray = this.state.nodesJSON[courseId].text
4✔
397
    const courseLabel = courseLabelArray[courseLabelArray.length - 1].text
4✔
398
    const wasSelected = this.state.nodesStatus[courseId].selected
4✔
399
    const temp = this.state.selectedNodes
4✔
400
    const credits = this.numCredits(courseId)
4✔
401

402
    if (typeof this.props.incrementFCECount === "function" && wasSelected) {
4!
403
      this.toggleSelection(courseId)
4✔
404
      this.props.incrementFCECount(-credits)
4✔
405
      temp.delete(courseLabel)
4✔
406
    }
407
  }
408

409
  /**
410
   * Assuming the first section of courseId follows the format:
411
   * 3 letters for department, 3 numbers for course, and characters for session and campus.
412
   * e.g. csc108h1 or mat235y1mat237y1mat257y1
413
   */
414
  numCredits = courseId => {
122✔
415
    let session = courseId[6]
80✔
416
    return typeof session === "string" && session.toLowerCase() === "y" ? 1.0 : 0.5
80✔
417
  }
418

419
  /**
420
   * Transforms <x> and <y> with the given transformation.
421
   * Assumes that transformation is a valid list [a,b,c,d,e,f] of length 6 that represents the
422
   * transformation matrix [[a, c, e], [b, d, f], [0, 0, 1]]
423
   * @param {list} transformation - a list that represents a matrix transformation
424
   * @param {float} x - the x coordinate to be transformed
425
   * @param {float} y - the y coordinate to be transformed
426
   */
427
  transformPoint = (transformation, x, y) => {
122✔
428
    const [a, b, c, d, e, f] = transformation
×
429
    const newX = a * x + c * y + e
×
430
    const newY = b * x + d * y + f
×
431
    return [newX, newY]
×
432
  }
433

434
  /**
435
   * Drawing mode is not implemented, meaning the onDraw defaults to false right now.
436
   */
437
  nodeMouseEnter = event => {
122✔
438
    const currTarg = event.currentTarget
108✔
439
    let courseId
440
    if (currTarg.tagName === "g") {
108✔
441
      courseId = currTarg.id
104✔
442
    } else if (currTarg.tagName === "LI") {
4✔
443
      courseId = currTarg.getAttribute("data-node-id")
2✔
444
    } else if (currTarg.tagName === "DIV") {
2!
445
      courseId = currTarg.childNodes[0].textContent.toLowerCase()
2✔
446
    } else {
447
      throw new Error("Invalid Element Type!")
×
448
    }
449

450
    const currentNode = this.state.nodesJSON[courseId]
108✔
451
    this.focusPrereqs(courseId)
108✔
452

453
    let xPos = currentNode.pos[0]
108✔
454
    let yPos = currentNode.pos[1]
108✔
455
    const rightSide = xPos > 222
108✔
456
    // The tooltip is offset with a 'padding' of 5.
457
    if (rightSide) {
108!
458
      xPos = parseFloat(xPos) - 65
108✔
459
    } else {
460
      xPos = parseFloat(xPos) + parseFloat(currentNode.width) + 5
×
461
    }
462

463
    yPos = parseFloat(yPos)
108✔
464

465
    if (currentNode.transform) {
108!
466
      ;[xPos, yPos] = this.transformPoint(currentNode.transform, xPos, yPos)
×
467
    }
468

469
    if (!this.state.onDraw) {
108!
470
      this.setState({
108✔
471
        showInfoBox: true,
472
        infoBoxXPos: xPos,
473
        infoBoxYPos: yPos,
474
        infoBoxNodeId: courseId,
475
        isMouseOnNode: true,
476
      })
477
    }
478
    this.setState({ buttonHover: true })
108✔
479
  }
480

481
  nodeMouseLeave = event => {
122✔
482
    const currTarg = event.currentTarget
60✔
483
    let courseId
484
    if (currTarg.tagName === "g") {
60✔
485
      courseId = currTarg.id
57✔
486
    } else if (currTarg.tagName === "LI") {
3✔
487
      courseId = currTarg.getAttribute("data-node-id")
2✔
488
    } else if (currTarg.tagName === "DIV") {
1!
489
      courseId = currTarg.childNodes[0].textContent.toLowerCase()
1✔
490
    } else {
491
      throw new Error("Invalid Element Type!")
×
492
    }
493

494
    this.unfocusPrereqs(courseId)
60✔
495

496
    const timeout = setTimeout(() => {
60✔
497
      if (!this.state.isMouseOnInfoBox && !this.state.isMouseOnNode) {
13✔
498
        this.setState({ showInfoBox: false })
7✔
499
      }
500
    }, 200)
501

502
    this.setState({
60✔
503
      infoboxTimeouts: this.state.infoboxTimeouts.concat(timeout),
504
      buttonHover: false,
505
      isMouseOnNode: false,
506
    })
507
  }
508

509
  /**
510
   * This handles the clicking of course items from the side bar, pulling up
511
   * the corresponding course-info modal.
512
   * @param  {string} courseCode - the course code for the clicked course
513
   */
514
  handleCourseClick = courseCode => {
122✔
515
    this.setState({
1✔
516
      courseId: courseCode.substring(0, 6),
517
      showCourseModal: true,
518
    })
519
  }
520

521
  /**
522
   * Drawing mode not implemented, so this function may not work.
523
   */
524
  nodeMouseDown = event => {
122✔
525
    if (this.state.drawMode === "draw-node" && event.currentTarget.id.startsWith("n")) {
×
526
      this.setState({ draggingNode: event.currentTarget.id })
×
527
    }
528
  }
529

530
  /**
531
   * Drawing mode not implemented, so this function may not work.
532
   */
533
  drawMouseMove = event => {
122✔
534
    // in draw-node mode, drag a node as the mouse moves
535
    if (this.state.drawMode === "draw-node") {
×
536
      if (this.state.draggingNode !== null) {
×
537
        const newPos = this.getRelativeCoords(event)
×
538
        let currentNode
539
        for (const node of Object.values(this.state.nodesJSON)) {
×
540
          if (node.id_ === this.state.draggingNode) {
×
541
            currentNode = node
×
542
          }
543
        }
544
        currentNode.pos = [newPos.x - 20, newPos.y - 15]
×
545
        currentNode.text[0].pos = [newPos.x, newPos.y + 5]
×
546
        const newNodesJSON = { ...this.state.nodesJSON }
×
547
        newNodesJSON[currentNode.id_] = currentNode
×
548
        this.setState({ nodesJSON: newNodesJSON })
×
549
      }
550
    }
551
  }
552

553
  /**
554
   * Drawing mode not implemented, so this function may not work.
555
   */
556
  drawMouseUp = event => {
122✔
557
    // in draw-node mode, drop a dragged node to a new location
558
    if (this.state.drawMode === "draw-node") {
×
559
      if (this.state.draggingNode !== null) {
×
560
        const newPos = this.getRelativeCoords(event)
×
561
        let currentNode
562
        for (const node of Object.values(this.state.nodesJSON)) {
×
563
          if (node.id_ === this.state.draggingNode) {
×
564
            currentNode = node
×
565
          }
566
        }
567
        currentNode.pos = [newPos.x - 20, newPos.y - 15]
×
568
        currentNode.text[0].pos = [newPos.x, newPos.y + 5]
×
569
        const newNodesJSON = { ...this.state.nodesJSON }
×
570
        newNodesJSON[currentNode.id_] = currentNode
×
571
        this.setState({
×
572
          nodesJSON: newNodesJSON,
573
          draggingNode: null,
574
        })
575
      }
576
    }
577
  }
578

579
  /**
580
   * Initializes the panning process by recording the position of the mouse pointer or touch event.
581
   * Right now only support for one finger is implemented.
582
   * @param {Event} event
583
   */
584
  startPanning = event => {
122✔
585
    if (event.type === "mousedown") {
87✔
586
      this.setState({
86✔
587
        panning: true,
588
        panStartX: event.clientX + this.state.horizontalPanFactor,
589
        panStartY: event.clientY + this.state.verticalPanFactor,
590
      })
591
    } else if (event.type === "touchstart") {
1!
592
      this.setState({
1✔
593
        panning: true,
594
        panStartX: event.touches[0].clientX + this.state.horizontalPanFactor,
595
        panStartY: event.touches[0].clientY + this.state.verticalPanFactor,
596
      })
597
    }
598
  }
599

600
  /**
601
   * Pans the graph by moving it in the direction that the mouse moved.
602
   * @param {Event} event
603
   */
604
  panGraph = event => {
122✔
605
    if (!this.state.panning) {
147✔
606
      return
145✔
607
    }
608

609
    let currentX, currentY
610
    if (event.type === "mousemove") {
2✔
611
      currentX = event.clientX
1✔
612
      currentY = event.clientY
1✔
613
    } else if (event.type === "touchmove") {
1!
614
      currentX = event.touches[0].clientX
1✔
615
      currentY = event.touches[0].clientY
1✔
616
    } else {
617
      return
×
618
    }
619

620
    const deltaX = currentX - this.state.panStartX
2✔
621
    const deltaY = currentY - this.state.panStartY
2✔
622

623
    this.setState({
2✔
624
      horizontalPanFactor: -deltaX,
625
      verticalPanFactor: -deltaY,
626
    })
627
  }
628

629
  /**
630
   * Stops the panning process.
631
   */
632
  stopPanning = () => {
122✔
633
    this.setState({
119✔
634
      panning: false,
635
      panStartX: 0,
636
      panStartY: 0,
637
    })
638
  }
639

640
  infoBoxMouseEnter = () => {
122✔
641
    this.setState({ showInfoBox: true, isMouseOnInfoBox: true })
8✔
642
  }
643

644
  infoBoxMouseLeave = () => {
122✔
645
    this.setState({ isMouseOnInfoBox: false })
5✔
646
    const timeout = setTimeout(() => {
5✔
647
      this.setState({ showInfoBox: false })
5✔
648
    }, 200)
649

650
    this.setState({
5✔
651
      infoboxTimeouts: this.state.infoboxTimeouts.concat(timeout),
652
    })
653
  }
654

655
  infoBoxMouseClick = () => {
122✔
656
    const newCourse = this.state.infoBoxNodeId.substring(0, 6)
7✔
657
    this.setState({
7✔
658
      courseId: newCourse,
659
      showCourseModal: true,
660
    })
661
  }
662

663
  onClose = () => {
122✔
664
    this.setState({ showCourseModal: false })
×
665
  }
666

667
  openExportModal = () => {
122✔
668
    this.exportModal.current.openModal()
×
669
  }
670

671
  // Reset graph
672
  reset = () => {
122✔
673
    this.props.setFCECount(0)
8✔
674
    const nodesStatus = Object.keys(this.state.nodesStatus).reduce((acc, curr) => {
8✔
675
      const state =
676
        this.state.connections.parents[curr].length === 0 ? "takeable" : "inactive"
88✔
677
      localStorage.setItem(curr, state)
88✔
678
      acc[curr] = {
88✔
679
        status: state,
680
        selected: false,
681
      }
682
      return acc
88✔
683
    }, {})
684

685
    const edgesStatus = Object.keys(this.state.edgesStatus).reduce(
8✔
686
      (acc, curr) => ((acc[curr] = "inactive"), acc),
59✔
687
      {}
688
    )
689

690
    const boolStatus = Object.keys(this.state.boolsStatus).reduce(
8✔
691
      (acc, curr) => ((acc[curr] = "inactive"), acc),
16✔
692
      {}
693
    )
694
    this.setState({
8✔
695
      boolsStatus: boolStatus,
696
      edgesStatus: edgesStatus,
697
      nodesStatus: nodesStatus,
698
      selectedNodes: new Set(),
699
    })
700
    if (this.state.currFocus !== null) {
8!
701
      this.highlightFocuses([])
8✔
702
    }
703
  }
704

705
  renderArrowHead = () => {
122✔
706
    const polylineAttrs = { points: "0,1 10,5 0,9", fill: "black" }
1,166✔
707
    return (
1,166✔
708
      <defs>
709
        <marker
710
          id="arrowHead"
711
          viewBox="0 0 10 10"
712
          refX="4"
713
          refY="5"
714
          markerUnits="strokeWidth"
715
          markerWidth="7"
716
          markerHeight="7"
717
          orient="auto"
718
        >
719
          <polyline {...polylineAttrs} />
720
        </marker>
721
      </defs>
722
    )
723
  }
724

725
  renderEllipseBlurFilter = () => {
122✔
726
    return (
1,166✔
727
      <defs>
728
        <filter id="blur-filter">
729
          <feGaussianBlur in="SourceGraphic" stdDeviation="2" />
730
        </filter>
731
      </defs>
732
    )
733
  }
734

735
  /** Zoom into the graph by calculating new viewbox dimensions
736
   *
737
   * @param {number} zoomMode - Determines whether to zoom in, zoom out, or rerender at current zoom level
738
   */
739
  zoomViewbox = zoomMode => {
122✔
740
    if (this.state.showCourseModal) {
4!
741
      return
×
742
    }
743
    let newZoomFactor = this.state.zoomFactor
4✔
744
    if (zoomMode === ZOOM_ENUM.ZOOM_IN) {
4✔
745
      newZoomFactor -= ZOOM_INCREMENT
2✔
746
    } else if (zoomMode === ZOOM_ENUM.ZOOM_OUT) {
2!
747
      newZoomFactor += ZOOM_INCREMENT
2✔
748
    }
749
    this.setState({
4✔
750
      zoomFactor: newZoomFactor,
751
    })
752
  }
753

754
  calculateRatioGraphSizeToContainerSize = () => {
122✔
755
    const containerWidth = document.getElementById("react-graph").clientWidth
×
756
    const containerHeight = document.getElementById("react-graph").clientHeight
×
757
    const heightToContainerRatio = this.state.height / containerHeight
×
758
    const widthToContainerRatio = this.state.width / containerWidth
×
759
    return Math.max(heightToContainerRatio, widthToContainerRatio)
×
760
  }
761

762
  resetZoomAndPan = () => {
122✔
763
    this.setState({
×
764
      zoomFactor: 1,
765
      verticalPanFactor: 0,
766
      horizontalPanFactor: 0,
767
    })
768
  }
769

770
  onWheel = event => {
122✔
771
    const zoomIn = event.deltaY < 0
2✔
772
    if (zoomIn) {
2✔
773
      this.zoomViewbox(ZOOM_ENUM.ZOOM_IN)
1✔
774
    } else {
775
      this.zoomViewbox(ZOOM_ENUM.ZOOM_OUT)
1✔
776
    }
777
  }
778

779
  buttonMouseEnter = () => {
122✔
780
    this.setState({ buttonHover: true })
×
781
  }
782

783
  buttonMouseLeave = () => {
122✔
784
    this.setState({ buttonHover: false })
×
785
  }
786

787
  getRelativeCoords = event => {
122✔
788
    let x = event.nativeEvent.offsetX
×
789
    let y = event.nativeEvent.offsetY
×
790
    x = x * this.state.zoomFactor + this.state.horizontalPanFactor
×
791
    y = y * this.state.zoomFactor + this.state.verticalPanFactor
×
792
    return { x: x, y: y }
×
793
  }
794

795
  drawNode = (x, y) => {
122✔
796
    let xPos, yPos
797

798
    // if node would extend offscreen, instead place it at the
799
    // edge. Give 2 pixels extra for node border width.
800
    if (x + 42 > this.state.width) {
×
801
      xPos = this.state.width - 42
×
802
    } else if (x < 2) {
×
803
      xPos = 2
×
804
    } else {
805
      xPos = x
×
806
    }
807

808
    if (y + 34 > this.state.height) {
×
809
      yPos = this.state.height - 34
×
810
    } else if (y < 2) {
×
811
      yPos = 2
×
812
    } else {
813
      yPos = y
×
814
    }
815

816
    // text is an empty string for now until implementation,
817
    // text position uses node position for now
818
    const textJSON = {
×
819
      align: "begin",
820
      fill: "",
821
      graph: 0,
822
      pos: [xPos, yPos + 20],
823
      rId: "text" + this.state.drawNodeID,
824
      text: "la",
825
    }
826

827
    const nodeJSON = {
×
828
      fill: "#" + document.getElementById("select-colour").value,
829
      graph: 0,
830
      // default dimensions for a node
831
      height: 32,
832
      width: 40,
833
      id_: "n" + this.state.drawNodeID,
834
      pos: [xPos, yPos],
835
      stroke: "",
836
      text: [textJSON],
837
      tolerance: 9,
838
      type_: "Node",
839
    }
840

841
    const newNodesJSON = { ...this.state.nodesJSON }
×
842
    newNodesJSON[nodeJSON.id_] = nodeJSON
×
843
    this.setState({
×
844
      nodesJSON: newNodesJSON,
845
      drawNodeID: this.state.drawNodeID + 1,
846
    })
847
  }
848

849
  /**
850
    * In draw-node creates a new node at the position of the click event on the SVG canvas.
851
    * In path-mode creates an elbow at the position of the click event on the SVG canvas,
852
      if the startNode is defined.
853

854
      NOTE: Drawing mode is not fully implemented yet, so this method may not work as expected.
855
    * @param {object} e The mousedown event.
856
    */
857
  drawGraphObject = e => {
122✔
858
    const pos = this.getRelativeCoords(e)
×
859
    // check if the user is trying to draw a node. Also check
860
    // if the user is trying to press a button instead (ie zoom buttons)
861
    if (this.state.drawMode === "draw-node" && !this.state.buttonHover) {
×
862
      this.drawNode(pos.x, pos.y)
×
863
    }
864
  }
865

866
  highlightDependencies = dependencies => {
122✔
867
    this.setState({ highlightedNodesDeps: dependencies })
116✔
868
  }
869

870
  highlightFocuses = focuses => {
122✔
871
    this.setState({ highlightedNodesFocus: focuses })
15✔
872
  }
873

874
  /** Allows panning and entering draw mode via the keyboard.
875
   *
876
   * @param {KeyboardEvent} event
877
   */
878
  onKeyDown = event => {
122✔
879
    if (event.key === "ArrowRight") {
188✔
880
      this.setState({
1✔
881
        horizontalPanFactor:
882
          this.state.horizontalPanFactor - KEYBOARD_PANNING_INCREMENT,
883
      })
884
    } else if (event.key === "ArrowDown") {
187✔
885
      this.setState({
8✔
886
        verticalPanFactor: this.state.verticalPanFactor - KEYBOARD_PANNING_INCREMENT,
887
      })
888
    } else if (event.key === "ArrowLeft") {
179✔
889
      this.setState({
1✔
890
        horizontalPanFactor:
891
          this.state.horizontalPanFactor + KEYBOARD_PANNING_INCREMENT,
892
      })
893
    } else if (event.key === "ArrowUp") {
178✔
894
      this.setState({
1✔
895
        verticalPanFactor: this.state.verticalPanFactor + KEYBOARD_PANNING_INCREMENT,
896
      })
897
    } else if (event.key === "+") {
177✔
898
      this.zoomViewbox(ZOOM_ENUM.ZOOM_IN)
1✔
899
    } else if (event.key === "-") {
176✔
900
      this.zoomViewbox(ZOOM_ENUM.ZOOM_OUT)
1✔
901
    } else if (this.state.onDraw && event.key === "n") {
175!
902
      this.setState({ drawMode: "draw-node" })
×
903
    }
904
  }
905

906
  /**
907
   * Update the Bool's state at any moment given the prereqs and current state.
908
   */
909
  updateNodeBool = boolId => {
122✔
910
    const newState = this.arePrereqsSatisfiedBool(boolId) ? "active" : "inactive"
98✔
911
    const childs = this.state.connections.children[boolId]
98✔
912
    const inEdges = this.state.connections.inEdges[boolId]
98✔
913
    const outEdges = this.state.connections.outEdges[boolId]
98✔
914

915
    this.setState(
98✔
916
      prevState => {
917
        const boolsStatus = { ...prevState.boolsStatus }
98✔
918
        boolsStatus[boolId] = newState
98✔
919
        return {
98✔
920
          boolsStatus: boolsStatus,
921
        }
922
      },
923
      () => {
924
        localStorage.setItem(boolId, newState)
98✔
925
        childs.forEach(node => {
98✔
926
          this.updateNode(node)
98✔
927
        })
928
        const allEdges = outEdges.concat(inEdges)
98✔
929
        allEdges.forEach(edge => {
98✔
930
          const currentEdge = this.state.edgesJSON[edge]
294✔
931
          this.updateEdgeStatus(
294✔
932
            undefined,
933
            currentEdge.id_,
934
            currentEdge.source,
935
            currentEdge.target
936
          )
937
        })
938
      }
939
    )
940
  }
941

942
  /**
943
   * Update the state/status of a node (and its children/edges).
944
   * @param  {boolean} recursive whether we should recurse on its children
945
   */
946
  updateNode = (nodeId, recursive) => {
122✔
947
    let newState
948
    if (this.arePrereqsSatisfiedNode(nodeId)) {
324✔
949
      if (this.isSelected(nodeId) || this.state.hybridsJSON[nodeId]) {
198✔
950
        newState = "active"
101✔
951
      } else {
952
        newState = "takeable"
97✔
953
      }
954
    } else {
955
      if (this.isSelected(nodeId) && !this.state.hybridsJSON[nodeId]) {
126✔
956
        newState = "overridden"
37✔
957
      } else {
958
        newState = "inactive"
89✔
959
      }
960
    }
961

962
    const childs = this.state.connections.children[nodeId]
324✔
963
    const status =
964
      this.state.nodesStatus[nodeId]?.status || this.state.boolsStatus[nodeId]
324!
965

966
    // Updating the children will be unnecessary if the selected state of the current node has not
967
    // changed, and the original state was not 'missing'
968
    const allEdges =
969
      this.state.connections.inEdges[nodeId]?.concat(
324✔
970
        this.state.connections.outEdges[nodeId]
971
      ) || []
972

973
    if (
324✔
974
      ["active", "overridden"].includes(newState) &&
517✔
975
      ["active", "overridden"].includes(status) &&
976
      status !== "missing"
977
    ) {
978
      localStorage.setItem(nodeId, newState)
55✔
979

980
      this.setState(
55✔
981
        prevState => {
982
          if (nodeId in prevState.nodesStatus) {
55!
983
            const nodesStatus = { ...prevState.nodesStatus }
55✔
984
            nodesStatus[nodeId].status = newState
55✔
985
            return { nodesStatus: nodesStatus }
55✔
986
          } else if (nodeId in prevState.boolsStatus) {
×
987
            const boolsStatus = { ...prevState.boolsStatus }
×
988
            boolsStatus[nodeId] = newState
×
989
            return { boolsStatus: boolsStatus }
×
990
          }
991
        },
992
        () => {
993
          allEdges.forEach(edge => {
55✔
994
            const currentEdge = this.state.edgesStatus[edge]
57✔
995
            this.updateEdgeStatus(
57✔
996
              undefined,
997
              currentEdge.id_,
998
              currentEdge.source,
999
              currentEdge.target
1000
            )
1001
          })
1002
        }
1003
      )
1004
      return
55✔
1005
    }
1006

1007
    if (recursive === undefined || recursive) {
269✔
1008
      this.setState(
202✔
1009
        prevState => {
1010
          if (nodeId in prevState.nodesStatus) {
202!
1011
            const nodesStatus = { ...prevState.nodesStatus }
202✔
1012
            nodesStatus[nodeId].status = newState
202✔
1013
            return { nodesStatus: nodesStatus }
202✔
1014
          } else if (nodeId in prevState.boolsStatus) {
×
1015
            const boolsStatus = { ...prevState.boolsStatus }
×
1016
            boolsStatus[nodeId] = newState
×
1017
            return { boolsStatus: boolsStatus }
×
1018
          }
1019
        },
1020
        () => {
1021
          localStorage.setItem(nodeId, newState)
202✔
1022
          childs?.forEach(n => {
202✔
1023
            if (this.state.nodesStatus[n]) {
120✔
1024
              this.updateNode(n)
32✔
1025
            } else if (this.state.boolsJSON[n]) {
88!
1026
              this.updateNodeBool(n)
88✔
1027
            }
1028
          })
1029
          allEdges.forEach(edge => {
202✔
1030
            const currentEdge = this.state.edgesJSON[edge]
222✔
1031
            this.updateEdgeStatus(
222✔
1032
              undefined,
1033
              currentEdge.id_,
1034
              currentEdge.source,
1035
              currentEdge.target
1036
            )
1037
          })
1038
        }
1039
      )
1040
    } else {
1041
      this.setState(
67✔
1042
        prevState => {
1043
          const nodesStatus = { ...prevState.nodesStatus }
67✔
1044
          nodesStatus[nodeId].status = newState
67✔
1045
          return { nodesStatus: nodesStatus }
67✔
1046
        },
1047
        () => {
1048
          allEdges.forEach(edge => {
67✔
1049
            const currentEdge = this.state.edgesJSON[edge]
51✔
1050
            this.updateEdgeStatus(
51✔
1051
              undefined,
1052
              currentEdge.id_,
1053
              currentEdge.source,
1054
              currentEdge.target
1055
            )
1056
          })
1057
        }
1058
      )
1059
      localStorage.setItem(nodeId, newState)
67✔
1060
    }
1061
  }
1062

1063
  /** Controls the selection and deselection of a node by switching states and updating the graph */
1064
  toggleSelection = nodeId => {
122✔
1065
    this.setState(
78✔
1066
      prevState => {
1067
        const nodesStatus = { ...prevState.nodesStatus }
78✔
1068
        nodesStatus[nodeId].selected = !nodesStatus[nodeId].selected
78✔
1069
        return { nodesStatus: nodesStatus }
78✔
1070
      },
1071
      () => {
1072
        this.updateNode(nodeId)
78✔
1073
      }
1074
    )
1075
  }
1076

1077
  /**
1078
   * Cross check with the selected focus prerequisites.
1079
   */
1080
  focusPrereqsBool = boolId => {
122✔
1081
    const status = this.state.boolsStatus[boolId]
26✔
1082
    const inEdges = this.state.connections.inEdges[boolId]
26✔
1083
    const parents = this.state.connections.parents[boolId]
26✔
1084
    // Check if there are any missing prerequisites.
1085
    if (status !== "active") {
26✔
1086
      this.setState(
20✔
1087
        prevState => {
1088
          const boolsStatus = { ...prevState.boolsStatus }
20✔
1089
          boolsStatus[boolId] = "missing"
20✔
1090
          return {
20✔
1091
            boolsStatus: boolsStatus,
1092
          }
1093
        },
1094
        () => {
1095
          inEdges?.forEach(edge => {
20✔
1096
            const currentEdge = this.state.edgesJSON[edge]
40✔
1097
            if (!this.isSelected(currentEdge.source)) {
40!
1098
              this.updateEdgeStatus(
40✔
1099
                "missing",
1100
                currentEdge.id_,
1101
                currentEdge.source,
1102
                currentEdge.target
1103
              )
1104
            }
1105
          })
1106
          parents?.forEach(node => {
20✔
1107
            this.state.nodesStatus[node]
40!
1108
              ? this.focusPrereqs(node)
1109
              : this.focusPrereqsBool(node)
1110
          })
1111
        }
1112
      )
1113
    }
1114
  }
1115
  /**
1116
   * Remove the focus preqrequisites if the focus is unselected.
1117
   */
1118
  unfocusPrereqsBool = boolId => {
122✔
1119
    this.updateNodeBool(boolId)
10✔
1120
    const parents = this.state.connections.parents[boolId]
10✔
1121
    parents.forEach(node => {
10✔
1122
      this.state.nodesStatus[node]
20!
1123
        ? this.unfocusPrereqs(node)
1124
        : this.unfocusPrereqsBool(node)
1125
    })
1126
  }
1127

1128
  /** Sets the status of all missing prerequisites to 'missing' */
1129
  focusPrereqs = nodeId => {
122✔
1130
    const status = this.state.nodesStatus[nodeId]?.status
222✔
1131
    const inEdges = this.state.connections.inEdges[nodeId]
222✔
1132
    const parents = this.state.connections.parents[nodeId]
222✔
1133

1134
    if (["inactive", "overridden", "takeable"].includes(status)) {
222✔
1135
      this.setState(
211✔
1136
        prevState => {
1137
          const nodesStatus = { ...prevState.nodesStatus }
211✔
1138
          nodesStatus[nodeId].status = "missing"
211✔
1139
          return {
211✔
1140
            nodesStatus: nodesStatus,
1141
            highlightedNodesDeps: [...prevState.highlightedNodesDeps, nodeId],
1142
          }
1143
        },
1144
        () => {
1145
          inEdges?.forEach(edge => {
211✔
1146
            const currentEdge = this.state.edgesJSON[edge]
66✔
1147
            if (!this.isSelected(currentEdge.source)) {
66✔
1148
              this.updateEdgeStatus(
54✔
1149
                "missing",
1150
                currentEdge.id_,
1151
                currentEdge.source,
1152
                currentEdge.target
1153
              )
1154
            }
1155
          })
1156
          parents?.forEach(node => {
211✔
1157
            if (typeof node === "string") {
100!
1158
              this.state.nodesStatus[node]
100✔
1159
                ? this.focusPrereqs(node)
1160
                : this.focusPrereqsBool(node)
1161
            } else {
1162
              node.forEach(n => {
×
1163
                this.state.nodesStatus[n]
×
1164
                  ? this.focusPrereqs(n)
1165
                  : this.focusPrereqsBool(n)
1166
              })
1167
            }
1168
          })
1169
        }
1170
      )
1171
    }
1172
  }
1173

1174
  /**
1175
   * Resets 'missing' nodes and edges to the previous statuses:
1176
   *  active, inactive, overridden, takeable
1177
   */
1178
  unfocusPrereqs = nodeId => {
122✔
1179
    this.highlightDependencies([])
116✔
1180
    this.updateNode(nodeId, false)
116✔
1181
    const parents = this.state.connections.parents[nodeId]
116✔
1182
    const inEdges = this.state.connections.inEdges[nodeId]
116✔
1183
    parents?.forEach(node => {
116✔
1184
      if (typeof node === "string") {
46!
1185
        this.state.nodesStatus[node]
46✔
1186
          ? this.unfocusPrereqs(node)
1187
          : this.unfocusPrereqsBool(node)
1188
      } else {
1189
        node.forEach(n => {
×
1190
          this.state.nodesStatus[n]
×
1191
            ? this.unfocusPrereqs(n)
1192
            : this.unfocusPrereqsBool(n)
1193
        })
1194
      }
1195
    })
1196
    inEdges?.forEach(edge => {
116✔
1197
      if (this.state.edgesStatus[edge] === "missing") {
28✔
1198
        const currentEdge = this.state.edgesJSON[edge]
12✔
1199
        this.updateEdgeStatus(
12✔
1200
          undefined,
1201
          currentEdge.id_,
1202
          currentEdge.source,
1203
          currentEdge.target
1204
        )
1205
      }
1206
    })
1207
  }
1208

1209
  isSelected = nodeId => {
122✔
1210
    if (this.state.nodesStatus[nodeId]) {
2,235✔
1211
      return this.isSelectedNode(nodeId)
1,389✔
1212
    } else {
1213
      return this.isSelectedBool(nodeId)
846✔
1214
    }
1215
  }
1216

1217
  /**
1218
   * Checks whether this Node is selected
1219
   * @return {boolean}
1220
   */
1221
  isSelectedNode = nodeId => {
122✔
1222
    if (this.state.nodesJSON[nodeId]) {
1,389✔
1223
      return this.state.nodesStatus[nodeId].selected
1,180✔
1224
    } else {
1225
      return this.state.nodesStatus[nodeId].status === "active"
209✔
1226
    }
1227
  }
1228

1229
  /**
1230
   * Check whether the Bool is selected.
1231
   * @returns {boolean} Whether status is active or not.
1232
   */
1233
  isSelectedBool = boolId => {
122✔
1234
    return this.state.boolsStatus[boolId] === "active"
846✔
1235
  }
1236

1237
  /**
1238
   * Check if the prerequisite courses have been satisfied based on bool type.
1239
   * @returns {boolean} Whether any of the prereqs are satisfied.
1240
   */
1241
  arePrereqsSatisfiedBool = boolId => {
122✔
1242
    const isAllTrue = element => {
98✔
1243
      return this.state.nodesStatus[element]
143!
1244
        ? this.isSelected(element)
1245
        : this.isSelected(element)
1246
    }
1247

1248
    if (this.state.boolsJSON[boolId].text[0].text === "and") {
98✔
1249
      return this.state.connections.parents[boolId].every(isAllTrue)
52✔
1250
    } else if (this.state.boolsJSON[boolId].text[0].text === "or") {
46!
1251
      return this.state.connections.parents[boolId].some(isAllTrue)
46✔
1252
    }
1253
  }
1254

1255
  /**
1256
   * Checks whether all prerequisite/preceding nodes for the current one are satisfied
1257
   * @return {boolean}
1258
   */
1259
  arePrereqsSatisfiedNode = nodeId => {
122✔
1260
    const parents = this.state.connections.parents[nodeId]
324✔
1261
    /**
1262
     * Recursively checks that preceding nodes are selected
1263
     * @param  {string|Array} element Node(s)/other on the graph
1264
     * @return {boolean}
1265
     */
1266
    const isAllTrue = element => {
324✔
1267
      if (typeof element === "string") {
202!
1268
        if (this.state.nodesStatus[element] || this.state.boolsJSON[element]) {
202!
1269
          return this.isSelected(element)
202✔
1270
        } else {
1271
          return false
×
1272
        }
1273
      } else {
1274
        return element.some(isAllTrue)
×
1275
      }
1276
    }
1277

1278
    return parents.every(isAllTrue)
324✔
1279
  }
1280

1281
  /**
1282
   * Renders a group of Bools
1283
   * @param {JSON} boolsJSON
1284
   * @param {object} boolsStatus
1285
   * @param {object} connections
1286
   * @return {JSX.Element}
1287
   */
1288
  renderBoolGroup = (boolsJSON, boolsStatus, connections) => {
122✔
1289
    const generateBool = boolJSON => {
1,167✔
1290
      const { parents } = connections
2,074✔
1291
      const json = {
2,074✔
1292
        ...boolJSON,
1293
        transform: this.formatTransform(boolJSON.transform),
1294
      }
1295

1296
      return (
2,074✔
1297
        <Bool
1298
          JSON={json}
1299
          className="bool"
1300
          key={boolJSON.id_}
1301
          parents={parents[boolJSON.id_]}
1302
          logicalType={(boolJSON.text[0] && boolJSON.text[0].text) || "and"}
4,148!
1303
          inEdges={connections.inEdges[boolJSON.id_]}
1304
          outEdges={connections.outEdges[boolJSON.id_]}
1305
          status={boolsStatus[boolJSON.id_]}
1306
        />
1307
      )
1308
    }
1309

1310
    return <g id="bools">{Object.values(boolsJSON).map(generateBool)}</g>
1,167✔
1311
  }
1312

1313
  /**
1314
   * Renders a group of Edges
1315
   * @param {JSON} edgesJSON
1316
   * @param {object} edgesStatus
1317
   * @return {JSX.Element}
1318
   */
1319
  renderEdgeGroup = (edgesJSON, edgesStatus) => {
122✔
1320
    const generateEdge = edgeJSON => {
1,167✔
1321
      return (
7,259✔
1322
        <Edge
1323
          className="path"
1324
          key={edgeJSON.id_}
1325
          source={edgeJSON.source}
1326
          target={edgeJSON.target}
1327
          points={edgeJSON.points}
1328
          status={edgesStatus[edgeJSON.id_]}
1329
          transform={this.formatTransform(edgeJSON.transform)}
1330
        />
1331
      )
1332
    }
1333

1334
    // Missing edges must be rendered last. The sort
1335
    // method custom sorts a copy of edgesJSON so that all missing edges
1336
    // are last in the list. Then render based on that list.
1337
    const edges = Object.values(edgesJSON)
1,167✔
1338
    const edgesCopy = [...edges]
1,167✔
1339
    const state = edgesStatus
1,167✔
1340
    edgesCopy.sort((a, b) => {
1,167✔
1341
      // If an edge is missing, its edgeID should be in EdgeGroup's
1342
      // state and its value should be true.
1343
      const aID = a.id_
6,222✔
1344
      const bID = b.id_
6,222✔
1345
      const aMiss = aID in state && state[aID]
6,222✔
1346
      const bMiss = bID in state && state[bID]
6,222✔
1347
      if ((aMiss && bMiss) || (!aMiss && !bMiss)) {
6,222!
1348
        // a and b are equal
1349
        return 0
6,222✔
1350
      } else if (aMiss && !bMiss) {
×
1351
        // sort a after b
1352
        return 1
×
1353
      } else if (!aMiss && bMiss) {
×
1354
        // sort b after a
1355
        return -1
×
1356
      }
1357
    })
1358

1359
    return <g id="edges">{edgesCopy.map(generateEdge)}</g>
1,167✔
1360
  }
1361

1362
  /**
1363
   * Renders a group of Nodes
1364
   * @param {JSON} edgesJSON
1365
   * @param {object} edgesStatus
1366
   * @return {JSX.Element}
1367
   */
1368
  renderNodeGroup = (
122✔
1369
    nodeClick,
1370
    nodeMouseEnter,
1371
    nodeMouseLeave,
1372
    nodeMouseDown,
1373
    onKeyDown,
1374
    onWheel,
1375
    nodesStatus,
1376
    nodesJSON,
1377
    hybridsJSON,
1378
    highlightedNodesFocus,
1379
    highlightedNodesDeps,
1380
    connections,
1381
    nodeDropshadowFilter
1382
  ) => {
1383
    return (
1,167✔
1384
      <g id="nodes">
1385
        {Object.values(hybridsJSON).map(entry => {
1386
          return (
1,037✔
1387
            <Node
1388
              JSON={entry}
1389
              className={"hybrid"}
1390
              key={entry.id_}
1391
              hybrid={true}
1392
              parents={connections.parents[entry.id_]}
1393
              childs={connections.children[entry.id_]}
1394
              status={nodesStatus[entry.id_].status}
1395
              transform={this.formatTransform(entry.transform)}
1396
              onWheel={onWheel}
1397
              onKeydown={onKeyDown}
1398
              nodeDropshadowFilter={nodeDropshadowFilter}
1399
            />
1400
          )
1401
        })}
1402
        {Object.values(nodesJSON).map(entry => {
1403
          // using `includes` to match "mat235" from "mat235237257calc2" and other math/stats courses
1404
          const highlightFocus = highlightedNodesFocus.some(node =>
10,370✔
1405
            entry.id_.includes(node)
2,240✔
1406
          )
1407
          const highlightDeps = highlightedNodesDeps.some(node =>
10,370✔
1408
            entry.id_.includes(node)
11,426✔
1409
          )
1410
          return (
10,370✔
1411
            <Node
1412
              JSON={entry}
1413
              className="node"
1414
              key={entry.id_}
1415
              hybrid={false}
1416
              parents={connections.parents[entry.id_]}
1417
              status={nodesStatus[entry.id_].status}
1418
              transform={this.formatTransform(entry.transform)}
1419
              highlightDeps={highlightDeps}
1420
              highlightFocus={highlightFocus}
1421
              onClick={nodeClick}
1422
              onMouseEnter={nodeMouseEnter}
1423
              onMouseLeave={nodeMouseLeave}
1424
              onMouseDown={nodeMouseDown}
1425
              onWheel={onWheel}
1426
              onKeydown={onKeyDown}
1427
              nodeDropshadowFilter={nodeDropshadowFilter}
1428
            />
1429
          )
1430
        })}
1431
      </g>
1432
    )
1433
  }
1434

1435
  renderRegions = regionsJSON => {
122✔
1436
    return Object.values(regionsJSON).map(function (entry, value) {
1,167✔
1437
      const pathAttrs = { d: "M" }
1✔
1438
      entry.points.forEach(function (x) {
1✔
1439
        pathAttrs["d"] += x[0] + "," + x[1] + " "
4✔
1440
      })
1441

1442
      const pathStyle = { fill: entry.fill }
1✔
1443
      return <path {...pathAttrs} key={value} className="region" style={pathStyle} />
1✔
1444
    })
1445
  }
1446

1447
  renderLabels = labelsJSON => {
122✔
1448
    return Object.values(labelsJSON).map((entry, value) => {
1,167✔
1449
      const textAttrs = {
1✔
1450
        x: entry.pos[0],
1451
        y: entry.pos[1],
1452
      }
1453

1454
      const textStyle = { fill: entry.fill }
1✔
1455

1456
      return (
1✔
1457
        <text
1458
          {...textAttrs}
1459
          key={value}
1460
          style={textStyle}
1461
          className="region-label"
1462
          textAnchor={entry["text-anchor"]}
1463
          transform={this.formatTransform(entry.transform)}
1464
        >
1465
          {entry["text"]}
1466
        </text>
1467
      )
1468
    })
1469
  }
1470

1471
  renderRegionsLabels(regionsJSON, labelsJSON) {
1472
    return (
1,167✔
1473
      <g id="regions">
1474
        {this.renderRegions(regionsJSON)}
1475
        {this.renderLabels(labelsJSON)}
1476
      </g>
1477
    )
1478
  }
1479

1480
  /**
1481
   * Formats fransformation to a valid CSS transform value.
1482
   * Assumes that <transform> is a list of length 6 that represents a matrix transformation if it exists,
1483
   * that is, [a, b, c, d, e, f] => matrix(a, b, c, d, e, f)
1484
   */
1485
  formatTransform = transform => {
122✔
1486
    return transform ? `matrix(${transform.join(", ")})` : ""
20,741!
1487
  }
1488

1489
  /**
1490
   * Compute the dimensions of the bounding box (bbox) that encloses every valid shape (node, hybrid, and bool) in the graph.
1491
   * @returns {?{minX, minY, maxX, maxY, width, height}} The bbox dimensions (minimum xy-coordinates, maximum
1492
   * xy-coordinates, width, and height), or null if all shapes in the graph have no size or position.
1493
   */
1494
  computeShapesBbox = () => {
122✔
NEW
1495
    const shapes = Object.values(this.state.nodesJSON)
×
1496
      .concat(Object.values(this.state.hybridsJSON))
1497
      .concat(Object.values(this.state.boolsJSON))
NEW
1498
    let minX = Infinity
×
NEW
1499
    let minY = Infinity
×
NEW
1500
    let maxX = -Infinity
×
NEW
1501
    let maxY = -Infinity
×
1502

NEW
1503
    for (let i = 0; i < shapes.length; i++) {
×
1504
      // Skip shapes with no position
NEW
1505
      if (!Array.isArray(shapes[i].pos)) {
×
NEW
1506
        continue
×
1507
      }
1508

NEW
1509
      let shapeWidth = Number(shapes[i].width)
×
NEW
1510
      if (Number.isNaN(shapeWidth) || shapeWidth < 0) {
×
NEW
1511
        shapeWidth = 0
×
1512
      }
1513

NEW
1514
      let shapeHeight = Number(shapes[i].height)
×
NEW
1515
      if (Number.isNaN(shapeHeight) || shapeHeight < 0) {
×
NEW
1516
        shapeHeight = 0
×
1517
      }
1518

1519
      // Skip shapes with no size
NEW
1520
      if (shapeWidth === 0 && shapeHeight === 0) {
×
NEW
1521
        continue
×
1522
      }
1523

NEW
1524
      const x = shapes[i].pos[0]
×
NEW
1525
      const y = shapes[i].pos[1]
×
NEW
1526
      minX = Math.min(minX, x)
×
NEW
1527
      minY = Math.min(minY, y)
×
NEW
1528
      maxX = Math.max(maxX, x + shapeWidth)
×
NEW
1529
      maxY = Math.max(maxY, y + shapeHeight)
×
1530
    }
1531

1532
    // Return null if all shapes have no size or position
NEW
1533
    if (!Number.isFinite(minX)) {
×
NEW
1534
      return null
×
1535
    }
1536

NEW
1537
    return { minX, minY, maxX, maxY }
×
1538
  }
1539

1540
  render() {
1541
    let containerWidth = 0
1,166✔
1542
    let containerHeight = 0
1,166✔
1543
    if (document.getElementById("react-graph") !== null) {
1,166✔
1544
      const reactGraph = document.getElementById("react-graph")
1,048✔
1545
      containerWidth = reactGraph.clientWidth
1,048✔
1546
      containerHeight = reactGraph.clientHeight
1,048✔
1547
    }
1548

1549
    let newViewboxWidth = this.state.width * this.state.zoomFactor
1,166✔
1550
    let newViewboxHeight = this.state.height * this.state.zoomFactor
1,166✔
1551
    let viewboxCentreX = this.state.width / 2
1,166✔
1552
    let viewboxCentreY = this.state.height / 2
1,166✔
1553
    if (document.getElementById("generateRoot") !== null) {
1,166!
NEW
1554
      const bbox = this.computeShapesBbox()
×
NEW
1555
      if (bbox !== null) {
×
1556
        // Centre viewBox on bbox
NEW
1557
        viewboxCentreX = (bbox.minX + bbox.maxX) / 2
×
NEW
1558
        viewboxCentreY = (bbox.minY + bbox.maxY) / 2
×
1559
      }
1560

UNCOV
1561
      newViewboxWidth =
×
1562
        Math.max(this.state.width, containerWidth) * this.state.zoomFactor
NEW
1563
      newViewboxHeight =
×
1564
        Math.max(this.state.height, containerHeight) * this.state.zoomFactor
1565
    }
1566

1567
    const viewboxContainerRatio =
1568
      containerHeight !== 0 ? newViewboxHeight / containerHeight : 1
1,166!
1569
    const viewboxX =
1570
      viewboxCentreX -
1,166✔
1571
      newViewboxWidth / 2 +
1572
      this.state.horizontalPanFactor * viewboxContainerRatio
1573
    const viewboxY =
1574
      viewboxCentreY -
1,166✔
1575
      newViewboxHeight / 2 +
1576
      this.state.verticalPanFactor * viewboxContainerRatio
1577

1578
    // not all of these properties are supported in React
1579
    const svgAttrs = {
1,166✔
1580
      height: "100%",
1581
      width: "100%",
1582
      viewBox: `${viewboxX} ${viewboxY} ${newViewboxWidth} ${newViewboxHeight}`,
1583
      preserveAspectRatio: "xMidYMin",
1584
      "xmlns:svg": "http://www.w3.org/2000/svg",
1585
      "xmlns:dc": "http://purl.org/dc/elements/1.1/",
1586
      "xmlns:cc": "http://creativecommons.org/ns#",
1587
      "xmlns:rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
1588
    }
1589

1590
    const resetDisabled =
1591
      this.state.zoomFactor === 1 &&
1,166✔
1592
      this.state.horizontalPanFactor === 0 &&
1593
      this.state.verticalPanFactor === 0
1594

1595
    // Mouse events for draw tool
1596
    let svgMouseEvents
1597
    if (this.state.onDraw) {
1,166!
1598
      svgMouseEvents = {
×
1599
        onMouseDown: this.drawGraphObject,
1600
        onMouseUp: this.drawMouseUp,
1601
        onMouseMove: this.drawMouseMove,
1602
      }
1603
    } else {
1604
      svgMouseEvents = {
1,166✔
1605
        onMouseDown: this.startPanning,
1606
        onTouchStart: this.startPanning,
1607
      }
1608
    }
1609

1610
    const reactGraphPointerEvents = {
1,166✔
1611
      onMouseMove: this.panGraph,
1612
      onMouseUp: this.stopPanning,
1613
      onTouchMove: this.panGraph,
1614
      onTouchEnd: this.stopPanning,
1615
      onWheel: this.onWheel,
1616
    }
1617

1618
    let reactGraphClass = "react-graph"
1,166✔
1619
    if (this.state.panning) {
1,166✔
1620
      reactGraphClass += " panning"
89✔
1621
    }
1622
    if (this.state.highlightedNodesFocus.length > 0 && this.props.currFocus) {
1,166✔
1623
      reactGraphClass += " highlight-nodes"
5✔
1624
    }
1625

1626
    return (
1,166✔
1627
      <div
1628
        id="react-graph"
1629
        data-testid="react-graph"
1630
        className={reactGraphClass}
1631
        {...reactGraphPointerEvents}
1632
      >
1633
        {
1634
          // Filtering by node.text.length is a temporary fix for a bug on Generate where the first node is empty
1635
          Object.keys(this.state.nodesJSON).length > 1 && (
2,203✔
1636
            <Sidebar
1637
              fceCount={this.props.fceCount}
1638
              reset={this.reset}
1639
              activeCourses={this.state.selectedNodes}
1640
              courses={Object.values(this.state.nodesJSON).map(node => [
10,370✔
1641
                node.id_,
1642
                node.text.length > 0 ? node.text[node.text.length - 1].text : "",
10,370!
1643
              ])}
1644
              courseClick={this.handleCourseClick}
1645
              xClick={this.nodeUnselect}
1646
              sidebarItemClick={this.nodeClick}
1647
              onHover={this.nodeMouseEnter}
1648
              onMouseLeave={this.nodeMouseLeave}
1649
            />
1650
          )
1651
        }
1652
        <CourseModal
1653
          showCourseModal={this.state.showCourseModal}
1654
          courseId={this.state.courseId}
1655
          onClose={this.onClose}
1656
        />
1657
        <ExportModal context="graph" session="" ref={this.exportModal} />
1658
        {Object.keys(this.state.nodesJSON).length > 1 && (
2,203✔
1659
          <div className="graph-button-group">
1660
            <div className="button-group">
1661
              <Button
1662
                mouseDown={() => this.zoomViewbox(ZOOM_ENUM.ZOOM_IN)}
×
1663
                onMouseEnter={this.buttonMouseEnter}
1664
                onMouseLeave={this.buttonMouseLeave}
1665
              >
1666
                <FontAwesomeIcon icon={faMagnifyingGlassPlus} id="zoom-icon" />
1667
              </Button>
1668
              <Button
1669
                mouseDown={() => this.zoomViewbox(ZOOM_ENUM.ZOOM_OUT)}
×
1670
                onMouseEnter={this.buttonMouseEnter}
1671
                onMouseLeave={this.buttonMouseLeave}
1672
              >
1673
                <FontAwesomeIcon icon={faMagnifyingGlassMinus} id="zoom-icon" />
1674
              </Button>
1675
            </div>
1676
            <div className="button-group">
1677
              <Button
1678
                divId="reset-view-button"
1679
                mouseDown={this.resetZoomAndPan}
1680
                onMouseEnter={this.buttonMouseEnter}
1681
                onMouseLeave={this.buttonMouseLeave}
1682
                disabled={resetDisabled}
1683
              >
1684
                <img
1685
                  src="/static/res/ico/reset-view.png"
1686
                  alt="Reset View"
1687
                  title="Click to reset view"
1688
                />
1689
              </Button>
1690
            </div>
1691
          </div>
1692
        )}
1693

1694
        <svg
1695
          xmlns="http://www.w3.org/2000/svg"
1696
          xmlnsXlink="http://www.w3.org/1999/xlink"
1697
          id="main-graph"
1698
          {...svgAttrs}
1699
          version="1.1"
1700
          {...svgMouseEvents}
1701
        >
1702
          <filter id={this.nodeDropshadowFilter} height="130%">
1703
            <feGaussianBlur in="SourceAlpha" stdDeviation="3" />
1704
            <feOffset dx="2" dy="2" result="offsetblur" />
1705
            <feComponentTransfer>
1706
              <feFuncA type="linear" slope="0.8" />
1707
            </feComponentTransfer>
1708
            <feMerge>
1709
              <feMergeNode />
1710
              <feMergeNode in="SourceGraphic" />
1711
            </feMerge>
1712
          </filter>
1713
          {this.renderArrowHead()}
1714
          {this.renderEllipseBlurFilter()}
1715
          {this.renderRegionsLabels(this.state.regionsJSON, this.state.labelsJSON)}
1716

1717
          {this.renderBoolGroup(
1718
            this.state.boolsJSON,
1719
            this.state.boolsStatus,
1720
            this.state.connections
1721
          )}
1722

1723
          {this.renderEdgeGroup(this.state.edgesJSON, this.state.edgesStatus)}
1724

1725
          {this.renderNodeGroup(
1726
            this.nodeClick,
1727
            this.nodeMouseEnter,
1728
            this.nodeMouseLeave,
1729
            this.nodeMouseDown,
1730
            this.onKeyDown,
1731
            this.onWheel,
1732
            this.state.nodesStatus,
1733
            this.state.nodesJSON,
1734
            this.state.hybridsJSON,
1735
            this.state.highlightedNodesFocus,
1736
            this.state.highlightedNodesDeps,
1737
            this.state.connections,
1738
            this.nodeDropshadowFilter
1739
          )}
1740

1741
          <InfoBox
1742
            onClick={this.infoBoxMouseClick}
1743
            onMouseEnter={this.infoBoxMouseEnter}
1744
            onMouseLeave={this.infoBoxMouseLeave}
1745
            showInfoBox={this.state.showInfoBox}
1746
            xPos={this.state.infoBoxXPos}
1747
            yPos={this.state.infoBoxYPos}
1748
            nodeId={this.state.infoBoxNodeId}
1749
          />
1750
        </svg>
1751
      </div>
1752
    )
1753
  }
1754
}
1755

1756
export { ZOOM_INCREMENT, KEYBOARD_PANNING_INCREMENT }
1757

1758
/** Helper function that adds parents of hybridNode to the parents object, and adds hybrid nodes as children of the Nodes they represent
1759
 *
1760
 * @param {Node} hybridNode
1761
 * @param {Array} nodesJSON
1762
 * @param {Object} parents
1763
 * @param {Object} childrenObj
1764
 */
1765
export function populateHybridRelatives(hybridNode, nodesJSON, parents, childrenObj) {
1766
  // parse prereqs based on text
1767
  let hybridText = ""
108✔
1768
  hybridNode.text.forEach(textTag => (hybridText += textTag.text))
108✔
1769
  const nodeParents = []
108✔
1770
  // First search for entire string (see Stats graph)
1771
  let prereqNode = findRelationship(hybridText, nodesJSON)
108✔
1772
  if (prereqNode !== undefined) {
108✔
1773
    nodeParents.push(prereqNode.id_)
105✔
1774
    childrenObj[prereqNode.id_].push(hybridNode.id_)
105✔
1775
  } else {
1776
    // Parse text first
1777
    const prereqs = parseAnd(hybridText)[0]
3✔
1778
    prereqs.forEach(course => {
3✔
1779
      if (typeof course === "string") {
3✔
1780
        prereqNode = findRelationship(course, nodesJSON)
1✔
1781
        if (prereqNode !== undefined) {
1!
1782
          nodeParents.push(prereqNode.id_)
×
1783
          childrenObj[prereqNode.id_].push(hybridNode.id_)
×
1784
        } else {
1785
          console.error("Could not find prereq for ", hybridText)
1✔
1786
        }
1787
      } else if (typeof course === "object") {
2!
1788
        const orPrereq = []
2✔
1789
        course.forEach(c => {
2✔
1790
          const prereqNode = findRelationship(c, nodesJSON)
6✔
1791
          if (prereqNode !== undefined) {
6✔
1792
            orPrereq.push(prereqNode.id_)
5✔
1793
            childrenObj[prereqNode.id_].push(hybridNode.id_)
5✔
1794
          } else {
1795
            console.error("Could not find prereq for ", hybridText)
1✔
1796
          }
1797
        })
1798
        if (orPrereq.length > 0) {
2!
1799
          nodeParents.push(orPrereq)
2✔
1800
        }
1801
      }
1802
    })
1803
  }
1804
  parents[hybridNode.id_] = nodeParents
108✔
1805
}
1806

1807
/**
1808
 * Helper for hybrid computation. Finds the node with the same course label as the hybrid.
1809
 * @param  {string} course
1810
 * @param {Array} nodesJSON
1811
 * @return {Node}
1812
 */
1813
export var findRelationship = (course, nodesJSON) => {
15✔
1814
  const nodes = nodesJSON
122✔
1815
  const node = nodes.find(
122✔
1816
    n => n.type_ === "Node" && n.text.some(textTag => textTag.text.includes(course))
342✔
1817
  )
1818
  return node
122✔
1819
}
1820

1821
Graph.propTypes = {
15✔
1822
  currFocus: PropTypes.string,
1823
  edit: PropTypes.bool,
1824
  getLocalGraph: PropTypes.func,
1825
  graphName: PropTypes.string,
1826
  incrementFCECount: PropTypes.func,
1827
  initialDrawMode: PropTypes.string,
1828
  setFCECount: PropTypes.func,
1829
  start_blank: PropTypes.bool,
1830
  fceCount: PropTypes.number,
1831
  graphs: PropTypes.array,
1832
  updateGraph: PropTypes.func,
1833
}
1834

1835
Graph.defaultProps = {
15✔
1836
  currFocus: null,
1837
  graphName: "",
1838
  start_blank: false,
1839
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc