diff --git a/docs/.vitepress/api/gh-icon/[...data].get.ts b/docs/.vitepress/api/gh-icon/[...data].get.ts index def248303..e19a2f839 100644 --- a/docs/.vitepress/api/gh-icon/[...data].get.ts +++ b/docs/.vitepress/api/gh-icon/[...data].get.ts @@ -13,10 +13,10 @@ export default eventHandler((event) => { const data = pathData.at(-1).slice(0, -4); const [name] = pathData; - const src = Buffer.from(data, 'base64') - .toString('utf8') - .replaceAll('\n', '') - .replace(/]*>|<\/svg>/g, ''); + const src = Buffer.from(data, 'base64').toString('utf8').replaceAll('\n', ''); + + const width = parseInt((src.includes('svg') ? src.match(/width="(\d+)"/)?.[1] : null) ?? '24'); + const height = parseInt((src.includes('svg') ? src.match(/height="(\d+)"/)?.[1] : null) ?? '24'); const children = []; @@ -38,7 +38,7 @@ export default eventHandler((event) => { children.push( createElement(Backdrop, { backdropString, - src, + src: src.replace(/]*>|<\/svg>/g, ''), color: '#777', }), ); @@ -46,7 +46,18 @@ export default eventHandler((event) => { const svg = Buffer.from( // We can't use jsx here, is not supported here by nitro. - renderToString(createElement(SvgPreview, { src, showGrid: true }, children)), + renderToString( + createElement( + SvgPreview, + { + src: src.replace(/]*>|<\/svg>/g, ''), + height, + width, + showGrid: true, + }, + children, + ), + ), ).toString('utf8'); defaultContentType(event, 'image/svg+xml'); diff --git a/docs/.vitepress/lib/SvgPreview/GapViolationHighlight.tsx b/docs/.vitepress/lib/SvgPreview/GapViolationHighlight.tsx new file mode 100644 index 000000000..e12f96608 --- /dev/null +++ b/docs/.vitepress/lib/SvgPreview/GapViolationHighlight.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import pathToPoints from './path-to-points'; +import { Path, PathProps } from './types'; + +export const GapViolationHighlight = ({ + radius, + stroke, + strokeWidth, + strokeOpacity, + paths, + ...props +}: { + paths: Path[]; +} & PathProps<'stroke' | 'strokeOpacity' | 'strokeWidth', 'd'>) => { + const id = React.useId(); + + const groupedPaths = Object.entries( + paths.reduce( + (groups, val) => { + const key = val.c.id; + groups[key] = [...(groups[key] || []), val]; + return groups; + }, + {} as Record, + ), + ); + + const groups: Group[] = []; + + for (const [, paths] of groupedPaths) { + const d = paths.map((path) => path.d).join(' '); + const points = paths.flatMap((path) => pathToPoints(path)); + groups.push({ id: d, points }); + } + + const mergedGroups = mergeGroups(groups, 2); + + return ( + + + + + + + + {mergedGroups.flatMap((ds, idx, arr) => + arr.slice(0, idx).map((val, i) => ( + + + + + + + )), + )} + + ); +}; + +type Point = { x: number; y: number }; +type Group = { id: string; points: Point[] }; + +// Euclidean distance +function distance(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +// Check if two groups should be merged based on minimum distance +function shouldMerge(a: Group, b: Group, minDistance: number): boolean { + for (const pa of a.points) { + for (const pb of b.points) { + if (distance(pa, pb) <= minDistance) { + return true; + } + } + } + return false; +} + +// Merge groups and return arrays of merged group IDs +function mergeGroups(groups: Group[], minDistance: number): string[][] { + const mergedGroups: Group[][] = groups.map((g) => [g]); + + let changed = true; + while (changed) { + changed = false; + + outer: for (let i = 0; i < mergedGroups.length; i++) { + for (let j = i + 1; j < mergedGroups.length; j++) { + // Check if any group in mergedGroups[i] should merge with any in mergedGroups[j] + if ( + mergedGroups[i].some((ga) => + mergedGroups[j].some((gb) => shouldMerge(ga, gb, minDistance)), + ) + ) { + // Merge group j into group i + mergedGroups[i] = [...mergedGroups[i], ...mergedGroups[j]]; + mergedGroups.splice(j, 1); + changed = true; + break outer; + } + } + } + } + + // Return only arrays of IDs + return mergedGroups.map((groupList) => groupList.map((g) => g.id)); +} diff --git a/docs/.vitepress/lib/SvgPreview/index.tsx b/docs/.vitepress/lib/SvgPreview/index.tsx index 562020a8e..a09b88663 100644 --- a/docs/.vitepress/lib/SvgPreview/index.tsx +++ b/docs/.vitepress/lib/SvgPreview/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { PathProps, Path } from './types'; -import { getPaths, assert } from './utils'; +import getPaths, { assert } from './utils'; +import { GapViolationHighlight } from './GapViolationHighlight.tsx'; export const darkModeCss = ` @media screen and (prefers-color-scheme: light) { @@ -20,10 +21,16 @@ export const darkModeCss = ` export const Grid = ({ radius, - fill = '#fff', + fill, + height, + width, + subGridSize = 0, ...props }: { + height: number; + width: number; strokeWidth: number; + subGridSize?: number; radius: number; } & PathProps<'stroke', 'strokeWidth'>) => ( i) - .filter((i) => i % 3 !== 2) - .flatMap((i) => [ - `M${props.strokeWidth} ${i + 1}h${24 - props.strokeWidth * 2}`, - `M${i + 1} ${props.strokeWidth}v${24 - props.strokeWidth * 2}`, - ]) - .join('') - } - /> - i) - .filter((i) => i % 3 === 2) - .flatMap((i) => [ - `M${props.strokeWidth} ${i + 1}h${24 - props.strokeWidth * 2}`, - `M${i + 1} ${props.strokeWidth}v${24 - props.strokeWidth * 2}`, - ]) - .join('') + [ + ...new Array(Math.floor(width - 1)) + .fill(null) + .map((_, i) => i) + .filter((i) => !subGridSize || i % subGridSize !== subGridSize - 1) + .flatMap((i) => [`M${i + 1} ${props.strokeWidth}v${height - props.strokeWidth * 2}`]), + ...new Array(Math.floor(height - 1)) + .fill(null) + .map((_, i) => i) + .filter((i) => !subGridSize || i % subGridSize !== subGridSize - 1) + .flatMap((i) => [`M${props.strokeWidth} ${i + 1}h${width - props.strokeWidth * 2}`]), + ].join('') } /> + {!!subGridSize && ( + i) + .filter((i) => i % subGridSize === subGridSize - 1) + .flatMap((i) => [`M${i + 1} ${props.strokeWidth}v${height - props.strokeWidth * 2}`]), + ...new Array(Math.floor(height - 1)) + .fill(null) + .map((_, i) => i) + .filter((i) => i % subGridSize === subGridSize - 1) + .flatMap((i) => [`M${props.strokeWidth} ${i + 1}h${width - props.strokeWidth * 2}`]), + ].join('') + } + /> + )} ); @@ -99,6 +116,7 @@ const Shadow = ({ > {groupedPaths.map(([id, paths]) => ( ) => ( - - {paths.map(({ d, c }, i) => ( - - ))} - -); +}: { paths: Path[]; colors: string[] } & PathProps) => { + let idx = 0; + return ( + + {paths.map(({ d, c }, i) => ( + + ))} + + ); +}; const ControlPath = ({ paths, radius, pointSize, ...props -}: { pointSize: number; paths: Path[]; radius: number } & PathProps< - 'stroke' | 'strokeWidth', - 'd' ->) => { +}: { + pointSize: number; + paths: Path[]; + radius: number; +} & PathProps<'stroke' | 'strokeWidth', 'd'>) => { const controlPaths = paths.map((path, i) => { const element = paths.filter((p) => p.c.id === path.c.id); const lastElement = element.at(-1)?.next; @@ -207,8 +229,8 @@ const ControlPath = ({ - {controlPaths.map(({ d, prev, next, startMarker, endMarker }, i) => ( + {controlPaths.map(({ prev, next, startMarker, endMarker }, i) => ( {startMarker && ( {paths.map( - ({ c, prev, next, circle }, i) => + ({ circle, next, prev, c }, i) => circle && ( - {c.name !== 'circle' && ( - + {circle.tangentIntersection && c.name === 'path' && ( + <> + + + + + + )} + {c.name === 'path' && ( + )} ) => { - return ( - - {paths.map(({ c, prev, next, cp1, cp2 }) => ( - <> - {cp1 && } - {cp1 && ( - - )} - {cp2 && } - {cp2 && ( - - )} - - ))} - - ); -}; +}: { paths: Path[] } & PathProps<'strokeWidth' | 'stroke' | 'strokeOpacity', any>) => ( + + {paths.map(({ c, prev, next, cp1, cp2 }, i) => ( + + {cp1 && } + {cp1 && ( + + )} + {cp2 && } + {cp2 && ( + + )} + + ))} + +); const SvgPreview = React.forwardRef< SVGSVGElement, { + height?: number; + width?: number; src: string | ReturnType; showGrid?: boolean; } & React.SVGProps ->(({ src, children, showGrid = false, ...props }, ref) => { +>(({ src, children, height = 24, width = 24, showGrid = false, ...props }, ref) => { + const subGridSize = + Math.max(height, width) % 3 === 0 + ? Math.max(height, width) > 24 + ? 12 + : 3 + : Math.max(height, width) % 5 === 0 + ? 5 + : 0; const paths = typeof src === 'string' ? getPaths(src) : src; return ( {darkModeCss} {showGrid && ( @@ -385,6 +442,12 @@ const SvgPreview = React.forwardRef< radius={1} strokeOpacity={0.15} /> + { return []; }; -export const getNodes = (src: string) => - extractNodes(parseSync(src.includes('${src}`)); +export const getNodes = memoize((src: string) => + extractNodes(parseSync(src.includes('${src}`)), +); export const getCommands = (src: string) => getNodes(src) .map(convertToPathNode) .flatMap(({ d, name }, idx) => - new SVGPathData(d).toAbs().commands.map((c, cIdx) => ({ ...c, id: idx, idx: cIdx, name })), + new SVGPathData(d) + .toAbs() + // @ts-ignore + .commands.map((c, cIdx) => ({ ...c, id: idx, idx: cIdx, name })), ); -export const getPaths = (src: string) => { +const getPaths = (src: string) => { const commands = getCommands(src.includes('${src}`); const paths: Path[] = []; let prev: Point | undefined = undefined; @@ -237,6 +244,7 @@ export const getPaths = (src: string) => { break; } default: { + // @ts-ignore assertNever(c); } } @@ -244,7 +252,7 @@ export const getPaths = (src: string) => { return paths; }; -export const arcEllipseCenter = ( +const arcEllipseCenter = ( x1: number, y1: number, rx: number, @@ -296,5 +304,52 @@ export const arcEllipseCenter = ( M2[1][0] * Cp[0] + M2[1][1] * Cp[1] + V3[1], ]; - return { x: C[0], y: C[1] }; + return { + x: C[0], + y: C[1], + tangentIntersection: intersectTangents( + { x: x1, y: y1 }, + { x: x2, y: y2 }, + { x: C[0], y: C[1] }, + ), + }; }; + +function getTangentDirection(p: Point, center: Point): Point { + // Tangent is perpendicular to the radius vector (rotate radius 90°) + const dx = p.x - center.x; + const dy = p.y - center.y; + return { x: -dy, y: dx }; // 90° rotation +} + +function intersectTangents(start: Point, end: Point, center: Point): Point | null { + const t1 = getTangentDirection(start, center); + const t2 = getTangentDirection(end, center); + + // Solve: start + λ * t1 = end + μ * t2 + const A = [ + [t1.x, -t2.x], + [t1.y, -t2.y], + ]; + const b = [end.x - start.x, end.y - start.y]; + + // Compute determinant + const det = A[0][0] * A[1][1] - A[0][1] * A[1][0]; + + if (Math.abs(det) < 1e-10) { + // Lines are parallel, no intersection + return null; + } + + const invDet = 1 / det; + + const lambda = (b[0] * A[1][1] - b[1] * A[0][1]) * invDet; + + // Intersection point = start + lambda * t1 + return { + x: start.x + lambda * t1.x, + y: start.y + lambda * t1.y, + }; +} + +export default memoize(getPaths); diff --git a/docs/package.json b/docs/package.json index d572c630b..106435771 100644 --- a/docs/package.json +++ b/docs/package.json @@ -33,6 +33,7 @@ "h3": "^1.8.0", "nitropack": "2.8.1", "rollup-plugin-copy": "^3.4.0", + "svg-path-commander": "^2.1.11", "vitepress": "^1.3.1" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efb0d90d5..53253d92b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: rollup-plugin-copy: specifier: ^3.4.0 version: 3.5.0 + svg-path-commander: + specifier: ^2.1.11 + version: 2.1.11 vitepress: specifier: ^1.3.1 version: 1.6.3(@algolia/client-search@5.25.0)(@types/node@22.15.30)(@types/react@18.3.21)(axios@1.7.4)(fuse.js@6.6.2)(less@4.2.0)(postcss@8.5.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)(search-insights@2.8.2)(stylus@0.56.0)(terser@5.39.2)(typescript@5.8.3) @@ -5152,6 +5155,10 @@ packages: '@vue/compiler-sfc': optional: true + '@thednp/dommatrix@2.0.12': + resolution: {integrity: sha512-eOshhlSShBXLfrMQqqhA450TppJXhKriaQdN43mmniOCMn9sD60QKF1Axsj7bKl339WH058LuGFS6H84njYH5w==} + engines: {node: '>=20', pnpm: '>=8.6.0'} + '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} @@ -12213,6 +12220,10 @@ packages: resolution: {integrity: sha512-QIYtKnJGkubWXtNkrUBKVCvyo9gjcccdbnvXfwsGNhvbeNNdQjRDTa/BiQcJ2kWXbXPQbWKyT7CUu53KIj1rfw==} engines: {node: '>=18'} + svg-path-commander@2.1.11: + resolution: {integrity: sha512-wmQ6QA3Od+HOcpIzLjPlbv59+x3yd3V5W6xitUOvAHmqZpP7wVrRM2CHqEm5viHUbZu6PjzFsjbTEFtIeUxaNA==} + engines: {node: '>=16', pnpm: '>=8.6.0'} + svg-pathdata@6.0.3: resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} engines: {node: '>=12.0.0'} @@ -18275,6 +18286,8 @@ snapshots: optionalDependencies: '@vue/compiler-sfc': 3.5.14 + '@thednp/dommatrix@2.0.12': {} + '@tokenizer/token@0.3.0': {} '@tootallnate/once@1.1.2': {} @@ -27273,6 +27286,10 @@ snapshots: magic-string: 0.30.17 zimmerframe: 1.1.2 + svg-path-commander@2.1.11: + dependencies: + '@thednp/dommatrix': 2.0.12 + svg-pathdata@6.0.3: {} svg-pathdata@7.2.0: {}