@morscherlab/mint-sdk 1.0.0-beta.2 → 1.0.0-beta.4

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 (427) hide show
  1. package/README.md +225 -6
  2. package/dist/__tests__/components/ActionItem.test.d.ts +1 -0
  3. package/dist/__tests__/components/AppAvatarMenu.test.d.ts +1 -0
  4. package/dist/__tests__/components/AppPageSelector.test.d.ts +1 -0
  5. package/dist/__tests__/components/AppPillNav.test.d.ts +1 -0
  6. package/dist/__tests__/components/AppPluginSwitcher.test.d.ts +1 -0
  7. package/dist/__tests__/components/AppToastContainer.test.d.ts +1 -0
  8. package/dist/__tests__/components/BaseRadioGroup.test.d.ts +1 -0
  9. package/dist/__tests__/components/BaseSelect.test.d.ts +1 -0
  10. package/dist/__tests__/components/BaseTabs.test.d.ts +1 -0
  11. package/dist/__tests__/components/BatchProgressList.test.d.ts +1 -0
  12. package/dist/__tests__/components/BioTemplateExperimentWorkspaceView.test.d.ts +1 -0
  13. package/dist/__tests__/components/BioTemplatePackWorkspaceView.test.d.ts +1 -0
  14. package/dist/__tests__/components/BioTemplatePresetWorkspaceView.test.d.ts +1 -0
  15. package/dist/__tests__/components/BioTemplateRenderer.test.d.ts +1 -0
  16. package/dist/__tests__/components/Breadcrumb.test.d.ts +1 -0
  17. package/dist/__tests__/components/CalendarGridPanel.test.d.ts +1 -0
  18. package/dist/__tests__/components/ComponentBindingRenderer.test.d.ts +1 -0
  19. package/dist/__tests__/components/ConcentrationInput.test.d.ts +1 -0
  20. package/dist/__tests__/components/ControlWorkspaceView.test.d.ts +1 -0
  21. package/dist/__tests__/components/DatePicker.test.d.ts +1 -0
  22. package/dist/__tests__/components/DateTimePicker.test.d.ts +1 -0
  23. package/dist/__tests__/components/DoseDesignWorkspaceView.test.d.ts +1 -0
  24. package/dist/__tests__/components/EmptyState.test.d.ts +1 -0
  25. package/dist/__tests__/components/ExperimentPopover.test.d.ts +1 -0
  26. package/dist/__tests__/components/FormBuilder.test.d.ts +1 -0
  27. package/dist/__tests__/components/GroupAssigner.test.d.ts +1 -0
  28. package/dist/__tests__/components/MultiSelect.test.d.ts +1 -0
  29. package/dist/__tests__/components/PluginWorkspaceView.test.d.ts +1 -0
  30. package/dist/__tests__/components/ProtocolStepEditor.test.d.ts +1 -0
  31. package/dist/__tests__/components/ReagentList.test.d.ts +1 -0
  32. package/dist/__tests__/components/SampleHierarchyTree.test.d.ts +1 -0
  33. package/dist/__tests__/components/SampleSelector.test.d.ts +1 -0
  34. package/dist/__tests__/components/SegmentedControl.test.d.ts +1 -0
  35. package/dist/__tests__/components/SettingsModal.test.d.ts +1 -0
  36. package/dist/__tests__/components/TagsInput.test.d.ts +1 -0
  37. package/dist/__tests__/components/ThemeToggle.test.d.ts +1 -0
  38. package/dist/__tests__/components/TimePicker.test.d.ts +1 -0
  39. package/dist/__tests__/composables/experiment-utils.test.d.ts +1 -0
  40. package/dist/__tests__/composables/useApi.test.d.ts +1 -0
  41. package/dist/__tests__/composables/useBioTemplatePackWorkspace.test.d.ts +1 -0
  42. package/dist/__tests__/composables/useBioTemplatePresetWorkspace.test.d.ts +1 -0
  43. package/dist/__tests__/composables/useBioTemplateWorkspace.test.d.ts +1 -0
  44. package/dist/__tests__/composables/useCalendarGrid.test.d.ts +1 -0
  45. package/dist/__tests__/composables/useControlSchema.test.d.ts +1 -0
  46. package/dist/__tests__/composables/useDebouncedWatch.test.d.ts +1 -0
  47. package/dist/__tests__/composables/useDropdownState.test.d.ts +1 -0
  48. package/dist/__tests__/composables/useEventListener.test.d.ts +1 -0
  49. package/dist/__tests__/composables/useExpansionSet.test.d.ts +1 -0
  50. package/dist/__tests__/composables/useExperimentData.test.d.ts +1 -0
  51. package/dist/__tests__/composables/useExperimentSelector.test.d.ts +1 -0
  52. package/dist/__tests__/composables/useGroupAssignment.test.d.ts +1 -0
  53. package/dist/__tests__/composables/useListSelection.test.d.ts +1 -0
  54. package/dist/__tests__/composables/usePluginClient.test.d.ts +1 -0
  55. package/dist/__tests__/composables/usePluginConfig.test.d.ts +1 -0
  56. package/dist/__tests__/composables/useRequestSyncState.test.d.ts +1 -0
  57. package/dist/__tests__/composables/useSampleGroups.test.d.ts +1 -0
  58. package/dist/__tests__/composables/useSelectionLimit.test.d.ts +1 -0
  59. package/dist/__tests__/composables/useSortedItems.test.d.ts +1 -0
  60. package/dist/__tests__/composables/useTemplateCollection.test.d.ts +1 -0
  61. package/dist/__tests__/composables/useTextSearch.test.d.ts +1 -0
  62. package/dist/__tests__/composables/useTheme.test.d.ts +1 -0
  63. package/dist/__tests__/composables/useTimeUtils.test.d.ts +1 -0
  64. package/dist/__tests__/docs/frontendDocsCatalog.test.d.ts +1 -0
  65. package/dist/__tests__/templates/templates.test.d.ts +1 -0
  66. package/dist/{auth-DsI0rQ7_.js → auth-QQj2kkze.js} +12 -5
  67. package/dist/auth-QQj2kkze.js.map +1 -0
  68. package/dist/components/AppAvatarMenu.vue.d.ts +2 -7
  69. package/dist/components/AppContainer.vue.d.ts +1 -1
  70. package/dist/components/AppLayout.vue.d.ts +20 -1
  71. package/dist/components/AppSidebar.vue.d.ts +111 -6
  72. package/dist/components/AppTopBar.vue.d.ts +35 -22
  73. package/dist/components/BaseButton.vue.d.ts +1 -1
  74. package/dist/components/BaseCheckbox.vue.d.ts +1 -1
  75. package/dist/components/BaseInput.vue.d.ts +2 -2
  76. package/dist/components/BasePill.vue.d.ts +2 -2
  77. package/dist/components/BaseRadioGroup.vue.d.ts +3 -3
  78. package/dist/components/BaseSelect.vue.d.ts +3 -3
  79. package/dist/components/BaseTabs.vue.d.ts +2 -2
  80. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  81. package/dist/components/BaseToggle.vue.d.ts +1 -1
  82. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +119 -0
  83. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +93 -0
  84. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +87 -0
  85. package/dist/components/BioTemplateRenderer.vue.d.ts +29 -0
  86. package/dist/components/Breadcrumb.vue.d.ts +2 -2
  87. package/dist/components/Calendar.vue.d.ts +1 -1
  88. package/dist/components/CollapsibleCard.vue.d.ts +1 -1
  89. package/dist/components/ComponentBindingRenderer.vue.d.ts +44 -0
  90. package/dist/components/ConcentrationInput.vue.d.ts +2 -2
  91. package/dist/components/ConfirmDialog.vue.d.ts +2 -2
  92. package/dist/components/ControlWorkspaceView.vue.d.ts +147 -0
  93. package/dist/components/DatePicker.vue.d.ts +1 -1
  94. package/dist/components/DateTimePicker.vue.d.ts +3 -3
  95. package/dist/components/Divider.vue.d.ts +1 -1
  96. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +149 -0
  97. package/dist/components/DropdownButton.vue.d.ts +3 -3
  98. package/dist/components/EmptyState.vue.d.ts +1 -2
  99. package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
  100. package/dist/components/ExperimentTimeline.vue.d.ts +2 -2
  101. package/dist/components/FileUploader.vue.d.ts +1 -1
  102. package/dist/components/FitPanel.vue.d.ts +1 -1
  103. package/dist/components/FormActions.vue.d.ts +4 -4
  104. package/dist/components/FormBuilder.vue.d.ts +31 -17
  105. package/dist/components/FormulaInput.vue.d.ts +2 -2
  106. package/dist/components/MoleculeInput.vue.d.ts +2 -2
  107. package/dist/components/MultiSelect.vue.d.ts +3 -3
  108. package/dist/components/NumberInput.vue.d.ts +1 -1
  109. package/dist/components/PlateMapEditor.vue.d.ts +1 -1
  110. package/dist/components/PluginWorkspaceView.vue.d.ts +310 -0
  111. package/dist/components/ProgressBar.vue.d.ts +1 -1
  112. package/dist/components/ProtocolStepEditor.vue.d.ts +3 -1
  113. package/dist/components/RackEditor.vue.d.ts +2 -2
  114. package/dist/components/SampleLegend.vue.d.ts +2 -2
  115. package/dist/components/ScheduleCalendar.vue.d.ts +2 -2
  116. package/dist/components/SegmentedControl.vue.d.ts +2 -2
  117. package/dist/components/SequenceInput.vue.d.ts +3 -3
  118. package/dist/components/SettingsModal.vue.d.ts +14 -6
  119. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  120. package/dist/components/TagsInput.vue.d.ts +3 -2
  121. package/dist/components/TimePicker.vue.d.ts +3 -3
  122. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  123. package/dist/components/UnitInput.vue.d.ts +2 -2
  124. package/dist/components/WellPlate.vue.d.ts +6 -6
  125. package/dist/components/index.d.ts +9 -8
  126. package/dist/components/index.js +3 -3
  127. package/dist/components/{SettingsButton.vue.d.ts → internal/ActionItemInternal.vue.d.ts} +11 -9
  128. package/dist/components/{AppPageSelector.vue.d.ts → internal/AppPageSelectorInternal.vue.d.ts} +3 -6
  129. package/dist/components/{AppPillNav.vue.d.ts → internal/AppPillNavInternal.vue.d.ts} +4 -2
  130. package/dist/components/internal/CalendarGridPanelInternal.vue.d.ts +25 -0
  131. package/dist/components/{FormFieldRenderer.vue.d.ts → internal/FormFieldRendererInternal.vue.d.ts} +2 -2
  132. package/dist/components/{FormSection.vue.d.ts → internal/FormSectionRenderer.vue.d.ts} +7 -7
  133. package/dist/components/{WellEditPopup.vue.d.ts → internal/WellEditPopupInternal.vue.d.ts} +1 -1
  134. package/dist/{components-_XqPEhP9.js → components-BkGF4B4y.js} +9760 -8471
  135. package/dist/components-BkGF4B4y.js.map +1 -0
  136. package/dist/composables/experiment-utils.d.ts +8 -0
  137. package/dist/composables/index.d.ts +22 -5
  138. package/dist/composables/index.js +4 -3
  139. package/dist/composables/platformContextHelpers.d.ts +14 -0
  140. package/dist/composables/useAppExperiment.d.ts +31 -2
  141. package/dist/composables/useBioTemplateComponents.d.ts +22 -0
  142. package/dist/composables/useBioTemplateControls.d.ts +6 -0
  143. package/dist/composables/useBioTemplatePackWorkspace.d.ts +46 -0
  144. package/dist/composables/useBioTemplatePresetWorkspace.d.ts +75 -0
  145. package/dist/composables/useBioTemplateWorkspace.d.ts +51 -0
  146. package/dist/composables/useCalendarGrid.d.ts +26 -0
  147. package/dist/composables/useControlSchema.d.ts +343 -0
  148. package/dist/composables/useDebouncedWatch.d.ts +20 -0
  149. package/dist/composables/useDropdownState.d.ts +19 -0
  150. package/dist/composables/useEventListener.d.ts +13 -0
  151. package/dist/composables/useExpansionSet.d.ts +21 -0
  152. package/dist/composables/useExperimentData.d.ts +10 -0
  153. package/dist/composables/useExperimentSave.d.ts +31 -2
  154. package/dist/composables/useExperimentSelector.d.ts +20 -0
  155. package/dist/composables/useForm.d.ts +2 -0
  156. package/dist/composables/useGroupAssignment.d.ts +31 -0
  157. package/dist/composables/useListSelection.d.ts +35 -0
  158. package/dist/composables/usePlatformContext.d.ts +21 -3
  159. package/dist/composables/usePluginClient.d.ts +112 -0
  160. package/dist/composables/usePluginConfig.d.ts +12 -0
  161. package/dist/composables/useRequestSyncState.d.ts +34 -0
  162. package/dist/composables/useSampleGroups.d.ts +32 -0
  163. package/dist/composables/useSelectionLimit.d.ts +17 -0
  164. package/dist/composables/useSortedItems.d.ts +32 -0
  165. package/dist/composables/useTemplateCollection.d.ts +58 -0
  166. package/dist/composables/useTextSearch.d.ts +18 -0
  167. package/dist/composables/useTimeUtils.d.ts +8 -0
  168. package/dist/{composables-tiZqLu1M.js → composables-CHsME9H1.js} +240 -146
  169. package/dist/composables-CHsME9H1.js.map +1 -0
  170. package/dist/index.d.ts +6 -4
  171. package/dist/index.js +6 -5
  172. package/dist/install.d.ts +7 -2
  173. package/dist/install.js +2 -2
  174. package/dist/install.js.map +1 -1
  175. package/dist/stores/index.js +1 -1
  176. package/dist/stores/settings.d.ts +4 -1
  177. package/dist/styles.css +4746 -5514
  178. package/dist/templates/adapters.d.ts +43 -0
  179. package/dist/templates/builders.d.ts +63 -0
  180. package/dist/templates/catalog.d.ts +188 -0
  181. package/dist/templates/componentBindings.d.ts +71 -0
  182. package/dist/templates/controlSchemas.d.ts +25 -0
  183. package/dist/templates/index.d.ts +15 -0
  184. package/dist/templates/index.js +2 -0
  185. package/dist/templates/lookup.d.ts +4 -0
  186. package/dist/templates/packs.d.ts +18 -0
  187. package/dist/templates/presets.d.ts +90 -0
  188. package/dist/templates/types.d.ts +531 -0
  189. package/dist/templates-B5jmTWuk.js +9388 -0
  190. package/dist/templates-B5jmTWuk.js.map +1 -0
  191. package/dist/types/components.d.ts +26 -23
  192. package/dist/types/form-builder.d.ts +6 -8
  193. package/dist/types/index.d.ts +2 -2
  194. package/dist/types/platform.d.ts +7 -1
  195. package/dist/useScheduleDrag-BgzpQT53.js +4414 -0
  196. package/dist/useScheduleDrag-BgzpQT53.js.map +1 -0
  197. package/dist/utils/formModelSync.d.ts +5 -0
  198. package/dist/utils/items.d.ts +8 -0
  199. package/dist/utils/options.d.ts +6 -0
  200. package/dist/utils/pluginIcon.d.ts +9 -0
  201. package/package.json +7 -2
  202. package/src/__tests__/components/ActionItem.test.ts +99 -0
  203. package/src/__tests__/components/AppAvatarMenu.test.ts +27 -0
  204. package/src/__tests__/components/AppLayout.test.ts +44 -0
  205. package/src/__tests__/components/AppPageSelector.test.ts +134 -0
  206. package/src/__tests__/components/AppPillNav.test.ts +125 -0
  207. package/src/__tests__/components/AppPluginSwitcher.test.ts +44 -0
  208. package/src/__tests__/components/AppSidebar.test.ts +496 -0
  209. package/src/__tests__/components/AppToastContainer.test.ts +37 -0
  210. package/src/__tests__/components/AppTopBar.test.ts +455 -9
  211. package/src/__tests__/components/BaseRadioGroup.test.ts +25 -0
  212. package/src/__tests__/components/BaseSelect.test.ts +21 -0
  213. package/src/__tests__/components/BaseTabs.test.ts +25 -0
  214. package/src/__tests__/components/BatchProgressList.test.ts +52 -0
  215. package/src/__tests__/components/BioTemplateExperimentWorkspaceView.test.ts +159 -0
  216. package/src/__tests__/components/BioTemplatePackWorkspaceView.test.ts +175 -0
  217. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +306 -0
  218. package/src/__tests__/components/BioTemplateRenderer.test.ts +71 -0
  219. package/src/__tests__/components/Breadcrumb.test.ts +23 -0
  220. package/src/__tests__/components/CalendarGridPanel.test.ts +36 -0
  221. package/src/__tests__/components/ComponentBindingRenderer.test.ts +161 -0
  222. package/src/__tests__/components/ConcentrationInput.test.ts +45 -0
  223. package/src/__tests__/components/ControlWorkspaceView.test.ts +1102 -0
  224. package/src/__tests__/components/DataFrame.test.ts +11 -0
  225. package/src/__tests__/components/DatePicker.test.ts +45 -0
  226. package/src/__tests__/components/DateTimePicker.test.ts +48 -0
  227. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +185 -0
  228. package/src/__tests__/components/DropdownButton.test.ts +23 -0
  229. package/src/__tests__/components/EmptyState.test.ts +23 -0
  230. package/src/__tests__/components/ExperimentPopover.test.ts +56 -0
  231. package/src/__tests__/components/FormBuilder.test.ts +296 -0
  232. package/src/__tests__/components/GroupAssigner.test.ts +30 -0
  233. package/src/__tests__/components/MultiSelect.test.ts +48 -0
  234. package/src/__tests__/components/PluginWorkspaceView.test.ts +548 -0
  235. package/src/__tests__/components/ProtocolStepEditor.test.ts +33 -0
  236. package/src/__tests__/components/ReagentList.test.ts +82 -0
  237. package/src/__tests__/components/SampleHierarchyTree.test.ts +53 -0
  238. package/src/__tests__/components/SampleSelector.test.ts +60 -0
  239. package/src/__tests__/components/SegmentedControl.test.ts +24 -0
  240. package/src/__tests__/components/SettingsModal.test.ts +296 -0
  241. package/src/__tests__/components/TagsInput.test.ts +75 -0
  242. package/src/__tests__/components/ThemeToggle.test.ts +47 -0
  243. package/src/__tests__/components/TimePicker.test.ts +38 -0
  244. package/src/__tests__/composables/experiment-utils.test.ts +30 -0
  245. package/src/__tests__/composables/useApi.test.ts +30 -0
  246. package/src/__tests__/composables/useAppExperiment.test.ts +100 -1
  247. package/src/__tests__/composables/useBioTemplatePackWorkspace.test.ts +125 -0
  248. package/src/__tests__/composables/useBioTemplatePresetWorkspace.test.ts +199 -0
  249. package/src/__tests__/composables/useBioTemplateWorkspace.test.ts +104 -0
  250. package/src/__tests__/composables/useCalendarGrid.test.ts +38 -0
  251. package/src/__tests__/composables/useControlSchema.test.ts +1033 -0
  252. package/src/__tests__/composables/useDebouncedWatch.test.ts +93 -0
  253. package/src/__tests__/composables/useDropdownState.test.ts +95 -0
  254. package/src/__tests__/composables/useEventListener.test.ts +116 -0
  255. package/src/__tests__/composables/useExpansionSet.test.ts +62 -0
  256. package/src/__tests__/composables/useExperimentData.test.ts +4 -0
  257. package/src/__tests__/composables/useExperimentSave.test.ts +203 -8
  258. package/src/__tests__/composables/useExperimentSelector.test.ts +164 -0
  259. package/src/__tests__/composables/useForm.test.ts +58 -0
  260. package/src/__tests__/composables/useFormBuilder.test.ts +77 -0
  261. package/src/__tests__/composables/useGroupAssignment.test.ts +73 -0
  262. package/src/__tests__/composables/useListSelection.test.ts +66 -0
  263. package/src/__tests__/composables/usePluginClient.test.ts +541 -0
  264. package/src/__tests__/composables/usePluginConfig.test.ts +5 -0
  265. package/src/__tests__/composables/useRequestSyncState.test.ts +92 -0
  266. package/src/__tests__/composables/useSampleGroups.test.ts +66 -0
  267. package/src/__tests__/composables/useSelectionLimit.test.ts +41 -0
  268. package/src/__tests__/composables/useSortedItems.test.ts +87 -0
  269. package/src/__tests__/composables/useTemplateCollection.test.ts +147 -0
  270. package/src/__tests__/composables/useTextSearch.test.ts +55 -0
  271. package/src/__tests__/composables/useTheme.test.ts +91 -0
  272. package/src/__tests__/composables/useTimeUtils.test.ts +35 -0
  273. package/src/__tests__/docs/frontendDocsCatalog.test.ts +324 -0
  274. package/src/__tests__/fixtures/templates/dose-response.json +81 -0
  275. package/src/__tests__/fixtures/templates/plate-map.json +54 -0
  276. package/src/__tests__/fixtures/templates/qpcr-plate.json +96 -0
  277. package/src/__tests__/fixtures/templates/sample-sheet.json +71 -0
  278. package/src/__tests__/templates/templates.test.ts +1055 -0
  279. package/src/components/AppAvatarMenu.vue +15 -69
  280. package/src/components/AppLayout.story.vue +64 -25
  281. package/src/components/AppLayout.vue +83 -2
  282. package/src/components/AppPluginSwitcher.vue +41 -145
  283. package/src/components/AppSidebar.story.vue +203 -1
  284. package/src/components/AppSidebar.vue +320 -25
  285. package/src/components/{ToastNotification.story.vue → AppToastContainer.story.vue} +6 -6
  286. package/src/components/{ToastNotification.vue → AppToastContainer.vue} +1 -1
  287. package/src/components/AppTopBar.story.vue +7 -33
  288. package/src/components/AppTopBar.vue +104 -300
  289. package/src/components/BaseModal.vue +3 -5
  290. package/src/components/BaseRadioGroup.vue +7 -3
  291. package/src/components/BaseSelect.vue +11 -7
  292. package/src/components/BaseTabs.vue +6 -4
  293. package/src/components/BatchProgressList.vue +5 -8
  294. package/src/components/BioTemplateExperimentWorkspaceView.story.vue +123 -0
  295. package/src/components/BioTemplateExperimentWorkspaceView.vue +343 -0
  296. package/src/components/BioTemplatePackWorkspaceView.story.vue +107 -0
  297. package/src/components/BioTemplatePackWorkspaceView.vue +177 -0
  298. package/src/components/BioTemplatePresetWorkspaceView.story.vue +163 -0
  299. package/src/components/BioTemplatePresetWorkspaceView.vue +401 -0
  300. package/src/components/BioTemplateRenderer.story.vue +57 -0
  301. package/src/components/BioTemplateRenderer.vue +57 -0
  302. package/src/components/Breadcrumb.vue +14 -8
  303. package/src/components/ComponentBindingRenderer.story.vue +57 -0
  304. package/src/components/ComponentBindingRenderer.vue +308 -0
  305. package/src/components/ConcentrationInput.vue +27 -64
  306. package/src/components/ControlWorkspaceView.story.vue +347 -0
  307. package/src/components/ControlWorkspaceView.vue +378 -0
  308. package/src/components/DataFrame.vue +34 -50
  309. package/src/components/DatePicker.vue +59 -192
  310. package/src/components/DateTimePicker.vue +50 -171
  311. package/src/components/DoseDesignWorkspaceView.story.vue +77 -0
  312. package/src/components/DoseDesignWorkspaceView.vue +255 -0
  313. package/src/components/DropdownButton.vue +14 -32
  314. package/src/components/EmptyState.vue +4 -2
  315. package/src/components/ExperimentPopover.vue +7 -28
  316. package/src/components/ExperimentSelectorModal.vue +6 -5
  317. package/src/components/FormBuilder.story.vue +190 -0
  318. package/src/components/FormBuilder.vue +124 -27
  319. package/src/components/GroupAssigner.vue +24 -56
  320. package/src/components/MultiSelect.vue +17 -12
  321. package/src/components/PlateMapEditor.vue +3 -8
  322. package/src/components/PluginIcon.vue +2 -22
  323. package/src/components/PluginWorkspaceView.story.vue +334 -0
  324. package/src/components/PluginWorkspaceView.vue +708 -0
  325. package/src/components/ProtocolStepEditor.vue +13 -22
  326. package/src/components/ReagentList.vue +25 -33
  327. package/src/components/SampleHierarchyTree.vue +12 -23
  328. package/src/components/SampleSelector.vue +42 -122
  329. package/src/components/SegmentedControl.vue +7 -3
  330. package/src/components/SettingsModal.story.vue +88 -1
  331. package/src/components/SettingsModal.vue +120 -29
  332. package/src/components/TagsInput.vue +29 -14
  333. package/src/components/ThemeToggle.vue +9 -7
  334. package/src/components/TimePicker.vue +19 -41
  335. package/src/components/Tooltip.vue +7 -12
  336. package/src/components/WellPlate.vue +6 -12
  337. package/src/components/index.ts +9 -8
  338. package/src/components/internal/ActionItemInternal.vue +82 -0
  339. package/src/components/internal/AppPageSelectorInternal.vue +128 -0
  340. package/src/components/internal/AppPillNavInternal.vue +194 -0
  341. package/src/components/internal/CalendarGridPanelInternal.vue +120 -0
  342. package/src/components/{FormFieldRenderer.vue → internal/FormFieldRendererInternal.vue} +4 -12
  343. package/src/components/{FormSection.vue → internal/FormSectionRenderer.vue} +6 -18
  344. package/src/components/{WellEditPopup.vue → internal/WellEditPopupInternal.vue} +5 -10
  345. package/src/composables/experiment-utils.ts +26 -0
  346. package/src/composables/index.ts +229 -3
  347. package/src/composables/platformContextHelpers.ts +74 -0
  348. package/src/composables/useApi.ts +9 -2
  349. package/src/composables/useAppExperiment.ts +85 -13
  350. package/src/composables/useBioTemplateComponents.ts +105 -0
  351. package/src/composables/useBioTemplateControls.ts +41 -0
  352. package/src/composables/useBioTemplatePackWorkspace.ts +185 -0
  353. package/src/composables/useBioTemplatePresetWorkspace.ts +326 -0
  354. package/src/composables/useBioTemplateWorkspace.ts +141 -0
  355. package/src/composables/useCalendarGrid.ts +140 -0
  356. package/src/composables/useControlSchema.ts +1362 -0
  357. package/src/composables/useDebouncedWatch.ts +119 -0
  358. package/src/composables/useDropdownState.ts +83 -0
  359. package/src/composables/useEventListener.ts +111 -0
  360. package/src/composables/useExpansionSet.ts +117 -0
  361. package/src/composables/useExperimentData.ts +20 -11
  362. package/src/composables/useExperimentSave.ts +202 -50
  363. package/src/composables/useExperimentSelector.ts +86 -72
  364. package/src/composables/useForm.ts +49 -4
  365. package/src/composables/useFormBuilder.ts +93 -42
  366. package/src/composables/useGroupAssignment.ts +148 -0
  367. package/src/composables/useListSelection.ts +158 -0
  368. package/src/composables/usePluginClient.ts +466 -0
  369. package/src/composables/usePluginConfig.ts +34 -13
  370. package/src/composables/useRequestSyncState.ts +126 -0
  371. package/src/composables/useSampleGroups.ts +126 -0
  372. package/src/composables/useSelectionLimit.ts +57 -0
  373. package/src/composables/useSortedItems.ts +118 -0
  374. package/src/composables/useTemplateCollection.ts +229 -0
  375. package/src/composables/useTextSearch.ts +60 -0
  376. package/src/composables/useTheme.ts +2 -28
  377. package/src/composables/useTimeUtils.ts +26 -2
  378. package/src/composables/useWellPlateEditor.ts +13 -9
  379. package/src/index.ts +11 -348
  380. package/src/install.ts +11 -4
  381. package/src/stores/settings.ts +13 -9
  382. package/src/styles/components/app-layout.css +82 -0
  383. package/src/styles/components/app-page-selector.css +23 -0
  384. package/src/styles/components/app-pill-nav.css +77 -0
  385. package/src/styles/components/app-sidebar.css +119 -0
  386. package/src/styles/components/app-top-bar.css +0 -201
  387. package/src/styles/components/concentration-input.css +3 -142
  388. package/src/styles/components/empty-state.css +0 -16
  389. package/src/styles/components/theme-toggle.css +3 -66
  390. package/src/styles/index.css +0 -2
  391. package/src/templates/adapters.ts +785 -0
  392. package/src/templates/builders.ts +2149 -0
  393. package/src/templates/catalog.ts +245 -0
  394. package/src/templates/componentBindings.ts +653 -0
  395. package/src/templates/controlSchemas.ts +718 -0
  396. package/src/templates/index.ts +318 -0
  397. package/src/templates/lookup.ts +18 -0
  398. package/src/templates/packs.ts +156 -0
  399. package/src/templates/presets.ts +146 -0
  400. package/src/templates/types.ts +668 -0
  401. package/src/types/components.ts +39 -27
  402. package/src/types/form-builder.ts +7 -2
  403. package/src/types/index.ts +13 -3
  404. package/src/types/platform.ts +7 -1
  405. package/src/utils/formModelSync.ts +52 -0
  406. package/src/utils/items.ts +28 -0
  407. package/src/utils/options.ts +23 -0
  408. package/src/utils/pluginIcon.ts +30 -0
  409. package/dist/__tests__/composables/usePluginApi.test.d.ts +0 -13
  410. package/dist/auth-DsI0rQ7_.js.map +0 -1
  411. package/dist/components/GroupingModal.vue.d.ts +0 -12
  412. package/dist/components-_XqPEhP9.js.map +0 -1
  413. package/dist/composables/usePluginApi.d.ts +0 -29
  414. package/dist/composables-tiZqLu1M.js.map +0 -1
  415. package/dist/useScheduleDrag-CA9sGNJG.js +0 -7181
  416. package/dist/useScheduleDrag-CA9sGNJG.js.map +0 -1
  417. package/src/__tests__/composables/usePluginApi.test.ts +0 -81
  418. package/src/components/AppPageSelector.vue +0 -159
  419. package/src/components/AppPillNav.vue +0 -66
  420. package/src/components/GroupingModal.story.vue +0 -52
  421. package/src/components/GroupingModal.vue +0 -422
  422. package/src/components/SettingsButton.story.vue +0 -58
  423. package/src/components/SettingsButton.vue +0 -76
  424. package/src/composables/usePluginApi.ts +0 -39
  425. package/src/styles/components/grouping-modal.css +0 -323
  426. package/src/styles/components/settings-button.css +0 -94
  427. /package/dist/components/{ToastNotification.vue.d.ts → AppToastContainer.vue.d.ts} +0 -0
@@ -0,0 +1,2149 @@
1
+ import type {
2
+ AssayFeature,
3
+ AssayMatrixTemplate,
4
+ AssayMatrixTemplateData,
5
+ AssaySample,
6
+ BioTemplateEnvelope,
7
+ CalibrationAcceptance,
8
+ CalibrationCurveTemplate,
9
+ CalibrationCurveTemplateData,
10
+ CalibrationPoint,
11
+ CalibrationPointInput,
12
+ CompoundDoseSeries,
13
+ ControlDefinition,
14
+ CreateBioTemplatePackCollectionOptions,
15
+ CreateElisaAssayCollectionOptions,
16
+ CreateLcmsBatchCollectionOptions,
17
+ CreateAssayMatrixTemplateOptions,
18
+ CreateCalibrationCurveTemplateOptions,
19
+ CreateDoseResponseTemplateOptions,
20
+ CreateFlowCytometryAssayCollectionOptions,
21
+ CreateWesternBlotAssayCollectionOptions,
22
+ CreateFlowCytometryPanelTemplateOptions,
23
+ CreateInstrumentRunTemplateOptions,
24
+ CreatePlateMapTemplateOptions,
25
+ CreateProtocolStepsTemplateOptions,
26
+ CreateQpcrExpressionCollectionOptions,
27
+ CreateQpcrPlateTemplateOptions,
28
+ CreateReagentListTemplateOptions,
29
+ CreateSampleSheetTemplateOptions,
30
+ CreateSamplePrepTemplateOptions,
31
+ CreateTimeCourseTemplateOptions,
32
+ CreateWellPlateScreenCollectionOptions,
33
+ DoseResponseTemplate,
34
+ DoseResponseTemplateData,
35
+ FlowCytometryPanelTemplate,
36
+ FlowCytometryPanelTemplateData,
37
+ FlowPanelControl,
38
+ FlowPanelMarker,
39
+ InstrumentMethod,
40
+ InstrumentRunItem,
41
+ InstrumentRunTemplate,
42
+ InstrumentRunTemplateData,
43
+ PlateMapTemplate,
44
+ PlateMapTemplateData,
45
+ ProtocolStepRecord,
46
+ ProtocolStepsTemplate,
47
+ ProtocolStepsTemplateData,
48
+ QpcrPlateTemplate,
49
+ QpcrPlateTemplateData,
50
+ QpcrReaction,
51
+ QpcrSample,
52
+ QpcrTarget,
53
+ ReagentListTemplate,
54
+ ReagentListTemplateData,
55
+ ReagentRecord,
56
+ ReagentTemplateInput,
57
+ TimeCourseCondition,
58
+ TimeCourseTemplate,
59
+ TimeCourseTemplateData,
60
+ TimePoint,
61
+ SampleRecord,
62
+ SampleSheetTemplate,
63
+ SampleSheetTemplateData,
64
+ SamplePrepStep,
65
+ SamplePrepStepInput,
66
+ SamplePrepTemplate,
67
+ SamplePrepTemplateData,
68
+ TemplateCollection,
69
+ TemplateCollectionEnvelope,
70
+ TemplateId,
71
+ TemplatePackId,
72
+ TemplatePresetId,
73
+ TemplateSample,
74
+ } from './types'
75
+ import type { WellPlateFormat } from '../types'
76
+ import { getBioTemplateInfo } from './catalog'
77
+ import { getBioTemplatePackInfo } from './packs'
78
+ import { getBioTemplatePresetInfo } from './presets'
79
+
80
+ export const TEMPLATE_COLLECTION_KEY = 'templates'
81
+
82
+ const DEFAULT_SAMPLE_COLORS = [
83
+ '#3B82F6',
84
+ '#10B981',
85
+ '#EF4444',
86
+ '#F59E0B',
87
+ '#8B5CF6',
88
+ '#F97316',
89
+ '#06B6D4',
90
+ '#14B8A6',
91
+ '#6B7280',
92
+ ]
93
+
94
+ const PLATE_DIMENSIONS: Record<number, readonly [number, number]> = {
95
+ 6: [2, 3],
96
+ 12: [3, 4],
97
+ 24: [4, 6],
98
+ 48: [6, 8],
99
+ 54: [6, 9],
100
+ 96: [8, 12],
101
+ 384: [16, 24],
102
+ }
103
+
104
+ export function createTemplateEnvelope<TData>(
105
+ templateId: TemplateId,
106
+ data: TData,
107
+ metadata: Record<string, unknown> = {},
108
+ templateVersion = '1.0'
109
+ ): BioTemplateEnvelope<TData> {
110
+ return {
111
+ template_id: templateId,
112
+ template_version: templateVersion,
113
+ kind: 'experiment-design',
114
+ data,
115
+ metadata,
116
+ }
117
+ }
118
+
119
+ export function createPlateMapTemplate(
120
+ options: CreatePlateMapTemplateOptions = {}
121
+ ): PlateMapTemplate {
122
+ const {
123
+ id = 'plate-1',
124
+ name = 'Plate 1',
125
+ format = 96,
126
+ samples = [],
127
+ metadata = {},
128
+ } = options
129
+
130
+ const sampleDefinitions = samples.map((sample, index): TemplateSample => {
131
+ if (typeof sample !== 'string') return sample
132
+ return {
133
+ id: sampleIdFromName(sample, index),
134
+ name: sample,
135
+ color: DEFAULT_SAMPLE_COLORS[index % DEFAULT_SAMPLE_COLORS.length],
136
+ }
137
+ })
138
+
139
+ return createTemplateEnvelope('plate-map', {
140
+ plates: [
141
+ {
142
+ id,
143
+ name,
144
+ format,
145
+ wells: {},
146
+ },
147
+ ],
148
+ samples: sampleDefinitions,
149
+ activePlateId: id,
150
+ selectedWells: [],
151
+ }, metadata)
152
+ }
153
+
154
+ export function createSampleSheetTemplate(
155
+ options: CreateSampleSheetTemplateOptions
156
+ ): SampleSheetTemplate {
157
+ const data: SampleSheetTemplateData = {
158
+ samples: options.samples.map(normalizeSampleRecord),
159
+ columns: options.columns ?? [
160
+ { id: 'sampleId', label: 'Sample ID', type: 'string', required: true, groupable: false },
161
+ { id: 'name', label: 'Name', type: 'string', required: false, groupable: false },
162
+ ],
163
+ groups: options.groups ?? [],
164
+ }
165
+ validateSampleSheetData(data)
166
+ return createTemplateEnvelope('sample-sheet', data, options.metadata ?? {})
167
+ }
168
+
169
+ export function createSamplePrepTemplate(
170
+ options: CreateSamplePrepTemplateOptions
171
+ ): SamplePrepTemplate {
172
+ const prepType = options.prepType ?? 'extraction'
173
+ const volumeUnit = options.volumeUnit ?? 'uL'
174
+ const steps: SamplePrepStep[] = []
175
+ let order = 1
176
+
177
+ for (const [index, sample] of options.samples.entries()) {
178
+ if (typeof sample !== 'string') {
179
+ const step = normalizeSamplePrepStep(sample, prepType, volumeUnit, order, index)
180
+ steps.push(step)
181
+ order = Math.max(order, step.order + 1)
182
+ continue
183
+ }
184
+ const sourceSampleId = idFromName(sample, `sample-${index + 1}`)
185
+ steps.push({
186
+ id: `${sourceSampleId}-prep`,
187
+ order,
188
+ type: prepType,
189
+ name: `Prepare ${sample}`,
190
+ sourceSampleId,
191
+ destinationSampleId: `${sourceSampleId}-prepared`,
192
+ inputVolume: options.inputVolume,
193
+ outputVolume: options.outputVolume,
194
+ volumeUnit,
195
+ status: 'planned',
196
+ metadata: {},
197
+ })
198
+ order += 1
199
+ }
200
+
201
+ const data: SamplePrepTemplateData = {
202
+ protocolName: options.protocolName ?? 'Sample preparation',
203
+ steps,
204
+ metadata: options.metadata ?? {},
205
+ }
206
+ validateSamplePrepData(data)
207
+ return createTemplateEnvelope('sample-prep', data, options.metadata ?? {})
208
+ }
209
+
210
+ export function createDoseResponseTemplate(
211
+ options: CreateDoseResponseTemplateOptions
212
+ ): DoseResponseTemplate {
213
+ const compounds = Array.isArray(options.compounds)
214
+ ? options.compounds.map(normalizeCompound)
215
+ : Object.entries(options.compounds).map(([name, concentrations], index) => normalizeCompound({
216
+ id: compoundIdFromName(name, index),
217
+ name,
218
+ concentrations,
219
+ metadata: {},
220
+ }))
221
+
222
+ const layout = options.layout
223
+ ? isEnvelope<PlateMapTemplateData>(options.layout) ? options.layout.data : options.layout
224
+ : undefined
225
+
226
+ const data: DoseResponseTemplateData = {
227
+ compounds,
228
+ unit: options.unit,
229
+ replicates: options.replicates ?? 1,
230
+ controls: (options.controls ?? []).map(normalizeControl),
231
+ layout,
232
+ metadata: options.metadata ?? {},
233
+ }
234
+ validateDoseResponseData(data)
235
+ return createTemplateEnvelope('dose-response', data, options.metadata ?? {})
236
+ }
237
+
238
+ export function createCalibrationCurveTemplate(
239
+ options: CreateCalibrationCurveTemplateOptions
240
+ ): CalibrationCurveTemplate {
241
+ const unit = options.unit ?? 'uM'
242
+ const points: CalibrationPoint[] = []
243
+ let order = 1
244
+
245
+ if (options.includeBlank !== false) {
246
+ points.push({
247
+ id: 'blank',
248
+ order,
249
+ role: 'blank',
250
+ level: 'Blank',
251
+ concentration: 0,
252
+ unit,
253
+ replicate: 1,
254
+ include: true,
255
+ status: 'planned',
256
+ metadata: {},
257
+ })
258
+ order += 1
259
+ }
260
+
261
+ const standardValues: number[] = []
262
+ for (const [index, concentration] of options.concentrations.entries()) {
263
+ if (typeof concentration !== 'number') {
264
+ const point = normalizeCalibrationPoint(concentration, unit, order, index)
265
+ points.push(point)
266
+ if (point.role === 'standard') standardValues.push(point.concentration)
267
+ order = Math.max(order, point.order + 1)
268
+ continue
269
+ }
270
+ standardValues.push(concentration)
271
+ points.push({
272
+ id: `std-${index + 1}`,
273
+ order,
274
+ role: 'standard',
275
+ level: `Standard ${index + 1}`,
276
+ concentration,
277
+ unit,
278
+ replicate: 1,
279
+ include: true,
280
+ status: 'planned',
281
+ metadata: {},
282
+ })
283
+ order += 1
284
+ }
285
+
286
+ if (options.includeQc !== false) {
287
+ const positiveValues = [...standardValues].filter(value => value > 0).sort((a, b) => a - b)
288
+ const qcValues = positiveValues.length === 0
289
+ ? []
290
+ : positiveValues[0] === positiveValues[positiveValues.length - 1]
291
+ ? [positiveValues[0]]
292
+ : [positiveValues[0], positiveValues[positiveValues.length - 1]]
293
+ for (const [index, concentration] of qcValues.entries()) {
294
+ const level = index === 0 ? 'QC low' : 'QC high'
295
+ points.push({
296
+ id: idFromName(level, `qc-${index + 1}`),
297
+ order,
298
+ role: 'qc',
299
+ level,
300
+ concentration,
301
+ unit,
302
+ replicate: 1,
303
+ include: true,
304
+ status: 'planned',
305
+ metadata: {},
306
+ })
307
+ order += 1
308
+ }
309
+ }
310
+
311
+ const data: CalibrationCurveTemplateData = {
312
+ analyte: options.analyte ?? 'Analyte',
313
+ model: options.model ?? 'linear',
314
+ xUnit: unit,
315
+ responseUnit: options.responseUnit ?? 'response',
316
+ points,
317
+ acceptance: normalizeCalibrationAcceptance(options.acceptance, options.includeBlank !== false),
318
+ metadata: options.metadata ?? {},
319
+ }
320
+ validateCalibrationCurveData(data)
321
+ return createTemplateEnvelope('calibration-curve', data, options.metadata ?? {})
322
+ }
323
+
324
+ export function createTimeCourseTemplate(
325
+ options: CreateTimeCourseTemplateOptions
326
+ ): TimeCourseTemplate {
327
+ const timepoints = options.timepoints.map((timepoint, index): TimePoint => {
328
+ if (typeof timepoint !== 'number') return normalizeTimePoint(timepoint)
329
+ return {
330
+ id: timepointId(timepoint, options.unit, index),
331
+ label: `${timepoint} ${options.unit}`,
332
+ value: timepoint,
333
+ unit: options.unit,
334
+ metadata: {},
335
+ }
336
+ }).sort((a, b) => a.value - b.value)
337
+
338
+ const conditions = options.conditions.map((condition, index): TimeCourseCondition => {
339
+ if (typeof condition !== 'string') return normalizeCondition(condition)
340
+ return {
341
+ id: idFromName(condition, `condition-${index + 1}`),
342
+ name: condition,
343
+ color: DEFAULT_SAMPLE_COLORS[index % DEFAULT_SAMPLE_COLORS.length],
344
+ metadata: {},
345
+ }
346
+ })
347
+
348
+ const samples = conditions.flatMap(condition =>
349
+ timepoints.flatMap(timepoint =>
350
+ Array.from({ length: options.replicates ?? 1 }, (_, index) => ({
351
+ sampleId: `${condition.id}-${timepoint.id}-r${index + 1}`,
352
+ conditionId: condition.id,
353
+ timepointId: timepoint.id,
354
+ replicate: index + 1,
355
+ metadata: {},
356
+ }))
357
+ )
358
+ )
359
+
360
+ const data: TimeCourseTemplateData = {
361
+ timepoints,
362
+ conditions,
363
+ samples,
364
+ metadata: options.metadata ?? {},
365
+ }
366
+ validateTimeCourseData(data)
367
+ return createTemplateEnvelope('time-course', data, options.metadata ?? {})
368
+ }
369
+
370
+ export function createProtocolStepsTemplate(
371
+ options: CreateProtocolStepsTemplateOptions
372
+ ): ProtocolStepsTemplate {
373
+ const steps = options.steps.map((step, index): ProtocolStepRecord => {
374
+ if (typeof step === 'string') {
375
+ return {
376
+ id: idFromName(step, `step-${index + 1}`),
377
+ type: 'custom',
378
+ name: step,
379
+ status: 'pending',
380
+ parameters: {},
381
+ order: index,
382
+ metadata: {},
383
+ }
384
+ }
385
+ return normalizeProtocolStep(step, index)
386
+ }).sort((a, b) => a.order - b.order)
387
+
388
+ const data: ProtocolStepsTemplateData = {
389
+ steps,
390
+ metadata: options.metadata ?? {},
391
+ }
392
+ validateProtocolStepsData(data)
393
+ return createTemplateEnvelope('protocol-steps', data, options.metadata ?? {})
394
+ }
395
+
396
+ export function createAssayMatrixTemplate(
397
+ options: CreateAssayMatrixTemplateOptions
398
+ ): AssayMatrixTemplate {
399
+ const samples = options.samples.map((sample, index): AssaySample => {
400
+ if (typeof sample !== 'string') return normalizeAssaySample(sample)
401
+ return {
402
+ sampleId: idFromName(sample, `sample-${index + 1}`),
403
+ name: sample,
404
+ metadata: {},
405
+ }
406
+ })
407
+ const features = options.features.map((feature, index): AssayFeature => {
408
+ if (typeof feature !== 'string') return normalizeAssayFeature(feature)
409
+ return {
410
+ id: idFromName(feature, `feature-${index + 1}`),
411
+ name: feature,
412
+ type: 'readout',
413
+ metadata: {},
414
+ }
415
+ })
416
+ const sampleLookup = createAssaySampleLookup(samples)
417
+ const featureLookup = createAssayFeatureLookup(features)
418
+ const measurements = Object.entries(options.values ?? {}).flatMap(([sampleId, featureValues]) =>
419
+ Object.entries(featureValues).map(([featureId, value]) => ({
420
+ sampleId: sampleLookup.get(sampleId) ?? sampleId,
421
+ featureId: featureLookup.get(featureId) ?? featureId,
422
+ value,
423
+ metadata: {},
424
+ }))
425
+ )
426
+
427
+ const data: AssayMatrixTemplateData = {
428
+ samples,
429
+ features,
430
+ measurements,
431
+ metadata: options.metadata ?? {},
432
+ }
433
+ validateAssayMatrixData(data)
434
+ return createTemplateEnvelope('assay-matrix', data, options.metadata ?? {})
435
+ }
436
+
437
+ export function createReagentListTemplate(
438
+ options: CreateReagentListTemplateOptions
439
+ ): ReagentListTemplate {
440
+ const reagents = options.reagents.map((reagent, index): ReagentRecord => {
441
+ if (typeof reagent !== 'string') return normalizeReagent(reagent)
442
+ return {
443
+ id: idFromName(reagent, `reagent-${index + 1}`),
444
+ name: reagent,
445
+ kind: 'reagent',
446
+ metadata: {},
447
+ }
448
+ })
449
+ const data: ReagentListTemplateData = {
450
+ reagents,
451
+ metadata: options.metadata ?? {},
452
+ }
453
+ validateReagentListData(data)
454
+ return createTemplateEnvelope('reagent-list', data, options.metadata ?? {})
455
+ }
456
+
457
+ export function createFlowCytometryPanelTemplate(
458
+ options: CreateFlowCytometryPanelTemplateOptions
459
+ ): FlowCytometryPanelTemplate {
460
+ const markers = options.markers.map((marker, index): FlowPanelMarker => {
461
+ if (typeof marker !== 'string') return normalizeFlowMarker(marker, index)
462
+ return {
463
+ id: idFromName(marker, `marker-${index + 1}`),
464
+ marker,
465
+ fluorophore: 'unassigned',
466
+ purpose: 'phenotype',
467
+ compensationRequired: true,
468
+ metadata: {},
469
+ }
470
+ })
471
+ const controls = options.controls === undefined
472
+ ? options.includeDefaultControls === false
473
+ ? []
474
+ : defaultFlowControls(markers)
475
+ : options.controls.map((control, index): FlowPanelControl => {
476
+ if (typeof control !== 'string') return normalizeFlowControl(control, index)
477
+ return {
478
+ id: idFromName(control, `control-${index + 1}`),
479
+ name: control,
480
+ kind: 'other',
481
+ required: true,
482
+ metadata: {},
483
+ }
484
+ })
485
+ const data: FlowCytometryPanelTemplateData = {
486
+ markers,
487
+ controls,
488
+ instrument: options.instrument,
489
+ metadata: options.metadata ?? {},
490
+ }
491
+ validateFlowCytometryPanelData(data)
492
+ return createTemplateEnvelope('flow-cytometry-panel', data, options.metadata ?? {})
493
+ }
494
+
495
+ export function createInstrumentRunTemplate(
496
+ options: CreateInstrumentRunTemplateOptions
497
+ ): InstrumentRunTemplate {
498
+ const method = typeof options.method === 'string' || options.method === undefined
499
+ ? {
500
+ id: idFromName(options.method ?? 'Default method', 'method-1'),
501
+ name: options.method ?? 'Default method',
502
+ instrument: options.instrument,
503
+ metadata: {},
504
+ }
505
+ : normalizeInstrumentMethod(options.method, options.instrument)
506
+ const items: InstrumentRunItem[] = []
507
+ let order = 1
508
+
509
+ if (options.includeBlanks !== false) {
510
+ items.push({
511
+ id: 'blank-start',
512
+ order,
513
+ kind: 'blank',
514
+ name: 'Blank start',
515
+ methodId: method.id,
516
+ status: 'planned',
517
+ metadata: {},
518
+ })
519
+ order += 1
520
+ }
521
+
522
+ if (options.includeQc !== false) {
523
+ items.push({
524
+ id: 'qc-start',
525
+ order,
526
+ kind: 'qc',
527
+ name: 'QC start',
528
+ methodId: method.id,
529
+ status: 'planned',
530
+ metadata: {},
531
+ })
532
+ order += 1
533
+ }
534
+
535
+ for (const [index, item] of options.items.entries()) {
536
+ if (typeof item !== 'string') {
537
+ const normalized = normalizeInstrumentRunItem(item, method.id, order, index)
538
+ items.push(normalized)
539
+ order = Math.max(order, normalized.order + 1)
540
+ continue
541
+ }
542
+ const sampleId = idFromName(item, `sample-${index + 1}`)
543
+ items.push({
544
+ id: `${sampleId}-run`,
545
+ order,
546
+ kind: 'sample',
547
+ sampleId,
548
+ name: item,
549
+ methodId: method.id,
550
+ status: 'planned',
551
+ metadata: {},
552
+ })
553
+ order += 1
554
+ }
555
+
556
+ if (options.includeQc !== false) {
557
+ items.push({
558
+ id: 'qc-end',
559
+ order,
560
+ kind: 'qc',
561
+ name: 'QC end',
562
+ methodId: method.id,
563
+ status: 'planned',
564
+ metadata: {},
565
+ })
566
+ }
567
+
568
+ const data: InstrumentRunTemplateData = {
569
+ runId: 'run-1',
570
+ instrument: options.instrument,
571
+ methods: [method],
572
+ items,
573
+ metadata: options.metadata ?? {},
574
+ }
575
+ validateInstrumentRunData(data)
576
+ return createTemplateEnvelope('instrument-run', data, options.metadata ?? {})
577
+ }
578
+
579
+ export function createQpcrPlateTemplate(
580
+ options: CreateQpcrPlateTemplateOptions
581
+ ): QpcrPlateTemplate {
582
+ const format = options.format ?? 96
583
+ const replicates = options.replicates ?? 3
584
+ if (replicates < 1) {
585
+ throw new Error('qPCR replicates must be at least 1.')
586
+ }
587
+
588
+ const samples = options.samples.map((sample, index): QpcrSample => {
589
+ if (typeof sample !== 'string') return normalizeQpcrSample(sample)
590
+ return {
591
+ sampleId: idFromName(sample, `sample-${index + 1}`),
592
+ name: sample,
593
+ metadata: {},
594
+ }
595
+ })
596
+ const targets = options.targets.map((target, index): QpcrTarget => {
597
+ if (typeof target !== 'string') return normalizeQpcrTarget(target)
598
+ return {
599
+ id: idFromName(target, `target-${index + 1}`),
600
+ name: target,
601
+ metadata: {},
602
+ }
603
+ })
604
+ const wells = wellIdsForFormat(format)
605
+ let wellIndex = 0
606
+ const reactions: QpcrReaction[] = []
607
+
608
+ for (const sample of samples) {
609
+ for (const target of targets) {
610
+ for (let replicate = 1; replicate <= replicates; replicate += 1) {
611
+ const wellId = wells[wellIndex]
612
+ if (!wellId) throw new Error(`qPCR plate format ${format} does not have enough wells.`)
613
+ reactions.push({
614
+ id: `${sample.sampleId}-${target.id}-r${replicate}`,
615
+ wellId,
616
+ sampleId: sample.sampleId,
617
+ targetId: target.id,
618
+ replicate,
619
+ controlKind: 'sample',
620
+ flags: [],
621
+ metadata: {},
622
+ })
623
+ wellIndex += 1
624
+ }
625
+ }
626
+ }
627
+
628
+ if (options.includeNoTemplateControls !== false) {
629
+ for (const target of targets) {
630
+ const wellId = wells[wellIndex]
631
+ if (!wellId) throw new Error(`qPCR plate format ${format} does not have enough wells.`)
632
+ reactions.push({
633
+ id: `ntc-${target.id}`,
634
+ wellId,
635
+ targetId: target.id,
636
+ replicate: 1,
637
+ controlKind: 'no-template',
638
+ flags: [],
639
+ metadata: {},
640
+ })
641
+ wellIndex += 1
642
+ }
643
+ }
644
+
645
+ const data: QpcrPlateTemplateData = {
646
+ plateId: 'qpcr-plate-1',
647
+ format,
648
+ samples,
649
+ targets,
650
+ reactions,
651
+ instrument: options.instrument,
652
+ chemistry: options.chemistry ?? 'sybr',
653
+ metadata: options.metadata ?? {},
654
+ }
655
+ validateQpcrPlateData(data)
656
+ return createTemplateEnvelope('qpcr-plate', data, options.metadata ?? {})
657
+ }
658
+
659
+ export function createDefaultBioTemplate(templateId: TemplateId | string): BioTemplateEnvelope<unknown> {
660
+ const template = getBioTemplateInfo(templateId)
661
+ if (!template) {
662
+ throw new Error(`Unknown bio template '${templateId}'.`)
663
+ }
664
+
665
+ switch (template.template_id) {
666
+ case 'plate-map':
667
+ return createPlateMapTemplate({
668
+ name: 'Plate 1',
669
+ samples: ['Control', 'Treatment'],
670
+ })
671
+ case 'sample-sheet':
672
+ return createSampleSheetTemplate({
673
+ samples: [
674
+ { sampleId: 'S001', name: 'Control 1', group: 'Control' },
675
+ { sampleId: 'S002', name: 'Treatment 1', group: 'Treatment' },
676
+ ],
677
+ })
678
+ case 'sample-prep':
679
+ return createSamplePrepTemplate({
680
+ samples: ['S001', 'S002'],
681
+ prepType: 'extraction',
682
+ outputVolume: 50,
683
+ })
684
+ case 'dose-response':
685
+ return createDoseResponseTemplate({
686
+ compounds: {
687
+ 'Compound A': [10, 1, 0.1],
688
+ },
689
+ unit: 'uM',
690
+ replicates: 3,
691
+ })
692
+ case 'calibration-curve':
693
+ return createCalibrationCurveTemplate({
694
+ concentrations: [0.1, 1, 10, 100],
695
+ analyte: 'Analyte',
696
+ unit: 'uM',
697
+ })
698
+ case 'time-course':
699
+ return createTimeCourseTemplate({
700
+ timepoints: [0, 6, 24],
701
+ unit: 'hour',
702
+ conditions: ['Control', 'Treatment'],
703
+ replicates: 3,
704
+ })
705
+ case 'protocol-steps':
706
+ return createProtocolStepsTemplate({
707
+ steps: [
708
+ {
709
+ id: 'seed-cells',
710
+ type: 'addition',
711
+ name: 'Seed cells',
712
+ description: 'Add cells to wells or culture vessels.',
713
+ duration: 15,
714
+ },
715
+ {
716
+ id: 'incubate-overnight',
717
+ type: 'incubation',
718
+ name: 'Incubate overnight',
719
+ duration: 960,
720
+ parameters: { temperature: '37 C', co2: '5%' },
721
+ },
722
+ {
723
+ id: 'measure-readout',
724
+ type: 'measurement',
725
+ name: 'Measure readout',
726
+ duration: 45,
727
+ },
728
+ ],
729
+ })
730
+ case 'assay-matrix':
731
+ return createAssayMatrixTemplate({
732
+ samples: ['S001', 'S002'],
733
+ features: ['Lactate', 'Glucose'],
734
+ values: {
735
+ s001: { lactate: 1.2, glucose: 5.4 },
736
+ s002: { lactate: 2.1, glucose: 4.8 },
737
+ },
738
+ })
739
+ case 'reagent-list':
740
+ return createReagentListTemplate({
741
+ reagents: ['DMSO', 'Compound A', 'Anti-HA antibody'],
742
+ })
743
+ case 'flow-cytometry-panel':
744
+ return createFlowCytometryPanelTemplate({
745
+ markers: ['CD3', 'CD4', 'CD8'],
746
+ instrument: 'BD LSRFortessa',
747
+ })
748
+ case 'instrument-run':
749
+ return createInstrumentRunTemplate({
750
+ items: ['S001', 'S002'],
751
+ instrument: 'LC-MS',
752
+ method: 'Default method',
753
+ })
754
+ case 'qpcr-plate':
755
+ return createQpcrPlateTemplate({
756
+ samples: ['Control', 'Treatment'],
757
+ targets: ['ACTB', 'GAPDH'],
758
+ replicates: 3,
759
+ instrument: 'QuantStudio',
760
+ })
761
+ default:
762
+ throw new Error(`Unknown bio template '${templateId}'.`)
763
+ }
764
+ }
765
+
766
+ export function createBioTemplatePackCollection(
767
+ name: TemplatePackId | string,
768
+ options: CreateBioTemplatePackCollectionOptions = {},
769
+ ): TemplateCollectionEnvelope {
770
+ const pack = getBioTemplatePackInfo(name)
771
+ if (!pack) {
772
+ throw new Error(`Unknown template pack '${name}'.`)
773
+ }
774
+
775
+ return createTemplateCollection(
776
+ pack.templates.map(templateId => createDefaultBioTemplate(templateId)),
777
+ { pack: pack.name, ...(options.metadata ?? {}) },
778
+ )
779
+ }
780
+
781
+ export function createWellPlateScreenCollection(
782
+ options: CreateWellPlateScreenCollectionOptions = {}
783
+ ): TemplateCollectionEnvelope {
784
+ const sampleRecords = presetSampleRecords(options.samples ?? ['Control', 'Treatment'])
785
+ const plate = createPlateMapTemplate({
786
+ name: options.plateName ?? 'Screen plate',
787
+ format: options.plateFormat ?? 96,
788
+ samples: sampleRecords.map((sample, index) => ({
789
+ id: sample.sampleId,
790
+ name: sample.name ?? sample.sampleId,
791
+ color: DEFAULT_SAMPLE_COLORS[index % DEFAULT_SAMPLE_COLORS.length],
792
+ })),
793
+ })
794
+ const sampleSheet = createSampleSheetTemplate({ samples: sampleRecords })
795
+ const doseResponse = createDoseResponseTemplate({
796
+ compounds: options.compounds ?? { 'Compound A': [10, 1, 0.1] },
797
+ unit: options.unit ?? 'uM',
798
+ replicates: options.replicates ?? 3,
799
+ layout: plate,
800
+ })
801
+ return createTemplateCollection(
802
+ [plate, sampleSheet, doseResponse],
803
+ { preset: 'wellplate-screen', ...(options.metadata ?? {}) }
804
+ )
805
+ }
806
+
807
+ export function createQpcrExpressionCollection(
808
+ options: CreateQpcrExpressionCollectionOptions = {}
809
+ ): TemplateCollectionEnvelope {
810
+ const sampleRecords = presetSampleRecords(options.samples ?? ['Control', 'Treatment'])
811
+ const sampleSheet = createSampleSheetTemplate({ samples: sampleRecords })
812
+ const qpcr = createQpcrPlateTemplate({
813
+ samples: sampleRecords.map(sample => ({
814
+ sampleId: sample.sampleId,
815
+ name: sample.name,
816
+ group: sample.group,
817
+ metadata: sample.metadata ?? {},
818
+ })),
819
+ targets: options.targets ?? ['ACTB', 'GAPDH'],
820
+ chemistry: options.chemistry,
821
+ format: options.format,
822
+ includeNoTemplateControls: options.includeNoTemplateControls,
823
+ replicates: options.replicates ?? 3,
824
+ instrument: options.instrument,
825
+ })
826
+ return createTemplateCollection(
827
+ [sampleSheet, qpcr],
828
+ { preset: 'qpcr-expression', ...(options.metadata ?? {}) }
829
+ )
830
+ }
831
+
832
+ export function createLcmsBatchCollection(
833
+ options: CreateLcmsBatchCollectionOptions = {}
834
+ ): TemplateCollectionEnvelope {
835
+ const sampleRecords = presetSampleRecords(options.samples ?? ['S001', 'S002'])
836
+ const sampleSheet = createSampleSheetTemplate({ samples: sampleRecords })
837
+ const instrumentRun = createInstrumentRunTemplate({
838
+ items: sampleRecords.map(sample => ({
839
+ sampleId: sample.sampleId,
840
+ name: sample.name ?? sample.sampleId,
841
+ })),
842
+ method: options.method ?? 'Default method',
843
+ instrument: options.instrument ?? 'LC-MS',
844
+ includeQc: options.includeQc,
845
+ })
846
+ const assayMatrix = createAssayMatrixTemplate({
847
+ samples: sampleRecords.map(sample => ({
848
+ sampleId: sample.sampleId,
849
+ name: sample.name,
850
+ group: sample.group,
851
+ metadata: sample.metadata ?? {},
852
+ })),
853
+ features: options.features ?? ['Glucose', 'Lactate'],
854
+ })
855
+ return createTemplateCollection(
856
+ [sampleSheet, instrumentRun, assayMatrix],
857
+ { preset: 'lcms-batch', ...(options.metadata ?? {}) }
858
+ )
859
+ }
860
+
861
+ export function createElisaAssayCollection(
862
+ options: CreateElisaAssayCollectionOptions = {}
863
+ ): TemplateCollectionEnvelope {
864
+ const sampleRecords = presetSampleRecords(options.samples ?? ['Control', 'Treatment'])
865
+ const analyte = options.analyte ?? 'Analyte'
866
+ const responseUnit = options.responseUnit ?? 'OD450'
867
+ const plate = createPlateMapTemplate({
868
+ name: options.plateName ?? 'ELISA plate',
869
+ format: options.plateFormat ?? 96,
870
+ samples: [
871
+ { id: 'blank', name: 'Blank' },
872
+ { id: 'standard', name: 'Standards' },
873
+ { id: 'qc', name: 'QC' },
874
+ ...sampleRecords.map((sample, index) => ({
875
+ id: sample.sampleId,
876
+ name: sample.name ?? sample.sampleId,
877
+ color: DEFAULT_SAMPLE_COLORS[index % DEFAULT_SAMPLE_COLORS.length],
878
+ })),
879
+ ],
880
+ })
881
+ const sampleSheet = createSampleSheetTemplate({ samples: sampleRecords })
882
+ const calibrationCurve = createCalibrationCurveTemplate({
883
+ concentrations: options.standardConcentrations ?? [1000, 100, 10, 1],
884
+ analyte,
885
+ unit: options.unit ?? 'pg/mL',
886
+ responseUnit,
887
+ model: 'four-parameter-logistic',
888
+ })
889
+ const assayMatrix = createAssayMatrixTemplate({
890
+ samples: sampleRecords.map(sample => ({
891
+ sampleId: sample.sampleId,
892
+ name: sample.name,
893
+ group: sample.group,
894
+ metadata: sample.metadata ?? {},
895
+ })),
896
+ features: [{
897
+ id: idFromName(analyte, 'analyte'),
898
+ name: analyte,
899
+ type: 'protein',
900
+ unit: responseUnit,
901
+ metadata: {},
902
+ }],
903
+ metadata: { analyte, responseUnit },
904
+ })
905
+ return createTemplateCollection(
906
+ [plate, sampleSheet, calibrationCurve, assayMatrix],
907
+ { preset: 'elisa-assay', ...(options.metadata ?? {}) }
908
+ )
909
+ }
910
+
911
+ export function createFlowCytometryAssayCollection(
912
+ options: CreateFlowCytometryAssayCollectionOptions = {}
913
+ ): TemplateCollectionEnvelope {
914
+ const sampleRecords = presetSampleRecords(options.samples ?? ['Control', 'Treatment'])
915
+ const markers = options.markers ?? ['CD3', 'CD4', 'CD8']
916
+ const sampleSheet = createSampleSheetTemplate({ samples: sampleRecords })
917
+ const flowPanel = createFlowCytometryPanelTemplate({
918
+ markers,
919
+ instrument: options.instrument ?? 'Flow cytometer',
920
+ includeDefaultControls: options.includeDefaultControls,
921
+ })
922
+ const assayMatrix = createAssayMatrixTemplate({
923
+ samples: sampleRecords.map(sample => ({
924
+ sampleId: sample.sampleId,
925
+ name: sample.name,
926
+ group: sample.group,
927
+ metadata: sample.metadata ?? {},
928
+ })),
929
+ features: options.features ?? flowPanel.data.markers.map(marker => ({
930
+ id: marker.id,
931
+ name: `${marker.marker}+ cells`,
932
+ type: 'cell',
933
+ unit: '%',
934
+ metadata: { marker: marker.marker, fluorophore: marker.fluorophore },
935
+ })),
936
+ })
937
+ return createTemplateCollection(
938
+ [sampleSheet, flowPanel, assayMatrix],
939
+ { preset: 'flow-cytometry-assay', ...(options.metadata ?? {}) }
940
+ )
941
+ }
942
+
943
+ export function createWesternBlotAssayCollection(
944
+ options: CreateWesternBlotAssayCollectionOptions = {}
945
+ ): TemplateCollectionEnvelope {
946
+ const sampleRecords = presetSampleRecords(options.samples ?? ['Control', 'Treatment'])
947
+ const loadingControl = options.loadingControl ?? 'ACTB'
948
+ const targetNames = (options.targets ?? ['Target protein']).map(target =>
949
+ typeof target === 'string' ? target : target.name ?? target.id
950
+ )
951
+ const featureNames = loadingControl && !targetNames.includes(loadingControl)
952
+ ? [...targetNames, loadingControl]
953
+ : targetNames
954
+ const sampleSheet = createSampleSheetTemplate({ samples: sampleRecords })
955
+ const reagentList = createReagentListTemplate({
956
+ reagents: [
957
+ { id: 'primary-antibody', name: 'Primary antibody', kind: 'antibody', storageCondition: '4C' },
958
+ { id: 'secondary-antibody', name: 'Secondary antibody', kind: 'antibody', storageCondition: '4C' },
959
+ { id: 'lysis-buffer', name: 'Lysis buffer', kind: 'buffer', storageCondition: '4C' },
960
+ { id: 'blocking-buffer', name: 'Blocking buffer', kind: 'buffer', storageCondition: '4C' },
961
+ { id: 'wash-buffer', name: 'TBST wash buffer', kind: 'buffer', storageCondition: 'RT' },
962
+ { id: 'substrate', name: 'Detection substrate', kind: 'reagent', storageCondition: '4C' },
963
+ ],
964
+ metadata: { assay: 'western-blot' },
965
+ })
966
+ const protocol = createProtocolStepsTemplate({
967
+ steps: [
968
+ { id: 'lyse-samples', type: 'addition', name: 'Lyse samples', duration: 30 },
969
+ { id: 'run-gel', type: 'custom', name: 'Run SDS-PAGE', duration: 90 },
970
+ { id: 'transfer', type: 'transfer', name: 'Transfer to membrane', duration: 60 },
971
+ { id: 'block', type: 'incubation', name: 'Block membrane', duration: 60 },
972
+ { id: 'primary-antibody', type: 'incubation', name: 'Primary antibody incubation', duration: 960 },
973
+ { id: 'secondary-antibody', type: 'incubation', name: 'Secondary antibody incubation', duration: 60 },
974
+ { id: 'detect', type: 'measurement', name: 'Detect and quantify bands', duration: 30 },
975
+ ],
976
+ metadata: { assay: 'western-blot' },
977
+ })
978
+ const assayMatrix = createAssayMatrixTemplate({
979
+ samples: sampleRecords.map(sample => ({
980
+ sampleId: sample.sampleId,
981
+ name: sample.name,
982
+ group: sample.group,
983
+ metadata: sample.metadata ?? {},
984
+ })),
985
+ features: featureNames.map((feature, index) => ({
986
+ id: idFromName(feature, `feature-${index + 1}`),
987
+ name: feature,
988
+ type: 'protein',
989
+ unit: 'relative intensity',
990
+ metadata: { loadingControl: feature === loadingControl },
991
+ })),
992
+ metadata: { assay: 'western-blot', loadingControl },
993
+ })
994
+ return createTemplateCollection(
995
+ [sampleSheet, reagentList, protocol, assayMatrix],
996
+ { preset: 'western-blot-assay', ...(options.metadata ?? {}) }
997
+ )
998
+ }
999
+
1000
+ export function createBioTemplatePresetCollection(
1001
+ name: 'wellplate-screen',
1002
+ options?: CreateWellPlateScreenCollectionOptions
1003
+ ): TemplateCollectionEnvelope
1004
+ export function createBioTemplatePresetCollection(
1005
+ name: 'qpcr-expression',
1006
+ options?: CreateQpcrExpressionCollectionOptions
1007
+ ): TemplateCollectionEnvelope
1008
+ export function createBioTemplatePresetCollection(
1009
+ name: 'lcms-batch',
1010
+ options?: CreateLcmsBatchCollectionOptions
1011
+ ): TemplateCollectionEnvelope
1012
+ export function createBioTemplatePresetCollection(
1013
+ name: 'elisa-assay',
1014
+ options?: CreateElisaAssayCollectionOptions
1015
+ ): TemplateCollectionEnvelope
1016
+ export function createBioTemplatePresetCollection(
1017
+ name: 'flow-cytometry-assay',
1018
+ options?: CreateFlowCytometryAssayCollectionOptions
1019
+ ): TemplateCollectionEnvelope
1020
+ export function createBioTemplatePresetCollection(
1021
+ name: 'western-blot-assay',
1022
+ options?: CreateWesternBlotAssayCollectionOptions
1023
+ ): TemplateCollectionEnvelope
1024
+ export function createBioTemplatePresetCollection(
1025
+ name: string,
1026
+ options?:
1027
+ | CreateWellPlateScreenCollectionOptions
1028
+ | CreateQpcrExpressionCollectionOptions
1029
+ | CreateLcmsBatchCollectionOptions
1030
+ | CreateElisaAssayCollectionOptions
1031
+ | CreateFlowCytometryAssayCollectionOptions
1032
+ | CreateWesternBlotAssayCollectionOptions
1033
+ ): TemplateCollectionEnvelope
1034
+ export function createBioTemplatePresetCollection(
1035
+ name: TemplatePresetId | string,
1036
+ options:
1037
+ | CreateWellPlateScreenCollectionOptions
1038
+ | CreateQpcrExpressionCollectionOptions
1039
+ | CreateLcmsBatchCollectionOptions
1040
+ | CreateElisaAssayCollectionOptions
1041
+ | CreateFlowCytometryAssayCollectionOptions
1042
+ | CreateWesternBlotAssayCollectionOptions = {}
1043
+ ): TemplateCollectionEnvelope {
1044
+ const preset = getBioTemplatePresetInfo(name)
1045
+ if (!preset) {
1046
+ throw new Error(`Unknown template preset '${name}'.`)
1047
+ }
1048
+ if (preset.name === 'wellplate-screen') {
1049
+ return createWellPlateScreenCollection(options as CreateWellPlateScreenCollectionOptions)
1050
+ }
1051
+ if (preset.name === 'qpcr-expression') {
1052
+ return createQpcrExpressionCollection(options as CreateQpcrExpressionCollectionOptions)
1053
+ }
1054
+ if (preset.name === 'lcms-batch') {
1055
+ return createLcmsBatchCollection(options as CreateLcmsBatchCollectionOptions)
1056
+ }
1057
+ if (preset.name === 'elisa-assay') {
1058
+ return createElisaAssayCollection(options as CreateElisaAssayCollectionOptions)
1059
+ }
1060
+ if (preset.name === 'flow-cytometry-assay') {
1061
+ return createFlowCytometryAssayCollection(options as CreateFlowCytometryAssayCollectionOptions)
1062
+ }
1063
+ if (preset.name === 'western-blot-assay') {
1064
+ return createWesternBlotAssayCollection(options as CreateWesternBlotAssayCollectionOptions)
1065
+ }
1066
+ throw new Error(`Unknown template preset '${name}'.`)
1067
+ }
1068
+
1069
+ export type BioTemplateControlValues = Record<string, unknown>
1070
+
1071
+ export function bioTemplatePresetControlValuesToOptions(
1072
+ name: 'wellplate-screen',
1073
+ values: BioTemplateControlValues
1074
+ ): CreateWellPlateScreenCollectionOptions
1075
+ export function bioTemplatePresetControlValuesToOptions(
1076
+ name: 'qpcr-expression',
1077
+ values: BioTemplateControlValues
1078
+ ): CreateQpcrExpressionCollectionOptions
1079
+ export function bioTemplatePresetControlValuesToOptions(
1080
+ name: 'lcms-batch',
1081
+ values: BioTemplateControlValues
1082
+ ): CreateLcmsBatchCollectionOptions
1083
+ export function bioTemplatePresetControlValuesToOptions(
1084
+ name: 'elisa-assay',
1085
+ values: BioTemplateControlValues
1086
+ ): CreateElisaAssayCollectionOptions
1087
+ export function bioTemplatePresetControlValuesToOptions(
1088
+ name: 'flow-cytometry-assay',
1089
+ values: BioTemplateControlValues
1090
+ ): CreateFlowCytometryAssayCollectionOptions
1091
+ export function bioTemplatePresetControlValuesToOptions(
1092
+ name: 'western-blot-assay',
1093
+ values: BioTemplateControlValues
1094
+ ): CreateWesternBlotAssayCollectionOptions
1095
+ export function bioTemplatePresetControlValuesToOptions(
1096
+ name: TemplatePresetId | string,
1097
+ values: BioTemplateControlValues
1098
+ ):
1099
+ | CreateWellPlateScreenCollectionOptions
1100
+ | CreateQpcrExpressionCollectionOptions
1101
+ | CreateLcmsBatchCollectionOptions
1102
+ | CreateElisaAssayCollectionOptions
1103
+ | CreateFlowCytometryAssayCollectionOptions
1104
+ | CreateWesternBlotAssayCollectionOptions
1105
+ export function bioTemplatePresetControlValuesToOptions(
1106
+ name: TemplatePresetId | string,
1107
+ values: BioTemplateControlValues
1108
+ ):
1109
+ | CreateWellPlateScreenCollectionOptions
1110
+ | CreateQpcrExpressionCollectionOptions
1111
+ | CreateLcmsBatchCollectionOptions
1112
+ | CreateElisaAssayCollectionOptions
1113
+ | CreateFlowCytometryAssayCollectionOptions
1114
+ | CreateWesternBlotAssayCollectionOptions {
1115
+ const preset = getBioTemplatePresetInfo(name)
1116
+ if (!preset) {
1117
+ throw new Error(`Unknown template preset '${name}'.`)
1118
+ }
1119
+
1120
+ if (preset.name === 'wellplate-screen') {
1121
+ return {
1122
+ samples: readStringList(values.sampleNames, ['Control', 'Treatment']),
1123
+ plateName: readString(values.plateName, 'Screen plate'),
1124
+ plateFormat: readPlateFormat(values.plateFormat, 96),
1125
+ unit: readString(values.unit, 'uM'),
1126
+ replicates: readInteger(values.replicates, 3),
1127
+ }
1128
+ }
1129
+
1130
+ if (preset.name === 'qpcr-expression') {
1131
+ return {
1132
+ samples: readStringList(values.sampleNames, ['Control', 'Treatment']),
1133
+ targets: readStringList(values.targets, ['ACTB', 'GAPDH']),
1134
+ chemistry: readString(values.chemistry, 'sybr') as CreateQpcrPlateTemplateOptions['chemistry'],
1135
+ format: readPlateFormat(values.plateFormat, 96),
1136
+ includeNoTemplateControls: readBoolean(values.includeNoTemplateControls, true),
1137
+ replicates: readInteger(values.replicates, 3),
1138
+ instrument: readOptionalString(values.instrument),
1139
+ }
1140
+ }
1141
+
1142
+ if (preset.name === 'lcms-batch') {
1143
+ return {
1144
+ samples: readStringList(values.sampleNames, ['S001', 'S002']),
1145
+ features: readStringList(values.featureNames, ['Glucose', 'Lactate']),
1146
+ instrument: readString(values.instrument, 'LC-MS'),
1147
+ method: readString(values.method, 'Default method'),
1148
+ includeQc: readBoolean(values.includeQc, true),
1149
+ }
1150
+ }
1151
+
1152
+ if (preset.name === 'elisa-assay') {
1153
+ return {
1154
+ samples: readStringList(values.sampleNames, ['Control', 'Treatment']),
1155
+ analyte: readString(values.analyte, 'Analyte'),
1156
+ standardConcentrations: readNumberList(values.standardConcentrations, [1000, 100, 10, 1]),
1157
+ unit: readString(values.unit, 'pg/mL'),
1158
+ responseUnit: readString(values.responseUnit, 'OD450'),
1159
+ plateName: readString(values.plateName, 'ELISA plate'),
1160
+ plateFormat: readPlateFormat(values.plateFormat, 96),
1161
+ }
1162
+ }
1163
+
1164
+ if (preset.name === 'flow-cytometry-assay') {
1165
+ return {
1166
+ samples: readStringList(values.sampleNames, ['Control', 'Treatment']),
1167
+ markers: readStringList(values.markers, ['CD3', 'CD4', 'CD8']),
1168
+ features: readStringList(values.featureNames, ['CD3+ cells', 'CD4+ cells', 'CD8+ cells']),
1169
+ instrument: readString(values.instrument, 'Flow cytometer'),
1170
+ includeDefaultControls: readBoolean(values.includeDefaultControls, true),
1171
+ }
1172
+ }
1173
+
1174
+ if (preset.name === 'western-blot-assay') {
1175
+ return {
1176
+ samples: readStringList(values.sampleNames, ['Control', 'Treatment']),
1177
+ targets: readStringList(values.targets, ['Target protein']),
1178
+ loadingControl: readString(values.loadingControl, 'ACTB'),
1179
+ }
1180
+ }
1181
+
1182
+ throw new Error(`Unknown template preset '${name}'.`)
1183
+ }
1184
+
1185
+ export function createBioTemplatePresetCollectionFromControls(
1186
+ name: 'wellplate-screen',
1187
+ values: BioTemplateControlValues
1188
+ ): TemplateCollectionEnvelope
1189
+ export function createBioTemplatePresetCollectionFromControls(
1190
+ name: 'qpcr-expression',
1191
+ values: BioTemplateControlValues
1192
+ ): TemplateCollectionEnvelope
1193
+ export function createBioTemplatePresetCollectionFromControls(
1194
+ name: 'lcms-batch',
1195
+ values: BioTemplateControlValues
1196
+ ): TemplateCollectionEnvelope
1197
+ export function createBioTemplatePresetCollectionFromControls(
1198
+ name: 'elisa-assay',
1199
+ values: BioTemplateControlValues
1200
+ ): TemplateCollectionEnvelope
1201
+ export function createBioTemplatePresetCollectionFromControls(
1202
+ name: 'flow-cytometry-assay',
1203
+ values: BioTemplateControlValues
1204
+ ): TemplateCollectionEnvelope
1205
+ export function createBioTemplatePresetCollectionFromControls(
1206
+ name: 'western-blot-assay',
1207
+ values: BioTemplateControlValues
1208
+ ): TemplateCollectionEnvelope
1209
+ export function createBioTemplatePresetCollectionFromControls(
1210
+ name: TemplatePresetId | string,
1211
+ values: BioTemplateControlValues
1212
+ ): TemplateCollectionEnvelope
1213
+ export function createBioTemplatePresetCollectionFromControls(
1214
+ name: TemplatePresetId | string,
1215
+ values: BioTemplateControlValues
1216
+ ): TemplateCollectionEnvelope {
1217
+ const options = bioTemplatePresetControlValuesToOptions(name, values)
1218
+ return createBioTemplatePresetCollection(name, options)
1219
+ }
1220
+
1221
+ export function createTemplateCollection(
1222
+ templates: Array<BioTemplateEnvelope<unknown>>,
1223
+ metadata?: Record<string, unknown>
1224
+ ): TemplateCollectionEnvelope {
1225
+ const collection: TemplateCollection = {}
1226
+ for (const template of templates) {
1227
+ assertGenericTemplateEnvelope(template)
1228
+ const templateId = template.template_id
1229
+ if (collection[templateId]) {
1230
+ throw new Error(`Duplicate template_id '${templateId}' in template collection.`)
1231
+ }
1232
+ collection[templateId] = template
1233
+ }
1234
+
1235
+ return metadata === undefined
1236
+ ? { [TEMPLATE_COLLECTION_KEY]: collection }
1237
+ : { [TEMPLATE_COLLECTION_KEY]: collection, metadata }
1238
+ }
1239
+
1240
+ export function extractTemplateCollection(value: unknown): TemplateCollection {
1241
+ if (isEnvelope<unknown>(value)) {
1242
+ assertGenericTemplateEnvelope(value)
1243
+ return { [value.template_id]: value }
1244
+ }
1245
+ if (!isRecord(value)) {
1246
+ throw new Error('Template collection payload must be an object.')
1247
+ }
1248
+
1249
+ const rawCollection = value[TEMPLATE_COLLECTION_KEY]
1250
+ if (rawCollection === undefined) {
1251
+ return {}
1252
+ }
1253
+ if (!isRecord(rawCollection)) {
1254
+ throw new Error('Template collection must be an object.')
1255
+ }
1256
+
1257
+ const collection: TemplateCollection = {}
1258
+ for (const [templateId, template] of Object.entries(rawCollection)) {
1259
+ assertGenericTemplateEnvelope(template)
1260
+ if (template.template_id !== templateId) {
1261
+ throw new Error(
1262
+ `Template collection key '${templateId}' does not match envelope template_id '${template.template_id}'.`
1263
+ )
1264
+ }
1265
+ collection[templateId] = template
1266
+ }
1267
+ return collection
1268
+ }
1269
+
1270
+ export function ensureTemplateFromCollection<TTemplate extends BioTemplateEnvelope<unknown>>(
1271
+ value: unknown,
1272
+ templateId: TemplateId | string
1273
+ ): TTemplate {
1274
+ const collection = extractTemplateCollection(value)
1275
+ const template = collection[templateId]
1276
+ if (!template) {
1277
+ throw new Error(`Template '${templateId}' was not found in template collection.`)
1278
+ }
1279
+ assertTemplateEnvelope(template, templateId)
1280
+ return template as TTemplate
1281
+ }
1282
+
1283
+ export function assertTemplateEnvelope<TData>(
1284
+ value: unknown,
1285
+ templateId: TemplateId | string
1286
+ ): asserts value is BioTemplateEnvelope<TData> {
1287
+ assertGenericTemplateEnvelope(value)
1288
+ if (value.template_id !== templateId) {
1289
+ throw new Error(`Expected template_id '${templateId}', got '${String(value.template_id)}'.`)
1290
+ }
1291
+ }
1292
+
1293
+ export function ensureTemplateEnvelope<TTemplate extends BioTemplateEnvelope<unknown>>(
1294
+ value: unknown,
1295
+ templateId: TemplateId | string
1296
+ ): TTemplate {
1297
+ assertTemplateEnvelope(value, templateId)
1298
+ return value as TTemplate
1299
+ }
1300
+
1301
+ export function getTemplateData<TData>(
1302
+ template: BioTemplateEnvelope<TData> | TData,
1303
+ templateId?: TemplateId | string
1304
+ ): TData {
1305
+ if (isEnvelope<TData>(template)) {
1306
+ if (templateId) assertTemplateEnvelope<TData>(template, templateId)
1307
+ return template.data
1308
+ }
1309
+ return template
1310
+ }
1311
+
1312
+ export function validatePlateMapData(data: PlateMapTemplateData): void {
1313
+ if (!Array.isArray(data.plates) || data.plates.length === 0) {
1314
+ throw new Error('Plate-map template requires at least one plate.')
1315
+ }
1316
+ const sampleIds = new Set(data.samples.map(sample => sample.id))
1317
+ if (sampleIds.size !== data.samples.length) {
1318
+ throw new Error('Plate-map sample ids must be unique.')
1319
+ }
1320
+ for (const plate of data.plates) {
1321
+ for (const [wellId, well] of Object.entries(plate.wells)) {
1322
+ if (well.id !== wellId) {
1323
+ throw new Error(`Well key '${wellId}' does not match well id '${well.id}'.`)
1324
+ }
1325
+ if (well.sampleType && !sampleIds.has(well.sampleType)) {
1326
+ throw new Error(`Well '${well.id}' references unknown sample '${well.sampleType}'.`)
1327
+ }
1328
+ }
1329
+ }
1330
+ }
1331
+
1332
+ export function validateSampleSheetData(data: SampleSheetTemplateData): void {
1333
+ if (!Array.isArray(data.samples) || data.samples.length === 0) {
1334
+ throw new Error('Sample-sheet template requires at least one sample.')
1335
+ }
1336
+ const sampleIds = new Set(data.samples.map(sample => sample.sampleId))
1337
+ if (sampleIds.size !== data.samples.length) {
1338
+ throw new Error('Sample-sheet sample ids must be unique.')
1339
+ }
1340
+ for (const group of data.groups) {
1341
+ for (const sampleId of group.samples) {
1342
+ if (!sampleIds.has(sampleId)) {
1343
+ throw new Error(`Group '${group.name}' references unknown sample '${sampleId}'.`)
1344
+ }
1345
+ }
1346
+ }
1347
+ }
1348
+
1349
+ export function validateSamplePrepData(data: SamplePrepTemplateData): void {
1350
+ if (!data.protocolName) {
1351
+ throw new Error('Sample-prep template requires protocolName.')
1352
+ }
1353
+ if (!Array.isArray(data.steps) || data.steps.length === 0) {
1354
+ throw new Error('Sample-prep template requires at least one step.')
1355
+ }
1356
+ const stepIds = new Set(data.steps.map(step => step.id))
1357
+ if (stepIds.size !== data.steps.length) {
1358
+ throw new Error('Sample-prep step ids must be unique.')
1359
+ }
1360
+ const orders = new Set(data.steps.map(step => step.order))
1361
+ if (orders.size !== data.steps.length) {
1362
+ throw new Error('Sample-prep step orders must be unique.')
1363
+ }
1364
+ for (const step of data.steps) {
1365
+ if (!step.id || !step.name) {
1366
+ throw new Error('Sample-prep steps require id and name.')
1367
+ }
1368
+ if (step.order < 0) {
1369
+ throw new Error(`Sample-prep step '${step.id}' has a negative order.`)
1370
+ }
1371
+ if (!step.volumeUnit) {
1372
+ throw new Error(`Sample-prep step '${step.id}' requires volumeUnit.`)
1373
+ }
1374
+ if (step.inputVolume !== undefined && step.inputVolume < 0) {
1375
+ throw new Error(`Sample-prep step '${step.id}' has a negative input volume.`)
1376
+ }
1377
+ if (step.outputVolume !== undefined && step.outputVolume < 0) {
1378
+ throw new Error(`Sample-prep step '${step.id}' has a negative output volume.`)
1379
+ }
1380
+ if (step.durationMin !== undefined && step.durationMin < 0) {
1381
+ throw new Error(`Sample-prep step '${step.id}' has a negative duration.`)
1382
+ }
1383
+ if (!step.sourceSampleId && !step.destinationSampleId && !step.reagentId) {
1384
+ throw new Error(`Sample-prep step '${step.id}' requires a source, destination, or reagent.`)
1385
+ }
1386
+ }
1387
+ }
1388
+
1389
+ export function validateDoseResponseData(data: DoseResponseTemplateData): void {
1390
+ if (!Array.isArray(data.compounds) || data.compounds.length === 0) {
1391
+ throw new Error('Dose-response template requires at least one compound.')
1392
+ }
1393
+ if (!data.unit) {
1394
+ throw new Error('Dose-response template requires a unit.')
1395
+ }
1396
+ if (data.replicates < 1) {
1397
+ throw new Error('Dose-response replicates must be at least 1.')
1398
+ }
1399
+ for (const compound of data.compounds) {
1400
+ if (compound.concentrations.length === 0) {
1401
+ throw new Error(`Compound '${compound.name}' requires at least one concentration.`)
1402
+ }
1403
+ if (compound.concentrations.some(value => value < 0)) {
1404
+ throw new Error(`Compound '${compound.name}' has a negative concentration.`)
1405
+ }
1406
+ }
1407
+ }
1408
+
1409
+ export function validateCalibrationCurveData(data: CalibrationCurveTemplateData): void {
1410
+ if (!data.analyte) {
1411
+ throw new Error('Calibration-curve template requires an analyte.')
1412
+ }
1413
+ if (!data.xUnit) {
1414
+ throw new Error('Calibration-curve template requires xUnit.')
1415
+ }
1416
+ if (!data.responseUnit) {
1417
+ throw new Error('Calibration-curve template requires responseUnit.')
1418
+ }
1419
+ if (!Array.isArray(data.points) || data.points.length === 0) {
1420
+ throw new Error('Calibration-curve template requires at least one point.')
1421
+ }
1422
+ if (data.acceptance.minRSquared < 0 || data.acceptance.minRSquared > 1) {
1423
+ throw new Error('Calibration-curve minRSquared must be between 0 and 1.')
1424
+ }
1425
+ if (data.acceptance.standardTolerancePercent < 0 || data.acceptance.qcTolerancePercent < 0) {
1426
+ throw new Error('Calibration-curve tolerances cannot be negative.')
1427
+ }
1428
+
1429
+ const pointIds = new Set(data.points.map(point => point.id))
1430
+ if (pointIds.size !== data.points.length) {
1431
+ throw new Error('Calibration-curve point ids must be unique.')
1432
+ }
1433
+ const orders = new Set(data.points.map(point => point.order))
1434
+ if (orders.size !== data.points.length) {
1435
+ throw new Error('Calibration-curve point orders must be unique.')
1436
+ }
1437
+
1438
+ let standards = 0
1439
+ for (const point of data.points) {
1440
+ if (!point.id || !point.level) {
1441
+ throw new Error('Calibration-curve points require id and level.')
1442
+ }
1443
+ if (!point.unit) {
1444
+ throw new Error(`Calibration-curve point '${point.id}' requires unit.`)
1445
+ }
1446
+ if (point.order < 0) {
1447
+ throw new Error(`Calibration-curve point '${point.id}' has a negative order.`)
1448
+ }
1449
+ if (point.concentration < 0) {
1450
+ throw new Error(`Calibration-curve point '${point.id}' has a negative concentration.`)
1451
+ }
1452
+ if ((point.role === 'standard' || point.role === 'qc') && point.concentration <= 0) {
1453
+ throw new Error(`Calibration-curve point '${point.id}' requires positive concentration.`)
1454
+ }
1455
+ if (point.replicate < 1) {
1456
+ throw new Error(`Calibration-curve point '${point.id}' replicate must be at least 1.`)
1457
+ }
1458
+ if (point.role === 'standard' && point.include) {
1459
+ standards += 1
1460
+ }
1461
+ }
1462
+
1463
+ if (standards < 2) {
1464
+ throw new Error('Calibration-curve template requires at least two included standards.')
1465
+ }
1466
+ if (data.acceptance.requireBlank && !data.points.some(point => point.role === 'blank')) {
1467
+ throw new Error('Calibration-curve template requires a blank point when requireBlank is true.')
1468
+ }
1469
+ }
1470
+
1471
+ export function validateTimeCourseData(data: TimeCourseTemplateData): void {
1472
+ if (!Array.isArray(data.timepoints) || data.timepoints.length === 0) {
1473
+ throw new Error('Time-course template requires at least one time point.')
1474
+ }
1475
+ if (!Array.isArray(data.conditions) || data.conditions.length === 0) {
1476
+ throw new Error('Time-course template requires at least one condition.')
1477
+ }
1478
+ const timepointIds = new Set(data.timepoints.map(timepoint => timepoint.id))
1479
+ if (timepointIds.size !== data.timepoints.length) {
1480
+ throw new Error('Time-course time point ids must be unique.')
1481
+ }
1482
+ const conditionIds = new Set(data.conditions.map(condition => condition.id))
1483
+ if (conditionIds.size !== data.conditions.length) {
1484
+ throw new Error('Time-course condition ids must be unique.')
1485
+ }
1486
+ const sampleIds = new Set(data.samples.map(sample => sample.sampleId))
1487
+ if (sampleIds.size !== data.samples.length) {
1488
+ throw new Error('Time-course sample ids must be unique.')
1489
+ }
1490
+ for (const sample of data.samples) {
1491
+ if (!timepointIds.has(sample.timepointId)) {
1492
+ throw new Error(`Sample '${sample.sampleId}' references unknown time point.`)
1493
+ }
1494
+ if (!conditionIds.has(sample.conditionId)) {
1495
+ throw new Error(`Sample '${sample.sampleId}' references unknown condition.`)
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ export function validateProtocolStepsData(data: ProtocolStepsTemplateData): void {
1501
+ if (!Array.isArray(data.steps) || data.steps.length === 0) {
1502
+ throw new Error('Protocol-steps template requires at least one step.')
1503
+ }
1504
+ const stepIds = new Set(data.steps.map(step => step.id))
1505
+ if (stepIds.size !== data.steps.length) {
1506
+ throw new Error('Protocol-steps ids must be unique.')
1507
+ }
1508
+ const orders = new Set(data.steps.map(step => step.order))
1509
+ if (orders.size !== data.steps.length) {
1510
+ throw new Error('Protocol-steps orders must be unique.')
1511
+ }
1512
+ for (const step of data.steps) {
1513
+ if (!step.id) {
1514
+ throw new Error('Protocol step id is required.')
1515
+ }
1516
+ if (!step.name) {
1517
+ throw new Error(`Protocol step '${step.id}' requires a name.`)
1518
+ }
1519
+ if (step.duration !== undefined && step.duration < 0) {
1520
+ throw new Error(`Protocol step '${step.id}' has a negative duration.`)
1521
+ }
1522
+ if (step.order < 0) {
1523
+ throw new Error(`Protocol step '${step.id}' has a negative order.`)
1524
+ }
1525
+ }
1526
+ }
1527
+
1528
+ export function validateAssayMatrixData(data: AssayMatrixTemplateData): void {
1529
+ if (!Array.isArray(data.samples) || data.samples.length === 0) {
1530
+ throw new Error('Assay-matrix template requires at least one sample.')
1531
+ }
1532
+ if (!Array.isArray(data.features) || data.features.length === 0) {
1533
+ throw new Error('Assay-matrix template requires at least one feature.')
1534
+ }
1535
+ const sampleIds = new Set(data.samples.map(sample => sample.sampleId))
1536
+ if (sampleIds.size !== data.samples.length) {
1537
+ throw new Error('Assay-matrix sample ids must be unique.')
1538
+ }
1539
+ const featureIds = new Set(data.features.map(feature => feature.id))
1540
+ if (featureIds.size !== data.features.length) {
1541
+ throw new Error('Assay-matrix feature ids must be unique.')
1542
+ }
1543
+ const measurementIds = new Set<string>()
1544
+ for (const measurement of data.measurements) {
1545
+ const key = `${measurement.sampleId}:${measurement.featureId}`
1546
+ if (measurementIds.has(key)) {
1547
+ throw new Error(`Duplicate assay measurement for '${key}'.`)
1548
+ }
1549
+ measurementIds.add(key)
1550
+ if (!sampleIds.has(measurement.sampleId)) {
1551
+ throw new Error(`Measurement references unknown sample '${measurement.sampleId}'.`)
1552
+ }
1553
+ if (!featureIds.has(measurement.featureId)) {
1554
+ throw new Error(`Measurement references unknown feature '${measurement.featureId}'.`)
1555
+ }
1556
+ }
1557
+ }
1558
+
1559
+ export function validateReagentListData(data: ReagentListTemplateData): void {
1560
+ if (!Array.isArray(data.reagents) || data.reagents.length === 0) {
1561
+ throw new Error('Reagent-list template requires at least one reagent.')
1562
+ }
1563
+ const reagentIds = new Set(data.reagents.map(reagent => reagent.id))
1564
+ if (reagentIds.size !== data.reagents.length) {
1565
+ throw new Error('Reagent-list reagent ids must be unique.')
1566
+ }
1567
+ for (const reagent of data.reagents) {
1568
+ if (!reagent.id) {
1569
+ throw new Error('Reagent-list reagent id is required.')
1570
+ }
1571
+ if (!reagent.name) {
1572
+ throw new Error(`Reagent '${reagent.id}' requires a name.`)
1573
+ }
1574
+ if (reagent.stockLevel !== undefined && reagent.stockLevel < 0) {
1575
+ throw new Error(`Reagent '${reagent.id}' has a negative stock level.`)
1576
+ }
1577
+ }
1578
+ }
1579
+
1580
+ export function validateFlowCytometryPanelData(data: FlowCytometryPanelTemplateData): void {
1581
+ if (!Array.isArray(data.markers) || data.markers.length === 0) {
1582
+ throw new Error('Flow-cytometry panel template requires at least one marker.')
1583
+ }
1584
+ const markerIds = new Set(data.markers.map(marker => marker.id))
1585
+ if (markerIds.size !== data.markers.length) {
1586
+ throw new Error('Flow-cytometry panel marker ids must be unique.')
1587
+ }
1588
+ const controlIds = new Set(data.controls.map(control => control.id))
1589
+ if (controlIds.size !== data.controls.length) {
1590
+ throw new Error('Flow-cytometry panel control ids must be unique.')
1591
+ }
1592
+ for (const marker of data.markers) {
1593
+ if (!marker.id) {
1594
+ throw new Error('Flow-cytometry panel marker id is required.')
1595
+ }
1596
+ if (!marker.marker) {
1597
+ throw new Error(`Flow-cytometry panel marker '${marker.id}' requires a marker name.`)
1598
+ }
1599
+ }
1600
+ for (const control of data.controls) {
1601
+ if (!control.id) {
1602
+ throw new Error('Flow-cytometry panel control id is required.')
1603
+ }
1604
+ if (!control.name) {
1605
+ throw new Error(`Flow-cytometry panel control '${control.id}' requires a name.`)
1606
+ }
1607
+ if (control.markerId && !markerIds.has(control.markerId)) {
1608
+ throw new Error(`Flow-cytometry panel control '${control.id}' references unknown marker '${control.markerId}'.`)
1609
+ }
1610
+ }
1611
+ }
1612
+
1613
+ export function validateInstrumentRunData(data: InstrumentRunTemplateData): void {
1614
+ if (!Array.isArray(data.methods) || data.methods.length === 0) {
1615
+ throw new Error('Instrument-run template requires at least one method.')
1616
+ }
1617
+ if (!Array.isArray(data.items) || data.items.length === 0) {
1618
+ throw new Error('Instrument-run template requires at least one run item.')
1619
+ }
1620
+ const methodIds = new Set(data.methods.map(method => method.id))
1621
+ if (methodIds.size !== data.methods.length) {
1622
+ throw new Error('Instrument-run method ids must be unique.')
1623
+ }
1624
+ for (const method of data.methods) {
1625
+ if (!method.id || !method.name) {
1626
+ throw new Error('Instrument-run methods require id and name.')
1627
+ }
1628
+ }
1629
+ const itemIds = new Set(data.items.map(item => item.id))
1630
+ if (itemIds.size !== data.items.length) {
1631
+ throw new Error('Instrument-run item ids must be unique.')
1632
+ }
1633
+ const orders = new Set(data.items.map(item => item.order))
1634
+ if (orders.size !== data.items.length) {
1635
+ throw new Error('Instrument-run item orders must be unique.')
1636
+ }
1637
+ for (const item of data.items) {
1638
+ if (!item.id) {
1639
+ throw new Error('Instrument-run items require id.')
1640
+ }
1641
+ if (!methodIds.has(item.methodId)) {
1642
+ throw new Error(`Instrument-run item '${item.id}' references unknown method '${item.methodId}'.`)
1643
+ }
1644
+ if (item.kind === 'sample' && !item.sampleId) {
1645
+ throw new Error(`Instrument-run sample item '${item.id}' requires sampleId.`)
1646
+ }
1647
+ if (item.order < 0) {
1648
+ throw new Error(`Instrument-run item '${item.id}' has a negative order.`)
1649
+ }
1650
+ if (item.injectionVolume !== undefined && item.injectionVolume < 0) {
1651
+ throw new Error(`Instrument-run item '${item.id}' has a negative injection volume.`)
1652
+ }
1653
+ if (item.expectedDurationMin !== undefined && item.expectedDurationMin < 0) {
1654
+ throw new Error(`Instrument-run item '${item.id}' has a negative expected duration.`)
1655
+ }
1656
+ }
1657
+ }
1658
+
1659
+ export function validateQpcrPlateData(data: QpcrPlateTemplateData): void {
1660
+ if (!Array.isArray(data.samples) || data.samples.length === 0) {
1661
+ throw new Error('qPCR plate template requires at least one sample.')
1662
+ }
1663
+ if (!Array.isArray(data.targets) || data.targets.length === 0) {
1664
+ throw new Error('qPCR plate template requires at least one target.')
1665
+ }
1666
+ if (!Array.isArray(data.reactions) || data.reactions.length === 0) {
1667
+ throw new Error('qPCR plate template requires at least one reaction.')
1668
+ }
1669
+ const sampleIds = new Set(data.samples.map(sample => sample.sampleId))
1670
+ if (sampleIds.size !== data.samples.length) {
1671
+ throw new Error('qPCR plate sample ids must be unique.')
1672
+ }
1673
+ const targetIds = new Set(data.targets.map(target => target.id))
1674
+ if (targetIds.size !== data.targets.length) {
1675
+ throw new Error('qPCR plate target ids must be unique.')
1676
+ }
1677
+ const reactionIds = new Set(data.reactions.map(reaction => reaction.id))
1678
+ if (reactionIds.size !== data.reactions.length) {
1679
+ throw new Error('qPCR plate reaction ids must be unique.')
1680
+ }
1681
+ const wellIds = new Set(data.reactions.map(reaction => reaction.wellId))
1682
+ if (wellIds.size !== data.reactions.length) {
1683
+ throw new Error('qPCR plate reaction wells must be unique.')
1684
+ }
1685
+ for (const reaction of data.reactions) {
1686
+ assertWellInFormat(reaction.wellId, data.format)
1687
+ if (!reaction.id) {
1688
+ throw new Error('qPCR reaction id is required.')
1689
+ }
1690
+ if (!reaction.targetId || !targetIds.has(reaction.targetId)) {
1691
+ throw new Error(`qPCR reaction '${reaction.id}' references unknown target '${reaction.targetId}'.`)
1692
+ }
1693
+ if (reaction.sampleId && !sampleIds.has(reaction.sampleId)) {
1694
+ throw new Error(`qPCR reaction '${reaction.id}' references unknown sample '${reaction.sampleId}'.`)
1695
+ }
1696
+ if (reaction.controlKind === 'sample' && !reaction.sampleId) {
1697
+ throw new Error(`qPCR sample reaction '${reaction.id}' requires sampleId.`)
1698
+ }
1699
+ if (reaction.replicate < 1) {
1700
+ throw new Error(`qPCR reaction '${reaction.id}' replicate must be at least 1.`)
1701
+ }
1702
+ if (reaction.cq !== undefined && reaction.cq !== null && reaction.cq < 0) {
1703
+ throw new Error(`qPCR reaction '${reaction.id}' has a negative Cq.`)
1704
+ }
1705
+ if (reaction.quantity !== undefined && reaction.quantity !== null && reaction.quantity < 0) {
1706
+ throw new Error(`qPCR reaction '${reaction.id}' has a negative quantity.`)
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ function normalizeCompound(compound: CompoundDoseSeries): CompoundDoseSeries {
1712
+ return {
1713
+ ...compound,
1714
+ concentrations: [...compound.concentrations].sort((a, b) => b - a),
1715
+ metadata: compound.metadata ?? {},
1716
+ }
1717
+ }
1718
+
1719
+ function normalizeCalibrationAcceptance(
1720
+ acceptance?: Partial<CalibrationAcceptance>,
1721
+ requireBlank = true
1722
+ ): CalibrationAcceptance {
1723
+ return {
1724
+ minRSquared: acceptance?.minRSquared ?? 0.99,
1725
+ standardTolerancePercent: acceptance?.standardTolerancePercent ?? 15,
1726
+ qcTolerancePercent: acceptance?.qcTolerancePercent ?? 20,
1727
+ requireBlank: acceptance?.requireBlank ?? requireBlank,
1728
+ metadata: acceptance?.metadata ?? {},
1729
+ }
1730
+ }
1731
+
1732
+ function normalizeCalibrationPoint(
1733
+ point: CalibrationPointInput,
1734
+ unit: string,
1735
+ order: number,
1736
+ index: number
1737
+ ): CalibrationPoint {
1738
+ const role = point.role ?? 'standard'
1739
+ const level = point.level || `${role.charAt(0).toUpperCase()}${role.slice(1)} ${index + 1}`
1740
+ return {
1741
+ id: point.id || idFromName(level, `${role}-${index + 1}`),
1742
+ order: point.order ?? order,
1743
+ role,
1744
+ level,
1745
+ concentration: point.concentration ?? 0,
1746
+ unit: point.unit || unit,
1747
+ expectedResponse: point.expectedResponse,
1748
+ response: point.response,
1749
+ replicate: point.replicate ?? 1,
1750
+ include: point.include ?? true,
1751
+ status: point.status ?? 'planned',
1752
+ metadata: point.metadata ?? {},
1753
+ }
1754
+ }
1755
+
1756
+ function normalizeSampleRecord(sample: SampleRecord): SampleRecord {
1757
+ return {
1758
+ ...sample,
1759
+ metadata: sample.metadata ?? {},
1760
+ }
1761
+ }
1762
+
1763
+ function presetSampleRecords(samples: Array<string | SampleRecord>): SampleRecord[] {
1764
+ return samples.map((sample, index) => {
1765
+ if (typeof sample !== 'string') return normalizeSampleRecord(sample)
1766
+ return {
1767
+ sampleId: sampleIdFromName(sample, index),
1768
+ name: sample,
1769
+ metadata: {},
1770
+ }
1771
+ })
1772
+ }
1773
+
1774
+ function normalizeSamplePrepStep(
1775
+ step: SamplePrepStepInput,
1776
+ prepType: SamplePrepStep['type'],
1777
+ volumeUnit: string,
1778
+ order: number,
1779
+ index: number
1780
+ ): SamplePrepStep {
1781
+ const type = step.type ?? prepType
1782
+ const sourceSampleId = step.sourceSampleId
1783
+ const name = step.name || `Prepare ${sourceSampleId ?? index + 1}`
1784
+ return {
1785
+ id: step.id || idFromName(name, `${type}-${index + 1}`),
1786
+ order: step.order ?? order,
1787
+ type,
1788
+ name,
1789
+ sourceSampleId,
1790
+ sourcePlateId: step.sourcePlateId,
1791
+ sourceWellId: step.sourceWellId,
1792
+ destinationSampleId: step.destinationSampleId,
1793
+ destinationPlateId: step.destinationPlateId,
1794
+ destinationWellId: step.destinationWellId,
1795
+ reagentId: step.reagentId,
1796
+ inputVolume: step.inputVolume,
1797
+ outputVolume: step.outputVolume,
1798
+ volumeUnit: step.volumeUnit || volumeUnit,
1799
+ durationMin: step.durationMin,
1800
+ status: step.status ?? 'planned',
1801
+ metadata: step.metadata ?? {},
1802
+ }
1803
+ }
1804
+
1805
+ function normalizeControl(control: ControlDefinition): ControlDefinition {
1806
+ return {
1807
+ ...control,
1808
+ metadata: control.metadata ?? {},
1809
+ }
1810
+ }
1811
+
1812
+ function normalizeTimePoint(timepoint: TimePoint): TimePoint {
1813
+ return {
1814
+ ...timepoint,
1815
+ metadata: timepoint.metadata ?? {},
1816
+ }
1817
+ }
1818
+
1819
+ function normalizeCondition(condition: TimeCourseCondition): TimeCourseCondition {
1820
+ return {
1821
+ ...condition,
1822
+ metadata: condition.metadata ?? {},
1823
+ }
1824
+ }
1825
+
1826
+ function normalizeProtocolStep(
1827
+ step: Partial<ProtocolStepRecord> & { name: string },
1828
+ index: number
1829
+ ): ProtocolStepRecord {
1830
+ return {
1831
+ id: step.id ?? idFromName(step.name, `step-${index + 1}`),
1832
+ type: step.type ?? 'custom',
1833
+ name: step.name,
1834
+ description: step.description,
1835
+ duration: step.duration,
1836
+ status: step.status ?? 'pending',
1837
+ parameters: step.parameters ?? {},
1838
+ order: step.order ?? index,
1839
+ metadata: step.metadata ?? {},
1840
+ }
1841
+ }
1842
+
1843
+ function normalizeAssaySample(sample: AssaySample): AssaySample {
1844
+ return {
1845
+ ...sample,
1846
+ metadata: sample.metadata ?? {},
1847
+ }
1848
+ }
1849
+
1850
+ function normalizeAssayFeature(feature: AssayFeature): AssayFeature {
1851
+ return {
1852
+ ...feature,
1853
+ metadata: feature.metadata ?? {},
1854
+ }
1855
+ }
1856
+
1857
+ function normalizeReagent(reagent: ReagentTemplateInput): ReagentRecord {
1858
+ return {
1859
+ ...reagent,
1860
+ expiryDate: normalizeDateString(reagent.expiryDate),
1861
+ kind: reagent.kind ?? 'reagent',
1862
+ metadata: reagent.metadata ?? {},
1863
+ }
1864
+ }
1865
+
1866
+ function normalizeDateString(value: Date | string | null | undefined): string | undefined {
1867
+ if (value === undefined || value === null) return undefined
1868
+ return value instanceof Date ? value.toISOString() : value
1869
+ }
1870
+
1871
+ function normalizeFlowMarker(
1872
+ marker: Partial<FlowPanelMarker> & { marker: string },
1873
+ index: number
1874
+ ): FlowPanelMarker {
1875
+ return {
1876
+ id: marker.id ?? idFromName(marker.marker, `marker-${index + 1}`),
1877
+ marker: marker.marker,
1878
+ fluorophore: marker.fluorophore ?? 'unassigned',
1879
+ detector: marker.detector,
1880
+ clone: marker.clone,
1881
+ reagentId: marker.reagentId,
1882
+ purpose: marker.purpose ?? 'phenotype',
1883
+ compensationRequired: marker.compensationRequired ?? true,
1884
+ metadata: marker.metadata ?? {},
1885
+ }
1886
+ }
1887
+
1888
+ function normalizeFlowControl(
1889
+ control: Partial<FlowPanelControl> & { name: string },
1890
+ index: number
1891
+ ): FlowPanelControl {
1892
+ return {
1893
+ id: control.id ?? idFromName(control.name, `control-${index + 1}`),
1894
+ name: control.name,
1895
+ kind: control.kind ?? 'other',
1896
+ markerId: control.markerId,
1897
+ required: control.required ?? true,
1898
+ metadata: control.metadata ?? {},
1899
+ }
1900
+ }
1901
+
1902
+ function normalizeInstrumentMethod(
1903
+ method: Partial<InstrumentMethod> & { name?: string },
1904
+ fallbackInstrument?: string
1905
+ ): InstrumentMethod {
1906
+ const name = method.name || method.id || 'Default method'
1907
+ return {
1908
+ id: method.id || idFromName(name, 'method-1'),
1909
+ name,
1910
+ instrument: method.instrument ?? fallbackInstrument,
1911
+ acquisitionMode: method.acquisitionMode,
1912
+ metadata: method.metadata ?? {},
1913
+ }
1914
+ }
1915
+
1916
+ function normalizeInstrumentRunItem(
1917
+ item: Partial<InstrumentRunItem> & { name?: string; sampleId?: string },
1918
+ defaultMethodId: string,
1919
+ fallbackOrder: number,
1920
+ index: number
1921
+ ): InstrumentRunItem {
1922
+ const kind = item.kind ?? 'sample'
1923
+ const sampleId = item.sampleId || (kind === 'sample'
1924
+ ? idFromName(item.name ?? `Sample ${index + 1}`, `sample-${index + 1}`)
1925
+ : undefined)
1926
+ const label = item.name ?? sampleId ?? kind
1927
+ return {
1928
+ id: item.id || idFromName(label, `${kind}-${index + 1}`),
1929
+ order: item.order ?? fallbackOrder,
1930
+ kind,
1931
+ sampleId,
1932
+ name: item.name,
1933
+ methodId: item.methodId || defaultMethodId,
1934
+ vial: item.vial,
1935
+ plateId: item.plateId,
1936
+ wellId: item.wellId,
1937
+ injectionVolume: item.injectionVolume,
1938
+ expectedDurationMin: item.expectedDurationMin,
1939
+ status: item.status ?? 'planned',
1940
+ metadata: item.metadata ?? {},
1941
+ }
1942
+ }
1943
+
1944
+ function normalizeQpcrSample(sample: QpcrSample): QpcrSample {
1945
+ return {
1946
+ ...sample,
1947
+ metadata: sample.metadata ?? {},
1948
+ }
1949
+ }
1950
+
1951
+ function normalizeQpcrTarget(target: QpcrTarget): QpcrTarget {
1952
+ return {
1953
+ ...target,
1954
+ metadata: target.metadata ?? {},
1955
+ }
1956
+ }
1957
+
1958
+ function defaultFlowControls(markers: FlowPanelMarker[]): FlowPanelControl[] {
1959
+ return [
1960
+ {
1961
+ id: 'unstained',
1962
+ name: 'Unstained',
1963
+ kind: 'unstained',
1964
+ required: true,
1965
+ metadata: {},
1966
+ },
1967
+ ...markers
1968
+ .filter(marker => marker.compensationRequired)
1969
+ .map(marker => ({
1970
+ id: `${marker.id}-single-stain`,
1971
+ name: `${marker.marker} single stain`,
1972
+ kind: 'single-stain' as const,
1973
+ markerId: marker.id,
1974
+ required: true,
1975
+ metadata: {},
1976
+ })),
1977
+ ]
1978
+ }
1979
+
1980
+ function createAssaySampleLookup(samples: AssaySample[]): Map<string, string> {
1981
+ const lookup = new Map<string, string>()
1982
+ for (const sample of samples) {
1983
+ addLookupAlias(lookup, sample.sampleId, sample.sampleId)
1984
+ if (sample.name) {
1985
+ addLookupAlias(lookup, sample.name, sample.sampleId)
1986
+ addLookupAlias(lookup, idFromName(sample.name, sample.sampleId), sample.sampleId)
1987
+ }
1988
+ }
1989
+ return lookup
1990
+ }
1991
+
1992
+ function createAssayFeatureLookup(features: AssayFeature[]): Map<string, string> {
1993
+ const lookup = new Map<string, string>()
1994
+ for (const feature of features) {
1995
+ addLookupAlias(lookup, feature.id, feature.id)
1996
+ addLookupAlias(lookup, feature.name, feature.id)
1997
+ addLookupAlias(lookup, idFromName(feature.name, feature.id), feature.id)
1998
+ }
1999
+ return lookup
2000
+ }
2001
+
2002
+ function addLookupAlias(lookup: Map<string, string>, alias: string, value: string): void {
2003
+ if (!lookup.has(alias)) {
2004
+ lookup.set(alias, value)
2005
+ }
2006
+ }
2007
+
2008
+ function wellIdsForFormat(format: number): string[] {
2009
+ const dimensions = PLATE_DIMENSIONS[format]
2010
+ if (!dimensions) {
2011
+ throw new Error(`Unsupported plate format '${format}'.`)
2012
+ }
2013
+ const [rows, cols] = dimensions
2014
+ return Array.from({ length: rows }, (_, row) =>
2015
+ Array.from({ length: cols }, (_col, col) => `${rowLabel(row)}${col + 1}`)
2016
+ ).flat()
2017
+ }
2018
+
2019
+ function assertWellInFormat(wellId: string, format: number): void {
2020
+ const dimensions = PLATE_DIMENSIONS[format]
2021
+ if (!dimensions) {
2022
+ throw new Error(`Unsupported plate format '${format}'.`)
2023
+ }
2024
+ const match = /^([A-Z]+)([1-9][0-9]*)$/.exec(wellId.toUpperCase())
2025
+ if (!match) {
2026
+ throw new Error(`Invalid well id '${wellId}'.`)
2027
+ }
2028
+ const row = rowIndex(match[1])
2029
+ const col = Number(match[2]) - 1
2030
+ const [rows, cols] = dimensions
2031
+ if (row < 0 || col < 0 || row >= rows || col >= cols) {
2032
+ throw new Error(`Well '${wellId}' is outside a ${format}-well plate.`)
2033
+ }
2034
+ }
2035
+
2036
+ function rowLabel(index: number): string {
2037
+ let label = ''
2038
+ let current = index
2039
+ while (true) {
2040
+ label = String.fromCharCode(65 + (current % 26)) + label
2041
+ current = Math.floor(current / 26) - 1
2042
+ if (current < 0) return label
2043
+ }
2044
+ }
2045
+
2046
+ function rowIndex(label: string): number {
2047
+ return [...label].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0) - 1
2048
+ }
2049
+
2050
+ function assertGenericTemplateEnvelope(value: unknown): asserts value is BioTemplateEnvelope<unknown> {
2051
+ if (!isRecord(value)) {
2052
+ throw new Error('Template envelope must be an object.')
2053
+ }
2054
+ if (typeof value.template_id !== 'string' || value.template_id.length === 0) {
2055
+ throw new Error('Template envelope requires a template_id string.')
2056
+ }
2057
+ if (typeof value.template_version !== 'string' || value.template_version.length === 0) {
2058
+ throw new Error('Template envelope requires a template_version string.')
2059
+ }
2060
+ if (!isRecord(value.data)) {
2061
+ throw new Error('Template envelope data must be an object.')
2062
+ }
2063
+ if ('metadata' in value && !isRecord(value.metadata)) {
2064
+ throw new Error('Template envelope metadata must be an object.')
2065
+ }
2066
+ }
2067
+
2068
+ function isEnvelope<TData>(value: unknown): value is BioTemplateEnvelope<TData> {
2069
+ return isRecord(value) && 'template_id' in value && 'data' in value
2070
+ }
2071
+
2072
+ function isRecord(value: unknown): value is Record<string, unknown> {
2073
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
2074
+ }
2075
+
2076
+ function readStringList(value: unknown, fallback: string[]): string[] {
2077
+ if (Array.isArray(value)) {
2078
+ const items = value.map(item => String(item).trim()).filter(Boolean)
2079
+ return items.length ? items : fallback
2080
+ }
2081
+ if (typeof value === 'string') {
2082
+ const items = value.split(',').map(item => item.trim()).filter(Boolean)
2083
+ return items.length ? items : fallback
2084
+ }
2085
+ return fallback
2086
+ }
2087
+
2088
+ function readNumberList(value: unknown, fallback: number[]): number[] {
2089
+ const rawItems = Array.isArray(value)
2090
+ ? value
2091
+ : typeof value === 'string'
2092
+ ? value.split(',')
2093
+ : []
2094
+ const items = rawItems
2095
+ .map(item => Number(String(item).trim()))
2096
+ .filter(item => Number.isFinite(item))
2097
+ return items.length ? items : fallback
2098
+ }
2099
+
2100
+ function readString(value: unknown, fallback: string): string {
2101
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback
2102
+ }
2103
+
2104
+ function readOptionalString(value: unknown): string | undefined {
2105
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
2106
+ }
2107
+
2108
+ function readInteger(value: unknown, fallback: number): number {
2109
+ const parsed = typeof value === 'number'
2110
+ ? value
2111
+ : typeof value === 'string'
2112
+ ? Number(value)
2113
+ : Number.NaN
2114
+ return Number.isFinite(parsed) ? Math.max(1, Math.round(parsed)) : fallback
2115
+ }
2116
+
2117
+ function readBoolean(value: unknown, fallback: boolean): boolean {
2118
+ return typeof value === 'boolean' ? value : fallback
2119
+ }
2120
+
2121
+ function readPlateFormat(value: unknown, fallback: WellPlateFormat): WellPlateFormat {
2122
+ const parsed = typeof value === 'number'
2123
+ ? value
2124
+ : typeof value === 'string'
2125
+ ? Number(value)
2126
+ : Number.NaN
2127
+ return parsed in PLATE_DIMENSIONS ? (parsed as WellPlateFormat) : fallback
2128
+ }
2129
+
2130
+ function sampleIdFromName(name: string, index: number): string {
2131
+ return idFromName(name, `sample-${index + 1}`)
2132
+ }
2133
+
2134
+ function compoundIdFromName(name: string, index: number): string {
2135
+ return idFromName(name, `compound-${index + 1}`)
2136
+ }
2137
+
2138
+ function idFromName(name: string, fallback: string): string {
2139
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
2140
+ return slug || fallback
2141
+ }
2142
+
2143
+ function timepointId(value: number, unit: string, index: number): string {
2144
+ const raw = Number.isInteger(value)
2145
+ ? String(value)
2146
+ : String(value).replace(/0+$/, '').replace(/\.$/, '')
2147
+ const token = raw.replace('.', 'p')
2148
+ return token ? `t${token}-${unit}` : `timepoint-${index + 1}`
2149
+ }