Migrate to a D3-based graph renderer
continuous-integration/drone/push Build is passing Details

main
Clément FRÉVILLE 5 months ago
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',
},
};

@ -4,14 +4,6 @@ finite-state-automaton
Use
---
### No build-tool
While this project is written with the Node.Js tooling (Vite+MermaidJS), a simple transformation can be done to make it work in a browser.
```sh
sed -i "s|from 'mermaid'|from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs'|" src/fsm.js
```
### Build-tool
Run your favorite package manager (npm, yarn, pnpm, bun, etc.) to install the dependencies and build the Vite application:

@ -21,8 +21,7 @@
<div id="input-buttons"></div>
<button id="clear-button">Clear</button>
</div>
<pre id="pen"></pre>
<pre id="state-graph"></pre>
<div id="state-graph"></div>
</div>
<script type="module" src="src/main.js"></script>
</body>

@ -9,10 +9,16 @@
"preview": "vite preview"
},
"devDependencies": {
"@types/d3": "^7.4.3",
"@types/node": "^20.11.16",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"eslint": "^8.56.0",
"typescript": "^5.3.3",
"vite": "^5.0.8"
"vite": "^5.0.12"
},
"dependencies": {
"mermaid": "^10.6.1"
"d3": "^7.8.5",
"ml-matrix": "^6.11.0"
}
}

@ -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),
],
]);
}

@ -1,4 +1,5 @@
import mermaid from 'mermaid';
import { Graph } from './editor/d3/graph.ts';
import { GraphEditor } from './editor/GraphEditor.ts';
const IS_VALID = 'is-valid';
const IS_INVALID = 'is-invalid';
@ -8,11 +9,6 @@ const buttons = /** @type {HTMLDivElement} */ (document.getElementById('input-bu
const clearButton = /** @type {HTMLButtonElement} */ (document.getElementById('clear-button'));
const light = /** @type {HTMLDivElement} */ (document.getElementById('light'));
mermaid.initialize({
theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default',
startOnLoad: false,
});
/**
* @param {import('./examples.js').State[]} states
*/
@ -20,25 +16,28 @@ export async function selectAutomaton(states) {
let state = 0;
let builder = '';
// Build the mermaid graph definition
let graphDefinition = 'stateDiagram-v2\n classDef acceptingnode font-weight:bold,stroke-width:2px,stroke:yellow';
for (let i = 0; i < states.length; ++i) {
graphDefinition += `\n s${i} : ${i}`;
if (i === 0) {
graphDefinition += '\n [*] --> s0';
}
const viewer = new GraphEditor();
viewer.readonly = true;
const graph = new Graph();
for (let i = 0; i < states.length; i++) {
const node = graph.createNode(i);
if (states[i].accepting) {
graphDefinition += `\n s${i} --> [*]`;
graphDefinition += `\n class s${i} acceptingnode`;
node.accepting = true;
}
for (const [transition, destination] of Object.entries(states[i].transitions)) {
graphDefinition += `\n s${i} --> s${destination}: ${transition}`;
if (i === 0) {
node.start = true;
}
}
const graph = /** @type {HTMLDivElement} */ (document.getElementById('pen'));
const { svg } = await mermaid.render('state-graph', graphDefinition);
graph.innerHTML = svg;
const nodes = graph.querySelectorAll('.label-container');
for (let i = 0; i < states.length; i++) {
const state = states[i];
for (const [letter, target] of Object.entries(state.transitions)) {
graph.createLink(i, target, letter);
}
}
const container = /** @type {HTMLDivElement} */ (document.getElementById('state-graph'));
container.appendChild(viewer);
viewer.graph = graph;
/**
* Updates the UI to reflect the current state.
@ -52,10 +51,11 @@ export async function selectAutomaton(states) {
light.classList.remove(IS_INVALID);
light.classList.add(IS_VALID);
}
nodes.forEach((node) => node.classList.remove('current-node'));
if (state in nodes) {
nodes[state].classList.add('current-node');
graph.forEach((node) => node.active = false);
if (state in graph.nodes) {
graph.nodes[state].active = true;
}
viewer.restart();
}
/**

@ -16,11 +16,11 @@ for (const [displayName, automaton] of Object.entries(AUTOMATONS)) {
<p class="card-text">${automaton.length} states</p>
</div>
`;
async function handleEvent() {
const handleEvent = async () => {
automatonSelector.setAttribute('hidden', 'hidden');
app.removeAttribute('hidden');
await selectAutomaton(automaton);
}
};
card.addEventListener('click', handleEvent);
card.addEventListener('keydown', async (event) => {
if (event.key === 'Enter' || event.key === ' ') {

@ -15,9 +15,6 @@
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
@ -36,6 +33,7 @@ h1 {
.input {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5em;
}
input {
@ -78,13 +76,6 @@ button:focus-visible {
background-color: #fd3838;
}
.current-node {
fill: red !important;
}
.accepting-node {
stroke: yellow;
}
#automaton-selector {
width: 100%;
max-width: 600px;
@ -124,3 +115,85 @@ button:focus-visible {
outline-color: #9370DB;
}
}
graph-editor {
display: block;
height: 600px;
}
.link {
stroke: orange;
stroke-width: 4px;
fill: none;
}
.link.draggable {
stroke: orangered;
stroke-dasharray: 8px 2px;
pointer-events: none;
}
.link.hidden {
stroke-width: 0;
}
.arrow {
fill: orange;
}
.arrow.draggable {
fill: orangered;
}
.node {
fill: #007aff;
stroke: none;
cursor: pointer;
}
.node.active {
fill: green;
}
.start-arrow {
fill: #007aff;
stroke: none;
}
.node.accepting {
outline: 5px solid #007aff;
outline-offset: 4px;
border-radius: 50%;
}
.clickbox {
stroke: rgba(0, 0, 0, 0);
stroke-width: 16px;
fill: none;
cursor: pointer;
}
.context-menu {
position: absolute;
background: white;
border: 1px solid #ccc;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
min-width: 150px;
}
.context-menu ul {
padding: 0;
margin: 0;
cursor: pointer;
list-style-type: none;
}
.context-menu li {
padding: 4px 8px;
}
.context-menu li:hover, .context-menu li:focus {
background-color: #f0f0f0;
}
.context-menu li:active {
background-color: #e0e0e0;
}
.context-menu .checked:after {
content: "✓";
float: right;
}

@ -4,7 +4,7 @@
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"types": ["@types/node", "vite/client"],
"skipLibCheck": true,
"allowJs": true,
"checkJs": true,

@ -6,10 +6,10 @@ export default defineConfig({
target: 'esnext',
modulePreload: false,
rollupOptions: {
external: ['mermaid'],
external: ['d3'],
output: {
paths: {
mermaid: 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs',
d3: 'https://cdn.jsdelivr.net/npm/d3@7/+esm',
},
},
},

Loading…
Cancel
Save