(() => { const SVG_NS = 'http://www.w3.org/2000/svg'; const VIEWBOX_WIDTH = 2000; const VIEWBOX_HEIGHT = 1000; const SNAP_TOLERANCE = 12; function initFloorSvgEditor() { const editor = document.getElementById('floor-svg-editor'); const svg = document.getElementById('floor-svg-canvas'); const hiddenInput = document.getElementById('floor-svg-content'); if (!editor || !svg || !hiddenInput) { return; } const controls = { startPolyline: document.getElementById('floor-start-polyline'), finishPolyline: document.getElementById('floor-finish-polyline'), deletePolyline: document.getElementById('floor-delete-polyline'), clearDrawing: document.getElementById('floor-clear-drawing'), lock45: document.getElementById('floor-lock-45'), snapGuides: document.getElementById('floor-snap-guides'), addGuide: document.getElementById('floor-add-guide'), guideOrientation: document.getElementById('floor-guide-orientation'), guidePosition: document.getElementById('floor-guide-position'), guideList: document.getElementById('floor-guide-list') }; const state = { polylines: [], guides: [], selectedPolylineId: null, activePolylineId: null, draggingVertex: null }; loadFromExistingSvg(hiddenInput.value, state); bindControlEvents(controls, state, svg, hiddenInput); bindCanvasEvents(svg, controls, state, hiddenInput); render(svg, controls, state, hiddenInput); } function bindControlEvents(controls, state, svg, hiddenInput) { controls.startPolyline.addEventListener('click', () => { const id = createId('poly'); state.polylines.push({ id, points: [] }); state.activePolylineId = id; state.selectedPolylineId = id; render(svg, controls, state, hiddenInput); }); controls.finishPolyline.addEventListener('click', () => { finishActivePolyline(state); render(svg, controls, state, hiddenInput); }); controls.deletePolyline.addEventListener('click', () => { if (!state.selectedPolylineId) { return; } state.polylines = state.polylines.filter((line) => line.id !== state.selectedPolylineId); if (state.activePolylineId === state.selectedPolylineId) { state.activePolylineId = null; } state.selectedPolylineId = null; render(svg, controls, state, hiddenInput); }); controls.clearDrawing.addEventListener('click', () => { state.polylines = []; state.guides = []; state.selectedPolylineId = null; state.activePolylineId = null; state.draggingVertex = null; render(svg, controls, state, hiddenInput); }); controls.addGuide.addEventListener('click', () => { const orientation = controls.guideOrientation.value === 'horizontal' ? 'horizontal' : 'vertical'; const position = Number(controls.guidePosition.value); if (!Number.isFinite(position)) { return; } state.guides.push({ id: createId('guide'), orientation, position: Math.round(position) }); controls.guidePosition.value = ''; render(svg, controls, state, hiddenInput); }); controls.guideList.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const id = target.getAttribute('data-remove-guide'); if (!id) { return; } state.guides = state.guides.filter((guide) => guide.id !== id); render(svg, controls, state, hiddenInput); }); } function bindCanvasEvents(svg, controls, state, hiddenInput) { svg.addEventListener('pointerdown', (event) => { const target = event.target; if (!(target instanceof SVGElement)) { return; } const vertex = target.closest('[data-vertex-index]'); if (vertex) { const polylineId = vertex.getAttribute('data-polyline-id'); const vertexIndex = Number(vertex.getAttribute('data-vertex-index')); if (polylineId && Number.isInteger(vertexIndex)) { state.selectedPolylineId = polylineId; state.draggingVertex = { polylineId, vertexIndex }; vertex.setPointerCapture(event.pointerId); } return; } const polylineEl = target.closest('[data-polyline-id]'); if (polylineEl) { const polylineId = polylineEl.getAttribute('data-polyline-id'); if (polylineId) { state.selectedPolylineId = polylineId; if (!state.activePolylineId) { state.activePolylineId = polylineId; } render(svg, controls, state, hiddenInput); } return; } const point = toSvgPoint(svg, event); if (!point) { return; } if (!state.activePolylineId) { const id = createId('poly'); state.polylines.push({ id, points: [] }); state.activePolylineId = id; state.selectedPolylineId = id; } const activeLine = state.polylines.find((line) => line.id === state.activePolylineId); if (!activeLine) { return; } let nextPoint = point; if (controls.lock45.checked && activeLine.points.length > 0) { nextPoint = lockTo45(activeLine.points[activeLine.points.length - 1], nextPoint); } if (controls.snapGuides.checked) { nextPoint = snapPointToGuides(nextPoint, state.guides, SNAP_TOLERANCE); } activeLine.points.push({ x: Math.round(nextPoint.x), y: Math.round(nextPoint.y) }); render(svg, controls, state, hiddenInput); }); svg.addEventListener('pointermove', (event) => { if (!state.draggingVertex) { return; } const point = toSvgPoint(svg, event); if (!point) { return; } const line = state.polylines.find((item) => item.id === state.draggingVertex.polylineId); if (!line) { return; } const index = state.draggingVertex.vertexIndex; if (!line.points[index]) { return; } let nextPoint = point; if (controls.lock45.checked && index > 0 && line.points[index - 1]) { nextPoint = lockTo45(line.points[index - 1], nextPoint); } if (controls.snapGuides.checked) { nextPoint = snapPointToGuides(nextPoint, state.guides, SNAP_TOLERANCE); } line.points[index] = { x: Math.round(nextPoint.x), y: Math.round(nextPoint.y) }; render(svg, controls, state, hiddenInput); }); svg.addEventListener('pointerup', () => { state.draggingVertex = null; }); svg.addEventListener('pointercancel', () => { state.draggingVertex = null; }); } function render(svg, controls, state, hiddenInput) { const selected = state.polylines.find((line) => line.id === state.selectedPolylineId) || null; svg.innerHTML = ''; svg.setAttribute('viewBox', `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`); const background = createSvgElement('rect'); background.setAttribute('x', '0'); background.setAttribute('y', '0'); background.setAttribute('width', String(VIEWBOX_WIDTH)); background.setAttribute('height', String(VIEWBOX_HEIGHT)); background.setAttribute('fill', '#fafafa'); background.setAttribute('stroke', '#e1e1e1'); background.setAttribute('stroke-width', '1'); svg.appendChild(background); state.guides.forEach((guide) => { const line = createSvgElement('line'); if (guide.orientation === 'horizontal') { line.setAttribute('x1', '0'); line.setAttribute('y1', String(guide.position)); line.setAttribute('x2', String(VIEWBOX_WIDTH)); line.setAttribute('y2', String(guide.position)); } else { line.setAttribute('x1', String(guide.position)); line.setAttribute('y1', '0'); line.setAttribute('x2', String(guide.position)); line.setAttribute('y2', String(VIEWBOX_HEIGHT)); } line.setAttribute('stroke', '#8f8f8f'); line.setAttribute('stroke-width', '1'); line.setAttribute('stroke-dasharray', '8 6'); line.setAttribute('opacity', '0.8'); line.setAttribute('data-guide-id', guide.id); svg.appendChild(line); }); state.polylines.forEach((polyline) => { const line = createSvgElement('polyline'); line.setAttribute('fill', 'none'); line.setAttribute('stroke', polyline.id === state.selectedPolylineId ? '#007bff' : '#1f2937'); line.setAttribute('stroke-width', polyline.id === state.selectedPolylineId ? '5' : '3'); line.setAttribute('points', polyline.points.map((point) => `${point.x},${point.y}`).join(' ')); line.setAttribute('data-polyline-id', polyline.id); svg.appendChild(line); }); if (selected) { selected.points.forEach((point, index) => { const vertex = createSvgElement('circle'); vertex.setAttribute('cx', String(point.x)); vertex.setAttribute('cy', String(point.y)); vertex.setAttribute('r', '8'); vertex.setAttribute('fill', '#ffffff'); vertex.setAttribute('stroke', '#dc3545'); vertex.setAttribute('stroke-width', '3'); vertex.setAttribute('data-polyline-id', selected.id); vertex.setAttribute('data-vertex-index', String(index)); svg.appendChild(vertex); }); } controls.guideList.innerHTML = ''; state.guides.forEach((guide) => { const li = document.createElement('li'); const label = guide.orientation === 'horizontal' ? 'Horizontal' : 'Vertikal'; li.innerHTML = ` ${label}: ${Math.round(guide.position)} `; controls.guideList.appendChild(li); }); hiddenInput.value = buildSvgMarkup(state.polylines, state.guides); } function finishActivePolyline(state) { const active = state.polylines.find((line) => line.id === state.activePolylineId); if (active && active.points.length < 2) { state.polylines = state.polylines.filter((line) => line.id !== active.id); if (state.selectedPolylineId === active.id) { state.selectedPolylineId = null; } } state.activePolylineId = null; } function loadFromExistingSvg(raw, state) { const content = String(raw || '').trim(); if (!content) { return; } try { const parser = new DOMParser(); const doc = parser.parseFromString(content, 'image/svg+xml'); const root = doc.documentElement; if (!root || root.nodeName.toLowerCase() === 'parsererror') { return; } root.querySelectorAll('line[data-guide="1"], line.floor-guide').forEach((line) => { const orientation = line.getAttribute('data-orientation') === 'horizontal' ? 'horizontal' : 'vertical'; const position = orientation === 'horizontal' ? Number(line.getAttribute('y1')) : Number(line.getAttribute('x1')); if (Number.isFinite(position)) { state.guides.push({ id: createId('guide'), orientation, position: Math.round(position) }); } }); root.querySelectorAll('polyline').forEach((polyline) => { const pointsAttr = polyline.getAttribute('points') || ''; const points = parsePoints(pointsAttr); if (points.length < 2) { return; } state.polylines.push({ id: createId('poly'), points }); }); if (state.polylines.length > 0) { state.selectedPolylineId = state.polylines[0].id; } } catch (error) { // ignore invalid svg content } } function parsePoints(pointsAttr) { return pointsAttr .trim() .split(/\s+/) .map((pair) => { const [x, y] = pair.split(',').map((value) => Number(value)); if (!Number.isFinite(x) || !Number.isFinite(y)) { return null; } return { x: Math.round(x), y: Math.round(y) }; }) .filter(Boolean); } function buildSvgMarkup(polylines, guides) { const svg = createSvgElement('svg'); svg.setAttribute('xmlns', SVG_NS); svg.setAttribute('viewBox', `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`); const background = createSvgElement('rect'); background.setAttribute('x', '0'); background.setAttribute('y', '0'); background.setAttribute('width', String(VIEWBOX_WIDTH)); background.setAttribute('height', String(VIEWBOX_HEIGHT)); background.setAttribute('fill', '#fafafa'); background.setAttribute('stroke', '#e1e1e1'); background.setAttribute('stroke-width', '1'); svg.appendChild(background); guides.forEach((guide) => { const line = createSvgElement('line'); if (guide.orientation === 'horizontal') { line.setAttribute('x1', '0'); line.setAttribute('y1', String(guide.position)); line.setAttribute('x2', String(VIEWBOX_WIDTH)); line.setAttribute('y2', String(guide.position)); } else { line.setAttribute('x1', String(guide.position)); line.setAttribute('y1', '0'); line.setAttribute('x2', String(guide.position)); line.setAttribute('y2', String(VIEWBOX_HEIGHT)); } line.setAttribute('stroke', '#8f8f8f'); line.setAttribute('stroke-width', '1'); line.setAttribute('stroke-dasharray', '8 6'); line.setAttribute('class', 'floor-guide'); line.setAttribute('data-guide', '1'); line.setAttribute('data-orientation', guide.orientation); svg.appendChild(line); }); polylines.forEach((polyline) => { if (polyline.points.length < 2) { return; } const line = createSvgElement('polyline'); line.setAttribute('fill', 'none'); line.setAttribute('stroke', '#1f2937'); line.setAttribute('stroke-width', '3'); line.setAttribute('class', 'floor-polyline'); line.setAttribute('points', polyline.points.map((point) => `${point.x},${point.y}`).join(' ')); svg.appendChild(line); }); return new XMLSerializer().serializeToString(svg); } function lockTo45(origin, point) { const dx = point.x - origin.x; const dy = point.y - origin.y; const length = Math.sqrt((dx * dx) + (dy * dy)); if (length === 0) { return { x: origin.x, y: origin.y }; } const angle = Math.atan2(dy, dx); const step = Math.PI / 4; const snapped = Math.round(angle / step) * step; return { x: origin.x + Math.cos(snapped) * length, y: origin.y + Math.sin(snapped) * length }; } function snapPointToGuides(point, guides, tolerance) { let next = { ...point }; guides.forEach((guide) => { if (guide.orientation === 'vertical') { if (Math.abs(next.x - guide.position) <= tolerance) { next.x = guide.position; } } else if (Math.abs(next.y - guide.position) <= tolerance) { next.y = guide.position; } }); return next; } 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 }; } function createSvgElement(name) { return document.createElementNS(SVG_NS, name); } function createId(prefix) { return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; } document.addEventListener('DOMContentLoaded', initFloorSvgEditor); })();