import { ref, computed, watch, type Ref, type ComputedRef } from 'vue' import type { Rack, SlotPosition, WellEditData, WellPlateFormat, WellSampleDropData, Well } from '../types' import { PLATE_CONFIGS, rowIndex, rowLabel as plateRowLabel } from '../components/WellPlate.geometry' export interface UseRackEditorOptions { defaultFormat?: WellPlateFormat defaultInjectionVolume?: number maxRacks?: number minRacks?: number } export interface UseRackEditorReturn { racks: Ref activeRack: ComputedRef activeRackId: Ref addRack(name?: string): Rack removeRack(rackId: string): void reorderRacks(fromIndex: number, toIndex: number): void updateRack(rackId: string, data: Partial): void setActiveRack(rackId: string): void setWellData(rackId: string, wellId: string, data: Partial): void clearWell(rackId: string, wellId: string): void clearAllWells(rackId: string): void fillSeries(rackId: string, prefix?: string): void /** Swaps two wells (a move when the target is empty). */ moveWell(rackId: string, sourceWellId: string, targetWellId: string): void /** Puts every well in group `groupId` (an empty well becomes a sample); `undefined` empties them. */ assignWells(rackId: string, wellIds: string[], groupId: string | undefined): void /** * Applies every PlateEditor intent to this state, so the component can hold * data it never mutates itself: * `` */ plateEditorListeners: PlateEditorListeners /** Steps back through every change to `racks` (at most 50), restoring the plate that was showing. */ undo(): void redo(): void canUndo: ComputedRef canRedo: ComputedRef /** Forgets every undo and redo step, e.g. after loading unrelated plates. */ clearHistory(): void getAllWells(): Array<{ rackId: string; wellId: string; well: Partial }> totalSampleCount: ComputedRef reset(): void } /** Handlers for PlateEditor's intent events, keyed by event name (bind with `v-on`). */ export interface PlateEditorListeners { 'update:activeRackId': (rackId: string) => void 'rack-add': () => void 'rack-remove': (rackId: string) => void 'rack-reorder': (rackIds: string[]) => void 'update:format': (rackId: string, format: WellPlateFormat) => void 'update:slot': (rackId: string, slot: SlotPosition) => void 'well-edit': (rackId: string, wellId: string, data: WellEditData) => void 'well-clear': (rackId: string, wellId: string) => void 'well-move': (rackId: string, sourceWellId: string, targetWellId: string) => void 'sample-drop': (rackId: string, wellId: string, data: WellSampleDropData, event: DragEvent) => void 'assign': (rackId: string, wellIds: string[], groupId: string | undefined) => void 'clear': (rackId: string) => void 'fill-series': (rackId: string) => void 'undo': () => void 'redo': () => void } const HISTORY_LIMIT = 50 const SLOT_CYCLE: SlotPosition[] = ['R', 'G', 'B', 'Y'] function wellFromEditData(data: WellEditData): Partial { return { state: 'filled', sampleType: data.sampleType || undefined, metadata: { label: data.label, injectionVolume: data.injectionVolume, injectionCount: data.injectionCount, customMethod: data.customMethod || null, }, } } function wellFromDrop(data: WellSampleDropData, rack: Rack): Partial | null { const label = (data.label ?? data.sampleName ?? data.id ?? '').trim() if (!label) return null return { state: 'filled', sampleType: data.sampleType ?? 'sample', metadata: { label, injectionVolume: data.injectionVolume ?? rack.injectionVolume, injectionCount: data.injectionCount ?? 1, customMethod: data.customMethod ?? null, }, } } let globalIdCounter = 0 function generateId(): string { globalIdCounter++ return `rack-${Date.now()}-${globalIdCounter}` } /** Manages a set of sample racks with well-level assignment, series fill, and multi-rack reordering. */ export function useRackEditor( initialRacks?: Rack[], options?: UseRackEditorOptions, ): UseRackEditorReturn { const defaultFormat = options?.defaultFormat ?? 54 const defaultInjVol = options?.defaultInjectionVolume ?? 5 const maxRacks = options?.maxRacks ?? 10 const minRacks = options?.minRacks ?? 1 function createDefaultRack(name?: string, slotIndex?: number): Rack { const slot = SLOT_CYCLE[(slotIndex ?? 0) % SLOT_CYCLE.length]! return { id: generateId(), name: name ?? `Rack ${racks.value.length + 1}`, format: defaultFormat, slot, injectionVolume: defaultInjVol, wells: {}, } } const racks = ref( initialRacks && initialRacks.length > 0 ? initialRacks.map(r => ({ ...r, wells: { ...r.wells } })) : [createDefaultRack('Rack 1', 0)] ) as Ref const activeRackId = ref(racks.value[0]?.id ?? '') const activeRack = computed(() => racks.value.find(r => r.id === activeRackId.value) ?? racks.value[0] ) function addRack(name?: string): Rack { if (racks.value.length >= maxRacks) { return racks.value[racks.value.length - 1]! } const rack = createDefaultRack(name, racks.value.length) racks.value.push(rack) activeRackId.value = rack.id return rack } function removeRack(rackId: string): void { if (racks.value.length <= minRacks) return const index = racks.value.findIndex(r => r.id === rackId) if (index === -1) return racks.value.splice(index, 1) if (activeRackId.value === rackId && racks.value[0]) { activeRackId.value = racks.value[0].id } } function reorderRacks(fromIndex: number, toIndex: number): void { if (fromIndex < 0 || fromIndex >= racks.value.length) return if (toIndex < 0 || toIndex >= racks.value.length) return if (fromIndex === toIndex) return const [moved] = racks.value.splice(fromIndex, 1) if (moved) { racks.value.splice(toIndex, 0, moved) } } function updateRack(rackId: string, data: Partial): void { const rack = racks.value.find(r => r.id === rackId) if (!rack) return if (data.name !== undefined) rack.name = data.name if (data.format !== undefined) rack.format = data.format if (data.slot !== undefined) rack.slot = data.slot if (data.injectionVolume !== undefined) rack.injectionVolume = data.injectionVolume if (data.wells !== undefined) rack.wells = data.wells } function setActiveRack(rackId: string): void { if (racks.value.some(r => r.id === rackId)) { activeRackId.value = rackId } } function setWellData(rackId: string, wellId: string, data: Partial): void { const rack = racks.value.find(r => r.id === rackId) if (!rack) return rack.wells[wellId] = { ...rack.wells[wellId], ...data } } function clearWell(rackId: string, wellId: string): void { const rack = racks.value.find(r => r.id === rackId) if (!rack) return delete rack.wells[wellId] } function clearAllWells(rackId: string): void { const rack = racks.value.find(r => r.id === rackId) if (!rack) return rack.wells = {} } function fillSeries(rackId: string, prefix: string = 'S'): void { const rack = racks.value.find(r => r.id === rackId) if (!rack) return const config = PLATE_CONFIGS[rack.format] if (!config) return let counter = 1 for (let row = 0; row < config.rows; row++) { for (let col = 0; col < config.cols; col++) { const wellId = `${plateRowLabel(row)}${col + 1}` // Only fill empty wells if (!rack.wells[wellId]?.sampleType) { const paddedNum = String(counter).padStart(3, '0') rack.wells[wellId] = { id: wellId, row, col, state: 'filled', sampleType: 'sample', metadata: { label: `${prefix}${paddedNum}`, injectionVolume: rack.injectionVolume, injectionCount: 1, }, } } counter++ } } } function moveWell(rackId: string, sourceWellId: string, targetWellId: string): void { const rack = racks.value.find(r => r.id === rackId) if (!rack) return const { [sourceWellId]: source, [targetWellId]: target } = rack.wells delete rack.wells[sourceWellId] delete rack.wells[targetWellId] if (target) rack.wells[sourceWellId] = placed(target, sourceWellId) if (source) rack.wells[targetWellId] = placed(source, targetWellId) } /** A moved well keeps its position fields in step with its new key. */ function placed(well: Partial, wellId: string): Partial { if (well.id === undefined) return well const [, row = '', col = ''] = /^([A-Z]+)(\d+)$/.exec(wellId) ?? [] return { ...well, id: wellId, row: rowIndex(row), col: Number(col) - 1 } } function assignWells(rackId: string, wellIds: string[], groupId: string | undefined): void { const rack = racks.value.find(r => r.id === rackId) for (const wellId of wellIds) { const sampleType = rack?.wells[wellId]?.sampleType ?? 'sample' if (groupId) setWellData(rackId, wellId, { state: 'filled', sampleType, group: groupId }) else clearWell(rackId, wellId) } } // History: the serialized racks before each change, with the plate that was showing. // Every change counts, not just the intents: each public mutator call is its own step, // and a change made outside them (an import assigning `racks`) becomes one at the next // tick, so undo never skips past it. interface Snapshot { racks: string; activeRackId: string } const undoStack = ref([]) const redoStack = ref([]) const canUndo = computed(() => undoStack.value.length > 0) const canRedo = computed(() => redoStack.value.length > 0) /** The racks as the history last saw them. */ let known = JSON.stringify(racks.value) function pushUndo(snapshot: Snapshot): void { undoStack.value = [...undoStack.value, snapshot].slice(-HISTORY_LIMIT) redoStack.value = [] } /** Records a change made since the history last looked, as its own step. */ function absorb(): void { const now = JSON.stringify(racks.value) if (now === known) return pushUndo({ racks: known, activeRackId: activeRackId.value }) known = now } watch(racks, absorb, { deep: true }) function step(from: Ref, to: Ref): void { absorb() const snapshot = from.value.pop() if (snapshot === undefined) return to.value.push({ racks: known, activeRackId: activeRackId.value }) racks.value = JSON.parse(snapshot.racks) as Rack[] known = snapshot.racks activeRackId.value = racks.value.some(rack => rack.id === snapshot.activeRackId) ? snapshot.activeRackId : racks.value[0]?.id ?? '' } const undo = (): void => step(undoStack, redoStack) const redo = (): void => step(redoStack, undoStack) function clearHistory(): void { undoStack.value = [] redoStack.value = [] known = JSON.stringify(racks.value) } /** Runs `apply` and records the prior state when it changed anything. */ function recorded(apply: (...args: Args) => Result) { return (...args: Args): Result => { absorb() const before = { racks: known, activeRackId: activeRackId.value } const result = apply(...args) const after = JSON.stringify(racks.value) if (after !== known) { pushUndo(before) known = after } return result } } const plateEditorListeners: PlateEditorListeners = { 'update:activeRackId': setActiveRack, 'rack-add': recorded(() => { addRack() }), 'rack-remove': recorded(removeRack), 'rack-reorder': recorded((rackIds: string[]) => { racks.value = rackIds.flatMap(id => racks.value.filter(rack => rack.id === id)) }), 'update:format': recorded((rackId: string, format: WellPlateFormat) => updateRack(rackId, { format })), 'update:slot': recorded((rackId: string, slot: SlotPosition) => updateRack(rackId, { slot })), 'well-edit': recorded((rackId: string, wellId: string, data: WellEditData) => setWellData(rackId, wellId, wellFromEditData(data))), 'well-clear': recorded(clearWell), 'well-move': recorded(moveWell), 'sample-drop': recorded((rackId: string, wellId: string, data: WellSampleDropData) => { const rack = racks.value.find(r => r.id === rackId) const well = rack && wellFromDrop(data, rack) if (well) setWellData(rackId, wellId, well) }), 'assign': recorded(assignWells), 'clear': recorded(clearAllWells), 'fill-series': recorded((rackId: string) => fillSeries(rackId)), 'undo': undo, 'redo': redo, } function getAllWells(): Array<{ rackId: string; wellId: string; well: Partial }> { const result: Array<{ rackId: string; wellId: string; well: Partial }> = [] for (const rack of racks.value) { for (const [wellId, well] of Object.entries(rack.wells)) { result.push({ rackId: rack.id, wellId, well }) } } return result } const totalSampleCount = computed(() => { let count = 0 for (const rack of racks.value) { count += Object.keys(rack.wells).length } return count }) function reset(): void { const rack = createDefaultRack('Rack 1', 0) racks.value = [rack] activeRackId.value = rack.id clearHistory() } return { racks, activeRack, activeRackId, // Wrapped here, not at the definitions, so assignWells' inner calls stay one step. addRack: recorded(addRack), removeRack: recorded(removeRack), reorderRacks: recorded(reorderRacks), updateRack: recorded(updateRack), setActiveRack, setWellData: recorded(setWellData), clearWell: recorded(clearWell), clearAllWells: recorded(clearAllWells), fillSeries: recorded(fillSeries), moveWell: recorded(moveWell), assignWells: recorded(assignWells), plateEditorListeners, undo, redo, canUndo, canRedo, clearHistory, getAllWells, totalSampleCount, reset, } }