Skip to content

Instantly share code, notes, and snippets.

@jossmac
Last active August 20, 2025 06:11
Show Gist options
  • Select an option

  • Save jossmac/aaf776ccee649ce5ee5ed65b8e142efd to your computer and use it in GitHub Desktop.

Select an option

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.
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