diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 10c08d1..f9c9698 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -1,10 +1,9 @@ name: Github Actions on: - push: pull_request: jobs: - test: - name: Test + test-api: + name: test-api runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 @@ -23,3 +22,23 @@ jobs: ${{ runner.OS }}- - run: npm ci - run: npm run test + test-unit: + name: test-unit + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: "14.x" + - name: Cache Node.js modules + uses: actions/cache@v2 + with: + # npm cache files are stored in `~/.npm` on Linux/macOS + path: ~/.npm + key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.OS }}-node- + ${{ runner.OS }}- + - run: npm ci + - run: npm run test-unit diff --git a/app/classes/Matrix.ts b/app/classes/Matrix.ts index cad7f6e..0af9b58 100644 --- a/app/classes/Matrix.ts +++ b/app/classes/Matrix.ts @@ -1,3 +1,10 @@ +import { range } from "../utils/misc" + +import { EchelonType } from "../types/Matrix" +import { leadingEntryIndex } from "../utils/Matrix" + +import * as _ from "lodash" + interface IMatrix { rows: number columns: number @@ -23,6 +30,213 @@ abstract class BaseMatrix { this.entries = tmp } } + + /** + * Returns whether a matrix is in RREF, REF, or NONE. + */ + echelonStatus(): EchelonType { + // obtain array of leading entry indices + let leadingIndices: number[] = [] + this.entries.forEach((row) => { + const leadingIndex = leadingEntryIndex(row) + leadingIndices.push(leadingIndex) + }) + + // remove all end -1s as they are extra zero rows at the bottom of the matrix + while ( + leadingIndices.length > 0 && + leadingIndices[leadingIndices.length - 1] === -1 + ) + leadingIndices.pop() + + // Check 1a: if there are still non-zero rows, this means they are not at the bottom, so return NONE + if (leadingIndices.some((one) => one === -1)) return EchelonType.NONE + + // Check 1b: if leadingEntryIndices not in increasing order, return NONE + let idx = 1 + while (idx < this.rows) { + if (leadingIndices[idx - 1] >= leadingIndices[idx]) + return EchelonType.NONE + idx++ + } + + // reaching here means is either REF or RREF + + // Check 2: if leadingIndices has no more entries (i.e. was all -1s), this is a zero matrix, and is RREF + if (leadingIndices.length === 0) return EchelonType.RREF + + // Check 3: For RREF, all leading entries of nonzero rows should be 1 + const allLeadingOne = leadingIndices.every( + (colIdx, rowIdx) => this.entries[rowIdx][colIdx] === 1 + ) + if (!allLeadingOne) return EchelonType.REF + + // Check 4: For RREF, the only nonzero value in a pivot column is the pivot point + const pivotColumnsCheck = leadingIndices.every((colIdx, idx) => { + const res = range(this.rows).every((rowIdx) => { + // if the leading index found previously belongs to this row + if (idx == rowIdx) return true + + return this.entries[rowIdx][colIdx] === 0 + }) + return res + }) + if (!pivotColumnsCheck) return EchelonType.REF + + // reaching here means RREF + return EchelonType.RREF + } + + /** + * Multiplies a row by a given factor + * @param rowIdx row index number + * @param factor factor to multiply by + */ + multiplyRow(rowIdx: number, factor: number) { + this.entries[rowIdx] = this.entries[rowIdx].map((one) => factor * one) + } + + /** + * Swaps rows i and j + * @param i row index number + * @param j row index number + */ + swapRows(i: number, j: number) { + const tmp = this.entries[i] + this.entries[i] = this.entries[j] + this.entries[j] = tmp + } + + /** + * Adds a multiple of one row to another row + * @param rowIdx index of the row to multiply factor + * @param factor factor to multiply by + * @param addToIdx index of the row to add to + */ + addMultiple(rowIdx: number, factor: number, addToIdx: number) { + range(this.columns).forEach((colIdx) => { + this.entries[addToIdx][colIdx] += this.entries[rowIdx][colIdx] * factor + }) + } + + /** + * Returns true if every entry in column colIdx is 0, false otherwise + * @param colIdx index of the column + */ + isZeroColumn(colIdx: number) { + return range(this.rows).every( + (rowIdx) => this.entries[rowIdx][colIdx] === 0 + ) + } + + /** + * Reduces the matrix to REF + */ + toREF() { + let actions = [] // array of actions done during REF + + actions.push({ + action: "none", + params: [], + matrix: _.cloneDeep(this.entries), + }) + + let colIdx = 0 // leftmost nonzero column idx + let rowsDone = 0 // number of rows done + + while (this.echelonStatus() === EchelonType.NONE) { + // find next nonzero column + let foundColumn = false + while (!foundColumn && colIdx < this.columns) { + if (this.isZeroColumn(colIdx)) colIdx++ + else foundColumn = true + } + + if (foundColumn) { + let firstEntryRowIdx = -1 + // find first nonzero entry in column, that is excluding the done rows + for (let i = rowsDone; i < this.rows; i++) + if (this.entries[i][colIdx] !== 0) { + firstEntryRowIdx = i + break + } + + // swap top row with this row + if (rowsDone !== firstEntryRowIdx) { + this.swapRows(rowsDone, firstEntryRowIdx) + actions.push({ + action: "swap", + params: [rowsDone, firstEntryRowIdx], + matrix: _.cloneDeep(this.entries), + }) + } + + // add multiple of top row to every other row below + const pivotValue = this.entries[rowsDone][colIdx] + for (let i = rowsDone + 1; i < this.rows; i++) { + const factor = (this.entries[i][colIdx] / pivotValue) * -1 + this.addMultiple(rowsDone, factor, i) + actions.push({ + action: "addMultiple", + params: [rowsDone, factor, i], + matrix: _.cloneDeep(this.entries), + }) + } + + // mark as done + rowsDone++ + colIdx++ + } else { + break + } + } + + return actions + } + + /** + * Reduces the matrix to RREF + */ + toRREF() { + let actions = this.toREF() + + // make all leading entries 1 + this.entries.forEach((row, rowIdx) => { + const colIdx = leadingEntryIndex(row) + if (colIdx !== -1) { + const leadingVal = row[colIdx] + const factor = 1 / leadingVal + if (factor !== 1) { + this.multiplyRow(rowIdx, factor) + actions.push({ + action: "multiplyRow", + params: [rowIdx, factor], + matrix: _.cloneDeep(this.entries), + }) + } + } + }) + + // add multiples to rows above + for (let i = this.rows - 1; i >= 0; i--) { + const colIdx = leadingEntryIndex(this.entries[i]) + if (colIdx === -1) continue + + for (let j = i - 1; j >= 0; j--) { + const factor = -this.entries[j][colIdx] + if (factor !== 0) { + this.addMultiple(i, factor, j) + actions.push({ + action: "addMultiple", + params: [i, factor, j], + matrix: _.cloneDeep(this.entries), + }) + } + } + } + + return actions + } } class Matrix extends BaseMatrix { diff --git a/app/controllers/matrix.ts b/app/controllers/matrix.ts index 14ec5f5..50d2813 100644 --- a/app/controllers/matrix.ts +++ b/app/controllers/matrix.ts @@ -1,6 +1,6 @@ import { VercelRequest, VercelResponse } from "@vercel/node" -import { SquareMatrix } from "../classes/Matrix" +import { Matrix, SquareMatrix } from "../classes/Matrix" const calcDeterminant = (req: VercelRequest, res: VercelResponse) => { try { @@ -27,6 +27,34 @@ const calcDeterminant = (req: VercelRequest, res: VercelResponse) => { } } +const reduceRREF = (req: VercelRequest, res: VercelResponse) => { + try { + let arr + // string array + if (Array.isArray(req.query.matrix)) + arr = JSON.parse(req.query.matrix.join()) + else arr = JSON.parse(req.query.matrix) + + const rows = arr.length + const cols = arr[0].length + + const matrix = new Matrix({ + rows, + columns: cols, + entries: arr, + }) + + const actions = matrix.toRREF() + res.json({ + actions, + matrix: matrix.entries, + }) + } catch (err) { + res.status(500).json({ message: err.message }) + } +} + export default { calcDeterminant, + reduceRREF, } diff --git a/app/tests/unit/index.ts b/app/tests/unit/index.ts new file mode 100644 index 0000000..f27ce0e --- /dev/null +++ b/app/tests/unit/index.ts @@ -0,0 +1,7 @@ +import utils from "./utils" +import matrix from "./matrix" + +describe("Unit tests", () => { + describe("Utils tests", utils) + describe("Matrix tests", matrix) +}) diff --git a/app/tests/unit/matrix.ts b/app/tests/unit/matrix.ts new file mode 100644 index 0000000..f883a59 --- /dev/null +++ b/app/tests/unit/matrix.ts @@ -0,0 +1,243 @@ +// standard testing modules +import chai from "chai" + +import { EchelonType } from "../../types/Matrix" + +// import stuff to be tested +import { Matrix } from "../../classes/Matrix" + +export default () => { + describe("echelonStatus", () => { + it("should return RREF correctly", () => { + const matrices = [ + [ + [1, 0, 0], + [0, 1, 0], + ], + [ + [0, 0], + [0, 0], + [0, 0], + ], + ] + + matrices.forEach((arr) => { + const matrix = new Matrix({ + rows: arr.length, + columns: arr[0].length, + entries: arr, + }) + const status = matrix.echelonStatus() + chai.expect(status).to.equal(EchelonType.RREF) + }) + }) + it("should return REF correctly", () => { + const matrices = [ + [ + [1, 0, 0], + [0, 2, 0], + ], + [ + [1, 1, 3], + [0, 2, 0], + ], + [ + [1, 0, 3], + [0, 0, 1], + ], + ] + + matrices.forEach((arr) => { + const matrix = new Matrix({ + rows: arr.length, + columns: arr[0].length, + entries: arr, + }) + const status = matrix.echelonStatus() + chai.expect(status).to.equal(EchelonType.REF) + }) + }) + it("should return NONE correctly", () => { + const matrices = [ + [ + [0, 0, 0], + [1, 0, 3], + [0, 0, 0], + ], + [ + [0, 1, 0], + [1, 0, 3], + [0, 0, 0], + ], + ] + + matrices.forEach((arr) => { + const matrix = new Matrix({ + rows: arr.length, + columns: arr[0].length, + entries: arr, + }) + const status = matrix.echelonStatus() + chai.expect(status).to.equal(EchelonType.NONE) + }) + }) + }) + + describe("Row operations", () => { + describe("multiplyRow", () => { + it("should multiply a row by a factor correctly", () => { + const matrix = new Matrix({ + rows: 3, + columns: 3, + entries: [ + [1, 2, 3], + [1, 0, 0], + [0, 1, 0], + ], + }) + + // tests + const inputs = [ + { rowIdx: 0, factor: 2 }, + { rowIdx: 1, factor: 1 }, + { rowIdx: 2, factor: -1 }, + ] + const outputs = [ + [2, 4, 6], + [1, 0, 0], + [-0, -1, -0], + ] + + inputs.forEach((options, idx) => { + matrix.multiplyRow(options.rowIdx, options.factor) + chai.expect(matrix.entries[options.rowIdx]).to.eql(outputs[idx]) + }) + }) + }) + describe("swapRows", () => { + it("should swap rows correctly", () => { + const matrix = new Matrix({ + rows: 3, + columns: 3, + entries: [ + [1, 2, 3], + [1, 0, 0], + [0, 1, 0], + ], + }) + + // tests + const inputs = [ + { rowOne: 0, rowTwo: 2 }, + { rowOne: 1, rowTwo: 0 }, + ] + const outputs = [ + { + newRowOne: [0, 1, 0], + newRowTwo: [1, 2, 3], + }, + { + newRowOne: [0, 1, 0], + newRowTwo: [1, 0, 0], + }, + ] + + inputs.forEach((options, idx) => { + matrix.swapRows(options.rowOne, options.rowTwo) + chai + .expect(matrix.entries[options.rowOne]) + .to.eql(outputs[idx].newRowOne) + chai + .expect(matrix.entries[options.rowTwo]) + .to.eql(outputs[idx].newRowTwo) + }) + }) + }) + describe("addMultiple", () => { + it("should add a multiple of one row to another row correctly", () => { + const matrix = new Matrix({ + rows: 3, + columns: 3, + entries: [ + [1, 2, 3], + [1, 0, 0], + [0, 1, 0], + ], + }) + + // tests + const inputs = [ + { baseRowIdx: 0, factor: 1, addToIdx: 2 }, + { baseRowIdx: 1, factor: 2, addToIdx: 0 }, + ] + const outputs = [ + [1, 3, 3], + [3, 2, 3], + ] + + inputs.forEach((options, idx) => { + matrix.addMultiple( + options.baseRowIdx, + options.factor, + options.addToIdx + ) + chai.expect(matrix.entries[options.addToIdx]).to.eql(outputs[idx]) + }) + }) + }) + }) + + describe("toREF", () => { + it("should correctly reduce a matrix to REF", () => { + const matrices = [ + [ + [1, 2, 3], + [2, 3, 4], + [3, 4, 5], + ], + [ + [1, 0, 0], + [0, 3, 0], + [0, 0, 2], + ], + ] + matrices.forEach((array) => { + const matrix = new Matrix({ + rows: array.length, + columns: array[0].length, + entries: array, + }) + const actions = matrix.toREF() + const res = matrix.echelonStatus() + chai.expect(res).to.not.equal(EchelonType.NONE) + }) + }) + }) + + describe("toREF", () => { + it("should correctly reduce a matrix to RREF", () => { + const matrices = [ + [ + [1, 2, 3], + [2, 3, 4], + [3, 4, 5], + ], + [ + [1, 0, 0], + [0, 3, 0], + [0, 0, 2], + ], + ] + matrices.forEach((array) => { + const matrix = new Matrix({ + rows: array.length, + columns: array[0].length, + entries: array, + }) + const actions = matrix.toRREF() + const res = matrix.echelonStatus() + chai.expect(res).to.equal(EchelonType.RREF) + }) + }) + }) +} diff --git a/app/tests/unit/utils.ts b/app/tests/unit/utils.ts new file mode 100644 index 0000000..6793577 --- /dev/null +++ b/app/tests/unit/utils.ts @@ -0,0 +1,57 @@ +// standard testing modules +import chai from "chai" + +// import utils to be tested +import { range } from "../../utils/misc" +import { isZeroRow, leadingEntryIndex } from "../../utils/Matrix" + +export default () => { + describe("Misc: range", () => { + it("should return an array of length N", () => { + const res = range(5).every((val) => { + return range(val + 1).length === val + 1 + }) + chai.expect(res).to.equal(true) + }) + }) + + describe("Matrix: isZeroRow", () => { + it("should return true for zero array", () => { + const res = range(5).every((val) => { + // create zero array with length val+1 + const tmp = [] + for (let i = 0; i < val + 1; i++) tmp.push(0) + return isZeroRow(tmp) + }) + chai.expect(res).to.equal(true) + }) + it("should return false for non-zero array", () => { + const nonZeroArrays = [[1, 2, 3], [0, 0, 1], [1]] + nonZeroArrays.forEach((arr) => { + const res = isZeroRow(arr) + chai.expect(res).to.equal(false) + }) + }) + }) + + describe("Matrix: leadingEntryIndex", () => { + it("should return index of the first nonzero entry", () => { + const arrays = [[1, 2, 3], [0, 0, 1], [1]] + const ans = [0, 2, 0] + + arrays.forEach((arr, idx) => { + const res = leadingEntryIndex(arr) + chai.expect(res).to.equal(ans[idx]) + }) + }) + it("should return -1 for zero arrays", () => { + const res = range(5).every((val) => { + // create zero array with length val+1 + const tmp = [] + for (let i = 0; i < val + 1; i++) tmp.push(0) + return leadingEntryIndex(tmp) === -1 + }) + chai.expect(res).to.equal(true) + }) + }) +} diff --git a/app/types/Matrix.ts b/app/types/Matrix.ts new file mode 100644 index 0000000..4b68bbc --- /dev/null +++ b/app/types/Matrix.ts @@ -0,0 +1,7 @@ +enum EchelonType { + NONE = "None", + REF = "Row echelon form", + RREF = "Reduced row echelon form", +} + +export { EchelonType } diff --git a/app/utils/Matrix.ts b/app/utils/Matrix.ts new file mode 100644 index 0000000..82f7248 --- /dev/null +++ b/app/utils/Matrix.ts @@ -0,0 +1,22 @@ +/** + * Returns true if every entry in the array is 0, false otherwise + * @param arr 1D array + */ +const isZeroRow = (arr: number[]) => { + return !arr.some((num) => num !== 0) +} + +/** + * Returns the index of the first non-zero entry in the array. Returns -1 if not found + * @param arr 1D array + */ +const leadingEntryIndex = (arr: number[]) => { + let idx = 0 + while (idx < arr.length) { + if (arr[idx] !== 0) return idx + idx++ + } + return -1 +} + +export { isZeroRow, leadingEntryIndex } diff --git a/app/utils/misc.ts b/app/utils/misc.ts new file mode 100644 index 0000000..d6c25d1 --- /dev/null +++ b/app/utils/misc.ts @@ -0,0 +1,9 @@ +/** + * @param max highest value + * @returns Array of [0, 1, ..., max-1] + */ +const range = (max: number) => { + return [...Array(max).keys()] +} + +export { range } diff --git a/package-lock.json b/package-lock.json index 579bbc5..d13a63f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "axios": "^0.21.1", "express": "^4.17.1", "framer-motion": "^4.1.17", + "lodash": "^4.17.21", "next": "^11.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/package.json b/package.json index cd5f4e6..a6389d6 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "version": "auto-changelog -p -l 0 && git add CHANGELOG.md", - "test": "mocha -r ts-node/register app/tests/index.ts" + "test": "mocha -r ts-node/register app/tests/index.ts", + "test-unit": "mocha -r ts-node/register app/tests/unit/index.ts" }, "repository": { "type": "git", @@ -28,6 +29,7 @@ "axios": "^0.21.1", "express": "^4.17.1", "framer-motion": "^4.1.17", + "lodash": "^4.17.21", "next": "^11.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/pages/api/[...route].js b/pages/api/[...route].js index 633cd93..987b193 100644 --- a/pages/api/[...route].js +++ b/pages/api/[...route].js @@ -5,11 +5,7 @@ const app = require("express")() app.get("/api/test", Test.test) -/** - * Calculates the determinant of a square matrix - * - * @param {*} matrix 2D Javascript array - */ app.get("/api/matrix/determinant", Matrix.calcDeterminant) +app.get("/api/matrix/rref", Matrix.reduceRREF) module.exports = app diff --git a/pages/matrix.js b/pages/matrix.js index 7321aa1..f4d44ea 100644 --- a/pages/matrix.js +++ b/pages/matrix.js @@ -1,15 +1,33 @@ -import { Button, Flex, HStack, Input, Text, VStack } from "@chakra-ui/react" -import { useState } from "react" +import { + Button, + Container, + HStack, + Input, + Spinner, + Text, + VStack, +} from "@chakra-ui/react" +import { useEffect, useState } from "react" +import Router, { useRouter } from "next/router" import axios from "axios" import { convert2DArrayToMatrix } from "../utils" const Page = () => { - const [query, setQuery] = useState("") - const [inputArray, setInputArray] = useState("") - const [answer, setAnswer] = useState("") - const [error, setError] = useState("") + const [query, setQuery] = useState("") // text in the input + const [error, setError] = useState("") // error regarding query + + const [loading, setLoading] = useState(true) // if page is loading or handleSubmit is running + + const [command, setCommand] = useState("") // action from processed query + const [inputArray, setInputArray] = useState([[]]) // matrix from processed query + const [answer, setAnswer] = useState("") // Latex string + + // only for rref + const [rrefActions, setRrefActions] = useState([]) + + const router = useRouter() const handleChange = (e) => { setError("") @@ -20,62 +38,200 @@ const Page = () => { if (e.keyCode == 13 || e.key == "Enter") handleSubmit() } - const handleSubmit = async () => { + const handleSubmit = async (passedQuery = "") => { // empty query - if (query === "") return + if (query === "" && passedQuery === "") return try { - const res = await axios.get("/api/matrix/determinant", { - params: { - matrix: query, - }, - }) + setLoading(true) + + // split query into action and matrix + let arr = query === "" ? passedQuery.split(" ") : query.split(" ") + const action = arr.shift() + const matrix = arr.join(" ") - setInputArray(JSON.parse(query)) - setAnswer(res.data) + let res + switch (action) { + case "determinant": + case "det": + res = await axios.get("/api/matrix/determinant", { + params: { + matrix, + }, + }) + setCommand("\\mathrm{det}") + setAnswer(res.data) + setRrefActions([]) + Router.push({ + query: { action: "det", matrix }, + }) + break + case "rref": + res = await axios.get("/api/matrix/rref", { + params: { + matrix, + }, + }) + setCommand("\\mathrm{rref}") + setAnswer(convert2DArrayToMatrix(res.data.matrix)) + setRrefActions(res.data.actions) + Router.push({ + query: { action: "rref", matrix }, + }) + break + default: + setLoading(false) + setAnswer("") + setRrefActions([]) + throw new Error("Unrecognized command") + } + setInputArray(JSON.parse(matrix)) // force math typesetting MathJax.typeset() + + setLoading(false) } catch (err) { setError(err.message) } } + useEffect(() => { + if (query === "") { + if (router.query.action && router.query.matrix) { + const command = router.query.action + const matrix = router.query.matrix + setQuery(command + " " + matrix) + handleSubmit(command + " " + matrix) + } else { + setQuery("") + setInputArray([[]]) + setAnswer("") + } + } + }, [router.query]) + + useEffect(() => { + setLoading(false) + }, []) + return ( - - + + {error !== "" && Error: {error}} + {loading && ( + + + + )} {answer !== "" && ( - + Your input is interpreted as: ${convert2DArrayToMatrix(inputArray)}$ - $\mathrm{"{"}det{"}"} + ${command} {convert2DArrayToMatrix(inputArray)} = $ ${answer}$ + {rrefActions.length > 0 && rrefSteps(rrefActions)} )} - + + ) +} + +const rrefSteps = (rrefActions) => { + const descriptionText = (action, params) => { + if (action === "none") return ["Begin with"] + else if (action === "addMultiple") { + if (params[1] > 0) + return [ + "Add", + `$${params[1]}$`, + "times of row", + `$${params[0]}$`, + "to row", + `$${params[2]}$`, + `$(\\text{or }R_${params[2]} + ${params[1]}R_${params[0]})$`, + ] + else + return [ + "Subtract", + `$${-params[1]}$`, + "times of row", + `$${params[0]}$`, + "from row", + `$${params[2]}$`, + `$(\\text{or }R_${params[2]} - ${-params[1]}R_${params[0]})$`, + ] + } else if (action === "swap") + return [ + "Swap row", + `$${params[0]}$`, + "with row", + `$${params[1]}$`, + `$(\\text{or }R_${params[0]} \\leftrightarrow R_${params[1]})$`, + ] + else if (action === "multiplyRow") + return [ + "Multiply row", + `$${params[0]}$`, + "by factor", + `$${params[1]}$`, + `$(\\text{or }${params[1]}R_${params[0]})$`, + ] + } + + return ( + + {rrefActions.map((one) => ( + <> + + {descriptionText(one.action, one.params).map((two) => ( + {two} + ))} + + ${convert2DArrayToMatrix(one.matrix)}$ + + ))} + ) }