PageSourceSearch

https://zbooy.pl/giga/giga.js?1788286004

js zbooy.pl collected 2026-09-24 22:58:55 UTC 346,007 bytes, 9,168 lines download raw bytes

1var k = null, kmini = null;
2
3var giga = {
4
5	advanced: false,
6
7	wikiCache: {},
8	prevPanID: '',
9
10	hotspotOptions: {
11		includeDistance: true,
12		includeElevation: false
13	},
14	
15	showModes: {
16		COLLISION: 0,
17		AUTO: 1,
18		NOTEXT: 2,
19		HIDDEN: 3,
20		ALL: 4
21	},
22	
23	Pi180: Math.PI / 180.0,
24	Pi180Inverse: 180.0 / Math.PI,
25	
26	setAdvancedMode: function(val) {
27		giga.advanced = val;
28		document.querySelector('html').classList.toggle('standard-mode', !giga.advanced);
29		if (k) {
30			k.set('state.advancedMode', giga.advanced ? 1 : 0);
31			k.set('debugmode', giga.advanced);
32			k.set('showerrors', giga.advanced);
33		};
34		giga.config.set('advanced', giga.advanced ? 1 : 0);
35	},
36	
37	toggleAdvancedMode: function() {
38		giga.setAdvancedMode(!giga.advanced);
39	},
40	
41	toggleSidePanel: function(show) {
42		const sidePanel = document.getElementById('side-panel'), pano = document.getElementById('pano');
43		show = hasValue(show) ? show : sidePanel.classList.contains('hidden');
44		if (show) {
45			k.set('layer[btn_togglepanel].crop',       '1536|128|128|128');
46			k.set('layer[btn_togglepanel].onovercrop', '1536|0|128|128');		
47			sidePanel.classList.remove('hidden');
48			pano.classList.remove('full-width');
49		} else {
50			k.set('layer[btn_togglepanel].crop',       '1664|128|128|128');
51			k.set('layer[btn_togglepanel].onovercrop', '1664|0|128|128');		
52			sidePanel.classList.add('hidden');
53			pano.classList.add('full-width');
54		}
55	},
56	
57	resizeHandler: function() {
58		giga.currentPanorama.recalculateLevels();
59		//k.call('adjust_button_sizes();');
60	},
61	
62	sw: {
63		clearCache: function() {
64			try {
65				caches.keys().then(keys => Promise.all(keys.map(k => caches.delete(k))));
66			} catch(e) {
67				console.log('cache clearing failed');
68			}
69		}
70	},
71	
72	getStatistics: function() {
73		try {
74			return {
75				panoramas: panos?.length || 0,
76				hotspots: panos?.reduce((cnt, pid) => cnt + (giga.data[pid]?.length || 0), 0) || 0,
77				mpix: panos?.reduce((cnt, pid) => cnt + (Math.round(giga.sizes[pid]?.width*giga.sizes[pid]?.height/(1000000)) || 0), 0) || 0,
78				points: Array.from(hotspots.points?.values()).length || 0
79			};
80		} catch(e) {
81			return null;
82		}
83	},
84	
85	currentPanorama: {
86		richHotspots: [],
87		loadedID: '',
88		getID: function() {
89			return k.get('state.currentpanorama');
90		},
91		levels: [],
92		originalLevels: [],
93		getLevel: function(fov) {
94			const lev = giga.currentPanorama.levels.findLastIndex( lev => lev.fov>=fov );
95			return lev>=0 ? lev : -1;
96		},
97		updateInfo: function() {
98			// console.log('updateInfo');
99			giga.currentPanorama.levels = [];
100			k.labels.group.getArray().forEach( function(e) {
101				const level = parseInt(e.name.substring(5),10);
102				giga.currentPanorama.levels[level] = {fov: +e.fov, level: level, name: e.name};
103			});
104			giga.currentPanorama.originalLevels = giga.currentPanorama.levels;
105		},
106		recalculateLevels: function() {
107			const fovmin = typeof k =='undefined' ? 1 : k.view?.fovmin;
108			if (fovmin==1) {
109				console.log('view not ready yet in recalculateLevels!',fovmin);
110				return; // view not ready yet!
111			}
112			const zoomedIn = giga.currentPanorama.levels.at(-1).fov;
113			const zoomedOut = giga.currentPanorama.levels[0].fov;
114			//console.log('fovmin: ',fovmin);
115			//console.log('zoomedIn: ',zoomedIn);
116			//console.log('zoomedOut: ',zoomedOut);
117			if (1 || fovmin>zoomedIn) {
118				//console.log('recalculating');
119				const steps = giga.currentPanorama.levels.length;
120				const precision = 7;
121				const factor = Math.pow(zoomedOut/(fovmin*1.07), 1/(steps-1));
122				for (let i=0; i<steps; i++) {
123					const zz = (+(zoomedOut/Math.pow(factor,i)).toFixed(precision));
124					//console.log(i, zz);
125					giga.currentPanorama.levels[i].fov = zz;
126					//console.log(zz, fovmin, k.view.fovmin, k.get('view.fovmin'));
127				}
128				giga.updateViewInfo();
129				k.call('doSetHotspotVisibility');
130			}
131		},
132		generatePTTKCode: function() {
133			const info = k.info;
134			const maxlevel = k.image.level.getArray()[k.image.level.count-1];
135			const iwidth = +maxlevel.tiledimagewidth;
136			const iheight = +maxlevel.tiledimageheight;
137			const mpix = Math.round(iwidth*iheight/(1000000));
138			return '<p>&nbsp;</p><p>GIGAPANORAMA - <a href="https://zbooy.pl/giga/view,'+giga.currentPanorama.getID()+'.html" target="_blank" rel="noopener noreferrer">'+info.title+'</a></p>'+
139				'<p>'+mpix+' '+odmien(mpix,'megapiksel','megapiksele','megapikseli')+' ('+iwidth+'x'+iheight+')</p>'+
140				'<p><a href="https://zbooy.pl/giga/view,'+giga.currentPanorama.getID()+'.html" target="_blank" rel="noopener noreferrer">'+
141				'<img style="max-width: 100%;" src="https://zbooy.pl/giga/data/'+giga.currentPanorama.getID()+'/tiles/preview.jpg" alt="Miniatura panoramy - kliknij, żeby przejść do pełnej wersji"></a></p>'+
142				'<p>AUTOR: Szymon "Zbooy" Madej</p>';
143		},
144		getHotspotsList: function() {
145			return k.hotspot.getArray().filter( e => e.style?.startsWith("hst")).map( e => {
146				return {
147					id: e.id,
148					name: e.name,
149					labelname: e.labelname,
150					title: e.title,
151					ath: e.ath,
152					atv: e.atv,
153					kind: e.kind || 'peak',
154					level: e.level || 0
155				};
156			});
157		},
158		getRichHotspotsList: function(doNotUseCache=false) {
159			const currentID = giga.currentPanorama.getID();
160			if (currentID!=giga.currentPanorama.loadedID || giga.currentPanorama.richHotspots.length==0 || doNotUseCache) {
161				const hsList = giga.currentPanorama.getHotspotsList();
162				const info = k.info;
163				giga.currentPanorama.richHotspots = hsList.map( function(h) {
164					const p = hotspots.points.get(h.id);
165					h.bearingonpan = giga.hposToAzimuth(+h.ath);
166					if (hasValue(p)) {
167						h.kind = p.kind;
168						h.elevation = p.elevation;
169						if (hasValue(p.lat, p.lon)) {
170							h.distance = giga.getDistance(info.lat,info.lon,p.lat,p.lon);
171							h.bearingreal = giga.getBearing(info.lat,info.lon,p.lat,p.lon);
172							h.bearingdiff = (h.bearingreal-h.bearingonpan);
173						}
174					} else {
175						console.warn(`hotspots.points["${h.id}"] NOT FOUND!`);
176					}
177					return h;
178				});
179				giga.currentPanorama.loadedID = currentID;
180			}
181			return giga.currentPanorama.richHotspots;
182		},
183		
184		getHotspotDescription: function(hsi, opts) {
185			const {
186				includeDistance = giga.hotspotOptions.includeDistance,
187				includeElevation = giga.hotspotOptions.includeElevation,
188				includeBearingError = false,
189				useLongTitle = false
190			} = opts || {};
191
192			let hhtml = useLongTitle && hsi?.longtitle ? hsi.longtitle : hsi.displaytitle ?? hsi.title;
193			if (hhtml.indexOf('|') !== -1) {
194				hhtml = hhtml.split('|').join('<br>');
195			}
196
197			const point = hasValue(hsi.id) ? hotspots.points.get(hsi.id) : null;
198
199			if (hasValue(point)) {
200				const { kind, elevation, lat, lon } = point;
201
202				if (kind === 'town' || kind === 'valley') hhtml = hhtml.toUpperCase();
203
204				if (includeElevation && hasValue(elevation)) {
205					hhtml += ` <span class="hsl-elev">${elevation}</span>`;
206				}
207
208				if ((includeDistance || includeBearingError) && hasValue(lat) && hasValue(lon)) {
209					const { lat: infoLat, lon: infoLon } = k.info;
210
211					if (includeDistance) {
212						const dist = giga.getDistance(+infoLat, +infoLon, +lat, +lon);
213						hhtml = `<b>${dist.toFixed(1)} km</b><br>` + hhtml;
214					}
215
216					if (includeBearingError) {
217						const bearingOnPan = giga.hposToAzimuth(+hsi.ath);
218						const bearingReal = giga.getBearing(+infoLat, +infoLon, +lat, +lon);
219						const bearingDiff = giga.plusMinus180(bearingReal - bearingOnPan);
220						const prefix = bearingDiff > 0 ? '+' : '';
221						hhtml = `<span class="hsl-berror">${prefix}${bearingDiff.toFixed(2)}&deg;</span> ` + hhtml;
222					}
223				}
224			} else if (hasValue(hsi.id)) {
225				//console.log('No such point: ' + hsi.id);
226			}
227
228			hhtml += `<span class="hsl-level" data-level="${hsi.level ?? 0}"></span>`;
229			return hhtml;
230		},
231	
232		getLongHotspotDescription: function(hsi, opts) {
233			const options = Object.assign({
234				useLongTitle: true
235			}, opts);
236			return giga.currentPanorama.getHotspotDescription(hsi, options);
237		},
238		
239		generateLabels: function(opts) {
240			k.layer.getArray().filter(l => l.class == 'hotspotlabel').forEach(l => { l.html = giga.currentPanorama.getHotspotDescription(l.hs, opts); });
241		},
242
243		appendExtraAttribute: function(...attributes) {
244			k.layer.getArray().filter(l => l.class == 'hotspotlabel').forEach(l => {
245				const postfixes = [];
246				for (const attr of attributes) { 
247					const a = hotspots.points.get(l.hs.id)?.[attr];
248					if (a) postfixes.push(`<span class="hsl-extra">${a}</span>`);
249				}
250				l.html = giga.currentPanorama.getHotspotDescription(l.hs, {}) + postfixes.join(' ');
251			});
252		},
253		
254		toggleHotspotsLabels: function() {
255			giga.loader.show();
256			if (!giga.hotspotOptions.includeDistance) {
257				giga.hotspotOptions.includeDistance = true;
258				giga.hotspotOptions.includeElevation = !giga.hotspotOptions.includeElevation;
259			} else {
260				giga.hotspotOptions.includeDistance = !giga.hotspotOptions.includeDistance;
261			}
262
263			const { includeDistance, includeElevation } = giga.hotspotOptions;
264			const hsLabelDescription = (includeDistance || includeElevation)
265				? ['nazwa', ...(includeDistance ? ['odległość'] : []), ...(includeElevation ? ['wysokość'] : [])]
266				: ['tylko nazwa'];
267
268			showScreenMessage(`Etykiety: ${hsLabelDescription.join(' + ')}`, { category: 'hotspot-labels' });
269			giga.currentPanorama.generateLabels({includeBearingError: 'true'===k.get('state.includeBearingError')});
270			giga.loader.hide();
271		},
272		
273		getObjectsList: function(sortBy) {
274			const info = k.info;
275			var hsList = giga.currentPanorama.getHotspotsList();
276			
277			// add info from points database + calculate distance & bearing errors
278			hsList = hsList.map( function(h) {
279				const p = hotspots.points.get(h.id);
280				if (hasValue(p)) {
281					h.elevation = p.elevation || '';
282					h.range = p.range || '';
283					h.verified = giga.isPointVerified(p);
284					h.kind = p.kind;
285					if (hasValue(p.lat, p.lon)) {
286						h.distance = giga.getDistance(info.lat,info.lon,p.lat,p.lon);
287						var bearingOnPan = giga.hposToAzimuth(+h.ath);
288						var bearingReal = giga.getBearing(info.lat,info.lon,p.lat,p.lon);
289						var bearingDiff = giga.plusMinus180(bearingReal-bearingOnPan);
290					}
291					h.bearing = bearingReal || null;
292					h.bearingError = bearingDiff || '';
293				}
294				return h;
295			});
296			
297			var sortFunction;
298			switch(sortBy) {
299				case 'bearing':
300					sortFunction = function(a,b) { return +a.ath-+b.ath; };
301					break;
302				case 'bearing-error':
303					sortFunction = function(a,b) { return Math.abs(+b.bearingError)-Math.abs(+a.bearingError); };
304					break;
305				case 'distance':
306					sortFunction = function(a,b) { return +b.distance-+a.distance; };
307					break;
308				case 'title':
309				default: 
310					sortFunction = function(a,b) { return a.title.localeCompare(b.title) };
311					break;
312			}
313			return hsList.sort(sortFunction);
314		},
315		
316		makeObjectsList: function(sortBy) {
317			const ret = giga.currentPanorama.getObjectsList(sortBy).map( function(h) {
318				let t = "", elev = "", extra = "";
319				t = h.range || '';
320				if (h.distance) t = h.distance.toFixed(1) + ' km' + (t?' / ':'') + t;
321				elev = h.elevation ? (' <small>'+h.elevation+'&nbsp;m</small>') : '';
322				if (hasValue(h.bearingError)) extra = '<small>'+(h.bearingError>=0?'+':'')+h.bearingError.toFixed(2)+'&deg;</small> ';
323				return '<div class="tr hs clickable '+giga.getBearingErrorClass(h.bearingError)+(h.verified?' verified':'')+(!h.distance && (h.kind=='peak' || h.kind=='saddle')?' no-coords-feature':'')+' kind-'+h.kind+'" data-name="'+h.name+'" title="'+t+'"><span class="td">'+extra+'</span><span class="td">'+h.title+elev+'</span></div>';
324			}).join('\n');
325			return `\n\n<div class="noborder-table tbl">\n${ret}\n</div>\n`;
326		},
327		
328		// object statistics
329		
330		makeObjectsStats: function() {
331			const hsList = giga.currentPanorama.getHotspotsList();
332			const info = k.info;
333			var stats = {
334				count: 0,
335				bearingError: {min: 1000, max: 0, avg: 0, median: 0},
336				distance: {min: 1000, max: 0, avg: 0, median: 0}
337			};
338			var distances = [], bearingErrors = [];
339			hsList.forEach( h => {
340				const p = hotspots.points.get(h.id);
341				if (hasValue(p?.lat, p?.lon)) {
342					stats.count++;
343					distances.push(giga.getDistance(info.lat,info.lon,p.lat,p.lon));
344					const bearingOnPan = giga.hposToAzimuth(+h.ath);
345					//const bearingReal = giga.getBearing(info.lat,info.lon,p.lat,p.lon);
346					const bearingReal = giga.getBearingVincenty(info.lat,info.lon,p.lat,p.lon);
347					const bearingDiff = giga.plusMinus180(bearingReal-bearingOnPan);
348					bearingErrors.push(bearingDiff);
349				}
350			});
351			if (stats.count>0) {
352				stats.distance.min = Math.min(...distances);
353				stats.distance.max = Math.max(...distances);
354				stats.distance.avg = mean(distances);
355				stats.distance.median = median(distances);
356				stats.bearingError.min = Math.min(...bearingErrors);
357				stats.bearingError.max = Math.max(...bearingErrors);
358				stats.bearingError.avg = mean(bearingErrors);
359				stats.bearingError.median = median(bearingErrors);
360			}
361			return stats;
362		},
363		
364		makeBearingErrorsChart: function() {
365			const bearingErrors = giga.currentPanorama.getObjectsList('bearing').filter(h=>h.bearingError);
366			const errs = bearingErrors.map(h=>h.bearingError);
367			const min = Math.min(...errs);
368			const max = Math.max(...errs);
369			const absminmax = Math.max(Math.abs(max),Math.abs(min));
370			const divs = bearingErrors.map( h => {
371				let cls = '', ht = 0;
372				if (h.bearingError>=0) {
373					cls = 'positive';
374					if (h.bearingError>0) ht = 100*(h.bearingError/absminmax);
375				} else {
376					cls = 'negative';
377					ht = 100*(Math.abs(h.bearingError)/absminmax);
378				}
379				return `<div class="clickable hs ${cls}" title="${h.bearingError.toFixed(2)}° : ${h.bearing.toFixed(2)}° : ${h.distance.toFixed(2)} km : ${makeAttr(h.title)}" style="height:${ht.toFixed(1)}%" data-name="${h.name}"></div>`;
380			});
381			return `<div class="errors-chart">${divs.join(' ')}</div>`;
382		},
383		
384		showBearingErrorsChart: function() {
385			document.querySelectorAll('.errors-chart-container').forEach(el => el.remove());
386			const div = document.createElement('div');
387			div.className='errors-chart-container';
388			document.body.appendChild(div);
389			div.innerHTML = giga.currentPanorama.makeBearingErrorsChart();
390		},
391		
392		hideBearingErrorsChart: function() {
393			document.querySelectorAll('.errors-chart-container').forEach(el => el.remove());
394		},
395		
396		toggleBearingErrorsChart: function() {
397			if (document.querySelectorAll('.errors-chart-container').length>0) {
398				giga.currentPanorama.hideBearingErrorsChart();
399			} else {
400				giga.currentPanorama.showBearingErrorsChart();
401			}
402		},
403		
404		
405		minCalculationDistance: 1.0,
406		
407		calculateErrors: function(leftCorrection,rightCorrection,latCorrection,lonCorrection) {
408			leftCorrection = +leftCorrection || 0;
409			rightCorrection = +rightCorrection || 0;
410			latCorrection = +latCorrection || 0;
411			lonCorrection = +lonCorrection || 0;
412			
413			const hsList = giga.currentPanorama.getHotspotsList();
414			const info = k.info;
415			const stats = {
416				count: 0,
417				bearingError: {min: 1000, max: 0, avg: 0, median: 0}
418			};
419			const newLeft = (+info.azimuthleft+leftCorrection) % 360;
420			const newRight = (+info.azimuthright+rightCorrection) % 360;
421			const bearingErrors = [];
422			
423			hsList.forEach( function(h) {
424				const p = hotspots.points.get(h.id);
425				if (hasValue(p?.lat, p?.lon) ) {
426					const distance = giga.getDistance(+info.lat,+info.lon,p.lat,p.lon);
427					if (distance>=giga.currentPanorama.minCalculationDistance) {
428						stats.count++;
429						const bearingOnPan = giga.hposToAzimuthGeneric(+h.ath,newLeft,newRight,1);
430						const bearingReal = giga.getBearing(+info.lat+latCorrection,+info.lon+lonCorrection,p.lat,p.lon);
431						const bearingDiff = giga.plusMinus180(bearingReal-bearingOnPan);
432						bearingErrors.push(bearingDiff);
433					}
434				}
435			});
436			
437			if (stats.count>0) {
438				stats.bearingError.lat = +info.lat+latCorrection;
439				stats.bearingError.lon = +info.lon+lonCorrection;
440				stats.bearingError.left = newLeft;
441				stats.bearingError.right = newRight;
442				stats.bearingError.min = Math.min(...bearingErrors);
443				stats.bearingError.max = Math.max(...bearingErrors);
444				stats.bearingError.avg = mean(bearingErrors);
445				stats.bearingError.median = median(bearingErrors);
446			}
447			return stats;
448		},
449		
450		correctRange: function(steps,step,latCorrection=0,lonCorrection=0) {
451			var errors = [];
452			for(let l=-steps; l<=steps; l++) {
453				for(let r=-steps; r<=steps; r++) {
454					errors.push(giga.currentPanorama.calculateErrors(l*step,r*step,+latCorrection,+lonCorrection).bearingError);
455				}
456			}
457			/*
458			errors.sort( (a,b) => Math.abs(a.avg) - Math.abs(b.avg) );
459			console.log('by average error');
460			for(i=1;i<=5;i++) console.log('('+errors[i].left.toFixed(2)+'...'+errors[i].right.toFixed(2)+')','avg',errors[i].avg.toFixed(2),'median',errors[i].median.toFixed(2),'min',errors[i].min.toFixed(2),'max',errors[i].max.toFixed(2));
461			console.log('by abs(max error)');
462			errors.sort( (a,b) => Math.max(Math.abs(a.min),Math.abs(a.max)) - Math.max(Math.abs(b.min),Math.abs(b.max)) );
463			for(i=1;i<=5;i++) console.log('('+errors[i].left.toFixed(2)+'...'+errors[i].right.toFixed(2)+')','avg',errors[i].avg.toFixed(2),'median',errors[i].median.toFixed(2),'min',errors[i].min.toFixed(2),'max',errors[i].max.toFixed(2));
464			*/
465			return errors;
466		},
467		
468		correctPosition: function(steps,step,minDistance=0) {
469			giga.currentPanorama.minCalculationDistance = minDistance;
470			var errors = [];
471			for (let l=-steps; l<=steps; l++) {
472				for(let r=-steps; r<=steps; r++) {
473					const e = giga.currentPanorama.correctRange(5,0.01, l*step,r*step);
474					e.sort( (a,b) => Math.abs(a.avg) - Math.abs(b.avg) );
475					errors.push(e[0]);
476				}
477			}
478			console.log('by average error');
479			errors.sort( (a,b) => Math.abs(a.avg) - Math.abs(b.avg) );
480			for (let i=1;i<=5;i++) console.log(errors[i].lat.toFixed(5),errors[i].lon.toFixed(5), '('+errors[i].left.toFixed(2)+'...'+errors[i].right.toFixed(2)+')','avg',errors[i].avg.toFixed(2),'median',errors[i].median.toFixed(2),'min',errors[i].min.toFixed(2),'max',errors[i].max.toFixed(2));
481			console.log('by abs(max error)');
482			errors.sort( (a,b) => Math.max(Math.abs(a.min),Math.abs(a.max)) - Math.max(Math.abs(b.min),Math.abs(b.max)) );
483			for (let i=1;i<=5;i++) console.log(errors[i].lat.toFixed(5),errors[i].lon.toFixed(5), '('+errors[i].left.toFixed(2)+'...'+errors[i].right.toFixed(2)+')','avg',errors[i].avg.toFixed(2),'median',errors[i].median.toFixed(2),'min',errors[i].min.toFixed(2),'max',errors[i].max.toFixed(2));
484		},
485		
486
487		optimizeCentralPoint: (initialCentral, points) => {
488			// initialCentral: {lat: number, lon: number}
489			// points: [{lat:number, lon:number, bearing:number}]
490
491			const info = k.info;
492			initialCentral ??= { lat: +info.lat, lon: +info.lon };
493			points ??= giga.currentPanorama.getHotspotsList().map( h => {
494				return { ...hotspots.points.get(h.id), ath: +h.ath }
495			}).filter( p => 
496				hasValue(p?.lat, p?.lon) && giga.getDistance(+info.lat,+info.lon,p.lat,p.lon) >= giga.currentPanorama.minCalculationDistance
497			).map( p => {
498				return { lat: p.lat, lon: p.lon, bearing: giga.hposToAzimuthGeneric(+p.ath,+info.azimuthleft,+info.azimuthright,1) } 
499			});
500		
501			let central = { ...initialCentral };
502			const learningRate = 0.0001; // small step size
503			const maxIterations = 10000;  //10000
504			const tolerance = 1e-9;
505			
506			console.log(central);
507			console.log(points);
508			
509			function computeError(center) {
510				let error = 0;
511				for (const pt of points) {
512					const calcBearing = (giga.getBearing(center.lat, center.lon, pt.lat, pt.lon) + 360) % 360;
513					let diff = calcBearing - pt.bearing;
514					//console.log(diff);
515					if (diff > 180) diff -= 360;
516					if (diff < -180) diff += 360;
517					error += diff * diff;
518				}
519				return error;
520			}
521
522			// Numerical gradient approximation for latitude and longitude
523			function computeGradient(center) {
524				const h = 1e-6;
525				const err = computeError(center);
526
527				const latUp = { lat: center.lat + h, lon: center.lon };
528				const errLatUp = computeError(latUp);
529				const dErrLat = (errLatUp - err) / h;
530
531				const lonUp = { lat: center.lat, lon: center.lon + h };
532				const errLonUp = computeError(lonUp);
533				const dErrLon = (errLonUp - err) / h;
534
535				return { dLat: dErrLat, dLon: dErrLon };
536			}
537
538			let prevError = computeError(central);
539
540			for (let i = 0; i < maxIterations; i++) {
541				const showIter = (i % 200 == 0);
542				if (showIter) console.log(`iteration ${i}`);
543				const grad = computeGradient(central);
544
545				// Update central point coordinates
546				central.lat -= learningRate * grad.dLat;
547				central.lon -= learningRate * grad.dLon;
548
549				const currentError = computeError(central);
550				if (showIter) console.log(currentError);
551
552				if (Math.abs(prevError - currentError) < tolerance) {
553					prevError = currentError;
554					break; // Stop if error improvement is below tolerance
555				}
556				prevError = currentError;
557			}
558			console.log(prevError);
559
560			return central;
561		},
562		
563		/*
564		// Example usage:
565		const initialCentral = { lat: 50.0, lon: 20.0 };
566		const points = [
567		  { lat: 50.1, lon: 20.1, bearing: 45 },
568		  { lat: 50.2, lon: 19.9, bearing: 315 },
569		  { lat: 49.9, lon: 20.2, bearing: 90 },
570		];
571
572		const optimizedCentral = optimizeCentralPoint(initialCentral, points);
573		console.log('Optimized Central Point:', optimizedCentral);
574		*/
575		
576		formatObjectsStats: function(stats) {
577			var s = '';
578			s += `<div class="ind"><i class="fas fa-fw fa-thumbtack"></i> ${stats.count} ${odmien(stats.count,'uwzględniony','uwzględnione','uwzględnionych')}</div>
579				<div class="ind" title="odległość do obiektów (średnia/mediana, zakres)"><i class="fas fa-fw fa-binoculars"></i> ${stats.distance.avg.toFixed(1)}/${stats.distance.median.toFixed(1)} km (${stats.distance.min.toFixed(1)}...${stats.distance.max.toFixed(1)} km)</div>
580				<div class="ind" title="odchylenia azymutu (średnia/mediana, zakres)"><i class="fas fa-fw fa-arrows-alt-h"></i> ${addSign(stats.bearingError.avg.toFixed(2))}/${addSign(stats.bearingError.median.toFixed(2))}° (${addSign(stats.bearingError.min.toFixed(2))}...${addSign(stats.bearingError.max.toFixed(2))}°)</div>`;
581			return s;
582		},
583		
584		getMinMaxElevationAngles: function() {
585			const info = k.info; 
586			const ang = giga.currentPanorama.getHotspotsList().filter( (h) => { let p = hotspots.points.get(h.id); return !!p && !!p?.lat && !!p?.lon && !!p?.elevation; } ).map( (h) => { 
587				let p = hotspots.points.get(h.id);
588				return giga.getElevationAngle(+info.lat,+info.lon,+info.elevation, +p.lat,+p.lon,+p.elevation); 
589			}).sort((a,b)=>a-b);
590			return [ang[0],ang[ang.length-1]];
591		},
592		
593		interpolateVerticalRange: function() {
594			console.log('interpolating...');
595			const info = k.info, image = k.image; 
596			const allAngles = giga.currentPanorama.getHotspotsList().filter( (h) => { let p = hotspots.points.get(h.id); return !!p && !!p?.lat && !!p?.lon && !!p?.elevation; } ).map( (h) => { 
597				let p = hotspots.points.get(h.id);
598				return [+h.atv, giga.getElevationAngle(+info.lat,+info.lon,+info.elevation, +p.lat,+p.lon,+p.elevation)]; 
599			}).sort((a,b)=>a[1]-b[1]);
600			if (allAngles.length==0) return null;
601			let angles = [allAngles[0],allAngles[Math.round(allAngles.length/2)],allAngles[allAngles.length-1]];
602			//console.log(angles);
603			let ip = interpolatingPolynomial(angles);
604			console.log('top: ',-image.vfov,-ip(-image.vfov/2));
605			console.log('bot: ',+image.vfov,-ip(+image.vfov/2));
606			return {
607				azimuthtop: -ip(-image.vfov/2).toFixed(2),
608				azimuthbottom: -ip(+image.vfov/2).toFixed(2)
609			}
610		},
611		
612		multiSelect: {
613			points: [],
614			infowindow: null,
615			
616			add: function(h) {
617				const p = hotspots.points.get(h?.id);
618				if (!hasValue(p?.lat,p?.lon)) return false;
619				
620				giga.currentPanorama.multiSelect.points.push(h);
621				if (!giga.currentPanorama.multiSelect.infowindow) {
622					const el = document.createElement("div");
623					el.classList.add("popup-info-window");
624					giga.currentPanorama.multiSelect.infowindow = el;
625					document.getElementById('content').appendChild(el);
626				}
627				giga.currentPanorama.multiSelect.infowindow.innerHTML = giga.currentPanorama.multiSelect.getText() + '<div class="close" title="Zamknij" onclick="giga.currentPanorama.multiSelect.clear()">X</div>';
628				giga.currentPanorama.multiSelect.drawLine();
629			},
630			
631			clear: function() {
632				giga.currentPanorama.multiSelect.points = [];
633				giga.currentPanorama.multiSelect.drawLine();
634				if (giga.currentPanorama.multiSelect.infowindow) {
635					giga.currentPanorama.multiSelect.infowindow.remove();
636					giga.currentPanorama.multiSelect.infowindow = null;
637				}
638			},
639			
640			getText: function() {
641				var prevPoint, cumulativeDistance = 0;
642				var ret = giga.currentPanorama.multiSelect.points.map( (h,i) => {
643					//console.log(h,i);	
644					let p = hotspots.points.get(h.id);
645					let dist = 0, bear;
646					if (hasValue(prevPoint, p?.lat,p?.lon)) {
647						dist = giga.getDistance(prevPoint.lat,prevPoint.lon,p.lat,p.lon);
648						bear = giga.getBearing(prevPoint.lat,prevPoint.lon,p.lat,p.lon);
649						cumulativeDistance += dist;
650					}
651					if (hasValue(p?.lat,p?.lon)) prevPoint = p;
652					return `<tr><td align="right">${i+1}</td><td><b>${p.names[0]}</b></td><!--<td>${p?.lat}</td><td>${p?.lon}</td>--><td align="right" title="azymut od poprzedniego punktu">${bear ? (bear.toFixed(2)+'&deg;') : ''}</td><td align="right" title="odległość od poprzedniego punktu"><b>${dist.toFixed(3)} km</b></td><td align="right" title="sumaryczna odległość od pierwszego punktu"><b>${cumulativeDistance.toFixed(3)} km</b></td></tr>`;
653				}).join('');
654				return `<table>${ret}</table>`;
655			},
656			
657			drawLine: function () {
658				try {
659					k.removehotspot('distanceline');
660				} catch (e) {}
661				if (giga.currentPanorama.multiSelect.points.length>0) {
662					const dline = k.addhotspot('distanceline');
663					dline.loadstyle('distline');
664					dline.points = giga.currentPanorama.multiSelect.points.map(hs => `${hs.ath},${hs.atv}`).join(',');
665					//dline.polyline = true;
666					//dline.fillcolor = "0xbb4400";
667					//dline.fillalpha = 0.7;
668					//dline.borderwidth = 4.0;
669
670				}
671			},
672			
673			show: function() {
674			}
675		},
676		
677	
678		// overlay maps
679		
680		overlayMap: {
681			map: undefined,
682			semi: undefined,
683			point: undefined,
684			
685			make: function(mapContainer) {
686				const info = k.info;
687				const latlng = [+info.lat, +info.lon];
688				const hsList = giga.currentPanorama.getHotspotsList().filter( (h) => {
689					let p = hotspots.points.get(h.id);
690					return hasValue(p?.lat, p?.lon);
691				}).sort( (a, b) => +a.ath >= +b.ath );
692
693				const mymap = L.map(mapContainer, {
694					zoomControl: false
695				}).addControl(L.control.zoom({
696					zoomInTitle: 'Przybliż',
697					zoomOutTitle: 'Oddal',
698					zoomSnap: 0.5,
699					zoomDelta: 0.5
700				})).setView(latlng, 10);
701				//mymap.createPane('view');
702				//mymap.getPane('view').style.zIndex = 350;
703				
704				L.tileLayer('https://api.mapbox.com/styles/v1/zbooyek/cjb3mj9ja03ru2sp7l0x32j60/tiles/256/{z}/{x}/{y}?access_token=pk.eyJ1IjoiemJvb3llayIsImEiOiJjajF4OHdmZHMwMTY0MzNta2VvYnY3d2RxIn0.KA0JiliY1oiTuzFustFIaA', {
705					attribution: 'Map data © <a href="https://openstreetmap.org">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="https://mapbox.com">Mapbox</a>',
706					maxZoom: 18,
707					id: 'mapbox.streets',
708					accessToken: 'pk.eyJ1IjoiemJvb3llayIsImEiOiJjajF4OHdmZHMwMTY0MzNta2VvYnY3d2RxIn0.KA0JiliY1oiTuzFustFIaA'
709				}).addTo(mymap);
710
711				const aStart = +info.azimuthleft;
712				const aStop = +info.azimuthright;
713				var range, direction;
714				if (aStop>=aStart) {
715					range = aStop-aStart;
716					direction = aStart + range/2;
717				} else {
718					range = aStop+360-aStart;
719					direction = aStart + range/2;
720					if (direction>=360) direction -= 360;
721				}
722
723				const wholeView = L.semiCircle(latlng, {
724					interactive: false,
725					fill: true,
726					fillColor:'#444444',
727					fillOpacity: 0.11,
728					color: '#444444',
729					opacity: 0.0,
730					sticky: true,
731					radius: info.range ? +info.range*1000 : 100000
732				}).setDirection(+direction,+range).addTo(mymap);
733				
734				L.control.mousePosition({
735					latFormatter: n => n.toFixed(5),
736					lngFormatter: n => n.toFixed(5),
737					position: 'topright',
738					separator: ', ',
739					emptyString: '---'
740				}).addTo(mymap);
741				
742				const lines = L.featureGroup();
743				hsList.map( function(h) {
744					let p = hotspots.points.get(h.id);
745					L.polyline([latlng, [+p.lat,+p.lon]], {
746						keyboard: false,
747						title: p.name,
748						color: '#ff0000',
749						opacity: 0.66,
750						weight: 2
751					}).on('click', function() {
752						giga.moveToHotspot(h.name);
753					}).bindTooltip(h.title, {
754						keyboard: false,
755						interactive: false,
756						sticky: true
757					}).addTo(lines);
758				});
759				lines.addTo(mymap);
760				mymap.fitBounds(lines.getBounds());
761				
762				L.circleMarker(latlng, {
763					radius: 3,
764					fill: '#00aa00',
765					color: '#00aa00'
766				}).addTo(mymap);
767
768				$('#'+mapContainer).parents('.resizable').on("resizestop", function( event, ui ) {
769					console.log('resizestop handler');
770					window.setTimeout( function() {
771						mymap.invalidateSize({
772							pan: false,
773							debounceMoveend: true
774						});
775					}, 50);
776				});
777				
778				return mymap;
779			},
780			
781			center: function(lat,lon) {
782				giga.currentPanorama.overlayMap.map?.panTo([+lat,+lon]);
783			},
784			
785			show: function() {
786				document.getElementById('overlay-map').classList.remove('hidden');
787				if (!giga.currentPanorama.overlayMap.map) {
788					giga.currentPanorama.overlayMap.map = giga.currentPanorama.overlayMap.make('overlay-map-inside');
789				}
790				giga.currentPanorama.overlayMap.updateView(giga.getCurrentViewParameters());
791			},
792			
793			hide: function() {
794				document.getElementById('overlay-map').classList.add('hidden');
795			},
796			
797			toggle: function() {
798				if (document.getElementById('overlay-map').classList.contains('hidden')) {
799					giga.currentPanorama.overlayMap.show();
800				} else {
801					giga.currentPanorama.overlayMap.hide();
802				}
803			},	
804			
805			remove: function() {
806				if (giga.currentPanorama.overlayMap.map) {
807					giga.currentPanorama.overlayMap.map.remove();
808					giga.currentPanorama.overlayMap.map = giga.currentPanorama.overlayMap.semi = undefined;
809				}
810				document.getElementById('overlay-map').classList.add('hidden');
811			},
812			
813			updateView: function(currentView) {
814				if (!giga.currentPanorama.overlayMap.map) return;
815				const info = k.info;
816				const latlng = [+info.lat, +info.lon];
817				if (!hasValue(giga.currentPanorama.overlayMap.semi)) { 
818					giga.currentPanorama.overlayMap.semi = L.semiCircle(latlng, {
819						interactive: false,
820						fill: true,
821						fillColor:'#00cc22',
822						fillOpacity: 0.22,
823						color: '#00cc22',
824						opacity: 0.0,
825						sticky: true,
826						radius: info.range ? +info.range*1000 : 100000
827					}).addTo(giga.currentPanorama.overlayMap.map).bringToFront();
828				}
829				giga.currentPanorama.overlayMap.semi.setDirection(+currentView.center,+currentView.range);
830			},
831			
832			showPoint: function(lat,lon,title='') {
833				if (!giga.currentPanorama.overlayMap.map) return;
834				giga.currentPanorama.overlayMap.removePoint();
835				giga.currentPanorama.overlayMap.point = L.circleMarker([lat,lon], {
836					radius: 3,
837					title: title,
838					zIndexOffset: 100,
839					fill: '#0000ff',
840					color: '#0000ff'
841				}).addTo(giga.currentPanorama.overlayMap.map);
842			},
843			
844			removePoint: function() {
845				if (giga.currentPanorama.overlayMap.point) {
846					giga.currentPanorama.overlayMap.point.remove();
847					giga.currentPanorama.overlayMap.point = undefined;
848				}
849			}
850		}
851	},
852	
853	getBearingErrorClass(bearingDiff) {
854		var bearingColorClass = '';
855		if (Math.abs(bearingDiff)>=0.4) {
856			bearingColorClass = 'red';
857		} else if (Math.abs(bearingDiff)>=0.2) {
858			bearingColorClass = 'yellow';
859		}
860		return bearingColorClass;
861	},
862	
863	kindToString: function(kind) {
864		switch(kind) {
865			case 'peak': return 'szczyt'; 
866			case 'col':  return 'przełęcz'; 
867			case 'saddle':  return 'przełęcz'; 
868			case 'town': return 'miejscowość'; 
869			case 'lake': return 'jezioro';
870			case 'valley': return 'dolina'; 
871			case 'glade': return 'polana';
872			case 'glacier': return 'lodowiec';
873			case 'forest': return 'las';
874			case 'ridge': return 'grzbiet';
875			case 'formation': return 'formacja';
876			case 'building': return 'konstrukcja';
877			case 'island': return 'wyspa'; 
878			case 'river': return 'rzeka'; 
879			case 'canal': return 'kanał'; 
880			case 'waterfall': return 'wodospad'; 
881			case 'road': return 'droga';
882			case 'smokestack': return 'komin';
883			case 'chimney': return 'komin';
884			case 'mineshaft': return 'szyb kopalni';
885			case 'mast': return 'maszt';
886		}
887		return `? (${kind})`;
888	},
889
890	// To convert radians to degrees, divide by (Math.PI/180), and multiply by this to convert the other way.
891	deg2rad: function(d) {
892		return d * giga.Pi180;
893	},
894	rad2deg: function(d) {
895		return d * giga.Pi180Inverse;
896	},
897	
898	getDistanceCosine: function(startLat,startLong,endLat,endLong) {
899		const endRad = giga.deg2rad(+endLat), startRad = giga.deg2rad(+startLat);
900		const theta = startLong  -endLong;
901		const d = Math.sin(startRad) * Math.sin(endRad) + Math.cos(startRad) * Math.cos(endRad) * Math.cos(giga.deg2rad(theta));	
902		return giga.rad2deg(Math.acos(d)) * 60 * 1.1515 * 1.609344;
903	},
904	haversine: function (x) {
905		const sx = Math.sin(x/2);
906		return sx * sx;
907	},
908	getDistance: function (startLat,startLong,endLat,endLong) {
909		const endRad = giga.deg2rad(+endLat), startRad = giga.deg2rad(+startLat);
910		const ht = giga.haversine(endRad - startRad) + Math.cos(startRad) * Math.cos(endRad) * giga.haversine(giga.deg2rad(+endLong) - giga.deg2rad(+startLong));
911		return 12756.274 * Math.asin(Math.sqrt(ht));
912	},
913	
914	wrap360: function(degrees) {
915        if (0<=degrees && degrees<360) return degrees; // avoid rounding due to arithmetic ops if within range
916        // bearing wrapping requires a sawtooth wave function with a vertical offset equal to the
917        // amplitude and a corresponding phase shift; this changes the general sawtooth wave function from
918        //     f(x) = (2ax/p - p/2) % p - a
919        // to
920        //     f(x) = (2ax/p) % p
921        // where a = amplitude, p = period, % = modulo; however, JavaScript '%' is a remainder operator
922        // not a modulo operator - for modulo, replace 'x%n' with '((x%n)+n)%n'
923        const x = degrees, a = 180, p = 360;
924        return (((2*a*x/p)%p)+p)%p;
925	},
926	
927	plusMinus180: function(degrees) {
928		if (degrees>180.0) {
929			degrees -= 360.0;
930		} else if (degrees<-180.0) {
931			degrees += 360.0;
932		}
933		return degrees;
934	},
935	
936	vincentyInverse: function(startLat, startLon, endLat, endLon) {
937		const a = 6378137, b = 6356752.3142, f = 1 / 298.257223563;
938		const f1 = 1 - f, a2mb2 = a*a - b*b, b2 = b*b;
939		const Pi = Math.PI, Eps = Number.EPSILON;
940
941		const Phi1 = giga.deg2rad(startLat), Phi2 = giga.deg2rad(endLat);
942		const L    = giga.deg2rad(endLon - startLon);
943
944		const tanU1 = f1 * Math.tan(Phi1), cosU1 = 1 / Math.sqrt(1 + tanU1*tanU1), sinU1 = tanU1 * cosU1;
945		const tanU2 = f1 * Math.tan(Phi2), cosU2 = 1 / Math.sqrt(1 + tanU2*tanU2), sinU2 = tanU2 * cosU2;
946		const cosU1cosU2 = cosU1 * cosU2, sinU1sinU2 = sinU1 * sinU2, cosU1sinU2 = cosU1 * sinU2, sinU1cosU2 = sinU1 * cosU2;
947
948		const antipodal = Math.abs(L) > Pi/2 || Math.abs(Phi2 - Phi1) > Pi/2;
949
950		let Lambda = L, LambdaPrim;
951		let sinLambda, cosLambda;
952		let sinSigma = 0, cosSigma = antipodal ? -1 : 1, Sigma = antipodal ? Pi : 0;
953		let sinSqSigma = null, cos2Sigma_m = 1, cosSqAlpha = 1;
954		let iterations = 0;
955
956		do {
957			sinLambda       = Math.sin(Lambda);
958			cosLambda       = Math.cos(Lambda);
959			const t         = cosU1sinU2 - sinU1cosU2 * cosLambda;
960			sinSqSigma      = (cosU2 * sinLambda) ** 2 + t * t;
961			if (Math.abs(sinSqSigma) < 1e-24) break;
962			sinSigma        = Math.sqrt(sinSqSigma);
963			cosSigma        = sinU1sinU2 + cosU1cosU2 * cosLambda;
964			Sigma           = Math.atan2(sinSigma, cosSigma);
965			const sinAlpha  = cosU1cosU2 * sinLambda / sinSigma;
966			cosSqAlpha      = 1 - sinAlpha * sinAlpha;
967			cos2Sigma_m     = cosSqAlpha !== 0 ? cosSigma - 2 * sinU1sinU2 / cosSqAlpha : 0;
968			const C         = f / 16 * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha));
969			LambdaPrim      = Lambda;
970			Lambda          = L + (1 - C) * f * sinAlpha * (Sigma + C * sinSigma * (cos2Sigma_m + C * cosSigma * (-1 + 2 * cos2Sigma_m * cos2Sigma_m)));
971			if ((antipodal ? Math.abs(Lambda) - Pi : Math.abs(Lambda)) > Pi) throw new EvalError('Lambda > Pi');
972		} while (Math.abs(Lambda - LambdaPrim) > 1e-12 && ++iterations < 1000);
973
974		if (iterations >= 1000) throw new EvalError('Vincenty formula failed to converge');
975
976		const uSq        = cosSqAlpha * a2mb2 / b2;
977		const A          = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));
978		const B          = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
979		const ss2        = sinSigma * sinSigma;
980		const c2sm2      = cos2Sigma_m * cos2Sigma_m;
981		const DeltaSigma = B * sinSigma * (cos2Sigma_m + B / 4 * (cosSigma * (-1 + 2 * c2sm2) - B / 6 * cos2Sigma_m * (-3 + 4 * ss2) * (-3 + 4 * c2sm2)));
982		const s          = b * A * (Sigma - DeltaSigma);
983
984		const tiny   = Math.abs(sinSqSigma) < Eps;
985		const Alpha1 = tiny ? 0  : Math.atan2(cosU2 * sinLambda,  cosU1sinU2 - sinU1cosU2 * cosLambda);
986		const Alpha2 = tiny ? Pi : Math.atan2(cosU1 * sinLambda, -sinU1cosU2 + cosU1sinU2 * cosLambda);
987		const nanDist = Math.abs(s) < Eps;
988
989		return {
990			distance:       s,
991			initialBearing: nanDist ? NaN : giga.wrap360(giga.rad2deg(Alpha1)),
992			finalBearing:   nanDist ? NaN : giga.wrap360(giga.rad2deg(Alpha2)),
993			iterations,
994		};
995	},	
996	
997	vincentyInverseOLD: function (startLat,startLong,endLat,endLong) {
998        const Phi1 = giga.deg2rad(startLat), Lambda1 = giga.deg2rad(startLong);
999        const Phi2 = giga.deg2rad(endLat), Lambda2 = giga.deg2rad(endLong);
1000		const Pi = Math.PI, Epsilon = Number.EPSILON;
1001
1002		const a = 6378137, b = 6356752.3142, f = 1 / 298.257223563; // WGS-84 ellipsoid params
1003
1004        const L = Lambda2 - Lambda1; // L = difference in longitude, U = reduced latitude, defined by tan U = (1-f)·tanPhi.
1005        const tanU1 = (1-f) * Math.tan(Phi1), cosU1 = 1 / Math.sqrt((1 + tanU1*tanU1)), sinU1 = tanU1 * cosU1;
1006        const tanU2 = (1-f) * Math.tan(Phi2), cosU2 = 1 / Math.sqrt((1 + tanU2*tanU2)), sinU2 = tanU2 * cosU2;
1007
1008        const antipodal = Math.abs(L) > Pi/2 || Math.abs(Phi2-Phi1) > Pi/2;
1009
1010        let Lambda = L, sinLambda = null, cosLambda = null; // Lambda = difference in longitude on an auxiliary sphere
1011        let Sigma = antipodal ? Pi : 0, sinSigma = 0, cosSigma = antipodal ? -1 : 1, sinSqSigma = null; // Sigma = angular distance P₁ P₂ on the sphere
1012        let cos2Sigma_m = 1;                      // Sigma_m = angular distance on the sphere from the equator to the midpoint of the line
1013        let cosSqAlpha = 1;                      // Alpha = azimuth of the geodesic at the equator
1014
1015        let LambdaPrim = null, iterations = 0;
1016        do {
1017            sinLambda = Math.sin(Lambda);
1018            cosLambda = Math.cos(Lambda);
1019            sinSqSigma = (cosU2*sinLambda)**2 + (cosU1*sinU2-sinU1*cosU2*cosLambda)**2;
1020            if (Math.abs(sinSqSigma) < 1e-24) break;  // co-incident/antipodal points (Sigma < ≈0.006mm)
1021            sinSigma = Math.sqrt(sinSqSigma);
1022            cosSigma = sinU1*sinU2 + cosU1*cosU2*cosLambda;
1023            Sigma = Math.atan2(sinSigma, cosSigma);
1024            const sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma;
1025            cosSqAlpha = 1 - sinAlpha*sinAlpha;
1026            cos2Sigma_m = (cosSqAlpha != 0) ? (cosSigma - 2*sinU1*sinU2/cosSqAlpha) : 0; // on equatorial line cos²Alpha = 0 (paragraph 6)
1027            const C = f/16*cosSqAlpha*(4+f*(4-3*cosSqAlpha));
1028            LambdaPrim = Lambda;
1029            Lambda = L + (1-C) * f * sinAlpha * (Sigma + C*sinSigma*(cos2Sigma_m+C*cosSigma*(-1+2*cos2Sigma_m*cos2Sigma_m)));
1030            const iterationCheck = antipodal ? Math.abs(Lambda)-Pi : Math.abs(Lambda);
1031            if (iterationCheck > Pi) throw new EvalError('Lambda > Pi');
1032        } while (Math.abs(Lambda-LambdaPrim) > 1e-12 && ++iterations<1000); // TV: 'iterate until negligible change in Lambda' (≈0.006mm)
1033        if (iterations >= 1000) throw new EvalError('Vincenty formula failed to converge');
1034
1035        const uSq = cosSqAlpha * (a*a - b*b) / (b*b);
1036        const A = 1 + uSq/16384*(4096+uSq*(-768+uSq*(320-175*uSq)));
1037        const B = uSq/1024 * (256+uSq*(-128+uSq*(74-47*uSq)));
1038        const DeltaSigma = B*sinSigma*(cos2Sigma_m+B/4*(cosSigma*(-1+2*cos2Sigma_m*cos2Sigma_m)-B/6*cos2Sigma_m*(-3+4*sinSigma*sinSigma)*(-3+4*cos2Sigma_m*cos2Sigma_m)));
1039
1040        const s = b*A*(Sigma-DeltaSigma); // s = length of the geodesic
1041
1042        // note special handling of exactly antipodal points where sin²Sigma = 0 (due to discontinuity
1043        // atan2(0, 0) = 0 but atan2(Epsilon, 0) = Pi/2 / 90°) - in which case bearing is always meridional,
1044        // due north (or due south!)
1045        // Alpha = azimuths of the geodesic; Alpha2 the direction P₁ P₂ produced
1046        const Alpha1 = Math.abs(sinSqSigma) < Epsilon ? 0 : Math.atan2(cosU2*sinLambda,  cosU1*sinU2-sinU1*cosU2*cosLambda);
1047        const Alpha2 = Math.abs(sinSqSigma) < Epsilon ? Pi : Math.atan2(cosU1*sinLambda, -sinU1*cosU2+cosU1*sinU2*cosLambda);
1048
1049        return {
1050            distance:       s,
1051            initialBearing: Math.abs(s) < Epsilon ? NaN : giga.wrap360(giga.rad2deg(Alpha1)),
1052            finalBearing:   Math.abs(s) < Epsilon ? NaN : giga.wrap360(giga.rad2deg(Alpha2)),
1053            iterations:     iterations,
1054        };
1055    },
1056	
1057	getDistanceVincenty: function(startLat,startLong,endLat,endLong) {
1058		return giga.vincentyInverse(startLat,startLong,endLat,endLong).distance / 1000;
1059	},
1060	
1061	getDistanceVincenty3D: function(lat1, lon1, elev1, lat2, lon2, elev2) {
1062		// WGS84 ellipsoid parameters
1063		const a = 6378137.0; // Semi-major axis (equatorial radius) in meters
1064		const f = 1 / 298.257223563; // Flattening
1065		const b = a * (1 - f); // Semi-minor axis (polar radius)
1066
1067		// Convert degrees to radians
1068		const lat1Rad = lat1*giga.Pi180;
1069		const lat2Rad = lat2*giga.Pi180;
1070		const lon1Rad = lon1*giga.Pi180;
1071		const lon2Rad = lon2*giga.Pi180;
1072
1073		const L = lon2Rad - lon1Rad;
1074
1075		const U1 = Math.atan((1 - f) * Math.tan(lat1Rad));
1076		const U2 = Math.atan((1 - f) * Math.tan(lat2Rad));
1077
1078		const sinU1 = Math.sin(U1);
1079		const cosU1 = Math.cos(U1);
1080		const sinU2 = Math.sin(U2);
1081		const cosU2 = Math.cos(U2);
1082
1083		let lambda = L;
1084		let lambdaP;
1085		let iterLimit = 100;
1086		let cosSqAlpha, sinSigma, cos2SigmaM, cosSigma, sigma;
1087
1088		do {
1089			const sinLambda = Math.sin(lambda);
1090			const cosLambda = Math.cos(lambda);
1091
1092			sinSigma = Math.sqrt(
1093				(cosU2 * sinLambda) * (cosU2 * sinLambda) +
1094				(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) *
1095				(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda)
1096			);
1097
1098			if (sinSigma === 0) {
1099				return Math.abs(elev2 - elev1); // Co-incident points
1100			}
1101
1102			cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda;
1103			sigma = Math.atan2(sinSigma, cosSigma);
1104
1105			const sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma;
1106			cosSqAlpha = 1 - sinAlpha * sinAlpha;
1107
1108			cos2SigmaM = cosSigma - 2 * sinU1 * sinU2 / cosSqAlpha;
1109			if (isNaN(cos2SigmaM)) {
1110				cos2SigmaM = 0; // Equatorial line
1111			}
1112
1113			const C = f / 16 * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha));
1114
1115			lambdaP = lambda;
1116			lambda = L + (1 - C) * f * sinAlpha * (
1117				sigma + C * sinSigma * (
1118					cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM)
1119				)
1120			);
1121
1122		} while (Math.abs(lambda - lambdaP) > 1e-12 && --iterLimit > 0);
1123
1124		if (iterLimit === 0) {
1125			return NaN; // Formula failed to converge
1126		}
1127
1128		const uSq = cosSqAlpha * (a * a - b * b) / (b * b);
1129		const A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));
1130		const B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
1131
1132		const deltaSigma = B * sinSigma * (
1133			cos2SigmaM + B / 4 * (
1134				cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) -
1135				B / 6 * cos2SigmaM * (-3 + 4 * sinSigma * sinSigma) *
1136				(-3 + 4 * cos2SigmaM * cos2SigmaM)
1137			)
1138		);
1139
1140		const s = b * A * (sigma - deltaSigma);
1141
1142		// Calculate elevation difference
1143		const elevDiff = elev2 - elev1;
1144
1145		// Calculate 3D distance using Pythagorean theorem
1146		return Math.sqrt(s * s + elevDiff * elevDiff) / 1000;
1147	},
1148	
1149	getBearing: function(startLat,startLong,endLat,endLong) {
1150		startLat = giga.deg2rad(startLat);
1151		startLong = giga.deg2rad(startLong);
1152		endLat = giga.deg2rad(endLat);
1153		endLong = giga.deg2rad(endLong);
1154
1155		const y = Math.sin(endLong-startLong) * Math.cos(endLat);
1156		const x = Math.cos(startLat)*Math.sin(endLat) - Math.sin(startLat)*Math.cos(endLat)*Math.cos(endLong-startLong);
1157		return (giga.rad2deg(Math.atan2(y,x)) + 360) % 360; // in degrees
1158	},
1159	
1160	getBearingVincenty: function (startLat, startLng, endLat, endLng) {
1161		const startLatRad = giga.deg2rad(startLat), startLngRad = giga.deg2rad(startLng);
1162		const endLatRad = giga.deg2rad(endLat), endLngRad = giga.deg2rad(endLng);
1163
1164		// WGS84 ellipsoid parameters
1165		const a = 6378137; // Earth semi-major axis in meters
1166		const b = 6356752.3142; // Earth semi-minor axis in meters
1167
1168		// Flattening
1169		const f = (a - b) / a;
1170
1171		// Calculate auxiliary values
1172		const U1 = Math.atan((1 - f) * Math.tan(startLatRad));
1173		const U2 = Math.atan((1 - f) * Math.tan(endLatRad));
1174		const sinU1 = Math.sin(U1);
1175		const cosU1 = Math.cos(U1);
1176		const sinU2 = Math.sin(U2);
1177		const cosU2 = Math.cos(U2);
1178
1179		const L = endLngRad - startLngRad;
1180
1181		// Calculate the longitude difference on the ellipsoid
1182		let lambda = L;
1183		let iterations = 0;
1184		let maxIterations = 20;
1185		while (Math.abs(lambda) > 1e-12 && iterations < maxIterations) {
1186			const sinLambda = Math.sin(lambda);
1187			const cosLambda = Math.cos(lambda);
1188			const t1 = sinU1 * cosLambda;
1189			const t2 = sinU2 * sinLambda;
1190			const t3 = cosU1 * cosU2 * cosLambda - sinU1 * sinU2;
1191			const N = a / Math.sqrt(1 - f * sinU2 * sinU2);
1192			lambda -= (t1 - t2) / (N * Math.sin(N * (t3 + 1e-6)));
1193			iterations++;
1194		}
1195
1196		// Calculate the bearing in radians
1197		const A = Math.cos(U2) * Math.sin(lambda);
1198		const B = Math.cos(U1) * Math.sin(U2) - Math.sin(U1) * Math.cos(U2) * Math.cos(lambda);
1199		const bearingRad = Math.atan2(A, B);
1200
1201		// Convert bearing to degrees and adjust for north being 0°
1202		const bearingDeg = (giga.rad2deg(bearingRad) + 360) % 360;
1203
1204		return bearingDeg;
1205	},
1206
1207	getDistanceAndBearing: function(startLat, startLong, endLat, endLong) {
1208		return {
1209			distance: giga.getDistance(startLat, startLong, endLat, endLong),
1210			bearing: giga.getBearing(startLat, startLong, endLat, endLong)
1211		};
1212	},
1213	
1214	getAllBearings: function(lat, lon) {
1215		const oName = `bearings:${lat}:${lon}`;
1216		if (0 && storageAvailable('sessionStorage')) {
1217			if (!!sessionStorage.getItem(oName)) {
1218				return JSON.parse(sessionStorage.getItem(oName));
1219			}
1220		}
1221		const ret = [];
1222		hotspots.points.forEach( (p, n) => {
1223			if (hasValue(p.lat, p.lon)) {
1224				const dist = giga.getDistance(lat, lon, p.lat, p.lon);
1225				if (dist < 300)
1226					ret.push({
1227						id: p.id,
1228						kind: p.kind,
1229						elevation: p.elevation,
1230						bearing: giga.getBearing(lat, lon, p.lat, p.lon),
1231						distance: dist,
1232						q: p.quality
1233					});
1234			}
1235		});
1236		/* 
1237		if (storageAvailable('sessionStorage')) {
1238			sessionStorage.setItem(oName, JSON.stringify(ret));
1239		}
1240		*/
1241		return ret;
1242	},
1243	
1244	getElevationAngle: function(startLat,startLong,startElev, endLat,endLong,endElev) {
1245		// https://tchester.org/sgm/analysis/peaks/how_to_get_view_params.html
1246		const earthRadius = 6371009;
1247		const dist = 1000*giga.getDistance(startLat,startLong,endLat,endLong);
1248		const lambda = giga.rad2deg( Math.asin((endElev-startElev)/dist) - dist/(2*earthRadius) ); 
1249		return lambda;
1250	},
1251	
1252	getObjectsFromBounds: function(...args) {
1253		let lat1, lon1, lat2, lon2;
1254		let returnAll = false;
1255		if (args.length === 2 && Array.isArray(args[0]) && Array.isArray(args[1])) {
1256			[lat1, lon1, lat2, lon2] = [...args[0], ...args[1]];
1257		} else if (args.length === 4) {
1258			[lat1, lon1, lat2, lon2] = [...args];
1259		} else if (args.length === 1 && args[0] instanceof L.LatLngBounds) {
1260			lat1 = args[0].getSouth();
1261			lat2 = args[0].getNorth();
1262			lon1 = args[0].getWest();
1263			lon2 = args[0].getEast();
1264		} else if (args.length === 0) {
1265			returnAll = true;
1266		}
1267		if (lat2 < lat1) [lat1, lat2] = [lat2, lat1];
1268		if (lon2 < lon1) [lon1, lon2] = [lon2, lon1];
1269		const ret = [];
1270		hotspots.points.forEach( (p, n) => {
1271			if (hasValue(p.lat, p.lon)) {
1272				if (returnAll || (p.lat>=lat1 && p.lat<=lat2 && p.lon>=lon1 && p.lon<=lon2)) {
1273					ret.push(p);
1274				}
1275			}
1276		});
1277		return ret;
1278	},
1279	
1280	getObjectsFromBearing: function(lat,lon,azimuth,count,mindist,maxdist) {
1281		var bearings = giga.getAllBearings(lat,lon);
1282		mindist = isNumeric(mindist) ? mindist : 0.001; 
1283		maxdist = isNumeric(maxdist) ? maxdist : 250.0;
1284		count = count || 20;
1285		bearings = bearings.filter( e => e.distance>=+mindist && e.distance<=+maxdist );
1286		bearings.forEach( function(e) {
1287			e.originalDiff = e.diff = (e.bearing-azimuth);
1288			e.diff = giga.plusMinus180(e.diff);
1289		});
1290		return bearings.sort(function(a,b) { 
1291			return Math.abs(a.diff)-Math.abs(b.diff); 
1292		}).slice(0,count);
1293	},
1294	
1295	getEndPointHaversine: function(startLat,startLong,bearing,distance) {
1296		// φ is latitude, λ is longitude, θ is the bearing (clockwise from north), δ is the angular distance d/R; d being the distance travelled, R the earth’s radius
1297		startLat = giga.deg2rad(startLat);
1298		startLong = giga.deg2rad(startLong);
1299		bearing = giga.deg2rad(bearing);
1300		const dr = distance/6371009;
1301		const endLat = Math.asin( Math.sin(startLat)*Math.cos(dr) + Math.cos(startLat)*Math.sin(dr)*Math.cos(bearing) );
1302		const endLong = startLong + Math.atan2(Math.sin(bearing)*Math.sin(dr)*Math.cos(startLat), Math.cos(dr)-Math.sin(startLat)*Math.sin(endLat));
1303		
1304		// The longitude can be normalised to −180…+180 using (lon+540)%360-180
1305		return { lat: giga.rad2deg(endLat), lon: (giga.rad2deg(endLong)+540)%360-180 };
1306	},
1307	
1308	getEndPoint: function(startLat, startLon, bearing, distance) {
1309		return giga.lineOfSight.vincentyDirect(startLat, startLon, giga.deg2rad(bearing), distance);
1310	},
1311	
1312	getEndPoint2: function(startLat,startLong,bearing,distance) {
1313		// Vincenty's algorithm
1314		// https://gist.github.com/mathiasbynens/354587/4137c2350d7cee9b757444ef3a6f13a1f69c2abc
1315		const a = 6378137,
1316			b = 6356752.3142,
1317			f = 1 / 298.257223563, // WGS-84 ellipsoid
1318			s = distance,
1319			alpha1 = giga.deg2rad(bearing),
1320			sinAlpha1 = Math.sin(alpha1),
1321			cosAlpha1 = Math.cos(alpha1),
1322			tanU1 = (1 - f) * Math.tan(giga.deg2rad(startLat)),
1323			cosU1 = 1 / Math.sqrt((1 + tanU1 * tanU1)), sinU1 = tanU1 * cosU1,
1324			sigma1 = Math.atan2(tanU1, cosAlpha1),
1325			sinAlpha = cosU1 * sinAlpha1,
1326			cosSqAlpha = 1 - sinAlpha * sinAlpha,
1327			uSq = cosSqAlpha * (a * a - b * b) / (b * b),
1328			A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq))),
1329			B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
1330		var sigma = s / (b * A),
1331			sigmaP = 2 * Math.PI;
1332		while (Math.abs(sigma - sigmaP) > 1e-12) {
1333			var cos2SigmaM = Math.cos(2*sigma1 + sigma),
1334				sinSigma = Math.sin(sigma),
1335				cosSigma = Math.cos(sigma),
1336				deltaSigma = B * sinSigma * (cos2SigmaM + B / 4 * (cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) - B / 6 * cos2SigmaM * (-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM * cos2SigmaM)));
1337			sigmaP = sigma;
1338			sigma = s / (b * A) + deltaSigma;
1339		};
1340		var tmp = sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1,
1341			lat2 = Math.atan2(sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, (1 - f) * Math.sqrt(sinAlpha * sinAlpha + tmp * tmp)),
1342			lambda = Math.atan2(sinSigma * sinAlpha1, cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1),
1343			C = f / 16 * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha)),
1344			L = lambda - (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM))),
1345			revAz = Math.atan2(sinAlpha, -tmp); // final bearing
1346		return { lat: giga.rad2deg(lat2), lon: +startLong + giga.rad2deg(L) };
1347	},
1348	
1349	getBearingErrors: function(pan) {
1350		pan = pan || giga.currentPanorama.getID();
1351		const info = hotspots.panoramas[pan];
1352		const diffArr = [];
1353		
1354		for (const e of hotspots.data[pan]) {
1355			const w = e.getAttribute('wiki');
1356			if (hasValue(w)) {
1357				const p = hotspots.points.get(w);
1358				if (hasValue(p?.lat, p?.lon)) {
1359					const bearingOnPan = giga.hposToAzimuth(+e.getAttribute('ath'));
1360					const bearingReal = giga.getBearing(info.lat, info.lon, p.lat, p.lon);
1361					const diff = bearingReal - bearingOnPan;
1362					diffArr.push({wiki: w, diff: diff, bearingReal: bearingReal, bearingOnPan: bearingOnPan, ath: +e.getAttribute('ath')});
1363				}
1364			}
1365		}
1366	
1367		diffArr.sort( (a, b) => a.ath-b.ath );
1368		return diffArr;
1369	},
1370	
1371	minimizeErrors: function(pan) {
1372		pan = pan || giga.currentPanorama.getID();
1373		const allErr = giga.getBearingErrors(pan);
1374		const mapped = allErr.map( function(x) { return [x.ath,x.diff]; } );
1375		const err = mapped.map( function(x) { return x[1]; } );
1376		const result = regression.linear(mapped);
1377		const gradient = result.equation[0];
1378		const yIntercept = result.equation[1];		
1379		console.log('result:',result.string, '| count: '+err.length, '| min: '+Math.min(...err), '| max: '+Math.max(...err), '| mean: '+mean(err));
1380	},
1381	
1382	KML: {
1383		mimeType: 'application/vnd.google-earth.kml+xml',
1384		makeDocument: function(name, elements) {
1385			return '<'+'?xml version="1.0" encoding="UTF-8"'+'?>'+'<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:atom="http://www.w3.org/2005/Atom"> ' +
1386				'\n<Document>\n<name>'+name+'</name>\n' + giga.KML.makeDefaultStyle() + '\n' + elements.join('\n') + '\n</Document></kml>';
1387		},
1388		makePoint: function(o) {
1389			return '<Placemark> <name>' + escapeXML(o.name) + '</name> <styleUrl>#def</styleUrl> <LookAt> <longitude>' + o.lon + '</longitude> <latitude>' + o.lat + '</latitude> <altitude>0</altitude> <gx:altitudeMode>relativeToSeaFloor</gx:altitudeMode> </LookAt> ' +
1390				'<Point><coordinates>' + o.lon + ',' + o.lat + ',0</coordinates></Point> </Placemark>';
1391		},
1392		makeDefaultStyle: function() {
1393			return `<Style id="hl">\n\t<IconStyle><color>ff0000ff</color><scale>0.72</scale><Icon><href>https://maps.google.com/mapfiles/kml/shapes/placemark_circle_highlight.png</href></Icon></IconStyle>
1394			\t<ListStyle></ListStyle>\n\t<LabelStyle><scale>0.7</scale></LabelStyle>\n</Style>\n<Style id="df">\n\t<IconStyle><color>ff3300ff</color><scale>0.6</scale>
1395			<Icon><href>https://maps.google.com/mapfiles/kml/shapes/placemark_circle.png</href></Icon></IconStyle>\n\t<ListStyle></ListStyle>\n
1396			\t<LabelStyle><scale>0.6</scale></LabelStyle>\n</Style>\n<StyleMap id="def">\n\t<Pair><key>normal</key><styleUrl>#df</styleUrl></Pair><Pair><key>highlight</key><styleUrl>#hl</styleUrl></Pair>\n</StyleMap>\n`;
1397		},
1398		createPoint: function(o) {
1399			// name,lat,lon
1400			return giga.KML.makeDocument(o.name,[giga.KML.makePoint(o)]);
1401		},		
1402		createPoints: function(name,points) {
1403			// name,lat,lon
1404			return giga.KML.makeDocument(name,points.map( o => giga.KML.makePoint(o) ));
1405		},
1406		createLine: function(o) {
1407			// name,description,startLat,startLong,endLat,endLong,bearing,distance
1408			const range = o.distance || giga.getDistance(o.startLat,o.startLong,o.endLat,o.endLong);
1409			const heading = o.bearing || giga.getBearing(o.startLat,o.startLong,o.endLat,o.endLong);
1410			return '<'+'?xml version="1.0" encoding="UTF-8"'+'?>'+'<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:atom="http://www.w3.org/2005/Atom"> <Document> <name>'+o.name+'</name> <Style id="inline"> <LineStyle> <color>ff0000ff</color> <width>2</width> </LineStyle> </Style> <StyleMap id="inline0"> <Pair> <key>normal</key> <styleUrl>#inline1</styleUrl> </Pair> <Pair> <key>highlight</key> <styleUrl>#inline</styleUrl> </Pair> </StyleMap> <Style id="inline1"> <LineStyle> <color>ff0000ff</color> <width>2</width> </LineStyle> </Style> <LookAt> <longitude>'+o.endLong+'</longitude> <latitude>'+o.endLat+'</latitude> <altitude>0</altitude> <range>'+range+'</range> <tilt>88.0</tilt> <heading>'+heading+'</heading> <altitudeMode>relativeToGround</altitudeMode> </LookAt> <Placemark> <name>'+o.name+'</name> <styleUrl>#inline0</styleUrl> <LineString> <altitudeMode>
1410clampToGround</altitudeMode> <tessellate>1</tessellate> <coordinates>'+o.startLong+','+o.startLat+',0 '+o.endLong+','+o.endLat+',0 </coordinates> </LineString> </Placemark> </Document> </kml>';
1411		},
1412		createBearingLine: function(name,startLat,startLong,bearing,distance) {
1413			const endPoint = giga.getEndPoint(startLat,startLong,bearing,distance);
1414			return giga.KML.createLine({
1415				name: name,
1416				startLat: startLat,
1417				startLong: startLong,
1418				endLat: endPoint.lat,
1419				endLong: endPoint.lon,
1420				bearing: bearing,
1421				distance: distance
1422			});
1423		},
1424		saveAs: function(contents,filename) {
1425			const blob = new Blob([contents], {type: giga.KML.mimeType+";charset=utf-8"});
1426			saveAs(blob, filename);
1427		}
1428	},
1429	
1430	configCookies: {
1431		get: function(el) {
1432			return Cookies.get(el);
1433		},
1434		set: function(el, val) {
1435			Cookies.set(el,val,{expires:365,'SameSite':'Lax'})
1436		}
1437	},
1438	
1439	config: {
1440		get: function(el) {
1441			return localStorage.getItem(el);
1442		},
1443		set: function(el, val) {
1444			localStorage.setItem(el,val);
1445			return giga.config;
1446		}
1447	},
1448	
1449	addUnderscores: function(str) {
1450		return str.trim().replace(/ /g, '_');
1451	},
1452	
1453	escapeUnicode: function(str) {
1454		// stackoverflow.com/questions/21014476/javascript-convert-unicode-string-to-javascript-escape
1455		return str.replace(/[^\0-~]/g, function(ch) {
1456			return "\\u" + ("0000" + ch.charCodeAt().toString(16)).slice(-4);
1457		});
1458	},
1459	
1460	makePanoramaLink: function(text,p) {
1461		return `<a title="Przejdź do panoramy!" data-panorama="${p}" class="panorama-hotspot-link" href="view,${p}.html">${text}</a>`;
1462	},
1463	
1464	makeHotspotLink: function(text,point,p,h,v,z,currentBearing) {
1465		const ppan = hotspots.panoramas[p];
1466		const bearing = giga.hposToAzimuthGeneric(+h, +ppan.azimuthleft, +ppan.azimuthright, 1);
1467		let bearingDiffText = '';
1468		let diff = 999;
1469		let dist;
1470		
1471		if (hasValue(currentBearing)) {
1472			diff = Math.abs(currentBearing-bearing);
1473			if (diff>180) diff = 360-diff;
1474			bearingDiffText = `, różnica: ${diff.toFixed(2)}°`;
1475		}
1476		if (hasValue(point?.lat,point?.lon)) {
1477			dist = giga.getDistance(+ppan.lat, +ppan.lon, +point.lat, +point.lon);
1478		}
1479		return `<a title="Pokaż na panoramie! (${dist ? `odległość: ${dist.toFixed(2)} km; ` : ''}azymut ${bearing.toFixed(2)}°${bearingDiffText})" data-bearing="${bearing.toFixed(2)}" 
1480			data-bearingdiff="${diff.toFixed(2)}" data-distance="${dist ? dist.toFixed(2) : ''}" data-panorama="${p}" data-ath="${h}" data-atv="${v}" data-zoom="${z}" 
1481			class="panorama-hotspot-link" href="view,${p}.html#!${h},${v},${z}">${text}</a> 
1482			<a title="Pokaż podgląd w okienku" data-panorama="${p}" data-ath="${h}" data-atv="${v}" data-zoom="${z}" class="panorama-hotspot-link-mini" href="view,${p}.html#!${h},${v},${z}">&rect;</a>`;
1483
1484	},
1485	
1486	makeHotspotLinkOnly: function(text,p,h,v,z) {
1487		return '<a title="Pokaż na panoramie!" data-panorama="'+p+'" data-ath="'+h+'" data-atv="'+v+'" data-zoom="'+z+'" href="view,'+p+'.html#!'+h+','+v+','+z+'">'+text+'</a>';
1488	},
1489	
1490	lastPushStateObject: null,
1491	lastPopStateObject: null,
1492	
1493	stateObjectsEqual: function(o1,o2) {
1494		//console.log('stateObjectsEqual',o1,o2);
1495		for (const prop in o1) if (o1?.[prop]!=o2?.[prop]) return false;
1496		return true;
1497	},
1498	
1499	savePositionTimeout: null,
1500	
1501	savePosition: function(panid, hlookat, vlookat, vfov) {
1502		//console.log('savePosition!');
1503		//document.location.hash = panid + ',' + hlookat + ',' + vlookat + ',' + vfov; 
1504		if (window.onpopstate===null) {
1505			window.onpopstate = function(ev) {
1506				window.setTimeout( () => {
1507					var st = ev.state;
1508					console.log(st);
1509					if (giga.stateObjectsEqual(st,giga.lastPopStateObject)) {
1510						console.log('pop state object equal, going back once more');
1511						history.back();
1512					} else if (st!==null) {
1513						giga.lastPopStateObject = st;
1514						if (st.panid!=giga.currentPanorama.loadedID) {
1515							giga.loadPanorama(st.panid, st.hlookat, st.vlookat, st.vfov);
1516						} else {
1517							window.setTimeout( function() {
1518								k.call('lookto('+st.hlookat+', '+st.vlookat+', '+st.vfov+',smooth(500,-400,720));');
1519							}, 40);	
1520						}
1521					}
1522				}, 0);
1523			}
1524		}
1525		const newURL = `view,${panid}.html#!${hlookat},${vlookat},${vfov}`;
1526		var stateObject = { panid, hlookat, vlookat, vfov };
1527		//console.log(hlookat, vlookat, vfov, ' ', newURL, ' :: ', panid, ':', prevPanID );
1528		if (!giga.stateObjectsEqual(stateObject,giga.lastPushStateObject)) {
1529			giga.lastPushStateObject = stateObject;
1530			//console.log('pushing... ', newURL);
1531			history.pushState( stateObject, '', newURL);
1532		}
1533		giga.prevPanID = panid;
1534	},
1535	
1536	updateViewInfo: function() {
1537		const currentView = giga.getCurrentViewParameters();
1538		if (giga.compass.container) giga.compass.update(currentView.range,currentView.center);
1539		document.getElementById('view-range').innerHTML = currentView.range.toFixed(1)+'°';	
1540		document.getElementById('view-level').innerHTML = currentView.level>=0 ? currentView.level : '&nbsp;';	
1541		giga.currentPanorama.overlayMap.updateView(currentView);
1542		giga.peakFinder.updateView();
1543	},
1544	
1545	showInfoboxTab: function() {
1546		document.querySelector('#panel-navi .panel-tab-button[data-title="obiekty"]')?.click();
1547	},
1548	
1549	makePointLatLonLinks: function(point) {
1550		var ret = '';
1551		ret += ' <a class="" href="https://www.openstreetmap.org/?mlat='+point.lat+'&mlon='+point.lon+'&zoom=15" target="_blank"><abbr title="OpenStreetMap">OSM</abbr></a> '+
1552			' <a class="" href="https://www.opentopomap.org/?mlat='+point.lat+'&mlon='+point.lon+'&zoom=15#map=15/'+point.lat+'/'+point.lon+'" target="_blank"><abbr title="OpenTopoMap">OTM</abbr></a> '+
1553			' <a class="" href="https://www.geonames.org/maps/google_'+point.lat+'_'+point.lon+'.html" target="_blank"><abbr title="GeoNames">GEON</abbr></a> '+
1554			' <a class="" href="https://wikimapia.org/#lang=pl&lat='+point.lat+'&lon='+point.lon+'&z=14&m=w" target="_blank"><abbr title="Wikimapia">WIKM</abbr></a> '+
1555			' <a class="" href="https://www.google.com/maps?ll='+point.lat+','+point.lon+'&z=14" target="_blank"><abbr title="Google Maps">GM</abbr></a> '+
1556			' <a class="" href="https://earth.google.com/web/search/'+point.lat+','+point.lon+'/" target="_blank"><abbr title="Google Earth for the Web">GEW</abbr></a> ';
1557			
1558		if (point.lat>=49.0 && point.lat<=51.2 && point.lon>=14.07 && point.lon<=24.1) {
1559			// mapa turystyczna
1560			ret += ' <a class="" href="https://mapa-turystyczna.pl/#'+point.lat+'/'+point.lon+'/15" target="_blank"><abbr title="Mapa Turystyczna">MT</abbr></a> ';
1561
1562			// geoportal
1563			proj4.defs("EPSG:2180","+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
1564			const pl1992 = proj4("EPSG:2180").forward([+point.lon,+point.lat]);
1565			const xoffset = 500, yoffset = 350, bbox = (pl1992[0]-xoffset) +','+(pl1992[1]-yoffset) +','+(pl1992[0]+xoffset) +','+(pl1992[1]+yoffset);
1566			ret += ' <a class="" href="https://mapy.geoportal.gov.pl/imap/Imgp_2.html?bbox=' + bbox + '" target="_blank"><abbr title="geoportal">GEO</abbr></a> ';
1567			ret += ' <a class="" href="https://mapy.geoportal.gov.pl/mobile/?bbox=' + bbox + '" target="_blank"><abbr title="geoportal mobile">GEO/M</abbr></a> ';
1568			ret += ` <a class="" href="https://mapy.geoportal.gov.pl/imapnext/imap/index.html?moduleId=modulTur&mapview=${point.lat}%2C${point.lon}%2C10000s" target="_blank"><abbr title="geoportal imapNext">GEO/N</abbr></a> `;
1569
1570			// geoserwis GDOŚ
1571			ret += ' <a class="" href="https://geoserwis.gdos.gov.pl/mapy/?extent=' + bbox + '&addLayers=,GDOS-misc:mezoregiony&mapNr=2&styleMask=013013013013013013013013013013" target="_blank"><abbr title="geoserwis GDOŚ">GDOŚ</abbr></a> ';
1572			
1573			// regionalizacja 2018
1574			ret += ' <a class="" href="https://mapa.wirtualneszlaki.pl/krainy-geograficzne-polska#14/'+point.lat+'/'+point.lon+'/OSM_bw-mezoregiony" target="_blank"><abbr title="Krainy geograficzne Polski">REG</abbr></a> ';
1575		}
1576		
1577		if (point.country?.includes('PL') && point.range?.startsWith('Tatry')) {
1578			//console.log(point);
1579			const pl1992 = proj4("EPSG:3857").forward([+point.lon,+point.lat]);
1580			proj4.defs("EPSG:9700","+proj=geocent +ellps=GRS80 +units=m +no_defs +type=crs");
1581			const pl9700 = proj4("EPSG:9700").forward([+point.lon,+point.lat]);
1582			/* map types: 
1583			1 = model 3D, 17 = nmt, 18 = nmpt, 19 = QL HS RGB, 20 = QL HS CIR, 21 = CHM, 22 = zdjęcia ukośne, 23 = OpenStreetMap,
1584			ortofotomapy:
1585			2: 2020 - RGB, 3: 2020 - CIR, 4: 2018 - RGB, 5: 2017 - RGB, 6: 2017 - CIR, 
1586			7: 2015 - RGB, 8: 2015 - CIR, 9: 2013 - RGB, 10: 2013 - CIR, 11: 2012 - RGB, 
1587			12: 2012 - CIR, 13: 2009 - RGB, 14: 1994 - RGB, 15: 1977 - PAN, 16: 1955 - PAN
1588			*/
1589			const mapType = 2; 
1590			const tpnLink = `https://tpn.obliview.com/?d=0&l=-1&r=${mapType}&z=17&x=${pl1992[0]}&y=${pl1992[1]}&poz=${pl9700[0]},${pl9700[1]},4816182.96,-0.6129,-0.2233,-0.7580,-0.7122,-0.2595,0.6523,-0.3424,0.9396,-0.0000&k=orto&akLay=empty`;
1591			ret += ` <a class="" href="${tpnLink}" target="_blank"><abbr title="Geoportal TATRY">TPN</abbr></a> `;
1592		}
1593		
1594		if (point.lat>=47.645846 && point.lat<=49.714710 && point.lon>=16.661685 && point.lon<=22.620043 || point.country.includes('SK')) {
1595			/*
1596			ret += ` <a class="" href="https://mapy.hiking.sk/?y=${point.lat}&x=${point.lon}" target="_blank"><abbr title="mapy.hiking.sk">H</abbr></a>
1597				<a class="" href="https://zbgis.skgeodesy.sk/mapka/zakladna-mapa?bm=zbgis&z=14&c=${point.lon},${point.lat}#!" target="_blank"><abbr title="zbgis.skgeodesy.sk">SK</abbr></a>
1598				<a class="" href="https://zbgis.skgeodesy.sk/mapka/sk/teren/toc?bm=orto&z=19&c=${point.lon},${point.lat}&sc=n&pos=${point.lat},${point.lon},18" target="_blank"><abbr title="zbgis.skgeodesy.sk LIDAR DEM">SKL</abbr></a>
1599				<a class="" href="https://zbgis.skgeodesy.sk/mapka/sk/teren/export?pos=${point.lat}%2C${point.lon}%2C16" target="_blank"><abbr title="zbgis.skgeodesy.sk DMR export">ZEX</abbr></a>  
1600				<a class="" href="https://www.staremapy.sk/?zoom=14&lat=${point.lat}&lng=${point.lon}&map=sazp_retm25" target="_blank"><abbr title="StareMapy.sk">SM</abbr></a>
1601				<a class="" href="https://www.cykloserver.cz/cykloatlas/#pos=${point.lat}P${point.lon}P15@b=2" target="_blank"><abbr title="cykloserver.cz">CS</abbr></a>
1602			`;
1603			*/
1604			proj4.defs("EPSG:5514","+proj=krovak +lat_0=49.5 +lon_0=24.8333333333333 +alpha=30.2881397527778 +k=0.9999 +x_0=0 +y_0=0 +ellps=bessel +towgs84=485,169.5,483.8,7.786,4.398,4.103,0 +units=m +no_defs +type=crs");
1605			//proj4.defs("EPSG:5514","+proj=krovak +lat_0=49.5 +lon_0=24.8333333333333 +alpha=30.2881397527778 +k=0.9999 +x_0=0 +y_0=0 +ellps=bessel +towgs84=589,76,480,0,0,0,0 +units=m +no_defs +type=crs");
1606			const jtsk = proj4("EPSG:5514").forward([+point.lon,+point.lat]);
1607
1608			ret += ` <a class="" href="https://mapy.hiking.sk/?y=${point.lat}&x=${point.lon}" target="_blank"><abbr title="mapy.hiking.sk">H</abbr></a>
1609				<a class="" href="https://zbgis.skgeodesy.sk/mapka/sk/zakladna-mapa/toc?bm=zbgis&z=14&c=${point.lon},${point.lat}&pos=${point.lat},${point.lon},18" target="_blank"><abbr title="zbgis.skgeodesy.sk">SK</abbr></a>
1610				<a class="" href="https://zbgis.skgeodesy.sk/mapka/sk/teren/toc?bm=orto&z=19&c=${point.lon},${point.lat}&sc=n&pos=${point.lat},${point.lon},18" target="_blank"><abbr title="zbgis.skgeodesy.sk LIDAR DEM">SKL</abbr></a>
1611				<a class="" href="https://zbgis.skgeodesy.sk/mapka/sk/teren/export?pos=${point.lat}%2C${point.lon}%2C16" target="_blank"><abbr title="zbgis.skgeodesy.sk DMR export">ZEX</abbr></a>  
1612				<a class="" href="https://geoportal.gov.sk/maps/historical-maps/datasets?view=${Math.round(jtsk[0])},${Math.round(jtsk[1])},1.0&loc=${Math.round(jtsk[0])},${Math.round(jtsk[1])},100" target="_blank"><abbr title="geoportal.gov.sk">GSK</abbr></a>  
1613				<a class="" href="https://www.staremapy.sk/?zoom=14&lat=${point.lat}&lng=${point.lon}&map=sazp_retm25" target="_blank"><abbr title="StareMapy.sk">SM</abbr></a>
1614			`;
1615			
1616			// https://zbgis.skgeodesy.sk/mapka/sk/teren/toc?pos=49.105934,18.916214,17
1617		}
1618		
1619		ret += ` <a class="" href="https://mapy.com/fnc/v1/showmap?mapset=outdoor&center=${point.lon},${point.lat}&zoom=15&marker=true" target="_blank"><abbr title="mapy.com">MC</abbr></a> `;
1620		if (point.elevation) ret += ' <a class="" href="https://mapy.com/en/zakladni?'+makeParams({
1621			m3d: 1,
1622			x: point.lon,
1623			y: point.lat,
1624			z: 16,
1625			height: point.elevation,
1626			pitch: -15,
1627			yaw: 0,
1628			base: 'ophoto'
1629		})+'" target="_blank"><abbr title="mapy.com 3D">MC3D</abbr></a> ';
1630		
1631		ret += ' <a class="" href="https://gpxmapy.cz/index.html?' + makeParams({
1632			x1: point.lon,
1633			y1: point.lat,
1634			n1: point.title
1635		}) + '" target="_blank"><abbr title="gpxmapy.cz">GMC</abbr></a> ';
1636		
1637		ret += ' <a class="" href="https://web.locusmap.app/pl/?lat='+point.lat+'&lng='+point.lon+'&z=15" target="_blank"><abbr title="Locus Map">LO</abbr></a> ';
1638		ret += ` <a class="" href="https://www.cykloserver.cz/cykloatlas/#pos=${point.lat}P${point.lon}P15@b=2" target="_blank"><abbr title="cykloserver.cz">CS</abbr></a> `;
1639		
1640		// ČÚZK Geoprohlížeč 
1641		if (point.lat>=48.58 && point.lat<=51.06 && point.lon>
1641=12.09 && point.lon<=18.86 || point.country.includes('CZ')) {
1642			proj4.defs("EPSG:5514","+proj=krovak +lat_0=49.5 +lon_0=24.83333333333333 +alpha=30.28813972222222 +k=0.9999 +x_0=0 +y_0=0 +ellps=bessel +towgs84=570.8,85.7,462.8,4.998,1.587,5.261,3.56 +units=m +no_defs");
1643			const krovak = proj4("EPSG:5514").forward([+point.lon,+point.lat]), xy = krovak[0]+','+krovak[1];
1644			ret += ' <a class="" href="https://ags.cuzk.cz/geoprohlizec/?'+makeParams({ xy: xy })+'" target="_blank"><abbr title="ČÚZK Geoprohlížeč">ČÚZK</abbr></a> ';
1645			ret += ' <a class="" href="https://ags.cuzk.cz/geoprohlizec/mapa.html?'+makeParams({ xy: xy, name: encodeURIComponent(point.title), b: 'zm', p: '' })+'" target="_blank"><abbr title="ČÚZK Geoprohlížeč / mapa">Č/M</abbr></a> ';
1646			ret += ' <a class="" href="https://ags.cuzk.cz/geoprohlizec/mapa.html?'+makeParams({ xy: xy, name: encodeURIComponent(point.title), b: 'orto', p: '' })+'" target="_blank"><abbr title="ČÚZK Geoprohlížeč / orto">Č/O</abbr></a> ';
1647		}
1648		
1649		if (point.lat>=45.82 && point.lat<=47.81 && point.lon>=5.96 && point.lon<=10.49 || point.country.includes('CH')) {
1650			proj4.defs("EPSG:2056","+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs");
1651			const lv = proj4("EPSG:2056").forward([+point.lon,+point.lat]);
1652			ret += ' <a class="" href="https://map.geo.admin.ch/?lang=en&topic=ech&bgLayer=ch.swisstopo.pixelkarte-farbe&layers=ch.swisstopo.zeitreihen,ch.bfs.gebaeude_wohnungs_register,ch.bav.haltestellen-oev,ch.swisstopo.swisstlm3d-wanderwege&layers_visibility=false,false,false,false&layers_timestamp=18641231,,,&E='+(lv[0])+'&N='+(lv[1])+'&zoom=8&crosshair=marker" target="_blank"><abbr title="geo.admin.ch">CH</abbr></a> '
1653		}
1654		
1655		if (point.country.includes('DE')) {
1656			ret += ' <a class="" href="https://geoviewer.bgr.de/mapapps4/resources/apps/geoviewer/index.html?lang=en&center='+point.lon+','+point.lat+',4326&lod=14&basemap=+:wmts_topplus_web_open_141,-:worldtopo_142" target="_blank"><abbr title="BGR Geoportal">BGR</abbr></a> ';
1657		}
1658
1659		if (point.kind=='peak') {
1660			ret += ' <a class="" href="https://peakbagger.com/search.aspx?ss=&tid=R&pid=0&lat='+point.lat+'&lon='+point.lon+'&u=m" target="_blank"><abbr title="Peakbagger">PB</abbr></a> '+
1661				' <a class="" href="https://www.peakfinder.com/?'+makeParams({ lat: +point.lat, lng: +point.lon, name: point.id, ele: point.elevation ?? '' })+'" target="_blank"><abbr title="Peakfinder">PF</abbr></a> ' +
1662				' <a class="" href="https://peakery.com/map/#lat='+point.lat+'&lng='+point.lon+'&zoom=5&elev_min=0&elev_max=29500&prom_min=0&prom_max=29500&length_min=0&length_max=20&vertical_min=0&vertical_max=10000" target="_blank"><abbr title="Peakery">PR</abbr></a> ' +
1663				'';
1664
1665		}
1666		if (point.kind=='peak' || point.kind=='saddle') {
1667			ret += ' <a class="" href="'+giga.overpass.buildQuery(point)+'" target="_blank"><abbr title="Overpass query">OP</abbr></a> ' +
1668				' <a class="" href="'+giga.overpass.buildQuery(point,true)+'" target="_blank"><abbr title="Overpass query around">OP/a</abbr></a> ';
1669		}
1670		return ret;
1671	},
1672	
1673	hoverMapTimeout: null,
1674	
1675	hoverOut: function() {
1676		//giga.currentPanorama.overlayMap.removePoint();
1677		if (giga.hoverMapTimeout) {
1678			window.clearTimeout(giga.hoverMapTimeout);
1679			giga.hoverMapTimeout = null;
1680		}
1681	},
1682	
1683	showHover: function(hsname) {
1684		//console.log('showHover:'+hsname);
1685		const alreadyDisplayed = $('#infobox').data('hs')==hsname;
1686		
1687		$('#infobox').data('hs',hsname);
1688		giga.showInfoboxTab();
1689		
1690		const h = k.hotspot.getItem(hsname);
1691		const info = k.info;
1692		var point = {};
1693		var elev = rangeInfo = otherNamesText = '';
1694		var title = h?.title;
1695		
1696		if (hasValue(h.id) && hasValue(hotspots.points.get(h.id))) {
1697			point = hotspots.points.get(h.id);
1698		}
1699		const hasCoords = hasValue(point.lat,point.lon);
1700		if (hasCoords && giga.mapycom.isShown()) {
1701			//giga.currentPanorama.overlayMap.showPoint(point.lat,point.lon,point?.title);
1702			if (giga.hoverMapTimeout) window.clearTimeout(giga.hoverMapTimeout); 
1703			giga.hoverMapTimeout = window.setTimeout ( () => {
1704				giga.mapycom.flyToWithLine(point.lat,point.lon,+info.lat,+info.lon);
1705			}, 100);
1706		}
1707		if (alreadyDisplayed) return;
1708		
1709		//console.log(point);
1710		if (hasValue(point.title)) title = point.title;
1711		title = `<span data-name="${hsname}" class="hs">${title}</span>`;
1712		if (hasValue(point.elevation)) elev = ` (${point.elevation}&nbsp;m)`;
1713		if (hasValue(point.names)) {
1714			const uniqNames = [...new Set(point.names)];
1715			if (uniqNames.length>1) {
1716				otherNamesText = ' (' + uniqNames.slice(1).map( s => `<span title="">${s}</span>` ).join(', ') + ') ';
1717			}
1718		}
1719		var infotext = '<b class="hs-header">' + title + otherNamesText + elev + '</b>' + (!hasValue(point.id)?('&nbsp;<i title="brak obiektu &quot;'+(h.id.replace(/\"/g, '&quot;'))+'&quot; w bazie!" class="fas fa-exclamation-triangle warning"></i>'):'');
1720		var distances = [];
1721		const altDistH = hasValue(point.lat,point.lon) ? giga.getDistance(info.lat,info.lon,point.lat,point.lon) : '';
1722		if (hasValue(altDistH)) distances.push(altDistH.toFixed(1));
1723		if (distances.length>0) {
1724			infotext = `<abbr title="Odległość do obiektu: ${altDistH.toFixed(3)} km" class="distance">${distances.join('/')}&nbsp;km</abbr> ${infotext}`;
1725		}
1726		//console.log(point);
1727		if (point.q=='raw') infotext += ' <i class="fab fa-dev" title="Punkt niezweryfikowany!"></i>';
1728		$('#infobox').html(infotext+'<div id="wiki"></div><div id="futo"></div>').fadeIn('fast');
1729		
1730		if (giga.advanced) {
1731			var extraInfo = [];
1732			
1733			// współrzędne punktu
1734			if (hasCoords) {
1735				const openCode = OpenLocationCode.encode(point.lat,point.lon);
1736				const coordsDeg = dmsCoords(+point.lat,+point.lon);
1737				const src = giga.isPointVerified(point) ? ` <i title="źródło: ${point?.source}" class="far fa-thumbs-up"></i>` : '';
1738				extraInfo.push(`<div class="ind"><span class="download download-point-kml alt-copy clickable" data-copy="${coordsDeg}" 
1739					data-lat="${point.lat}" data-lon="${point.lon}" data-name="${point.wiki}"><i title="współrzędne &bull; ${openCode}" class="fas fa-fw fa-map-marker-alt"></i> 
1740					<span title="${coordsDeg}">${point.lat.toFixed(5)}, ${point.lon.toFixed(5)}</span></span> 
1741					<span class="open-mapy-cz clickable" data-lat="${point.lat}" data-lon="${point.lon}" 
1742					data-name="${point.names[0]}"><i title="Otwórz mapkę mapy.com" class="fas fa-map-marked-alt"></i></span>${src}<span id="hover-point-coords"></span></div>`);
1743				extraInfo.push(`<div class="ind"><i title="pokaż w innych serwisach" class="far fa-fw fa-map"></i> ${giga.makePointLatLonLinks(point)}</div>`);
1744			} else {
1745				const dd = `<div class="ind"><i class="fas fa-fw fa-search"></i> 
1746					<a class="" href="https://www.zrg/?query=${encodeURIComponent(h.title)}" target="_blank" title="Wyszukaj w serwisie OpenStreetMap">OSM</a> &middot; 
1747					<a class="" href="https://www.geonames.org/search.html?q=${encodeURIComponent(h.title)}" target="_blank" title="Wyszukaj w serwisie GeoNames">GeoNames</a> &middot; 
1748					<a class="" href="https://mapy.com/?q=${encodeURIComponent(h.title)}" target="_blank" title="Wyszukaj w serwisie mapy.com">Mapy.com</a>
1749				</div>`;
1750				extraInfo.push(dd);
1751			}
1752			if (hasValue(point.height)) {
1753				extraInfo.push(`<div class="ind"><i title="wysokość obiektu" class="fas fa-fw fa-long-arrow-alt-up"></i> ${point.height}&nbsp;m</div>`);
1754			}
1755			
1756			if (hasValue(point.futo) || (hasValue(point.range) && (point.range=='Tatry Wysokie' || point.range=='Tatry Bielskie'))) {
1757				let aa = $('<a/>').attr('href','#').addClass('futo-search').data('id',point.futo||'').data('name',point.title).html('szukaj w słowniku Endre Futó &raquo;');
1758				aa.one('click', function(ev) {
1759					ev.preventDefault();
1760					giga.futo.replace($(this));
1761				});
1762				$('#futo').append(aa);
1763			}
1764			
1765			// azymut punktu
1766			var bearingOnPan = giga.hposToAzimuth(+h.ath);
1767			var azimuthText = `<span class="download download-line-kml clickable standard" data-azimuth="${bearingOnPan}">${bearingOnPan.toFixed(2)}°</span>`;
1768			if (hasCoords) {
1769				const bearingReal = giga.getBearing(info.lat,info.lon,point.lat,point.lon);
1770				let bearingDiff = giga.plusMinus180(bearingOnPan - bearingReal);
1771				
1772				const vin = giga.vincentyInverse(info.lat,info.lon,point.lat,point.lon);
1773				const bearingRealVincentyInitial = vin.initialBearing;
1774				const bearingRealVincentyFinal = vin.finalBearing;
1775				
1776				const bearingRealVincenty = giga.getBearingVincenty(info.lat,info.lon,point.lat,point.lon);
1777				let bearingDiffVincenty = giga.plusMinus180(bearingOnPan - bearingRealVincenty);
1778				let prefix = '';
1779				let opacity = Math.abs(bearingDiff)<0.03 ? 0.3 : 1;
1780				if (Math.abs(bearingDiff)<0.005) {
1781					prefix = '<i title="różnica między azymutem na mapie i na zdjęciu" class="far fa-circle"></i>';
1782				} else if (bearingDiff>0) {
1783					prefix = `<i title="różnica między azymutem na mapie i na zdjęciu" class="fas fa-arrow-circle-left" style="opacity:${opacity}"></i>`;
1784				} else {
1785					prefix = `<i title="różnica między azymutem na mapie i na zdjęciu" class="fas fa-arrow-circle-right" style="opacity:${opacity}"></i>`;
1786				}
1787				prefix += '&nbsp;';
1788				azimuthText += ` (<i title="azymut wynikający z mapy" class="far fa-map"></i> 
1789					<span class="download download-line-kml clickable standard" data-azimuth="${bearingReal}">
1790					<span title="(V: ${bearingRealVincenty.toFixed(2)}...${bearingRealVincentyFinal.toFixed(2)}°)">${bearingReal.toFixed(2)}° (V: ${bearingRealVincenty.toFixed(2)}°)</span>, 
1791					<span title="${bearingDiff.toFixed(4)}°">${prefix}${Math.abs(bearingDiff).toFixed(2)}</span>°</span>)`;
1792				//azimuthText += ` (<i title="azymut wynikający z mapy" class="far fa-map"></i> <span class="download download-line-kml clickable standard" data-azimuth="${bearingReal}">${bearingReal.toFixed(2)}°, ${prefix}${Math.abs(bearingDiff).toFixed(2)}°</span>)`;
1793			}
1794			extraInfo.push(`<div class="ind"><i title="azymut" class="fas fa-fw fa-compass"></i> ${azimuthText}</div>`);
1795
1796			let profileText;
1797			//console.log(point);
1798			if (hasCoords && point.elevation) {
1799				const elevationAngle = giga.getElevationAngle(info.lat,info.lon,info.elevation, point.lat,point.lon,point.elevation);
1800				extraInfo.push(`<div class="ind"><i title="kąt pionowy" class="fas fa-fw fa-arrows-alt-v"></i> ${elevationAngle.toFixed(2)}°</div>`);
1801			}
1802			if (hasCoords) {
1803				profileText = document.createElement('span');
1804				profileText.classList.add('profile');
1805				profileText.innerHTML = ' profil&nbsp;&raquo;';
1806				profileText.setAttribute('title', 'oblicz i pokaż profil widzialności do tego punktu');
1807				profileText.addEventListener('click', async () => giga.lineOfSight.checkFromCurrent(point));
1808				const distHaversine = +giga.getDistance(info.lat,info.lon,point.lat,point.lon);
1809				const distVincenty = +giga.getDistanceVincenty(info.lat,info.lon,point.lat,point.lon);
1810				let dist3Dtext = '';
1811				if (point.elevation && info.elevation) {
1812					const distVincenty3D = +giga.getDistanceVincenty3D(info.lat,info.lon,+info.elevation,point.lat,point.lon,+point.elevation);
1813					dist3Dtext = `, <span title="odległość wyliczona metodą Vincenty'ego z uwzględnieniem wysokości">V3D: ${distVincenty3D.toFixed(3)} km</span> (${addSign(Math.round(1000*(distVincenty3D-distHaversine)))} m)`;
1814				}
1815				extraInfo.push(`<div class="ind"><i title="odległości" class="fas fa-fw fa-arrows-alt-h"></i> <span title="odległość wyliczona wzorem haversine">Hav: ${distHaversine.toFixed(3)} km</span>, 
1816					<span title="odległość wyliczona metodą Vincenty'ego">V: ${distVincenty.toFixed(3)} km</span> (${addSign(Math.round(1000*(distVincenty-distHaversine)))} m)${dist3Dtext}</div>`
1817				);
1818			}
1819			
1820			if (!!point.range) extraInfo.push(`<div class="range ind" title="rejon"><i class="fas fa-fw fa-map-signs"></i> <span class="clickable zoom-to-range">${point.range}</span></div>`);
1821			if (!!point.country && point.country.length>0) extraInfo.push(`<div class="country ind" title="państwo"><i class="far fa-fw fa-flag"></i> ${point.country.map(code => ISOCountryCode(code)).join(', ')}</div>`);
1822			if (!!point.peakbagger || !!point.summitpost || !!point.mapycz || !!point.osm) {
1823				let sitelinks = '';
1824				if (!!point.peakbagger) sitelinks += `PB <a target="_blank" href="https://peakbagger.com/peak.aspx?pid=${point.peakbagger}">${point.peakbagger}</a> `;
1825				if (!!point.summitpost) sitelinks += `SP <a target="_blank" href="https://www.summitpost.org/${kebabCase(h.title)}/${point.summitpost}">${point.summitpost}</a> `;
1826				if (!!point.osm) { 
1827					sitelinks += `OSM <a target="_blank" href="https://www.openstreetmap.org/node/${point.osm}">${point.osm}</a> `;
1828					if (point.country.includes('SK')) sitelinks += ` <a target="_blank" href="https://poi.oma.sk/n${point.osm}">OMA</a> `;
1829				}
1830				if (!!point.mapycz) {
1831					let [msource, mid] = point.mapycz.split(' ');
1832					sitelinks += `MAPY.COM <a title="Strona tego obiektu w serwisie mapy.com" target="_blank" href="https://mapy.com/en/turisticka?source=${msource}&id=${mid}">O</a> 
1833						<a title="Zdjęcia tego obiektu w serwisie mapy.com" target="_blank" href="https://mapy.com/en/turisticka?source=${msource}&id=${mid}&gallery=1">G</a> `;
1834				}
1835				if(sitelinks!='') extraInfo.push(`<div class="websites ind"><i class="fas fa-mountain"></i> ${sitelinks}</div>`);
1836			}
1837			
1838			var hlevel = '<span class="level-symbol" title="'+makeAttr(h.id)+'">'+(h.level||0)+'</span>';
1839			$('#infobox').append('<div class="infobox-section extra-info"><h2>Dodatkowe informacje '+hlevel+'</h2>'+extraInfo.join(' ')+'</div>');			
1840			if (profileText) document.querySelector('#infobox h2')?.appendChild(profileText);
1841			
1842			$('#hover-point-coords').html('');
1843
1844			if (hasCoords) {
1845				if (!!point.country && point?.country.includes('PL')) giga.fetchGugikElevation(point.lat,point.lon).then( t => {
1846					if (!!t && t!=='0' && $('#infobox').data('hs')==hsname) $('#hover-point-coords').append(' &nbsp;<span title="wysokość wg NMT GUGiK" data-title="'+makeAttr(point.id)+'" data-lat="'+point.lat+'" data-lon="'+point.lon+'" class="clickable nmt-refine">NMT '+t+'&nbsp;m</span>');
1847					return t;
1848				});
1849				if (!!point.country && !(point?.country.length==1 && point?.country[0]=='PL') && (point?.country.includes('SK') || point?.range.startsWith('Tatry'))) {
1850					giga.fetchZbgisElevation(point.lat,point.lon).then( t => {
1851						//if (t && t!=0 && t?.attributes['Pixel Value']!='NoData' && $('#infobox').data('hs')==hsname) {
1852						if (t && t?.attributes['Stretch.Pixel Value']!='NoData') {
1853							let spv = +(+(t?.attributes['Stretch.Pixel Value']).replace(',','.')).toFixed(1);
1854							if ($('#infobox').data('hs')==hsname && !isNaN(spv)) $('#hover-point-coords').append(' &nbsp;<span title="wysokość wg NMT ZBGIS ('+t?.layerName+')" data-title="'+makeAttr(point.id)+'" data-lat="'+point.lat+'" data-lon="'+point.lon+'" class="clickable zbgis-refine">ZBGIS '+spv+'&nbsp;m</span>');
1855						}
1856						return t;
1857					});
1858				}
1859				if (!!point.country && point.country.includes('CZ')) giga.fetchCuzkElevation(point.lat,point.lon).then( t => {
1860					if (!!t && t!='0' && t?.value>0 && $('#infobox').data('hs')==hsname) {
1861						$('#hover-point-coords').append(' &nbsp;<span title="wysokość wg NMT ČÚZK" data-title="'+makeAttr(point.id)+'" data-lat="'+point.lat+'" data-lon="'+point.lon+'" class="clickable cuzk-refine">ČÚZK '+ ((+t.value).toFixed(1)) +'&nbsp;m</span>');
1862					}
1863					return t;
1864				});
1865				if (!!point.country && point?.country.includes('CH') || point?.range.startsWith('Alpy')) giga.fetchSwissElevation(point.lat,point.lon).then( t => {
1866					if (!!t && t?.height!=0 && $('#infobox').data('hs')==hsname) $('#hover-point-coords').append(' &nbsp;<span title="wysokość wg Geoadmin.ch" data-title="'+makeAttr(point.id)+'" data-lat="'+point.lat+'" data-lon="'+point.lon+'" class="clickable swiss-refine">CH '+t.height+'&nbsp;m</span>');
1867					return t;
1868				});
1869			}
1870		}
1871		
1872		if (hasValue(point.id)) {
1873			const pansFromThere = hotspots.findPanoramasByLocation(point.id);
1874			if (pansFromThere.length>0) {
1875				var pansFromThereTexts = [];
1876				pansFromThere.forEach(function(e) {
1877					pansFromThereTexts.push('<div>'+giga.makePanoramaLink(hotspots.panoramas[e].title, e)+'</div>');
1878				});
1879				$('#infobox').append('<div class="infobox-section see-also"><h2>Panoramy z tego punktu ('+pansFromThere.length+')</h2>'+pansFromThereTexts.join(' ')+'</div>');
1880			}
1881		}
1882		
1883		if (hasValue(point.wiki)) {
1884			$('#wiki').html('<img src="img/loader30dark.svg" width="160" height="20">');
1885			giga.loadWiki('#wiki',point.wiki,point.wikilang);
1886		} else {
1887			const wikiPrompt = giga.advanced ? ' <a class="standard" target="_blank" href="https://pl.wikipedia.org/w/index.php?title='+(giga.addUnderscores(h.title))+'&action=edit&redlink=1">Utworzyć nowe</a>? '+
1888			'<a class="standard" target="_blank" href="https://www.google.pl/search?'+makeParams({q: `"${h.title}"`})+'">Wyguglać</a>?' : '';
1889			let descText = '';
1890			if (hasValue(h.description)) {
1891				descText = '<div class="description">'+h.description+'</div>';
1892			} else if (hasValue(point.description)) {
1893				descText = '<div class="description">'+point.description+'</div>';				
1894			}
1895			if (point.kind!='adhoc') {
1896				descText += '<span class="lighter">Prawdopodobnie brak takiego hasła w Wikipedii.'+wikiPrompt+'</span>';
1897			}
1898			document.getElementById('wiki').innerHTML = descText;
1899		}
1900		
1901		if (hasValue(point.id)) {
1902			const seeAlso = hotspots.findByAttr('i',point.id);
1903			const seeAlsoTexts = [];
1904			if (seeAlso.length>1) {
1905				let lastPan = '';
1906				seeAlso.forEach( function(e) {
1907					if (e.p != lastPan) {  // jezeli dwa razy na jednej pano, wez tylko pierwsze!
1908						lastPan = e.p;
1909						const ppan = hotspots.panoramas[e.p];
1910						const t = giga.makeHotspotLink(ppan.title, point, e.p, e.h, e.v, 0.02, bearingOnPan);
1911						let dif = '';
1912
1913						if (hasCoords && giga.advanced) {
1914							const bearingOnOtherPan = giga.hposToAzimuthGeneric(+e.h, +ppan.azimuthleft, +ppan.azimuthright, 1);
1915							const bearingOtherReal = giga.getBearing(+ppan.lat, +ppan.lon, point.lat, point.lon);
1916							let bearingOtherDiff = giga.plusMinus180(bearingOnOtherPan - bearingOtherReal);
1917							dif = '&nbsp;<small title="różnica między azymutem na panoramie a wyliczonym ze współrzędnych" class="'+giga.getBearingErrorClass(bearingOtherDiff)+'">'+(bearingOtherDiff>=0?'+':'')+bearingOtherDiff.toFixed(2)+'&deg;</small> ';
1918						}
1919
1920						if (e.p!=k.state.currentpanorama) seeAlsoTexts.push('<div class="panorama-hotspot-link-line">'+t+dif+'</div>');
1921					}
1922				});
1923				$('#infobox').append('<div class="infobox-section see-also other-panoramas"><h2>Inne panoramy z tym obiektem ('+seeAlsoTexts.length+')</h2>'+seeAlsoTexts.join(' ')+'</div>');
1924				giga.sortOtherPanoramas('bearingdiff',true);
1925			}
1926		}
1927		
1928	},
1929	
1930	sortOtherPanoramas: function(datafield, numeric) {
1931		numeric = numeric===true;
1932		const divs = $('.other-panoramas').find('div.panorama-hotspot-link-line').detach();
1933		[].sort.call(divs, function(a,b) {
1934			let a1 = $(a).find('.panorama-hotspot-link').data(datafield);
1935			let b1 = $(b).find('.panorama-hotspot-link').data(datafield);
1936			if (numeric) {
1937				a1 = +a1;
1938				b1 = +b1;
1939			}
1940			return a1==b1 ? 0 : ( a1>b1 ? 1 : -1 );
1941		}).appendTo($('.other-panoramas'));
1942	},
1943	
1944	showPointInfo: function(id) {
1945		var title = '', elev = '';
1946		var info = k.info;
1947		var h = k.hotspot.getItem('newhs');
1948		
1949		if ($('#infobox').data('hs')=='point:'+id) return;
1950		$('#infobox').data('hs','point:'+id);
1951		
1952		const point = hotspots.points.get(id) || {};
1953		
1954		giga.showInfoboxTab();
1955		
1956		if (hasValue(point.title)) title = point.title;
1957		title = '<span>'+title+'</span>';
1958		const  hasCoords = hasValue(point.lat, point.lon);
1959		if (hasValue(point.elevation)) elev = ' (' + point.elevation +'&nbsp;m)';
1960		var infotext = '<b class="hs-header">' + title + elev + '</b>';
1961		const altDist2 = hasCoords ? Math.round10(giga.getDistance(info.lat,info.lon,point.lat,point.lon),-1) : '';
1962		var distances = [];
1963		if (hasValue(altDist2)) distances.push(altDist2);
1964		if (distances.length>0) {
1965			infotext = `<abbr title="Odległość do obiektu" class="distance">${distances.join('/')}&nbsp;km</abbr> ${infotext}`;
1966		}
1967		if (point.q=='raw') infotext += ' <i class="fab fa-dev" title="Punkt niezweryfikowany!"></i>';
1968		$('#infobox').html(infotext+'<div id="wiki"></div>').fadeIn('fast');
1969		
1970		if (giga.advanced) {
1971			const extraInfo = [];
1972			
1973			// współrzędne punktu
1974			if (hasCoords) {
1975				const openCode = OpenLocationCode.encode(point.lat,point.lon);
1976				extraInfo.push(`<div><span class="download download-point-kml clickable" data-lat="${point.lat}" data-lon="${point.lon}" 
1977					data-name="${point.wiki}"><i title="współrzędne &bull; ${openCode}" class="fas fa-fw fa-map-marker-alt"></i> 
1978					${point.lat.toFixed(5)}, ${point.lon.toFixed(5)}</span> <span class="open-mapy-cz clickable" data-lat="${point.lat}" data-lon="${point.lon}" 
1979					data-name="${point.names[0]}"><i title="Otwórz mapkę mapy.com" class="fas fa-map-marked-alt"></i></span></div>`);
1980				extraInfo.push('<div><i title="pokaż w innych serwisach" class="far fa-fw fa-map"></i> '+ giga.makePointLatLonLinks(point) + '</div>');
1981			}
1982			if (hasValue(point.height)) {
1983				extraInfo.push(`<div class="ind"><i title="wysokość obiektu" class="fas fa-fw fa-long-arrow-alt-up"></i> ${point.height}&nbsp;m</div>`);
1984			}
1985
1986			let profileText;
1987			// azymut punktu
1988			if (hasValue(h)) {
1989				const bearingOnPan = giga.hposToAzimuth(+h.ath);
1990				var azimuthText = `<span class="download download-line-kml clickable standard" data-azimuth="${bearingOnPan}">${bearingOnPan.toFixed(2)}°</span>`;
1991				if (hasCoords) {
1992					const bearingReal = giga.getBearing(info.lat,info.lon,point.lat,point.lon);
1993					let prefix = '';
1994					if (Math.abs(bearingOnPan-bearingReal)<0.03) {
1995						prefix = '<i title="różnica między azymutem na mapie i na zdjęciu" class="far fa-circle"></i>';
1996					} else if ((bearingOnPan-bearingReal)>0) {
1997						prefix = '<i title="różnica między azymutem na mapie i na zdjęciu" class="fas fa-arrow-circle-left"></i>';
1998					} else {
1999						prefix = '<i title="różnica między azymutem na mapie i na zdjęciu" class="fas fa-arrow-circle-right"></i>';
2000					}
2001					prefix = prefix + '&nbsp;';
2002					//if (bearingOnPan-bearingReal>0) prefix = prefix+'+';
2003					azimuthText += ' (<i title="azymut wynikający z mapy" class="far fa-map"></i> <span class="download download-line-kml clickable standard" data-azimuth="'+bearingReal+'">'+bearingReal.toFixed(2)+'°, ' +
2004						prefix + '' +Math.abs(bearingOnPan-bearingReal).toFixed(2)+'°</span>)';
2005					profileText = document.createElement('span');
2006					profileText.classList.add('profile');
2007					profileText.innerHTML = ' profil &raquo;';
2008					profileText.setAttribute('title', 'oblicz i pokaż profil widzialności do tego punktu');
2009					profileText.addEventListener('click', async () => giga.lineOfSight.checkFromCurrent(point));
2010				}
2011				extraInfo.push('<div><i title="azymut" class="far fa-fw fa-compass"></i> '+azimuthText+'</div>');
2012				//console.log(point);
2013				if (hasCoords && point.elevation) {
2014					const elevationAngle = giga.getElevationAngle(info.lat,info.lon,info.elevation, point.lat,point.lon,point.elevation);
2015					extraInfo.push('<div><i title="kąt pionowy" class="fas fa-fw fa-arrows-alt-v"></i> '+elevationAngle.toFixed(2)+'°</div>');
2016				}
2017			}
2018			
2019			if (!!point.range) extraInfo.push('<div class="range" title="rejon"><i class="fas fa-fw fa-map-signs"></i> <span class="zoom-to-range">'+point.range+'</span></div>');				
2020			if (!!point.country && point.country.length>0) extraInfo.push('<div class="country" title="państwo"><i class="far fa-fw fa-flag"></i> '+point.country.map( code => ISOCountryCode(code)).join(', ')+'</div>');
2021			
2022			if (!!point.peakbagger || !!point.summitpost || !!point.mapycz || !!point.osm) {
2023				let sitelinks = '';
2024				if (!!point.peakbagger) sitelinks += `PB <a target="_blank" href="https://peakbagger.com/peak.aspx?pid=${point.peakbagger}">${point.peakbagger}</a> `;
2025				if (!!point.summitpost) sitelinks += `SP <a target="_blank" href="https://www.summitpost.org/${kebabCase(point.title)}/${point.summitpost}">${point.summitpost}</a> `;
2026				if (!!point.osm) { 
2027					sitelinks += `OSM <a target="_blank" href="https://www.openstreetmap.org/node/${point.osm}">${point.osm}</a> `;
2028					if (point.country.includes('SK')) sitelinks += ` <a target="_blank" href="https://poi.oma.sk/n${point.osm}">OMA</a> `;
2029				}
2030				if (!!point.mapycz) {
2031					let [msource, mid] = point.mapycz.split(' ');
2032					sitelinks += `MAPY.COM <a target="_blank" href="https://mapy.com/en/turisticka?source=${msource}&id=${mid}">O</a> <a target="_blank" href="https://mapy.com/en/turisticka?source=${msource}&id=${mid}&gallery=1">G</a> `;
2033				}
2034				if (sitelinks!='') extraInfo.push(`<div class="websites ind"><i class="fas fa-mountain"></i> ${sitelinks}</div>`);
2035			}
2036
2037			$('#infobox').append('<div class="infobox-section extra-info"><h2>Dodatkowe informacje</h2>'+extraInfo.join(' ')+'</div>');		
2038			if (profileText) document.querySelector('#infobox h2')?.appendChild(profileText);
2039			
2040		}
2041		
2042		if (hasValue(point.id)) {
2043			const pansFromThere = hotspots.findPanoramasByLocation(point.id);
2044			if (pansFromThere.length>0) {
2045				var pansFromThereTexts = [];
2046				pansFromThere.forEach(function(e) {
2047					pansFromThereTexts.push('<div>'+giga.makePanoramaLink(hotspots.panoramas[e].title, e)+'</div>');
2048				});
2049				$('#infobox').append('<div class="infobox-section see-also"><h2>Panoramy z tego punktu ('+pansFromThere.length+')</h2>'+pansFromThereTexts.join(' ')+'</div>');
2050			}
2051		}
2052		
2053		if (hasValue(point.wiki)) {
2054			$('#wiki').html('<img src="img/loader30dark.svg" width="160" height="20">');
2055			giga.loadWiki('#wiki',point.wiki,point?.wikilang);
2056		}
2057		
2058		if (hasValue(point.id)) {
2059			const seeAlso = hotspots.findByAttr('i',point.id);
2060			var seeAlsoTexts = [];
2061			if (seeAlso.length>0) {
2062				let lastPan = '';
2063				seeAlso.forEach( function(e) {
2064					if (e.p!=lastPan) {  // jezeli dwa razy na jednej pano, wez tylko pierwsze!
2065						lastPan = e.p;
2066						var t = giga.makeHotspotLink(hotspots.panoramas[e.p].title, point, e.p, e.h, e.v, 0.02);
2067					seeAlsoTexts.push(`<div class="panorama-hotspot-link-line">${t}</div>`);
2068					}
2069				});
2070			$('#infobox').append(`<div class="infobox-section see-also"><h2>Wszystkie panoramy z tym obiektem (${seeAlsoTexts.length})</h2>${seeAlsoTexts.join(' ')}</div>`);
2071			}
2072		}
2073	},
2074	
2075	getRangeLimits: function(func) {
2076		var hsList = giga.currentPanorama.getHotspotsList().filter(func).sort((a,b)=>+a.ath-b.ath);
2077		const ret = {};
2078		ret.left = +hsList[0].ath;
2079		ret.right = +hsList[hsList.length-1].ath;
2080		console.log(hsList.length + ': ' + hsList[0].id + ' ... ' + hsList[hsList.length-1].id);
2081		hsList = hsList.sort((a,b)=>+a.atv-b.atv);
2082		ret.top = +hsList[0].atv;
2083		ret.bottom = +hsList[hsList.length-1].atv;
2084		return ret;
2085	},
2086	
2087	closeInfobox: function () {
2088		$('#infobox').fadeOut('fast');
2089	},
2090	
2091	loadPanorama: function(p,h,v,z) {
2092		giga.loader.show();
2093		if (hasValue(h,v,z)) {
2094			k.call(`loadPanorama('${p}',${h},${v},${z});`);
2095		} else {
2096			k.call(`loadPanorama('${p}');`);
2097		}
2098	},
2099	
2100	onXMLComplete: function() {
2101		console.log('onXMLComplete...');
2102		giga.message('XML załadowany');
2103		if (!k.preview.type) {
2104			k.preview.type = 'CYLINDER';
2105			k.preview.url = `/giga/data/${giga.currentPanorama.getID()}/tiles/preview.jpg`;
2106		}
2107	},
2108	
2109	onLoadComplete: function() {
2110		console.log('onLoadComplete...');
2111		giga.message('dane załadowane');
2112		giga.loader.hide();
2113		giga.currentPanorama.recalculateLevels();
2114	},
2115
2116	onNewPano: function() {
2117		giga.search.disable();
2118
2119		const infobox = document.getElementById('infobox');
2120		infobox.innerHTML = 'Najedź kursorem na podpisany obiekt, żeby zobaczyć więcej informacji na jego temat...';
2121		infobox.style.display = '';
2122
2123		document.querySelectorAll('input.reset-on-new-pano, textarea.reset-on-new-pano').forEach(el => el.value = '');		
2124		giga.azimuthCorrection = 0;
2125		k.state.includebearingerror = false;
2126		giga.message('trwa ładowanie nowej panoramy');
2127		giga.mapycom.resetMap();
2128		giga.currentPanorama.hideBearingErrorsChart();
2129		giga.compass.updateBounds();
2130		giga.currentPanorama.updateInfo();
2131	},
2132	
2133	onRemovePano: function() {
2134		giga.currentPanorama.overlayMap.remove();
2135		giga.currentPanorama.multiSelect.clear();
2136		giga.lineOfSight.clear();
2137		giga.resetAllAdjustments();
2138		$('#addhotspot').html('').hide();
2139		giga.loader.show();
2140		giga.peakFinder.close();
2141		giga.collisions._labelCache  = null;
2142		giga.collisions._labelLevels = new WeakMap();
2143		$('#infobox').html('Ładuję nową panoramę...').show();
2144	},
2145	
2146	onMouseWheel: function(wheeldelta) {
2147	},
2148	
2149	altKeyDown: 0,
2150	ctrlKeyDown: 0,
2151	shiftKeyDown: 0,
2152	
2153	altKeyHandler: function(keyCode,state) {
2154		//console.log(keyCode,state);
2155		switch(keyCode) {
2156			case 'Shift': giga.shiftKeyDown = state; break;
2157			case 'Control': giga.ctrlKeyDown = state; break;
2158			case 'Alt': giga.altKeyDown = state; break;
2159		} 
2160	},
2161
2162	message: function(s) {
2163		console.log('MESSAGE: '+s);
2164		$('#head-message').html(s);
2165		return giga;
2166	},
2167	
2168	moveToHotspot: (hsname, showCircle = true) => {
2169		const h = k.hotspot.getItem(hsname);
2170		if (h) window.setTimeout( function() {
2171			let targetFOV = giga.currentPanorama.levels[+h.level];
2172			targetFOV = targetFOV ? (0.96 * targetFOV) : k.view.fov;
2173			targetFOV = k.view.fov;
2174			const { x: ath, y: atv } = h.ispoly ? h.getcenter() : { x: h.ath, y: h.atv };
2175			k.tween(k.view, {fov: targetFOV}, 1.0, "default");
2176			k.tween(k.view, {hlookat: ath, vlookat: atv}, 1.0, "default", () => { if (showCircle) giga.addMovingCircle(ath,atv); });
2177		}, 100);
2178	},
2179	
2180	addMovingCircle: (ath,atv) => {
2181		const circleName = `circleoverlay`;
2182		const c = k.hotspot.getItem(circleName);
2183		if (c) { 
2184			k.stoptween(c, '*');
2185			k.removehotspot(circleName);
2186		}
2187		console.log('adding hs: ',circleName);
2188		const circle = k.addhotspot(circleName);
2189		circle.loadstyle('circle');
2190		circle.ath = ath;
2191		circle.atv = atv;
2192		circle.title = 'circle-marker';
2193		circle.id = 'circle-marker-id' + Date.now();
2194 
2195		k.call(`
2196			tween(hotspot[${circleName}].scale, 1.3, 0.1, easeInOutQuad);
2197			wait(0.1);
2198			tween(hotspot[${circleName}].scale, 0.1, 0.8, easeInOutQuad);
2199			wait(0.6);
2200			tween(hotspot[${circleName}].alpha, 0.0, 0.2, linear);
2201			wait(0.2);
2202			jscall(console.log('removing hs: ${circleName}'));
2203			removehotspot(${circleName});
2204		`);
2205	},
2206	
2207	moveToHotspotAndClick: (hsname, showCircle = true) => {
2208		const h = k.hotspot.getItem(hsname);
2209		if (h) {
2210			console.log('moveToHotspotAndClick');
2211			k.call('hotspotOnOver('+h.labelname+','+hsname+');');
2212			giga.moveToHotspot(hsname, showCircle);
2213		}
2214	},
2215
2216	reformatDate: function(s,includeWeekday=true) {
2217		const y = s.substr(0,4), m = s.substr(5,2), d = s.substr(8,2);
2218		const date = new Date(y,m-1,d);
2219		return `${includeWeekday ? (giga.weekDays[date.getDay()]+' ') : ''}${d}.${m}.${y}`;
2220	},
2221	
2222	labelsStatus: -1,
2223	
2224	setLabelsStatus: function(n) {
2225		if (n==2 && giga.labelsStatus<=1) {
2226			//console.log('SHOW FOR THE FIRST TIME');
2227		}
2228		giga.labelsStatus = n;
2229	},
2230		
2231	weekDays: [
2232		'niedziela',
2233		'poniedziałek',
2234		'wtorek',
2235		'środa',
2236		'czwartek',
2237		'piątek',
2238		'sobota'
2239	],
2240	
2241	setTitle: function(t, g,extrainfo) {
2242		const info = k.info;
2243		document.title = info.title + ' :: gigapanorama';
2244		
2245		const maxlevel = k.image.level.getArray()[k.image.level.count-1];
2246		const iwidth = +maxlevel.tiledimagewidth;
2247		const iheight = +maxlevel.tiledimageheight;
2248		const mpix = Math.round(iwidth*iheight/(1000000));
2249
2250		var ude = '';
2251		var azimuthInfo = '' , refractionInfo = '';
2252		const udeZoom = info.zoom || 2;
2253		if (hasValue(info.azimuthleft,info.azimuthright,info.lat,info.lon)) {
2254			const udeParams = { 
2255				lat: info.lat,
2256				lon: info.lon,
2257				leftbound: info.azimuthleft,
2258				rightbound: info.azimuthright,
2259				zoom: udeZoom,
2260				alt: info.elevation || 'auto',
2261				range: info.range || 250,
2262				//alt: 'auto',
2263				title: info.title,
2264				description: 'wygenerowane via zbooy-giga'
2265			};
2266			ude = ' &nbsp;<a href="'+giga.generateUdeuschleURL(udeParams)+'" title="wygeneruj panoramę całości (zoom '+udeZoom+'x)" target="_blank">[&nbsp;&diams;&nbsp;]</a>';
2267			
2268			const azimuthRange = (360 + info.azimuthright - info.azimuthleft) % 360;
2269			// const lookAzimuth = (info.azimuthleft + (k.get('view.hlookatrange')/2 + k.get('view.hlookat'))*azimuthRange) % 360;
2270			
2271			azimuthInfo = `<div><i title="kąt widzenia" class="fas fa-fw fa-eye"></i> ${azimuthRange.toFixed(1)}&deg; (${info.azimuthleft}...${info.azimuthright}&deg;)</div>`;
2272			if (info.refraction) refractionInfo = `<div><i title="współczynnik refrakcji" class="fas fa-fw fa-sun"></i> ${info.refraction}</div>`;
2273		}
2274		
2275		$('header h1 .panorama-title').html(info.title);
2276		
2277		$('#panobox').html('').append(
2278			'<div class="infobox-section no-top-border">' +
2279				//'<h2>Lokalizacja</h2>' + 
2280				'<div title="miejsce wykonania"><i class="fas fa-fw fa-flag"></i> <b>' + info.location + '</b></div>' +
2281				'<div class="download download-point-kml clickable" data-lat="'+info.lat+'" data-lon="'+info.lon+'"><i title="współrzędne" class="fas fa-fw fa-map-marker-alt"></i>  <span title="' + 
2282				(formatcoords(+info.lat,+info.lon).format('DD MM ss X', {latLonSeparator: ', ',  decimalPlaces: 1})) + 
2283				'">' + info.lat + ', ' + info.lon + '</span></div>' +
2284				'<div><i title="wysokość n.p.m." class="fas fa-fw fa-level-up-alt"></i> <span title="">' + info.elevation + ' m</span></div>' +
2285				azimuthInfo +
2286				refractionInfo +
2287				'<!--<br><span class="toggle-overlay-map clickable noselect">Pokaż/ukryj mapę</span>-->' +
2288			'</div>'
2289		);
2290		
2291		const pansFromThere = hotspots.findPanoramasByLocation(info.location);
2292		if (pansFromThere.length>0) {
2293			let pansFromThereTexts = [];
2294			pansFromThere.forEach(function(e) {
2295				pansFromThereTexts.push(`<div>${giga.makePanoramaLink(hotspots.panoramas[e].title, e)}</div>`);
2296			});
2297			$('#panobox').append(`<div class="infobox-section see-also"><h2>Wszystkie panoramy z tego punktu (${pansFromThere.length})</h2>${pansFromThereTexts.join(' ')}</div>`);
2298		}
2299		
2300		$('#panobox').append(`
2301			<div class="infobox-section">
2302				<h2>Zdjęcie</h2>
2303
2304				<div class="ind">
2305					<i title="czas wykonania zdjęcia" class="far fa-fw fa-clock"></i>
2306					${giga.reformatDate(info.date)} ${info.time}
2307				</div>
2308
2309				<div class="ind">
2310					<i title="rozmiary zdjęcia" class="far fa-fw fa-image"></i>
2311					${iwidth}x${iheight} px (${mpix} mpix)
2312				</div>
2313
2314				${info.comments ? 
2315				`<div class="ind">
2316				 <i title="inne" class="fas fa-fw fa-camera"></i>
2317				 ${info.comments}
2318				</div>`
2319				: ''}
2320
2321				${typeof info.copyright !== 'undefined' ? 
2322				`<div class="ind">
2323				 <i title="copyright" class="far fa-fw fa-copyright"></i>
2324				 ${info.copyright}
2325				</div>`
2326				: ''}
2327
2328				${typeof info.published !== 'undefined' ? 
2329				`<div class="ind">
2330				 <i title="data opublikowania" class="far fa-fw fa-calendar"></i>
2331				 ${giga.reformatDate(info.published, false)}
2332				</div>`
2333				: ''}
2334			</div>`
2335		);		
2336		
2337		if (info.tags!='') {
2338			$('#panobox').append(
2339				'<div class="infobox-section">' +
2340					'<h2>Słowa kluczowe</h2>' + 
2341					info.tags.split(/, */).map( s => `<span class="clickable tag" data-results-container="tag-results-container" data-tag="${s}">${s}</span>` ).join(', ') +
2342					'<div id="tag-results-container" style="display:none"></div>' +
2343				'</div>'
2344			);
2345		}
2346		
2347		const farthest = giga.currentPanorama.getRichHotspotsList(true).filter(a => hasValue(a.distance)).sort( function(a,b) {return +a.distance==+b.distance ? 0 : (+a.distance<+b.distance ? 1 : -1); } );
2348		const maxdist = farthest.length > 0 ? `max. dystans: <span class="hs clickable" data-name="${farthest[0].name}">${farthest[0].title}, <b>${farthest[0].distance.toFixed(1)} km</b></span>` : '';
2349		const hsCount = giga.currentPanorama.getRichHotspotsList().length;
2350
2351		$('#panobox').append(
2352			'<div class="infobox-section">' +
2353				'<h2>Obiekty</h2>' + 
2354				'<div class="ind"><i class="fas fa-fw fa-thumbtack"></i> <b>' + hsCount + '</b>
2354 ' + odmien(hsCount,'opisany','opisane','opisanych') + '</div>' +
2355				(!!maxdist ? '<div class="ind"><i class="fas fa-fw fa-binoculars"></i> ' + maxdist + '</div>' : '') +
2356				'<div><br>Lista: ' +
2357				'<span class="list-all-objects clickable" data-sortby="title">alfabetycznie</span> &middot; ' +
2358				'<span class="list-all-objects clickable" data-sortby="bearing">od lewej</span> &middot; ' +
2359				'<span class="list-all-objects clickable" data-sortby="bearing-error">wg błędu</span> ' +
2360				'</div>' +
2361			'</div>'
2362		);
2363		
2364		$('.generate-pttk-code, .enable-vertical-angle-optimizer').toggle(giga.advanced);
2365		
2366		/*		
2367		'<div id="panogroup">' + info.group + '</div>' + 
2368			'<div id="extrainfo">' + 
2369			'<div><a target="_blank" title="Pokaż na OSM" href="https://www.openstreetmap.org/index.html?mlat=' + info.lat + '&mlon=' + info.lon + '&zoom=14">' + info.lat + ', ' + info.lon + '</a>' + ude + '</div>' +
2370			azimuthInfo +
2371			'<div></div>' +
2372			maxdist + 
2373			'</div>');
2374		*/
2375	},
2376	
2377	getCurrentViewParameters: function() {
2378		const info = k.info, view = k.view;
2379		const aleft = giga.hposToAzimuth(view.hlookat-view.hfov/2), aright = giga.hposToAzimuth(view.hlookat+view.hfov/2);
2380		const acenter = giga.hposToAzimuth(view.hlookat), arange = (720+aright-aleft) % 360;
2381		return {
2382			left: aleft,
2383			right: aright,
2384			center: acenter,
2385			range: arange,
2386			level: giga.currentPanorama.getLevel(view.fov)
2387		};
2388	},
2389	
2390	getCurrentVerticalAngle: function() {
2391		const info = k.info, image = k.image, view = k.view;
2392		const z = (view.vlookat + image.vfov/2) / image.vfov;
2393		const vRange = giga.currentPanorama.interpolateVerticalRange();
2394		const atop = info.azimuthtop || vRange.azimuthtop;
2395		const abot = info.azimuthbottom || vRange.azimuthbottom;
2396		return +atop + z*(+abot-atop);
2397	},
2398	
2399	generateCurrentViewMapyCz3D: function() {
2400		const info = k.info;
2401		const currentView = giga.getCurrentViewParameters();
2402		const params = { 
2403			x: info.lon,
2404			y: info.lat,
2405			yaw: Math.round(currentView.center - 180),
2406			pitch: -10,
2407			height: info.elevation + 2,
2408			base: 'ophoto',
2409			z: 16
2410		};
2411		window.open(giga.generateMapyCz3DURL(params));
2412	},
2413	
2414	generateCurrentViewUde: function() {
2415		const info = k.info;
2416		const currentView = giga.getCurrentViewParameters();
2417		const azoom = Math.min(30,Math.ceil(0.2*360/currentView.range));
2418		const udeParams = { 
2419			lat: info.lat,
2420			lon: info.lon,
2421			leftbound: currentView.left,
2422			rightbound: currentView.right,
2423			zoom: azoom,
2424			range: info.range || 250,
2425			alt: info.elevation || 'auto',
2426			title: info.title,
2427			description: 'azymut: '+currentView.center.toFixed(2)+'°'
2428		};
2429		if (info.refraction) udeParams.refraction = info.refraction;
2430		//console.log('ude params: ', udeParams);
2431		window.open(giga.generateUdeuschleURL(udeParams));
2432	},
2433	
2434	generateWholeViewUde: function() {
2435		const info = k.info;
2436		const udeParams = { 
2437			lat: info.lat,
2438			lon: info.lon,
2439			leftbound: info.azimuthleft,
2440			rightbound: info.azimuthright,
2441			zoom: info.zoom || 2,
2442			range: info.range || 250,
2443			alt: info.elevation || 'auto',
2444			//alt: 'auto',
2445			title: info.title,
2446			description: 'wygenerowane via zbooy-giga'
2447		};
2448		//console.log(udeParams);
2449		if (info.refraction) udeParams.refraction = info.refraction;
2450		window.open(giga.generateUdeuschleURL(udeParams));
2451	},
2452	
2453	generateWholeViewPeakfinder: function() {
2454		const info = k.info;
2455		const currentView = giga.getCurrentViewParameters();
2456		// https://www.peakfinder.com/about/resources/api/
2457		// lat, lng, name, ele, off, azi, alt, fov
2458		const pfParams = { 
2459			lat: info.lat,
2460			lng: info.lon,
2461			ele: info.elevation,
2462			azi: giga.hposToAzimuth(currentView.hlookat),
2463			fov: currentView.range.toFixed(1),
2464			date: info.date+'T'+info.time+':00Z',
2465			name: info.title
2466		};
2467		window.open(giga.generatePeakfinderURL(pfParams));
2468	},
2469	
2470	generateWholeViewPeakVisor: function() {
2471		const info = k.info;
2472		const currentView = giga.getCurrentViewParameters();
2473		const azi = giga.hposToAzimuth(currentView.hlookat);
2474		const pvParams = { 
2475			lat: info.lat,
2476			lng: info.lon,
2477			alt: info.elevation,
2478			yaw: azi<=180 ? azi.toFixed(2) : (azi-360).toFixed(2),
2479			hfov: currentView.range.toFixed(2)
2480		};
2481		window.open(giga.generatePeakVisorURL(pvParams));
2482	},
2483	
2484	/*
2485	savePointCoords(pointid, coords) {
2486		const info = {
2487			pointid: pointid,
2488			coords: coords
2489		};
2490		$.post("add-coords.html", info, function(data) {
2491		});
2492	},
2493	*/
2494	
2495	hposToAzimuth: function(hpos) {
2496		const info = k.info, view = k.view;
2497		hpos = hasValue(hpos) ? hpos : view.hlookat;
2498		const azimuthRange = (360 + (+info.azimuthright) - (+info.azimuthleft)) % 360;
2499		const lookAzimuth = (+info.azimuthleft + (view.hlookatrange/2 + hpos)*azimuthRange) % 360;
2500		return lookAzimuth;
2501	},
2502	
2503	azimuthToHpos: function(azimuth) {
2504		const info = k.info, view = k.view;
2505		const azimuthRange = (360 + (+info.azimuthright) - (+info.azimuthleft)) % 360;
2506		const hpos = ((360+azimuth-info.azimuthleft)%360)/azimuthRange - (view.hlookatrange/2);
2507		return hpos;
2508	},
2509	
2510	hposToAzimuthGeneric: function(hpos,azimuthleft,azimuthright,hlookatrange) {
2511		const azimuthRange = (360 + (+azimuthright) - (+azimuthleft)) % 360;
2512		const lookAzimuth = (+azimuthleft + (hlookatrange/2 + hpos)*azimuthRange) % 360;
2513		return lookAzimuth;
2514	},
2515	
2516	downloadViewLineKML: function(lookAzimuth,length) {
2517		length = length || 100000;  // distance in metres
2518		const info = k.info;
2519		const name = info.location + ' ' + lookAzimuth.toFixed(2) + '°';
2520		giga.KML.saveAs(giga.KML.createBearingLine(name,info.lat,info.lon,lookAzimuth,length),giga.currentPanorama.getID()+'-('+lookAzimuth.toFixed(2)+').kml');
2521	},
2522	
2523	downloadPointKML: function(name,lat,lon) {
2524		giga.KML.saveAs(giga.KML.createPoint({name:name||(lat+', '+lon),lat:lat,lon:lon}),(name||(lat+'-'+lon))+'.kml');
2525	},
2526	
2527	downloadAllPointsKML: function() {
2528		const points = [];
2529		hotspots.points.forEach( p => {
2530			if (hasValue(p.lat,p.lon)) {
2531				points.push({
2532					lat: p.lat,
2533					lon: p.lon,
2534					name: p.id
2535				});
2536			}
2537		});
2538		const dateStr = new Date().toLocaleDateString('sv').replace(/-/g, '');
2539		giga.KML.saveAs(giga.KML.createPoints(`points (${points.length})`, points), `points-${dateStr}-${points.length}.kml`);
2540	},
2541	
2542	getSuggested: function(lat, lon, azimuth, azimuthCorrection, count, mindist, maxdist, filterFunc) {
2543		const azimuth2  = (azimuth + azimuthCorrection + 360) % 360;
2544		const info      = k.info;
2545		const iLeft     = +info.azimuthleft;
2546		const iRight    = +info.azimuthright;
2547		const wrapRange = iLeft >= iRight;
2548
2549		const usedIds = new Set(giga.currentPanorama.getHotspotsList().map(h => h.id));
2550
2551		let objects = giga.getObjectsFromBearing(+info.lat, +info.lon, azimuth2, 500, mindist, maxdist);
2552
2553		objects.forEach(p => { p.used = usedIds.has(p.id); });
2554
2555		if (typeof filterFunc === 'function') objects = objects.filter(filterFunc);
2556
2557		return objects.slice(0, count).map(p => {
2558			const elev = p.elevation ? `${p.elevation}&nbsp;m` : '';
2559			const icon = p.diff < 0 ? '<i class="fas fa-fw fa-arrow-circle-left"></i>'
2560									: '<i class="fas fa-fw fa-arrow-circle-right"></i>';
2561
2562			let cls = 'suggested';
2563			if (p.used)  cls += ' already-used';
2564			if (p.q === 'raw') cls += ' raw';
2565			if (wrapRange ? (p.bearing > iRight && p.bearing < iLeft)
2566						  : (p.bearing < iLeft || p.bearing > iRight)) cls += ' out-of-range';
2567
2568			return `<div title="${makeAttr(transliterate(p.id))} (${p.distance.toFixed(3)} km)" class="${cls}" data-bearing="${p.bearing}" data-id="${p.id}">${icon}&nbsp;${Math.abs(p.diff).toFixed(2)}° <b>${p.id}</b> ${elev} (${p.distance.toFixed(1)}&nbsp;km)</div>`;
2569		});
2570	},
2571	
2572	getSuggestedOLD: function(lat,lon,azimuth,azimuthCorrection,count,mindist,maxdist,filterFunc) {
2573		const azimuth2 = ((azimuth+azimuthCorrection)+360) % 360;
2574		const hsList = giga.currentPanorama.getHotspotsList();
2575		var objects = giga.getObjectsFromBearing(+k.info.lat, +k.info.lon, azimuth2, 500, mindist, maxdist);
2576		objects.forEach( (p,i,a) => { 
2577			a[i].used = hsList.find( h => h.id==p.id )!==undefined;
2578		});
2579		if (typeof filterFunc=='function') {
2580			objects = objects.filter(filterFunc);
2581		}
2582		const info = k.info;
2583		const iLeft = +info.azimuthleft, iRight = +info.azimuthright;
2584		objects = objects.slice(0,count);
2585		return suggested = objects.map( function(p) {
2586			//console.log(p);
2587			const elev = !!p.elevation ? (p.elevation+'&nbsp;m') : '';
2588			const icon = p.diff<0 ? '<i class="fas fa-fw fa-arrow-circle-left"></i>' : '<i class="fas fa-fw fa-arrow-circle-right"></i>';
2589			const extraStyles = [];
2590			if (p.used) extraStyles.push('already-used');
2591			if (p.q==='raw') extraStyles.push('raw');
2592			if (iLeft<iRight) {
2593				if (p.bearing<iLeft || p.bearing>iRight) extraStyles.push('out-of-range');
2594			} else {
2595				if (p.bearing<iLeft && p.bearing>iRight) extraStyles.push('out-of-range');
2596			}
2597			return `<div title="${makeAttr(transliterate(p.id))} (${p.distance.toFixed(3)} km)" class="suggested ${extraStyles.join(' ')}" data-bearing="${p.bearing}" data-id="${p.id}">
2598				${icon}&nbsp;${Math.abs(p.diff).toFixed(2)}° 
2599				<b>${p.id}</b> ${elev} (${p.distance.toFixed(1)}&nbsp;km)
2600			</div>`;
2601		});
2602	},
2603	
2604	azimuthCorrection: 0.0,
2605	
2606	colorInput: function(el) {
2607		const vv = el.value.trim();
2608		el.classList.remove('name-found', 'name-not-found');
2609		if (vv!='') {
2610			el.classList.add( hasValue(hotspots.points.get(vv)) ? 'name-found' : 'name-not-found' );
2611		}
2612	},
2613	
2614	addNewHotspot: function(mousex,mousey, toh,tov) {
2615		// mousex, mousey nieuzywane??
2616		//console.log('addNewHotspot ',mousex,mousey, toh,tov);
2617		const azimuth = giga.hposToAzimuth(+toh);
2618		const corrStep = 0.01;
2619		const tohf = (+toh).toFixed(8), tovf = (+tov).toFixed(8);
2620		const atText = 'ath="'+tohf+'" atv="'+tovf+'"';
2621		const currentID = giga.currentPanorama.getID();
2622		// var suggested = giga.getSuggested(+k.get('info.lat'),+k.get('info.lon'),azimuth,9);
2623		/*
2624		$('#addhotspot').html(
2625			`<form><b>Dodaj nowy obiekt:</b> 
2626			<span id="dist-range"></span>
2627			<div id="azimuth-correction-container" title="Ustaw poprawkę azymutu">
2628				<span class="azimuth-correction" data-correction="-${corrStep}" title="skoryguj w lewo (Shift = x5)">&ndash;</span>
2629				<span class="azimuth-correction" data-correction="+${corrStep}" title="skoryguj w prawo (Shift = x5)">+</span>
2630			</div>
2631			<div id="hs-info-line">
2632				<span class="clickable copy" data-copy=\'${atText}\'><span id="newH">${tohf}</span>, <span id="newV">${tovf}</span></span>
2633				<span title="wygeneruj KML z linią w tym kierunku (${azimuth.toFixed(2)}
2633°)" class="download-line-kml clickable" data-azimuth="${azimuth}">&rarr;${azimuth.toFixed(2)}°</span>
2634			</div>
2635			<div class="typeahead__container"><div class="typeahead__field">
2636				<div class="typeahead__query"><input placeholder="wpisz nazwę obiektu" type="text" id="hsname" autocomplete="off" name="qa" class="js-typeahead"></div>
2637				<div class="typeahead__button" style="display:none"><button type="submit"><span class="typeahead__search-icon"></span></button></div>
2638			</div></div>
2639			<input type="hidden" id="hstitle" value=""><input placeholder="uwagi?" type="text" id="hsdesc" list="hsdesc-options">
2640			<datalist id="hsdesc-options"><option value="down"></option></datalist><span class="addhotspot-options">
2641				<input type="checkbox" id="label-narrow" title="przełam podpis jak najwęziej (F2)">
2642				<label for="label-narrow" title="przełam podpis jak najwęziej" style="position:relative;top:-2px">&#x2195;</label>
2643				<input type="checkbox" id="label-acronym" title="podpis z samych pierwszych liter (F4)">
2644				<label for="label-acronym" title="podpis z samych pierwszych liter" style="position:relative;top:-2px">XY</label>
2645			</span><br>
2646			<div id="slider-range-container" title="Ustaw minimalny i maksymalny dystans do obiektu"><div id="slider-range"></div></div>
2647			
2648			<div id="filter-points">
2649			<input type="checkbox" id="unused"${giga.config.get('filter-unused-' + currentID) == 1 ? ' checked' : ''}><label for="unused" title="pokazuj tylko punkty, których nie są oznaczone na bieżącej panoramie">niewykorzystane</label> 
2650			<input type="checkbox" id="peaks"${giga.config.get('filter-peaks-' + currentID) == 1 ? ' checked' : ''}><label for="peaks" title="pokazuj tylko szczyty">szczyty</label>
2651			<input type="checkbox" id="raw"${giga.config.get('filter-raw-' + currentID) == 1 ? ' checked' : ''}><label for="raw" title="uwzględniaj także dodatkowe, surowe zbiory danych o punktach">dodatkowe</label>
2652			</div>
2653			
2654			<div id="suggested-points" class=""></div><div id="send" class="active">wyślij</div></form>
2655			<div class="close" title="zamknij">X</div>`
2656		).fadeIn('fast');
2657		*/
2658		
2659		document.getElementById('addhotspot').innerHTML =
2660			`<form><b>Dodaj nowy obiekt:</b> 
2661			<span id="dist-range"></span>
2662			<div id="azimuth-correction-container" title="Ustaw poprawkę azymutu">
2663				<span class="azimuth-correction" data-correction="-${corrStep}" title="skoryguj w lewo (Shift = x5)">&ndash;</span>
2664				<span class="azimuth-correction" data-correction="+${corrStep}" title="skoryguj w prawo (Shift = x5)">+</span>
2665			</div>
2666			<div id="hs-info-line">
2667				<span class="clickable copy" data-copy='${atText}'><span id="newH">${tohf}</span>, <span id="newV">${tovf}</span></span>
2668				<span title="wygeneruj KML z linią w tym kierunku (${azimuth.toFixed(2)}°)" class="download-line-kml clickable" data-azimuth="${azimuth}">&rarr;${azimuth.toFixed(2)}°</span>
2669			</div>
2670			<div class="typeahead__container"><div class="typeahead__field">
2671				<div class="typeahead__query"><input placeholder="wpisz nazwę obiektu" type="text" id="hsname" autocomplete="off" name="qa" class="js-typeahead"></div>
2672				<div class="typeahead__button" style="display:none"><button type="submit"><span class="typeahead__search-icon"></span></button></div>
2673			</div></div>
2674			<input type="hidden" id="hstitle" value=""><input placeholder="uwagi?" type="text" id="hsdesc" list="hsdesc-options">
2675			<datalist id="hsdesc-options"><option value="down"></option></datalist><span class="addhotspot-options">
2676				<input type="checkbox" id="label-narrow" title="przełam podpis jak najwęziej (F2)">
2677				<label for="label-narrow" title="przełam podpis jak najwęziej" style="position:relative;top:-2px">&#x2195;</label>
2678				<input type="checkbox" id="label-acronym" title="podpis z samych pierwszych liter (F4)">
2679				<label for="label-acronym" title="podpis z samych pierwszych liter" style="position:relative;top:-2px">XY</label>
2680			</span><br>
2681			<div id="slider-range-container" title="Ustaw minimalny i maksymalny dystans do obiektu"><div id="slider-range"></div></div>
2682
2683			<div id="filter-points">
2684				<input type="checkbox" id="unused"${giga.config.get('filter-unused-' + currentID) == 1 ? ' checked' : ''}><label for="unused" title="pokazuj tylko punkty, których nie są oznaczone na bieżącej panoramie">niewykorzystane</label> 
2685				<input type="checkbox" id="peaks"${giga.config.get('filter-peaks-' + currentID) == 1 ? ' checked' : ''}><label for="peaks" title="pokazuj tylko szczyty">szczyty</label>
2686				<input type="checkbox" id="raw"${giga.config.get('filter-raw-' + currentID) == 1 ? ' checked' : ''}><label for="raw" title="uwzględniaj także dodatkowe, surowe zbiory danych o punktach">dodatkowe</label>
2687			</div>
2688
2689			<div id="suggested-points" class=""></div><div id="send" class="active">wyślij</div></form>
2690			<div class="close" title="zamknij">X</div>
2691		`;
2692		
2693		// check if visible, move if outside the viewport
2694		const hswin = document.getElementById('addhotspot'), rect = hswin.getBoundingClientRect();
2695		if (rect.left<0 || rect.left>=window.innerWidth-20) {
2696			hswin.style.left = '';
2697			hswin.style.right = '20px';
2698		}
2699		if (rect.top<0 || rect.top>=window.innerHeight-20) {
2700			hswin.style.top = '';
2701			hswin.style.bottom = '20px';
2702		}
2703
2704		fadeIn(hswin, 100);
2705
2706		const correctAzimuth = function(corr) {
2707			giga.azimuthCorrection += +corr;
2708			const values = $("#slider-range").slider("option", "values");
2709			makeSlider(azimuth, values[0], values[1]);
2710		}
2711		const setAzimuth = function(azimuthCorr) {
2712			giga.azimuthCorrection = azimuthCorr;
2713			const values = $("#slider-range").slider("option", "values");
2714			makeSlider(azimuth, values[0], values[1]);
2715		}
2716		
2717		onWheelDirection(document.getElementById('azimuth-correction-container'), 
2718			(direction, e) => {
2719				correctAzimuth(direction=='up' ? 0.01 : -0.01);
2720			}
2721		);
2722	
2723		var makeSlider = function(azimuth,min,max) {
2724			const makeFilter = function(p) {
2725				if (p.used && $('#unused').prop('checked')) return false;
2726				if (p.kind!='peak' && $('#peaks').prop('checked')) return false;
2727				if (p.q=='raw' && !$('#raw').prop('checked')) return false;
2728				return true;
2729			}		
2730			//console.log('azimuthCorrection',giga.azimuthCorrection);
2731			
2732			const distRange = document.getElementById('dist-range');
2733			distRange.textContent = ` (${min}...${max} km)`;
2734			const span = document.createElement('span');
2735			span.className = 'azimuth-correction-reset';
2736			span.textContent = ` ${giga.azimuthCorrection > 0 ? '+' : ''}${giga.azimuthCorrection.toFixed(2)}°`;
2737			if (+giga.azimuthCorrection.toFixed(2) == 0) span.style.opacity = 0;
2738			distRange.appendChild(span);
2739			span.addEventListener('click', () => setAzimuth(0.0));
2740			// ****
2741			let startingCorrection = giga.azimuthCorrection;
2742			onRightClickDrag(span, {
2743				throttleMs: 10,
2744				onStart: () => {
2745					console.log('drag start');
2746					startingCorrection = giga.azimuthCorrection;
2747				},
2748				onDrag: ({ dx, dy, totalDx, totalDy, x, y }) => {
2749					//console.log(dx,dy);
2750					const pixelsPerUnit = 60;
2751					const correction = Math.round(totalDx / pixelsPerUnit) / 100;
2752					if (giga.azimuthCorrection != startingCorrection + correction) setAzimuth(startingCorrection + correction);
2753				}
2754			});
2755			onWheelDirection(span, 
2756				(direction, e) => {
2757					correctAzimuth(direction=='up' ? 0.01 : -0.01);
2758				}
2759			);
2760			
2761			document.getElementById('suggested-points').innerHTML = giga.getSuggested(+k.info.lat,+k.info.lon,azimuth,giga.azimuthCorrection,9,min,max,makeFilter).join('\n');
2762			$("#suggested-points .suggested").on('mouseover', function() {
2763				const corrAz = (360 + $(this).data('bearing') - giga.azimuthCorrection) % 360;
2764				k.call('addAzimuthLine('+corrAz+')');
2765				const pp = hotspots.points.get($(this).data('id'));
2766				if (hasValue(pp.lat,pp.lon) && giga.mapycom.isShown()) {
2767					if (giga.mapycom.timeout) window.clearTimeout(giga.mapycom.timeout);
2768					giga.mapycom.timeout = window.setTimeout( () => {
2769						giga.mapycom.flyToWithLine(+pp.lat,+pp.lon, +k.info.lat,+k.info.lon);
2770						giga.currentPanorama.overlayMap.showPoint(+pp.lat,+pp.lon,pp.title);
2771					}, 300);
2772				}
2773			}).on('mouseout', function() {
2774				k.call('removeAzimuthLine()');
2775				giga.currentPanorama.overlayMap.removePoint();
2776				if (giga.mapycom.timeout) window.clearTimeout(giga.mapycom.timeout);
2777			}).on('click','.fa-fw', function(ev) {
2778				ev.stopImmediatePropagation();
2779				ev.preventDefault();
2780				const par = $(this).parents('.suggested').first();
2781				const pp = hotspots.points.get(par.data('id'));
2782				const corrAz = (360 + par.data('bearing') - giga.azimuthCorrection) % 360;
2783				const corrHpos = giga.azimuthToHpos(corrAz);
2784				k.call('moveto('+corrHpos+',get(view.vlookat))');
2785				if (hasValue(pp.lat,pp.lon)) {
2786					giga.currentPanorama.overlayMap.center(+pp.lat,+pp.lon);
2787				}
2788			});
2789			giga.config.set('range-min-'+giga.currentPanorama.getID(), min);
2790			giga.config.set('range-max-'+giga.currentPanorama.getID(), max);
2791		}
2792		
2793		$('#filter-points input').on('change', function(e) { 
2794			const values = $("#slider-range").slider("option", "values");
2795			$("#slider-range").slider("values", [values[0],values[1]]);
2796			giga.config.set('filter-unused-'+giga.currentPanorama.getID(), $('#unused').prop('checked') ? 1 : 0);
2797			giga.config.set('filter-peaks-'+giga.currentPanorama.getID(), $('#peaks').prop('checked') ? 1 : 0);
2798			giga.config.set('filter-raw-'+giga.currentPanorama.getID(), $('#raw').prop('checked') ? 1 : 0);
2799		});
2800		
2801		$("#slider-range").slider({
2802			range: true,
2803			min: 0,
2804			max: 250,
2805			values: [0, 250],
2806			change: function(event, ui) {
2807				makeSlider(azimuth,ui.values[0],ui.values[1]);
2808			},
2809			slide: function(event, ui) {
2810				makeSlider(azimuth,ui.values[0],ui.values[1]);
2811			}
2812		});
2813		const currMin = isNumeric(giga.config.get('range-min-'+giga.currentPanorama.getID())) ? +giga.config.get('range-min-'+giga.currentPanorama.getID()) : 0;
2814		const currMax = isNumeric(giga.config.get('range-max-'+giga.currentPanorama.getID())) ? +giga.config.get('range-max-'+giga.currentPanorama.getID()) : 250;
2815		$("#slider-range").slider("values", [currMin,currMax]);
2816		
2817		$(".azimuth-correction").on('click', function(e) {
2818			correctAzimuth(+$(this).data('correction')*(e.shiftKey?5:1));
2819		});
2820		const inputEl = document.getElementById('hsname');
2821		inputEl.focus();
2822		$('#addhotspot form').on('keydown', 'input', function(e) { 
2823			if (e.which == 13) { //enter
2824				$('#addhotspot #send').click();
2825				$('#addhotspot form').off('keydown', 'input');
2826			} else if (e.which == 27) {  //esc
2827				$('#addhotspot .close').click();
2828				$('#addhotspot form').off('keydown', 'input');
2829			} else {
2830				$('input#hstitle').val('');
2831			}
2832		});
2833		/*
2834		$('input#hsname').on('input propertychange paste', function() {
2835			giga.colorInput('input#hsname');
2836		});
2837		*/
2838		['input', 'propertychange', 'change', 'paste'].forEach(ev => {
2839			inputEl.addEventListener(ev, () => {
2840				giga.colorInput(inputEl);
2841			});
2842		});
2843
2844		
2845		// typeahead setup
2846		//console.log('$.typeahead starting...');
2847		$.typeahead({
2848			input: 'input#hsname',
2849			minLength: 2,
2850			maxItem: 8,
2851			accent: {
2852				from: 'ãàáäâẽèéëêìíïîõòóöôùúüûñçąćęłńóśżźčšžňďšáéíýóúŕĺôľäöěřů',
2853				to:   'aaaaaeeeeeiiiiooooouuuuncacelnoszzcszndsaeiyourlolaoeru'
2854			},
2855			display: ["id"],
2856			order: "asc",
2857			source: {
2858				data: hotspots.getAllNames()
2859			},
2860			template: function (query, item) {
2861				//console.log(query, item);
2862				const info = k.info;
2863				const point = hotspots.points.get(item.id);
2864				var template = '<span>{{id}}</span>';
2865				if (hasValue(point)) {
2866					//if (hasValue(point.elevation)) template += ` <span class="ele">${point.elevation}</span>`;
2867					if (hasValue(point.lat,point.lon)) {
2868						let extraStyles = ['dist'];
2869						const {distance, bearing} = giga.getDistanceAndBearing(info.lat,info.lon,point.lat,point.lon);
2870						const iLeft = +info.azimuthleft, iRight = +info.azimuthright;
2871						if (iLeft<iRight) {
2872							if (bearing<iLeft || bearing>iRight) extraStyles.push('out-of-range');
2873						} else {
2874							if (bearing<iLeft && bearing>iRight) extraStyles.push('out-of-range');
2875						}
2876						template += ` <span class="${extraStyles.join(' ')}">${distance.toFixed(1)} km</span>`;
2877					}	
2878				}
2879				template = `<span>${template}</span>`;
2880				return template;
2881			},
2882			callback: {
2883				onClickBefore: function (node, a, item, event) {
2884					//console.log(node, a, item, event);
2885					if (event.ctrlKey) {
2886						event.stopImmediatePropagation();
2887						event.preventDefault();
2888						// move panorama to correct azimuth
2889						const point = hotspots.points.get(item.id);
2890						if (hasValue(point.lat,point.lon)) {
2891							const corrAz = (360 + giga.getBearing(k.info.lat,k.info.lon,point.lat,point.lon) - giga.azimuthCorrection) % 360;
2892							const corrHpos = giga.azimuthToHpos(corrAz);
2893							k.call(`moveto(${corrHpos},get(view.vlookat))`);
2894							giga.currentPanorama.overlayMap.center(+point.lat,+point.lon);
2895							k.call('addAzimuthLine('+corrAz+')');
2896							window.setTimeout(() => {
2897								k.call('removeAzimuthLine()');
2898							},2000)
2899						}
2900						giga.showPointInfo(item.id);
2901					}
2902				},
2903				onMouseEnter: function (node, a, item, event) {
2904				},
2905				onMouseLeave: function (node, a, item, event) {
2906				},
2907				onClickAfter: function (node, a, item, event) {
2908					const xtitle = hotspots.points.get(item.id).title;
2909					$('input#hstitle').val(xtitle);
2910					giga.colorInput(inputEl);
2911				},
2912				onSubmit: function (node, form, item, event) {
2913					event.preventDefault();
2914				}
2915			}
2916		});
2917		
2918	},
2919	
2920	removeNewHotspot: function() {
2921		k.call('removehotspot(newhs);');
2922	},
2923	
2924	changeLabelFontSize: (step) => {
2925		const rule = getCssRule('.labelstyle');
2926		if (rule) {
2927			const current = parseFloat(rule.style.fontSize) || 11;
2928			const next = Math.round(Math.max(1, current + step));
2929			rule.style.fontSize = next + 'px';
2930			k.events.dispatch('onviewchange');
2931			return next;
2932		}
2933		return undefined;
2934	},
2935
2936	onReadyHandler: function(krpano) {
2937		k = krpano;
2938	},
2939	
2940	keyDownHandler: function(event) {
2941		//console.log(event);
2942		const keycode = event.key;
2943		const showAutolevelsMessage = () => {
2944			showScreenMessage(`${giga.autolevels.getMethodName()} ${Math.round(giga.autolevels.getStrength()*100)}%`, {category: 'autolevels'});
2945		}
2946		switch (keycode) {
2947			case 'Alt':
2948			case 'Control':
2949			case 'Shift':
2950				giga.altKeyHandler(keycode,1);
2951				break;
2952
2953			case 'PageUp':  // page up
2954				k.call('stepZoom(+1)');
2955				break;
2956			case 'PageDown':  // page down
2957				k.call('stepZoom(-1)');
2958				break;
2959			case 'End':  // end
2960				k.call('moveToRightEdge()');
2961				break;
2962			case 'Home':  // home
2963				k.call('moveToLeftEdge()');
2964				break;
2965				
2966 			case 'b':  // b
2967 			case 'B':  // b
2968				if (giga.advanced) {
2969					if (typeof k.state.includebearingerror=='undefined' || k.state.includebearingerror=='false') k.state.includebearingerror = false;
2970					k.state.includebearingerror = !k.state.includebearingerror;
2971					if (k.state.includebearingerror) {
2972						showScreenMessage(`pokaż atrybuty: błąd kątowy`, {category: 'bearingerrors'});
2973					} else {
2974						showScreenMessage(`ukryj atrybuty: błąd kątowy`, {category: 'bearingerrors'});
2975					}
2976					giga.currentPanorama.generateLabels( {includeBearingError: k.state.includebearingerror} ); 
2977				}
2978				break;
2979 			case 'c':  // c
2980				if (event.ctrlKey) giga.copySnapshot(); 
2981				break;
2982			case 'n':  // n
2983				showScreenMessage(`czcionka: ${giga.changeLabelFontSize(+1)} px`, {category: 'label-font-size'});
2984				break;
2985			case 'N':  // N
2986				showScreenMessage(`czcionka: ${giga.changeLabelFontSize(-1)} px`, {category: 'label-font-size'});
2987				break;
2988			case 't':  // t
2989			case 'T':  // t
2990				k.call('toggleHotspots()');
2991				break;
2992			case 'v':  // v
2993			case 'V':  // v
2994				k.layer.getItem('view_layer').visible = !k.layer.getItem('view_layer').visible;
2995				break;
2996			case 'q':  // q
2997			case 'Q':  // q
2998				giga.currentPanorama.toggleHotspotsLabels();
2999				//showScreenMessage(`zmiana wyświetlanych informacji`);
3000				break;
3001			case 'm':  // m
3002			case 'M':  // m
3003				//giga.currentPanorama.overlayMap.toggle();
3004				giga.mapycom.toggle();
3005				break;
3006			case 'o':  // o
3007			case 'O':  // o
3008				giga.currentPanorama.appendExtraAttribute("mapycz");
3009				showScreenMessage('pokaż atrybuty: mapy.com', {category: 'mapycom'});
3010				break;
3011			case 'w':  // w
3012			case 'W':  // w
3013				giga.currentPanorama.appendExtraAttribute("wiki");
3014				showScreenMessage('pokaż atrybuty: Wikipedia', {category: 'wiki'});
3015				break;
3016			case '9':  // 9
3017				if (k.device.webgl) {
3018					giga.resetAllAdjustments();
3019					showScreenMessage('reset filtrów obrazu', {category: 'reset'});
3020				}
3021				break;
3022			case 'e':  // e
3023			case 'E':  // e
3024				giga.currentPanorama.toggleBearingErrorsChart();
3025				break;
3026			case '=':  // 
3027				if (k.device.webgl) {
3028					giga.sharpen.increase();
3029					showScreenMessage(`wyostrzanie: ${giga.sharpen.get().toFixed(1)}`, {category: 'sharpen'});
3030				}
3031				break;
3032			case '-':  // 
3033				if (k.device.webgl) {
3034					giga.sharpen.decrease();
3035					showScreenMessage(`wyostrzanie: ${giga.sharpen.get().toFixed(1)}`, {category: 'sharpen'});
3036				}
3037				break;
3038
3039			case ';':  // ;
3040				giga.adjust.decrease('brightness');
3041				showScreenMessage(`jasność: ${addSign(giga.adjust.get('brightness').toFixed(2))}`, {category: 'brightness'});
3042				break;
3043			case "'":   // '
3044				giga.adjust.increase('brightness');
3045				showScreenMessage(`jasność: ${addSign(giga.adjust.get('brightness').toFixed(2))}`, {category: 'brightness'});
3046				break;
3047			case '[':   // [
3048				giga.adjust.decrease('contrast');
3049				showScreenMessage(`kontrast: ${giga.adjust.get('contrast').toFixed(1)}`, {category: 'contrast'});
3050				break;
3051			case ']':   // ]
3052				giga.adjust.increase('contrast');
3053				showScreenMessage(`kontrast: ${giga.adjust.get('contrast').toFixed(1)}`, {category: 'contrast'});
3054				break;
3055			case '\\':   // \
3056				giga.adjust.reset();
3057				giga.vibrance.reset();
3058				showScreenMessage(`reset regulacji kolorów`);
3059				break;
3060			case 's':   // s
3061				giga.vibrance.increase();
3062				showScreenMessage(`jaskrawość: ${giga.vibrance.get().toFixed(1)}`, {category: 'vibrance'});
3063				break;
3064			case 'S':    
3065				giga.vibrance.decrease();
3066				showScreenMessage(`jaskrawość: ${giga.vibrance.get().toFixed(1)}`, {category: 'vibrance'});
3067				break;
3068			case '0':   // 0
3069				giga.edge.reset();
3070				giga.sharpen.reset();
3071				showScreenMessage('wyostrzanie i podkreślanie krawędzi wyłączone', {category: 'edge'});
3072				break;
3073			case ',':   // ,
3074				giga.adjust.increase('gamma',1.01);
3075				giga.adjust.increase('brightness',0.09);
3076				giga.adjust.decrease('contrast',1.13);
3077				showScreenMessage(`zwiększenie gammy`, {category: 'gamma'});
3078				break;
3079			case '.':   // .
3080				giga.adjust.decrease('gamma',1.01);
3081				giga.adjust.decrease('brightness',0.09);
3082				giga.adjust.increase('contrast',1.13);
3083				showScreenMessage(`zmniejszenie gammy`, {category: 'gamma'});
3084				break;
3085			case 'g':  // g
3086				giga.grayscale.toggle();
3087				showScreenMessage(giga.grayscale.get()>0 ? 'skala szarości' : 'kolor');
3088				break;
3089			case '1':   
3090				giga.autolevels.decreaseStrength();
3091				showAutolevelsMessage();
3092				break;
3093			case '2':   
3094				giga.autolevels.increaseStrength();
3095				showAutolevelsMessage();
3096				break;
3097			case '3':   
3098				giga.autolevels.toggle();
3099				showAutolevelsMessage();
3100				break;
3101			case '5':  
3102				giga.autolevels.toggleLuminanceOnly();
3103				showScreenMessage(`autolevels: ${giga.autolevels.getLuminanceOnly()==1 ? 'tylko luminancja' : 'RGB'}`, {category: 'autolevels'});
3104				break;
3105			case '4':  
3106				giga.autolevels.next();
3107				showAutolevelsMessage();
3108				break;
3109			case '$':  
3110				giga.autolevels.prev();
3111				showAutolevelsMessage();
3112				break;
3113			case '6': 
3114				const currentMode = +k.state.hotspotsshowmode;
3115				if (currentMode!=giga.showModes.HIDDEN) {
3116					showScreenMessage(`ukryj podpisy`, {category: 'toggle-hotspots', duration:500});
3117					k.call(`toggleHotspots(${giga.showModes.HIDDEN})`);
3118					giga.lastShowMode = currentMode;
3119				} else {
3120					showScreenMessage(`pokaż podpisy`, {category: 'toggle-hotspots', duration:500});
3121					let newMode = typeof giga.lastShowMode=='number' ? giga.lastShowMode : giga.showModes.COLLISION;
3122					k.call(`toggleHotspots(${newMode})`);
3123				}
3124				break;
3125			case 'D': 
3126				giga.edge.decrease(); 
3127				showScreenMessage(`podkreślanie krawędzi: ${giga.edge.get().toFixed(2)}`, {category: 'edge'});
3128				break;
3129			case 'd': 
3130				giga.edge.increase(); 
3131				showScreenMessage(`podkreślanie krawędzi: ${giga.edge.get().toFixed(2)}`, {category: 'edge'});
3132				break;
3133			case 'L': 
3134				giga.localcontrast.decreaseStrength(); 
3135				showScreenMessage(`lokalny kontrast: ${giga.localcontrast.get().strength.toFixed(1)} / ${giga.localcontrast.get().radius.toFixed(1)} `, {category: 'edge'});
3136				break;
3137			case 'l': 
3138				giga.localcontrast.increaseStrength(); 
3139				showScreenMessage(`lokalny kontrast: ${giga.localcontrast.get().strength.toFixed(1)} / ${giga.localcontrast.get().radius.toFixed(1)} `, {category: 'edge'});
3140				break;
3141			case 'j': 
3142				showScreenMessage('zamknij wszystkie poboczne okna');
3143				if (giga.mapycom.isShown) giga.mapycom.toggle();
3144				giga.lineOfSight.clear();
3145				giga.removeMiniPanorama();
3146				document.querySelector('#addhotspot .close')?.click()
3147				break;
3148			case 'h':  // h
3149				k.state.ignoremaxzoom = !k.state.ignoremaxzoom;
3150				if (k.state.ignoremaxzoom) {
3151					k.view.savedmaxpixelzoom = k.view.maxpixelzoom;
3152					k.view.maxpixelzoom = 5;
3153					console.log(`maxpixelzoom: ${k.view.maxpixelzoom}`);
3154					showScreenMessage('zoom bez ograniczeń', {category: 'zoom-block'});
3155				} else {
3156					k.view.maxpixelzoom = k.view.savedmaxpixelzoom;
3157					console.log(`maxpixelzoom: ${k.view.maxpixelzoom}`);
3158					showScreenMessage('zoom ograniczony', {category: 'zoom-block'});
3159				}
3160				break;
3161		}
3162	},
3163	
3164	generateMapyCz3DURL: function(params) {
3165		return 'https://mapy.com/en/zakladni?m3d=1&'+makeParams(params);
3166	},
3167	
3168	generatePeakfinderURL: function (params) {
3169		// https://www.peakfinder.com/about/resources/api/
3170		// lat, lng, azi, ele, date, fov, name
3171		return 'https://www.peakfinder.com/?cfg=smr&'+makeParams(params);
3172	},
3173	
3174	generatePeakVisorURL: function(params) {
3175		// lat, lng, alt, yaw, hfov
3176		return 'https://peakvisor.com/panorama.html?'+makeParams(params);
3177	},
3178	
3179	generateUdeuschleURL1: function(params) {
3180		/* 
3181		lon=8.0713
3182		lat=46.6926
3183		alt=auto
3184		altcam=5
3185		hialt=false
3186		resolution=40
3187		azimut=85
3188		sweep=10
3189		leftbound=80
3190		rightbound=90
3191		split=30
3192		splitnr=1
3193		tilt=auto
3194		tiltsplit=false
3195		elexagg=1.2
3196		range=300
3197		colorcoding=true
3198		colorcodinglimit=134
3199		title=PANO%20TITLE
3200		description=PANO%20DESC
3201		email=
3202		language=en
3203		screenwidth=1920
3204		screenheight=1080
3205		*/
3206		var defaults = { 
3207			lat: 50.0,
3208			lon: 20.0,
3209			alt: 'auto',
3210			altcam: 5,
3211			hialt: false,
3212			resolution: 20,
3213			azimut: 180,
3214			sweep: 360,
3215			leftbound: 0,
3216			rightbound: 360,
3217			split: 30,
3218			splitnr: 1,
3219			tilt: 'auto',
3220			tiltsplit: false,
3221			elexagg: 1.2,
3222			range: 250,
3223			refraction: '',
3224			colorcoding: false,
3225			colorcodinglimit: 134,
3226			title: 'Panorama title',
3227			description: 'Panorama description',
3228			email: '',
3229			language: 'en',
3230			screenwidth: screen.width,
3231			screenheight: screen.height
3232		};
3233	 
3234		// Merge defaults and options, without modifying defaults
3235		var settings = $.extend( {}, defaults, params );
3236		
3237		settings.sweep = (settings.rightbound - settings.leftbound + 360) % 360;
3238		settings.azimut = (settings.leftbound + settings.sweep/2) % 360;
3239		if (hasValue(settings.zoom)) {
3240			settings.resolution = settings.zoom*20;
3241			delete settings.zoom;
3242		}
3243		if (hasValue(settings.refraction)) { //refraction coefficient, default is 0.13
3244			settings.range += '_RC'+settings.refraction;
3245			delete settings.refraction;
3246		}
3247		settings.title = giga.escapeUnicode(settings.title);
3248		settings.description = giga.escapeUnicode(settings.description);
3249		const p = $.param(settings).replace(/=/g,'%3A').replace(/&/g,'%24%24%24').replace(/%5Cu/g,'%u');
3250		const url = 'https://www.udeuschle.de/panoramas/panqueryfull.aspx?mode=newstandard&data=' + p;
3251		return url;
3252	},
3253	
3254	generateUdeuschleURL: function(params) {
3255		const defaults = {
3256			lat: 50.0,
3257			lon: 20.0,
3258			alt: 'auto',
3259			altcam: 5,
3260			hialt: false,
3261			resolution: 20,
3262			azimut: 180,
3263			sweep: 360,
3264			leftbound: 0,
3265			rightbound: 360,
3266			split: 30,
3267			splitnr: 1,
3268			tilt: 'auto',
3269			tiltsplit: false,
3270			elexagg: 1.2,
3271			range: 250,
3272			refraction: '',
3273			colorcoding: false,
3274			colorcodinglimit: 134,
3275			title: 'Panorama title',
3276			description: 'Panorama description',
3277			email: '',
3278			language: 'en',
3279			screenwidth: screen.width,
3280			screenheight: screen.height
3281		};
3282
3283		const settings = { ...defaults, ...params };
3284
3285		settings.sweep = (settings.rightbound - settings.leftbound + 360) % 360;
3286		settings.azimut = (settings.leftbound + settings.sweep / 2) % 360;
3287
3288		if (hasValue(settings.zoom)) {
3289			settings.resolution = settings.zoom * 20;
3290			delete settings.zoom;
3291		}
3292		if (hasValue(settings.refraction)) {  //refraction coefficient, default is 0.13
3293			settings.range += '_RC' + settings.refraction;
3294			delete settings.refraction;
3295		}
3296
3297		settings.title = giga.escapeUnicode(settings.title);
3298		settings.description = giga.escapeUnicode(settings.description);
3299
3300		const p = new URLSearchParams(settings).toString()
3301			.replace(/=/g, '%3A')
3302			.replace(/&/g, '%24%24%24')
3303			.replace(/%5Cu/g, '%u');
3304
3305		return 'https://www.udeuschle.de/panoramas/panqueryfull.aspx?mode=newstandard&data=' + p;
3306	},
3307	
3308	/*
3309	Based on:
3310	* File: jquery.wikiblurb.js
3311	* Version: 1.0.0
3312	* Description: A simple jQuery plugin to get sections of Wikipedia and other Wikis
3313	* Author: 9bit Studios
3314	* Copyright 2012, 9bit Studios https://www.9bitstudios.com
3315	* Free to use and abuse under the MIT license, https://www.opensource.org/licenses/mit-license.php
3316	*/
3317	
3318	loadWiki: async function (selector, article, lang = 'pl') {
3319		if (giga.wikiCache[article]?.status === 'loading') return;
3320
3321		const settings = {
3322			page: giga.addUnderscores(article),
3323			section: 0,
3324			wikiURL: `https://${lang}.wikipedia.org/`,
3325			apiPath: 'w',
3326			removeLinks: true,
3327			customSelector: '',
3328			filterSelector: 'table.infobox'
3329		};
3330
3331		const object = document.querySelector(selector);
3332		const wLink = `
3333			<div>
3334				<a title="Artykuł: ${makeAttr(article)}"
3335				   target="_blank"
3336				   href="${settings.wikiURL}wiki/${settings.page}">
3337				   czytaj więcej na Wikipedii ${lang === 'pl' ? '' : `(${lang})`} &raquo;
3338				</a>
3339			</div>
3340		`;
3341
3342		// Cached OK
3343		if (giga.wikiCache[article]?.status === 'ok') {
3344			object.innerHTML = giga.wikiCache[article].data + wLink;
3345			return;
3346		}
3347
3348		giga.wikiCache[article] = { status: 'loading' };
3349
3350		const url =
3351			`${settings.wikiURL}${settings.apiPath}/api.php?action=parse&format=json&origin=*` +
3352			`&redirects=true${settings.section !== null ? `&section=${settings.section}` : ''}` +
3353			`&prop=text&disabletoc=true&page=${settings.page}`;
3354
3355		const response = await fetch(url);
3356
3357		if (!response.ok) {
3358			giga.wikiCache[article] = { status: 'error' };
3359			object.textContent = `wystąpił problem z załadowaniem artykułu z Wikipedii (${response.status})`;
3360			return;
3361		}
3362
3363		const data = await response.json();
3364
3365		if (data.error) {
3366			giga.wikiCache[article] = { status: 'error' };
3367
3368			if (data.error.code === 'missingtitle') {
3369				object.innerHTML =
3370					`Na Wikipedii nie znaleziono takiej strony! ` +
3371					`<a class="standard" target="_blank" href="https://www.google.pl/search?${makeParams({ q: article })}">Wyguglać</a>?`;
3372			} else {
3373				object.textContent = `wystąpił błąd: ${data.error.info}`;
3374			}
3375			return;
3376		}
3377
3378		const markup = data.parse.text["*"];
3379
3380		const template = document.createElement('template');
3381		template.innerHTML = `<div class="nbs-wikiblurb">${markup}</div>`;
3382		const blurb = template.content.firstChild;
3383
3384		// Remove links
3385		if (settings.removeLinks) {
3386			blurb.querySelectorAll('a').forEach(a => {
3387				const span = document.createElement('span');
3388				span.innerHTML = a.innerHTML;
3389				a.replaceWith(span);
3390			});
3391		} else {
3392			blurb.querySelectorAll('a').forEach(a => {
3393				const href = a.getAttribute('href');
3394				if (href?.startsWith('/')) {
3395					a.href = settings.wikiURL + href.substring(1);
3396				}
3397			});
3398		}
3399
3400		// Remove references
3401		blurb.querySelectorAll('sup').forEach(el => el.remove());
3402
3403		// Remove cite errors
3404		blurb.querySelectorAll('.mw-ext-cite-error').forEach(el => el.remove());
3405
3406		// Remove filtered elements
3407		if (settings.filterSelector) {
3408			blurb.querySelectorAll(settings.filterSelector).forEach(el => el.remove());
3409		}
3410
3411		// Extract first <p> and truncate using $.truncate
3412		const firstP = blurb.querySelector('p');
3413		const truncated = truncateHTML(firstP?.innerHTML || '', {
3414			length: 820,
3415			words: true,
3416			keepFirstWord: true,
3417			finishBlock: false,
3418			ellipsis: '&nbsp;...'
3419		});
3420
3421		blurb.innerHTML = truncated;
3422
3423		giga.wikiCache[article] = {
3424			data: blurb.innerHTML || '?',
3425			status: 'ok'
3426		};
3427
3428		object.innerHTML = giga.wikiCache[article].data + wLink;
3429	},
3430	
3431	panoramas: {},
3432	data: {},
3433	sizes: {},
3434	points: {},
3435	
3436	loadPoints: function() {
3437		console.warn('giga.loadPoints called');
3438		$.get('points.xml', 'xml', function(d) {
3439			const allPoints = $(d).find('points').find('point');
3440			allPoints.each( function(i,p) {
3441				const $p = $(p), w = $p.attr('wiki');
3442				giga.points[w] = {
3443					name: $p.find('name').text(),
3444					wiki: w,
3445					wikilang: $p.attr('wikilang'),
3446					lat: $p.attr('lat'),
3447					lon: $p.attr('lon'),
3448					ele: $p.attr('elevation'),
3449					kind: $p.attr('kind')
3450				};
3451			});
3452		});
3453	},
3454	
3455	loadPoints2: async function () {
3456		try {
3457			const res = await fetch('points.xml');
3458			const xmlText = await res.text();
3459
3460			const xml = new DOMParser().parseFromString(xmlText, "text/xml");
3461			const points = xml.querySelectorAll('points > point');
3462
3463			points.forEach(p => {
3464				const w = p.getAttribute('wiki');
3465
3466				giga.points[w] = {
3467					name: p.querySelector('name')?.textContent ?? "",
3468					wiki: w,
3469					wikilang: p.getAttribute('wikilang'),
3470					lat: p.getAttribute('lat'),
3471					lon: p.getAttribute('lon'),
3472					ele: p.getAttribute('elevation'),
3473					kind: p.getAttribute('kind')
3474				};
3475			});
3476
3477		} catch (err) {
3478			console.error("Failed to load points.xml:", err);
3479		}
3480	},
3481	
3482	loadData: async function(pans, onReady) {
3483		await Promise.all(pans.map(async v => {
3484			const d = await fetch(`info/${v}.xml`).then(r => r.text());
3485			const xml = new DOMParser().parseFromString(d, "text/xml");
3486
3487			const info = xml.querySelector("info");
3488			giga.panoramas[v] = {
3489				title:        info.getAttribute("title")        ?? "",
3490				location:     info.getAttribute("location")     ?? "",
3491				group:        info.getAttribute("group")        ?? "",
3492				tags:         info.getAttribute("tags")         ?? "",
3493				lat:          info.getAttribute("lat")          ?? "",
3494				lon:          info.getAttribute("lon")          ?? "",
3495				azimuthleft:  info.getAttribute("azimuthleft")  ?? "",
3496				azimuthright: info.getAttribute("azimuthright") ?? "",
3497				elevation:    info.getAttribute("elevation")    ?? "",
3498				date:         info.getAttribute("date")         ?? "",
3499				time:         info.getAttribute("time")         ?? "",
3500				published:    info.getAttribute("published")    ?? "",
3501				comments:     info.getAttribute("comments")     ?? "",
3502				range:        info.getAttribute("range")        ?? "",
3503				zoom:         info.getAttribute("zoom")         ?? "",
3504				refraction:   info.getAttribute("refraction")   ?? "",
3505				_el: info,
3506			};
3507
3508			giga.data[v] = Array.from(xml.querySelectorAll("hotspot"));
3509
3510			const img = xml.querySelector("image");
3511			const str = img ? img.innerHTML.trim() : "";
3512			const w = [...str.matchAll(/tiledimagewidth="(\d+)"/g)].map(m=>+m[1]).sort((a,b)=>b-a)[0]
3513			       || [...str.matchAll(/(\d+)x(\d+)/g)].map(m=>+m[1]).sort((a,b)=>b-a)[0];
3514			const h = [...str.matchAll(/tiledimageheight="(\d+)"/g)].map(m=>+m[1]).sort((a,b)=>b-a)[0]
3515			       || [...str.matchAll(/(\d+)x(\d+)/g)].map(m=>+m[2]).sort((a,b)=>b-a)[0];
3516			giga.sizes[v] = { width: w || 0, height: h || 0 };
3517		}));
3518		if (onReady) onReady();
3519	},
3520	
3521	loader: {
3522		show: function() {
3523			document.querySelector('h1 #homebutton')?.classList.add('rotate-scale-up');
3524		},
3525		hide: function() {
3526			document.querySelector('h1 #homebutton')?.classList.remove('rotate-scale-up');
3527		}
3528	},
3529	
3530	saveSnapshot: function(dataURI, extension = 'jpg') {
3531		// docs: https://krpano.com/docu/plugininterface/#krpano.webGL.makeScreenshot
3532		const azi = giga.hposToAzimuth(k.view.hlookat);
3533		saveAs(dataURItoBlob(dataURI), giga.currentPanorama.getID() + "-" + azi.toFixed(2) + "." + extension);
3534	},
3535	
3536	copySnapshot: async function() {
3537		const itemType = "image/png";
3538		const dataURI = k.webGL.makeScreenshot(k.webGL.canvas.width, k.webGL.canvas.height, false, 'png');
3539		if (ClipboardItem.supports(itemType)) {
3540			try {
3541				const data = [new ClipboardItem({ [itemType]: dataURItoBlob(dataURI) })];
3542				await navigator.clipboard.write(data);
3543				console.log("Copied OK");
3544			} catch (error) {
3545				console.log(error);
3546			}
3547		} else {
3548			console.log("image/png is not supported");
3549		}
3550	},
3551	
3552	makeWindowScreenshot: async function() {
3553		const dataURI = await screenshot.takeScreenshot();
3554		if (dataURI) {
3555			giga.saveSnapshot(dataURI, 'png');
3556		}
3557	},
3558	
3559	resetAllAdjustments: () => {
3560		giga.adjust.reset();
3561		giga.sharpen.reset();
3562		giga.vibrance.reset();
3563		giga.grayscale.reset();
3564		giga.autolevels.reset();
3565		giga.localcontrast.reset();
3566		giga.temperature.reset();
3567		//giga.shadowHighlight.reset();
3568		giga.light.reset();
3569		giga.edge.reset();
3570	},
3571	
3572	edge: {
3573		ready: false,
3574		readyHandler: function() {
3575			//console.log('giga.edge.readyHandler');
3576			if (k.get('plugin[edge]')) giga.edge.ready = true;
3577		},
3578		set: function(v) {
3579			if (giga.edge.ready) k.plugin.getItem('edge').strength = v;
3580		},
3581		get: function() {
3582			return +k.plugin.getItem('edge').strength;
3583		},
3584		getText: function() {
3585			return ' E:'+giga.edge.get().toFixed(2);
3586		},
3587		reset: function() {
3588			if (giga.edge.ready) giga.edge.set(0);
3589		},
3590		increase: function(amount = 0.02) {
3591			amount = isNumeric(amount) ? +amount : 0.02;
3592			if (giga.edge.ready) {
3593				giga.edge.set(+giga.edge.get()+amount);
3594			}
3595		}, 
3596		decrease: function(amount = 0.02) {
3597			amount = isNumeric(amount) ? +amount : 0.02;
3598			if (giga.edge.ready) {
3599				let newAmount = +giga.edge.get() - amount;
3600				giga.edge.set(newAmount>0 ? newAmount : 0.0);
3601			}
3602		},
3603		isNonZero: function() {
3604			return giga.edge.get() != 0;
3605		}
3606	},
3607	
3608	sharpen: {
3609		ready: false,
3610		readyHandler: function() {
3611			// console.log('giga.sharpen.readyHandler');
3612			if (k.get('plugin[sharpen]')) giga.sharpen.ready = true;
3613		},
3614		set: function(v) {
3615			if (giga.sharpen.ready) k.plugin.getItem('sharpen').strength = v;
3616		},
3617		get: function() {
3618			return +k.plugin.getItem('sharpen').strength;
3619		},
3620		getText: function() {
3621			return ' S:'+giga.sharpen.get().toFixed(2);
3622		},
3623		reset: function() {
3624			if (giga.sharpen.ready) giga.sharpen.set(0);
3625		},
3626		change: function(amount) {
3627			if (amount>=0) {
3628				giga.sharpen.increase(amount);
3629			} else {
3630				giga.sharpen.decrease(-amount);
3631			}
3632		},
3633		increase: function(amount) {
3634			amount = isNumeric(amount) ? +amount : 0.2;
3635			if (giga.sharpen.ready) {
3636				giga.sharpen.set(+giga.sharpen.get()+amount);
3637			}
3638		}, 
3639		decrease: function(amount) {
3640			amount = isNumeric(amount) ? +amount : 0.2;
3641			if (giga.sharpen.ready) {
3642				giga.sharpen.set(Math.max(0,giga.sharpen.get()-amount));
3643			}
3644		},
3645		isNonZero: function() {
3646			return giga.sharpen.get()!=0;
3647		}
3648	},
3649	
3650	grayscale: {
3651		ready: false,
3652		readyHandler: function() {
3653			if (k.get('plugin[grayscale]')) giga.grayscale.ready = true;
3654		},
3655		set: function(v) {
3656			if (giga.grayscale.ready) k.set('plugin[grayscale].factor',v);
3657		},
3658		get: function() {
3659			return +k.get('plugin[grayscale].factor');
3660		},
3661		reset: function() {
3662			if (giga.grayscale.ready) giga.grayscale.set(0.0);
3663		},
3664		toggle: function() {
3665			if (giga.grayscale.ready) {
3666				giga.grayscale.set(giga.grayscale.get()>0 ? 0.0 : 1.0);
3667			}
3668		}, 		
3669		increase: function(amount) {
3670			amount = isNumeric(amount) ? +amount : 0.1;
3671			if (giga.grayscale.ready) {
3672				giga.grayscale.set(Math.min(1.0,+giga.grayscale.get()+amount));
3673			}
3674		}, 
3675		decrease: function(amount) {
3676			amount = isNumeric(amount) ? +amount : 0.1;
3677			if (giga.grayscale.ready) {
3678				giga.grayscale.set(Math.max(0.0,giga.grayscale.get()-amount));
3679			}
3680		} 
3681	},
3682	
3683	
3684	autolevels: {
3685
3686		ready: false,
3687		getMethodName: (n) => {
3688			if (typeof n==='undefined') n = giga.autolevels.get();
3689			switch (n) {
3690				case 0: return 'auto-levels: wyłączone';
3691				case 1: return 'auto-levels: histogram equalization';
3692				//case 2: return 'auto-levels: multiscale retinex';
3693				case 2: return 'auto-levels: sigmoid';
3694				case 3: return 'auto-levels: fast sigmoid';
3695				case 4: return 'auto-levels: CLAHE';
3696				case 5: return 'auto-levels: tone mapping';
3697				case 6: return 'auto-levels: histogram stretching';
3698			}
3699			return 'auto-levels: ???';
3700		},
3701		maxMethod: 6,
3702		prevMethod: 2,
3703		readyHandler: function() {
3704			if (k.get('plugin[autolevels]')) giga.autolevels.ready = true;
3705		},
3706		set: function(method) {
3707			if (giga.autolevels.ready) {
3708				console.log(`autolevels method ${method}`);
3709				if (method>0) giga.autolevels.prevMethod = method;
3710				k.set('plugin[autolevels].method',method);
3711				k.set('plugin[autolevels].enabled',method>0);
3712			}
3713		},
3714		get: function() {
3715			return +k.get('plugin[autolevels].method');
3716		},
3717		setStrength: function(strength) {
3718			if (giga.autolevels.ready) {
3719				console.log(`autolevels strength ${strength}`);
3720				k.set('plugin[autolevels].strength', strength);
3721			}
3722		},
3723		getStrength: function() {
3724			return +k.get('plugin[autolevels].strength');
3725		}, 		
3726		setLuminanceOnly: function(luminanceOnly = 0) {
3727			if (giga.autolevels.ready) {
3728				console.log(`autolevels luminanceonly ${luminanceOnly}`);
3729				k.set('plugin[autolevels].luminanceonly', luminanceOnly);
3730			}
3731		},
3732		getLuminanceOnly: function() {
3733			return +k.get('plugin[autolevels].luminanceonly');
3734		}, 
3735		toggleLuminanceOnly: function() {
3736			giga.autolevels.setLuminanceOnly( giga.autolevels.getLuminanceOnly() == 0 ? 1 : 0 );
3737		},	
3738		increaseStrength: function(amount) {
3739			amount = isNumeric(amount) ? +amount : 0.1;
3740			if (giga.autolevels.ready) {
3741				giga.autolevels.setStrength(Math.min(1.0,+giga.autolevels.getStrength()+amount));
3742			}
3743		}, 
3744		decreaseStrength: function(amount) {
3745			amount = isNumeric(amount) ? +amount : 0.1;
3746			if (giga.autolevels.ready) {
3747				giga.autolevels.setStrength(Math.max(0.0,giga.autolevels.getStrength()-amount));
3748			}
3749		},
3750		reset: function() {
3751			if (giga.autolevels.ready) giga.autolevels.set(0);
3752		},
3753		next: function() {
3754			if (giga.autolevels.ready) {
3755				giga.autolevels.set((giga.autolevels.get() + 1) % (1+giga.autolevels.maxMethod));
3756			}
3757		},
3758		prev: function() {
3759			if (giga.autolevels.ready) {
3760				giga.autolevels.set((giga.autolevels.get() + giga.autolevels.maxMethod) % (1+giga.autolevels.maxMethod));
3761			}
3762		},
3763		toggle: function() {
3764			if (giga.autolevels.ready) {
3765				giga.autolevels.set(giga.autolevels.get()>0 ? 0 : giga.autolevels.prevMethod);
3766			}
3767		}		
3768	},
3769	
3770	localcontrast: {
3771
3772		ready: false,
3773		readyHandler: function() {
3774			if (k.get('plugin[localcontrast]')) giga.localcontrast.ready = true;
3775		},
3776		set: function(strength, radius) {
3777			if (giga.localcontrast.ready) {
3778				k.set('plugin[localcontrast].radius', radius);
3779				k.set('plugin[localcontrast].strength', strength);
3780				k.set('plugin[localcontrast].enabled', strength > 1e-5 || radius > 1e-5);
3781			}
3782		},
3783		setRadius: function(radius) {
3784			if (giga.localcontrast.ready) {
3785				k.set('plugin[localcontrast].radius', radius);
3786				const params = giga.localcontrast.get();
3787				k.set('plugin[localcontrast].enabled', strength > 1e-5 || radius > 1e-5);
3788			}
3789		},
3790		setStrength: function(strength) {
3791			if (giga.localcontrast.ready) {
3792				k.set('plugin[localcontrast].strength', strength);
3793				const params = giga.localcontrast.get();
3794				k.set('plugin[localcontrast].enabled', strength > 1e-5 || radius > 1e-5);
3795			}
3796		},
3797		get: function() {
3798			return {
3799				strength: +k.get('plugin[localcontrast].strength'),
3800				radius: +k.get('plugin[localcontrast].radius')
3801			};
3802		},
3803		reset: function() {
3804			if (giga.localcontrast.ready) giga.localcontrast.set(0,0);
3805		},
3806		getText: function() {
3807			const params = giga.localcontrast.get();
3808			return ' LCE:'+params.strength.toFixed(1) + '/'+params.radius.toFixed(1);
3809		},
3810		increaseStrength: function(amount = 0.5) {
3811			if (giga.localcontrast.ready) {
3812				giga.localcontrast.set(Math.min(20.0, +giga.localcontrast.get().strength+amount), 10.0);
3813			}
3814		}, 
3815		decreaseStrength: function(amount = 0.5) {
3816			if (giga.localcontrast.ready) {
3817				giga.localcontrast.set(Math.max(0.0, +giga.localcontrast.get().strength-amount), 10.0);
3818			}
3819		} 
3820	
3821	},
3822	
3823	vibrance: {
3824		ready: false,
3825		readyHandler: function() {
3826			if (k.get('plugin[vibrance]')) giga.vibrance.ready = true;
3827		},
3828		set: function(v) {
3829			if (giga.vibrance.ready) k.set('plugin[vibrance].vibrance',v);
3830		},
3831		get: function() {
3832			return +k.get('plugin[vibrance].vibrance');
3833		},
3834		getText: function() {
3835			return ' V:'+giga.vibrance.get().toFixed(1);
3836		},
3837		reset: function() {
3838			if (giga.vibrance.ready) giga.vibrance.set(1.0);
3839		},
3840		increase: function(amount) {
3841			amount = isNumeric(amount) ? +amount : 0.1;
3842			if (giga.vibrance.ready) {
3843				giga.vibrance.set(Math.min(20.0,+giga.vibrance.get()+amount));
3844			}
3845		}, 
3846		decrease: function(amount) {
3847			amount = isNumeric(amount) ? +amount : 0.1;
3848			if (giga.vibrance.ready) {
3849				giga.vibrance.set(Math.max(0.0,giga.vibrance.get()-amount));
3850			}
3851		} 
3852	},
3853	
3854	shadowHighlight: {
3855		ready: false,
3856		readyHandler: function() {
3857			if (k.get('plugin[shadowhighlight]')) giga.shadowHighlight.ready = true;
3858		},
3859		set: function(temp,tint=0) {
3860			if (giga.shadowHighlight.ready) {
3861			}
3862		},
3863		get: function() {
3864			return {
3865			};
3866		},
3867		getText: function() {
3868		},
3869		reset: function() {
3870		}
3871	},
3872	
3873	temperature: {
3874		ready: false,
3875		readyHandler: function() {
3876			if (k.get('plugin[temperature]')) giga.temperature.ready = true;
3877		},
3878		set: function(temp,tint=0) {
3879			if (giga.temperature.ready) {
3880				k.set('plugin[temperature].tint',tint);
3881				k.set('plugin[temperature].temperature',temp);
3882			}
3883		},
3884		get: function() {
3885			return {
3886				temperature: +k.get('plugin[temperature].temperature'),
3887				tint: +k.get('plugin[temperature].tint')
3888			};
3889		},
3890		getText: function() {
3891			const params = giga.temperature.get();
3892			return ' TEMP:'+params.temperature.toFixed(1) + ' TINT:'+params.tint.toFixed(1);
3893		},
3894		reset: function() {
3895			if (giga.temperature.ready) giga.temperature.set(0,0);
3896		},
3897		increaseTemperature: function(amount) {
3898			amount = isNumeric(amount) ? +amount : 0.1;
3899			if (giga.temperature.ready) {
3900				console.log('increasing temp...',giga.temperature.get());
3901				giga.temperature.set(+giga.temperature.get().temperature + amount, +giga.temperature.get().tint);
3902				console.log('increased temp...',giga.temperature.get());
3903			}
3904		}, 
3905		decreaseTemperature: function(amount) {
3906			amount = isNumeric(amount) ? +amount : 0.1;
3907			if (giga.temperature.ready) {
3908				console.log('decreasing temp...',giga.temperature.get());
3909				giga.temperature.set(+giga.temperature.get().temperature - amount, +giga.temperature.get().tint);
3910				console.log('decreased temp...',giga.temperature.get());
3911			}
3912		}, 
3913		increaseTint: function(amount) {
3914			amount = isNumeric(amount) ? +amount : 0.1;
3915			if (giga.temperature.ready) {
3916				console.log('increasing tint...',giga.temperature.get());
3917				giga.temperature.set(+giga.temperature.get().temperature, +giga.temperature.get().tint + amount);
3918				console.log('increased tint...',giga.temperature.get());
3919			}
3920		}, 
3921		decreaseTint: function(amount) {
3922			amount = isNumeric(amount) ? +amount : 0.1;
3923			if (giga.temperature.ready) {
3924				console.log('decreasing tint...',giga.temperature.get());
3925				giga.temperature.set(+giga.temperature.get().temperature, +giga.temperature.get().tint - amount);
3926				console.log('decreased tint...',giga.temperature.get());
3927			}
3928		} 
3929	},
3930	
3931	adjust: {
3932		ready: false,
3933		readyHandler: function() {
3934			// console.log('giga.adjust.readyHandler');
3935			if (k.get('plugin[adjust]')) giga.adjust.ready = true;
3936		},
3937		setBrightness: function(b) {
3938			giga.adjust.set('brightness',b);
3939		},
3940		setContrast: function(c) {
3941			giga.adjust.set('contrast',c);
3942		},
3943		setGamma: function(g) {
3944			giga.adjust.set('gamma',g);
3945		},
3946		setAll: function(b,c,g) {
3947			if (giga.adjust.ready) k.call('plugin[adjust].adjust('+b+','+c+','+g+');');
3948		},
3949		reset: function() {
3950			if (giga.adjust.ready) giga.adjust.setAll(0,1,1);
3951		},
3952		set: function(o,v) {
3953			//console.log('setting ',o,v);
3954			if (giga.adjust.ready) k.set('plugin[adjust].'+o,v);
3955		},
3956		get: function(o) {
3957			return k.get('plugin[adjust].'+o);
3958		},
3959		getAll: function() {
3960			return {
3961				brightness: giga.adjust.get('brightness'),
3962				contrast: giga.adjust.get('contrast'),
3963				gamma: giga.adjust.get('gamma')
3964			};
3965		},
3966		getText: function() {
3967			const b = giga.adjust.get('brightness'), c = giga.adjust.get('contrast'), g = giga.adjust.get('gamma');
3968			return `B:${b ? b.toFixed(2) : '0'} C:${c ? c.toFixed(2) : '0'} G:${g ? g.toFixed(2) : '0'}`;
3969		},
3970		increase: function(o, amount) {
3971			var v = giga.adjust.get(o);
3972			//newcolor.r = (pow(color.r,gamma) - 0.5)*contrast + brightness + 0.5;
3973			switch(o) {
3974				case 'brightness': 
3975					v += isNumeric(amount) ? +amount : 0.05;
3976				break;                       
3977				case 'contrast':             
3978					v *= isNumeric(amount) ? +amount : 1.1;
3979				break;                       
3980				case 'gamma':                
3981					v *= isNumeric(amount) ? +amount : 1.1;
3982				break;
3983			}
3984			giga.adjust.set(o,v);
3985			//console.log(giga.adjust.getAll());
3986		},
3987		decrease: function(o, amount) {
3988			var v = giga.adjust.get(o);
3989			switch(o) {
3990				case 'brightness': 
3991					v -= isNumeric(amount) ? +amount : 0.05;
3992				break;
3993				case 'contrast': 
3994					v /= isNumeric(amount) ? +amount : 1.1;
3995				break;
3996				case 'gamma': 
3997					v /= isNumeric(amount) ? +amount : 1.1;
3998				break;
3999			}
4000			giga.adjust.set(o,v);
4001			//console.log(giga.adjust.getAll());
4002		},
4003		isNonZero: function() {
4004			return giga.adjust.get('brightness')!=0 && giga.adjust.get('contrast')!=0 && giga.adjust.get('gamma')!=0;
4005		}
4006	},
4007		
4008	light: {
4009		ready: false,
4010		readyHandler: function() {
4011			if (k.get('plugin[light]')) giga.light.ready = true;
4012		},
4013		setExposure: function(e=0) {
4014			giga.light.set('exposure',e);
4015		},
4016		setLights: function(l=0) {
4017			giga.light.set('lights',l);
4018		},
4019		setShadows: function(s=0) {
4020			giga.light.set('shadows',s);
4021		},
4022		setAll: function(e,l,s) {
4023			if (giga.light.ready) {
4024				giga.light.set('exposure',e);
4025				giga.light.set('lights',l);
4026				giga.light.set('shadows',s);
4027			}
4028		},
4029		reset: function() {
4030			if (giga.light.ready) giga.light.setAll(0,0,0);
4031		},
4032		set: function(o,v) {
4033			if (giga.light.ready) k.set('plugin[light].'+o,v);
4034		},
4035		get: function(o) {
4036			return k.get('plugin[light].'+o);
4037		},
4038		getAll: function() {
4039			return {
4040				exposure: giga.light.get('exposure'),
4041				lights: giga.light.get('lights'),
4042				shadows: giga.light.get('shadows')
4043			};
4044		},
4045		increase: function(o) {
4046			var v = giga.light.get(o);
4047			switch(o) {
4048				case 'exposure': 
4049					v += 0.2;
4050				break;
4051				case 'lights': 
4052					v += 0.2;
4053				break;
4054				case 'shadows': 
4055					v += 0.2;
4056				break;
4057			}
4058			giga.light.set(o,v);
4059			//console.log(giga.light.getAll());
4060		},
4061		decrease: function(o) {
4062			var v = giga.light.get(o);
4063			switch(o) {
4064				case 'exposure': 
4065					v -= 0.2;
4066				break;
4067				case 'lights': 
4068					v -= 0.2;
4069				break;
4070				case 'shadows': 
4071					v -= 0.2;
4072				break;
4073			}
4074			giga.light.set(o,v);
4075			//console.log(giga.light.getAll());
4076		}
4077	},
4078	
4079	compass: {
4080		container: null,
4081		svg: null,
4082		bearing: 0,
4083		angle: 0,
4084		leftbound: 0,
4085		rightbound: 0,
4086		create: function(sel) {
4087			giga.compass.container = document.querySelector(sel);
4088			if (giga.compass.container) {
4089				giga.compass.container.classList.add('clickable','toggle-overlay-map','noselect');
4090				giga.compass.container.title = "pokaż/ukryj mapę (M)";
4091				giga.compass.container.innerHTML = `
4092					<svg id="compass-svg" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 300 300">
4093						<circle id="compass-circle" cx="150" cy="150" r="150" />
4094						<path id="compass-bounds" d="M 150,150 l 150,0 a150,150 0 0,0 150,0 z" fill="yellow" />
4095						<path id="compass-segment" d="M 150,150 l 150,0 a150,150 0 0,0 150,0 z" fill="green" />
4096						<text id="compass-bearing" text-anchor="middle" dominant-baseline="central" x="150" y="150" />
4097					</svg>
4098				`;
4099				giga.compass.svg = document.querySelector('#compass-svg');
4100			}
4101			return giga.compass;
4102		},
4103		updateBounds: function(leftbound,rightbound) {
4104			//console.log('updating compass bounds');
4105			giga.compass.leftbound = leftbound || +k.info.azimuthleft;
4106			giga.compass.rightbound = rightbound || +k.info.azimuthright;
4107			//console.log(giga.compass.leftbound,giga.compass.rightbound);
4108			if (giga.compass.container) {
4109				const angle = (360 + giga.compass.rightbound - giga.compass.leftbound) % 360;
4110				const arc = giga.compass.svg.children['compass-bounds'];
4111				const pii = 0.017453292519943295;
4112				const lastx = 150, lasty = 0, arcType = angle<=180 ? 0 : 1;
4113				const nextx = Math.cos(angle*pii)*150;
4114				const nexty = Math.sin(angle*pii)*150;
4115				const rotangle = giga.compass.rightbound-90;
4116				//console.log('rotation angle '+rotangle);
4117				arc.setAttribute('d', `M 150,150 l 150,0 a150,150 0 ${arcType},0 ${nextx - lastx},${-(nexty - lasty)} z`);
4118				arc.setAttribute('transform', `rotate(${rotangle},150,150)`);
4119			}
4120			return giga.compass;
4121		},
4122		update: function(angle,bearing) {
4123			if (giga.compass.container) {
4124				giga.compass.bearing = bearing || 0;
4125				giga.compass.angle = angle;
4126				const arc = giga.compass.svg.children['compass-segment'];
4127				const text = giga.compass.svg.children['compass-bearing'];
4128				const pii = 0.017453292519943295;
4129				const lastx = 150, lasty = 0, arcType = angle<=180 ? 0 : 1;
4130				const nextx = Math.cos(angle*pii)*150;
4131				const nexty = Math.sin(angle*pii)*150;
4132				//arc.setAttribute('d', 'M 150,150 l 150,0 a150,150 0 '+arcType+',0 '+(nextx-lastx)+','+(-(nexty-lasty))+' z');
4133				arc.setAttribute('d', `M 150,150 l 150,0 a150,150 0 ${arcType},0 ${nextx - lastx},${-(nexty - lasty)} z`);
4134				arc.setAttribute('transform', 'rotate('+(giga.compass.angle/2-90+giga.compass.bearing)+',150,150)');
4135				text.textContent = giga.compass.bearing.toFixed(1)+'°';
4136			}
4137			return giga.compass;
4138		}
4139	},
4140	
4141	clipboard: {
4142		fallbackCopy: (text) => {
4143			const textarea = document.createElement("textarea");
4144			textarea.value = text;
4145			textarea.style.position = "fixed";
4146			textarea.style.top = "0";
4147			textarea.style.left = "0";
4148			document.body.appendChild(textarea);
4149			textarea.focus();
4150			textarea.select();
4151			try {
4152				document.execCommand("copy");
4153			} catch (_) {
4154				console.error('document.execCommand("copy") failed');
4155			}
4156			document.body.removeChild(textarea);
4157		},
4158		copy: (text) => {
4159			console.log('clipboard copy: ',text);
4160			if (navigator.clipboard && navigator.clipboard.writeText) {
4161				return navigator.clipboard.writeText(text).catch(() => {
4162					console.error('trying fallbackCopy');
4163					giga.clipboard.fallbackCopy(text);
4164				});
4165			} else {
4166				console.error('trying fallbackCopy (2)');
4167				giga.clipboard.fallbackCopy(text);
4168			}
4169
4170			/*
4171			let input = document.createElement('textarea');
4172			input.value = text;
4173			document.body.appendChild(input);
4174			input.select();
4175			document.execCommand('copy');
4176			document.body.removeChild(input);
4177			*/
4178		}
4179	},
4180	
4181	
4182	search: {
4183		disable: function() {
4184			const el = document.querySelector('header .js-typeahead');
4185			el.value = '';
4186			el.disabled = true;
4187		},
4188		initialize: function() {
4189			//console.log('initializing search: '+giga.currentPanorama.getID());
4190			const hslist = giga.currentPanorama.getHotspotsList();
4191			$.typeahead({
4192				debug: false,
4193				minLength: 0,
4194				maxItem: 10,
4195				cancelButton: false,
4196				accent: true,
4197				input: "header .js-typeahead",
4198				order: "asc",
4199				display: ["title"],
4200				mustSelectItem: true,
4201				source: hslist,
4202				href: function(e) {
4203					return 'view,'+giga.currentPanorama.getID()+'.html#!'+e.ath+','+e.atv+','+'0.02';
4204				},
4205				template: function (query, item) {
4206					const point = hotspots.points.get(item.id);
4207					let poptext = '', template = '<span>{{title}}</span>';
4208					if (hasValue(point)) {
4209						if (hasValue(point.lat,point.lon)) {
4210							const rh = giga.currentPanorama.richHotspots.find( e => e.id == item.id );
4211							if (rh && rh?.distance) poptext = `odległość: ${rh.distance.toFixed(1)} km, azymut: ${rh.bearingreal.toFixed(2)}°`;
4212						}
4213						if (hasValue(point.elevation)) template += ` <span class="ele">${point.elevation}</span>`;
4214						if (hasValue(point.range)) template += ` <span class="range">${point.range}</span>`;
4215					}
4216					template = `<span title="${poptext}">${template}</span>`;
4217					return template;
4218				},
4219				callback: {
4220					onSearch: function (node, query) {
4221						//console.log('onSearch');
4222						//console.log(node, query);
4223					},
4224					onClickAfter: function (node, a, item, event) {
4225						//console.log('onClickAfter');
4226						//console.log(item);
4227						giga.moveToHotspotAndClick(item.name);
4228						//console.log(node, a, item, event);
4229					},
4230					onSubmit: function (node, form, item, event) {
4231						event.preventDefault();
4232						giga.moveToHotspotAndClick(item.name);
4233						//console.log('onSubmit');
4234						//console.log(node, form, item, event);
4235					}
4236				},
4237				emptyTemplate: function (query) {
4238					if (query.length>0) {
4239						return `Nic nie znaleziono dla <i>${escapeXML(query)}</i>`;
4240					}
4241				}
4242			});
4243			document.querySelector('header').querySelectorAll('.typeahead-hint, .typeahead-result, .typeahead-filter-button, .typeahead-filter').forEach(el => el.remove());
4244			document.querySelector('input#search').placeholder = `szukaj na panoramie (${hslist.length} ${odmien(hslist.length,'obiekt','obiekty','obiektów')})`;
4245			document.querySelector('header .js-typeahead').disabled = false;
4246		}
4247	},
4248	
4249	onMiniReadyHandler: function(krpano) {
4250		kmini = krpano;
4251	},
4252	
4253	loadMiniPanorama: function(panid,ath,atv,vfov) {
4254		
4255		console.log('loadMiniPanorama',panid,ath,atv,vfov);
4256		
4257		if ($('#pano-mini').length==0) {
4258			$('<div id="pano-mini" class="draggable resizable"></div>').appendTo($('body')).show();
4259			$('#pano-mini').resizable({
4260				handles: 'n, e, s, w, se, sw, nw',
4261				autoHide: true,
4262				cancel: ".no-resize",
4263				minWidth: 180,
4264				minHeight: 120
4265			});
4266			if (0) $('#pano-mini').draggable({
4267				cursor: 'move',
4268				cancel: '.no-drag, input, textarea, button, select, option',
4269				scroll: false
4270			});
4271		} else {
4272			giga.removeMiniPanorama();
4273			$('#pano-mini').empty().show();
4274		}
4275		
4276		const settings = {
4277			inithlookat: ath,
4278			initvlookat: atv,
4279			initvfov: vfov
4280		};
4281		
4282		embedpano({
4283			id: "krpano-mini",
4284			xml: "mini.xml",
4285			target: "pano-mini",
4286			html5: "only",
4287			initvars: {dir: panid}, 
4288			vars: settings,
4289			mobilescale: 0.5,
4290			passQueryParameters: false,
4291			preserveDrawingBuffer: true,
4292			debugmode: giga.advanced,
4293			showerrors: false,
4294			onready: giga.onMiniReadyHandler,
4295			consolelog: true
4296		});
4297	},
4298	
4299	removeMiniPanorama: function() {
4300		console.log('removeMiniPanorama');
4301		try {
4302			removepano('krpano-mini');
4303			$('#pano-mini').hide();
4304		} catch(e) {
4305		}
4306		kmini = null;
4307	},
4308	
4309	collisions: {
4310		margin: 5,
4311		delay: 60,
4312		timeoutIndex: 0,
4313		timeoutIDs: [],
4314		isMonitoring: false,
4315		_labelCache: null,
4316		_labelLevels: new WeakMap(),
4317
4318		_rectCache: [],
4319		_rectCount: 0,
4320		_grid: new Map(),
4321		cellSize: 120,   // fallback
4322
4323		// ---------------------------------------------------------
4324		// QUICKSELECT (median) — in-place, no allocations
4325		// ---------------------------------------------------------
4326		_quickselect(arr, k, left = 0, right = arr.length - 1) {
4327			while (true) {
4328				if (left === right) return arr[left];
4329				let pivotIndex = left + ((right - left) >> 1);
4330				pivotIndex = this._partition(arr, left, right, pivotIndex);
4331				if (k === pivotIndex) return arr[k];
4332				else if (k < pivotIndex) right = pivotIndex - 1;
4333				else left = pivotIndex + 1;
4334			}
4335		},
4336
4337		_partition(arr, left, right, pivotIndex) {
4338			const pivotValue = arr[pivotIndex];
4339			[arr[pivotIndex], arr[right]] = [arr[right], arr[pivotIndex]];
4340			let storeIndex = left;
4341			for (let i = left; i < right; i++) {
4342				if (arr[i] < pivotValue) {
4343					[arr[storeIndex], arr[i]] = [arr[i], arr[storeIndex]];
4344					storeIndex++;
4345				}
4346			}
4347			[arr[right], arr[storeIndex]] = [arr[storeIndex], arr[right]];
4348			return storeIndex;
4349		},
4350
4351		// ---------------------------------------------------------
4352		// COMPUTE CELL SIZE FROM MEDIAN LABEL SIZE
4353		// ---------------------------------------------------------
4354		_computeCellSize(rects, n) {
4355			if (n === 0) return 120;
4356
4357			const widths  = new Array(n);
4358			const heights = new Array(n);
4359
4360			for (let i = 0; i < n; i++) {
4361				const r = rects[i];
4362				widths[i]  = r.right - r.left;
4363				heights[i] = r.bottom - r.top;
4364			}
4365
4366			const mid = n >> 1;
4367
4368			const medW = this._quickselect(widths, mid);
4369			const medH = this._quickselect(heights, mid);
4370
4371			const base = Math.max(medW, medH) + this.margin * 2;
4372
4373			// minimal sanity clamp
4374			return base < 40 ? 40 : base;
4375		},
4376
4377		// ---------------------------------------------------------
4378		// GRID KEY
4379		// ---------------------------------------------------------
4380		_key(x, y) {
4381			return (x << 16) ^ y;
4382		},
4383
4384		// ---------------------------------------------------------
4385		// BUILD GRID
4386		// ---------------------------------------------------------
4387		_buildGrid(rects, n) {
4388			const grid = this._grid;
4389			grid.clear();
4390
4391			const cs = this.cellSize;
4392
4393			for (let i = 0; i < n; i++) {
4394				const r = rects[i];
4395
4396				const x1 = (r.left  / cs) | 0;
4397				const y1 = (r.top   / cs) | 0;
4398				const x2 = (r.right / cs) | 0;
4399				const y2 = (r.bottom/ cs) | 0;
4400
4401				for (let gx = x1; gx <= x2; gx++) {
4402					for (let gy = y1; gy <= y2; gy++) {
4403						const key = this._key(gx, gy);
4404						let cell = grid.get(key);
4405						if (!cell) {
4406							cell = [];
4407							grid.set(key, cell);
4408						}
4409						cell.push(r);
4410					}
4411				}
4412			}
4413		},
4414
4415		// ---------------------------------------------------------
4416		// COLLISION CHECK
4417		// ---------------------------------------------------------
4418		_collide(r1, r2, marg) {
4419			return (
4420				r1.right + marg >= r2.left &&
4421				r1.left <= r2.right + marg &&
4422				r1.bottom + marg >= r2.top &&
4423				r1.top <= r2.bottom + marg
4424			);
4425		},
4426
4427		// ---------------------------------------------------------
4428		// GRID COLLISION DETECTION
4429		// ---------------------------------------------------------
4430		calculate(rects, n) {
4431			const out = [];
4432			const marg = this.margin;
4433			const grid = this._grid;
4434
4435			for (const cell of grid.values()) {
4436				const m = cell.length;
4437				if (m < 2) continue;
4438
4439				for (let i = 0; i < m; i++) {
4440					const r1 = cell[i];
4441					for (let j = i + 1; j < m; j++) {
4442						const r2 = cell[j];
4443						if (this._collide(r1, r2, marg)) {
4444							out.push([r1, r2]);
4445						}
4446					}
4447				}
4448			}
4449			return out;
4450		},
4451
4452		// ---------------------------------------------------------
4453		// LABEL CACHE
4454		// ---------------------------------------------------------
4455		getLabels() {
4456			return this._labelCache ??
4457				Array.from(document.querySelectorAll('#pano .labelstyle'));
4458		},
4459
4460		clearFlags() {
4461			const labels = this.getLabels();
4462			for (let i = 0; i < labels.length; i++) {
4463				labels[i].classList.remove('colliding');
4464			}
4465		},
4466
4467		// ---------------------------------------------------------
4468		// MAIN GET()
4469		// ---------------------------------------------------------
4470		get() {
4471			const self   = this;
4472			const labels = self.getLabels();
4473
4474			if (self._labelCache === null && labels.length > 0) {
4475				self._labelCache = labels;
4476				self._labelLevels = new WeakMap();
4477				for (let i = 0; i < labels.length; i++) {
4478					const e = labels[i];
4479					const lvl = +(e.querySelector('[data-level]')?.dataset.level ?? 0);
4480					self._labelLevels.set(e, lvl);
4481				}
4482			}
4483
4484			const rects = self._rectCache;
4485			let count = 0;
4486
4487			for (let i = 0; i < labels.length; i++) {
4488				const e = labels[i];
4489				const r = e.getBoundingClientRect();
4490				if (r.width !== 0) {
4491					const obj = rects[count] || (rects[count] = {});
4492					obj.left   = r.left;
4493					obj.right  = r.right;
4494					obj.top    = r.top;
4495					obj.bottom = r.bottom;
4496					obj.level  = self._labelLevels.get(e);
4497					obj.element = e;
4498					count++;
4499				}
4500			}
4501
4502			self._rectCount = count;
4503
4504			// dynamic cell size from median label size
4505			self.cellSize = self._computeCellSize(rects, count);
4506
4507			// build spatial grid
4508			self._buildGrid(rects, count);
4509
4510			// detect collisions
4511			return self.calculate(rects, count);
4512		},
4513
4514		// ---------------------------------------------------------
4515		// SHOW
4516		// ---------------------------------------------------------
4517		show() {
4518			//console.log('collisions.show()');
4519			this.clearFlags();
4520			const collisions = this.get();
4521
4522			const curLvl = giga.getCurrentViewParameters().level;
4523			const maxLvl = giga.currentPanorama.levels.at(-1).level;
4524
4525			if (maxLvl !== curLvl || k.state.ignoremaxzoom) {
4526				//console.log('collisions.show() real');
4527				for (let i = 0; i < collisions.length; i++) {
4528					const [a, b] = collisions[i];
4529					const which = (a.level > b.level) ? a : b;
4530					const other = (which === a) ? b : a;
4531
4532					if (!other.element.classList.contains('colliding')) {
4533						which.element.classList.add('colliding');
4534					}
4535				}
4536			}
4537			return collisions;
4538		},
4539
4540		// ---------------------------------------------------------
4541		// MONITORING
4542		// ---------------------------------------------------------
4543		monitorCallback() {
4544			const self = giga.collisions;
4545			
4546			if (self.timeoutIDs.length > 3) {
4547				clearTimeout(self.timeoutIDs.splice(1, 1)[0].id);
4548			}
4549
4550			const idx = self.timeoutIndex++;
4551			const tout = {
4552				idx,
4553				id: setTimeout(() => {
4554					const tIdx = self.timeoutIDs.findIndex(e => e.idx === idx);
4555					if (tIdx >= 0) self.timeoutIDs.splice(tIdx, 1);
4556					self.show();
4557				}, self.delay)
4558			};
4559			self.timeoutIDs.push(tout);
4560		},
4561
4562		monitor(start = true) {
4563			const self = giga.collisions;
4564
4565			if (start) {
4566				if (!self.isMonitoring) {
4567					self._labelCache = null;
4568					self._labelLevels = new WeakMap();
4569					k.events.addListener('onviewchange', self.monitorCallback);
4570				}
4571				self.isMonitoring = true;
4572			} else {
4573				if (self.isMonitoring) {
4574					k.events.removeListener('onviewchange', self.monitorCallback);
4575					self.clearFlags();
4576					self._labelCache = null;
4577					self._labelLevels = new WeakMap();
4578				}
4579				self.isMonitoring = false;
4580			}
4581		}
4582	},
4583
4584	collisionsV5: {
4585		
4586		margin: 5,
4587		delay: 60,
4588		timeoutIndex: 0,
4589		timeoutIDs: [],
4590		isMonitoring: false,
4591		_labelCache: null,
4592		_labelLevels: new WeakMap(),
4593
4594		calculate: function(rects) {
4595			const marg       = giga.collisions.margin;
4596			const collisions = [];
4597
4598			// filter zero-width rects once, sort by left edge for sweep
4599			const active = rects.filter(r => r.width !== 0).sort((a, b) => a.left - b.left);
4600
4601			const n = active.length;
4602			for (let i = 0; i < n; i++) {
4603				const r1        = active[i];
4604				const sweepEdge = r1.right + marg;
4605				for (let j = i + 1; j < n; j++) {
4606					const r2 = active[j];
4607					if (r2.left > sweepEdge) break;  // all remaining r2s are further right
4608					if (r1.bottom + marg >= r2.top && r1.top <= r2.bottom + marg) {
4609						collisions.push([r1, r2]);
4610					}
4611				}
4612			}
4613			return collisions;
4614		},
4615		
4616		calculateOLD: function(rectangles) {
4617			const collisions = [];
4618			for (let i = 0; i < rectangles.length; i++) {
4619				for (let j = i + 1; j < rectangles.length; j++) {
4620					if (giga.collisions.isColliding(rectangles[i], rectangles[j])) {
4621						collisions.push([rectangles[i], rectangles[j]]);
4622					}
4623				}
4624			}
4625			return collisions;
4626		},
4627		
4628		getLabels: function() {
4629			return giga.collisions._labelCache ?? Array.from(document.querySelectorAll('#pano .labelstyle'));
4630		},
4631		
4632		clearFlags: function() {
4633			giga.collisions.getLabels().forEach(e => { e.classList.remove('colliding') });
4634		},
4635
4636		isColliding: function(rect1, rect2) {
4637			if (rect1.width==0 || rect2.width==0) return false;
4638			const marg = giga.collisions.margin;
4639			return !(rect1.right+marg < rect2.left || rect1.left > rect2.right+marg || rect1.bottom+marg < rect2.top || rect1.top > rect2.bottom+marg);
4640		},
4641
4642		get: function() {
4643			const self   = giga.collisions;
4644			const labels = self.getLabels();
4645			// populate caches on first get() that actually finds elements
4646			if (self._labelCache === null && labels.length > 0) {
4647				self._labelCache = labels;
4648				self._labelLevels = new WeakMap();
4649				labels.forEach(e => {
4650					self._labelLevels.set(e, +(e.querySelector('[data-level]')?.dataset.level ?? 0));
4651				});
4652			}
4653			const rects = labels.map(e => {
4654				const lvl = self._labelLevels.has(e)
4655					? self._labelLevels.get(e)
4656					: +(e.querySelector('[data-level]')?.dataset.level ?? 0);
4657				//return Object.assign(e.getBoundingClientRect(), {text: e.innerText, level: lvl, element: e});
4658				return Object.assign(e.getBoundingClientRect(), {level: lvl, element: e});
4659			});
4660			return self.calculate(rects);
4661		},
4662
4663		show: function() {
4664			//console.time('collisions.show'); 
4665			giga.collisions.clearFlags();
4666			const collisions = giga.collisions.get();
4667			if (giga.currentPanorama.levels.at(-1).level!=giga.getCurrentViewParameters().level || k.state.ignoremaxzoom) collisions.forEach(els => { 
4668				const which = els[0].level>els[1].level ? 0 : 1;
4669				const other = (which+1)%2;
4670				if (!els[other].element.classList.contains('colliding')) {
4671					els[which].element.classList.add('colliding');
4672				}
4673			});
4674			//console.timeEnd('collisions.show');
4675			return collisions;
4676		},
4677		
4678		monitorCallback: function() {
4679			const self = giga.collisions;
4680			if (self.timeoutIDs.length > 3) {
4681				clearTimeout(self.timeoutIDs.splice(1, 1)[0].id);
4682			}
4683			const idx  = self.timeoutIndex++;
4684			const tout = {idx: idx, id: setTimeout(() => {
4685				const tIdx = self.timeoutIDs.findIndex(e => e.idx==idx);
4686				if (tIdx >= 0) self.timeoutIDs.splice(tIdx, 1);
4687				self.show();
4688			}, self.delay)};
4689			self.timeoutIDs.push(tout);
4690		},
4691
4692		monitor: function(startMonitoring = true) {
4693			const self = giga.collisions;
4694			if (startMonitoring) {
4695				if (!self.isMonitoring) {
4696					// reset caches — get() will populate on first call once elements exist
4697					self._labelCache  = null;
4698					self._labelLevels = new WeakMap();
4699					k.events.addListener('onviewchange', self.monitorCallback);
4700				}
4701				self.isMonitoring = true;
4702			} else {
4703				if (self.isMonitoring) {
4704					k.events.removeListener('onviewchange', self.monitorCallback);
4705					self.clearFlags();
4706					self._labelCache  = null;
4707					self._labelLevels = new WeakMap();
4708				}
4709				self.isMonitoring = false;
4710			}
4711		}
4712	
4713	},
4714	
4715		
4716	collisionsV4: {
4717
4718		margin: 5,
4719		timeoutIndex: 0,
4720		timeoutIDs: [],
4721		delay: 50,
4722		isMonitoring: false,
4723		visibleLabels: new Set(),
4724		_observer: null,
4725		_labelLevels: new WeakMap(),
4726
4727		calculate: function(rects) {
4728			const marg       = giga.collisions.margin;
4729			const collisions = [];
4730			const active     = rects.filter(r => r.width !== 0)
4731								   .sort((a, b) => a.left - b.left);
4732			const n = active.length;
4733			for (let i = 0; i < n; i++) {
4734				const r1        = active[i];
4735				const sweepEdge = r1.right + marg;
4736				for (let j = i + 1; j < n; j++) {
4737					const r2 = active[j];
4738					if (r2.left > sweepEdge) break;
4739					if (r1.bottom + marg >= r2.top && r1.top <= r2.bottom + marg) {
4740						collisions.push([r1, r2]);
4741					}
4742				}
4743			}
4744			return collisions;
4745		},
4746
4747		getLabels: function() {
4748			return document.querySelectorAll('#pano .labelstyle');
4749		},
4750
4751		clearFlags: function() {
4752			giga.collisions.getLabels().forEach(e => e.classList.remove('colliding'));
4753		},
4754
4755		get: function() {
4756			const self    = giga.collisions;
4757			const visible = self.visibleLabels;
4758			const labels  = visible.size > 0
4759				? Array.from(visible)
4760				: Array.from(self.getLabels());
4761			const n        = labels.length;
4762			const domRects = new Array(n);
4763			for (let i = 0; i < n; i++) domRects[i] = labels[i].getBoundingClientRect();
4764
4765			const rects = new Array(n);
4766			for (let i = 0; i < n; i++) {
4767				const e   = labels[i];
4768				const r   = domRects[i];
4769				rects[i]  = { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, level: self._labelLevels.get(e) ?? 0, element: e };
4770			}
4771			return self.calculate(rects);
4772		},
4773
4774		show: function() {
4775			const self       = giga.collisions;
4776			const collisions = self.get();
4777			const atMaxZoom  = giga.currentPanorama.levels.at(-1).level === giga.getCurrentViewParameters().level
4778							   && !k.state.ignoremaxzoom;
4779
4780			const toCollide = new Set();
4781			if (!atMaxZoom) {
4782				for (const els of collisions) {
4783					const which = els[0].level > els[1].level ? 0 : 1;
4784					const other = which ^ 1;
4785					if (!els[other].element.classList.contains('colliding')) {
4786						toCollide.add(els[which].element);
4787					}
4788				}
4789			}
4790
4791			self.getLabels().forEach(e => {
4792				if (toCollide.has(e)) {
4793					e.classList.add('colliding');
4794				} else {
4795					e.classList.remove('colliding');
4796				}
4797			});
4798
4799			return collisions;
4800		},
4801
4802		monitorCallback: function() {
4803			const self = giga.collisions;
4804			if (self.timeoutIDs.length > 3) {
4805				clearTimeout(self.timeoutIDs.splice(1).id);
4806			}
4807			const idx  = self.timeoutIndex++;
4808			const tout = {
4809				idx,
4810				id: setTimeout(() => {
4811					const tIdx = self.timeoutIDs.findIndex(e => e.idx === idx);
4812					if (tIdx >= 0) self.timeoutIDs.splice(tIdx, 1);
4813					self.show();
4814				}, self.delay)
4815			};
4816			self.timeoutIDs.push(tout);
4817		},
4818
4819		monitor: function(startMonitoring = true) {
4820			const self   = giga.collisions;
4821			const labels = self.getLabels();
4822
4823			if (startMonitoring) {
4824				if (!self.isMonitoring) {
4825					// cache data-level for each label element once
4826					self._labelLevels = new WeakMap();
4827					labels.forEach(e => {
4828						const lvl = e.querySelector('[data-level]')?.dataset.level ?? 0;
4829						self._labelLevels.set(e, +lvl);
4830					});
4831
4832					self._observer = new IntersectionObserver(entries => {
4833						for (const entry of entries) {
4834							if (entry.isIntersecting) {
4835								self.visibleLabels.add(entry.target);
4836							} else {
4837								self.visibleLabels.delete(entry.target);
4838								entry.target.classList.remove('colliding');
4839							}
4840						}
4841						self.show();
4842					}, {
4843						root: document.getElementById('pano'),
4844						threshold: 0
4845					});
4846
4847					labels.forEach(e => self._observer.observe(e));
4848					k.events.addListener('onviewchange', self.monitorCallback);
4849				}
4850				self.isMonitoring = true;
4851			} else {
4852				if (self.isMonitoring) {
4853					k.events.removeListener('onviewchange', self.monitorCallback);
4854					self._observer.disconnect();
4855					self._observer = null;
4856					self.visibleLabels.clear();
4857					self._labelLevels = new WeakMap();
4858					self.clearFlags();
4859				}
4860				self.isMonitoring = false;
4861			}
4862		},
4863
4864	},
4865	
4866	
4867	collisionsV3: {
4868
4869		margin: 5,
4870		timeoutIndex: 0,
4871		timeoutIDs: [],
4872		isMonitoring: false,
4873		visibleLabels: new Set(),
4874		_observer: null,
4875
4876		calculate: function(rects) {
4877			const marg       = giga.collisions.margin;
4878			const collisions = [];
4879			const active     = rects.filter(r => r.width !== 0)
4880								   .sort((a, b) => a.left - b.left);
4881			const n = active.length;
4882			for (let i = 0; i < n; i++) {
4883				const r1        = active[i];
4884				const sweepEdge = r1.right + marg;
4885				for (let j = i + 1; j < n; j++) {
4886					const r2 = active[j];
4887					if (r2.left > sweepEdge) break;
4888					if (r1.bottom + marg >= r2.top && r1.top <= r2.bottom + marg) {
4889						collisions.push([r1, r2]);
4890					}
4891				}
4892			}
4893			return collisions;
4894		},
4895
4896		getLabels: function() {
4897			return document.querySelectorAll('#pano .labelstyle');
4898		},
4899
4900		clearFlags: function() {
4901			giga.collisions.getLabels().forEach(e => e.classList.remove('colliding'));
4902		},
4903
4904		get: function() {
4905			const visible = giga.collisions.visibleLabels;
4906			const labels  = visible.size > 0
4907				? Array.from(visible)
4908				: Array.from(giga.collisions.getLabels());
4909			const n        = labels.length;
4910			const domRects = new Array(n);
4911			for (let i = 0; i < n; i++) domRects[i] = labels[i].getBoundingClientRect();
4912
4913			const rects = new Array(n);
4914			for (let i = 0; i < n; i++) {
4915				const e   = labels[i];
4916				const r   = domRects[i];
4917				const lvl = e.querySelector('[data-level]')?.dataset.level ?? 0;
4918				rects[i]  = { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, level: +lvl, element: e };
4919			}
4920			return giga.collisions.calculate(rects);
4921		},
4922		show: function() {
4923			const self       = giga.collisions;
4924			const collisions = self.get();
4925			const atMaxZoom  = self.levels?.at(-1).level === giga.getCurrentViewParameters().level
4926							   && !k.state.ignoremaxzoom;
4927
4928			// collect all class changes first, apply in one write pass
4929			const toCollide = new Set();
4930			const toClear   = new Set();
4931
4932			if (!atMaxZoom) {
4933				for (const els of collisions) {
4934					const which = els[0].level > els[1].level ? 0 : 1;
4935					const other = which ^ 1;
4936					if (!els[other].element.classList.contains('colliding')) {
4937						toCollide.add(els[which].element);
4938					}
4939				}
4940			}
4941
4942			// single write pass: clear all, then add where needed
4943			self.getLabels().forEach(e => {
4944				if (toCollide.has(e)) {
4945					e.classList.add('colliding');
4946				} else {
4947					e.classList.remove('colliding');
4948				}
4949			});
4950
4951			return collisions;
4952		},
4953		showXX: function() {
4954			giga.collisions.clearFlags();
4955
4956			const collisions = giga.collisions.get();
4957			const atMaxZoom  = giga.currentPanorama.levels.at(-1).level === giga.getCurrentViewParameters().level
4958							   && !k.state.ignoremaxzoom;
4959			if (!atMaxZoom) {
4960				for (const els of collisions) {
4961					const which = els[0].level > els[1].level ? 0 : 1;
4962					const other = which ^ 1;
4963					if (!els[other].element.classList.contains('colliding')) {
4964						els[which].element.classList.add('colliding');
4965					}
4966				}
4967			}
4968			return collisions;
4969		},
4970
4971		monitorCallback: function() {
4972			const self = giga.collisions;
4973			if (self.timeoutIDs.length > 3) {
4974				clearTimeout(self.timeoutIDs.splice(1).id);
4975			}
4976			const idx  = self.timeoutIndex++;
4977			const tout = {
4978				idx,
4979				id: setTimeout(() => {
4980					const tIdx = self.timeoutIDs.findIndex(e => e.idx === idx);
4981					if (tIdx >= 0) self.timeoutIDs.splice(tIdx, 1);
4982					self.show();
4983				}, 60)
4984			};
4985			self.timeoutIDs.push(tout);
4986		},
4987
4988		monitor: function(startMonitoring = true) {
4989			const self   = giga.collisions;
4990			const labels = giga.collisions.getLabels();
4991
4992			if (startMonitoring) {
4993				if (!self.isMonitoring) {
4994					self._observer = new IntersectionObserver(entries => {
4995						for (const entry of entries) {
4996							if (entry.isIntersecting) {
4997								self.visibleLabels.add(entry.target);
4998							} else {
4999								self.visibleLabels.delete(entry.target);
5000								entry.target.classList.remove('colliding');
5001							}
5002						}
5003						self.show();
5004					}, {
5005						root: document.getElementById('pano'),
5006						threshold: 0
5007					});
5008					labels.forEach(e => self._observer.observe(e));
5009					k.events.addListener('onviewchange', self.monitorCallback);
5010				}
5011				self.isMonitoring = true;
5012			} else {
5013				if (self.isMonitoring) {
5014					k.events.removeListener('onviewchange', self.monitorCallback);
5015					self._observer.disconnect();
5016					self._observer = null;
5017					self.visibleLabels.clear();
5018					self.clearFlags();
5019				}
5020				self.isMonitoring = false;
5021			}
5022		},
5023
5024	},
5025	collisionsV2AI: {
5026
5027		margin: 5,
5028		timeoutIndex: 0,
5029		timeoutIDs: [],
5030		isMonitoring: false,
5031		
5032		calculate: function(rects) {
5033			const marg       = giga.collisions.margin;
5034			const collisions = [];
5035
5036			// filter zero-width rects once, sort by left edge for sweep
5037			const active = rects.filter(r => r.width !== 0).sort((a, b) => a.left - b.left);
5038
5039			const n = active.length;
5040			for (let i = 0; i < n; i++) {
5041				const r1        = active[i];
5042				const sweepEdge = r1.right + marg;
5043				for (let j = i + 1; j < n; j++) {
5044					const r2 = active[j];
5045					if (r2.left > sweepEdge) break;  // all remaining r2s are further right
5046					if (r1.bottom + marg >= r2.top && r1.top <= r2.bottom + marg) {
5047						collisions.push([r1, r2]);
5048					}
5049				}
5050			}
5051			return collisions;
5052		},
5053		
5054		calculateN2: function(rects) {
5055			const n          = rects.length;
5056			const marg       = giga.collisions.margin;
5057			const collisions = [];
5058			for (let i = 0; i < n; i++) {
5059				const r1 = rects[i];
5060				if (r1.width === 0) continue;
5061				for (let j = i + 1; j < n; j++) {
5062					const r2 = rects[j];
5063					if (r2.width === 0) continue;
5064					if (r1.right+marg >= r2.left && r1.left <= r2.right+marg &&
5065						r1.bottom+marg >= r2.top  && r1.top  <= r2.bottom+marg) {
5066						collisions.push([r1, r2]);
5067					}
5068				}
5069			}
5070			return collisions;
5071		},
5072
5073		getLabels: function() {
5074			return document.querySelectorAll('#pano .labelstyle');
5075		},
5076
5077		clearFlags: function() {
5078			giga.collisions.getLabels().forEach(e => e.classList.remove('colliding'));
5079		},
5080		
5081		get: function() {
5082			const labels  = giga.collisions.getLabels();
5083			const n       = labels.length;
5084			const domRects = new Array(n);
5085			for (let i = 0; i < n; i++) domRects[i] = labels[i].getBoundingClientRect();
5086
5087			const rects = new Array(n);
5088			for (let i = 0; i < n; i++) {
5089				const e   = labels[i];
5090				const r   = domRects[i];
5091				const lvl = e.querySelector('[data-level]')?.dataset.level ?? 0;
5092				rects[i]  = { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, level: +lvl, element: e };
5093			}
5094			return giga.collisions.calculate(rects);
5095		},
5096		
5097		getOLD: function() {
5098			const rects = Array.from(giga.collisions.getLabels()).map(e => {
5099				const lvl = e.querySelector('[data-level]')?.dataset.level ?? 0;
5100				return Object.assign(e.getBoundingClientRect(), { level: +lvl, element: e });
5101			});
5102			return giga.collisions.calculate(rects);
5103		},
5104
5105		show: function() {
5106			giga.collisions.clearFlags();
5107			const collisions = giga.collisions.get();
5108			const atMaxZoom  = giga.currentPanorama.levels.at(-1).level === giga.getCurrentViewParameters().level
5109							   && !k.state.ignoremaxzoom;
5110			if (!atMaxZoom) {
5111				for (const els of collisions) {
5112					const which = els[0].level > els[1].level ? 0 : 1;
5113					const other = which ^ 1;
5114					if (!els[other].element.classList.contains('colliding')) {
5115						els[which].element.classList.add('colliding');
5116					}
5117				}
5118			}
5119			return collisions;
5120		},
5121
5122		monitorCallback: function() {
5123			if (giga.collisions.timeoutIDs.length > 3) {
5124				clearTimeout(giga.collisions.timeoutIDs.splice(1).id);
5125			}
5126			const idx  = giga.collisions.timeoutIndex++;
5127			const tout = {
5128				idx,
5129				id: setTimeout(() => {
5130					const tIdx = giga.collisions.timeoutIDs.findIndex(e => e.idx === idx);
5131					if (tIdx >= 0) giga.collisions.timeoutIDs.splice(tIdx, 1);
5132					giga.collisions.show();
5133				}, 60)
5134			};
5135			giga.collisions.timeoutIDs.push(tout);
5136		},
5137
5138		monitor: function(startMonitoring = true) {
5139			if (startMonitoring) {
5140				if (!giga.collisions.isMonitoring) {
5141					k.events.addListener('onviewchange', giga.collisions.monitorCallback);
5142				}
5143				giga.collisions.isMonitoring = true;
5144			} else {
5145				if (giga.collisions.isMonitoring) {
5146					k.events.removeListener('onviewchange', giga.collisions.monitorCallback);
5147					giga.collisions.clearFlags();
5148				}
5149				giga.collisions.isMonitoring = false;
5150			}
5151		}
5152
5153	},
5154	
5155	collisionsV1: {
5156		
5157		margin: 5,
5158		timeoutIndex: 0,
5159		timeoutIDs: [],
5160		isMonitoring: false,
5161
5162		calculate: function(rects) {
5163			const marg       = giga.collisions.margin;
5164			const collisions = [];
5165
5166			// filter zero-width rects once, sort by left edge for sweep
5167			const active = rects.filter(r => r.width !== 0).sort((a, b) => a.left - b.left);
5168
5169			const n = active.length;
5170			for (let i = 0; i < n; i++) {
5171				const r1        = active[i];
5172				const sweepEdge = r1.right + marg;
5173				for (let j = i + 1; j < n; j++) {
5174					const r2 = active[j];
5175					if (r2.left > sweepEdge) break;  // all remaining r2s are further right
5176					if (r1.bottom + marg >= r2.top && r1.top <= r2.bottom + marg) {
5177						collisions.push([r1, r2]);
5178					}
5179				}
5180			}
5181			return collisions;
5182		},
5183		
5184		calculateOLD: function(rectangles) {
5185			const collisions = [];
5186			for (let i = 0; i < rectangles.length; i++) {
5187				for (let j = i + 1; j < rectangles.length; j++) {
5188					if (giga.collisions.isColliding(rectangles[i], rectangles[j])) {
5189						collisions.push([rectangles[i], rectangles[j]]);
5190					}
5191				}
5192			}
5193			return collisions;
5194		},
5195		
5196		getLabels: () => {
5197			return document.querySelectorAll('#pano .labelstyle');
5198		},
5199		
5200		clearFlags: () => {
5201			giga.collisions.getLabels().forEach(e => { e.classList.remove('colliding') });
5202		},
5203
5204		isColliding: function(rect1, rect2) {
5205			if (rect1.width==0 || rect2.width==0) return false;
5206			const marg = giga.collisions.margin;
5207			return !(rect1.right+marg < rect2.left || rect1.left > rect2.right+marg || rect1.bottom+marg < rect2.top || rect1.top > rect2.bottom+marg);
5208		},
5209
5210		get: function() {
5211			const rects = Array.from(giga.collisions.getLabels()).map(e => {
5212				const lvl = e.querySelector('[data-level]')?.dataset.level ?? 0;
5213				//return Object.assign(e.getBoundingClientRect(), {text: e.innerText, level: +lvl, element: e});
5214				return Object.assign(e.getBoundingClientRect(), {level: +lvl, element: e});
5215			});
5216			return giga.collisions.calculate(rects);
5217		},
5218
5219		show: function() {
5220			//console.time('collisions.show'); 
5221			giga.collisions.clearFlags();
5222			const collisions = giga.collisions.get();
5223			if (giga.currentPanorama.levels.at(-1).level!=giga.getCurrentViewParameters().level || k.state.ignoremaxzoom) collisions.forEach(els => { 
5224				const which = els[0].level>els[1].level ? 0 : 1;
5225				const other = (which+1)%2;
5226				if (!els[other].element.classList.contains('colliding')) {
5227					els[which].element.classList.add('colliding');
5228				}
5229			});
5230			//console.timeEnd('collisions.show');
5231			return collisions;
5232		},
5233		
5234		monitorCallback: function() {
5235			//console.log('giga.collisions.timeoutIDs.length: ',giga.collisions.timeoutIDs.length);
5236			if (giga.collisions.timeoutIDs.length>3) {
5237				//console.log('cleaning');
5238				clearTimeout(giga.collisions.timeoutIDs.splice(1).id); 
5239			}
5240			//console.log('giga.collisions.timeoutIDs.length: ',giga.collisions.timeoutIDs.length);
5241			const idx = giga.collisions.timeoutIndex++;
5242			const tout = {idx: idx, id: setTimeout( () => {
5243				const tIdx = giga.collisions.timeoutIDs.findIndex(e => e.idx==idx);
5244				if (tIdx>=0) giga.collisions.timeoutIDs.splice(tIdx);
5245				giga.collisions.show();
5246			}, 60)};
5247			//console.log(tout);
5248			giga.collisions.timeoutIDs.push(tout);			
5249		},
5250
5251		monitor: function(startMonitoring = true) {
5252			if (startMonitoring) {
5253				if (!giga.collisions.isMonitoring) {
5254					k.events.addListener('onviewchange', giga.collisions.monitorCallback);
5255				}
5256				giga.collisions.isMonitoring = true;
5257			} else {
5258				if (giga.collisions.isMonitoring) { 
5259					k.events.removeListener('onviewchange', giga.collisions.monitorCallback);
5260					giga.collisions.clearFlags();
5261				}
5262				giga.collisions.isMonitoring = false;
5263			}
5264		}
5265	
5266	},
5267	
5268	futoNEWnotWorking: {
5269
5270		state: 0,
5271
5272		objects: {},
5273
5274		sources: {
5275			0: '',
5276
5277			// Wysokie
5278			1: 'Ivan Bohuš: Od A po Z o názvoch Vysokých Tatier, TANAP - Tatranská Lomnica 1996',
5279			2: 'Komarnicki Gyula: A Magas-Tátra hegyvilága, Sport Budapest, 1985., wydanie II przepracowane Henrykom Bucsekom',
5280			3: 'Z. Radwańska-Paryska, W. H. Paryski: Wielka encyklopedia tatrzańskai, Wydawnictwo górskie – Poronin 1995.',
5281			4: 'Farkas Zsolt, Kunos Gábor: A Magas-Tátra szlovák-magyar nevei: Előtanulmány a Magas-Tátra gerinctérképéhez, niewydane studium, 1997.',
5282			5: 'Futó Endre: nowa nazwa wprowadzona po raz pierwszy w tej pracy',
5283			6: 'Józef Nyka: Tatry Słowackie, Przewodnik, Wydanie II, Trawers, Latchorzew 1998; Józef Nyka: Tatry Polskie, Przewodnik, Wydanie X, Trawers, Latchorzew 1998',
5284			7: 'Alfred Grósz: Die Hohe Tatra, Arbeitgemeinschaft der Karpatendeutschen aus Slowakei, Stuttgart 1961',
5285			8: 'Arno Puškáš: Vysoké Tatry, horolezecký sprievodca, tom I-X, Vydavateľstvo Šport, Bratislava 1957-1989',
5286			9: 'Turistaság és Alpinizmus, 1910-1935',
5287			10: 'Szlovákia autóstérkép magyar településnevekkel, AGÁT Kft. TOP-O-GRÁF Térképészeti Iroda, Budapest 2000.',
5288			11: 'Szontagh Miklós: A Magas Tátra és hegyvidéke, Újtátrafüred 1895., wydanie autora',
5289			12: 'Neidenbach Ákos: nowa nazwa wprowadzona po raz pierwszy w tej pracy lub kolekcja starych nazw z roku 1994',
5290			13: 'Komarnicki Gyula: A Magas-Tátra hegymászókalauza, Wydanie III, Késmárk-Budapest, 1926 Turistaság és Alpinizmus lap-, könyv- és térképkiadó RT',
5291			14: 'Komarnicki Gyula: Hohe Tatra, Késmárk 1918',
5292			15: 'Witold H. Paryski: Tatry Wysokie - Przewodnik Taternicki, tom I-XXIV, Sport i Turystyka, Warszawa, 1951-1988',
5293			16: 'Scheirich László: Emberek, sziklák, századok, Nap kiadó, Dunajská Streda 2001.',
5294			17: 'Klaus Gattinger: Die Hohe Tatra, ViViT s.r.o., Kieżmarok 2003.',
5295			18: 'Władysław Cywiński: Tatry, przewodnik szczegółowy, tom 1–19, Poronin 1994–2014',
5296			19: 'Andrzej Z. Górski: korekta językowa części polskiej',
5297			20: 'Jan Kiełkowski: Tatry Wysokie, część 1-2, Wydawnictwo EXPLO, Gliwice, 1997; ; część 3, Antykwariat Górski Filar, Kielce 2013.',
5298			21: 'Wildner Dénes: A történelmi Magyarország egykori területeinek helynévtára, Verlag Ungarisches Institut, München 1996',
5299			22: 'Ivan Bohuš: Rozprávanie o názvoch a o prvých výstupoch na tatranské vrcholy, Vydavateľstvo I&B, Tatranská Lomnica, 2003.',
5300			23: 'Kolbenheyer Károly, Kovács Pál: A Magas Tátra, Teschen, 1882.',
5301			24: 'Bácskai Gusztáv: korekta językowa części węgierskiej',
5302			25: 'Alfred Grósz: Die Benennungen der Östlichen Tatra, niewydany rękopis, Kieżmarok',
5303			26: 'Miloš Janoška: Sprievodca po Vysokých Tatrách, wydanie druge ponownie, wydal F. Klimeš v Liptovskim Świętym Mikulaszy, 1923.',
5304			27: 'Miloš Janoška: Sprievodca po Tatrách, wydal F. Klimeš v Liptovskim Świętym Mikulaszy, 1911.',
5305			28: 'Adam Piechowski: Grań Tatr, Wydawnictwo TEXT, Warszawa 1992.',
5306			29: 'Nagy Árpád: korekta językowa części węgierskiej',
5307			30: 'Marian Jurík: leśnik Tatranskiego Narodowego Parku',
5308			31: 'Karel Drož: Tatry, wydal F. Klimeš v Rużomberku, 1897; Karel Drož: Život na Tatrách, Rokycany 1906; Karel Drož: Z našich Tater, Użhorod 1924',
5309			32: 'Maroš Bobovčák, Maroš Jacina: Vysoké Tatry – zachodnia cześć, przewodnik taternicki, Szczyrbskie Jezioro 2010; Maroš Bobovčák, Maroš Jacina: Vysoké Tatry – wschodnia cześć, przewodnik taternicki, Szczyrbskie Jezioro 2011',
5310			33: 'Pavol Jackovič: Slavkovský štít, horolezecký sprievodca, Poprad 2014;',
5311			34: 'Ivan Bohuš sen. i Ivan Bohuš jun.: Príbehy zemepisných názvov Vysokých Tatier, Wydawnictwo IB Vysoké Tatry s.r.o., Łomnica Tatrzańska 2018',
5312			35: 'Jozef Gurník, Krywan, niewydane studium',
5313
5314			// Bielskie
5315			1001: 'Władysław Cywiński: Tatry, przewodnik szczegółowy, IV. i V. tom, Poronin 1997.',
5316			1002: 'Tadeusz Szczerba: Tatry Bielskie, Monografia, Przewodnik, Wydawnictwo Ryszard M. Remiszewski – RMR, Gliwice 1997.',
5317			1003: 'Z. Radwańska-Paryska, W. H. Paryski: Wielka encyklopedia tatrzańskai, Wydawnictwo górskie – Poronin 1995.',
5318			1004: 'Witold H. Paryski: Tatry Wysokie – Przewodnik Taternicki, 24. tom, Wydawnictwo Sport i Turystyka, Warszawa, 1951-1988.',
5319			1005: 'Futó Endre: nowa nazwa wprowadzona po raz pierwszy w tej pracy',
5320			1006: 'Stanisław Eljasz-Radzikowski: Tatry Bielskie, Pamietnik Towarzystwa Tatrzańskiego, Kraków 1893 és 1894.',
5321			1007: 'Józef Nyka: Tatry Słowackie, Przewodnik, Wydanie VI, Trawers, Latchorzew 1998.',
5322			1008: 'Klaus Gattinger: Die Hohe Tatra, ViViT s.r.o., Kieżmarok 2003.',
5323			1009: 'Alfred Grósz: Die Benennungen der Östlichen Tatra, niewydany rękopis, Kieżmarok',
5324			10010: 'Szontagh Miklós: A Magas Tátra és hegyvidéke, Újtátrafüred 1895., wydanie autora',
5325			10011: 'Kolbenheyer Károly, Kovács Pál: A Magas Tátra, Teschen, 1882.',
5326			10012: 'Hefty Gyula Andor és Vigyázó János: A Magas Tátra részletes kalauza, Turistaság és Alpinizmus kiadása, Budapest 1917.',
5327			10013: 'Ivan Bohuš: Od A po Z o názvoch Vysokých Tatier, TANAP – Tatranská Lomnica 1996.',
5328			10014: 'Komarnicki Gyula: A Magas-Tátra hegyvilága, Sport Budapest, 1985., wydanie II przepracowane Henrykem Bucsekom',
5329			10015: 'Tatra Wysokie słowackie i polskie, mapa turystyczna 1:25000, Wydawnictwo Kartograficzne Polkart, Warszawa-Zielona Góra-Zakopane, 2003/2004; Vysoké Tatry, turistická mapa 1:25000, Sygnatura, 6. vydanie, 2010; Magas-Tátra, turista-, hegymászó-, kerékpáros és szabadidőtérkép, vydal Térkép Faragó Bt., Budapest, 2004',
5330			10016: 'J. Januszewski, G. Głazek, W. Fedorowicz-Jackowski: Tatry i Podtatrze, Atlas Satelitarny, Geosystems Polska, Warszawa 2005',
5331			10017: 'Współpracownicy Slowackiego Tatrańskiego Parku Narodowego: Juraj Ksiažek, Stanislav Pavlarčík, Ján Slivinský',
5332			10018: 'Kolektyw autorów: Tatra – Príroda, Wydawnictwo Baset, Praga 2010'
5333		},
5334
5335		load(callback) {
5336			if (giga.futo.state === 0) {
5337				giga.futo.state = 1;
5338				fetch('futo/futo.json?s=2')
5339					.then(res => {
5340						if (!res.ok) throw new Error(`HTTP error ${res.status}`);
5341						return res.json();
5342					})
5343					.then(data => {
5344						giga.futo.state = 2;
5345						giga.futo.objects = data;
5346						callback();
5347					})
5348					.catch(err => {
5349						giga.futo.state = 0;
5350						console.error('Failed to load futo.json:', err);
5351					});
5352			} else {
5353				callback();
5354			}
5355		},
5356
5357		parseNames(s) {
5358			const names = s.split('<BR>').map(e => giga.futo.parseString(e));
5359			if (names.length === 1) names[0].main = true;
5360			return names;
5361		},
5362
5363		getSource(i) {
5364			return giga.futo.sources[i];
5365		},
5366
5367		parseString(s) {
5368			const m = s.match(/(<B>)?([^<(]*)(\(([0-9]+)\))?(<\/B>)?/);
5369			return {
5370				txt: m[2].trim(),
5371				main: m[1] !== undefined,
5372				src: +(m[4] || 1),
5373				raw: s
5374			};
5375		},
5376
5377		replace(el) {
5378			const id = el.dataset.id;
5379			const name = el.dataset.name;
5380			giga.futo.load(() => {
5381				const o = id ? [giga.futo.findByID(id)] : giga.futo.findByName(name);
5382				const html = o.length > 0
5383					? '<br><h2>Słownik Endre Futó</h2>' + o.map(oo => giga.futo.toHTML(oo)).join('<br>')
5384					: '<br>nie znaleziono';
5385				el.outerHTML = html;
5386			});
5387		},
5388
5389		findByID(id) {
5390			return giga.futo.objects[id] || {};
5391		},
5392
5393		findByName(name, lang = 'pl') {
5394			const ret = [];
5395			for (const obj of Object.values(giga.futo.objects)) {
5396				if (obj[lang]?.some(n => n.txt === name)) {
5397					ret.push(obj);
5398				}
5399			}
5400			return ret;
5401		},
5402
5403		toHTML(o) {
5404			const langs = ['pl', 'sk', 'de', 'hu'];
5405			const html = langs
5406				.filter(lang => o[lang])
5407				.map(lang => {
5408					const label = `<b style="text-transform:uppercase">${lang}</b> `;
5409					const names = o[lang]
5410						.map(t => {
5411							const title = giga.futo.sources[t.src] || '';
5412							const cls = t.main ? 'futo-main' : '';
5413							return `<span title="${title}" class="${cls}">${t.txt}</span>`;
5414						})
5415						.join(' &bull; ');
5416					return `<div>${label}${names}</div>`;
5417				})
5418				.join('');
5419			return `<div class="fitem"><b>ID</b> ${o.id}${html}</div>`;
5420		},
5421
5422		get(name, lang) {
5423			const o = giga.futo.findByName(name, lang);
5424			return o.length > 0 ? o.map(oo => giga.futo.toHTML(oo)).join('<br>') : '';
5425		}
5426
5427	},
5428	
5429	futo: {
5430	
5431		state: 0,
5432		
5433		objects: {},
5434		
5435		sources: {
5436			0: '',
5437			
5438			// Wysokie
5439			1: 'Ivan Bohuš: Od A po Z o názvoch Vysokých Tatier, TANAP - Tatranská Lomnica 1996',
5440			2: 'Komarnicki Gyula: A Magas-Tátra hegyvilága, Sport Budapest, 1985., wydanie II przepracowane Henrykom Bucsekom',
5441			3: 'Z. Radwańska-Paryska, W. H. Paryski: Wielka encyklopedia tatrzańskai, Wydawnictwo górskie – Poronin 1995.',
5442			4: 'Farkas Zsolt, Kunos Gábor: A Magas-Tátra szlovák-magyar nevei: Előtanulmány a Magas-Tátra gerinctérképéhez, niewydane studium, 1997.',
5443			5: 'Futó Endre: nowa nazwa wprowadzona po raz pierwszy w tej pracy',
5444			6: 'Józef Nyka: Tatry Słowackie, Przewodnik, Wydanie II, Trawers, Latchorzew 1998; Józef Nyka: Tatry Polskie, Przewodnik, Wydanie X, Trawers, Latchorzew 1998',
5445			7: 'Alfred Grósz: Die Hohe Tatra, Arbeitgemeinschaft der Karpatendeutschen aus Slowakei, Stuttgart 1961',
5446			8: 'Arno Puškáš: Vysoké Tatry, horolezecký sprievodca, tom I-X, Vydavateľstvo Šport, Bratislava 1957-1989',
5447			9: 'Turistaság és Alpinizmus, 1910-1935',
5448			10: 'Szlovákia autóstérkép magyar településnevekkel, AGÁT Kft. TOP-O-GRÁF Térképészeti Iroda, Budapest 2000.',
5449			11: 'Szontagh Miklós: A Magas Tátra és hegyvidéke, Újtátrafüred 1895., wydanie autora',
5450			12: 'Neidenbach Ákos: nowa nazwa wprowadzona po raz pierwszy w tej pracy lub kolekcja starych nazw z roku 1994',
5451			13: 'Komarnicki Gyula: A Magas-Tátra hegymászókalauza, Wydanie III, Késmárk-Budapest, 1926 Turistaság és Alpinizmus lap-, könyv- és térképkiadó RT',
5452			14: 'Komarnicki Gyula: Hohe Tatra, Késmárk 1918',
5453			15: 'Witold H. Paryski: Tatry Wysokie - Przewodnik Taternicki, tom I-XXIV, Sport i Turystyka, Warszawa, 1951-1988',
5454			16: 'Scheirich László: Emberek, sziklák, századok, Nap kiadó, Dunajská Streda 2001.',
5455			17: 'Klaus Gattinger: Die Hohe Tatra, ViViT s.r.o., Kieżmarok 2003.',
5456			18: 'Władysław Cywiński: Tatry, przewodnik szczegółowy, tom 1–19, Poronin 1994–2014',
5457			19: 'Andrzej Z. Górski: korekta językowa części polskiej',
5458			20: 'Jan Kiełkowski: Tatry Wysokie, część 1-2, Wydawnictwo EXPLO, Gliwice, 1997; ; część 3, Antykwariat Górski Filar, Kielce 2013.',
5459			21: 'Wildner Dénes: A történelmi Magyarország egykori területeinek helynévtára, Verlag Ungarisches Institut, München 1996',
5460			22: 'Ivan Bohuš: Rozprávanie o názvoch a o prvých výstupoch na tatranské vrcholy, Vydavateľstvo I&B, Tatranská Lomnica, 2003.',
5461			23: 'Kolbenheyer Károly, Kovács Pál: A Magas Tátra, Teschen, 1882.',
5462			24: 'Bácskai Gusztáv: korekta językowa części węgierskiej',
5463			25: 'Alfred Grósz: Die Benennungen der Östlichen Tatra, niewydany rękopis, Kieżmarok',
5464			26: 'Miloš Janoška: Sprievodca po Vysokých Tatrách, wydanie druge ponownie, wydal F. Klimeš v Liptovskim Świętym Mikulaszy, 1923.',
5465			27: 'Miloš Janoška: Sprievodca po Tatrách, wydal F. Klimeš v Liptovskim Świętym Mikulaszy, 1911.',
5466			28: 'Adam Piechowski: Grań Tatr, Wydawnictwo TEXT, Warszawa 1992.',
5467			29: 'Nagy Árpád: korekta językowa części węgierskiej',
5468			30: 'Marian Jurík: leśnik Tatranskiego Narodowego Parku',
5469			31: 'Karel Drož: Tatry, wydal F. Klimeš v Rużomberku, 1897; Karel Drož: Život na Tatrách, Rokycany 1906; Karel Drož: Z našich Tater, Użhorod 1924',
5470			32: 'Maroš Bobovčák, Maroš Jacina: Vysoké Tatry – zachodnia cześć, przewodnik taternicki, Szczyrbskie Jezioro 2010; Maroš Bobovčák, Maroš Jacina: Vysoké Tatry – wschodnia cześć, przewodnik taternicki, Szczyrbskie Jezioro 2011',
5471			33: 'Pavol Jackovič: Slavkovský štít, horolezecký sprievodca, Poprad 2014;',
5472			34: 'Ivan Bohuš sen. i Ivan Bohuš jun.: Príbehy zemepisných názvov Vysokých Tatier, Wydawnictwo IB Vysoké Tatry s.r.o., Łomnica Tatrzańska 2018',
5473			35: 'Jozef Gurník, Krywan, niewydane studium',
5474			
5475			// Bielskie
5476			1001: 'Władysław Cywiński: Tatry, przewodnik szczegółowy, IV. i V. tom, Poronin 1997.',
5477			1002: 'Tadeusz Szczerba: Tatry Bielskie, Monografia, Przewodnik, Wydawnictwo Ryszard M. Remiszewski – RMR, Gliwice 1997.',
5478			1003: 'Z. Radwańska-Paryska, W. H. Paryski: Wielka encyklopedia tatrzańskai, Wydawnictwo górskie – Poronin 1995.',
5479			1004: 'Witold H. Paryski: Tatry Wysokie – Przewodnik Taternicki, 24. tom, Wydawnictwo Sport i Turystyka, Warszawa, 1951-1988.',
5480			1005: 'Futó Endre: nowa nazwa wprowadzona po raz pierwszy w tej pracy',
5481			1006: 'Stanisław Eljasz-Radzikowski: Tatry Bielskie, Pamietnik Towarzystwa Tatrzańskiego, Kraków 1893 és 1894.',
5482			1007: 'Józef Nyka: Tatry Słowackie, Przewodnik, Wydanie VI, Trawers, Latchorzew 1998.',
5483			1008: 'Klaus Gattinger: Die Hohe Tatra, ViViT s.r.o., Kieżmarok 2003.',
5484			1009: 'Alfred Grósz: Die Benennungen der Östlichen Tatra, niewydany rękopis, Kieżmarok',
5485			10010: 'Szontagh Miklós: A Magas Tátra és hegyvidéke, Újtátrafüred 1895., wydanie autora',
5486			10011: 'Kolbenheyer Károly, Kovács Pál: A Magas Tátra, Teschen, 1882.',
5487			10012: 'Hefty Gyula Andor és Vigyázó János: A Magas Tátra részletes kalauza, Turistaság és Alpinizmus kiadása, Budapest 1917.',
5488			10013: 'Ivan Bohuš: Od A po Z o názvoch Vysokých Tatier, TANAP – Tatranská Lomnica 1996.',
5489			10014: 'Komarnicki Gyula: A Magas-Tátra hegyvilága, Sport Budapest, 1985., wydanie II przepracowane Henrykom Bucsekom',
5490			10015: 'Tatra Wysokie słowackie i polskie, mapa turystyczna 1:25000, Wydawnictwo 
5490Kartograficzne Polkart, Warszawa-Zielona Góra-Zakopane, 2003/2004; Vysoké Tatry, turistická mapa 1:25000, Sygnatura, 6. vydanie, 2010; Magas-Tátra, turista-, hegymászó-, kerékpáros és szabadidőtérkép, vydal Térkép Faragó Bt., Budapest, 2004',
5491			10016: 'J. Januszewski, G. Głazek, W. Fedorowicz-Jackowski: Tatry i Podtatrze, Atlas Satelitarny, Geosystems Polska, Warszawa 2005',
5492			10017: 'Współpracownicy Slowackiego Tatrańskiego Parku Narodowego: Juraj Ksiažek, Stanislav Pavlarčík, Ján Slivinský',
5493			10018: 'Kolektyw autorów: Tatra – Príroda, Wydawnictwo Baset, Praga 2010'
5494				
5495		},
5496		
5497		load: function(callback) {
5498			if (giga.futo.state === 0) {
5499				giga.futo.state = 1;
5500				fetch('futo/futo.json?s=2')
5501					.then(res => {
5502						if (!res.ok) throw new Error(`HTTP error ${res.status}`);
5503						return res.json();
5504					})
5505					.then(data => {
5506						giga.futo.state = 2;
5507						giga.futo.objects = data;
5508						callback();
5509					})
5510					.catch(err => {
5511						giga.futo.state = 0;
5512						console.error('Failed to load futo.json:', err);
5513					});
5514			} else {
5515				callback();
5516			}
5517		},	
5518		
5519		parseNames: function(s) {
5520			var ret = [];
5521			const names = s.split('<BR>');
5522			names.forEach( function(e) {
5523				ret.push( giga.futo.parseString(e) );
5524			});
5525			if (ret.length==1) ret[0].main = true;
5526			return ret;
5527		},
5528		
5529		getSource: function(i) {
5530			return giga.futo.sources[i];
5531		},
5532		
5533		parseString: function(s) {
5534			const m = s.match(/(<B>)?([^<\(]*)(\(([0-9]+)\))?(<\/B>)?/);
5535			return {
5536				txt: m[2].trim(),
5537				main: m[1]!==undefined,
5538				src: +(m[4] || 1),
5539				raw: s
5540			}
5541		},
5542		
5543		replace: function(el) {
5544			const $el = $(el);
5545			giga.futo.load( function() {
5546				let o = $el.data('id') ? [giga.futo.findByID($el.data('id'))] : giga.futo.findByName($el.data('name'));
5547				$el.replaceWith( o.length>0 ? ('<br><h2>Słownik Endre Futó</h2>' + o.map( oo => giga.futo.toHTML(oo) ).join('<br>')) : '<br>nie znaleziono' );
5548			});
5549		},
5550		
5551		findByID: function(id) {
5552			return giga.futo.objects[id] || {};
5553		},
5554		
5555		findByName: function(name, lang = 'pl') {
5556			var ret = [], found = false;
5557			Object.keys(giga.futo.objects).forEach(function(el) {
5558				found = false;
5559				giga.futo.objects[el][lang].forEach(function(n) {
5560					if (name==n.txt && !found) {
5561						ret.push(giga.futo.objects[el]);
5562						found = true;
5563					}
5564				});
5565			});
5566			return ret;
5567		},
5568		
5569		toHTML: function(o) {
5570			var html = '';
5571			['pl','sk','de','hu'].forEach( function(lang) {
5572				if (o[lang]) {
5573					let s = '<b style="text-transform:uppercase">'+lang+'</b> ';
5574					let z = o[lang].map( t => '<span title="' + (giga.futo.sources[t.src] || '') + '" class="'+(t.main?'futo-main':'')+'">' + t.txt + '</span>' ).join(' &bull; ');
5575					html += '<div>' + s + z + '</div>';
5576				}
5577			});
5578			return '<div class="fitem"><b>ID</b> '+o.id+html+'</div>';
5579		},
5580		
5581		get: function(name,lang) {
5582			const o = giga.futo.findByName(name,lang);
5583			return o.length>0 ? (o.map( oo => giga.futo.toHTML(oo) ).join('<br>')) : '';
5584		}
5585	},
5586	
5587	elevationSources: {
5588		GUGIK: 1,
5589		ZBGIS: 2,
5590		SWISS: 3,
5591		CUZK:  4,
5592		OPEN:  5
5593	},
5594	
5595	fetchElevation: async function(lat,lon,source) {
5596		var url, response, t, h;
5597		var ret = { ok: false };
5598		try {
5599			switch (source) {
5600				case giga.elevationSources.GUGIK:
5601					proj4.defs("EPSG:2180","+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
5602					let pl1992 = proj4("EPSG:2180").forward([+lon,+lat]);
5603					url = `https://services.gugik.gov.pl/nmt/?request=GetHbyXY&x=${pl1992[1].toFixed(3)}&y=${pl1992[0].toFixed(3)}`;
5604					response = await fetch(url);
5605					if (response?.ok) {
5606						t = await response.text();
5607						if (+t>=0) {
5608							ret.ok = true;
5609							ret.elevation = +t;
5610						}
5611					}
5612				break;
5613				case giga.elevationSources.ZBGIS:
5614					url = `https://zbgis.skgeodesy.sk/zbgis/rest/services/LLS_DMR5/MapServer/identify?f=json&geometryType=esriGeometryPoint&geometry=%7B%22y%22:${lat},%22x%22:${lon}%7D&sr=4326&mapExtent=0,0,0,0&imageDisplay=1,1,1&tolerance=0&layers=all:3&returnGeometry=false&returnZ=false&returnM=false`;
5615					response = await fetch(url);
5616					if (response?.ok) {
5617						t = await response.json();
5618						if (t?.results.length>
56180 && t?.results[0].attributes['Stretch.Pixel Value']!='NoData') {
5619							ret.ok = true;
5620							ret.elevation = +t?.results[0].attributes['Stretch.Pixel Value'];
5621							ret.extra = t?.results[0].layerName;
5622						}
5623					}
5624				break;
5625				case giga.elevationSources.CUZK:
5626					proj4.defs("EPSG:5514","+proj=krovak +lat_0=49.5 +lon_0=24.83333333333333 +alpha=30.28813972222222 +k=0.9999 +x_0=0 +y_0=0 +ellps=bessel +towgs84=570.8,85.7,462.8,4.998,1.587,5.261,3.56 +units=m +no_defs");
5627					let krovak = proj4("EPSG:5514").forward([+lon,+lat]);
5628					url = `https://ags.cuzk.cz/arcgis2/rest/services/dmr5g/ImageServer/identify?geometry={x:${krovak[0].toFixed(5)},y:${krovak[1].toFixed(5)}}&geometryType=esriGeometryPoint&returnGeometry=false&returnCatalogItems=false&f=pjson`;
5629					response = await fetch(url);
5630					if (response?.ok) {
5631						t = await response.json();
5632						h = Number.parseFloat(t.value);
5633						if (!Number.isNaN(h)) {
5634							ret.ok = true;
5635							ret.elevation = h;
5636						}
5637					}
5638				break;
5639				case giga.elevationSources.SWISS:
5640					proj4.defs("EPSG:2056","+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs");
5641					let swiss = proj4("EPSG:2056").forward([+lon,+lat]);
5642					url = `https://api3.geo.admin.ch/rest/services/height?sr=2056&easting=${Math.round(swiss[0])}&northing=${Math.round(swiss[1])}`;
5643					response = await fetch(url);
5644					if (response?.ok) {
5645						t = await response.json();
5646						h = Number.parseFloat(t.height);
5647						if (!Number.isNaN(h)) {
5648							ret.ok = true;
5649							ret.elevation = h;
5650						}
5651					}
5652				break;
5653				case giga.elevationSources.OPEN:
5654					url = `https://api.openelevation.com/v1/elevation?lat=${lat}&lon=${lon}`;
5655					response = await fetch(url);
5656					if (response?.ok) {
5657						t = await response.json();
5658						h = Number.parseFloat(t.elevation);
5659						if (!Number.isNaN(h)) {
5660							ret.ok = true;
5661							ret.elevation = h;
5662						}
5663					}
5664				break;
5665			}
5666		} catch (error) {
5667			ret.extra = 'Network error';
5668			return ret;
5669		}
5670		return ret;
5671	},	
5672	
5673	fetchGugikElevation: function(lat,lon) {
5674		proj4.defs("EPSG:2180","+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
5675		const pl1992 = proj4("EPSG:2180").forward([+lon,+lat]);
5676		const url = 'https://services.gugik.gov.pl/nmt/?request=GetHbyXY&x=' + pl1992[1].toFixed(3) + '&y=' + pl1992[0].toFixed(3);
5677		return fetch(url).then(response => response?.ok ? response.text() : null);
5678	},
5679	
5680	fetchSwissElevation: function(lat,lon) {
5681		proj4.defs("EPSG:2056","+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs");
5682		const swiss = proj4("EPSG:2056").forward([+lon,+lat]);
5683		const url = 'https://api3.geo.admin.ch/rest/services/height?sr=2056&easting=' + Math.round(swiss[0]) + '&northing=' + Math.round(swiss[1]);
5684		return fetch(url).then(response => response?.ok ? response.json() : null);
5685	},
5686	
5687	fetchZbgisElevationOLD: async function(lat,lon) {
5688		const url = 'https://zbgis.skgeodesy.sk/zbgis/rest/services/LLS_DMR5/MapServer/identify?f=json&geometryType=esriGeometryPoint&geometry=%7B%22y%22:'+lat+',%22x%22:'+lon+'%7D&sr=4326&mapExtent=0,0,0,0&imageDisplay=1,1,1&tolerance=0&layers=all:3&returnGeometry=false&returnZ=false&returnM=false';
5689		return await fetch(url).then(response => response?.ok ? response.json() : null);
5690	},
5691	
5692	fetchZbgisElevation: async function(lat,lon) {
5693		const url = `https://zbgis.skgeodesy.sk/zbgis/rest/services/LLS_DMR5/MapServer/identify?f=json&geometryType=esriGeometryPoint&geometry=%7B%22y%22:${lat},%22x%22:${lon}%7D&sr=4326&mapExtent=0,0,0,0&imageDisplay=1,1,1&tolerance=0&layers=all:3&returnGeometry=false&returnZ=false&returnM=false`;
5694		try {
5695			let response = await fetch(url);
5696			let e = await response.json();
5697			if (e?.results.length>0) {
5698				return e.results[0];
5699			} else {
5700				return null;
5701			}
5702		} catch(e) {
5703			console.log('fetchZbgisElevation error! ',e);
5704			return null;
5705		}
5706	},
5707	
5708	fetchCuzkElevation: async function(lat,lon) {
5709		proj4.defs("EPSG:5514","+proj=krovak +lat_0=49.5 +lon_0=24.83333333333333 +alpha=30.28813972222222 +k=0.9999 +x_0=0 +y_0=0 +ellps=bessel +towgs84=570.8,85.7,462.8,4.998,1.587,5.261,3.56 +units=m +no_defs");
5710		const krovak = proj4("EPSG:5514").forward([+lon,+lat]);
5711		const url = 'https://ags.cuzk.cz/arcgis2/rest/services/dmr5g/ImageServer/identify?geometry={x:' + krovak[0].toFixed(5) + ',y:' + krovak[1].toFixed(5) + '}&geometryType=esriGeometryPoint&returnGeometry=false&returnCatalogItems=false&f=pjson';
5712		return await fetch(url).then(response => response.ok ? response.json() : null);
5713	},
5714	
5715	fetchOpenElevation: async function (lat, lon) {
5716		return await fetch(`https://api.openelevation.com/v1/elevation?lat=${lat}&lon=${lon}`)
5717			.then(response => response.json())
5718			.then(data => data.elevation);
5719	},
5720	
5721	openNMTRefine: function(lat,lon,title) {
5722		const params = new URLSearchParams({
5723			'title': title ?? '',
5724			'lat': lat,
5725			'lon': lon,
5726			'radius': 4,
5727			'step': 5
5728		});
5729		window.open('nmt-refine.html?'+params.toString())
5730	},
5731	
5732	openSwissRefine: function(lat,lon,title) {
5733		const params = new URLSearchParams({
5734			'title': title ?? '',
5735			'lat': lat,
5736			'lon': lon,
5737			'radius': 4,
5738			'step': 5
5739		});
5740		window.open('swiss-refine.html?'+params.toString())
5741	},
5742
5743	openZbgisRefine: function(lat,lon,title) {
5744		const params = new URLSearchParams({
5745			'title': title ?? '',
5746			'lat': lat,
5747			'lon': lon,
5748			'radius': 4,
5749			'step': 0.00005
5750		});
5751		window.open('zbgis-refine.html?'+params.toString())
5752	},
5753
5754	openCuzkRefine: function(lat,lon,title) {
5755		const params = new URLSearchParams({
5756			'title': title ?? '',
5757			'lat': lat,
5758			'lon': lon,
5759			'radius': 4,
5760			'step': 5
5761		});
5762		window.open('cuzk-refine.html?'+params.toString())
5763	},
5764	
5765	isPointVerified: function(point) {
5766		return (point?.source=="lidar-cloud" || point?.source=="lidar-refine" || point?.source=="zbgis-nmt" || point?.source=="gugik-refine");
5767	},
5768	
5769	clearCacheAndReload: () => {
5770		hotspots.clearPointsStorage();
5771		giga.sw.clearCache();
5772		location.reload();
5773	},
5774	
5775	userAgentString: 'PanoramyEuCountryCheck/1.0 ([email protected])',
5776	
5777	checkCountry: (lat,lon) => {
5778	
5779		function pointInPolygon(lat, lon, polygon) {
5780			let inside = false;
5781			for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
5782				const xi = polygon[i][0], yi = polygon[i][1];
5783				const xj = polygon[j][0], yj = polygon[j][1];
5784				const intersect = ((yi > lon) !== (yj > lon)) && (lat < (xj - xi) * (lon - yi) / (yj - yi) + xi);
5785				if (intersect) inside = !inside;
5786			}
5787			return inside;
5788		}
5789
5790		const countryPolygons = {
5791			"PL":[[50.31748237,17.47802196],[50.37503024,17.65502653],[50.28333618,17.8132116],[50.1044646,17.7939597],[50.15363417,17.8810871],[50.07605843,18.15571504],[49.99876691,18.41025839],[49.94073048,18.66185922],[49.80558271,18.68474424],[49.65594743,18.85444605],[49.51608214,19.03858965],[49.55640434,19.23528091],[49.69237024,19.44662835],[49.58197412,19.61275572],[49.50053166,19.80135472],[49.34160418,19.8772705],[49.26898337,20.02634588],[49.41870314,20.20942672],[49.4419247,20.4607864],[49.39357254,20.74845534],[49.44480981,20.95988778],[49.49339712,21.222384],[49.49241152,21.51465971],[49.44349108,21.79021735],[49.35385446,22.05365185],[49.26362101,22.31617941],[49.17989168,22.57105395],[49.37291932,22.56303876],[49.56593244,22.5550614],[49.72192319,22.71554967],[49.87181878,22.90348126],[50.02136862,23.09266687],[50.17056916,23.28311847],[50.31941681,23.47484813],[50.44836568,23.6854022],[50.5224467,23.9273658],[50.69899109,23.92141927],[50.85485719,23.83993417],[50.97461676,23.87047238],[51.13186511,23.6918334],[51.29047544,23.52018601],[51.48335399,23.52007044],[51.6762232,23.52006795],[51.86908362,23.52017999],[52.05592791,23.50136684],[52.16756666,23.24453088],[52.27866118,22.98647096],[52.40876485,23.20486728],[52.53896766,23.43902114],[52.66867824,23.67463568],[52.83024899,23.79409882],[53.02121758,23.77132113],[53.20911685,23.69937544],[53.39697892,23.62691503],[53.58480384,23.5539314],[53.77259165,23.48041588],[53.9603424,23.40635961],[54.13043104,23.28245447],[54.25474964,23.03033541],[54.31783396,22.73518819],[54.32869663,22.40438962],[54.33867079,22.07346237],[54.34775601,21.7424195],[54.35595191,21.41127411],[54.36325818,21.08003932],[54.36967455,20.7487283],[54.37520083,20.41735423],[54.37983686,20.0859303],[54.38358255,19.75446974],[54.35661282,19.42887305],[54.30928895,19.10773198],[54.29644332,18.7933711],[54.37989703,18.4950765],[54.56240949,18.401485],[54.74891632,18.31555571],[54.75598213,17.99602134],[54.70674882,17.67509674],[54.64355148,17.35956324],[54.57954719,17.0450323],[54.51474036,16.73150982],[54.38625281,16.49753909],[54.23334424,16.29502893],[54.15841738,16.00294106],[54.10413197,15.68686107],[54.04903061,15.37162573],[53.9931171,15.05724241],[53.93639524,14.74371831],[53.82815385,14.74894005],[53.63574949,14.71793277],[53.50407171,14.63220044],[53.49958383,14.46769075],[53.31143637,14.54150543],[53.13219601,14.47869551],[52.95785639,14.34002419],[52.80707783,14.38908602],[52.68033902,14.62959527],[52.53203817,14.74534514],[52.34907242,14.69694724],[52.17165734,14.82198244],[51.99324055,14.83286256],[51.81371665,14.7169306],[51.64112656,14.84926129],[51.46861895,14.98932043],[51.29594567,15.12836163],[51.1131154,15.05873856],[51.11603954,15.18144962],[50.94778777,15.33265196],[50.83416782,15.54
5791051633],[50.81215336,15.83727832],[50.68565205,16.01993525],[50.71539223,16.31699667],[50.6013801,16.52768685],[50.48273157,16.45804733],[50.35099282,16.43614457],[50.21006297,16.643086],[50.25264002,16.87887469],[50.43187428,16.7736665],[50.48654968,16.97325704],[50.39134346,17.23714086],[50.31748237,17.47802196],[50.31748237,17.47802196]],
5792			"SK":[[47.83167303,18.66814999],[47.8967594,18.69741245],[47.97825894,18.65406957],[48.04287784,18.73252385],[48.09363965,18.82971467],[48.10388166,18.95793113],[48.11397307,19.0862038],[48.12391376,19.21453202],[48.13370363,19.34291514],[48.17784682,19.44508636],[48.24125985,19.53271924],[48.28563582,19.62615058],[48.24858339,19.74345766],[48.21140802,19.86059955],[48.21011611,19.97547679],[48.25280038,20.08788066],[48.29536564,20.20048229],[48.35368758,20.28790527],[48.43128194,20.34462149],[48.50884139,20.40152746],[48.58272545,20.46281826],[48.59855177,20.59102328],[48.61422785,20.71931516],[48.61529557,20.84566606],[48.5838276,20.96740859],[48.5522278,21.08900497],[48.56978103,21.21180253],[48.5979395,21.33501392],[48.61851798,21.45552861],[48.55465075,21.54376791],[48.49071531,21.63179482],[48.42671191,21.71961019],[48.40631719,21.83017454],[48.41973533,21.95859634],[48.45333751,22.05485495],[48.53288724,22.10499323],[48.61240811,22.15530841],[48.69190002,22.20580149],[48.77136284,22.25647346],[48.85079648,22.30732533],[48.93020081,22.35835812],[49.00957572,22.40957285],[49.06048451,22.35807137],[49.09119631,22.23591476],[49.12205574,22.1125276],[49.14858693,21.99325955],[49.22172094,21.92701223],[49.29194539,21.8631214],[49.32492621,21.74737484],[49.36027642,21.62627681],[49.37140484,21.49766229],[49.36970415,21.36508342],[49.36807306,21.24879141],[49.36606895,21.11624322],[49.30014143,21.03408928],[49.2314089,20.95433371],[49.25471355,20.85410933],[49.30600615,20.74741032],[49.34537598,20.63528761],[49.34227966,20.5028835],[49.33902609,20.37049885],[49.28416379,20.27784612],[49.23823051,20.17846714],[49.15799711,20.1373603],[49.11436946,20.04706864],[49.15985839,19.93632408],[49.13556515,19.82303727],[49.14489285,19.69184206],[49.22208243,19.67939931],[49.30286282,19.69811947],[49.34734617,19.63380453],[49.37429481,19.53815446],[49.4488072,19.48770461],[49.52011117,19.43430228],[49.47100917,19.33088268],[49.4160529,19.27278221],[49.35522602,19.17887643],[49.3457454,19.06180826],[49.33493093,18.93365958],[49.39330916,18.87732465],[49.4561755,18.83273234],[49.4419781,18.70659368],[49.43059842,18.57815837],[49.37311072,18.47936731],[49.31553208,18.38081749],[49.25786283,18.28250819],[49.19241175,18.20037864],[49.10743184,18.18469077],[49.02845815,18.14860069],[48.97128198,18.05108616],[48.93273258,17.96631203],[48.86870943,17.8869975],[48.83398143,17.77838827],[48.79494014,17.66165374],[48.75742303,17.54470554],[48.77196636,17.42577407],[48.78355956,17.30160629],[48.80100379,17.18415143],[48.72115173,17.13573005],[48.64048892,17.08902228],[48.55980206,17.04247545],[48.47909123,16.99608863],[48.39835653,16.94986088],[48.31884985,16.96542679],[48.24009451,17.0191961],[48.16131205,17.07280802],[48.0828998,17.12673605],[48.06550374,17.25323371],[48.0463216,17.37845151],[47.99000796,17.47652287],[47.93360792,17.57438481],[47.87712178,17.67203794],[47.82054984,17.76948285],[47.82196399,17.89749249],[47.82395959,18.02590313],[47.82580427,18.15432759],[47.82749798,18.2827652],[47.8290407,18.41121526],[47.83043239,18.53967709],[47.83167303,18.66814999],[47.83167303,18.66814999]],
5793			"CZ":[[48.70758686,14.44183779],[48.66208879,14.59009478],[48.72017759,14.67692584],[48.82011924,14.73923992],[48.83245264,14.8747384],[48.93902653,14.9023473],[49.04558817,14.9300874],[49.05744769,15.0519494],[49.0309939,15.21152378],[49.00431367,15.37093139],[48.9774074,15.53017123],[48.9502755,15.68924234],[48.92291836,15.84814374],[48.8602801,15.9814906],[48.79946088,16.11484813],[48.81191794,16.2774786],[48.82413644,16.44019521],[48.83611613,16.60299661],[48.80677144,16.7576838],[48.76722843,16.91017412],[48.79284968,16.99254791],[48.88569227,17.06761387],[48.89380777,17.23118633],[48.88398778,17.38625882],[48.87143297,17.53253198],[48.90987194,17.6846625],[48.97756657,17.81243692],[49.04525126,17.94044363],[49.12876518,18.023134],[49.23324015,18.06501078],[49.32265973,18.14515647],[49.3653497,18.29629268],[49.4534246,18.32981714],[49.49320489,18.45329712],[49.54048316,18.57984176],[49.5
57935260615,18.73075393],[49.62875354,18.66945238],[49.72390719,18.59511805],[49.82253273,18.5266336],[49.86602276,18.39513014],[49.8618917,18.2476014],[49.93096245,18.1247622],[49.97312631,17.98467194],[49.91138968,17.84722004],[49.97032936,17.72621815],[50.04887832,17.62699778],[50.11778791,17.55068528],[50.21467165,17.6040324],[50.20651541,17.54323108],[50.18963489,17.3917725],[50.24703278,17.25377774],[50.31080315,17.13322207],[50.35366844,16.99785654],[50.26322379,17.07897824],[50.17713459,17.07211805],[50.14410618,16.924291],[50.08574603,16.79734532],[50.02952748,16.65558011],[50.09820149,16.53804868],[50.18644437,16.4504751],[50.26671191,16.34657381],[50.31884691,16.21661309],[50.39566896,16.11598907],[50.48113002,16.19091826],[50.55143033,16.30840132],[50.57726688,16.2268799],[50.56588486,16.07702586],[50.55293845,15.92452857],[50.62070684,15.84662298],[50.69336122,15.72751714],[50.71621489,15.56297048],[50.72294765,15.41315846],[50.75553704,15.27678511],[50.84418821,15.19901363],[50.92444658,15.13015444],[50.90928221,15.06306249],[50.80432516,15.07620375],[50.80261777,14.91257483],[50.79642323,14.74222017],[50.78997691,14.57191578],[50.88098628,14.49619041],[50.98016975,14.42815419],[50.89990392,14.42678622],[50.82832497,14.37016886],[50.79340632,14.20867439],[50.75826027,14.04742646],[50.72288741,13.88642585],[50.68728834,13.72567329],[50.64525146,13.5705181],[50.57872846,13.4366162],[50.51204695,13.30309831],[50.44520767,13.16996323],[50.37821138,13.03720976],[50.34785126,12.88306392],[50.34206983,12.71434776],[50.32871712,12.55263549],[50.23568697,12.46671304],[50.14258878,12.38113157],[50.05710147,12.3587334],[49.99038289,12.49083784],[49.92351131,12.6225782],[49.83125068,12.55571077],[49.73693922,12.48314502],[49.64173581,12.56225398],[49.54647541,12.64105805],[49.45869605,12.73148599],[49.40457064,12.87506101],[49.35026291,13.01832105],[49.27445303,13.13379038],[49.19154016,13.23990055],[49.10852646,13.34565887],[49.02541244,13.45106722],[49.00079325,13.60435305],[48.93760601,13.7258874],[48.85430619,13.83062175],[48.77090763,13.93501202],[48.68741083,14.03906003],[48.63674638,14.17333526],[48.63273928,14.32412509],[48.70758686,14.44183779],[48.70758686,14.44183779]]		
5794		};
5795		
5796		let res = {};
5797		Object.entries(countryPolygons).some( ([country, polygon]) => {
5798			if (pointInPolygon(lat, lon, polygon)) {
5799				res = {countryCode: country};
5800				return true;
5801			} else {
5802				return false;
5803			}
5804		});
5805		return res;
5806	},
5807	
5808	checkCountryOSM: async function (lat, lon) {
5809		const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=3&addressdetails=1`;
5810
5811		const res = await fetch(url, {
5812			headers: {
5813				"User-Agent": giga.userAgentString,
5814			}
5815		});
5816
5817		if (!res.ok) {
5818			throw new Error(`Nominatim error: ${res.status}`);
5819		}
5820
5821		const data = await res.json();
5822
5823		// Nominatim returns lowercase ISO2 codes (e.g., "fr", "us")
5824		const code = data.address?.country_code;
5825
5826		if (!code) {
5827			return []; // ocean, Antarctica, or no match
5828		}
5829
5830		return [code.toUpperCase()];
5831	},
5832	
5833	peakFinder: {
5834		panel: null,
5835		window: null,
5836		eventID: null,
5837		open: function() {
5838			window.addEventListener('unload', giga.peakFinder.close);
5839			window.addEventListener('pagehide', giga.peakFinder.close);
5840			giga.peakFinder.window = window.open('peakfinder.html','peakfinder-window');
5841		},
5842		setCurrentView: function() {
5843			const scalingFactor = 1.0;
5844			if (giga.peakFinder.panel) {
5845				if (giga.peakFinder.eventID) window.clearTimeout(giga.peakFinder.eventID);
5846				giga.peakFinder.eventID = window.setTimeout(function() {
5847					const currentView = giga.getCurrentViewParameters();
5848					const currentVerticalAngle = giga.getCurrentVerticalAngle() * scalingFactor;
5849					giga.peakFinder.panel.addEventListener('viewpointjourney finished', async function(args) {
5850						console.log(`viewpoint ready`, args);
5851						const pfAltitude = +args.elev, elevation = +k.info.elevation;
5852						const eleDifference = elevation - pfAltitude;
5853						console.log('eleDifference', eleDifference);
5854						if (Math.abs(eleDifference)>5) {
5855							console.log('setting eleDifference');
5856							giga.peakFinder.panel.elevationOffset(eleDifference);
5857						}
5858					}, { once: true });
5859					giga.peakFinder.panel.azimut(currentView.center);
5860					giga.peakFinder.panel.fieldofview(currentView.range);
5861					window.setTimeout(function() {
5862						//console.log('currentVerticalAngle: ',currentVerticalAngle);
5863						giga.peakFinder.panel.altitude(currentVerticalAngle);
5864					}, 0);
5865				}, 0);
5866			}
5867		},
5868		updateView: function() {
5869			if (giga.peakFinder.panel) giga.peakFinder.setCurrentView();
5870		},
5871		ready: function(panelReference) {
5872			console.log('peakfinder signaled ready');
5873			const info = k.info, vRange = info.range || 250;
5874			giga.peakFinder.panel = panelReference;
5875			giga.peakFinder.panel.loadViewpoint(+info.lat, +info.lon, info.title);
5876			giga.peakFinder.panel.settings.visibilityRange(vRange*1000);
5877			giga.peakFinder.panel.astro.currentDateTime(+info.date.substr(0,4), +info.date.substr(5,2), +info.date.substr(8,2), +info.time.substr(0,2), +info.time.substr(3,2));
5878			window.setTimeout(function() {
5879				if (giga.peakFinder.window) giga.peakFinder.window.document.title = giga.peakFinder.panel.viewpoint.name();
5880				giga.peakFinder.setCurrentView();
5881			}, 600);
5882		},
5883		finish: function() {
5884			giga.peakFinder.window = giga.peakFinder.panel = null;
5885			window.removeEventListener('unload', giga.peakFinder.close);
5886			window.removeEventListener('pagehide', giga.peakFinder.close);
5887		},
5888		close: function() {
5889			try {
5890				giga.peakFinder.window.close();
5891			} catch {
5892			} finally { 
5893				giga.peakFinder.finish();
5894			}
5895		},
5896		updateOptimizer: function() {
5897			console.log('updating optimizer');
5898			const info = k.info;
5899			const v = document.getElementById('vertical-angle-optimizer');
5900			v.querySelector('.vertical-angle-top').textContent = `${(+info.azimuthtop).toFixed(1)}°`;
5901			v.querySelector('.vertical-angle-bottom').textContent = `${(+info.azimuthbottom).toFixed(1)}°`;
5902			giga.peakFinder.updateView();
5903		},
5904		showOptimizer: function() {
5905			const v = $('#vertical-angle-optimizer');
5906			const step = 0.1;
5907			v.find('.top-down').on('click',    () => { k.info.azimuthtop    = Number((+k.info.azimuthtop+0.1).toFixed(2));    giga.peakFinder.updateOptimizer(); });
5908			v.find('.bottom-up').on('click',   () => { k.info.azimuthbottom = Number((+k.info.azimuthbottom-0.1).toFixed(2)); giga.peakFinder.updateOptimizer(); });
5909			v.find('.top-up').on('click',      () => { k.info.azimuthtop    = Number((+k.info.azimuthtop-0.1).toFixed(2));    giga.peakFinder.updateOptimizer(); });
5910			v.find('.bottom-down').on('click', () => { k.info.azimuthbottom = Number((+k.info.azimuthbottom+0.1).toFixed(2)); giga.peakFinder.updateOptimizer(); });
5911			giga.peakFinder.updateOptimizer();
5912			v.show();
5913		},
5914		hideOptimizer: function() {
5915			const v = $('#vertical-angle-optimizer');
5916			v.find('.top-up').off('click');
5917			v.find('.top-down').off('click');
5918			v.find('.bottom-up').off('click');
5919			v.find('.bottom-down').off('click');
5920			$('#vertical-angle-optimizer').hide();
5921		}
5922	},
5923	
5924	overpass: {
5925		buildQuery: function(point,around) {
5926			// https://overpass-api.de/api/interpreter?data=%5Bout%3Ajson%5D%5Btimeout%3A60%5D%3B%0A%28%2
59260node%5Bname%3D%22Mo%C5%A1n%C3%ADk%22%5D%5Bnatural%3D%22peak%22%5D%3B%20%29%3B%0Aout%20center%3B
5927			// new:
5928			// https://overpass-api.de/api/interpreter?data=%5Bout%3Ajson%5D%5Btimeout%3A90%5D%3B%0A(%20node%5Bname%3D%22Mo%C5%A1n%C3%ADk%22%5D%5Bnatural%3D%22peak%22%5D%3B)%3B%0Aout%20center%3B
5929			const server = 'https://overpass-api.de/api/interpreter?data=';
5930			var kind = 'peak', aroundPart = '';
5931			var q = '[out:json][timeout:90]; \n';
5932			if (around===true) around = '(around:100.00,'+point.lat+','+point.lon+')';
5933			//q += '( node'+aroundPart+'[name="' + point.title + '"][natural="' + kind + '"];); \n';
5934			q += '( node'+aroundPart+'[~"^name(:.*)?$"~"^' + point.title + '$"][natural="' + kind + '"];); \n';
5935			q += 'out center;';
5936			const url = server + fixedEncodeURIComponent(q);
5937			return url;
5938		},	
5939		
5940		JSONtoHTML: function(o) {
5941			var ret = '';
5942			o.elements.forEach( e => {
5943				ret += `<div><b><big>${e.tags.name}</big></b><br><b>coords:</b> ${e.lat.toFixed(5)}, ${e.lon.toFixed(5)}</div>`;
5944				for (const t in e.tags) {
5945					ret += `<div><b>${t}</b>: ${e.tags[t]}</div>`;
5946				}
5947				ret += '<br>';
5948			} );
5949			return ret;
5950		},
5951		
5952		fetchNearby: async function(lat, lon, radiusInMeters=100, kind='peak') {			
5953			const query = `[out:json][timeout:30];
5954				(node["natural"="${kind}"](around:${radiusInMeters}, ${lat}, ${lon});
5955				);
5956				out body;
5957				>;
5958				out skel qt;`;
5959			console.log(query);
5960			const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(query)}`;
5961			try {
5962				const response = await fetch(url);
5963				const data = await response.json();
5964				const mountainData = data.elements.map(element => ({
5965					name: element.tags?.name || '???',
5966					lat: element.lat,
5967					lon: element.lon,
5968					elevation: element.tags?.ele || ''
5969				}));
5970				return mountainData;
5971			} catch (error) {
5972				console.error('Error fetching mountain data:', error);
5973				return [];
5974			}
5975		},
5976		
5977		getFromBounds: async function(lat1, lon1, lat2, lon2, kinds = [['natural', 'peak'], ['natural', 'saddle']]) {	
5978			const nodes = kinds.length>0 ?
5979				kinds.map( pair => `node["${pair[0]}"="${pair[1]}"]["name"](${lat1},${lon1},${lat2},${lon2});` ).join("\n") :
5980				`node(${lat1},${lon1},${lat2},${lon2});;`
5981			;
5982			const query = `[out:json][timeout:30];
5983				(
5984				${nodes}
5985				);
5986				out body;
5987				>;
5988				out skel qt;`;
5989			//console.log(query);
5990			const servers = ['https://overpass.kumi.systems','https://overpass-api.de'];
5991			const url = `${servers[Math.floor(Math.random() * servers.length)]}/api/interpreter?data=${encodeURIComponent(query)}`;
5992			try {
5993				const response = await fetch(url);
5994				if (!response.ok) {
5995					if (response.status === 504) {
5996						showScreenMessage('Gateway timeout, spróbuj później', { severity: 'error', duration: 1100, category: 'gather-objects-error'});
5997					} else {
5998						showScreenMessage('Błąd komunikacji (${response.status}), spróbuj później', { severity: 'error', duration: 1100, category: 'gather-objects-error'});
5999					}
6000					return [];
6001				}
6002				const data = await response.json();
6003				const mountainData = data.elements.map(element => {
6004					const ret = {
6005						name: element.tags?.name || '???',
6006						lat: element.lat,
6007						lon: element.lon,
6008						kind: element.tags?.natural,
6009						country: '',
6010						ele: element.tags?.ele ? Math.round(element.tags?.ele) : 0
6011					};
6012					if (element.tags?.wikipedia) {
6013						const wiki = parseWikiLangString(element.tags.wikipedia);
6014						ret.wiki = wiki.text;
6015						if (wiki.lang && wiki.lang!=='pl') ret.wikilang = wiki.lang;
6016					}
6017					return ret;
6018				});
6019				showScreenMessage('Sprawdzanie kraju', { duration: 5000, category: 'gather-objects'});
6020				try {
6021					const countries = await giga.countryCheck.batch(mountainData.map(element => [element.lat, element.lon]));
6022					mountainData.forEach((element, i) => {
6023						element.country = countries[i].join(' ');
6024					});
6025				} catch(e) {
6026				}
6027				return mountainData;
6028			} catch (error) {
6029				showScreenMessage('Wystąpił problem', { severity: 'error', duration: 1100, category: 'gather-objects'});
6030				console.error('Error fetching mountain data:', error);
6031				return [];
6032			}
6033		}
6034	},
6035	
6036	countryCheck: {
6037		// -------------------------
6038		// Public API
6039		// -------------------------
6040
6041		async single(lat, lon) {
6042			const providers = [
6043				this._bigDataCloudLookup.bind(this),
6044				this._nominatimLookup.bind(this)
6045			];
6046
6047			for (const provider of providers) {
6048				try {
6049					const result = await provider(lat, lon);
6050					if (result.length > 0) {
6051						return result;
6052					}
6053				} catch (err) {
6054					// continue to next provider
6055				}
6056			}
6057
6058			return [];
6059		},
6060
6061		async batch(coords, { parallel = false } = {}) {
6062			if (parallel) {
6063				// Run all lookups concurrently
6064				const tasks = coords.map(([lat, lon]) => this.single(lat, lon));
6065				return Promise.all(tasks);
6066			}
6067
6068			// Run lookups sequentially (safer for rate limits)
6069			const results = [];
6070			for (const [lat, lon] of coords) {
6071				const r = await this.single(lat, lon);
6072				results.push(r);
6073			}
6074			return results;
6075		},
6076
6077		// -------------------------
6078		// Providers
6079		// -------------------------
6080
6081		async _nominatimLookup(lat, lon) {
6082			const url =
6083				`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=3&addressdetails=1`;
6084
6085			const res = await fetch(url, {
6086				headers: {
6087					"User-Agent": giga.userAgentString
6088				}
6089			});
6090
6091			if (!res.ok) throw new Error("Nominatim failed");
6092
6093			const data = await res.json();
6094			const code = data.address?.country_code;
6095
6096			return code ? [code.toUpperCase()] : [];
6097		},
6098
6099		async _bigDataCloudLookup(lat, lon) {
6100			const url =
6101				`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`;
6102
6103			const res = await fetch(url);
6104			if (!res.ok) throw new Error("BigDataCloud failed");
6105
6106			const data = await res.json();
6107			const code = data.countryCode;
6108
6109			return code ? [code.toUpperCase()] : [];
6110		}
6111	},
6112	
6113	mapycom: {
6114		APIKEY: 'sdn07WziDRnbXIEVJKzaKTRQtO0jYLxhklXggwezxwU',
6115		
6116		objectsLayer: null,
6117		markerLayer: null,
6118		lineLayer: null,
6119		starLayer: null,
6120		contextMenuMarkerLayer: null,
6121		layerControl: null,
6122		recentLayers: [],
6123		
6124		resetMap: function() {
6125			if (giga.mapycom.mapContainer) {
6126				giga.mapycom.mapContainer.remove();
6127				giga.mapycom.mapContainer = null;
6128			}
6129			if (giga.mapycom.map) {
6130				giga.mapycom.map = null;
6131			}
6132		},
6133		
6134		isShown: () => {
6135			return giga.mapycom.mapContainer && giga.mapycom.mapContainer.style.display == 'block';
6136		},
6137
6138		toggle: () => {
6139			if (!giga.mapycom.mapContainer) {
6140				giga.mapycom.showMap(+k.info.lat,+k.info.lon);
6141			} else {
6142				if (giga.mapycom.mapContainer.style.display=='none') {
6143					giga.mapycom.mapContainer.style.display = 'block';
6144				} else {
6145					giga.mapycom.mapContainer.style.display = 'none';
6146				}
6147			}
6148		},
6149		
6150		showMap: function (lat, lon, name='', opts={}) {
6151			console.log('showMap', lat, lon, name, opts);
6152			const zoom = opts?.zoom ?? 14;
6153			const center = [lat, lon];
6154			if (!giga.mapycom.mapContainer) {
6155				giga.mapycom.mapContainer = document.createElement("div");
6156				giga.mapycom.mapContainer.setAttribute('id','mapycom-map');
6157				giga.mapycom.mapContainer.classList.add('popup','draggable','resizable');
6158				
6159				const close = document.createElement("div");
6160				close.textContent = 'X';
6161				close.classList = 'close';
6162				giga.mapycom.mapContainer.appendChild(close);
6163				
6164				document.body.appendChild(giga.mapycom.mapContainer);
6165				$("#mapycom-map").show().draggable({
6166					cursor: 'move',
6167					handle: '.close',
6168					scroll: false
6169				});
6170				
6171				L.Control.Layers.include({
6172					getOverlays: function() {
6173						var control, layers;
6174						layers = {};
6175						control = this;
6176						control._layers.forEach(function(obj) {
6177							var layerName;
6178							if (obj.overlay) {
6179								layerName = obj.name;
6180								return layers[layerName] = control._map.hasLayer(obj.layer);
6181							}
6182						});
6183						return layers;
6184					},
6185					getCurrentLayer: function() {
6186						const control = this;
6187						return control._layers.find( obj => !obj.overlay && control._map.hasLayer(obj.layer));
6188					}
6189				});
6190				
6191				function getRadius() {
6192					const bounds = giga.mapycom.map.getBounds();
6193					const sw = bounds.getSouthWest();
6194					const ne = bounds.getNorthEast();
6195					const width = sw.distanceTo(L.latLng(sw.lat, ne.lng));
6196					const height = sw.distanceTo(L.latLng(ne.lat, sw.lng));
6197					const radius = Math.round(0.35 * height);
6198					return radius;
6199				}
6200				
6201				function showMouseOverRectangle(e) {
6202					const radius = getRadius();
6203					const bounds = e.latlng.toBounds(radius * 2);
6204					radiusRectangle = L.rectangle(bounds, {
6205						stroke: false,
6206						fillColor: "#ff7800",  
6207						fillOpacity: 0.3  
6208					});
6209					radiusRectangle.addTo(giga.mapycom.map);
6210				}
6211
6212				function hideMouseOverRectangle(e) {
6213					giga.mapycom.map.removeLayer(radiusRectangle);
6214				}
6215				
6216				async function gatherObjects(e, forceAll=false) {
6217					
6218					let country = giga.checkCountry(+e.latlng.lat, +e.latlng.lng);
6219
6220					if (!country?.countryCode) country = await giga.getCountryFromCoordinates(+e.latlng.lat, +e.latlng.lng);
6221			
6222					let data = [];
6223					if (country?.countryCode=='PL' && !forceAll) {
6224						showScreenMessage(`Polska - dane z GUGiK`, {duration: 10000, category: 'gather-objects'});
6225						
6226						const bounds = e.latlng.toBounds(getRadius() * 2);
6227						data = await giga.getGugikObjectsFromBounds(bounds.getSouth(), bounds.getWest(), bounds.getNorth(), bounds.getEast());
6228						
6229						data.forEach( el => {
6230							el.ele = 0;
6231							el.country = 'PL';
6232							el.kind = '';
6233							el.source = 'gugik';
6234						});
6235						
6236					} else if (country?.countryCode=='SK' && !forceAll) {
6237						
6238						showScreenMessage(`Słowacja - dane z rejestru ZBGIS`, {duration: 10000, category: 'gather-objects'});
6239						const response = await fetch('zbgis-find.html?'+makeParams({
6240							lat: +e.latlng.lat,
6241							lon: +e.latlng.lng,
6242							radius: getRadius()
6243						}));
6244						data = await response.json();
6245						
6246						proj4.defs("EPSG:5514","+proj=krovak +lat_0=49.5 +lon_0=24.8333333333333 +alpha=30.2881397527778 +k=0.9999 +x_0=0 +y_0=0 +ellps=bessel +towgs84=485,169.5,483.8,7.786,4.398,4.103,0 +units=m +no_defs +type=crs");
6247						// add wgs coords to results
6248						data.forEach( el => {
6249							const [lon, lat] = proj4("EPSG:5514").inverse([+el.xcoord,+el.ycoord]);
6250							el.lat = lat;
6251							el.lon = lon;
6252							el.country = 'SK';
6253							el.source = 'zbgis';
6254						});
6255						
6256					} else {
6257						
6258						showScreenMessage(`${country?.countryCode  || 'nieznany kraj'} - Overpass`, {duration: 30000, category: 'gather-objects'});
6259						const bounds = e.latlng.toBounds(getRadius() * 2);
6260						// ALL objects
6261						data = await giga.overpass.getFromBounds(bounds.getSouth(), bounds.getWest(), bounds.getNorth(), bounds.getEast());
6262						data.forEach( el => {
6263							el.source = 'overpass';
6264						});	
6265						//console.log(data);
6266
6267					}
6268					
6269					const makeXMLfromRow = function (el) {
6270						let nameX = el.name, names = '', wiki = '', wikilang = '';
6271						if (hotspots.points.has(nameX)) {
6272							if (el.ele>0) {
6273								nameX += ` (${el.ele} m)`;
6274							}
6275							if (hotspots.points.has(nameX)) {
6276								nameX = el.name + '*';
6277							}
6278						}
6279						if (Array.isArray(el.names)) {
6280							names = el.names.map(e => `<name>${e}</name>`).join('');
6281						} else {
6282							names = `<name>${el.name}</name>`;
6283						}
6284						if (el.wiki && el.wikilang) wikilang = ` wikilang="${el.wikilang}"`;
6285
6286						return `<point id="${nameX}" wiki="${el.wiki || ''}"${wikilang} lat="${el.lat.toFixed(5)}" lon="${el.lon.toFixed(5)}" source="${el.source}" elevation="${el.ele>0 ? el.ele : ''}" country="${el.country}" kind="${el.kind}">${names}<range></range></point>`;
6287					}
6288					
6289					const xmlStrings = [], rawNames = [];
6290					data.forEach( el => {
6291						const xmlString = makeXMLfromRow(el);
6292						rawNames.push(el.name);
6293						xmlStrings.push(xmlString);
6294						//console.log(xmlString);
6295						giga.mapycom.addPoint(el.lat, el.lon, el.name);
6296					});
6297					
6298					if (xmlStrings.length==0) {
6299						showScreenMessage('Nie znaleziono żadnych obiektów', {category: 'gather-objects'});
6300					} else {
6301						showScreenMessage(`Skopiowano ${xmlStrings.length} ${odmien(xmlStrings.length,'obiekt','obiekty','obiektów')}: ${rawNames.join(', ')}`, {duration: 2000, category:'gather-objects'});
6302						giga.clipboard.copy(xmlStrings.join("\n"));
6303					}
6304				}
6305
6306				let radiusRectangle;
6307				
6308				const menuOptions = {
6309					contextmenu: true,
6310					contextmenuAnchor: [0, 0],
6311					contextmenuItems: [
6312						{
6313							text: '***'
6314						}, 						
6315						'-', 
6316						{
6317							text: 'Więcej o tych współrzędnych',
6318							callback: e => {
6319								window.open(`/giga/coord.html?lat=${e.latlng.lat.toFixed(6)}&lon=${e.latlng.lng.toFixed(6)}`);
6320							}
6321						}, 						
6322						{
6323							text: 'Otwórz w Google Maps',
6324							callback: e => {
6325								window.open(`https://www.google.com/maps?ll=${e.latlng.lat},${e.latlng.lng}&z=${giga.mapycom.map.getZoom()}`);
6326							}
6327						}, 						
6328						{
6329							text: 'Otwórz bieżącą mapę',
6330							callback: e => {
6331								if (typeof giga.mapycom.layerControl.getCurrentLayer().layer.options.getURL == 'function') {
6332									window.open(giga.mapycom.layerControl.getCurrentLayer().layer.options.getURL(e.latlng.lat, e.latlng.lng, giga.mapycom.map.getZoom()));
6333								} 
6334							}
6335						}, 
6336						{
6337							text: 'Pokaż wysokość z mapy.com',
6338							callback: e => {
6339								fetch(`https://api.mapy.cz/v1/elevation?lang=cs&positions=${e.latlng.lng}%2C${e.latlng.lat}&apikey=${giga.mapycom.APIKEY}`)
6340								.then(response => response.json())
6341								.then(data => {
6342									L.popup()
6343										.setLatLng(e.latlng)
6344										.setContent(`${data.items[0].elevation} m`)
6345										.openOn(giga.mapycom.map);
6346								})
6347								.catch(error => console.error('Problem! ', error));
6348							}
6349						}, 
6350						{
6351							text: 'Pokaż profil widzialności',
6352							callback: e => {
6353								fetch(`https://api.mapy.cz/v1/elevation?lang=cs&positions=${e.latlng.lng}%2C${e.latlng.lat}&apikey=${giga.mapycom.APIKEY}`)
6354								.then(response => response.json())
6355								.then(data => {
6356									const point = {lat: e.latlng.lat, lon: e.latlng.lng, elevation: Math.round(data.items[0].elevation), title: 'Punkt na mapie' };
6357									giga.lineOfSight.checkFromCurrent(point);
6358								})
6359								.catch(error => console.error('Problem! ', error));
6360							}
6361						}, 
6362						{
6363							text: 'Szukaj obiektów w pobliżu (lokalne)',
6364							mouseover: e => {
6365								showMouseOverRectangle(e);
6366							},
6367							mouseout: e => {
6368								console.log('mouseout' ,e);
6369								hideMouseOverRectangle(e);
6370							},
6371							callback: async (e) => {
6372								await gatherObjects(e);
6373							}
6374						}, 
6375						{
6376							text: 'Szukaj obiektów w pobliżu (Overpass)',
6377							mouseover: e => {
6378								showMouseOverRectangle(e);
6379							},
6380							mouseout: e => {
6381								hideMouseOverRectangle(e);
6382							},
6383							callback: async (e) => {
6384								await gatherObjects(e, true);
6385							}
6386						}, 
6387						'-', 
6388						{
6389							text: 'Wyśrodkuj tu mapę',
6390							callback: e => {
6391								giga.mapycom.map.panTo(e.latlng);
6392							}
6393						},				
6394						{
6395							text: 'Ustaw azymut na ten punkt (Alt-click)',
6396							callback: e => {
6397								const info = k.info;
6398								const azimuth = giga.getBearing(+info.lat, +info.lon, e.latlng.lat, e.latlng.lng);
6399								const hpos = giga.azimuthToHpos(azimuth);
6400								k.call('moveto('+hpos+',get(view.vlookat))');
6401							}
6402						},				
6403						'-', 
6404						{
6405							text: 'Zamknij',
6406							callback: e => {
6407								$(giga.mapycom.mapContainer).hide();
6408							}
6409						}
6410					]
6411				};
6412				
6413				giga.mapycom.map = L.map(giga.mapycom.mapContainer.id, menuOptions).setVie
6413w(center, zoom);
6414				
6415				const countryBounds = {
6416					'WORLD':  [[90,-180],[-90,180]],
6417					'SK': [[47.7584288, 16.8799829], [49.5715740, 22.5581376]],
6418					'CZ': [[48.5553052, 12.2401111], [51.1172677, 18.8531441]],
6419					'CH': [[45.7769477, 6.02260949], [47.8308275, 10.4427014]],  // [[45.398181, 5.140242], [48.230651, 11.47757]]
6420					'PL': [[49.0273953, 14.0745211], [54.8515359, 24.0299857]]
6421				}
6422				
6423				const tileLayers = {
6424					'mapy.com: podstawa': 
6425						L.tileLayer(`https://api.mapy.com/v1/maptiles/basic/256/{z}/{x}/{y}?apikey=${giga.mapycom.APIKEY}`, {
6426						minZoom: 0,
6427						maxZoom: 19,
6428						bounds: countryBounds.WORLD,
6429						name: 'mapy.com',
6430						attribution: 'mapy.com podstawa <a href="https://api.mapy.com/copyright" target="_blank">© Seznam.cz a.s. a další</a>',
6431						getURL: (lat,lon,zoom=16) => `https://mapy.com/en/zakladni?x=${lon}&y=${lat}&z=${zoom}`,
6432					}),
6433					'mapy.com: turystyczna': 
6434						L.tileLayer(`https://api.mapy.com/v1/maptiles/outdoor/256/{z}/{x}/{y}?apikey=${giga.mapycom.APIKEY}`, {
6435						minZoom: 0,
6436						maxZoom: 19,
6437						bounds: countryBounds.WORLD,
6438						name: 'mapy.com',
6439						attribution: 'mapy.com turystyczna <a href="https://api.mapy.com/copyright" target="_blank">© Seznam.cz a.s. a další</a>',
6440						getURL: (lat,lon,zoom=16) => `https://mapy.com/en/turisticka?x=${lon}&y=${lat}&z=${zoom}`,
6441					}),
6442					'mapy.com: lotnicza':
6443						L.tileLayer(`https://api.mapy.com/v1/maptiles/aerial/256/{z}/{x}/{y}?apikey=${giga.mapycom.APIKEY}`, {
6444						minZoom: 0,
6445						maxZoom: 19,
6446						bounds: countryBounds.WORLD,
6447						name: 'mapy.com',
6448						attribution: 'mapy.com ortofoto WMTS <a href="https://api.mapy.com/copyright" target="_blank">© Seznam.cz a.s. a další</a>',
6449						getURL: (lat,lon,zoom=16) => `https://mapy.com/en/letecka?x=${lon}&y=${lat}&z=${zoom}`,
6450					}),
6451
6452					'Geoportal: PRNG': 
6453						L.tileLayer.wms('https://mapy.geoportal.gov.pl/wss/service/PZGiK/PRNG/WMS/GeographicalNames?', {
6454						layers: 'O1_UrzedoweNazwyObiektowFizjograficznych,O2_ZestandaryzowaneNazwyObiektowFizjograficznych',
6455						minZoom: 5,
6456						maxZoom: 22,
6457						bounds: countryBounds.PL,
6458						format: 'image/png',
6459						transparent: true,
6460						version: '1.3.0',
6461						tiled: true,
6462						crossorigin: true,
6463						attribution: "Geoportal PRNG © Główny Urząd Geodezji i Kartografii"
6464					}),
6465						
6466					'Geoportal: ortofotomapa HR': 
6467						L.tileLayer.wms('https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/HighResolution?', {
6468						layers: 'Raster',
6469						minZoom: 5,
6470						maxZoom: 22,
6471						bounds: countryBounds.PL,
6472						format: 'image/png',
6473						transparent: false,
6474						version: '1.1.0',
6475						tiled: true,
6476						crossorigin: true,
6477						attribution: "Geoportal ortofoto HR © Główny Urząd Geodezji i Kartografii"
6478					}),
6479							
6480					/*
6481					'Geoportal: ortofotomapa SR': 
6482						L.tileLayer.wms('https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution?', {
6483						layers: 'Raster',
6484						minZoom: 5,
6485						maxZoom: 20,
6486						bounds: countryBounds.PL,
6487						format: 'image/png',
6488						transparent: false,
6489						version: '1.1.0',
6490						tiled: true,
6491						crossorigin: true,
6492						attribution: "© Główny Urząd Geodezji i Kartografii"
6493					}),
6494					*/
6495							
6496					'Geoportal: ortofotomapa (WMTS)':
6497						L.tileLayer(`https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/StandardResolution?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTOFOTOMAPA&STYLE=default&FORMAT=image/jpeg&TILEMATRIXSET=EPSG:3857&TILEMATRIX=EPSG:3857:{z}&TILEROW={y}&TILECOL={x}`, {
6498						minZoom: 0,
6499						maxZoom: 19,
6500						bounds: countryBounds.PL,
6501						attribution: "Geoportal ortofoto WMTS © Główny Urząd Geodezji i Kartografii"
6502					}),
6503							
6504					'ZBGIS: základna topo 1:10000': 
6505						L.tileLayer(`https://zbgis.skgeodesy.sk/zbgis/rest/services/ZMSR_10/MapServer/tile/{z}/{y}/{x}?blankTile=false`, {
6506						minZoom: 8,
6507						maxZoom: 19,
6508						maxNativeZoom: 17,
6509						bounds: countryBounds.SK,
6510						transparent: false,
6511						attribution: 'ZBGIS topo 10k © Geodetický a kartografický ústav Bratislava'
6512					}),
6513					
6514					'ZBGIS: mapa topo': 
6515						L.tileLayer.wms('https://zbgisws.skgeodesy.sk/retm_wms/service.svc/get', {
6516						layers: '1',
6517						minZoom: 8,
6518						maxZoom: 18,
6519						bounds: countryBounds.SK,
6520						format: 'image/png',
6521						transparent: false,
6522						attribution: 'ZBGIS topo © Geodetický a kartografický ústav Bratislava'
6523					}),
6524					
6525					'ZBGIS: ortofotomapa': 
6526						L.tileLayer.wms('https://zbgisws.skgeodesy.sk/zbgis_ortofoto_wms/service.svc/get', {
6527						layers: '1',
6528						bounds: countryBounds.SK,
6529						format: 'image/jpeg',
6530						transparent: false,
6531						attribution: 'ZBGIS ortofoto © Geodetický a kartografický ústav Bratislava'
6532					}),
6533					
6534					'mapy.dennikn.sk': 
6535						L.tileLayer(`https://static.mapy.hiking.sk/topo/{z}/{x}/{y}.png`, {
6536						minZoom: 9,
6537						maxZoom: 18,
6538						maxNativeZoom: 15,
6539						bounds: [countryBounds.CZ, countryBounds.SK],
6540						attribution: "SHOCart, https://hiking.dennikn.sk/",
6541						getURL: (lat,lon,zoom=15) => `https://mapy.dennikn.sk/?y=${lat}&x=${lon}&zoom=${zoom}`,
6542					}),
6543					
6544					'ČÚZK: ortofotomapa': 
6545						L.tileLayer.wms('https://ags.cuzk.gov.cz/arcgis1/services/ORTOFOTO/MapServer/WMSServer?', {
6546						layers: '0',
6547						bounds: countryBounds.CZ,
6548						format: 'image/png',
6549						transparent: true,
6550						version: '1.1.0',
6551						tiled: true,
6552						attribution: 'ČÚZK ortofoto  © Czech Geoportal (ČÚZK)'
6553					}),
6554					
6555					'ČÚZK: topo 1:5000': 
6556						L.tileLayer.wms('https://ags.cuzk.gov.cz/arcgis1/services/ZTM/ZTM5/MapServer/WMSServer?', {
6557						layers: '0',
6558						bounds: countryBounds.CZ,
6559						format: 'image/png',
6560						transparent: true,
6561						version: '1.1.0',
6562						tiled: true,
6563						attribution: 'ČÚZK topo © Czech Geoportal (ČÚZK)'
6564					}),
6565					
6566					"Google Streets": 
6567						L.tileLayer('https://{s}.google.com/vt/lyrs=m&x={x}&y={y}&z={z}&hl=pl', { 
6568						maxZoom: 20, 
6569						bounds: countryBounds.WORLD,
6570						subdomains:['mt0','mt1','mt2','mt3'], 
6571						attribution: 'Google Streets © Google',
6572						getURL: (lat,lon,zoom=16) => `https://www.google.com/maps/@${lat},${lon},${zoom}z`,
6573
6574					}),
6575					"Google Hybrid": 
6576						L.tileLayer('https://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}&hl=pl', { 
6577						maxZoom: 20, 
6578						bounds: countryBounds.WORLD,
6579						subdomains:['mt0','mt1','mt2','mt3'], 
6580						attribution: 'Google Hybrid © Google',
6581						getURL: (lat,lon,zoom=16) => `https://www.google.com/maps/@${lat},${lon},${zoom}z/data=!3m1!1e3!5m1!1e4`,
6582					}),
6583					"Google Satellite": 
6584						L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}&hl=pl', { 
6585						maxZoom: 20, 
6586						bounds: countryBounds.WORLD,
6587						subdomains:['mt0','mt1','mt2','mt3'], 
6588						attribution: 'Google Sat © Google', 
6589						getURL: (lat,lon,zoom=16) => `https://www.google.com/maps/@${lat},${lon},${zoom}z/data=!3m1!1e3!5m1!1e4`,
6590					}),
6591					"Google Terrain": 
6592						L.tileLayer('https://{s}.google.com/vt/lyrs=p&x={x}&y={y}&z={z}&hl=pl', { 
6593						maxZoom: 20, 
6594						bounds: countryBounds.WORLD,
6595						subdomains:['mt0','mt1','mt2','mt3'], 
6596						attribution: 'Google Terrain © Google', 
6597						getURL: (lat,lon,zoom=16) => `https://www.google.com/maps/@${lat},${lon},${zoom}z/data=!3m1!1e3!5m1!1e4!6m1!1e1`,
6598					}),
6599					
6600					/*
6601					'Geoportal BDOT10k': 
6602					new L.TileLayer.WMTS('https://mapy.geoportal.gov.pl/wss/service/WMTS/guest/wmts/BDOT10k', {
6603						crs: new L.Proj.CRS(
6604							"EPSG:2180",
6605							"+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs",
6606							{}
6607						),
6608						layer: "",
6609						style: "",
6610						bounds: countryBounds.PL,
6611						tilematrixset: "EPSG:2180",
6612						format: "image/png",
6613						attribution: "© Główny Urząd Geodezji i Kartografii",
6614						crossorigin: true
6615					}),
6616					*/
6617					
6618					'Open Topo Map': 
6619						L.tileLayer(`https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png`, {
6620						minZoom: 0,
6621						maxNativeZoom: 17,
6622						maxZoom: 18,
6623						subdomains:['a','b','c'], 
6624						bounds: countryBounds.WORLD,
6625						attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
6626						getURL: (lat,lon,zoom=16) => `https://www.opentopomap.org/?mlat=${lat}&mlon=${lon}&zoom=${zoom}`
6627					}),
6628					
6629					'Open Street Map': 
6630						L.tileLayer(`https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`, {
6631						minZoom: 0,
6632						maxZoom: 19,
6633						bounds: countryBounds.WORLD,
6634						attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
6635						getURL: (lat,lon,zoom=16) => `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=${zoom}`
6636					}),
6637					
6638					'Swisstopo: mapa topo': 
6639						L.tileLayer('https://wmts{s}.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg', {
6640						subdomains: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
6641						minZoom: 2,
6642						maxZoom: 20,
6643						bounds: countryBounds.CH,
6644						attribution: '© <a href="https://www.swisstopo.admin.ch/">swisstopo</a>'
6645					}),
6646					
6647					'Swisstopo: ortofotomapa': 
6648						L.tileLayer('https://wmts{s}.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg', {
6649						subdomains: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
6650						minZoom: 2,
6651						maxZoom: 20,
6652						bounds: countryBounds.CH,
6653						attribution: '© <a href="https://www.swisstopo.admin.ch/">swisstopo</a>'
6654					})
6655
6656				};
6657				
6658				const LogoControl = L.Control.extend({
6659					options: {
6660						position: 'bottomleft',
6661					},
6662					onAdd: function (map) {
6663						const container = L.DomUtil.create('div');
6664						const link = L.DomUtil.create('a', '', container);
6665
6666						link.setAttribute('href', 'http://mapy.com/');
6667						link.setAttribute('target', '_blank');
6668						link.innerHTML = '<img src="https://api.mapy.com/img/api/logo.svg" />';
6669						L.DomEvent.disableClickPropagation(link);
6670
6671						return container;
6672					},
6673				});
6674				const logoControl = new LogoControl();
6675			
6676				giga.mapycom.map.on('layeradd', function (e) {
6677					//console.log('layeradd');
6678					//console.log(e);
6679					if (e.layer.options.name?.includes('mapy.com')) {
6680						logoControl.addTo(giga.mapycom.map);
6681					}
6682				});
6683
6684				giga.mapycom.map.on('layerremove', function (e) {
6685					//console.log('layerremove');
6686					//console.log(e);
6687					if (e.layer.options.name?.includes('mapy.com')) {
6688						logoControl.remove();
6689					}
6690				});
6691
6692				L.control.mousePosition({
6693					latFormatter: n => n.toFixed(5),
6694					lngFormatter: n => n.toFixed(5),
6695					prefixFormatter: latlng => {
6696						const info = k.info;
6697						const {distance, bearing} = giga.getDistanceAndBearing(info.lat,info.lon,latlng.lat,latlng.lng);
6698						let zoomText = `Zoom <span class="zoom-level-info">${giga.mapycom.map.getZoom()}</span>`;
6699						if (typeof giga.mapycom.layerControl.getCurrentLayer().layer.options.getURL == 'function') {
6700							const center = giga.mapycom.map.getCenter();
6701							zoomText = `<a href="${giga.mapycom.layerControl.getCurrentLayer().layer.options.getURL(center.lat, center.lng, giga.mapycom.map.getZoom())}" target="_blank">${zoomText}</a>`;
6702						}
6703						return `${zoomText} : <b>${distance.toFixed(2)} km</b> : ${bearing.toFixed(2)}&deg; : `;
6704					},
6705					position: 'bottomleft',
6706					separator: ', ',
6707					emptyString: '---',
6708				}).addTo(giga.mapycom.map);
6709				
6710				tileLayers['mapy.com: turystyczna'].addTo(giga.mapycom.map);
6711				giga.mapycom.recentLayers = [tileLayers['mapy.com: turystyczna'], tileLayers['mapy.com: lotnicza']];
6712				
6713				
6714				function updateBaseLayerControl(map, tileLayers) {
6715					
6716					const mapBounds = map.getBounds();
6717					const visibleBaseLayers = {};
6718
6719					//console.log('updating visible base layers list');
6720
6721					Object.entries(tileLayers).forEach(([name, layer]) => {
6722						//console.log(mapBounds);
6723						//console.log(layer.options.bounds);
6724						if (layer.options.bounds && ( Array.isArray(layer.options.bounds[0][0]) ? layer.options.bounds.some(b => mapBounds.intersects(b)) : mapBounds.intersects(layer.options.bounds)) ) {
6725							//console.log('adding ' + name);
6726							visibleBaseLayers[name] = layer;
6727						} else {
6728							//console.log('removing ' + name);
6729						}
6730					});
6731
6732					// Remove old control and add new one
6733					if (giga.mapycom.layerControl) {
6734						giga.mapycom.map.removeControl(giga.mapycom.layerControl);
6735					}
6736
6737					giga.mapycom.layerControl = L.control.layers(visibleBaseLayers, null).addTo(map);
6738				}
6739
6740				giga.mapycom.layerControl = L.control.layers(tileLayers);
6741				giga.mapycom.layerControl.addTo(giga.mapycom.map);
6742				updateBaseLayerControl(giga.mapycom.map, tileLayers);
6743
6744				giga.mapycom.objectsLayer =  L.featureGroup();
6745				giga.mapycom.markerLayer = L.featureGroup();
6746				giga.mapycom.lineLayer = L.featureGroup();
6747				giga.mapycom.contextMenuMarkerLayer = L.featureGroup();
6748				
6749				giga.mapycom.objectsLayer.addTo(giga.mapycom.map);
6750				giga.mapycom.markerLayer.addTo(giga.mapycom.map);
6751				giga.mapycom.lineLayer.addTo(giga.mapycom.map);
6752				giga.mapycom.contextMenuMarkerLayer.addTo(giga.mapycom.map);
6753				
6754				giga.mapycom.loadObjects();
6755			
6756				giga.mapycom.map.on('moveend', () => updateBaseLayerControl(giga.mapycom.map, tileLayers));
6757				giga.mapycom.map.on('click', e => {
6758					if (e.originalEvent.altKey) {
6759						const info = k.info;
6760						const azimuth = giga.getBearing(+info.lat, +info.lon, e.latlng.lat, e.latlng.lng);
6761						const hpos = giga.azimuthToHpos(azimuth);
6762						k.call('moveto('+hpos+',get(view.vlookat))');
6763					}
6764				});
6765				
6766				giga.mapycom.map.on('contextmenu.item.mouseover', e => {
6767					//console.log('contextmenu.item.mouseover');
6768					//console.log(e);
6769				});
6770				giga.mapycom.map.on('contextmenu.item.mouseout', e => {
6771					//console.log('contextmenu.item.mouseout');
6772					//console.log(e);
6773				});
6774				giga.mapycom.map.on('contextmenu.show', e => {
6775					//console.log('contextmenu.show');
6776					//console.log(e);
6777					L.circleMarker(e.latlng, {radius: 4, color: '#f00', opacity: 0.7}).addTo(giga.mapycom.contextMenuMarkerLayer);
6778					//if (e.relatedTarget) return;
6779					e.contextmenu.updateItem(0, {
6780						text: `<b>${e.latlng.lat.toFixed(6)}, ${e.latlng.lng.toFixed(6)}</b>`,
6781						callback: e => {
6782							giga.clipboard.copy(`lat="${e.latlng.lat.toFixed(5)}" lon="${e.latlng.lng.toFixed(5)}"`);
6783						}
6784					});
6785					if (typeof giga.mapycom.layerControl.getCurrentLayer().layer.options.getURL == 'function') {
6786						e.contextmenu.showItem(4);
6787					} else {
6788						e.contextmenu.hideItem(4);
6789					}
6790
6791					if (giga.mapycom.objectsLayer.getLayers().length>0) {
6792						e.contextmenu.insertItem({
6793							text: 'Usuń załadowane obiekty',
6794							callback: e => {
6795								showScreenMessage(`Obiekty usunięte`, {category:'map-show-objects'});
6796								giga.mapycom.objectsLayer?.clearLayers();
6797							}
6798						}, 7); 
6799					} else {
6800						e.contextmenu.insertItem({
6801							text: 'Załaduj obiekty z bazy',
6802							callback: e => {
6803								giga.mapycom.loadObjects();
6804							}
6805						}, 7); 
6806					}
6807
6808					if (giga.mapycom.starLayer) {
6809						e.contextmenu.insertItem({
6810							text: 'Schowaj, co widać',
6811							callback: e => {
6812								giga.mapycom.removeStarLayer();
6813							}
6814						}, 8); 
6815					} else {
6816						e.contextmenu.insertItem({
6817							text: 'Pokaż, co widać',
6818							callback: e => {
6819								giga.mapycom.addStarLayer();
6820							}
6821						}, 8); 
6822					}
6823
6824				});			
6825				giga.mapycom.map.on('contextmenu.hide', e => {
6826					e.contextmenu.removeItem(8);
6827					e.contextmenu.removeItem(7);
6828					//e.contextmenu.removeItem(0);
6829					giga.mapycom.contextMenuMarkerLayer.clearLayers();
6830				});	
6831				
6832				giga.mapycom.map.on('baselayerchange', e => {
6833					const current = e.layer;
6834					if (giga.mapycom.recentLayers[0] !== current) {
6835						giga.mapycom.recentLayers.unshift(current);
6836						if (giga.mapycom.recentLayers.length > 2) giga.mapycom.recentLayers.pop();
6837					}
6838					giga.mapycom.map.contextmenu.toggleItem(4, typeof giga.mapycom.layerControl.getCurrentLayer().layer.options.getURL == 'function');
6839
6840				});
6841				
6842				giga.mapycom.map.on('keydown', function(e) {
6843					if (e.originalEvent.key.toLowerCase() === 'z' && giga.mapycom.recentLayers.length === 2) {
6844						giga.mapycom.map.eachLayer(layer => {
6845							if (Object.values(tileLayers).includes(layer)) {
6846								giga.mapycom.map.removeLayer(layer);
6847							}
6848						});
6849						giga.mapycom.map.addLayer(giga.mapycom.recentLayers[1]);
6850						// Swap the layers
6851						recentLayers = [giga.mapycom.recentLayers[1], giga.mapycom.recentLayers[0]];
6852					}
6853				});
6854
6855				giga.mapycom.map.on('zoomend', e => {
6856					const zoomInfo = giga.mapycom.mapContainer.querySelector('.zoom-level-info');
6857					const zoom = giga.mapycom.map.getZoom();
6858					if (zoomInfo) zoomInfo.textContent = zoom;
6859					// hide objects layer when zoomed out
6860					if (zoom <= 8) {
6861						if (giga.mapycom.map.hasLayer(giga.mapycom.objectsLayer)) {
6862							giga.mapycom.map.removeLayer(giga.mapycom.objectsLayer);
6863						}
6864					} else {
6865						if (!giga.mapycom.map.hasLayer(giga.mapycom.objectsLayer)) {
6866							giga.mapycom.map.addLayer(giga.mapycom.objectsLayer);
6867						}
6868					}
6869				});	
6870			
6871			} else {
6872				$("#mapycom-map").show();
6873			}
6874			
6875			giga.mapycom.markerLayer?.clearLayers();
6876			giga.mapycom.lineLayer?.clearLayers();
6877			
6878			// POV marker
6879			const markerTitle = `${name}: ${lat}, ${lon}`;
6880			const marker = L.marker(center, {
6881				opacity: 0.9,
6882				title: markerTitle,
6883				icon: new L.Icon.Default({ 
6884					imagePath: '/giga/lib/leaflet-1.9.4/images/',
6885					iconUrl: 'marker-icon-red.png',
6886					iconRetinaUrl: 'marker-icon-red-2x.png'
6887				}),
6888			});
6889			marker.bindPopup(markerTitle);
6890			marker.addTo(giga.mapycom.markerLayer);
6891			
6892			if (Array.isArray(opts?.lines)) {
6893				opts.lines.forEach( l => giga.mapycom.addLine(l[0].lat,l[0].lon, l[1].lat,l[1].lon) );
6894			}		
6895			return giga.mapycom;
6896
6897		},
6898		
6899		removeStarLayer: () => {
6900			if (!giga.mapycom.starLayer) return;
6901			giga.mapycom.starLayer.clearLayers();
6902			giga.mapycom.starLayer = null;
6903		},
6904		
6905		addStarLayer: () => {
6906			if (giga.mapycom.starLayer) return;
6907			const info = k.info;
6908			const latlng = [+info.lat, +info.lon];
6909			const hsList = giga.currentPanorama.getHotspotsList().filter( (h) => {
6910				const p = hotspots.points.get(h.id);
6911				return hasValue(p?.lat, p?.lon);
6912			}).sort( (a, b) => +a.ath >= +b.ath );
6913
6914			const aStart = +info.azimuthleft;
6915			const aStop = +info.azimuthright;
6916			var range, direction;
6917			if (aStop>=aStart) {
6918				range = aStop-aStart;
6919				direction = aStart + range/2;
6920			} else {
6921				range = aStop+360-aStart;
6922				direction = aStart + range/2;
6923				if (direction>=360) direction -= 360;
6924			}
6925			
6926			giga.mapycom.starLayer = L.featureGroup();
6927
6928			const wholeView = L.semiCircle(latlng, {
6929				interactive: false,
6930				fill: true,
6931				fillColor:'#444444',
6932				fillOpacity: 0.11,
6933				color: '#444444',
6934				opacity: 0.0,
6935				sticky: true,
6936				radius: info.range ? +info.range*1000 : 100000
6937			}).setDirection(+direction,+range).addTo(giga.mapycom.starLayer);
6938
6939			const lines = L.featureGroup();
6940			hsList.forEach( (h) => {
6941				let p = hotspots.points.get(h.id);
6942				L.polyline([latlng, [+p.lat,+p.lon]], {
6943					keyboard: false,
6944					title: p.name,
6945					color: '#ff0000',
6946					opacity: 0.66,
6947					weight: 2
6948				}).on('click', function() {
6949					giga.moveToHotspot(h.name);
6950				}).bindTooltip(h.title, {
6951					keyboard: false,
6952					interactive: false,
6953					sticky: true
6954				}).addTo(lines);
6955			});
6956			lines.addTo(giga.mapycom.starLayer);
6957			
6958			L.circleMarker(latlng, {
6959				radius: 3,
6960				fill: '#00aa00',
6961				color: '#00aa00'
6962			}).addTo(giga.mapycom.starLayer);
6963			
6964			giga.mapycom.starLayer.addTo(giga.mapycom.map);
6965			giga.mapycom.map.fitBounds(lines.getBounds());
6966
6967		},
6968			
6969		addLine: function(lat1,lon1,lat2,lon2, temporary=false) {
6970
6971			const points = [
6972				[lat1, lon1],
6973				[lat2, lon2]
6974			];
6975			const options = {
6976				color: temporary ? "#01e" : "#f00",
6977				weight: 2,
6978				opacity: 0.9
6979			};
6980			const polyline = L.polyline(points, options);
6981			polyline.addTo(giga.mapycom.lineLayer);
6982	
6983			const greatCircleOptions = {
6984				color: temporary ? "#09e" : "#f80",
6985				weight: 2,
6986				opacity: 0.7,
6987				contextmenu: true,
6988				contextmenuInheritItems: false,
6989				contextmenuItems: [{
6990					text: 'Koło wielkie',
6991					callback: () => {}
6992				}]
6993			};
6994			const greatCircleLine = L.geodesic(points, greatCircleOptions);
6995			greatCircleLine.addTo(giga.mapycom.lineLayer);
6996			
6997		},
6998		
6999		addPoint: function(lat,lon, title='') {
7000			if (giga.mapycom.map) {
7001				const marker = L.marker([lat, lon], {
7002					opacity: 0.9,
7003					title: title,
7004					icon: new L.Icon.Default({ 
7005						imagePath: '/giga/lib/leaflet-1.9.4/images/',
7006						iconUrl: 'marker-icon-yellow.png',
7007						iconRetinaUrl: 'marker-icon-yellow-2x.png'
7008					}),
7009				});
7010				marker.addTo(giga.mapycom.markerLayer);
7011			}
7012		},
7013		
7014		removeLines: function() {
7015			if (giga.mapycom.lineLayer) {
7016				giga.mapycom.lineLayer.clearLayers();
7017			}
7018		},
7019				
7020		flyTo: function(lat, lon) {
7021			if (giga.mapycom.map) {
7022				giga.mapycom.map.panTo([lat,lon]);
7023			}
7024		},
7025		
7026		flyToWithLine: function(lat, lon, startLat, startLon) {
7027			console.log(lat, lon, startLat, startLon);
7028			if (giga.mapycom.map) {
7029				giga.mapycom.removeLines();
7030				giga.mapycom.addLine(startLat, startLon, lat, lon, true);
7031				giga.mapycom.map.panTo([lat,lon]);
7032			}
7033		},
7034		
7035		search: () => {
7036			alert('search not implemented!');
7037		},
7038		
7039		reverseGeocode: async (lat, lon) => {
7040			const response = await fetch(`https://api.mapy.com/v1/rgeocode?lon=${lon}&lat=${lat}&lang=pl&apiKey=${giga.mapycom.APIKEY}&type=poi`);
7041			return await response.json();
7042		},
7043		
7044		loadObjects: () => {
7045			const objects = giga.getObjectsFromBounds();
7046			giga.mapycom.objectsLayer?.clearLayers();
7047			const markerCluster = L.markerClusterGroup({
7048				maxClusterRadius: 45
7049			});
7050			showScreenMessage(`Znaleziono ${objects.length} ${odmien(objects.length,'obiekt','obiekty','obiektów')}`, {category:'map-show-objects'});
7051			const hotspotIDs = new Set(giga.currentPanorama.getHotspotsList().map(h => h.id));
7052			const redDotIcon = new L.Icon.Default({
7053				iconUrl: 'marker-icon-red-fill.png',
7054				iconRetinaUrl: 'marker-icon-red-fill-2x.png'
7055			});
7056			objects.forEach(p => {
7057				let markerOptions = {
7058					title: p.names[0]
7059				};
7060				const isOnPano = hotspotIDs.has(p.id);
7061				if (isOnPano) markerOptions.icon = redDotIcon;
7062				const marker = L.marker([p.lat, p.lon], markerOptions).on('click', e => {
7063					if (isOnPano) {
7064						const hs = giga.currentPanorama.getHotspotsList().find(h => h.id==p.id);
7065						if (hs?.name) giga.moveToHotspotAndClick(hs.name);
7066					} else {
7067						giga.showPointInfo(p.id);
7068						const bearing = giga.getBearing(+k.info.lat,+k.info.lon,p.lat,p.lon);
7069						const corrAz = (360 + bearing - giga.azimuthCorrection) % 360;
7070						const corrHpos = giga.azimuthToHpos(corrAz);
7071						k.call(`moveto(${corrHpos},get(view.vlookat))`);
7072					}
7073				});
7074				markerCluster.addLayer(marker);
7075			});
7076			giga.mapycom.objectsLayer.addLayer(markerCluster);
7077		}
7078
7079				
7080	},
7081	
7082	lineOfSight: {
7083		infowindow: null,
7084		/**
7085		 * Check if point B is visible from point A considering Earth's curvature,
7086		 * atmospheric refraction, and terrain elevation.
7087		 * 
7088		 * @param {Object} pointA - {lat: number, lon: number, elevation: number}
7089		 * @param {Object} pointB - {lat: number, lon: number, elevation: number, height: number}
7090		 * @param {number} samples - Number of elevation samples between points (default: 50)
7091
7092		 * @returns {Promise<Object>} - {visible: boolean, reason: string, blocker: Object|null}
7093		 */
7094		clear: () => {
7095			if (giga.lineOfSight.infowindow) {
7096				giga.lineOfSight.infowindow.remove();
7097				giga.lineOfSight.infowindow = null;
7098			}			 
7099		},
7100		
7101		checkFromCurrent: async function(point, options = {} ) {
7102			showScreenMessage('profil widzialności');
7103			options.samples ??= 800;
7104			if (k.info.refraction) options.refraction ??= +k.info.refraction;
7105
7106			const startLocation = hotspots.points.get(k.info.location);
7107			if (!options.fetchMethod && startLocation?.country?.includes('PL') && point.country?.includes('PL')) {
7108				options.fetchMethod = giga.lineOfSight.fetchMethods.GUGIK;
7109			}
7110			
7111			if (point.title) options.caption = point.title;
7112			let objectHeight = 0;
7113			if (!!point.height) objectHeight = Number(point.height);
7114
7115			giga.lineOfSight.check({
7116				lat: +k.info.lat, lon: +k.info.lon, elevation: +k.info.elevation
7117			}, {
7118				lat: point.lat, lon: point.lon, elevation: point.elevation, height: objectHeight
7119			}, options).then( res => console.log(res) );
7120		},
7121	
7122		check: async function (pointA, pointB, options = {}) {
7123			options.caption ??= '';
7124			options.samples ??= 100;
7125			options.refraction ??= 0.13;
7126			options.fetchMethod ??= giga.lineOfSight.fetchMethods.OPENELEVATION;  // HEIGIT
7127			
7128			const EARTH_RADIUS = 6378137.0; // 6371000; // meters
7129			const REFRACTION_COEFFICIENT = options.refraction;
7130			const MAX_SAMPLES = 5000;
7131			
7132			let ret;
7133			
7134			if (!giga.lineOfSight.infowindow) {
7135				const el = document.createElement("div");
7136				el.classList.add("popup-info-window","lineofsight-infowindow");
7137				giga.lineOfSight.infowindow = el;
7138				document.getElementById('content').append(el);
7139			} else {
7140				giga.lineOfSight.infowindow.classList.remove('blocked','visible');
7141				giga.lineOfSight.infowindow.replaceChildren();
7142			}
7143			const close = document.createElement("div");
7144			close.textContent = 'X';
7145			close.classList = 'close';
7146			close.setAttribute('title', 'Zamknij');
7147			close.onclick = giga.lineOfSight.clear;
7148			giga.lineOfSight.infowindow.append(close);
7149			
7150			const infoText = document.createElement("div");
7151			giga.lineOfSight.infowindow.append(infoText);
7152	
7153			// Calculate distance between points
7154			const distance = giga.lineOfSight.vincentyDistance(pointA, pointB);
7155			if (typeof options.sampleInterval==='number' && options.sampleInterval>=1) {
7156				console.log('adjusting samples according to sampleInterval = ' + options.sampleInterval);
7157				options.samples = Math.max(10, Math.min(Math.round(distance / options.sampleInterval), MAX_SAMPLES));
7158			}
7159			
7160			let elevationText = '';
7161			if (pointB.elevation) {
7162				elevationText = `${pointB.elevation}`;
7163				if (pointB.height>0) elevationText += `+${pointB.height}`;
7164				elevationText = ` (${elevationText} m)`;
7165			}
7166			
7167			if (options.caption) {
7168				const div = document.createElement('div');
7169				div.innerHTML = `<div class="lineofsight-info"><b>${options.caption}</b><br>
7170					<span class="open-mapy-cz-line" data-lat="${pointB.lat.toFixed(5)}" data-lon="${pointB.lon.toFixed(5)}">
7171					${pointB.lat.toFixed(5)}, ${pointB.lon.toFixed(5)}</span><span class="elevation-text">${elevationText}</span>. 
7172					Odległość: ${(distance/1000).toFixed(3)}&nbsp;km.</div>`;
7173				infoText.append(div);
7174			}
7175			
7176			infoText.append(makeElement('div',`Źródło danych wysokościowych: <span id="fetch-method" class="hoverable">${options.fetchMethod.short}</span>.`));
7177		
7178			infoText.append(makeElement('div', 
7179				`Współczynnik refrakcji: <span id="refraction-coef" class="hoverable">${REFRACTION_COEFFICIENT}</span>. 
7180				Liczba próbek: <span id="sample-count" class="hoverable">${options.samples}</span> (co <span id="sample-interval" class="hoverable">${Math.round(distance/(options.samples+1))}</span>&nbsp;m).
7181			`));
7182			
7183			new Dropdown(
7184				document.getElementById("fetch-method"),
7185				Object.entries(giga.lineOfSight.fetchMethods).map(([id, m]) => {
7186					return {label: m.description,  value: m};
7187				}),
7188				(val) => {
7189					let pOptions = { ...options };
7190					pOptions.fetchMethod = val;
7191					giga.lineOfSight.check(pointA, pointB, pOptions);
7192				},
7193				{ mode: 'hover' }
7194			);
7195			new Dropdown(
7196				document.getElementById("sample-count"),
7197				[
7198					{label:"100",  value:100},
7199					{label:"200",  value:200},
7200					{label:"500",  value:500},
7201					{label:"800",  value:800},
7202					{label:"1000", value:1000},
7203					{label:"2000", value:2000},
7204					{label:"3000", value:3000},
7205					{label:"5000", value:5000}
7206				],
7207				(val) => {
7208					let pOptions = { ...options };
7209					delete pOptions.sampleInterval;
7210					pOptions.samples = val;
7211					giga.lineOfSight.check(pointA, pointB, pOptions);
7212				},
7213				{ mode: 'hover' }
7214			);
7215			new Dropdown(
7216				document.getElementById("sample-interval"),
7217				[
7218					{label:"10 m",  value:10},
7219					{label:"20 m",  value:20},
7220					{label:"50 m",  value:50},
7221					{label:"100 m", value:100},
7222					{label:"200 m", value:200}
7223				],
7224				(val) => {
7225					let pOptions = { ...options };
7226					delete pOptions.samples;
7227					pOptions.sampleInterval = val;
7228					giga.lineOfSight.check(pointA, pointB, pOptions);
7229				},
7230				{ mode: 'hover' }
7231			);
7232			new Dropdown(
7233				document.getElementById("refraction-coef"),
7234				[
7235					{label:"0.13",  value:0.13},
7236					{label:"0.14",  value:0.14},
7237					{label:"0.15",  value:0.15},
7238					{label:"0.16",  value:0.16},
7239					{label:"0.17",  value:0.17},
7240					{label:"0.18",  value:0.18},
7241					{label:"0.19",  value:0.19},
7242					{label:"0.20",  value:0.20},
7243					{label:"0.22",  value:0.22},
7244					{label:"0.25",  value:0.25}
7245				],
7246				(val) => {
7247					let pOptions = { ...options };
7248					pOptions.refraction = val;
7249					giga.lineOfSight.check(pointA, pointB, pOptions);
7250				},
7251				{ mode: 'hover' }
7252			);
7253
7254			if (distance === null) {
7255				throw new Error('Distance calculation failed to converge');
7256			}
7257			
7258			// Generate intermediate points
7259			const intermediatePoints = giga.lineOfSight.generateIntermediatePoints(pointA, pointB, options.samples);
7260			//console.log(intermediatePoints);
7261			
7262			// Fetch elevations for all intermediate points
7263			const elevations = await giga.lineOfSight.fetchElevationsWithBatching(intermediatePoints, {fetchMethod: options.fetchMethod});
7264			
7265			if (!elevationText) {
7266				// jesli nie ma podanej wysokosci, daj tam sciagnieta wysokosc z NMT
7267				pointB.elevation = elevations.at(-1);
7268				elevationText = `${pointB.elevation}`;
7269				if (pointB.height>
72690) elevationText += `+${pointB.height}`;
7270				elevationText = ` (${elevationText} m NMT)`;
7271				document.querySelector('.lineofsight-info .elevation-text').innerHTML = elevationText;
7272			}
7273			
7274			if (pointB.height>0) pointB.elevation += pointB.height;
7275			intermediatePoints[0] = {
7276				...intermediatePoints[0],
7277				index: 0,
7278				elevation: pointA.elevation,
7279				requiredHeight: 0,
7280				obstructionHeight: 0,
7281				obstruction: false,
7282				distanceFromStart: 0,
7283				distanceFromEnd: distance
7284			};
7285
7286			const lastIndex = intermediatePoints.length - 1;
7287			intermediatePoints[lastIndex] = {
7288				...intermediatePoints[lastIndex],
7289				index: lastIndex,
7290				elevation: pointB.elevation,
7291				requiredHeight: pointB.elevation,
7292				obstructionHeight: 0,
7293				obstruction: false,
7294				distanceFromStart: distance,
7295				distanceFromEnd: 0
7296			};
7297
7298			// Check line of sight
7299			for (let i = 1; i < intermediatePoints.length - 1; i++) {
7300				const point = intermediatePoints[i];
7301				const distFromA = giga.lineOfSight.vincentyDistance(pointA, point);
7302				const distFromB = giga.lineOfSight.vincentyDistance(pointB, point);
7303				
7304				// Calculate required height at this point for line of sight
7305				const requiredHeight = giga.lineOfSight.calculateRequiredHeight(
7306					pointA.elevation,
7307					pointB.elevation,
7308					distFromA,
7309					distance,
7310					EARTH_RADIUS,
7311					REFRACTION_COEFFICIENT
7312				);
7313				
7314				intermediatePoints[i] = {
7315					...intermediatePoints[i],
7316					index: i,
7317					elevation: elevations[i],
7318					requiredHeight: requiredHeight,
7319					obstructionHeight: elevations[i] - requiredHeight,
7320					obstruction: elevations[i] > requiredHeight,
7321					distanceFromStart: distFromA,
7322					distanceFromEnd: distFromB
7323				};
7324				
7325				// Check if terrain blocks the view
7326				if (elevations[i] > requiredHeight) {
7327
7328					if (!ret) {
7329						ret = {
7330							visible: false,
7331							reason: 'Terrain obstruction',
7332							blocker: {
7333								index: i,
7334								lat: point.lat,
7335								lon: point.lon,
7336								elevation: elevations[i],
7337								requiredHeight: requiredHeight,
7338								obstruction: elevations[i] - requiredHeight
7339							},
7340							maxBlocker: {
7341								index: i,
7342								lat: point.lat,
7343								lon: point.lon,
7344								elevation: elevations[i],
7345								requiredHeight: requiredHeight,
7346								obstruction: elevations[i] - requiredHeight
7347							}
7348						}
7349					} else {
7350						if (elevations[i] - requiredHeight > ret.maxBlocker.obstruction) {
7351							ret.maxBlocker = {
7352								index: i,
7353								lat: point.lat,
7354								lon: point.lon,
7355								elevation: elevations[i],
7356								requiredHeight: requiredHeight,
7357								obstruction: elevations[i] - requiredHeight
7358							}
7359						}
7360					}
7361					
7362				}
7363			}
7364			
7365			const bars = document.createElement("div");
7366			bars.classList.add('elevation-bars');
7367			const barsImage = giga.drawBars(intermediatePoints, {drawCurveLine: true, barWidth: 2, spacing: 0 });
7368			
7369			giga.lineOfSight.infowindow.appendChild(bars);
7370			const display = document.createElement("div");
7371			giga.lineOfSight.infowindow.appendChild(display);
7372			
7373			const zoomable = new ZoomableImage({
7374				imgEl: barsImage, 
7375				containerEl: bars, 
7376				width: 410, 
7377				height: 160, 
7378				statusOverlay: true,
7379				onMouseOver: (args) => {
7380					const sampleIndex = Math.floor(args.unscaledX / 2), thisPoint = intermediatePoints[sampleIndex];
7381					if (thisPoint) {
7382						const obstructionText = thisPoint.obstruction ? `(przeszkoda: ${Math.round(Math.abs(thisPoint.obstructionHeight))} m)` : `(rezerwa: ${Math.round(Math.abs(thisPoint.obstructionHeight))} m)`;
7383						display.innerHTML = `<span style="color:${thisPoint.obstruction ? 'red' : 'green'}">próbka ${sampleIndex}</span>: 
7384							${(thisPoint.distanceFromStart/1000).toFixed(2)} km, wysokość ${thisPoint.elevation} m ${obstructionText}`;
7385					} else {
7386						display.textContent = '';						
7387					}
7388				}, 
7389				onClick: (args) => { 
7390					const sampleIndex = Math.floor(args.unscaledX / 2), thisPoint = intermediatePoints[sampleIndex];
7391					const info = k.info;
7392					giga.mapycom.showMap(+thisPoint.lat, +thisPoint.lon, '', {
7393						zoom: 15,
7394						/*
7395						lines: [
7396							[ {lat: +info.lat, lon: +info.lon}, {lat: +thisPoint.lat, lon: +thisPoint.lon} ] 
7397						]
7398						*/
7399					}).flyToWithLine(+thisPoint.lat, +thisPoint.lon, +info.lat, +info.lon);
7400				} 
7401			});			
7402			
7403			/*
7404			barsImage.addEventListener('mousemove', (e) => {
7405				const rect = barsImage.getBoundingClientRect();
7406				const x = e.clientX - rect.left;
7407				const percent = Math.min(100, Math.max(0, (x / rect.width) * 100)).toFixed(1);
7408				const sampleIndex = Math.round(percent*intermediatePoints.length/100), thisPoint = intermediatePoints[sampleIndex];
7409				const obstructionText = thisPoint.obstruction ? `(przeszkoda: ${Math.round(Math.abs(thisPoint.obstructionHeight))} m)` : `(rezerwa: ${Math.round(Math.abs(thisPoint.obstructionHeight))} m)`;
7410				display.innerHTML = `<span style="color:${thisPoint.obstruction ? 'red' : 'green'}">próbka ${sampleIndex}</span>: 
7411					${(thisPoint.distanceFromStart/1000).toFixed(2)} km, wysokość ${thisPoint.elevation} m ${obstructionText}`;
7412
7413			});
7414			
7415			barsImage.addEventListener('click', (e) => {
7416				const rect = barsImage.getBoundingClientRect();
7417				const x = e.clientX - rect.left;
7418				const percent = Math.min(100, Math.max(0, (x / rect.width) * 100)).toFixed(1);
7419				const sampleIndex = Math.round(percent*intermediatePoints.length/100), thisPoint = intermediatePoints[sampleIndex];
7420				
7421				const info = k.info;
7422				giga.mapycom.showMap(+thisPoint.lat,+thisPoint.lon,'', {
7423					zoom: 15,
7424				}).flyToWithLine(+thisPoint.lat, +thisPoint.lon, +info.lat, +info.lon);
7425			});
7426			*/
7427
7428			barsImage.addEventListener('mouseleave', () => {
7429				display.textContent = '';
7430			});
7431
7432			if (ret) {
7433				giga.lineOfSight.infowindow.classList.remove('visible');
7434				giga.lineOfSight.infowindow.classList.add('blocked');
7435				
7436				function formatBlockerData(blocker) {
7437					return `
7438						<td title="do widzialności brakło ${blocker.obstruction.toFixed(1)} m"><b>${Math.round(blocker.obstruction)} m</b></td>
7439						<td title="wysokość npm. przeszkody">(${Math.round(blocker.elevation)} m)</td>
7440						<td title="odległość do przeszkody">&#x291D;&nbsp;${giga.getDistanceVincenty(pointA.lat,pointA.lon,blocker.lat,blocker.lon).toFixed(2)}&nbsp;km</td>
7441						<td title="odległość od przeszkody do celu">${giga.getDistanceVincenty(blocker.lat,blocker.lon,pointB.lat,pointB.lon).toFixed(2)}&nbsp;km&nbsp;&#x291D;</td>
7442						<td title="współrzędne przeszkody - kliknij, żeby pokazać punkt na mapie"><span class="open-mapy-cz-line" data-lat="${blocker.lat.toFixed(5)}" data-lon="${blocker.lon.toFixed(5)}">${blocker.lat.toFixed(3)}, ${blocker.lon.toFixed(3)}</span></td>
7443					`;
7444				}
7445				
7446				let blockerText = '';
7447				blockerText += '<table class="blocker-data">';
7448				blockerText += `<tr><td>#1</td> ${formatBlockerData(ret.blocker)}</tr>`;
7449				if (ret.maxBlocker.index != ret.blocker.index) blockerText += `<tr><td>Max</td> ${formatBlockerData(ret.maxBlocker)}</tr>`;
7450				blockerText += '</table>';
7451				
7452				infoText.append(makeElement('div',blockerText));
7453				return ret;
7454			}
7455			// const minReserve = Math.min(...intermediatePoints.map( p => -p.obstructionHeight || 10000 ));
7456			infoText.append(makeElement('div', 'Widoczny!'));
7457			giga.lineOfSight.infowindow.classList.remove('blocked');
7458			giga.lineOfSight.infowindow.classList.add('visible');
7459			return {
7460				visible: true,
7461				reason: 'Clear line of sight',
7462				blocker: null
7463			};
7464		},
7465
7466		/**
7467		 * Calculate the required height at a point for line of sight
7468		 */
7469		calculateRequiredHeight: function (elevA, elevB, distFromA, totalDist, earthRadius, refractionCoef) {
7470			// Linear interpolation of height
7471			const linearHeight = elevA + (elevB - elevA) * (distFromA / totalDist);
7472			
7473			// Earth curvature drop (adjusted for refraction)
7474			// Refraction effectively increases Earth's radius
7475			const effectiveRadius = earthRadius / (1 - refractionCoef);
7476			const curvatureDrop = (distFromA * (totalDist - distFromA)) / (2 * effectiveRadius);
7477			
7478			return linearHeight - curvatureDrop;
7479		},
7480
7481		/**
7482		 * Calculate distance between two points using Vincenty's formula (inverse problem)
7483		 * More accurate than Haversine, accounts for Earth's ellipsoidal shape
7484		 */
7485		vincentyDistance: function (point1, point2) {
7486			const a = 6378137.0; // WGS-84 semi-major axis (meters)
7487			const b = 6356752.314245; // WGS-84 semi-minor axis (meters)
7488			const f = 1 / 298.257223563; // WGS-84 flattening
7489			const Pi180 = Math.PI / 180;
7490			
7491			const lat1 = point1.lat * Pi180;
7492			const lat2 = point2.lat * Pi180;
7493			const lon1 = point1.lon * Pi180;
7494			const lon2 = point2.lon * Pi180;
7495			
7496			const L = lon2 - lon1;
7497			const U1 = Math.atan((1 - f) * Math.tan(lat1));
7498			const U2 = Math.atan((1 - f) * Math.tan(lat2));
7499			const sinU1 = Math.sin(U1);
7500			const cosU1 = Math.cos(U1);
7501			const sinU2 = Math.sin(U2);
7502			const cosU2 = Math.cos(U2);
7503			
7504			let lambda = L;
7505			let lambdaP;
7506			let iterLimit = 100;
7507			let cosSqAlpha, sinSigma, cos2SigmaM, cosSigma, sigma;
7508			
7509			do {
7510				const sinLambda = Math.sin(lambda);
7511				const cosLambda = Math.cos(lambda);
7512				sinSigma = Math.sqrt((cosU2 * sinLambda) * (cosU2 * sinLambda) +
7513					(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) * 
7514					(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda));
7515				
7516				if (sinSigma === 0) return 0; // Co-incident points
7517				
7518				cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda;
7519				sigma = Math.atan2(sinSigma, cosSigma);
7520				const sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma;
7521				cosSqAlpha = 1 - sinAlpha * sinAlpha;
7522				cos2SigmaM = cosSigma - 2 * sinU1 * sinU2 / cosSqAlpha;
7523				
7524				if (isNaN(cos2SigmaM)) cos2SigmaM = 0; // Equatorial line
7525				
7526				const C = f / 16 * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha));
7527				lambdaP = lambda;
7528				lambda = L + (1 - C) * f * sinAlpha *
7529					(sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * 
7530					(-1 + 2 * cos2SigmaM * cos2SigmaM)));
7531			} while (Math.abs(lambda - lambdaP) > 1e-12 && --iterLimit > 0);
7532			
7533			if (iterLimit === 0) {
7534				// Formula failed to converge, fall back to approximate distance
7535				return null;
7536			}
7537			
7538			const uSq = cosSqAlpha * (a * a - b * b) / (b * b);
7539			const A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));
7540			const B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
7541			const deltaSigma = B * sinSigma * (cos2SigmaM + B / 4 * (cosSigma * 
7542				(-1 + 2 * cos2SigmaM * cos2SigmaM) - B / 6 * cos2SigmaM * 
7543				(-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM * cos2SigmaM)));
7544			
7545			const distance = b * A * (sigma - deltaSigma);
7546			
7547			return distance;
7548		},
7549
7550		/**
7551		 * Generate intermediate points between A and B along the geodesic
7552		 * Uses Vincenty's direct formula for accurate ellipsoidal calculations
7553		 */
7554		generateIntermediatePoints: function (pointA, pointB, samples) {
7555			const a = 6378137.0; // WGS-84 semi-major axis
7556			const b = 6356752.314245; // WGS-84 semi-minor axis
7557			const f = 1 / 298.257223563; // WGS-84 flattening
7558			const toRad = Math.PI / 180;
7559			
7560			const lat1 = pointA.lat * toRad;
7561			const lon1 = pointA.lon * toRad;
7562			const lat2 = pointB.lat * toRad;
7563			const lon2 = pointB.lon * toRad;
7564			
7565			// Calculate total distance and initial/final azimuths using Vincenty inverse
7566			const vincentyInverse = giga.lineOfSight.calculateVincentyInverse(pointA, pointB);
7567			if (!vincentyInverse) {
7568				// Fallback to simple linear interpolation if Vincenty fails
7569				return giga.lineOfSight.generateLinearPoints(pointA, pointB, samples);
7570			}
7571			
7572			const totalDistance = vincentyInverse.distance;
7573			const initialAzimuth = vincentyInverse.initialAzimuth;
7574			
7575			const points = [];
7576			
7577			for (let i = 0; i <= samples; i++) {
7578				const fraction = i / samples;
7579				const distance = totalDistance * fraction;
7580				
7581				if (i === 0) {
7582					points.push({ lat: pointA.lat, lon: pointA.lon, elevation: pointA.elevation });
7583				} else if (i === samples) {
7584					points.push({ lat: pointB.lat, lon: pointB.lon, elevation: pointB.elevation });
7585				} else {
7586					// Use Vincenty direct formula to find point at distance along geodesic
7587					const point = giga.lineOfSight.vincentyDirect(pointA.lat, pointA.lon, initialAzimuth, distance);
7588					points.push(point);
7589				}
7590			}
7591			
7592			return points;
7593		},
7594
7595		/**
7596		 * Calculate Vincenty inverse problem (distance and azimuths between two points)
7597		 */
7598		calculateVincentyInverse: function (point1, point2) {
7599			const a = 6378137.0;
7600			const b = 6356752.314245;
7601			const f = 1 / 298.257223563;
7602			const toRad = Math.PI / 180;
7603			
7604			const lat1 = point1.lat * toRad;
7605			const lat2 = point2.lat * toRad;
7606			const lon1 = point1.lon * toRad;
7607			const lon2 = point2.lon * toRad;
7608			
7609			const L = lon2 - lon1;
7610			const U1 = Math.atan((1 - f) * Math.tan(lat1));
7611			const U2 = Math.atan((1 - f) * Math.tan(lat2));
7612			const sinU1 = Math.sin(U1);
7613			const cosU1 = Math.cos(U1);
7614			const sinU2 = Math.sin(U2);
7615			const cosU2 = Math.cos(U2);
7616			
7617			let lambda = L;
7618			let lambdaP;
7619			let iterLimit = 100;
7620			let cosSqAlpha, sinSigma, cos2SigmaM, cosSigma, sigma;
7621			
7622			do {
7623				const sinLambda = Math.sin(lambda);
7624				const cosLambda = Math.cos(lambda);
7625				sinSigma = Math.sqrt((cosU2 * sinLambda) * (cosU2 * sinLambda) +
7626					(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) * 
7627					(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda));
7628				
7629				if (sinSigma === 0) return { distance: 0, initialAzimuth: 0, finalAzimuth: 0 };
7630				
7631				cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda;
7632				sigma = Math.atan2(sinSigma, cosSigma);
7633				const sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma;
7634				cosSqAlpha = 1 - sinAlpha * sinAlpha;
7635				cos2SigmaM = cosSigma - 2 * sinU1 * sinU2 / cosSqAlpha;
7636				
7637				if (isNaN(cos2SigmaM)) cos2SigmaM = 0;
7638				
7639				const C = f / 16 * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha));
7640				lambdaP = lambda;
7641				lambda = L + (1 - C) * f * sinAlpha *
7642					(sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * 
7643					(-1 + 2 * cos2SigmaM * cos2SigmaM)));
7644			} while (Math.abs(lambda - lambdaP) > 1e-12 && --iterLimit > 0);
7645			
7646			if (iterLimit === 0) return null;
7647			
7648			const uSq = cosSqAlpha * (a * a - b * b) / (b * b);
7649			const A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));
7650			const B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
7651			const deltaSigma = B * sinSigma * (cos2SigmaM + B / 4 * (cosSigma * 
7652				(-1 + 2 * cos2SigmaM * cos2SigmaM) - B / 6 * cos2SigmaM * 
7653				(-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM * cos2SigmaM)));
7654			
7655			const distance = b * A * (sigma - deltaSigma);
7656			
7657			const initialAzimuth = Math.atan2(cosU2 * Math.sin(lambda), 
7658				cosU1 * sinU2 - sinU1 * cosU2 * Math.cos(lambda));
7659			
7660			return {
7661				distance: distance,
7662				initialAzimuth: initialAzimuth
7663			};
7664		},
7665
7666		/**
7667		 * Vincenty direct formula: given a point, azimuth, and distance, find destination point
7668		 */
7669		vincentyDirect: function (lat, lon, azimuth, distance) {
7670			const a = 6378137.0;
7671			const b = 6356752.314245;
7672			const f = 1 / 298.257223563;
7673			const toRad = Math.PI / 180;
7674			const toDeg = 180 / Math.PI;
7675		
7676			const lat1 = lat * toRad;
7677			const lon1 = lon * toRad;
7678			const alpha1 = azimuth;
7679			const s = distance;
7680			
7681			const sinAlpha1 = Math.sin(alpha1);
7682			const cosAlpha1 = Math.cos(alpha1);
7683			
7684			const tanU1 = (1 - f) * Math.tan(lat1);
7685			const cosU1 = 1 / Math.sqrt(1 + tanU1 * tanU1);
7686			const sinU1 = tanU1 * cosU1;
7687			const sigma1 = Math.atan2(tanU1, cosAlpha1);
7688			const sinAlpha = cosU1 * sinAlpha1;
7689			const cosSqAlpha = 1 - sinAlpha * sinAlpha;
7690			const uSq = cosSqAlpha * (a * a - b * b) / (b * b);
7691			const A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));
7692			const B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
7693			
7694			let sigma = s / (b * A);
7695			let sigmaP;
7696			let cos2SigmaM, sinSigma, cosSigma, deltaSigma;
7697			let iterLimit = 100;
7698			
7699			do {
7700				cos2SigmaM = Math.cos(2 * sigma1 + sigma);
7701				sinSigma = Math.sin(sigma);
7702				cosSigma = Math.cos(sigma);
7703				deltaSigma = B * sinSigma * (cos2SigmaM + B / 4 * (cosSigma * 
7704					(-1 + 2 * cos2SigmaM * cos2SigmaM) - B / 6 * cos2SigmaM * 
7705					(-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM * cos2SigmaM)));
7706				sigmaP = sigma;
7707				sigma = s / (b * A) + deltaSigma;
7708			} while (Math.abs(sigma - sigmaP) > 1e-12 && --iterLimit > 0);
7709			
7710			const tmp = sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1;
7711			const lat2 = Math.atan2(sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1,
7712				(1 - f) * Math.sqrt(sinAlpha * sinAlpha + tmp * tmp));
7713			const lambda = Math.atan2(sinSigma * sinAlpha1, 
7714				cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1);
7715			const C = f / 16 * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha));
7716			const L = lambda - (1 - C) * f * sinAlpha *
7717				(sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * 
7718				(-1 + 2 * cos2SigmaM * cos2SigmaM)));
7719			const lon2 = lon1 + L;
7720			
7721			return {
7722				lat: lat2 * toDeg,
7723				lon: lon2 * toDeg
7724			};
7725		},
7726
7727		/**
7728		 * Fallback: simple linear interpolation
7729		 */
7730		generateLinearPoints: function (pointA, pointB, samples) {
7731			const points = [];
7732			for (let i = 0; i <= samples; i++) {
7733				const fraction = i / samples;
7734				points.push({
7735					lat: pointA.lat + (pointB.lat - pointA.lat) * fraction,
7736					lon: pointA.lon + (pointB.lon - pointA.lon) * fraction
7737				});
7738			}
7739			return points;
7740		},
7741
7742		fetchElevations: async function (points, options={}) {
7743			options.fetchMethod ??= giga.lineOfSight.fetchMethods.OPENELEVATION;
7744			const elevs = await options.fetchMethod.method(points);
7745			return elevs;
7746		},
7747		
7748		fetchElevationsWithBatching: async function (points, options={}) {
7749			options.delay ??= 300;
7750			options.fetchMethod ??= giga.lineOfSight.fetchMethods.OPENELEVATION;
7751
7752			const results = [];
7753			const batches = chunkArray(points, options.fetchMethod.batchSize);
7754			
7755			console.log('fetchElevationsWithBatching');
7756			console.log(options);
7757			console.log(`Processing ${points.length} points in ${batches.length} batches...`);
7758			
7759			for (let i = 0; i < batches.length; i++) {
7760				const batch = batches[i];
7761				
7762				try {
7763					//console.log(`Fetching batch ${i + 1}/${batches.length} (${batch.length} points)...`);
7764					showScreenMessage(`Ściąganie danych z ${options.fetchMethod.description} ${i + 1}/${batches.length}`, { category:'elevation-download', duration:10000 } );
7765					const elevations = await options.fetchMethod.method(batch);
7766					results.push(...elevations);
7767					
7768					// Add delay between batches to avoid overwhelming the server
7769					if (i < batches.length - 1) {
7770						await sleep(options.delay);
7771					}
7772				} catch (error) {
7773					showScreenMessage(`Problem z danymi ${i + 1}/${batches.length}`, { severity: 'error', category:'elevation-download', duration:2000 } );
7774					console.error(`Error fetching batch ${i + 1}:`, error);
7775					// Add failed batch with null elevations
7776					batch.forEach(coord => {
7777						results.push({ ...coord, elevation: null, error: error.message });
7778					});
7779				}
7780			}
7781			showScreenMessage(`Koniec ściągania danych`, {category:'elevation-download', duration:1000} );
7782			
7783			return results;
7784		},
7785
7786		fetchElevationsGugik: async function (points) {
7787			proj4.defs("EPSG:2180","+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
7788
7789			const locations = points.map(p => {
7790				let pl1992 = proj4("EPSG:2180").forward([+p.lon,+p.lat]);
7791				return { latitude: p.lat, longitude: p.lon, x: Math.round(pl1992[1]), y: Math.round(pl1992[0])  };
7792			});
7793			const plLocs = locations.map(p => `${p.x} ${p.y}`).join(',');
7794			//console.log(plLocs);
7795			const url = `https://services.gugik.gov.pl/nmt/?request=GetHByPointList&list=${plLocs}`; //563800 243490,563950 243490,563950 243400
7796			try {
7797				const response = await fetch(url);
7798				if (!response.ok) {
7799					throw new Error(`API error: ${response.status}`);
7800				}
7801				
7802				function parseReturn(input) {
7803					const regex = /-?\d+(?:\.\d+)?/g;
7804					const matches = input.match(regex);
7805
7806					if (!matches || matches.length < 3) {
7807						throw new Error("Input must contain at least three numbers separated by spaces.");
7808						return null;
7809					} else {
7810						return matches.map(Number).at(-1);
7811					}
7812				}					
7813					
7814				const data = await response.text();
7815				//console.log(data);
7816				return data.split(',').map(str => parseReturn(str));
7817			} catch (error) {
7818				console.error('Error fetching elevations:', error);
7819				throw error;
7820				return null;
7821			}
7822		},
7823		
7824		fetchElevationsOpenElevation: async function (points, timeoutSeconds = 10) {
7825			const locations = points.map(p => ({ latitude: p.lat, longitude: p.lon }));
7826
7827			const controller = new AbortController();
7828			const timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
7829
7830			try {
7831				const response = await fetch('https://api.open-elevation.com/api/v1/lookup', {
7832					method: 'POST',
7833					headers: {
7834						'Content-Type': 'application/json',
7835					},
7836					body: JSON.stringify({ locations }),
7837					signal: controller.signal
7838				});
7839
7840				if (!response.ok) {
7841					showScreenMessage(`Błąd Open Elevation API: ${response.status}`, {severity: 'error'});
7842					throw new Error(`API error: ${response.status}`);
7843				}
7844
7845				const data = await response.json();
7846				return data.results.map(r => r.elevation);
7847			} catch (error) {
7848				if (error.name === 'AbortError') {
7849					const msg = `Open Elevation API timeout po ${timeoutSeconds}s`;
7850					showScreenMessage(msg, {severity: 'error'});
7851					throw new Error(msg);
7852				}
7853				console.error('Error fetching elevations:', error);
7854				throw error;
7855			} finally {
7856				clearTimeout(timeoutId);
7857			}
7858		}, 	
7859		
7860		fetchElevationsHeiGIT: async function (points) {
7861			const ors = new OrsElevation('eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6IjM3ZjI0NzJhY2YzMzQxMDliNjQyMWZhYjM0ODBiMDY4IiwiaCI6Im11cm11cjY0In0=');
7862			const line = await ors.line(points.map( p => [+p.lat.toFixed(6),+p.lon.toFixed(6)] ));
7863			const profile = line.map(p => p.elevation);
7864			return profile;
7865		},
7866 		
7867		fetchMethods: {
7868			'OPENELEVATION': { method: args => giga.lineOfSight.fetchElevationsOpenElevation(args), description: 'api.open-elevation.com', short: 'OpenE', batchSize: 310 },
7869			'GUGIK'        : { method: args => giga.lineOfSight.fetchElevationsGugik(args), description: 'GUGiK', short: 'GUGiK', batchSize: 500 },
7870			'HEIGIT'       : { method: args => giga.lineOfSight.fetchElevationsHeiGIT(args), description: 'HeiGIT', short: 'HeiGIT', batchSize: 1000 },
7871		},
7872
7873		/**
7874		 * Fetch elevations from OpenTopoData API
7875		 * Supports multiple datasets: aster30m, etopo1, eudem25m, mapzen, ned10m, nzdem8m, srtm30m, srtm90m
7876		 * @param {Array} points - Array of {lat, lon} objects
7877		 * @param {string} dataset - Dataset to use (default: 'aster30m' for global 30m resolution)
7878		 */
7879		fetchElevationsOpenTopoData: async function (points, dataset = 'eudem25m') {
7880			// OpenTopoData has a limit of 100 locations per request
7881			const BATCH_SIZE = 100;
7882			const allElevations = [];
7883			
7884			for (let i = 0; i < points.length; i += BATCH_SIZE) {
7885				const batch = points.slice(i, i + BATCH_SIZE);
7886				const locations = batch.map(p => `${p.lat},${p.lon}`).join('|');
7887				
7888				try {
7889					const response = await fetch(
7890						`https://api.opentopodata.org/v1/${dataset}?locations=${locations}`
7891					);
7892					
7893					if (!response.ok) {
7894						throw new Error(`API error: ${response.status} ${response.statusText}`);
7895					}
7896					
7897					const data = await response.json();
7898					
7899					if (data.status === 'INVALID_REQUEST') {
7900						throw new Error(`Invalid request: ${data.error}`);
7901					}
7902					
7903					const elevations = data.results.map(r => {
7904						if (r.elevation === null) {
7905							throw new Error(`No elevation data at ${r.location.lat}, ${r.location.lng}`);
7906						}
7907						return r.elevation;
7908					});
7909					
7910					allElevations.push(...elevations);
7911					
7912					// Rate limiting: OpenTopoData requests max 1 call per second for public API
7913					if (i + BATCH_SIZE < points.length) {
7914						await new Promise(resolve => setTimeout(resolve, 1000));
7915					}
7916				} catch (error) {
7917					console.error('Error fetching elevations:', error);
7918					throw error;
7919				}
7920			}
7921			
7922			return allElevations;
7923		}
7924		
7925	},
7926
7927	drawBars: function(values, options = {}) {
7928		const {
7929			barWidth = 2,
7930			spacing = .5,
7931			maxHeight = 150,
7932			color = '#3498db',
7933			color2 = '#dd0000',
7934			drawTopLine = false,
7935			topLineColor = '#44aa44',
7936			drawCurveLine = false,
7937			curveLineColor = '#44aa44'
7938		} = options;
7939		
7940		// Create a canvas element
7941		const canvas = document.createElement('canvas');
7942		const ctx = canvas.getContext('2d');
7943
7944		// Set canvas dimensions
7945
7946		const canvasWidth = values.length * (barWidth + spacing);
7947		const canvasHeight = maxHeight + 20;
7948
7949		canvas.width = canvasWidth;
7950		canvas.height = canvasHeight;
7951
7952		// Normalize elevation values to fit within maxHeight
7953		const maxElev = Math.max(...values.map(v=>v.elevation || 0));
7954		const scale = maxHeight / maxElev;
7955
7956		// Draw bars
7957		const barTops = [];
7958		//console.log(values);
7959		values.forEach((v, i) => {
7960			ctx.fillStyle = v.obstruction===true ? color2 : color;
7961			const barHeight = v.elevation * scale;
7962			const x = i * (barWidth + spacing);
7963			const y = canvasHeight - barHeight;
7964			ctx.fillRect(x, y, barWidth, barHeight);
7965			barTops.push({x: x + barWidth/2, y: y });
7966		});
7967		//console.log(barTops);
7968		if (drawTopLine) {
7969			console.log('drawing topline');
7970			ctx.strokeStyle = topLineColor;
7971			ctx.lineWidth = 1;
7972			ctx.beginPath();
7973			ctx.moveTo(barTops[0].x, barTops[0].y);
7974			ctx.lineTo(barTops[barTops.length - 1].x, barTops[barTops.length - 1].y);
7975			ctx.stroke();
7976		}
7977
7978		if (drawCurveLine) {
7979			console.log('drawing curve line');
7980			const tension = 0.5;
7981			const points = values.map((v,i) => {
7982				const yHeight = v.requiredHeight * scale;
7983				const x = i * (barWidth + spacing);
7984				const y = canvasHeight - yHeight;
7985				return {x: x + barWidth/2, y: y };			
7986			});
7987
7988			ctx.strokeStyle = curveLineColor;
7989			ctx.lineWidth = 1;
7990			ctx.beginPath();
7991			ctx.moveTo(points[0].x, points[0].y);
7992
7993			for (let i = 0; i < points.length - 1; i++) {
7994				const p0 = points[i - 1] || points[i];
7995				const p1 = points[i];
7996				const p2 = points[i + 1];
7997				const p3 = points[i + 2] || p2;
7998
7999				const cp1x = p1.x + (p2.x - p0.x) / 6 * tension;
8000				const cp1y = p1.y + (p2.y - p0.y) / 6 * tension;
8001				const cp2x = p2.x - (p3.x - p1.x) / 6 * tension;
8002				const cp2y = p2.y - (p3.y - p1.y) / 6 * tension;
8003
8004				ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
8005			}
8006			ctx.stroke();
8007		}
8008
8009		// Return the canvas as an image
8010		const img = new Image();
8011		img.src = canvas.toDataURL('image/png');
8012		return img;
8013	},
8014	
8015	/*
8016	Kolejną funkcjonalnością w usłudze NMT jest możliwość wyznaczania minimalnej
8017	i maksymalnej wysokości w obszarze, a także lokalizację punktów charakteryzujących się
8018	tymi ekstremalnymi wysokościami. Przykładowe zapytanie związane z opisywaną
8019	funkcjonalnością ma postać:
8020	https://services.gugik.gov.pl/nmt/?request=GetMinMaxByPolygon&polygon=POLYGON((563800
8021	243490,563950 243490,563950 243400,563800 243400,563800 243490))&json
8022	W wyniku zapytania otrzymujemy minimalną i maksymalną wysokość w zadanym
8023	obszarze oraz dwie listy punktów - jedną z wysokościami minimalnymi, a drugą
8024	z wysokościami maksymalnymi (rys. 52).
8025	*/
8026	
8027	getGugikMinMaxByPolygon: async (lat, lon, size = 80) => {
8028		proj4.defs("EPSG:2180","+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
8029
8030		let pl1992 = proj4("EPSG:2180").forward([+lon,+lat]);
8031		const point = { latitude: lat, longitude: lon, y: Math.round(pl1992[1]), x: Math.round(pl1992[0])  };
8032		const x1 = Math.round(point.x - size/2);
8033		const x2 = Math.round(point.x + size/2);
8034		const y1 = Math.round(point.y - size/2);
8035		const y2 = Math.round(point.y + size/2);
8036		const polygon = `POLYGON((${x1} ${y2},${x2} ${y2},${x2} ${y1},${x1} ${y1},${x1} ${y2}))`;
8037		console.log(point);
8038		console.log(polygon);
8039		
8040		const url = 'https://services.gugik.gov.pl/nmt/?' + makeParams({
8041			request: 'GetMinMaxByPolygon',
8042			polygon: polygon
8043		}) + '&json';
8044		console.log(url);
8045//		https://services.gugik.gov.pl/nmt/?request=GetMinMaxByPolygon&polygon=POLYGON((563800 243490,563950 243490,563950 243400,563800 243400,563800 243490))&json
8046		try {
8047			const response = await fetch(url);
8048			if (!response.ok) {
8049				throw new Error(`API error: ${response.status}`);
8050			}
8051			const data = await response.json();
8052			console.log(data);
8053			let wkt = new Wkt.Wkt();
8054			wkt.read(data['Hmax geom'][0]);
8055			const wktObj = wkt.toObject()._latlng;
8056			const wgs = proj4("EPSG:2180").inverse([wktObj.lng,wktObj.lat]);
8057			const ret = { data: data, maxPoint: { lat: wgs[1], lon: wgs[0], elevation: +data.Hmax } };
8058			console.log(ret);
8059			console.log(`lat="${ret.maxPoint.lat.toFixed(5)}" lon="${ret.maxPoint.lon.toFixed(5)}" source="gugik-refine" elevation="${Math.round(ret.maxPoint.elevation)}"`);
8060			return ret;
8061		} catch (error) {
8062			console.error('Error fetching min/max:', error);
8063			throw error;
8064			return null;
8065		}
8066		
8067	},
8068	
8069	getGugikObjectsFromBounds: async (lat1, lon1, lat2, lon2) => {
8070		const response = await fetch(`https://mapy.geoportal.gov.pl/wss/service/wfsGN/guest?SERVICE=WFS&REQUEST=GetFeature&bbox=${lat1},${lon1},${lat2},${lon2}&VERSION=2.0.0&TYPENAMES=gn:NamedPlace&TYPENAME=gn:NamedPlace&outputFormat=application/json`); 
8071		const geoJsonData = await response.json(); 
8072		return geoJsonData?.features?.filter( f => f.type=='Feature' && f.geometry.coordinates.length==1 && f.properties.localType?.LocalisedCharacterString=='obUksztaltowaniaTerenu' ).map(f => {
8073			const ret = { 
8074				name: f.properties.name[0].spelling.SpellingOfName.text, 
8075				lat:  f.geometry.coordinates[0][1], 
8076				lon:  f.geometry.coordinates[0][0]
8077			} 
8078			if (f.properties.name.length>1) {
8079				ret.names = f.properties.name.map( n => n.spelling.SpellingOfName.text );
8080			}
8081			return ret;
8082		});
8083	},
8084	
8085	getCountryFromCoordinates: async (lat, lon) => {
8086		const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=3&addressdetails=1`;
8087
8088		try {
8089			const response = await fetch(url, {
8090				headers: {
8091					"User-Agent": giga.userAgentString
8092				}
8093			});
8094			const data = await response.json();
8095
8096			const country = data.address?.country;
8097			const countryCode = data.address?.country_code?.toUpperCase();
8098
8099			console.log(`Country: ${country} (${countryCode})`);
8100			return { country, countryCode };
8101		} catch (error) {
8102			console.error("Error fetching country from Nominatim:", error);
8103			return null;
8104		}
8105	},
8106	
8107	defineCoordTransformations: () => {
8108		proj4.defs("EPSG:5514:CZ", "+proj=krovak +lat_0=49.5 +lon_0=24.8333333333333 +alpha=30.2881397527778 +k=0.9999 +x_0=0 +y_0=0 +ellps=bessel +towgs84=570.8,85.7,462.8,4.998,1.587,5.261,3.56 +units=m +no_defs");
8109		proj4.defs("EPSG:5514:SK", "+proj=krovak +lat_0=49.5 +lon_0=24.8333333333333 +alpha=30.2881397527778 +k=0.9999 +x_0=0 +y_0=0 +ellps=bessel +towgs84=485.021,169.465,483.839,7.786342,4.397554,4.102655,0 +units=m +no_defs");
8110	}
8111
8112	
8113};
8114
8115
8116$(function() {
8117	
8118	giga.compass.create('#compass-container');
8119	
8120	/*
8121	$(window).on('resize', function() {
8122		giga.resizeHandler();
8123	});
8124	
8125	*/
8126	
8127	document.body.addEventListener('click', function(event) {
8128		const target = event.target.closest('.hs');
8129		if (target) {
8130			giga.moveToHotspot(target.dataset.name);
8131		}
8132	});
8133	document.body.addEventListener('click', function(event) {
8134		const el = event.target.closest('.tag');
8135		if (!el) return;
8136
8137		const tag = el.dataset.tag;
8138
8139		Array.from(el.parentElement.children)
8140			.filter(sibling => sibling !== el && sibling.classList.contains('tag'))
8141			.forEach(sibling => sibling.classList.remove('active'));
8142
8143		el.classList.add('active');
8144
8145		const resultsContainer = document.getElementById(el.dataset.resultsContainer);
8146		resultsContainer.innerHTML = '<div class="infobox-section no-top-border see-also">' +
8147		hotspots.findPanoramasByTag(tag).map(e => '<div>' + giga.makePanoramaLink(hotspots.panoramas[e].title, e) + '</div>').join(' ') + '</div>';
8148		resultsContainer.style.display = '';
8149	});
8150	
8151	$('body').on('click', '.zoom-to-range', function() {
8152		const limits = giga.getRangeLimits( h => hotspots.points.get(h.id)?.range==$(this).text()?.trim() );
8153		const fov = limits.right - limits.left;
8154		const dirH = limits.right - fov/2;
8155		const dirV = limits.top - (limits.top-limits.bottom)/2;
8156		const lookto = `lookto(${dirH},${dirV})`;
8157		k.view.hfov = fov * 1.1;  //add horizontal 10% margin
8158		k.call(lookto);
8159	});
8160	
8161	$('body').on('click', 'a.reload', function() {
8162		window.location.assign($(this).attr('href'));
8163		window.location.reload(true);
8164	});
8165	
8166	$('body').on('click', 'a.panorama-hotspot-link', function(event) {
8167		const $this = $(this);
8168		if (!!$this.data('panorama')) {
8169			event.preventDefault();
8170			giga.loadPanorama($this.data('panorama'),$this.data('ath'),$this.data('atv'),$this.data('zoom'));
8171		}
8172	});
8173	
8174	$('body').on('click', 'a.panorama-hotspot-link-mini', function(event) {
8175		const $this = $(this);
8176		//console.log($this);
8177		if (!!$this.data('panorama')) {
8178			event.preventDefault();
8179			giga.loadMiniPanorama($this.data('panorama'),$this.data('ath'),$this.data('atv'),$this.data('zoom')*0.5);
8180		}
8181	});
8182	
8183	$('html').on('keydown', function(e) { 
8184		if (e.altKey && (e.which==49 || e.which==50 || e.which==51 || e.which==52)) {
8185			$('#panel-navi .panel-tab-button')[e.which-49].click();
8186		} else if (e.key=='F2') {
8187			if (document.querySelector('input#label-narrow')?.checkVisibility()) {
8188				document.querySelector('input#label-narrow').click();
8189			} 
8190		} else if (e.key=='F4') {
8191			if (document.querySelector('input#label-acronym')?.checkVisibility()) {
8192				document.querySelector('input#label-acronym').click();
8193			} 
8194		} else {
8195			//console.log(e);
8196		}
8197	});
8198	
8199	$('#addhotspot').on('click', '#send.active', function() {
8200		if ($('#hsname').val()) {
8201			$('#addhotspot #send').removeClass('active').html('...czekaj...');
8202			const info = {
8203				panoid: k.state.currentpanorama,
8204				ath: $('#newH').text(),
8205				atv: $('#newV').text(),
8206				text: $('#hsname').val(),
8207				title: $('#hstitle').val(),
8208				level: giga.getCurrentViewParameters().level,
8209				narrow: $('#label-narrow').prop('checked') ? 1 : 0,
8210				acronym: $('#label-acronym').prop('checked') ? 1 : 0,
8211				description: $('#hsdesc').val()
8212			};
8213			giga.removeNewHotspot();
8214			$.post("add-hotspot.html", info, function(data) {
8215				$('#addhotspot #send').html('OK');
8216				$('#addhotspot').fadeOut('slow');
8217			});
8218		}
8219	});
8220	
8221	$('#addhotspot').on('click', '.suggested', function(ev) {
8222		const id = $(this).data('id');
8223		const point = hotspots.points.get(id);
8224		$('input#hsname').val(id).trigger('input');
8225		$('input#hstitle').val(point.title);
8226		giga.showPointInfo(id);
8227		if (ev.ctrlKey) {
8228			const info = k.info, lat = point.lat, lon = point.lon;
8229			giga.mapycom.showMap(lat, lon, point.name, {
8230				zoom: 15,
8231				lines: [
8232					[ {lat: +info.lat, lon: +info.lon}, {lat: lat, lon: lon} ] 
8233				]
8234			}).flyToWithLine(lat, lon, +info.lat, +info.lon);		
8235		} else if (ev.altKey && k.info.elevation) {
8236			giga.lineOfSight.checkFromCurrent(point);
8237		} else if (ev.altKey) {
8238			showScreenMessage('nieznana wysokość punktu początkowego');
8239		}
8240	});
8241	
8242	$('body').on('click','.popup .close', function() {
8243		$(this).parents('.popup').hide();
8244		$('#krpano').focus();
8245	});
8246	
8247	$('#addhotspot').on('click','.close', function() {
8248		giga.removeNewHotspot();
8249	});	
8250
8251	$('#overlay-map').on('click','.close', function() {
8252		giga.currentPanorama.overlayMap.hide();
8253		//$('#krpano').focus();
8254	});
8255	
8256	document.getElementById('panobox')?.addEventListener('click', ev => {
8257		const el = ev.target.closest('.list-all-objects');
8258		if (!el) return;
8259
8260		const sortby = el.dataset.sortby;
8261		let stats = '';
8262		if (giga.advanced) stats = giga.currentPanorama.formatObjectsStats(giga.currentPanorama.makeObjectsStats()) + '<br>';
8263
8264		document.getElementById('objects-list')?.remove();
8265
8266		const div = document.createElement('div');
8267		div.className = 'infobox-section';
8268		div.id = 'objects-list';
8269		div.innerHTML = `<h2>Lista wszystkich</h2>${stats}${giga.currentPanorama.makeObjectsList(sortby)}`;
8270		document.getElementById('panobox').appendChild(div);
8271	});
8272	
8273	$('#infobox').on('click','.close', function() {
8274		$('#infobox').data('hs','');
8275	});
8276	
8277	document.body.addEventListener('click', ev => {
8278		const el = ev.target.closest('.download-line-kml');
8279		if (!el) return;
8280		const azimuth = el.dataset.azimuth || giga.hposToAzimuth(k.view.hlookat);
8281		giga.downloadViewLineKML(+azimuth, 120000);
8282	});
8283
8284	document.body.addEventListener('click', ev => {
8285		const el = ev.target.closest('.download-point-kml');
8286		if (!el) return;
8287
8288		const { lat, lon, name } = el.dataset;
8289
8290		if (ev.ctrlKey) {
8291			window.open('/giga/coord.html?' + makeParams({ lat, lon, title: name }));
8292		} else if (!ev.altKey) {
8293			giga.downloadPointKML(name, lat, lon);
8294		}
8295	});
8296	
8297	$('body').on('click','.open-mapy-cz', function(ev) {
8298		const $this = $(this), info = k.info, lat = +$this.data('lat'), lon = +$this.data('lon');
8299		giga.mapycom.showMap(lat, lon, $this.data('name'), {
8300			zoom: 15,
8301			lines: [
8302				[ {lat: +info.lat, lon: +info.lon}, {lat: lat, lon: lon} ] 
8303			]
8304		}).flyTo(lat, lon);
8305	});
8306	
8307	$('body').on('click','.open-mapy-cz-line', function(ev) {
8308		const $this = $(this), info = k.info, lat = +$this.data('lat'), lon = +$this.data('lon');
8309		giga.mapycom.showMap(lat, lon, $this.data('name'), {
8310			zoom: 15,
8311			lines: [
8312				[ {lat: +info.lat, lon: +info.lon}, {lat: lat, lon: lon} ] 
8313			]
8314		}).flyToWithLine(lat, lon, +info.lat, +info.lon);
8315	});
8316	
8317	document.body.addEventListener('click', ev => {
8318		const el = ev.target.closest('.clear-points-storage');
8319		if (!el) return;
8320
8321		el.style.transition = 'opacity 0.6s';
8322		el.style.opacity = '0';
8323		el.addEventListener('transitionend', () => el.style.display = 'none', { once: true });
8324		hotspots.clearPointsStorage();
8325		giga.sw.clearCache();
8326	});
8327	
8328	const CLICK_ACTIONS = {
8329		'toggle-overlay-map':              () =>
8329 giga.mapycom.toggle(),
8330		'generate-current-view-ude':       () => giga.generateCurrentViewUde(),
8331		'advanced-mode-toggle':            () => giga.toggleAdvancedMode(),
8332		'generate-pttk-code':              () => giga.clipboard.copy(giga.currentPanorama.generatePTTKCode()),
8333		'enable-vertical-angle-optimizer': () => giga.peakFinder.showOptimizer(),
8334		'copy':         el => giga.clipboard.copy(el.dataset.copy),
8335		'level-symbol': el => giga.clipboard.copy(`id="${el.title}"`),
8336		'nmt-refine':   el => giga.openNMTRefine(el.dataset.lat, el.dataset.lon, el.dataset.title),
8337		'swiss-refine': el => giga.openSwissRefine(el.dataset.lat, el.dataset.lon, el.dataset.title),
8338		'zbgis-refine': el => giga.openZbgisRefine(el.dataset.lat, el.dataset.lon, el.dataset.title),
8339		'cuzk-refine':  el => giga.openCuzkRefine(el.dataset.lat, el.dataset.lon, el.dataset.title),
8340	};
8341
8342	document.body.addEventListener('click', ev => {
8343		// alt-copy handled separately due to modifier key requirement
8344		if (ev.altKey && ev.target.closest('.alt-copy')) {
8345			giga.clipboard.copy(ev.target.closest('.alt-copy').dataset.copy);
8346			return;
8347		}
8348
8349		const selector = Object.keys(CLICK_ACTIONS).join(',.');
8350		const el = ev.target.closest(`.${selector}`);
8351		if (!el) return;
8352
8353		const action = Object.keys(CLICK_ACTIONS).find(cls => el.classList.contains(cls));
8354		if (action) CLICK_ACTIONS[action](el);
8355	});
8356	
8357	const draggables = $(".draggable");
8358	if (draggables.length>0 && 'draggable' in draggables) draggables.draggable({
8359		cursor: 'move',
8360		cancel: '.no-drag, input, textarea, button, select, option',
8361		scroll: false
8362	});
8363
8364	const resizables = $(".resizable");
8365	if (resizables.length>0 && 'resizable' in resizables) resizables.resizable({
8366		handles: 'n, e, s, w, se, sw, nw',
8367		autoHide: true,
8368		cancel: ".no-resize",
8369		minWidth: 120,
8370		minHeight: 80
8371	});
8372	
8373	$('#side-panel .handle').data('hidden',false);
8374	$('#side-panel .handle').on('click',function() {
8375		if ($(this).data('hidden')) {
8376			$(this).data('hidden',false);
8377			$('#side-panel').removeClass('panel-hidden');
8378			$('#side-panel .handle div').html('&raquo;');
8379			$('#pano').removeClass('panel-hidden');
8380		} else {
8381			$(this).data('hidden',true);
8382			$('#side-panel').addClass('panel-hidden');
8383			$('#side-panel .handle div').html('&laquo;');
8384			$('#pano').addClass('panel-hidden');
8385		}
8386	});
8387	
8388	$('#side-panel').on('click', '.other-panoramas h3', function() {
8389		giga.sortOtherPanoramas('panorama',false);
8390	});
8391	
8392	window.setTimeout( function() {
8393		document.getElementById('krpano')?.focus(); 
8394		try {
8395			k.focus(); 
8396		} catch(e) {
8397		}
8398		window.activekrpanowindow='krpano';
8399	}, 90);
8400	
8401});
8402
8403
8404
8405// some helpers
8406
8407function ifset(o,out) {
8408	out = typeof(out)==='undefined' ? '' : out;
8409	return typeof(o)==='undefined' || o===null || o=='' ? out : o; 
8410}
8411
8412function hasValue(...a) {
8413	return a.every( x => typeof(x)!=='undefined' && x!=='' );
8414}
8415
8416function isNumeric(obj) {
8417	// As of jQuery 3.0, isNumeric is limited to
8418	// strings and numbers (primitives or objects)
8419	// that can be coerced to finite numbers (gh-2662)
8420	// parseFloat NaNs numeric-cast false positives ("")
8421	// ...but misinterprets leading-number strings, particularly hex literals ("0x...")
8422	// subtraction forces infinities to NaN
8423	return ( typeof(obj)==="number" || typeof(obj)==="string" ) && !isNaN( obj - parseFloat(obj) );
8424}
8425
8426function odmien(x,t1,t2,t3) {
8427	if (x==1) return t1;
8428	x = x % 100;
8429	const j = x % 10;
8430	const d = Math.floor(x/10);
8431	if (j>=2 && j<=4 && d!=1) return t2;
8432	return t3;
8433}
8434
8435function addSign(n) {
8436	return n>0 ? ('+'+n) : n;
8437}
8438
8439function makeAttr(s) {
8440	return s.replace(/\"/g,'&quot;');
8441}
8442
8443function makeParams(params) {
8444	return (new URLSearchParams(params)).toString();
8445}
8446
8447function transliterate(s) {
8448	return /[а-яА-ЯЁё]/.test(s) ? cyrillicToTranslit({ preset: 'uk' }).transform(s,' ') : s; 
8449}
8450
8451function median(arr1) {
8452	const arr = arr1.slice().sort((a, b) => a - b);
8453	const mid = arr.length >> 1;
8454	return arr.length & 1 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2;
8455}
8456
8457function mean(arr) {
8458	let sum = 0;
8459	for (let i = 0; i < arr.length; i++) sum += arr[i];
8460	return sum / arr.length;
8461}
8462
8463function kebabCase(s) { 
8464	return s.normalize("NFKD").replace(/\p{Diacritic}/gu, "").replace('ł', 'l').replace(/[\s_]+/g, '-').toLowerCase();
8465}	
8466
8467const XML_ESCAPE = { '&': '&amp;', '"': '&quot;', "'": '&apos;', '<': '&lt;', '>': '&gt;' };
8468function escapeXML(s) {
8469	return s.replace(/[&"'<>]/g, c => XML_ESCAPE[c]);
8470}
8471
8472function dmsCoords(lat,lon) {
8473	return formatcoords(+lat,+lon).format('DDMMss X', {latLonSeparator: ' ',  decimalPlaces: 1});
8474}
8475
8476const ISO_COUNTRIES = {
8477	PL: 'Polska',      SK: 'Słowacja',      CZ: 'Czechy',
8478	AT: 'Austria',     DE: 'Niemcy',        LI: 'Liechtenstein',
8479	CH: 'Szwajcaria',  FR: 'Francja',       IT: 'Włochy',
8480	UA: 'Ukraina',     HU: 'Węgry',         RO: 'Rumunia',
8481	HR: 'Chorwacja',   SI: 'Słowenia',      ES: 'Hiszpania',
8482};
8483
8484function ISOCountryCode(code) {
8485	return ISO_COUNTRIES[code] ?? '???';
8486}
8487
8488function getScaling(zoomedOut,zoomedIn,precision,steps) {
8489	steps = steps || 10;
8490	precision = precision || 4;
8491	const factor = Math.pow(zoomedOut/zoomedIn, 1/(steps-1));
8492	var levels = [];
8493	for (let i=0;i<steps;i++) {
8494		levels.push(+(zoomedOut/Math.pow(factor,i)).toFixed(precision));
8495	}
8496	return levels;
8497}
8498
8499function getScalingToPrint(zoomedOut,zoomedIn,precision,steps) {
8500	return getScaling(zoomedOut,zoomedIn,precision,steps).map((x,i) => `<group name="level${i}" fov="${x}" />`).join('\n');
8501}
8502
8503function storageAvailable(type) {
8504    try {
8505        var storage = window[type],
8506            x = '__storage_test__';
8507        storage.setItem(x, x);
8508        storage.removeItem(x);
8509        return true;
8510    } catch(e) {
8511        return e instanceof DOMException && (
8512            // everything except Firefox
8513            (e.code === 22 ||
8514            // Firefox
8515            e.code === 1014 ||
8516            // test name field too, because code might not be present
8517            // everything except Firefox
8518            e.name === 'QuotaExceededError' || // Firefox
8519            e.name === 'NS_ERROR_DOM_QUOTA_REACHED')) &&
8520            // acknowledge QuotaExceededError only if there's something already stored
8521            storage.length !== 0;
8522    }
8523}
8524
8525
8526// https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Math/round
8527
8528(function() {
8529  /**
8530   * Decimal adjustment of a number.
8531   *
8532   * @param {String}  type  The type of adjustment.
8533   * @param {Number}  value The number.
8534   * @param {Integer} exp   The exponent (the 10 logarithm of the adjustment base).
8535   * @returns {Number} The adjusted value.
8536   */
8537  function decimalAdjust(type, value, exp) {
8538    // If the exp is undefined or zero...
8539    if (typeof exp === 'undefined' || +exp === 0) {
8540      return Math[type](value);
8541    }
8542    value = +value;
8543    exp = +exp;
8544    // If the value is not a number or the exp is not an integer...
8545    if (isNaN(value) || !(typeof exp === 'number' && exp % 1 === 0)) {
8546      return NaN;
8547    }
8548    // If the value is negative...
8549    if (value < 0) {
8550      return -decimalAdjust(type, -value, exp);
8551    }
8552    // Shift
8553    value = value.toString().split('e');
8554    value = Math[type](+(value[0] + 'e' + (value[1] ? (+value[1] - exp) : -exp)));
8555    // Shift back
8556    value = value.toString().split('e');
8557    return +(value[0] + 'e' + (value[1] ? (+value[1] + exp) : exp));
8558  }
8559
8560  // Decimal round
8561  if (!Math.round10) {
8562    Math.round10 = function(value, exp) {
8563      return decimalAdjust('round', value, exp);
8564    };
8565  }
8566  // Decimal floor
8567  if (!Math.floor10) {
8568    Math.floor10 = function(value, exp) {
8569      return decimalAdjust('floor', value, exp);
8570    };
8571  }
8572  // Decimal ceil
8573  if (!Math.ceil10) {
8574    Math.ceil10 = function(value, exp) {
8575      return decimalAdjust('ceil', value, exp);
8576    };
8577  }
8578})();
8579
8580
8581// Vanilla JS truncate utility
8582function truncateHTML(html, options = {}) {
8583    const defaults = {
8584        stripTags: false,
8585        words: false,
8586        keepFirstWord: false,
8587        noBreaks: false,
8588        finishBlock: false,
8589        length: Infinity,
8590        ellipsis: '…'
8591    };
8592
8593    const o = Object.assign({}, defaults, options);
8594
8595    // Build DOM
8596    const wrapper = document.createElement('div');
8597    wrapper.innerHTML = html;
8598
8599    if (o.noBreaks) {
8600        wrapper.querySelectorAll('br').forEach(br => br.replaceWith(' '));
8601    }
8602
8603    if (o.stripTags) {
8604        wrapper.textContent = wrapper.textContent; // strip all tags
8605    }
8606
8607    let fullText = wrapper.textContent;
8608    if (fullText.length <= o.length) {
8609        return wrapper.innerHTML;
8610    }
8611
8612    // Word‑safe logic
8613    let cut = o.length;
8614    if (o.words) {
8615        const chop = /(\s*\S+|\s)$/;
8616        const start = /^(\S*)/;
8617
8618        const truncated = fullText.slice(0, o.length).replace(chop, '').length;
8619
8620        if (o.keepFirstWord && truncated === 0) {
8621            cut = start.exec(fullText)[0].length;
8622        } else {
8623            cut = truncated;
8624        }
8625    }
8626
8627    // Recursive DOM trimming
8628    function trimNode(node, remaining) {
8629        const text = node.textContent;
8630        const len = text.length;
8631
8632        if (len <= remaining) {
8633            return remaining - len;
8634        }
8635
8636        // Text node
8637        if (node.nodeType === Node.TEXT_NODE) {
8638            const cutPos = o.finishBlock ? len : (len - remaining - 1);
8639            const remainder = node.splitText(cutPos);
8640            remainder.replaceWith(o.ellipsis);
8641            return 0;
8642        }
8643
8644        // Element node
8645        const children = Array.from(node.childNodes).reverse();
8646        for (const child of children) {
8647            remaining = trimNode(child, remaining);
8648            if (remaining <= 0) break;
8649        }
8650        return remaining;
8651    }
8652
8653    trimNode(wrapper, fullText.length - cut);
8654
8655    return wrapper.innerHTML;
8656}
8657
8658
8659function fixedEncodeURIComponent(str) {
8660	return encodeURIComponent(str).replace(
8661		/[!'()*]/g,
8662		(c) =>
8662 `%${c.charCodeAt(0).toString(16).toUpperCase()}`
8663	);
8664}
8665
8666function interpolatingPolynomial(points) {
8667	var n = points.length - 1, p;
8668
8669	p = function (i, j, x) {
8670		if (i === j) return points[i][1];
8671		return ((points[j][0] - x) * p(i, j - 1, x) + (x - points[i][0]) * p(i + 1, j, x)) / (points[j][0] - points[i][0]);
8672	};
8673
8674	return function (x) {
8675		if (points.length === 0) return 0;
8676		return p(0, n, x);
8677	};
8678};
8679
8680function dataURItoBlob(dataURI) {
8681	// convert base64 to raw binary data held in a string
8682	// doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
8683	const byteString = atob(dataURI.split(',')[1]);
8684
8685	// separate out the mime component
8686	const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
8687
8688	// write the bytes of the string to an ArrayBuffer
8689	const ab = new ArrayBuffer(byteString.length);
8690
8691	// create a view into the buffer
8692	var ia = new Uint8Array(ab);
8693
8694	// set the bytes of the buffer to the correct values
8695	for (let i = 0; i < byteString.length; i++) {
8696		ia[i] = byteString.charCodeAt(i);
8697	}
8698
8699	// write the ArrayBuffer to a blob, and you're done
8700	const blob = new Blob([ab], {type: mimeString});
8701	return blob;
8702}
8703
8704const animateCSS = (element, animation, prefix = 'animate__') => {
8705	// We create a Promise and return it
8706	new Promise((resolve, reject) => {
8707		const animationName = `${prefix}${animation}`;
8708		const node = typeof element == 'string' ? document.querySelector(element) : element;
8709		console.log(node);
8710		node.classList.add(`${prefix}animated`, animationName);
8711		// When the animation ends, we clean the classes and resolve the Promise
8712		function handleAnimationEnd(event) {
8713			event.stopPropagation();
8714			node.classList.remove(`${prefix}animated`, animationName);
8715			resolve('Animation ended');
8716		}
8717		node.addEventListener('animationend', handleAnimationEnd, {once: true});
8718	});
8719};
8720
8721function parseWikiLangString (input) {
8722	if (typeof input !== 'string') return { lang: '', text: '' };
8723
8724	const parts = input.split(':');
8725	if (parts.length < 2 || !parts[0] || !parts[1]) {
8726		return { 
8727			lang: '', text: input 
8728		}; // fallback: treat whole string as text
8729	}
8730
8731	return {
8732		lang: parts[0].trim(),
8733		text: parts.slice(1).join(':').trim() // handles multiple colons
8734	};
8735}
8736
8737const loadScript = (url, async = true, type = "text/javascript") => {
8738    return new Promise((resolve, reject) => {
8739        try {
8740            const scriptEle = document.createElement("script");
8741            scriptEle.type = type;
8742            scriptEle.async = async;
8743            scriptEle.src = url;
8744
8745            scriptEle.addEventListener("load", function(ev) {
8746                resolve({ status: true });
8747            });
8748
8749            scriptEle.addEventListener("error", function(ev) {
8750                reject({
8751					status: false,
8752                    message: `Failed to load the script ${url}`
8753                });
8754            });
8755
8756            document.body.appendChild(scriptEle);
8757        } catch (error) {
8758            reject(error);
8759        }
8760    });
8761};
8762
8763/*
8764loadScript("file1_url")
8765    .then( data  => {
8766        console.log("Script loaded successfully", data);
8767    })
8768    .catch( err => {
8769        console.error(err);
8770    });
8771*/
8772
8773const findDuplicates = function (obj) {
8774	const z = obj instanceof Array ? obj : Array.from(obj);
8775	return z.reduce((acc, v, i, arr) => arr.indexOf(v) !== i && acc.indexOf(v) === -1 ? acc.concat(v) : acc, []);
8776}
8777//findDuplicates(Array.from(hotspots.points.values()).filter(p=>!!p.mapycz).map(p=>p.mapycz));
8778
8779
8780const findDuplicatePoints = (arr, precision) => {
8781    const frequency = new Map();
8782    const duplicates = [];
8783	
8784	const makeKey = (p, precision) => {
8785		if (Number.isInteger(precision)) {
8786			return {lat: p.lat.toFixed(precision), lon: p.lon.toFixed(precision)};
8787		} else {
8788			return {lat: p.lat, lon: p.lon};
8789		}
8790	}
8791
8792    arr.forEach(point => {
8793		const key = JSON.stringify(makeKey(point, precision));
8794		const val = (frequency.get(key) || []);
8795		val.push({id: point.id, lat: point.lat, lon: point.lon});
8796        frequency.set(key, val);
8797    });
8798
8799    frequency.forEach((value, key) => {
8800        if (value.length > 1) duplicates.push(value);
8801    });
8802
8803    return duplicates;
8804}
8805
8806function isObjectEmpty(obj) {
8807    for (const prop in obj) {
8808        if (Object.hasOwn(obj, prop)) return false;
8809    }
8810    return true;
8811}
8812
8813const getHotspotsFrequency = () => {
8814	const doCount = () => {
8815		const mm = new Map(); 
8816		panos.forEach(pan => giga.data[pan].toArray().forEach(hs => mm.set(hs.id, 1 + (mm.get(hs.id) || 0))));
8817		return Array.from(mm).sort((a,b) => b[1]-a[1]);
8818	}
8819	if (isObjectEmpty(giga.data)) {
8820		return giga.loadData(panos).then(doCount);
8821	} else {
8822		return Promise.resolve(doCount());
8823	}
8824}
8825// .....filter(el=>hotspots.points.get(el[0])?.mapycz).forEach(el=>console.log(el));
8826
8827const measurePerformance = (func, iterations=1, description='Measured') => {
8828	const startTime = performance.now();
8829	for (let i=1;i<=iterations;i++) func();
8830	const endTime = performance.now();
8831	console.log(`${description}: ${(endTime-startTime).toFixed(5)} ms`)
8832}
8833
8834if (!('getScreenDetails' in window)) {
8835	window.getScreenDetails = async () => [window.screen];
8836	window.screen.isExtended = false;
8837}
8838function makeElement (tag='div', html='') {
8839	const el = document.createElement(tag);
8840	if (html) el.innerHTML = html;
8841	return el;
8842}
8843function chunkArray(arr, chunkSize) {
8844	let list = [...arr];
8845	return [...Array(Math.ceil(list.length / chunkSize))].map(() => list.splice(0, chunkSize));
8846}
8847function sleep(ms) {
8848	return new Promise(resolve => setTimeout(resolve, ms));
8849}
8850
8851function showScreenMessage(text, options={}) {
8852	const {
8853		severity = '',
8854		category = '',
8855		duration = 900
8856	} = options;
8857	const containerId = 'screen-message-container';
8858	let container = document.getElementById(containerId);
8859
8860	// Create container if it doesn't exist
8861	if (!container) {
8862		const panoContainer = document.getElementById('pano');
8863		if (!panoContainer) return;
8864		container = document.createElement('div');
8865		container.id = containerId;
8866		panoContainer.appendChild(container);
8867	}
8868
8869	function scheduleMessageTimeout(msg, duration) {
8870		return setTimeout(() => {
8871			msg.style.opacity = '0';
8872			msg.addEventListener('transitionend', () => {
8873				msg.remove();
8874				// If no messages left, remove container
8875				if (container.children.length === 0) {
8876					//container.remove();
8877				}
8878			}, { once: true });
8879		}, duration);
8880	}
8881	
8882	while (container.children.length >= 6) {
8883		container.children[0].remove();
8884	}
8885	if (category) {
8886		let lastMessage = container.children[container.children.length-1];
8887		if (lastMessage && category==lastMessage.dataset.category) {
8888			clearTimeout(Number(lastMessage.dataset.messageTimeout));
8889			lastMessage.textContent = text;
8890			lastMessage.dataset.messageTimeout = scheduleMessageTimeout(lastMessage, duration);
8891			return;
8892		}
8893	}
8894	
8895	const msg = document.createElement('div');
8896	msg.textContent = text;
8897	msg.classList.add('screen-message');
8898	if (category) msg.dataset.category = category;
8899	if (severity) msg.dataset.severity = severity;
8900	container.appendChild(msg);
8901
8902	// Trigger fade-in
8903	requestAnimationFrame(() => {
8904		msg.style.opacity = '1';
8905		msg.dataset.messageTimeout = scheduleMessageTimeout(msg, duration);
8906	});
8907
8908}
8909
8910// simulateKey(document, "keydown", "Enter", { code: "Enter" });
8911function simulateKey(target, type, key, options = {}) {
8912	const event = new KeyboardEvent(type, {
8913		key,
8914		code: options.code || key,
8915		keyCode: key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0,
8916		which: key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0,
8917		bubbles: true,
8918		cancelable: true,
8919		...options
8920	});
8921
8922	// Some browsers ignore keyCode/which unless forced
8923	Object.defineProperty(event, "keyCode", { get: () => event.keyCode });
8924	Object.defineProperty(event, "which", { get: () => event.which });
8925
8926	target.dispatchEvent(event);
8927}
8928function simulateKeyDown(key) {
8929	simulateKey(document, 'keydown', key) 	;
8930}
8931
8932class Dropdown {
8933	/*
8934	 * @param {HTMLElement} triggerEl - The element that shows the dropdown
8935	 * @param {Array<{label:string,value:any}>} items - Array of objects with label and value
8936	 * @param {Function} callback - Function called with the selected value
8937	 * @param {Object} options - {mode: "click"|"hover"}
8938	 */
8939	constructor(triggerEl, items, callback, options = {mode: "click"}) {
8940		if (!triggerEl) {
8941			console.error('no trigger element for dropdown');
8942			return;
8943		}
8944		this.triggerEl = triggerEl;
8945		this.items = items;
8946		this.callback = callback;
8947		this.dropdown = null;
8948		this.mode = options.mode;
8949
8950		if (this.mode === "click") {
8951			this.triggerEl.addEventListener("click", () => {
8952				this.dropdown ? this.close() : this.open();
8953			});
8954		} else if (this.mode === "hover") {
8955			this.triggerEl.addEventListener("mouseenter", () => this.open());
8956			this.triggerEl.addEventListener("mouseleave", () => this.closeDelayed());
8957		}
8958	}
8959
8960	open() {
8961		this.close(); // ensure clean state
8962
8963		this.dropdown = document.createElement("div");
8964		this.dropdown.className = "dropdown";
8965
8966		this.items.forEach(itemData => {
8967			const item = document.createElement("div");
8968			item.textContent = itemData.label;
8969			item.addEventListener("click", () => {
8970				this.close();
8971				this.callback(itemData.value);
8972			});
8973			this.dropdown.appendChild(item);
8974		});
8975
8976		document.body.appendChild(this.dropdown);
8977
8978		const rect = this.triggerEl.getBoundingClientRect();
8979		this.dropdown.style.left = rect.left + "px";
8980		this.dropdown.style.top = rect.bottom + "px";
8981
8982		if (this.mode === "hover") {
8983			this.dropdown.addEventListener("mouseenter", () => this.cancelClose());
8984			this.dropdown.addEventListener("mouseleave", () => this.closeDelayed());
8985		}
8986
8987		document.addEventListener("click", this.outsideClickHandler);
8988		document.addEventListener("keydown", this.escHandler);
8989	}
8990
8991	close() {
8992		if (this.dropdown) {
8993			this.dropdown.remove();
8994			this.dropdown = null;
8995
8996			document.removeEventListener("click", this.outsideClickHandler);
8997			document.removeEventListener("keydown", this.escHandler);
8998		}
8999	}
9000
9001	closeDelayed() {
9002		this.closeTimer = setTimeout(() => this.close(), 200);
9003	}
9004
9005	cancelClose() {
9006		if (this.closeTimer) {
9007			clearTimeout(this.closeTimer);
9008			this.closeTimer = null;
9009		}
9010	}
9011
9012	outsideClickHandler = (e) => {
9013		if (this.dropdown && !this.dropdown.contains(e.target) && e.target !== this.triggerEl) {
9014			this.close();
9015		}
9016	}
9017
9018	escHandler = (e) => {
9019		if (e.key === "Escape") {
9020			this.close();
9021		}
9022	}
9023}
9024
9025function getCssRule(selector) {
9026    for (const sheet of document.styleSheets) {
9027        try {
9028            for (const rule of sheet.cssRules) {
9029                if (rule.type === CSSRule.STYLE_RULE &&
9030                    rule.selectorText === selector) {
9031                    return rule;
9032                }
9033            }
9034        } catch (e) {
9035            // ignore cross-origin stylesheets
9036        }
9037    }
9038    return null;
9039}
9040
9041 /**
9042 * Attach wheel-direction listener to an element.
9043 * @param {Element} element
9044 * @param {(direction: 'up' | 'down', e: WheelEvent) => void} callback
9045 * @param {Object} [options]
9046 * @param {boolean} [options.preventDefault=true] - block page scroll
9047 * @param {number} [options.threshold=0] - ignore deltas smaller than this
9048 * @returns {() => void} cleanup function to remove the listener
9049 
9050// usage
9051const removeListener = onWheelDirection(
9052  document.getElementById('myElement'),
9053  (direction, e) => console.log('scrolled', direction)
9054);
9055
9056// later, to detach:
9057// removeListener();
9058 */
9059function onWheelDirection(element, callback, { preventDefault = true, threshold = 0 } = {}) {
9060  const handler = (e) => {
9061    if (preventDefault) e.preventDefault();
9062
9063    const delta = e.deltaY;
9064    if (Math.abs(delta) <= threshold) return;
9065
9066    callback(delta < 0 ? 'up' : 'down', e);
9067  };
9068
9069  element.addEventListener('wheel', handler, { passive: !preventDefault });
9070
9071  return () => element.removeEventListener('wheel', handler);
9072}
9073
9074
9075
9076function onRightClickDrag(element, {
9077  onStart,
9078  onDrag,
9079  onEnd,
9080  throttleMs = 0,
9081} = {}) {
9082  let dragging = false;
9083  let didDrag = false;
9084  let startX, startY, lastX, lastY;
9085  let lastCall = 0;
9086  let pending = null;
9087
9088  window.addEventListener('contextmenu', e => {
9089    if (didDrag) {
9090      e.preventDefault();
9091      didDrag = false;
9092    }
9093  }, { capture: true });
9094
9095  element.addEventListener('mousedown', e => {
9096    if (e.button !== 2) return;
9097    e.preventDefault();
9098    dragging = true;
9099    didDrag = false;
9100    startX = lastX = e.clientX;
9101    startY = lastY = e.clientY;
9102    lastCall = 0;
9103    onStart?.({ x: startX, y: startY });
9104  });
9105
9106  window.addEventListener('mousemove', e => {
9107    if (!dragging) return;
9108    didDrag = true;
9109    const dx = e.clientX - lastX;
9110    const dy = e.clientY - lastY;
9111    lastX = e.clientX;
9112    lastY = e.clientY;
9113
9114    const payload = {
9115      dx, dy,
9116      totalDx: e.clientX - startX,
9117      totalDy: e.clientY - startY,
9118      x: e.clientX,
9119      y: e.clientY,
9120    };
9121
9122    if (!throttleMs) {
9123      onDrag?.(payload);
9124      return;
9125    }
9126
9127    const now = Date.now();
9128    clearTimeout(pending);
9129
9130    if (now - lastCall >= throttleMs) {
9131      lastCall = now;
9132      onDrag?.(payload);
9133    } else {
9134      pending = setTimeout(() => {
9135        lastCall = Date.now();
9136        onDrag?.(payload);
9137      }, throttleMs - (now - lastCall));
9138    }
9139  }, { capture: true });
9140
9141  window.addEventListener('mouseup', e => {
9142    if (!dragging || e.button !== 2) return;
9143    dragging = false;
9144    clearTimeout(pending);
9145    onEnd?.({
9146      totalDx: e.clientX - startX,
9147      totalDy: e.clientY - startY,
9148    });
9149  }, { capture: true });
9150}
9151
9152function fadeIn(el, duration = 200) {
9153    el.style.opacity = 0;
9154    el.style.display = 'block';
9155    const start = performance.now();
9156    (function step(now) {
9157        const p = Math.min((now - start) / duration, 1);
9158        el.style.opacity = p;
9159        if (p < 1) requestAnimationFrame(step);
9160    })(start);
9161}
9162
9163// napisz funkcję zwracającą informacje z wikidata o danym obiekcie
9164// https://www.wikidata.org/w/api.php?action=wbgetentities&ids=Q42&format=json&props=claims
9165// https://www.wikidata.org/w/api.php?action=wbgetentities&ids=Q42&format=json&props=claims|labels|descriptions|sitelinks/urls
9166// https://www.wikidata.org/w/api.php?action=wbgetentities&ids=Q42&format=json&props=claims|labels|descriptions|sitelinks/urls&languages=pl
9167// https://www.wikidata.org/w/api.php?action=wbgetentities&ids=Q42&format=json&props=claims|labels|descriptions|sitelinks/urls&languages=pl&languagefallback=1
9168

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.