Files
c0de.it/public/assets/app.js
Lars Gebhardt-Kusche e7708831d0
All checks were successful
Deploy / deploy (push) Successful in 15s
ui rack
2026-05-15 23:23:35 +02:00

1048 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
bootstrap: null,
rackTemplateId: null,
projectName: "Neues Rack-Projekt",
rackColor: "#50545c",
components: [],
placedItems: [],
nextPlacementId: 1,
dragPlacementId: null,
pointerDrag: null,
selectedComponentId: null,
};
const ui = {};
const RACK_UNIT_PX = 38;
document.addEventListener("DOMContentLoaded", async () => {
cacheDom();
bindEvents();
await loadBootstrap();
});
function cacheDom() {
ui.templateSelect = document.getElementById("rack-template-select");
ui.projectName = document.getElementById("project-name");
ui.rackColor = document.getElementById("rack-color");
ui.selectedComponentInfo = document.getElementById("selected-component-info");
ui.componentFilter = document.getElementById("component-filter");
ui.pluginInput = document.getElementById("plugin-input");
ui.componentLibrary = document.getElementById("component-library");
ui.rackSummary = document.getElementById("rack-summary");
ui.rackGrid = document.getElementById("rack-grid");
ui.projectStats = document.getElementById("project-stats");
ui.bomOutput = document.getElementById("bom-output");
ui.cableFrom = document.getElementById("cable-from");
ui.cableTo = document.getElementById("cable-to");
ui.cableSlack = document.getElementById("cable-slack");
ui.cableOutput = document.getElementById("cable-output");
ui.validationOutput = document.getElementById("validation-output");
ui.exportBom = document.getElementById("export-bom");
}
function bindEvents() {
ui.templateSelect.addEventListener("change", () => {
state.rackTemplateId = ui.templateSelect.value;
state.placedItems = [];
renderAll();
});
ui.projectName.addEventListener("input", () => {
state.projectName = ui.projectName.value.trim() || "Neues Rack-Projekt";
renderSummary();
});
ui.rackColor.addEventListener("input", () => {
state.rackColor = ui.rackColor.value;
renderRack();
});
ui.componentFilter.addEventListener("input", renderLibrary);
ui.cableSlack.addEventListener("input", renderCableEstimate);
ui.cableFrom.addEventListener("change", renderCableEstimate);
ui.cableTo.addEventListener("change", renderCableEstimate);
ui.pluginInput.addEventListener("change", importPluginPack);
ui.exportBom.addEventListener("click", copyBomCsv);
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
}
async function loadBootstrap() {
const response = await fetch(window.APP_CONFIG.apiBootstrapUrl, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Bootstrap request failed: ${response.status}`);
}
state.bootstrap = await response.json();
state.components = [...state.bootstrap.components];
state.rackTemplateId = state.bootstrap.rackTemplates[0]?.id ?? null;
renderTemplateOptions();
renderAll();
}
function renderAll() {
renderSummary();
renderLibrary();
renderSelectionInfo();
renderRack();
renderStats();
renderBom();
renderCableSelectors();
renderCableEstimate();
renderValidation();
}
function renderTemplateOptions() {
const options = state.bootstrap.rackTemplates
.map((template) => {
const selected = template.id === state.rackTemplateId ? " selected" : "";
return `<option value="${escapeHtml(template.id)}"${selected}>${escapeHtml(template.name)}</option>`;
})
.join("");
ui.templateSelect.innerHTML = options;
}
function renderSummary() {
const rack = getCurrentRackTemplate();
if (!rack) {
ui.rackSummary.innerHTML = "";
return;
}
ui.rackSummary.innerHTML = `
<div class="summary-card">
<span>Projekt</span>
<strong>${escapeHtml(state.projectName)}</strong>
</div>
<div class="summary-card">
<span>Rack-Standard</span>
<strong>${rack.rackStandard === "19_inch" ? "19 inch" : "10 inch"}</strong>
</div>
<div class="summary-card">
<span>Kapazitaet</span>
<strong>${rack.totalU}U / ${rack.usableDepthMm} mm</strong>
</div>
`;
}
function renderLibrary() {
const rack = getCurrentRackTemplate();
if (!rack) {
return;
}
const needle = ui.componentFilter.value.trim().toLowerCase();
const filtered = state.components.filter((component) => {
const compatible = component.rackStandard === rack.rackStandard;
const haystack = `${component.name} ${component.category} ${component.partNumber}`.toLowerCase();
return compatible && (!needle || haystack.includes(needle));
});
if (filtered.length === 0) {
ui.componentLibrary.innerHTML = `<div class="empty-state">Keine passenden Komponenten gefunden.</div>`;
return;
}
ui.componentLibrary.innerHTML = filtered
.map(
(component) => `
<article class="component-card component-card--${escapeHtml(component.category)}">
<div class="component-card__preview">
${renderComponentFace(component, "library")}
</div>
<div class="component-card__header">
<div>
<strong>${escapeHtml(component.name)}</strong>
<div>${escapeHtml(component.partNumber)}</div>
</div>
<span class="chip">${component.heightU}U</span>
</div>
<div class="component-card__meta">
<span>${escapeHtml(component.category)}</span>
<span>${component.depthMm} mm tief</span>
<span>${formatCurrency(component.priceNet, component.currency)}</span>
</div>
<button type="button" data-action="select-component" data-component-id="${escapeHtml(component.id)}">
${state.selectedComponentId === component.id ? "Ausgewaehlt" : "Zum Einstecken waehlen"}
</button>
</article>
`
)
.join("");
ui.componentLibrary.querySelectorAll("[data-action='select-component']").forEach((button) => {
button.addEventListener("click", () => {
state.selectedComponentId = button.dataset.componentId;
renderAll();
});
});
}
function addComponentToRack(componentId) {
const component = state.components.find((entry) => entry.id === componentId);
const rack = getCurrentRackTemplate();
if (!component || !rack) {
return;
}
const position = findFirstFreePosition(component.heightU);
if (position === null) {
alert("Keine freie Position fuer diese Komponente im aktuellen Rack.");
return;
}
state.placedItems.push({
placementId: `p${state.nextPlacementId++}`,
componentId: component.id,
y: position,
x: getDefaultComponentX(component),
});
renderAll();
}
function renderSelectionInfo() {
const component = getComponent(state.selectedComponentId);
if (!component) {
ui.selectedComponentInfo.textContent = "Kein Element ausgewaehlt";
ui.selectedComponentInfo.classList.remove("is-active");
return;
}
ui.selectedComponentInfo.textContent = `${component.name} · ${component.heightU}U · freie HE-Zone anklicken`;
ui.selectedComponentInfo.classList.add("is-active");
}
function renderRack() {
const rack = getCurrentRackTemplate();
if (!rack) {
return;
}
ui.rackGrid.style.setProperty("--rack-unit-height", `${RACK_UNIT_PX}px`);
applyRackColorTheme(ui.rackGrid, state.rackColor);
const slots = [];
for (let u = rack.totalU; u >= 1; u -= 1) {
slots.push(`
<div class="rack-slot" data-slot-u="${u}">
<span class="rack-slot__label">${u}U</span>
<span class="rack-slot__label rack-slot__label--right">${u}</span>
</div>
`);
}
const insertionZones = renderInsertionZones(rack);
ui.rackGrid.innerHTML = `
<div class="rack-grid__header-badge rack-grid__header-badge--left">${rack.rackStandard === "19_inch" ? '19" Rack' : '10" Rack'}</div>
<div class="rack-grid__header-badge rack-grid__header-badge--right">${rack.totalU} HE</div>
<div class="rack-grid__bay">
<div class="rack-grid__guides">${slots.join("")}</div>
<div class="rack-insertion-layer">${insertionZones}</div>
<div class="rack-items-layer" id="rack-items-layer"></div>
</div>
`;
ui.rackGrid.querySelectorAll("[data-action='insert-component']").forEach((element) => {
element.addEventListener("click", () => insertSelectedComponentAt(Number(element.dataset.insertY)));
});
const layer = document.getElementById("rack-items-layer");
const rackHeight = rack.totalU * RACK_UNIT_PX;
layer.style.height = `${rackHeight}px`;
state.placedItems.forEach((item) => {
const component = getComponent(item.componentId);
if (!component) {
return;
}
const rect = getPlacementRect(item, component);
const element = document.createElement("article");
element.className = `rack-item rack-item--${component.category.replace(/_/g, "-")}`;
element.dataset.standard = component.rackStandard;
element.style.top = `${rect.top}px`;
element.style.left = `${rect.left}px`;
element.style.width = `${rect.width}px`;
element.style.height = `${rect.height}px`;
element.innerHTML = `
<div class="rack-item__face rack-item__face--${escapeHtml(component.category)}">
${renderComponentFace(component, "rack")}
</div>
<div class="rack-item__overlay">
<div class="rack-item__header">
<div>
<strong>${escapeHtml(component.name)}</strong>
<div class="rack-item__meta">${component.heightU}U · ${component.depthMm} mm · ${formatCurrency(component.priceNet, component.currency)}</div>
</div>
<span class="chip">${formatRackPosition(item.y)}</span>
</div>
<div class="rack-item__actions">
<button type="button" data-action="move-left">←</button>
<button type="button" data-action="move-right">→</button>
<button type="button" data-action="move-up">+1U</button>
<button type="button" data-action="move-down">-1U</button>
<button type="button" data-action="remove">Entfernen</button>
</div>
</div>
`;
element.addEventListener("pointerdown", (event) => beginPointerDrag(event, item, component));
element.querySelector("[data-action='move-left']").addEventListener("click", () => nudgePlacedItem(item.placementId, -2, 0));
element.querySelector("[data-action='move-right']").addEventListener("click", () => nudgePlacedItem(item.placementId, 2, 0));
element.querySelector("[data-action='move-up']").addEventListener("click", () => nudgePlacedItem(item.placementId, 0, -RACK_UNIT_PX));
element.querySelector("[data-action='move-down']").addEventListener("click", () => nudgePlacedItem(item.placementId, 0, RACK_UNIT_PX));
element.querySelector("[data-action='remove']").addEventListener("click", () => {
state.placedItems = state.placedItems.filter((entry) => entry.placementId !== item.placementId);
renderAll();
});
layer.appendChild(element);
});
}
function nudgePlacedItem(placementId, deltaX, deltaY) {
const rack = getCurrentRackTemplate();
const item = state.placedItems.find((entry) => entry.placementId === placementId);
const component = item ? getComponent(item.componentId) : null;
if (!rack || !item || !component) {
return;
}
item.x = clamp(item.x + deltaX, 0, 100 - getComponentWidthPercent(component));
item.y = clamp(item.y + deltaY, 0, getRackHeightPx(rack) - getComponentHeightPx(component));
renderAll();
}
function insertSelectedComponentAt(y) {
if (!state.selectedComponentId) {
return;
}
const component = getComponent(state.selectedComponentId);
const rack = getCurrentRackTemplate();
if (!component || !rack) {
return;
}
const nextRect = {
left: getDefaultComponentX(component),
top: y,
width: getComponentWidthPercent(component),
height: getComponentHeightPx(component),
};
const hasOverlap = state.placedItems.some((item) => {
const other = getComponent(item.componentId);
if (!other) {
return false;
}
return rectanglesOverlap(
nextRect,
{
left: item.x,
top: item.y,
width: getComponentWidthPercent(other),
height: getComponentHeightPx(other),
}
);
});
if (hasOverlap) {
alert("Diese Einbauposition ist bereits belegt.");
return;
}
state.placedItems.push({
placementId: `p${state.nextPlacementId++}`,
componentId: component.id,
y,
x: getDefaultComponentX(component),
});
state.selectedComponentId = null;
renderAll();
}
function renderStats() {
const rack = getCurrentRackTemplate();
if (!rack) {
return;
}
const usedU = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.heightU ?? 0);
}, 0);
const usedWeight = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.weightKg ?? 0);
}, 0);
const usedPower = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.powerW ?? 0);
}, 0);
ui.projectStats.innerHTML = `
<div class="stat-card">
<span>Belegte U</span>
<strong>${usedU} / ${rack.totalU}</strong>
</div>
<div class="stat-card">
<span>Freie U</span>
<strong>${rack.totalU - usedU}</strong>
</div>
<div class="stat-card">
<span>Gewicht</span>
<strong>${usedWeight.toFixed(1)} / ${rack.maxWeightKg} kg</strong>
</div>
<div class="stat-card">
<span>Leistung</span>
<strong>${usedPower} W</strong>
</div>
`;
}
function renderBom() {
const aggregated = new Map();
state.placedItems.forEach((item) => {
const component = getComponent(item.componentId);
if (!component) {
return;
}
const key = component.partNumber;
if (!aggregated.has(key)) {
aggregated.set(key, {
quantity: 0,
name: component.name,
manufacturer: component.manufacturer,
partNumber: component.partNumber,
currency: component.currency,
priceNet: component.priceNet,
});
}
aggregated.get(key).quantity += 1;
});
const lines = Array.from(aggregated.values());
if (lines.length === 0) {
ui.bomOutput.innerHTML = `<div class="empty-state">Noch keine Komponenten platziert.</div>`;
return;
}
ui.bomOutput.innerHTML = lines
.map((line) => {
const total = line.quantity * line.priceNet;
return `
<div class="bom-line">
<div>
<strong>${escapeHtml(line.name)}</strong>
<div>${escapeHtml(line.partNumber)} · ${escapeHtml(line.manufacturer)}</div>
</div>
<div>${line.quantity} × ${formatCurrency(total, line.currency)}</div>
</div>
`;
})
.join("");
}
function renderCableSelectors() {
const options = ['<option value="">Bitte waehlen</option>']
.concat(
state.placedItems.map((item) => {
const component = getComponent(item.componentId);
if (!component) {
return "";
}
return `<option value="${escapeHtml(item.placementId)}">${escapeHtml(component.name)} @ ${formatRackPosition(item.y)}</option>`;
})
)
.join("");
ui.cableFrom.innerHTML = options;
ui.cableTo.innerHTML = options;
}
function renderCableEstimate() {
const fromId = ui.cableFrom.value;
const toId = ui.cableTo.value;
const slackPercent = Number(ui.cableSlack.value || 0);
if (!fromId || !toId || fromId === toId) {
ui.cableOutput.textContent = "Fuer eine Schaetzung zwei verschiedene Komponenten auswaehlen.";
return;
}
const from = state.placedItems.find((item) => item.placementId === fromId);
const to = state.placedItems.find((item) => item.placementId === toId);
const rack = getCurrentRackTemplate();
const fromComponent = from ? getComponent(from.componentId) : null;
const toComponent = to ? getComponent(to.componentId) : null;
if (!from || !to || !rack || !fromComponent || !toComponent) {
ui.cableOutput.textContent = "Kabelschaetzung momentan nicht verfuegbar.";
return;
}
const verticalMm = Math.abs(getPlacementCenterY(from, fromComponent) - getPlacementCenterY(to, toComponent)) * (44.45 / RACK_UNIT_PX);
const depthAllowance = Math.min(rack.usableDepthMm * 0.35, 280);
const sideAllowance = estimateSideAllowance(fromComponent, toComponent);
const rawLength = verticalMm + depthAllowance + sideAllowance;
const withSlack = rawLength * (1 + slackPercent / 100);
const recommended = recommendCableLength(withSlack);
ui.cableOutput.innerHTML = `
Geschaetzt: <strong>${Math.round(rawLength)} mm</strong><br>
Mit Reserve: <strong>${Math.round(withSlack)} mm</strong><br>
Bestelllaenge: <strong>${recommended.toFixed(2)} m</strong>
`;
}
function renderValidation() {
const rack = getCurrentRackTemplate();
if (!rack) {
return;
}
const notes = [];
const usedU = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.heightU ?? 0);
}, 0);
const usedWeight = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.weightKg ?? 0);
}, 0);
if (usedU <= rack.totalU) {
notes.push({ className: "ok", text: `U-Belegung ist gueltig. ${rack.totalU - usedU}U frei.` });
} else {
notes.push({ className: "warn", text: "Rack ist ueberbelegt." });
}
if (usedWeight <= rack.maxWeightKg) {
notes.push({ className: "ok", text: `Gewicht innerhalb der Rack-Grenze (${usedWeight.toFixed(1)} kg).` });
} else {
notes.push({ className: "warn", text: `Gewicht ueberschreitet die Rack-Grenze von ${rack.maxWeightKg} kg.` });
}
const tooDeep = state.placedItems
.map((item) => ({ item, component: getComponent(item.componentId) }))
.filter(({ component }) => component && component.depthMm > rack.usableDepthMm);
const overlaps = getPlacementOverlaps();
if (tooDeep.length === 0) {
notes.push({ className: "ok", text: "Keine Tiefenkonflikte erkannt." });
} else {
tooDeep.forEach(({ component }) => {
notes.push({ className: "warn", text: `${component.name} ist tiefer als das Rack erlaubt.` });
});
}
if (overlaps.length === 0) {
notes.push({ className: "ok", text: "Keine visuellen Ueberschneidungen im Rack erkannt." });
} else {
overlaps.forEach((entry) => {
notes.push({ className: "warn", text: `${entry.a} ueberlappt mit ${entry.b}.` });
});
}
ui.validationOutput.innerHTML = notes.map((note) => `<li class="${note.className}">${escapeHtml(note.text)}</li>`).join("");
}
function importPluginPack(event) {
const file = event.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const payload = JSON.parse(String(reader.result || "{}"));
validatePluginPack(payload);
const imported = payload.components.map((component, index) => ({
...component,
id: component.id || `plugin-${Date.now()}-${index}`,
}));
state.components = deduplicateComponents([...state.components, ...imported]);
renderAll();
ui.pluginInput.value = "";
alert(`Plugin-Pack "${payload.name}" importiert.`);
} catch (error) {
alert(`Plugin-Import fehlgeschlagen: ${error.message}`);
}
};
reader.readAsText(file);
}
function validatePluginPack(payload) {
if (!payload || typeof payload !== "object") {
throw new Error("Ungueltiges JSON.");
}
["manifestVersion", "name", "version", "components"].forEach((key) => {
if (!(key in payload)) {
throw new Error(`Pflichtfeld fehlt: ${key}`);
}
});
if (!Array.isArray(payload.components) || payload.components.length === 0) {
throw new Error("Plugin enthaelt keine Komponenten.");
}
payload.components.forEach((component) => {
["name", "partNumber", "rackStandard", "heightU", "depthMm", "priceNet", "currency"].forEach((key) => {
if (!(key in component)) {
throw new Error(`Komponente unvollstaendig: ${component.name || "unbekannt"} (${key} fehlt)`);
}
});
});
}
async function copyBomCsv() {
const rows = [["quantity", "manufacturer", "partNumber", "name", "unitPrice", "currency", "totalPrice"]];
const aggregated = new Map();
state.placedItems.forEach((item) => {
const component = getComponent(item.componentId);
if (!component) {
return;
}
const key = component.partNumber;
if (!aggregated.has(key)) {
aggregated.set(key, { quantity: 0, component });
}
aggregated.get(key).quantity += 1;
});
aggregated.forEach(({ quantity, component }) => {
rows.push([
String(quantity),
component.manufacturer,
component.partNumber,
component.name,
String(component.priceNet),
component.currency,
String(quantity * component.priceNet),
]);
});
const csv = rows.map((row) => row.map(csvEscape).join(",")).join("\n");
try {
await navigator.clipboard.writeText(csv);
ui.exportBom.textContent = "CSV kopiert";
window.setTimeout(() => {
ui.exportBom.textContent = "CSV kopieren";
}, 1600);
} catch (error) {
console.error(error);
alert("CSV konnte nicht in die Zwischenablage kopiert werden.");
}
}
function findFirstFreePosition(heightU) {
const rack = getCurrentRackTemplate();
if (!rack) {
return null;
}
const testHeight = heightU * RACK_UNIT_PX;
for (let y = 0; y <= getRackHeightPx(rack) - testHeight; y += RACK_UNIT_PX) {
const blocked = state.placedItems.some((item) => {
const otherComponent = getComponent(item.componentId);
if (!otherComponent) {
return false;
}
return rectanglesOverlap(
{
left: getDefaultComponentX(component),
top: y,
width: getComponentWidthPercent(component),
height: testHeight,
},
{
left: item.x,
top: item.y,
width: getComponentWidthPercent(otherComponent),
height: getComponentHeightPx(otherComponent),
}
);
});
if (!blocked) {
return y;
}
}
return null;
}
function getCurrentRackTemplate() {
return state.bootstrap?.rackTemplates.find((template) => template.id === state.rackTemplateId) ?? null;
}
function getComponent(componentId) {
return state.components.find((component) => component.id === componentId) ?? null;
}
function deduplicateComponents(components) {
const byId = new Map();
components.forEach((component) => {
byId.set(component.id, component);
});
return Array.from(byId.values());
}
function estimateSideAllowance(fromComponent, toComponent) {
const fromSide = fromComponent.ports?.[0]?.side ?? "front";
const toSide = toComponent.ports?.[0]?.side ?? "front";
return fromSide === toSide ? 160 : 420;
}
function recommendCableLength(lengthMm) {
const options = [0.25, 0.5, 1, 1.5, 2, 3, 5, 7.5, 10];
const meters = lengthMm / 1000;
return options.find((option) => option >= meters) ?? Math.ceil(meters);
}
function beginPointerDrag(event, item, component) {
if (event.target.closest("button")) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
state.pointerDrag = {
placementId: item.placementId,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
widthPercent: getComponentWidthPercent(component),
heightPx: getComponentHeightPx(component),
};
event.currentTarget.setPointerCapture?.(event.pointerId);
event.currentTarget.classList.add("is-dragging");
}
function handlePointerMove(event) {
if (!state.pointerDrag) {
return;
}
const layer = document.getElementById("rack-items-layer");
const rack = getCurrentRackTemplate();
if (!layer || !rack) {
return;
}
const item = state.placedItems.find((entry) => entry.placementId === state.pointerDrag.placementId);
if (!item) {
return;
}
const layerRect = layer.getBoundingClientRect();
const widthPx = (state.pointerDrag.widthPercent / 100) * layerRect.width;
const xPx = clamp(event.clientX - layerRect.left - state.pointerDrag.offsetX, 0, layerRect.width - widthPx);
const yPx = clamp(event.clientY - layerRect.top - state.pointerDrag.offsetY, 0, getRackHeightPx(rack) - state.pointerDrag.heightPx);
item.x = (xPx / layerRect.width) * 100;
item.y = yPx;
renderAll();
}
function handlePointerUp() {
if (!state.pointerDrag) {
return;
}
state.pointerDrag = null;
ui.rackGrid.querySelectorAll(".rack-item.is-dragging").forEach((element) => {
element.classList.remove("is-dragging");
});
}
function formatCurrency(value, currency) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: currency || "EUR",
maximumFractionDigits: 0,
}).format(value);
}
function renderComponentFace(component, mode) {
const ports = renderPortStrip(component);
const leds = '<div class="device-leds"><span></span><span></span><span></span></div>';
const label = `<div class="device-silkscreen">${escapeHtml(component.manufacturer || component.category)}</div>`;
const modeClass = mode === "rack" ? "device-face--rack" : "device-face--library";
const header = `<div class="device-brand">${escapeHtml(component.name)}</div>`;
switch (component.category) {
case "switch":
return `
<div class="device-face device-face--switch ${modeClass}">
<div class="device-mount-hole device-mount-hole--left"></div>
<div class="device-mount-hole device-mount-hole--right"></div>
${header}
${label}
${leds}
${ports}
<div class="device-uplink"></div>
</div>
`;
case "patch_panel":
return `
<div class="device-face device-face--patch-panel ${modeClass}">
<div class="device-mount-hole device-mount-hole--left"></div>
<div class="device-mount-hole device-mount-hole--right"></div>
${header}
${label}
${renderPatchPanel(component)}
</div>
`;
case "pdu":
return `
<div class="device-face device-face--pdu ${modeClass}">
<div class="device-mount-hole device-mount-hole--left"></div>
<div class="device-mount-hole device-mount-hole--right"></div>
${header}
${label}
<div class="device-sockets">${renderSocketStrip(component)}</div>
${leds}
</div>
`;
case "ups":
return `
<div class="device-face device-face--ups ${modeClass}">
<div class="device-mount-hole device-mount-hole--left"></div>
<div class="device-mount-hole device-mount-hole--right"></div>
${header}
<div class="device-display"></div>
<div class="device-vents"></div>
<div class="device-handles"><span></span><span></span></div>
${label}
</div>
`;
case "shelf":
return `
<div class="device-face device-face--shelf ${modeClass}">
<div class="device-mount-hole device-mount-hole--left"></div>
<div class="device-mount-hole device-mount-hole--right"></div>
${header}
<div class="device-shelf-top"></div>
${label}
</div>
`;
case "blank_panel":
return `
<div class="device-face device-face--blank ${modeClass}">
<div class="device-mount-hole device-mount-hole--left"></div>
<div class="device-mount-hole device-mount-hole--right"></div>
${header}
${label}
</div>
`;
default:
return `
<div class="device-face device-face--generic ${modeClass}">
<div class="device-mount-hole device-mount-hole--left"></div>
<div class="device-mount-hole device-mount-hole--right"></div>
${header}
${label}
</div>
`;
}
}
function renderPortStrip(component) {
const totalPorts = component.name.includes("24") ? 24 : component.name.includes("12") ? 12 : 8;
const ports = Array.from({ length: totalPorts }, (_, index) => `<span title="Port ${index + 1}"></span>`).join("");
return `<div class="device-ports device-ports--${Math.min(totalPorts, 24)}">${ports}</div>`;
}
function renderPatchPanel(component) {
const totalPorts = component.name.includes("24") ? 24 : 12;
const ports = Array.from({ length: totalPorts }, (_, index) => `<span title="Patch ${index + 1}"></span>`).join("");
return `<div class="device-keystones">${ports}</div>`;
}
function renderSocketStrip(component) {
const totalSockets = component.name.includes("8") ? 8 : 6;
return Array.from({ length: totalSockets }, () => "<span></span>").join("");
}
function renderInsertionZones(rack) {
const component = getComponent(state.selectedComponentId);
if (!component) {
return "";
}
const zones = [];
for (let y = 0; y <= getRackHeightPx(rack) - getComponentHeightPx(component); y += RACK_UNIT_PX) {
const occupied = state.placedItems.some((item) => {
const other = getComponent(item.componentId);
if (!other) {
return false;
}
return rectanglesOverlap(
{
left: getDefaultComponentX(component),
top: y,
width: getComponentWidthPercent(component),
height: getComponentHeightPx(component),
},
{
left: item.x,
top: item.y,
width: getComponentWidthPercent(other),
height: getComponentHeightPx(other),
}
);
});
zones.push(`
<button
type="button"
class="rack-insert-zone${occupied ? " is-disabled" : ""}"
data-action="insert-component"
data-insert-y="${y}"
style="top:${y}px;height:${getComponentHeightPx(component)}px;left:${getDefaultComponentX(component)}%;width:${getComponentWidthPercent(component)}%;"
${occupied ? "disabled" : ""}
>
<span class="rack-insert-zone__ear rack-insert-zone__ear--left"></span>
<span class="rack-insert-zone__body">
<span class="rack-insert-zone__icon">☞</span>
<span>Element hier einstecken</span>
</span>
<span class="rack-insert-zone__ear rack-insert-zone__ear--right"></span>
</button>
`);
}
return zones.join("");
}
function getRackHeightPx(rack) {
return rack.totalU * RACK_UNIT_PX;
}
function getComponentHeightPx(component) {
return component.heightU * RACK_UNIT_PX;
}
function getComponentWidthPercent(component) {
return component.rackStandard === "10_inch" ? 58 : 86;
}
function getDefaultComponentX(component) {
return (100 - getComponentWidthPercent(component)) / 2;
}
function getPlacementRect(item, component) {
const rackInnerWidthPx = getRackInnerWidthPx();
const width = (getComponentWidthPercent(component) / 100) * rackInnerWidthPx;
const left = (item.x / 100) * rackInnerWidthPx;
return {
left,
top: item.y,
width,
height: getComponentHeightPx(component),
};
}
function getPlacementCenterY(item, component) {
return item.y + getComponentHeightPx(component) / 2;
}
function formatRackPosition(y) {
return `${(y / RACK_UNIT_PX + 1).toFixed(1)}U`;
}
function getPlacementOverlaps() {
const overlaps = [];
for (let index = 0; index < state.placedItems.length; index += 1) {
for (let compareIndex = index + 1; compareIndex < state.placedItems.length; compareIndex += 1) {
const aItem = state.placedItems[index];
const bItem = state.placedItems[compareIndex];
const aComponent = getComponent(aItem.componentId);
const bComponent = getComponent(bItem.componentId);
if (!aComponent || !bComponent) {
continue;
}
if (rectanglesOverlap(getPlacementRect(aItem, aComponent), getPlacementRect(bItem, bComponent))) {
overlaps.push({ a: aComponent.name, b: bComponent.name });
}
}
}
return overlaps;
}
function rectanglesOverlap(a, b) {
return a.left < b.left + b.width && a.left + a.width > b.left && a.top < b.top + b.height && a.top + a.height > b.top;
}
function getRackInnerWidthPx() {
const layer = document.getElementById("rack-items-layer");
if (layer) {
return layer.clientWidth || 620;
}
return 620;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function applyRackColorTheme(element, color) {
const rgb = hexToRgb(color);
if (!rgb) {
return;
}
element.style.setProperty("--rack-frame", color);
element.style.setProperty("--rack-frame-dark", shadeRgb(rgb, -34));
element.style.setProperty("--rack-frame-light", shadeRgb(rgb, 28));
element.style.setProperty("--rack-rail", shadeRgb(rgb, 18));
}
function hexToRgb(hex) {
const normalized = String(hex).replace("#", "");
if (!/^[a-f0-9]{6}$/i.test(normalized)) {
return null;
}
return {
r: Number.parseInt(normalized.slice(0, 2), 16),
g: Number.parseInt(normalized.slice(2, 4), 16),
b: Number.parseInt(normalized.slice(4, 6), 16),
};
}
function shadeRgb(rgb, amount) {
const channel = (value) => Math.max(0, Math.min(255, value + amount));
return `rgb(${channel(rgb.r)}, ${channel(rgb.g)}, ${channel(rgb.b)})`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function csvEscape(value) {
const stringValue = String(value ?? "");
if (stringValue.includes(",") || stringValue.includes('"') || stringValue.includes("\n")) {
return `"${stringValue.replaceAll('"', '""')}"`;
}
return stringValue;
}