Files
c0de.it/public/assets/app.js
Lars Gebhardt-Kusche 030b0c48ae
All checks were successful
Deploy / deploy (push) Successful in 16s
dsfsdf
2026-05-17 01:22:54 +02:00

1098 lines
35 KiB
JavaScript
Raw Permalink 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,
libraryDragComponentId: null,
rackDropPreviewY: 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.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";
renderStats();
});
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);
document.addEventListener("dragend", handleLibraryDragEnd);
}
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() {
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() {
renderStats();
}
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)}" draggable="true" data-component-drag-id="${escapeHtml(component.id)}">
<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 ? "Bereit zum Ziehen" : "Ins Rack ziehen"}
</button>
</article>
`
)
.join("");
ui.componentLibrary.querySelectorAll("[data-action='select-component']").forEach((button) => {
button.addEventListener("click", () => {
state.selectedComponentId = button.dataset.componentId;
renderAll();
});
});
ui.componentLibrary.querySelectorAll("[data-component-drag-id]").forEach((card) => {
card.addEventListener("dragstart", (event) => beginLibraryDrag(event, card.dataset.componentDragId));
});
}
function renderSelectionInfo() {
const component = getComponent(state.libraryDragComponentId || 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 · aus der Bibliothek ins Rack ziehen`;
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`);
ui.rackGrid.style.setProperty("--rack-total-u", String(rack.totalU));
applyRackColorTheme(ui.rackGrid, state.rackColor);
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-shell">
<div class="rack-shell__frame rack-shell__frame--top"></div>
<div class="rack-shell__frame rack-shell__frame--bottom"></div>
<div class="rack-shell__screw rack-shell__screw--tl"></div>
<div class="rack-shell__screw rack-shell__screw--tr"></div>
<div class="rack-shell__screw rack-shell__screw--bl"></div>
<div class="rack-shell__screw rack-shell__screw--br"></div>
<div class="rack-core">
<div class="rack-mount-plane" id="rack-mount-plane">
<div class="rack-insertion-layer" id="rack-insertion-layer"></div>
<div class="rack-items-layer" id="rack-items-layer"></div>
</div>
<div class="rack-rail rack-rail--left">${renderRailScale(rack, "left")}</div>
<div class="rack-bay" id="rack-bay">
<div class="rack-bay__ceiling"></div>
<div class="rack-bay__wall rack-bay__wall--left"></div>
<div class="rack-bay__wall rack-bay__wall--right"></div>
<div class="rack-bay__back"></div>
<div class="rack-bay__floor"></div>
</div>
<div class="rack-rail rack-rail--right">${renderRailScale(rack, "right")}</div>
</div>
</div>
`;
const layer = document.getElementById("rack-items-layer");
layer.style.height = "100%";
const mountPlane = document.getElementById("rack-mount-plane");
mountPlane.addEventListener("dragover", handleRackDragOver);
mountPlane.addEventListener("drop", handleRackDrop);
mountPlane.addEventListener("dragleave", handleRackDragLeave);
const insertionLayer = document.getElementById("rack-insertion-layer");
insertionLayer.innerHTML = renderInsertionZones(rack);
ui.rackGrid.querySelectorAll("[data-action='insert-component']").forEach((element) => {
element.addEventListener("click", () => insertSelectedComponentAt(Number(element.dataset.insertY)));
});
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, component)}</span>
</div>
<div class="rack-item__drag-hint">Ziehen zum verschieben</div>
<button type="button" class="rack-item__remove" data-action="remove" aria-label="Komponente entfernen">×</button>
</div>
`;
element.addEventListener("pointerdown", (event) => beginPointerDrag(event, item, component));
element.querySelector("[data-action='remove']").addEventListener("click", () => {
state.placedItems = state.placedItems.filter((entry) => entry.placementId !== item.placementId);
renderAll();
});
layer.appendChild(element);
});
}
function renderRailScale(rack, side) {
const rows = [];
for (let u = rack.totalU; u >= 1; u -= 1) {
rows.push(`
<div class="rack-rail__unit">
<span class="rack-rail__label rack-rail__label--${side}">${u}</span>
<span class="rack-rail__tick rack-rail__tick--${side}"></span>
<span class="rack-rail__holes rack-rail__holes--${side}">
<span></span><span></span><span></span>
</span>
</div>
`);
}
return rows.join("");
}
function insertSelectedComponentAt(y) {
const componentId = state.libraryDragComponentId || state.selectedComponentId;
if (!componentId) {
return;
}
const component = getComponent(componentId);
const rack = getCurrentRackTemplate();
if (!component || !rack) {
return;
}
const nextRect = {
left: 0,
top: snapToUnit(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: snapToUnit(y),
x: 0,
});
state.libraryDragComponentId = null;
state.rackDropPreviewY = null;
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>Projekt</span>
<strong>${escapeHtml(state.projectName)}</strong>
</div>
<div class="stat-card">
<span>Rack</span>
<strong>${rack.rackStandard === "19_inch" ? '19"' : '10"'} · ${rack.totalU}U</strong>
</div>
<div class="stat-card">
<span>Tiefe</span>
<strong>${rack.usableDepthMm} mm</strong>
</div>
<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, component)}</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 / getUnitHeightPx());
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 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,
offsetY: event.clientY - rect.top,
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 yPx = clamp(event.clientY - layerRect.top - state.pointerDrag.offsetY, 0, getRackHeightPx(rack) - state.pointerDrag.heightPx);
item.x = 0;
item.y = snapToUnit(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) {
if (!state.libraryDragComponentId) {
return "";
}
const component = getComponent(state.libraryDragComponentId);
if (!component) {
return "";
}
const unitHeight = getUnitHeightPx();
const zones = [];
for (let y = 0; y <= getRackHeightPx(rack) - getComponentHeightPx(component); y += unitHeight) {
const occupied = state.placedItems.some((item) => {
const other = getComponent(item.componentId);
if (!other) {
return false;
}
return rectanglesOverlap(
{
left: 0,
top: y,
width: 100,
height: getComponentHeightPx(component),
},
{
left: 0,
top: item.y,
width: 100,
height: getComponentHeightPx(other),
}
);
});
const isPreview = state.rackDropPreviewY !== null && Math.abs(state.rackDropPreviewY - y) < 1;
zones.push(`
<button
type="button"
class="rack-insert-zone${occupied ? " is-disabled" : ""}${isPreview ? " is-preview" : ""}"
data-action="insert-component"
data-insert-y="${y}"
style="top:${y}px;height:${getComponentHeightPx(component)}px;left:0;width:100%;"
${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) {
const layer = document.getElementById("rack-items-layer");
if (layer) {
return layer.clientHeight || rack.totalU * RACK_UNIT_PX;
}
return rack.totalU * RACK_UNIT_PX;
}
function getComponentHeightPx(component) {
return component.heightU * getUnitHeightPx();
}
function getComponentWidthPercent(component) {
return 100;
}
function getDefaultComponentX(component) {
return 0;
}
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, component) {
const rack = getCurrentRackTemplate();
if (!rack) {
return "1U";
}
const rowIndex = Math.round(y / getUnitHeightPx());
const componentHeightU = component?.heightU ?? 1;
const u = rack.totalU - rowIndex - componentHeightU + 1;
return `${Math.max(1, u)}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 getUnitHeightPx() {
const rack = getCurrentRackTemplate();
const layer = document.getElementById("rack-items-layer");
if (rack && layer && layer.clientHeight) {
return layer.clientHeight / rack.totalU;
}
return RACK_UNIT_PX;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function snapToUnit(value) {
const unitHeight = getUnitHeightPx();
return Math.round(value / unitHeight) * unitHeight;
}
function beginLibraryDrag(event, componentId) {
state.libraryDragComponentId = componentId;
state.selectedComponentId = componentId;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData("text/plain", componentId);
}
window.requestAnimationFrame(() => {
renderAll();
});
}
function handleRackDragOver(event) {
if (!state.libraryDragComponentId) {
return;
}
event.preventDefault();
const rack = getCurrentRackTemplate();
const component = getComponent(state.libraryDragComponentId);
const layer = document.getElementById("rack-items-layer");
if (!rack || !component || !layer) {
return;
}
const rect = layer.getBoundingClientRect();
const y = clamp(event.clientY - rect.top - getComponentHeightPx(component) / 2, 0, getRackHeightPx(rack) - getComponentHeightPx(component));
const snappedY = snapToUnit(y);
if (state.rackDropPreviewY !== snappedY) {
state.rackDropPreviewY = snappedY;
const insertionLayer = document.getElementById("rack-insertion-layer");
if (insertionLayer) {
insertionLayer.innerHTML = renderInsertionZones(rack);
ui.rackGrid.querySelectorAll("[data-action='insert-component']").forEach((element) => {
element.addEventListener("click", () => insertSelectedComponentAt(Number(element.dataset.insertY)));
});
}
}
}
function handleRackDrop(event) {
if (!state.libraryDragComponentId) {
return;
}
event.preventDefault();
if (state.rackDropPreviewY !== null) {
insertSelectedComponentAt(state.rackDropPreviewY);
}
}
function handleRackDragLeave(event) {
if (!state.libraryDragComponentId) {
return;
}
if (event.currentTarget.contains(event.relatedTarget)) {
return;
}
state.rackDropPreviewY = null;
const rack = getCurrentRackTemplate();
const insertionLayer = document.getElementById("rack-insertion-layer");
if (rack && insertionLayer) {
insertionLayer.innerHTML = renderInsertionZones(rack);
ui.rackGrid.querySelectorAll("[data-action='insert-component']").forEach((element) => {
element.addEventListener("click", () => insertSelectedComponentAt(Number(element.dataset.insertY)));
});
}
}
function handleLibraryDragEnd() {
if (!state.libraryDragComponentId && state.rackDropPreviewY === null) {
return;
}
state.libraryDragComponentId = null;
state.rackDropPreviewY = null;
renderAll();
}
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;
}