@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 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
- ![Demo Animation](./brutalist_playground_visual_1782343082910.webp)
10
+ ![Demo Animation](./interactive.gif)
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
- To showcase the responsiveness and design rules of the layout solver, we built a **Google Stitch Stark Brutalist Playground** in the `examples/` directory:
198
+ ### Start here one self-contained page, no build step
199
199
 
200
- * **[interactive-demo.html](file:///Users/bonzai-carn/Documents/CanworkStudios/content-generator/storyboard/layout-engine/examples/interactive-demo.html)**: Open this file directly in any browser. It renders an interactive viewport with a corner drag-handle, running the layout solver in real-time as you scale or squeeze the container.
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
- Additionally, we have prepared self-contained console scripts demonstrating each use case:
202
+ ### Four worked TypeScript scenes
203
203
 
204
- 1. **`01-bento-grid.ts`**: Builds a dashboard-style "Bento box" grid with side-by-side splits and shows how it adapts dynamically between desktop and mobile portrait layout shapes.
205
- 2. **`02-vector-connections.ts`**: Demonstrates horizontal pipeline layout structures, retrieving coordinate boxes, and generating Bezier path coordinates to draw custom SVG connection arrows.
206
- 3. **`03-absolute-to-responsive.ts`**: Illustrates how to take a set of static, absolute coordinate elements (e.g. from PDF.js) and wrap them into a responsive Boxwood column grid.
207
- 4. **`04-dynamic-reflow-overflow.ts`**: Walks through the dynamic text measurement system, showing how text wraps and shifts downstream sibling cards downwards to prevent overlap, alongside emitting overflow alerts.
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
 
@@ -1 +1 @@
1
- "use strict";var Boxwood=(()=>{var A=Object.defineProperty;var G=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var O=Object.prototype.hasOwnProperty;var X=(e,t)=>{for(var n in t)A(e,n,{get:t[n],enumerable:!0})},Y=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of I(t))!O.call(e,s)&&s!==n&&A(e,s,{get:()=>t[s],enumerable:!(o=G(t,s))||o.enumerable});return e};var Z=e=>Y(A({},"__esModule",{value:!0}),e);var re={};X(re,{defaultMeasure:()=>B,resolveColWidths:()=>w,resolveCollisions:()=>N,resolveGap:()=>L,resolveLayout:()=>j,resolveRowHeights:()=>M,resolveSides:()=>d,shrinkToFit:()=>E,splitCols:()=>g,splitGrid:()=>R,splitRows:()=>S,splitZones:()=>v});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 K(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 L(e,t){return K(e,t,0)}function w(e,t,n){if(typeof e=="number"){let r=Math.max(1,e),h=(t-n*(r-1))/r;return Array.from({length:r},(c,x)=>({w:h,kind:"fixed",idx:x}))}let o=0,s=0,i=0;for(let r of e)typeof r=="number"?o+=r:r==="auto"?i++:r.endsWith("%")&&(s+=parseFloat(r));let l=s/100*t,f=t-o-l-n*(e.length-1),u=i>0?Math.max(0,f/i):0;return e.map((r,h)=>{if(typeof r=="number")return{w:r,kind:"fixed",idx:h};if(r==="auto")return{w:u,kind:"auto",idx:h};let c=parseFloat(r);return r.endsWith("%")&&!isNaN(c)?{w:c/100*t,kind:"pct",idx:h}:{w:0,kind:"fixed",idx:h}})}function g(e,t,n,o){let s=e.x+o.left,i=e.y+o.top,l=e.w-o.left-o.right,f=e.h-o.top-o.bottom,u=[],r=s;for(let h of t)u.push({x:r,y:i,w:h.w,h:f}),r+=h.w+n;return u}function M(e,t,n,o){if(typeof e=="number"){let c=Math.max(1,e),x=(t-n*(c-1))/c;return Array.from({length:c},(m,b)=>({h:x,kind:"fixed",idx:b}))}let s=e.length,i=0,l=0,f=e.filter(c=>c==="auto").length;for(let c=0;c<s;c++){let x=e[c];if(typeof x=="number")i+=x;else if(typeof x=="string"&&x.endsWith("%")){let m=parseFloat(x);isNaN(m)||(l+=m/100*t)}}let u=t-i-l-n*(s-1),r=o.reduce((c,x)=>c+x,0),h=0;return f>0&&(r<=u?h=r/f:h=u/f),e.map((c,x)=>{if(typeof c=="number")return{h:c,kind:"fixed",idx:x};if(c==="auto")return{h,kind:"auto",idx:x};let m=parseFloat(c);return c.endsWith("%")&&!isNaN(m)?{h:m/100*t,kind:"pct",idx:x}:{h:0,kind:"fixed",idx:x}})}function S(e,t,n,o){let s=e.x+o.left,i=e.y+o.top,l=e.w-o.left-o.right,f=e.h-o.top-o.bottom,u=[],r=i;for(let h of t)u.push({x:s,y:r,w:l,h:h.h}),r+=h.h+n;return u}function R(e,t,n,o){let[s,i]=t,l=e.x+o.left,f=e.y+o.top,u=e.w-o.left-o.right,r=e.h-o.top-o.bottom,h=(u-n*(s-1))/s,c=(r-n*(i-1))/i,x=[];for(let m=0;m<i;m++)for(let b=0;b<s;b++)x.push({x:l+b*(h+n),y:f+m*(c+n),w:h,h:c});return x}function v(e){return Object.values(e)}function k(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 W(e,t,n){if(t<=0||!e)return[e];let o=e.split(/\s+/),s=[],i="";for(let l of o){let f=i?i+" "+l:l;n(f)<=t||!i?i=f:(s.push(i),i=l)}return i&&s.push(i),s}var T=8;function q(e,t){return e.length*t*(T/14)}function B(e,t,n){let o=k(t.fontSize,n,h=>q(e,h)),s=o*(T/14),i=h=>h.length*s,l=W(e,n,i),f=o*1.2,u=o*.8,r=o*.2;return{lines:l,w:Math.min(n,Math.max(...l.map(i))),h:l.length*f,ascent:u,descent:r}}function E(e,t,n,o,s,i=14){let l=t;for(;l>=i;){let u=s(e,{fontSize:l},n);if(u.h<=o)return{fontSize:l,lines:u.lines,h:u.h};l-=1}let f=s(e,{fontSize:i},n);return{fontSize:i,lines:f.lines,h:f.h}}function H(e,t){let n=t.parent;for(;n;){if(n===e)return!0;n=n.parent}return!1}function J(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 Q(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 P=10,$=5;function N(e,t){if(!(e.length<2)){for(let n=0;n<$;n++){let o=!1;for(let s=0;s<e.length;s++)for(let i=s+1;i<e.length;i++){let l=e[s],f=e[i];if(H(l,f)||H(f,l)||!J(l.box,f.box))continue;o=!0;let u=l.box,r=f.box,h=Q(u,r),c=Math.min(u.x+u.w,r.x+r.w)-Math.max(u.x,r.x),x=Math.min(u.y+u.h,r.y+r.h)-Math.max(u.y,r.y),m=c<x?P:0,b=c>=x?P:0;m!==0&&(u.x<r.x?r.x+=m:u.x+=m),b!==0&&(u.y<r.y?r.y+=b:u.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 V(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 F(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 C(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 ee(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?C(e.x,t.w,t.x):t.x,y:e.y!==void 0?C(e.y,t.h,t.y):t.y}}function _(e,t){if(t.push(e),e.children)for(let n of e.children)_(n,t)}function te(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 y(e,t,n,o,s){let i=d(e.style.margin),l=d(e.style.padding),f,u,r,h,c=te(e.style.h,t.h);if(n)f=n.w,u=n.h!==0?n.h:c.h,r=n.x,h=n.y;else{f=C(e.style.w,t.w,t.w),u=c.h;let a=ee(e.style.pos,t,f,u||t.h);r=a.x,h=a.y}let x=V({x:r,y:h,w:f,h:u},i),m=F(x,l);u===0&&!e.measure&&(x.h=t.h,m.h=Math.max(0,t.h-l.top-l.bottom));let b,p;if(e.split?b=ne(e,e.split,m,o,s):e.children&&e.children.length>0&&(b=e.children.map(a=>y(a,m,null,o,s))),e.measure&&m.w>0){let a=e.measure(m);if(p={lines:a.lines,fontSize:a.ascent?Math.round(a.ascent/.8):14,ascent:a.ascent,descent:a.descent},u===0){let D=a.h+l.top+l.bottom;x.h=Math.max(D,1),m.h=Math.max(a.h,1)}a.h>m.h&&m.h>0&&s.push({path:"",box:m,fontSize:p.fontSize,text:a.lines.join(" ")})}let z={node:e,box:{x:x.x,y:x.y,w:x.w,h:x.h},contentFrame:{x:m.x,y:m.y,w:m.w,h:m.h},textLayout:p,children:b};if(b)for(let a of b)a.parent=z;return z}function ne(e,t,n,o,s){let i=e.children;if(!i||i.length===0)return[];let l="gap"in t&&t.gap!==void 0?L(t.gap,n.w):0,f="pad"in t&&t.pad!==void 0?d(t.pad):d(void 0);if("rows"in t&&(typeof t.rows=="number"||Array.isArray(t.rows))){let u=n.w-f.left-f.right,r=[];for(let m=0;m<i.length;m++)r.push(oe(i[m],u,o.measure));let h=n.h-f.top-f.bottom,c=M(t.rows,h,l,r),x=S(n,c,l,f);return i.map((m,b)=>{let p=x[Math.min(b,x.length-1)];return y(m,n,p,o,s)})}if("cols"in t&&(typeof t.cols=="number"||Array.isArray(t.cols))){let u=n.w-f.left-f.right,r=w(t.cols,u,l),h=g(n,r,l,f);return i.map((c,x)=>{let m=h[Math.min(x,h.length-1)];return y(c,n,m,o,s)})}if("grid"in t){let u=R(n,t.grid,l,f);return i.map((r,h)=>{let c=u[Math.min(h,u.length-1)];return y(r,n,c,o,s)})}if("zones"in t){let u=v(t.zones);return i.map((r,h)=>{let c=u[Math.min(h,u.length-1)];return y(r,n,c,o,s)})}return[]}function oe(e,t,n){if(!e.measure)return e.children&&e.children.length>0?60:0;let o={x:0,y:0,w:t,h:0},s=e.measure(o),i=d(e.style.padding);return s.h+i.top+i.bottom}function j(e,t,n){let o=n?.measure??B,s=[],i=y(e,t,null,{measure:o},s),l=[];return _(i,l),N(l,t),{root:i,boxes:l,overflow:s}}return Z(re);})();
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
@@ -1,4 +1,5 @@
1
1
  import type { LBox, LNode, Measure, LayoutResult } from "./types.js";
2
2
  export declare function resolveLayout(root: LNode, world: LBox, hooks?: {
3
3
  measure?: Measure;
4
+ collide?: boolean;
4
5
  }): LayoutResult;
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
- function resolveNode(node, parentFrame, slotBox, ctx, overflow) {
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
- boxH = slotBox.h !== 0 ? slotBox.h : hRes.h;
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, overflow);
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, overflow));
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
- if (boxH === 0) {
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, overflow) {
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
- const intrinsicHs = [];
149
- for (let i = 0; i < ch.length; i++) {
150
- intrinsicHs.push(estimateIntrinsicHeight(ch[i], innerW, ctx.measure));
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
- const cells = splitRows(contentFrame, rowSpecs, gap, sp);
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
- const cells = splitCols(contentFrame, colSpecs, gap, sp);
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
- const cells = splitGrid(contentFrame, split.grid, gap, sp);
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
- const cells = splitZones(split.zones);
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
- function estimateIntrinsicHeight(child, innerW, measure) {
186
- if (!child.measure) {
187
- if (child.children && child.children.length > 0) {
188
- return 60; // default min row height for containers
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
- const avail = { x: 0, y: 0, w: innerW, h: 0 };
193
- const m = child.measure(avail);
194
- const pad = resolveSides(child.style.padding);
195
- return m.h + pad.top + pad.bottom;
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 resolved = resolveNode(root, world, null, { measure }, overflow);
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
- resolveCollisions(boxes, world);
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.0.0",
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
  }