document.addEventListener('DOMContentLoaded', () => { const SVG_NS = 'http://www.w3.org/2000/svg'; const DEFAULT_PLAN_SIZE = { width: 2000, height: 1000 }; const canvas = document.getElementById('floor-plan-canvas'); const overlay = document.getElementById('floor-plan-overlay'); const positionLabel = document.getElementById('floor-plan-position'); if (!canvas || !overlay) { return; } const xFieldName = canvas.dataset.xField; const yFieldName = canvas.dataset.yField; const xField = xFieldName ? document.querySelector(`input[name="${xFieldName}"]`) : null; const yField = yFieldName ? document.querySelector(`input[name="${yFieldName}"]`) : null; if (!xField || !yField) { return; } const markerWidth = Math.max(1, Number(canvas.dataset.markerWidth) || 10); const markerHeight = Math.max(1, Number(canvas.dataset.markerHeight) || 10); const markerType = canvas.dataset.markerType || ''; const activeId = Number(canvas.dataset.activeId || 0); const panelReferences = JSON.parse(canvas.dataset.referencePanels || '[]'); const outletReferences = JSON.parse(canvas.dataset.referenceOutlets || '[]'); const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); let markerX = 0; let markerY = 0; let dragging = false; let panning = false; let panStart = null; let dragOffsetX = 0; let dragOffsetY = 0; let viewX = 0; let viewY = 0; let viewWidth = DEFAULT_PLAN_SIZE.width; let viewHeight = DEFAULT_PLAN_SIZE.height; const activeMarker = document.createElementNS(SVG_NS, 'rect'); activeMarker.classList.add('active-marker'); if (markerType === 'patchpanel') { activeMarker.classList.add('panel-marker'); } else { activeMarker.classList.add('outlet-marker'); } activeMarker.setAttribute('width', String(markerWidth)); activeMarker.setAttribute('height', String(markerHeight)); overlay.appendChild(activeMarker); const planSize = { ...DEFAULT_PLAN_SIZE }; const updateOverlayViewBox = () => { overlay.setAttribute('viewBox', `${viewX} ${viewY} ${viewWidth} ${viewHeight}`); }; const updatePositionLabel = (x, y) => { if (positionLabel) { positionLabel.textContent = `${Math.round(x)} x ${Math.round(y)}`; } }; const paintActiveMarker = () => { activeMarker.setAttribute('x', String(Math.round(markerX))); activeMarker.setAttribute('y', String(Math.round(markerY))); }; const setMarkerPosition = (rawX, rawY) => { const maxX = Math.max(0, planSize.width - markerWidth); const maxY = Math.max(0, planSize.height - markerHeight); markerX = clamp(rawX, 0, maxX); markerY = clamp(rawY, 0, maxY); paintActiveMarker(); xField.value = Math.round(markerX); yField.value = Math.round(markerY); updatePositionLabel(markerX, markerY); }; const toOverlayPoint = (clientX, clientY) => { const rect = overlay.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return null; } const ratioX = (clientX - rect.left) / rect.width; const ratioY = (clientY - rect.top) / rect.height; const transformed = { x: viewX + (ratioX * viewWidth), y: viewY + (ratioY * viewHeight) }; return { x: transformed.x, y: transformed.y }; }; const clampView = () => { const minWidth = Math.max(30, planSize.width * 0.1); const minHeight = Math.max(30, planSize.height * 0.1); viewWidth = clamp(viewWidth, minWidth, planSize.width); viewHeight = clamp(viewHeight, minHeight, planSize.height); viewX = clamp(viewX, 0, Math.max(0, planSize.width - viewWidth)); viewY = clamp(viewY, 0, Math.max(0, planSize.height - viewHeight)); }; const applyView = () => { clampView(); updateOverlayViewBox(); }; const zoomAt = (clientX, clientY, factor) => { const point = toOverlayPoint(clientX, clientY); if (!point) { return; } const ratioX = (point.x - viewX) / viewWidth; const ratioY = (point.y - viewY) / viewHeight; const nextWidth = viewWidth * factor; const nextHeight = viewHeight * factor; viewX = point.x - (ratioX * nextWidth); viewY = point.y - (ratioY * nextHeight); viewWidth = nextWidth; viewHeight = nextHeight; applyView(); }; const resetView = () => { viewX = 0; viewY = 0; viewWidth = planSize.width; viewHeight = planSize.height; applyView(); }; const updateFromInputs = () => { setMarkerPosition(Number(xField.value) || 0, Number(yField.value) || 0); }; const clearReferenceMarkers = () => { overlay.querySelectorAll('.reference-marker').forEach((node) => node.remove()); }; const clearRoomHighlight = () => { overlay.querySelectorAll('.room-highlight').forEach((node) => node.remove()); }; const getNumericCoord = (value) => { if (value === null || value === undefined || value === '') { return null; } const num = Number(value); return Number.isFinite(num) ? num : null; }; const appendReference = (entry, cssClass, width, height) => { const rawX = entry.x ?? entry.pos_x; const rawY = entry.y ?? entry.pos_y; const x = getNumericCoord(rawX); const y = getNumericCoord(rawY); if (x === null || y === null) { return; } const ref = document.createElementNS(SVG_NS, 'rect'); ref.classList.add('reference-marker', cssClass); ref.setAttribute('x', String(Math.round(x))); ref.setAttribute('y', String(Math.round(y))); ref.setAttribute('width', String(Math.max(1, Math.round(width)))); ref.setAttribute('height', String(Math.max(1, Math.round(height)))); if (entry.name) { ref.setAttribute('aria-label', String(entry.name)); } overlay.insertBefore(ref, activeMarker); }; const appendRoomHighlight = () => { if (!outletRoomSelect) { return; } const selectedRoomOption = outletRoomSelect.selectedOptions?.[0]; if (!selectedRoomOption || !selectedRoomOption.value) { return; } const currentFloorId = getCurrentFloorId(); const roomFloorId = Number(selectedRoomOption.dataset.floorId || 0); if (!currentFloorId || roomFloorId !== currentFloorId) { return; } const polygonRaw = String(selectedRoomOption.dataset.roomPolygon || '').trim(); if (polygonRaw) { try { const parsed = JSON.parse(polygonRaw); if (Array.isArray(parsed)) { const points = parsed .map((point) => ({ x: Number(point && point.x), y: Number(point && point.y) })) .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)); if (points.length >= 3) { const polygon = document.createElementNS(SVG_NS, 'polygon'); polygon.classList.add('room-highlight'); polygon.setAttribute( 'points', points.map((point) => `${Math.round(point.x)},${Math.round(point.y)}`).join(' ') ); overlay.insertBefore(polygon, activeMarker); return; } } } catch (error) { // ignore invalid room polygon json } } const x = Number(selectedRoomOption.dataset.roomX || 0); const y = Number(selectedRoomOption.dataset.roomY || 0); const width = Number(selectedRoomOption.dataset.roomWidth || 0); const height = Number(selectedRoomOption.dataset.roomHeight || 0); if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) { return; } if (width <= 0 || height <= 0) { return; } const rect = document.createElementNS(SVG_NS, 'rect'); rect.classList.add('room-highlight'); rect.setAttribute('x', String(Math.round(x))); rect.setAttribute('y', String(Math.round(y))); rect.setAttribute('width', String(Math.round(width))); rect.setAttribute('height', String(Math.round(height))); overlay.insertBefore(rect, activeMarker); }; const panelLocationSelect = document.getElementById('panel-location-select'); const panelBuildingSelect = document.getElementById('panel-building-select'); const panelFloorSelect = document.getElementById('panel-floor-select'); const outletRoomSelect = document.getElementById('outlet-room-select'); const floorPlanSvg = document.getElementById('floor-plan-svg'); const panelPlacementFields = document.getElementById('panel-placement-fields'); const panelFloorPlanGroup = document.getElementById('panel-floor-plan-group'); const panelFloorMissingHint = document.getElementById('panel-floor-missing-hint'); const outletBindPatchpanelSelect = document.getElementById('outlet-bind-patchpanel-port-id'); const buildingOptions = panelBuildingSelect ? Array.from(panelBuildingSelect.options).filter((option) => option.value !== '') : []; const floorOptions = panelFloorSelect ? Array.from(panelFloorSelect.options).filter((option) => option.value !== '') : []; const getCurrentFloorId = () => { const floorOption = panelFloorSelect?.selectedOptions?.[0]; if (floorOption?.value) { return Number(floorOption.value); } const roomOption = outletRoomSelect?.selectedOptions?.[0]; return Number(roomOption?.dataset?.floorId || 0); }; const filterPatchpanelBindOptions = () => { if (!outletBindPatchpanelSelect) { return; } const currentFloorId = getCurrentFloorId(); const options = Array.from(outletBindPatchpanelSelect.options).filter((option) => option.value !== ''); let firstMatch = ''; let selectedStillVisible = false; options.forEach((option) => { const optionFloorId = Number(option.dataset.floorId || 0); const matchesFloor = !currentFloorId || optionFloorId === currentFloorId; option.hidden = !matchesFloor; if (matchesFloor && !option.disabled && !firstMatch) { firstMatch = option.value; } if (matchesFloor && option.selected) { selectedStillVisible = true; } }); if (!selectedStillVisible && firstMatch && !outletBindPatchpanelSelect.value) { outletBindPatchpanelSelect.value = firstMatch; } }; const renderReferenceMarkers = () => { clearRoomHighlight(); clearReferenceMarkers(); const currentFloorId = getCurrentFloorId(); if (!currentFloorId) { return; } appendRoomHighlight(); panelReferences.forEach((entry) => { if (Number(entry.floor_id) !== currentFloorId) { return; } if (markerType === 'patchpanel' && Number(entry.id) === activeId) { return; } appendReference(entry, 'panel-marker', Math.max(1, Number(entry.width) || 20), Math.max(1, Number(entry.height) || 5)); }); outletReferences.forEach((entry) => { if (Number(entry.floor_id) !== currentFloorId) { return; } if (markerType === 'outlet' && Number(entry.id) === activeId) { return; } appendReference(entry, 'outlet-marker', 10, 10); }); }; const updateFloorPlanImage = () => { if (!floorPlanSvg) { return; } const floorOption = panelFloorSelect?.selectedOptions?.[0]; const roomOption = outletRoomSelect?.selectedOptions?.[0]; const svgUrl = floorOption?.dataset?.svgUrl || roomOption?.dataset?.floorSvgUrl || ''; if (svgUrl) { floorPlanSvg.src = svgUrl; floorPlanSvg.hidden = false; loadPlanDimensions(svgUrl); } else { floorPlanSvg.removeAttribute('src'); floorPlanSvg.hidden = true; planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; resetView(); } renderReferenceMarkers(); filterPatchpanelBindOptions(); }; if (floorPlanSvg) { floorPlanSvg.addEventListener('error', () => { floorPlanSvg.removeAttribute('src'); floorPlanSvg.hidden = true; }); } const loadPlanDimensions = async (svgUrl) => { if (!svgUrl) { return; } try { const response = await fetch(svgUrl, { credentials: 'same-origin' }); if (!response.ok) { throw new Error('SVG not available'); } const raw = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(raw, 'image/svg+xml'); const root = doc.documentElement; if (!root || root.nodeName.toLowerCase() === 'parsererror') { throw new Error('Invalid SVG'); } const vb = String(root.getAttribute('viewBox') || '').trim(); if (vb) { const parts = vb.split(/\s+/).map((value) => Number(value)); if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) { planSize.width = Math.max(1, parts[2]); planSize.height = Math.max(1, parts[3]); resetView(); renderReferenceMarkers(); updateFromInputs(); return; } } const width = Number(root.getAttribute('width')); const height = Number(root.getAttribute('height')); if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { planSize.width = width; planSize.height = height; } else { planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; } resetView(); renderReferenceMarkers(); updateFromInputs(); } catch (error) { planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; resetView(); renderReferenceMarkers(); updateFromInputs(); } }; const updatePanelPlacementVisibility = () => { if (!panelFloorSelect || !panelPlacementFields || !panelFloorPlanGroup) { return; } const hasFloor = !!panelFloorSelect.value; panelPlacementFields.hidden = !hasFloor; panelFloorPlanGroup.hidden = !hasFloor; if (panelFloorMissingHint) { panelFloorMissingHint.hidden = hasFloor; } }; const filterFloorOptions = () => { if (!panelFloorSelect) { return; } const buildingValue = panelBuildingSelect?.value || ''; let firstMatch = ''; floorOptions.forEach((option) => { const matches = !buildingValue || option.dataset.buildingId === buildingValue; option.hidden = !matches; option.disabled = !matches; if (matches && !firstMatch) { firstMatch = option.value; } }); const selectedOption = panelFloorSelect.querySelector(`option[value="${panelFloorSelect.value}"]`); const isSelectedHidden = selectedOption ? selectedOption.hidden : true; if (buildingValue && (!panelFloorSelect.value || isSelectedHidden) && firstMatch) { panelFloorSelect.value = firstMatch; } updatePanelPlacementVisibility(); updateFloorPlanImage(); }; const filterBuildingOptions = () => { if (!panelBuildingSelect) { return; } const locationValue = panelLocationSelect?.value || ''; let firstMatch = ''; buildingOptions.forEach((option) => { const matches = !locationValue || option.dataset.locationId === locationValue; option.hidden = !matches; option.disabled = !matches; if (matches && !firstMatch) { firstMatch = option.value; } }); const selectedOption = panelBuildingSelect.querySelector(`option[value="${panelBuildingSelect.value}"]`); const isSelectedHidden = selectedOption ? selectedOption.hidden : true; if (locationValue && (!panelBuildingSelect.value || isSelectedHidden) && firstMatch) { panelBuildingSelect.value = firstMatch; } }; activeMarker.addEventListener('pointerdown', (event) => { event.preventDefault(); dragging = true; panning = false; const point = toOverlayPoint(event.clientX, event.clientY); if (!point) { return; } dragOffsetX = point.x - markerX; dragOffsetY = point.y - markerY; activeMarker.setPointerCapture(event.pointerId); }); activeMarker.addEventListener('pointermove', (event) => { if (!dragging) { return; } const point = toOverlayPoint(event.clientX, event.clientY); if (!point) { return; } setMarkerPosition(point.x - dragOffsetX, point.y - dragOffsetY); }); const stopDrag = (event) => { if (dragging) { dragging = false; if (activeMarker.hasPointerCapture(event.pointerId)) { activeMarker.releasePointerCapture(event.pointerId); } } if (panning) { panning = false; panStart = null; if (overlay.hasPointerCapture(event.pointerId)) { overlay.releasePointerCapture(event.pointerId); } } }; ['pointerup', 'pointercancel', 'pointerleave'].forEach((evt) => { activeMarker.addEventListener(evt, stopDrag); }); overlay.addEventListener('pointerdown', (event) => { if (event.shiftKey || event.button === 1) { event.preventDefault(); panning = true; dragging = false; panStart = { clientX: event.clientX, clientY: event.clientY, viewX, viewY }; overlay.setPointerCapture(event.pointerId); return; } if (event.target !== overlay) { return; } const point = toOverlayPoint(event.clientX, event.clientY); if (!point) { return; } setMarkerPosition(point.x - markerWidth / 2, point.y - markerHeight / 2); }); overlay.addEventListener('pointermove', (event) => { if (!panning || !panStart) { return; } const rect = overlay.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return; } const scaleX = viewWidth / rect.width; const scaleY = viewHeight / rect.height; const dx = (event.clientX - panStart.clientX) * scaleX; const dy = (event.clientY - panStart.clientY) * scaleY; viewX = panStart.viewX - dx; viewY = panStart.viewY - dy; applyView(); }); overlay.addEventListener('pointerup', stopDrag); overlay.addEventListener('pointercancel', stopDrag); overlay.addEventListener('wheel', (event) => { event.preventDefault(); const factor = event.deltaY < 0 ? 0.9 : 1.1; zoomAt(event.clientX, event.clientY, factor); }, { passive: false }); [xField, yField].forEach((input) => { input.addEventListener('input', () => { updateFromInputs(); }); }); window.addEventListener('resize', () => { updateFromInputs(); renderReferenceMarkers(); }); if (panelLocationSelect) { panelLocationSelect.addEventListener('change', () => { filterBuildingOptions(); filterFloorOptions(); }); } if (panelBuildingSelect) { panelBuildingSelect.addEventListener('change', () => { filterFloorOptions(); }); } if (panelFloorSelect) { panelFloorSelect.addEventListener('change', () => { updatePanelPlacementVisibility(); updateFloorPlanImage(); }); } if (outletRoomSelect) { outletRoomSelect.addEventListener('change', () => { updateFloorPlanImage(); }); } document.querySelectorAll('[data-floor-plan-zoom]').forEach((button) => { button.addEventListener('click', () => { const action = button.getAttribute('data-floor-plan-zoom'); if (action === 'in') { const rect = overlay.getBoundingClientRect(); zoomAt(rect.left + (rect.width / 2), rect.top + (rect.height / 2), 0.85); return; } if (action === 'out') { const rect = overlay.getBoundingClientRect(); zoomAt(rect.left + (rect.width / 2), rect.top + (rect.height / 2), 1.15); return; } resetView(); }); }); updateOverlayViewBox(); updateFromInputs(); filterPatchpanelBindOptions(); if (panelLocationSelect) { filterBuildingOptions(); filterFloorOptions(); } else { updateFloorPlanImage(); } updatePanelPlacementVisibility(); });