Add SVG/PNG/draw.io export for rack concepts, center the add-device hint

- Extract the elevation's <svg> body into inc/elevation_svg_content.html,
  shared by the on-page elevation and a new standalone export template
  so they never drift apart.
- Add "Export" dropdown above the elevation: Front/Rear as standalone
  SVG (self-contained inline styles, no dependency on the page's CSS,
  absolute device-type image URLs so the file still works once saved
  elsewhere), Front/Rear as PNG (rasterized entirely client-side via
  <canvas> from that same SVG - no new server-side image dependency),
  and the whole concept as a draw.io (.drawio / mxGraph XML) diagram
  with both faces side by side, reusing the identical block geometry.
- Center the "+ Add device" hint on empty units instead of
  right-aligning it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 12:34:26 +02:00
co-authored by Claude Sonnet 5
parent 27d915c45e
commit 2c991e37ae
9 changed files with 387 additions and 82 deletions
@@ -0,0 +1,83 @@
/*
* "Export as PNG" - fetches the same standalone SVG the SVG download link points to
* and rasterizes it client-side via <canvas>. Deliberately no server-side image
* library involved: a NetBox plugin shouldn't need one just for this, and every
* browser can already turn an SVG into a PNG on its own.
*/
(function () {
'use strict';
const SCALE = 2; // render at 2x so the PNG stays crisp
document.querySelectorAll('.concept-export-png').forEach(function (link) {
link.addEventListener('click', function (event) {
event.preventDefault();
exportPng(link.dataset.svgUrl, link.dataset.filename || 'export.png');
});
});
function exportPng(svgUrl, filename) {
fetch(svgUrl, { credentials: 'same-origin' })
.then(function (response) {
if (!response.ok) {
throw new Error('Could not load the SVG (' + response.status + ').');
}
return response.text();
})
.then(function (svgText) {
rasterize(svgText, filename);
})
.catch(function () {
window.alert('The PNG could not be generated.');
});
}
function rasterize(svgText, filename) {
const sizeMatch = svgText.match(/<svg[^>]*\swidth="([\d.]+)"[^>]*\sheight="([\d.]+)"/);
const width = sizeMatch ? parseFloat(sizeMatch[1]) : 800;
const height = sizeMatch ? parseFloat(sizeMatch[2]) : 600;
const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
const svgUrl = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = function () {
URL.revokeObjectURL(svgUrl);
const canvas = document.createElement('canvas');
canvas.width = width * SCALE;
canvas.height = height * SCALE;
const ctx = canvas.getContext('2d');
// The SVG itself has a white background, but that's inside a <style> block,
// which some rasterizers (older WebKit) ignore when drawing to canvas.
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(function (blob) {
if (!blob) {
window.alert('The PNG could not be generated.');
return;
}
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
}, 'image/png');
};
img.onerror = function () {
URL.revokeObjectURL(svgUrl);
// Most likely a device type image from a different origin without
// permissive CORS headers, which taints the canvas and blocks toBlob().
window.alert('The PNG could not be generated. This can happen when a device '
+ 'type image is hosted on a different domain without CORS enabled.');
};
img.src = svgUrl;
}
})();