import { computed, toValue, type ComputedRef, type Ref } from 'vue' import type { SampleGroup } from '../types' import { deriveShade } from '../utils/color' import { splitSampleGroupPath } from '../utils/sampleGroupPath' export type SampleGroupSource = | T | Ref | ComputedRef | (() => T) export interface DisplaySampleSubGroup extends SampleGroup { displayColor: string displayBg: string displayBorder: string } export interface SampleMajorGroup { name: string color: string subGroups: DisplaySampleSubGroup[] allSamples: string[] } /** * One node of the sample-group display hierarchy. Interior nodes are path * prefixes of the group names (`Liver`, `Liver / High`); leaves carry the * concrete group. A group's `name` stays the full path on its leaf because * every mutation — drag, delete, colour, selection — resolves groups by name. */ export interface SampleGroupNode { /** Unique across the tree; use for `:key` and expansion state. */ key: string /** This node's own path segment, not the full path. */ label: string color: string displayBg: string displayBorder: string /** Every sample beneath this node, in group order. */ allSamples: string[] /** Empty for leaves. */ children: SampleGroupNode[] /** Present only on leaves. */ group?: DisplaySampleSubGroup } export interface UseSampleGroupsOptions { groups: SampleGroupSource samples?: SampleGroupSource separator?: SampleGroupSource fallbackColor?: SampleGroupSource } export interface UseSampleGroupsReturn { groups: ComputedRef separator: ComputedRef /** Full N-layer display hierarchy; one level per group-name segment. */ groupTree: ComputedRef /** Deepest layer count across the tree (1 when groups are flat). */ maxDepth: ComputedRef hierarchicalGroups: ComputedRef showHierarchy: ComputedRef groupedSamples: ComputedRef> ungroupedSamples: ComputedRef findGroup: (groupName: string) => SampleGroup | undefined getGroupColor: (groupName: string) => string } /** Shared sample-group hierarchy, color, and ungrouped-sample derivation for lab selectors. */ export function useSampleGroups(options: UseSampleGroupsOptions): UseSampleGroupsReturn { const groups = computed(() => [...toValue(options.groups)]) const samples = computed(() => [...(toValue(options.samples) ?? [])]) const fallbackColor = computed(() => toValue(options.fallbackColor) || '#3B82F6') const separator = computed(() => { const explicit = toValue(options.separator) if (explicit) return explicit return groups.value.some(group => group.name.includes('/')) ? '/' : '_' }) // Groups are bucketed by their first segment (the major/family), then each // family is expanded into a full prefix tree. Shades are assigned per family // across its leaves so a family keeps one hue with a light→dark gradient, // exactly as the two-level model did. const families = computed(() => { const byMajor = new Map() for (const group of groups.value) { const majorName = getMajorGroupName(group.name, separator.value) const existing = byMajor.get(majorName) if (existing) existing.push(group) else byMajor.set(majorName, [group]) } return [...byMajor.entries()].map(([name, members]) => { const color = members[0]?.color || fallbackColor.value return { name, color, subGroups: members.map((group, index) => { const displayColor = deriveShade(color, index, members.length) return { ...group, displayColor, displayBg: displayColor + '20', displayBorder: displayColor + '40', } }), } }) }) const groupTree = computed(() => { const roots: SampleGroupNode[] = [] for (const family of families.value) { // Interior nodes are shared by path so siblings collapse into one parent; // leaves are keyed separately so a group whose full path equals an // interior path (e.g. "Liver" alongside "Liver / High") stays reachable // instead of being swallowed by the prefix node. const interior = new Map() for (const subGroup of family.subGroups) { const segments = splitSampleGroupPath(subGroup.name, separator.value) const leafLabel = segments[segments.length - 1] ?? subGroup.name let siblings = roots let path = '' for (const segment of segments.slice(0, -1)) { path = path ? `${path}/${segment}` : segment let node = interior.get(path) if (!node) { node = { key: `node:${family.name}/${path}`, label: segment, color: family.color, displayBg: family.color + '20', displayBorder: family.color + '40', allSamples: [], children: [], } interior.set(path, node) siblings.push(node) } node.allSamples.push(...subGroup.samples) siblings = node.children } siblings.push({ key: `leaf:${subGroup.name}`, label: leafLabel, color: subGroup.displayColor, displayBg: subGroup.displayBg, displayBorder: subGroup.displayBorder, allSamples: [...subGroup.samples], children: [], group: subGroup, }) } } return roots }) const maxDepth = computed(() => { const depthOf = (nodes: SampleGroupNode[]): number => nodes.reduce((deepest, node) => Math.max(deepest, 1 + depthOf(node.children)), 0) return depthOf(groupTree.value) }) const hierarchicalGroups = computed(() => families.value.map(family => ({ name: family.name, color: family.color, subGroups: family.subGroups, allSamples: family.subGroups.flatMap(group => group.samples), })), ) const showHierarchy = computed(() => maxDepth.value > 1) const groupedSamples = computed(() => new Set(groups.value.flatMap(group => group.samples)) ) const ungroupedSamples = computed(() => samples.value.filter(sample => !groupedSamples.value.has(sample)) ) function findGroup(groupName: string): SampleGroup | undefined { return groups.value.find(group => group.name === groupName) } function getGroupColor(groupName: string): string { return findGroup(groupName)?.color || fallbackColor.value } return { groups, separator, groupTree, maxDepth, hierarchicalGroups, showHierarchy, groupedSamples, ungroupedSamples, findGroup, getGroupColor, } } function getMajorGroupName(groupName: string, separator: string): string { return splitSampleGroupPath(groupName, separator)[0] }