@khanacademy/perseus 18.0.1 → 19.1.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 (57) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/es/index.css +209 -128
  3. package/dist/es/index.js +1026 -3516
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.css +209 -128
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +917 -3409
  8. package/dist/index.js.map +1 -1
  9. package/dist/mixins/changeable.d.ts +1 -1
  10. package/dist/perseus-api.d.ts +1 -0
  11. package/dist/perseus-types.d.ts +2 -109
  12. package/dist/renderer.d.ts +2 -4
  13. package/dist/server-item-renderer.d.ts +1 -0
  14. package/dist/strings.js +0 -29
  15. package/dist/types.d.ts +4 -4
  16. package/dist/util/geometry.d.ts +2 -0
  17. package/dist/util/graphie.d.ts +107 -1
  18. package/dist/util/math.d.ts +2 -0
  19. package/dist/util/parse-perseus-json.d.ts +3 -3
  20. package/dist/widgets/expression.d.ts +8 -2
  21. package/dist/widgets/label-image.d.ts +2 -0
  22. package/dist/widgets.d.ts +1 -1
  23. package/package.json +3 -4
  24. package/src/__testdata__/renderer.testdata.ts +152 -0
  25. package/src/__tests__/renderer.test.tsx +17 -0
  26. package/src/__tests__/widgets.test.ts +0 -1
  27. package/src/components/math-input.tsx +4 -6
  28. package/src/extra-widgets.ts +0 -2
  29. package/src/index.ts +8 -4
  30. package/src/mixins/changeable.ts +1 -1
  31. package/src/perseus-types.ts +1 -154
  32. package/src/renderer.tsx +14 -8
  33. package/src/styles/perseus-renderer.less +0 -1
  34. package/src/types.ts +9 -5
  35. package/src/util/geometry.test.ts +75 -0
  36. package/src/util/geometry.ts +19 -0
  37. package/src/util/graphie.test.ts +1291 -0
  38. package/src/util/graphie.ts +832 -784
  39. package/src/util/interactive.ts +23 -51
  40. package/src/util/math.test.ts +11 -0
  41. package/src/util/math.ts +8 -0
  42. package/src/util/parse-perseus-json.test.ts +3 -3
  43. package/src/util/parse-perseus-json.ts +4 -4
  44. package/src/util/register-all-widgets-for-testing.ts +1 -3
  45. package/src/widgets/__tests__/__snapshots__/input-number.test.ts.snap +4 -2
  46. package/src/widgets/__tests__/__snapshots__/numeric-input.test.ts.snap +4 -2
  47. package/src/widgets/__tests__/expression-mobile.test.tsx +8 -5
  48. package/src/widgets/__tests__/renderQuestion.tsx +11 -3
  49. package/src/widgets/expression.tsx +1 -23
  50. package/src/widgets/label-image.tsx +0 -1
  51. package/src/widgets.ts +1 -9
  52. package/tsconfig-build.tsbuildinfo +1 -1
  53. package/dist/widgets/transformer.d.ts +0 -94
  54. package/src/styles/widgets/transformer.less +0 -29
  55. package/src/widgets/__stories__/transformer.stories.tsx +0 -14
  56. package/src/widgets/__testdata__/transformer.testdata.ts +0 -107
  57. package/src/widgets/transformer.tsx +0 -2936
@@ -1,9 +1,13 @@
1
1
  /* eslint-disable @babel/no-invalid-this */
2
- import {point as kpoint, vector as kvector} from "@khanacademy/kmath";
2
+ import {
3
+ point as kpoint,
4
+ vector as kvector,
5
+ number as knumber,
6
+ } from "@khanacademy/kmath";
7
+ import {entries} from "@khanacademy/wonder-stuff-core";
3
8
  import $ from "jquery";
4
9
  // eslint-disable-next-line import/no-extraneous-dependencies
5
10
  import Raphael from "raphael";
6
- import _ from "underscore";
7
11
 
8
12
  // Minify Raphael ourselves because IE8 has a problem with the 1.5.2 minified
9
13
  // release
@@ -23,7 +27,7 @@ import type {Coord} from "../interactive2/types";
23
27
 
24
28
  const {processMath} = Tex;
25
29
 
26
- export function polar(r: number | Coord, th: number) {
30
+ export function polar(r: number | Coord, th: number): Coord {
27
31
  if (typeof r === "number") {
28
32
  r = [r, r];
29
33
  }
@@ -31,64 +35,62 @@ export function polar(r: number | Coord, th: number) {
31
35
  return [r[0] * Math.cos(th), r[1] * Math.sin(th)];
32
36
  }
33
37
 
34
- const GraphUtils: any = {
35
- unscaledSvgPath: function (points) {
36
- // If this is an empty closed path, return "" instead of "z", which
37
- // would give an error
38
- if (points[0] === true) {
39
- return "";
40
- }
41
- return $.map(points, function (point, i) {
42
- if (point === true) {
43
- return "z";
44
- }
45
- return (i === 0 ? "M" : "L") + point[0] + " " + point[1];
46
- }).join("");
47
- },
48
-
49
- getDistance: function (point1, point2) {
50
- return kpoint.distanceToPoint(point1, point2);
51
- },
52
-
53
- /**
54
- * Return the difference between two sets of coordinates
55
- */
56
- coordDiff: function (startCoord, endCoord) {
57
- return _.map(endCoord, function (val, i) {
58
- return endCoord[i] - startCoord[i];
59
- });
60
- },
61
-
62
- /**
63
- * Round the given coordinates to a given snap value
64
- * (e.g., nearest 0.2 increment)
65
- */
66
- snapCoord: function (coord, snap) {
67
- return _.map(coord, function (val, i) {
68
- return KhanMath.roundToNearest(snap[i], val);
69
- });
70
- },
38
+ interface RaphaelElement {
39
+ type: string;
40
+ attrs: Record<string, any>;
41
+ node: {
42
+ style: {
43
+ shapeRendering: "crispEdges" | "geometricPrecision";
44
+ };
45
+ };
46
+ }
71
47
 
72
- // Find the angle in degrees between two or three points
73
- findAngle: function (point1, point2, vertex) {
74
- if (vertex === undefined) {
75
- const x = point1[0] - point2[0];
76
- const y = point1[1] - point2[1];
77
- if (!x && !y) {
78
- return 0;
79
- }
80
- return (180 + (Math.atan2(-y, -x) * 180) / Math.PI + 360) % 360;
81
- }
82
- return (
83
- GraphUtils.findAngle(point1, vertex) -
84
- GraphUtils.findAngle(point2, vertex)
85
- );
86
- },
48
+ type PositionedShape = {wrapper: HTMLDivElement; visibleShape: RaphaelElement};
87
49
 
88
- graphs: {},
50
+ export type StyleParams = {
51
+ fill?: string;
52
+ labelDistance?: number;
53
+ opacity?: number;
54
+ step?: Coord;
55
+ stroke?: string;
56
+ strokeWidth?: number;
89
57
  };
90
58
 
91
- class Graphie {
59
+ type LabelPosition =
60
+ | "center"
61
+ | "above"
62
+ | "below"
63
+ | "right"
64
+ | "left"
65
+ | "above right"
66
+ | "above left"
67
+ | "below right"
68
+ | "below left";
69
+
70
+ interface LabelMethod {
71
+ (point: Coord, text: string, position: LabelPosition): any;
72
+ (
73
+ point: Coord,
74
+ text: string,
75
+ position: LabelPosition,
76
+ renderTex: boolean,
77
+ ): any;
78
+ (
79
+ point: Coord,
80
+ text: string,
81
+ position: LabelPosition,
82
+ style: StyleParams,
83
+ ): any;
84
+ (
85
+ point: Coord,
86
+ text: string,
87
+ position: LabelPosition,
88
+ renderTex: boolean,
89
+ style: StyleParams,
90
+ ): any;
91
+ }
92
+
93
+ export class Graphie {
92
94
  el: HTMLElement;
93
95
  #bounds?: GraphBounds;
94
96
  #drawingTransform?: DrawingTransform;
@@ -121,7 +123,7 @@ class Graphie {
121
123
  init(options: {
122
124
  range?: [Interval, Interval];
123
125
  scale?: number | Coord;
124
- isMobile: boolean;
126
+ isMobile?: boolean;
125
127
  }) {
126
128
  let scale = options.scale || [40, 40];
127
129
  scale = typeof scale === "number" ? [scale, scale] : scale;
@@ -156,7 +158,7 @@ class Graphie {
156
158
  this.xpixels = w;
157
159
  this.ypixels = h;
158
160
 
159
- this.isMobile = options.isMobile;
161
+ this.isMobile = options.isMobile ?? false;
160
162
 
161
163
  return this;
162
164
  }
@@ -173,14 +175,37 @@ class Graphie {
173
175
  // - labelStep: [a, b] or number (relative to tick steps)
174
176
  // - yLabelFormat: fn to format label string for y-axis
175
177
  // - xLabelFormat: fn to format label string for x-axis
176
- graphInit(options: any) {
178
+ graphInit(options: {
179
+ range?: [Interval, Interval] | Coord;
180
+ grid?: boolean;
181
+ gridRange?: [Interval, Interval] | Coord;
182
+ scale: Coord | number;
183
+ axes?: true;
184
+ axisArrows?: "<->" | "->" | true | "";
185
+ axisOpacity?: number;
186
+ axisCenter?: Coord;
187
+ axisLabels?: [string, string] | false;
188
+ gridOpacity?: number;
189
+ gridStep?: Coord | number;
190
+ ticks?: boolean;
191
+ tickStep?: Coord | number;
192
+ tickLen?: Coord | number;
193
+ tickOpacity?: number;
194
+ labels?: boolean;
195
+ labelStep?: number;
196
+ labelOpacity?: number;
197
+ labelFormat?: (a: number) => string;
198
+ yLabelFormat?: (y: number) => string;
199
+ xLabelFormat?: (x: number) => string;
200
+ unityLabels?: boolean | [boolean, boolean];
201
+ isMobile?: boolean;
202
+ }) {
177
203
  options = options || {};
178
204
 
179
- $.each(options, function (prop, val: any) {
205
+ for (const [prop, val] of entries(options)) {
180
206
  // allow options to be specified by a single number for shorthand if
181
207
  // the horizontal and vertical components are the same
182
208
  if (
183
- // @ts-expect-error - TS2339 - Property 'match' does not exist on type 'string | number | symbol'.
184
209
  !prop.match(/.*Opacity$/) &&
185
210
  prop !== "range" &&
186
211
  typeof val === "number"
@@ -190,32 +215,21 @@ class Graphie {
190
215
 
191
216
  // allow symmetric ranges to be specified by the absolute values
192
217
  if (prop === "range" || prop === "gridRange") {
193
- if (val.constructor === Array) {
194
- // but don't mandate symmetric ranges
195
- if (val[0].constructor !== Array) {
196
- options[prop] = [
197
- [-val[0], val[0]],
198
- [-val[1], val[1]],
199
- ];
200
- }
201
- } else if (typeof val === "number") {
202
- options[prop] = [
203
- [-val, val],
204
- [-val, val],
205
- ];
206
- }
218
+ options[prop] = normalizeRange(options[prop]);
207
219
  }
208
- });
220
+ }
209
221
 
210
- const range = options.range || [
211
- [-10, 10],
212
- [-10, 10],
213
- ];
214
- const gridRange = options.gridRange || options.range;
222
+ const range = normalizeRange(
223
+ options.range || [
224
+ [-10, 10],
225
+ [-10, 10],
226
+ ],
227
+ );
228
+ const gridRange = normalizeRange(options.gridRange || range);
215
229
  const scale = options.scale || [20, 20];
216
230
  const grid = options.grid != null ? options.grid : true;
217
231
  const gridOpacity = options.gridOpacity || 0.1;
218
- const gridStep = options.gridStep || [1, 1];
232
+ const gridStep = toPair(options.gridStep || [1, 1]);
219
233
  const axes = options.axes != null ? options.axes : true;
220
234
  const axisArrows = options.axisArrows || "";
221
235
  const axisOpacity = options.axisOpacity || 1.0;
@@ -251,7 +265,7 @@ class Graphie {
251
265
  ],
252
266
  ] as [Interval, Interval];
253
267
 
254
- if (!_.isArray(unityLabels)) {
268
+ if (!Array.isArray(unityLabels)) {
255
269
  unityLabels = [unityLabels, unityLabels];
256
270
  }
257
271
 
@@ -366,7 +380,7 @@ class Graphie {
366
380
  opacity: options.isMobile ? 1 : tickOpacity,
367
381
  strokeWidth: 1,
368
382
  },
369
- function () {
383
+ () => {
370
384
  // horizontal axis
371
385
  let step = gridStep[0] * tickStep[0];
372
386
  let len = tickLen[0] / scale[1];
@@ -424,7 +438,6 @@ class Graphie {
424
438
  y += step
425
439
  ) {
426
440
  if (y < stop || !axisArrows) {
427
- // @ts-expect-error - TS2683 - 'this' implicitly has type 'any' because it does not have a type annotation.
428
441
  this.line(
429
442
  [-len + axisCenter[0], y],
430
443
  [
@@ -443,7 +456,6 @@ class Graphie {
443
456
  y -= step
444
457
  ) {
445
458
  if (y > start || !axisArrows) {
446
- // @ts-expect-error - TS2683 - 'this' implicitly has type 'any' because it does not have a type annotation.
447
459
  this.line(
448
460
  [-len + axisCenter[0], y],
449
461
  [
@@ -552,7 +564,7 @@ class Graphie {
552
564
  }
553
565
  }
554
566
 
555
- drawingTransform(): DrawingTransform {
567
+ private drawingTransform(): DrawingTransform {
556
568
  if (this.#drawingTransform == null) {
557
569
  throw new Error(
558
570
  "Can't get drawingTransform of an uninitialized Graphie",
@@ -561,7 +573,7 @@ class Graphie {
561
573
  return this.#drawingTransform;
562
574
  }
563
575
 
564
- bounds(): GraphBounds {
576
+ private bounds(): GraphBounds {
565
577
  if (this.#bounds == null) {
566
578
  throw new Error("Can't get bounds of an uninitialized Graphie");
567
579
  }
@@ -573,39 +585,517 @@ class Graphie {
573
585
 
574
586
  if (typeof fn === "function") {
575
587
  const oldStyle = this.currentStyle;
576
- this.currentStyle = $.extend({}, this.currentStyle, processed);
588
+ this.currentStyle = {...this.currentStyle, ...processed};
577
589
  const result = fn.call(this);
578
590
  this.currentStyle = oldStyle;
579
591
  return result;
580
592
  }
581
- $.extend(this.currentStyle, processed);
593
+ Object.assign(this.currentStyle, processed);
582
594
  }
583
595
 
584
- label(point: any, text: any, direction: any, latex?: any) {}
596
+ grid(xr: Interval, yr: Interval, style?: StyleParams): unknown {
597
+ return this.withStyle(style, () => {
598
+ const step: any = this.currentStyle.step || [1, 1];
599
+ const set = this.raphael.set();
600
+
601
+ let x = step[0] * Math.ceil(xr[0] / step[0]);
602
+ for (; x <= xr[1]; x += step[0]) {
603
+ set.push(this.line([x, yr[0]], [x, yr[1]]));
604
+ }
605
+
606
+ let y = step[1] * Math.ceil(yr[0] / step[1]);
607
+ for (; y <= yr[1]; y += step[1]) {
608
+ set.push(this.line([xr[0], y], [xr[1], y]));
609
+ }
585
610
 
586
- grid(xr: any, yr: any, styleAttributes: any) {}
611
+ return set;
612
+ });
613
+ }
587
614
 
588
- // path is a stub that gets overwritten with a function from drawingTools
589
- // in createGraphie
590
- path(points: any) {}
615
+ arc(
616
+ center: Coord,
617
+ radius: Coord,
618
+ startAngle: number,
619
+ endAngle: number,
620
+ sector: boolean,
621
+ style?: StyleParams,
622
+ ): RaphaelElement {
623
+ return this.withStyle(style, () => {
624
+ startAngle = ((startAngle % 360) + 360) % 360;
625
+ endAngle = ((endAngle % 360) + 360) % 360;
626
+
627
+ const cent = this.scalePoint(center);
628
+ const radii = this.scaleVector(radius);
629
+ const startVector = polar(radius, startAngle);
630
+ const endVector = polar(radius, endAngle);
631
+
632
+ // We round the coordinates to make testing easier, because trig
633
+ // operations return unround numbers.
634
+ // TODO(benchristel): move rounding to scalePoint?
635
+ const round = (x) => knumber.round(x, 6);
636
+ const startPoint = this.scalePoint([
637
+ round(center[0] + startVector[0]),
638
+ round(center[1] + startVector[1]),
639
+ ]);
640
+ const endPoint = this.scalePoint([
641
+ round(center[0] + endVector[0]),
642
+ round(center[1] + endVector[1]),
643
+ ]);
644
+
645
+ const largeAngle =
646
+ (((endAngle - startAngle) % 360) + 360) % 360 > 180;
647
+
648
+ return this.raphael.path(
649
+ "M" +
650
+ startPoint.join(" ") +
651
+ "A" +
652
+ radii.join(" ") +
653
+ " 0 " + // ellipse rotation
654
+ (largeAngle ? 1 : 0) +
655
+ " 0 " + // sweep flag
656
+ endPoint.join(" ") +
657
+ (sector ? "L" + cent.join(" ") + "z" : ""),
658
+ );
659
+ });
660
+ }
591
661
 
592
- // line is a stub that gets overwritten with a function from drawingTools
593
- // in createGraphie
594
- line(start: any, end: any) {}
662
+ circle(center: Coord, radius: number, style?: StyleParams) {
663
+ return this.withStyle(style, () =>
664
+ this.raphael.ellipse(
665
+ ...this.scalePoint(center),
666
+ ...this.scaleVector([radius, radius]),
667
+ ),
668
+ );
669
+ }
595
670
 
596
- svgPath = (points: any, alreadyScaled) => {
597
- return $.map(points, (point, i) => {
598
- if (point === true) {
599
- return "z";
671
+ // (x, y) is coordinate of bottom left corner
672
+ rect(x, y, width, height, style?: StyleParams) {
673
+ return this.withStyle(style, () => {
674
+ // Raphael needs (x, y) to be coordinate of upper left corner
675
+ const corner = this.scalePoint([x, y + height]);
676
+ const dims = this.scaleVector([width, height]);
677
+ const elem = this.raphael.rect(...corner.concat(dims));
678
+
679
+ if (this.isMobile) {
680
+ elem.node.style.shapeRendering = "crispEdges";
600
681
  }
601
- const scaled = alreadyScaled ? point : this.scalePoint(point);
602
- return (
603
- (i === 0 ? "M" : "L") +
604
- KhanMath.bound(scaled[0]) +
605
- " " +
606
- KhanMath.bound(scaled[1])
682
+
683
+ return elem;
684
+ });
685
+ }
686
+
687
+ ellipse(center, radii, style?: StyleParams) {
688
+ return this.withStyle(style, () =>
689
+ this.raphael.ellipse(
690
+ ...this.scalePoint(center).concat(this.scaleVector(radii)),
691
+ ),
692
+ );
693
+ }
694
+
695
+ fixedEllipse(
696
+ center: number | Coord,
697
+ // Different type than Coord, this is radiusX, radiusY
698
+ radii: number | [number, number],
699
+ maxScale: number,
700
+ padding: number,
701
+ style?: StyleParams,
702
+ ): PositionedShape {
703
+ return this.withStyle(style, () => {
704
+ // Scale point and radius
705
+ const scaledPoint = this.scalePoint(center);
706
+ const scaledRadii = this.scaleVector(radii);
707
+
708
+ const width = 2 * scaledRadii[0] * maxScale + padding;
709
+ const height = 2 * scaledRadii[1] * maxScale + padding;
710
+
711
+ // Calculate absolute left, top
712
+ const left = scaledPoint[0] - width / 2;
713
+ const top = scaledPoint[1] - height / 2;
714
+
715
+ // Wrap in <div>
716
+ const wrapper = document.createElement("div");
717
+ $(wrapper).css({
718
+ position: "absolute",
719
+ width: width + "px",
720
+ height: height + "px",
721
+ left: left + "px",
722
+ top: top + "px",
723
+ });
724
+ // wrapper.setAttribute("data-graphie-type", "ellipse");
725
+
726
+ // Create Raphael canvas
727
+ const localRaphael = Raphael(wrapper, width, height);
728
+ const visibleShape = localRaphael.ellipse(
729
+ width / 2,
730
+ height / 2,
731
+ scaledRadii[0],
732
+ scaledRadii[1],
733
+ );
734
+
735
+ return {
736
+ wrapper: wrapper,
737
+ visibleShape: visibleShape,
738
+ };
739
+ });
740
+ }
741
+
742
+ private unstyledPath(points: Coord[]): RaphaelElement {
743
+ const p = this.raphael.path(this.svgPath(points));
744
+ p.graphiePath = points;
745
+ return p;
746
+ }
747
+
748
+ path(points: Coord[], style?: StyleParams): RaphaelElement {
749
+ return this.withStyle(style, () => {
750
+ return this.unstyledPath(points);
751
+ });
752
+ }
753
+
754
+ // fixedPath is a stub that gets overwritten with a function from
755
+ // drawingTools in createGraphie
756
+ fixedPath(
757
+ points: Coord[],
758
+ center: Coord | null,
759
+ createPath: (scaledPoints: Coord[]) => string,
760
+ ): PositionedShape {
761
+ points = points.map(this.scalePoint);
762
+ center = center ? this.scalePoint(center) : null;
763
+ createPath = createPath || this.svgPath;
764
+
765
+ // Compute bounding box
766
+ const xs = points.map((p) => p[0]);
767
+ const ys = points.map((p) => p[1]);
768
+ const pathLeft = Math.min(...xs);
769
+ const pathRight = Math.max(...xs);
770
+ const pathTop = Math.min(...ys);
771
+ const pathBottom = Math.max(...ys);
772
+
773
+ // Apply padding to line
774
+ const padding: Coord = [4, 4];
775
+
776
+ // Calculate and apply additional offset
777
+ const topLeftOfBoundingBox: Coord = [pathLeft, pathTop];
778
+
779
+ // Apply padding and offset to points to convert from
780
+ // canvas coordinates to pixel coordinates relative to bounding box
781
+ points = points.map(function (point) {
782
+ return kvector.add(
783
+ kvector.subtract(point, topLeftOfBoundingBox),
784
+ kvector.scale(padding, 0.5),
785
+ );
786
+ });
787
+
788
+ // Calculate <div> dimensions
789
+ const width = pathRight - pathLeft + padding[0];
790
+ const height = pathBottom - pathTop + padding[1];
791
+ const left = topLeftOfBoundingBox[0] - padding[0] / 2;
792
+ const top = topLeftOfBoundingBox[1] - padding[1] / 2;
793
+
794
+ // Create <div>
795
+ const wrapper = document.createElement("div");
796
+ $(wrapper).css({
797
+ position: "absolute",
798
+ width: width + "px",
799
+ height: height + "px",
800
+ left: left + "px",
801
+ top: top + "px",
802
+ // If user specified a center, set it
803
+ // NOTE(kevinb): jQuery doesn't like that `transformOrigin `could be `null`
804
+ // so we cast to `any` here.
805
+ transformOrigin: center
806
+ ? width / 2 +
807
+ center[0] +
808
+ "px " +
809
+ (height / 2 + center[1]) +
810
+ "px"
811
+ : null,
812
+ } as any);
813
+
814
+ // Create Raphael canvas
815
+ const localRaphael = Raphael(wrapper, width, height);
816
+
817
+ // Calculate path
818
+ const visibleShape = localRaphael.path(createPath(points));
819
+
820
+ return {
821
+ wrapper: wrapper,
822
+ visibleShape: visibleShape,
823
+ };
824
+ }
825
+
826
+ scaledPath(points: Coord[], style?: StyleParams): RaphaelElement {
827
+ return this.withStyle(style, () => {
828
+ const p = this.raphael.path(
829
+ this.svgPath(points, /* alreadyScaled */ true),
607
830
  );
608
- }).join("");
831
+ p.graphiePath = points;
832
+ return p;
833
+ });
834
+ }
835
+
836
+ line(start: Coord, end: Coord, style?: StyleParams): RaphaelElement {
837
+ return this.withStyle(style, () => {
838
+ const l = this.unstyledPath([start, end]);
839
+
840
+ if (this.isMobile) {
841
+ l.node.style.shapeRendering = "crispEdges";
842
+ }
843
+
844
+ return l;
845
+ });
846
+ }
847
+
848
+ parabola(
849
+ a: number,
850
+ b: number,
851
+ c: number,
852
+ style?: StyleParams,
853
+ ): RaphaelElement {
854
+ return this.withStyle(style, () =>
855
+ this.raphael.path(this.svgParabolaPath(a, b, c)),
856
+ );
857
+ }
858
+
859
+ fixedLine(start: Coord, end: Coord, thickness: number): PositionedShape {
860
+ // Apply padding to line
861
+ const padding: Coord = [thickness, thickness];
862
+
863
+ // Scale points to get values in pixels
864
+ start = this.scalePoint(start);
865
+ end = this.scalePoint(end);
866
+
867
+ // Calculate and apply additional offset
868
+ const extraOffset: Coord = [
869
+ Math.min(start[0], end[0]),
870
+ Math.min(start[1], end[1]),
871
+ ];
872
+
873
+ // Apply padding and offset to start, end points
874
+ start = kvector.add(
875
+ kvector.subtract(start, extraOffset),
876
+ kvector.scale(padding, 0.5),
877
+ );
878
+ end = kvector.add(
879
+ kvector.subtract(end, extraOffset),
880
+ kvector.scale(padding, 0.5),
881
+ );
882
+
883
+ // Calculate <div> dimensions
884
+ const left = extraOffset[0] - padding[0] / 2;
885
+ const top = extraOffset[1] - padding[1] / 2;
886
+ const width = Math.abs(start[0] - end[0]) + padding[0];
887
+ const height = Math.abs(start[1] - end[1]) + padding[1];
888
+
889
+ // Create <div>
890
+ const wrapper = document.createElement("div");
891
+ $(wrapper).css({
892
+ position: "absolute",
893
+ width: width + "px",
894
+ height: height + "px",
895
+ left: left + "px",
896
+ top: top + "px",
897
+ // Outsiders should feel like the line's 'origin' (i.e., for
898
+ // rotation) is the starting point
899
+ transformOrigin: start[0] + "px " + start[1] + "px",
900
+ });
901
+
902
+ // Create Raphael canvas
903
+ const localRaphael = Raphael(wrapper, width, height);
904
+
905
+ // Calculate path
906
+ const path =
907
+ "M" + start[0] + " " + start[1] + " " + "L" + end[0] + " " + end[1];
908
+ const visibleShape = localRaphael.path(path);
909
+ visibleShape.graphiePath = [start, end];
910
+
911
+ return {
912
+ wrapper: wrapper,
913
+ visibleShape: visibleShape,
914
+ };
915
+ }
916
+
917
+ sinusoid(
918
+ a: number,
919
+ b: number,
920
+ c: number,
921
+ d: number,
922
+ style?: StyleParams,
923
+ ): RaphaelElement {
924
+ return this.withStyle(style, () =>
925
+ // Plot a sinusoid of the form: f(x) = a * sin(b * x - c) + d
926
+ this.raphael.path(this.svgSinusoidPath(a, b, c, d)),
927
+ );
928
+ }
929
+
930
+ label: LabelMethod = (
931
+ point: Coord,
932
+ text: string,
933
+ direction:
934
+ | "center"
935
+ | "above"
936
+ | "below"
937
+ | "right"
938
+ | "left"
939
+ | "above right"
940
+ | "above left"
941
+ | "below right"
942
+ | "below left",
943
+ arg4?: boolean | StyleParams,
944
+ arg5?: StyleParams,
945
+ ): any => {
946
+ const style = typeof arg4 === "object" ? arg4 : arg5;
947
+ const latex = typeof arg4 === "boolean" ? arg4 : true;
948
+ return this.withStyle(style, () => {
949
+ const $span = $("<span>").addClass("graphie-label");
950
+
951
+ const pad = this.currentStyle["label-distance"];
952
+
953
+ $span
954
+ .css({
955
+ position: "absolute",
956
+ padding: (pad != null ? pad : 7) + "px",
957
+ color: "black",
958
+ })
959
+ .data("labelDirection", direction)
960
+ .appendTo(this.el);
961
+
962
+ // @ts-expect-error - TS2339 - Property 'setPosition' does not exist on type 'JQuery<HTMLElement>'.
963
+ $span.setPosition = (point) => {
964
+ const scaledPoint = this.scalePoint(point);
965
+ $span.css({
966
+ left: scaledPoint[0],
967
+ top: scaledPoint[1],
968
+ });
969
+ };
970
+
971
+ // @ts-expect-error - TS2339 - Property 'setPosition' does not exist on type 'JQuery<HTMLElement>'.
972
+ $span.setPosition(point);
973
+
974
+ const span = $span[0];
975
+
976
+ // @ts-expect-error - TS2339 - Property 'processMath' does not exist on type 'JQuery<HTMLElement>'.
977
+ $span.processMath = function (math, force) {
978
+ processMath(span, math, force, function () {
979
+ const width = span.scrollWidth;
980
+ const height = span.scrollHeight;
981
+ setLabelMargins(span, [width, height]);
982
+ });
983
+ };
984
+
985
+ // @ts-expect-error - TS2339 - Property 'processText' does not exist on type 'JQuery<HTMLElement>'.
986
+ $span.processText = function (text: any) {
987
+ $span.html(text);
988
+ const width = span.scrollWidth;
989
+ const height = span.scrollHeight;
990
+ setLabelMargins(span, [width, height]);
991
+ };
992
+
993
+ if (latex) {
994
+ // @ts-expect-error - TS2339 - Property 'processMath' does not exist on type 'JQuery<HTMLElement>'.
995
+ $span.processMath(text, /* force */ false);
996
+ } else {
997
+ // @ts-expect-error - TS2339 - Property 'processText' does not exist on type 'JQuery<HTMLElement>'.
998
+ $span.processText(text);
999
+ }
1000
+
1001
+ return $span;
1002
+ });
1003
+ };
1004
+
1005
+ plotParametric(
1006
+ fn: (t: number) => Coord,
1007
+ range: Interval,
1008
+ style?: StyleParams,
1009
+ ): RaphaelElement {
1010
+ return this.withStyle(style, () => {
1011
+ // We truncate to 500,000, since anything bigger causes
1012
+ // overflow in the firefox svg renderer. This is safe
1013
+ // since 500,000 is outside the viewport anyway. We
1014
+ // write these functions the way we do to handle undefined.
1015
+ const clip = (xy) => {
1016
+ if (Math.abs(xy[1]) > 500000) {
1017
+ return [xy[0], Math.min(Math.max(xy[1], -500000), 500000)];
1018
+ }
1019
+ return xy;
1020
+ };
1021
+ const clippedFn = (x) => clip(fn(x));
1022
+
1023
+ const min = range[0];
1024
+ const max = range[1];
1025
+ let step = (max - min) / (this.currentStyle["plot-points"] || 800);
1026
+ if (step === 0) {
1027
+ step = 1;
1028
+ }
1029
+
1030
+ const paths = this.raphael.set();
1031
+ let points: Coord[] = [];
1032
+ let lastY = clippedFn(min)[1];
1033
+
1034
+ for (let t = min; t <= max; t += step) {
1035
+ const point = clippedFn(t);
1036
+ const y = point[1];
1037
+
1038
+ // Find points where it flips
1039
+ if (
1040
+ // if there is an asymptote here, meaning that the graph
1041
+ // switches signs and has a large difference
1042
+ (y > 0 !== lastY > 0 &&
1043
+ Math.abs(y - lastY) >
1044
+ 2 * this.drawingTransform().pixelsPerUnitY()) ||
1045
+ // or the function is undefined
1046
+ isNaN(y)
1047
+ ) {
1048
+ // split the path at this point, and draw it
1049
+ paths.push(this.unstyledPath(points));
1050
+ // restart the path, excluding this point
1051
+ points = [];
1052
+ } else {
1053
+ // otherwise, just add the point to the path
1054
+ points.push(point);
1055
+ }
1056
+
1057
+ lastY = y;
1058
+ }
1059
+
1060
+ paths.push(this.unstyledPath(points));
1061
+
1062
+ return paths;
1063
+ });
1064
+ }
1065
+
1066
+ plot(
1067
+ fn: (x: number) => number,
1068
+ range: Interval,
1069
+ style?: StyleParams,
1070
+ ): RaphaelElement {
1071
+ return this.withStyle(style, () => {
1072
+ const min = range[0];
1073
+ const max = range[1];
1074
+ if (!this.currentStyle["plot-points"]) {
1075
+ this.currentStyle["plot-points"] =
1076
+ 2 * (max - min) * this.drawingTransform().pixelsPerUnitX();
1077
+ }
1078
+
1079
+ const parametricFn = (x): Coord => [x, fn(x)];
1080
+ return this.plotParametric(parametricFn, range);
1081
+ });
1082
+ }
1083
+
1084
+ svgPath = (points: (Coord | true)[], alreadyScaled?: boolean) => {
1085
+ return points
1086
+ .map((point, i) => {
1087
+ if (point === true) {
1088
+ return "z";
1089
+ }
1090
+ const scaled = alreadyScaled ? point : this.scalePoint(point);
1091
+ return (
1092
+ (i === 0 ? "M" : "L") +
1093
+ KhanMath.bound(scaled[0]) +
1094
+ " " +
1095
+ KhanMath.bound(scaled[1])
1096
+ );
1097
+ })
1098
+ .join("");
609
1099
  };
610
1100
 
611
1101
  svgParabolaPath = (a: any, b: any, c: any) => {
@@ -615,11 +1105,10 @@ class Graphie {
615
1105
 
616
1106
  // If points are collinear, plot a line instead
617
1107
  if (a === 0) {
618
- const points = [
1108
+ const points: Coord[] = [
619
1109
  [this.bounds().xMin, computeParabola(this.bounds().xMin)],
620
1110
  [this.bounds().xMax, computeParabola(this.bounds().xMax)],
621
1111
  ];
622
- // @ts-expect-error - TS2554 - Expected 2 arguments, but got 1.
623
1112
  return this.svgPath(points);
624
1113
  }
625
1114
 
@@ -639,17 +1128,16 @@ class Graphie {
639
1128
  const point = [xPoint, computeParabola(xPoint)];
640
1129
 
641
1130
  // Calculate SVG 'control' point, defined by spec
642
- const control = [vertex[0], vertex[1] - (point[1] - vertex[1])];
1131
+ const control: Coord = [vertex[0], vertex[1] - (point[1] - vertex[1])];
643
1132
 
644
1133
  // Calculate mirror points across parabola's axis of symmetry
645
1134
  const dx = Math.abs(vertex[0] - point[0]);
646
- const left = [vertex[0] - dx, point[1]];
647
- const right = [vertex[0] + dx, point[1]];
1135
+ const left: Coord = [vertex[0] - dx, point[1]];
1136
+ const right: Coord = [vertex[0] + dx, point[1]];
648
1137
 
649
1138
  // Scale and bound
650
- // @ts-expect-error - TS2345 - Argument of type '(point: number | Coord) => Coord' is not assignable to parameter of type 'Iteratee<any[][], any, any[]>'.
651
- const points = _.map([left, control, right], this.scalePoint);
652
- const values = _.map(_.flatten(points), KhanMath.bound);
1139
+ const points = [left, control, right].map(this.scalePoint);
1140
+ const values = points.flat().map(KhanMath.bound);
653
1141
  return (
654
1142
  "M" +
655
1143
  values[0] +
@@ -666,7 +1154,7 @@ class Graphie {
666
1154
  );
667
1155
  };
668
1156
 
669
- svgSinusoidPath = (a, b, c: any, d: any) => {
1157
+ svgSinusoidPath = (a: number, b: number, c: number, d: number) => {
670
1158
  // Plot a sinusoid of the form: f(x) = a * sin(b * x - c) + d
671
1159
  const quarterPeriod = Math.abs(Math.PI / (2 * b));
672
1160
 
@@ -699,9 +1187,8 @@ class Graphie {
699
1187
  computeSine(x1),
700
1188
  ];
701
1189
 
702
- // Zip and scale
703
- // @ts-expect-error - TS2345 - Argument of type '(point: number | Coord) => Coord' is not assignable to parameter of type 'Iteratee<any[][], any, any[]>'.
704
- return _.map(_.zip(xCoords, yCoords), this.scalePoint);
1190
+ const points = kvector.zip(xCoords, yCoords);
1191
+ return points.map(this.scalePoint);
705
1192
  };
706
1193
 
707
1194
  // How many quarter-periods do we need to span the graph?
@@ -752,10 +1239,101 @@ class Graphie {
752
1239
  return path;
753
1240
  };
754
1241
 
755
- scalePoint = (point: number | Coord): Coord => {
756
- return this.drawingTransform().scalePoint(point);
757
- };
758
-
1242
+ private withStyle<T>(style: StyleParams | undefined, fn: () => T): T {
1243
+ const oldStyle = this.currentStyle;
1244
+ this.currentStyle = {
1245
+ ...this.currentStyle,
1246
+ ...this.processAttributes(style),
1247
+ };
1248
+ const result = this.postprocessDrawingResult(fn());
1249
+ this.currentStyle = oldStyle;
1250
+ return result;
1251
+ }
1252
+
1253
+ private postprocessDrawingResult(result: any): any {
1254
+ // Bad heuristic for recognizing Raphael elements and sets
1255
+ const type = result.constructor.prototype;
1256
+ if (type === Raphael.el || type === Raphael.st) {
1257
+ result.attr(this.currentStyle);
1258
+
1259
+ if (this.currentStyle.arrows) {
1260
+ result = this.addArrowheads(result);
1261
+ }
1262
+ } else if (result instanceof $) {
1263
+ // We assume that if it's not a Raphael element/set, it
1264
+ // does not contain SVG.
1265
+ // @ts-expect-error - TS2339 - Property 'css' does not exist on type '{}'.
1266
+ result.css({
1267
+ ...this.currentStyle,
1268
+ ...SVG_SPECIFIC_STYLE_MASK,
1269
+ });
1270
+ }
1271
+
1272
+ return result;
1273
+ }
1274
+
1275
+ private addArrowheads(path: any) {
1276
+ const type = path.constructor.prototype;
1277
+
1278
+ if (type === Raphael.el) {
1279
+ if (
1280
+ path.type === "path" &&
1281
+ typeof path.arrowheadsDrawn === "undefined"
1282
+ ) {
1283
+ const w = path.attr("stroke-width");
1284
+ const s = 0.6 + 0.4 * w;
1285
+ const l = path.getTotalLength();
1286
+ const set = this.raphael.set();
1287
+ const head = this.raphael.path(
1288
+ this.isMobile
1289
+ ? "M-4,4 C-4,4 -0.25,0 -0.25,0 C-0.25,0 -4,-4 -4,-4"
1290
+ : "M-3 4 C-2.75 2.5 0 0.25 0.75 0C0 -0.25 -2.75 -2.5 -3 -4",
1291
+ );
1292
+ const end = path.getPointAtLength(l - 0.4);
1293
+ const almostTheEnd = path.getPointAtLength(l - 0.75 * s);
1294
+ const angle =
1295
+ (Math.atan2(
1296
+ end.y - almostTheEnd.y,
1297
+ end.x - almostTheEnd.x,
1298
+ ) *
1299
+ 180) /
1300
+ Math.PI;
1301
+ const attrs = path.attr();
1302
+ delete attrs.path;
1303
+
1304
+ let subpath = path.getSubpath(0, l - 0.75 * s);
1305
+ subpath = this.raphael.path(subpath).attr(attrs);
1306
+ subpath.arrowheadsDrawn = true;
1307
+ path.remove();
1308
+
1309
+ // For some unknown reason 0 doesn't work for the rotation
1310
+ // origin so we use a tiny number.
1311
+ head.rotate(angle, this.isMobile ? 1e-5 : 0.75, 0)
1312
+ .scale(s, s, 0.75, 0)
1313
+ .translate(almostTheEnd.x, almostTheEnd.y)
1314
+ .attr(attrs)
1315
+ .attr({
1316
+ "stroke-linejoin": "round",
1317
+ "stroke-linecap": "round",
1318
+ });
1319
+
1320
+ head.arrowheadsDrawn = true;
1321
+ set.push(subpath);
1322
+ set.push(head);
1323
+ return set;
1324
+ }
1325
+ } else if (type === Raphael.st) {
1326
+ for (let i = 0, l = path.items.length; i < l; i++) {
1327
+ this.addArrowheads(path.items[i]);
1328
+ }
1329
+ }
1330
+ return path;
1331
+ }
1332
+
1333
+ scalePoint = (point: number | Coord): Coord => {
1334
+ return this.drawingTransform().scalePoint(point);
1335
+ };
1336
+
759
1337
  scaleVector = (point: number | Coord) => {
760
1338
  return this.drawingTransform().scaleVector(point);
761
1339
  };
@@ -768,7 +1346,7 @@ class Graphie {
768
1346
  return this.drawingTransform().unscaleVector(point);
769
1347
  };
770
1348
 
771
- processAttributes(attrs: any) {
1349
+ private processAttributes(attrs: any) {
772
1350
  const transformers = {
773
1351
  scale: (scale) => {
774
1352
  this.drawingTransform().setScale(scale);
@@ -805,14 +1383,13 @@ class Graphie {
805
1383
  } as const;
806
1384
 
807
1385
  const processed: Record<string, any> = {};
808
- $.each(attrs || {}, function (key, value) {
1386
+ Object.entries(attrs || {}).forEach(function ([key, value]) {
809
1387
  const transformer = transformers[key];
810
1388
 
811
1389
  if (typeof transformer === "function") {
812
- $.extend(processed, transformer(value));
1390
+ Object.assign(processed, transformer(value));
813
1391
  } else {
814
- const dasherized = key
815
- // @ts-expect-error - TS2339 - Property 'replace' does not exist on type 'string | number | symbol'.
1392
+ const dasherized = String(key)
816
1393
  .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
817
1394
  .replace(/([a-z\d])([A-Z])/g, "$1-$2")
818
1395
  .toLowerCase();
@@ -824,8 +1401,6 @@ class Graphie {
824
1401
  }
825
1402
  }
826
1403
 
827
- GraphUtils.Graphie = Graphie;
828
-
829
1404
  const labelDirections = {
830
1405
  center: [-0.5, -0.5],
831
1406
  above: [-0.5, -1.0],
@@ -838,689 +1413,162 @@ const labelDirections = {
838
1413
  "above left": [-1.0, -1.0],
839
1414
  } as const;
840
1415
 
841
- /**
842
- * Safari applies some SVG-specific styles to things that are not SVGs, so we
843
- * need to exclude those styles from things that are not SVGs.
844
- *
845
- * To see this behavior in action, open https://codepen.io/anon/pen/zENEoa in
846
- * Safari.
847
- *
848
- * Usage `$.extend({}, someStyles, SVG_SPECIFIC_STYLE_MASK)`
849
- */
850
- const SVG_SPECIFIC_STYLE_MASK = {
851
- "stroke-width": null,
852
- } as const;
853
-
854
- GraphUtils.createGraphie = function (el: any) {
855
- const thisGraphie = new Graphie(el);
856
-
857
- const setLabelMargins = function (span: any, size: Array<any>) {
858
- const $span = $(span);
859
- const direction = $span.data("labelDirection");
860
- let [width, height] = size;
861
- // This can happen when a span
862
- // is invisible but we still want to update the CSS. At worst, we will
863
- // be off by a few pixels instead of in a different position entirely.
864
- if (width === 0 && height === 0) {
865
- [width, height] = [1, 1];
866
- Log.log("Label size was 0x0 in graphie.js; using 1x1 instead");
867
- }
868
- $span.css("visibility", "");
869
-
870
- if (typeof direction === "number") {
871
- const x = Math.cos(direction);
872
- const y = Math.sin(direction);
873
-
874
- const scale = Math.min(
875
- width / 2 / Math.abs(x),
876
- height / 2 / Math.abs(y),
877
- );
878
-
879
- $span.css({
880
- marginLeft: -width / 2 + x * scale,
881
- marginTop: -height / 2 - y * scale,
882
- });
1416
+ // A range for a Graphie can be specified in one of three forms:
1417
+ // - A pair of intervals, [[xMin, xMax], [yMin, yMax]]. This is the
1418
+ // "normalized" form.
1419
+ // - A pair of magnitudes for the x and y ranges, respectively. E.g. [5, 10]
1420
+ // is equivalent to [[-5, 5], [-10, 10]].
1421
+ // - A single magnitude to be used for both x and y. E.g. 10 is equivalent to
1422
+ // [[-10, 10], [-10, 10]]
1423
+ type RangeSpecifier = [Interval, Interval] | Coord | number;
1424
+
1425
+ // Overload normalizeRange to specify that it only returns undefined if its
1426
+ // argument is undefined.
1427
+ export function normalizeRange(range: RangeSpecifier): [Interval, Interval];
1428
+ export function normalizeRange(
1429
+ range: RangeSpecifier | undefined,
1430
+ ): [Interval, Interval] | undefined;
1431
+
1432
+ // normalizeRange converts a RangeSpecifier into a pair of Intervals that give
1433
+ // the x and y ranges, respectively
1434
+ export function normalizeRange(
1435
+ range: RangeSpecifier | undefined,
1436
+ ): [Interval, Interval] | undefined {
1437
+ function normalizeInterval(magnitude: number | Interval): Interval {
1438
+ if (typeof magnitude === "number") {
1439
+ return [-magnitude, magnitude];
883
1440
  } else {
884
- const multipliers = labelDirections[direction || "center"];
885
- $span.css({
886
- marginLeft: Math.round(width * multipliers[0]),
887
- marginTop: Math.round(height * multipliers[1]),
888
- });
1441
+ return magnitude;
889
1442
  }
890
- };
891
-
892
- // `svgPath` is independent of graphie range, so we export it independently
893
- GraphUtils.svgPath = thisGraphie.svgPath;
894
-
895
- const addArrowheads = function arrows(path: any) {
896
- const type = path.constructor.prototype;
897
-
898
- if (type === Raphael.el) {
899
- if (
900
- path.type === "path" &&
901
- typeof path.arrowheadsDrawn === "undefined"
902
- ) {
903
- const w = path.attr("stroke-width");
904
- const s = 0.6 + 0.4 * w;
905
- const l = path.getTotalLength();
906
- const set = thisGraphie.raphael.set();
907
- const head = thisGraphie.raphael.path(
908
- thisGraphie.isMobile
909
- ? "M-4,4 C-4,4 -0.25,0 -0.25,0 C-0.25,0 -4,-4 -4,-4"
910
- : "M-3 4 C-2.75 2.5 0 0.25 0.75 0C0 -0.25 -2.75 -2.5 -3 -4",
911
- );
912
- const end = path.getPointAtLength(l - 0.4);
913
- const almostTheEnd = path.getPointAtLength(l - 0.75 * s);
914
- const angle =
915
- (Math.atan2(
916
- end.y - almostTheEnd.y,
917
- end.x - almostTheEnd.x,
918
- ) *
919
- 180) /
920
- Math.PI;
921
- const attrs = path.attr();
922
- delete attrs.path;
923
-
924
- let subpath = path.getSubpath(0, l - 0.75 * s);
925
- subpath = thisGraphie.raphael.path(subpath).attr(attrs);
926
- subpath.arrowheadsDrawn = true;
927
- path.remove();
928
-
929
- // For some unknown reason 0 doesn't work for the rotation
930
- // origin so we use a tiny number.
931
- head.rotate(angle, thisGraphie.isMobile ? 1e-5 : 0.75, 0)
932
- .scale(s, s, 0.75, 0)
933
- .translate(almostTheEnd.x, almostTheEnd.y)
934
- .attr(attrs)
935
- .attr({
936
- "stroke-linejoin": "round",
937
- "stroke-linecap": "round",
938
- });
939
-
940
- head.arrowheadsDrawn = true;
941
- set.push(subpath);
942
- set.push(head);
943
- return set;
944
- }
945
- } else if (type === Raphael.st) {
946
- for (let i = 0, l = path.items.length; i < l; i++) {
947
- arrows(path.items[i]);
948
- }
949
- }
950
- return path;
951
- };
952
-
953
- function circle(center, radius) {
954
- return thisGraphie.raphael.ellipse(
955
- ...thisGraphie
956
- .scalePoint(center)
957
- .concat(thisGraphie.scaleVector([radius, radius])),
958
- );
959
1443
  }
960
1444
 
961
- // (x, y) is coordinate of bottom left corner
962
- function rect(x, y, width, height) {
963
- // Raphael needs (x, y) to be coordinate of upper left corner
964
- const corner = thisGraphie.scalePoint([x, y + height]);
965
- const dims = thisGraphie.scaleVector([width, height]);
966
- const elem = thisGraphie.raphael.rect(...corner.concat(dims));
967
-
968
- if (thisGraphie.isMobile) {
969
- elem.node.style.shapeRendering = "crispEdges";
1445
+ function getXAndYRanges(
1446
+ range: RangeSpecifier,
1447
+ ): Coord | [Interval, Interval] {
1448
+ if (Array.isArray(range)) {
1449
+ return range;
1450
+ } else {
1451
+ return [range, range];
970
1452
  }
971
-
972
- return elem;
973
1453
  }
974
1454
 
975
- function ellipse(center, radii) {
976
- return thisGraphie.raphael.ellipse(
977
- ...thisGraphie
978
- .scalePoint(center)
979
- .concat(thisGraphie.scaleVector(radii)),
980
- );
1455
+ if (range == null) {
1456
+ return range;
981
1457
  }
982
1458
 
983
- function fixedEllipse(
984
- center: number | Coord,
985
- // Different type than Coord, this is radiusX, radiusY
986
- radii: number | [number, number],
987
- maxScale: number,
988
- padding: number,
989
- ) {
990
- // Scale point and radius
991
- const scaledPoint = thisGraphie.scalePoint(center);
992
- const scaledRadii = thisGraphie.scaleVector(radii);
993
-
994
- const width = 2 * scaledRadii[0] * maxScale + padding;
995
- const height = 2 * scaledRadii[1] * maxScale + padding;
996
-
997
- // Calculate absolute left, top
998
- const left = scaledPoint[0] - width / 2;
999
- const top = scaledPoint[1] - height / 2;
1000
-
1001
- // Wrap in <div>
1002
- const wrapper = document.createElement("div");
1003
- $(wrapper).css({
1004
- position: "absolute",
1005
- width: width + "px",
1006
- height: height + "px",
1007
- left: left + "px",
1008
- top: top + "px",
1009
- });
1010
- // wrapper.setAttribute("data-graphie-type", "ellipse");
1011
-
1012
- // Create Raphael canvas
1013
- const localRaphael = Raphael(wrapper, width, height);
1014
- const visibleShape = localRaphael.ellipse(
1015
- width / 2,
1016
- height / 2,
1017
- scaledRadii[0],
1018
- scaledRadii[1],
1019
- );
1020
-
1021
- return {
1022
- wrapper: wrapper,
1023
- visibleShape: visibleShape,
1024
- };
1025
- }
1026
-
1027
- function arc(center, radius, startAngle, endAngle, sector) {
1028
- startAngle = ((startAngle % 360) + 360) % 360;
1029
- endAngle = ((endAngle % 360) + 360) % 360;
1030
-
1031
- const cent = thisGraphie.scalePoint(center);
1032
- const radii = thisGraphie.scaleVector(radius);
1033
- const startVector = polar(radius, startAngle);
1034
- const endVector = polar(radius, endAngle);
1035
-
1036
- const startPoint = thisGraphie.scalePoint([
1037
- center[0] + startVector[0],
1038
- center[1] + startVector[1],
1039
- ]);
1040
- const endPoint = thisGraphie.scalePoint([
1041
- center[0] + endVector[0],
1042
- center[1] + endVector[1],
1043
- ]);
1044
-
1045
- const largeAngle = (((endAngle - startAngle) % 360) + 360) % 360 > 180;
1046
-
1047
- return thisGraphie.raphael.path(
1048
- "M" +
1049
- startPoint.join(" ") +
1050
- "A" +
1051
- radii.join(" ") +
1052
- " 0 " + // ellipse rotation
1053
- (largeAngle ? 1 : 0) +
1054
- " 0 " + // sweep flag
1055
- endPoint.join(" ") +
1056
- (sector ? "L" + cent.join(" ") + "z" : ""),
1057
- );
1058
- }
1059
-
1060
- function path(points) {
1061
- // @ts-expect-error - TS2554 - Expected 2 arguments, but got 1.
1062
- const p = thisGraphie.raphael.path(thisGraphie.svgPath(points));
1063
- p.graphiePath = points;
1064
-
1065
- return p;
1066
- }
1067
-
1068
- function fixedPath(points, center, createPath) {
1069
- points = _.map(points, thisGraphie.scalePoint);
1070
- center = center ? thisGraphie.scalePoint(center) : null;
1071
- createPath = createPath || thisGraphie.svgPath;
1072
-
1073
- const pathLeft = _.min(_.pluck(points, 0));
1074
- const pathRight = _.max(_.pluck(points, 0));
1075
- const pathTop = _.min(_.pluck(points, 1));
1076
- const pathBottom = _.max(_.pluck(points, 1));
1077
-
1078
- // Apply padding to line
1079
- const padding = [4, 4];
1080
-
1081
- // Calculate and apply additional offset
1082
- const extraOffset = [pathLeft, pathTop];
1083
-
1084
- // Apply padding and offset to points
1085
- points = _.map(points, function (point) {
1086
- return kvector.add(
1087
- kvector.subtract(point, extraOffset),
1088
- kvector.scale(padding, 0.5),
1089
- );
1090
- });
1091
-
1092
- // Calculate <div> dimensions
1093
- const width = pathRight - pathLeft + padding[0];
1094
- const height = pathBottom - pathTop + padding[1];
1095
- const left = extraOffset[0] - padding[0] / 2;
1096
- const top = extraOffset[1] - padding[1] / 2;
1097
-
1098
- // Create <div>
1099
- const wrapper = document.createElement("div");
1100
- $(wrapper).css({
1101
- position: "absolute",
1102
- width: width + "px",
1103
- height: height + "px",
1104
- left: left + "px",
1105
- top: top + "px",
1106
- // If user specified a center, set it
1107
- // NOTE(kevinb): jQuery doesn't like that `transformOrigin `could be `null`
1108
- // so we cast to `any` here.
1109
- transformOrigin: center
1110
- ? width / 2 +
1111
- center[0] +
1112
- "px " +
1113
- (height / 2 + center[1]) +
1114
- "px"
1115
- : null,
1116
- } as any);
1117
-
1118
- // Create Raphael canvas
1119
- const localRaphael = Raphael(wrapper, width, height);
1120
-
1121
- // Calculate path
1122
- const visibleShape = localRaphael.path(createPath(points));
1123
-
1124
- return {
1125
- wrapper: wrapper,
1126
- visibleShape: visibleShape,
1127
- };
1128
- }
1459
+ const [xRange, yRange] = getXAndYRanges(range);
1460
+ return [normalizeInterval(xRange), normalizeInterval(yRange)];
1461
+ }
1129
1462
 
1130
- function scaledPath(points) {
1131
- const p = thisGraphie.raphael.path(
1132
- thisGraphie.svgPath(points, /* alreadyScaled */ true),
1133
- );
1134
- p.graphiePath = points;
1135
- return p;
1463
+ function toPair(x: number | [number, number]): [number, number] {
1464
+ if (Array.isArray(x)) {
1465
+ return x;
1136
1466
  }
1467
+ return [x, x];
1468
+ }
1137
1469
 
1138
- function line(start, end) {
1139
- const l = path([start, end]);
1140
-
1141
- if (thisGraphie.isMobile) {
1142
- l.node.style.shapeRendering = "crispEdges";
1143
- }
1144
-
1145
- return l;
1146
- }
1470
+ /**
1471
+ * Safari applies some SVG-specific styles to things that are not SVGs, so we
1472
+ * need to exclude those styles from things that are not SVGs.
1473
+ *
1474
+ * To see this behavior in action, open https://codepen.io/anon/pen/zENEoa in
1475
+ * Safari.
1476
+ *
1477
+ * Usage `{...someStyles, ...SVG_SPECIFIC_STYLE_MASK}`
1478
+ */
1479
+ const SVG_SPECIFIC_STYLE_MASK = {
1480
+ "stroke-width": null,
1481
+ } as const;
1147
1482
 
1148
- function parabola(a, b, c) {
1149
- // Plot a parabola of the form: f(x) = (a * x + b) * x + c
1150
- return thisGraphie.raphael.path(thisGraphie.svgParabolaPath(a, b, c));
1483
+ const setLabelMargins = function (span: HTMLElement, size: Coord): void {
1484
+ const $span = $(span);
1485
+ const direction = $span.data("labelDirection");
1486
+ let [width, height] = size;
1487
+ // This can happen when a span
1488
+ // is invisible but we still want to update the CSS. At worst, we will
1489
+ // be off by a few pixels instead of in a different position entirely.
1490
+ if (width === 0 && height === 0) {
1491
+ [width, height] = [1, 1];
1492
+ Log.log("Label size was 0x0 in graphie.js; using 1x1 instead");
1151
1493
  }
1494
+ $span.css("visibility", "");
1152
1495
 
1153
- function fixedLine(start, end, thickness) {
1154
- // Apply padding to line
1155
- const padding = [thickness, thickness];
1156
-
1157
- // Scale points to get values in pixels
1158
- start = thisGraphie.scalePoint(start);
1159
- end = thisGraphie.scalePoint(end);
1496
+ if (typeof direction === "number") {
1497
+ const x = Math.cos(direction);
1498
+ const y = Math.sin(direction);
1160
1499
 
1161
- // Calculate and apply additional offset
1162
- const extraOffset = [
1163
- Math.min(start[0], end[0]),
1164
- Math.min(start[1], end[1]),
1165
- ];
1166
-
1167
- // Apply padding and offset to start, end points
1168
- start = kvector.add(
1169
- kvector.subtract(start, extraOffset),
1170
- kvector.scale(padding, 0.5),
1500
+ const scale = Math.min(
1501
+ width / 2 / Math.abs(x),
1502
+ height / 2 / Math.abs(y),
1171
1503
  );
1172
- end = kvector.add(
1173
- kvector.subtract(end, extraOffset),
1174
- kvector.scale(padding, 0.5),
1175
- );
1176
-
1177
- // Calculate <div> dimensions
1178
- const left = extraOffset[0] - padding[0] / 2;
1179
- const top = extraOffset[1] - padding[1] / 2;
1180
- const width = Math.abs(start[0] - end[0]) + padding[0];
1181
- const height = Math.abs(start[1] - end[1]) + padding[1];
1182
1504
 
1183
- // Create <div>
1184
- const wrapper = document.createElement("div");
1185
- $(wrapper).css({
1186
- position: "absolute",
1187
- width: width + "px",
1188
- height: height + "px",
1189
- left: left + "px",
1190
- top: top + "px",
1191
- // Outsiders should feel like the line's 'origin' (i.e., for
1192
- // rotation) is the starting point
1193
- transformOrigin: start[0] + "px " + start[1] + "px",
1505
+ $span.css({
1506
+ marginLeft: -width / 2 + x * scale,
1507
+ marginTop: -height / 2 - y * scale,
1508
+ });
1509
+ } else {
1510
+ const multipliers = labelDirections[direction || "center"];
1511
+ $span.css({
1512
+ marginLeft: Math.round(width * multipliers[0]),
1513
+ marginTop: Math.round(height * multipliers[1]),
1194
1514
  });
1195
-
1196
- // Create Raphael canvas
1197
- const localRaphael = Raphael(wrapper, width, height);
1198
-
1199
- // Calculate path
1200
- const path =
1201
- "M" + start[0] + " " + start[1] + " " + "L" + end[0] + " " + end[1];
1202
- const visibleShape = localRaphael.path(path);
1203
- visibleShape.graphiePath = [start, end];
1204
-
1205
- return {
1206
- wrapper: wrapper,
1207
- visibleShape: visibleShape,
1208
- };
1209
- }
1210
-
1211
- function sinusoid(a, b, c, d) {
1212
- // Plot a sinusoid of the form: f(x) = a * sin(b * x - c) + d
1213
- return thisGraphie.raphael.path(
1214
- thisGraphie.svgSinusoidPath(a, b, c, d),
1215
- );
1216
- }
1217
-
1218
- function grid(xr, yr) {
1219
- const step: any = thisGraphie.currentStyle.step || [1, 1];
1220
- const set = thisGraphie.raphael.set();
1221
-
1222
- let x = step[0] * Math.ceil(xr[0] / step[0]);
1223
- for (; x <= xr[1]; x += step[0]) {
1224
- set.push(line([x, yr[0]], [x, yr[1]]));
1225
- }
1226
-
1227
- let y = step[1] * Math.ceil(yr[0] / step[1]);
1228
- for (; y <= yr[1]; y += step[1]) {
1229
- set.push(line([xr[0], y], [xr[1], y]));
1230
- }
1231
-
1232
- return set;
1233
- }
1234
-
1235
- function label(point, text, direction, latex) {
1236
- latex = typeof latex === "undefined" || latex;
1237
-
1238
- const $span = $("<span>").addClass("graphie-label");
1239
-
1240
- const pad = thisGraphie.currentStyle["label-distance"];
1241
-
1242
- $span
1243
- .css(
1244
- $.extend(
1245
- {},
1246
- {
1247
- position: "absolute",
1248
- padding: (pad != null ? pad : 7) + "px",
1249
- color: "black",
1250
- },
1251
- ),
1252
- )
1253
- .data("labelDirection", direction)
1254
- .appendTo(thisGraphie.el);
1255
-
1256
- // @ts-expect-error - TS2339 - Property 'setPosition' does not exist on type 'JQuery<HTMLElement>'.
1257
- $span.setPosition = function (point) {
1258
- const scaledPoint = thisGraphie.scalePoint(point);
1259
- $span.css({
1260
- left: scaledPoint[0],
1261
- top: scaledPoint[1],
1262
- });
1263
- };
1264
-
1265
- // @ts-expect-error - TS2339 - Property 'setPosition' does not exist on type 'JQuery<HTMLElement>'.
1266
- $span.setPosition(point);
1267
-
1268
- const span = $span[0];
1269
-
1270
- // @ts-expect-error - TS2339 - Property 'processMath' does not exist on type 'JQuery<HTMLElement>'.
1271
- $span.processMath = function (math, force) {
1272
- processMath(span, math, force, function () {
1273
- const width = span.scrollWidth;
1274
- const height = span.scrollHeight;
1275
- setLabelMargins(span, [width, height]);
1276
- });
1277
- };
1278
-
1279
- // @ts-expect-error - TS2339 - Property 'processText' does not exist on type 'JQuery<HTMLElement>'.
1280
- $span.processText = function (text: any) {
1281
- $span.html(text);
1282
- const width = span.scrollWidth;
1283
- const height = span.scrollHeight;
1284
- setLabelMargins(span, [width, height]);
1285
- };
1286
-
1287
- if (latex) {
1288
- // @ts-expect-error - TS2339 - Property 'processMath' does not exist on type 'JQuery<HTMLElement>'.
1289
- $span.processMath(text, /* force */ false);
1290
- } else {
1291
- // @ts-expect-error - TS2339 - Property 'processText' does not exist on type 'JQuery<HTMLElement>'.
1292
- $span.processText(text);
1293
- }
1294
-
1295
- return $span;
1296
1515
  }
1516
+ };
1297
1517
 
1298
- function plotParametric(
1299
- fn: (t: number) => Coord,
1300
- range,
1301
- shade,
1302
- fn2: (t: number) => Coord = (t) => [t, 0],
1303
- ) {
1304
- // Note: fn2 should only be set if 'shade' is true, as it denotes
1305
- // the function between which fn should have its area shaded.
1306
- // In general, plotParametric shouldn't be used to shade the area
1307
- // between two arbitrary parametrics functions over an interval,
1308
- // as the method assumes that fn and fn2 are both of the form
1309
- // fn(t) = (t, fn'(t)) for some initial fn'.
1310
-
1311
- // We truncate to 500,000, since anything bigger causes
1312
- // overflow in the firefox svg renderer. This is safe
1313
- // since 500,000 is outside the viewport anyway. We
1314
- // write these functions the way we do to handle undefined.
1315
- const clipper = (xy) => {
1316
- if (Math.abs(xy[1]) > 500000) {
1317
- return [xy[0], Math.min(Math.max(xy[1], -500000), 500000)];
1318
- }
1319
- return xy;
1320
- };
1321
- const clippedFn = (x) => clipper(fn(x));
1322
- const clippedFn2 = (x: number) => clipper(fn2(x));
1518
+ const GraphUtils: any = {
1519
+ Graphie,
1323
1520
 
1324
- if (!thisGraphie.currentStyle.strokeLinejoin) {
1325
- thisGraphie.currentStyle.strokeLinejoin = "round";
1326
- }
1327
- if (!thisGraphie.currentStyle.strokeLinecap) {
1328
- thisGraphie.currentStyle.strokeLinecap = "round";
1329
- }
1521
+ createGraphie: function (el: any): Graphie {
1522
+ return new Graphie(el);
1523
+ },
1330
1524
 
1331
- const min = range[0];
1332
- const max = range[1];
1333
- let step =
1334
- (max - min) / (thisGraphie.currentStyle["plot-points"] || 800);
1335
- if (step === 0) {
1336
- step = 1;
1525
+ unscaledSvgPath: function (points) {
1526
+ // If this is an empty closed path, return "" instead of "z", which
1527
+ // would give an error
1528
+ if (points[0] === true) {
1529
+ return "";
1337
1530
  }
1338
-
1339
- const paths = thisGraphie.raphael.set();
1340
- let points = [];
1341
- let lastDiff = GraphUtils.coordDiff(clippedFn(min), clippedFn2(min));
1342
-
1343
- let lastFlip = min;
1344
- for (let t = min; t <= max; t += step) {
1345
- const top = clippedFn(t);
1346
- const bottom = clippedFn2(t);
1347
- const diff = GraphUtils.coordDiff(top, bottom);
1348
-
1349
- // Find points where it flips
1350
- // Create path that sketches area between the two functions
1351
- if (
1352
- // if there is an asymptote here, meaning that the graph
1353
- // switches signs and has a large difference
1354
- (diff[1] < 0 !== lastDiff[1] < 0 &&
1355
- Math.abs(diff[1] - lastDiff[1]) >
1356
- 2 * thisGraphie.drawingTransform().pixelsPerUnitY()) ||
1357
- // or the function is undefined
1358
- isNaN(diff[1])
1359
- ) {
1360
- // split the path at this point, and draw it
1361
- if (shade) {
1362
- // @ts-expect-error - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'.
1363
- points.push(top);
1364
-
1365
- // backtrack to draw paired function
1366
- for (let u = t - step; u >= lastFlip; u -= step) {
1367
- // @ts-expect-error - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'.
1368
- points.push(clippedFn2(u));
1369
- }
1370
- lastFlip = t;
1371
- }
1372
- paths.push(path(points));
1373
- // restart the path, excluding this point
1374
- points = [];
1375
- if (shade) {
1376
- // @ts-expect-error - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'.
1377
- points.push(top);
1531
+ return points
1532
+ .map(function (point, i) {
1533
+ if (point === true) {
1534
+ return "z";
1378
1535
  }
1379
- } else {
1380
- // otherwise, just add the point to the path
1381
- // @ts-expect-error - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'.
1382
- points.push(top);
1383
- }
1384
-
1385
- lastDiff = diff;
1386
- }
1387
-
1388
- if (shade) {
1389
- // backtrack to draw paired function
1390
- for (let u = max - step; u >= lastFlip; u -= step) {
1391
- // @ts-expect-error - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'.
1392
- points.push(clippedFn2(u));
1393
- }
1394
- }
1395
- paths.push(path(points));
1536
+ return (i === 0 ? "M" : "L") + point[0] + " " + point[1];
1537
+ })
1538
+ .join("");
1539
+ },
1396
1540
 
1397
- return paths;
1398
- }
1541
+ getDistance: function (point1, point2) {
1542
+ return kpoint.distanceToPoint(point1, point2);
1543
+ },
1399
1544
 
1400
- function plot(fn, range, swapAxes, shade, fn2) {
1401
- const min = range[0];
1402
- const max = range[1];
1403
- if (!thisGraphie.currentStyle["plot-points"]) {
1404
- thisGraphie.currentStyle["plot-points"] =
1405
- 2 *
1406
- (max - min) *
1407
- thisGraphie.drawingTransform().pixelsPerUnitX();
1408
- }
1545
+ /**
1546
+ * Round the given coordinates to a given snap value
1547
+ * (e.g., nearest 0.2 increment)
1548
+ */
1549
+ snapCoord: function (coord, snap) {
1550
+ return coord.map(function (val, i) {
1551
+ return KhanMath.roundToNearest(snap[i], val);
1552
+ });
1553
+ },
1409
1554
 
1410
- if (swapAxes) {
1411
- if (fn2) {
1412
- // TODO(charlie): support swapped axis area shading
1413
- throw new PerseusError(
1414
- "Can't shade area between functions with swapped axes.",
1415
- Errors.Internal,
1416
- );
1417
- }
1418
- return plotParametric(
1419
- function (y) {
1420
- return [fn(y), y];
1421
- },
1422
- range,
1423
- shade,
1424
- );
1425
- }
1426
- if (fn2) {
1427
- if (shade) {
1428
- return plotParametric(
1429
- function (x) {
1430
- return [x, fn(x)];
1431
- },
1432
- range,
1433
- shade,
1434
- function (x) {
1435
- return [x, fn2(x)];
1436
- },
1437
- );
1555
+ // Find the angle in degrees between two or three points
1556
+ findAngle: function (point1, point2, vertex) {
1557
+ if (vertex === undefined) {
1558
+ const x = point1[0] - point2[0];
1559
+ const y = point1[1] - point2[1];
1560
+ if (!x && !y) {
1561
+ return 0;
1438
1562
  }
1439
- throw new PerseusError(
1440
- "fn2 should only be set when 'shade' is True.",
1441
- Errors.Internal,
1442
- );
1563
+ return (180 + (Math.atan2(-y, -x) * 180) / Math.PI + 360) % 360;
1443
1564
  }
1444
- return plotParametric(
1445
- function (x) {
1446
- return [x, fn(x)];
1447
- },
1448
- range,
1449
- shade,
1565
+ return (
1566
+ GraphUtils.findAngle(point1, vertex) -
1567
+ GraphUtils.findAngle(point2, vertex)
1450
1568
  );
1451
- }
1452
-
1453
- const drawingTools = {
1454
- circle,
1455
- rect,
1456
- ellipse,
1457
- fixedEllipse,
1458
- arc,
1459
- path,
1460
- fixedPath,
1461
- scaledPath,
1462
- line,
1463
- parabola,
1464
- fixedLine,
1465
- sinusoid,
1466
- grid,
1467
- label,
1468
- plotParametric,
1469
- plot,
1470
- };
1471
-
1472
- function graphify(drawingFn: any): any {
1473
- return function (...args) {
1474
- const last = args[args.length - 1];
1475
- const oldStyle = thisGraphie.currentStyle;
1476
- let result;
1477
-
1478
- // The last argument is probably trying to change the style
1479
- if (typeof last === "object" && !_.isArray(last)) {
1480
- thisGraphie.currentStyle = {
1481
- ...thisGraphie.currentStyle,
1482
- ...thisGraphie.processAttributes(last),
1483
- };
1484
-
1485
- const rest = [].slice.call(args, 0, args.length - 1);
1486
- result = drawingFn(...rest);
1487
- } else {
1488
- thisGraphie.currentStyle = $.extend(
1489
- {},
1490
- thisGraphie.currentStyle,
1491
- );
1492
-
1493
- result = drawingFn(...args);
1494
- }
1495
-
1496
- // Bad heuristic for recognizing Raphael elements and sets
1497
- const type = result.constructor.prototype;
1498
- if (type === Raphael.el || type === Raphael.st) {
1499
- result.attr(thisGraphie.currentStyle);
1500
-
1501
- if (thisGraphie.currentStyle.arrows) {
1502
- result = addArrowheads(result);
1503
- }
1504
- } else if (result instanceof $) {
1505
- // We assume that if it's not a Raphael element/set, it
1506
- // does not contain SVG.
1507
- // @ts-expect-error - TS2339 - Property 'css' does not exist on type '{}'.
1508
- result.css({
1509
- ...thisGraphie.currentStyle,
1510
- ...SVG_SPECIFIC_STYLE_MASK,
1511
- });
1512
- }
1513
-
1514
- thisGraphie.currentStyle = oldStyle;
1515
- return result;
1516
- };
1517
- }
1518
-
1519
- $.each(drawingTools, function (name) {
1520
- thisGraphie[name] = graphify(drawingTools[name]);
1521
- });
1569
+ },
1522
1570
 
1523
- return thisGraphie;
1571
+ graphs: {},
1524
1572
  };
1525
1573
 
1526
1574
  export default GraphUtils;