├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── cypress.json
├── cypress
├── fixtures
│ └── example.json
├── integration
│ ├── AddCourseDialog
│ │ ├── AddCourseTextSearch.spec.js
│ │ ├── CustomCourseForm.spec.js
│ │ └── UwCourseForm.spec.js
│ ├── App
│ │ ├── ContextMenu.spec.js
│ │ └── CourseNode.spec.js
│ ├── EditDataDialog
│ │ └── EditDataDialog.spec.js
│ ├── NewFlowDialog
│ │ ├── CurriculumSelect.spec.js
│ │ ├── DegreeSelect.spec.js
│ │ ├── NewBlankFlow.spec.js
│ │ └── NewFlowTextSearch.spec.js
│ ├── TableDialog
│ │ └── TableDialog.spec.js
│ └── utils.js
├── plugins
│ └── index.js
└── support
│ ├── commands.js
│ └── index.js
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── browserconfig.xml
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── mstile-144x144.png
├── mstile-150x150.png
├── mstile-310x150.png
├── mstile-310x310.png
├── mstile-70x70.png
├── robots.txt
├── safari-pinned-tab.svg
└── site.webmanifest
├── src
├── App.scss
├── App.tsx
├── _utils.scss
├── components
│ ├── ContextMenu
│ │ ├── ConditionalNodesOpts.tsx
│ │ ├── CourseNodeOpts.tsx
│ │ ├── PaneOpts.tsx
│ │ ├── index.scss
│ │ ├── index.tsx
│ │ └── types.d.ts
│ ├── UserControls.scss
│ ├── UserControls.tsx
│ ├── dialogs
│ │ ├── AboutDialog.scss
│ │ ├── AboutDialog.tsx
│ │ ├── AddCourseDialog
│ │ │ ├── AddCourseTextSearch.scss
│ │ │ ├── AddCourseTextSearch.tsx
│ │ │ ├── CustomCourseForm.scss
│ │ │ ├── CustomCourseForm.tsx
│ │ │ ├── UwCourseForm.scss
│ │ │ ├── UwCourseForm.tsx
│ │ │ ├── index.scss
│ │ │ ├── index.tsx
│ │ │ └── types.d.ts
│ │ ├── AmbiguitySelect.scss
│ │ ├── AmbiguitySelect.tsx
│ │ ├── ArchiveDialog.scss
│ │ ├── ArchiveDialog.tsx
│ │ ├── CampusSelect.scss
│ │ ├── CampusSelect.tsx
│ │ ├── EditDataDialog.scss
│ │ ├── EditDataDialog.tsx
│ │ ├── ModalDialog
│ │ │ ├── CloseButton.scss
│ │ │ ├── CloseButton.tsx
│ │ │ └── index.tsx
│ │ ├── NewFlowDialog
│ │ │ ├── CurriculumSelect.scss
│ │ │ ├── CurriculumSelect.tsx
│ │ │ ├── DegreeSelect.scss
│ │ │ ├── DegreeSelect.tsx
│ │ │ ├── NewFlowTextSearch.scss
│ │ │ ├── NewFlowTextSearch.tsx
│ │ │ ├── PreWarning.scss
│ │ │ ├── PreWarning.tsx
│ │ │ ├── index.scss
│ │ │ ├── index.tsx
│ │ │ └── types.d.ts
│ │ ├── OpenFileDialog
│ │ │ ├── Dropzone.tsx
│ │ │ ├── index.scss
│ │ │ └── index.tsx
│ │ ├── TableDialog.scss
│ │ └── TableDialog.tsx
│ ├── flow
│ │ ├── AndNode.tsx
│ │ ├── CourseNode.tsx
│ │ ├── CustomEdge.tsx
│ │ ├── FlowInternalLifter.tsx
│ │ └── OrNode.tsx
│ └── header
│ │ ├── Header.scss
│ │ ├── Header.tsx
│ │ └── HeaderButton.tsx
├── dagre
│ ├── acyclic.js
│ ├── add-border-segments.js
│ ├── coordinate-system.js
│ ├── data
│ │ └── list.js
│ ├── debug.js
│ ├── greedy-fas.js
│ ├── index.js
│ ├── layout.js
│ ├── nesting-graph.js
│ ├── normalize.js
│ ├── order
│ │ ├── add-subgraph-constraints.js
│ │ ├── barycenter.js
│ │ ├── build-layer-graph.js
│ │ ├── cross-count.js
│ │ ├── index.js
│ │ ├── init-order.js
│ │ ├── resolve-conflicts.js
│ │ ├── sort-subgraph.js
│ │ └── sort.js
│ ├── parent-dummy-chains.js
│ ├── position
│ │ ├── bk.js
│ │ └── index.js
│ ├── rank
│ │ ├── feasible-tree.js
│ │ ├── index.js
│ │ ├── network-simplex.js
│ │ └── util.js
│ ├── util.js
│ └── version.js
├── data
│ ├── demo-flow.json
│ ├── final_majors.json
│ ├── final_seattle_courses.json
│ └── final_seattle_curricula.json
├── graphlib
│ ├── alg
│ │ ├── components.js
│ │ ├── dfs.js
│ │ ├── dijkstra-all.js
│ │ ├── dijkstra.js
│ │ ├── find-cycles.js
│ │ ├── floyd-warshall.js
│ │ ├── index.js
│ │ ├── is-acyclic.js
│ │ ├── postorder.js
│ │ ├── preorder.js
│ │ ├── prim.js
│ │ ├── tarjan.js
│ │ └── topsort.js
│ ├── data
│ │ └── priority-queue.js
│ ├── graph.js
│ ├── index.js
│ ├── json.js
│ └── utils.js
├── icons
│ ├── chevron-right.svg
│ ├── envelope.svg
│ ├── github.svg
│ ├── plus.svg
│ ├── question.svg
│ ├── table.svg
│ ├── times.svg
│ ├── triangle.svg
│ ├── x-black.svg
│ └── x-gray.svg
├── index.scss
├── index.tsx
├── source-sans
│ ├── sourcesanspro-bold-webfont.woff
│ ├── sourcesanspro-bold-webfont.woff2
│ ├── sourcesanspro-boldit-webfont.woff
│ ├── sourcesanspro-boldit-webfont.woff2
│ ├── sourcesanspro-it-webfont.woff
│ ├── sourcesanspro-it-webfont.woff2
│ ├── sourcesanspro-regular-webfont.woff
│ └── sourcesanspro-regular-webfont.woff2
├── state.ts
├── tests
│ ├── test-course-data.json
│ ├── test-flow.json
│ ├── test-flow.png
│ └── utils.test.js
├── useDialogStatus.tsx
├── usePrefersReducedMotion.tsx
├── utils.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── types
├── beta.d.ts
├── beta1.d.ts
├── beta2.d.ts
└── main.d.ts
└── vite.config.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | src/graphlib/**/*
2 | src/dagre/**/*
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | },
5 | settings: {
6 | "import/resolver": {
7 | typescript: {},
8 | },
9 | },
10 | extends: ["plugin:react/recommended", "airbnb"],
11 | parserOptions: {
12 | ecmaFeatures: {
13 | jsx: true,
14 | },
15 | ecmaVersion: "latest",
16 | sourceType: "module",
17 | },
18 | plugins: ["react"],
19 | rules: {
20 | "no-continue": 0,
21 | "no-else-return": 0,
22 | "no-param-reassign": [2, { props: false }],
23 | "no-plusplus": [2, { allowForLoopAfterthoughts: true }],
24 | "no-restricted-syntax": [
25 | 2,
26 | "ForInStatement",
27 | "LabeledStatement",
28 | "WithStatement",
29 | ],
30 | "no-underscore-dangle": 0,
31 |
32 | "import/extensions": [
33 | 2,
34 | "never",
35 | {
36 | json: "always",
37 | svg: "always",
38 | },
39 | ],
40 | "import/no-default-export": 2,
41 | "import/prefer-default-export": 0,
42 | },
43 | overrides: [
44 | {
45 | files: ["*.ts?(x)"],
46 | extends: ["plugin:@typescript-eslint/recommended"],
47 | parser: "@typescript-eslint/parser",
48 | plugins: ["@typescript-eslint"],
49 | rules: {
50 | "@typescript-eslint/consistent-type-imports": 2,
51 | "@typescript-eslint/explicit-function-return-type": [
52 | 2,
53 | { allowExpressions: true },
54 | ],
55 | "no-empty-function": 0,
56 | "@typescript-eslint/no-empty-function": 0,
57 | "@typescript-eslint/no-non-null-assertion": 0,
58 | "@typescript-eslint/no-inferrable-types": 0,
59 | "no-unused-vars": 0,
60 | "@typescript-eslint/no-unused-vars": [
61 | 2,
62 | {
63 | varsIgnorePattern: "^_",
64 | argsIgnorePattern: "^_",
65 | },
66 | ],
67 | },
68 | },
69 | {
70 | files: ["*.tsx"],
71 | rules: {
72 | "import/no-default-export": 0,
73 | "import/prefer-default-export": 2,
74 |
75 | "react/jsx-boolean-value": [2, "always"],
76 | "react/jsx-filename-extension": [2, { extensions: [".tsx"] }],
77 | "react/jsx-no-bind": 0,
78 | "react/no-unescaped-entities": 0,
79 | "react/react-in-jsx-scope": 0,
80 | "react/require-default-props": 0,
81 | "react/self-closing-comp": [2, { component: true, html: false }],
82 |
83 | "jsx-a11y/label-has-associated-control": [2, { assert: "either" }],
84 | },
85 | },
86 | {
87 | files: ["src/components/ContextMenu/*.tsx"],
88 | rules: {
89 | "react/destructuring-assignment": 0,
90 | "jsx-a11y/click-events-have-key-events": 0,
91 | "jsx-a11y/no-noninteractive-element-interactions": 0,
92 | },
93 | },
94 | {
95 | files: ["src/tests/*.test.js"],
96 | rules: {
97 | "no-unused-expressions": 0,
98 | },
99 | },
100 | {
101 | files: ["cypress/**/*.js"],
102 | extends: ["plugin:cypress/recommended"],
103 | plugins: ["cypress"],
104 | },
105 | {
106 | files: ["*.js?(x)", "*.ts?(x)"],
107 | extends: ["plugin:prettier/recommended"],
108 | plugins: ["prettier"],
109 | rules: {
110 | "arrow-body-style": [2, "as-needed"],
111 | curly: [2, "all"],
112 | },
113 | },
114 | ],
115 | };
116 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /dist/
3 | /coverage/
4 | /ignore/
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "trailingComma": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 awu43
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # prereq-flow 📦 [ARCHIVED] 📦
2 |
3 | Prereq Flow is an unofficial course planning aid for University of Washington students that visualizes courses and prerequisites in undergraduate degrees. Unsupported on Safari.
4 |
5 | Powered by [React Flow](https://reactflow.dev/) in the front and [FastAPI](https://fastapi.tiangolo.com/) in the back. Built with [Vite](https://vitejs.dev/) and hosted on [Vercel](https://vercel.com/).
6 |
7 | Archived version here: https://prereq-flow.vercel.app/
8 |
9 | ## License
10 | MIT
11 |
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "baseUrl": "http://localhost:3001"
3 | }
4 |
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
6 |
--------------------------------------------------------------------------------
/cypress/integration/AddCourseDialog/AddCourseTextSearch.spec.js:
--------------------------------------------------------------------------------
1 | describe("AddCourseTextSearch", () => {
2 | beforeEach(() => {
3 | cy.setCookie("archive-notice-seen", "true");
4 | cy.visit("/");
5 | cy.get(".Header").contains("Add courses").click();
6 | cy.get('[role="tablist"]').contains("Text search").click();
7 | });
8 | it("Adds courses", () => {
9 | cy.get(".AddCourseTextSearch__textarea").type("CHEM 110, CHEM 162");
10 | cy.get(".AddCourseTextSearch").contains("Add courses").click();
11 | cy.get(".AddCourseDialog .CloseButton").click();
12 | cy.get(".AddCourseDialog").should("not.exist");
13 | cy.get('[data-id="CHEM 110"]');
14 | cy.get('[data-id="CHEM 162"]');
15 | });
16 | it("Disables Get courses button when textarea is empty or whitespace", () => {
17 | cy.get(".AddCourseTextSearch__add-courses-button").should("be.disabled");
18 | cy.get(".AddCourseTextSearch__textarea").type(" ");
19 | cy.get(".AddCourseTextSearch__add-courses-button").should("be.disabled");
20 | });
21 | it("Displays an error message when no course IDs found", () => {
22 | cy.get(".AddCourseTextSearch__textarea").clear();
23 | cy.get(".AddCourseTextSearch__textarea").type("String with no courses");
24 | cy.get(".AddCourseTextSearch").contains("Add courses").click();
25 | cy.get(".tippy-box--error");
26 | });
27 | it("Connects courses to existing prereqs", () => {
28 | cy.get('[data-cy="text-connect-to-prereqs"]').uncheck();
29 | cy.get('[data-cy="text-connect-to-postreqs"]').uncheck();
30 | cy.get('[data-cy="text-connect-to-prereqs"]').check();
31 | cy.get(".AddCourseTextSearch__textarea").type("CHEM 162");
32 | cy.get(".AddCourseTextSearch__add-courses-button").click();
33 | cy.get(".AddCourseDialog .CloseButton").click();
34 | cy.get(".AddCourseDialog").should("not.exist");
35 | cy.get('[data-id="CHEM 162"]');
36 | cy.get('[data-testid="CHEM 152 -> CHEM 162"]');
37 | });
38 | it("Does not connect courses to existing prereqs", () => {
39 | cy.get('[data-cy="text-connect-to-prereqs"]').uncheck();
40 | cy.get('[data-cy="text-connect-to-postreqs"]').uncheck();
41 | cy.get(".AddCourseTextSearch__textarea").type("CHEM 162");
42 | cy.get(".AddCourseTextSearch__add-courses-button").click();
43 | cy.get(".AddCourseDialog .CloseButton").click();
44 | cy.get(".AddCourseDialog").should("not.exist");
45 | cy.get('[data-id="CHEM 162"]');
46 | cy.get('[data-testid="CHEM 152 -> CHEM 162"]').should("not.exist");
47 | });
48 | it("Connects courses to existing postreqs", () => {
49 | cy.get('[data-cy="text-connect-to-prereqs"]').uncheck();
50 | cy.get('[data-cy="text-connect-to-postreqs"]').uncheck();
51 | cy.get('[data-cy="text-connect-to-postreqs"]').check();
52 | cy.get(".AddCourseTextSearch__textarea").type("CHEM 110");
53 | cy.get(".AddCourseTextSearch__add-courses-button").click();
54 | cy.get(".AddCourseDialog .CloseButton").click();
55 | cy.get(".AddCourseDialog").should("not.exist");
56 | cy.get('[data-id="CHEM 110"]');
57 | cy.get('[data-testid="CHEM 110 -> CHEM 142"]');
58 | });
59 | it("Does not connect courses to existing postreqs", () => {
60 | cy.get('[data-cy="text-connect-to-prereqs"]').uncheck();
61 | cy.get('[data-cy="text-connect-to-postreqs"]').uncheck();
62 | cy.get(".AddCourseTextSearch__textarea").type("CHEM 110");
63 | cy.get(".AddCourseTextSearch__add-courses-button").click();
64 | cy.get(".AddCourseDialog .CloseButton").click();
65 | cy.get(".AddCourseDialog").should("not.exist");
66 | cy.get('[data-id="CHEM 110"]');
67 | cy.get('[data-testid="CHEM 110 -> CHEM 142"]').should("not.exist");
68 | });
69 | it("Persists state", () => {
70 | cy.get(".AddCourseTextSearch__textarea").type("FOOBAR BAZ");
71 | cy.get('[data-cy="text-connect-to-prereqs"]').uncheck();
72 | cy.get('[data-cy="text-connect-to-postreqs"]').uncheck();
73 | cy.get('[data-cy="text-connect-to-postreqs"]').check();
74 | cy.get(".CloseButton").click();
75 | cy.get(".Header").contains("Add courses").click();
76 | cy.get(".AddCourseTextSearch__textarea").should("have.value", "FOOBAR BAZ");
77 | cy.get('input[type="checkbox"][checked]')
78 | .parent()
79 | .contains("Connect to existing postreqs");
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/cypress/integration/AddCourseDialog/CustomCourseForm.spec.js:
--------------------------------------------------------------------------------
1 | describe("CustomCourseForm", () => {
2 | beforeEach(() => {
3 | cy.setCookie("archive-notice-seen", "true");
4 | cy.visit("/");
5 | cy.get(".Header").contains("Add courses").click();
6 | cy.get('[role="tablist"]').contains("Custom course").click();
7 | });
8 | it("Adds a custom course", () => {
9 | cy.get(".CustomCourseForm__id-input").type("FOO 123");
10 | cy.get(".CustomCourseForm__add-button").click();
11 | cy.get(".AddCourseDialog .CloseButton").click();
12 | cy.get(".AddCourseDialog").should("not.exist");
13 | cy.get('[data-id="FOO 123"]');
14 | });
15 | it("Disables the add button when ID field is empty or whitespace", () => {
16 | cy.get(".CustomCourseForm__id-input").clear();
17 | cy.get(".CustomCourseForm__add-button").should("be.disabled");
18 | cy.get(".CustomCourseForm__id-input").type(" ");
19 | cy.get(".CustomCourseForm__add-button").should("be.disabled");
20 | });
21 | it("Displays an error message when ID field matches existing course", () => {
22 | cy.get(".CustomCourseForm__id-input").type("MATH 124");
23 | cy.get(".tippy-box--error");
24 | });
25 | it("Persists state", () => {
26 | cy.get(".CustomCourseForm__id-input").type("WEB 101");
27 | cy.get(".CustomCourseForm__name-input").type(
28 | "Introduction to Web Development",
29 | );
30 | cy.get(".AddCourseDialog .CloseButton").click();
31 | cy.get(".Header").contains("Add courses").click();
32 | cy.get(".CustomCourseForm__id-input").should("have.value", "WEB 101");
33 | cy.get(".CustomCourseForm__name-input").should(
34 | "have.value",
35 | "Introduction to Web Development",
36 | );
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/cypress/integration/AddCourseDialog/UwCourseForm.spec.js:
--------------------------------------------------------------------------------
1 | describe("UwCourseForm", () => {
2 | beforeEach(() => {
3 | cy.setCookie("archive-notice-seen", "true");
4 | cy.visit("/");
5 | cy.get(".Header").contains("Add courses").click();
6 | cy.get('[role="tablist"]').contains("UW course").click();
7 | });
8 | it("Adds a UW course", () => {
9 | cy.get(".UwCourseForm__searchbar").type("CHEM 110");
10 | cy.get(".UwCourseForm__add-button").click();
11 | cy.get(".AddCourseDialog .CloseButton").click();
12 | cy.get(".AddCourseDialog").should("not.exist");
13 | cy.get('[data-id="CHEM 110"]');
14 | });
15 | it("Disables add button when searchbar is empty or whitespace", () => {
16 | cy.get(".UwCourseForm__add-button").should("be.disabled");
17 | cy.get(".UwCourseForm__searchbar").type(" ");
18 | cy.get(".UwCourseForm__add-button").should("be.disabled");
19 | });
20 | it("Displays an error message when no course ID found", () => {
21 | cy.get(".UwCourseForm__searchbar").type("not a valid course search");
22 | cy.get(".UwCourseForm__add-button").click();
23 | cy.get(".tippy-box--error");
24 | });
25 | it("Displays an error message when course already exists", () => {
26 | cy.get(".UwCourseForm__searchbar").type("MATH 124");
27 | cy.get(".UwCourseForm__add-button").click();
28 | cy.get(".tippy-box--error");
29 | });
30 | it("Returns focus to searchbar after searching", () => {
31 | cy.get(".UwCourseForm__searchbar").type("CHEM 110");
32 | cy.get(".UwCourseForm__add-button").click();
33 | cy.get(".UwCourseForm__searchbar").should("be.focused");
34 | });
35 | it("Connects a UW course to existing prereqs", () => {
36 | cy.get('[data-cy="uw-connect-to-prereqs"]').uncheck();
37 | cy.get('[data-cy="uw-connect-to-postreqs"]').uncheck();
38 | cy.get('[data-cy="uw-connect-to-prereqs"]').check();
39 | cy.get(".UwCourseForm__searchbar").type("CHEM 162");
40 | cy.get(".UwCourseForm__add-button").click();
41 | cy.get(".AddCourseDialog .CloseButton").click();
42 | cy.get(".AddCourseDialog").should("not.exist");
43 | cy.get('[data-id="CHEM 162"]');
44 | cy.get('[data-testid="CHEM 152 -> CHEM 162"]');
45 | });
46 | it("Does not connect a UW course to existing prereqs", () => {
47 | cy.get('[data-cy="uw-connect-to-prereqs"]').uncheck();
48 | cy.get('[data-cy="uw-connect-to-postreqs"]').uncheck();
49 | cy.get(".UwCourseForm__searchbar").type("CHEM 162");
50 | cy.get(".UwCourseForm__add-button").click();
51 | cy.get(".AddCourseDialog .CloseButton").click();
52 | cy.get(".AddCourseDialog").should("not.exist");
53 | cy.get('[data-id="CHEM 162"]');
54 | cy.get('[data-testid="CHEM 152 -> CHEM 162"]').should("not.exist");
55 | });
56 | it("Connects a UW course to existing postreqs", () => {
57 | cy.get('[data-cy="uw-connect-to-prereqs"]').uncheck();
58 | cy.get('[data-cy="uw-connect-to-postreqs"]').uncheck();
59 | cy.get('[data-cy="uw-connect-to-postreqs"]').check();
60 | cy.get(".UwCourseForm__searchbar").type("CHEM 110");
61 | cy.get(".UwCourseForm__add-button").click();
62 | cy.get(".AddCourseDialog .CloseButton").click();
63 | cy.get(".AddCourseDialog").should("not.exist");
64 | cy.get('[data-id="CHEM 110"]');
65 | cy.get('[data-testid="CHEM 110 -> CHEM 142"]');
66 | });
67 | it("Does not connect a UW course to existing postreqs", () => {
68 | cy.get('[data-cy="uw-connect-to-prereqs"]').uncheck();
69 | cy.get('[data-cy="uw-connect-to-postreqs"]').uncheck();
70 | cy.get(".UwCourseForm__searchbar").type("CHEM 110");
71 | cy.get(".UwCourseForm__add-button").click();
72 | cy.get(".AddCourseDialog .CloseButton").click();
73 | cy.get(".AddCourseDialog").should("not.exist");
74 | cy.get('[data-id="CHEM 110"]');
75 | cy.get('[data-testid="CHEM 110 -> CHEM 142"]').should("not.exist");
76 | });
77 | it("Persists state", () => {
78 | cy.get('[data-cy="uw-connect-to-prereqs"]').uncheck();
79 | cy.get('[data-cy="uw-connect-to-postreqs"]').uncheck();
80 | cy.get('[data-cy="uw-connect-to-postreqs"]').check();
81 | cy.get(".UwCourseForm__searchbar").type("CHEM 110");
82 | cy.get(".AddCourseDialog .CloseButton").click();
83 | cy.get(".Header").contains("Add courses").click();
84 | cy.get(".UwCourseForm__searchbar").should("have.value", "CHEM 110");
85 | cy.get('input[type="checkbox"][checked]')
86 | .parent()
87 | .contains("Connect to existing postreqs");
88 | });
89 | });
90 |
--------------------------------------------------------------------------------
/cypress/integration/App/CourseNode.spec.js:
--------------------------------------------------------------------------------
1 | import { getNode } from "../utils";
2 |
3 | describe("CourseNode", () => {
4 | beforeEach(() => {
5 | cy.setCookie("archive-notice-seen", "true");
6 | cy.visit("/");
7 | });
8 | describe("tippy", () => {
9 | beforeEach(() => {
10 | getNode("MATH 125").trigger("mouseenter");
11 | });
12 | it("Appears when node is hovered over", () => {
13 | cy.get(".tippy-box--flow");
14 | });
15 | it("Highlights prerequisite course IDs", () => {
16 | cy.get(".tippy-box--flow .uw-course-id--highlighted").should(
17 | "have.text",
18 | "MATH 124",
19 | );
20 | });
21 | it("Highlights offered quarters", () => {
22 | cy.get(".tippy-box--flow span.offered-autumn");
23 | cy.get(".tippy-box--flow span.offered-winter");
24 | cy.get(".tippy-box--flow span.offered-spring");
25 | cy.get(".tippy-box--flow span.offered-summer");
26 | });
27 | });
28 | it("Advances status on Alt + click", () => {
29 | getNode("MATH 124").should("have.class", "ready");
30 | getNode("MATH 124").click({ altKey: true });
31 | getNode("MATH 124").should("have.class", "enrolled");
32 | });
33 | it("Is multiselected with Ctrl + click", () => {
34 | getNode("MATH 124").trigger("keydown", { key: "Control" });
35 | cy.get(".react-flow__node.selected").should("have.length", 0);
36 | getNode("MATH 124").click();
37 | getNode("MATH 125").click();
38 | cy.get(".react-flow__node.selected").should("have.length", 2);
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/cypress/integration/EditDataDialog/EditDataDialog.spec.js:
--------------------------------------------------------------------------------
1 | import { clickNodeContextOpt, getEdge, getNode } from "../utils";
2 |
3 | describe("EditDataDialog", () => {
4 | beforeEach(() => {
5 | cy.setCookie("archive-notice-seen", "true");
6 | cy.visit("/");
7 | });
8 | it("Disables save button when ID field is empty", () => {
9 | clickNodeContextOpt("MATH 125", "Edit data");
10 | cy.get(".EditDataDialog")
11 | .contains("Save")
12 | .should("not.have.attr", "disabled");
13 | cy.get(".EditDataDialog .EditDataForm__id-input").clear();
14 | cy.get(".EditDataDialog").contains("Save").should("have.attr", "disabled");
15 | });
16 |
17 | it("Shows error and disables save button when ID already exists", () => {
18 | clickNodeContextOpt("MATH 125", "Edit data");
19 | cy.get(".EditDataDialog")
20 | .contains("Save")
21 | .should("not.have.attr", "disabled");
22 | cy.get(".EditDataDialog .EditDataForm__id-input").clear();
23 | cy.get(".EditDataDialog .EditDataForm__id-input").type("MATH 124");
24 | cy.get(".tippy-box").contains("ID already in use");
25 | });
26 | it("Saves new course ID", () => {
27 | clickNodeContextOpt("MSE 170", "Edit data");
28 | cy.get(".EditDataDialog .EditDataForm__id-input").clear();
29 | cy.get(".EditDataDialog .EditDataForm__id-input").type("FOO 123");
30 | cy.get(".EditDataDialog").contains("Save").click();
31 | getNode("MSE 170").should("not.exist");
32 | getEdge("CHEM 142", "MSE 170").should("not.exist");
33 | getEdge("MSE 170", "M E 354").should("not.exist");
34 | getNode("FOO 123");
35 | getEdge("CHEM 142", "FOO 123");
36 | getEdge("FOO 123", "M E 354");
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/cypress/integration/NewFlowDialog/CurriculumSelect.spec.js:
--------------------------------------------------------------------------------
1 | import ALL_COURSES from "../../../src/data/final_seattle_courses.json";
2 |
3 | describe("CurriculumSelect", () => {
4 | beforeEach(() => {
5 | cy.setCookie("archive-notice-seen", "true");
6 | cy.visit("/");
7 | cy.get(".Header").contains("New flow").click();
8 | // eslint-disable-next-line cypress/no-unnecessary-waiting
9 | cy.get(".NewFlowDialog").contains("Continue").click().wait(300);
10 | cy.get('[role="tablist"]').contains("Curriculum").click();
11 | });
12 | it("Generates a new curriculum flow without external prereqs", () => {
13 | cy.get(".CurriculumSelect__select-input").select("A A");
14 | cy.get(".CurriculumSelect").contains("Get courses").click();
15 | cy.get(".NewFlowDialog").should("not.exist");
16 | for (const course of ALL_COURSES) {
17 | if (course.id.startsWith("A A")) {
18 | cy.get(`[data-id="${course.id}"`);
19 | }
20 | }
21 | });
22 | it("Generates a new curriculum flow with external prereqs", () => {
23 | cy.get(".CurriculumSelect__select-input").select("A A");
24 | cy.get(".CurriculumSelect__external-checkbox input").check();
25 | cy.get(".CurriculumSelect").contains("Get courses").click();
26 | cy.get(".NewFlowDialog").should("not.exist");
27 | cy.get(".react-flow__node")
28 | .its("length")
29 | .then(numCourses => {
30 | cy.get('[data-id^="A A"')
31 | .its("length")
32 | .then(numAA => {
33 | expect(numCourses).to.be.greaterThan(numAA);
34 | });
35 | });
36 | });
37 | it("Displays Seattle curricula", () => {
38 | cy.get(".CampusSelect__radio-label--seattle input").check();
39 | cy.get('.CurriculumSelect__select-input [value="A A"]');
40 | cy.get('.CurriculumSelect__select-input [value="B ARAB"]').should(
41 | "not.exist",
42 | );
43 | cy.get('.CurriculumSelect__select-input [value="T ACCT"]').should(
44 | "not.exist",
45 | );
46 | });
47 | xit("Displays Bothell curricula", () => {
48 | cy.get(".CampusSelect__radio-label--bothell input").check();
49 | cy.get('.CurriculumSelect__select-input [value="A A"]').should("not.exist");
50 | cy.get('.CurriculumSelect__select-input [value="B ARAB"]');
51 | cy.get('.CurriculumSelect__select-input [value="T ACCT"]').should(
52 | "not.exist",
53 | );
54 | });
55 | xit("Displays Tacoma curricula", () => {
56 | cy.get(".CampusSelect__radio-label--tacoma input").check();
57 | cy.get('.CurriculumSelect__select-input [value="A A"]').should("not.exist");
58 | cy.get('.CurriculumSelect__select-input [value="T ACCT"]');
59 | cy.get('.CurriculumSelect__select-input [value="B ARAB"]').should(
60 | "not.exist",
61 | );
62 | });
63 | it("Persists state", () => {
64 | cy.get(".CurriculumSelect__select-input").select(
65 | "AES: American Ethnic Studies",
66 | );
67 | // cy.get(".CampusSelect__radio-label--bothell input").check();
68 | // cy.get(".CurriculumSelect__select-input").select("B BIO: Biology");
69 | // cy.get(".CampusSelect__radio-label--tacoma input").check();
70 | // cy.get(".CurriculumSelect__select-input").select("T CHEM: Chemistry");
71 | cy.get(".CurriculumSelect__external-checkbox input").check();
72 | cy.get(".CurriculumSelect .AmbiguitySelect label")
73 | .contains("Cautiously")
74 | .click();
75 | cy.get(".CloseButton").click();
76 | cy.get(".Header").contains("New flow").click();
77 | cy.get(".CurriculumSelect");
78 | // cy.get(".CurriculumSelect__select-input")
79 | // .find(":selected")
80 | // .contains("T CHEM: Chemistry");
81 | // cy.get(".CampusSelect__radio-label--bothell input").check();
82 | // cy.get(".CurriculumSelect__select-input")
83 | // .find(":selected")
84 | // .contains("B BIO: Biology");
85 | // cy.get(".CampusSelect__radio-label--seattle input").check();
86 | cy.get(".CurriculumSelect__select-input")
87 | .find(":selected")
88 | .contains("AES: American Ethnic Studies");
89 | cy.get(".CurriculumSelect__external-checkbox input[checked]");
90 | cy.get('input[type="radio"][checked]').parent().contains("Cautiously");
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/cypress/integration/NewFlowDialog/DegreeSelect.spec.js:
--------------------------------------------------------------------------------
1 | import ALL_MAJORS from "../../../src/data/final_majors.json";
2 |
3 | describe("DegreeSelect", () => {
4 | beforeEach(() => {
5 | cy.setCookie("archive-notice-seen", "true");
6 | cy.visit("/");
7 | cy.get(".Header").contains("New flow").click();
8 | // eslint-disable-next-line cypress/no-unnecessary-waiting
9 | cy.get(".NewFlowDialog").contains("Continue").click().wait(300);
10 | cy.get('[role="tablist"]').contains("Degree").click();
11 | });
12 | it("Generates a new flow from majors", () => {
13 | cy.get(".majors__select-input").select(
14 | "Aeronautical and Astronautical Engineering",
15 | );
16 | cy.get(".majors__add-button").click();
17 | cy.get(".DegreeSelect").contains("Get courses").click();
18 | cy.get(".NewFlowDialog").should("not.exist");
19 | const AA = ALL_MAJORS.find(
20 | m => m[0] === "Aeronautical and Astronautical Engineering",
21 | )[1];
22 | for (const course of AA) {
23 | cy.get(`[data-id="${course}"`);
24 | }
25 | });
26 | it("Adds a major", () => {
27 | cy.get(".majors__select-input").select(
28 | "Aeronautical and Astronautical Engineering",
29 | );
30 | cy.get(".majors__add-button").click();
31 | cy.get(".majors__selected-list").contains(
32 | "Aeronautical and Astronautical Engineering",
33 | );
34 | });
35 | it("Deletes a major", () => {
36 | cy.get(".majors__select-input").select(
37 | "Aeronautical and Astronautical Engineering",
38 | );
39 | cy.get(".majors__add-button").click();
40 | cy.get(".majors__selected-list").contains(
41 | "Aeronautical and Astronautical Engineering",
42 | );
43 | cy.get(".majors__delete-button").click();
44 | cy.get(".majors__selected-list")
45 | .contains("Aeronautical and Astronautical Engineering")
46 | .should("not.exist");
47 | });
48 | it("Disables the add button when current selection already selected", () => {
49 | cy.get(".majors__add-button").click();
50 | cy.get(".majors__add-button").should("be.disabled");
51 | cy.get(".majors__select-input").select("Computer Engineering");
52 | cy.get(".majors__add-button").should("not.be.disabled");
53 | });
54 | it("Disables the add button when three majors are selected", () => {
55 | cy.get(".majors__add-button").click();
56 | cy.get(".majors__select-input").select("Mechanical Engineering");
57 | cy.get(".majors__add-button").click();
58 | cy.get(".majors__select-input").select("Computer Engineering");
59 | cy.get(".majors__add-button").click();
60 | cy.get(".majors__select-input").select("Electrical Engineering");
61 | cy.get(".majors__add-button").should("be.disabled");
62 | });
63 | it("Disables the Get courses button when no degrees are selected", () => {
64 | cy.get(".DegreeSelect").contains("Get courses").should("be.disabled");
65 | cy.get(".majors__add-button").click();
66 | cy.get(".DegreeSelect").contains("Get courses").should("not.be.disabled");
67 | });
68 | it("Persists state", () => {
69 | cy.get(".majors__add-button").click();
70 | cy.get(".majors__select-input").select("Electrical Engineering");
71 | cy.get(".majors__add-button").click();
72 | cy.get(".majors__select-input").select("Civil Engineering");
73 | cy.get(".AmbiguitySelect label").contains("Cautiously").click();
74 | cy.get(".CloseButton").click();
75 | cy.get(".Header").contains("New flow").click();
76 | cy.get(".DegreeSelect");
77 | cy.get("li").contains("Aeronautical and Astronautical Engineering");
78 | cy.get("li").contains("Electrical Engineering");
79 | cy.get(".majors__select-input")
80 | .find(":selected")
81 | .contains("Civil Engineering");
82 | cy.get('input[type="radio"][checked]').parent().contains("Cautiously");
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/cypress/integration/NewFlowDialog/NewBlankFlow.spec.js:
--------------------------------------------------------------------------------
1 | describe("NewBlankFlow", () => {
2 | it("Generates a new blank dialog", () => {
3 | cy.setCookie("archive-notice-seen", "true");
4 | cy.visit("/");
5 | cy.get(".Header").contains("New flow").click();
6 | // eslint-disable-next-line cypress/no-unnecessary-waiting
7 | cy.get(".NewFlowDialog").contains("Continue").click().wait(300);
8 | cy.get('[role="tablist"]').contains("Blank").click();
9 | cy.get(".NewBlankFlow button").click();
10 | cy.get(".NewFlowDialog").should("not.exist");
11 | cy.get(".react-flow__node").should("not.exist");
12 | cy.get(".react-flow__edge").should("not.exist");
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/cypress/integration/NewFlowDialog/NewFlowTextSearch.spec.js:
--------------------------------------------------------------------------------
1 | describe("NewFlowTextSearch", () => {
2 | beforeEach(() => {
3 | cy.setCookie("archive-notice-seen", "true");
4 | cy.visit("/");
5 | cy.get(".Header").contains("New flow").click();
6 | // eslint-disable-next-line cypress/no-unnecessary-waiting
7 | cy.get(".NewFlowDialog").contains("Continue").click().wait(300);
8 | cy.get('[role="tablist"]').contains("Text search").click();
9 | });
10 | it("Generates a new flow from text search", () => {
11 | cy.get('[role="tablist"]').contains("Blank").click();
12 | cy.get(".NewBlankFlow button").click();
13 | cy.get(".NewFlowDialog").should("not.exist");
14 | cy.get(".Header").contains("New flow").click();
15 | cy.get('[role="tablist"]').contains("Text search").click();
16 |
17 | cy.get(".NewFlowTextSearch__textarea").type("MATH 124, MATH 125, MATH 126");
18 | cy.get(".NewFlowTextSearch").contains("Get courses").click();
19 | cy.get(".NewFlowDialog").should("not.exist");
20 | cy.get('[data-id="MATH 124"]');
21 | cy.get('[data-id="MATH 125"]');
22 | cy.get('[data-id="MATH 126"]');
23 | });
24 | it("Disables Get courses button when textarea is empty or whitespace", () => {
25 | cy.get(".NewFlowTextSearch").contains("Get courses").should("be.disabled");
26 | cy.get(".NewFlowTextSearch__textarea").type(" ");
27 | cy.get(".NewFlowTextSearch").contains("Get courses").should("be.disabled");
28 | });
29 | it("Displays an error message when no course IDs found", () => {
30 | cy.get(".NewFlowTextSearch__textarea").clear();
31 | cy.get(".NewFlowTextSearch__textarea").type("String with no courses");
32 | cy.get(".NewFlowTextSearch").contains("Get courses").click();
33 | cy.get(".tippy-box--error");
34 | });
35 | it("Persists state", () => {
36 | cy.get(".NewFlowTextSearch__textarea").type("FOOBAR BAZ");
37 | cy.get(".NewFlowTextSearch .AmbiguitySelect label")
38 | .contains("Cautiously")
39 | .click();
40 | cy.get(".CloseButton").click();
41 | cy.get(".Header").contains("New flow").click();
42 | cy.get(".NewFlowTextSearch__textarea").should("have.value", "FOOBAR BAZ");
43 | cy.get('input[type="radio"][checked]').parent().contains("Cautiously");
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/cypress/integration/TableDialog/TableDialog.spec.js:
--------------------------------------------------------------------------------
1 | import { getNode } from "../utils";
2 |
3 | describe("TableDialog", () => {
4 | beforeEach(() => {
5 | cy.setCookie("archive-notice-seen", "true");
6 | cy.visit("/");
7 | cy.get(".TableDialog__open-btn").click();
8 | });
9 | it("Sorts courses by depth", () => {
10 | cy.get(".SortBy__radio-label--depth input").click();
11 | cy.get(".TableDialog tbody tr > td:nth-child(1)").then(jElems => {
12 | const depths = [];
13 | for (const elem of jElems) {
14 | depths.push(Number(elem.textContent));
15 | }
16 | expect(depths).to.eql(depths.slice().sort((a, b) => a - b));
17 | });
18 | });
19 | it("Sorts courses by ID", () => {
20 | cy.get(".SortBy__radio-label--id input").click();
21 | cy.get(".TableDialog tbody tr > td:nth-child(2)").then(jElems => {
22 | const ids = [];
23 | for (const elem of jElems) {
24 | ids.push(elem.textContent);
25 | }
26 | expect(ids).to.eql(ids.slice().sort((a, b) => a.localeCompare(b)));
27 | });
28 | });
29 | it("Sorts courses by ID number", () => {
30 | cy.get(".SortBy__radio-label--id-num input").click();
31 | cy.get(".TableDialog tbody tr > td:nth-child(2)").then(jElems => {
32 | const idNums = [];
33 | for (const elem of jElems) {
34 | idNums.push(Number(elem.textContent.match(/\b\d{3}\b/)[0]));
35 | // COURSE_NUM_REGEX from TableDialog
36 | }
37 | expect(idNums).to.eql(idNums.slice().sort((a, b) => a - b));
38 | });
39 | });
40 | it("Sorts courses by Name", () => {
41 | cy.get(".SortBy__radio-label--name input").click();
42 | cy.get(".TableDialog tbody tr > td:nth-child(3)").then(jElems => {
43 | const names = [];
44 | for (const elem of jElems) {
45 | names.push(elem.textContent);
46 | }
47 | expect(names).to.eql(names.slice().sort((a, b) => a.localeCompare(b)));
48 | });
49 | });
50 | it("Links ID column to MyPlan", () => {
51 | cy.get(".TableDialog tbody tr:nth-child(4) td:nth-child(2)")
52 | .contains("AMATH 352")
53 | .should(
54 | "have.attr",
55 | "href",
56 | "https://myplan.uw.edu/course/#/courses/AMATH 352",
57 | );
58 | });
59 | it("Links Prerequisite column to MyPlan", () => {
60 | cy.get(".TableDialog tbody tr:nth-child(4) td:nth-child(4)")
61 | .contains("MATH 126")
62 | .should(
63 | "have.attr",
64 | "href",
65 | "https://myplan.uw.edu/course/#/courses/MATH 126",
66 | );
67 | cy.get(".TableDialog tbody tr:nth-child(4) td:nth-child(4)")
68 | .contains("MATH 136")
69 | .should(
70 | "have.attr",
71 | "href",
72 | "https://myplan.uw.edu/course/#/courses/MATH 136",
73 | );
74 | });
75 | it("Deletes a course from the Incoming column", () => {
76 | cy.get(".TableDialog tbody tr").then(origRows => {
77 | cy.get(
78 | ".TableDialog tbody tr:nth-child(4) td:nth-child(6) button",
79 | ).click();
80 | cy.get(".TableDialog tbody tr").should(
81 | "have.length",
82 | origRows.length - 1,
83 | );
84 | getNode("MATH 126").should("not.exist");
85 | });
86 | });
87 | it("Links Incoming column to MyPlan", () => {
88 | cy.get(".TableDialog tbody tr:nth-child(4) td:nth-child(6)")
89 | .contains("MATH 126")
90 | .should(
91 | "have.attr",
92 | "href",
93 | "https://myplan.uw.edu/course/#/courses/MATH 126",
94 | );
95 | });
96 | it("Deletes a course from the Outgoing column", () => {
97 | cy.get(".TableDialog tbody tr").then(origRows => {
98 | cy.get(
99 | ".TableDialog tbody tr:nth-child(4) td:nth-child(7) button",
100 | ).click();
101 | cy.get(".TableDialog tbody tr").should(
102 | "have.length",
103 | origRows.length - 1,
104 | );
105 | getNode("M E 373").should("not.exist");
106 | });
107 | });
108 | it("Links Outgoing column to MyPlan", () => {
109 | cy.get(".TableDialog tbody tr:nth-child(4) td:nth-child(7)")
110 | .contains("M E 373")
111 | .should(
112 | "have.attr",
113 | "href",
114 | "https://myplan.uw.edu/course/#/courses/M E 373",
115 | );
116 | });
117 | it("Deletes a course from the Delete column", () => {
118 | cy.get(".TableDialog tbody tr").then(origRows => {
119 | cy.get(
120 | ".TableDialog tbody tr:nth-child(4) td:nth-child(8) button",
121 | ).click();
122 | cy.get(".TableDialog tbody tr").should(
123 | "have.length",
124 | origRows.length - 1,
125 | );
126 | getNode("AMATH 352").should("not.exist");
127 | });
128 | });
129 | });
130 |
--------------------------------------------------------------------------------
/cypress/integration/utils.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | export function getNode(nodeId) {
4 | return cy.get(`[data-id="${nodeId}"] > [class*="Node"]`);
5 | }
6 |
7 | export function clickContextOption(text) {
8 | cy.get(".ContextMenu").contains(text).click();
9 | }
10 |
11 | export function clickNodeContextOpt(nodeId, optText) {
12 | getNode(nodeId).rightclick();
13 | clickContextOption(optText);
14 | }
15 |
16 | export function getByTestId(id) {
17 | return cy.get(`[data-testid="${id}"]`);
18 | }
19 |
20 | export function getEdge(src, dest) {
21 | return getByTestId(`${src} -> ${dest}`);
22 | }
23 |
24 | export function deleteNode(nodeId) {
25 | clickNodeContextOpt(nodeId, "Delete");
26 | }
27 |
28 | export function deleteEdge(src, dest) {
29 | getEdge(src, dest).rightclick({ force: true });
30 | clickContextOption("Delete");
31 | }
32 |
--------------------------------------------------------------------------------
/cypress/plugins/index.js:
--------------------------------------------------------------------------------
1 | ///
2 | // ***********************************************************
3 | // This example plugins/index.js can be used to load plugins
4 | //
5 | // You can change the location of this file or turn off loading
6 | // the plugins file with the 'pluginsFile' configuration option.
7 | //
8 | // You can read more here:
9 | // https://on.cypress.io/plugins-guide
10 | // ***********************************************************
11 |
12 | // This function is called when a project is opened or re-opened (e.g. due to
13 | // the project's config changing)
14 |
15 | /**
16 | * @type {Cypress.PluginConfig}
17 | */
18 | // eslint-disable-next-line no-unused-vars
19 | module.exports = (on, config) => {
20 | // `on` is used to hook into various events Cypress emits
21 | // `config` is the resolved Cypress config
22 | };
23 |
--------------------------------------------------------------------------------
/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add('login', (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This will overwrite an existing command --
25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
26 |
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import "./commands";
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
21 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Prereq Flow
17 |
18 |
19 |
20 | You need to enable JavaScript to run this app.
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "start": "vite",
5 | "build": "tsc && vite build",
6 | "vitest": "vitest run",
7 | "cy": "cypress open",
8 | "format": "eslint --fix \"src/**/*.{js,tsx,ts}\" && eslint --fix \"cypress/**/*.js\"",
9 | "lint": "eslint \"src/**/*.{js,tsx,ts}\" && eslint \"cypress/**/*.js\"",
10 | "tscheck": "tsc --noEmit"
11 | },
12 | "dependencies": {
13 | "@reach/combobox": "^0.15.3",
14 | "@reach/dialog": "^0.15.3",
15 | "@reach/tabs": "^0.15.3",
16 | "@tippyjs/react": "^4.2.6",
17 | "classnames": "^2.3.1",
18 | "fuse.js": "^7.0.0",
19 | "jotai": "^2.5.1",
20 | "lodash": "^4.17.21",
21 | "nanoid": "^4.0.0",
22 | "react": "^17.0.2",
23 | "react-cookie": "^4.1.1",
24 | "react-dom": "^17.0.2",
25 | "react-dropzone": "^11.7.1",
26 | "react-flow-renderer": "^9.6.0"
27 | },
28 | "devDependencies": {
29 | "@types/react": "^17.0.47",
30 | "@types/react-dom": "^17.0.17",
31 | "@typescript-eslint/eslint-plugin": "^5.29.0",
32 | "@typescript-eslint/parser": "^5.29.0",
33 | "@vitejs/plugin-react": "^1.3.2",
34 | "cypress": "^9.7.0",
35 | "eslint": "^8.18.0",
36 | "eslint-config-airbnb": "^19.0.4",
37 | "eslint-config-prettier": "^8.5.0",
38 | "eslint-import-resolver-typescript": "^3.1.0",
39 | "eslint-plugin-cypress": "^2.12.1",
40 | "eslint-plugin-import": "^2.26.0",
41 | "eslint-plugin-jsx-a11y": "^6.6.0",
42 | "eslint-plugin-prettier": "^4.0.0",
43 | "eslint-plugin-react": "^7.30.1",
44 | "eslint-plugin-react-hooks": "^4.6.0",
45 | "postcss": "^8.4.14",
46 | "postcss-preset-env": "^7.7.2",
47 | "prettier": "^2.7.1",
48 | "sass": "^1.53.0",
49 | "typescript": "^4.7.4",
50 | "vite": "^2.9.12",
51 | "vite-tsconfig-paths": "^3.5.0",
52 | "vitest": "^0.16.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [require("postcss-preset-env")],
3 | };
4 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #00aba9
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/favicon.ico
--------------------------------------------------------------------------------
/public/mstile-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/mstile-144x144.png
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/mstile-310x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/mstile-310x150.png
--------------------------------------------------------------------------------
/public/mstile-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/mstile-310x310.png
--------------------------------------------------------------------------------
/public/mstile-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/public/mstile-70x70.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Allow: /$
4 | Disallow: /
5 |
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.11, written by Peter Selinger 2001-2013
9 |
10 |
12 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/src/_utils.scss:
--------------------------------------------------------------------------------
1 | @mixin gray-border {
2 | border: 2px solid gray;
3 | border-radius: 3px;
4 | }
5 |
6 | @mixin lightgray-border {
7 | border: 2px solid lightgray;
8 | border-radius: 3px;
9 | }
10 |
11 | @mixin base-button-style {
12 | appearance: none;
13 | @include gray-border;
14 | // background-color: rgb(240, 240, 240);
15 | padding: 0.25rem;
16 |
17 | &:not(:disabled):hover {
18 | color: white;
19 | background-color: gray;
20 | }
21 | }
22 |
23 | @mixin img-button-style($width, $height: 0) {
24 | @include base-button-style;
25 | padding: 0;
26 |
27 | display: flex;
28 | align-items: center;
29 | justify-content: center;
30 |
31 | img {
32 | width: percentage(calc($width / 100));
33 | @if $height == 0 {
34 | height: percentage(calc($width / 100));
35 | } @else {
36 | height: percentage(calc($height / 100));
37 | }
38 | }
39 | &:disabled img {
40 | filter:
41 | invert(55%)
42 | sepia(0%)
43 | saturate(28%)
44 | hue-rotate(137deg)
45 | brightness(92%)
46 | contrast(83%);
47 | }
48 | &:not(:disabled):hover img {
49 | filter: invert(100%);
50 | }
51 | }
52 |
53 | @mixin base-text-input-style {
54 | height: 1.5rem;
55 | padding: 0.75rem 0.25rem;
56 | @include gray-border;
57 | }
58 |
59 | $flow-colors: (
60 | "completed": black,
61 | "enrolled": darkviolet,
62 | "ready": limegreen,
63 | "under-one-away": goldenrod,
64 | "one-away": darkorange,
65 | "over-one-away": red,
66 | );
67 |
--------------------------------------------------------------------------------
/src/components/ContextMenu/ConditionalNodesOpts.tsx:
--------------------------------------------------------------------------------
1 | import type { Node } from "types/main";
2 | import type { OptListProps } from "./types";
3 |
4 | export default function ConditionalNodeOpts({
5 | contextProps,
6 | sharedOpts,
7 | }: OptListProps): JSX.Element {
8 | const { elements, nodeData, data, rerouteSingle, reroutePointless } =
9 | contextProps;
10 | const {
11 | disconnectPrereqsOpt,
12 | disconnectPostreqsOpt,
13 | disconnectAllOpt,
14 |
15 | deleteElemsOpt,
16 | } = sharedOpts;
17 |
18 | const { target } = data;
19 |
20 | const hasPrereqs = !!nodeData.get(target[0]).incomingEdges.length;
21 | const hasPostreqs = !!nodeData.get(target[0]).outgoingEdges.length;
22 | let pointlessOrNodeFound = false;
23 | const numNodes = nodeData.size;
24 | for (let i = 0; i < numNodes; i++) {
25 | const elem = elements[i] as Node;
26 | if (elem.type === "or" && nodeData.get(elem.id).incomingEdges.length <= 1) {
27 | pointlessOrNodeFound = true;
28 | break;
29 | }
30 | }
31 | const rerouteSingleOpt = (
32 | rerouteSingle(target[0])}>
33 | Reroute
34 |
35 | );
36 | const reroutePointlessOpt = (
37 |
38 | Reroute pointless OR nodes
39 |
40 | );
41 |
42 | return (
43 | <>
44 | {hasPrereqs && disconnectPrereqsOpt}
45 | {hasPostreqs && disconnectPostreqsOpt}
46 | {hasPrereqs && hasPostreqs && disconnectAllOpt}
47 | {hasPrereqs && hasPostreqs && rerouteSingleOpt}
48 | {pointlessOrNodeFound && reroutePointlessOpt}
49 | {deleteElemsOpt}
50 | >
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/ContextMenu/CourseNodeOpts.tsx:
--------------------------------------------------------------------------------
1 | import type { CourseStatus, CourseNode } from "types/main";
2 |
3 | import {
4 | isCourseNode,
5 | courseIdMatch,
6 | edgeArrowId,
7 | COURSE_STATUS_CODES,
8 | } from "@utils";
9 |
10 | import type { OptListProps } from "./types";
11 |
12 | const COURSE_REGEX = /^(?:[A-Z&]+ )+\d{3}$/;
13 |
14 | export default function CourseNodeOpts({
15 | contextProps,
16 | sharedOpts,
17 | }: OptListProps): JSX.Element {
18 | const {
19 | elements,
20 | nodeData,
21 | elemIndexes,
22 | data,
23 | setSelectionStatuses,
24 | editCourseData,
25 | } = contextProps;
26 | const {
27 | connectPrereqsOpt,
28 | connectPostreqsOpt,
29 | connectAllOpt,
30 |
31 | disconnectPrereqsOpt,
32 | disconnectPostreqsOpt,
33 | disconnectAllOpt,
34 |
35 | deleteElemsOpt,
36 | } = sharedOpts;
37 |
38 | const { target, targetStatus } = data;
39 |
40 | const targetNode = target[0];
41 | const targetStatusCode = COURSE_STATUS_CODES[targetStatus as CourseStatus];
42 | const courseStatusOptions = (
43 | <>
44 | = 2 ? "current" : ""}
47 | onClick={() => setSelectionStatuses(target, "ready")}
48 | >
49 | Planned
50 |
51 | setSelectionStatuses(target, "enrolled")}
55 | >
56 | Enrolled
57 |
58 | setSelectionStatuses(target, "completed")}
62 | >
63 | Completed
64 |
65 |
66 | >
67 | );
68 | const allPrereqs = courseIdMatch(
69 | (elements[elemIndexes.get(targetNode)] as CourseNode).data.prerequisite,
70 | );
71 | const allPrereqsConnected = allPrereqs
72 | ? allPrereqs.every(
73 | p => !elemIndexes.has(p) || elemIndexes.has(edgeArrowId(p, targetNode)),
74 | )
75 | : true;
76 | const notConnectedPostreqs: CourseNode[] = [];
77 | const numNodes = nodeData.size;
78 | for (let i = 0; i < numNodes; i++) {
79 | const postreq = elements[i];
80 | if (
81 | isCourseNode(postreq) &&
82 | postreq.data.prerequisite.includes(targetNode) &&
83 | !elemIndexes.has(edgeArrowId(targetNode, postreq.id))
84 | ) {
85 | notConnectedPostreqs.push(postreq);
86 | }
87 | }
88 | const hasPrereqs = !!nodeData.get(targetNode).incomingEdges.length;
89 | const hasPostreqs = !!nodeData.get(targetNode).outgoingEdges.length;
90 |
91 | return (
92 | <>
93 | {targetStatusCode < 3 && courseStatusOptions}
94 | {!allPrereqsConnected && connectPrereqsOpt}
95 | {!!notConnectedPostreqs.length && connectPostreqsOpt}
96 | {!allPrereqsConnected && !!notConnectedPostreqs.length && connectAllOpt}
97 | {hasPrereqs && disconnectPrereqsOpt}
98 | {hasPostreqs && disconnectPostreqsOpt}
99 | {hasPrereqs && hasPostreqs && disconnectAllOpt}
100 | editCourseData(targetNode)}>
101 | Edit data
102 |
103 | {deleteElemsOpt}
104 | {COURSE_REGEX.test(targetNode) ? (
105 | <>
106 |
107 |
108 |
109 |
114 | Open in MyPlan
115 |
116 |
117 |
118 | >
119 | ) : null}
120 | >
121 | );
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ContextMenu/PaneOpts.tsx:
--------------------------------------------------------------------------------
1 | import type { Node } from "types/main";
2 | import type { OptListProps } from "./types";
3 |
4 | export default function PaneOpts({
5 | contextProps,
6 | }: Omit): JSX.Element {
7 | const { elements, nodeData, xy, newConditionalNode, reroutePointless } =
8 | contextProps;
9 |
10 | let pointlessOrNodeFound = false;
11 | const numNodes = nodeData.size;
12 | for (let i = 0; i < numNodes; i++) {
13 | const elem = elements[i] as Node;
14 | if (elem.type === "or" && nodeData.get(elem.id).incomingEdges.length <= 1) {
15 | pointlessOrNodeFound = true;
16 | break;
17 | }
18 | }
19 | const reroutePointlessOpt = (
20 |
21 | Reroute pointless OR nodes
22 |
23 | );
24 |
25 | return (
26 | <>
27 | newConditionalNode("or", xy)}>
28 | New OR node
29 |
30 | newConditionalNode("and", xy)}>
31 | New AND node
32 |
33 | {pointlessOrNodeFound && reroutePointlessOpt}
34 | >
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ContextMenu/index.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .ContextMenu {
4 | position: fixed;
5 | z-index: 12;
6 | display: flex;
7 | flex-direction: column;
8 | list-style: none;
9 |
10 | background-color: hsl(0, 0%, 95%);
11 | @include gray-border;
12 |
13 | cursor: default;
14 |
15 | p {
16 | font-size: 0.9rem;
17 | margin: 0.1rem 0.5rem;
18 | padding-left: 1rem;
19 | user-select: none;
20 | }
21 |
22 | hr {
23 | margin: 0.2rem 0.5rem;
24 | }
25 |
26 | a {
27 | color: black;
28 | text-decoration: none;
29 | }
30 |
31 | li.current p {
32 | padding-left: 0;
33 | &::before {
34 | content: "✓";
35 | display: inline-block;
36 | width: 1rem;
37 | }
38 | }
39 |
40 | li:hover {
41 | p, a {
42 | color: white;
43 | }
44 | background-color: gray;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/ContextMenu/types.d.ts:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from "react";
2 | import type { XYPosition } from "react-flow-renderer";
3 |
4 | import type {
5 | CourseStatus,
6 | NodeId,
7 | EdgeId,
8 | ElementId,
9 | ConditionalTypes,
10 | Element,
11 | NodeDataMap,
12 | ElemIndexMap,
13 | ConnectTo,
14 | } from "types/main";
15 |
16 | type ContextTargetType =
17 | | "coursenode"
18 | | "conditionalnode"
19 | | "edge"
20 | | "coursemultiselect"
21 | | "conditionalmultiselect"
22 | | "mixedmultiselect"
23 | | "courseselection"
24 | | "conditionalselection"
25 | | "pane";
26 |
27 | type ContextTargetStatus = CourseStatus | "" | "concurrent";
28 |
29 | interface ContextTarget {
30 | target: ElementId[];
31 | targetType: ContextTargetType;
32 | targetStatus: ContextTargetStatus;
33 | }
34 |
35 | interface ContextMenuProps {
36 | elements: Element[];
37 | nodeData: NodeDataMap;
38 | elemIndexes: ElemIndexMap;
39 | active: boolean;
40 | data: ContextTarget;
41 | xy: XYPosition;
42 | setSelectionStatuses: (nodeIds: NodeId[], newStatus: CourseStatus) => void;
43 | toggleEdgeConcurrency: (edgeId: EdgeId) => void;
44 | editCourseData: (courseId: NodeId) => void;
45 | deleteElems: (elemIds: ElementId[]) => void;
46 | connect: (targetId: NodeId, to?: ConnectTo) => void;
47 | disconnect: (targetIds: NodeId[], from?: ConnectTo) => void;
48 | newConditionalNode: (type: ConditionalTypes, xy: XYPosition) => void;
49 | rerouteSingle: (targetId: NodeId) => void;
50 | reroutePointless: () => void;
51 | }
52 |
53 | interface OptListProps {
54 | contextProps: ContextMenuProps;
55 | sharedOpts: Record;
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/UserControls.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .UserControls {
4 | &__open-btn,
5 | &__close-btn {
6 | position: absolute;
7 | bottom: 0.5rem;
8 | right: 0.5rem;
9 | z-index: 10;
10 |
11 | height: 2rem;
12 | width: 2rem;
13 |
14 | @include img-button-style(60);
15 | }
16 |
17 | &__content {
18 | position: absolute;
19 | bottom: 0;
20 | right: 0;
21 | max-width: 20rem;
22 | z-index: 11;
23 |
24 | @include lightgray-border;
25 | border-top-right-radius: 0;
26 | border-bottom-right-radius: 0;
27 | border-right: none;
28 | border-bottom: none;
29 | padding-top: 1rem;
30 | padding-bottom: 1rem;
31 | padding-left: 2rem;
32 | padding-right: 3rem;
33 | background-color: white;
34 | font-size: 0.9rem;
35 |
36 | li {
37 | list-style-type: square;
38 | margin: 0.25rem 0;
39 | }
40 |
41 | @media (prefers-reduced-motion: no-preference) {
42 | transition: transform 150ms ease-out;
43 | }
44 |
45 | &--closed {
46 | transform: translateX(100%);
47 | }
48 |
49 | kbd {
50 | background-color: #eee;
51 | border-radius: 3px;
52 | border: 1px solid #b4b4b4;
53 | color: #333;
54 | display: inline-block;
55 | font-size: .85em;
56 | font-weight: 700;
57 | line-height: 0.85;
58 | padding: 2px 4px;
59 | white-space: nowrap;
60 | font-family: consolas;
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/UserControls.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef } from "react";
2 |
3 | import classNames from "classnames";
4 |
5 | import questionIcon from "@icons/question.svg";
6 | import chevronRightIcon from "@icons/chevron-right.svg";
7 |
8 | import "./UserControls.scss";
9 |
10 | export default function UserControls(): JSX.Element {
11 | const [controlsClosed, setControlsClosed] = useState(true);
12 | const openControlsButtonRef = useRef(null);
13 | // const closeControlsButtonRef = useRef(null);
14 |
15 | return (
16 | <>
17 | setControlsClosed(!controlsClosed)}
22 | // Focusing on close button causes offscreen jerk
23 | >
24 |
25 |
26 |
31 |
32 | Click for single select
33 | Right click for context menus
34 |
35 | Hover over a node for connections and course info (click to
36 | hide tooltip)
37 |
38 |
39 | Drag to create a new edge from a node when crosshair
40 | icon appears
41 |
42 | Drag to reconnect an edge when 4-way arrow icon appears
43 |
44 | Alt + click to advance course status
45 |
46 |
47 | Ctrl + click for multiple select
48 |
49 |
50 | Shift + drag for area select
51 |
52 |
53 | Del to delete selected elements
54 |
55 |
56 | Ctrl + Z to undo (max 20)
57 |
58 |
59 | Ctrl + Y to redo
60 |
61 | {
66 | setControlsClosed(true);
67 | openControlsButtonRef.current?.focus();
68 | }}
69 | tabIndex={controlsClosed ? -1 : 0}
70 | >
71 |
72 |
73 |
74 |
75 | >
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/dialogs/AboutDialog.scss:
--------------------------------------------------------------------------------
1 | .AboutDialog {
2 | width: min(22.5rem, 100% - 1rem);
3 |
4 | h2 {
5 | margin-bottom: 0.5rem;
6 | }
7 |
8 | p:not(:first-of-type) {
9 | margin: 1rem 0;
10 | }
11 |
12 | &__github-link,
13 | &__email-link {
14 | display: inline-flex;
15 | align-items: center;
16 |
17 | img {
18 | height: 2rem;
19 | width: 2rem;
20 | margin-right: 0.5rem;
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/dialogs/AboutDialog.tsx:
--------------------------------------------------------------------------------
1 | import type { ModalClass, CloseModal } from "@useDialogStatus";
2 | import githubIcon from "@icons/github.svg";
3 |
4 | import "./AboutDialog.scss";
5 | import ModalDialog from "./ModalDialog";
6 |
7 | interface AboutDialogProps {
8 | modalCls: ModalClass;
9 | closeDialog: CloseModal;
10 | }
11 | export default function AboutDialog({
12 | modalCls,
13 | closeDialog,
14 | }: AboutDialogProps): JSX.Element {
15 | return (
16 |
23 |
24 | About
25 |
26 | Prereq Flow is an unofficial course planning aid for University of
27 | Washington students that visualizes courses and prerequisites in
28 | undergraduate degrees.
29 |
30 |
31 |
32 | Powered by{" "}
33 |
34 | React Flow
35 | {" "}
36 | in the front and{" "}
37 |
42 | FastAPI
43 | {" "}
44 | in the back. Built with{" "}
45 |
46 | Vite
47 | {" "}
48 | and hosted on
49 |
50 | Vercel
51 |
52 | .
53 |
54 |
55 |
56 |
62 |
63 | Source code
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/dialogs/AddCourseDialog/AddCourseTextSearch.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .AddCourseTextSearch {
4 | height: 100%;
5 | display: flex;
6 | flex-direction: column;
7 | align-items: flex-start;
8 |
9 | &__textarea {
10 | flex: 1 0;
11 |
12 | width: 100%;
13 | resize: none;
14 | margin: 0 0 0.5rem 0;
15 | padding: 0.25rem;
16 | @include gray-border;
17 |
18 | font-family: sans-serif;
19 | font-size: 0.8rem;
20 | }
21 |
22 | p {
23 | margin-bottom: 0.5rem;
24 | }
25 |
26 | &__add-courses-button {
27 | position: absolute;
28 | bottom: 0;
29 | right: 0;
30 |
31 | @include base-button-style;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/dialogs/AddCourseDialog/AddCourseTextSearch.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from "react";
2 | import type { MouseEvent } from "react";
3 |
4 | import Tippy from "@tippyjs/react";
5 | // eslint-disable-next-line import/no-extraneous-dependencies
6 | import "tippy.js/dist/tippy.css";
7 |
8 | import type { SetState } from "types/main";
9 |
10 | import "./AddCourseTextSearch.scss";
11 |
12 | import { stateUpdater } from "@utils";
13 |
14 | import type { TextSearchState } from "./types";
15 |
16 | interface TextSearchProps {
17 | tabIndex: number;
18 | tsState: TextSearchState;
19 | setTsState: SetState;
20 | busy: boolean;
21 | addCoursesFromText: (e: MouseEvent) => Promise;
22 | }
23 | export default function NewFlowTextSearch({
24 | tabIndex,
25 | tsState,
26 | setTsState,
27 | busy,
28 | addCoursesFromText,
29 | }: TextSearchProps): JSX.Element {
30 | const tsUpdater = stateUpdater(setTsState);
31 |
32 | const textAreaRef = useRef(null);
33 | useEffect(() => {
34 | textAreaRef.current?.setSelectionRange(
35 | tsState.text.length,
36 | tsState.text.length,
37 | );
38 | if (tabIndex === 2) {
39 | textAreaRef.current?.focus();
40 | }
41 | }, []);
42 |
43 | return (
44 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/src/components/dialogs/AddCourseDialog/CustomCourseForm.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .CustomCourseForm {
4 | height: 100%;
5 | display: flex;
6 | flex-direction: column;
7 |
8 | &__header-row {
9 | display: flex;
10 | justify-content: flex-start;
11 | align-items: flex-start;
12 | }
13 | &__footer-row {
14 | display: flex;
15 | margin-bottom: 1.5rem;
16 | }
17 |
18 |
19 | &__id-input {
20 | width: 8rem;
21 | }
22 | &__name-input {
23 | margin: 0 0.5rem;
24 | flex-grow: 1;
25 | }
26 | &__credits-input {
27 | width: 3.5rem;
28 | }
29 | &__description-input {
30 | margin: 0.5rem 0;
31 | @include gray-border;
32 | padding: 0.25rem;
33 | resize: none;
34 | flex: 1;
35 | font-size: 0.8rem;
36 | }
37 |
38 | &__prerequisite-input {
39 | flex-grow: 1;
40 | margin-right: 0.25rem;
41 | }
42 | &__offered-input {
43 | flex-grow: 1;
44 | margin-left: 0.25rem;
45 | }
46 |
47 | &__add-button {
48 | height: 2rem;
49 | @include base-button-style;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/dialogs/AddCourseDialog/CustomCourseForm.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | import Tippy from "@tippyjs/react";
4 | // eslint-disable-next-line import/no-extraneous-dependencies
5 | import "tippy.js/dist/tippy.css";
6 |
7 | import type {
8 | SetState,
9 | CourseData,
10 | NewCoursePosition,
11 | NodeDataMap,
12 | } from "types/main";
13 | import { textChangeUpdater } from "@utils";
14 |
15 | import "./CustomCourseForm.scss";
16 |
17 | interface CustomCourseFormProps {
18 | tabIndex: number;
19 | busy: boolean;
20 | setBusy: SetState;
21 | nodeData: NodeDataMap;
22 | customCourseData: CourseData;
23 | setCustomCourseData: SetState;
24 | addNewNode: (data: CourseData, position: NewCoursePosition) => void;
25 | }
26 | export default function CustomCourseForm({
27 | tabIndex,
28 | busy,
29 | setBusy,
30 | nodeData,
31 | customCourseData,
32 | setCustomCourseData,
33 | addNewNode,
34 | }: CustomCourseFormProps): JSX.Element {
35 | const customCourseIdRef = useRef(null);
36 | const textAreaRef = useRef(null);
37 | useEffect(() => {
38 | if (tabIndex === 1) {
39 | textAreaRef.current?.setSelectionRange(
40 | customCourseData.description.length,
41 | customCourseData.description.length,
42 | );
43 | customCourseIdRef.current?.focus();
44 | }
45 | }, []);
46 |
47 | function resetCustomCourseData(): void {
48 | setCustomCourseData({
49 | id: "",
50 | name: "",
51 | credits: "",
52 | description: "",
53 | prerequisite: "",
54 | offered: "",
55 | });
56 | }
57 |
58 | const onChangeFn = textChangeUpdater(setCustomCourseData);
59 |
60 | function addCustomCourse(): void {
61 | setBusy(true);
62 | addNewNode(customCourseData, "zero");
63 | resetCustomCourseData();
64 | setBusy(false);
65 | customCourseIdRef.current?.focus();
66 | }
67 |
68 | return (
69 |
70 |
71 |
80 |
90 |
91 |
99 |
107 |
108 |
116 |
117 |
125 |
133 |
134 |
144 | Add custom course
145 |
146 |
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/src/components/dialogs/AddCourseDialog/UwCourseForm.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .UwCourseForm {
4 | display: flex;
5 | flex-direction: column;
6 | align-items: flex-start;
7 |
8 | &__bar-and-button {
9 | width: 100%;
10 | display: flex;
11 | margin-bottom: 2.5rem;
12 | }
13 | [data-reach-combobox] {
14 | max-width: 30rem;
15 | flex-grow: 1;
16 | margin-right: 0.25rem;
17 |
18 | display: flex;
19 | input {
20 | @include base-text-input-style;
21 | flex: 1;
22 | }
23 | }
24 | &__add-button {
25 | @include base-button-style;
26 | height: 1.75rem;
27 | width: 2.5rem;
28 | padding: 0 0.25rem;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/dialogs/AddCourseDialog/index.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .AddCourseDialog {
4 | width: min(30rem, 100% - 1rem);
5 | min-height: 21rem;
6 | display: flex;
7 | flex-direction: column;
8 |
9 | [data-reach-tabs] {
10 | flex: 1;
11 |
12 | display: flex;
13 | flex-direction: column;
14 | }
15 |
16 | [data-reach-tab-panels] {
17 | flex: 1;
18 |
19 | position: relative;
20 | }
21 |
22 | h2 {
23 | position: relative;
24 |
25 | &.connection-error::after {
26 | content: "Connection error";
27 | position: absolute;
28 | display: inline-block;
29 | margin-left: 1rem;
30 | padding: 5px 9px;
31 | border-radius: 4px;
32 |
33 | font-size: 14px;
34 | font-weight: normal;
35 | color: white;
36 | background: darkred;
37 | }
38 | }
39 |
40 | [data-reach-tab-list] {
41 | margin: 0.5rem 0 0.75rem 0;
42 | }
43 |
44 | input,
45 | textarea {
46 | font-family: sans-serif;
47 | }
48 |
49 | input[type=text],
50 | input[type=search] {
51 | @include base-text-input-style
52 | }
53 |
54 | input[type=radio],
55 | input[type=checkbox] {
56 | margin-right: 0.25rem;
57 | }
58 |
59 | fieldset label {
60 | display: block;
61 | }
62 |
63 | &__custom-course-tab-panel,
64 | &__text-search-tab-panel {
65 | position: absolute;
66 | height: 100%;
67 | width: 100%;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/dialogs/AddCourseDialog/types.d.ts:
--------------------------------------------------------------------------------
1 | import type { Campus, ConnectTo } from "types/main";
2 |
3 | export interface UwCourseFormState {
4 | campus: Campus;
5 | searchText: string;
6 | connectTo: ConnectTo;
7 | alwaysAtZero: boolean;
8 | errorMsg: string;
9 | }
10 |
11 | export interface TextSearchState {
12 | text: string;
13 | connectTo: ConnectTo;
14 | errorMsg: string;
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/dialogs/AmbiguitySelect.scss:
--------------------------------------------------------------------------------
1 | .AmbiguitySelect {
2 | border: none;
3 |
4 | display: flex;
5 | flex-direction: column;
6 | align-items: flex-start;
7 |
8 | label {
9 | margin-left: 0.5rem;
10 | input {
11 | margin-right: 0.25rem;
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/dialogs/AmbiguitySelect.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { nanoid } from "nanoid";
3 |
4 | import "./AmbiguitySelect.scss";
5 |
6 | export type AmbiguityHandling = "aggressively" | "cautiously";
7 |
8 | interface AmbiGuitySelectProps {
9 | ambiguityHandling: AmbiguityHandling;
10 | setAmbiguityHandling: (a: AmbiguityHandling) => void;
11 | busy: boolean;
12 | }
13 | export default function AmbiguitySelect({
14 | ambiguityHandling,
15 | setAmbiguityHandling,
16 | busy,
17 | }: AmbiGuitySelectProps): JSX.Element {
18 | const [fieldName, _setFieldName] = useState(() => nanoid());
19 | // Need a unique name for every new instance
20 |
21 | return (
22 |
23 | When prereq text parsing fails, make connections
24 |
25 | setAmbiguityHandling("aggressively")}
30 | />
31 | Aggressively (all possible connections)
32 |
33 |
34 | setAmbiguityHandling("cautiously")}
39 | />
40 | Cautiously (no new connections)
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/dialogs/ArchiveDialog.scss:
--------------------------------------------------------------------------------
1 | .ShutdownDialog {
2 | width: min(35rem, 100% - 1rem);
3 |
4 | h2 {
5 | margin-bottom: 0.5rem;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/dialogs/ArchiveDialog.tsx:
--------------------------------------------------------------------------------
1 | import type { CloseModal, ModalClass } from "@useDialogStatus";
2 | import type { useCookies } from "react-cookie";
3 | import ModalDialog from "./ModalDialog";
4 | import "./ArchiveDialog.scss";
5 |
6 | interface ArchiveDialogProps {
7 | modalCls: ModalClass;
8 | closeDialog: CloseModal;
9 | setCookie: ReturnType[1];
10 | }
11 | export default function ArchiveDialog({
12 | modalCls,
13 | closeDialog,
14 | setCookie,
15 | }: ArchiveDialogProps): JSX.Element {
16 | function close(): void {
17 | closeDialog();
18 | setCookie("archive-notice-seen", true, { sameSite: "lax" });
19 | }
20 |
21 | return (
22 |
29 | Archived on December 1, 2023
30 |
31 | You've reached the archived version of Prereq Flow, last updated on
32 | December 1, 2023. Data for a limited number of majors and courses has
33 | been kept for demo purposes.
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/dialogs/CampusSelect.scss:
--------------------------------------------------------------------------------
1 | .CampusSelect {
2 | margin: 0.25rem 0;
3 | border: none;
4 | display: flex;
5 |
6 | &__radio-button {
7 | margin-right: 0.25rem;
8 | }
9 |
10 | &__radio-label--seattle,
11 | &__radio-label--bothell {
12 | margin-right: 1rem;
13 | }
14 |
15 | &__radio-label--tacoma,
16 | &__radio-label--bothell {
17 | color: #c7c7ce;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/dialogs/CampusSelect.tsx:
--------------------------------------------------------------------------------
1 | import "./CampusSelect.scss";
2 |
3 | import type { Campus } from "types/main";
4 |
5 | interface CampusSelectProps {
6 | selectedCampus: Campus;
7 | setSelectedCampus: (c: Campus) => void;
8 | busy: boolean;
9 | }
10 | export default function CampusSelect({
11 | selectedCampus,
12 | setSelectedCampus,
13 | busy,
14 | }: CampusSelectProps): JSX.Element {
15 | return (
16 |
17 |
18 | setSelectedCampus("Seattle")}
24 | />
25 | Seattle
26 |
27 |
28 | setSelectedCampus("Bothell")}
34 | disabled={true}
35 | />
36 | Bothell
37 |
38 |
39 | setSelectedCampus("Tacoma")}
45 | disabled={true}
46 | />
47 | Tacoma
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/dialogs/EditDataDialog.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .EditDataDialog {
4 | width: min(30rem, 100% - 1rem);
5 | min-height: 21rem;
6 |
7 | display: flex;
8 | flex-direction: column;
9 |
10 | input,
11 | textarea {
12 | font-family: sans-serif;
13 | }
14 |
15 | input[type=text],
16 | input[type=search] {
17 | @include base-text-input-style
18 | }
19 |
20 | .EditDataForm {
21 | flex: 1;
22 |
23 | display: flex;
24 | flex-direction: column;
25 |
26 | &__header-row {
27 | display: flex;
28 | justify-content: flex-start;
29 | align-items: flex-start;
30 | }
31 | &__footer-row {
32 | display: flex;
33 | margin-bottom: 1.5rem;
34 | }
35 |
36 |
37 | &__id-input {
38 | width: 8rem;
39 | }
40 | &__name-input {
41 | margin: 0 0.5rem;
42 | flex-grow: 1;
43 | }
44 | &__credits-input {
45 | width: 3.5rem;
46 | }
47 | &__description-input {
48 | margin: 0.5rem 0;
49 | @include gray-border;
50 | padding: 0.25rem;
51 | resize: none;
52 | flex: 1;
53 | font-size: 0.8rem;
54 | }
55 |
56 | &__prerequisite-input {
57 | flex-grow: 1;
58 | margin-right: 0.25rem;
59 | }
60 | &__offered-input {
61 | flex-grow: 1;
62 | margin-left: 0.25rem;
63 | }
64 |
65 | &__add-button {
66 | height: 2rem;
67 | @include base-button-style;
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/dialogs/ModalDialog/CloseButton.scss:
--------------------------------------------------------------------------------
1 | .CloseButton {
2 | position: absolute;
3 | top: 0.5rem;
4 | right: 0.5rem;
5 | width: 1.6rem;
6 | height: 1.6rem;
7 | z-index: 20;
8 |
9 | appearance: none;
10 | border: none;
11 | background: none;
12 |
13 | &:disabled img {
14 | filter:
15 | invert(55%)
16 | sepia(0%)
17 | saturate(28%)
18 | hue-rotate(137deg)
19 | brightness(92%)
20 | contrast(83%);
21 | }
22 |
23 | // https://codepen.io/sosuke/pen/Pjoqqp
24 | // Red: #FF0000
25 | &:not(:disabled):hover,
26 | &:not(:disabled):active {
27 | img {
28 | filter:
29 | invert(14%)
30 | sepia(100%)
31 | saturate(7418%)
32 | hue-rotate(2deg)
33 | brightness(100%)
34 | contrast(113%);
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/dialogs/ModalDialog/CloseButton.tsx:
--------------------------------------------------------------------------------
1 | import type { RefObject } from "react";
2 |
3 | import xBlackIcon from "@icons/x-black.svg";
4 |
5 | import "./CloseButton.scss";
6 |
7 | interface CloseButtonProps {
8 | btnRef?: RefObject | null;
9 | onClick: () => void;
10 | disabled?: boolean;
11 | }
12 | export default function CloseButton({
13 | btnRef = null,
14 | onClick,
15 | disabled = false,
16 | }: CloseButtonProps): JSX.Element {
17 | return (
18 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/dialogs/ModalDialog/index.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode, KeyboardEvent, RefObject } from "react";
2 |
3 | import { DialogOverlay, DialogContent } from "@reach/dialog";
4 |
5 | import type { ModalClass } from "@useDialogStatus";
6 |
7 | import CloseButton from "./CloseButton";
8 |
9 | interface ModalDialogProps {
10 | modalCls: ModalClass;
11 | close: () => void;
12 | closeBtnRef?: RefObject | null;
13 | busy: boolean;
14 | contentCls: string;
15 | contentAriaLabel: string;
16 | children: ReactNode;
17 | }
18 | export default function ModalDialog({
19 | modalCls,
20 | close,
21 | closeBtnRef = null,
22 | busy,
23 | contentCls,
24 | contentAriaLabel,
25 | children,
26 | }: ModalDialogProps): JSX.Element {
27 | return (
28 | {
33 | if ((event as KeyboardEvent).key === "Escape" && !busy) {
34 | close();
35 | }
36 | }}
37 | >
38 |
39 |
45 | {children}
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/CurriculumSelect.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .CurriculumSelect {
4 | .CampusSelect {
5 | margin: 0.5rem 0;
6 | }
7 |
8 | &__select-input {
9 | height: 1.5rem;
10 | }
11 |
12 | &__external-checkbox {
13 | align-self: flex-start;
14 | margin: 3rem 0 0.5rem;
15 |
16 | input {
17 | margin-right: 0.25rem;
18 | }
19 | }
20 |
21 | &__get-courses-button {
22 | @include base-button-style;
23 | font-size: 1rem;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/CurriculumSelect.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import type { MouseEvent } from "react";
3 |
4 | import Tippy from "@tippyjs/react";
5 | // eslint-disable-next-line import/no-extraneous-dependencies
6 | import "tippy.js/dist/tippy.css";
7 |
8 | import type { SetState } from "types/main";
9 | import { stateUpdater } from "@utils";
10 |
11 | import CampusSelect from "../CampusSelect";
12 | import AmbiguitySelect from "../AmbiguitySelect";
13 |
14 | import "./CurriculumSelect.scss";
15 | import type { CurriculumSelectState } from "./types";
16 |
17 | interface CurriculumSelectProps {
18 | tabIndex: number;
19 | connectionError: boolean;
20 | busy: boolean;
21 | supportedCurricula: [string, string][];
22 | csState: CurriculumSelectState;
23 | setCsState: SetState;
24 | newCurriculumFlow: () => void;
25 | }
26 | export default function CurriculumSelect({
27 | tabIndex,
28 | connectionError,
29 | busy,
30 | supportedCurricula,
31 | csState,
32 | setCsState,
33 | newCurriculumFlow,
34 | }: CurriculumSelectProps): JSX.Element {
35 | const csUpdater = stateUpdater(setCsState);
36 |
37 | useEffect(() => {
38 | if (supportedCurricula.length && !csState.selected) {
39 | csUpdater.value("selected", supportedCurricula[0][0]);
40 | }
41 | }, [supportedCurricula]);
42 |
43 | function getCourses(event: MouseEvent): void {
44 | event.preventDefault();
45 | newCurriculumFlow();
46 | }
47 |
48 | return (
49 |
50 |
{}}
53 | busy={busy}
54 | />
55 |
64 | {
68 | csUpdater.value("selected", e.target.selectedOptions[0].value);
69 | }}
70 | disabled={connectionError || busy || !supportedCurricula.length}
71 | >
72 | {supportedCurricula.map(([id, name]) => (
73 |
74 | {name}
75 |
76 | ))}
77 |
78 |
79 |
80 | {
84 | csUpdater.transform(
85 | "includeExternal",
86 | prev => !prev.includeExternal,
87 | );
88 | }}
89 | disabled={busy}
90 | />
91 | Include external prerequisites
92 |
93 | csUpdater.value("ambiguityHandling", a)}
96 | busy={busy}
97 | />
98 |
99 |
105 | Get courses
106 |
107 |
108 |
109 |
110 | );
111 | }
112 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/DegreeSelect.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .DegreeSelect {
4 | .majors,
5 | .minors {
6 | margin-bottom: 0.5rem;
7 |
8 | &__selected-list {
9 | height: 6rem;
10 | margin: 0.5rem 0;
11 | }
12 | &__selected-item {
13 | display: flex;
14 | justify-content: space-between;
15 | margin: 0.5rem 0;
16 | padding-left: 0.5rem;
17 | font-size: 0.9rem;
18 | line-height: 1.5rem;
19 |
20 | background-color: hsl(0, 0%, 97%);
21 | }
22 |
23 | &__add-button,
24 | &__delete-button {
25 | margin-left : 0.25rem;
26 | height: 1.5rem;
27 | width: 1.5rem;
28 | }
29 |
30 | &__add-button {
31 | @include img-button-style(70);
32 | }
33 | &__delete-button {
34 | @include img-button-style(75);
35 | }
36 |
37 | &__bar-and-button {
38 | display: flex;
39 | justify-content: space-between;
40 | }
41 | &__select-input {
42 | flex-grow: 1;
43 | height: 1.5rem;
44 | }
45 | }
46 |
47 | .AmbiguitySelect {
48 | margin-top: 1rem;
49 | }
50 |
51 | &__get-courses-button {
52 | @include base-button-style;
53 | font-size: 1rem;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/NewFlowTextSearch.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .NewFlowTextSearch {
4 | &__textarea {
5 | flex: 1 0;
6 |
7 | @include gray-border;
8 | width: 100%;
9 | resize: none;
10 | margin: 0.5rem 0 2.5rem;
11 | padding: 0.25rem;
12 |
13 | font-family: sans-serif;
14 | font-size: 0.8rem;
15 | }
16 |
17 | &__button-wrapper {
18 | margin-bottom: 0;
19 | }
20 | &__get-courses-button {
21 | @include base-button-style;
22 | font-size: 1rem;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/NewFlowTextSearch.tsx:
--------------------------------------------------------------------------------
1 | import type { MouseEvent } from "react";
2 | import { useEffect, useRef } from "react";
3 |
4 | import Tippy from "@tippyjs/react";
5 | // eslint-disable-next-line import/no-extraneous-dependencies
6 | import "tippy.js/dist/tippy.css";
7 |
8 | import type { SetState } from "types/main";
9 |
10 | import "./NewFlowTextSearch.scss";
11 | import AmbiguitySelect from "../AmbiguitySelect";
12 |
13 | import type { TextSearchState } from "./types";
14 |
15 | interface TextSearchProps {
16 | tabIndex: number;
17 | connectionError: boolean;
18 | busy: boolean;
19 | tsState: TextSearchState;
20 | setTsState: SetState;
21 | newTextSearchFlow: () => Promise;
22 | }
23 | export default function NewFlowTextSearch({
24 | tabIndex,
25 | connectionError,
26 | busy,
27 | tsState,
28 | setTsState,
29 | newTextSearchFlow,
30 | }: TextSearchProps): JSX.Element {
31 | function generateFlow(event: MouseEvent): void {
32 | event.preventDefault();
33 | newTextSearchFlow();
34 | }
35 |
36 | const textAreaRef = useRef(null);
37 | useEffect(() => {
38 | if (tabIndex === 2) {
39 | textAreaRef.current?.setSelectionRange(
40 | tsState.text.length,
41 | tsState.text.length,
42 | );
43 | textAreaRef.current?.focus();
44 | }
45 | }, []);
46 |
47 | return (
48 |
49 |
58 |
64 | setTsState(prev => ({
65 | ...prev,
66 | text: e.target.value.toUpperCase(),
67 | errorMsg: "",
68 | }))
69 | }
70 | disabled={connectionError || busy}
71 | >
72 |
73 |
74 |
{
77 | setTsState(prev => ({ ...prev, ambiguityHandling: a }));
78 | }}
79 | busy={busy}
80 | />
81 |
82 |
83 |
89 | Get courses
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/PreWarning.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .PreWarning {
4 | & > section {
5 | position: relative;
6 | }
7 | overflow-y: scroll;
8 |
9 | p:not(:first-of-type) {
10 | margin-top: 1rem;
11 | }
12 |
13 |
14 | li {
15 | margin-top: 0.25rem;
16 | margin-left: 1.5rem;
17 | list-style-type: square;
18 | }
19 |
20 | &__accept-button {
21 | @include base-button-style;
22 | font-size: 1rem;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/PreWarning.tsx:
--------------------------------------------------------------------------------
1 | import type { RefObject } from "react";
2 |
3 | import "./PreWarning.scss";
4 |
5 | import type { SetState } from "types/main";
6 |
7 | interface PreWarningProps {
8 | warningAccepted: number;
9 | setWarningAccepted: SetState;
10 | closeButtonRef: RefObject;
11 | }
12 | export default function PreWarning({
13 | warningAccepted,
14 | setWarningAccepted,
15 | closeButtonRef,
16 | }: PreWarningProps): JSX.Element {
17 | return (
18 |
19 |
20 | ⚠️ Important information ⚠️
21 |
22 | Prereq Flow is not an official University of Washington resource. No
23 | guarantees are made about the accuracy, completeness, or
24 | up-to-dateness of any information presented.
25 |
26 |
27 | Some limitations to keep in mind:
28 |
29 | Grouping and either/or conditions are not displayed.
30 | Co-requisite conditions are not displayed.
31 | Registration restrictions are not displayed.
32 |
33 |
34 |
35 | All caveats for Prereq Map (now part of{" "}
36 |
42 | DawgPath
43 |
44 | ) also apply here:
45 |
46 |
47 |
48 | Prerequisites and graduation requirements may change over time.
49 |
50 |
51 | Non-course graduation requirements (e.g. 5 credits of VLPA) are
52 | not displayed.
53 |
54 |
55 | Equivalencies (e.g. placements tests, AP credits) are
56 | not displayed.
57 |
58 |
59 | Talk to your advisor when course planning.
60 |
61 | setWarningAccepted(1)}
65 | onKeyDown={event => {
66 | if (event.key === "Tab" && !warningAccepted) {
67 | event.preventDefault();
68 | closeButtonRef.current?.focus();
69 | }
70 | }}
71 | disabled={!!warningAccepted}
72 | >
73 | Continue
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/index.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .NewFlowDialog {
4 | width: min(30rem, 100% - 1rem);
5 | overflow-x: hidden;
6 | padding: 1rem 0 0 0;
7 |
8 | h2 {
9 | margin: 0.5rem 1rem;
10 | margin-top: 0;
11 |
12 | position: relative;
13 |
14 | &.connection-error::after {
15 | content: "Connection error";
16 | position: absolute;
17 | display: inline-block;
18 | margin-left: 1rem;
19 | padding: 5px 9px;
20 | border-radius: 4px;
21 |
22 | font-size: 14px;
23 | font-weight: normal;
24 | color: white;
25 | background: darkred;
26 | }
27 | }
28 |
29 | h3 {
30 | margin: 0.5rem 0;
31 | }
32 |
33 | hr {
34 | margin: 0 1rem;
35 | }
36 |
37 | &__slides {
38 | width: 200%;
39 |
40 | display: flex;
41 | max-height: 26rem;
42 |
43 | @media (prefers-reduced-motion: no-preference) {
44 | transition: transform 250ms;
45 | }
46 |
47 | // No transform for slide-0
48 | &.slide-1 {
49 | transform: translateX(calc(100%/-2));
50 | }
51 |
52 | & > * {
53 | width: calc(100%/2);
54 | padding: 0 1rem;
55 | }
56 | }
57 | }
58 |
59 | .FlowType {
60 | [data-reach-tab-list] {
61 | height: 1.8rem;
62 | }
63 |
64 | [data-reach-tabs] {
65 | height: calc(100% - 1rem);
66 | }
67 |
68 | [data-reach-tab-panels] {
69 | height: calc(100% - 1.8rem);
70 | }
71 |
72 | [data-reach-tab-panel] {
73 | height: 100%;
74 | }
75 | }
76 |
77 | .PreWarning,
78 | .DegreeSelect,
79 | .CurriculumSelect,
80 | .NewFlowTextSearch,
81 | .NewBlankFlow {
82 | display: flex;
83 | flex-direction: column;
84 |
85 | &__button-wrapper {
86 | margin: 1rem 0;
87 | display: flex;
88 | justify-content: flex-end;
89 | }
90 | }
91 |
92 | .DegreeSelect,
93 | .CurriculumSelect,
94 | .NewFlowTextSearch,
95 | .NewBlankFlow {
96 | height: 100%;
97 |
98 | &__end-padding {
99 | flex-grow: 1;
100 | background-image: url("../../../icons/x-gray.svg");
101 | }
102 | }
103 |
104 | .NewBlankFlow {
105 | p {
106 | margin-top: 0.5rem;
107 | }
108 |
109 | &__generate-button {
110 | @include base-button-style;
111 | font-size: 1rem;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/dialogs/NewFlowDialog/types.d.ts:
--------------------------------------------------------------------------------
1 | import type { AmbiguityHandling } from "../AmbiguitySelect";
2 |
3 | export interface DegreeSelectState {
4 | majors: string[];
5 | selected: string;
6 | ambiguityHandling: AmbiguityHandling;
7 | errorMsg: string;
8 | }
9 |
10 | export interface CurriculumSelectState {
11 | selected: string;
12 | includeExternal: boolean;
13 | ambiguityHandling: AmbiguityHandling;
14 | errorMsg: string;
15 | }
16 |
17 | export interface TextSearchState {
18 | text: string;
19 | ambiguityHandling: AmbiguityHandling;
20 | errorMsg: string;
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/dialogs/OpenFileDialog/Dropzone.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useMemo } from "react";
2 |
3 | import classNames from "classnames";
4 |
5 | import { useDropzone } from "react-dropzone";
6 | // https://react-dropzone.js.org/#section-styling-dropzone
7 |
8 | import type { SetState } from "types/main";
9 |
10 | interface DropzoneProps {
11 | busy: boolean;
12 | errorMsg: string;
13 | setErrorMsg: SetState;
14 | openFile: (files: File[]) => void;
15 | }
16 | export default function Dropzone({
17 | busy,
18 | errorMsg,
19 | setErrorMsg,
20 | openFile,
21 | }: DropzoneProps): JSX.Element {
22 | const [message, setMessage] = useState("Drop file or click to select");
23 |
24 | // Moved to CSS
25 | // const baseStyle = {
26 | // flex: 1,
27 | // display: "flex",
28 | // flexDirection: "column",
29 | // alignItems: "center",
30 | // padding: "2rem 1rem",
31 | // borderWidth: 2,
32 | // borderRadius: 2,
33 | // borderColor: "gray",
34 | // borderStyle: "dashed",
35 | // backgroundColor: "#fafafa",
36 | // color: "black",
37 | // outline: "none",
38 | // transition: !prefersReducedMotion ? "border .24s ease-in-out" : "none",
39 | // };
40 | // const activeStyle = {
41 | // borderColor: "#2196f3"
42 | // };
43 |
44 | const {
45 | getRootProps,
46 | getInputProps,
47 | // isDragActive,
48 | isDragAccept,
49 | isDragReject,
50 | } = useDropzone({
51 | accept: "application/json",
52 | disabled: busy,
53 | multiple: false,
54 | onDropAccepted: files => {
55 | setMessage("Opening...");
56 | setErrorMsg("");
57 | openFile(files);
58 | },
59 | onDropRejected: fileRejections => {
60 | if (fileRejections.length > 1) {
61 | setErrorMsg("Only one file allowed");
62 | } else {
63 | setErrorMsg("Invalid file type");
64 | }
65 | },
66 | });
67 |
68 | const style = useMemo(() => {
69 | const acceptStyle = {
70 | borderColor: "lime",
71 | };
72 | const rejectStyle = {
73 | borderColor: "red",
74 | };
75 | return {
76 | // ...baseStyle,
77 | // ...(isDragActive ? activeStyle : {}), // Not sure what this does
78 | ...(isDragAccept ? acceptStyle : {}),
79 | ...(isDragReject ? rejectStyle : {}),
80 | };
81 | }, [isDragReject, isDragAccept]);
82 |
83 | return (
84 |
85 | {/* eslint-disable-next-line react/jsx-props-no-spreading */}
86 |
87 | {/* eslint-disable-next-line react/jsx-props-no-spreading */}
88 |
89 |
90 | {errorMsg.length ? errorMsg : message}
91 |
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/dialogs/OpenFileDialog/index.scss:
--------------------------------------------------------------------------------
1 | .OpenFileDialog {
2 | width: min(20rem, 100% - 1rem);
3 | min-height: 10rem;
4 |
5 | h2 {
6 | margin-bottom: 0.5rem;
7 | }
8 |
9 | .Dropzone {
10 | & > div {
11 | display: flex;
12 | flex-direction: column;
13 | align-items: center;
14 | padding: 2rem 1rem;
15 | border-width: 2px;
16 | border-radius: 2px;
17 | border-color: gray;
18 | border-style: dashed;
19 | background-color: hsl(0, 0%, 98%);
20 | color: black;
21 | outline: none;
22 | @media (prefers-reduced-motion: no-preference) {
23 | transition: border .24s ease-in-out;
24 | }
25 |
26 | &:hover,
27 | &:focus {
28 | background-color: hsl(0, 0%, 95%);
29 | }
30 | }
31 |
32 | &--disabled > div {
33 | background-color: hsl(0, 0%, 90%);
34 | }
35 |
36 | p.error {
37 | color: red;
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/dialogs/TableDialog.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .TableDialog {
4 | overflow-y: auto;
5 |
6 | // https://css-tricks.com/sticky-as-a-local-fixed/
7 | .CloseButton {
8 | position: sticky;
9 | top: 0;
10 | left: calc(100% - 1.5rem);
11 | }
12 |
13 | &__prereq-warning {
14 | margin-bottom: 1rem;
15 | }
16 |
17 | .SortBy {
18 | margin: 0.25rem 0;
19 | border: none;
20 | display: flex;
21 |
22 | &__radio-label:first-child {
23 | margin-left: 0.5rem;
24 | }
25 |
26 | &__radio-label:not(:last-child) {
27 | margin-right: 1rem;
28 | }
29 |
30 | &__radio-button {
31 | margin-right: 0.25rem;
32 | }
33 | }
34 |
35 | &__course-table {
36 | border-collapse: collapse;
37 | font-size: 0.9rem;
38 |
39 | tr:nth-child(even) td {
40 | background-color: rgb(250,250,250);
41 | }
42 |
43 | tr:nth-child(odd) td {
44 | background-color: rgb(245,245,245);
45 | }
46 |
47 | td,
48 | th {
49 | border: 1px solid rgb(190,190,190);
50 | padding: 0.75rem 0.5rem;
51 |
52 | // Name
53 | &:nth-child(3) {
54 | min-width: 10rem;
55 | max-width: 20rem;
56 | }
57 |
58 | // Prerequisite
59 | &:nth-child(4) {
60 | min-width: 10rem;
61 | max-width: 30rem;
62 |
63 | a[class="uw-course-id"] {
64 | text-decoration: none;
65 |
66 | &:hover {
67 | text-decoration: underline;
68 | }
69 | }
70 | }
71 |
72 | // Offered
73 | &:nth-child(5) {
74 | max-width: 12.5rem;
75 |
76 | .offered-autumn,
77 | .offered-winter,
78 | .offered-spring,
79 | .offered-summer {
80 | border-radius: 0;
81 | }
82 | }
83 | }
84 |
85 | th {
86 | background-color: rgb(235,235,235);
87 | text-align: start;
88 | }
89 |
90 | td {
91 | vertical-align: top;
92 |
93 | // Depth
94 | &:first-child {
95 | text-align: center;
96 | }
97 |
98 | &:nth-child(2),
99 | &:nth-child(4),
100 | &:nth-child(6),
101 | &:nth-child(7) {
102 | a:hover {
103 | @each $status, $color in $flow-colors {
104 | &.#{$status} {
105 | color: white;
106 | background-color: $color;
107 | text-decoration: none;
108 | }
109 | }
110 | }
111 | }
112 |
113 | &:nth-child(2) a,
114 | &:nth-child(4) a,
115 | &:nth-child(6) a,
116 | &:nth-child(7) a,
117 | span {
118 | @each $status, $color in $flow-colors {
119 | padding: 0 0.1rem;
120 | border-radius: 3px;
121 | &.#{$status} {
122 | text-decoration: underline $color 3px;
123 | }
124 | }
125 | }
126 |
127 | .uw-course-id {
128 | white-space: nowrap;
129 | }
130 | }
131 |
132 |
133 | ul {
134 | list-style-type: none;
135 | }
136 |
137 | li {
138 | display: flex;
139 | align-items: center;
140 | white-space: nowrap;
141 |
142 | height: 1.4rem;
143 |
144 | span {
145 | height: 1.25rem;
146 | line-height: 1.25rem;
147 | }
148 | }
149 | }
150 |
151 | &__small-delete-btn {
152 | height: 1.25rem;
153 | width: 1.25rem;
154 | margin-right: 0.1rem;
155 |
156 | @include img-button-style(100);
157 | }
158 |
159 |
160 | &__large-delete-btn {
161 | margin: 0 auto;
162 | height: 2rem;
163 | width: 2rem;
164 |
165 | @include img-button-style(100);
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/src/components/flow/AndNode.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 |
3 | import { Handle, Position } from "react-flow-renderer";
4 |
5 | import type { BaseNodeData } from "types/main";
6 |
7 | export default function AndNode({ data }: { data: BaseNodeData }): JSX.Element {
8 | return (
9 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/flow/CourseNode.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from "react";
2 |
3 | import classNames from "classnames";
4 |
5 | import Tippy from "@tippyjs/react";
6 | // eslint-disable-next-line import/no-extraneous-dependencies
7 | import "tippy.js/dist/tippy.css";
8 |
9 | import { Handle, Position } from "react-flow-renderer";
10 |
11 | import type { InnerText, CourseNodeData } from "types/main";
12 |
13 | import usePrefersReducedMotion from "@usePrefersReducedMotion";
14 | import { CRS } from "@utils";
15 |
16 | function capitalizeFirstCharacter(text: string): string {
17 | return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
18 | }
19 |
20 | export function splitByCourses(
21 | text: string,
22 | capitalizeFirst = false,
23 | ): InnerText {
24 | if (capitalizeFirst) {
25 | return capitalizeFirstCharacter(text)
26 | .replaceAll("/", "/\u200B")
27 | .split(new RegExp(`(${CRS})`));
28 | } else {
29 | return text.replaceAll("/", "/\u200B").split(new RegExp(`(${CRS})`));
30 | }
31 | }
32 |
33 | export function generateUwCourseElements(
34 | innerHTML: InnerText,
35 | elemFunc: (elemText: string, i: number) => ReactElement,
36 | ): void {
37 | for (let i = 1; i < innerHTML.length; i += 2) {
38 | innerHTML[i] = elemFunc(innerHTML[i] as string, i);
39 | }
40 | }
41 |
42 | function highlightUwCourses(text: string): InnerText {
43 | const innerHTML = splitByCourses(text, true);
44 | generateUwCourseElements(innerHTML, (elemText, i) => (
45 |
46 | {elemText}
47 |
48 | ));
49 | return innerHTML;
50 | }
51 |
52 | const QUARTER_REGEX = {
53 | autumn: /\bA(?=W|Sp|S|\b)(?=[WSp]*\.\s*$)/,
54 | winter: /\bW(?=Sp|S|\b)(?=[Sp]*\.\s*$)/,
55 | spring: /\bSp(?=S|\b)(?=S?\.\s*$)/,
56 | summer: /\bS(?=\b\.\s*$)/,
57 | };
58 | export function markOfferedQuarters(innerHTML: InnerText): void {
59 | for (const [quarter, regex] of Object.entries(QUARTER_REGEX)) {
60 | const lastIndex = innerHTML.length - 1;
61 | const remainingText = innerHTML[lastIndex] as string;
62 | const match = remainingText.match(regex);
63 | if (match) {
64 | const [matchStr] = match;
65 | const remainingItems: InnerText = remainingText.split(regex);
66 | remainingItems.splice(
67 | 1,
68 | 0,
69 |
70 | {matchStr}
71 | ,
72 | );
73 | innerHTML.splice(lastIndex, 1, ...remainingItems);
74 | }
75 | }
76 | }
77 |
78 | interface CourseNodeProps {
79 | data: CourseNodeData;
80 | }
81 | export default function CourseNode({ data }: CourseNodeProps): JSX.Element {
82 | const prefersReducedMotion = usePrefersReducedMotion();
83 |
84 | const descriptionHTML = highlightUwCourses(data.description);
85 | const prereqHTML = highlightUwCourses(data.prerequisite);
86 | let offeredHTML = null;
87 | if (data.offered) {
88 | offeredHTML = splitByCourses(data.offered, true);
89 | generateUwCourseElements(offeredHTML, (elemText, i) => (
90 |
91 | {elemText}
92 |
93 | ));
94 | markOfferedQuarters(offeredHTML);
95 | offeredHTML = Offered: {offeredHTML}
;
96 | }
97 |
98 | const tippyContent = (
99 | <>
100 |
101 | {data.id} — {data.name} ({data.credits})
102 |
103 | {descriptionHTML}
104 |
105 | Prerequisite: {prereqHTML}
106 | {offeredHTML}
107 | >
108 | );
109 |
110 | return (
111 |
122 |
127 |
128 |
{data.id}
129 |
130 |
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/src/components/flow/CustomEdge.tsx:
--------------------------------------------------------------------------------
1 | import type { Position } from "react-flow-renderer";
2 | import { getBezierPath, getEdgeCenter, EdgeText } from "react-flow-renderer";
3 |
4 | const CONCURRENT_LABEL = {
5 | label: "CC",
6 | labelBgPadding: [2, 2] as [number, number],
7 | labelBgBorderRadius: 4,
8 | };
9 |
10 | interface CustomEdgeProps {
11 | id: string;
12 | sourceX: number;
13 | sourceY: number;
14 | targetX: number;
15 | targetY: number;
16 | sourcePosition: Position;
17 | targetPosition: Position;
18 | data: { concurrent: boolean };
19 | }
20 | export default function CustomEdge({
21 | id,
22 | sourceX,
23 | sourceY,
24 | targetX,
25 | targetY,
26 | sourcePosition,
27 | targetPosition,
28 | data = { concurrent: false },
29 | }: CustomEdgeProps): JSX.Element {
30 | const edgePath = getBezierPath({
31 | sourceX,
32 | sourceY,
33 | sourcePosition,
34 | targetX,
35 | targetY,
36 | targetPosition,
37 | });
38 | const [centerX, centerY, _offsetX, _offsetY] = getEdgeCenter({
39 | sourceX,
40 | sourceY,
41 | targetX,
42 | targetY,
43 | });
44 |
45 | return (
46 | <>
47 |
48 |
54 | >
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/flow/FlowInternalLifter.tsx:
--------------------------------------------------------------------------------
1 | import type { MutableRefObject } from "react";
2 |
3 | // https://github.com/wbkd/react-flow/blob/main/src/store/actions.ts
4 | import { useStoreState, useStoreActions } from "react-flow-renderer";
5 | import type { NodePosUpdate, FlowElement } from "react-flow-renderer";
6 |
7 | import type { Element } from "types/main";
8 |
9 | export type UpdateNodePos = ({ id, pos }: NodePosUpdate) => void;
10 | export type SelectedElements = FlowElement[];
11 | export type SetSelectedElements = (e: FlowElement[]) => void;
12 |
13 | interface FlowInternalLifterProps {
14 | updateNodePos: MutableRefObject;
15 | selectedElements: MutableRefObject;
16 | setSelectedElements: MutableRefObject;
17 | resetSelectedElements: MutableRefObject<() => void>;
18 | unsetNodesSelection: MutableRefObject<() => void>;
19 | }
20 | export default function FlowInternalLifter({
21 | updateNodePos,
22 | selectedElements,
23 | setSelectedElements,
24 | resetSelectedElements,
25 | unsetNodesSelection,
26 | }: FlowInternalLifterProps): null {
27 | updateNodePos.current = useStoreActions(state => state.updateNodePos);
28 | selectedElements.current = useStoreState(
29 | state => state.selectedElements,
30 | ) as Element[];
31 | setSelectedElements.current = useStoreActions(
32 | action => action.setSelectedElements,
33 | );
34 | resetSelectedElements.current = useStoreActions(
35 | action => action.resetSelectedElements,
36 | );
37 | unsetNodesSelection.current = useStoreActions(
38 | actions => actions.unsetNodesSelection,
39 | );
40 |
41 | return null;
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/flow/OrNode.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 |
3 | import { Handle, Position } from "react-flow-renderer";
4 |
5 | import type { BaseNodeData } from "types/main";
6 |
7 | export default function OrNode({ data }: { data: BaseNodeData }): JSX.Element {
8 | return (
9 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/header/Header.scss:
--------------------------------------------------------------------------------
1 | @use "src/utils" as *;
2 |
3 | .Header {
4 | position: absolute;
5 | top: 0;
6 | left: 50%;
7 | transform: translateX(-50%);
8 | z-index: 10;
9 |
10 | &--pinned {
11 | .Header__content {
12 | transform: translateY(0);
13 | }
14 | .Header__pin-button img {
15 | transform: rotate(0);
16 | }
17 | }
18 |
19 | &:hover &__content{
20 | transform: translateY(0);
21 | }
22 | &:not(:hover) &__content {
23 | transition-delay: 500ms;
24 | }
25 |
26 | &__content {
27 | transform: translateY(-100%);
28 |
29 | @media (prefers-reduced-motion: no-preference) {
30 | transition: transform 250ms;
31 | }
32 |
33 | @include lightgray-border;
34 | border-top: none;
35 | border-top-left-radius: 0;
36 | border-top-right-radius: 0;
37 | padding: 0.5rem;
38 | background: rgba(white, 0.95);
39 | }
40 |
41 | &__pin-button {
42 | position: absolute;
43 | top: 0.5rem;
44 | left: 0.5rem;
45 | z-index: 10;
46 |
47 | height: 1.5rem;
48 | width: 1.5rem;
49 |
50 | @include img-button-style(90);
51 |
52 | img {
53 | transform: rotate(-90deg);
54 | @media (prefers-reduced-motion: no-preference) {
55 | transition: transform 50ms;
56 | }
57 | }
58 |
59 | &:not(:disabled):hover img {
60 | filter: invert(100%);
61 | }
62 | }
63 |
64 | h1 {
65 | text-align: center;
66 | }
67 |
68 | &__nav-buttons {
69 | display: flex;
70 | margin-top: 0.5rem;
71 |
72 | .HeaderButton {
73 | @include base-button-style;
74 | margin: 0 0.25rem;
75 | &:first-child {
76 | margin-left: 0;
77 | }
78 | &:last-child {
79 | margin-right: 0;
80 | }
81 | }
82 | }
83 |
84 | &__version {
85 | position: absolute;
86 | top: 0.5rem;
87 | right: 0.5rem;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/header/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import type { ReactNode } from "react";
3 |
4 | import classNames from "classnames";
5 |
6 | import Tippy from "@tippyjs/react";
7 | // eslint-disable-next-line import/no-extraneous-dependencies
8 | import "tippy.js/dist/tippy.css";
9 |
10 | import usePrefersReducedMotion from "@usePrefersReducedMotion";
11 | import triangleIcon from "@icons/triangle.svg";
12 |
13 | import "./Header.scss";
14 |
15 | interface HeaderProps {
16 | children: ReactNode;
17 | }
18 | export default function Header({ children }: HeaderProps): JSX.Element {
19 | const prefersReducedMotion = usePrefersReducedMotion();
20 |
21 | const [pinned, setPinned] = useState(true);
22 |
23 | return (
24 |
25 |
26 |
33 | setPinned(!pinned)}
37 | >
38 |
39 |
40 |
41 |
Prereq Flow
42 |
{children}
43 |
Archived
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/header/HeaderButton.tsx:
--------------------------------------------------------------------------------
1 | import Tippy from "@tippyjs/react";
2 | // eslint-disable-next-line import/no-extraneous-dependencies
3 | import "tippy.js/dist/tippy.css";
4 |
5 | import usePrefersReducedMotion from "@usePrefersReducedMotion";
6 |
7 | interface HeaderButtonProps {
8 | label: string;
9 | description: string;
10 | onClick: () => void;
11 | }
12 | export default function HeaderButton({
13 | label,
14 | description,
15 | onClick,
16 | }: HeaderButtonProps): JSX.Element {
17 | const prefersReducedMotion = usePrefersReducedMotion();
18 |
19 | return (
20 |
27 |
28 | {label}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/dagre/acyclic.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | import { greedyFAS } from "./greedy-fas";
4 |
5 | function dfsFAS(g) {
6 | const fas = [];
7 | const stack = {};
8 | const visited = {};
9 |
10 | function dfs(v) {
11 | if (_.has(visited, v)) {
12 | return;
13 | }
14 | visited[v] = true;
15 | stack[v] = true;
16 | _.forEach(g.outEdges(v), e => {
17 | if (_.has(stack, e.w)) {
18 | fas.push(e);
19 | } else {
20 | dfs(e.w);
21 | }
22 | });
23 | delete stack[v];
24 | }
25 |
26 | _.forEach(g.nodes(), dfs);
27 | return fas;
28 | }
29 |
30 | export function run(g) {
31 | function weightFn(g_) {
32 | return function (e) {
33 | return g_.edge(e).weight;
34 | };
35 | }
36 |
37 | const fas =
38 | g.graph().acyclicer === "greedy" ? greedyFAS(g, weightFn(g)) : dfsFAS(g);
39 | _.forEach(fas, e => {
40 | const label = g.edge(e);
41 | g.removeEdge(e);
42 | label.forwardName = e.name;
43 | label.reversed = true;
44 | g.setEdge(e.w, e.v, label, _.uniqueId("rev"));
45 | });
46 | }
47 |
48 | export function undo(g) {
49 | _.forEach(g.edges(), e => {
50 | const label = g.edge(e);
51 | if (label.reversed) {
52 | g.removeEdge(e);
53 |
54 | const { forwardName } = label;
55 | delete label.reversed;
56 | delete label.forwardName;
57 | g.setEdge(e.w, e.v, label, forwardName);
58 | }
59 | });
60 | }
61 |
--------------------------------------------------------------------------------
/src/dagre/add-border-segments.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | import { addDummyNode } from "./util";
4 |
5 | function addBorderNode(g, prop, prefix, sg, sgNode, rank) {
6 | const label = { width: 0, height: 0, rank, borderType: prop };
7 | const prev = sgNode[prop][rank - 1];
8 | const curr = addDummyNode(g, "border", label, prefix);
9 | sgNode[prop][rank] = curr;
10 | g.setParent(curr, sg);
11 | if (prev) {
12 | g.setEdge(prev, curr, { weight: 1 });
13 | }
14 | }
15 |
16 | export function addBorderSegments(g) {
17 | function dfs(v) {
18 | const children = g.children(v);
19 | const node = g.node(v);
20 | if (children.length) {
21 | _.forEach(children, dfs);
22 | }
23 |
24 | if (_.has(node, "minRank")) {
25 | node.borderLeft = [];
26 | node.borderRight = [];
27 | for (
28 | let rank = node.minRank, maxRank = node.maxRank + 1;
29 | rank < maxRank;
30 | ++rank
31 | ) {
32 | addBorderNode(g, "borderLeft", "_bl", v, node, rank);
33 | addBorderNode(g, "borderRight", "_br", v, node, rank);
34 | }
35 | }
36 | }
37 |
38 | _.forEach(g.children(), dfs);
39 | }
40 |
--------------------------------------------------------------------------------
/src/dagre/coordinate-system.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | function swapWidthHeightOne(attrs) {
4 | const w = attrs.width;
5 | attrs.width = attrs.height;
6 | attrs.height = w;
7 | }
8 |
9 | function swapWidthHeight(g) {
10 | _.forEach(g.nodes(), v => {
11 | swapWidthHeightOne(g.node(v));
12 | });
13 | _.forEach(g.edges(), e => {
14 | swapWidthHeightOne(g.edge(e));
15 | });
16 | }
17 |
18 | export function adjust(g) {
19 | const rankDir = g.graph().rankdir.toLowerCase();
20 | if (rankDir === "lr" || rankDir === "rl") {
21 | swapWidthHeight(g);
22 | }
23 | }
24 |
25 | function reverseYOne(attrs) {
26 | attrs.y = -attrs.y;
27 | }
28 |
29 | function reverseY(g) {
30 | _.forEach(g.nodes(), v => {
31 | reverseYOne(g.node(v));
32 | });
33 |
34 | _.forEach(g.edges(), e => {
35 | const edge = g.edge(e);
36 | _.forEach(edge.points, reverseYOne);
37 | if (_.has(edge, "y")) {
38 | reverseYOne(edge);
39 | }
40 | });
41 | }
42 |
43 | function swapXYOne(attrs) {
44 | const { x } = attrs;
45 | attrs.x = attrs.y;
46 | attrs.y = x;
47 | }
48 |
49 | function swapXY(g) {
50 | _.forEach(g.nodes(), v => {
51 | swapXYOne(g.node(v));
52 | });
53 |
54 | _.forEach(g.edges(), e => {
55 | const edge = g.edge(e);
56 | _.forEach(edge.points, swapXYOne);
57 | if (_.has(edge, "x")) {
58 | swapXYOne(edge);
59 | }
60 | });
61 | }
62 |
63 | export function undo(g) {
64 | const rankDir = g.graph().rankdir.toLowerCase();
65 | if (rankDir === "bt" || rankDir === "rl") {
66 | reverseY(g);
67 | }
68 |
69 | if (rankDir === "lr" || rankDir === "rl") {
70 | swapXY(g);
71 | swapWidthHeight(g);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/dagre/data/list.js:
--------------------------------------------------------------------------------
1 | function unlink(entry) {
2 | entry._prev._next = entry._next;
3 | entry._next._prev = entry._prev;
4 | delete entry._next;
5 | delete entry._prev;
6 | }
7 |
8 | function filterOutLinks(k, v) {
9 | if (k !== "_next" && k !== "_prev") {
10 | return v;
11 | }
12 | }
13 |
14 | /*
15 | * Simple doubly linked list implementation derived from Cormen, et al.,
16 | * "Introduction to Algorithms".
17 | */
18 | export class List {
19 | constructor() {
20 | const sentinel = {};
21 | sentinel._next = sentinel;
22 | sentinel._prev = sentinel;
23 | this._sentinel = sentinel;
24 | }
25 |
26 | dequeue() {
27 | const sentinel = this._sentinel;
28 | const entry = sentinel._prev;
29 | if (entry !== sentinel) {
30 | unlink(entry);
31 | return entry;
32 | }
33 | }
34 |
35 | enqueue(entry) {
36 | const sentinel = this._sentinel;
37 | if (entry._prev && entry._next) {
38 | unlink(entry);
39 | }
40 | entry._next = sentinel._next;
41 | sentinel._next._prev = entry;
42 | sentinel._next = entry;
43 | entry._prev = sentinel;
44 | }
45 |
46 | toString() {
47 | const strs = [];
48 | const sentinel = this._sentinel;
49 | let curr = sentinel._prev;
50 | while (curr !== sentinel) {
51 | strs.push(JSON.stringify(curr, filterOutLinks));
52 | curr = curr._prev;
53 | }
54 | return `[${strs.join(", ")}]`;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/dagre/debug.js:
--------------------------------------------------------------------------------
1 | import Graph from "../graphlib";
2 | import _ from "lodash";
3 |
4 | import { buildLayerMatrix } from "./util";
5 |
6 | export function debugOrdering(g) {
7 | const layerMatrix = buildLayerMatrix(g);
8 |
9 | const h = new Graph({ compound: true, multigraph: true }).setGraph({});
10 |
11 | _.forEach(g.nodes(), v => {
12 | h.setNode(v, { label: v });
13 | h.setParent(v, `layer${g.node(v).rank}`);
14 | });
15 |
16 | _.forEach(g.edges(), e => {
17 | h.setEdge(e.v, e.w, {}, e.name);
18 | });
19 |
20 | _.forEach(layerMatrix, (layer, i) => {
21 | const layerV = `layer${i}`;
22 | h.setNode(layerV, { rank: "same" });
23 | _.reduce(layer, (u, v) => {
24 | h.setEdge(u, v, { style: "invis" });
25 | return v;
26 | });
27 | });
28 |
29 | return h;
30 | }
31 |
--------------------------------------------------------------------------------
/src/dagre/greedy-fas.js:
--------------------------------------------------------------------------------
1 | import Graph from "../graphlib";
2 | import _ from "lodash";
3 |
4 | import { List } from "./data/list";
5 |
6 | const DEFAULT_WEIGHT_FN = _.constant(1);
7 |
8 | function assignBucket(buckets, zeroIdx, entry) {
9 | if (!entry.out) {
10 | buckets[0].enqueue(entry);
11 | } else if (!entry.in) {
12 | buckets[buckets.length - 1].enqueue(entry);
13 | } else {
14 | buckets[entry.out - entry.in + zeroIdx].enqueue(entry);
15 | }
16 | }
17 |
18 | function removeNode(g, buckets, zeroIdx, entry, collectPredecessors) {
19 | const results = collectPredecessors ? [] : undefined;
20 |
21 | _.forEach(g.inEdges(entry.v), edge => {
22 | const weight = g.edge(edge);
23 | const uEntry = g.node(edge.v);
24 |
25 | if (collectPredecessors) {
26 | results.push({ v: edge.v, w: edge.w });
27 | }
28 |
29 | uEntry.out -= weight;
30 | assignBucket(buckets, zeroIdx, uEntry);
31 | });
32 |
33 | _.forEach(g.outEdges(entry.v), edge => {
34 | const weight = g.edge(edge);
35 | const { w } = edge;
36 | const wEntry = g.node(w);
37 | wEntry.in -= weight;
38 | assignBucket(buckets, zeroIdx, wEntry);
39 | });
40 |
41 | g.removeNode(entry.v);
42 |
43 | return results;
44 | }
45 |
46 | function buildState(g, weightFn) {
47 | const fasGraph = new Graph();
48 | let maxIn = 0;
49 | let maxOut = 0;
50 |
51 | _.forEach(g.nodes(), v => {
52 | fasGraph.setNode(v, { v, in: 0, out: 0 });
53 | });
54 |
55 | // Aggregate weights on nodes, but also sum the weights across multi-edges
56 | // into a single edge for the fasGraph.
57 | _.forEach(g.edges(), e => {
58 | const prevWeight = fasGraph.edge(e.v, e.w) || 0;
59 | const weight = weightFn(e);
60 | const edgeWeight = prevWeight + weight;
61 | fasGraph.setEdge(e.v, e.w, edgeWeight);
62 | maxOut = Math.max(maxOut, (fasGraph.node(e.v).out += weight));
63 | maxIn = Math.max(maxIn, (fasGraph.node(e.w).in += weight));
64 | });
65 |
66 | const buckets = _.range(maxOut + maxIn + 3).map(() => new List());
67 | const zeroIdx = maxIn + 1;
68 |
69 | _.forEach(fasGraph.nodes(), v => {
70 | assignBucket(buckets, zeroIdx, fasGraph.node(v));
71 | });
72 |
73 | return { graph: fasGraph, buckets, zeroIdx };
74 | }
75 | function doGreedyFAS(g, buckets, zeroIdx) {
76 | let results = [];
77 | const sources = buckets[buckets.length - 1];
78 | const sinks = buckets[0];
79 |
80 | let entry;
81 | while (g.nodeCount()) {
82 | // eslint-disable-next-line no-cond-assign
83 | while ((entry = sinks.dequeue())) {
84 | removeNode(g, buckets, zeroIdx, entry);
85 | }
86 | // eslint-disable-next-line no-cond-assign
87 | while ((entry = sources.dequeue())) {
88 | removeNode(g, buckets, zeroIdx, entry);
89 | }
90 | if (g.nodeCount()) {
91 | for (let i = buckets.length - 2; i > 0; --i) {
92 | entry = buckets[i].dequeue();
93 | if (entry) {
94 | results = results.concat(
95 | removeNode(g, buckets, zeroIdx, entry, true)
96 | );
97 | break;
98 | }
99 | }
100 | }
101 | }
102 |
103 | return results;
104 | }
105 |
106 | /*
107 | * A greedy heuristic for finding a feedback arc set for a graph. A feedback
108 | * arc set is a set of edges that can be removed to make a graph acyclic.
109 | * The algorithm comes from: P. Eades, X. Lin, and W. F. Smyth, "A fast and
110 | * effective heuristic for the feedback arc set problem." This implementation
111 | * adjusts that from the paper to allow for weighted edges.
112 | */
113 | export function greedyFAS(g, weightFn) {
114 | if (g.nodeCount() <= 1) {
115 | return [];
116 | }
117 | const state = buildState(g, weightFn || DEFAULT_WEIGHT_FN);
118 | const results = doGreedyFAS(state.graph, state.buckets, state.zeroIdx);
119 |
120 | // Expand multi-edges
121 | return _.flatten(
122 | _.map(results, e => g.outEdges(e.v, e.w)),
123 | true
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/src/dagre/index.js:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright (c) 2012-2014 Chris Pettitt
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in
12 | all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | THE SOFTWARE.
21 | */
22 |
23 | export { layout } from "./layout";
24 |
--------------------------------------------------------------------------------
/src/dagre/nesting-graph.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | import { addBorderNode, addDummyNode } from "./util";
4 |
5 | function dfs(g, root, nodeSep, weight, height, depths, v) {
6 | const children = g.children(v);
7 | if (!children.length) {
8 | if (v !== root) {
9 | g.setEdge(root, v, { weight: 0, minlen: nodeSep });
10 | }
11 | return;
12 | }
13 |
14 | const top = addBorderNode(g, "_bt");
15 | const bottom = addBorderNode(g, "_bb");
16 | const label = g.node(v);
17 |
18 | g.setParent(top, v);
19 | label.borderTop = top;
20 | g.setParent(bottom, v);
21 | label.borderBottom = bottom;
22 |
23 | _.forEach(children, child => {
24 | dfs(g, root, nodeSep, weight, height, depths, child);
25 |
26 | const childNode = g.node(child);
27 | const childTop = childNode.borderTop ? childNode.borderTop : child;
28 | const childBottom = childNode.borderBottom ? childNode.borderBottom : child;
29 | const thisWeight = childNode.borderTop ? weight : 2 * weight;
30 | const minlen = childTop !== childBottom ? 1 : height - depths[v] + 1;
31 |
32 | g.setEdge(top, childTop, {
33 | weight: thisWeight,
34 | minlen,
35 | nestingEdge: true,
36 | });
37 |
38 | g.setEdge(childBottom, bottom, {
39 | weight: thisWeight,
40 | minlen,
41 | nestingEdge: true,
42 | });
43 | });
44 |
45 | if (!g.parent(v)) {
46 | g.setEdge(root, top, { weight: 0, minlen: height + depths[v] });
47 | }
48 | }
49 |
50 | function treeDepths(g) {
51 | const depths = {};
52 | function dfs_(v, depth) {
53 | const children = g.children(v);
54 | if (children && children.length) {
55 | _.forEach(children, child => {
56 | dfs_(child, depth + 1);
57 | });
58 | }
59 | depths[v] = depth;
60 | }
61 | _.forEach(g.children(), v => {
62 | dfs_(v, 1);
63 | });
64 | return depths;
65 | }
66 |
67 | function sumWeights(g) {
68 | return _.reduce(g.edges(), (acc, e) => acc + g.edge(e).weight, 0);
69 | }
70 |
71 | export function cleanup(g) {
72 | const graphLabel = g.graph();
73 | g.removeNode(graphLabel.nestingRoot);
74 | delete graphLabel.nestingRoot;
75 | _.forEach(g.edges(), e => {
76 | const edge = g.edge(e);
77 | if (edge.nestingEdge) {
78 | g.removeEdge(e);
79 | }
80 | });
81 | }
82 |
83 | /*
84 | * A nesting graph creates dummy nodes for the tops and bottoms of subgraphs,
85 | * adds appropriate edges to ensure that all cluster nodes are placed between
86 | * these boundries, and ensures that the graph is connected.
87 | *
88 | * In addition we ensure, through the use of the minlen property, that nodes
89 | * and subgraph border nodes to not end up on the same rank.
90 | *
91 | * Preconditions:
92 | *
93 | * 1. Input graph is a DAG
94 | * 2. Nodes in the input graph has a minlen attribute
95 | *
96 | * Postconditions:
97 | *
98 | * 1. Input graph is connected.
99 | * 2. Dummy nodes are added for the tops and bottoms of subgraphs.
100 | * 3. The minlen attribute for nodes is adjusted to ensure nodes do not
101 | * get placed on the same rank as subgraph border nodes.
102 | *
103 | * The nesting graph idea comes from Sander, "Layout of Compound Directed
104 | * Graphs."
105 | */
106 | export function run(g) {
107 | const root = addDummyNode(g, "root", {}, "_root");
108 | const depths = treeDepths(g);
109 | const height = _.max(_.values(depths)) - 1; // Note: depths is an Object not an array
110 | const nodeSep = 2 * height + 1;
111 |
112 | g.graph().nestingRoot = root;
113 |
114 | // Multiply minlen by nodeSep to align nodes on non-border ranks.
115 | _.forEach(g.edges(), e => {
116 | g.edge(e).minlen *= nodeSep;
117 | });
118 |
119 | // Calculate a weight that is sufficient to keep subgraphs vertically compact
120 | const weight = sumWeights(g) + 1;
121 |
122 | // Create border nodes and link them up
123 | _.forEach(g.children(), child => {
124 | dfs(g, root, nodeSep, weight, height, depths, child);
125 | });
126 |
127 | // Save the multiplier for node layers for later removal of empty border
128 | // layers.
129 | g.graph().nodeRankFactor = nodeSep;
130 | }
131 |
--------------------------------------------------------------------------------
/src/dagre/normalize.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | import { addDummyNode } from "./util";
4 |
5 | function normalizeEdge(g, e) {
6 | let { v } = e;
7 | let vRank = g.node(v).rank;
8 | const { w } = e;
9 | const wRank = g.node(w).rank;
10 | const { name } = e;
11 | const edgeLabel = g.edge(e);
12 | const { labelRank } = edgeLabel;
13 |
14 | if (wRank === vRank + 1) {
15 | return;
16 | }
17 |
18 | g.removeEdge(e);
19 |
20 | let dummy;
21 | let attrs;
22 | let i;
23 | // eslint-disable-next-line no-plusplus
24 | for (i = 0, ++vRank; vRank < wRank; ++i, ++vRank) {
25 | edgeLabel.points = [];
26 | attrs = {
27 | width: 0,
28 | height: 0,
29 | edgeLabel,
30 | edgeObj: e,
31 | rank: vRank,
32 | };
33 | dummy = addDummyNode(g, "edge", attrs, "_d");
34 | if (vRank === labelRank) {
35 | attrs.width = edgeLabel.width;
36 | attrs.height = edgeLabel.height;
37 | attrs.dummy = "edge-label";
38 | attrs.labelpos = edgeLabel.labelpos;
39 | }
40 | g.setEdge(v, dummy, { weight: edgeLabel.weight }, name);
41 | if (i === 0) {
42 | g.graph().dummyChains.push(dummy);
43 | }
44 | v = dummy;
45 | }
46 |
47 | g.setEdge(v, w, { weight: edgeLabel.weight }, name);
48 | }
49 |
50 | /*
51 | * Breaks any long edges in the graph into short segments that span 1 layer
52 | * each. This operation is undoable with the denormalize function.
53 | *
54 | * Pre-conditions:
55 | *
56 | * 1. The input graph is a DAG.
57 | * 2. Each node in the graph has a "rank" property.
58 | *
59 | * Post-condition:
60 | *
61 | * 1. All edges in the graph have a length of 1.
62 | * 2. Dummy nodes are added where edges have been split into segments.
63 | * 3. The graph is augmented with a "dummyChains" attribute which contains
64 | * the first dummy in each chain of dummy nodes produced.
65 | */
66 | export function run(g) {
67 | g.graph().dummyChains = [];
68 | _.forEach(g.edges(), edge => {
69 | normalizeEdge(g, edge);
70 | });
71 | }
72 |
73 | export function undo(g) {
74 | _.forEach(g.graph().dummyChains, v => {
75 | let node = g.node(v);
76 | const origLabel = node.edgeLabel;
77 | let w;
78 | g.setEdge(node.edgeObj, origLabel);
79 | while (node.dummy) {
80 | // eslint-disable-next-line prefer-destructuring
81 | w = g.successors(v)[0];
82 | g.removeNode(v);
83 | origLabel.points.push({ x: node.x, y: node.y });
84 | if (node.dummy === "edge-label") {
85 | origLabel.x = node.x;
86 | origLabel.y = node.y;
87 | origLabel.width = node.width;
88 | origLabel.height = node.height;
89 | }
90 | // eslint-disable-next-line no-param-reassign
91 | v = w;
92 | node = g.node(v);
93 | }
94 | });
95 | }
96 |
--------------------------------------------------------------------------------
/src/dagre/order/add-subgraph-constraints.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | export function addSubgraphConstraints(g, cg, vs) {
4 | const prev = {};
5 | let rootPrev;
6 |
7 | _.forEach(vs, v => {
8 | let child = g.parent(v);
9 | let parent;
10 | let prevChild;
11 | while (child) {
12 | parent = g.parent(child);
13 | if (parent) {
14 | prevChild = prev[parent];
15 | prev[parent] = child;
16 | } else {
17 | prevChild = rootPrev;
18 | rootPrev = child;
19 | }
20 | if (prevChild && prevChild !== child) {
21 | cg.setEdge(prevChild, child);
22 | return;
23 | }
24 | child = parent;
25 | }
26 | });
27 |
28 | /*
29 | function dfs(v) {
30 | var children = v ? g.children(v) : g.children();
31 | if (children.length) {
32 | var min = Number.POSITIVE_INFINITY,
33 | subgraphs = [];
34 | _.each(children, function(child) {
35 | var childMin = dfs(child);
36 | if (g.children(child).length) {
37 | subgraphs.push({ v: child, order: childMin });
38 | }
39 | min = Math.min(min, childMin);
40 | });
41 | _.reduce(_.sortBy(subgraphs, "order"), function(prev, curr) {
42 | cg.setEdge(prev.v, curr.v);
43 | return curr;
44 | });
45 | return min;
46 | }
47 | return g.node(v).order;
48 | }
49 | dfs(undefined);
50 | */
51 | }
52 |
--------------------------------------------------------------------------------
/src/dagre/order/barycenter.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | export function barycenter(g, movable) {
4 | return _.map(movable, v => {
5 | const inV = g.inEdges(v);
6 | if (!inV.length) {
7 | return { v };
8 | } else {
9 | const result = _.reduce(
10 | inV,
11 | (acc, e) => {
12 | const edge = g.edge(e);
13 | const nodeU = g.node(e.v);
14 | return {
15 | sum: acc.sum + edge.weight * nodeU.order,
16 | weight: acc.weight + edge.weight,
17 | };
18 | },
19 | { sum: 0, weight: 0 }
20 | );
21 |
22 | return {
23 | v,
24 | barycenter: result.sum / result.weight,
25 | weight: result.weight,
26 | };
27 | }
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/src/dagre/order/build-layer-graph.js:
--------------------------------------------------------------------------------
1 | import Graph from "../../graphlib";
2 | import _ from "lodash";
3 |
4 | function createRootNode(g) {
5 | let v;
6 | // eslint-disable-next-line no-cond-assign, no-empty
7 | while (g.hasNode((v = _.uniqueId("_root")))) {}
8 | return v;
9 | }
10 |
11 | /*
12 | * Constructs a graph that can be used to sort a layer of nodes. The graph will
13 | * contain all base and subgraph nodes from the request layer in their original
14 | * hierarchy and any edges that are incident on these nodes and are of the type
15 | * requested by the "relationship" parameter.
16 | *
17 | * Nodes from the requested rank that do not have parents are assigned a root
18 | * node in the output graph, which is set in the root graph attribute. This
19 | * makes it easy to walk the hierarchy of movable nodes during ordering.
20 | *
21 | * Pre-conditions:
22 | *
23 | * 1. Input graph is a DAG
24 | * 2. Base nodes in the input graph have a rank attribute
25 | * 3. Subgraph nodes in the input graph has minRank and maxRank attributes
26 | * 4. Edges have an assigned weight
27 | *
28 | * Post-conditions:
29 | *
30 | * 1. Output graph has all nodes in the movable rank with preserved
31 | * hierarchy.
32 | * 2. Root nodes in the movable layer are made children of the node
33 | * indicated by the root attribute of the graph.
34 | * 3. Non-movable nodes incident on movable nodes, selected by the
35 | * relationship parameter, are included in the graph (without hierarchy).
36 | * 4. Edges incident on movable nodes, selected by the relationship
37 | * parameter, are added to the output graph.
38 | * 5. The weights for copied edges are aggregated as need, since the output
39 | * graph is not a multi-graph.
40 | */
41 | export function buildLayerGraph(g, rank, relationship) {
42 | const root = createRootNode(g);
43 | const result = new Graph({ compound: true })
44 | .setGraph({ root })
45 | .setDefaultNodeLabel(v => g.node(v));
46 |
47 | _.forEach(g.nodes(), v => {
48 | const node = g.node(v);
49 | const parent = g.parent(v);
50 |
51 | if (node.rank === rank || (node.minRank <= rank && rank <= node.maxRank)) {
52 | result.setNode(v);
53 | result.setParent(v, parent || root);
54 |
55 | // This assumes we have only short edges!
56 | _.forEach(g[relationship](v), e => {
57 | const u = e.v === v ? e.w : e.v;
58 | const edge = result.edge(u, v);
59 | const weight = !_.isUndefined(edge) ? edge.weight : 0;
60 | result.setEdge(u, v, { weight: g.edge(e).weight + weight });
61 | });
62 |
63 | if (_.has(node, "minRank")) {
64 | result.setNode(v, {
65 | borderLeft: node.borderLeft[rank],
66 | borderRight: node.borderRight[rank],
67 | });
68 | }
69 | }
70 | });
71 |
72 | return result;
73 | }
74 |
--------------------------------------------------------------------------------
/src/dagre/order/cross-count.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | function twoLayerCrossCount(g, northLayer, southLayer) {
4 | // Sort all of the edges between the north and south layers by their position
5 | // in the north layer and then the south. Map these edges to the position of
6 | // their head in the south layer.
7 | const southPos = _.zipObject(
8 | southLayer,
9 | _.map(southLayer, (v, i) => i)
10 | );
11 | const southEntries = _.flatten(
12 | _.map(northLayer, v =>
13 | _.sortBy(
14 | _.map(g.outEdges(v), e => ({
15 | pos: southPos[e.w],
16 | weight: g.edge(e).weight,
17 | })),
18 | "pos"
19 | )
20 | ),
21 | true
22 | );
23 |
24 | // Build the accumulator tree
25 | let firstIndex = 1;
26 | while (firstIndex < southLayer.length) {
27 | // eslint-disable-next-line no-bitwise
28 | firstIndex <<= 1;
29 | }
30 | const treeSize = 2 * firstIndex - 1;
31 | firstIndex -= 1;
32 | const tree = _.map(new Array(treeSize), () => 0);
33 |
34 | // Calculate the weighted crossings
35 | let cc = 0;
36 | _.forEach(
37 | southEntries.forEach(entry => {
38 | let index = entry.pos + firstIndex;
39 | tree[index] += entry.weight;
40 | let weightSum = 0;
41 | while (index > 0) {
42 | if (index % 2) {
43 | weightSum += tree[index + 1];
44 | }
45 | // eslint-disable-next-line no-bitwise
46 | index = (index - 1) >> 1;
47 | tree[index] += entry.weight;
48 | }
49 | cc += entry.weight * weightSum;
50 | })
51 | );
52 |
53 | return cc;
54 | }
55 |
56 | /*
57 | * A function that takes a layering (an array of layers, each with an array of
58 | * ordererd nodes) and a graph and returns a weighted crossing count.
59 | *
60 | * Pre-conditions:
61 | *
62 | * 1. Input graph must be simple (not a multigraph), directed, and include
63 | * only simple edges.
64 | * 2. Edges in the input graph must have assigned weights.
65 | *
66 | * Post-conditions:
67 | *
68 | * 1. The graph and layering matrix are left unchanged.
69 | *
70 | * This algorithm is derived from Barth, et al., "Bilayer Cross Counting."
71 | */
72 | export function crossCount(g, layering) {
73 | let cc = 0;
74 | for (let i = 1; i < layering.length; ++i) {
75 | cc += twoLayerCrossCount(g, layering[i - 1], layering[i]);
76 | }
77 | return cc;
78 | }
79 |
--------------------------------------------------------------------------------
/src/dagre/order/index.js:
--------------------------------------------------------------------------------
1 | import Graph from "../../graphlib";
2 | import _ from "lodash";
3 |
4 | import { maxRank, buildLayerMatrix } from "../util";
5 |
6 | import { addSubgraphConstraints } from "./add-subgraph-constraints";
7 | import { buildLayerGraph } from "./build-layer-graph";
8 | import { crossCount } from "./cross-count";
9 | import { initOrder } from "./init-order";
10 | import { sortSubgraph } from "./sort-subgraph";
11 |
12 | function buildLayerGraphs(g, ranks, relationship) {
13 | return _.map(ranks, rank => buildLayerGraph(g, rank, relationship));
14 | }
15 |
16 | function assignOrder(g, layering) {
17 | _.forEach(layering, layer => {
18 | _.forEach(layer, (v, i) => {
19 | g.node(v).order = i;
20 | });
21 | });
22 | }
23 |
24 | function sweepLayerGraphs(layerGraphs, biasRight) {
25 | const cg = new Graph();
26 | _.forEach(layerGraphs, lg => {
27 | const { root } = lg.graph();
28 | const sorted = sortSubgraph(lg, root, cg, biasRight);
29 | _.forEach(sorted.vs, (v, i) => {
30 | lg.node(v).order = i;
31 | });
32 | addSubgraphConstraints(lg, cg, sorted.vs);
33 | });
34 | }
35 |
36 | /*
37 | * Applies heuristics to minimize edge crossings in the graph and sets the best
38 | * order solution as an order attribute on each node.
39 | *
40 | * Pre-conditions:
41 | *
42 | * 1. Graph must be DAG
43 | * 2. Graph nodes must be objects with a "rank" attribute
44 | * 3. Graph edges must have the "weight" attribute
45 | *
46 | * Post-conditions:
47 | *
48 | * 1. Graph nodes will have an "order" attribute based on the results of the
49 | * algorithm.
50 | */
51 | export function order(g) {
52 | const maxRank_ = maxRank(g);
53 | const downLayerGraphs = buildLayerGraphs(
54 | g,
55 | _.range(1, maxRank_ + 1),
56 | "inEdges"
57 | );
58 | const upLayerGraphs = buildLayerGraphs(
59 | g,
60 | _.range(maxRank_ - 1, -1, -1),
61 | "outEdges"
62 | );
63 |
64 | let layering = initOrder(g);
65 | assignOrder(g, layering);
66 |
67 | let bestCC = Number.POSITIVE_INFINITY;
68 | let best;
69 |
70 | for (let i = 0, lastBest = 0; lastBest < 4; ++i, ++lastBest) {
71 | sweepLayerGraphs(i % 2 ? downLayerGraphs : upLayerGraphs, i % 4 >= 2);
72 |
73 | layering = buildLayerMatrix(g);
74 | const cc = crossCount(g, layering);
75 | if (cc < bestCC) {
76 | lastBest = 0;
77 | best = _.cloneDeep(layering);
78 | bestCC = cc;
79 | }
80 | }
81 |
82 | assignOrder(g, best);
83 | }
84 |
--------------------------------------------------------------------------------
/src/dagre/order/init-order.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | /*
4 | * Assigns an initial order value for each node by performing a DFS search
5 | * starting from nodes in the first rank. Nodes are assigned an order in their
6 | * rank as they are first visited.
7 | *
8 | * This approach comes from Gansner, et al., "A Technique for Drawing Directed
9 | * Graphs."
10 | *
11 | * Returns a layering matrix with an array per layer and each layer sorted by
12 | * the order of its nodes.
13 | */
14 | export function initOrder(g) {
15 | const visited = {};
16 | const simpleNodes = _.filter(g.nodes(), v => !g.children(v).length);
17 | const maxRank = _.max(_.map(simpleNodes, v => g.node(v).rank));
18 | const layers = _.map(_.range(maxRank + 1), () => []);
19 |
20 | function dfs(v) {
21 | if (_.has(visited, v)) {
22 | return;
23 | }
24 | visited[v] = true;
25 | const node = g.node(v);
26 | layers[node.rank].push(v);
27 | _.forEach(g.successors(v), dfs);
28 | }
29 |
30 | const orderedVs = _.sortBy(simpleNodes, v => g.node(v).rank);
31 | _.forEach(orderedVs, dfs);
32 |
33 | return layers;
34 | }
35 |
--------------------------------------------------------------------------------
/src/dagre/order/resolve-conflicts.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | function mergeEntries(target, source) {
4 | let sum = 0;
5 | let weight = 0;
6 |
7 | if (target.weight) {
8 | sum += target.barycenter * target.weight;
9 | weight += target.weight;
10 | }
11 |
12 | if (source.weight) {
13 | sum += source.barycenter * source.weight;
14 | weight += source.weight;
15 | }
16 |
17 | target.vs = source.vs.concat(target.vs);
18 | target.barycenter = sum / weight;
19 | target.weight = weight;
20 | target.i = Math.min(source.i, target.i);
21 | source.merged = true;
22 | }
23 |
24 | function doResolveConflicts(sourceSet) {
25 | const entries = [];
26 |
27 | function handleIn(vEntry) {
28 | return function (uEntry) {
29 | if (uEntry.merged) {
30 | return;
31 | }
32 | if (
33 | _.isUndefined(uEntry.barycenter) ||
34 | _.isUndefined(vEntry.barycenter) ||
35 | uEntry.barycenter >= vEntry.barycenter
36 | ) {
37 | mergeEntries(vEntry, uEntry);
38 | }
39 | };
40 | }
41 |
42 | function handleOut(vEntry) {
43 | return function (wEntry) {
44 | wEntry.in.push(vEntry);
45 | // eslint-disable-next-line no-plusplus
46 | if (--wEntry.indegree === 0) {
47 | sourceSet.push(wEntry);
48 | }
49 | };
50 | }
51 |
52 | while (sourceSet.length) {
53 | const entry = sourceSet.pop();
54 | entries.push(entry);
55 | _.forEach(entry.in.reverse(), handleIn(entry));
56 | _.forEach(entry.out, handleOut(entry));
57 | }
58 |
59 | return _.map(
60 | _.filter(entries, entry => !entry.merged),
61 | entry => _.pick(entry, ["vs", "i", "barycenter", "weight"])
62 | );
63 | }
64 |
65 | /*
66 | * Given a list of entries of the form {v, barycenter, weight} and a
67 | * constraint graph this function will resolve any conflicts between the
68 | * constraint graph and the barycenters for the entries. If the barycenters for
69 | * an entry would violate a constraint in the constraint graph then we coalesce
70 | * the nodes in the conflict into a new node that respects the contraint and
71 | * aggregates barycenter and weight information.
72 | *
73 | * This implementation is based on the description in Forster, "A Fast and
74 | * Simple Hueristic for Constrained Two-Level Crossing Reduction," thought it
75 | * differs in some specific details.
76 | *
77 | * Pre-conditions:
78 | *
79 | * 1. Each entry has the form {v, barycenter, weight}, or if the node has
80 | * no barycenter, then {v}.
81 | *
82 | * Returns:
83 | *
84 | * A new list of entries of the form {vs, i, barycenter, weight}. The list
85 | * `vs` may either be a singleton or it may be an aggregation of nodes
86 | * ordered such that they do not violate constraints from the constraint
87 | * graph. The property `i` is the lowest original index of any of the
88 | * elements in `vs`.
89 | */
90 | export function resolveConflicts(entries, cg) {
91 | const mappedEntries = {};
92 | _.forEach(entries, (entry, i) => {
93 | const tmp = {
94 | indegree: 0,
95 | in: [],
96 | out: [],
97 | vs: [entry.v],
98 | i,
99 | };
100 | mappedEntries[entry.v] = tmp;
101 | if (!_.isUndefined(entry.barycenter)) {
102 | tmp.barycenter = entry.barycenter;
103 | tmp.weight = entry.weight;
104 | }
105 | });
106 |
107 | _.forEach(cg.edges(), e => {
108 | const entryV = mappedEntries[e.v];
109 | const entryW = mappedEntries[e.w];
110 | if (!_.isUndefined(entryV) && !_.isUndefined(entryW)) {
111 | entryW.indegree += 1;
112 | entryV.out.push(mappedEntries[e.w]);
113 | }
114 | });
115 |
116 | const sourceSet = _.filter(mappedEntries, entry => !entry.indegree);
117 |
118 | return doResolveConflicts(sourceSet);
119 | }
120 |
--------------------------------------------------------------------------------
/src/dagre/order/sort-subgraph.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | import { barycenter } from "./barycenter";
4 | import { resolveConflicts } from "./resolve-conflicts";
5 | import { sort } from "./sort";
6 |
7 | function mergeBarycenters(target, other) {
8 | if (!_.isUndefined(target.barycenter)) {
9 | target.barycenter =
10 | (target.barycenter * target.weight + other.barycenter * other.weight) /
11 | (target.weight + other.weight);
12 | target.weight += other.weight;
13 | } else {
14 | target.barycenter = other.barycenter;
15 | target.weight = other.weight;
16 | }
17 | }
18 |
19 | function expandSubgraphs(entries, subgraphs) {
20 | _.forEach(entries, entry => {
21 | entry.vs = _.flatten(
22 | entry.vs.map(v => {
23 | if (subgraphs[v]) {
24 | return subgraphs[v].vs;
25 | }
26 | return v;
27 | }),
28 | true
29 | );
30 | });
31 | }
32 |
33 | export function sortSubgraph(g, v, cg, biasRight) {
34 | let movable = g.children(v);
35 | const node = g.node(v);
36 | const bl = node ? node.borderLeft : undefined;
37 | const br = node ? node.borderRight : undefined;
38 | const subgraphs = {};
39 |
40 | if (bl) {
41 | movable = _.filter(movable, w => w !== bl && w !== br);
42 | }
43 |
44 | const barycenters = barycenter(g, movable);
45 | _.forEach(barycenters, entry => {
46 | if (g.children(entry.v).length) {
47 | const subgraphResult = sortSubgraph(g, entry.v, cg, biasRight);
48 | subgraphs[entry.v] = subgraphResult;
49 | if (_.has(subgraphResult, "barycenter")) {
50 | mergeBarycenters(entry, subgraphResult);
51 | }
52 | }
53 | });
54 |
55 | const entries = resolveConflicts(barycenters, cg);
56 | expandSubgraphs(entries, subgraphs);
57 |
58 | const result = sort(entries, biasRight);
59 |
60 | if (bl) {
61 | result.vs = _.flatten([bl, result.vs, br], true);
62 | if (g.predecessors(bl).length) {
63 | const blPred = g.node(g.predecessors(bl)[0]);
64 | const brPred = g.node(g.predecessors(br)[0]);
65 | if (!_.has(result, "barycenter")) {
66 | result.barycenter = 0;
67 | result.weight = 0;
68 | }
69 | result.barycenter =
70 | (result.barycenter * result.weight + blPred.order + brPred.order) /
71 | (result.weight + 2);
72 | result.weight += 2;
73 | }
74 | }
75 |
76 | return result;
77 | }
78 |
--------------------------------------------------------------------------------
/src/dagre/order/sort.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | import { partition } from "../util";
4 |
5 | function compareWithBias(bias) {
6 | return function (entryV, entryW) {
7 | if (entryV.barycenter < entryW.barycenter) {
8 | return -1;
9 | } else if (entryV.barycenter > entryW.barycenter) {
10 | return 1;
11 | }
12 |
13 | return !bias ? entryV.i - entryW.i : entryW.i - entryV.i;
14 | };
15 | }
16 | function consumeUnsortable(vs, unsortable, index) {
17 | let last;
18 | // eslint-disable-next-line no-cond-assign
19 | while (unsortable.length && (last = _.last(unsortable)).i <= index) {
20 | unsortable.pop();
21 | vs.push(last.vs);
22 | // eslint-disable-next-line no-param-reassign, no-plusplus
23 | index++;
24 | }
25 | return index;
26 | }
27 |
28 | export function sort(entries, biasRight) {
29 | const parts = partition(entries, entry => _.has(entry, "barycenter"));
30 | const sortable = parts.lhs;
31 | const unsortable = _.sortBy(parts.rhs, entry => -entry.i);
32 | const vs = [];
33 | let sum = 0;
34 | let weight = 0;
35 | let vsIndex = 0;
36 |
37 | sortable.sort(compareWithBias(!!biasRight));
38 |
39 | vsIndex = consumeUnsortable(vs, unsortable, vsIndex);
40 |
41 | _.forEach(sortable, entry => {
42 | vsIndex += entry.vs.length;
43 | vs.push(entry.vs);
44 | sum += entry.barycenter * entry.weight;
45 | weight += entry.weight;
46 | vsIndex = consumeUnsortable(vs, unsortable, vsIndex);
47 | });
48 |
49 | const result = { vs: _.flatten(vs, true) };
50 | if (weight) {
51 | result.barycenter = sum / weight;
52 | result.weight = weight;
53 | }
54 | return result;
55 | }
56 |
--------------------------------------------------------------------------------
/src/dagre/parent-dummy-chains.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | function postorder(g) {
4 | const result = {};
5 | let lim = 0;
6 |
7 | function dfs(v) {
8 | const low = lim;
9 | _.forEach(g.children(v), dfs);
10 | lim += 1;
11 | result[v] = { low, lim };
12 | }
13 | _.forEach(g.children(), dfs);
14 |
15 | return result;
16 | }
17 |
18 | // Find a path from v to w through the lowest common ancestor (LCA). Return the
19 | // full path and the LCA.
20 | function findPath(g, postorderNums, v, w) {
21 | const vPath = [];
22 | const wPath = [];
23 | const low = Math.min(postorderNums[v].low, postorderNums[w].low);
24 | const lim = Math.max(postorderNums[v].lim, postorderNums[w].lim);
25 |
26 | // Traverse up from v to find the LCA
27 | let parent = v;
28 | do {
29 | parent = g.parent(parent);
30 | vPath.push(parent);
31 | } while (
32 | parent &&
33 | (postorderNums[parent].low > low || lim > postorderNums[parent].lim)
34 | );
35 | const lca = parent;
36 |
37 | // Traverse from w to LCA
38 | parent = w;
39 | // eslint-disable-next-line no-cond-assign
40 | while ((parent = g.parent(parent)) !== lca) {
41 | wPath.push(parent);
42 | }
43 |
44 | return { path: vPath.concat(wPath.reverse()), lca };
45 | }
46 |
47 | export function parentDummyChains(g) {
48 | const postorderNums = postorder(g);
49 |
50 | _.forEach(g.graph().dummyChains, v => {
51 | let node = g.node(v);
52 | const { edgeObj } = node;
53 | const pathData = findPath(g, postorderNums, edgeObj.v, edgeObj.w);
54 | const { path } = pathData;
55 | const { lca } = pathData;
56 | let pathIdx = 0;
57 | let pathV = path[pathIdx];
58 | let ascending = true;
59 |
60 | while (v !== edgeObj.w) {
61 | node = g.node(v);
62 |
63 | if (ascending) {
64 | while (
65 | // eslint-disable-next-line no-cond-assign
66 | (pathV = path[pathIdx]) !== lca &&
67 | g.node(pathV).maxRank < node.rank
68 | ) {
69 | pathIdx += 1;
70 | }
71 |
72 | if (pathV === lca) {
73 | ascending = false;
74 | }
75 | }
76 |
77 | if (!ascending) {
78 | while (
79 | pathIdx < path.length - 1 &&
80 | // eslint-disable-next-line no-cond-assign
81 | g.node((pathV = path[pathIdx + 1])).minRank <= node.rank
82 | ) {
83 | pathIdx += 1;
84 | }
85 | pathV = path[pathIdx];
86 | }
87 |
88 | g.setParent(v, pathV);
89 | // eslint-disable-next-line prefer-destructuring, no-param-reassign
90 | v = g.successors(v)[0];
91 | }
92 | });
93 | }
94 |
--------------------------------------------------------------------------------
/src/dagre/position/index.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | import { buildLayerMatrix, asNonCompoundGraph } from "../util";
4 |
5 | import { positionX } from "./bk";
6 |
7 | function positionY(g) {
8 | const layering = buildLayerMatrix(g);
9 | const rankSep = g.graph().ranksep;
10 | let prevY = 0;
11 | _.forEach(layering, layer => {
12 | const maxHeight = _.max(_.map(layer, v => g.node(v).height));
13 | _.forEach(layer, v => {
14 | g.node(v).y = prevY + maxHeight / 2;
15 | });
16 | prevY += maxHeight + rankSep;
17 | });
18 | }
19 |
20 | export function position(g) {
21 | // eslint-disable-next-line no-param-reassign
22 | g = asNonCompoundGraph(g);
23 |
24 | positionY(g);
25 | _.forEach(positionX(g), (x, v) => {
26 | g.node(v).x = x;
27 | });
28 | }
29 |
--------------------------------------------------------------------------------
/src/dagre/rank/feasible-tree.js:
--------------------------------------------------------------------------------
1 | import Graph from "../../graphlib";
2 | import _ from "lodash";
3 |
4 | import { slack } from "./util";
5 |
6 | /*
7 | * Finds a maximal tree of tight edges and returns the number of nodes in the
8 | * tree.
9 | */
10 | function tightTree(t, g) {
11 | function dfs(v) {
12 | _.forEach(g.nodeEdges(v), e => {
13 | const edgeV = e.v;
14 | const w = v === edgeV ? e.w : edgeV;
15 | if (!t.hasNode(w) && !slack(g, e)) {
16 | t.setNode(w, {});
17 | t.setEdge(v, w, {});
18 | dfs(w);
19 | }
20 | });
21 | }
22 |
23 | _.forEach(t.nodes(), dfs);
24 | return t.nodeCount();
25 | }
26 |
27 | /*
28 | * Finds the edge with the smallest slack that is incident on tree and returns
29 | * it.
30 | */
31 | function findMinSlackEdge(t, g) {
32 | return _.minBy(g.edges(), e => {
33 | if (t.hasNode(e.v) !== t.hasNode(e.w)) {
34 | return slack(g, e);
35 | }
36 | });
37 | }
38 |
39 | function shiftRanks(t, g, delta) {
40 | _.forEach(t.nodes(), v => {
41 | g.node(v).rank += delta;
42 | });
43 | }
44 |
45 | /*
46 | * Constructs a spanning tree with tight edges and adjusted the input node's
47 | * ranks to achieve this. A tight edge is one that is has a length that matches
48 | * its "minlen" attribute.
49 | *
50 | * The basic structure for this function is derived from Gansner, et al., "A
51 | * Technique for Drawing Directed Graphs."
52 | *
53 | * Pre-conditions:
54 | *
55 | * 1. Graph must be a DAG.
56 | * 2. Graph must be connected.
57 | * 3. Graph must have at least one node.
58 | * 5. Graph nodes must have been previously assigned a "rank" property that
59 | * respects the "minlen" property of incident edges.
60 | * 6. Graph edges must have a "minlen" property.
61 | *
62 | * Post-conditions:
63 | *
64 | * - Graph nodes will have their rank adjusted to ensure that all edges are
65 | * tight.
66 | *
67 | * Returns a tree (undirected graph) that is constructed using only "tight"
68 | * edges.
69 | */
70 | export function feasibleTree(g) {
71 | const t = new Graph({ directed: false });
72 |
73 | // Choose arbitrary node from which to start our tree
74 | const start = g.nodes()[0];
75 | const size = g.nodeCount();
76 | t.setNode(start, {});
77 |
78 | let edge;
79 | let delta;
80 | while (tightTree(t, g) < size) {
81 | edge = findMinSlackEdge(t, g);
82 | delta = t.hasNode(edge.v) ? slack(g, edge) : -slack(g, edge);
83 | shiftRanks(t, g, delta);
84 | }
85 |
86 | return t;
87 | }
88 |
--------------------------------------------------------------------------------
/src/dagre/rank/index.js:
--------------------------------------------------------------------------------
1 | import { feasibleTree } from "./feasible-tree";
2 | import { networkSimplex } from "./network-simplex";
3 | import { longestPath } from "./util";
4 |
5 | function tightTreeRanker(g) {
6 | longestPath(g);
7 | feasibleTree(g);
8 | }
9 |
10 | function networkSimplexRanker(g) {
11 | networkSimplex(g);
12 | }
13 |
14 | /*
15 | * Assigns a rank to each node in the input graph that respects the "minlen"
16 | * constraint specified on edges between nodes.
17 | *
18 | * This basic structure is derived from Gansner, et al., "A Technique for
19 | * Drawing Directed Graphs."
20 | *
21 | * Pre-conditions:
22 | *
23 | * 1. Graph must be a connected DAG
24 | * 2. Graph nodes must be objects
25 | * 3. Graph edges must have "weight" and "minlen" attributes
26 | *
27 | * Post-conditions:
28 | *
29 | * 1. Graph nodes will have a "rank" attribute based on the results of the
30 | * algorithm. Ranks can start at any index (including negative), we'll
31 | * fix them up later.
32 | */
33 | export function rank(g) {
34 | switch (g.graph().ranker) {
35 | case "network-simplex":
36 | networkSimplexRanker(g);
37 | break;
38 | case "tight-tree":
39 | tightTreeRanker(g);
40 | break;
41 | case "longest-path":
42 | longestPath(g);
43 | // A fast and simple ranker, but results are far from optimal.
44 | break;
45 | default:
46 | networkSimplexRanker(g);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/dagre/rank/util.js:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 |
3 | /*
4 | * Initializes ranks for the input graph using the longest path algorithm. This
5 | * algorithm scales well and is fast in practice, it yields rather poor
6 | * solutions. Nodes are pushed to the lowest layer possible, leaving the bottom
7 | * ranks wide and leaving edges longer than necessary. However, due to its
8 | * speed, this algorithm is good for getting an initial ranking that can be fed
9 | * into other algorithms.
10 | *
11 | * This algorithm does not normalize layers because it will be used by other
12 | * algorithms in most cases. If using this algorithm directly, be sure to
13 | * run normalize at the end.
14 | *
15 | * Pre-conditions:
16 | *
17 | * 1. Input graph is a DAG.
18 | * 2. Input graph node labels can be assigned properties.
19 | *
20 | * Post-conditions:
21 | *
22 | * 1. Each node will be assign an (unnormalized) "rank" property.
23 | */
24 | export function longestPath(g) {
25 | const visited = {};
26 |
27 | function dfs(v) {
28 | const label = g.node(v);
29 | if (_.has(visited, v)) {
30 | return label.rank;
31 | }
32 | visited[v] = true;
33 |
34 | let rank = _.min(_.map(g.outEdges(v), e => dfs(e.w) - g.edge(e).minlen));
35 |
36 | if (
37 | rank === Number.POSITIVE_INFINITY || // return value of _.map([]) for Lodash 3
38 | rank === undefined || // return value of _.map([]) for Lodash 4
39 | rank === null
40 | ) {
41 | // return value of _.map([null])
42 | rank = 0;
43 | }
44 |
45 | // eslint-disable-next-line no-return-assign
46 | return (label.rank = rank);
47 | }
48 |
49 | _.forEach(g.sources(), dfs);
50 | }
51 |
52 | /*
53 | * Returns the amount of slack for the given edge. The slack is defined as the
54 | * difference between the length of the edge and its minimum length.
55 | */
56 | export function slack(g, e) {
57 | return g.node(e.w).rank - g.node(e.v).rank - g.edge(e).minlen;
58 | }
59 |
--------------------------------------------------------------------------------
/src/dagre/version.js:
--------------------------------------------------------------------------------
1 | module.exports = "0.8.6-pre";
2 |
--------------------------------------------------------------------------------
/src/data/final_majors.json:
--------------------------------------------------------------------------------
1 | [["Aeronautical and Astronautical Engineering", ["A A 320", "A A 321", "A A 322", "MATH 124", "MATH 125", "MATH 126", "MATH 207", "MATH 208", "MATH 224", "PHYS 121", "PHYS 122", "PHYS 123", "CHEM 142", "A A 210", "CEE 220", "M E 230", "A A 260", "A A 310", "A A 311", "AMATH 301", "A A 301", "A A 312", "A A 331", "A A 395", "A A 302", "A A 332", "A A 460", "A A 410", "A A 420", "A A 411", "A A 421", "A A 447"]], ["Civil Engineering", ["ENGR 231", "ECON 200", "ECON 201", "IND E 250", "MATH 124", "MATH 125", "MATH 126", "MATH 208", "AMATH 352", "MATH 207", "AMATH 351", "IND E 315", "STAT 390", "Q SCI 381", "PHYS 121", "PHYS 122", "PHYS 123", "CHEM 142", "CHEM 152", "CSE 122", "CSE 142", "CSE 160", "AMATH 301", "A A 210", "CEE 220", "M E 230", "M E 123", "MSE 170", "E E 215", "A A 260", "MATH 209", "MATH 224", "CEE 307", "CEE 317", "CEE 327", "CEE 337", "CEE 347", "CEE 357", "CEE 367", "CEE 377", "CEE 440", "CEE 441", "CEE 442", "CEE 444", "CEE 445"]], ["Computer Engineering", ["ENGR 231", "MATH 124", "MATH 125", "MATH 126", "MATH 208", "PHYS 121", "PHYS 122", "CSE 123", "CSE 143", "CSE 311", "CSE 312", "CSE 332", "CSE 351", "CSE 369", "CSE 371", "E E 205", "E E 215", "CSE 403", "CSE 474", "CSE 480", "CSE 484"]], ["Electrical Engineering", ["ENGR 231", "E E 393", "ENGR 333", "MATH 124", "MATH 125", "MATH 126", "MATH 207", "AMATH 351", "MATH 208", "AMATH 352", "MATH 224", "STAT 390", "STAT 391", "STAT 394", "IND E 315", "PHYS 121", "PHYS 122", "PHYS 123", "CHEM 142", "CSE 123", "CSE 143", "E E 215", "E E 233", "E E 242"]], ["Mechanical Engineering", ["MATH 124", "MATH 125", "MATH 126", "MATH 207", "AMATH 351", "MATH 208", "AMATH 352", "MATH 209", "MATH 224", "AMATH 353", "IND E 315", "STAT 390", "PHYS 121", "PHYS 122", "PHYS 123", "CHEM 142", "CHEM 152", "A A 210", "AMATH 301", "CEE 220", "E E 215", "M E 123", "M E 230", "MSE 170", "M E 323", "M E 331", "M E 333", "M E 354", "M E 355", "M E 356", "M E 373", "M E 374", "M E 493", "M E 414", "M E 494", "M E 495"]]]
--------------------------------------------------------------------------------
/src/data/final_seattle_curricula.json:
--------------------------------------------------------------------------------
1 | [["A A", "A A: Aeronautics and Astronautics"], ["AMATH", "AMATH: Applied Mathematics"], ["ASTR", "ASTR: Astronomy"], ["ATM S", "ATM S: Atmospheric Sciences"], ["BIOC", "BIOC: Biochemistry"], ["BIOEN", "BIOEN: Bioengineering"], ["BIOL", "BIOL: Biology"], ["BSE", "BSE: Bioresource and Science Engineering"], ["C ENV", "C ENV: College of the Environment"], ["CEE", "CEE: Civil and Environmental Engineering"], ["CHEM", "CHEM: Chemistry"], ["CHEM E", "CHEM E: Chemical Engineering"], ["CSE", "CSE: Computer Science and Engineering"], ["E E", "E E: Electrical and Computer Engineering"], ["ECON", "ECON: Economics"], ["ENGR", "ENGR: Engineering"], ["ENVIR", "ENVIR: Program on the Environment"], ["ESRM", "ESRM: Environmental Science and Resource Management"], ["ESS", "ESS: Earth and Space Sciences"], ["FHL", "FHL: Friday Harbor Labs"], ["FISH", "FISH: Aquatic and Fishery Sciences"], ["HCDE", "HCDE: Human Centered Design and Engineering"], ["IND E", "IND E: Industrial Engineering"], ["INFO", "INFO: Informatics"], ["ITA", "ITA: Information Technology Applications"], ["M E", "M E: Mechanical Engineering"], ["MARBIO", "MARBIO: Marine Biology"], ["MATH", "MATH: Mathematics"], ["MICROM", "MICROM: Microbiology"], ["MSE", "MSE: Materials Science and Engineering"], ["NME", "NME: Nanoscience and Molecular Engineering"], ["OCEAN", "OCEAN: Oceanography"], ["PHYS", "PHYS: Physics"], ["Q SCI", "Q SCI: Quantitative Science"], ["SMEA", "SMEA: School of Marine and Environmental Affairs"], ["STAT", "STAT: Statistics"]]
--------------------------------------------------------------------------------
/src/graphlib/alg/components.js:
--------------------------------------------------------------------------------
1 | import * as utils from "../utils";
2 |
3 | export default function components(g) {
4 | const visited = {};
5 | const cmpts = [];
6 | let cmpt;
7 |
8 | function dfs(v) {
9 | if (utils.has(visited, v)) return;
10 | visited[v] = true;
11 | cmpt.push(v);
12 | g.successors(v).forEach(dfs);
13 | g.predecessors(v).forEach(dfs);
14 | }
15 |
16 | g.nodes().forEach(function (v) {
17 | cmpt = [];
18 | dfs(v);
19 | if (cmpt.length) {
20 | cmpts.push(cmpt);
21 | }
22 | });
23 |
24 | return cmpts;
25 | }
26 |
--------------------------------------------------------------------------------
/src/graphlib/alg/dfs.js:
--------------------------------------------------------------------------------
1 | import * as utils from "../utils";
2 |
3 | function doDfs(g, v, postorder, visited, navigation, acc) {
4 | if (!utils.has(visited, v)) {
5 | visited[v] = true;
6 |
7 | if (!postorder) {
8 | acc.push(v);
9 | }
10 | navigation(v).forEach(function (w) {
11 | doDfs(g, w, postorder, visited, navigation, acc);
12 | });
13 | if (postorder) {
14 | acc.push(v);
15 | }
16 | }
17 | }
18 |
19 | /*
20 | * A helper that preforms a pre- or post-order traversal on the input graph
21 | * and returns the nodes in the order they were visited. If the graph is
22 | * undirected then this algorithm will navigate using neighbors. If the graph
23 | * is directed then this algorithm will navigate using successors.
24 | *
25 | * Order must be one of "pre" or "post".
26 | */
27 | export default function dfs(g, vs_, order) {
28 | const vs = !Array.isArray(vs_) ? [vs_] : vs_;
29 |
30 | const navigation = (g.isDirected() ? g.successors : g.neighbors).bind(g);
31 |
32 | const acc = [];
33 | const visited = {};
34 | vs.forEach(function (v) {
35 | if (!g.hasNode(v)) {
36 | throw new Error(`Graph does not have node: ${v}`);
37 | }
38 |
39 | doDfs(g, v, order === "post", visited, navigation, acc);
40 | });
41 | return acc;
42 | }
43 |
--------------------------------------------------------------------------------
/src/graphlib/alg/dijkstra-all.js:
--------------------------------------------------------------------------------
1 | import * as utils from "../utils";
2 | import dijkstra from "./dijkstra";
3 |
4 | export default function dijkstraAll(g, weightFunc, edgeFunc) {
5 | return utils.transform(
6 | g.nodes(),
7 | function (acc, v) {
8 | acc[v] = dijkstra(g, v, weightFunc, edgeFunc);
9 | },
10 | {}
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/graphlib/alg/dijkstra.js:
--------------------------------------------------------------------------------
1 | import PriorityQueue from "../data/priority-queue";
2 |
3 | const DEFAULT_WEIGHT_FUNC = () => 1;
4 |
5 | function runDijkstra(g, source, weightFn, edgeFn) {
6 | const results = {};
7 | const pq = new PriorityQueue();
8 | let v;
9 | let vEntry;
10 |
11 | const updateNeighbors = function (edge) {
12 | const w = edge.v !== v ? edge.v : edge.w;
13 | const wEntry = results[w];
14 | const weight = weightFn(edge);
15 | const distance = vEntry.distance + weight;
16 |
17 | if (weight < 0) {
18 | throw new Error(
19 | `dijkstra does not allow negative edge weights. Bad edge: ${edge} Weight: ${weight}`
20 | );
21 | }
22 |
23 | if (distance < wEntry.distance) {
24 | wEntry.distance = distance;
25 | wEntry.predecessor = v;
26 | pq.decrease(w, distance);
27 | }
28 | };
29 |
30 | g.nodes().forEach(function (v2) {
31 | const distance = v2 === source ? 0 : Number.POSITIVE_INFINITY;
32 | results[v2] = { distance };
33 | pq.add(v2, distance);
34 | });
35 |
36 | while (pq.size() > 0) {
37 | v = pq.removeMin();
38 | vEntry = results[v];
39 | if (vEntry.distance === Number.POSITIVE_INFINITY) {
40 | break;
41 | }
42 |
43 | edgeFn(v).forEach(updateNeighbors);
44 | }
45 |
46 | return results;
47 | }
48 |
49 | export default function dijkstra(g, source, weightFn, edgeFn) {
50 | return runDijkstra(
51 | g,
52 | String(source),
53 | weightFn || DEFAULT_WEIGHT_FUNC,
54 | edgeFn ||
55 | function (v) {
56 | return g.outEdges(v);
57 | }
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/graphlib/alg/find-cycles.js:
--------------------------------------------------------------------------------
1 | import tarjan from "./tarjan";
2 |
3 | export default function findCycles(g) {
4 | return tarjan(g).filter(function (cmpt) {
5 | return (
6 | cmpt.length > 1 || (cmpt.length === 1 && g.hasEdge(cmpt[0], cmpt[0]))
7 | );
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/graphlib/alg/floyd-warshall.js:
--------------------------------------------------------------------------------
1 | const DEFAULT_WEIGHT_FUNC = () => 1;
2 |
3 | function runFloydWarshall(g, weightFn, edgeFn) {
4 | const results = {};
5 | const nodes = g.nodes();
6 |
7 | nodes.forEach(function (v) {
8 | results[v] = {};
9 | results[v][v] = { distance: 0 };
10 | nodes.forEach(function (w) {
11 | if (v !== w) {
12 | results[v][w] = { distance: Number.POSITIVE_INFINITY };
13 | }
14 | });
15 | edgeFn(v).forEach(function (edge) {
16 | const w = edge.v === v ? edge.w : edge.v;
17 | const d = weightFn(edge);
18 | results[v][w] = { distance: d, predecessor: v };
19 | });
20 | });
21 |
22 | nodes.forEach(function (k) {
23 | const rowK = results[k];
24 | nodes.forEach(function (i) {
25 | const rowI = results[i];
26 | nodes.forEach(function (j) {
27 | const ik = rowI[k];
28 | const kj = rowK[j];
29 | const ij = rowI[j];
30 | const altDistance = ik.distance + kj.distance;
31 | if (altDistance < ij.distance) {
32 | ij.distance = altDistance;
33 | ij.predecessor = kj.predecessor;
34 | }
35 | });
36 | });
37 | });
38 |
39 | return results;
40 | }
41 |
42 | export default function floydWarshall(g, weightFn, edgeFn) {
43 | return runFloydWarshall(
44 | g,
45 | weightFn || DEFAULT_WEIGHT_FUNC,
46 | edgeFn ||
47 | function (v) {
48 | return g.outEdges(v);
49 | }
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/graphlib/alg/index.js:
--------------------------------------------------------------------------------
1 | export { default as components } from "./components";
2 | export { default as dijkstra } from "./dijkstra";
3 | export { default as dijkstraAll } from "./dijkstra-all";
4 | export { default as findCycles } from "./find-cycles";
5 | export { default as floydWarshall } from "./floyd-warshall";
6 | export { default as isAcyclic } from "./is-acyclic";
7 | export { default as postorder } from "./postorder";
8 | export { default as preorder } from "./preorder";
9 | export { default as prim } from "./prim";
10 | export { default as tarjan } from "./tarjan";
11 | export { default as topsort } from "./topsort";
12 |
--------------------------------------------------------------------------------
/src/graphlib/alg/is-acyclic.js:
--------------------------------------------------------------------------------
1 | import topsort from "./topsort";
2 |
3 | export default function isAcyclic(g) {
4 | try {
5 | topsort(g);
6 | } catch (e) {
7 | if (e instanceof topsort.CycleException) {
8 | return false;
9 | }
10 | throw e;
11 | }
12 | return true;
13 | }
14 |
--------------------------------------------------------------------------------
/src/graphlib/alg/postorder.js:
--------------------------------------------------------------------------------
1 | import dfs from "./dfs";
2 |
3 | export default function postorder(g, vs) {
4 | return dfs(g, vs, "post");
5 | }
6 |
--------------------------------------------------------------------------------
/src/graphlib/alg/preorder.js:
--------------------------------------------------------------------------------
1 | import dfs from "./dfs";
2 |
3 | export default function preorder(g, vs) {
4 | return dfs(g, vs, "pre");
5 | }
6 |
--------------------------------------------------------------------------------
/src/graphlib/alg/prim.js:
--------------------------------------------------------------------------------
1 | import * as utils from "../utils";
2 | import Graph from "../graph";
3 | import PriorityQueue from "../data/priority-queue";
4 |
5 | export default function prim(g, weightFunc) {
6 | const result = new Graph();
7 | const parents = {};
8 | const pq = new PriorityQueue();
9 | let v;
10 |
11 | function updateNeighbors(edge) {
12 | const w = edge.v === v ? edge.w : edge.v;
13 | const pri = pq.priority(w);
14 | if (pri !== undefined) {
15 | const edgeWeight = weightFunc(edge);
16 | if (edgeWeight < pri) {
17 | parents[w] = v;
18 | pq.decrease(w, edgeWeight);
19 | }
20 | }
21 | }
22 |
23 | if (g.nodeCount() === 0) {
24 | return result;
25 | }
26 |
27 | g.nodes().forEach(function (v_) {
28 | pq.add(v_, Number.POSITIVE_INFINITY);
29 | result.setNode(v_);
30 | });
31 |
32 | // Start from an arbitrary node
33 | pq.decrease(g.nodes()[0], 0);
34 |
35 | let init = false;
36 | while (pq.size() > 0) {
37 | v = pq.removeMin();
38 | if (utils.has(parents, v)) {
39 | result.setEdge(v, parents[v]);
40 | } else if (init) {
41 | throw new Error(`Input graph is not connected: ${g}`);
42 | } else {
43 | init = true;
44 | }
45 |
46 | g.nodeEdges(v).forEach(updateNeighbors);
47 | }
48 |
49 | return result;
50 | }
51 |
--------------------------------------------------------------------------------
/src/graphlib/alg/tarjan.js:
--------------------------------------------------------------------------------
1 | import * as utils from "../utils";
2 |
3 | export default function tarjan(g) {
4 | let index = 0;
5 | const stack = [];
6 | const visited = {}; // node id -> { onStack, lowlink, index }
7 | const results = [];
8 |
9 | function dfs(v) {
10 | visited[v] = {
11 | onStack: true,
12 | lowlink: index,
13 | index,
14 | };
15 | const entry = visited[v];
16 | index += 1;
17 | stack.push(v);
18 |
19 | g.successors(v).forEach(function (w) {
20 | if (!utils.has(visited, w)) {
21 | dfs(w);
22 | entry.lowlink = Math.min(entry.lowlink, visited[w].lowlink);
23 | } else if (visited[w].onStack) {
24 | entry.lowlink = Math.min(entry.lowlink, visited[w].index);
25 | }
26 | });
27 |
28 | if (entry.lowlink === entry.index) {
29 | const cmpt = [];
30 | let w;
31 | do {
32 | w = stack.pop();
33 | visited[w].onStack = false;
34 | cmpt.push(w);
35 | } while (v !== w);
36 | results.push(cmpt);
37 | }
38 | }
39 |
40 | g.nodes().forEach(function (v) {
41 | if (!utils.has(visited, v)) {
42 | dfs(v);
43 | }
44 | });
45 |
46 | return results;
47 | }
48 |
--------------------------------------------------------------------------------
/src/graphlib/alg/topsort.js:
--------------------------------------------------------------------------------
1 | import * as utils from "../utils";
2 |
3 | function CycleException() {}
4 | CycleException.prototype = new Error();
5 | // must be an instance of Error to pass testing
6 |
7 | export default function topsort(g) {
8 | const visited = {};
9 | const stack = {};
10 | const results = [];
11 |
12 | function visit(node) {
13 | if (utils.has(stack, node)) {
14 | throw new CycleException();
15 | }
16 |
17 | if (!utils.has(visited, node)) {
18 | stack[node] = true;
19 | visited[node] = true;
20 | g.predecessors(node).forEach(visit);
21 | delete stack[node];
22 | results.push(node);
23 | }
24 | }
25 |
26 | g.sinks().forEach(visit);
27 |
28 | if (Object.keys(visited).length !== g.nodeCount()) {
29 | throw new CycleException();
30 | }
31 |
32 | return results;
33 | }
34 | topsort.CycleException = CycleException;
35 |
--------------------------------------------------------------------------------
/src/graphlib/data/priority-queue.js:
--------------------------------------------------------------------------------
1 | import * as utils from "../utils";
2 |
3 | /**
4 | * A min-priority queue data structure. This algorithm is derived from Cormen,
5 | * et al., "Introduction to Algorithms". The basic idea of a min-priority
6 | * queue is that you can efficiently (in O(1) time) get the smallest key in
7 | * the queue. Adding and removing elements takes O(log n) time. A key can
8 | * have its priority decreased in O(log n) time.
9 | */
10 | export default function PriorityQueue() {
11 | this._arr = [];
12 | this._keyIndices = {};
13 | }
14 |
15 | /**
16 | * Returns the number of elements in the queue. Takes `O(1)` time.
17 | */
18 | PriorityQueue.prototype.size = function () {
19 | return this._arr.length;
20 | };
21 |
22 | /**
23 | * Returns the keys that are in the queue. Takes `O(n)` time.
24 | */
25 | PriorityQueue.prototype.keys = function () {
26 | return this._arr.map(function (x) {
27 | return x.key;
28 | });
29 | };
30 |
31 | /**
32 | * Returns `true` if **key** is in the queue and `false` if not.
33 | */
34 | PriorityQueue.prototype.has = function (key) {
35 | return utils.has(this._keyIndices, key);
36 | };
37 |
38 | /**
39 | * Returns the priority for **key**. If **key** is not present in the queue
40 | * then this function returns `undefined`. Takes `O(1)` time.
41 | *
42 | * @param {Object} key
43 | */
44 | PriorityQueue.prototype.priority = function (key) {
45 | const index = this._keyIndices[key];
46 | if (index !== undefined) {
47 | return this._arr[index].priority;
48 | }
49 | };
50 |
51 | /**
52 | * Returns the key for the minimum element in this queue. If the queue is
53 | * empty this function throws an Error. Takes `O(1)` time.
54 | */
55 | PriorityQueue.prototype.min = function () {
56 | if (this.size() === 0) {
57 | throw new Error("Queue underflow");
58 | }
59 | return this._arr[0].key;
60 | };
61 |
62 | /**
63 | * Inserts a new key into the priority queue. If the key already exists in
64 | * the queue this function returns `false`; otherwise it will return `true`.
65 | * Takes `O(n)` time.
66 | *
67 | * @param {Object} key the key to add
68 | * @param {Number} priority the initial priority for the key
69 | */
70 | PriorityQueue.prototype.add = function (key_, priority) {
71 | const keyIndices = this._keyIndices;
72 | const key = String(key_);
73 | if (!utils.has(keyIndices, key)) {
74 | const arr = this._arr;
75 | const index = arr.length;
76 | keyIndices[key] = index;
77 | arr.push({ key, priority });
78 | this._decrease(index);
79 | return true;
80 | }
81 | return false;
82 | };
83 |
84 | /**
85 | * Removes and returns the smallest key in the queue. Takes `O(log n)` time.
86 | */
87 | PriorityQueue.prototype.removeMin = function () {
88 | this._swap(0, this._arr.length - 1);
89 | const min = this._arr.pop();
90 | delete this._keyIndices[min.key];
91 | this._heapify(0);
92 | return min.key;
93 | };
94 |
95 | /**
96 | * Decreases the priority for **key** to **priority**. If the new priority is
97 | * greater than the previous priority, this function will throw an Error.
98 | *
99 | * @param {Object} key the key for which to raise priority
100 | * @param {Number} priority the new priority for the key
101 | */
102 | PriorityQueue.prototype.decrease = function (key, priority) {
103 | const index = this._keyIndices[key];
104 | if (priority > this._arr[index].priority) {
105 | throw new Error(
106 | `New priority is greater than current priority. Key: ${key} Old: ${this._arr[index].priority} New: ${priority}`
107 | );
108 | }
109 | this._arr[index].priority = priority;
110 | this._decrease(index);
111 | };
112 |
113 | PriorityQueue.prototype._heapify = function (i) {
114 | const arr = this._arr;
115 | const l = 2 * i;
116 | const r = l + 1;
117 | let largest = i;
118 | if (l < arr.length) {
119 | largest = arr[l].priority < arr[largest].priority ? l : largest;
120 | if (r < arr.length) {
121 | largest = arr[r].priority < arr[largest].priority ? r : largest;
122 | }
123 | if (largest !== i) {
124 | this._swap(i, largest);
125 | this._heapify(largest);
126 | }
127 | }
128 | };
129 |
130 | PriorityQueue.prototype._decrease = function (index_) {
131 | let index = index_;
132 | const arr = this._arr;
133 | const { priority } = arr[index];
134 | let parent;
135 | while (index !== 0) {
136 | // eslint-disable-next-line no-bitwise
137 | parent = index >> 1;
138 | if (arr[parent].priority < priority) {
139 | break;
140 | }
141 | this._swap(index, parent);
142 | index = parent;
143 | }
144 | };
145 |
146 | PriorityQueue.prototype._swap = function (i, j) {
147 | const arr = this._arr;
148 | const keyIndices = this._keyIndices;
149 | [arr[i], arr[j]] = [arr[j], arr[i]];
150 | keyIndices[arr[i].key] = i;
151 | keyIndices[arr[j].key] = j;
152 | };
153 |
--------------------------------------------------------------------------------
/src/graphlib/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) 2014, Chris Pettitt
3 | * All rights reserved.
4 | *
5 | * Redistribution and use in source and binary forms, with or without
6 | * modification, are permitted provided that the following conditions are met:
7 | *
8 | * 1. Redistributions of source code must retain the above copyright notice, this
9 | * list of conditions and the following disclaimer.
10 | *
11 | * 2. Redistributions in binary form must reproduce the above copyright notice,
12 | * this list of conditions and the following disclaimer in the documentation
13 | * and/or other materials provided with the distribution.
14 | *
15 | * 3. Neither the name of the copyright holder nor the names of its contributors
16 | * may be used to endorse or promote products derived from this software without
17 | * specific prior written permission.
18 | *
19 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 | */
30 |
31 | import Graph from "./graph";
32 | import * as json from "./json";
33 | import * as alg from "./alg";
34 |
35 | export default Graph;
36 | export { json, alg };
37 |
--------------------------------------------------------------------------------
/src/graphlib/json.js:
--------------------------------------------------------------------------------
1 | import Graph from "./graph";
2 |
3 | export function read(json) {
4 | const g = new Graph(json.options).setGraph(json.value);
5 | json.nodes.forEach(function (entry) {
6 | g.setNode(entry.v, entry.value);
7 | if (entry.parent) {
8 | g.setParent(entry.v, entry.parent);
9 | }
10 | });
11 | json.edges.forEach(function (entry) {
12 | g.setEdge({ v: entry.v, w: entry.w, name: entry.name }, entry.value);
13 | });
14 | return g;
15 | }
16 |
17 | function writeNodes(g) {
18 | return g.nodes().map(function (v) {
19 | const nodeValue = g.node(v);
20 | const parent = g.parent(v);
21 | const node = { v };
22 | if (nodeValue !== undefined) {
23 | node.value = nodeValue;
24 | }
25 | if (parent !== undefined) {
26 | node.parent = parent;
27 | }
28 | return node;
29 | });
30 | }
31 |
32 | function writeEdges(g) {
33 | return g.edges().map(function (e) {
34 | const edgeValue = g.edge(e);
35 | const edge = { v: e.v, w: e.w };
36 | if (e.name !== undefined) {
37 | edge.name = e.name;
38 | }
39 | if (edgeValue !== undefined) {
40 | edge.value = edgeValue;
41 | }
42 | return edge;
43 | });
44 | }
45 |
46 | export function write(g) {
47 | const json = {
48 | options: {
49 | directed: g.isDirected(),
50 | multigraph: g.isMultigraph(),
51 | compound: g.isCompound(),
52 | },
53 | nodes: writeNodes(g),
54 | edges: writeEdges(g),
55 | };
56 | if (g.graph() !== undefined) {
57 | json.value = JSON.parse(JSON.stringify(g.graph()));
58 | }
59 | return json;
60 | }
61 |
--------------------------------------------------------------------------------
/src/graphlib/utils.js:
--------------------------------------------------------------------------------
1 | export function has(obj, key) {
2 | try {
3 | return hasOwnProperty.call(obj, key);
4 | } catch (error) {
5 | if (error instanceof TypeError) {
6 | // obj is null or undefined
7 | return false;
8 | } else {
9 | throw error;
10 | }
11 | }
12 | }
13 |
14 | export function isEmpty(obj) {
15 | if (Array.isArray(obj)) {
16 | return !obj.length;
17 | } else if (obj instanceof Map || obj instanceof Set) {
18 | return !obj.size;
19 | } else if (typeof obj === "object") {
20 | return !Object.keys(obj).length;
21 | } else {
22 | throw new Error("Attempted isEmpty() on non-container object");
23 | }
24 | }
25 |
26 | export function union(...arrays) {
27 | const values = new Set();
28 | const newArray = [];
29 | for (const arr of arrays) {
30 | for (const e of arr) {
31 | if (!values.has(e)) {
32 | newArray.push(e);
33 | values.add(e);
34 | }
35 | }
36 | }
37 | return newArray;
38 | }
39 |
40 | export function transform(obj, callbackfn, accumulator) {
41 | const keys = Object.keys(obj);
42 | for (let i = 0, j = keys.length; i < j; i++) {
43 | const key = keys[i];
44 | const end = callbackfn(accumulator, obj[key], key);
45 | if (end === false) {
46 | break;
47 | }
48 | }
49 | return accumulator;
50 | }
51 |
--------------------------------------------------------------------------------
/src/icons/chevron-right.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/envelope.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/plus.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/question.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/table.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/times.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/triangle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/x-black.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/x-gray.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'source_sans';
3 | src: url('./source-sans/sourcesanspro-regular-webfont.woff2') format('woff2'),
4 | url('./source-sans/sourcesanspro-regular-webfont.woff') format('woff');
5 | font-weight: normal;
6 | font-style: normal;
7 |
8 | }
9 |
10 | @font-face {
11 | font-family: 'source_sans';
12 | src: url('./source-sans/sourcesanspro-bold-webfont.woff2') format('woff2'),
13 | url('./source-sans/sourcesanspro-bold-webfont.woff') format('woff');
14 | font-weight: bold;
15 | font-style: normal;
16 |
17 | }
18 |
19 | @font-face {
20 | font-family: 'source_sans';
21 | src: url('./source-sans/sourcesanspro-it-webfont.woff2') format('woff2'),
22 | url('./source-sans/sourcesanspro-it-webfont.woff') format('woff');
23 | font-weight: normal;
24 | font-style: italic;
25 |
26 | }
27 |
28 | @font-face {
29 | font-family: 'source_sans';
30 | src: url('./source-sans/sourcesanspro-boldit-webfont.woff2') format('woff2'),
31 | url('./source-sans/sourcesanspro-boldit-webfont.woff') format('woff');
32 | font-weight: bold;
33 | font-style: italic;
34 |
35 | }
36 |
37 | *,
38 | *::before,
39 | *::after {
40 | box-sizing: border-box;
41 | padding: 0;
42 | margin: 0;
43 | }
44 |
45 | .--transparent {
46 | opacity: 0;
47 | }
48 |
49 | body {
50 | font-family: "source_sans", sans-serif;
51 | }
52 |
53 | html,
54 | body,
55 | #root {
56 | height: 100%;
57 | }
58 |
59 | #root {
60 | display: flex;
61 | flex-direction: column;
62 | }
63 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | import type { Element } from "types/main";
5 |
6 | import "./index.scss";
7 | import App from "./App";
8 |
9 | import demoFlow from "./data/demo-flow.json";
10 |
11 | if (window.location.host === "www.prereqflow.com") {
12 | window.location.replace("https://prereq-flow.vercel.app/");
13 | } else {
14 | ReactDOM.render(
15 |
16 |
17 | ,
18 | document.getElementById("root"),
19 | );
20 | }
21 |
22 | // TODO: Mobile/tablet warning
23 | // TODO: Add concurrent edge to legend
24 | // TODO: Dark mode
25 | // TODO: N of prereq (PHYS 321)
26 |
27 | // TODO: Context menu for edge style
28 |
--------------------------------------------------------------------------------
/src/source-sans/sourcesanspro-bold-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/source-sans/sourcesanspro-bold-webfont.woff
--------------------------------------------------------------------------------
/src/source-sans/sourcesanspro-bold-webfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/source-sans/sourcesanspro-bold-webfont.woff2
--------------------------------------------------------------------------------
/src/source-sans/sourcesanspro-boldit-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/source-sans/sourcesanspro-boldit-webfont.woff
--------------------------------------------------------------------------------
/src/source-sans/sourcesanspro-boldit-webfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/source-sans/sourcesanspro-boldit-webfont.woff2
--------------------------------------------------------------------------------
/src/source-sans/sourcesanspro-it-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/source-sans/sourcesanspro-it-webfont.woff
--------------------------------------------------------------------------------
/src/source-sans/sourcesanspro-it-webfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/source-sans/sourcesanspro-it-webfont.woff2
--------------------------------------------------------------------------------
/src/source-sans/sourcesanspro-regular-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/source-sans/sourcesanspro-regular-webfont.woff
--------------------------------------------------------------------------------
/src/source-sans/sourcesanspro-regular-webfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/source-sans/sourcesanspro-regular-webfont.woff2
--------------------------------------------------------------------------------
/src/state.ts:
--------------------------------------------------------------------------------
1 | import { atom } from "jotai";
2 | import type { CourseData } from "types/main";
3 |
4 | export const courseDataAtom = atom([]);
5 | export const courseMapAtom = atom>(
6 | get => new Map(get(courseDataAtom).map(c => [c.id, c])),
7 | );
8 |
--------------------------------------------------------------------------------
/src/tests/test-flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awu43/prereq-flow/2e72d0c2416c9e359432cf3d37be3c3076d548b0/src/tests/test-flow.png
--------------------------------------------------------------------------------
/src/useDialogStatus.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | import usePrefersReducedMotion from "./usePrefersReducedMotion";
4 |
5 | export type ModalClass = "--transparent --display-none" | "--transparent" | "";
6 | export type OpenModal = () => void;
7 | export type CloseModal = () => void;
8 |
9 | export default function useDialogStatus(): [ModalClass, OpenModal, CloseModal] {
10 | const [dialogCls, setDialogCls] = useState(
11 | "--transparent --display-none",
12 | );
13 |
14 | const prefersReducedMotion = usePrefersReducedMotion();
15 |
16 | function openDialog(): void {
17 | if (!prefersReducedMotion) {
18 | setDialogCls("--transparent");
19 | setTimeout(() => {
20 | setDialogCls("");
21 | }, 25);
22 | } else {
23 | setDialogCls("");
24 | }
25 | }
26 |
27 | function closeDialog(): void {
28 | if (!prefersReducedMotion) {
29 | setDialogCls("--transparent");
30 | setTimeout(() => {
31 | setDialogCls("--transparent --display-none");
32 | }, 100);
33 | } else {
34 | setDialogCls("--transparent --display-none");
35 | }
36 | }
37 |
38 | return [dialogCls, openDialog, closeDialog];
39 | }
40 |
--------------------------------------------------------------------------------
/src/usePrefersReducedMotion.tsx:
--------------------------------------------------------------------------------
1 | // https://www.joshwcomeau.com/react/prefers-reduced-motion/
2 | import { useState, useEffect } from "react";
3 |
4 | const QUERY = "(prefers-reduced-motion: no-preference)";
5 | const getInitialState = (): boolean => !window.matchMedia(QUERY).matches;
6 |
7 | export default function usePrefersReducedMotion(): boolean {
8 | const [prefersReducedMotion, setPrefersReducedMotion] =
9 | useState(getInitialState);
10 |
11 | useEffect(() => {
12 | const mediaQueryList = window.matchMedia(QUERY);
13 | function listener(event: MediaQueryListEvent): void {
14 | setPrefersReducedMotion(!event.matches);
15 | }
16 | mediaQueryList.addEventListener("change", listener);
17 | return () => mediaQueryList.removeEventListener("change", listener);
18 | }, []);
19 |
20 | return prefersReducedMotion;
21 | }
22 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": true,
7 | "skipLibCheck": false,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "paths": {
19 | "@data/*": ["./src/data/*"],
20 | "@state": ["./src/state.ts"],
21 | "@utils": ["./src/utils.ts"],
22 | "@usePrefersReducedMotion": ["./src/usePrefersReducedMotion.tsx"],
23 | "@useDialogStatus": ["./src/useDialogStatus"],
24 | "@icons/*": ["./src/icons/*"],
25 | "types/*": ["./types/*"],
26 | },
27 | },
28 | "include": ["src"],
29 | "references": [{ "path": "./tsconfig.node.json" }],
30 | }
31 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "esnext",
5 | "moduleResolution": "node"
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/types/beta.d.ts:
--------------------------------------------------------------------------------
1 | import type { XYPosition } from "react-flow-renderer";
2 |
3 | type NodeId = string;
4 | type EdgeId = string;
5 | type ElementId = NodeId | EdgeId;
6 |
7 | interface CourseData {
8 | id: string;
9 | name: string;
10 | credits: string;
11 | description: string;
12 | prerequisite: string;
13 | offered: string;
14 | }
15 |
16 | type CourseStatus =
17 | | "completed"
18 | | "enrolled"
19 | | "ready"
20 | | `${"under-one" | "one" | "over-one"}-away`;
21 |
22 | interface NodeData extends CourseData {
23 | nodeStatus: CourseStatus;
24 | nodeConnected: boolean;
25 | }
26 |
27 | export interface Node {
28 | id: NodeId;
29 | type: "custom";
30 | position: XYPosition;
31 | selected: boolean;
32 | data: NodeData;
33 | }
34 |
35 | interface Edge {
36 | id: EdgeId;
37 | source: NodeId;
38 | target: NodeId;
39 | className: CourseStatus;
40 | label: null | string;
41 | animated?: boolean;
42 | }
43 |
44 | export type Element = Node | Edge;
45 |
--------------------------------------------------------------------------------
/types/beta1.d.ts:
--------------------------------------------------------------------------------
1 | import type { XYPosition } from "react-flow-renderer";
2 |
3 | type Campus = "Seattle" | "Bothell" | "Tacoma";
4 |
5 | interface CurriculumData {
6 | campus: Campus;
7 | id: string;
8 | name: string;
9 | }
10 |
11 | type NodeId = string;
12 | type EdgeId = string;
13 | type ElementId = NodeId | EdgeId;
14 |
15 | interface CourseData {
16 | campus?: Campus;
17 | id: string;
18 | name: string;
19 | credits: string;
20 | description: string;
21 | prerequisite: string;
22 | offered: string;
23 | }
24 |
25 | type CourseStatus =
26 | | "completed"
27 | | "enrolled"
28 | | "ready"
29 | | `${"under-one" | "one" | "over-one"}-away`;
30 |
31 | type ConditionalTypes = "or" | "and";
32 | // type NodeTypes = "course" | ConditionalTypes;
33 |
34 | interface BaseNode {
35 | id: NodeId;
36 | position: XYPosition;
37 | }
38 |
39 | interface BaseNodeData {
40 | nodeStatus: CourseStatus;
41 | nodeConnected: boolean;
42 | }
43 |
44 | interface CourseNodeData extends CourseData, BaseNodeData {}
45 |
46 | interface CourseNode extends BaseNode {
47 | type: "course";
48 | data: CourseNodeData;
49 | }
50 |
51 | interface ConditionalNode extends BaseNode {
52 | type: ConditionalTypes;
53 | data: BaseNodeData;
54 | }
55 |
56 | type Node = CourseNode | ConditionalNode;
57 |
58 | interface Edge {
59 | id: EdgeId;
60 | source: NodeId;
61 | target: NodeId;
62 | className: CourseStatus;
63 | label: null | string;
64 | animated?: boolean;
65 | }
66 |
67 | type Element = Node | Edge;
68 |
--------------------------------------------------------------------------------
/types/beta2.d.ts:
--------------------------------------------------------------------------------
1 | import type { XYPosition } from "react-flow-renderer";
2 |
3 | type Campus = "Seattle" | "Bothell" | "Tacoma";
4 |
5 | interface CourseData {
6 | campus?: Campus;
7 | id: string;
8 | name: string;
9 | credits: string;
10 | description: string;
11 | prerequisite: string;
12 | offered: string;
13 | }
14 |
15 | type CourseStatus =
16 | | "completed"
17 | | "enrolled"
18 | | "ready"
19 | | `${"under-one" | "one" | "over-one"}-away`;
20 |
21 | type ConditionalTypes = "or" | "and";
22 |
23 | type NodeId = string;
24 | type EdgeId = string;
25 | type ElementId = NodeId | EdgeId;
26 |
27 | interface BaseNode {
28 | id: NodeId;
29 | position: XYPosition;
30 | }
31 |
32 | interface BaseNodeData {
33 | nodeStatus: CourseStatus;
34 | nodeConnected: boolean;
35 | }
36 |
37 | interface CourseNodeData extends CourseData, BaseNodeData {}
38 |
39 | interface CourseNode extends BaseNode {
40 | type: "course";
41 | data: CourseNodeData;
42 | }
43 |
44 | interface ConditionalNode extends BaseNode {
45 | type: ConditionalTypes;
46 | data: BaseNodeData;
47 | }
48 |
49 | type Node = CourseNode | ConditionalNode;
50 |
51 | interface Edge {
52 | id: EdgeId;
53 | type: "custom";
54 | source: NodeId;
55 | target: NodeId;
56 | className: CourseStatus;
57 | data: { concurrent: boolean };
58 | animated?: boolean;
59 | }
60 |
61 | export type Element = Node | Edge;
62 |
--------------------------------------------------------------------------------
/types/main.d.ts:
--------------------------------------------------------------------------------
1 | import type { Dispatch, SetStateAction, ReactElement } from "react";
2 | import type { XYPosition } from "react-flow-renderer";
3 |
4 | export type SetState = Dispatch>;
5 |
6 | type WriteOnlyMap = Omit, "get">;
7 |
8 | export type AlwaysDefinedMap = WriteOnlyMap & {
9 | get: (key: K) => V;
10 | };
11 |
12 | export type Campus = "Seattle" | "Bothell" | "Tacoma";
13 |
14 | export type NodeId = string;
15 | export type EdgeId = string;
16 | export type ElementId = NodeId | EdgeId;
17 |
18 | export interface CourseData {
19 | campus?: Campus;
20 | id: string;
21 | name: string;
22 | credits: string;
23 | description: string;
24 | prerequisite: string;
25 | offered: string;
26 | }
27 |
28 | export type CourseStatus =
29 | | "completed"
30 | | "enrolled"
31 | | "ready"
32 | | `${"under-one" | "one" | "over-one"}-away`;
33 |
34 | export type ConditionalTypes = "or" | "and";
35 | // type NodeTypes = "course" | ConditionalTypes;
36 |
37 | interface BaseNode {
38 | id: NodeId;
39 | position: XYPosition;
40 | }
41 |
42 | export interface BaseNodeData {
43 | nodeStatus: CourseStatus;
44 | nodeConnected: boolean;
45 | }
46 |
47 | export interface CourseNodeData extends CourseData, BaseNodeData {}
48 |
49 | export interface CourseNode extends BaseNode {
50 | type: "course";
51 | data: CourseNodeData;
52 | }
53 |
54 | export interface ConditionalNode extends BaseNode {
55 | type: ConditionalTypes;
56 | data: BaseNodeData;
57 | }
58 |
59 | export type Node = CourseNode | ConditionalNode;
60 |
61 | export type InnerText = (string | ReactElement)[];
62 |
63 | export interface Edge {
64 | id: EdgeId;
65 | type: "custom";
66 | source: NodeId;
67 | target: NodeId;
68 | className: CourseStatus;
69 | data: { concurrent: boolean };
70 | animated?: boolean;
71 | }
72 |
73 | export type Element = Node | Edge;
74 |
75 | interface NodeDataValue {
76 | depth: number;
77 | incomingNodes: NodeId[];
78 | incomingEdges: EdgeId[];
79 | outgoingEdges: EdgeId[];
80 | outgoingNodes: NodeId[];
81 | }
82 | export type NodeDataMap = AlwaysDefinedMap;
83 |
84 | type NodeIndex = number;
85 | type EdgeIndex = number;
86 | type ElementIndex = NodeIndex | EdgeIndex;
87 |
88 | export type ElemIndexMap = AlwaysDefinedMap;
89 |
90 | export interface ConnectTo {
91 | prereq: boolean;
92 | postreq: boolean;
93 | }
94 |
95 | export type NewCoursePosition = "zero" | "relative";
96 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-default-export */
2 | /* eslint-disable import/no-extraneous-dependencies */
3 | import { defineConfig } from "vite";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 | import react from "@vitejs/plugin-react";
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [react(), tsconfigPaths()],
10 | build: {
11 | target: "es2017",
12 | },
13 | server: {
14 | port: 3001,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------