@khanacademy/perseus 24.1.0 → 24.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/es/index.css +7 -0
  2. package/dist/es/index.js +655 -130
  3. package/dist/es/index.js.map +1 -1
  4. package/dist/index.css +7 -0
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.js +678 -141
  7. package/dist/index.js.map +1 -1
  8. package/dist/perseus-api.d.ts +2 -0
  9. package/dist/perseus-types.d.ts +17 -2
  10. package/dist/server-item-renderer.d.ts +2 -0
  11. package/dist/types.d.ts +1 -1
  12. package/dist/widgets/expression.d.ts +12 -0
  13. package/dist/widgets/interactive-graph.d.ts +1 -1
  14. package/dist/widgets/interactive-graphs/graphs/angle.d.ts +5 -0
  15. package/dist/widgets/interactive-graphs/graphs/components/{angle.d.ts → angle-indicators.d.ts} +12 -2
  16. package/dist/widgets/interactive-graphs/graphs/components/movable-line.d.ts +11 -0
  17. package/dist/widgets/interactive-graphs/graphs/components/text-label.d.ts +1 -1
  18. package/dist/widgets/interactive-graphs/graphs/index.d.ts +1 -0
  19. package/dist/widgets/interactive-graphs/interactive-graph-question-builder.d.ts +3 -1
  20. package/dist/widgets/interactive-graphs/locked-figures/locked-function.d.ts +4 -0
  21. package/dist/widgets/interactive-graphs/mafs-supported-graph-types.d.ts +1 -1
  22. package/dist/widgets/interactive-graphs/math/angles.d.ts +3 -0
  23. package/dist/widgets/interactive-graphs/math/box.d.ts +4 -0
  24. package/dist/widgets/interactive-graphs/math/clamp.d.ts +1 -0
  25. package/dist/widgets/interactive-graphs/math/coordinates.d.ts +2 -0
  26. package/dist/widgets/interactive-graphs/math/index.d.ts +6 -0
  27. package/dist/widgets/interactive-graphs/math/interval.d.ts +5 -0
  28. package/dist/widgets/interactive-graphs/math/snap.d.ts +2 -0
  29. package/dist/widgets/interactive-graphs/types.d.ts +9 -1
  30. package/dist/widgets/interactive-graphs/utils.d.ts +0 -2
  31. package/dist/widgets/label-image.d.ts +4 -0
  32. package/package.json +2 -2
package/dist/es/index.js CHANGED
@@ -29,7 +29,7 @@ import { OptionItem, SingleSelect, MultiSelect } from '@khanacademy/wonder-block
29
29
  import caretDown from '@phosphor-icons/core/regular/caret-down.svg';
30
30
  import caretUp from '@phosphor-icons/core/regular/caret-up.svg';
31
31
  import debounce$2 from 'lodash.debounce';
32
- import { vec, Ellipse, Line as Line$4, Point as Point$2, Polygon, useTransformContext, Text as Text$1, Plot as Plot$3, Coordinates, Mafs } from 'mafs';
32
+ import { vec, Ellipse, Plot as Plot$3, Line as Line$4, Point as Point$2, Polygon, useTransformContext, Text as Text$1, Coordinates, Mafs } from 'mafs';
33
33
  import { useDrag } from '@use-gesture/react';
34
34
  import { PhosphorIcon } from '@khanacademy/wonder-blocks-icon';
35
35
  import { Body, LabelSmall, LabelMedium } from '@khanacademy/wonder-blocks-typography';
@@ -8969,7 +8969,7 @@ var Tex$1 = {
8969
8969
  const {
8970
8970
  processMath: processMath$1
8971
8971
  } = Tex$1;
8972
- function polar(r, th) {
8972
+ function polar$1(r, th) {
8973
8973
  if (typeof r === "number") {
8974
8974
  r = [r, r];
8975
8975
  }
@@ -9458,8 +9458,8 @@ class Graphie$1 {
9458
9458
  endAngle = (endAngle % 360 + 360) % 360;
9459
9459
  const cent = this.scalePoint(center);
9460
9460
  const radii = this.scaleVector(radius);
9461
- const startVector = polar(radius, startAngle);
9462
- const endVector = polar(radius, endAngle);
9461
+ const startVector = polar$1(radius, startAngle);
9462
+ const endVector = polar$1(radius, endAngle);
9463
9463
 
9464
9464
  // We round the coordinates to make testing easier, because trig
9465
9465
  // operations return unround numbers.
@@ -13732,7 +13732,7 @@ _.extend(MovableAngle.prototype, {
13732
13732
  let angle = GraphUtils.findAngle(newPoint, vertex);
13733
13733
  angle = Math.round((angle - snapOffset) / snap) * snap + snapOffset;
13734
13734
  const distance = GraphUtils.getDistance(newPoint, vertex);
13735
- return vector$1.add(vertex, polar(distance, angle));
13735
+ return vector$1.add(vertex, polar$1(distance, angle));
13736
13736
  }
13737
13737
  return true;
13738
13738
  };
@@ -16648,7 +16648,7 @@ const Path = GraphieClasses.createClass({
16648
16648
  this.path.toFront();
16649
16649
  }
16650
16650
  });
16651
- const Arc = GraphieClasses.createSimpleClass((graphie, props) => {
16651
+ const Arc$1 = GraphieClasses.createSimpleClass((graphie, props) => {
16652
16652
  let center = props.center;
16653
16653
  let radius = props.radius;
16654
16654
  if (props.unscaled) {
@@ -16664,7 +16664,7 @@ const Rect$1 = GraphieClasses.createSimpleClass((graphie, props) => {
16664
16664
  return graphie.rect(props.x, props.y, props.width, props.height, props.style);
16665
16665
  });
16666
16666
  var Movables = {
16667
- Arc: Arc,
16667
+ Arc: Arc$1,
16668
16668
  Circle: Circle,
16669
16669
  Label: Label$1,
16670
16670
  Line: Line$3,
@@ -28363,6 +28363,91 @@ const useTransformDimensionsToPixels = (...dimens) => {
28363
28363
  return dimensionsToPixels(dimens, graphState);
28364
28364
  };
28365
28365
 
28366
+ // Restricts `value` to be between `min` and `max`.
28367
+ function clamp(value, min, max) {
28368
+ if (value < min) {
28369
+ return min;
28370
+ }
28371
+ if (value > max) {
28372
+ return max;
28373
+ }
28374
+ return value;
28375
+ }
28376
+
28377
+ // Rounds each component of `point` to the nearest multiple of the
28378
+ // corresponding component of `snapStep`. E.g. if the snap step is [2, 3],
28379
+ // the x-coord of the point will be rounded to the nearest multiple of 2, and
28380
+ // the y-coord of the point will be rounded to the nearest multiple of 3.
28381
+ function snap(snapStep, point) {
28382
+ const [requestedX, requestedY] = point;
28383
+ const [snapX, snapY] = snapStep;
28384
+ return [Math.round(requestedX / snapX) * snapX, Math.round(requestedY / snapY) * snapY];
28385
+ }
28386
+
28387
+ // Use X and Y to index into arrays that represent coordinate pairs.
28388
+ // e.g. point[X]
28389
+ const X = 0;
28390
+ const Y = 1;
28391
+
28392
+ // Use MIN and MAX to index into arrays that represent intervals.
28393
+ // e.g. range[MAX]
28394
+ const MIN = 0;
28395
+ const MAX = 1;
28396
+ function size([min, max]) {
28397
+ return max - min;
28398
+ }
28399
+
28400
+ // Removes an equal amount from each end of the interval.
28401
+ //
28402
+ // If the given amount cannot be removed (because the interval is too small),
28403
+ // half the interval's size is removed from each end instead, so the interval
28404
+ // ends up with size = 0, and both MAX and MIN equal to its former midpoint.
28405
+ //
28406
+ // If `amount` is negative, the interval is expanded instead.
28407
+ function trim(amount, interval) {
28408
+ if (amount * 2 > size(interval)) {
28409
+ const middle = average(...interval);
28410
+ return [middle, middle];
28411
+ }
28412
+ return [interval[MIN] + amount, interval[MAX] - amount];
28413
+ }
28414
+ function average(a, b) {
28415
+ return (a + b) / 2;
28416
+ }
28417
+
28418
+ // Restricts the `point` to be within `box`, by clamping each coordinate to the
28419
+ // corresponding interval.
28420
+ function clampToBox(box, point) {
28421
+ return [clamp(point[X], ...box[X]), clamp(point[Y], ...box[Y])];
28422
+ }
28423
+
28424
+ // Reduces the size of `box`, trimming each corner by the given positive
28425
+ // `amount`. If a component of `amount` is negative, the box is expanded in
28426
+ // that dimension instead.
28427
+ function inset(amount, box) {
28428
+ return [trim(amount[X], box[X]), trim(amount[Y], box[Y])];
28429
+ }
28430
+
28431
+ // This file contains helper functions for working with angles.
28432
+ function polar(r, th) {
28433
+ if (typeof r === "number") {
28434
+ r = [r, r];
28435
+ }
28436
+ th = th * Math.PI / 180;
28437
+ return [r[0] * Math.cos(th), r[1] * Math.sin(th)];
28438
+ }
28439
+ const findAngle = (point1, point2, vertex) => {
28440
+ if (vertex === undefined) {
28441
+ const x = point1[0] - point2[0];
28442
+ const y = point1[1] - point2[1];
28443
+ if (!x && !y) {
28444
+ return 0;
28445
+ }
28446
+ return (180 + Math.atan2(-y, -x) * 180 / Math.PI + 360) % 360;
28447
+ }
28448
+ return findAngle(point1, vertex) - findAngle(point2, vertex);
28449
+ };
28450
+
28366
28451
  function AxisLabels() {
28367
28452
  const {
28368
28453
  range,
@@ -28370,8 +28455,8 @@ function AxisLabels() {
28370
28455
  width,
28371
28456
  height
28372
28457
  } = useGraphConfig();
28373
- const yAxisLabelLocation = [0, range[1][1]];
28374
- const xAxisLabelLocation = [range[0][1], 0];
28458
+ const yAxisLabelLocation = [0, range[Y][MAX]];
28459
+ const xAxisLabelLocation = [range[X][MAX], 0];
28375
28460
  const [xAxisLabelText, yAxisLabelText] = labels;
28376
28461
  const graphInfo = {
28377
28462
  range,
@@ -28542,6 +28627,32 @@ const LockedEllipse = props => {
28542
28627
  });
28543
28628
  };
28544
28629
 
28630
+ const LockedFunction = props => {
28631
+ const {
28632
+ color,
28633
+ strokeStyle,
28634
+ directionalAxis,
28635
+ domain
28636
+ } = props;
28637
+ const plotProps = {
28638
+ color: lockedFigureColors[color],
28639
+ style: strokeStyle,
28640
+ domain
28641
+ };
28642
+ const equation = props.equationParsed || KAS.parse(props.equation).expr;
28643
+ return /*#__PURE__*/React.createElement("g", {
28644
+ className: "locked-function"
28645
+ }, directionalAxis === "x" && /*#__PURE__*/React.createElement(Plot$3.OfX, _extends({
28646
+ y: x => equation.eval({
28647
+ x
28648
+ })
28649
+ }, plotProps)), directionalAxis === "y" && /*#__PURE__*/React.createElement(Plot$3.OfY, _extends({
28650
+ x: y => equation.eval({
28651
+ y
28652
+ })
28653
+ }, plotProps)));
28654
+ };
28655
+
28545
28656
  function pathBuilder() {
28546
28657
  return new PathBuilder();
28547
28658
  }
@@ -28640,7 +28751,7 @@ function Arrowhead(props) {
28640
28751
  const [point] = useTransformVectorsToPixels(props.tip);
28641
28752
  return /*#__PURE__*/React.createElement("g", {
28642
28753
  className: "interactive-graph-arrowhead",
28643
- transform: `translate(${point[0]} ${point[1]}) rotate(${props.angle})`
28754
+ transform: `translate(${point[X]} ${point[Y]}) rotate(${props.angle})`
28644
28755
  }, /*#__PURE__*/React.createElement("g", {
28645
28756
  transform: "translate(-1.5)"
28646
28757
  }, /*#__PURE__*/React.createElement("path", {
@@ -28709,10 +28820,10 @@ function SVGLine(props) {
28709
28820
  testId
28710
28821
  } = props;
28711
28822
  return /*#__PURE__*/React.createElement("line", {
28712
- x1: start[0],
28713
- y1: start[1],
28714
- x2: end[0],
28715
- y2: end[1],
28823
+ x1: start[X],
28824
+ y1: start[Y],
28825
+ x2: end[X],
28826
+ y2: end[Y],
28716
28827
  style: style,
28717
28828
  className: className,
28718
28829
  "data-testid": testId
@@ -28797,8 +28908,8 @@ const LockedLine = props => {
28797
28908
  return /*#__PURE__*/React.createElement("g", {
28798
28909
  className: kind === "ray" ? "locked-ray" : "locked-line"
28799
28910
  }, line, showPoint1 && /*#__PURE__*/React.createElement(Point$2, {
28800
- x: point1.coord[0],
28801
- y: point1.coord[1],
28911
+ x: point1.coord[X],
28912
+ y: point1.coord[Y],
28802
28913
  svgCircleProps: {
28803
28914
  style: {
28804
28915
  fill: point1.filled ? lockedFigureColors[point1.color] : color.white,
@@ -28807,8 +28918,8 @@ const LockedLine = props => {
28807
28918
  }
28808
28919
  }
28809
28920
  }), showPoint2 && /*#__PURE__*/React.createElement(Point$2, {
28810
- x: point2.coord[0],
28811
- y: point2.coord[1],
28921
+ x: point2.coord[X],
28922
+ y: point2.coord[Y],
28812
28923
  svgCircleProps: {
28813
28924
  style: {
28814
28925
  fill: point2.filled ? lockedFigureColors[point2.color] : color.white,
@@ -28856,8 +28967,8 @@ const LockedPolygon = props => {
28856
28967
  color: lockedFigureColors[color]
28857
28968
  }), showVertices && points.map((point, index) => /*#__PURE__*/React.createElement(Point$2, {
28858
28969
  key: `locked-polygon-point-${index}`,
28859
- x: point[0],
28860
- y: point[1],
28970
+ x: point[X],
28971
+ y: point[Y],
28861
28972
  color: lockedFigureColors[color]
28862
28973
  })));
28863
28974
  };
@@ -28904,6 +29015,10 @@ const GraphLockedLayer = props => {
28904
29015
  return /*#__PURE__*/React.createElement(LockedPolygon, _extends({
28905
29016
  key: `polygon-${index}`
28906
29017
  }, figure));
29018
+ case "function":
29019
+ return /*#__PURE__*/React.createElement(LockedFunction, _extends({
29020
+ key: `function-${index}`
29021
+ }, figure));
28907
29022
  default:
28908
29023
  /**
28909
29024
  * Devlopment-time future-proofing: This should
@@ -28996,24 +29111,18 @@ const normalizePoints = (range, step, coordsList, noSnap) => coordsList.map(coor
28996
29111
  coords.map((coord, i) => {
28997
29112
  const axisRange = range[i];
28998
29113
  if (noSnap) {
28999
- return axisRange[0] + (axisRange[1] - axisRange[0]) * coord;
29114
+ return axisRange[MIN] + size(axisRange) * coord;
29000
29115
  }
29001
29116
  const axisStep = step[i];
29002
- const nSteps = Math.floor((axisRange[1] - axisRange[0]) / axisStep);
29117
+ const nSteps = Math.floor(size(axisRange) / axisStep);
29003
29118
  const tick = Math.round(coord * nSteps);
29004
- return axisRange[0] + axisStep * tick;
29119
+ return axisRange[MIN] + axisStep * tick;
29005
29120
  }));
29006
29121
 
29007
29122
  // same as normalizeCoords in interactive-graph.tsx
29008
29123
  const normalizeCoords = (coordsList, ranges) => coordsList.map(coords => coords.map((coord, i) => {
29009
- const extent = ranges[i][1] - ranges[i][0];
29010
- return (coord + ranges[i][1]) / extent;
29124
+ return (coord + ranges[i][1]) / size(ranges[i]);
29011
29125
  }));
29012
- function snap(snapStep, point) {
29013
- const [requestedX, requestedY] = point;
29014
- const [snapX, snapY] = snapStep;
29015
- return [Math.round(requestedX / snapX) * snapX, Math.round(requestedY / snapY) * snapY];
29016
- }
29017
29126
 
29018
29127
  // Returns the closest point to the given `point` that is within the graph
29019
29128
  // bounds given in `state`.
@@ -29022,19 +29131,8 @@ function bound$1({
29022
29131
  range,
29023
29132
  point
29024
29133
  }) {
29025
- const [requestedX, requestedY] = point;
29026
- const [snapX, snapY] = snapStep;
29027
- const [[minX, maxX], [minY, maxY]] = range;
29028
- return [clamp(requestedX, minX + snapX, maxX - snapX), clamp(requestedY, minY + snapY, maxY - snapY)];
29029
- }
29030
- function clamp(value, min, max) {
29031
- if (value < min) {
29032
- return min;
29033
- }
29034
- if (value > max) {
29035
- return max;
29036
- }
29037
- return value;
29134
+ const boundingBox = inset(snapStep, range);
29135
+ return clampToBox(boundingBox, point);
29038
29136
  }
29039
29137
 
29040
29138
  var isProduction = process.env.NODE_ENV === 'production';
@@ -29104,8 +29202,8 @@ function useDraggable(args) {
29104
29202
  metaKey,
29105
29203
  shiftKey
29106
29204
  } = state;
29107
- const direction = [yDownDirection[0], -yDownDirection[1]];
29108
- const span = Math.abs(direction[0]) ? xSpan : ySpan;
29205
+ const direction = [yDownDirection[X], -yDownDirection[Y]];
29206
+ const span = Math.abs(direction[X]) ? xSpan : ySpan;
29109
29207
  let divisions = 50;
29110
29208
  if (altKey || metaKey) {
29111
29209
  divisions = 200;
@@ -29213,8 +29311,10 @@ const MovablePointView = /*#__PURE__*/forwardRef((props, hitboxRef) => {
29213
29311
  const wbColorName = (_Object$entries$find$ = (_Object$entries$find = Object.entries(color).find(([_, value]) => value === color$1)) == null ? void 0 : _Object$entries$find[0]) != null ? _Object$entries$find$ : "blue";
29214
29312
  const pointClasses = `movable-point ${dragging ? "movable-point--dragging" : ""} ${focusClass(focusBehavior)}`;
29215
29313
  const [[x, y]] = useTransformVectorsToPixels(point);
29216
- const [xMin, xMax] = range[0];
29217
- const [yMin, yMax] = range[1];
29314
+
29315
+ // TODO(benchristel): destructure range in one line
29316
+ const [xMin, xMax] = range[X];
29317
+ const [yMin, yMax] = range[Y];
29218
29318
  const [[verticalStartX]] = useTransformVectorsToPixels([xMin, 0]);
29219
29319
  const [[verticalEndX]] = useTransformVectorsToPixels([xMax, 0]);
29220
29320
  const [[_, horizontalStartY]] = useTransformVectorsToPixels([0, yMin]);
@@ -29271,7 +29371,7 @@ const MovablePointView = /*#__PURE__*/forwardRef((props, hitboxRef) => {
29271
29371
  return /*#__PURE__*/React.createElement(React.Fragment, null, showHairlines && hairlines, showTooltips ? /*#__PURE__*/React.createElement(Tooltip$1, {
29272
29372
  autoUpdate: true,
29273
29373
  backgroundColor: wbColorName,
29274
- content: `(${point[0]}, ${point[1]})`,
29374
+ content: `(${point[X]}, ${point[Y]})`,
29275
29375
  contentStyle: {
29276
29376
  color: "white"
29277
29377
  }
@@ -29456,16 +29556,7 @@ function trimRange(range, graphDimensionsInPixels) {
29456
29556
  const graphUnitsPerPixelY = size(yRange) / pixelsTall;
29457
29557
  const graphUnitsToTrimX = pixelsToTrim * graphUnitsPerPixelX;
29458
29558
  const graphUnitsToTrimY = pixelsToTrim * graphUnitsPerPixelY;
29459
- return [trim(xRange, graphUnitsToTrimX), trim(yRange, graphUnitsToTrimY)];
29460
- }
29461
- function trim(interval, amount) {
29462
- if (size(interval) < amount * 2) {
29463
- return [0, 0];
29464
- }
29465
- return [interval[0] + amount, interval[1] - amount];
29466
- }
29467
- function size(interval) {
29468
- return interval[1] - interval[0];
29559
+ return inset([graphUnitsToTrimX, graphUnitsToTrimY], range);
29469
29560
  }
29470
29561
 
29471
29562
  const SegmentGraph = props => {
@@ -29598,7 +29689,7 @@ const SvgDefs = () => /*#__PURE__*/React__default.createElement("defs", null, /*
29598
29689
  in: "SourceGraphic"
29599
29690
  })));
29600
29691
 
29601
- const Angle = ({
29692
+ const PolygonAngle = ({
29602
29693
  centerPoint,
29603
29694
  endPoints,
29604
29695
  range,
@@ -29625,7 +29716,7 @@ const Angle = ({
29625
29716
  // Used to calculate the inside of the angle and to create square
29626
29717
  const [x3, y3] = vec.add(centerPoint, vec.add(vec.sub([x1, y1], centerPoint), vec.sub([x2, y2], centerPoint)));
29627
29718
  if (!showAngles) {
29628
- return isRightAngle(angle) ? /*#__PURE__*/React.createElement(RightAngleSquare, {
29719
+ return isRightPolygonAngle(angle) ? /*#__PURE__*/React.createElement(RightAngleSquare, {
29629
29720
  start: [x1, y1],
29630
29721
  vertex: [x2, y2],
29631
29722
  end: [x3, y3]
@@ -29662,17 +29753,101 @@ const Angle = ({
29662
29753
  start: [x1, y1],
29663
29754
  vertex: [x2, y2],
29664
29755
  end: [x3, y3]
29665
- }) : /*#__PURE__*/React.createElement(MafsCssTransformWrapper, null, /*#__PURE__*/React.createElement("path", {
29666
- d: arc,
29667
- strokeWidth: 0.02,
29668
- fill: "none"
29669
- })), /*#__PURE__*/React.createElement(TextLabel, {
29756
+ }) : /*#__PURE__*/React.createElement(Arc, {
29757
+ arc: arc
29758
+ }), /*#__PURE__*/React.createElement(TextLabel, {
29670
29759
  x: x3,
29671
29760
  y: y3,
29672
29761
  attach: y3 - centerY > 0 ? "s" : "n",
29673
29762
  attachDistance: Math.abs(y3 - centerY) < 0.2 || vec.dist([x3, y3], centerPoint) < 0.3 ? 20 : 10
29674
29763
  }, angleLabel, "\xB0"));
29675
29764
  };
29765
+ const Angle = ({
29766
+ vertex,
29767
+ coords,
29768
+ showAngles,
29769
+ allowReflexAngles,
29770
+ range
29771
+ }) => {
29772
+ // Check if the points are clockwise or not, depending on whether we allow reflex angles
29773
+ const areClockwise = clockwise$1([...coords, vertex]);
29774
+ const shouldReverseCoords = areClockwise && !allowReflexAngles;
29775
+
29776
+ // Reverse the coordinates accordingly to ensure the angle is calculated correctly
29777
+ const clockwiseCoords = shouldReverseCoords ? coords : coords.reverse();
29778
+
29779
+ // Calculate the angles between the two points
29780
+ const startAngle = findAngle(clockwiseCoords[0], vertex);
29781
+ const endAngle = findAngle(clockwiseCoords[1], vertex);
29782
+ const angle = (startAngle + 360 - endAngle) % 360;
29783
+
29784
+ // Check if the angle is reflexive
29785
+ const isReflexive = angle > 180;
29786
+
29787
+ // Break out the necessary points for the arc calculation
29788
+ const [centerX, centerY] = vertex;
29789
+ const [point1, point2] = clockwiseCoords;
29790
+ const [startX, startY] = point1;
29791
+ const [endX, endY] = point2;
29792
+ const a = vec.dist(vertex, point1);
29793
+ const b = vec.dist(vertex, point2);
29794
+
29795
+ // Set the radius of the arc
29796
+ const radius = 2;
29797
+
29798
+ // Calculate the end points of the arc
29799
+ const y1 = centerY + (startY - centerY) / a * radius;
29800
+ const x2 = centerX + (endX - centerX) / b * radius;
29801
+ const x1 = centerX + (startX - centerX) / a * radius;
29802
+ const y2 = centerY + (endY - centerY) / b * radius;
29803
+
29804
+ // Used to calculate the inside of the angle and to create square
29805
+ const [x3, y3] = vec.add(vertex, vec.add(vec.sub([x1, y1], vertex), vec.sub([x2, y2], vertex)));
29806
+
29807
+ // Determine whether the arc should be drawn outside the angle
29808
+ const isOutside = shouldDrawArcOutside([x3, y3], vertex, range, [[vertex, point1], [vertex, point2]]);
29809
+
29810
+ // Determine the flags for the arc
29811
+ // If the angle is outside OR reflexive, we want to draw a large arc
29812
+ // If the angle is outside AND reflexive, we want to sweep the arc
29813
+ const largeArcFlag = isOutside || isReflexive ? 1 : 0;
29814
+ const sweepFlag = isOutside && isReflexive ? 1 : 0;
29815
+
29816
+ // Create the SVG path for the arc
29817
+ const arc = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${x2} ${y2}`;
29818
+
29819
+ // We only ever want to show whole angles
29820
+ const angleLabel = parseFloat(angle.toFixed(0)); // Only want to show whole angles
29821
+
29822
+ // Calculate the text position based on the angle and whether we allow reflex angles
29823
+ // Let's try the angle bisector method to find the midpoint of the arc
29824
+ const [textX, textY] = calculateBisectorPoint(point1, point2, vertex, isReflexive, allowReflexAngles, radius);
29825
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("defs", null, /*#__PURE__*/React.createElement("filter", {
29826
+ id: "background",
29827
+ x: "-5%",
29828
+ width: "110%",
29829
+ y: "0%",
29830
+ height: "100%"
29831
+ }, /*#__PURE__*/React.createElement("feFlood", {
29832
+ floodColor: "#FFF",
29833
+ floodOpacity: "0.5"
29834
+ }), /*#__PURE__*/React.createElement("feComposite", {
29835
+ operator: "over",
29836
+ in: "SourceGraphic"
29837
+ }))), isRightAngle(angle) ? /*#__PURE__*/React.createElement(RightAngleSquare, {
29838
+ start: [x1, y1],
29839
+ vertex: [x2, y2],
29840
+ end: [x3, y3],
29841
+ className: "arc-right-angle"
29842
+ }) : /*#__PURE__*/React.createElement(Arc, {
29843
+ arc: arc,
29844
+ className: "angle-arc"
29845
+ }), showAngles && /*#__PURE__*/React.createElement(TextLabel, {
29846
+ x: textX,
29847
+ y: textY,
29848
+ color: color.blue
29849
+ }, angleLabel, "\xB0"));
29850
+ };
29676
29851
 
29677
29852
  /**
29678
29853
  * This is broken out into its own component so that it can be used for an early return
@@ -29681,13 +29856,30 @@ const Angle = ({
29681
29856
  const RightAngleSquare = ({
29682
29857
  start: [x1, y1],
29683
29858
  vertex: [x2, y2],
29684
- end: [x3, y3]
29859
+ end: [x3, y3],
29860
+ className
29685
29861
  }) => /*#__PURE__*/React.createElement(MafsCssTransformWrapper, null, /*#__PURE__*/React.createElement("path", {
29686
29862
  d: `M ${x1} ${y1} L ${x3} ${y3} M ${x3} ${y3} L ${x2} ${y2}`,
29687
29863
  strokeWidth: 0.02,
29688
- fill: "none"
29864
+ fill: "none",
29865
+ className: className
29689
29866
  }));
29690
- const isRightAngle = angle => Math.abs(angle - Math.PI / 2) < 0.01;
29867
+
29868
+ // We're conditionally adding the class name here so that we can style the arc differently
29869
+ // based on whether it's an angle or a polygon angle
29870
+ const Arc = ({
29871
+ arc,
29872
+ className
29873
+ }) => {
29874
+ return /*#__PURE__*/React.createElement(MafsCssTransformWrapper, null, /*#__PURE__*/React.createElement("path", {
29875
+ d: arc,
29876
+ strokeWidth: 0.02,
29877
+ fill: "none",
29878
+ className: className
29879
+ }));
29880
+ };
29881
+ const isRightPolygonAngle = angle => Math.abs(angle - Math.PI / 2) < 0.01;
29882
+ const isRightAngle = angle => Math.round(angle) === 90;
29691
29883
 
29692
29884
  /**
29693
29885
  * Determines if an angle is an inside (false) or outside (true) angle.
@@ -29721,6 +29913,75 @@ const linesIntersect = ([[a, b], [c, d]], [[p, q], [r, s]]) => {
29721
29913
  };
29722
29914
  const isEven = n => n % 2 === 0;
29723
29915
 
29916
+ // This function calculates the bisector point of an angle formed by two points
29917
+ // and a vertex. It is used to position the angle label so that it is always
29918
+ // outside the arc at the exact midpoint of the angle.
29919
+ // Reflex angles make use of polar coordinates to determine the correct direction
29920
+ function calculateBisectorPoint(point1, point2, vertex, isReflex, allowReflex, arcRadius) {
29921
+ const [originX, originY] = vertex;
29922
+ const [x1, y1] = point1;
29923
+ const [x2, y2] = point2;
29924
+
29925
+ // Convert the Cartesian Coordinates to vectors
29926
+ const vectorA = [x1 - originX, y1 - originY];
29927
+ const vectorB = [x2 - originX, y2 - originY];
29928
+
29929
+ // Calculate the average angle of the vectors
29930
+ // so that we can point the bisector in the correct direction using polar coordinates
29931
+ const angleA = Math.atan2(vectorA[1], vectorA[0]);
29932
+ const angleB = Math.atan2(vectorB[1], vectorB[0]);
29933
+
29934
+ // Calculate the average of these two angles
29935
+ let averageAngle = (angleA + angleB) / 2;
29936
+ const angleRadians = Math.abs(angleA - angleB);
29937
+
29938
+ // Adjust the average angle to point in the correct direction
29939
+ // and to account for reflex angles and a bug with 180 degrees
29940
+ // that occurs due to our angleOffsetDegrees option in the graph
29941
+ if (allowReflex) {
29942
+ // If the resulting angles are less than 180 degrees,
29943
+ // or if the user has flipped the points on the interactive element
29944
+ // ensure that we have the correct direction for the reflex angle
29945
+ if (angleRadians <= Math.PI && isReflex || angleB > angleA) {
29946
+ averageAngle += Math.PI; // Add 180 degrees
29947
+ }
29948
+ } else {
29949
+ // If the resulting angles are greater than 180 degrees, adjust
29950
+ // to get the correct angle, as we already know that the angle is not reflexive
29951
+ // This fixes a bug that occurs at 180 degrees due to our angleOffsetDegrees option in the graph
29952
+ if (angleRadians > Math.PI) {
29953
+ averageAngle -= Math.PI; // Subract 180 degrees
29954
+ }
29955
+ }
29956
+
29957
+ // Convert the average angle back to Cartesian coordinates
29958
+ const sum = [Math.cos(averageAngle), Math.sin(averageAngle)];
29959
+
29960
+ // Calculate the magnitude of the sum to normalize it
29961
+ const sumMagnitude = Math.sqrt(sum[0] ** 2 + sum[1] ** 2);
29962
+ const bisectorDirection = [sum[0] / sumMagnitude, sum[1] / sumMagnitude];
29963
+
29964
+ // Calculate the initial distance of the bisector direction from the origin
29965
+ const initialDistance = Math.sqrt(bisectorDirection[0] ** 2 + bisectorDirection[1] ** 2);
29966
+
29967
+ // Determine the minimum radius to ensure that the text is always outside the arc
29968
+ const requiredDistance = arcRadius * 1.75;
29969
+ let radius = requiredDistance / initialDistance;
29970
+
29971
+ // Apply radius only if the initial distance is less than required
29972
+ if (initialDistance >= requiredDistance) {
29973
+ radius = 1; // Use the unit vector as is
29974
+ }
29975
+
29976
+ // Scale the bisector direction by the radius
29977
+ const bisectorPoint = [bisectorDirection[0] * radius, bisectorDirection[1] * radius];
29978
+
29979
+ // Add the vertex to the bisector point to get the final position
29980
+ // to ensure that the angle label moves with the interactive element
29981
+ const scaledBisector = vec.add(bisectorPoint, vertex);
29982
+ return scaledBisector;
29983
+ }
29984
+
29724
29985
  const StyledMovablePoint = props => {
29725
29986
  const {
29726
29987
  snapStep
@@ -29801,7 +30062,7 @@ const PolygonGraph = props => {
29801
30062
  if (!pt1 || !pt2) {
29802
30063
  return null;
29803
30064
  }
29804
- return /*#__PURE__*/React.createElement(Angle, {
30065
+ return /*#__PURE__*/React.createElement(PolygonAngle, {
29805
30066
  key: "angle-" + i,
29806
30067
  centerPoint: point,
29807
30068
  endPoints: [pt1, pt2],
@@ -29893,6 +30154,12 @@ function getGradableGraph(state, initialGraph) {
29893
30154
  coords: state.coords
29894
30155
  });
29895
30156
  }
30157
+ if (state.type === "angle" && initialGraph.type === "angle") {
30158
+ return _extends({}, initialGraph, {
30159
+ coords: state.coords,
30160
+ allowReflexAngles: state.allowReflexAngles
30161
+ });
30162
+ }
29896
30163
  throw new Error("Mafs is not yet implemented for graph type: " + initialGraph.type);
29897
30164
  }
29898
30165
 
@@ -29955,16 +30222,16 @@ function MovableCircle(props) {
29955
30222
  className: `movable-circle ${dragging ? "movable-circle--dragging" : ""}`
29956
30223
  }, /*#__PURE__*/React.createElement("ellipse", {
29957
30224
  className: "focus-ring",
29958
- cx: centerPx[0],
29959
- cy: centerPx[1],
29960
- rx: radiiPx[0] + 3,
29961
- ry: radiiPx[1] + 3
30225
+ cx: centerPx[X],
30226
+ cy: centerPx[Y],
30227
+ rx: radiiPx[X] + 3,
30228
+ ry: radiiPx[Y] + 3
29962
30229
  }), /*#__PURE__*/React.createElement("ellipse", {
29963
30230
  className: "circle",
29964
- cx: centerPx[0],
29965
- cy: centerPx[1],
29966
- rx: radiiPx[0],
29967
- ry: radiiPx[1]
30231
+ cx: centerPx[X],
30232
+ cy: centerPx[Y],
30233
+ rx: radiiPx[X],
30234
+ ry: radiiPx[Y]
29968
30235
  }), /*#__PURE__*/React.createElement(DragHandle, {
29969
30236
  center: center
29970
30237
  }));
@@ -29980,15 +30247,16 @@ function DragHandle(props) {
29980
30247
  const topLeft = vec.sub(centerPx, vec.scale(dragHandleDimensions, 0.5));
29981
30248
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("rect", {
29982
30249
  className: "movable-circle-handle",
29983
- x: topLeft[0],
29984
- y: topLeft[1],
29985
- width: dragHandleDimensions[0],
29986
- height: dragHandleDimensions[1],
30250
+ x: topLeft[X],
30251
+ y: topLeft[Y],
30252
+ width: dragHandleDimensions[X],
30253
+ height: dragHandleDimensions[Y],
29987
30254
  rx: cornerRadius,
29988
30255
  ry: cornerRadius
29989
30256
  }), dragHandleDotPositions.map(offsetPx => {
29990
30257
  const [xPx, yPx] = vec.add(offsetPx, centerPx);
29991
30258
  return /*#__PURE__*/React.createElement("circle", {
30259
+ key: `circle-${xPx}-${yPx}`,
29992
30260
  className: "movable-circle-handle-dot",
29993
30261
  cx: xPx,
29994
30262
  cy: yPx
@@ -30117,15 +30385,15 @@ const getSinusoidCoefficients = coords => {
30117
30385
  const p2 = coords[1];
30118
30386
 
30119
30387
  // If the x-coordinates are the same, we are unable to calculate the coefficients
30120
- if (p2[0] === p1[0]) {
30388
+ if (p2[X] === p1[X]) {
30121
30389
  return;
30122
30390
  }
30123
30391
 
30124
30392
  // Resulting coefficients are canonical for this sine curve
30125
- const amplitude = p2[1] - p1[1];
30126
- const angularFrequency = Math.PI / (2 * (p2[0] - p1[0]));
30127
- const phase = p1[0] * angularFrequency;
30128
- const verticalOffset = p1[1];
30393
+ const amplitude = p2[Y] - p1[Y];
30394
+ const angularFrequency = Math.PI / (2 * (p2[X] - p1[X]));
30395
+ const phase = p1[X] * angularFrequency;
30396
+ const verticalOffset = p1[Y];
30129
30397
  return {
30130
30398
  amplitude,
30131
30399
  angularFrequency,
@@ -30134,6 +30402,74 @@ const getSinusoidCoefficients = coords => {
30134
30402
  };
30135
30403
  };
30136
30404
 
30405
+ function AngleGraph(props) {
30406
+ const {
30407
+ dispatch,
30408
+ graphState
30409
+ } = props;
30410
+ const {
30411
+ graphDimensionsInPixels
30412
+ } = useGraphConfig();
30413
+ const {
30414
+ coords,
30415
+ showAngles,
30416
+ range,
30417
+ allowReflexAngles,
30418
+ angleOffsetDeg,
30419
+ snapDegrees
30420
+ } = graphState;
30421
+
30422
+ // Break the coords into the two end points and the center point
30423
+ const endPoints = [coords[0], coords[2]];
30424
+ const centerPoint = coords[1];
30425
+
30426
+ // Convert the vectors to pixels for rendering the svg lines
30427
+ const angleLines = [[centerPoint, endPoints[0]], [centerPoint, endPoints[1]]];
30428
+ const linePixelCoords = [useTransformVectorsToPixels(centerPoint, endPoints[0]), useTransformVectorsToPixels(centerPoint, endPoints[1])];
30429
+
30430
+ // Create the SVG lines for the angle
30431
+ const svgLines = linePixelCoords.map(([startPtPx, endPtPx], i) => {
30432
+ const trimmedRange = trimRange(range, graphDimensionsInPixels);
30433
+ const endExtend = getIntersectionOfRayWithBox(angleLines[i][0], angleLines[i][1], trimmedRange);
30434
+ return /*#__PURE__*/React.createElement("g", {
30435
+ key: `line-${i}`
30436
+ }, /*#__PURE__*/React.createElement(SVGLine, {
30437
+ start: startPtPx,
30438
+ end: endPtPx,
30439
+ style: {
30440
+ stroke: "var(--movable-line-stroke-color)",
30441
+ strokeWidth: "var(--movable-line-stroke-weight)"
30442
+ }
30443
+ }), /*#__PURE__*/React.createElement(Vector, {
30444
+ tail: angleLines[i][1],
30445
+ tip: endExtend,
30446
+ color: "var(--movable-line-stroke-color)"
30447
+ }));
30448
+ });
30449
+
30450
+ // Create the angle indicator parameters
30451
+ const angleParams = {
30452
+ vertex: centerPoint,
30453
+ coords: endPoints,
30454
+ allowReflexAngles: allowReflexAngles || false,
30455
+ // Whether to allow reflex angles or not
30456
+ angleOffsetDeg: angleOffsetDeg || 0,
30457
+ // The angle offset from the x-axis
30458
+ snapDegrees: snapDegrees || 1,
30459
+ // The multiple of degrees to snap to
30460
+ range: range,
30461
+ showAngles: showAngles || false // Whether to show the angle or not
30462
+ };
30463
+
30464
+ // Render the lines, angle, and then movable points
30465
+ return /*#__PURE__*/React.createElement(React.Fragment, null, svgLines, /*#__PURE__*/React.createElement(Angle, angleParams), coords.map((point, i) => /*#__PURE__*/React.createElement(StyledMovablePoint, {
30466
+ key: "point-" + i,
30467
+ snapTo: "angles",
30468
+ point: point,
30469
+ onMove: destination => dispatch(movePoint(i, destination))
30470
+ })));
30471
+ }
30472
+
30137
30473
  // Universal padding for the axis ticks to ensure that the labels
30138
30474
  // have enough space to render without overlapping the tick lines
30139
30475
  const tickPadding = 5;
@@ -30143,10 +30479,12 @@ const AxisTickLabels = () => {
30143
30479
  tickStep,
30144
30480
  range
30145
30481
  } = graphConfig;
30146
- const [xMin, xMax] = range[0];
30147
- const [yMin, yMax] = range[1];
30148
- const yTickStep = tickStep[1];
30149
- const xTickStep = tickStep[0];
30482
+
30483
+ // TODO(benchristel): use destructuring here
30484
+ const [xMin, xMax] = range[X];
30485
+ const [yMin, yMax] = range[Y];
30486
+ const yTickStep = tickStep[Y];
30487
+ const xTickStep = tickStep[X];
30150
30488
  const yGridTicks = generateTickLocations$1(yTickStep, yMin, yMax);
30151
30489
  const xGridTicks = generateTickLocations$1(xTickStep, xMin, xMax);
30152
30490
  return /*#__PURE__*/React.createElement("div", {
@@ -30425,13 +30763,13 @@ const YGridTick = ({
30425
30763
 
30426
30764
  // If the graph is zoomed in, we want to make sure the ticks are still visible
30427
30765
  // even if they are outside the graph's range.
30428
- if (graphInfo.range[0][0] > 0) {
30766
+ if (graphInfo.range[X][MIN] > 0) {
30429
30767
  // If the graph is on the positive side of the x-axis, lock the ticks to the left side of the graph
30430
- xPointOnAxis = graphInfo.range[0][0];
30768
+ xPointOnAxis = graphInfo.range[X][MIN];
30431
30769
  }
30432
- if (graphInfo.range[0][1] < 0) {
30770
+ if (graphInfo.range[X][MAX] < 0) {
30433
30771
  // If the graph is on the negative side of the x-axis, lock the ticks to the right side of the graph
30434
- xPointOnAxis = graphInfo.range[0][1];
30772
+ xPointOnAxis = graphInfo.range[X][MAX];
30435
30773
  }
30436
30774
  const pointOnAxis = [xPointOnAxis, y];
30437
30775
  const [[xPosition, yPosition]] = useTransformVectorsToPixels(pointOnAxis);
@@ -30465,13 +30803,13 @@ const XGridTick = ({
30465
30803
  let yPointOnAxis = 0;
30466
30804
  // If the graph is zoomed in, we want to make sure the ticks are still visible
30467
30805
  // even if they are outside the graph's range.
30468
- if (graphInfo.range[1][0] > 0) {
30806
+ if (graphInfo.range[Y][MIN] > 0) {
30469
30807
  // If the graph is on the positive side of the y-axis, lock the ticks to the top of the graph
30470
- yPointOnAxis = graphInfo.range[1][0];
30808
+ yPointOnAxis = graphInfo.range[Y][MIN];
30471
30809
  }
30472
- if (graphInfo.range[1][1] < 0) {
30810
+ if (graphInfo.range[Y][MAX] < 0) {
30473
30811
  // If the graph is on the negative side of the x-axis, lock the ticks to the bottom of the graph
30474
- yPointOnAxis = graphInfo.range[1][1];
30812
+ yPointOnAxis = graphInfo.range[Y][MAX];
30475
30813
  }
30476
30814
  const pointOnAxis = [x, yPointOnAxis];
30477
30815
  const [[xPosition, yPosition]] = useTransformVectorsToPixels(pointOnAxis);
@@ -30526,10 +30864,12 @@ const AxisTicks = () => {
30526
30864
  width,
30527
30865
  height
30528
30866
  };
30529
- const [xMin, xMax] = range[0];
30530
- const [yMin, yMax] = range[1];
30531
- const yTickStep = tickStep[1];
30532
- const xTickStep = tickStep[0];
30867
+
30868
+ // TODO(benchristel): destructure these in one line
30869
+ const [xMin, xMax] = range[X];
30870
+ const [yMin, yMax] = range[Y];
30871
+ const yTickStep = tickStep[Y];
30872
+ const xTickStep = tickStep[X];
30533
30873
  const yGridTicks = generateTickLocations(yTickStep, yMin, yMax);
30534
30874
  const xGridTicks = generateTickLocations(xTickStep, xMin, xMax);
30535
30875
  return /*#__PURE__*/React.createElement("g", {
@@ -30568,8 +30908,8 @@ const axisOptions = (props, axisIndex) => {
30568
30908
  };
30569
30909
  const Grid = props => {
30570
30910
  return props.markings === "none" ? null : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Coordinates.Cartesian, {
30571
- xAxis: axisOptions(props, 0),
30572
- yAxis: axisOptions(props, 1)
30911
+ xAxis: axisOptions(props, X),
30912
+ yAxis: axisOptions(props, Y)
30573
30913
  }),
30574
30914
  // Only render the axis ticks and arrows if the markings are set to a full "graph"
30575
30915
  props.markings === "graph" && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(AxisTicks, null), /*#__PURE__*/React.createElement(AxisArrows, null)));
@@ -31178,7 +31518,7 @@ const LegacyGrid = ({
31178
31518
  height
31179
31519
  } = backgroundImage != null ? backgroundImage : {};
31180
31520
  if (url && typeof url === "string") {
31181
- const scale = box[0] / interactiveSizes$1.defaultBoxSize;
31521
+ const scale = box[X] / interactiveSizes$1.defaultBoxSize;
31182
31522
  return /*#__PURE__*/React.createElement(View, {
31183
31523
  style: {
31184
31524
  position: "absolute",
@@ -31243,9 +31583,9 @@ function Protractor() {
31243
31583
  const angle = calculateAngleInDegrees(rotationHandleOffset) - calculateAngleInDegrees(centerToRotationHandle);
31244
31584
  return /*#__PURE__*/React.createElement("g", {
31245
31585
  ref: draggableRef,
31246
- transform: `translate(${topLeftPx[0]}, ${topLeftPx[1]}), rotate(${angle})`,
31586
+ transform: `translate(${topLeftPx[X]}, ${topLeftPx[Y]}), rotate(${angle})`,
31247
31587
  style: {
31248
- transformOrigin: `${-centerToTopLeft[0]}px ${-centerToTopLeft[1]}px`
31588
+ transformOrigin: `${-centerToTopLeft[X]}px ${-centerToTopLeft[Y]}px`
31249
31589
  }
31250
31590
  }, /*#__PURE__*/React.createElement("image", {
31251
31591
  href: protractorImage
@@ -31410,7 +31750,18 @@ function initializeGraphState(params) {
31410
31750
  coords: getSinusoidCoords(graph, range, step)
31411
31751
  });
31412
31752
  case "angle":
31413
- throw new Error("Mafs not yet implemented for graph type: " + graph.type);
31753
+ return _extends({}, shared, {
31754
+ type: graph.type,
31755
+ showAngles: Boolean(graph.showAngles),
31756
+ coords: getAngleCoords({
31757
+ graph,
31758
+ range,
31759
+ step
31760
+ }),
31761
+ angleOffsetDeg: Number(graph.angleOffsetDeg),
31762
+ allowReflexAngles: Boolean(graph.allowReflexAngles),
31763
+ snapDegrees: Number(graph.snapDegrees)
31764
+ });
31414
31765
  default:
31415
31766
  throw new UnreachableCaseError(graph);
31416
31767
  }
@@ -31544,6 +31895,43 @@ function getCircleCoords(graph) {
31544
31895
  radiusPoint: [2, 0]
31545
31896
  };
31546
31897
  }
31898
+ const getAngleCoords = params => {
31899
+ const {
31900
+ graph,
31901
+ range,
31902
+ step
31903
+ } = params;
31904
+ if (graph.coords) {
31905
+ return graph.coords;
31906
+ }
31907
+ const {
31908
+ snapDegrees,
31909
+ angleOffsetDeg
31910
+ } = graph;
31911
+ const snap = snapDegrees || 1;
31912
+ let angle = snap;
31913
+ while (angle < 20) {
31914
+ angle += snap;
31915
+ }
31916
+ angle = angle * Math.PI / 180;
31917
+ const offset = (angleOffsetDeg || 0) * Math.PI / 180;
31918
+ let defaultCoords = [[0.85, 0.5], [0.5, 0.5]];
31919
+ defaultCoords = normalizePoints(range, step, defaultCoords, true);
31920
+
31921
+ // @ts-expect-error - TS2345 - Argument of type 'number[]' is not assignable to parameter of type 'readonly Coord[]'. | TS2556 - A spread argument must either have a tuple type or be passed to a rest parameter.
31922
+ const radius = magnitude(vector(...defaultCoords));
31923
+
31924
+ // We're adding a placeholder for the third point to appease ts and so that we
31925
+ // can calculate it after we've adjusted the first point by the angleOffsetDeg
31926
+ const coords = [...defaultCoords, [0, 0]];
31927
+
31928
+ // Adjust the lower point by angleOffsetDeg degrees
31929
+ coords[0] = [coords[1][0] + radius * Math.cos(offset), coords[1][1] + radius * Math.sin(offset)];
31930
+ // Position the upper point angle radians from the
31931
+ // lower point
31932
+ coords[2] = [coords[1][0] + radius * Math.cos(angle + offset), coords[1][1] + radius * Math.sin(angle + offset)];
31933
+ return coords;
31934
+ };
31547
31935
 
31548
31936
  function interactiveGraphReducer(state, action) {
31549
31937
  switch (action.type) {
@@ -31605,6 +31993,7 @@ function doMoveControlPoint(state, action) {
31605
31993
  coords: newCoords
31606
31994
  });
31607
31995
  }
31996
+ case "angle":
31608
31997
  case "circle":
31609
31998
  throw new Error("FIXME implement circle reducer");
31610
31999
  case "point":
@@ -31706,12 +32095,30 @@ function doMoveAll(state, action) {
31706
32095
  }
31707
32096
  function doMovePoint(state, action) {
31708
32097
  switch (state.type) {
32098
+ case "angle":
32099
+ // If the index is 1, we are moving the vertex of the angle,
32100
+ // which will move the other two points as well
32101
+ if (action.index === 1) {
32102
+ const updatedCoords = boundAndSnapAngleVertex(state, action);
32103
+ return _extends({}, state, {
32104
+ hasBeenInteractedWith: true,
32105
+ coords: updatedCoords
32106
+ });
32107
+ }
32108
+ return _extends({}, state, {
32109
+ hasBeenInteractedWith: true,
32110
+ coords: setAtIndex({
32111
+ array: state.coords,
32112
+ index: action.index,
32113
+ newValue: boundAndSnapAngleEndPoints(action.destination, state, action.index)
32114
+ })
32115
+ });
31709
32116
  case "polygon":
31710
32117
  let newValue;
31711
32118
  if (state.snapTo === "sides") {
31712
32119
  newValue = boundAndSnapToSides(action.destination, state, action.index);
31713
32120
  } else if (state.snapTo === "angles") {
31714
- newValue = boundAndSnapToAngle(action.destination, state, action.index);
32121
+ newValue = boundAndSnapToPolygonAngle(action.destination, state, action.index);
31715
32122
  } else {
31716
32123
  newValue = boundAndSnapToGrid(action.destination, state);
31717
32124
  }
@@ -31750,7 +32157,7 @@ function doMovePoint(state, action) {
31750
32157
  // vertical line. If they are, then we don't want to move the point
31751
32158
  const _newCoords = [...state.coords];
31752
32159
  _newCoords[action.index] = boundDestination;
31753
- if (_newCoords[0][0] === _newCoords[1][0]) {
32160
+ if (_newCoords[0][X] === _newCoords[1][X]) {
31754
32161
  return state;
31755
32162
  }
31756
32163
  return _extends({}, state, {
@@ -31808,13 +32215,13 @@ function doMoveCenter(state, action) {
31808
32215
  // if it otherwise would be off the chart
31809
32216
  // ex: if the handle is on the right and we move the center
31810
32217
  // to the rightmost position, move the handle to the left
31811
- const [xMin, xMax] = state.range[0];
32218
+ const [xMin, xMax] = state.range[X];
31812
32219
  const [radX] = newRadiusPoint;
31813
32220
  if (radX < xMin || radX > xMax) {
31814
- const xJumpDist = (radX - constrainedCenter[0]) * 2;
32221
+ const xJumpDist = (radX - constrainedCenter[X]) * 2;
31815
32222
  const possibleNewX = radX - xJumpDist;
31816
32223
  if (possibleNewX >= xMin && possibleNewX <= xMax) {
31817
- newRadiusPoint[0] = possibleNewX;
32224
+ newRadiusPoint[X] = possibleNewX;
31818
32225
  }
31819
32226
  }
31820
32227
  return _extends({}, state, {
@@ -31831,11 +32238,11 @@ function doMoveRadiusPoint(state, action) {
31831
32238
  switch (state.type) {
31832
32239
  case "circle":
31833
32240
  {
31834
- const [xMin, xMax] = state.range[0];
32241
+ const [xMin, xMax] = state.range[X];
31835
32242
  const nextRadiusPoint = [
31836
32243
  // Constrain to graph range
31837
32244
  // The +0 is to convert -0 to +0
31838
- Math.min(Math.max(xMin, action.destination[0] + 0), xMax), state.center[1]];
32245
+ clamp(action.destination[X] + 0, xMin, xMax), state.center[1]];
31839
32246
  if (_.isEqual(nextRadiusPoint, state.center)) {
31840
32247
  return state;
31841
32248
  }
@@ -31870,10 +32277,10 @@ function doChangeRange(state, action) {
31870
32277
  }
31871
32278
  const getDeltaVertex = (maxMoves, minMoves, delta) => {
31872
32279
  const [deltaX, deltaY] = delta;
31873
- const maxXMove = Math.min(...maxMoves.map(move => move[0]));
31874
- const maxYMove = Math.min(...maxMoves.map(move => move[1]));
31875
- const minXMove = Math.max(...minMoves.map(move => move[0]));
31876
- const minYMove = Math.max(...minMoves.map(move => move[1]));
32280
+ const maxXMove = Math.min(...maxMoves.map(move => move[X]));
32281
+ const maxYMove = Math.min(...maxMoves.map(move => move[Y]));
32282
+ const minXMove = Math.max(...minMoves.map(move => move[X]));
32283
+ const minYMove = Math.max(...minMoves.map(move => move[Y]));
31877
32284
  const dx = clamp(deltaX, minXMove, maxXMove);
31878
32285
  const dy = clamp(deltaY, minYMove, maxYMove);
31879
32286
  return [dx, dy];
@@ -31904,7 +32311,122 @@ function boundAndSnapToGrid(point, {
31904
32311
  point
31905
32312
  }));
31906
32313
  }
31907
- function boundAndSnapToAngle(destinationPoint, {
32314
+ function boundAndSnapAngleVertex({
32315
+ range,
32316
+ coords,
32317
+ snapStep
32318
+ }, {
32319
+ destination
32320
+ }) {
32321
+ // Needed to prevent updating the original coords before the checks for
32322
+ // degenerate triangles and overlapping sides
32323
+ const coordsCopy = [...coords];
32324
+
32325
+ // Get the current and upcoming positions of the vertex
32326
+ const startingVertex = coordsCopy[1];
32327
+ const newVertex = bound$1({
32328
+ snapStep,
32329
+ range,
32330
+ point: snap(snapStep, destination)
32331
+ });
32332
+
32333
+ // Get the vector from the starting vertex to the new vertex
32334
+ const delta = vec.add(newVertex, reverseVector(startingVertex));
32335
+
32336
+ // Apply the delta to each of the other two points so that the angle is maintained
32337
+ let valid = true;
32338
+ const newPoints = {};
32339
+ for (const i of [0, 2]) {
32340
+ const oldPoint = coordsCopy[i];
32341
+ let newPoint = vec.add(oldPoint, delta);
32342
+ let angle = findAngle(newVertex, newPoint);
32343
+ angle *= Math.PI / 180;
32344
+ newPoint = constrainToBoundsOnAngle(newPoint, angle, range, snapStep);
32345
+ newPoints[i] = newPoint;
32346
+
32347
+ // Check if the new point is too close to the vertex
32348
+ if (tooClose(newVertex, newPoint)) {
32349
+ valid = false;
32350
+ }
32351
+ }
32352
+
32353
+ // Update the vertex after snapping to the snapStep
32354
+ newPoints[1] = newVertex;
32355
+ // Only move points if all new positions are valid
32356
+ if (valid) {
32357
+ Object.entries(newPoints).forEach(([i, newPoint]) => {
32358
+ coordsCopy[i] = newPoint;
32359
+ });
32360
+ }
32361
+ return coordsCopy;
32362
+ }
32363
+
32364
+ // This function is used to ensure that the vertex of
32365
+ // an angle is not too close to the other points
32366
+ function tooClose(point1, point2, range) {
32367
+ const safeDistance = 2;
32368
+ const distance = vec.dist(point1, point2);
32369
+ return distance < safeDistance;
32370
+ }
32371
+ function constrainToBoundsOnAngle(point, angle, range, snapStep) {
32372
+ // We're subtracting the snapStep from the lower bound and adding it to the upper bound
32373
+ // to ensure that the point is within the bounds of the graph even after snapping to the nearest degree
32374
+ const lower = [range[0][0] + snapStep[0], range[1][0] + snapStep[0]];
32375
+ const upper = [range[0][1] - snapStep[1], range[1][1] - snapStep[1]];
32376
+ let result = point;
32377
+ if (result[0] < lower[0]) {
32378
+ result = [lower[0], result[1] + (lower[0] - result[0]) * Math.tan(angle)];
32379
+ } else if (result[0] > upper[0]) {
32380
+ result = [upper[0], result[1] - (result[0] - upper[0]) * Math.tan(angle)];
32381
+ }
32382
+ if (result[1] < lower[1]) {
32383
+ result = [result[0] + (lower[1] - result[1]) / Math.tan(angle), lower[1]];
32384
+ } else if (result[1] > upper[1]) {
32385
+ result = [result[0] - (result[1] - upper[1]) / Math.tan(angle), upper[1]];
32386
+ }
32387
+ return result;
32388
+ }
32389
+ function boundAndSnapAngleEndPoints(destinationPoint, {
32390
+ range,
32391
+ coords,
32392
+ snapDegrees,
32393
+ angleOffsetDeg,
32394
+ snapStep
32395
+ }, index) {
32396
+ const snap = snapDegrees || 1;
32397
+ const offsetDegrees = angleOffsetDeg || 0;
32398
+
32399
+ // Needed to prevent updating the original coords before the checks for
32400
+ // degenerate triangles and overlapping sides
32401
+ const coordsCopy = [...coords];
32402
+
32403
+ // We want to subtract or add the snapStep to the lower and upper bounds
32404
+ // respectively to ensure that the point is within the bounds of the graph
32405
+ // even after snapping to the nearest degree
32406
+ const angleRange = [[range[0][0] + snapStep[0], range[0][1] - snapStep[0]], [range[1][0] + snapStep[1], range[1][1] - snapStep[1]]];
32407
+
32408
+ // Takes the destination point and makes sure it is within the bounds of the graph
32409
+ // SnapStep is [0, 0] because we don't want to snap these points to the grid at all
32410
+ const boundPoint = bound$1({
32411
+ snapStep: [0, 0],
32412
+ range: angleRange,
32413
+ point: destinationPoint
32414
+ });
32415
+ coordsCopy[index] = boundPoint;
32416
+
32417
+ // Get the vertex of the angle
32418
+ const vertex = coords[1];
32419
+
32420
+ // Gets the angle between the coords and the vertex
32421
+ let angle = findAngle(coordsCopy[index], vertex);
32422
+
32423
+ // Snap the angle to the nearest multiple of snapDegrees (if provided)
32424
+ angle = Math.round((angle - offsetDegrees) / snap) * snap + offsetDegrees;
32425
+ const distance = vec.dist(coordsCopy[index], vertex);
32426
+ const snappedValue = vec.add(vertex, polar(distance, angle));
32427
+ return snappedValue;
32428
+ }
32429
+ function boundAndSnapToPolygonAngle(destinationPoint, {
31908
32430
  range,
31909
32431
  coords
31910
32432
  }, index) {
@@ -31935,7 +32457,7 @@ function boundAndSnapToAngle(destinationPoint, {
31935
32457
  angles[rel(j)] = Math.round(angles[rel(j)]);
31936
32458
  });
31937
32459
  const getAngle = function getAngle(a, vertex, b) {
31938
- const angle = GraphUtils.findAngle(coordsCopy[rel(a)], coordsCopy[rel(b)], coordsCopy[rel(vertex)]);
32460
+ const angle = findAngle(coordsCopy[rel(a)], coordsCopy[rel(b)], coordsCopy[rel(vertex)]);
31939
32461
  return (angle + 360) % 360;
31940
32462
  };
31941
32463
  const innerAngles = [angles[rel(-1)] - getAngle(-2, -1, 1), angles[rel(1)] - getAngle(-1, 1, 2)];
@@ -31963,7 +32485,7 @@ function boundAndSnapToAngle(destinationPoint, {
31963
32485
  const side = Math.sin(innerAngles[1] * Math.PI / 180) / Math.sin(innerAngles[2] * Math.PI / 180) * knownSide;
31964
32486
 
31965
32487
  // Angle at the second vertex of the polygon
31966
- const outerAngle = GraphUtils.findAngle(coordsCopy[rel(1)], coordsCopy[rel(-1)]);
32488
+ const outerAngle = findAngle(coordsCopy[rel(1)], coordsCopy[rel(-1)]);
31967
32489
 
31968
32490
  // Uses the length of the side of the polygon (radial coordinate)
31969
32491
  // and the angle between the first and second sides of the
@@ -32013,7 +32535,7 @@ function boundAndSnapToSides(destinationPoint, {
32013
32535
  const innerAngle = lawOfCosines(sides[0], sides[2], sides[1]);
32014
32536
 
32015
32537
  // Angle at the second vertex of the polygon
32016
- const outerAngle = GraphUtils.findAngle(coordsCopy[rel(1)], coordsCopy[rel(-1)]);
32538
+ const outerAngle = findAngle(coordsCopy[rel(1)], coordsCopy[rel(-1)]);
32017
32539
 
32018
32540
  // Returns true if the points form a counter-clockwise turn;
32019
32541
  // a.k.a if the point is on the left or right of the polygon.
@@ -32091,6 +32613,11 @@ const renderGraph = props => {
32091
32613
  type
32092
32614
  } = state;
32093
32615
  switch (type) {
32616
+ case "angle":
32617
+ return /*#__PURE__*/React.createElement(AngleGraph, {
32618
+ graphState: state,
32619
+ dispatch: dispatch
32620
+ });
32094
32621
  case "segment":
32095
32622
  return /*#__PURE__*/React.createElement(SegmentGraph, {
32096
32623
  graphState: state,
@@ -32250,8 +32777,8 @@ const MafsGraph = props => {
32250
32777
  }, props.markings === "graph" && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(AxisLabels, null), /*#__PURE__*/React.createElement(AxisTickLabels, null)), /*#__PURE__*/React.createElement(Mafs, {
32251
32778
  preserveAspectRatio: false,
32252
32779
  viewBox: {
32253
- x: state.range[0],
32254
- y: state.range[1],
32780
+ x: state.range[X],
32781
+ y: state.range[Y],
32255
32782
  padding: 0
32256
32783
  },
32257
32784
  pan: false,
@@ -33092,7 +33619,7 @@ class LegacyInteractiveGraph extends React.Component {
33092
33619
  // Solve for side by using the law of sines
33093
33620
  const side = Math.sin(innerAngles[1] * Math.PI / 180) / Math.sin(innerAngles[2] * Math.PI / 180) * knownSide;
33094
33621
  const outerAngle = GraphUtils.findAngle(coords[rel(1)], coords[rel(-1)]);
33095
- const offset = polar(side, outerAngle + (onLeft ? 1 : -1) * innerAngles[0]);
33622
+ const offset = polar$1(side, outerAngle + (onLeft ? 1 : -1) * innerAngles[0]);
33096
33623
  return (_this$graphie5 = this.graphie) == null ? void 0 : _this$graphie5.addPoints(coords[rel(-1)], offset);
33097
33624
  }
33098
33625
  // @ts-expect-error - TS2339 - Property 'snapTo' does not exist on type 'PerseusGraphType'.
@@ -33117,7 +33644,7 @@ class LegacyInteractiveGraph extends React.Component {
33117
33644
  const innerAngle = lawOfCosines(sides[0], sides[2], sides[1]);
33118
33645
  const outerAngle = GraphUtils.findAngle(coords[rel(1)], coords[rel(-1)]);
33119
33646
  const onLeft = sign(ccw(coords[rel(-1)], coords[rel(1)], coords[i])) === 1;
33120
- const offset = polar(sides[0], outerAngle + (onLeft ? 1 : -1) * innerAngle);
33647
+ const offset = polar$1(sides[0], outerAngle + (onLeft ? 1 : -1) * innerAngle);
33121
33648
  return (_this$graphie6 = this.graphie) == null ? void 0 : _this$graphie6.addPoints(coords[rel(-1)], offset);
33122
33649
  }
33123
33650
  // Snap to grid (already done)
@@ -33778,11 +34305,9 @@ class InteractiveGraph extends React.Component {
33778
34305
  const radius = magnitude(vector(...coords));
33779
34306
 
33780
34307
  // Adjust the lower point by angleOffsetDeg degrees
33781
- // @ts-expect-error - TS2542 - Index signature in type 'readonly Coord[]' only permits reading.
33782
34308
  coords[0] = [coords[1][0] + radius * Math.cos(offset), coords[1][1] + radius * Math.sin(offset)];
33783
34309
  // Position the upper point angle radians from the
33784
34310
  // lower point
33785
- // @ts-expect-error - TS2542 - Index signature in type 'readonly Coord[]' only permits reading.
33786
34311
  coords[2] = [coords[1][0] + radius * Math.cos(angle + offset), coords[1][1] + radius * Math.sin(angle + offset)];
33787
34312
  return coords;
33788
34313
  }
@@ -44395,7 +44920,7 @@ const init = function init(options) {
44395
44920
 
44396
44921
  // This file is processed by a Rollup plugin (replace) to inject the production
44397
44922
  const libName = "@khanacademy/perseus";
44398
- const libVersion = "24.1.0";
44923
+ const libVersion = "24.3.0";
44399
44924
  addLibraryVersionToPerseusDebug(libName, libVersion);
44400
44925
 
44401
44926
  const apiVersion = {