@myrmidon/ngx-viz
Version:
Angular viz.js wrapper to render DOT graphs.
334 lines (329 loc) • 17.1 kB
JavaScript
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 });
_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('');
error = signal(null);
loading = signal(false);
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: "20.0.6", ngImport: i0, type: VizComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.6", 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\n class=\"viz-container\"\n #container\n (wheel)=\"onWheel($event)\"\n (mousedown)=\"onMouseDown($event)\"\n (mousemove)=\"onMouseMove($event)\"\n (mouseup)=\"onMouseUp()\"\n (mouseleave)=\"onMouseUp()\"\n >\n <div #graphContainer></div>\n @if (error()) {\n <div class=\"error-message\">\n {{ error() }}\n </div>\n }\n @if (loading()) {\n <div class=\"loading\">Loading...</div>\n }\n <div class=\"zoom-controls\">\n <button type=\"button\" (click)=\"zoomIn()\" class=\"zoom-btn\" title=\"Zoom In\">\n +\n </button>\n <button type=\"button\" (click)=\"zoomOut()\" class=\"zoom-btn\" title=\"Zoom Out\">\n -\n </button>\n <button\n type=\"button\"\n (click)=\"fitToContainer()\"\n class=\"zoom-btn fit-btn\"\n title=\"Fit to Container\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n width=\"14\"\n height=\"14\"\n stroke=\"currentColor\"\n fill=\"none\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7\"\n />\n </svg>\n </button>\n <button type=\"button\" (click)=\"resetView()\" class=\"zoom-btn\" title=\"Reset\">\n \u21BA\n </button>\n <button type=\"button\" (click)=\"exportSVG()\" class=\"zoom-btn\" title=\"Save\">\n \uD83D\uDCBE\n </button>\n </div>\n </div>\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: "20.0.6", ngImport: i0, type: VizComponent, decorators: [{
type: Component,
args: [{ selector: 'ngx-viz', standalone: true, imports: [], template: "<div\n class=\"viz-container\"\n #container\n (wheel)=\"onWheel($event)\"\n (mousedown)=\"onMouseDown($event)\"\n (mousemove)=\"onMouseMove($event)\"\n (mouseup)=\"onMouseUp()\"\n (mouseleave)=\"onMouseUp()\"\n >\n <div #graphContainer></div>\n @if (error()) {\n <div class=\"error-message\">\n {{ error() }}\n </div>\n }\n @if (loading()) {\n <div class=\"loading\">Loading...</div>\n }\n <div class=\"zoom-controls\">\n <button type=\"button\" (click)=\"zoomIn()\" class=\"zoom-btn\" title=\"Zoom In\">\n +\n </button>\n <button type=\"button\" (click)=\"zoomOut()\" class=\"zoom-btn\" title=\"Zoom Out\">\n -\n </button>\n <button\n type=\"button\"\n (click)=\"fitToContainer()\"\n class=\"zoom-btn fit-btn\"\n title=\"Fit to Container\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n width=\"14\"\n height=\"14\"\n stroke=\"currentColor\"\n fill=\"none\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7\"\n />\n </svg>\n </button>\n <button type=\"button\" (click)=\"resetView()\" class=\"zoom-btn\" title=\"Reset\">\n \u21BA\n </button>\n <button type=\"button\" (click)=\"exportSVG()\" class=\"zoom-btn\" title=\"Save\">\n \uD83D\uDCBE\n </button>\n </div>\n </div>\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']
}] } });
/*
* Public API Surface of ngx-viz
*/
/**
* Generated bundle index. Do not edit.
*/
export { VizComponent };
//# sourceMappingURL=myrmidon-ngx-viz.mjs.map