From 226807fae303c165581fdba074ee1ef0f0c907e7 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Wed, 15 Jan 2025 10:34:17 -0600 Subject: [PATCH 1/3] plotter --- packages/perseus-score/src/index.ts | 1 + .../src/widgets/plotter/score-plotter.test.ts | 2 +- .../src/widgets/plotter/score-plotter.ts | 12 +++++------- .../src/widgets/plotter/validate-plotter.test.ts | 2 +- .../src/widgets/plotter/validate-plotter.ts | 10 ++++------ packages/perseus/src/widgets/plotter/plotter.tsx | 11 +++++------ 6 files changed, 17 insertions(+), 21 deletions(-) rename packages/{perseus => perseus-score}/src/widgets/plotter/score-plotter.test.ts (96%) rename packages/{perseus => perseus-score}/src/widgets/plotter/score-plotter.ts (76%) rename packages/{perseus => perseus-score}/src/widgets/plotter/validate-plotter.test.ts (96%) rename packages/{perseus => perseus-score}/src/widgets/plotter/validate-plotter.ts (80%) diff --git a/packages/perseus-score/src/index.ts b/packages/perseus-score/src/index.ts index bf6c5474a5..a966ec3781 100644 --- a/packages/perseus-score/src/index.ts +++ b/packages/perseus-score/src/index.ts @@ -9,6 +9,7 @@ export {default as scoreIframe} from "./widgets/iframe/score-iframe"; export {default as scoreMatcher} from "./widgets/matcher/score-matcher"; export {default as scoreNumberLine} from "./widgets/number-line/score-number-line"; export {default as scoreNumericInput} from "./widgets/numeric-input/score-numeric-input"; +export {default as scorePlotter} from "./widgets/plotter/score-plotter"; export {default as scoreRadio} from "./widgets/radio/score-radio"; export {default as scoreTable} from "./widgets/table/score-table"; export { diff --git a/packages/perseus/src/widgets/plotter/score-plotter.test.ts b/packages/perseus-score/src/widgets/plotter/score-plotter.test.ts similarity index 96% rename from packages/perseus/src/widgets/plotter/score-plotter.test.ts rename to packages/perseus-score/src/widgets/plotter/score-plotter.test.ts index d346ad71d9..96dc8595fc 100644 --- a/packages/perseus/src/widgets/plotter/score-plotter.test.ts +++ b/packages/perseus-score/src/widgets/plotter/score-plotter.test.ts @@ -3,7 +3,7 @@ import scorePlotter from "./score-plotter"; import type { PerseusPlotterScoringData, PerseusPlotterUserInput, -} from "@khanacademy/perseus-score"; +} from "../../validation.types"; describe("scorePlotter", () => { it("can be answered correctly", () => { diff --git a/packages/perseus/src/widgets/plotter/score-plotter.ts b/packages/perseus-score/src/widgets/plotter/score-plotter.ts similarity index 76% rename from packages/perseus/src/widgets/plotter/score-plotter.ts rename to packages/perseus-score/src/widgets/plotter/score-plotter.ts index f3ffedcb58..102667e988 100644 --- a/packages/perseus/src/widgets/plotter/score-plotter.ts +++ b/packages/perseus-score/src/widgets/plotter/score-plotter.ts @@ -1,14 +1,12 @@ -import Util from "../../util"; +import _ from "underscore"; import validatePlotter from "./validate-plotter"; import type { - PerseusScore, - PerseusPlotterScoringData, PerseusPlotterUserInput, -} from "@khanacademy/perseus-score"; - -const {deepEq} = Util; + PerseusPlotterScoringData, + PerseusScore, +} from "../../validation.types"; function scorePlotter( userInput: PerseusPlotterUserInput, @@ -20,7 +18,7 @@ function scorePlotter( } return { type: "points", - earned: deepEq(userInput, scoringData.correct) ? 1 : 0, + earned: _.isEqual(userInput, scoringData.correct) ? 1 : 0, total: 1, message: null, }; diff --git a/packages/perseus/src/widgets/plotter/validate-plotter.test.ts b/packages/perseus-score/src/widgets/plotter/validate-plotter.test.ts similarity index 96% rename from packages/perseus/src/widgets/plotter/validate-plotter.test.ts rename to packages/perseus-score/src/widgets/plotter/validate-plotter.test.ts index ad8cd07b93..a4c298f351 100644 --- a/packages/perseus/src/widgets/plotter/validate-plotter.test.ts +++ b/packages/perseus-score/src/widgets/plotter/validate-plotter.test.ts @@ -3,7 +3,7 @@ import validatePlotter from "./validate-plotter"; import type { PerseusPlotterUserInput, PerseusPlotterValidationData, -} from "@khanacademy/perseus-score"; +} from "../../validation.types"; describe("validatePlotter", () => { it("is invalid if the start and end are the same", () => { diff --git a/packages/perseus/src/widgets/plotter/validate-plotter.ts b/packages/perseus-score/src/widgets/plotter/validate-plotter.ts similarity index 80% rename from packages/perseus/src/widgets/plotter/validate-plotter.ts rename to packages/perseus-score/src/widgets/plotter/validate-plotter.ts index ce22e090f7..0f3ac00a22 100644 --- a/packages/perseus/src/widgets/plotter/validate-plotter.ts +++ b/packages/perseus-score/src/widgets/plotter/validate-plotter.ts @@ -1,12 +1,10 @@ -import Util from "../../util"; +import _ from "underscore"; import type { - ValidationResult, PerseusPlotterUserInput, PerseusPlotterValidationData, -} from "@khanacademy/perseus-score"; - -const {deepEq} = Util; + ValidationResult, +} from "../../validation.types"; /** * Checks user input to confirm it is not the same as the starting values for the graph. @@ -18,7 +16,7 @@ function validatePlotter( userInput: PerseusPlotterUserInput, validationData: PerseusPlotterValidationData, ): ValidationResult { - if (deepEq(userInput, validationData.starting)) { + if (_.isEqual(userInput, validationData.starting)) { return { type: "invalid", message: null, diff --git a/packages/perseus/src/widgets/plotter/plotter.tsx b/packages/perseus/src/widgets/plotter/plotter.tsx index 21ab30a1cb..3eb871496b 100644 --- a/packages/perseus/src/widgets/plotter/plotter.tsx +++ b/packages/perseus/src/widgets/plotter/plotter.tsx @@ -1,5 +1,10 @@ /* eslint-disable react/no-unsafe */ import {KhanMath} from "@khanacademy/kmath"; +import { + scorePlotter, + type PerseusPlotterScoringData, + type PerseusPlotterUserInput, +} from "@khanacademy/perseus-score"; import $ from "jquery"; import * as React from "react"; import ReactDOM from "react-dom"; @@ -13,15 +18,9 @@ import KhanColors from "../../util/colors"; import GraphUtils from "../../util/graph-utils"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/plotter/plotter-ai-utils"; -import scorePlotter from "./score-plotter"; - import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {UnsupportedWidgetPromptJSON} from "../../widget-ai-utils/unsupported-widget"; import type {PerseusPlotterWidgetOptions} from "@khanacademy/perseus-core"; -import type { - PerseusPlotterScoringData, - PerseusPlotterUserInput, -} from "@khanacademy/perseus-score"; type RenderProps = PerseusPlotterWidgetOptions; From ac902bb1f52bbdec214078015a753cc83b1106c5 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Wed, 15 Jan 2025 10:36:42 -0600 Subject: [PATCH 2/3] sorter --- packages/perseus-score/src/index.ts | 1 + .../src/widgets/sorter/score-sorter.test.ts | 4 ++-- .../src/widgets/sorter/score-sorter.ts | 10 +++++----- .../src/widgets/sorter/validate-sorter.test.ts | 2 +- .../src/widgets/sorter/validate-sorter.ts | 4 ++-- packages/perseus/src/widgets/sorter/sorter.tsx | 11 +++++------ 6 files changed, 16 insertions(+), 16 deletions(-) rename packages/{perseus => perseus-score}/src/widgets/sorter/score-sorter.test.ts (98%) rename packages/{perseus => perseus-score}/src/widgets/sorter/score-sorter.ts (79%) rename packages/{perseus => perseus-score}/src/widgets/sorter/validate-sorter.test.ts (92%) rename packages/{perseus => perseus-score}/src/widgets/sorter/validate-sorter.ts (96%) diff --git a/packages/perseus-score/src/index.ts b/packages/perseus-score/src/index.ts index a966ec3781..141e449e73 100644 --- a/packages/perseus-score/src/index.ts +++ b/packages/perseus-score/src/index.ts @@ -11,6 +11,7 @@ export {default as scoreNumberLine} from "./widgets/number-line/score-number-lin export {default as scoreNumericInput} from "./widgets/numeric-input/score-numeric-input"; export {default as scorePlotter} from "./widgets/plotter/score-plotter"; export {default as scoreRadio} from "./widgets/radio/score-radio"; +export {default as scoreSorter} from "./widgets/sorter/score-sorter"; export {default as scoreTable} from "./widgets/table/score-table"; export { default as scoreInputNumber, diff --git a/packages/perseus/src/widgets/sorter/score-sorter.test.ts b/packages/perseus-score/src/widgets/sorter/score-sorter.test.ts similarity index 98% rename from packages/perseus/src/widgets/sorter/score-sorter.test.ts rename to packages/perseus-score/src/widgets/sorter/score-sorter.test.ts index eb56acbd90..5569f4032d 100644 --- a/packages/perseus/src/widgets/sorter/score-sorter.test.ts +++ b/packages/perseus-score/src/widgets/sorter/score-sorter.test.ts @@ -2,9 +2,9 @@ import scoreSorter from "./score-sorter"; import * as SorterValidator from "./validate-sorter"; import type { - PerseusSorterRubric, PerseusSorterUserInput, -} from "@khanacademy/perseus-score"; + PerseusSorterRubric, +} from "../../validation.types"; describe("scoreSorter", () => { it("is correct when the user input values are in the order defined in the rubric", () => { diff --git a/packages/perseus/src/widgets/sorter/score-sorter.ts b/packages/perseus-score/src/widgets/sorter/score-sorter.ts similarity index 79% rename from packages/perseus/src/widgets/sorter/score-sorter.ts rename to packages/perseus-score/src/widgets/sorter/score-sorter.ts index b3c1d5be47..f82cbba472 100644 --- a/packages/perseus/src/widgets/sorter/score-sorter.ts +++ b/packages/perseus-score/src/widgets/sorter/score-sorter.ts @@ -1,12 +1,12 @@ -import Util from "../../util"; +import _ from "underscore"; import validateSorter from "./validate-sorter"; import type { - PerseusScore, - PerseusSorterRubric, PerseusSorterUserInput, -} from "@khanacademy/perseus-score"; + PerseusSorterRubric, + PerseusScore, +} from "../../validation.types"; function scoreSorter( userInput: PerseusSorterUserInput, @@ -17,7 +17,7 @@ function scoreSorter( return validationError; } - const correct = Util.deepEq(userInput.options, rubric.correct); + const correct = _.isEqual(userInput.options, rubric.correct); return { type: "points", earned: correct ? 1 : 0, diff --git a/packages/perseus/src/widgets/sorter/validate-sorter.test.ts b/packages/perseus-score/src/widgets/sorter/validate-sorter.test.ts similarity index 92% rename from packages/perseus/src/widgets/sorter/validate-sorter.test.ts rename to packages/perseus-score/src/widgets/sorter/validate-sorter.test.ts index cf97463b49..c79beca6c0 100644 --- a/packages/perseus/src/widgets/sorter/validate-sorter.test.ts +++ b/packages/perseus-score/src/widgets/sorter/validate-sorter.test.ts @@ -1,6 +1,6 @@ import validateSorter from "./validate-sorter"; -import type {PerseusSorterUserInput} from "@khanacademy/perseus-score"; +import type {PerseusSorterUserInput} from "../../validation.types"; describe("validateSorter", () => { it("is invalid when the user has not made any changes", () => { diff --git a/packages/perseus/src/widgets/sorter/validate-sorter.ts b/packages/perseus-score/src/widgets/sorter/validate-sorter.ts similarity index 96% rename from packages/perseus/src/widgets/sorter/validate-sorter.ts rename to packages/perseus-score/src/widgets/sorter/validate-sorter.ts index cf264797a1..709839a8f5 100644 --- a/packages/perseus/src/widgets/sorter/validate-sorter.ts +++ b/packages/perseus-score/src/widgets/sorter/validate-sorter.ts @@ -1,7 +1,7 @@ import type { - ValidationResult, PerseusSorterUserInput, -} from "@khanacademy/perseus-score"; + ValidationResult, +} from "../../validation.types"; /** * Checks user input for the sorter widget to ensure that the user has made diff --git a/packages/perseus/src/widgets/sorter/sorter.tsx b/packages/perseus/src/widgets/sorter/sorter.tsx index 9c9931902f..baacc2201b 100644 --- a/packages/perseus/src/widgets/sorter/sorter.tsx +++ b/packages/perseus/src/widgets/sorter/sorter.tsx @@ -1,20 +1,19 @@ import {linterContextDefault} from "@khanacademy/perseus-linter"; +import { + scoreSorter, + type PerseusSorterRubric, + type PerseusSorterUserInput, +} from "@khanacademy/perseus-score"; import * as React from "react"; import Sortable from "../../components/sortable"; import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/sorter/sorter-ai-utils"; -import scoreSorter from "./score-sorter"; - import type {SortableOption} from "../../components/sortable"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; import type {SorterPromptJSON} from "../../widget-ai-utils/sorter/sorter-ai-utils"; import type {PerseusSorterWidgetOptions} from "@khanacademy/perseus-core"; -import type { - PerseusSorterRubric, - PerseusSorterUserInput, -} from "@khanacademy/perseus-score"; const {shuffle} = Util; From 806808a085acd140d34b619ed100aea1b23a28a3 Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 17 Jan 2025 15:19:17 -0600 Subject: [PATCH 3/3] Move scorer: Orderer (#2114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * orderer * Move scorerer: LabelImage (#2115) * move label-image * rename labelImageScoreMarker * Move scoring logic: Matrix (#2116) * move matrix * Move scoring logic: Expression (#2118) * move expression scorer * respond to Ben's feedback * merge Grapher move * fix conflict again * Move scorer: Grapher (#2119) * move matrix * move expression scorer * move grapher scorer * respond to Ben's feedback * Move scorer: Interactive Graph (#2120) * STOPSHIP some type errors still * add back duplicate declarations * add back Line duplicate * all tests passing * Revert changes to underscore's isEqual (#2125) ## Summary: [Original comment](https://github.com/Khan/perseus/pull/2113#discussion_r1919335906) I made a separate PR because I made this mistake in a couple of PRs so I thought I'd knock them out all at once. Issue: LEMS-2737 ## Test plan: Author: handeyeco Reviewers: jeremywiebe, benchristel Required Reviewers: Approved By: jeremywiebe Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ❌ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ⏹️ [cancelled] Publish npm snapshot (ubuntu-latest, 20.x), ⏹️ [cancelled] Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ⏹️ [cancelled] Check builds for changes in size (ubuntu-latest, 20.x), ⏹️ [cancelled] Cypress (ubuntu-latest, 20.x), ⏹️ [cancelled] Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏹️ [cancelled] Publish npm snapshot (ubuntu-latest, 20.x), ⏹️ [cancelled] Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏹️ [cancelled] Check builds for changes in size (ubuntu-latest, 20.x), ⏹️ [cancelled] Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ⏹️ [cancelled] Cypress (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x) Pull Request URL: https://github.com/Khan/perseus/pull/2125 * respond to Jeremy's feedback --- .../math => kmath/src}/angles.test.ts | 0 .../math => kmath/src}/angles.ts | 16 +- packages/kmath/src/coefficients.ts | 62 ++ .../src/util => kmath/src}/geometry.test.ts | 0 .../src/util => kmath/src}/geometry.ts | 25 +- packages/kmath/src/index.ts | 8 + packages/kmath/src/types.ts | 3 + .../components/key-handlers/key-translator.ts | 3 +- .../src/components/keypad/button-assets.tsx | 7 +- packages/math-input/src/utils.ts | 37 - packages/perseus-core/src/index.ts | 7 + .../perseus-core/src/utils/deep-clone.test.ts | 25 + packages/perseus-core/src/utils/deep-clone.ts | 18 + packages/perseus-core/src/utils/equality.ts | 55 ++ .../src/utils}/get-decimal-separator.ts | 0 .../perseus-core/src/utils/get-matrix-size.ts | 27 + .../src/utils}/grapher-types.ts | 2 +- .../perseus-core/src/utils/grapher-util.ts | 646 +++++++++++++++++ .../src/components/graph-settings.tsx | 3 +- .../src/widgets/grapher-editor.tsx | 4 +- .../interaction-editor/interaction-editor.tsx | 3 +- .../start-coords/util.ts | 5 +- .../src/widgets/matrix-editor.tsx | 25 +- packages/perseus-score/src/error-codes.ts | 2 + packages/perseus-score/src/index.ts | 9 + .../expression/score-expression.test.ts | 7 +- .../expression/score-expression.testdata.ts | 36 + .../widgets/expression/score-expression.ts | 26 +- .../expression/validate-expression.test.ts | 0 .../widgets/expression/validate-expression.ts | 4 +- .../src/widgets/grapher/score-grapher.test.ts | 6 +- .../src/widgets/grapher/score-grapher.ts | 18 +- .../score-interactive-graph.test.ts | 9 +- .../score-interactive-graph.ts | 57 +- .../label-image/score-label-image.test.ts | 14 +- .../widgets/label-image/score-label-image.ts | 12 +- .../src/widgets/matrix/score-matrix.test.ts | 40 +- .../src/widgets/matrix/score-matrix.ts | 15 +- .../widgets/matrix/validate-matrix.test.ts | 10 +- .../src/widgets/matrix/validate-matrix.ts | 12 +- .../src/widgets/orderer/score-orderer.test.ts | 50 +- .../src/widgets/orderer/score-orderer.ts | 8 +- .../widgets/orderer/validate-orderer.test.ts | 2 +- .../src/widgets/orderer/validate-orderer.ts | 4 +- .../src/widgets/plotter/score-plotter.ts | 3 +- .../src/widgets/plotter/validate-plotter.ts | 3 +- .../src/widgets/sorter/score-sorter.ts | 3 +- packages/perseus/src/__tests__/util.test.ts | 24 - .../perseus/src/components/graphie-classes.ts | 9 +- packages/perseus/src/components/graphie.tsx | 10 +- packages/perseus/src/index.ts | 6 - packages/perseus/src/strings.ts | 1 + packages/perseus/src/util.ts | 74 -- packages/perseus/src/util/interactive.ts | 4 +- .../perseus/src/util/is-real-json-parse.ts | 6 +- .../src/widgets/expression/expression.tsx | 17 +- .../perseus/src/widgets/grapher/grapher.tsx | 18 +- packages/perseus/src/widgets/grapher/util.tsx | 661 +----------------- .../perseus/src/widgets/interactive-graph.tsx | 69 +- .../interactive-graphs/graphs/angle.tsx | 5 +- .../components/angle-indicators.test.ts | 4 +- .../graphs/components/angle-indicators.tsx | 7 +- .../graphs/components/vector.tsx | 4 +- .../interactive-graphs/graphs/quadratic.tsx | 3 +- .../interactive-graphs/graphs/sinusoid.tsx | 18 +- .../locked-figures/locked-line.tsx | 5 +- .../widgets/interactive-graphs/math/index.ts | 7 - .../widgets/interactive-graphs/protractor.tsx | 11 +- .../reducer/initialize-graph-state.ts | 4 +- .../reducer/interactive-graph-reducer.test.ts | 5 +- .../reducer/interactive-graph-reducer.ts | 51 +- .../reducer/interactive-graph-state.ts | 4 +- .../src/widgets/interactive-graphs/types.ts | 3 +- .../src/widgets/label-image/label-image.tsx | 15 +- .../perseus/src/widgets/matrix/matrix.tsx | 44 +- .../perseus/src/widgets/orderer/orderer.tsx | 11 +- 76 files changed, 1257 insertions(+), 1174 deletions(-) rename packages/{perseus/src/widgets/interactive-graphs/math => kmath/src}/angles.test.ts (100%) rename packages/{perseus/src/widgets/interactive-graphs/math => kmath/src}/angles.ts (86%) create mode 100644 packages/kmath/src/coefficients.ts rename packages/{perseus/src/util => kmath/src}/geometry.test.ts (100%) rename packages/{perseus/src/util => kmath/src}/geometry.ts (96%) create mode 100644 packages/kmath/src/types.ts create mode 100644 packages/perseus-core/src/utils/deep-clone.test.ts create mode 100644 packages/perseus-core/src/utils/deep-clone.ts create mode 100644 packages/perseus-core/src/utils/equality.ts rename packages/{perseus/src/widgets/expression => perseus-core/src/utils}/get-decimal-separator.ts (100%) create mode 100644 packages/perseus-core/src/utils/get-matrix-size.ts rename packages/{perseus/src/widgets/grapher => perseus-core/src/utils}/grapher-types.ts (97%) create mode 100644 packages/perseus-core/src/utils/grapher-util.ts rename packages/{perseus => perseus-score}/src/widgets/expression/score-expression.test.ts (96%) create mode 100644 packages/perseus-score/src/widgets/expression/score-expression.testdata.ts rename packages/{perseus => perseus-score}/src/widgets/expression/score-expression.ts (93%) rename packages/{perseus => perseus-score}/src/widgets/expression/validate-expression.test.ts (100%) rename packages/{perseus => perseus-score}/src/widgets/expression/validate-expression.ts (93%) rename packages/{perseus => perseus-score}/src/widgets/grapher/score-grapher.test.ts (98%) rename packages/{perseus => perseus-score}/src/widgets/grapher/score-grapher.ts (86%) rename packages/{perseus/src/widgets/interactive-graphs => perseus-score/src/widgets/interactive-graph}/score-interactive-graph.test.ts (97%) rename packages/{perseus/src/widgets/interactive-graphs => perseus-score/src/widgets/interactive-graph}/score-interactive-graph.ts (88%) rename packages/{perseus => perseus-score}/src/widgets/label-image/score-label-image.test.ts (94%) rename packages/{perseus => perseus-score}/src/widgets/label-image/score-label-image.ts (94%) rename packages/{perseus => perseus-score}/src/widgets/matrix/score-matrix.test.ts (82%) rename packages/{perseus => perseus-score}/src/widgets/matrix/score-matrix.ts (86%) rename packages/{perseus => perseus-score}/src/widgets/matrix/validate-matrix.test.ts (75%) rename packages/{perseus => perseus-score}/src/widgets/matrix/validate-matrix.ts (81%) rename packages/{perseus => perseus-score}/src/widgets/orderer/score-orderer.test.ts (65%) rename packages/{perseus => perseus-score}/src/widgets/orderer/score-orderer.ts (87%) rename packages/{perseus => perseus-score}/src/widgets/orderer/validate-orderer.test.ts (91%) rename packages/{perseus => perseus-score}/src/widgets/orderer/validate-orderer.ts (93%) diff --git a/packages/perseus/src/widgets/interactive-graphs/math/angles.test.ts b/packages/kmath/src/angles.test.ts similarity index 100% rename from packages/perseus/src/widgets/interactive-graphs/math/angles.test.ts rename to packages/kmath/src/angles.test.ts diff --git a/packages/perseus/src/widgets/interactive-graphs/math/angles.ts b/packages/kmath/src/angles.ts similarity index 86% rename from packages/perseus/src/widgets/interactive-graphs/math/angles.ts rename to packages/kmath/src/angles.ts index c2d20bd4e8..ec4b55b294 100644 --- a/packages/perseus/src/widgets/interactive-graphs/math/angles.ts +++ b/packages/kmath/src/angles.ts @@ -1,9 +1,8 @@ -import {clockwise} from "../../../util/geometry"; +// This file contains helper functions for working with angles. -import type {Coord} from "@khanacademy/perseus"; -import type {vec} from "mafs"; +import {clockwise} from "./geometry"; -// This file contains helper functions for working with angles. +import type {Coord} from "@khanacademy/perseus-core"; export function convertDegreesToRadians(degrees: number): number { return (degrees / 180) * Math.PI; @@ -11,12 +10,12 @@ export function convertDegreesToRadians(degrees: number): number { // Returns a value between -180 and 180, inclusive. The angle is measured // between the positive x-axis and the given vector. -export function calculateAngleInDegrees([x, y]: vec.Vector2): number { +export function calculateAngleInDegrees([x, y]: Coord): number { return (Math.atan2(y, x) * 180) / Math.PI; } // Converts polar coordinates to cartesian. The th(eta) parameter is in degrees. -export function polar(r: number | vec.Vector2, th: number): vec.Vector2 { +export function polar(r: number | Coord, th: number): Coord { if (typeof r === "number") { r = [r, r]; } @@ -26,10 +25,7 @@ export function polar(r: number | vec.Vector2, th: number): vec.Vector2 { // This function calculates the angle between two points and an optional vertex. // If the vertex is not provided, the angle is measured between the two points. // This does not account for reflex angles or clockwise position. -export const getAngleFromVertex = ( - point: vec.Vector2, - vertex: vec.Vector2, -): number => { +export const getAngleFromVertex = (point: Coord, vertex: Coord): number => { const x = point[0] - vertex[0]; const y = point[1] - vertex[1]; if (!x && !y) { diff --git a/packages/kmath/src/coefficients.ts b/packages/kmath/src/coefficients.ts new file mode 100644 index 0000000000..c33c9ba9b2 --- /dev/null +++ b/packages/kmath/src/coefficients.ts @@ -0,0 +1,62 @@ +import type {SineCoefficient} from "./geometry"; +import type {Coord} from "@khanacademy/perseus-core"; + +export type NamedSineCoefficient = { + amplitude: number; + angularFrequency: number; + phase: number; + verticalOffset: number; +}; + +// TODO: there's another, very similar getSinusoidCoefficients function +// they should probably be merged +export function getSinusoidCoefficients( + coords: ReadonlyArray, +): SineCoefficient { + // It's assumed that p1 is the root and p2 is the first peak + const p1 = coords[0]; + const p2 = coords[1]; + + // Resulting coefficients are canonical for this sine curve + const amplitude = p2[1] - p1[1]; + const angularFrequency = Math.PI / (2 * (p2[0] - p1[0])); + const phase = p1[0] * angularFrequency; + const verticalOffset = p1[1]; + + return [amplitude, angularFrequency, phase, verticalOffset]; +} + +export type QuadraticCoefficient = [number, number, number]; + +// TODO: there's another, very similar getQuadraticCoefficients function +// they should probably be merged +export function getQuadraticCoefficients( + coords: ReadonlyArray, +): QuadraticCoefficient { + const p1 = coords[0]; + const p2 = coords[1]; + const p3 = coords[2]; + + const denom = (p1[0] - p2[0]) * (p1[0] - p3[0]) * (p2[0] - p3[0]); + if (denom === 0) { + // Many of the callers assume that the return value is always defined. + // @ts-expect-error - TS2322 - Type 'undefined' is not assignable to type 'QuadraticCoefficient'. + return; + } + const a = + (p3[0] * (p2[1] - p1[1]) + + p2[0] * (p1[1] - p3[1]) + + p1[0] * (p3[1] - p2[1])) / + denom; + const b = + (p3[0] * p3[0] * (p1[1] - p2[1]) + + p2[0] * p2[0] * (p3[1] - p1[1]) + + p1[0] * p1[0] * (p2[1] - p3[1])) / + denom; + const c = + (p2[0] * p3[0] * (p2[0] - p3[0]) * p1[1] + + p3[0] * p1[0] * (p3[0] - p1[0]) * p2[1] + + p1[0] * p2[0] * (p1[0] - p2[0]) * p3[1]) / + denom; + return [a, b, c]; +} diff --git a/packages/perseus/src/util/geometry.test.ts b/packages/kmath/src/geometry.test.ts similarity index 100% rename from packages/perseus/src/util/geometry.test.ts rename to packages/kmath/src/geometry.test.ts diff --git a/packages/perseus/src/util/geometry.ts b/packages/kmath/src/geometry.ts similarity index 96% rename from packages/perseus/src/util/geometry.ts rename to packages/kmath/src/geometry.ts index ab7eacc0fe..3ded900a65 100644 --- a/packages/perseus/src/util/geometry.ts +++ b/packages/kmath/src/geometry.ts @@ -2,14 +2,16 @@ * A collection of geomtry-related utility functions */ -import {number as knumber, point as kpoint, sum} from "@khanacademy/kmath"; +import { + approximateDeepEqual, + approximateEqual, + type Coord, +} from "@khanacademy/perseus-core"; import _ from "underscore"; -import Util from "../util"; - -import type {Coord, Line} from "../interactive2/types"; +import {number as knumber, point as kpoint, sum} from "@khanacademy/kmath"; -const {eq, deepEq} = Util; +type Line = [Coord, Coord]; // This should really be a readonly tuple of [number, number] export type Range = [number, number]; @@ -21,12 +23,9 @@ export type SineCoefficient = [ number, // verticalOffset ]; -// a, b, c -export type QuadraticCoefficient = [number, number, number]; - // Given a number, return whether it is positive (1), negative (-1), or zero (0) export function sign(val: number): 0 | 1 | -1 { - if (eq(val, 0)) { + if (approximateEqual(val, 0)) { return 0; } return val > 0 ? 1 : -1; @@ -39,7 +38,7 @@ export function ccw(a: Coord, b: Coord, c: Coord): number { } export function collinear(a: Coord, b: Coord, c: Coord): boolean { - return eq(ccw(a, b, c), 0); + return approximateEqual(ccw(a, b, c), 0); } // Given rect bounding points A and B, whether point C is inside the rect @@ -229,7 +228,7 @@ export function similar( // @ts-expect-error - TS4104 - The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'. sides = rotate(sides, i); - if (deepEq(angles1, angles)) { + if (approximateDeepEqual(angles1, angles)) { const sidePairs = _.zip(sides1, sides); const factors = _.map(sidePairs, function (pair) { @@ -237,7 +236,7 @@ export function similar( }); const same = _.all(factors, function (factor) { - return eq(factors[0], factor); + return approximateEqual(factors[0], factor); }); const congruentEnough = _.all(sidePairs, function (pair) { @@ -304,7 +303,7 @@ export function rotate( } export function getLineEquation(first: Coord, second: Coord): string { - if (eq(first[0], second[0])) { + if (approximateEqual(first[0], second[0])) { return "x = " + first[0].toFixed(3); } const m = (second[1] - first[1]) / (second[0] - first[0]); diff --git a/packages/kmath/src/index.ts b/packages/kmath/src/index.ts index cbeb7d8403..284f4eb02e 100644 --- a/packages/kmath/src/index.ts +++ b/packages/kmath/src/index.ts @@ -5,5 +5,13 @@ export * as vector from "./vector"; export * as point from "./point"; export * as line from "./line"; export * as ray from "./ray"; +export * as angles from "./angles"; +export * as geometry from "./geometry"; +export * as coefficients from "./coefficients"; export {default as KhanMath, sum} from "./math"; + +export type {Range, SineCoefficient} from "./geometry"; +export type {NamedSineCoefficient, QuadraticCoefficient} from "./coefficients"; + +export type * from "./types"; diff --git a/packages/kmath/src/types.ts b/packages/kmath/src/types.ts new file mode 100644 index 0000000000..7c24a6dd7b --- /dev/null +++ b/packages/kmath/src/types.ts @@ -0,0 +1,3 @@ +import type {Coord} from "@khanacademy/perseus-core"; + +export type QuadraticCoords = [Coord, Coord, Coord]; diff --git a/packages/math-input/src/components/key-handlers/key-translator.ts b/packages/math-input/src/components/key-handlers/key-translator.ts index ced26e7254..6d5b8347bf 100644 --- a/packages/math-input/src/components/key-handlers/key-translator.ts +++ b/packages/math-input/src/components/key-handlers/key-translator.ts @@ -1,5 +1,6 @@ +import {getDecimalSeparator} from "@khanacademy/perseus-core"; + import {MathFieldActionType} from "../../types"; -import {getDecimalSeparator} from "../../utils"; import {mathQuillInstance} from "../input/mathquill-instance"; import handleArrow from "./handle-arrow"; diff --git a/packages/math-input/src/components/keypad/button-assets.tsx b/packages/math-input/src/components/keypad/button-assets.tsx index 732efcd596..5803f6ceca 100644 --- a/packages/math-input/src/components/keypad/button-assets.tsx +++ b/packages/math-input/src/components/keypad/button-assets.tsx @@ -10,9 +10,9 @@ asset. In the future it would be great if these were included from files so that no copying and pasting is necessary. */ +import {getDecimalSeparator} from "@khanacademy/perseus-core"; import * as React from "react"; -import {DecimalSeparator, getDecimalSeparator} from "../../utils"; import {useMathInputI18n} from "../i18n-context"; import type Key from "../../data/keys"; @@ -176,10 +176,7 @@ export default function ButtonAsset({id}: Props): React.ReactNode { case "PERIOD": // Different locales use different symbols for the decimal separator // (, vs .) - if ( - id === "DECIMAL" && - getDecimalSeparator(locale) === DecimalSeparator.COMMA - ) { + if (id === "DECIMAL" && getDecimalSeparator(locale) !== ".") { // comma decimal separator return ( { - let separator: string = DecimalSeparator.PERIOD; - - switch (locale) { - // TODO(somewhatabstract): Remove this when Chrome supports the `ka` - // locale properly. - // https://github.com/formatjs/formatjs/issues/1526#issuecomment-559891201 - // - // Supported locales in Chrome: - // https://source.chromium.org/chromium/chromium/src/+/master:third_party/icu/scripts/chrome_ui_languages.list - case "ka": - separator = ","; - break; - - default: - const numberWithDecimalSeparator = 1.1; - // TODO(FEI-3647): Update to use .formatToParts() once we no longer have to - // support Safari 12. - const match = new Intl.NumberFormat(locale) - .format(numberWithDecimalSeparator) - // 0x661 is ARABIC-INDIC DIGIT ONE - // 0x6F1 is EXTENDED ARABIC-INDIC DIGIT ONE - .match(/[^\d\u0661\u06F1]/); - separator = match?.[0] ?? "."; - } - - return separator === "," ? DecimalSeparator.COMMA : DecimalSeparator.PERIOD; -}; - const CDOT_ONLY = [ "az", "cs", diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index a284735b04..df95a6a770 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -8,9 +8,16 @@ export type { Relationship, } from "./types"; export type {ErrorKind} from "./error/errors"; +export type {FunctionTypeMappingKeys} from "./utils/grapher-util"; +export type {Coords} from "./utils/grapher-types"; // Careful, `version.ts` uses this function so it _must_ be imported above it export {addLibraryVersionToPerseusDebug} from "./utils/add-library-version-to-perseus-debug"; +export {default as getMatrixSize} from "./utils/get-matrix-size"; +export {default as getDecimalSeparator} from "./utils/get-decimal-separator"; +export {approximateEqual, approximateDeepEqual} from "./utils/equality"; +export {default as deepClone} from "./utils/deep-clone"; +export * as GrapherUtil from "./utils/grapher-util"; export {libVersion} from "./version"; diff --git a/packages/perseus-core/src/utils/deep-clone.test.ts b/packages/perseus-core/src/utils/deep-clone.test.ts new file mode 100644 index 0000000000..682349af3f --- /dev/null +++ b/packages/perseus-core/src/utils/deep-clone.test.ts @@ -0,0 +1,25 @@ +import deepClone from "./deep-clone"; + +describe("deepClone", () => { + it("does nothing to a primitive", () => { + expect(deepClone(3)).toBe(3); + }); + + it("copies an array", () => { + const input = [1, 2, 3]; + + const result = deepClone(input); + + expect(result).toEqual(input); + expect(result).not.toBe(input); + }); + + it("recursively clones array elements", () => { + const input = [[1]]; + + const result = deepClone(input); + + expect(result).toEqual(input); + expect(result[0]).not.toBe(input[0]); + }); +}); diff --git a/packages/perseus-core/src/utils/deep-clone.ts b/packages/perseus-core/src/utils/deep-clone.ts new file mode 100644 index 0000000000..941a89e95a --- /dev/null +++ b/packages/perseus-core/src/utils/deep-clone.ts @@ -0,0 +1,18 @@ +// TODO(benchristel): in the future, we may want to make deepClone work for +// Record as well. Currently, it only does arrays. +type Cloneable = + | null + | undefined + | boolean + | string + | number + | Cloneable[] + | readonly Cloneable[]; +function deepClone(obj: T): T { + if (Array.isArray(obj)) { + return obj.map(deepClone) as T; + } + return obj; +} + +export default deepClone; diff --git a/packages/perseus-core/src/utils/equality.ts b/packages/perseus-core/src/utils/equality.ts new file mode 100644 index 0000000000..9d2bf59e46 --- /dev/null +++ b/packages/perseus-core/src/utils/equality.ts @@ -0,0 +1,55 @@ +import _ from "underscore"; + +/** + * APPROXIMATE equality on numbers and primitives. + */ +export function approximateEqual(x: T, y: T): boolean { + if (typeof x === "number" && typeof y === "number") { + return Math.abs(x - y) < 1e-9; + } + return x === y; +} + +/** + * Deep APPROXIMATE equality on primitives, numbers, arrays, and objects. + * Recursive. + */ +export function approximateDeepEqual(x: T, y: T): boolean { + if (Array.isArray(x) && Array.isArray(y)) { + if (x.length !== y.length) { + return false; + } + for (let i = 0; i < x.length; i++) { + if (!approximateDeepEqual(x[i], y[i])) { + return false; + } + } + return true; + } + if (Array.isArray(x) || Array.isArray(y)) { + return false; + } + if (typeof x === "function" && typeof y === "function") { + return approximateEqual(x, y); + } + if (typeof x === "function" || typeof y === "function") { + return false; + } + if (typeof x === "object" && typeof y === "object" && !!x && !!y) { + return ( + x === y || + (_.all(x, function (v, k) { + // @ts-expect-error - TS2536 - Type 'CollectionKey' cannot be used to index type 'T'. + return approximateDeepEqual(y[k], v); + }) && + _.all(y, function (v, k) { + // @ts-expect-error - TS2536 - Type 'CollectionKey' cannot be used to index type 'T'. + return approximateDeepEqual(x[k], v); + })) + ); + } + if ((typeof x === "object" && !!x) || (typeof y === "object" && !!y)) { + return false; + } + return approximateEqual(x, y); +} diff --git a/packages/perseus/src/widgets/expression/get-decimal-separator.ts b/packages/perseus-core/src/utils/get-decimal-separator.ts similarity index 100% rename from packages/perseus/src/widgets/expression/get-decimal-separator.ts rename to packages/perseus-core/src/utils/get-decimal-separator.ts diff --git a/packages/perseus-core/src/utils/get-matrix-size.ts b/packages/perseus-core/src/utils/get-matrix-size.ts new file mode 100644 index 0000000000..1388852973 --- /dev/null +++ b/packages/perseus-core/src/utils/get-matrix-size.ts @@ -0,0 +1,27 @@ +import _ from "underscore"; + +function getMatrixSize(matrix: ReadonlyArray>) { + const matrixSize = [1, 1]; + + // We need to find the widest row and tallest column to get the correct + // matrix size. + _(matrix).each((matrixRow, row) => { + let rowWidth = 0; + _(matrixRow).each((matrixCol, col) => { + if (matrixCol != null && matrixCol.toString().length) { + rowWidth = col + 1; + } + }); + + // Matrix width: + matrixSize[1] = Math.max(matrixSize[1], rowWidth); + + // Matrix height: + if (rowWidth > 0) { + matrixSize[0] = Math.max(matrixSize[0], row + 1); + } + }); + return matrixSize; +} + +export default getMatrixSize; diff --git a/packages/perseus/src/widgets/grapher/grapher-types.ts b/packages/perseus-core/src/utils/grapher-types.ts similarity index 97% rename from packages/perseus/src/widgets/grapher/grapher-types.ts rename to packages/perseus-core/src/utils/grapher-types.ts index c004902cbb..232f91831d 100644 --- a/packages/perseus/src/widgets/grapher/grapher-types.ts +++ b/packages/perseus-core/src/utils/grapher-types.ts @@ -1,4 +1,4 @@ -import type {Coord} from "@khanacademy/perseus"; +import type {Coord} from "../data-schema"; export type Coords = [Coord, Coord]; diff --git a/packages/perseus-core/src/utils/grapher-util.ts b/packages/perseus-core/src/utils/grapher-util.ts new file mode 100644 index 0000000000..072b9d8f58 --- /dev/null +++ b/packages/perseus-core/src/utils/grapher-util.ts @@ -0,0 +1,646 @@ +import _ from "underscore"; + +import {approximateDeepEqual} from "./equality"; + +import type { + LinearType, + QuadraticType, + SinusoidType, + TangentType, + ExponentialType, + LogarithmType, + AbsoluteValueType, + Coords, +} from "./grapher-types"; +import type {Coord} from "../data-schema"; + +export const MOVABLES = { + PLOT: "PLOT", + PARABOLA: "PARABOLA", + SINUSOID: "SINUSOID", +}; + +// TODO(charlie): These really need to go into a utility file as they're being +// used by both interactive-graph and now grapher. +function canonicalSineCoefficients(coeffs: any) { + // For a curve of the form f(x) = a * Sin(b * x - c) + d, + // this function ensures that a, b > 0, and c is its + // smallest possible positive value. + let amplitude = coeffs[0]; + let angularFrequency = coeffs[1]; + let phase = coeffs[2]; + const verticalOffset = coeffs[3]; + + // Guarantee a > 0 + if (amplitude < 0) { + amplitude *= -1; + angularFrequency *= -1; + phase *= -1; + } + + const period = 2 * Math.PI; + // Guarantee b > 0 + if (angularFrequency < 0) { + angularFrequency *= -1; + phase *= -1; + phase += period / 2; + } + + // Guarantee c is smallest possible positive value + while (phase > 0) { + phase -= period; + } + while (phase < 0) { + phase += period; + } + + return [amplitude, angularFrequency, phase, verticalOffset]; +} + +function canonicalTangentCoefficients(coeffs: any) { + // For a curve of the form f(x) = a * Tan(b * x - c) + d, + // this function ensures that a, b > 0, and c is its + // smallest possible positive value. + let amplitude = coeffs[0]; + let angularFrequency = coeffs[1]; + let phase = coeffs[2]; + const verticalOffset = coeffs[3]; + + // Guarantee a > 0 + if (amplitude < 0) { + amplitude *= -1; + angularFrequency *= -1; + phase *= -1; + } + + const period = Math.PI; + // Guarantee b > 0 + if (angularFrequency < 0) { + angularFrequency *= -1; + phase *= -1; + phase += period / 2; + } + + // Guarantee c is smallest possible positive value + while (phase > 0) { + phase -= period; + } + while (phase < 0) { + phase += period; + } + + return [amplitude, angularFrequency, phase, verticalOffset]; +} + +const PlotDefaults = { + areEqual: function ( + coeffs1: ReadonlyArray, + coeffs2: ReadonlyArray, + ): boolean { + return approximateDeepEqual(coeffs1, coeffs2); + }, + movable: MOVABLES.PLOT, + getPropsForCoeffs: function (coeffs: ReadonlyArray): {fn: any} { + return { + // @ts-expect-error - TS2339 - Property 'getFunctionForCoeffs' does not exist on type '{ readonly areEqual: (coeffs1: any, coeffs2: any) => boolean; readonly Movable: any; readonly getPropsForCoeffs: (coeffs: any) => any; }'. + fn: _.partial(this.getFunctionForCoeffs, coeffs), + }; + }, +} as const; + +const Linear: LinearType = _.extend({}, PlotDefaults, { + url: "https://ka-perseus-graphie.s3.amazonaws.com/67aaf581e6d9ef9038c10558a1f70ac21c11c9f8.png", + + defaultCoords: [ + [0.25, 0.75], + [0.75, 0.75], + ], + + getCoefficients: function ( + coords: Coords, + ): ReadonlyArray | undefined { + const p1 = coords[0]; + const p2 = coords[1]; + + const denom = p2[0] - p1[0]; + const num = p2[1] - p1[1]; + + if (denom === 0) { + return; + } + + const m = num / denom; + const b = p2[1] - m * p2[0]; + return [m, b]; + }, + + getFunctionForCoeffs: function (coeffs: ReadonlyArray, x: number) { + const m = coeffs[0]; + const b = coeffs[1]; + return m * x + b; + }, + + getEquationString: function (coords: Coords) { + const coeffs: ReadonlyArray = this.getCoefficients(coords); + const m: number = coeffs[0]; + const b: number = coeffs[1]; + return "y = " + m.toFixed(3) + "x + " + b.toFixed(3); + }, +}); + +const Quadratic: QuadraticType = _.extend({}, PlotDefaults, { + url: "https://ka-perseus-graphie.s3.amazonaws.com/e23d36e6fc29ee37174e92c9daba2a66677128ab.png", + + defaultCoords: [ + [0.5, 0.5], + [0.75, 0.75], + ], + movable: MOVABLES.PARABOLA, + + getCoefficients: function (coords: Coords): ReadonlyArray { + const p1 = coords[0]; + const p2 = coords[1]; + + // Parabola with vertex (h, k) has form: y = a * (h - k)^2 + k + const h = p1[0]; + const k = p1[1]; + + // Use these to calculate familiar a, b, c + const a = (p2[1] - k) / ((p2[0] - h) * (p2[0] - h)); + const b = -2 * h * a; + const c = a * h * h + k; + + return [a, b, c]; + }, + + getFunctionForCoeffs: function ( + coeffs: ReadonlyArray, + x: number, + ): number { + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + return (a * x + b) * x + c; + }, + + getPropsForCoeffs: function (coeffs: ReadonlyArray): { + a: number; + b: number; + c: number; + } { + return { + a: coeffs[0], + b: coeffs[1], + c: coeffs[2], + }; + }, + + getEquationString: function (coords: Coords) { + const coeffs = this.getCoefficients(coords); + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + return ( + "y = " + + a.toFixed(3) + + "x^2 + " + + b.toFixed(3) + + "x + " + + c.toFixed(3) + ); + }, +}); + +const Sinusoid: SinusoidType = _.extend({}, PlotDefaults, { + url: "https://ka-perseus-graphie.s3.amazonaws.com/3d68e7718498475f53b206c2ab285626baf8857e.png", + + defaultCoords: [ + [0.5, 0.5], + [0.6, 0.6], + ], + movable: MOVABLES.SINUSOID, + + getCoefficients: function (coords: Coords) { + const p1 = coords[0]; + const p2 = coords[1]; + + const a = p2[1] - p1[1]; + const b = Math.PI / (2 * (p2[0] - p1[0])); + const c = p1[0] * b; + const d = p1[1]; + + return [a, b, c, d]; + }, + + getFunctionForCoeffs: function (coeffs: ReadonlyArray, x: number) { + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + const d = coeffs[3]; + return a * Math.sin(b * x - c) + d; + }, + + getPropsForCoeffs: function (coeffs: ReadonlyArray) { + return { + a: coeffs[0], + b: coeffs[1], + c: coeffs[2], + d: coeffs[3], + }; + }, + + getEquationString: function (coords: Coords) { + const coeffs = this.getCoefficients(coords); + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + const d = coeffs[3]; + return ( + "y = " + + a.toFixed(3) + + " sin(" + + b.toFixed(3) + + "x - " + + c.toFixed(3) + + ") + " + + d.toFixed(3) + ); + }, + + areEqual: function ( + coeffs1: ReadonlyArray, + coeffs2: ReadonlyArray, + ) { + return approximateDeepEqual( + canonicalSineCoefficients(coeffs1), + canonicalSineCoefficients(coeffs2), + ); + }, +}); + +const Tangent: TangentType = _.extend({}, PlotDefaults, { + url: "https://ka-perseus-graphie.s3.amazonaws.com/7db80d23c35214f98659fe1cf0765811c1bbfbba.png", + + defaultCoords: [ + [0.5, 0.5], + [0.75, 0.75], + ], + + getCoefficients: function (coords: Coords) { + const p1 = coords[0]; + const p2 = coords[1]; + + const a = p2[1] - p1[1]; + const b = Math.PI / (4 * (p2[0] - p1[0])); + const c = p1[0] * b; + const d = p1[1]; + + return [a, b, c, d]; + }, + + getFunctionForCoeffs: function (coeffs: ReadonlyArray, x: number) { + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + const d = coeffs[3]; + return a * Math.tan(b * x - c) + d; + }, + + getEquationString: function (coords: Coords) { + const coeffs = this.getCoefficients(coords); + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + const d = coeffs[3]; + return ( + "y = " + + a.toFixed(3) + + " sin(" + + b.toFixed(3) + + "x - " + + c.toFixed(3) + + ") + " + + d.toFixed(3) + ); + }, + + areEqual: function ( + coeffs1: ReadonlyArray, + coeffs2: ReadonlyArray, + ) { + return approximateDeepEqual( + canonicalTangentCoefficients(coeffs1), + canonicalTangentCoefficients(coeffs2), + ); + }, +}); + +const Exponential: ExponentialType = _.extend({}, PlotDefaults, { + url: "https://ka-perseus-graphie.s3.amazonaws.com/9cbfad55525e3ce755a31a631b074670a5dad611.png", + + defaultCoords: [ + [0.5, 0.55], + [0.75, 0.75], + ], + + defaultAsymptote: [ + [0, 0.5], + [1.0, 0.5], + ], + + /** + * Add extra constraints for movement of the points or asymptote (below): + * newCoord: [x, y] + * The end position of the point or asymptote endpoint + * oldCoord: [x, y] + * The old position of the point or asymptote endpoint + * coords: + * An array of coordinates representing the proposed end configuration + * of the plot coordinates. + * asymptote: + * An array of coordinates representing the proposed end configuration + * of the asymptote. + * + * Return: either a coordinate (to be used as the resulting coordinate of + * the move) or a boolean, where `true` uses newCoord as the resulting + * coordinate, and `false` uses oldCoord as the resulting coordinate. + */ + extraCoordConstraint: function ( + newCoord: Coord, + oldCoord: Coord, + coords: Coords, + asymptote: Coords, + graph, + ) { + const y: number = asymptote[0][1]; + return _.all(coords, (coord) => coord[1] !== y); + }, + + extraAsymptoteConstraint: function ( + newCoord: Coord, + oldCoord: Coord, + coords: Coords, + asymptote: Coords, + graph, + ): Coord { + const y = newCoord[1]; + const isValid = + _.all(coords, (coord) => coord[1] > y) || + _.all(coords, (coord) => coord[1] < y); + + if (isValid) { + return [oldCoord[0], y]; + } + // Snap the asymptote as close as possible, i.e., if the user moves + // the mouse really quickly into an invalid region + const oldY = oldCoord[1]; + const wasBelow = _.all(coords, (coord) => coord[1] > oldY); + if (wasBelow) { + const bottomMost = _.min(_.map(coords, (coord) => coord[1])); + return [oldCoord[0], bottomMost - graph.snapStep[1]]; + } + const topMost = _.max(_.map(coords, (coord) => coord[1])); + return [oldCoord[0], topMost + graph.snapStep[1]]; + }, + + allowReflectOverAsymptote: true, + + getCoefficients: function ( + coords: Coords, + asymptote: Coords, + ): ReadonlyArray { + const p1 = coords[0]; + const p2 = coords[1]; + + const c = asymptote[0][1]; + const b = Math.log((p1[1] - c) / (p2[1] - c)) / (p1[0] - p2[0]); + const a = (p1[1] - c) / Math.exp(b * p1[0]); + return [a, b, c]; + }, + + getFunctionForCoeffs: function ( + coeffs: ReadonlyArray, + x: number, + ): number { + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + return a * Math.exp(b * x) + c; + }, + + getEquationString: function (coords: Coords, asymptote: Coords) { + if (!asymptote) { + return null; + } + const coeffs = this.getCoefficients(coords, asymptote); + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + return ( + "y = " + + a.toFixed(3) + + "e^(" + + b.toFixed(3) + + "x) + " + + c.toFixed(3) + ); + }, +}); + +const Logarithm: LogarithmType = _.extend({}, PlotDefaults, { + url: "https://ka-perseus-graphie.s3.amazonaws.com/f6491e99d34af34d924bfe0231728ad912068dc3.png", + + defaultCoords: [ + [0.55, 0.5], + [0.75, 0.75], + ], + + defaultAsymptote: [ + [0.5, 0], + [0.5, 1.0], + ], + + extraCoordConstraint: function ( + newCoord: Coord, + oldCoord: Coord, + coords: Coord, + asymptote: Coords, + graph, + ) { + const x = asymptote[0][0]; + return ( + _.all(coords, (coord) => coord[0] !== x) && + coords[0][1] !== coords[1][1] + ); + }, + + extraAsymptoteConstraint: function ( + newCoord: Coord, + oldCoord: Coord, + coords: Coords, + asymptote: Coords, + graph, + ): ReadonlyArray { + const x = newCoord[0]; + const isValid = + _.all(coords, (coord) => coord[0] > x) || + _.all(coords, (coord) => coord[0] < x); + + if (isValid) { + return [x, oldCoord[1]]; + } + // Snap the asymptote as close as possible, i.e., if the user moves + // the mouse really quickly into an invalid region + const oldX = oldCoord[0]; + const wasLeft = _.all(coords, (coord) => coord[0] > oldX); + if (wasLeft) { + const leftMost = _.min(_.map(coords, (coord) => coord[0])); + return [leftMost - graph.snapStep[0], oldCoord[1]]; + } + const rightMost = _.max(_.map(coords, (coord) => coord[0])); + return [rightMost + graph.snapStep[0], oldCoord[1]]; + }, + + allowReflectOverAsymptote: true, + + getCoefficients: function ( + coords: Coords, + asymptote: Coords, + ): ReadonlyArray | undefined { + // It's easiest to calculate the logarithm's coefficients by thinking + // about it as the inverse of the exponential, so we flip x and y and + // perform some algebra on the coefficients. This also unifies the + // logic between the two 'models'. + const flip = (coord: Coord): Coord => [coord[1], coord[0]]; + const inverseCoeffs = Exponential.getCoefficients( + _.map(coords, flip) as Coords, + _.map(asymptote, flip) as Coords, + ); + if (inverseCoeffs) { + const c = -inverseCoeffs[2] / inverseCoeffs[0]; + const b = 1 / inverseCoeffs[0]; + const a = 1 / inverseCoeffs[1]; + return [a, b, c]; + } + }, + + getFunctionForCoeffs: function ( + coeffs: ReadonlyArray, + x: number, + asymptote: Coords, + ) { + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + return a * Math.log(b * x + c); + }, + + getEquationString: function (coords: Coords, asymptote: Coords) { + if (!asymptote) { + return null; + } + const coeffs: ReadonlyArray = this.getCoefficients( + coords, + asymptote, + ); + const a = coeffs[0]; + const b = coeffs[1]; + const c = coeffs[2]; + return ( + "y = ln(" + + a.toFixed(3) + + "x + " + + b.toFixed(3) + + ") + " + + c.toFixed(3) + ); + }, +}); + +const AbsoluteValue: AbsoluteValueType = _.extend({}, PlotDefaults, { + url: "https://ka-perseus-graphie.s3.amazonaws.com/8256a630175a0cb1d11de223d6de0266daf98721.png", + + defaultCoords: [ + [0.5, 0.5], + [0.75, 0.75], + ], + + getCoefficients: function ( + coords: Coords, + ): ReadonlyArray | undefined { + const p1 = coords[0]; + const p2 = coords[1]; + + const denom = p2[0] - p1[0]; + const num = p2[1] - p1[1]; + + if (denom === 0) { + return; + } + + let m = Math.abs(num / denom); + if (p2[1] < p1[1]) { + m *= -1; + } + const horizontalOffset = p1[0]; + const verticalOffset = p1[1]; + + return [m, horizontalOffset, verticalOffset]; + }, + + getFunctionForCoeffs: function (coeffs: ReadonlyArray, x: number) { + const m = coeffs[0]; + const horizontalOffset = coeffs[1]; + const verticalOffset = coeffs[2]; + return m * Math.abs(x - horizontalOffset) + verticalOffset; + }, + + getEquationString: function (coords: Coords) { + const coeffs: ReadonlyArray = this.getCoefficients(coords); + const m = coeffs[0]; + const horizontalOffset = coeffs[1]; + const verticalOffset = coeffs[2]; + return ( + "y = " + + m.toFixed(3) + + "| x - " + + horizontalOffset.toFixed(3) + + "| + " + + verticalOffset.toFixed(3) + ); + }, +}); + +/* Utility functions for dealing with graphing interfaces. */ +const functionTypeMapping = { + linear: Linear, + quadratic: Quadratic, + sinusoid: Sinusoid, + tangent: Tangent, + exponential: Exponential, + logarithm: Logarithm, + absolute_value: AbsoluteValue, +} as const; + +export const allTypes: any = _.keys(functionTypeMapping); + +export type FunctionTypeMappingKeys = keyof typeof functionTypeMapping; + +type ConditionalGraderType = + // prettier-ignore + T extends "linear" ? LinearType + : T extends "quadratic" ? QuadraticType + : T extends "sinusoid" ? SinusoidType + : T extends "tangent" ? TangentType + : T extends "exponential" ? ExponentialType + : T extends "logarithm" ? LogarithmType + : T extends "absolute_value" ? AbsoluteValueType + : never; + +export function functionForType( + type: T, +): ConditionalGraderType { + // @ts-expect-error: TypeScript doesn't know how to use deal with generics + // and conditional types in this way. + return functionTypeMapping[type]; +} diff --git a/packages/perseus-editor/src/components/graph-settings.tsx b/packages/perseus-editor/src/components/graph-settings.tsx index 4ae48751f5..4f551f628e 100644 --- a/packages/perseus-editor/src/components/graph-settings.tsx +++ b/packages/perseus-editor/src/components/graph-settings.tsx @@ -15,8 +15,7 @@ import * as React from "react"; import ReactDOM from "react-dom"; import _ from "underscore"; -import type {Coords} from "@khanacademy/perseus"; -import type {MarkingsType} from "@khanacademy/perseus-core"; +import type {Coords, MarkingsType} from "@khanacademy/perseus-core"; const {ButtonGroup, InfoTip, RangeInput} = components; diff --git a/packages/perseus-editor/src/widgets/grapher-editor.tsx b/packages/perseus-editor/src/widgets/grapher-editor.tsx index f2056e96f5..9b2d1631bb 100644 --- a/packages/perseus-editor/src/widgets/grapher-editor.tsx +++ b/packages/perseus-editor/src/widgets/grapher-editor.tsx @@ -7,6 +7,7 @@ import { containerSizeClass, getInteractiveBoxFromSizeClass, } from "@khanacademy/perseus"; +import {GrapherUtil as CoreGrapherUtil} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; @@ -16,7 +17,6 @@ const {InfoTip, MultiButtonGroup} = components; const Grapher = GrapherWidget.widget; const { DEFAULT_GRAPHER_PROPS, - allTypes, chooseType, defaultPlotProps, getEquationString, @@ -141,7 +141,7 @@ class GrapherEditor extends React.Component { diff --git a/packages/perseus-editor/src/widgets/interaction-editor/interaction-editor.tsx b/packages/perseus-editor/src/widgets/interaction-editor/interaction-editor.tsx index 1b6f03ef32..c35e9e6c6a 100644 --- a/packages/perseus-editor/src/widgets/interaction-editor/interaction-editor.tsx +++ b/packages/perseus-editor/src/widgets/interaction-editor/interaction-editor.tsx @@ -21,8 +21,7 @@ import ParametricEditor from "./parametric-editor"; import PointEditor from "./point-editor"; import RectangleEditor from "./rectangle-editor"; -import type {Coords} from "@khanacademy/perseus"; -import type {MarkingsType} from "@khanacademy/perseus-core"; +import type {Coords, MarkingsType} from "@khanacademy/perseus-core"; const {getDependencies} = Dependencies; const {unescapeMathMode} = Util; diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts index 8ce80a5df8..99dc26d410 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts @@ -1,4 +1,4 @@ -import {vector as kvector} from "@khanacademy/kmath"; +import {angles, vector as kvector} from "@khanacademy/kmath"; import { getAngleCoords, getCircleCoords, @@ -9,13 +9,14 @@ import { getQuadraticCoords, getSegmentCoords, getSinusoidCoords, - getClockwiseAngle, } from "@khanacademy/perseus"; import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; import type {StartCoords} from "./types"; import type {Range, PerseusGraphType, Coord} from "@khanacademy/perseus-core"; +const {getClockwiseAngle} = angles; + export function getStartCoords(graph: PerseusGraphType): StartCoords { if ("startCoords" in graph) { return graph.startCoords; diff --git a/packages/perseus-editor/src/widgets/matrix-editor.tsx b/packages/perseus-editor/src/widgets/matrix-editor.tsx index 2f9321165e..48e191ab73 100644 --- a/packages/perseus-editor/src/widgets/matrix-editor.tsx +++ b/packages/perseus-editor/src/widgets/matrix-editor.tsx @@ -4,6 +4,7 @@ import { EditorJsonify, MatrixWidget, } from "@khanacademy/perseus"; +import {getMatrixSize} from "@khanacademy/perseus-core"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; @@ -17,30 +18,6 @@ const Matrix = MatrixWidget.widget; // have to cap it at some point. const MAX_BOARD_SIZE = 6; -const getMatrixSize = function (matrix: any) { - const matrixSize = [1, 1]; - - // We need to find the widest row and tallest column to get the correct - // matrix size. - _(matrix).each((matrixRow, row) => { - let rowWidth = 0; - _(matrixRow).each((matrixCol, col) => { - if (matrixCol != null && matrixCol.toString().length) { - rowWidth = col + 1; - } - }); - - // Matrix width: - matrixSize[1] = Math.max(matrixSize[1], rowWidth); - - // Matrix height: - if (rowWidth > 0) { - matrixSize[0] = Math.max(matrixSize[0], row + 1); - } - }); - return matrixSize; -}; - type Props = any; class MatrixEditor extends React.Component { diff --git a/packages/perseus-score/src/error-codes.ts b/packages/perseus-score/src/error-codes.ts index 50551148e2..2fe56ba195 100644 --- a/packages/perseus-score/src/error-codes.ts +++ b/packages/perseus-score/src/error-codes.ts @@ -8,6 +8,7 @@ const MULTIPLICATION_SIGN_ERROR = "MULTIPLICATION_SIGN_ERROR"; const INVALID_SELECTION_ERROR = "INVALID_SELECTION_ERROR"; const CHOOSE_CORRECT_NUM_ERROR = "CHOOSE_CORRECT_NUM_ERROR"; const NOT_NONE_ABOVE_ERROR = "NOT_NONE_ABOVE_ERROR"; +const FILL_ALL_CELLS_ERROR = "FILL_ALL_CELLS_ERROR"; const ErrorCodes = { MISSING_PERCENT_ERROR, @@ -20,6 +21,7 @@ const ErrorCodes = { INVALID_SELECTION_ERROR, CHOOSE_CORRECT_NUM_ERROR, NOT_NONE_ABOVE_ERROR, + FILL_ALL_CELLS_ERROR, }; export default ErrorCodes; diff --git a/packages/perseus-score/src/index.ts b/packages/perseus-score/src/index.ts index 141e449e73..4378337722 100644 --- a/packages/perseus-score/src/index.ts +++ b/packages/perseus-score/src/index.ts @@ -5,10 +5,19 @@ export type * from "./validation.types"; export {default as scoreCategorizer} from "./widgets/categorizer/score-categorizer"; export {default as scoreCSProgram} from "./widgets/cs-program/score-cs-program"; export {default as scoreDropdown} from "./widgets/dropdown/score-dropdown"; +export {default as scoreExpression} from "./widgets/expression/score-expression"; +export {default as scoreGrapher} from "./widgets/grapher/score-grapher"; export {default as scoreIframe} from "./widgets/iframe/score-iframe"; +export {default as scoreInteractiveGraph} from "./widgets/interactive-graph/score-interactive-graph"; +export { + default as scoreLabelImage, + scoreLabelImageMarker, +} from "./widgets/label-image/score-label-image"; export {default as scoreMatcher} from "./widgets/matcher/score-matcher"; +export {default as scoreMatrix} from "./widgets/matrix/score-matrix"; export {default as scoreNumberLine} from "./widgets/number-line/score-number-line"; export {default as scoreNumericInput} from "./widgets/numeric-input/score-numeric-input"; +export {default as scoreOrderer} from "./widgets/orderer/score-orderer"; export {default as scorePlotter} from "./widgets/plotter/score-plotter"; export {default as scoreRadio} from "./widgets/radio/score-radio"; export {default as scoreSorter} from "./widgets/sorter/score-sorter"; diff --git a/packages/perseus/src/widgets/expression/score-expression.test.ts b/packages/perseus-score/src/widgets/expression/score-expression.test.ts similarity index 96% rename from packages/perseus/src/widgets/expression/score-expression.test.ts rename to packages/perseus-score/src/widgets/expression/score-expression.test.ts index 97b78f9019..2bab201af6 100644 --- a/packages/perseus/src/widgets/expression/score-expression.test.ts +++ b/packages/perseus-score/src/widgets/expression/score-expression.test.ts @@ -1,11 +1,12 @@ -import {mockStrings} from "../../strings"; - -import {expressionItem3Options} from "./expression.testdata"; import scoreExpression from "./score-expression"; +import {expressionItem3Options} from "./score-expression.testdata"; import * as ExpressionValidator from "./validate-expression"; import type {PerseusExpressionRubric} from "@khanacademy/perseus-score"; +// TODO: remove strings as a param for scorers +const mockStrings = {}; + describe("scoreExpression", () => { it("should be correctly answerable if validation passes", function () { // Arrange diff --git a/packages/perseus-score/src/widgets/expression/score-expression.testdata.ts b/packages/perseus-score/src/widgets/expression/score-expression.testdata.ts new file mode 100644 index 0000000000..ee152ecb48 --- /dev/null +++ b/packages/perseus-score/src/widgets/expression/score-expression.testdata.ts @@ -0,0 +1,36 @@ +import type {PerseusExpressionWidgetOptions} from "@khanacademy/perseus-core"; + +export const expressionItem3Options: PerseusExpressionWidgetOptions = { + answerForms: [ + { + considered: "ungraded", + form: false, + simplify: false, + value: "x+1", + }, + { + considered: "wrong", + form: false, + simplify: false, + value: "y+1", + }, + { + considered: "correct", + form: false, + simplify: false, + value: "z+1", + }, + { + considered: "correct", + form: false, + simplify: false, + value: "a+1", + }, + ], + times: false, + buttonSets: ["basic"], + functions: ["f", "g", "h"], + buttonsVisible: "focused", + visibleLabel: "number of cm", + ariaLabel: "number of centimeters", +}; diff --git a/packages/perseus/src/widgets/expression/score-expression.ts b/packages/perseus-score/src/widgets/expression/score-expression.ts similarity index 93% rename from packages/perseus/src/widgets/expression/score-expression.ts rename to packages/perseus-score/src/widgets/expression/score-expression.ts index aefdfacba8..94e3569264 100644 --- a/packages/perseus/src/widgets/expression/score-expression.ts +++ b/packages/perseus-score/src/widgets/expression/score-expression.ts @@ -1,21 +1,22 @@ import * as KAS from "@khanacademy/kas"; -import {Errors} from "@khanacademy/perseus-core"; -import {KhanAnswerTypes} from "@khanacademy/perseus-score"; +import { + Errors, + getDecimalSeparator, + PerseusError, +} from "@khanacademy/perseus-core"; import _ from "underscore"; -import {Log} from "../../logging/log"; +import KhanAnswerTypes from "../../util/answer-types"; -import getDecimalSeparator from "./get-decimal-separator"; import validateExpression from "./validate-expression"; -import type {PerseusStrings} from "../../strings"; -import type {PerseusExpressionAnswerForm} from "@khanacademy/perseus-core"; +import type {Score} from "../../util/answer-types"; import type { - PerseusScore, - Score, PerseusExpressionRubric, PerseusExpressionUserInput, -} from "@khanacademy/perseus-score"; + PerseusScore, +} from "../../validation.types"; +import type {PerseusExpressionAnswerForm} from "@khanacademy/perseus-core"; /* Content creators input a list of answers which are matched from top to * bottom. The intent is that they can include spcific solutions which should @@ -38,7 +39,8 @@ import type { function scoreExpression( userInput: PerseusExpressionUserInput, rubric: PerseusExpressionRubric, - strings: PerseusStrings, + // TODO: remove strings as a param for scorers + strings: any, locale: string, ): PerseusScore { const validationError = validateExpression(userInput); @@ -62,12 +64,10 @@ function scoreExpression( // in the function variables list for the expression. if (!expression.parsed) { /* c8 ignore next */ - Log.error( + throw new PerseusError( "Unable to parse solution answer for expression", Errors.InvalidInput, - {loggedMetadata: {rubric: JSON.stringify(rubric)}}, ); - return null; } return KhanAnswerTypes.expression.createValidatorFunctional( diff --git a/packages/perseus/src/widgets/expression/validate-expression.test.ts b/packages/perseus-score/src/widgets/expression/validate-expression.test.ts similarity index 100% rename from packages/perseus/src/widgets/expression/validate-expression.test.ts rename to packages/perseus-score/src/widgets/expression/validate-expression.test.ts diff --git a/packages/perseus/src/widgets/expression/validate-expression.ts b/packages/perseus-score/src/widgets/expression/validate-expression.ts similarity index 93% rename from packages/perseus/src/widgets/expression/validate-expression.ts rename to packages/perseus-score/src/widgets/expression/validate-expression.ts index 6c7bda3a6c..3941aae140 100644 --- a/packages/perseus/src/widgets/expression/validate-expression.ts +++ b/packages/perseus-score/src/widgets/expression/validate-expression.ts @@ -1,7 +1,7 @@ import type { - ValidationResult, PerseusExpressionUserInput, -} from "@khanacademy/perseus-score"; + ValidationResult, +} from "../../validation.types"; /** * Checks user input from the expression widget to see if it is scorable. diff --git a/packages/perseus/src/widgets/grapher/score-grapher.test.ts b/packages/perseus-score/src/widgets/grapher/score-grapher.test.ts similarity index 98% rename from packages/perseus/src/widgets/grapher/score-grapher.test.ts rename to packages/perseus-score/src/widgets/grapher/score-grapher.test.ts index 518de91441..db06034e43 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.test.ts +++ b/packages/perseus-score/src/widgets/grapher/score-grapher.test.ts @@ -1,10 +1,10 @@ import scoreGrapher from "./score-grapher"; -import type {Coord} from "../../interactive2/types"; import type { - PerseusGrapherRubric, PerseusGrapherUserInput, -} from "@khanacademy/perseus-score"; + PerseusGrapherRubric, +} from "../../validation.types"; +import type {Coord} from "@khanacademy/perseus-core"; describe("scoreGrapher", () => { it("is incorrect when user input type doesn't match rubric type", () => { diff --git a/packages/perseus/src/widgets/grapher/score-grapher.ts b/packages/perseus-score/src/widgets/grapher/score-grapher.ts similarity index 86% rename from packages/perseus/src/widgets/grapher/score-grapher.ts rename to packages/perseus-score/src/widgets/grapher/score-grapher.ts index a64f486ebd..79b93f64e8 100644 --- a/packages/perseus/src/widgets/grapher/score-grapher.ts +++ b/packages/perseus-score/src/widgets/grapher/score-grapher.ts @@ -1,13 +1,11 @@ -import {Errors, PerseusError} from "@khanacademy/perseus-core"; +import {Errors, PerseusError, GrapherUtil} from "@khanacademy/perseus-core"; -import {functionForType} from "./util"; - -import type {GrapherAnswerTypes} from "@khanacademy/perseus-core"; import type { - PerseusScore, - PerseusGrapherRubric, PerseusGrapherUserInput, -} from "@khanacademy/perseus-score"; + PerseusGrapherRubric, + PerseusScore, +} from "../../validation.types"; +import type {GrapherAnswerTypes} from "@khanacademy/perseus-core"; function getCoefficientsByType( data: GrapherAnswerTypes, @@ -16,7 +14,7 @@ function getCoefficientsByType( return undefined; } if (data.type === "exponential" || data.type === "logarithm") { - const grader = functionForType(data.type); + const grader = GrapherUtil.functionForType(data.type); return grader.getCoefficients(data.coords, data.asymptote); } else if ( data.type === "linear" || @@ -25,7 +23,7 @@ function getCoefficientsByType( data.type === "sinusoid" || data.type === "tangent" ) { - const grader = functionForType(data.type); + const grader = GrapherUtil.functionForType(data.type); return grader.getCoefficients(data.coords); } else { throw new PerseusError("Invalid grapher type", Errors.InvalidInput); @@ -54,7 +52,7 @@ function scoreGrapher( } // Get new function handler for grading - const grader = functionForType(userInput.type); + const grader = GrapherUtil.functionForType(userInput.type); const guessCoeffs = getCoefficientsByType(userInput); const correctCoeffs = getCoefficientsByType(rubric.correct); diff --git a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts b/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.test.ts similarity index 97% rename from packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts rename to packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.test.ts index e6e09e8e78..a1c530ff08 100644 --- a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.test.ts +++ b/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.test.ts @@ -1,11 +1,10 @@ import invariant from "tiny-invariant"; - -import {clone} from "../../../../../testing/object-utils"; +import _ from "underscore"; import scoreInteractiveGraph from "./score-interactive-graph"; +import type {PerseusInteractiveGraphRubric} from "../../validation.types"; import type {PerseusGraphType} from "@khanacademy/perseus-core"; -import type {PerseusInteractiveGraphRubric} from "@khanacademy/perseus-score"; describe("InteractiveGraph scoring on a segment question", () => { it("marks the answer invalid if guess.coords is missing", () => { @@ -326,7 +325,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const guessClone = clone(guess); + const guessClone = _.clone(guess); scoreInteractiveGraph(guess, rubric); @@ -352,7 +351,7 @@ describe("InteractiveGraph scoring on a point question", () => { }, }; - const rubricClone = clone(rubric); + const rubricClone = _.clone(rubric); scoreInteractiveGraph(guess, rubric); diff --git a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts b/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.ts similarity index 88% rename from packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts rename to packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.ts index 8e542b0249..d78804c196 100644 --- a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts +++ b/packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.ts @@ -1,18 +1,15 @@ -import {number as knumber} from "@khanacademy/kmath"; -import _ from "underscore"; - -import Util from "../../util"; import { - canonicalSineCoefficients, - collinear, - similar, -} from "../../util/geometry"; + number as knumber, + geometry, + angles, + coefficients, +} from "@khanacademy/kmath"; import { - getQuadraticCoefficients, - getSinusoidCoefficients, -} from "../interactive-graph"; - -import {getClockwiseAngle} from "./math/angles"; + approximateDeepEqual, + approximateEqual, + deepClone, +} from "@khanacademy/perseus-core"; +import _ from "underscore"; import type { PerseusScore, @@ -20,8 +17,9 @@ import type { PerseusInteractiveGraphUserInput, } from "@khanacademy/perseus-score"; -const eq = Util.eq; -const deepEq = Util.deepEq; +const {collinear, canonicalSineCoefficients, similar} = geometry; +const {getClockwiseAngle} = angles; +const {getSinusoidCoefficients, getQuadraticCoefficients} = coefficients; function scoreInteractiveGraph( userInput: PerseusInteractiveGraphUserInput, @@ -104,7 +102,7 @@ function scoreInteractiveGraph( const correctCoeffs = getQuadraticCoefficients( rubric.correct.coords, ); - if (deepEq(guessCoeffs, correctCoeffs)) { + if (approximateDeepEqual(guessCoeffs, correctCoeffs)) { return { type: "points", earned: 1, @@ -126,7 +124,12 @@ function scoreInteractiveGraph( const canonicalCorrectCoeffs = canonicalSineCoefficients(correctCoeffs); // If the canonical coefficients match, it's correct. - if (deepEq(canonicalGuessCoeffs, canonicalCorrectCoeffs)) { + if ( + approximateDeepEqual( + canonicalGuessCoeffs, + canonicalCorrectCoeffs, + ) + ) { return { type: "points", earned: 1, @@ -139,8 +142,8 @@ function scoreInteractiveGraph( rubric.correct.type === "circle" ) { if ( - deepEq(userInput.center, rubric.correct.center) && - eq(userInput.radius, rubric.correct.radius) + approximateDeepEqual(userInput.center, rubric.correct.center) && + approximateEqual(userInput.radius, rubric.correct.radius) ) { return { type: "points", @@ -167,7 +170,7 @@ function scoreInteractiveGraph( guess?.sort(); // @ts-expect-error - TS2339 - Property 'sort' does not exist on type 'readonly Coord[]'. correct.sort(); - if (deepEq(guess, correct)) { + if (approximateDeepEqual(guess, correct)) { return { type: "points", earned: 1, @@ -194,7 +197,7 @@ function scoreInteractiveGraph( /* exact */ guess.sort(); correct.sort(); - match = deepEq(guess, correct); + match = approximateDeepEqual(guess, correct); } if (match) { @@ -210,11 +213,11 @@ function scoreInteractiveGraph( rubric.correct.type === "segment" && userInput.coords != null ) { - let guess = Util.deepClone(userInput.coords); - let correct = Util.deepClone(rubric.correct.coords); + let guess = deepClone(userInput.coords); + let correct = deepClone(rubric.correct.coords); guess = _.invoke(guess, "sort").sort(); correct = _.invoke(correct, "sort").sort(); - if (deepEq(guess, correct)) { + if (approximateDeepEqual(guess, correct)) { return { type: "points", earned: 1, @@ -230,7 +233,7 @@ function scoreInteractiveGraph( const guess = userInput.coords; const correct = rubric.correct.coords; if ( - deepEq(guess[0], correct[0]) && + approximateDeepEqual(guess[0], correct[0]) && collinear(correct[0], correct[1], guess[1]) ) { return { @@ -258,11 +261,11 @@ function scoreInteractiveGraph( return angle; }); // @ts-expect-error - TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter. - match = eq(...angles); + match = approximateEqual(...angles); } else { /* exact */ match = // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. - deepEq(guess[1], correct[1]) && + approximateDeepEqual(guess[1], correct[1]) && // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. collinear(correct[1], correct[0], guess[0]) && // @ts-expect-error - TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. | TS2532 - Object is possibly 'undefined'. diff --git a/packages/perseus/src/widgets/label-image/score-label-image.test.ts b/packages/perseus-score/src/widgets/label-image/score-label-image.test.ts similarity index 94% rename from packages/perseus/src/widgets/label-image/score-label-image.test.ts rename to packages/perseus-score/src/widgets/label-image/score-label-image.test.ts index cc58962b27..4085586f04 100644 --- a/packages/perseus/src/widgets/label-image/score-label-image.test.ts +++ b/packages/perseus-score/src/widgets/label-image/score-label-image.test.ts @@ -1,4 +1,4 @@ -import scoreLabelImage, {scoreMarker} from "./score-label-image"; +import scoreLabelImage, {scoreLabelImageMarker} from "./score-label-image"; const emptyMarker = { label: "", @@ -8,9 +8,9 @@ const emptyMarker = { y: 0, } as const; -describe("scoreMarker", function () { +describe("scoreLabelImageMarker", function () { it("should score correct for empty marker with no user answers", function () { - const score = scoreMarker({ + const score = scoreLabelImageMarker({ ...emptyMarker, }); @@ -21,7 +21,7 @@ describe("scoreMarker", function () { }); it("should score incorrect for empty marker with user answer", function () { - const score = scoreMarker({ + const score = scoreLabelImageMarker({ ...emptyMarker, selected: ["Fiat"], }); @@ -33,7 +33,7 @@ describe("scoreMarker", function () { }); it("should score incorrect for no user answers", function () { - const score = scoreMarker({ + const score = scoreLabelImageMarker({ ...emptyMarker, answers: ["Lamborghini", "Fiat", "Ferrari"], }); @@ -45,7 +45,7 @@ describe("scoreMarker", function () { }); it("should score incorrect for wrong user answers", function () { - const score = scoreMarker({ + const score = scoreLabelImageMarker({ ...emptyMarker, answers: ["Lamborghini", "Fiat", "Ferrari"], selected: ["Fiat", "Ferrari"], @@ -58,7 +58,7 @@ describe("scoreMarker", function () { }); it("should score correct for user answers", function () { - const score = scoreMarker({ + const score = scoreLabelImageMarker({ ...emptyMarker, answers: ["Lamborghini", "Fiat", "Ferrari"], selected: ["Lamborghini", "Fiat", "Ferrari"], diff --git a/packages/perseus/src/widgets/label-image/score-label-image.ts b/packages/perseus-score/src/widgets/label-image/score-label-image.ts similarity index 94% rename from packages/perseus/src/widgets/label-image/score-label-image.ts rename to packages/perseus-score/src/widgets/label-image/score-label-image.ts index 770caa84ea..998262b267 100644 --- a/packages/perseus/src/widgets/label-image/score-label-image.ts +++ b/packages/perseus-score/src/widgets/label-image/score-label-image.ts @@ -1,9 +1,9 @@ -import type {InteractiveMarkerType} from "@khanacademy/perseus-core"; import type { - PerseusScore, - PerseusLabelImageRubric, PerseusLabelImageUserInput, -} from "@khanacademy/perseus-score"; + PerseusLabelImageRubric, + PerseusScore, +} from "../../validation.types"; +import type {InteractiveMarkerType} from "@khanacademy/perseus-core"; // Question state for marker as result of user selected answers. type InteractiveMarkerScore = { @@ -13,7 +13,7 @@ type InteractiveMarkerScore = { isCorrect: boolean; }; -export function scoreMarker( +export function scoreLabelImageMarker( marker: InteractiveMarkerType, ): InteractiveMarkerScore { const score = { @@ -52,7 +52,7 @@ function scoreLabelImage( let numCorrect = 0; for (const marker of userInput.markers) { - const score = scoreMarker(marker); + const score = scoreLabelImageMarker(marker); if (score.hasAnswers) { numAnswered++; diff --git a/packages/perseus/src/widgets/matrix/score-matrix.test.ts b/packages/perseus-score/src/widgets/matrix/score-matrix.test.ts similarity index 82% rename from packages/perseus/src/widgets/matrix/score-matrix.test.ts rename to packages/perseus-score/src/widgets/matrix/score-matrix.test.ts index 97cfd20bbe..115022239d 100644 --- a/packages/perseus/src/widgets/matrix/score-matrix.test.ts +++ b/packages/perseus-score/src/widgets/matrix/score-matrix.test.ts @@ -1,12 +1,10 @@ -import {mockStrings} from "../../strings"; - import scoreMatrix from "./score-matrix"; import * as MatrixValidator from "./validate-matrix"; import type { PerseusMatrixRubric, PerseusMatrixUserInput, -} from "@khanacademy/perseus-score"; +} from "../../validation.types"; describe("scoreMatrix", () => { it("should be correctly answerable if validation passes", function () { @@ -28,14 +26,10 @@ describe("scoreMatrix", () => { }; // Act - const score = scoreMatrix(userInput, rubric, mockStrings); + const score = scoreMatrix(userInput, rubric); // Assert - expect(mockValidator).toHaveBeenCalledWith( - userInput, - rubric, - mockStrings, - ); + expect(mockValidator).toHaveBeenCalledWith(userInput, rubric); expect(score).toHaveBeenAnsweredCorrectly(); }); @@ -58,14 +52,10 @@ describe("scoreMatrix", () => { }; // Act - const score = scoreMatrix(userInput, rubric, mockStrings); + const score = scoreMatrix(userInput, rubric); // Assert - expect(mockValidator).toHaveBeenCalledWith( - userInput, - rubric, - mockStrings, - ); + expect(mockValidator).toHaveBeenCalledWith(userInput, rubric); expect(score).toHaveInvalidInput(); }); @@ -84,7 +74,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric, mockStrings); + const result = scoreMatrix(userInput, rubric); // Assert expect(result).toHaveBeenAnsweredCorrectly(); @@ -109,7 +99,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric, mockStrings); + const result = scoreMatrix(userInput, rubric); // Assert expect(result).toHaveBeenAnsweredIncorrectly(); @@ -137,7 +127,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric, mockStrings); + const result = scoreMatrix(userInput, rubric); // Assert expect(result).toHaveInvalidInput(); @@ -165,7 +155,7 @@ describe("scoreMatrix", () => { }; // Act - const result = scoreMatrix(userInput, rubric, mockStrings); + const result = scoreMatrix(userInput, rubric); // Assert expect(result).toHaveInvalidInput(); @@ -194,16 +184,8 @@ describe("scoreMatrix", () => { }; // Act - const correctResult = scoreMatrix( - correctUserInput, - rubric, - mockStrings, - ); - const incorrectResult = scoreMatrix( - incorrectUserInput, - rubric, - mockStrings, - ); + const correctResult = scoreMatrix(correctUserInput, rubric); + const incorrectResult = scoreMatrix(incorrectUserInput, rubric); // Assert expect(correctResult).toHaveBeenAnsweredCorrectly(); diff --git a/packages/perseus/src/widgets/matrix/score-matrix.ts b/packages/perseus-score/src/widgets/matrix/score-matrix.ts similarity index 86% rename from packages/perseus/src/widgets/matrix/score-matrix.ts rename to packages/perseus-score/src/widgets/matrix/score-matrix.ts index 5e226d5edd..02962ac248 100644 --- a/packages/perseus/src/widgets/matrix/score-matrix.ts +++ b/packages/perseus-score/src/widgets/matrix/score-matrix.ts @@ -1,22 +1,21 @@ -import {KhanAnswerTypes} from "@khanacademy/perseus-score"; +import {getMatrixSize} from "@khanacademy/perseus-core"; import _ from "underscore"; -import {getMatrixSize} from "./matrix"; +import KhanAnswerTypes from "../../util/answer-types"; + import validateMatrix from "./validate-matrix"; -import type {PerseusStrings} from "../../strings"; import type { - PerseusScore, - PerseusMatrixRubric, PerseusMatrixUserInput, -} from "@khanacademy/perseus-score"; + PerseusMatrixRubric, + PerseusScore, +} from "../../validation.types"; function scoreMatrix( userInput: PerseusMatrixUserInput, rubric: PerseusMatrixRubric, - strings: PerseusStrings, ): PerseusScore { - const validationResult = validateMatrix(userInput, rubric, strings); + const validationResult = validateMatrix(userInput, rubric); if (validationResult != null) { return validationResult; } diff --git a/packages/perseus/src/widgets/matrix/validate-matrix.test.ts b/packages/perseus-score/src/widgets/matrix/validate-matrix.test.ts similarity index 75% rename from packages/perseus/src/widgets/matrix/validate-matrix.test.ts rename to packages/perseus-score/src/widgets/matrix/validate-matrix.test.ts index 6ec58eabaf..3100e7fdee 100644 --- a/packages/perseus/src/widgets/matrix/validate-matrix.test.ts +++ b/packages/perseus-score/src/widgets/matrix/validate-matrix.test.ts @@ -1,8 +1,6 @@ -import {mockStrings} from "../../strings"; - import validateMatrix from "./validate-matrix"; -import type {PerseusMatrixUserInput} from "@khanacademy/perseus-score"; +import type {PerseusMatrixUserInput} from "../../validation.types"; describe("matrixValidator", () => { it("should return invalid when answers is completely empty", () => { @@ -12,7 +10,7 @@ describe("matrixValidator", () => { }; // Act - const result = validateMatrix(userInput, {}, mockStrings); + const result = validateMatrix(userInput, {}); // Assert expect(result).toHaveInvalidInput(); @@ -25,7 +23,7 @@ describe("matrixValidator", () => { }; // Act - const result = validateMatrix(userInput, {}, mockStrings); + const result = validateMatrix(userInput, {}); // Assert expect(result).toHaveInvalidInput(); @@ -42,7 +40,7 @@ describe("matrixValidator", () => { }; // Act - const result = validateMatrix(userInput, {}, mockStrings); + const result = validateMatrix(userInput, {}); // Assert expect(result).toBeNull(); diff --git a/packages/perseus/src/widgets/matrix/validate-matrix.ts b/packages/perseus-score/src/widgets/matrix/validate-matrix.ts similarity index 81% rename from packages/perseus/src/widgets/matrix/validate-matrix.ts rename to packages/perseus-score/src/widgets/matrix/validate-matrix.ts index cfefd59811..2697a2333e 100644 --- a/packages/perseus/src/widgets/matrix/validate-matrix.ts +++ b/packages/perseus-score/src/widgets/matrix/validate-matrix.ts @@ -1,13 +1,12 @@ -import _ from "underscore"; +import {getMatrixSize} from "@khanacademy/perseus-core"; -import {getMatrixSize} from "./matrix"; +import ErrorCodes from "../../error-codes"; -import type {PerseusStrings} from "../../strings"; import type { - ValidationResult, PerseusMatrixUserInput, PerseusMatrixValidationData, -} from "@khanacademy/perseus-score"; + ValidationResult, +} from "../../validation.types"; /** * Checks user input from the matrix widget to see if it is scorable. @@ -20,7 +19,6 @@ import type { function validateMatrix( userInput: PerseusMatrixUserInput, validationData: PerseusMatrixValidationData, - strings: PerseusStrings, ): ValidationResult { const supplied = userInput.answers; const suppliedSize = getMatrixSize(supplied); @@ -33,7 +31,7 @@ function validateMatrix( ) { return { type: "invalid", - message: strings.fillAllCells, + message: ErrorCodes.FILL_ALL_CELLS_ERROR, }; } } diff --git a/packages/perseus/src/widgets/orderer/score-orderer.test.ts b/packages/perseus-score/src/widgets/orderer/score-orderer.test.ts similarity index 65% rename from packages/perseus/src/widgets/orderer/score-orderer.test.ts rename to packages/perseus-score/src/widgets/orderer/score-orderer.test.ts index b4fd690dc0..4f7695833f 100644 --- a/packages/perseus/src/widgets/orderer/score-orderer.test.ts +++ b/packages/perseus-score/src/widgets/orderer/score-orderer.test.ts @@ -1,22 +1,36 @@ -import {question1} from "./orderer.testdata"; -import {scoreOrderer} from "./score-orderer"; +import scoreOrderer from "./score-orderer"; import * as OrdererValidator from "./validate-orderer"; import type { PerseusOrdererRubric, PerseusOrdererUserInput, -} from "@khanacademy/perseus-score"; +} from "../../validation.types"; + +function generateOrdererRubric(): PerseusOrdererRubric { + return { + otherOptions: [], + layout: "horizontal", + options: [ + {content: "a", images: {}, widgets: {}}, + {content: "c", images: {}, widgets: {}}, + {content: "b", images: {}, widgets: {}}, + ], + correctOptions: [ + {content: "a", images: {}, widgets: {}}, + {content: "b", images: {}, widgets: {}}, + {content: "c", images: {}, widgets: {}}, + ], + height: "normal", + }; +} describe("scoreOrderer", () => { it("is correct when the userInput is in the same order and is the same length as the rubric's correctOption content items", () => { // Arrange - const rubric: PerseusOrdererRubric = - question1.widgets["orderer 1"].options; + const rubric: PerseusOrdererRubric = generateOrdererRubric(); const userInput: PerseusOrdererUserInput = { - current: question1.widgets["orderer 1"].options.correctOptions.map( - (option) => option.content, - ), + current: rubric.correctOptions.map((e) => e.content), }; // Act @@ -28,11 +42,10 @@ describe("scoreOrderer", () => { it("is incorrect when the userInput is not in the same order as the rubric's correctOption content items", () => { // Arrange - const rubric: PerseusOrdererRubric = - question1.widgets["orderer 1"].options; + const rubric: PerseusOrdererRubric = generateOrdererRubric(); const userInput: PerseusOrdererUserInput = { - current: ["$10.9$", "$11$", "$\\sqrt{120}$"], + current: rubric.options.map((e) => e.content), }; // Act @@ -44,11 +57,10 @@ describe("scoreOrderer", () => { it("is incorrect when the userInput is not the same length as the rubric's correctOption content items", () => { // Arrange - const rubric: PerseusOrdererRubric = - question1.widgets["orderer 1"].options; + const rubric: PerseusOrdererRubric = generateOrdererRubric(); const userInput: PerseusOrdererUserInput = { - current: ["$10.9$", "$11$"], + current: rubric.correctOptions.map((e) => e.content).slice(1), }; // Act @@ -64,13 +76,10 @@ describe("scoreOrderer", () => { .spyOn(OrdererValidator, "default") .mockReturnValue(null); - const rubric: PerseusOrdererRubric = - question1.widgets["orderer 1"].options; + const rubric: PerseusOrdererRubric = generateOrdererRubric(); const userInput: PerseusOrdererUserInput = { - current: question1.widgets["orderer 1"].options.correctOptions.map( - (option) => option.content, - ), + current: rubric.correctOptions.map((e) => e.content), }; // Act const result = scoreOrderer(userInput, rubric); @@ -89,8 +98,7 @@ describe("scoreOrderer", () => { message: null, }); - const rubric: PerseusOrdererRubric = - question1.widgets["orderer 1"].options; + const rubric: PerseusOrdererRubric = generateOrdererRubric(); const userInput: PerseusOrdererUserInput = { current: [], diff --git a/packages/perseus/src/widgets/orderer/score-orderer.ts b/packages/perseus-score/src/widgets/orderer/score-orderer.ts similarity index 87% rename from packages/perseus/src/widgets/orderer/score-orderer.ts rename to packages/perseus-score/src/widgets/orderer/score-orderer.ts index 58c01258c6..5057dd0569 100644 --- a/packages/perseus/src/widgets/orderer/score-orderer.ts +++ b/packages/perseus-score/src/widgets/orderer/score-orderer.ts @@ -3,12 +3,12 @@ import _ from "underscore"; import validateOrderer from "./validate-orderer"; import type { - PerseusScore, PerseusOrdererRubric, PerseusOrdererUserInput, -} from "@khanacademy/perseus-score"; + PerseusScore, +} from "../../validation.types"; -export function scoreOrderer( +function scoreOrderer( userInput: PerseusOrdererUserInput, rubric: PerseusOrdererRubric, ): PerseusScore { @@ -29,3 +29,5 @@ export function scoreOrderer( message: null, }; } + +export default scoreOrderer; diff --git a/packages/perseus/src/widgets/orderer/validate-orderer.test.ts b/packages/perseus-score/src/widgets/orderer/validate-orderer.test.ts similarity index 91% rename from packages/perseus/src/widgets/orderer/validate-orderer.test.ts rename to packages/perseus-score/src/widgets/orderer/validate-orderer.test.ts index 8adb798eeb..7f48781dda 100644 --- a/packages/perseus/src/widgets/orderer/validate-orderer.test.ts +++ b/packages/perseus-score/src/widgets/orderer/validate-orderer.test.ts @@ -1,6 +1,6 @@ import validateOrderer from "./validate-orderer"; -import type {PerseusOrdererUserInput} from "@khanacademy/perseus-score"; +import type {PerseusOrdererUserInput} from "../../validation.types"; describe("validateOrderer", () => { it("is invalid when the user has not started ordering the options and current is empty", () => { diff --git a/packages/perseus/src/widgets/orderer/validate-orderer.ts b/packages/perseus-score/src/widgets/orderer/validate-orderer.ts similarity index 93% rename from packages/perseus/src/widgets/orderer/validate-orderer.ts rename to packages/perseus-score/src/widgets/orderer/validate-orderer.ts index 7b27c05d9d..d88c24ec22 100644 --- a/packages/perseus/src/widgets/orderer/validate-orderer.ts +++ b/packages/perseus-score/src/widgets/orderer/validate-orderer.ts @@ -1,7 +1,7 @@ import type { - ValidationResult, PerseusOrdererUserInput, -} from "@khanacademy/perseus-score"; + ValidationResult, +} from "../../validation.types"; /** * Checks user input from the orderer widget to see if the user has started diff --git a/packages/perseus-score/src/widgets/plotter/score-plotter.ts b/packages/perseus-score/src/widgets/plotter/score-plotter.ts index 102667e988..fb311bf1d3 100644 --- a/packages/perseus-score/src/widgets/plotter/score-plotter.ts +++ b/packages/perseus-score/src/widgets/plotter/score-plotter.ts @@ -1,3 +1,4 @@ +import {approximateDeepEqual} from "@khanacademy/perseus-core"; import _ from "underscore"; import validatePlotter from "./validate-plotter"; @@ -18,7 +19,7 @@ function scorePlotter( } return { type: "points", - earned: _.isEqual(userInput, scoringData.correct) ? 1 : 0, + earned: approximateDeepEqual(userInput, scoringData.correct) ? 1 : 0, total: 1, message: null, }; diff --git a/packages/perseus-score/src/widgets/plotter/validate-plotter.ts b/packages/perseus-score/src/widgets/plotter/validate-plotter.ts index 0f3ac00a22..3c0c89f0c1 100644 --- a/packages/perseus-score/src/widgets/plotter/validate-plotter.ts +++ b/packages/perseus-score/src/widgets/plotter/validate-plotter.ts @@ -1,3 +1,4 @@ +import {approximateDeepEqual} from "@khanacademy/perseus-core"; import _ from "underscore"; import type { @@ -16,7 +17,7 @@ function validatePlotter( userInput: PerseusPlotterUserInput, validationData: PerseusPlotterValidationData, ): ValidationResult { - if (_.isEqual(userInput, validationData.starting)) { + if (approximateDeepEqual(userInput, validationData.starting)) { return { type: "invalid", message: null, diff --git a/packages/perseus-score/src/widgets/sorter/score-sorter.ts b/packages/perseus-score/src/widgets/sorter/score-sorter.ts index f82cbba472..ce7570b8f9 100644 --- a/packages/perseus-score/src/widgets/sorter/score-sorter.ts +++ b/packages/perseus-score/src/widgets/sorter/score-sorter.ts @@ -1,3 +1,4 @@ +import {approximateDeepEqual} from "@khanacademy/perseus-core"; import _ from "underscore"; import validateSorter from "./validate-sorter"; @@ -17,7 +18,7 @@ function scoreSorter( return validationError; } - const correct = _.isEqual(userInput.options, rubric.correct); + const correct = approximateDeepEqual(userInput.options, rubric.correct); return { type: "points", earned: correct ? 1 : 0, diff --git a/packages/perseus/src/__tests__/util.test.ts b/packages/perseus/src/__tests__/util.test.ts index e3d31b49f0..88e83f0255 100644 --- a/packages/perseus/src/__tests__/util.test.ts +++ b/packages/perseus/src/__tests__/util.test.ts @@ -38,27 +38,3 @@ describe("#constrainedTickStepsFromTickSteps", () => { expect(result).toEqual([5, 5]); }); }); - -describe("deepClone", () => { - it("does nothing to a primitive", () => { - expect(Util.deepClone(3)).toBe(3); - }); - - it("copies an array", () => { - const input = [1, 2, 3]; - - const result = Util.deepClone(input); - - expect(result).toEqual(input); - expect(result).not.toBe(input); - }); - - it("recursively clones array elements", () => { - const input = [[1]]; - - const result = Util.deepClone(input); - - expect(result).toEqual(input); - expect(result[0]).not.toBe(input[0]); - }); -}); diff --git a/packages/perseus/src/components/graphie-classes.ts b/packages/perseus/src/components/graphie-classes.ts index 350237f25d..689189679c 100644 --- a/packages/perseus/src/components/graphie-classes.ts +++ b/packages/perseus/src/components/graphie-classes.ts @@ -1,12 +1,15 @@ /* eslint-disable @babel/no-invalid-this */ -import {Errors, PerseusError} from "@khanacademy/perseus-core"; +import { + approximateDeepEqual, + Errors, + PerseusError, +} from "@khanacademy/perseus-core"; import _ from "underscore"; import Util from "../util"; const nestedMap = Util.nestedMap; -const deepEq = Util.deepEq; /** * A base class for all Graphie Movables @@ -116,7 +119,7 @@ const createSimpleClass = function (addFunction: any): any { }, modify: function (graphie) { - if (!deepEq(this.props, this._prevProps)) { + if (!approximateDeepEqual(this.props, this._prevProps)) { this.remove(); this.add(graphie); this._prevProps = this.props; diff --git a/packages/perseus/src/components/graphie.tsx b/packages/perseus/src/components/graphie.tsx index cbc30b4bfc..dc207229d2 100644 --- a/packages/perseus/src/components/graphie.tsx +++ b/packages/perseus/src/components/graphie.tsx @@ -1,4 +1,4 @@ -import {Errors} from "@khanacademy/perseus-core"; +import {approximateDeepEqual, Errors} from "@khanacademy/perseus-core"; import $ from "jquery"; import * as React from "react"; import _ from "underscore"; @@ -18,7 +18,7 @@ import type {Range, Size} from "@khanacademy/perseus-core"; const GraphieMovable = GraphieClasses.GraphieMovable; const createGraphie = GraphUtils.createGraphie; -const {deepEq, nestedMap} = Util; +const {nestedMap} = Util; const {assert} = InteractiveUtil; type Props = { @@ -114,9 +114,9 @@ class Graphie extends React.Component { ); } if ( - !deepEq(this.props.options, prevProps.options) || - !deepEq(this.props.box, prevProps.box) || - !deepEq(this.props.range, prevProps.range) + !approximateDeepEqual(this.props.options, prevProps.options) || + !approximateDeepEqual(this.props.box, prevProps.box) || + !approximateDeepEqual(this.props.range, prevProps.range) ) { this._setupGraphie(); } diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 3e213f24e5..972bc3609f 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -41,7 +41,6 @@ export {default as GrapherWidget} from "./widgets/grapher"; // `perseus`, so only export the stuff that does need to be exposed import {keScoreFromPerseusScore} from "./util/scoring"; import { - allTypes, DEFAULT_GRAPHER_PROPS, chooseType, defaultPlotProps, @@ -50,7 +49,6 @@ import { } from "./widgets/grapher/util"; export const GrapherUtil = { - allTypes, DEFAULT_GRAPHER_PROPS, chooseType, defaultPlotProps, @@ -147,9 +145,6 @@ export { getQuadraticCoords, getAngleCoords, } from "./widgets/interactive-graphs/reducer/initialize-graph-state"; -// This export is to support necessary functionality in the perseus-editor package. -// It should be removed if widgets and editors become colocated. -export {getClockwiseAngle} from "./widgets/interactive-graphs/math"; export {makeSafeUrl} from "./widgets/phet-simulation"; @@ -197,7 +192,6 @@ export type { export type {ParsedValue} from "./util"; export type {Result, Success, Failure} from "./util/parse-perseus-json/result"; export type {Coord} from "./interactive2/types"; -export type {Coords} from "./widgets/grapher/grapher-types"; export type { RendererPromptJSON, WidgetPromptJSON, diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index 0c15a86739..6a840b42b9 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -716,6 +716,7 @@ const errorToString: ErrorStringMap = { INVALID_SELECTION_ERROR: strings.invalidSelection as string, CHOOSE_CORRECT_NUM_ERROR: strings.chooseCorrectNum as string, NOT_NONE_ABOVE_ERROR: strings.notNoneOfTheAbove as string, + FILL_ALL_CELLS_ERROR: strings.fillAllCells as string, }; export function mapErrorToString(err: string | null | undefined) { diff --git a/packages/perseus/src/util.ts b/packages/perseus/src/util.ts index d1f823a169..9fcd365604 100644 --- a/packages/perseus/src/util.ts +++ b/packages/perseus/src/util.ts @@ -408,60 +408,6 @@ function constrainedTickStepsFromTickSteps( ]; } -/** - * Approximate equality on numbers and primitives. - */ -function eq(x: T, y: T): boolean { - if (typeof x === "number" && typeof y === "number") { - return Math.abs(x - y) < 1e-9; - } - return x === y; -} - -/** - * Deep approximate equality on primitives, numbers, arrays, and objects. - * Recursive. - */ -function deepEq(x: T, y: T): boolean { - if (Array.isArray(x) && Array.isArray(y)) { - if (x.length !== y.length) { - return false; - } - for (let i = 0; i < x.length; i++) { - if (!deepEq(x[i], y[i])) { - return false; - } - } - return true; - } - if (Array.isArray(x) || Array.isArray(y)) { - return false; - } - if (typeof x === "function" && typeof y === "function") { - return eq(x, y); - } - if (typeof x === "function" || typeof y === "function") { - return false; - } - if (typeof x === "object" && typeof y === "object" && !!x && !!y) { - return ( - x === y || - (_.all(x, function (v, k) { - // @ts-expect-error - TS2536 - Type 'CollectionKey' cannot be used to index type 'T'. - return deepEq(y[k], v); - }) && - _.all(y, function (v, k) { - // @ts-expect-error - TS2536 - Type 'CollectionKey' cannot be used to index type 'T'. - return deepEq(x[k], v); - })) - ); - } - if ((typeof x === "object" && !!x) || (typeof y === "object" && !!y)) { - return false; - } - return eq(x, y); -} - /** * Query String Parser * @@ -704,23 +650,6 @@ const unescapeMathMode: (label: string) => string = (label) => const random: RNG = seededRNG(new Date().getTime() & 0xffffffff); -// TODO(benchristel): in the future, we may want to make deepClone work for -// Record as well. Currently, it only does arrays. -type Cloneable = - | null - | undefined - | boolean - | string - | number - | Cloneable[] - | readonly Cloneable[]; -function deepClone(obj: T): T { - if (Array.isArray(obj)) { - return obj.map(deepClone) as T; - } - return obj; -} - const Util = { inputPathsEqual, nestedMap, @@ -741,8 +670,6 @@ const Util = { gridStepFromTickStep, tickStepFromNumTicks, constrainedTickStepsFromTickSteps, - eq, - deepEq, parseQueryString, updateQueryString, strongEncodeURIComponent, @@ -761,7 +688,6 @@ const Util = { textarea, unescapeMathMode, random, - deepClone, } as const; export default Util; diff --git a/packages/perseus/src/util/interactive.ts b/packages/perseus/src/util/interactive.ts index 9c35fbf3fc..7724c82720 100644 --- a/packages/perseus/src/util/interactive.ts +++ b/packages/perseus/src/util/interactive.ts @@ -13,6 +13,7 @@ import { point as kpoint, line as kline, KhanMath, + geometry, } from "@khanacademy/kmath"; import {Errors, PerseusError} from "@khanacademy/perseus-core"; import $ from "jquery"; @@ -29,13 +30,14 @@ import WrappedEllipse from "../interactive2/wrapped-ellipse"; import WrappedLine from "../interactive2/wrapped-line"; import KhanColors from "./colors"; -import {clockwise, reverseVector} from "./geometry"; import GraphUtils, {polar} from "./graphie"; import type {Coord} from "../interactive2/types"; export type MouseHandler = (position: Coord) => void; +const {clockwise, reverseVector} = geometry; + function scaledDistanceFromAngle(angle: number) { const a = 3.51470560176242 * 20; const b = 0.5687298702748785 * 20; diff --git a/packages/perseus/src/util/is-real-json-parse.ts b/packages/perseus/src/util/is-real-json-parse.ts index 548f17518a..073a425d66 100644 --- a/packages/perseus/src/util/is-real-json-parse.ts +++ b/packages/perseus/src/util/is-real-json-parse.ts @@ -1,6 +1,4 @@ -import Util from "../util"; - -const deepEq = Util.deepEq; +import {approximateDeepEqual} from "@khanacademy/perseus-core"; export function isRealJSONParse(jsonParse: typeof JSON.parse): boolean { const randomPhrase = buildRandomPhrase(); @@ -55,7 +53,7 @@ export function isRealJSONParse(jsonParse: typeof JSON.parse): boolean { const parsedTestJSON = jsonParse(testJSON); const parsedTestItemData: string = parsedTestJSON.data.assessmentItem.item.itemData; - return deepEq(parsedTestItemData, testingObject); + return approximateDeepEqual(parsedTestItemData, testingObject); } function buildRandomString(capitalize: boolean = false) { diff --git a/packages/perseus/src/widgets/expression/expression.tsx b/packages/perseus/src/widgets/expression/expression.tsx index ccdeb8ba26..483148acd7 100644 --- a/packages/perseus/src/widgets/expression/expression.tsx +++ b/packages/perseus/src/widgets/expression/expression.tsx @@ -1,6 +1,15 @@ import * as KAS from "@khanacademy/kas"; import {KeyArray, KeypadInput, KeypadType} from "@khanacademy/math-input"; +import { + getDecimalSeparator, + type PerseusExpressionWidgetOptions, +} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; +import { + scoreExpression, + type PerseusExpressionRubric, + type PerseusExpressionUserInput, +} from "@khanacademy/perseus-score"; import {View} from "@khanacademy/wonder-blocks-core"; import Tooltip from "@khanacademy/wonder-blocks-tooltip"; import {LabelSmall} from "@khanacademy/wonder-blocks-typography"; @@ -18,18 +27,10 @@ import {ApiOptions, ClassNames as ApiClassNames} from "../../perseus-api"; import a11y from "../../util/a11y"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/expression/expression-ai-utils"; -import getDecimalSeparator from "./get-decimal-separator"; -import scoreExpression from "./score-expression"; - import type {DependenciesContext} from "../../dependencies"; import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types"; import type {ExpressionPromptJSON} from "../../widget-ai-utils/expression/expression-ai-utils"; import type {Keys as Key, KeypadConfiguration} from "@khanacademy/math-input"; -import type {PerseusExpressionWidgetOptions} from "@khanacademy/perseus-core"; -import type { - PerseusExpressionRubric, - PerseusExpressionUserInput, -} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; type InputPath = ReadonlyArray; diff --git a/packages/perseus/src/widgets/grapher/grapher.tsx b/packages/perseus/src/widgets/grapher/grapher.tsx index d78082bdda..f7e7f3e0f8 100644 --- a/packages/perseus/src/widgets/grapher/grapher.tsx +++ b/packages/perseus/src/widgets/grapher/grapher.tsx @@ -3,6 +3,12 @@ import { vector as kvector, point as kpoint, } from "@khanacademy/kmath"; +import {GrapherUtil} from "@khanacademy/perseus-core"; +import { + scoreGrapher, + type PerseusGrapherRubric, + type PerseusGrapherUserInput, +} from "@khanacademy/perseus-score"; import * as React from "react"; import _ from "underscore"; @@ -20,14 +26,13 @@ import {getInteractiveBoxFromSizeClass} from "../../util/sizing-utils"; /* Mixins. */ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/grapher/grapher-ai-utils"; -import scoreGrapher from "./score-grapher"; import { DEFAULT_GRAPHER_PROPS, chooseType, defaultPlotProps, - functionForType, getGridAndSnapSteps, maybePointsFromNormalized, + movableTypeToComponent, typeToButton, } from "./util"; @@ -40,10 +45,6 @@ import type { MarkingsType, PerseusGrapherWidgetOptions, } from "@khanacademy/perseus-core"; -import type { - PerseusGrapherRubric, - PerseusGrapherUserInput, -} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; // @ts-expect-error - TS2339 - Property 'MovablePoint' does not exist on type 'typeof Graphie'. @@ -137,8 +138,9 @@ class FunctionGrapher extends React.Component { } const functionProps = model.getPropsForCoeffs(coeffs, xRange); + const Movable = movableTypeToComponent[model.movable]; return ( - implements Widget { setup: this._setupGraphie, }, onChange: this.handlePlotChanges, - model: type && functionForType(type), + model: type && GrapherUtil.functionForType(type), coords: coords, asymptote: asymptote, static: this.props.static, diff --git a/packages/perseus/src/widgets/grapher/util.tsx b/packages/perseus/src/widgets/grapher/util.tsx index ed7c6af887..5d59c24a80 100644 --- a/packages/perseus/src/widgets/grapher/util.tsx +++ b/packages/perseus/src/widgets/grapher/util.tsx @@ -1,4 +1,5 @@ import {point as kpoint} from "@khanacademy/kmath"; +import {GrapherUtil} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; @@ -6,656 +7,29 @@ import Graphie from "../../components/graphie"; import {getDependencies} from "../../dependencies"; import Util from "../../util"; -import type { - Coords, - LinearType, - QuadraticType, - SinusoidType, - TangentType, - ExponentialType, - LogarithmType, - AbsoluteValueType, -} from "./grapher-types"; import type {Coord} from "../../interactive2/types"; +import type {FunctionTypeMappingKeys} from "@khanacademy/perseus-core"; -// @ts-expect-error - TS2339 - Property 'Plot' does not exist on type 'typeof Graphie'. -const Plot = Graphie.Plot; - -const DEFAULT_BACKGROUND_IMAGE = { - url: null, -} as const; - -// TODO(charlie): These really need to go into a utility file as they're being -// used by both interactive-graph and now grapher. -function canonicalSineCoefficients(coeffs: any) { - // For a curve of the form f(x) = a * Sin(b * x - c) + d, - // this function ensures that a, b > 0, and c is its - // smallest possible positive value. - let amplitude = coeffs[0]; - let angularFrequency = coeffs[1]; - let phase = coeffs[2]; - const verticalOffset = coeffs[3]; - - // Guarantee a > 0 - if (amplitude < 0) { - amplitude *= -1; - angularFrequency *= -1; - phase *= -1; - } - - const period = 2 * Math.PI; - // Guarantee b > 0 - if (angularFrequency < 0) { - angularFrequency *= -1; - phase *= -1; - phase += period / 2; - } - - // Guarantee c is smallest possible positive value - while (phase > 0) { - phase -= period; - } - while (phase < 0) { - phase += period; - } - - return [amplitude, angularFrequency, phase, verticalOffset]; -} - -function canonicalTangentCoefficients(coeffs: any) { - // For a curve of the form f(x) = a * Tan(b * x - c) + d, - // this function ensures that a, b > 0, and c is its - // smallest possible positive value. - let amplitude = coeffs[0]; - let angularFrequency = coeffs[1]; - let phase = coeffs[2]; - const verticalOffset = coeffs[3]; - - // Guarantee a > 0 - if (amplitude < 0) { - amplitude *= -1; - angularFrequency *= -1; - phase *= -1; - } - - const period = Math.PI; - // Guarantee b > 0 - if (angularFrequency < 0) { - angularFrequency *= -1; - phase *= -1; - phase += period / 2; - } - - // Guarantee c is smallest possible positive value - while (phase > 0) { - phase -= period; - } - while (phase < 0) { - phase += period; - } - - return [amplitude, angularFrequency, phase, verticalOffset]; -} - -const PlotDefaults = { - areEqual: function ( - coeffs1: ReadonlyArray, - coeffs2: ReadonlyArray, - ): boolean { - return Util.deepEq(coeffs1, coeffs2); - }, - Movable: Plot, - getPropsForCoeffs: function (coeffs: ReadonlyArray): {fn: any} { - return { - // @ts-expect-error - TS2339 - Property 'getFunctionForCoeffs' does not exist on type '{ readonly areEqual: (coeffs1: any, coeffs2: any) => boolean; readonly Movable: any; readonly getPropsForCoeffs: (coeffs: any) => any; }'. - fn: _.partial(this.getFunctionForCoeffs, coeffs), - }; - }, -} as const; - -const Linear: LinearType = _.extend({}, PlotDefaults, { - url: "https://ka-perseus-graphie.s3.amazonaws.com/67aaf581e6d9ef9038c10558a1f70ac21c11c9f8.png", - - defaultCoords: [ - [0.25, 0.75], - [0.75, 0.75], - ], - - getCoefficients: function ( - coords: Coords, - ): ReadonlyArray | undefined { - const p1 = coords[0]; - const p2 = coords[1]; - - const denom = p2[0] - p1[0]; - const num = p2[1] - p1[1]; - - if (denom === 0) { - return; - } - - const m = num / denom; - const b = p2[1] - m * p2[0]; - return [m, b]; - }, - - getFunctionForCoeffs: function (coeffs: ReadonlyArray, x: number) { - const m = coeffs[0]; - const b = coeffs[1]; - return m * x + b; - }, - - getEquationString: function (coords: Coords) { - const coeffs: ReadonlyArray = this.getCoefficients(coords); - const m: number = coeffs[0]; - const b: number = coeffs[1]; - return "y = " + m.toFixed(3) + "x + " + b.toFixed(3); - }, -}); - -const Quadratic: QuadraticType = _.extend({}, PlotDefaults, { - url: "https://ka-perseus-graphie.s3.amazonaws.com/e23d36e6fc29ee37174e92c9daba2a66677128ab.png", - - defaultCoords: [ - [0.5, 0.5], - [0.75, 0.75], - ], +type MovableMap = { + [K in keyof typeof GrapherUtil.MOVABLES]: any; +}; +export const movableTypeToComponent: MovableMap = { + // @ts-expect-error - TS2339 - Property 'Plot' does not exist on type 'typeof Graphie'. + PLOT: Graphie.Plot, // @ts-expect-error - TS2339 - Property 'Parabola' does not exist on type 'typeof Graphie'. - Movable: Graphie.Parabola, - - getCoefficients: function (coords: Coords): ReadonlyArray { - const p1 = coords[0]; - const p2 = coords[1]; - - // Parabola with vertex (h, k) has form: y = a * (h - k)^2 + k - const h = p1[0]; - const k = p1[1]; - - // Use these to calculate familiar a, b, c - const a = (p2[1] - k) / ((p2[0] - h) * (p2[0] - h)); - const b = -2 * h * a; - const c = a * h * h + k; - - return [a, b, c]; - }, - - getFunctionForCoeffs: function ( - coeffs: ReadonlyArray, - x: number, - ): number { - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - return (a * x + b) * x + c; - }, - - getPropsForCoeffs: function (coeffs: ReadonlyArray): { - a: number; - b: number; - c: number; - } { - return { - a: coeffs[0], - b: coeffs[1], - c: coeffs[2], - }; - }, - - getEquationString: function (coords: Coords) { - const coeffs = this.getCoefficients(coords); - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - return ( - "y = " + - a.toFixed(3) + - "x^2 + " + - b.toFixed(3) + - "x + " + - c.toFixed(3) - ); - }, -}); - -const Sinusoid: SinusoidType = _.extend({}, PlotDefaults, { - url: "https://ka-perseus-graphie.s3.amazonaws.com/3d68e7718498475f53b206c2ab285626baf8857e.png", - - defaultCoords: [ - [0.5, 0.5], - [0.6, 0.6], - ], + PARABOLA: Graphie.Parabola, // @ts-expect-error - TS2339 - Property 'Sinusoid' does not exist on type 'typeof Graphie'. - Movable: Graphie.Sinusoid, - - getCoefficients: function (coords: Coords) { - const p1 = coords[0]; - const p2 = coords[1]; - - const a = p2[1] - p1[1]; - const b = Math.PI / (2 * (p2[0] - p1[0])); - const c = p1[0] * b; - const d = p1[1]; - - return [a, b, c, d]; - }, - - getFunctionForCoeffs: function (coeffs: ReadonlyArray, x: number) { - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - const d = coeffs[3]; - return a * Math.sin(b * x - c) + d; - }, - - getPropsForCoeffs: function (coeffs: ReadonlyArray) { - return { - a: coeffs[0], - b: coeffs[1], - c: coeffs[2], - d: coeffs[3], - }; - }, - - getEquationString: function (coords: Coords) { - const coeffs = this.getCoefficients(coords); - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - const d = coeffs[3]; - return ( - "y = " + - a.toFixed(3) + - " sin(" + - b.toFixed(3) + - "x - " + - c.toFixed(3) + - ") + " + - d.toFixed(3) - ); - }, - - areEqual: function ( - coeffs1: ReadonlyArray, - coeffs2: ReadonlyArray, - ) { - return Util.deepEq( - canonicalSineCoefficients(coeffs1), - canonicalSineCoefficients(coeffs2), - ); - }, -}); - -const Tangent: TangentType = _.extend({}, PlotDefaults, { - url: "https://ka-perseus-graphie.s3.amazonaws.com/7db80d23c35214f98659fe1cf0765811c1bbfbba.png", - - defaultCoords: [ - [0.5, 0.5], - [0.75, 0.75], - ], - - getCoefficients: function (coords: Coords) { - const p1 = coords[0]; - const p2 = coords[1]; - - const a = p2[1] - p1[1]; - const b = Math.PI / (4 * (p2[0] - p1[0])); - const c = p1[0] * b; - const d = p1[1]; - - return [a, b, c, d]; - }, - - getFunctionForCoeffs: function (coeffs: ReadonlyArray, x: number) { - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - const d = coeffs[3]; - return a * Math.tan(b * x - c) + d; - }, - - getEquationString: function (coords: Coords) { - const coeffs = this.getCoefficients(coords); - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - const d = coeffs[3]; - return ( - "y = " + - a.toFixed(3) + - " sin(" + - b.toFixed(3) + - "x - " + - c.toFixed(3) + - ") + " + - d.toFixed(3) - ); - }, - - areEqual: function ( - coeffs1: ReadonlyArray, - coeffs2: ReadonlyArray, - ) { - return Util.deepEq( - canonicalTangentCoefficients(coeffs1), - canonicalTangentCoefficients(coeffs2), - ); - }, -}); - -const Exponential: ExponentialType = _.extend({}, PlotDefaults, { - url: "https://ka-perseus-graphie.s3.amazonaws.com/9cbfad55525e3ce755a31a631b074670a5dad611.png", - - defaultCoords: [ - [0.5, 0.55], - [0.75, 0.75], - ], - - defaultAsymptote: [ - [0, 0.5], - [1.0, 0.5], - ], - - /** - * Add extra constraints for movement of the points or asymptote (below): - * newCoord: [x, y] - * The end position of the point or asymptote endpoint - * oldCoord: [x, y] - * The old position of the point or asymptote endpoint - * coords: - * An array of coordinates representing the proposed end configuration - * of the plot coordinates. - * asymptote: - * An array of coordinates representing the proposed end configuration - * of the asymptote. - * - * Return: either a coordinate (to be used as the resulting coordinate of - * the move) or a boolean, where `true` uses newCoord as the resulting - * coordinate, and `false` uses oldCoord as the resulting coordinate. - */ - extraCoordConstraint: function ( - newCoord: Coord, - oldCoord: Coord, - coords: Coords, - asymptote: Coords, - graph, - ) { - const y: number = asymptote[0][1]; - return _.all(coords, (coord) => coord[1] !== y); - }, - - extraAsymptoteConstraint: function ( - newCoord: Coord, - oldCoord: Coord, - coords: Coords, - asymptote: Coords, - graph, - ): Coord { - const y = newCoord[1]; - const isValid = - _.all(coords, (coord) => coord[1] > y) || - _.all(coords, (coord) => coord[1] < y); - - if (isValid) { - return [oldCoord[0], y]; - } - // Snap the asymptote as close as possible, i.e., if the user moves - // the mouse really quickly into an invalid region - const oldY = oldCoord[1]; - const wasBelow = _.all(coords, (coord) => coord[1] > oldY); - if (wasBelow) { - const bottomMost = _.min(_.map(coords, (coord) => coord[1])); - return [oldCoord[0], bottomMost - graph.snapStep[1]]; - } - const topMost = _.max(_.map(coords, (coord) => coord[1])); - return [oldCoord[0], topMost + graph.snapStep[1]]; - }, - - allowReflectOverAsymptote: true, - - getCoefficients: function ( - coords: Coords, - asymptote: Coords, - ): ReadonlyArray { - const p1 = coords[0]; - const p2 = coords[1]; - - const c = asymptote[0][1]; - const b = Math.log((p1[1] - c) / (p2[1] - c)) / (p1[0] - p2[0]); - const a = (p1[1] - c) / Math.exp(b * p1[0]); - return [a, b, c]; - }, - - getFunctionForCoeffs: function ( - coeffs: ReadonlyArray, - x: number, - ): number { - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - return a * Math.exp(b * x) + c; - }, - - getEquationString: function (coords: Coords, asymptote: Coords) { - if (!asymptote) { - return null; - } - const coeffs = this.getCoefficients(coords, asymptote); - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - return ( - "y = " + - a.toFixed(3) + - "e^(" + - b.toFixed(3) + - "x) + " + - c.toFixed(3) - ); - }, -}); - -const Logarithm: LogarithmType = _.extend({}, PlotDefaults, { - url: "https://ka-perseus-graphie.s3.amazonaws.com/f6491e99d34af34d924bfe0231728ad912068dc3.png", - - defaultCoords: [ - [0.55, 0.5], - [0.75, 0.75], - ], - - defaultAsymptote: [ - [0.5, 0], - [0.5, 1.0], - ], - - extraCoordConstraint: function ( - newCoord: Coord, - oldCoord: Coord, - coords: Coord, - asymptote: Coords, - graph, - ) { - const x = asymptote[0][0]; - return ( - _.all(coords, (coord) => coord[0] !== x) && - coords[0][1] !== coords[1][1] - ); - }, - - extraAsymptoteConstraint: function ( - newCoord: Coord, - oldCoord: Coord, - coords: Coords, - asymptote: Coords, - graph, - ): ReadonlyArray { - const x = newCoord[0]; - const isValid = - _.all(coords, (coord) => coord[0] > x) || - _.all(coords, (coord) => coord[0] < x); - - if (isValid) { - return [x, oldCoord[1]]; - } - // Snap the asymptote as close as possible, i.e., if the user moves - // the mouse really quickly into an invalid region - const oldX = oldCoord[0]; - const wasLeft = _.all(coords, (coord) => coord[0] > oldX); - if (wasLeft) { - const leftMost = _.min(_.map(coords, (coord) => coord[0])); - return [leftMost - graph.snapStep[0], oldCoord[1]]; - } - const rightMost = _.max(_.map(coords, (coord) => coord[0])); - return [rightMost + graph.snapStep[0], oldCoord[1]]; - }, - - allowReflectOverAsymptote: true, - - getCoefficients: function ( - coords: Coords, - asymptote: Coords, - ): ReadonlyArray | undefined { - // It's easiest to calculate the logarithm's coefficients by thinking - // about it as the inverse of the exponential, so we flip x and y and - // perform some algebra on the coefficients. This also unifies the - // logic between the two 'models'. - const flip = (coord: Coord): Coord => [coord[1], coord[0]]; - const inverseCoeffs = Exponential.getCoefficients( - _.map(coords, flip) as Coords, - _.map(asymptote, flip) as Coords, - ); - if (inverseCoeffs) { - const c = -inverseCoeffs[2] / inverseCoeffs[0]; - const b = 1 / inverseCoeffs[0]; - const a = 1 / inverseCoeffs[1]; - return [a, b, c]; - } - }, - - getFunctionForCoeffs: function ( - coeffs: ReadonlyArray, - x: number, - asymptote: Coords, - ) { - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - return a * Math.log(b * x + c); - }, - - getEquationString: function (coords: Coords, asymptote: Coords) { - if (!asymptote) { - return null; - } - const coeffs: ReadonlyArray = this.getCoefficients( - coords, - asymptote, - ); - const a = coeffs[0]; - const b = coeffs[1]; - const c = coeffs[2]; - return ( - "y = ln(" + - a.toFixed(3) + - "x + " + - b.toFixed(3) + - ") + " + - c.toFixed(3) - ); - }, -}); - -const AbsoluteValue: AbsoluteValueType = _.extend({}, PlotDefaults, { - url: "https://ka-perseus-graphie.s3.amazonaws.com/8256a630175a0cb1d11de223d6de0266daf98721.png", - - defaultCoords: [ - [0.5, 0.5], - [0.75, 0.75], - ], - - getCoefficients: function ( - coords: Coords, - ): ReadonlyArray | undefined { - const p1 = coords[0]; - const p2 = coords[1]; - - const denom = p2[0] - p1[0]; - const num = p2[1] - p1[1]; - - if (denom === 0) { - return; - } - - let m = Math.abs(num / denom); - if (p2[1] < p1[1]) { - m *= -1; - } - const horizontalOffset = p1[0]; - const verticalOffset = p1[1]; - - return [m, horizontalOffset, verticalOffset]; - }, - - getFunctionForCoeffs: function (coeffs: ReadonlyArray, x: number) { - const m = coeffs[0]; - const horizontalOffset = coeffs[1]; - const verticalOffset = coeffs[2]; - return m * Math.abs(x - horizontalOffset) + verticalOffset; - }, - - getEquationString: function (coords: Coords) { - const coeffs: ReadonlyArray = this.getCoefficients(coords); - const m = coeffs[0]; - const horizontalOffset = coeffs[1]; - const verticalOffset = coeffs[2]; - return ( - "y = " + - m.toFixed(3) + - "| x - " + - horizontalOffset.toFixed(3) + - "| + " + - verticalOffset.toFixed(3) - ); - }, -}); + SINUSOID: Graphie.Sinusoid, +}; -/* Utility functions for dealing with graphing interfaces. */ -const functionTypeMapping = { - linear: Linear, - quadratic: Quadratic, - sinusoid: Sinusoid, - tangent: Tangent, - exponential: Exponential, - logarithm: Logarithm, - absolute_value: AbsoluteValue, +const DEFAULT_BACKGROUND_IMAGE = { + url: null, } as const; -export const allTypes: any = _.keys(functionTypeMapping); - -type FunctionTypeMappingKeys = keyof typeof functionTypeMapping; - -type ConditionalGraderType = - // prettier-ignore - T extends "linear" ? LinearType - : T extends "quadratic" ? QuadraticType - : T extends "sinusoid" ? SinusoidType - : T extends "tangent" ? TangentType - : T extends "exponential" ? ExponentialType - : T extends "logarithm" ? LogarithmType - : T extends "absolute_value" ? AbsoluteValueType - : never; - -export function functionForType( - type: T, -): ConditionalGraderType { - // @ts-expect-error: TypeScript doesn't know how to use deal with generics - // and conditional types in this way. - return functionTypeMapping[type]; -} - export const getEquationString = (props: any): string => { const plot = props.plot; if (plot.type && plot.coords) { - const handler = functionForType(plot.type); + const handler = GrapherUtil.functionForType(plot.type); const result = handler.getEquationString(plot.coords, plot.asymptote); return result || ""; } @@ -728,7 +102,7 @@ export const defaultPlotProps = ( // extra information. It would be valid to always submit the default // widget before even reading the question; you can't lose, but you // might get a free win. - const model = functionForType(type); + const model = GrapherUtil.functionForType(type); const defaultAsymptote = "defaultAsymptote" in model ? model.defaultAsymptote : null; const gridStep = [1, 1]; @@ -806,7 +180,10 @@ export const typeToButton = (type: FunctionTypeMappingKeys): any => { value: type, title: capitalized, content: ( - {capitalized} + {capitalized} ), }; }; diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index 21d4240078..c3b3433ff1 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -1,6 +1,20 @@ /* eslint-disable @babel/no-invalid-this, react/no-unsafe, react/sort-comp */ -import {number as knumber, point as kpoint} from "@khanacademy/kmath"; -import {Errors, PerseusError} from "@khanacademy/perseus-core"; +import { + angles, + geometry, + number as knumber, + point as kpoint, +} from "@khanacademy/kmath"; +import { + approximateEqual, + Errors, + PerseusError, +} from "@khanacademy/perseus-core"; +import { + scoreInteractiveGraph, + type PerseusInteractiveGraphRubric, + type PerseusInteractiveGraphUserInput, +} from "@khanacademy/perseus-score"; import $ from "jquery"; import debounce from "lodash.debounce"; import * as React from "react"; @@ -12,39 +26,24 @@ import Interactive2 from "../interactive2"; import WrappedLine from "../interactive2/wrapped-line"; import Util from "../util"; import KhanColors from "../util/colors"; -import { - angleMeasures, - ccw, - collinear, - getLineEquation, - getLineIntersection, - intersects, - lawOfCosines, - magnitude, - rotate, - sign, - vector, -} from "../util/geometry"; import GraphUtils from "../util/graph-utils"; import {polar} from "../util/graphie"; import {getInteractiveBoxFromSizeClass} from "../util/sizing-utils"; import {getPromptJSON} from "../widget-ai-utils/interactive-graph/interactive-graph-ai-utils"; import {StatefulMafsGraph} from "./interactive-graphs"; -import {getClockwiseAngle} from "./interactive-graphs/math"; -import scoreInteractiveGraph from "./interactive-graphs/score-interactive-graph"; import type {StatefulMafsGraphType} from "./interactive-graphs/stateful-mafs-graph"; import type {QuadraticGraphState} from "./interactive-graphs/types"; import type {Coord} from "../interactive2/types"; import type {ChangeHandler, WidgetExports, WidgetProps} from "../types"; +import type {InteractiveGraphPromptJSON} from "../widget-ai-utils/interactive-graph/interactive-graph-ai-utils"; +import type {UnsupportedWidgetPromptJSON} from "../widget-ai-utils/unsupported-widget"; import type { QuadraticCoefficient, - Range, SineCoefficient, -} from "../util/geometry"; -import type {InteractiveGraphPromptJSON} from "../widget-ai-utils/interactive-graph/interactive-graph-ai-utils"; -import type {UnsupportedWidgetPromptJSON} from "../widget-ai-utils/unsupported-widget"; + Range, +} from "@khanacademy/kmath"; import type { PerseusGraphType, PerseusGraphTypeAngle, @@ -57,12 +56,24 @@ import type { PerseusImageBackground, MarkingsType, } from "@khanacademy/perseus-core"; -import type { - PerseusInteractiveGraphRubric, - PerseusInteractiveGraphUserInput, -} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; +const {getClockwiseAngle} = angles; + +const { + angleMeasures, + ccw, + collinear, + getLineEquation, + getLineIntersection, + intersects, + lawOfCosines, + magnitude, + rotate, + sign, + vector, +} = geometry; + const TRASH_ICON_URI = "https://ka-perseus-graphie.s3.amazonaws.com/b1452c0d79fd0f7ff4c3af9488474a0a0decb361.png"; @@ -70,8 +81,6 @@ const defaultBackgroundImage = { url: null, }; -const eq = Util.eq; - const UNLIMITED = "unlimited" as const; // Sample background image: @@ -84,7 +93,7 @@ function defaultVal(actual: T | null | undefined, defaultValue: T): T { // Less than or approximately equal function leq(a: any, b) { - return a < b || eq(a, b); + return a < b || approximateEqual(a, b); } function capitalize(str) { @@ -2291,12 +2300,12 @@ class InteractiveGraph extends React.Component { static getLinearEquationString(props: Props): string { const coords = InteractiveGraph.getLineCoords(props.graph, props); - if (eq(coords[0][0], coords[1][0])) { + if (approximateEqual(coords[0][0], coords[1][0])) { return "x = " + coords[0][0].toFixed(3); } const m = (coords[1][1] - coords[0][1]) / (coords[1][0] - coords[0][0]); const b = coords[0][1] - m * coords[0][0]; - if (eq(m, 0)) { + if (approximateEqual(m, 0)) { return "y = " + b.toFixed(3); } return "y = " + m.toFixed(3) + "x + " + b.toFixed(3); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx index 9960185e23..48d01c8026 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx @@ -1,8 +1,9 @@ +import {angles} from "@khanacademy/kmath"; import {vec} from "mafs"; import * as React from "react"; import {usePerseusI18n} from "../../../components/i18n-context"; -import {X, Y, calculateAngleInDegrees, getClockwiseAngle, polar} from "../math"; +import {X, Y} from "../math"; import {findIntersectionOfRays} from "../math/geometry"; import {actions} from "../reducer/interactive-graph-action"; import useGraphConfig from "../reducer/use-graph-config"; @@ -25,6 +26,8 @@ import type { } from "../types"; import type {CollinearTuple} from "@khanacademy/perseus-core"; +const {calculateAngleInDegrees, getClockwiseAngle, polar} = angles; + type AngleGraphProps = MafsGraphProps; export function renderAngleGraph( diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.test.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.test.ts index e64854cf5e..dc730e1444 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.test.ts @@ -1,4 +1,4 @@ -import {getClockwiseAngle} from "../../math"; +import {angles} from "@khanacademy/kmath"; import {shouldDrawArcOutside} from "./angle-indicators"; @@ -6,6 +6,8 @@ import type {Coord} from "@khanacademy/perseus"; import type {CollinearTuple} from "@khanacademy/perseus-core"; import type {vec, Interval} from "mafs"; +const {getClockwiseAngle} = angles; + describe("shouldDrawArcOutside", () => { const range = [ [-1, 6], diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx index 1d958c4917..6df6d4f865 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx @@ -1,9 +1,9 @@ +import {angles, geometry} from "@khanacademy/kmath"; import {color} from "@khanacademy/wonder-blocks-tokens"; import {vec} from "mafs"; import * as React from "react"; -import {clockwise} from "../../../../util/geometry"; -import {getAngleFromVertex, segmentsIntersect} from "../../math"; +import {segmentsIntersect} from "../../math"; import {getIntersectionOfRayWithBox as getRangeIntersectionVertex} from "../utils"; import {MafsCssTransformWrapper} from "./css-transform-wrapper"; @@ -12,6 +12,9 @@ import {TextLabel} from "./text-label"; import type {CollinearTuple} from "@khanacademy/perseus-core"; import type {Interval} from "mafs"; +const {clockwise} = geometry; +const {getAngleFromVertex} = angles; + interface PolygonAngleProps { centerPoint: vec.Vector2; endPoints: [vec.Vector2, vec.Vector2]; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/vector.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/vector.tsx index 637b5651fc..7c223434fe 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/vector.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/vector.tsx @@ -1,12 +1,14 @@ +import {angles} from "@khanacademy/kmath"; import {vec} from "mafs"; import * as React from "react"; -import {calculateAngleInDegrees} from "../../math"; import {useTransformVectorsToPixels} from "../use-transform"; import {Arrowhead} from "./arrowhead"; import {SVGLine} from "./svg-line"; +const {calculateAngleInDegrees} = angles; + type Props = { tail: vec.Vector2; tip: vec.Vector2; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx index c06966d992..e29aeee8e3 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx @@ -12,6 +12,7 @@ import type { Dispatch, InteractiveGraphElementSuite, } from "../types"; +import type {QuadraticCoefficient, QuadraticCoords} from "@khanacademy/kmath"; export function renderQuadraticGraph( state: QuadraticGraphState, @@ -24,8 +25,6 @@ export function renderQuadraticGraph( } type QuadraticGraphProps = MafsGraphProps; -type QuadraticCoefficient = [number, number, number]; -export type QuadraticCoords = QuadraticGraphState["coords"]; function QuadraticGraph(props: QuadraticGraphProps) { const {dispatch, graphState} = props; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/sinusoid.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/sinusoid.tsx index d8ec98d7ef..974eda7b24 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/sinusoid.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/sinusoid.tsx @@ -2,18 +2,19 @@ import {color} from "@khanacademy/wonder-blocks-tokens"; import {Plot} from "mafs"; import * as React from "react"; -import {X, Y} from "../math"; +import {X, Y} from "../math/coordinates"; import {actions} from "../reducer/interactive-graph-action"; import {MovablePoint} from "./components/movable-point"; -import type {Coord} from "../../../interactive2/types"; import type { SinusoidGraphState, MafsGraphProps, Dispatch, InteractiveGraphElementSuite, } from "../types"; +import type {NamedSineCoefficient} from "@khanacademy/kmath"; +import type {Coord} from "@khanacademy/perseus-core"; export function renderSinusoidGraph( state: SinusoidGraphState, @@ -27,13 +28,6 @@ export function renderSinusoidGraph( type SinusoidGraphProps = MafsGraphProps; -export type SineCoefficient = { - amplitude: number; - angularFrequency: number; - phase: number; - verticalOffset: number; -}; - function SinusoidGraph(props: SinusoidGraphProps) { const {dispatch, graphState} = props; @@ -46,7 +40,7 @@ function SinusoidGraph(props: SinusoidGraphProps) { // to content creators the currently selected "correct answer" in the Content Editor. // While we should technically never have invalid coordinates, we want to ensure that // we have a fallback so that the graph can still be plotted without crashing. - const coeffRef = React.useRef({ + const coeffRef = React.useRef({ amplitude: 1, angularFrequency: 1, phase: 1, @@ -82,7 +76,7 @@ function SinusoidGraph(props: SinusoidGraphProps) { // Plot a sinusoid of the form: f(x) = a * sin(b * x - c) + d export const computeSine = function ( x: number, // x-coordinate - sinusoidCoefficients: SineCoefficient, + sinusoidCoefficients: NamedSineCoefficient, ) { // Break down the coefficients for the sine function to improve readability const { @@ -97,7 +91,7 @@ export const computeSine = function ( export const getSinusoidCoefficients = ( coords: ReadonlyArray, -): SineCoefficient | undefined => { +): NamedSineCoefficient | undefined => { // It's assumed that p1 is the root and p2 is the first peak const p1 = coords[0]; const p2 = coords[1]; diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-line.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-line.tsx index 36fcf12e48..65c5e2be3f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-line.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-line.tsx @@ -1,3 +1,4 @@ +import {angles} from "@khanacademy/kmath"; import {lockedFigureColors} from "@khanacademy/perseus-core"; import {color as wbColor, spacing} from "@khanacademy/wonder-blocks-tokens"; import {Point, Line, vec} from "mafs"; @@ -7,11 +8,13 @@ import {Arrowhead} from "../graphs/components/arrowhead"; import {Vector} from "../graphs/components/vector"; import {useTransformVectorsToPixels} from "../graphs/use-transform"; import {getIntersectionOfRayWithBox} from "../graphs/utils"; -import {X, Y, calculateAngleInDegrees} from "../math"; +import {X, Y} from "../math"; import type {LockedLineType} from "@khanacademy/perseus-core"; import type {Interval} from "mafs"; +const {calculateAngleInDegrees} = angles; + type Props = LockedLineType & { range: [Interval, Interval]; }; diff --git a/packages/perseus/src/widgets/interactive-graphs/math/index.ts b/packages/perseus/src/widgets/interactive-graphs/math/index.ts index 86f02fcb3e..eb478ac6d9 100644 --- a/packages/perseus/src/widgets/interactive-graphs/math/index.ts +++ b/packages/perseus/src/widgets/interactive-graphs/math/index.ts @@ -5,10 +5,3 @@ export {X, Y} from "./coordinates"; export {MIN, MAX, size} from "./interval"; export {lerp} from "./interpolation"; export {segmentsIntersect} from "./geometry"; -export { - calculateAngleInDegrees, - convertDegreesToRadians, - getClockwiseAngle, - getAngleFromVertex, - polar, -} from "./angles"; diff --git a/packages/perseus/src/widgets/interactive-graphs/protractor.tsx b/packages/perseus/src/widgets/interactive-graphs/protractor.tsx index 6931c4a75f..ff9070468c 100644 --- a/packages/perseus/src/widgets/interactive-graphs/protractor.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/protractor.tsx @@ -1,3 +1,4 @@ +import {angles} from "@khanacademy/kmath"; import {useDrag} from "@use-gesture/react"; import {vec} from "mafs"; import * as React from "react"; @@ -7,13 +8,7 @@ import {pathBuilder} from "../../util/svg"; import {useDraggable} from "./graphs/use-draggable"; import {useTransformVectorsToPixels} from "./graphs/use-transform"; -import { - calculateAngleInDegrees, - convertDegreesToRadians, - lerp, - X, - Y, -} from "./math"; +import {lerp, X, Y} from "./math"; import useGraphConfig from "./reducer/use-graph-config"; import {bound, TARGET_SIZE} from "./utils"; @@ -21,6 +16,8 @@ import type {RefObject} from "react"; import "./protractor.css"; +const {calculateAngleInDegrees, convertDegreesToRadians} = angles; + const protractorImage = "https://ka-perseus-graphie.s3.amazonaws.com/e9d032f2ab8b95979f674fbfa67056442ba1ff6a.png"; diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts index 6b95a560e0..63735b2926 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts @@ -1,7 +1,7 @@ +import {geometry} from "@khanacademy/kmath"; import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; import {vec} from "mafs"; -import {magnitude, vector} from "../../../util/geometry"; import {normalizeCoords, normalizePoints} from "../utils"; import type {Coord} from "../../../interactive2/types"; @@ -21,6 +21,8 @@ import type { } from "@khanacademy/perseus-core"; import type {Interval} from "mafs"; +const {magnitude, vector} = geometry; + export type InitializeGraphStateParams = { range: [x: Interval, y: Interval]; step: [x: number, y: number]; diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts index fc0ed20ee8..14044e27a5 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts @@ -1,7 +1,6 @@ +import {angles} from "@khanacademy/kmath"; import invariant from "tiny-invariant"; -import {getClockwiseAngle} from "../math/angles"; - import {changeSnapStep, changeRange, actions} from "./interactive-graph-action"; import {interactiveGraphReducer} from "./interactive-graph-reducer"; @@ -13,6 +12,8 @@ import type { } from "../types"; import type {GraphRange} from "@khanacademy/perseus-core"; +const {getClockwiseAngle} = angles; + const baseSegmentGraphState: InteractiveGraphState = { hasBeenInteractedWith: false, type: "segment", diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts index 5b5e6974c7..33d1611bca 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts @@ -1,32 +1,16 @@ -import {vector as kvector} from "@khanacademy/kmath"; +import { + angles, + coefficients, + geometry, + vector as kvector, +} from "@khanacademy/kmath"; +import {approximateEqual} from "@khanacademy/perseus-core"; import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; import {vec} from "mafs"; import _ from "underscore"; -import Util from "../../../util"; -import { - angleMeasures, - ccw, - lawOfCosines, - magnitude, - polygonSidesIntersect, - reverseVector, - sign, - vector, -} from "../../../util/geometry"; -import {getQuadraticCoefficients} from "../graphs/quadratic"; import {getArrayWithoutDuplicates} from "../graphs/utils"; -import { - clamp, - clampToBox, - getAngleFromVertex, - getClockwiseAngle, - inset, - polar, - snap, - X, - Y, -} from "../math"; +import {clamp, clampToBox, inset, snap, X, Y} from "../math"; import {bound, isUnlimitedGraphState} from "../utils"; import {initializeGraphState} from "./initialize-graph-state"; @@ -71,14 +55,27 @@ import { } from "./interactive-graph-action"; import type {Coord} from "../../../interactive2/types"; -import type {QuadraticCoords} from "../graphs/quadratic"; import type { AngleGraphState, InteractiveGraphState, PairOfPoints, } from "../types"; +import type {QuadraticCoords} from "@khanacademy/kmath"; import type {Interval} from "mafs"; +const {getAngleFromVertex, getClockwiseAngle, polar} = angles; +const { + angleMeasures, + ccw, + lawOfCosines, + magnitude, + polygonSidesIntersect, + reverseVector, + sign, + vector, +} = geometry; +const {getQuadraticCoefficients} = coefficients; + const minDistanceBetweenAngleVertexAndSidePoint = 2; export function interactiveGraphReducer( @@ -777,11 +774,9 @@ interface ConstraintArgs { point: vec.Vector2; } -const eq = Util.eq; - // Less than or approximately equal function leq(a: any, b) { - return a < b || eq(a, b); + return a < b || approximateEqual(a, b); } function boundAndSnapToGrid( diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts index 6e28b1aed4..28cb7ac64f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts @@ -1,9 +1,11 @@ -import {clockwise} from "../../../util/geometry"; +import {geometry} from "@khanacademy/kmath"; import type {Coord} from "../../../interactive2/types"; import type {CircleGraphState, InteractiveGraphState} from "../types"; import type {PerseusGraphType} from "@khanacademy/perseus-core"; +const {clockwise} = geometry; + export function getGradableGraph( state: InteractiveGraphState, initialGraph: PerseusGraphType, diff --git a/packages/perseus/src/widgets/interactive-graphs/types.ts b/packages/perseus/src/widgets/interactive-graphs/types.ts index 488d5c3b7a..7055d5a1d0 100644 --- a/packages/perseus/src/widgets/interactive-graphs/types.ts +++ b/packages/perseus/src/widgets/interactive-graphs/types.ts @@ -1,6 +1,7 @@ import type {InteractiveGraphAction} from "./reducer/interactive-graph-action"; import type {Coord} from "../../interactive2/types"; import type {WidgetProps} from "../../types"; +import type {QuadraticCoords} from "@khanacademy/kmath"; import type {PerseusInteractiveGraphWidgetOptions} from "@khanacademy/perseus-core"; import type {Interval, vec} from "mafs"; import type {ReactNode} from "react"; @@ -106,7 +107,7 @@ export interface CircleGraphState extends InteractiveGraphStateCommon { export interface QuadraticGraphState extends InteractiveGraphStateCommon { type: "quadratic"; - coords: [Coord, Coord, Coord]; + coords: QuadraticCoords; } export interface SinusoidGraphState extends InteractiveGraphStateCommon { diff --git a/packages/perseus/src/widgets/label-image/label-image.tsx b/packages/perseus/src/widgets/label-image/label-image.tsx index 15580dae08..527a08c317 100644 --- a/packages/perseus/src/widgets/label-image/label-image.tsx +++ b/packages/perseus/src/widgets/label-image/label-image.tsx @@ -6,6 +6,12 @@ * knowledge by directly interacting with the image. */ +import { + scoreLabelImageMarker, + scoreLabelImage, + type PerseusLabelImageRubric, + type PerseusLabelImageUserInput, +} from "@khanacademy/perseus-score"; import Clickable from "@khanacademy/wonder-blocks-clickable"; import {View} from "@khanacademy/wonder-blocks-core"; import {StyleSheet, css} from "aphrodite"; @@ -25,7 +31,6 @@ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/label-image import AnswerChoices from "./answer-choices"; import {HideAnswersToggle} from "./hide-answers-toggle"; import Marker from "./marker"; -import scoreLabelImage, {scoreMarker} from "./score-label-image"; import type {DependencyProps} from "../../dependencies"; import type {ChangeableProps} from "../../mixins/changeable"; @@ -35,10 +40,6 @@ import type { InteractiveMarkerType, PerseusLabelImageWidgetOptions, } from "@khanacademy/perseus-core"; -import type { - PerseusLabelImageRubric, - PerseusLabelImageUserInput, -} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; import type {CSSProperties} from "aphrodite"; @@ -326,7 +327,7 @@ export class LabelImage const {onChange} = this.props; const updatedMarkers = markers.map((marker) => { - const score = scoreMarker(marker); + const score = scoreLabelImageMarker(marker); return { ...marker, @@ -478,7 +479,7 @@ export class LabelImage }[markerPosition]; } - const score = scoreMarker(marker); + const score = scoreLabelImageMarker(marker); // Once the question is answered, show markers // with correct answers, otherwise passthrough // the correctness state. diff --git a/packages/perseus/src/widgets/matrix/matrix.tsx b/packages/perseus/src/widgets/matrix/matrix.tsx index 036dd4140a..6a55a0ee88 100644 --- a/packages/perseus/src/widgets/matrix/matrix.tsx +++ b/packages/perseus/src/widgets/matrix/matrix.tsx @@ -1,4 +1,14 @@ +import { + getMatrixSize, + type PerseusMatrixWidgetAnswers, + type PerseusMatrixWidgetOptions, +} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; +import { + scoreMatrix, + type PerseusMatrixRubric, + type PerseusMatrixUserInput, +} from "@khanacademy/perseus-score"; import {StyleSheet} from "aphrodite"; import classNames from "classnames"; import * as React from "react"; @@ -15,18 +25,8 @@ import Renderer from "../../renderer"; import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; -import scoreMatrix from "./score-matrix"; - import type {WidgetExports, WidgetProps, Widget, FocusPath} from "../../types"; import type {MatrixPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; -import type { - PerseusMatrixWidgetAnswers, - PerseusMatrixWidgetOptions, -} from "@khanacademy/perseus-core"; -import type { - PerseusMatrixRubric, - PerseusMatrixUserInput, -} from "@khanacademy/perseus-score"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; const {assert} = InteractiveUtil; @@ -79,30 +79,6 @@ const getRefForPath = function (path: FocusPath) { return "answer" + row + "," + column; }; -export function getMatrixSize(matrix: ReadonlyArray>) { - const matrixSize = [1, 1]; - - // We need to find the widest row and tallest column to get the correct - // matrix size. - _(matrix).each((matrixRow, row) => { - let rowWidth = 0; - _(matrixRow).each((matrixCol, col) => { - if (matrixCol != null && matrixCol.toString().length) { - rowWidth = col + 1; - } - }); - - // Matrix width: - matrixSize[1] = Math.max(matrixSize[1], rowWidth); - - // Matrix height: - if (rowWidth > 0) { - matrixSize[0] = Math.max(matrixSize[0], row + 1); - } - }); - return matrixSize; -} - type ExternalProps = WidgetProps< { // Translatable Text; Shown before the matrix diff --git a/packages/perseus/src/widgets/orderer/orderer.tsx b/packages/perseus/src/widgets/orderer/orderer.tsx index d3bb28bdc3..faa41334e6 100644 --- a/packages/perseus/src/widgets/orderer/orderer.tsx +++ b/packages/perseus/src/widgets/orderer/orderer.tsx @@ -2,6 +2,11 @@ /* eslint-disable @babel/no-invalid-this, react/no-unsafe */ import {Errors} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; +import { + scoreOrderer, + type PerseusOrdererRubric, + type PerseusOrdererUserInput, +} from "@khanacademy/perseus-score"; import $ from "jquery"; import * as React from "react"; import ReactDOM from "react-dom"; @@ -14,16 +19,10 @@ import Renderer from "../../renderer"; import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; -import {scoreOrderer} from "./score-orderer"; - import type {WidgetExports, WidgetProps, Widget} from "../../types"; import type {OrdererPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; import type {PerseusOrdererWidgetOptions} from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; -import type { - PerseusOrdererRubric, - PerseusOrdererUserInput, -} from "@khanacademy/perseus-score"; type PlaceholderCardProps = { width: number | null | undefined;