maplibre-preview 0.0.2 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +16 -0
- data/.rspec +3 -0
- data/.rubocop.yml +260 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +21 -0
- data/README.md +312 -0
- data/Rakefile +50 -0
- data/docs/README_RU.md +312 -0
- data/lib/maplibre-preview/public/js/contour.js +85 -0
- data/lib/maplibre-preview/public/js/filters.js +323 -0
- data/lib/maplibre-preview/version.rb +1 -1
- data/lib/maplibre-preview/views/map.slim +829 -0
- data/lib/maplibre-preview/views/map_layout.slim +329 -0
- data/lib/maplibre-preview.rb +113 -2
- data/spec/maplibre_preview_spec.rb +52 -0
- data/spec/spec_helper.rb +11 -0
- metadata +88 -35
- data/lib/maplibre-preview/sinatra_ext.rb +0 -27
- data/lib/maplibre-preview/views/maplibre_preview.slim +0 -3
@@ -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();
|