/** * Pure adapters mapping the `useAutoGroup` engine state to the controlled * Smart Group UI (`SmartGroupFieldRecipe` / `SmartGroupManual`) and back to an * `AutoGroupResult`. Framework-free so the mapping is unit-testable in isolation * from `AutoGroupModal.vue`. */ import type { AutoGroupResult, ClassSchema, ColumnInfo, ColumnRole, SampleClass } from '../types/auto-group' import type { SampleGroup } from '../types' import type { FieldRecipeGroup, QcRoute, SmartGroupField } from './SmartGroupFieldRecipe.groups' import type { ManualSample } from './SmartGroupManual.cohorts' import type { FieldRecipeAnatomyToken, FieldRecipeQcChip, SmartGroupSeed } from './SmartGroup.types' import { GROUPING_FIELD_ROLES, classKey, isExperimentalKind } from '../composables/autoGroup' import type { ExperimentHeader } from '../composables/autoGroup' const PREVIEW_VALUES = 3 /** Value preview of a field card: the first three values, then the count of the rest. */ export function previewValues(values: string[]): string { const shown = values.slice(0, PREVIEW_VALUES).join(', ') return values.length > PREVIEW_VALUES ? `${shown} … +${values.length - PREVIEW_VALUES}` : shown } /** Experiment line of the modal header: `exp157 · 2025-10-13 · LT`. */ export function experimentLabel(header: ExperimentHeader): string { return [header.code, header.date, header.operator].filter(Boolean).join(' · ') } /** * Names of all columns of the experimental classes. A field cannot take one * of them: the engine merges fields by name. */ export function engineColumnNames(schemas: Record): string[] { const names = Object.values(schemas) .filter(schema => isExperimentalKind(schema.classKind)) .flatMap(schema => schema.columns.map(column => column.displayName ?? column.name)) return [...new Set(names)] } /** One chip for each QC-type class, with its sample count. */ export function engineQcChips(classes: SampleClass[]): FieldRecipeQcChip[] { return classes .filter(c => !isExperimentalKind(c.kind)) .map(c => ({ name: c.label, count: c.members.length })) } /** * The tokens of one study sample with the role the engine gave each of them, * so the user sees how the names were read. The sample is the first member of * the largest experimental class. `cards` supplies the on/off state and the * layer of the tokens that are grouping fields. */ export function engineAnatomy( classes: SampleClass[], schemas: Record, tokenized: string[][], cards: { fields: SmartGroupField[]; layers: (number | null)[] }, ): FieldRecipeAnatomyToken[] { const cls = classes .filter(c => isExperimentalKind(c.kind) && schemas[classKey(c)]) .sort((a, b) => b.members.length - a.members.length)[0] if (!cls) return [] const tokens = tokenized[cls.members[0]] ?? [] return schemas[classKey(cls)].columns.flatMap((column): FieldRecipeAnatomyToken[] => { const text = column.sourceIndices.map(index => tokens[index] ?? '').join('_') if (!text) return [] const name = column.displayName ?? column.name const card = cards.fields.findIndex(field => field.key === name) if (card === -1) { // A token that is not a field shows an inferred name only when the // name says something (Date, Injection #); `Username` or `Matrix 2` on a // constant token does not. const caption = /^(Field \d+|Username.*|.* \d+)$/.test(name) ? roleLabel(column.role).toLowerCase() : name return [{ text, caption, state: 'fixed' }] } const layer = cards.layers[card] return [layer === null ? { text, caption: name, state: 'off' } : { text, caption: String(layer), state: 'on' }] }) } const ROLE_LABELS: Record = { 'constant': 'Constant', 'factor': 'Factor', 'replicate': 'Replicate', 'run-order': 'Run', 'id': 'Sample ID', 'numeric': 'Numeric', 'class-tag': 'Class tag', 'ignore': 'Ignore', } /** Human label for a column role; defaults to `Factor` when unset. */ export function roleLabel(role?: ColumnRole): string { return role ? ROLE_LABELS[role] : 'Factor' } /** * Columns worth offering as grouping toggles: factors, numerics, replicates and * sample IDs (the last two are off by default), plus any column already enabled * in `groupBy` (so a manual group-by choice still shows). * Preserves the engine's column order. `groupBy` is the schema's ORDERED layer * list — entry N is hierarchy layer N of the composed group names. */ export function candidateColumns(columns: ColumnInfo[], groupBy: readonly number[]): ColumnInfo[] { const enabled = new Set(groupBy) return columns.filter(c => GROUPING_FIELD_ROLES.has(c.role) || enabled.has(c.index)) } /** * Map engine columns to Field Recipe cards + their on/off state (same order). * `layers` aligns with the cards: the column's 1-based position in the * `groupBy` layer order, or `null` when the column is not grouped by. */ export function engineFieldsToCards( columns: ColumnInfo[], groupBy: readonly number[], ): { fields: SmartGroupField[]; enabled: boolean[]; layers: (number | null)[] } { const candidates = candidateColumns(columns, groupBy) return { fields: candidates.map(c => ({ key: c.displayName ?? c.name, role: roleLabel(c.role), vals: previewValues(c.uniqueValues), rep: c.role === 'replicate' || c.role === 'id', })), enabled: candidates.map(c => groupBy.includes(c.index)), layers: candidates.map(c => { const pos = groupBy.indexOf(c.index) return pos === -1 ? null : pos + 1 }), } } /** * Build the batch-wide field recipe from every class schema. The active schema * stays first for a stable card order; fields and enabled hierarchy layers that * only exist in another token-length/class split are appended instead of being * hidden from the controlled UI. */ export function engineSchemasToCards( schemas: Record, activeClassKey: string, ): { fields: SmartGroupField[]; enabled: boolean[]; layers: (number | null)[] } { const entries = Object.entries(schemas) const active = entries.find(([key]) => key === activeClassKey) const ordered = active ? [active, ...entries.filter(([key]) => key !== activeClassKey)] : entries const candidates = new Map() const enabledOrder: string[] = [] const enabled = new Set() for (const [, schema] of ordered) { // An overlay class (QC, blank, standard) is one group; its columns are not fields. if (!isExperimentalKind(schema.classKind)) continue for (const index of schema.groupBy) { const column = schema.columns.find(candidate => candidate.index === index) if (!column) continue const identity = column.displayName ?? column.name if (!enabled.has(identity)) { enabled.add(identity) enabledOrder.push(identity) } } for (const column of candidateColumns(schema.columns, schema.groupBy)) { const identity = column.displayName ?? column.name if (!candidates.has(identity)) candidates.set(identity, column) } } const cards = [...candidates.entries()] return { fields: cards.map(([key, column]) => ({ key, role: roleLabel(column.role), vals: previewValues(column.uniqueValues), rep: column.role === 'replicate' || column.role === 'id', })), enabled: cards.map(([key]) => enabled.has(key)), layers: cards.map(([key]) => { const position = enabledOrder.indexOf(key) return position === -1 ? null : position + 1 }), } } /** Map engine sample groups to the live preview rail shape. */ export function engineGroupsToRail(groups: SampleGroup[]): FieldRecipeGroup[] { return groups.map(g => ({ name: g.name, count: g.samples.length, color: g.color })) } const ROUTE_TO_ENGINE: Record = { 'Overlay only': 'overlay', 'Exclude': 'exclude', 'Mix into groups': 'mix', } const ENGINE_TO_ROUTE: Record<'overlay' | 'exclude' | 'mix', QcRoute> = { overlay: 'Overlay only', exclude: 'Exclude', mix: 'Mix into groups', } /** Map the Field Recipe QC label to the engine's class disposition routing. */ export function qcRouteToEngine(route: QcRoute): 'overlay' | 'exclude' | 'mix' { return ROUTE_TO_ENGINE[route] } /** Map the engine's QC routing back to the Field Recipe label. */ export function engineToQcRoute(routing: 'overlay' | 'exclude' | 'mix'): QcRoute { return ENGINE_TO_ROUTE[routing] } /** * Seed the manual cohort builder from existing groups. The hierarchical group * name (`group/sub/sub2`) splits into the three cohort levels; colour carries * over. One seed entry per sample. Names with more than three `/`-segments keep * the remainder in `sub2` (rejoined with `/`) so the round-trip is lossless — * `manualSamplesToResult` re-joins on `/`. */ export function seedFromGroups(groups: SampleGroup[]): Record { const seed: Record = {} for (const group of groups) { const parts = group.name.split('/') const grp = parts[0] const sub = parts[1] const sub2 = parts.length > 3 ? parts.slice(2).join('/') : parts[2] for (const sample of group.samples) { seed[sample] = { group: grp, sub: sub || undefined, sub2: sub2 || undefined, color: group.color, } } } return seed } /** * Convert the manual cohort assignments back into an `AutoGroupResult`. * `baseGroups` (the modal's incoming `groups` prop) are preserved in their * original order and colour — including any that end up empty — so re-opening * the modal and moving samples between cohorts is non-destructive. Newly created * cohorts are appended in first-seen order. */ export function manualSamplesToResult( samples: ManualSample[], baseGroups: SampleGroup[] = [], ): AutoGroupResult { const assigned = new Map() const order: string[] = [] for (const sample of samples) { if (!sample.group) continue const name = [sample.group, sample.sub, sample.sub2].filter(Boolean).join('/') if (!assigned.has(name)) { assigned.set(name, { color: sample.color ?? '', samples: [] }) order.push(name) } assigned.get(name)!.samples.push(sample.name) } const groups: SampleGroup[] = [] const consumed = new Set() for (const base of baseGroups) { consumed.add(base.name) groups.push({ name: base.name, color: base.color, samples: assigned.get(base.name)?.samples ?? [] }) } for (const name of order) { if (consumed.has(name)) continue const entry = assigned.get(name)! groups.push({ name, color: entry.color, samples: entry.samples }) } return { groups, experimentalGroups: groups, qcGroups: [], metadata: [], excludedSamples: [] } }