@morscherlab/mint-sdk 1.0.0-beta.1 → 1.0.0-beta.3
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.
- package/README.md +218 -6
- package/dist/__tests__/components/ActionItem.test.d.ts +1 -0
- package/dist/__tests__/components/AppAvatarMenu.test.d.ts +1 -0
- package/dist/__tests__/components/AppPageSelector.test.d.ts +1 -0
- package/dist/__tests__/components/AppPillNav.test.d.ts +1 -0
- package/dist/__tests__/components/AppPluginSwitcher.test.d.ts +1 -0
- package/dist/__tests__/components/AppToastContainer.test.d.ts +1 -0
- package/dist/__tests__/components/BaseRadioGroup.test.d.ts +1 -0
- package/dist/__tests__/components/BaseSelect.test.d.ts +1 -0
- package/dist/__tests__/components/BaseTabs.test.d.ts +1 -0
- package/dist/__tests__/components/BatchProgressList.test.d.ts +1 -0
- package/dist/__tests__/components/BioTemplateExperimentWorkspaceView.test.d.ts +1 -0
- package/dist/__tests__/components/BioTemplatePackWorkspaceView.test.d.ts +1 -0
- package/dist/__tests__/components/BioTemplatePresetWorkspaceView.test.d.ts +1 -0
- package/dist/__tests__/components/BioTemplateRenderer.test.d.ts +1 -0
- package/dist/__tests__/components/Breadcrumb.test.d.ts +1 -0
- package/dist/__tests__/components/CalendarGridPanel.test.d.ts +1 -0
- package/dist/__tests__/components/ConcentrationInput.test.d.ts +1 -0
- package/dist/__tests__/components/ControlWorkspaceView.test.d.ts +1 -0
- package/dist/__tests__/components/DatePicker.test.d.ts +1 -0
- package/dist/__tests__/components/DateTimePicker.test.d.ts +1 -0
- package/dist/__tests__/components/EmptyState.test.d.ts +1 -0
- package/dist/__tests__/components/ExperimentPopover.test.d.ts +1 -0
- package/dist/__tests__/components/FormBuilder.test.d.ts +1 -0
- package/dist/__tests__/components/FormCompatibility.test.d.ts +1 -0
- package/dist/__tests__/components/GroupAssigner.test.d.ts +1 -0
- package/dist/__tests__/components/GroupingModal.test.d.ts +1 -0
- package/dist/__tests__/components/MultiSelect.test.d.ts +1 -0
- package/dist/__tests__/components/PluginIcon.test.d.ts +1 -0
- package/dist/__tests__/components/ProtocolStepEditor.test.d.ts +1 -0
- package/dist/__tests__/components/ReagentList.test.d.ts +1 -0
- package/dist/__tests__/components/SampleHierarchyTree.test.d.ts +1 -0
- package/dist/__tests__/components/SampleSelector.test.d.ts +1 -0
- package/dist/__tests__/components/SegmentedControl.test.d.ts +1 -0
- package/dist/__tests__/components/SettingsButton.test.d.ts +1 -0
- package/dist/__tests__/components/SettingsModal.test.d.ts +1 -0
- package/dist/__tests__/components/TagsInput.test.d.ts +1 -0
- package/dist/__tests__/components/ThemeToggle.test.d.ts +1 -0
- package/dist/__tests__/components/TimePicker.test.d.ts +1 -0
- package/dist/__tests__/composables/useBioTemplatePackWorkspace.test.d.ts +1 -0
- package/dist/__tests__/composables/useBioTemplatePresetWorkspace.test.d.ts +1 -0
- package/dist/__tests__/composables/useBioTemplateWorkspace.test.d.ts +1 -0
- package/dist/__tests__/composables/useCalendarGrid.test.d.ts +1 -0
- package/dist/__tests__/composables/useControlSchema.test.d.ts +1 -0
- package/dist/__tests__/composables/useDebouncedWatch.test.d.ts +1 -0
- package/dist/__tests__/composables/useDropdownState.test.d.ts +1 -0
- package/dist/__tests__/composables/useEventListener.test.d.ts +1 -0
- package/dist/__tests__/composables/useExpansionSet.test.d.ts +1 -0
- package/dist/__tests__/composables/useExperimentData.test.d.ts +1 -0
- package/dist/__tests__/composables/useExperimentSelector.test.d.ts +1 -0
- package/dist/__tests__/composables/useGroupAssignment.test.d.ts +1 -0
- package/dist/__tests__/composables/useListSelection.test.d.ts +1 -0
- package/dist/__tests__/composables/usePluginClient.test.d.ts +1 -0
- package/dist/__tests__/composables/usePluginConfig.test.d.ts +1 -0
- package/dist/__tests__/composables/useRequestSyncState.test.d.ts +1 -0
- package/dist/__tests__/composables/useSampleGroups.test.d.ts +1 -0
- package/dist/__tests__/composables/useSelectionLimit.test.d.ts +1 -0
- package/dist/__tests__/composables/useSortedItems.test.d.ts +1 -0
- package/dist/__tests__/composables/useTemplateCollection.test.d.ts +1 -0
- package/dist/__tests__/composables/useTextSearch.test.d.ts +1 -0
- package/dist/__tests__/composables/useTheme.test.d.ts +1 -0
- package/dist/__tests__/composables/useTimeUtils.test.d.ts +1 -0
- package/dist/__tests__/docs/frontendDocsCatalog.test.d.ts +1 -0
- package/dist/__tests__/templates/templates.test.d.ts +1 -0
- package/dist/{auth-DsI0rQ7_.js → auth-QQj2kkze.js} +12 -5
- package/dist/auth-QQj2kkze.js.map +1 -0
- package/dist/components/ActionItem.vue.d.ts +32 -0
- package/dist/components/AppAvatarMenu.vue.d.ts +2 -7
- package/dist/components/AppPageSelector.vue.d.ts +3 -6
- package/dist/components/AppPillNav.vue.d.ts +2 -2
- package/dist/components/AppSidebar.vue.d.ts +56 -3
- package/dist/components/AppToastContainer.vue.d.ts +2 -0
- package/dist/components/AppTopBar.vue.d.ts +43 -10
- package/dist/components/BaseButton.vue.d.ts +2 -2
- package/dist/components/BaseCheckbox.vue.d.ts +1 -1
- package/dist/components/BaseInput.vue.d.ts +2 -2
- package/dist/components/BasePill.vue.d.ts +3 -3
- package/dist/components/BaseRadioGroup.vue.d.ts +4 -4
- package/dist/components/BaseSelect.vue.d.ts +4 -4
- package/dist/components/BaseSlider.vue.d.ts +1 -1
- package/dist/components/BaseTabs.vue.d.ts +2 -2
- package/dist/components/BaseTextarea.vue.d.ts +2 -2
- package/dist/components/BaseToggle.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +117 -0
- package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +92 -0
- package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +82 -0
- package/dist/components/BioTemplateRenderer.vue.d.ts +29 -0
- package/dist/components/Breadcrumb.vue.d.ts +2 -2
- package/dist/components/Calendar.vue.d.ts +1 -1
- package/dist/components/CalendarGridPanel.vue.d.ts +25 -0
- package/dist/components/CollapsibleCard.vue.d.ts +1 -1
- package/dist/components/ColorSlider.vue.d.ts +1 -1
- package/dist/components/ConcentrationInput.vue.d.ts +2 -2
- package/dist/components/ConfirmDialog.vue.d.ts +2 -2
- package/dist/components/ControlWorkspaceView.vue.d.ts +130 -0
- package/dist/components/DatePicker.vue.d.ts +2 -2
- package/dist/components/DateTimePicker.vue.d.ts +3 -3
- package/dist/components/DropdownButton.vue.d.ts +4 -4
- package/dist/components/EmptyState.vue.d.ts +1 -2
- package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
- package/dist/components/ExperimentTimeline.vue.d.ts +2 -2
- package/dist/components/FileUploader.vue.d.ts +2 -2
- package/dist/components/FitPanel.vue.d.ts +1 -1
- package/dist/components/FormActions.vue.d.ts +4 -4
- package/dist/components/FormBuilder.vue.d.ts +22 -8
- package/dist/components/FormFieldRenderer.vue.d.ts +7 -10
- package/dist/components/FormSection.vue.d.ts +11 -24
- package/dist/components/FormulaInput.vue.d.ts +2 -2
- package/dist/components/IconButton.vue.d.ts +1 -1
- package/dist/components/LoadingSpinner.vue.d.ts +1 -1
- package/dist/components/MoleculeInput.vue.d.ts +2 -2
- package/dist/components/MultiSelect.vue.d.ts +3 -3
- package/dist/components/NumberInput.vue.d.ts +2 -2
- package/dist/components/PluginIcon.vue.d.ts +11 -0
- package/dist/components/ProgressBar.vue.d.ts +2 -2
- package/dist/components/ProtocolStepEditor.vue.d.ts +3 -1
- package/dist/components/RackEditor.vue.d.ts +2 -2
- package/dist/components/ReagentEditor.vue.d.ts +1 -1
- package/dist/components/ResourceCard.vue.d.ts +1 -1
- package/dist/components/SampleLegend.vue.d.ts +2 -2
- package/dist/components/SampleSelector.vue.d.ts +1 -1
- package/dist/components/ScheduleCalendar.vue.d.ts +2 -2
- package/dist/components/ScientificNumber.vue.d.ts +1 -1
- package/dist/components/SegmentedControl.vue.d.ts +3 -3
- package/dist/components/SequenceInput.vue.d.ts +3 -3
- package/dist/components/SettingsButton.vue.d.ts +2 -2
- package/dist/components/SettingsModal.vue.d.ts +32 -4
- package/dist/components/StatusIndicator.vue.d.ts +1 -1
- package/dist/components/TagsInput.vue.d.ts +3 -2
- package/dist/components/TimePicker.vue.d.ts +3 -3
- package/dist/components/TimeRangeInput.vue.d.ts +2 -2
- package/dist/components/UnitInput.vue.d.ts +2 -2
- package/dist/components/WellPlate.vue.d.ts +8 -8
- package/dist/components/index.d.ts +12 -1
- package/dist/components/index.js +3 -3
- package/dist/components/internal/FormFieldRendererInternal.vue.d.ts +31 -0
- package/dist/components/internal/FormSectionRenderer.vue.d.ts +43 -0
- package/dist/{components-CzbQQPCb.js → components-D_Sr0adg.js} +9629 -8647
- package/dist/components-D_Sr0adg.js.map +1 -0
- package/dist/composables/index.d.ts +21 -2
- package/dist/composables/index.js +4 -3
- package/dist/composables/platformContextHelpers.d.ts +14 -0
- package/dist/composables/useBioTemplateComponents.d.ts +20 -0
- package/dist/composables/useBioTemplateControls.d.ts +6 -0
- package/dist/composables/useBioTemplatePackWorkspace.d.ts +45 -0
- package/dist/composables/useBioTemplatePresetWorkspace.d.ts +74 -0
- package/dist/composables/useBioTemplateWorkspace.d.ts +50 -0
- package/dist/composables/useCalendarGrid.d.ts +26 -0
- package/dist/composables/useControlSchema.d.ts +321 -0
- package/dist/composables/useDebouncedWatch.d.ts +20 -0
- package/dist/composables/useDropdownState.d.ts +19 -0
- package/dist/composables/useEventListener.d.ts +13 -0
- package/dist/composables/useExpansionSet.d.ts +21 -0
- package/dist/composables/useExperimentData.d.ts +10 -0
- package/dist/composables/useExperimentSave.d.ts +31 -2
- package/dist/composables/useExperimentSelector.d.ts +20 -0
- package/dist/composables/useForm.d.ts +2 -0
- package/dist/composables/useGroupAssignment.d.ts +31 -0
- package/dist/composables/useListSelection.d.ts +35 -0
- package/dist/composables/usePlatformContext.d.ts +24 -3
- package/dist/composables/usePluginApi.d.ts +7 -14
- package/dist/composables/usePluginClient.d.ts +109 -0
- package/dist/composables/usePluginConfig.d.ts +12 -0
- package/dist/composables/useRequestSyncState.d.ts +34 -0
- package/dist/composables/useSampleGroups.d.ts +32 -0
- package/dist/composables/useSelectionLimit.d.ts +17 -0
- package/dist/composables/useSortedItems.d.ts +32 -0
- package/dist/composables/useTemplateCollection.d.ts +58 -0
- package/dist/composables/useTextSearch.d.ts +18 -0
- package/dist/composables/useTimeUtils.d.ts +8 -0
- package/dist/{composables-BXklV5ii.js → composables-C3dpXQN5.js} +228 -146
- package/dist/composables-C3dpXQN5.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +6 -5
- package/dist/install.d.ts +7 -2
- package/dist/install.js +2 -2
- package/dist/install.js.map +1 -1
- package/dist/stores/auth.d.ts +1 -1
- package/dist/stores/index.js +1 -1
- package/dist/stores/settings.d.ts +4 -1
- package/dist/styles.css +5255 -5654
- package/dist/templates/adapters.d.ts +43 -0
- package/dist/templates/builders.d.ts +63 -0
- package/dist/templates/catalog.d.ts +188 -0
- package/dist/templates/componentBindings.d.ts +58 -0
- package/dist/templates/controlSchemas.d.ts +25 -0
- package/dist/templates/index.d.ts +15 -0
- package/dist/templates/index.js +2 -0
- package/dist/templates/lookup.d.ts +4 -0
- package/dist/templates/packs.d.ts +18 -0
- package/dist/templates/presets.d.ts +90 -0
- package/dist/templates/types.d.ts +531 -0
- package/dist/templates-50NPjaxL.js +9333 -0
- package/dist/templates-50NPjaxL.js.map +1 -0
- package/dist/types/components.d.ts +62 -1
- package/dist/types/form-builder.d.ts +6 -8
- package/dist/types/index.d.ts +2 -2
- package/dist/types/platform.d.ts +8 -1
- package/dist/useScheduleDrag-D4oWdh41.js +4371 -0
- package/dist/useScheduleDrag-D4oWdh41.js.map +1 -0
- package/dist/utils/formModelSync.d.ts +5 -0
- package/dist/utils/items.d.ts +8 -0
- package/dist/utils/options.d.ts +6 -0
- package/dist/utils/pluginIcon.d.ts +9 -0
- package/package.json +7 -2
- package/src/__tests__/components/ActionItem.test.ts +99 -0
- package/src/__tests__/components/AppAvatarMenu.test.ts +27 -0
- package/src/__tests__/components/AppPageSelector.test.ts +134 -0
- package/src/__tests__/components/AppPillNav.test.ts +78 -0
- package/src/__tests__/components/AppPluginSwitcher.test.ts +44 -0
- package/src/__tests__/components/AppSidebar.test.ts +370 -0
- package/src/__tests__/components/AppToastContainer.test.ts +48 -0
- package/src/__tests__/components/AppTopBar.test.ts +414 -13
- package/src/__tests__/components/BaseRadioGroup.test.ts +25 -0
- package/src/__tests__/components/BaseSelect.test.ts +21 -0
- package/src/__tests__/components/BaseTabs.test.ts +25 -0
- package/src/__tests__/components/BatchProgressList.test.ts +52 -0
- package/src/__tests__/components/BioTemplateExperimentWorkspaceView.test.ts +153 -0
- package/src/__tests__/components/BioTemplatePackWorkspaceView.test.ts +161 -0
- package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +281 -0
- package/src/__tests__/components/BioTemplateRenderer.test.ts +71 -0
- package/src/__tests__/components/Breadcrumb.test.ts +23 -0
- package/src/__tests__/components/CalendarGridPanel.test.ts +36 -0
- package/src/__tests__/components/ConcentrationInput.test.ts +45 -0
- package/src/__tests__/components/ControlWorkspaceView.test.ts +1031 -0
- package/src/__tests__/components/DataFrame.test.ts +11 -0
- package/src/__tests__/components/DatePicker.test.ts +45 -0
- package/src/__tests__/components/DateTimePicker.test.ts +48 -0
- package/src/__tests__/components/DropdownButton.test.ts +23 -0
- package/src/__tests__/components/EmptyState.test.ts +23 -0
- package/src/__tests__/components/ExperimentPopover.test.ts +56 -0
- package/src/__tests__/components/FormBuilder.test.ts +296 -0
- package/src/__tests__/components/FormCompatibility.test.ts +94 -0
- package/src/__tests__/components/GroupAssigner.test.ts +30 -0
- package/src/__tests__/components/GroupingModal.test.ts +73 -0
- package/src/__tests__/components/MultiSelect.test.ts +48 -0
- package/src/__tests__/components/PluginIcon.test.ts +119 -0
- package/src/__tests__/components/ProtocolStepEditor.test.ts +33 -0
- package/src/__tests__/components/ReagentList.test.ts +82 -0
- package/src/__tests__/components/SampleHierarchyTree.test.ts +53 -0
- package/src/__tests__/components/SampleSelector.test.ts +60 -0
- package/src/__tests__/components/SegmentedControl.test.ts +24 -0
- package/src/__tests__/components/SettingsButton.test.ts +44 -0
- package/src/__tests__/components/SettingsModal.test.ts +296 -0
- package/src/__tests__/components/TagsInput.test.ts +75 -0
- package/src/__tests__/components/ThemeToggle.test.ts +47 -0
- package/src/__tests__/components/TimePicker.test.ts +38 -0
- package/src/__tests__/composables/useBioTemplatePackWorkspace.test.ts +122 -0
- package/src/__tests__/composables/useBioTemplatePresetWorkspace.test.ts +199 -0
- package/src/__tests__/composables/useBioTemplateWorkspace.test.ts +99 -0
- package/src/__tests__/composables/useCalendarGrid.test.ts +38 -0
- package/src/__tests__/composables/useControlSchema.test.ts +919 -0
- package/src/__tests__/composables/useDebouncedWatch.test.ts +93 -0
- package/src/__tests__/composables/useDropdownState.test.ts +95 -0
- package/src/__tests__/composables/useEventListener.test.ts +116 -0
- package/src/__tests__/composables/useExpansionSet.test.ts +62 -0
- package/src/__tests__/composables/useExperimentData.test.ts +4 -0
- package/src/__tests__/composables/useExperimentSave.test.ts +203 -8
- package/src/__tests__/composables/useExperimentSelector.test.ts +164 -0
- package/src/__tests__/composables/useForm.test.ts +58 -0
- package/src/__tests__/composables/useFormBuilder.test.ts +77 -0
- package/src/__tests__/composables/useGroupAssignment.test.ts +73 -0
- package/src/__tests__/composables/useListSelection.test.ts +66 -0
- package/src/__tests__/composables/usePluginClient.test.ts +444 -0
- package/src/__tests__/composables/usePluginConfig.test.ts +5 -0
- package/src/__tests__/composables/useRequestSyncState.test.ts +92 -0
- package/src/__tests__/composables/useSampleGroups.test.ts +66 -0
- package/src/__tests__/composables/useSelectionLimit.test.ts +41 -0
- package/src/__tests__/composables/useSortedItems.test.ts +87 -0
- package/src/__tests__/composables/useTemplateCollection.test.ts +147 -0
- package/src/__tests__/composables/useTextSearch.test.ts +55 -0
- package/src/__tests__/composables/useTheme.test.ts +91 -0
- package/src/__tests__/composables/useTimeUtils.test.ts +35 -0
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +229 -0
- package/src/__tests__/fixtures/templates/dose-response.json +81 -0
- package/src/__tests__/fixtures/templates/plate-map.json +54 -0
- package/src/__tests__/fixtures/templates/qpcr-plate.json +96 -0
- package/src/__tests__/fixtures/templates/sample-sheet.json +71 -0
- package/src/__tests__/templates/templates.test.ts +1043 -0
- package/src/components/ActionItem.vue +82 -0
- package/src/components/AppAvatarMenu.vue +15 -69
- package/src/components/AppLayout.story.vue +25 -25
- package/src/components/AppPageSelector.vue +63 -94
- package/src/components/AppPillNav.vue +44 -39
- package/src/components/AppPluginSwitcher.vue +41 -145
- package/src/components/AppSidebar.story.vue +94 -0
- package/src/components/AppSidebar.vue +187 -12
- package/src/components/{ToastNotification.story.vue → AppToastContainer.story.vue} +6 -6
- package/src/components/AppToastContainer.vue +62 -0
- package/src/components/AppTopBar.story.vue +7 -30
- package/src/components/AppTopBar.vue +283 -84
- package/src/components/BaseModal.vue +3 -5
- package/src/components/BaseRadioGroup.vue +7 -3
- package/src/components/BaseSelect.vue +11 -7
- package/src/components/BaseTabs.vue +6 -4
- package/src/components/BatchProgressList.vue +5 -8
- package/src/components/BioTemplateExperimentWorkspaceView.story.vue +123 -0
- package/src/components/BioTemplateExperimentWorkspaceView.vue +337 -0
- package/src/components/BioTemplatePackWorkspaceView.story.vue +107 -0
- package/src/components/BioTemplatePackWorkspaceView.vue +176 -0
- package/src/components/BioTemplatePresetWorkspaceView.story.vue +151 -0
- package/src/components/BioTemplatePresetWorkspaceView.vue +392 -0
- package/src/components/BioTemplateRenderer.story.vue +57 -0
- package/src/components/BioTemplateRenderer.vue +269 -0
- package/src/components/Breadcrumb.vue +14 -8
- package/src/components/CalendarGridPanel.vue +120 -0
- package/src/components/ConcentrationInput.vue +27 -64
- package/src/components/ControlWorkspaceView.story.vue +336 -0
- package/src/components/ControlWorkspaceView.vue +347 -0
- package/src/components/DataFrame.vue +34 -50
- package/src/components/DatePicker.vue +59 -192
- package/src/components/DateTimePicker.vue +50 -171
- package/src/components/DropdownButton.vue +14 -32
- package/src/components/EmptyState.vue +4 -2
- package/src/components/ExperimentPopover.vue +5 -22
- package/src/components/FormBuilder.vue +124 -27
- package/src/components/FormFieldRenderer.vue +15 -38
- package/src/components/FormSection.vue +20 -73
- package/src/components/GroupAssigner.vue +24 -56
- package/src/components/GroupingModal.story.vue +3 -3
- package/src/components/GroupingModal.vue +30 -391
- package/src/components/MultiSelect.vue +17 -12
- package/src/components/PlateMapEditor.vue +3 -8
- package/src/components/PluginIcon.story.vue +71 -0
- package/src/components/PluginIcon.vue +68 -0
- package/src/components/ProtocolStepEditor.vue +13 -22
- package/src/components/ReagentList.vue +25 -33
- package/src/components/SampleHierarchyTree.vue +12 -23
- package/src/components/SampleSelector.vue +42 -122
- package/src/components/SegmentedControl.vue +7 -3
- package/src/components/SettingsButton.story.vue +1 -1
- package/src/components/SettingsButton.vue +15 -27
- package/src/components/SettingsModal.story.vue +337 -45
- package/src/components/SettingsModal.vue +344 -66
- package/src/components/TagsInput.vue +29 -14
- package/src/components/ThemeToggle.vue +9 -7
- package/src/components/TimePicker.vue +19 -41
- package/src/components/ToastNotification.vue +4 -57
- package/src/components/Tooltip.vue +7 -12
- package/src/components/WellEditPopup.vue +3 -8
- package/src/components/WellPlate.vue +4 -10
- package/src/components/index.ts +12 -1
- package/src/components/internal/FormFieldRendererInternal.vue +50 -0
- package/src/components/internal/FormSectionRenderer.vue +78 -0
- package/src/composables/index.ts +212 -0
- package/src/composables/platformContextHelpers.ts +74 -0
- package/src/composables/useBioTemplateComponents.ts +93 -0
- package/src/composables/useBioTemplateControls.ts +41 -0
- package/src/composables/useBioTemplatePackWorkspace.ts +181 -0
- package/src/composables/useBioTemplatePresetWorkspace.ts +337 -0
- package/src/composables/useBioTemplateWorkspace.ts +139 -0
- package/src/composables/useCalendarGrid.ts +140 -0
- package/src/composables/useControlSchema.ts +1274 -0
- package/src/composables/useDebouncedWatch.ts +119 -0
- package/src/composables/useDropdownState.ts +83 -0
- package/src/composables/useEventListener.ts +111 -0
- package/src/composables/useExpansionSet.ts +117 -0
- package/src/composables/useExperimentData.ts +20 -11
- package/src/composables/useExperimentSave.ts +202 -50
- package/src/composables/useExperimentSelector.ts +86 -72
- package/src/composables/useForm.ts +49 -4
- package/src/composables/useFormBuilder.ts +93 -42
- package/src/composables/useGroupAssignment.ts +148 -0
- package/src/composables/useListSelection.ts +158 -0
- package/src/composables/usePluginApi.ts +7 -14
- package/src/composables/usePluginClient.ts +425 -0
- package/src/composables/usePluginConfig.ts +34 -13
- package/src/composables/useRequestSyncState.ts +126 -0
- package/src/composables/useSampleGroups.ts +126 -0
- package/src/composables/useSelectionLimit.ts +57 -0
- package/src/composables/useSortedItems.ts +118 -0
- package/src/composables/useTemplateCollection.ts +229 -0
- package/src/composables/useTextSearch.ts +60 -0
- package/src/composables/useTheme.ts +2 -28
- package/src/composables/useTimeUtils.ts +26 -2
- package/src/composables/useWellPlateEditor.ts +13 -9
- package/src/index.ts +228 -4
- package/src/install.ts +11 -4
- package/src/stores/settings.ts +13 -9
- package/src/styles/components/app-page-selector.css +23 -0
- package/src/styles/components/app-pill-nav.css +8 -2
- package/src/styles/components/app-top-bar.css +35 -2
- package/src/styles/components/button.css +3 -7
- package/src/styles/components/concentration-input.css +3 -142
- package/src/styles/components/dropdown-button.css +4 -4
- package/src/styles/components/empty-state.css +0 -16
- package/src/styles/components/input.css +4 -5
- package/src/styles/components/number-input.css +3 -3
- package/src/styles/components/plugin-icon.css +38 -0
- package/src/styles/components/segmented-control.css +4 -7
- package/src/styles/components/settings-button.css +3 -66
- package/src/styles/components/settings-modal.css +184 -0
- package/src/styles/components/tabs.css +1 -2
- package/src/styles/components/textarea.css +4 -5
- package/src/styles/components/theme-toggle.css +3 -66
- package/src/styles/components/unit-input.css +3 -3
- package/src/styles/index.css +0 -1
- package/src/templates/adapters.ts +785 -0
- package/src/templates/builders.ts +2149 -0
- package/src/templates/catalog.ts +245 -0
- package/src/templates/componentBindings.ts +615 -0
- package/src/templates/controlSchemas.ts +718 -0
- package/src/templates/index.ts +314 -0
- package/src/templates/lookup.ts +18 -0
- package/src/templates/packs.ts +156 -0
- package/src/templates/presets.ts +146 -0
- package/src/templates/types.ts +668 -0
- package/src/types/components.ts +80 -1
- package/src/types/form-builder.ts +7 -2
- package/src/types/index.ts +17 -0
- package/src/types/platform.ts +8 -1
- package/src/utils/formModelSync.ts +52 -0
- package/src/utils/items.ts +28 -0
- package/src/utils/options.ts +23 -0
- package/src/utils/pluginIcon.ts +30 -0
- package/dist/auth-DsI0rQ7_.js.map +0 -1
- package/dist/components-CzbQQPCb.js.map +0 -1
- package/dist/composables-BXklV5ii.js.map +0 -1
- package/dist/useScheduleDrag-CxBeqYcu.js +0 -7181
- package/dist/useScheduleDrag-CxBeqYcu.js.map +0 -1
- package/src/styles/components/grouping-modal.css +0 -323
|
@@ -0,0 +1,4371 @@
|
|
|
1
|
+
import { Dn as useControlWorkspace, Ft as extractTemplateCollection, Mn as useConcentrationUnits, On as getFieldRegistryEntry, Ot as createTemplateCollection, Pt as ensureTemplateFromCollection, Xt as getBioTemplatePresetInfo, _ as toBioTemplateComponentSnippets, d as getBioTemplateComponentProps$1, dt as createBioTemplatePresetCollection, en as getBioTemplatePackInfo, ft as createBioTemplatePresetCollectionFromControls, g as toBioTemplateComponentPropsById, h as toBioTemplateComponentPropsByComponent$1, i as createBioTemplateControlToolkit, kn as getTypeDefault, m as toBioTemplateComponentProps, p as toBioTemplateComponentImports, u as getBioTemplateComponentBindings, ut as createBioTemplatePackCollection, v as toBioTemplateComponentUsage } from "./templates-50NPjaxL.js";
|
|
2
|
+
import { r as useSettingsStore, t as useAuthStore } from "./auth-QQj2kkze.js";
|
|
3
|
+
import { computed, effectScope, getCurrentScope, onMounted, onScopeDispose, onUnmounted, provide, reactive, readonly, ref, shallowRef, toRaw, toValue, watch } from "vue";
|
|
4
|
+
import axios from "axios";
|
|
5
|
+
//#region src/composables/useSortedItems.ts
|
|
6
|
+
/** Shared sorting for SDK tables and lists with stable empty-value handling. */
|
|
7
|
+
function useSortedItems(options) {
|
|
8
|
+
const sort = computed(() => toValue(options.sort));
|
|
9
|
+
const enabled = computed(() => toValue(options.enabled) ?? true);
|
|
10
|
+
return {
|
|
11
|
+
sort,
|
|
12
|
+
sortedItems: computed(() => {
|
|
13
|
+
const items = [...toValue(options.items)];
|
|
14
|
+
const activeSort = sort.value;
|
|
15
|
+
if (!enabled.value || !activeSort?.key || !activeSort.direction) return items;
|
|
16
|
+
const direction = activeSort.direction;
|
|
17
|
+
const caseSensitive = toValue(options.caseSensitive) ?? true;
|
|
18
|
+
return items.sort((a, b) => {
|
|
19
|
+
const aValue = options.getValue(a, activeSort.key);
|
|
20
|
+
const bValue = options.getValue(b, activeSort.key);
|
|
21
|
+
if (options.compare) return options.compare(aValue, bValue, {
|
|
22
|
+
key: activeSort.key,
|
|
23
|
+
direction,
|
|
24
|
+
a,
|
|
25
|
+
b
|
|
26
|
+
});
|
|
27
|
+
return compareSortValues(aValue, bValue, direction, { caseSensitive });
|
|
28
|
+
});
|
|
29
|
+
})
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function compareSortValues(aValue, bValue, direction = "asc", options = {}) {
|
|
33
|
+
const aEmpty = aValue === null || aValue === void 0;
|
|
34
|
+
const bEmpty = bValue === null || bValue === void 0;
|
|
35
|
+
if (aEmpty && bEmpty) return 0;
|
|
36
|
+
if (aEmpty) return 1;
|
|
37
|
+
if (bEmpty) return -1;
|
|
38
|
+
const aComparable = toComparableSortValue(aValue, options);
|
|
39
|
+
const bComparable = toComparableSortValue(bValue, options);
|
|
40
|
+
let comparison = 0;
|
|
41
|
+
if (typeof aComparable === "number" && typeof bComparable === "number") comparison = aComparable - bComparable;
|
|
42
|
+
else comparison = String(aComparable).localeCompare(String(bComparable));
|
|
43
|
+
return direction === "asc" ? comparison : -comparison;
|
|
44
|
+
}
|
|
45
|
+
function toComparableSortValue(value, options) {
|
|
46
|
+
if (value instanceof Date) return value.getTime();
|
|
47
|
+
if (typeof value === "string" && options.caseSensitive === false) return value.toLowerCase();
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/composables/useTextSearch.ts
|
|
52
|
+
/** Shared text-search filtering for tables, lists, selectors, and chip inputs. */
|
|
53
|
+
function useTextSearch(options) {
|
|
54
|
+
const query = computed(() => normalizeSearchQuery(toValue(options.query)));
|
|
55
|
+
const enabled = computed(() => toValue(options.enabled) ?? true);
|
|
56
|
+
function matches(item, overrideQuery) {
|
|
57
|
+
const activeQuery = overrideQuery === void 0 ? query.value : normalizeSearchQuery(overrideQuery);
|
|
58
|
+
if (!activeQuery) return true;
|
|
59
|
+
return candidateMatchesSearch(options.getText(item), activeQuery);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
query,
|
|
63
|
+
filteredItems: computed(() => {
|
|
64
|
+
const items = [...toValue(options.items)];
|
|
65
|
+
if (!enabled.value || !query.value) return items;
|
|
66
|
+
return items.filter((item) => matches(item));
|
|
67
|
+
}),
|
|
68
|
+
matches
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function normalizeSearchQuery(value) {
|
|
72
|
+
return (value ?? "").trim().toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
function candidateMatchesSearch(candidate, query) {
|
|
75
|
+
const normalizedQuery = normalizeSearchQuery(query);
|
|
76
|
+
if (!normalizedQuery) return true;
|
|
77
|
+
return (Array.isArray(candidate) ? candidate : [candidate]).some((value) => {
|
|
78
|
+
if (value === null || value === void 0) return false;
|
|
79
|
+
return String(value).toLowerCase().includes(normalizedQuery);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/composables/useToast.ts
|
|
84
|
+
var toasts = ref([]);
|
|
85
|
+
var nextId = 0;
|
|
86
|
+
/** Reactive global toast queue with success/error/warning/info variants and auto-dismiss timers. */
|
|
87
|
+
function useToast() {
|
|
88
|
+
function show(message, type = "success", duration = 3500) {
|
|
89
|
+
const id = nextId++;
|
|
90
|
+
toasts.value.push({
|
|
91
|
+
id,
|
|
92
|
+
message,
|
|
93
|
+
type,
|
|
94
|
+
duration
|
|
95
|
+
});
|
|
96
|
+
setTimeout(() => dismiss(id), duration);
|
|
97
|
+
}
|
|
98
|
+
function success(message, duration = 3500) {
|
|
99
|
+
show(message, "success", duration);
|
|
100
|
+
}
|
|
101
|
+
function error(message, duration = 5e3) {
|
|
102
|
+
show(message, "error", duration);
|
|
103
|
+
}
|
|
104
|
+
function warning(message, duration = 4e3) {
|
|
105
|
+
show(message, "warning", duration);
|
|
106
|
+
}
|
|
107
|
+
function info(message, duration = 3500) {
|
|
108
|
+
show(message, "info", duration);
|
|
109
|
+
}
|
|
110
|
+
function dismiss(id) {
|
|
111
|
+
toasts.value = toasts.value.filter((t) => t.id !== id);
|
|
112
|
+
}
|
|
113
|
+
function clear() {
|
|
114
|
+
toasts.value = [];
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
toasts,
|
|
118
|
+
show,
|
|
119
|
+
success,
|
|
120
|
+
error,
|
|
121
|
+
warning,
|
|
122
|
+
info,
|
|
123
|
+
dismiss,
|
|
124
|
+
clear
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/composables/useTheme.ts
|
|
129
|
+
/** Reads and toggles the active theme (light/dark/system) with reactivity to OS preference changes. */
|
|
130
|
+
function useTheme() {
|
|
131
|
+
const settings = useSettingsStore();
|
|
132
|
+
settings.initialize();
|
|
133
|
+
const isDark = computed(() => settings.isDark());
|
|
134
|
+
function toggleTheme() {
|
|
135
|
+
settings.theme = isDark.value ? "light" : "dark";
|
|
136
|
+
}
|
|
137
|
+
function setTheme(theme) {
|
|
138
|
+
settings.theme = theme;
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
isDark,
|
|
142
|
+
toggleTheme,
|
|
143
|
+
setTheme
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/composables/useForm.ts
|
|
148
|
+
var validators = {
|
|
149
|
+
required: (value, message = "This field is required") => {
|
|
150
|
+
if (value === null || value === void 0 || value === "") return message;
|
|
151
|
+
if (Array.isArray(value) && value.length === 0) return message;
|
|
152
|
+
return null;
|
|
153
|
+
},
|
|
154
|
+
minLength: (value, min, message) => {
|
|
155
|
+
if (typeof value !== "string") return null;
|
|
156
|
+
if (value.length < min) return message || `Must be at least ${min} characters`;
|
|
157
|
+
return null;
|
|
158
|
+
},
|
|
159
|
+
maxLength: (value, max, message) => {
|
|
160
|
+
if (typeof value !== "string") return null;
|
|
161
|
+
if (value.length > max) return message || `Must be at most ${max} characters`;
|
|
162
|
+
return null;
|
|
163
|
+
},
|
|
164
|
+
min: (value, min, message) => {
|
|
165
|
+
if (typeof value !== "number") return null;
|
|
166
|
+
if (value < min) return message || `Must be at least ${min}`;
|
|
167
|
+
return null;
|
|
168
|
+
},
|
|
169
|
+
max: (value, max, message) => {
|
|
170
|
+
if (typeof value !== "number") return null;
|
|
171
|
+
if (value > max) return message || `Must be at most ${max}`;
|
|
172
|
+
return null;
|
|
173
|
+
},
|
|
174
|
+
pattern: (value, pattern, message) => {
|
|
175
|
+
if (typeof value !== "string") return null;
|
|
176
|
+
if (!pattern.test(value)) return message || "Invalid format";
|
|
177
|
+
return null;
|
|
178
|
+
},
|
|
179
|
+
email: (value, message = "Invalid email address") => {
|
|
180
|
+
if (typeof value !== "string" || !value) return null;
|
|
181
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return message;
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Form state management composable with validation.
|
|
187
|
+
*
|
|
188
|
+
* @param initialValues - Initial form values
|
|
189
|
+
* @param rules - Validation rules for each field
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```typescript
|
|
193
|
+
* const { data, errors, isValid, handleSubmit, getFieldProps } = useForm(
|
|
194
|
+
* { email: '', password: '' },
|
|
195
|
+
* {
|
|
196
|
+
* email: { required: true, email: true },
|
|
197
|
+
* password: { required: true, minLength: 8 },
|
|
198
|
+
* }
|
|
199
|
+
* )
|
|
200
|
+
*
|
|
201
|
+
* // In template
|
|
202
|
+
* <BaseInput v-bind="getFieldProps('email')" label="Email" />
|
|
203
|
+
* <BaseInput v-bind="getFieldProps('password')" type="password" label="Password" />
|
|
204
|
+
* <BaseButton @click="handleSubmit(onSubmit)" :disabled="!isValid">Submit</BaseButton>
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
/** Reactive form state with field-level validation, dirty tracking, and submit handling. */
|
|
208
|
+
function useForm(initialValues, rules = {}) {
|
|
209
|
+
const cloneableInitialValues = deepToRaw(initialValues);
|
|
210
|
+
let _initialValues = structuredClone(cloneableInitialValues);
|
|
211
|
+
const data = reactive(structuredClone(cloneableInitialValues));
|
|
212
|
+
const errors = reactive(Object.keys(initialValues).reduce((acc, key) => {
|
|
213
|
+
acc[key] = null;
|
|
214
|
+
return acc;
|
|
215
|
+
}, {}));
|
|
216
|
+
const touched = reactive(Object.keys(initialValues).reduce((acc, key) => {
|
|
217
|
+
acc[key] = false;
|
|
218
|
+
return acc;
|
|
219
|
+
}, {}));
|
|
220
|
+
const dirty = reactive(Object.keys(initialValues).reduce((acc, key) => {
|
|
221
|
+
acc[key] = false;
|
|
222
|
+
return acc;
|
|
223
|
+
}, {}));
|
|
224
|
+
const isSubmitting = ref(false);
|
|
225
|
+
watch(() => ({ ...data }), (newData) => {
|
|
226
|
+
for (const key of Object.keys(newData)) dirty[key] = newData[key] !== _initialValues[key];
|
|
227
|
+
}, { deep: true });
|
|
228
|
+
function validateField(field) {
|
|
229
|
+
const value = data[field];
|
|
230
|
+
const fieldRules = rules[field];
|
|
231
|
+
if (!fieldRules) {
|
|
232
|
+
errors[field] = null;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
if (fieldRules.required) {
|
|
236
|
+
const message = typeof fieldRules.required === "string" ? fieldRules.required : void 0;
|
|
237
|
+
const error = validators.required(value, message);
|
|
238
|
+
if (error) {
|
|
239
|
+
errors[field] = error;
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (value === null || value === void 0 || value === "") {
|
|
244
|
+
errors[field] = null;
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (fieldRules.minLength !== void 0) {
|
|
248
|
+
const config = typeof fieldRules.minLength === "number" ? {
|
|
249
|
+
value: fieldRules.minLength,
|
|
250
|
+
message: void 0
|
|
251
|
+
} : fieldRules.minLength;
|
|
252
|
+
const error = validators.minLength(value, config.value, config.message);
|
|
253
|
+
if (error) {
|
|
254
|
+
errors[field] = error;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (fieldRules.maxLength !== void 0) {
|
|
259
|
+
const config = typeof fieldRules.maxLength === "number" ? {
|
|
260
|
+
value: fieldRules.maxLength,
|
|
261
|
+
message: void 0
|
|
262
|
+
} : fieldRules.maxLength;
|
|
263
|
+
const error = validators.maxLength(value, config.value, config.message);
|
|
264
|
+
if (error) {
|
|
265
|
+
errors[field] = error;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (fieldRules.min !== void 0) {
|
|
270
|
+
const config = typeof fieldRules.min === "number" ? {
|
|
271
|
+
value: fieldRules.min,
|
|
272
|
+
message: void 0
|
|
273
|
+
} : fieldRules.min;
|
|
274
|
+
const error = validators.min(value, config.value, config.message);
|
|
275
|
+
if (error) {
|
|
276
|
+
errors[field] = error;
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (fieldRules.max !== void 0) {
|
|
281
|
+
const config = typeof fieldRules.max === "number" ? {
|
|
282
|
+
value: fieldRules.max,
|
|
283
|
+
message: void 0
|
|
284
|
+
} : fieldRules.max;
|
|
285
|
+
const error = validators.max(value, config.value, config.message);
|
|
286
|
+
if (error) {
|
|
287
|
+
errors[field] = error;
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (fieldRules.pattern !== void 0) {
|
|
292
|
+
const config = fieldRules.pattern instanceof RegExp ? {
|
|
293
|
+
value: fieldRules.pattern,
|
|
294
|
+
message: void 0
|
|
295
|
+
} : fieldRules.pattern;
|
|
296
|
+
const error = validators.pattern(value, config.value, config.message);
|
|
297
|
+
if (error) {
|
|
298
|
+
errors[field] = error;
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (fieldRules.email) {
|
|
303
|
+
const message = typeof fieldRules.email === "string" ? fieldRules.email : void 0;
|
|
304
|
+
const error = validators.email(value, message);
|
|
305
|
+
if (error) {
|
|
306
|
+
errors[field] = error;
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (fieldRules.custom) {
|
|
311
|
+
const customRules = Array.isArray(fieldRules.custom) ? fieldRules.custom : [fieldRules.custom];
|
|
312
|
+
for (const rule of customRules) {
|
|
313
|
+
const error = rule(value, data);
|
|
314
|
+
if (error) {
|
|
315
|
+
errors[field] = error;
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
errors[field] = null;
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
function validate() {
|
|
324
|
+
let isAllValid = true;
|
|
325
|
+
for (const field of Object.keys(data)) if (!validateField(field)) isAllValid = false;
|
|
326
|
+
return isAllValid;
|
|
327
|
+
}
|
|
328
|
+
const isValid = computed(() => {
|
|
329
|
+
return Object.values(errors).every((error) => error === null);
|
|
330
|
+
});
|
|
331
|
+
const isDirty = computed(() => {
|
|
332
|
+
return Object.values(dirty).some((d) => d);
|
|
333
|
+
});
|
|
334
|
+
function setFieldValue(field, value) {
|
|
335
|
+
data[field] = value;
|
|
336
|
+
if (touched[field]) validateField(field);
|
|
337
|
+
}
|
|
338
|
+
function setFieldError(field, error) {
|
|
339
|
+
errors[field] = error;
|
|
340
|
+
}
|
|
341
|
+
function setFieldTouched(field, isTouched = true) {
|
|
342
|
+
touched[field] = isTouched;
|
|
343
|
+
if (isTouched) validateField(field);
|
|
344
|
+
}
|
|
345
|
+
function reset(values) {
|
|
346
|
+
const resetValues = values ? {
|
|
347
|
+
..._initialValues,
|
|
348
|
+
...values
|
|
349
|
+
} : _initialValues;
|
|
350
|
+
for (const key of Object.keys(data)) {
|
|
351
|
+
data[key] = structuredClone(deepToRaw(resetValues[key]));
|
|
352
|
+
errors[key] = null;
|
|
353
|
+
touched[key] = false;
|
|
354
|
+
dirty[key] = false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function replaceState(values) {
|
|
358
|
+
const nextValues = structuredClone(deepToRaw(values));
|
|
359
|
+
_initialValues = structuredClone(nextValues);
|
|
360
|
+
for (const key of Object.keys(data)) {
|
|
361
|
+
if (key in nextValues) continue;
|
|
362
|
+
delete data[key];
|
|
363
|
+
delete errors[key];
|
|
364
|
+
delete touched[key];
|
|
365
|
+
delete dirty[key];
|
|
366
|
+
}
|
|
367
|
+
for (const key of Object.keys(nextValues)) {
|
|
368
|
+
data[key] = structuredClone(deepToRaw(nextValues[key]));
|
|
369
|
+
errors[key] = null;
|
|
370
|
+
touched[key] = false;
|
|
371
|
+
dirty[key] = false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function handleSubmit(onSubmit) {
|
|
375
|
+
return async (e) => {
|
|
376
|
+
e?.preventDefault();
|
|
377
|
+
for (const field of Object.keys(data)) touched[field] = true;
|
|
378
|
+
if (!validate()) return;
|
|
379
|
+
isSubmitting.value = true;
|
|
380
|
+
try {
|
|
381
|
+
await onSubmit(data);
|
|
382
|
+
} finally {
|
|
383
|
+
isSubmitting.value = false;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function getFieldProps(field) {
|
|
388
|
+
const fieldStr = field;
|
|
389
|
+
return {
|
|
390
|
+
modelValue: data[field],
|
|
391
|
+
"onUpdate:modelValue": (value) => setFieldValue(field, value),
|
|
392
|
+
onBlur: () => setFieldTouched(fieldStr),
|
|
393
|
+
error: touched[fieldStr] ? errors[fieldStr] : null
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
data,
|
|
398
|
+
errors,
|
|
399
|
+
touched,
|
|
400
|
+
dirty,
|
|
401
|
+
isValid,
|
|
402
|
+
isDirty,
|
|
403
|
+
isSubmitting,
|
|
404
|
+
setFieldValue,
|
|
405
|
+
setFieldError,
|
|
406
|
+
setFieldTouched,
|
|
407
|
+
validateField,
|
|
408
|
+
validate,
|
|
409
|
+
reset,
|
|
410
|
+
replaceState,
|
|
411
|
+
handleSubmit,
|
|
412
|
+
getFieldProps
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function deepToRaw(value) {
|
|
416
|
+
const raw = toRaw(value);
|
|
417
|
+
if (Array.isArray(raw)) return raw.map((item) => deepToRaw(item));
|
|
418
|
+
if (isPlainRecord(raw)) return Object.fromEntries(Object.entries(raw).map(([key, item]) => [key, deepToRaw(item)]));
|
|
419
|
+
return raw;
|
|
420
|
+
}
|
|
421
|
+
function isPlainRecord(value) {
|
|
422
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/composables/useFormBuilder.ts
|
|
426
|
+
/**
|
|
427
|
+
* Evaluate a JSON-serializable field condition against the current form data.
|
|
428
|
+
*
|
|
429
|
+
* Supports logical operators (`and`, `or`, `not`) and comparison operators
|
|
430
|
+
* (`eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`, `notIn`, `truthy`, `falsy`,
|
|
431
|
+
* `contains`). Returns `true` if the condition passes.
|
|
432
|
+
*/
|
|
433
|
+
function evaluateCondition(condition, data) {
|
|
434
|
+
if ("and" in condition) return condition.and.every((c) => evaluateCondition(c, data));
|
|
435
|
+
if ("or" in condition) return condition.or.some((c) => evaluateCondition(c, data));
|
|
436
|
+
if ("not" in condition) return !evaluateCondition(condition.not, data);
|
|
437
|
+
const value = data[condition.field];
|
|
438
|
+
if ("eq" in condition) return value === condition.eq;
|
|
439
|
+
if ("neq" in condition) return value !== condition.neq;
|
|
440
|
+
if ("gt" in condition) return typeof value === "number" && value > condition.gt;
|
|
441
|
+
if ("lt" in condition) return typeof value === "number" && value < condition.lt;
|
|
442
|
+
if ("gte" in condition) return typeof value === "number" && value >= condition.gte;
|
|
443
|
+
if ("lte" in condition) return typeof value === "number" && value <= condition.lte;
|
|
444
|
+
if ("in" in condition) return condition.in.includes(value);
|
|
445
|
+
if ("notIn" in condition) return !condition.notIn.includes(value);
|
|
446
|
+
if ("truthy" in condition) return !!value;
|
|
447
|
+
if ("falsy" in condition) return !value;
|
|
448
|
+
if ("contains" in condition) {
|
|
449
|
+
if (typeof value === "string") return value.includes(condition.contains);
|
|
450
|
+
if (Array.isArray(value)) return value.includes(condition.contains);
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
/** Return all sections across steps (wizard) or directly from a flat schema. */
|
|
456
|
+
function collectSections(schema) {
|
|
457
|
+
return schema.steps ? schema.steps.flatMap((step) => step.sections) : schema.sections;
|
|
458
|
+
}
|
|
459
|
+
/** Return all field schemas in schema order, flattening sections and steps. */
|
|
460
|
+
function flattenFields(schema) {
|
|
461
|
+
return collectSections(schema).flatMap((section) => section.fields);
|
|
462
|
+
}
|
|
463
|
+
/** Convert a JSON-safe `FieldValidation` descriptor to a runtime `FieldRules` object. */
|
|
464
|
+
function convertValidation(v) {
|
|
465
|
+
const rules = {};
|
|
466
|
+
if (v.required !== void 0) rules.required = v.required;
|
|
467
|
+
if (v.minLength !== void 0) rules.minLength = v.minLength;
|
|
468
|
+
if (v.maxLength !== void 0) rules.maxLength = v.maxLength;
|
|
469
|
+
if (v.min !== void 0) rules.min = v.min;
|
|
470
|
+
if (v.max !== void 0) rules.max = v.max;
|
|
471
|
+
if (v.email !== void 0) rules.email = v.email;
|
|
472
|
+
if (v.pattern !== void 0) rules.pattern = typeof v.pattern === "string" ? new RegExp(v.pattern) : {
|
|
473
|
+
value: new RegExp(v.pattern.value),
|
|
474
|
+
message: v.pattern.message
|
|
475
|
+
};
|
|
476
|
+
return rules;
|
|
477
|
+
}
|
|
478
|
+
function buildInitialValues(fields, initialData) {
|
|
479
|
+
const initialValues = {};
|
|
480
|
+
for (const field of fields) {
|
|
481
|
+
const key = field.name;
|
|
482
|
+
if (initialData && hasOwnKey(initialData, key)) initialValues[key] = initialData[key];
|
|
483
|
+
else if (field.defaultValue !== void 0) initialValues[key] = field.defaultValue;
|
|
484
|
+
else initialValues[key] = getTypeDefault(field.type);
|
|
485
|
+
}
|
|
486
|
+
return initialValues;
|
|
487
|
+
}
|
|
488
|
+
function buildRules(fields, enhancements) {
|
|
489
|
+
const rules = {};
|
|
490
|
+
for (const field of fields) {
|
|
491
|
+
const base = field.validation ? convertValidation(field.validation) : {};
|
|
492
|
+
const enhancement = enhancements?.fields?.[field.name];
|
|
493
|
+
const customValidators = [];
|
|
494
|
+
if (enhancement?.validate) {
|
|
495
|
+
const fn = enhancement.validate;
|
|
496
|
+
customValidators.push((value, formData) => fn(value, formData));
|
|
497
|
+
}
|
|
498
|
+
if (customValidators.length > 0) base.custom = customValidators;
|
|
499
|
+
if (Object.keys(base).length > 0) rules[field.name] = base;
|
|
500
|
+
}
|
|
501
|
+
return rules;
|
|
502
|
+
}
|
|
503
|
+
function replaceArray(target, values) {
|
|
504
|
+
target.splice(0, target.length, ...values);
|
|
505
|
+
}
|
|
506
|
+
function replaceRecord(target, source) {
|
|
507
|
+
for (const key of Object.keys(target)) delete target[key];
|
|
508
|
+
Object.assign(target, source);
|
|
509
|
+
}
|
|
510
|
+
function hasOwnKey(source, key) {
|
|
511
|
+
return Object.prototype.hasOwnProperty.call(source, key);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Drive a `FormSchema` as reactive form state.
|
|
515
|
+
*
|
|
516
|
+
* Builds initial values from schema defaults and `initialData`, derives
|
|
517
|
+
* validation rules from `FieldValidation` descriptors and enhancement
|
|
518
|
+
* validators, evaluates `FieldCondition` expressions for field/section
|
|
519
|
+
* visibility, and wires wizard step navigation when `schema.steps` is set.
|
|
520
|
+
*
|
|
521
|
+
* @param schema - Declarative form or wizard schema.
|
|
522
|
+
* @param initialData - Values that override schema defaults.
|
|
523
|
+
* @param enhancements - TypeScript-only callbacks (dynamic options, validators,
|
|
524
|
+
* submit handler, transform, field-change watcher).
|
|
525
|
+
*/
|
|
526
|
+
/** Drives a declarative FormSchema as reactive state with conditional fields, wizard steps, and validation. */
|
|
527
|
+
function useFormBuilder(schema, initialData, enhancements) {
|
|
528
|
+
const currentSchema = shallowRef(schema);
|
|
529
|
+
const fields = reactive(flattenFields(schema));
|
|
530
|
+
const sections = reactive(collectSections(schema));
|
|
531
|
+
const rules = reactive(buildRules(fields, enhancements));
|
|
532
|
+
const form = useForm(buildInitialValues(fields, initialData), rules);
|
|
533
|
+
function isFieldVisible(name) {
|
|
534
|
+
const enhancement = enhancements?.fields?.[name];
|
|
535
|
+
if (enhancement?.visible) return enhancement.visible(form.data);
|
|
536
|
+
const field = fields.find((f) => f.name === name);
|
|
537
|
+
if (field?.condition) return evaluateCondition(field.condition, form.data);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
function isSectionVisible(id) {
|
|
541
|
+
const section = sections.find((s) => s.id === id);
|
|
542
|
+
if (section?.condition) return evaluateCondition(section.condition, form.data);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
function getResolvedFieldProps(field) {
|
|
546
|
+
const entry = getFieldRegistryEntry(field.type);
|
|
547
|
+
const formProps = form.getFieldProps(field.name);
|
|
548
|
+
const merged = {
|
|
549
|
+
...entry.defaults,
|
|
550
|
+
...field.props ?? {}
|
|
551
|
+
};
|
|
552
|
+
const enhancement = enhancements?.fields?.[field.name];
|
|
553
|
+
if (enhancement?.props) Object.assign(merged, enhancement.props(form.data));
|
|
554
|
+
const options = getFieldOptions(field.name);
|
|
555
|
+
if (options) merged.options = options;
|
|
556
|
+
if (entry.vModel) {
|
|
557
|
+
merged.modelValue = formProps.modelValue;
|
|
558
|
+
merged["onUpdate:modelValue"] = formProps["onUpdate:modelValue"];
|
|
559
|
+
}
|
|
560
|
+
merged.onBlur = formProps.onBlur;
|
|
561
|
+
if (formProps.error) merged.error = true;
|
|
562
|
+
if (field.placeholder) merged.placeholder = field.placeholder;
|
|
563
|
+
if (field.size) merged.size = field.size;
|
|
564
|
+
if (field.disabled) merged.disabled = true;
|
|
565
|
+
if (field.readonly) merged.readonly = true;
|
|
566
|
+
if (field.type === "radio" && !merged.name) merged.name = field.name;
|
|
567
|
+
return merged;
|
|
568
|
+
}
|
|
569
|
+
function getFieldOptions(name) {
|
|
570
|
+
const enhancement = enhancements?.fields?.[name];
|
|
571
|
+
if (enhancement?.options) return enhancement.options(form.data);
|
|
572
|
+
const field = fields.find((f) => f.name === name);
|
|
573
|
+
if (field?.props?.options) return field.props.options;
|
|
574
|
+
}
|
|
575
|
+
const currentStep = ref(0);
|
|
576
|
+
const isCurrentStepValid = computed(() => {
|
|
577
|
+
if (!currentSchema.value.steps) return form.isValid.value;
|
|
578
|
+
const step = currentSchema.value.steps[currentStep.value];
|
|
579
|
+
if (!step) return true;
|
|
580
|
+
for (const section of step.sections) {
|
|
581
|
+
if (!isSectionVisible(section.id)) continue;
|
|
582
|
+
for (const field of section.fields) {
|
|
583
|
+
if (!isFieldVisible(field.name)) continue;
|
|
584
|
+
if (!form.validateField(field.name)) return false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return true;
|
|
588
|
+
});
|
|
589
|
+
function goNext() {
|
|
590
|
+
if (!currentSchema.value.steps) return false;
|
|
591
|
+
const step = currentSchema.value.steps[currentStep.value];
|
|
592
|
+
if (!step) return false;
|
|
593
|
+
let valid = true;
|
|
594
|
+
for (const section of step.sections) {
|
|
595
|
+
if (!isSectionVisible(section.id)) continue;
|
|
596
|
+
for (const field of section.fields) {
|
|
597
|
+
if (!isFieldVisible(field.name)) continue;
|
|
598
|
+
form.setFieldTouched(field.name, true);
|
|
599
|
+
if (!form.validateField(field.name)) valid = false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!valid) return false;
|
|
603
|
+
if (currentStep.value < currentSchema.value.steps.length - 1) currentStep.value++;
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
function goBack() {
|
|
607
|
+
if (currentStep.value > 0) currentStep.value--;
|
|
608
|
+
}
|
|
609
|
+
function goToStep(index) {
|
|
610
|
+
if (!currentSchema.value.steps) return;
|
|
611
|
+
if (index >= 0 && index < currentSchema.value.steps.length) currentStep.value = index;
|
|
612
|
+
}
|
|
613
|
+
function validate() {
|
|
614
|
+
let allValid = true;
|
|
615
|
+
for (const field of fields) {
|
|
616
|
+
if (!isFieldVisible(field.name)) continue;
|
|
617
|
+
form.setFieldTouched(field.name, true);
|
|
618
|
+
if (!form.validateField(field.name)) allValid = false;
|
|
619
|
+
}
|
|
620
|
+
return allValid;
|
|
621
|
+
}
|
|
622
|
+
async function submit() {
|
|
623
|
+
if (!validate()) return;
|
|
624
|
+
let submitData = {};
|
|
625
|
+
for (const field of fields) if (isFieldVisible(field.name)) submitData[field.name] = form.data[field.name];
|
|
626
|
+
if (enhancements?.transform) submitData = enhancements.transform(submitData);
|
|
627
|
+
if (enhancements?.onSubmit) {
|
|
628
|
+
form.isSubmitting.value = true;
|
|
629
|
+
try {
|
|
630
|
+
await enhancements.onSubmit(submitData);
|
|
631
|
+
} finally {
|
|
632
|
+
form.isSubmitting.value = false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function reset(values) {
|
|
637
|
+
form.reset(values);
|
|
638
|
+
currentStep.value = 0;
|
|
639
|
+
}
|
|
640
|
+
function updateSchema(nextSchema, nextData) {
|
|
641
|
+
currentSchema.value = nextSchema;
|
|
642
|
+
const nextFields = flattenFields(nextSchema);
|
|
643
|
+
replaceArray(fields, nextFields);
|
|
644
|
+
replaceArray(sections, collectSections(nextSchema));
|
|
645
|
+
replaceRecord(rules, buildRules(nextFields, enhancements));
|
|
646
|
+
form.replaceState(buildInitialValues(nextFields, nextData));
|
|
647
|
+
currentStep.value = 0;
|
|
648
|
+
}
|
|
649
|
+
if (enhancements?.onFieldChange) {
|
|
650
|
+
const callback = enhancements.onFieldChange;
|
|
651
|
+
watch(() => ({ ...form.data }), (newData, oldData) => {
|
|
652
|
+
if (!oldData) return;
|
|
653
|
+
for (const key of Object.keys(newData)) if (newData[key] !== oldData[key]) callback(key, newData[key], newData);
|
|
654
|
+
}, { deep: true });
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
form,
|
|
658
|
+
rules,
|
|
659
|
+
isFieldVisible,
|
|
660
|
+
isSectionVisible,
|
|
661
|
+
fields,
|
|
662
|
+
getResolvedFieldProps,
|
|
663
|
+
getFieldOptions,
|
|
664
|
+
currentStep,
|
|
665
|
+
isCurrentStepValid,
|
|
666
|
+
goNext,
|
|
667
|
+
goBack,
|
|
668
|
+
goToStep,
|
|
669
|
+
validate,
|
|
670
|
+
reset,
|
|
671
|
+
submit,
|
|
672
|
+
updateSchema
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/composables/useApi.ts
|
|
677
|
+
var apiClientInstance = null;
|
|
678
|
+
var interceptorAttached = false;
|
|
679
|
+
function getApiClient() {
|
|
680
|
+
if (!apiClientInstance) apiClientInstance = axios.create({ headers: { "Content-Type": "application/json" } });
|
|
681
|
+
return apiClientInstance;
|
|
682
|
+
}
|
|
683
|
+
/** Axios-based API client that injects the plugin base URL and JWT auth header on every request. */
|
|
684
|
+
function useApi(options = {}) {
|
|
685
|
+
const settingsStore = useSettingsStore();
|
|
686
|
+
const authStore = useAuthStore();
|
|
687
|
+
const apiClient = getApiClient();
|
|
688
|
+
if (!authStore.isInitialized) authStore.initialize();
|
|
689
|
+
if (!interceptorAttached) {
|
|
690
|
+
apiClient.interceptors.request.use((config) => {
|
|
691
|
+
if (authStore.token && config.headers && !config.headers.Authorization) config.headers.Authorization = `Bearer ${authStore.token}`;
|
|
692
|
+
return config;
|
|
693
|
+
});
|
|
694
|
+
interceptorAttached = true;
|
|
695
|
+
}
|
|
696
|
+
function requestConfig(config) {
|
|
697
|
+
const base = {
|
|
698
|
+
baseURL: options.baseUrl ?? settingsStore.getApiBaseUrl(),
|
|
699
|
+
timeout: options.timeout ?? settingsStore.requestTimeout,
|
|
700
|
+
...config
|
|
701
|
+
};
|
|
702
|
+
if (options.withAuth === false) base.headers = {
|
|
703
|
+
...base.headers,
|
|
704
|
+
Authorization: void 0
|
|
705
|
+
};
|
|
706
|
+
return base;
|
|
707
|
+
}
|
|
708
|
+
async function get(url, config) {
|
|
709
|
+
return (await apiClient.get(url, requestConfig(config))).data;
|
|
710
|
+
}
|
|
711
|
+
async function post(url, data, config) {
|
|
712
|
+
return (await apiClient.post(url, data, requestConfig(config))).data;
|
|
713
|
+
}
|
|
714
|
+
async function put(url, data, config) {
|
|
715
|
+
return (await apiClient.put(url, data, requestConfig(config))).data;
|
|
716
|
+
}
|
|
717
|
+
async function patch(url, data, config) {
|
|
718
|
+
return (await apiClient.patch(url, data, requestConfig(config))).data;
|
|
719
|
+
}
|
|
720
|
+
async function del(url, config) {
|
|
721
|
+
return (await apiClient.delete(url, requestConfig(config))).data;
|
|
722
|
+
}
|
|
723
|
+
async function upload(url, file, fieldName = "file", additionalData) {
|
|
724
|
+
const formData = new FormData();
|
|
725
|
+
formData.append(fieldName, file);
|
|
726
|
+
if (additionalData) Object.entries(additionalData).forEach(([key, value]) => {
|
|
727
|
+
if (value !== void 0 && value !== null) formData.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
728
|
+
});
|
|
729
|
+
return (await apiClient.post(url, formData, requestConfig({ headers: { "Content-Type": void 0 } }))).data;
|
|
730
|
+
}
|
|
731
|
+
async function download(url, filename) {
|
|
732
|
+
const response = await apiClient.get(url, requestConfig({ responseType: "blob" }));
|
|
733
|
+
const blob = new Blob([response.data]);
|
|
734
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
735
|
+
if (filename) {
|
|
736
|
+
const link = document.createElement("a");
|
|
737
|
+
link.href = blobUrl;
|
|
738
|
+
link.download = filename;
|
|
739
|
+
document.body.appendChild(link);
|
|
740
|
+
link.click();
|
|
741
|
+
document.body.removeChild(link);
|
|
742
|
+
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
|
743
|
+
return "";
|
|
744
|
+
}
|
|
745
|
+
return blobUrl;
|
|
746
|
+
}
|
|
747
|
+
function buildUrl(path) {
|
|
748
|
+
return `${options.baseUrl ?? settingsStore.getApiBaseUrl()}${path}`;
|
|
749
|
+
}
|
|
750
|
+
function buildWsUrl(path) {
|
|
751
|
+
return `${settingsStore.getWsBaseUrl()}${path}`;
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
client: apiClient,
|
|
755
|
+
get,
|
|
756
|
+
post,
|
|
757
|
+
put,
|
|
758
|
+
patch,
|
|
759
|
+
delete: del,
|
|
760
|
+
upload,
|
|
761
|
+
download,
|
|
762
|
+
buildUrl,
|
|
763
|
+
buildWsUrl
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
//#endregion
|
|
767
|
+
//#region src/composables/useDebouncedWatch.ts
|
|
768
|
+
/** Watch a Vue source with debounced callback execution and explicit cancel/flush controls. */
|
|
769
|
+
function useDebouncedWatch(source, callback, options = {}) {
|
|
770
|
+
const { delay = 300, ...watchOptions } = options;
|
|
771
|
+
const isPending = ref(false);
|
|
772
|
+
let timer = null;
|
|
773
|
+
let hasLatestValue = false;
|
|
774
|
+
let latestValue;
|
|
775
|
+
let latestOldValue;
|
|
776
|
+
let callbackCleanup = null;
|
|
777
|
+
let stopped = false;
|
|
778
|
+
function clearTimer() {
|
|
779
|
+
if (timer) {
|
|
780
|
+
clearTimeout(timer);
|
|
781
|
+
timer = null;
|
|
782
|
+
}
|
|
783
|
+
isPending.value = false;
|
|
784
|
+
}
|
|
785
|
+
function runCallbackCleanup() {
|
|
786
|
+
if (!callbackCleanup) return;
|
|
787
|
+
callbackCleanup();
|
|
788
|
+
callbackCleanup = null;
|
|
789
|
+
}
|
|
790
|
+
function cancel() {
|
|
791
|
+
clearTimer();
|
|
792
|
+
hasLatestValue = false;
|
|
793
|
+
latestOldValue = void 0;
|
|
794
|
+
}
|
|
795
|
+
function flush() {
|
|
796
|
+
if (!hasLatestValue) return;
|
|
797
|
+
const value = latestValue;
|
|
798
|
+
const oldValue = latestOldValue;
|
|
799
|
+
cancel();
|
|
800
|
+
runCallbackCleanup();
|
|
801
|
+
callback(value, oldValue, (cleanup) => {
|
|
802
|
+
callbackCleanup = cleanup;
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
function schedule(value, oldValue) {
|
|
806
|
+
cancel();
|
|
807
|
+
hasLatestValue = true;
|
|
808
|
+
latestValue = value;
|
|
809
|
+
latestOldValue = oldValue;
|
|
810
|
+
isPending.value = true;
|
|
811
|
+
timer = setTimeout(flush, delay);
|
|
812
|
+
}
|
|
813
|
+
const stopWatch = watch(source, (value, oldValue, onCleanup) => {
|
|
814
|
+
schedule(value, oldValue);
|
|
815
|
+
onCleanup(() => {
|
|
816
|
+
cancel();
|
|
817
|
+
runCallbackCleanup();
|
|
818
|
+
});
|
|
819
|
+
}, watchOptions);
|
|
820
|
+
function stop() {
|
|
821
|
+
if (stopped) return;
|
|
822
|
+
stopped = true;
|
|
823
|
+
stopWatch();
|
|
824
|
+
cancel();
|
|
825
|
+
runCallbackCleanup();
|
|
826
|
+
}
|
|
827
|
+
onScopeDispose(stop);
|
|
828
|
+
return {
|
|
829
|
+
isPending,
|
|
830
|
+
cancel,
|
|
831
|
+
flush,
|
|
832
|
+
stop
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
//#endregion
|
|
836
|
+
//#region src/composables/useRequestSyncState.ts
|
|
837
|
+
/** Shared loading/error/timestamp state for generated plugin request helpers. */
|
|
838
|
+
function useRequestSyncState(defaultErrorMessage = "Request failed.") {
|
|
839
|
+
const loading = ref(false);
|
|
840
|
+
const error = ref(null);
|
|
841
|
+
const lastLoadedAt = ref(null);
|
|
842
|
+
const lastSavedAt = ref(null);
|
|
843
|
+
const lastRunAt = ref(null);
|
|
844
|
+
function clearError() {
|
|
845
|
+
error.value = null;
|
|
846
|
+
}
|
|
847
|
+
function readErrorMessage(value, fallback = defaultErrorMessage) {
|
|
848
|
+
if (value instanceof Error && value.message) return value.message;
|
|
849
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
850
|
+
if (typeof value === "object" && value !== null && "message" in value && typeof value.message === "string" && value.message) return value.message;
|
|
851
|
+
return fallback;
|
|
852
|
+
}
|
|
853
|
+
function setError(value, fallback) {
|
|
854
|
+
const message = readErrorMessage(value, fallback);
|
|
855
|
+
error.value = message;
|
|
856
|
+
return message;
|
|
857
|
+
}
|
|
858
|
+
function markLoaded(date = /* @__PURE__ */ new Date()) {
|
|
859
|
+
lastLoadedAt.value = date;
|
|
860
|
+
}
|
|
861
|
+
function markSaved(date = /* @__PURE__ */ new Date()) {
|
|
862
|
+
lastSavedAt.value = date;
|
|
863
|
+
}
|
|
864
|
+
function markRun(date = /* @__PURE__ */ new Date()) {
|
|
865
|
+
lastRunAt.value = date;
|
|
866
|
+
}
|
|
867
|
+
async function run(operation, options = {}) {
|
|
868
|
+
loading.value = true;
|
|
869
|
+
clearError();
|
|
870
|
+
try {
|
|
871
|
+
const response = await operation();
|
|
872
|
+
if (options.success === "load") markLoaded();
|
|
873
|
+
else if (options.success === "save") markSaved();
|
|
874
|
+
else if (options.success === "run") markRun();
|
|
875
|
+
return response;
|
|
876
|
+
} catch (err) {
|
|
877
|
+
setError(err, options.errorMessage);
|
|
878
|
+
throw err;
|
|
879
|
+
} finally {
|
|
880
|
+
loading.value = false;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return {
|
|
884
|
+
loading,
|
|
885
|
+
error,
|
|
886
|
+
lastLoadedAt,
|
|
887
|
+
lastSavedAt,
|
|
888
|
+
lastRunAt,
|
|
889
|
+
clearError,
|
|
890
|
+
readErrorMessage,
|
|
891
|
+
setError,
|
|
892
|
+
markLoaded,
|
|
893
|
+
markSaved,
|
|
894
|
+
markRun,
|
|
895
|
+
run
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
//#endregion
|
|
899
|
+
//#region src/composables/experiment-utils.ts
|
|
900
|
+
function formatExperimentDate(dateStr) {
|
|
901
|
+
try {
|
|
902
|
+
return new Date(dateStr).toLocaleDateString(void 0, {
|
|
903
|
+
year: "numeric",
|
|
904
|
+
month: "short",
|
|
905
|
+
day: "numeric"
|
|
906
|
+
});
|
|
907
|
+
} catch {
|
|
908
|
+
return dateStr;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function datePresetToISO(preset) {
|
|
912
|
+
const now = /* @__PURE__ */ new Date();
|
|
913
|
+
const days = preset === "last_7_days" ? 7 : preset === "last_30_days" ? 30 : 90;
|
|
914
|
+
return (/* @__PURE__ */ new Date(now.getTime() - days * 864e5)).toISOString();
|
|
915
|
+
}
|
|
916
|
+
var EXPERIMENT_STATUS_OPTIONS = [
|
|
917
|
+
{
|
|
918
|
+
value: "",
|
|
919
|
+
label: "All statuses"
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
value: "planned",
|
|
923
|
+
label: "Planned"
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
value: "ongoing",
|
|
927
|
+
label: "Ongoing"
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
value: "completed",
|
|
931
|
+
label: "Completed"
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
value: "cancelled",
|
|
935
|
+
label: "Cancelled"
|
|
936
|
+
}
|
|
937
|
+
];
|
|
938
|
+
var EXPERIMENT_STATUS_VARIANT_MAP = {
|
|
939
|
+
planned: "default",
|
|
940
|
+
ongoing: "primary",
|
|
941
|
+
completed: "success",
|
|
942
|
+
cancelled: "error"
|
|
943
|
+
};
|
|
944
|
+
var EXPERIMENT_STATUS_LABELS = {
|
|
945
|
+
planned: "Planned",
|
|
946
|
+
ongoing: "Ongoing",
|
|
947
|
+
completed: "Completed",
|
|
948
|
+
cancelled: "Cancelled"
|
|
949
|
+
};
|
|
950
|
+
var DATE_PRESET_OPTIONS = [
|
|
951
|
+
{
|
|
952
|
+
value: "",
|
|
953
|
+
label: "Any time"
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
value: "last_7_days",
|
|
957
|
+
label: "Last 7 days"
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
value: "last_30_days",
|
|
961
|
+
label: "Last 30 days"
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
value: "last_90_days",
|
|
965
|
+
label: "Last 90 days"
|
|
966
|
+
}
|
|
967
|
+
];
|
|
968
|
+
var SORT_OPTIONS = [
|
|
969
|
+
{
|
|
970
|
+
value: "created_at:desc",
|
|
971
|
+
label: "Newest first"
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
value: "created_at:asc",
|
|
975
|
+
label: "Oldest first"
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
value: "updated_at:desc",
|
|
979
|
+
label: "Recently updated"
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
value: "name:asc",
|
|
983
|
+
label: "Name A–Z"
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
value: "name:desc",
|
|
987
|
+
label: "Name Z–A"
|
|
988
|
+
}
|
|
989
|
+
];
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/composables/useExperimentSelector.ts
|
|
992
|
+
function getPlatformContext() {
|
|
993
|
+
if (typeof window === "undefined") return void 0;
|
|
994
|
+
return window.__MINT_PLATFORM__;
|
|
995
|
+
}
|
|
996
|
+
function getPlatformApiUrl() {
|
|
997
|
+
return getPlatformContext()?.platformApiUrl;
|
|
998
|
+
}
|
|
999
|
+
/** Fetches a paginated, filtered experiment list from the platform API for picker and selector UIs. */
|
|
1000
|
+
function useExperimentSelector(options = {}) {
|
|
1001
|
+
const { limit = 100, immediate = false, experimentType, apiBaseUrl } = options;
|
|
1002
|
+
const platformBase = apiBaseUrl ?? getPlatformApiUrl();
|
|
1003
|
+
const api = useApi();
|
|
1004
|
+
const experiments = ref([]);
|
|
1005
|
+
const total = ref(0);
|
|
1006
|
+
const selectedExperiment = ref(null);
|
|
1007
|
+
const request = useRequestSyncState("Failed to fetch experiments");
|
|
1008
|
+
const isLoading = request.loading;
|
|
1009
|
+
const error = request.error;
|
|
1010
|
+
const lastLoadedAt = request.lastLoadedAt;
|
|
1011
|
+
const page = ref(0);
|
|
1012
|
+
const sortKey = ref("created_at:desc");
|
|
1013
|
+
const experimentTypes = ref([]);
|
|
1014
|
+
const projects = ref([]);
|
|
1015
|
+
let filterOptionsFetched = false;
|
|
1016
|
+
const hasMore = computed(() => experiments.value.length < total.value);
|
|
1017
|
+
const filters = reactive({
|
|
1018
|
+
search: void 0,
|
|
1019
|
+
status: void 0,
|
|
1020
|
+
project: void 0,
|
|
1021
|
+
experimentType: void 0,
|
|
1022
|
+
datePreset: void 0
|
|
1023
|
+
});
|
|
1024
|
+
function parseSortKey() {
|
|
1025
|
+
const [field, order] = sortKey.value.split(":");
|
|
1026
|
+
return {
|
|
1027
|
+
sortBy: field || "created_at",
|
|
1028
|
+
sortOrder: order || "desc"
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
async function fetchExperiments() {
|
|
1032
|
+
try {
|
|
1033
|
+
await request.run(async () => {
|
|
1034
|
+
const params = new URLSearchParams();
|
|
1035
|
+
const allowedTypes = getPlatformContext()?.allowedExperimentTypes;
|
|
1036
|
+
const effectiveType = experimentType ?? (allowedTypes?.length === 1 ? allowedTypes[0] : void 0) ?? filters.experimentType ?? void 0;
|
|
1037
|
+
if (effectiveType) params.set("experiment_type", effectiveType);
|
|
1038
|
+
if (filters.status) params.set("status", filters.status);
|
|
1039
|
+
if (filters.search) params.set("search", filters.search);
|
|
1040
|
+
if (filters.project) params.set("project", filters.project);
|
|
1041
|
+
const { sortBy, sortOrder } = parseSortKey();
|
|
1042
|
+
params.set("sort_by", sortBy);
|
|
1043
|
+
params.set("sort_order", sortOrder);
|
|
1044
|
+
if (filters.datePreset) params.set("created_after", datePresetToISO(filters.datePreset));
|
|
1045
|
+
params.set("limit", String(limit));
|
|
1046
|
+
params.set("skip", String(page.value * limit));
|
|
1047
|
+
const query = params.toString();
|
|
1048
|
+
const url = `${platformBase ?? ""}/experiments${query ? `?${query}` : ""}`;
|
|
1049
|
+
const data = await api.get(url);
|
|
1050
|
+
let filtered = data.experiments;
|
|
1051
|
+
if (!effectiveType && allowedTypes && allowedTypes.length > 1) {
|
|
1052
|
+
const typeSet = new Set(allowedTypes);
|
|
1053
|
+
filtered = filtered.filter((e) => typeSet.has(e.experiment_type));
|
|
1054
|
+
}
|
|
1055
|
+
if (page.value === 0) experiments.value = filtered;
|
|
1056
|
+
else experiments.value = [...experiments.value, ...filtered];
|
|
1057
|
+
if (!effectiveType && allowedTypes && allowedTypes.length > 1) if (data.experiments.length < limit) total.value = experiments.value.length;
|
|
1058
|
+
else total.value = experiments.value.length + 1;
|
|
1059
|
+
else total.value = data.total;
|
|
1060
|
+
}, {
|
|
1061
|
+
success: "load",
|
|
1062
|
+
errorMessage: "Failed to fetch experiments"
|
|
1063
|
+
});
|
|
1064
|
+
} catch {
|
|
1065
|
+
if (page.value === 0) {
|
|
1066
|
+
experiments.value = [];
|
|
1067
|
+
total.value = 0;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
async function fetchFilterOptions() {
|
|
1072
|
+
if (filterOptionsFetched) return;
|
|
1073
|
+
filterOptionsFetched = true;
|
|
1074
|
+
const base = platformBase ?? "";
|
|
1075
|
+
const [typesRes, projectsRes] = await Promise.allSettled([api.get(`${base}/experiments/experiment-types`), api.get(`${base}/projects`)]);
|
|
1076
|
+
if (typesRes.status === "fulfilled" && Array.isArray(typesRes.value)) experimentTypes.value = typesRes.value.map((t) => ({
|
|
1077
|
+
value: t.value,
|
|
1078
|
+
label: t.label,
|
|
1079
|
+
color: t.color
|
|
1080
|
+
}));
|
|
1081
|
+
if (projectsRes.status === "fulfilled" && projectsRes.value?.projects && Array.isArray(projectsRes.value.projects)) projects.value = projectsRes.value.projects.map((p) => ({
|
|
1082
|
+
value: p.name,
|
|
1083
|
+
label: p.name
|
|
1084
|
+
}));
|
|
1085
|
+
}
|
|
1086
|
+
async function loadMore() {
|
|
1087
|
+
if (!hasMore.value || isLoading.value) return;
|
|
1088
|
+
page.value++;
|
|
1089
|
+
await fetchExperiments();
|
|
1090
|
+
}
|
|
1091
|
+
function reset() {
|
|
1092
|
+
page.value = 0;
|
|
1093
|
+
experiments.value = [];
|
|
1094
|
+
total.value = 0;
|
|
1095
|
+
fetchExperiments();
|
|
1096
|
+
}
|
|
1097
|
+
function select(experiment) {
|
|
1098
|
+
selectedExperiment.value = experiment;
|
|
1099
|
+
}
|
|
1100
|
+
function clear() {
|
|
1101
|
+
selectedExperiment.value = null;
|
|
1102
|
+
filters.search = void 0;
|
|
1103
|
+
filters.status = void 0;
|
|
1104
|
+
filters.project = void 0;
|
|
1105
|
+
filters.experimentType = void 0;
|
|
1106
|
+
filters.datePreset = void 0;
|
|
1107
|
+
sortKey.value = "created_at:desc";
|
|
1108
|
+
page.value = 0;
|
|
1109
|
+
request.clearError();
|
|
1110
|
+
}
|
|
1111
|
+
const groupedByProject = computed(() => {
|
|
1112
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1113
|
+
for (const exp of experiments.value) {
|
|
1114
|
+
const key = exp.project_name ?? exp.project ?? "No project";
|
|
1115
|
+
const list = groups.get(key);
|
|
1116
|
+
if (list) list.push(exp);
|
|
1117
|
+
else groups.set(key, [exp]);
|
|
1118
|
+
}
|
|
1119
|
+
return [...groups.entries()].sort(([a], [b]) => {
|
|
1120
|
+
if (a === "No project") return 1;
|
|
1121
|
+
if (b === "No project") return -1;
|
|
1122
|
+
return a.localeCompare(b);
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
const searchWatch = useDebouncedWatch(() => filters.search, () => {
|
|
1126
|
+
page.value = 0;
|
|
1127
|
+
fetchExperiments();
|
|
1128
|
+
}, { delay: 300 });
|
|
1129
|
+
watch(() => [
|
|
1130
|
+
filters.status,
|
|
1131
|
+
filters.project,
|
|
1132
|
+
filters.experimentType,
|
|
1133
|
+
filters.datePreset,
|
|
1134
|
+
sortKey.value
|
|
1135
|
+
], () => {
|
|
1136
|
+
searchWatch.cancel();
|
|
1137
|
+
page.value = 0;
|
|
1138
|
+
fetchExperiments();
|
|
1139
|
+
});
|
|
1140
|
+
if (immediate) fetchExperiments();
|
|
1141
|
+
return {
|
|
1142
|
+
experiments,
|
|
1143
|
+
total,
|
|
1144
|
+
selectedExperiment,
|
|
1145
|
+
filters,
|
|
1146
|
+
isLoading,
|
|
1147
|
+
error,
|
|
1148
|
+
lastLoadedAt,
|
|
1149
|
+
page,
|
|
1150
|
+
hasMore,
|
|
1151
|
+
sortKey,
|
|
1152
|
+
experimentTypes,
|
|
1153
|
+
projects,
|
|
1154
|
+
groupedByProject,
|
|
1155
|
+
fetch: fetchExperiments,
|
|
1156
|
+
loadMore,
|
|
1157
|
+
reset,
|
|
1158
|
+
select,
|
|
1159
|
+
clear,
|
|
1160
|
+
fetchFilterOptions
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
//#endregion
|
|
1164
|
+
//#region src/composables/usePlatformContext.ts
|
|
1165
|
+
var DEFAULT_CONTEXT = {
|
|
1166
|
+
isIntegrated: false,
|
|
1167
|
+
theme: "system"
|
|
1168
|
+
};
|
|
1169
|
+
var platformContext = ref({ ...DEFAULT_CONTEXT });
|
|
1170
|
+
var inferredOrigins = /* @__PURE__ */ new Set();
|
|
1171
|
+
var allowedOrigins = /* @__PURE__ */ new Set();
|
|
1172
|
+
var allowAnyOrigin = false;
|
|
1173
|
+
var initialized = false;
|
|
1174
|
+
var listenerCount = 0;
|
|
1175
|
+
var nextConsumerId = 0;
|
|
1176
|
+
var currentHandler = null;
|
|
1177
|
+
var consumerOrigins = /* @__PURE__ */ new Map();
|
|
1178
|
+
var allowAnyOriginConsumers = /* @__PURE__ */ new Set();
|
|
1179
|
+
/**
|
|
1180
|
+
* Derive origin from URL (protocol + host)
|
|
1181
|
+
*/
|
|
1182
|
+
function getOriginFromUrl(url) {
|
|
1183
|
+
try {
|
|
1184
|
+
return new URL(url).origin;
|
|
1185
|
+
} catch {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
function normalizeAllowedOrigins(origins) {
|
|
1190
|
+
const normalized = /* @__PURE__ */ new Set();
|
|
1191
|
+
if (!origins) return normalized;
|
|
1192
|
+
for (const origin of origins) normalized.add(getOriginFromUrl(origin) || origin);
|
|
1193
|
+
return normalized;
|
|
1194
|
+
}
|
|
1195
|
+
function recomputeOriginPolicy() {
|
|
1196
|
+
allowedOrigins = new Set(inferredOrigins);
|
|
1197
|
+
for (const origins of consumerOrigins.values()) for (const origin of origins) allowedOrigins.add(origin);
|
|
1198
|
+
allowAnyOrigin = allowAnyOriginConsumers.size > 0;
|
|
1199
|
+
}
|
|
1200
|
+
function resetPlatformContextState() {
|
|
1201
|
+
inferredOrigins = /* @__PURE__ */ new Set();
|
|
1202
|
+
allowedOrigins = /* @__PURE__ */ new Set();
|
|
1203
|
+
allowAnyOrigin = false;
|
|
1204
|
+
initialized = false;
|
|
1205
|
+
listenerCount = 0;
|
|
1206
|
+
currentHandler = null;
|
|
1207
|
+
consumerOrigins.clear();
|
|
1208
|
+
allowAnyOriginConsumers.clear();
|
|
1209
|
+
platformContext.value = { ...DEFAULT_CONTEXT };
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Check if an origin is allowed for postMessage communication
|
|
1213
|
+
*/
|
|
1214
|
+
function isOriginAllowed(origin) {
|
|
1215
|
+
if (allowAnyOrigin) {
|
|
1216
|
+
console.warn("[MINT SDK] postMessage origin validation disabled - only use in development");
|
|
1217
|
+
return true;
|
|
1218
|
+
}
|
|
1219
|
+
if (origin === window.location.origin) return true;
|
|
1220
|
+
return allowedOrigins.has(origin);
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Platform context composable for plugin integration with MINT Platform.
|
|
1224
|
+
*
|
|
1225
|
+
* Provides secure communication with the parent platform via postMessage.
|
|
1226
|
+
*
|
|
1227
|
+
* @param options - Configuration options
|
|
1228
|
+
* @param options.allowedOrigins - List of allowed origins for postMessage
|
|
1229
|
+
* @param options.allowAnyOrigin - Allow any origin (UNSAFE, development only)
|
|
1230
|
+
*
|
|
1231
|
+
* @example
|
|
1232
|
+
* ```typescript
|
|
1233
|
+
* // Basic usage - derives origin from platform injection
|
|
1234
|
+
* const { isIntegrated, user, theme } = usePlatformContext()
|
|
1235
|
+
*
|
|
1236
|
+
* // With explicit allowed origins
|
|
1237
|
+
* const { isIntegrated } = usePlatformContext({
|
|
1238
|
+
* allowedOrigins: ['https://mint.example.com']
|
|
1239
|
+
* })
|
|
1240
|
+
*
|
|
1241
|
+
* // Development mode (UNSAFE)
|
|
1242
|
+
* const { isIntegrated } = usePlatformContext({
|
|
1243
|
+
* allowAnyOrigin: import.meta.env.DEV
|
|
1244
|
+
* })
|
|
1245
|
+
* ```
|
|
1246
|
+
*/
|
|
1247
|
+
/** Connects a plugin to the MINT platform via postMessage, exposing user, theme, and experiment context. */
|
|
1248
|
+
function usePlatformContext(options = {}) {
|
|
1249
|
+
const consumerId = ++nextConsumerId;
|
|
1250
|
+
const instanceOrigins = normalizeAllowedOrigins(options.allowedOrigins);
|
|
1251
|
+
const instanceAllowAnyOrigin = options.allowAnyOrigin === true;
|
|
1252
|
+
function detectPlatform() {
|
|
1253
|
+
const detectedOrigins = /* @__PURE__ */ new Set();
|
|
1254
|
+
const platformData = window.__MINT_PLATFORM__;
|
|
1255
|
+
if (platformData) {
|
|
1256
|
+
platformContext.value = {
|
|
1257
|
+
...platformData,
|
|
1258
|
+
isIntegrated: true
|
|
1259
|
+
};
|
|
1260
|
+
if (platformData.platformOrigin) detectedOrigins.add(platformData.platformOrigin);
|
|
1261
|
+
else if (platformData.platformApiUrl) {
|
|
1262
|
+
const origin = getOriginFromUrl(platformData.platformApiUrl);
|
|
1263
|
+
if (origin) detectedOrigins.add(origin);
|
|
1264
|
+
}
|
|
1265
|
+
} else {
|
|
1266
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
1267
|
+
const hasPluginParam = urlParams.has("mint-plugin");
|
|
1268
|
+
const platformOrigin = urlParams.get("mint-origin");
|
|
1269
|
+
if (platformOrigin) {
|
|
1270
|
+
const origin = getOriginFromUrl(platformOrigin);
|
|
1271
|
+
if (origin) detectedOrigins.add(origin);
|
|
1272
|
+
}
|
|
1273
|
+
platformContext.value = {
|
|
1274
|
+
isIntegrated: hasPluginParam,
|
|
1275
|
+
theme: (() => {
|
|
1276
|
+
try {
|
|
1277
|
+
const s = localStorage.getItem("mint-settings");
|
|
1278
|
+
if (s) return JSON.parse(s).theme || "system";
|
|
1279
|
+
} catch {}
|
|
1280
|
+
return "system";
|
|
1281
|
+
})(),
|
|
1282
|
+
platformOrigin: platformOrigin || void 0
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
inferredOrigins = detectedOrigins;
|
|
1286
|
+
}
|
|
1287
|
+
function handlePlatformMessage(event) {
|
|
1288
|
+
if (event.source !== window.parent) return;
|
|
1289
|
+
if (!isOriginAllowed(event.origin)) {
|
|
1290
|
+
console.warn(`[MINT SDK] Rejected postMessage from untrusted origin: ${event.origin}`);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
try {
|
|
1294
|
+
const platformEvent = event.data;
|
|
1295
|
+
if (!platformEvent.type?.startsWith("mint:")) return;
|
|
1296
|
+
switch (platformEvent.type) {
|
|
1297
|
+
case "mint:theme-changed":
|
|
1298
|
+
platformContext.value.theme = platformEvent.payload;
|
|
1299
|
+
break;
|
|
1300
|
+
case "mint:user-changed":
|
|
1301
|
+
platformContext.value.user = platformEvent.payload;
|
|
1302
|
+
break;
|
|
1303
|
+
}
|
|
1304
|
+
} catch {}
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Send a message to the parent platform.
|
|
1308
|
+
* Uses validated target origin for security.
|
|
1309
|
+
*/
|
|
1310
|
+
function sendToPlatform(event) {
|
|
1311
|
+
if (!platformContext.value.isIntegrated || window.parent === window) return;
|
|
1312
|
+
let targetOrigin;
|
|
1313
|
+
if (platformContext.value.platformOrigin) targetOrigin = platformContext.value.platformOrigin;
|
|
1314
|
+
else if (allowedOrigins.size > 0) targetOrigin = allowedOrigins.values().next().value;
|
|
1315
|
+
else if (allowAnyOrigin) {
|
|
1316
|
+
targetOrigin = "*";
|
|
1317
|
+
console.warn("[MINT SDK] Using wildcard origin for postMessage - only use in development");
|
|
1318
|
+
} else {
|
|
1319
|
+
console.warn("[MINT SDK] Cannot send postMessage: no platform origin configured");
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
window.parent.postMessage(event, targetOrigin);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Request navigation to a path in the platform.
|
|
1326
|
+
*/
|
|
1327
|
+
function navigate(path) {
|
|
1328
|
+
sendToPlatform({
|
|
1329
|
+
type: "mint:navigate",
|
|
1330
|
+
payload: path
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Show a notification in the platform.
|
|
1335
|
+
*/
|
|
1336
|
+
function notify(message, type = "info") {
|
|
1337
|
+
sendToPlatform({
|
|
1338
|
+
type: "mint:notification",
|
|
1339
|
+
payload: {
|
|
1340
|
+
message,
|
|
1341
|
+
type
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
onMounted(() => {
|
|
1346
|
+
consumerOrigins.set(consumerId, instanceOrigins);
|
|
1347
|
+
if (instanceAllowAnyOrigin) allowAnyOriginConsumers.add(consumerId);
|
|
1348
|
+
if (!initialized) {
|
|
1349
|
+
detectPlatform();
|
|
1350
|
+
currentHandler = handlePlatformMessage;
|
|
1351
|
+
window.addEventListener("message", handlePlatformMessage);
|
|
1352
|
+
initialized = true;
|
|
1353
|
+
}
|
|
1354
|
+
listenerCount++;
|
|
1355
|
+
recomputeOriginPolicy();
|
|
1356
|
+
});
|
|
1357
|
+
onUnmounted(() => {
|
|
1358
|
+
consumerOrigins.delete(consumerId);
|
|
1359
|
+
allowAnyOriginConsumers.delete(consumerId);
|
|
1360
|
+
listenerCount = Math.max(0, listenerCount - 1);
|
|
1361
|
+
if (listenerCount === 0) {
|
|
1362
|
+
if (currentHandler) window.removeEventListener("message", currentHandler);
|
|
1363
|
+
resetPlatformContextState();
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
recomputeOriginPolicy();
|
|
1367
|
+
});
|
|
1368
|
+
return {
|
|
1369
|
+
context: platformContext,
|
|
1370
|
+
isIntegrated: computed(() => platformContext.value.isIntegrated),
|
|
1371
|
+
plugin: computed(() => platformContext.value.plugin),
|
|
1372
|
+
user: computed(() => platformContext.value.user),
|
|
1373
|
+
theme: computed(() => platformContext.value.theme),
|
|
1374
|
+
features: computed(() => platformContext.value.features),
|
|
1375
|
+
navigate,
|
|
1376
|
+
notify,
|
|
1377
|
+
sendToPlatform
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
//#endregion
|
|
1381
|
+
//#region src/composables/useAppExperiment.ts
|
|
1382
|
+
var APP_EXPERIMENT_KEY = Symbol("app-experiment");
|
|
1383
|
+
/** Manages the active experiment selection, save flow, and detach action for a plugin's app shell. */
|
|
1384
|
+
function useAppExperiment(options = {}) {
|
|
1385
|
+
const experimentName = ref();
|
|
1386
|
+
const experimentCode = ref();
|
|
1387
|
+
const experimentStatus = ref();
|
|
1388
|
+
const experimentId = ref(null);
|
|
1389
|
+
const showModal = ref(false);
|
|
1390
|
+
const saveLoading = ref(false);
|
|
1391
|
+
const saveSuccessMessage = ref();
|
|
1392
|
+
let successTimer = null;
|
|
1393
|
+
const showSave = ref(!!options.onSave);
|
|
1394
|
+
const showDetach = computed(() => experimentId.value !== null);
|
|
1395
|
+
const saveDisabled = computed(() => toValue(options.saveDisabled) ?? false);
|
|
1396
|
+
const saveDisabledMessage = computed(() => toValue(options.saveDisabledMessage));
|
|
1397
|
+
function set(experiment) {
|
|
1398
|
+
experimentId.value = experiment.id;
|
|
1399
|
+
experimentName.value = experiment.name;
|
|
1400
|
+
experimentCode.value = experiment.experiment_code ?? (experiment.id != null ? `EXP-${experiment.id}` : void 0);
|
|
1401
|
+
experimentStatus.value = experiment.status;
|
|
1402
|
+
}
|
|
1403
|
+
function clear() {
|
|
1404
|
+
experimentId.value = null;
|
|
1405
|
+
experimentName.value = void 0;
|
|
1406
|
+
experimentCode.value = void 0;
|
|
1407
|
+
experimentStatus.value = void 0;
|
|
1408
|
+
}
|
|
1409
|
+
function openModal() {
|
|
1410
|
+
showModal.value = true;
|
|
1411
|
+
}
|
|
1412
|
+
function closeModal() {
|
|
1413
|
+
showModal.value = false;
|
|
1414
|
+
}
|
|
1415
|
+
function handleSelect(experiment) {
|
|
1416
|
+
set(experiment);
|
|
1417
|
+
showModal.value = false;
|
|
1418
|
+
options.onSelect?.(experiment);
|
|
1419
|
+
}
|
|
1420
|
+
async function handleSave() {
|
|
1421
|
+
if (!options.onSave || saveLoading.value) return;
|
|
1422
|
+
saveLoading.value = true;
|
|
1423
|
+
try {
|
|
1424
|
+
const message = await options.onSave();
|
|
1425
|
+
if (message) {
|
|
1426
|
+
saveSuccessMessage.value = message;
|
|
1427
|
+
if (successTimer) clearTimeout(successTimer);
|
|
1428
|
+
successTimer = setTimeout(() => {
|
|
1429
|
+
saveSuccessMessage.value = void 0;
|
|
1430
|
+
successTimer = null;
|
|
1431
|
+
}, 3e3);
|
|
1432
|
+
}
|
|
1433
|
+
} finally {
|
|
1434
|
+
saveLoading.value = false;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
function handleDetach() {
|
|
1438
|
+
clear();
|
|
1439
|
+
options.onDetach?.();
|
|
1440
|
+
}
|
|
1441
|
+
onScopeDispose(() => {
|
|
1442
|
+
if (successTimer) clearTimeout(successTimer);
|
|
1443
|
+
});
|
|
1444
|
+
provide(APP_EXPERIMENT_KEY, {
|
|
1445
|
+
experimentName,
|
|
1446
|
+
experimentCode,
|
|
1447
|
+
experimentStatus,
|
|
1448
|
+
experimentId,
|
|
1449
|
+
showModal,
|
|
1450
|
+
saveLoading,
|
|
1451
|
+
saveSuccessMessage,
|
|
1452
|
+
showSave,
|
|
1453
|
+
showDetach,
|
|
1454
|
+
saveDisabled,
|
|
1455
|
+
saveDisabledMessage,
|
|
1456
|
+
openModal,
|
|
1457
|
+
closeModal,
|
|
1458
|
+
handleSelect,
|
|
1459
|
+
handleSave,
|
|
1460
|
+
handleDetach
|
|
1461
|
+
});
|
|
1462
|
+
return {
|
|
1463
|
+
set,
|
|
1464
|
+
clear,
|
|
1465
|
+
experimentName: readonly(experimentName),
|
|
1466
|
+
experimentCode: readonly(experimentCode),
|
|
1467
|
+
experimentId: readonly(experimentId)
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
//#endregion
|
|
1471
|
+
//#region src/composables/useDoseCalculator.ts
|
|
1472
|
+
var VOLUME_UNITS = [
|
|
1473
|
+
"µL",
|
|
1474
|
+
"mL",
|
|
1475
|
+
"L"
|
|
1476
|
+
];
|
|
1477
|
+
var VOLUME_FACTORS = {
|
|
1478
|
+
"µL": 1e-6,
|
|
1479
|
+
"mL": .001,
|
|
1480
|
+
"L": 1
|
|
1481
|
+
};
|
|
1482
|
+
/** Calculates single dilutions, serial dilution series, and plate-filling volumes for dose-response experiments. */
|
|
1483
|
+
function useDoseCalculator() {
|
|
1484
|
+
const { convert } = useConcentrationUnits();
|
|
1485
|
+
function convertVolume(value, from, to) {
|
|
1486
|
+
if (from === to) return value;
|
|
1487
|
+
return value * VOLUME_FACTORS[from] / VOLUME_FACTORS[to];
|
|
1488
|
+
}
|
|
1489
|
+
function formatVolume(volume, precision = 3) {
|
|
1490
|
+
const { value, unit } = volume;
|
|
1491
|
+
if (value === 0) return `0 ${unit}`;
|
|
1492
|
+
let formattedValue;
|
|
1493
|
+
if (Math.abs(value) >= 1e3) formattedValue = value.toExponential(precision - 1);
|
|
1494
|
+
else if (Math.abs(value) < .001) formattedValue = value.toExponential(precision - 1);
|
|
1495
|
+
else formattedValue = value.toPrecision(precision);
|
|
1496
|
+
formattedValue = formattedValue.replace(/\.?0+$/, "");
|
|
1497
|
+
formattedValue = formattedValue.replace(/\.?0+e/, "e");
|
|
1498
|
+
return `${formattedValue} ${unit}`;
|
|
1499
|
+
}
|
|
1500
|
+
function calculateDilution(params) {
|
|
1501
|
+
const { stockConcentration, finalConcentration, finalVolume } = params;
|
|
1502
|
+
if (stockConcentration.value <= 0) return {
|
|
1503
|
+
stockVolume: {
|
|
1504
|
+
value: 0,
|
|
1505
|
+
unit: "µL"
|
|
1506
|
+
},
|
|
1507
|
+
diluentVolume: {
|
|
1508
|
+
value: 0,
|
|
1509
|
+
unit: "µL"
|
|
1510
|
+
},
|
|
1511
|
+
dilutionFactor: 0,
|
|
1512
|
+
valid: false,
|
|
1513
|
+
error: "Stock concentration must be positive"
|
|
1514
|
+
};
|
|
1515
|
+
if (finalConcentration.value <= 0) return {
|
|
1516
|
+
stockVolume: {
|
|
1517
|
+
value: 0,
|
|
1518
|
+
unit: "µL"
|
|
1519
|
+
},
|
|
1520
|
+
diluentVolume: {
|
|
1521
|
+
value: 0,
|
|
1522
|
+
unit: "µL"
|
|
1523
|
+
},
|
|
1524
|
+
dilutionFactor: 0,
|
|
1525
|
+
valid: false,
|
|
1526
|
+
error: "Final concentration must be positive"
|
|
1527
|
+
};
|
|
1528
|
+
if (finalVolume.value <= 0) return {
|
|
1529
|
+
stockVolume: {
|
|
1530
|
+
value: 0,
|
|
1531
|
+
unit: "µL"
|
|
1532
|
+
},
|
|
1533
|
+
diluentVolume: {
|
|
1534
|
+
value: 0,
|
|
1535
|
+
unit: "µL"
|
|
1536
|
+
},
|
|
1537
|
+
dilutionFactor: 0,
|
|
1538
|
+
valid: false,
|
|
1539
|
+
error: "Final volume must be positive"
|
|
1540
|
+
};
|
|
1541
|
+
const convertedFinal = convert(finalConcentration.value, finalConcentration.unit, stockConcentration.unit);
|
|
1542
|
+
if (convertedFinal === null) return {
|
|
1543
|
+
stockVolume: {
|
|
1544
|
+
value: 0,
|
|
1545
|
+
unit: "µL"
|
|
1546
|
+
},
|
|
1547
|
+
diluentVolume: {
|
|
1548
|
+
value: 0,
|
|
1549
|
+
unit: "µL"
|
|
1550
|
+
},
|
|
1551
|
+
dilutionFactor: 0,
|
|
1552
|
+
valid: false,
|
|
1553
|
+
error: "Cannot convert between concentration units. Provide molecular weight for mass↔molarity conversion."
|
|
1554
|
+
};
|
|
1555
|
+
if (convertedFinal >= stockConcentration.value) return {
|
|
1556
|
+
stockVolume: {
|
|
1557
|
+
value: 0,
|
|
1558
|
+
unit: "µL"
|
|
1559
|
+
},
|
|
1560
|
+
diluentVolume: {
|
|
1561
|
+
value: 0,
|
|
1562
|
+
unit: "µL"
|
|
1563
|
+
},
|
|
1564
|
+
dilutionFactor: 0,
|
|
1565
|
+
valid: false,
|
|
1566
|
+
error: "Stock concentration must be higher than final concentration"
|
|
1567
|
+
};
|
|
1568
|
+
const dilutionFactor = stockConcentration.value / convertedFinal;
|
|
1569
|
+
const finalVolumeInL = convertVolume(finalVolume.value, finalVolume.unit, "L");
|
|
1570
|
+
const stockVolumeInL = finalVolumeInL / dilutionFactor;
|
|
1571
|
+
const diluentVolumeInL = finalVolumeInL - stockVolumeInL;
|
|
1572
|
+
let outputUnit = "µL";
|
|
1573
|
+
if (stockVolumeInL >= .001) outputUnit = "mL";
|
|
1574
|
+
if (stockVolumeInL >= 1) outputUnit = "L";
|
|
1575
|
+
return {
|
|
1576
|
+
stockVolume: {
|
|
1577
|
+
value: convertVolume(stockVolumeInL, "L", outputUnit),
|
|
1578
|
+
unit: outputUnit
|
|
1579
|
+
},
|
|
1580
|
+
diluentVolume: {
|
|
1581
|
+
value: convertVolume(diluentVolumeInL, "L", outputUnit),
|
|
1582
|
+
unit: outputUnit
|
|
1583
|
+
},
|
|
1584
|
+
dilutionFactor,
|
|
1585
|
+
valid: true
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
function calculateSerialDilution(params) {
|
|
1589
|
+
const { startingConcentration, dilutionFactor, numberOfDilutions, volumePerWell } = params;
|
|
1590
|
+
if (startingConcentration.value <= 0) return {
|
|
1591
|
+
steps: [],
|
|
1592
|
+
totalStockVolume: {
|
|
1593
|
+
value: 0,
|
|
1594
|
+
unit: "µL"
|
|
1595
|
+
},
|
|
1596
|
+
valid: false,
|
|
1597
|
+
error: "Starting concentration must be positive"
|
|
1598
|
+
};
|
|
1599
|
+
if (dilutionFactor <= 1) return {
|
|
1600
|
+
steps: [],
|
|
1601
|
+
totalStockVolume: {
|
|
1602
|
+
value: 0,
|
|
1603
|
+
unit: "µL"
|
|
1604
|
+
},
|
|
1605
|
+
valid: false,
|
|
1606
|
+
error: "Dilution factor must be greater than 1"
|
|
1607
|
+
};
|
|
1608
|
+
if (numberOfDilutions < 1) return {
|
|
1609
|
+
steps: [],
|
|
1610
|
+
totalStockVolume: {
|
|
1611
|
+
value: 0,
|
|
1612
|
+
unit: "µL"
|
|
1613
|
+
},
|
|
1614
|
+
valid: false,
|
|
1615
|
+
error: "Number of dilutions must be at least 1"
|
|
1616
|
+
};
|
|
1617
|
+
if (volumePerWell.value <= 0) return {
|
|
1618
|
+
steps: [],
|
|
1619
|
+
totalStockVolume: {
|
|
1620
|
+
value: 0,
|
|
1621
|
+
unit: "µL"
|
|
1622
|
+
},
|
|
1623
|
+
valid: false,
|
|
1624
|
+
error: "Volume per well must be positive"
|
|
1625
|
+
};
|
|
1626
|
+
const steps = [];
|
|
1627
|
+
let currentConcentration = startingConcentration.value;
|
|
1628
|
+
const transferVolume = volumePerWell.value / (dilutionFactor - 1);
|
|
1629
|
+
const diluentVolume = volumePerWell.value - transferVolume;
|
|
1630
|
+
for (let i = 0; i < numberOfDilutions; i++) {
|
|
1631
|
+
steps.push({
|
|
1632
|
+
stepNumber: i + 1,
|
|
1633
|
+
concentration: {
|
|
1634
|
+
value: currentConcentration,
|
|
1635
|
+
unit: startingConcentration.unit
|
|
1636
|
+
},
|
|
1637
|
+
transferVolume: {
|
|
1638
|
+
value: i === 0 ? volumePerWell.value : transferVolume,
|
|
1639
|
+
unit: volumePerWell.unit
|
|
1640
|
+
},
|
|
1641
|
+
diluentVolume: {
|
|
1642
|
+
value: i === 0 ? 0 : diluentVolume,
|
|
1643
|
+
unit: volumePerWell.unit
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
currentConcentration /= dilutionFactor;
|
|
1647
|
+
}
|
|
1648
|
+
return {
|
|
1649
|
+
steps,
|
|
1650
|
+
totalStockVolume: {
|
|
1651
|
+
value: volumePerWell.value + transferVolume * (numberOfDilutions - 1),
|
|
1652
|
+
unit: volumePerWell.unit
|
|
1653
|
+
},
|
|
1654
|
+
valid: true
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
function convertMassToMolar(mass, massUnit, mw) {
|
|
1658
|
+
const converted = convert(mass, massUnit, "µM", mw);
|
|
1659
|
+
if (converted !== null) return {
|
|
1660
|
+
value: converted,
|
|
1661
|
+
unit: "µM"
|
|
1662
|
+
};
|
|
1663
|
+
return {
|
|
1664
|
+
value: mass * getMassVolumeFactor(massUnit) / mw * 1e6,
|
|
1665
|
+
unit: "µM"
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
function convertMolarToMass(molar, molarUnit, mw) {
|
|
1669
|
+
const converted = convert(molar, molarUnit, "µg/mL", mw);
|
|
1670
|
+
if (converted !== null) return {
|
|
1671
|
+
value: converted,
|
|
1672
|
+
unit: "µg/mL"
|
|
1673
|
+
};
|
|
1674
|
+
return {
|
|
1675
|
+
value: molar * getMolarityFactor(molarUnit) * mw * 1e6,
|
|
1676
|
+
unit: "µg/mL"
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
function getMassVolumeFactor(unit) {
|
|
1680
|
+
return {
|
|
1681
|
+
"pg/mL": 1e-12,
|
|
1682
|
+
"ng/mL": 1e-9,
|
|
1683
|
+
"µg/mL": 1e-6,
|
|
1684
|
+
"mg/mL": .001,
|
|
1685
|
+
"g/mL": 1
|
|
1686
|
+
}[unit];
|
|
1687
|
+
}
|
|
1688
|
+
function getMolarityFactor(unit) {
|
|
1689
|
+
return {
|
|
1690
|
+
"pM": 1e-12,
|
|
1691
|
+
"nM": 1e-9,
|
|
1692
|
+
"µM": 1e-6,
|
|
1693
|
+
"mM": .001,
|
|
1694
|
+
"M": 1
|
|
1695
|
+
}[unit];
|
|
1696
|
+
}
|
|
1697
|
+
function generateWellConcentrations(result, wellIds) {
|
|
1698
|
+
if (!result.valid || result.steps.length === 0) return [];
|
|
1699
|
+
return result.steps.slice(0, wellIds.length).map((step, index) => ({
|
|
1700
|
+
wellId: wellIds[index],
|
|
1701
|
+
concentration: step.concentration,
|
|
1702
|
+
volume: step.transferVolume
|
|
1703
|
+
}));
|
|
1704
|
+
}
|
|
1705
|
+
return {
|
|
1706
|
+
volumeUnits: VOLUME_UNITS,
|
|
1707
|
+
calculateDilution,
|
|
1708
|
+
calculateSerialDilution,
|
|
1709
|
+
convertMassToMolar,
|
|
1710
|
+
convertMolarToMass,
|
|
1711
|
+
convertVolume,
|
|
1712
|
+
formatVolume,
|
|
1713
|
+
generateWellConcentrations
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
//#endregion
|
|
1717
|
+
//#region src/composables/useWellPlateEditor.ts
|
|
1718
|
+
var DEFAULT_PALETTE = [
|
|
1719
|
+
"#3B82F6",
|
|
1720
|
+
"#10B981",
|
|
1721
|
+
"#EF4444",
|
|
1722
|
+
"#F59E0B",
|
|
1723
|
+
"#8B5CF6",
|
|
1724
|
+
"#F97316",
|
|
1725
|
+
"#06B6D4",
|
|
1726
|
+
"#14B8A6",
|
|
1727
|
+
"#6B7280"
|
|
1728
|
+
];
|
|
1729
|
+
var MAX_HISTORY = 50;
|
|
1730
|
+
function createEmptyPlate(name, format, id) {
|
|
1731
|
+
return {
|
|
1732
|
+
id: id || `plate-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
1733
|
+
name,
|
|
1734
|
+
format,
|
|
1735
|
+
wells: {}
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
function generateSampleId() {
|
|
1739
|
+
return `sample-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
1740
|
+
}
|
|
1741
|
+
function cloneValue(value) {
|
|
1742
|
+
return structuredClone(toRaw(value));
|
|
1743
|
+
}
|
|
1744
|
+
/** Manages multi-plate well-plate state with sample assignment, selection, and undo/redo history. */
|
|
1745
|
+
function useWellPlateEditor(initialState, options = {}) {
|
|
1746
|
+
const { maxHistory = MAX_HISTORY, defaultFormat = 96 } = options;
|
|
1747
|
+
const defaultPlate = createEmptyPlate("Plate 1", defaultFormat);
|
|
1748
|
+
const internalState = ref({
|
|
1749
|
+
plates: initialState?.plates || [defaultPlate],
|
|
1750
|
+
activePlateId: initialState?.activePlateId || defaultPlate.id,
|
|
1751
|
+
samples: initialState?.samples || [],
|
|
1752
|
+
selectedWells: initialState?.selectedWells || [],
|
|
1753
|
+
activeSampleId: initialState?.activeSampleId
|
|
1754
|
+
});
|
|
1755
|
+
const history = ref([]);
|
|
1756
|
+
const historyIndex = ref(-1);
|
|
1757
|
+
const state = computed(() => internalState.value);
|
|
1758
|
+
const plates = computed(() => internalState.value.plates);
|
|
1759
|
+
const activePlate = computed(() => internalState.value.plates.find((p) => p.id === internalState.value.activePlateId));
|
|
1760
|
+
const samples = computed(() => internalState.value.samples);
|
|
1761
|
+
const selectedWells = computed(() => internalState.value.selectedWells);
|
|
1762
|
+
const activeSampleId = computed(() => internalState.value.activeSampleId);
|
|
1763
|
+
const canUndo = computed(() => historyIndex.value >= 0);
|
|
1764
|
+
const canRedo = computed(() => historyIndex.value < history.value.length - 1);
|
|
1765
|
+
function saveToHistory() {
|
|
1766
|
+
const entry = {
|
|
1767
|
+
plates: cloneValue(internalState.value.plates),
|
|
1768
|
+
samples: cloneValue(internalState.value.samples)
|
|
1769
|
+
};
|
|
1770
|
+
if (historyIndex.value < history.value.length - 1) history.value = history.value.slice(0, historyIndex.value + 1);
|
|
1771
|
+
history.value.push(entry);
|
|
1772
|
+
if (history.value.length > maxHistory) history.value.shift();
|
|
1773
|
+
else historyIndex.value++;
|
|
1774
|
+
}
|
|
1775
|
+
function setActivePlate(plateId) {
|
|
1776
|
+
if (internalState.value.plates.some((p) => p.id === plateId)) {
|
|
1777
|
+
internalState.value.activePlateId = plateId;
|
|
1778
|
+
internalState.value.selectedWells = [];
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
function setActiveSample(sampleId) {
|
|
1782
|
+
internalState.value.activeSampleId = sampleId;
|
|
1783
|
+
}
|
|
1784
|
+
function setSelectedWells(wellIds) {
|
|
1785
|
+
internalState.value.selectedWells = wellIds;
|
|
1786
|
+
}
|
|
1787
|
+
function addPlate(name, format) {
|
|
1788
|
+
saveToHistory();
|
|
1789
|
+
const plateNumber = internalState.value.plates.length + 1;
|
|
1790
|
+
const plate = createEmptyPlate(name || `Plate ${plateNumber}`, format || defaultFormat);
|
|
1791
|
+
internalState.value.plates.push(plate);
|
|
1792
|
+
internalState.value.activePlateId = plate.id;
|
|
1793
|
+
internalState.value.selectedWells = [];
|
|
1794
|
+
return plate;
|
|
1795
|
+
}
|
|
1796
|
+
function removePlate(plateId) {
|
|
1797
|
+
if (internalState.value.plates.length <= 1) return;
|
|
1798
|
+
saveToHistory();
|
|
1799
|
+
const index = internalState.value.plates.findIndex((p) => p.id === plateId);
|
|
1800
|
+
if (index === -1) return;
|
|
1801
|
+
internalState.value.plates.splice(index, 1);
|
|
1802
|
+
if (internalState.value.activePlateId === plateId) {
|
|
1803
|
+
internalState.value.activePlateId = internalState.value.plates[0].id;
|
|
1804
|
+
internalState.value.selectedWells = [];
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
function addSample(name, color) {
|
|
1808
|
+
saveToHistory();
|
|
1809
|
+
const sample = {
|
|
1810
|
+
id: generateSampleId(),
|
|
1811
|
+
name,
|
|
1812
|
+
color: color || DEFAULT_PALETTE[internalState.value.samples.length % DEFAULT_PALETTE.length],
|
|
1813
|
+
count: 0
|
|
1814
|
+
};
|
|
1815
|
+
internalState.value.samples.push(sample);
|
|
1816
|
+
return sample;
|
|
1817
|
+
}
|
|
1818
|
+
function removeSample(sampleId) {
|
|
1819
|
+
saveToHistory();
|
|
1820
|
+
const index = internalState.value.samples.findIndex((s) => s.id === sampleId);
|
|
1821
|
+
if (index === -1) return;
|
|
1822
|
+
internalState.value.samples.splice(index, 1);
|
|
1823
|
+
for (const plate of internalState.value.plates) for (const well of Object.values(plate.wells)) if (well.sampleType === sampleId) {
|
|
1824
|
+
delete well.sampleType;
|
|
1825
|
+
well.state = "empty";
|
|
1826
|
+
}
|
|
1827
|
+
if (internalState.value.activeSampleId === sampleId) internalState.value.activeSampleId = void 0;
|
|
1828
|
+
updateSampleCounts();
|
|
1829
|
+
}
|
|
1830
|
+
function assignSample(wellIds, sampleId) {
|
|
1831
|
+
if (wellIds.length === 0) return;
|
|
1832
|
+
saveToHistory();
|
|
1833
|
+
const plate = activePlate.value;
|
|
1834
|
+
if (!plate) return;
|
|
1835
|
+
for (const wellId of wellIds) {
|
|
1836
|
+
const well = plate.wells[wellId] || {
|
|
1837
|
+
id: wellId,
|
|
1838
|
+
row: wellId.charCodeAt(0) - 65,
|
|
1839
|
+
col: parseInt(wellId.slice(1)) - 1,
|
|
1840
|
+
state: "empty"
|
|
1841
|
+
};
|
|
1842
|
+
if (sampleId) {
|
|
1843
|
+
well.sampleType = sampleId;
|
|
1844
|
+
well.state = "filled";
|
|
1845
|
+
} else {
|
|
1846
|
+
delete well.sampleType;
|
|
1847
|
+
well.state = "empty";
|
|
1848
|
+
}
|
|
1849
|
+
plate.wells[wellId] = well;
|
|
1850
|
+
}
|
|
1851
|
+
updateSampleCounts();
|
|
1852
|
+
}
|
|
1853
|
+
function clearWells(wellIds) {
|
|
1854
|
+
assignSample(wellIds, void 0);
|
|
1855
|
+
}
|
|
1856
|
+
function updateSampleCounts() {
|
|
1857
|
+
const counts = {};
|
|
1858
|
+
for (const plate of internalState.value.plates) for (const well of Object.values(plate.wells)) if (well.sampleType) counts[well.sampleType] = (counts[well.sampleType] || 0) + 1;
|
|
1859
|
+
for (const sample of internalState.value.samples) sample.count = counts[sample.id] || 0;
|
|
1860
|
+
}
|
|
1861
|
+
function undo() {
|
|
1862
|
+
if (!canUndo.value) return;
|
|
1863
|
+
const entry = history.value[historyIndex.value];
|
|
1864
|
+
historyIndex.value--;
|
|
1865
|
+
internalState.value.plates = cloneValue(entry.plates);
|
|
1866
|
+
internalState.value.samples = cloneValue(entry.samples);
|
|
1867
|
+
if (!internalState.value.plates.some((p) => p.id === internalState.value.activePlateId)) internalState.value.activePlateId = internalState.value.plates[0]?.id || "";
|
|
1868
|
+
internalState.value.selectedWells = [];
|
|
1869
|
+
}
|
|
1870
|
+
function redo() {
|
|
1871
|
+
if (!canRedo.value) return;
|
|
1872
|
+
historyIndex.value++;
|
|
1873
|
+
const entry = history.value[historyIndex.value];
|
|
1874
|
+
internalState.value.plates = cloneValue(entry.plates);
|
|
1875
|
+
internalState.value.samples = cloneValue(entry.samples);
|
|
1876
|
+
internalState.value.selectedWells = [];
|
|
1877
|
+
}
|
|
1878
|
+
function exportData(format) {
|
|
1879
|
+
if (format === "json") return JSON.stringify({
|
|
1880
|
+
plates: internalState.value.plates,
|
|
1881
|
+
samples: internalState.value.samples
|
|
1882
|
+
}, null, 2);
|
|
1883
|
+
const sampleMap = new Map(internalState.value.samples.map((s) => [s.id, s.name]));
|
|
1884
|
+
const rows = ["Plate,Well,Sample Type,Sample Name"];
|
|
1885
|
+
for (const plate of internalState.value.plates) for (const [wellId, well] of Object.entries(plate.wells)) if (well.sampleType) {
|
|
1886
|
+
const sampleName = sampleMap.get(well.sampleType) || "";
|
|
1887
|
+
rows.push(`"${plate.name}","${wellId}","${well.sampleType}","${sampleName}"`);
|
|
1888
|
+
}
|
|
1889
|
+
return rows.join("\n");
|
|
1890
|
+
}
|
|
1891
|
+
function importData(data, format) {
|
|
1892
|
+
try {
|
|
1893
|
+
saveToHistory();
|
|
1894
|
+
if (format === "json") {
|
|
1895
|
+
const parsed = JSON.parse(data);
|
|
1896
|
+
if (parsed.plates && Array.isArray(parsed.plates)) {
|
|
1897
|
+
internalState.value.plates = parsed.plates;
|
|
1898
|
+
internalState.value.activePlateId = parsed.plates[0]?.id || "";
|
|
1899
|
+
}
|
|
1900
|
+
if (parsed.samples && Array.isArray(parsed.samples)) internalState.value.samples = parsed.samples;
|
|
1901
|
+
internalState.value.selectedWells = [];
|
|
1902
|
+
updateSampleCounts();
|
|
1903
|
+
return true;
|
|
1904
|
+
}
|
|
1905
|
+
const lines = data.trim().split("\n");
|
|
1906
|
+
if (lines.length < 2) return false;
|
|
1907
|
+
const plateMap = /* @__PURE__ */ new Map();
|
|
1908
|
+
const sampleMap = /* @__PURE__ */ new Map();
|
|
1909
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1910
|
+
const parts = lines[i].match(/(?:[^",]+|"[^"]*")+/g);
|
|
1911
|
+
if (!parts || parts.length < 4) continue;
|
|
1912
|
+
const plateName = parts[0].replace(/^"|"$/g, "");
|
|
1913
|
+
const wellId = parts[1].replace(/^"|"$/g, "");
|
|
1914
|
+
const sampleId = parts[2].replace(/^"|"$/g, "");
|
|
1915
|
+
const sampleName = parts[3].replace(/^"|"$/g, "");
|
|
1916
|
+
if (!plateMap.has(plateName)) plateMap.set(plateName, createEmptyPlate(plateName, defaultFormat));
|
|
1917
|
+
if (sampleId && !sampleMap.has(sampleId)) sampleMap.set(sampleId, {
|
|
1918
|
+
id: sampleId,
|
|
1919
|
+
name: sampleName || sampleId,
|
|
1920
|
+
color: DEFAULT_PALETTE[sampleMap.size % DEFAULT_PALETTE.length],
|
|
1921
|
+
count: 0
|
|
1922
|
+
});
|
|
1923
|
+
const plate = plateMap.get(plateName);
|
|
1924
|
+
plate.wells[wellId] = {
|
|
1925
|
+
id: wellId,
|
|
1926
|
+
row: wellId.charCodeAt(0) - 65,
|
|
1927
|
+
col: parseInt(wellId.slice(1)) - 1,
|
|
1928
|
+
state: sampleId ? "filled" : "empty",
|
|
1929
|
+
sampleType: sampleId || void 0
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
internalState.value.plates = Array.from(plateMap.values());
|
|
1933
|
+
internalState.value.samples = Array.from(sampleMap.values());
|
|
1934
|
+
internalState.value.activePlateId = internalState.value.plates[0]?.id || "";
|
|
1935
|
+
internalState.value.selectedWells = [];
|
|
1936
|
+
updateSampleCounts();
|
|
1937
|
+
return true;
|
|
1938
|
+
} catch {
|
|
1939
|
+
return false;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
function loadState(state) {
|
|
1943
|
+
saveToHistory();
|
|
1944
|
+
if (state.plates && state.plates.length > 0) {
|
|
1945
|
+
internalState.value.plates = cloneValue(state.plates);
|
|
1946
|
+
internalState.value.activePlateId = state.activePlateId ?? state.plates[0].id;
|
|
1947
|
+
}
|
|
1948
|
+
if (state.samples) internalState.value.samples = cloneValue(state.samples);
|
|
1949
|
+
internalState.value.selectedWells = state.selectedWells ?? [];
|
|
1950
|
+
internalState.value.activeSampleId = state.activeSampleId;
|
|
1951
|
+
updateSampleCounts();
|
|
1952
|
+
}
|
|
1953
|
+
function reset() {
|
|
1954
|
+
const plate = createEmptyPlate("Plate 1", defaultFormat);
|
|
1955
|
+
internalState.value = {
|
|
1956
|
+
plates: [plate],
|
|
1957
|
+
activePlateId: plate.id,
|
|
1958
|
+
samples: [],
|
|
1959
|
+
selectedWells: [],
|
|
1960
|
+
activeSampleId: void 0
|
|
1961
|
+
};
|
|
1962
|
+
history.value = [];
|
|
1963
|
+
historyIndex.value = -1;
|
|
1964
|
+
}
|
|
1965
|
+
return {
|
|
1966
|
+
state,
|
|
1967
|
+
plates,
|
|
1968
|
+
activePlate,
|
|
1969
|
+
samples,
|
|
1970
|
+
selectedWells,
|
|
1971
|
+
activeSampleId,
|
|
1972
|
+
canUndo,
|
|
1973
|
+
canRedo,
|
|
1974
|
+
setActivePlate,
|
|
1975
|
+
setActiveSample,
|
|
1976
|
+
setSelectedWells,
|
|
1977
|
+
addPlate,
|
|
1978
|
+
removePlate,
|
|
1979
|
+
addSample,
|
|
1980
|
+
removeSample,
|
|
1981
|
+
assignSample,
|
|
1982
|
+
clearWells,
|
|
1983
|
+
undo,
|
|
1984
|
+
redo,
|
|
1985
|
+
exportData,
|
|
1986
|
+
importData,
|
|
1987
|
+
loadState,
|
|
1988
|
+
reset
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
//#endregion
|
|
1992
|
+
//#region src/composables/useAutoGroup.ts
|
|
1993
|
+
var DEFAULT_COLORS = [
|
|
1994
|
+
"#3B82F6",
|
|
1995
|
+
"#10B981",
|
|
1996
|
+
"#F59E0B",
|
|
1997
|
+
"#EF4444",
|
|
1998
|
+
"#8B5CF6",
|
|
1999
|
+
"#EC4899",
|
|
2000
|
+
"#06B6D4",
|
|
2001
|
+
"#84CC16",
|
|
2002
|
+
"#F97316",
|
|
2003
|
+
"#6366F1"
|
|
2004
|
+
];
|
|
2005
|
+
var DELIMITER_CANDIDATES = [
|
|
2006
|
+
"_",
|
|
2007
|
+
"-",
|
|
2008
|
+
"."
|
|
2009
|
+
];
|
|
2010
|
+
function analyzeDelimiter(lines) {
|
|
2011
|
+
if (lines.length === 0) return {
|
|
2012
|
+
delimiter: "_",
|
|
2013
|
+
dominantFieldCount: 1,
|
|
2014
|
+
minFieldCount: 1,
|
|
2015
|
+
consistency: 0
|
|
2016
|
+
};
|
|
2017
|
+
let bestDelimiter = "_";
|
|
2018
|
+
let bestConsistency = -1;
|
|
2019
|
+
let bestFieldCount = 1;
|
|
2020
|
+
for (const candidate of DELIMITER_CANDIDATES) {
|
|
2021
|
+
const fieldCounts = lines.map((line) => line.split(candidate).length);
|
|
2022
|
+
const countFrequency = /* @__PURE__ */ new Map();
|
|
2023
|
+
for (const count of fieldCounts) countFrequency.set(count, (countFrequency.get(count) ?? 0) + 1);
|
|
2024
|
+
let modeCount = 1;
|
|
2025
|
+
let modeFrequency = 0;
|
|
2026
|
+
for (const [count, freq] of countFrequency) if (freq > modeFrequency || freq === modeFrequency && count > modeCount) {
|
|
2027
|
+
modeCount = count;
|
|
2028
|
+
modeFrequency = freq;
|
|
2029
|
+
}
|
|
2030
|
+
const rawConsistency = modeFrequency / lines.length;
|
|
2031
|
+
const consistency = modeCount > 1 ? rawConsistency : 0;
|
|
2032
|
+
if (consistency > bestConsistency || consistency === bestConsistency && DELIMITER_CANDIDATES.indexOf(candidate) < DELIMITER_CANDIDATES.indexOf(bestDelimiter)) {
|
|
2033
|
+
bestDelimiter = candidate;
|
|
2034
|
+
bestConsistency = consistency;
|
|
2035
|
+
bestFieldCount = modeCount;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
const multiFieldCounts = lines.map((line) => line.split(bestDelimiter).length).filter((c) => c >= 2);
|
|
2039
|
+
const minFieldCount = multiFieldCounts.length > 0 ? Math.min(...multiFieldCounts) : 1;
|
|
2040
|
+
return {
|
|
2041
|
+
delimiter: bestDelimiter,
|
|
2042
|
+
dominantFieldCount: bestFieldCount,
|
|
2043
|
+
minFieldCount,
|
|
2044
|
+
consistency: bestConsistency
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
function detectOutliers(lines, delimiter, minFieldCount) {
|
|
2048
|
+
const outliers = [];
|
|
2049
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2050
|
+
const fieldCount = lines[i].split(delimiter).length;
|
|
2051
|
+
if (fieldCount < minFieldCount) outliers.push({
|
|
2052
|
+
sample: lines[i],
|
|
2053
|
+
index: i,
|
|
2054
|
+
fieldCount,
|
|
2055
|
+
action: "include"
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
return outliers;
|
|
2059
|
+
}
|
|
2060
|
+
var QC_KEYWORDS = new Set([
|
|
2061
|
+
"eqc",
|
|
2062
|
+
"iqc",
|
|
2063
|
+
"qc",
|
|
2064
|
+
"blank",
|
|
2065
|
+
"std",
|
|
2066
|
+
"standard",
|
|
2067
|
+
"test"
|
|
2068
|
+
]);
|
|
2069
|
+
function classifyOutlierAction(sample, delimiter) {
|
|
2070
|
+
return sample.split(delimiter).some((seg) => QC_KEYWORDS.has(seg.toLowerCase())) ? "qc" : "include";
|
|
2071
|
+
}
|
|
2072
|
+
function isUsefulField(field, rowCount) {
|
|
2073
|
+
return field.cardinality > 1 && !(rowCount > 1 && field.cardinality === rowCount);
|
|
2074
|
+
}
|
|
2075
|
+
function extractColumns(samples, delimiter, minFieldCount) {
|
|
2076
|
+
if (samples.length === 0) return [];
|
|
2077
|
+
const suffixCount = minFieldCount - 1;
|
|
2078
|
+
const rows = samples.map((s) => {
|
|
2079
|
+
const parts = s.split(delimiter);
|
|
2080
|
+
const splitAt = parts.length - suffixCount;
|
|
2081
|
+
return [parts.slice(0, splitAt).join(delimiter), ...parts.slice(splitAt)];
|
|
2082
|
+
});
|
|
2083
|
+
const columnCount = minFieldCount;
|
|
2084
|
+
const columns = [];
|
|
2085
|
+
for (let col = 0; col < columnCount; col++) {
|
|
2086
|
+
const values = rows.map((row) => row[col]);
|
|
2087
|
+
const unique = [...new Set(values)];
|
|
2088
|
+
columns.push({
|
|
2089
|
+
index: col,
|
|
2090
|
+
name: col === 0 ? "Condition" : `Field ${col + 1}`,
|
|
2091
|
+
uniqueValues: unique,
|
|
2092
|
+
cardinality: unique.length,
|
|
2093
|
+
type: col === 0 ? "prefix" : "suffix"
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
return columns;
|
|
2097
|
+
}
|
|
2098
|
+
function parseCSVLine(line, delimiter = ",") {
|
|
2099
|
+
const result = [];
|
|
2100
|
+
let current = "";
|
|
2101
|
+
let inQuotes = false;
|
|
2102
|
+
for (let i = 0; i < line.length; i++) {
|
|
2103
|
+
const char = line[i];
|
|
2104
|
+
if (char === "\"") inQuotes = !inQuotes;
|
|
2105
|
+
else if (char === delimiter && !inQuotes) {
|
|
2106
|
+
result.push(current.trim());
|
|
2107
|
+
current = "";
|
|
2108
|
+
} else current += char;
|
|
2109
|
+
}
|
|
2110
|
+
result.push(current.trim());
|
|
2111
|
+
return result;
|
|
2112
|
+
}
|
|
2113
|
+
function parseCSV(text) {
|
|
2114
|
+
const lines = text.trim().split("\n");
|
|
2115
|
+
if (lines.length < 2) throw new Error("CSV must have at least a header and one data row");
|
|
2116
|
+
const firstLine = lines[0];
|
|
2117
|
+
const csvDelimiter = firstLine.includes(" ") ? " " : ",";
|
|
2118
|
+
const headers = parseCSVLine(firstLine, csvDelimiter);
|
|
2119
|
+
const rows = [];
|
|
2120
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2121
|
+
const values = parseCSVLine(lines[i], csvDelimiter);
|
|
2122
|
+
if (values.length !== headers.length) continue;
|
|
2123
|
+
const row = {};
|
|
2124
|
+
headers.forEach((header, idx) => {
|
|
2125
|
+
row[header] = values[idx];
|
|
2126
|
+
});
|
|
2127
|
+
rows.push(row);
|
|
2128
|
+
}
|
|
2129
|
+
const sampleKeywords = [
|
|
2130
|
+
"sample",
|
|
2131
|
+
"name",
|
|
2132
|
+
"id",
|
|
2133
|
+
"sample_name",
|
|
2134
|
+
"samplename",
|
|
2135
|
+
"file name",
|
|
2136
|
+
"filename",
|
|
2137
|
+
"file_name"
|
|
2138
|
+
];
|
|
2139
|
+
return {
|
|
2140
|
+
columns: headers,
|
|
2141
|
+
rows,
|
|
2142
|
+
sampleColumn: headers.find((h) => sampleKeywords.includes(h.toLowerCase())) ?? headers[0],
|
|
2143
|
+
delimiter: csvDelimiter
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
function computeGroups(allSamples, columns, enabledFields, outlierActions, delimiter, minFieldCount) {
|
|
2147
|
+
const excludedSamples = [];
|
|
2148
|
+
const qcSamples = [];
|
|
2149
|
+
const conformingSamples = [];
|
|
2150
|
+
for (let i = 0; i < allSamples.length; i++) {
|
|
2151
|
+
const action = outlierActions.get(i);
|
|
2152
|
+
if (action === "exclude") excludedSamples.push(allSamples[i]);
|
|
2153
|
+
else if (action === "qc") qcSamples.push(allSamples[i]);
|
|
2154
|
+
else conformingSamples.push(allSamples[i]);
|
|
2155
|
+
}
|
|
2156
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
2157
|
+
const metadata = [];
|
|
2158
|
+
const enabledIndices = [...enabledFields].sort((a, b) => a - b);
|
|
2159
|
+
const suffixCount = minFieldCount - 1;
|
|
2160
|
+
for (const sample of conformingSamples) {
|
|
2161
|
+
const parts = sample.split(delimiter);
|
|
2162
|
+
const splitAt = Math.max(1, parts.length - suffixCount);
|
|
2163
|
+
const row = [parts.slice(0, splitAt).join(delimiter), ...parts.slice(splitAt)];
|
|
2164
|
+
const keyParts = [];
|
|
2165
|
+
for (const idx of enabledIndices) if (idx < row.length && idx < columns.length) keyParts.push(row[idx]);
|
|
2166
|
+
const groupKey = keyParts.join(" / ");
|
|
2167
|
+
if (!groupMap.has(groupKey)) groupMap.set(groupKey, []);
|
|
2168
|
+
groupMap.get(groupKey).push(sample);
|
|
2169
|
+
const fields = {};
|
|
2170
|
+
for (const col of columns) if (col.index < row.length) fields[col.name] = row[col.index];
|
|
2171
|
+
metadata.push({
|
|
2172
|
+
sampleName: sample,
|
|
2173
|
+
fields,
|
|
2174
|
+
group: groupKey
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
const groups = [];
|
|
2178
|
+
let colorIdx = 0;
|
|
2179
|
+
for (const [name, samples] of groupMap) {
|
|
2180
|
+
groups.push({
|
|
2181
|
+
name,
|
|
2182
|
+
color: DEFAULT_COLORS[colorIdx % DEFAULT_COLORS.length],
|
|
2183
|
+
samples
|
|
2184
|
+
});
|
|
2185
|
+
colorIdx++;
|
|
2186
|
+
}
|
|
2187
|
+
if (qcSamples.length > 0) {
|
|
2188
|
+
groups.push({
|
|
2189
|
+
name: "QC",
|
|
2190
|
+
color: "#6B7280",
|
|
2191
|
+
samples: qcSamples
|
|
2192
|
+
});
|
|
2193
|
+
for (const sample of qcSamples) metadata.push({
|
|
2194
|
+
sampleName: sample,
|
|
2195
|
+
fields: {},
|
|
2196
|
+
group: "QC"
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
return {
|
|
2200
|
+
groups,
|
|
2201
|
+
metadata,
|
|
2202
|
+
excludedSamples
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Extract sample metadata from raw design_data into ParsedCsvData format.
|
|
2207
|
+
*
|
|
2208
|
+
* Looks for a `samples` array in the design data. For each sample, merges
|
|
2209
|
+
* the `conditions` dict (the metadata table) with the `sample_name` to
|
|
2210
|
+
* produce a flat tabular row. QC and blank samples are filtered out by
|
|
2211
|
+
* their explicit `sample_type` field.
|
|
2212
|
+
*
|
|
2213
|
+
* Returns null if no samples with conditions are found.
|
|
2214
|
+
*/
|
|
2215
|
+
function extractSamplesFromDesignData(rawData) {
|
|
2216
|
+
const samples = rawData.samples;
|
|
2217
|
+
if (!Array.isArray(samples) || samples.length === 0) return null;
|
|
2218
|
+
const allConditionKeys = [];
|
|
2219
|
+
const keySet = /* @__PURE__ */ new Set();
|
|
2220
|
+
const filteredSamples = [];
|
|
2221
|
+
for (const sample of samples) {
|
|
2222
|
+
const sampleType = String(sample.sample_type ?? "sample").toLowerCase();
|
|
2223
|
+
if (sampleType === "qc" || sampleType === "blank") continue;
|
|
2224
|
+
filteredSamples.push(sample);
|
|
2225
|
+
const conditions = sample.conditions;
|
|
2226
|
+
if (conditions && typeof conditions === "object") {
|
|
2227
|
+
for (const key of Object.keys(conditions)) if (!keySet.has(key)) {
|
|
2228
|
+
keySet.add(key);
|
|
2229
|
+
allConditionKeys.push(key);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
if (filteredSamples.length === 0 || allConditionKeys.length === 0) return null;
|
|
2234
|
+
return {
|
|
2235
|
+
columns: ["sample_name", ...allConditionKeys],
|
|
2236
|
+
rows: filteredSamples.map((sample) => {
|
|
2237
|
+
const conditions = sample.conditions ?? {};
|
|
2238
|
+
const row = { sample_name: String(sample.sample_name ?? "") };
|
|
2239
|
+
for (const key of allConditionKeys) row[key] = conditions[key] ?? "";
|
|
2240
|
+
return row;
|
|
2241
|
+
}),
|
|
2242
|
+
sampleColumn: "sample_name",
|
|
2243
|
+
delimiter: ","
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
function computeGroupsFromCsv(csvData, columns, enabledFields) {
|
|
2247
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
2248
|
+
const metadata = [];
|
|
2249
|
+
const enabledCols = columns.filter((c) => enabledFields.has(c.index)).sort((a, b) => a.index - b.index);
|
|
2250
|
+
for (const row of csvData.rows) {
|
|
2251
|
+
const sampleName = row[csvData.sampleColumn];
|
|
2252
|
+
const groupKey = enabledCols.map((col) => row[col.originalName ?? col.name]).join(" / ");
|
|
2253
|
+
if (!groupMap.has(groupKey)) groupMap.set(groupKey, []);
|
|
2254
|
+
groupMap.get(groupKey).push(sampleName);
|
|
2255
|
+
const fields = {};
|
|
2256
|
+
for (const col of columns) fields[col.name] = row[col.originalName ?? col.name];
|
|
2257
|
+
metadata.push({
|
|
2258
|
+
sampleName,
|
|
2259
|
+
fields,
|
|
2260
|
+
group: groupKey
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
const groups = [];
|
|
2264
|
+
let colorIdx = 0;
|
|
2265
|
+
for (const [name, samples] of groupMap) {
|
|
2266
|
+
groups.push({
|
|
2267
|
+
name,
|
|
2268
|
+
color: DEFAULT_COLORS[colorIdx % DEFAULT_COLORS.length],
|
|
2269
|
+
samples
|
|
2270
|
+
});
|
|
2271
|
+
colorIdx++;
|
|
2272
|
+
}
|
|
2273
|
+
return {
|
|
2274
|
+
groups,
|
|
2275
|
+
metadata,
|
|
2276
|
+
excludedSamples: []
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
/** Parses sample names or CSV data to propose group assignments with outlier detection and preview. */
|
|
2280
|
+
function useAutoGroup() {
|
|
2281
|
+
const inputMode = ref("paste");
|
|
2282
|
+
const rawText = ref("");
|
|
2283
|
+
const csvData = ref(null);
|
|
2284
|
+
const delimiter = ref("_");
|
|
2285
|
+
const dominantFieldCount = ref(1);
|
|
2286
|
+
const minFieldCount = ref(1);
|
|
2287
|
+
const outliers = ref([]);
|
|
2288
|
+
const fields = ref([]);
|
|
2289
|
+
const fieldNames = ref({});
|
|
2290
|
+
const enabledFields = ref(/* @__PURE__ */ new Set());
|
|
2291
|
+
const isTabularMode = computed(() => (inputMode.value === "csv" || inputMode.value === "experiment") && csvData.value !== null);
|
|
2292
|
+
const samples = computed(() => {
|
|
2293
|
+
if (isTabularMode.value && csvData.value) return csvData.value.rows.map((r) => r[csvData.value.sampleColumn]);
|
|
2294
|
+
return rawText.value.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
2295
|
+
});
|
|
2296
|
+
const hasOutliers = computed(() => outliers.value.length > 0);
|
|
2297
|
+
const conformingSamples = computed(() => {
|
|
2298
|
+
const outlierIndices = new Set(outliers.value.map((o) => o.index));
|
|
2299
|
+
return samples.value.filter((_, i) => !outlierIndices.has(i));
|
|
2300
|
+
});
|
|
2301
|
+
const outlierActions = computed(() => {
|
|
2302
|
+
const map = /* @__PURE__ */ new Map();
|
|
2303
|
+
for (const o of outliers.value) map.set(o.index, o.action);
|
|
2304
|
+
return map;
|
|
2305
|
+
});
|
|
2306
|
+
const effectiveColumns = computed(() => {
|
|
2307
|
+
return fields.value.map((col) => ({
|
|
2308
|
+
...col,
|
|
2309
|
+
name: fieldNames.value[col.index] ?? col.name
|
|
2310
|
+
}));
|
|
2311
|
+
});
|
|
2312
|
+
const _computedResult = computed(() => {
|
|
2313
|
+
if (effectiveColumns.value.length === 0 || enabledFields.value.size === 0) return {
|
|
2314
|
+
groups: [],
|
|
2315
|
+
metadata: [],
|
|
2316
|
+
excludedSamples: []
|
|
2317
|
+
};
|
|
2318
|
+
if (isTabularMode.value && csvData.value) return computeGroupsFromCsv(csvData.value, effectiveColumns.value, enabledFields.value);
|
|
2319
|
+
return computeGroups(samples.value, effectiveColumns.value, enabledFields.value, outlierActions.value, delimiter.value, minFieldCount.value);
|
|
2320
|
+
});
|
|
2321
|
+
const groups = computed(() => _computedResult.value.groups);
|
|
2322
|
+
const metadata = computed(() => _computedResult.value.metadata);
|
|
2323
|
+
const excludedSamples = computed(() => _computedResult.value.excludedSamples);
|
|
2324
|
+
const allSingletons = computed(() => groups.value.length > 1 && groups.value.every((g) => g.samples.length === 1));
|
|
2325
|
+
const result = _computedResult;
|
|
2326
|
+
function parseInput() {
|
|
2327
|
+
if (isTabularMode.value) parseCsvInput();
|
|
2328
|
+
else parsePasteInput();
|
|
2329
|
+
}
|
|
2330
|
+
function parsePasteInput() {
|
|
2331
|
+
const lines = samples.value;
|
|
2332
|
+
if (lines.length === 0) return;
|
|
2333
|
+
const analysis = analyzeDelimiter(lines);
|
|
2334
|
+
delimiter.value = analysis.delimiter;
|
|
2335
|
+
dominantFieldCount.value = analysis.dominantFieldCount;
|
|
2336
|
+
outliers.value = detectOutliers(lines, analysis.delimiter, analysis.dominantFieldCount);
|
|
2337
|
+
for (const outlier of outliers.value) outlier.action = classifyOutlierAction(outlier.sample, analysis.delimiter);
|
|
2338
|
+
const conforming = lines.filter((_, i) => !outliers.value.some((o) => o.index === i));
|
|
2339
|
+
const conformingFieldCounts = conforming.map((s) => s.split(analysis.delimiter).length);
|
|
2340
|
+
minFieldCount.value = conformingFieldCounts.length > 0 ? Math.min(...conformingFieldCounts) : analysis.dominantFieldCount;
|
|
2341
|
+
fields.value = extractColumns(conforming, analysis.delimiter, minFieldCount.value);
|
|
2342
|
+
fieldNames.value = {};
|
|
2343
|
+
const rowCount = conforming.length;
|
|
2344
|
+
enabledFields.value = new Set(fields.value.filter((f) => isUsefulField(f, rowCount)).map((f) => f.index));
|
|
2345
|
+
}
|
|
2346
|
+
function parseCsvInput() {
|
|
2347
|
+
if (!csvData.value) return;
|
|
2348
|
+
const csv = csvData.value;
|
|
2349
|
+
fields.value = csv.columns.filter((c) => c !== csv.sampleColumn).map((col, i) => {
|
|
2350
|
+
const values = csv.rows.map((r) => r[col]);
|
|
2351
|
+
const unique = [...new Set(values)];
|
|
2352
|
+
return {
|
|
2353
|
+
index: i,
|
|
2354
|
+
name: col,
|
|
2355
|
+
originalName: col,
|
|
2356
|
+
uniqueValues: unique,
|
|
2357
|
+
cardinality: unique.length
|
|
2358
|
+
};
|
|
2359
|
+
});
|
|
2360
|
+
outliers.value = [];
|
|
2361
|
+
delimiter.value = csv.delimiter;
|
|
2362
|
+
dominantFieldCount.value = csv.columns.length;
|
|
2363
|
+
fieldNames.value = Object.fromEntries(fields.value.map((f) => [f.index, f.name]));
|
|
2364
|
+
const rowCount = csv.rows.length;
|
|
2365
|
+
enabledFields.value = new Set(fields.value.filter((f) => isUsefulField(f, rowCount)).map((f) => f.index));
|
|
2366
|
+
}
|
|
2367
|
+
function setOutlierAction(index, action) {
|
|
2368
|
+
const outlier = outliers.value.find((o) => o.index === index);
|
|
2369
|
+
if (outlier) {
|
|
2370
|
+
outlier.action = action;
|
|
2371
|
+
outliers.value = [...outliers.value];
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
function setAllOutlierActions(action) {
|
|
2375
|
+
for (const outlier of outliers.value) outlier.action = action;
|
|
2376
|
+
outliers.value = [...outliers.value];
|
|
2377
|
+
}
|
|
2378
|
+
function toggleField(index) {
|
|
2379
|
+
const newSet = new Set(enabledFields.value);
|
|
2380
|
+
if (newSet.has(index)) newSet.delete(index);
|
|
2381
|
+
else newSet.add(index);
|
|
2382
|
+
enabledFields.value = newSet;
|
|
2383
|
+
}
|
|
2384
|
+
function renameField(index, name) {
|
|
2385
|
+
fieldNames.value = {
|
|
2386
|
+
...fieldNames.value,
|
|
2387
|
+
[index]: name
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
function loadExperimentData(rawData) {
|
|
2391
|
+
const parsed = extractSamplesFromDesignData(rawData);
|
|
2392
|
+
if (!parsed) return false;
|
|
2393
|
+
inputMode.value = "experiment";
|
|
2394
|
+
csvData.value = parsed;
|
|
2395
|
+
parseCsvInput();
|
|
2396
|
+
return true;
|
|
2397
|
+
}
|
|
2398
|
+
function reset() {
|
|
2399
|
+
rawText.value = "";
|
|
2400
|
+
csvData.value = null;
|
|
2401
|
+
delimiter.value = "_";
|
|
2402
|
+
dominantFieldCount.value = 1;
|
|
2403
|
+
minFieldCount.value = 1;
|
|
2404
|
+
outliers.value = [];
|
|
2405
|
+
fields.value = [];
|
|
2406
|
+
fieldNames.value = {};
|
|
2407
|
+
enabledFields.value = /* @__PURE__ */ new Set();
|
|
2408
|
+
}
|
|
2409
|
+
return {
|
|
2410
|
+
inputMode,
|
|
2411
|
+
rawText,
|
|
2412
|
+
csvData,
|
|
2413
|
+
delimiter,
|
|
2414
|
+
dominantFieldCount,
|
|
2415
|
+
minFieldCount,
|
|
2416
|
+
outliers,
|
|
2417
|
+
fields,
|
|
2418
|
+
fieldNames,
|
|
2419
|
+
enabledFields,
|
|
2420
|
+
samples,
|
|
2421
|
+
hasOutliers,
|
|
2422
|
+
conformingSamples,
|
|
2423
|
+
groups,
|
|
2424
|
+
metadata,
|
|
2425
|
+
excludedSamples,
|
|
2426
|
+
allSingletons,
|
|
2427
|
+
result,
|
|
2428
|
+
effectiveColumns,
|
|
2429
|
+
parseInput,
|
|
2430
|
+
loadExperimentData,
|
|
2431
|
+
setOutlierAction,
|
|
2432
|
+
setAllOutlierActions,
|
|
2433
|
+
toggleField,
|
|
2434
|
+
renameField,
|
|
2435
|
+
reset
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
//#endregion
|
|
2439
|
+
//#region src/utils/color.ts
|
|
2440
|
+
var HEX_RE = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
2441
|
+
function clamp(n, min, max) {
|
|
2442
|
+
return Math.min(max, Math.max(min, n));
|
|
2443
|
+
}
|
|
2444
|
+
function hexToHsl(hex) {
|
|
2445
|
+
const match = HEX_RE.exec(hex.trim());
|
|
2446
|
+
if (!match) return {
|
|
2447
|
+
h: 0,
|
|
2448
|
+
s: 0,
|
|
2449
|
+
l: 50
|
|
2450
|
+
};
|
|
2451
|
+
let raw = match[1];
|
|
2452
|
+
if (raw.length === 3) raw = raw.split("").map((c) => c + c).join("");
|
|
2453
|
+
const r = parseInt(raw.slice(0, 2), 16) / 255;
|
|
2454
|
+
const g = parseInt(raw.slice(2, 4), 16) / 255;
|
|
2455
|
+
const b = parseInt(raw.slice(4, 6), 16) / 255;
|
|
2456
|
+
const max = Math.max(r, g, b);
|
|
2457
|
+
const min = Math.min(r, g, b);
|
|
2458
|
+
const delta = max - min;
|
|
2459
|
+
let h = 0;
|
|
2460
|
+
if (delta !== 0) if (max === r) h = (g - b) / delta % 6;
|
|
2461
|
+
else if (max === g) h = (b - r) / delta + 2;
|
|
2462
|
+
else h = (r - g) / delta + 4;
|
|
2463
|
+
h = (h * 60 + 360) % 360;
|
|
2464
|
+
const l = (max + min) / 2;
|
|
2465
|
+
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
|
2466
|
+
return {
|
|
2467
|
+
h,
|
|
2468
|
+
s: s * 100,
|
|
2469
|
+
l: l * 100
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
function hslToHex(h, s, l) {
|
|
2473
|
+
const sNorm = clamp(s, 0, 100) / 100;
|
|
2474
|
+
const lNorm = clamp(l, 0, 100) / 100;
|
|
2475
|
+
const hNorm = (h % 360 + 360) % 360;
|
|
2476
|
+
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
|
2477
|
+
const x = c * (1 - Math.abs(hNorm / 60 % 2 - 1));
|
|
2478
|
+
const m = lNorm - c / 2;
|
|
2479
|
+
let r = 0, g = 0, b = 0;
|
|
2480
|
+
if (hNorm < 60) [r, g, b] = [
|
|
2481
|
+
c,
|
|
2482
|
+
x,
|
|
2483
|
+
0
|
|
2484
|
+
];
|
|
2485
|
+
else if (hNorm < 120) [r, g, b] = [
|
|
2486
|
+
x,
|
|
2487
|
+
c,
|
|
2488
|
+
0
|
|
2489
|
+
];
|
|
2490
|
+
else if (hNorm < 180) [r, g, b] = [
|
|
2491
|
+
0,
|
|
2492
|
+
c,
|
|
2493
|
+
x
|
|
2494
|
+
];
|
|
2495
|
+
else if (hNorm < 240) [r, g, b] = [
|
|
2496
|
+
0,
|
|
2497
|
+
x,
|
|
2498
|
+
c
|
|
2499
|
+
];
|
|
2500
|
+
else if (hNorm < 300) [r, g, b] = [
|
|
2501
|
+
x,
|
|
2502
|
+
0,
|
|
2503
|
+
c
|
|
2504
|
+
];
|
|
2505
|
+
else [r, g, b] = [
|
|
2506
|
+
c,
|
|
2507
|
+
0,
|
|
2508
|
+
x
|
|
2509
|
+
];
|
|
2510
|
+
const toHex = (n) => Math.round((n + m) * 255).toString(16).padStart(2, "0");
|
|
2511
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
2514
|
+
* Derive a same-hue-family shade of `parentHex` for child at `index` of `total`.
|
|
2515
|
+
*
|
|
2516
|
+
* Lightness is distributed evenly within [35, 75] so all children remain
|
|
2517
|
+
* readable on light/dark backgrounds. Hue and saturation are inherited from
|
|
2518
|
+
* the parent. With `total <= 1`, returns the parent unchanged.
|
|
2519
|
+
*
|
|
2520
|
+
* Children are ordered light → dark by index, so index 0 is the brightest
|
|
2521
|
+
* sibling and index `total - 1` is the darkest.
|
|
2522
|
+
*/
|
|
2523
|
+
function deriveShade(parentHex, index, total) {
|
|
2524
|
+
if (total <= 1) return parentHex;
|
|
2525
|
+
const { h, s, l } = hexToHsl(parentHex);
|
|
2526
|
+
const L_MIN = 35;
|
|
2527
|
+
const L_MAX = 75;
|
|
2528
|
+
const spread = Math.min(L_MAX - L_MIN, 40);
|
|
2529
|
+
const center = clamp(l, L_MIN + spread / 2, L_MAX - spread / 2);
|
|
2530
|
+
const top = center + spread / 2;
|
|
2531
|
+
const bot = center - spread / 2;
|
|
2532
|
+
const childL = top - (total === 1 ? .5 : index / (total - 1)) * (top - bot);
|
|
2533
|
+
return hslToHex(h, clamp(s, 35, 100), childL);
|
|
2534
|
+
}
|
|
2535
|
+
//#endregion
|
|
2536
|
+
//#region src/composables/useSampleGroups.ts
|
|
2537
|
+
/** Shared sample-group hierarchy, color, and ungrouped-sample derivation for lab selectors. */
|
|
2538
|
+
function useSampleGroups(options) {
|
|
2539
|
+
const groups = computed(() => [...toValue(options.groups)]);
|
|
2540
|
+
const samples = computed(() => [...toValue(options.samples) ?? []]);
|
|
2541
|
+
const fallbackColor = computed(() => toValue(options.fallbackColor) || "#3B82F6");
|
|
2542
|
+
const separator = computed(() => {
|
|
2543
|
+
const explicit = toValue(options.separator);
|
|
2544
|
+
if (explicit) return explicit;
|
|
2545
|
+
return groups.value.some((group) => group.name.includes("/")) ? "/" : "_";
|
|
2546
|
+
});
|
|
2547
|
+
const hierarchicalGroups = computed(() => {
|
|
2548
|
+
if (groups.value.length === 0) return [];
|
|
2549
|
+
const groupedByMajor = /* @__PURE__ */ new Map();
|
|
2550
|
+
for (const group of groups.value) {
|
|
2551
|
+
const majorName = getMajorGroupName(group.name, separator.value);
|
|
2552
|
+
const existing = groupedByMajor.get(majorName);
|
|
2553
|
+
if (existing) existing.push(group);
|
|
2554
|
+
else groupedByMajor.set(majorName, [group]);
|
|
2555
|
+
}
|
|
2556
|
+
return [...groupedByMajor.entries()].map(([majorName, subGroups]) => {
|
|
2557
|
+
const color = subGroups[0]?.color || fallbackColor.value;
|
|
2558
|
+
return {
|
|
2559
|
+
name: majorName,
|
|
2560
|
+
color,
|
|
2561
|
+
subGroups: subGroups.map((subGroup, index) => {
|
|
2562
|
+
const displayColor = deriveShade(color, index, subGroups.length);
|
|
2563
|
+
return {
|
|
2564
|
+
...subGroup,
|
|
2565
|
+
displayColor,
|
|
2566
|
+
displayBg: displayColor + "20",
|
|
2567
|
+
displayBorder: displayColor + "40"
|
|
2568
|
+
};
|
|
2569
|
+
}),
|
|
2570
|
+
allSamples: subGroups.flatMap((group) => group.samples)
|
|
2571
|
+
};
|
|
2572
|
+
});
|
|
2573
|
+
});
|
|
2574
|
+
const showHierarchy = computed(() => hierarchicalGroups.value.some((major) => major.subGroups.length > 1 || major.subGroups.length === 1 && major.name !== major.subGroups[0].name));
|
|
2575
|
+
const groupedSamples = computed(() => new Set(groups.value.flatMap((group) => group.samples)));
|
|
2576
|
+
const ungroupedSamples = computed(() => samples.value.filter((sample) => !groupedSamples.value.has(sample)));
|
|
2577
|
+
function findGroup(groupName) {
|
|
2578
|
+
return groups.value.find((group) => group.name === groupName);
|
|
2579
|
+
}
|
|
2580
|
+
function getGroupColor(groupName) {
|
|
2581
|
+
return findGroup(groupName)?.color || fallbackColor.value;
|
|
2582
|
+
}
|
|
2583
|
+
return {
|
|
2584
|
+
groups,
|
|
2585
|
+
separator,
|
|
2586
|
+
hierarchicalGroups,
|
|
2587
|
+
showHierarchy,
|
|
2588
|
+
groupedSamples,
|
|
2589
|
+
ungroupedSamples,
|
|
2590
|
+
findGroup,
|
|
2591
|
+
getGroupColor
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
function getMajorGroupName(groupName, separator) {
|
|
2595
|
+
const parts = groupName.split(separator);
|
|
2596
|
+
return parts.length > 1 ? parts[0] : groupName;
|
|
2597
|
+
}
|
|
2598
|
+
//#endregion
|
|
2599
|
+
//#region src/composables/useExpansionSet.ts
|
|
2600
|
+
/** Shared expansion state for trees, grouped selectors, and disclosure lists. */
|
|
2601
|
+
function useExpansionSet(options = {}) {
|
|
2602
|
+
const expandedIds = ref(new Set(normalizeIds(toValue(options.defaultIds))));
|
|
2603
|
+
const expandedList = computed(() => [...expandedIds.value]);
|
|
2604
|
+
if (options.expandAll !== void 0) watch(() => toValue(options.expandAll), (shouldExpandAll) => {
|
|
2605
|
+
if (shouldExpandAll === void 0 || shouldExpandAll === null) return;
|
|
2606
|
+
if (shouldExpandAll) setExpanded(normalizeIds(toValue(options.allIds)));
|
|
2607
|
+
else reset();
|
|
2608
|
+
}, { immediate: true });
|
|
2609
|
+
if (options.allIds !== void 0) watch(() => normalizeIds(toValue(options.allIds)), (ids) => {
|
|
2610
|
+
if (toValue(options.expandAll)) setExpanded(ids);
|
|
2611
|
+
});
|
|
2612
|
+
function isExpanded(id) {
|
|
2613
|
+
return expandedIds.value.has(id);
|
|
2614
|
+
}
|
|
2615
|
+
function expand(id) {
|
|
2616
|
+
if (expandedIds.value.has(id)) return;
|
|
2617
|
+
expandedIds.value = new Set([...expandedIds.value, id]);
|
|
2618
|
+
}
|
|
2619
|
+
function collapse(id) {
|
|
2620
|
+
if (!expandedIds.value.has(id)) return;
|
|
2621
|
+
const next = new Set(expandedIds.value);
|
|
2622
|
+
next.delete(id);
|
|
2623
|
+
expandedIds.value = next;
|
|
2624
|
+
}
|
|
2625
|
+
function toggle(id) {
|
|
2626
|
+
if (expandedIds.value.has(id)) {
|
|
2627
|
+
collapse(id);
|
|
2628
|
+
return false;
|
|
2629
|
+
}
|
|
2630
|
+
expand(id);
|
|
2631
|
+
return true;
|
|
2632
|
+
}
|
|
2633
|
+
function expandMany(ids) {
|
|
2634
|
+
expandedIds.value = new Set([...expandedIds.value, ...normalizeIds(ids)]);
|
|
2635
|
+
}
|
|
2636
|
+
function setExpanded(ids) {
|
|
2637
|
+
expandedIds.value = new Set(normalizeIds(ids));
|
|
2638
|
+
}
|
|
2639
|
+
function collapseAll() {
|
|
2640
|
+
expandedIds.value = /* @__PURE__ */ new Set();
|
|
2641
|
+
}
|
|
2642
|
+
function reset() {
|
|
2643
|
+
setExpanded(normalizeIds(toValue(options.defaultIds)));
|
|
2644
|
+
}
|
|
2645
|
+
return {
|
|
2646
|
+
expandedIds,
|
|
2647
|
+
expandedList,
|
|
2648
|
+
isExpanded,
|
|
2649
|
+
expand,
|
|
2650
|
+
collapse,
|
|
2651
|
+
toggle,
|
|
2652
|
+
expandMany,
|
|
2653
|
+
setExpanded,
|
|
2654
|
+
collapseAll,
|
|
2655
|
+
reset
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
function normalizeIds(ids) {
|
|
2659
|
+
if (!ids) return [];
|
|
2660
|
+
return [...new Set(ids.filter(Boolean))];
|
|
2661
|
+
}
|
|
2662
|
+
//#endregion
|
|
2663
|
+
//#region src/composables/platformContextHelpers.ts
|
|
2664
|
+
function getInjectedPlatformContext() {
|
|
2665
|
+
if (typeof window === "undefined") return void 0;
|
|
2666
|
+
return window.__MINT_PLATFORM__;
|
|
2667
|
+
}
|
|
2668
|
+
function getInjectedExperimentContext() {
|
|
2669
|
+
return getInjectedPlatformContext();
|
|
2670
|
+
}
|
|
2671
|
+
function currentExperimentFromContext(context = getInjectedExperimentContext()) {
|
|
2672
|
+
const candidate = context?.currentExperiment ?? context?.experiment;
|
|
2673
|
+
return candidate && typeof candidate === "object" ? candidate : void 0;
|
|
2674
|
+
}
|
|
2675
|
+
function currentExperimentIdFromContext(context = getInjectedExperimentContext()) {
|
|
2676
|
+
return parseExperimentId(context?.currentExperimentId ?? context?.experimentId ?? context?.currentExperiment ?? context?.experiment);
|
|
2677
|
+
}
|
|
2678
|
+
function currentExperimentIdFromUrl() {
|
|
2679
|
+
if (typeof window === "undefined") return void 0;
|
|
2680
|
+
const params = new URLSearchParams(window.location.search);
|
|
2681
|
+
return parseExperimentId(params.get("experimentId") ?? params.get("experiment_id")) ?? experimentIdFromPath(window.location.pathname) ?? experimentIdFromPath(window.location.hash.replace(/^#\/?/, "/"));
|
|
2682
|
+
}
|
|
2683
|
+
function resolveCurrentExperimentId(context = getInjectedExperimentContext()) {
|
|
2684
|
+
return currentExperimentIdFromContext(context) ?? currentExperimentIdFromUrl();
|
|
2685
|
+
}
|
|
2686
|
+
function parseExperimentId(value) {
|
|
2687
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
2688
|
+
if (typeof value === "string" && value.trim()) {
|
|
2689
|
+
const parsed = Number(value);
|
|
2690
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2691
|
+
}
|
|
2692
|
+
if (value && typeof value === "object" && "id" in value) return parseExperimentId(value.id);
|
|
2693
|
+
}
|
|
2694
|
+
function experimentIdFromPath(path) {
|
|
2695
|
+
const segments = path.split("/").filter(Boolean);
|
|
2696
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
2697
|
+
const segment = segments[index]?.toLowerCase();
|
|
2698
|
+
if (segment === "experiment" || segment === "experiments") return parseExperimentId(decodeURIComponent(segments[index + 1] ?? ""));
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
//#endregion
|
|
2702
|
+
//#region src/composables/useExperimentSave.ts
|
|
2703
|
+
/** Persists experiment design, analysis, and built-in template preset data through the platform API. */
|
|
2704
|
+
function useExperimentSave(options = {}) {
|
|
2705
|
+
const injectedContext = getInjectedPlatformContext();
|
|
2706
|
+
const api = useApi({ baseUrl: options.apiBaseUrl ?? injectedContext?.platformApiUrl });
|
|
2707
|
+
const pluginId = options.pluginId ?? injectedContext?.plugin?.id;
|
|
2708
|
+
const schemaVersion = options.schemaVersion ?? "1.0";
|
|
2709
|
+
const currentExperimentId = computed(() => {
|
|
2710
|
+
return resolveCurrentExperimentId(injectedContext);
|
|
2711
|
+
});
|
|
2712
|
+
const hasCurrentExperiment = computed(() => currentExperimentId.value !== void 0);
|
|
2713
|
+
const request = useRequestSyncState("Experiment request failed.");
|
|
2714
|
+
const isSaving = ref(false);
|
|
2715
|
+
const isLoading = ref(false);
|
|
2716
|
+
const error = request.error;
|
|
2717
|
+
const lastLoadedAt = request.lastLoadedAt;
|
|
2718
|
+
const lastSavedAt = request.lastSavedAt;
|
|
2719
|
+
function requireCurrentExperimentId() {
|
|
2720
|
+
const id = currentExperimentId.value;
|
|
2721
|
+
if (id === void 0) throw new Error("[MINT SDK] No current experiment is selected.");
|
|
2722
|
+
return id;
|
|
2723
|
+
}
|
|
2724
|
+
function currentExperimentIdOrError(action) {
|
|
2725
|
+
const id = currentExperimentId.value;
|
|
2726
|
+
if (id === void 0) {
|
|
2727
|
+
request.setError(`No current experiment is selected for ${action}`);
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
return id;
|
|
2731
|
+
}
|
|
2732
|
+
async function saveDesign(experimentId, data) {
|
|
2733
|
+
if (!pluginId) {
|
|
2734
|
+
request.setError("pluginId is required for saveDesign");
|
|
2735
|
+
return false;
|
|
2736
|
+
}
|
|
2737
|
+
isSaving.value = true;
|
|
2738
|
+
try {
|
|
2739
|
+
await request.run(async () => {
|
|
2740
|
+
await api.put(`/experiments/${experimentId}/data`, {
|
|
2741
|
+
plugin_id: pluginId,
|
|
2742
|
+
data,
|
|
2743
|
+
schema_version: schemaVersion
|
|
2744
|
+
});
|
|
2745
|
+
}, { success: "save" });
|
|
2746
|
+
return true;
|
|
2747
|
+
} catch {
|
|
2748
|
+
return false;
|
|
2749
|
+
} finally {
|
|
2750
|
+
isSaving.value = false;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
async function saveCurrentDesign(data) {
|
|
2754
|
+
const experimentId = currentExperimentIdOrError("saveDesign");
|
|
2755
|
+
return experimentId === void 0 ? false : saveDesign(experimentId, data);
|
|
2756
|
+
}
|
|
2757
|
+
async function saveTemplatePreset(experimentId, name, presetOptions = {}) {
|
|
2758
|
+
return saveDesign(experimentId, createBioTemplatePresetCollection(name, presetOptions));
|
|
2759
|
+
}
|
|
2760
|
+
async function saveCurrentTemplatePreset(name, presetOptions = {}) {
|
|
2761
|
+
const experimentId = currentExperimentIdOrError("saveTemplatePreset");
|
|
2762
|
+
return experimentId === void 0 ? false : saveTemplatePreset(experimentId, name, presetOptions);
|
|
2763
|
+
}
|
|
2764
|
+
async function saveAnalysis(experimentId, result) {
|
|
2765
|
+
if (!pluginId) {
|
|
2766
|
+
request.setError("pluginId is required for saveAnalysis");
|
|
2767
|
+
return false;
|
|
2768
|
+
}
|
|
2769
|
+
isSaving.value = true;
|
|
2770
|
+
try {
|
|
2771
|
+
await request.run(async () => {
|
|
2772
|
+
await api.put(`/experiments/${experimentId}/results/${pluginId}`, { result });
|
|
2773
|
+
}, { success: "save" });
|
|
2774
|
+
return true;
|
|
2775
|
+
} catch {
|
|
2776
|
+
return false;
|
|
2777
|
+
} finally {
|
|
2778
|
+
isSaving.value = false;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
async function saveCurrentAnalysis(result) {
|
|
2782
|
+
const experimentId = currentExperimentIdOrError("saveAnalysis");
|
|
2783
|
+
return experimentId === void 0 ? false : saveAnalysis(experimentId, result);
|
|
2784
|
+
}
|
|
2785
|
+
async function save(experimentId, opts) {
|
|
2786
|
+
if ((opts.design || opts.analysis) && !pluginId) {
|
|
2787
|
+
request.setError("pluginId is required for save");
|
|
2788
|
+
return false;
|
|
2789
|
+
}
|
|
2790
|
+
isSaving.value = true;
|
|
2791
|
+
try {
|
|
2792
|
+
await request.run(async () => {
|
|
2793
|
+
if (opts.design) await api.put(`/experiments/${experimentId}/data`, {
|
|
2794
|
+
plugin_id: pluginId,
|
|
2795
|
+
data: opts.design,
|
|
2796
|
+
schema_version: schemaVersion
|
|
2797
|
+
});
|
|
2798
|
+
if (opts.analysis) await api.put(`/experiments/${experimentId}/results/${pluginId}`, { result: opts.analysis });
|
|
2799
|
+
}, { success: "save" });
|
|
2800
|
+
return true;
|
|
2801
|
+
} catch {
|
|
2802
|
+
return false;
|
|
2803
|
+
} finally {
|
|
2804
|
+
isSaving.value = false;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
async function loadDesign(experimentId) {
|
|
2808
|
+
isLoading.value = true;
|
|
2809
|
+
try {
|
|
2810
|
+
return await request.run(() => api.get(`/experiments/${experimentId}/data`), { success: "load" });
|
|
2811
|
+
} catch {
|
|
2812
|
+
return null;
|
|
2813
|
+
} finally {
|
|
2814
|
+
isLoading.value = false;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
async function loadCurrentDesign() {
|
|
2818
|
+
const experimentId = currentExperimentIdOrError("loadDesign");
|
|
2819
|
+
return experimentId === void 0 ? null : loadDesign(experimentId);
|
|
2820
|
+
}
|
|
2821
|
+
async function loadAnalysis(experimentId) {
|
|
2822
|
+
if (!pluginId) {
|
|
2823
|
+
request.setError("pluginId is required for loadAnalysis");
|
|
2824
|
+
return null;
|
|
2825
|
+
}
|
|
2826
|
+
isLoading.value = true;
|
|
2827
|
+
try {
|
|
2828
|
+
return await request.run(() => api.get(`/experiments/${experimentId}/results/${pluginId}`), { success: "load" });
|
|
2829
|
+
} catch {
|
|
2830
|
+
return null;
|
|
2831
|
+
} finally {
|
|
2832
|
+
isLoading.value = false;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
async function loadCurrentAnalysis() {
|
|
2836
|
+
const experimentId = currentExperimentIdOrError("loadAnalysis");
|
|
2837
|
+
return experimentId === void 0 ? null : loadAnalysis(experimentId);
|
|
2838
|
+
}
|
|
2839
|
+
async function deleteDesign(experimentId) {
|
|
2840
|
+
isLoading.value = true;
|
|
2841
|
+
try {
|
|
2842
|
+
await request.run(() => api.delete(`/experiments/${experimentId}/data`));
|
|
2843
|
+
return true;
|
|
2844
|
+
} catch {
|
|
2845
|
+
return false;
|
|
2846
|
+
} finally {
|
|
2847
|
+
isLoading.value = false;
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
async function deleteCurrentDesign() {
|
|
2851
|
+
const experimentId = currentExperimentIdOrError("deleteDesign");
|
|
2852
|
+
return experimentId === void 0 ? false : deleteDesign(experimentId);
|
|
2853
|
+
}
|
|
2854
|
+
async function deleteAnalysis(experimentId) {
|
|
2855
|
+
if (!pluginId) {
|
|
2856
|
+
request.setError("pluginId is required for deleteAnalysis");
|
|
2857
|
+
return false;
|
|
2858
|
+
}
|
|
2859
|
+
isLoading.value = true;
|
|
2860
|
+
try {
|
|
2861
|
+
await request.run(() => api.delete(`/experiments/${experimentId}/results/${pluginId}`));
|
|
2862
|
+
return true;
|
|
2863
|
+
} catch {
|
|
2864
|
+
return false;
|
|
2865
|
+
} finally {
|
|
2866
|
+
isLoading.value = false;
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
async function deleteCurrentAnalysis() {
|
|
2870
|
+
const experimentId = currentExperimentIdOrError("deleteAnalysis");
|
|
2871
|
+
return experimentId === void 0 ? false : deleteAnalysis(experimentId);
|
|
2872
|
+
}
|
|
2873
|
+
return {
|
|
2874
|
+
currentExperimentId,
|
|
2875
|
+
hasCurrentExperiment,
|
|
2876
|
+
isSaving,
|
|
2877
|
+
isLoading,
|
|
2878
|
+
error,
|
|
2879
|
+
lastLoadedAt,
|
|
2880
|
+
lastSavedAt,
|
|
2881
|
+
saveDesign,
|
|
2882
|
+
saveCurrentDesign,
|
|
2883
|
+
saveTemplatePreset,
|
|
2884
|
+
saveCurrentTemplatePreset,
|
|
2885
|
+
saveAnalysis,
|
|
2886
|
+
saveCurrentAnalysis,
|
|
2887
|
+
save,
|
|
2888
|
+
loadDesign,
|
|
2889
|
+
loadCurrentDesign,
|
|
2890
|
+
loadAnalysis,
|
|
2891
|
+
loadCurrentAnalysis,
|
|
2892
|
+
deleteDesign,
|
|
2893
|
+
deleteCurrentDesign,
|
|
2894
|
+
deleteAnalysis,
|
|
2895
|
+
deleteCurrentAnalysis,
|
|
2896
|
+
requireCurrentExperimentId
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
//#endregion
|
|
2900
|
+
//#region src/composables/useTemplateCollection.ts
|
|
2901
|
+
/** Manages a biology template collection in the current experiment's design_data. */
|
|
2902
|
+
function useTemplateCollection(options = {}) {
|
|
2903
|
+
const experimentSave = useExperimentSave(options);
|
|
2904
|
+
const templateCollection = ref(createInitialCollection(options));
|
|
2905
|
+
const templates = computed(() => extractTemplateCollection(templateCollection.value));
|
|
2906
|
+
const metadata = computed(() => templateCollection.value.metadata ?? {});
|
|
2907
|
+
const templateIds = computed(() => Object.keys(templates.value));
|
|
2908
|
+
const hasTemplates = computed(() => templateIds.value.length > 0);
|
|
2909
|
+
function apply(value, metadata) {
|
|
2910
|
+
templateCollection.value = normalizeTemplateCollection(value, metadata);
|
|
2911
|
+
return templateCollection.value;
|
|
2912
|
+
}
|
|
2913
|
+
function reset() {
|
|
2914
|
+
templateCollection.value = createInitialCollection(options);
|
|
2915
|
+
return templateCollection.value;
|
|
2916
|
+
}
|
|
2917
|
+
function setTemplate(template) {
|
|
2918
|
+
const nextTemplates = {
|
|
2919
|
+
...templates.value,
|
|
2920
|
+
[template.template_id]: template
|
|
2921
|
+
};
|
|
2922
|
+
templateCollection.value = createTemplateCollectionEnvelope(Object.values(nextTemplates), templateCollection.value.metadata);
|
|
2923
|
+
return templateCollection.value;
|
|
2924
|
+
}
|
|
2925
|
+
function removeTemplate(templateId) {
|
|
2926
|
+
if (!templates.value[templateId]) return false;
|
|
2927
|
+
const nextTemplates = { ...templates.value };
|
|
2928
|
+
delete nextTemplates[templateId];
|
|
2929
|
+
templateCollection.value = createTemplateCollectionEnvelope(Object.values(nextTemplates), templateCollection.value.metadata);
|
|
2930
|
+
return true;
|
|
2931
|
+
}
|
|
2932
|
+
function getTemplate(templateId) {
|
|
2933
|
+
return templates.value[templateId] ?? null;
|
|
2934
|
+
}
|
|
2935
|
+
function requireTemplate(templateId) {
|
|
2936
|
+
return ensureTemplateFromCollection(templateCollection.value, templateId);
|
|
2937
|
+
}
|
|
2938
|
+
async function load(experimentId) {
|
|
2939
|
+
const data = await experimentSave.loadDesign(experimentId);
|
|
2940
|
+
return data === null ? null : apply(data);
|
|
2941
|
+
}
|
|
2942
|
+
async function loadCurrent() {
|
|
2943
|
+
const data = await experimentSave.loadCurrentDesign();
|
|
2944
|
+
return data === null ? null : apply(data);
|
|
2945
|
+
}
|
|
2946
|
+
async function save(experimentId) {
|
|
2947
|
+
return experimentSave.saveDesign(experimentId, templateCollection.value);
|
|
2948
|
+
}
|
|
2949
|
+
async function saveCurrent() {
|
|
2950
|
+
return experimentSave.saveCurrentDesign(templateCollection.value);
|
|
2951
|
+
}
|
|
2952
|
+
return {
|
|
2953
|
+
currentExperimentId: experimentSave.currentExperimentId,
|
|
2954
|
+
hasCurrentExperiment: experimentSave.hasCurrentExperiment,
|
|
2955
|
+
templateCollection,
|
|
2956
|
+
templates,
|
|
2957
|
+
metadata,
|
|
2958
|
+
templateIds,
|
|
2959
|
+
hasTemplates,
|
|
2960
|
+
isLoading: experimentSave.isLoading,
|
|
2961
|
+
isSaving: experimentSave.isSaving,
|
|
2962
|
+
error: experimentSave.error,
|
|
2963
|
+
lastLoadedAt: experimentSave.lastLoadedAt,
|
|
2964
|
+
lastSavedAt: experimentSave.lastSavedAt,
|
|
2965
|
+
apply,
|
|
2966
|
+
reset,
|
|
2967
|
+
setTemplate,
|
|
2968
|
+
removeTemplate,
|
|
2969
|
+
getTemplate,
|
|
2970
|
+
requireTemplate,
|
|
2971
|
+
load,
|
|
2972
|
+
loadCurrent,
|
|
2973
|
+
save,
|
|
2974
|
+
saveCurrent,
|
|
2975
|
+
requireCurrentExperimentId: experimentSave.requireCurrentExperimentId
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
function createInitialCollection(options) {
|
|
2979
|
+
const initial = typeof options.initial === "function" ? options.initial() : options.initial;
|
|
2980
|
+
if (initial === void 0) return { templates: {} };
|
|
2981
|
+
return normalizeTemplateCollection(initial);
|
|
2982
|
+
}
|
|
2983
|
+
function normalizeTemplateCollection(value, metadata) {
|
|
2984
|
+
if (Array.isArray(value)) return createTemplateCollectionEnvelope(value, metadata);
|
|
2985
|
+
return createTemplateCollectionEnvelope(Object.values(extractTemplateCollection(value)), readCollectionMetadata(value) ?? metadata);
|
|
2986
|
+
}
|
|
2987
|
+
function createTemplateCollectionEnvelope(templates, metadata) {
|
|
2988
|
+
return metadata === void 0 ? createTemplateCollection(templates) : createTemplateCollection(templates, metadata);
|
|
2989
|
+
}
|
|
2990
|
+
function readCollectionMetadata(value) {
|
|
2991
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || !("metadata" in value)) return;
|
|
2992
|
+
const metadata = value.metadata;
|
|
2993
|
+
if (typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)) return metadata;
|
|
2994
|
+
}
|
|
2995
|
+
//#endregion
|
|
2996
|
+
//#region src/composables/useBioTemplateControls.ts
|
|
2997
|
+
/** Build FormBuilder schema, AppSidebar panels, and defaults for a built-in biology template or preset. */
|
|
2998
|
+
function useBioTemplateControls(target, options = {}) {
|
|
2999
|
+
const { initialValues, topBarSettings: _topBarSettings, ...schemaOptions } = options;
|
|
3000
|
+
const controls = createBioTemplateControlToolkit(target, schemaOptions);
|
|
3001
|
+
if (initialValues === void 0) return controls;
|
|
3002
|
+
return {
|
|
3003
|
+
...controls,
|
|
3004
|
+
initialValues: {
|
|
3005
|
+
...controls.initialValues,
|
|
3006
|
+
...initialValues
|
|
3007
|
+
}
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
//#endregion
|
|
3011
|
+
//#region src/composables/useBioTemplateComponents.ts
|
|
3012
|
+
/** Return component props grouped by SDK component name for direct WellPlate/DoseCalculator binding. */
|
|
3013
|
+
function toBioTemplateComponentPropsByComponent(target) {
|
|
3014
|
+
return toBioTemplateComponentPropsByComponent$1(target);
|
|
3015
|
+
}
|
|
3016
|
+
/** Return the first props object for an SDK component, optionally restricted by template id or binding id. */
|
|
3017
|
+
function getBioTemplateComponentProps(target, component, options = {}) {
|
|
3018
|
+
return getBioTemplateComponentProps$1(target, component, options);
|
|
3019
|
+
}
|
|
3020
|
+
/** Map built-in biology templates to SDK components such as WellPlate, DataFrame, DoseCalculator, and PlateMapEditor. */
|
|
3021
|
+
function useBioTemplateComponents(target) {
|
|
3022
|
+
return {
|
|
3023
|
+
bindings: getBioTemplateComponentBindings(target),
|
|
3024
|
+
imports: toBioTemplateComponentImports(target),
|
|
3025
|
+
componentProps: typeof target === "string" ? [] : toBioTemplateComponentProps(target),
|
|
3026
|
+
componentPropsById: typeof target === "string" ? {} : toBioTemplateComponentPropsById(target),
|
|
3027
|
+
componentPropsByComponent: typeof target === "string" ? {} : toBioTemplateComponentPropsByComponent(target),
|
|
3028
|
+
snippets: typeof target === "string" ? [] : toBioTemplateComponentSnippets(target),
|
|
3029
|
+
usage: typeof target === "string" ? null : toBioTemplateComponentUsage(target),
|
|
3030
|
+
getComponentProps: (component, options) => typeof target === "string" ? void 0 : getBioTemplateComponentProps(target, component, options)
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
//#endregion
|
|
3034
|
+
//#region src/composables/useBioTemplateWorkspace.ts
|
|
3035
|
+
/** One-stop wiring for BioTemplateRenderer, FormBuilder, AppSidebar, and template-aware SDK components. */
|
|
3036
|
+
function useBioTemplateWorkspace(target, options = {}) {
|
|
3037
|
+
const controls = useBioTemplateControls(target, options);
|
|
3038
|
+
const workspace = useControlWorkspace(controls.controls, options);
|
|
3039
|
+
const components = useBioTemplateComponents(target);
|
|
3040
|
+
const renderer = isTemplateObjectTarget(target) ? { target } : null;
|
|
3041
|
+
const bindings = {
|
|
3042
|
+
renderer,
|
|
3043
|
+
form: workspace.bindings.form,
|
|
3044
|
+
sidebar: workspace.bindings.sidebar,
|
|
3045
|
+
topBar: workspace.bindings.topBar,
|
|
3046
|
+
topBarTabs: workspace.bindings.topBarTabs,
|
|
3047
|
+
topBarSettings: workspace.bindings.topBarSettings,
|
|
3048
|
+
pillNav: workspace.bindings.pillNav,
|
|
3049
|
+
componentBindings: components.bindings,
|
|
3050
|
+
componentProps: components.componentProps,
|
|
3051
|
+
componentPropsById: components.componentPropsById,
|
|
3052
|
+
componentPropsByComponent: components.componentPropsByComponent,
|
|
3053
|
+
componentSnippets: components.snippets,
|
|
3054
|
+
componentUsage: components.usage,
|
|
3055
|
+
getComponentProps: components.getComponentProps
|
|
3056
|
+
};
|
|
3057
|
+
return {
|
|
3058
|
+
target,
|
|
3059
|
+
controls,
|
|
3060
|
+
workspace,
|
|
3061
|
+
controlSchema: controls.controls,
|
|
3062
|
+
components,
|
|
3063
|
+
values: workspace.values,
|
|
3064
|
+
activeView: workspace.activeView,
|
|
3065
|
+
form: workspace.form,
|
|
3066
|
+
sidebar: workspace.sidebar,
|
|
3067
|
+
topBar: workspace.topBar,
|
|
3068
|
+
pillNav: workspace.pillNav,
|
|
3069
|
+
topBarSettings: workspace.topBarSettings,
|
|
3070
|
+
bindings,
|
|
3071
|
+
renderer,
|
|
3072
|
+
componentBindings: components.bindings,
|
|
3073
|
+
componentImports: components.imports,
|
|
3074
|
+
componentProps: components.componentProps,
|
|
3075
|
+
componentPropsById: components.componentPropsById,
|
|
3076
|
+
componentPropsByComponent: components.componentPropsByComponent,
|
|
3077
|
+
componentSnippets: components.snippets,
|
|
3078
|
+
componentUsage: components.usage,
|
|
3079
|
+
getComponentProps: components.getComponentProps
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
function isTemplateObjectTarget(target) {
|
|
3083
|
+
return typeof target !== "string";
|
|
3084
|
+
}
|
|
3085
|
+
//#endregion
|
|
3086
|
+
//#region src/composables/useBioTemplatePackWorkspace.ts
|
|
3087
|
+
/** Create a complete workspace for a curated biology template pack from a single pack id. */
|
|
3088
|
+
function useBioTemplatePackWorkspace(packId, options = {}) {
|
|
3089
|
+
const pack = getBioTemplatePackInfo(packId);
|
|
3090
|
+
if (!pack) throw new Error(`Unknown template pack '${packId}'.`);
|
|
3091
|
+
const resolvedPack = pack;
|
|
3092
|
+
const templateCollection = useTemplateCollection({
|
|
3093
|
+
...options,
|
|
3094
|
+
initial: createDefaultCollection
|
|
3095
|
+
});
|
|
3096
|
+
const collection = templateCollection.templateCollection;
|
|
3097
|
+
let workspaceScope;
|
|
3098
|
+
function createWorkspace(value) {
|
|
3099
|
+
workspaceScope?.stop();
|
|
3100
|
+
workspaceScope = effectScope();
|
|
3101
|
+
return workspaceScope.run(() => useBioTemplateWorkspace(value));
|
|
3102
|
+
}
|
|
3103
|
+
const workspaceRef = shallowRef(createWorkspace(collection.value));
|
|
3104
|
+
const workspace = computed(() => workspaceRef.value);
|
|
3105
|
+
const renderer = computed(() => ({ target: collection.value }));
|
|
3106
|
+
const form = computed(() => workspace.value.form);
|
|
3107
|
+
const sidebar = computed(() => workspace.value.sidebar);
|
|
3108
|
+
const topBar = computed(() => workspace.value.topBar);
|
|
3109
|
+
const pillNav = computed(() => workspace.value.pillNav);
|
|
3110
|
+
const topBarSettings = computed(() => workspace.value.topBarSettings);
|
|
3111
|
+
const bindings = computed(() => workspace.value.bindings);
|
|
3112
|
+
const componentBindings = computed(() => workspace.value.componentBindings);
|
|
3113
|
+
const componentImports = computed(() => workspace.value.componentImports);
|
|
3114
|
+
const componentProps = computed(() => workspace.value.componentProps);
|
|
3115
|
+
const componentPropsById = computed(() => workspace.value.componentPropsById);
|
|
3116
|
+
const componentPropsByComponent = computed(() => workspace.value.componentPropsByComponent);
|
|
3117
|
+
const componentSnippets = computed(() => workspace.value.componentSnippets);
|
|
3118
|
+
const componentUsage = computed(() => workspace.value.componentUsage);
|
|
3119
|
+
watch(collection, (value) => {
|
|
3120
|
+
workspaceRef.value = createWorkspace(value);
|
|
3121
|
+
});
|
|
3122
|
+
if (getCurrentScope()) onScopeDispose(() => workspaceScope?.stop());
|
|
3123
|
+
function createDefaultCollection() {
|
|
3124
|
+
return createBioTemplatePackCollection(resolvedPack.name);
|
|
3125
|
+
}
|
|
3126
|
+
function applyCollection(value, metadata) {
|
|
3127
|
+
return templateCollection.apply(value, metadata);
|
|
3128
|
+
}
|
|
3129
|
+
function resetToDefaultCollection() {
|
|
3130
|
+
return templateCollection.apply(createDefaultCollection());
|
|
3131
|
+
}
|
|
3132
|
+
return {
|
|
3133
|
+
packId: resolvedPack.name,
|
|
3134
|
+
pack: resolvedPack,
|
|
3135
|
+
collection,
|
|
3136
|
+
templates: templateCollection.templates,
|
|
3137
|
+
templateIds: templateCollection.templateIds,
|
|
3138
|
+
currentExperimentId: templateCollection.currentExperimentId,
|
|
3139
|
+
hasCurrentExperiment: templateCollection.hasCurrentExperiment,
|
|
3140
|
+
loading: templateCollection.isLoading,
|
|
3141
|
+
saving: templateCollection.isSaving,
|
|
3142
|
+
error: templateCollection.error,
|
|
3143
|
+
lastLoadedAt: templateCollection.lastLoadedAt,
|
|
3144
|
+
lastSavedAt: templateCollection.lastSavedAt,
|
|
3145
|
+
renderer,
|
|
3146
|
+
workspace,
|
|
3147
|
+
form,
|
|
3148
|
+
sidebar,
|
|
3149
|
+
topBar,
|
|
3150
|
+
pillNav,
|
|
3151
|
+
topBarSettings,
|
|
3152
|
+
bindings,
|
|
3153
|
+
componentBindings,
|
|
3154
|
+
componentImports,
|
|
3155
|
+
componentProps,
|
|
3156
|
+
componentPropsById,
|
|
3157
|
+
componentPropsByComponent,
|
|
3158
|
+
componentSnippets,
|
|
3159
|
+
componentUsage,
|
|
3160
|
+
getComponentProps: (component, options) => workspace.value.getComponentProps(component, options),
|
|
3161
|
+
applyCollection,
|
|
3162
|
+
resetToDefaultCollection,
|
|
3163
|
+
save: templateCollection.save,
|
|
3164
|
+
saveCurrent: templateCollection.saveCurrent,
|
|
3165
|
+
load: templateCollection.load,
|
|
3166
|
+
loadCurrent: templateCollection.loadCurrent,
|
|
3167
|
+
requireCurrentExperimentId: templateCollection.requireCurrentExperimentId
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
//#endregion
|
|
3171
|
+
//#region src/composables/useBioTemplatePresetWorkspace.ts
|
|
3172
|
+
/** Create a complete editable WellPlate/DoseCalculator workspace for dose design from a built-in biology template preset with current-experiment save wiring. */
|
|
3173
|
+
function useBioTemplatePresetWorkspace(presetId, options = {}) {
|
|
3174
|
+
const { controlOptions, initialValues, ...collectionOptions } = options;
|
|
3175
|
+
const resolvedControlOptions = mergeInitialValues(controlOptions, initialValues);
|
|
3176
|
+
const presetWorkspace = useBioTemplateWorkspace(presetId, resolvedControlOptions);
|
|
3177
|
+
const presetInfo = computed(() => getBioTemplatePresetInfo(presetId));
|
|
3178
|
+
const presetLabel = computed(() => presetInfo.value?.label ?? humanizePresetId(presetId));
|
|
3179
|
+
const controls = presetWorkspace.controls;
|
|
3180
|
+
const controlValues = ref({ ...controls.initialValues });
|
|
3181
|
+
const controlViewIds = computed(() => controls.viewIds);
|
|
3182
|
+
const controlViewItems = computed(() => controls.viewItems);
|
|
3183
|
+
const activeControlView = ref(controls.defaultView);
|
|
3184
|
+
const templateCollection = useTemplateCollection({
|
|
3185
|
+
...collectionOptions,
|
|
3186
|
+
initial: createDefaultCollection
|
|
3187
|
+
});
|
|
3188
|
+
const collection = templateCollection.templateCollection;
|
|
3189
|
+
const workspace = computed(() => useBioTemplateWorkspace(collection.value, resolvedControlOptions));
|
|
3190
|
+
const renderer = computed(() => ({ target: collection.value }));
|
|
3191
|
+
const componentBindings = computed(() => workspace.value.componentBindings);
|
|
3192
|
+
const componentImports = computed(() => workspace.value.componentImports);
|
|
3193
|
+
const componentProps = computed(() => workspace.value.componentProps);
|
|
3194
|
+
const componentPropsById = computed(() => workspace.value.componentPropsById);
|
|
3195
|
+
const componentPropsByComponent = computed(() => workspace.value.componentPropsByComponent);
|
|
3196
|
+
const componentSnippets = computed(() => workspace.value.componentSnippets);
|
|
3197
|
+
const componentUsage = computed(() => workspace.value.componentUsage);
|
|
3198
|
+
const form = reactive({
|
|
3199
|
+
...controls.form,
|
|
3200
|
+
modelValue: controlValues.value,
|
|
3201
|
+
"onUpdate:modelValue": setControlValues
|
|
3202
|
+
});
|
|
3203
|
+
const sidebar = reactive({
|
|
3204
|
+
...controls.sidebar,
|
|
3205
|
+
activeView: activeControlView.value,
|
|
3206
|
+
modelValue: controlValues.value,
|
|
3207
|
+
"onUpdate:modelValue": setControlValues,
|
|
3208
|
+
values: controlValues.value,
|
|
3209
|
+
"onUpdate:values": setControlValues
|
|
3210
|
+
});
|
|
3211
|
+
const topBar = reactive({
|
|
3212
|
+
tabs: controls.topBarTabs,
|
|
3213
|
+
currentTabId: activeControlView.value,
|
|
3214
|
+
onTabSelect: (tab) => setActiveControlView(tab.id)
|
|
3215
|
+
});
|
|
3216
|
+
const pillNav = reactive({
|
|
3217
|
+
items: controls.viewItems,
|
|
3218
|
+
currentItemId: activeControlView.value,
|
|
3219
|
+
onSelect: (item) => setActiveControlView(item.id)
|
|
3220
|
+
});
|
|
3221
|
+
const topBarSettings = reactive({
|
|
3222
|
+
showSettings: true,
|
|
3223
|
+
settingsConfig: {
|
|
3224
|
+
...controls.topBarSettingsConfig,
|
|
3225
|
+
values: controlValues.value
|
|
3226
|
+
},
|
|
3227
|
+
onSettingsValuesChange: setControlValues
|
|
3228
|
+
});
|
|
3229
|
+
const topBarProps = computed(() => ({
|
|
3230
|
+
pillNav: pillNav.items,
|
|
3231
|
+
currentPillId: pillNav.currentItemId,
|
|
3232
|
+
onPillSelect: pillNav.onSelect,
|
|
3233
|
+
...topBarSettings
|
|
3234
|
+
}));
|
|
3235
|
+
const topBarTabsProps = computed(() => ({
|
|
3236
|
+
tabs: topBar.tabs,
|
|
3237
|
+
currentTabId: topBar.currentTabId,
|
|
3238
|
+
onTabSelect: topBar.onTabSelect,
|
|
3239
|
+
...topBarSettings
|
|
3240
|
+
}));
|
|
3241
|
+
function getComponentProps(component, options) {
|
|
3242
|
+
return workspace.value.getComponentProps(component, options);
|
|
3243
|
+
}
|
|
3244
|
+
const bindings = {
|
|
3245
|
+
renderer,
|
|
3246
|
+
form,
|
|
3247
|
+
sidebar,
|
|
3248
|
+
topBar: topBarProps,
|
|
3249
|
+
topBarTabs: topBarTabsProps,
|
|
3250
|
+
topBarSettings,
|
|
3251
|
+
pillNav,
|
|
3252
|
+
componentBindings,
|
|
3253
|
+
componentProps,
|
|
3254
|
+
componentPropsById,
|
|
3255
|
+
componentPropsByComponent,
|
|
3256
|
+
componentSnippets,
|
|
3257
|
+
componentUsage,
|
|
3258
|
+
getComponentProps
|
|
3259
|
+
};
|
|
3260
|
+
function createDefaultCollection() {
|
|
3261
|
+
return createBioTemplatePresetCollectionFromControls(presetId, controls.initialValues);
|
|
3262
|
+
}
|
|
3263
|
+
watch(controlValues, (values) => {
|
|
3264
|
+
syncControlValueBindings(values);
|
|
3265
|
+
templateCollection.apply(createBioTemplatePresetCollectionFromControls(presetId, values));
|
|
3266
|
+
}, { deep: true });
|
|
3267
|
+
watch(activeControlView, (viewId) => {
|
|
3268
|
+
sidebar.activeView = viewId;
|
|
3269
|
+
topBar.currentTabId = viewId;
|
|
3270
|
+
pillNav.currentItemId = viewId;
|
|
3271
|
+
}, { flush: "sync" });
|
|
3272
|
+
watch(() => sidebar.activeView, setActiveControlView, { flush: "sync" });
|
|
3273
|
+
watch(() => topBar.currentTabId, setActiveControlView, { flush: "sync" });
|
|
3274
|
+
watch(() => pillNav.currentItemId, setActiveControlView, { flush: "sync" });
|
|
3275
|
+
function applyCollection(value) {
|
|
3276
|
+
return templateCollection.apply(value);
|
|
3277
|
+
}
|
|
3278
|
+
function setActiveControlView(viewId) {
|
|
3279
|
+
if (!controlViewIds.value.includes(viewId)) return;
|
|
3280
|
+
activeControlView.value = viewId;
|
|
3281
|
+
}
|
|
3282
|
+
function setControlValues(values) {
|
|
3283
|
+
const nextValues = { ...values };
|
|
3284
|
+
controlValues.value = nextValues;
|
|
3285
|
+
syncControlValueBindings(nextValues);
|
|
3286
|
+
return templateCollection.apply(createBioTemplatePresetCollectionFromControls(presetId, nextValues));
|
|
3287
|
+
}
|
|
3288
|
+
function updateControlValues(values) {
|
|
3289
|
+
return setControlValues({
|
|
3290
|
+
...controlValues.value,
|
|
3291
|
+
...values
|
|
3292
|
+
});
|
|
3293
|
+
}
|
|
3294
|
+
function resetToDefaultCollection() {
|
|
3295
|
+
return setControlValues({ ...controls.initialValues });
|
|
3296
|
+
}
|
|
3297
|
+
function syncControlValueBindings(values) {
|
|
3298
|
+
form.modelValue = values;
|
|
3299
|
+
sidebar.modelValue = values;
|
|
3300
|
+
sidebar.values = values;
|
|
3301
|
+
topBarSettings.settingsConfig = {
|
|
3302
|
+
...topBarSettings.settingsConfig,
|
|
3303
|
+
values
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
return {
|
|
3307
|
+
presetId,
|
|
3308
|
+
presetInfo,
|
|
3309
|
+
presetLabel,
|
|
3310
|
+
collection,
|
|
3311
|
+
templates: templateCollection.templates,
|
|
3312
|
+
templateIds: templateCollection.templateIds,
|
|
3313
|
+
currentExperimentId: templateCollection.currentExperimentId,
|
|
3314
|
+
hasCurrentExperiment: templateCollection.hasCurrentExperiment,
|
|
3315
|
+
saving: templateCollection.isSaving,
|
|
3316
|
+
error: templateCollection.error,
|
|
3317
|
+
lastSavedAt: templateCollection.lastSavedAt,
|
|
3318
|
+
controls,
|
|
3319
|
+
controlSchema: controls.controls,
|
|
3320
|
+
controlValues,
|
|
3321
|
+
controlViewIds,
|
|
3322
|
+
controlViewItems,
|
|
3323
|
+
activeControlView,
|
|
3324
|
+
form,
|
|
3325
|
+
sidebar,
|
|
3326
|
+
topBar,
|
|
3327
|
+
pillNav,
|
|
3328
|
+
topBarSettings,
|
|
3329
|
+
bindings,
|
|
3330
|
+
renderer,
|
|
3331
|
+
workspace,
|
|
3332
|
+
componentBindings,
|
|
3333
|
+
componentImports,
|
|
3334
|
+
componentProps,
|
|
3335
|
+
componentPropsById,
|
|
3336
|
+
componentPropsByComponent,
|
|
3337
|
+
componentSnippets,
|
|
3338
|
+
componentUsage,
|
|
3339
|
+
getComponentProps,
|
|
3340
|
+
setActiveControlView,
|
|
3341
|
+
setControlValues,
|
|
3342
|
+
updateControlValues,
|
|
3343
|
+
applyCollection,
|
|
3344
|
+
resetToDefaultCollection,
|
|
3345
|
+
save: templateCollection.save,
|
|
3346
|
+
saveCurrent: templateCollection.saveCurrent
|
|
3347
|
+
};
|
|
3348
|
+
}
|
|
3349
|
+
function humanizePresetId(value) {
|
|
3350
|
+
return value.replace(/[-_]+/g, " ").replace(/\b\w/g, (match) => match.toUpperCase());
|
|
3351
|
+
}
|
|
3352
|
+
function mergeInitialValues(controlOptions, initialValues) {
|
|
3353
|
+
if (initialValues === void 0) return controlOptions;
|
|
3354
|
+
return {
|
|
3355
|
+
...controlOptions ?? {},
|
|
3356
|
+
initialValues: {
|
|
3357
|
+
...controlOptions?.initialValues ?? {},
|
|
3358
|
+
...initialValues
|
|
3359
|
+
}
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
//#endregion
|
|
3363
|
+
//#region src/composables/useRackEditor.ts
|
|
3364
|
+
var SLOT_CYCLE = [
|
|
3365
|
+
"R",
|
|
3366
|
+
"G",
|
|
3367
|
+
"B",
|
|
3368
|
+
"Y"
|
|
3369
|
+
];
|
|
3370
|
+
var PLATE_CONFIGS = {
|
|
3371
|
+
6: {
|
|
3372
|
+
rows: 2,
|
|
3373
|
+
cols: 3
|
|
3374
|
+
},
|
|
3375
|
+
12: {
|
|
3376
|
+
rows: 3,
|
|
3377
|
+
cols: 4
|
|
3378
|
+
},
|
|
3379
|
+
24: {
|
|
3380
|
+
rows: 4,
|
|
3381
|
+
cols: 6
|
|
3382
|
+
},
|
|
3383
|
+
48: {
|
|
3384
|
+
rows: 6,
|
|
3385
|
+
cols: 8
|
|
3386
|
+
},
|
|
3387
|
+
54: {
|
|
3388
|
+
rows: 6,
|
|
3389
|
+
cols: 9
|
|
3390
|
+
},
|
|
3391
|
+
96: {
|
|
3392
|
+
rows: 8,
|
|
3393
|
+
cols: 12
|
|
3394
|
+
},
|
|
3395
|
+
384: {
|
|
3396
|
+
rows: 16,
|
|
3397
|
+
cols: 24
|
|
3398
|
+
}
|
|
3399
|
+
};
|
|
3400
|
+
var globalIdCounter = 0;
|
|
3401
|
+
function generateId() {
|
|
3402
|
+
globalIdCounter++;
|
|
3403
|
+
return `rack-${Date.now()}-${globalIdCounter}`;
|
|
3404
|
+
}
|
|
3405
|
+
/** Manages a set of sample racks with well-level assignment, series fill, and multi-rack reordering. */
|
|
3406
|
+
function useRackEditor(initialRacks, options) {
|
|
3407
|
+
const defaultFormat = options?.defaultFormat ?? 54;
|
|
3408
|
+
const defaultInjVol = options?.defaultInjectionVolume ?? 5;
|
|
3409
|
+
const maxRacks = options?.maxRacks ?? 10;
|
|
3410
|
+
const minRacks = options?.minRacks ?? 1;
|
|
3411
|
+
function createDefaultRack(name, slotIndex) {
|
|
3412
|
+
const slot = SLOT_CYCLE[(slotIndex ?? 0) % SLOT_CYCLE.length];
|
|
3413
|
+
return {
|
|
3414
|
+
id: generateId(),
|
|
3415
|
+
name: name ?? `Rack ${racks.value.length + 1}`,
|
|
3416
|
+
format: defaultFormat,
|
|
3417
|
+
slot,
|
|
3418
|
+
injectionVolume: defaultInjVol,
|
|
3419
|
+
wells: {}
|
|
3420
|
+
};
|
|
3421
|
+
}
|
|
3422
|
+
const racks = ref(initialRacks && initialRacks.length > 0 ? initialRacks.map((r) => ({ ...r })) : [createDefaultRack("Rack 1", 0)]);
|
|
3423
|
+
const activeRackId = ref(racks.value[0]?.id ?? "");
|
|
3424
|
+
const activeRack = computed(() => racks.value.find((r) => r.id === activeRackId.value) ?? racks.value[0]);
|
|
3425
|
+
function addRack(name) {
|
|
3426
|
+
if (racks.value.length >= maxRacks) return racks.value[racks.value.length - 1];
|
|
3427
|
+
const rack = createDefaultRack(name, racks.value.length);
|
|
3428
|
+
racks.value.push(rack);
|
|
3429
|
+
activeRackId.value = rack.id;
|
|
3430
|
+
return rack;
|
|
3431
|
+
}
|
|
3432
|
+
function removeRack(rackId) {
|
|
3433
|
+
if (racks.value.length <= minRacks) return;
|
|
3434
|
+
const index = racks.value.findIndex((r) => r.id === rackId);
|
|
3435
|
+
if (index === -1) return;
|
|
3436
|
+
racks.value.splice(index, 1);
|
|
3437
|
+
if (activeRackId.value === rackId && racks.value[0]) activeRackId.value = racks.value[0].id;
|
|
3438
|
+
}
|
|
3439
|
+
function reorderRacks(fromIndex, toIndex) {
|
|
3440
|
+
if (fromIndex < 0 || fromIndex >= racks.value.length) return;
|
|
3441
|
+
if (toIndex < 0 || toIndex >= racks.value.length) return;
|
|
3442
|
+
if (fromIndex === toIndex) return;
|
|
3443
|
+
const [moved] = racks.value.splice(fromIndex, 1);
|
|
3444
|
+
if (moved) racks.value.splice(toIndex, 0, moved);
|
|
3445
|
+
}
|
|
3446
|
+
function updateRack(rackId, data) {
|
|
3447
|
+
const rack = racks.value.find((r) => r.id === rackId);
|
|
3448
|
+
if (!rack) return;
|
|
3449
|
+
if (data.name !== void 0) rack.name = data.name;
|
|
3450
|
+
if (data.format !== void 0) rack.format = data.format;
|
|
3451
|
+
if (data.slot !== void 0) rack.slot = data.slot;
|
|
3452
|
+
if (data.injectionVolume !== void 0) rack.injectionVolume = data.injectionVolume;
|
|
3453
|
+
if (data.wells !== void 0) rack.wells = data.wells;
|
|
3454
|
+
}
|
|
3455
|
+
function setActiveRack(rackId) {
|
|
3456
|
+
if (racks.value.some((r) => r.id === rackId)) activeRackId.value = rackId;
|
|
3457
|
+
}
|
|
3458
|
+
function setWellData(rackId, wellId, data) {
|
|
3459
|
+
const rack = racks.value.find((r) => r.id === rackId);
|
|
3460
|
+
if (!rack) return;
|
|
3461
|
+
rack.wells[wellId] = {
|
|
3462
|
+
...rack.wells[wellId],
|
|
3463
|
+
...data
|
|
3464
|
+
};
|
|
3465
|
+
}
|
|
3466
|
+
function clearWell(rackId, wellId) {
|
|
3467
|
+
const rack = racks.value.find((r) => r.id === rackId);
|
|
3468
|
+
if (!rack) return;
|
|
3469
|
+
delete rack.wells[wellId];
|
|
3470
|
+
}
|
|
3471
|
+
function clearAllWells(rackId) {
|
|
3472
|
+
const rack = racks.value.find((r) => r.id === rackId);
|
|
3473
|
+
if (!rack) return;
|
|
3474
|
+
rack.wells = {};
|
|
3475
|
+
}
|
|
3476
|
+
function fillSeries(rackId, prefix = "S") {
|
|
3477
|
+
const rack = racks.value.find((r) => r.id === rackId);
|
|
3478
|
+
if (!rack) return;
|
|
3479
|
+
const config = PLATE_CONFIGS[rack.format];
|
|
3480
|
+
if (!config) return;
|
|
3481
|
+
let counter = 1;
|
|
3482
|
+
for (let row = 0; row < config.rows; row++) for (let col = 0; col < config.cols; col++) {
|
|
3483
|
+
const wellId = `${String.fromCharCode(65 + row)}${col + 1}`;
|
|
3484
|
+
if (!rack.wells[wellId]?.sampleType) {
|
|
3485
|
+
const paddedNum = String(counter).padStart(3, "0");
|
|
3486
|
+
rack.wells[wellId] = {
|
|
3487
|
+
id: wellId,
|
|
3488
|
+
row,
|
|
3489
|
+
col,
|
|
3490
|
+
state: "filled",
|
|
3491
|
+
sampleType: "sample",
|
|
3492
|
+
metadata: {
|
|
3493
|
+
label: `${prefix}${paddedNum}`,
|
|
3494
|
+
injectionVolume: rack.injectionVolume,
|
|
3495
|
+
injectionCount: 1
|
|
3496
|
+
}
|
|
3497
|
+
};
|
|
3498
|
+
}
|
|
3499
|
+
counter++;
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
function getAllWells() {
|
|
3503
|
+
const result = [];
|
|
3504
|
+
for (const rack of racks.value) for (const [wellId, well] of Object.entries(rack.wells)) result.push({
|
|
3505
|
+
rackId: rack.id,
|
|
3506
|
+
wellId,
|
|
3507
|
+
well
|
|
3508
|
+
});
|
|
3509
|
+
return result;
|
|
3510
|
+
}
|
|
3511
|
+
const totalSampleCount = computed(() => {
|
|
3512
|
+
let count = 0;
|
|
3513
|
+
for (const rack of racks.value) count += Object.keys(rack.wells).length;
|
|
3514
|
+
return count;
|
|
3515
|
+
});
|
|
3516
|
+
function reset() {
|
|
3517
|
+
racks.value = [createDefaultRack("Rack 1", 0)];
|
|
3518
|
+
activeRackId.value = racks.value[0].id;
|
|
3519
|
+
}
|
|
3520
|
+
return {
|
|
3521
|
+
racks,
|
|
3522
|
+
activeRack,
|
|
3523
|
+
activeRackId,
|
|
3524
|
+
addRack,
|
|
3525
|
+
removeRack,
|
|
3526
|
+
reorderRacks,
|
|
3527
|
+
updateRack,
|
|
3528
|
+
setActiveRack,
|
|
3529
|
+
setWellData,
|
|
3530
|
+
clearWell,
|
|
3531
|
+
clearAllWells,
|
|
3532
|
+
fillSeries,
|
|
3533
|
+
getAllWells,
|
|
3534
|
+
totalSampleCount,
|
|
3535
|
+
reset
|
|
3536
|
+
};
|
|
3537
|
+
}
|
|
3538
|
+
//#endregion
|
|
3539
|
+
//#region src/composables/useGroupAssignment.ts
|
|
3540
|
+
/** Shared two-zone group assignment model for control/treatment style workflows. */
|
|
3541
|
+
function useGroupAssignment(options) {
|
|
3542
|
+
const groups = computed(() => [...toValue(options.groups)]);
|
|
3543
|
+
const group1 = computed(() => [...toValue(options.group1)]);
|
|
3544
|
+
const group2 = computed(() => [...toValue(options.group2)]);
|
|
3545
|
+
const label1 = computed(() => toValue(options.label1) || "Control");
|
|
3546
|
+
const label2 = computed(() => toValue(options.label2) || "Treatment");
|
|
3547
|
+
const minPerGroup = computed(() => normalizeMinimum(toValue(options.minPerGroup)));
|
|
3548
|
+
const group1Set = computed(() => new Set(group1.value));
|
|
3549
|
+
const group2Set = computed(() => new Set(group2.value));
|
|
3550
|
+
const unassignedGroups = computed(() => groups.value.filter((group) => !group1Set.value.has(group.name) && !group2Set.value.has(group.name)));
|
|
3551
|
+
const zone1Groups = computed(() => groups.value.filter((group) => group1Set.value.has(group.name)));
|
|
3552
|
+
const zone2Groups = computed(() => groups.value.filter((group) => group2Set.value.has(group.name)));
|
|
3553
|
+
const zone1Count = computed(() => zone1Groups.value.reduce((sum, group) => sum + group.count, 0));
|
|
3554
|
+
const zone2Count = computed(() => zone2Groups.value.reduce((sum, group) => sum + group.count, 0));
|
|
3555
|
+
const isValid = computed(() => group1.value.length >= minPerGroup.value && group2.value.length >= minPerGroup.value);
|
|
3556
|
+
const validationMessage = computed(() => {
|
|
3557
|
+
if (isValid.value) return null;
|
|
3558
|
+
const missing1 = Math.max(0, minPerGroup.value - group1.value.length);
|
|
3559
|
+
const missing2 = Math.max(0, minPerGroup.value - group2.value.length);
|
|
3560
|
+
const parts = [];
|
|
3561
|
+
if (missing1 > 0) parts.push(`${missing1} more to ${label1.value}`);
|
|
3562
|
+
if (missing2 > 0) parts.push(`${missing2} more to ${label2.value}`);
|
|
3563
|
+
return `Add ${parts.join(" and ")}`;
|
|
3564
|
+
});
|
|
3565
|
+
function getZoneForGroup(groupName) {
|
|
3566
|
+
if (group1Set.value.has(groupName)) return "zone1";
|
|
3567
|
+
if (group2Set.value.has(groupName)) return "zone2";
|
|
3568
|
+
return null;
|
|
3569
|
+
}
|
|
3570
|
+
function assignToZone(groupName, zone) {
|
|
3571
|
+
const next = removeFromBoth(groupName);
|
|
3572
|
+
if (zone === "zone1") return {
|
|
3573
|
+
group1: [...next.group1, groupName],
|
|
3574
|
+
group2: next.group2
|
|
3575
|
+
};
|
|
3576
|
+
return {
|
|
3577
|
+
group1: next.group1,
|
|
3578
|
+
group2: [...next.group2, groupName]
|
|
3579
|
+
};
|
|
3580
|
+
}
|
|
3581
|
+
function removeFromZone(groupName, zone) {
|
|
3582
|
+
if (zone === "zone1") return {
|
|
3583
|
+
group1: group1.value.filter((name) => name !== groupName),
|
|
3584
|
+
group2: group2.value
|
|
3585
|
+
};
|
|
3586
|
+
return {
|
|
3587
|
+
group1: group1.value,
|
|
3588
|
+
group2: group2.value.filter((name) => name !== groupName)
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
function clearAll() {
|
|
3592
|
+
return {
|
|
3593
|
+
group1: [],
|
|
3594
|
+
group2: []
|
|
3595
|
+
};
|
|
3596
|
+
}
|
|
3597
|
+
function removeFromBoth(groupName) {
|
|
3598
|
+
return {
|
|
3599
|
+
group1: group1.value.filter((name) => name !== groupName),
|
|
3600
|
+
group2: group2.value.filter((name) => name !== groupName)
|
|
3601
|
+
};
|
|
3602
|
+
}
|
|
3603
|
+
return {
|
|
3604
|
+
unassignedGroups,
|
|
3605
|
+
zone1Groups,
|
|
3606
|
+
zone2Groups,
|
|
3607
|
+
zone1Count,
|
|
3608
|
+
zone2Count,
|
|
3609
|
+
isValid,
|
|
3610
|
+
validationMessage,
|
|
3611
|
+
getZoneForGroup,
|
|
3612
|
+
assignToZone,
|
|
3613
|
+
removeFromZone,
|
|
3614
|
+
clearAll
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
function normalizeMinimum(value) {
|
|
3618
|
+
if (value === null || value === void 0 || !Number.isFinite(value)) return 1;
|
|
3619
|
+
return Math.max(0, Math.floor(value));
|
|
3620
|
+
}
|
|
3621
|
+
//#endregion
|
|
3622
|
+
//#region src/composables/useReagentSeries.ts
|
|
3623
|
+
var DEFAULT_PRESETS = [
|
|
3624
|
+
{
|
|
3625
|
+
label: "2x",
|
|
3626
|
+
factor: 2
|
|
3627
|
+
},
|
|
3628
|
+
{
|
|
3629
|
+
label: "3x",
|
|
3630
|
+
factor: 3
|
|
3631
|
+
},
|
|
3632
|
+
{
|
|
3633
|
+
label: "½ log",
|
|
3634
|
+
factor: "half-log"
|
|
3635
|
+
},
|
|
3636
|
+
{
|
|
3637
|
+
label: "10x",
|
|
3638
|
+
factor: 10
|
|
3639
|
+
}
|
|
3640
|
+
];
|
|
3641
|
+
var DEFAULT_UNITS = [
|
|
3642
|
+
"µM",
|
|
3643
|
+
"nM",
|
|
3644
|
+
"mM",
|
|
3645
|
+
"pM"
|
|
3646
|
+
];
|
|
3647
|
+
function resolveFactor(factor) {
|
|
3648
|
+
return factor === "half-log" ? Math.sqrt(10) : factor;
|
|
3649
|
+
}
|
|
3650
|
+
function roundToSignificant(value, digits = 4) {
|
|
3651
|
+
if (value === 0) return 0;
|
|
3652
|
+
const magnitude = Math.floor(Math.log10(Math.abs(value))) + 1;
|
|
3653
|
+
const factor = Math.pow(10, digits - magnitude);
|
|
3654
|
+
return Math.round(value * factor) / factor;
|
|
3655
|
+
}
|
|
3656
|
+
function generateDilutionSeries(startConcentration, count, factor, defaultReplicates = 1) {
|
|
3657
|
+
if (count < 1 || startConcentration <= 0) return [];
|
|
3658
|
+
const divisor = resolveFactor(factor);
|
|
3659
|
+
const levels = [];
|
|
3660
|
+
for (let i = 0; i < count; i++) levels.push({
|
|
3661
|
+
value: roundToSignificant(startConcentration / Math.pow(divisor, i)),
|
|
3662
|
+
replicates: defaultReplicates
|
|
3663
|
+
});
|
|
3664
|
+
return levels;
|
|
3665
|
+
}
|
|
3666
|
+
/** Generates and sorts geometric dilution series (2x, 3x, ½ log, 10x) for plate-based experiments. */
|
|
3667
|
+
function useReagentSeries() {
|
|
3668
|
+
function generateSeries(start, count, factor, defaultReplicates = 1) {
|
|
3669
|
+
return generateDilutionSeries(start, count, factor, defaultReplicates);
|
|
3670
|
+
}
|
|
3671
|
+
function sortLevels(levels, order = "desc") {
|
|
3672
|
+
return [...levels].sort((a, b) => order === "desc" ? b.value - a.value : a.value - b.value);
|
|
3673
|
+
}
|
|
3674
|
+
function totalPositions(levels) {
|
|
3675
|
+
return levels.reduce((sum, l) => sum + l.replicates, 0);
|
|
3676
|
+
}
|
|
3677
|
+
return {
|
|
3678
|
+
generateSeries,
|
|
3679
|
+
sortLevels,
|
|
3680
|
+
totalPositions
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
//#endregion
|
|
3684
|
+
//#region src/composables/useProtocolTemplates.ts
|
|
3685
|
+
var BUILT_IN_TEMPLATES = [
|
|
3686
|
+
{
|
|
3687
|
+
id: "builtin-incubation",
|
|
3688
|
+
type: "incubation",
|
|
3689
|
+
name: "Incubation",
|
|
3690
|
+
description: "Incubate samples at specified conditions",
|
|
3691
|
+
defaultDuration: 60,
|
|
3692
|
+
isBuiltIn: true,
|
|
3693
|
+
parameters: [
|
|
3694
|
+
{
|
|
3695
|
+
key: "temperature",
|
|
3696
|
+
label: "Temperature",
|
|
3697
|
+
type: "temperature",
|
|
3698
|
+
unit: "°C",
|
|
3699
|
+
required: true,
|
|
3700
|
+
default: 37,
|
|
3701
|
+
min: -80,
|
|
3702
|
+
max: 100
|
|
3703
|
+
},
|
|
3704
|
+
{
|
|
3705
|
+
key: "duration",
|
|
3706
|
+
label: "Duration",
|
|
3707
|
+
type: "duration",
|
|
3708
|
+
unit: "hours",
|
|
3709
|
+
required: true,
|
|
3710
|
+
default: 24,
|
|
3711
|
+
min: 0
|
|
3712
|
+
},
|
|
3713
|
+
{
|
|
3714
|
+
key: "co2",
|
|
3715
|
+
label: "CO2",
|
|
3716
|
+
type: "number",
|
|
3717
|
+
unit: "%",
|
|
3718
|
+
required: false,
|
|
3719
|
+
default: 5,
|
|
3720
|
+
min: 0,
|
|
3721
|
+
max: 100
|
|
3722
|
+
},
|
|
3723
|
+
{
|
|
3724
|
+
key: "humidity",
|
|
3725
|
+
label: "Humidity",
|
|
3726
|
+
type: "number",
|
|
3727
|
+
unit: "%",
|
|
3728
|
+
required: false,
|
|
3729
|
+
default: 95,
|
|
3730
|
+
min: 0,
|
|
3731
|
+
max: 100
|
|
3732
|
+
}
|
|
3733
|
+
]
|
|
3734
|
+
},
|
|
3735
|
+
{
|
|
3736
|
+
id: "builtin-wash",
|
|
3737
|
+
type: "wash",
|
|
3738
|
+
name: "Wash",
|
|
3739
|
+
description: "Wash samples with buffer",
|
|
3740
|
+
defaultDuration: 5,
|
|
3741
|
+
isBuiltIn: true,
|
|
3742
|
+
parameters: [
|
|
3743
|
+
{
|
|
3744
|
+
key: "buffer",
|
|
3745
|
+
label: "Buffer",
|
|
3746
|
+
type: "select",
|
|
3747
|
+
required: true,
|
|
3748
|
+
default: "PBS",
|
|
3749
|
+
options: [
|
|
3750
|
+
{
|
|
3751
|
+
value: "PBS",
|
|
3752
|
+
label: "PBS"
|
|
3753
|
+
},
|
|
3754
|
+
{
|
|
3755
|
+
value: "PBST",
|
|
3756
|
+
label: "PBST"
|
|
3757
|
+
},
|
|
3758
|
+
{
|
|
3759
|
+
value: "TBS",
|
|
3760
|
+
label: "TBS"
|
|
3761
|
+
},
|
|
3762
|
+
{
|
|
3763
|
+
value: "TBST",
|
|
3764
|
+
label: "TBST"
|
|
3765
|
+
},
|
|
3766
|
+
{
|
|
3767
|
+
value: "Water",
|
|
3768
|
+
label: "Water"
|
|
3769
|
+
},
|
|
3770
|
+
{
|
|
3771
|
+
value: "Other",
|
|
3772
|
+
label: "Other"
|
|
3773
|
+
}
|
|
3774
|
+
]
|
|
3775
|
+
},
|
|
3776
|
+
{
|
|
3777
|
+
key: "volume",
|
|
3778
|
+
label: "Volume",
|
|
3779
|
+
type: "number",
|
|
3780
|
+
unit: "µL",
|
|
3781
|
+
required: true,
|
|
3782
|
+
default: 200,
|
|
3783
|
+
min: 0
|
|
3784
|
+
},
|
|
3785
|
+
{
|
|
3786
|
+
key: "cycles",
|
|
3787
|
+
label: "Wash Cycles",
|
|
3788
|
+
type: "number",
|
|
3789
|
+
required: true,
|
|
3790
|
+
default: 3,
|
|
3791
|
+
min: 1,
|
|
3792
|
+
max: 10
|
|
3793
|
+
}
|
|
3794
|
+
]
|
|
3795
|
+
},
|
|
3796
|
+
{
|
|
3797
|
+
id: "builtin-addition",
|
|
3798
|
+
type: "addition",
|
|
3799
|
+
name: "Addition",
|
|
3800
|
+
description: "Add reagent or compound to samples",
|
|
3801
|
+
defaultDuration: 5,
|
|
3802
|
+
isBuiltIn: true,
|
|
3803
|
+
parameters: [
|
|
3804
|
+
{
|
|
3805
|
+
key: "reagent",
|
|
3806
|
+
label: "Reagent",
|
|
3807
|
+
type: "text",
|
|
3808
|
+
required: true,
|
|
3809
|
+
placeholder: "Enter reagent name"
|
|
3810
|
+
},
|
|
3811
|
+
{
|
|
3812
|
+
key: "volume",
|
|
3813
|
+
label: "Volume",
|
|
3814
|
+
type: "number",
|
|
3815
|
+
unit: "µL",
|
|
3816
|
+
required: true,
|
|
3817
|
+
default: 100,
|
|
3818
|
+
min: 0
|
|
3819
|
+
},
|
|
3820
|
+
{
|
|
3821
|
+
key: "concentration",
|
|
3822
|
+
label: "Concentration",
|
|
3823
|
+
type: "concentration",
|
|
3824
|
+
required: false
|
|
3825
|
+
}
|
|
3826
|
+
]
|
|
3827
|
+
},
|
|
3828
|
+
{
|
|
3829
|
+
id: "builtin-measurement",
|
|
3830
|
+
type: "measurement",
|
|
3831
|
+
name: "Measurement",
|
|
3832
|
+
description: "Take measurements using specified instrument",
|
|
3833
|
+
defaultDuration: 15,
|
|
3834
|
+
isBuiltIn: true,
|
|
3835
|
+
parameters: [
|
|
3836
|
+
{
|
|
3837
|
+
key: "instrument",
|
|
3838
|
+
label: "Instrument",
|
|
3839
|
+
type: "select",
|
|
3840
|
+
required: true,
|
|
3841
|
+
options: [
|
|
3842
|
+
{
|
|
3843
|
+
value: "plate_reader",
|
|
3844
|
+
label: "Plate Reader"
|
|
3845
|
+
},
|
|
3846
|
+
{
|
|
3847
|
+
value: "microscope",
|
|
3848
|
+
label: "Microscope"
|
|
3849
|
+
},
|
|
3850
|
+
{
|
|
3851
|
+
value: "flow_cytometer",
|
|
3852
|
+
label: "Flow Cytometer"
|
|
3853
|
+
},
|
|
3854
|
+
{
|
|
3855
|
+
value: "spectrophotometer",
|
|
3856
|
+
label: "Spectrophotometer"
|
|
3857
|
+
},
|
|
3858
|
+
{
|
|
3859
|
+
value: "other",
|
|
3860
|
+
label: "Other"
|
|
3861
|
+
}
|
|
3862
|
+
]
|
|
3863
|
+
},
|
|
3864
|
+
{
|
|
3865
|
+
key: "readout",
|
|
3866
|
+
label: "Readout Type",
|
|
3867
|
+
type: "text",
|
|
3868
|
+
required: false,
|
|
3869
|
+
placeholder: "e.g., Absorbance 450nm"
|
|
3870
|
+
},
|
|
3871
|
+
{
|
|
3872
|
+
key: "notes",
|
|
3873
|
+
label: "Parameters",
|
|
3874
|
+
type: "text",
|
|
3875
|
+
required: false,
|
|
3876
|
+
placeholder: "Additional parameters"
|
|
3877
|
+
}
|
|
3878
|
+
]
|
|
3879
|
+
},
|
|
3880
|
+
{
|
|
3881
|
+
id: "builtin-centrifuge",
|
|
3882
|
+
type: "centrifuge",
|
|
3883
|
+
name: "Centrifuge",
|
|
3884
|
+
description: "Centrifuge samples",
|
|
3885
|
+
defaultDuration: 10,
|
|
3886
|
+
isBuiltIn: true,
|
|
3887
|
+
parameters: [
|
|
3888
|
+
{
|
|
3889
|
+
key: "speed",
|
|
3890
|
+
label: "Speed",
|
|
3891
|
+
type: "number",
|
|
3892
|
+
unit: "RPM",
|
|
3893
|
+
required: true,
|
|
3894
|
+
default: 1e3,
|
|
3895
|
+
min: 100,
|
|
3896
|
+
max: 2e4
|
|
3897
|
+
},
|
|
3898
|
+
{
|
|
3899
|
+
key: "duration",
|
|
3900
|
+
label: "Duration",
|
|
3901
|
+
type: "number",
|
|
3902
|
+
unit: "min",
|
|
3903
|
+
required: true,
|
|
3904
|
+
default: 5,
|
|
3905
|
+
min: 1
|
|
3906
|
+
},
|
|
3907
|
+
{
|
|
3908
|
+
key: "temperature",
|
|
3909
|
+
label: "Temperature",
|
|
3910
|
+
type: "temperature",
|
|
3911
|
+
unit: "°C",
|
|
3912
|
+
required: false,
|
|
3913
|
+
default: 4,
|
|
3914
|
+
min: -10,
|
|
3915
|
+
max: 40
|
|
3916
|
+
}
|
|
3917
|
+
]
|
|
3918
|
+
},
|
|
3919
|
+
{
|
|
3920
|
+
id: "builtin-transfer",
|
|
3921
|
+
type: "transfer",
|
|
3922
|
+
name: "Transfer",
|
|
3923
|
+
description: "Transfer samples between containers",
|
|
3924
|
+
defaultDuration: 10,
|
|
3925
|
+
isBuiltIn: true,
|
|
3926
|
+
parameters: [
|
|
3927
|
+
{
|
|
3928
|
+
key: "source",
|
|
3929
|
+
label: "Source",
|
|
3930
|
+
type: "text",
|
|
3931
|
+
required: true,
|
|
3932
|
+
placeholder: "Source container"
|
|
3933
|
+
},
|
|
3934
|
+
{
|
|
3935
|
+
key: "destination",
|
|
3936
|
+
label: "Destination",
|
|
3937
|
+
type: "text",
|
|
3938
|
+
required: true,
|
|
3939
|
+
placeholder: "Destination container"
|
|
3940
|
+
},
|
|
3941
|
+
{
|
|
3942
|
+
key: "volume",
|
|
3943
|
+
label: "Volume",
|
|
3944
|
+
type: "number",
|
|
3945
|
+
unit: "µL",
|
|
3946
|
+
required: true,
|
|
3947
|
+
default: 100,
|
|
3948
|
+
min: 0
|
|
3949
|
+
}
|
|
3950
|
+
]
|
|
3951
|
+
},
|
|
3952
|
+
{
|
|
3953
|
+
id: "builtin-mix",
|
|
3954
|
+
type: "mix",
|
|
3955
|
+
name: "Mix",
|
|
3956
|
+
description: "Mix samples",
|
|
3957
|
+
defaultDuration: 5,
|
|
3958
|
+
isBuiltIn: true,
|
|
3959
|
+
parameters: [
|
|
3960
|
+
{
|
|
3961
|
+
key: "method",
|
|
3962
|
+
label: "Method",
|
|
3963
|
+
type: "select",
|
|
3964
|
+
required: true,
|
|
3965
|
+
default: "pipette",
|
|
3966
|
+
options: [
|
|
3967
|
+
{
|
|
3968
|
+
value: "pipette",
|
|
3969
|
+
label: "Pipette mixing"
|
|
3970
|
+
},
|
|
3971
|
+
{
|
|
3972
|
+
value: "vortex",
|
|
3973
|
+
label: "Vortex"
|
|
3974
|
+
},
|
|
3975
|
+
{
|
|
3976
|
+
value: "shaker",
|
|
3977
|
+
label: "Orbital shaker"
|
|
3978
|
+
},
|
|
3979
|
+
{
|
|
3980
|
+
value: "inversion",
|
|
3981
|
+
label: "Inversion"
|
|
3982
|
+
}
|
|
3983
|
+
]
|
|
3984
|
+
},
|
|
3985
|
+
{
|
|
3986
|
+
key: "duration",
|
|
3987
|
+
label: "Duration",
|
|
3988
|
+
type: "number",
|
|
3989
|
+
unit: "sec",
|
|
3990
|
+
required: false,
|
|
3991
|
+
default: 30,
|
|
3992
|
+
min: 1
|
|
3993
|
+
},
|
|
3994
|
+
{
|
|
3995
|
+
key: "speed",
|
|
3996
|
+
label: "Speed/Intensity",
|
|
3997
|
+
type: "text",
|
|
3998
|
+
required: false,
|
|
3999
|
+
placeholder: "e.g., 300 RPM"
|
|
4000
|
+
}
|
|
4001
|
+
]
|
|
4002
|
+
},
|
|
4003
|
+
{
|
|
4004
|
+
id: "builtin-custom",
|
|
4005
|
+
type: "custom",
|
|
4006
|
+
name: "Custom Step",
|
|
4007
|
+
description: "Custom protocol step",
|
|
4008
|
+
defaultDuration: 15,
|
|
4009
|
+
isBuiltIn: true,
|
|
4010
|
+
parameters: [{
|
|
4011
|
+
key: "description",
|
|
4012
|
+
label: "Description",
|
|
4013
|
+
type: "text",
|
|
4014
|
+
required: true,
|
|
4015
|
+
placeholder: "Describe the step"
|
|
4016
|
+
}, {
|
|
4017
|
+
key: "notes",
|
|
4018
|
+
label: "Notes",
|
|
4019
|
+
type: "text",
|
|
4020
|
+
required: false,
|
|
4021
|
+
placeholder: "Additional notes"
|
|
4022
|
+
}]
|
|
4023
|
+
}
|
|
4024
|
+
];
|
|
4025
|
+
var STORAGE_KEY = "mint-custom-protocol-templates";
|
|
4026
|
+
function loadCustomTemplates() {
|
|
4027
|
+
try {
|
|
4028
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
4029
|
+
if (stored) return JSON.parse(stored);
|
|
4030
|
+
} catch {}
|
|
4031
|
+
return [];
|
|
4032
|
+
}
|
|
4033
|
+
function saveCustomTemplatesToStorage(templates) {
|
|
4034
|
+
try {
|
|
4035
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
|
|
4036
|
+
} catch {}
|
|
4037
|
+
}
|
|
4038
|
+
/** Provides built-in and custom protocol step templates with validation and step creation helpers. */
|
|
4039
|
+
function useProtocolTemplates() {
|
|
4040
|
+
const customTemplates = ref(loadCustomTemplates());
|
|
4041
|
+
const allTemplates = computed(() => {
|
|
4042
|
+
return [...BUILT_IN_TEMPLATES, ...customTemplates.value];
|
|
4043
|
+
});
|
|
4044
|
+
function getTemplateByType(type) {
|
|
4045
|
+
return customTemplates.value.find((t) => t.type === type) || BUILT_IN_TEMPLATES.find((t) => t.type === type);
|
|
4046
|
+
}
|
|
4047
|
+
function getTemplateById(id) {
|
|
4048
|
+
return customTemplates.value.find((t) => t.id === id) || BUILT_IN_TEMPLATES.find((t) => t.id === id);
|
|
4049
|
+
}
|
|
4050
|
+
function saveCustomTemplate(template) {
|
|
4051
|
+
const newTemplate = {
|
|
4052
|
+
...template,
|
|
4053
|
+
isBuiltIn: false,
|
|
4054
|
+
id: template.id || `custom-${Date.now()}`
|
|
4055
|
+
};
|
|
4056
|
+
const index = customTemplates.value.findIndex((t) => t.id === newTemplate.id);
|
|
4057
|
+
if (index >= 0) customTemplates.value[index] = newTemplate;
|
|
4058
|
+
else customTemplates.value.push(newTemplate);
|
|
4059
|
+
saveCustomTemplatesToStorage(customTemplates.value);
|
|
4060
|
+
}
|
|
4061
|
+
function deleteCustomTemplate(templateId) {
|
|
4062
|
+
const index = customTemplates.value.findIndex((t) => t.id === templateId);
|
|
4063
|
+
if (index >= 0) {
|
|
4064
|
+
customTemplates.value.splice(index, 1);
|
|
4065
|
+
saveCustomTemplatesToStorage(customTemplates.value);
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
function validateStep(step, template) {
|
|
4069
|
+
const errors = {};
|
|
4070
|
+
if (!step.name || step.name.trim() === "") errors.name = "Step name is required";
|
|
4071
|
+
for (const param of template.parameters) {
|
|
4072
|
+
if (param.required) {
|
|
4073
|
+
const value = step.parameters?.[param.key];
|
|
4074
|
+
if (value === void 0 || value === null || value === "") errors[param.key] = `${param.label} is required`;
|
|
4075
|
+
}
|
|
4076
|
+
if (param.type === "number" || param.type === "temperature" || param.type === "duration") {
|
|
4077
|
+
const value = step.parameters?.[param.key];
|
|
4078
|
+
if (value !== void 0 && value !== null && value !== "") {
|
|
4079
|
+
const numValue = Number(value);
|
|
4080
|
+
if (isNaN(numValue)) errors[param.key] = `${param.label} must be a number`;
|
|
4081
|
+
else {
|
|
4082
|
+
if (param.min !== void 0 && numValue < param.min) errors[param.key] = `${param.label} must be at least ${param.min}`;
|
|
4083
|
+
if (param.max !== void 0 && numValue > param.max) errors[param.key] = `${param.label} must be at most ${param.max}`;
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
return {
|
|
4089
|
+
valid: Object.keys(errors).length === 0,
|
|
4090
|
+
errors
|
|
4091
|
+
};
|
|
4092
|
+
}
|
|
4093
|
+
function createStepFromTemplate(template, overrides) {
|
|
4094
|
+
const parameters = {};
|
|
4095
|
+
for (const param of template.parameters) if (param.default !== void 0) parameters[param.key] = param.default;
|
|
4096
|
+
return {
|
|
4097
|
+
id: `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
4098
|
+
type: template.type,
|
|
4099
|
+
name: template.name,
|
|
4100
|
+
description: template.description,
|
|
4101
|
+
duration: template.defaultDuration,
|
|
4102
|
+
status: "pending",
|
|
4103
|
+
parameters,
|
|
4104
|
+
order: 0,
|
|
4105
|
+
...overrides
|
|
4106
|
+
};
|
|
4107
|
+
}
|
|
4108
|
+
function formatParameterValue(value, param) {
|
|
4109
|
+
if (value === void 0 || value === null || value === "") return "-";
|
|
4110
|
+
if (param.type === "select" && param.options) {
|
|
4111
|
+
const option = param.options.find((o) => o.value === value);
|
|
4112
|
+
if (option) return option.label;
|
|
4113
|
+
}
|
|
4114
|
+
if (param.type === "concentration" && typeof value === "object") {
|
|
4115
|
+
const conc = value;
|
|
4116
|
+
return `${conc.value} ${conc.unit}`;
|
|
4117
|
+
}
|
|
4118
|
+
const strValue = String(value);
|
|
4119
|
+
if (param.unit) return `${strValue} ${param.unit}`;
|
|
4120
|
+
return strValue;
|
|
4121
|
+
}
|
|
4122
|
+
return {
|
|
4123
|
+
builtInTemplates: BUILT_IN_TEMPLATES,
|
|
4124
|
+
customTemplates,
|
|
4125
|
+
allTemplates,
|
|
4126
|
+
getTemplateByType,
|
|
4127
|
+
getTemplateById,
|
|
4128
|
+
saveCustomTemplate,
|
|
4129
|
+
deleteCustomTemplate,
|
|
4130
|
+
validateStep,
|
|
4131
|
+
createStepFromTemplate,
|
|
4132
|
+
formatParameterValue
|
|
4133
|
+
};
|
|
4134
|
+
}
|
|
4135
|
+
//#endregion
|
|
4136
|
+
//#region src/composables/useExperimentData.ts
|
|
4137
|
+
/** Fetches and normalises experiment output data (tree, table, summary) from the platform API. */
|
|
4138
|
+
function useExperimentData(options = {}) {
|
|
4139
|
+
const api = useApi({ baseUrl: options.apiBaseUrl });
|
|
4140
|
+
const data = ref(null);
|
|
4141
|
+
const request = useRequestSyncState("Failed to fetch experiment data");
|
|
4142
|
+
const isLoading = request.loading;
|
|
4143
|
+
const error = request.error;
|
|
4144
|
+
const lastLoadedAt = request.lastLoadedAt;
|
|
4145
|
+
let lastExperimentId = null;
|
|
4146
|
+
const treeData = computed(() => {
|
|
4147
|
+
if (!data.value) return [];
|
|
4148
|
+
const tree = data.value.tree_data ?? data.value.treeData;
|
|
4149
|
+
return Array.isArray(tree) ? tree : [];
|
|
4150
|
+
});
|
|
4151
|
+
const tableData = computed(() => {
|
|
4152
|
+
if (!data.value) return [];
|
|
4153
|
+
const table = data.value.table_data ?? data.value.tableData;
|
|
4154
|
+
return Array.isArray(table) ? table : [];
|
|
4155
|
+
});
|
|
4156
|
+
const summaryData = computed(() => {
|
|
4157
|
+
if (!data.value) return null;
|
|
4158
|
+
const summary = data.value.summary_data ?? data.value.summaryData;
|
|
4159
|
+
if (summary && typeof summary === "object" && "metadata" in summary) return summary;
|
|
4160
|
+
return null;
|
|
4161
|
+
});
|
|
4162
|
+
async function fetchData(experimentId) {
|
|
4163
|
+
lastExperimentId = experimentId;
|
|
4164
|
+
try {
|
|
4165
|
+
data.value = await request.run(() => api.get(`/experiments/${experimentId}/data`), {
|
|
4166
|
+
success: "load",
|
|
4167
|
+
errorMessage: "Failed to fetch experiment data"
|
|
4168
|
+
});
|
|
4169
|
+
} catch {
|
|
4170
|
+
data.value = null;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
async function refresh() {
|
|
4174
|
+
if (lastExperimentId !== null) await fetchData(lastExperimentId);
|
|
4175
|
+
}
|
|
4176
|
+
return {
|
|
4177
|
+
data,
|
|
4178
|
+
treeData,
|
|
4179
|
+
tableData,
|
|
4180
|
+
summaryData,
|
|
4181
|
+
isLoading,
|
|
4182
|
+
error,
|
|
4183
|
+
lastLoadedAt,
|
|
4184
|
+
fetch: fetchData,
|
|
4185
|
+
refresh
|
|
4186
|
+
};
|
|
4187
|
+
}
|
|
4188
|
+
//#endregion
|
|
4189
|
+
//#region src/composables/useScheduleDrag.ts
|
|
4190
|
+
/** Handles pointer-driven create, move, and resize drag interactions for the ScheduleCalendar grid. */
|
|
4191
|
+
function useScheduleDrag(options) {
|
|
4192
|
+
const isDragging = ref(false);
|
|
4193
|
+
const dragState = ref(null);
|
|
4194
|
+
const ghost = computed(() => {
|
|
4195
|
+
if (!dragState.value) return null;
|
|
4196
|
+
const state = dragState.value;
|
|
4197
|
+
const slotH = options.slotHeight.value;
|
|
4198
|
+
const slotMin = options.slotDuration.value;
|
|
4199
|
+
const dayStart = options.dayStartHour.value * 60;
|
|
4200
|
+
if (state.type === "create") {
|
|
4201
|
+
const deltaY = state.currentY - state.startY;
|
|
4202
|
+
const startOffset = state.startY;
|
|
4203
|
+
const endOffset = startOffset + deltaY;
|
|
4204
|
+
const topPx = Math.min(startOffset, endOffset);
|
|
4205
|
+
const bottomPx = Math.max(startOffset, endOffset);
|
|
4206
|
+
const startMinutes = dayStart + Math.round(topPx / slotH * slotMin);
|
|
4207
|
+
const endMinutes = dayStart + Math.round(bottomPx / slotH * slotMin);
|
|
4208
|
+
const snappedStart = snapMinutes(startMinutes, slotMin);
|
|
4209
|
+
const snappedEnd = Math.max(snapMinutes(endMinutes, slotMin), snappedStart + slotMin);
|
|
4210
|
+
const snappedTopPx = (snappedStart - dayStart) / slotMin * slotH;
|
|
4211
|
+
const snappedHeightPx = (snappedEnd - snappedStart) / slotMin * slotH;
|
|
4212
|
+
return {
|
|
4213
|
+
start: minutesToDate(state.startDate, snappedStart),
|
|
4214
|
+
end: minutesToDate(state.startDate, snappedEnd),
|
|
4215
|
+
dayIndex: state.dayIndex,
|
|
4216
|
+
style: {
|
|
4217
|
+
top: `${snappedTopPx}px`,
|
|
4218
|
+
height: `${snappedHeightPx}px`
|
|
4219
|
+
}
|
|
4220
|
+
};
|
|
4221
|
+
}
|
|
4222
|
+
if (state.type === "move" && state.event) {
|
|
4223
|
+
const deltaY = state.currentY - state.startY;
|
|
4224
|
+
const eventStart = dateToMinutes(new Date(state.event.start));
|
|
4225
|
+
const duration = dateToMinutes(new Date(state.event.end)) - eventStart;
|
|
4226
|
+
const newStart = snapMinutes(eventStart + Math.round(deltaY / slotH * slotMin), slotMin);
|
|
4227
|
+
const clamped = clampRange(newStart, newStart + duration, dayStart, options.dayEndHour.value * 60);
|
|
4228
|
+
const topPx = (clamped.start - dayStart) / slotMin * slotH;
|
|
4229
|
+
const heightPx = (clamped.end - clamped.start) / slotMin * slotH;
|
|
4230
|
+
return {
|
|
4231
|
+
start: minutesToDate(state.startDate, clamped.start),
|
|
4232
|
+
end: minutesToDate(state.startDate, clamped.end),
|
|
4233
|
+
dayIndex: state.currentDayIndex,
|
|
4234
|
+
style: {
|
|
4235
|
+
top: `${topPx}px`,
|
|
4236
|
+
height: `${heightPx}px`
|
|
4237
|
+
}
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
if ((state.type === "resize-top" || state.type === "resize-bottom") && state.event) {
|
|
4241
|
+
const eventStart = dateToMinutes(new Date(state.event.start));
|
|
4242
|
+
const eventEnd = dateToMinutes(new Date(state.event.end));
|
|
4243
|
+
const deltaY = state.currentY - state.startY;
|
|
4244
|
+
const pixelDelta = Math.round(deltaY / slotH * slotMin);
|
|
4245
|
+
let newStart = eventStart;
|
|
4246
|
+
let newEnd = eventEnd;
|
|
4247
|
+
if (state.type === "resize-top") {
|
|
4248
|
+
newStart = snapMinutes(eventStart + pixelDelta, slotMin);
|
|
4249
|
+
newStart = Math.min(newStart, newEnd - slotMin);
|
|
4250
|
+
} else {
|
|
4251
|
+
newEnd = snapMinutes(eventEnd + pixelDelta, slotMin);
|
|
4252
|
+
newEnd = Math.max(newEnd, newStart + slotMin);
|
|
4253
|
+
}
|
|
4254
|
+
const clamped = clampRange(newStart, newEnd, dayStart, options.dayEndHour.value * 60);
|
|
4255
|
+
const topPx = (clamped.start - dayStart) / slotMin * slotH;
|
|
4256
|
+
const heightPx = (clamped.end - clamped.start) / slotMin * slotH;
|
|
4257
|
+
return {
|
|
4258
|
+
start: minutesToDate(state.startDate, clamped.start),
|
|
4259
|
+
end: minutesToDate(state.startDate, clamped.end),
|
|
4260
|
+
dayIndex: state.dayIndex,
|
|
4261
|
+
style: {
|
|
4262
|
+
top: `${topPx}px`,
|
|
4263
|
+
height: `${heightPx}px`
|
|
4264
|
+
}
|
|
4265
|
+
};
|
|
4266
|
+
}
|
|
4267
|
+
return null;
|
|
4268
|
+
});
|
|
4269
|
+
function startCreate(date, y, dayIndex) {
|
|
4270
|
+
if (options.readonly.value) return;
|
|
4271
|
+
isDragging.value = true;
|
|
4272
|
+
dragState.value = {
|
|
4273
|
+
type: "create",
|
|
4274
|
+
startDate: date,
|
|
4275
|
+
startY: y,
|
|
4276
|
+
currentY: y,
|
|
4277
|
+
dayIndex,
|
|
4278
|
+
currentDayIndex: dayIndex
|
|
4279
|
+
};
|
|
4280
|
+
addListeners();
|
|
4281
|
+
}
|
|
4282
|
+
function startMove(event, y, dayIndex) {
|
|
4283
|
+
if (options.readonly.value || event.draggable === false) return;
|
|
4284
|
+
isDragging.value = true;
|
|
4285
|
+
dragState.value = {
|
|
4286
|
+
type: "move",
|
|
4287
|
+
event,
|
|
4288
|
+
startDate: new Date(event.start),
|
|
4289
|
+
startY: y,
|
|
4290
|
+
currentY: y,
|
|
4291
|
+
dayIndex,
|
|
4292
|
+
currentDayIndex: dayIndex
|
|
4293
|
+
};
|
|
4294
|
+
addListeners();
|
|
4295
|
+
}
|
|
4296
|
+
function startResize(event, edge, y, dayIndex) {
|
|
4297
|
+
if (options.readonly.value || event.resizable === false) return;
|
|
4298
|
+
isDragging.value = true;
|
|
4299
|
+
dragState.value = {
|
|
4300
|
+
type: edge === "top" ? "resize-top" : "resize-bottom",
|
|
4301
|
+
event,
|
|
4302
|
+
startDate: new Date(event.start),
|
|
4303
|
+
startY: y,
|
|
4304
|
+
currentY: y,
|
|
4305
|
+
dayIndex,
|
|
4306
|
+
currentDayIndex: dayIndex
|
|
4307
|
+
};
|
|
4308
|
+
addListeners();
|
|
4309
|
+
}
|
|
4310
|
+
function onPointerMove(e) {
|
|
4311
|
+
if (!dragState.value) return;
|
|
4312
|
+
dragState.value = {
|
|
4313
|
+
...dragState.value,
|
|
4314
|
+
currentY: e.clientY
|
|
4315
|
+
};
|
|
4316
|
+
}
|
|
4317
|
+
function onPointerUp() {
|
|
4318
|
+
if (!dragState.value || !ghost.value) {
|
|
4319
|
+
cleanup();
|
|
4320
|
+
return;
|
|
4321
|
+
}
|
|
4322
|
+
const g = ghost.value;
|
|
4323
|
+
const state = dragState.value;
|
|
4324
|
+
if (state.type === "create" && options.onCreateComplete) options.onCreateComplete(g.start, g.end);
|
|
4325
|
+
else if (state.type === "move" && state.event && options.onMoveComplete) options.onMoveComplete(state.event, g.start, g.end);
|
|
4326
|
+
else if ((state.type === "resize-top" || state.type === "resize-bottom") && state.event && options.onResizeComplete) options.onResizeComplete(state.event, g.start, g.end);
|
|
4327
|
+
cleanup();
|
|
4328
|
+
}
|
|
4329
|
+
function addListeners() {
|
|
4330
|
+
document.addEventListener("pointermove", onPointerMove);
|
|
4331
|
+
document.addEventListener("pointerup", onPointerUp);
|
|
4332
|
+
}
|
|
4333
|
+
function cleanup() {
|
|
4334
|
+
isDragging.value = false;
|
|
4335
|
+
dragState.value = null;
|
|
4336
|
+
document.removeEventListener("pointermove", onPointerMove);
|
|
4337
|
+
document.removeEventListener("pointerup", onPointerUp);
|
|
4338
|
+
}
|
|
4339
|
+
onUnmounted(cleanup);
|
|
4340
|
+
return {
|
|
4341
|
+
isDragging,
|
|
4342
|
+
dragState,
|
|
4343
|
+
ghost,
|
|
4344
|
+
startCreate,
|
|
4345
|
+
startMove,
|
|
4346
|
+
startResize
|
|
4347
|
+
};
|
|
4348
|
+
}
|
|
4349
|
+
function snapMinutes(minutes, step) {
|
|
4350
|
+
return Math.round(minutes / step) * step;
|
|
4351
|
+
}
|
|
4352
|
+
function dateToMinutes(date) {
|
|
4353
|
+
return date.getHours() * 60 + date.getMinutes();
|
|
4354
|
+
}
|
|
4355
|
+
function minutesToDate(baseDate, minutes) {
|
|
4356
|
+
const d = new Date(baseDate);
|
|
4357
|
+
d.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
4358
|
+
return d;
|
|
4359
|
+
}
|
|
4360
|
+
function clampRange(start, end, min, max) {
|
|
4361
|
+
const s = Math.max(start, min);
|
|
4362
|
+
const e = Math.min(end, max);
|
|
4363
|
+
return {
|
|
4364
|
+
start: s,
|
|
4365
|
+
end: Math.max(e, s)
|
|
4366
|
+
};
|
|
4367
|
+
}
|
|
4368
|
+
//#endregion
|
|
4369
|
+
export { normalizeSearchQuery as $, useAutoGroup as A, EXPERIMENT_STATUS_VARIANT_MAP as B, useSampleGroups as C, DEFAULT_COLORS as D, hslToHex as E, usePlatformContext as F, useDebouncedWatch as G, datePresetToISO as H, useExperimentSelector as I, useFormBuilder as J, useApi as K, DATE_PRESET_OPTIONS as L, useDoseCalculator as M, APP_EXPERIMENT_KEY as N, extractSamplesFromDesignData as O, useAppExperiment as P, candidateMatchesSearch as Q, EXPERIMENT_STATUS_LABELS as R, useExpansionSet as S, hexToHsl as T, formatExperimentDate as U, SORT_OPTIONS as V, useRequestSyncState as W, useTheme as X, useForm as Y, useToast as Z, useTemplateCollection as _, DEFAULT_UNITS as a, getInjectedPlatformContext as b, useGroupAssignment as c, useBioTemplatePackWorkspace as d, useTextSearch as et, useBioTemplateWorkspace as f, useBioTemplateControls as g, useBioTemplateComponents as h, DEFAULT_PRESETS as i, useWellPlateEditor as j, parseCSV as k, useRackEditor as l, toBioTemplateComponentPropsByComponent as m, useExperimentData as n, useSortedItems as nt, generateDilutionSeries as o, getBioTemplateComponentProps as p, evaluateCondition as q, useProtocolTemplates as r, useReagentSeries as s, useScheduleDrag as t, compareSortValues as tt, useBioTemplatePresetWorkspace as u, useExperimentSave as v, deriveShade as w, resolveCurrentExperimentId as x, currentExperimentFromContext as y, EXPERIMENT_STATUS_OPTIONS as z };
|
|
4370
|
+
|
|
4371
|
+
//# sourceMappingURL=useScheduleDrag-D4oWdh41.js.map
|