@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.
- package/dist/__tests__/components/SmartGroupFieldRecipe.groups.test.d.ts +1 -0
- package/dist/__tests__/components/SmartGroupManual.cohorts.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.adapter.d.ts +42 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -105
- package/dist/components/DataFrame.vue.d.ts +15 -0
- package/dist/components/SmartGroup.types.d.ts +71 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +53 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +37 -0
- package/dist/components/SmartGroupManual.cohorts.d.ts +52 -0
- package/dist/components/SmartGroupManual.vue.d.ts +17 -0
- package/dist/components/SmartGroupModal.vue.d.ts +27 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-BGVwavdd.js → components-C7UFkNIp.js} +3036 -2751
- package/dist/components-C7UFkNIp.js.map +1 -0
- package/dist/composables/index.js +2 -2
- package/dist/{composables-C_hPF0Gn.js → composables-CpBhNKHf.js} +7 -147
- package/dist/composables-CpBhNKHf.js.map +1 -0
- package/dist/index.js +4 -4
- package/dist/install.js +1 -1
- package/dist/styles.css +12824 -12195
- package/dist/{useProtocolTemplates-BbvlHoPD.js → useProtocolTemplates-C8-YlHj1.js} +206 -66
- package/dist/useProtocolTemplates-C8-YlHj1.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AutoGroupModal.adapter.test.ts +164 -0
- package/src/__tests__/components/DataFrame.test.ts +100 -0
- package/src/__tests__/components/SampleSelector.test.ts +176 -16
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +96 -0
- package/src/__tests__/components/SmartGroupManual.cohorts.test.ts +97 -0
- package/src/components/AutoGroupModal.adapter.ts +147 -0
- package/src/components/AutoGroupModal.vue +176 -1321
- package/src/components/DataFrame.vue +97 -5
- package/src/components/SampleSelector.vue +8 -23
- package/src/components/SmartGroup.types.ts +93 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +105 -0
- package/src/components/SmartGroupFieldRecipe.story.vue +58 -0
- package/src/components/SmartGroupFieldRecipe.vue +427 -0
- package/src/components/SmartGroupManual.cohorts.ts +112 -0
- package/src/components/SmartGroupManual.story.vue +55 -0
- package/src/components/SmartGroupManual.vue +398 -0
- package/src/components/SmartGroupModal.story.vue +61 -0
- package/src/components/SmartGroupModal.vue +61 -0
- package/src/components/index.ts +3 -0
- package/src/styles/components/dataframe.css +79 -0
- package/src/styles/components/sample-selector.css +1 -5
- package/src/styles/components/smart-group.css +708 -0
- package/src/styles/index.css +1 -1
- package/dist/components-BGVwavdd.js.map +0 -1
- package/dist/composables-C_hPF0Gn.js.map +0 -1
- package/dist/useProtocolTemplates-BbvlHoPD.js.map +0 -1
- package/src/__tests__/components/AutoGroupModal.preview.test.ts +0 -46
- package/src/styles/components/auto-group-modal.css +0 -1336
- /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>
|