Skip to content

Commit 3502d9f

Browse files
committed
feat(config): add flexible layout configuration
Add support for configuring view layouts via runtime config JSON with two approaches: 1. Simple grid layouts using 2D string arrays: { "layout": [["axial", "sagittal"], ["coronal", "volume"]] } 2. Nested hierarchical layouts with full control: { "layout": { "direction": "V", "items": [ "axial", { "direction": "H", "items": ["volume", "coronal"] } ] } } Features: - String shortcuts for common views (axial, coronal, sagittal, volume, oblique) - Full view objects with custom options (viewDirection, viewUp, orientation) - Mix strings and full specs in nested layouts - Backwards compatible with existing gridSize format Implementation: - Add Zod schemas for layout config validation - Add parseGridLayout() and parseNestedLayout() parsers - Add setLayoutWithViews() method to view store - Add comprehensive e2e tests (4/4 new tests passing) Related directories created in volview-backend/files: - prostate-simple-grid/ - Example 2x2 grid layout - prostate-custom-layout/ - Example asymmetric nested layout
1 parent 5bfd831 commit 3502d9f

File tree

3 files changed

+432
-7
lines changed

3 files changed

+432
-7
lines changed

src/io/import/configJson.ts

Lines changed: 202 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,70 @@ import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
1111
import { useSegmentGroupStore } from '@/src/store/segmentGroups';
1212
import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool';
1313
import useLoadDataStore from '@/src/store/load-data';
14+
import type { Layout, LayoutItem } from '@/src/types/layout';
15+
import type { ViewInfoInit } from '@/src/types/views';
1416

1517
// --------------------------------------------------------------------------
16-
// Interface
18+
// View Specifications
1719

18-
const layout = z
19-
.object({
20-
gridSize: z.tuple([z.number(), z.number()]).optional(),
21-
})
22-
.optional();
20+
const viewString = z.enum([
21+
'axial',
22+
'coronal',
23+
'sagittal',
24+
'volume',
25+
'oblique',
26+
]);
27+
28+
const view2D = z.object({
29+
type: z.literal('2D'),
30+
name: z.string().optional(),
31+
orientation: z.enum(['Axial', 'Coronal', 'Sagittal']),
32+
});
33+
34+
const view3D = z.object({
35+
type: z.literal('3D'),
36+
name: z.string().optional(),
37+
viewDirection: z.enum(['Left', 'Right', 'Posterior', 'Anterior', 'Superior', 'Inferior']).optional(),
38+
viewUp: z.enum(['Left', 'Right', 'Posterior', 'Anterior', 'Superior', 'Inferior']).optional(),
39+
});
40+
41+
const viewOblique = z.object({
42+
type: z.literal('Oblique'),
43+
name: z.string().optional(),
44+
});
45+
46+
const viewSpec = z.union([viewString, view2D, view3D, viewOblique]);
47+
48+
// --------------------------------------------------------------------------
49+
// Layout Specifications
50+
51+
type LayoutConfigItem = z.infer<typeof viewSpec> | {
52+
direction: 'H' | 'V';
53+
items: LayoutConfigItem[];
54+
};
55+
56+
const layoutConfigItem: z.ZodType<LayoutConfigItem> = z.lazy(() =>
57+
z.union([
58+
viewSpec,
59+
z.object({
60+
direction: z.enum(['H', 'V']),
61+
items: z.array(layoutConfigItem),
62+
}),
63+
])
64+
);
65+
66+
const layoutConfig = z.union([
67+
z.array(z.array(viewString)),
68+
z.object({
69+
direction: z.enum(['H', 'V']),
70+
items: z.array(layoutConfigItem),
71+
}),
72+
z.object({
73+
gridSize: z.tuple([z.number(), z.number()]),
74+
}),
75+
]);
76+
77+
const layout = layoutConfig.optional();
2378

2479
const shortcuts = z.record(zodEnumFromObjKeys(ACTIONS), z.string()).optional();
2580

@@ -89,6 +144,138 @@ export const readConfigFile = async (configFile: File) => {
89144
return config.parse(JSON.parse(text));
90145
};
91146

147+
// --------------------------------------------------------------------------
148+
// Layout Parsing
149+
150+
const stringToViewInfoInit = (str: z.infer<typeof viewString>): ViewInfoInit => {
151+
switch (str) {
152+
case 'axial':
153+
return { name: 'Axial', type: '2D', dataID: null, options: { orientation: 'Axial' } };
154+
case 'coronal':
155+
return { name: 'Coronal', type: '2D', dataID: null, options: { orientation: 'Coronal' } };
156+
case 'sagittal':
157+
return { name: 'Sagittal', type: '2D', dataID: null, options: { orientation: 'Sagittal' } };
158+
case 'volume':
159+
return { name: 'Volume', type: '3D', dataID: null, options: { viewDirection: 'Posterior', viewUp: 'Superior' } };
160+
case 'oblique':
161+
return { name: 'Oblique', type: 'Oblique', dataID: null, options: {} };
162+
default:
163+
throw new Error(`Unknown view string: ${str}`);
164+
}
165+
};
166+
167+
const viewSpecToViewInfoInit = (spec: z.infer<typeof viewSpec>): ViewInfoInit => {
168+
if (typeof spec === 'string') {
169+
return stringToViewInfoInit(spec);
170+
}
171+
172+
if (spec.type === '2D') {
173+
return {
174+
name: spec.name ?? spec.orientation,
175+
type: '2D',
176+
dataID: null,
177+
options: { orientation: spec.orientation },
178+
};
179+
}
180+
181+
if (spec.type === '3D') {
182+
return {
183+
name: spec.name ?? 'Volume',
184+
type: '3D',
185+
dataID: null,
186+
options: {
187+
viewDirection: spec.viewDirection ?? 'Posterior',
188+
viewUp: spec.viewUp ?? 'Superior',
189+
},
190+
};
191+
}
192+
193+
if (spec.type === 'Oblique') {
194+
return {
195+
name: spec.name ?? 'Oblique',
196+
type: 'Oblique',
197+
dataID: null,
198+
options: {},
199+
};
200+
}
201+
202+
throw new Error(`Unknown view spec type`);
203+
};
204+
205+
const parseGridLayout = (grid: z.infer<typeof viewString>[][]): { layout: Layout; views: ViewInfoInit[] } => {
206+
const views: ViewInfoInit[] = [];
207+
let slotIndex = 0;
208+
209+
const items: LayoutItem[] = grid.map((row) => {
210+
const rowItems: LayoutItem[] = row.map(() => {
211+
const currentSlot = slotIndex;
212+
slotIndex += 1;
213+
return { type: 'slot' as const, slotIndex: currentSlot };
214+
});
215+
216+
return {
217+
type: 'layout' as const,
218+
direction: 'V' as const,
219+
items: rowItems,
220+
};
221+
});
222+
223+
grid.flat().forEach((viewStr) => {
224+
views.push(stringToViewInfoInit(viewStr));
225+
});
226+
227+
return {
228+
layout: {
229+
direction: 'H',
230+
items,
231+
},
232+
views,
233+
};
234+
};
235+
236+
const parseNestedLayout = (
237+
layoutItem: LayoutConfigItem
238+
): { layout: Layout; views: ViewInfoInit[] } => {
239+
const views: ViewInfoInit[] = [];
240+
let slotIndex = 0;
241+
242+
const processItem = (item: LayoutConfigItem): LayoutItem => {
243+
if (typeof item === 'string' || 'type' in item) {
244+
const viewInfo = viewSpecToViewInfoInit(item as z.infer<typeof viewSpec>);
245+
views.push(viewInfo);
246+
const currentSlot = slotIndex;
247+
slotIndex += 1;
248+
return { type: 'slot', slotIndex: currentSlot };
249+
}
250+
251+
return {
252+
type: 'layout',
253+
direction: item.direction,
254+
items: item.items.map(processItem),
255+
};
256+
};
257+
258+
if (typeof layoutItem === 'string' || 'type' in layoutItem) {
259+
const viewInfo = viewSpecToViewInfoInit(layoutItem as z.infer<typeof viewSpec>);
260+
views.push(viewInfo);
261+
return {
262+
layout: {
263+
direction: 'H',
264+
items: [{ type: 'slot', slotIndex: 0 }],
265+
},
266+
views,
267+
};
268+
}
269+
270+
return {
271+
layout: {
272+
direction: layoutItem.direction,
273+
items: layoutItem.items.map(processItem),
274+
},
275+
views,
276+
};
277+
};
278+
92279
const applyLabels = (manifest: Config) => {
93280
if (!manifest.labels) return;
94281

@@ -115,8 +302,16 @@ const applyLabels = (manifest: Config) => {
115302
};
116303

117304
const applyLayout = (manifest: Config) => {
118-
if (manifest.layout?.gridSize) {
305+
if (!manifest.layout) return;
306+
307+
if (Array.isArray(manifest.layout)) {
308+
const parsedLayout = parseGridLayout(manifest.layout);
309+
useViewStore().setLayoutWithViews(parsedLayout.layout, parsedLayout.views);
310+
} else if ('gridSize' in manifest.layout) {
119311
useViewStore().setLayoutFromGrid(manifest.layout.gridSize);
312+
} else {
313+
const parsedLayout = parseNestedLayout(manifest.layout);
314+
useViewStore().setLayoutWithViews(parsedLayout.layout, parsedLayout.views);
120315
}
121316
};
122317

src/store/views.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,24 @@ export const useViewStore = defineStore('view', () => {
168168
setLayout(generateLayoutFromGrid(gridSize));
169169
}
170170

171+
function setLayoutWithViews(newLayout: Layout, views: ViewInfoInit[]) {
172+
isActiveViewMaximized.value = false;
173+
174+
const newLayoutViewCount = calcLayoutViewCount(newLayout);
175+
if (newLayoutViewCount !== views.length) {
176+
throw new Error('Layout view count does not match views array length');
177+
}
178+
179+
layoutSlots.value = views.map((viewInit) => addView(viewInit));
180+
layout.value = newLayout;
181+
182+
if (!visibleViews.value.length) {
183+
setActiveView(null);
184+
} else {
185+
setActiveView(visibleViews.value[0].id);
186+
}
187+
}
188+
171189
function setDataForView(viewID: string, dataID: Maybe<string>) {
172190
if (!(viewID in viewByID)) return;
173191
viewByID[viewID].dataID = dataID;
@@ -248,6 +266,7 @@ export const useViewStore = defineStore('view', () => {
248266
replaceView,
249267
setLayout,
250268
setLayoutFromGrid,
269+
setLayoutWithViews,
251270
setActiveView,
252271
setDataForView,
253272
setDataForActiveView,

0 commit comments

Comments
 (0)