@@ -40,10 +40,162 @@ function NodeHandles() {
4040}
4141
4242// ---------------------------------------------------------------------------
43- // Shape visual styles
43+ // SVG shape renderers — diamond, hexagon, cylinder
4444// ---------------------------------------------------------------------------
4545
46- function getShapeStyle ( shape : CanvasShape | undefined , selected : boolean ) : React . CSSProperties {
46+ interface SvgShapeProps {
47+ width : number ;
48+ height : number ;
49+ selected : boolean ;
50+ label : string ;
51+ shape : CanvasShape ;
52+ }
53+
54+ const STROKE_REST = "var(--border-default)" ;
55+ const STROKE_SELECTED = "var(--accent-primary)" ;
56+ const FILL_COLOR = "var(--bg-surface)" ;
57+ const STROKE_WIDTH = 1.5 ;
58+
59+ function DiamondSvg ( { width, height, selected, label } : SvgShapeProps ) {
60+ const stroke = selected ? STROKE_SELECTED : STROKE_REST ;
61+ const mid = { x : width / 2 , y : height / 2 } ;
62+ const points = `${ mid . x } ,0 ${ width } ,${ mid . y } ${ mid . x } ,${ height } 0,${ mid . y } ` ;
63+ return (
64+ < svg
65+ width = { width }
66+ height = { height }
67+ viewBox = { `0 0 ${ width } ${ height } ` }
68+ style = { { display : "block" , transition : "stroke 0.15s ease" , overflow : "visible" } }
69+ >
70+ < polygon
71+ points = { points }
72+ fill = { FILL_COLOR }
73+ stroke = { stroke }
74+ strokeWidth = { STROKE_WIDTH }
75+ />
76+ < text
77+ x = { mid . x }
78+ y = { mid . y }
79+ textAnchor = "middle"
80+ dominantBaseline = "central"
81+ style = { {
82+ fontSize : 12 ,
83+ fontFamily : "var(--font-sans)" ,
84+ fill : label ? "var(--text-primary)" : "var(--text-muted)" ,
85+ fontStyle : label ? "normal" : "italic" ,
86+ pointerEvents : "none" ,
87+ } }
88+ >
89+ { label || "diamond" }
90+ </ text >
91+ </ svg >
92+ ) ;
93+ }
94+
95+ function HexagonSvg ( { width, height, selected, label } : SvgShapeProps ) {
96+ const stroke = selected ? STROKE_SELECTED : STROKE_REST ;
97+ const mid = { x : width / 2 , y : height / 2 } ;
98+ const qx = width * 0.25 ;
99+ const qx3 = width * 0.75 ;
100+ const points = `${ qx } ,0 ${ qx3 } ,0 ${ width } ,${ mid . y } ${ qx3 } ,${ height } ${ qx } ,${ height } 0,${ mid . y } ` ;
101+ return (
102+ < svg
103+ width = { width }
104+ height = { height }
105+ viewBox = { `0 0 ${ width } ${ height } ` }
106+ style = { { display : "block" , transition : "stroke 0.15s ease" , overflow : "visible" } }
107+ >
108+ < polygon
109+ points = { points }
110+ fill = { FILL_COLOR }
111+ stroke = { stroke }
112+ strokeWidth = { STROKE_WIDTH }
113+ />
114+ < text
115+ x = { mid . x }
116+ y = { mid . y }
117+ textAnchor = "middle"
118+ dominantBaseline = "central"
119+ style = { {
120+ fontSize : 12 ,
121+ fontFamily : "var(--font-sans)" ,
122+ fill : label ? "var(--text-primary)" : "var(--text-muted)" ,
123+ fontStyle : label ? "normal" : "italic" ,
124+ pointerEvents : "none" ,
125+ } }
126+ >
127+ { label || "hexagon" }
128+ </ text >
129+ </ svg >
130+ ) ;
131+ }
132+
133+ function CylinderSvg ( { width, height, selected, label } : SvgShapeProps ) {
134+ const stroke = selected ? STROKE_SELECTED : STROKE_REST ;
135+ // Cap ellipse radius along Y axis — proportional to width
136+ const ry = Math . max ( 6 , width * 0.12 ) ;
137+ const mid = { x : width / 2 , y : height / 2 + ry / 2 } ;
138+ return (
139+ < svg
140+ width = { width }
141+ height = { height }
142+ viewBox = { `0 0 ${ width } ${ height } ` }
143+ style = { { display : "block" , transition : "stroke 0.15s ease" , overflow : "visible" } }
144+ >
145+ { /* Body rectangle */ }
146+ < rect
147+ x = { 0 }
148+ y = { ry }
149+ width = { width }
150+ height = { height - ry }
151+ fill = { FILL_COLOR }
152+ stroke = { stroke }
153+ strokeWidth = { STROKE_WIDTH }
154+ />
155+ { /* Bottom cap ellipse */ }
156+ < ellipse
157+ cx = { width / 2 }
158+ cy = { height }
159+ rx = { width / 2 }
160+ ry = { ry }
161+ fill = { FILL_COLOR }
162+ stroke = { stroke }
163+ strokeWidth = { STROKE_WIDTH }
164+ />
165+ { /* Top cap ellipse */ }
166+ < ellipse
167+ cx = { width / 2 }
168+ cy = { ry }
169+ rx = { width / 2 }
170+ ry = { ry }
171+ fill = { FILL_COLOR }
172+ stroke = { stroke }
173+ strokeWidth = { STROKE_WIDTH }
174+ />
175+ < text
176+ x = { mid . x }
177+ y = { mid . y }
178+ textAnchor = "middle"
179+ dominantBaseline = "central"
180+ style = { {
181+ fontSize : 12 ,
182+ fontFamily : "var(--font-sans)" ,
183+ fill : label ? "var(--text-primary)" : "var(--text-muted)" ,
184+ fontStyle : label ? "normal" : "italic" ,
185+ pointerEvents : "none" ,
186+ } }
187+ >
188+ { label || "cylinder" }
189+ </ text >
190+ </ svg >
191+ ) ;
192+ }
193+
194+ // ---------------------------------------------------------------------------
195+ // CSS shape styles — rectangle, circle, pill
196+ // ---------------------------------------------------------------------------
197+
198+ function getCssShapeStyle ( shape : CanvasShape | undefined , selected : boolean ) : React . CSSProperties {
47199 const base : React . CSSProperties = {
48200 width : "100%" ,
49201 height : "100%" ,
@@ -52,56 +204,23 @@ function getShapeStyle(shape: CanvasShape | undefined, selected: boolean): React
52204 justifyContent : "center" ,
53205 boxSizing : "border-box" ,
54206 background : "var(--bg-surface)" ,
55- border : `1.5px solid ${ selected ? "var(--accent-primary)" : "var(--border-default)" } ` ,
207+ border : `${ STROKE_WIDTH } px solid ${ selected ? STROKE_SELECTED : STROKE_REST } ` ,
56208 transition : "border-color 0.15s ease" ,
57209 overflow : "hidden" ,
58210 position : "relative" ,
59211 } ;
60212
61213 switch ( shape ) {
62- case "circle" : return { ...base , borderRadius : "50%" } ;
63- case "pill" : return { ...base , borderRadius : 9999 } ;
64- case "cylinder" : return { ...base , borderRadius : "50% / 15%" } ;
65- case "diamond" :
66- return {
67- ...base ,
68- background : "transparent" ,
69- border : "none" ,
70- clipPath : "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)" ,
71- } ;
72- case "hexagon" :
73- return {
74- ...base ,
75- background : "transparent" ,
76- border : "none" ,
77- clipPath : "polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)" ,
78- } ;
214+ case "circle" :
215+ return { ...base , borderRadius : "50%" } ;
216+ case "pill" :
217+ return { ...base , borderRadius : 9999 } ;
79218 case "rectangle" :
80219 default :
81220 return { ...base , borderRadius : 6 } ;
82221 }
83222}
84223
85- // Diamond and hexagon need a filled inner layer since the outer uses clip-path (border gets clipped).
86- function ClipFill ( { shape, selected } : { shape : CanvasShape ; selected : boolean } ) {
87- const clipPath =
88- shape === "diamond"
89- ? "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)"
90- : "polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)" ;
91- return (
92- < div
93- style = { {
94- position : "absolute" ,
95- inset : 0 ,
96- background : "var(--bg-surface)" ,
97- border : `1.5px solid ${ selected ? "var(--accent-primary)" : "var(--border-default)" } ` ,
98- clipPath,
99- transition : "border-color 0.15s ease" ,
100- } }
101- />
102- ) ;
103- }
104-
105224// ---------------------------------------------------------------------------
106225// Dimension input — appears on node hover
107226// ---------------------------------------------------------------------------
@@ -158,6 +277,8 @@ function DimInput({ axis, value, onCommit }: DimInputProps) {
158277// Custom node component
159278// ---------------------------------------------------------------------------
160279
280+ const SVG_SHAPES = new Set < CanvasShape > ( [ "diamond" , "hexagon" , "cylinder" ] ) ;
281+
161282export const CanvasNodeComponent = memo ( function CanvasNodeComponent ( {
162283 data,
163284 selected,
@@ -184,7 +305,7 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({
184305 [ id , setNodes ] ,
185306 ) ;
186307
187- const needsClipFill = shape === "diamond" || shape === "hexagon" ;
308+ const isSvgShape = shape !== undefined && SVG_SHAPES . has ( shape ) ;
188309
189310 return (
190311 < div
@@ -202,31 +323,68 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({
202323
203324 < NodeHandles />
204325
205- { /* Shape visual — inner div so clip-path doesn't swallow the handles */ }
206- < div style = { getShapeStyle ( shape , ! ! selected ) } >
207- { needsClipFill && < ClipFill shape = { shape ! } selected = { ! ! selected } /> }
208- < span
326+ { isSvgShape ? (
327+ /* SVG-based shapes — diamond, hexagon, cylinder */
328+ < div
209329 style = { {
210- fontSize : 12 ,
211- fontFamily : "var(--font-sans)" ,
212- color : "var(--text-primary)" ,
213- textAlign : "center" ,
214- lineHeight : 1.4 ,
215- overflow : "hidden" ,
216- textOverflow : "ellipsis" ,
217- whiteSpace : "nowrap" ,
218- maxWidth : "80%" ,
219- position : "relative" ,
220- zIndex : 1 ,
330+ width : "100%" ,
331+ height : "100%" ,
332+ display : "flex" ,
333+ alignItems : "center" ,
334+ justifyContent : "center" ,
221335 } }
222336 >
223- { data . label || (
224- < span style = { { color : "var(--text-muted)" , fontStyle : "italic" } } >
225- { shape ?? "node" }
226- </ span >
337+ { shape === "diamond" && (
338+ < DiamondSvg
339+ width = { width }
340+ height = { height }
341+ selected = { ! ! selected }
342+ label = { data . label }
343+ shape = { shape }
344+ />
345+ ) }
346+ { shape === "hexagon" && (
347+ < HexagonSvg
348+ width = { width }
349+ height = { height }
350+ selected = { ! ! selected }
351+ label = { data . label }
352+ shape = { shape }
353+ />
227354 ) }
228- </ span >
229- </ div >
355+ { shape === "cylinder" && (
356+ < CylinderSvg
357+ width = { width }
358+ height = { height }
359+ selected = { ! ! selected }
360+ label = { data . label }
361+ shape = { shape }
362+ />
363+ ) }
364+ </ div >
365+ ) : (
366+ /* CSS-based shapes — rectangle, circle, pill */
367+ < div style = { getCssShapeStyle ( shape , ! ! selected ) } >
368+ < span
369+ style = { {
370+ fontSize : 12 ,
371+ fontFamily : "var(--font-sans)" ,
372+ color : data . label ? "var(--text-primary)" : "var(--text-muted)" ,
373+ fontStyle : data . label ? "normal" : "italic" ,
374+ textAlign : "center" ,
375+ lineHeight : 1.4 ,
376+ overflow : "hidden" ,
377+ textOverflow : "ellipsis" ,
378+ whiteSpace : "nowrap" ,
379+ maxWidth : "80%" ,
380+ position : "relative" ,
381+ zIndex : 1 ,
382+ } }
383+ >
384+ { data . label || ( shape ?? "node" ) }
385+ </ span >
386+ </ div >
387+ ) }
230388
231389 { /* Dimension inputs — only on hover */ }
232390 { isHovered && (
0 commit comments