From e7708831d02c6e38904edcf5361495a056eccdd9 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Fri, 15 May 2026 23:23:35 +0200 Subject: [PATCH] ui rack --- public/assets/app.css | 387 +++++++++++++++++++++++++++++-------- public/assets/app.js | 437 +++++++++++++++++++++++++++++++++++------- public/index.php | 11 ++ 3 files changed, 695 insertions(+), 140 deletions(-) diff --git a/public/assets/app.css b/public/assets/app.css index 78b0856..3042dfa 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -15,6 +15,11 @@ --radius-md: 18px; --radius-sm: 12px; --rack-unit-height: 38px; + --rack-inner-width: 620px; + --rack-frame: #50545c; + --rack-frame-dark: #2e323a; + --rack-frame-light: #6c7078; + --rack-rail: #656972; --font-display: "Avenir Next", "Segoe UI", sans-serif; --font-body: "IBM Plex Sans", "Segoe UI", sans-serif; } @@ -197,7 +202,9 @@ button, input, select { .component-card__preview { padding: 10px; border-radius: 14px; - background: linear-gradient(180deg, rgba(44, 47, 53, 0.08), rgba(18, 19, 23, 0.02)); + background: + linear-gradient(180deg, rgba(189, 193, 201, 0.36), rgba(255, 255, 255, 0.78)), + linear-gradient(90deg, rgba(88, 92, 99, 0.08), rgba(88, 92, 99, 0.02)); } .component-card__header { @@ -282,32 +289,37 @@ button, input, select { .rack-grid { position: relative; border-radius: 28px; - border: 2px solid rgba(42, 34, 24, 0.55); + border: 1px solid rgba(105, 108, 115, 0.22); background: - linear-gradient(90deg, rgba(9, 11, 15, 0.88), rgba(20, 25, 31, 0.15) 14%, rgba(20, 25, 31, 0.15) 86%, rgba(9, 11, 15, 0.88)), - linear-gradient(180deg, #6e737b 0%, #3a3f48 12%, #15181d 22%, #0f1217 100%); - padding: 24px 38px 24px 74px; + linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(228, 229, 232, 0.86)), + linear-gradient(90deg, rgba(144, 148, 156, 0.06), rgba(144, 148, 156, 0)); + padding: 28px 40px 28px 74px; overflow: hidden; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.18), - inset 0 0 0 2px rgba(255, 255, 255, 0.04), - 0 24px 40px rgba(9, 11, 15, 0.22); + inset 0 1px 0 rgba(255, 255, 255, 0.65), + inset 0 -18px 24px rgba(134, 137, 145, 0.12), + 0 24px 40px rgba(92, 84, 73, 0.12); +} + +.rack-grid { + outline: 18px solid var(--rack-frame-dark); + outline-offset: -18px; } .rack-grid::before, .rack-grid::after { content: ""; position: absolute; - top: 18px; - bottom: 18px; + top: 20px; + bottom: 20px; width: 16px; border-radius: 6px; background: - radial-gradient(circle at center 8px, rgba(205, 214, 229, 0.75) 0 2px, transparent 2.5px) center top / 100% 18px repeat-y, - linear-gradient(180deg, #404550, #1a1d23); + radial-gradient(circle at center 8px, rgba(116, 120, 128, 0.48) 0 2px, transparent 2.5px) center top / 100% 18px repeat-y, + linear-gradient(180deg, var(--rack-frame-light), var(--rack-rail)); box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.08), - inset 0 0 12px rgba(0, 0, 0, 0.4); + inset 0 0 0 1px rgba(255, 255, 255, 0.45), + inset 0 -10px 10px rgba(79, 82, 90, 0.16); } .rack-grid::before { @@ -318,12 +330,35 @@ button, input, select { right: 26px; } +.rack-grid__bay { + position: relative; + width: var(--rack-inner-width); + margin: 0 auto; + padding: 18px 0 14px; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.5), transparent 13%, transparent 87%, rgba(0, 0, 0, 0.48)), + linear-gradient(135deg, rgba(255, 255, 255, 0.05), transparent 30%); + border-radius: 8px; + box-shadow: + inset 0 0 0 2px rgba(0, 0, 0, 0.35), + inset 0 18px 32px rgba(0, 0, 0, 0.3), + inset 0 -18px 32px rgba(0, 0, 0, 0.24); +} + +.rack-grid__guides { + position: relative; + width: 100%; + background: linear-gradient(180deg, rgba(20, 22, 28, 0.92), rgba(7, 8, 11, 0.98)); + border-left: 4px solid rgba(0, 0, 0, 0.4); + border-right: 4px solid rgba(0, 0, 0, 0.4); +} + .rack-slot { position: relative; height: var(--rack-unit-height); - border-top: 1px solid rgba(202, 212, 222, 0.06); + border-top: 1px solid rgba(126, 131, 140, 0.14); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0.06)); + linear-gradient(180deg, rgba(255, 255, 255, 0.52), rgba(221, 224, 230, 0.42)); } .rack-slot:first-child { @@ -340,35 +375,144 @@ button, input, select { left: -52px; top: 8px; font-size: 0.76rem; - color: rgba(219, 225, 235, 0.84); + color: rgba(76, 78, 84, 0.82); +} + +.rack-slot__label--right { + left: auto; + right: -42px; } .rack-items-layer { position: absolute; - inset: 24px 38px 24px 74px; + inset: 18px 0 14px; + width: var(--rack-inner-width); +} + +.rack-insertion-layer { + position: absolute; + inset: 18px 0 14px; + width: var(--rack-inner-width); + pointer-events: none; +} + +.rack-grid__header-badge { + position: absolute; + top: 16px; + z-index: 4; + padding: 14px 18px; + border-radius: 14px; + background: rgba(17, 19, 24, 0.84); + color: #f6f7f9; + font-size: 1rem; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.12), + 0 10px 20px rgba(20, 22, 28, 0.18); +} + +.rack-grid__header-badge--left { + left: 16px; +} + +.rack-grid__header-badge--right { + right: 16px; +} + +.rack-insert-zone { + position: absolute; + display: flex; + align-items: center; + padding: 0; + border: 0; + background: transparent; + pointer-events: auto; + cursor: pointer; +} + +.rack-insert-zone.is-disabled { + cursor: not-allowed; +} + +.rack-insert-zone__ear { + width: 28px; + height: calc(100% - 2px); + border-radius: 10px; + background: rgba(83, 156, 243, 0.26); + border: 1px solid rgba(116, 184, 255, 0.5); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); + position: relative; +} + +.rack-insert-zone__ear::before, +.rack-insert-zone__ear::after { + content: ""; + position: absolute; + left: 50%; + width: 10px; + height: 10px; + margin-left: -5px; + border-radius: 50%; + background: rgba(18, 25, 33, 0.75); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.rack-insert-zone__ear::before { + top: 10px; +} + +.rack-insert-zone__ear::after { + bottom: 10px; +} + +.rack-insert-zone__body { + flex: 1; + height: calc(100% - 2px); + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + color: #deefff; + border-top: 2px dashed rgba(113, 187, 255, 0.92); + border-bottom: 2px dashed rgba(113, 187, 255, 0.92); + background: linear-gradient(180deg, rgba(35, 92, 154, 0.34), rgba(23, 52, 84, 0.42)); + box-shadow: inset 0 0 0 1px rgba(120, 182, 247, 0.24); + font-size: 1rem; +} + +.rack-insert-zone__icon { + font-size: 1.4rem; + opacity: 0.9; +} + +.rack-insert-zone:hover .rack-insert-zone__body, +.rack-insert-zone:hover .rack-insert-zone__ear { + background-color: rgba(72, 151, 228, 0.34); +} + +.rack-insert-zone.is-disabled .rack-insert-zone__body, +.rack-insert-zone.is-disabled .rack-insert-zone__ear { + opacity: 0.28; } .rack-item { position: absolute; left: 0; - right: 0; padding: 0; user-select: none; overflow: hidden; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: linear-gradient(180deg, #272c34, #11151b); + border: 1px solid rgba(122, 127, 137, 0.35); + background: linear-gradient(180deg, #fcfcfd, #e1e5eb); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - inset 0 -12px 18px rgba(0, 0, 0, 0.28); -} - -.rack-item[data-standard="10_inch"] { - right: 16%; + inset 0 1px 0 rgba(255, 255, 255, 0.76), + 0 10px 18px rgba(80, 84, 93, 0.18); + touch-action: none; + cursor: grab; } .rack-item.is-dragging { - opacity: 0.6; + opacity: 0.82; + cursor: grabbing; } .rack-item__header { @@ -385,7 +529,7 @@ button, input, select { flex-direction: column; justify-content: space-between; padding: 8px 10px 8px 12px; - background: linear-gradient(90deg, rgba(11, 13, 16, 0.72), rgba(11, 13, 16, 0.08) 45%, rgba(11, 13, 16, 0.56)); + background: linear-gradient(90deg, rgba(250, 251, 252, 0.86), rgba(250, 251, 252, 0.08) 38%, rgba(250, 251, 252, 0.72)); } .rack-item__face { @@ -396,12 +540,12 @@ button, input, select { .rack-item__meta, .list-output, .notes { - color: rgba(222, 227, 235, 0.76); + color: rgba(77, 80, 87, 0.86); font-size: 0.9rem; } .rack-item__header strong { - color: #f3f5f8; + color: #17191c; } .rack-item__actions { @@ -413,7 +557,7 @@ button, input, select { .rack-item__actions button { padding: 8px 10px; font-size: 0.84rem; - background: rgba(181, 93, 45, 0.9); + background: rgba(181, 93, 45, 0.92); } .subpanel { @@ -439,6 +583,27 @@ button, input, select { gap: 12px; } +.field--status { + align-content: end; +} + +.selection-info { + min-height: 46px; + display: flex; + align-items: center; + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(83, 58, 31, 0.14); + color: var(--muted); +} + +.selection-info.is-active { + background: rgba(28, 109, 190, 0.08); + border-color: rgba(28, 109, 190, 0.3); + color: #1e4f82; +} + .list-output { display: grid; gap: 10px; @@ -484,7 +649,7 @@ button, input, select { position: relative; width: 100%; height: 100%; - padding: 8px 12px; + padding: 8px 28px; overflow: hidden; } @@ -502,40 +667,93 @@ button, input, select { .device-face--pdu, .device-face--blank, .device-face--generic { - background: linear-gradient(180deg, #4d535d 0%, #262c34 18%, #12161d 100%); + background: linear-gradient(180deg, #fcfcfd 0%, #e9edf3 18%, #d7dbe2 100%); } .device-face--ups { - background: linear-gradient(180deg, #636973 0%, #2a3038 16%, #0f1319 100%); + background: linear-gradient(180deg, #f7f8fb 0%, #e0e4ea 20%, #cfd5dc 100%); } .device-face--shelf { - background: linear-gradient(180deg, #3e444c 0%, #171b21 100%); + background: linear-gradient(180deg, #f5f6f8 0%, #d4d9e0 100%); +} + +.device-face::before, +.device-face::after { + content: ""; + position: absolute; + top: 5px; + bottom: 5px; + width: 20px; + border-radius: 5px; + background: linear-gradient(180deg, #d7dbe2, #b4bbc6); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.65), + inset 0 -8px 10px rgba(95, 101, 112, 0.18); +} + +.device-face::before { + left: 4px; +} + +.device-face::after { + right: 4px; +} + +.device-brand { + position: absolute; + top: 7px; + left: 36px; + font-size: 0.72rem; + letter-spacing: 0.04em; + color: rgba(51, 58, 67, 0.92); + font-weight: 700; } .device-silkscreen { position: absolute; - left: 12px; + left: 36px; bottom: 8px; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; - color: rgba(222, 227, 235, 0.58); + color: rgba(90, 95, 104, 0.76); +} + +.device-mount-hole { + position: absolute; + top: 50%; + width: 7px; + height: 7px; + margin-top: -3.5px; + border-radius: 50%; + background: + radial-gradient(circle at 50% 50%, #79808b 0 1px, #4c525b 1.3px 2.5px, #cdd2da 2.5px); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); + z-index: 2; +} + +.device-mount-hole--left { + left: 11px; +} + +.device-mount-hole--right { + right: 11px; } .device-leds { display: flex; gap: 5px; position: absolute; - top: 10px; - right: 12px; + top: 9px; + right: 38px; } .device-leds span { width: 7px; height: 7px; border-radius: 50%; - background: radial-gradient(circle at 35% 35%, #b8ff8e, #367f26 72%); + background: radial-gradient(circle at 35% 35%, #9dff87, #2d7b1a 72%); box-shadow: 0 0 8px rgba(114, 255, 86, 0.4); } @@ -548,19 +766,21 @@ button, input, select { .device-ports { position: absolute; - left: 16px; - right: 54px; - top: 18px; + left: 40px; + right: 70px; + top: 22px; grid-template-columns: repeat(12, minmax(0, 1fr)); } .device-ports span, .device-keystones span { - height: 11px; - border-radius: 2px; - background: linear-gradient(180deg, #181d23, #050709); - border: 1px solid rgba(183, 191, 207, 0.12); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + height: 12px; + border-radius: 3px; + background: linear-gradient(180deg, #444c56, #1d2229); + border: 1px solid rgba(125, 132, 143, 0.22); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 1px 1px rgba(255, 255, 255, 0.08); } .device-ports--8 { @@ -573,37 +793,37 @@ button, input, select { .device-keystones { position: absolute; - left: 16px; - right: 16px; - top: 18px; + left: 40px; + right: 40px; + top: 20px; grid-template-columns: repeat(12, minmax(0, 1fr)); } .device-face--patch-panel .device-keystones span { - height: 13px; - background: linear-gradient(180deg, #0b0d10, #1b2128); + height: 14px; + background: linear-gradient(180deg, #dde2e8, #acb3bd); } .device-face--patch-panel .device-keystones span:nth-child(odd) { - border-color: rgba(239, 147, 78, 0.24); + border-color: rgba(119, 124, 135, 0.32); } .device-uplink { position: absolute; - top: 16px; - right: 16px; + top: 20px; + right: 40px; width: 22px; height: 16px; border-radius: 4px; - border: 1px solid rgba(188, 197, 214, 0.15); - background: linear-gradient(180deg, #11161c, #050709); + border: 1px solid rgba(110, 117, 127, 0.25); + background: linear-gradient(180deg, #5b646f, #262b31); } .device-sockets { position: absolute; - top: 14px; - left: 16px; - right: 44px; + top: 18px; + left: 40px; + right: 56px; grid-template-columns: repeat(8, minmax(0, 1fr)); align-items: center; } @@ -613,15 +833,15 @@ button, input, select { height: 15px; border-radius: 50%; background: - radial-gradient(circle at center, transparent 0 3px, #0f1216 3px 5px, transparent 5px), - linear-gradient(180deg, #aeb8c6, #57606e); - border: 1px solid rgba(229, 233, 240, 0.18); + radial-gradient(circle at center, transparent 0 3px, #4a515b 3px 5px, transparent 5px), + linear-gradient(180deg, #f5f6f8, #c4cad4); + border: 1px solid rgba(120, 126, 137, 0.18); } .device-display { position: absolute; - left: 16px; - top: 14px; + left: 40px; + top: 16px; width: 68px; height: 30px; border-radius: 8px; @@ -635,24 +855,38 @@ button, input, select { .device-vents { position: absolute; - top: 16px; - right: 18px; + top: 18px; + right: 42px; width: 96px; height: 22px; background: - repeating-linear-gradient(90deg, rgba(222, 227, 235, 0.16) 0 3px, transparent 3px 7px); + repeating-linear-gradient(90deg, rgba(102, 108, 118, 0.36) 0 3px, transparent 3px 7px); opacity: 0.8; } +.device-handles { + position: absolute; + inset: auto 48px 12px 48px; + display: flex; + justify-content: space-between; +} + +.device-handles span { + width: 18px; + height: 8px; + border-radius: 999px; + border: 2px solid rgba(88, 95, 106, 0.7); +} + .device-shelf-top { position: absolute; - left: 18px; - right: 18px; - top: 14px; + left: 40px; + right: 40px; + top: 18px; height: 18px; border-radius: 4px 4px 12px 12px; - background: linear-gradient(180deg, #69707a, #262b33); - box-shadow: 0 5px 12px rgba(0, 0, 0, 0.28); + background: linear-gradient(180deg, #d1d7df, #8f98a4); + box-shadow: 0 5px 12px rgba(78, 82, 90, 0.18); } @media (max-width: 1280px) { @@ -681,7 +915,8 @@ button, input, select { padding-right: 26px; } + .rack-grid__bay, .rack-items-layer { - inset: 24px 26px 24px 62px; + width: 100%; } } diff --git a/public/assets/app.js b/public/assets/app.js index c4b0a52..1ccf487 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -2,13 +2,17 @@ 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(); @@ -19,6 +23,8 @@ document.addEventListener("DOMContentLoaded", async () => { 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"); @@ -45,6 +51,10 @@ function bindEvents() { 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); @@ -52,6 +62,8 @@ function bindEvents() { 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() { @@ -76,6 +88,7 @@ async function loadBootstrap() { function renderAll() { renderSummary(); renderLibrary(); + renderSelectionInfo(); renderRack(); renderStats(); renderBom(); @@ -155,16 +168,19 @@ function renderLibrary() { ${component.depthMm} mm tief ${formatCurrency(component.priceNet, component.currency)} - ` ) .join(""); - ui.componentLibrary.querySelectorAll("[data-action='add-component']").forEach((button) => { - button.addEventListener("click", () => addComponentToRack(button.dataset.componentId)); + ui.componentLibrary.querySelectorAll("[data-action='select-component']").forEach((button) => { + button.addEventListener("click", () => { + state.selectedComponentId = button.dataset.componentId; + renderAll(); + }); }); } @@ -184,49 +200,62 @@ function addComponentToRack(componentId) { state.placedItems.push({ placementId: `p${state.nextPlacementId++}`, componentId: component.id, - startU: position, + 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", "38px"); + 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(`
${u}U + ${u}
`); } - ui.rackGrid.innerHTML = `${slots.join("")}
`; + const insertionZones = renderInsertionZones(rack); - ui.rackGrid.querySelectorAll(".rack-slot").forEach((slot) => { - slot.addEventListener("dragover", (event) => { - event.preventDefault(); - slot.classList.add("is-drop-target"); - }); - slot.addEventListener("dragleave", () => slot.classList.remove("is-drop-target")); - slot.addEventListener("drop", (event) => { - event.preventDefault(); - slot.classList.remove("is-drop-target"); - if (!state.dragPlacementId) { - return; - } - movePlacedItem(state.dragPlacementId, Number(slot.dataset.slotU)); - }); + ui.rackGrid.innerHTML = ` +
${rack.rackStandard === "19_inch" ? '19" Rack' : '10" Rack'}
+
${rack.totalU} HE
+
+
${slots.join("")}
+
${insertionZones}
+
+
+ `; + + 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 * 38; + const rackHeight = rack.totalU * RACK_UNIT_PX; layer.style.height = `${rackHeight}px`; state.placedItems.forEach((item) => { @@ -235,14 +264,14 @@ function renderRack() { return; } - const top = (rack.totalU - (item.startU + component.heightU) + 1) * 38; - const height = component.heightU * 38 - 2; + 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.draggable = true; - element.style.top = `${top}px`; - element.style.height = `${height}px`; + 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 = `
${renderComponentFace(component, "rack")} @@ -253,9 +282,11 @@ function renderRack() { ${escapeHtml(component.name)}
${component.heightU}U · ${component.depthMm} mm · ${formatCurrency(component.priceNet, component.currency)}
- ${item.startU}U + ${formatRackPosition(item.y)}
+ + @@ -263,16 +294,11 @@ function renderRack() {
`; - element.addEventListener("dragstart", () => { - state.dragPlacementId = item.placementId; - element.classList.add("is-dragging"); - }); - element.addEventListener("dragend", () => { - state.dragPlacementId = null; - element.classList.remove("is-dragging"); - }); - element.querySelector("[data-action='move-up']").addEventListener("click", () => movePlacedItem(item.placementId, item.startU + 1)); - element.querySelector("[data-action='move-down']").addEventListener("click", () => movePlacedItem(item.placementId, item.startU - 1)); + 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(); @@ -282,7 +308,7 @@ function renderRack() { }); } -function movePlacedItem(placementId, requestedStartU) { +function nudgePlacedItem(placementId, deltaX, deltaY) { const rack = getCurrentRackTemplate(); const item = state.placedItems.find((entry) => entry.placementId === placementId); const component = item ? getComponent(item.componentId) : null; @@ -290,23 +316,56 @@ function movePlacedItem(placementId, requestedStartU) { return; } - const normalizedStart = Math.max(1, Math.min(requestedStartU, rack.totalU - component.heightU + 1)); - const occupied = state.placedItems.some((entry) => { - if (entry.placementId === placementId) { - return false; - } - const other = getComponent(entry.componentId); - if (!other) { - return false; - } - return rangesOverlap(normalizedStart, component.heightU, entry.startU, other.heightU); - }); + item.x = clamp(item.x + deltaX, 0, 100 - getComponentWidthPercent(component)); + item.y = clamp(item.y + deltaY, 0, getRackHeightPx(rack) - getComponentHeightPx(component)); + renderAll(); +} - if (occupied) { +function insertSelectedComponentAt(y) { + if (!state.selectedComponentId) { return; } - item.startU = normalizedStart; + 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(); } @@ -405,7 +464,7 @@ function renderCableSelectors() { if (!component) { return ""; } - return ``; + return ``; }) ) .join(""); @@ -435,7 +494,7 @@ function renderCableEstimate() { return; } - const verticalMm = Math.abs(from.startU - to.startU) * 44.45; + 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; @@ -481,6 +540,8 @@ function renderValidation() { .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 { @@ -489,6 +550,14 @@ function renderValidation() { }); } + 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) => `
  • ${escapeHtml(note.text)}
  • `).join(""); } @@ -587,25 +656,36 @@ function findFirstFreePosition(heightU) { return null; } - for (let start = 1; start <= rack.totalU - heightU + 1; start += 1) { + 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 component = getComponent(item.componentId); - return component && rangesOverlap(start, heightU, item.startU, component.heightU); + 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 start; + return y; } } return null; } -function rangesOverlap(startA, heightA, startB, heightB) { - const endA = startA + heightA - 1; - const endB = startB + heightB - 1; - return startA <= endB && startB <= endA; -} - function getCurrentRackTemplate() { return state.bootstrap?.rackTemplates.find((template) => template.id === state.rackTemplateId) ?? null; } @@ -634,6 +714,60 @@ function recommendCableLength(lengthMm) { 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", @@ -647,11 +781,15 @@ function renderComponentFace(component, mode) { const leds = '
    '; const label = `
    ${escapeHtml(component.manufacturer || component.category)}
    `; const modeClass = mode === "rack" ? "device-face--rack" : "device-face--library"; + const header = `
    ${escapeHtml(component.name)}
    `; switch (component.category) { case "switch": return `
    +
    +
    + ${header} ${label} ${leds} ${ports} @@ -661,6 +799,9 @@ function renderComponentFace(component, mode) { case "patch_panel": return `
    +
    +
    + ${header} ${label} ${renderPatchPanel(component)}
    @@ -668,6 +809,9 @@ function renderComponentFace(component, mode) { case "pdu": return `
    +
    +
    + ${header} ${label}
    ${renderSocketStrip(component)}
    ${leds} @@ -676,14 +820,21 @@ function renderComponentFace(component, mode) { case "ups": return `
    +
    +
    + ${header}
    +
    ${label}
    `; case "shelf": return `
    +
    +
    + ${header}
    ${label}
    @@ -691,12 +842,18 @@ function renderComponentFace(component, mode) { case "blank_panel": return `
    +
    +
    + ${header} ${label}
    `; default: return `
    +
    +
    + ${header} ${label}
    `; @@ -720,6 +877,158 @@ function renderSocketStrip(component) { return Array.from({ length: totalSockets }, () => "").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(` + + `); + } + + 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("&", "&") diff --git a/public/index.php b/public/index.php index ec18e09..ded5764 100644 --- a/public/index.php +++ b/public/index.php @@ -58,6 +58,17 @@ $apiBase = $publicBase === '' ? '' : $publicBase; +
    + +
    + Einsteckmodus +
    Kein Element ausgewaehlt
    +
    +
    +