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 = ) => ReturnType>( func: F, waitFor: number = 150, ) => { let timeout: NodeJS.Timeout; return (...args: Parameters) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), waitFor); }; }; export class GraphEditor extends HTMLElement { private menu!: HTMLElement; private menuActions!: HTMLElement; private host!: d3.Selection; 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(this); this.canvas = createCanvas( this.host, createZoom((event: D3ZoomEvent) => 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): 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('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; function createDrag(simulation: Simulation): Drag { return d3 .drag() .filter((event) => event.button === 1) .on( 'start', (event: D3DragEvent, 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, d: Node) => { d.fx = event.x; d.fy = event.y; }) .on('end', (event: D3DragEvent, 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);