@morscherlab/mint-sdk 1.0.44 → 1.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/__tests__/components/SmartGroupFieldRecipe.groups.test.d.ts +1 -0
  2. package/dist/__tests__/components/SmartGroupManual.cohorts.test.d.ts +1 -0
  3. package/dist/components/AutoGroupModal.adapter.d.ts +42 -0
  4. package/dist/components/AutoGroupModal.vue.d.ts +1 -105
  5. package/dist/components/DataFrame.vue.d.ts +15 -0
  6. package/dist/components/SmartGroup.types.d.ts +71 -0
  7. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +53 -0
  8. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +37 -0
  9. package/dist/components/SmartGroupManual.cohorts.d.ts +52 -0
  10. package/dist/components/SmartGroupManual.vue.d.ts +17 -0
  11. package/dist/components/SmartGroupModal.vue.d.ts +27 -0
  12. package/dist/components/index.d.ts +3 -0
  13. package/dist/components/index.js +2 -2
  14. package/dist/{components-BGVwavdd.js → components-C7UFkNIp.js} +3036 -2751
  15. package/dist/components-C7UFkNIp.js.map +1 -0
  16. package/dist/composables/index.js +2 -2
  17. package/dist/{composables-C_hPF0Gn.js → composables-CpBhNKHf.js} +7 -147
  18. package/dist/composables-CpBhNKHf.js.map +1 -0
  19. package/dist/index.js +4 -4
  20. package/dist/install.js +1 -1
  21. package/dist/styles.css +12824 -12195
  22. package/dist/{useProtocolTemplates-BbvlHoPD.js → useProtocolTemplates-C8-YlHj1.js} +206 -66
  23. package/dist/useProtocolTemplates-C8-YlHj1.js.map +1 -0
  24. package/package.json +1 -1
  25. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +164 -0
  26. package/src/__tests__/components/DataFrame.test.ts +100 -0
  27. package/src/__tests__/components/SampleSelector.test.ts +176 -16
  28. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +96 -0
  29. package/src/__tests__/components/SmartGroupManual.cohorts.test.ts +97 -0
  30. package/src/components/AutoGroupModal.adapter.ts +147 -0
  31. package/src/components/AutoGroupModal.vue +176 -1321
  32. package/src/components/DataFrame.vue +97 -5
  33. package/src/components/SampleSelector.vue +8 -23
  34. package/src/components/SmartGroup.types.ts +93 -0
  35. package/src/components/SmartGroupFieldRecipe.groups.ts +105 -0
  36. package/src/components/SmartGroupFieldRecipe.story.vue +58 -0
  37. package/src/components/SmartGroupFieldRecipe.vue +427 -0
  38. package/src/components/SmartGroupManual.cohorts.ts +112 -0
  39. package/src/components/SmartGroupManual.story.vue +55 -0
  40. package/src/components/SmartGroupManual.vue +398 -0
  41. package/src/components/SmartGroupModal.story.vue +61 -0
  42. package/src/components/SmartGroupModal.vue +61 -0
  43. package/src/components/index.ts +3 -0
  44. package/src/styles/components/dataframe.css +79 -0
  45. package/src/styles/components/sample-selector.css +1 -5
  46. package/src/styles/components/smart-group.css +708 -0
  47. package/src/styles/index.css +1 -1
  48. package/dist/components-BGVwavdd.js.map +0 -1
  49. package/dist/composables-C_hPF0Gn.js.map +0 -1
  50. package/dist/useProtocolTemplates-BbvlHoPD.js.map +0 -1
  51. package/src/__tests__/components/AutoGroupModal.preview.test.ts +0 -46
  52. package/src/styles/components/auto-group-modal.css +0 -1336
  53. /package/dist/__tests__/components/{AutoGroupModal.preview.test.d.ts → AutoGroupModal.adapter.test.d.ts} +0 -0
@@ -0,0 +1,427 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SmartGroupFieldRecipe — the "auto" mode of the Smart Group modal.
4
+ *
5
+ * MINT parses the structure of sample names into candidate fields; the user
6
+ * switches on the factors that define their groups. A live preview rail
7
+ * reflects the resulting groups, an inline summary explains what will happen
8
+ * (including the replicate "singleton trap"), and a QC routing control decides
9
+ * how quality-control samples are handled. Grouping maths live in the tested
10
+ * `./SmartGroupFieldRecipe.groups` module.
11
+ *
12
+ * The reference-fixture defaults live in this module-scope `<script>` block
13
+ * because `defineProps` default factories are hoisted and cannot reference
14
+ * bindings declared inside `<script setup>`.
15
+ */
16
+ import type { SmartGroupField, SmartGroupSampleRecord } from './SmartGroupFieldRecipe.groups'
17
+
18
+ const FR_BASE = [
19
+ { Condition: 'Control', Dose: 'Vehicle' },
20
+ { Condition: 'Treatment', Dose: 'Low' },
21
+ { Condition: 'Treatment', Dose: 'High' },
22
+ ]
23
+ const DEFAULT_SAMPLES: SmartGroupSampleRecord[] = FR_BASE.flatMap(base =>
24
+ [1, 2, 3].map(rep => ({ ...base, Replicate: `Rep${rep}` })),
25
+ )
26
+ const DEFAULT_FIELDS: SmartGroupField[] = [
27
+ { key: 'Condition', role: 'Factor', vals: 'Control, Treatment', rep: false },
28
+ { key: 'Dose', role: 'Factor', vals: 'Vehicle, Low, High', rep: false },
29
+ { key: 'Replicate', role: 'Replicate', vals: 'Rep1, Rep2, Rep3', rep: true },
30
+ ]
31
+ const DEFAULT_PALETTE = ['var(--grp-1)', 'var(--grp-2)', 'var(--grp-3)', 'var(--grp-4)', 'var(--grp-5)']
32
+ </script>
33
+
34
+ <script setup lang="ts">
35
+ import { computed, h, ref, type FunctionalComponent } from 'vue'
36
+ import {
37
+ appendQcGroup,
38
+ computeFieldGroups,
39
+ fieldRecipeSummary,
40
+ sampleTotalFor,
41
+ type QcRoute,
42
+ } from './SmartGroupFieldRecipe.groups'
43
+ import { useDropdownState } from '../composables/useDropdownState'
44
+ import BaseButton from './BaseButton.vue'
45
+ import LoadingSpinner from './LoadingSpinner.vue'
46
+ import type { FieldRecipeApplyPayload, SmartGroupFieldRecipeProps } from './SmartGroup.types'
47
+
48
+ const props = withDefaults(defineProps<SmartGroupFieldRecipeProps>(), {
49
+ samples: () => DEFAULT_SAMPLES,
50
+ fields: () => DEFAULT_FIELDS,
51
+ experimentLabel: 'EXP-014',
52
+ headerCount: 12,
53
+ initialRoute: 'Overlay only',
54
+ palette: () => DEFAULT_PALETTE,
55
+ qcColor: 'var(--grp-qc)',
56
+ canDownloadTemplate: true,
57
+ loading: false,
58
+ })
59
+
60
+ const emit = defineEmits<{
61
+ apply: [payload: FieldRecipeApplyPayload]
62
+ cancel: []
63
+ manual: []
64
+ close: []
65
+ /** Controlled mode: a field toggle was clicked (card index). */
66
+ toggle: [index: number]
67
+ /** Controlled mode: the QC routing select changed. */
68
+ 'update:route': [route: QcRoute]
69
+ /** A CSV/TSV file was chosen (upload button) or dropped; parent parses it. */
70
+ 'csv-file': [file: File]
71
+ /** Download a blank metadata template (parent calls the engine). */
72
+ 'download-template': []
73
+ /** Download a pre-filled metadata template (parent calls the engine). */
74
+ 'download-prefilled': []
75
+ }>()
76
+
77
+ /** Tiny stroke-icon helper sharing the design's path data. */
78
+ const Icon: FunctionalComponent<{ d: string; size?: number }> = iconProps =>
79
+ h(
80
+ 'svg',
81
+ {
82
+ width: iconProps.size ?? 16,
83
+ height: iconProps.size ?? 16,
84
+ viewBox: '0 0 24 24',
85
+ fill: 'none',
86
+ stroke: 'currentColor',
87
+ 'stroke-width': 2,
88
+ 'stroke-linecap': 'round',
89
+ 'stroke-linejoin': 'round',
90
+ },
91
+ [h('path', { d: iconProps.d })],
92
+ )
93
+ const ICON = {
94
+ bolt: 'M13 10V3L4 14h7v7l9-11h-7z',
95
+ info: 'M12 16v-4M12 8h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
96
+ warn: 'M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z',
97
+ check: 'M20 6L9 17l-5-5',
98
+ close: 'M6 18L18 6M6 6l12 12',
99
+ upload: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12',
100
+ download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
101
+ chev: 'M6 9l6 6 6-6',
102
+ }
103
+
104
+ const MAX_BAR = 44
105
+
106
+ // CSV upload (presentational): the component only surfaces the control and
107
+ // emits the chosen/dropped File; AutoGroupModal parses it into the engine.
108
+ const fileInput = ref<HTMLInputElement | null>(null)
109
+ const dragOver = ref(false)
110
+ let dragDepth = 0
111
+
112
+ function isCsvLike(file: File): boolean {
113
+ return /\.(csv|tsv)$/i.test(file.name)
114
+ || file.type === 'text/csv'
115
+ || file.type === 'text/tab-separated-values'
116
+ }
117
+ function pickCsv() {
118
+ fileInput.value?.click()
119
+ }
120
+ function onFileChange(event: Event) {
121
+ const input = event.target as HTMLInputElement
122
+ const file = input.files?.[0]
123
+ if (file) emit('csv-file', file)
124
+ input.value = '' // allow re-selecting the same file
125
+ }
126
+ function onDragEnter() {
127
+ dragDepth += 1
128
+ dragOver.value = true
129
+ }
130
+ function onDragLeave() {
131
+ dragDepth = Math.max(0, dragDepth - 1)
132
+ if (dragDepth === 0) dragOver.value = false
133
+ }
134
+ function onDrop(event: DragEvent) {
135
+ dragDepth = 0
136
+ dragOver.value = false
137
+ const file = event.dataTransfer?.files?.[0]
138
+ if (file && isCsvLike(file)) emit('csv-file', file)
139
+ }
140
+
141
+ // CSV action menu — reuses the SDK's dropdown state (outside-click / Escape
142
+ // close) but renders a compact action menu in the .fr-* styling, since the
143
+ // items are actions (upload / download), not a persistent value selection.
144
+ const { isOpen: csvMenuOpen, rootRef: csvMenuRoot, close: closeCsvMenu, toggle: toggleCsvMenu } =
145
+ useDropdownState()
146
+
147
+ function chooseUpload() {
148
+ closeCsvMenu()
149
+ pickCsv()
150
+ }
151
+ function chooseDownloadTemplate() {
152
+ if (!props.canDownloadTemplate) return
153
+ closeCsvMenu()
154
+ emit('download-template')
155
+ }
156
+ function chooseDownloadPrefilled() {
157
+ if (!props.canDownloadTemplate) return
158
+ closeCsvMenu()
159
+ emit('download-prefilled')
160
+ }
161
+
162
+ // Uncontrolled fallback state (used by the standalone story). In controlled
163
+ // mode AutoGroupModal supplies `enabled` / `groups` / `route` + `qcChip` and the
164
+ // `toggle` / `update:route` events drive the engine instead of this local state.
165
+ const onInner = ref<boolean[]>(props.initialOn ? [...props.initialOn] : props.fields.map(field => !field.rep))
166
+ const routeInner = ref<QcRoute>(props.initialRoute)
167
+
168
+ const controlledEnabled = computed(() => props.enabled !== undefined)
169
+ const controlledRoute = computed(() => props.route !== undefined)
170
+
171
+ const on = computed(() => props.enabled ?? onInner.value)
172
+ const currentRoute = computed(() => props.route ?? routeInner.value)
173
+
174
+ const computedBase = computed(() => computeFieldGroups(props.samples, props.fields, on.value, props.palette))
175
+ const baseGroups = computed(() => props.groups ?? computedBase.value)
176
+ const groups = computed(() => props.groups ?? appendQcGroup(computedBase.value, currentRoute.value, props.qcColor))
177
+ const activeFactors = computed(() => props.fields.filter((_, i) => on.value[i]).map(field => field.key))
178
+ const counts = computed(() => baseGroups.value.map(group => group.count))
179
+ const uniform = computed(() => counts.value.length > 0 && counts.value.every(count => count === counts.value[0]))
180
+ const repField = computed(() => props.fields.find(field => field.rep))
181
+ const repOn = computed(() => props.fields.some((field, i) => field.rep && on.value[i]))
182
+ const repRange = computed(() => {
183
+ const parts = (repField.value?.vals ?? '').split(',').map(part => part.trim()).filter(Boolean)
184
+ if (parts.length === 0) return ''
185
+ return parts.length > 1 ? `${parts[0]}–${parts[parts.length - 1]}` : parts[0]
186
+ })
187
+
188
+ const summary = computed(() =>
189
+ fieldRecipeSummary({ activeFactors: activeFactors.value, repOn: repOn.value, baseGroups: baseGroups.value }),
190
+ )
191
+ const groupedSampleTotal = computed(() => groups.value.reduce((sum, group) => sum + group.count, 0))
192
+ const maxCount = computed(() => Math.max(...groups.value.map(group => group.count), 1))
193
+ const qcCount = computed(() => props.qcCount ?? Math.max(0, props.headerCount - props.samples.length))
194
+ const realCount = computed(() => Math.max(0, props.headerCount - qcCount.value))
195
+ const sampleTotal = computed(() => sampleTotalFor(currentRoute.value, realCount.value, qcCount.value))
196
+ const qcChip = computed(() =>
197
+ props.qcChip !== undefined
198
+ ? props.qcChip
199
+ : currentRoute.value !== 'Mix into groups'
200
+ ? { name: 'QC_Pool', count: qcCount.value }
201
+ : null,
202
+ )
203
+ const footHint = computed(() =>
204
+ currentRoute.value === 'Exclude'
205
+ ? ` · ${qcCount.value} QC excluded`
206
+ : currentRoute.value === 'Mix into groups'
207
+ ? ' · QC mixed in'
208
+ : ` · ${qcCount.value} QC overlaid`,
209
+ )
210
+
211
+ function toggle(index: number) {
212
+ if (controlledEnabled.value) {
213
+ emit('toggle', index)
214
+ return
215
+ }
216
+ onInner.value = onInner.value.map((value, i) => (i === index ? !value : value))
217
+ }
218
+
219
+ function onRouteChange(value: QcRoute) {
220
+ if (controlledRoute.value) {
221
+ emit('update:route', value)
222
+ return
223
+ }
224
+ routeInner.value = value
225
+ }
226
+
227
+ function onApply() {
228
+ if (activeFactors.value.length === 0) return
229
+ emit('apply', {
230
+ groups: groups.value,
231
+ route: currentRoute.value,
232
+ sampleTotal: sampleTotal.value,
233
+ activeFactors: activeFactors.value,
234
+ })
235
+ }
236
+ </script>
237
+
238
+ <template>
239
+ <div class="sg-modal fr">
240
+ <!-- header -->
241
+ <div class="sg-head">
242
+ <Icon :d="ICON.bolt" :size="18" />
243
+ <span class="sg-head__title">Group samples</span>
244
+ <span class="sg-head__sub">· {{ headerCount }} samples<template v-if="experimentLabel"> from {{ experimentLabel }}</template></span>
245
+ <div class="sg-head__spacer" />
246
+ <div ref="csvMenuRoot" class="fr-csv">
247
+ <button
248
+ type="button"
249
+ class="fr-upload"
250
+ aria-haspopup="menu"
251
+ :aria-expanded="csvMenuOpen"
252
+ @click="toggleCsvMenu"
253
+ >
254
+ <Icon :d="ICON.upload" :size="14" /> CSV <Icon :d="ICON.chev" :size="13" />
255
+ </button>
256
+ <div v-if="csvMenuOpen" class="fr-csv-menu" role="menu">
257
+ <button type="button" class="fr-csv-item" role="menuitem" @click="chooseUpload">
258
+ <Icon :d="ICON.upload" :size="14" /> Upload CSV
259
+ </button>
260
+ <button
261
+ type="button"
262
+ class="fr-csv-item"
263
+ role="menuitem"
264
+ :disabled="!canDownloadTemplate"
265
+ :aria-disabled="!canDownloadTemplate"
266
+ :title="canDownloadTemplate ? undefined : 'Load samples first'"
267
+ @click="chooseDownloadTemplate"
268
+ >
269
+ <Icon :d="ICON.download" :size="14" /> Download template
270
+ </button>
271
+ <button
272
+ type="button"
273
+ class="fr-csv-item"
274
+ role="menuitem"
275
+ :disabled="!canDownloadTemplate"
276
+ :aria-disabled="!canDownloadTemplate"
277
+ :title="canDownloadTemplate ? undefined : 'Load samples first'"
278
+ @click="chooseDownloadPrefilled"
279
+ >
280
+ <Icon :d="ICON.download" :size="14" /> Download pre-filled
281
+ </button>
282
+ </div>
283
+ <input
284
+ ref="fileInput"
285
+ type="file"
286
+ accept=".csv,.tsv,text/csv,text/tab-separated-values"
287
+ hidden
288
+ @change="onFileChange"
289
+ />
290
+ </div>
291
+ <div class="sg-mode" role="tablist" aria-label="Grouping mode">
292
+ <button type="button" class="sg-mode__btn sg-mode__btn--on" role="tab" :aria-selected="true">Auto</button>
293
+ <button type="button" class="sg-mode__btn" role="tab" :aria-selected="false" @click="emit('manual')">Manual</button>
294
+ </div>
295
+ <button class="sg-head__close" aria-label="Close" @click="emit('close')">
296
+ <Icon :d="ICON.close" :size="16" />
297
+ </button>
298
+ </div>
299
+
300
+ <div class="sg-body">
301
+ <!-- main -->
302
+ <div
303
+ class="sg-body__main"
304
+ @dragenter.prevent="onDragEnter"
305
+ @dragover.prevent
306
+ @dragleave.prevent="onDragLeave"
307
+ @drop.prevent="onDrop"
308
+ >
309
+ <div v-if="dragOver" class="fr-dropzone">
310
+ <Icon :d="ICON.upload" :size="22" />
311
+ <span>Drop CSV to load samples</span>
312
+ </div>
313
+ <div v-if="loading" class="fr-loading">
314
+ <LoadingSpinner size="md" label="Loading experiment metadata" />
315
+ <span>Loading experiment metadata…</span>
316
+ </div>
317
+ <div class="sg-lead">
318
+ <span class="sg-lead__q">Group by which fields?</span>
319
+ <span class="sg-lead__hint">
320
+ MINT read the structure of your sample names. Switch on the factors that define your groups —
321
+ replicate fields are detected and left off.
322
+ </span>
323
+ </div>
324
+
325
+ <div class="sg-recipe">
326
+ <div
327
+ v-for="(field, i) in fields"
328
+ :key="field.key"
329
+ class="sg-field"
330
+ :class="{ 'sg-field--on': on[i], 'fr-field--warn': field.rep && on[i] }"
331
+ >
332
+ <div class="fr-field__head">
333
+ <span class="sg-field__name">{{ field.key }}</span>
334
+ <span class="sg-field__role" :class="{ 'sg-field__role--rep': field.rep }">{{ field.role }}</span>
335
+ </div>
336
+ <button
337
+ class="sg-switch fr-switch"
338
+ :class="{ on: on[i] }"
339
+ role="switch"
340
+ :aria-checked="on[i]"
341
+ :aria-label="'Group by ' + field.key"
342
+ @click="toggle(i)"
343
+ />
344
+ <div class="sg-field__vals">{{ field.vals }}</div>
345
+ </div>
346
+ </div>
347
+
348
+ <!-- inline plain-english summary -->
349
+ <div v-if="summary.kind === 'mute'" class="sg-summary fr-summary--mute">
350
+ <span class="sg-summary__icon"><Icon :d="ICON.info" :size="16" /></span>
351
+ <span>Turn on at least one field to form groups.</span>
352
+ </div>
353
+ <div v-else-if="summary.kind === 'warn'" class="sg-summary sg-summary--warn">
354
+ <span class="sg-summary__icon"><Icon :d="ICON.warn" :size="16" /></span>
355
+ <span>
356
+ Including <b>{{ repField?.key ?? 'Replicate' }}</b> makes every sample unique —
357
+ <b>{{ baseGroups.length }} groups of 1</b>. Turn it off to keep <code>{{ repRange }}</code> together.
358
+ </span>
359
+ </div>
360
+ <div v-else class="sg-summary">
361
+ <span class="sg-summary__icon"><Icon :d="ICON.info" :size="16" /></span>
362
+ <span>
363
+ Grouping by <b>{{ activeFactors.join(' × ') }}</b> →
364
+ <b>{{ baseGroups.length }} groups<template v-if="uniform"> of {{ counts[0] }}</template></b>.
365
+ Replicates kept together.
366
+ </span>
367
+ </div>
368
+ </div>
369
+
370
+ <!-- rail -->
371
+ <div class="sg-body__side">
372
+ <div class="sg-preview__head">
373
+ Resulting groups
374
+ <span class="sg-preview__count">{{ groups.length }} groups · {{ groupedSampleTotal }} samples</span>
375
+ </div>
376
+ <div class="sg-grouplist">
377
+ <div v-if="groups.length === 0" class="fr-empty">No groups yet — pick a field to start.</div>
378
+ <div v-for="group in groups" v-else :key="group.name" class="sg-group">
379
+ <span class="sg-dot" :style="{ background: group.color }" />
380
+ <span class="sg-group__name">{{ group.name }}</span>
381
+ <span
382
+ class="sg-group__bar"
383
+ :style="{ width: (MAX_BAR * group.count) / maxCount + 'px', '--g': group.color }"
384
+ />
385
+ <span class="sg-group__count">{{ group.count }}</span>
386
+ </div>
387
+ </div>
388
+
389
+ <div class="sg-qc">
390
+ <div class="sg-qc__head">
391
+ <span class="sg-qc__title">Quality control</span>
392
+ <select
393
+ class="sg-qc__route"
394
+ :value="currentRoute"
395
+ @change="onRouteChange(($event.target as HTMLSelectElement).value as QcRoute)"
396
+ >
397
+ <option>Overlay only</option>
398
+ <option>Exclude</option>
399
+ <option>Mix into groups</option>
400
+ </select>
401
+ </div>
402
+ <span
403
+ v-if="qcChip"
404
+ class="sg-qc__chip"
405
+ :class="{ 'fr-qc__chip--off': currentRoute === 'Exclude' }"
406
+ >
407
+ <span class="sg-dot" :style="{ background: qcColor }" />{{ qcChip.name }} ×{{ qcChip.count }}
408
+ </span>
409
+ </div>
410
+ </div>
411
+ </div>
412
+
413
+ <div class="sg-foot">
414
+ <span class="sg-foot__hint">Applies to {{ sampleTotal }} samples{{ footHint }}</span>
415
+ <div class="sg-foot__spacer" />
416
+ <BaseButton variant="ghost" size="sm" @click="emit('cancel')">Cancel</BaseButton>
417
+ <BaseButton variant="primary" size="sm" :disabled="activeFactors.length === 0" @click="onApply">
418
+ <template #icon-left><Icon :d="ICON.check" :size="15" /></template>
419
+ Apply grouping
420
+ </BaseButton>
421
+ </div>
422
+ </div>
423
+ </template>
424
+
425
+ <style>
426
+ @import '../styles/components/smart-group.css';
427
+ </style>
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Pure cohort logic for SmartGroupManual (the "manual" grouping mode).
3
+ *
4
+ * Mirrors the design prototype's selection-first model: a flat list of samples,
5
+ * each optionally assigned to a group / subgroup / sub-subgroup, derived into a
6
+ * three-level cohort tree for the right rail. Framework-free for unit testing.
7
+ */
8
+
9
+ /** A sample with its (optional) manual cohort assignment. */
10
+ export interface ManualSample {
11
+ name: string
12
+ group: string | null
13
+ sub: string | null
14
+ sub2: string | null
15
+ color: string | null
16
+ }
17
+
18
+ /** Which slice of the sample list is shown. */
19
+ export type ManualFilter = 'all' | 'unassigned' | 'assigned'
20
+
21
+ /** A sample paired with its index in the source list (so the UI can toggle it). */
22
+ export interface ManualMatch {
23
+ sample: ManualSample
24
+ index: number
25
+ }
26
+
27
+ /** Second-level subgroup node: a sub-subgroup name → count map. */
28
+ export interface CohortSubNode {
29
+ total: number
30
+ subs2: Map<string, number>
31
+ }
32
+
33
+ /** Top-level cohort node: colour, total, and its subgroups. */
34
+ export interface CohortNode {
35
+ color: string | null
36
+ total: number
37
+ subs: Map<string, CohortSubNode>
38
+ }
39
+
40
+ /** The assignment values applied to a selection. */
41
+ export interface CohortAssignment {
42
+ grp: string
43
+ sub: string
44
+ sub2: string
45
+ color: string
46
+ }
47
+
48
+ /**
49
+ * Build the group → subgroup → sub-subgroup tree from assigned samples.
50
+ * Unassigned samples are ignored. Missing sub/sub2 values collapse to `—`.
51
+ */
52
+ export function buildCohortTree(samples: ManualSample[]): Map<string, CohortNode> {
53
+ const groups = new Map<string, CohortNode>()
54
+ for (const sample of samples) {
55
+ if (!sample.group) continue
56
+ if (!groups.has(sample.group)) {
57
+ groups.set(sample.group, { color: sample.color, total: 0, subs: new Map() })
58
+ }
59
+ const node = groups.get(sample.group)!
60
+ node.total++
61
+
62
+ const subKey = sample.sub || '—'
63
+ if (!node.subs.has(subKey)) {
64
+ node.subs.set(subKey, { total: 0, subs2: new Map() })
65
+ }
66
+ const subNode = node.subs.get(subKey)!
67
+ subNode.total++
68
+
69
+ const sub2Key = sample.sub2 || '—'
70
+ subNode.subs2.set(sub2Key, (subNode.subs2.get(sub2Key) ?? 0) + 1)
71
+ }
72
+ return groups
73
+ }
74
+
75
+ /** Filter the sample list by a case-insensitive name query and assignment state. */
76
+ export function filterSamples(
77
+ samples: ManualSample[],
78
+ query: string,
79
+ filter: ManualFilter,
80
+ ): ManualMatch[] {
81
+ const q = query.trim().toLowerCase()
82
+ return samples
83
+ .map((sample, index) => ({ sample, index }))
84
+ .filter(({ sample }) => {
85
+ if (q && !sample.name.toLowerCase().includes(q)) return false
86
+ if (filter === 'unassigned') return !sample.group
87
+ if (filter === 'assigned') return !!sample.group
88
+ return true
89
+ })
90
+ }
91
+
92
+ /**
93
+ * Apply a cohort assignment to the selected sample indices, returning a new list.
94
+ * Blank subgroup / sub-subgroup values become `null`.
95
+ */
96
+ export function assignSamples(
97
+ samples: ManualSample[],
98
+ selected: ReadonlySet<number>,
99
+ assignment: CohortAssignment,
100
+ ): ManualSample[] {
101
+ return samples.map((sample, index) =>
102
+ selected.has(index)
103
+ ? {
104
+ ...sample,
105
+ group: assignment.grp.trim(),
106
+ sub: assignment.sub.trim() || null,
107
+ sub2: assignment.sub2.trim() || null,
108
+ color: assignment.color,
109
+ }
110
+ : sample,
111
+ )
112
+ }
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import SmartGroupManual from './SmartGroupManual.vue'
3
+ import type { ManualDonePayload } from './SmartGroup.types'
4
+ </script>
5
+
6
+ <template>
7
+ <Story title="Lab/SmartGroupManual">
8
+ <Variant title="Playground" :init-state="() => ({ log: '' })">
9
+ <template #default="{ state }">
10
+ <div class="sg-story">
11
+ <div class="sg-story__stage">
12
+ <SmartGroupManual
13
+ @done="(r: ManualDonePayload) => (state.log = `${r.samples.filter(s => s.group).length} of ${r.samples.length} grouped`)"
14
+ @auto="state.log = 'back to smart grouping'"
15
+ @cancel="state.log = 'cancelled'"
16
+ @close="state.log = 'closed'"
17
+ />
18
+ </div>
19
+ <p v-if="state.log" class="sg-story__log">{{ state.log }}</p>
20
+ </div>
21
+ </template>
22
+ </Variant>
23
+
24
+ <Variant title="Empty (nothing seeded)">
25
+ <template #default>
26
+ <div class="sg-story">
27
+ <div class="sg-story__stage">
28
+ <SmartGroupManual :seed="{}" />
29
+ </div>
30
+ </div>
31
+ </template>
32
+ </Variant>
33
+ </Story>
34
+ </template>
35
+
36
+ <style scoped>
37
+ .sg-story {
38
+ padding: 2rem;
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 1rem;
42
+ align-items: flex-start;
43
+ }
44
+ .sg-story__stage {
45
+ width: 980px;
46
+ height: 640px;
47
+ max-width: 100%;
48
+ }
49
+ .sg-story__log {
50
+ margin: 0;
51
+ font-family: 'Fira Code', monospace;
52
+ font-size: 0.8125rem;
53
+ color: var(--text-secondary, #64748b);
54
+ }
55
+ </style>