@khanacademy/perseus 24.2.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 +654 -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 +677 -141
  7. package/dist/index.js.map +1 -1
  8. package/dist/perseus-api.d.ts +1 -0
  9. package/dist/perseus-types.d.ts +17 -2
  10. package/dist/server-item-renderer.d.ts +1 -0
  11. package/dist/types.d.ts +1 -1
  12. package/dist/widgets/expression.d.ts +6 -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 +2 -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,10 +30247,10 @@ 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 => {
@@ -30118,15 +30385,15 @@ const getSinusoidCoefficients = coords => {
30118
30385
  const p2 = coords[1];
30119
30386
 
30120
30387
  // If the x-coordinates are the same, we are unable to calculate the coefficients
30121
- if (p2[0] === p1[0]) {
30388
+ if (p2[X] === p1[X]) {
30122
30389
  return;
30123
30390
  }
30124
30391
 
30125
30392
  // Resulting coefficients are canonical for this sine curve
30126
- const amplitude = p2[1] - p1[1];
30127
- const angularFrequency = Math.PI / (2 * (p2[0] - p1[0]));
30128
- const phase = p1[0] * angularFrequency;
30129
- 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];
30130
30397
  return {
30131
30398
  amplitude,
30132
30399
  angularFrequency,
@@ -30135,6 +30402,74 @@ const getSinusoidCoefficients = coords => {
30135
30402
  };
30136
30403
  };
30137
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
+
30138
30473
  // Universal padding for the axis ticks to ensure that the labels
30139
30474
  // have enough space to render without overlapping the tick lines
30140
30475
  const tickPadding = 5;
@@ -30144,10 +30479,12 @@ const AxisTickLabels = () => {
30144
30479
  tickStep,
30145
30480
  range
30146
30481
  } = graphConfig;
30147
- const [xMin, xMax] = range[0];
30148
- const [yMin, yMax] = range[1];
30149
- const yTickStep = tickStep[1];
30150
- 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];
30151
30488
  const yGridTicks = generateTickLocations$1(yTickStep, yMin, yMax);
30152
30489
  const xGridTicks = generateTickLocations$1(xTickStep, xMin, xMax);
30153
30490
  return /*#__PURE__*/React.createElement("div", {
@@ -30426,13 +30763,13 @@ const YGridTick = ({
30426
30763
 
30427
30764
  // If the graph is zoomed in, we want to make sure the ticks are still visible
30428
30765
  // even if they are outside the graph's range.
30429
- if (graphInfo.range[0][0] > 0) {
30766
+ if (graphInfo.range[X][MIN] > 0) {
30430
30767
  // If the graph is on the positive side of the x-axis, lock the ticks to the left side of the graph
30431
- xPointOnAxis = graphInfo.range[0][0];
30768
+ xPointOnAxis = graphInfo.range[X][MIN];
30432
30769
  }
30433
- if (graphInfo.range[0][1] < 0) {
30770
+ if (graphInfo.range[X][MAX] < 0) {
30434
30771
  // If the graph is on the negative side of the x-axis, lock the ticks to the right side of the graph
30435
- xPointOnAxis = graphInfo.range[0][1];
30772
+ xPointOnAxis = graphInfo.range[X][MAX];
30436
30773
  }
30437
30774
  const pointOnAxis = [xPointOnAxis, y];
30438
30775
  const [[xPosition, yPosition]] = useTransformVectorsToPixels(pointOnAxis);
@@ -30466,13 +30803,13 @@ const XGridTick = ({
30466
30803
  let yPointOnAxis = 0;
30467
30804
  // If the graph is zoomed in, we want to make sure the ticks are still visible
30468
30805
  // even if they are outside the graph's range.
30469
- if (graphInfo.range[1][0] > 0) {
30806
+ if (graphInfo.range[Y][MIN] > 0) {
30470
30807
  // If the graph is on the positive side of the y-axis, lock the ticks to the top of the graph
30471
- yPointOnAxis = graphInfo.range[1][0];
30808
+ yPointOnAxis = graphInfo.range[Y][MIN];
30472
30809
  }
30473
- if (graphInfo.range[1][1] < 0) {
30810
+ if (graphInfo.range[Y][MAX] < 0) {
30474
30811
  // If the graph is on the negative side of the x-axis, lock the ticks to the bottom of the graph
30475
- yPointOnAxis = graphInfo.range[1][1];
30812
+ yPointOnAxis = graphInfo.range[Y][MAX];
30476
30813
  }
30477
30814
  const pointOnAxis = [x, yPointOnAxis];
30478
30815
  const [[xPosition, yPosition]] = useTransformVectorsToPixels(pointOnAxis);
@@ -30527,10 +30864,12 @@ const AxisTicks = () => {
30527
30864
  width,
30528
30865
  height
30529
30866
  };
30530
- const [xMin, xMax] = range[0];
30531
- const [yMin, yMax] = range[1];
30532
- const yTickStep = tickStep[1];
30533
- 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];
30534
30873
  const yGridTicks = generateTickLocations(yTickStep, yMin, yMax);
30535
30874
  const xGridTicks = generateTickLocations(xTickStep, xMin, xMax);
30536
30875
  return /*#__PURE__*/React.createElement("g", {
@@ -30569,8 +30908,8 @@ const axisOptions = (props, axisIndex) => {
30569
30908
  };
30570
30909
  const Grid = props => {
30571
30910
  return props.markings === "none" ? null : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Coordinates.Cartesian, {
30572
- xAxis: axisOptions(props, 0),
30573
- yAxis: axisOptions(props, 1)
30911
+ xAxis: axisOptions(props, X),
30912
+ yAxis: axisOptions(props, Y)
30574
30913
  }),
30575
30914
  // Only render the axis ticks and arrows if the markings are set to a full "graph"
30576
30915
  props.markings === "graph" && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(AxisTicks, null), /*#__PURE__*/React.createElement(AxisArrows, null)));
@@ -31179,7 +31518,7 @@ const LegacyGrid = ({
31179
31518
  height
31180
31519
  } = backgroundImage != null ? backgroundImage : {};
31181
31520
  if (url && typeof url === "string") {
31182
- const scale = box[0] / interactiveSizes$1.defaultBoxSize;
31521
+ const scale = box[X] / interactiveSizes$1.defaultBoxSize;
31183
31522
  return /*#__PURE__*/React.createElement(View, {
31184
31523
  style: {
31185
31524
  position: "absolute",
@@ -31244,9 +31583,9 @@ function Protractor() {
31244
31583
  const angle = calculateAngleInDegrees(rotationHandleOffset) - calculateAngleInDegrees(centerToRotationHandle);
31245
31584
  return /*#__PURE__*/React.createElement("g", {
31246
31585
  ref: draggableRef,
31247
- transform: `translate(${topLeftPx[0]}, ${topLeftPx[1]}), rotate(${angle})`,
31586
+ transform: `translate(${topLeftPx[X]}, ${topLeftPx[Y]}), rotate(${angle})`,
31248
31587
  style: {
31249
- transformOrigin: `${-centerToTopLeft[0]}px ${-centerToTopLeft[1]}px`
31588
+ transformOrigin: `${-centerToTopLeft[X]}px ${-centerToTopLeft[Y]}px`
31250
31589
  }
31251
31590
  }, /*#__PURE__*/React.createElement("image", {
31252
31591
  href: protractorImage
@@ -31411,7 +31750,18 @@ function initializeGraphState(params) {
31411
31750
  coords: getSinusoidCoords(graph, range, step)
31412
31751
  });
31413
31752
  case "angle":
31414
- 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
+ });
31415
31765
  default:
31416
31766
  throw new UnreachableCaseError(graph);
31417
31767
  }
@@ -31545,6 +31895,43 @@ function getCircleCoords(graph) {
31545
31895
  radiusPoint: [2, 0]
31546
31896
  };
31547
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
+ };
31548
31935
 
31549
31936
  function interactiveGraphReducer(state, action) {
31550
31937
  switch (action.type) {
@@ -31606,6 +31993,7 @@ function doMoveControlPoint(state, action) {
31606
31993
  coords: newCoords
31607
31994
  });
31608
31995
  }
31996
+ case "angle":
31609
31997
  case "circle":
31610
31998
  throw new Error("FIXME implement circle reducer");
31611
31999
  case "point":
@@ -31707,12 +32095,30 @@ function doMoveAll(state, action) {
31707
32095
  }
31708
32096
  function doMovePoint(state, action) {
31709
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
+ });
31710
32116
  case "polygon":
31711
32117
  let newValue;
31712
32118
  if (state.snapTo === "sides") {
31713
32119
  newValue = boundAndSnapToSides(action.destination, state, action.index);
31714
32120
  } else if (state.snapTo === "angles") {
31715
- newValue = boundAndSnapToAngle(action.destination, state, action.index);
32121
+ newValue = boundAndSnapToPolygonAngle(action.destination, state, action.index);
31716
32122
  } else {
31717
32123
  newValue = boundAndSnapToGrid(action.destination, state);
31718
32124
  }
@@ -31751,7 +32157,7 @@ function doMovePoint(state, action) {
31751
32157
  // vertical line. If they are, then we don't want to move the point
31752
32158
  const _newCoords = [...state.coords];
31753
32159
  _newCoords[action.index] = boundDestination;
31754
- if (_newCoords[0][0] === _newCoords[1][0]) {
32160
+ if (_newCoords[0][X] === _newCoords[1][X]) {
31755
32161
  return state;
31756
32162
  }
31757
32163
  return _extends({}, state, {
@@ -31809,13 +32215,13 @@ function doMoveCenter(state, action) {
31809
32215
  // if it otherwise would be off the chart
31810
32216
  // ex: if the handle is on the right and we move the center
31811
32217
  // to the rightmost position, move the handle to the left
31812
- const [xMin, xMax] = state.range[0];
32218
+ const [xMin, xMax] = state.range[X];
31813
32219
  const [radX] = newRadiusPoint;
31814
32220
  if (radX < xMin || radX > xMax) {
31815
- const xJumpDist = (radX - constrainedCenter[0]) * 2;
32221
+ const xJumpDist = (radX - constrainedCenter[X]) * 2;
31816
32222
  const possibleNewX = radX - xJumpDist;
31817
32223
  if (possibleNewX >= xMin && possibleNewX <= xMax) {
31818
- newRadiusPoint[0] = possibleNewX;
32224
+ newRadiusPoint[X] = possibleNewX;
31819
32225
  }
31820
32226
  }
31821
32227
  return _extends({}, state, {
@@ -31832,11 +32238,11 @@ function doMoveRadiusPoint(state, action) {
31832
32238
  switch (state.type) {
31833
32239
  case "circle":
31834
32240
  {
31835
- const [xMin, xMax] = state.range[0];
32241
+ const [xMin, xMax] = state.range[X];
31836
32242
  const nextRadiusPoint = [
31837
32243
  // Constrain to graph range
31838
32244
  // The +0 is to convert -0 to +0
31839
- Math.min(Math.max(xMin, action.destination[0] + 0), xMax), state.center[1]];
32245
+ clamp(action.destination[X] + 0, xMin, xMax), state.center[1]];
31840
32246
  if (_.isEqual(nextRadiusPoint, state.center)) {
31841
32247
  return state;
31842
32248
  }
@@ -31871,10 +32277,10 @@ function doChangeRange(state, action) {
31871
32277
  }
31872
32278
  const getDeltaVertex = (maxMoves, minMoves, delta) => {
31873
32279
  const [deltaX, deltaY] = delta;
31874
- const maxXMove = Math.min(...maxMoves.map(move => move[0]));
31875
- const maxYMove = Math.min(...maxMoves.map(move => move[1]));
31876
- const minXMove = Math.max(...minMoves.map(move => move[0]));
31877
- 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]));
31878
32284
  const dx = clamp(deltaX, minXMove, maxXMove);
31879
32285
  const dy = clamp(deltaY, minYMove, maxYMove);
31880
32286
  return [dx, dy];
@@ -31905,7 +32311,122 @@ function boundAndSnapToGrid(point, {
31905
32311
  point
31906
32312
  }));
31907
32313
  }
31908
- 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, {
31909
32430
  range,
31910
32431
  coords
31911
32432
  }, index) {
@@ -31936,7 +32457,7 @@ function boundAndSnapToAngle(destinationPoint, {
31936
32457
  angles[rel(j)] = Math.round(angles[rel(j)]);
31937
32458
  });
31938
32459
  const getAngle = function getAngle(a, vertex, b) {
31939
- 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)]);
31940
32461
  return (angle + 360) % 360;
31941
32462
  };
31942
32463
  const innerAngles = [angles[rel(-1)] - getAngle(-2, -1, 1), angles[rel(1)] - getAngle(-1, 1, 2)];
@@ -31964,7 +32485,7 @@ function boundAndSnapToAngle(destinationPoint, {
31964
32485
  const side = Math.sin(innerAngles[1] * Math.PI / 180) / Math.sin(innerAngles[2] * Math.PI / 180) * knownSide;
31965
32486
 
31966
32487
  // Angle at the second vertex of the polygon
31967
- const outerAngle = GraphUtils.findAngle(coordsCopy[rel(1)], coordsCopy[rel(-1)]);
32488
+ const outerAngle = findAngle(coordsCopy[rel(1)], coordsCopy[rel(-1)]);
31968
32489
 
31969
32490
  // Uses the length of the side of the polygon (radial coordinate)
31970
32491
  // and the angle between the first and second sides of the
@@ -32014,7 +32535,7 @@ function boundAndSnapToSides(destinationPoint, {
32014
32535
  const innerAngle = lawOfCosines(sides[0], sides[2], sides[1]);
32015
32536
 
32016
32537
  // Angle at the second vertex of the polygon
32017
- const outerAngle = GraphUtils.findAngle(coordsCopy[rel(1)], coordsCopy[rel(-1)]);
32538
+ const outerAngle = findAngle(coordsCopy[rel(1)], coordsCopy[rel(-1)]);
32018
32539
 
32019
32540
  // Returns true if the points form a counter-clockwise turn;
32020
32541
  // a.k.a if the point is on the left or right of the polygon.
@@ -32092,6 +32613,11 @@ const renderGraph = props => {
32092
32613
  type
32093
32614
  } = state;
32094
32615
  switch (type) {
32616
+ case "angle":
32617
+ return /*#__PURE__*/React.createElement(AngleGraph, {
32618
+ graphState: state,
32619
+ dispatch: dispatch
32620
+ });
32095
32621
  case "segment":
32096
32622
  return /*#__PURE__*/React.createElement(SegmentGraph, {
32097
32623
  graphState: state,
@@ -32251,8 +32777,8 @@ const MafsGraph = props => {
32251
32777
  }, props.markings === "graph" && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(AxisLabels, null), /*#__PURE__*/React.createElement(AxisTickLabels, null)), /*#__PURE__*/React.createElement(Mafs, {
32252
32778
  preserveAspectRatio: false,
32253
32779
  viewBox: {
32254
- x: state.range[0],
32255
- y: state.range[1],
32780
+ x: state.range[X],
32781
+ y: state.range[Y],
32256
32782
  padding: 0
32257
32783
  },
32258
32784
  pan: false,
@@ -33093,7 +33619,7 @@ class LegacyInteractiveGraph extends React.Component {
33093
33619
  // Solve for side by using the law of sines
33094
33620
  const side = Math.sin(innerAngles[1] * Math.PI / 180) / Math.sin(innerAngles[2] * Math.PI / 180) * knownSide;
33095
33621
  const outerAngle = GraphUtils.findAngle(coords[rel(1)], coords[rel(-1)]);
33096
- const offset = polar(side, outerAngle + (onLeft ? 1 : -1) * innerAngles[0]);
33622
+ const offset = polar$1(side, outerAngle + (onLeft ? 1 : -1) * innerAngles[0]);
33097
33623
  return (_this$graphie5 = this.graphie) == null ? void 0 : _this$graphie5.addPoints(coords[rel(-1)], offset);
33098
33624
  }
33099
33625
  // @ts-expect-error - TS2339 - Property 'snapTo' does not exist on type 'PerseusGraphType'.
@@ -33118,7 +33644,7 @@ class LegacyInteractiveGraph extends React.Component {
33118
33644
  const innerAngle = lawOfCosines(sides[0], sides[2], sides[1]);
33119
33645
  const outerAngle = GraphUtils.findAngle(coords[rel(1)], coords[rel(-1)]);
33120
33646
  const onLeft = sign(ccw(coords[rel(-1)], coords[rel(1)], coords[i])) === 1;
33121
- const offset = polar(sides[0], outerAngle + (onLeft ? 1 : -1) * innerAngle);
33647
+ const offset = polar$1(sides[0], outerAngle + (onLeft ? 1 : -1) * innerAngle);
33122
33648
  return (_this$graphie6 = this.graphie) == null ? void 0 : _this$graphie6.addPoints(coords[rel(-1)], offset);
33123
33649
  }
33124
33650
  // Snap to grid (already done)
@@ -33779,11 +34305,9 @@ class InteractiveGraph extends React.Component {
33779
34305
  const radius = magnitude(vector(...coords));
33780
34306
 
33781
34307
  // Adjust the lower point by angleOffsetDeg degrees
33782
- // @ts-expect-error - TS2542 - Index signature in type 'readonly Coord[]' only permits reading.
33783
34308
  coords[0] = [coords[1][0] + radius * Math.cos(offset), coords[1][1] + radius * Math.sin(offset)];
33784
34309
  // Position the upper point angle radians from the
33785
34310
  // lower point
33786
- // @ts-expect-error - TS2542 - Index signature in type 'readonly Coord[]' only permits reading.
33787
34311
  coords[2] = [coords[1][0] + radius * Math.cos(angle + offset), coords[1][1] + radius * Math.sin(angle + offset)];
33788
34312
  return coords;
33789
34313
  }
@@ -44396,7 +44920,7 @@ const init = function init(options) {
44396
44920
 
44397
44921
  // This file is processed by a Rollup plugin (replace) to inject the production
44398
44922
  const libName = "@khanacademy/perseus";
44399
- const libVersion = "24.2.0";
44923
+ const libVersion = "24.3.0";
44400
44924
  addLibraryVersionToPerseusDebug(libName, libVersion);
44401
44925
 
44402
44926
  const apiVersion = {