378 lines
14 KiB
JavaScript
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);
|
|
})();
|