11import type { LayoutResult , NodeRecord } from "./solver" ;
22
3+ export type Vec2 = { x : number ; y : number } ;
4+
5+ export const vec = ( x : number , y : number ) : Vec2 => ( { x, y } ) ;
6+
7+ export const addVec = ( a : Vec2 , b : Vec2 ) : Vec2 => ( {
8+ x : a . x + b . x ,
9+ y : a . y + b . y ,
10+ } ) ;
11+
12+ export const subVec = ( a : Vec2 , b : Vec2 ) : Vec2 => ( {
13+ x : a . x - b . x ,
14+ y : a . y - b . y ,
15+ } ) ;
16+
17+ export const scaleVec = ( v : Vec2 , s : number ) : Vec2 => ( {
18+ x : v . x * s ,
19+ y : v . y * s ,
20+ } ) ;
21+
22+ export const lengthVec = ( v : Vec2 ) : number => Math . hypot ( v . x , v . y ) ;
23+
24+ export const perpVec = ( v : Vec2 ) : Vec2 => ( { x : - v . y , y : v . x } ) ;
25+
26+ export type BoundingBox2d = { start : Vec2 ; end : Vec2 } ;
27+
28+ export const boundingBoxFromPoints = ( ...pts : Vec2 [ ] ) : BoundingBox2d => {
29+ const xs = pts . map ( ( p ) => p . x ) ;
30+ const ys = pts . map ( ( p ) => p . y ) ;
31+ return {
32+ start : { x : Math . min ( ...xs ) , y : Math . min ( ...ys ) } ,
33+ end : { x : Math . max ( ...xs ) , y : Math . max ( ...ys ) } ,
34+ } ;
35+ } ;
36+
37+ export const boundingBoxFromRect = ( rect : {
38+ x : number ;
39+ y : number ;
40+ width : number ;
41+ height : number ;
42+ } ) : BoundingBox2d =>
43+ boundingBoxFromPoints (
44+ vec ( rect . x , rect . y ) ,
45+ vec ( rect . x + rect . width , rect . y + rect . height ) ,
46+ ) ;
47+
48+ export function unionBoundingBox2d (
49+ a : BoundingBox2d ,
50+ b : BoundingBox2d ,
51+ ) : BoundingBox2d {
52+ return {
53+ start : {
54+ x : Math . min ( a . start . x , b . start . x ) ,
55+ y : Math . min ( a . start . y , b . start . y ) ,
56+ } ,
57+ end : {
58+ x : Math . max ( a . end . x , b . end . x ) ,
59+ y : Math . max ( a . end . y , b . end . y ) ,
60+ } ,
61+ } ;
62+ }
63+
64+ export function layoutBounds (
65+ layout : LayoutResult ,
66+ nodes ?: NodeRecord [ ] ,
67+ ) : BoundingBox2d {
68+ const boxes = Object . values ( layout )
69+ . map ( boundingBoxFromRect )
70+ . reduce < BoundingBox2d | undefined > (
71+ ( acc , bb ) => ( acc ? unionBoundingBox2d ( acc , bb ) : bb ) ,
72+ undefined ,
73+ ) ;
74+
75+ const withStroke = Object . entries ( layout ) . reduce < BoundingBox2d | undefined > (
76+ ( acc , [ id , box ] ) => {
77+ const n = nodes ?. find ( ( m ) => m . id === id ) ;
78+ const sw = n ?. strokeWidth ?? ( n ?. type === "arrow" ? 3 : 0 ) ;
79+ if ( ! sw ) return acc ;
80+ const bb = boundingBoxFromRect ( {
81+ x : box . x - sw / 2 ,
82+ y : box . y - sw / 2 ,
83+ width : box . width + sw ,
84+ height : box . height + sw ,
85+ } ) ;
86+ return acc ? unionBoundingBox2d ( acc , bb ) : bb ;
87+ } ,
88+ boxes ,
89+ ) ;
90+
91+ const fallback = boundingBoxFromPoints ( vec ( 0 , 0 ) , vec ( 0 , 0 ) ) ;
92+ return withStroke ?? fallback ;
93+ }
94+
395export function xml (
496 tag : string ,
597 args : Record < string , string | number | undefined > ,
@@ -31,106 +123,96 @@ function rect(
31123 id : string ,
32124 box : LayoutResult [ string ] ,
33125 n : NodeRecord | undefined ,
34- dx : number ,
35- dy : number ,
126+ offset : Vec2 ,
36127) : string {
37128 const sw = n ?. strokeWidth ?? 0 ;
38- return ` ${ xml ( "rect" , {
129+ return xml ( "rect" , {
39130 id,
40- x : box . x + dx - sw / 2 ,
41- y : box . y + dy - sw / 2 ,
131+ x : box . x + offset . x - sw / 2 ,
132+ y : box . y + offset . y - sw / 2 ,
42133 width : box . width + sw ,
43134 height : box . height + sw ,
44135 ...attrs ( n ) ,
45- } ) } \n` ;
136+ } ) ;
46137}
47138
48139function circle (
49140 id : string ,
50141 box : LayoutResult [ string ] ,
51142 n : NodeRecord ,
52- dx : number ,
53- dy : number ,
143+ offset : Vec2 ,
54144) : string {
55145 const r = ( n . r ?? box . width / 2 ) as number ;
56- const cx = box . x + dx + r ;
57- const cy = box . y + dy + r ;
58- return ` ${ xml ( "circle" , {
146+ const cx = box . x + offset . x + r ;
147+ const cy = box . y + offset . y + r ;
148+ return xml ( "circle" , {
59149 id,
60150 cx,
61151 cy,
62152 r,
63153 ...attrs ( n ) ,
64- } ) } \n` ;
154+ } ) ;
65155}
66156
67157function textNode (
68158 id : string ,
69159 box : LayoutResult [ string ] ,
70160 n : NodeRecord ,
71- dx : number ,
72- dy : number ,
161+ offset : Vec2 ,
73162) : string {
74163 const t = n . text ?? "" ;
75- return ` ${ xml (
164+ return xml (
76165 "text" ,
77166 {
78167 id,
79- x : box . x + dx ,
80- y : box . y + dy ,
168+ x : box . x + offset . x ,
169+ y : box . y + offset . y ,
81170 "dominant-baseline" : "hanging" ,
171+ "font-family" : "sans-serif" ,
82172 ...attrs ( n ) ,
83173 } ,
84174 t ,
85- ) } \n` ;
175+ ) ;
86176}
87177
88178function arrow (
89179 id : string ,
90180 n : NodeRecord ,
91181 layout : LayoutResult ,
92- dx : number ,
93- dy : number ,
182+ shift : Vec2 ,
94183) : string | undefined {
95184 const a = n . from ? layout [ n . from ] : undefined ;
96185 const b = n . to ? layout [ n . to ] : undefined ;
97186 if ( ! a || ! b ) return ;
98187 const margin = 5 ;
99- const x1 = a . x + dx + a . width / 2 ;
100- const y1 = a . y + dy + a . height + margin ;
101- const x2 = b . x + dx + b . width / 2 ;
102- const y2 = b . y + dy - margin ;
103- const dxv = x2 - x1 ;
104- const dyv = y2 - y1 ;
105- const len = Math . hypot ( dxv , dyv ) ;
188+ const start = vec (
189+ a . x + shift . x + a . width / 2 ,
190+ a . y + shift . y + a . height + margin ,
191+ ) ;
192+ const tip = vec ( b . x + shift . x + b . width / 2 , b . y + shift . y - margin ) ;
193+ const dir = subVec ( tip , start ) ;
194+ const len = lengthVec ( dir ) ;
106195 const head = 6 ;
107196 const ratio = len > 0 ? ( len - head ) / len : 0 ;
108- const sx2 = x1 + dxv * ratio ;
109- const sy2 = y1 + dyv * ratio ;
110- const ux = len === 0 ? 0 : dxv / len ;
111- const uy = len === 0 ? 0 : dyv / len ;
112- const perpX = - uy ;
113- const perpY = ux ;
197+ const shaftEnd = addVec ( start , scaleVec ( dir , ratio ) ) ;
198+ const unit = len === 0 ? vec ( 0 , 0 ) : scaleVec ( dir , 1 / len ) ;
199+ const perp = perpVec ( unit ) ;
114200 const w = head * 0.6 ;
115- const bx = sx2 ;
116- const by = sy2 ;
117- const leftX = bx + perpX * w * 0.5 ;
118- const leftY = by + perpY * w * 0.5 ;
119- const rightX = bx - perpX * w * 0.5 ;
120- const rightY = by - perpY * w * 0.5 ;
201+ const left = addVec ( shaftEnd , scaleVec ( perp , w * 0.5 ) ) ;
202+ const right = addVec ( shaftEnd , scaleVec ( perp , - w * 0.5 ) ) ;
121203 const line = xml ( "line" , {
122204 id,
123- x1,
124- y1,
125- x2 : sx2 ,
126- y2 : sy2 ,
205+ x1 : start . x ,
206+ y1 : start . y ,
207+ x2 : shaftEnd . x ,
208+ y2 : shaftEnd . y ,
127209 ...attrs ( n ) ,
128210 } ) ;
129211 const poly = xml ( "polygon" , {
130- points : `${ x2 } ,${ y2 } ${ leftX } ,${ leftY } ${ rightX } ,${ rightY } ` ,
212+ points : `${ tip . x } ,${ tip . y } ${ left . x } ,${ left . y } ${ right . x } ,${ right . y } ` ,
131213 ...attrs ( n ) ,
132214 } ) ;
133- return `${ line } \n${ poly } \n ` ;
215+ return `${ line } \n${ poly } ` ;
134216}
135217
136218export function layoutToSvg (
@@ -139,72 +221,23 @@ export function layoutToSvg(
139221) : string {
140222 const byId = new Map < string , NodeRecord > ( ) ;
141223 if ( nodes ) for ( const n of nodes ) byId . set ( n . id , n ) ;
142- const boxes = Object . values ( layout ) ;
143- let minX = Math . min ( ...boxes . map ( ( b ) => b . x ) ) ;
144- let minY = Math . min ( ...boxes . map ( ( b ) => b . y ) ) ;
145- let maxX = Math . max ( ...boxes . map ( ( b ) => b . x + b . width ) ) ;
146- let maxY = Math . max ( ...boxes . map ( ( b ) => b . y + b . height ) ) ;
147- for ( const [ id , box ] of Object . entries ( layout ) ) {
148- const n = nodes ?. find ( ( m ) => m . id === id ) ;
149- const sw = n ?. strokeWidth ?? ( n ?. type === "arrow" ? 3 : 0 ) ;
150- if ( sw ) {
151- minX = Math . min ( minX , box . x - sw / 2 ) ;
152- minY = Math . min ( minY , box . y - sw / 2 ) ;
153- maxX = Math . max ( maxX , box . x + box . width + sw / 2 ) ;
154- maxY = Math . max ( maxY , box . y + box . height + sw / 2 ) ;
155- }
156- }
157- // account for arrow endpoints which may lie outside node boxes
158- for ( const n of nodes ?? [ ] ) {
159- if ( n . type === "arrow" && n . from && n . to ) {
160- const a = layout [ n . from ] ;
161- const b = layout [ n . to ] ;
162- if ( a && b ) {
163- const margin = 5 ;
164- const x1 = a . x + a . width / 2 ;
165- const y1 = a . y + a . height + margin ;
166- const x2 = b . x + b . width / 2 ;
167- const y2 = b . y - margin ;
168- const dxv = x2 - x1 ;
169- const dyv = y2 - y1 ;
170- const len = Math . hypot ( dxv , dyv ) ;
171- const head = 6 ;
172- const ratio = len > 0 ? ( len - head ) / len : 0 ;
173- const sx2 = x1 + dxv * ratio ;
174- const sy2 = y1 + dyv * ratio ;
175- const ux = len === 0 ? 0 : dxv / len ;
176- const uy = len === 0 ? 0 : dyv / len ;
177- const perpX = - uy ;
178- const perpY = ux ;
179- const w = head * 0.6 ;
180- const leftX = sx2 + perpX * w * 0.5 ;
181- const leftY = sy2 + perpY * w * 0.5 ;
182- const rightX = sx2 - perpX * w * 0.5 ;
183- const rightY = sy2 - perpY * w * 0.5 ;
184- minX = Math . min ( minX , x1 , x2 , leftX , rightX ) ;
185- minY = Math . min ( minY , y1 , y2 , leftY , rightY ) ;
186- maxX = Math . max ( maxX , x1 , x2 , leftX , rightX ) ;
187- maxY = Math . max ( maxY , y1 , y2 , leftY , rightY ) ;
188- }
189- }
190- }
191- const dx = minX < 0 ? - minX : 0 ;
192- const dy = minY < 0 ? - minY : 0 ;
193- const body = Object . entries ( layout )
194- . map ( ( [ id , box ] ) => {
195- const n = byId . get ( id ) ;
196- if ( ! n ?. type ) return "" ;
197- if ( n . type === "circle" ) return circle ( id , box , n , dx , dy ) ;
198- if ( n . type === "text" ) return textNode ( id , box , n , dx , dy ) ;
199- if ( n . type === "arrow" ) return arrow ( id , n , layout , dx , dy ) ?? "" ;
200- return rect ( id , box , n , dx , dy ) ;
201- } )
202- . join ( "" ) ;
203- const w = maxX - minX ;
204- const h = maxY - minY ;
224+ const bounds = layoutBounds ( layout , nodes ) ;
225+ const min = bounds . start ;
226+ const max = bounds . end ;
227+ const offset = vec ( min . x < 0 ? - min . x : 0 , min . y < 0 ? - min . y : 0 ) ;
228+ const body = Object . entries ( layout ) . map ( ( [ id , box ] ) => {
229+ const n = byId . get ( id ) ;
230+ if ( ! n ?. type ) return "" ;
231+ if ( n . type === "circle" ) return circle ( id , box , n , offset ) ;
232+ if ( n . type === "text" ) return textNode ( id , box , n , offset ) ;
233+ if ( n . type === "arrow" ) return arrow ( id , n , layout , offset ) ?? "" ;
234+ return rect ( id , box , n , offset ) ;
235+ } ) ;
236+ const width = max . x - min . x ;
237+ const height = max . y - min . y ;
205238 return xml (
206239 "svg" ,
207- { xmlns : "http://www.w3.org/2000/svg" , width : w , height : h } ,
208- `\n ${ body } ` ,
240+ { xmlns : "http://www.w3.org/2000/svg" , width, height } ,
241+ body ,
209242 ) ;
210243}
0 commit comments