@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,4071 @@
1
+ /* eslint-disable @babel/no-invalid-this */
2
+ // @flow
3
+ /**
4
+ * Interactive graphie utilities.
5
+ *
6
+ * This file exposes a couple functions, but mostly it adds functions to the
7
+ * `Graphie` prototype for dealing with interactive graphie elements.
8
+ */
9
+
10
+ // TODO(emily): This file breaks our line length limits like nobody's business.
11
+ // Figure out how to fix that.
12
+ import $ from "jquery";
13
+ import {vector as kvector, point as kpoint, line as kline} from "kmath";
14
+ import Raphael from "raphael";
15
+ import _ from "underscore";
16
+
17
+ // NOTE(jeresig): We include a special copy of jQuery vmouse so
18
+ // that Perseus will continue to work in mobile native devices
19
+ // (this should have no impact in the browser)
20
+ // eslint-disable-next-line import/no-unassigned-import
21
+ import "../jquery.mobile.vmouse.js";
22
+ import InteractiveUtil from "../interactive2/interactive-util.js";
23
+ import WrappedEllipse from "../interactive2/wrapped-ellipse.js";
24
+ import WrappedLine from "../interactive2/wrapped-line.js";
25
+ import WrappedPath from "../interactive2/wrapped-path.js";
26
+ import {Errors} from "../logging/log.js";
27
+ import {PerseusError} from "../perseus-error.js";
28
+
29
+ import KhanColors from "./colors.js";
30
+ import GraphUtils from "./graphie.js";
31
+ import KhanMath from "./math.js";
32
+
33
+ import type {Coord} from "../interactive2/types.js";
34
+
35
+ const {getCanUse3dTransform} = InteractiveUtil;
36
+
37
+ function sum(array) {
38
+ return _.reduce(
39
+ array,
40
+ function (memo, arg) {
41
+ return memo + arg;
42
+ },
43
+ 0,
44
+ );
45
+ }
46
+
47
+ function clockwise(points) {
48
+ const segments = _.zip(points, points.slice(1).concat(points.slice(0, 1)));
49
+ const areas = _.map(segments, function (segment) {
50
+ const p1 = segment[0];
51
+ const p2 = segment[1];
52
+ return (p2[0] - p1[0]) * (p2[1] + p1[1]);
53
+ });
54
+ return sum(areas) > 0;
55
+ }
56
+
57
+ /* vector-add multiple [x, y] coords/vectors */
58
+ function addPoints(...points) {
59
+ const zipped = _.zip(...points);
60
+ return _.map(zipped, sum);
61
+ }
62
+
63
+ function reverseVector(vector) {
64
+ return _.map(vector, function (coord) {
65
+ return coord * -1;
66
+ });
67
+ }
68
+
69
+ function scaledDistanceFromAngle(angle) {
70
+ const a = 3.51470560176242 * 20;
71
+ const b = 0.5687298702748785 * 20;
72
+ const c = -0.037587715462826674;
73
+ return (a - b) * Math.exp(c * angle) + b;
74
+ }
75
+
76
+ function scaledPolarRad(radius, radians) {
77
+ return [
78
+ radius * Math.cos(radians),
79
+ radius * Math.sin(radians) * -1, // SVG flips y axis
80
+ ];
81
+ }
82
+
83
+ function scaledPolarDeg(radius, degrees) {
84
+ const radians = (degrees * Math.PI) / 180;
85
+ return scaledPolarRad(radius, radians);
86
+ }
87
+
88
+ // Global dragging state
89
+ let dragging = false;
90
+
91
+ const InteractiveUtils: $FlowFixMe = {
92
+ // Useful for shapes that are only sometimes drawn. If a shape isn't
93
+ // needed, it can be replaced with bogusShape which just has stub methods
94
+ // that successfully do nothing.
95
+ // The alternative would be 'if..typeof' checks all over the place.
96
+ bogusShape: {
97
+ animate: function () {},
98
+ attr: function () {},
99
+ remove: function () {},
100
+ },
101
+ };
102
+
103
+ _.extend(GraphUtils.Graphie.prototype, {
104
+ // graphie puts text spans on top of the SVG, which looks good, but gets
105
+ // in the way of mouse events. This adds another SVG element on top
106
+ // of everything else where we can add invisible shapes with mouse
107
+ // handlers wherever we want.
108
+ addMouseLayer: function (options) {
109
+ const graph = this;
110
+ options = _.extend(
111
+ {
112
+ allowScratchpad: false,
113
+ setDrawingAreaAvailable: function () {},
114
+ },
115
+ options,
116
+ );
117
+
118
+ const mouselayerZIndex = 2;
119
+ graph.mouselayer = Raphael(
120
+ graph.raphael.canvas.parentNode,
121
+ graph.xpixels,
122
+ graph.ypixels,
123
+ );
124
+ $(graph.mouselayer.canvas).css("z-index", mouselayerZIndex);
125
+ if (
126
+ options.onClick ||
127
+ options.onMouseDown ||
128
+ options.onMouseMove ||
129
+ options.onMouseOver ||
130
+ options.onMouseOut
131
+ ) {
132
+ const canvasClickTarget = graph.mouselayer
133
+ .rect(0, 0, graph.xpixels, graph.ypixels)
134
+ .attr({
135
+ fill: "#000",
136
+ opacity: 0,
137
+ });
138
+ let isClickingCanvas = false;
139
+
140
+ $(graph.mouselayer.canvas).on("vmousedown", function (e) {
141
+ if (e.target === canvasClickTarget[0]) {
142
+ if (options.onMouseDown) {
143
+ options.onMouseDown(graph.getMouseCoord(e));
144
+ }
145
+ isClickingCanvas = true;
146
+
147
+ if (options.onMouseMove) {
148
+ $(document).bind("vmousemove.mouseLayer", function (e) {
149
+ if (isClickingCanvas) {
150
+ e.preventDefault();
151
+ options.onMouseMove(graph.getMouseCoord(e));
152
+ }
153
+ });
154
+ }
155
+
156
+ $(document).bind("vmouseup.mouseLayer", function (e) {
157
+ $(document).unbind(".mouseLayer");
158
+
159
+ // Only register clicks that started on the canvas,
160
+ // and not on another mouseLayer target
161
+ if (isClickingCanvas && options.onClick) {
162
+ options.onClick(graph.getMouseCoord(e));
163
+ }
164
+ isClickingCanvas = false;
165
+ });
166
+ }
167
+ });
168
+ if (options.onMouseOver) {
169
+ $(graph.mouselayer.canvas).on("vmouseover", function (e) {
170
+ options.onMouseOver(graph.getMouseCoord(e));
171
+ });
172
+ }
173
+ if (options.onMouseOut) {
174
+ $(graph.mouselayer.canvas).on("vmouseout", function (e) {
175
+ options.onMouseOut(graph.getMouseCoord(e));
176
+ });
177
+ }
178
+ }
179
+ if (!options.allowScratchpad) {
180
+ options.setDrawingAreaAvailable(false);
181
+ }
182
+
183
+ // Add mouse and visible wrapper layers for DOM-node-wrapped movables
184
+ graph._mouselayerWrapper = document.createElement("div");
185
+ $(graph._mouselayerWrapper).css({
186
+ position: "absolute",
187
+ left: 0,
188
+ top: 0,
189
+ zIndex: mouselayerZIndex,
190
+ });
191
+
192
+ graph._visiblelayerWrapper = document.createElement("div");
193
+ $(graph._visiblelayerWrapper).css({
194
+ position: "absolute",
195
+ left: 0,
196
+ top: 0,
197
+ });
198
+
199
+ const el = graph.raphael.canvas.parentNode;
200
+ el.appendChild(graph._visiblelayerWrapper);
201
+ el.appendChild(graph._mouselayerWrapper);
202
+
203
+ // Add functions for adding to wrappers
204
+ graph.addToMouseLayerWrapper = function (el) {
205
+ this._mouselayerWrapper.appendChild(el);
206
+ };
207
+ graph.addToVisibleLayerWrapper = function (el) {
208
+ this._visiblelayerWrapper.appendChild(el);
209
+ };
210
+ },
211
+
212
+ /**
213
+ * Get mouse coordinates in pixels
214
+ */
215
+ getMousePx: function (event) {
216
+ const graphie = this;
217
+
218
+ const mouseX =
219
+ event.pageX - $(graphie.raphael.canvas.parentNode).offset().left;
220
+ const mouseY =
221
+ event.pageY - $(graphie.raphael.canvas.parentNode).offset().top;
222
+
223
+ return [mouseX, mouseY];
224
+ },
225
+
226
+ /**
227
+ * Get mouse coordinates in graph coordinates
228
+ */
229
+ getMouseCoord: function (event) {
230
+ return this.unscalePoint(this.getMousePx(event));
231
+ },
232
+
233
+ /**
234
+ * Unlike all other Graphie-related code, the following three functions use
235
+ * a lot of scaled coordinates (so that labels appear the same size
236
+ * regardless of current shape/figure scale). These are prefixed with 's'.
237
+ */
238
+ labelAngle: function (options) {
239
+ const graphie = this;
240
+
241
+ _.defaults(options, {
242
+ point1: [0, 0],
243
+ vertex: [0, 0],
244
+ point3: [0, 0],
245
+ label: null,
246
+ numArcs: 1,
247
+ showRightAngleMarker: true,
248
+ pushOut: 0,
249
+ clockwise: false,
250
+ style: {},
251
+ });
252
+
253
+ let text = options.text === undefined ? "" : options.text;
254
+ const vertex = options.vertex;
255
+ const sVertex = graphie.scalePoint(vertex);
256
+ let p1;
257
+ let p3;
258
+ if (options.clockwise) {
259
+ p1 = options.point1;
260
+ p3 = options.point3;
261
+ } else {
262
+ p1 = options.point3;
263
+ p3 = options.point1;
264
+ }
265
+
266
+ const startAngle = GraphUtils.findAngle(p1, vertex);
267
+ const endAngle = GraphUtils.findAngle(p3, vertex);
268
+ const angle = (endAngle + 360 - startAngle) % 360;
269
+ const halfAngle = (startAngle + angle / 2) % 360;
270
+ const sPadding = 5 * options.pushOut;
271
+ let sRadius = sPadding + scaledDistanceFromAngle(angle);
272
+ const temp = [];
273
+
274
+ if (Math.abs(angle - 90) < 1e-9 && options.showRightAngleMarker) {
275
+ const v1 = addPoints(sVertex, scaledPolarDeg(sRadius, startAngle));
276
+ const v2 = addPoints(sVertex, scaledPolarDeg(sRadius, endAngle));
277
+
278
+ sRadius *= Math.SQRT2;
279
+ const v3 = addPoints(sVertex, scaledPolarDeg(sRadius, halfAngle));
280
+
281
+ _.each([v1, v2], function (v) {
282
+ temp.push(graphie.scaledPath([v, v3], options.style));
283
+ });
284
+ } else {
285
+ // Draw arcs
286
+ _.times(options.numArcs, function (i) {
287
+ temp.push(
288
+ graphie.arc(
289
+ vertex,
290
+ graphie.unscaleVector(sRadius),
291
+ startAngle,
292
+ endAngle,
293
+ options.style,
294
+ ),
295
+ );
296
+ sRadius += 3;
297
+ });
298
+ }
299
+
300
+ if (text) {
301
+ const match = text.match(/\$deg(\d)?/);
302
+ if (match) {
303
+ const precision = match[1] || 1;
304
+ text = text.replace(
305
+ match[0],
306
+ KhanMath.toFixedApprox(angle, precision) + "^{\\circ}",
307
+ );
308
+ }
309
+
310
+ const sOffset = scaledPolarDeg(sRadius + 15, halfAngle);
311
+ const sPosition = addPoints(sVertex, sOffset);
312
+ const position = graphie.unscalePoint(sPosition);
313
+
314
+ // Reuse label if possible
315
+ if (options.label) {
316
+ options.label.setPosition(position);
317
+ options.label.processMath(text, /* force */ true);
318
+ } else {
319
+ graphie.label(position, text, "center", options.style);
320
+ }
321
+ }
322
+
323
+ return temp;
324
+ },
325
+
326
+ labelSide: function (options) {
327
+ const graphie = this;
328
+
329
+ _.defaults(options, {
330
+ point1: [0, 0],
331
+ point2: [0, 0],
332
+ label: null,
333
+ text: "",
334
+ numTicks: 0,
335
+ numArrows: 0,
336
+ clockwise: false,
337
+ style: {},
338
+ });
339
+
340
+ let p1;
341
+ let p2;
342
+ if (options.clockwise) {
343
+ p1 = options.point1;
344
+ p2 = options.point2;
345
+ } else {
346
+ p1 = options.point2;
347
+ p2 = options.point1;
348
+ }
349
+
350
+ const midpoint = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2];
351
+ const sMidpoint = graphie.scalePoint(midpoint);
352
+ const parallelAngle = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]);
353
+ const perpendicularAngle = parallelAngle + Math.PI / 2;
354
+ const temp = [];
355
+ let sCumulativeOffset = 0;
356
+
357
+ if (options.numTicks) {
358
+ const n = options.numTicks;
359
+
360
+ const sSpacing = 5;
361
+ const sHeight = 5;
362
+
363
+ const style = _.extend({}, options.style, {
364
+ strokeWidth: 2,
365
+ });
366
+
367
+ _.times(n, function (i) {
368
+ const sOffset = sSpacing * (i - (n - 1) / 2);
369
+
370
+ const sOffsetVector = scaledPolarRad(sOffset, parallelAngle);
371
+ const sHeightVector = scaledPolarRad(
372
+ sHeight,
373
+ perpendicularAngle,
374
+ );
375
+
376
+ const sPath = [
377
+ addPoints(sMidpoint, sOffsetVector, sHeightVector),
378
+ addPoints(
379
+ sMidpoint,
380
+ sOffsetVector,
381
+ reverseVector(sHeightVector),
382
+ ),
383
+ ];
384
+
385
+ temp.push(graphie.scaledPath(sPath, style));
386
+ });
387
+
388
+ sCumulativeOffset += sSpacing * (n - 1) + 15;
389
+ }
390
+
391
+ if (options.numArrows) {
392
+ const n = options.numArrows;
393
+
394
+ const start = [p1, p2].sort(function (a, b) {
395
+ if (a[1] === b[1]) {
396
+ return a[0] - b[0];
397
+ }
398
+ return a[1] - b[1];
399
+ })[0];
400
+ const sStart = graphie.scalePoint(start);
401
+
402
+ const style = _.extend({}, options.style, {
403
+ arrows: "->",
404
+ strokeWidth: 2,
405
+ });
406
+
407
+ const sSpacing = 5;
408
+
409
+ _.times(n, function (i) {
410
+ const sOffset = sCumulativeOffset + sSpacing * i;
411
+ let sOffsetVector = scaledPolarRad(sOffset, parallelAngle);
412
+
413
+ if (start !== p1) {
414
+ sOffsetVector = reverseVector(sOffsetVector);
415
+ }
416
+
417
+ const sEnd = addPoints(sMidpoint, sOffsetVector);
418
+
419
+ temp.push(graphie.scaledPath([sStart, sEnd], style));
420
+ });
421
+ }
422
+
423
+ let text = options.text;
424
+ if (text) {
425
+ const match = text.match(/\$len(\d)?/);
426
+ if (match) {
427
+ const distance = GraphUtils.getDistance(p1, p2);
428
+ const precision = match[1] || 1;
429
+ text = text.replace(
430
+ match[0],
431
+ KhanMath.toFixedApprox(distance, precision),
432
+ );
433
+ }
434
+
435
+ const sOffset = 20;
436
+ const sOffsetVector = scaledPolarRad(sOffset, perpendicularAngle);
437
+ const sPosition = addPoints(sMidpoint, sOffsetVector);
438
+ const position = graphie.unscalePoint(sPosition);
439
+
440
+ // Reuse label if possible
441
+ if (options.label) {
442
+ options.label.setPosition(position);
443
+ options.label.processMath(text, /* force */ true);
444
+ } else {
445
+ graphie.label(position, text, "center", options.style);
446
+ }
447
+ }
448
+
449
+ return temp;
450
+ },
451
+
452
+ /* Can also be used to label points that aren't vertices */
453
+ labelVertex: function (options) {
454
+ const graphie = this;
455
+
456
+ _.defaults(options, {
457
+ point1: null,
458
+ vertex: [0, 0],
459
+ point3: null,
460
+ label: null,
461
+ text: "",
462
+ clockwise: false,
463
+ style: {},
464
+ });
465
+
466
+ if (!options.text) {
467
+ return;
468
+ }
469
+
470
+ const vertex = options.vertex;
471
+ const sVertex = graphie.scalePoint(vertex);
472
+ let p1;
473
+ let p3;
474
+ if (options.clockwise) {
475
+ p1 = options.point1;
476
+ p3 = options.point3;
477
+ } else {
478
+ p1 = options.point3;
479
+ p3 = options.point1;
480
+ }
481
+
482
+ let angle = 135;
483
+ let halfAngle;
484
+ if (p1 && p3) {
485
+ const startAngle = GraphUtils.findAngle(p1, vertex);
486
+ const endAngle = GraphUtils.findAngle(p3, vertex);
487
+ angle = (endAngle + 360 - startAngle) % 360;
488
+ halfAngle = (startAngle + angle / 2 + 180) % 360;
489
+ } else if (p1) {
490
+ const parallelAngle = GraphUtils.findAngle(vertex, p1);
491
+ halfAngle = parallelAngle + 90;
492
+ } else if (p3) {
493
+ const parallelAngle = GraphUtils.findAngle(p3, vertex);
494
+ halfAngle = parallelAngle + 90;
495
+ } else {
496
+ // Standalone point
497
+ halfAngle = 135;
498
+ }
499
+
500
+ const sRadius = 10 + scaledDistanceFromAngle(360 - angle);
501
+ const sOffsetVector = scaledPolarDeg(sRadius, halfAngle);
502
+ const sPosition = addPoints(sVertex, sOffsetVector);
503
+ const position = graphie.unscalePoint(sPosition);
504
+
505
+ // Reuse label if possible
506
+ if (options.label) {
507
+ options.label.setPosition(position);
508
+ options.label.processMath(options.text, /* force */ true);
509
+ } else {
510
+ graphie.label(position, options.text, "center", options.style);
511
+ }
512
+ },
513
+
514
+ // Add a point to the graph that can be dragged around.
515
+ // It allows automatic constraints on its movement as well as automatically
516
+ // managing line segments that terminate at the point.
517
+ //
518
+ // Options can be set to control how the point behaves:
519
+ // coord[]:
520
+ // The initial position of the point
521
+ // snapX, snapY:
522
+ // The minimum increment the point can be moved
523
+ //
524
+ // The return value is an object that can be used to manipulate the point:
525
+ // The coordX and coordY properties tell you the current position
526
+ //
527
+ // By adding an onMove() method to the returned object, you can install an
528
+ // event handler that gets called every time the user moves the point.
529
+ //
530
+ // The returned object also provides a moveTo(x,y) method that will move
531
+ // the point to a specific coordinate
532
+ //
533
+ // Constraints can be set on the on the returned object:
534
+ //
535
+ // - Set point to be immovable:
536
+ // movablePoint.constraints.fixed = true
537
+ //
538
+ // - Constrain point to a fixed distance from another point. The resulting
539
+ // point will move in a circle:
540
+ // movablePoint.fixedDistance = {
541
+ // dist: 2,
542
+ // point: point1
543
+ // }
544
+ //
545
+ // - Constrain point to a line defined by a fixed angle between it and
546
+ // two other points:
547
+ // movablePoint.fixedAngle = {
548
+ // angle: 45,
549
+ // vertex: point1,
550
+ // ref: point2
551
+ // }
552
+ //
553
+ // - Confined the point to traveling in a vertical or horizontal line,
554
+ // respectively
555
+ // movablePoint.constrainX = true;
556
+ // movablePoint.constrainY = true;
557
+ //
558
+ // - Connect a movableLineSegment to a movablePoint. The point is attached
559
+ // to a specific end of the line segment by adding the segment either to
560
+ // the list of lines that start at the point or the list of lines that
561
+ // end at the point (movableLineSegment can do this for you):
562
+ // movablePoint.lineStarts.push(movableLineSegment);
563
+ // - or -
564
+ // movablePoint.lineEnds.push(movableLineSegment);
565
+ //
566
+ // - Connect a movablePolygon to a movablePoint in exacty the same way:
567
+ // movablePoint.polygonVertices.push(movablePolygon);
568
+ //
569
+ addMovablePoint: function (options) {
570
+ const movablePoint = $.extend(
571
+ true,
572
+ {
573
+ graph: this,
574
+ coord: [0, 0],
575
+ snapX: 0,
576
+ snapY: 0,
577
+ pointSize: 4,
578
+ highlight: false,
579
+ dragging: false,
580
+ visible: true,
581
+ bounded: true,
582
+ constraints: {
583
+ fixed: false,
584
+ constrainX: false,
585
+ constrainY: false,
586
+ fixedAngle: {},
587
+ fixedDistance: {},
588
+ },
589
+ lineStarts: [],
590
+ lineEnds: [],
591
+ polygonVertices: [],
592
+ normalStyle: {},
593
+ highlightStyle: {
594
+ fill: KhanColors.INTERACTING,
595
+ stroke: KhanColors.INTERACTING,
596
+ },
597
+ labelStyle: {
598
+ color: KhanColors.INTERACTIVE,
599
+ },
600
+ vertexLabel: "",
601
+ mouseTarget: null,
602
+ },
603
+ options,
604
+ );
605
+
606
+ const normalColor = movablePoint.constraints.fixed
607
+ ? KhanColors.DYNAMIC
608
+ : KhanColors.INTERACTIVE;
609
+ movablePoint.normalStyle = _.extend(
610
+ {},
611
+ {
612
+ fill: normalColor,
613
+ stroke: normalColor,
614
+ },
615
+ options.normalStyle,
616
+ );
617
+
618
+ // deprecated: don't use coordX/coordY; use coord[]
619
+ if (options.coordX !== undefined) {
620
+ movablePoint.coord[0] = options.coordX;
621
+ }
622
+ if (options.coordY !== undefined) {
623
+ movablePoint.coord[1] = options.coordY;
624
+ }
625
+
626
+ const graph = movablePoint.graph;
627
+
628
+ const applySnapAndConstraints = function (coord) {
629
+ // coord should be the scaled coordinate
630
+
631
+ // move point away from edge of graph unless it's invisible or fixed
632
+ if (
633
+ movablePoint.visible &&
634
+ movablePoint.bounded &&
635
+ !movablePoint.constraints.fixed
636
+ ) {
637
+ // can't go beyond 10 pixels from the edge
638
+ coord = graph.constrainToBounds(coord, 10);
639
+ }
640
+
641
+ let coordX = coord[0];
642
+ let coordY = coord[1];
643
+
644
+ // snap coordinates to grid
645
+ if (movablePoint.snapX !== 0) {
646
+ coordX =
647
+ Math.round(coordX / movablePoint.snapX) *
648
+ movablePoint.snapX;
649
+ }
650
+ if (movablePoint.snapY !== 0) {
651
+ coordY =
652
+ Math.round(coordY / movablePoint.snapY) *
653
+ movablePoint.snapY;
654
+ }
655
+
656
+ // snap to points around circle
657
+ if (movablePoint.constraints.fixedDistance.snapPoints) {
658
+ const mouse = graph.scalePoint(coord);
659
+ let mouseX = mouse[0];
660
+ let mouseY = mouse[1];
661
+
662
+ const snapRadians =
663
+ (2 * Math.PI) /
664
+ movablePoint.constraints.fixedDistance.snapPoints;
665
+ const radius = movablePoint.constraints.fixedDistance.dist;
666
+
667
+ const centerCoord =
668
+ movablePoint.constraints.fixedDistance.point;
669
+ const centerX =
670
+ (centerCoord[0] - graph.range[0][0]) * graph.scale[0];
671
+ const centerY =
672
+ (-centerCoord[1] + graph.range[1][1]) * graph.scale[1];
673
+
674
+ let mouseXrel = mouseX - centerX;
675
+ let mouseYrel = -mouseY + centerY;
676
+ let radians = Math.atan(mouseYrel / mouseXrel);
677
+ const outsideArcTanRange = mouseXrel < 0;
678
+
679
+ // adjust so that angles increase from 0 to 2 pi as you go
680
+ // around the circle
681
+ if (outsideArcTanRange) {
682
+ radians += Math.PI;
683
+ }
684
+
685
+ // perform the snap
686
+ radians = Math.round(radians / snapRadians) * snapRadians;
687
+
688
+ // convert from radians back to pixels
689
+ mouseXrel = radius * Math.cos(radians);
690
+ mouseYrel = radius * Math.sin(radians);
691
+ // convert back to coordinates relative to graphie canvas
692
+ mouseX = mouseXrel + centerX;
693
+ mouseY = -mouseYrel + centerY;
694
+ coordX = KhanMath.roundTo(
695
+ 5,
696
+ mouseX / graph.scale[0] + graph.range[0][0],
697
+ );
698
+ coordY = KhanMath.roundTo(
699
+ 5,
700
+ graph.range[1][1] - mouseY / graph.scale[1],
701
+ );
702
+ }
703
+
704
+ const result = movablePoint.applyConstraint([coordX, coordY]);
705
+ return result;
706
+ };
707
+
708
+ // Using the passed coordinates, apply any constraints and return
709
+ // the closest coordinates that match the constraints.
710
+ movablePoint.applyConstraint = function (
711
+ coord,
712
+ extraConstraints,
713
+ override,
714
+ ) {
715
+ let newCoord = coord.slice();
716
+ const constraints = {};
717
+ if (override) {
718
+ $.extend(
719
+ constraints,
720
+ {
721
+ fixed: false,
722
+ constrainX: false,
723
+ constrainY: false,
724
+ fixedAngle: {},
725
+ fixedDistance: {},
726
+ },
727
+ extraConstraints,
728
+ );
729
+ } else {
730
+ $.extend(constraints, this.constraints, extraConstraints);
731
+ }
732
+
733
+ // constrain to vertical movement
734
+ if (constraints.constrainX) {
735
+ newCoord = [this.coord[0], coord[1]];
736
+
737
+ // constrain to horizontal movement
738
+ } else if (constraints.constrainY) {
739
+ newCoord = [coord[0], this.coord[1]];
740
+
741
+ // both distance and angle are constrained
742
+ } else if (
743
+ typeof constraints.fixedAngle.angle === "number" &&
744
+ typeof constraints.fixedDistance.dist === "number"
745
+ ) {
746
+ const vertex =
747
+ constraints.fixedAngle.vertex.coord ||
748
+ constraints.fixedAngle.vertex;
749
+ const ref =
750
+ constraints.fixedAngle.ref.coord ||
751
+ constraints.fixedAngle.ref;
752
+ const distPoint =
753
+ constraints.fixedDistance.point.coord ||
754
+ constraints.fixedDistance.point;
755
+
756
+ const constrainedAngle =
757
+ ((constraints.fixedAngle.angle +
758
+ GraphUtils.findAngle(ref, vertex)) *
759
+ Math.PI) /
760
+ 180;
761
+ const length = constraints.fixedDistance.dist;
762
+ newCoord[0] =
763
+ length * Math.cos(constrainedAngle) + distPoint[0];
764
+ newCoord[1] =
765
+ length * Math.sin(constrainedAngle) + distPoint[1];
766
+
767
+ // angle is constrained
768
+ } else if (typeof constraints.fixedAngle.angle === "number") {
769
+ const vertex =
770
+ constraints.fixedAngle.vertex.coord ||
771
+ constraints.fixedAngle.vertex;
772
+ const ref =
773
+ constraints.fixedAngle.ref.coord ||
774
+ constraints.fixedAngle.ref;
775
+
776
+ const constrainedAngle =
777
+ ((constraints.fixedAngle.angle +
778
+ GraphUtils.findAngle(ref, vertex)) *
779
+ Math.PI) /
780
+ 180;
781
+ const angle =
782
+ (GraphUtils.findAngle(coord, vertex) * Math.PI) / 180;
783
+ const distance = GraphUtils.getDistance(coord, vertex);
784
+ let length = distance * Math.cos(constrainedAngle - angle);
785
+ length = length < 1.0 ? 1.0 : length;
786
+ newCoord[0] = length * Math.cos(constrainedAngle) + vertex[0];
787
+ newCoord[1] = length * Math.sin(constrainedAngle) + vertex[1];
788
+
789
+ // distance is constrained
790
+ } else if (typeof constraints.fixedDistance.dist === "number") {
791
+ const distPoint =
792
+ constraints.fixedDistance.point.coord ||
793
+ constraints.fixedDistance.point;
794
+
795
+ let angle = GraphUtils.findAngle(coord, distPoint);
796
+ const length = constraints.fixedDistance.dist;
797
+ angle = (angle * Math.PI) / 180;
798
+ newCoord[0] = length * Math.cos(angle) + distPoint[0];
799
+ newCoord[1] = length * Math.sin(angle) + distPoint[1];
800
+
801
+ // point is fixed
802
+ } else if (constraints.fixed) {
803
+ newCoord = movablePoint.coord;
804
+ }
805
+ return newCoord;
806
+ };
807
+
808
+ movablePoint.coord = applySnapAndConstraints(movablePoint.coord);
809
+
810
+ const highlightScale = 2;
811
+
812
+ if (movablePoint.visible) {
813
+ graph.style(movablePoint.normalStyle, function () {
814
+ const radii = [
815
+ movablePoint.pointSize / graph.scale[0],
816
+ movablePoint.pointSize / graph.scale[1],
817
+ ];
818
+ const options = {
819
+ maxScale: highlightScale,
820
+ // Add in 2px of padding to avoid clipping at the edges.
821
+ padding: 2,
822
+ };
823
+ // $FlowFixMe[invalid-constructor]
824
+ movablePoint.visibleShape = new WrappedEllipse(
825
+ graph,
826
+ movablePoint.coord,
827
+ radii,
828
+ options,
829
+ );
830
+ movablePoint.visibleShape.attr(
831
+ _.omit(movablePoint.normalStyle, "scale"),
832
+ );
833
+ movablePoint.visibleShape.toFront();
834
+ });
835
+ }
836
+ movablePoint.normalStyle.scale = 1;
837
+ movablePoint.highlightStyle.scale = highlightScale;
838
+
839
+ if (movablePoint.vertexLabel) {
840
+ movablePoint.labeledVertex = this.label(
841
+ [0, 0],
842
+ "",
843
+ "center",
844
+ movablePoint.labelStyle,
845
+ );
846
+ }
847
+
848
+ movablePoint.drawLabel = function () {
849
+ if (movablePoint.vertexLabel) {
850
+ movablePoint.graph.labelVertex({
851
+ vertex: movablePoint.coord,
852
+ label: movablePoint.labeledVertex,
853
+ text: movablePoint.vertexLabel,
854
+ style: movablePoint.labelStyle,
855
+ });
856
+ }
857
+ };
858
+
859
+ movablePoint.drawLabel();
860
+
861
+ movablePoint.grab = function (offset) {
862
+ // The offset for the gesture. When provided, the movable point will
863
+ // track the mouse's position, plus this offset. This is typically
864
+ // used to lock the distance between a user's finger and the movable
865
+ // point, when dragging.
866
+ offset = offset || [0, 0];
867
+
868
+ $(document).bind(
869
+ "vmousemove.point vmouseup.point",
870
+ function (event) {
871
+ event.preventDefault();
872
+ movablePoint.dragging = true;
873
+ dragging = true;
874
+
875
+ // Adjust the target coordinate by accounting for the gesture's
876
+ // offset.
877
+ let coord = kvector.add(graph.getMouseCoord(event), offset);
878
+
879
+ coord = applySnapAndConstraints(coord);
880
+ let coordX = coord[0];
881
+ let coordY = coord[1];
882
+ let mouseX;
883
+ let mouseY;
884
+
885
+ if (event.type === "vmousemove") {
886
+ let doMove = true;
887
+ // The caller has the option of adding an onMove() method
888
+ // to the movablePoint object we return as a sort of event
889
+ // handler. By returning false from onMove(), the move
890
+ // can be vetoed, providing custom constraints on where
891
+ // the point can be moved. By returning array [x, y], the
892
+ // move can be overridden
893
+ if (_.isFunction(movablePoint.onMove)) {
894
+ const result = movablePoint.onMove(coordX, coordY);
895
+ if (result === false) {
896
+ doMove = false;
897
+ }
898
+ if (_.isArray(result)) {
899
+ coordX = result[0];
900
+ coordY = result[1];
901
+ }
902
+ }
903
+ // coord{X|Y} may have been modified by constraints or
904
+ // onMove handler; adjust mouse{X|Y} to match
905
+ mouseX = (coordX - graph.range[0][0]) * graph.scale[0];
906
+ mouseY = (-coordY + graph.range[1][1]) * graph.scale[1];
907
+
908
+ if (doMove) {
909
+ const point = graph.unscalePoint([mouseX, mouseY]);
910
+ movablePoint.visibleShape.moveTo(point);
911
+ movablePoint.mouseTarget.moveTo(point);
912
+ movablePoint.coord = [coordX, coordY];
913
+ movablePoint.updateLineEnds();
914
+ $(movablePoint).trigger("move");
915
+ }
916
+
917
+ movablePoint.drawLabel();
918
+ } else if (event.type === "vmouseup") {
919
+ $(document).unbind(".point");
920
+ movablePoint.dragging = false;
921
+ dragging = false;
922
+ if (_.isFunction(movablePoint.onMoveEnd)) {
923
+ const result = movablePoint.onMoveEnd(
924
+ coordX,
925
+ coordY,
926
+ );
927
+ if (_.isArray(result)) {
928
+ coordX = result[0];
929
+ coordY = result[1];
930
+ mouseX =
931
+ (coordX - graph.range[0][0]) *
932
+ graph.scale[0];
933
+ mouseY =
934
+ (-coordY + graph.range[1][1]) *
935
+ graph.scale[1];
936
+ const point = graph.unscalePoint([
937
+ mouseX,
938
+ mouseY,
939
+ ]);
940
+ movablePoint.visibleShape.moveTo(point);
941
+ movablePoint.mouseTarget.moveTo(point);
942
+ movablePoint.coord = [coordX, coordY];
943
+ }
944
+ }
945
+ if (!movablePoint.highlight) {
946
+ movablePoint.visibleShape.animate(
947
+ movablePoint.normalStyle,
948
+ 50,
949
+ );
950
+ if (movablePoint.onUnhighlight) {
951
+ movablePoint.onUnhighlight();
952
+ }
953
+ }
954
+ }
955
+ },
956
+ );
957
+ };
958
+
959
+ if (movablePoint.visible && !movablePoint.constraints.fixed) {
960
+ // the invisible shape in front of the point that gets mouse events
961
+ if (!movablePoint.mouseTarget) {
962
+ const radii = graph.unscaleVector(16);
963
+ const options = {
964
+ mouselayer: true,
965
+ padding: 0,
966
+ disableMouseEventsOnWrapper: true,
967
+ interactiveKindForTesting: "movable-point",
968
+ };
969
+ // $FlowFixMe[invalid-constructor]
970
+ movablePoint.mouseTarget = new WrappedEllipse(
971
+ graph,
972
+ movablePoint.coord,
973
+ radii,
974
+ options,
975
+ );
976
+ movablePoint.mouseTarget.attr({fill: "#000", opacity: 0.0});
977
+ }
978
+
979
+ const $mouseTarget = $(movablePoint.mouseTarget.getMouseTarget());
980
+ $mouseTarget.css("cursor", "move");
981
+ $mouseTarget.bind(
982
+ "vmousedown vmouseover vmouseout",
983
+ function (event) {
984
+ if (event.type === "vmouseover") {
985
+ movablePoint.highlight = true;
986
+ if (!dragging) {
987
+ movablePoint.visibleShape.animate(
988
+ movablePoint.highlightStyle,
989
+ 50,
990
+ );
991
+ if (movablePoint.onHighlight) {
992
+ movablePoint.onHighlight();
993
+ }
994
+ }
995
+ } else if (event.type === "vmouseout") {
996
+ movablePoint.highlight = false;
997
+ if (!movablePoint.dragging && !dragging) {
998
+ movablePoint.visibleShape.animate(
999
+ movablePoint.normalStyle,
1000
+ 50,
1001
+ );
1002
+ if (movablePoint.onUnhighlight) {
1003
+ movablePoint.onUnhighlight();
1004
+ }
1005
+ }
1006
+ } else if (
1007
+ event.type === "vmousedown" &&
1008
+ (event.which === 1 || event.which === 0)
1009
+ ) {
1010
+ event.preventDefault();
1011
+
1012
+ // The offset between the cursor or finger and the initial
1013
+ // coordinates of the point. This is tracked so as to avoid
1014
+ // locking the moving point to the user's finger on touch
1015
+ // devices, which would obscure it, no matter how large we
1016
+ // made the touch target. Instead, we respect the offset at
1017
+ // which the point was grabbed for the entirety of the
1018
+ // gesture, if it's a touch-based interaction.
1019
+ const startCoord = movablePoint.coord;
1020
+ const startMouseCoord = graph.getMouseCoord(event);
1021
+ const isMouse = !("ontouchstart" in window);
1022
+ const touchOffset = isMouse
1023
+ ? [0, 0]
1024
+ : kvector.subtract(startCoord, startMouseCoord);
1025
+
1026
+ movablePoint.grab(touchOffset);
1027
+ }
1028
+ },
1029
+ );
1030
+ }
1031
+
1032
+ // Method to let the caller animate the point to a new position.
1033
+ // Useful as part of a hint to show the user the correct place
1034
+ // to put the point.
1035
+ movablePoint.moveTo = function (coordX, coordY, updateLines) {
1036
+ const distance = GraphUtils.getDistance(
1037
+ this.graph.scalePoint([coordX, coordY]),
1038
+ this.graph.scalePoint(this.coord),
1039
+ );
1040
+
1041
+ const time = distance * 5;
1042
+
1043
+ const cb =
1044
+ updateLines &&
1045
+ function (coord) {
1046
+ movablePoint.coord = coord;
1047
+ movablePoint.updateLineEnds();
1048
+ };
1049
+ this.visibleShape.animateTo([coordX, coordY], time, cb);
1050
+ this.mouseTarget.animateTo([coordX, coordY], time, cb);
1051
+ this.coord = [coordX, coordY];
1052
+ if (_.isFunction(this.onMove)) {
1053
+ this.onMove(coordX, coordY);
1054
+ }
1055
+ };
1056
+
1057
+ // After moving the point, call this to update all line segments
1058
+ // terminating at the point
1059
+ movablePoint.updateLineEnds = function () {
1060
+ $(this.lineStarts).each(function () {
1061
+ this.coordA = movablePoint.coord;
1062
+ this.transform();
1063
+ });
1064
+ $(this.lineEnds).each(function () {
1065
+ this.coordZ = movablePoint.coord;
1066
+ this.transform();
1067
+ });
1068
+ $(this.polygonVertices).each(function () {
1069
+ this.transform();
1070
+ });
1071
+ };
1072
+
1073
+ // Put the point at a new position without any checks, animation,
1074
+ // or callbacks
1075
+ movablePoint.setCoord = function (coord) {
1076
+ if (this.visible) {
1077
+ this.visibleShape.moveTo(coord);
1078
+ if (this.mouseTarget != null) {
1079
+ this.mouseTarget.moveTo(coord);
1080
+ }
1081
+ }
1082
+ this.coord = coord.slice();
1083
+ };
1084
+
1085
+ // Put the point at the new position, checking that it is
1086
+ // within the graph's bounds
1087
+ movablePoint.setCoordConstrained = function (coord) {
1088
+ this.setCoord(applySnapAndConstraints(coord));
1089
+ };
1090
+
1091
+ // Change z-order to back
1092
+ movablePoint.toBack = function () {
1093
+ if (this.visible) {
1094
+ if (this.mouseTarget != null) {
1095
+ this.mouseTarget.toBack();
1096
+ }
1097
+ this.visibleShape.toBack();
1098
+ }
1099
+ };
1100
+
1101
+ // Change z-order to front
1102
+ movablePoint.toFront = function () {
1103
+ if (this.visible) {
1104
+ if (this.mouseTarget != null) {
1105
+ this.mouseTarget.toFront();
1106
+ }
1107
+ this.visibleShape.toFront();
1108
+ }
1109
+ };
1110
+
1111
+ movablePoint.remove = function () {
1112
+ if (this.visibleShape) {
1113
+ this.visibleShape.remove();
1114
+ }
1115
+ if (this.mouseTarget) {
1116
+ this.mouseTarget.remove();
1117
+ }
1118
+ if (this.labeledVertex) {
1119
+ this.labeledVertex.remove();
1120
+ }
1121
+ };
1122
+
1123
+ return movablePoint;
1124
+ },
1125
+
1126
+ // MovableLineSegment is a line segment that can be dragged around the
1127
+ // screen. By attaching a smartPoint to each (or one) end, the ends can be
1128
+ // manipulated individually.
1129
+ //
1130
+ // To use with smartPoints, add the smartPoints first, then:
1131
+ // addMovableLineSegment({ pointA: smartPoint1, pointZ: smartPoint2 });
1132
+ // Or just one end:
1133
+ // addMovableLineSegment({ pointA: smartPoint, coordZ: [0, 0] });
1134
+ //
1135
+ // Include "fixed: true" in the options if you don't want the entire line
1136
+ // to be draggable (you can still use points to make the endpoints
1137
+ // draggable)
1138
+ //
1139
+ // The returned object includes the following properties/methods:
1140
+ //
1141
+ // - lineSegment.coordA / lineSegment.coordZ
1142
+ // The coordinates of each end of the line segment
1143
+ //
1144
+ // - lineSegment.transform(syncToPoints)
1145
+ // Repositions the line segment. Call after changing coordA
1146
+ // and/or coordZ, or pass syncToPoints = true to use the current
1147
+ // position of the corresponding smartPoints, if the segment was
1148
+ // defined using smartPoints
1149
+ //
1150
+ addMovableLineSegment: function (options: $FlowFixMe) {
1151
+ const lineSegment = $.extend(
1152
+ {
1153
+ graph: this,
1154
+ coordA: [0, 0],
1155
+ coordZ: [1, 1],
1156
+ snapX: 0,
1157
+ snapY: 0,
1158
+ fixed: false,
1159
+ ticks: 0,
1160
+ normalStyle: {},
1161
+ highlightStyle: {
1162
+ stroke: KhanColors.INTERACTING,
1163
+ "stroke-width": 6,
1164
+ },
1165
+ labelStyle: {
1166
+ stroke: KhanColors.INTERACTIVE,
1167
+ color: KhanColors.INTERACTIVE,
1168
+ },
1169
+ highlight: false,
1170
+ dragging: false,
1171
+ tick: [],
1172
+ extendLine: false,
1173
+ extendRay: false,
1174
+ constraints: {
1175
+ fixed: false,
1176
+ constrainX: false,
1177
+ constrainY: false,
1178
+ },
1179
+ sideLabel: "",
1180
+ vertexLabels: [],
1181
+ numArrows: 0,
1182
+ numTicks: 0,
1183
+ movePointsWithLine: false,
1184
+ },
1185
+ options,
1186
+ );
1187
+
1188
+ const normalColor = lineSegment.fixed
1189
+ ? KhanColors.DYNAMIC
1190
+ : KhanColors.INTERACTIVE;
1191
+ lineSegment.normalStyle = _.extend(
1192
+ {},
1193
+ {
1194
+ "stroke-width": 2,
1195
+ stroke: normalColor,
1196
+ },
1197
+ options.normalStyle,
1198
+ );
1199
+ // arrowStyle should be kept in sync with styling of the line
1200
+ lineSegment.arrowStyle = _.extend({}, lineSegment.normalStyle, {
1201
+ color: lineSegment.normalStyle.stroke,
1202
+ });
1203
+
1204
+ // If the line segment is defined by movablePoints, coordA/coordZ are
1205
+ // owned by the points, otherwise they're owned by us
1206
+ if (options.pointA !== undefined) {
1207
+ lineSegment.coordA = options.pointA.coord;
1208
+ lineSegment.pointA.lineStarts.push(lineSegment);
1209
+ } else if (options.coordA !== undefined) {
1210
+ lineSegment.coordA = options.coordA.slice();
1211
+ }
1212
+
1213
+ if (options.pointZ !== undefined) {
1214
+ lineSegment.coordZ = options.pointZ.coord;
1215
+ lineSegment.pointZ.lineEnds.push(lineSegment);
1216
+ } else if (options.coordA !== undefined) {
1217
+ lineSegment.coordA = lineSegment.coordA.slice();
1218
+ }
1219
+
1220
+ const graph = lineSegment.graph;
1221
+
1222
+ graph.style(lineSegment.normalStyle);
1223
+ for (let i = 0; i < lineSegment.ticks; ++i) {
1224
+ lineSegment.tick[i] = InteractiveUtils.bogusShape;
1225
+ }
1226
+ let path = GraphUtils.unscaledSvgPath([
1227
+ [0, 0],
1228
+ [1, 0],
1229
+ ]);
1230
+ for (let i = 0; i < lineSegment.ticks; ++i) {
1231
+ const tickoffset =
1232
+ 0.5 - (lineSegment.ticks - 1 + i * 2) / graph.scale[0];
1233
+ // TODO(kevinb) figure out why path isn't being used
1234
+ // eslint-disable-next-line no-unused-vars
1235
+ path += GraphUtils.unscaledSvgPath([
1236
+ [tickoffset, -7],
1237
+ [tickoffset, 7],
1238
+ ]);
1239
+ }
1240
+
1241
+ options = {
1242
+ thickness: Math.max(
1243
+ lineSegment.normalStyle["stroke-width"],
1244
+ lineSegment.highlightStyle["stroke-width"],
1245
+ ),
1246
+ };
1247
+ // $FlowFixMe[invalid-constructor]
1248
+ lineSegment.visibleLine = new WrappedLine(
1249
+ graph,
1250
+ [0, 0],
1251
+ [1, 0],
1252
+ options,
1253
+ );
1254
+ lineSegment.visibleLine.attr(lineSegment.normalStyle);
1255
+
1256
+ // Add mouse target
1257
+ if (!lineSegment.fixed) {
1258
+ const options = {
1259
+ thickness: 30,
1260
+ mouselayer: true,
1261
+ interactiveKindForTesting: "movable-line",
1262
+ };
1263
+ // $FlowFixMe[invalid-constructor]
1264
+ lineSegment.mouseTarget = new WrappedLine(
1265
+ graph,
1266
+ [0, 0],
1267
+ [1, 0],
1268
+ options,
1269
+ );
1270
+ lineSegment.mouseTarget.attr({fill: "#000", opacity: 0.0});
1271
+ }
1272
+
1273
+ // Reposition the line segment. Call after changing coordA and/or
1274
+ // coordZ, or pass syncToPoints = true to use the current position of
1275
+ // the corresponding movablePoints, if the segment was defined using
1276
+ // movablePoints
1277
+ lineSegment.transform = function (syncToPoints) {
1278
+ if (syncToPoints) {
1279
+ if (typeof this.pointA === "object") {
1280
+ this.coordA = this.pointA.coord;
1281
+ }
1282
+ if (typeof this.pointZ === "object") {
1283
+ this.coordZ = this.pointZ.coord;
1284
+ }
1285
+ }
1286
+
1287
+ const getScaledAngle = function (line) {
1288
+ const scaledA = line.graph.scalePoint(line.coordA);
1289
+ const scaledZ = line.graph.scalePoint(line.coordZ);
1290
+ return kvector.polarDegFromCart(
1291
+ kvector.subtract(scaledZ, scaledA),
1292
+ )[1];
1293
+ };
1294
+
1295
+ const getClipPoint = function (graph, coord, angle) {
1296
+ graph = lineSegment.graph;
1297
+ const xExtent = graph.range[0][1] - graph.range[0][0];
1298
+ const yExtent = graph.range[1][1] - graph.range[1][0];
1299
+
1300
+ const distance = xExtent + yExtent;
1301
+ const angleVec = graph.unscaleVector(
1302
+ kvector.cartFromPolarDeg([1, angle]),
1303
+ );
1304
+ const distVec = kvector.scale(
1305
+ kvector.normalize(angleVec),
1306
+ distance,
1307
+ );
1308
+ const farCoord = kvector.add(coord, distVec);
1309
+ const scaledAngle = kvector.polarDegFromCart(angleVec)[1];
1310
+ const clipPoint = graph.constrainToBoundsOnAngle(
1311
+ farCoord,
1312
+ 4,
1313
+ (scaledAngle * Math.PI) / 180,
1314
+ );
1315
+ return clipPoint;
1316
+ };
1317
+
1318
+ const angle = getScaledAngle(this);
1319
+ let start = this.coordA;
1320
+ let end = this.coordZ;
1321
+
1322
+ // Extend start, end if necessary (i.e., if not a line segment)
1323
+ if (this.extendLine) {
1324
+ start = getClipPoint(graph, start, 360 - angle);
1325
+ end = getClipPoint(graph, end, (540 - angle) % 360);
1326
+ } else if (this.extendRay) {
1327
+ end = getClipPoint(graph, start, 360 - angle);
1328
+ }
1329
+
1330
+ const elements = [this.visibleLine];
1331
+ if (!this.fixed) {
1332
+ elements.push(this.mouseTarget);
1333
+ }
1334
+ _.each(elements, function (element) {
1335
+ element.moveTo(start, end);
1336
+ });
1337
+
1338
+ const createArrow = function (graph, style) {
1339
+ const center = [0.75, 0];
1340
+ let points = [
1341
+ [-3, 4],
1342
+ [-2.75, 2.5],
1343
+ [0, 0.25],
1344
+ center,
1345
+ [0, -0.25],
1346
+ [-2.75, -2.5],
1347
+ [-3, -4],
1348
+ ];
1349
+
1350
+ const scale = 1.4;
1351
+ points = _.map(points, function (point) {
1352
+ const pv = kvector.subtract(point, center);
1353
+ const pvScaled = kvector.scale(pv, scale);
1354
+ return kvector.add(center, pvScaled);
1355
+ });
1356
+
1357
+ const createCubicPath = function (points) {
1358
+ let path = "M" + points[0][0] + " " + points[0][1];
1359
+ for (let i = 1; i < points.length; i += 3) {
1360
+ path +=
1361
+ "C" +
1362
+ points[i][0] +
1363
+ " " +
1364
+ points[i][1] +
1365
+ " " +
1366
+ points[i + 1][0] +
1367
+ " " +
1368
+ points[i + 1][1] +
1369
+ " " +
1370
+ points[i + 2][0] +
1371
+ " " +
1372
+ points[i + 2][1];
1373
+ }
1374
+ return path;
1375
+ };
1376
+
1377
+ const unscaledPoints = _.map(points, graph.unscalePoint);
1378
+ const options = {
1379
+ center: graph.unscalePoint(center),
1380
+ createPath: createCubicPath,
1381
+ };
1382
+ // $FlowFixMe[invalid-constructor]
1383
+ const arrowHead = new WrappedPath(
1384
+ graph,
1385
+ unscaledPoints,
1386
+ options,
1387
+ );
1388
+ arrowHead.attr(
1389
+ _.extend(
1390
+ {
1391
+ "stroke-linejoin": "round",
1392
+ "stroke-linecap": "round",
1393
+ "stroke-dasharray": "",
1394
+ },
1395
+ style,
1396
+ ),
1397
+ );
1398
+
1399
+ // Add custom function for transforming arrowheads that
1400
+ // accounts for center, scaling, etc.
1401
+ // $FlowFixMe[prop-missing]
1402
+ arrowHead.toCoordAtAngle = function (
1403
+ coord: Coord,
1404
+ angle: number,
1405
+ ): void {
1406
+ const clipPoint = graph.scalePoint(
1407
+ getClipPoint(graph, coord, angle),
1408
+ );
1409
+ const do3dTransform = getCanUse3dTransform();
1410
+ arrowHead.transform(
1411
+ "translateX(" +
1412
+ (clipPoint[0] + scale * center[0]) +
1413
+ "px) " +
1414
+ "translateY(" +
1415
+ (clipPoint[1] + scale * center[1]) +
1416
+ "px) " +
1417
+ (do3dTransform ? "translateZ(0) " : "") +
1418
+ "rotate(" +
1419
+ (360 - KhanMath.bound(angle)) +
1420
+ "deg)",
1421
+ );
1422
+ };
1423
+
1424
+ return arrowHead;
1425
+ };
1426
+
1427
+ // Add arrows
1428
+ if (this._arrows == null) {
1429
+ this._arrows = [];
1430
+
1431
+ if (this.extendLine) {
1432
+ this._arrows.push(createArrow(graph, this.normalStyle));
1433
+ this._arrows.push(createArrow(graph, this.normalStyle));
1434
+ } else if (this.extendRay) {
1435
+ this._arrows.push(createArrow(graph, this.normalStyle));
1436
+ }
1437
+ }
1438
+
1439
+ const coordForArrow = [this.coordA, this.coordZ];
1440
+ const angleForArrow = [360 - angle, (540 - angle) % 360];
1441
+ _.each(this._arrows, function (arrow, i) {
1442
+ arrow.toCoordAtAngle(coordForArrow[i], angleForArrow[i]);
1443
+ });
1444
+
1445
+ // Temporary objects: array of SVG nodes that get recreated on drag
1446
+ _.invoke(this.temp, "remove");
1447
+ this.temp = [];
1448
+
1449
+ const isClockwise =
1450
+ this.coordA[0] < this.coordZ[0] ||
1451
+ (this.coordA[0] === this.coordZ[0] &&
1452
+ this.coordA[1] > this.coordZ[1]);
1453
+
1454
+ // Update side label
1455
+ if (this.sideLabel) {
1456
+ this.temp.push(
1457
+ this.graph.labelSide({
1458
+ point1: this.coordA,
1459
+ point2: this.coordZ,
1460
+ label: this.labeledSide,
1461
+ text: this.sideLabel,
1462
+ numArrows: this.numArrows,
1463
+ numTicks: this.numTicks,
1464
+ clockwise: isClockwise,
1465
+ style: this.labelStyle,
1466
+ }),
1467
+ );
1468
+ }
1469
+
1470
+ // Update vertex labels
1471
+ if (this.vertexLabels.length) {
1472
+ this.graph.labelVertex({
1473
+ vertex: this.coordA,
1474
+ point3: this.coordZ,
1475
+ label: this.labeledVertices[0],
1476
+ text: this.vertexLabels[0],
1477
+ clockwise: isClockwise,
1478
+ style: this.labelStyle,
1479
+ });
1480
+
1481
+ this.graph.labelVertex({
1482
+ point1: this.coordA,
1483
+ vertex: this.coordZ,
1484
+ label: this.labeledVertices[1],
1485
+ text: this.vertexLabels[1],
1486
+ clockwise: isClockwise,
1487
+ style: this.labelStyle,
1488
+ });
1489
+ }
1490
+
1491
+ this.temp = _.flatten(this.temp);
1492
+ };
1493
+
1494
+ // Change z-order to back;
1495
+ lineSegment.toBack = function () {
1496
+ if (!lineSegment.fixed) {
1497
+ lineSegment.mouseTarget.toBack();
1498
+ }
1499
+ lineSegment.visibleLine.toBack();
1500
+ };
1501
+
1502
+ // Change z-order to front
1503
+ lineSegment.toFront = function () {
1504
+ if (!lineSegment.fixed) {
1505
+ lineSegment.mouseTarget.toFront();
1506
+ }
1507
+ lineSegment.visibleLine.toFront();
1508
+ };
1509
+
1510
+ lineSegment.remove = function () {
1511
+ if (!lineSegment.fixed) {
1512
+ lineSegment.mouseTarget.remove();
1513
+ }
1514
+ lineSegment.visibleLine.remove();
1515
+ if (lineSegment.labeledSide) {
1516
+ lineSegment.labeledSide.remove();
1517
+ }
1518
+ if (lineSegment.labeledVertices) {
1519
+ _.invoke(lineSegment.labeledVertices, "remove");
1520
+ }
1521
+ if (lineSegment._arrows) {
1522
+ _.invoke(lineSegment._arrows, "remove");
1523
+ }
1524
+ if (lineSegment.temp.length) {
1525
+ _.invoke(lineSegment.temp, "remove");
1526
+ }
1527
+ };
1528
+
1529
+ lineSegment.hide = function () {
1530
+ lineSegment.visibleLine.hide();
1531
+ if (lineSegment.temp.length) {
1532
+ _.invoke(lineSegment.temp, "hide");
1533
+ }
1534
+ if (lineSegment._arrows) {
1535
+ _.invoke(lineSegment._arrows, "hide");
1536
+ }
1537
+ };
1538
+
1539
+ lineSegment.show = function () {
1540
+ lineSegment.visibleLine.show();
1541
+ if (lineSegment.temp.length) {
1542
+ _.invoke(lineSegment.temp, "show");
1543
+ }
1544
+ if (lineSegment._arrows) {
1545
+ _.invoke(lineSegment._arrows, "show");
1546
+ }
1547
+ };
1548
+
1549
+ if (lineSegment.sideLabel) {
1550
+ lineSegment.labeledSide = this.label(
1551
+ [0, 0],
1552
+ "",
1553
+ "center",
1554
+ lineSegment.labelStyle,
1555
+ );
1556
+ }
1557
+
1558
+ if (lineSegment.vertexLabels.length) {
1559
+ lineSegment.labeledVertices = _.map(
1560
+ lineSegment.vertexLabels,
1561
+ function (label) {
1562
+ return this.label(
1563
+ [0, 0],
1564
+ "",
1565
+ "center",
1566
+ lineSegment.labelStyle,
1567
+ );
1568
+ },
1569
+ this,
1570
+ );
1571
+ }
1572
+
1573
+ if (!lineSegment.fixed && !lineSegment.constraints.fixed) {
1574
+ const $mouseTarget = $(lineSegment.mouseTarget.getMouseTarget());
1575
+ $mouseTarget.css("cursor", "move");
1576
+ $mouseTarget.bind(
1577
+ "vmousedown vmouseover vmouseout",
1578
+ function (event) {
1579
+ if (event.type === "vmouseover") {
1580
+ if (!dragging) {
1581
+ lineSegment.highlight = true;
1582
+ lineSegment.visibleLine.animate(
1583
+ lineSegment.highlightStyle,
1584
+ 50,
1585
+ );
1586
+ lineSegment.arrowStyle = _.extend(
1587
+ {},
1588
+ lineSegment.arrowStyle,
1589
+ {
1590
+ color: lineSegment.highlightStyle.stroke,
1591
+ stroke: lineSegment.highlightStyle.stroke,
1592
+ },
1593
+ );
1594
+ lineSegment.transform();
1595
+ }
1596
+ } else if (event.type === "vmouseout") {
1597
+ lineSegment.highlight = false;
1598
+ if (!lineSegment.dragging) {
1599
+ lineSegment.visibleLine.animate(
1600
+ lineSegment.normalStyle,
1601
+ 50,
1602
+ );
1603
+ lineSegment.arrowStyle = _.extend(
1604
+ {},
1605
+ lineSegment.arrowStyle,
1606
+ {
1607
+ color: lineSegment.normalStyle.stroke,
1608
+ stroke: lineSegment.normalStyle.stroke,
1609
+ },
1610
+ );
1611
+ lineSegment.transform();
1612
+ }
1613
+ } else if (
1614
+ event.type === "vmousedown" &&
1615
+ (event.which === 1 || event.which === 0)
1616
+ ) {
1617
+ event.preventDefault();
1618
+ let coordX =
1619
+ (event.pageX -
1620
+ $(graph.raphael.canvas.parentNode).offset()
1621
+ .left) /
1622
+ graph.scale[0] +
1623
+ graph.range[0][0];
1624
+ let coordY =
1625
+ graph.range[1][1] -
1626
+ (event.pageY -
1627
+ $(graph.raphael.canvas.parentNode).offset()
1628
+ .top) /
1629
+ graph.scale[1];
1630
+ if (lineSegment.snapX > 0) {
1631
+ coordX =
1632
+ Math.round(coordX / lineSegment.snapX) *
1633
+ lineSegment.snapX;
1634
+ }
1635
+ if (lineSegment.snapY > 0) {
1636
+ coordY =
1637
+ Math.round(coordY / lineSegment.snapY) *
1638
+ lineSegment.snapY;
1639
+ }
1640
+ const mouseOffsetA = [
1641
+ lineSegment.coordA[0] - coordX,
1642
+ lineSegment.coordA[1] - coordY,
1643
+ ];
1644
+ const mouseOffsetZ = [
1645
+ lineSegment.coordZ[0] - coordX,
1646
+ lineSegment.coordZ[1] - coordY,
1647
+ ];
1648
+
1649
+ const offsetLeft = -Math.min(
1650
+ graph.scaleVector(mouseOffsetA)[0],
1651
+ graph.scaleVector(mouseOffsetZ)[0],
1652
+ );
1653
+ const offsetRight = Math.max(
1654
+ graph.scaleVector(mouseOffsetA)[0],
1655
+ graph.scaleVector(mouseOffsetZ)[0],
1656
+ );
1657
+ const offsetTop = Math.max(
1658
+ graph.scaleVector(mouseOffsetA)[1],
1659
+ graph.scaleVector(mouseOffsetZ)[1],
1660
+ );
1661
+ const offsetBottom = -Math.min(
1662
+ graph.scaleVector(mouseOffsetA)[1],
1663
+ graph.scaleVector(mouseOffsetZ)[1],
1664
+ );
1665
+
1666
+ $(document).bind(
1667
+ "vmousemove.lineSegment vmouseup.lineSegment",
1668
+ function (event) {
1669
+ event.preventDefault();
1670
+ lineSegment.dragging = true;
1671
+ dragging = true;
1672
+
1673
+ let mouseX =
1674
+ event.pageX -
1675
+ $(graph.raphael.canvas.parentNode).offset()
1676
+ .left;
1677
+ let mouseY =
1678
+ event.pageY -
1679
+ $(graph.raphael.canvas.parentNode).offset()
1680
+ .top;
1681
+ // no part of the line segment can go beyond 10
1682
+ // pixels from the edge
1683
+ mouseX = Math.max(
1684
+ offsetLeft + 10,
1685
+ Math.min(
1686
+ graph.xpixels - 10 - offsetRight,
1687
+ mouseX,
1688
+ ),
1689
+ );
1690
+ mouseY = Math.max(
1691
+ offsetTop + 10,
1692
+ Math.min(
1693
+ graph.ypixels - 10 - offsetBottom,
1694
+ mouseY,
1695
+ ),
1696
+ );
1697
+
1698
+ let coordX =
1699
+ mouseX / graph.scale[0] + graph.range[0][0];
1700
+ let coordY =
1701
+ graph.range[1][1] - mouseY / graph.scale[1];
1702
+ if (lineSegment.snapX > 0) {
1703
+ coordX =
1704
+ Math.round(coordX / lineSegment.snapX) *
1705
+ lineSegment.snapX;
1706
+ }
1707
+ if (lineSegment.snapY > 0) {
1708
+ coordY =
1709
+ Math.round(coordY / lineSegment.snapY) *
1710
+ lineSegment.snapY;
1711
+ }
1712
+
1713
+ if (event.type === "vmousemove") {
1714
+ if (lineSegment.constraints.constrainX) {
1715
+ coordX =
1716
+ lineSegment.coordA[0] -
1717
+ mouseOffsetA[0];
1718
+ }
1719
+ if (lineSegment.constraints.constrainY) {
1720
+ coordY =
1721
+ lineSegment.coordA[1] -
1722
+ mouseOffsetA[1];
1723
+ }
1724
+ const dX =
1725
+ coordX +
1726
+ mouseOffsetA[0] -
1727
+ lineSegment.coordA[0];
1728
+ const dY =
1729
+ coordY +
1730
+ mouseOffsetA[1] -
1731
+ lineSegment.coordA[1];
1732
+ lineSegment.coordA = [
1733
+ coordX + mouseOffsetA[0],
1734
+ coordY + mouseOffsetA[1],
1735
+ ];
1736
+ lineSegment.coordZ = [
1737
+ coordX + mouseOffsetZ[0],
1738
+ coordY + mouseOffsetZ[1],
1739
+ ];
1740
+ lineSegment.transform();
1741
+
1742
+ if (lineSegment.movePointsWithLine) {
1743
+ // If the points are movablePoints, adjust
1744
+ // their coordinates when the line itself is
1745
+ // dragged
1746
+ if (
1747
+ typeof lineSegment.pointA ===
1748
+ "object"
1749
+ ) {
1750
+ lineSegment.pointA.setCoord([
1751
+ lineSegment.pointA.coord[0] +
1752
+ dX,
1753
+ lineSegment.pointA.coord[1] +
1754
+ dY,
1755
+ ]);
1756
+ }
1757
+ if (
1758
+ typeof lineSegment.pointZ ===
1759
+ "object"
1760
+ ) {
1761
+ lineSegment.pointZ.setCoord([
1762
+ lineSegment.pointZ.coord[0] +
1763
+ dX,
1764
+ lineSegment.pointZ.coord[1] +
1765
+ dY,
1766
+ ]);
1767
+ }
1768
+ }
1769
+
1770
+ if (_.isFunction(lineSegment.onMove)) {
1771
+ lineSegment.onMove(dX, dY);
1772
+ }
1773
+ } else if (event.type === "vmouseup") {
1774
+ $(document).unbind(".lineSegment");
1775
+ lineSegment.dragging = false;
1776
+ dragging = false;
1777
+ if (!lineSegment.highlight) {
1778
+ lineSegment.visibleLine.animate(
1779
+ lineSegment.normalStyle,
1780
+ 50,
1781
+ );
1782
+ lineSegment.arrowStyle = _.extend(
1783
+ {},
1784
+ lineSegment.arrowStyle,
1785
+ {
1786
+ color: lineSegment.normalStyle
1787
+ .stroke,
1788
+ stroke: lineSegment.normalStyle
1789
+ .stroke,
1790
+ },
1791
+ );
1792
+ lineSegment.transform();
1793
+ }
1794
+ if (_.isFunction(lineSegment.onMoveEnd)) {
1795
+ lineSegment.onMoveEnd();
1796
+ }
1797
+ }
1798
+
1799
+ $(lineSegment).trigger("move");
1800
+ },
1801
+ );
1802
+ }
1803
+ },
1804
+ );
1805
+ }
1806
+
1807
+ if (lineSegment.pointA !== undefined) {
1808
+ lineSegment.pointA.toFront();
1809
+ }
1810
+ if (lineSegment.pointZ !== undefined) {
1811
+ lineSegment.pointZ.toFront();
1812
+ }
1813
+ lineSegment.transform();
1814
+ return lineSegment;
1815
+ },
1816
+
1817
+ // MovablePolygon is a polygon that can be dragged around the screen.
1818
+ // By attaching a smartPoint to each vertex, the points can be
1819
+ // manipulated individually.
1820
+ //
1821
+ // To use with smartPoints, add the smartPoints first, then:
1822
+ // addMovablePolygon({points: [...]});
1823
+ //
1824
+ // Include "fixed: true" in the options if you don't want the entire
1825
+ // polygon to be draggable (you can still use points to make the
1826
+ // vertices draggable)
1827
+ //
1828
+ // The returned object includes the following properties/methods:
1829
+ //
1830
+ // - polygon.points
1831
+ // The polygon's dynamic smartPoints and static coordinates, mixed.
1832
+ //
1833
+ // - polygon.coords
1834
+ // The polygon's current coordinates (generated, don't edit).
1835
+ //
1836
+ // - polygon.transform()
1837
+ // Repositions the polygon. Call after changing any points.
1838
+ //
1839
+ addMovablePolygon: function (options) {
1840
+ const graphie = this;
1841
+
1842
+ const polygon = $.extend(
1843
+ {
1844
+ snapX: 0,
1845
+ snapY: 0,
1846
+ fixed: false,
1847
+ constrainToGraph: true,
1848
+ normalStyle: {},
1849
+ highlightStyle: {
1850
+ stroke: KhanColors.INTERACTING,
1851
+ "stroke-width": 2,
1852
+ fill: KhanColors.INTERACTING,
1853
+ "fill-opacity": 0.05,
1854
+ },
1855
+ pointHighlightStyle: {
1856
+ fill: KhanColors.INTERACTING,
1857
+ stroke: KhanColors.INTERACTING,
1858
+ },
1859
+ labelStyle: {
1860
+ stroke: KhanColors.DYNAMIC,
1861
+ "stroke-width": 1,
1862
+ color: KhanColors.DYNAMIC,
1863
+ },
1864
+ angleLabels: [],
1865
+ showRightAngleMarkers: [],
1866
+ sideLabels: [],
1867
+ vertexLabels: [],
1868
+ numArcs: [],
1869
+ numArrows: [],
1870
+ numTicks: [],
1871
+ updateOnPointMove: true,
1872
+ closed: true,
1873
+ },
1874
+ _.omit(options, "points"),
1875
+ );
1876
+
1877
+ const normalColor = polygon.fixed
1878
+ ? KhanColors.DYNAMIC
1879
+ : KhanColors.INTERACTIVE;
1880
+ polygon.normalStyle = _.extend(
1881
+ {
1882
+ "stroke-width": 2,
1883
+ "fill-opacity": 0,
1884
+ fill: normalColor,
1885
+ stroke: normalColor,
1886
+ },
1887
+ options.normalStyle,
1888
+ );
1889
+
1890
+ // don't deep copy the points array with $.extend;
1891
+ // we may want to append to it later for click-to-add-points
1892
+ polygon.points = options.points;
1893
+
1894
+ const isPoint = function (coordOrPoint) {
1895
+ return !_.isArray(coordOrPoint);
1896
+ };
1897
+
1898
+ polygon.update = function () {
1899
+ const n = polygon.points.length;
1900
+
1901
+ // Update coords
1902
+ polygon.coords = _.map(polygon.points, function (coordOrPoint, i) {
1903
+ if (isPoint(coordOrPoint)) {
1904
+ return coordOrPoint.coord;
1905
+ }
1906
+ return coordOrPoint;
1907
+ });
1908
+
1909
+ // Calculate bounding box
1910
+ polygon.left = _.min(_.pluck(polygon.coords, 0));
1911
+ polygon.right = _.max(_.pluck(polygon.coords, 0));
1912
+ polygon.top = _.max(_.pluck(polygon.coords, 1));
1913
+ polygon.bottom = _.min(_.pluck(polygon.coords, 1));
1914
+
1915
+ let scaledCoords = _.map(polygon.coords, function (coord) {
1916
+ return graphie.scalePoint(coord);
1917
+ });
1918
+
1919
+ // Create path
1920
+ if (polygon.closed) {
1921
+ scaledCoords.push(true);
1922
+ } else {
1923
+ // For open polygons, concatenate a reverse of the path,
1924
+ // to remove the inside area of the path, which would
1925
+ // otherwise be clickable (even if the closing line segment
1926
+ // wasn't drawn
1927
+ scaledCoords = scaledCoords.concat(
1928
+ _.clone(scaledCoords).reverse(),
1929
+ );
1930
+ }
1931
+ polygon.path = GraphUtils.unscaledSvgPath(scaledCoords);
1932
+
1933
+ // Temporary objects
1934
+ _.invoke(polygon.temp, "remove");
1935
+ polygon.temp = [];
1936
+
1937
+ const isClockwise = clockwise(polygon.coords);
1938
+
1939
+ // Update angle labels
1940
+ if (
1941
+ polygon.angleLabels.length ||
1942
+ polygon.showRightAngleMarkers.length
1943
+ ) {
1944
+ _.each(polygon.labeledAngles, function (label, i) {
1945
+ polygon.temp.push(
1946
+ graphie.labelAngle({
1947
+ point1: polygon.coords[(i - 1 + n) % n],
1948
+ vertex: polygon.coords[i],
1949
+ point3: polygon.coords[(i + 1) % n],
1950
+ label: label,
1951
+ text: polygon.angleLabels[i],
1952
+ showRightAngleMarker:
1953
+ polygon.showRightAngleMarkers[i],
1954
+ numArcs: polygon.numArcs[i],
1955
+ clockwise: isClockwise,
1956
+ style: polygon.labelStyle,
1957
+ }),
1958
+ );
1959
+ });
1960
+ }
1961
+
1962
+ // Update side labels
1963
+ if (polygon.sideLabels.length) {
1964
+ _.each(polygon.labeledSides, function (label, i) {
1965
+ polygon.temp.push(
1966
+ graphie.labelSide({
1967
+ point1: polygon.coords[i],
1968
+ point2: polygon.coords[(i + 1) % n],
1969
+ label: label,
1970
+ text: polygon.sideLabels[i],
1971
+ numArrows: polygon.numArrows[i],
1972
+ numTicks: polygon.numTicks[i],
1973
+ clockwise: isClockwise,
1974
+ style: polygon.labelStyle,
1975
+ }),
1976
+ );
1977
+ });
1978
+ }
1979
+
1980
+ // Update vertex labels
1981
+ if (polygon.vertexLabels.length) {
1982
+ _.each(polygon.labeledVertices, function (label, i) {
1983
+ graphie.labelVertex({
1984
+ point1: polygon.coords[(i - 1 + n) % n],
1985
+ vertex: polygon.coords[i],
1986
+ point3: polygon.coords[(i + 1) % n],
1987
+ label: label,
1988
+ text: polygon.vertexLabels[i],
1989
+ clockwise: isClockwise,
1990
+ style: polygon.labelStyle,
1991
+ });
1992
+ });
1993
+ }
1994
+
1995
+ polygon.temp = _.flatten(polygon.temp);
1996
+ };
1997
+
1998
+ polygon.transform = function () {
1999
+ polygon.update();
2000
+
2001
+ polygon.visibleShape.attr({path: polygon.path});
2002
+
2003
+ if (!polygon.fixed) {
2004
+ polygon.mouseTarget.attr({path: polygon.path});
2005
+ }
2006
+ };
2007
+
2008
+ polygon.remove = function () {
2009
+ polygon.visibleShape.remove();
2010
+
2011
+ if (!polygon.fixed) {
2012
+ polygon.mouseTarget.remove();
2013
+ }
2014
+
2015
+ if (polygon.labeledAngles) {
2016
+ _.invoke(polygon.labeledAngles, "remove");
2017
+ }
2018
+
2019
+ if (polygon.labeledSides) {
2020
+ _.invoke(polygon.labeledSides, "remove");
2021
+ }
2022
+
2023
+ if (polygon.labeledVertices) {
2024
+ _.invoke(polygon.labeledVertices, "remove");
2025
+ }
2026
+
2027
+ if (polygon.temp.length) {
2028
+ _.invoke(polygon.temp, "remove");
2029
+ }
2030
+ };
2031
+
2032
+ polygon.toBack = function () {
2033
+ if (!polygon.fixed) {
2034
+ polygon.mouseTarget.toBack();
2035
+ }
2036
+
2037
+ polygon.visibleShape.toBack();
2038
+ };
2039
+
2040
+ polygon.toFront = function () {
2041
+ if (!polygon.fixed) {
2042
+ polygon.mouseTarget.toFront();
2043
+ }
2044
+
2045
+ polygon.visibleShape.toFront();
2046
+ };
2047
+
2048
+ // Setup
2049
+
2050
+ if (polygon.updateOnPointMove) {
2051
+ _.each(_.filter(polygon.points, isPoint), function (coordOrPoint) {
2052
+ coordOrPoint.polygonVertices.push(polygon);
2053
+ });
2054
+ }
2055
+
2056
+ polygon.coords = new Array(polygon.points.length);
2057
+
2058
+ if (polygon.angleLabels.length) {
2059
+ const numLabels = Math.max(
2060
+ polygon.angleLabels.length,
2061
+ polygon.showRightAngleMarkers.length,
2062
+ );
2063
+ polygon.labeledAngles = _.times(
2064
+ numLabels,
2065
+ function () {
2066
+ return this.label([0, 0], "", "center", polygon.labelStyle);
2067
+ },
2068
+ this,
2069
+ );
2070
+ }
2071
+
2072
+ if (polygon.sideLabels.length) {
2073
+ polygon.labeledSides = _.map(
2074
+ polygon.sideLabels,
2075
+ function (label) {
2076
+ return this.label([0, 0], "", "center", polygon.labelStyle);
2077
+ },
2078
+ this,
2079
+ );
2080
+ }
2081
+
2082
+ if (polygon.vertexLabels.length) {
2083
+ polygon.labeledVertices = _.map(
2084
+ polygon.vertexLabels,
2085
+ function (label) {
2086
+ return this.label([0, 0], "", "center", polygon.labelStyle);
2087
+ },
2088
+ this,
2089
+ );
2090
+ }
2091
+
2092
+ polygon.update();
2093
+
2094
+ polygon.visibleShape = graphie.raphael.path(polygon.path);
2095
+ polygon.visibleShape.attr(polygon.normalStyle);
2096
+
2097
+ if (!polygon.fixed) {
2098
+ polygon.mouseTarget = graphie.mouselayer.path(polygon.path);
2099
+ polygon.mouseTarget.attr({
2100
+ fill: "#000",
2101
+ opacity: 0,
2102
+ cursor: "move",
2103
+ });
2104
+
2105
+ $(polygon.mouseTarget[0]).bind(
2106
+ "vmousedown vmouseover vmouseout",
2107
+ function (event) {
2108
+ if (event.type === "vmouseover") {
2109
+ if (!dragging || polygon.dragging) {
2110
+ polygon.highlight = true;
2111
+ polygon.visibleShape.animate(
2112
+ polygon.highlightStyle,
2113
+ 50,
2114
+ );
2115
+ _.each(
2116
+ _.filter(polygon.points, isPoint),
2117
+ function (point) {
2118
+ point.visibleShape.animate(
2119
+ polygon.pointHighlightStyle,
2120
+ 50,
2121
+ );
2122
+ },
2123
+ );
2124
+ }
2125
+ } else if (event.type === "vmouseout") {
2126
+ polygon.highlight = false;
2127
+ if (!polygon.dragging) {
2128
+ polygon.visibleShape.animate(
2129
+ polygon.normalStyle,
2130
+ 50,
2131
+ );
2132
+ const points = _.filter(polygon.points, isPoint);
2133
+ if (!_.any(_.pluck(points, "dragging"))) {
2134
+ _.each(points, function (point) {
2135
+ point.visibleShape.animate(
2136
+ point.normalStyle,
2137
+ 50,
2138
+ );
2139
+ });
2140
+ }
2141
+ }
2142
+ } else if (
2143
+ event.type === "vmousedown" &&
2144
+ (event.which === 1 || event.which === 0)
2145
+ ) {
2146
+ event.preventDefault();
2147
+
2148
+ _.each(
2149
+ _.filter(polygon.points, isPoint),
2150
+ function (point) {
2151
+ point.dragging = true;
2152
+ },
2153
+ );
2154
+
2155
+ let startX =
2156
+ (event.pageX -
2157
+ $(graphie.raphael.canvas.parentNode).offset()
2158
+ .left) /
2159
+ graphie.scale[0] +
2160
+ graphie.range[0][0];
2161
+ let startY =
2162
+ graphie.range[1][1] -
2163
+ (event.pageY -
2164
+ $(graphie.raphael.canvas.parentNode).offset()
2165
+ .top) /
2166
+ graphie.scale[1];
2167
+ if (polygon.snapX > 0) {
2168
+ startX =
2169
+ Math.round(startX / polygon.snapX) *
2170
+ polygon.snapX;
2171
+ }
2172
+ if (polygon.snapY > 0) {
2173
+ startY =
2174
+ Math.round(startY / polygon.snapY) *
2175
+ polygon.snapY;
2176
+ }
2177
+ let lastX = startX;
2178
+ let lastY = startY;
2179
+
2180
+ const polygonCoords = polygon.coords.slice();
2181
+
2182
+ const offsetLeft =
2183
+ (startX - polygon.left) * graphie.scale[0];
2184
+ const offsetRight =
2185
+ (polygon.right - startX) * graphie.scale[0];
2186
+ const offsetTop =
2187
+ (polygon.top - startY) * graphie.scale[1];
2188
+ const offsetBottom =
2189
+ (startY - polygon.bottom) * graphie.scale[1];
2190
+
2191
+ $(document).bind(
2192
+ "vmousemove.polygon vmouseup.polygon",
2193
+ function (event) {
2194
+ event.preventDefault();
2195
+
2196
+ polygon.dragging = true;
2197
+ dragging = true;
2198
+
2199
+ let mouseX =
2200
+ event.pageX -
2201
+ $(
2202
+ graphie.raphael.canvas.parentNode,
2203
+ ).offset().left;
2204
+ let mouseY =
2205
+ event.pageY -
2206
+ $(
2207
+ graphie.raphael.canvas.parentNode,
2208
+ ).offset().top;
2209
+
2210
+ // no part of the polygon can go beyond 10 pixels from
2211
+ // the edge
2212
+ if (polygon.constrainToGraph) {
2213
+ mouseX = Math.max(
2214
+ offsetLeft + 10,
2215
+ Math.min(
2216
+ graphie.xpixels - 10 - offsetRight,
2217
+ mouseX,
2218
+ ),
2219
+ );
2220
+ mouseY = Math.max(
2221
+ offsetTop + 10,
2222
+ Math.min(
2223
+ graphie.ypixels - 10 - offsetBottom,
2224
+ mouseY,
2225
+ ),
2226
+ );
2227
+ }
2228
+
2229
+ let currentX =
2230
+ mouseX / graphie.scale[0] +
2231
+ graphie.range[0][0];
2232
+ let currentY =
2233
+ graphie.range[1][1] -
2234
+ mouseY / graphie.scale[1];
2235
+ if (polygon.snapX > 0) {
2236
+ currentX =
2237
+ Math.round(currentX / polygon.snapX) *
2238
+ polygon.snapX;
2239
+ }
2240
+ if (polygon.snapY > 0) {
2241
+ currentY =
2242
+ Math.round(currentY / polygon.snapY) *
2243
+ polygon.snapY;
2244
+ }
2245
+
2246
+ if (event.type === "vmousemove") {
2247
+ let dX = currentX - startX;
2248
+ let dY = currentY - startY;
2249
+
2250
+ let doMove = true;
2251
+ if (_.isFunction(polygon.onMove)) {
2252
+ const onMoveResult = polygon.onMove(
2253
+ dX,
2254
+ dY,
2255
+ );
2256
+ if (onMoveResult === false) {
2257
+ doMove = false;
2258
+ } else if (_.isArray(onMoveResult)) {
2259
+ dX = onMoveResult[0];
2260
+ dY = onMoveResult[1];
2261
+ currentX = startX + dX;
2262
+ currentY = startY + dY;
2263
+ }
2264
+ }
2265
+
2266
+ const increment = function (i) {
2267
+ return [
2268
+ polygonCoords[i][0] + dX,
2269
+ polygonCoords[i][1] + dY,
2270
+ ];
2271
+ };
2272
+
2273
+ if (doMove) {
2274
+ _.each(
2275
+ polygon.points,
2276
+ function (coordOrPoint, i) {
2277
+ if (isPoint(coordOrPoint)) {
2278
+ coordOrPoint.setCoord(
2279
+ increment(i),
2280
+ );
2281
+ } else {
2282
+ polygon.points[i] =
2283
+ increment(i);
2284
+ }
2285
+ },
2286
+ );
2287
+
2288
+ polygon.transform();
2289
+
2290
+ $(polygon).trigger("move");
2291
+
2292
+ lastX = currentX;
2293
+ lastY = currentY;
2294
+ }
2295
+ } else if (event.type === "vmouseup") {
2296
+ $(document).unbind(".polygon");
2297
+
2298
+ const points = _.filter(
2299
+ polygon.points,
2300
+ isPoint,
2301
+ );
2302
+ _.each(points, function (point) {
2303
+ point.dragging = false;
2304
+ });
2305
+
2306
+ polygon.dragging = false;
2307
+ dragging = false;
2308
+ if (!polygon.highlight) {
2309
+ polygon.visibleShape.animate(
2310
+ polygon.normalStyle,
2311
+ 50,
2312
+ );
2313
+
2314
+ _.each(points, function (point) {
2315
+ point.visibleShape.animate(
2316
+ point.normalStyle,
2317
+ 50,
2318
+ );
2319
+ });
2320
+ }
2321
+ if (_.isFunction(polygon.onMoveEnd)) {
2322
+ polygon.onMoveEnd(
2323
+ lastX - startX,
2324
+ lastY - startY,
2325
+ );
2326
+ }
2327
+ }
2328
+ },
2329
+ );
2330
+ }
2331
+ },
2332
+ );
2333
+ }
2334
+
2335
+ // Bring any movable points to the front
2336
+ _.invoke(_.filter(polygon.points, isPoint), "toFront");
2337
+
2338
+ return polygon;
2339
+ },
2340
+
2341
+ /**
2342
+ * Constrain a point to be within the graph (including padding).
2343
+ * If outside graph, point's x and y coordinates are clamped within
2344
+ * the graph.
2345
+ */
2346
+ constrainToBounds: function (point, padding) {
2347
+ const lower = this.unscalePoint([padding, this.ypixels - padding]);
2348
+ const upper = this.unscalePoint([this.xpixels - padding, padding]);
2349
+ const coordX = Math.max(lower[0], Math.min(upper[0], point[0]));
2350
+ const coordY = Math.max(lower[1], Math.min(upper[1], point[1]));
2351
+ return [coordX, coordY];
2352
+ },
2353
+
2354
+ /**
2355
+ * Constrain a point to be within the graph (including padding).
2356
+ * If outside graph, point is moved along the ray specified by angle
2357
+ * until inside graph.
2358
+ */
2359
+ constrainToBoundsOnAngle: function (point, padding, angle) {
2360
+ const lower = this.unscalePoint([padding, this.ypixels - padding]);
2361
+ const upper = this.unscalePoint([this.xpixels - padding, padding]);
2362
+
2363
+ let result = point.slice();
2364
+
2365
+ if (result[0] < lower[0]) {
2366
+ result = [
2367
+ lower[0],
2368
+ result[1] + (lower[0] - result[0]) * Math.tan(angle),
2369
+ ];
2370
+ } else if (result[0] > upper[0]) {
2371
+ result = [
2372
+ upper[0],
2373
+ result[1] - (result[0] - upper[0]) * Math.tan(angle),
2374
+ ];
2375
+ }
2376
+
2377
+ if (result[1] < lower[1]) {
2378
+ result = [
2379
+ result[0] + (lower[1] - result[1]) / Math.tan(angle),
2380
+ lower[1],
2381
+ ];
2382
+ } else if (result[1] > upper[1]) {
2383
+ result = [
2384
+ result[0] - (result[1] - upper[1]) / Math.tan(angle),
2385
+ upper[1],
2386
+ ];
2387
+ }
2388
+
2389
+ return result;
2390
+ },
2391
+
2392
+ // MovableAngle is an angle that can be dragged around the screen.
2393
+ // By attaching a smartPoint to the vertex and ray control points, the
2394
+ // rays can be manipulated individually.
2395
+ //
2396
+ // Use only with smartPoints; add the smartPoints first, then:
2397
+ // addMovableAngle({points: [...]});
2398
+ //
2399
+ // The rays can be controlled to snap on degrees (more useful than snapping
2400
+ // on coordinates) by setting snapDegrees to a positive integer.
2401
+ //
2402
+ // The returned object includes the following properties/methods:
2403
+ //
2404
+ // - movableAngle.points
2405
+ // The movableAngle's dynamic smartPoints.
2406
+ //
2407
+ // - movableAngle.coords
2408
+ // The movableAngle's current coordinates (generated, don't edit).
2409
+ //
2410
+ addMovableAngle: function (options) {
2411
+ // $FlowFixMe[invalid-constructor]
2412
+ return new MovableAngle(this, options);
2413
+ },
2414
+
2415
+ // center: movable point
2416
+ // radius: int
2417
+ // circ: graphie circle
2418
+ // perim: invisible mouse target for dragging/changing radius
2419
+ addCircleGraph: function (options) {
2420
+ const graphie = this;
2421
+ const circle = $.extend(
2422
+ {
2423
+ center: [0, 0],
2424
+ radius: 2,
2425
+ snapX: 0.5,
2426
+ snapY: 0.5,
2427
+ snapRadius: 0.5,
2428
+ minRadius: 1,
2429
+ centerConstraints: {},
2430
+ centerNormalStyle: {},
2431
+ centerHighlightStyle: {
2432
+ stroke: KhanColors.INTERACTING,
2433
+ fill: KhanColors.INTERACTING,
2434
+ },
2435
+ circleNormalStyle: {
2436
+ stroke: KhanColors.INTERACTIVE,
2437
+ "fill-opacity": 0,
2438
+ },
2439
+ circleHighlightStyle: {
2440
+ stroke: KhanColors.INTERACTING,
2441
+ fill: KhanColors.INTERACTING,
2442
+ "fill-opacity": 0.05,
2443
+ },
2444
+ },
2445
+ options,
2446
+ );
2447
+
2448
+ const normalColor = circle.centerConstraints.fixed
2449
+ ? KhanColors.DYNAMIC
2450
+ : KhanColors.INTERACTIVE;
2451
+ const centerNormalStyle = options ? options.centerNormalStyle : null;
2452
+ circle.centerNormalStyle = _.extend(
2453
+ {},
2454
+ {
2455
+ fill: normalColor,
2456
+ stroke: normalColor,
2457
+ },
2458
+ centerNormalStyle,
2459
+ );
2460
+
2461
+ circle.centerPoint = graphie.addMovablePoint({
2462
+ graph: graphie,
2463
+ coord: circle.center,
2464
+ normalStyle: circle.centerNormalStyle,
2465
+ snapX: circle.snapX,
2466
+ snapY: circle.snapY,
2467
+ constraints: circle.centerConstraints,
2468
+ });
2469
+ circle.circ = graphie.circle(
2470
+ circle.center,
2471
+ circle.radius,
2472
+ circle.circleNormalStyle,
2473
+ );
2474
+ circle.perim = graphie.mouselayer
2475
+ .circle(
2476
+ graphie.scalePoint(circle.center)[0],
2477
+ graphie.scalePoint(circle.center)[1],
2478
+ graphie.scaleVector(circle.radius)[0],
2479
+ )
2480
+ .attr({
2481
+ "stroke-width": 20,
2482
+ opacity: 0.002, // This is as close to 0 as MSIE will allow
2483
+ });
2484
+
2485
+ // Highlight circle circumference on center point hover
2486
+ if (!circle.centerConstraints.fixed) {
2487
+ $(circle.centerPoint.mouseTarget.getMouseTarget()).on(
2488
+ "vmouseover vmouseout",
2489
+ function (event) {
2490
+ if (
2491
+ circle.centerPoint.highlight ||
2492
+ circle.centerPoint.dragging
2493
+ ) {
2494
+ circle.circ.animate(circle.circleHighlightStyle, 50);
2495
+ } else {
2496
+ circle.circ.animate(circle.circleNormalStyle, 50);
2497
+ }
2498
+ },
2499
+ );
2500
+ }
2501
+
2502
+ circle.toFront = function () {
2503
+ circle.circ.toFront();
2504
+ circle.perim.toFront();
2505
+ circle.centerPoint.visibleShape.toFront();
2506
+ if (!circle.centerConstraints.fixed) {
2507
+ circle.centerPoint.mouseTarget.toFront();
2508
+ }
2509
+ };
2510
+
2511
+ circle.centerPoint.onMove = function (x, y) {
2512
+ circle.toFront();
2513
+ circle.circ.attr({
2514
+ cx: graphie.scalePoint(x)[0],
2515
+ cy: graphie.scalePoint(y)[1],
2516
+ });
2517
+ circle.perim.attr({
2518
+ cx: graphie.scalePoint(x)[0],
2519
+ cy: graphie.scalePoint(y)[1],
2520
+ });
2521
+ if (circle.onMove) {
2522
+ circle.onMove(x, y);
2523
+ }
2524
+ };
2525
+
2526
+ $(circle.centerPoint).on("move", function () {
2527
+ circle.center = this.coord;
2528
+ $(circle).trigger("move");
2529
+ });
2530
+
2531
+ // circle.setCenter(x, y) moves the circle to the specified
2532
+ // x, y coordinate as if the user had dragged it there.
2533
+ circle.setCenter = function (x, y) {
2534
+ circle.centerPoint.setCoord([x, y]);
2535
+ circle.centerPoint.onMove(x, y);
2536
+ circle.center = [x, y];
2537
+ };
2538
+
2539
+ // circle.setRadius(r) sets the circle's radius to the specified
2540
+ // value as if the user had dragged it there.
2541
+ circle.setRadius = function (r) {
2542
+ circle.radius = r;
2543
+
2544
+ circle.perim.attr({
2545
+ r: graphie.scaleVector(r)[0],
2546
+ });
2547
+ circle.circ.attr({
2548
+ rx: graphie.scaleVector(r)[0],
2549
+ ry: graphie.scaleVector(r)[1],
2550
+ });
2551
+ };
2552
+
2553
+ circle.remove = function () {
2554
+ circle.centerPoint.remove();
2555
+ circle.circ.remove();
2556
+ circle.perim.remove();
2557
+ };
2558
+
2559
+ // Define a set of axes using polar coordinates to specify
2560
+ // which resizing cursor we want to show based on where the
2561
+ // mouse position lies in relation to the circle's center.
2562
+ // The first two columns in cursorAxes refer to the minimum
2563
+ // and maximum angle values bounding a circle sector, and
2564
+ // the third column refers to the cursor name that will be
2565
+ // applied if the mouse position falls inside the given sector.
2566
+ const cursorAxes = [
2567
+ [Math.PI * -1.0, Math.PI * -0.875, "ew-resize"],
2568
+ [Math.PI * -0.875, Math.PI * -0.625, "nesw-resize"],
2569
+ [Math.PI * -0.625, Math.PI * -0.375, "ns-resize"],
2570
+ [Math.PI * -0.375, Math.PI * -0.125, "nwse-resize"],
2571
+ [Math.PI * -0.125, Math.PI * 0.125, "ew-resize"],
2572
+ [Math.PI * 0.125, Math.PI * 0.375, "nesw-resize"],
2573
+ [Math.PI * 0.375, Math.PI * 0.625, "ns-resize"],
2574
+ [Math.PI * 0.625, Math.PI * 0.875, "nwse-resize"],
2575
+ [Math.PI * 0.875, Math.PI * 1.0, "ew-resize"],
2576
+ ];
2577
+
2578
+ // When the mouse moves along the circle's perimeter, we
2579
+ // dynamically set a CSS rule to show the correct
2580
+ // bidirectional cursor so a student knows they can resize
2581
+ // our circle. To do this, we convert the x and y coordinates
2582
+ // of the mouse position into polar coordinates and use the
2583
+ // defined cursorAxes above to set our rule.
2584
+ $(circle.perim.node).on("vmousemove", (event) => {
2585
+ let [x, y] = this.getMouseCoord(event);
2586
+
2587
+ x -= circle.center[0];
2588
+ y -= circle.center[1];
2589
+
2590
+ const theta = Math.atan2(y, x);
2591
+
2592
+ cursorAxes.forEach(function (axes) {
2593
+ const [min, max, cursorName] = axes;
2594
+ if (theta >= min && theta < max) {
2595
+ $(circle.perim.node).css("cursor", cursorName);
2596
+ }
2597
+ });
2598
+ });
2599
+
2600
+ // Set a default resizing-friendly cursor to be safe.
2601
+ $(circle.perim.node).css("cursor", "nesw-resize");
2602
+
2603
+ // Prevent the page from scrolling when we grab and drag the circle on
2604
+ // a mobile device.
2605
+ circle.perim.node.addEventListener(
2606
+ "touchstart",
2607
+ function (event) {
2608
+ event.preventDefault();
2609
+ },
2610
+ {passive: false},
2611
+ );
2612
+
2613
+ $(circle.perim.node).on(
2614
+ "vmouseover vmouseout vmousedown",
2615
+ function (event) {
2616
+ if (event.type === "vmouseover") {
2617
+ circle.highlight = true;
2618
+ if (!dragging) {
2619
+ // TODO(jack): Figure out why this doesn't work
2620
+ // for circleHighlightStyle's that change
2621
+ // stroke-dasharray
2622
+ circle.circ.animate(circle.circleHighlightStyle, 50);
2623
+ circle.centerPoint.visibleShape.animate(
2624
+ circle.centerHighlightStyle,
2625
+ 50,
2626
+ );
2627
+ }
2628
+ } else if (event.type === "vmouseout") {
2629
+ circle.highlight = false;
2630
+ if (!circle.dragging && !circle.centerPoint.dragging) {
2631
+ circle.circ.animate(circle.circleNormalStyle, 50);
2632
+ circle.centerPoint.visibleShape.animate(
2633
+ circle.centerNormalStyle,
2634
+ 50,
2635
+ );
2636
+ }
2637
+ } else if (
2638
+ event.type === "vmousedown" &&
2639
+ (event.which === 1 || event.which === 0)
2640
+ ) {
2641
+ event.preventDefault();
2642
+ circle.toFront();
2643
+ const startRadius = circle.radius;
2644
+
2645
+ $(document).on("vmousemove vmouseup", function (event) {
2646
+ event.preventDefault();
2647
+ circle.dragging = true;
2648
+ dragging = true;
2649
+
2650
+ if (event.type === "vmousemove") {
2651
+ const coord = graphie.constrainToBounds(
2652
+ graphie.getMouseCoord(event),
2653
+ 10,
2654
+ );
2655
+
2656
+ let radius = GraphUtils.getDistance(
2657
+ circle.centerPoint.coord,
2658
+ coord,
2659
+ );
2660
+ radius = Math.max(
2661
+ circle.minRadius,
2662
+ Math.round(radius / circle.snapRadius) *
2663
+ circle.snapRadius,
2664
+ );
2665
+ const oldRadius = circle.radius;
2666
+ let doResize = true;
2667
+ if (circle.onResize) {
2668
+ const onResizeResult = circle.onResize(
2669
+ radius,
2670
+ oldRadius,
2671
+ );
2672
+ if (_.isNumber(onResizeResult)) {
2673
+ radius = onResizeResult;
2674
+ } else if (onResizeResult === false) {
2675
+ doResize = false;
2676
+ }
2677
+ }
2678
+ if (doResize) {
2679
+ circle.setRadius(radius);
2680
+ $(circle).trigger("move");
2681
+ }
2682
+ } else if (event.type === "vmouseup") {
2683
+ $(document).off("vmousemove vmouseup");
2684
+ circle.dragging = false;
2685
+ dragging = false;
2686
+ if (circle.onResizeEnd) {
2687
+ circle.onResizeEnd(circle.radius, startRadius);
2688
+ }
2689
+ }
2690
+ });
2691
+ }
2692
+ },
2693
+ );
2694
+
2695
+ return circle;
2696
+ },
2697
+
2698
+ addRotateHandle: (function () {
2699
+ const drawRotateHandle = function (
2700
+ graphie,
2701
+ center,
2702
+ radius,
2703
+ halfWidth,
2704
+ lengthAngle,
2705
+ angle,
2706
+ interacting,
2707
+ ) {
2708
+ const getRotateHandlePoint = function (
2709
+ offset,
2710
+ distanceFromArrowMidline,
2711
+ ) {
2712
+ const distFromRotationCenter =
2713
+ radius + distanceFromArrowMidline;
2714
+ const vec = kvector.cartFromPolarDeg([
2715
+ distFromRotationCenter,
2716
+ angle + offset,
2717
+ ]);
2718
+ const absolute = kvector.add(center, vec);
2719
+ const pixels = graphie.scalePoint(absolute);
2720
+ return pixels[0] + "," + pixels[1];
2721
+ };
2722
+
2723
+ const innerR = graphie.scaleVector(radius - halfWidth);
2724
+ const outerR = graphie.scaleVector(radius + halfWidth);
2725
+
2726
+ // Draw the double-headed arrow thing that shows users where to
2727
+ // click and drag to rotate
2728
+ return graphie.raphael
2729
+ .path(
2730
+ // upper arrowhead
2731
+ " M" +
2732
+ getRotateHandlePoint(lengthAngle, -halfWidth) +
2733
+ " L" +
2734
+ getRotateHandlePoint(lengthAngle, -3 * halfWidth) +
2735
+ " L" +
2736
+ getRotateHandlePoint(2 * lengthAngle, 0) +
2737
+ " L" +
2738
+ getRotateHandlePoint(lengthAngle, 3 * halfWidth) +
2739
+ " L" +
2740
+ getRotateHandlePoint(lengthAngle, halfWidth) +
2741
+ // outer arc
2742
+ " A" +
2743
+ outerR[0] +
2744
+ "," +
2745
+ outerR[1] +
2746
+ ",0,0,1," +
2747
+ getRotateHandlePoint(-lengthAngle, halfWidth) +
2748
+ // lower arrowhead
2749
+ " L" +
2750
+ getRotateHandlePoint(-lengthAngle, 3 * halfWidth) +
2751
+ " L" +
2752
+ getRotateHandlePoint(-2 * lengthAngle, 0) +
2753
+ " L" +
2754
+ getRotateHandlePoint(-lengthAngle, -3 * halfWidth) +
2755
+ " L" +
2756
+ getRotateHandlePoint(-lengthAngle, -halfWidth) +
2757
+ // inner arc
2758
+ " A" +
2759
+ innerR[0] +
2760
+ "," +
2761
+ innerR[1] +
2762
+ ",0,0,0," +
2763
+ getRotateHandlePoint(lengthAngle, -halfWidth) +
2764
+ " Z",
2765
+ )
2766
+ .attr({
2767
+ stroke: null,
2768
+ fill: interacting
2769
+ ? KhanColors.INTERACTING
2770
+ : KhanColors.INTERACTIVE,
2771
+ });
2772
+ };
2773
+
2774
+ return function (options) {
2775
+ const graph = this;
2776
+
2777
+ let rotatePoint = options.center;
2778
+ const radius = options.radius;
2779
+ const lengthAngle = options.lengthAngle || 30;
2780
+ const hideArrow = options.hideArrow || false;
2781
+ const mouseTarget = options.mouseTarget;
2782
+ const id = _.uniqueId("rotateHandle");
2783
+
2784
+ // Normalize rotatePoint into something that always looks
2785
+ // like a movablePoint
2786
+ if (_.isArray(rotatePoint)) {
2787
+ rotatePoint = {
2788
+ coord: rotatePoint,
2789
+ };
2790
+ }
2791
+
2792
+ const rotateHandle = graph.addMovablePoint({
2793
+ coord: kpoint.addVector(
2794
+ rotatePoint.coord,
2795
+ kvector.cartFromPolarDeg(radius, options.angleDeg || 0),
2796
+ ),
2797
+ constraints: {
2798
+ fixedDistance: {
2799
+ dist: radius,
2800
+ point: rotatePoint,
2801
+ },
2802
+ },
2803
+ mouseTarget: mouseTarget,
2804
+ });
2805
+
2806
+ // move the rotatePoint in front of the rotateHandle to avoid
2807
+ // confusing clicking/scaling of the rotateHandle when the user
2808
+ // intends to click on the rotatePoint
2809
+ // $FlowFixMe[prop-missing]
2810
+ rotatePoint.toFront();
2811
+
2812
+ let rotatePointPrevCoord = rotatePoint.coord;
2813
+ let rotateHandlePrevCoord = rotateHandle.coord;
2814
+ let rotateHandleStartCoord = rotateHandlePrevCoord;
2815
+ let isRotating = false;
2816
+ let isHovering = false;
2817
+ let drawnRotateHandle;
2818
+
2819
+ const redrawRotateHandle = function (handleCoord) {
2820
+ if (hideArrow) {
2821
+ return; // Don't draw anything!
2822
+ }
2823
+
2824
+ const handleVec = kvector.subtract(
2825
+ handleCoord,
2826
+ rotatePoint.coord,
2827
+ );
2828
+ const handlePolar = kvector.polarDegFromCart(handleVec);
2829
+ const angle = handlePolar[1];
2830
+
2831
+ if (drawnRotateHandle) {
2832
+ drawnRotateHandle.remove();
2833
+ }
2834
+
2835
+ drawnRotateHandle = drawRotateHandle(
2836
+ graph,
2837
+ rotatePoint.coord,
2838
+ options.radius,
2839
+ isRotating || isHovering
2840
+ ? options.hoverWidth / 2
2841
+ : options.width / 2,
2842
+ lengthAngle,
2843
+ angle,
2844
+ isRotating || isHovering,
2845
+ );
2846
+ };
2847
+
2848
+ // when the rotation center moves, we need to move
2849
+ // the rotationHandle as well, or it will end up out
2850
+ // of sync
2851
+ $(rotatePoint).on("move." + id, function () {
2852
+ const delta = kvector.subtract(
2853
+ rotatePoint.coord,
2854
+ rotatePointPrevCoord,
2855
+ );
2856
+
2857
+ rotateHandle.setCoord(kvector.add(rotateHandle.coord, delta));
2858
+
2859
+ redrawRotateHandle(rotateHandle.coord);
2860
+
2861
+ rotatePointPrevCoord = rotatePoint.coord;
2862
+ rotateHandle.constraints.fixedDistance.point = rotatePoint;
2863
+ rotateHandlePrevCoord = rotateHandle.coord;
2864
+ });
2865
+
2866
+ // Rotate polygon with rotateHandle
2867
+ rotateHandle.onMove = function (x, y) {
2868
+ if (!isRotating) {
2869
+ rotateHandleStartCoord = rotateHandlePrevCoord;
2870
+ isRotating = true;
2871
+ }
2872
+
2873
+ let coord = [x, y];
2874
+
2875
+ if (options.onMove) {
2876
+ const oldPolar = kvector.polarDegFromCart(
2877
+ kvector.subtract(
2878
+ rotateHandlePrevCoord,
2879
+ rotatePoint.coord,
2880
+ ),
2881
+ );
2882
+ const newPolar = kvector.polarDegFromCart(
2883
+ kvector.subtract(coord, rotatePoint.coord),
2884
+ );
2885
+
2886
+ const oldAngle = oldPolar[1];
2887
+ const newAngle = newPolar[1];
2888
+ let result = options.onMove(newAngle, oldAngle);
2889
+ if (result != null && result !== true) {
2890
+ if (result === false) {
2891
+ result = oldAngle;
2892
+ }
2893
+ coord = kvector.add(
2894
+ rotatePoint.coord,
2895
+ kvector.cartFromPolarDeg([oldPolar[0], result]),
2896
+ );
2897
+ }
2898
+ }
2899
+
2900
+ redrawRotateHandle(coord);
2901
+
2902
+ rotateHandlePrevCoord = coord;
2903
+ return coord;
2904
+ };
2905
+
2906
+ rotateHandle.onMoveEnd = function () {
2907
+ isRotating = false;
2908
+ redrawRotateHandle(rotateHandle.coord);
2909
+ if (options.onMoveEnd) {
2910
+ const oldPolar = kvector.polarDegFromCart(
2911
+ kvector.subtract(
2912
+ rotateHandleStartCoord,
2913
+ rotatePoint.coord,
2914
+ ),
2915
+ );
2916
+ const newPolar = kvector.polarDegFromCart(
2917
+ kvector.subtract(rotateHandle.coord, rotatePoint.coord),
2918
+ );
2919
+ options.onMoveEnd(newPolar[1], oldPolar[1]);
2920
+ }
2921
+ };
2922
+
2923
+ // Remove the default dot added by the movablePoint since we have
2924
+ // our double-arrow thing
2925
+ rotateHandle.visibleShape.remove();
2926
+
2927
+ if (!mouseTarget) {
2928
+ // Make the default mouse target bigger to encompass the whole
2929
+ // area around the double-arrow thing
2930
+ rotateHandle.mouseTarget.attr({scale: 2});
2931
+ }
2932
+
2933
+ const $mouseTarget = $(rotateHandle.mouseTarget.getMouseTarget());
2934
+ $mouseTarget.bind("vmouseover", function (e) {
2935
+ isHovering = true;
2936
+ redrawRotateHandle(rotateHandle.coord);
2937
+ });
2938
+ $mouseTarget.bind("vmouseout", function (e) {
2939
+ isHovering = false;
2940
+ redrawRotateHandle(rotateHandle.coord);
2941
+ });
2942
+
2943
+ redrawRotateHandle(rotateHandle.coord);
2944
+
2945
+ const oldRemove = rotateHandle.remove;
2946
+ rotateHandle.remove = function () {
2947
+ oldRemove.call(rotateHandle);
2948
+ if (drawnRotateHandle) {
2949
+ drawnRotateHandle.remove();
2950
+ }
2951
+ $(rotatePoint).off("move." + id);
2952
+ };
2953
+
2954
+ rotateHandle.update = function () {
2955
+ redrawRotateHandle(rotateHandle.coord);
2956
+ };
2957
+
2958
+ return rotateHandle;
2959
+ };
2960
+ })(),
2961
+
2962
+ addReflectButton: (function () {
2963
+ const drawButton = function (
2964
+ graphie,
2965
+ buttonCoord,
2966
+ lineCoords,
2967
+ size,
2968
+ distanceFromCenter,
2969
+ leftStyle,
2970
+ rightStyle,
2971
+ ) {
2972
+ // Avoid invalid lines
2973
+ if (kpoint.equal(lineCoords[0], lineCoords[1])) {
2974
+ lineCoords = [
2975
+ lineCoords[0],
2976
+ kpoint.addVector(lineCoords[0], [1, 1]),
2977
+ ];
2978
+ }
2979
+
2980
+ const lineDirection = kvector.normalize(
2981
+ kvector.subtract(lineCoords[1], lineCoords[0]),
2982
+ );
2983
+
2984
+ const lineVec = kvector.scale(lineDirection, size / 2);
2985
+
2986
+ const centerVec = kvector.scale(lineDirection, distanceFromCenter);
2987
+ const leftCenterVec = kvector.rotateDeg(centerVec, 90);
2988
+ const rightCenterVec = kvector.rotateDeg(centerVec, -90);
2989
+
2990
+ const negLineVec = kvector.negate(lineVec);
2991
+ const leftVec = kvector.rotateDeg(lineVec, 90);
2992
+ const rightVec = kvector.rotateDeg(lineVec, -90);
2993
+
2994
+ const leftCenter = kpoint.addVectors(buttonCoord, leftCenterVec);
2995
+ const rightCenter = kpoint.addVectors(buttonCoord, rightCenterVec);
2996
+
2997
+ const leftCoord1 = kpoint.addVectors(
2998
+ buttonCoord,
2999
+ leftCenterVec,
3000
+ lineVec,
3001
+ leftVec,
3002
+ );
3003
+ const leftCoord2 = kpoint.addVectors(
3004
+ buttonCoord,
3005
+ leftCenterVec,
3006
+ negLineVec,
3007
+ leftVec,
3008
+ );
3009
+ const rightCoord1 = kpoint.addVectors(
3010
+ buttonCoord,
3011
+ rightCenterVec,
3012
+ lineVec,
3013
+ rightVec,
3014
+ );
3015
+ const rightCoord2 = kpoint.addVectors(
3016
+ buttonCoord,
3017
+ rightCenterVec,
3018
+ negLineVec,
3019
+ rightVec,
3020
+ );
3021
+
3022
+ const leftButton = graphie.path(
3023
+ [leftCenter, leftCoord1, leftCoord2, true],
3024
+ leftStyle,
3025
+ );
3026
+ const rightButton = graphie.path(
3027
+ [rightCenter, rightCoord1, rightCoord2, true],
3028
+ rightStyle,
3029
+ );
3030
+
3031
+ return {
3032
+ remove: function () {
3033
+ leftButton.remove();
3034
+ rightButton.remove();
3035
+ },
3036
+ };
3037
+ };
3038
+
3039
+ return function (options) {
3040
+ const graphie = this;
3041
+
3042
+ const line = options.line;
3043
+
3044
+ const button = graphie.addMovablePoint({
3045
+ constraints: options.constraints,
3046
+ coord: kline.midpoint([line.pointA.coord, line.pointZ.coord]),
3047
+ snapX: graphie.snap[0],
3048
+ snapY: graphie.snap[1],
3049
+ onMove: function (x, y) {
3050
+ // Don't allow the button to actually move. This is a hack
3051
+ // around the inability to both set a point as fixed AND
3052
+ // allow it to be clicked.
3053
+ return false;
3054
+ },
3055
+ onMoveEnd: function (x, y) {
3056
+ if (options.onMoveEnd) {
3057
+ options.onMoveEnd.call(this, x, y);
3058
+ }
3059
+ },
3060
+ });
3061
+
3062
+ let isHovering = false;
3063
+ let isFlipped = false;
3064
+ let currentlyDrawnButton;
3065
+
3066
+ const isHighlight = function () {
3067
+ return isHovering;
3068
+ };
3069
+
3070
+ const styles = _.map([0, 1], function (isHighlight) {
3071
+ const baseStyle = isHighlight
3072
+ ? options.highlightStyle
3073
+ : options.normalStyle;
3074
+
3075
+ return _.map([0, 1], function (opacity) {
3076
+ return _.defaults(
3077
+ {
3078
+ "fill-opacity": opacity,
3079
+ },
3080
+ baseStyle,
3081
+ );
3082
+ });
3083
+ });
3084
+
3085
+ const getStyle = function (isRight) {
3086
+ if (isFlipped) {
3087
+ isRight = !isRight;
3088
+ }
3089
+ return styles[+isHighlight()][+isRight];
3090
+ };
3091
+
3092
+ const redraw = function (coord, lineCoords) {
3093
+ if (currentlyDrawnButton) {
3094
+ currentlyDrawnButton.remove();
3095
+ }
3096
+ currentlyDrawnButton = drawButton(
3097
+ graphie,
3098
+ coord,
3099
+ lineCoords,
3100
+ isHighlight() ? options.size * 1.5 : options.size,
3101
+ isHighlight() ? options.size * 0.125 : 0.25,
3102
+ getStyle(0),
3103
+ getStyle(1),
3104
+ );
3105
+ };
3106
+
3107
+ const update = function (coordA, coordZ) {
3108
+ coordA = coordA || line.pointA.coord;
3109
+ coordZ = coordZ || line.pointZ.coord;
3110
+
3111
+ const buttonCoord = kline.midpoint([coordA, coordZ]);
3112
+ button.setCoord(buttonCoord);
3113
+
3114
+ redraw(buttonCoord, [coordA, coordZ]);
3115
+ };
3116
+
3117
+ $(line).on("move", _.bind(update, button, null, null));
3118
+
3119
+ const $mouseTarget = $(button.mouseTarget.getMouseTarget());
3120
+ $mouseTarget.on("vclick", function () {
3121
+ const result = options.onClick();
3122
+ if (result !== false) {
3123
+ isFlipped = !isFlipped;
3124
+ redraw(button.coord, [
3125
+ line.pointA.coord,
3126
+ line.pointZ.coord,
3127
+ ]);
3128
+ }
3129
+ });
3130
+
3131
+ // Bring the reflection line handles in front of the button, so
3132
+ // that if we drag the reflectPoints really close together, we can
3133
+ // still move the handles away from each other, rather than only
3134
+ // being able to apply the reflection.
3135
+ line.pointA.toFront();
3136
+ line.pointZ.toFront();
3137
+
3138
+ // Replace the visual point with the double triangle thing
3139
+ button.visibleShape.remove();
3140
+ const pointScale = graphie.scaleVector(options.size)[0] / 20;
3141
+ button.mouseTarget.attr({scale: 1.5 * pointScale});
3142
+ $mouseTarget.css("cursor", "pointer");
3143
+
3144
+ // Make the arrow-thing grow and shrink with mouseover/out
3145
+ $mouseTarget.bind("vmouseover", function (e) {
3146
+ isHovering = true;
3147
+ redraw(button.coord, [line.pointA.coord, line.pointZ.coord]);
3148
+ });
3149
+ $mouseTarget.bind("vmouseout", function (e) {
3150
+ isHovering = false;
3151
+ redraw(button.coord, [line.pointA.coord, line.pointZ.coord]);
3152
+ });
3153
+
3154
+ const oldButtonRemove = button.remove;
3155
+ button.remove = function () {
3156
+ currentlyDrawnButton.remove();
3157
+ oldButtonRemove.call(button);
3158
+ };
3159
+
3160
+ button.update = update;
3161
+ button.isFlipped = function () {
3162
+ return isFlipped;
3163
+ };
3164
+
3165
+ update();
3166
+ return button;
3167
+ };
3168
+ })(),
3169
+
3170
+ protractor: function (center) {
3171
+ // $FlowFixMe[invalid-constructor]
3172
+ return new Protractor(this, center);
3173
+ },
3174
+
3175
+ ruler: function (options) {
3176
+ // $FlowFixMe[invalid-constructor]
3177
+ return new Ruler(this, options || {});
3178
+ },
3179
+
3180
+ addPoints: addPoints,
3181
+ });
3182
+
3183
+ function Protractor(graph, center) {
3184
+ this.set = graph.raphael.set();
3185
+
3186
+ this.cx = center[0];
3187
+ this.cy = center[1];
3188
+ const pro = this;
3189
+
3190
+ const r = graph.unscaleVector(180.5)[0];
3191
+ const imgPos = graph.scalePoint([
3192
+ this.cx - r,
3193
+ this.cy + r - graph.unscaleVector(10.5)[1],
3194
+ ]);
3195
+ const image = graph.mouselayer.image(
3196
+ "https://ka-perseus-graphie.s3.amazonaws.com/e9d032f2ab8b95979f674fbfa67056442ba1ff6a.png",
3197
+ imgPos[0],
3198
+ imgPos[1],
3199
+ 360,
3200
+ 180,
3201
+ );
3202
+ this.set.push(image);
3203
+
3204
+ // Prevent the page from scrolling when we grab and drag the image on a
3205
+ // mobile device.
3206
+ image.node.addEventListener(
3207
+ "touchstart",
3208
+ function (event) {
3209
+ event.preventDefault();
3210
+ },
3211
+ {passive: false},
3212
+ );
3213
+
3214
+ const arrowHelper = function (angle, pixelsFromEdge) {
3215
+ const scaledRadius = graph.scaleVector(r);
3216
+ scaledRadius[0] -= 16;
3217
+ scaledRadius[1] -= 16;
3218
+ const scaledCenter = graph.scalePoint(center);
3219
+ const x =
3220
+ Math.sin(((angle + 90) * Math.PI) / 180) *
3221
+ (scaledRadius[0] + pixelsFromEdge) +
3222
+ scaledCenter[0];
3223
+ const y =
3224
+ Math.cos(((angle + 90) * Math.PI) / 180) *
3225
+ (scaledRadius[1] + pixelsFromEdge) +
3226
+ scaledCenter[1];
3227
+ return x + "," + y;
3228
+ };
3229
+
3230
+ const arrow = graph.raphael
3231
+ .path(
3232
+ " M" +
3233
+ arrowHelper(180, 6) +
3234
+ " L" +
3235
+ arrowHelper(180, 2) +
3236
+ " L" +
3237
+ arrowHelper(183, 10) +
3238
+ " L" +
3239
+ arrowHelper(180, 18) +
3240
+ " L" +
3241
+ arrowHelper(180, 14) +
3242
+ " A" +
3243
+ (graph.scaleVector(r)[0] + 10) +
3244
+ "," +
3245
+ (graph.scaleVector(r)[1] + 10) +
3246
+ ",0,0,1," +
3247
+ arrowHelper(170, 14) +
3248
+ " L" +
3249
+ arrowHelper(170, 18) +
3250
+ " L" +
3251
+ arrowHelper(167, 10) +
3252
+ " L" +
3253
+ arrowHelper(170, 2) +
3254
+ " L" +
3255
+ arrowHelper(170, 6) +
3256
+ " A" +
3257
+ (graph.scaleVector(r)[0] + 10) +
3258
+ "," +
3259
+ (graph.scaleVector(r)[1] + 10) +
3260
+ ",0,0,0," +
3261
+ arrowHelper(180, 6) +
3262
+ " Z",
3263
+ )
3264
+ .attr({
3265
+ stroke: null,
3266
+ fill: KhanColors.INTERACTIVE,
3267
+ });
3268
+
3269
+ // add it to the set so it translates with everything else
3270
+ this.set.push(arrow);
3271
+
3272
+ this.centerPoint = graph.addMovablePoint({
3273
+ coord: center,
3274
+ visible: false,
3275
+ });
3276
+
3277
+ // Use a movablePoint for rotation
3278
+ this.rotateHandle = graph.addMovablePoint({
3279
+ bounded: false,
3280
+ coord: [
3281
+ Math.sin((275 * Math.PI) / 180) * r + this.cx,
3282
+ Math.cos((275 * Math.PI) / 180) * r + this.cy,
3283
+ ],
3284
+ onMove: function (x, y) {
3285
+ const angle =
3286
+ (Math.atan2(
3287
+ pro.centerPoint.coord[1] - y,
3288
+ pro.centerPoint.coord[0] - x,
3289
+ ) *
3290
+ 180) /
3291
+ Math.PI;
3292
+ pro.rotate(-angle - 5, true);
3293
+ },
3294
+ });
3295
+
3296
+ // Add a constraint so the point moves in a circle
3297
+ this.rotateHandle.constraints.fixedDistance.dist = r;
3298
+ this.rotateHandle.constraints.fixedDistance.point = this.centerPoint;
3299
+
3300
+ // Remove the default dot added by the movablePoint since we have our
3301
+ // double-arrow thing
3302
+ this.rotateHandle.visibleShape.remove();
3303
+ // Make the mouse target bigger to encompass the whole area around the
3304
+ // double-arrow thing
3305
+ this.rotateHandle.mouseTarget.attr({scale: 2.0});
3306
+
3307
+ let isDragging = false;
3308
+ let isHovering = false;
3309
+ const isHighlight = function () {
3310
+ return isHovering || isDragging;
3311
+ };
3312
+
3313
+ const self = this;
3314
+ const $mouseTarget = $(self.rotateHandle.mouseTarget.getMouseTarget());
3315
+ $mouseTarget.bind("vmousedown", function (event) {
3316
+ isDragging = true;
3317
+ $mouseTarget.css("cursor", "-webkit-grabbing");
3318
+ $mouseTarget.css("cursor", "grabbing");
3319
+ arrow.animate({scale: 1.5, fill: KhanColors.INTERACTING}, 50);
3320
+
3321
+ $(document).bind("vmouseup.rotateHandle", function (event) {
3322
+ isDragging = false;
3323
+ $mouseTarget.css("cursor", "-webkit-grab");
3324
+ $mouseTarget.css("cursor", "grab");
3325
+
3326
+ if (!isHighlight()) {
3327
+ arrow.animate({scale: 1.0, fill: KhanColors.INTERACTIVE}, 50);
3328
+ }
3329
+
3330
+ $(document).unbind("vmouseup.rotateHandle");
3331
+ });
3332
+ });
3333
+
3334
+ $mouseTarget.bind("vmouseover", function (event) {
3335
+ isHovering = true;
3336
+ arrow.animate({scale: 1.5, fill: KhanColors.INTERACTING}, 50);
3337
+ });
3338
+ $mouseTarget.bind("vmouseout", function (event) {
3339
+ isHovering = false;
3340
+ if (!isHighlight()) {
3341
+ arrow.animate({scale: 1.0, fill: KhanColors.INTERACTIVE}, 50);
3342
+ }
3343
+ });
3344
+
3345
+ const setNodes = $.map(this.set, function (el) {
3346
+ return el.node;
3347
+ });
3348
+ this.makeTranslatable = function makeTranslatable() {
3349
+ $(setNodes).css("cursor", "move");
3350
+ $mouseTarget.css("cursor", "-webkit-grab");
3351
+ $mouseTarget.css("cursor", "grab");
3352
+
3353
+ $(setNodes).bind("vmousedown", function (event) {
3354
+ event.preventDefault();
3355
+ let startx =
3356
+ event.pageX - $(graph.raphael.canvas.parentNode).offset().left;
3357
+ let starty =
3358
+ event.pageY - $(graph.raphael.canvas.parentNode).offset().top;
3359
+
3360
+ $(document).bind("vmousemove.protractor", function (event) {
3361
+ let mouseX =
3362
+ event.pageX -
3363
+ $(graph.raphael.canvas.parentNode).offset().left;
3364
+ let mouseY =
3365
+ event.pageY -
3366
+ $(graph.raphael.canvas.parentNode).offset().top;
3367
+ // can't go beyond 10 pixels from the edge
3368
+ mouseX = Math.max(10, Math.min(graph.xpixels - 10, mouseX));
3369
+ mouseY = Math.max(10, Math.min(graph.ypixels - 10, mouseY));
3370
+
3371
+ const dx = mouseX - startx;
3372
+ const dy = mouseY - starty;
3373
+
3374
+ $.each(pro.set.items, function () {
3375
+ this.translate(dx, dy);
3376
+ });
3377
+ pro.centerPoint.setCoord([
3378
+ pro.centerPoint.coord[0] + dx / graph.scale[0],
3379
+ pro.centerPoint.coord[1] - dy / graph.scale[1],
3380
+ ]);
3381
+ pro.rotateHandle.setCoord([
3382
+ pro.rotateHandle.coord[0] + dx / graph.scale[0],
3383
+ pro.rotateHandle.coord[1] - dy / graph.scale[1],
3384
+ ]);
3385
+ startx = mouseX;
3386
+ starty = mouseY;
3387
+ });
3388
+
3389
+ $(document).one("vmouseup", function (event) {
3390
+ $(document).unbind("vmousemove.protractor");
3391
+ });
3392
+ });
3393
+ };
3394
+
3395
+ this.rotation = 0;
3396
+
3397
+ this.rotate = function (offset, absolute) {
3398
+ const center = graph.scalePoint(this.centerPoint.coord);
3399
+
3400
+ if (absolute) {
3401
+ this.rotation = 0;
3402
+ }
3403
+
3404
+ this.set.rotate(this.rotation + offset, center[0], center[1]);
3405
+ this.rotation = this.rotation + offset;
3406
+
3407
+ return this;
3408
+ };
3409
+
3410
+ this.moveTo = function moveTo(x, y) {
3411
+ const start = graph.scalePoint(pro.centerPoint.coord);
3412
+ const end = graph.scalePoint([x, y]);
3413
+ const time = GraphUtils.getDistance(start, end) * 2;
3414
+
3415
+ $({x: start[0], y: start[1]}).animate(
3416
+ {x: end[0], y: end[1]},
3417
+ {
3418
+ duration: time,
3419
+ step: function (now, fx) {
3420
+ let dx = 0;
3421
+ let dy = 0;
3422
+ if (fx.prop === "x") {
3423
+ dx = now - graph.scalePoint(pro.centerPoint.coord)[0];
3424
+ } else if (fx.prop === "y") {
3425
+ dy = now - graph.scalePoint(pro.centerPoint.coord)[1];
3426
+ }
3427
+ $.each(pro.set.items, function () {
3428
+ this.translate(dx, dy);
3429
+ });
3430
+ pro.centerPoint.setCoord([
3431
+ pro.centerPoint.coord[0] + dx / graph.scale[0],
3432
+ pro.centerPoint.coord[1] - dy / graph.scale[1],
3433
+ ]);
3434
+ pro.rotateHandle.setCoord([
3435
+ pro.rotateHandle.coord[0] + dx / graph.scale[0],
3436
+ pro.rotateHandle.coord[1] - dy / graph.scale[1],
3437
+ ]);
3438
+ },
3439
+ },
3440
+ );
3441
+ };
3442
+
3443
+ this.rotateTo = function rotateTo(angle) {
3444
+ if (Math.abs(this.rotation - angle) > 180) {
3445
+ this.rotation += 360;
3446
+ }
3447
+ const time = Math.abs(this.rotation - angle) * 5;
3448
+ // $FlowFixMe[unsupported-syntax]
3449
+ $({0: this.rotation}).animate(
3450
+ // $FlowFixMe[unsupported-syntax]
3451
+ {0: angle},
3452
+ {
3453
+ duration: time,
3454
+ step: function (now, fx) {
3455
+ pro.rotate(now, true);
3456
+ pro.rotateHandle.setCoord([
3457
+ Math.sin(((now + 275) * Math.PI) / 180) * r +
3458
+ pro.centerPoint.coord[0],
3459
+ Math.cos(((now + 275) * Math.PI) / 180) * r +
3460
+ pro.centerPoint.coord[1],
3461
+ ]);
3462
+ },
3463
+ },
3464
+ );
3465
+ };
3466
+
3467
+ this.remove = function () {
3468
+ this.set.remove();
3469
+ };
3470
+
3471
+ this.makeTranslatable();
3472
+ return this;
3473
+ }
3474
+
3475
+ function Ruler(graphie, options) {
3476
+ _.defaults(options, {
3477
+ center: [0, 0],
3478
+ pixelsPerUnit: 40,
3479
+ ticksPerUnit: 10, // 10 or power of 2
3480
+ units: 10, // the length the ruler can measure
3481
+ label: "", // e.g "cm" (the shorter, the better)
3482
+ style: {
3483
+ fill: null,
3484
+ stroke: KhanColors.GRAY,
3485
+ },
3486
+ });
3487
+
3488
+ const light = _.extend({}, options.style, {strokeWidth: 1});
3489
+ const bold = _.extend({}, options.style, {strokeWidth: 2});
3490
+
3491
+ const width = options.units * options.pixelsPerUnit;
3492
+ const height = 50;
3493
+
3494
+ const leftBottom = graphie.unscalePoint(
3495
+ kvector.subtract(
3496
+ graphie.scalePoint(options.center),
3497
+ kvector.scale([width, -height], 0.5),
3498
+ ),
3499
+ );
3500
+
3501
+ const graphieUnitsPerUnit = options.pixelsPerUnit / graphie.scale[0];
3502
+ const graphieUnitsHeight = height / graphie.scale[0];
3503
+
3504
+ const rightTop = kvector.add(leftBottom, [
3505
+ options.units * graphieUnitsPerUnit,
3506
+ graphieUnitsHeight,
3507
+ ]);
3508
+
3509
+ const tickHeight = 1.0;
3510
+ let tickHeightMap;
3511
+
3512
+ if (options.ticksPerUnit === 10) {
3513
+ // decimal, as on a centimeter ruler
3514
+ tickHeightMap = {
3515
+ // $FlowFixMe[unsupported-syntax]
3516
+ 10: tickHeight,
3517
+ // $FlowFixMe[unsupported-syntax]
3518
+ 5: tickHeight * 0.55,
3519
+ // $FlowFixMe[unsupported-syntax]
3520
+ 1: tickHeight * 0.35,
3521
+ };
3522
+ } else {
3523
+ const sizes = [1, 0.6, 0.45, 0.3];
3524
+
3525
+ tickHeightMap = {};
3526
+ for (let i = options.ticksPerUnit; i >= 1; i /= 2) {
3527
+ tickHeightMap[i] = tickHeight * (sizes.shift() || 0.2);
3528
+ }
3529
+ }
3530
+
3531
+ const tickFrequencies = _.keys(tickHeightMap).sort(function (a, b) {
3532
+ return b - a;
3533
+ });
3534
+
3535
+ function getTickHeight(i): number {
3536
+ for (let k = 0; k < tickFrequencies.length; k++) {
3537
+ const key = tickFrequencies[k];
3538
+ if (i % key === 0) {
3539
+ return tickHeightMap[key];
3540
+ }
3541
+ }
3542
+ return 0;
3543
+ }
3544
+
3545
+ const left = leftBottom[0];
3546
+ const bottom = leftBottom[1];
3547
+ const right = rightTop[0];
3548
+ const top = rightTop[1];
3549
+
3550
+ const numTicks = options.units * options.ticksPerUnit + 1;
3551
+
3552
+ const set = graphie.raphael.set();
3553
+
3554
+ const px = 1 / graphie.scale[0];
3555
+ set.push(graphie.line([left - px, bottom], [right + px, bottom], bold));
3556
+ set.push(graphie.line([left - px, top], [right + px, top], bold));
3557
+
3558
+ _.times(numTicks, function (i) {
3559
+ const n = i / options.ticksPerUnit;
3560
+ const x = left + n * graphieUnitsPerUnit;
3561
+ const height = getTickHeight(i) * graphieUnitsHeight;
3562
+
3563
+ const style = i === 0 || i === numTicks - 1 ? bold : light;
3564
+ set.push(graphie.line([x, bottom], [x, bottom + height], style));
3565
+
3566
+ if (n % 1 === 0) {
3567
+ const coord = graphie.scalePoint([x, top]);
3568
+ let text;
3569
+ let offset;
3570
+
3571
+ if (n === 0) {
3572
+ // Unit label
3573
+ text = options.label;
3574
+ offset =
3575
+ {
3576
+ mm: 13,
3577
+ cm: 11,
3578
+ m: 8,
3579
+ km: 11,
3580
+ in: 8,
3581
+ ft: 8,
3582
+ yd: 10,
3583
+ mi: 10,
3584
+ }[text] || 3 * text.toString().length;
3585
+ } else {
3586
+ // Tick label
3587
+ text = n;
3588
+ offset = -3 * (n.toString().length + 1);
3589
+ }
3590
+ const label = graphie.raphael.text(
3591
+ coord[0] + offset,
3592
+ coord[1] + 10,
3593
+ text,
3594
+ );
3595
+ label.attr({
3596
+ "font-family": "KaTeX_Main",
3597
+ "font-size": "12px",
3598
+ color: "#444",
3599
+ });
3600
+ set.push(label);
3601
+ }
3602
+ });
3603
+
3604
+ const mouseTarget = graphie.mouselayer.path(
3605
+ GraphUtils.svgPath([
3606
+ leftBottom,
3607
+ [left, top],
3608
+ rightTop,
3609
+ [right, bottom],
3610
+ /* closed */ true,
3611
+ ]),
3612
+ );
3613
+ mouseTarget.attr({
3614
+ fill: "#000",
3615
+ opacity: 0,
3616
+ stroke: "#000",
3617
+ "stroke-width": 2,
3618
+ });
3619
+ set.push(mouseTarget);
3620
+
3621
+ // Prevent the page from scrolling when we grab and drag the ruler on a
3622
+ // mobile device.
3623
+ mouseTarget.node.addEventListener(
3624
+ "touchstart",
3625
+ function (event) {
3626
+ event.preventDefault();
3627
+ },
3628
+ {passive: false},
3629
+ );
3630
+
3631
+ const setNodes = $.map(set, function (el) {
3632
+ return el.node;
3633
+ });
3634
+ $(setNodes).css("cursor", "move");
3635
+
3636
+ $(setNodes).bind("vmousedown", function (event) {
3637
+ event.preventDefault();
3638
+ let startx =
3639
+ event.pageX - $(graphie.raphael.canvas.parentNode).offset().left;
3640
+ let starty =
3641
+ event.pageY - $(graphie.raphael.canvas.parentNode).offset().top;
3642
+
3643
+ $(document).bind("vmousemove.ruler", function (event) {
3644
+ let mouseX =
3645
+ event.pageX -
3646
+ $(graphie.raphael.canvas.parentNode).offset().left;
3647
+ let mouseY =
3648
+ event.pageY - $(graphie.raphael.canvas.parentNode).offset().top;
3649
+ // can't go beyond 10 pixels from the edge
3650
+ mouseX = Math.max(10, Math.min(graphie.xpixels - 10, mouseX));
3651
+ mouseY = Math.max(10, Math.min(graphie.ypixels - 10, mouseY));
3652
+
3653
+ const dx = mouseX - startx;
3654
+ const dy = mouseY - starty;
3655
+
3656
+ set.translate(dx, dy);
3657
+ leftBottomHandle.setCoord([
3658
+ leftBottomHandle.coord[0] + dx / graphie.scale[0],
3659
+ leftBottomHandle.coord[1] - dy / graphie.scale[1],
3660
+ ]);
3661
+ rightBottomHandle.setCoord([
3662
+ rightBottomHandle.coord[0] + dx / graphie.scale[0],
3663
+ rightBottomHandle.coord[1] - dy / graphie.scale[1],
3664
+ ]);
3665
+
3666
+ startx = mouseX;
3667
+ starty = mouseY;
3668
+ });
3669
+
3670
+ $(document).one("vmouseup", function (event) {
3671
+ $(document).unbind("vmousemove.ruler");
3672
+ });
3673
+ });
3674
+
3675
+ const leftBottomHandle = graphie.addMovablePoint({
3676
+ coord: leftBottom,
3677
+ normalStyle: {
3678
+ fill: KhanColors.INTERACTIVE,
3679
+ "fill-opacity": 0,
3680
+ stroke: KhanColors.INTERACTIVE,
3681
+ },
3682
+ highlightStyle: {
3683
+ fill: KhanColors.INTERACTING,
3684
+ "fill-opacity": 0.1,
3685
+ stroke: KhanColors.INTERACTING,
3686
+ },
3687
+ pointSize: 6, // or 8 maybe?
3688
+ onMove: function (x, y) {
3689
+ const dy = rightBottomHandle.coord[1] - y;
3690
+ const dx = rightBottomHandle.coord[0] - x;
3691
+ const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
3692
+ const center = kvector.scale(
3693
+ kvector.add([x, y], rightBottomHandle.coord),
3694
+ 0.5,
3695
+ );
3696
+ const scaledCenter = graphie.scalePoint(center);
3697
+ const oldCenter = kvector.scale(
3698
+ kvector.add(leftBottomHandle.coord, rightBottomHandle.coord),
3699
+ 0.5,
3700
+ );
3701
+ const scaledOldCenter = graphie.scalePoint(oldCenter);
3702
+ const diff = kvector.subtract(scaledCenter, scaledOldCenter);
3703
+ set.rotate(-angle, scaledOldCenter[0], scaledOldCenter[1]);
3704
+ set.translate(diff[0], diff[1]);
3705
+ },
3706
+ });
3707
+ const rightBottomHandle = graphie.addMovablePoint({
3708
+ coord: [right, bottom],
3709
+ normalStyle: {
3710
+ fill: KhanColors.INTERACTIVE,
3711
+ "fill-opacity": 0,
3712
+ stroke: KhanColors.INTERACTIVE,
3713
+ },
3714
+ highlightStyle: {
3715
+ fill: KhanColors.INTERACTING,
3716
+ "fill-opacity": 0.1,
3717
+ stroke: KhanColors.INTERACTING,
3718
+ },
3719
+ pointSize: 6, // or 8 maybe?
3720
+ onMove: function (x, y) {
3721
+ const dy = y - leftBottomHandle.coord[1];
3722
+ const dx = x - leftBottomHandle.coord[0];
3723
+ const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
3724
+ const center = kvector.scale(
3725
+ kvector.add([x, y], leftBottomHandle.coord),
3726
+ 0.5,
3727
+ );
3728
+ const scaledCenter = graphie.scalePoint(center);
3729
+ const oldCenter = kvector.scale(
3730
+ kvector.add(leftBottomHandle.coord, rightBottomHandle.coord),
3731
+ 0.5,
3732
+ );
3733
+ const scaledOldCenter = graphie.scalePoint(oldCenter);
3734
+ const diff = kvector.subtract(scaledCenter, scaledOldCenter);
3735
+ set.rotate(-angle, scaledOldCenter[0], scaledOldCenter[1]);
3736
+ set.translate(diff[0], diff[1]);
3737
+ },
3738
+ });
3739
+
3740
+ // Make each handle rotate the ruler about the other one
3741
+ leftBottomHandle.constraints.fixedDistance.dist = width / graphie.scale[0];
3742
+ leftBottomHandle.constraints.fixedDistance.point = rightBottomHandle;
3743
+ rightBottomHandle.constraints.fixedDistance.dist = width / graphie.scale[0];
3744
+ rightBottomHandle.constraints.fixedDistance.point = leftBottomHandle;
3745
+
3746
+ this.remove = function () {
3747
+ set.remove();
3748
+ leftBottomHandle.remove();
3749
+ rightBottomHandle.remove();
3750
+ };
3751
+
3752
+ return this;
3753
+ }
3754
+
3755
+ function MovableAngle(graphie, options) {
3756
+ this.graphie = graphie;
3757
+
3758
+ // TODO(alex): Move standard colors from math.js to somewhere else
3759
+ // so that they are available when this file is first parsed
3760
+ _.extend(this, options);
3761
+ _.defaults(this, {
3762
+ normalStyle: {
3763
+ stroke: KhanColors.INTERACTIVE,
3764
+ "stroke-width": 2,
3765
+ fill: KhanColors.INTERACTIVE,
3766
+ },
3767
+ highlightStyle: {
3768
+ stroke: KhanColors.INTERACTING,
3769
+ "stroke-width": 2,
3770
+ fill: KhanColors.INTERACTING,
3771
+ },
3772
+ labelStyle: {
3773
+ stroke: KhanColors.DYNAMIC,
3774
+ "stroke-width": 1,
3775
+ color: KhanColors.DYNAMIC,
3776
+ },
3777
+ angleStyle: {
3778
+ stroke: KhanColors.DYNAMIC,
3779
+ "stroke-width": 1,
3780
+ color: KhanColors.DYNAMIC,
3781
+ },
3782
+ allowReflex: true, // not on MovableAngle.prototype so that
3783
+ // it is not overridden by undefined
3784
+ });
3785
+
3786
+ if (!this.points || this.points.length !== 3) {
3787
+ throw new PerseusError(
3788
+ "MovableAngle requires 3 points",
3789
+ Errors.InvalidInput,
3790
+ );
3791
+ }
3792
+
3793
+ // Handle coordinates that are not MovablePoints (i.e. [2, 4])
3794
+ this.points = _.map(
3795
+ options.points,
3796
+ function (point) {
3797
+ if (_.isArray(point)) {
3798
+ return graphie.addMovablePoint({
3799
+ coord: point,
3800
+ visible: false,
3801
+ constraints: {
3802
+ fixed: true,
3803
+ },
3804
+ normalStyle: this.normalStyle,
3805
+ });
3806
+ }
3807
+ return point;
3808
+ },
3809
+ this,
3810
+ );
3811
+ this.coords = _.pluck(this.points, "coord");
3812
+ if (this.reflex == null) {
3813
+ if (this.allowReflex) {
3814
+ this.reflex = this._getClockwiseAngle(this.coords) > 180;
3815
+ } else {
3816
+ this.reflex = false;
3817
+ }
3818
+ }
3819
+
3820
+ this.rays = _.map(
3821
+ [0, 2],
3822
+ function (i) {
3823
+ return graphie.addMovableLineSegment({
3824
+ pointA: this.points[1],
3825
+ pointZ: this.points[i],
3826
+ fixed: true,
3827
+ extendRay: true,
3828
+ });
3829
+ },
3830
+ this,
3831
+ );
3832
+
3833
+ this.temp = [];
3834
+ this.labeledAngle = graphie.label([0, 0], "", "center", this.labelStyle);
3835
+
3836
+ if (!this.fixed) {
3837
+ this.addMoveHandlers();
3838
+ this.addHighlightHandlers();
3839
+ }
3840
+ this.update();
3841
+ }
3842
+
3843
+ _.extend(MovableAngle.prototype, {
3844
+ points: [],
3845
+ snapDegrees: 0,
3846
+ snapOffsetDeg: 0,
3847
+ angleLabel: "",
3848
+ numArcs: 1,
3849
+ pushOut: 0,
3850
+ fixed: false,
3851
+
3852
+ addMoveHandlers: function () {
3853
+ const graphie = this.graphie;
3854
+
3855
+ function tooClose(point1, point2) {
3856
+ const safeDistance = 30;
3857
+ const distance = GraphUtils.getDistance(
3858
+ graphie.scalePoint(point1),
3859
+ graphie.scalePoint(point2),
3860
+ );
3861
+ return distance < safeDistance;
3862
+ }
3863
+
3864
+ const points = this.points;
3865
+
3866
+ // Drag the vertex to move the entire angle
3867
+ points[1].onMove = function (x, y) {
3868
+ const oldVertex = points[1].coord;
3869
+ const newVertex = [x, y];
3870
+ const delta = addPoints(newVertex, reverseVector(oldVertex));
3871
+
3872
+ let valid = true;
3873
+ const newPoints = {};
3874
+ _.each([0, 2], function (i) {
3875
+ const oldPoint = points[i].coord;
3876
+ let newPoint = addPoints(oldPoint, delta);
3877
+
3878
+ let angle = GraphUtils.findAngle(newVertex, newPoint);
3879
+ angle *= Math.PI / 180;
3880
+ newPoint = graphie.constrainToBoundsOnAngle(
3881
+ newPoint,
3882
+ 10,
3883
+ angle,
3884
+ );
3885
+ newPoints[i] = newPoint;
3886
+
3887
+ if (tooClose(newVertex, newPoint)) {
3888
+ valid = false;
3889
+ }
3890
+ });
3891
+
3892
+ // Only move points if all new positions are valid
3893
+ if (valid) {
3894
+ _.each(newPoints, function (newPoint, i) {
3895
+ points[i].setCoord(newPoint);
3896
+ });
3897
+ }
3898
+ return valid;
3899
+ };
3900
+
3901
+ const snap = this.snapDegrees;
3902
+ const snapOffset = this.snapOffsetDeg;
3903
+
3904
+ // Drag ray control points to move each ray individually
3905
+ _.each([0, 2], function (i) {
3906
+ points[i].onMove = function (x, y) {
3907
+ const newPoint = [x, y];
3908
+ const vertex = points[1].coord;
3909
+
3910
+ if (tooClose(vertex, newPoint)) {
3911
+ return false;
3912
+ }
3913
+ if (snap) {
3914
+ let angle = GraphUtils.findAngle(newPoint, vertex);
3915
+ angle =
3916
+ Math.round((angle - snapOffset) / snap) * snap +
3917
+ snapOffset;
3918
+ const distance = GraphUtils.getDistance(newPoint, vertex);
3919
+ return addPoints(vertex, graphie.polar(distance, angle));
3920
+ }
3921
+ return true;
3922
+ };
3923
+ });
3924
+
3925
+ // Expose only a single move event
3926
+ $(points).on(
3927
+ "move",
3928
+ function () {
3929
+ this.update();
3930
+ $(this).trigger("move");
3931
+ }.bind(this),
3932
+ );
3933
+ },
3934
+
3935
+ addHighlightHandlers: function () {
3936
+ const vertex = this.points[1];
3937
+
3938
+ vertex.onHighlight = function () {
3939
+ _.each(
3940
+ this.points,
3941
+ function (point) {
3942
+ point.visibleShape.animate(this.highlightStyle, 50);
3943
+ },
3944
+ this,
3945
+ );
3946
+ _.each(
3947
+ this.rays,
3948
+ function (ray) {
3949
+ ray.visibleLine.animate(this.highlightStyle, 50);
3950
+ ray.arrowStyle = _.extend({}, ray.arrowStyle, {
3951
+ color: this.highlightStyle.stroke,
3952
+ stroke: this.highlightStyle.stroke,
3953
+ });
3954
+ },
3955
+ this,
3956
+ );
3957
+
3958
+ this.angleStyle = _.extend({}, this.angleStyle, {
3959
+ color: this.highlightStyle.stroke,
3960
+ stroke: this.highlightStyle.stroke,
3961
+ });
3962
+ this.update();
3963
+ }.bind(this);
3964
+
3965
+ vertex.onUnhighlight = function () {
3966
+ _.each(
3967
+ this.points,
3968
+ function (point) {
3969
+ point.visibleShape.animate(this.normalStyle, 50);
3970
+ },
3971
+ this,
3972
+ );
3973
+ _.each(
3974
+ this.rays,
3975
+ function (ray) {
3976
+ ray.visibleLine.animate(ray.normalStyle, 50);
3977
+ ray.arrowStyle = _.extend({}, ray.arrowStyle, {
3978
+ color: ray.normalStyle.stroke,
3979
+ stroke: ray.normalStyle.stroke,
3980
+ });
3981
+ },
3982
+ this,
3983
+ );
3984
+
3985
+ this.angleStyle = _.extend({}, this.angleStyle, {
3986
+ color: KhanColors.DYNAMIC,
3987
+ stroke: KhanColors.DYNAMIC,
3988
+ });
3989
+ this.update();
3990
+ }.bind(this);
3991
+ },
3992
+
3993
+ /**
3994
+ * Returns the angle in [0, 360) degrees created by the
3995
+ * coords when interpreted in a clockwise direction.
3996
+ */
3997
+ _getClockwiseAngle: function (coords) {
3998
+ const rawAngle = GraphUtils.findAngle(
3999
+ // The order of these is "weird" to match what a clockwise
4000
+ // order is in graphie.labelAngle
4001
+ coords[2], // from the second point
4002
+ coords[0], // clockwise to the first point
4003
+ coords[1], // the vertex parameter is last
4004
+ );
4005
+ const clockwiseAngle = rawAngle + (360 % 360);
4006
+
4007
+ return clockwiseAngle;
4008
+ },
4009
+
4010
+ isReflex: function () {
4011
+ return this.reflex;
4012
+ },
4013
+
4014
+ isClockwise: function () {
4015
+ const clockwiseReflexive = this._getClockwiseAngle(this.coords) > 180;
4016
+ return clockwiseReflexive === this.reflex;
4017
+ },
4018
+
4019
+ getClockwiseCoords: function () {
4020
+ if (this.isClockwise()) {
4021
+ return _.clone(this.coords);
4022
+ }
4023
+ return _.clone(this.coords).reverse();
4024
+ },
4025
+
4026
+ update: function (shouldChangeReflexivity) {
4027
+ const prevCoords = this.coords;
4028
+ this.coords = _.pluck(this.points, "coord");
4029
+
4030
+ // Update lines
4031
+ _.invoke(this.points, "updateLineEnds");
4032
+
4033
+ const prevAngle = this._getClockwiseAngle(prevCoords);
4034
+ const angle = this._getClockwiseAngle(this.coords);
4035
+ const prevClockwiseReflexive = prevAngle > 180;
4036
+ const clockwiseReflexive = angle > 180;
4037
+
4038
+ if (this.allowReflex) {
4039
+ if (shouldChangeReflexivity == null) {
4040
+ shouldChangeReflexivity =
4041
+ prevClockwiseReflexive !== clockwiseReflexive &&
4042
+ Math.abs(angle - prevAngle) < 180;
4043
+ }
4044
+
4045
+ if (shouldChangeReflexivity) {
4046
+ this.reflex = !this.reflex;
4047
+ }
4048
+ }
4049
+
4050
+ _.invoke(this.temp, "remove");
4051
+ this.temp = this.graphie.labelAngle({
4052
+ point1: this.coords[0],
4053
+ vertex: this.coords[1],
4054
+ point3: this.coords[2],
4055
+ label: this.labeledAngle,
4056
+ text: this.angleLabel,
4057
+ numArcs: this.numArcs,
4058
+ pushOut: this.pushOut,
4059
+ clockwise: this.reflex === clockwiseReflexive,
4060
+ style: this.angleStyle,
4061
+ });
4062
+ },
4063
+
4064
+ remove: function () {
4065
+ _.invoke(this.rays, "remove");
4066
+ _.invoke(this.temp, "remove");
4067
+ this.labeledAngle.remove();
4068
+ },
4069
+ });
4070
+
4071
+ export default InteractiveUtils;