@morscherlab/mint-sdk 1.1.8 → 1.1.10
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/RetiredStyles.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
- package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
- package/dist/components/AppTopBar.vue.d.ts +6 -11
- package/dist/components/AutoGroupModal.adapter.d.ts +22 -7
- package/dist/components/PluginIcon.vue.d.ts +2 -2
- package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
- package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
- package/dist/components/SampleSelector.groups.d.ts +7 -7
- package/dist/components/SampleSelector.selection.d.ts +6 -12
- package/dist/components/SampleSelector.tree.d.ts +39 -0
- package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
- package/dist/components/SmartGroup.types.d.ts +5 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-CDomhGSj.js → components-HXV-ZkBg.js} +939 -948
- package/dist/components-HXV-ZkBg.js.map +1 -0
- package/dist/composables/index.d.ts +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/composables/useAutoGroup.d.ts +3 -0
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/composables/useSampleGroups.d.ts +25 -0
- package/dist/index.js +2 -2
- package/dist/install.js +1 -1
- package/dist/styles.css +130 -557
- package/dist/types/platform.d.ts +1 -0
- package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-D3IZKLqr.js} +198 -24
- package/dist/useProtocolTemplates-D3IZKLqr.js.map +1 -0
- package/dist/utils/pluginIcon.d.ts +1 -1
- package/dist/utils/sampleGroupPath.d.ts +6 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
- package/src/__tests__/components/AppTopBar.test.ts +84 -10
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
- package/src/__tests__/components/AutoGroupModal.adapter.test.ts +47 -7
- package/src/__tests__/components/PluginIcon.test.ts +19 -0
- package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
- package/src/__tests__/components/RetiredStyles.test.ts +82 -0
- package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
- package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
- package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
- package/src/__tests__/components/SampleSelector.test.ts +35 -0
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
- package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
- package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +278 -0
- package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
- package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
- package/src/components/AppTopBar.story.vue +9 -7
- package/src/components/AppTopBar.vue +79 -72
- package/src/components/AutoGroupModal.adapter.ts +69 -13
- package/src/components/AutoGroupModal.story.vue +89 -0
- package/src/components/AutoGroupModal.vue +18 -8
- package/src/components/PluginIcon.story.vue +11 -9
- package/src/components/PluginIcon.vue +3 -3
- package/src/components/PluginWorkspaceView.props.ts +5 -7
- package/src/components/PluginWorkspaceView.vue +0 -4
- package/src/components/SampleSelector.drag.ts +5 -5
- package/src/components/SampleSelector.groups.ts +15 -15
- package/src/components/SampleSelector.selection.ts +7 -46
- package/src/components/SampleSelector.story.vue +37 -0
- package/src/components/SampleSelector.tree.ts +48 -0
- package/src/components/SampleSelector.vue +52 -320
- package/src/components/SampleSelectorGroupNode.vue +197 -0
- package/src/components/SmartGroup.types.ts +5 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
- package/src/components/SmartGroupFieldRecipe.vue +76 -4
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
- package/src/composables/autoGroup/compose.ts +31 -10
- package/src/composables/index.ts +1 -0
- package/src/composables/useAutoGroup.ts +112 -5
- package/src/composables/useSampleGroups.ts +118 -33
- package/src/styles/components/app-avatar-menu.css +2 -1
- package/src/styles/components/app-plugin-switcher.css +0 -13
- package/src/styles/components/app-top-bar.css +13 -54
- package/src/styles/components/dose-calculator.css +0 -4
- package/src/styles/components/experiment-popover.css +2 -104
- package/src/styles/components/experiment-selector-modal.css +0 -7
- package/src/styles/components/plugin-icon.css +3 -0
- package/src/styles/components/protocol-step-editor.css +0 -6
- package/src/styles/components/reagent-editor.css +0 -5
- package/src/styles/components/sample-hierarchy-tree.css +0 -4
- package/src/styles/components/sample-selector.css +0 -96
- package/src/styles/components/schedule-calendar.css +0 -6
- package/src/styles/components/smart-group.css +22 -0
- package/src/styles/components/well-plate.css +1 -27
- package/src/types/platform.ts +1 -0
- package/src/utils/pluginIcon.ts +5 -2
- package/src/utils/sampleGroupPath.ts +19 -0
- package/dist/components-CDomhGSj.js.map +0 -1
- package/dist/useProtocolTemplates-BMt0LlXf.js.map +0 -1
|
@@ -64,6 +64,8 @@ const emit = defineEmits<{
|
|
|
64
64
|
close: []
|
|
65
65
|
/** Controlled mode: a field toggle was clicked (card index). */
|
|
66
66
|
toggle: [index: number]
|
|
67
|
+
/** Controlled mode: a layer reorder button was clicked (card index). */
|
|
68
|
+
move: [index: number, direction: 'up' | 'down']
|
|
67
69
|
/** Controlled mode: the QC routing select changed. */
|
|
68
70
|
'update:route': [route: QcRoute]
|
|
69
71
|
/** A CSV/TSV file was chosen (upload button) or dropped; parent parses it. */
|
|
@@ -99,6 +101,7 @@ const ICON = {
|
|
|
99
101
|
upload: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12',
|
|
100
102
|
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
|
|
101
103
|
chev: 'M6 9l6 6 6-6',
|
|
104
|
+
chevUp: 'M18 15l-6-6-6 6',
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
const MAX_BAR = 44
|
|
@@ -160,9 +163,12 @@ function chooseDownloadPrefilled() {
|
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
// Uncontrolled fallback state (used by the standalone story). In controlled
|
|
163
|
-
// mode AutoGroupModal supplies `enabled` / `
|
|
164
|
-
// `toggle` / `update:route` events drive the engine
|
|
166
|
+
// mode AutoGroupModal supplies `enabled` / `layers` / `groups` / `route` +
|
|
167
|
+
// `qcChip` and the `toggle` / `move` / `update:route` events drive the engine
|
|
168
|
+
// instead of this local state. `orderInner` tracks the uncontrolled layer
|
|
169
|
+
// order: field indices in toggle order, first entry = hierarchy layer 1.
|
|
165
170
|
const onInner = ref<boolean[]>(props.initialOn ? [...props.initialOn] : props.fields.map(field => !field.rep))
|
|
171
|
+
const orderInner = ref<number[]>(onInner.value.flatMap((isOn, i) => (isOn ? [i] : [])))
|
|
166
172
|
const routeInner = ref<QcRoute>(props.initialRoute)
|
|
167
173
|
|
|
168
174
|
const controlledEnabled = computed(() => props.enabled !== undefined)
|
|
@@ -171,10 +177,34 @@ const controlledRoute = computed(() => props.route !== undefined)
|
|
|
171
177
|
const on = computed(() => props.enabled ?? onInner.value)
|
|
172
178
|
const currentRoute = computed(() => props.route ?? routeInner.value)
|
|
173
179
|
|
|
174
|
-
|
|
180
|
+
// Per-card hierarchy layer (1-based, null = not grouped by). Controlled mode
|
|
181
|
+
// passes `layers` from the engine; controlled-without-layers keeps the legacy
|
|
182
|
+
// field-order numbering; uncontrolled mode derives from the local toggle order.
|
|
183
|
+
const layers = computed<(number | null)[]>(() => {
|
|
184
|
+
if (props.layers !== undefined) return props.layers
|
|
185
|
+
if (controlledEnabled.value) {
|
|
186
|
+
let layer = 0
|
|
187
|
+
return props.fields.map((_, i) => (on.value[i] ? ++layer : null))
|
|
188
|
+
}
|
|
189
|
+
return props.fields.map((_, i) => {
|
|
190
|
+
const pos = orderInner.value.indexOf(i)
|
|
191
|
+
return pos === -1 ? null : pos + 1
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
// Field indices sorted by layer — the join order for names and the summary.
|
|
195
|
+
const layerOrder = computed<number[]>(() =>
|
|
196
|
+
layers.value
|
|
197
|
+
.flatMap((layer, i) => (layer === null ? [] : [{ layer, i }]))
|
|
198
|
+
.sort((a, b) => a.layer - b.layer)
|
|
199
|
+
.map(entry => entry.i),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const computedBase = computed(() =>
|
|
203
|
+
computeFieldGroups(props.samples, props.fields, on.value, props.palette, layerOrder.value),
|
|
204
|
+
)
|
|
175
205
|
const baseGroups = computed(() => props.groups ?? computedBase.value)
|
|
176
206
|
const groups = computed(() => props.groups ?? appendQcGroup(computedBase.value, currentRoute.value, props.qcColor))
|
|
177
|
-
const activeFactors = computed(() =>
|
|
207
|
+
const activeFactors = computed(() => layerOrder.value.map(i => props.fields[i].key))
|
|
178
208
|
const counts = computed(() => baseGroups.value.map(group => group.count))
|
|
179
209
|
const uniform = computed(() => counts.value.length > 0 && counts.value.every(count => count === counts.value[0]))
|
|
180
210
|
const repField = computed(() => props.fields.find(field => field.rep))
|
|
@@ -213,7 +243,26 @@ function toggle(index: number) {
|
|
|
213
243
|
emit('toggle', index)
|
|
214
244
|
return
|
|
215
245
|
}
|
|
246
|
+
const wasOn = onInner.value[index]
|
|
216
247
|
onInner.value = onInner.value.map((value, i) => (i === index ? !value : value))
|
|
248
|
+
// A re-enabled field appends as the LAST layer, mirroring the engine.
|
|
249
|
+
orderInner.value = wasOn
|
|
250
|
+
? orderInner.value.filter(i => i !== index)
|
|
251
|
+
: [...orderInner.value, index]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function moveLayer(index: number, direction: 'up' | 'down') {
|
|
255
|
+
if (controlledEnabled.value) {
|
|
256
|
+
emit('move', index, direction)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
const from = orderInner.value.indexOf(index)
|
|
260
|
+
if (from === -1) return
|
|
261
|
+
const to = direction === 'up' ? from - 1 : from + 1
|
|
262
|
+
if (to < 0 || to >= orderInner.value.length) return
|
|
263
|
+
const next = [...orderInner.value]
|
|
264
|
+
;[next[from], next[to]] = [next[to], next[from]]
|
|
265
|
+
orderInner.value = next
|
|
217
266
|
}
|
|
218
267
|
|
|
219
268
|
function onRouteChange(value: QcRoute) {
|
|
@@ -341,6 +390,29 @@ function onApply() {
|
|
|
341
390
|
:aria-label="'Group by ' + field.key"
|
|
342
391
|
@click="toggle(i)"
|
|
343
392
|
/>
|
|
393
|
+
<div v-if="on[i] && layers[i] !== null" class="sg-field__order">
|
|
394
|
+
<span class="sg-field__layer">Layer {{ layers[i] }}</span>
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
class="sg-field__order-btn"
|
|
398
|
+
:disabled="layers[i] === 1"
|
|
399
|
+
:aria-label="'Move ' + field.key + ' up a layer'"
|
|
400
|
+
title="Move up a layer"
|
|
401
|
+
@click.stop="moveLayer(i, 'up')"
|
|
402
|
+
>
|
|
403
|
+
<Icon :d="ICON.chevUp" :size="12" />
|
|
404
|
+
</button>
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
class="sg-field__order-btn"
|
|
408
|
+
:disabled="layers[i] === layerOrder.length"
|
|
409
|
+
:aria-label="'Move ' + field.key + ' down a layer'"
|
|
410
|
+
title="Move down a layer"
|
|
411
|
+
@click.stop="moveLayer(i, 'down')"
|
|
412
|
+
>
|
|
413
|
+
<Icon :d="ICON.chev" :size="12" />
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
344
416
|
<div class="sg-field__vals">{{ field.vals }}</div>
|
|
345
417
|
</div>
|
|
346
418
|
</div>
|
|
@@ -63,7 +63,7 @@ function handleSelect(page: PageSelectorItem) {
|
|
|
63
63
|
class="mint-page-selector__trigger-metadata-icon"
|
|
64
64
|
:icon="current.icon"
|
|
65
65
|
size="sm"
|
|
66
|
-
variant="
|
|
66
|
+
variant="solid"
|
|
67
67
|
/>
|
|
68
68
|
<span v-else class="mint-page-selector__icon-fallback">{{ current.label.charAt(0) }}</span>
|
|
69
69
|
</slot>
|
|
@@ -111,7 +111,7 @@ function handleSelect(page: PageSelectorItem) {
|
|
|
111
111
|
class="mint-page-selector__metadata-icon"
|
|
112
112
|
:icon="page.icon"
|
|
113
113
|
size="sm"
|
|
114
|
-
variant="
|
|
114
|
+
variant="solid"
|
|
115
115
|
/>
|
|
116
116
|
<span v-else class="mint-page-selector__icon-fallback">{{ page.label.charAt(0) }}</span>
|
|
117
117
|
</slot>
|
|
@@ -67,6 +67,31 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
|
|
|
67
67
|
const metadata: MetadataRow[] = []
|
|
68
68
|
let colorIdx = 0
|
|
69
69
|
|
|
70
|
+
// Class-label prefixing exists ONLY to disambiguate group names when several
|
|
71
|
+
// experimental classes could emit the same factor key (e.g. a Liver class and
|
|
72
|
+
// a Kidney class both producing "5min / High"). With a single experimental
|
|
73
|
+
// class there is nothing to disambiguate, so groups start directly at the
|
|
74
|
+
// user's first layer and the sample selector's major-group slot is not
|
|
75
|
+
// wasted on a constant root. When a prefix IS needed it should occupy ONE
|
|
76
|
+
// hierarchy layer, so prefer the short distinguishing `subKind` ("Liver")
|
|
77
|
+
// over the full label ("Biological / Liver") — falling back to the full
|
|
78
|
+
// label when two classes share a subKind (e.g. token-length splits).
|
|
79
|
+
const experimentalClasses = input.classes.filter(
|
|
80
|
+
c => c.disposition === 'group' && input.schemas[classKey(c)],
|
|
81
|
+
)
|
|
82
|
+
const classPrefix = new Map<SampleClass, string>()
|
|
83
|
+
if (input.prefixClassLabels !== false && experimentalClasses.length > 1) {
|
|
84
|
+
const shortCounts = new Map<string, number>()
|
|
85
|
+
for (const c of experimentalClasses) {
|
|
86
|
+
const short = c.subKind ?? c.label
|
|
87
|
+
shortCounts.set(short, (shortCounts.get(short) ?? 0) + 1)
|
|
88
|
+
}
|
|
89
|
+
for (const c of experimentalClasses) {
|
|
90
|
+
const short = c.subKind ?? c.label
|
|
91
|
+
classPrefix.set(c, (shortCounts.get(short) ?? 0) > 1 ? c.label : short)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
// Nested hierarchy (class → groupBy levels → sample leaves) built alongside
|
|
71
96
|
// the flat groups. `treeIndex` lets repeated members reuse the same path node.
|
|
72
97
|
// PATH_SEP is a control char so it never collides with a real token value.
|
|
@@ -125,9 +150,9 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
|
|
|
125
150
|
})
|
|
126
151
|
: [cls.label]
|
|
127
152
|
|
|
128
|
-
// Prepend the class
|
|
129
|
-
//
|
|
130
|
-
//
|
|
153
|
+
// Prepend the class's short distinguishing label (see `classPrefix`
|
|
154
|
+
// above) so group names from different classes don't collide when they
|
|
155
|
+
// share a factor value (e.g. two tissue classes both emitting "5min").
|
|
131
156
|
//
|
|
132
157
|
// Only fires for disposition === 'group' classes with actual factor
|
|
133
158
|
// keyParts: overlay/exclude classes already use cls.label AS their
|
|
@@ -137,15 +162,11 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
|
|
|
137
162
|
input.missingGroupName &&
|
|
138
163
|
cls.disposition === 'group' &&
|
|
139
164
|
keyParts.some(part => part.trim().length === 0)
|
|
140
|
-
const
|
|
141
|
-
input.prefixClassLabels !== false &&
|
|
142
|
-
cls.disposition === 'group' &&
|
|
143
|
-
cls.kind !== 'unknown' &&
|
|
144
|
-
keyParts.length > 0
|
|
165
|
+
const prefix = cls.kind !== 'unknown' && keyParts.length > 0 ? classPrefix.get(cls) : undefined
|
|
145
166
|
const groupKey = hasMissingGroupPart
|
|
146
167
|
? input.missingGroupName!
|
|
147
|
-
:
|
|
148
|
-
? `${
|
|
168
|
+
: prefix
|
|
169
|
+
? `${prefix} / ${keyParts.join(' / ')}`
|
|
149
170
|
: keyParts.length > 0
|
|
150
171
|
? keyParts.join(' / ')
|
|
151
172
|
: cls.label
|
package/src/composables/index.ts
CHANGED
|
@@ -271,18 +271,123 @@ export function useAutoGroup() {
|
|
|
271
271
|
if (changed) classes.value = next
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// `groupBy` is an ORDERED layer list: entry N is hierarchy layer N of the
|
|
275
|
+
// composed group names (layer 1 = the sample selector's major group). A
|
|
276
|
+
// column toggled back on therefore appends as the LAST layer rather than
|
|
277
|
+
// returning to its token position.
|
|
274
278
|
function toggleGroupBy(key: string, idx: number) {
|
|
275
279
|
const schema = schemas.value[key]
|
|
276
280
|
if (!schema) return
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
281
|
+
const groupBy = schema.groupBy.includes(idx)
|
|
282
|
+
? schema.groupBy.filter(i => i !== idx)
|
|
283
|
+
: [...schema.groupBy, idx]
|
|
280
284
|
schemas.value = {
|
|
281
285
|
...schemas.value,
|
|
282
|
-
[key]: { ...schema, groupBy
|
|
286
|
+
[key]: { ...schema, groupBy },
|
|
283
287
|
}
|
|
284
288
|
}
|
|
285
289
|
|
|
290
|
+
/** Move an enabled column one step up (toward layer 1) or down in the layer order. */
|
|
291
|
+
function moveGroupBy(key: string, idx: number, direction: 'up' | 'down') {
|
|
292
|
+
const schema = schemas.value[key]
|
|
293
|
+
if (!schema) return
|
|
294
|
+
const from = schema.groupBy.indexOf(idx)
|
|
295
|
+
if (from === -1) return
|
|
296
|
+
const to = direction === 'up' ? from - 1 : from + 1
|
|
297
|
+
if (to < 0 || to >= schema.groupBy.length) return
|
|
298
|
+
const groupBy = [...schema.groupBy]
|
|
299
|
+
;[groupBy[from], groupBy[to]] = [groupBy[to], groupBy[from]]
|
|
300
|
+
schemas.value = {
|
|
301
|
+
...schemas.value,
|
|
302
|
+
[key]: { ...schema, groupBy },
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The Field Recipe presents ONE set of grouping fields for the whole batch,
|
|
307
|
+
// but classes each own a schema — so a field edit must reach every class that
|
|
308
|
+
// exposes that field, or a multi-class batch ends up with divergent layer
|
|
309
|
+
// orders (half the groups reordered). Fields are matched by display identity
|
|
310
|
+
// rather than column index, since a class split off by token length can carry
|
|
311
|
+
// the same field at a different position.
|
|
312
|
+
function schemasExposing(fieldKey: string): { key: string; index: number }[] {
|
|
313
|
+
return Object.entries(schemas.value).flatMap(([key, schema]) => {
|
|
314
|
+
const col = schema.columns.find(c =>
|
|
315
|
+
(c.displayName ?? c.name) === fieldKey
|
|
316
|
+
&& (c.role === 'factor' || c.role === 'replicate' || schema.groupBy.includes(c.index)),
|
|
317
|
+
)
|
|
318
|
+
return col ? [{ key, index: col.index }] : []
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function fieldIdentity(schema: ClassSchema, index: number): string | undefined {
|
|
323
|
+
const column = schema.columns.find(candidate => candidate.index === index)
|
|
324
|
+
return column ? (column.displayName ?? column.name) : undefined
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Toggle a grouping field across every class exposing it. The batch card is
|
|
329
|
+
* on when any schema groups by the field, so clicking it disables all such
|
|
330
|
+
* schemas; only a fully disabled field is enabled across the batch.
|
|
331
|
+
*/
|
|
332
|
+
function toggleGroupByField(fieldKey: string) {
|
|
333
|
+
const targets = schemasExposing(fieldKey)
|
|
334
|
+
if (targets.length === 0) return
|
|
335
|
+
const enable = !targets.some(({ key, index }) => schemas.value[key]?.groupBy.includes(index))
|
|
336
|
+
for (const { key, index } of targets) {
|
|
337
|
+
const schema = schemas.value[key]
|
|
338
|
+
if (!schema || schema.groupBy.includes(index) === enable) continue
|
|
339
|
+
toggleGroupBy(key, index)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Move a grouping field in the active schema, then project that visible field
|
|
345
|
+
* order across every class. Class-only fields keep their relative order after
|
|
346
|
+
* the shared fields because the Field Recipe has no card with which to place
|
|
347
|
+
* them explicitly.
|
|
348
|
+
*/
|
|
349
|
+
function moveGroupByField(fieldKey: string, direction: 'up' | 'down') {
|
|
350
|
+
const targets = schemasExposing(fieldKey)
|
|
351
|
+
if (targets.length === 0) return
|
|
352
|
+
const active = targets.find(target =>
|
|
353
|
+
target.key === activeClassKey.value && schemas.value[target.key]?.groupBy.includes(target.index),
|
|
354
|
+
) ?? targets.find(target => schemas.value[target.key]?.groupBy.includes(target.index)) ?? targets[0]
|
|
355
|
+
const activeSchema = schemas.value[active.key]
|
|
356
|
+
if (!activeSchema) return
|
|
357
|
+
const from = activeSchema.groupBy.indexOf(active.index)
|
|
358
|
+
if (from === -1) return
|
|
359
|
+
const to = direction === 'up' ? from - 1 : from + 1
|
|
360
|
+
if (to < 0 || to >= activeSchema.groupBy.length) return
|
|
361
|
+
|
|
362
|
+
const activeOrder = [...activeSchema.groupBy]
|
|
363
|
+
;[activeOrder[from], activeOrder[to]] = [activeOrder[to], activeOrder[from]]
|
|
364
|
+
const desiredRank = new Map(
|
|
365
|
+
activeOrder.flatMap((index, rank) => {
|
|
366
|
+
const identity = fieldIdentity(activeSchema, index)
|
|
367
|
+
return identity === undefined ? [] : [[identity, rank] as const]
|
|
368
|
+
}),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
let changed = false
|
|
372
|
+
const nextSchemas = { ...schemas.value }
|
|
373
|
+
for (const [key, schema] of Object.entries(schemas.value)) {
|
|
374
|
+
const shared: Array<{ index: number; rank: number }> = []
|
|
375
|
+
const classOnly: number[] = []
|
|
376
|
+
for (const index of schema.groupBy) {
|
|
377
|
+
const identity = fieldIdentity(schema, index)
|
|
378
|
+
const rank = identity === undefined ? undefined : desiredRank.get(identity)
|
|
379
|
+
if (rank === undefined) classOnly.push(index)
|
|
380
|
+
else shared.push({ index, rank })
|
|
381
|
+
}
|
|
382
|
+
shared.sort((left, right) => left.rank - right.rank)
|
|
383
|
+
const groupBy = [...shared.map(entry => entry.index), ...classOnly]
|
|
384
|
+
if (groupBy.every((index, position) => index === schema.groupBy[position])) continue
|
|
385
|
+
nextSchemas[key] = { ...schema, groupBy }
|
|
386
|
+
changed = true
|
|
387
|
+
}
|
|
388
|
+
if (changed) schemas.value = nextSchemas
|
|
389
|
+
}
|
|
390
|
+
|
|
286
391
|
function updateColumn(key: string, idx: number, patch: Partial<ColumnInfo>) {
|
|
287
392
|
const schema = schemas.value[key]
|
|
288
393
|
if (!schema) return
|
|
@@ -481,7 +586,9 @@ export function useAutoGroup() {
|
|
|
481
586
|
hasOutliers, outliers, conformingSamples, allSingletons,
|
|
482
587
|
canDownloadTemplate,
|
|
483
588
|
// Mutators
|
|
484
|
-
setClassDisposition, setClassDispositions, toggleGroupBy,
|
|
589
|
+
setClassDisposition, setClassDispositions, toggleGroupBy, moveGroupBy,
|
|
590
|
+
toggleGroupByField, moveGroupByField,
|
|
591
|
+
setRole, setColumnName, setValueOps, setBinning,
|
|
485
592
|
mergeColumns, loadFingerprint,
|
|
486
593
|
// Actions
|
|
487
594
|
parseInput, downloadTemplate, composeTemplateForTest, loadExperimentData, reset,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, toValue, type ComputedRef, type Ref } from 'vue'
|
|
2
2
|
import type { SampleGroup } from '../types'
|
|
3
3
|
import { deriveShade } from '../utils/color'
|
|
4
|
+
import { splitSampleGroupPath } from '../utils/sampleGroupPath'
|
|
4
5
|
|
|
5
6
|
export type SampleGroupSource<T> =
|
|
6
7
|
| T
|
|
@@ -21,6 +22,28 @@ export interface SampleMajorGroup {
|
|
|
21
22
|
allSamples: string[]
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* One node of the sample-group display hierarchy. Interior nodes are path
|
|
27
|
+
* prefixes of the group names (`Liver`, `Liver / High`); leaves carry the
|
|
28
|
+
* concrete group. A group's `name` stays the full path on its leaf because
|
|
29
|
+
* every mutation — drag, delete, colour, selection — resolves groups by name.
|
|
30
|
+
*/
|
|
31
|
+
export interface SampleGroupNode {
|
|
32
|
+
/** Unique across the tree; use for `:key` and expansion state. */
|
|
33
|
+
key: string
|
|
34
|
+
/** This node's own path segment, not the full path. */
|
|
35
|
+
label: string
|
|
36
|
+
color: string
|
|
37
|
+
displayBg: string
|
|
38
|
+
displayBorder: string
|
|
39
|
+
/** Every sample beneath this node, in group order. */
|
|
40
|
+
allSamples: string[]
|
|
41
|
+
/** Empty for leaves. */
|
|
42
|
+
children: SampleGroupNode[]
|
|
43
|
+
/** Present only on leaves. */
|
|
44
|
+
group?: DisplaySampleSubGroup
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
export interface UseSampleGroupsOptions {
|
|
25
48
|
groups: SampleGroupSource<readonly SampleGroup[]>
|
|
26
49
|
samples?: SampleGroupSource<readonly string[]>
|
|
@@ -31,6 +54,10 @@ export interface UseSampleGroupsOptions {
|
|
|
31
54
|
export interface UseSampleGroupsReturn {
|
|
32
55
|
groups: ComputedRef<SampleGroup[]>
|
|
33
56
|
separator: ComputedRef<string>
|
|
57
|
+
/** Full N-layer display hierarchy; one level per group-name segment. */
|
|
58
|
+
groupTree: ComputedRef<SampleGroupNode[]>
|
|
59
|
+
/** Deepest layer count across the tree (1 when groups are flat). */
|
|
60
|
+
maxDepth: ComputedRef<number>
|
|
34
61
|
hierarchicalGroups: ComputedRef<SampleMajorGroup[]>
|
|
35
62
|
showHierarchy: ComputedRef<boolean>
|
|
36
63
|
groupedSamples: ComputedRef<Set<string>>
|
|
@@ -50,48 +77,105 @@ export function useSampleGroups(options: UseSampleGroupsOptions): UseSampleGroup
|
|
|
50
77
|
return groups.value.some(group => group.name.includes('/')) ? '/' : '_'
|
|
51
78
|
})
|
|
52
79
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
80
|
+
// Groups are bucketed by their first segment (the major/family), then each
|
|
81
|
+
// family is expanded into a full prefix tree. Shades are assigned per family
|
|
82
|
+
// across its leaves so a family keeps one hue with a light→dark gradient,
|
|
83
|
+
// exactly as the two-level model did.
|
|
84
|
+
const families = computed(() => {
|
|
85
|
+
const byMajor = new Map<string, SampleGroup[]>()
|
|
57
86
|
for (const group of groups.value) {
|
|
58
87
|
const majorName = getMajorGroupName(group.name, separator.value)
|
|
59
|
-
const existing =
|
|
60
|
-
if (existing)
|
|
61
|
-
|
|
62
|
-
} else {
|
|
63
|
-
groupedByMajor.set(majorName, [group])
|
|
64
|
-
}
|
|
88
|
+
const existing = byMajor.get(majorName)
|
|
89
|
+
if (existing) existing.push(group)
|
|
90
|
+
else byMajor.set(majorName, [group])
|
|
65
91
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const color = subGroups[0]?.color || fallbackColor.value
|
|
69
|
-
const displaySubs = subGroups.map((subGroup, index) => {
|
|
70
|
-
const displayColor = deriveShade(color, index, subGroups.length)
|
|
71
|
-
return {
|
|
72
|
-
...subGroup,
|
|
73
|
-
displayColor,
|
|
74
|
-
displayBg: displayColor + '20',
|
|
75
|
-
displayBorder: displayColor + '40',
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
|
|
92
|
+
return [...byMajor.entries()].map(([name, members]) => {
|
|
93
|
+
const color = members[0]?.color || fallbackColor.value
|
|
79
94
|
return {
|
|
80
|
-
name
|
|
95
|
+
name,
|
|
81
96
|
color,
|
|
82
|
-
subGroups:
|
|
83
|
-
|
|
97
|
+
subGroups: members.map((group, index) => {
|
|
98
|
+
const displayColor = deriveShade(color, index, members.length)
|
|
99
|
+
return {
|
|
100
|
+
...group,
|
|
101
|
+
displayColor,
|
|
102
|
+
displayBg: displayColor + '20',
|
|
103
|
+
displayBorder: displayColor + '40',
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
84
106
|
}
|
|
85
107
|
})
|
|
86
108
|
})
|
|
87
109
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
const groupTree = computed<SampleGroupNode[]>(() => {
|
|
111
|
+
const roots: SampleGroupNode[] = []
|
|
112
|
+
|
|
113
|
+
for (const family of families.value) {
|
|
114
|
+
// Interior nodes are shared by path so siblings collapse into one parent;
|
|
115
|
+
// leaves are keyed separately so a group whose full path equals an
|
|
116
|
+
// interior path (e.g. "Liver" alongside "Liver / High") stays reachable
|
|
117
|
+
// instead of being swallowed by the prefix node.
|
|
118
|
+
const interior = new Map<string, SampleGroupNode>()
|
|
119
|
+
|
|
120
|
+
for (const subGroup of family.subGroups) {
|
|
121
|
+
const segments = splitSampleGroupPath(subGroup.name, separator.value)
|
|
122
|
+
const leafLabel = segments[segments.length - 1] ?? subGroup.name
|
|
123
|
+
let siblings = roots
|
|
124
|
+
let path = ''
|
|
125
|
+
|
|
126
|
+
for (const segment of segments.slice(0, -1)) {
|
|
127
|
+
path = path ? `${path}/${segment}` : segment
|
|
128
|
+
let node = interior.get(path)
|
|
129
|
+
if (!node) {
|
|
130
|
+
node = {
|
|
131
|
+
key: `node:${family.name}/${path}`,
|
|
132
|
+
label: segment,
|
|
133
|
+
color: family.color,
|
|
134
|
+
displayBg: family.color + '20',
|
|
135
|
+
displayBorder: family.color + '40',
|
|
136
|
+
allSamples: [],
|
|
137
|
+
children: [],
|
|
138
|
+
}
|
|
139
|
+
interior.set(path, node)
|
|
140
|
+
siblings.push(node)
|
|
141
|
+
}
|
|
142
|
+
node.allSamples.push(...subGroup.samples)
|
|
143
|
+
siblings = node.children
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
siblings.push({
|
|
147
|
+
key: `leaf:${subGroup.name}`,
|
|
148
|
+
label: leafLabel,
|
|
149
|
+
color: subGroup.displayColor,
|
|
150
|
+
displayBg: subGroup.displayBg,
|
|
151
|
+
displayBorder: subGroup.displayBorder,
|
|
152
|
+
allSamples: [...subGroup.samples],
|
|
153
|
+
children: [],
|
|
154
|
+
group: subGroup,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return roots
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const maxDepth = computed(() => {
|
|
163
|
+
const depthOf = (nodes: SampleGroupNode[]): number =>
|
|
164
|
+
nodes.reduce((deepest, node) => Math.max(deepest, 1 + depthOf(node.children)), 0)
|
|
165
|
+
return depthOf(groupTree.value)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const hierarchicalGroups = computed<SampleMajorGroup[]>(() =>
|
|
169
|
+
families.value.map(family => ({
|
|
170
|
+
name: family.name,
|
|
171
|
+
color: family.color,
|
|
172
|
+
subGroups: family.subGroups,
|
|
173
|
+
allSamples: family.subGroups.flatMap(group => group.samples),
|
|
174
|
+
})),
|
|
93
175
|
)
|
|
94
176
|
|
|
177
|
+
const showHierarchy = computed(() => maxDepth.value > 1)
|
|
178
|
+
|
|
95
179
|
const groupedSamples = computed(() =>
|
|
96
180
|
new Set(groups.value.flatMap(group => group.samples))
|
|
97
181
|
)
|
|
@@ -111,6 +195,8 @@ export function useSampleGroups(options: UseSampleGroupsOptions): UseSampleGroup
|
|
|
111
195
|
return {
|
|
112
196
|
groups,
|
|
113
197
|
separator,
|
|
198
|
+
groupTree,
|
|
199
|
+
maxDepth,
|
|
114
200
|
hierarchicalGroups,
|
|
115
201
|
showHierarchy,
|
|
116
202
|
groupedSamples,
|
|
@@ -121,6 +207,5 @@ export function useSampleGroups(options: UseSampleGroupsOptions): UseSampleGroup
|
|
|
121
207
|
}
|
|
122
208
|
|
|
123
209
|
function getMajorGroupName(groupName: string, separator: string): string {
|
|
124
|
-
|
|
125
|
-
return parts.length > 1 ? parts[0] : groupName
|
|
210
|
+
return splitSampleGroupPath(groupName, separator)[0]
|
|
126
211
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
/* Pill trigger — avatar circle + username text on the right. Matches the
|
|
9
|
-
|
|
9
|
+
shared TopBar account-trigger convention (GitHub/Linear-style). */
|
|
10
10
|
.mint-avatar-menu__trigger {
|
|
11
11
|
display: inline-flex;
|
|
12
12
|
align-items: center;
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
align-items: center;
|
|
40
40
|
justify-content: center;
|
|
41
41
|
font-size: 0.75rem;
|
|
42
|
+
line-height: 1;
|
|
42
43
|
font-weight: 500;
|
|
43
44
|
color: white;
|
|
44
45
|
background: linear-gradient(135deg, var(--color-primary-hover) 0%, var(--color-primary) 100%);
|
|
@@ -51,19 +51,6 @@
|
|
|
51
51
|
box-shadow: 0 0 0 3px var(--color-primary-soft);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
.mint-plugin-switcher__swatch {
|
|
55
|
-
width: 1.5rem;
|
|
56
|
-
height: 1.5rem;
|
|
57
|
-
border-radius: 0.375rem;
|
|
58
|
-
color: white;
|
|
59
|
-
display: grid;
|
|
60
|
-
place-items: center;
|
|
61
|
-
font-family: var(--font-mono, ui-monospace, monospace);
|
|
62
|
-
font-weight: 700;
|
|
63
|
-
font-size: 0.65625rem;
|
|
64
|
-
flex-shrink: 0;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
54
|
.mint-plugin-switcher__label {
|
|
68
55
|
font-weight: 600;
|
|
69
56
|
color: var(--text-primary);
|