index.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. // RackPeek graph rendering shim.
  2. //
  3. // Public API (called from Blazor via JSInterop):
  4. // window.rackpeekGraph.render(elementId, mermaidSource)
  5. // – Wipes the target element, runs Mermaid on the source, and inserts
  6. // the resulting SVG with pan/zoom enabled.
  7. //
  8. // Assumes Mermaid is already loaded globally (via <script src="mermaid.min.js">).
  9. // The ELK layout plugin is loaded lazily on first render.
  10. (function () {
  11. "use strict";
  12. let _initPromise = null;
  13. let _mermaidScriptPromise = null;
  14. function loadMermaidScript() {
  15. // Inject the (large) mermaid UMD bundle on first use rather than
  16. // shipping it on every page. Keeps non-graph pages light so Blazor's
  17. // SignalR circuit establishes promptly under load.
  18. if (_mermaidScriptPromise) return _mermaidScriptPromise;
  19. _mermaidScriptPromise = new Promise((resolve, reject) => {
  20. if (window.mermaid) {
  21. resolve();
  22. return;
  23. }
  24. const script = document.createElement("script");
  25. script.src = "/_content/Shared.Rcl/js/graph/mermaid.min.js";
  26. script.async = true;
  27. script.onload = () => resolve();
  28. script.onerror = () => reject(new Error("Failed to load mermaid.min.js"));
  29. document.head.appendChild(script);
  30. });
  31. return _mermaidScriptPromise;
  32. }
  33. async function ensureInitialised() {
  34. if (_initPromise) return _initPromise;
  35. _initPromise = (async () => {
  36. await loadMermaidScript();
  37. if (!window.mermaid) {
  38. throw new Error("Mermaid bundle loaded but window.mermaid is undefined");
  39. }
  40. // Register the ELK layout loader. Mermaid 11 dispatches by the
  41. // `layout` config key; "elk" maps to the layered algorithm.
  42. try {
  43. const elk = await import("./mermaid-layout-elk.min.mjs");
  44. window.mermaid.registerLayoutLoaders(elk.default);
  45. } catch (e) {
  46. // Fall back silently to the default dagre layout — still
  47. // renders, just with the less polished arrow routing.
  48. console.warn("[rackpeekGraph] ELK plugin failed to load:", e);
  49. }
  50. window.mermaid.initialize({
  51. startOnLoad: false,
  52. securityLevel: "loose",
  53. theme: "dark",
  54. fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif"
  55. });
  56. })();
  57. return _initPromise;
  58. }
  59. async function render(elementId, source) {
  60. const host = document.getElementById(elementId);
  61. if (host) host.innerHTML = "";
  62. // Empty source = "clear" request. Don't hand "" to mermaid.render —
  63. // it treats that as malformed input and produces a "Syntax error in
  64. // text" SVG which can leak out into the page if the host element has
  65. // already been detached (e.g. component disposal during navigation).
  66. if (!source || !source.trim()) {
  67. cleanupOrphans();
  68. return;
  69. }
  70. // Defer the (heavy) mermaid + ELK work to browser idle time so the
  71. // Blazor circuit and nav-click handlers stay responsive on pages
  72. // that render diagrams (e.g. the homepage). If the host element is
  73. // gone by the time idle fires (user navigated away), bail.
  74. await waitForIdle();
  75. if (!document.getElementById(elementId)) {
  76. cleanupOrphans();
  77. return;
  78. }
  79. await ensureInitialised();
  80. // The host may have been detached while ensureInitialised was awaiting
  81. // (especially on first render). Re-fetch and bail if it's gone.
  82. const liveHost = document.getElementById(elementId);
  83. if (!liveHost) {
  84. cleanupOrphans();
  85. return;
  86. }
  87. // A unique id per render avoids collisions when the same element is
  88. // re-rendered with different source.
  89. const renderId = `rpkg-${elementId}-${Date.now()}`;
  90. let result;
  91. try {
  92. result = await window.mermaid.render(renderId, source);
  93. } finally {
  94. // Mermaid creates a scratch <div id="d{renderId}"> in <body> for
  95. // measurement and normally removes it; sweep up just in case.
  96. const scratch = document.getElementById("d" + renderId);
  97. if (scratch && scratch.parentElement) scratch.parentElement.removeChild(scratch);
  98. }
  99. // Host may have been disposed during the render await.
  100. const stillLive = document.getElementById(elementId);
  101. if (!stillLive) {
  102. cleanupOrphans();
  103. return;
  104. }
  105. stillLive.innerHTML = result.svg;
  106. const svgEl = stillLive.querySelector("svg");
  107. if (svgEl) {
  108. // Let the SVG fill its container rather than honouring the
  109. // intrinsic max-width Mermaid sets, so pan/zoom feels natural.
  110. svgEl.removeAttribute("width");
  111. svgEl.removeAttribute("height");
  112. svgEl.style.maxWidth = "100%";
  113. svgEl.style.width = "100%";
  114. svgEl.style.height = "100%";
  115. }
  116. if (result.bindFunctions) result.bindFunctions(stillLive);
  117. }
  118. function waitForIdle() {
  119. return new Promise((resolve) => {
  120. if (typeof window.requestIdleCallback === "function") {
  121. // 2s timeout means we still fire eventually if the browser
  122. // never goes idle.
  123. window.requestIdleCallback(() => resolve(), { timeout: 2000 });
  124. } else {
  125. // Safari < 16 has no requestIdleCallback — fall back to a
  126. // short defer that still yields the current event loop.
  127. setTimeout(resolve, 50);
  128. }
  129. });
  130. }
  131. function cleanupOrphans() {
  132. // Mermaid sometimes leaves "d{renderId}" scratch nodes attached to
  133. // <body> when the originating host is gone — remove any that match
  134. // our renderId prefix.
  135. document.querySelectorAll("body > [id^='drpkg-']").forEach((el) => {
  136. el.parentElement?.removeChild(el);
  137. });
  138. }
  139. function triggerDownload(blob, filename) {
  140. const url = URL.createObjectURL(blob);
  141. const a = document.createElement("a");
  142. a.href = url;
  143. a.download = filename;
  144. document.body.appendChild(a);
  145. a.click();
  146. document.body.removeChild(a);
  147. // Defer revoke so Safari has time to start the download.
  148. setTimeout(() => URL.revokeObjectURL(url), 1000);
  149. }
  150. function downloadSvg(elementId, filename, background) {
  151. const host = document.getElementById(elementId);
  152. if (!host) {
  153. console.warn(`[rackpeekGraph] element '${elementId}' not found`);
  154. return;
  155. }
  156. const svg = host.querySelector("svg");
  157. if (!svg) {
  158. console.warn(`[rackpeekGraph] no SVG in element '${elementId}' to export`);
  159. return;
  160. }
  161. // Clone so the in-page interactive copy isn't modified. Ensure
  162. // xmlns + a viewBox-derived width/height so the file renders cleanly
  163. // in any standalone viewer.
  164. const clone = svg.cloneNode(true);
  165. if (!clone.getAttribute("xmlns")) {
  166. clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
  167. }
  168. if (!clone.getAttribute("xmlns:xlink")) {
  169. clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
  170. }
  171. const vb = clone.getAttribute("viewBox");
  172. if (vb && !clone.getAttribute("width")) {
  173. const parts = vb.split(/\s+/);
  174. if (parts.length === 4) {
  175. clone.setAttribute("width", parts[2]);
  176. clone.setAttribute("height", parts[3]);
  177. }
  178. }
  179. // Inject a full-bleed background rect so exports match the in-app
  180. // appearance instead of rendering on a transparent canvas (which
  181. // shows as white in most viewers / dark in others depending on OS).
  182. const bg = (background ?? "#18181b").trim();
  183. if (bg && bg.toLowerCase() !== "transparent" && bg.toLowerCase() !== "none") {
  184. const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
  185. const vbParts = (vb || "").split(/\s+/);
  186. if (vbParts.length === 4) {
  187. rect.setAttribute("x", vbParts[0]);
  188. rect.setAttribute("y", vbParts[1]);
  189. rect.setAttribute("width", vbParts[2]);
  190. rect.setAttribute("height", vbParts[3]);
  191. } else {
  192. rect.setAttribute("x", "0");
  193. rect.setAttribute("y", "0");
  194. rect.setAttribute("width", "100%");
  195. rect.setAttribute("height", "100%");
  196. }
  197. rect.setAttribute("fill", bg);
  198. clone.insertBefore(rect, clone.firstChild);
  199. }
  200. const serialiser = new XMLSerializer();
  201. const body = serialiser.serializeToString(clone);
  202. const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + body;
  203. triggerDownload(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }), filename);
  204. }
  205. function downloadText(content, filename, mime) {
  206. triggerDownload(
  207. new Blob([content ?? ""], { type: (mime ?? "text/plain") + ";charset=utf-8" }),
  208. filename);
  209. }
  210. function buildExportSvg(host, background) {
  211. const svg = host.querySelector("svg");
  212. if (!svg) return null;
  213. const clone = svg.cloneNode(true);
  214. if (!clone.getAttribute("xmlns")) {
  215. clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
  216. }
  217. if (!clone.getAttribute("xmlns:xlink")) {
  218. clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
  219. }
  220. // Prefer viewBox dimensions — Mermaid sets `width="100%"` on the
  221. // live SVG so that the responsive page layout can size it. Parsing
  222. // that as a number gives 100 (px), producing a postage-stamp PNG.
  223. // The viewBox carries the real document dimensions.
  224. const vb = clone.getAttribute("viewBox");
  225. const vbParts = (vb || "").split(/\s+/);
  226. let width = 0, height = 0;
  227. if (vbParts.length === 4) {
  228. width = parseFloat(vbParts[2]) || 0;
  229. height = parseFloat(vbParts[3]) || 0;
  230. }
  231. if (!width || !height) {
  232. const rect = svg.getBoundingClientRect();
  233. if (!width) width = rect.width;
  234. if (!height) height = rect.height;
  235. }
  236. // Pin explicit pixel dimensions so `new Image()` knows how to size
  237. // the bitmap. Strip any % units inherited from the live element.
  238. clone.setAttribute("width", String(width));
  239. clone.setAttribute("height", String(height));
  240. clone.removeAttribute("style");
  241. const bg = (background ?? "#18181b").trim();
  242. if (bg && bg.toLowerCase() !== "transparent" && bg.toLowerCase() !== "none") {
  243. const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
  244. if (vbParts.length === 4) {
  245. rect.setAttribute("x", vbParts[0]);
  246. rect.setAttribute("y", vbParts[1]);
  247. rect.setAttribute("width", vbParts[2]);
  248. rect.setAttribute("height", vbParts[3]);
  249. } else {
  250. rect.setAttribute("x", "0");
  251. rect.setAttribute("y", "0");
  252. rect.setAttribute("width", String(width));
  253. rect.setAttribute("height", String(height));
  254. }
  255. rect.setAttribute("fill", bg);
  256. clone.insertBefore(rect, clone.firstChild);
  257. }
  258. const serialiser = new XMLSerializer();
  259. const body = serialiser.serializeToString(clone);
  260. return { xml: body, width, height };
  261. }
  262. function downloadSvg(elementId, filename, background) {
  263. const host = document.getElementById(elementId);
  264. if (!host) {
  265. console.warn(`[rackpeekGraph] element '${elementId}' not found`);
  266. return;
  267. }
  268. const built = buildExportSvg(host, background);
  269. if (!built) {
  270. console.warn(`[rackpeekGraph] no SVG in element '${elementId}' to export`);
  271. return;
  272. }
  273. const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + built.xml;
  274. triggerDownload(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }), filename);
  275. }
  276. function downloadPng(elementId, filename, background, scale) {
  277. const host = document.getElementById(elementId);
  278. if (!host) {
  279. console.warn(`[rackpeekGraph] element '${elementId}' not found`);
  280. return Promise.resolve();
  281. }
  282. const built = buildExportSvg(host, background);
  283. if (!built || !built.width || !built.height) {
  284. console.warn(`[rackpeekGraph] cannot rasterise SVG in element '${elementId}'`);
  285. return Promise.resolve();
  286. }
  287. // Render at 2× DPI by default so the PNG is sharp on retina displays
  288. // and when zoomed in for documentation.
  289. const ratio = scale && scale > 0 ? scale : 2;
  290. // A data URL (vs Blob URL) avoids a class of foreignObject taint
  291. // issues in some browsers — the SVG is treated as same-origin and
  292. // doesn't get caught by the canvas security checks. The trade-off
  293. // is a longer string, which is fine for diagram-sized payloads.
  294. const url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(built.xml);
  295. return new Promise((resolve) => {
  296. const img = new Image();
  297. img.onload = () => {
  298. try {
  299. const canvas = document.createElement("canvas");
  300. canvas.width = Math.ceil(built.width * ratio);
  301. canvas.height = Math.ceil(built.height * ratio);
  302. const ctx = canvas.getContext("2d");
  303. ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  304. canvas.toBlob((png) => {
  305. if (png) {
  306. triggerDownload(png, filename);
  307. } else {
  308. // Tainted canvas — fall back to toDataURL which
  309. // throws SecurityError instead of returning null.
  310. try {
  311. const dataUrl = canvas.toDataURL("image/png");
  312. fetch(dataUrl)
  313. .then(r => r.blob())
  314. .then(b => triggerDownload(b, filename))
  315. .catch(e => console.warn("[rackpeekGraph] PNG fallback failed", e))
  316. .finally(resolve);
  317. return;
  318. } catch (e) {
  319. console.warn("[rackpeekGraph] canvas tainted, cannot export PNG", e);
  320. }
  321. }
  322. resolve();
  323. }, "image/png");
  324. } catch (e) {
  325. console.warn("[rackpeekGraph] PNG render failed", e);
  326. resolve();
  327. }
  328. };
  329. img.onerror = (err) => {
  330. console.warn("[rackpeekGraph] SVG could not be loaded as image (likely a foreignObject/HTML-label rendering issue in this browser)", err);
  331. resolve();
  332. };
  333. img.src = url;
  334. });
  335. }
  336. window.rackpeekGraph = { render, downloadSvg, downloadPng, downloadText };
  337. })();