type PuzzleMergedGroupShape = { colSpan: number; rowSpan: number; pieces: Array<{ localRow: number; localCol: number; }>; }; type GridPoint = { x: number; y: number; }; type GridEdge = { start: GridPoint; end: GridPoint; }; const MERGED_GROUP_OUTLINE_CORNER_RADIUS = 0.16; const MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR = 1.2; function buildLocalCellKey(row: number, col: number) { return `${row}:${col}`; } function formatSvgNumber(value: number) { const normalizedValue = Object.is(value, -0) ? 0 : value; return Number(normalizedValue.toFixed(4)).toString(); } function formatSvgPoint(point: GridPoint) { return `${formatSvgNumber(point.x)} ${formatSvgNumber(point.y)}`; } function gridPointKey(point: GridPoint) { return `${formatSvgNumber(point.x)}:${formatSvgNumber(point.y)}`; } function distanceBetweenGridPoints(first: GridPoint, second: GridPoint) { return Math.hypot(second.x - first.x, second.y - first.y); } function moveGridPointToward( from: GridPoint, target: GridPoint, distance: number, ) { const fullDistance = distanceBetweenGridPoints(from, target); if (fullDistance <= 0) { return from; } const ratio = Math.min(1, distance / fullDistance); return { x: from.x + (target.x - from.x) * ratio, y: from.y + (target.y - from.y) * ratio, }; } function isCollinearGridCorner( previous: GridPoint, current: GridPoint, next: GridPoint, ) { return ( (previous.x === current.x && current.x === next.x) || (previous.y === current.y && current.y === next.y) ); } function removeCollinearGridPoints(points: GridPoint[]) { if (points.length <= 3) { return points; } return points.filter((point, index) => { const previous = points[(index - 1 + points.length) % points.length]; const next = points[(index + 1) % points.length]; return previous && next && !isCollinearGridCorner(previous, point, next); }); } function computePolygonSignedArea(points: GridPoint[]) { let area = 0; for (let index = 0; index < points.length; index += 1) { const current = points[index]; const next = points[(index + 1) % points.length]; if (!current || !next) { continue; } area += current.x * next.y - next.x * current.y; } return area / 2; } type CornerRadiusResolver = (corner: { point: GridPoint; previous: GridPoint; next: GridPoint; isConvex: boolean; radius: number; }) => number; function buildRoundedGridCyclePath( points: GridPoint[], radius: number, transformPoint: (point: GridPoint) => GridPoint = (point) => point, resolveCornerRadius?: CornerRadiusResolver, ) { const cyclePoints = removeCollinearGridPoints(points); if (cyclePoints.length < 3) { return ''; } const polygonOrientation = computePolygonSignedArea(cyclePoints) >= 0 ? 1 : -1; const resolveCorner = (index: number) => { const point = cyclePoints[index]; const previous = cyclePoints[ (index - 1 + cyclePoints.length) % cyclePoints.length ]; const next = cyclePoints[(index + 1) % cyclePoints.length]; if (!point || !previous || !next) { return null; } const previousVectorX = point.x - previous.x; const previousVectorY = point.y - previous.y; const nextVectorX = next.x - point.x; const nextVectorY = next.y - point.y; const turnCross = previousVectorX * nextVectorY - previousVectorY * nextVectorX; const isConvex = turnCross * polygonOrientation > 0; const resolvedRadius = Math.max( 0, resolveCornerRadius?.({ point, previous, next, isConvex, radius, }) ?? radius, ); const safeRadius = Math.min( resolvedRadius, distanceBetweenGridPoints(point, previous) / 2, distanceBetweenGridPoints(point, next) / 2, ); return { point, entry: moveGridPointToward(point, previous, safeRadius), exit: moveGridPointToward(point, next, safeRadius), }; }; const firstCorner = resolveCorner(0); if (!firstCorner) { return ''; } const commands = [`M ${formatSvgPoint(transformPoint(firstCorner.exit))}`]; for (let index = 1; index <= cyclePoints.length; index += 1) { const corner = resolveCorner(index % cyclePoints.length); if (!corner) { continue; } commands.push(`L ${formatSvgPoint(transformPoint(corner.entry))}`); commands.push( `Q ${formatSvgPoint(transformPoint(corner.point))} ${formatSvgPoint( transformPoint(corner.exit), )}`, ); } commands.push('Z'); return commands.join(' '); } function buildMergedGroupBoundaryCycles(group: PuzzleMergedGroupShape) { const groupCellKeys = new Set( group.pieces.map((piece) => buildLocalCellKey(piece.localRow, piece.localCol), ), ); const hasCell = (row: number, col: number) => groupCellKeys.has(buildLocalCellKey(row, col)); const edges: GridEdge[] = []; for (const piece of group.pieces) { const { localRow: row, localCol: col } = piece; if (!hasCell(row - 1, col)) { edges.push({ start: { x: col, y: row }, end: { x: col + 1, y: row } }); } if (!hasCell(row, col + 1)) { edges.push({ start: { x: col + 1, y: row }, end: { x: col + 1, y: row + 1 }, }); } if (!hasCell(row + 1, col)) { edges.push({ start: { x: col + 1, y: row + 1 }, end: { x: col, y: row + 1 }, }); } if (!hasCell(row, col - 1)) { edges.push({ start: { x: col, y: row + 1 }, end: { x: col, y: row } }); } } const edgeIndexesByStart = new Map(); edges.forEach((edge, index) => { const key = gridPointKey(edge.start); const indexes = edgeIndexesByStart.get(key) ?? []; indexes.push(index); edgeIndexesByStart.set(key, indexes); }); const unusedEdgeIndexes = new Set(edges.map((_, index) => index)); const cycles: GridPoint[][] = []; while (unusedEdgeIndexes.size > 0) { const firstEdgeIndex = unusedEdgeIndexes.values().next().value as | number | undefined; if (firstEdgeIndex === undefined) { break; } const firstEdge = edges[firstEdgeIndex]; if (!firstEdge) { unusedEdgeIndexes.delete(firstEdgeIndex); continue; } const cycle: GridPoint[] = [firstEdge.start]; let currentEdge = firstEdge; unusedEdgeIndexes.delete(firstEdgeIndex); for (let guard = 0; guard < edges.length + 1; guard += 1) { const currentEnd = currentEdge.end; const cycleStart = cycle[0]; if (!cycleStart || gridPointKey(currentEnd) === gridPointKey(cycleStart)) { break; } cycle.push(currentEnd); const nextEdgeIndex = ( edgeIndexesByStart.get(gridPointKey(currentEnd)) ?? [] ).find((index) => unusedEdgeIndexes.has(index)); if (nextEdgeIndex === undefined) { break; } const nextEdge = edges[nextEdgeIndex]; if (!nextEdge) { unusedEdgeIndexes.delete(nextEdgeIndex); break; } currentEdge = nextEdge; unusedEdgeIndexes.delete(nextEdgeIndex); } if (cycle.length >= 3) { cycles.push(cycle); } } return cycles; } export function buildRoundedGridCellClipPath( radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS, ) { return buildRoundedGridCyclePath([ { x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }, { x: 0, y: 1 }, ], radius); } export function buildMergedGroupOutlinePath( group: PuzzleMergedGroupShape, radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS, ) { // 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。 return buildMergedGroupBoundaryCycles(group) .map((cycle) => buildRoundedGridCyclePath( cycle, radius, (corner) => corner, ({ isConvex }) => isConvex ? radius : radius * MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR, ), ) .filter(Boolean) .join(' '); } export function buildMergedGroupClipPath( group: PuzzleMergedGroupShape, radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS, ) { return buildMergedGroupBoundaryCycles(group) .map((cycle) => buildRoundedGridCyclePath( cycle, radius, (point) => ({ x: point.x / group.colSpan, y: point.y / group.rowSpan, }), ({ isConvex }) => isConvex ? radius : radius * MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR, ), ) .filter(Boolean) .join(' '); } export function sanitizeSvgId(value: string) { return value.replace(/[^a-zA-Z0-9_-]/g, '-'); } export function resolveDraggedPieceCellLayer( pieceId: string | null | undefined, draggingPieceId: string | null, isMerged: boolean, ) { if (!pieceId || isMerged || pieceId !== draggingPieceId) { return undefined; } return 80; } export function resolveDraggedPieceLayer( pieceId: string | null | undefined, draggingPieceId: string | null, isMerged: boolean, ) { if (!pieceId || isMerged || pieceId !== draggingPieceId) { return undefined; } return 81; } export function resolveDraggedMergedGroupLayer( groupId: string, draggingGroupId: string | null, ) { return groupId === draggingGroupId ? 90 : undefined; }