| 1 | import type { InsertShapeType } from './buildInsertElementHtml' |
| 2 | |
| 3 | export interface ShapeDefinition { |
| 4 | type: InsertShapeType |
| 5 | defaultWidth: number |
| 6 | defaultHeight: number |
| 7 | defaultFill: string |
| 8 | defaultStroke: string |
| 9 | strokeWidth: number |
| 10 | /** |
| 11 | * Produce the inner SVG markup for a viewBox of the given width/height. |
| 12 | * Filled shapes use preserveAspectRatio="none" so users can stretch them |
| 13 | * freely; the stroke-only line keeps a non-scaling stroke. |
| 14 | */ |
| 15 | renderInner: (viewBoxW: number, viewBoxH: number, ctx: ShapeRenderContext) => string |
| 16 | } |
| 17 | |
| 18 | export interface ShapeRenderContext { |
| 19 | fill: string |
| 20 | stroke: string |
| 21 | strokeWidth: number |
| 22 | } |
| 23 | |
| 24 | const DEFAULT_FILL = '#d4e4c1' |
| 25 | const DEFAULT_STROKE = '#7a875f' |
| 26 | |
| 27 | function round(n: number): number { |
| 28 | return Math.round(n * 100) / 100 |
| 29 | } |
| 30 | |
| 31 | /** Points string for a regular polygon inscribed in radius r around (cx, cy). */ |
| 32 | function regularPolygon( |
| 33 | cx: number, |
| 34 | cy: number, |
| 35 | r: number, |
| 36 | sides: number, |
| 37 | startAngleDeg: number |
| 38 | ): string { |
| 39 | const pts: string[] = [] |
| 40 | for (let i = 0; i < sides; i++) { |
| 41 | const a = ((startAngleDeg + (360 / sides) * i) * Math.PI) / 180 |
| 42 | pts.push(`${round(cx + r * Math.cos(a))},${round(cy + r * Math.sin(a))}`) |
| 43 | } |
| 44 | return pts.join(' ') |
| 45 | } |
| 46 | |
| 47 | /** Points string for a star with `points` spikes, alternating outer/inner radius. */ |
| 48 | function starPolygon( |
| 49 | cx: number, |
| 50 | cy: number, |
| 51 | outerR: number, |
| 52 | innerR: number, |
| 53 | points: number, |
| 54 | startAngleDeg: number |
| 55 | ): string { |
| 56 | const pts: string[] = [] |
| 57 | for (let i = 0; i < points * 2; i++) { |
| 58 | const r = i % 2 === 0 ? outerR : innerR |
| 59 | const a = ((startAngleDeg + (180 / points) * i) * Math.PI) / 180 |
| 60 | pts.push(`${round(cx + r * Math.cos(a))},${round(cy + r * Math.sin(a))}`) |
| 61 | } |
| 62 | return pts.join(' ') |
| 63 | } |
| 64 | |
| 65 | function filledPolygon( |
| 66 | points: string, |
| 67 | ctx: ShapeRenderContext, |
| 68 | extraAttrs = '' |
| 69 | ): string { |
| 70 | return `<polygon points="${points}" fill="${ctx.fill}" stroke="${ctx.stroke}" stroke-width="${ctx.strokeWidth}" stroke-linejoin="round" ${extraAttrs} />` |
| 71 | } |
| 72 | |
| 73 | export const SHAPE_REGISTRY: Record<InsertShapeType, ShapeDefinition> = { |
| 74 | rect: { |
| 75 | type: 'rect', |
| 76 | defaultWidth: 220, |
| 77 | defaultHeight: 120, |
| 78 | defaultFill: DEFAULT_FILL, |
| 79 | defaultStroke: DEFAULT_STROKE, |
| 80 | strokeWidth: 2, |
| 81 | renderInner: (w, h, ctx) => { |
| 82 | const inset = ctx.strokeWidth / 2 + 0.5 |
| 83 | return `<rect x="${inset}" y="${inset}" width="${round(w - inset * 2)}" height="${round(h - inset * 2)}" fill="${ctx.fill}" stroke="${ctx.stroke}" stroke-width="${ctx.strokeWidth}" />` |
| 84 | } |
| 85 | }, |
| 86 | 'rounded-rect': { |
| 87 | type: 'rounded-rect', |
| 88 | defaultWidth: 240, |
| 89 | defaultHeight: 120, |
| 90 | defaultFill: DEFAULT_FILL, |
| 91 | defaultStroke: DEFAULT_STROKE, |
| 92 | strokeWidth: 2, |
| 93 | renderInner: (w, h, ctx) => { |
| 94 | const inset = ctx.strokeWidth / 2 + 0.5 |
| 95 | const rx = Math.min(w, h) * 0.15 |
| 96 | return `<rect x="${inset}" y="${inset}" width="${round(w - inset * 2)}" height="${round(h - inset * 2)}" rx="${rx}" ry="${rx}" fill="${ctx.fill}" stroke="${ctx.stroke}" stroke-width="${ctx.strokeWidth}" />` |
| 97 | } |
| 98 | }, |
| 99 | ellipse: { |
| 100 | type: 'ellipse', |
| 101 | defaultWidth: 140, |
| 102 | defaultHeight: 140, |
| 103 | defaultFill: DEFAULT_FILL, |
| 104 | defaultStroke: DEFAULT_STROKE, |
| 105 | strokeWidth: 2, |
| 106 | renderInner: (w, h, ctx) => { |
| 107 | const cx = w / 2 |
| 108 | const cy = h / 2 |
| 109 | const rx = w / 2 - ctx.strokeWidth / 2 - 0.5 |
| 110 | const ry = h / 2 - ctx.strokeWidth / 2 - 0.5 |
| 111 | return `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${ctx.fill}" stroke="${ctx.stroke}" stroke-width="${ctx.strokeWidth}" />` |
| 112 | } |
| 113 | }, |
| 114 | triangle: { |
| 115 | type: 'triangle', |
| 116 | defaultWidth: 140, |
| 117 | defaultHeight: 120, |
| 118 | defaultFill: DEFAULT_FILL, |
| 119 | defaultStroke: DEFAULT_STROKE, |
| 120 | strokeWidth: 2, |
| 121 | renderInner: (w, h, ctx) => { |
| 122 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 123 | const pts = `${w / 2},${pad} ${w - pad},${h - pad} ${pad},${h - pad}` |
| 124 | return filledPolygon(pts, ctx) |
| 125 | } |
| 126 | }, |
| 127 | diamond: { |
| 128 | type: 'diamond', |
| 129 | defaultWidth: 160, |
| 130 | defaultHeight: 120, |
| 131 | defaultFill: DEFAULT_FILL, |
| 132 | defaultStroke: DEFAULT_STROKE, |
| 133 | strokeWidth: 2, |
| 134 | renderInner: (w, h, ctx) => { |
| 135 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 136 | const cx = w / 2 |
| 137 | const cy = h / 2 |
| 138 | const pts = `${cx},${pad} ${w - pad},${cy} ${cx},${h - pad} ${pad},${cy}` |
| 139 | return filledPolygon(pts, ctx) |
| 140 | } |
| 141 | }, |
| 142 | pentagon: { |
| 143 | type: 'pentagon', |
| 144 | defaultWidth: 140, |
| 145 | defaultHeight: 140, |
| 146 | defaultFill: DEFAULT_FILL, |
| 147 | defaultStroke: DEFAULT_STROKE, |
| 148 | strokeWidth: 2, |
| 149 | renderInner: (w, h, ctx) => { |
| 150 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 151 | const r = Math.min(w, h) / 2 - pad |
| 152 | const pts = regularPolygon(w / 2, h / 2, r, 5, -90) |
| 153 | return filledPolygon(pts, ctx) |
| 154 | } |
| 155 | }, |
| 156 | hexagon: { |
| 157 | type: 'hexagon', |
| 158 | defaultWidth: 160, |
| 159 | defaultHeight: 140, |
| 160 | defaultFill: DEFAULT_FILL, |
| 161 | defaultStroke: DEFAULT_STROKE, |
| 162 | strokeWidth: 2, |
| 163 | renderInner: (w, h, ctx) => { |
| 164 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 165 | const r = Math.min(w, h) / 2 - pad |
| 166 | // pointy-top hexagon (vertex at top) |
| 167 | const pts = regularPolygon(w / 2, h / 2, r, 6, -90) |
| 168 | return filledPolygon(pts, ctx) |
| 169 | } |
| 170 | }, |
| 171 | parallelogram: { |
| 172 | type: 'parallelogram', |
| 173 | defaultWidth: 220, |
| 174 | defaultHeight: 120, |
| 175 | defaultFill: DEFAULT_FILL, |
| 176 | defaultStroke: DEFAULT_STROKE, |
| 177 | strokeWidth: 2, |
| 178 | renderInner: (w, h, ctx) => { |
| 179 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 180 | const skew = h * 0.4 |
| 181 | const pts = `${pad + skew},${pad} ${w - pad},${pad} ${w - pad - skew},${h - pad} ${pad},${h - pad}` |
| 182 | return filledPolygon(pts, ctx) |
| 183 | } |
| 184 | }, |
| 185 | trapezoid: { |
| 186 | type: 'trapezoid', |
| 187 | defaultWidth: 200, |
| 188 | defaultHeight: 120, |
| 189 | defaultFill: DEFAULT_FILL, |
| 190 | defaultStroke: DEFAULT_STROKE, |
| 191 | strokeWidth: 2, |
| 192 | renderInner: (w, h, ctx) => { |
| 193 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 194 | const topInset = w * 0.2 |
| 195 | const pts = `${pad + topInset},${pad} ${w - pad - topInset},${pad} ${w - pad},${h - pad} ${pad},${h - pad}` |
| 196 | return filledPolygon(pts, ctx) |
| 197 | } |
| 198 | }, |
| 199 | 'star-5': { |
| 200 | type: 'star-5', |
| 201 | defaultWidth: 140, |
| 202 | defaultHeight: 140, |
| 203 | defaultFill: DEFAULT_FILL, |
| 204 | defaultStroke: DEFAULT_STROKE, |
| 205 | strokeWidth: 2, |
| 206 | renderInner: (w, h, ctx) => { |
| 207 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 208 | const outerR = Math.min(w, h) / 2 - pad |
| 209 | const innerR = outerR * 0.4 |
| 210 | const pts = starPolygon(w / 2, h / 2, outerR, innerR, 5, -90) |
| 211 | return filledPolygon(pts, ctx) |
| 212 | } |
| 213 | }, |
| 214 | line: { |
| 215 | type: 'line', |
| 216 | defaultWidth: 240, |
| 217 | defaultHeight: 8, |
| 218 | defaultFill: 'none', |
| 219 | defaultStroke: DEFAULT_STROKE, |
| 220 | strokeWidth: 4, |
| 221 | renderInner: (w, h, { stroke, strokeWidth }) => { |
| 222 | const y = h / 2 |
| 223 | return `<line x1="0" y1="${y}" x2="${w}" y2="${y}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linecap="round" vector-effect="non-scaling-stroke" />` |
| 224 | } |
| 225 | }, |
| 226 | 'arrow-right': { |
| 227 | type: 'arrow-right', |
| 228 | defaultWidth: 240, |
| 229 | defaultHeight: 80, |
| 230 | defaultFill: DEFAULT_STROKE, |
| 231 | defaultStroke: DEFAULT_STROKE, |
| 232 | strokeWidth: 2, |
| 233 | renderInner: (w, h, ctx) => { |
| 234 | // Solid block arrow: rectangular shaft + triangular head, filled. |
| 235 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 236 | const shaftH = h * 0.5 |
| 237 | const shaftTop = (h - shaftH) / 2 |
| 238 | const shaftBottom = shaftTop + shaftH |
| 239 | const headW = h * 0.6 |
| 240 | const bodyEnd = w - headW |
| 241 | const pts = [ |
| 242 | `${pad},${round(shaftTop)}`, |
| 243 | `${round(bodyEnd)},${round(shaftTop)}`, |
| 244 | `${round(bodyEnd)},${pad}`, |
| 245 | `${w - pad},${h / 2}`, |
| 246 | `${round(bodyEnd)},${h - pad}`, |
| 247 | `${round(bodyEnd)},${round(shaftBottom)}`, |
| 248 | `${pad},${round(shaftBottom)}` |
| 249 | ].join(' ') |
| 250 | return filledPolygon(pts, ctx) |
| 251 | } |
| 252 | }, |
| 253 | 'chevron-right': { |
| 254 | type: 'chevron-right', |
| 255 | defaultWidth: 200, |
| 256 | defaultHeight: 120, |
| 257 | defaultFill: DEFAULT_FILL, |
| 258 | defaultStroke: DEFAULT_STROKE, |
| 259 | strokeWidth: 2, |
| 260 | renderInner: (w, h, ctx) => { |
| 261 | const pad = ctx.strokeWidth / 2 + 0.5 |
| 262 | const notch = w * 0.35 |
| 263 | const pts = [ |
| 264 | `${pad},${pad}`, |
| 265 | `${round(w - notch)},${pad}`, |
| 266 | `${w - pad},${h / 2}`, |
| 267 | `${round(w - notch)},${h - pad}`, |
| 268 | `${pad},${h - pad}`, |
| 269 | `${round(pad + notch)},${h / 2}` |
| 270 | ].join(' ') |
| 271 | return filledPolygon(pts, ctx) |
| 272 | } |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | export const SHAPE_LIST: ShapeDefinition[] = Object.values(SHAPE_REGISTRY) |
| 277 | |
| 278 | export function getShapeDefinition(type: string): ShapeDefinition | undefined { |
| 279 | return SHAPE_REGISTRY[type as InsertShapeType] |
| 280 | } |
| 281 | |
| 282 | export function isRegisteredShapeType(type: string): boolean { |
| 283 | return Boolean(SHAPE_REGISTRY[type as InsertShapeType]) |
| 284 | } |
| 285 |