277 lines
7.3 KiB
TypeScript
277 lines
7.3 KiB
TypeScript
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;
|
|
|
|
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 buildRoundedGridCyclePath(
|
|
points: GridPoint[],
|
|
radius: number,
|
|
transformPoint: (point: GridPoint) => GridPoint = (point) => point,
|
|
) {
|
|
const cyclePoints = removeCollinearGridPoints(points);
|
|
if (cyclePoints.length < 3) {
|
|
return '';
|
|
}
|
|
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 safeRadius = Math.min(
|
|
radius,
|
|
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<string, number[]>();
|
|
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 buildMergedGroupOutlinePath(
|
|
group: PuzzleMergedGroupShape,
|
|
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
|
|
) {
|
|
// 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。
|
|
return buildMergedGroupBoundaryCycles(group)
|
|
.map((cycle) => buildRoundedGridCyclePath(cycle, radius))
|
|
.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,
|
|
})),
|
|
)
|
|
.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;
|
|
}
|