diff options
Diffstat (limited to 'apps/web/src/lib/graphics/definitions/polygon.ts')
| -rw-r--r-- | apps/web/src/lib/graphics/definitions/polygon.ts | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/apps/web/src/lib/graphics/definitions/polygon.ts b/apps/web/src/lib/graphics/definitions/polygon.ts new file mode 100644 index 00000000..780c974c --- /dev/null +++ b/apps/web/src/lib/graphics/definitions/polygon.ts @@ -0,0 +1,211 @@ +import type { ParamDefinition } from "@/lib/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface Point { + x: number; + y: number; +} + +interface PolygonParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + sides: number; + cornerRadius: number; +} + +const POLYGON_PARAMS: ParamDefinition<keyof PolygonParams & string>[] = [ + { + key: "fill", + label: "Fill", + type: "color", + default: "#ffffff", + }, + { + key: "stroke", + label: "Color", + type: "color", + default: "#000000", + group: "stroke", + }, + { + key: "strokeWidth", + label: "Width", + type: "number", + default: 0, + min: 0, + max: 64, + step: 1, + shortLabel: "W", + group: "stroke", + }, + STROKE_ALIGN_PARAM, + { + key: "sides", + label: "Sides", + type: "number", + default: 5, + min: 3, + max: 12, + step: 1, + shortLabel: "S", + }, + { + key: "cornerRadius", + label: "Corner radius", + type: "number", + default: 0, + min: 0, + max: 50, + step: 1, + shortLabel: "R", + }, +]; + +function buildPolygonVertices({ + centerX, + centerY, + radius, + sides, +}: { + centerX: number; + centerY: number; + radius: number; + sides: number; +}): Point[] { + return Array.from({ length: sides }, (_, index) => { + const angle = -Math.PI / 2 + (index * Math.PI * 2) / sides; + return { + x: centerX + Math.cos(angle) * radius, + y: centerY + Math.sin(angle) * radius, + }; + }); +} + +function distance({ a, b }: { a: Point; b: Point }): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function normalize(point: Point): Point { + const length = Math.hypot(point.x, point.y) || 1; + return { + x: point.x / length, + y: point.y / length, + }; +} + +function traceRoundedPolygonPath({ + path, + vertices, + radius, +}: { + path: Path2D; + vertices: Point[]; + radius: number; +}): void { + if (vertices.length < 3) { + return; + } + + if (radius <= 0) { + path.moveTo(vertices[0].x, vertices[0].y); + for (let index = 1; index < vertices.length; index++) { + path.lineTo(vertices[index].x, vertices[index].y); + } + path.closePath(); + return; + } + + for (let index = 0; index < vertices.length; index++) { + const previous = vertices[(index - 1 + vertices.length) % vertices.length]; + const current = vertices[index]; + const next = vertices[(index + 1) % vertices.length]; + const toPrevious = normalize({ + x: previous.x - current.x, + y: previous.y - current.y, + }); + const toNext = normalize({ + x: next.x - current.x, + y: next.y - current.y, + }); + const angle = Math.acos( + Math.max(-1, Math.min(1, toPrevious.x * toNext.x + toPrevious.y * toNext.y)), + ); + const maxOffset = + Math.min(distance({ a: previous, b: current }), distance({ a: current, b: next })) / 2; + const tangentOffset = Math.min(radius / Math.tan(angle / 2), maxOffset); + const start = { + x: current.x + toPrevious.x * tangentOffset, + y: current.y + toPrevious.y * tangentOffset, + }; + const end = { + x: current.x + toNext.x * tangentOffset, + y: current.y + toNext.y * tangentOffset, + }; + + if (index === 0) { + path.moveTo(start.x, start.y); + } else { + path.lineTo(start.x, start.y); + } + + path.arcTo( + current.x, + current.y, + end.x, + end.y, + Math.min(radius, maxOffset), + ); + } + + path.closePath(); +} + +export const polygonGraphicDefinition: GraphicDefinition = { + id: "polygon", + name: "Polygon", + keywords: ["polygon", "triangle", "pentagon", "hexagon", "diamond"], + params: POLYGON_PARAMS, + render({ ctx, params, width, height }) { + const fill = String(params.fill ?? "#ffffff"); + const stroke = String(params.stroke ?? "#000000"); + const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0)); + const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign; + const sides = Math.max(3, Math.min(12, Math.round(Number(params.sides ?? 5)))); + const inset = strokeAlign === "center" ? strokeWidth / 2 : 0; + const radius = Math.max(1, Math.min(width, height) / 2 - inset); + const maxCornerRadius = radius * Math.sin(Math.PI / sides); + const cornerRadiusPercent = Math.max(0, Number(params.cornerRadius ?? 0)); + const cornerRadius = + maxCornerRadius * Math.min(cornerRadiusPercent, 50) / 50; + const vertices = buildPolygonVertices({ + centerX: width / 2, + centerY: height / 2, + radius, + sides, + }); + + ctx.clearRect(0, 0, width, height); + const path = new Path2D(); + traceRoundedPolygonPath({ + path, + vertices, + radius: cornerRadius, + }); + ctx.fillStyle = fill; + ctx.fill(path); + + if (strokeWidth > 0) { + applyAlignedStroke({ + ctx, + path, + strokeWidth, + strokeAlign, + strokeColor: stroke, + }); + } + }, +}; |
