| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- // RackPeek graph rendering shim.
- //
- // Public API (called from Blazor via JSInterop):
- // window.rackpeekGraph.render(elementId, mermaidSource)
- // – Wipes the target element, runs Mermaid on the source, and inserts
- // the resulting SVG with pan/zoom enabled.
- //
- // Assumes Mermaid is already loaded globally (via <script src="mermaid.min.js">).
- // The ELK layout plugin is loaded lazily on first render.
- (function () {
- "use strict";
- let _initPromise = null;
- let _mermaidScriptPromise = null;
- function loadMermaidScript() {
- // Inject the (large) mermaid UMD bundle on first use rather than
- // shipping it on every page. Keeps non-graph pages light so Blazor's
- // SignalR circuit establishes promptly under load.
- if (_mermaidScriptPromise) return _mermaidScriptPromise;
- _mermaidScriptPromise = new Promise((resolve, reject) => {
- if (window.mermaid) {
- resolve();
- return;
- }
- const script = document.createElement("script");
- script.src = "/_content/Shared.Rcl/js/graph/mermaid.min.js";
- script.async = true;
- script.onload = () => resolve();
- script.onerror = () => reject(new Error("Failed to load mermaid.min.js"));
- document.head.appendChild(script);
- });
- return _mermaidScriptPromise;
- }
- async function ensureInitialised() {
- if (_initPromise) return _initPromise;
- _initPromise = (async () => {
- await loadMermaidScript();
- if (!window.mermaid) {
- throw new Error("Mermaid bundle loaded but window.mermaid is undefined");
- }
- // Register the ELK layout loader. Mermaid 11 dispatches by the
- // `layout` config key; "elk" maps to the layered algorithm.
- try {
- const elk = await import("./mermaid-layout-elk.min.mjs");
- window.mermaid.registerLayoutLoaders(elk.default);
- } catch (e) {
- // Fall back silently to the default dagre layout — still
- // renders, just with the less polished arrow routing.
- console.warn("[rackpeekGraph] ELK plugin failed to load:", e);
- }
- window.mermaid.initialize({
- startOnLoad: false,
- securityLevel: "loose",
- theme: "dark",
- fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif"
- });
- })();
- return _initPromise;
- }
- async function render(elementId, source) {
- const host = document.getElementById(elementId);
- if (host) host.innerHTML = "";
- // Empty source = "clear" request. Don't hand "" to mermaid.render —
- // it treats that as malformed input and produces a "Syntax error in
- // text" SVG which can leak out into the page if the host element has
- // already been detached (e.g. component disposal during navigation).
- if (!source || !source.trim()) {
- cleanupOrphans();
- return;
- }
- // Defer the (heavy) mermaid + ELK work to browser idle time so the
- // Blazor circuit and nav-click handlers stay responsive on pages
- // that render diagrams (e.g. the homepage). If the host element is
- // gone by the time idle fires (user navigated away), bail.
- await waitForIdle();
- if (!document.getElementById(elementId)) {
- cleanupOrphans();
- return;
- }
- await ensureInitialised();
- // The host may have been detached while ensureInitialised was awaiting
- // (especially on first render). Re-fetch and bail if it's gone.
- const liveHost = document.getElementById(elementId);
- if (!liveHost) {
- cleanupOrphans();
- return;
- }
- // A unique id per render avoids collisions when the same element is
- // re-rendered with different source.
- const renderId = `rpkg-${elementId}-${Date.now()}`;
- let result;
- try {
- result = await window.mermaid.render(renderId, source);
- } finally {
- // Mermaid creates a scratch <div id="d{renderId}"> in <body> for
- // measurement and normally removes it; sweep up just in case.
- const scratch = document.getElementById("d" + renderId);
- if (scratch && scratch.parentElement) scratch.parentElement.removeChild(scratch);
- }
- // Host may have been disposed during the render await.
- const stillLive = document.getElementById(elementId);
- if (!stillLive) {
- cleanupOrphans();
- return;
- }
- stillLive.innerHTML = result.svg;
- const svgEl = stillLive.querySelector("svg");
- if (svgEl) {
- // Let the SVG fill its container rather than honouring the
- // intrinsic max-width Mermaid sets, so pan/zoom feels natural.
- svgEl.removeAttribute("width");
- svgEl.removeAttribute("height");
- svgEl.style.maxWidth = "100%";
- svgEl.style.width = "100%";
- svgEl.style.height = "100%";
- }
- if (result.bindFunctions) result.bindFunctions(stillLive);
- }
- function waitForIdle() {
- return new Promise((resolve) => {
- if (typeof window.requestIdleCallback === "function") {
- // 2s timeout means we still fire eventually if the browser
- // never goes idle.
- window.requestIdleCallback(() => resolve(), { timeout: 2000 });
- } else {
- // Safari < 16 has no requestIdleCallback — fall back to a
- // short defer that still yields the current event loop.
- setTimeout(resolve, 50);
- }
- });
- }
- function cleanupOrphans() {
- // Mermaid sometimes leaves "d{renderId}" scratch nodes attached to
- // <body> when the originating host is gone — remove any that match
- // our renderId prefix.
- document.querySelectorAll("body > [id^='drpkg-']").forEach((el) => {
- el.parentElement?.removeChild(el);
- });
- }
- function triggerDownload(blob, filename) {
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- // Defer revoke so Safari has time to start the download.
- setTimeout(() => URL.revokeObjectURL(url), 1000);
- }
- function downloadSvg(elementId, filename, background) {
- const host = document.getElementById(elementId);
- if (!host) {
- console.warn(`[rackpeekGraph] element '${elementId}' not found`);
- return;
- }
- const svg = host.querySelector("svg");
- if (!svg) {
- console.warn(`[rackpeekGraph] no SVG in element '${elementId}' to export`);
- return;
- }
- // Clone so the in-page interactive copy isn't modified. Ensure
- // xmlns + a viewBox-derived width/height so the file renders cleanly
- // in any standalone viewer.
- const clone = svg.cloneNode(true);
- if (!clone.getAttribute("xmlns")) {
- clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
- }
- if (!clone.getAttribute("xmlns:xlink")) {
- clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
- }
- const vb = clone.getAttribute("viewBox");
- if (vb && !clone.getAttribute("width")) {
- const parts = vb.split(/\s+/);
- if (parts.length === 4) {
- clone.setAttribute("width", parts[2]);
- clone.setAttribute("height", parts[3]);
- }
- }
- // Inject a full-bleed background rect so exports match the in-app
- // appearance instead of rendering on a transparent canvas (which
- // shows as white in most viewers / dark in others depending on OS).
- const bg = (background ?? "#18181b").trim();
- if (bg && bg.toLowerCase() !== "transparent" && bg.toLowerCase() !== "none") {
- const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
- const vbParts = (vb || "").split(/\s+/);
- if (vbParts.length === 4) {
- rect.setAttribute("x", vbParts[0]);
- rect.setAttribute("y", vbParts[1]);
- rect.setAttribute("width", vbParts[2]);
- rect.setAttribute("height", vbParts[3]);
- } else {
- rect.setAttribute("x", "0");
- rect.setAttribute("y", "0");
- rect.setAttribute("width", "100%");
- rect.setAttribute("height", "100%");
- }
- rect.setAttribute("fill", bg);
- clone.insertBefore(rect, clone.firstChild);
- }
- const serialiser = new XMLSerializer();
- const body = serialiser.serializeToString(clone);
- const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + body;
- triggerDownload(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }), filename);
- }
- function downloadText(content, filename, mime) {
- triggerDownload(
- new Blob([content ?? ""], { type: (mime ?? "text/plain") + ";charset=utf-8" }),
- filename);
- }
- function buildExportSvg(host, background) {
- const svg = host.querySelector("svg");
- if (!svg) return null;
- const clone = svg.cloneNode(true);
- if (!clone.getAttribute("xmlns")) {
- clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
- }
- if (!clone.getAttribute("xmlns:xlink")) {
- clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
- }
- // Prefer viewBox dimensions — Mermaid sets `width="100%"` on the
- // live SVG so that the responsive page layout can size it. Parsing
- // that as a number gives 100 (px), producing a postage-stamp PNG.
- // The viewBox carries the real document dimensions.
- const vb = clone.getAttribute("viewBox");
- const vbParts = (vb || "").split(/\s+/);
- let width = 0, height = 0;
- if (vbParts.length === 4) {
- width = parseFloat(vbParts[2]) || 0;
- height = parseFloat(vbParts[3]) || 0;
- }
- if (!width || !height) {
- const rect = svg.getBoundingClientRect();
- if (!width) width = rect.width;
- if (!height) height = rect.height;
- }
- // Pin explicit pixel dimensions so `new Image()` knows how to size
- // the bitmap. Strip any % units inherited from the live element.
- clone.setAttribute("width", String(width));
- clone.setAttribute("height", String(height));
- clone.removeAttribute("style");
- const bg = (background ?? "#18181b").trim();
- if (bg && bg.toLowerCase() !== "transparent" && bg.toLowerCase() !== "none") {
- const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
- if (vbParts.length === 4) {
- rect.setAttribute("x", vbParts[0]);
- rect.setAttribute("y", vbParts[1]);
- rect.setAttribute("width", vbParts[2]);
- rect.setAttribute("height", vbParts[3]);
- } else {
- rect.setAttribute("x", "0");
- rect.setAttribute("y", "0");
- rect.setAttribute("width", String(width));
- rect.setAttribute("height", String(height));
- }
- rect.setAttribute("fill", bg);
- clone.insertBefore(rect, clone.firstChild);
- }
- const serialiser = new XMLSerializer();
- const body = serialiser.serializeToString(clone);
- return { xml: body, width, height };
- }
- function downloadSvg(elementId, filename, background) {
- const host = document.getElementById(elementId);
- if (!host) {
- console.warn(`[rackpeekGraph] element '${elementId}' not found`);
- return;
- }
- const built = buildExportSvg(host, background);
- if (!built) {
- console.warn(`[rackpeekGraph] no SVG in element '${elementId}' to export`);
- return;
- }
- const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + built.xml;
- triggerDownload(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }), filename);
- }
- function downloadPng(elementId, filename, background, scale) {
- const host = document.getElementById(elementId);
- if (!host) {
- console.warn(`[rackpeekGraph] element '${elementId}' not found`);
- return Promise.resolve();
- }
- const built = buildExportSvg(host, background);
- if (!built || !built.width || !built.height) {
- console.warn(`[rackpeekGraph] cannot rasterise SVG in element '${elementId}'`);
- return Promise.resolve();
- }
- // Render at 2× DPI by default so the PNG is sharp on retina displays
- // and when zoomed in for documentation.
- const ratio = scale && scale > 0 ? scale : 2;
- // A data URL (vs Blob URL) avoids a class of foreignObject taint
- // issues in some browsers — the SVG is treated as same-origin and
- // doesn't get caught by the canvas security checks. The trade-off
- // is a longer string, which is fine for diagram-sized payloads.
- const url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(built.xml);
- return new Promise((resolve) => {
- const img = new Image();
- img.onload = () => {
- try {
- const canvas = document.createElement("canvas");
- canvas.width = Math.ceil(built.width * ratio);
- canvas.height = Math.ceil(built.height * ratio);
- const ctx = canvas.getContext("2d");
- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
- canvas.toBlob((png) => {
- if (png) {
- triggerDownload(png, filename);
- } else {
- // Tainted canvas — fall back to toDataURL which
- // throws SecurityError instead of returning null.
- try {
- const dataUrl = canvas.toDataURL("image/png");
- fetch(dataUrl)
- .then(r => r.blob())
- .then(b => triggerDownload(b, filename))
- .catch(e => console.warn("[rackpeekGraph] PNG fallback failed", e))
- .finally(resolve);
- return;
- } catch (e) {
- console.warn("[rackpeekGraph] canvas tainted, cannot export PNG", e);
- }
- }
- resolve();
- }, "image/png");
- } catch (e) {
- console.warn("[rackpeekGraph] PNG render failed", e);
- resolve();
- }
- };
- img.onerror = (err) => {
- console.warn("[rackpeekGraph] SVG could not be loaded as image (likely a foreignObject/HTML-label rendering issue in this browser)", err);
- resolve();
- };
- img.src = url;
- });
- }
- window.rackpeekGraph = { render, downloadSvg, downloadPng, downloadText };
- })();
|