UNPKG

@myrmidon/ngx-viz

Version:

Angular viz.js wrapper to render DOT graphs.

334 lines (329 loc) 17.6 kB
import * as i0 from '@angular/core'; import { inject, DestroyRef, signal, input, effect, ViewChild, Component } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; const VIZ_URL = 'https://unpkg.com/viz.js@2.1.2/viz.js'; const RENDER_URL = 'https://unpkg.com/viz.js@2.1.2/full.render.js'; /** * Wrapper for Viz.js library to render DOT graphs in Angular. */ class VizComponent { ZOOM_SPEED = 0.1; MIN_SCALE = 0.1; MAX_SCALE = 5; destroyRef = inject(DestroyRef); _transform = signal({ x: 0, y: 0, scale: 1 }, ...(ngDevMode ? [{ debugName: "_transform" }] : [])); _isDragging = false; _lastPosition = { x: 0, y: 0 }; _originalViewBox = null; _vizInstance = null; _vizPromise = null; _scriptsLoaded = new BehaviorSubject(false); graphContainer; container; /** * DOT code to render as a graph. */ code = input('', ...(ngDevMode ? [{ debugName: "code" }] : [])); error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : [])); loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : [])); constructor() { effect(() => { if (this.code()) { this.checkAndRender(); } }); } ngOnInit() { this.initViz(); } // #region Viewport and Zoom fitToContainer() { if (!this.graphContainer || !this._originalViewBox) return; const container = this.container.nativeElement; const containerRect = container.getBoundingClientRect(); // get the bounding box of the graph elements const svg = this.graphContainer.nativeElement.querySelector('svg'); const graphBoundingBox = svg.getBBox(); // account for potential invisible elements by expanding the bounding box const expandedBoundingBox = { x: graphBoundingBox.x - 20, y: graphBoundingBox.y - 30, width: graphBoundingBox.width + 40, height: graphBoundingBox.height + 60, }; // calculate padding based on expanded bounding box const paddingX = Math.max(expandedBoundingBox.width / 10, 10); const paddingY = Math.max(expandedBoundingBox.height / 10, 10); // calculate scale to fit the graph within the container, considering padding const scaleX = (containerRect.width - 2 * paddingX) / (expandedBoundingBox.width + 2 * paddingX); const scaleY = (containerRect.height - 2 * paddingY) / (expandedBoundingBox.height + 2 * paddingY); const scale = Math.min(scaleX, scaleY); // calculate center position based on the expanded bounding box const x = (containerRect.width - (expandedBoundingBox.width + expandedBoundingBox.x) * scale) / 2; const y = (containerRect.height - (expandedBoundingBox.height + expandedBoundingBox.y) * scale) / 2; // apply transform with animation this._transform.set({ x, y, scale }); this.applyTransform(true); } zoomIn() { this.updateZoom(this._transform().scale + this.ZOOM_SPEED); } zoomOut() { this.updateZoom(this._transform().scale - this.ZOOM_SPEED); } resetView() { this._transform.set({ x: 0, y: 0, scale: 1 }); this.applyTransform(); } updateZoom(newScale) { const scale = Math.max(this.MIN_SCALE, Math.min(this.MAX_SCALE, newScale)); this._transform.update((t) => ({ ...t, scale })); this.applyTransform(); } //#endregion //#region Mouse Events onWheel(event) { event.preventDefault(); const delta = event.deltaY > 0 ? -this.ZOOM_SPEED : this.ZOOM_SPEED; this.updateZoom(this._transform().scale + delta); } onMouseDown(event) { this._isDragging = true; this._lastPosition = { x: event.clientX, y: event.clientY }; } onMouseMove(event) { if (!this._isDragging) return; const dx = event.clientX - this._lastPosition.x; const dy = event.clientY - this._lastPosition.y; this._transform.update((t) => ({ ...t, x: t.x + dx, y: t.y + dy, })); this._lastPosition = { x: event.clientX, y: event.clientY }; this.applyTransform(); } onMouseUp() { this._isDragging = false; } //#endregion applyTransform(animate = false) { if (!this.graphContainer) return; const svg = this.graphContainer.nativeElement.querySelector('svg'); if (!svg) return; const { x, y, scale } = this._transform(); const transform = `translate(${x}px, ${y}px) scale(${scale})`; if (animate) { svg.style.transition = 'transform 0.3s ease-out'; // remove transition after animation setTimeout(() => { svg.style.transition = 'none'; }, 300); } else { svg.style.transition = 'none'; } svg.style.transform = transform; svg.style.transformOrigin = 'center'; } storeOriginalViewBox(svg) { // get original viewBox or computed size const viewBox = svg.getAttribute('viewBox'); if (viewBox) { const [x, y, width, height] = viewBox.split(' ').map(Number); this._originalViewBox = { x, y, width, height }; } else { const rect = svg.getBoundingClientRect(); this._originalViewBox = { x: 0, y: 0, width: rect.width, height: rect.height, }; } } loadScript(url) { return new Promise((resolve, reject) => { // check if script is already loaded const existingScript = document.querySelector(`script[src="${url}"]`); if (existingScript) { resolve(); return; } const script = document.createElement('script'); script.type = 'text/javascript'; script.src = url; script.async = true; script.onerror = () => reject(new Error(`Failed to load script: ${url}`)); script.onload = () => resolve(); document.head.appendChild(script); }); } async initViz() { if (this._vizPromise) { return this._vizPromise; } this._vizPromise = new Promise((resolve, reject) => { // First, check if Viz is already available if (window.Viz) { resolve(); return; } // Create a temporary AMD environment if none exists const hadAMD = 'define' in window && 'requirejs' in window; let originalDefine; let originalRequire; if (!hadAMD) { // Save original values if they exist originalDefine = window.define; originalRequire = window.requirejs; // Create minimal AMD environment window.define = function (factory) { try { window.Viz = factory(); } catch (e) { console.error('Error in Viz.js factory:', e); } }; window.define.amd = true; } // Load Viz.js first this.loadScript(VIZ_URL) .then(() => { // Restore original AMD environment before loading render.js if (!hadAMD) { if (originalDefine) { window.define = originalDefine; } else { delete window.define; } if (originalRequire) { window.requirejs = originalRequire; } else { delete window.requirejs; } } // Now load the renderer return this.loadScript(RENDER_URL); }) .then(() => { if (window.Viz) { resolve(); } else { reject(new Error('Viz.js failed to initialize')); } }) .catch(reject); }); try { await this._vizPromise; this._scriptsLoaded.next(true); } catch (error) { console.error('Error initializing Viz.js:', error); this.error.set('Failed to initialize Viz.js'); this._scriptsLoaded.next(false); } return this._vizPromise; } async checkAndRender() { this.loading.set(true); this.error.set(null); try { await this.initViz(); await this.renderGraph(); } catch (e) { this.error.set(e instanceof Error ? e.message : 'Error initializing or rendering graph'); console.error('Viz.js error:', e); } finally { this.loading.set(false); } } async renderGraph() { if (!this.graphContainer || !window.Viz) return; try { // create a new Viz instance if we don't have one if (!this._vizInstance) { this._vizInstance = new window.Viz(); } const result = await this._vizInstance.renderSVGElement(this.code()); // clear previous content this.graphContainer.nativeElement.innerHTML = ''; // add new SVG this.graphContainer.nativeElement.appendChild(result); // store original viewBox and prepare SVG const svg = this.graphContainer.nativeElement.querySelector('svg'); if (svg) { this.storeOriginalViewBox(svg); svg.style.transition = 'transform 0.3s ease-out'; svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); } // fit to container after rendering this.fitToContainer(); } catch (e) { if (e instanceof Error && e.message.includes('Worker is already disposed')) { // if worker is disposed, create a new instance and retry this._vizInstance = new window.Viz(); return this.renderGraph(); } throw new Error(e instanceof Error ? e.message : 'Error rendering graph'); } } /** * Exports the rendered graph as an SVG file. */ exportSVG() { const svgElement = this.graphContainer.nativeElement.querySelector('svg'); if (svgElement) { const serializer = new XMLSerializer(); const svgString = serializer.serializeToString(svgElement); const blob = new Blob([svgString], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'graph.svg'; a.click(); URL.revokeObjectURL(url); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.0", ngImport: i0, type: VizComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.0", type: VizComponent, isStandalone: true, selector: "ngx-viz", inputs: { code: { classPropertyName: "code", publicName: "code", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "graphContainer", first: true, predicate: ["graphContainer"], descendants: true }, { propertyName: "container", first: true, predicate: ["container"], descendants: true }], ngImport: i0, template: "<div\r\n class=\"viz-container\"\r\n #container\r\n (wheel)=\"onWheel($event)\"\r\n (mousedown)=\"onMouseDown($event)\"\r\n (mousemove)=\"onMouseMove($event)\"\r\n (mouseup)=\"onMouseUp()\"\r\n (mouseleave)=\"onMouseUp()\"\r\n >\r\n <div #graphContainer></div>\r\n @if (error()) {\r\n <div class=\"error-message\">\r\n {{ error() }}\r\n </div>\r\n }\r\n @if (loading()) {\r\n <div class=\"loading\">Loading...</div>\r\n }\r\n <div class=\"zoom-controls\">\r\n <button type=\"button\" (click)=\"zoomIn()\" class=\"zoom-btn\" title=\"Zoom In\">\r\n +\r\n </button>\r\n <button type=\"button\" (click)=\"zoomOut()\" class=\"zoom-btn\" title=\"Zoom Out\">\r\n -\r\n </button>\r\n <button\r\n type=\"button\"\r\n (click)=\"fitToContainer()\"\r\n class=\"zoom-btn fit-btn\"\r\n title=\"Fit to Container\"\r\n >\r\n <svg\r\n viewBox=\"0 0 24 24\"\r\n width=\"14\"\r\n height=\"14\"\r\n stroke=\"currentColor\"\r\n fill=\"none\"\r\n >\r\n <path\r\n stroke-linecap=\"round\"\r\n stroke-linejoin=\"round\"\r\n stroke-width=\"2\"\r\n d=\"M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7\"\r\n />\r\n </svg>\r\n </button>\r\n <button type=\"button\" (click)=\"resetView()\" class=\"zoom-btn\" title=\"Reset\">\r\n \u21BA\r\n </button>\r\n <button type=\"button\" (click)=\"exportSVG()\" class=\"zoom-btn\" title=\"Save\">\r\n \uD83D\uDCBE\r\n </button>\r\n </div>\r\n </div>\r\n", styles: [".viz-container{width:100%;height:100%;position:relative;overflow:hidden;cursor:grab}.viz-container:active{cursor:grabbing}.viz-container>div:first-child{width:100%;height:100%}.viz-container svg{width:100%;height:100%;max-width:100%}.error-message{color:red;padding:1rem}.loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.zoom-controls{position:absolute;bottom:1rem;right:1rem;display:flex;gap:.5rem;background:#fffc;padding:.5rem;border-radius:.5rem;box-shadow:0 2px 4px #0000001a}.zoom-btn{width:2rem;height:2rem;border:1px solid #ccc;background:#fff;border-radius:.25rem;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:1.2rem;line-height:1}.zoom-btn:hover{background:#f0f0f0}.fit-btn{padding:.25rem}\n"] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.0", ngImport: i0, type: VizComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-viz', standalone: true, imports: [], template: "<div\r\n class=\"viz-container\"\r\n #container\r\n (wheel)=\"onWheel($event)\"\r\n (mousedown)=\"onMouseDown($event)\"\r\n (mousemove)=\"onMouseMove($event)\"\r\n (mouseup)=\"onMouseUp()\"\r\n (mouseleave)=\"onMouseUp()\"\r\n >\r\n <div #graphContainer></div>\r\n @if (error()) {\r\n <div class=\"error-message\">\r\n {{ error() }}\r\n </div>\r\n }\r\n @if (loading()) {\r\n <div class=\"loading\">Loading...</div>\r\n }\r\n <div class=\"zoom-controls\">\r\n <button type=\"button\" (click)=\"zoomIn()\" class=\"zoom-btn\" title=\"Zoom In\">\r\n +\r\n </button>\r\n <button type=\"button\" (click)=\"zoomOut()\" class=\"zoom-btn\" title=\"Zoom Out\">\r\n -\r\n </button>\r\n <button\r\n type=\"button\"\r\n (click)=\"fitToContainer()\"\r\n class=\"zoom-btn fit-btn\"\r\n title=\"Fit to Container\"\r\n >\r\n <svg\r\n viewBox=\"0 0 24 24\"\r\n width=\"14\"\r\n height=\"14\"\r\n stroke=\"currentColor\"\r\n fill=\"none\"\r\n >\r\n <path\r\n stroke-linecap=\"round\"\r\n stroke-linejoin=\"round\"\r\n stroke-width=\"2\"\r\n d=\"M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7\"\r\n />\r\n </svg>\r\n </button>\r\n <button type=\"button\" (click)=\"resetView()\" class=\"zoom-btn\" title=\"Reset\">\r\n \u21BA\r\n </button>\r\n <button type=\"button\" (click)=\"exportSVG()\" class=\"zoom-btn\" title=\"Save\">\r\n \uD83D\uDCBE\r\n </button>\r\n </div>\r\n </div>\r\n", styles: [".viz-container{width:100%;height:100%;position:relative;overflow:hidden;cursor:grab}.viz-container:active{cursor:grabbing}.viz-container>div:first-child{width:100%;height:100%}.viz-container svg{width:100%;height:100%;max-width:100%}.error-message{color:red;padding:1rem}.loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.zoom-controls{position:absolute;bottom:1rem;right:1rem;display:flex;gap:.5rem;background:#fffc;padding:.5rem;border-radius:.5rem;box-shadow:0 2px 4px #0000001a}.zoom-btn{width:2rem;height:2rem;border:1px solid #ccc;background:#fff;border-radius:.25rem;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:1.2rem;line-height:1}.zoom-btn:hover{background:#f0f0f0}.fit-btn{padding:.25rem}\n"] }] }], ctorParameters: () => [], propDecorators: { graphContainer: [{ type: ViewChild, args: ['graphContainer'] }], container: [{ type: ViewChild, args: ['container'] }], code: [{ type: i0.Input, args: [{ isSignal: true, alias: "code", required: false }] }] } }); /* * Public API Surface of ngx-viz */ /** * Generated bundle index. Do not edit. */ export { VizComponent }; //# sourceMappingURL=myrmidon-ngx-viz.mjs.map