@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.
Files changed (91) hide show
  1. package/dist/__tests__/components/RetiredStyles.test.d.ts +1 -0
  2. package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
  4. package/dist/components/AppTopBar.vue.d.ts +6 -11
  5. package/dist/components/AutoGroupModal.adapter.d.ts +22 -7
  6. package/dist/components/PluginIcon.vue.d.ts +2 -2
  7. package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
  8. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
  9. package/dist/components/SampleSelector.groups.d.ts +7 -7
  10. package/dist/components/SampleSelector.selection.d.ts +6 -12
  11. package/dist/components/SampleSelector.tree.d.ts +39 -0
  12. package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
  13. package/dist/components/SmartGroup.types.d.ts +5 -0
  14. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
  15. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
  16. package/dist/components/index.js +1 -1
  17. package/dist/{components-CDomhGSj.js → components-HXV-ZkBg.js} +939 -948
  18. package/dist/components-HXV-ZkBg.js.map +1 -0
  19. package/dist/composables/index.d.ts +1 -1
  20. package/dist/composables/index.js +1 -1
  21. package/dist/composables/useAutoGroup.d.ts +3 -0
  22. package/dist/composables/usePlatformContext.d.ts +3 -0
  23. package/dist/composables/useSampleGroups.d.ts +25 -0
  24. package/dist/index.js +2 -2
  25. package/dist/install.js +1 -1
  26. package/dist/styles.css +130 -557
  27. package/dist/types/platform.d.ts +1 -0
  28. package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-D3IZKLqr.js} +198 -24
  29. package/dist/useProtocolTemplates-D3IZKLqr.js.map +1 -0
  30. package/dist/utils/pluginIcon.d.ts +1 -1
  31. package/dist/utils/sampleGroupPath.d.ts +6 -0
  32. package/package.json +1 -1
  33. package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
  34. package/src/__tests__/components/AppTopBar.test.ts +84 -10
  35. package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
  36. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +47 -7
  37. package/src/__tests__/components/PluginIcon.test.ts +19 -0
  38. package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
  39. package/src/__tests__/components/RetiredStyles.test.ts +82 -0
  40. package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
  41. package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
  42. package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
  43. package/src/__tests__/components/SampleSelector.test.ts +35 -0
  44. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
  45. package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
  46. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +278 -0
  47. package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
  48. package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
  49. package/src/components/AppTopBar.story.vue +9 -7
  50. package/src/components/AppTopBar.vue +79 -72
  51. package/src/components/AutoGroupModal.adapter.ts +69 -13
  52. package/src/components/AutoGroupModal.story.vue +89 -0
  53. package/src/components/AutoGroupModal.vue +18 -8
  54. package/src/components/PluginIcon.story.vue +11 -9
  55. package/src/components/PluginIcon.vue +3 -3
  56. package/src/components/PluginWorkspaceView.props.ts +5 -7
  57. package/src/components/PluginWorkspaceView.vue +0 -4
  58. package/src/components/SampleSelector.drag.ts +5 -5
  59. package/src/components/SampleSelector.groups.ts +15 -15
  60. package/src/components/SampleSelector.selection.ts +7 -46
  61. package/src/components/SampleSelector.story.vue +37 -0
  62. package/src/components/SampleSelector.tree.ts +48 -0
  63. package/src/components/SampleSelector.vue +52 -320
  64. package/src/components/SampleSelectorGroupNode.vue +197 -0
  65. package/src/components/SmartGroup.types.ts +5 -0
  66. package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
  67. package/src/components/SmartGroupFieldRecipe.vue +76 -4
  68. package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
  69. package/src/composables/autoGroup/compose.ts +31 -10
  70. package/src/composables/index.ts +1 -0
  71. package/src/composables/useAutoGroup.ts +112 -5
  72. package/src/composables/useSampleGroups.ts +118 -33
  73. package/src/styles/components/app-avatar-menu.css +2 -1
  74. package/src/styles/components/app-plugin-switcher.css +0 -13
  75. package/src/styles/components/app-top-bar.css +13 -54
  76. package/src/styles/components/dose-calculator.css +0 -4
  77. package/src/styles/components/experiment-popover.css +2 -104
  78. package/src/styles/components/experiment-selector-modal.css +0 -7
  79. package/src/styles/components/plugin-icon.css +3 -0
  80. package/src/styles/components/protocol-step-editor.css +0 -6
  81. package/src/styles/components/reagent-editor.css +0 -5
  82. package/src/styles/components/sample-hierarchy-tree.css +0 -4
  83. package/src/styles/components/sample-selector.css +0 -96
  84. package/src/styles/components/schedule-calendar.css +0 -6
  85. package/src/styles/components/smart-group.css +22 -0
  86. package/src/styles/components/well-plate.css +1 -27
  87. package/src/types/platform.ts +1 -0
  88. package/src/utils/pluginIcon.ts +5 -2
  89. package/src/utils/sampleGroupPath.ts +19 -0
  90. package/dist/components-CDomhGSj.js.map +0 -1
  91. 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` / `groups` / `route` + `qcChip` and the
164
- // `toggle` / `update:route` events drive the engine instead of this local state.
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
- const computedBase = computed(() => computeFieldGroups(props.samples, props.fields, on.value, props.palette))
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(() => props.fields.filter((_, i) => on.value[i]).map(field => field.key))
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="tinted"
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="tinted"
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 label to the factor-key parts so group names carry
129
- // class context (e.g. "Biological / Tissues / POS / 5min" instead of a
130
- // bare "5min" that collides with another class's "5min").
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 useClassPrefix =
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
- : useClassPrefix
148
- ? `${cls.label} / ${keyParts.join(' / ')}`
168
+ : prefix
169
+ ? `${prefix} / ${keyParts.join(' / ')}`
149
170
  : keyParts.length > 0
150
171
  ? keyParts.join(' / ')
151
172
  : cls.label
@@ -287,6 +287,7 @@ export {
287
287
  export {
288
288
  useSampleGroups,
289
289
  type DisplaySampleSubGroup,
290
+ type SampleGroupNode,
290
291
  type SampleGroupSource,
291
292
  type SampleMajorGroup,
292
293
  type UseSampleGroupsOptions,
@@ -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 set = new Set(schema.groupBy)
278
- if (set.has(idx)) set.delete(idx)
279
- else set.add(idx)
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: [...set].sort((a, b) => a - b) },
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, setRole, setColumnName, setValueOps, setBinning,
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
- const hierarchicalGroups = computed<SampleMajorGroup[]>(() => {
54
- if (groups.value.length === 0) return []
55
- const groupedByMajor = new Map<string, SampleGroup[]>()
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 = groupedByMajor.get(majorName)
60
- if (existing) {
61
- existing.push(group)
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
- return [...groupedByMajor.entries()].map(([majorName, subGroups]) => {
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: majorName,
95
+ name,
81
96
  color,
82
- subGroups: displaySubs,
83
- allSamples: subGroups.flatMap(group => group.samples),
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 showHierarchy = computed(() =>
89
- hierarchicalGroups.value.some(major =>
90
- major.subGroups.length > 1 ||
91
- (major.subGroups.length === 1 && major.name !== major.subGroups[0].name)
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
- const parts = groupName.split(separator)
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
- legacy `.mint-topbar__profile-btn` convention (GitHub/Linear-style). */
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);