Lint / Python (flake8) (push) Failing after 47s
Lint / JS (eslint) (push) Successful in 17s
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m26s
Lint / Notify on failure (push) Successful in 7s
Test / Python Tests (pytest) (push) Successful in 2m2s
A USW Flex Mini lies on top of the NVR. A shelf item with Front drawing "No shelf: gear resting on what is below" draws no tray or ears, so its contents sit directly on the item under it. UniFi desk switches on a shelf (Flex Mini, Flex) are drawn with their live faceplate, scaled to their real width from Ubiquiti's tech specs (107 mm and 122.5 mm), with working ports. faceplateSvg() returns a faceplate's SVG on its own so it can be placed inside the shelf drawing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
587 lines
26 KiB
JavaScript
587 lines
26 KiB
JavaScript
'use strict';
|
|
/* global FACEPLATES, renderFaceplate, faceplateSvg, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
|
|
// Rack page: draws the rack front from /api/rack. Switches use their real faceplates,
|
|
// patch panels a generic panel; hovering a port lights up every port on the same cable run.
|
|
|
|
const esc = s => lt.escHtml(s == null ? '' : String(s));
|
|
const RATIO = 10.5; // a 19" rack's width to one unit's height
|
|
const KIND_TEXT = { patch_panel: 'Patch panel', wall_jack: 'Wall jack', device: 'Device', shelf: 'Shelf', other: 'Other' };
|
|
|
|
let _rack = null;
|
|
|
|
function speedText(mbps) {
|
|
if (!mbps) return '';
|
|
return mbps >= 1000 ? `${mbps / 1000}G` : `${mbps}M`;
|
|
}
|
|
|
|
function portMapOf(ports) {
|
|
const map = {};
|
|
Object.entries(ports || {}).forEach(([name, p]) => {
|
|
if (p.port_idx != null) map[p.port_idx] = Object.assign({ name }, p);
|
|
});
|
|
return map;
|
|
}
|
|
|
|
function portTitle(port, idx) {
|
|
const parts = [port && port.name && port.name !== `Port ${idx}` ? `Port ${idx} "${port.name}"` : `Port ${idx}`];
|
|
if (port) {
|
|
parts.push(port.up ? (speedText(port.speed_mbps) || 'up') : 'down');
|
|
const nb = port.lldp && (port.lldp.system_name || port.lldp.chassis_id);
|
|
if (nb) parts.push(`→ ${nb}`);
|
|
}
|
|
return esc(parts.join(' · '));
|
|
}
|
|
|
|
function isRackFaceplate(model) {
|
|
const spec = FACEPLATES[model];
|
|
return !!spec && !/fp-desk/.test(spec.frameClass || '');
|
|
}
|
|
|
|
// ── Item drawings ───────────────────────────────────────────────────────
|
|
function switchHtml(item, model) {
|
|
// A live switch, or a faceplate picked by Front drawing (no port data: drawn unlinked)
|
|
const sw = item.switch || { model, ports: {}, outlets: [], online: item.status === 'up' };
|
|
return renderFaceplate(model || sw.model, {
|
|
swName: item.device, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, rack: true,
|
|
titleFor: (port, idx) => portTitle(port, idx),
|
|
cutsheetFor: idx => {
|
|
const m = item.ports[String(idx)];
|
|
return m ? { mark: m.mark, text: m.text } : null;
|
|
},
|
|
});
|
|
}
|
|
|
|
function panelSvg(item) {
|
|
const count = Math.max(1, item.port_count || 24);
|
|
const units = item.rack_height || 1;
|
|
const rows = Math.max(1, Math.min(units * 2, Math.ceil(count / 24)));
|
|
const perRow = Math.ceil(count / rows);
|
|
const W = 1100, H = 105 * units, ear = 70;
|
|
const pitch = Math.min(38, (W - ear * 2 - 40) / perRow);
|
|
const left = (W - pitch * perRow) / 2;
|
|
const rowH = (H - 20) / rows;
|
|
// Label characters that fit under one port at the label's font size (9px, ~5.6px a character)
|
|
const fit = Math.max(3, Math.floor((pitch - 2) / 5.6));
|
|
const short = (t, max = fit) => (t.length > max ? `${t.slice(0, max - 1)}…` : t);
|
|
const ports = [];
|
|
for (let n = 1; n <= count; n++) {
|
|
const r = Math.floor((n - 1) / perRow), c = (n - 1) % perRow;
|
|
const x = left + c * pitch + pitch / 2, y = 10 + r * rowH + rowH / 2;
|
|
const m = item.ports[String(n)];
|
|
const label = item.labels[String(n)] || '';
|
|
const title = [`Port ${n}`, label && `"${label}"`, m ? m.text : 'not on the cutsheet'].filter(Boolean).join(' · ');
|
|
const attrs = m ? ` data-run="${m.run.join(' ')}" data-cable="${m.cable_id}" tabindex="0" role="link"` : '';
|
|
ports.push(`<g class="rack-pp-port${m ? ` rack-pp-${m.mark}` : ''}"${attrs} transform="translate(${x} ${y})">
|
|
<title>${esc(title)}</title>
|
|
<text class="rack-pp-num" y="-17" text-anchor="middle">${n}</text>
|
|
<rect class="rack-pp-jack" x="-12" y="-10" width="24" height="20" rx="2"/>
|
|
<rect class="rack-pp-hit" x="${-pitch / 2}" y="-28" width="${pitch}" height="56"/>
|
|
${label ? `<text class="rack-pp-label" y="24" text-anchor="middle">${esc(short(label))}</text>` : ''}
|
|
</g>`);
|
|
}
|
|
return `<svg class="rack-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="group"
|
|
aria-label="${esc(item.name)} patch panel">
|
|
<rect class="rack-pp-body" width="${W}" height="${H}" rx="3"/>
|
|
<text class="rack-pp-name" x="14" y="17"><title>${esc(item.name)}</title>${esc(short(item.name, 48))}</text>
|
|
${ports.join('')}
|
|
</svg>`;
|
|
}
|
|
|
|
const STATUS_CLASS = { up: 'dot-up', down: 'dot-down', degraded: 'dot-degraded' };
|
|
|
|
function statusDot(status) {
|
|
return status ? `<span class="${STATUS_CLASS[status] || 'dot-unknown'}" title="${esc(status)}"></span>` : '';
|
|
}
|
|
|
|
// Ubiquiti UACC-Rack-Panel-Patch-Blank-24, laid out from the CC0 NetBox devicetype-library image
|
|
// (elevation-images/Ubiquiti/ubiquiti-uacc-rack-panel-patch-blank-24.front.png): a grey steel face,
|
|
// darker ears with oval holes, 24 square keystone openings in one row with the cable-support bar
|
|
// behind them, and a narrow cable slot at each end. A port with a documented cable gets a keystone jack.
|
|
function uaccPanelSvg(item) {
|
|
const W = 1178, H = 108, x0 = 107, pitch = 40.5, ow = 34, oy = 36, oh = 40;
|
|
const count = Math.min(item.port_count || 24, 24);
|
|
const ports = [];
|
|
for (let n = 1; n <= count; n++) {
|
|
const x = x0 + (n - 1) * pitch;
|
|
const m = item.ports[String(n)];
|
|
const label = item.labels[String(n)] || '';
|
|
const title = [`Port ${n}`, label && `"${label}"`, m ? m.text : 'not on the cutsheet'].filter(Boolean).join(' · ');
|
|
const attrs = m ? ` data-run="${m.run.join(' ')}" data-cable="${m.cable_id}" tabindex="0" role="link"` : '';
|
|
const jack = m ? `<rect class="ua-keystone" x="${x + 3}" y="${oy + 3}" width="${ow - 6}" height="${oh - 6}" rx="2"/>
|
|
<path class="ua-keystone-hole" d="M${x + 7} ${oy + 13} h20 v14 h-4 v4 h-12 v-4 h-4 z"/>` : '';
|
|
ports.push(`<g class="rack-pp-port ua-port${m ? ` rack-pp-${m.mark}` : ''}"${attrs}>
|
|
<title>${esc(title)}</title>
|
|
<rect class="ua-open" x="${x}" y="${oy}" width="${ow}" height="${oh}"/>
|
|
<rect class="ua-bar" x="${x}" y="${oy + 19}" width="${ow}" height="5"/>
|
|
${jack}
|
|
<rect class="rack-pp-jack ua-mark" x="${x - 1}" y="${oy - 1}" width="${ow + 2}" height="${oh + 2}" rx="1"/>
|
|
<text class="ua-num" x="${x + ow / 2}" y="${oy - 6}" text-anchor="middle">${n}</text>
|
|
${label ? `<text class="ua-label" x="${x + ow / 2}" y="${oy + oh + 12}" text-anchor="middle">${esc(label.length > 7 ? `${label.slice(0, 6)}…` : label)}</text>` : ''}
|
|
<rect class="rack-pp-hit" x="${x - 3}" y="${oy - 16}" width="${pitch}" height="${oh + 32}"/>
|
|
</g>`);
|
|
}
|
|
const hole = (x, y) => `<rect class="ua-hole" x="${x}" y="${y}" width="22" height="13" rx="6.5"/>`;
|
|
return `<svg class="rack-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="group"
|
|
aria-label="${esc(item.name)} patch panel">
|
|
<title>${esc(item.name)}</title>
|
|
<rect class="ua-ear" x="0" y="2" width="44" height="${H - 4}" rx="3"/>
|
|
<rect class="ua-ear" x="${W - 44}" y="2" width="44" height="${H - 4}" rx="3"/>
|
|
${hole(8, 12)}${hole(8, 83)}${hole(W - 30, 12)}${hole(W - 30, 83)}
|
|
<rect class="ua-face" x="36" y="0" width="${W - 72}" height="${H}" rx="2"/>
|
|
<rect class="ua-lip" x="36" y="0" width="${W - 72}" height="4"/>
|
|
<rect class="ua-open" x="62" y="${oy}" width="28" height="${oh}"/><rect class="ua-bar" x="62" y="${oy + 19}" width="28" height="5"/>
|
|
<rect class="ua-open" x="${W - 95}" y="${oy}" width="28" height="${oh}"/><rect class="ua-bar" x="${W - 95}" y="${oy + 19}" width="28" height="5"/>
|
|
${ports.join('')}
|
|
</svg>`;
|
|
}
|
|
|
|
function plateHtml(item) {
|
|
const unlinked = item.kind === 'device' && !item.linked;
|
|
const sub = [item.device !== item.name ? item.device : '', KIND_TEXT[item.kind] || item.kind, item.location,
|
|
unlinked ? 'not linked to a Gandalf device' : ''].filter(Boolean).join(' · ');
|
|
return `<div class="rack-plate">
|
|
${statusDot(item.status)}<a class="device-link rack-plate-name" href="${esc(item.url)}">${esc(item.name)}</a>
|
|
<span class="rack-plate-sub">${esc(sub)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
// Live faceplates for UniFi desk gear sitting on a shelf (a Flex Mini on top of the NVR)
|
|
const shelfFaceplates = {
|
|
spec: model => FACEPLATES[model],
|
|
svg: (c, attrs) => faceplateSvg(c.model, {
|
|
swName: c.device || c.name, portMap: portMapOf(c.ports), outlets: [], online: c.status === 'up',
|
|
titleFor: (port, idx) => portTitle(port, idx),
|
|
}, attrs).svg,
|
|
};
|
|
|
|
function backHtml(item) {
|
|
// A full-depth item seen from the rear: its back, not its faceplate
|
|
return `<div class="rack-plate rack-back">
|
|
${statusDot(item.status)}<a class="device-link rack-plate-name" href="${esc(item.url)}">${esc(item.name)}</a>
|
|
<span class="rack-plate-sub">back of a full-depth ${esc(KIND_TEXT[item.kind] || item.kind).toLowerCase()}</span>
|
|
</div>`;
|
|
}
|
|
|
|
function itemHtml(item) {
|
|
let body;
|
|
if (item.through) body = backHtml(item);
|
|
else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
|
|
else if (CHASSIS_FACEPLATES[item.chassis]) body = switchHtml(item, CHASSIS_FACEPLATES[item.chassis]);
|
|
else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
|
|
else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item);
|
|
else if (item.kind === 'patch_panel') body = panelSvg(item);
|
|
else if (item.kind === 'shelf') body = shelfSvg(item, esc, shelfFaceplates);
|
|
else body = plateHtml(item);
|
|
return `<div class="rack-item rack-kind-${esc(item.kind)}" data-item="${esc(item.id)}">${body}</div>`;
|
|
}
|
|
|
|
function uLabels(top, bottom) {
|
|
const spans = [];
|
|
for (let u = top; u >= bottom; u--) spans.push(`<span>${u}</span>`);
|
|
return `<div class="rack-u" aria-hidden="true">${spans.join('')}</div>`;
|
|
}
|
|
|
|
// Long runs of empty units are folded into one thin band unless the viewer asks for them
|
|
const GAP_MIN = 3;
|
|
|
|
function showEmpty() {
|
|
try { return localStorage.getItem('gandalf.rackShowEmpty') === '1'; } catch (_) { return false; }
|
|
}
|
|
|
|
function gapRow(top, bottom) {
|
|
const n = top - bottom + 1;
|
|
return `<div class="rack-row rack-row-gap" data-gap="${n}" style="--n:${n}">
|
|
<div class="rack-u" aria-hidden="true"></div>
|
|
<div class="rack-gap">U${bottom}-${top} · ${n}U free</div>
|
|
</div>`;
|
|
}
|
|
|
|
function row(top, bottom, inner, cls) {
|
|
const n = top - bottom + 1;
|
|
return `<div class="rack-row ${cls}" style="--n:${n}">
|
|
${uLabels(top, bottom)}
|
|
<div class="rack-slot" style="aspect-ratio:${RATIO} / ${n}">${inner}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Page ────────────────────────────────────────────────────────────────
|
|
function renderTabs(data) {
|
|
const tabs = document.getElementById('rack-tabs');
|
|
tabs.hidden = data.racks.length < 2;
|
|
tabs.innerHTML = data.racks.map(r => {
|
|
const on = r.name === data.rack;
|
|
return `<button type="button" class="rack-tab${on ? ' active' : ''}" role="tab" aria-selected="${on}"
|
|
data-rack="${esc(r.name)}">${esc(r.name)} <span class="rack-tab-count">${r.count}</span></button>`;
|
|
}).join('');
|
|
const VIEW_TITLE = { front: 'front', behind: 'rear posts, from the front', rear: 'rear' };
|
|
document.getElementById('rack-title').textContent = `${data.rack} · ${VIEW_TITLE[data.face] || data.face}`;
|
|
document.querySelectorAll('.rack-face-btn').forEach(b => {
|
|
const on = b.dataset.face === data.face;
|
|
b.classList.toggle('active', on);
|
|
b.setAttribute('aria-pressed', on);
|
|
});
|
|
const r = data.racks.find(x => x.name === data.rack) || {};
|
|
document.querySelector('.rack-face-btn[data-face="behind"]').textContent = r.behind ? `Rear posts (${r.behind})` : 'Rear posts';
|
|
document.querySelector('.rack-face-btn[data-face="rear"]').textContent = r.rear ? `Rear (${r.rear})` : 'Rear';
|
|
}
|
|
|
|
function render(data) {
|
|
_rack = data;
|
|
renderTabs(data);
|
|
const byTop = Object.fromEntries(data.items.map(i => [i.rack_top, i]));
|
|
const rows = [];
|
|
let u = data.units;
|
|
while (u >= 1) {
|
|
const item = byTop[u];
|
|
if (item) {
|
|
rows.push(row(u, item.rack_u, itemHtml(item), 'rack-row-item'));
|
|
u = item.rack_u - 1;
|
|
continue;
|
|
}
|
|
let low = u;
|
|
while (low - 1 >= 1 && !byTop[low - 1]) low--;
|
|
rows.push(!showEmpty() && u - low + 1 >= GAP_MIN ? gapRow(u, low) : row(u, low, '', 'rack-row-empty'));
|
|
u = low - 1;
|
|
}
|
|
document.getElementById('rack').innerHTML = rows.join('');
|
|
// Every rack device is 19" wide: stretch a faceplate to fill its slot when its drawing is close to
|
|
// the slot's proportions. A drawing far from them (the PDU's is twice as tall as a real 1U) keeps its
|
|
// shape rather than being squashed.
|
|
document.querySelectorAll('#rack .rack-slot').forEach(slot => {
|
|
const svg = slot.querySelector('.fp-svg');
|
|
if (!svg) return;
|
|
const [, , w, h] = svg.getAttribute('viewBox').split(' ').map(Number);
|
|
const n = parseInt(slot.closest('.rack-row').style.getPropertyValue('--n'), 10);
|
|
const off = (w / h) / (RATIO / n);
|
|
svg.setAttribute('preserveAspectRatio', off > 0.85 && off < 1.15 ? 'none' : 'xMidYMid meet');
|
|
});
|
|
document.getElementById('rack-show-empty').checked = showEmpty();
|
|
document.getElementById('rack-count').textContent = data.items.length;
|
|
|
|
// Faceplate ports carry their cable run too, for hover and click
|
|
document.querySelectorAll('.rack-item .fp-port').forEach(g => {
|
|
const item = data.items.find(i => i.device === g.dataset.switch);
|
|
const m = item && item.ports[g.dataset.portIdx];
|
|
if (m) {
|
|
g.dataset.run = m.run.join(' ');
|
|
g.dataset.cable = m.cable_id;
|
|
}
|
|
});
|
|
|
|
const notes = [];
|
|
data.overlaps.forEach(o => notes.push(`<strong>${esc(o.name)}</strong> (${esc(o.units)}) overlaps ${esc(o.with.join(', '))} and is not drawn.`));
|
|
data.outside.forEach(o => notes.push(`<strong>${esc(o.name)}</strong> (${esc(o.units)}) is above the top of a ${data.units}U rack and is not drawn.`));
|
|
const warn = document.getElementById('rack-warnings');
|
|
warn.hidden = !notes.length;
|
|
warn.innerHTML = notes.length ? `<ul>${notes.map(n => `<li>${n}</li>`).join('')}</ul>
|
|
<p>Fix the rack unit on the <a href="/cutsheet#cs-items-section">cutsheet</a>.</p>` : '';
|
|
|
|
const unplaced = document.getElementById('rack-unplaced');
|
|
unplaced.innerHTML = data.unplaced.length
|
|
? `Not in the drawing (no rack unit set): ${data.unplaced.map(n => `<span class="cs-chip">${esc(n)}</span>`).join(' ')}`
|
|
: '';
|
|
|
|
if (!data.items.length) {
|
|
document.getElementById('rack').insertAdjacentHTML('afterbegin',
|
|
`<div class="lt-empty-state lt-empty-state--sm rack-empty"><div class="lt-empty-state-title">Nothing placed yet</div>
|
|
<div class="lt-empty-state-text">Give panels, shelves and devices a rack unit on the <a href="/cutsheet">cutsheet</a> and they appear here.</div></div>`);
|
|
}
|
|
}
|
|
|
|
function readout(el) {
|
|
const out = document.getElementById('rack-readout');
|
|
if (!el) {
|
|
out.textContent = 'Hover a port to follow its cable.';
|
|
return;
|
|
}
|
|
const title = el.querySelector('title');
|
|
out.textContent = title ? title.textContent : '';
|
|
}
|
|
|
|
function highlight(el) {
|
|
const ids = el && el.dataset.run ? el.dataset.run.split(' ') : [];
|
|
document.querySelectorAll('#rack [data-run]').forEach(x => {
|
|
x.classList.toggle('rack-hl', ids.length > 0 && x.dataset.run.split(' ').some(i => ids.includes(i)));
|
|
});
|
|
}
|
|
|
|
function portAt(target) {
|
|
return target.closest && target.closest('#rack .fp-port, #rack .rack-pp-port');
|
|
}
|
|
|
|
function onEnter(e) {
|
|
const el = portAt(e.target);
|
|
if (!el) return;
|
|
highlight(el);
|
|
readout(el);
|
|
}
|
|
|
|
function onLeave(e) {
|
|
const el = portAt(e.target);
|
|
if (el && !el.contains(e.relatedTarget)) {
|
|
highlight(null);
|
|
readout(null);
|
|
}
|
|
}
|
|
|
|
function openPort(el) {
|
|
if (el.classList.contains('fp-port')) {
|
|
const item = _rack && _rack.items.find(i => i.device === el.dataset.switch);
|
|
if (item && !item.linked) {
|
|
// A faceplate drawn by Front drawing for gear Gandalf can't see (the NVR) has no device page:
|
|
// open the port's cable if one is documented, otherwise the item on the cutsheet
|
|
location.href = el.dataset.cable ? `/cutsheet#cable-${encodeURIComponent(el.dataset.cable)}` : item.url;
|
|
return;
|
|
}
|
|
location.href = `/device/${encodeURIComponent(el.dataset.switch)}#port-${encodeURIComponent(el.dataset.portIdx)}`;
|
|
} else if (el.dataset.cable) {
|
|
location.href = `/cutsheet#cable-${encodeURIComponent(el.dataset.cable)}`;
|
|
}
|
|
}
|
|
|
|
const rackEl = document.getElementById('rack');
|
|
rackEl.addEventListener('mouseover', onEnter);
|
|
rackEl.addEventListener('mouseout', onLeave);
|
|
rackEl.addEventListener('focusin', onEnter);
|
|
rackEl.addEventListener('focusout', onLeave);
|
|
rackEl.addEventListener('click', e => {
|
|
const el = portAt(e.target);
|
|
if (el) openPort(el);
|
|
});
|
|
rackEl.addEventListener('keydown', e => {
|
|
const el = portAt(e.target);
|
|
if (el && (e.key === 'Enter' || e.key === ' ')) {
|
|
e.preventDefault();
|
|
openPort(el);
|
|
}
|
|
});
|
|
|
|
function currentRack() {
|
|
return new URLSearchParams(location.search).get('rack') || '';
|
|
}
|
|
|
|
function currentFace() {
|
|
const face = new URLSearchParams(location.search).get('face');
|
|
return ['behind', 'rear'].includes(face) ? face : 'front';
|
|
}
|
|
|
|
function setView(changes) {
|
|
const q = new URLSearchParams(location.search);
|
|
Object.entries(changes).forEach(([k, v]) => (v ? q.set(k, v) : q.delete(k)));
|
|
const qs = q.toString();
|
|
history.replaceState(null, '', qs ? `?${qs}` : location.pathname);
|
|
_rack = null;
|
|
load();
|
|
}
|
|
|
|
document.getElementById('rack-tabs').addEventListener('click', e => {
|
|
const tab = e.target.closest('[data-rack]');
|
|
if (!tab) return;
|
|
const first = _rack && _rack.racks[0] && _rack.racks[0].name;
|
|
setView({ rack: tab.dataset.rack === first ? '' : tab.dataset.rack });
|
|
});
|
|
|
|
document.querySelector('.rack-face').addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-face]');
|
|
if (btn) setView({ face: btn.dataset.face === 'front' ? '' : btn.dataset.face });
|
|
});
|
|
|
|
async function load() {
|
|
try {
|
|
const q = new URLSearchParams();
|
|
if (currentRack()) q.set('rack', currentRack());
|
|
if (currentFace() !== 'front') q.set('face', currentFace());
|
|
render(await lt.api.get(`/api/rack${q.toString() ? `?${q}` : ''}`));
|
|
} catch (err) {
|
|
if (_rack) return; // keep the last drawing on a failed refresh
|
|
document.getElementById('rack').innerHTML = `<div class="lt-empty-state lt-empty-state--sm">
|
|
<div class="lt-empty-state-title">Could not load the rack</div>
|
|
<div class="lt-empty-state-text">${esc(err.message || err)}</div></div>`;
|
|
}
|
|
}
|
|
|
|
readout(null);
|
|
load();
|
|
setInterval(() => { if (!document.hidden && !document.querySelector('#rack .rack-hl')) load(); }, 60000);
|
|
|
|
// ── Export (SVG / PNG) ──────────────────────────────────────────────────
|
|
// Rebuilds the drawing as one standalone SVG on a white page, for printing or planning.
|
|
// Faceplates and panels are copied from the page with their computed styles inlined,
|
|
// since the stylesheet doesn't travel with the file.
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
const STYLE_PROPS = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray',
|
|
'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-size', 'font-family', 'font-weight', 'letter-spacing',
|
|
'text-anchor', 'dominant-baseline', 'display', 'visibility'];
|
|
|
|
function inlineStyles(src, dst) {
|
|
const cs = getComputedStyle(src);
|
|
dst.setAttribute('style', STYLE_PROPS.map(p => `${p}:${cs.getPropertyValue(p)}`).join(';'));
|
|
dst.removeAttribute('class');
|
|
for (let i = 0; i < src.children.length; i++) inlineStyles(src.children[i], dst.children[i]);
|
|
}
|
|
|
|
function svgEl(tag, attrs, text) {
|
|
const el = document.createElementNS(SVG_NS, tag);
|
|
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
|
|
if (text != null) el.textContent = text;
|
|
return el;
|
|
}
|
|
|
|
function buildExport() {
|
|
document.querySelectorAll('#rack .rack-hl').forEach(el => el.classList.remove('rack-hl'));
|
|
const W = 1100, gutter = 46, pad = 20, head = 44, unit = W / RATIO;
|
|
const rows = [...document.querySelectorAll('#rack .rack-row')];
|
|
const height = rows.reduce((h, r) => h + (r.dataset.gap ? 26 : parseInt(r.style.getPropertyValue('--n'), 10) * unit), 0);
|
|
const H = head + height + pad * 2;
|
|
const root = svgEl('svg', { xmlns: SVG_NS, width: W + gutter + pad * 2, height: H,
|
|
viewBox: `0 0 ${W + gutter + pad * 2} ${H}`, 'font-family': 'Helvetica, Arial, sans-serif' });
|
|
root.appendChild(svgEl('rect', { width: '100%', height: '100%', fill: '#ffffff' }));
|
|
const stamp = new Date().toLocaleString();
|
|
root.appendChild(svgEl('text', { x: pad, y: pad + 14, 'font-size': 18, 'font-weight': 700, fill: '#111' },
|
|
`${_rack.rack} · ${_rack.face}`));
|
|
root.appendChild(svgEl('text', { x: W + gutter + pad, y: pad + 14, 'font-size': 11, fill: '#666', 'text-anchor': 'end' },
|
|
`Gandalf · ${stamp}`));
|
|
|
|
let y = head + pad;
|
|
const x = pad + gutter;
|
|
rows.forEach(row => {
|
|
const n = parseInt(row.style.getPropertyValue('--n'), 10);
|
|
if (row.dataset.gap) {
|
|
const band = 26;
|
|
root.appendChild(svgEl('rect', { x, y: y + 2, width: W, height: band - 4, fill: '#f4f5f7', stroke: '#d5d9de',
|
|
'stroke-dasharray': '4 4' }));
|
|
root.appendChild(svgEl('text', { x: x + W / 2, y: y + band / 2 + 4, 'font-size': 11, fill: '#777',
|
|
'text-anchor': 'middle' }, row.textContent.trim()));
|
|
y += band;
|
|
return;
|
|
}
|
|
const h = n * unit;
|
|
[...row.querySelectorAll('.rack-u span')].forEach((s, i) => {
|
|
root.appendChild(svgEl('text', { x: x - 8, y: y + unit * i + unit / 2 + 4, 'font-size': 11, fill: '#777',
|
|
'text-anchor': 'end' }, s.textContent));
|
|
});
|
|
const art = row.querySelector('svg.fp-svg, svg.rack-svg');
|
|
const plate = row.querySelector('.rack-plate');
|
|
if (art) {
|
|
const clone = art.cloneNode(true);
|
|
inlineStyles(art, clone);
|
|
['x', 'y', 'width', 'height'].forEach((k, i) => clone.setAttribute(k, [x, y, W, h][i]));
|
|
clone.setAttribute('preserveAspectRatio', art.getAttribute('preserveAspectRatio') || 'xMidYMid meet');
|
|
clone.removeAttribute('style');
|
|
root.appendChild(clone);
|
|
} else if (plate) {
|
|
root.appendChild(svgEl('rect', { x, y: y + 1, width: W, height: h - 2, rx: 3, fill: '#e9ecef', stroke: '#9aa3ad' }));
|
|
const name = plate.querySelector('.rack-plate-name, .rack-shelf-label');
|
|
const things = [...plate.querySelectorAll('.rack-shelf-name')].map(t => t.textContent);
|
|
const sub = things.length ? things.join(' · ') : (plate.querySelector('.rack-plate-sub') || {}).textContent || '';
|
|
root.appendChild(svgEl('text', { x: x + 16, y: y + h / 2 + (sub ? -2 : 5), 'font-size': 15, 'font-weight': 700,
|
|
fill: '#111' }, name ? name.textContent : ''));
|
|
if (sub) root.appendChild(svgEl('text', { x: x + 16, y: y + h / 2 + 15, 'font-size': 11, fill: '#444' }, sub));
|
|
} else {
|
|
root.appendChild(svgEl('rect', { x, y: y + 1, width: W, height: h - 2, fill: 'none', stroke: '#d5d9de',
|
|
'stroke-dasharray': '4 4' }));
|
|
}
|
|
y += h;
|
|
});
|
|
return new XMLSerializer().serializeToString(root);
|
|
}
|
|
|
|
function download(blob, name) {
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = name;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
|
|
}
|
|
|
|
async function exportRack(kind) {
|
|
if (!_rack || !_rack.items.length) {
|
|
lt.toast.error('Nothing in this rack to export yet');
|
|
return;
|
|
}
|
|
const svg = buildExport();
|
|
const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-${_rack.face}`;
|
|
if (kind === 'svg') {
|
|
download(new Blob([svg], { type: 'image/svg+xml' }), `${base}.svg`);
|
|
return;
|
|
}
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
|
|
try {
|
|
await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; });
|
|
const scale = 2;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = img.width * scale;
|
|
canvas.height = img.height * scale;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.scale(scale, scale);
|
|
ctx.drawImage(img, 0, 0);
|
|
canvas.toBlob(blob => download(blob, `${base}.png`), 'image/png');
|
|
} catch (err) {
|
|
lt.toast.error('Could not render the PNG; try the SVG');
|
|
} finally {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
document.getElementById('rack-show-empty').addEventListener('change', e => {
|
|
try { localStorage.setItem('gandalf.rackShowEmpty', e.target.checked ? '1' : '0'); } catch (_) { /* private mode */ }
|
|
if (_rack) render(_rack);
|
|
});
|
|
|
|
document.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', () => exportRack(btn.dataset.export)));
|
|
|
|
// ── Rack settings (admins) ──────────────────────────────────────────────
|
|
function openRackForm(adding) {
|
|
const form = document.getElementById('rack-form');
|
|
form.reset();
|
|
const r = adding ? null : (_rack.racks.find(x => x.name === _rack.rack) || {});
|
|
form.id.value = r && r.id ? r.id : '';
|
|
// The default rack exists only in config until saved; its items follow the new name
|
|
form.previous.value = r && !r.id ? r.name : '';
|
|
form.name.value = r ? r.name : '';
|
|
form.units.value = r ? r.units : 42;
|
|
form.notes.value = r ? (r.notes || '') : '';
|
|
form.querySelector('[data-action="delete-rack"]').hidden = !(r && r.id);
|
|
document.getElementById('rack-modal-title').textContent = adding ? 'Add a rack' : 'Rack settings';
|
|
lt.modal.open('rack-modal');
|
|
form.name.focus();
|
|
}
|
|
|
|
async function saveRack(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const body = { name: form.name.value.trim(), units: form.units.value, notes: form.notes.value, previous: form.previous.value };
|
|
try {
|
|
if (form.id.value) await lt.api.put(`/api/cutsheet/racks/${encodeURIComponent(form.id.value)}`, body);
|
|
else await lt.api.post('/api/cutsheet/racks', body);
|
|
lt.modal.close('rack-modal');
|
|
lt.toast.success('Rack saved');
|
|
setView({ rack: body.name });
|
|
} catch (err) {
|
|
lt.toast.error(err.message || 'Save failed');
|
|
}
|
|
}
|
|
|
|
async function deleteRack() {
|
|
const form = document.getElementById('rack-form');
|
|
if (!form.id.value || !window.confirm(`Delete the rack ${form.name.value}?`)) return;
|
|
try {
|
|
await lt.api.delete(`/api/cutsheet/racks/${encodeURIComponent(form.id.value)}`);
|
|
lt.modal.close('rack-modal');
|
|
lt.toast.success('Rack deleted');
|
|
setView({ rack: '' });
|
|
} catch (err) {
|
|
lt.toast.error(err.message || 'Delete failed');
|
|
}
|
|
}
|
|
|
|
if (RACK_CAN_EDIT) {
|
|
document.querySelector('[data-action="rack-settings"]').addEventListener('click', () => _rack && openRackForm(false));
|
|
document.querySelector('[data-action="add-rack"]').addEventListener('click', () => _rack && openRackForm(true));
|
|
document.getElementById('rack-form').addEventListener('submit', saveRack);
|
|
document.querySelector('[data-action="delete-rack"]').addEventListener('click', deleteRack);
|
|
}
|