diff --git a/CHANGELOG.md b/CHANGELOG.md index aa52fe19e..71d912726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Restructured `courseography.cabal` to separate executable from library, extract shared settings, and remove `hlint` - Refactored `createReqBody` in `app/Config.hs` to use session field from `config.yaml` - Refactored the `Times` table to `Time` with a database migration +- Extracted `loadGraphData.ts` and `util.ts` (both TypeScript) out of `js/components/graph/Graph.js`, and moved `js/util/util.ts` to `js/components/graph/requirementParser.ts` ## [0.8.1] - 2026-08-10 diff --git a/js/components/generate/GenerateForm.js b/js/components/generate/GenerateForm.js index 0ecce4148..fac0e4289 100644 --- a/js/components/generate/GenerateForm.js +++ b/js/components/generate/GenerateForm.js @@ -3,7 +3,8 @@ import { ErrorMessage, Field, Form, Formik } from "formik" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faWandSparkles } from "@fortawesome/free-solid-svg-icons" import { Tooltip } from "react-tooltip" -import { Graph, populateHybridRelatives } from "../graph/Graph" +import { Graph } from "../graph/Graph" +import { populateHybridRelatives } from "../graph/util" import Disclaimer from "../common/Disclaimer" import { NavBar } from "../common/NavBar" import AutocompleteDropdown from "./AutocompleteDropdown" diff --git a/js/components/graph/Graph.js b/js/components/graph/Graph.js index 2262ea6d4..7ae9489d8 100644 --- a/js/components/graph/Graph.js +++ b/js/components/graph/Graph.js @@ -9,7 +9,9 @@ import Node from "./Node" import Button from "./Button" import InfoBox from "./InfoBox" import Sidebar from "./Sidebar" -import { parseAnd } from "../../util/util" +import { parseAnd } from "./requirementParser" +import { findRelationship, numCredits, transformPoint, formatTransform } from "./util" +import { loadGraphData } from "./loadGraphData" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faMagnifyingGlassMinus, @@ -124,170 +126,7 @@ export class Graph extends React.Component { }) .then(data => { localStorage.setItem("active-graph", graphName) - const labelsJSON = {} - const regionsJSON = {} - const nodesJSON = {} - const nodesStatus = {} - const hybridsJSON = {} - const boolsJSON = {} - const boolsStatusObj = {} - const edgesJSON = {} - const parentsObj = {} - const inEdgesObj = {} - const childrenObj = {} - const outEdgesObj = {} - const storedNodes = new Set() - - data.texts.forEach(entry => { - if (entry.rId.startsWith("tspan")) { - labelsJSON[entry.rId] = entry - } - }) - - data.shapes.forEach(function (entry) { - if (entry.type_ === "Node") { - nodesJSON[entry.id_] = entry - } else if (entry.type_ === "Hybrid") { - hybridsJSON[entry.id_] = entry - } else if (entry.type_ === "BoolNode") { - boolsStatusObj[entry.id_] = localStorage.getItem(entry.id_) || "inactive" - boolsJSON[entry.id_] = entry - } - }) - - data.paths.forEach(function (entry) { - if (entry.isRegion) { - regionsJSON[entry.id_] = entry - } else { - edgesJSON[entry.id_] = entry - } - }) - - // The duplicate filter is a temporary fix, as previously there were two nodes, Hybrid and Node, - // that were placed in the same spot on some graphs where there should be only a Hybrid node. - const noDuplicatesNodesJSON = {} - Object.values(nodesJSON).forEach(node => { - if (!(node.id_ in parentsObj)) { - parentsObj[node.id_] = [] - inEdgesObj[node.id_] = [] - childrenObj[node.id_] = [] - outEdgesObj[node.id_] = [] - // Quickly adding any active nodes from local storage into the selected nodes - if (localStorage.getItem(node.id_) === "active") { - storedNodes.add(node.text[node.text.length - 1].text) - } - - noDuplicatesNodesJSON[node.id_] = node - } - }) - - Object.values(hybridsJSON).forEach(hybrid => { - childrenObj[hybrid.id_] = [] - outEdgesObj[hybrid.id_] = [] - const nodesList = Object.values(nodesJSON) - populateHybridRelatives(hybrid, nodesList, parentsObj, childrenObj) - let state = localStorage.getItem(hybrid.id_) - if (state === null) { - state = parentsObj[hybrid.id_].length === 0 ? "takeable" : "inactive" - } - nodesStatus[hybrid.id_] = { - status: state, - selected: ["active", "overridden"].indexOf(state) >= 0, - } - }) - - Object.values(edgesJSON).forEach(edge => { - if (edge.target in parentsObj && edge.target in inEdgesObj) { - parentsObj[edge.target].push(edge.source) - inEdgesObj[edge.target].push(edge.id_) - } - - if (edge.source in childrenObj && edge.source in outEdgesObj) { - childrenObj[edge.source].push(edge.target) - outEdgesObj[edge.source].push(edge.id_) - } - }) - - Object.keys(boolsJSON).forEach(boolId => { - const parents = [] - const childs = [] - const outEdges = [] - const inEdges = [] - Object.values(edgesJSON).forEach(edge => { - if (boolId === edge.target) { - parents.push(edge.source) - inEdges.push(edge.id_) - } else if (boolId === edge.source) { - childs.push(edge.target) - outEdges.push(edge.id_) - } - }) - parentsObj[boolId] = parents - childrenObj[boolId] = childs - outEdgesObj[boolId] = outEdges - inEdgesObj[boolId] = inEdges - }) - - Object.keys(noDuplicatesNodesJSON).forEach(nodeId => { - let state = localStorage.getItem(nodeId) - if (state === null) { - state = parentsObj[nodeId].length === 0 ? "takeable" : "inactive" - } - nodesStatus[nodeId] = { - status: state, - selected: ["active", "overridden"].indexOf(state) >= 0, - } - }) - - const edgesStatus = Object.values(edgesJSON).reduce((acc, curr) => { - const source = curr.source - const target = curr.target - let status - const isSourceSelected = - nodesStatus[source]?.selected || boolsStatusObj[source] === "active" - - const isTargetSelected = - nodesStatus[target]?.selected || boolsStatusObj[target] === "active" - - const targetStatus = nodesStatus[target]?.status || boolsStatusObj[target] - - if (!isSourceSelected && targetStatus === "missing") { - status = "missing" - } else if (!isSourceSelected) { - status = "inactive" - } else if (!isTargetSelected) { - status = "takeable" - } else { - status = "active" - } - acc[curr.id_] = status - return acc - }, {}) - - this.setState({ - labelsJSON: labelsJSON, - regionsJSON: regionsJSON, - nodesJSON: noDuplicatesNodesJSON, - hybridsJSON: hybridsJSON, - boolsJSON: boolsJSON, - boolsStatus: boolsStatusObj, - edgesJSON: edgesJSON, - width: data.width, - height: data.height, - zoomFactor: 1, - horizontalPanFactor: 0, - verticalPanFactor: 0, - graphName: graphName, - edgesStatus: edgesStatus, - connections: { - parents: parentsObj, - inEdges: inEdgesObj, - children: childrenObj, - outEdges: outEdgesObj, - }, - nodesStatus: nodesStatus, - selectedNodes: storedNodes, - }) + this.setState({ ...loadGraphData(data), graphName: graphName }) }) .catch(err => { console.error("Fetch API failed. Here are the headers: ") @@ -303,7 +142,7 @@ export class Graph extends React.Component { !this.state.hybridsJSON[nodeJSON.id_] && this.state.nodesStatus[nodeJSON.id_].selected ) { - totalFCEs += this.numCredits(nodeJSON.id_) + totalFCEs += numCredits(nodeJSON.id_) } }) if (this.props.setFCECount) { @@ -378,7 +217,7 @@ export class Graph extends React.Component { const courseLabel = courseLabelArray[courseLabelArray.length - 1].text const wasSelected = this.state.nodesStatus[courseId].selected const temp = [...this.state.selectedNodes] - const credits = this.numCredits(courseId) + const credits = numCredits(courseId) this.toggleSelection(courseId) if (typeof this.props.incrementFCECount === "function") { @@ -397,7 +236,7 @@ export class Graph extends React.Component { const courseLabel = courseLabelArray[courseLabelArray.length - 1].text const wasSelected = this.state.nodesStatus[courseId].selected const temp = this.state.selectedNodes - const credits = this.numCredits(courseId) + const credits = numCredits(courseId) if (typeof this.props.incrementFCECount === "function" && wasSelected) { this.toggleSelection(courseId) @@ -406,31 +245,6 @@ export class Graph extends React.Component { } } - /** - * Assuming the first section of courseId follows the format: - * 3 letters for department, 3 numbers for course, and characters for session and campus. - * e.g. csc108h1 or mat235y1mat237y1mat257y1 - */ - numCredits = courseId => { - let session = courseId[6] - return typeof session === "string" && session.toLowerCase() === "y" ? 1.0 : 0.5 - } - - /** - * Transforms and with the given transformation. - * Assumes that transformation is a valid list [a,b,c,d,e,f] of length 6 that represents the - * transformation matrix [[a, c, e], [b, d, f], [0, 0, 1]] - * @param {list} transformation - a list that represents a matrix transformation - * @param {float} x - the x coordinate to be transformed - * @param {float} y - the y coordinate to be transformed - */ - transformPoint = (transformation, x, y) => { - const [a, b, c, d, e, f] = transformation - const newX = a * x + c * y + e - const newY = b * x + d * y + f - return [newX, newY] - } - /** * Drawing mode is not implemented, meaning the onDraw defaults to false right now. */ @@ -463,7 +277,7 @@ export class Graph extends React.Component { yPos = parseFloat(yPos) if (currentNode.transform) { - ;[xPos, yPos] = this.transformPoint(currentNode.transform, xPos, yPos) + ;[xPos, yPos] = transformPoint(currentNode.transform, xPos, yPos) } if (!this.state.onDraw) { @@ -1349,7 +1163,7 @@ export class Graph extends React.Component { const { parents } = connections const json = { ...boolJSON, - transform: this.formatTransform(boolJSON.transform), + transform: formatTransform(boolJSON.transform), } return ( @@ -1385,7 +1199,7 @@ export class Graph extends React.Component { target={edgeJSON.target} points={edgeJSON.points} status={edgesStatus[edgeJSON.id_]} - transform={this.formatTransform(edgeJSON.transform)} + transform={formatTransform(edgeJSON.transform)} /> ) } @@ -1451,7 +1265,7 @@ export class Graph extends React.Component { parents={connections.parents[entry.id_]} childs={connections.children[entry.id_]} status={nodesStatus[entry.id_].status} - transform={this.formatTransform(entry.transform)} + transform={formatTransform(entry.transform)} onWheel={onWheel} onKeydown={onKeyDown} nodeDropshadowFilter={nodeDropshadowFilter} @@ -1474,7 +1288,7 @@ export class Graph extends React.Component { hybrid={false} parents={connections.parents[entry.id_]} status={nodesStatus[entry.id_].status} - transform={this.formatTransform(entry.transform)} + transform={formatTransform(entry.transform)} highlightDeps={highlightDeps} highlightFocus={highlightFocus} onClick={nodeClick} @@ -1519,7 +1333,7 @@ export class Graph extends React.Component { style={textStyle} className="region-label" textAnchor={entry["text-anchor"]} - transform={this.formatTransform(entry.transform)} + transform={formatTransform(entry.transform)} > {entry["text"]} @@ -1536,15 +1350,6 @@ export class Graph extends React.Component { ) } - /** - * Formats fransformation to a valid CSS transform value. - * Assumes that is a list of length 6 that represents a matrix transformation if it exists, - * that is, [a, b, c, d, e, f] => matrix(a, b, c, d, e, f) - */ - formatTransform = transform => { - return transform ? `matrix(${transform.join(", ")})` : "" - } - render() { let containerWidth = 0 let containerHeight = 0 @@ -1752,65 +1557,6 @@ export class Graph extends React.Component { export { ZOOM_INCREMENT, KEYBOARD_PANNING_INCREMENT } -/** - * Helper function to populate the parents object and children object with the connections of a hybrid node. - * A parent-child connection is added for each course that appears in the hybrid node's prereq string. - * @param {Node} hybridNode - * @param {Array} nodesJSON - * @param {Object} parentsObj - * @param {Object} childrenObj - */ -export function populateHybridRelatives( - hybridNode, - nodesJSON, - parentsObj, - childrenObj -) { - // parse prereqs based on text - let hybridText = hybridNode.text.map(textTag => textTag.text).join("") - const nodeParents = [] - // First search for a node that matches the entire string (see Stats graph) - let prereqNode = findRelationship(hybridText, nodesJSON) - if (prereqNode !== undefined) { - nodeParents.push(prereqNode.id_) - childrenObj[prereqNode.id_].push(hybridNode.id_) - } - // Otherwise, parse the hybrid node's text as a prerequisite string of multiple courses, - // and add a parent-child connection for each involved course - else { - let prereqs = parseAnd(hybridText) - if (typeof prereqs === "object") { - prereqs = prereqs.flat(Infinity) - prereqs.forEach(course => { - prereqNode = findRelationship(course, nodesJSON) - if (prereqNode !== undefined) { - nodeParents.push(prereqNode.id_) - childrenObj[prereqNode.id_].push(hybridNode.id_) - } else { - console.error("Could not find prereq for ", hybridText) - } - }) - } else { - console.error("Could not find prereq for ", hybridText) - } - } - parentsObj[hybridNode.id_] = nodeParents -} - -/** - * Helper for hybrid computation. Finds the node with the same course label as the hybrid. - * @param {string} course - * @param {Array} nodesJSON - * @return {Node} - */ -export var findRelationship = (course, nodesJSON) => { - const nodes = nodesJSON - const node = nodes.find( - n => n.type_ === "Node" && n.text.some(textTag => textTag.text.includes(course)) - ) - return node -} - Graph.propTypes = { currFocus: PropTypes.string, edit: PropTypes.bool, diff --git a/js/components/graph/Node.tsx b/js/components/graph/Node.tsx index f8fc0560b..bfade01f4 100644 --- a/js/components/graph/Node.tsx +++ b/js/components/graph/Node.tsx @@ -8,7 +8,7 @@ interface NodeProps { highlightDeps?: boolean highlightFocus?: boolean hybrid: boolean - JSON: GraphNodeJSON & { id_: string } + JSON: GraphNodeJSON onClick?: () => void onMouseEnter?: () => void onMouseLeave?: () => void diff --git a/js/components/graph/__tests__/Bool.test.js b/js/components/graph/__tests__/Bool.test.js index a6d9d8ef3..8bfa114b6 100644 --- a/js/components/graph/__tests__/Bool.test.js +++ b/js/components/graph/__tests__/Bool.test.js @@ -24,7 +24,7 @@ describe("AND Bool", () => { fill: "", graph: 1, height: 14.7368002, - id: "bool1", + id_: "bool1", pos: [1020.015148, 243.51310800000002], stroke: "", text: [ @@ -140,7 +140,7 @@ describe("OR Bool", () => { fill: "", graph: 1, height: 14.7368002, - id: "bool1", + id_: "bool1", pos: [1020.015148, 243.51310800000002], stroke: "", text: [ diff --git a/js/components/graph/__tests__/__snapshots__/Bool.test.js.snap b/js/components/graph/__tests__/__snapshots__/Bool.test.js.snap index 79c41572f..9a82beb79 100644 --- a/js/components/graph/__tests__/__snapshots__/Bool.test.js.snap +++ b/js/components/graph/__tests__/__snapshots__/Bool.test.js.snap @@ -7,7 +7,7 @@ exports[`AND Bool should match snapshot 1`] = ` fill="" graph="1" height="14.7368002" - id="bool1" + id_="bool1" pos="1020.015148,243.51310800000002" stroke="" text="[object Object]" @@ -36,7 +36,7 @@ exports[`OR Bool should match snapshot 1`] = ` fill="" graph="1" height="14.7368002" - id="bool1" + id_="bool1" pos="1020.015148,243.51310800000002" stroke="" text="[object Object]" diff --git a/js/components/graph/__tests__/findRelationship.test.js b/js/components/graph/__tests__/findRelationship.test.js index b23866b68..2c518be20 100644 --- a/js/components/graph/__tests__/findRelationship.test.js +++ b/js/components/graph/__tests__/findRelationship.test.js @@ -1,4 +1,4 @@ -import { findRelationship } from "../Graph.js" +import { findRelationship } from "../util" describe("findRelationship", () => { describe("nodesJSON contains a node for CSC207", () => { diff --git a/js/components/graph/__tests__/populateHybridRelatives.test.js b/js/components/graph/__tests__/populateHybridRelatives.test.js index 29d7cfbfb..33aa0da65 100644 --- a/js/components/graph/__tests__/populateHybridRelatives.test.js +++ b/js/components/graph/__tests__/populateHybridRelatives.test.js @@ -1,4 +1,4 @@ -import { populateHybridRelatives } from "../Graph.js" +import { populateHybridRelatives } from "../util" describe("populateHybridRelatives", () => { let hybridNode diff --git a/js/util/parse.test.js b/js/components/graph/__tests__/requirementParser.test.js similarity index 97% rename from js/util/parse.test.js rename to js/components/graph/__tests__/requirementParser.test.js index 8fd010e63..93dbddd95 100644 --- a/js/util/parse.test.js +++ b/js/components/graph/__tests__/requirementParser.test.js @@ -1,5 +1,10 @@ -// Tests for parsing algorithms from js/util/util.ts -import { parseAnd, parseOr, splitPrereqString, removeOuterParens } from "./util" +// Tests for parsing algorithms from js/components/graph/requirementParser.ts +import { + parseAnd, + parseOr, + splitPrereqString, + removeOuterParens, +} from "../requirementParser" describe("parseAnd", () => { test("parseAnd correctly parses courses when a comma separates two couress", () => { diff --git a/js/components/graph/loadGraphData.ts b/js/components/graph/loadGraphData.ts new file mode 100644 index 000000000..e97095ea3 --- /dev/null +++ b/js/components/graph/loadGraphData.ts @@ -0,0 +1,234 @@ +import { populateHybridRelatives } from "./util" +import { GraphNodeJSON } from "./types" + +interface GraphLabelEntry { + rId: string +} + +interface GraphEdgeEntry { + id_: string + isRegion?: false + source: string + target: string +} + +interface GraphRegionEntry { + id_: string + isRegion: true +} + +type GraphPathEntry = GraphEdgeEntry | GraphRegionEntry + +interface GraphRawData { + texts: GraphLabelEntry[] + shapes: (GraphNodeJSON & { type_: string })[] + paths: GraphPathEntry[] + width: number + height: number +} + +export interface GraphConnections { + parents: Record + inEdges: Record + children: Record + outEdges: Record +} + +export interface GraphNodeStatus { + status: string + selected: boolean +} + +export interface LoadedGraphData { + labelsJSON: Record + regionsJSON: Record + nodesJSON: Record + hybridsJSON: Record + boolsJSON: Record + boolsStatus: Record + edgesJSON: Record + width: number + height: number + zoomFactor: number + horizontalPanFactor: number + verticalPanFactor: number + edgesStatus: Record + connections: GraphConnections + nodesStatus: Record + selectedNodes: Set +} + +/** + * Parses the raw graph JSON fetched from the server into the normalized shapes + * (nodes, edges, bools, connections, and their statuses) that Graph keeps in state. + * @param data the raw JSON fetched from `/get-json-data` + * @return the parsed data, ready to be merged into Graph's state + */ +export function loadGraphData(data: GraphRawData): LoadedGraphData { + const labelsJSON: Record = {} + const regionsJSON: Record = {} + const nodesJSON: Record = {} + const nodesStatus: Record = {} + const hybridsJSON: Record = {} + const boolsJSON: Record = {} + const boolsStatusObj: Record = {} + const edgesJSON: Record = {} + const parentsObj: Record = {} + const inEdgesObj: Record = {} + const childrenObj: Record = {} + const outEdgesObj: Record = {} + const storedNodes: Set = new Set() + + data.texts.forEach((entry: GraphLabelEntry) => { + if (entry.rId.startsWith("tspan")) { + labelsJSON[entry.rId] = entry + } + }) + + data.shapes.forEach(function (entry: GraphNodeJSON & { type_: string }) { + if (entry.type_ === "Node") { + nodesJSON[entry.id_] = entry + } else if (entry.type_ === "Hybrid") { + hybridsJSON[entry.id_] = entry + } else if (entry.type_ === "BoolNode") { + boolsStatusObj[entry.id_] = localStorage.getItem(entry.id_) || "inactive" + boolsJSON[entry.id_] = entry + } + }) + + data.paths.forEach(function (entry: GraphPathEntry) { + if (entry.isRegion) { + regionsJSON[entry.id_] = entry + } else { + edgesJSON[entry.id_] = entry + } + }) + + // The duplicate filter is a temporary fix, as previously there were two nodes, Hybrid and Node, + // that were placed in the same spot on some graphs where there should be only a Hybrid node. + const noDuplicatesNodesJSON: Record = {} + Object.values(nodesJSON).forEach((node: GraphNodeJSON) => { + if (!(node.id_ in parentsObj)) { + parentsObj[node.id_] = [] + inEdgesObj[node.id_] = [] + childrenObj[node.id_] = [] + outEdgesObj[node.id_] = [] + // Quickly adding any active nodes from local storage into the selected nodes + if (localStorage.getItem(node.id_) === "active") { + storedNodes.add(node.text[node.text.length - 1].text) + } + + noDuplicatesNodesJSON[node.id_] = node + } + }) + + Object.values(hybridsJSON).forEach((hybrid: GraphNodeJSON) => { + childrenObj[hybrid.id_] = [] + outEdgesObj[hybrid.id_] = [] + const nodesList = Object.values(nodesJSON) + populateHybridRelatives(hybrid, nodesList, parentsObj, childrenObj) + let state = localStorage.getItem(hybrid.id_) + if (state === null) { + state = parentsObj[hybrid.id_].length === 0 ? "takeable" : "inactive" + } + nodesStatus[hybrid.id_] = { + status: state, + selected: ["active", "overridden"].indexOf(state) >= 0, + } + }) + + Object.values(edgesJSON).forEach((edge: GraphEdgeEntry) => { + if (edge.target in parentsObj && edge.target in inEdgesObj) { + parentsObj[edge.target].push(edge.source) + inEdgesObj[edge.target].push(edge.id_) + } + + if (edge.source in childrenObj && edge.source in outEdgesObj) { + childrenObj[edge.source].push(edge.target) + outEdgesObj[edge.source].push(edge.id_) + } + }) + + Object.keys(boolsJSON).forEach((boolId: string) => { + const parents: string[] = [] + const childs: string[] = [] + const outEdges: string[] = [] + const inEdges: string[] = [] + Object.values(edgesJSON).forEach((edge: GraphEdgeEntry) => { + if (boolId === edge.target) { + parents.push(edge.source) + inEdges.push(edge.id_) + } else if (boolId === edge.source) { + childs.push(edge.target) + outEdges.push(edge.id_) + } + }) + parentsObj[boolId] = parents + childrenObj[boolId] = childs + outEdgesObj[boolId] = outEdges + inEdgesObj[boolId] = inEdges + }) + + Object.keys(noDuplicatesNodesJSON).forEach((nodeId: string) => { + let state = localStorage.getItem(nodeId) + if (state === null) { + state = parentsObj[nodeId].length === 0 ? "takeable" : "inactive" + } + nodesStatus[nodeId] = { + status: state, + selected: ["active", "overridden"].indexOf(state) >= 0, + } + }) + + const edgesStatus: Record = Object.values(edgesJSON).reduce( + (acc: Record, curr: GraphEdgeEntry) => { + const source = curr.source + const target = curr.target + let status: string + const isSourceSelected = + nodesStatus[source]?.selected || boolsStatusObj[source] === "active" + + const isTargetSelected = + nodesStatus[target]?.selected || boolsStatusObj[target] === "active" + + const targetStatus = nodesStatus[target]?.status || boolsStatusObj[target] + + if (!isSourceSelected && targetStatus === "missing") { + status = "missing" + } else if (!isSourceSelected) { + status = "inactive" + } else if (!isTargetSelected) { + status = "takeable" + } else { + status = "active" + } + acc[curr.id_] = status + return acc + }, + {} as Record + ) + + return { + labelsJSON, + regionsJSON, + nodesJSON: noDuplicatesNodesJSON, + hybridsJSON, + boolsJSON, + boolsStatus: boolsStatusObj, + edgesJSON, + width: data.width, + height: data.height, + zoomFactor: 1, + horizontalPanFactor: 0, + verticalPanFactor: 0, + edgesStatus, + connections: { + parents: parentsObj, + inEdges: inEdgesObj, + children: childrenObj, + outEdges: outEdgesObj, + }, + nodesStatus, + selectedNodes: storedNodes, + } +} diff --git a/js/util/util.ts b/js/components/graph/requirementParser.ts similarity index 100% rename from js/util/util.ts rename to js/components/graph/requirementParser.ts diff --git a/js/components/graph/types.ts b/js/components/graph/types.ts index 3fafe0fa7..0d9857a0e 100644 --- a/js/components/graph/types.ts +++ b/js/components/graph/types.ts @@ -11,7 +11,8 @@ export interface GraphTextTag { /** The SVG shape data for a graph node or boolean (and/or) node. */ export interface GraphNodeJSON { - id_?: string + id_: string + type_?: string pos: [number, number] width: number height: number diff --git a/js/components/graph/util.ts b/js/components/graph/util.ts new file mode 100644 index 000000000..8aff0edc2 --- /dev/null +++ b/js/components/graph/util.ts @@ -0,0 +1,102 @@ +import { parseAnd } from "./requirementParser" +import { GraphNodeJSON } from "./types" + +/** + * Helper function to populate `parentsObj` and `childrenObj` with the connections of a hybrid node. + * A parent-child connection is added for each course that appears in `hybridNode`'s prereq string. + * @param hybridNode + * @param nodesJSON + * @param parentsObj + * @param childrenObj + */ +export function populateHybridRelatives( + hybridNode: GraphNodeJSON, + nodesJSON: GraphNodeJSON[], + parentsObj: Record, + childrenObj: Record +): void { + // parse prereqs based on text + let hybridText = hybridNode.text.map(textTag => textTag.text).join("") + const nodeParents: string[] = [] + // First search for a node that matches the entire string (see Stats graph) + let prereqNode = findRelationship(hybridText, nodesJSON) + if (prereqNode !== undefined) { + nodeParents.push(prereqNode.id_) + childrenObj[prereqNode.id_].push(hybridNode.id_) + } else { + // Otherwise, parse the hybrid node's text as a prerequisite string of multiple courses, + // and add a parent-child connection for each involved course + const prereqs = parseAnd(hybridText) + if (typeof prereqs === "object") { + // Cast away the recursive `PrereqTree` type before flattening — TypeScript's + // type-level recursion for `.flat(Infinity)` on a self-referential array type + // blows up ("Type instantiation is excessively deep"). + const flatPrereqs = (prereqs as unknown[]).flat(Infinity) as string[] + flatPrereqs.forEach(course => { + prereqNode = findRelationship(course, nodesJSON) + if (prereqNode !== undefined) { + nodeParents.push(prereqNode.id_) + childrenObj[prereqNode.id_].push(hybridNode.id_) + } else { + console.error("Could not find prereq for ", hybridText) + } + }) + } else { + console.error("Could not find prereq for ", hybridText) + } + } + parentsObj[hybridNode.id_] = nodeParents +} + +/** + * Helper for hybrid computation. Finds the node in `nodesJSON` that matches the given course. + * @param course + * @param nodesJSON + * @return the matching node, or undefined if none is found. + */ +export function findRelationship( + course: string, + nodesJSON: GraphNodeJSON[] +): GraphNodeJSON | undefined { + return nodesJSON.find( + n => n.type_ === "Node" && n.text.some(textTag => textTag.text.includes(course)) + ) +} + +/** + * Assuming the first section of `courseId` follows the format: + * 3 letters for department, 3 numbers for course, and characters for credit type and campus. + * e.g. csc108h1 or mat235y1mat237y1mat257y1 + */ +export function numCredits(courseId: string): number { + const creditType = courseId[6] + return typeof creditType === "string" && creditType.toLowerCase() === "y" ? 1.0 : 0.5 +} + +/** + * Transforms `x` and `y` with the given `transformation`. + * Assumes that transformation is a valid list [a,b,c,d,e,f] of length 6 that represents the + * transformation matrix [[a, c, e], [b, d, f], [0, 0, 1]] + * @param transformation - a list that represents a matrix transformation + * @param x - the x coordinate to be transformed + * @param y - the y coordinate to be transformed + */ +export function transformPoint( + transformation: number[], + x: number, + y: number +): [number, number] { + const [a, b, c, d, e, f] = transformation + const newX = a * x + c * y + e + const newY = b * x + d * y + f + return [newX, newY] +} + +/** + * Formats transformation to a valid CSS transform value. + * Assumes that `transform` is a list of length 6 that represents a matrix transformation if it exists, + * that is, [a, b, c, d, e, f] => matrix(a, b, c, d, e, f) + */ +export function formatTransform(transform: number[] | undefined): string { + return transform ? `matrix(${transform.join(", ")})` : "" +}