maplibre-preview 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,829 @@
1
+ ruby:
2
+ style_name = params[:style]
3
+ external_style_url = params[:style_url]
4
+ style_url = external_style_url || (style_name ? "#{request.base_url}/styles/#{style_name}" : options[:style_url])
5
+
6
+ #loading-indicator
7
+ .loading-progress
8
+ .loading-text Loading map...
9
+ .progress-bar
10
+ .progress-fill
11
+
12
+ - if style_name || external_style_url || options[:style_url]
13
+ .layer-controls
14
+ .mode-switcher
15
+ button.mode-button.active id="mode-filters" onclick="switchMode(this, 'filters')" Filters
16
+ button.mode-button id="mode-layers" onclick="switchMode(this, 'layers')" Layers
17
+ button.control-button onclick="toggleHoverMode()" id="hover-mode-btn" Hover Mode
18
+ button.control-button onclick="toggleProfileMode()" id="profile-mode-btn" style="display: none;" Elevation Profile
19
+
20
+ #filters-panel.control-panel.active
21
+ .control-panel-header
22
+ button.control-button onclick="toggleAllFilters()" Toggle All Filters
23
+ button.control-button onclick="toggleBasemap()" Show/Hide Basemap
24
+ .control-panel-content
25
+ #filter-buttons
26
+
27
+ #layers-panel.control-panel
28
+ .control-panel-header
29
+ button.control-button onclick="toggleAllLayers()" Toggle All Layers
30
+ button.control-button onclick="toggleBasemap()" Show/Hide Basemap
31
+ .control-panel-content
32
+ #layer-buttons
33
+
34
+ #map-container
35
+ #map.map-layer data-style-url="#{style_url}"
36
+
37
+ #performance-panel.performance-overlay
38
+ button.performance-toggle onclick="togglePerformancePanel()" ×
39
+ .performance-content
40
+ .metric-row
41
+ .metric
42
+ span.metric-label FPS:
43
+ span.metric-value#fps-value 0
44
+ .metric
45
+ span.metric-label Frame:
46
+ span.metric-value#frame-time-value 0ms
47
+ .metric
48
+ span.metric-label Memory:
49
+ span.metric-value#memory-usage-value 0MB
50
+ .metric
51
+ span.metric-label Zoom:
52
+ span.metric-value#zoom-level-value 0
53
+ .metric-row
54
+ .metric
55
+ span.metric-label Tiles:
56
+ span.metric-value#tiles-loaded-value 0
57
+ .metric
58
+ span.metric-label Loading:
59
+ span.metric-value#tiles-loading-value 0
60
+ .metric
61
+ span.metric-label Layers:
62
+ span.metric-value#layers-active-value 0
63
+ .metric-row#terrain-row style="display: none;"
64
+ .metric
65
+ span.metric-label Terrain:
66
+ span.metric-value#terrain-status-value -
67
+
68
+ javascript:
69
+ const mapEl = document.getElementById('map');
70
+ const style_url = mapEl?.dataset?.styleUrl || null;
71
+ let showBasemapFilters = true, showBasemapLayers = true, currentMode = 'filters';
72
+ let hoverMode = 'click';
73
+ let layerStates = {};
74
+ let map = null;
75
+ let styleLoaded = false, resourcesLoaded = 0, totalResources = 0;
76
+ let tilesLoaded = 0, tilesTotal = 0;
77
+ let currentStyle = null;
78
+ const layerIdToDomId = {};
79
+ let filters = null;
80
+ let profileMode = false;
81
+ let profilePoints = [];
82
+ let profileLine = null;
83
+ let currentProfile = null;
84
+ let contourManager = null;
85
+
86
+ const toDomId = (prefix, id) => `${prefix}-${String(id).replace(/[^a-zA-Z0-9_-]/g, '_')}`;
87
+
88
+ const showLoading = () => document.getElementById('loading-indicator').style.display = 'block';
89
+ const hideLoading = () => document.getElementById('loading-indicator').style.display = 'none';
90
+
91
+ const updateLoadingProgress = () => {
92
+ let progress = 0;
93
+ let loadingText = 'Loading map...';
94
+
95
+ if (totalResources > 0) {
96
+ const resourceProgress = Math.round((resourcesLoaded / totalResources) * 50);
97
+ progress = resourceProgress;
98
+ loadingText = `Loading resources... ${resourceProgress * 2}%`;
99
+ }
100
+
101
+ if (map?.getStyle()?.sources) {
102
+ const sources = Object.keys(map.getStyle().sources);
103
+ if (sources.length > 0) {
104
+ const tileProgress = Math.round((tilesLoaded || 0) / sources.length * 50);
105
+ progress = Math.min(100, (totalResources > 0 ? 50 : 0) + tileProgress);
106
+ loadingText = `Loading tiles... ${progress}%`;
107
+ }
108
+ }
109
+
110
+ document.querySelector('.loading-text').textContent = loadingText;
111
+ document.querySelector('.progress-fill').style.width = `${progress}%`;
112
+ };
113
+
114
+ const switchMode = (arg1, arg2) => {
115
+ const isStringArg = typeof arg1 === 'string';
116
+ const mode = isStringArg ? arg1 : arg2;
117
+ const target = isStringArg ? null : (arg1?.currentTarget || arg1);
118
+ currentMode = mode;
119
+
120
+ document.querySelectorAll('.mode-button').forEach(btn => btn.classList.remove('active'));
121
+ (target || document.getElementById(`mode-${mode}`))?.classList.add('active');
122
+
123
+ document.getElementById('filters-panel').classList.toggle('active', mode === 'filters');
124
+ document.getElementById('layers-panel').classList.toggle('active', mode === 'layers');
125
+
126
+ mode === 'filters' ? filters?.setMode(mode) : applyLayerMode();
127
+ applyBasemapVisibility();
128
+ updateBasemapButton();
129
+ };
130
+
131
+ const applyLayerMode = () => {
132
+ Object.keys(layerStates).forEach(layerId => {
133
+ if (map.getLayer(layerId)) {
134
+ map.setLayoutProperty(layerId, 'visibility', layerStates[layerId] ? 'visible' : 'none');
135
+ }
136
+ });
137
+ };
138
+
139
+ const applyBasemapVisibility = () => {
140
+ const isVisible = currentMode === 'filters' ? showBasemapFilters : showBasemapLayers;
141
+ map?.getLayer?.('preview-basemap-layer') && map.setLayoutProperty('preview-basemap-layer', 'visibility', isVisible ? 'visible' : 'none');
142
+ };
143
+
144
+ const checkStyleResources = (style) => {
145
+ totalResources = 0;
146
+ resourcesLoaded = 0;
147
+ const promises = [];
148
+
149
+ if (style.glyphs) {
150
+ totalResources += 1;
151
+ promises.push(Promise.resolve().then(() => {
152
+ resourcesLoaded++;
153
+ updateLoadingProgress();
154
+ }));
155
+ }
156
+
157
+ if (totalResources > 0) {
158
+ showLoading();
159
+ updateLoadingProgress();
160
+ Promise.all(promises).then(() => {
161
+ styleLoaded = true;
162
+ });
163
+ }
164
+ };
165
+
166
+ const createMap = (container, style) => new maplibregl.Map({
167
+ container,
168
+ style,
169
+ center: [35.15, 47.41],
170
+ zoom: 2,
171
+ attributionControl: false,
172
+ validateStyle: false,
173
+ antialias: true
174
+ });
175
+
176
+ const addBasemapToStyle = (originalStyle) => {
177
+ const modifiedStyle = JSON.parse(JSON.stringify(originalStyle));
178
+
179
+ modifiedStyle.sources = modifiedStyle.sources || {};
180
+ modifiedStyle.sources['preview-basemap'] = {
181
+ type: 'raster',
182
+ tiles: [
183
+ 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
184
+ 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png',
185
+ 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png'
186
+ ],
187
+ tileSize: 256,
188
+ maxzoom: 19,
189
+ attribution: '© OpenStreetMap contributors'
190
+ };
191
+
192
+ modifiedStyle.layers = modifiedStyle.layers || [];
193
+ modifiedStyle.layers.unshift({
194
+ id: 'preview-basemap-layer',
195
+ type: 'raster',
196
+ source: 'preview-basemap',
197
+ layout: {visibility: 'visible'},
198
+ paint: {'raster-opacity': 0.8, 'raster-fade-duration': 300}
199
+ });
200
+
201
+ return modifiedStyle;
202
+ };
203
+
204
+ const popupFeature = (features, e, popup) => {
205
+ const tt = (tooltip, feat) => tooltip?.replace(/\{([^}]+)\}/g, (match, prop) => {
206
+ let value = feat;
207
+ for (const p of prop.split('.')) {
208
+ value = value?.[p];
209
+ }
210
+ return typeof value === 'object' ? JSON.stringify(value, null, 2) : (value || '');
211
+ });
212
+
213
+ const tooltips = features.map((feat) =>
214
+ tt(feat.layer.metadata?.tooltip, feat) ||
215
+ tt(`<pre>"id": {id},\n"source": {source},\n"source-layer": {sourceLayer},\n"properties": {properties}</pre>`, feat)
216
+ );
217
+ popup.setLngLat(e.lngLat).setHTML(tooltips.join('<br>')).addTo(map);
218
+ };
219
+
220
+ const initializeMap = () => {
221
+ showLoading();
222
+ const emptyStyle = {version: 8, sources: {}, layers: []};
223
+
224
+ style_url
225
+ ? fetch(style_url)
226
+ .then(response => response.json())
227
+ .then(originalStyle => createMapWithStyle(addBasemapToStyle(originalStyle)))
228
+ .catch(error => {
229
+ console.error('Style loading error:', error);
230
+ createMapWithStyle(addBasemapToStyle(emptyStyle));
231
+ })
232
+ : createMapWithStyle(addBasemapToStyle(emptyStyle));
233
+ };
234
+
235
+ const createMapWithStyle = (style) => {
236
+ map = createMap('map', style);
237
+ setupMapEvents();
238
+ };
239
+
240
+ const setupMapEvents = () => {
241
+ map.addControl(new maplibregl.NavigationControl({
242
+ visualizePitch: true,
243
+ showZoom: true,
244
+ showCompass: true
245
+ }));
246
+
247
+ map.addControl(new maplibregl.ScaleControl({
248
+ maxWidth: 100,
249
+ unit: 'metric'
250
+ }));
251
+ map.on('error', (e) => console.error('[MapLibre ERROR]', e?.error || e));
252
+
253
+ map.on('style.load', () => {
254
+ currentStyle = map.getStyle();
255
+ currentStyle && initializeFiltersAndLayers();
256
+ map.once('idle', onStyleReady);
257
+ setupMapInteractions();
258
+ setupPerformanceMonitoring();
259
+ });
260
+ };
261
+
262
+ const initializeFiltersAndLayers = () => {
263
+ try {
264
+ checkStyleResources(currentStyle);
265
+ filters = new Filters({
266
+ map,
267
+ container: '#filter-buttons',
268
+ element_template: (title) => `<div class="element">${title}</div>`,
269
+ group_template: (title) => `<div class="group">${title}</div>`
270
+ });
271
+ filters.init();
272
+ createLayerButtons();
273
+ } catch (e) {
274
+ console.warn('Filter initialization failed:', e);
275
+ }
276
+ };
277
+
278
+ const initializeContourManager = () => {
279
+ contourManager = new ContourManager({map});
280
+ contourManager.init();
281
+ };
282
+
283
+ const onStyleReady = () => {
284
+ const hasTerrain = currentStyle?.terrain;
285
+ const projectionType = hasTerrain ? 'mercator' : 'globe';
286
+
287
+ try {
288
+ map.setProjection({type: projectionType});
289
+ } catch (e) {
290
+ console.warn('Projection setting failed:', e);
291
+ }
292
+
293
+ try {
294
+ map.addControl(new maplibregl.GlobeControl(), 'top-right');
295
+ } catch (e) {
296
+ console.warn('GlobeControl failed:', e);
297
+ }
298
+
299
+ if (currentStyle?.terrain) {
300
+ const terrainSourceName = currentStyle.terrain.source;
301
+ map.addControl(new maplibregl.TerrainControl({
302
+ source: terrainSourceName
303
+ }), 'top-right');
304
+
305
+ initializeContourManager();
306
+ }
307
+
308
+ updateTerrainIndicator();
309
+ };
310
+
311
+ const setupMapInteractions = () => {
312
+ const popup = new maplibregl.Popup({closeButton: false, closeOnClick: false});
313
+
314
+ map.on('click', function (e) {
315
+ if (profileMode) {
316
+ handleProfileClick(e);
317
+ return;
318
+ }
319
+
320
+ const features = this.queryRenderedFeatures(e.point);
321
+ features.length > 0 && hoverMode === 'click' && popupFeature(features, e, popup);
322
+ features.length > 0 && console.log('Clicked feature:', features[0]);
323
+
324
+ if (hoverMode === 'click' && currentStyle?.terrain) {
325
+ const elevation = map.queryTerrainElevation([e.lngLat.lng, e.lngLat.lat]);
326
+ if (elevation != null) {
327
+ elevationTooltip && elevationTooltip.style.display === 'block'
328
+ ? hideElevationTooltip()
329
+ : showElevationTooltip(e.originalEvent, elevation);
330
+ }
331
+ }
332
+ });
333
+
334
+ let timeout, point;
335
+ map.on('mousemove', (e) => {
336
+ if (hoverMode !== 'hover') {
337
+ map.getCanvas().style.cursor = '';
338
+ popup.remove();
339
+ profileMode && profilePoints.length === 1 && updateTemporaryLine(e.lngLat);
340
+ return;
341
+ }
342
+
343
+ clearTimeout(timeout);
344
+ const features = map.queryRenderedFeatures(e.point);
345
+ const hasFeatures = features.length > 0;
346
+
347
+ map.getCanvas().style.cursor = hasFeatures ? 'crosshair' : '';
348
+ hasFeatures ? timeout = setTimeout(() => popupFeature(features, e, popup), 100) : popup.remove();
349
+ point?.equals(e.point) || popup.remove();
350
+ point = e.point.clone();
351
+
352
+ hideElevationTooltip();
353
+
354
+ if (currentStyle?.terrain) {
355
+ const elevation = map.queryTerrainElevation([e.lngLat.lng, e.lngLat.lat]);
356
+ elevation != null && showElevationTooltip(e.originalEvent, elevation);
357
+ }
358
+
359
+ profileMode && profilePoints.length === 1 && updateTemporaryLine(e.lngLat);
360
+ });
361
+
362
+ map.on('sourcedata', (e) => {
363
+ e.sourceId && e.isSourceLoaded && (tilesLoaded++,
364
+ tilesTotal === 0 && (tilesTotal = Object.keys(map.getStyle().sources).length),
365
+ updateLoadingProgress());
366
+ });
367
+
368
+ map.on('idle', () => {
369
+ const allLoaded = (totalResources === 0 || styleLoaded) && (tilesTotal === 0 || tilesLoaded >= tilesTotal);
370
+ allLoaded && hideLoading();
371
+ });
372
+
373
+ setTimeout(hideLoading, 10000);
374
+ };
375
+
376
+ const setupPerformanceMonitoring = () => {
377
+ [startPerformanceMonitoring, () => requestAnimationFrame(countFrame),
378
+ updateHoverModeButton, updateBasemapButton].forEach(fn => fn());
379
+ };
380
+
381
+ const toggleBasemap = () => {
382
+ currentMode === 'filters' ? showBasemapFilters = !showBasemapFilters : showBasemapLayers = !showBasemapLayers;
383
+ applyBasemapVisibility();
384
+ updateBasemapButton();
385
+ };
386
+
387
+ const updateBasemapButton = () => {
388
+ const isVisible = currentMode === 'filters' ? showBasemapFilters : showBasemapLayers;
389
+ document.querySelectorAll('button[onclick="toggleBasemap()"]').forEach(btn => {
390
+ btn.textContent = isVisible ? 'Hide Basemap' : 'Show Basemap';
391
+ btn.className = `control-button ${isVisible ? 'active' : 'inactive'}`;
392
+ });
393
+ };
394
+
395
+ const updateTerrainIndicator = () => {
396
+ const terrainRow = document.getElementById('terrain-row');
397
+ const terrainElement = document.getElementById('terrain-status-value');
398
+ const profileBtn = document.getElementById('profile-mode-btn');
399
+
400
+ if (!terrainRow || !terrainElement) return;
401
+
402
+ const hasTerrain = currentStyle?.terrain;
403
+ if (hasTerrain) {
404
+ terrainRow.style.display = 'flex';
405
+ terrainElement.textContent = 'is detected';
406
+ terrainElement.className = 'metric-value success';
407
+ profileBtn && (profileBtn.style.display = 'block');
408
+ } else {
409
+ terrainRow.style.display = 'none';
410
+ profileBtn && (profileBtn.style.display = 'none');
411
+ }
412
+ };
413
+
414
+ let elevationTooltip = null;
415
+
416
+ const showElevationTooltip = (e, elevation) => {
417
+ if (!elevationTooltip) {
418
+ elevationTooltip = Object.assign(document.createElement('div'), {className: 'elevation-tooltip'});
419
+ document.body.appendChild(elevationTooltip);
420
+ }
421
+
422
+ elevationTooltip.textContent = `${elevation.toFixed(1)} m`;
423
+ elevationTooltip.style.left = `${e.clientX}px`;
424
+ elevationTooltip.style.top = `${e.clientY}px`;
425
+ elevationTooltip.style.display = 'block';
426
+ };
427
+
428
+ const hideElevationTooltip = () => {
429
+ elevationTooltip && (elevationTooltip.style.display = 'none');
430
+ };
431
+
432
+ const toggleProfileMode = () => {
433
+ if (!currentStyle?.terrain) return;
434
+
435
+ profileMode = !profileMode;
436
+ const btn = document.getElementById('profile-mode-btn');
437
+
438
+ map.getCanvas().style.cursor = profileMode ? 'crosshair' : '';
439
+ btn.textContent = profileMode ? 'Exit Elev. Profile' : 'Elevation Profile';
440
+ btn.className = `control-button ${profileMode ? 'active' : ''}`;
441
+ clearProfile();
442
+ };
443
+
444
+ const handleProfileClick = (e) => {
445
+ if (!currentStyle?.terrain) return;
446
+
447
+ profilePoints.push([e.lngLat.lng, e.lngLat.lat]);
448
+ profilePoints.length === 2 && createProfileFromPoints();
449
+ };
450
+
451
+ const updateTemporaryLine = (currentLngLat) => {
452
+ if (profilePoints.length !== 1) return;
453
+
454
+ const sourceData = {
455
+ type: 'Feature',
456
+ geometry: {
457
+ type: 'LineString',
458
+ coordinates: [profilePoints[0], [currentLngLat.lng, currentLngLat.lat]]
459
+ }
460
+ };
461
+
462
+ const source = map.getSource('temporary-line');
463
+ source ? source.setData(sourceData) : map.addSource('temporary-line', {type: 'geojson', data: sourceData});
464
+
465
+ !map.getLayer('temporary-line') && map.addLayer({
466
+ id: 'temporary-line', type: 'line', source: 'temporary-line',
467
+ layout: {'line-join': 'round', 'line-cap': 'round'},
468
+ paint: {'line-color': '#ff0000', 'line-width': 2, 'line-opacity': 0.7}
469
+ });
470
+ };
471
+
472
+ const createProfileFromPoints = () => {
473
+ if (profilePoints.length !== 2) return;
474
+
475
+ map.getLayer('temporary-line') && (map.removeLayer('temporary-line'), map.removeSource('temporary-line'));
476
+
477
+ const [start, end] = profilePoints.map(([lng, lat]) => ({lng, lat}));
478
+ currentProfile = createElevationProfile(start, end, 300);
479
+
480
+ drawProfileLine(profilePoints);
481
+ showProfileOnMap(currentProfile);
482
+ profilePoints = [];
483
+ };
484
+
485
+ const createElevationProfile = (start, end, numPoints) => {
486
+ if (!currentStyle?.terrain) return [];
487
+
488
+ return Array.from({length: numPoints + 1}, (_, i) => {
489
+ const t = i / numPoints;
490
+ const lng = start.lng + (end.lng - start.lng) * t;
491
+ const lat = start.lat + (end.lat - start.lat) * t;
492
+ const elevation = map.queryTerrainElevation([lng, lat]);
493
+ const distance = calculateDistance([start.lng, start.lat], [lng, lat]);
494
+
495
+ return {distance, elevation: elevation || 0, coordinates: [lng, lat]};
496
+ });
497
+ };
498
+
499
+ const calculateDistance = (p1, p2) => {
500
+ const R = 6371000;
501
+ const [dLat, dLng] = [(p2[1] - p1[1]) * Math.PI / 180, (p2[0] - p1[0]) * Math.PI / 180];
502
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(p1[1] * Math.PI / 180) * Math.cos(p2[1] * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
503
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
504
+ };
505
+
506
+ const drawProfileLine = (points) => {
507
+ profileLine && (map.removeLayer('profile-line'), map.removeSource('profile-line'), profileLine = null);
508
+
509
+ const sourceData = {type: 'Feature', geometry: {type: 'LineString', coordinates: points}};
510
+ const source = map.getSource('profile-line');
511
+ source ? source.setData(sourceData) : map.addSource('profile-line', {type: 'geojson', data: sourceData});
512
+
513
+ !map.getLayer('profile-line') && map.addLayer({
514
+ id: 'profile-line', type: 'line', source: 'profile-line',
515
+ layout: {'line-join': 'round', 'line-cap': 'round'},
516
+ paint: {'line-color': '#ff0000', 'line-width': 3}
517
+ });
518
+ };
519
+
520
+ const showProfileOnMap = (profile) => {
521
+ document.getElementById('profile-overlay')?.remove();
522
+
523
+ const [min, max, distance] = [
524
+ d3.min(profile, p => p.elevation),
525
+ d3.max(profile, p => p.elevation),
526
+ profile[profile.length - 1].distance / 1000
527
+ ];
528
+
529
+ const overlay = Object.assign(document.createElement('div'), {
530
+ id: 'profile-overlay',
531
+ className: 'profile-overlay'
532
+ });
533
+
534
+ const header = Object.assign(document.createElement('div'), {className: 'profile-header'});
535
+ const title = Object.assign(document.createElement('span'), {
536
+ className: 'profile-title',
537
+ textContent: 'Elevation Profile'
538
+ });
539
+ const closeBtn = Object.assign(document.createElement('button'), {
540
+ className: 'profile-close',
541
+ textContent: '×',
542
+ onclick: hideProfile
543
+ });
544
+ [title, closeBtn].forEach(el => header.appendChild(el));
545
+
546
+ const stats = Object.assign(document.createElement('div'), {className: 'profile-stats'});
547
+ const distanceSpan = Object.assign(document.createElement('span'), {textContent: `Distance: ${distance.toFixed(2)} km`});
548
+ const elevationSpan = Object.assign(document.createElement('span'), {textContent: `Min: ${min.toFixed(0)}m | Max: ${max.toFixed(0)}m`});
549
+ [distanceSpan, elevationSpan].forEach(el => stats.appendChild(el));
550
+
551
+ const chart = Object.assign(document.createElement('div'), {className: 'profile-chart'});
552
+ chart.innerHTML = '<svg id="profile-svg" width="100%" height="150"></svg>';
553
+
554
+ [header, stats, chart].forEach(el => overlay.appendChild(el));
555
+ document.getElementById('map-container').appendChild(overlay);
556
+ setTimeout(() => drawSimpleProfileChart(profile), 10);
557
+ };
558
+
559
+ const drawSimpleProfileChart = (profile) => {
560
+ const svg = d3.select('#profile-svg').html('');
561
+ const containerWidth = svg.node().getBoundingClientRect().width;
562
+ const margin = {top: 10, right: 10, bottom: 10, left: 10};
563
+ const width = containerWidth - margin.left - margin.right;
564
+ const height = 150 - margin.top - margin.bottom;
565
+
566
+ const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
567
+
568
+ const xScale = d3.scaleLinear().domain([0, d3.max(profile, d => d.distance)]).range([0, width]);
569
+ const yScale = d3.scaleLinear().domain([d3.min(profile, d => d.elevation), d3.max(profile, d => d.elevation)]).range([height, 0]);
570
+
571
+ const line = d3.line().x(d => xScale(d.distance)).y(d => yScale(d.elevation));
572
+ const area = d3.area().x(d => xScale(d.distance)).y0(height).y1(d => yScale(d.elevation));
573
+
574
+ g.append('path').datum(profile).attr('d', area).attr('class', 'profile-area');
575
+ g.append('path').datum(profile).attr('d', line).attr('class', 'profile-line');
576
+
577
+ const tooltip = g.append('g').style('display', 'none');
578
+ tooltip.append('line').attr('class', 'profile-tooltip-line');
579
+ tooltip.append('circle').attr('class', 'profile-tooltip-circle');
580
+ tooltip.append('text').attr('class', 'profile-tooltip-text');
581
+
582
+ g.append('rect').attr('width', width).attr('height', height).attr('fill', 'transparent')
583
+ .on('mousemove', function (event) {
584
+ const [mx] = d3.pointer(event);
585
+ const distance = xScale.invert(mx);
586
+ const closest = profile.reduce((prev, curr) => Math.abs(curr.distance - distance) < Math.abs(prev.distance - distance) ? curr : prev);
587
+
588
+ const yPos = yScale(closest.elevation);
589
+ tooltip.style('display', null)
590
+ .attr('transform', `translate(${xScale(closest.distance)},${yPos})`);
591
+ tooltip.select('line').attr('y2', height - yPos);
592
+ tooltip.select('text')
593
+ .text(`${closest.elevation.toFixed(0)}m`)
594
+ .attr('dy', yPos < 30 ? '20px' : '-8px');
595
+
596
+ showMapMarker(closest.coordinates);
597
+ })
598
+ .on('mouseleave', () => {
599
+ tooltip.style('display', 'none');
600
+ hideMapMarker();
601
+ });
602
+ };
603
+
604
+ const showMapMarker = (coordinates) => {
605
+ const sourceData = {type: 'Feature', geometry: {type: 'Point', coordinates}};
606
+ const source = map.getSource('profile-marker');
607
+ source ? source.setData(sourceData) : map.addSource('profile-marker', {type: 'geojson', data: sourceData});
608
+
609
+ !map.getLayer('profile-marker') && map.addLayer({
610
+ id: 'profile-marker', type: 'circle', source: 'profile-marker',
611
+ paint: {
612
+ 'circle-radius': 6,
613
+ 'circle-color': '#ff0000',
614
+ 'circle-stroke-width': 2,
615
+ 'circle-stroke-color': '#ffffff'
616
+ }
617
+ });
618
+ };
619
+
620
+ const hideMapMarker = () => map.getLayer('profile-marker') && (map.removeLayer('profile-marker'), map.removeSource('profile-marker'));
621
+
622
+ const clearProfile = () => {
623
+ map.getLayer('profile-line') && (map.removeLayer('profile-line'), map.removeSource('profile-line'));
624
+ map.getLayer('temporary-line') && (map.removeLayer('temporary-line'), map.removeSource('temporary-line'));
625
+ hideMapMarker();
626
+ profileLine = null;
627
+ currentProfile = null;
628
+ profilePoints = [];
629
+ hideProfile();
630
+ };
631
+
632
+ const hideProfile = () => {
633
+ document.getElementById('profile-overlay')?.remove();
634
+ map.getLayer('profile-line') && (map.removeLayer('profile-line'), map.removeSource('profile-line'));
635
+ map.getLayer('temporary-line') && (map.removeLayer('temporary-line'), map.removeSource('temporary-line'));
636
+ hideMapMarker();
637
+ };
638
+
639
+ const toggleHoverMode = () => {
640
+ hoverMode = hoverMode === 'hover' ? 'click' : 'hover';
641
+ hideElevationTooltip();
642
+ updateHoverModeButton();
643
+ };
644
+
645
+ const updateHoverModeButton = () => {
646
+ const btn = document.getElementById('hover-mode-btn');
647
+ if (!btn) return;
648
+
649
+ const isHoverMode = hoverMode === 'hover';
650
+ btn.textContent = isHoverMode ? 'Click Mode' : 'Hover Mode';
651
+ btn.className = `control-button ${isHoverMode ? 'active' : ''}`;
652
+ };
653
+
654
+ const toggleAllLayers = () => {
655
+ if (currentMode !== 'layers') return;
656
+ const allVisible = Object.values(layerStates).every(state => state);
657
+ const newState = !allVisible;
658
+
659
+ Object.keys(layerStates).forEach(layerId => {
660
+ layerStates[layerId] = newState;
661
+ if (map.getLayer(layerId)) {
662
+ map.setLayoutProperty(layerId, 'visibility', newState ? 'visible' : 'none');
663
+ }
664
+ });
665
+
666
+ if (map.getLayer('preview-basemap-layer')) {
667
+ showBasemapLayers = newState;
668
+ }
669
+
670
+ updateLayerButtons();
671
+ updateBasemapButton();
672
+ };
673
+
674
+ const toggleLayer = (layerId) => {
675
+ if (currentMode !== 'layers') return;
676
+ layerStates[layerId] = !layerStates[layerId];
677
+
678
+ if (map.getLayer(layerId)) {
679
+ map.setLayoutProperty(layerId, 'visibility', layerStates[layerId] ? 'visible' : 'none');
680
+ }
681
+
682
+ if (layerId === 'preview-basemap-layer') {
683
+ showBasemapLayers = layerStates[layerId];
684
+ updateBasemapButton();
685
+ }
686
+
687
+ updateLayerButtons();
688
+ };
689
+
690
+ const updateLayerButtons = () => {
691
+ Object.keys(layerStates).forEach(layerId => {
692
+ const btnId = layerIdToDomId[layerId] || toDomId('layer', layerId);
693
+ const button = document.getElementById(btnId);
694
+ if (button) {
695
+ button.className = `control-button ${layerStates[layerId] ? 'active' : 'inactive'}`;
696
+ }
697
+ });
698
+ };
699
+
700
+ const createLayerButtons = () => {
701
+ if (!currentStyle?.layers) return;
702
+
703
+ const layerButtonsContainer = document.getElementById('layer-buttons');
704
+ if (!layerButtonsContainer) return;
705
+
706
+ currentStyle.layers.forEach(layer => {
707
+ const button = document.createElement('button');
708
+ const buttonId = toDomId('layer', layer.id);
709
+ button.id = buttonId;
710
+ layerStates[layer.id] = map.getLayoutProperty(layer.id, 'visibility') !== 'none';
711
+ button.className = `control-button ${layerStates[layer.id] ? 'active' : 'inactive'}`;
712
+ button.textContent = layer.id;
713
+ button.onclick = () => toggleLayer(layer.id);
714
+ layerButtonsContainer.appendChild(button);
715
+ layerIdToDomId[layer.id] = buttonId;
716
+ });
717
+ };
718
+
719
+ let fpsCounter = 0, lastFpsTime = 0, frameCount = 0;
720
+ let performanceMonitor = null;
721
+ let performancePanelVisible = true;
722
+
723
+ const togglePerformancePanel = () => {
724
+ const panel = document.getElementById('performance-panel');
725
+ performancePanelVisible = !performancePanelVisible;
726
+ panel.style.display = performancePanelVisible ? 'block' : 'none';
727
+ };
728
+
729
+ const startPerformanceMonitoring = () => {
730
+ if (performanceMonitor) return;
731
+ performanceMonitor = setInterval(updatePerformanceMetrics, 1000);
732
+ };
733
+
734
+ const stopPerformanceMonitoring = () => {
735
+ if (performanceMonitor) {
736
+ clearInterval(performanceMonitor);
737
+ performanceMonitor = null;
738
+ }
739
+ };
740
+
741
+ const updatePerformanceMetrics = () => {
742
+ if (!map) return;
743
+
744
+ const now = performance.now();
745
+ if (lastFpsTime === 0) {
746
+ lastFpsTime = now;
747
+ frameCount = 0;
748
+ }
749
+
750
+ frameCount++;
751
+ if (now - lastFpsTime >= 1000) {
752
+ const fps = Math.round((frameCount * 1000) / (now - lastFpsTime));
753
+ const fpsElement = document.getElementById('fps-value');
754
+ fpsElement.textContent = fps;
755
+ fpsElement.className = `metric-value ${fps < 30 ? 'error' : fps < 50 ? 'warning' : 'success'}`;
756
+
757
+ const frameTime = Math.round(1000 / fps);
758
+ const frameElement = document.getElementById('frame-time-value');
759
+ frameElement.textContent = `${frameTime}ms`;
760
+ frameElement.className = `metric-value ${frameTime > 33 ? 'error' : frameTime > 20 ? 'warning' : 'success'}`;
761
+
762
+ lastFpsTime = now;
763
+ frameCount = 0;
764
+ }
765
+
766
+ try {
767
+ const sourcesCount = map.getStyle()?.sources ? Object.keys(map.getStyle().sources).length : 0;
768
+ document.getElementById('tiles-loaded-value').textContent = sourcesCount;
769
+
770
+ const loadingTiles = map.isStyleLoaded() ? 0 : 1;
771
+ document.getElementById('tiles-loading-value').textContent = loadingTiles;
772
+ } catch (e) {
773
+ console.warn('Could not get tile metrics:', e);
774
+ }
775
+
776
+ try {
777
+ if (performance.memory) {
778
+ const memoryMB = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024);
779
+ const memoryElement = document.getElementById('memory-usage-value');
780
+ memoryElement.textContent = `${memoryMB}MB`;
781
+ memoryElement.className = `metric-value ${memoryMB > 500 ? 'error' : memoryMB > 200 ? 'warning' : 'success'}`;
782
+ } else {
783
+ document.getElementById('memory-usage-value').textContent = 'N/A';
784
+ }
785
+ } catch (e) {
786
+ document.getElementById('memory-usage-value').textContent = 'N/A';
787
+ }
788
+
789
+ try {
790
+ let activeLayers = 0;
791
+ if (currentStyle?.layers) {
792
+ currentStyle.layers.forEach(layer => {
793
+ if (map.getLayer(layer.id)) {
794
+ const visibility = map.getLayoutProperty(layer.id, 'visibility');
795
+ if (visibility === 'visible') activeLayers++;
796
+ }
797
+ });
798
+ }
799
+ const layersElement = document.getElementById('layers-active-value');
800
+ layersElement.textContent = activeLayers;
801
+ layersElement.className = `metric-value ${activeLayers > 50 ? 'warning' : 'success'}`;
802
+ } catch (e) {
803
+ document.getElementById('layers-active-value').textContent = 'N/A';
804
+ }
805
+
806
+ try {
807
+ const zoom = map.getZoom();
808
+ document.getElementById('zoom-level-value').textContent = zoom.toFixed(1);
809
+ } catch (e) {
810
+ document.getElementById('zoom-level-value').textContent = 'N/A';
811
+ }
812
+ updateTerrainIndicator();
813
+ };
814
+
815
+ const countFrame = () => {
816
+ frameCount++;
817
+ requestAnimationFrame(countFrame);
818
+ };
819
+
820
+ window.switchMode = switchMode;
821
+ window.toggleBasemap = toggleBasemap;
822
+ window.toggleHoverMode = toggleHoverMode;
823
+ window.toggleAllFilters = () => filters?.toggleAllFilters();
824
+ window.toggleAllLayers = toggleAllLayers;
825
+ window.togglePerformancePanel = togglePerformancePanel;
826
+ window.toggleProfileMode = toggleProfileMode;
827
+ window.hideProfile = hideProfile;
828
+
829
+ initializeMap();