virtual-tree-canvas 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +335 -0
  3. package/package.json +40 -0
  4. package/src/assets/.gitkeep +1 -0
  5. package/src/benchmark/benchmark-stats.js +99 -0
  6. package/src/benchmark/index.js +2 -0
  7. package/src/benchmark/renderer-state.js +62 -0
  8. package/src/core/asset-manager.js +25 -0
  9. package/src/core/culling.js +12 -0
  10. package/src/core/event-emitter.js +21 -0
  11. package/src/core/icon-registry.js +173 -0
  12. package/src/core/index.js +19 -0
  13. package/src/core/layout-engine.js +64 -0
  14. package/src/core/patch-batcher.js +29 -0
  15. package/src/core/scene.js +13 -0
  16. package/src/core/search-index.js +70 -0
  17. package/src/core/selection-manager.js +38 -0
  18. package/src/core/theme-manager.js +162 -0
  19. package/src/core/tree-column-model.js +151 -0
  20. package/src/core/tree-expansion-manager.js +67 -0
  21. package/src/core/tree-index.js +79 -0
  22. package/src/core/tree-model.js +80 -0
  23. package/src/core/tree-view-viewport.js +69 -0
  24. package/src/core/tree-worker-client.js +50 -0
  25. package/src/core/tree-worker-operations.js +152 -0
  26. package/src/core/types.js +44 -0
  27. package/src/core/viewport.js +66 -0
  28. package/src/core/visible-row-model.js +137 -0
  29. package/src/index.js +6 -0
  30. package/src/input/index.js +2 -0
  31. package/src/input/pointer-controller.js +66 -0
  32. package/src/input/tree-view-input-controller.js +235 -0
  33. package/src/inspector/cell-editor-manager.js +256 -0
  34. package/src/inspector/editor-resolver.js +33 -0
  35. package/src/inspector/index.js +4 -0
  36. package/src/inspector/model-inspector-builder.js +139 -0
  37. package/src/inspector/model-path.js +48 -0
  38. package/src/renderers/canvas2d-renderer.js +120 -0
  39. package/src/renderers/index.js +3 -0
  40. package/src/renderers/renderer.js +10 -0
  41. package/src/renderers/tree-row-renderer.js +443 -0
  42. package/src/tree-view-controller.js +825 -0
  43. package/src/workers/tree-worker.js +59 -0
@@ -0,0 +1,173 @@
1
+ export class IconRegistry {
2
+ constructor() {
3
+ this.icons = new Map();
4
+ this.loading = new Map();
5
+ this.#registerBuiltIns();
6
+ }
7
+
8
+ register(name, icon) {
9
+ if (!name) throw new Error('Icon name is required');
10
+ if (this.icons.has(name)) return this.icons.get(name);
11
+ if (typeof icon === 'function') {
12
+ this.icons.set(name, { kind: 'vector', draw: icon });
13
+ return this.icons.get(name);
14
+ }
15
+ if (typeof icon === 'string') {
16
+ if (icon.trim().startsWith('<svg')) return this.#registerSvgString(name, icon);
17
+ return this.#registerUrl(name, icon);
18
+ }
19
+ this.icons.set(name, { kind: 'image', image: icon, loaded: true });
20
+ return this.icons.get(name);
21
+ }
22
+
23
+ get(name) {
24
+ return this.icons.get(name) ?? this.icons.get('placeholder');
25
+ }
26
+
27
+ draw(ctx, name, x, y, size, color) {
28
+ const icon = this.get(name);
29
+ if (!icon) return;
30
+ if (icon.kind === 'vector') {
31
+ icon.draw(ctx, x, y, size, color);
32
+ return;
33
+ }
34
+ if (icon.loaded && icon.image) {
35
+ ctx.drawImage(icon.image, x, y, size, size);
36
+ return;
37
+ }
38
+ this.get('placeholder')?.draw(ctx, x, y, size, color);
39
+ }
40
+
41
+ #registerUrl(name, url) {
42
+ if (this.icons.has(name)) return this.icons.get(name);
43
+ const entry = { kind: 'image', image: null, loaded: false, url };
44
+ this.icons.set(name, entry);
45
+ if (typeof Image !== 'undefined' && !this.loading.has(url)) {
46
+ const image = new Image();
47
+ image.decoding = 'async';
48
+ image.onload = () => {
49
+ entry.image = image;
50
+ entry.loaded = true;
51
+ this.loading.delete(url);
52
+ };
53
+ image.src = url;
54
+ this.loading.set(url, image);
55
+ }
56
+ return entry;
57
+ }
58
+
59
+ #registerSvgString(name, svg) {
60
+ if (typeof Blob === 'undefined' || typeof URL === 'undefined') {
61
+ this.icons.set(name, { kind: 'svg', svg, loaded: false });
62
+ return this.icons.get(name);
63
+ }
64
+ const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
65
+ return this.#registerUrl(name, url);
66
+ }
67
+
68
+ #registerBuiltIns() {
69
+ this.register('placeholder', drawPlaceholder);
70
+ this.register('folder', drawFolder);
71
+ this.register('aircraft', drawAircraft);
72
+ this.register('radar', drawRadar);
73
+ this.register('warning', drawWarning);
74
+ this.register('error', drawError);
75
+ this.register('task', drawTask);
76
+ this.register('track', drawTrack);
77
+ }
78
+ }
79
+
80
+ function drawPlaceholder(ctx, x, y, size, color) {
81
+ ctx.strokeStyle = color;
82
+ ctx.strokeRect(x + 2.5, y + 2.5, size - 5, size - 5);
83
+ }
84
+
85
+ function drawFolder(ctx, x, y, size, color) {
86
+ ctx.fillStyle = color;
87
+ ctx.beginPath();
88
+ ctx.moveTo(x + 1, y + 5);
89
+ ctx.lineTo(x + size * 0.38, y + 5);
90
+ ctx.lineTo(x + size * 0.48, y + 8);
91
+ ctx.lineTo(x + size - 1, y + 8);
92
+ ctx.lineTo(x + size - 1, y + size - 2);
93
+ ctx.lineTo(x + 1, y + size - 2);
94
+ ctx.closePath();
95
+ ctx.fill();
96
+ }
97
+
98
+ function drawAircraft(ctx, x, y, size, color) {
99
+ ctx.fillStyle = color;
100
+ ctx.beginPath();
101
+ ctx.moveTo(x + size * 0.5, y + 1);
102
+ ctx.lineTo(x + size * 0.62, y + size * 0.58);
103
+ ctx.lineTo(x + size - 1, y + size * 0.72);
104
+ ctx.lineTo(x + size * 0.58, y + size * 0.78);
105
+ ctx.lineTo(x + size * 0.54, y + size - 1);
106
+ ctx.lineTo(x + size * 0.46, y + size - 1);
107
+ ctx.lineTo(x + size * 0.42, y + size * 0.78);
108
+ ctx.lineTo(x + 1, y + size * 0.72);
109
+ ctx.lineTo(x + size * 0.38, y + size * 0.58);
110
+ ctx.closePath();
111
+ ctx.fill();
112
+ }
113
+
114
+ function drawRadar(ctx, x, y, size, color) {
115
+ ctx.strokeStyle = color;
116
+ ctx.lineWidth = 1.5;
117
+ ctx.beginPath();
118
+ ctx.arc(x + size / 2, y + size / 2, size * 0.34, -0.4, Math.PI * 1.4);
119
+ ctx.moveTo(x + size / 2, y + size / 2);
120
+ ctx.lineTo(x + size * 0.84, y + size * 0.28);
121
+ ctx.stroke();
122
+ ctx.fillStyle = color;
123
+ ctx.beginPath();
124
+ ctx.arc(x + size / 2, y + size / 2, 2, 0, Math.PI * 2);
125
+ ctx.fill();
126
+ }
127
+
128
+ function drawWarning(ctx, x, y, size, color) {
129
+ ctx.fillStyle = color;
130
+ ctx.beginPath();
131
+ ctx.moveTo(x + size / 2, y + 1);
132
+ ctx.lineTo(x + size - 1, y + size - 2);
133
+ ctx.lineTo(x + 1, y + size - 2);
134
+ ctx.closePath();
135
+ ctx.fill();
136
+ }
137
+
138
+ function drawError(ctx, x, y, size, color) {
139
+ ctx.fillStyle = color;
140
+ ctx.beginPath();
141
+ ctx.arc(x + size / 2, y + size / 2, size * 0.42, 0, Math.PI * 2);
142
+ ctx.fill();
143
+ ctx.strokeStyle = '#ffffff';
144
+ ctx.beginPath();
145
+ ctx.moveTo(x + 5, y + 5);
146
+ ctx.lineTo(x + size - 5, y + size - 5);
147
+ ctx.moveTo(x + size - 5, y + 5);
148
+ ctx.lineTo(x + 5, y + size - 5);
149
+ ctx.stroke();
150
+ }
151
+
152
+ function drawTask(ctx, x, y, size, color) {
153
+ ctx.strokeStyle = color;
154
+ ctx.lineWidth = 1.5;
155
+ ctx.strokeRect(x + 3, y + 2, size - 6, size - 4);
156
+ ctx.beginPath();
157
+ ctx.moveTo(x + 5, y + size * 0.52);
158
+ ctx.lineTo(x + size * 0.42, y + size - 5);
159
+ ctx.lineTo(x + size - 5, y + 5);
160
+ ctx.stroke();
161
+ }
162
+
163
+ function drawTrack(ctx, x, y, size, color) {
164
+ ctx.strokeStyle = color;
165
+ ctx.lineWidth = 1.5;
166
+ ctx.beginPath();
167
+ ctx.arc(x + size / 2, y + size / 2, size * 0.34, 0, Math.PI * 2);
168
+ ctx.moveTo(x + size / 2, y + 1);
169
+ ctx.lineTo(x + size / 2, y + size - 1);
170
+ ctx.moveTo(x + 1, y + size / 2);
171
+ ctx.lineTo(x + size - 1, y + size / 2);
172
+ ctx.stroke();
173
+ }
@@ -0,0 +1,19 @@
1
+ export * from './asset-manager.js';
2
+ export * from './culling.js';
3
+ export * from './event-emitter.js';
4
+ export * from './icon-registry.js';
5
+ export * from './layout-engine.js';
6
+ export * from './patch-batcher.js';
7
+ export * from './scene.js';
8
+ export * from './search-index.js';
9
+ export * from './selection-manager.js';
10
+ export * from './theme-manager.js';
11
+ export * from './tree-column-model.js';
12
+ export * from './tree-expansion-manager.js';
13
+ export * from './tree-index.js';
14
+ export * from './tree-model.js';
15
+ export * from './tree-worker-client.js';
16
+ export * from './tree-worker-operations.js';
17
+ export * from './tree-view-viewport.js';
18
+ export * from './viewport.js';
19
+ export * from './visible-row-model.js';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Simple top-down hierarchical layout. Replaceable by design.
3
+ */
4
+ export class TreeLayoutEngine {
5
+ constructor(options = {}) {
6
+ this.nodeWidth = options.nodeWidth ?? 132;
7
+ this.nodeHeight = options.nodeHeight ?? 26;
8
+ this.levelGap = options.levelGap ?? 92;
9
+ this.rowGap = options.rowGap ?? 12;
10
+ }
11
+
12
+ /**
13
+ * @param {import('./tree-model.js').TreeModel} model
14
+ */
15
+ layout(model) {
16
+ /** @type {import('./types.js').LayoutNode[]} */
17
+ const layoutNodes = [];
18
+ /** @type {{ sourceIndex: number, targetIndex: number }[]} */
19
+ const edges = [];
20
+ const typeMap = new Map();
21
+ const iconMap = new Map();
22
+ let row = 0;
23
+
24
+ const typeIndex = (value = '') => {
25
+ if (!typeMap.has(value)) typeMap.set(value, typeMap.size);
26
+ return typeMap.get(value);
27
+ };
28
+ const iconIndex = (value = '') => {
29
+ if (!iconMap.has(value)) iconMap.set(value, iconMap.size);
30
+ return iconMap.get(value);
31
+ };
32
+
33
+ const visit = (id, depth, parentIndex) => {
34
+ if (!model.isVisibleByExpansion(id)) return;
35
+ const node = model.index.getNode(id);
36
+ if (!node) return;
37
+ const index = layoutNodes.length;
38
+ const item = {
39
+ index,
40
+ id: node.id,
41
+ parentIndex,
42
+ x: depth * this.levelGap,
43
+ y: row * (this.nodeHeight + this.rowGap),
44
+ width: this.nodeWidth,
45
+ height: this.nodeHeight,
46
+ depth,
47
+ iconIndex: iconIndex(node.icon),
48
+ typeIndex: typeIndex(node.type),
49
+ };
50
+ layoutNodes.push(item);
51
+ if (parentIndex >= 0) edges.push({ sourceIndex: parentIndex, targetIndex: index });
52
+ row++;
53
+ if (!model.expanded.has(id)) return;
54
+ for (const childId of model.index.getChildren(id)) visit(childId, depth + 1, index);
55
+ };
56
+
57
+ for (const rootId of model.index.roots) visit(rootId, 0, -1);
58
+
59
+ const width = layoutNodes.reduce((max, node) => Math.max(max, node.x + node.width), 0);
60
+ const height = layoutNodes.reduce((max, node) => Math.max(max, node.y + node.height), 0);
61
+ return { nodes: layoutNodes, edges, bounds: { x: 0, y: 0, width, height }, typeMap, iconMap };
62
+ }
63
+ }
64
+
@@ -0,0 +1,29 @@
1
+ export class PatchBatcher {
2
+ constructor() {
3
+ /** @type {Map<string, import('./types.js').NodeDynamicState>} */
4
+ this.pending = new Map();
5
+ }
6
+
7
+ /** @param {string} id @param {import('./types.js').NodeDynamicState} state */
8
+ set(id, state) {
9
+ const current = this.pending.get(id);
10
+ if (current) Object.assign(current, state);
11
+ else this.pending.set(id, { ...state });
12
+ }
13
+
14
+ /** @param {Array<import('./types.js').DynamicPatch>} patches */
15
+ addMany(patches) {
16
+ for (const patch of patches) this.set(patch.id, patch.state);
17
+ }
18
+
19
+ flush() {
20
+ const patches = Array.from(this.pending, ([id, state]) => ({ id, state }));
21
+ this.pending.clear();
22
+ return patches;
23
+ }
24
+
25
+ get size() {
26
+ return this.pending.size;
27
+ }
28
+ }
29
+
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Builds the renderer scene from cold-path layout data and hot-path state maps.
3
+ */
4
+ export function createScene(model, layout) {
5
+ const idToLayoutIndex = new Map(layout.nodes.map((node) => [node.id, node.index]));
6
+ return {
7
+ model,
8
+ layout,
9
+ idToLayoutIndex,
10
+ dynamicState: model.dynamicState,
11
+ };
12
+ }
13
+
@@ -0,0 +1,70 @@
1
+ export class TreeSearchIndex {
2
+ constructor() {
3
+ this.records = [];
4
+ this.results = [];
5
+ this.cursor = -1;
6
+ this.lastQuery = '';
7
+ }
8
+
9
+ /** @param {import('./tree-model.js').TreeModel} model */
10
+ rebuild(model) {
11
+ this.records = model.nodes.map((node) => ({
12
+ id: node.id,
13
+ label: (node.label ?? '').toLowerCase(),
14
+ path: (model.index.pathById.get(node.id) ?? '').toLowerCase(),
15
+ tags: (node.tags ?? []).join(' ').toLowerCase(),
16
+ type: (node.type ?? '').toLowerCase(),
17
+ }));
18
+ }
19
+
20
+ /**
21
+ * @param {string} query
22
+ * @param {{ fields?: string[], limit?: number }} options
23
+ */
24
+ search(query, options = {}) {
25
+ const q = query.trim().toLowerCase();
26
+ this.lastQuery = query;
27
+ if (!q) {
28
+ this.results = [];
29
+ this.cursor = -1;
30
+ return [];
31
+ }
32
+ const fields = options.fields ?? ['label', 'id', 'path', 'tags', 'type'];
33
+ const limit = options.limit ?? 100;
34
+ const results = [];
35
+ for (const record of this.records) {
36
+ for (const field of fields) {
37
+ if (String(record[field] ?? '').includes(q)) {
38
+ results.push(record.id);
39
+ break;
40
+ }
41
+ }
42
+ if (results.length >= limit) break;
43
+ }
44
+ this.results = results;
45
+ this.cursor = results.length ? 0 : -1;
46
+ return results;
47
+ }
48
+
49
+ nextSearchResult() {
50
+ if (!this.results.length) return null;
51
+ this.cursor = (this.cursor + 1) % this.results.length;
52
+ return this.results[this.cursor];
53
+ }
54
+
55
+ previousSearchResult() {
56
+ if (!this.results.length) return null;
57
+ this.cursor = (this.cursor - 1 + this.results.length) % this.results.length;
58
+ return this.results[this.cursor];
59
+ }
60
+
61
+ currentSearchResult() {
62
+ return this.cursor >= 0 ? this.results[this.cursor] ?? null : null;
63
+ }
64
+
65
+ clear() {
66
+ this.results = [];
67
+ this.cursor = -1;
68
+ this.lastQuery = '';
69
+ }
70
+ }
@@ -0,0 +1,38 @@
1
+ export class TreeSelectionManager extends EventTarget {
2
+ constructor() {
3
+ super();
4
+ this.selected = new Set();
5
+ this.hovered = null;
6
+ this.focused = null;
7
+ }
8
+
9
+ setHover(id) {
10
+ if (this.hovered === id) return;
11
+ this.hovered = id;
12
+ this.dispatchEvent(new Event('change'));
13
+ }
14
+
15
+ select(id, additive = false) {
16
+ if (!additive) this.selected.clear();
17
+ if (id) this.selected.add(id);
18
+ this.focused = id;
19
+ this.dispatchEvent(new Event('change'));
20
+ }
21
+
22
+ toggle(id) {
23
+ if (this.selected.has(id)) this.selected.delete(id);
24
+ else this.selected.add(id);
25
+ this.focused = id;
26
+ this.dispatchEvent(new Event('change'));
27
+ }
28
+
29
+ clear() {
30
+ this.selected.clear();
31
+ this.focused = null;
32
+ this.dispatchEvent(new Event('change'));
33
+ }
34
+
35
+ toPatches() {
36
+ return Array.from(this.selected, (id) => ({ id, state: { selected: true } }));
37
+ }
38
+ }
@@ -0,0 +1,162 @@
1
+ export const darkTheme = {
2
+ rowHeight: 28,
3
+ indentWidth: 18,
4
+ font: '12px system-ui, sans-serif',
5
+ colors: {
6
+ background: '#0b1020',
7
+ row: '#0b1020',
8
+ rowHover: '#111827',
9
+ rowSelected: '#1e3a8a',
10
+ rowHighlighted: '#3b0764',
11
+ text: '#e5e7eb',
12
+ textMuted: '#94a3b8',
13
+ guide: '#1f2937',
14
+ chevron: '#94a3b8',
15
+ focus: '#38bdf8',
16
+ progressTrack: '#1f2937',
17
+ progressFill: '#22c55e',
18
+ badgeText: '#ffffff',
19
+ border: '#1f2937',
20
+ },
21
+ types: {
22
+ root: { icon: 'folder', color: '#38bdf8' },
23
+ system: { icon: 'folder', color: '#818cf8' },
24
+ platform: { icon: 'aircraft', color: '#60a5fa' },
25
+ sensor: { icon: 'radar', color: '#34d399' },
26
+ track: { icon: 'track', color: '#a78bfa' },
27
+ warning: { icon: 'warning', color: '#facc15' },
28
+ error: { icon: 'error', color: '#ef4444' },
29
+ task: { icon: 'task', color: '#f97316' },
30
+ },
31
+ statuses: {
32
+ 0: { label: 'OK', color: '#22c55e' },
33
+ 1: { label: 'WARN', color: '#facc15' },
34
+ 2: { label: 'ERR', color: '#ef4444' },
35
+ },
36
+ };
37
+
38
+ export const lightTheme = {
39
+ ...darkTheme,
40
+ colors: {
41
+ background: '#f8fafc',
42
+ row: '#ffffff',
43
+ rowHover: '#e2e8f0',
44
+ rowSelected: '#bfdbfe',
45
+ rowHighlighted: '#f3e8ff',
46
+ text: '#0f172a',
47
+ textMuted: '#64748b',
48
+ guide: '#cbd5e1',
49
+ chevron: '#64748b',
50
+ focus: '#0284c7',
51
+ progressTrack: '#e2e8f0',
52
+ progressFill: '#16a34a',
53
+ badgeText: '#ffffff',
54
+ border: '#e2e8f0',
55
+ },
56
+ };
57
+
58
+ export const tacticalTheme = {
59
+ ...darkTheme,
60
+ colors: {
61
+ background: '#050806',
62
+ row: '#050806',
63
+ rowHover: '#0d1f13',
64
+ rowSelected: '#12351f',
65
+ rowHighlighted: '#372b0a',
66
+ text: '#d7ffe3',
67
+ textMuted: '#7fa88c',
68
+ guide: '#183321',
69
+ chevron: '#7fa88c',
70
+ focus: '#7ddc92',
71
+ progressTrack: '#102217',
72
+ progressFill: '#45d483',
73
+ badgeText: '#031006',
74
+ border: '#183321',
75
+ },
76
+ types: {
77
+ ...darkTheme.types,
78
+ root: { icon: 'folder', color: '#7ddc92' },
79
+ platform: { icon: 'aircraft', color: '#93c572' },
80
+ sensor: { icon: 'radar', color: '#45d483' },
81
+ track: { icon: 'track', color: '#d6f264' },
82
+ warning: { icon: 'warning', color: '#ffd166' },
83
+ error: { icon: 'error', color: '#ff5c5c' },
84
+ },
85
+ };
86
+
87
+ export const themes = {
88
+ dark: darkTheme,
89
+ light: lightTheme,
90
+ tactical: tacticalTheme,
91
+ };
92
+
93
+ export class ThemeManager extends EventTarget {
94
+ constructor(theme = darkTheme) {
95
+ super();
96
+ this.theme = normalizeTheme(theme);
97
+ }
98
+
99
+ setTheme(theme) {
100
+ this.theme = normalizeTheme(theme);
101
+ this.dispatchEvent(new Event('change'));
102
+ }
103
+
104
+ get() {
105
+ return this.theme;
106
+ }
107
+
108
+ /** @param {import('./types.js').TreeNode} node @param {import('./types.js').NodeDynamicState} state */
109
+ resolveNodeStyle(node, state = {}) {
110
+ const typeRule = this.theme.types[node?.type ?? ''] ?? {};
111
+ const statusRule = this.resolveStatus(state.status);
112
+ return {
113
+ icon: state.icon ?? node?.icon ?? typeRule.icon ?? 'placeholder',
114
+ color: state.color ?? typeRule.color ?? this.theme.colors.progressFill,
115
+ typeColor: typeRule.color ?? this.theme.colors.progressFill,
116
+ status: statusRule,
117
+ font: this.theme.font,
118
+ colors: this.theme.colors,
119
+ };
120
+ }
121
+
122
+ resolveStatus(status) {
123
+ const key = status ?? 0;
124
+ return this.theme.statuses[key] ?? { label: String(key), color: this.theme.colors.textMuted };
125
+ }
126
+ }
127
+
128
+ function normalizeTheme(theme = {}) {
129
+ const base = mergeTheme(darkTheme, theme);
130
+ return {
131
+ ...base,
132
+ // Compatibility aliases for the graph renderer and older row renderer code.
133
+ background: base.colors.background,
134
+ nodeFill: base.colors.progressTrack,
135
+ nodeStroke: base.colors.border,
136
+ edge: base.colors.guide,
137
+ label: base.colors.text,
138
+ mutedLabel: base.colors.textMuted,
139
+ selected: base.colors.focus,
140
+ selectedRow: base.colors.rowSelected,
141
+ highlighted: base.colors.rowHighlighted,
142
+ highlightedRow: base.colors.rowHighlighted,
143
+ hoverRow: base.colors.rowHover,
144
+ rowBorder: base.colors.border,
145
+ focusOutline: base.colors.focus,
146
+ indentGuide: base.colors.guide,
147
+ chevron: base.colors.chevron,
148
+ chevronMuted: base.colors.textMuted,
149
+ progress: base.colors.progressFill,
150
+ };
151
+ }
152
+
153
+ function mergeTheme(base, override) {
154
+ return {
155
+ ...base,
156
+ ...override,
157
+ colors: { ...base.colors, ...(override.colors ?? {}) },
158
+ types: { ...base.types, ...(override.types ?? {}) },
159
+ statuses: { ...base.statuses, ...(override.statuses ?? {}) },
160
+ };
161
+ }
162
+