(() => { const TYPES = { wall: "wall", floor: "floor", position: "position", forbidden: "forbidden", preferred: "preferred", unpreferred: "unpreferred", speed: "speed", sound: "sound", directional: "directional", directional_line: "directional_line", planner: "planner", io: "io", }; const FLOOR_PLAN_TYPES = new Set([TYPES.wall, TYPES.floor]); const POLYGON_TYPES = new Set([ TYPES.floor, TYPES.forbidden, TYPES.preferred, TYPES.unpreferred, TYPES.speed, TYPES.sound, TYPES.directional, TYPES.planner, TYPES.io, ]); const POINT_SHAPE_TYPES = new Set([TYPES.wall, TYPES.directional_line, ...POLYGON_TYPES]); function newId() { return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; } function normalizePoint(p) { const x = Number(p?.x); const y = Number(p?.y); if (!Number.isFinite(x) || !Number.isFinite(y)) return null; return { x, y }; } function minPoints(type) { if (type === TYPES.wall || type === TYPES.directional_line) return 2; if (POLYGON_TYPES.has(type)) return 3; return 0; } function isFloorPlanType(type) { return FLOOR_PLAN_TYPES.has(type); } function isPlannerZoneType(type) { return window.MapPlannerZones?.isPlannerZoneType(type) || false; } function isBehaviorZoneType(type) { return window.MapBehaviorZones?.isBehaviorZoneType(type) || false; } function isAdvancedZoneType(type) { return window.MapAdvancedZones?.isAdvancedZoneType(type) || false; } function isDirectionalLineType(type) { return type === TYPES.directional_line; } function clampSpeedMps(value) { return window.MapBehaviorZones?.clampSpeed(value) ?? 0.8; } function isPolygonType(type) { return POLYGON_TYPES.has(type); } function isPolylineType(type) { return type === TYPES.wall || type === TYPES.directional_line; } function isPointShapeType(type) { return POINT_SHAPE_TYPES.has(type); } function isOverlayObjectType(type) { return ( type === TYPES.position || isPlannerZoneType(type) || isBehaviorZoneType(type) || isAdvancedZoneType(type) ); } function isValidPolygon(z) { if (!z || !isPolygonType(z.type)) return false; const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : []; return points.length >= minPoints(z.type); } function isValidWall(z) { if (!z || z.type !== TYPES.wall) return false; const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : []; return points.length >= 2; } function isValidPosition(z) { return ( z && z.type === TYPES.position && Number.isFinite(Number(z.x)) && Number.isFinite(Number(z.y)) && Number.isFinite(Number(z.yaw)) ); } function isValidSpeedZone(z) { if (!isValidPolygon(z) || z.type !== TYPES.speed) return false; return Number.isFinite(Number(z.speed_mps)); } function isValidSoundZone(z) { return isValidPolygon(z) && z.type === TYPES.sound && typeof z.sound_id === "string"; } function isValidDirectionalShape(z) { return isValidPolygon(z) && z.type === TYPES.directional && Number.isFinite(Number(z.direction_deg)); } function isValidDirectionalLine(z) { if (!z || z.type !== TYPES.directional_line) return false; const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : []; return points.length >= 2; } function isValidPlannerZone(z) { return isValidPolygon(z) && z.type === TYPES.planner; } function isValidIoZone(z) { return isValidPolygon(z) && z.type === TYPES.io && typeof z.io_module === "string"; } function isValidZone(z) { if (z?.type === TYPES.position) return isValidPosition(z); if (z?.type === TYPES.wall) return isValidWall(z); if (z?.type === TYPES.speed) return isValidSpeedZone(z); if (z?.type === TYPES.sound) return isValidSoundZone(z); if (z?.type === TYPES.directional) return isValidDirectionalShape(z); if (z?.type === TYPES.directional_line) return isValidDirectionalLine(z); if (z?.type === TYPES.planner) return isValidPlannerZone(z); if (z?.type === TYPES.io) return isValidIoZone(z); if (isPolygonType(z?.type)) return isValidPolygon(z); return false; } /** Parse all map objects from API / database payload. */ function parseZones(raw) { if (!Array.isArray(raw)) return []; return raw .map((z) => { if (!z) return null; if (z.type === TYPES.position) { return { id: typeof z.id === "string" && z.id ? z.id : newId(), type: TYPES.position, name: typeof z.name === "string" ? z.name : "", x: Number(z.x), y: Number(z.y), yaw: Number(z.yaw), }; } if (isPointShapeType(z.type)) { const base = { id: typeof z.id === "string" && z.id ? z.id : newId(), type: z.type, points: (Array.isArray(z.points) ? z.points : []).map(normalizePoint).filter(Boolean), }; if (z.type === TYPES.speed) { base.speed_mps = clampSpeedMps(z.speed_mps); } if (z.type === TYPES.sound) { base.sound_id = typeof z.sound_id === "string" ? z.sound_id : ""; } if (z.type === TYPES.directional) { base.direction_deg = window.MapAdvancedZones?.normalizeDirectionDeg(z.direction_deg) ?? 0; } if (z.type === TYPES.directional_line) { base.reversed = !!z.reversed; base.line_width = Number.isFinite(Number(z.line_width)) ? Number(z.line_width) : 8; } if (z.type === TYPES.planner) { Object.assign( base, window.MapAdvancedZones?.normalizePlannerSettings(z) || {}, ); } if (z.type === TYPES.io) { const io = window.MapAdvancedZones?.normalizeIoSettings(z) || {}; base.io_module = io.io_module || ""; base.plc_register = io.plc_register; base.plc_value = io.plc_value; base.plc_mode = io.plc_mode; } return base; } return null; }) .filter(isValidZone); } function createZone(type, points, extra = {}) { const pts = points.map(normalizePoint).filter(Boolean); if (pts.length < minPoints(type)) return null; const zone = { id: newId(), type, points: pts }; if (type === TYPES.speed) zone.speed_mps = clampSpeedMps(extra.speed_mps); if (type === TYPES.sound) zone.sound_id = typeof extra.sound_id === "string" ? extra.sound_id : ""; if (type === TYPES.directional) { zone.direction_deg = window.MapAdvancedZones?.normalizeDirectionDeg(extra.direction_deg) ?? 0; } if (type === TYPES.directional_line) { zone.reversed = !!extra.reversed; zone.line_width = Number.isFinite(Number(extra.line_width)) ? Number(extra.line_width) : 8; } if (type === TYPES.planner) { Object.assign(zone, window.MapAdvancedZones?.normalizePlannerSettings(extra) || {}); } if (type === TYPES.io) { const io = window.MapAdvancedZones?.normalizeIoSettings(extra) || {}; zone.io_module = io.io_module || ""; zone.plc_register = io.plc_register; zone.plc_value = io.plc_value; zone.plc_mode = io.plc_mode; } return zone; } function createPosition(worldX, worldY, yaw, name = "") { if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(yaw)) return null; return { id: newId(), type: TYPES.position, name: name || "", x: worldX, y: worldY, yaw, }; } function distPointToSegment(px, py, x1, y1, x2, y2) { const dx = x2 - x1; const dy = y2 - y1; const lenSq = dx * dx + dy * dy; if (lenSq === 0) return Math.hypot(px - x1, py - y1); let t = ((px - x1) * dx + (py - y1) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)); } function pointInPolygon(px, py, points) { return window.MapPlannerZones?.pointInPolygon(px, py, points) || false; } function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } function positionPixel(z, mapMeta, imgW, imgH) { const geo = window.MapGeo; if (!geo || !z) return null; return geo.worldToPixel(mapMeta, imgW, imgH, z.x, z.y); } function hitTestPlannerZone(zones, px, py) { for (let i = zones.length - 1; i >= 0; i--) { const z = zones[i]; if (isPlannerZoneType(z.type) && pointInPolygon(px, py, z.points)) return z; } return null; } function hitTestBehaviorZone(zones, px, py) { for (let i = zones.length - 1; i >= 0; i--) { const z = zones[i]; if (isBehaviorZoneType(z.type) && pointInPolygon(px, py, z.points)) return z; } return null; } function hitTestAdvancedZone(zones, px, py, tolerance = 8) { for (let i = zones.length - 1; i >= 0; i--) { const z = zones[i]; if (!isAdvancedZoneType(z.type)) continue; if (z.type === TYPES.directional_line) { for (let j = 0; j < z.points.length - 1; j++) { const p1 = z.points[j]; const p2 = z.points[j + 1]; const width = Number(z.line_width) || 8; if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= width / 2 + tolerance) return z; } continue; } if (z.points?.length && pointInPolygon(px, py, z.points)) return z; } return null; } /** Find topmost floor-plan shape at image pixel. */ function hitTest(zones, px, py, tolerance = 8) { for (let i = zones.length - 1; i >= 0; i--) { const z = zones[i]; if (z.type === TYPES.floor && pointInPolygon(px, py, z.points)) return z; if (z.type === TYPES.wall) { for (let j = 0; j < z.points.length - 1; j++) { const p1 = z.points[j]; const p2 = z.points[j + 1]; if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= tolerance) return z; } } if (z.type === TYPES.directional_line) { for (let j = 0; j < z.points.length - 1; j++) { const p1 = z.points[j]; const p2 = z.points[j + 1]; const width = Number(z.line_width) || 8; if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= width / 2 + tolerance) return z; } } } return null; } function hitTestPosition(zones, px, py, mapMeta, imgW, imgH, tolerance = 14) { for (let i = zones.length - 1; i >= 0; i--) { const z = zones[i]; if (z.type !== TYPES.position) continue; const pt = positionPixel(z, mapMeta, imgW, imgH); if (pt && dist({ x: px, y: py }, pt) <= tolerance) return z; } return null; } function hitTestAny(zones, px, py, mapMeta, imgW, imgH, tolerance = 8) { const pos = hitTestPosition(zones, px, py, mapMeta, imgW, imgH, tolerance + 4); if (pos) return pos; const behavior = hitTestBehaviorZone(zones, px, py); if (behavior) return behavior; const advanced = hitTestAdvancedZone(zones, px, py, tolerance); if (advanced) return advanced; const planner = hitTestPlannerZone(zones, px, py); if (planner) return planner; return hitTest(zones, px, py, tolerance); } /** Hit vertex handle on selected or any point-based shape. */ function hitTestVertex(zones, px, py, selectedId, tolerance = 10) { const tryZone = (z) => { if (!isPointShapeType(z.type)) return null; for (let i = 0; i < z.points.length; i++) { if (dist({ x: px, y: py }, z.points[i]) <= tolerance) { return { zoneId: z.id, pointIndex: i }; } } return null; }; if (selectedId) { const sel = zones.find((z) => z.id === selectedId); const hit = sel ? tryZone(sel) : null; if (hit) return hit; } for (let i = zones.length - 1; i >= 0; i--) { const hit = tryZone(zones[i]); if (hit) return hit; } return null; } function pointsAttr(points) { return points.map((p) => `${p.x},${p.y}`).join(" "); } function clearSvg(svgEl) { while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild); } function appendPolyline(parent, points, className) { if (points.length < 2) return null; const el = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); el.setAttribute("class", className); el.setAttribute("points", pointsAttr(points)); el.setAttribute("fill", "none"); parent.appendChild(el); return el; } function appendPolygon(parent, points, className) { if (points.length < 3) return null; const el = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); el.setAttribute("class", className); el.setAttribute("points", pointsAttr(points)); parent.appendChild(el); return el; } function appendLine(parent, p1, p2, className) { const el = document.createElementNS("http://www.w3.org/2000/svg", "line"); el.setAttribute("class", className); el.setAttribute("x1", String(p1.x)); el.setAttribute("y1", String(p1.y)); el.setAttribute("x2", String(p2.x)); el.setAttribute("y2", String(p2.y)); parent.appendChild(el); return el; } function appendVertex(parent, p, className, r = 5, dataAttrs = {}) { const el = document.createElementNS("http://www.w3.org/2000/svg", "circle"); el.setAttribute("class", className); el.setAttribute("cx", String(p.x)); el.setAttribute("cy", String(p.y)); el.setAttribute("r", String(r)); Object.entries(dataAttrs).forEach(([k, v]) => el.setAttribute(k, v)); parent.appendChild(el); return el; } function appendPositionMarker(parent, px, py, yawDeg, selected) { const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("class", selected ? "mapObjPosition mapObjPosition--selected" : "mapObjPosition"); g.setAttribute("transform", `translate(${px},${py}) rotate(${yawDeg})`); const shaft = document.createElementNS("http://www.w3.org/2000/svg", "line"); shaft.setAttribute("class", "mapObjPositionShaft"); shaft.setAttribute("x1", "0"); shaft.setAttribute("y1", "0"); shaft.setAttribute("x2", "22"); shaft.setAttribute("y2", "0"); g.appendChild(shaft); const head = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); head.setAttribute("class", "mapObjPositionHead"); head.setAttribute("points", "22,0 14,-5 14,5"); g.appendChild(head); const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle"); dot.setAttribute("class", "mapObjPositionDot"); dot.setAttribute("cx", "0"); dot.setAttribute("cy", "0"); dot.setAttribute("r", "4"); g.appendChild(dot); parent.appendChild(g); return g; } function appendSelectionRect(parent, rect) { if (!rect) return null; const x = Math.min(rect.x0, rect.x1); const y = Math.min(rect.y0, rect.y1); const w = Math.abs(rect.x1 - rect.x0); const h = Math.abs(rect.y1 - rect.y0); if (w < 1 && h < 1) return null; const el = document.createElementNS("http://www.w3.org/2000/svg", "rect"); el.setAttribute("class", "mapObjSelectionRect"); el.setAttribute("x", String(x)); el.setAttribute("y", String(y)); el.setAttribute("width", String(w)); el.setAttribute("height", String(h)); parent.appendChild(el); return el; } function polygonCentroid(points) { if (!points?.length) return null; let x = 0; let y = 0; for (const p of points) { x += p.x; y += p.y; } return { x: x / points.length, y: y / points.length }; } function lineMidpoint(points) { if (!points?.length) return null; if (points.length === 1) return { ...points[0] }; const p0 = points[0]; const p1 = points[points.length - 1]; return { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }; } function appendDirectionArrow(parent, cx, cy, directionDeg, selected = false) { const adv = window.MapAdvancedZones; const deg = adv?.normalizeDirectionDeg(directionDeg) ?? 0; const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute( "class", selected ? "mapObjDirectionArrow mapObjDirectionArrow--selected" : "mapObjDirectionArrow", ); g.setAttribute("transform", `translate(${cx},${cy}) rotate(${-deg})`); const shaft = document.createElementNS("http://www.w3.org/2000/svg", "line"); shaft.setAttribute("class", "mapObjDirectionShaft"); shaft.setAttribute("x1", "-14"); shaft.setAttribute("y1", "0"); shaft.setAttribute("x2", "14"); shaft.setAttribute("y2", "0"); g.appendChild(shaft); const head = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); head.setAttribute("class", "mapObjDirectionHead"); head.setAttribute("points", "14,0 6,-5 6,5"); g.appendChild(head); parent.appendChild(g); return g; } function appendPolylineStyled(parent, points, className, strokeWidth = null) { const el = appendPolyline(parent, points, className); if (el && strokeWidth != null) el.setAttribute("stroke-width", String(strokeWidth)); return el; } function polygonClass(type, selected, draft = false) { const base = { [TYPES.floor]: "mapObjFloor", [TYPES.forbidden]: "mapObjForbidden", [TYPES.preferred]: "mapObjPreferred", [TYPES.unpreferred]: "mapObjUnpreferred", [TYPES.speed]: "mapObjSpeed", [TYPES.sound]: "mapObjSound", [TYPES.directional]: "mapObjDirectional", [TYPES.planner]: "mapObjPlannerSettings", [TYPES.io]: "mapObjIo", }[type]; if (!base) return ""; if (draft) return `${base} ${base}--draft`; if (selected) return `${base} ${base}--selected`; return base; } function filterVisible(zones, visibility) { const vis = visibility || {}; return zones.filter((z) => { if (z.type === TYPES.wall) return vis.walls !== false; if (z.type === TYPES.floor) return vis.floors !== false; if (z.type === TYPES.position) return vis.positions !== false; if (z.type === TYPES.forbidden) return vis.forbidden !== false; if (z.type === TYPES.preferred) return vis.preferred !== false; if (z.type === TYPES.unpreferred) return vis.unpreferred !== false; if (z.type === TYPES.speed) return vis.speed !== false; if (z.type === TYPES.sound) return vis.sound !== false; if (z.type === TYPES.directional || z.type === TYPES.directional_line) { return vis.directional !== false; } if (z.type === TYPES.planner) return vis.planner !== false; if (z.type === TYPES.io) return vis.io !== false; return true; }); } /** * Render map objects on SVG overlay. * @param {SVGSVGElement} svgEl * @param {object[]} zones * @param {object} opts */ function render(svgEl, zones, opts = {}) { if (!svgEl) return; clearSvg(svgEl); const mapMeta = opts.mapMeta || {}; const imgW = opts.imageWidth || 0; const imgH = opts.imageHeight || 0; const list = filterVisible(zones, opts.visibility); list.forEach((z) => { const selected = z.id === opts.selectedId; if (z.type === TYPES.wall) { appendPolyline(svgEl, z.points, selected ? "mapObjWall mapObjWall--selected" : "mapObjWall"); } else if (z.type === TYPES.directional_line) { const cls = selected ? "mapObjDirectionalLine mapObjDirectionalLine--selected" : "mapObjDirectionalLine"; appendPolylineStyled(svgEl, z.points, cls, z.line_width || 8); const mid = lineMidpoint(z.points); if (mid) { appendDirectionArrow( svgEl, mid.x, mid.y, window.MapAdvancedZones?.zoneDirectionDeg(z) ?? 0, selected, ); } } else if (isPolygonType(z.type)) { appendPolygon(svgEl, z.points, polygonClass(z.type, selected)); if (z.type === TYPES.directional) { const c = polygonCentroid(z.points); if (c) appendDirectionArrow(svgEl, c.x, c.y, z.direction_deg ?? 0, selected); } } else if (z.type === TYPES.position) { const pt = positionPixel(z, mapMeta, imgW, imgH); if (pt) { const yawDeg = (-Number(z.yaw) * 180) / Math.PI; appendPositionMarker(svgEl, pt.x, pt.y, yawDeg, selected); } } }); if (opts.selectedId && opts.showVertices !== false) { const sel = list.find((z) => z.id === opts.selectedId); if (sel && isPointShapeType(sel.type)) { sel.points.forEach((p, i) => { appendVertex(svgEl, p, "mapObjVertex mapObjVertex--handle", 6, { "data-vertex-index": String(i), }); }); } } const draft = opts.draft; if (draft?.kind === "shape" && draft.type && Array.isArray(draft.points) && draft.points.length) { const pts = draft.points; const hover = draft.hover; if (draft.type === TYPES.wall || draft.type === TYPES.directional_line) { const draftCls = draft.type === TYPES.directional_line ? "mapObjDirectionalLine mapObjDirectionalLine--draft" : "mapObjWall mapObjWall--draft"; appendPolylineStyled(svgEl, pts, draftCls, draft.type === TYPES.directional_line ? 8 : null); if (hover && pts.length) appendLine(svgEl, pts[pts.length - 1], hover, "mapObjDraftLine"); } else if (isPolygonType(draft.type)) { if (pts.length >= 3) appendPolygon(svgEl, pts, polygonClass(draft.type, false, true)); else if (pts.length === 2) appendPolyline(svgEl, pts, "mapObjDraftLine"); if (hover && pts.length) appendLine(svgEl, pts[pts.length - 1], hover, "mapObjDraftLine"); appendVertex(svgEl, pts[0], "mapObjCloseHint", 6); } pts.forEach((p) => appendVertex(svgEl, p, "mapObjVertex")); } if (draft?.kind === "position" && draft.px != null && draft.py != null) { const yawDeg = (-Number(draft.yaw || 0) * 180) / Math.PI; appendPositionMarker(svgEl, draft.px, draft.py, yawDeg, true); } appendSelectionRect(svgEl, opts.selectionRect); } function constrainAxis(from, to) { const dx = Math.abs(to.x - from.x); const dy = Math.abs(to.y - from.y); if (dx >= dy) return { x: to.x, y: from.y }; return { x: from.x, y: to.y }; } function nearPoint(a, b, tolerance = 12) { return dist(a, b) <= tolerance; } function yawFromPoints(origin, target) { return Math.atan2(-(target.y - origin.y), target.x - origin.x); } window.MapObjects = { TYPES, FLOOR_PLAN_TYPES, POLYGON_TYPES, POINT_SHAPE_TYPES, newId, parseZones, createZone, createPosition, minPoints, isFloorPlanType, isPlannerZoneType, isBehaviorZoneType, isAdvancedZoneType, isDirectionalLineType, clampSpeedMps, isPolygonType, isPolylineType, isPointShapeType, isOverlayObjectType, isValidZone, hitTest, hitTestPlannerZone, hitTestBehaviorZone, hitTestAdvancedZone, hitTestPosition, hitTestAny, hitTestVertex, positionPixel, render, filterVisible, constrainAxis, nearPoint, yawFromPoints, }; })();