PageSourceSearch

https://www.liljeholmstorget.se/app/themes/citycon2026_03_09_21_26/assets/js/floor-map.js?ver=1778846838

js liljeholmstorget.se collected 2026-09-26 12:50:50 UTC 54,368 bytes, 1,726 lines download raw bytes

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.