diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..0829442 --- /dev/null +++ b/.eslintrc.cjs @@ -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', + }, +}; diff --git a/README.md b/README.md index 00e278d..f246998 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/index.html b/index.html index 14c34ce..61d4201 100644 --- a/index.html +++ b/index.html @@ -21,8 +21,7 @@
-

-      

+      
diff --git a/package.json b/package.json index 35fa534..0ab93bf 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/editor/GraphEditor.ts b/src/editor/GraphEditor.ts new file mode 100644 index 0000000..d10fdfd --- /dev/null +++ b/src/editor/GraphEditor.ts @@ -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 = ) => 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 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); + }, + ); + 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): 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('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; + +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); diff --git a/src/editor/d3/canvas.ts b/src/editor/d3/canvas.ts new file mode 100644 index 0000000..7993d55 --- /dev/null +++ b/src/editor/d3/canvas.ts @@ -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; +export type Zoom = d3.ZoomBehavior; + +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) => void, +): Zoom { + return d3 + .zoom() + .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(graph!.nodes) + .on('tick', () => onTick()) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('charge', d3.forceManyBody().strength(-500)) + .force('collision', d3.forceCollide().radius(config.nodeRadius)) + .force( + 'link', + d3 + .forceLink() + .links(graph!.links) + .id((d: Node) => d.id) + .distance(config.nodeRadius * 10), + ) + .force('x', d3.forceX(width / 2).strength(0.05)) + .force('y', d3.forceY(height / 2).strength(0.05)); +} + +function terminate(event: Event): void { + event.preventDefault(); + event.stopPropagation(); +} diff --git a/src/editor/d3/graph.ts b/src/editor/d3/graph.ts new file mode 100644 index 0000000..c0a900b --- /dev/null +++ b/src/editor/d3/graph.ts @@ -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 { + public constructor( + public readonly source: Node, + public readonly target: Node, + public transition: string, + ) {} +} diff --git a/src/editor/d3/markers.ts b/src/editor/d3/markers.ts new file mode 100644 index 0000000..b6b3e9c --- /dev/null +++ b/src/editor/d3/markers.ts @@ -0,0 +1,31 @@ +import * as d3 from 'd3'; +import { GraphConfiguration } from './canvas.ts'; + +export function initMarkers( + canvas: d3.Selection, + config: GraphConfiguration, +): void { + createLinkMarker(canvas, config, 'link-arrow', 'arrow'); + createLinkMarker(canvas, config, 'draggable-link-arrow', 'arrow draggable'); +} + +function createLinkMarker( + canvas: d3.Selection, + 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)}`); +} diff --git a/src/editor/d3/paths.ts b/src/editor/d3/paths.ts new file mode 100644 index 0000000..5c491f8 --- /dev/null +++ b/src/editor/d3/paths.ts @@ -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), + ], + ]); +} diff --git a/src/fsm.js b/src/fsm.js index 746cfa8..8c5c15c 100644 --- a/src/fsm.js +++ b/src/fsm.js @@ -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(); } /** diff --git a/src/main.js b/src/main.js index 00bb10b..e251a52 100644 --- a/src/main.js +++ b/src/main.js @@ -16,11 +16,11 @@ for (const [displayName, automaton] of Object.entries(AUTOMATONS)) {

${automaton.length} states

`; - 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 === ' ') { diff --git a/src/style.css b/src/style.css index 5b47263..67fea09 100644 --- a/src/style.css +++ b/src/style.css @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json index 591aec8..e93c9eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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, diff --git a/vite.config.ts b/vite.config.ts index 20d0cf7..1fe016a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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', }, }, },