@morscherlab/mint-sdk 1.0.13 → 1.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/composables/useCommandHistory.test.d.ts +1 -0
- package/dist/__tests__/composables/useFileImport.test.d.ts +1 -0
- package/dist/__tests__/composables/useOptimisticMutation.test.d.ts +1 -0
- package/dist/__tests__/composables/useResourceCrud.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPainting.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPlateAdapter.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPlateValidation.test.d.ts +1 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-CzFtQExv.js → components-Dq02EVZH.js} +4 -4
- package/dist/components-Dq02EVZH.js.map +1 -0
- package/dist/composables/index.d.ts +7 -0
- package/dist/composables/index.js +2 -2
- package/dist/composables/useCommandHistory.d.ts +29 -0
- package/dist/composables/useFileImport.d.ts +35 -0
- package/dist/composables/useOptimisticMutation.d.ts +28 -0
- package/dist/composables/useResourceCrud.d.ts +40 -0
- package/dist/composables/useWellPainting.d.ts +35 -0
- package/dist/composables/useWellPlateAdapter.d.ts +36 -0
- package/dist/composables/useWellPlateValidation.d.ts +27 -0
- package/dist/{composables-BuG5yAb7.js → composables-D9mexHSW.js} +666 -3
- package/dist/composables-D9mexHSW.js.map +1 -0
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/composables/useCommandHistory.test.ts +43 -0
- package/src/__tests__/composables/useFileImport.test.ts +44 -0
- package/src/__tests__/composables/useOptimisticMutation.test.ts +40 -0
- package/src/__tests__/composables/useResourceCrud.test.ts +56 -0
- package/src/__tests__/composables/useWellPainting.test.ts +52 -0
- package/src/__tests__/composables/useWellPlateAdapter.test.ts +50 -0
- package/src/__tests__/composables/useWellPlateValidation.test.ts +42 -0
- package/src/components/PluginWorkspaceView.vue +3 -3
- package/src/composables/index.ts +67 -0
- package/src/composables/useCommandHistory.ts +113 -0
- package/src/composables/useFileImport.ts +231 -0
- package/src/composables/useOptimisticMutation.ts +107 -0
- package/src/composables/useResourceCrud.ts +245 -0
- package/src/composables/useWellPainting.ts +187 -0
- package/src/composables/useWellPlateAdapter.ts +147 -0
- package/src/composables/useWellPlateValidation.ts +85 -0
- package/dist/components-CzFtQExv.js.map +0 -1
- package/dist/composables-BuG5yAb7.js.map +0 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { computed, ref, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type ResourceKey = string | number
|
|
4
|
+
|
|
5
|
+
export interface ResourceCrudAdapter<TItem, TCreate = Partial<TItem>, TUpdate = Partial<TItem>, TKey extends ResourceKey = ResourceKey> {
|
|
6
|
+
list: () => Promise<TItem[]>
|
|
7
|
+
create?: (payload: TCreate) => Promise<TItem>
|
|
8
|
+
update?: (key: TKey, payload: TUpdate) => Promise<TItem>
|
|
9
|
+
remove?: (key: TKey) => Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseResourceCrudOptions<
|
|
13
|
+
TItem,
|
|
14
|
+
TCreate = Partial<TItem>,
|
|
15
|
+
TUpdate = Partial<TItem>,
|
|
16
|
+
TKey extends ResourceKey = ResourceKey,
|
|
17
|
+
> {
|
|
18
|
+
adapter: ResourceCrudAdapter<TItem, TCreate, TUpdate, TKey>
|
|
19
|
+
initialItems?: readonly TItem[]
|
|
20
|
+
getKey?: (item: TItem) => TKey
|
|
21
|
+
readErrorMessage?: (error: unknown) => string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseResourceCrudReturn<TItem, TCreate, TUpdate, TKey extends ResourceKey> {
|
|
25
|
+
items: Ref<TItem[]>
|
|
26
|
+
loading: ShallowRef<boolean>
|
|
27
|
+
saving: ShallowRef<boolean>
|
|
28
|
+
deleting: ShallowRef<boolean>
|
|
29
|
+
error: Ref<string | null>
|
|
30
|
+
isBusy: ComputedRef<boolean>
|
|
31
|
+
load: () => Promise<TItem[]>
|
|
32
|
+
createItem: (payload: TCreate) => Promise<TItem>
|
|
33
|
+
updateItem: (key: TKey, payload: TUpdate) => Promise<TItem>
|
|
34
|
+
deleteItem: (keyOrItem: TKey | TItem) => Promise<void>
|
|
35
|
+
setItems: (items: readonly TItem[]) => void
|
|
36
|
+
upsertLocal: (item: TItem) => void
|
|
37
|
+
removeLocal: (keyOrItem: TKey | TItem) => void
|
|
38
|
+
clearError: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Common list/create/update/delete state wrapper for generated plugin clients. */
|
|
42
|
+
export function useResourceCrud<
|
|
43
|
+
TItem,
|
|
44
|
+
TCreate = Partial<TItem>,
|
|
45
|
+
TUpdate = Partial<TItem>,
|
|
46
|
+
TKey extends ResourceKey = ResourceKey,
|
|
47
|
+
>(
|
|
48
|
+
options: UseResourceCrudOptions<TItem, TCreate, TUpdate, TKey>,
|
|
49
|
+
): UseResourceCrudReturn<TItem, TCreate, TUpdate, TKey> {
|
|
50
|
+
const items = ref<TItem[]>([...(options.initialItems ?? [])]) as Ref<TItem[]>
|
|
51
|
+
const loading = shallowRef(false)
|
|
52
|
+
const saving = shallowRef(false)
|
|
53
|
+
const deleting = shallowRef(false)
|
|
54
|
+
const error = ref<string | null>(null)
|
|
55
|
+
const isBusy = computed(() => loading.value || saving.value || deleting.value)
|
|
56
|
+
|
|
57
|
+
const readMessage = options.readErrorMessage ?? readErrorMessage
|
|
58
|
+
const getKey = options.getKey ?? defaultGetKey<TKey, TItem>
|
|
59
|
+
|
|
60
|
+
function clearError(): void {
|
|
61
|
+
error.value = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function setError(err: unknown): void {
|
|
65
|
+
error.value = readMessage(err)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setItems(nextItems: readonly TItem[]): void {
|
|
69
|
+
items.value = [...nextItems]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function upsertLocal(item: TItem): void {
|
|
73
|
+
const key = getKey(item)
|
|
74
|
+
const index = items.value.findIndex(existing => getKey(existing) === key)
|
|
75
|
+
if (index === -1) {
|
|
76
|
+
items.value = [...items.value, item]
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
items.value = [
|
|
80
|
+
...items.value.slice(0, index),
|
|
81
|
+
item,
|
|
82
|
+
...items.value.slice(index + 1),
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function removeLocal(keyOrItem: TKey | TItem): void {
|
|
87
|
+
const key = resolveKey(keyOrItem, getKey)
|
|
88
|
+
items.value = items.value.filter(item => getKey(item) !== key)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function load(): Promise<TItem[]> {
|
|
92
|
+
loading.value = true
|
|
93
|
+
clearError()
|
|
94
|
+
try {
|
|
95
|
+
const result = await options.adapter.list()
|
|
96
|
+
setItems(result)
|
|
97
|
+
return result
|
|
98
|
+
} catch (err) {
|
|
99
|
+
setError(err)
|
|
100
|
+
throw err
|
|
101
|
+
} finally {
|
|
102
|
+
loading.value = false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function createItem(payload: TCreate): Promise<TItem> {
|
|
107
|
+
if (!options.adapter.create) {
|
|
108
|
+
throw new Error('[MINT SDK] Resource adapter does not implement create().')
|
|
109
|
+
}
|
|
110
|
+
saving.value = true
|
|
111
|
+
clearError()
|
|
112
|
+
try {
|
|
113
|
+
const result = await options.adapter.create(payload)
|
|
114
|
+
upsertLocal(result)
|
|
115
|
+
return result
|
|
116
|
+
} catch (err) {
|
|
117
|
+
setError(err)
|
|
118
|
+
throw err
|
|
119
|
+
} finally {
|
|
120
|
+
saving.value = false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function updateItem(key: TKey, payload: TUpdate): Promise<TItem> {
|
|
125
|
+
if (!options.adapter.update) {
|
|
126
|
+
throw new Error('[MINT SDK] Resource adapter does not implement update().')
|
|
127
|
+
}
|
|
128
|
+
saving.value = true
|
|
129
|
+
clearError()
|
|
130
|
+
try {
|
|
131
|
+
const result = await options.adapter.update(key, payload)
|
|
132
|
+
upsertLocal(result)
|
|
133
|
+
return result
|
|
134
|
+
} catch (err) {
|
|
135
|
+
setError(err)
|
|
136
|
+
throw err
|
|
137
|
+
} finally {
|
|
138
|
+
saving.value = false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function deleteItem(keyOrItem: TKey | TItem): Promise<void> {
|
|
143
|
+
if (!options.adapter.remove) {
|
|
144
|
+
throw new Error('[MINT SDK] Resource adapter does not implement remove().')
|
|
145
|
+
}
|
|
146
|
+
const key = resolveKey(keyOrItem, getKey)
|
|
147
|
+
deleting.value = true
|
|
148
|
+
clearError()
|
|
149
|
+
try {
|
|
150
|
+
await options.adapter.remove(key)
|
|
151
|
+
removeLocal(key)
|
|
152
|
+
} catch (err) {
|
|
153
|
+
setError(err)
|
|
154
|
+
throw err
|
|
155
|
+
} finally {
|
|
156
|
+
deleting.value = false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
items,
|
|
162
|
+
loading,
|
|
163
|
+
saving,
|
|
164
|
+
deleting,
|
|
165
|
+
error,
|
|
166
|
+
isBusy,
|
|
167
|
+
load,
|
|
168
|
+
createItem,
|
|
169
|
+
updateItem,
|
|
170
|
+
deleteItem,
|
|
171
|
+
setItems,
|
|
172
|
+
upsertLocal,
|
|
173
|
+
removeLocal,
|
|
174
|
+
clearError,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PluginResourceClientOptions<
|
|
179
|
+
TClient,
|
|
180
|
+
TItem,
|
|
181
|
+
TCreate = Partial<TItem>,
|
|
182
|
+
TUpdate = Partial<TItem>,
|
|
183
|
+
TKey extends ResourceKey = ResourceKey,
|
|
184
|
+
> {
|
|
185
|
+
client: TClient
|
|
186
|
+
list: (client: TClient) => Promise<TItem[]>
|
|
187
|
+
create?: (client: TClient, payload: TCreate) => Promise<TItem>
|
|
188
|
+
update?: (client: TClient, key: TKey, payload: TUpdate) => Promise<TItem>
|
|
189
|
+
remove?: (client: TClient, key: TKey) => Promise<void>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function createPluginResourceClient<
|
|
193
|
+
TClient,
|
|
194
|
+
TItem,
|
|
195
|
+
TCreate = Partial<TItem>,
|
|
196
|
+
TUpdate = Partial<TItem>,
|
|
197
|
+
TKey extends ResourceKey = ResourceKey,
|
|
198
|
+
>(
|
|
199
|
+
options: PluginResourceClientOptions<TClient, TItem, TCreate, TUpdate, TKey>,
|
|
200
|
+
): ResourceCrudAdapter<TItem, TCreate, TUpdate, TKey> {
|
|
201
|
+
return {
|
|
202
|
+
list: () => options.list(options.client),
|
|
203
|
+
create: options.create
|
|
204
|
+
? payload => options.create!(options.client, payload)
|
|
205
|
+
: undefined,
|
|
206
|
+
update: options.update
|
|
207
|
+
? (key, payload) => options.update!(options.client, key, payload)
|
|
208
|
+
: undefined,
|
|
209
|
+
remove: options.remove
|
|
210
|
+
? key => options.remove!(options.client, key)
|
|
211
|
+
: undefined,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function defaultGetKey<TKey extends ResourceKey, TItem>(item: TItem): TKey {
|
|
216
|
+
if (item && typeof item === 'object' && 'id' in item) {
|
|
217
|
+
return (item as { id: ResourceKey }).id as TKey
|
|
218
|
+
}
|
|
219
|
+
throw new Error('[MINT SDK] Resource items need an id field or a getKey option.')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveKey<TItem, TKey extends ResourceKey>(
|
|
223
|
+
keyOrItem: TKey | TItem,
|
|
224
|
+
getKey: (item: TItem) => TKey,
|
|
225
|
+
): TKey {
|
|
226
|
+
if (typeof keyOrItem === 'string' || typeof keyOrItem === 'number') {
|
|
227
|
+
return keyOrItem as TKey
|
|
228
|
+
}
|
|
229
|
+
return getKey(keyOrItem as TItem)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readErrorMessage(value: unknown): string {
|
|
233
|
+
if (value instanceof Error && value.message) return value.message
|
|
234
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
235
|
+
if (
|
|
236
|
+
typeof value === 'object'
|
|
237
|
+
&& value !== null
|
|
238
|
+
&& 'message' in value
|
|
239
|
+
&& typeof (value as { message?: unknown }).message === 'string'
|
|
240
|
+
&& (value as { message: string }).message.trim()
|
|
241
|
+
) {
|
|
242
|
+
return (value as { message: string }).message
|
|
243
|
+
}
|
|
244
|
+
return 'Request failed.'
|
|
245
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { computed, ref, toValue, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import type { WellPlateFormat } from '../types'
|
|
3
|
+
|
|
4
|
+
export type WellPlateSource<T> = T | Ref<T> | ComputedRef<T> | (() => T)
|
|
5
|
+
|
|
6
|
+
export interface WellCoordinate {
|
|
7
|
+
row: number
|
|
8
|
+
col: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WellPaintResult<TMode extends string = string, TPayload = unknown> {
|
|
12
|
+
wellIds: string[]
|
|
13
|
+
mode: TMode
|
|
14
|
+
payload?: TPayload
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseWellPaintingOptions<TMode extends string = string, TPayload = unknown> {
|
|
18
|
+
format: WellPlateSource<WellPlateFormat>
|
|
19
|
+
initialMode?: TMode
|
|
20
|
+
initialPayload?: TPayload
|
|
21
|
+
onComplete?: (result: WellPaintResult<TMode, TPayload>) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseWellPaintingReturn<TMode extends string = string, TPayload = unknown> {
|
|
25
|
+
activeMode: Ref<TMode>
|
|
26
|
+
activePayload: Ref<TPayload | undefined>
|
|
27
|
+
isDragging: Ref<boolean>
|
|
28
|
+
previewWellIds: ComputedRef<string[]>
|
|
29
|
+
setMode: (mode: TMode, payload?: TPayload) => void
|
|
30
|
+
setPayload: (payload: TPayload | undefined) => void
|
|
31
|
+
onWellMouseDown: (wellId: string) => void
|
|
32
|
+
onWellMouseMove: (wellId: string) => void
|
|
33
|
+
onWellMouseUp: () => void
|
|
34
|
+
cancel: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ROW_LABELS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
38
|
+
|
|
39
|
+
const PLATE_DIMENSIONS: Record<WellPlateFormat, { rows: number; cols: number }> = {
|
|
40
|
+
6: { rows: 2, cols: 3 },
|
|
41
|
+
12: { rows: 3, cols: 4 },
|
|
42
|
+
24: { rows: 4, cols: 6 },
|
|
43
|
+
48: { rows: 6, cols: 8 },
|
|
44
|
+
54: { rows: 6, cols: 9 },
|
|
45
|
+
96: { rows: 8, cols: 12 },
|
|
46
|
+
384: { rows: 16, cols: 24 },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pointer painting/rectangle selection state for WellPlate-based editors. */
|
|
50
|
+
export function useWellPainting<TMode extends string = string, TPayload = unknown>(
|
|
51
|
+
options: UseWellPaintingOptions<TMode, TPayload>,
|
|
52
|
+
): UseWellPaintingReturn<TMode, TPayload> {
|
|
53
|
+
const activeMode = ref<TMode>(options.initialMode ?? 'select' as TMode) as Ref<TMode>
|
|
54
|
+
const activePayload = ref<TPayload | undefined>(options.initialPayload) as Ref<TPayload | undefined>
|
|
55
|
+
const isDragging = ref(false)
|
|
56
|
+
const dragStart = ref<WellCoordinate | null>(null)
|
|
57
|
+
const dragEnd = ref<WellCoordinate | null>(null)
|
|
58
|
+
const paintTrail = ref<Set<string>>(new Set())
|
|
59
|
+
|
|
60
|
+
const previewWellIds = computed(() => {
|
|
61
|
+
if (!isDragging.value) return []
|
|
62
|
+
if (activeMode.value === 'select') {
|
|
63
|
+
if (!dragStart.value || !dragEnd.value) return []
|
|
64
|
+
return wellIdsInRectangle(dragStart.value, dragEnd.value, toValue(options.format))
|
|
65
|
+
}
|
|
66
|
+
return [...paintTrail.value]
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
function setMode(mode: TMode, payload?: TPayload): void {
|
|
70
|
+
activeMode.value = mode
|
|
71
|
+
activePayload.value = payload
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setPayload(payload: TPayload | undefined): void {
|
|
75
|
+
activePayload.value = payload
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onWellMouseDown(wellId: string): void {
|
|
79
|
+
const coord = wellIdToCoordinate(wellId)
|
|
80
|
+
if (!coord) return
|
|
81
|
+
isDragging.value = true
|
|
82
|
+
dragStart.value = coord
|
|
83
|
+
dragEnd.value = coord
|
|
84
|
+
paintTrail.value = new Set([wellId])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onWellMouseMove(wellId: string): void {
|
|
88
|
+
if (!isDragging.value) return
|
|
89
|
+
const coord = wellIdToCoordinate(wellId)
|
|
90
|
+
if (!coord) return
|
|
91
|
+
dragEnd.value = coord
|
|
92
|
+
if (activeMode.value !== 'select') {
|
|
93
|
+
paintTrail.value = new Set([...paintTrail.value, wellId])
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function onWellMouseUp(): void {
|
|
98
|
+
if (!isDragging.value) return
|
|
99
|
+
const wellIds = previewWellIds.value
|
|
100
|
+
isDragging.value = false
|
|
101
|
+
if (wellIds.length > 0) {
|
|
102
|
+
options.onComplete?.({
|
|
103
|
+
wellIds,
|
|
104
|
+
mode: activeMode.value,
|
|
105
|
+
payload: activePayload.value,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
clearDragState()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cancel(): void {
|
|
112
|
+
isDragging.value = false
|
|
113
|
+
clearDragState()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clearDragState(): void {
|
|
117
|
+
dragStart.value = null
|
|
118
|
+
dragEnd.value = null
|
|
119
|
+
paintTrail.value = new Set()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
activeMode,
|
|
124
|
+
activePayload,
|
|
125
|
+
isDragging,
|
|
126
|
+
previewWellIds,
|
|
127
|
+
setMode,
|
|
128
|
+
setPayload,
|
|
129
|
+
onWellMouseDown,
|
|
130
|
+
onWellMouseMove,
|
|
131
|
+
onWellMouseUp,
|
|
132
|
+
cancel,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function wellIdToCoordinate(wellId: string): WellCoordinate | null {
|
|
137
|
+
const match = /^([A-Z]+)(\d+)$/i.exec(wellId.trim())
|
|
138
|
+
if (!match) return null
|
|
139
|
+
return {
|
|
140
|
+
row: rowLabelToIndex(match[1].toUpperCase()),
|
|
141
|
+
col: Number(match[2]) - 1,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function coordinateToWellId(coord: WellCoordinate): string {
|
|
146
|
+
return `${indexToRowLabel(coord.row)}${coord.col + 1}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function wellIdsInRectangle(
|
|
150
|
+
start: WellCoordinate,
|
|
151
|
+
end: WellCoordinate,
|
|
152
|
+
format: WellPlateFormat,
|
|
153
|
+
): string[] {
|
|
154
|
+
const dimensions = PLATE_DIMENSIONS[format]
|
|
155
|
+
const minRow = Math.max(0, Math.min(start.row, end.row))
|
|
156
|
+
const maxRow = Math.min(dimensions.rows - 1, Math.max(start.row, end.row))
|
|
157
|
+
const minCol = Math.max(0, Math.min(start.col, end.col))
|
|
158
|
+
const maxCol = Math.min(dimensions.cols - 1, Math.max(start.col, end.col))
|
|
159
|
+
const ids: string[] = []
|
|
160
|
+
|
|
161
|
+
for (let row = minRow; row <= maxRow; row++) {
|
|
162
|
+
for (let col = minCol; col <= maxCol; col++) {
|
|
163
|
+
ids.push(coordinateToWellId({ row, col }))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return ids
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function rowLabelToIndex(label: string): number {
|
|
170
|
+
let result = 0
|
|
171
|
+
for (const char of label) {
|
|
172
|
+
result = result * 26 + (char.charCodeAt(0) - 64)
|
|
173
|
+
}
|
|
174
|
+
return result - 1
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function indexToRowLabel(index: number): string {
|
|
178
|
+
if (index < ROW_LABELS.length) return ROW_LABELS[index]
|
|
179
|
+
let value = index + 1
|
|
180
|
+
let label = ''
|
|
181
|
+
while (value > 0) {
|
|
182
|
+
value--
|
|
183
|
+
label = ROW_LABELS[value % 26] + label
|
|
184
|
+
value = Math.floor(value / 26)
|
|
185
|
+
}
|
|
186
|
+
return label
|
|
187
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { computed, toValue, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import type { ColumnCondition, RowCondition, Well } from '../types'
|
|
3
|
+
|
|
4
|
+
export type WellPlateAdapterSource<T> =
|
|
5
|
+
| T
|
|
6
|
+
| Ref<T>
|
|
7
|
+
| ComputedRef<T>
|
|
8
|
+
| (() => T)
|
|
9
|
+
|
|
10
|
+
export interface WellPlateEntry {
|
|
11
|
+
wellId: string
|
|
12
|
+
state?: Well['state']
|
|
13
|
+
sampleType?: string
|
|
14
|
+
value?: number
|
|
15
|
+
metadata?: Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WellPlateAdapterOptions<TEntry> {
|
|
19
|
+
entries: WellPlateAdapterSource<readonly TEntry[]>
|
|
20
|
+
getWellId: (entry: TEntry) => string
|
|
21
|
+
getState?: (entry: TEntry) => Well['state'] | undefined
|
|
22
|
+
getSampleType?: (entry: TEntry) => string | undefined
|
|
23
|
+
getValue?: (entry: TEntry) => number | undefined
|
|
24
|
+
getMetadata?: (entry: TEntry) => Record<string, unknown> | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseWellPlateAdapterReturn {
|
|
28
|
+
wells: ComputedRef<Record<string, Partial<Well>>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Convert domain rows into WellPlate's wells prop without hand-written adapters. */
|
|
32
|
+
export function useWellPlateAdapter<TEntry>(
|
|
33
|
+
options: WellPlateAdapterOptions<TEntry>,
|
|
34
|
+
): UseWellPlateAdapterReturn {
|
|
35
|
+
const wells = computed(() => createWellPlateWells(toValue(options.entries), options))
|
|
36
|
+
return { wells }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createWellPlateWells<TEntry>(
|
|
40
|
+
entries: readonly TEntry[],
|
|
41
|
+
options: Omit<WellPlateAdapterOptions<TEntry>, 'entries'>,
|
|
42
|
+
): Record<string, Partial<Well>> {
|
|
43
|
+
return Object.fromEntries(entries.map((entry) => {
|
|
44
|
+
const wellId = options.getWellId(entry)
|
|
45
|
+
const coord = parseWellId(wellId)
|
|
46
|
+
const well: Partial<Well> = {
|
|
47
|
+
id: wellId,
|
|
48
|
+
row: coord.row,
|
|
49
|
+
col: coord.col,
|
|
50
|
+
state: options.getState?.(entry) ?? 'filled',
|
|
51
|
+
sampleType: options.getSampleType?.(entry),
|
|
52
|
+
value: options.getValue?.(entry),
|
|
53
|
+
metadata: options.getMetadata?.(entry),
|
|
54
|
+
}
|
|
55
|
+
return [wellId, well]
|
|
56
|
+
}))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AxisConditionEntry {
|
|
60
|
+
label: string
|
|
61
|
+
color: string
|
|
62
|
+
start: string | number
|
|
63
|
+
concentrations: readonly { value: number; replicates?: number }[]
|
|
64
|
+
unit?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createColumnConditions(entries: readonly AxisConditionEntry[]): ColumnCondition[] {
|
|
68
|
+
return entries.map((entry) => {
|
|
69
|
+
const startCol = typeof entry.start === 'number'
|
|
70
|
+
? entry.start
|
|
71
|
+
: Number.parseInt(entry.start, 10)
|
|
72
|
+
const cols: number[] = []
|
|
73
|
+
const concentrations: number[] = []
|
|
74
|
+
|
|
75
|
+
for (const concentration of entry.concentrations) {
|
|
76
|
+
const replicates = concentration.replicates ?? 1
|
|
77
|
+
for (let index = 0; index < replicates; index++) {
|
|
78
|
+
cols.push(startCol + cols.length)
|
|
79
|
+
concentrations.push(concentration.value)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
label: entry.label,
|
|
85
|
+
color: entry.color,
|
|
86
|
+
unit: entry.unit,
|
|
87
|
+
cols,
|
|
88
|
+
concentrations,
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createRowConditions(entries: readonly AxisConditionEntry[]): RowCondition[] {
|
|
94
|
+
return entries.map((entry) => {
|
|
95
|
+
const startRow = typeof entry.start === 'number'
|
|
96
|
+
? entry.start
|
|
97
|
+
: rowLabelToIndex(entry.start)
|
|
98
|
+
const rows: string[] = []
|
|
99
|
+
const concentrations: number[] = []
|
|
100
|
+
|
|
101
|
+
for (const concentration of entry.concentrations) {
|
|
102
|
+
const replicates = concentration.replicates ?? 1
|
|
103
|
+
for (let index = 0; index < replicates; index++) {
|
|
104
|
+
rows.push(indexToRowLabel(startRow + rows.length))
|
|
105
|
+
concentrations.push(concentration.value)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
label: entry.label,
|
|
111
|
+
color: entry.color,
|
|
112
|
+
unit: entry.unit,
|
|
113
|
+
rows,
|
|
114
|
+
concentrations,
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseWellId(wellId: string): { row: number; col: number } {
|
|
120
|
+
const match = /^([A-Z]+)(\d+)$/i.exec(wellId.trim())
|
|
121
|
+
if (!match) {
|
|
122
|
+
throw new Error(`[MINT SDK] Invalid well id "${wellId}".`)
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
row: rowLabelToIndex(match[1].toUpperCase()),
|
|
126
|
+
col: Number(match[2]) - 1,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function rowLabelToIndex(label: string): number {
|
|
131
|
+
let result = 0
|
|
132
|
+
for (const char of label.toUpperCase()) {
|
|
133
|
+
result = result * 26 + (char.charCodeAt(0) - 64)
|
|
134
|
+
}
|
|
135
|
+
return result - 1
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function indexToRowLabel(index: number): string {
|
|
139
|
+
let value = index + 1
|
|
140
|
+
let label = ''
|
|
141
|
+
while (value > 0) {
|
|
142
|
+
value--
|
|
143
|
+
label = String.fromCharCode(65 + (value % 26)) + label
|
|
144
|
+
value = Math.floor(value / 26)
|
|
145
|
+
}
|
|
146
|
+
return label
|
|
147
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { computed, toValue, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type WellPlateValidationSeverity = 'error' | 'warning' | 'info'
|
|
4
|
+
|
|
5
|
+
export interface WellPlateValidationWarning<TType extends string = string> {
|
|
6
|
+
type: TType
|
|
7
|
+
severity: WellPlateValidationSeverity
|
|
8
|
+
message: string
|
|
9
|
+
affectedWells?: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface WellPlateValidationRule<TContext, TType extends string = string> {
|
|
13
|
+
type: TType
|
|
14
|
+
severity: WellPlateValidationSeverity
|
|
15
|
+
validate: (context: TContext) => false | string | Omit<WellPlateValidationWarning<TType>, 'type' | 'severity'>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type WellPlateValidationSource<T> =
|
|
19
|
+
| T
|
|
20
|
+
| Ref<T>
|
|
21
|
+
| ComputedRef<T>
|
|
22
|
+
| (() => T)
|
|
23
|
+
|
|
24
|
+
export interface UseWellPlateValidationOptions<TContext, TType extends string = string> {
|
|
25
|
+
context: WellPlateValidationSource<TContext>
|
|
26
|
+
rules: WellPlateValidationSource<readonly WellPlateValidationRule<TContext, TType>[]>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseWellPlateValidationReturn<TType extends string = string> {
|
|
30
|
+
warnings: ComputedRef<WellPlateValidationWarning<TType>[]>
|
|
31
|
+
hasBlockingIssues: ComputedRef<boolean>
|
|
32
|
+
warningCount: ComputedRef<number>
|
|
33
|
+
errorCount: ComputedRef<number>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Rule-driven validation state for WellPlate layout/design screens. */
|
|
37
|
+
export function useWellPlateValidation<TContext, TType extends string = string>(
|
|
38
|
+
options: UseWellPlateValidationOptions<TContext, TType>,
|
|
39
|
+
): UseWellPlateValidationReturn<TType> {
|
|
40
|
+
const warnings = computed(() => runWellPlateValidation(
|
|
41
|
+
toValue(options.context),
|
|
42
|
+
toValue(options.rules),
|
|
43
|
+
))
|
|
44
|
+
const hasBlockingIssues = computed(() => warnings.value.some(warning => warning.severity === 'error'))
|
|
45
|
+
const warningCount = computed(() => warnings.value.length)
|
|
46
|
+
const errorCount = computed(() => warnings.value.filter(warning => warning.severity === 'error').length)
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
warnings,
|
|
50
|
+
hasBlockingIssues,
|
|
51
|
+
warningCount,
|
|
52
|
+
errorCount,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function runWellPlateValidation<TContext, TType extends string = string>(
|
|
57
|
+
context: TContext,
|
|
58
|
+
rules: readonly WellPlateValidationRule<TContext, TType>[],
|
|
59
|
+
): WellPlateValidationWarning<TType>[] {
|
|
60
|
+
const warnings: WellPlateValidationWarning<TType>[] = []
|
|
61
|
+
for (const rule of rules) {
|
|
62
|
+
const result = rule.validate(context)
|
|
63
|
+
if (!result) continue
|
|
64
|
+
warnings.push(normalizeWarning(rule, result))
|
|
65
|
+
}
|
|
66
|
+
return warnings
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeWarning<TContext, TType extends string>(
|
|
70
|
+
rule: WellPlateValidationRule<TContext, TType>,
|
|
71
|
+
result: string | Omit<WellPlateValidationWarning<TType>, 'type' | 'severity'>,
|
|
72
|
+
): WellPlateValidationWarning<TType> {
|
|
73
|
+
if (typeof result === 'string') {
|
|
74
|
+
return {
|
|
75
|
+
type: rule.type,
|
|
76
|
+
severity: rule.severity,
|
|
77
|
+
message: result,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
type: rule.type,
|
|
82
|
+
severity: rule.severity,
|
|
83
|
+
...result,
|
|
84
|
+
}
|
|
85
|
+
}
|