Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

### 🔧 Internal changes

- Extracted the root `<svg>` setup (viewBox calculation, shared filters/markers) of `Graph.js` into a typed `GraphCanvas.tsx` component
- Refactored `react_modal.js.jsx` into modular functional TypeScript components (`CampusMap.tsx`, `CourseModal.tsx`, `DayBox.tsx`, `Description.tsx`, `FocusModal.tsx`, `MapModal.tsx`, `MapSidebar.tsx`), added shared `types.ts`, and removed unused dead code
- Migrated `Sidebar.js`, `FocusBar.js`, and `NavBar.js.jsx` to TypeScript
- Removed `Time'` data type in favour of `(MeetingId -> Times)` function type in `Database.Tables`
Expand Down
99 changes: 9 additions & 90 deletions js/components/graph/Graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Edge from "./Edge"
import Node from "./Node"
import Button from "./Button"
import InfoBox from "./InfoBox"
import GraphCanvas, { NODE_DROPSHADOW_FILTER_ID } from "./GraphCanvas"
import Sidebar from "./Sidebar"
import { parseAnd } from "./requirementParser"
import { findRelationship, numCredits, transformPoint, formatTransform } from "./util"
Expand Down Expand Up @@ -67,7 +68,6 @@ export class Graph extends React.Component {
selectedNodes: new Set(),
}
this.exportModal = React.createRef()
this.nodeDropshadowFilter = "dropshadow"
}

componentDidMount() {
Expand Down Expand Up @@ -516,36 +516,6 @@ export class Graph extends React.Component {
}
}

renderArrowHead = () => {
const polylineAttrs = { points: "0,1 10,5 0,9", fill: "black" }
return (
<defs>
<marker
id="arrowHead"
viewBox="0 0 10 10"
refX="4"
refY="5"
markerUnits="strokeWidth"
markerWidth="7"
markerHeight="7"
orient="auto"
>
<polyline {...polylineAttrs} />
</marker>
</defs>
)
}

renderEllipseBlurFilter = () => {
return (
<defs>
<filter id="blur-filter">
<feGaussianBlur in="SourceGraphic" stdDeviation="2" />
</filter>
</defs>
)
}

/** Zoom into the graph by calculating new viewbox dimensions
*
* @param {number} zoomMode - Determines whether to zoom in, zoom out, or rerender at current zoom level
Expand Down Expand Up @@ -1351,44 +1321,6 @@ export class Graph extends React.Component {
}

render() {
let containerWidth = 0
let containerHeight = 0
if (document.getElementById("react-graph") !== null) {
const reactGraph = document.getElementById("react-graph")
containerWidth = reactGraph.clientWidth
containerHeight = reactGraph.clientHeight
}

let newViewboxWidth = this.state.width * this.state.zoomFactor
let newViewboxHeight = this.state.height * this.state.zoomFactor
if (document.getElementById("generateRoot") !== null) {
newViewboxWidth =
Math.max(this.state.width, containerWidth) * this.state.zoomFactor
newViewboxHeight =
Math.max(this.state.height, containerHeight) * this.state.zoomFactor
}

const viewboxContainerRatio =
containerHeight !== 0 ? newViewboxHeight / containerHeight : 1
const viewboxX =
(this.state.width - newViewboxWidth) / 2 +
this.state.horizontalPanFactor * viewboxContainerRatio
const viewboxY =
(this.state.height - newViewboxHeight) / 2 +
this.state.verticalPanFactor * viewboxContainerRatio

// not all of these properties are supported in React
const svgAttrs = {
height: "100%",
width: "100%",
viewBox: `${viewboxX} ${viewboxY} ${newViewboxWidth} ${newViewboxHeight}`,
preserveAspectRatio: "xMidYMid",
"xmlns:svg": "http://www.w3.org/2000/svg",
"xmlns:dc": "http://purl.org/dc/elements/1.1/",
"xmlns:cc": "http://creativecommons.org/ns#",
"xmlns:rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
}

const resetDisabled =
this.state.zoomFactor === 1 &&
this.state.horizontalPanFactor === 0 &&
Expand Down Expand Up @@ -1493,27 +1425,14 @@ export class Graph extends React.Component {
</div>
)}

<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
id="main-graph"
{...svgAttrs}
version="1.1"
<GraphCanvas
width={this.state.width}
height={this.state.height}
zoomFactor={this.state.zoomFactor}
horizontalPanFactor={this.state.horizontalPanFactor}
verticalPanFactor={this.state.verticalPanFactor}
{...svgMouseEvents}
>
<filter id={this.nodeDropshadowFilter} height="130%">
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
<feOffset dx="2" dy="2" result="offsetblur" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.8" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{this.renderArrowHead()}
{this.renderEllipseBlurFilter()}
{this.renderRegionsLabels(this.state.regionsJSON, this.state.labelsJSON)}

{this.renderBoolGroup(
Expand All @@ -1537,7 +1456,7 @@ export class Graph extends React.Component {
this.state.highlightedNodesFocus,
this.state.highlightedNodesDeps,
this.state.connections,
this.nodeDropshadowFilter
NODE_DROPSHADOW_FILTER_ID
)}

<InfoBox
Expand All @@ -1549,7 +1468,7 @@ export class Graph extends React.Component {
yPos={this.state.infoBoxYPos}
nodeId={this.state.infoBoxNodeId}
/>
</svg>
</GraphCanvas>
</div>
)
}
Expand Down
116 changes: 116 additions & 0 deletions js/components/graph/GraphCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from "react"

/** Element id of the drop shadow filter applied to (non-hybrid) course nodes */
export const NODE_DROPSHADOW_FILTER_ID = "dropshadow"

/**
* Namespace declarations with no effect on inline SVG in the browser, kept so the
* markup is a valid standalone SVG document if serialized (e.g. for export).
* Spread as an object because React's SVG prop types don't declare colon-named attributes.
*/
const EXTRA_NAMESPACE_ATTRS = {
"xmlns:svg": "http://www.w3.org/2000/svg",
"xmlns:dc": "http://purl.org/dc/elements/1.1/",
"xmlns:cc": "http://creativecommons.org/ns#",
"xmlns:rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
}

interface GraphCanvasProps {
width: number
height: number
zoomFactor: number
horizontalPanFactor: number
verticalPanFactor: number
onMouseDown?: React.MouseEventHandler<SVGSVGElement>
onMouseUp?: React.MouseEventHandler<SVGSVGElement>
onMouseMove?: React.MouseEventHandler<SVGSVGElement>
onTouchStart?: React.TouchEventHandler<SVGSVGElement>
children?: React.ReactNode
}

/**
* The root <svg> element of a graph. Computes the viewBox from the current zoom/pan
* state and defines the shared filters and markers referenced by nodes and edges.
*/
export default function GraphCanvas({
width,
height,
zoomFactor,
horizontalPanFactor,
verticalPanFactor,
onMouseDown,
onMouseUp,
onMouseMove,
onTouchStart,
children,
}: GraphCanvasProps) {
let containerWidth = 0
let containerHeight = 0
const reactGraph = document.getElementById("react-graph")
if (reactGraph !== null) {
containerWidth = reactGraph.clientWidth
containerHeight = reactGraph.clientHeight
}

let newViewboxWidth = width * zoomFactor
let newViewboxHeight = height * zoomFactor
if (document.getElementById("generateRoot") !== null) {
newViewboxWidth = Math.max(width, containerWidth) * zoomFactor
newViewboxHeight = Math.max(height, containerHeight) * zoomFactor
}

const viewboxContainerRatio =
containerHeight !== 0 ? newViewboxHeight / containerHeight : 1
const viewboxX =
(width - newViewboxWidth) / 2 + horizontalPanFactor * viewboxContainerRatio
const viewboxY =
(height - newViewboxHeight) / 2 + verticalPanFactor * viewboxContainerRatio

return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
{...EXTRA_NAMESPACE_ATTRS}
version="1.1"
id="main-graph"
width="100%"
height="100%"
viewBox={`${viewboxX} ${viewboxY} ${newViewboxWidth} ${newViewboxHeight}`}
preserveAspectRatio="xMidYMid"
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
onTouchStart={onTouchStart}
>
<defs>
<filter id={NODE_DROPSHADOW_FILTER_ID} height="130%">
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
<feOffset dx="2" dy="2" result="offsetblur" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.8" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<marker
id="arrowHead"
viewBox="0 0 10 10"
refX="4"
refY="5"
markerUnits="strokeWidth"
markerWidth="7"
markerHeight="7"
orient="auto"
>
<polyline points="0,1 10,5 0,9" fill="black" />
</marker>
<filter id="blur-filter">
<feGaussianBlur in="SourceGraphic" stdDeviation="2" />
</filter>
</defs>
{children}
</svg>
)
}
73 changes: 73 additions & 0 deletions js/components/graph/__tests__/GraphCanvas.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from "react"
import { render } from "@testing-library/react"
import GraphCanvas from "../GraphCanvas"

/**
* Creates a #react-graph container (optionally inside a #generateRoot) with the given
* client dimensions, since jsdom does not compute layout.
*/
function createContainer(clientWidth, clientHeight, inGenerate) {
const reactGraph = document.createElement("div")
reactGraph.id = "react-graph"
Object.defineProperty(reactGraph, "clientWidth", { value: clientWidth })
Object.defineProperty(reactGraph, "clientHeight", { value: clientHeight })

if (inGenerate) {
const generateRoot = document.createElement("div")
generateRoot.id = "generateRoot"
generateRoot.appendChild(reactGraph)
document.body.appendChild(generateRoot)
} else {
document.body.appendChild(reactGraph)
}
return reactGraph
}

const canvasProps = {
width: 800,
height: 600,
zoomFactor: 1,
horizontalPanFactor: 10,
verticalPanFactor: 5,
}

describe("GraphCanvas", () => {
afterEach(() => {
document.body.innerHTML = ""
})

it("Should size the viewBox to the graph dimensions", () => {
const container = createContainer(1000, 500, false)
render(<GraphCanvas {...canvasProps} />, { container })

const svg = document.querySelector("#main-graph")
expect(svg.getAttribute("viewBox")).toBe("12 6 800 600")
})

it("Should expand the viewBox to fill the container on the Generate page", () => {
const container = createContainer(1000, 500, true)
render(<GraphCanvas {...canvasProps} />, { container })

const svg = document.querySelector("#main-graph")
expect(svg.getAttribute("viewBox")).toBe("-88 6 1000 600")
})

it("Should not scale panning when there is no #react-graph container", () => {
render(<GraphCanvas {...canvasProps} />)

const svg = document.querySelector("#main-graph")
expect(svg.getAttribute("viewBox")).toBe("10 5 800 600")
})

it("Should render its children inside the svg", () => {
const container = createContainer(1000, 500, false)
render(
<GraphCanvas {...canvasProps}>
<g id="test-child" />
</GraphCanvas>,
{ container }
)

expect(document.querySelector("#main-graph > #test-child")).not.toBeNull()
})
})
Loading