refactoring van de cmap structuren bijna compleet

This commit is contained in:
2026-09-03 16:21:21 +02:00
parent 1abc84489f
commit bd1ef6bed0
75 changed files with 2547 additions and 1967 deletions
+116
View File
@@ -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]";
}
+184
View File
@@ -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");
}
}
+204
View File
@@ -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;
}
}
}
+113
View File
@@ -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;
}
}
+48
View File
@@ -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 };
+524
View File
@@ -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 };
+9
View File
@@ -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";
+308
View File
@@ -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 };
+340
View File
@@ -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 };
+615
View File
@@ -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 };