@khanacademy/perseus 0.0.1 → 0.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 (503) hide show
  1. package/.babelrc.js +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/es/index.js +15 -1
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.css +11934 -0
  6. package/dist/index.js +15 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/strings.js +148 -1
  9. package/package.json +40 -4
  10. package/src/README.md +7 -0
  11. package/src/__stories__/item-renderer.stories.jsx +27 -0
  12. package/src/__stories__/server-item-renderer.stories.jsx +27 -0
  13. package/src/__testdata__/item-renderer_testdata.js +161 -0
  14. package/src/__testdata__/renderer_testdata.js +123 -0
  15. package/src/__tests__/__snapshots__/item-renderer_test.jsx.snap +233 -0
  16. package/src/__tests__/__snapshots__/perseus-markdown_test.jsx.snap +516 -0
  17. package/src/__tests__/__snapshots__/pure-markdown_test.js.snap +113 -0
  18. package/src/__tests__/__snapshots__/renderer_test.jsx.snap +576 -0
  19. package/src/__tests__/__snapshots__/server-item-renderer_test.jsx.snap +306 -0
  20. package/src/__tests__/a11y_test.jsx +304 -0
  21. package/src/__tests__/item-renderer_test.jsx +892 -0
  22. package/src/__tests__/mock-asset-loading-widget.jsx +57 -0
  23. package/src/__tests__/mock-widget.jsx +35 -0
  24. package/src/__tests__/perseus-markdown_test.jsx +747 -0
  25. package/src/__tests__/pure-markdown_test.js +552 -0
  26. package/src/__tests__/renderability_test.jsx +481 -0
  27. package/src/__tests__/renderer-api_test.jsx +333 -0
  28. package/src/__tests__/renderer_test.jsx +1792 -0
  29. package/src/__tests__/server-item-renderer_test.jsx +516 -0
  30. package/src/__tests__/test-items/image-item.js +25 -0
  31. package/src/__tests__/test-items/input-number-1-item.js +27 -0
  32. package/src/__tests__/test-items/input-number-2-item.js +39 -0
  33. package/src/__tests__/test-items/table-item.js +30 -0
  34. package/src/__tests__/tex-wrangler_test.js +83 -0
  35. package/src/__tests__/util_test.js +41 -0
  36. package/src/a11y.js +46 -0
  37. package/src/all-widgets.js +9 -0
  38. package/src/article-renderer.jsx +215 -0
  39. package/src/asset-context.js +30 -0
  40. package/src/basic-widgets.js +13 -0
  41. package/src/components/__stories__/button-group.stories.jsx +61 -0
  42. package/src/components/__stories__/fixed-to-responsive.stories.jsx +132 -0
  43. package/src/components/__stories__/graph.stories.jsx +27 -0
  44. package/src/components/__stories__/graphie.stories.jsx +28 -0
  45. package/src/components/__stories__/hud.stories.jsx +36 -0
  46. package/src/components/__stories__/icon.stories.jsx +56 -0
  47. package/src/components/__stories__/image-loader.stories.jsx +60 -0
  48. package/src/components/__stories__/info-tip.stories.jsx +33 -0
  49. package/src/components/__stories__/inline-icon.stories.jsx +39 -0
  50. package/src/components/__stories__/input-with-examples.stories.jsx +51 -0
  51. package/src/components/__stories__/lint.stories.jsx +40 -0
  52. package/src/components/__stories__/math-input.stories.jsx +29 -0
  53. package/src/components/__stories__/math-output.stories.jsx +24 -0
  54. package/src/components/__stories__/multi-button-group.stories.jsx +69 -0
  55. package/src/components/__stories__/number-input.stories.jsx +42 -0
  56. package/src/components/__stories__/possible-answers.stories.jsx +22 -0
  57. package/src/components/__stories__/prop-check-box.stories.jsx +62 -0
  58. package/src/components/__stories__/range-input.stories.jsx +28 -0
  59. package/src/components/__stories__/simple-keypad-input.stories.jsx +28 -0
  60. package/src/components/__stories__/sortable.stories.jsx +73 -0
  61. package/src/components/__stories__/stub-tag-editor.stories.jsx +44 -0
  62. package/src/components/__stories__/svg-image.stories.jsx +60 -0
  63. package/src/components/__stories__/tex.stories.jsx +18 -0
  64. package/src/components/__stories__/text-buttons.stories.jsx +51 -0
  65. package/src/components/__stories__/text-input.stories.jsx +34 -0
  66. package/src/components/__stories__/text-list-editor.stories.jsx +33 -0
  67. package/src/components/__stories__/zoomable-tex.stories.jsx +39 -0
  68. package/src/components/__stories__/zoomable.stories.jsx +40 -0
  69. package/src/components/__testdata__/sorter_testdata.js +20 -0
  70. package/src/components/__tests__/__snapshots__/sortable_test.jsx.snap +79 -0
  71. package/src/components/__tests__/__snapshots__/sorter_test.jsx.snap +297 -0
  72. package/src/components/__tests__/number-input_test.jsx +112 -0
  73. package/src/components/__tests__/sortable_test.jsx +234 -0
  74. package/src/components/__tests__/sorter_test.jsx +111 -0
  75. package/src/components/__tests__/zoomable_test.jsx +388 -0
  76. package/src/components/button-group.jsx +131 -0
  77. package/src/components/constants.js +15 -0
  78. package/src/components/fixed-to-responsive.jsx +163 -0
  79. package/src/components/graph.jsx +414 -0
  80. package/src/components/graphie-classes.jsx +148 -0
  81. package/src/components/graphie-movables.jsx +342 -0
  82. package/src/components/graphie.jsx +353 -0
  83. package/src/components/highlighting/highlightable-content.jsx +245 -0
  84. package/src/components/highlighting/highlights.js +208 -0
  85. package/src/components/highlighting/ranges.js +299 -0
  86. package/src/components/highlighting/types.js +73 -0
  87. package/src/components/highlighting/ui/highlight-renderer.jsx +179 -0
  88. package/src/components/highlighting/ui/highlight-set-renderer.jsx +190 -0
  89. package/src/components/highlighting/ui/highlight-tooltip.jsx +153 -0
  90. package/src/components/highlighting/ui/highlighting-ui.jsx +163 -0
  91. package/src/components/highlighting/ui/selection-tracker.jsx +195 -0
  92. package/src/components/highlighting/ui/types.js +21 -0
  93. package/src/components/highlighting/ui/util.js +166 -0
  94. package/src/components/highlighting/word-indexer.jsx +155 -0
  95. package/src/components/hud.jsx +179 -0
  96. package/src/components/icon.jsx +149 -0
  97. package/src/components/image-loader.jsx +175 -0
  98. package/src/components/info-tip/info-tip.jsx +149 -0
  99. package/src/components/info-tip.jsx +38 -0
  100. package/src/components/inline-icon.jsx +81 -0
  101. package/src/components/input-with-examples.jsx +231 -0
  102. package/src/components/lint.jsx +456 -0
  103. package/src/components/math-input.jsx +282 -0
  104. package/src/components/math-output.jsx +128 -0
  105. package/src/components/multi-button-group.jsx +137 -0
  106. package/src/components/new-tooltip/README.md +110 -0
  107. package/src/components/new-tooltip/__stories__/flexible-position.stories.jsx +437 -0
  108. package/src/components/new-tooltip/__stories__/new-tooltip.stories.jsx +665 -0
  109. package/src/components/new-tooltip/__stories__/tooltip-arrow.stories.jsx +70 -0
  110. package/src/components/new-tooltip/__stories__/tooltip-bubble.stories.jsx +98 -0
  111. package/src/components/new-tooltip/__tests__/style-constants_test.js +8 -0
  112. package/src/components/new-tooltip/__tests__/util_test.js +47 -0
  113. package/src/components/new-tooltip/flexible-position.jsx +182 -0
  114. package/src/components/new-tooltip/new-tooltip.jsx +399 -0
  115. package/src/components/new-tooltip/style-constants.js +13 -0
  116. package/src/components/new-tooltip/tooltip-arrow.jsx +184 -0
  117. package/src/components/new-tooltip/tooltip-bubble.jsx +223 -0
  118. package/src/components/new-tooltip/tooltip-portal.jsx +101 -0
  119. package/src/components/new-tooltip/tooltip-positioner.jsx +529 -0
  120. package/src/components/new-tooltip/types.js +183 -0
  121. package/src/components/new-tooltip/util.js +27 -0
  122. package/src/components/number-input.jsx +253 -0
  123. package/src/components/possible-answers.jsx +36 -0
  124. package/src/components/prop-check-box.jsx +78 -0
  125. package/src/components/range-input.jsx +61 -0
  126. package/src/components/simple-keypad-input.jsx +91 -0
  127. package/src/components/sortable.jsx +872 -0
  128. package/src/components/stub-tag-editor.jsx +53 -0
  129. package/src/components/svg-image.jsx +762 -0
  130. package/src/components/tex-buttons.jsx +322 -0
  131. package/src/components/tex.jsx +70 -0
  132. package/src/components/text-input.jsx +82 -0
  133. package/src/components/text-list-editor.jsx +157 -0
  134. package/src/components/tooltip.jsx +395 -0
  135. package/src/components/visibility-observer/__stories__/visibility-observer.stories.jsx +140 -0
  136. package/src/components/visibility-observer/get-scroll-parent.js +45 -0
  137. package/src/components/visibility-observer/visibility-observer.js +109 -0
  138. package/src/components/visibility-observer/visibility-observer.jsx +123 -0
  139. package/src/components/zoomable-tex.jsx +70 -0
  140. package/src/components/zoomable.jsx +304 -0
  141. package/src/components.js +17 -0
  142. package/src/dependencies.js +22 -0
  143. package/src/error-boundary.jsx +48 -0
  144. package/src/extra-widgets.js +75 -0
  145. package/src/gorgon/README +40 -0
  146. package/src/gorgon/__tests__/matcher_test.js +497 -0
  147. package/src/gorgon/__tests__/rule_test.js +101 -0
  148. package/src/gorgon/__tests__/rules_test.js +487 -0
  149. package/src/gorgon/__tests__/selector-parser_test.js +52 -0
  150. package/src/gorgon/__tests__/tree-transformer_test.js +446 -0
  151. package/src/gorgon/gorgon.js +290 -0
  152. package/src/gorgon/proptypes.js +29 -0
  153. package/src/gorgon/rule.js +413 -0
  154. package/src/gorgon/rules/absolute-url.js +24 -0
  155. package/src/gorgon/rules/all-rules.js +72 -0
  156. package/src/gorgon/rules/blockquoted-math.js +10 -0
  157. package/src/gorgon/rules/blockquoted-widget.js +10 -0
  158. package/src/gorgon/rules/double-spacing-after-terminal.js +12 -0
  159. package/src/gorgon/rules/extra-content-spacing.js +12 -0
  160. package/src/gorgon/rules/heading-level-1.js +14 -0
  161. package/src/gorgon/rules/heading-level-skip.js +20 -0
  162. package/src/gorgon/rules/heading-sentence-case.js +11 -0
  163. package/src/gorgon/rules/heading-title-case.js +63 -0
  164. package/src/gorgon/rules/image-alt-text.js +21 -0
  165. package/src/gorgon/rules/image-in-table.js +10 -0
  166. package/src/gorgon/rules/image-spaces-around-urls.js +35 -0
  167. package/src/gorgon/rules/image-widget.js +50 -0
  168. package/src/gorgon/rules/link-click-here.js +11 -0
  169. package/src/gorgon/rules/lint-utils.js +48 -0
  170. package/src/gorgon/rules/long-paragraph.js +14 -0
  171. package/src/gorgon/rules/math-adjacent.js +10 -0
  172. package/src/gorgon/rules/math-align-extra-break.js +11 -0
  173. package/src/gorgon/rules/math-align-linebreaks.js +43 -0
  174. package/src/gorgon/rules/math-empty.js +10 -0
  175. package/src/gorgon/rules/math-font-size.js +12 -0
  176. package/src/gorgon/rules/math-frac.js +10 -0
  177. package/src/gorgon/rules/math-nested.js +11 -0
  178. package/src/gorgon/rules/math-starts-with-space.js +12 -0
  179. package/src/gorgon/rules/math-text-empty.js +10 -0
  180. package/src/gorgon/rules/math-without-dollars.js +14 -0
  181. package/src/gorgon/rules/nested-lists.js +11 -0
  182. package/src/gorgon/rules/profanity.js +10 -0
  183. package/src/gorgon/rules/table-missing-cells.js +20 -0
  184. package/src/gorgon/rules/unbalanced-code-delimiters.js +14 -0
  185. package/src/gorgon/rules/unescaped-dollar.js +10 -0
  186. package/src/gorgon/rules/widget-in-table.js +10 -0
  187. package/src/gorgon/selector.js +506 -0
  188. package/src/gorgon/tree-transformer.js +588 -0
  189. package/src/hint-renderer.jsx +159 -0
  190. package/src/hints-renderer.jsx +359 -0
  191. package/src/icon-paths.js +120 -0
  192. package/src/index.js +149 -2
  193. package/src/init.js +49 -0
  194. package/src/interaction-tracker.js +60 -0
  195. package/src/interactive2/__tests__/movable-mock.js +48 -0
  196. package/src/interactive2/__tests__/movable-point_test.js +146 -0
  197. package/src/interactive2/interactive-util.js +152 -0
  198. package/src/interactive2/movable-helper-methods.js +160 -0
  199. package/src/interactive2/movable-line-options.js +392 -0
  200. package/src/interactive2/movable-line.js +322 -0
  201. package/src/interactive2/movable-point-options.js +190 -0
  202. package/src/interactive2/movable-point.jsx +604 -0
  203. package/src/interactive2/movable-polygon-options.js +351 -0
  204. package/src/interactive2/movable-polygon.js +367 -0
  205. package/src/interactive2/movable.js +273 -0
  206. package/src/interactive2/objective_.js +79 -0
  207. package/src/interactive2/types.js +3 -0
  208. package/src/interactive2/wrapped-defaults.js +86 -0
  209. package/src/interactive2/wrapped-ellipse.js +87 -0
  210. package/src/interactive2/wrapped-line.js +109 -0
  211. package/src/interactive2/wrapped-path.js +47 -0
  212. package/src/interactive2.js +91 -0
  213. package/src/item-renderer.jsx +518 -0
  214. package/src/item-version.js +12 -0
  215. package/src/jipt-paragraphs.js +44 -0
  216. package/src/jquery.mobile.vmouse.js +545 -0
  217. package/src/keypad-context.js +33 -0
  218. package/src/loading-context.js +20 -0
  219. package/src/logging/log.js +107 -0
  220. package/src/logging/log_test.js +41 -0
  221. package/src/mixins/changeable.jsx +115 -0
  222. package/src/mixins/editor-jsonify.jsx +13 -0
  223. package/src/mixins/provide-keypad.jsx +131 -0
  224. package/src/mixins/widget-jsonify-deprecated.jsx +23 -0
  225. package/src/mixins/widget-prop-blacklist.js +36 -0
  226. package/src/multi-items/__stories__/multi-renderer.stories.js +88 -0
  227. package/src/multi-items/__testdata__/multi-renderer_testdata.js +174 -0
  228. package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +994 -0
  229. package/src/multi-items/__tests__/items_test.js +245 -0
  230. package/src/multi-items/__tests__/multi-renderer_test.js +903 -0
  231. package/src/multi-items/__tests__/prop-type-builders_test.js +134 -0
  232. package/src/multi-items/__tests__/shapes_test.js +44 -0
  233. package/src/multi-items/__tests__/trees_test.js +316 -0
  234. package/src/multi-items/item-types.js +55 -0
  235. package/src/multi-items/items.js +176 -0
  236. package/src/multi-items/multi-renderer.jsx +542 -0
  237. package/src/multi-items/prop-type-builders.js +105 -0
  238. package/src/multi-items/shape-types.js +47 -0
  239. package/src/multi-items/shapes.js +53 -0
  240. package/src/multi-items/tree-types.js +42 -0
  241. package/src/multi-items/trees.js +345 -0
  242. package/src/multi-items.js +76 -0
  243. package/src/not-gorgon.js +129 -0
  244. package/src/perseus-api.jsx +270 -0
  245. package/src/perseus-env.js +26 -0
  246. package/src/perseus-error.js +19 -0
  247. package/src/perseus-markdown.jsx +434 -0
  248. package/src/perseus-types.js +1597 -0
  249. package/src/pure-markdown.js +346 -0
  250. package/src/question-paragraph.jsx +30 -0
  251. package/src/renderability.jsx +108 -0
  252. package/src/renderer.jsx +1931 -0
  253. package/src/server-item-renderer.jsx +467 -0
  254. package/src/sigfigs.jsx +492 -0
  255. package/src/styles/articles.less +748 -0
  256. package/src/styles/constants.js +119 -0
  257. package/src/styles/global-constants.js +350 -0
  258. package/src/styles/global-styles.js +307 -0
  259. package/src/styles/khan-exercise.css +1304 -0
  260. package/src/styles/media-queries.js +48 -0
  261. package/src/styles/perseus-renderer.less +1008 -0
  262. package/src/styles/shared.js +132 -0
  263. package/src/styles/util.less +47 -0
  264. package/src/styles/variables.boostrap.less +301 -0
  265. package/src/styles/variables.less +68 -0
  266. package/src/styles/variables.shared.less +356 -0
  267. package/src/styles/widgets/categorizer.less +39 -0
  268. package/src/styles/widgets/definition.less +86 -0
  269. package/src/styles/widgets/dropdown.less +3 -0
  270. package/src/styles/widgets/expression.less +147 -0
  271. package/src/styles/widgets/graded-group.less +36 -0
  272. package/src/styles/widgets/grapher.less +11 -0
  273. package/src/styles/widgets/group.less +19 -0
  274. package/src/styles/widgets/image.less +29 -0
  275. package/src/styles/widgets/interactive-graph.less +61 -0
  276. package/src/styles/widgets/label-image.less +41 -0
  277. package/src/styles/widgets/matcher.less +8 -0
  278. package/src/styles/widgets/matrix.less +120 -0
  279. package/src/styles/widgets/measurer.less +20 -0
  280. package/src/styles/widgets/orderer.less +97 -0
  281. package/src/styles/widgets/passage.less +151 -0
  282. package/src/styles/widgets/plotter.less +33 -0
  283. package/src/styles/widgets/radio.less +52 -0
  284. package/src/styles/widgets/reaction-diagram.less +35 -0
  285. package/src/styles/widgets/sequence.less +5 -0
  286. package/src/styles/widgets/sortable.less +127 -0
  287. package/src/styles/widgets/table.less +56 -0
  288. package/src/styles/widgets/transformer.less +28 -0
  289. package/src/styles/widgets/unit.less +14 -0
  290. package/src/tex-wrangler.js +176 -0
  291. package/src/traversal.jsx +141 -0
  292. package/src/types.js +538 -0
  293. package/src/util/a11y.js +24 -0
  294. package/src/util/answer-types.js +862 -0
  295. package/src/util/answer-types_test.js +115 -0
  296. package/src/util/colors.js +94 -0
  297. package/src/util/fix-passage-refs.jsx +122 -0
  298. package/src/util/graph-utils.js +16 -0
  299. package/src/util/graphie.js +1585 -0
  300. package/src/util/interactive.js +4071 -0
  301. package/src/util/katex-preprocess.js +16 -0
  302. package/src/util/math.js +296 -0
  303. package/src/util/mobile-native-utils.js +14 -0
  304. package/src/util/mobile-native-utils_test.js +24 -0
  305. package/src/util/register-all-widgets-for-testing.js +12 -0
  306. package/src/util/scroll-utils.js +162 -0
  307. package/src/util/sizing-utils.js +46 -0
  308. package/src/util/tex.js +203 -0
  309. package/src/util/url-utils.js +36 -0
  310. package/src/util.js +990 -0
  311. package/src/version.js +11 -0
  312. package/src/widget-container.jsx +174 -0
  313. package/src/widgets/__stories__/categorizer.stories.jsx +15 -0
  314. package/src/widgets/__stories__/cs-program.stories.jsx +15 -0
  315. package/src/widgets/__stories__/definition.stories.jsx +38 -0
  316. package/src/widgets/__stories__/dropdown.stories.jsx +15 -0
  317. package/src/widgets/__stories__/explanation.stories.jsx +19 -0
  318. package/src/widgets/__stories__/expression.stories.jsx +26 -0
  319. package/src/widgets/__stories__/graded-group-set.stories.jsx +32 -0
  320. package/src/widgets/__stories__/graded-group.stories.jsx +32 -0
  321. package/src/widgets/__stories__/grapher.stories.jsx +47 -0
  322. package/src/widgets/__stories__/group.stories.jsx +15 -0
  323. package/src/widgets/__stories__/iframe.stories.jsx +15 -0
  324. package/src/widgets/__stories__/image.stories.jsx +62 -0
  325. package/src/widgets/__stories__/input-number.stories.jsx +90 -0
  326. package/src/widgets/__stories__/interaction.stories.jsx +15 -0
  327. package/src/widgets/__stories__/interactive-graph.stories.jsx +61 -0
  328. package/src/widgets/__stories__/matcher.stories.jsx +15 -0
  329. package/src/widgets/__stories__/matrix.stories.jsx +15 -0
  330. package/src/widgets/__stories__/number-line.stories.jsx +15 -0
  331. package/src/widgets/__stories__/numeric-input.stories.jsx +19 -0
  332. package/src/widgets/__stories__/orderer.stories.jsx +15 -0
  333. package/src/widgets/__stories__/passage-ref.stories.jsx +19 -0
  334. package/src/widgets/__stories__/passage.stories.jsx +19 -0
  335. package/src/widgets/__stories__/radio.stories.jsx +82 -0
  336. package/src/widgets/__stories__/sorter.stories.jsx +15 -0
  337. package/src/widgets/__stories__/transformer.stories.jsx +15 -0
  338. package/src/widgets/__stories__/video.stories.jsx +15 -0
  339. package/src/widgets/__testdata__/categorizer_testdata.js +50 -0
  340. package/src/widgets/__testdata__/cs-program_testdata.js +28 -0
  341. package/src/widgets/__testdata__/dropdown_testdata.js +34 -0
  342. package/src/widgets/__testdata__/explanation_testdata.js +56 -0
  343. package/src/widgets/__testdata__/expression_testdata.js +95 -0
  344. package/src/widgets/__testdata__/graded-group-set_testdata.js +358 -0
  345. package/src/widgets/__testdata__/graded-group_testdata.js +79 -0
  346. package/src/widgets/__testdata__/grapher_testdata.js +376 -0
  347. package/src/widgets/__testdata__/group_testdata.js +156 -0
  348. package/src/widgets/__testdata__/iframe_testdata.js +36 -0
  349. package/src/widgets/__testdata__/image_testdata.js +38 -0
  350. package/src/widgets/__testdata__/input-number_testdata.js +73 -0
  351. package/src/widgets/__testdata__/interaction_testdata.js +264 -0
  352. package/src/widgets/__testdata__/interactive-graph_testdata.js +646 -0
  353. package/src/widgets/__testdata__/matcher_testdata.js +34 -0
  354. package/src/widgets/__testdata__/matrix_testdata.js +29 -0
  355. package/src/widgets/__testdata__/number-line_testdata.js +31 -0
  356. package/src/widgets/__testdata__/numeric-input_testdata.js +266 -0
  357. package/src/widgets/__testdata__/orderer_testdata.js +58 -0
  358. package/src/widgets/__testdata__/passage-ref_testdata.js +41 -0
  359. package/src/widgets/__testdata__/passage_testdata.js +71 -0
  360. package/src/widgets/__testdata__/radio_testdata.js +210 -0
  361. package/src/widgets/__testdata__/sorter_testdata.js +20 -0
  362. package/src/widgets/__testdata__/transformer_testdata.js +109 -0
  363. package/src/widgets/__testdata__/video_testdata.js +22 -0
  364. package/src/widgets/__tests__/__snapshots__/categorizer_test.jsx.snap +928 -0
  365. package/src/widgets/__tests__/__snapshots__/cs-program_test.jsx.snap +75 -0
  366. package/src/widgets/__tests__/__snapshots__/definition_test.js.snap +99 -0
  367. package/src/widgets/__tests__/__snapshots__/dropdown_test.jsx.snap +143 -0
  368. package/src/widgets/__tests__/__snapshots__/explanation_test.js.snap +473 -0
  369. package/src/widgets/__tests__/__snapshots__/expression_test.jsx.snap +38 -0
  370. package/src/widgets/__tests__/__snapshots__/graded-group-set-jipt_test.jsx.snap +754 -0
  371. package/src/widgets/__tests__/__snapshots__/graded-group-set_test.jsx.snap +310 -0
  372. package/src/widgets/__tests__/__snapshots__/graded-group_test.jsx.snap +863 -0
  373. package/src/widgets/__tests__/__snapshots__/grapher_test.jsx.snap +2880 -0
  374. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +1154 -0
  375. package/src/widgets/__tests__/__snapshots__/iframe_test.jsx.snap +69 -0
  376. package/src/widgets/__tests__/__snapshots__/image_test.jsx.snap +388 -0
  377. package/src/widgets/__tests__/__snapshots__/input-number_test.jsx.snap +978 -0
  378. package/src/widgets/__tests__/__snapshots__/interaction_test.jsx.snap +311 -0
  379. package/src/widgets/__tests__/__snapshots__/interactive-graph_test.jsx.snap +2915 -0
  380. package/src/widgets/__tests__/__snapshots__/matcher_test.jsx.snap +1039 -0
  381. package/src/widgets/__tests__/__snapshots__/matrix_test.jsx.snap +450 -0
  382. package/src/widgets/__tests__/__snapshots__/number-line_test.jsx.snap +888 -0
  383. package/src/widgets/__tests__/__snapshots__/numeric-input_test.jsx.snap +930 -0
  384. package/src/widgets/__tests__/__snapshots__/orderer_test.jsx.snap +275 -0
  385. package/src/widgets/__tests__/__snapshots__/passage-ref_test.jsx.snap +446 -0
  386. package/src/widgets/__tests__/__snapshots__/passage_test.jsx.snap +1988 -0
  387. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +7107 -0
  388. package/src/widgets/__tests__/__snapshots__/video_test.jsx.snap +163 -0
  389. package/src/widgets/__tests__/categorizer_test.jsx +205 -0
  390. package/src/widgets/__tests__/cs-program_test.jsx +45 -0
  391. package/src/widgets/__tests__/definition_test.js +152 -0
  392. package/src/widgets/__tests__/dropdown_test.jsx +106 -0
  393. package/src/widgets/__tests__/explanation_test.js +199 -0
  394. package/src/widgets/__tests__/expression_test.jsx +439 -0
  395. package/src/widgets/__tests__/graded-group-set-jipt_test.jsx +33 -0
  396. package/src/widgets/__tests__/graded-group-set_test.jsx +270 -0
  397. package/src/widgets/__tests__/graded-group_test.jsx +233 -0
  398. package/src/widgets/__tests__/grapher.cypress.jsx +486 -0
  399. package/src/widgets/__tests__/grapher_test.jsx +35 -0
  400. package/src/widgets/__tests__/group_test.jsx +512 -0
  401. package/src/widgets/__tests__/iframe_test.jsx +47 -0
  402. package/src/widgets/__tests__/image_test.jsx +65 -0
  403. package/src/widgets/__tests__/input-number_test.jsx +360 -0
  404. package/src/widgets/__tests__/interaction_test.jsx +33 -0
  405. package/src/widgets/__tests__/interactive-graph.cypress.jsx +697 -0
  406. package/src/widgets/__tests__/interactive-graph_test.jsx +105 -0
  407. package/src/widgets/__tests__/matcher_test.jsx +157 -0
  408. package/src/widgets/__tests__/matrix_test.jsx +84 -0
  409. package/src/widgets/__tests__/number-line_test.jsx +81 -0
  410. package/src/widgets/__tests__/numeric-input_test.jsx +574 -0
  411. package/src/widgets/__tests__/orderer_test.jsx +75 -0
  412. package/src/widgets/__tests__/passage-ref_test.jsx +166 -0
  413. package/src/widgets/__tests__/passage_test.jsx +133 -0
  414. package/src/widgets/__tests__/radio/base-radio_test.jsx +82 -0
  415. package/src/widgets/__tests__/radio_test.jsx +651 -0
  416. package/src/widgets/__tests__/renderQuestion.jsx +65 -0
  417. package/src/widgets/__tests__/unit_test.jsx +144 -0
  418. package/src/widgets/__tests__/video_test.jsx +55 -0
  419. package/src/widgets/categorizer.jsx +338 -0
  420. package/src/widgets/cs-program.jsx +228 -0
  421. package/src/widgets/definition.jsx +141 -0
  422. package/src/widgets/dropdown.jsx +143 -0
  423. package/src/widgets/explanation.jsx +315 -0
  424. package/src/widgets/expression.jsx +654 -0
  425. package/src/widgets/graded-group-answer-bar.jsx +152 -0
  426. package/src/widgets/graded-group-set.jsx +285 -0
  427. package/src/widgets/graded-group.jsx +448 -0
  428. package/src/widgets/grapher/util.jsx +819 -0
  429. package/src/widgets/grapher.jsx +658 -0
  430. package/src/widgets/group.jsx +213 -0
  431. package/src/widgets/iframe.jsx +199 -0
  432. package/src/widgets/image.jsx +309 -0
  433. package/src/widgets/input-number.jsx +410 -0
  434. package/src/widgets/interaction/arrow-picker.jsx +39 -0
  435. package/src/widgets/interaction/color-picker.jsx +69 -0
  436. package/src/widgets/interaction/dash-picker.jsx +46 -0
  437. package/src/widgets/interaction/element-container.jsx +97 -0
  438. package/src/widgets/interaction.jsx +742 -0
  439. package/src/widgets/interactive-graph.jsx +2821 -0
  440. package/src/widgets/label-image/__stories__/answer-choices.stories.jsx +93 -0
  441. package/src/widgets/label-image/__stories__/marker.stories.jsx +172 -0
  442. package/src/widgets/label-image/answer-choices.jsx +311 -0
  443. package/src/widgets/label-image/marker.jsx +365 -0
  444. package/src/widgets/label-image/types.js +33 -0
  445. package/src/widgets/label-image.jsx +841 -0
  446. package/src/widgets/label-image_test.js +840 -0
  447. package/src/widgets/lights-puzzle.jsx +356 -0
  448. package/src/widgets/matcher.jsx +271 -0
  449. package/src/widgets/matrix.jsx +615 -0
  450. package/src/widgets/measurer.jsx +215 -0
  451. package/src/widgets/molecule/__tests__/layout_test.jsx +159 -0
  452. package/src/widgets/molecule/__tests__/parser_test.jsx +142 -0
  453. package/src/widgets/molecule/molecule-drawing.jsx +175 -0
  454. package/src/widgets/molecule/molecule-layout.jsx +367 -0
  455. package/src/widgets/molecule/smiles-parser.jsx +300 -0
  456. package/src/widgets/molecule.jsx +172 -0
  457. package/src/widgets/number-line.jsx +858 -0
  458. package/src/widgets/numeric-input.jsx +622 -0
  459. package/src/widgets/orderer.jsx +753 -0
  460. package/src/widgets/passage/__tests__/passage-markdown_test.jsx +382 -0
  461. package/src/widgets/passage/get-line-height-for-node.js +10 -0
  462. package/src/widgets/passage/passage-markdown.jsx +416 -0
  463. package/src/widgets/passage-ref-target.jsx +70 -0
  464. package/src/widgets/passage-ref.jsx +198 -0
  465. package/src/widgets/passage.jsx +601 -0
  466. package/src/widgets/plotter.jsx +1198 -0
  467. package/src/widgets/radio/__stories__/base-radio.stories.jsx +40 -0
  468. package/src/widgets/radio/__stories__/choice-icon.stories.jsx +157 -0
  469. package/src/widgets/radio/__stories__/choice-none-above.stories.jsx +26 -0
  470. package/src/widgets/radio/__stories__/cross-out-button.stories.jsx +64 -0
  471. package/src/widgets/radio/__stories__/cross-out-menu.stories.jsx +59 -0
  472. package/src/widgets/radio/__stories__/cross-out-tooltip.stories.jsx +59 -0
  473. package/src/widgets/radio/__stories__/focus-ring.stories.jsx +38 -0
  474. package/src/widgets/radio/__stories__/option-status.stories.jsx +118 -0
  475. package/src/widgets/radio/__stories__/toggleable-radio-button.stories.jsx +28 -0
  476. package/src/widgets/radio/base-radio.jsx +642 -0
  477. package/src/widgets/radio/choice-icon.jsx +468 -0
  478. package/src/widgets/radio/choice-none-above.jsx +54 -0
  479. package/src/widgets/radio/choice.jsx +692 -0
  480. package/src/widgets/radio/choice.jsx.fixture.js +64 -0
  481. package/src/widgets/radio/cross-out-button.jsx +136 -0
  482. package/src/widgets/radio/cross-out-menu-wrapper.jsx +492 -0
  483. package/src/widgets/radio/cross-out-tooltip.jsx +129 -0
  484. package/src/widgets/radio/focus-ring.jsx +51 -0
  485. package/src/widgets/radio/option-status.jsx +89 -0
  486. package/src/widgets/radio/toggleable-radio-button.jsx +133 -0
  487. package/src/widgets/radio/util.js +32 -0
  488. package/src/widgets/radio/util_test.js +19 -0
  489. package/src/widgets/radio/widget.jsx +470 -0
  490. package/src/widgets/radio.jsx +156 -0
  491. package/src/widgets/reaction-diagram.jsx +136 -0
  492. package/src/widgets/sequence.jsx +140 -0
  493. package/src/widgets/simulator.jsx +808 -0
  494. package/src/widgets/sorter.jsx +146 -0
  495. package/src/widgets/table.jsx +395 -0
  496. package/src/widgets/transformer.jsx +2870 -0
  497. package/src/widgets/unit.jsx +371 -0
  498. package/src/widgets/video-transcript-link.jsx +84 -0
  499. package/src/widgets/video-transcript-link_test.js +211 -0
  500. package/src/widgets/video.jsx +131 -0
  501. package/src/widgets.js +431 -0
  502. package/src/zoom.js +539 -0
  503. package/src/__tests__/index.test.js +0 -14
@@ -0,0 +1,2821 @@
1
+ /* eslint-disable @babel/no-invalid-this, react/no-unsafe, react/sort-comp */
2
+ // @flow
3
+ import * as i18n from "@khanacademy/wonder-blocks-i18n";
4
+ import $ from "jquery";
5
+ import kmath from "kmath";
6
+ import debounce from "lodash.debounce";
7
+ import * as React from "react";
8
+ import _ from "underscore";
9
+
10
+ import Graph from "../components/graph.jsx";
11
+ import Interactive2 from "../interactive2.js";
12
+ import WrappedLine from "../interactive2/wrapped-line.js";
13
+ import {Errors} from "../logging/log.js";
14
+ import {PerseusError} from "../perseus-error.js";
15
+ import Util from "../util.js";
16
+ import KhanColors from "../util/colors.js";
17
+ import GraphUtils from "../util/graph-utils.js";
18
+ import {getInteractiveBoxFromSizeClass} from "../util/sizing-utils.js";
19
+
20
+ import type {Coord, Line} from "../interactive2/types.js";
21
+ import type {
22
+ PerseusGraphType,
23
+ PerseusGraphTypeAngle,
24
+ PerseusGraphTypePoint,
25
+ PerseusGraphTypePolygon,
26
+ PerseusGraphTypeSegment,
27
+ PerseusInteractiveGraphWidgetOptions,
28
+ } from "../perseus-types.js";
29
+ import type {PerseusScore, WidgetExports, WidgetProps} from "../types.js";
30
+
31
+ type Range = $ReadOnlyArray<number>; // This should really be a readonly tuple of [number, number]
32
+ type SineCoefficient = [
33
+ /* amplitude */ number,
34
+ /* angularFrequency */ number,
35
+ /* phase */ number,
36
+ /* verticalOffset */ number,
37
+ ];
38
+ type QuadraticCoefficient = [/* a */ number, /* b */ number, /* c */ number];
39
+
40
+ const {number: knumber, point: kpoint} = kmath;
41
+ const {DeprecationMixin} = Util;
42
+
43
+ const TRASH_ICON_URI =
44
+ "https://ka-perseus-graphie.s3.amazonaws.com/b1452c0d79fd0f7ff4c3af9488474a0a0decb361.png";
45
+
46
+ const defaultBackgroundImage = {
47
+ url: null,
48
+ };
49
+
50
+ const eq = Util.eq;
51
+ const deepEq = Util.deepEq;
52
+
53
+ const UNLIMITED = ("unlimited": "unlimited");
54
+
55
+ // Sample background image:
56
+ // https://ka-perseus-graphie.s3.amazonaws.com/29c1b0fcd17fe63df0f148fe357044d5d5c7d0bb.png
57
+
58
+ function ccw(a: Coord, b: Coord, c: Coord): number {
59
+ return (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]);
60
+ }
61
+
62
+ function collinear(a: Coord, b: Coord, c: Coord): boolean {
63
+ return eq(ccw(a, b, c), 0);
64
+ }
65
+
66
+ function sign(val: number): 0 | 1 | -1 {
67
+ if (eq(val, 0)) {
68
+ return 0;
69
+ }
70
+ return val > 0 ? 1 : -1;
71
+ }
72
+
73
+ // default to defaultValue if actual is null or undefined
74
+ function defaultVal<T>(actual: ?T, defaultValue: T): T {
75
+ return actual == null ? defaultValue : actual;
76
+ }
77
+
78
+ // Given rect bounding points A and B, whether point C is inside the rect
79
+ function pointInRect(a: Coord, b: Coord, c: Coord): boolean {
80
+ return (
81
+ c[0] <= Math.max(a[0], b[0]) &&
82
+ c[0] >= Math.min(a[0], b[0]) &&
83
+ c[1] <= Math.max(a[1], b[1]) &&
84
+ c[1] >= Math.min(a[1], b[1])
85
+ );
86
+ }
87
+
88
+ // Whether line segment AB intersects line segment CD
89
+ // http://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/
90
+ function intersects(ab: Line, cd: Line): boolean {
91
+ const triplets = [
92
+ [ab[0], ab[1], cd[0]],
93
+ [ab[0], ab[1], cd[1]],
94
+ [cd[0], cd[1], ab[0]],
95
+ [cd[0], cd[1], ab[1]],
96
+ ];
97
+
98
+ const orientations = _.map(triplets, function (triplet) {
99
+ return sign(ccw(...triplet));
100
+ });
101
+
102
+ if (
103
+ orientations[0] !== orientations[1] &&
104
+ orientations[2] !== orientations[3]
105
+ ) {
106
+ return true;
107
+ }
108
+
109
+ for (let i = 0; i < 4; i++) {
110
+ if (orientations[i] === 0 && pointInRect(...triplets[i])) {
111
+ return true;
112
+ }
113
+ }
114
+
115
+ return false;
116
+ }
117
+
118
+ function vector(a, b) {
119
+ return _.map(_.zip(a, b), function (pair) {
120
+ return pair[0] - pair[1];
121
+ });
122
+ }
123
+
124
+ function magnitude(v: $ReadOnlyArray<Coord>): number {
125
+ return Math.sqrt(
126
+ _.reduce(
127
+ v,
128
+ function (memo, el) {
129
+ return memo + Math.pow(el, 2);
130
+ },
131
+ 0,
132
+ ),
133
+ );
134
+ }
135
+
136
+ function dotProduct(a: Coord, b: Coord): number {
137
+ return _.reduce(
138
+ _.zip(a, b),
139
+ function (memo, pair) {
140
+ return memo + pair[0] * pair[1];
141
+ },
142
+ 0,
143
+ );
144
+ }
145
+
146
+ function sideLengths(coords: $ReadOnlyArray<Coord>): $ReadOnlyArray<number> {
147
+ const segments = _.zip(coords, rotate(coords));
148
+ return segments.map(function (segment) {
149
+ return magnitude(vector(...segment));
150
+ });
151
+ }
152
+
153
+ // Based on http://math.stackexchange.com/a/151149
154
+ function angleMeasures(coords: $ReadOnlyArray<Coord>): $ReadOnlyArray<number> {
155
+ const triplets = _.zip(rotate(coords, -1), coords, rotate(coords, 1));
156
+
157
+ const offsets = _.map(triplets, function (triplet) {
158
+ const p = vector(triplet[1], triplet[0]);
159
+ const q = vector(triplet[2], triplet[1]);
160
+ const raw = Math.acos(dotProduct(p, q) / (magnitude(p) * magnitude(q)));
161
+ return sign(ccw(...triplet)) > 0 ? raw : -raw;
162
+ });
163
+
164
+ const sum = _.reduce(
165
+ offsets,
166
+ function (memo, arg) {
167
+ return memo + arg;
168
+ },
169
+ 0,
170
+ );
171
+
172
+ return _.map(offsets, function (offset) {
173
+ return sum > 0 ? Math.PI - offset : Math.PI + offset;
174
+ });
175
+ }
176
+
177
+ // Whether two polygons are similar (or if specified, congruent)
178
+ function similar(
179
+ coords1: $ReadOnlyArray<Coord>,
180
+ coords2: $ReadOnlyArray<Coord>,
181
+ tolerance: number,
182
+ ): boolean {
183
+ if (coords1.length !== coords2.length) {
184
+ return false;
185
+ }
186
+
187
+ const n = coords1.length;
188
+
189
+ const angles1 = angleMeasures(coords1);
190
+ const angles2 = angleMeasures(coords2);
191
+
192
+ const sides1 = sideLengths(coords1);
193
+ const sides2 = sideLengths(coords2);
194
+
195
+ for (let i = 0; i < 2 * n; i++) {
196
+ let angles = angles2.slice();
197
+ let sides = sides2.slice();
198
+
199
+ // Reverse angles and sides to allow matching reflected polygons
200
+ if (i >= n) {
201
+ angles.reverse();
202
+ sides.reverse();
203
+ // Since sides are calculated from two coordinates,
204
+ // simply reversing results in an off by one error
205
+ sides = rotate(sides, 1);
206
+ }
207
+
208
+ angles = rotate(angles, i);
209
+ sides = rotate(sides, i);
210
+
211
+ if (deepEq(angles1, angles)) {
212
+ const sidePairs = _.zip(sides1, sides);
213
+
214
+ const factors = _.map(sidePairs, function (pair) {
215
+ return pair[0] / pair[1];
216
+ });
217
+
218
+ const same = _.all(factors, function (factor) {
219
+ return eq(factors[0], factor);
220
+ });
221
+
222
+ const congruentEnough = _.all(sidePairs, function (pair) {
223
+ return knumber.equal(pair[0], pair[1], tolerance);
224
+ });
225
+
226
+ if (same && congruentEnough) {
227
+ return true;
228
+ }
229
+ }
230
+ }
231
+
232
+ return false;
233
+ }
234
+
235
+ // Less than or approximately equal
236
+ function leq(a, b) {
237
+ return a < b || eq(a, b);
238
+ }
239
+
240
+ // Given triangle with sides ABC return angle opposite side C in degrees
241
+ function lawOfCosines(a: number, b: number, c: number): number {
242
+ return (Math.acos((a * a + b * b - c * c) / (2 * a * b)) * 180) / Math.PI;
243
+ }
244
+
245
+ function canonicalSineCoefficients([
246
+ amplitude,
247
+ angularFrequency,
248
+ phase,
249
+ verticalOffset,
250
+ ]: SineCoefficient) {
251
+ // For a curve of the form f(x) = a * Sin(b * x - c) + d,
252
+ // this function ensures that a, b > 0, and c is its
253
+ // smallest possible positive value.
254
+
255
+ // Guarantee a > 0
256
+ if (amplitude < 0) {
257
+ amplitude *= -1;
258
+ angularFrequency *= -1;
259
+ phase *= -1;
260
+ }
261
+
262
+ const period = 2 * Math.PI;
263
+ // Guarantee b > 0
264
+ if (angularFrequency < 0) {
265
+ angularFrequency *= -1;
266
+ phase *= -1;
267
+ phase += period / 2;
268
+ }
269
+
270
+ // Guarantee c is smallest possible positive value
271
+ while (phase > 0) {
272
+ phase -= period;
273
+ }
274
+ while (phase < 0) {
275
+ phase += period;
276
+ }
277
+
278
+ return [amplitude, angularFrequency, phase, verticalOffset];
279
+ }
280
+
281
+ // e.g. rotate([1, 2, 3]) -> [2, 3, 1]
282
+ function rotate<T>(array: $ReadOnlyArray<T>, n?: number): $ReadOnlyArray<T> {
283
+ n = typeof n === "undefined" ? 1 : n % array.length;
284
+ return array.slice(n).concat(array.slice(0, n));
285
+ }
286
+
287
+ function capitalize(str) {
288
+ return str.replace(/(?:^|-)(.)/g, function (match, letter) {
289
+ return letter.toUpperCase();
290
+ });
291
+ }
292
+
293
+ function getLineEquation(first: Coord, second: Coord): string {
294
+ if (eq(first[0], second[0])) {
295
+ return "x = " + first[0].toFixed(3);
296
+ }
297
+ const m = (second[1] - first[1]) / (second[0] - first[0]);
298
+ const b = first[1] - m * first[0];
299
+ return "y = " + m.toFixed(3) + "x + " + b.toFixed(3);
300
+ }
301
+
302
+ // Stolen from the wikipedia article
303
+ // http://en.wikipedia.org/wiki/Line-line_intersection
304
+ function getLineIntersection(
305
+ // TODO(LP-10725): update these to be 2-tuples
306
+ firstPoints: $ReadOnlyArray<Coord>,
307
+ secondPoints: $ReadOnlyArray<Coord>,
308
+ ): string {
309
+ const x1 = firstPoints[0][0];
310
+ const y1 = firstPoints[0][1];
311
+ const x2 = firstPoints[1][0];
312
+ const y2 = firstPoints[1][1];
313
+ const x3 = secondPoints[0][0];
314
+ const y3 = secondPoints[0][1];
315
+ const x4 = secondPoints[1][0];
316
+ const y4 = secondPoints[1][1];
317
+
318
+ const determinant = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
319
+
320
+ if (Math.abs(determinant) < 1e-9) {
321
+ return "Lines are parallel";
322
+ }
323
+ const x =
324
+ ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) /
325
+ determinant;
326
+ const y =
327
+ ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) /
328
+ determinant;
329
+ return "Intersection: (" + x.toFixed(3) + ", " + y.toFixed(3) + ")";
330
+ }
331
+
332
+ function numSteps(range: Range, step: number) {
333
+ return Math.floor((range[1] - range[0]) / step);
334
+ }
335
+
336
+ const deprecatedProps = {
337
+ showGraph: function (props) {
338
+ return {markings: props.showGraph ? "graph" : "none"};
339
+ },
340
+ };
341
+
342
+ const _getShouldShowInstructions: (Props) => boolean = (props) => {
343
+ return (
344
+ _isClickToAddPoints(props) &&
345
+ (props.graph.coords == null || props.graph.coords.length === 0)
346
+ );
347
+ };
348
+
349
+ const _isClickToAddPoints: (Props) => boolean = (props) => {
350
+ return (
351
+ (props.graph.type === "point" && props.graph.numPoints === UNLIMITED) ||
352
+ (props.graph.type === "polygon" && props.graph.numSides === UNLIMITED)
353
+ );
354
+ };
355
+
356
+ const makeInvalidTypeError = (
357
+ functionName: string,
358
+ graphType: string,
359
+ ): PerseusError => {
360
+ return new PerseusError(
361
+ `${functionName} called but current graph type is not a '${graphType}'`,
362
+ Errors.NotAllowed,
363
+ {metadata: {graphType}},
364
+ );
365
+ };
366
+
367
+ type RenderProps = PerseusInteractiveGraphWidgetOptions; // There's no transform function in exports
368
+ type Rubric = PerseusInteractiveGraphWidgetOptions;
369
+ type Props = WidgetProps<RenderProps, Rubric>;
370
+ type State = $FlowFixMe;
371
+ type DefaultProps = {|
372
+ labels: Props["labels"],
373
+ range: Props["range"],
374
+ step: Props["step"],
375
+ backgroundImage: Props["backgroundImage"],
376
+ markings: Props["markings"],
377
+ showTooltips: Props["showTooltips"],
378
+ showProtractor: Props["showProtractor"],
379
+ showRuler: Props["showRuler"],
380
+ rulerLabel: Props["rulerLabel"],
381
+ rulerTicks: Props["rulerTicks"],
382
+ graph: Props["graph"],
383
+ |};
384
+
385
+ class InteractiveGraph extends React.Component<Props, State> {
386
+ angle: ?$FlowFixMe;
387
+ circle: ?$FlowFixMe;
388
+ graphie: ?$FlowFixMe;
389
+ horizHairline: ?$FlowFixMe;
390
+ line: ?$FlowFixMe;
391
+ lines: ?$ReadOnlyArray<$ReadOnlyArray<Coord>>;
392
+ parabola: ?$FlowFixMe;
393
+ pointA: ?$FlowFixMe;
394
+ pointB: ?$FlowFixMe;
395
+ pointC: ?$FlowFixMe;
396
+ // eslint-disable-next-line flowtype/no-mutable-array
397
+ points: Array<$FlowFixMe>;
398
+ polygon: ?$FlowFixMe;
399
+ shouldResetGraphie: boolean;
400
+ sinusoid: ?$FlowFixMe;
401
+ trashCan: ?$FlowFixMe;
402
+ vertHairline: ?$FlowFixMe;
403
+
404
+ static defaultProps: DefaultProps = {
405
+ labels: ["x", "y"],
406
+ range: [
407
+ [-10, 10],
408
+ [-10, 10],
409
+ ],
410
+ step: [1, 1],
411
+ // $FlowFixMe[prop-missing]
412
+ backgroundImage: defaultBackgroundImage,
413
+ markings: "graph",
414
+ showTooltips: false,
415
+ showProtractor: false,
416
+ showRuler: false,
417
+ rulerLabel: "",
418
+ rulerTicks: 10,
419
+ graph: {
420
+ type: "linear",
421
+ },
422
+ };
423
+
424
+ state: State = {
425
+ shouldShowInstructions: _getShouldShowInstructions(this.props),
426
+ };
427
+
428
+ // TODO(jangmi, CP-3288): Remove usage of `UNSAFE_componentWillMount`
429
+ UNSAFE_componentWillMount() {
430
+ DeprecationMixin.UNSAFE_componentWillMount.call(this);
431
+ }
432
+
433
+ componentDidMount() {
434
+ // eslint-disable-next-line react/no-string-refs
435
+ this.setGraphie(this.refs.graph.graphie());
436
+ }
437
+
438
+ UNSAFE_componentWillReceiveProps(nextProps: Props) {
439
+ if (this.isClickToAddPoints() !== this.isClickToAddPoints(nextProps)) {
440
+ this.shouldResetGraphie = true;
441
+ this.setState({
442
+ shouldShowInstructions: _getShouldShowInstructions(nextProps),
443
+ });
444
+ }
445
+
446
+ if (
447
+ this.props.backgroundImage?.url !==
448
+ nextProps.backgroundImage?.url ||
449
+ this.props.backgroundImage !== nextProps.backgroundImage ||
450
+ this.props.containerSizeClass !== nextProps.containerSizeClass
451
+ ) {
452
+ this.shouldResetGraphie = true;
453
+ }
454
+ }
455
+
456
+ componentDidUpdate(prevProps: Props, prevState: State) {
457
+ // TODO(jeremy): This feels underspecified. For example, if the props
458
+ // change the coords of a segment graph, but not the numSegments, we'd
459
+ // not count that as an update.
460
+ const oldType = prevProps.graph.type;
461
+ const newType = this.props.graph.type;
462
+ if (
463
+ oldType !== newType ||
464
+ (prevProps.graph.type === "angle" &&
465
+ this.props.graph.type === "angle" &&
466
+ this.areAngleGraphsEqual(prevProps.graph, this.props.graph)) ||
467
+ (prevProps.graph.type === "point" &&
468
+ this.props.graph.type === "point" &&
469
+ this.arePointGraphsEqual(prevProps.graph, this.props.graph)) ||
470
+ (prevProps.graph.type === "polygon" &&
471
+ this.props.graph.type === "polygon" &&
472
+ this.arePolygonGraphsEqual(
473
+ prevProps.graph,
474
+ this.props.graph,
475
+ )) ||
476
+ (prevProps.graph.type === "segment" &&
477
+ this.props.graph.type === "segment" &&
478
+ this.areSegmentGraphsEqual(prevProps.graph, this.props.graph))
479
+ ) {
480
+ // $FlowFixMe[incompatible-use]
481
+ this["remove" + capitalize(oldType) + "Controls"]();
482
+ // $FlowFixMe[incompatible-use]
483
+ this["add" + capitalize(newType) + "Controls"]();
484
+ }
485
+ if (this.shouldResetGraphie) {
486
+ this.resetGraphie();
487
+ }
488
+ }
489
+
490
+ _getShouldShowInstructions: (?Props) => boolean = (props) => {
491
+ props = props || this.props;
492
+ return (
493
+ this.isClickToAddPoints(props) &&
494
+ (props.graph.coords == null || props.graph.coords.length === 0)
495
+ );
496
+ };
497
+
498
+ deprecatedProps: $FlowFixMe = deprecatedProps;
499
+
500
+ setGraphie: ($FlowFixMe) => void = (newGraphie) => {
501
+ this.graphie = newGraphie;
502
+ this.setupGraphie();
503
+ };
504
+
505
+ handleAddPointsMouseDown: (Coord) => void = (coord) => {
506
+ // This function should only be called when this.isClickToAddPoints()
507
+ // is true
508
+ if (!this.isClickToAddPoints()) {
509
+ throw new PerseusError(
510
+ "handleAddPointsClick should not be registered" +
511
+ "when isClickToAddPoints() is false",
512
+ Errors.NotAllowed,
513
+ );
514
+ }
515
+ if (!this.isCoordInTrash(coord)) {
516
+ let point;
517
+
518
+ if (this.props.graph.type === "point") {
519
+ point = this.createPointForPointsType(
520
+ coord,
521
+ this.points.length,
522
+ );
523
+ if (!point.constrain()) {
524
+ point.remove();
525
+ return;
526
+ }
527
+ this.points.push(point);
528
+
529
+ // interactive2 allows us to grab the point
530
+ const idx = this.points.length - 1;
531
+ this.points[idx].grab(coord);
532
+
533
+ this.updateCoordsFromPoints();
534
+ } else if (this.props.graph.type === "polygon") {
535
+ if (this.polygon?.closed()) {
536
+ return;
537
+ }
538
+ point = this.createPointForPolygonType(
539
+ coord,
540
+ this.points.length,
541
+ );
542
+ this.points.push(point);
543
+
544
+ const idx = this.points.length - 1;
545
+ this.points[idx].grab(coord);
546
+
547
+ // We don't call updateCoordsFromPoints for
548
+ // polygons, since the polygon won't be
549
+ // closed yet.
550
+ this.updatePolygon();
551
+ }
552
+
553
+ this.setState({
554
+ shouldShowInstructions: false,
555
+ });
556
+ }
557
+ };
558
+
559
+ resetGraphie: () => void = () => {
560
+ this.shouldResetGraphie = false;
561
+ this.parabola = null;
562
+ this.sinusoid = null;
563
+ // eslint-disable-next-line react/no-string-refs
564
+ this.refs.graph.reset();
565
+ };
566
+
567
+ setupGraphie: () => void = () => {
568
+ this.setTrashCanVisibility(0);
569
+ if (this.isClickToAddPoints()) {
570
+ this.setTrashCanVisibility(0.5);
571
+ }
572
+
573
+ if (this.props.apiOptions.isMobile) {
574
+ // $FlowFixMe[invalid-constructor]
575
+ this.horizHairline = new WrappedLine(this.graphie, [0, 0], [0, 0], {
576
+ normalStyle: {
577
+ strokeWidth: 1,
578
+ },
579
+ });
580
+ this.horizHairline.attr({
581
+ stroke: KhanColors.INTERACTIVE,
582
+ });
583
+ this.horizHairline?.hide();
584
+
585
+ // $FlowFixMe[invalid-constructor]
586
+ this.vertHairline = new WrappedLine(this.graphie, [0, 0], [0, 0], {
587
+ normalStyle: {
588
+ strokeWidth: 1,
589
+ },
590
+ });
591
+ this.vertHairline.attr({
592
+ stroke: KhanColors.INTERACTIVE,
593
+ });
594
+ this.vertHairline?.hide();
595
+ }
596
+
597
+ const type = this.props.graph.type;
598
+ // $FlowFixMe[incompatible-use]
599
+ this["add" + capitalize(type) + "Controls"]();
600
+ };
601
+
602
+ showHairlines: (Coord) => void = (point) => {
603
+ if (this.props.apiOptions.isMobile && this.props.markings !== "none") {
604
+ // Hairlines are already initialized when the graph is loaded, so
605
+ // here we just move them to the updated location and make them
606
+ // visible.
607
+ this.horizHairline?.moveTo(
608
+ [this.props.range[0][0], point[1]],
609
+ [this.props.range[0][1], point[1]],
610
+ );
611
+
612
+ this.horizHairline?.show();
613
+
614
+ this.vertHairline?.moveTo(
615
+ [point[0], this.props.range[1][0]],
616
+ [point[0], this.props.range[1][1]],
617
+ );
618
+
619
+ this.vertHairline?.show();
620
+ }
621
+ };
622
+
623
+ hideHairlines: () => void = () => {
624
+ if (this.props.apiOptions.isMobile) {
625
+ this.horizHairline?.hide();
626
+ this.vertHairline?.hide();
627
+ }
628
+ };
629
+
630
+ setTrashCanVisibility: (number) => void = (opacity) => {
631
+ const graphie = this.graphie;
632
+
633
+ if (knumber.equal(opacity, 0)) {
634
+ if (this.trashCan) {
635
+ this.trashCan.remove();
636
+ this.trashCan = null;
637
+ }
638
+ } else if (!this.props.apiOptions.isMobile) {
639
+ // Only if trash tooltips are not being used, we initialize the old
640
+ // trash can area.
641
+ if (!this.trashCan) {
642
+ this.trashCan = graphie?.raphael?.image(
643
+ TRASH_ICON_URI,
644
+ graphie.xpixels - 40,
645
+ graphie.ypixels - 40,
646
+ 40,
647
+ 40,
648
+ );
649
+ }
650
+ this.trashCan?.attr({
651
+ opacity: opacity,
652
+ });
653
+ }
654
+ };
655
+
656
+ isClickToAddPoints: (?Props) => boolean = (props) => {
657
+ props = props || this.props;
658
+ return _isClickToAddPoints(props);
659
+ };
660
+
661
+ areAngleGraphsEqual(
662
+ prevGraph: PerseusGraphTypeAngle,
663
+ currentGraph: PerseusGraphTypeAngle,
664
+ ): boolean {
665
+ return (
666
+ prevGraph.allowReflexAngles !== currentGraph.allowReflexAngles ||
667
+ prevGraph.angleOffsetDeg !== currentGraph.angleOffsetDeg ||
668
+ prevGraph.snapDegrees !== currentGraph.snapDegrees
669
+ );
670
+ }
671
+
672
+ arePointGraphsEqual(
673
+ prevGraph: PerseusGraphTypePoint,
674
+ currentGraph: PerseusGraphTypePoint,
675
+ ): boolean {
676
+ return prevGraph.numPoints !== currentGraph.numPoints;
677
+ }
678
+
679
+ arePolygonGraphsEqual(
680
+ prevGraph: PerseusGraphTypePolygon,
681
+ currentGraph: PerseusGraphTypePolygon,
682
+ ): boolean {
683
+ return (
684
+ prevGraph.numSides !== currentGraph.numSides ||
685
+ prevGraph.showAngles !== currentGraph.showAngles ||
686
+ prevGraph.showSides !== currentGraph.showSides ||
687
+ prevGraph.snapTo !== currentGraph.snapTo
688
+ );
689
+ }
690
+
691
+ areSegmentGraphsEqual(
692
+ prevGraph: PerseusGraphTypeSegment,
693
+ currentGraph: PerseusGraphTypeSegment,
694
+ ): boolean {
695
+ return prevGraph.numSegments !== currentGraph.numSegments;
696
+ }
697
+
698
+ _lineStroke: () => {|"stroke-width"?: number|} = () => {
699
+ // This should probably use: this.props.apiOptions.isMobile
700
+ // $FlowFixMe[prop-missing]
701
+ // $FlowFixMe[incompatible-exact]
702
+ return this.props.isMobile ? {"stroke-width": 3} : {};
703
+ };
704
+
705
+ addLine: (string) => void = (type) => {
706
+ const self = this;
707
+ const graphie = self.graphie;
708
+ const coords = InteractiveGraph.getLineCoords(
709
+ self.props.graph,
710
+ self.props,
711
+ );
712
+
713
+ const points = (self.points = _.map(coords, (coord) => {
714
+ return Interactive2.addMaybeMobileMovablePoint(this, {
715
+ coord: coord,
716
+ constraints: [
717
+ // $FlowFixMe[prop-missing]
718
+ Interactive2.MovablePoint.constraints.bound(),
719
+ // $FlowFixMe[prop-missing]
720
+ Interactive2.MovablePoint.constraints.snap(),
721
+ ],
722
+ onMove: () => {
723
+ const graph = _.extend({}, self.props.graph, {
724
+ coords: _.invoke(points, "coord"),
725
+ });
726
+ self.onChange({graph: graph});
727
+ },
728
+ });
729
+ }));
730
+
731
+ const lineConfig = {
732
+ points: points,
733
+ static: true,
734
+ normalStyle: {
735
+ stroke: this.props.apiOptions.isMobile
736
+ ? KhanColors.BLUE_C
737
+ : KhanColors.INTERACTIVE,
738
+ ...this._lineStroke(),
739
+ },
740
+ };
741
+
742
+ if (type === "line") {
743
+ // $FlowFixMe[prop-missing]
744
+ lineConfig.extendLine = true;
745
+ } else if (type === "ray") {
746
+ // $FlowFixMe[prop-missing]
747
+ lineConfig.extendRay = true;
748
+ }
749
+
750
+ self.line = Interactive2.addMovableLine(graphie, lineConfig);
751
+
752
+ // A and B can't be in the same place
753
+ points[0].listen("constraints", "isLine", (coord) => {
754
+ return !kpoint.equal(coord, points[1].coord());
755
+ });
756
+ points[1].listen("constraints", "isLine", (coord) => {
757
+ return !kpoint.equal(coord, points[0].coord());
758
+ });
759
+ };
760
+
761
+ removeLine: () => void = () => {
762
+ _.invoke(this.points, "remove");
763
+ this.line?.remove();
764
+ };
765
+
766
+ addLinearControls: () => void = () => {
767
+ this.addLine("line");
768
+ };
769
+
770
+ removeLinearControls: () => void = () => {
771
+ this.removeLine();
772
+ };
773
+
774
+ addQuadraticControls: () => void = () => {
775
+ if (this.props.graph.type !== "quadratic") {
776
+ throw makeInvalidTypeError("addQuadraticControls", "quadratic");
777
+ }
778
+
779
+ let coords = this.props.graph.coords;
780
+ if (!coords) {
781
+ coords = InteractiveGraph.defaultQuadraticCoords(this.props);
782
+ }
783
+
784
+ const onMoveHandler = () => {
785
+ const graph = _.extend({}, this.props.graph, {
786
+ // $FlowFixMe[incompatible-use]
787
+ coords: [pointA.coord(), pointB.coord(), pointC.coord()],
788
+ });
789
+ this.onChange({graph});
790
+ this.updateQuadratic();
791
+ };
792
+
793
+ // NOTE(jeresig): This code attempts to access the variable while it's
794
+ // still being defined, it should be refactored! The callbacks appear
795
+ // to be executed synchronously, which causes this issue.
796
+ let pointA = null;
797
+ pointA = this.pointA = Interactive2.addMaybeMobileMovablePoint(this, {
798
+ coord: coords[0],
799
+ constraints: [
800
+ // $FlowFixMe[prop-missing]
801
+ Interactive2.MovablePoint.constraints.bound(),
802
+ // $FlowFixMe[prop-missing]
803
+ Interactive2.MovablePoint.constraints.snap(),
804
+ (coord) => {
805
+ return (
806
+ !pointA ||
807
+ // $FlowFixMe[incompatible-use]
808
+ (coord[0] !== pointB.coord()[0] &&
809
+ // $FlowFixMe[incompatible-use]
810
+ coord[0] !== pointC.coord()[0])
811
+ );
812
+ },
813
+ ],
814
+ onMove: onMoveHandler,
815
+ });
816
+
817
+ // NOTE(jeresig): This code attempts to access the variable while it's
818
+ // still being defined, it should be refactored! The callbacks appear
819
+ // to be executed synchronously, which causes this issue.
820
+ let pointB = null;
821
+ pointB = this.pointB = Interactive2.addMaybeMobileMovablePoint(this, {
822
+ coord: coords[1],
823
+ constraints: [
824
+ // $FlowFixMe[prop-missing]
825
+ Interactive2.MovablePoint.constraints.bound(),
826
+ // $FlowFixMe[prop-missing]
827
+ Interactive2.MovablePoint.constraints.snap(),
828
+ (coord) => {
829
+ return (
830
+ !pointB ||
831
+ // $FlowFixMe[incompatible-use]
832
+ (coord[0] !== pointA.coord()[0] &&
833
+ // $FlowFixMe[incompatible-use]
834
+ coord[0] !== pointC.coord()[0])
835
+ );
836
+ },
837
+ ],
838
+ onMove: onMoveHandler,
839
+ });
840
+
841
+ // NOTE(jeresig): This code attempts to access the variable while it's
842
+ // still being defined, it should be refactored! The callbacks appear
843
+ // to be executed synchronously, which causes this issue.
844
+ let pointC = null;
845
+ pointC = this.pointC = Interactive2.addMaybeMobileMovablePoint(this, {
846
+ coord: coords[2],
847
+ constraints: [
848
+ // $FlowFixMe[prop-missing]
849
+ Interactive2.MovablePoint.constraints.bound(),
850
+ // $FlowFixMe[prop-missing]
851
+ Interactive2.MovablePoint.constraints.snap(),
852
+ (coord) => {
853
+ return (
854
+ !pointC ||
855
+ // $FlowFixMe[incompatible-use]
856
+ (coord[0] !== pointA.coord()[0] &&
857
+ // $FlowFixMe[incompatible-use]
858
+ coord[0] !== pointB.coord()[0])
859
+ );
860
+ },
861
+ ],
862
+ onMove: onMoveHandler,
863
+ });
864
+
865
+ this.updateQuadratic();
866
+ };
867
+
868
+ updateQuadratic: () => void = () => {
869
+ const coeffs = InteractiveGraph.getCurrentQuadraticCoefficients(
870
+ this.props,
871
+ );
872
+ if (!coeffs) {
873
+ return;
874
+ }
875
+
876
+ // Extract coefficients the parabola
877
+ const a = coeffs[0];
878
+ const b = coeffs[1];
879
+ const c = coeffs[2];
880
+
881
+ // Plot and style
882
+ if (this.parabola) {
883
+ const path = this.graphie?.svgParabolaPath(a, b, c);
884
+ this.parabola?.attr({path: path});
885
+ } else {
886
+ this.parabola = this.graphie?.parabola(a, b, c);
887
+ this.parabola?.attr({
888
+ stroke: this.props.apiOptions.isMobile
889
+ ? KhanColors.BLUE_C
890
+ : KhanColors.DYNAMIC,
891
+ ...this._lineStroke(),
892
+ });
893
+ this.parabola?.toBack();
894
+ }
895
+ };
896
+
897
+ removeQuadraticControls: () => void = () => {
898
+ this.pointA?.remove();
899
+ this.pointB?.remove();
900
+ this.pointC?.remove();
901
+ if (this.parabola) {
902
+ this.parabola.remove();
903
+ this.parabola = null;
904
+ }
905
+ };
906
+
907
+ addSinusoidControls: () => void = () => {
908
+ if (this.props.graph.type !== "sinusoid") {
909
+ throw makeInvalidTypeError("addSinusoidControls", "sinusoid");
910
+ }
911
+ let coords = this.props.graph.coords;
912
+ if (!coords) {
913
+ coords = InteractiveGraph.defaultSinusoidCoords(this.props);
914
+ }
915
+
916
+ const onMoveHandler = () => {
917
+ const graph = _.extend({}, this.props.graph, {
918
+ // $FlowFixMe[incompatible-use]
919
+ coords: [pointA.coord(), pointB.coord()],
920
+ });
921
+ this.onChange({graph: graph});
922
+ this.updateSinusoid();
923
+ };
924
+
925
+ // NOTE(jeresig): This code attempts to access the variable while it's
926
+ // still being defined, it should be refactored! The callbacks appear
927
+ // to be executed synchronously, which causes this issue.
928
+ let pointA = null;
929
+ pointA = this.pointA = Interactive2.addMaybeMobileMovablePoint(this, {
930
+ coord: coords[0],
931
+ constraints: [
932
+ // $FlowFixMe[prop-missing]
933
+ Interactive2.MovablePoint.constraints.bound(),
934
+ // $FlowFixMe[prop-missing]
935
+ Interactive2.MovablePoint.constraints.snap(),
936
+ (coord) => {
937
+ return !pointA || coord[0] !== pointB.coord()[0];
938
+ },
939
+ ],
940
+ onMove: onMoveHandler,
941
+ });
942
+
943
+ const pointB = (this.pointB = Interactive2.addMaybeMobileMovablePoint(
944
+ this,
945
+ {
946
+ coord: coords[1],
947
+ constraints: [
948
+ // $FlowFixMe[prop-missing]
949
+ Interactive2.MovablePoint.constraints.bound(),
950
+ // $FlowFixMe[prop-missing]
951
+ Interactive2.MovablePoint.constraints.snap(),
952
+ (coord) => {
953
+ return !pointA || coord[0] !== pointA.coord()[0];
954
+ },
955
+ ],
956
+ onMove: onMoveHandler,
957
+ },
958
+ ));
959
+
960
+ this.updateSinusoid();
961
+ };
962
+
963
+ updateSinusoid: () => void = () => {
964
+ const coeffs = InteractiveGraph.getCurrentSinusoidCoefficients(
965
+ this.props,
966
+ );
967
+ if (!coeffs) {
968
+ return;
969
+ }
970
+
971
+ const a = coeffs[0];
972
+ const b = coeffs[1];
973
+ const c = coeffs[2];
974
+ const d = coeffs[3];
975
+
976
+ // Plot and style
977
+ if (this.sinusoid) {
978
+ const path = this.graphie?.svgSinusoidPath(a, b, c, d);
979
+ this.sinusoid?.attr({path: path});
980
+ } else {
981
+ this.sinusoid = this.graphie?.sinusoid(a, b, c, d);
982
+ this.sinusoid?.attr({
983
+ stroke: this.props.apiOptions.isMobile
984
+ ? KhanColors.BLUE_C
985
+ : KhanColors.DYNAMIC,
986
+ ...this._lineStroke(),
987
+ });
988
+ this.sinusoid?.toBack();
989
+ }
990
+ };
991
+
992
+ removeSinusoidControls: () => void = () => {
993
+ this.pointA?.remove();
994
+ this.pointB?.remove();
995
+ if (this.sinusoid) {
996
+ this.sinusoid.remove();
997
+ this.sinusoid = null;
998
+ }
999
+ };
1000
+
1001
+ addCircleControls: () => void = () => {
1002
+ const graphie = this.graphie;
1003
+ // $FlowFixMe[incompatible-use]
1004
+ const minSnap = _.min(graphie.snap);
1005
+
1006
+ const circle = (this.circle = graphie?.addCircleGraph({
1007
+ center: this.props.graph.center || [0, 0],
1008
+ radius: this.props.graph.radius || _.min(this.props.step),
1009
+ snapX: graphie.snap[0],
1010
+ snapY: graphie.snap[1],
1011
+ minRadius: minSnap,
1012
+ snapRadius: minSnap,
1013
+ }));
1014
+
1015
+ $(circle).on("move", () => {
1016
+ const graph = _.extend({}, this.props.graph, {
1017
+ // $FlowFixMe[incompatible-use]
1018
+ center: circle.center,
1019
+ // $FlowFixMe[incompatible-use]
1020
+ radius: circle.radius,
1021
+ });
1022
+ this.onChange({graph: graph});
1023
+ });
1024
+ };
1025
+
1026
+ removeCircleControls: () => void = () => {
1027
+ this.circle?.remove();
1028
+ };
1029
+
1030
+ addLinearSystemControls: () => void = () => {
1031
+ const graphie = this.graphie;
1032
+ const coords = InteractiveGraph.getLinearSystemCoords(
1033
+ this.props.graph,
1034
+ this.props,
1035
+ );
1036
+
1037
+ const segmentColors = [KhanColors.INTERACTIVE, KhanColors.GREEN];
1038
+ const points = (this.points = _.map(
1039
+ coords,
1040
+ (segmentCoords, segmentIndex) => {
1041
+ // NOTE(jeresig): This code attempts to access the variable
1042
+ // while it's still being defined, it should be refactored!
1043
+ // The callbacks appear to be executed synchronously, which
1044
+ // causes this issue.
1045
+ const segmentPoints = [];
1046
+
1047
+ for (let i = 0; i < segmentCoords.length; i += 1) {
1048
+ const coord = segmentCoords[i];
1049
+ segmentPoints.push(
1050
+ Interactive2.addMaybeMobileMovablePoint(this, {
1051
+ coord: coord,
1052
+ constraints: [
1053
+ // $FlowFixMe[prop-missing]
1054
+ Interactive2.MovablePoint.constraints.bound(),
1055
+ // $FlowFixMe[prop-missing]
1056
+ Interactive2.MovablePoint.constraints.snap(),
1057
+ (coord) => {
1058
+ const otherSegment = segmentPoints[1 - i];
1059
+ if (!otherSegment) {
1060
+ // segment hasn't been defined yet
1061
+ // because we're still creating them
1062
+ return;
1063
+ }
1064
+ return !kpoint.equal(
1065
+ coord,
1066
+ otherSegment.coord(),
1067
+ );
1068
+ },
1069
+ ],
1070
+ onMove: () => {
1071
+ const graph = _.extend({}, this.props.graph, {
1072
+ coords: _.map(this.points, (segment) =>
1073
+ _.invoke(segment, "coord"),
1074
+ ),
1075
+ });
1076
+ this.onChange({graph: graph});
1077
+ },
1078
+ normalStyle: {
1079
+ fill: segmentColors[segmentIndex],
1080
+ },
1081
+ highlightStyle: {
1082
+ fill: segmentColors[segmentIndex],
1083
+ },
1084
+ }),
1085
+ );
1086
+ }
1087
+
1088
+ return segmentPoints;
1089
+ },
1090
+ ));
1091
+
1092
+ this.lines = _.map(points, (segmentPoints, segmentIndex) => {
1093
+ return Interactive2.addMovableLine(graphie, {
1094
+ points: segmentPoints,
1095
+ static: true,
1096
+ extendLine: true,
1097
+ normalStyle: {
1098
+ stroke: segmentColors[segmentIndex],
1099
+ },
1100
+ });
1101
+ });
1102
+ };
1103
+
1104
+ removeLinearSystemControls: () => void = () => {
1105
+ _.invoke(this.lines, "remove");
1106
+ _.map(this.points, (segment) => _.invoke(segment, "remove"));
1107
+ };
1108
+
1109
+ isCoordInTrash: (Coord) => boolean = (coord) => {
1110
+ if (this.props.apiOptions.isMobile) {
1111
+ return false;
1112
+ }
1113
+
1114
+ const graphie = this.graphie;
1115
+ const screenPoint = graphie?.scalePoint(coord);
1116
+ return (
1117
+ // $FlowFixMe[incompatible-use]
1118
+ screenPoint[0] >= graphie.xpixels - 40 &&
1119
+ // $FlowFixMe[incompatible-use]
1120
+ screenPoint[1] >= graphie.ypixels - 40
1121
+ );
1122
+ };
1123
+
1124
+ createPointForPointsType: (Coord, number) => $FlowFixMe = (coord, i) => {
1125
+ const self = this;
1126
+
1127
+ const remove = () => {
1128
+ self.points = _.filter(self.points, function (pt) {
1129
+ return pt !== point;
1130
+ });
1131
+ // update the correct answer box
1132
+ self.updateCoordsFromPoints();
1133
+
1134
+ // remove this movablePoint from graphie.
1135
+ // we wait to do this until we're not inside of
1136
+ // said point's onMoveEnd method so its state is
1137
+ // consistent throughout this method call
1138
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
1139
+ // $FlowFixMe[incompatible-use]
1140
+ setTimeout(point.remove.bind(point), 0); // eslint-disable-line no-restricted-syntax
1141
+ };
1142
+
1143
+ // NOTE(jeresig): This code attempts to access the variable while it's
1144
+ // still being defined, it should be refactored! The callbacks appear
1145
+ // to be executed synchronously, which causes this issue.
1146
+ let point = null;
1147
+ point = Interactive2.addMaybeMobileMovablePoint(this, {
1148
+ coord: coord,
1149
+ constraints: [
1150
+ // $FlowFixMe[prop-missing]
1151
+ Interactive2.MovablePoint.constraints.bound(),
1152
+ // $FlowFixMe[prop-missing]
1153
+ Interactive2.MovablePoint.constraints.snap(),
1154
+ function (coord) {
1155
+ // TODO(jack): There ought to be a
1156
+ // MovablePoint.constraints.avoid
1157
+ // default that lets you do things like this
1158
+ return _.all(self.points, function (pt) {
1159
+ return point === pt || !kpoint.equal(coord, pt.coord());
1160
+ });
1161
+ },
1162
+ ],
1163
+ onMoveStart: function () {
1164
+ if (self.isClickToAddPoints()) {
1165
+ self.setTrashCanVisibility(1);
1166
+ }
1167
+ },
1168
+ onMove: self.updateCoordsFromPoints,
1169
+ onMoveEnd: function (coord) {
1170
+ if (self.isClickToAddPoints()) {
1171
+ if (self.isCoordInTrash(coord)) {
1172
+ remove();
1173
+ }
1174
+ // In case we mouseup'd off the graphie and that
1175
+ // stopped the move (in which case, we might not
1176
+ // be in isCoordInTrash()
1177
+ self.setTrashCanVisibility(0.5);
1178
+ }
1179
+ },
1180
+ ...(this.props.apiOptions.isMobile && self.isClickToAddPoints()
1181
+ ? {onRemove: remove}
1182
+ : {}),
1183
+ });
1184
+
1185
+ return point;
1186
+ };
1187
+
1188
+ removePoint: (Coord) => ?number = (point) => {
1189
+ let index = null;
1190
+ this.points = _.filter(this.points, function (pt, i) {
1191
+ if (pt === point) {
1192
+ index = i;
1193
+ return false;
1194
+ }
1195
+ return true;
1196
+ });
1197
+ return index;
1198
+ };
1199
+
1200
+ createPointForPolygonType: (Coord, number) => $FlowFixMe = (coord, i) => {
1201
+ if (this.props.graph.type !== "polygon") {
1202
+ throw makeInvalidTypeError("createPointForPolygonType", "polygon");
1203
+ }
1204
+
1205
+ // TODO(alex): check against "grid" instead, use constants
1206
+ const snapToGrid = !_.contains(
1207
+ ["angles", "sides"],
1208
+ this.props.graph.snapTo,
1209
+ );
1210
+
1211
+ // Index relative to current point -> absolute index
1212
+ // NOTE: This does not work when isClickToAddPoints() == true,
1213
+ // as `i` can be changed by dragging a point to the trash
1214
+ // Currently this function is only called when !isClickToAddPoints()
1215
+ const rel = (j) => {
1216
+ return (i + j + this.points.length) % this.points.length;
1217
+ };
1218
+
1219
+ const remove = () => {
1220
+ // remove this point from points
1221
+ const index = this.removePoint(point);
1222
+ if (this.polygon?.closed()) {
1223
+ // We should be checking if this.points is defined before rotating them.
1224
+ // $FlowFixMe[incompatible-call]
1225
+ // $FlowFixMe[incompatible-type]
1226
+ this.points = rotate(this.points, index);
1227
+ this.polygon?.update({closed: false});
1228
+ }
1229
+ this.updatePolygon();
1230
+ // the polygon is now unclosed, so we need to
1231
+ // remove any points props
1232
+ this.clearCoords();
1233
+
1234
+ // remove this movablePoint from graphie.
1235
+ // wait to do this until we're not inside of
1236
+ // said point's onMoveEnd method so state is
1237
+ // consistent throughout the method call
1238
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
1239
+ // eslint-disable-next-line no-restricted-syntax
1240
+ setTimeout(point.remove.bind(point), 0);
1241
+ };
1242
+
1243
+ const setInitialMove = debounce((point, bool) => {
1244
+ point.state.isInitialMove = bool;
1245
+ }, 1000);
1246
+
1247
+ const onMoveEndHandler = (coord) => {
1248
+ // If the user clicked and dragged a point over endpoint,
1249
+ // join the them
1250
+ const overlappedPoints =
1251
+ this.points.length > 1 &&
1252
+ ((point === this.points[0] &&
1253
+ kpoint.equal(coord, _.last(this.points).coord())) ||
1254
+ (point === _.last(this.points) &&
1255
+ kpoint.equal(coord, this.points[0].coord())));
1256
+
1257
+ if (this.isClickToAddPoints()) {
1258
+ if (this.isCoordInTrash(coord)) {
1259
+ remove();
1260
+ } else if (overlappedPoints) {
1261
+ const pointToRemove = this.points.pop();
1262
+
1263
+ if (this.points.length > 2) {
1264
+ this.polygon?.update({closed: true});
1265
+ this.updateCoordsFromPoints();
1266
+ } else {
1267
+ this.polygon?.update({closed: false});
1268
+ this.clearCoords();
1269
+ }
1270
+
1271
+ this.updatePolygon();
1272
+ // remove this movablePoint from graphie.
1273
+ // wait to do this until we're not inside of
1274
+ // said point's onMoveEnd method so state is
1275
+ // consistent throughout the method call
1276
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
1277
+ // eslint-disable-next-line no-restricted-syntax
1278
+ setTimeout(pointToRemove.remove.bind(pointToRemove), 0);
1279
+ } else {
1280
+ // If the user clicked and dragged a point over any other
1281
+ // existing point, fix shape
1282
+ const shouldRemove = _.any(this.points, function (pt) {
1283
+ return pt !== point && kpoint.equal(pt.coord(), coord);
1284
+ });
1285
+
1286
+ if (shouldRemove) {
1287
+ this.removePoint(point);
1288
+
1289
+ if (this.points.length < 3) {
1290
+ this.polygon?.update({closed: false});
1291
+ this.clearCoords();
1292
+ } else if (this.polygon?.closed()) {
1293
+ this.updateCoordsFromPoints();
1294
+ }
1295
+
1296
+ this.updatePolygon();
1297
+ // remove this movablePoint from graphie.
1298
+ // wait to do this until we're not inside
1299
+ // said point's onMoveEnd method so state
1300
+ // is consistent throughout the method call
1301
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
1302
+ // eslint-disable-next-line no-restricted-syntax
1303
+ setTimeout(point.remove.bind(point), 0);
1304
+ } else {
1305
+ // If this was
1306
+ // * a click on the first or last point
1307
+ // * and not a deletion
1308
+ // * and not a drag,
1309
+ // * and not a creation of a new point
1310
+ // (see !point.state.isInitialMove, below),
1311
+ // * and our polygon is not closed,
1312
+ // * and we can close it (we need at least the same
1313
+ // number of points as coordinates asked for in the
1314
+ // question),
1315
+ // then close it
1316
+ const isFirstOrLastPoint =
1317
+ point === _.head(this.points) ||
1318
+ point === _.last(this.points);
1319
+ const pointNotMoved = !point.hasMoved();
1320
+ // NOTE (jsatk): Currently different on mobile versus
1321
+ // destkop
1322
+ const notANewPoint = !point.state.isInitialMove;
1323
+ const polygonNotClosed = !this.polygon?.closed();
1324
+ const allPointsPlotted = this.points.length > 2;
1325
+
1326
+ const canCloseUnclosedPolygon =
1327
+ isFirstOrLastPoint &&
1328
+ pointNotMoved &&
1329
+ notANewPoint &&
1330
+ polygonNotClosed &&
1331
+ allPointsPlotted;
1332
+
1333
+ if (canCloseUnclosedPolygon) {
1334
+ this.polygon?.update({closed: true});
1335
+ this.updatePolygon();
1336
+
1337
+ // We finally have a closed polygon, so save our
1338
+ // points to props
1339
+ this.updateCoordsFromPoints();
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ // In case we mouseup'd off the graphie and that
1345
+ // stopped the move
1346
+ this.setTrashCanVisibility(0.5);
1347
+ }
1348
+
1349
+ setInitialMove(point, false);
1350
+ };
1351
+
1352
+ const graphConstraint = (coord) => {
1353
+ // These constraints are all relative to the other points, so if
1354
+ // we're creating the initial points and haven't added any others
1355
+ // to the graph, we can't enforce them.
1356
+ if (this.points == null || this.points.length === 0) {
1357
+ return true;
1358
+ }
1359
+
1360
+ // eslint-disable-next-line flowtype/no-mutable-array
1361
+ const coords: Array<Coord> = _.invoke(this.points, "coord");
1362
+ coords[i] = coord;
1363
+
1364
+ // Check for invalid positioning, but only if we aren't adding
1365
+ // points one click at a time, since those added points could
1366
+ // have already violated these constraints
1367
+ if (!this.isClickToAddPoints()) {
1368
+ // Polygons can't have consecutive collinear points
1369
+ if (
1370
+ collinear(coords[rel(-2)], coords[rel(-1)], coords[i]) ||
1371
+ collinear(coords[rel(-1)], coords[i], coords[rel(1)]) ||
1372
+ collinear(coords[i], coords[rel(1)], coords[rel(2)])
1373
+ ) {
1374
+ return false;
1375
+ }
1376
+
1377
+ const segments = _.zip(coords, rotate(coords));
1378
+
1379
+ if (this.points.length > 3) {
1380
+ // Constrain to simple (non self-intersecting) polygon by
1381
+ // testing whether adjacent segments intersect any others
1382
+ for (let j = -1; j <= 0; j++) {
1383
+ const segment = segments[rel(j)];
1384
+ const others = _.without(
1385
+ segments,
1386
+ segment,
1387
+ segments[rel(j - 1)],
1388
+ segments[rel(j + 1)],
1389
+ );
1390
+
1391
+ for (let k = 0; k < others.length; k++) {
1392
+ const other = others[k];
1393
+ if (intersects(segment, other)) {
1394
+ return false;
1395
+ }
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+
1401
+ if (
1402
+ this.props.graph.snapTo === "angles" &&
1403
+ this.points.length > 2
1404
+ ) {
1405
+ // Snap to whole degree interior angles
1406
+
1407
+ const angles = _.map(angleMeasures(coords), function (rad) {
1408
+ return (rad * 180) / Math.PI;
1409
+ });
1410
+
1411
+ _.each([-1, 1], function (j) {
1412
+ angles[rel(j)] = Math.round(angles[rel(j)]);
1413
+ });
1414
+
1415
+ const getAngle = function (a, vertex, b) {
1416
+ const angle = GraphUtils.findAngle(
1417
+ coords[rel(a)],
1418
+ coords[rel(b)],
1419
+ coords[rel(vertex)],
1420
+ );
1421
+ return (angle + 360) % 360;
1422
+ };
1423
+
1424
+ const innerAngles = [
1425
+ angles[rel(-1)] - getAngle(-2, -1, 1),
1426
+ angles[rel(1)] - getAngle(-1, 1, 2),
1427
+ ];
1428
+ innerAngles[2] = 180 - (innerAngles[0] + innerAngles[1]);
1429
+
1430
+ // Avoid degenerate triangles
1431
+ if (
1432
+ _.any(innerAngles, function (angle) {
1433
+ return leq(angle, 1);
1434
+ })
1435
+ ) {
1436
+ return false;
1437
+ }
1438
+
1439
+ const knownSide = magnitude(
1440
+ vector(coords[rel(-1)], coords[rel(1)]),
1441
+ );
1442
+
1443
+ const onLeft =
1444
+ sign(ccw(coords[rel(-1)], coords[rel(1)], coords[i])) === 1;
1445
+
1446
+ // Solve for side by using the law of sines
1447
+ const side =
1448
+ (Math.sin((innerAngles[1] * Math.PI) / 180) /
1449
+ Math.sin((innerAngles[2] * Math.PI) / 180)) *
1450
+ knownSide;
1451
+
1452
+ const outerAngle = GraphUtils.findAngle(
1453
+ coords[rel(1)],
1454
+ coords[rel(-1)],
1455
+ );
1456
+
1457
+ const offset = this.graphie?.polar(
1458
+ side,
1459
+ outerAngle + (onLeft ? 1 : -1) * innerAngles[0],
1460
+ );
1461
+
1462
+ return this.graphie?.addPoints(coords[rel(-1)], offset);
1463
+ }
1464
+ if (this.props.graph.snapTo === "sides" && this.points.length > 1) {
1465
+ // Snap to whole unit side measures
1466
+
1467
+ const sides = _.map(
1468
+ [
1469
+ [coords[rel(-1)], coords[i]],
1470
+ [coords[i], coords[rel(1)]],
1471
+ [coords[rel(-1)], coords[rel(1)]],
1472
+ ],
1473
+ function (coords) {
1474
+ return magnitude(vector(...coords));
1475
+ },
1476
+ );
1477
+
1478
+ _.each([0, 1], function (j) {
1479
+ sides[j] = Math.round(sides[j]);
1480
+ });
1481
+
1482
+ // Avoid degenerate triangles
1483
+ if (
1484
+ leq(sides[1] + sides[2], sides[0]) ||
1485
+ leq(sides[0] + sides[2], sides[1]) ||
1486
+ leq(sides[0] + sides[1], sides[2])
1487
+ ) {
1488
+ return false;
1489
+ }
1490
+
1491
+ // Solve for angle by using the law of cosines
1492
+ const innerAngle = lawOfCosines(sides[0], sides[2], sides[1]);
1493
+
1494
+ const outerAngle = GraphUtils.findAngle(
1495
+ coords[rel(1)],
1496
+ coords[rel(-1)],
1497
+ );
1498
+
1499
+ const onLeft =
1500
+ sign(ccw(coords[rel(-1)], coords[rel(1)], coords[i])) === 1;
1501
+
1502
+ const offset = this.graphie?.polar(
1503
+ sides[0],
1504
+ outerAngle + (onLeft ? 1 : -1) * innerAngle,
1505
+ );
1506
+
1507
+ return this.graphie?.addPoints(coords[rel(-1)], offset);
1508
+ }
1509
+ // Snap to grid (already done)
1510
+ return true;
1511
+ };
1512
+
1513
+ const point = Interactive2.addMaybeMobileMovablePoint(this, {
1514
+ coord: coord,
1515
+ constraints: [
1516
+ // $FlowFixMe[prop-missing]
1517
+ Interactive2.MovablePoint.constraints.bound(),
1518
+ snapToGrid
1519
+ ? // $FlowFixMe[prop-missing]
1520
+ Interactive2.MovablePoint.constraints.snap()
1521
+ : null,
1522
+ graphConstraint,
1523
+ ],
1524
+ onMoveStart: () => {
1525
+ if (this.isClickToAddPoints()) {
1526
+ this.setTrashCanVisibility(1);
1527
+ }
1528
+ },
1529
+ onMove: () => {
1530
+ if (this.polygon?.closed()) {
1531
+ this.updateCoordsFromPoints();
1532
+ }
1533
+ },
1534
+ onMoveEnd: onMoveEndHandler,
1535
+ ...(this.props.apiOptions.isMobile && this.isClickToAddPoints()
1536
+ ? {onRemove: remove}
1537
+ : {}),
1538
+ });
1539
+
1540
+ point.state.isInitialMove = true;
1541
+
1542
+ return point;
1543
+ };
1544
+
1545
+ updateCoordsFromPoints: () => void = () => {
1546
+ const graph = _.extend({}, this.props.graph, {
1547
+ // Handle old movable points with .coord, or
1548
+ // Interactive2.MovablePoint's with .coord()
1549
+ coords: _.map(this.points, function (point) {
1550
+ return _.result(point, "coord");
1551
+ }),
1552
+ });
1553
+ this.onChange({graph: graph});
1554
+ };
1555
+
1556
+ clearCoords: () => void = () => {
1557
+ const graph = _.extend({}, this.props.graph, {
1558
+ coords: null,
1559
+ });
1560
+ this.onChange({graph: graph});
1561
+ };
1562
+
1563
+ onChange: ($FlowFixMe) => void = (data) => {
1564
+ this.props.onChange(data);
1565
+ this.props.trackInteraction();
1566
+ };
1567
+
1568
+ addPointControls: () => void = () => {
1569
+ if (this.props.graph.type !== "point") {
1570
+ throw makeInvalidTypeError("addPointControls", "point");
1571
+ }
1572
+
1573
+ const coords = InteractiveGraph.getPointCoords(
1574
+ this.props.graph,
1575
+ this.props,
1576
+ );
1577
+ // Clear out our old points so that newly added points don't
1578
+ // "collide" with them and reposition when being added
1579
+ // Without this, when added, each point checks whether it is on top
1580
+ // of a point in this.points, which (a) shouldn't matter since
1581
+ // we're clearing out this.points anyways, and (b) can cause problems
1582
+ // if each of this.points is a MovablePoint instead of an
1583
+ // Interactive2.MovablePoint, since one has a .coord and the other
1584
+ // has .coord()
1585
+ // TODO(jack): Figure out a better way to do this
1586
+ this.points = [];
1587
+ this.points = _.map(coords, this.createPointForPointsType, this);
1588
+ };
1589
+
1590
+ removePointControls: () => void = () => {
1591
+ _.invoke(this.points, "remove");
1592
+ };
1593
+
1594
+ addSegmentControls: () => void = () => {
1595
+ if (this.props.graph.type !== "segment") {
1596
+ throw makeInvalidTypeError("addSegmentControls", "segment");
1597
+ }
1598
+
1599
+ const self = this;
1600
+ const graphie = this.graphie;
1601
+
1602
+ const coords = InteractiveGraph.getSegmentCoords(
1603
+ this.props.graph,
1604
+ this.props,
1605
+ );
1606
+
1607
+ const createPoint = (options) =>
1608
+ Interactive2.addMaybeMobileMovablePoint(this, options);
1609
+
1610
+ this.points = [];
1611
+ this.lines = _.map(
1612
+ coords,
1613
+ function (segment, i) {
1614
+ const updateCoordProps = function () {
1615
+ const graph = _.extend({}, self.props.graph, {
1616
+ coords: _.invoke(self.lines, "coords"),
1617
+ });
1618
+ self.onChange({graph: graph});
1619
+ };
1620
+
1621
+ // NOTE(jeresig): This code attempts to access the variable
1622
+ // while it's still being defined, it should be refactored!
1623
+ // The callbacks appear to be executed synchronously, which
1624
+ // causes this issue.
1625
+ const points = [];
1626
+
1627
+ for (let i = 0; i < segment.length; i += 1) {
1628
+ const coord = segment[i];
1629
+ points.push(
1630
+ createPoint({
1631
+ coord: coord,
1632
+ constraints: [
1633
+ // $FlowFixMe[prop-missing]
1634
+ Interactive2.MovablePoint.constraints.bound(),
1635
+ // $FlowFixMe[prop-missing]
1636
+ Interactive2.MovablePoint.constraints.snap(),
1637
+ (coord) => {
1638
+ const otherPoint = points[1 - i];
1639
+ if (!otherPoint) {
1640
+ // point hasn't been defined yet
1641
+ // because we're still creating them
1642
+ // We return false as the points aren't
1643
+ // going to be in the same position at
1644
+ // this point.
1645
+ return false;
1646
+ }
1647
+ return !kpoint.equal(
1648
+ coord,
1649
+ otherPoint.coord(),
1650
+ );
1651
+ },
1652
+ ],
1653
+ onMove: updateCoordProps,
1654
+ }),
1655
+ );
1656
+ }
1657
+
1658
+ self.points = self.points.concat(points);
1659
+ const line = Interactive2.addMovableLine(graphie, {
1660
+ points: points,
1661
+ static: false,
1662
+ constraints: [
1663
+ // $FlowFixMe[prop-missing]
1664
+ Interactive2.MovableLine.constraints.bound(),
1665
+ // $FlowFixMe[prop-missing]
1666
+ Interactive2.MovableLine.constraints.snap(),
1667
+ ],
1668
+ onMove: [
1669
+ // $FlowFixMe[prop-missing]
1670
+ Interactive2.MovableLine.onMove.updatePoints,
1671
+ updateCoordProps,
1672
+ ],
1673
+ normalStyle: {
1674
+ stroke: this.props.apiOptions.isMobile
1675
+ ? KhanColors.BLUE_C
1676
+ : KhanColors.INTERACTIVE,
1677
+ ...this._lineStroke(),
1678
+ },
1679
+ highlightStyle: {
1680
+ stroke: this.props.apiOptions.isMobile
1681
+ ? KhanColors.BLUE_C
1682
+ : KhanColors.INTERACTING,
1683
+ ...this._lineStroke(),
1684
+ },
1685
+ });
1686
+ _.invoke(points, "toFront");
1687
+
1688
+ return line;
1689
+ },
1690
+ this,
1691
+ );
1692
+ };
1693
+
1694
+ removeSegmentControls: () => void = () => {
1695
+ _.invoke(this.points, "remove");
1696
+ _.invoke(this.lines, "remove");
1697
+ };
1698
+
1699
+ addRayControls: () => void = () => {
1700
+ this.addLine("ray");
1701
+ };
1702
+
1703
+ removeRayControls: () => void = () => {
1704
+ this.removeLine();
1705
+ };
1706
+
1707
+ addPolygonControls: () => void = () => {
1708
+ this.polygon = null;
1709
+ const coords = InteractiveGraph.getPolygonCoords(
1710
+ this.props.graph,
1711
+ this.props,
1712
+ );
1713
+ // Clear out our old points so that newly added points don't
1714
+ // "collide", as in `addPointControls`
1715
+ this.points = [];
1716
+ this.points = _.map(coords, this.createPointForPolygonType, this);
1717
+ this.updatePolygon();
1718
+ };
1719
+
1720
+ updatePolygon: () => void = () => {
1721
+ if (this.props.graph.type !== "polygon") {
1722
+ throw makeInvalidTypeError("updatePolygon", "polygon");
1723
+ }
1724
+
1725
+ let closed;
1726
+ if (this.polygon) {
1727
+ closed = this.polygon.closed();
1728
+ } else if (this.points.length >= 3) {
1729
+ closed = true;
1730
+ } else {
1731
+ // There will only be fewer than 3 points in click-to-add-vertices
1732
+ // mode, so we don't need to explicitly check for that here.
1733
+ closed = false;
1734
+ }
1735
+
1736
+ const graphie = this.graphie;
1737
+ const n = this.points.length;
1738
+
1739
+ // TODO(alex): check against "grid" instead, use constants
1740
+ const snapToGrid = !_.contains(
1741
+ ["angles", "sides"],
1742
+ this.props.graph.snapTo,
1743
+ );
1744
+
1745
+ const angleLabels = _.times(
1746
+ n,
1747
+ function (i) {
1748
+ if (
1749
+ !this.props.graph.showAngles ||
1750
+ (!closed && (i === 0 || i === n - 1))
1751
+ ) {
1752
+ return "";
1753
+ }
1754
+ if (this.props.graph.snapTo === "angles") {
1755
+ return "$deg0";
1756
+ }
1757
+ return "$deg1";
1758
+ },
1759
+ this,
1760
+ );
1761
+
1762
+ const showRightAngleMarkers = _.times(
1763
+ n,
1764
+ function (i) {
1765
+ return closed || (i !== 0 && i !== n - 1);
1766
+ },
1767
+ this,
1768
+ );
1769
+
1770
+ const numArcs = _.times(
1771
+ n,
1772
+ function (i) {
1773
+ if (
1774
+ this.props.graph.showAngles &&
1775
+ (closed || (i !== 0 && i !== n - 1))
1776
+ ) {
1777
+ return 1;
1778
+ }
1779
+ return 0;
1780
+ },
1781
+ this,
1782
+ );
1783
+
1784
+ const sideLabels = _.times(
1785
+ n,
1786
+ function (i) {
1787
+ if (!this.props.graph.showSides || (!closed && i === n - 1)) {
1788
+ return "";
1789
+ }
1790
+ if (this.props.graph.snapTo === "sides") {
1791
+ return "$len0";
1792
+ }
1793
+ return "$len1";
1794
+ },
1795
+ this,
1796
+ );
1797
+
1798
+ if (this.polygon == null) {
1799
+ const self = this;
1800
+ self.polygon = Interactive2.addMovablePolygon(graphie, {
1801
+ constraints: [
1802
+ // $FlowFixMe[prop-missing]
1803
+ Interactive2.MovablePolygon.constraints.bound(),
1804
+ snapToGrid
1805
+ ? // $FlowFixMe[prop-missing]
1806
+ Interactive2.MovablePolygon.constraints.snap()
1807
+ : null,
1808
+ ],
1809
+ closed: closed,
1810
+ points: self.points,
1811
+ angleLabels: angleLabels,
1812
+ showRightAngleMarkers: showRightAngleMarkers,
1813
+ numArcs: numArcs,
1814
+ sideLabels: sideLabels,
1815
+ onMove: [
1816
+ // $FlowFixMe[prop-missing]
1817
+ Interactive2.MovablePolygon.onMove.updatePoints,
1818
+ function () {
1819
+ if (this.closed()) {
1820
+ self.updateCoordsFromPoints();
1821
+ }
1822
+ },
1823
+ ],
1824
+ normalStyle: {
1825
+ stroke: this.props.apiOptions.isMobile
1826
+ ? KhanColors.BLUE_C
1827
+ : KhanColors.INTERACTIVE,
1828
+ ...this._lineStroke(),
1829
+ },
1830
+ });
1831
+ } else {
1832
+ // We only need to pass in the properties that might've changed
1833
+ this.polygon.update({
1834
+ closed: closed,
1835
+ points: this.points,
1836
+ angleLabels: angleLabels,
1837
+ showRightAngleMarkers: showRightAngleMarkers,
1838
+ numArcs: numArcs,
1839
+ sideLabels: sideLabels,
1840
+ });
1841
+ }
1842
+ };
1843
+
1844
+ removePolygonControls: () => void = () => {
1845
+ _.invoke(this.points, "remove");
1846
+ this.polygon?.remove();
1847
+ };
1848
+
1849
+ addAngleControls: () => void = () => {
1850
+ if (this.props.graph.type !== "angle") {
1851
+ throw makeInvalidTypeError("addAngleControls", "angle");
1852
+ }
1853
+
1854
+ const graph = this.props.graph;
1855
+ const graphie = this.graphie;
1856
+
1857
+ const coords = InteractiveGraph.getAngleCoords(
1858
+ this.props.graph,
1859
+ this.props,
1860
+ );
1861
+
1862
+ // The vertex snaps to the grid, but the rays don't...
1863
+ this.points = coords.map(function (coord, i) {
1864
+ return graphie?.addMovablePoint(
1865
+ _.extend(
1866
+ {
1867
+ coord: coord,
1868
+ normalStyle: {
1869
+ stroke: KhanColors.INTERACTIVE,
1870
+ fill: KhanColors.INTERACTIVE,
1871
+ },
1872
+ },
1873
+ i === 1
1874
+ ? {
1875
+ snapX: graphie.snap[0],
1876
+ snapY: graphie.snap[1],
1877
+ }
1878
+ : {},
1879
+ ),
1880
+ );
1881
+ });
1882
+
1883
+ // ...they snap to whole-degree angles from the vertex.
1884
+ this.angle = graphie?.addMovableAngle({
1885
+ points: this.points,
1886
+ snapDegrees: graph.snapDegrees || 1,
1887
+ snapOffsetDeg: graph.angleOffsetDeg || 0,
1888
+ angleLabel: graph.showAngles ? "$deg0" : "",
1889
+ pushOut: 2,
1890
+ allowReflex: defaultVal(graph.allowReflexAngles, true),
1891
+ });
1892
+
1893
+ $(this.angle).on("move", () => {
1894
+ this.onChange({
1895
+ graph: {...graph, coords: this.angle?.getClockwiseCoords()},
1896
+ });
1897
+ });
1898
+ };
1899
+
1900
+ removeAngleControls: () => void = () => {
1901
+ _.invoke(this.points, "remove");
1902
+ this.angle?.remove();
1903
+ };
1904
+
1905
+ toggleShowAngles: () => void = () => {
1906
+ if (this.props.graph.type !== "polygon") {
1907
+ throw makeInvalidTypeError("toggleShowAngles", "polygon");
1908
+ }
1909
+ const graph = _.extend({}, this.props.graph, {
1910
+ showAngles: !this.props.graph.showAngles,
1911
+ });
1912
+ this.onChange({graph: graph});
1913
+ };
1914
+
1915
+ toggleShowSides: () => void = () => {
1916
+ if (this.props.graph.type !== "polygon") {
1917
+ throw makeInvalidTypeError("toggleShowSides", "polygon");
1918
+ }
1919
+ const graph = _.extend({}, this.props.graph, {
1920
+ showSides: !this.props.graph.showSides,
1921
+ });
1922
+ this.onChange({graph: graph});
1923
+ };
1924
+
1925
+ getUserInput: () => PerseusGraphType = () => {
1926
+ return InteractiveGraph.getUserInputFromProps(this.props);
1927
+ };
1928
+
1929
+ simpleValidate: (Rubric) => PerseusScore = (rubric) => {
1930
+ return InteractiveGraph.validate(this.getUserInput(), rubric, this);
1931
+ };
1932
+
1933
+ focus: () => void = $.noop;
1934
+
1935
+ render(): React.Node {
1936
+ const box = getInteractiveBoxFromSizeClass(
1937
+ this.props.containerSizeClass,
1938
+ );
1939
+
1940
+ let instructions;
1941
+ if (this.isClickToAddPoints() && this.state.shouldShowInstructions) {
1942
+ if (this.props.graph.type === "point") {
1943
+ instructions = i18n._("Click to add points");
1944
+ } else if (this.props.graph.type === "polygon") {
1945
+ instructions = i18n._("Click to add vertices");
1946
+ }
1947
+ } else {
1948
+ instructions = undefined;
1949
+ }
1950
+
1951
+ const onMouseDown = this.isClickToAddPoints()
1952
+ ? this.handleAddPointsMouseDown
1953
+ : null;
1954
+
1955
+ const gridStep =
1956
+ this.props.gridStep ||
1957
+ Util.getGridStep(this.props.range, this.props.step, box[0]);
1958
+ const snapStep =
1959
+ this.props.snapStep || Util.snapStepFromGridStep(gridStep);
1960
+
1961
+ const isMobile = this.props.apiOptions.isMobile;
1962
+
1963
+ return (
1964
+ <div
1965
+ className={
1966
+ "perseus-widget " + "perseus-widget-interactive-graph"
1967
+ }
1968
+ style={{
1969
+ width: box[0],
1970
+ height: box[1],
1971
+ }}
1972
+ >
1973
+ <Graph
1974
+ instructions={instructions}
1975
+ // eslint-disable-next-line react/no-string-refs
1976
+ ref="graph"
1977
+ box={box}
1978
+ labels={this.props.labels}
1979
+ range={this.props.range}
1980
+ step={
1981
+ isMobile
1982
+ ? Util.constrainedTickStepsFromTickSteps(
1983
+ this.props.step,
1984
+ this.props.range,
1985
+ )
1986
+ : this.props.step
1987
+ }
1988
+ gridStep={gridStep}
1989
+ snapStep={snapStep}
1990
+ markings={this.props.markings}
1991
+ backgroundImage={this.props.backgroundImage}
1992
+ showProtractor={this.props.showProtractor}
1993
+ showRuler={this.props.showRuler}
1994
+ rulerLabel={this.props.rulerLabel}
1995
+ rulerTicks={this.props.rulerTicks}
1996
+ onMouseDown={onMouseDown}
1997
+ onGraphieUpdated={this.setGraphie}
1998
+ setDrawingAreaAvailable={
1999
+ this.props.apiOptions.setDrawingAreaAvailable
2000
+ }
2001
+ isMobile={isMobile}
2002
+ />
2003
+ </div>
2004
+ );
2005
+ }
2006
+
2007
+ static getQuadraticCoefficients(
2008
+ coords: $ReadOnlyArray<Coord>,
2009
+ ): QuadraticCoefficient {
2010
+ const p1 = coords[0];
2011
+ const p2 = coords[1];
2012
+ const p3 = coords[2];
2013
+
2014
+ const denom = (p1[0] - p2[0]) * (p1[0] - p3[0]) * (p2[0] - p3[0]);
2015
+ if (denom === 0) {
2016
+ // Many of the callers assume that the return value is always defined.
2017
+ // $FlowFixMe[incompatible-return]
2018
+ return;
2019
+ }
2020
+ const a =
2021
+ (p3[0] * (p2[1] - p1[1]) +
2022
+ p2[0] * (p1[1] - p3[1]) +
2023
+ p1[0] * (p3[1] - p2[1])) /
2024
+ denom;
2025
+ const b =
2026
+ (p3[0] * p3[0] * (p1[1] - p2[1]) +
2027
+ p2[0] * p2[0] * (p3[1] - p1[1]) +
2028
+ p1[0] * p1[0] * (p2[1] - p3[1])) /
2029
+ denom;
2030
+ const c =
2031
+ (p2[0] * p3[0] * (p2[0] - p3[0]) * p1[1] +
2032
+ p3[0] * p1[0] * (p3[0] - p1[0]) * p2[1] +
2033
+ p1[0] * p2[0] * (p1[0] - p2[0]) * p3[1]) /
2034
+ denom;
2035
+ return [a, b, c];
2036
+ }
2037
+
2038
+ static getSinusoidCoefficients(
2039
+ coords: $ReadOnlyArray<Coord>,
2040
+ ): SineCoefficient {
2041
+ // It's assumed that p1 is the root and p2 is the first peak
2042
+ const p1 = coords[0];
2043
+ const p2 = coords[1];
2044
+
2045
+ // Resulting coefficients are canonical for this sine curve
2046
+ const amplitude = p2[1] - p1[1];
2047
+ const angularFrequency = Math.PI / (2 * (p2[0] - p1[0]));
2048
+ const phase = p1[0] * angularFrequency;
2049
+ const verticalOffset = p1[1];
2050
+
2051
+ return [amplitude, angularFrequency, phase, verticalOffset];
2052
+ }
2053
+
2054
+ /**
2055
+ * @param {object} graph Like props.graph or props.correct
2056
+ * @param {object} props of an InteractiveGraph instance
2057
+ */
2058
+ static getLineCoords(
2059
+ graph: PerseusGraphType,
2060
+ props: Props,
2061
+ ): $ReadOnlyArray<Coord> {
2062
+ return (
2063
+ // $FlowFixMe[incompatible-return]
2064
+ graph.coords ||
2065
+ InteractiveGraph.pointsFromNormalized(props, [
2066
+ [0.25, 0.75],
2067
+ [0.75, 0.75],
2068
+ ])
2069
+ );
2070
+ }
2071
+
2072
+ /**
2073
+ * @param {object} graph Like props.graph or props.correct
2074
+ * @param {object} props of an InteractiveGraph instance
2075
+ */
2076
+ static getPointCoords(
2077
+ graph: PerseusGraphTypePoint,
2078
+ props: Props,
2079
+ ): $ReadOnlyArray<Coord> {
2080
+ const numPoints = graph.numPoints || 1;
2081
+ let coords = graph.coords;
2082
+
2083
+ if (coords) {
2084
+ return coords;
2085
+ }
2086
+ switch (numPoints) {
2087
+ case 1:
2088
+ // Back in the day, one point's coords were in graph.coord
2089
+ coords = [graph.coord || [0, 0]];
2090
+ break;
2091
+ case 2:
2092
+ coords = [
2093
+ [-5, 0],
2094
+ [5, 0],
2095
+ ];
2096
+ break;
2097
+ case 3:
2098
+ coords = [
2099
+ [-5, 0],
2100
+ [0, 0],
2101
+ [5, 0],
2102
+ ];
2103
+ break;
2104
+ case 4:
2105
+ coords = [
2106
+ [-6, 0],
2107
+ [-2, 0],
2108
+ [2, 0],
2109
+ [6, 0],
2110
+ ];
2111
+ break;
2112
+ case 5:
2113
+ coords = [
2114
+ [-6, 0],
2115
+ [-3, 0],
2116
+ [0, 0],
2117
+ [3, 0],
2118
+ [6, 0],
2119
+ ];
2120
+ break;
2121
+ case 6:
2122
+ coords = [
2123
+ [-5, 0],
2124
+ [-3, 0],
2125
+ [-1, 0],
2126
+ [1, 0],
2127
+ [3, 0],
2128
+ [5, 0],
2129
+ ];
2130
+ break;
2131
+ case UNLIMITED:
2132
+ coords = [];
2133
+ break;
2134
+ }
2135
+ // Transform coords from their -10 to 10 space to 0 to 1
2136
+ // because of the old graph.coord, and also it's easier.
2137
+ const range = [
2138
+ [-10, 10],
2139
+ [-10, 10],
2140
+ ];
2141
+ // $FlowFixMe[incompatible-call]
2142
+ const newCoords = InteractiveGraph.normalizeCoords(coords, range);
2143
+
2144
+ return InteractiveGraph.pointsFromNormalized(props, newCoords);
2145
+ }
2146
+
2147
+ /**
2148
+ * @param {object} graph Like props.graph or props.correct
2149
+ * @param {object} props of an InteractiveGraph instance
2150
+ */
2151
+ static getLinearSystemCoords(
2152
+ graph: PerseusGraphType,
2153
+ props: Props,
2154
+ ): $ReadOnlyArray<$ReadOnlyArray<Coord>> {
2155
+ return (
2156
+ // The callers assume that we're return an array of points
2157
+ // $FlowFixMe[incompatible-return]
2158
+ graph.coords ||
2159
+ _.map(
2160
+ [
2161
+ [
2162
+ [0.25, 0.75],
2163
+ [0.75, 0.75],
2164
+ ],
2165
+ [
2166
+ [0.25, 0.25],
2167
+ [0.75, 0.25],
2168
+ ],
2169
+ ],
2170
+ (coords) => {
2171
+ return InteractiveGraph.pointsFromNormalized(props, coords);
2172
+ },
2173
+ )
2174
+ );
2175
+ }
2176
+
2177
+ /**
2178
+ * @param {object} graph Like props.graph or props.correct
2179
+ * @param {object} props of an InteractiveGraph instance
2180
+ */
2181
+ static getPolygonCoords(
2182
+ graph: PerseusGraphType,
2183
+ props: Props,
2184
+ ): $ReadOnlyArray<Coord> {
2185
+ if (graph.type !== "polygon") {
2186
+ throw makeInvalidTypeError("toggleShowSides", "polygon");
2187
+ }
2188
+
2189
+ let coords = graph.coords;
2190
+ if (coords) {
2191
+ return coords;
2192
+ }
2193
+
2194
+ const n = graph.numSides || 3;
2195
+
2196
+ if (n === UNLIMITED) {
2197
+ coords = [];
2198
+ } else {
2199
+ // $FlowFixMe[unsafe-addition]
2200
+ const angle = (2 * Math.PI) / n;
2201
+ // $FlowFixMe[unsafe-addition]
2202
+ const offset = (1 / n - 1 / 2) * Math.PI;
2203
+
2204
+ // TODO(alex): Generalize this to more than just triangles so that
2205
+ // all polygons have whole number side lengths if snapping to sides
2206
+ const radius =
2207
+ graph.snapTo === "sides" ? (Math.sqrt(3) / 3) * 7 : 4;
2208
+
2209
+ // Generate coords of a regular polygon with n sides
2210
+ coords = _.times(n, function (i) {
2211
+ return [
2212
+ radius * Math.cos(i * angle + offset),
2213
+ radius * Math.sin(i * angle + offset),
2214
+ ];
2215
+ });
2216
+ }
2217
+
2218
+ const ranges: [Range, Range] = [
2219
+ [-10, 10],
2220
+ [-10, 10],
2221
+ ];
2222
+ coords = InteractiveGraph.normalizeCoords(coords, ranges);
2223
+
2224
+ const snapToGrid = !_.contains(["angles", "sides"], graph.snapTo);
2225
+ coords = InteractiveGraph.pointsFromNormalized(
2226
+ props,
2227
+ coords,
2228
+ /* noSnap */ !snapToGrid,
2229
+ );
2230
+
2231
+ return coords;
2232
+ }
2233
+
2234
+ /**
2235
+ * @param {object} graph Like props.graph or props.correct
2236
+ * @param {object} props of an InteractiveGraph instance
2237
+ */
2238
+ static getSegmentCoords(
2239
+ graph: PerseusGraphTypeSegment,
2240
+ props: Props,
2241
+ ): $ReadOnlyArray<$ReadOnlyArray<Coord>> {
2242
+ const coords = graph.coords;
2243
+ if (coords) {
2244
+ return coords;
2245
+ }
2246
+
2247
+ const n = graph.numSegments || 1;
2248
+ const ys: $ReadOnlyArray<number> = {
2249
+ // $FlowFixMe[unsupported-syntax]
2250
+ 1: [5],
2251
+ // $FlowFixMe[unsupported-syntax]
2252
+ 2: [5, -5],
2253
+ // $FlowFixMe[unsupported-syntax]
2254
+ 3: [5, 0, -5],
2255
+ // $FlowFixMe[unsupported-syntax]
2256
+ 4: [6, 2, -2, -6],
2257
+ // $FlowFixMe[unsupported-syntax]
2258
+ 5: [6, 3, 0, -3, -6],
2259
+ // $FlowFixMe[unsupported-syntax]
2260
+ 6: [5, 3, 1, -1, -3, -5],
2261
+ }[n];
2262
+ const range = [
2263
+ [-10, 10],
2264
+ [-10, 10],
2265
+ ];
2266
+
2267
+ return ys.map(function (y) {
2268
+ let segment = [
2269
+ [-5, y],
2270
+ [5, y],
2271
+ ];
2272
+ segment = InteractiveGraph.normalizeCoords(segment, range);
2273
+ segment = InteractiveGraph.pointsFromNormalized(props, segment);
2274
+ return segment;
2275
+ });
2276
+ }
2277
+
2278
+ /**
2279
+ * @param {object} graph Like props.graph or props.correct
2280
+ * @param {object} props of an InteractiveGraph instance
2281
+ */
2282
+ static getAngleCoords(
2283
+ graph: PerseusGraphTypeAngle,
2284
+ props: Props,
2285
+ ): $ReadOnlyArray<Coord> {
2286
+ let coords = graph.coords;
2287
+ if (coords) {
2288
+ return coords;
2289
+ }
2290
+
2291
+ const snap = graph.snapDegrees || 1;
2292
+ let angle = snap;
2293
+ while (angle < 20) {
2294
+ angle += snap;
2295
+ }
2296
+ angle = (angle * Math.PI) / 180;
2297
+ const offset = ((graph.angleOffsetDeg || 0) * Math.PI) / 180;
2298
+
2299
+ coords = InteractiveGraph.pointsFromNormalized(props, [
2300
+ [0.85, 0.5],
2301
+ [0.5, 0.5],
2302
+ ]);
2303
+
2304
+ const radius = magnitude(vector(...coords));
2305
+
2306
+ // Adjust the lower point by angleOffsetDeg degrees
2307
+ // $FlowFixMe[cannot-write]
2308
+ coords[0] = [
2309
+ coords[1][0] + radius * Math.cos(offset),
2310
+ coords[1][1] + radius * Math.sin(offset),
2311
+ ];
2312
+ // Position the upper point angle radians from the
2313
+ // lower point
2314
+ // $FlowFixMe[cannot-write]
2315
+ coords[2] = [
2316
+ coords[1][0] + radius * Math.cos(angle + offset),
2317
+ coords[1][1] + radius * Math.sin(angle + offset),
2318
+ ];
2319
+
2320
+ return coords;
2321
+ }
2322
+
2323
+ static normalizeCoords(
2324
+ coordsList: $ReadOnlyArray<Coord>,
2325
+ ranges: [Range, Range],
2326
+ ): $ReadOnlyArray<Coord> {
2327
+ return _.map(coordsList, function (coords) {
2328
+ return _.map(coords, function (coord, i) {
2329
+ const extent = ranges[i][1] - ranges[i][0];
2330
+ return (coord + ranges[i][1]) / extent;
2331
+ });
2332
+ });
2333
+ }
2334
+
2335
+ static getEquationString(props: Props): string {
2336
+ const type = props.graph.type;
2337
+ const funcName = "get" + capitalize(type) + "EquationString";
2338
+ // $FlowFixMe[incompatible-use]
2339
+ return InteractiveGraph[funcName](props);
2340
+ }
2341
+
2342
+ static pointsFromNormalized(
2343
+ props: Props,
2344
+ coordsList: $ReadOnlyArray<Coord>,
2345
+ noSnap?: boolean,
2346
+ ): $ReadOnlyArray<Coord> {
2347
+ return _.map(coordsList, function (coords) {
2348
+ return _.map(coords, function (coord, i) {
2349
+ const range: Range = props.range[i];
2350
+ if (noSnap) {
2351
+ return range[0] + (range[1] - range[0]) * coord;
2352
+ }
2353
+ const step = props.step[i];
2354
+ const nSteps = numSteps(range, step);
2355
+ const tick = Math.round(coord * nSteps);
2356
+ return range[0] + step * tick;
2357
+ });
2358
+ });
2359
+ }
2360
+
2361
+ static getLinearEquationString(props: Props): string {
2362
+ const coords = InteractiveGraph.getLineCoords(props.graph, props);
2363
+ if (eq(coords[0][0], coords[1][0])) {
2364
+ return "x = " + coords[0][0].toFixed(3);
2365
+ }
2366
+ const m = (coords[1][1] - coords[0][1]) / (coords[1][0] - coords[0][0]);
2367
+ const b = coords[0][1] - m * coords[0][0];
2368
+ if (eq(m, 0)) {
2369
+ return "y = " + b.toFixed(3);
2370
+ }
2371
+ return "y = " + m.toFixed(3) + "x + " + b.toFixed(3);
2372
+ }
2373
+
2374
+ static getCurrentQuadraticCoefficients(props: Props): QuadraticCoefficient {
2375
+ // TODO(alpert): Don't duplicate
2376
+ const coords =
2377
+ props.graph.coords ||
2378
+ InteractiveGraph.defaultQuadraticCoords(props);
2379
+ // $FlowFixMe[incompatible-call]
2380
+ return InteractiveGraph.getQuadraticCoefficients(coords);
2381
+ }
2382
+
2383
+ static defaultQuadraticCoords(props: Props): $ReadOnlyArray<Coord> {
2384
+ const coords = [
2385
+ [0.25, 0.75],
2386
+ [0.5, 0.25],
2387
+ [0.75, 0.75],
2388
+ ];
2389
+ return InteractiveGraph.pointsFromNormalized(props, coords);
2390
+ }
2391
+
2392
+ static getQuadraticEquationString(props: Props): string {
2393
+ const coeffs = InteractiveGraph.getCurrentQuadraticCoefficients(props);
2394
+ return (
2395
+ "y = " +
2396
+ coeffs[0].toFixed(3) +
2397
+ "x^2 + " +
2398
+ coeffs[1].toFixed(3) +
2399
+ "x + " +
2400
+ coeffs[2].toFixed(3)
2401
+ );
2402
+ }
2403
+
2404
+ static getCurrentSinusoidCoefficients(props: Props): SineCoefficient {
2405
+ const coords =
2406
+ props.graph.coords || InteractiveGraph.defaultSinusoidCoords(props);
2407
+ // $FlowFixMe[incompatible-call]
2408
+ return InteractiveGraph.getSinusoidCoefficients(coords);
2409
+ }
2410
+
2411
+ static defaultSinusoidCoords(props: Props): $ReadOnlyArray<Coord> {
2412
+ const coords = [
2413
+ [0.5, 0.5],
2414
+ [0.65, 0.6],
2415
+ ];
2416
+ return InteractiveGraph.pointsFromNormalized(props, coords);
2417
+ }
2418
+
2419
+ static getSinusoidEquationString(props: Props): string {
2420
+ const coeffs = InteractiveGraph.getCurrentSinusoidCoefficients(props);
2421
+ return (
2422
+ "y = " +
2423
+ coeffs[0].toFixed(3) +
2424
+ "sin(" +
2425
+ coeffs[1].toFixed(3) +
2426
+ "x - " +
2427
+ coeffs[2].toFixed(3) +
2428
+ ") + " +
2429
+ coeffs[3].toFixed(3)
2430
+ );
2431
+ }
2432
+
2433
+ static getCircleEquationString(props: Props): string {
2434
+ const graph = props.graph;
2435
+ // TODO(alpert): Don't duplicate
2436
+ const center = graph.center || [0, 0];
2437
+ const radius = graph.radius || 2;
2438
+ return (
2439
+ "center (" + center[0] + ", " + center[1] + "), radius " + radius
2440
+ );
2441
+ }
2442
+
2443
+ static getLinearSystemEquationString(props: Props): string {
2444
+ const coords = InteractiveGraph.getLinearSystemCoords(
2445
+ props.graph,
2446
+ props,
2447
+ );
2448
+
2449
+ return (
2450
+ "\n" +
2451
+ getLineEquation(coords[0][0], coords[0][1]) +
2452
+ "\n" +
2453
+ getLineEquation(coords[1][0], coords[1][1]) +
2454
+ "\n" +
2455
+ getLineIntersection(coords[0], coords[1])
2456
+ );
2457
+ }
2458
+
2459
+ static getPointEquationString(props: Props): string {
2460
+ if (props.graph.type !== "point") {
2461
+ throw makeInvalidTypeError("getPointEquationString", "point");
2462
+ }
2463
+
2464
+ const coords = InteractiveGraph.getPointCoords(props.graph, props);
2465
+ return coords
2466
+ .map(function (coord) {
2467
+ return "(" + coord[0] + ", " + coord[1] + ")";
2468
+ })
2469
+ .join(", ");
2470
+ }
2471
+
2472
+ static getSegmentEquationString(props: Props): string {
2473
+ if (props.graph.type !== "segment") {
2474
+ throw makeInvalidTypeError("getSegmentEquationString", "segment");
2475
+ }
2476
+
2477
+ const segments = InteractiveGraph.getSegmentCoords(props.graph, props);
2478
+ return _.map(segments, function (segment) {
2479
+ return (
2480
+ "[" +
2481
+ _.map(segment, function (coord) {
2482
+ return "(" + coord.join(", ") + ")";
2483
+ }).join(" ") +
2484
+ "]"
2485
+ );
2486
+ }).join(" ");
2487
+ }
2488
+
2489
+ static getRayEquationString(props: Props): string {
2490
+ if (props.graph.type !== "ray") {
2491
+ throw makeInvalidTypeError("createPointForPolygonType", "ray");
2492
+ }
2493
+
2494
+ const coords = InteractiveGraph.getLineCoords(props.graph, props);
2495
+ const a = coords[0];
2496
+ const b = coords[1];
2497
+ let eq = InteractiveGraph.getLinearEquationString(props);
2498
+
2499
+ if (a[0] > b[0]) {
2500
+ eq += " (for x <= " + a[0].toFixed(3) + ")";
2501
+ } else if (a[0] < b[0]) {
2502
+ eq += " (for x >= " + a[0].toFixed(3) + ")";
2503
+ } else if (a[1] > b[1]) {
2504
+ eq += " (for y <= " + a[1].toFixed(3) + ")";
2505
+ } else {
2506
+ eq += " (for y >= " + a[1].toFixed(3) + ")";
2507
+ }
2508
+
2509
+ return eq;
2510
+ }
2511
+
2512
+ static getPolygonEquationString(props: Props): string {
2513
+ if (props.graph.type !== "polygon") {
2514
+ throw makeInvalidTypeError("getPolygonEquationString", "polygon");
2515
+ }
2516
+ const coords = InteractiveGraph.getPolygonCoords(props.graph, props);
2517
+ return _.map(coords, function (coord) {
2518
+ return "(" + coord.join(", ") + ")";
2519
+ }).join(" ");
2520
+ }
2521
+
2522
+ static getAngleEquationString(props: Props): string {
2523
+ if (props.graph.type !== "angle") {
2524
+ throw makeInvalidTypeError("getAngleEquationString", "angle");
2525
+ }
2526
+ const coords = InteractiveGraph.getAngleCoords(props.graph, props);
2527
+ const angle = GraphUtils.findAngle(coords[2], coords[0], coords[1]);
2528
+ return (
2529
+ angle.toFixed(0) +
2530
+ "\u00B0 angle" +
2531
+ " at (" +
2532
+ coords[1].join(", ") +
2533
+ ")"
2534
+ );
2535
+ }
2536
+
2537
+ static validate(
2538
+ // TODO(kevinb): rename state to userInput
2539
+ state: PerseusGraphType, // state === userInput
2540
+ rubric: Rubric,
2541
+ component: $FlowFixMe,
2542
+ ): PerseusScore {
2543
+ // When nothing has moved, there will neither be coords nor the
2544
+ // circle's center/radius fields. When those fields are absent, skip
2545
+ // all these checks; just go mark the answer as empty.
2546
+ const hasValue = !!(state.coords || (state.center && state.radius));
2547
+
2548
+ if (state.type === rubric.correct.type && hasValue) {
2549
+ if (
2550
+ state.type === "linear" &&
2551
+ rubric.correct.type === "linear" &&
2552
+ state.coords != null
2553
+ ) {
2554
+ const guess = state.coords;
2555
+ const correct = rubric.correct.coords;
2556
+
2557
+ // If both of the guess points are on the correct line, it's
2558
+ // correct.
2559
+ if (
2560
+ // $FlowFixMe[incompatible-use] - Fix type so coords can't be omitted
2561
+ collinear(correct[0], correct[1], guess[0]) &&
2562
+ collinear(correct[0], correct[1], guess[1])
2563
+ ) {
2564
+ return {
2565
+ type: "points",
2566
+ earned: 1,
2567
+ total: 1,
2568
+ message: null,
2569
+ };
2570
+ }
2571
+ } else if (
2572
+ state.type === "linear-system" &&
2573
+ rubric.correct.type === "linear-system" &&
2574
+ state.coords != null
2575
+ ) {
2576
+ const guess = state.coords;
2577
+ // $FlowFixMe[incompatible-cast] - Fix type so this can't be undefined
2578
+ const correct = (rubric.correct.coords: $ReadOnlyArray<
2579
+ $ReadOnlyArray<Coord>,
2580
+ >);
2581
+
2582
+ if (
2583
+ (collinear(correct[0][0], correct[0][1], guess[0][0]) &&
2584
+ collinear(correct[0][0], correct[0][1], guess[0][1]) &&
2585
+ collinear(correct[1][0], correct[1][1], guess[1][0]) &&
2586
+ collinear(correct[1][0], correct[1][1], guess[1][1])) ||
2587
+ (collinear(correct[0][0], correct[0][1], guess[1][0]) &&
2588
+ collinear(correct[0][0], correct[0][1], guess[1][1]) &&
2589
+ collinear(correct[1][0], correct[1][1], guess[0][0]) &&
2590
+ collinear(correct[1][0], correct[1][1], guess[0][1]))
2591
+ ) {
2592
+ return {
2593
+ type: "points",
2594
+ earned: 1,
2595
+ total: 1,
2596
+ message: null,
2597
+ };
2598
+ }
2599
+ } else if (
2600
+ state.type === "quadratic" &&
2601
+ rubric.correct.type === "quadratic" &&
2602
+ state.coords != null
2603
+ ) {
2604
+ // If the parabola coefficients match, it's correct.
2605
+ const guessCoeffs = this.getQuadraticCoefficients(state.coords);
2606
+ const correctCoeffs = this.getQuadraticCoefficients(
2607
+ // $FlowFixMe[incompatible-call]
2608
+ // $FlowFixMe[prop-missing]
2609
+ rubric.correct.coords,
2610
+ );
2611
+ if (deepEq(guessCoeffs, correctCoeffs)) {
2612
+ return {
2613
+ type: "points",
2614
+ earned: 1,
2615
+ total: 1,
2616
+ message: null,
2617
+ };
2618
+ }
2619
+ } else if (
2620
+ state.type === "sinusoid" &&
2621
+ rubric.correct.type === "sinusoid" &&
2622
+ state.coords != null
2623
+ ) {
2624
+ const guessCoeffs = this.getSinusoidCoefficients(state.coords);
2625
+ const correctCoeffs = this.getSinusoidCoefficients(
2626
+ // $FlowFixMe[incompatible-call]
2627
+ // $FlowFixMe[prop-missing]
2628
+ rubric.correct.coords,
2629
+ );
2630
+
2631
+ const canonicalGuessCoeffs =
2632
+ canonicalSineCoefficients(guessCoeffs);
2633
+ const canonicalCorrectCoeffs =
2634
+ canonicalSineCoefficients(correctCoeffs);
2635
+ // If the canonical coefficients match, it's correct.
2636
+ if (deepEq(canonicalGuessCoeffs, canonicalCorrectCoeffs)) {
2637
+ return {
2638
+ type: "points",
2639
+ earned: 1,
2640
+ total: 1,
2641
+ message: null,
2642
+ };
2643
+ }
2644
+ } else if (
2645
+ state.type === "circle" &&
2646
+ rubric.correct.type === "circle"
2647
+ ) {
2648
+ if (
2649
+ deepEq(state.center, rubric.correct.center) &&
2650
+ // $FlowFixMe[prop-missing]
2651
+ eq(state.radius, rubric.correct.radius)
2652
+ ) {
2653
+ return {
2654
+ type: "points",
2655
+ earned: 1,
2656
+ total: 1,
2657
+ message: null,
2658
+ };
2659
+ }
2660
+ } else if (
2661
+ state.type === "point" &&
2662
+ rubric.correct.type === "point" &&
2663
+ state.coords != null
2664
+ ) {
2665
+ let correct = InteractiveGraph.getPointCoords(
2666
+ rubric.correct,
2667
+ component,
2668
+ );
2669
+ const guess = state.coords?.slice();
2670
+ correct = correct.slice();
2671
+ // Everything's already rounded so we shouldn't need to do an
2672
+ // eq() comparison but _.isEqual(0, -0) is false, so we'll use
2673
+ // eq() anyway. The sort should be fine because it'll stringify
2674
+ // it and -0 converted to a string is "0"
2675
+ guess?.sort();
2676
+ correct.sort();
2677
+ if (deepEq(guess, correct)) {
2678
+ return {
2679
+ type: "points",
2680
+ earned: 1,
2681
+ total: 1,
2682
+ message: null,
2683
+ };
2684
+ }
2685
+ } else if (
2686
+ state.type === "polygon" &&
2687
+ rubric.correct.type === "polygon" &&
2688
+ state.coords != null
2689
+ ) {
2690
+ const guess: Array<Coord> = state.coords?.slice(); // eslint-disable-line flowtype/no-mutable-array
2691
+ // $FlowFixMe[incompatible-type]
2692
+ // $FlowFixMe[prop-missing]
2693
+ const correct: Array<Coord> = rubric.correct.coords?.slice(); // eslint-disable-line flowtype/no-mutable-array
2694
+
2695
+ let match;
2696
+ if (rubric.correct.match === "similar") {
2697
+ match = similar(guess, correct, Number.POSITIVE_INFINITY);
2698
+ } else if (rubric.correct.match === "congruent") {
2699
+ match = similar(guess, correct, knumber.DEFAULT_TOLERANCE);
2700
+ } else if (rubric.correct.match === "approx") {
2701
+ match = similar(guess, correct, 0.1);
2702
+ } else {
2703
+ /* exact */
2704
+ guess.sort();
2705
+ correct.sort();
2706
+ match = deepEq(guess, correct);
2707
+ }
2708
+
2709
+ if (match) {
2710
+ return {
2711
+ type: "points",
2712
+ earned: 1,
2713
+ total: 1,
2714
+ message: null,
2715
+ };
2716
+ }
2717
+ } else if (
2718
+ state.type === "segment" &&
2719
+ rubric.correct.type === "segment" &&
2720
+ state.coords != null
2721
+ ) {
2722
+ let guess = state.coords.slice();
2723
+ // $FlowFixMe[prop-missing]
2724
+ let correct = rubric.correct.coords?.slice();
2725
+ guess = _.invoke(guess, "sort").sort();
2726
+ correct = _.invoke(correct, "sort").sort();
2727
+ if (deepEq(guess, correct)) {
2728
+ return {
2729
+ type: "points",
2730
+ earned: 1,
2731
+ total: 1,
2732
+ message: null,
2733
+ };
2734
+ }
2735
+ } else if (
2736
+ state.type === "ray" &&
2737
+ rubric.correct.type === "ray" &&
2738
+ state.coords != null
2739
+ ) {
2740
+ const guess = state.coords;
2741
+ const correct = rubric.correct.coords;
2742
+ if (
2743
+ // $FlowFixMe[incompatible-use]
2744
+ deepEq(guess[0], correct[0]) &&
2745
+ // $FlowFixMe[incompatible-use]
2746
+ collinear(correct[0], correct[1], guess[1])
2747
+ ) {
2748
+ return {
2749
+ type: "points",
2750
+ earned: 1,
2751
+ total: 1,
2752
+ message: null,
2753
+ };
2754
+ }
2755
+ } else if (
2756
+ state.type === "angle" &&
2757
+ rubric.correct.type === "angle"
2758
+ ) {
2759
+ const guess = state.coords;
2760
+ const correct = rubric.correct.coords;
2761
+
2762
+ let match;
2763
+ if (rubric.correct.match === "congruent") {
2764
+ const angles = _.map([guess, correct], function (coords) {
2765
+ const angle = GraphUtils.findAngle(
2766
+ coords[2],
2767
+ coords[0],
2768
+ coords[1],
2769
+ );
2770
+ return (angle + 360) % 360;
2771
+ });
2772
+ match = eq(...angles);
2773
+ } else {
2774
+ /* exact */
2775
+ match =
2776
+ // $FlowFixMe[incompatible-use]
2777
+ deepEq(guess[1], correct[1]) &&
2778
+ // $FlowFixMe[incompatible-use]
2779
+ collinear(correct[1], correct[0], guess[0]) &&
2780
+ // $FlowFixMe[incompatible-use]
2781
+ collinear(correct[1], correct[2], guess[2]);
2782
+ }
2783
+
2784
+ if (match) {
2785
+ return {
2786
+ type: "points",
2787
+ earned: 1,
2788
+ total: 1,
2789
+ message: null,
2790
+ };
2791
+ }
2792
+ }
2793
+ }
2794
+
2795
+ // The input wasn't correct, so check if it's a blank input or if it's
2796
+ // actually just wrong
2797
+ if (!hasValue || _.isEqual(state, rubric.graph)) {
2798
+ // We're where we started.
2799
+ return {
2800
+ type: "invalid",
2801
+ message: null,
2802
+ };
2803
+ }
2804
+ return {
2805
+ type: "points",
2806
+ earned: 0,
2807
+ total: 1,
2808
+ message: null,
2809
+ };
2810
+ }
2811
+
2812
+ static getUserInputFromProps(props: Props): PerseusGraphType {
2813
+ return props.graph;
2814
+ }
2815
+ }
2816
+
2817
+ export default ({
2818
+ name: "interactive-graph",
2819
+ displayName: "Interactive graph",
2820
+ widget: InteractiveGraph,
2821
+ }: WidgetExports<typeof InteractiveGraph>);