155 lines
6.1 KiB
JavaScript
155 lines
6.1 KiB
JavaScript
"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)
|
|
};
|
|
}
|
|
}
|