1// ââ HTML Entity Decoder âââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 3function decodeHtmlEntities(text) { 4 if (!text || typeof text !== 'string') return text; 5 var ta = document.createElement('textarea'); 6 ta.innerHTML = text; 7 return ta.value; 8} 9 10function normalizeTenantName(name) { 11 return decodeHtmlEntities(name || '') 12 .replace(/\s+/g, ' ') 13 .trim() 14 .toLowerCase(); 15} 16 17function revealSvgPremiseElement(el) { 18 if (!el || !el.removeAttribute) return; 19 el.removeAttribute('display'); 20 el.style.display = ''; 21} 22// ââ SVG Path Offset Utilities for Wall Border ââââââââââââââââââââââââââââââ 23 24function parseSvgPathToSubpaths(d) { 25 var cmds = d.match(/[MmLlHhVvCcSsQqTtAaZz][^MmLlHhVvCcSsQqTtAaZz]*/g) || []; 26 var subs = []; 27 var cur = []; 28 var x = 0, y = 0, sx = 0, sy = 0; 29 30 cmds.forEach(function (raw) { 31 var t = raw.trim(); 32 var ch = t[0]; 33 var nums = (t.substring(1).match(/-?[\d.]+(?:e[+-]?\d+)?/gi) || []).map(Number); 34 35 switch (ch) { 36 case "M": 37 if (cur.length > 2) subs.push(cur); 38 cur = []; 39 x = nums[0]; 40 y = nums[1]; 41 sx = x; 42 sy = y; 43 cur.push({ x: x, y: y }); 44 for (var i = 2; i < nums.length; i += 2) { 45 x = nums[i]; 46 y = nums[i + 1]; 47 cur.push({ x: x, y: y }); 48 } 49 break; 50 case "m": 51 if (cur.length > 2) subs.push(cur); 52 cur = []; 53 x += nums[0]; 54 y += nums[1]; 55 sx = x; 56 sy = y; 57 cur.push({ x: x, y: y }); 58 for (var i = 2; i < nums.length; i += 2) { 59 x += nums[i]; 60 y += nums[i + 1]; 61 cur.push({ x: x, y: y }); 62 } 63 break; 64 case "L": 65 for (var i = 0; i < nums.length; i += 2) { 66 x = nums[i]; 67 y = nums[i + 1]; 68 cur.push({ x: x, y: y }); 69 } 70 break; 71 case "l": 72 for (var i = 0; i < nums.length; i += 2) { 73 x += nums[i]; 74 y += nums[i + 1]; 75 cur.push({ x: x, y: y }); 76 } 77 break; 78 case "H": 79 for (var i = 0; i < nums.length; i++) { 80 x = nums[i]; 81 cur.push({ x: x, y: y }); 82 } 83 break; 84 case "h": 85 for (var i = 0; i < nums.length; i++) { 86 x += nums[i]; 87 cur.push({ x: x, y: y }); 88 } 89 break; 90 case "V": 91 for (var i = 0; i < nums.length; i++) { 92 y = nums[i]; 93 cur.push({ x: x, y: y }); 94 } 95 break; 96 case "v": 97 for (var i = 0; i < nums.length; i++) { 98 y += nums[i]; 99 cur.push({ x: x, y: y }); 100 } 101 break; 102 case "C": 103 for (var i = 0; i < nums.length; i += 6) { 104 var ox = x, oy = y; 105 for (var s = 1; s <= 8; s++) { 106 var tt = s / 8, u = 1 - tt; 107 var px = u * u * u * ox + 3 * u * u * tt * nums[i] + 3 * u * tt * tt * nums[i + 2] + tt * tt * tt * nums[i + 4]; 108 var py = u * u * u * oy + 3 * u * u * tt * nums[i + 1] + 3 * u * tt * tt * nums[i + 3] + tt * tt * tt * nums[i + 5]; 109 cur.push({ x: px, y: py }); 110 } 111 x = nums[i + 4]; 112 y = nums[i + 5]; 113 } 114 break; 115 case "c": 116 for (var i = 0; i < nums.length; i += 6) { 117 var ox = x, oy = y; 118 for (var s = 1; s <= 8; s++) { 119 var tt = s / 8, u = 1 - tt; 120 var px = u * u * u * ox + 3 * u * u * tt * (ox + nums[i]) + 3 * u * tt * tt * (ox + nums[i + 2]) + tt * tt * tt * (ox + nums[i + 4]); 121 var py = u * u * u * oy + 3 * u * u * tt * (oy + nums[i + 1]) + 3 * u * tt * tt * (oy + nums[i + 3]) + tt * tt * tt * (oy + nums[i + 5]); 122 cur.push({ x: px, y: py }); 123 } 124 x = ox + nums[i + 4]; 125 y = oy + nums[i + 5]; 126 } 127 break; 128 case "Z": 129 case "z": 130 x = sx; 131 y = sy; 132 break; 133 } 134 }); 135 if (cur.length > 2) subs.push(cur); 136 return subs; 137} 138 139function segSegIntersect(a, b) { 140 var dax = a.x2 - a.x1; 141 var day = a.y2 - a.y1; 142 var dbx = b.x2 - b.x1; 143 var dby = b.y2 - b.y1; 144 var denom = dax * dby - day * dbx; 145 if (Math.abs(denom) < 1e-10) return null; 146 var t = ((b.x1 - a.x1) * dby - (b.y1 - a.y1) * dbx) / denom; 147 return { x: a.x1 + t * dax, y: a.y1 + t * day }; 148} 149 150function offsetPolygonPoints(pts, dist) { 151 var area = 0; 152 var n = pts.length; 153 for (var i = 0; i < n; i++) { 154 var j = (i + 1) % n; 155 area += pts[i].x * pts[j].y - pts[j].x * pts[i].y; 156 } 157 var sign = area > 0 ? 1 : -1; 158 var off = dist * sign; 159 160 var edges = []; 161 for (var i = 0; i < n; i++) { 162 var j = (i + 1) % n; 163 var dx = pts[j].x - pts[i].x; 164 var dy = pts[j].y - pts[i].y; 165 var len = Math.sqrt(dx * dx + dy * dy); 166 if (len < 1e-10) { 167 edges.push(null); 168 continue; 169 } 170 var nx = (dy / len) * off; 171 var ny = (-dx / len) * off; 172 edges.push({ 173 x1: pts[i].x + nx, 174 y1: pts[i].y + ny, 175 x2: pts[j].x + nx, 176 y2: pts[j].y + ny, 177 }); 178 } 179 180 var result = []; 181 for (var i = 0; i < n; i++) { 182 var a = edges[i]; 183 if (!a) continue; 184 var j = (i + 1) % n; 185 while (!edges[j] && j !== i) j = (j + 1) % n; 186 var b = edges[j]; 187 if (!b) { 188 result.push({ x: a.x2, y: a.y2 }); 189 continue; 190 } 191 var p = segSegIntersect(a, b); 192 result.push(p || { x: a.x2, y: a.y2 }); 193 } 194 return result; 195} 196 197function buildOffsetPath(d, offset) { 198 var subs = parseSvgPathToSubpaths(d); 199 var parts = []; 200 for (var i = 0; i < subs.length; i++) { 201 var pts = subs[i]; 202 if (pts.length < 3) continue; 203 var off = offsetPolygonPoints(pts, offset); 204 if (off.length < 3) continue; 205 if (off.some(function (p) { return !p || !isFinite(p.x) || !isFinite(p.y); })) continue; 206 parts.push( 207 "M" + 208 off.map(function (p) { return p.x.toFixed(2) + "," + p.y.toFixed(2); }).join("L") + 209 "Z" 210 ); 211 } 212 return parts.join(" "); 213} 214 215function initWallOverlay() { 216 var style = getComputedStyle(document.documentElement); 217 var offset = parseFloat(style.getPropertyValue("--fm-boundary-offset")) || 0; 218 219 // Target SVG elements in both fullscreen and trigger views 220 var svgs = document.querySelectorAll('.floor-map-fs__svg-floor svg, .floor-map-trigger__svg svg');
221 svgs.forEach(function (svg) { 222 // Find all explicit outline elements. Some floor plans split the building 223 // footprint into several island outlines: building_outline, building_outline1, etc. 224 var walls = Array.from(svg.querySelectorAll( 225 "[id^='building_outline'], [id^='buiding_outline'], #wall" 226 )); 227 var decorOutlineShapes = Array.from(svg.querySelectorAll( 228 "#decor > polygon[fill='none'][stroke='red'], " + 229 "#decor > rect[fill='none'][stroke='red'], " + 230 "#decor > path[fill='none'][stroke='red'], " + 231 "#decor > polyline[fill='none'][stroke='red']" 232 )); 233 var rooms = svg.querySelector("#Rooms"); 234 235 if (!rooms) return; 236 237 decorOutlineShapes.forEach(function (wall) { 238 if (walls.indexOf(wall) === -1) walls.push(wall); 239 }); 240 241 if (!walls.length) { 242 var fallbackWall = svg.querySelector("#decor polygon:first-child"); 243 if (fallbackWall) walls.push(fallbackWall); 244 } 245 246 if (!walls.length) return; 247 248 // Check if overlay already exists 249 if (svg.querySelector(".fm-wall-outline")) return; 250 251 var insertionPoint = rooms; 252 253 walls.forEach(function (wall) { 254 var overlay = wall.cloneNode(true); 255 overlay.removeAttribute("id"); 256 overlay.setAttribute("class", "fm-wall-outline"); 257 258 // Try to apply offset if it's a path element with offset > 0 259 if (offset > 0 && overlay.getAttribute("d")) { 260 var newD = buildOffsetPath(overlay.getAttribute("d"), offset); 261 if (newD && newD.indexOf('NaN') === -1) { 262 overlay.setAttribute("d", newD); 263 } 264 } 265 // For polygon/rect elements without offset, just use the clone as-is 266 // For path elements without offset, same - use the clone 267 268 insertionPoint.after(overlay); 269 insertionPoint = overlay; 270 }); 271 272 // Move entrance markers above the outline 273 var entrances = svg.querySelectorAll('[id^="wejscie"]'); 274 entrances.forEach(function (entrance) { 275 insertionPoint.after(entrance); 276 insertionPoint = entrance; 277 }); 278 }); 279} 280 281document.addEventListener('DOMContentLoaded', function () { 282 283 // ââ Trigger: double click / Enter / double tap â opens fullscreen on the right floor 284 var trigger = document.querySelector('.floor-map-trigger'); 285 var overlay = document.getElementById('floor-map-fullscreen'); 286 var fmDebug = (new URLSearchParams(window.location.search)).get('fmdebug') === '1'; 287 288 function fmDiagLog() { 289 if (!fmDebug || !window.console || !console.log) return; 290 console.log.apply(console, ['[floor-map]'].concat(Array.prototype.slice.call(arguments))); 291 } 292 293 function fmDiagError() { 294 if (!fmDebug || !window.console || !console.error) return; 295 console.error.apply(console, ['[floor-map]'].concat(Array.prototype.slice.call(arguments))); 296 } 297 298 if (fmDebug) { 299 window.addEventListener('error', function (e) { 300 fmDiagError('window error', e.message, e.filename + ':' + e.lineno + ':' + e.colno, e.error && e.error.stack); 301 }); 302 window.addEventListener('unhandledrejection', function (e) { 303 fmDiagError('unhandled rejection', e.reason); 304 }); 305 } 306 307 fmDiagLog('DOMContentLoaded', { 308 trigger: !!trigger, 309 overlay: !!overlay, 310 href: window.location.href 311 }); 312 313 if (!trigger || !overlay) { 314 fmDiagError('Missing required elements', { 315 trigger: !!trigger, 316 overlay: !!overlay 317 }); 318 return; 319 } 320 321 function getTriggerFloor(el) { 322 return (el && el.getAttribute && el.getAttribute('data-open-floor')) || ''; 323 } 324 325 // Show/hide the "double-click to open" hint on hover 326 var triggerHint = trigger.querySelector('.floor-map-trigger__hint'); 327 if (triggerHint) { 328 trigger.addEventListener('mouseenter', function () { 329 triggerHint.classList.add('is-visible'); 330 }); 331 trigger.addEventListener('mouseleave', function () { 332 triggerHint.classList.remove('is-visible'); 333 }); 334 trigger.addEventListener('focus', function () { 335 triggerHint.classList.add('is-visible'); 336 }); 337 trigger.addEventListener('blur', function () { 338 triggerHint.classList.remove('is-visible'); 339 }); 340 } 341 342 // Badge click stays passive on the thumbnail; double-click opens fullscreen. 343 trigger.querySelectorAll('.store-map-badge').forEach(function (badge) { 344 badge.addEventListener('click', function (e) { 345 e.stopPropagation(); 346 }); 347 348 badge.addEventListener('dblclick', function (e) { 349 e.preventDefault(); 350 e.stopPropagation(); 351 fmDiagLog('badge dblclick', badge.getAttribute('data-floor-slug') || getTriggerFloor(trigger)); 352 openFloorMapFS(badge.getAttribute('data-floor-slug') || getTriggerFloor(trigger)); 353 }); 354 }); 355 356 trigger.addEventListener('dblclick', function (e) { 357 e.preventDefault(); 358 fmDiagLog('trigger dblclick', getTriggerFloor(trigger)); 359 openFloorMapFS(getTriggerFloor(trigger)); 360 }); 361 362 var lastTriggerTap = 0; 363 trigger.addEventListener('touchend', function (e) { 364 var now = Date.now(); 365 var badge = e.target.closest('.store-map-badge'); 366 var targetFloor = badge ? (badge.getAttribute('data-floor-slug') || getTriggerFloor(trigger)) : getTriggerFloor(trigger); 367 if (now - lastTriggerTap < 350) { 368 e.preventDefault(); 369 fmDiagLog('trigger double tap', targetFloor); 370 openFloorMapFS(targetFloor); 371 lastTriggerTap = 0; 372 return; 373 } 374 lastTriggerTap = now; 375 }, { passive: false }); 376 377 trigger.addEventListener('keydown', function (e) { 378 if (e.key === 'Enter') { 379 fmDiagLog('trigger Enter', getTriggerFloor(trigger)); 380 openFloorMapFS(getTriggerFloor(trigger)); 381 } 382 }); 383
384 // Mini-map: remove native SVG titles so the passive thumbnail does not show tooltips. 385 (function disableMiniMapTenantTitles() { 386 trigger.querySelectorAll('.floor-map-trigger__svg title').forEach(function (t) { t.remove(); }); 387 388 var tenantName = trigger.getAttribute('data-tenant-name') || ''; 389 if (!tenantName) return; 390 var highlights = trigger.querySelectorAll('.floor-map-trigger__svg .fm-tenant-highlight'); 391 highlights.forEach(function (el) { 392 el.setAttribute('data-tenant-name', tenantName); 393 }); 394 })(); 395 396 // ââ Open / Close ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 397 function openFloorMapFS(targetSlug) { 398 fmDiagLog('openFloorMapFS start', { 399 targetSlug: targetSlug, 400 overlayClass: overlay.className 401 }); 402 403 overlay.classList.add('is-open'); 404 overlay.setAttribute('aria-hidden', 'false'); 405 document.body.classList.add('floor-map-fs-open'); 406 407 // Initialize wall overlay border 408 initWallOverlay(); 409 410 showList(); 411 412 resetTransform(); 413 414 // If a target floor was requested (e.g. from single-store badge or trigger), 415 // simulate a click on the matching picker item so the same switch logic runs. 416 if (targetSlug) { 417 var pickerBtn = document.querySelector( 418 '.floor-map-fs .floor-map-fs__picker-item[data-floor-slug="' + targetSlug + '"]' 419 ); 420 if (pickerBtn && !pickerBtn.classList.contains('is-active')) { 421 fmDiagLog('openFloorMapFS picker click', targetSlug); 422 pickerBtn.click(); 423 } 424 } 425 426 var activeFloorEl = document.querySelector('.floor-map-fs__svg-floor.is-active'); 427 if (activeFloorEl) { 428 var slug = activeFloorEl.getAttribute('data-floor-img'); 429 injectThumbnails(slug); 430 highlightTenantsOnSvg(slug); 431 highlightTenantOnMap(activeTenantName); 432 } 433 434 fmDiagLog('openFloorMapFS end', { 435 isOpen: overlay.classList.contains('is-open'), 436 activeFloor: activeFloorEl ? activeFloorEl.getAttribute('data-floor-img') : null, 437 activeTenantName: activeTenantName 438 }); 439 } 440 441 function closeFloorMapFS() { 442 overlay.classList.remove('is-open'); 443 overlay.setAttribute('aria-hidden', 'true'); 444 document.body.classList.remove('floor-map-fs-open'); 445 svgTooltip.style.display = 'none'; 446 closePicker(); 447 var _aside = document.getElementById('floor-map-fs-aside'); 448 if (_aside) _aside.classList.remove('is-list-open'); 449 } 450 451 var closeBtn = document.getElementById('floor-map-fs-close'); 452 if (closeBtn) { 453 closeBtn.addEventListener('click', closeFloorMapFS); 454 } 455 456 document.addEventListener('keydown', function (e) { 457 if (e.key === 'Escape' && overlay.classList.contains('is-open')) { 458 closeFloorMapFS(); 459 } 460 }); 461 462 // ââ Aside slide: lista â szczegóÅy ââââââââââââââââââââââââââââââââââââââââ 463 var slider = document.getElementById('floor-map-fs-aside-slider'); 464 var detailPanel = document.getElementById('floor-map-fs-detail'); 465 var backBtn = document.getElementById('floor-map-fs-back'); 466 var activeTenantName = trigger.getAttribute('data-tenant-name') || ''; 467 468 function showDetail() { 469 slider.classList.add('is-detail'); 470 detailPanel.setAttribute('aria-hidden', 'false'); 471 } 472 473 function showList() { 474 slider.classList.remove('is-detail'); 475 detailPanel.setAttribute('aria-hidden', 'true'); 476 } 477 478 if (backBtn) { 479 backBtn.addEventListener('click', showList); 480 } 481 482 // Klik na tenanta â wypeÅnij panel + przesuÅ 483 document.querySelectorAll('.floor-map-fs__tenant').forEach(function (el) { 484 el.addEventListener('click', function () { 485 openTenantDetail(el); 486 }); 487 el.addEventListener('keydown', function (e) { 488 if (e.key === 'Enter' || e.key === ' ') { 489 e.preventDefault(); 490 openTenantDetail(el); 491 } 492 }); 493 }); 494 495 function openTenantDetail(el) { 496 var name = el.getAttribute('data-store-name') || ''; 497 var targetFloor = getTenantTargetFloor(name, el); 498 var level = el.getAttribute('data-store-level') || ''; 499 var cat = el.getAttribute('data-store-cat') || ''; 500 var thumb = el.getAttribute('data-store-thumb') || ''; 501 var hours = el.getAttribute('data-store-hours') || ''; 502 var url = el.getAttribute('data-store-url') || '#'; 503 504 var nameEl = document.getElementById('floor-map-fs-detail-name'); 505 var metaEl = document.getElementById('floor-map-fs-detail-meta'); 506 var hoursEl = document.getElementById('floor-map-fs-detail-hours'); 507 var linkEl = document.getElementById('floor-map-fs-detail-link'); 508 var thumbEl = document.getElementById('floor-map-fs-detail-thumb'); 509 var thumbWrap = document.getElementById('floor-map-fs-detail-thumb-wrap'); 510 511 if (nameEl) nameEl.textContent = name; 512 if (hoursEl) hoursEl.textContent = hours; 513 if (linkEl) linkEl.href = url; 514 515 if (metaEl) {
516 var parts = [cat, level].filter(Boolean); 517 metaEl.textContent = parts.join(' · '); 518 } 519 520 if (thumbWrap && thumbEl) { 521 if (thumb) { 522 thumbEl.src = thumb; 523 thumbEl.alt = name; 524 thumbWrap.style.display = ''; 525 } else { 526 thumbWrap.style.display = 'none'; 527 } 528 } 529 530 activeTenantName = name; 531 532 var currentFloor = getActiveFloorSlug(); 533 if (targetFloor && selectFloorBySlug(targetFloor)) { 534 scheduleTenantFlyTo(currentFloor !== targetFloor ? 460 : 0); 535 } else { 536 highlightTenantOnMap(name); 537 scheduleTenantFlyTo(0); 538 } 539 540 closeMobileList(); 541 showDetail(); 542 } 543 544 // ââ Pan + Zoom (viewBox-based for crisp SVG rendering) âââââââââââââââââââ 545 var viewport = document.getElementById('floor-map-fs-viewport'); 546 547 // viewBox-based zoom state 548 var vbX = 0, vbY = 0, vbW = 0, vbH = 0; // current viewBox 549 var baseW = 0, baseH = 0; // original viewBox size (= zoom 1Ã) 550 var enableTenantFlyTo = window.location.search.indexOf('fmfly=0') === -1; 551 var tenantFlyFrame = 0; 552 var tenantFlyTimeout = 0; 553 554 var MIN_SCALE = 0.5; 555 var MAX_SCALE = 5; 556 var TENANT_FLY_SCALE = 3; 557 558 function getActiveSvg() { 559 var floor = document.querySelector('.floor-map-fs__svg-floor.is-active'); 560 return floor ? floor.querySelector('svg') : null; 561 } 562 563 function initViewBox() { 564 var svg = getActiveSvg(); 565 if (!svg) return; 566 567 var vb = svg.getAttribute('viewBox'); 568 if (!vb) return; 569 var parts = vb.split(/[\s,]+/).map(Number); 570 baseW = parts[2]; 571 baseH = parts[3]; 572 vbX = parts[0]; 573 vbY = parts[1]; 574 vbW = baseW; 575 vbH = baseH; 576 } 577 578 function applyViewBox() { 579 var svg = getActiveSvg(); 580 if (!svg) return; 581 svg.setAttribute('viewBox', vbX + ' ' + vbY + ' ' + vbW + ' ' + vbH); 582 } 583 584 function currentScale() { 585 return baseW ? baseW / vbW : 1; 586 } 587 588 function clampScale(s) { 589 return Math.min(MAX_SCALE, Math.max(MIN_SCALE, s)); 590 } 591 592 function cancelTenantFlyTo() { 593 if (!tenantFlyFrame) return; 594 window.cancelAnimationFrame(tenantFlyFrame); 595 tenantFlyFrame = 0; 596 } 597 598 function cancelScheduledTenantFlyTo() { 599 if (!tenantFlyTimeout) return; 600 window.clearTimeout(tenantFlyTimeout); 601 tenantFlyTimeout = 0; 602 } 603 604 function scheduleTenantFlyTo(delay) { 605 if (!enableTenantFlyTo) { 606 fmDiagLog('tenant fly skipped by fmfly=0'); 607 return; 608 } 609 610 cancelScheduledTenantFlyTo(); 611 fmDiagLog('scheduleTenantFlyTo', { 612 delay: delay, 613 activeFloor: getActiveFloorSlug() 614 }); 615 tenantFlyTimeout = window.setTimeout(function () { 616 tenantFlyTimeout = 0; 617 try { 618 flyToSelectedTenant(); 619 } catch (e) { 620 cancelTenantFlyTo(); 621 } 622 }, delay); 623 } 624 625 // Strip fixed width/height so SVGs fill their container via viewBox + CSS 626 document.querySelectorAll('.floor-map-fs__svg-floor svg').forEach(function (svg) { 627 svg.removeAttribute('width'); 628 svg.removeAttribute('height'); 629 svg.removeAttribute('x'); 630 svg.removeAttribute('y'); 631 svg.style.width = '100%'; 632 svg.style.height = '100%'; 633 }); 634 635 function resetTransform() { 636 cancelTenantFlyTo(); 637 initViewBox(); 638 applyViewBox(); 639 } 640 641 // Convert a viewport-pixel offset to SVG-unit offset 642 function pxToSvg(pxX, pxY) { 643 var svg = getActiveSvg(); 644 if (!svg) return { x: 0, y: 0 }; 645 var rect = svg.getBoundingClientRect(); 646 return { 647 x: vbX + (pxX / rect.width) * vbW, 648 y: vbY + (pxY / rect.height) * vbH, 649 }; 650 } 651 652 function zoomAt(ox, oy, factor) { 653 cancelScheduledTenantFlyTo(); 654 cancelTenantFlyTo(); 655 // ox, oy are in viewport-pixel coords (relative to viewport element) 656 var pt = pxToSvg(ox, oy); 657 658 var newScale = clampScale(currentScale() * factor); 659 var newW = baseW / newScale; 660 var newH = baseH / newScale; 661 662 // Keep the point under the cursor fixed 663 vbX = pt.x - (ox / viewport.getBoundingClientRect().width) * newW; 664 vbY = pt.y - (oy / viewport.getBoundingClientRect().height) * newH; 665 vbW = newW; 666 vbH = newH; 667 668 applyViewBox(); 669 } 670 671 // ââ Mouse: drag + scroll zoom âââââââââââââââââââââââââââââââââââââââââââââ 672 var isDragging = false; 673 var dragStartX = 0; 674 var dragStartY = 0; 675 var dragVbX = 0; 676 var dragVbY = 0; 677 678 viewport.addEventListener('mousedown', function (e) { 679 if (e.button !== 0) return; 680 cancelScheduledTenantFlyTo(); 681 cancelTenantFlyTo(); 682 isDragging = true; 683 dragStartX = e.clientX; 684 dragStartY = e.clientY; 685 dragVbX = vbX; 686 dragVbY = vbY; 687 viewport.style.cursor = 'grabbing'; 688 e.preventDefault(); 689 }); 690 691 document.addEventListener('mousemove', function (e) { 692 if (!isDragging) return; 693 var svg = getActiveSvg(); 694 if (!svg) return; 695 var rect = svg.getBoundingClientRect(); 696 // Convert pixel delta to viewBox-unit delta (inverted: drag right = viewBox moves left) 697 vbX = dragVbX - ((e.clientX - dragStartX) / rect.width) * vbW; 698 vbY = dragVbY - ((e.clientY - dragStartY) / rect.height) * vbH; 699 applyViewBox(); 700 }); 701 702 document.addEventListener('mouseup', function () { 703 if (isDragging) { 704 isDragging = false;
705 viewport.style.cursor = 'grab'; 706 } 707 }); 708 709 viewport.addEventListener('wheel', function (e) { 710 e.preventDefault(); 711 712 var rect = viewport.getBoundingClientRect(); 713 var ox = e.clientX - rect.left; 714 var oy = e.clientY - rect.top; 715 var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; 716 717 zoomAt(ox, oy, factor); 718 }, { passive: false }); 719 720 // ââ Touch: 1 palec = pan, 2 palce = pinch zoom âââââââââââââââââââââââââââ 721 var lastTouchX = 0; 722 var lastTouchY = 0; 723 var lastPinchDist = 0; 724 var isTouching = false; 725 var isPinching = false; 726 727 function getTouchDist(touches) { 728 var dx = touches[0].clientX - touches[1].clientX; 729 var dy = touches[0].clientY - touches[1].clientY; 730 return Math.sqrt(dx * dx + dy * dy); 731 } 732 733 function getTouchMidpoint(touches, rect) { 734 return { 735 x: ((touches[0].clientX + touches[1].clientX) / 2) - rect.left, 736 y: ((touches[0].clientY + touches[1].clientY) / 2) - rect.top, 737 }; 738 } 739 740 viewport.addEventListener('touchstart', function (e) { 741 cancelScheduledTenantFlyTo(); 742 cancelTenantFlyTo(); 743 if (e.touches.length === 1) { 744 isTouching = true; 745 isPinching = false; 746 lastTouchX = e.touches[0].clientX; 747 lastTouchY = e.touches[0].clientY; 748 } else if (e.touches.length === 2) { 749 isTouching = false; 750 isPinching = true; 751 lastPinchDist = getTouchDist(e.touches); 752 } 753 }, { passive: true }); 754 755 viewport.addEventListener('touchmove', function (e) { 756 if (isPinching && e.touches.length === 2) { 757 var dist = getTouchDist(e.touches); 758 var factor = dist / lastPinchDist; 759 lastPinchDist = dist; 760 761 var rect = viewport.getBoundingClientRect(); 762 var mid = getTouchMidpoint(e.touches, rect); 763 zoomAt(mid.x, mid.y, factor); 764 765 } else if (isTouching && e.touches.length === 1) { 766 var svg = getActiveSvg(); 767 if (svg) { 768 var rect = svg.getBoundingClientRect(); 769 var dx = e.touches[0].clientX - lastTouchX; 770 var dy = e.touches[0].clientY - lastTouchY; 771 // Convert pixel delta to viewBox units (inverted) 772 vbX -= (dx / rect.width) * vbW; 773 vbY -= (dy / rect.height) * vbH; 774 applyViewBox(); 775 } 776 lastTouchX = e.touches[0].clientX; 777 lastTouchY = e.touches[0].clientY; 778 } 779 }, { passive: true }); 780 781 viewport.addEventListener('touchend', function (e) { 782 if (e.touches.length === 0) { 783 isTouching = false; 784 isPinching = false; 785 } else if (e.touches.length === 1) { 786 isPinching = false; 787 isTouching = true; 788 lastTouchX = e.touches[0].clientX; 789 lastTouchY = e.touches[0].clientY; 790 } 791 }, { passive: true }); 792 793 // ââ Floor picker ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 794 var pickerToggle = document.getElementById('floor-map-fs-picker-toggle'); 795 var pickerList = document.getElementById('floor-map-fs-picker-list'); 796 797 function openPicker() { 798 pickerList.classList.add('is-open'); 799 pickerToggle.setAttribute('aria-expanded', 'true'); 800 } 801 802 function closePicker() { 803 if (!pickerList) return; 804 pickerList.classList.remove('is-open'); 805 pickerToggle.setAttribute('aria-expanded', 'false'); 806 } 807 808 if (pickerToggle) { 809 pickerToggle.addEventListener('click', function (e) { 810 e.stopPropagation(); 811 var isOpen = pickerList.classList.contains('is-open'); 812 isOpen ? closePicker() : openPicker(); 813 }); 814 } 815 816 overlay.addEventListener('click', function (e) { 817 if (!pickerList) return; 818 if (!pickerList.contains(e.target) && e.target !== pickerToggle) { 819 closePicker(); 820 } 821 }); 822 823 // ââ Floor index tracking for slide direction âââââââââââââââââââââââââââââ 824 var allFloorEls = Array.from(document.querySelectorAll('.floor-map-fs__svg-floor')); 825 var allFloorSlugs = allFloorEls.map(function (el) { return el.getAttribute('data-floor-img'); }); 826 var currentFloorIndex = allFloorEls.findIndex(function (el) { return el.classList.contains('is-active'); }); 827 if (currentFloorIndex === -1) currentFloorIndex = 0; 828 829 // Floor picker item click â animated floor switch 830 var pickerLabel = document.getElementById('floor-map-fs-picker-label'); 831 832 function getActiveFloorSlug() { 833 var activeFloorEl = document.querySelector('.floor-map-fs__svg-floor.is-active'); 834 return activeFloorEl ? activeFloorEl.getAttribute('data-floor-img') : ''; 835 } 836 837 function findPickerButtonBySlug(slug) { 838 var buttons = overlay.querySelectorAll('.floor-map-fs__picker-item'); 839 for (var i = 0; i < buttons.length; i++) { 840 if (buttons[i].getAttribute('data-floor-slug') === slug) { 841 return buttons[i]; 842 } 843 } 844 return null; 845 } 846 847 function selectFloorBySlug(slug) { 848 var btn = findPickerButtonBySlug(slug); 849 if (!btn) { 850 fmDiagError('selectFloorBySlug missing picker button', slug); 851 return false;
852 } 853 854 var label = btn.textContent.trim(); 855 var newIndex = allFloorSlugs.indexOf(slug); 856 fmDiagLog('selectFloorBySlug', { 857 slug: slug, 858 label: label, 859 newIndex: newIndex, 860 currentFloorIndex: currentFloorIndex 861 }); 862 863 overlay.querySelectorAll('.floor-map-fs__picker-item').forEach(function (b) { 864 b.classList.toggle('is-active', b === btn); 865 }); 866 867 // Animated floor transition 868 if (newIndex !== -1 && newIndex !== currentFloorIndex) { 869 var direction = newIndex > currentFloorIndex ? 'up' : 'down'; 870 switchFloor(slug, direction); 871 currentFloorIndex = newIndex; 872 } else { 873 highlightTenantOnMap(activeTenantName); 874 } 875 876 // Legacy img-based floors 877 document.querySelectorAll('.floor-map-fs__img').forEach(function (img) { 878 var imgFloor = img.getAttribute('data-floor-img'); 879 img.classList.toggle('is-active', imgFloor === slug || (imgFloor === 'default' && slug === '')); 880 }); 881 882 if (pickerLabel) pickerLabel.textContent = label; 883 884 closePicker(); 885 return true; 886 } 887 888 overlay.querySelectorAll('.floor-map-fs__picker-item').forEach(function (btn) { 889 btn.addEventListener('click', function () { 890 selectFloorBySlug(btn.getAttribute('data-floor-slug')); 891 }); 892 }); 893 894 // ââ Animated floor switching âââââââââââââââââââââââââââââââââââââââââââââ 895 function switchFloor(newSlug, direction) { 896 var currentEl = document.querySelector('.floor-map-fs__svg-floor.is-active'); 897 var newEl = document.querySelector('.floor-map-fs__svg-floor[data-floor-img="' + newSlug + '"]'); 898 fmDiagLog('switchFloor start', { 899 newSlug: newSlug, 900 direction: direction, 901 currentSlug: currentEl ? currentEl.getAttribute('data-floor-img') : null, 902 hasNewEl: !!newEl 903 }); 904 if (!newEl || newEl === currentEl) return; 905 906 // Going up (e.g. floor 0â1): new floor enters from top, old exits to bottom 907 // Going down (e.g. floor 1â0): new floor enters from bottom, old exits to top 908 var enterFrom = direction === 'up' ? '-100%' : '100%'; 909 var exitTo = direction === 'up' ? '100%' : '-100%'; 910 911 // Prepare new element off-screen 912 newEl.style.transform = 'translateY(' + enterFrom + ')'; 913 newEl.style.display = 'block'; 914 newEl.classList.add('is-active'); 915 916 // Apply highlights immediately on the new floor (don't wait for animation) 917 injectThumbnails(newSlug); 918 highlightTenantsOnSvg(newSlug, newEl); 919 highlightTenantOnMap(activeTenantName, newEl); 920 921 // Force reflow so the initial transform is applied before transition 922 newEl.offsetHeight; 923 924 // Animate both 925 if (currentEl) { 926 currentEl.style.transition = 'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1)'; 927 currentEl.style.transform = 'translateY(' + exitTo + ')'; 928 } 929 930 newEl.style.transition = 'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1)'; 931 newEl.style.transform = 'translateY(0)'; 932 933 // After animation, clean up 934 setTimeout(function () { 935 if (currentEl && currentEl !== newEl) { 936 currentEl.classList.remove('is-active'); 937 currentEl.style.display = ''; 938 currentEl.style.transform = ''; 939 currentEl.style.transition = ''; 940 } 941 newEl.style.transition = ''; 942 newEl.style.transform = ''; 943 944 resetTransform(); 945 fmDiagLog('switchFloor end', { 946 activeSlug: newEl.getAttribute('data-floor-img') 947 }); 948 }, 420); 949 } 950 951 // ââ Mobile: floating bar + wysuwana lista ââââââââââââââââââââââââââââââââ 952 var asideEl = document.getElementById('floor-map-fs-aside'); 953 var mobileSearchBtn = document.getElementById('floor-map-fs-mobile-search-btn'); 954 var closeMobileBtn = document.getElementById('floor-map-fs-close-mobile'); 955 var searchClose = document.getElementById('floor-map-fs-search-close'); 956 var searchInput = document.getElementById('floor-map-fs-search'); 957 958 function openMobileList() { 959 if (!asideEl) return; 960 asideEl.classList.add('is-list-open'); 961 if (searchInput) searchInput.focus(); 962 } 963 964 function closeMobileList() { 965 if (!asideEl) return; 966 asideEl.classList.remove('is-list-open'); 967 if (searchInput) searchInput.blur(); 968 } 969 970 if (mobileSearchBtn) { 971 mobileSearchBtn.addEventListener('click', function () { 972 openMobileList(); 973 }); 974 } 975 976 if (searchClose) { 977 searchClose.addEventListener('click', closeMobileList); 978 } 979 980 if (closeMobileBtn) { 981 closeMobileBtn.addEventListener('click', closeFloorMapFS); 982 } 983 984 // ââ Tenant search âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 985 var activeSearch = ''; 986 var activeFloor = ''; 987 988 if (searchInput) { 989 searchInput.addEventListener('input', function () { 990 activeSearch = searchInput.value.toLowerCase().trim(); 991 filterTenants(activeSearch, null); 992 }); 993 } 994 995 function filterTenants(search, floor) { 996 if (search !== null) activeSearch = search; 997 if (floor !== null) activeFloor = floor; 998 999 document.querySelectorAll('.floor-map-fs__tenant').forEach(function (t) { 1000 var title = t.getAttribute('data-title') || ''; 1001 var tenantFloor = t.getAttribute('data-floor') || ''; 1002 1003 var matchSearch = !activeSearch || title.indexOf(activeSearch) !== -1; 1004 var matchFloor = !activeFloor || tenantFloor.split(' ').indexOf(activeFloor) !== -1; 1005 1006 t.style.display = (matchSearch && matchFloor) ? '' : 'none'; 1007 }); 1008 1009 document.querySelectorAll('.floor-map-fs__letter-group').forEach(function (grp) { 1010 var hasVisible = Array.from(grp.querySelectorAll('.floor-map-fs__tenant')).some(function (t) { 1011 return t.style.display !== 'none'; 1012 }); 1013 grp.style.display = hasVisible ? '' : 'none'; 1014 }); 1015 } 1016 1017 // ââ Interactive SVG tenant highlighting âââââââââââââââââââââââââââââââââââ 1018 1019 var tenantDataEl = document.getElementById('floor-map-tenant-data'); 1020 var tenantMapData = {}; 1021 if (tenantDataEl) { 1022 try { tenantMapData = JSON.parse(tenantDataEl.textContent); } catch (e) { /* noop */ } 1023 } 1024 var debugDataEl = document.getElementById('floor-map-debug-data'); 1025 var floorMapDebugData = {}; 1026 if (debugDataEl) { 1027 try { floorMapDebugData = JSON.parse(debugDataEl.textContent); } catch (e) { /* noop */ } 1028 } 1029 1030 function highlightTenantOnMap(tenantName, floorEl) { 1031 document.querySelectorAll('.floor-map-fs__svg-floor .fm-selected').forEach(function (el) { 1032 el.classList.remove('fm-selected'); 1033 }); 1034 1035 if (!tenantName) return; 1036 var normalizedTenantName = normalizeTenantName(tenantName); 1037 1038 // Find all premise IDs for this tenant (may have multiple locations) 1039 var activeFloorEl = floorEl || document.querySelector('.floor-map-fs__svg-floor.is-active'); 1040 if (!activeFloorEl) return; 1041 1042 var activeFloorSlug = activeFloorEl.getAttribute('data-floor-img'); 1043 var floorData = tenantMapData[activeFloorSlug] || {}; 1044 1045 // Find ALL premise IDs that match this tenant name 1046 var matchingPremiseIds = [];
1047 Object.keys(floorData).forEach(function (premiseId) { 1048 if (normalizeTenantName(floorData[premiseId].title) === normalizedTenantName) { 1049 matchingPremiseIds.push(premiseId); 1050 } 1051 }); 1052 1053 if (matchingPremiseIds.length === 0) return; 1054 1055 // Highlight ALL matching paths for this tenant 1056 matchingPremiseIds.forEach(function (premiseId) { 1057 var pathEl = activeFloorEl.querySelector('#' + CSS.escape(premiseId)); 1058 if (pathEl) { 1059 revealSvgPremiseElement(pathEl); 1060 pathEl.classList.add('fm-selected'); 1061 } 1062 }); 1063 } 1064 1065 function getSelectedTenantBox() { 1066 var activeFloorEl = document.querySelector('.floor-map-fs__svg-floor.is-active'); 1067 if (!activeFloorEl) return null; 1068 1069 var box = null; 1070 activeFloorEl.querySelectorAll('.fm-selected').forEach(function (el) { 1071 if (!el.getBBox) return; 1072 1073 var b; 1074 try { 1075 b = el.getBBox(); 1076 } catch (e) { 1077 return; 1078 } 1079 1080 if (!b || !isFinite(b.x) || !isFinite(b.y) || !isFinite(b.width) || !isFinite(b.height)) { 1081 return; 1082 } 1083 1084 var x2 = b.x + b.width; 1085 var y2 = b.y + b.height; 1086 1087 if (!box) { 1088 box = { x: b.x, y: b.y, x2: x2, y2: y2 }; 1089 return; 1090 } 1091 1092 box.x = Math.min(box.x, b.x); 1093 box.y = Math.min(box.y, b.y); 1094 box.x2 = Math.max(box.x2, x2); 1095 box.y2 = Math.max(box.y2, y2); 1096 }); 1097 1098 return box; 1099 } 1100 1101 function animateViewBox(targetX, targetY, targetW, targetH) { 1102 cancelTenantFlyTo(); 1103 1104 var startX = vbX; 1105 var startY = vbY; 1106 var startW = vbW; 1107 var startH = vbH; 1108 var startTime = window.performance.now(); 1109 var duration = 650; 1110 1111 function step(now) { 1112 var progress = Math.min(1, (now - startTime) / duration); 1113 var eased = progress < 0.5 1114 ? 4 * progress * progress * progress 1115 : 1 - Math.pow(-2 * progress + 2, 3) / 2; 1116 1117 vbX = startX + (targetX - startX) * eased; 1118 vbY = startY + (targetY - startY) * eased; 1119 vbW = startW + (targetW - startW) * eased; 1120 vbH = startH + (targetH - startH) * eased; 1121 applyViewBox(); 1122 1123 if (progress < 1) { 1124 tenantFlyFrame = window.requestAnimationFrame(step); 1125 } else { 1126 tenantFlyFrame = 0; 1127 } 1128 } 1129 1130 tenantFlyFrame = window.requestAnimationFrame(step); 1131 } 1132 1133 function flyToSelectedTenant() { 1134 var box = getSelectedTenantBox(); 1135 fmDiagLog('flyToSelectedTenant', { 1136 hasBox: !!box, 1137 box: box, 1138 baseW: baseW, 1139 baseH: baseH, 1140 currentScale: currentScale() 1141 }); 1142 if (!box || !baseW || !baseH) return; 1143 1144 var targetScale = clampScale(Math.max(currentScale(), TENANT_FLY_SCALE)); 1145 var targetW = baseW / targetScale; 1146 var targetH = baseH / targetScale; 1147 var centerX = box.x + (box.x2 - box.x) / 2; 1148 var centerY = box.y + (box.y2 - box.y) / 2; 1149 var targetX = centerX - targetW / 2; 1150 var targetY = centerY - targetH / 2; 1151 var maxX = baseW - targetW; 1152 var maxY = baseH - targetH; 1153 1154 if (maxX > 0) targetX = Math.min(Math.max(targetX, 0), maxX); 1155 if (maxY > 0) targetY = Math.min(Math.max(targetY, 0), maxY); 1156 1157 animateViewBox(targetX, targetY, targetW, targetH); 1158 } 1159 1160 function floorHasTenant(floorSlug, tenantName) { 1161 if (!floorSlug || !tenantName) return false; 1162 var normalizedTenantName = normalizeTenantName(tenantName); 1163 var floorData = tenantMapData[floorSlug] || {}; 1164 1165 return Object.keys(floorData).some(function (premiseId) { 1166 return normalizeTenantName(floorData[premiseId].title) === normalizedTenantName; 1167 }); 1168 } 1169 1170 function getTenantTargetFloor(tenantName, tenantEl) { 1171 if (!tenantEl) return ''; 1172 1173 var floorSlugs = (tenantEl.getAttribute('data-floor') || '').split(/\s+/).filter(Boolean); 1174 if (!floorSlugs.length) return ''; 1175 1176 var activeFloorSlug = getActiveFloorSlug(); 1177 if (floorSlugs.indexOf(activeFloorSlug) !== -1 && floorHasTenant(activeFloorSlug, tenantName)) { 1178 return activeFloorSlug; 1179 } 1180 1181 for (var i = 0; i < floorSlugs.length; i++) { 1182 if (floorHasTenant(floorSlugs[i], tenantName)) { 1183 return floorSlugs[i]; 1184 } 1185 } 1186 1187 return floorSlugs[0]; 1188 } 1189 1190 // SVG tooltip 1191 var svgTooltip = document.createElement('div'); 1192 svgTooltip.className = 'floor-map-fs__svg-tooltip'; 1193 svgTooltip.style.display = 'none'; 1194 document.body.appendChild(svgTooltip); 1195 1196 function highlightTenantsOnSvg(floorSlug, floorEl) { 1197 var floorData = tenantMapData[floorSlug] || {}; 1198 var activeFloorEl = floorEl || document.querySelector('.floor-map-fs__svg-floor.is-active'); 1199 if (!activeFloorEl) return; 1200 1201 // Reset all highlights 1202 activeFloorEl.querySelectorAll('.fm-tenant-highlight').forEach(function (el) { 1203 el.classList.remove('fm-tenant-highlight'); 1204 }); 1205 1206 // Apply highlights for occupied premises
1207 Object.keys(floorData).forEach(function (premiseId) { 1208 var pathEl = activeFloorEl.querySelector('#' + CSS.escape(premiseId)); 1209 if (pathEl) { 1210 var tenantName = decodeHtmlEntities(floorData[premiseId].title); 1211 revealSvgPremiseElement(pathEl); 1212 pathEl.classList.add('fm-tenant-highlight'); 1213 pathEl.setAttribute('data-tenant-name', tenantName); 1214 pathEl.setAttribute('data-tenant-url', floorData[premiseId].url); 1215 pathEl.style.cursor = 'pointer'; 1216 } 1217 }); 1218 1219 if (fmDebug) { 1220 var highlightDiag = collectSvgDiagnostics(floorSlug); 1221 fmDebugLog('highlightTenantsOnSvg', highlightDiag); 1222 if (highlightDiag.missingPremises.length) { 1223 fmDebugWarn('Missing premise IDs in SVG for floor ' + floorSlug, highlightDiag.missingPremises); 1224 } 1225 } 1226 } 1227 1228 // ââ Inject tenant thumbnails into SVG image_containers âââââââââââââââââââ 1229 // SVG contains rect elements like <rect id="image_p0o008" ...> 1230 // For each tenant with a premise (e.g. p0o008), inject an <image> into 1231 // the corresponding image_p0o008 rect position. 1232 1233 var injectedFloors = {}; 1234 var thumbClipPathCounter = 0; 1235 var fmDebug = (new URLSearchParams(window.location.search)).get('fmdebug') === '1'; 1236 1237 function fmDebugLog() { 1238 if (!fmDebug) return; 1239 var args = Array.prototype.slice.call(arguments); 1240 args.unshift('[floor-map]'); 1241 console.log.apply(console, args); 1242 } 1243 1244 function fmDebugWarn() { 1245 if (!fmDebug) return; 1246 var args = Array.prototype.slice.call(arguments); 1247 args.unshift('[floor-map]'); 1248 console.warn.apply(console, args); 1249 } 1250 1251 function findImageContainer(svgEl, premiseId) { 1252 if (!svgEl || !premiseId) return null; 1253 1254 var exactId = 'image_' + premiseId; 1255 var exact = svgEl.querySelector('#' + CSS.escape(exactId)); 1256 if (exact) return exact; 1257 1258 var imageContainers = svgEl.querySelector('#image_containers'); 1259 if (!imageContainers) return null; 1260 1261 var normalizedId = exactId.toLowerCase(); 1262 var normalizedPremiseId = premiseId.toLowerCase(); 1263 var candidates = imageContainers.querySelectorAll('[id]'); 1264 for (var i = 0; i < candidates.length; i++) { 1265 if ((candidates[i].id || '').toLowerCase() === normalizedId) { 1266 return candidates[i]; 1267 } 1268 } 1269 for (var j = 0; j < candidates.length; j++) { 1270 var candidateId = (candidates[j].id || '').toLowerCase(); 1271 if (candidateId.indexOf(normalizedPremiseId) !== 0) continue; 1272 if (/^\d+$/.test(candidateId.slice(normalizedPremiseId.length))) { 1273 return candidates[j]; 1274 } 1275 } 1276 1277 return null; 1278 } 1279 1280 function getImageContainerDimElement(container) { 1281 if (!container) return null; 1282 if (container.tagName.toLowerCase() === 'g') { 1283 return container.querySelector('rect') || container; 1284 } 1285 return container; 1286 } 1287 1288 function getImageContainerBox(container) { 1289 var dimEl = getImageContainerDimElement(container); 1290 if (!dimEl) return null; 1291 1292 var x = parseFloat(dimEl.getAttribute('x')) || 0; 1293 var y = parseFloat(dimEl.getAttribute('y')) || 0; 1294 var width = parseFloat(dimEl.getAttribute('width')) || 0; 1295 var height = parseFloat(dimEl.getAttribute('height')) || 0; 1296 1297 if ((width <= 0 || height <= 0) && dimEl.getBBox) { 1298 try { 1299 var bb = dimEl.getBBox(); 1300 x = bb.x; 1301 y = bb.y; 1302 width = bb.width; 1303 height = bb.height; 1304 } catch (e) { 1305 return null; 1306 } 1307 } 1308 1309 if (width <= 0 || height <= 0) return null; 1310 return { x: x, y: y, width: width, height: height }; 1311 } 1312 1313 function pointInPolygon(point, polygon) { 1314 var inside = false;
1315 for (var i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { 1316 var xi = polygon[i].x; 1317 var yi = polygon[i].y; 1318 var xj = polygon[j].x; 1319 var yj = polygon[j].y; 1320 var intersects = ((yi > point.y) !== (yj > point.y)) && 1321 (point.x < (xj - xi) * (point.y - yi) / ((yj - yi) || 1e-10) + xi); 1322 if (intersects) inside = !inside; 1323 } 1324 return inside; 1325 } 1326 1327 function findGeometryImageContainer(svgEl, premiseEl, reservedImageIds) { 1328 var imageContainers = svgEl ? svgEl.querySelector('#image_containers') : null; 1329 var points = getPolygonPoints(premiseEl); 1330 if (!imageContainers || !points) return null; 1331 1332 var candidates = imageContainers.querySelectorAll('[id^="image_"], [id^="Image_"]'); 1333 var best = null; 1334 var bestArea = 0; 1335 1336 for (var i = 0; i < candidates.length; i++) { 1337 var candidate = candidates[i]; 1338 var candidateId = candidate.id || ''; 1339 if (!candidateId) continue; 1340 if (reservedImageIds && reservedImageIds[candidateId.toLowerCase()]) continue; 1341 if (candidate.getAttribute('data-thumb-injected')) continue; 1342 if (candidate.tagName.toLowerCase() === 'g' && candidate.querySelector('image')) continue; 1343 1344 var box = getImageContainerBox(candidate); 1345 if (!box) continue; 1346 1347 var center = { 1348 x: box.x + box.width / 2, 1349 y: box.y + box.height / 2 1350 }; 1351 if (!pointInPolygon(center, points)) continue; 1352 1353 var area = box.width * box.height; 1354 if (area > bestArea) { 1355 best = candidate; 1356 bestArea = area; 1357 } 1358 } 1359 1360 return best; 1361 } 1362 1363 function collectSvgDiagnostics(floorSlug) { 1364 var floorData = tenantMapData[floorSlug] || {}; 1365 var floorEl = document.querySelector('.floor-map-fs__svg-floor[data-floor-img="' + floorSlug + '"]'); 1366 var premiseIds = Object.keys(floorData); 1367 var missingPremises = []; 1368 var missingImages = []; 1369 var matchedPremiseIds = []; 1370 var matchedImageIds = []; 1371 var missingThumbnails = []; 1372 1373 if (!floorEl) { 1374 return { 1375 floorSlug: floorSlug, 1376 hasFloorElement: false, 1377 tenantCount: premiseIds.length, 1378 matchedPremiseIds: [], 1379 matchedImageIds: [], 1380 missingThumbnails: premiseIds.filter(function (premiseId) { 1381 return !floorData[premiseId] || !floorData[premiseId].thumbnail; 1382 }), 1383 missingPremises: premiseIds, 1384 missingImages: premiseIds 1385 }; 1386 } 1387 1388 premiseIds.forEach(function (premiseId) { 1389 if (!floorEl.querySelector('#' + CSS.escape(premiseId))) { 1390 missingPremises.push(premiseId); 1391 } else { 1392 matchedPremiseIds.push(premiseId); 1393 } 1394 if (!findImageContainer(floorEl, premiseId)) { 1395 missingImages.push(premiseId); 1396 } else { 1397 matchedImageIds.push(premiseId); 1398 } 1399 if (!floorData[premiseId] || !floorData[premiseId].thumbnail) { 1400 missingThumbnails.push(premiseId); 1401 } 1402 }); 1403 1404 return { 1405 floorSlug: floorSlug, 1406 hasFloorElement: true, 1407 tenantCount: premiseIds.length, 1408 matchedPremiseIds: matchedPremiseIds, 1409 matchedImageIds: matchedImageIds, 1410 missingThumbnails: missingThumbnails, 1411 matchedPremises: premiseIds.length - missingPremises.length, 1412 missingPremises: missingPremises, 1413 matchedImages: premiseIds.length - missingImages.length, 1414 missingImages: missingImages 1415 }; 1416 } 1417 1418 if (fmDebug) { 1419 fmDebugLog('bootstrap', { 1420 centerFloors: floorMapDebugData.centerFloors || [], 1421 svgFloorSlugs: floorMapDebugData.svgFloorSlugs || [], 1422 tenantFloorSlugs: floorMapDebugData.tenantFloorSlugs || [], 1423 tenantCountByFloor: floorMapDebugData.tenantCountByFloor || {} 1424 }); 1425 1426 var svgFloorSlugs = floorMapDebugData.svgFloorSlugs || []; 1427 var svgOnlyFloors = svgFloorSlugs.filter(function (slug) { 1428 return !tenantMapData[slug]; 1429 }); 1430 var tenantOnlyFloors = Object.keys(tenantMapData).filter(function (slug) { 1431 return svgFloorSlugs.indexOf(slug) === -1; 1432 }); 1433 1434 if (svgOnlyFloors.length) { 1435 fmDebugWarn('SVG floors without tenant data', svgOnlyFloors); 1436 } 1437 if (tenantOnlyFloors.length) { 1438 fmDebugWarn('Tenant data floors without matching SVG floor', tenantOnlyFloors); 1439 } 1440 } 1441 1442 function getPolygonPoints(el) { 1443 if (!el) return null; 1444 var tag = el.tagName.toLowerCase(); 1445 if (tag === 'polygon') { 1446 var raw = el.getAttribute('points') || ''; 1447 var nums = raw.trim().split(/[\s,]+/).map(Number).filter(function (n) { return !isNaN(n); }); 1448 var result = []; 1449 for (var i = 0; i + 1 < nums.length; i += 2) result.push({ x: nums[i], y: nums[i + 1] }); 1450 return result.length >= 3 ? result : null; 1451 } 1452 if (tag === 'path') { 1453 var subs = parseSvgPathToSubpaths(el.getAttribute('d') || ''); 1454 return (subs && subs[0] && subs[0].length >= 3) ? subs[0] : null; 1455 } 1456 return null; 1457 } 1458 1459 function sanitizeSvgFragmentId(id) { 1460 return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '-'); 1461 } 1462 1463 function clipThumbnailToPremise(svgEl, imgEl, premiseEl, floorSlug, premiseId) { 1464 if (!svgEl || !imgEl || !premiseEl || !premiseEl.cloneNode) return; 1465 1466 var defs = svgEl.querySelector('defs'); 1467 if (!defs) { 1468 defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); 1469 svgEl.insertBefore(defs, svgEl.firstChild); 1470 } 1471
1472 var clipPathId = 'fm-thumb-clip-' + sanitizeSvgFragmentId(floorSlug) + '-' + sanitizeSvgFragmentId(premiseId) + '-' + (++thumbClipPathCounter); 1473 var clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath'); 1474 clipPath.setAttribute('id', clipPathId); 1475 clipPath.setAttribute('clipPathUnits', 'userSpaceOnUse'); 1476 1477 var clipShape = premiseEl.cloneNode(true); 1478 clipShape.removeAttribute('id'); 1479 clipShape.removeAttribute('class'); 1480 clipShape.removeAttribute('style'); 1481 clipShape.removeAttribute('data-tenant-name'); 1482 clipShape.removeAttribute('data-tenant-url'); 1483 clipShape.removeAttribute('data-thumb-injected'); 1484 clipShape.setAttribute('fill', '#fff'); 1485 clipShape.setAttribute('stroke', 'none'); 1486 1487 clipPath.appendChild(clipShape); 1488 defs.appendChild(clipPath); 1489 imgEl.setAttribute('clip-path', 'url(#' + clipPathId + ')'); 1490 } 1491 1492 function injectThumbnails(floorSlug) { 1493 if (injectedFloors[floorSlug]) return; // already done for this floor 1494 injectedFloors[floorSlug] = true; 1495 1496 var floorData = tenantMapData[floorSlug] || {}; 1497 var activeFloorEl = document.querySelector('.floor-map-fs__svg-floor[data-floor-img="' + floorSlug + '"]'); 1498 if (!activeFloorEl) return; 1499 1500 // Debug logging - add ?fmdebug=1 to URL to see tenant data 1501 if (fmDebug) { 1502 fmDebugLog('injectThumbnails floor', floorSlug); 1503 fmDebugLog('injectThumbnails tenant data', floorData); 1504 Object.keys(floorData).forEach(function (pid) { 1505 fmDebugLog('premise', pid, floorData[pid].title, '(level:', floorData[pid].floor + ')'); 1506 }); 1507 } 1508 1509 var svgEl = activeFloorEl.querySelector('svg'); 1510 if (!svgEl) return; 1511 1512 var XLINK = 'http://www.w3.org/1999/xlink'; 1513 var hasImageContainers = !!svgEl.querySelector('#image_containers'); 1514 var reservedImageIds = {}; 1515 Object.keys(floorData).forEach(function (premiseId) { 1516 var imageContainer = findImageContainer(svgEl, premiseId); 1517 if (imageContainer && imageContainer.id) { 1518 reservedImageIds[imageContainer.id.toLowerCase()] = true; 1519 } 1520 }); 1521 1522 Object.keys(floorData).forEach(function (premiseId) { 1523 var tenant = floorData[premiseId]; 1524 if (!tenant.thumbnail) return; 1525 1526 // Find the image rect: image_p0o008 for premise p0o008 1527 var rectId = 'image_' + premiseId; 1528 var rect = findImageContainer(svgEl, premiseId); 1529 1530 var x, y, w, h; 1531 var fallbackPremiseEl = null; 1532 1533 if (!rect) { 1534 var premiseEl = svgEl.querySelector('#' + CSS.escape(premiseId)); 1535 if (!premiseEl) { 1536 fmDebugWarn('Missing image rect and premise element for premise', { 1537 floorSlug: floorSlug, 1538 premiseId: premiseId, 1539 rectId: rectId, 1540 tenant: tenant.title 1541 }); 1542 return; 1543 } 1544 1545 if (hasImageContainers) { 1546 rect = findGeometryImageContainer(svgEl, premiseEl, reservedImageIds); 1547 if (!rect) { 1548 fmDebugWarn('Missing image slot; skipping thumbnail fallback because image_containers exists', { 1549 floorSlug: floorSlug, 1550 premiseId: premiseId, 1551 rectId: rectId, 1552 tenant: tenant.title 1553 }); 1554 return; 1555 } 1556 } 1557 } 1558 1559 if (!rect) { 1560 // Fallback: use the bounding box of the premise element itself 1561 // Already injected via fallback? 1562 if (premiseEl.getAttribute('data-thumb-injected')) return; 1563 premiseEl.setAttribute('data-thumb-injected', '1'); 1564 revealSvgPremiseElement(premiseEl); 1565 fallbackPremiseEl = premiseEl; 1566 1567 try { 1568 var bb = premiseEl.getBBox(); 1569 // For non-rectangular shapes (e.g. trapezoids), the bounding box is larger 1570 // than the actual shape, causing the logo to overflow. Instead, compute the 1571 // largest square centered on the shape's centroid that fits inside. 1572 var pts = getPolygonPoints(premiseEl); 1573 if (pts && pts.length >= 3) { 1574 var cx = 0, cy = 0; 1575 for (var pi = 0; pi < pts.length; pi++) { cx += pts[pi].x; cy += pts[pi].y; } 1576 cx /= pts.length; cy /= pts.length; 1577 // Use 70% of the shorter bounding-box dimension as the logo square size. 1578 // This avoids overflow on irregular shapes while staying large enough to be visible. 1579 var side = Math.min(bb.width, bb.height) * 0.7; 1580 x = cx - side / 2; 1581 y = cy - side / 2; 1582 w = side; 1583 h = side; 1584 } else { 1585 x = bb.x; 1586 y = bb.y; 1587 w = bb.width; 1588 h = bb.height; 1589 } 1590 } catch (e) { 1591 return; 1592 } 1593 } else { 1594 // Already injected: check for an actual <image> child, not just the attribute 1595 if (rect.querySelector('image') || (rect.tagName.toLowerCase() !== 'g' && rect.getAttribute('data-thumb-injected'))) { 1596 return; 1597 } 1598 rect.setAttribute('data-thumb-injected', '1'); 1599 1600 // If the element is a <g>, find the inner <rect> for dimensions 1601 var dimEl = getImageContainerDimElement(rect); 1602 1603 x = parseFloat(dimEl.getAttribute('x')) || 0; 1604 y = parseFloat(dimEl.getAttribute('y')) || 0; 1605 w = parseFloat(dimEl.getAttribute('width')) || 0; 1606 h = parseFloat(dimEl.getAttribute('height')) || 0; 1607 } 1608 1609 if (w <= 0 || h <= 0) return; 1610 1611 var imgEl = document.createElementNS('http://www.w3.org/2000/svg', 'image'); 1612 1613 // Set both href and xlink:href for broader browser compatibility 1614 if (tenant.thumbnail) { 1615 imgEl.setAttributeNS(XLINK, 'xlink:href', tenant.thumbnail); 1616 imgEl.setAttribute('href', tenant.thumbnail); 1617 } 1618 1619 imgEl.setAttribute('x', x); 1620 imgEl.setAttribute('y', y); 1621 imgEl.setAttribute('width', w); 1622 imgEl.setAttribute('height', h); 1623 1624 // Use meet for contain-like behavior (letterbox) 1625 imgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet'); 1626 imgEl.style.pointerEvents = 'none'; 1627 imgEl.style.objectFit = 'contain'; 1628 if (fallbackPremiseEl) { 1629 clipThumbnailToPremise(svgEl, imgEl, fallbackPremiseEl, floorSlug, premiseId); 1630 } 1631 1632 // Insert: into <g> containers, after <rect>/<polygon> elements 1633 var insertAnchor = rect || svgEl.querySelector('#' + CSS.escape(premiseId)); 1634 if (insertAnchor) { 1635 if (insertAnchor.tagName.toLowerCase() === 'g') { 1636 insertAnchor.appendChild(imgEl); 1637 } else if (insertAnchor.parentNode) { 1638 insertAnchor.parentNode.insertBefore(imgEl, insertAnchor.nextSibling); 1639 } else { 1640 svgEl.appendChild(imgEl); 1641 } 1642 } else { 1643 svgEl.appendChild(imgEl); 1644 } 1645 }); 1646 1647 if (fmDebug) { 1648 var injectDiag = collectSvgDiagnostics(floorSlug);
1649 fmDebugLog('injectThumbnails diagnostics', injectDiag); 1650 fmDebugLog('image container count', { 1651 floorSlug: floorSlug, 1652 imageContainers: svgEl.querySelectorAll('#image_containers rect[id^="image_"]').length 1653 }); 1654 if (injectDiag.matchedImageIds.length) { 1655 fmDebugLog('matched image rect ids for floor ' + floorSlug, injectDiag.matchedImageIds); 1656 } 1657 if (injectDiag.missingThumbnails.length) { 1658 fmDebugWarn('Tenants without thumbnails for floor ' + floorSlug, injectDiag.missingThumbnails); 1659 } 1660 if (injectDiag.missingImages.length) { 1661 fmDebugWarn('Missing image slots in SVG for floor ' + floorSlug, injectDiag.missingImages); 1662 } 1663 } 1664 } 1665 1666 // Initial highlight + thumbnails on load 1667 var initialActiveFloor = document.querySelector('.floor-map-fs__svg-floor.is-active'); 1668 if (initialActiveFloor) { 1669 var initialSlug = initialActiveFloor.getAttribute('data-floor-img'); 1670 if (initialSlug) { 1671 highlightTenantsOnSvg(initialSlug); 1672 injectThumbnails(initialSlug); 1673 } 1674 } 1675 1676 function getFullscreenTenantTarget(eventTarget) { 1677 if (!overlay.classList.contains('is-open')) return null; 1678 if (!eventTarget || !eventTarget.closest) return null; 1679 var target = eventTarget.closest('.floor-map-fs__svg-floor .fm-tenant-highlight'); 1680 return target && overlay.contains(target) ? target : null; 1681 } 1682 1683 // Click on highlighted tenant path â open detail 1684 document.addEventListener('click', function (e) { 1685 var target = getFullscreenTenantTarget(e.target); 1686 if (!target) return; 1687 1688 var tenantName = target.getAttribute('data-tenant-name') || ''; 1689 1690 // Find matching tenant in sidebar and open detail 1691 var tenants = document.querySelectorAll('.floor-map-fs__tenant'); 1692 for (var i = 0; i < tenants.length; i++) { 1693 var t = tenants[i]; 1694 if (t.getAttribute('data-store-name') === tenantName) { 1695 openTenantDetail(t); 1696 break; 1697 } 1698 } 1699 }); 1700 1701 // Hover tooltip on tenant paths 1702 document.addEventListener('mouseover', function (e) { 1703 var target = getFullscreenTenantTarget(e.target); 1704 if (!target) return; 1705 var name = target.getAttribute('data-tenant-name') || target.id; 1706 svgTooltip.textContent = name; 1707 svgTooltip.style.display = 'block'; 1708 }); 1709 1710 document.addEventListener('mousemove', function (e) { 1711 if (svgTooltip.style.display === 'block') { 1712 svgTooltip.style.left = (e.pageX + 12) + 'px'; 1713 svgTooltip.style.top = (e.pageY - 28) + 'px'; 1714 } 1715 }); 1716 1717 document.addEventListener('mouseout', function (e) { 1718 if (getFullscreenTenantTarget(e.target)) { 1719 svgTooltip.style.display = 'none'; 1720 } 1721 }); 1722 1723 // Initialize wall overlays for all SVG floors on page load 1724 initWallOverlay(); 1725 1726});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.