refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Public handle for one component rendered by a DiagramEngine.
|
||||
*
|
||||
* A handle exposes only the operations used by the CMap editor. The drawing
|
||||
* object remains private to the engine so application code cannot depend on
|
||||
* the representation inherited from the original renderer.
|
||||
*/
|
||||
export class DiagramComponent {
|
||||
constructor(engine, component, attributeNames) {
|
||||
this.engine = engine;
|
||||
this.component = component;
|
||||
this.attributeNames = attributeNames;
|
||||
this.baseVisible = true;
|
||||
}
|
||||
|
||||
/** Read or update the supported rendering attributes. */
|
||||
attr(name, value) {
|
||||
if (name === undefined) {
|
||||
const attributes = {};
|
||||
for (const attributeName of this.attributeNames) {
|
||||
attributes[attributeName] = this.component[attributeName]();
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
if (isPlainObject(name)) {
|
||||
for (const [attributeName, attributeValue] of Object.entries(name)) {
|
||||
this.attr(attributeName, attributeValue);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!this.attributeNames.includes(name)) return this;
|
||||
if (value === undefined) return this.component[name]();
|
||||
|
||||
this.component[name](value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Remove this component from its diagram. */
|
||||
remove() {
|
||||
this.engine.removeComponent(this);
|
||||
}
|
||||
|
||||
/** Move this component to the front of its own rendering band. */
|
||||
toFront() {
|
||||
this.engine.drawingSurface.toFront(this.component);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Return the concrete element currently rendering this component. */
|
||||
element() {
|
||||
return this.component.element();
|
||||
}
|
||||
|
||||
/** Immediately render the current component state. */
|
||||
redraw() {
|
||||
this.component.redraw();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Read or change whether the component participates in the presentation. */
|
||||
visible(value) {
|
||||
if (value === undefined) return this.component.visible !== false;
|
||||
|
||||
this.baseVisible = Boolean(value);
|
||||
this.engine.applyFilter(this);
|
||||
return this.component.visible;
|
||||
}
|
||||
|
||||
/** Register a callback invoked after this component has been rendered. */
|
||||
onRendered(handler) {
|
||||
validateOptionalHandler(handler, "render");
|
||||
this.component.renderedHandler = handler ?
|
||||
(element) => handler(this, element) : null;
|
||||
|
||||
if (handler && this.component.element()) handler(this, this.component.element());
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Read or change whether the component can be dragged. */
|
||||
draggable(enabled) {
|
||||
if (enabled === undefined) {
|
||||
return this.engine.drawingSurface.dragEnabled(this.component);
|
||||
}
|
||||
|
||||
if (enabled) this.engine.drawingSurface.enableDrag(this.component);
|
||||
else this.engine.drawingSurface.disableDrag(this.component);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a new object containing only supported input attributes. */
|
||||
export function pickAttributes(attributes, names) {
|
||||
if (attributes === undefined) return {};
|
||||
if (!isPlainObject(attributes)) throw new TypeError("Invalid component attributes");
|
||||
|
||||
const selected = {};
|
||||
for (const name of names) {
|
||||
if (name in attributes) selected[name] = attributes[name];
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/** Validate an optional event handler at the public engine boundary. */
|
||||
export function validateOptionalHandler(handler, meaning) {
|
||||
if (handler !== null && handler !== undefined && typeof handler !== "function") {
|
||||
throw new TypeError(`Invalid ${meaning} handler`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine whether a value is a plain attributes object. */
|
||||
function isPlainObject(value) {
|
||||
return typeof value === "object" && value !== null &&
|
||||
Object.prototype.toString.call(value) === "[object Object]";
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { DrawingSurface } from "./drawing-core.js";
|
||||
import { validateOptionalHandler } from "./diagram-component.js";
|
||||
import { DiagramLink } from "./diagram-link.js";
|
||||
import { DiagramNode } from "./diagram-node.js";
|
||||
import { DiagramGroup } from "./diagram-group.js";
|
||||
|
||||
/**
|
||||
* Render and interact with a diagram of nodes and links.
|
||||
*
|
||||
* DiagramEngine is the complete public boundary of the drawing engine. It
|
||||
* translates low-level hit-test results to DiagramNode and DiagramLink
|
||||
* handles and owns every rendering object's lifetime.
|
||||
*/
|
||||
export class DiagramEngine {
|
||||
constructor(element) {
|
||||
this.drawingSurface = new DrawingSurface(element);
|
||||
this.handles = new Map();
|
||||
this.selectionHandler = null;
|
||||
this.activationHandler = null;
|
||||
this.filter = null;
|
||||
this.groups = new Set();
|
||||
this.destroyed = false;
|
||||
|
||||
this.drawingSurface.selectionHandler = (component, event) => {
|
||||
if (this.selectionHandler) {
|
||||
this.selectionHandler(component ? this.handleFor(component) : null, event);
|
||||
}
|
||||
};
|
||||
this.drawingSurface.activationHandler = (component, event) => {
|
||||
if (this.activationHandler) {
|
||||
this.activationHandler(component ? this.handleFor(component) : null, event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Register a callback for a single hit-tested component selection. */
|
||||
onSelection(handler) {
|
||||
validateOptionalHandler(handler, "selection");
|
||||
this.selectionHandler = handler || null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Register a callback for activation of one hit-tested component. */
|
||||
onActivation(handler) {
|
||||
validateOptionalHandler(handler, "activation");
|
||||
this.activationHandler = handler || null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Create and render a node owned by this engine. */
|
||||
node(attributes) {
|
||||
this.ensureActive();
|
||||
const node = new DiagramNode(this, attributes);
|
||||
this.addComponent(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Create and render a link owned by this engine. */
|
||||
link(attributes) {
|
||||
this.ensureActive();
|
||||
const link = new DiagramLink(this, attributes);
|
||||
this.addComponent(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generic group frame for nodes and nested groups.
|
||||
* @returns {DiagramGroup} A group owned by this engine.
|
||||
*/
|
||||
group(options) {
|
||||
this.ensureActive();
|
||||
const group = new DiagramGroup(this, options);
|
||||
this.groups.add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Install a policy that controls component visibility.
|
||||
* pre : filter is null or a function receiving a public component handle.
|
||||
* post : All existing components use the new policy immediately.
|
||||
* result : This engine, for fluent setup.
|
||||
* internals : A policy may return a boolean or `{ visible }`; the component's
|
||||
* own visibility remains the base value and is combined with the policy.
|
||||
*/
|
||||
setFilter(filter) {
|
||||
if (filter !== null && filter !== undefined && typeof filter !== "function") {
|
||||
throw new TypeError("A diagram filter must be a function");
|
||||
}
|
||||
this.filter = filter || null;
|
||||
for (const handle of this.handles.values()) this.applyFilter(handle);
|
||||
for (const group of this.groups) group.redraw();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Read or update the diagram zoom factor. */
|
||||
zoom(value) {
|
||||
if (value === undefined) return this.drawingSurface.zoomFactor;
|
||||
|
||||
const factor = Number(value);
|
||||
if (!Number.isFinite(factor) || factor <= 0) {
|
||||
throw new TypeError("Invalid zoom factor");
|
||||
}
|
||||
|
||||
this.drawingSurface.zoomFactor = factor;
|
||||
const element = this.drawingSurface.element();
|
||||
if (element) element.style.zoom = String(factor);
|
||||
return factor;
|
||||
}
|
||||
|
||||
/** Destroy the surface and all nodes and links created through this engine. */
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
|
||||
const element = this.drawingSurface.element();
|
||||
this.selectionHandler = null;
|
||||
this.activationHandler = null;
|
||||
this.drawingSurface.selectionHandler = null;
|
||||
this.drawingSurface.activationHandler = null;
|
||||
for (const group of this.groups) group.destroy();
|
||||
this.groups.clear();
|
||||
for (const component of this.drawingSurface.componentList().toArray()) {
|
||||
component.dispose();
|
||||
component.parentElement(null);
|
||||
}
|
||||
this.drawingSurface.dispose();
|
||||
this.handles.clear();
|
||||
this.destroyed = true;
|
||||
|
||||
if (element && element.parentNode) element.parentNode.removeChild(element);
|
||||
}
|
||||
|
||||
/** Return the public handle associated with a low-level drawing object. */
|
||||
handleFor(component) {
|
||||
return this.handles.get(component) || null;
|
||||
}
|
||||
|
||||
/** Add a newly constructed public component to the drawing surface. */
|
||||
addComponent(handle) {
|
||||
this.handles.set(handle.component, handle);
|
||||
this.drawingSurface.add(handle.component);
|
||||
this.applyFilter(handle);
|
||||
}
|
||||
|
||||
/** Remove a public component and forget its low-level drawing object. */
|
||||
removeComponent(handle) {
|
||||
if (!handle || handle.engine !== this || !this.handles.has(handle.component)) return;
|
||||
this.drawingSurface.remove(handle.component);
|
||||
this.handles.delete(handle.component);
|
||||
}
|
||||
|
||||
/** Return the DOM surface on which components and group frames are drawn. */
|
||||
surfaceElement() {
|
||||
return this.drawingSurface.element();
|
||||
}
|
||||
|
||||
/** Apply the current base visibility and optional external filter to a handle. */
|
||||
applyFilter(handle) {
|
||||
const decision = this.filter ? this.filter(handle) : true;
|
||||
let visible = typeof decision === "boolean" ? decision : decision?.visible !== false;
|
||||
if (handle instanceof DiagramLink) {
|
||||
const source = handle.sourceNode();
|
||||
const target = handle.targetNode();
|
||||
visible = visible && (!source || source.visible()) && (!target || target.visible());
|
||||
}
|
||||
handle.component.visible = handle.baseVisible && visible;
|
||||
handle.component.redraw();
|
||||
if (handle instanceof DiagramNode) {
|
||||
for (const candidate of this.handles.values()) {
|
||||
if (!(candidate instanceof DiagramLink)) continue;
|
||||
if (candidate.sourceNode() === handle || candidate.targetNode() === handle) {
|
||||
this.applyFilter(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of this.groups) {
|
||||
if (group.members.has(handle)) group.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject operations after the engine and its DOM surface were destroyed. */
|
||||
ensureActive() {
|
||||
if (this.destroyed) throw new Error("The diagram engine has been destroyed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { DiagramComponent } from "./diagram-component.js";
|
||||
|
||||
/**
|
||||
* Render a nested group frame around diagram components.
|
||||
*
|
||||
* A group is a view-level container, not a domain model. It owns membership,
|
||||
* expanded state and frame geometry while DiagramEngine continues to own the
|
||||
* lifetime of nodes and links. Application code can map a wiki submap or any
|
||||
* other grouping concept onto this generic abstraction.
|
||||
*/
|
||||
export class DiagramGroup {
|
||||
/**
|
||||
* goal : Create an empty diagram group attached to one engine.
|
||||
* pre : engine is a DiagramEngine and options contains only presentation data.
|
||||
* post : The group has no members and has not yet rendered a frame.
|
||||
* result : A DiagramGroup instance.
|
||||
* internals : Membership is kept as a Set so nested groups and repeated add
|
||||
* operations remain deterministic; bounds are calculated from member handles.
|
||||
*/
|
||||
constructor(engine, options = {}) {
|
||||
if (!engine) throw new TypeError("A diagram engine is required");
|
||||
this.engine = engine;
|
||||
this.label = String(options.label || "");
|
||||
this.className = String(options.className || "cmap-group-frame");
|
||||
this.backgroundColor = options.backgroundColor || "transparent";
|
||||
this.borderColor = options.borderColor || "#5d6d7e";
|
||||
this.padding = Number.isFinite(Number(options.padding)) ? Number(options.padding) : 24;
|
||||
this.depth = Number.isFinite(Number(options.depth)) ? Number(options.depth) : 0;
|
||||
this.expanded = options.expanded !== false;
|
||||
this.manageVisibility = options.manageVisibility !== false;
|
||||
this.members = new Set();
|
||||
this.elementValue = null;
|
||||
this.onToggleHandler = null;
|
||||
this.onPointerDownHandler = options.onPointerDown || null;
|
||||
this.onDoubleClickHandler = options.onDoubleClick || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Add one node or nested group to this group.
|
||||
* pre : member belongs to the same DiagramEngine.
|
||||
* post : The member contributes to group bounds and rendering.
|
||||
* result : This group, for fluent setup.
|
||||
*/
|
||||
add(member) {
|
||||
if (!(member instanceof DiagramComponent) && !(member instanceof DiagramGroup)) {
|
||||
throw new TypeError("A diagram group member is required");
|
||||
}
|
||||
if (member.engine !== this.engine) throw new TypeError("Group member belongs to another engine");
|
||||
this.members.add(member);
|
||||
this.redraw();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Remove a member and update the frame. */
|
||||
remove(member) {
|
||||
this.members.delete(member);
|
||||
this.redraw();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Register the callback invoked when the generic frame toggle is clicked. */
|
||||
onToggle(handler) {
|
||||
if (handler !== null && handler !== undefined && typeof handler !== "function") {
|
||||
throw new TypeError("Invalid group toggle handler");
|
||||
}
|
||||
this.onToggleHandler = handler || null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Set the visual appearance of the group frame.
|
||||
* pre : values contains optional CSS colour values.
|
||||
* post : The next redraw uses the supplied background and border colours.
|
||||
* result : This group, for fluent setup.
|
||||
* internals : Values are kept on the group and applied as CSS custom
|
||||
* properties in redraw(), allowing application-specific frame styles.
|
||||
*/
|
||||
setAppearance(values = {}) {
|
||||
if (values.backgroundColor !== undefined) this.backgroundColor = String(values.backgroundColor);
|
||||
if (values.borderColor !== undefined) this.borderColor = String(values.borderColor);
|
||||
if (values.label !== undefined) this.label = String(values.label);
|
||||
this.redraw();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Change whether group contents are shown.
|
||||
* pre : expanded is boolean-coercible.
|
||||
* post : The frame and all member components reflect the new state.
|
||||
* result : The resulting expanded state.
|
||||
* internals : A collapsed group hides direct members; nested groups redraw
|
||||
* themselves so their own frames disappear with the enclosing group.
|
||||
*/
|
||||
setExpanded(expanded) {
|
||||
this.expanded = Boolean(expanded);
|
||||
if (this.manageVisibility) {
|
||||
for (const member of this.members) {
|
||||
if (member instanceof DiagramGroup) member.setExpanded(this.expanded && member.expanded);
|
||||
else member.visible(this.expanded);
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
return this.expanded;
|
||||
}
|
||||
|
||||
/** Return the union bounds of visible member nodes and nested groups. */
|
||||
bounds() {
|
||||
const members = [...this.members]
|
||||
.map((member) => member instanceof DiagramGroup ? member.bounds() :
|
||||
(member.visible() ? this.componentBounds(member) : null))
|
||||
.filter(Boolean);
|
||||
if (!members.length) return null;
|
||||
return {
|
||||
left: Math.min(...members.map((bounds) => bounds.left)) - this.padding,
|
||||
top: Math.min(...members.map((bounds) => bounds.top)) - this.padding,
|
||||
right: Math.max(...members.map((bounds) => bounds.right)) + this.padding,
|
||||
bottom: Math.max(...members.map((bounds) => bounds.bottom)) + this.padding
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the DOM frame, if the group currently has one. */
|
||||
element() {
|
||||
return this.elementValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render or remove the generic frame according to membership and state.
|
||||
* The engine calls this after component redraws; applications may call it
|
||||
* directly after changing group presentation or membership.
|
||||
*/
|
||||
redraw() {
|
||||
const surface = this.engine.surfaceElement();
|
||||
const bounds = this.expanded && this.bounds();
|
||||
if (!surface || !bounds) {
|
||||
this.removeElement();
|
||||
return this;
|
||||
}
|
||||
if (!this.elementValue) {
|
||||
const frame = document.createElement("div");
|
||||
frame.className = this.className;
|
||||
frame.setAttribute("role", "group");
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
const dragEdge = document.createElement("div");
|
||||
dragEdge.className = `${this.className}-drag-edge ${this.className}-drag-edge-${side}`;
|
||||
if (this.onPointerDownHandler) dragEdge.addEventListener("pointerdown", this.onPointerDownHandler);
|
||||
if (this.onDoubleClickHandler) dragEdge.addEventListener("dblclick", this.onDoubleClickHandler);
|
||||
frame.append(dragEdge);
|
||||
}
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = `${this.className}-toggle`;
|
||||
toggle.addEventListener("click", () => {
|
||||
this.setExpanded(!this.expanded);
|
||||
if (this.onToggleHandler) this.onToggleHandler(this, this.expanded);
|
||||
});
|
||||
frame.append(toggle);
|
||||
surface.prepend(frame);
|
||||
this.elementValue = frame;
|
||||
}
|
||||
const toggle = this.elementValue.querySelector(`.${this.className}-toggle`);
|
||||
if (toggle) {
|
||||
toggle.textContent = this.expanded ? "-" : "+";
|
||||
toggle.setAttribute("aria-expanded", String(this.expanded));
|
||||
toggle.setAttribute("aria-label", this.expanded ? "Collapse group" : "Expand group");
|
||||
}
|
||||
this.elementValue.setAttribute("aria-label", this.label);
|
||||
Object.assign(this.elementValue.style, {
|
||||
left: `${bounds.left}px`,
|
||||
top: `${bounds.top}px`,
|
||||
width: `${bounds.right - bounds.left}px`,
|
||||
height: `${bounds.bottom - bounds.top}px`,
|
||||
zIndex: String(this.depth)
|
||||
});
|
||||
this.elementValue.style.setProperty("--rw-cmap-submap-background", this.backgroundColor);
|
||||
this.elementValue.style.setProperty("--rw-cmap-submap-border", this.borderColor);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Remove the frame and all references owned by this group. */
|
||||
destroy() {
|
||||
for (const member of this.members) {
|
||||
if (member instanceof DiagramGroup) member.destroy();
|
||||
}
|
||||
this.members.clear();
|
||||
this.removeElement();
|
||||
}
|
||||
|
||||
componentBounds(component) {
|
||||
const attributes = component.attr();
|
||||
const x = Number(attributes.x);
|
||||
const y = Number(attributes.y);
|
||||
const width = Number(attributes.width);
|
||||
const height = Number(attributes.height);
|
||||
if (![x, y, width, height].every(Number.isFinite)) return null;
|
||||
return { left: x, top: y, right: x + width, bottom: y + height };
|
||||
}
|
||||
|
||||
removeElement() {
|
||||
if (this.elementValue) {
|
||||
this.elementValue.remove();
|
||||
this.elementValue = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
DiagramComponent,
|
||||
pickAttributes,
|
||||
validateOptionalHandler
|
||||
} from "./diagram-component.js";
|
||||
import { DrawingLink, DrawingSurface, DrawingTriple } from "./drawing-core.js";
|
||||
import { DiagramNode } from "./diagram-node.js";
|
||||
|
||||
const LINK_ATTRIBUTES = [
|
||||
"content",
|
||||
"contentType",
|
||||
"cx",
|
||||
"cy",
|
||||
"width",
|
||||
"height",
|
||||
"backgroundColor",
|
||||
"borderColor",
|
||||
"borderWidth",
|
||||
"textColor",
|
||||
"sourceX",
|
||||
"sourceY",
|
||||
"targetX",
|
||||
"targetY",
|
||||
"lineColor",
|
||||
"lineWidth",
|
||||
"hasArrow"
|
||||
];
|
||||
|
||||
/**
|
||||
* Public rendering handle for one diagram link.
|
||||
*
|
||||
* A link owns only visual and interaction state. Its source and target are
|
||||
* DiagramNode instances from the same DiagramEngine.
|
||||
*/
|
||||
export class DiagramLink extends DiagramComponent {
|
||||
constructor(engine, attributes) {
|
||||
const component = new DrawingLink(pickAttributes(attributes, LINK_ATTRIBUTES));
|
||||
super(engine, component, LINK_ATTRIBUTES);
|
||||
}
|
||||
|
||||
/** Read or replace the source node. Pass null to disconnect it. */
|
||||
sourceNode(node) {
|
||||
return this.connectNode(DrawingSurface.CONNECTION_TYPE_SOURCE, node);
|
||||
}
|
||||
|
||||
/** Read or replace the target node. Pass null to disconnect it. */
|
||||
targetNode(node) {
|
||||
return this.connectNode(DrawingSurface.CONNECTION_TYPE_TARGET, node);
|
||||
}
|
||||
|
||||
/** Register a callback for a source or target connection change. */
|
||||
onConnectionChange(handler) {
|
||||
validateOptionalHandler(handler, "connection-change");
|
||||
this.component.connectionChangeHandler = handler ? (type, node, event) => {
|
||||
handler(this, type, node ? this.engine.handleFor(node) : null, event);
|
||||
} : null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Straighten both link segments between their current endpoints. */
|
||||
straighten() {
|
||||
const relation = this.component.relations()
|
||||
.find((candidate) => candidate instanceof DrawingTriple);
|
||||
const sourceNode = relation ? relation.sourceNode() : null;
|
||||
const targetNode = relation ? relation.targetNode() : null;
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
this.component.straighten();
|
||||
return this;
|
||||
}
|
||||
|
||||
const sourcePoint = relation.connectedPoint(
|
||||
sourceNode, targetNode.cx(), targetNode.cy());
|
||||
const targetPoint = relation.connectedPoint(
|
||||
targetNode, sourceNode.cx(), sourceNode.cy());
|
||||
this.component.straighten(
|
||||
sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Read or update one connected endpoint. */
|
||||
connectNode(type, node) {
|
||||
const connectedComponent = this.engine.drawingSurface
|
||||
.connectedNode(type, this.component);
|
||||
|
||||
if (node === undefined) {
|
||||
return connectedComponent ? this.engine.handleFor(connectedComponent) : null;
|
||||
}
|
||||
|
||||
if (node !== null && !this.validateNode(type, node)) return this;
|
||||
if (connectedComponent) {
|
||||
this.engine.drawingSurface.disconnect(type, connectedComponent, this.component);
|
||||
}
|
||||
if (node !== null) {
|
||||
this.engine.drawingSurface.connect(type, node.component, this.component);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Ensure an endpoint belongs to this engine and is not used at both ends. */
|
||||
validateNode(type, node) {
|
||||
if (!(node instanceof DiagramNode) || node.engine !== this.engine) {
|
||||
throw new TypeError("Invalid diagram node");
|
||||
}
|
||||
|
||||
const otherType = DrawingSurface.anotherConnectionType(type);
|
||||
const otherNode = this.engine.drawingSurface.connectedNode(otherType, this.component);
|
||||
if (otherNode === node.component) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
DiagramComponent,
|
||||
pickAttributes,
|
||||
validateOptionalHandler
|
||||
} from "./diagram-component.js";
|
||||
import { DrawingNode } from "./drawing-core.js";
|
||||
|
||||
const NODE_ATTRIBUTES = [
|
||||
"content",
|
||||
"contentType",
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height",
|
||||
"backgroundColor",
|
||||
"borderColor",
|
||||
"borderWidth",
|
||||
"textColor"
|
||||
];
|
||||
|
||||
/**
|
||||
* Public rendering handle for one diagram node.
|
||||
*
|
||||
* DiagramEngine creates nodes and owns their lifetime. The editor uses this
|
||||
* handle to update presentation attributes and receive completed moves.
|
||||
*/
|
||||
export class DiagramNode extends DiagramComponent {
|
||||
constructor(engine, attributes) {
|
||||
const component = new DrawingNode(pickAttributes(attributes, NODE_ATTRIBUTES));
|
||||
super(engine, component, NODE_ATTRIBUTES);
|
||||
}
|
||||
|
||||
/** Constrain or observe the node while it is being dragged. */
|
||||
onMove(handler) {
|
||||
validateOptionalHandler(handler, "move");
|
||||
this.component.moveHandler = handler ?
|
||||
(x, y) => handler(this, x, y) : null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Register a callback for the end of a node drag gesture. */
|
||||
onMoveEnd(handler) {
|
||||
validateOptionalHandler(handler, "move-end");
|
||||
this.component.moveEndHandler = handler ?
|
||||
(x, y, event) => handler(this, x, y, event) : null;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Maintain renderer component order and coordinate hit-test priority.
|
||||
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||
*/
|
||||
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
|
||||
import { Component, helper } from "./drawing-support.js";
|
||||
|
||||
class ComponentList extends helper.List {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
toFront(component) {
|
||||
var data = this.data;
|
||||
var index = data.indexOf(component);
|
||||
|
||||
if (index === -1)
|
||||
return;
|
||||
|
||||
data.splice(index, 1);
|
||||
data.push(component);
|
||||
}
|
||||
|
||||
fromPoint(ctor, x, y) {
|
||||
var data = this.data;
|
||||
// The visual stack is connector controls, concepts and finally relations.
|
||||
// Use that same priority for coordinate hit testing so a relation that is
|
||||
// hidden behind a concept can never steal the concept's click.
|
||||
var types = (ctor === Component) ? [Connector, Node, Link] : [ctor];
|
||||
|
||||
for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) {
|
||||
var tolerance = toleranceIndex === 0 ? 0 : 8;
|
||||
for (var typeIndex = 0; typeIndex < types.length; typeIndex++) {
|
||||
for (var i = data.length - 1; i >= 0; i--) {
|
||||
var component = data[i];
|
||||
|
||||
if (!(component instanceof types[typeIndex]))
|
||||
continue;
|
||||
|
||||
if (component.visible === false)
|
||||
continue;
|
||||
|
||||
if (component.contains(x, y, tolerance))
|
||||
return component;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class DisabledConnectorList extends helper.List {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
add(type, link) {
|
||||
super.add( {
|
||||
type: type,
|
||||
link: link
|
||||
});
|
||||
}
|
||||
|
||||
remove(type, link) {
|
||||
super.remove( {
|
||||
type: type,
|
||||
link: link
|
||||
});
|
||||
}
|
||||
|
||||
contains(type, link) {
|
||||
return super.contains( {
|
||||
type: type,
|
||||
link: link
|
||||
});
|
||||
}
|
||||
|
||||
equal(a, b) {
|
||||
return a.type === b.type && a.link === b.link;
|
||||
}
|
||||
}
|
||||
|
||||
export { ComponentList, DisabledConnectorList };
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Render the node, link and connector primitives of a diagram.
|
||||
*
|
||||
* These classes contain geometry and DOM presentation only. Application code
|
||||
* reaches them through DiagramNode and DiagramLink handles.
|
||||
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||
*/
|
||||
import { Component, dom, helper } from "./drawing-support.js";
|
||||
|
||||
class Node extends Component {
|
||||
constructor(props) {
|
||||
super();
|
||||
this.visible = true;
|
||||
this.content = this.prop(props.content, '', helper.toString);
|
||||
this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
|
||||
this.x = this.prop(props.x, 0, helper.toNumber);
|
||||
this.y = this.prop(props.y, 0, helper.toNumber);
|
||||
this.width = this.prop(props.width, 75, helper.toNumber);
|
||||
this.height = this.prop(props.height, 30, helper.toNumber);
|
||||
this.backgroundColor = this.prop(props.backgroundColor, '#a7cbe6', helper.toString);
|
||||
this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
|
||||
this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
|
||||
this.textColor = this.prop(props.textColor, '#333', helper.toString);
|
||||
this.zIndex = this.prop('auto');
|
||||
this.element = this.prop(null);
|
||||
this.parentElement = this.prop(null);
|
||||
this.cache = this.prop({});
|
||||
this.relations = this.prop([]);
|
||||
this.moveHandler = null;
|
||||
}
|
||||
|
||||
cx() {
|
||||
return this.x() + this.width() / 2;
|
||||
}
|
||||
|
||||
cy() {
|
||||
return this.y() + this.height() / 2;
|
||||
}
|
||||
|
||||
borderRadius() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
contains(x, y, tolerance) {
|
||||
var nx = this.x();
|
||||
var ny = this.y();
|
||||
var nwidth = this.width();
|
||||
var nheight = this.height();
|
||||
|
||||
return (nx - tolerance <= x && x <= nx + nwidth + tolerance &&
|
||||
ny - tolerance <= y && y <= ny + nheight + tolerance);
|
||||
}
|
||||
|
||||
style() {
|
||||
var contentType = this.contentType();
|
||||
var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
|
||||
var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
|
||||
var translate = 'translate(' + this.x() + 'px, ' + this.y() + 'px)';
|
||||
var borderWidthOffset = this.borderWidth() * 2;
|
||||
|
||||
return {
|
||||
backgroundColor: this.backgroundColor(),
|
||||
border: this.borderWidth() + 'px solid ' + this.borderColor(),
|
||||
borderRadius: this.borderRadius() + 'px',
|
||||
color: this.textColor(),
|
||||
display: this.visible ? '' : 'none',
|
||||
height: (this.height() - borderWidthOffset) + 'px',
|
||||
lineHeight: (lineHeight - borderWidthOffset) + 'px',
|
||||
msTransform: translate,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'auto',
|
||||
position: 'absolute',
|
||||
textAlign: textAlign,
|
||||
textOverflow: 'ellipsis',
|
||||
transform: translate,
|
||||
webkitTransform: translate,
|
||||
whiteSpace: 'nowrap',
|
||||
width: (this.width() - borderWidthOffset) + 'px',
|
||||
zIndex: this.zIndex()
|
||||
};
|
||||
}
|
||||
|
||||
redraw() {
|
||||
var element = this.element();
|
||||
var parentElement = this.parentElement();
|
||||
|
||||
if (!parentElement && !element)
|
||||
return;
|
||||
|
||||
// add element
|
||||
if (parentElement && !element) {
|
||||
element = dom.el('<div>');
|
||||
this.element(element);
|
||||
dom.append(parentElement, element);
|
||||
this.redraw();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// remove element
|
||||
if (!parentElement && element) {
|
||||
dom.remove(element);
|
||||
this.element(null);
|
||||
this.cache({});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var cache = this.cache();
|
||||
|
||||
// update element
|
||||
var content = this.content();
|
||||
|
||||
if (content !== cache.content) {
|
||||
var contentType = this.contentType();
|
||||
|
||||
if (contentType === helper.CONTENT_TYPE_TEXT)
|
||||
dom.text(element, content);
|
||||
else if (contentType === helper.CONTENT_TYPE_HTML)
|
||||
dom.html(element, content);
|
||||
|
||||
cache.content = content;
|
||||
}
|
||||
|
||||
var style = this.style();
|
||||
|
||||
dom.css(element, helper.diffObj(style, cache.style));
|
||||
cache.style = style;
|
||||
this.notifyRendered();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class Link extends Component {
|
||||
constructor(props) {
|
||||
super();
|
||||
this.visible = true;
|
||||
this.content = this.prop(props.content, '', helper.toString);
|
||||
this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
|
||||
this.cx = this.prop(props.cx, 100, helper.toNumber);
|
||||
this.cy = this.prop(props.cy, 40, helper.toNumber);
|
||||
this.width = this.prop(props.width, 50, helper.toNumber);
|
||||
this.height = this.prop(props.height, 20, helper.toNumber);
|
||||
this.backgroundColor = this.prop(props.backgroundColor, 'white', helper.toString);
|
||||
this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
|
||||
this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
|
||||
this.textColor = this.prop(props.textColor, '#333', helper.toString);
|
||||
this.sourceX = this.prop(props.sourceX, this.cx() - 70, helper.toNumber);
|
||||
this.sourceY = this.prop(props.sourceY, this.cy(), helper.toNumber);
|
||||
this.targetX = this.prop(props.targetX, this.cx() + 70, helper.toNumber);
|
||||
this.targetY = this.prop(props.targetY, this.cy(), helper.toNumber);
|
||||
this.lineColor = this.prop(props.lineColor, '#333', helper.toString);
|
||||
this.lineWidth = this.prop(props.lineWidth, 2, helper.toNumber);
|
||||
this.hasArrow = this.prop(props.hasArrow, true, helper.toBoolean);
|
||||
this.zIndex = this.prop('auto');
|
||||
this.element = this.prop(null);
|
||||
this.parentElement = this.prop(null);
|
||||
this.cache = this.prop({});
|
||||
this.relations = this.prop([]);
|
||||
this.connectionChangeHandler = null;
|
||||
}
|
||||
|
||||
straighten(sx, sy, tx, ty) {
|
||||
if (arguments.length === 0) {
|
||||
this.cx((this.sourceX() + this.targetX()) / 2);
|
||||
this.cy((this.sourceY() + this.targetY()) / 2);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.cx((sx + tx) / 2);
|
||||
this.cy((sy + ty) / 2);
|
||||
this.sourceX(sx);
|
||||
this.sourceY(sy);
|
||||
this.targetX(tx);
|
||||
this.targetY(ty);
|
||||
}
|
||||
|
||||
contains(x, y, tolerance) {
|
||||
var content = this.content();
|
||||
var lcx = this.cx();
|
||||
var lcy = this.cy();
|
||||
|
||||
// content area
|
||||
if (content) {
|
||||
var lwidth = this.width();
|
||||
var lheight = this.height();
|
||||
|
||||
var lx = lcx - lwidth / 2;
|
||||
var ly = lcy - lheight / 2;
|
||||
|
||||
if (lx - tolerance <= x && x <= lx + lwidth + tolerance &&
|
||||
ly - tolerance <= y && y <= ly + lheight + tolerance) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var lineWidth = this.lineWidth();
|
||||
|
||||
// source path
|
||||
if (this.containsPath(this.sourceX(), this.sourceY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
|
||||
return true;
|
||||
|
||||
// target path
|
||||
if (this.containsPath(this.targetX(), this.targetY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
containsPath(x0, y0, x1, y1, x, y, d) {
|
||||
var ax = x1 - x0;
|
||||
var ay = y1 - y0;
|
||||
|
||||
var bx = x - x0;
|
||||
var by = y - y0;
|
||||
|
||||
var r = (ax * bx + ay * by) / (ax * ax + ay * ay);
|
||||
|
||||
if (0 <= r && r <= 1) {
|
||||
var px = x0 + r * ax;
|
||||
var py = y0 + r * ay;
|
||||
|
||||
var dx = px - x;
|
||||
var dy = py - y;
|
||||
|
||||
if (dx * dx + dy * dy <= d * d)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
style() {
|
||||
return {
|
||||
display: this.visible ? '' : 'none',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: this.zIndex()
|
||||
};
|
||||
}
|
||||
|
||||
pathContainerStyle() {
|
||||
var width = Math.max(this.cx(), this.sourceX(), this.targetX());
|
||||
var height = Math.max(this.cy(), this.sourceY(), this.targetY());
|
||||
|
||||
return {
|
||||
height: height + 'px',
|
||||
overflow: 'visible',
|
||||
position: 'absolute',
|
||||
width: width + 'px'
|
||||
};
|
||||
}
|
||||
|
||||
lineAttributes() {
|
||||
var d = [
|
||||
'M', this.sourceX(), this.sourceY(),
|
||||
'L', this.cx(), this.cy(),
|
||||
'L', this.targetX(), this.targetY()
|
||||
].join(' ');
|
||||
|
||||
return {
|
||||
d: d,
|
||||
fill: 'none',
|
||||
stroke: this.lineColor(),
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-width': this.lineWidth()
|
||||
};
|
||||
}
|
||||
|
||||
arrowAttributes() {
|
||||
var cx = this.cx();
|
||||
var cy = this.cy();
|
||||
var tx = this.targetX();
|
||||
var ty = this.targetY();
|
||||
|
||||
var radians = Math.atan2(ty - cy, tx - cx);
|
||||
|
||||
var p0 = {
|
||||
x: 15 * Math.cos(radians - 26 * Math.PI / 180),
|
||||
y: 15 * Math.sin(radians - 26 * Math.PI / 180)
|
||||
};
|
||||
|
||||
var p1 = {
|
||||
x: 15 * Math.cos(radians + 26 * Math.PI / 180),
|
||||
y: 15 * Math.sin(radians + 26 * Math.PI / 180)
|
||||
};
|
||||
|
||||
var p2 = {
|
||||
x: 7 * Math.cos(radians),
|
||||
y: 7 * Math.sin(radians)
|
||||
};
|
||||
|
||||
var d = [
|
||||
'M', tx - p0.x, ty - p0.y,
|
||||
'L', tx, ty,
|
||||
'L', tx - p1.x, ty - p1.y,
|
||||
'Q', tx - p2.x, ty - p2.y, tx - p0.x, ty - p0.y,
|
||||
'Z'
|
||||
].join(' ');
|
||||
|
||||
return {
|
||||
d: d,
|
||||
fill: this.lineColor(),
|
||||
stroke: this.lineColor(),
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': this.lineWidth(),
|
||||
visibility: this.hasArrow() ? 'visible' : 'hidden'
|
||||
};
|
||||
}
|
||||
|
||||
contentStyle() {
|
||||
var contentType = this.contentType();
|
||||
var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
|
||||
var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
|
||||
var x = this.cx() - this.width() / 2;
|
||||
var y = this.cy() - this.height() / 2;
|
||||
var translate = 'translate(' + x + 'px, ' + y + 'px)';
|
||||
var borderWidthOffset = this.borderWidth() * 2;
|
||||
|
||||
return {
|
||||
backgroundColor: this.backgroundColor(),
|
||||
border: this.borderWidth() + 'px solid ' + this.borderColor(),
|
||||
borderRadius: '4px',
|
||||
color: this.textColor(),
|
||||
height: (this.height() - borderWidthOffset) + 'px',
|
||||
lineHeight: (lineHeight - borderWidthOffset) + 'px',
|
||||
msTransform: translate,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
textAlign: textAlign,
|
||||
textOverflow: 'ellipsis',
|
||||
transform: translate,
|
||||
visibility: this.content() ? 'visible' : 'hidden',
|
||||
webkitTransform: translate,
|
||||
whiteSpace: 'nowrap',
|
||||
width: (this.width() - borderWidthOffset) + 'px'
|
||||
};
|
||||
}
|
||||
|
||||
redraw() {
|
||||
var element = this.element();
|
||||
var parentElement = this.parentElement();
|
||||
|
||||
if (!parentElement && !element)
|
||||
return;
|
||||
|
||||
// add element
|
||||
if (parentElement && !element) {
|
||||
element = dom.el('<div>');
|
||||
dom.html(element, '<svg><path></path><path></path></svg><div></div>');
|
||||
this.element(element);
|
||||
dom.append(parentElement, element);
|
||||
this.redraw();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// remove element
|
||||
if (!parentElement && element) {
|
||||
dom.remove(element);
|
||||
this.element(null);
|
||||
this.cache({});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var cache = this.cache();
|
||||
|
||||
// update path container element
|
||||
var pathContainerStyle = this.pathContainerStyle();
|
||||
var pathContainerElement = dom.child(element, 0);
|
||||
|
||||
dom.css(pathContainerElement, helper.diffObj(pathContainerStyle, cache.pathContainerElementStyle));
|
||||
cache.pathContainerElementStyle = contentStyle;
|
||||
|
||||
// update line element
|
||||
var lineAttributes = this.lineAttributes();
|
||||
var lineElement = dom.child(pathContainerElement, 0);
|
||||
|
||||
dom.attr(lineElement, helper.diffObj(lineAttributes, cache.lineAttributes));
|
||||
cache.lineAttributes = lineAttributes;
|
||||
|
||||
// update arrow element
|
||||
var arrowAttributes = this.arrowAttributes();
|
||||
var arrowElement = dom.child(pathContainerElement, 1);
|
||||
|
||||
dom.attr(arrowElement, helper.diffObj(arrowAttributes, cache.arrowAttributes));
|
||||
cache.arrowAttributes = arrowAttributes;
|
||||
|
||||
// update content element
|
||||
var content = this.content();
|
||||
var contentStyle = this.contentStyle();
|
||||
var contentElement = dom.child(element, 1);
|
||||
|
||||
if (content !== cache.content) {
|
||||
var contentType = this.contentType();
|
||||
|
||||
if (contentType === helper.CONTENT_TYPE_TEXT)
|
||||
dom.text(contentElement, content);
|
||||
else if (contentType === helper.CONTENT_TYPE_HTML)
|
||||
dom.html(contentElement, content);
|
||||
|
||||
cache.content = content;
|
||||
}
|
||||
|
||||
dom.css(contentElement, helper.diffObj(contentStyle, cache.contentStyle));
|
||||
cache.contentStyle = contentStyle;
|
||||
|
||||
// update container element
|
||||
var style = this.style();
|
||||
|
||||
dom.css(element, helper.diffObj(style, cache.style));
|
||||
cache.style = style;
|
||||
this.notifyRendered();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class Connector extends Component {
|
||||
constructor(props) {
|
||||
super();
|
||||
this.x = this.prop(props.x, 0, helper.toNumber);
|
||||
this.y = this.prop(props.y, 0, helper.toNumber);
|
||||
this.color = this.prop(Connector.COLOR_UNCONNECTED);
|
||||
this.zIndex = this.prop('auto');
|
||||
this.element = this.prop(null);
|
||||
this.parentElement = this.prop(null);
|
||||
this.cache = this.prop({});
|
||||
this.relations = this.prop([]);
|
||||
}
|
||||
|
||||
r() {
|
||||
return 16;
|
||||
}
|
||||
|
||||
contains(x, y, tolerance) {
|
||||
var dx = x - this.x();
|
||||
var dy = y - this.y();
|
||||
var r = this.r() + tolerance;
|
||||
|
||||
return (dx * dx + dy * dy <= r * r);
|
||||
}
|
||||
|
||||
style() {
|
||||
var r = this.r();
|
||||
var x = this.x() - r;
|
||||
var y = this.y() - r;
|
||||
var translate = 'translate(' + x + 'px, ' + y + 'px)';
|
||||
|
||||
return {
|
||||
backgroundColor: this.color(),
|
||||
border: '2px solid lightgray',
|
||||
borderRadius: '50%',
|
||||
boxSizing: 'border-box',
|
||||
height: r * 2 + 'px',
|
||||
msTransform: translate,
|
||||
opacity: 0.6,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
transform: translate,
|
||||
webkitTransform: translate,
|
||||
width: r * 2 + 'px',
|
||||
zIndex: this.zIndex()
|
||||
};
|
||||
}
|
||||
|
||||
redraw() {
|
||||
var element = this.element();
|
||||
var parentElement = this.parentElement();
|
||||
|
||||
if (!parentElement && !element)
|
||||
return;
|
||||
|
||||
// add element
|
||||
if (parentElement && !element) {
|
||||
element = dom.el('<div>');
|
||||
this.element(element);
|
||||
dom.append(parentElement, element);
|
||||
this.redraw();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// remove element
|
||||
if (!parentElement && element) {
|
||||
dom.remove(element);
|
||||
this.element(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var cache = this.cache();
|
||||
|
||||
// update element
|
||||
var style = this.style();
|
||||
|
||||
dom.css(element, helper.diffObj(style, cache.style));
|
||||
cache.style = style;
|
||||
this.notifyRendered();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Connector.COLOR_CONNECTED = 'lightgreen';
|
||||
Connector.COLOR_UNCONNECTED = 'pink';
|
||||
|
||||
export { Connector, Link as DrawingLink, Node as DrawingNode };
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Internal exports of the modular diagram rendering core.
|
||||
*
|
||||
* The implementation is derived from the MIT-licensed ionstage/cmap 0.1.3
|
||||
* renderer and is maintained as part of racket-wiki.
|
||||
*/
|
||||
export { DrawingLink, DrawingNode } from "./drawing-components.js";
|
||||
export { DrawingTriple } from "./drawing-relations.js";
|
||||
export { DrawingSurface } from "./drawing-surface.js";
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Keep diagram endpoints and connector controls geometrically related.
|
||||
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||
*/
|
||||
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
|
||||
|
||||
class Relation {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
prop(initialValue) {
|
||||
var cache = initialValue;
|
||||
|
||||
return function(value) {
|
||||
if (typeof value === 'undefined')
|
||||
return cache;
|
||||
|
||||
cache = value;
|
||||
};
|
||||
}
|
||||
|
||||
update() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class Triple extends Relation {
|
||||
constructor(props) {
|
||||
super();
|
||||
this.link = this.prop(props.link);
|
||||
this.sourceNode = this.prop(props.sourceNode || null);
|
||||
this.targetNode = this.prop(props.targetNode || null);
|
||||
this.skipNextUpdate = this.prop(false);
|
||||
this.nodePositionsCache = this.prop({});
|
||||
}
|
||||
|
||||
update(changedComponent) {
|
||||
if (this.skipNextUpdate()) {
|
||||
this.skipNextUpdate(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var link = this.link();
|
||||
var sourceNode = this.sourceNode();
|
||||
var targetNode = this.targetNode();
|
||||
|
||||
if (changedComponent instanceof Node)
|
||||
this.updateNode(link, sourceNode, targetNode, changedComponent);
|
||||
else if (changedComponent instanceof Link)
|
||||
this.updateLink(link, sourceNode, targetNode);
|
||||
}
|
||||
|
||||
updateNode(link, sourceNode, targetNode, changedNode) {
|
||||
if (sourceNode && targetNode)
|
||||
this.rotateLink(link, sourceNode, targetNode, changedNode);
|
||||
else
|
||||
this.shiftLink(link, sourceNode, targetNode, changedNode);
|
||||
|
||||
this.updateNodePositionsCache();
|
||||
}
|
||||
|
||||
rotateLink(link, sourceNode, targetNode, changedNode) {
|
||||
var cache = this.nodePositionsCache();
|
||||
|
||||
var sncx = cache.sncx;
|
||||
var sncy = cache.sncy;
|
||||
var tncx = cache.tncx;
|
||||
var tncy = cache.tncy;
|
||||
|
||||
var lcx = link.cx();
|
||||
var lcy = link.cy();
|
||||
|
||||
var ts_dx = tncx - sncx;
|
||||
var ts_dy = tncy - sncy;
|
||||
var cs_dx = lcx - sncx;
|
||||
var cs_dy = lcy - sncy;
|
||||
|
||||
var ts_rad0 = Math.atan2(ts_dy, ts_dx);
|
||||
var cs_rad0 = Math.atan2(cs_dy, cs_dx);
|
||||
|
||||
// changed node position
|
||||
if (changedNode === sourceNode) {
|
||||
sncx = sourceNode.cx();
|
||||
sncy = sourceNode.cy();
|
||||
} else if (changedNode === targetNode) {
|
||||
tncx = targetNode.cx();
|
||||
tncy = targetNode.cy();
|
||||
}
|
||||
|
||||
// center positions of two nodes are equal
|
||||
if (cs_rad0 === 0) {
|
||||
link.cx((sncx + tncx) / 2);
|
||||
link.cy((sncy + tncy) / 2);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var ts_d0 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
|
||||
var cs_d0 = Math.sqrt(cs_dx * cs_dx + cs_dy * cs_dy);
|
||||
|
||||
var ts_cs_rad = ts_rad0 - cs_rad0;
|
||||
|
||||
ts_dx = tncx - sncx;
|
||||
ts_dy = tncy - sncy;
|
||||
|
||||
var ts_rad1 = Math.atan2(ts_dy, ts_dx);
|
||||
var cs_rad1 = ts_rad1 - ts_cs_rad;
|
||||
|
||||
var ts_d1 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
|
||||
var d_rate = (ts_d0 !== 0) ? ts_d1 / ts_d0 : 1;
|
||||
var cs_d1 = cs_d0 * d_rate;
|
||||
|
||||
lcx = sncx + cs_d1 * Math.cos(cs_rad1);
|
||||
lcy = sncy + cs_d1 * Math.sin(cs_rad1);
|
||||
|
||||
link.cx(lcx);
|
||||
link.cy(lcy);
|
||||
}
|
||||
|
||||
shiftLink(link, sourceNode, targetNode, changedNode) {
|
||||
var cache = this.nodePositionsCache();
|
||||
|
||||
var ncx = changedNode.cx();
|
||||
var ncy = changedNode.cy();
|
||||
|
||||
if (changedNode === sourceNode) {
|
||||
link.targetX(link.targetX() + (ncx - cache.sncx));
|
||||
link.targetY(link.targetY() + (ncy - cache.sncy));
|
||||
} else if (changedNode === targetNode) {
|
||||
link.sourceX(link.sourceX() + (ncx - cache.tncx));
|
||||
link.sourceY(link.sourceY() + (ncy - cache.tncy));
|
||||
}
|
||||
}
|
||||
|
||||
updateLink(link, sourceNode, targetNode) {
|
||||
var lx, ly, p;
|
||||
|
||||
if (sourceNode) {
|
||||
// connect link to source node
|
||||
lx = targetNode ? link.cx() : link.targetX();
|
||||
ly = targetNode ? link.cy() : link.targetY();
|
||||
p = this.connectedPoint(sourceNode, lx, ly);
|
||||
link.sourceX(p.x);
|
||||
link.sourceY(p.y);
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
// connect link to target node
|
||||
lx = sourceNode ? link.cx() : link.sourceX();
|
||||
ly = sourceNode ? link.cy() : link.sourceY();
|
||||
p = this.connectedPoint(targetNode, lx, ly);
|
||||
link.targetX(p.x);
|
||||
link.targetY(p.y);
|
||||
}
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
// link content moves to midpoint
|
||||
link.cx((link.sourceX() + link.targetX()) / 2);
|
||||
link.cy((link.sourceY() + link.targetY()) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
updateLinkAngle(radians) {
|
||||
var link = this.link();
|
||||
var sourceNode = this.sourceNode();
|
||||
var targetNode = this.targetNode();
|
||||
|
||||
var ldx = link.targetX() - link.sourceX();
|
||||
var ldy = link.targetY() - link.sourceY();
|
||||
var d = Math.sqrt(ldx * ldx + ldy * ldy);
|
||||
|
||||
var connectedNode = sourceNode || targetNode;
|
||||
var cx = connectedNode.cx();
|
||||
var cy = connectedNode.cy();
|
||||
var lx = cx + d * Math.cos(radians);
|
||||
var ly = cy + d * Math.sin(radians);
|
||||
var p = this.connectedPoint(connectedNode, lx, ly);
|
||||
|
||||
if (connectedNode === sourceNode)
|
||||
link.straighten(p.x, p.y, lx + p.x - cx, ly + p.y - cy);
|
||||
else if (connectedNode === targetNode)
|
||||
link.straighten(lx + p.x - cx, ly + p.y - cy, p.x, p.y);
|
||||
}
|
||||
|
||||
updateNodePositionsCache() {
|
||||
var sourceNode = this.sourceNode();
|
||||
var targetNode = this.targetNode();
|
||||
var cache = this.nodePositionsCache();
|
||||
|
||||
if (sourceNode) {
|
||||
cache.sncx = sourceNode.cx();
|
||||
cache.sncy = sourceNode.cy();
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
cache.tncx = targetNode.cx();
|
||||
cache.tncy = targetNode.cy();
|
||||
}
|
||||
}
|
||||
|
||||
connectedPoint(node, lx, ly) {
|
||||
var nx = node.x();
|
||||
var ny = node.y();
|
||||
var nwidth = node.width();
|
||||
var nheight = node.height();
|
||||
var ncx = node.cx();
|
||||
var ncy = node.cy();
|
||||
|
||||
var alpha = Math.atan2(ly - ncy, lx - ncx);
|
||||
var beta = Math.PI / 2 - alpha;
|
||||
var t = Math.atan2(nheight, nwidth);
|
||||
|
||||
var x, y;
|
||||
|
||||
// left edge
|
||||
if (alpha < t - Math.PI || alpha > Math.PI - t) {
|
||||
x = nx;
|
||||
y = ncy - nwidth * Math.tan(alpha) / 2;
|
||||
}
|
||||
// top edge
|
||||
else if (alpha < -t) {
|
||||
x = ncx - nheight * Math.tan(beta) / 2;
|
||||
y = ny;
|
||||
}
|
||||
// right edge
|
||||
else if (alpha < t) {
|
||||
x = nx + nwidth;
|
||||
y = ncy + nwidth * Math.tan(alpha) / 2;
|
||||
}
|
||||
// bottom edge
|
||||
else {
|
||||
x = ncx + nheight * Math.tan(beta) / 2;
|
||||
y = ny + nheight;
|
||||
}
|
||||
|
||||
var x0, y0, l, ex, ey;
|
||||
var r = node.borderRadius();
|
||||
var atCorner = false;
|
||||
|
||||
// top-left corner
|
||||
if (x < nx + r && y < ny + r) {
|
||||
x0 = nx + r;
|
||||
y0 = ny + r;
|
||||
atCorner = true;
|
||||
}
|
||||
// top-right corner
|
||||
else if (x > nx + nwidth - r && y < ny + r) {
|
||||
x0 = nx + nwidth - r;
|
||||
y0 = ny + r;
|
||||
atCorner = true;
|
||||
}
|
||||
// bottom-left corner
|
||||
else if (x < nx + r && y > ny + nheight - r) {
|
||||
x0 = nx + r;
|
||||
y0 = ny + nheight - r;
|
||||
atCorner = true;
|
||||
}
|
||||
// bottom-right corner
|
||||
else if (x > nx + nwidth - r && y > ny + nheight - r) {
|
||||
x0 = nx + nwidth - r;
|
||||
y0 = ny + nheight - r;
|
||||
atCorner = true;
|
||||
}
|
||||
|
||||
if (atCorner) {
|
||||
l = Math.sqrt((x0 - x) * (x0 - x) + (y0 - y) * (y0 - y));
|
||||
ex = (x0 - x) / l;
|
||||
ey = (y0 - y) / l;
|
||||
x = x0 - r * ex;
|
||||
y = y0 - r * ey;
|
||||
}
|
||||
|
||||
return {
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class LinkConnectorRelation extends Relation {
|
||||
constructor(props) {
|
||||
super();
|
||||
this.type = this.prop(props.type);
|
||||
this.link = this.prop(props.link);
|
||||
this.connector = this.prop(props.connector);
|
||||
}
|
||||
|
||||
isConnected(isConnected) {
|
||||
var color = isConnected ? Connector.COLOR_CONNECTED : Connector.COLOR_UNCONNECTED;
|
||||
this.connector().color(color);
|
||||
}
|
||||
|
||||
update(changedComponent) {
|
||||
var type = this.type();
|
||||
var link = this.link();
|
||||
var connector = this.connector();
|
||||
|
||||
if (changedComponent === link) {
|
||||
connector.x(link[type + 'X']());
|
||||
connector.y(link[type + 'Y']());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { LinkConnectorRelation, Triple as DrawingTriple };
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Shared DOM and state support for the low-level diagram renderer.
|
||||
*
|
||||
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||
*/
|
||||
const CONTENT_TYPE_TEXT = 'text';
|
||||
const CONTENT_TYPE_HTML = 'html';
|
||||
|
||||
class ItemList {
|
||||
constructor() {
|
||||
this.data = [];
|
||||
}
|
||||
|
||||
add(item) {
|
||||
if (!this.contains(item)) this.data.push(item);
|
||||
}
|
||||
|
||||
remove(item) {
|
||||
for (let index = this.data.length - 1; index >= 0; index -= 1) {
|
||||
if (this.equal(this.data[index], item)) {
|
||||
this.data.splice(index, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contains(item) {
|
||||
return this.data.some((candidate) => this.equal(candidate, item));
|
||||
}
|
||||
|
||||
equal(first, second) {
|
||||
return first === second;
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return this.data.slice();
|
||||
}
|
||||
}
|
||||
|
||||
const helper = {
|
||||
CONTENT_TYPE_HTML,
|
||||
CONTENT_TYPE_TEXT,
|
||||
List: ItemList,
|
||||
|
||||
toNumber(value, defaultValue) {
|
||||
return !isNaN(value) ? Number(value) : defaultValue;
|
||||
},
|
||||
|
||||
toString(value, defaultValue) {
|
||||
return value !== undefined ? String(value) : defaultValue;
|
||||
},
|
||||
|
||||
toBoolean(value, defaultValue) {
|
||||
return value !== undefined ? Boolean(value) : defaultValue;
|
||||
},
|
||||
|
||||
toContentType(value, defaultValue) {
|
||||
if (value === CONTENT_TYPE_TEXT || value === CONTENT_TYPE_HTML) return value;
|
||||
return defaultValue;
|
||||
},
|
||||
|
||||
eachInstance(values, constructor, callback) {
|
||||
values.filter((value) => value instanceof constructor).forEach(callback);
|
||||
},
|
||||
|
||||
firstInstance(values, constructor) {
|
||||
return values.find((value) => value instanceof constructor);
|
||||
},
|
||||
|
||||
diffObj(newObject, oldObject) {
|
||||
const difference = {};
|
||||
for (const key in newObject) {
|
||||
if (!oldObject || newObject[key] !== oldObject[key]) {
|
||||
difference[key] = newObject[key];
|
||||
}
|
||||
}
|
||||
return difference;
|
||||
},
|
||||
|
||||
identity(value) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
var dom = {};
|
||||
|
||||
dom.disabled = function() {
|
||||
return (typeof document === 'undefined');
|
||||
};
|
||||
|
||||
dom.el = function(selector) {
|
||||
if (selector.charAt(0) === '<') {
|
||||
selector = selector.match(/<(.+)>/)[1];
|
||||
return document.createElement(selector);
|
||||
}
|
||||
};
|
||||
|
||||
dom.body = function() {
|
||||
return document.body;
|
||||
};
|
||||
|
||||
dom.attr = function(el, props) {
|
||||
for (var key in props) {
|
||||
el.setAttribute(key, props[key]);
|
||||
}
|
||||
};
|
||||
|
||||
dom.css = function(el, props) {
|
||||
var style = el.style;
|
||||
|
||||
for (var key in props) {
|
||||
style[key] = props[key];
|
||||
}
|
||||
};
|
||||
|
||||
dom.rect = function(el) {
|
||||
return el.getBoundingClientRect();
|
||||
};
|
||||
|
||||
dom.clientWidth = function(el) {
|
||||
return el.clientWidth;
|
||||
};
|
||||
|
||||
dom.clientHeight = function(el) {
|
||||
return el.clientHeight;
|
||||
};
|
||||
|
||||
dom.scrollLeft = function(el) {
|
||||
return el.scrollLeft;
|
||||
};
|
||||
|
||||
dom.scrollTop = function(el) {
|
||||
return el.scrollTop;
|
||||
};
|
||||
|
||||
dom.scrollWidth = function(el) {
|
||||
return el.scrollWidth;
|
||||
};
|
||||
|
||||
dom.scrollHeight = function(el) {
|
||||
return el.scrollHeight;
|
||||
};
|
||||
|
||||
dom.text = function(el, s) {
|
||||
el.textContent = s;
|
||||
};
|
||||
|
||||
dom.html = function(el, s) {
|
||||
el.innerHTML = s;
|
||||
};
|
||||
|
||||
dom.append = function(parent, el) {
|
||||
parent.appendChild(el);
|
||||
};
|
||||
|
||||
dom.remove = function(el) {
|
||||
el.parentNode.removeChild(el);
|
||||
};
|
||||
|
||||
dom.child = function(el, index) {
|
||||
return el.childNodes[index];
|
||||
};
|
||||
|
||||
dom.animate = function(callback) {
|
||||
return window.requestAnimationFrame(callback);
|
||||
};
|
||||
|
||||
dom.supportsTouch = function() {
|
||||
return ('ontouchstart' in window || (typeof DocumentTouch !== 'undefined' && document instanceof DocumentTouch));
|
||||
};
|
||||
|
||||
dom.on = function(el, type, listener) {
|
||||
el.addEventListener(type, listener);
|
||||
};
|
||||
|
||||
dom.off = function(el, type, listener) {
|
||||
el.removeEventListener(type, listener);
|
||||
};
|
||||
|
||||
dom.pagePoint = function(event, offset) {
|
||||
if (dom.supportsTouch())
|
||||
event = event.changedTouches[0];
|
||||
|
||||
return {
|
||||
x: event.pageX - (offset ? offset.x : 0),
|
||||
y: event.pageY - (offset ? offset.y : 0)
|
||||
};
|
||||
};
|
||||
|
||||
dom.clientPoint = function(event, offset) {
|
||||
if (dom.supportsTouch())
|
||||
event = event.changedTouches[0];
|
||||
|
||||
return {
|
||||
x: event.clientX - (offset ? offset.x : 0),
|
||||
y: event.clientY - (offset ? offset.y : 0)
|
||||
};
|
||||
};
|
||||
|
||||
dom.cancel = function(event) {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
class Draggable {
|
||||
constructor(element, onStart, onMove, onEnd) {
|
||||
this.element = element;
|
||||
this.onStart = onStart;
|
||||
this.onMove = onMove;
|
||||
this.onEnd = onEnd;
|
||||
this.start = this.start.bind(this);
|
||||
this.move = this.move.bind(this);
|
||||
this.end = this.end.bind(this);
|
||||
this.locked = false;
|
||||
this.startingPoint = null;
|
||||
this.startEvent = dom.supportsTouch() ? 'touchstart' : 'mousedown';
|
||||
this.moveEvent = dom.supportsTouch() ? 'touchmove' : 'mousemove';
|
||||
this.endEvent = dom.supportsTouch() ? 'touchend' : 'mouseup';
|
||||
|
||||
dom.on(this.element, this.startEvent, this.start);
|
||||
}
|
||||
|
||||
start(event) {
|
||||
if (this.locked)
|
||||
return;
|
||||
|
||||
this.locked = true;
|
||||
this.startingPoint = dom.pagePoint(event);
|
||||
const rectangle = dom.rect(this.element);
|
||||
const point = dom.clientPoint(event, {
|
||||
x: rectangle.left - dom.scrollLeft(this.element),
|
||||
y: rectangle.top - dom.scrollTop(this.element)
|
||||
});
|
||||
|
||||
if (typeof this.onStart === 'function') this.onStart(point.x, point.y, event);
|
||||
|
||||
dom.on(document, this.moveEvent, this.move);
|
||||
dom.on(document, this.endEvent, this.end);
|
||||
}
|
||||
|
||||
move(event) {
|
||||
const distance = dom.pagePoint(event, this.startingPoint);
|
||||
if (typeof this.onMove === 'function') this.onMove(distance.x, distance.y, event);
|
||||
}
|
||||
|
||||
end(event) {
|
||||
dom.off(document, this.moveEvent, this.move);
|
||||
dom.off(document, this.endEvent, this.end);
|
||||
|
||||
const distance = dom.pagePoint(event, this.startingPoint);
|
||||
if (typeof this.onEnd === 'function') this.onEnd(distance.x, distance.y, event);
|
||||
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
dom.draggable = function(element, onStart, onMove, onEnd) {
|
||||
if (dom.disabled()) return null;
|
||||
return new Draggable(element, onStart, onMove, onEnd);
|
||||
};
|
||||
|
||||
const dirtyComponents = [];
|
||||
let renderRequestId = null;
|
||||
|
||||
class Component {
|
||||
constructor() {
|
||||
this.disposed = false;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
prop(initialValue, defaultValue, converter) {
|
||||
const convert = typeof converter === 'function' ? converter : helper.identity;
|
||||
let cache = convert(initialValue, defaultValue);
|
||||
|
||||
return (value) => {
|
||||
if (typeof value === 'undefined')
|
||||
return cache;
|
||||
|
||||
if (value === cache)
|
||||
return;
|
||||
|
||||
cache = convert(value, cache);
|
||||
this.markDirty();
|
||||
};
|
||||
}
|
||||
|
||||
relations() {
|
||||
return [];
|
||||
}
|
||||
|
||||
redraw() {}
|
||||
|
||||
notifyRendered() {
|
||||
if (typeof this.renderedHandler === 'function' && this.element())
|
||||
this.renderedHandler(this.element());
|
||||
}
|
||||
|
||||
markDirty() {
|
||||
if (dom.disabled() || this.disposed)
|
||||
return;
|
||||
|
||||
if (!dirtyComponents.includes(this))
|
||||
dirtyComponents.push(this);
|
||||
|
||||
if (renderRequestId !== null)
|
||||
return;
|
||||
|
||||
renderRequestId = dom.animate(redrawDirtyComponents);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDirtyRelations(index) {
|
||||
const initialLength = dirtyComponents.length;
|
||||
for (let position = index; position < initialLength; position += 1) {
|
||||
const component = dirtyComponents[position];
|
||||
if (component.disposed)
|
||||
continue;
|
||||
component.relations().forEach((relation) => {
|
||||
if (!relation.disposed)
|
||||
relation.update(component);
|
||||
});
|
||||
}
|
||||
|
||||
if (dirtyComponents.length > initialLength)
|
||||
updateDirtyRelations(initialLength);
|
||||
}
|
||||
|
||||
function redrawDirtyComponents() {
|
||||
updateDirtyRelations(0);
|
||||
dirtyComponents.forEach((component) => {
|
||||
if (!component.disposed)
|
||||
component.redraw();
|
||||
});
|
||||
dirtyComponents.length = 0;
|
||||
renderRequestId = null;
|
||||
}
|
||||
|
||||
export { Component, dom, helper };
|
||||
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* Own the diagram surface, component relations and pointer interaction.
|
||||
*
|
||||
* DrawingSurface is internal to DiagramEngine. It coordinates the primitive
|
||||
* renderers, performs hit testing and translates drag gestures to geometry.
|
||||
* Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
|
||||
*/
|
||||
import { ComponentList, DisabledConnectorList } from "./drawing-collections.js";
|
||||
import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
|
||||
import { DrawingTriple as Triple, LinkConnectorRelation } from "./drawing-relations.js";
|
||||
import { Component, dom, helper } from "./drawing-support.js";
|
||||
|
||||
class Cmap extends Component {
|
||||
constructor(rootElement) {
|
||||
super();
|
||||
this.componentList = this.prop(new ComponentList());
|
||||
this.disabledConnectorList = this.prop(new DisabledConnectorList());
|
||||
this.dragDisabledComponentList = this.prop(new ComponentList());
|
||||
this.element = this.prop(null);
|
||||
this.rootElement = this.prop(rootElement || null);
|
||||
this.retainerElement = this.prop(null);
|
||||
this.dragContext = this.prop({});
|
||||
this.selectionHandler = null;
|
||||
this.activationHandler = null;
|
||||
this.lastClickComponent = null;
|
||||
this.lastClickTime = 0;
|
||||
this.zoomFactor = 1;
|
||||
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
add(component) {
|
||||
component.parentElement(this.element());
|
||||
this.componentList().add(component);
|
||||
this.updateZIndex();
|
||||
}
|
||||
|
||||
static anotherConnectionType(type) {
|
||||
if (type === Cmap.CONNECTION_TYPE_SOURCE)
|
||||
return Cmap.CONNECTION_TYPE_TARGET;
|
||||
else if (type === Cmap.CONNECTION_TYPE_TARGET)
|
||||
return Cmap.CONNECTION_TYPE_SOURCE;
|
||||
}
|
||||
|
||||
remove(component) {
|
||||
component.parentElement(null);
|
||||
|
||||
if (component instanceof Link)
|
||||
this.hideConnectors(component);
|
||||
|
||||
this.disconnect(component);
|
||||
this.componentList().remove(component);
|
||||
this.updateZIndex();
|
||||
}
|
||||
|
||||
toFront(component) {
|
||||
this.componentList().toFront(component);
|
||||
this.updateZIndex();
|
||||
}
|
||||
|
||||
updateZIndex() {
|
||||
var linkIndex = 0;
|
||||
var nodeIndex = 0;
|
||||
this.componentList().toArray().forEach(function(component) {
|
||||
if (component instanceof Connector)
|
||||
return;
|
||||
|
||||
// Relations always occupy a lower band than concept nodes. Reordering a
|
||||
// selected component therefore only changes its order inside that band.
|
||||
var zIndex = component instanceof Link ?
|
||||
Cmap.LINK_Z_INDEX_BASE + linkIndex++ :
|
||||
Cmap.NODE_Z_INDEX_BASE + nodeIndex++;
|
||||
component.zIndex(zIndex);
|
||||
|
||||
if (!(component instanceof Link))
|
||||
return;
|
||||
|
||||
// update connector z-index of link
|
||||
helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) {
|
||||
relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
connect(type, node, link) {
|
||||
var linkRelations = link.relations();
|
||||
var triple = helper.firstInstance(linkRelations, Triple);
|
||||
var nodeKey = type + 'Node';
|
||||
|
||||
if (triple && triple[nodeKey]())
|
||||
throw new Error('Already connected');
|
||||
|
||||
var anotherType = Cmap.anotherConnectionType(type);
|
||||
var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
|
||||
|
||||
if (anotherSideNode === node)
|
||||
throw new Error('Already connected to the ' + anotherType + ' of the link');
|
||||
|
||||
if (triple) {
|
||||
triple[nodeKey](node);
|
||||
} else {
|
||||
var tripleProps = {};
|
||||
tripleProps.link = link;
|
||||
tripleProps[nodeKey] = node;
|
||||
triple = new Triple(tripleProps);
|
||||
|
||||
// add triple to the beginning of link relations to be ahead of link-connector relation
|
||||
// connector position won't be updated before triple update
|
||||
linkRelations.unshift(triple);
|
||||
}
|
||||
|
||||
// add triple to node
|
||||
node.relations().push(triple);
|
||||
triple.updateNodePositionsCache();
|
||||
|
||||
// update connectors of link
|
||||
helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
|
||||
if (relation.type() === type)
|
||||
relation.isConnected(true);
|
||||
});
|
||||
|
||||
// link content moves to midpoint of connected nodes
|
||||
if (anotherSideNode) {
|
||||
link.cx((node.cx() + anotherSideNode.cx()) / 2);
|
||||
link.cy((node.cy() + anotherSideNode.cy()) / 2);
|
||||
}
|
||||
|
||||
// do not need to mark node dirty (stay unchanged)
|
||||
link.markDirty();
|
||||
}
|
||||
|
||||
disconnect(type, node, link) {
|
||||
if (type instanceof Component) {
|
||||
var component = type;
|
||||
var relations = component.relations().slice();
|
||||
|
||||
// disconnect all connections of component
|
||||
helper.eachInstance(relations, Triple, function(triple) {
|
||||
var link = triple.link();
|
||||
var sourceNode = triple.sourceNode();
|
||||
var targetNode = triple.targetNode();
|
||||
|
||||
if (sourceNode && (component === link || component === sourceNode))
|
||||
this.disconnect(Cmap.CONNECTION_TYPE_SOURCE, sourceNode, link);
|
||||
|
||||
if (targetNode && (component === link || component === targetNode))
|
||||
this.disconnect(Cmap.CONNECTION_TYPE_TARGET, targetNode, link);
|
||||
}.bind(this));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var linkRelations = link.relations();
|
||||
var triple = helper.firstInstance(linkRelations, Triple);
|
||||
var nodeKey = type + 'Node';
|
||||
|
||||
if (!triple || triple[nodeKey]() !== node)
|
||||
throw new Error('Not connected');
|
||||
|
||||
triple[nodeKey](null);
|
||||
|
||||
// remove triple from node
|
||||
var nodeRelations = node.relations();
|
||||
nodeRelations.splice(nodeRelations.indexOf(triple), 1);
|
||||
|
||||
// remove triple from link
|
||||
if (!triple.sourceNode() && !triple.targetNode())
|
||||
linkRelations.splice(linkRelations.indexOf(triple), 1);
|
||||
|
||||
// update connectors of link
|
||||
helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
|
||||
if (relation.type() === type)
|
||||
relation.isConnected(false);
|
||||
});
|
||||
|
||||
// do not need to mark node dirty (stay unchanged)
|
||||
link.markDirty();
|
||||
}
|
||||
|
||||
connectedNode(type, link) {
|
||||
var triple = helper.firstInstance(link.relations(), Triple);
|
||||
|
||||
if (!triple)
|
||||
return null;
|
||||
|
||||
return triple[type + 'Node']();
|
||||
}
|
||||
|
||||
showConnector(type, link) {
|
||||
if (this.connectorVisible(type, link))
|
||||
return;
|
||||
|
||||
var disabledConnectorList = this.disabledConnectorList();
|
||||
var connectorDisabled = disabledConnectorList.contains(type, link);
|
||||
|
||||
if (!connectorDisabled)
|
||||
this.addConnector(type, link);
|
||||
}
|
||||
|
||||
connectorVisible(type, link) {
|
||||
return link.relations().some(function(relation) {
|
||||
return relation instanceof LinkConnectorRelation && relation.type() === type;
|
||||
});
|
||||
}
|
||||
|
||||
addConnector(type, link) {
|
||||
var connector = new Connector({
|
||||
x: link[type + 'X'](),
|
||||
y: link[type + 'Y']()
|
||||
});
|
||||
|
||||
var linkConnectorRelation = new LinkConnectorRelation({
|
||||
type: type,
|
||||
link: link,
|
||||
connector: connector
|
||||
});
|
||||
|
||||
var linkRelations = link.relations();
|
||||
var triple = helper.firstInstance(linkRelations, Triple);
|
||||
var isConnected = (triple && !!triple[type + 'Node']());
|
||||
|
||||
linkConnectorRelation.isConnected(isConnected);
|
||||
linkRelations.push(linkConnectorRelation);
|
||||
connector.relations().push(linkConnectorRelation);
|
||||
|
||||
this.add(connector);
|
||||
}
|
||||
|
||||
hideConnector(type, link) {
|
||||
var linkRelations = link.relations();
|
||||
|
||||
for (var i = linkRelations.length - 1; i >= 0; i--) {
|
||||
var relation = linkRelations[i];
|
||||
|
||||
if (!(relation instanceof LinkConnectorRelation) || relation.type() !== type)
|
||||
continue;
|
||||
|
||||
// remove connector component
|
||||
this.remove(relation.connector());
|
||||
|
||||
// remove link-connector relation from link
|
||||
linkRelations.splice(i, 1);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
showConnectors(link) {
|
||||
this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
|
||||
this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link);
|
||||
}
|
||||
|
||||
hideConnectors(link) {
|
||||
this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
|
||||
this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link);
|
||||
}
|
||||
|
||||
hideAllConnectors() {
|
||||
this.componentList().toArray().forEach(function(component) {
|
||||
if (component instanceof Link)
|
||||
this.hideConnectors(component);
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
enableConnector(type, link) {
|
||||
this.disabledConnectorList().remove(type, link);
|
||||
}
|
||||
|
||||
disableConnector(type, link) {
|
||||
// remove showing connector
|
||||
this.hideConnector(type, link);
|
||||
|
||||
this.disabledConnectorList().add(type, link);
|
||||
}
|
||||
|
||||
connectorEnabled(type, link) {
|
||||
return !this.disabledConnectorList().contains(type, link);
|
||||
}
|
||||
|
||||
enableDrag(component) {
|
||||
this.dragDisabledComponentList().remove(component);
|
||||
}
|
||||
|
||||
disableDrag(component) {
|
||||
this.dragDisabledComponentList().add(component);
|
||||
}
|
||||
|
||||
dragEnabled(component) {
|
||||
return !this.dragDisabledComponentList().contains(component);
|
||||
}
|
||||
|
||||
onstart(x, y, event) {
|
||||
var context = this.dragContext();
|
||||
|
||||
var component = this.componentList().fromPoint(Component, x, y);
|
||||
context.component = component;
|
||||
|
||||
if (typeof this.selectionHandler === 'function')
|
||||
this.selectionHandler(component, event);
|
||||
|
||||
if (!(component instanceof Connector))
|
||||
this.hideAllConnectors();
|
||||
|
||||
if (!component)
|
||||
return;
|
||||
|
||||
var draggable = !this.dragDisabledComponentList().contains(component);
|
||||
context.draggable = draggable;
|
||||
|
||||
if (!draggable)
|
||||
return;
|
||||
|
||||
dom.cancel(event);
|
||||
|
||||
this.toFront(component);
|
||||
|
||||
if (component instanceof Node) {
|
||||
context.x = component.x();
|
||||
context.y = component.y();
|
||||
} else if (component instanceof Link) {
|
||||
context.cx = component.cx();
|
||||
context.cy = component.cy();
|
||||
context.sourceX = component.sourceX();
|
||||
context.sourceY = component.sourceY();
|
||||
context.targetX = component.targetX();
|
||||
context.targetY = component.targetY();
|
||||
context.triple = helper.firstInstance(component.relations(), Triple);
|
||||
|
||||
this.showConnectors(component);
|
||||
} else if (component instanceof Connector) {
|
||||
var linkConnectorRelation = helper.firstInstance(component.relations(), LinkConnectorRelation);
|
||||
|
||||
context.x = x;
|
||||
context.y = y;
|
||||
context.type = linkConnectorRelation.type();
|
||||
context.link = linkConnectorRelation.link();
|
||||
}
|
||||
|
||||
this.fixScrollSize();
|
||||
}
|
||||
|
||||
onmove(dx, dy, event) {
|
||||
var context = this.dragContext();
|
||||
|
||||
var component = context.component;
|
||||
|
||||
if (!component)
|
||||
return;
|
||||
|
||||
if (!context.draggable)
|
||||
return;
|
||||
|
||||
if (component instanceof Node) {
|
||||
var nodeX = context.x + dx;
|
||||
var nodeY = context.y + dy;
|
||||
|
||||
if (typeof component.moveHandler === 'function') {
|
||||
var constrainedPosition = component.moveHandler(nodeX, nodeY);
|
||||
|
||||
if (constrainedPosition && isFinite(constrainedPosition.x) && isFinite(constrainedPosition.y)) {
|
||||
nodeX = constrainedPosition.x;
|
||||
nodeY = constrainedPosition.y;
|
||||
}
|
||||
}
|
||||
|
||||
component.x(nodeX);
|
||||
component.y(nodeY);
|
||||
} else if (component instanceof Link) {
|
||||
var cx = context.cx + dx;
|
||||
var cy = context.cy + dy;
|
||||
var triple = context.triple;
|
||||
var connectedNode = null;
|
||||
|
||||
if (triple) {
|
||||
var sourceNode = triple.sourceNode();
|
||||
var targetNode = triple.targetNode();
|
||||
|
||||
if (sourceNode && !targetNode)
|
||||
connectedNode = sourceNode;
|
||||
else if (!sourceNode && targetNode)
|
||||
connectedNode = targetNode;
|
||||
}
|
||||
|
||||
if (connectedNode) {
|
||||
// only one node connected
|
||||
var x = cx - connectedNode.cx();
|
||||
var y = cy - connectedNode.cy();
|
||||
triple.updateLinkAngle(Math.atan2(y, x));
|
||||
triple.skipNextUpdate(true);
|
||||
} else if (!triple || component.content()) {
|
||||
// not connected or link has content
|
||||
// (except two nodes connected but link has no content)
|
||||
component.cx(cx);
|
||||
component.cy(cy);
|
||||
component.sourceX(context.sourceX + dx);
|
||||
component.sourceY(context.sourceY + dy);
|
||||
component.targetX(context.targetX + dx);
|
||||
component.targetY(context.targetY + dy);
|
||||
}
|
||||
} else if (component instanceof Connector) {
|
||||
var x = context.x + dx;
|
||||
var y = context.y + dy;
|
||||
var type = context.type;
|
||||
var link = context.link;
|
||||
|
||||
var triple = helper.firstInstance(link.relations(), Triple);
|
||||
var connectedNode = triple ? triple[type + 'Node']() : null;
|
||||
var node = this.componentList().fromPoint(Node, x, y);
|
||||
|
||||
if (connectedNode && connectedNode === node) {
|
||||
// already connected (do nothing)
|
||||
return;
|
||||
}
|
||||
|
||||
var anotherType = Cmap.anotherConnectionType(type);
|
||||
var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
|
||||
|
||||
if (connectedNode && connectedNode !== node) {
|
||||
this.disconnect(type, connectedNode, link);
|
||||
connectedNode = null;
|
||||
}
|
||||
|
||||
var needsConnect = !connectedNode && node && anotherSideNode !== node;
|
||||
|
||||
if (needsConnect) {
|
||||
if (anotherSideNode) {
|
||||
var p = triple.connectedPoint(node, anotherSideNode.cx(), anotherSideNode.cy());
|
||||
|
||||
link[type + 'X'](p.x);
|
||||
link[type + 'Y'](p.y);
|
||||
|
||||
triple.update(link);
|
||||
triple.skipNextUpdate(true);
|
||||
}
|
||||
|
||||
this.connect(type, node, link);
|
||||
} else {
|
||||
link[type + 'X'](x);
|
||||
link[type + 'Y'](y);
|
||||
|
||||
if (!anotherSideNode)
|
||||
link.straighten();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onend(dx, dy, event) {
|
||||
var context = this.dragContext();
|
||||
|
||||
var component = context.component;
|
||||
|
||||
if (!component) {
|
||||
this.lastClickComponent = null;
|
||||
this.lastClickTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.draggable) {
|
||||
this.lastClickComponent = null;
|
||||
this.lastClickTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// dx/dy are logical map coordinates; keep the click tolerance at four
|
||||
// physical screen pixels at every zoom level.
|
||||
var clickTolerance = 4 / this.zoomFactor;
|
||||
var isClick = Math.abs(dx) <= clickTolerance && Math.abs(dy) <= clickTolerance;
|
||||
|
||||
if (isClick) {
|
||||
var now = Date.now();
|
||||
var isDoubleClick = (component === this.lastClickComponent &&
|
||||
now - this.lastClickTime <= 500);
|
||||
|
||||
if (isDoubleClick) {
|
||||
this.lastClickComponent = null;
|
||||
this.lastClickTime = 0;
|
||||
|
||||
if (typeof this.activationHandler === 'function')
|
||||
this.activationHandler(component, event);
|
||||
} else {
|
||||
this.lastClickComponent = component;
|
||||
this.lastClickTime = now;
|
||||
}
|
||||
} else {
|
||||
this.lastClickComponent = null;
|
||||
this.lastClickTime = 0;
|
||||
}
|
||||
|
||||
if (component instanceof Node && typeof component.moveEndHandler === 'function')
|
||||
component.moveEndHandler(component.x(), component.y(), event);
|
||||
|
||||
if (component instanceof Connector) {
|
||||
var link = context.link;
|
||||
var triple = helper.firstInstance(link.relations(), Triple);
|
||||
var connectedNode = triple ? triple[context.type + 'Node']() : null;
|
||||
|
||||
if (typeof link.connectionChangeHandler === 'function')
|
||||
link.connectionChangeHandler(context.type, connectedNode, event);
|
||||
}
|
||||
|
||||
this.unfixScrollSize();
|
||||
}
|
||||
|
||||
fixScrollSize() {
|
||||
var element = this.element();
|
||||
|
||||
var clientWidth = dom.clientWidth(element);
|
||||
var clientHeight = dom.clientHeight(element);
|
||||
var scrollWidth = dom.scrollWidth(element);
|
||||
var scrollHeight = dom.scrollHeight(element);
|
||||
|
||||
// check if scrolled
|
||||
if (clientWidth === scrollWidth && clientHeight === scrollHeight)
|
||||
return;
|
||||
|
||||
var translate = 'translate(' + (scrollWidth - 1) + 'px, ' + (scrollHeight - 1) + 'px)';
|
||||
|
||||
dom.css(this.retainerElement(), {
|
||||
msTransform: translate,
|
||||
transform: translate,
|
||||
webkitTransform: translate
|
||||
});
|
||||
}
|
||||
|
||||
unfixScrollSize() {
|
||||
var translate = 'translate(-1px, -1px)';
|
||||
|
||||
dom.css(this.retainerElement(), {
|
||||
msTransform: translate,
|
||||
transform: translate,
|
||||
webkitTransform: translate
|
||||
});
|
||||
}
|
||||
|
||||
style() {
|
||||
return {
|
||||
color: '#333',
|
||||
cursor: 'default',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '14px',
|
||||
height: '100%',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none',
|
||||
overflow: 'visible',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
webkitUserSelect: 'none',
|
||||
width: '100%',
|
||||
zoom: this.zoomFactor
|
||||
};
|
||||
}
|
||||
|
||||
retainerStyle() {
|
||||
return {
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
width: '1px'
|
||||
};
|
||||
}
|
||||
|
||||
redraw() {
|
||||
if (this.disposed)
|
||||
return;
|
||||
|
||||
var rootElement = this.rootElement();
|
||||
|
||||
if (!rootElement) {
|
||||
rootElement = dom.body();
|
||||
dom.css(rootElement, {
|
||||
height: '100vh',
|
||||
margin: '0',
|
||||
width: '100vw'
|
||||
});
|
||||
this.rootElement(rootElement);
|
||||
}
|
||||
|
||||
var previousElement = this.element();
|
||||
var element = dom.el('<div>');
|
||||
element.className = 'rw-cmap-surface';
|
||||
dom.draggable(element, function(x, y, event) {
|
||||
this.onstart(x / this.zoomFactor, y / this.zoomFactor, event);
|
||||
}.bind(this), function(dx, dy, event) {
|
||||
this.onmove(dx / this.zoomFactor, dy / this.zoomFactor, event);
|
||||
}.bind(this), function(dx, dy, event) {
|
||||
this.onend(dx / this.zoomFactor, dy / this.zoomFactor, event);
|
||||
}.bind(this));
|
||||
this.element(element);
|
||||
|
||||
this.componentList().toArray().forEach(function(component) {
|
||||
component.parentElement(element);
|
||||
});
|
||||
|
||||
var retainerElement = dom.el('<div>');
|
||||
dom.css(retainerElement, this.retainerStyle());
|
||||
dom.append(element, retainerElement);
|
||||
this.retainerElement(retainerElement);
|
||||
|
||||
// set initial position of retainer
|
||||
this.unfixScrollSize();
|
||||
|
||||
dom.css(element, this.style());
|
||||
if (previousElement && previousElement.parentNode)
|
||||
previousElement.parentNode.removeChild(previousElement);
|
||||
dom.append(rootElement, element);
|
||||
}
|
||||
}
|
||||
|
||||
Cmap.LINK_Z_INDEX_BASE = 100;
|
||||
Cmap.NODE_Z_INDEX_BASE = 100000;
|
||||
Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
|
||||
Cmap.CONNECTION_TYPE_SOURCE = 'source';
|
||||
Cmap.CONNECTION_TYPE_TARGET = 'target';
|
||||
|
||||
export { Cmap as DrawingSurface };
|
||||
Reference in New Issue
Block a user