You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
556 lines
17 KiB
556 lines
17 KiB
import * as d3 from 'd3';
|
|
import { D3DragEvent, D3ZoomEvent } from 'd3';
|
|
import { Canvas, createCanvas, createSimulation, createZoom, GraphConfiguration, Simulation } from './d3/canvas.ts';
|
|
import { Graph, Link, Node } from './d3/graph.ts';
|
|
import { initMarkers } from './d3/markers.ts';
|
|
import {
|
|
arcMiddlePoint,
|
|
linePath,
|
|
paddedArcPath,
|
|
paddedArcPathPoints,
|
|
paddedLinePath,
|
|
paddedReflexivePath,
|
|
paddedReflexivePathPoints,
|
|
} from './d3/paths.ts';
|
|
|
|
const nodeRadius = 24;
|
|
|
|
const tooltipOpacity = 1;
|
|
const tooltipFadeInTame = 500;
|
|
const tooltipFadeOutTime = 200;
|
|
|
|
const markerBoxSize = 4;
|
|
|
|
export const debounce = <F extends (...args: Parameters<F>) => ReturnType<F>>(
|
|
func: F,
|
|
waitFor: number = 150,
|
|
) => {
|
|
let timeout: NodeJS.Timeout;
|
|
|
|
return (...args: Parameters<F>) => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func(...args), waitFor);
|
|
};
|
|
};
|
|
|
|
export class GraphEditor extends HTMLElement {
|
|
private menu!: HTMLElement;
|
|
private menuActions!: HTMLElement;
|
|
private host!: d3.Selection<HTMLElement, undefined, null, undefined>;
|
|
private simulation!: Simulation;
|
|
private canvas!: Canvas;
|
|
graph = new Graph();
|
|
private node!: NodeSelection;
|
|
private ids!: NodeSelection;
|
|
private link!: LinkSelection;
|
|
private labels!: LinkSelection;
|
|
private triangles!: NodeSelection;
|
|
private drag!: Drag;
|
|
private draggableLink!: DraggableLink;
|
|
private draggableLinkSourceNode: Node | null = null;
|
|
private draggableLinkTargetNode: Node | null = null;
|
|
private draggableLinkEnd: [number, number] | null = null;
|
|
private xOffset: number = 0;
|
|
private yOffset: number = 0;
|
|
private scale: number = 1;
|
|
readonly: boolean = false;
|
|
private debounced = debounce(() => this.resetView());
|
|
private config: GraphConfiguration = {
|
|
nodeRadius,
|
|
|
|
tooltipOpacity,
|
|
tooltipFadeInTame,
|
|
tooltipFadeOutTime,
|
|
|
|
markerBoxSize,
|
|
markerPadding: nodeRadius + 2 * markerBoxSize,
|
|
markerRef: markerBoxSize / 2,
|
|
arrowPoints: [
|
|
[0, 0],
|
|
[0, markerBoxSize],
|
|
[markerBoxSize, markerBoxSize / 2],
|
|
],
|
|
markerPath: [0, 0, markerBoxSize, markerBoxSize].join(','),
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
connectedCallback(): void {
|
|
this.host = d3.select<HTMLElement, undefined>(this);
|
|
this.canvas = createCanvas(
|
|
this.host,
|
|
createZoom((event: D3ZoomEvent<any, any>) => this.onZoom(event)),
|
|
(event) => this.onPointerMoved(event),
|
|
(event) => this.onPointerUp(event),
|
|
(event) => {
|
|
if (this.readonly) {
|
|
return;
|
|
}
|
|
const [x, y] = d3.pointer(event, this.canvas!.node());
|
|
this.createNode(x, y);
|
|
this.fireOnChange();
|
|
},
|
|
);
|
|
initMarkers(this.canvas, this.config);
|
|
this.draggableLink = createDraggableLink(this.canvas);
|
|
this.node = createNode(this.canvas);
|
|
this.ids = createIds(this.canvas);
|
|
this.link = createLink(this.canvas);
|
|
this.labels = createLabel(this.canvas);
|
|
this.triangles = createTriangle(this.canvas);
|
|
this.simulation = createSimulation(
|
|
this.graph,
|
|
this.config,
|
|
this.clientWidth,
|
|
this.clientHeight,
|
|
() => this.onTick(),
|
|
);
|
|
this.drag = createDrag(this.simulation);
|
|
this.menu = document.createElement('nav');
|
|
this.menu.classList.add('context-menu');
|
|
this.menuActions = document.createElement('ul');
|
|
this.menu.appendChild(this.menuActions);
|
|
this.menu.setAttribute('hidden', 'hidden');
|
|
this.appendChild(this.menu);
|
|
this.addEventListener('click', this.closeMenu);
|
|
this.restart();
|
|
window.addEventListener('resize', this.debounced);
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
this.host.remove();
|
|
this.removeEventListener('click', this.closeMenu);
|
|
window.removeEventListener('resize', this.debounced);
|
|
}
|
|
|
|
private createNode(x: number, y: number): void {
|
|
this.graph.createNode(x, y);
|
|
this.restart();
|
|
}
|
|
|
|
private onZoom(event: D3ZoomEvent<any, any>): void {
|
|
this.xOffset = event.transform.x;
|
|
this.yOffset = event.transform.y;
|
|
this.scale = event.transform.k;
|
|
this.canvas.attr(
|
|
'transform',
|
|
`translate(${this.xOffset},${this.yOffset})scale(${this.scale})`,
|
|
);
|
|
}
|
|
|
|
private onPointerMoved(event: PointerEvent): void {
|
|
event.preventDefault();
|
|
if (this.draggableLinkSourceNode !== undefined) {
|
|
const pointer = d3.pointers(event, this.host.node())[0];
|
|
const point: [number, number] = [
|
|
(pointer[0] - this.xOffset) / this.scale,
|
|
(pointer[1] - this.yOffset) / this.scale,
|
|
];
|
|
if (event.pointerType === 'touch') {
|
|
point[1] = point[1] - 4 * this.config.nodeRadius;
|
|
// PointerEvents are not firing correctly for touch input.
|
|
// So for TouchEvents, we have to manually detect Nodes within range and set them as the current target node.
|
|
this.draggableLinkTargetNode = this.graph.nodes.find(
|
|
(node) =>
|
|
Math.sqrt(
|
|
Math.pow(node.x! - point[0], 2)
|
|
+ Math.pow(node.y! - point[1], 2),
|
|
) < this.config.nodeRadius,
|
|
) ?? null;
|
|
}
|
|
this.draggableLinkEnd = point;
|
|
this.updateDraggableLinkPath();
|
|
}
|
|
}
|
|
|
|
private onPointerUp(event: PointerEvent): void {
|
|
const source = this.draggableLinkSourceNode;
|
|
const target = this.draggableLinkTargetNode;
|
|
this.resetDraggableLink();
|
|
if (source === null || target === null) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (this.graph.createLink(source.id, target.id) !== null) {
|
|
this.fireOnChange();
|
|
this.restart();
|
|
}
|
|
}
|
|
|
|
restart(): void {
|
|
this.link = this.link.data(
|
|
this.graph.links,
|
|
(d: Link) => `${d.source.id}-${d.target.id}`,
|
|
).join((enter) => {
|
|
const linkGroup = enter.append('g');
|
|
linkGroup
|
|
.append('path')
|
|
.classed('link', true)
|
|
.style('marker-end', 'url(#link-arrow)');
|
|
if (!this.readonly) {
|
|
linkGroup
|
|
.append('path')
|
|
.classed('clickbox', true)
|
|
.on('click', (_: MouseEvent, d: Link) => {
|
|
const transition = prompt('Transition');
|
|
if (transition !== null) {
|
|
d.transition = transition;
|
|
this.fireOnChange();
|
|
this.restart();
|
|
}
|
|
})
|
|
.on('contextmenu', (event: MouseEvent, d: Link) => {
|
|
event.preventDefault();
|
|
this.graph.removeLink(d);
|
|
this.fireOnChange();
|
|
this.restart();
|
|
});
|
|
}
|
|
return linkGroup;
|
|
});
|
|
|
|
this.labels = this.labels.data(this.graph.links).join(
|
|
(enter) => {
|
|
const text = enter.append('text');
|
|
text
|
|
.classed('label', true)
|
|
.attr('fill', 'currentColor')
|
|
.style('pointer-events', 'none')
|
|
.text((d) => d.transition);
|
|
return text;
|
|
},
|
|
(update) => {
|
|
return update.text((d) => d.transition);
|
|
},
|
|
);
|
|
|
|
this.node = this.node.data(this.graph.nodes, (d) => d.id).join(
|
|
(enter) => {
|
|
const nodeGroup = enter
|
|
.append('g')
|
|
.call(this.drag);
|
|
|
|
if (!this.readonly) {
|
|
nodeGroup.on('contextmenu', (event: MouseEvent, d: Node) => {
|
|
this.moveMenu(event, [
|
|
new MenuAction('Start', () => d.start, () => {
|
|
d.start = !d.start;
|
|
this.fireOnChange();
|
|
this.restart();
|
|
}),
|
|
new MenuAction('Accepting', () => d.accepting, () => {
|
|
d.accepting = !d.accepting;
|
|
this.fireOnChange();
|
|
this.restart();
|
|
}),
|
|
new MenuAction('Delete', () => false, () => {
|
|
this.graph.removeNode(d);
|
|
this.fireOnChange();
|
|
this.resetDraggableLink();
|
|
this.restart();
|
|
}),
|
|
]);
|
|
});
|
|
}
|
|
const circle = nodeGroup
|
|
.append('circle')
|
|
.classed('node', true)
|
|
.classed('accepting', (d) => d.accepting)
|
|
.classed('active', (d) => d.active)
|
|
.attr('r', this.config.nodeRadius);
|
|
if (!this.readonly) {
|
|
circle.on(
|
|
'mouseenter',
|
|
(_, d: Node) => (this.draggableLinkTargetNode = d),
|
|
)
|
|
.on('mouseout', () => (this.draggableLinkTargetNode = null))
|
|
.on('pointerdown', (event: PointerEvent, d: Node) => {
|
|
this.onPointerDown(event, d);
|
|
})
|
|
.on('pointerup', (event: PointerEvent) => {
|
|
this.onPointerUp(event);
|
|
});
|
|
}
|
|
return nodeGroup;
|
|
},
|
|
(update) => {
|
|
update.select('circle').classed('accepting', (d) => d.accepting).classed('active', (d) => d.active);
|
|
return update;
|
|
},
|
|
);
|
|
|
|
this.ids = this.ids.data(this.graph.nodes, (d) => d.id).join(
|
|
(enter) =>
|
|
enter.append('text').attr('text-anchor', 'middle').attr('dy', '.35em').style('pointer-events', 'none').text((
|
|
d,
|
|
) => d.id + 1),
|
|
(update) => update.text((d) => d.id + 1),
|
|
);
|
|
|
|
const startNodes = this.graph.nodes.filter((n) => n.start);
|
|
this.triangles = this.triangles.data(startNodes).join(
|
|
(enter) =>
|
|
enter.append('path').classed('triangle', true).classed('start-arrow', true).classed('active', (d) => d.active),
|
|
(update) => update.classed('active', (d) => d.active),
|
|
);
|
|
this.simulation.nodes(this.graph.nodes);
|
|
this.simulation.alpha(0.5).restart();
|
|
}
|
|
|
|
private onPointerDown(event: PointerEvent, node: Node): void {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const coordinates: [number, number] = [node.x!, node.y!];
|
|
this.draggableLinkEnd = coordinates;
|
|
this.draggableLinkSourceNode = node;
|
|
this.draggableLink.style('marker-end', 'url(#draggable-link-arrow)')
|
|
.classed('hidden', false)
|
|
.attr('d', linePath(coordinates, coordinates));
|
|
this.restart();
|
|
}
|
|
|
|
private onTick(): void {
|
|
this.node.attr('transform', (d) => `translate(${d.x},${d.y})`);
|
|
|
|
this.link.selectAll<SVGPathElement, Link>('path').attr(
|
|
'd',
|
|
(d: Link) => {
|
|
if (d.source.id === d.target.id) {
|
|
return paddedReflexivePath(
|
|
d.source,
|
|
[this.clientWidth / 2, this.clientHeight / 2],
|
|
this.config,
|
|
);
|
|
} else if (this.isBidirectional(d.source, d.target)) {
|
|
return paddedArcPath(d.source, d.target, this.config);
|
|
} else {
|
|
return paddedLinePath(d.source, d.target, this.config);
|
|
}
|
|
},
|
|
);
|
|
this.labels.attr(
|
|
'transform',
|
|
(d: Link) => {
|
|
const x = (d.source.x! + d.target.x!) / 2;
|
|
const y = (d.source.y! + d.target.y!) / 2;
|
|
if (d.source.id === d.target.id) {
|
|
const [start, end] = paddedReflexivePathPoints(
|
|
d.source,
|
|
[this.clientWidth / 2, this.clientHeight / 2],
|
|
this.config,
|
|
);
|
|
// Places the text at the middle of the arc.
|
|
const [middleX, middleY] = arcMiddlePoint(start, end, this.config);
|
|
return `translate(${middleX},${middleY})`;
|
|
}
|
|
if (this.isBidirectional(d.source, d.target)) {
|
|
const [start, end] = paddedArcPathPoints(d.source, d.target, this.config);
|
|
const [middleX, middleY] = arcMiddlePoint(start, end, this.config);
|
|
return `translate(${middleX},${middleY})`;
|
|
}
|
|
return `translate(${x},${y})`;
|
|
},
|
|
);
|
|
this.ids.attr('transform', (d) => `translate(${d.x},${d.y})`);
|
|
this.triangles.attr(
|
|
'd',
|
|
(d: Node) => {
|
|
return `M ${d.x! - 2 * this.config.nodeRadius},${d.y! - this.config.nodeRadius} L ${
|
|
d.x! + 5 - this.config.nodeRadius
|
|
},${d.y!} L ${d.x! - 2 * this.config.nodeRadius},${d.y! + this.config.nodeRadius} Z`;
|
|
},
|
|
);
|
|
this.updateDraggableLinkPath();
|
|
}
|
|
|
|
resetView(): void {
|
|
this.simulation!.stop();
|
|
this.host.selectChildren().remove();
|
|
this.xOffset = 0;
|
|
this.yOffset = 0;
|
|
this.scale = 1;
|
|
this.canvas = undefined!;
|
|
this.draggableLink = undefined!;
|
|
this.link = undefined!;
|
|
this.node = undefined!;
|
|
this.simulation = undefined!;
|
|
this.resetDraggableLink();
|
|
this.connectedCallback();
|
|
}
|
|
|
|
resetDraggableLink(): void {
|
|
this.draggableLink?.classed('hidden', true).style('marker-end', '');
|
|
this.draggableLinkSourceNode = null;
|
|
this.draggableLinkTargetNode = null;
|
|
this.draggableLinkEnd = null;
|
|
}
|
|
|
|
updateDraggableLinkPath(): void {
|
|
const source = this.draggableLinkSourceNode;
|
|
if (source !== null) {
|
|
const target = this.draggableLinkTargetNode;
|
|
if (target !== null) {
|
|
this.draggableLink.attr('d', () => {
|
|
if (source.id === target.id) {
|
|
return paddedReflexivePath(
|
|
source,
|
|
[this.clientWidth / 2, this.clientHeight / 2],
|
|
this.config,
|
|
);
|
|
} else if (this.isBidirectional(source, target)) {
|
|
return paddedLinePath(source, target, this.config);
|
|
} else {
|
|
return paddedArcPath(source, target, this.config);
|
|
}
|
|
});
|
|
} else if (this.draggableLinkEnd !== null) {
|
|
const from: [number, number] = [source.x!, source.y!];
|
|
this.draggableLink.attr('d', linePath(from, this.draggableLinkEnd));
|
|
}
|
|
}
|
|
}
|
|
|
|
isBidirectional(source: Node, target: Node): boolean {
|
|
return (
|
|
source.id !== target.id
|
|
&& this.graph.links.some(
|
|
(l) => l.target.id === source.id && l.source.id === target.id,
|
|
)
|
|
&& this.graph.links.some(
|
|
(l) => l.target.id === target.id && l.source.id === source.id,
|
|
)
|
|
);
|
|
}
|
|
|
|
private moveMenu(event: MouseEvent, actions: MenuAction[]) {
|
|
event.preventDefault();
|
|
while (this.menuActions.firstChild) {
|
|
this.menuActions.removeChild(this.menuActions.firstChild);
|
|
}
|
|
for (const action of actions) {
|
|
const item = document.createElement('li');
|
|
item.tabIndex = 0;
|
|
item.textContent = action.label;
|
|
if (action.checkmark()) {
|
|
item.classList.add('checked');
|
|
}
|
|
const handleEvent = () => {
|
|
action.action();
|
|
this.closeMenu(event);
|
|
};
|
|
item.onclick = handleEvent;
|
|
item.onkeydown = (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
handleEvent();
|
|
}
|
|
};
|
|
this.menuActions.appendChild(item);
|
|
}
|
|
this.menu.style.left = `${event.pageX}px`;
|
|
this.menu.style.top = `${event.pageY}px`;
|
|
this.menu.removeAttribute('hidden');
|
|
}
|
|
|
|
private closeMenu(e: MouseEvent) {
|
|
e.preventDefault();
|
|
this.menu.setAttribute('hidden', 'hidden');
|
|
}
|
|
|
|
private fireOnChange() {
|
|
this.dispatchEvent(new Event('change'));
|
|
}
|
|
}
|
|
|
|
type NodeSelection = d3.Selection<
|
|
SVGGElement,
|
|
Node,
|
|
SVGGElement,
|
|
undefined
|
|
>;
|
|
|
|
function createNode(canvas: Canvas): NodeSelection {
|
|
return canvas.append('g').classed('nodes', true).selectAll('circle');
|
|
}
|
|
|
|
function createIds(canvas: Canvas): NodeSelection {
|
|
return canvas.append('g').classed('ids', true).selectAll('text');
|
|
}
|
|
|
|
type Drag = d3.DragBehavior<SVGGElement, Node, Node>;
|
|
|
|
function createDrag(simulation: Simulation): Drag {
|
|
return d3
|
|
.drag<SVGGElement, Node, Node>()
|
|
.filter((event) => event.button === 1)
|
|
.on(
|
|
'start',
|
|
(event: D3DragEvent<SVGCircleElement, Node, Node>, d: Node) => {
|
|
event.sourceEvent.preventDefault();
|
|
if (event.active === 0) {
|
|
simulation.alphaTarget(0.5).restart();
|
|
}
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
},
|
|
)
|
|
.on('drag', (event: D3DragEvent<SVGCircleElement, Node, Node>, d: Node) => {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
})
|
|
.on('end', (event: D3DragEvent<SVGCircleElement, Node, Node>, d: Node) => {
|
|
if (event.active === 0) {
|
|
simulation.alphaTarget(0);
|
|
}
|
|
d.fx = undefined;
|
|
d.fy = undefined;
|
|
});
|
|
}
|
|
|
|
type DraggableLink = d3.Selection<
|
|
SVGPathElement,
|
|
undefined,
|
|
null,
|
|
undefined
|
|
>;
|
|
|
|
function createDraggableLink(canvas: Canvas): DraggableLink {
|
|
return canvas
|
|
.append('path')
|
|
.classed('link draggable hidden', true)
|
|
.attr('d', 'M0,0L0,0');
|
|
}
|
|
|
|
type LinkSelection = d3.Selection<
|
|
SVGGElement,
|
|
Link,
|
|
SVGGElement,
|
|
undefined
|
|
>;
|
|
|
|
function createLink(canvas: Canvas): LinkSelection {
|
|
return canvas.append('g').classed('links', true).selectAll('path');
|
|
}
|
|
|
|
function createLabel(canvas: Canvas): LinkSelection {
|
|
return canvas.append('g').classed('labels', true).selectAll('.label');
|
|
}
|
|
|
|
function createTriangle(canvas: Canvas): NodeSelection {
|
|
return canvas.append('g').classed('triangles', true).selectAll('.label');
|
|
}
|
|
|
|
class MenuAction {
|
|
constructor(
|
|
public readonly label: string,
|
|
public readonly checkmark: () => boolean,
|
|
public readonly action: () => void,
|
|
) {
|
|
}
|
|
}
|
|
|
|
customElements.define('graph-editor', GraphEditor);
|