Migrate to a D3-based graph renderer
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
parent
b68f84bc98
commit
be1cd8b39f
@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
root: true,
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
};
|
@ -0,0 +1,527 @@
|
||||
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 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);
|
||||
},
|
||||
);
|
||||
initMarkers(this.canvas, this.config);
|
||||
this.draggableLink = createDraggableLink(this.canvas);
|
||||
this.node = createNode(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.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.restart();
|
||||
}
|
||||
})
|
||||
.on('contextmenu', (event: MouseEvent, d: Link) => {
|
||||
event.preventDefault();
|
||||
this.graph.removeLink(d);
|
||||
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')
|
||||
.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.restart();
|
||||
}),
|
||||
new MenuAction('Accepting', () => d.accepting, () => {
|
||||
d.accepting = !d.accepting;
|
||||
this.restart();
|
||||
}),
|
||||
new MenuAction('Delete', () => false, () => {
|
||||
this.graph.removeNode(d);
|
||||
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;
|
||||
},
|
||||
);
|
||||
const startNodes = this.graph.nodes.filter((n) => n.start);
|
||||
this.triangles = this.triangles.data(startNodes).join(
|
||||
(enter) => {
|
||||
return enter.append('path').classed('triangle', true).classed('start-arrow', true);
|
||||
},
|
||||
);
|
||||
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.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');
|
||||
}
|
||||
}
|
||||
|
||||
type NodeSelection = d3.Selection<
|
||||
SVGGElement,
|
||||
Node,
|
||||
SVGGElement,
|
||||
undefined
|
||||
>;
|
||||
|
||||
function createNode(canvas: Canvas): NodeSelection {
|
||||
return canvas.append('g').classed('nodes', true).selectAll('circle');
|
||||
}
|
||||
|
||||
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);
|
@ -0,0 +1,94 @@
|
||||
import * as d3 from 'd3';
|
||||
import { D3ZoomEvent } from 'd3';
|
||||
import { Graph, Link, Node } from './graph.ts';
|
||||
|
||||
export interface GraphConfiguration {
|
||||
nodeRadius: number;
|
||||
|
||||
tooltipOpacity: number;
|
||||
tooltipFadeInTame: number;
|
||||
tooltipFadeOutTime: number;
|
||||
|
||||
markerBoxSize: number;
|
||||
markerPadding: number;
|
||||
markerRef: number;
|
||||
arrowPoints: [number, number][];
|
||||
markerPath: string;
|
||||
}
|
||||
|
||||
export type GraphHost = d3.Selection<
|
||||
HTMLElement,
|
||||
undefined,
|
||||
null,
|
||||
undefined
|
||||
>;
|
||||
|
||||
export type Canvas = d3.Selection<
|
||||
SVGGElement,
|
||||
undefined,
|
||||
null,
|
||||
undefined
|
||||
>;
|
||||
|
||||
export type Simulation = d3.Simulation<Node, Link>;
|
||||
export type Zoom = d3.ZoomBehavior<SVGSVGElement, undefined>;
|
||||
|
||||
export function createCanvas(
|
||||
host: GraphHost,
|
||||
zoom: Zoom,
|
||||
onPointerMoved: (event: PointerEvent) => void,
|
||||
onPointerUp: (event: PointerEvent) => void,
|
||||
onDoubleClick: (event: PointerEvent) => void,
|
||||
): Canvas {
|
||||
return host
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%')
|
||||
.on('pointermove', (event: PointerEvent) => onPointerMoved(event))
|
||||
.on('pointerup', (event: PointerEvent) => onPointerUp(event))
|
||||
.on('contextmenu', (event: MouseEvent) => terminate(event))
|
||||
.on('dblclick', (event: PointerEvent) => onDoubleClick(event))
|
||||
.call(zoom)
|
||||
.on('dblclick.zoom', null)
|
||||
.append('g');
|
||||
}
|
||||
|
||||
export function createZoom(
|
||||
onZoom: (event: D3ZoomEvent<any, any>) => void,
|
||||
): Zoom {
|
||||
return d3
|
||||
.zoom<SVGSVGElement, undefined>()
|
||||
.scaleExtent([0.1, 10])
|
||||
.filter((event) => event.button === 0 || event.touches?.length >= 2)
|
||||
.on('zoom', (event) => onZoom(event));
|
||||
}
|
||||
|
||||
export function createSimulation(
|
||||
graph: Graph,
|
||||
config: GraphConfiguration,
|
||||
width: number,
|
||||
height: number,
|
||||
onTick: () => void,
|
||||
): Simulation {
|
||||
return d3
|
||||
.forceSimulation<Node, Link>(graph!.nodes)
|
||||
.on('tick', () => onTick())
|
||||
.force('center', d3.forceCenter<Node>(width / 2, height / 2))
|
||||
.force('charge', d3.forceManyBody<Node>().strength(-500))
|
||||
.force('collision', d3.forceCollide<Node>().radius(config.nodeRadius))
|
||||
.force(
|
||||
'link',
|
||||
d3
|
||||
.forceLink<Node, Link>()
|
||||
.links(graph!.links)
|
||||
.id((d: Node) => d.id)
|
||||
.distance(config.nodeRadius * 10),
|
||||
)
|
||||
.force('x', d3.forceX<Node>(width / 2).strength(0.05))
|
||||
.force('y', d3.forceY<Node>(height / 2).strength(0.05));
|
||||
}
|
||||
|
||||
function terminate(event: Event): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3';
|
||||
|
||||
export class Graph {
|
||||
private idCounter = 0;
|
||||
public readonly nodes: Node[] = [];
|
||||
public readonly links: Link[] = [];
|
||||
|
||||
public createNode(x?: number, y?: number): Node {
|
||||
const node = new Node(this.idCounter++, x, y);
|
||||
this.nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
public createLink(sourceId: number, targetId: number, label: string = 'a'): Link | null {
|
||||
const existingLink = this.links.find(
|
||||
(l) => l.source.id === sourceId && l.target.id === targetId,
|
||||
);
|
||||
if (typeof existingLink !== 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = this.nodes.find((node) => node.id === sourceId);
|
||||
if (typeof source === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = this.nodes.find((node) => node.id === targetId);
|
||||
if (typeof target === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const link = new Link(source, target, label);
|
||||
this.links.push(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
public removeNode(node: Node): [Node, Link[]] | undefined {
|
||||
const nodeIndex = this.nodes.findIndex((n) => n.id === node.id);
|
||||
if (nodeIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.nodes.splice(nodeIndex, 1);
|
||||
const attachedLinks = this.links.filter(
|
||||
(link) => link.source.id === node.id || link.target.id === node.id,
|
||||
);
|
||||
attachedLinks.forEach((link) => {
|
||||
const linkIndex = this.links.indexOf(link, 0);
|
||||
this.links.splice(linkIndex, 1);
|
||||
});
|
||||
|
||||
return [node, attachedLinks];
|
||||
}
|
||||
|
||||
public removeLink(link: Link): Link | undefined {
|
||||
const linkIndex = this.links.findIndex(
|
||||
(l) => l.source.id === link.source.id && l.target.id === link.target.id,
|
||||
);
|
||||
if (linkIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.links.splice(linkIndex, 1);
|
||||
return link;
|
||||
}
|
||||
|
||||
forEach(consumer: (node: Node, index: number, array: Node[]) => void): void {
|
||||
this.nodes.forEach(consumer);
|
||||
}
|
||||
}
|
||||
|
||||
export class Node implements SimulationNodeDatum {
|
||||
public constructor(
|
||||
public readonly id: number,
|
||||
public x?: number,
|
||||
public y?: number,
|
||||
public fx?: number,
|
||||
public fy?: number,
|
||||
public start: boolean = false,
|
||||
public accepting: boolean = false,
|
||||
public active: boolean = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Link implements SimulationLinkDatum<Node> {
|
||||
public constructor(
|
||||
public readonly source: Node,
|
||||
public readonly target: Node,
|
||||
public transition: string,
|
||||
) {}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import * as d3 from 'd3';
|
||||
import { GraphConfiguration } from './canvas.ts';
|
||||
|
||||
export function initMarkers(
|
||||
canvas: d3.Selection<SVGGElement, undefined, null, undefined>,
|
||||
config: GraphConfiguration,
|
||||
): void {
|
||||
createLinkMarker(canvas, config, 'link-arrow', 'arrow');
|
||||
createLinkMarker(canvas, config, 'draggable-link-arrow', 'arrow draggable');
|
||||
}
|
||||
|
||||
function createLinkMarker(
|
||||
canvas: d3.Selection<SVGGElement, undefined, null, undefined>,
|
||||
config: GraphConfiguration,
|
||||
id: string,
|
||||
classes: string,
|
||||
): void {
|
||||
canvas
|
||||
.append('defs')
|
||||
.append('marker')
|
||||
.attr('id', id)
|
||||
.attr('viewBox', config.markerPath)
|
||||
.attr('refX', config.markerRef)
|
||||
.attr('refY', config.markerRef)
|
||||
.attr('markerWidth', config.markerBoxSize)
|
||||
.attr('markerHeight', config.markerBoxSize)
|
||||
.attr('orient', 'auto')
|
||||
.classed(classes, true)
|
||||
.append('path')
|
||||
.attr('d', `${d3.line()(config.arrowPoints)}`);
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
import Matrix from 'ml-matrix';
|
||||
import { GraphConfiguration } from './canvas.ts';
|
||||
import { Node } from './graph.ts';
|
||||
|
||||
/**
|
||||
* Creates the path of a straight line between the edges of two nodes.
|
||||
*
|
||||
* @param source The source Node.
|
||||
* @param target The target Node.
|
||||
* @param graphConfiguration Visual configuration.
|
||||
*/
|
||||
export function paddedLinePath(
|
||||
source: Node,
|
||||
target: Node,
|
||||
graphConfiguration: GraphConfiguration,
|
||||
): string {
|
||||
const deltaX = target.x! - source.x!;
|
||||
const deltaY = target.y! - source.y!;
|
||||
const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
const normX = deltaX / dist;
|
||||
const normY = deltaY / dist;
|
||||
const sourceX = source.x! + (graphConfiguration.nodeRadius - 1) * normX;
|
||||
const sourceY = source.y! + (graphConfiguration.nodeRadius - 1) * normY;
|
||||
const targetX = target.x! - graphConfiguration.markerPadding * normX;
|
||||
const targetY = target.y! - graphConfiguration.markerPadding * normY;
|
||||
return `M${sourceX},${sourceY}
|
||||
L${targetX},${targetY}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the path of an arc line between the edges of two nodes.
|
||||
*
|
||||
* @param source The source Node.
|
||||
* @param target The target Node.
|
||||
* @param graphConfiguration Visual configuration.
|
||||
*/
|
||||
export function paddedArcPathPoints(
|
||||
source: Node,
|
||||
target: Node,
|
||||
graphConfiguration: GraphConfiguration,
|
||||
): [Matrix, Matrix, number] {
|
||||
const s = new Matrix([[source.x!, source.y!]]);
|
||||
const t = new Matrix([[target.x!, target.y!]]);
|
||||
const diff = Matrix.subtract(t, s);
|
||||
const dist = diff.norm('frobenius');
|
||||
const norm = diff.divide(dist);
|
||||
const rotation = degreesToRadians(10);
|
||||
const start = rotate(norm, -rotation)
|
||||
.multiply(graphConfiguration.nodeRadius - 1)
|
||||
.add(s);
|
||||
const endNorm = Matrix.multiply(norm, -1);
|
||||
const end = rotate(endNorm, rotation)
|
||||
.multiply(graphConfiguration.nodeRadius)
|
||||
.add(t)
|
||||
.add(
|
||||
rotate(endNorm, rotation).multiply(2 * graphConfiguration.markerBoxSize),
|
||||
);
|
||||
return [start, end, 1.2 * dist];
|
||||
}
|
||||
|
||||
export function paddedArcPath(
|
||||
source: Node,
|
||||
target: Node,
|
||||
graphConfiguration: GraphConfiguration,
|
||||
): string {
|
||||
const [start, end, arcRadius] = paddedArcPathPoints(source, target, graphConfiguration);
|
||||
return `M${start.get(0, 0)},${start.get(0, 1)}
|
||||
A${arcRadius},${arcRadius},0,0,1,${end.get(0, 0)},${end.get(0, 1)}`;
|
||||
}
|
||||
|
||||
export function arcMiddlePoint(
|
||||
start: Matrix,
|
||||
end: Matrix,
|
||||
{ nodeRadius }: GraphConfiguration,
|
||||
): [number, number] {
|
||||
const center = Matrix.add(start, end).divide(2);
|
||||
const angle = Math.atan2(end.get(0, 1) - start.get(0, 1), end.get(0, 0) - start.get(0, 0));
|
||||
const middleX = center.get(0, 0) + nodeRadius * Math.cos(angle + Math.PI / 2);
|
||||
const middleY = center.get(0, 1) + nodeRadius * Math.sin(angle + Math.PI / 2);
|
||||
return [middleX, middleY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the path of a reflexive line of a node.
|
||||
* It will be always be directed away from the center.
|
||||
*
|
||||
* @param node The Node.
|
||||
* @param center The center point of the graph.
|
||||
* @param graphConfiguration Visual configuration.
|
||||
*/
|
||||
export function paddedReflexivePathPoints(
|
||||
node: Node,
|
||||
center: [number, number],
|
||||
graphConfiguration: GraphConfiguration,
|
||||
): [Matrix, Matrix] {
|
||||
const n = new Matrix([[node.x!, node.y!]]);
|
||||
const c = new Matrix([center]);
|
||||
if (n.get(0, 0) === c.get(0, 0) && n.get(0, 1) === c.get(0, 1)) {
|
||||
c.add([[0, 1]]); // Nodes at the exact center of the Graph should have their reflexive edge above them.
|
||||
}
|
||||
const diff = Matrix.subtract(n, c);
|
||||
const norm = diff.divide(diff.norm('frobenius'));
|
||||
const rotation = degreesToRadians(40);
|
||||
const start = rotate(norm, rotation)
|
||||
.multiply(graphConfiguration.nodeRadius - 1)
|
||||
.add(n);
|
||||
const end = rotate(norm, -rotation)
|
||||
.multiply(graphConfiguration.nodeRadius)
|
||||
.add(n)
|
||||
.add(rotate(norm, -rotation).multiply(2 * graphConfiguration.markerBoxSize));
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
export function paddedReflexivePath(
|
||||
node: Node,
|
||||
center: [number, number],
|
||||
graphConfiguration: GraphConfiguration,
|
||||
): string {
|
||||
const [start, end] = paddedReflexivePathPoints(node, center, graphConfiguration);
|
||||
return `M${start.get(0, 0)},${start.get(0, 1)}
|
||||
A${graphConfiguration.nodeRadius},${graphConfiguration.nodeRadius},0,1,0,${end.get(0, 0)},${end.get(0, 1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a straight path between two points.
|
||||
*
|
||||
* @param from Source coordinates.
|
||||
* @param to Target coordinates.
|
||||
*/
|
||||
export function linePath(from: [number, number], to: [number, number]): string {
|
||||
return `M${from[0]},${from[1]}
|
||||
L${to[0]},${to[1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the radian value for the given degrees.
|
||||
*
|
||||
* @param degrees The degrees.
|
||||
*/
|
||||
export function degreesToRadians(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a vector by the given radians around the origin.
|
||||
*
|
||||
* @param vector The vector to be rotated.
|
||||
* @param radians The radians to rotate the vector by.
|
||||
*/
|
||||
export function rotate(vector: Matrix, radians: number): Matrix {
|
||||
const x = vector.get(0, 0);
|
||||
const y = vector.get(0, 1);
|
||||
return new Matrix([
|
||||
[
|
||||
x * Math.cos(radians) - y * Math.sin(radians),
|
||||
x * Math.sin(radians) + y * Math.cos(radians),
|
||||
],
|
||||
]);
|
||||
}
|
Loading…
Reference in new issue