"use strict"; /** Own logical diagram geometry, bounds, hit-testing and endpoint projection. */ export class CmapGeometryController { constructor(editor) { this.editor = editor; } get items() { return this.editor.items; } get connectors() { return this.editor.connectors; } get zoomFactor() { return this.editor.zoomFactor; } logicalCanvasWidth() { const surface = this.editor.surfaceElement(); return Math.max(this.editor.canvas.clientWidth / this.zoomFactor, surface ? surface.scrollWidth : 0); } logicalCanvasHeight() { const surface = this.editor.surfaceElement(); return Math.max(this.editor.canvas.clientHeight / this.zoomFactor, surface ? surface.scrollHeight : 0); } updateSubmapDepth(record, depth) { record.submapDepth = depth; for (const child of this.items.filter((item) => item.parentSubmap === record)) { this.updateSubmapDepth(child, depth + 1); } } pointInBounds(point, bounds) { return Boolean(bounds && point.x >= bounds.left && point.x <= bounds.right && point.y >= bounds.top && point.y <= bounds.bottom); } pointNearConnector(point, tolerance = 8 / this.zoomFactor) { const distanceToSegment = (start, end) => { const segmentX = end.x - start.x; const segmentY = end.y - start.y; const lengthSquared = (segmentX * segmentX) + (segmentY * segmentY); if (lengthSquared === 0) return Math.hypot(point.x - start.x, point.y - start.y); const projection = Math.max(0, Math.min(1, (((point.x - start.x) * segmentX) + ((point.y - start.y) * segmentY)) / lengthSquared)); const nearestX = start.x + (projection * segmentX); const nearestY = start.y + (projection * segmentY); return Math.hypot(point.x - nearestX, point.y - nearestY); }; return this.connectors.some((connector) => { const source = connector.visualSource || this.editor.connectorEndpoint(connector.source); const target = connector.visualTarget || this.editor.connectorEndpoint(connector.target); return source && target && source !== target && distanceToSegment(this.itemCenter(source), this.itemCenter(target)) <= tolerance; }); } submapAtPoint(point, excludedRecord = null, excludedRecords = []) { const exclusions = new Set(excludedRecords); const candidates = this.items .filter((item) => item.kind === "submap" && item.expanded && item !== excludedRecord && !exclusions.has(item) && (!excludedRecord || !this.editor.isDescendantOf(item, excludedRecord)) && !excludedRecords.some((record) => this.editor.isDescendantOf(item, record)) && this.editor.isItemVisible(item)) .sort((a, b) => b.submapDepth - a.submapDepth); const match = candidates.find((item) => this.pointInBounds(point, this.submapBounds(item, excludedRecord))); return match || (this.editor.activeMapRoot && this.editor.activeMapRoot !== excludedRecord && !exclusions.has(this.editor.activeMapRoot) ? this.editor.activeMapRoot : null); } submapBounds(record, excludedRecord = null) { const visibleItems = this.items.filter((item) => this.editor.isDescendantOf(item, record) && item !== excludedRecord && (!excludedRecord || !this.editor.isDescendantOf(item, excludedRecord)) && this.editor.isItemVisible(item)); if (visibleItems.length === 0) return null; return { left: Math.min(...visibleItems.map((item) => Number(item.node.attr("x")))) - 34, top: Math.min(...visibleItems.map((item) => Number(item.node.attr("y")))) - 42, right: Math.max(...visibleItems.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 34, bottom: Math.max(...visibleItems.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 34 }; } visualEndpointFor(record) { if (this.editor.isItemVisible(record)) return record; if (record.kind === "phrase" || this.editor.activeMapRoot) return null; let parent = record.parentSubmap; while (parent) { if (this.editor.isItemVisible(parent)) return parent; parent = parent.parentSubmap; } return null; } isEffectiveItemVisible(record) { if (!this.editor.isItemVisible(record)) return false; if (record.kind !== "phrase") return true; const neighbours = this.connectors .filter((connector) => connector.source === record || connector.target === record) .map((connector) => connector.source === record ? connector.target : connector.source) .filter((item) => item.kind !== "phrase"); return neighbours.every((item) => Boolean(this.visualEndpointFor(item))); } connectorEndpoint(record) { return record.kind === "phrase" ? (this.isEffectiveItemVisible(record) ? record : null) : this.visualEndpointFor(record); } applyConnectorVisualEndpoints(connector, source, target) { if (!source || !target || source === target) { connector.link.visible(false); return; } if (connector.visualSource !== source) { connector.link.sourceNode(source.node); connector.visualSource = source; } if (connector.visualTarget !== target) { connector.link.targetNode(target.node); connector.visualTarget = target; } connector.link.visible(true); connector.link.straighten(); connector.link.redraw(); } canvasPoint(event) { const rect = this.editor.canvas.getBoundingClientRect(); return { x: (event.clientX - rect.left + this.editor.canvas.scrollLeft) / this.zoomFactor, y: (event.clientY - rect.top + this.editor.canvas.scrollTop) / this.zoomFactor }; } itemAt(clientX, clientY) { const element = document.elementFromPoint(clientX, clientY); const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null; if (!itemElement) return null; const id = Number(itemElement.dataset.rwCmapItemId); return this.items.find((item) => item.id === id) || null; } itemCenter(record) { return { x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2), y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2) }; } }