Files
netwatch/app/assets/js/room-polygon-editor.js
2026-02-16 10:59:29 +01:00

378 lines
14 KiB
JavaScript

(() => {
const SVG_NS = 'http://www.w3.org/2000/svg';
const DEFAULT_VIEWBOX = { x: 0, y: 0, width: 2000, height: 1000 };
const SNAP_TOLERANCE = 16;
function initRoomPolygonEditor() {
const floorSelect = document.getElementById('room-floor-id');
const canvas = document.getElementById('room-polygon-canvas');
const polygonInput = document.getElementById('room-polygon-points');
const snapWalls = document.getElementById('room-snap-walls');
const undoButton = document.getElementById('room-undo-point');
const clearButton = document.getElementById('room-clear-polygon');
const mapHint = document.getElementById('room-map-hint');
if (!floorSelect || !canvas || !polygonInput) {
return;
}
const state = {
viewBox: { ...DEFAULT_VIEWBOX },
wallPoints: [],
floorLayerNodes: [],
points: parsePoints(polygonInput.value),
draggingIndex: null
};
const updateInput = () => {
if (state.points.length >= 3) {
polygonInput.value = JSON.stringify(state.points.map((point) => ({
x: Math.round(point.x),
y: Math.round(point.y)
})));
} else {
polygonInput.value = '';
}
};
const render = () => {
canvas.innerHTML = '';
canvas.setAttribute(
'viewBox',
`${state.viewBox.x} ${state.viewBox.y} ${state.viewBox.width} ${state.viewBox.height}`
);
const bg = createSvgElement('rect');
bg.setAttribute('x', String(state.viewBox.x));
bg.setAttribute('y', String(state.viewBox.y));
bg.setAttribute('width', String(state.viewBox.width));
bg.setAttribute('height', String(state.viewBox.height));
bg.setAttribute('fill', '#f7f7f7');
bg.setAttribute('stroke', '#e1e1e1');
canvas.appendChild(bg);
if (state.floorLayerNodes.length > 0) {
const floorLayer = createSvgElement('g');
floorLayer.setAttribute('opacity', '0.55');
floorLayer.setAttribute('pointer-events', 'none');
state.floorLayerNodes.forEach((node) => {
floorLayer.appendChild(node.cloneNode(true));
});
canvas.appendChild(floorLayer);
}
if (state.points.length >= 2) {
const polyline = createSvgElement('polyline');
polyline.setAttribute('fill', state.points.length >= 3 ? 'rgba(13, 110, 253, 0.16)' : 'none');
polyline.setAttribute('stroke', '#0d6efd');
polyline.setAttribute('stroke-width', '3');
polyline.setAttribute('points', buildPointString(state.points));
if (state.points.length >= 3) {
polyline.setAttribute('stroke-linejoin', 'round');
polyline.setAttribute('stroke-linecap', 'round');
polyline.setAttribute('points', `${buildPointString(state.points)} ${state.points[0].x},${state.points[0].y}`);
}
canvas.appendChild(polyline);
}
state.points.forEach((point, index) => {
const vertex = createSvgElement('circle');
vertex.setAttribute('cx', String(point.x));
vertex.setAttribute('cy', String(point.y));
vertex.setAttribute('r', '9');
vertex.setAttribute('fill', '#ffffff');
vertex.setAttribute('stroke', '#dc3545');
vertex.setAttribute('stroke-width', '3');
vertex.setAttribute('data-vertex-index', String(index));
canvas.appendChild(vertex);
});
updateInput();
};
const snapPoint = (point) => {
if (!snapWalls || !snapWalls.checked || state.wallPoints.length === 0) {
return point;
}
let nearest = null;
let nearestDist = Number.POSITIVE_INFINITY;
state.wallPoints.forEach((wallPoint) => {
const dx = wallPoint.x - point.x;
const dy = wallPoint.y - point.y;
const dist = Math.sqrt((dx * dx) + (dy * dy));
if (dist < nearestDist) {
nearestDist = dist;
nearest = wallPoint;
}
});
if (nearest && nearestDist <= SNAP_TOLERANCE) {
return { x: nearest.x, y: nearest.y };
}
return point;
};
const addPoint = (event) => {
const point = toSvgPoint(canvas, event);
if (!point) {
return;
}
state.points.push(snapPoint(point));
render();
};
const onPointerDown = (event) => {
const target = event.target;
if (!(target instanceof SVGElement)) {
return;
}
const vertex = target.closest('[data-vertex-index]');
if (vertex) {
const vertexIndex = Number(vertex.getAttribute('data-vertex-index'));
if (Number.isInteger(vertexIndex)) {
state.draggingIndex = vertexIndex;
vertex.setPointerCapture(event.pointerId);
}
return;
}
addPoint(event);
};
const onPointerMove = (event) => {
if (state.draggingIndex === null) {
return;
}
const point = toSvgPoint(canvas, event);
if (!point) {
return;
}
state.points[state.draggingIndex] = snapPoint(point);
render();
};
const stopDragging = () => {
state.draggingIndex = null;
};
const parseFloorSvg = (rawSvg) => {
const parser = new DOMParser();
const doc = parser.parseFromString(rawSvg, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() === 'parsererror') {
return null;
}
return root;
};
const readViewBox = (svgRoot) => {
const vb = (svgRoot.getAttribute('viewBox') || '').trim();
if (vb) {
const parts = vb.split(/\s+/).map((value) => Number(value));
if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) {
return {
x: parts[0],
y: parts[1],
width: parts[2],
height: parts[3]
};
}
}
const width = Number(svgRoot.getAttribute('width'));
const height = Number(svgRoot.getAttribute('height'));
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return { x: 0, y: 0, width, height };
}
return { ...DEFAULT_VIEWBOX };
};
const collectSnapPoints = (svgRoot) => {
const points = [];
const addPointValue = (x, y) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return;
}
points.push({ x: Math.round(x), y: Math.round(y) });
};
svgRoot.querySelectorAll('line').forEach((line) => {
addPointValue(Number(line.getAttribute('x1')), Number(line.getAttribute('y1')));
addPointValue(Number(line.getAttribute('x2')), Number(line.getAttribute('y2')));
});
svgRoot.querySelectorAll('polyline, polygon').forEach((shape) => {
parsePointString(shape.getAttribute('points') || '').forEach((point) => addPointValue(point.x, point.y));
});
svgRoot.querySelectorAll('rect').forEach((rect) => {
const x = Number(rect.getAttribute('x'));
const y = Number(rect.getAttribute('y'));
const width = Number(rect.getAttribute('width'));
const height = Number(rect.getAttribute('height'));
addPointValue(x, y);
addPointValue(x + width, y);
addPointValue(x + width, y + height);
addPointValue(x, y + height);
});
svgRoot.querySelectorAll('path').forEach((path) => {
const d = path.getAttribute('d') || '';
const numbers = (d.match(/-?\d+(\.\d+)?/g) || []).map((value) => Number(value));
for (let i = 0; i < numbers.length - 1; i += 2) {
addPointValue(numbers[i], numbers[i + 1]);
}
});
return dedupePoints(points);
};
const loadFloor = async () => {
const selected = floorSelect.selectedOptions[0];
const svgUrl = selected ? (selected.dataset.svgUrl || '') : '';
state.viewBox = { ...DEFAULT_VIEWBOX };
state.wallPoints = [];
state.floorLayerNodes = [];
if (!svgUrl) {
if (mapHint) {
mapHint.textContent = 'Fuer dieses Stockwerk ist keine Karte hinterlegt. Polygon kann trotzdem frei gezeichnet werden.';
}
render();
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 root = parseFloorSvg(raw);
if (!root) {
throw new Error('Invalid SVG');
}
state.viewBox = readViewBox(root);
state.wallPoints = collectSnapPoints(root);
state.floorLayerNodes = Array.from(root.childNodes)
.filter((node) => node.nodeType === Node.ELEMENT_NODE)
.map((node) => node.cloneNode(true));
if (mapHint) {
mapHint.textContent = state.wallPoints.length > 0
? 'Klick setzt Punkte. Punkte sind per Drag verschiebbar. Snap nutzt Wandpunkte aus der Stockwerkskarte.'
: 'Klick setzt Punkte. Punkte sind per Drag verschiebbar.';
}
} catch (error) {
if (mapHint) {
mapHint.textContent = 'Stockwerkskarte konnte nicht geladen werden. Polygon kann frei gezeichnet werden.';
}
}
render();
};
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', stopDragging);
canvas.addEventListener('pointercancel', stopDragging);
canvas.addEventListener('pointerleave', stopDragging);
floorSelect.addEventListener('change', () => {
loadFloor();
});
if (undoButton) {
undoButton.addEventListener('click', () => {
if (state.points.length === 0) {
return;
}
state.points.pop();
render();
});
}
if (clearButton) {
clearButton.addEventListener('click', () => {
state.points = [];
render();
});
}
render();
loadFloor();
}
function parsePoints(raw) {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map((point) => ({
x: Number(point && point.x),
y: Number(point && point.y)
}))
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
} catch (error) {
return [];
}
}
function parsePointString(pointsAttr) {
return pointsAttr
.trim()
.split(/\s+/)
.map((pair) => {
const values = pair.split(',');
if (values.length !== 2) {
return null;
}
const x = Number(values[0]);
const y = Number(values[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null;
}
return { x, y };
})
.filter(Boolean);
}
function dedupePoints(points) {
const map = new Map();
points.forEach((point) => {
const key = `${Math.round(point.x)}:${Math.round(point.y)}`;
if (!map.has(key)) {
map.set(key, { x: Math.round(point.x), y: Math.round(point.y) });
}
});
return Array.from(map.values());
}
function buildPointString(points) {
return points.map((point) => `${Math.round(point.x)},${Math.round(point.y)}`).join(' ');
}
function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
function toSvgPoint(svg, event) {
const pt = svg.createSVGPoint();
pt.x = event.clientX;
pt.y = event.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) {
return null;
}
const transformed = pt.matrixTransform(ctm.inverse());
return { x: transformed.x, y: transformed.y };
}
document.addEventListener('DOMContentLoaded', initRoomPolygonEditor);
})();