import './lifecycle' import { createGraphEngine } from '@graphora/engine' import { createBrowserWorkerForceLayout, createForceLayout, type GraphLayout, type LayoutResult } from '@graphora/layouts' import { highlightNeighborhood, highlightToInteractionRenderState } from '@graphora/interactions' import { CanvasGraphRenderer, createViewport, fitBounds, worldToScreen, resolveVisibleLabelIds, type DisplayDetailMode, type RenderSnapshot } from '@graphora/renderer' import { createPerformanceGraph, DEFAULT_PERFORMANCE_FIXTURE_SEED, type PerformanceFixtureShape, type PerformanceGraph } from './fixture' import './style.css' interface PerformanceMeasurements { fixtureGenerationMs: number engineLoadMs: number layoutComputationMs: number resultApplicationMs: number firstRenderMs: number readyMs: number panRenderBoundaryMs?: number zoomRenderBoundaryMs?: number nodeHitPickingMs?: number fullSceneMissPickingMs?: number highlightDerivationMs?: number highlightScheduleAndSnapshotMs?: number highlightCanvasRenderMs?: number highlightRenderBoundaryMs?: number } interface PerformanceEvidence { fixture: { nodeCount: number edgeCount: number density: number shape: PerformanceFixtureShape fixtureSeed: number hubCount: number maximumDegree: number iterations: number layoutSeed: number } displayDetail: DisplayDetailMode labeled: boolean labelWorkload: { sourceLabels: number; drawnLabels: number } layoutMode: 'worker' | 'main-thread' workload?: { pickingIterations: number highlightedNodeCount: number highlightedEdgeCount: number } measurements: PerformanceMeasurements } declare global { interface Window { __GRAPHORA_PERFORMANCE__?: PerformanceEvidence } } const startedAt = performance.now() const container = document.querySelector('#graph')! const status = document.querySelector('#status')! const fitButton = document.querySelector('#fit')! const selectButton = document.querySelector( '#select-benchmark-node' )! if ( container === null || status === null || fitButton === null || selectButton === null ) { throw new Error('Missing performance example elements.') } async function main(): Promise { const query = new URLSearchParams(location.search) const displayDetail = (query.get('detail') ?? 'off') as DisplayDetailMode const labeled = query.get('labels') === 'all' const nodeCount = readPositiveInteger(query, 'nodes', 180) const density = readPositiveNumber(query, 'density', 290 / 180) const edgeCount = Math.round(nodeCount * density) const shape = readFixtureShape(query) const fixtureSeed = readNonnegativeInteger( query, 'seed', DEFAULT_PERFORMANCE_FIXTURE_SEED ) const iterations = readNonnegativeInteger( query, 'iterations', nodeCount > 1000 ? 120 : 90 ) const layoutMode = query.get('layout') === 'main' ? 'main-thread' : 'worker' const fixtureStartedAt = performance.now() const graph = createPerformanceGraph({ nodeCount, edgeCount, shape, seed: fixtureSeed, ...(labeled ? { labelEvery: 1 } : {}) }) if (labeled) graph.edges.forEach((edge, index) => { edge.label = `Edge ${index}` }) const fixtureGenerationMs = performance.now() - fixtureStartedAt const renderer = new MeasuredCanvasGraphRenderer() const engineStartedAt = performance.now() const engine = createGraphEngine({ graph, renderer, displayDetail: { mode: displayDetail }, viewport: createViewport({ width: container.clientWidth, height: container.clientHeight }), theme: { backgroundColor: '#f8fafc', node: { radius: 4, strokeWidth: 0.6 }, edge: { strokeWidth: 0.6 }, label: { fontSize: 9 } }, interactionStyles: { selection: { node: { stroke: '#f59e0b', strokeWidth: 3 } } } }) engine.mount(container) const engineLoadMs = performance.now() - engineStartedAt const layoutOptions = { iterations, linkDistance: 36, chargeStrength: -55, collisionRadius: 6, centerStrength: 0.05, seed: 82 } let disposeLayout = (): void => {} let layout: GraphLayout if (layoutMode === 'worker') { const workerLayout = createBrowserWorkerForceLayout({ ...layoutOptions, timeoutMs: 120_000 }) layout = workerLayout disposeLayout = () => workerLayout.dispose() } else { layout = createForceLayout(layoutOptions) } let cleanedUp = false const cleanup = (): void => { if (cleanedUp) return cleanedUp = true disposeLayout() engine.destroy() } addEventListener('beforeunload', cleanup, { once: true }) const computeStartedAt = performance.now() let result: LayoutResult try { result = await layout.run(engine.snapshot()) } catch (error) { cleanup() throw error } const layoutComputationMs = performance.now() - computeStartedAt const applyStartedAt = performance.now() engine.applyLayoutResult(result, { fitToView: { padding: 72 } }) const resultApplicationMs = performance.now() - applyStartedAt const firstRenderStartedAt = performance.now() await waitForNonblankCanvas(container) const firstRenderMs = performance.now() - firstRenderStartedAt const evidence: PerformanceEvidence = { displayDetail, labeled, labelWorkload: renderer.labelWorkload, fixture: { nodeCount: graph.nodes.length, edgeCount: graph.edges.length, density, shape, fixtureSeed, hubCount: graph.nodes.filter((node) => node.data?.isHub).length, maximumDegree: maximumDegree(graph), iterations, layoutSeed: layoutOptions.seed }, layoutMode, measurements: { fixtureGenerationMs, engineLoadMs, layoutComputationMs, resultApplicationMs, firstRenderMs, readyMs: performance.now() - startedAt } } window.__GRAPHORA_PERFORMANCE__ = evidence container.dataset.ready = 'true' updateStatus( `Ready: ${graph.nodes.length} nodes, ${graph.edges.length} edges, ${shape}, ${layoutMode} force layout.` ) fitButton.addEventListener('click', () => { engine.setViewport( fitBounds(engine.getViewport(), result.bounds, { padding: 72 }) ) updateStatus('Refit complete.') }) selectButton.addEventListener('click', async () => { selectButton.disabled = true try { evidence.workload = await runInteractionWorkload( graph, engine, renderer, evidence.measurements ) container.dataset.interactionReady = 'true' updateStatus('Pan, zoom, picking and highlight workload complete.') } finally { selectButton.disabled = false } }) } async function runInteractionWorkload( graph: PerformanceGraph, engine: ReturnType, renderer: MeasuredCanvasGraphRenderer, measurements: PerformanceMeasurements ): Promise> { const fittedViewport = engine.getViewport() const targetNodeId = mostConnectedNodeId(graph) const targetNode = engine .snapshot() .nodes.find((node) => node.id === targetNodeId) if (targetNode?.position === null || targetNode?.position === undefined) throw new Error('Picking workload target did not have a layout position.') measurements.panRenderBoundaryMs = await measureRenderBoundary(() => { engine.setViewport({ center: { x: fittedViewport.center.x + 24 / fittedViewport.zoom, y: fittedViewport.center.y - 16 / fittedViewport.zoom }, zoom: fittedViewport.zoom }) }) engine.setViewport(fittedViewport) await afterTwoAnimationFrames() measurements.zoomRenderBoundaryMs = await measureRenderBoundary(() => { engine.setViewport({ center: fittedViewport.center, zoom: fittedViewport.zoom * 1.12 }) }) engine.setViewport(fittedViewport) await afterTwoAnimationFrames() const pickingIterations = 5 const hitPoint = worldToScreen(fittedViewport, targetNode.position) const hitStartedAt = performance.now() for (let index = 0; index < pickingIterations; index++) { const picked = renderer.pick(hitPoint) if (picked?.type !== 'node' || picked.nodeId !== targetNodeId) throw new Error('Node-hit picking workload returned an unexpected item.') } measurements.nodeHitPickingMs = performance.now() - hitStartedAt const missStartedAt = performance.now() for (let index = 0; index < pickingIterations; index++) { const picked = renderer.pick({ x: 4, y: 4 }) if (picked?.type !== 'canvas') throw new Error('Full-scene picking workload did not return canvas.') } measurements.fullSceneMissPickingMs = performance.now() - missStartedAt const highlightStartedAt = performance.now() const highlight = highlightNeighborhood(engine.snapshot(), { nodeId: targetNodeId, mode: 'direct' }) measurements.highlightDerivationMs = performance.now() - highlightStartedAt if ( highlight.highlightedNodeIds.length < 2 || highlight.highlightedEdgeIds.length < 1 ) throw new Error('Neighborhood highlight workload returned no neighbors.') const highlightRender = await measureHighlightRender(engine, renderer, () => { engine.setInteractionState( highlightToInteractionRenderState(highlight, engine.getInteractionState()) ) }) measurements.highlightScheduleAndSnapshotMs = highlightRender.scheduleAndSnapshotMs measurements.highlightCanvasRenderMs = highlightRender.canvasRenderMs measurements.highlightRenderBoundaryMs = highlightRender.boundaryMs return { pickingIterations, highlightedNodeCount: highlight.highlightedNodeIds.length, highlightedEdgeCount: highlight.highlightedEdgeIds.length } } class MeasuredCanvasGraphRenderer extends CanvasGraphRenderer { private workloadSnapshot?: RenderSnapshot get labelWorkload(): { sourceLabels: number; drawnLabels: number } { const snapshot = this.workloadSnapshot return snapshot ? { sourceLabels: snapshot.labels.length, drawnLabels: resolveVisibleLabelIds(snapshot, { mode: 'all' }).length } : { sourceLabels: 0, drawnLabels: 0 } } private measuring = false private measuredRenderMs = 0 beginRenderMeasurement(): void { this.measuring = true this.measuredRenderMs = 0 } finishRenderMeasurement(): number { this.measuring = false return this.measuredRenderMs } override render(snapshot: RenderSnapshot): void { this.workloadSnapshot = snapshot if (!this.measuring) { super.render(snapshot) return } const startedAt = performance.now() try { super.render(snapshot) } finally { this.measuredRenderMs += performance.now() - startedAt } } } async function measureHighlightRender( engine: ReturnType, renderer: MeasuredCanvasGraphRenderer, update: () => void ): Promise<{ boundaryMs: number scheduleAndSnapshotMs: number canvasRenderMs: number }> { const startedAt = performance.now() let renderBeforeAt: number | undefined const unsubscribe = engine.onLifecycle('render:before', () => { renderBeforeAt ??= performance.now() }) renderer.beginRenderMeasurement() let canvasRenderMs = 0 try { update() await afterTwoAnimationFrames() } finally { unsubscribe() canvasRenderMs = renderer.finishRenderMeasurement() } if (renderBeforeAt === undefined) throw new Error('Highlight workload did not reach render:before.') return { boundaryMs: performance.now() - startedAt, scheduleAndSnapshotMs: renderBeforeAt - startedAt, canvasRenderMs } } async function measureRenderBoundary(update: () => void): Promise { const startedAt = performance.now() update() await afterTwoAnimationFrames() return performance.now() - startedAt } function mostConnectedNodeId(graph: PerformanceGraph): string | number { const degrees = new Map( graph.nodes.map((node) => [node.id, 0]) ) for (const edge of graph.edges) { degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + 1) degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + 1) } let target = graph.nodes[0].id for (const node of graph.nodes) if ((degrees.get(node.id) ?? 0) > (degrees.get(target) ?? 0)) target = node.id return target } function maximumDegree(graph: PerformanceGraph): number { const target = mostConnectedNodeId(graph) let degree = 0 for (const edge of graph.edges) if (edge.source === target || edge.target === target) degree++ return degree } function updateStatus(message: string): void { status.textContent = message } void main().catch((error: unknown) => { container.dataset.error = 'true' updateStatus( error instanceof Error ? error.message : 'Performance example failed.' ) throw error }) async function waitForNonblankCanvas(host: HTMLElement): Promise { await afterTwoAnimationFrames() const canvas = host.querySelector('canvas') const context = canvas?.getContext('2d') if (canvas === null || context == null || canvas === undefined) throw new Error('Canvas renderer did not mount.') for (let attempt = 0; attempt < 120; attempt += 1) { const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data const first = `${pixels[0]}:${pixels[1]}:${pixels[2]}:${pixels[3]}` for ( let index = 0; index < pixels.length; index += Math.max(4, Math.floor(pixels.length / 2000 / 4) * 4) ) { if ( `${pixels[index]}:${pixels[index + 1]}:${pixels[index + 2]}:${pixels[index + 3]}` !== first ) return } await afterTwoAnimationFrames() } throw new Error('Canvas remained blank after layout application.') } function afterTwoAnimationFrames(): Promise { return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())) ) } function readPositiveInteger( query: URLSearchParams, key: string, fallback: number ): number { const value = Number(query.get(key) ?? fallback) if (!Number.isInteger(value) || value <= 0) throw new RangeError(`${key} must be a positive integer.`) return value } function readNonnegativeInteger( query: URLSearchParams, key: string, fallback: number ): number { const value = Number(query.get(key) ?? fallback) if (!Number.isInteger(value) || value < 0) throw new RangeError(`${key} must be a nonnegative integer.`) return value } function readPositiveNumber( query: URLSearchParams, key: string, fallback: number ): number { const value = Number(query.get(key) ?? fallback) if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${key} must be positive.`) return value } function readFixtureShape(query: URLSearchParams): PerformanceFixtureShape { const value = query.get('shape') ?? 'clustered' if (value !== 'clustered' && value !== 'dense-hubs') throw new RangeError('shape must be clustered or dense-hubs.') return value }