/** * Pure grouping logic for SmartGroupFieldRecipe (the "auto" / Field Recipe mode). * * Mirrors the design prototype's live grouping engine: groups are formed by * joining the values of whichever fields are switched on, in field order. * Kept framework-free so it is unit-testable in isolation from the component. */ /** A field/factor the user can group by. */ export interface SmartGroupField { /** Column key as it appears in each sample record. */ key: string /** Display role, e.g. `Factor` or `Replicate`. */ role: string /** Comma-separated preview of the field's distinct values. */ vals: string /** Whether this field is a replicate dimension (grouping by it shatters groups). */ rep: boolean } /** A single sample as a flat key → value record. */ export type SmartGroupSampleRecord = Record /** A resolved group in the live preview rail. */ export interface FieldRecipeGroup { name: string count: number color: string } /** Name of the QC pool group appended by `appendQcGroup`. */ export const QC_POOL_NAME = 'QC_Pool' /** How QC samples are routed relative to the biological groups. */ export type QcRoute = 'Overlay only' | 'Exclude' | 'Mix into groups' /** * Kind of inline summary callout to render. `warn`: a replicate field is on. * `singletons`: no replicate field is on, but half or more of the groups have one sample. */ export type FieldRecipeSummaryKind = 'mute' | 'warn' | 'singletons' | 'info' export interface FieldRecipeSummary { kind: FieldRecipeSummaryKind /** Number of base (non-QC) groups the current toggles produce. */ groupCount: number } /** * Build the ordered group list from whichever fields are switched on. * Returns an empty list when no field is active. `layerOrder` (field indices * in layer order) overrides the default field-order joining of the group name. */ export function computeFieldGroups( samples: SmartGroupSampleRecord[], fields: SmartGroupField[], on: boolean[], palette: string[], layerOrder?: readonly number[], ): FieldRecipeGroup[] { const keys = layerOrder ? layerOrder.filter(i => on[i]).map(i => fields[i].key) : fields.filter((_, i) => on[i]).map(field => field.key) if (keys.length === 0) return [] const order: string[] = [] const counts = new Map() for (const sample of samples) { const name = keys.map(key => sample[key]).join('_') if (!counts.has(name)) { counts.set(name, 0) order.push(name) } counts.set(name, (counts.get(name) ?? 0) + 1) } return order.map((name, index) => ({ name, count: counts.get(name) ?? 0, color: palette[index % palette.length], })) } /** * Append the QC pool group when QC is mixed into the biological groups. * `Overlay only` and `Exclude` leave the group list untouched. */ export function appendQcGroup( groups: FieldRecipeGroup[], route: QcRoute, qcColor: string, qcCount = 3, ): FieldRecipeGroup[] { if (route === 'Mix into groups') { return [...groups, { name: QC_POOL_NAME, count: qcCount, color: qcColor }] } return groups } /** Total samples the grouping applies to: QC is dropped only when excluded. */ export function sampleTotalFor(route: QcRoute, realCount = 9, qcCount = 3): number { return route === 'Exclude' ? realCount : realCount + qcCount } /** * True when half or more of the groups have one sample: the design has no * replicate in the names, or a field that is on is not part of the condition. */ export function mostlySingletons(groups: FieldRecipeGroup[]): boolean { return groups.length > 1 && groups.filter(group => group.count === 1).length * 2 >= groups.length } /** Decide which inline summary callout the Field Recipe should show. */ export function fieldRecipeSummary(input: { activeFactors: string[] repOn: boolean baseGroups: FieldRecipeGroup[] }): FieldRecipeSummary { const groupCount = input.baseGroups.length if (input.activeFactors.length === 0) return { kind: 'mute', groupCount } if (input.repOn) return { kind: 'warn', groupCount } if (mostlySingletons(input.baseGroups)) return { kind: 'singletons', groupCount } return { kind: 'info', groupCount } }