coursecode 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 (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/THIRD_PARTY_NOTICES.md +22 -0
  4. package/bin/cli.js +331 -0
  5. package/framework/assets/logo-coursecode-black.svg +14 -0
  6. package/framework/assets/logo-coursecode-white.svg +14 -0
  7. package/framework/assets/logo-coursecode.svg +14 -0
  8. package/framework/css/01-base.css +160 -0
  9. package/framework/css/02-layout.css +499 -0
  10. package/framework/css/accessibility.css +834 -0
  11. package/framework/css/components/accordions.css +710 -0
  12. package/framework/css/components/assessments.css +520 -0
  13. package/framework/css/components/audio-player.css +570 -0
  14. package/framework/css/components/badges.css +80 -0
  15. package/framework/css/components/breadcrumbs.css +87 -0
  16. package/framework/css/components/buttons.css +707 -0
  17. package/framework/css/components/callouts.css +1280 -0
  18. package/framework/css/components/cards.css +475 -0
  19. package/framework/css/components/carousel.css +193 -0
  20. package/framework/css/components/checkbox-group.css +123 -0
  21. package/framework/css/components/checklist.css +203 -0
  22. package/framework/css/components/collapse.css +96 -0
  23. package/framework/css/components/comparison.css +33 -0
  24. package/framework/css/components/content-image.css +36 -0
  25. package/framework/css/components/document-gallery.css +425 -0
  26. package/framework/css/components/dropdown.css +115 -0
  27. package/framework/css/components/embed-frame.css +142 -0
  28. package/framework/css/components/engagement.css +412 -0
  29. package/framework/css/components/features.css +35 -0
  30. package/framework/css/components/flip-cards.css +253 -0
  31. package/framework/css/components/footer.css +353 -0
  32. package/framework/css/components/forms.css +294 -0
  33. package/framework/css/components/hero.css +216 -0
  34. package/framework/css/components/images.css +528 -0
  35. package/framework/css/components/interactive-timeline.css +274 -0
  36. package/framework/css/components/intro-cards.css +30 -0
  37. package/framework/css/components/lightbox.css +666 -0
  38. package/framework/css/components/loading.css +65 -0
  39. package/framework/css/components/modals.css +235 -0
  40. package/framework/css/components/notifications.css +107 -0
  41. package/framework/css/components/quote.css +150 -0
  42. package/framework/css/components/sidebar.css +684 -0
  43. package/framework/css/components/slide-header.css +52 -0
  44. package/framework/css/components/spinner.css +62 -0
  45. package/framework/css/components/stats.css +44 -0
  46. package/framework/css/components/steps.css +232 -0
  47. package/framework/css/components/tables.css +90 -0
  48. package/framework/css/components/tabs.css +347 -0
  49. package/framework/css/components/timeline.css +154 -0
  50. package/framework/css/components/toggle.css +95 -0
  51. package/framework/css/components/tooltip.css +226 -0
  52. package/framework/css/components/video-player.css +438 -0
  53. package/framework/css/design-tokens.css +707 -0
  54. package/framework/css/framework.css +86 -0
  55. package/framework/css/interactions/accessibility.css +75 -0
  56. package/framework/css/interactions/base.css +92 -0
  57. package/framework/css/interactions/drag-drop.css +295 -0
  58. package/framework/css/interactions/fill-in-the-blank.css +236 -0
  59. package/framework/css/interactions/hotspots.css +69 -0
  60. package/framework/css/interactions/index.css +45 -0
  61. package/framework/css/interactions/interactive-image.css +359 -0
  62. package/framework/css/interactions/likert.css +126 -0
  63. package/framework/css/interactions/matching.css +354 -0
  64. package/framework/css/interactions/numeric-input.css +78 -0
  65. package/framework/css/interactions/sequencing.css +378 -0
  66. package/framework/css/interactions/true-false.css +177 -0
  67. package/framework/css/layouts/article.css +258 -0
  68. package/framework/css/layouts/base.css +30 -0
  69. package/framework/css/layouts/canvas.css +38 -0
  70. package/framework/css/layouts/focused.css +236 -0
  71. package/framework/css/layouts/index.css +29 -0
  72. package/framework/css/layouts/presentation.css +191 -0
  73. package/framework/css/layouts/traditional.css +52 -0
  74. package/framework/css/responsive.css +439 -0
  75. package/framework/css/utilities/accessibility-utils.css +59 -0
  76. package/framework/css/utilities/animations.css +419 -0
  77. package/framework/css/utilities/borders.css +72 -0
  78. package/framework/css/utilities/colors.css +76 -0
  79. package/framework/css/utilities/container.css +46 -0
  80. package/framework/css/utilities/decorative.css +442 -0
  81. package/framework/css/utilities/display.css +257 -0
  82. package/framework/css/utilities/flexbox.css +80 -0
  83. package/framework/css/utilities/grid.css +69 -0
  84. package/framework/css/utilities/icons.css +534 -0
  85. package/framework/css/utilities/lists.css +190 -0
  86. package/framework/css/utilities/spacing.css +167 -0
  87. package/framework/css/utilities/tables.css +81 -0
  88. package/framework/css/utilities/typography.css +159 -0
  89. package/framework/css/utilities/visibility.css +117 -0
  90. package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
  91. package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
  92. package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
  93. package/framework/docs/DATA_MODEL.md +409 -0
  94. package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
  95. package/framework/docs/USER_GUIDE.md +583 -0
  96. package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
  97. package/framework/docs/examples/cloudflare-data-worker.js +102 -0
  98. package/framework/docs/examples/cloudflare-error-worker.js +228 -0
  99. package/framework/index.html +175 -0
  100. package/framework/js/app/AppActions.js +410 -0
  101. package/framework/js/app/AppState.js +225 -0
  102. package/framework/js/app/AppUI.js +616 -0
  103. package/framework/js/assessment/AssessmentActions.js +615 -0
  104. package/framework/js/assessment/AssessmentFactory.js +471 -0
  105. package/framework/js/assessment/AssessmentState.js +322 -0
  106. package/framework/js/assessment/AssessmentUI.js +451 -0
  107. package/framework/js/automation/api-engagement.js +196 -0
  108. package/framework/js/automation/api-interactions.js +167 -0
  109. package/framework/js/automation/api.js +242 -0
  110. package/framework/js/automation/index.js +41 -0
  111. package/framework/js/components/interactions/drag-drop.js +884 -0
  112. package/framework/js/components/interactions/fill-in.js +535 -0
  113. package/framework/js/components/interactions/hotspot.js +702 -0
  114. package/framework/js/components/interactions/interaction-base.js +511 -0
  115. package/framework/js/components/interactions/likert.js +301 -0
  116. package/framework/js/components/interactions/matching.js +699 -0
  117. package/framework/js/components/interactions/multiple-choice.js +377 -0
  118. package/framework/js/components/interactions/numeric.js +271 -0
  119. package/framework/js/components/interactions/sequencing.js +423 -0
  120. package/framework/js/components/interactions/true-false.js +241 -0
  121. package/framework/js/components/ui-components/accordion.js +442 -0
  122. package/framework/js/components/ui-components/alert.js +88 -0
  123. package/framework/js/components/ui-components/audio-player.js +1193 -0
  124. package/framework/js/components/ui-components/callout.js +121 -0
  125. package/framework/js/components/ui-components/carousel.js +145 -0
  126. package/framework/js/components/ui-components/checkbox-group.js +87 -0
  127. package/framework/js/components/ui-components/checklist.js +40 -0
  128. package/framework/js/components/ui-components/collapse.js +114 -0
  129. package/framework/js/components/ui-components/comparison.js +30 -0
  130. package/framework/js/components/ui-components/conditional-display.js +150 -0
  131. package/framework/js/components/ui-components/content-image.js +41 -0
  132. package/framework/js/components/ui-components/dropdown.js +262 -0
  133. package/framework/js/components/ui-components/embed-frame.js +274 -0
  134. package/framework/js/components/ui-components/features.js +33 -0
  135. package/framework/js/components/ui-components/flip-card.js +230 -0
  136. package/framework/js/components/ui-components/form-validator.js +76 -0
  137. package/framework/js/components/ui-components/hero.js +49 -0
  138. package/framework/js/components/ui-components/index.js +12 -0
  139. package/framework/js/components/ui-components/interactive-image.js +235 -0
  140. package/framework/js/components/ui-components/interactive-timeline.js +285 -0
  141. package/framework/js/components/ui-components/intro-cards.js +35 -0
  142. package/framework/js/components/ui-components/lightbox.js +652 -0
  143. package/framework/js/components/ui-components/modal.js +386 -0
  144. package/framework/js/components/ui-components/notifications.js +145 -0
  145. package/framework/js/components/ui-components/progress.js +88 -0
  146. package/framework/js/components/ui-components/quote.js +41 -0
  147. package/framework/js/components/ui-components/stats.js +33 -0
  148. package/framework/js/components/ui-components/steps.js +41 -0
  149. package/framework/js/components/ui-components/tabs.js +255 -0
  150. package/framework/js/components/ui-components/timeline.js +42 -0
  151. package/framework/js/components/ui-components/toggle-group.js +73 -0
  152. package/framework/js/components/ui-components/tooltip.js +458 -0
  153. package/framework/js/components/ui-components/value-display.js +133 -0
  154. package/framework/js/components/ui-components/video-player.js +686 -0
  155. package/framework/js/core/component-catalog.js +121 -0
  156. package/framework/js/core/event-bus.js +178 -0
  157. package/framework/js/core/interaction-catalog.js +149 -0
  158. package/framework/js/dev/runtime-linter.js +1725 -0
  159. package/framework/js/drivers/cmi5-driver.js +768 -0
  160. package/framework/js/drivers/driver-factory.js +77 -0
  161. package/framework/js/drivers/driver-interface.js +110 -0
  162. package/framework/js/drivers/http-driver-base.js +241 -0
  163. package/framework/js/drivers/lti-driver.js +508 -0
  164. package/framework/js/drivers/proxy-driver.js +444 -0
  165. package/framework/js/drivers/scorm-12-driver.js +560 -0
  166. package/framework/js/drivers/scorm-2004-driver.js +775 -0
  167. package/framework/js/drivers/scorm-driver-base.js +112 -0
  168. package/framework/js/engagement/engagement-manager.js +404 -0
  169. package/framework/js/engagement/engagement-progress.js +191 -0
  170. package/framework/js/engagement/engagement-trackers.js +215 -0
  171. package/framework/js/engagement/requirement-strategies.js +268 -0
  172. package/framework/js/main.js +727 -0
  173. package/framework/js/managers/accessibility-manager.js +499 -0
  174. package/framework/js/managers/assessment-manager.js +230 -0
  175. package/framework/js/managers/audio-manager.js +944 -0
  176. package/framework/js/managers/comment-manager.js +88 -0
  177. package/framework/js/managers/flag-manager.js +86 -0
  178. package/framework/js/managers/interaction-manager.js +254 -0
  179. package/framework/js/managers/interaction-registry.js +96 -0
  180. package/framework/js/managers/objective-manager.js +423 -0
  181. package/framework/js/managers/score-manager.js +441 -0
  182. package/framework/js/managers/video-manager.js +536 -0
  183. package/framework/js/navigation/Breadcrumbs.js +234 -0
  184. package/framework/js/navigation/NavigationActions.js +1132 -0
  185. package/framework/js/navigation/NavigationState.js +276 -0
  186. package/framework/js/navigation/NavigationUI.js +574 -0
  187. package/framework/js/navigation/document-gallery.js +357 -0
  188. package/framework/js/navigation/navigation-helpers.js +175 -0
  189. package/framework/js/navigation/navigation-validators.js +174 -0
  190. package/framework/js/state/index.js +8 -0
  191. package/framework/js/state/lms-connection.js +482 -0
  192. package/framework/js/state/lms-error-utils.js +58 -0
  193. package/framework/js/state/state-commits.js +200 -0
  194. package/framework/js/state/state-domains.js +86 -0
  195. package/framework/js/state/state-manager.js +502 -0
  196. package/framework/js/state/state-validation.js +311 -0
  197. package/framework/js/state/transaction-log.js +41 -0
  198. package/framework/js/state/xapi-statement-service.js +325 -0
  199. package/framework/js/utilities/access-control.js +99 -0
  200. package/framework/js/utilities/breakpoint-manager.js +315 -0
  201. package/framework/js/utilities/canvas-slide.js +35 -0
  202. package/framework/js/utilities/conditional-display.js +388 -0
  203. package/framework/js/utilities/course-channel.js +214 -0
  204. package/framework/js/utilities/course-helpers.js +420 -0
  205. package/framework/js/utilities/data-reporter.js +273 -0
  206. package/framework/js/utilities/error-reporter.js +313 -0
  207. package/framework/js/utilities/hotspot-helper.js +341 -0
  208. package/framework/js/utilities/icons.js +348 -0
  209. package/framework/js/utilities/logger.js +92 -0
  210. package/framework/js/utilities/markdown-renderer.js +45 -0
  211. package/framework/js/utilities/scroll-tracker.js +68 -0
  212. package/framework/js/utilities/ui-initializer.js +146 -0
  213. package/framework/js/utilities/utilities.js +293 -0
  214. package/framework/js/utilities/view-manager.js +227 -0
  215. package/framework/js/validation/html-validators.js +422 -0
  216. package/framework/js/validation/scorm-validators.js +438 -0
  217. package/framework/js/vendor/pipwerks.js +931 -0
  218. package/framework/scripts/generate-narration.js +629 -0
  219. package/framework/scripts/tts-providers/azure-provider.js +178 -0
  220. package/framework/scripts/tts-providers/base-provider.js +81 -0
  221. package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
  222. package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
  223. package/framework/scripts/tts-providers/google-provider.js +272 -0
  224. package/framework/scripts/tts-providers/index.js +158 -0
  225. package/framework/scripts/tts-providers/openai-provider.js +143 -0
  226. package/framework/version.json +63 -0
  227. package/lib/authoring-api.js +919 -0
  228. package/lib/build-linter.js +450 -0
  229. package/lib/build-packaging.js +186 -0
  230. package/lib/build.js +88 -0
  231. package/lib/cloud.js +691 -0
  232. package/lib/convert.js +341 -0
  233. package/lib/course-parser.js +936 -0
  234. package/lib/course-writer.js +258 -0
  235. package/lib/create.js +248 -0
  236. package/lib/css-index.js +237 -0
  237. package/lib/dev.js +51 -0
  238. package/lib/export-content.js +1246 -0
  239. package/lib/headless-browser.js +413 -0
  240. package/lib/import.js +377 -0
  241. package/lib/index.js +80 -0
  242. package/lib/info.js +79 -0
  243. package/lib/interaction-formatters.js +568 -0
  244. package/lib/manifest/cmi5-manifest.js +63 -0
  245. package/lib/manifest/lti-tool-config.js +53 -0
  246. package/lib/manifest/manifest-factory.js +99 -0
  247. package/lib/manifest/scorm-12-manifest.js +61 -0
  248. package/lib/manifest/scorm-2004-manifest.js +94 -0
  249. package/lib/manifest/scorm-proxy-manifest.js +104 -0
  250. package/lib/manifest-parser.js +96 -0
  251. package/lib/mcp-prompts.js +753 -0
  252. package/lib/mcp-server.js +316 -0
  253. package/lib/narration.js +53 -0
  254. package/lib/pdf-structure.js +142 -0
  255. package/lib/preview-export.js +231 -0
  256. package/lib/preview-routes-api.js +662 -0
  257. package/lib/preview-routes-editing.js +159 -0
  258. package/lib/preview-routes-lms.js +230 -0
  259. package/lib/preview-server.js +564 -0
  260. package/lib/project-utils.js +269 -0
  261. package/lib/proxy-templates/proxy.html +68 -0
  262. package/lib/proxy-templates/scorm-bridge.js +112 -0
  263. package/lib/scaffold.js +193 -0
  264. package/lib/schema-extractor.js +361 -0
  265. package/lib/slide-source-editor.js +586 -0
  266. package/lib/stub-player/app-viewer.js +195 -0
  267. package/lib/stub-player/app.js +370 -0
  268. package/lib/stub-player/catalog-panel.js +312 -0
  269. package/lib/stub-player/config-panel.js +1303 -0
  270. package/lib/stub-player/content-generator.js +586 -0
  271. package/lib/stub-player/content-viewer.js +173 -0
  272. package/lib/stub-player/debug-panel.js +420 -0
  273. package/lib/stub-player/edit-mode.js +922 -0
  274. package/lib/stub-player/edit-utils.js +400 -0
  275. package/lib/stub-player/header-bar.js +354 -0
  276. package/lib/stub-player/interaction-editor.js +210 -0
  277. package/lib/stub-player/interactions-panel.js +565 -0
  278. package/lib/stub-player/lms-api.js +1094 -0
  279. package/lib/stub-player/login-screen.js +74 -0
  280. package/lib/stub-player/outline-mode.js +689 -0
  281. package/lib/stub-player/styles/_assessments-panel.css +245 -0
  282. package/lib/stub-player/styles/_base.css +89 -0
  283. package/lib/stub-player/styles/_catalog-icons.css +96 -0
  284. package/lib/stub-player/styles/_catalog-panel.css +291 -0
  285. package/lib/stub-player/styles/_config-panel.css +636 -0
  286. package/lib/stub-player/styles/_content-viewer.css +834 -0
  287. package/lib/stub-player/styles/_debug-panel.css +576 -0
  288. package/lib/stub-player/styles/_edit-mode.css +128 -0
  289. package/lib/stub-player/styles/_header-bar.css +343 -0
  290. package/lib/stub-player/styles/_interaction-editor.css +140 -0
  291. package/lib/stub-player/styles/_interactions-panel.css +1038 -0
  292. package/lib/stub-player/styles/_login-screen.css +102 -0
  293. package/lib/stub-player/styles/_outline-mode.css +752 -0
  294. package/lib/stub-player/styles.css +15 -0
  295. package/lib/stub-player.js +160 -0
  296. package/lib/test-data-reporting.js +176 -0
  297. package/lib/test-error-reporting.js +146 -0
  298. package/lib/token.js +86 -0
  299. package/lib/upgrade.js +257 -0
  300. package/lib/validation-rules.js +517 -0
  301. package/lib/vite-plugin-content-discovery.js +296 -0
  302. package/package.json +108 -0
  303. package/schemas/XMLSchema.dtd +402 -0
  304. package/schemas/adlcp_v1p3.xsd +111 -0
  305. package/schemas/adlnav_v1p3.xsd +61 -0
  306. package/schemas/adlseq_v1p3.xsd +93 -0
  307. package/schemas/common/anyElement.xsd +27 -0
  308. package/schemas/common/dataTypes.xsd +138 -0
  309. package/schemas/common/elementNames.xsd +767 -0
  310. package/schemas/common/elementTypes.xsd +786 -0
  311. package/schemas/common/rootElement.xsd +31 -0
  312. package/schemas/common/vocabTypes.xsd +345 -0
  313. package/schemas/common/vocabValues.xsd +257 -0
  314. package/schemas/datatypes.dtd +203 -0
  315. package/schemas/ims_xml.xsd +35 -0
  316. package/schemas/imscp_v1p1.xsd +368 -0
  317. package/schemas/imsss_v1p0.xsd +67 -0
  318. package/schemas/imsss_v1p0auxresource.xsd +19 -0
  319. package/schemas/imsss_v1p0control.xsd +20 -0
  320. package/schemas/imsss_v1p0delivery.xsd +17 -0
  321. package/schemas/imsss_v1p0limit.xsd +47 -0
  322. package/schemas/imsss_v1p0objective.xsd +67 -0
  323. package/schemas/imsss_v1p0random.xsd +16 -0
  324. package/schemas/imsss_v1p0rollup.xsd +46 -0
  325. package/schemas/imsss_v1p0seqrule.xsd +108 -0
  326. package/schemas/imsss_v1p0util.xsd +94 -0
  327. package/schemas/license.txt +17 -0
  328. package/schemas/lom.xsd +102 -0
  329. package/schemas/lomCustom.xsd +62 -0
  330. package/schemas/lomLoose.xsd +62 -0
  331. package/schemas/lomStrict.xsd +62 -0
  332. package/schemas/xml.xsd +81 -0
  333. package/template/.env.example +92 -0
  334. package/template/course/assets/audio/example-intro.mp3 +0 -0
  335. package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
  336. package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
  337. package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
  338. package/template/course/assets/docs/example_md_1.md +39 -0
  339. package/template/course/assets/docs/example_md_2.md +41 -0
  340. package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
  341. package/template/course/assets/docs/example_pdf_2.pdf +0 -0
  342. package/template/course/assets/images/course-architecture.svg +36 -0
  343. package/template/course/assets/images/logo.svg +14 -0
  344. package/template/course/assets/widgets/counter-demo.html +190 -0
  345. package/template/course/assets/widgets/gravity-painter.html +384 -0
  346. package/template/course/course-config.js +539 -0
  347. package/template/course/icons.js +19 -0
  348. package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
  349. package/template/course/slides/example-course-structure.js +138 -0
  350. package/template/course/slides/example-final-exam.js +144 -0
  351. package/template/course/slides/example-finishing.js +127 -0
  352. package/template/course/slides/example-interactions-showcase.js +615 -0
  353. package/template/course/slides/example-preview-tour.js +129 -0
  354. package/template/course/slides/example-remedial.js +143 -0
  355. package/template/course/slides/example-summary.js +103 -0
  356. package/template/course/slides/example-ui-showcase.js +1805 -0
  357. package/template/course/slides/example-welcome.js +123 -0
  358. package/template/course/slides/example-workflow.js +140 -0
  359. package/template/course/theme.css +165 -0
  360. package/template/eslint.config.js +47 -0
  361. package/template/package.json +28 -0
  362. package/template/vite.config.js +339 -0
@@ -0,0 +1,702 @@
1
+ import {
2
+ validateAgainstSchema,
3
+ createInteractionEventHandler,
4
+ renderInteractionControls,
5
+ displayFeedback,
6
+ clearFeedback,
7
+ validateContainer,
8
+ parseResponse,
9
+ recordInteractionResult,
10
+ registerCoreInteraction
11
+ } from './interaction-base.js';
12
+ import { logger } from '../../utilities/logger.js';
13
+
14
+ // Default appearance themes for hotspots
15
+ const HOTSPOT_DEFAULTS = {
16
+ appearance: {
17
+ correct: {
18
+ default: { fill: 'rgba(187, 247, 208, 0.3)', stroke: '#15803d', strokeWidth: 3 },
19
+ hover: { fill: 'rgba(187, 247, 208, 0.5)', stroke: '#15803d', strokeWidth: 4 },
20
+ selected: { fill: 'rgba(0, 200, 0, 0.3)', stroke: '#00cc00', strokeWidth: 4 }
21
+ },
22
+ incorrect: {
23
+ default: { fill: 'rgba(254, 205, 211, 0.3)', stroke: '#be123c', strokeWidth: 3 },
24
+ hover: { fill: 'rgba(254, 205, 211, 0.5)', stroke: '#be123c', strokeWidth: 4 },
25
+ selected: { fill: 'rgba(255, 0, 0, 0.3)', stroke: '#cc0000', strokeWidth: 4 }
26
+ },
27
+ primary: {
28
+ default: { fill: 'rgba(199, 210, 254, 0.3)', stroke: '#4338ca', strokeWidth: 3 },
29
+ hover: { fill: 'rgba(199, 210, 254, 0.5)', stroke: '#4338ca', strokeWidth: 4 },
30
+ selected: { fill: 'rgba(0, 200, 0, 0.3)', stroke: '#00cc00', strokeWidth: 4 }
31
+ },
32
+ accent: {
33
+ default: { fill: 'rgba(186, 230, 253, 0.3)', stroke: '#0369a1', strokeWidth: 3 },
34
+ hover: { fill: 'rgba(186, 230, 253, 0.5)', stroke: '#0369a1', strokeWidth: 4 },
35
+ selected: { fill: 'rgba(0, 200, 0, 0.3)', stroke: '#00cc00', strokeWidth: 4 }
36
+ }
37
+ }
38
+ };
39
+
40
+ /**
41
+ * Expands minimal hotspot syntax into full configuration.
42
+ * Supports shorthand properties with the ability to override any detail.
43
+ *
44
+ * Minimal syntax:
45
+ * { id, pos: [x, y, width, height], correct, label, feedback }
46
+ *
47
+ * Override examples:
48
+ * { id, pos: [x, y, w, h], correct, label, feedback, theme: 'primary' }
49
+ * { id, pos: [x, y, w, h], correct, label, feedback, appearance: { ... } }
50
+ * { id, position: { x, y, width, height }, ... } // Full syntax still works
51
+ */
52
+ export function expandHotspot(hotspot) {
53
+ // If already using full syntax, return as-is
54
+ if (hotspot.position && !hotspot.pos) {
55
+ return hotspot;
56
+ }
57
+
58
+ // Convert pos array to position object if provided
59
+ const position = hotspot.pos
60
+ ? { x: hotspot.pos[0], y: hotspot.pos[1], width: hotspot.pos[2], height: hotspot.pos[3] }
61
+ : hotspot.position;
62
+
63
+ // Determine appearance theme
64
+ let appearance;
65
+ if (hotspot.appearance) {
66
+ // Use explicit appearance override
67
+ appearance = hotspot.appearance;
68
+ } else if (hotspot.theme && HOTSPOT_DEFAULTS.appearance[hotspot.theme]) {
69
+ // Use named theme
70
+ appearance = HOTSPOT_DEFAULTS.appearance[hotspot.theme];
71
+ } else {
72
+ // Use default based on correctness
73
+ appearance = hotspot.correct
74
+ ? HOTSPOT_DEFAULTS.appearance.correct
75
+ : HOTSPOT_DEFAULTS.appearance.incorrect;
76
+ }
77
+
78
+ // Expand feedback shorthand
79
+ const feedback = typeof hotspot.feedback === 'string'
80
+ ? { onSelect: hotspot.feedback, onDeselect: null }
81
+ : hotspot.feedback || { onSelect: null, onDeselect: null };
82
+
83
+ return {
84
+ id: hotspot.id,
85
+ shape: hotspot.shape || 'rectangle',
86
+ position,
87
+ correct: hotspot.correct,
88
+ label: hotspot.label,
89
+ appearance,
90
+ feedback
91
+ };
92
+ }
93
+
94
+ // Metadata for hotspot interaction type
95
+ export const metadata = {
96
+ creator: 'createHotspotQuestion',
97
+ scormType: 'other',
98
+ showCheckAnswer: true,
99
+ isAnswered: (response) => {
100
+ return Array.isArray(response) && response.length > 0;
101
+ },
102
+ getCorrectAnswer: (config) => {
103
+ const normalizedHotspots = normalizeHotspots(config?.hotspots, config?.id);
104
+ if (!normalizedHotspots.length) {
105
+ return '';
106
+ }
107
+ const correctIds = normalizedHotspots.filter(h => h.correct).map(h => h.id);
108
+ return correctIds.length ? JSON.stringify(correctIds) : '';
109
+ },
110
+ formatCorrectAnswer: (question, correctAnswer) => {
111
+ const normalizedHotspots = normalizeHotspots(question?.hotspots, question?.id);
112
+ const byId = new Map(normalizedHotspots.map(spot => [spot.id, spot]));
113
+ const validIds = new Set(byId.keys());
114
+ const providedCorrectIds = sanitizeSelection(correctAnswer, validIds);
115
+ const resolvedSpots = (providedCorrectIds.length ? providedCorrectIds : normalizedHotspots.filter(h => h.correct))
116
+ .map(id => byId.get(id))
117
+ .filter(Boolean);
118
+
119
+ if (!resolvedSpots.length) {
120
+ return '<p class="correct-item">No correct hotspots configured</p>';
121
+ }
122
+
123
+ return '<ul class="list-disc pl-4 m-0">' + resolvedSpots.map(spot => `<li class="correct-item">${spot.label}</li>`).join('') + '</ul>';
124
+ },
125
+ formatUserResponse: (question, response) => {
126
+ const normalizedHotspots = normalizeHotspots(question?.hotspots, question?.id);
127
+ const byId = new Map(normalizedHotspots.map(spot => [spot.id, spot]));
128
+ const selections = sanitizeSelection(response, new Set(byId.keys()));
129
+
130
+ if (!selections.length) {
131
+ return '<p class="response-item">No hotspots selected</p>';
132
+ }
133
+
134
+ return '<ul class="list-disc pl-4 m-0">' + selections.map(id => {
135
+ const spot = byId.get(id);
136
+ return `<li class="response-item">${spot ? spot.label : id}</li>`;
137
+ }).join('') + '</ul>';
138
+ }
139
+ };
140
+
141
+ // Schema for validation, linting, and AI-assisted authoring
142
+ export const schema = {
143
+ type: 'hotspot',
144
+ description: 'Image-based click-to-select regions',
145
+ scormType: 'other',
146
+ example: `<div class="interaction hotspot" data-interaction-id="demo-hotspot">
147
+ <div class="question-prompt"><h3>Click the correct regions</h3></div>
148
+ <div class="hotspot-container">
149
+ <div class="image-container relative" style="position: relative; display: inline-block;">
150
+ <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='500' height='300' fill='%23f1f5f9'%3E%3Crect width='500' height='300' rx='8'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%2394a3b8' font-family='system-ui' font-size='14'%3EHotspot Image%3C/text%3E%3C/svg%3E" alt="Hotspot image" style="width: 100%; height: auto;">
151
+ <button type="button" role="checkbox" class="hotspot-area" aria-label="Region A" aria-checked="false" style="position: absolute; left: 15%; top: 20%; width: 20%; height: 30%; background: rgba(199,210,254,0.3); border: 3px solid #4338ca; border-radius: 4px; cursor: pointer;"></button>
152
+ <button type="button" role="checkbox" class="hotspot-area selected" aria-label="Region B" aria-checked="true" style="position: absolute; left: 60%; top: 40%; width: 25%; height: 35%; background: rgba(0,200,0,0.3); border: 3px solid #00cc00; border-radius: 4px; cursor: pointer;"></button>
153
+ </div>
154
+ </div>
155
+ </div>`,
156
+ properties: {
157
+ image: {
158
+ type: 'object',
159
+ required: true,
160
+ description: 'Background image',
161
+ valueSchema: {
162
+ src: { type: 'string', required: true },
163
+ alt: { type: 'string' }
164
+ }
165
+ },
166
+ hotspots: {
167
+ type: 'array',
168
+ required: true,
169
+ minItems: 1,
170
+ description: 'Clickable regions',
171
+ itemSchema: {
172
+ id: { type: 'string', required: true },
173
+ pos: { type: 'array', description: '[x, y, width, height] in %' },
174
+ correct: { type: 'boolean', required: true },
175
+ label: { type: 'string' }
176
+ }
177
+ }
178
+ },
179
+ notes: 'Use pos shorthand [x, y, w, h] or expanded position object'
180
+ };
181
+
182
+ export function createHotspotQuestion(config) {
183
+ validateAgainstSchema(config, schema);
184
+
185
+ const {
186
+ id,
187
+ prompt,
188
+ image = {},
189
+ hotspots = [],
190
+ feedback,
191
+ controlled = false
192
+ } = config;
193
+
194
+ if (!image?.src) {
195
+ throw new Error(`Hotspot question "${id}" is missing an image source.`);
196
+ }
197
+
198
+ if (!Array.isArray(hotspots) || hotspots.length === 0) {
199
+ throw new Error(`Hotspot question "${id}" has no hotspot regions configured.`);
200
+ }
201
+
202
+ // Expand minimal hotspot syntax before normalization
203
+ const expandedHotspots = hotspots.map(expandHotspot);
204
+ const normalizedHotspots = ensureUniqueHotspotIds(normalizeHotspots(expandedHotspots, id));
205
+ const hotspotMap = new Map(normalizedHotspots.map(spot => [spot.id, spot]));
206
+ const validSpotIds = new Set(hotspotMap.keys());
207
+ const correctSpots = normalizedHotspots.filter(spot => spot.correct).map(spot => spot.id);
208
+
209
+ let _container = null;
210
+
211
+ const questionObj = {
212
+ id,
213
+ type: 'hotspot',
214
+ render: (container, initialResponse = null) => {
215
+ validateContainer(container, id);
216
+ _container = container;
217
+
218
+ const initialSelections = sanitizeSelection(initialResponse, validSpotIds);
219
+ if (initialSelections.length) {
220
+ logger.debug(`[Hotspot] Initializing with ${initialSelections.length} saved selection(s)`);
221
+ }
222
+
223
+ container._hotspotState = {
224
+ selectedSpots: initialSelections
225
+ };
226
+
227
+ const hotspotButtons = normalizedHotspots.map((spot, index) => {
228
+ const isSelected = initialSelections.includes(spot.id);
229
+ const baseClasses = ['hotspot-area'];
230
+ if (isSelected) baseClasses.push('selected');
231
+
232
+ const inlineStyles = [
233
+ 'position: absolute',
234
+ `left: ${spot.cssLeft}`,
235
+ `top: ${spot.cssTop}`,
236
+ `width: ${spot.cssWidth}`,
237
+ `height: ${spot.cssHeight}`,
238
+ `background: ${isSelected ? spot.selectedBackgroundColor : spot.backgroundColor}`,
239
+ `border-color: ${isSelected ? spot.selectedBorderColor : spot.borderColor}`,
240
+ `border-width: ${(isSelected ? spot.selectedBorderWidth : spot.borderWidth)}px`,
241
+ `border-style: ${isSelected ? (spot.selectedBorderStyle || spot.borderStyle || 'solid') : (spot.borderStyle || 'solid')}`,
242
+ `border-radius: ${spot.borderRadius}`,
243
+ 'cursor: pointer',
244
+ 'z-index: 10'
245
+ ];
246
+
247
+ if (spot.style) {
248
+ inlineStyles.push(spot.style);
249
+ }
250
+
251
+ return `
252
+ <button
253
+ type="button"
254
+ role="checkbox"
255
+ class="${baseClasses.join(' ')}"
256
+ data-hotspot-id="${spot.id}"
257
+ data-correct="${spot.correct}"
258
+ data-default-bg="${spot.backgroundColor}"
259
+ data-selected-bg="${spot.selectedBackgroundColor}"
260
+ data-default-border="${spot.borderColor}"
261
+ data-selected-border="${spot.selectedBorderColor}"
262
+ data-default-border-width="${spot.borderWidth}"
263
+ data-selected-border-width="${spot.selectedBorderWidth}"
264
+ data-default-border-style="${spot.borderStyle || 'solid'}"
265
+ data-selected-border-style="${spot.selectedBorderStyle || spot.borderStyle || 'solid'}"
266
+ data-hover="false"
267
+ aria-label="${spot.label}"
268
+ aria-checked="${isSelected}"
269
+ title="${spot.label}"
270
+ style="${inlineStyles.join('; ')}"
271
+ data-testid="${id}-hotspot-${spot.id}"
272
+ >
273
+ <span class="sr-only">${spot.label || `Hotspot ${index + 1}`}</span>
274
+ </button>`;
275
+ }).join('');
276
+
277
+ const html = `
278
+ <div class="interaction hotspot" data-interaction-id="${id}">
279
+ <div class="question-prompt">
280
+ <h3>${prompt}</h3>
281
+ </div>
282
+ <div class="hotspot-container">
283
+ <div class="image-container relative">
284
+ <img
285
+ src="${image.src}"
286
+ alt="${image.alt || ''}"
287
+ class="w-full h-auto"
288
+ id="${id}_image"
289
+ />
290
+ ${hotspotButtons}
291
+ </div>
292
+ </div>
293
+ <div class="hotspot-feedback" id="${id}_feedback" aria-live="polite"></div>
294
+ ${renderInteractionControls(id, controlled)}
295
+ </div>`;
296
+
297
+ container.innerHTML = html;
298
+
299
+ if (initialSelections.length) {
300
+ questionObj.setResponse(initialSelections);
301
+ }
302
+
303
+ setupHotspotInteraction(container, questionObj, { controlled, validSpotIds, hotspotMap });
304
+ },
305
+
306
+ evaluate: (selectedSpots) => {
307
+ const selections = sanitizeSelection(Array.isArray(selectedSpots) ? selectedSpots : [], validSpotIds);
308
+ const correctSpots = normalizedHotspots.filter(spot => spot.correct).map(spot => spot.id);
309
+ const correctSet = new Set(correctSpots);
310
+ const allCorrectPresent = correctSpots.every(idValue => selections.includes(idValue));
311
+ const noIncorrectSelected = selections.every(idValue => correctSet.has(idValue));
312
+ const correct = selections.length > 0 && allCorrectPresent && noIncorrectSelected;
313
+ const score = correct ? 1 : 0;
314
+
315
+ return {
316
+ score,
317
+ correct,
318
+ results: { selected: selections, correct: correctSpots },
319
+ response: JSON.stringify(selections)
320
+ };
321
+ },
322
+
323
+ checkAnswer: (container = null) => {
324
+ const targetContainer = container || _container;
325
+ validateContainer(targetContainer, id);
326
+
327
+ const selections = questionObj.getResponse(targetContainer) || [];
328
+
329
+ if (!selections.length) {
330
+ displayFeedback(
331
+ targetContainer,
332
+ 'Select at least one hotspot before checking your answer.',
333
+ 'error'
334
+ );
335
+ return null;
336
+ }
337
+
338
+ const evaluation = questionObj.evaluate(selections);
339
+
340
+ if (evaluation.correct) {
341
+ displayFeedback(
342
+ targetContainer,
343
+ '✓ Excellent! You found all the correct areas.',
344
+ 'correct'
345
+ );
346
+ } else {
347
+ const correctCount = evaluation.results.correct.length;
348
+ const selectedCount = selections.length;
349
+ displayFeedback(
350
+ targetContainer,
351
+ `✗ Keep trying. ${selectedCount} selected / ${correctCount} required.`,
352
+ 'incorrect'
353
+ );
354
+ }
355
+
356
+ recordInteractionResult(
357
+ id,
358
+ 'other',
359
+ evaluation.response,
360
+ evaluation.correct,
361
+ JSON.stringify(normalizedHotspots.filter(h => h.correct).map(h => h.id)),
362
+ prompt,
363
+ controlled
364
+ );
365
+
366
+ return evaluation;
367
+ },
368
+
369
+ reset: (container = null) => {
370
+ const targetContainer = container || _container;
371
+ validateContainer(targetContainer, id);
372
+
373
+ questionObj.setResponse([], targetContainer);
374
+ clearFeedback(targetContainer);
375
+ },
376
+
377
+ setResponse: (response, container = null) => {
378
+ const targetContainer = container || _container;
379
+ validateContainer(targetContainer, id);
380
+
381
+ const selections = sanitizeSelection(response, validSpotIds);
382
+
383
+ if (!targetContainer._hotspotState) {
384
+ targetContainer._hotspotState = { selectedSpots: [] };
385
+ }
386
+ targetContainer._hotspotState.selectedSpots = selections;
387
+
388
+ const allSpots = targetContainer.querySelectorAll('.hotspot-area');
389
+ allSpots.forEach(spotEl => {
390
+ const spotId = spotEl.dataset.hotspotId;
391
+ const isSelected = selections.includes(spotId);
392
+ const spotConfig = hotspotMap.get(spotId);
393
+
394
+ spotEl.classList.toggle('selected', isSelected);
395
+ spotEl.setAttribute('aria-checked', isSelected ? 'true' : 'false');
396
+
397
+ if (spotConfig) {
398
+ const hover = spotEl.dataset.hover === 'true';
399
+ applyVisualState(spotEl, spotConfig, { selected: isSelected, hover });
400
+ }
401
+ });
402
+ },
403
+
404
+ getResponse: (container = null) => {
405
+ const targetContainer = container || _container;
406
+ validateContainer(targetContainer, id);
407
+
408
+ if (!targetContainer._hotspotState) {
409
+ return [];
410
+ }
411
+ return targetContainer._hotspotState.selectedSpots.slice();
412
+ },
413
+
414
+ getCorrectAnswer: () => {
415
+ return correctSpots.slice();
416
+ },
417
+
418
+ hotspots: normalizedHotspots,
419
+ prompt,
420
+ feedback,
421
+ controlled
422
+ };
423
+
424
+ // For uncontrolled interactions, register with the central registry for lifecycle mgmt
425
+ if (!controlled) {
426
+ registerCoreInteraction(config, questionObj);
427
+ }
428
+
429
+ return questionObj;
430
+ }
431
+
432
+ function setupHotspotInteraction(container, questionObj, { controlled, validSpotIds, hotspotMap }) {
433
+ // Click handler: toggle hotspot selection
434
+ container.addEventListener('click', (event) => {
435
+ const spot = event.target.closest('.hotspot-area');
436
+ if (!spot || spot.disabled) return;
437
+
438
+ const spotId = spot.dataset.hotspotId;
439
+ if (!validSpotIds.has(spotId)) return;
440
+
441
+ const state = container._hotspotState;
442
+ if (!state) return;
443
+
444
+ const isSelected = state.selectedSpots.includes(spotId);
445
+
446
+ if (isSelected) {
447
+ state.selectedSpots = state.selectedSpots.filter(id => id !== spotId);
448
+ } else {
449
+ state.selectedSpots.push(spotId);
450
+ }
451
+
452
+ const spotConfig = hotspotMap.get(spotId);
453
+ spot.classList.toggle('selected', !isSelected);
454
+ spot.setAttribute('aria-checked', !isSelected ? 'true' : 'false');
455
+ if (spotConfig) {
456
+ applyVisualState(spot, spotConfig, { selected: !isSelected, hover: spot.dataset.hover === 'true' });
457
+ }
458
+
459
+ // Show per-hotspot feedback if configured
460
+ const feedbackConfig = spotConfig?.feedback;
461
+ if (feedbackConfig) {
462
+ const msg = !isSelected ? feedbackConfig.onSelect : feedbackConfig.onDeselect;
463
+ if (msg) {
464
+ const feedbackEl = container.querySelector('.hotspot-feedback');
465
+ if (feedbackEl) feedbackEl.textContent = msg;
466
+ }
467
+ }
468
+ });
469
+
470
+ // Hover handlers for visual feedback
471
+ container.addEventListener('pointerenter', (event) => {
472
+ const spot = event.target.closest('.hotspot-area');
473
+ if (!spot || spot.disabled) return;
474
+ spot.dataset.hover = 'true';
475
+ const spotConfig = hotspotMap.get(spot.dataset.hotspotId);
476
+ if (spotConfig) {
477
+ applyVisualState(spot, spotConfig, { selected: spot.classList.contains('selected'), hover: true });
478
+ }
479
+ }, true);
480
+
481
+ container.addEventListener('pointerleave', (event) => {
482
+ const spot = event.target.closest('.hotspot-area');
483
+ if (!spot || spot.disabled) return;
484
+ spot.dataset.hover = 'false';
485
+ const spotConfig = hotspotMap.get(spot.dataset.hotspotId);
486
+ if (spotConfig) {
487
+ applyVisualState(spot, spotConfig, { selected: spot.classList.contains('selected'), hover: false });
488
+ }
489
+ }, true);
490
+
491
+ // Attach standard check/reset button handler in uncontrolled mode
492
+ if (!controlled) {
493
+ container.addEventListener('click', createInteractionEventHandler(questionObj, {
494
+ id: questionObj.id,
495
+ scormType: 'other',
496
+ controlled
497
+ }));
498
+ }
499
+ }
500
+
501
+ function applyVisualState(element, config, { selected, hover }) {
502
+ if (!element || !config) {
503
+ return;
504
+ }
505
+
506
+ const background = hover && config.hoverBackgroundColor
507
+ ? config.hoverBackgroundColor
508
+ : (selected ? config.selectedBackgroundColor : config.backgroundColor);
509
+
510
+ const borderColor = hover && config.hoverBorderColor
511
+ ? config.hoverBorderColor
512
+ : (selected ? config.selectedBorderColor : config.borderColor);
513
+
514
+ const borderWidth = hover && config.hoverBorderWidth !== undefined
515
+ ? config.hoverBorderWidth
516
+ : (selected ? config.selectedBorderWidth : config.borderWidth);
517
+
518
+ const borderStyle = hover && config.hoverBorderStyle
519
+ ? config.hoverBorderStyle
520
+ : (selected ? config.selectedBorderStyle : config.borderStyle);
521
+
522
+ element.style.background = background;
523
+ element.style.borderColor = borderColor;
524
+ element.style.borderWidth = `${Math.max(0, toNumber(borderWidth, config.borderWidth))}px`;
525
+ element.style.borderStyle = borderStyle || 'solid';
526
+ }
527
+
528
+ function sanitizeSelection(selections, validIds) {
529
+ const unique = new Set();
530
+ const parsedSelections = parseResponse(selections, 'array') || [];
531
+ parsedSelections.forEach(id => {
532
+ if (validIds.has(String(id))) {
533
+ unique.add(String(id));
534
+ }
535
+ });
536
+ return Array.from(unique);
537
+ }
538
+
539
+ function _normalizeIdAndLabel(spot, questionId, index) {
540
+ const generatedId = `${questionId || 'hotspot'}_${index + 1}`;
541
+ spot.id = typeof spot.id === 'string' && spot.id.trim() ? spot.id.trim() : generatedId;
542
+ spot.label = typeof spot.label === 'string' && spot.label.trim() ? spot.label.trim() : `Hotspot ${index + 1}`;
543
+ }
544
+
545
+ function _normalizePosition(spot) {
546
+ const position = (typeof spot.position === 'object' && spot.position !== null) ? spot.position : {};
547
+ const usesPositionObject = Object.keys(position).length > 0;
548
+
549
+ const leftUnit = resolveUnit(position.xUnit || position.unit || spot.xUnit, usesPositionObject ? 'px' : '%');
550
+ const topUnit = resolveUnit(position.yUnit || position.unit || spot.yUnit, usesPositionObject ? 'px' : '%');
551
+ const widthUnit = resolveUnit(position.widthUnit || position.unit || spot.widthUnit, usesPositionObject ? 'px' : '%');
552
+ const heightUnit = resolveUnit(position.heightUnit || position.unit || spot.heightUnit, usesPositionObject ? 'px' : '%');
553
+
554
+ spot.cssLeft = toCssValue(position.x ?? spot.x, { fallback: 0, unit: leftUnit, clampToPercent: leftUnit === '%', min: 0 });
555
+ spot.cssTop = toCssValue(position.y ?? spot.y, { fallback: 0, unit: topUnit, clampToPercent: topUnit === '%', min: 0 });
556
+ spot.cssWidth = toCssValue(position.width ?? spot.width, { fallback: usesPositionObject ? 80 : 5, unit: widthUnit, clampToPercent: widthUnit === '%', min: 1 });
557
+ spot.cssHeight = toCssValue(position.height ?? spot.height, { fallback: usesPositionObject ? 80 : 5, unit: heightUnit, clampToPercent: heightUnit === '%', min: 1 });
558
+
559
+ spot.x = Number.parseFloat(spot.cssLeft) || 0;
560
+ spot.y = Number.parseFloat(spot.cssTop) || 0;
561
+ spot.width = Number.parseFloat(spot.cssWidth) || (usesPositionObject ? 80 : 5);
562
+ spot.height = Number.parseFloat(spot.cssHeight) || (usesPositionObject ? 80 : 5);
563
+ }
564
+
565
+ function _normalizeAppearance(spot) {
566
+ const appearance = spot.appearance || {};
567
+ const defaultAppearance = appearance.default || {};
568
+ const selectedAppearance = appearance.selected || {};
569
+ const hoverAppearance = appearance.hover || {};
570
+
571
+ spot.backgroundColor = defaultAppearance.fill || spot.backgroundColor || 'rgba(255, 0, 0, 0.3)';
572
+ spot.selectedBackgroundColor = selectedAppearance.fill || spot.selectedBackgroundColor || 'rgba(0, 150, 255, 0.5)';
573
+ spot.hoverBackgroundColor = hoverAppearance.fill || spot.hoverBackgroundColor || '';
574
+
575
+ spot.borderColor = defaultAppearance.stroke || spot.borderColor || '#ff0000';
576
+ spot.borderWidth = toNumber(defaultAppearance.strokeWidth ?? spot.borderWidth, 2);
577
+ spot.selectedBorderColor = selectedAppearance.stroke || spot.selectedBorderColor || spot.borderColor;
578
+ spot.selectedBorderWidth = toNumber(selectedAppearance.strokeWidth ?? spot.selectedBorderWidth, spot.borderWidth);
579
+ spot.hoverBorderColor = hoverAppearance.stroke || spot.hoverBorderColor || '';
580
+ spot.hoverBorderWidth = toNumber(hoverAppearance.strokeWidth ?? spot.hoverBorderWidth, spot.borderWidth);
581
+ spot.borderStyle = defaultAppearance.strokeStyle || spot.borderStyle || 'solid';
582
+ spot.selectedBorderStyle = selectedAppearance.strokeStyle || spot.selectedBorderStyle || spot.borderStyle;
583
+ spot.hoverBorderStyle = hoverAppearance.strokeStyle || spot.hoverBorderStyle || spot.borderStyle;
584
+ }
585
+
586
+ function _normalizeMisc(spot) {
587
+ spot.borderRadius = typeof spot.borderRadius === 'string'
588
+ ? spot.borderRadius
589
+ : (spot.shape && spot.shape.toLowerCase() === 'rectangle' ? '0' : '50%');
590
+ spot.correct = spot.correct === true || spot.correct === 'true' || spot.correct === 1;
591
+ spot.style = typeof spot.style === 'string' ? spot.style : '';
592
+ }
593
+
594
+ function normalizeHotspots(hotspots, questionId) {
595
+ if (!Array.isArray(hotspots)) {
596
+ return [];
597
+ }
598
+
599
+ return hotspots.map((spot = {}, index) => {
600
+ const normalized = { ...spot };
601
+
602
+ _normalizeIdAndLabel(normalized, questionId, index);
603
+ _normalizePosition(normalized);
604
+ _normalizeAppearance(normalized);
605
+ _normalizeMisc(normalized);
606
+
607
+ return normalized;
608
+ });
609
+ }
610
+ function ensureUniqueHotspotIds(hotspots) {
611
+ const usedIds = new Set();
612
+ return hotspots.map((spot, index) => {
613
+ let spotId = spot.id;
614
+ if (!spotId) {
615
+ spotId = `hotspot_${index + 1}`;
616
+ }
617
+
618
+ let suffix = 1;
619
+ while (usedIds.has(spotId)) {
620
+ suffix += 1;
621
+ spotId = `${spot.id || `hotspot_${index + 1}`}_${suffix}`;
622
+ }
623
+
624
+ usedIds.add(spotId);
625
+ return { ...spot, id: spotId };
626
+ });
627
+ }
628
+
629
+ function resolveUnit(unit, fallback = '%') {
630
+ if (!unit && unit !== 0) {
631
+ return fallback;
632
+ }
633
+
634
+ const normalized = String(unit).trim().toLowerCase();
635
+ if (!normalized) {
636
+ return fallback;
637
+ }
638
+
639
+ if (normalized === '%' || normalized === 'percent' || normalized === 'percentage') {
640
+ return '%';
641
+ }
642
+
643
+ if (normalized === 'px' || normalized === 'pixel' || normalized === 'pixels') {
644
+ return 'px';
645
+ }
646
+
647
+ return normalized;
648
+ }
649
+
650
+ function toCssValue(value, { fallback = 0, unit = '%', clampToPercent = false, min = null, max = null } = {}) {
651
+ const resolvedUnit = resolveUnit(unit, '%');
652
+
653
+ if (typeof value === 'string' && value.trim()) {
654
+ return value.trim();
655
+ }
656
+
657
+ const numeric = Number.parseFloat(value);
658
+ if (Number.isFinite(numeric)) {
659
+ return formatNumericCssValue(numeric, resolvedUnit, { clampToPercent, min, max });
660
+ }
661
+
662
+ if (typeof fallback === 'string' && fallback.trim()) {
663
+ return fallback.trim();
664
+ }
665
+
666
+ const fallbackNumeric = Number.parseFloat(fallback);
667
+ if (Number.isFinite(fallbackNumeric)) {
668
+ return formatNumericCssValue(fallbackNumeric, resolvedUnit, { clampToPercent, min, max });
669
+ }
670
+
671
+ return formatNumericCssValue(0, resolvedUnit, { clampToPercent, min, max });
672
+ }
673
+
674
+ function formatNumericCssValue(value, unit, { clampToPercent, min, max }) {
675
+ let output = value;
676
+
677
+ if (typeof min === 'number') {
678
+ output = Math.max(min, output);
679
+ }
680
+ if (typeof max === 'number') {
681
+ output = Math.min(max, output);
682
+ }
683
+
684
+ if (clampToPercent) {
685
+ output = Math.min(100, Math.max(0, output));
686
+ }
687
+
688
+ return `${output}${unit}`;
689
+ }
690
+
691
+ function toNumber(value, fallback = 0) {
692
+ const numeric = Number.parseFloat(value);
693
+ if (Number.isFinite(numeric)) {
694
+ return Math.max(0, numeric);
695
+ }
696
+ const fallbackNumeric = Number.parseFloat(fallback);
697
+ if (Number.isFinite(fallbackNumeric)) {
698
+ return Math.max(0, fallbackNumeric);
699
+ }
700
+ return 0;
701
+ }
702
+