import { createGraphEngine } from '@graphora/engine' import { attachGraphInput, contextMenuEventFromPick, tooltipEventFromPick, type GraphContextMenuEvent, type GraphTooltipEvent } from '@graphora/interactions' import { createCircularLayout } from '@graphora/layouts' import { CanvasGraphRenderer, GRAPHORA_STYLE_ATTRIBUTE, createViewport, worldToScreen, type ScreenPoint } from '@graphora/renderer' import { attachKeyboardDemo } from '../../../shared/keyboard-demo' import './style.css' const container = document.querySelector('#graph') const status = document.querySelector('#status') if (container === null || status === null) { throw new Error('Missing interaction example elements.') } void main(container, status) async function main( container: HTMLDivElement, status: HTMLSpanElement ): Promise { const renderer = new CanvasGraphRenderer() const engine = createGraphEngine({ graph: { nodes: [ { id: 'plan', label: 'Plan', attributes: { [GRAPHORA_STYLE_ATTRIBUTE]: { fill: '#0ea5e9' } } }, { id: 'build', label: 'Build', attributes: { [GRAPHORA_STYLE_ATTRIBUTE]: { fill: '#22c55e' } } }, { id: 'verify', label: 'Verify', attributes: { [GRAPHORA_STYLE_ATTRIBUTE]: { fill: '#f97316' } } }, { id: 'ship', label: 'Ship' } ], edges: [ { id: 'plan-build', source: 'plan', target: 'build' }, { id: 'build-verify', source: 'build', target: 'verify' }, { id: 'verify-ship', source: 'verify', target: 'ship' } ] }, renderer, layout: createCircularLayout({ radius: 130 }), viewport: createViewport({ width: container.clientWidth, height: container.clientHeight }), theme: { backgroundColor: '#f8fafc', node: { radius: 22 }, edge: { strokeWidth: 2 }, label: { fontSize: 13 } }, interactionStyles: { hover: { node: { fill: '#38bdf8' } }, selection: { node: { fill: '#c4b5fd' }, edge: { strokeWidth: 6 } }, focus: { node: { stroke: '#0f172a', strokeWidth: 4 }, edge: { stroke: '#0f172a' } }, drag: { node: { opacity: 0.72, stroke: '#ea580c', strokeWidth: 4 } } } }) engine.mount(container) const disposeKeyboard = attachKeyboardDemo(container, engine) const contextMenu = createContextMenu() const tooltip = createTooltip() await engine.runLayout({ fitToView: { padding: 56 } }) exposeInteractionTestPoint(engine) const disposeInput = attachGraphInput(container, engine, renderer, { onClick(pick) { status.textContent = pick?.type === 'node' ? `Selected ${String(pick.nodeId)}` : pick?.type === 'edge' ? `Selected edge ${String(pick.edgeId)}` : 'Selection cleared' }, onDragEnd() { status.textContent = 'Node position updated' } }) window.addEventListener( 'pagehide', () => { disposeKeyboard() disposeInput() engine.destroy() }, { once: true } ) container.addEventListener('pointermove', (event) => { if (event.buttons) { hideTooltip() return } showTooltip( tooltipEventFromPick( renderer.pick(screenPointFromEvent(event)), engine.getViewport() ) ) }) container.addEventListener('pointerleave', hideTooltip) container.addEventListener('pointerdown', () => { hideTooltip() hideContextMenu() }) container.addEventListener('contextmenu', (event) => { event.preventDefault() const point = screenPointFromEvent(event) const menu = contextMenuEventFromPick( renderer.pick(point), engine.getViewport(), { trigger: 'pointer' } ) if (menu === null) { hideContextMenu() return } showContextMenu(menu) }) function createContextMenu(): HTMLDivElement { const element = document.createElement('div') element.className = 'context-menu' element.hidden = true element.setAttribute('role', 'menu') element.addEventListener('pointerdown', stopGraphEvent) element.addEventListener('pointerup', stopGraphEvent) element.addEventListener('click', stopGraphEvent) element.addEventListener('contextmenu', stopGraphEvent) container.append(element) return element } function createTooltip(): HTMLDivElement { const element = document.createElement('div') element.className = 'graph-tooltip' element.hidden = true element.setAttribute('role', 'tooltip') container.append(element) return element } function stopGraphEvent(event: Event): void { event.stopPropagation() } function showContextMenu(menu: GraphContextMenuEvent): void { contextMenu.replaceChildren( menuLabel(menu), menuAction('Focus', menu), menuAction('Inspect', menu) ) contextMenu.hidden = false contextMenu.style.left = `${menu.screenPoint.x}px` contextMenu.style.top = `${menu.screenPoint.y}px` status.textContent = contextMenuStatus(menu) } function hideContextMenu(): void { contextMenu.hidden = true } function showTooltip(nextTooltip: GraphTooltipEvent | null): void { if (nextTooltip === null || nextTooltip.type === 'canvas:tooltip') { hideTooltip() return } tooltip.hidden = false tooltip.textContent = tooltipLabel(nextTooltip) tooltip.style.left = `${nextTooltip.screenPoint.x}px` tooltip.style.top = `${nextTooltip.screenPoint.y}px` } function hideTooltip(): void { tooltip.hidden = true } function tooltipLabel(nextTooltip: GraphTooltipEvent): string { if (nextTooltip.type === 'node:tooltip') { return `Tooltip for node ${String(nextTooltip.nodeId)}` } if (nextTooltip.type === 'edge:tooltip') { return `Tooltip for edge ${String(nextTooltip.edgeId)}` } return 'Tooltip for canvas' } function menuLabel(menu: GraphContextMenuEvent): HTMLDivElement { const label = document.createElement('div') label.className = 'context-menu__label' label.textContent = contextMenuStatus(menu) return label } function menuAction( label: string, menu: GraphContextMenuEvent ): HTMLButtonElement { const button = document.createElement('button') button.type = 'button' button.setAttribute('role', 'menuitem') button.textContent = label button.addEventListener('click', () => { status.textContent = `${label} ${contextMenuTarget(menu)}` hideContextMenu() }) return button } function contextMenuStatus(menu: GraphContextMenuEvent): string { return `Menu for ${contextMenuTarget(menu)}` } function contextMenuTarget(menu: GraphContextMenuEvent): string { if (menu.type === 'node:context-menu') { return `node ${String(menu.nodeId)}` } if (menu.type === 'edge:context-menu') { return `edge ${String(menu.edgeId)}` } return 'canvas' } function screenPointFromEvent(event: MouseEvent | PointerEvent): ScreenPoint { const bounds = container.getBoundingClientRect() return { x: event.clientX - bounds.left, y: event.clientY - bounds.top } } function exposeInteractionTestPoint( currentEngine: ReturnType ): void { const planNode = currentEngine .snapshot() .nodes.find((node) => node.id === 'plan') if (planNode?.position !== null && planNode?.position !== undefined) { const point = worldToScreen( currentEngine.getViewport(), planNode.position ) container.dataset.planX = point.x.toFixed(2) container.dataset.planY = point.y.toFixed(2) } container.dataset.ready = 'true' } }