@canwork/boxwood 1.0.0 → 1.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.
- package/README.md +18 -8
- package/dist/boxwood.global.js +1 -1
- package/dist/resolve.d.ts +1 -0
- package/dist/resolve.js +119 -47
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A Declarative Box-Split and Collision Engine for Vector Graphics. It provides a
|
|
|
7
7
|
Unlike typical layout libraries (like Yoga or standard web browsers) that hide resolved position values inside a black box, **Boxwood** exposes absolute coordinate geometry (`{x, y, w, h}`) directly. That makes it easy to draw responsive visuals, route connectors, and manage collision-safe compositions.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
|
-

|
|
11
11
|
|
|
12
12
|
## Quick start
|
|
13
13
|
|
|
@@ -195,16 +195,26 @@ When visual cards enter with scale spring animations, or dynamic data changes th
|
|
|
195
195
|
|
|
196
196
|
## Visual Examples & Use Cases
|
|
197
197
|
|
|
198
|
-
|
|
198
|
+
### Start here — one self-contained page, no build step
|
|
199
199
|
|
|
200
|
-
|
|
200
|
+
**[`examples/showcase.html`](examples/showcase.html)** — open it directly in any browser. It loads Boxwood from a CDN and runs one complete use case: a declarative pipeline dashboard that re-resolves on every resize. Drag the ◢ corner and the *same* tree reflows from columns to rows while the SVG connectors re-route to follow the boxes. The entire integration is the `drawScene()` function at the bottom of the file — read it to see exactly how you'd wire Boxwood into your own project: build a tree → `resolveLayout()` → draw the boxes → connect their coordinates.
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
### Four worked TypeScript scenes
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
Each scene is a real `*.scene.ts` module — the way you'd actually structure Boxwood inside a TypeScript app. Run the build to bundle them into interactive, resizable HTML pages:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
npm run examples # → writes examples/01..04 *.html
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Then open any of the generated pages and drag the corner handle:
|
|
211
|
+
|
|
212
|
+
1. **`01-bento-grid.scene.ts`**: A dashboard "Bento" grid built from nested col/row splits, with a hero tile and a KPI cluster. Resize and it reflows from a 3-column layout to a single-column stack; every tile auto-sizes to its wrapped copy.
|
|
213
|
+
2. **`02-vector-connections.scene.ts`**: A 1-source / 3-worker / 1-sink pipeline. The six bezier connectors are routed purely from the resolved box coordinates, so they stay correct whether the split is laid out as columns (wide) or rows (tall).
|
|
214
|
+
3. **`03-absolute-to-responsive.scene.ts`**: Takes a PDF extract's absolute `x`/`width` spans, converts them to percentage columns once, and lets the engine reflow them — the two-column body narrows and the paragraphs rewrap as you shrink the panel.
|
|
215
|
+
4. **`04-dynamic-reflow-overflow.scene.ts`**: Auto-height (a card with no fixed height grows to its text), a real `OverflowSignal` (a fixed-height card surfaces a banner once the text no longer fits), and collision separation (two badges placed at the same coordinate are nudged apart).
|
|
216
|
+
|
|
217
|
+
The shared helpers live in [`examples/kit.ts`](examples/kit.ts) (connectors, intrinsic sizing) and [`examples/shell.ts`](examples/shell.ts) (the resize runtime + SVG drawing). The original brutalist playground is still available at [`examples/interactive-demo.html`](examples/interactive-demo.html).
|
|
208
218
|
|
|
209
219
|
---
|
|
210
220
|
|
package/dist/boxwood.global.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var Boxwood=(()=>{var
|
|
1
|
+
"use strict";var Boxwood=(()=>{var W=Object.defineProperty;var Y=Object.getOwnPropertyDescriptor;var Z=Object.getOwnPropertyNames;var $=Object.prototype.hasOwnProperty;var K=(e,t)=>{for(var n in t)W(e,n,{get:t[n],enumerable:!0})},U=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let u of Z(t))!$.call(e,u)&&u!==n&&W(e,u,{get:()=>t[u],enumerable:!(o=Y(t,u))||o.enumerable});return e};var q=e=>U(W({},"__esModule",{value:!0}),e);var se={};K(se,{defaultMeasure:()=>B,resolveColWidths:()=>M,resolveCollisions:()=>C,resolveGap:()=>g,resolveLayout:()=>X,resolveRowHeights:()=>S,resolveSides:()=>d,shrinkToFit:()=>P,splitCols:()=>v,splitGrid:()=>N,splitRows:()=>R,splitZones:()=>A});function d(e){return e===void 0?{top:0,right:0,bottom:0,left:0}:typeof e=="number"?{top:e,right:e,bottom:e,left:e}:{top:e.top??0,right:e.right??0,bottom:e.bottom??0,left:e.left??0}}function J(e,t,n){if(e===void 0||e==="auto")return n;if(typeof e=="number")return e;let o=parseFloat(e);return e.endsWith("%")&&!isNaN(o)?o/100*t:n}function g(e,t){return J(e,t,0)}function M(e,t,n){if(typeof e=="number"){let i=Math.max(1,e),m=(t-n*(i-1))/i;return Array.from({length:i},(h,s)=>({w:m,kind:"fixed",idx:s}))}let o=0,u=0,f=0;for(let i of e)typeof i=="number"?o+=i:i==="auto"?f++:i.endsWith("%")&&(u+=parseFloat(i));let l=u/100*t,r=t-o-l-n*(e.length-1),c=f>0?Math.max(0,r/f):0;return e.map((i,m)=>{if(typeof i=="number")return{w:i,kind:"fixed",idx:m};if(i==="auto")return{w:c,kind:"auto",idx:m};let h=parseFloat(i);return i.endsWith("%")&&!isNaN(h)?{w:h/100*t,kind:"pct",idx:m}:{w:0,kind:"fixed",idx:m}})}function v(e,t,n,o){let u=e.x+o.left,f=e.y+o.top,l=e.w-o.left-o.right,r=e.h-o.top-o.bottom,c=[],i=u;for(let m of t)c.push({x:i,y:f,w:m.w,h:r}),i+=m.w+n;return c}function S(e,t,n,o){if(typeof e=="number"){let h=Math.max(1,e),s=(t-n*(h-1))/h;return Array.from({length:h},(x,b)=>({h:s,kind:"fixed",idx:b}))}let u=e.length,f=0,l=0,r=e.filter(h=>h==="auto").length;for(let h=0;h<u;h++){let s=e[h];if(typeof s=="number")f+=s;else if(typeof s=="string"&&s.endsWith("%")){let x=parseFloat(s);isNaN(x)||(l+=x/100*t)}}let c=t-f-l-n*(u-1),i=o.reduce((h,s)=>h+s,0),m=0;return r>0&&(i<=c?m=i/r:m=c/r),e.map((h,s)=>{if(typeof h=="number")return{h,kind:"fixed",idx:s};if(h==="auto")return{h:m,kind:"auto",idx:s};let x=parseFloat(h);return h.endsWith("%")&&!isNaN(x)?{h:x/100*t,kind:"pct",idx:s}:{h:0,kind:"fixed",idx:s}})}function R(e,t,n,o){let u=e.x+o.left,f=e.y+o.top,l=e.w-o.left-o.right,r=e.h-o.top-o.bottom,c=[],i=f;for(let m of t)c.push({x:u,y:i,w:l,h:m.h}),i+=m.h+n;return c}function N(e,t,n,o){let[u,f]=t,l=e.x+o.left,r=e.y+o.top,c=e.w-o.left-o.right,i=e.h-o.top-o.bottom,m=(c-n*(u-1))/u,h=(i-n*(f-1))/f,s=[];for(let x=0;x<f;x++)for(let b=0;b<u;b++)s.push({x:l+b*(m+n),y:r+x*(h+n),w:m,h});return s}function A(e){return Object.values(e)}function T(e,t,n){if(t<=0)return e;let o=e;for(;o>14&&n(o)>t;)o-=1;return Math.max(14,o)}function H(e,t,n){if(t<=0||!e)return[e];let o=e.split(/\s+/),u=[],f="";for(let l of o){let r=f?f+" "+l:l;n(r)<=t||!f?f=r:(u.push(f),f=l)}return f&&u.push(f),u}var E=8;function V(e,t){return e.length*t*(E/14)}function B(e,t,n){let o=T(t.fontSize,n,m=>V(e,m)),u=o*(E/14),f=m=>m.length*u,l=H(e,n,f),r=o*1.2,c=o*.8,i=o*.2;return{lines:l,w:Math.min(n,Math.max(...l.map(f))),h:l.length*r,ascent:c,descent:i}}function P(e,t,n,o,u,f=14){let l=t;for(;l>=f;){let c=u(e,{fontSize:l},n);if(c.h<=o)return{fontSize:l,lines:c.lines,h:c.h};l-=1}let r=u(e,{fontSize:f},n);return{fontSize:f,lines:r.lines,h:r.h}}function j(e,t){let n=t.parent;for(;n;){if(n===e)return!0;n=n.parent}return!1}function F(e,t){return e.x<t.x+t.w&&e.x+e.w>t.x&&e.y<t.y+t.h&&e.y+e.h>t.y}function ee(e,t){let n=Math.max(0,Math.min(e.x+e.w,t.x+t.w)-Math.max(e.x,t.x)),o=Math.max(0,Math.min(e.y+e.h,t.y+t.h)-Math.max(e.y,t.y));return n*o}var _=10,te=5;function C(e,t){if(!(e.length<2)){for(let n=0;n<te;n++){let o=!1;for(let u=0;u<e.length;u++)for(let f=u+1;f<e.length;f++){let l=e[u],r=e[f];if(j(l,r)||j(r,l)||!F(l.box,r.box))continue;o=!0;let c=l.box,i=r.box,m=ee(c,i),h=Math.min(c.x+c.w,i.x+i.w)-Math.max(c.x,i.x),s=Math.min(c.y+c.h,i.y+i.h)-Math.max(c.y,i.y),x=h<s?_:0,b=h>=s?_:0;x!==0&&(c.x<i.x?i.x+=x:c.x+=x),b!==0&&(c.y<i.y?i.y+=b:c.y+=b)}if(!o)break}for(let n of e)n.box.x=Math.max(0,Math.min(n.box.x,t.w-n.box.w)),n.box.y=Math.max(0,Math.min(n.box.y,t.h-n.box.h))}}function ne(e,t){return{x:e.x+t.left,y:e.y+t.top,w:Math.max(0,e.w-t.left-t.right),h:Math.max(0,e.h-t.top-t.bottom)}}function oe(e,t){return{x:e.x+t.left,y:e.y+t.top,w:Math.max(0,e.w-t.left-t.right),h:Math.max(0,e.h-t.top-t.bottom)}}function z(e,t,n){if(e===void 0||e==="auto")return n;if(typeof e=="number")return e;let o=parseFloat(e);return e.endsWith("%")&&!isNaN(o)?o/100*t:n}function re(e,t,n,o){return e==="center"?{x:t.x+(t.w-n)/2,y:t.y+(t.h-o)/2}:e===void 0?{x:t.x,y:t.y}:{x:e.x!==void 0?z(e.x,t.w,t.x):t.x,y:e.y!==void 0?z(e.y,t.h,t.y):t.y}}function D(e,t){if(t.push(e),e.children)for(let n of e.children)D(n,t)}function G(e,t){if(e===void 0||e==="auto")return{h:0,isAuto:!0};if(typeof e=="number")return{h:e,isAuto:!1};if(typeof e=="string"&&e.endsWith("%")){let n=parseFloat(e);if(!isNaN(n))return{h:n/100*t,isAuto:!1}}return{h:0,isAuto:!0}}function I(e){return e.style.h===void 0||e.style.h==="auto"}function k(e,t,n,o,u){let f=d(e.style.margin),l=d(e.style.padding),r,c,i,m,h=G(e.style.h,t.h);if(n){if(r=n.w,n.h!==0&&!h.isAuto)c=n.h;else if(n.h!==0&&h.isAuto){let a=Math.max(0,n.w-l.left-l.right-f.left-f.right);c=Math.max(n.h,w(e,a,o.measure))}else c=h.h;i=n.x,m=n.y}else{r=z(e.style.w,t.w,t.w),c=h.h;let a=re(e.style.pos,t,r,c||t.h);i=a.x,m=a.y}let s=ne({x:i,y:m,w:r,h:c},f),x=oe(s,l);c===0&&!e.measure&&(s.h=t.h,x.h=Math.max(0,t.h-l.top-l.bottom));let b,p;if(e.split?b=ie(e,e.split,x,o,u):e.children&&e.children.length>0&&(b=e.children.map((a,L)=>k(a,x,null,o,O(u,a,L)))),e.measure&&x.w>0){let a=e.measure(x);if(p={lines:a.lines,fontSize:a.ascent?Math.round(a.ascent/.8):14,ascent:a.ascent,descent:a.descent},I(e)){let L=a.h+l.top+l.bottom;s.h=Math.max(s.h,L,1),x.h=Math.max(x.h,a.h,1)}else a.h>x.h&&x.h>0&&o.overflow.push({path:u,box:x,fontSize:p.fontSize,text:a.lines.join(" ")})}let y={node:e,box:{x:s.x,y:s.y,w:s.w,h:s.h},contentFrame:{x:x.x,y:x.y,w:x.w,h:x.h},textLayout:p,children:b};if(b)for(let a of b)a.parent=y;return y}function ie(e,t,n,o,u){let f=e.children;if(!f||f.length===0)return[];let l="gap"in t&&t.gap!==void 0?g(t.gap,n.w):0,r="pad"in t&&t.pad!==void 0?d(t.pad):d(void 0),c=i=>f.map((m,h)=>{let s=i[Math.min(h,i.length-1)];return k(m,n,s,o,O(u,m,h))});if("rows"in t&&(typeof t.rows=="number"||Array.isArray(t.rows))){let i=n.w-r.left-r.right,m=f.map(x=>w(x,i,o.measure)),h=n.h-r.top-r.bottom,s=S(t.rows,h,l,m);return c(R(n,s,l,r))}if("cols"in t&&(typeof t.cols=="number"||Array.isArray(t.cols))){let i=n.w-r.left-r.right,m=M(t.cols,i,l);return c(v(n,m,l,r))}return"grid"in t?c(N(n,t.grid,l,r)):"zones"in t?c(A(t.zones)):[]}function w(e,t,n){let o=d(e.style.padding),u=d(e.style.margin),f=Math.max(0,t-o.left-o.right-u.left-u.right);if(!I(e)){let s=G(e.style.h,0);if(!s.isAuto)return s.h+u.top+u.bottom}if(e.measure)return e.measure({x:0,y:0,w:f,h:0}).h+o.top+o.bottom+u.top+u.bottom;let l=e.children;if(!l||l.length===0)return 0;let r=e.split,c=r&&"gap"in r&&r.gap!==void 0?g(r.gap,f):0,i=r&&"pad"in r&&r.pad!==void 0?d(r.pad):d(void 0),m=Math.max(0,f-i.left-i.right),h;if(r&&"cols"in r){let s=Array.isArray(r.cols)?r.cols.length:r.cols,x=s>0?(m-c*(s-1))/s:m;h=Math.max(0,...l.map(b=>w(b,x,n)))}else if(r&&"grid"in r){let[s,x]=r.grid,b=s>0?(m-c*(s-1))/s:m,p=l.map(a=>w(a,b,n)),y=0;for(let a=0;a<x;a++){let L=Math.max(0,...p.slice(a*s,a*s+s));y=Math.max(y,L)}h=y*x+c*Math.max(0,x-1)}else if(r&&"zones"in r)h=Math.max(0,...Object.values(r.zones).map(s=>s.y+s.h));else{let s=l.map(x=>w(x,m,n));h=s.reduce((x,b)=>x+b,0)+c*Math.max(0,s.length-1)}return h+i.top+i.bottom+o.top+o.bottom+u.top+u.bottom}function O(e,t,n){let o=t.id??`[${n}]`;return e?`${e}.${o}`:o}function X(e,t,n){let o=n?.measure??B,u=[],f=e.id??"root",l=k(e,t,null,{measure:o,overflow:u},f),r=[];return D(l,r),n?.collide!==!1&&C(r,t),{root:l,boxes:r,overflow:u}}return q(se);})();
|
package/dist/resolve.d.ts
CHANGED
package/dist/resolve.js
CHANGED
|
@@ -65,7 +65,11 @@ function resolveBoxHeight(styleH, parentH) {
|
|
|
65
65
|
}
|
|
66
66
|
return { h: 0, isAuto: true };
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
/** True when a node's height is determined by its content (no explicit fixed h). */
|
|
69
|
+
function isAutoHeight(node) {
|
|
70
|
+
return node.style.h === undefined || node.style.h === "auto";
|
|
71
|
+
}
|
|
72
|
+
function resolveNode(node, parentFrame, slotBox, ctx, path) {
|
|
69
73
|
const margin = resolveSides(node.style.margin);
|
|
70
74
|
const pad = resolveSides(node.style.padding);
|
|
71
75
|
let boxW;
|
|
@@ -75,7 +79,21 @@ function resolveNode(node, parentFrame, slotBox, ctx, overflow) {
|
|
|
75
79
|
const hRes = resolveBoxHeight(node.style.h, parentFrame.h);
|
|
76
80
|
if (slotBox) {
|
|
77
81
|
boxW = slotBox.w;
|
|
78
|
-
|
|
82
|
+
// A slot only pins the height of a child that asked to be pinned (fixed h).
|
|
83
|
+
// An auto-height child instead grows to its own measured content (PASS 2 of
|
|
84
|
+
// the two-pass resolve), so it can push siblings rather than clip silently.
|
|
85
|
+
if (slotBox.h !== 0 && !hRes.isAuto) {
|
|
86
|
+
boxH = slotBox.h;
|
|
87
|
+
}
|
|
88
|
+
else if (slotBox.h !== 0 && hRes.isAuto) {
|
|
89
|
+
// The slot carries the intrinsic height computed bottom-up in pass 1, but
|
|
90
|
+
// a content-tall child may still exceed it — take the larger of the two.
|
|
91
|
+
const innerW = Math.max(0, slotBox.w - pad.left - pad.right - margin.left - margin.right);
|
|
92
|
+
boxH = Math.max(slotBox.h, measureIntrinsicHeight(node, innerW, ctx.measure));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
boxH = hRes.h;
|
|
96
|
+
}
|
|
79
97
|
boxX = slotBox.x;
|
|
80
98
|
boxY = slotBox.y;
|
|
81
99
|
}
|
|
@@ -89,7 +107,9 @@ function resolveNode(node, parentFrame, slotBox, ctx, overflow) {
|
|
|
89
107
|
let margined = applyMargin({ x: boxX, y: boxY, w: boxW, h: boxH }, margin);
|
|
90
108
|
let contentFrame = applyPad(margined, pad);
|
|
91
109
|
// Fix container auto-height BEFORE resolving children — children need a real
|
|
92
|
-
// content frame for row distribution.
|
|
110
|
+
// content frame for row distribution. A non-slotted auto container fills its
|
|
111
|
+
// parent frame (it is the outermost box of its subtree, like <body>); a
|
|
112
|
+
// SLOTTED auto container instead grew to its content above, so respect that.
|
|
93
113
|
if (boxH === 0 && !node.measure) {
|
|
94
114
|
margined.h = parentFrame.h;
|
|
95
115
|
contentFrame.h = Math.max(0, parentFrame.h - pad.top - pad.bottom);
|
|
@@ -97,10 +117,10 @@ function resolveNode(node, parentFrame, slotBox, ctx, overflow) {
|
|
|
97
117
|
let children;
|
|
98
118
|
let textLayout;
|
|
99
119
|
if (node.split) {
|
|
100
|
-
children = resolveSplitChildren(node, node.split, contentFrame, ctx,
|
|
120
|
+
children = resolveSplitChildren(node, node.split, contentFrame, ctx, path);
|
|
101
121
|
}
|
|
102
122
|
else if (node.children && node.children.length > 0) {
|
|
103
|
-
children = node.children.map((child) => resolveNode(child, contentFrame, null, ctx,
|
|
123
|
+
children = node.children.map((child, i) => resolveNode(child, contentFrame, null, ctx, childPath(path, child, i)));
|
|
104
124
|
}
|
|
105
125
|
if (node.measure && contentFrame.w > 0) {
|
|
106
126
|
const measured = node.measure(contentFrame);
|
|
@@ -110,14 +130,17 @@ function resolveNode(node, parentFrame, slotBox, ctx, overflow) {
|
|
|
110
130
|
ascent: measured.ascent,
|
|
111
131
|
descent: measured.descent,
|
|
112
132
|
};
|
|
113
|
-
|
|
133
|
+
// Auto-height text grows its box to the measured content (and a slotted
|
|
134
|
+
// auto child already had its slot grown above). Only a node whose height
|
|
135
|
+
// was explicitly fixed keeps it — and overflows when the text won't fit.
|
|
136
|
+
if (isAutoHeight(node)) {
|
|
114
137
|
const totalH = measured.h + pad.top + pad.bottom;
|
|
115
|
-
margined.h = Math.max(totalH, 1);
|
|
116
|
-
contentFrame.h = Math.max(measured.h, 1);
|
|
138
|
+
margined.h = Math.max(margined.h, totalH, 1);
|
|
139
|
+
contentFrame.h = Math.max(contentFrame.h, measured.h, 1);
|
|
117
140
|
}
|
|
118
|
-
if (measured.h > contentFrame.h && contentFrame.h > 0) {
|
|
119
|
-
overflow.push({
|
|
120
|
-
path
|
|
141
|
+
else if (measured.h > contentFrame.h && contentFrame.h > 0) {
|
|
142
|
+
ctx.overflow.push({
|
|
143
|
+
path,
|
|
121
144
|
box: contentFrame,
|
|
122
145
|
fontSize: textLayout.fontSize,
|
|
123
146
|
text: measured.lines.join(" "),
|
|
@@ -137,69 +160,118 @@ function resolveNode(node, parentFrame, slotBox, ctx, overflow) {
|
|
|
137
160
|
}
|
|
138
161
|
return resolved;
|
|
139
162
|
}
|
|
140
|
-
function resolveSplitChildren(node, split, contentFrame, ctx,
|
|
163
|
+
function resolveSplitChildren(node, split, contentFrame, ctx, path) {
|
|
141
164
|
const ch = node.children;
|
|
142
165
|
if (!ch || ch.length === 0)
|
|
143
166
|
return [];
|
|
144
167
|
const gap = "gap" in split && split.gap !== undefined ? resolveGap(split.gap, contentFrame.w) : 0;
|
|
145
168
|
const sp = "pad" in split && split.pad !== undefined ? resolveSides(split.pad) : resolveSides(undefined);
|
|
169
|
+
const place = (cells) => ch.map((child, i) => {
|
|
170
|
+
const cell = cells[Math.min(i, cells.length - 1)];
|
|
171
|
+
return resolveNode(child, contentFrame, cell, ctx, childPath(path, child, i));
|
|
172
|
+
});
|
|
146
173
|
if ("rows" in split && (typeof split.rows === "number" || Array.isArray(split.rows))) {
|
|
147
174
|
const innerW = contentFrame.w - sp.left - sp.right;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
// PASS 1 (bottom-up): every child reports the real height it needs at the
|
|
176
|
+
// available width — recursing through nested containers — so `auto` rows
|
|
177
|
+
// are sized to content instead of a guess.
|
|
178
|
+
const intrinsicHs = ch.map((c) => measureIntrinsicHeight(c, innerW, ctx.measure));
|
|
152
179
|
const innerH = contentFrame.h - sp.top - sp.bottom;
|
|
153
180
|
const rowSpecs = resolveRowHeights(split.rows, innerH, gap, intrinsicHs);
|
|
154
|
-
|
|
155
|
-
return ch.map((child, i) => {
|
|
156
|
-
const cell = cells[Math.min(i, cells.length - 1)];
|
|
157
|
-
return resolveNode(child, contentFrame, cell, ctx, overflow);
|
|
158
|
-
});
|
|
181
|
+
return place(splitRows(contentFrame, rowSpecs, gap, sp));
|
|
159
182
|
}
|
|
160
183
|
if ("cols" in split && (typeof split.cols === "number" || Array.isArray(split.cols))) {
|
|
161
184
|
const innerW = contentFrame.w - sp.left - sp.right;
|
|
162
185
|
const colSpecs = resolveColWidths(split.cols, innerW, gap);
|
|
163
|
-
|
|
164
|
-
return ch.map((child, i) => {
|
|
165
|
-
const cell = cells[Math.min(i, cells.length - 1)];
|
|
166
|
-
return resolveNode(child, contentFrame, cell, ctx, overflow);
|
|
167
|
-
});
|
|
186
|
+
return place(splitCols(contentFrame, colSpecs, gap, sp));
|
|
168
187
|
}
|
|
169
188
|
if ("grid" in split) {
|
|
170
|
-
|
|
171
|
-
return ch.map((child, i) => {
|
|
172
|
-
const cell = cells[Math.min(i, cells.length - 1)];
|
|
173
|
-
return resolveNode(child, contentFrame, cell, ctx, overflow);
|
|
174
|
-
});
|
|
189
|
+
return place(splitGrid(contentFrame, split.grid, gap, sp));
|
|
175
190
|
}
|
|
176
191
|
if ("zones" in split) {
|
|
177
|
-
|
|
178
|
-
return ch.map((child, i) => {
|
|
179
|
-
const cell = cells[Math.min(i, cells.length - 1)];
|
|
180
|
-
return resolveNode(child, contentFrame, cell, ctx, overflow);
|
|
181
|
-
});
|
|
192
|
+
return place(splitZones(split.zones));
|
|
182
193
|
}
|
|
183
194
|
return [];
|
|
184
195
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
/**
|
|
197
|
+
* PASS 1 — bottom-up intrinsic height.
|
|
198
|
+
*
|
|
199
|
+
* Returns the content height (including the node's own padding) that `node`
|
|
200
|
+
* needs to contain everything inside it at the given available width. This is
|
|
201
|
+
* the half the single-pass resolver lacked: a child's measured/wrapped height
|
|
202
|
+
* travels back UP so ancestors and sibling slots can be sized before final
|
|
203
|
+
* placement, instead of being guessed (the old hardcoded `60`).
|
|
204
|
+
*/
|
|
205
|
+
function measureIntrinsicHeight(node, availW, measure) {
|
|
206
|
+
const pad = resolveSides(node.style.padding);
|
|
207
|
+
const margin = resolveSides(node.style.margin);
|
|
208
|
+
const innerW = Math.max(0, availW - pad.left - pad.right - margin.left - margin.right);
|
|
209
|
+
// A node with an explicit fixed height contributes exactly that height.
|
|
210
|
+
if (!isAutoHeight(node)) {
|
|
211
|
+
const fixed = resolveBoxHeight(node.style.h, 0);
|
|
212
|
+
if (!fixed.isAuto)
|
|
213
|
+
return fixed.h + margin.top + margin.bottom;
|
|
214
|
+
}
|
|
215
|
+
// Leaf text: measure and wrap at the available content width.
|
|
216
|
+
if (node.measure) {
|
|
217
|
+
const m = node.measure({ x: 0, y: 0, w: innerW, h: 0 });
|
|
218
|
+
return m.h + pad.top + pad.bottom + margin.top + margin.bottom;
|
|
219
|
+
}
|
|
220
|
+
// Container: combine children's intrinsic heights per split type.
|
|
221
|
+
const ch = node.children;
|
|
222
|
+
if (!ch || ch.length === 0)
|
|
190
223
|
return 0;
|
|
224
|
+
const split = node.split;
|
|
225
|
+
const gap = split && "gap" in split && split.gap !== undefined ? resolveGap(split.gap, innerW) : 0;
|
|
226
|
+
const sp = split && "pad" in split && split.pad !== undefined
|
|
227
|
+
? resolveSides(split.pad)
|
|
228
|
+
: resolveSides(undefined);
|
|
229
|
+
const childW = Math.max(0, innerW - sp.left - sp.right);
|
|
230
|
+
let inner;
|
|
231
|
+
if (split && "cols" in split) {
|
|
232
|
+
// Columns sit side by side — the row is as tall as the TALLEST column.
|
|
233
|
+
const n = Array.isArray(split.cols) ? split.cols.length : split.cols;
|
|
234
|
+
const colW = n > 0 ? (childW - gap * (n - 1)) / n : childW;
|
|
235
|
+
inner = Math.max(0, ...ch.map((c) => measureIntrinsicHeight(c, colW, measure)));
|
|
236
|
+
}
|
|
237
|
+
else if (split && "grid" in split) {
|
|
238
|
+
const [cols, rows] = split.grid;
|
|
239
|
+
const cellW = cols > 0 ? (childW - gap * (cols - 1)) / cols : childW;
|
|
240
|
+
const cellHs = ch.map((c) => measureIntrinsicHeight(c, cellW, measure));
|
|
241
|
+
let tallestRow = 0;
|
|
242
|
+
for (let r = 0; r < rows; r++) {
|
|
243
|
+
const rowMax = Math.max(0, ...cellHs.slice(r * cols, r * cols + cols));
|
|
244
|
+
tallestRow = Math.max(tallestRow, rowMax);
|
|
245
|
+
}
|
|
246
|
+
inner = tallestRow * rows + gap * Math.max(0, rows - 1);
|
|
247
|
+
}
|
|
248
|
+
else if (split && "zones" in split) {
|
|
249
|
+
// Absolutely-zoned children don't stack; take the lowest bottom edge.
|
|
250
|
+
inner = Math.max(0, ...Object.values(split.zones).map((z) => z.y + z.h));
|
|
191
251
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
252
|
+
else {
|
|
253
|
+
// rows split OR a plain (no-split) container: children stack vertically.
|
|
254
|
+
const hs = ch.map((c) => measureIntrinsicHeight(c, childW, measure));
|
|
255
|
+
inner = hs.reduce((a, b) => a + b, 0) + gap * Math.max(0, hs.length - 1);
|
|
256
|
+
}
|
|
257
|
+
return inner + sp.top + sp.bottom + pad.top + pad.bottom + margin.top + margin.bottom;
|
|
258
|
+
}
|
|
259
|
+
/** Build the dotted path used in OverflowSignal (e.g. "root.children[1].grid[3]"). */
|
|
260
|
+
function childPath(parentPath, child, index) {
|
|
261
|
+
const key = child.id ?? `[${index}]`;
|
|
262
|
+
return parentPath ? `${parentPath}.${key}` : key;
|
|
196
263
|
}
|
|
197
264
|
export function resolveLayout(root, world, hooks) {
|
|
198
265
|
const measure = hooks?.measure ?? defaultMeasure;
|
|
199
266
|
const overflow = [];
|
|
200
|
-
const
|
|
267
|
+
const rootPath = root.id ?? "root";
|
|
268
|
+
const resolved = resolveNode(root, world, null, { measure, overflow }, rootPath);
|
|
201
269
|
const boxes = [];
|
|
202
270
|
flattenResolved(resolved, boxes);
|
|
203
|
-
|
|
271
|
+
// Collision separation is a post-layout geometric pass; on by default, opt
|
|
272
|
+
// out with { collide: false } when overlap is intentional.
|
|
273
|
+
if (hooks?.collide !== false) {
|
|
274
|
+
resolveCollisions(boxes, world);
|
|
275
|
+
}
|
|
204
276
|
return { root: resolved, boxes, overflow };
|
|
205
277
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canwork/boxwood",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A Declarative Box-Split and Collision Engine for Vector Graphics. It provides a CSS-like layout grammar (padding, gap, cols, rows, auto-height), but outputs a clean JSON coordinate tree that any visual canvas (SVG, <canvas>, WebGL) can use to draw pixels, connectors, and particles.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
"LICENSE"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
+
"test": "npx tsx __tests__/resolve.test.mts",
|
|
16
17
|
"build": "tsc",
|
|
17
18
|
"build:bundle": "esbuild index.ts --bundle --minify --format=iife --global-name=Boxwood --outfile=dist/boxwood.global.js",
|
|
18
19
|
"build:all": "npm run build && npm run build:bundle",
|
|
19
|
-
"prepublishOnly": "npm run build:all"
|
|
20
|
+
"prepublishOnly": "npm run build:all",
|
|
21
|
+
"examples": "tsx examples/build.ts"
|
|
20
22
|
},
|
|
21
23
|
"keywords": [
|
|
22
24
|
"layout-engine",
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
"license": "MIT",
|
|
41
43
|
"devDependencies": {
|
|
42
44
|
"esbuild": "^0.28.1",
|
|
45
|
+
"tsx": "^4.19.0",
|
|
43
46
|
"typescript": "^5.4.5"
|
|
44
47
|
}
|
|
45
48
|
}
|