import { computed, ref } from 'vue' import type { AutoGroupResult, ClassDisposition, ClassSchema, ColumnInfo, ColumnRole, InputMode, MergeSuggestion, NumericBinning, OutlierAction, OutlierInfo, ParsedCsvData, SampleClass, SchemaFingerprint, ValueOps, } from '../types/auto-group' import { GROUPING_FIELD_ROLES, buildClassSchema, classKey, composeGroups, composeTemplate, detectClass, findMerges, isExperimentalKind, restoreFingerprint, serializeFingerprint, splitByTokenLength, splitColumns, tokenizeBatch, type Delimiter, type ExperimentHeader, type TemplateOptions, } from './autoGroup' import { unwrapExperimentDesignData } from './experimentDesignData' export { DEFAULT_COLORS } from './autoGroup' const STRUCTURED_MISSING_GROUP = 'Outlier' type ParsedExperimentCsvData = ParsedCsvData & { sampleTypeHints: (string | undefined)[] } interface LoadExperimentDataOptions { samples?: readonly string[] } let deprecationWarned = false let outlierDeprecationWarned = false function warnDeprecated(api: string) { if (deprecationWarned) return if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') return console.warn( `[useAutoGroup] ${api} is deprecated; use the new class-first API (setClassDisposition / toggleGroupBy / setValueOps).`, ) deprecationWarned = true } // Re-export `parseCSV` for callers that imported it from this file. export { parseCSV } from './autoGroup/csv-shim' /** * Sample-grouping engine behind `AutoGroupModal`. * * @deprecated Scheduled for removal in MINT 1.4; no SDK replacement. */ export function useAutoGroup() { const inputMode = ref('paste') const rawText = ref('') const csvData = ref(null) const sampleTypeHints = ref<(string | undefined)[]>([]) const classes = ref([]) const schemas = ref>({}) const activeClassKey = ref('') const samples = computed(() => { if (csvData.value) { return csvData.value.rows.map(r => r[csvData.value!.sampleColumn] ?? '') } return rawText.value.split('\n').map(l => l.trim()).filter(l => l.length > 0) }) const tokenized = ref([]) const delimiter = ref('_') // Experiment code / date / operator that all pasted names share. Empty for // CSV and experiment input. const experiment = ref({}) function parseInput() { if ((inputMode.value === 'csv' || inputMode.value === 'experiment') && csvData.value) { parseInputFromCsv() return } parseInputFromPaste() } function parseInputFromCsv() { experiment.value = {} const csv = csvData.value! const nonSampleCols = csv.columns.filter(c => c !== csv.sampleColumn && c !== 'sample_type') if (nonSampleCols.length === 0) { // No metadata columns — degenerate, single Unknown class tokenized.value = csv.rows.map(() => []) classes.value = [{ kind: 'unknown', label: 'Unknown', members: csv.rows.map((_, i) => i), classTagPositions: [], disposition: 'group', }] schemas.value = { unknown: { classKind: 'unknown', columns: [], groupBy: [] } } activeClassKey.value = 'unknown' return } const tokens = csv.rows.map(r => nonSampleCols.map(col => r[col] ?? '')) tokenized.value = tokens const structuredClass: SampleClass = { kind: 'unknown', label: 'Unknown', members: csv.rows.map((_, i) => i), classTagPositions: [], disposition: 'group', } classes.value = [structuredClass] const schema = buildClassSchema(tokens, 'unknown', undefined, []) schema.columns = schema.columns.map((c, i) => ({ ...c, name: nonSampleCols[i] ?? c.name, originalName: nonSampleCols[i], })) // CSV headers are already the user-authored hierarchy. Default to every // metadata column so N CSV columns produce N visible grouping levels. schema.groupBy = schema.columns.map(c => c.index) schemas.value = { unknown: schema } activeClassKey.value = 'unknown' } function parseInputFromPaste() { const lines = samples.value if (lines.length === 0) { classes.value = [] schemas.value = {} experiment.value = {} return } // One token row for each sample. Replicate tokens, sample numbers and the // injection number stay in the row and get a column role below // ('replicate', 'id', 'run-order'), which keeps them out of the default // group key. Nothing is removed from a name before the user sees it. const batch = tokenizeBatch(lines) delimiter.value = batch.delimiter experiment.value = batch.experiment const tokens = batch.rows // Classify by sample type, THEN split each experimental class by token // length so every member of a class shares the same field count. This // keeps the per-class token matrix rectangular, so column index N means // the same field for every sample. const detected = splitByTokenLength( detectClass(tokens, { sampleTypeHints: sampleTypeHints.value, sharedPrefix: batch.sharedPrefix }), tokens, ) const newSchemas: Record = {} for (const cls of detected) { let memberTokens = cls.members.map(i => tokens[i]) let tailPositions: number[] = [] const experimental = isExperimentalKind(cls.kind) if (experimental) { // Tokens that hold two fields (`2-1`, `C1`) become two columns. const split = splitColumns(memberTokens) cls.members.forEach((member, row) => { tokens[member] = split.rows[row] }) cls.classTagPositions = split.origin.flatMap((source, index) => cls.classTagPositions.includes(source) ? [index] : [], ) memberTokens = split.rows tailPositions = split.tail } const schema = buildClassSchema( memberTokens, cls.kind, cls.subKind, cls.classTagPositions, tailPositions, ) // A QC-kind class is one group named by the class label, also when the // user routes it into the groups. if (!experimental) schema.groupBy = [] newSchemas[classKey(cls)] = { ...schema, tokenLength: cls.tokenLength } } tokenized.value = tokens classes.value = detected schemas.value = newSchemas if (detected.length > 0) { activeClassKey.value = classKey(detected[0]) } } const activeSchema = computed(() => schemas.value[activeClassKey.value] ?? null) const suggestions = computed(() => { const schema = activeSchema.value if (!schema) return [] const activeClass = classes.value.find( c => classKey(c) === activeClassKey.value, ) if (!activeClass) return [] const memberTokens = activeClass.members.map(i => tokenized.value[i]) return findMerges(schema, memberTokens) }) function composeWith(withSchemas: Record): AutoGroupResult { return composeGroups({ tokenizedSamples: tokenized.value, sampleNames: samples.value, schemas: withSchemas, classes: classes.value, prefixClassLabels: !(csvData.value && (inputMode.value === 'csv' || inputMode.value === 'experiment')), missingGroupName: csvData.value && (inputMode.value === 'csv' || inputMode.value === 'experiment') ? STRUCTURED_MISSING_GROUP : undefined, }) } const result = computed(() => composeWith(schemas.value)) const fingerprint = computed(() => serializeFingerprint(result.value.schemas ?? []), ) const groups = computed(() => result.value.groups) const qcGroups = computed(() => result.value.qcGroups ?? []) const metadata = computed(() => result.value.metadata) const excludedSamples = computed(() => result.value.excludedSamples) // ----- Mutators ----- function setClassDisposition(key: string, disposition: ClassDisposition) { const target = classes.value.find(c => classKey(c) === key) if (!target || target.disposition === disposition) return classes.value = classes.value.map(c => classKey(c) === key ? { ...c, disposition } : c, ) } function setClassDispositions( predicate: (c: SampleClass) => boolean, disposition: ClassDisposition, ) { let changed = false const next = classes.value.map(c => { if (predicate(c) && c.disposition !== disposition) { changed = true return { ...c, disposition } } return c }) if (changed) classes.value = next } // `groupBy` is an ORDERED layer list: entry N is hierarchy layer N of the // composed group names (layer 1 = the sample selector's major group). A // column toggled back on therefore appends as the LAST layer rather than // returning to its token position. function toggleGroupBy(key: string, idx: number) { const schema = schemas.value[key] if (!schema) return const groupBy = schema.groupBy.includes(idx) ? schema.groupBy.filter(i => i !== idx) : [...schema.groupBy, idx] schemas.value = { ...schemas.value, [key]: { ...schema, groupBy }, } } /** Move an enabled column one step up (toward layer 1) or down in the layer order. */ function moveGroupBy(key: string, idx: number, direction: 'up' | 'down') { const schema = schemas.value[key] if (!schema) return const from = schema.groupBy.indexOf(idx) if (from === -1) return const to = direction === 'up' ? from - 1 : from + 1 if (to < 0 || to >= schema.groupBy.length) return const groupBy = [...schema.groupBy] ;[groupBy[from], groupBy[to]] = [groupBy[to], groupBy[from]] schemas.value = { ...schemas.value, [key]: { ...schema, groupBy }, } } // The Field Recipe presents ONE set of grouping fields for the whole batch, // but classes each own a schema — so a field edit must reach every class that // exposes that field, or a multi-class batch ends up with divergent layer // orders (half the groups reordered). Fields are matched by display identity // rather than column index, since a class split off by token length can carry // the same field at a different position. function schemasExposing(fieldKey: string): { key: string; index: number }[] { return Object.entries(schemas.value).flatMap(([key, schema]) => { // An overlay class is one group; its columns are not grouping fields. if (!isExperimentalKind(schema.classKind)) return [] const col = schema.columns.find(c => (c.displayName ?? c.name) === fieldKey && (GROUPING_FIELD_ROLES.has(c.role) || schema.groupBy.includes(c.index)), ) return col ? [{ key, index: col.index }] : [] }) } function fieldIdentity(schema: ClassSchema, index: number): string | undefined { const column = schema.columns.find(candidate => candidate.index === index) return column ? (column.displayName ?? column.name) : undefined } /** * Toggle a grouping field across every class exposing it. The batch card is * on when any schema groups by the field, so clicking it disables all such * schemas; only a fully disabled field is enabled across the batch. */ function toggleGroupByField(fieldKey: string) { const targets = schemasExposing(fieldKey) if (targets.length === 0) return const enable = !targets.some(({ key, index }) => schemas.value[key]?.groupBy.includes(index)) for (const { key, index } of targets) { const schema = schemas.value[key] if (!schema || schema.groupBy.includes(index) === enable) continue toggleGroupBy(key, index) } } /** * Rename a grouping field in every class exposing it. A blank name, or a * name that a column of any experimental class has, changes nothing: the * name is the field's identity across classes, and the cards merge by it. */ function renameGroupByField(fieldKey: string, name: string) { const next = name.trim() const targets = schemasExposing(fieldKey) if (!next || next === fieldKey || targets.length === 0) return const taken = Object.values(schemas.value).some(schema => isExperimentalKind(schema.classKind) && schema.columns.some(c => (c.displayName ?? c.name) === next), ) if (taken) return for (const { key, index } of targets) setColumnName(key, index, next) } /** Number of experimental groups that remain when the field is switched off in every class. */ function groupCountWithout(fieldKey: string): number { const targets = new Map(schemasExposing(fieldKey).map(({ key, index }) => [key, index])) const without = Object.fromEntries( Object.entries(schemas.value).map(([key, schema]) => [ key, targets.has(key) ? { ...schema, groupBy: schema.groupBy.filter(i => i !== targets.get(key)) } : schema, ]), ) return composeWith(without).experimentalGroups?.length ?? 0 } /** * Move a grouping field in the active schema, then project that visible field * order across every class. Class-only fields keep their relative order after * the shared fields because the Field Recipe has no card with which to place * them explicitly. */ function moveGroupByField(fieldKey: string, direction: 'up' | 'down') { const targets = schemasExposing(fieldKey) if (targets.length === 0) return const active = targets.find(target => target.key === activeClassKey.value && schemas.value[target.key]?.groupBy.includes(target.index), ) ?? targets.find(target => schemas.value[target.key]?.groupBy.includes(target.index)) ?? targets[0] const activeSchema = schemas.value[active.key] if (!activeSchema) return const from = activeSchema.groupBy.indexOf(active.index) if (from === -1) return const to = direction === 'up' ? from - 1 : from + 1 if (to < 0 || to >= activeSchema.groupBy.length) return const activeOrder = [...activeSchema.groupBy] ;[activeOrder[from], activeOrder[to]] = [activeOrder[to], activeOrder[from]] const desiredRank = new Map( activeOrder.flatMap((index, rank) => { const identity = fieldIdentity(activeSchema, index) return identity === undefined ? [] : [[identity, rank] as const] }), ) let changed = false const nextSchemas = { ...schemas.value } for (const [key, schema] of Object.entries(schemas.value)) { const shared: Array<{ index: number; rank: number }> = [] const classOnly: number[] = [] for (const index of schema.groupBy) { const identity = fieldIdentity(schema, index) const rank = identity === undefined ? undefined : desiredRank.get(identity) if (rank === undefined) classOnly.push(index) else shared.push({ index, rank }) } shared.sort((left, right) => left.rank - right.rank) const groupBy = [...shared.map(entry => entry.index), ...classOnly] if (groupBy.every((index, position) => index === schema.groupBy[position])) continue nextSchemas[key] = { ...schema, groupBy } changed = true } if (changed) schemas.value = nextSchemas } function updateColumn(key: string, idx: number, patch: Partial) { const schema = schemas.value[key] if (!schema) return const col = schema.columns.find(c => c.index === idx) if (!col) return let changed = false for (const k of Object.keys(patch) as (keyof ColumnInfo)[]) { if ((col as unknown as Record)[k as string] !== (patch as unknown as Record)[k as string]) { changed = true break } } if (!changed) return schemas.value = { ...schemas.value, [key]: { ...schema, columns: schema.columns.map(c => (c.index === idx ? { ...c, ...patch } : c)), }, } } function setRole(key: string, idx: number, role: ColumnRole) { updateColumn(key, idx, { role }) } function setColumnName(key: string, idx: number, name: string) { updateColumn(key, idx, { displayName: name }) } function setValueOps(key: string, idx: number, ops: ValueOps) { updateColumn(key, idx, { ops }) } function setBinning(key: string, idx: number, binning: NumericBinning) { updateColumn(key, idx, { binning }) } function mergeColumns(key: string, indices: number[]) { const schema = schemas.value[key] if (!schema || indices.length < 2) return const sorted = [...indices].sort((a, b) => a - b) const start = sorted[0] const merged: ColumnInfo = { index: start, name: `Column ${sorted.map(i => i + 1).join('–')}`, sourceIndices: sorted.flatMap(i => schema.columns.find(c => c.index === i)?.sourceIndices ?? [i]), uniqueValues: [], cardinality: 0, role: 'factor', } const dropped = new Set(sorted) schemas.value = { ...schemas.value, [key]: { ...schema, columns: [merged, ...schema.columns.filter(c => !dropped.has(c.index))] .sort((a, b) => a.index - b.index), groupBy: schema.groupBy.filter(i => !dropped.has(i) || i === start), }, } } function loadFingerprint(fp: SchemaFingerprint) { const restored = restoreFingerprint(fp, Object.values(schemas.value)) const next: Record = {} for (const s of restored) { next[classKey({ kind: s.classKind, subKind: s.subKind, tokenLength: s.tokenLength })] = s } schemas.value = next } // ----- Template download ----- const canDownloadTemplate = computed(() => samples.value.length > 0) function downloadTemplate(mode: 'blank' | 'prefilled' = 'prefilled', format: 'csv' | 'tsv' = 'csv') { const { content, filename } = composeTemplate( samples.value, mode === 'prefilled' ? Object.values(schemas.value) : null, mode === 'prefilled' ? classes.value : null, { mode, format, tokenizedSamples: tokenized.value }, ) const blob = new Blob([content], { type: format === 'tsv' ? 'text/tab-separated-values' : 'text/csv' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } /** Test seam — exposes the template content/filename without triggering a browser download. */ function composeTemplateForTest(opts: Pick) { return composeTemplate( samples.value, opts.mode === 'prefilled' ? Object.values(schemas.value) : null, opts.mode === 'prefilled' ? classes.value : null, { ...opts, tokenizedSamples: tokenized.value }, ) } // ----- Legacy public refs and shims ----- const outliers = ref([]) const hasOutliers = computed(() => outliers.value.length > 0) const conformingSamples = computed(() => samples.value) const dominantFieldCount = computed(() => tokenized.value[0]?.length ?? 0) const minFieldCount = computed(() => { if (tokenized.value.length === 0) return 0 return Math.min(...tokenized.value.map(t => t.length)) }) const fields = computed(() => activeSchema.value?.columns ?? []) const fieldNames = computed>(() => { const out: Record = {} for (const c of fields.value) out[c.index] = c.displayName ?? c.name return out }) const enabledFields = computed>(() => activeSchema.value ? new Set(activeSchema.value.groupBy) : new Set(), ) const effectiveColumns = fields const allSingletons = computed( () => groups.value.length > 1 && groups.value.every(g => g.samples.length === 1), ) function setOutlierAction(_: number, __: OutlierAction) { if (!outlierDeprecationWarned) { if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') { console.warn( `[useAutoGroup] setOutlierAction is deprecated AND inactive in the class-first redesign. ` + `Use setClassDisposition('iqc' | 'eqc' | …, 'group' | 'overlay' | 'exclude') instead.`, ) outlierDeprecationWarned = true } } } function setAllOutlierActions(__: OutlierAction) { if (!outlierDeprecationWarned) { if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') { console.warn( `[useAutoGroup] setAllOutlierActions is deprecated AND inactive in the class-first redesign. ` + `Use setClassDisposition('iqc' | 'eqc' | …, 'group' | 'overlay' | 'exclude') instead.`, ) outlierDeprecationWarned = true } } } function toggleField(index: number) { warnDeprecated('toggleField') if (activeClassKey.value) toggleGroupBy(activeClassKey.value, index) } function renameField(index: number, name: string) { warnDeprecated('renameField') if (activeClassKey.value) setColumnName(activeClassKey.value, index, name) } function loadExperimentData( rawData: Record, options: LoadExperimentDataOptions = {}, ): boolean { const parsed = extractSamplesFromDesignData(rawData, options) if (!parsed) return false inputMode.value = 'experiment' csvData.value = parsed sampleTypeHints.value = parsed.sampleTypeHints parseInput() return true } function reset() { rawText.value = '' csvData.value = null classes.value = [] schemas.value = {} activeClassKey.value = '' tokenized.value = [] experiment.value = {} outliers.value = [] sampleTypeHints.value = [] } return { // State inputMode, rawText, csvData, sampleTypeHints, classes, schemas, activeClassKey, delimiter, experiment, dominantFieldCount, minFieldCount, // Computed samples, tokenized, activeSchema, suggestions, groups, qcGroups, metadata, excludedSamples, result, fingerprint, fields, fieldNames, enabledFields, effectiveColumns, hasOutliers, outliers, conformingSamples, allSingletons, canDownloadTemplate, // Mutators setClassDisposition, setClassDispositions, toggleGroupBy, moveGroupBy, toggleGroupByField, moveGroupByField, renameGroupByField, groupCountWithout, setRole, setColumnName, setValueOps, setBinning, mergeColumns, loadFingerprint, // Actions parseInput, downloadTemplate, composeTemplateForTest, loadExperimentData, reset, // Deprecated shims setOutlierAction, setAllOutlierActions, toggleField, renameField, } } /** * Reads experiment `design_data` samples into the `useAutoGroup` CSV shape. * * @deprecated Scheduled for removal in MINT 1.4 together with `useAutoGroup`. */ export function extractSamplesFromDesignData( rawData: Record, options: LoadExperimentDataOptions = {}, ): ParsedExperimentCsvData | null { const designData = unwrapExperimentDesignData(rawData) if (!designData) return null const samples = designData.samples if (!Array.isArray(samples) || samples.length === 0) return null const allConditionKeys: string[] = [] const keySet = new Set() const sampleTypeHints: (string | undefined)[] = [] for (const sample of samples) { const rawType = (sample as Record).sample_type sampleTypeHints.push(typeof rawType === 'string' ? rawType.toLowerCase() : undefined) const conditions = (sample as Record).conditions as Record | undefined if (conditions && typeof conditions === 'object') { for (const key of Object.keys(conditions)) { if (!keySet.has(key)) { keySet.add(key) allConditionKeys.push(key) } } } } if (allConditionKeys.length === 0) return null const columns = ['sample_name', ...allConditionKeys, 'sample_type'] const rows = (samples as Record[]).map(s => { const conditions = (s.conditions as Record) ?? {} const row: Record = { sample_name: String(s.sample_name ?? ''), sample_type: String(s.sample_type ?? 'sample'), } for (const key of allConditionKeys) row[key] = conditions[key] ?? '' return row }) return constrainParsedSamplesToInput( { columns, rows, sampleColumn: 'sample_name', delimiter: ',', sampleTypeHints }, options.samples, ) } function constrainParsedSamplesToInput( parsed: ParsedExperimentCsvData, samples: readonly string[] | undefined, ): ParsedExperimentCsvData { const requested = samples ?.map(sample => sample.trim()) .filter(sample => sample.length > 0) if (!requested || requested.length === 0) return parsed const rowBySample = new Map; hint: string | undefined }>() parsed.rows.forEach((row, index) => { const sample = row[parsed.sampleColumn] if (sample && !rowBySample.has(sample)) { rowBySample.set(sample, { row, hint: parsed.sampleTypeHints[index] }) } }) const metadataColumns = parsed.columns.filter( column => column !== parsed.sampleColumn && column !== 'sample_type', ) const rows: Record[] = [] const sampleTypeHints: (string | undefined)[] = [] for (const sample of requested) { const match = rowBySample.get(sample) if (match) { rows.push(match.row) sampleTypeHints.push(match.hint) continue } const outlierRow: Record = { [parsed.sampleColumn]: sample, sample_type: 'sample', } for (const column of metadataColumns) outlierRow[column] = '' rows.push(outlierRow) sampleTypeHints.push(undefined) } return { ...parsed, rows, sampleTypeHints } }