Last active
August 20, 2025 06:11
-
-
Save jossmac/aaf776ccee649ce5ee5ed65b8e142efd to your computer and use it in GitHub Desktop.
Generate the path data for shapes using familiar e.g. `circle` and `rect` syntax.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const DRAW_DIRECTION = ["clockwise", "anti-clockwise"] as const; | |
| /** | |
| * Generate a complex path string, using familiar SVG shape (`<circle>`, | |
| * `<rect>`, etc.) syntax. | |
| * | |
| * @param data An array of shape objects. | |
| * @param evenodd Alternate drawing direction for each path. When multiple paths are combined into a single string of path data, this will influence whether the paths are additive or subtractive. | |
| * @returns A string of SVG path data representing all shapes. | |
| * | |
| * @example | |
| * ```ts | |
| * shapePaths([ | |
| * { width: 32, height: 32 }, // rect | |
| * { cx: 12, cy: 12, r: 10 }, // circle | |
| * ]) | |
| * // "M0,0 H32 V32 H0 V0 Z M22,12 A10,10 0 1 0 2,12 A10,10 0 1 0 22,12 Z" | |
| * ``` | |
| */ | |
| export function shapePaths<T extends ShapeData>(data: T[], evenodd = false) { | |
| return data | |
| .map((shape, index) => { | |
| const draw = evenodd ? DRAW_DIRECTION[index % 2] : undefined; | |
| if (isCircleData(shape)) return circlePath(shape, { draw }); | |
| if (isEllipseData(shape)) return ellipsePath(shape, { draw }); | |
| if (isRectData(shape)) return rectPath(shape, { draw }); | |
| throw new Error(`Invalid shape data: ${JSON.stringify(shape)}`); | |
| }) | |
| .join(" ") | |
| .trim(); | |
| } | |
| // Transforms ----------------------------------------------------------------- | |
| /** | |
| * Generate the path data for a rectangle using familiar SVG `rect` syntax. | |
| * | |
| * @example | |
| * ```tsx | |
| * rectPath({ width: 100, height: 100 }) | |
| * // "M0,0 H100 V100 H0 Z" | |
| * ``` | |
| */ | |
| export function rectPath(rect: RectData, options: PathOptions = {}) { | |
| const { draw = "clockwise" } = options; | |
| const { x = 0, y = 0, width, height, radius } = rect; | |
| if (radius) { | |
| const r = Math.min(radius, width / 2, height / 2); | |
| if (draw === "anti-clockwise") { | |
| return directives` | |
| M${x + width - r},${y} | |
| H${x + r} | |
| A${r},${r} 0 0 0 ${x},${y + r} | |
| V${y + height - r} | |
| A${r},${r} 0 0 0 ${x + r},${y + height} | |
| H${x + width - r} | |
| A${r},${r} 0 0 0 ${x + width},${y + height - r} | |
| V${y + r} | |
| A${r},${r} 0 0 0 ${x + width - r},${y} | |
| Z | |
| `; | |
| } | |
| return directives` | |
| M${x + r},${y} | |
| H${x + width - r} | |
| A${r},${r} 0 0 1 ${x + width},${y + r} | |
| V${y + height - r} | |
| A${r},${r} 0 0 1 ${x + width - r},${y + height} | |
| H${x + r} | |
| A${r},${r} 0 0 1 ${x},${y + height - r} | |
| V${y + r} | |
| A${r},${r} 0 0 1 ${x + r},${y} | |
| Z | |
| `; | |
| } | |
| if (draw === "anti-clockwise") { | |
| return directives` | |
| M${x},${y} | |
| V${y + height} | |
| H${x + width} | |
| V${y} | |
| H${x} | |
| Z | |
| `; | |
| } | |
| return directives` | |
| M${x},${y} | |
| H${x + width} | |
| V${y + height} | |
| H${x} | |
| V${y} | |
| Z | |
| `; | |
| } | |
| /** | |
| * Generate the path data for a circle using familiar SVG `circle` syntax. | |
| * | |
| * @example | |
| * ```tsx | |
| * circlePath({ r: 50 }) | |
| * // "M50,0 A50,50 0 1 0 -50,0 A50,50 0 1 0 50,0 Z" | |
| * ``` | |
| */ | |
| export function circlePath(circle: CircleData, options: PathOptions = {}) { | |
| const { draw = "clockwise" } = options; | |
| const { cx = 0, cy = 0, r } = circle; | |
| if (draw === "anti-clockwise") { | |
| return directives` | |
| M${cx - r},${cy} | |
| A${r},${r} 0 1 1 ${cx + r},${cy} | |
| A${r},${r} 0 1 1 ${cx - r},${cy} | |
| Z | |
| `; | |
| } | |
| return directives` | |
| M${cx + r},${cy} | |
| A${r},${r} 0 1 0 ${cx - r},${cy} | |
| A${r},${r} 0 1 0 ${cx + r},${cy} | |
| Z | |
| `; | |
| } | |
| /** | |
| * Generate the path data for a ellipse using familiar SVG `ellipse` syntax. | |
| * | |
| * @example | |
| * ```tsx | |
| * ellipsePath({ rx: 50, ry: 30 }) | |
| * // "M50,0 A50,50 0 1 0 -50,0 A50,50 0 1 0 50,0 Z" | |
| * ``` | |
| */ | |
| export function ellipsePath(ellipse: EllipseData, options: PathOptions = {}) { | |
| const { draw = "clockwise" } = options; | |
| const { cx = 0, cy = 0, rx, ry } = ellipse; | |
| if (draw === "anti-clockwise") { | |
| return directives` | |
| M${cx - rx},${cy} | |
| A${rx},${ry} 0 1 1 ${cx + rx},${cy} | |
| A${rx},${ry} 0 1 1 ${cx - rx},${cy} | |
| Z | |
| `; | |
| } | |
| return directives` | |
| M${cx + rx},${cy} | |
| A${rx},${ry} 0 1 0 ${cx - rx},${cy} | |
| A${rx},${ry} 0 1 0 ${cx + rx},${cy} | |
| Z | |
| `; | |
| } | |
| // Types ---------------------------------------------------------------------- | |
| type ShapeData = CircleData | EllipseData | RectData; | |
| type CircleData = { | |
| /** | |
| * The x-coordinate of the center of the circle. | |
| * | |
| * @default 0 | |
| */ | |
| cx?: number; | |
| /** | |
| * The y-coordinate of the center of the circle. | |
| * | |
| * @default 0 | |
| */ | |
| cy?: number; | |
| /** The radius of the circle. */ | |
| r: number; | |
| }; | |
| type EllipseData = { | |
| /** | |
| * The x-coordinate of the center of the ellipse. | |
| * | |
| * @default 0 | |
| */ | |
| cx?: number; | |
| /** | |
| * The y-coordinate of the center of the ellipse. | |
| * | |
| * @default 0 | |
| */ | |
| cy?: number; | |
| /** The x-radius of the ellipse. */ | |
| rx: number; | |
| /** The y-radius of the ellipse. */ | |
| ry: number; | |
| }; | |
| type RectData = { | |
| /** | |
| * The x-coordinate of the top-left corner of the rectangle. | |
| * | |
| * @default 0 | |
| */ | |
| x?: number; | |
| /** | |
| * The y-coordinate of the top-left corner of the rectangle. | |
| * | |
| * @default 0 | |
| */ | |
| y?: number; | |
| /** The width of the rectangle. */ | |
| width: number; | |
| /** The height of the rectangle. */ | |
| height: number; | |
| /** Optionally provide a radius for all corners of the rectangle. */ | |
| // NOTE: Use "radius" instead of "r" to allow discriminating between the | |
| // `rect` and `circle` elements. | |
| radius?: number; | |
| }; | |
| type PathOptions = { | |
| /** | |
| * Declare which direction the path should be drawn. | |
| * | |
| * When multiple paths are combined into a single string of path data, this | |
| * will influence whether the paths are additive or subtractive. Alternate | |
| * between directions to get the same effect as "evenodd" clip/fill rules. | |
| * | |
| * @default 'clockwise' | |
| */ | |
| draw?: (typeof DRAW_DIRECTION)[number]; | |
| }; | |
| // Utilities ------------------------------------------------------------------- | |
| /** | |
| * Tagged template for building path strings: | |
| * - fix floating point precision issues | |
| * - clean up whitespace + line breaks | |
| */ | |
| function directives( | |
| strings: TemplateStringsArray, | |
| ...values: unknown[] | |
| ): string { | |
| const out = strings.map((str, i) => { | |
| let v = values[i] ?? ""; | |
| if (typeof v === "number") v = toFixedNumber(v, 2); | |
| return (str + v).trim(); | |
| }); | |
| return out.join(" ").replaceAll(" ,", ","); | |
| } | |
| /* Takes a value and rounds off to the number of digits. */ | |
| function toFixedNumber(value: number, digits: number, base: number = 10) { | |
| const pow = Math.pow(base, digits); | |
| return Math.round(value * pow) / pow; | |
| } | |
| function isCircleData(data: ShapeData): data is CircleData { | |
| return "r" in data; | |
| } | |
| function isEllipseData(data: ShapeData): data is EllipseData { | |
| return "rx" in data && "ry" in data; | |
| } | |
| function isRectData(data: ShapeData): data is RectData { | |
| return "width" in data && "height" in data; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment