Files
racket-wiki/static/js/cmap/controller/cmap-geometry-controller.js
T

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)
};
}
}