@morscherlab/mint-sdk 1.0.39 → 1.0.41
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/dist/{ExperimentPopover-DEzCbTqo.js → ExperimentPopover-8A4Rhffp.js} +1 -1
- package/dist/{ExperimentPopover-mzmSfAUp.js → ExperimentPopover-BbPkIFsI.js} +8 -2
- package/dist/ExperimentPopover-BbPkIFsI.js.map +1 -0
- package/dist/{ExperimentSelectorModal-Bn0Hmg07.js → ExperimentSelectorModal-B2qek_YG.js} +91 -46
- package/dist/ExperimentSelectorModal-B2qek_YG.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BAIlIybO.js → ExperimentSelectorModal-BwPbQN1g.js} +1 -1
- package/dist/__tests__/components/AutoGroupModal.preview.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/classKey.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/groupTree.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/tokenLength.test.d.ts +1 -0
- package/dist/components/index.js +3 -3
- package/dist/{components-Cyi0IfRl.js → components-CJ2--4Ex.js} +5606 -5592
- package/dist/components-CJ2--4Ex.js.map +1 -0
- package/dist/composables/autoGroup/classKey.d.ts +1 -0
- package/dist/composables/autoGroup/index.d.ts +2 -1
- package/dist/composables/autoGroup/replicatePreGroup.d.ts +10 -12
- package/dist/composables/autoGroup/tokenLength.d.ts +17 -0
- package/dist/composables/index.js +2 -2
- package/dist/composables/useAutoGroup.d.ts +2 -0
- package/dist/{composables-CFSn4NN3.js → composables-DrE6OcZZ.js} +2 -2
- package/dist/{composables-CFSn4NN3.js.map → composables-DrE6OcZZ.js.map} +1 -1
- package/dist/index.js +5 -5
- package/dist/install.js +3 -3
- package/dist/styles.css +1497 -1453
- package/dist/types/auto-group.d.ts +19 -0
- package/dist/{useProtocolTemplates-CXP2ZosM.js → useProtocolTemplates-BbvlHoPD.js} +218 -90
- package/dist/useProtocolTemplates-BbvlHoPD.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AutoGroupModal.preview.test.ts +46 -0
- package/src/__tests__/composables/autoGroup/classKey.test.ts +25 -0
- package/src/__tests__/composables/autoGroup/fingerprint.test.ts +72 -0
- package/src/__tests__/composables/autoGroup/groupTree.test.ts +99 -0
- package/src/__tests__/composables/autoGroup/tokenLength.test.ts +85 -0
- package/src/__tests__/composables/useAutoGroup.test.ts +111 -19
- package/src/components/AutoGroupModal.vue +23 -19
- package/src/components/BaseModal.story.vue +7 -15
- package/src/components/ExperimentDataViewer.vue +1 -0
- package/src/components/ExperimentPopover.vue +6 -4
- package/src/components/ExperimentSelectorModal.vue +30 -3
- package/src/components/IconButton.story.vue +5 -0
- package/src/components/SampleSelector.vue +3 -2
- package/src/components/SampleSelectorSampleRow.vue +4 -2
- package/src/composables/autoGroup/classKey.ts +5 -2
- package/src/composables/autoGroup/columns.ts +2 -2
- package/src/composables/autoGroup/compose.ts +56 -0
- package/src/composables/autoGroup/fingerprint.ts +15 -1
- package/src/composables/autoGroup/index.ts +2 -0
- package/src/composables/autoGroup/replicatePreGroup.ts +34 -0
- package/src/composables/autoGroup/template.ts +2 -2
- package/src/composables/autoGroup/tokenLength.ts +53 -0
- package/src/composables/autoGroup/vocab.json +1 -2
- package/src/composables/useAutoGroup.ts +34 -13
- package/src/styles/components/auto-group-modal.css +7 -11
- package/src/styles/components/button.css +10 -3
- package/src/styles/components/modal.css +3 -0
- package/src/styles/components/sample-selector.css +17 -0
- package/src/styles/variables.css +8 -0
- package/src/types/auto-group.ts +19 -0
- package/dist/ExperimentPopover-mzmSfAUp.js.map +0 -1
- package/dist/ExperimentSelectorModal-Bn0Hmg07.js.map +0 -1
- package/dist/components-Cyi0IfRl.js.map +0 -1
- package/dist/useProtocolTemplates-CXP2ZosM.js.map +0 -1
|
@@ -171,13 +171,9 @@ const sheetOpen = ref(false)
|
|
|
171
171
|
</Variant>
|
|
172
172
|
<Variant title="Drawer variant — right-edge detail panel">
|
|
173
173
|
<div style="padding: 2rem;">
|
|
174
|
-
<
|
|
175
|
-
type="button"
|
|
176
|
-
style="padding: 0.5rem 1rem; border-radius: 0.375rem; background: var(--color-primary); color: white; border: none; cursor: pointer;"
|
|
177
|
-
@click="drawerOpen = true"
|
|
178
|
-
>
|
|
174
|
+
<BaseButton variant="primary" @click="drawerOpen = true">
|
|
179
175
|
Open drawer
|
|
180
|
-
</
|
|
176
|
+
</BaseButton>
|
|
181
177
|
<p style="margin: 1rem 0 0; font-size: 0.8125rem; color: var(--text-muted);">
|
|
182
178
|
Slides in from the right edge. Fills viewport height. For detail panels that shouldn't take over the page.
|
|
183
179
|
</p>
|
|
@@ -203,20 +199,16 @@ const sheetOpen = ref(false)
|
|
|
203
199
|
</div>
|
|
204
200
|
</div>
|
|
205
201
|
<template #footer>
|
|
206
|
-
<
|
|
202
|
+
<BaseButton variant="primary" @click="drawerOpen = false">Close</BaseButton>
|
|
207
203
|
</template>
|
|
208
204
|
</BaseModal>
|
|
209
205
|
</Variant>
|
|
210
206
|
|
|
211
207
|
<Variant title="Sheet variant — mobile-friendly bottom sheet">
|
|
212
208
|
<div style="padding: 2rem;">
|
|
213
|
-
<
|
|
214
|
-
type="button"
|
|
215
|
-
style="padding: 0.5rem 1rem; border-radius: 0.375rem; background: var(--color-primary); color: white; border: none; cursor: pointer;"
|
|
216
|
-
@click="sheetOpen = true"
|
|
217
|
-
>
|
|
209
|
+
<BaseButton variant="primary" @click="sheetOpen = true">
|
|
218
210
|
Open sheet
|
|
219
|
-
</
|
|
211
|
+
</BaseButton>
|
|
220
212
|
<p style="margin: 1rem 0 0; font-size: 0.8125rem; color: var(--text-muted);">
|
|
221
213
|
Slides up from the bottom. Top corners rounded. Grab-hint bar at top. Auto-used on <768px viewports regardless of caller's variant.
|
|
222
214
|
</p>
|
|
@@ -228,8 +220,8 @@ const sheetOpen = ref(false)
|
|
|
228
220
|
<strong>Compound X · 10 µM · 24h</strong>. The change applies immediately and can be undone within 10 seconds.
|
|
229
221
|
</p>
|
|
230
222
|
<template #footer>
|
|
231
|
-
<
|
|
232
|
-
<
|
|
223
|
+
<BaseButton variant="secondary" @click="sheetOpen = false">Cancel</BaseButton>
|
|
224
|
+
<BaseButton variant="primary" @click="sheetOpen = false">Apply</BaseButton>
|
|
233
225
|
</template>
|
|
234
226
|
</BaseModal>
|
|
235
227
|
</Variant>
|
|
@@ -102,10 +102,11 @@ onUnmounted(() => {
|
|
|
102
102
|
{ 'mint-experiment-popover__trigger--empty': !experimentCode && !experimentName },
|
|
103
103
|
]"
|
|
104
104
|
:title="experimentName || experimentCode || undefined"
|
|
105
|
+
:aria-expanded="isOpen"
|
|
105
106
|
@click.stop="toggle"
|
|
106
107
|
>
|
|
107
108
|
<!-- Flask icon -->
|
|
108
|
-
<svg class="mint-experiment-popover__trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
109
|
+
<svg class="mint-experiment-popover__trigger-icon" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
109
110
|
<path
|
|
110
111
|
stroke-linecap="round"
|
|
111
112
|
stroke-linejoin="round"
|
|
@@ -118,7 +119,7 @@ onUnmounted(() => {
|
|
|
118
119
|
<span v-else-if="experimentCode" class="mint-experiment-popover__trigger-code">{{ experimentCode }}</span>
|
|
119
120
|
<span v-else class="mint-experiment-popover__trigger-text">No experiment</span>
|
|
120
121
|
<!-- Chevron -->
|
|
121
|
-
<svg class="mint-experiment-popover__trigger-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
122
|
+
<svg class="mint-experiment-popover__trigger-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
122
123
|
<path d="m6 9 6 6 6-6" />
|
|
123
124
|
</svg>
|
|
124
125
|
</button>
|
|
@@ -135,16 +136,17 @@ onUnmounted(() => {
|
|
|
135
136
|
]"
|
|
136
137
|
:disabled="saveDisabled && !showSuccess"
|
|
137
138
|
:title="saveDisabled && saveDisabledMessage ? saveDisabledMessage : showSuccess && saveSuccessMessage ? saveSuccessMessage : 'Save to Experiment'"
|
|
139
|
+
aria-label="Save to experiment"
|
|
138
140
|
@click.stop="handleSave"
|
|
139
141
|
>
|
|
140
142
|
<!-- Loading spinner -->
|
|
141
143
|
<span v-if="saveLoading" class="mint-experiment-popover__spinner--inline" />
|
|
142
144
|
<!-- Success check -->
|
|
143
|
-
<svg v-else-if="showSuccess" class="mint-experiment-popover__save-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
145
|
+
<svg v-else-if="showSuccess" class="mint-experiment-popover__save-trigger-icon" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
144
146
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
|
145
147
|
</svg>
|
|
146
148
|
<!-- Save icon -->
|
|
147
|
-
<svg v-else class="mint-experiment-popover__save-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
149
|
+
<svg v-else class="mint-experiment-popover__save-trigger-icon" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
148
150
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
|
149
151
|
</svg>
|
|
150
152
|
</button>
|
|
@@ -91,6 +91,19 @@ const flatExperiments = computed(() => {
|
|
|
91
91
|
return groupedByProject.value.flatMap(([, exps]) => exps)
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
// Stable ids so the search field (role="combobox") can expose the currently
|
|
95
|
+
// arrow-highlighted row to assistive tech via aria-activedescendant. This
|
|
96
|
+
// surfaces the existing keyboard model to screen readers without changing it.
|
|
97
|
+
const LISTBOX_ID = 'mint-experiment-selector-listbox'
|
|
98
|
+
function optionId(experiment: ExperimentSummary): string {
|
|
99
|
+
return `mint-experiment-option-${experiment.id}`
|
|
100
|
+
}
|
|
101
|
+
const activeDescendantId = computed(() => {
|
|
102
|
+
const exp = flatExperiments.value[activeIndex.value]
|
|
103
|
+
return activeIndex.value >= 0 && exp ? optionId(exp) : undefined
|
|
104
|
+
})
|
|
105
|
+
const hasResults = computed(() => !isLoading.value && !error.value && experiments.value.length > 0)
|
|
106
|
+
|
|
94
107
|
function setFilter<K extends keyof ExperimentFilters>(key: K, value: string | number) {
|
|
95
108
|
;(filters as Record<string, unknown>)[key] = String(value) || undefined
|
|
96
109
|
}
|
|
@@ -196,6 +209,11 @@ watch(
|
|
|
196
209
|
placeholder="Search experiments..."
|
|
197
210
|
size="sm"
|
|
198
211
|
type="search"
|
|
212
|
+
role="combobox"
|
|
213
|
+
aria-label="Search experiments"
|
|
214
|
+
:aria-expanded="hasResults"
|
|
215
|
+
:aria-controls="LISTBOX_ID"
|
|
216
|
+
:aria-activedescendant="activeDescendantId"
|
|
199
217
|
/>
|
|
200
218
|
</div>
|
|
201
219
|
<div class="mint-experiment-selector__filter-select">
|
|
@@ -218,9 +236,10 @@ watch(
|
|
|
218
236
|
class="mint-experiment-selector__filters-toggle"
|
|
219
237
|
:class="{ 'mint-experiment-selector__filters-toggle--active': hasActiveAdvancedFilters }"
|
|
220
238
|
type="button"
|
|
239
|
+
:aria-expanded="showAdvanced"
|
|
221
240
|
@click="showAdvanced = !showAdvanced"
|
|
222
241
|
>
|
|
223
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
242
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
224
243
|
<line x1="4" y1="6" x2="20" y2="6" /><line x1="8" y1="12" x2="20" y2="12" /><line x1="12" y1="18" x2="20" y2="18" />
|
|
225
244
|
<circle cx="6" cy="12" r="2" /><circle cx="10" cy="18" r="2" /><circle cx="6" cy="6" r="2" />
|
|
226
245
|
</svg>
|
|
@@ -290,16 +309,18 @@ watch(
|
|
|
290
309
|
/>
|
|
291
310
|
|
|
292
311
|
<!-- Experiment list: grouped mode -->
|
|
293
|
-
<div v-else-if="groupToggle" ref="listRef" class="mint-experiment-selector__list">
|
|
312
|
+
<div v-else-if="groupToggle" :id="LISTBOX_ID" ref="listRef" class="mint-experiment-selector__list" role="listbox" aria-label="Experiments">
|
|
294
313
|
<template v-for="([groupName, groupExps]) in groupedByProject" :key="groupName">
|
|
295
314
|
<button
|
|
296
315
|
type="button"
|
|
297
316
|
class="mint-experiment-selector__group-header"
|
|
317
|
+
:aria-expanded="!collapsedGroups.has(groupName)"
|
|
298
318
|
@click="toggleGroup(groupName)"
|
|
299
319
|
>
|
|
300
320
|
<svg
|
|
301
321
|
class="mint-experiment-selector__group-chevron"
|
|
302
322
|
:class="{ 'mint-experiment-selector__group-chevron--collapsed': collapsedGroups.has(groupName) }"
|
|
323
|
+
aria-hidden="true"
|
|
303
324
|
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
304
325
|
>
|
|
305
326
|
<polyline points="6 9 12 15 18 9" />
|
|
@@ -310,8 +331,11 @@ watch(
|
|
|
310
331
|
<template v-if="!collapsedGroups.has(groupName)">
|
|
311
332
|
<div
|
|
312
333
|
v-for="exp in groupExps"
|
|
334
|
+
:id="optionId(exp)"
|
|
313
335
|
:key="exp.id"
|
|
314
336
|
class="mint-experiment-selector__row"
|
|
337
|
+
role="option"
|
|
338
|
+
:aria-selected="exp.id === currentExperimentId"
|
|
315
339
|
:class="{
|
|
316
340
|
'mint-experiment-selector__row--active': exp.id === currentExperimentId,
|
|
317
341
|
'mint-experiment-selector__row--focused': getFlatIndex(exp) === activeIndex,
|
|
@@ -342,11 +366,14 @@ watch(
|
|
|
342
366
|
</div>
|
|
343
367
|
|
|
344
368
|
<!-- Experiment list: flat mode -->
|
|
345
|
-
<div v-else ref="listRef" class="mint-experiment-selector__list">
|
|
369
|
+
<div v-else :id="LISTBOX_ID" ref="listRef" class="mint-experiment-selector__list" role="listbox" aria-label="Experiments">
|
|
346
370
|
<div
|
|
347
371
|
v-for="(exp, idx) in experiments"
|
|
372
|
+
:id="optionId(exp)"
|
|
348
373
|
:key="exp.id"
|
|
349
374
|
class="mint-experiment-selector__row"
|
|
375
|
+
role="option"
|
|
376
|
+
:aria-selected="exp.id === currentExperimentId"
|
|
350
377
|
:class="{
|
|
351
378
|
'mint-experiment-selector__row--active': exp.id === currentExperimentId,
|
|
352
379
|
'mint-experiment-selector__row--focused': idx === activeIndex,
|
|
@@ -113,6 +113,11 @@ function handleClick() {
|
|
|
113
113
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
114
114
|
</svg>
|
|
115
115
|
</IconButton>
|
|
116
|
+
<IconButton label="AI" variant="ghost" @click="handleClick">
|
|
117
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
118
|
+
<path d="M12 3 14 10 21 12 14 14 12 21 10 14 3 12 10 10Z" />
|
|
119
|
+
</svg>
|
|
120
|
+
</IconButton>
|
|
116
121
|
</div>
|
|
117
122
|
</Variant>
|
|
118
123
|
|
|
@@ -301,12 +301,12 @@ defineExpose({ handleSmartGroupApply })
|
|
|
301
301
|
<div class="mint-sample-selector__groups-header">
|
|
302
302
|
<span class="mint-sample-selector__groups-title">Groups ({{ internalGroups.length }})</span>
|
|
303
303
|
<div class="mint-sample-selector__groups-controls">
|
|
304
|
-
<button type="button" class="mint-sample-selector__expand-btn" @click="expandAllGroups" title="Expand all">
|
|
304
|
+
<button type="button" class="mint-sample-selector__expand-btn" @click="expandAllGroups" title="Expand all" aria-label="Expand all groups">
|
|
305
305
|
<svg class="mint-sample-selector__expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
306
306
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
307
307
|
</svg>
|
|
308
308
|
</button>
|
|
309
|
-
<button type="button" class="mint-sample-selector__expand-btn" @click="collapseAllGroups" title="Collapse all">
|
|
309
|
+
<button type="button" class="mint-sample-selector__expand-btn" @click="collapseAllGroups" title="Collapse all" aria-label="Collapse all groups">
|
|
310
310
|
<svg class="mint-sample-selector__expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
311
311
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
312
312
|
</svg>
|
|
@@ -704,6 +704,7 @@ defineExpose({ handleSmartGroupApply })
|
|
|
704
704
|
type="checkbox"
|
|
705
705
|
:checked="modelValue.includes(sample)"
|
|
706
706
|
class="mint-sample-selector__checkbox"
|
|
707
|
+
:aria-label="`Select ${sample}`"
|
|
707
708
|
@change="toggleSample(sample)"
|
|
708
709
|
/>
|
|
709
710
|
<span class="mint-sample-selector__flat-name">{{ sample }}</span>
|
|
@@ -40,7 +40,7 @@ const checkboxClasses = computed(() => [
|
|
|
40
40
|
:aria-label="`Sample: ${sample}`"
|
|
41
41
|
draggable="true"
|
|
42
42
|
>
|
|
43
|
-
<svg class="mint-sample-selector__drag-handle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
43
|
+
<svg class="mint-sample-selector__drag-handle" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
44
44
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
|
|
45
45
|
</svg>
|
|
46
46
|
<input
|
|
@@ -48,6 +48,7 @@ const checkboxClasses = computed(() => [
|
|
|
48
48
|
:checked="selected"
|
|
49
49
|
:class="checkboxClasses"
|
|
50
50
|
:style="accentColor ? { accentColor } : undefined"
|
|
51
|
+
:aria-label="`Select sample ${sample}`"
|
|
51
52
|
@change="emit('toggle')"
|
|
52
53
|
/>
|
|
53
54
|
<span class="mint-sample-selector__sample-name">{{ sample }}</span>
|
|
@@ -56,9 +57,10 @@ const checkboxClasses = computed(() => [
|
|
|
56
57
|
type="button"
|
|
57
58
|
class="mint-sample-selector__remove-btn"
|
|
58
59
|
title="Remove from group"
|
|
60
|
+
aria-label="Remove from group"
|
|
59
61
|
@click="emit('remove')"
|
|
60
62
|
>
|
|
61
|
-
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
63
|
+
<svg aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
62
64
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
|
63
65
|
</svg>
|
|
64
66
|
</button>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SampleClass } from '../../types/auto-group'
|
|
2
2
|
|
|
3
|
-
export function classKey(
|
|
4
|
-
|
|
3
|
+
export function classKey(
|
|
4
|
+
c: Pick<SampleClass, 'kind'> & { subKind?: string; tokenLength?: number },
|
|
5
|
+
): string {
|
|
6
|
+
const base = c.subKind ? `${c.kind}:${c.subKind}` : c.kind
|
|
7
|
+
return c.tokenLength != null ? `${base}#${c.tokenLength}` : base
|
|
5
8
|
}
|
|
@@ -135,8 +135,8 @@ function inferColumnName(
|
|
|
135
135
|
// Date-like all-digits tokens (YYMMDD or YYYYMMDD).
|
|
136
136
|
if (values.every(v => /^\d{6}$/.test(v) || /^\d{8}$/.test(v))) return 'Date'
|
|
137
137
|
|
|
138
|
-
// Run-order column
|
|
139
|
-
if (role === 'run-order') return '
|
|
138
|
+
// Run-order column = the trailing injection / acquisition sequence number.
|
|
139
|
+
if (role === 'run-order') return 'Injection #'
|
|
140
140
|
|
|
141
141
|
// Polarity tokens (POS/NEG/positive/negative).
|
|
142
142
|
if (values.every(v => /^(pos|neg|positive|negative)$/i.test(v))) return 'Polarity'
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
SampleClass,
|
|
8
8
|
} from '../../types/auto-group'
|
|
9
9
|
import type { SampleGroup } from '../../types/components'
|
|
10
|
+
import type { TreeNode } from '../../types/componentLabTypes'
|
|
10
11
|
import { DEFAULT_COLORS, QC_OVERLAY_COLOR } from './colors'
|
|
11
12
|
import { classKey } from './classKey'
|
|
12
13
|
|
|
@@ -66,6 +67,14 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
|
|
|
66
67
|
const metadata: MetadataRow[] = []
|
|
67
68
|
let colorIdx = 0
|
|
68
69
|
|
|
70
|
+
// Nested hierarchy (class → groupBy levels → sample leaves) built alongside
|
|
71
|
+
// the flat groups. `treeIndex` lets repeated members reuse the same path node.
|
|
72
|
+
// PATH_SEP is a control char so it never collides with a real token value.
|
|
73
|
+
const PATH_SEP = String.fromCharCode(1)
|
|
74
|
+
const groupTree: TreeNode[] = []
|
|
75
|
+
const treeIndex = new Map<string, TreeNode>()
|
|
76
|
+
let treeColorIdx = 0
|
|
77
|
+
|
|
69
78
|
for (const cls of input.classes) {
|
|
70
79
|
const schema = input.schemas[classKey(cls)]
|
|
71
80
|
if (!schema) continue
|
|
@@ -80,6 +89,12 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
|
|
|
80
89
|
const groupMap = new Map<string, { name: string; samples: string[] }>()
|
|
81
90
|
const colByIdx = new Map(schema.columns.map(c => [c.index, c]))
|
|
82
91
|
|
|
92
|
+
// Only experimental (group) classes form the nested tree; overlay/QC stay flat.
|
|
93
|
+
const buildsTree = cls.disposition === 'group'
|
|
94
|
+
const runCol = schema.columns.find(c => c.role === 'run-order')
|
|
95
|
+
const classNodeId = classKey(cls)
|
|
96
|
+
let classNode: TreeNode | null = null
|
|
97
|
+
|
|
83
98
|
for (const m of cls.members) {
|
|
84
99
|
const tokens = input.tokenizedSamples[m]
|
|
85
100
|
const rowName = input.sampleNames[m]
|
|
@@ -143,6 +158,46 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
|
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
metadata.push({ sampleName: rowName, fields: fieldValues, group: groupKey })
|
|
161
|
+
|
|
162
|
+
// Grow the nested tree: class root → one node per groupBy column → leaf.
|
|
163
|
+
if (buildsTree) {
|
|
164
|
+
if (!classNode) {
|
|
165
|
+
classNode = {
|
|
166
|
+
id: classNodeId,
|
|
167
|
+
label: cls.label,
|
|
168
|
+
badge: 0,
|
|
169
|
+
children: [],
|
|
170
|
+
metadata: { color: DEFAULT_COLORS[treeColorIdx++ % DEFAULT_COLORS.length] },
|
|
171
|
+
}
|
|
172
|
+
treeIndex.set(classNodeId, classNode)
|
|
173
|
+
groupTree.push(classNode)
|
|
174
|
+
}
|
|
175
|
+
classNode.badge = (classNode.badge as number) + 1
|
|
176
|
+
let cursor = classNode
|
|
177
|
+
let pathId = classNodeId
|
|
178
|
+
// Reuse the already-computed groupBy values (`keyParts`) so the tree
|
|
179
|
+
// levels can never drift from the flat group key.
|
|
180
|
+
for (const val of keyParts) {
|
|
181
|
+
pathId += PATH_SEP + val
|
|
182
|
+
let node = treeIndex.get(pathId)
|
|
183
|
+
if (!node) {
|
|
184
|
+
node = { id: pathId, label: val, badge: 0, children: [] }
|
|
185
|
+
cursor.children!.push(node)
|
|
186
|
+
treeIndex.set(pathId, node)
|
|
187
|
+
}
|
|
188
|
+
node.badge = (node.badge as number) + 1
|
|
189
|
+
cursor = node
|
|
190
|
+
}
|
|
191
|
+
// Injection value goes through the column's op pipeline (alias/exclude)
|
|
192
|
+
// via `fieldValues`, matching what every other surface shows.
|
|
193
|
+
const injection = runCol ? fieldValues[runCol.displayName ?? runCol.name] : undefined
|
|
194
|
+
const leaf: TreeNode = { id: pathId + PATH_SEP + rowName, label: rowName, type: 'sample' }
|
|
195
|
+
if (injection) {
|
|
196
|
+
leaf.badge = injection
|
|
197
|
+
leaf.metadata = { injection }
|
|
198
|
+
}
|
|
199
|
+
cursor.children!.push(leaf)
|
|
200
|
+
}
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
for (const { name, samples } of groupMap.values()) {
|
|
@@ -163,5 +218,6 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
|
|
|
163
218
|
metadata,
|
|
164
219
|
excludedSamples,
|
|
165
220
|
schemas: Object.values(input.schemas),
|
|
221
|
+
groupTree,
|
|
166
222
|
}
|
|
167
223
|
}
|
|
@@ -6,6 +6,7 @@ export function serializeFingerprint(schemas: ClassSchema[]): SchemaFingerprint
|
|
|
6
6
|
classes: schemas.map(s => ({
|
|
7
7
|
kind: s.classKind,
|
|
8
8
|
subKind: s.subKind,
|
|
9
|
+
tokenLength: s.tokenLength,
|
|
9
10
|
columns: s.columns.map(c => ({
|
|
10
11
|
name: c.displayName ?? c.name,
|
|
11
12
|
role: c.role ?? 'factor',
|
|
@@ -23,7 +24,20 @@ export function restoreFingerprint(
|
|
|
23
24
|
current: ClassSchema[],
|
|
24
25
|
): ClassSchema[] {
|
|
25
26
|
return fp.classes.map(snap => {
|
|
26
|
-
|
|
27
|
+
// Match on token length too when the snapshot carries one, so length-split
|
|
28
|
+
// classes sharing a (kind, subKind) restore to the right schema. Snapshots
|
|
29
|
+
// saved before token-length splitting have no `tokenLength`; for those we
|
|
30
|
+
// match on (kind, subKind) and prefer the candidate with the same column
|
|
31
|
+
// count, so an old single-class fingerprint lands on the compatible split
|
|
32
|
+
// instead of throwing on the first (wrong-arity) hit.
|
|
33
|
+
const candidates = current.filter(
|
|
34
|
+
c =>
|
|
35
|
+
c.classKind === snap.kind &&
|
|
36
|
+
c.subKind === snap.subKind &&
|
|
37
|
+
(snap.tokenLength == null || c.tokenLength === snap.tokenLength),
|
|
38
|
+
)
|
|
39
|
+
const target =
|
|
40
|
+
candidates.find(c => c.columns.length === snap.columns.length) ?? candidates[0]
|
|
27
41
|
if (!target) {
|
|
28
42
|
throw new Error(
|
|
29
43
|
`Fingerprint class not present in current input: ${snap.kind}${snap.subKind ? '/' + snap.subKind : ''}`,
|
|
@@ -16,5 +16,7 @@ export {
|
|
|
16
16
|
stripReplicateTokens,
|
|
17
17
|
preGroupReplicates,
|
|
18
18
|
expandGroupsWithReplicates,
|
|
19
|
+
expandTreeWithReplicates,
|
|
19
20
|
} from './replicatePreGroup'
|
|
20
21
|
export type { ReplicatePreGrouping } from './replicatePreGroup'
|
|
22
|
+
export { splitByTokenLength } from './tokenLength'
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* before tokenisation removes a class of fragile cardinality heuristics.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { TreeNode } from '../../types/componentLabTypes'
|
|
14
15
|
import vocabData from './vocab.json'
|
|
15
16
|
|
|
16
17
|
const STRIP_PATTERNS: RegExp[] = (vocabData.replicateStripPatterns as string[]).map(
|
|
@@ -88,3 +89,36 @@ export function expandGroupsWithReplicates<G extends { samples: string[] }>(
|
|
|
88
89
|
}),
|
|
89
90
|
}))
|
|
90
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Expand a group hierarchy (built over base names) back to original samples.
|
|
95
|
+
* Each leaf node's `label` is a base name; it is replaced by one leaf per
|
|
96
|
+
* original sample that collapsed onto it (sharing the leaf's injection badge,
|
|
97
|
+
* since collapsed replicates have identical base tokens). Every ancestor's
|
|
98
|
+
* `badge` (sample count) is recomputed from the expanded leaf count so the tree
|
|
99
|
+
* agrees with the flat groups produced by `expandGroupsWithReplicates`.
|
|
100
|
+
*/
|
|
101
|
+
export function expandTreeWithReplicates(
|
|
102
|
+
nodes: TreeNode[],
|
|
103
|
+
preGrouping: ReplicatePreGrouping,
|
|
104
|
+
): TreeNode[] {
|
|
105
|
+
const baseToOriginals = new Map<string, string[]>()
|
|
106
|
+
for (let i = 0; i < preGrouping.baseNames.length; i++) {
|
|
107
|
+
baseToOriginals.set(
|
|
108
|
+
preGrouping.baseNames[i],
|
|
109
|
+
preGrouping.membersByBase[i].map(m => preGrouping.originalSamples[m]),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
const leafCount = (n: TreeNode): number =>
|
|
113
|
+
n.children && n.children.length ? n.children.reduce((acc, c) => acc + leafCount(c), 0) : 1
|
|
114
|
+
const expand = (node: TreeNode): TreeNode[] => {
|
|
115
|
+
if (node.children && node.children.length) {
|
|
116
|
+
const children = node.children.flatMap(expand)
|
|
117
|
+
return [{ ...node, children, badge: children.reduce((acc, c) => acc + leafCount(c), 0) }]
|
|
118
|
+
}
|
|
119
|
+
const originals = baseToOriginals.get(node.label)
|
|
120
|
+
if (!originals || originals.length === 0) return [node]
|
|
121
|
+
return originals.map((name, k) => ({ ...node, id: `${node.id}${k}`, label: name }))
|
|
122
|
+
}
|
|
123
|
+
return nodes.flatMap(expand)
|
|
124
|
+
}
|
|
@@ -62,14 +62,14 @@ export function composeTemplate(
|
|
|
62
62
|
|
|
63
63
|
const schemaByKey = new Map<string, ClassSchema>()
|
|
64
64
|
for (const s of schemas) {
|
|
65
|
-
const k =
|
|
65
|
+
const k = classKey({ kind: s.classKind, subKind: s.subKind, tokenLength: s.tokenLength })
|
|
66
66
|
schemaByKey.set(k, s)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const colByNameByKey = new Map<string, Map<string, ColumnInfo>>()
|
|
70
70
|
const groupBySetByKey = new Map<string, Set<number>>()
|
|
71
71
|
for (const s of schemas) {
|
|
72
|
-
const k =
|
|
72
|
+
const k = classKey({ kind: s.classKind, subKind: s.subKind, tokenLength: s.tokenLength })
|
|
73
73
|
const m = new Map<string, ColumnInfo>()
|
|
74
74
|
for (const c of s.columns) m.set(c.displayName ?? c.name, c)
|
|
75
75
|
colByNameByKey.set(k, m)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SampleClass } from '../../types/auto-group'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Token-length pre-grouping.
|
|
5
|
+
*
|
|
6
|
+
* After type classification (`detectClass`), members of one class can still have
|
|
7
|
+
* different token counts — e.g. a Plasma batch where some names carry an extra
|
|
8
|
+
* descriptor field. Splitting each class so every member shares the same token
|
|
9
|
+
* length is the "group same token-length sample, then extract tokens inside the
|
|
10
|
+
* group" step: it guarantees `buildClassSchema` sees a rectangular token matrix,
|
|
11
|
+
* so column index N means the same field for every sample in the group.
|
|
12
|
+
*
|
|
13
|
+
* Each output class carries `tokenLength`; `classKey` folds that into the schema
|
|
14
|
+
* key so length-split classes never collide. The label only gains a `· N fields`
|
|
15
|
+
* suffix when a (kind, subKind) genuinely spans more than one length, keeping the
|
|
16
|
+
* common (uniform) case visually identical to before.
|
|
17
|
+
*/
|
|
18
|
+
export function splitByTokenLength(
|
|
19
|
+
classes: SampleClass[],
|
|
20
|
+
tokenized: string[][],
|
|
21
|
+
): SampleClass[] {
|
|
22
|
+
const out: SampleClass[] = []
|
|
23
|
+
|
|
24
|
+
for (const cls of classes) {
|
|
25
|
+
// Bucket members by token count, preserving first-seen order of lengths.
|
|
26
|
+
const byLength = new Map<number, number[]>()
|
|
27
|
+
const lengthOrder: number[] = []
|
|
28
|
+
for (const m of cls.members) {
|
|
29
|
+
const len = tokenized[m]?.length ?? 0
|
|
30
|
+
const bucket = byLength.get(len)
|
|
31
|
+
if (bucket) {
|
|
32
|
+
bucket.push(m)
|
|
33
|
+
} else {
|
|
34
|
+
byLength.set(len, [m])
|
|
35
|
+
lengthOrder.push(len)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const multipleLengths = lengthOrder.length > 1
|
|
40
|
+
for (const len of lengthOrder) {
|
|
41
|
+
out.push({
|
|
42
|
+
...cls,
|
|
43
|
+
members: byLength.get(len)!,
|
|
44
|
+
tokenLength: len,
|
|
45
|
+
// Drop class-tag positions that fall outside this split's token range.
|
|
46
|
+
classTagPositions: cls.classTagPositions.filter(p => p < len),
|
|
47
|
+
label: multipleLengths ? `${cls.label} · ${len} fields` : cls.label,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"matrixVocab": "Lowercase token → canonical display name. Tokens are normalised to lowercase before lookup, so adding 'tissues' handles 'Tissues' / 'TISSUES' / 'tissues' uniformly. Multiple aliases map to one canonical (e.g. cell + cells → Cells).",
|
|
8
8
|
"ionizationModes": "Lowercase polarity tokens (pos / positive / neg / negative). Suffixed to the subKind so POS and NEG acquisitions get independent schemas.",
|
|
9
9
|
"tissueParentTokens": "Tokens that flag the immediately-following token as the organ subKind (so `tissues / kidney` becomes Biological/Tissues, tagging both positions).",
|
|
10
|
-
"replicateStripPatterns": "Regex sources stripped from each sample name during the replicate pre-grouping pass. Samples whose stripped (base) names match are treated as replicates of one another and grouped together before tokenisation. Default set covers
|
|
10
|
+
"replicateStripPatterns": "Regex sources stripped from each sample name during the replicate pre-grouping pass. Samples whose stripped (base) names match are treated as replicates of one another and grouped together before tokenisation. Default set covers _T<n>, _B<n>, and _Rep<n> markers. The trailing injection / run-order number is intentionally NOT stripped here — it survives tokenisation and is surfaced as a 'run-order' column (named 'Injection #'), then excluded from the default group key so replicates still collapse."
|
|
11
11
|
},
|
|
12
12
|
|
|
13
13
|
"patterns": {
|
|
@@ -59,7 +59,6 @@
|
|
|
59
59
|
"tissueParentTokens": ["tissue", "tissues"],
|
|
60
60
|
|
|
61
61
|
"replicateStripPatterns": [
|
|
62
|
-
"[_-]\\d{2,4}[A-Za-z]?$",
|
|
63
62
|
"[_-]T\\d+(?=[_-]|$)",
|
|
64
63
|
"[_-]B\\d+(?=[_-]|$)",
|
|
65
64
|
"[_-](?:rep(?:licate)?)\\d+(?=[_-]|$)"
|
|
@@ -22,11 +22,13 @@ import {
|
|
|
22
22
|
composeTemplate,
|
|
23
23
|
detectClass,
|
|
24
24
|
expandGroupsWithReplicates,
|
|
25
|
+
expandTreeWithReplicates,
|
|
25
26
|
findMerges,
|
|
26
27
|
pickPrimaryDelimiter,
|
|
27
28
|
preGroupReplicates,
|
|
28
29
|
restoreFingerprint,
|
|
29
30
|
serializeFingerprint,
|
|
31
|
+
splitByTokenLength,
|
|
30
32
|
splitMulti,
|
|
31
33
|
type ReplicatePreGrouping,
|
|
32
34
|
type TemplateOptions,
|
|
@@ -142,11 +144,13 @@ export function useAutoGroup() {
|
|
|
142
144
|
preGrouping.value = null
|
|
143
145
|
return
|
|
144
146
|
}
|
|
145
|
-
// Replicate pre-pass: strip
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
147
|
+
// Replicate pre-pass: strip _T<n> / _B<n> / _Rep<n> markers, then collapse
|
|
148
|
+
// samples whose stripped names match into one base entry. The trailing
|
|
149
|
+
// injection / run-order number is deliberately NOT stripped here — it
|
|
150
|
+
// survives tokenisation and is surfaced as a 'run-order' column below. The
|
|
151
|
+
// tokenize → classify → split-by-length → schema pipeline runs on the
|
|
152
|
+
// *base* names; the `result` computed expands each resulting group back to
|
|
153
|
+
// the original samples via `expandGroupsWithReplicates`.
|
|
150
154
|
const pre = preGroupReplicates(lines)
|
|
151
155
|
preGrouping.value = pre
|
|
152
156
|
|
|
@@ -156,17 +160,27 @@ export function useAutoGroup() {
|
|
|
156
160
|
// sampleTypeHints align to original samples, so pick the first sample's
|
|
157
161
|
// hint per base group as the representative hint.
|
|
158
162
|
const hints = pre.membersByBase.map(members => sampleTypeHints.value[members[0]])
|
|
159
|
-
|
|
163
|
+
// Classify by sample type, THEN split each class by token length so every
|
|
164
|
+
// member of a class shares the same field count. This keeps the per-class
|
|
165
|
+
// token matrix rectangular, so column index N means the same field for
|
|
166
|
+
// every sample and the trailing injection number lands in its own column.
|
|
167
|
+
const detected = splitByTokenLength(
|
|
168
|
+
detectClass(tokenized.value, { sampleTypeHints: hints }),
|
|
169
|
+
tokenized.value,
|
|
170
|
+
)
|
|
160
171
|
classes.value = detected
|
|
161
172
|
const newSchemas: Record<string, ClassSchema> = {}
|
|
162
173
|
for (const cls of detected) {
|
|
163
174
|
const memberTokens = cls.members.map(i => tokenized.value[i])
|
|
164
|
-
newSchemas[classKey(cls)] =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
175
|
+
newSchemas[classKey(cls)] = {
|
|
176
|
+
...buildClassSchema(
|
|
177
|
+
memberTokens,
|
|
178
|
+
cls.kind,
|
|
179
|
+
cls.subKind,
|
|
180
|
+
cls.classTagPositions,
|
|
181
|
+
),
|
|
182
|
+
tokenLength: cls.tokenLength,
|
|
183
|
+
}
|
|
170
184
|
}
|
|
171
185
|
schemas.value = newSchemas
|
|
172
186
|
if (detected.length > 0) {
|
|
@@ -215,6 +229,11 @@ export function useAutoGroup() {
|
|
|
215
229
|
qcGroups: composed.qcGroups
|
|
216
230
|
? expandGroupsWithReplicates(composed.qcGroups, pre)
|
|
217
231
|
: undefined,
|
|
232
|
+
// The tree is built over base names too; expand its leaves to the original
|
|
233
|
+
// samples so it agrees with the flat experimental-groups panel.
|
|
234
|
+
groupTree: composed.groupTree
|
|
235
|
+
? expandTreeWithReplicates(composed.groupTree, pre)
|
|
236
|
+
: composed.groupTree,
|
|
218
237
|
}
|
|
219
238
|
})
|
|
220
239
|
|
|
@@ -330,7 +349,9 @@ export function useAutoGroup() {
|
|
|
330
349
|
function loadFingerprint(fp: SchemaFingerprint) {
|
|
331
350
|
const restored = restoreFingerprint(fp, Object.values(schemas.value))
|
|
332
351
|
const next: Record<string, ClassSchema> = {}
|
|
333
|
-
for (const s of restored)
|
|
352
|
+
for (const s of restored) {
|
|
353
|
+
next[classKey({ kind: s.classKind, subKind: s.subKind, tokenLength: s.tokenLength })] = s
|
|
354
|
+
}
|
|
334
355
|
schemas.value = next
|
|
335
356
|
}
|
|
336
357
|
|