/
diloytd
/
rectangle
Обзор
Документация
Войти
/
diloytd
/
rectangle
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/geometry.js
407 строк
15 KB
diloytd
add modal window
09 июл 2026, 11:48
09 июл 2026, 11:48
85687f2
Код
Авторство
О чём код?
import { createId, getIsoTimestamp, toFiniteNumber } from "./utils.js"; export const SPLIT_TYPES = { VERTICAL: "vertical", HORIZONTAL: "horizontal", }; export const RESIZE_EDGE_TYPES = { VERTICAL: "vertical", HORIZONTAL: "horizontal", }; const MIN_RESIZED_RECTANGLE_SIZE = 10; /** * Creates the first external rectangle for a project. * Coordinates and sizes are stored in millimeters. * @param {number} width - External rectangle width in millimeters. * @param {number} height - External rectangle height in millimeters. * @returns {{id: string, x: number, y: number, width: number, height: number, parentId: string | null, createdAt: string}} Root rectangle. */ export const createRootRectangle = (width, height) => ({ id: createId("rect"), x: 0, y: 0, width, height, parentId: null, createdAt: getIsoTimestamp(), }); /** * Validates the initial project dimensions before creating the root rectangle. * @param {string | number} widthInput - Raw width input. * @param {string | number} heightInput - Raw height input. * @returns {{isValid: boolean, width: number, height: number, message: string}} Validation result. */ export const validateRootSize = (widthInput, heightInput) => { const width = toFiniteNumber(widthInput); const height = toFiniteNumber(heightInput); if (!Number.isFinite(width) || !Number.isFinite(height)) { return { isValid: false, width, height, message: "Введите корректные числовые размеры." }; } if (width <= 1 || height <= 1) { return { isValid: false, width, height, message: "Ширина и высота должны быть больше 1." }; } return { isValid: true, width, height, message: "" }; }; /** * Finds a rectangle by id in the current rectangle list. * @param {Array<{id: string}>} rectangles - Current rectangle list. * @param {string | null} id - Rectangle id to find. * @returns {{id: string} | null} Matching rectangle or null. */ export const getRectangleById = (rectangles, id) => { if (!id) { return null; } return rectangles.find((rectangle) => rectangle.id === id) ?? null; }; /** * Returns the measurable side used by a split type. * @param {{width: number, height: number}} rectangle - Selected rectangle. * @param {string} splitType - Split type, either vertical or horizontal. * @returns {number} Width for vertical split, height for horizontal split. */ export const getSplitLimit = (rectangle, splitType) => { if (splitType === SPLIT_TYPES.VERTICAL) { return rectangle.width; } return rectangle.height; }; /** * Validates the requested split distance. * Distance must be strictly inside the selected rectangle, never on an edge. * @param {{width: number, height: number}} rectangle - Selected rectangle. * @param {string} splitType - Split type, either vertical or horizontal. * @param {string | number} distanceInput - Raw distance input in millimeters. * @returns {{isValid: boolean, value: number, message: string}} Validation result. */ export const validateSplitDistance = (rectangle, splitType, distanceInput) => { const value = toFiniteNumber(distanceInput); const limit = getSplitLimit(rectangle, splitType); const axisName = splitType === SPLIT_TYPES.VERTICAL ? "ширины" : "высоты"; if (!Number.isFinite(value)) { return { isValid: false, value, message: "Введите число." }; } if (value <= 0) { return { isValid: false, value, message: "Разрез нельзя поставить на 0 или левее/выше края." }; } if (value >= limit) { return { isValid: false, value, message: `Разрез должен быть меньше ${axisName} выбранного прямоугольника (${limit}).`, }; } return { isValid: true, value, message: "" }; }; /** * Splits a rectangle into two child rectangles. * The original rectangle is not mutated and should be replaced by the caller. * @param {{id: string, x: number, y: number, width: number, height: number}} rectangle - Rectangle to split. * @param {string} splitType - Split type, either vertical or horizontal. * @param {number} distance - Distance from left or top edge in millimeters. * @returns {Array<{id: string, x: number, y: number, width: number, height: number, parentId: string, createdAt: string}>} Two new child rectangles. */ export const splitRectangle = (rectangle, splitType, distance) => { const createdAt = getIsoTimestamp(); if (splitType === SPLIT_TYPES.VERTICAL) { return [ { id: createId("rect"), x: rectangle.x, y: rectangle.y, width: distance, height: rectangle.height, parentId: rectangle.id, createdAt, }, { id: createId("rect"), x: rectangle.x + distance, y: rectangle.y, width: rectangle.width - distance, height: rectangle.height, parentId: rectangle.id, createdAt, }, ]; } return [ { id: createId("rect"), x: rectangle.x, y: rectangle.y, width: rectangle.width, height: distance, parentId: rectangle.id, createdAt, }, { id: createId("rect"), x: rectangle.x, y: rectangle.y + distance, width: rectangle.width, height: rectangle.height - distance, parentId: rectangle.id, createdAt, }, ]; }; /** * Replaces one rectangle with its split children while preserving all others. * @param {Array<{id: string}>} rectangles - Current rectangle list. * @param {string} sourceId - Rectangle id to remove. * @param {Array<{id: string}>} children - New rectangles to insert. * @returns {Array<{id: string}>} Updated rectangle list. */ export const replaceRectangleWithChildren = (rectangles, sourceId, children) => rectangles.flatMap((rectangle) => { if (rectangle.id !== sourceId) { return [rectangle]; } return children; }); /** * Checks whether two one-dimensional ranges have a visible overlap. * Used to detect shared rectangle borders without treating corner touches as draggable edges. * @param {number} firstStart - Start coordinate of the first range. * @param {number} firstEnd - End coordinate of the first range. * @param {number} secondStart - Start coordinate of the second range. * @param {number} secondEnd - End coordinate of the second range. * @returns {boolean} True when the ranges overlap by more than a point. */ const rangesOverlap = (firstStart, firstEnd, secondStart, secondEnd) => Math.max(firstStart, secondStart) < Math.min(firstEnd, secondEnd); /** * Creates raw resize segments for every pair of rectangles sharing a vertical border. * Several segments on the same coordinate are merged later into one draggable handle. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} rectangles - Current rectangle list. * @returns {Array<{type: string, coordinate: number, start: number, end: number, beforeIds: Set<string>, afterIds: Set<string>}>} Raw vertical edge segments. */ const createVerticalResizeSegments = (rectangles) => { const segments = []; rectangles.forEach((leftRectangle) => { rectangles.forEach((rightRectangle) => { const sharedX = leftRectangle.x + leftRectangle.width; if (leftRectangle.id === rightRectangle.id || sharedX !== rightRectangle.x) { return; } if (!rangesOverlap(leftRectangle.y, leftRectangle.y + leftRectangle.height, rightRectangle.y, rightRectangle.y + rightRectangle.height)) { return; } segments.push({ type: RESIZE_EDGE_TYPES.VERTICAL, coordinate: sharedX, start: Math.max(leftRectangle.y, rightRectangle.y), end: Math.min(leftRectangle.y + leftRectangle.height, rightRectangle.y + rightRectangle.height), beforeIds: new Set([leftRectangle.id]), afterIds: new Set([rightRectangle.id]), }); }); }); return segments; }; /** * Creates raw resize segments for every pair of rectangles sharing a horizontal border. * Top rectangles are stored in beforeIds, bottom rectangles are stored in afterIds. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} rectangles - Current rectangle list. * @returns {Array<{type: string, coordinate: number, start: number, end: number, beforeIds: Set<string>, afterIds: Set<string>}>} Raw horizontal edge segments. */ const createHorizontalResizeSegments = (rectangles) => { const segments = []; rectangles.forEach((topRectangle) => { rectangles.forEach((bottomRectangle) => { const sharedY = topRectangle.y + topRectangle.height; if (topRectangle.id === bottomRectangle.id || sharedY !== bottomRectangle.y) { return; } if (!rangesOverlap(topRectangle.x, topRectangle.x + topRectangle.width, bottomRectangle.x, bottomRectangle.x + bottomRectangle.width)) { return; } segments.push({ type: RESIZE_EDGE_TYPES.HORIZONTAL, coordinate: sharedY, start: Math.max(topRectangle.x, bottomRectangle.x), end: Math.min(topRectangle.x + topRectangle.width, bottomRectangle.x + bottomRectangle.width), beforeIds: new Set([topRectangle.id]), afterIds: new Set([bottomRectangle.id]), }); }); }); return segments; }; /** * Merges touching resize segments on the same coordinate into stable draggable edges. * This keeps split lines moving as one continuous border even when one side contains multiple rectangles. * @param {Array<{type: string, coordinate: number, start: number, end: number, beforeIds: Set<string>, afterIds: Set<string>}>} segments - Raw border segments. * @returns {Array<{id: string, type: string, coordinate: number, start: number, end: number, beforeIds: Array<string>, afterIds: Array<string>}>} Merged resize edges. */ const mergeResizeSegments = (segments) => { const groupedSegments = segments.reduce((groups, segment) => { const key = `${segment.type}:${segment.coordinate}`; const group = groups.get(key) ?? []; group.push(segment); groups.set(key, group); return groups; }, new Map()); return Array.from(groupedSegments.values()).flatMap((group) => { const sortedGroup = [...group].sort((first, second) => first.start - second.start); const mergedEdges = []; sortedGroup.forEach((segment) => { const currentEdge = mergedEdges.at(-1); if (!currentEdge || segment.start > currentEdge.end) { mergedEdges.push({ type: segment.type, coordinate: segment.coordinate, start: segment.start, end: segment.end, beforeIds: new Set(segment.beforeIds), afterIds: new Set(segment.afterIds), }); return; } currentEdge.end = Math.max(currentEdge.end, segment.end); segment.beforeIds.forEach((id) => currentEdge.beforeIds.add(id)); segment.afterIds.forEach((id) => currentEdge.afterIds.add(id)); }); return mergedEdges.map((edge) => ({ id: `${edge.type}-${edge.coordinate}-${edge.start}-${edge.end}`, type: edge.type, coordinate: edge.coordinate, start: edge.start, end: edge.end, beforeIds: [...edge.beforeIds], afterIds: [...edge.afterIds], })); }); }; /** * Finds all internal rectangle borders that can be dragged to resize adjacent rectangles. * External borders are intentionally ignored so the total project size remains stable. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} rectangles - Current rectangle list. * @returns {Array<{id: string, type: string, coordinate: number, start: number, end: number, beforeIds: Array<string>, afterIds: Array<string>}>} Draggable internal edges. */ export const findResizableEdges = (rectangles) => mergeResizeSegments([...createVerticalResizeSegments(rectangles), ...createHorizontalResizeSegments(rectangles)]); /** * Calculates the min and max coordinate for moving a resize edge without collapsing rectangles. * The minimum size guard prevents negative or unreadably small rectangles during drag. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} rectangles - Source rectangle list. * @param {{type: string, beforeIds: Array<string>, afterIds: Array<string>}} edge - Dragged edge descriptor. * @returns {{min: number, max: number}} Inclusive coordinate bounds. */ export const getResizeCoordinateBounds = (rectangles, edge) => { const beforeRectangles = rectangles.filter((rectangle) => edge.beforeIds.includes(rectangle.id)); const afterRectangles = rectangles.filter((rectangle) => edge.afterIds.includes(rectangle.id)); if (edge.type === RESIZE_EDGE_TYPES.VERTICAL) { return { min: Math.max(...beforeRectangles.map((rectangle) => rectangle.x + MIN_RESIZED_RECTANGLE_SIZE)), max: Math.min(...afterRectangles.map((rectangle) => rectangle.x + rectangle.width - MIN_RESIZED_RECTANGLE_SIZE)), }; } return { min: Math.max(...beforeRectangles.map((rectangle) => rectangle.y + MIN_RESIZED_RECTANGLE_SIZE)), max: Math.min(...afterRectangles.map((rectangle) => rectangle.y + rectangle.height - MIN_RESIZED_RECTANGLE_SIZE)), }; }; /** * Moves one internal resize edge and returns updated rectangle geometry. * Rectangles on the before side grow toward the new coordinate; rectangles on the after side move and shrink from it. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} rectangles - Source rectangle list. * @param {{type: string, beforeIds: Array<string>, afterIds: Array<string>}} edge - Dragged edge descriptor. * @param {number} coordinate - Requested edge coordinate in millimeters. * @returns {Array<{id: string, x: number, y: number, width: number, height: number}>} Updated rectangle list. */ export const resizeRectanglesByEdge = (rectangles, edge, coordinate) => { const { min, max } = getResizeCoordinateBounds(rectangles, edge); if (min > max) { return rectangles; } const nextCoordinate = Math.max(min, Math.min(max, coordinate)); return rectangles.map((rectangle) => { if (edge.type === RESIZE_EDGE_TYPES.VERTICAL && edge.beforeIds.includes(rectangle.id)) { return { ...rectangle, width: nextCoordinate - rectangle.x, }; } if (edge.type === RESIZE_EDGE_TYPES.VERTICAL && edge.afterIds.includes(rectangle.id)) { const right = rectangle.x + rectangle.width; return { ...rectangle, x: nextCoordinate, width: right - nextCoordinate, }; } if (edge.type === RESIZE_EDGE_TYPES.HORIZONTAL && edge.beforeIds.includes(rectangle.id)) { return { ...rectangle, height: nextCoordinate - rectangle.y, }; } if (edge.type === RESIZE_EDGE_TYPES.HORIZONTAL && edge.afterIds.includes(rectangle.id)) { const bottom = rectangle.y + rectangle.height; return { ...rectangle, y: nextCoordinate, height: bottom - nextCoordinate, }; } return rectangle; }); };