index.js 16 KB

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