@signalk/charts-plugin 3.7.0 → 3.8.1
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.
- package/.prettierignore +1 -0
- package/CHANGELOG.md +109 -0
- package/README.md +72 -0
- package/docs/screenshots/1_overview.png +0 -0
- package/docs/screenshots/2_region.png +0 -0
- package/docs/screenshots/3_jobs.png +0 -0
- package/package.json +16 -11
- package/plugin/cacheLayout.d.ts +72 -0
- package/plugin/cacheLayout.d.ts.map +1 -0
- package/plugin/cacheLayout.js +243 -0
- package/plugin/cacheLayout.js.map +1 -0
- package/plugin/chartDownloader.d.ts +122 -29
- package/plugin/chartDownloader.d.ts.map +1 -1
- package/plugin/chartDownloader.js +491 -314
- package/plugin/chartDownloader.js.map +1 -1
- package/plugin/chartDownloaderMBTilesHelpers.d.ts +10 -0
- package/plugin/chartDownloaderMBTilesHelpers.d.ts.map +1 -0
- package/plugin/chartDownloaderMBTilesHelpers.js +202 -0
- package/plugin/chartDownloaderMBTilesHelpers.js.map +1 -0
- package/plugin/chartDownloaderTileHelpers.d.ts +30 -0
- package/plugin/chartDownloaderTileHelpers.d.ts.map +1 -0
- package/plugin/chartDownloaderTileHelpers.js +277 -0
- package/plugin/chartDownloaderTileHelpers.js.map +1 -0
- package/plugin/charts.d.ts.map +1 -1
- package/plugin/charts.js +24 -39
- package/plugin/charts.js.map +1 -1
- package/plugin/constants.d.ts.map +1 -1
- package/plugin/index.d.ts.map +1 -1
- package/plugin/index.js +564 -47
- package/plugin/index.js.map +1 -1
- package/plugin/pluginStatus.js.map +1 -1
- package/plugin/projection.js.map +1 -1
- package/plugin/tileServer.d.ts +4 -2
- package/plugin/tileServer.d.ts.map +1 -1
- package/plugin/tileServer.js +37 -8
- package/plugin/tileServer.js.map +1 -1
- package/plugin/tokenProvider.d.ts +63 -0
- package/plugin/tokenProvider.d.ts.map +1 -0
- package/plugin/tokenProvider.js +258 -0
- package/plugin/tokenProvider.js.map +1 -0
- package/plugin/types.d.ts +37 -2
- package/plugin/types.d.ts.map +1 -1
- package/public/images/jobs.png +0 -0
- package/public/images/overview.png +0 -0
- package/public/images/region.png +0 -0
- package/public/index.html +392 -113
- package/public/js/index.js +620 -0
- package/public/leaflet/images/downloads.png +0 -0
- package/public/leaflet/images/layers-2x.png +0 -0
- package/public/leaflet/images/layers.png +0 -0
- package/public/leaflet/images/lifebuoy.png +0 -0
- package/public/leaflet/images/spritesheet-2x.png +0 -0
- package/public/leaflet/images/spritesheet.svg +156 -0
- package/public/leaflet/leaflet.css +677 -0
- package/public/leaflet/leaflet.draw.css +253 -0
- package/public/leaflet/leaflet.draw.js +2957 -0
- package/public/leaflet/leaflet.js +7467 -0
- package/public/style.css +351 -37
- package/docs/screenshots/1_placeholder.png +0 -0
- package/public/index.js +0 -349
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
var map
|
|
2
|
+
var settings
|
|
3
|
+
var layerControl
|
|
4
|
+
var drawnItems
|
|
5
|
+
var activeRegionLayer = null
|
|
6
|
+
var isDeleting = false
|
|
7
|
+
let jobsPollInterval = null
|
|
8
|
+
let jobsControl = null
|
|
9
|
+
|
|
10
|
+
const JobType = Object.freeze({
|
|
11
|
+
None: 0,
|
|
12
|
+
Seed: 1,
|
|
13
|
+
Delete: 2
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// Chart source types this tool can prefetch ("seed"). Only proxied raster tile
|
|
17
|
+
// sources have a {z}/{x}/{y} cache to fill; style / manifest types
|
|
18
|
+
// (mapstyleJSON, tileJSON) and vector ENC (S-57) cannot be seeded, so they are
|
|
19
|
+
// hidden from the chart picker and the preview map. Mirrors SEEDABLE_SOURCE_TYPES
|
|
20
|
+
// in src/tileServer.ts (the server enforces the same rule); keep the two in sync.
|
|
21
|
+
const SEEDABLE_CHART_TYPES = ['tilelayer', 'WMS', 'WMTS']
|
|
22
|
+
|
|
23
|
+
function isSeedableChart(info) {
|
|
24
|
+
return (
|
|
25
|
+
!!info && info.proxy === true && SEEDABLE_CHART_TYPES.includes(info.type)
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
|
30
|
+
await setupCharts()
|
|
31
|
+
|
|
32
|
+
setupRegions()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function normalizeLng(lng) {
|
|
36
|
+
let v = Number(lng)
|
|
37
|
+
if (!isFinite(v)) return v
|
|
38
|
+
while (v < -360) v += 360
|
|
39
|
+
while (v > 360) v -= 360
|
|
40
|
+
return v
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizePolygon(layer) {
|
|
44
|
+
const latlngs = layer.getLatLngs()
|
|
45
|
+
|
|
46
|
+
latlngs[0].forEach((ll) => {
|
|
47
|
+
ll.lng = normalizeLng(ll.lng)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
layer.setLatLngs(latlngs)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveSettings() {
|
|
54
|
+
localStorage.setItem('settings', JSON.stringify(settings))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function setupCharts() {
|
|
58
|
+
settings = JSON.parse(localStorage.getItem('settings') || '{}')
|
|
59
|
+
|
|
60
|
+
settings.map = settings.map || { center: [0, 0], zoomLevel: 3 }
|
|
61
|
+
settings.selections = settings.selections || { charts: [] }
|
|
62
|
+
settings.selections.charts = settings.selections.charts || []
|
|
63
|
+
|
|
64
|
+
map = L.map('map').setView(
|
|
65
|
+
settings ? settings.map.center : [0, 0],
|
|
66
|
+
settings ? settings.map.zoomLevel : 8
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
map.on('moveend', function () {
|
|
70
|
+
const center = map.getCenter()
|
|
71
|
+
const zoom = map.getZoom()
|
|
72
|
+
settings.map = {
|
|
73
|
+
center: [center.lat, center.lng],
|
|
74
|
+
zoomLevel: zoom
|
|
75
|
+
}
|
|
76
|
+
saveSettings()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Layer to store drawn shapes
|
|
80
|
+
drawnItems = new L.FeatureGroup()
|
|
81
|
+
map.addLayer(drawnItems)
|
|
82
|
+
|
|
83
|
+
// Add draw controls
|
|
84
|
+
const drawControl = new L.Control.Draw({
|
|
85
|
+
position: 'topleft',
|
|
86
|
+
edit: {
|
|
87
|
+
featureGroup: drawnItems
|
|
88
|
+
},
|
|
89
|
+
draw: {
|
|
90
|
+
polygon: {
|
|
91
|
+
allowIntersection: false, // Restricts shapes to simple polygons
|
|
92
|
+
drawError: {
|
|
93
|
+
color: '#e1e100', // Color the shape will turn when intersects
|
|
94
|
+
message: "<strong>Oh snap!<strong> you can't draw that!" // Message that will show when intersect
|
|
95
|
+
},
|
|
96
|
+
shapeOptions: {
|
|
97
|
+
color: '#97009c'
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
polygon: true,
|
|
101
|
+
polyline: false,
|
|
102
|
+
rectangle: true,
|
|
103
|
+
circle: false,
|
|
104
|
+
marker: false,
|
|
105
|
+
circlemarker: false
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
map.on(L.Draw.Event.DELETESTART, () => {
|
|
110
|
+
isDeleting = true
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
map.on(L.Draw.Event.DELETESTOP, () => {
|
|
114
|
+
isDeleting = false
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
map.addControl(drawControl)
|
|
118
|
+
|
|
119
|
+
jobsControl = L.control({ position: 'topright' })
|
|
120
|
+
|
|
121
|
+
jobsControl.onAdd = function () {
|
|
122
|
+
const btn = L.DomUtil.create(
|
|
123
|
+
'button',
|
|
124
|
+
'leaflet-bar leaflet-control leaflet-control-custom leaflet-control-button leaflet-button'
|
|
125
|
+
)
|
|
126
|
+
btn.innerHTML =
|
|
127
|
+
'<img src="leaflet/images/downloads.png" alt="Active seeding jobs" style="width:16px;height:16px;">'
|
|
128
|
+
|
|
129
|
+
// Create badge
|
|
130
|
+
const badge = L.DomUtil.create('span', 'jobs-badge', btn)
|
|
131
|
+
badge.innerText = '0' // initial value
|
|
132
|
+
// Prevent map drag when clicking
|
|
133
|
+
L.DomEvent.disableClickPropagation(btn)
|
|
134
|
+
|
|
135
|
+
btn.onclick = toggleJobsModal
|
|
136
|
+
// Store reference so we can update it later
|
|
137
|
+
jobsControl._badge = badge
|
|
138
|
+
updateJobsBadge(0) // hide badge initially
|
|
139
|
+
return btn
|
|
140
|
+
}
|
|
141
|
+
jobsControl.addTo(map)
|
|
142
|
+
|
|
143
|
+
const helpControl = L.control({ position: 'bottomright' })
|
|
144
|
+
|
|
145
|
+
helpControl.onAdd = function () {
|
|
146
|
+
const btn = L.DomUtil.create(
|
|
147
|
+
'button',
|
|
148
|
+
'leaflet-bar leaflet-control leaflet-control-custom leaflet-control-button leaflet-button'
|
|
149
|
+
)
|
|
150
|
+
btn.innerHTML =
|
|
151
|
+
'<img src="leaflet/images/lifebuoy.png" alt="Help" style="width:16px;height:16px;">'
|
|
152
|
+
|
|
153
|
+
// Prevent map drag when clicking
|
|
154
|
+
L.DomEvent.disableClickPropagation(btn)
|
|
155
|
+
|
|
156
|
+
btn.onclick = toggleHelpModal
|
|
157
|
+
return btn
|
|
158
|
+
}
|
|
159
|
+
helpControl.addTo(map)
|
|
160
|
+
|
|
161
|
+
map.on(L.Draw.Event.CREATED, (e) => {
|
|
162
|
+
const layer = e.layer
|
|
163
|
+
normalizePolygon(layer)
|
|
164
|
+
drawnItems.addLayer(layer)
|
|
165
|
+
layer.feature = {
|
|
166
|
+
type: 'Feature',
|
|
167
|
+
properties: { name: '' }
|
|
168
|
+
}
|
|
169
|
+
layer.on('click', () => {
|
|
170
|
+
if (isDeleting) return
|
|
171
|
+
openRegionModal(layer)
|
|
172
|
+
})
|
|
173
|
+
drawnItems.addLayer(layer)
|
|
174
|
+
openRegionModal(layer)
|
|
175
|
+
saveRegions()
|
|
176
|
+
})
|
|
177
|
+
map.on(L.Draw.Event.EDITED, () => saveRegions())
|
|
178
|
+
map.on(L.Draw.Event.DELETED, () => saveRegions())
|
|
179
|
+
await loadRegions()
|
|
180
|
+
|
|
181
|
+
// Create a custom control
|
|
182
|
+
const statusControl = L.control({ position: 'bottomleft' })
|
|
183
|
+
|
|
184
|
+
statusControl.onAdd = function () {
|
|
185
|
+
const div = L.DomUtil.create('div', 'map-status-control')
|
|
186
|
+
div.innerHTML = 'Lat: --, Lng: -- | Zoom: --'
|
|
187
|
+
return div
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
statusControl.addTo(map)
|
|
191
|
+
|
|
192
|
+
map.on('mousemove', function (e) {
|
|
193
|
+
const { lat, lng } = e.latlng
|
|
194
|
+
document.querySelector('.map-status-control').innerHTML =
|
|
195
|
+
`Lat: ${lat.toFixed(5)}, Lng: ${lng.toFixed(5)} | Zoom: ${map.getZoom()}`
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
map.on('zoomend', function () {
|
|
199
|
+
const div = document.querySelector('.map-status-control')
|
|
200
|
+
if (div.textContent.includes('Lat')) {
|
|
201
|
+
const zoom = map.getZoom()
|
|
202
|
+
div.innerHTML = div.innerHTML.replace(/Zoom: \d+/, `Zoom: ${zoom}`)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const chartSelect = document.getElementById('chartSelect')
|
|
207
|
+
if (!chartSelect) return
|
|
208
|
+
// Fetch available charts from SignalK server
|
|
209
|
+
try {
|
|
210
|
+
const resp = await fetch('/signalk/v2/api/resources/charts')
|
|
211
|
+
if (!resp.ok) {
|
|
212
|
+
throw new Error(`Failed to fetch charts: ${resp.status}`)
|
|
213
|
+
}
|
|
214
|
+
const data = await resp.json()
|
|
215
|
+
const entries = Object.entries(data || {})
|
|
216
|
+
let overlayMaps = {}
|
|
217
|
+
entries
|
|
218
|
+
.sort(([, a], [, b]) => {
|
|
219
|
+
const na = a && a.name ? String(a.name) : ''
|
|
220
|
+
const nb = b && b.name ? String(b.name) : ''
|
|
221
|
+
return na.localeCompare(nb)
|
|
222
|
+
})
|
|
223
|
+
.forEach(([id, info]) => {
|
|
224
|
+
// Only proxied raster tile charts can be seeded; skip style / vector
|
|
225
|
+
// sources (e.g. mapstyleJSON) so they aren't offered for a job that
|
|
226
|
+
// would only fail. See isSeedableChart.
|
|
227
|
+
if (isSeedableChart(info)) {
|
|
228
|
+
const opt = document.createElement('option')
|
|
229
|
+
opt.value = id
|
|
230
|
+
opt.textContent = info && info.name ? info.name : id
|
|
231
|
+
chartSelect.appendChild(opt)
|
|
232
|
+
|
|
233
|
+
let layer = L.tileLayer(`${info.url}`, {
|
|
234
|
+
maxZoom: info.maxZoom || 19,
|
|
235
|
+
minZoom: info.minZoom || 1,
|
|
236
|
+
checked: settings.selections.charts.includes(info.name)
|
|
237
|
+
})
|
|
238
|
+
layer.addTo(map)
|
|
239
|
+
if (!settings.selections.charts.includes(info.name)) {
|
|
240
|
+
map.removeLayer(layer)
|
|
241
|
+
}
|
|
242
|
+
overlayMaps[info.name || id] = layer
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
layerControl = L.control
|
|
246
|
+
.layers({}, overlayMaps, {
|
|
247
|
+
position: 'topright'
|
|
248
|
+
})
|
|
249
|
+
.addTo(map)
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error('Error loading maps:', err)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
map.on('overlayadd', function (e) {
|
|
255
|
+
const name = e.name
|
|
256
|
+
|
|
257
|
+
if (!settings.selections.charts.includes(name)) {
|
|
258
|
+
settings.selections.charts.push(name)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
saveSettings()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
map.on('overlayremove', function (e) {
|
|
265
|
+
const name = e.name
|
|
266
|
+
|
|
267
|
+
settings.selections.charts = settings.selections.charts.filter(
|
|
268
|
+
(c) => c !== name
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
saveSettings()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
setupDragAndDrop()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Help
|
|
278
|
+
|
|
279
|
+
function toggleHelpModal() {
|
|
280
|
+
const modal = document.getElementById('help')
|
|
281
|
+
modal.classList.toggle('hidden')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Jobs
|
|
285
|
+
|
|
286
|
+
function toggleJobsModal() {
|
|
287
|
+
let modal = document.getElementById('jobsModal')
|
|
288
|
+
const isHidden = modal.classList.toggle('hidden')
|
|
289
|
+
|
|
290
|
+
if (isHidden) {
|
|
291
|
+
// Stop polling while the panel is closed; nothing is watching it and the
|
|
292
|
+
// 2s fetch otherwise runs for the life of the page.
|
|
293
|
+
if (jobsPollInterval) {
|
|
294
|
+
clearInterval(jobsPollInterval)
|
|
295
|
+
jobsPollInterval = null
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
if (jobsPollInterval) return // already running
|
|
299
|
+
fetchActiveJobs() // fetch immediately, then poll while open
|
|
300
|
+
jobsPollInterval = setInterval(fetchActiveJobs, 2000)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function cloneTemplate(id) {
|
|
305
|
+
const tpl = document.getElementById(id)
|
|
306
|
+
if (!tpl) {
|
|
307
|
+
console.error(`Template "${id}" not found`)
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
return tpl.content.cloneNode(true)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function fillProgress(container, job) {
|
|
314
|
+
const pct =
|
|
315
|
+
typeof job.progress === 'number' && isFinite(job.progress)
|
|
316
|
+
? Math.max(0, Math.min(100, job.progress * 100))
|
|
317
|
+
: 0
|
|
318
|
+
const pctText = `${pct.toFixed(1)}%`
|
|
319
|
+
|
|
320
|
+
container.innerHTML = `
|
|
321
|
+
<span class="progress-bar">
|
|
322
|
+
<span class="progress-fill" style="width:${pct}%"></span>
|
|
323
|
+
</span>
|
|
324
|
+
<span class="percent">${pctText}</span>
|
|
325
|
+
`
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function createJobCard(job) {
|
|
329
|
+
const frag = cloneTemplate('job-card-template')
|
|
330
|
+
if (!frag) return ''
|
|
331
|
+
const card = frag.querySelector('.job-card')
|
|
332
|
+
|
|
333
|
+
card.dataset.id = job.id
|
|
334
|
+
|
|
335
|
+
card.querySelector('.job-region').textContent = job.regionName
|
|
336
|
+
card.querySelector('.job-chart').textContent = job.chartName
|
|
337
|
+
card.querySelector('.job-status').textContent = job.status || ''
|
|
338
|
+
card.querySelector('.job-total').textContent = job.totalTiles.toLocaleString()
|
|
339
|
+
if (job.type === JobType.Seed) {
|
|
340
|
+
card.querySelector('.job-seed').classList.remove('hidden')
|
|
341
|
+
card.querySelector('.job-downloaded').textContent =
|
|
342
|
+
job.downloadedTiles.toLocaleString()
|
|
343
|
+
card.querySelector('.job-cached').textContent =
|
|
344
|
+
job.cachedTiles.toLocaleString()
|
|
345
|
+
card.querySelector('.job-failed').textContent =
|
|
346
|
+
job.failedTiles.toLocaleString()
|
|
347
|
+
} else if (job.type === JobType.Delete) {
|
|
348
|
+
card.querySelector('.job-delete').classList.remove('hidden')
|
|
349
|
+
card.querySelector('.job-deleted').textContent = job.deletedTiles
|
|
350
|
+
? job.deletedTiles.toLocaleString()
|
|
351
|
+
: '0'
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
card.querySelector('.job-remove').addEventListener('click', () => {
|
|
355
|
+
takeAction(job.id, 'remove')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
fillProgress(card.querySelector('.job-card-progress'), job)
|
|
359
|
+
|
|
360
|
+
return frag
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderJobs(jobs) {
|
|
364
|
+
const cardContainer = document.getElementById('jobsGrid')
|
|
365
|
+
|
|
366
|
+
cardContainer.innerHTML = ''
|
|
367
|
+
|
|
368
|
+
if (!Array.isArray(jobs) || jobs.length === 0) {
|
|
369
|
+
document.getElementById('noJobsMessage').classList.remove('hidden')
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
document.getElementById('noJobsMessage').classList.add('hidden')
|
|
373
|
+
jobs.forEach((job, index) => {
|
|
374
|
+
cardContainer.appendChild(createJobCard(job))
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function fetchActiveJobs() {
|
|
379
|
+
try {
|
|
380
|
+
const resp = await fetch('/signalk/chart-tiles/cache/jobs')
|
|
381
|
+
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
|
382
|
+
const data = await resp.json()
|
|
383
|
+
renderJobs(data)
|
|
384
|
+
updateJobsBadge(data.filter((item) => item.state === 1).length)
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.error('Error fetching active jobs:', err)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function createJob(action) {
|
|
391
|
+
try {
|
|
392
|
+
if (activeRegionLayer == null) {
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
const chart = document.getElementById('chartSelect').value
|
|
396
|
+
const minZoom = document.getElementById('minZoom').value
|
|
397
|
+
const maxZoom = document.getElementById('maxZoom').value
|
|
398
|
+
const refetch = document.getElementById('refetch').checked
|
|
399
|
+
const mbtiles = document.getElementById('mbtiles').checked
|
|
400
|
+
const vacuum = document.getElementById('vacuum').checked
|
|
401
|
+
const body = {}
|
|
402
|
+
body.minZoom = minZoom
|
|
403
|
+
body.maxZoom = maxZoom
|
|
404
|
+
body.feature = activeRegionLayer.toGeoJSON()
|
|
405
|
+
body.action = action
|
|
406
|
+
body.options = { refetch: refetch, mbtiles: mbtiles, vacuum: vacuum }
|
|
407
|
+
|
|
408
|
+
const resp = await fetch(`/signalk/chart-tiles/cache/${chart}`, {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers: {
|
|
411
|
+
'Content-Type': 'application/json'
|
|
412
|
+
},
|
|
413
|
+
body: JSON.stringify(body)
|
|
414
|
+
})
|
|
415
|
+
if (!resp.ok) {
|
|
416
|
+
throw new Error(`Failed to seed charts: ${resp.status}`)
|
|
417
|
+
}
|
|
418
|
+
const data = await resp.json()
|
|
419
|
+
const responseSpan = document.getElementById('response')
|
|
420
|
+
responseSpan.classList.remove('hidden')
|
|
421
|
+
responseSpan.textContent = data.message || 'Success'
|
|
422
|
+
setTimeout(() => {
|
|
423
|
+
responseSpan.textContent = ''
|
|
424
|
+
responseSpan.classList.add('hidden')
|
|
425
|
+
}, 2000)
|
|
426
|
+
} catch (err) {
|
|
427
|
+
console.error('Error seeding charts:', err)
|
|
428
|
+
const responseSpan = document.getElementById('response')
|
|
429
|
+
if (responseSpan) {
|
|
430
|
+
responseSpan.classList.remove('hidden')
|
|
431
|
+
responseSpan.textContent = err.message || 'Error seeding charts'
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
closeRegionModal()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function updateJobsBadge(count) {
|
|
438
|
+
if (!jobsControl._badge) return
|
|
439
|
+
jobsControl._badge.innerText = count
|
|
440
|
+
jobsControl._badge.style.display = count > 0 ? 'flex' : 'none'
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function takeAction(jobId, action) {
|
|
444
|
+
const serverResponse = document.getElementById('response')
|
|
445
|
+
fetch(`/signalk/chart-tiles/cache/jobs/${jobId}`, {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
headers: {
|
|
448
|
+
'Content-Type': 'application/json'
|
|
449
|
+
},
|
|
450
|
+
body: JSON.stringify({
|
|
451
|
+
action: action
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
.then((response) => {
|
|
455
|
+
if (!response.ok) {
|
|
456
|
+
throw new Error(`Failed to ${action} job ${jobId}: ${response.status}`)
|
|
457
|
+
}
|
|
458
|
+
fetchActiveJobs()
|
|
459
|
+
})
|
|
460
|
+
.catch((error) => {
|
|
461
|
+
console.error(`Error ${action} job:`, error)
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Regions
|
|
466
|
+
|
|
467
|
+
function setupRegions() {
|
|
468
|
+
// Region modal form
|
|
469
|
+
document.getElementById('saveRegion').onclick = () => {
|
|
470
|
+
if (!activeRegionLayer) return
|
|
471
|
+
|
|
472
|
+
const p = activeRegionLayer.feature.properties
|
|
473
|
+
// Explicit DOM lookup rather than relying on the legacy
|
|
474
|
+
// id-becomes-window-property behaviour, which is non-strict-mode-only
|
|
475
|
+
// and a frequent source of "works in Chrome, breaks in tests" bugs.
|
|
476
|
+
p.name = document.getElementById('regionName').value
|
|
477
|
+
saveRegions()
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function setupDragAndDrop() {
|
|
482
|
+
const mapEl = map.getContainer()
|
|
483
|
+
|
|
484
|
+
// Required so browser allows drops
|
|
485
|
+
mapEl.addEventListener('dragover', (e) => {
|
|
486
|
+
e.preventDefault()
|
|
487
|
+
mapEl.classList.add('map-drag-hover')
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
mapEl.addEventListener('dragleave', () => {
|
|
491
|
+
mapEl.classList.remove('map-drag-hover')
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
mapEl.addEventListener('drop', async (e) => {
|
|
495
|
+
e.preventDefault()
|
|
496
|
+
mapEl.classList.remove('map-drag-hover')
|
|
497
|
+
|
|
498
|
+
const file = e.dataTransfer.files[0]
|
|
499
|
+
if (!file) return
|
|
500
|
+
|
|
501
|
+
if (!file.name.endsWith('.geojson') && !file.name.endsWith('.json')) {
|
|
502
|
+
alert('Please drop a valid GeoJSON file.')
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const text = await file.text()
|
|
507
|
+
|
|
508
|
+
let geojson
|
|
509
|
+
try {
|
|
510
|
+
geojson = JSON.parse(text)
|
|
511
|
+
} catch (err) {
|
|
512
|
+
alert('Invalid GeoJSON file.')
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
importGeoJSON(geojson)
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function importGeoJSON(geojson) {
|
|
521
|
+
if (!geojson || !geojson.features) {
|
|
522
|
+
alert('Invalid GeoJSON structure.')
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
L.geoJSON(geojson, {
|
|
527
|
+
onEachFeature: (feature, layer) => {
|
|
528
|
+
layer.feature = feature
|
|
529
|
+
layer.onclick = () => openRegionModal(layer)
|
|
530
|
+
drawnItems.addLayer(layer)
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
saveRegions()
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function saveRegions() {
|
|
537
|
+
const geojson = drawnItems.toGeoJSON()
|
|
538
|
+
let data = JSON.stringify(geojson)
|
|
539
|
+
|
|
540
|
+
const res = await fetch('/signalk/chart-tiles/cache/regions', {
|
|
541
|
+
method: 'POST',
|
|
542
|
+
headers: { 'Content-Type': 'application/json' },
|
|
543
|
+
body: data
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
if (!res.ok) {
|
|
547
|
+
console.error('Failed to save regions')
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
updateRegionButtons()
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function loadRegions() {
|
|
554
|
+
const res = await fetch('/signalk/chart-tiles/cache/regions')
|
|
555
|
+
if (!res.ok) {
|
|
556
|
+
console.error('Failed to load saved drawings')
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
const geojson = await res.json()
|
|
560
|
+
drawnItems.clearLayers()
|
|
561
|
+
L.geoJSON(geojson, {
|
|
562
|
+
style: {
|
|
563
|
+
color: '#97009c'
|
|
564
|
+
},
|
|
565
|
+
onEachFeature: (feature, layer) => {
|
|
566
|
+
layer.on('click', () => {
|
|
567
|
+
if (isDeleting) return
|
|
568
|
+
openRegionModal(layer)
|
|
569
|
+
})
|
|
570
|
+
drawnItems.addLayer(layer)
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function openRegionModal(layer) {
|
|
576
|
+
activeRegionLayer = layer
|
|
577
|
+
|
|
578
|
+
const p = layer.feature.properties
|
|
579
|
+
|
|
580
|
+
document.getElementById('regionName').value = p.name || p.Name || ''
|
|
581
|
+
const regionModal = document.getElementById('regionModal')
|
|
582
|
+
regionModal.classList.remove('hidden')
|
|
583
|
+
|
|
584
|
+
updateRegionButtons()
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function closeRegionModal() {
|
|
588
|
+
const regionModal = document.getElementById('regionModal')
|
|
589
|
+
regionModal.classList.add('hidden')
|
|
590
|
+
activeRegionLayer = null
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isValidRegionName(name) {
|
|
594
|
+
return /^[\p{L}0-9_-]+$/u.test(name)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function updateRegionButtons() {
|
|
598
|
+
const regionInput = document.getElementById('regionName')
|
|
599
|
+
const saveRegionBtn = document.getElementById('saveRegion')
|
|
600
|
+
// First validate the value in the textbox to enable/disabl the save button
|
|
601
|
+
if (isValidRegionName(regionInput.value.trim())) {
|
|
602
|
+
regionInput.style.borderColor = ''
|
|
603
|
+
saveRegionBtn.disabled = false
|
|
604
|
+
} else {
|
|
605
|
+
regionInput.style.borderColor = 'red'
|
|
606
|
+
saveRegionBtn.disabled = true
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const p = activeRegionLayer.feature.properties
|
|
610
|
+
const value = p.name || p.Name || ''
|
|
611
|
+
const createBtn = document.getElementById('createJobButton')
|
|
612
|
+
const deleteBtn = document.getElementById('deleteButton')
|
|
613
|
+
if (isValidRegionName(value)) {
|
|
614
|
+
createBtn.disabled = false
|
|
615
|
+
deleteBtn.disabled = false
|
|
616
|
+
} else {
|
|
617
|
+
createBtn.disabled = true
|
|
618
|
+
deleteBtn.disabled = true
|
|
619
|
+
}
|
|
620
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|