@grafloria/element 0.4.2 → 0.4.3
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/package.json +1 -1
- package/src/lib/dashboard-kit/dashboard.d.ts +147 -1
- package/src/lib/dashboard-kit/dashboard.js +524 -113
- package/src/lib/dashboard-kit/grid-binder.d.ts +43 -0
- package/src/lib/dashboard-kit/grid-binder.js +719 -106
- package/src/lib/dashboard-kit/index.d.ts +2 -0
- package/src/lib/dashboard-kit/index.js +2 -0
- package/src/lib/dashboard-kit/split-binder.d.ts +69 -0
- package/src/lib/dashboard-kit/split-binder.js +1042 -0
- package/src/lib/dashboard-kit/split-layout.d.ts +128 -0
- package/src/lib/dashboard-kit/split-layout.js +425 -0
- package/src/lib/dashboard-kit/styles.js +137 -20
- package/src/lib/dashboard-kit/widgets.d.ts +16 -1
- package/src/lib/dashboard-kit/widgets.js +146 -33
- package/src/lib/diagram-kit/card.d.ts +46 -0
- package/src/lib/diagram-kit/card.js +149 -0
- package/src/lib/diagram-kit/er.js +5 -3
- package/src/lib/diagram-kit/styles.js +40 -5
- package/src/lib/diagram-kit/uml.js +3 -4
- package/src/lib/diagram-kit/update.js +7 -3
- package/src/lib/grafloria.js +4 -4
- package/src/lib/load.d.ts +5 -0
- package/src/lib/load.js +72 -8
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPLIT LAYOUT — the DevExpress dashboard model, as pure arithmetic.
|
|
3
|
+
*
|
|
4
|
+
* A board is a tree: groups split their slot into children along one axis
|
|
5
|
+
* ('row' = left→right, 'column' = top→bottom), each child taking a WEIGHT of
|
|
6
|
+
* the group's length; leaves are the widgets. Every slot is covered exactly,
|
|
7
|
+
* so there is never an empty space — one widget fills the whole board, a
|
|
8
|
+
* second halves it, a third halves the larger half the other way. Measured
|
|
9
|
+
* on the DevExpress Web Dashboard designer (6 Sep 2026): one item 1481×823;
|
|
10
|
+
* two → 409/409; a third → the larger halved 738+738; a divider drag moved
|
|
11
|
+
* 409/409 to 286/532 (a percentage, not a pixel size); the item alone in its
|
|
12
|
+
* group has no divider of its own — its size IS the group's.
|
|
13
|
+
*
|
|
14
|
+
* Nothing in here touches the DOM or the model: the binder projects the tree
|
|
15
|
+
* onto a frame with `projectSplit`, hit-tests `dividersOf`, and persists the
|
|
16
|
+
* tree as plain JSON (`dashboardTree` on the board's group). One tree write
|
|
17
|
+
* per gesture is the whole undo story.
|
|
18
|
+
*/
|
|
19
|
+
import type { CellRect, WorldRect } from './grid-mapping.js';
|
|
20
|
+
export type SplitDir = 'row' | 'column';
|
|
21
|
+
export interface SplitLeaf {
|
|
22
|
+
id: string;
|
|
23
|
+
weight: number;
|
|
24
|
+
}
|
|
25
|
+
export interface SplitGroup {
|
|
26
|
+
dir: SplitDir;
|
|
27
|
+
weight: number;
|
|
28
|
+
children: SplitNode[];
|
|
29
|
+
}
|
|
30
|
+
export type SplitNode = SplitLeaf | SplitGroup;
|
|
31
|
+
export type SplitSide = 'left' | 'right' | 'top' | 'bottom';
|
|
32
|
+
export declare const isSplitGroup: (n: SplitNode | null | undefined) => n is SplitGroup;
|
|
33
|
+
/** Every widget id in the tree, depth-first, in reading order. */
|
|
34
|
+
export declare function splitLeaves(root: SplitNode | null): string[];
|
|
35
|
+
/** A deep copy — commands keep the before/after trees, so they must not share. */
|
|
36
|
+
export declare function cloneSplit<T extends SplitNode | null>(root: T): T;
|
|
37
|
+
/** Index path from the root to the leaf with `id` (empty for the root itself), or null. */
|
|
38
|
+
export declare function pathToLeaf(root: SplitNode | null, id: string, path?: number[]): number[] | null;
|
|
39
|
+
export declare function nodeAt(root: SplitNode | null, path: readonly number[]): SplitNode | null;
|
|
40
|
+
/**
|
|
41
|
+
* Project the tree onto `frame`: every leaf's world rect, `gap` px between
|
|
42
|
+
* siblings, `padding` px inside the frame. A group's children share its
|
|
43
|
+
* length in proportion to their weights.
|
|
44
|
+
*/
|
|
45
|
+
export declare function projectSplit(root: SplitNode | null, frame: WorldRect, gap?: number, padding?: number, rtl?: boolean): Map<string, WorldRect>;
|
|
46
|
+
/** Every GROUP's projected rect, keyed by its index path — drop targets for "under all of these". */
|
|
47
|
+
export interface SplitGroupRect {
|
|
48
|
+
path: number[];
|
|
49
|
+
dir: SplitDir;
|
|
50
|
+
rect: WorldRect;
|
|
51
|
+
}
|
|
52
|
+
export declare function groupRectsOf(root: SplitNode | null, frame: WorldRect, gap?: number, padding?: number, rtl?: boolean): SplitGroupRect[];
|
|
53
|
+
/** One draggable divider: the gap between two siblings of a group. */
|
|
54
|
+
export interface SplitDivider {
|
|
55
|
+
/** Index path to the GROUP that owns the divider. */
|
|
56
|
+
path: number[];
|
|
57
|
+
/** The divider sits after child `index` (between `index` and `index + 1`). */
|
|
58
|
+
index: number;
|
|
59
|
+
dir: SplitDir;
|
|
60
|
+
/** The gap's world rect — the hit zone the binder widens. */
|
|
61
|
+
rect: WorldRect;
|
|
62
|
+
/** The group's free length along its axis, so a px delta becomes a fraction. */
|
|
63
|
+
length: number;
|
|
64
|
+
}
|
|
65
|
+
export declare function dividersOf(root: SplitNode | null, frame: WorldRect, gap?: number, padding?: number, rtl?: boolean): SplitDivider[];
|
|
66
|
+
/**
|
|
67
|
+
* Split the leaf `targetId`'s slot to make room for `newId`: along `dir`,
|
|
68
|
+
* the newcomer `before` or after the target. If the target's parent already
|
|
69
|
+
* runs along `dir` the newcomer becomes a sibling and the two share the
|
|
70
|
+
* target's former weight; otherwise the target's slot becomes a new group of
|
|
71
|
+
* the two, equal halves. Returns the new root (never mutates the input).
|
|
72
|
+
*/
|
|
73
|
+
export declare function splitLeaf(root: SplitNode | null, targetId: string, newId: string, dir: SplitDir, before?: boolean): SplitNode;
|
|
74
|
+
/**
|
|
75
|
+
* Split the slot of the node at `path` — a leaf OR a whole group — to make
|
|
76
|
+
* room for `newId` beside it: the DevExpress drop on a group's outer edge
|
|
77
|
+
* ("under all of these columns", not under one of them). Same weight rule as
|
|
78
|
+
* `splitLeaf`; a group target keeps its inner structure intact.
|
|
79
|
+
*/
|
|
80
|
+
export declare function splitAt(root: SplitNode | null, path: readonly number[], newId: string, dir: SplitDir, before?: boolean): SplitNode;
|
|
81
|
+
/**
|
|
82
|
+
* ADD, the DevExpress way: the newcomer halves the LARGEST leaf, along that
|
|
83
|
+
* leaf's longer axis — so a wide board's first add gives left / right, and
|
|
84
|
+
* the halves, now taller than wide, split top / bottom under the next add.
|
|
85
|
+
*/
|
|
86
|
+
export declare function addSplitLeaf(root: SplitNode | null, newId: string, frame: WorldRect, gap?: number, padding?: number): SplitNode;
|
|
87
|
+
/**
|
|
88
|
+
* Remove a leaf. Its slot goes to the NEIGHBOUR it was split from — the
|
|
89
|
+
* sibling before it, else the one after (DevExpress: deleting an item hands
|
|
90
|
+
* its space to the adjacent item, and an add followed by a remove gives the
|
|
91
|
+
* board back exactly). A group left with one child collapses into its
|
|
92
|
+
* parent, and a collapsed child running the same way as its new parent is
|
|
93
|
+
* spliced in so the tree never carries a redundant level.
|
|
94
|
+
*/
|
|
95
|
+
export declare function removeSplitLeaf(root: SplitNode | null, id: string): SplitNode | null;
|
|
96
|
+
/** Fold single-child groups and same-direction nesting away. */
|
|
97
|
+
export declare function collapse(node: SplitNode | null): SplitNode | null;
|
|
98
|
+
/**
|
|
99
|
+
* Drop `id` on `side` of `targetId` (a move when `id` is already in the tree).
|
|
100
|
+
* left / right split the target's slot along a row, top / bottom along a
|
|
101
|
+
* column; the mover lands on the named side.
|
|
102
|
+
*/
|
|
103
|
+
export declare function insertSplitLeaf(root: SplitNode | null, id: string, target: string | {
|
|
104
|
+
path: readonly number[];
|
|
105
|
+
}, side: SplitSide): SplitNode | null;
|
|
106
|
+
/**
|
|
107
|
+
* Move the divider after child `index` of the group at `path` by `fraction`
|
|
108
|
+
* of the group's free length (positive = towards the end). Neither neighbour
|
|
109
|
+
* may shrink below `min` of the group. Returns the new root.
|
|
110
|
+
*/
|
|
111
|
+
export declare function moveSplitDivider(root: SplitNode, path: readonly number[], index: number, fraction: number, min?: number): SplitNode;
|
|
112
|
+
/** Weights as fractions summing to 1 in every group — tidy JSON, same picture. */
|
|
113
|
+
export declare function normalizeSplit<T extends SplitNode | null>(root: T): T;
|
|
114
|
+
/**
|
|
115
|
+
* A GRID layout (cells) as a split tree — guillotine cuts: every horizontal
|
|
116
|
+
* line no tile straddles becomes a row boundary (a column group), inside each
|
|
117
|
+
* band every vertical line no tile straddles becomes a column boundary (a row
|
|
118
|
+
* group), and so on. Weights are the bands' cell extents, so the tree paints
|
|
119
|
+
* the grid's proportions. Tiles that interleave with no clean cut (a pinwheel)
|
|
120
|
+
* fall back to a row in reading order.
|
|
121
|
+
*/
|
|
122
|
+
export declare function splitFromCells(cells: ReadonlyMap<string, CellRect>): SplitNode | null;
|
|
123
|
+
/**
|
|
124
|
+
* A split tree as GRID cells: project onto a `columns` × `rows` unit frame and
|
|
125
|
+
* snap every edge to the nearest line (never below one cell). What a designer
|
|
126
|
+
* gets when switching a split board back to the grid.
|
|
127
|
+
*/
|
|
128
|
+
export declare function cellsFromSplit(root: SplitNode | null, columns: number, rows: number): Map<string, CellRect>;
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPLIT LAYOUT — the DevExpress dashboard model, as pure arithmetic.
|
|
3
|
+
*
|
|
4
|
+
* A board is a tree: groups split their slot into children along one axis
|
|
5
|
+
* ('row' = left→right, 'column' = top→bottom), each child taking a WEIGHT of
|
|
6
|
+
* the group's length; leaves are the widgets. Every slot is covered exactly,
|
|
7
|
+
* so there is never an empty space — one widget fills the whole board, a
|
|
8
|
+
* second halves it, a third halves the larger half the other way. Measured
|
|
9
|
+
* on the DevExpress Web Dashboard designer (6 Sep 2026): one item 1481×823;
|
|
10
|
+
* two → 409/409; a third → the larger halved 738+738; a divider drag moved
|
|
11
|
+
* 409/409 to 286/532 (a percentage, not a pixel size); the item alone in its
|
|
12
|
+
* group has no divider of its own — its size IS the group's.
|
|
13
|
+
*
|
|
14
|
+
* Nothing in here touches the DOM or the model: the binder projects the tree
|
|
15
|
+
* onto a frame with `projectSplit`, hit-tests `dividersOf`, and persists the
|
|
16
|
+
* tree as plain JSON (`dashboardTree` on the board's group). One tree write
|
|
17
|
+
* per gesture is the whole undo story.
|
|
18
|
+
*/
|
|
19
|
+
export const isSplitGroup = (n) => !!n && typeof n === 'object' && Array.isArray(n.children);
|
|
20
|
+
/** Every widget id in the tree, depth-first, in reading order. */
|
|
21
|
+
export function splitLeaves(root) {
|
|
22
|
+
if (!root)
|
|
23
|
+
return [];
|
|
24
|
+
return isSplitGroup(root) ? root.children.flatMap(splitLeaves) : [root.id];
|
|
25
|
+
}
|
|
26
|
+
/** A deep copy — commands keep the before/after trees, so they must not share. */
|
|
27
|
+
export function cloneSplit(root) {
|
|
28
|
+
if (!root)
|
|
29
|
+
return root;
|
|
30
|
+
if (isSplitGroup(root)) {
|
|
31
|
+
return { dir: root.dir, weight: root.weight, children: root.children.map((c) => cloneSplit(c)) };
|
|
32
|
+
}
|
|
33
|
+
return { id: root.id, weight: root.weight };
|
|
34
|
+
}
|
|
35
|
+
/** Index path from the root to the leaf with `id` (empty for the root itself), or null. */
|
|
36
|
+
export function pathToLeaf(root, id, path = []) {
|
|
37
|
+
if (!root)
|
|
38
|
+
return null;
|
|
39
|
+
if (!isSplitGroup(root))
|
|
40
|
+
return root.id === id ? path : null;
|
|
41
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
42
|
+
const p = pathToLeaf(root.children[i], id, [...path, i]);
|
|
43
|
+
if (p)
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function nodeAt(root, path) {
|
|
49
|
+
var _a;
|
|
50
|
+
let n = root;
|
|
51
|
+
for (const i of path) {
|
|
52
|
+
if (!isSplitGroup(n))
|
|
53
|
+
return null;
|
|
54
|
+
n = (_a = n.children[i]) !== null && _a !== void 0 ? _a : null;
|
|
55
|
+
}
|
|
56
|
+
return n;
|
|
57
|
+
}
|
|
58
|
+
const sum = (children) => children.reduce((s, c) => s + Math.max(0, c.weight), 0) || 1;
|
|
59
|
+
/**
|
|
60
|
+
* Project the tree onto `frame`: every leaf's world rect, `gap` px between
|
|
61
|
+
* siblings, `padding` px inside the frame. A group's children share its
|
|
62
|
+
* length in proportion to their weights.
|
|
63
|
+
*/
|
|
64
|
+
export function projectSplit(root, frame, gap = 0, padding = 0, rtl = false) {
|
|
65
|
+
const out = new Map();
|
|
66
|
+
if (!root)
|
|
67
|
+
return out;
|
|
68
|
+
const inner = {
|
|
69
|
+
x: frame.x + padding,
|
|
70
|
+
y: frame.y + padding,
|
|
71
|
+
width: Math.max(0, frame.width - 2 * padding),
|
|
72
|
+
height: Math.max(0, frame.height - 2 * padding),
|
|
73
|
+
};
|
|
74
|
+
const walk = (node, r) => {
|
|
75
|
+
if (!isSplitGroup(node)) {
|
|
76
|
+
out.set(node.id, Object.assign({}, r));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const n = node.children.length;
|
|
80
|
+
if (!n)
|
|
81
|
+
return;
|
|
82
|
+
const total = sum(node.children);
|
|
83
|
+
const along = node.dir === 'row' ? r.width : r.height;
|
|
84
|
+
const free = Math.max(0, along - gap * (n - 1));
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
const order = node.dir === 'row' && rtl ? [...node.children].reverse() : node.children;
|
|
87
|
+
for (const child of order) {
|
|
88
|
+
const size = (free * Math.max(0, child.weight)) / total;
|
|
89
|
+
const cr = node.dir === 'row'
|
|
90
|
+
? { x: r.x + cursor, y: r.y, width: size, height: r.height }
|
|
91
|
+
: { x: r.x, y: r.y + cursor, width: r.width, height: size };
|
|
92
|
+
walk(child, cr);
|
|
93
|
+
cursor += size + gap;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(root, inner);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
export function groupRectsOf(root, frame, gap = 0, padding = 0, rtl = false) {
|
|
100
|
+
const out = [];
|
|
101
|
+
if (!root)
|
|
102
|
+
return out;
|
|
103
|
+
const inner = {
|
|
104
|
+
x: frame.x + padding,
|
|
105
|
+
y: frame.y + padding,
|
|
106
|
+
width: Math.max(0, frame.width - 2 * padding),
|
|
107
|
+
height: Math.max(0, frame.height - 2 * padding),
|
|
108
|
+
};
|
|
109
|
+
const walk = (node, r, path) => {
|
|
110
|
+
if (!isSplitGroup(node))
|
|
111
|
+
return;
|
|
112
|
+
out.push({ path, dir: node.dir, rect: Object.assign({}, r) });
|
|
113
|
+
const n = node.children.length;
|
|
114
|
+
if (!n)
|
|
115
|
+
return;
|
|
116
|
+
const total = sum(node.children);
|
|
117
|
+
const along = node.dir === 'row' ? r.width : r.height;
|
|
118
|
+
const free = Math.max(0, along - gap * (n - 1));
|
|
119
|
+
let cursor = 0;
|
|
120
|
+
const mirrored = node.dir === 'row' && rtl;
|
|
121
|
+
for (let i = 0; i < n; i++) {
|
|
122
|
+
const idx = mirrored ? n - 1 - i : i;
|
|
123
|
+
const c = node.children[idx];
|
|
124
|
+
const size = (free * Math.max(0, c.weight)) / total;
|
|
125
|
+
const cr = node.dir === 'row'
|
|
126
|
+
? { x: r.x + cursor, y: r.y, width: size, height: r.height }
|
|
127
|
+
: { x: r.x, y: r.y + cursor, width: r.width, height: size };
|
|
128
|
+
walk(c, cr, [...path, idx]);
|
|
129
|
+
cursor += size + gap;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(root, inner, []);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
export function dividersOf(root, frame, gap = 0, padding = 0, rtl = false) {
|
|
136
|
+
const out = [];
|
|
137
|
+
if (!root)
|
|
138
|
+
return out;
|
|
139
|
+
const inner = {
|
|
140
|
+
x: frame.x + padding,
|
|
141
|
+
y: frame.y + padding,
|
|
142
|
+
width: Math.max(0, frame.width - 2 * padding),
|
|
143
|
+
height: Math.max(0, frame.height - 2 * padding),
|
|
144
|
+
};
|
|
145
|
+
const walk = (node, r, path) => {
|
|
146
|
+
if (!isSplitGroup(node))
|
|
147
|
+
return;
|
|
148
|
+
const n = node.children.length;
|
|
149
|
+
if (!n)
|
|
150
|
+
return;
|
|
151
|
+
const total = sum(node.children);
|
|
152
|
+
const along = node.dir === 'row' ? r.width : r.height;
|
|
153
|
+
const free = Math.max(0, along - gap * (n - 1));
|
|
154
|
+
let cursor = 0;
|
|
155
|
+
const mirrored = node.dir === 'row' && rtl;
|
|
156
|
+
node.children.forEach((child, i) => {
|
|
157
|
+
const idx = mirrored ? n - 1 - i : i;
|
|
158
|
+
const c = node.children[idx];
|
|
159
|
+
const size = (free * Math.max(0, c.weight)) / total;
|
|
160
|
+
const cr = node.dir === 'row'
|
|
161
|
+
? { x: r.x + cursor, y: r.y, width: size, height: r.height }
|
|
162
|
+
: { x: r.x, y: r.y + cursor, width: r.width, height: size };
|
|
163
|
+
walk(c, cr, [...path, idx]);
|
|
164
|
+
if (i < n - 1) {
|
|
165
|
+
const gapRect = node.dir === 'row'
|
|
166
|
+
? { x: r.x + cursor + size, y: r.y, width: gap, height: r.height }
|
|
167
|
+
: { x: r.x, y: r.y + cursor + size, width: r.width, height: gap };
|
|
168
|
+
// The divider belongs between the two children in TREE order.
|
|
169
|
+
const after = mirrored ? idx - 1 : idx;
|
|
170
|
+
out.push({ path, index: after, dir: node.dir, rect: gapRect, length: free });
|
|
171
|
+
}
|
|
172
|
+
cursor += size + gap;
|
|
173
|
+
void child;
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
walk(root, inner, []);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Split the leaf `targetId`'s slot to make room for `newId`: along `dir`,
|
|
181
|
+
* the newcomer `before` or after the target. If the target's parent already
|
|
182
|
+
* runs along `dir` the newcomer becomes a sibling and the two share the
|
|
183
|
+
* target's former weight; otherwise the target's slot becomes a new group of
|
|
184
|
+
* the two, equal halves. Returns the new root (never mutates the input).
|
|
185
|
+
*/
|
|
186
|
+
export function splitLeaf(root, targetId, newId, dir, before = false) {
|
|
187
|
+
if (!root)
|
|
188
|
+
return { id: newId, weight: 1 };
|
|
189
|
+
const path = pathToLeaf(root, targetId);
|
|
190
|
+
return path ? splitAt(root, path, newId, dir, before) : cloneSplit(root);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Split the slot of the node at `path` — a leaf OR a whole group — to make
|
|
194
|
+
* room for `newId` beside it: the DevExpress drop on a group's outer edge
|
|
195
|
+
* ("under all of these columns", not under one of them). Same weight rule as
|
|
196
|
+
* `splitLeaf`; a group target keeps its inner structure intact.
|
|
197
|
+
*/
|
|
198
|
+
export function splitAt(root, path, newId, dir, before = false) {
|
|
199
|
+
const leaf = { id: newId, weight: 1 };
|
|
200
|
+
if (!root)
|
|
201
|
+
return leaf;
|
|
202
|
+
const tree = cloneSplit(root);
|
|
203
|
+
const target = nodeAt(tree, path);
|
|
204
|
+
if (!target)
|
|
205
|
+
return tree;
|
|
206
|
+
const parentPath = path.slice(0, -1);
|
|
207
|
+
const idx = path[path.length - 1];
|
|
208
|
+
const parent = path.length ? nodeAt(tree, parentPath) : null;
|
|
209
|
+
if (parent && parent.dir === dir) {
|
|
210
|
+
const half = target.weight / 2;
|
|
211
|
+
target.weight = half;
|
|
212
|
+
leaf.weight = half;
|
|
213
|
+
parent.children.splice(before ? idx : idx + 1, 0, leaf);
|
|
214
|
+
return tree;
|
|
215
|
+
}
|
|
216
|
+
const group = {
|
|
217
|
+
dir,
|
|
218
|
+
weight: target.weight,
|
|
219
|
+
children: before ? [leaf, Object.assign(Object.assign({}, target), { weight: 1 })] : [Object.assign(Object.assign({}, target), { weight: 1 }), leaf],
|
|
220
|
+
};
|
|
221
|
+
if (!parent)
|
|
222
|
+
return group;
|
|
223
|
+
parent.children[idx] = group;
|
|
224
|
+
return tree;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* ADD, the DevExpress way: the newcomer halves the LARGEST leaf, along that
|
|
228
|
+
* leaf's longer axis — so a wide board's first add gives left / right, and
|
|
229
|
+
* the halves, now taller than wide, split top / bottom under the next add.
|
|
230
|
+
*/
|
|
231
|
+
export function addSplitLeaf(root, newId, frame, gap = 0, padding = 0) {
|
|
232
|
+
if (!root)
|
|
233
|
+
return { id: newId, weight: 1 };
|
|
234
|
+
if (pathToLeaf(root, newId))
|
|
235
|
+
return cloneSplit(root);
|
|
236
|
+
const rects = projectSplit(root, frame, gap, padding);
|
|
237
|
+
let bestId = null;
|
|
238
|
+
let best = null;
|
|
239
|
+
for (const [id, r] of rects) {
|
|
240
|
+
if (!best || r.width * r.height > best.width * best.height + 0.5) {
|
|
241
|
+
best = r;
|
|
242
|
+
bestId = id;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!bestId || !best)
|
|
246
|
+
return cloneSplit(root);
|
|
247
|
+
return splitLeaf(root, bestId, newId, best.width >= best.height ? 'row' : 'column', false);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Remove a leaf. Its slot goes to the NEIGHBOUR it was split from — the
|
|
251
|
+
* sibling before it, else the one after (DevExpress: deleting an item hands
|
|
252
|
+
* its space to the adjacent item, and an add followed by a remove gives the
|
|
253
|
+
* board back exactly). A group left with one child collapses into its
|
|
254
|
+
* parent, and a collapsed child running the same way as its new parent is
|
|
255
|
+
* spliced in so the tree never carries a redundant level.
|
|
256
|
+
*/
|
|
257
|
+
export function removeSplitLeaf(root, id) {
|
|
258
|
+
var _a;
|
|
259
|
+
if (!root)
|
|
260
|
+
return null;
|
|
261
|
+
if (!isSplitGroup(root))
|
|
262
|
+
return root.id === id ? null : cloneSplit(root);
|
|
263
|
+
const tree = cloneSplit(root);
|
|
264
|
+
const path = pathToLeaf(tree, id);
|
|
265
|
+
if (!path)
|
|
266
|
+
return tree;
|
|
267
|
+
const parent = nodeAt(tree, path.slice(0, -1));
|
|
268
|
+
const i = path[path.length - 1];
|
|
269
|
+
const [gone] = parent.children.splice(i, 1);
|
|
270
|
+
const heir = (_a = parent.children[i - 1]) !== null && _a !== void 0 ? _a : parent.children[i];
|
|
271
|
+
if (heir)
|
|
272
|
+
heir.weight += Math.max(0, gone.weight);
|
|
273
|
+
return collapse(tree);
|
|
274
|
+
}
|
|
275
|
+
/** Fold single-child groups and same-direction nesting away. */
|
|
276
|
+
export function collapse(node) {
|
|
277
|
+
if (!node || !isSplitGroup(node))
|
|
278
|
+
return node;
|
|
279
|
+
const kids = node.children.map((c) => collapse(c)).filter((c) => !!c);
|
|
280
|
+
if (kids.length === 0)
|
|
281
|
+
return null;
|
|
282
|
+
if (kids.length === 1)
|
|
283
|
+
return Object.assign(Object.assign({}, kids[0]), { weight: node.weight });
|
|
284
|
+
const flat = [];
|
|
285
|
+
const total = sum(kids);
|
|
286
|
+
for (const k of kids) {
|
|
287
|
+
if (isSplitGroup(k) && k.dir === node.dir) {
|
|
288
|
+
const inner = sum(k.children);
|
|
289
|
+
for (const g of k.children)
|
|
290
|
+
flat.push(Object.assign(Object.assign({}, g), { weight: (k.weight / total) * (g.weight / inner) * total }));
|
|
291
|
+
}
|
|
292
|
+
else
|
|
293
|
+
flat.push(k);
|
|
294
|
+
}
|
|
295
|
+
return { dir: node.dir, weight: node.weight, children: flat };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Drop `id` on `side` of `targetId` (a move when `id` is already in the tree).
|
|
299
|
+
* left / right split the target's slot along a row, top / bottom along a
|
|
300
|
+
* column; the mover lands on the named side.
|
|
301
|
+
*/
|
|
302
|
+
export function insertSplitLeaf(root, id, target, side) {
|
|
303
|
+
if (typeof target === 'string' && id === target)
|
|
304
|
+
return cloneSplit(root);
|
|
305
|
+
const without = pathToLeaf(root, id) ? removeSplitLeaf(root, id) : cloneSplit(root);
|
|
306
|
+
if (!without)
|
|
307
|
+
return { id, weight: 1 };
|
|
308
|
+
const dir = side === 'left' || side === 'right' ? 'row' : 'column';
|
|
309
|
+
const before = side === 'left' || side === 'top';
|
|
310
|
+
if (typeof target === 'string') {
|
|
311
|
+
if (!pathToLeaf(without, target))
|
|
312
|
+
return without;
|
|
313
|
+
return splitLeaf(without, target, id, dir, before);
|
|
314
|
+
}
|
|
315
|
+
// A GROUP target: its path was read from the tree WITHOUT the mover (the
|
|
316
|
+
// painted one), so it addresses the same node here.
|
|
317
|
+
if (!nodeAt(without, target.path))
|
|
318
|
+
return without;
|
|
319
|
+
return splitAt(without, target.path, id, dir, before);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Move the divider after child `index` of the group at `path` by `fraction`
|
|
323
|
+
* of the group's free length (positive = towards the end). Neither neighbour
|
|
324
|
+
* may shrink below `min` of the group. Returns the new root.
|
|
325
|
+
*/
|
|
326
|
+
export function moveSplitDivider(root, path, index, fraction, min = 0.05) {
|
|
327
|
+
const tree = cloneSplit(root);
|
|
328
|
+
const group = nodeAt(tree, path);
|
|
329
|
+
if (!isSplitGroup(group) || index < 0 || index >= group.children.length - 1)
|
|
330
|
+
return tree;
|
|
331
|
+
const total = sum(group.children);
|
|
332
|
+
const a = group.children[index];
|
|
333
|
+
const b = group.children[index + 1];
|
|
334
|
+
const fa = a.weight / total;
|
|
335
|
+
const fb = b.weight / total;
|
|
336
|
+
const next = Math.min(Math.max(fa + fraction, min), fa + fb - min);
|
|
337
|
+
if (!Number.isFinite(next))
|
|
338
|
+
return tree;
|
|
339
|
+
a.weight = next * total;
|
|
340
|
+
b.weight = (fa + fb - next) * total;
|
|
341
|
+
return tree;
|
|
342
|
+
}
|
|
343
|
+
/** Weights as fractions summing to 1 in every group — tidy JSON, same picture. */
|
|
344
|
+
export function normalizeSplit(root) {
|
|
345
|
+
if (!root || !isSplitGroup(root))
|
|
346
|
+
return root;
|
|
347
|
+
const total = sum(root.children);
|
|
348
|
+
return {
|
|
349
|
+
dir: root.dir,
|
|
350
|
+
weight: root.weight,
|
|
351
|
+
children: root.children.map((c) => (Object.assign(Object.assign({}, normalizeSplit(c)), { weight: +(Math.max(0, c.weight) / total).toFixed(4) }))),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// ---- conversions ------------------------------------------------------------
|
|
355
|
+
/**
|
|
356
|
+
* A GRID layout (cells) as a split tree — guillotine cuts: every horizontal
|
|
357
|
+
* line no tile straddles becomes a row boundary (a column group), inside each
|
|
358
|
+
* band every vertical line no tile straddles becomes a column boundary (a row
|
|
359
|
+
* group), and so on. Weights are the bands' cell extents, so the tree paints
|
|
360
|
+
* the grid's proportions. Tiles that interleave with no clean cut (a pinwheel)
|
|
361
|
+
* fall back to a row in reading order.
|
|
362
|
+
*/
|
|
363
|
+
export function splitFromCells(cells) {
|
|
364
|
+
const items = [...cells].map(([id, c]) => (Object.assign({ id }, c)));
|
|
365
|
+
if (!items.length)
|
|
366
|
+
return null;
|
|
367
|
+
const cutsAlong = (list, axis) => {
|
|
368
|
+
const lo = Math.min(...list.map((i) => (axis === 'y' ? i.y : i.x)));
|
|
369
|
+
const hi = Math.max(...list.map((i) => (axis === 'y' ? i.y + i.h : i.x + i.w)));
|
|
370
|
+
const lines = new Set();
|
|
371
|
+
for (const i of list) {
|
|
372
|
+
const a = axis === 'y' ? i.y : i.x;
|
|
373
|
+
const b = axis === 'y' ? i.y + i.h : i.x + i.w;
|
|
374
|
+
if (a > lo)
|
|
375
|
+
lines.add(a);
|
|
376
|
+
if (b < hi)
|
|
377
|
+
lines.add(b);
|
|
378
|
+
}
|
|
379
|
+
return [...lines]
|
|
380
|
+
.filter((line) => list.every((i) => (axis === 'y' ? i.y >= line || i.y + i.h <= line : i.x >= line || i.x + i.w <= line)))
|
|
381
|
+
.sort((p, q) => p - q);
|
|
382
|
+
};
|
|
383
|
+
const build = (list, prefer) => {
|
|
384
|
+
if (list.length === 1)
|
|
385
|
+
return { id: list[0].id, weight: 1 };
|
|
386
|
+
for (const axis of [prefer, prefer === 'y' ? 'x' : 'y']) {
|
|
387
|
+
const cuts = cutsAlong(list, axis);
|
|
388
|
+
if (!cuts.length)
|
|
389
|
+
continue;
|
|
390
|
+
const lo = Math.min(...list.map((i) => (axis === 'y' ? i.y : i.x)));
|
|
391
|
+
const hi = Math.max(...list.map((i) => (axis === 'y' ? i.y + i.h : i.x + i.w)));
|
|
392
|
+
const edges = [lo, ...cuts, hi];
|
|
393
|
+
const children = [];
|
|
394
|
+
for (let k = 0; k < edges.length - 1; k++) {
|
|
395
|
+
const band = list.filter((i) => (axis === 'y' ? i.y >= edges[k] && i.y + i.h <= edges[k + 1] : i.x >= edges[k] && i.x + i.w <= edges[k + 1]));
|
|
396
|
+
if (!band.length)
|
|
397
|
+
continue;
|
|
398
|
+
const child = build(band, axis === 'y' ? 'x' : 'y');
|
|
399
|
+
children.push(Object.assign(Object.assign({}, child), { weight: edges[k + 1] - edges[k] }));
|
|
400
|
+
}
|
|
401
|
+
return children.length === 1 ? children[0] : { dir: axis === 'y' ? 'column' : 'row', weight: 1, children };
|
|
402
|
+
}
|
|
403
|
+
const sorted = [...list].sort((p, q) => p.x - q.x || p.y - q.y);
|
|
404
|
+
return { dir: 'row', weight: 1, children: sorted.map((i) => ({ id: i.id, weight: i.w })) };
|
|
405
|
+
};
|
|
406
|
+
return collapse(build(items, 'y'));
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* A split tree as GRID cells: project onto a `columns` × `rows` unit frame and
|
|
410
|
+
* snap every edge to the nearest line (never below one cell). What a designer
|
|
411
|
+
* gets when switching a split board back to the grid.
|
|
412
|
+
*/
|
|
413
|
+
export function cellsFromSplit(root, columns, rows) {
|
|
414
|
+
const out = new Map();
|
|
415
|
+
const rects = projectSplit(root, { x: 0, y: 0, width: columns, height: rows });
|
|
416
|
+
for (const [id, r] of rects) {
|
|
417
|
+
const x = Math.max(0, Math.min(columns - 1, Math.round(r.x)));
|
|
418
|
+
const y = Math.max(0, Math.round(r.y));
|
|
419
|
+
const x2 = Math.max(x + 1, Math.min(columns, Math.round(r.x + r.width)));
|
|
420
|
+
const y2 = Math.max(y + 1, Math.round(r.y + r.height));
|
|
421
|
+
out.set(id, { x, y, w: x2 - x, h: y2 - y });
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
//# sourceMappingURL=split-layout.js.map
|