@signalk/charts-plugin 3.5.0 → 3.6.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/public/index.js CHANGED
@@ -1,193 +1,231 @@
1
- ;(function () {})()
2
-
3
- document.addEventListener('DOMContentLoaded', async () => {
4
- const regionSelect = document.getElementById('region')
5
- const bboxInputs = document.querySelectorAll(
6
- '#bboxMinLon, #bboxMinLat, #bboxMaxLon, #bboxMaxLat'
1
+ // POSTs a seed-job request and returns the parsed job info. Extracted so the
2
+ // fetch + response-validation logic can be unit-tested in node without jsdom.
3
+ // The browser still calls this from the click handler below.
4
+ async function submitSeedJob(chart, body, fetchImpl) {
5
+ const fetchFn = fetchImpl || globalThis.fetch
6
+ const resp = await fetchFn(
7
+ `/signalk/chart-tiles/cache/${encodeURIComponent(chart)}`,
8
+ {
9
+ method: 'POST',
10
+ headers: { 'Content-Type': 'application/json' },
11
+ body: JSON.stringify(body)
12
+ }
7
13
  )
8
- const tileInputs = document.querySelectorAll('#tileX, #tileY, #tileZ')
9
-
10
- function updateInputs() {
11
- const type = document.querySelector('input[name="inputType"]:checked').value
12
-
13
- regionSelect.disabled = type !== 'region'
14
- bboxInputs.forEach((i) => (i.disabled = type !== 'bbox'))
15
- tileInputs.forEach((i) => (i.disabled = type !== 'tile'))
14
+ if (!resp.ok) {
15
+ let text = ''
16
+ try {
17
+ text = await resp.text()
18
+ } catch (_readErr) {
19
+ // Ignore read errors when composing the error message; the status code
20
+ // and our surrounding context are enough to diagnose.
21
+ }
22
+ throw new Error(
23
+ `Failed to seed charts: ${resp.status}${text ? ` - ${text}` : ''}`
24
+ )
16
25
  }
26
+ const data = await resp.json()
27
+ if (!data || typeof data.id !== 'number') {
28
+ throw new Error('Job response is missing a numeric id')
29
+ }
30
+ return data
31
+ }
17
32
 
18
- // Attach listeners
19
- document.querySelectorAll('input[name="inputType"]').forEach((r) => {
20
- r.addEventListener('change', updateInputs)
21
- })
22
-
23
- updateInputs() // initial load
33
+ // Node-only export for the unit test. The browser script tag ignores this
34
+ // branch because `module` is undefined in a plain <script>.
35
+ if (typeof module !== 'undefined' && module.exports) {
36
+ module.exports = { submitSeedJob }
37
+ }
24
38
 
25
- if (!regionSelect) return
39
+ // The browser attaches the DOM handlers; node (test context) skips them.
40
+ if (typeof document !== 'undefined') registerDomHandlers()
26
41
 
27
- try {
28
- const resp = await fetch('/signalk/v2/api/resources/regions')
29
- if (!resp.ok) throw new Error(`Failed to fetch regions: ${resp.status}`)
30
- const data = await resp.json()
31
- // clear existing options
32
- regionSelect.innerHTML = ''
42
+ function registerDomHandlers() {
43
+ document.addEventListener('DOMContentLoaded', async () => {
44
+ const regionSelect = document.getElementById('region')
45
+ const bboxInputs = document.querySelectorAll(
46
+ '#bboxMinLon, #bboxMinLat, #bboxMaxLon, #bboxMaxLat'
47
+ )
48
+ const tileInputs = document.querySelectorAll('#tileX, #tileY, #tileZ')
33
49
 
34
- const entries = Object.entries(data || {})
35
- entries
36
- .sort(([, a], [, b]) => {
37
- const na = a && a.name ? String(a.name) : ''
38
- const nb = b && b.name ? String(b.name) : ''
39
- return na.localeCompare(nb)
40
- })
41
- .forEach(([id, info]) => {
42
- const opt = document.createElement('option')
43
- opt.value = id
44
- opt.textContent = info && info.name ? info.name : id
45
- regionSelect.appendChild(opt)
46
- })
50
+ function updateInputs() {
51
+ const type = document.querySelector(
52
+ 'input[name="inputType"]:checked'
53
+ ).value
47
54
 
48
- // if no regions returned, add a fallback option
49
- if (!regionSelect.options.length) {
50
- const opt = document.createElement('option')
51
- opt.value = ''
52
- opt.textContent = '-- No regions available --'
53
- regionSelect.appendChild(opt)
55
+ regionSelect.disabled = type !== 'region'
56
+ bboxInputs.forEach((i) => (i.disabled = type !== 'bbox'))
57
+ tileInputs.forEach((i) => (i.disabled = type !== 'tile'))
54
58
  }
55
- } catch (err) {
56
- console.error('Error loading regions:', err)
57
- }
58
59
 
59
- //Fetch available maps and populate the chart select box
60
- const chartSelect = document.getElementById('chart')
61
- if (!chartSelect) return
60
+ // Attach listeners
61
+ document.querySelectorAll('input[name="inputType"]').forEach((r) => {
62
+ r.addEventListener('change', updateInputs)
63
+ })
64
+
65
+ updateInputs() // initial load
62
66
 
63
- try {
64
- const resp = await fetch('/signalk/v2/api/resources/charts')
65
- if (!resp.ok) throw new Error(`Failed to fetch charts: ${resp.status}`)
66
- const data = await resp.json()
67
- // clear existing options
68
- chartSelect.innerHTML = ''
67
+ if (!regionSelect) return
69
68
 
70
- const entries = Object.entries(data || {})
71
- entries
72
- .sort(([, a], [, b]) => {
73
- const na = a && a.name ? String(a.name) : ''
74
- const nb = b && b.name ? String(b.name) : ''
75
- return na.localeCompare(nb)
76
- })
77
- .forEach(([id, info]) => {
78
- if (info.proxy === true) {
69
+ try {
70
+ const resp = await fetch('/signalk/v2/api/resources/regions')
71
+ if (!resp.ok) throw new Error(`Failed to fetch regions: ${resp.status}`)
72
+ const data = await resp.json()
73
+ // clear existing options
74
+ regionSelect.innerHTML = ''
75
+
76
+ const entries = Object.entries(data || {})
77
+ entries
78
+ .sort(([, a], [, b]) => {
79
+ const na = a && a.name ? String(a.name) : ''
80
+ const nb = b && b.name ? String(b.name) : ''
81
+ return na.localeCompare(nb)
82
+ })
83
+ .forEach(([id, info]) => {
79
84
  const opt = document.createElement('option')
80
85
  opt.value = id
81
86
  opt.textContent = info && info.name ? info.name : id
82
- chartSelect.appendChild(opt)
83
- }
84
- })
87
+ regionSelect.appendChild(opt)
88
+ })
85
89
 
86
- // if no maps returned, add a fallback option
87
- if (!chartSelect.options.length) {
88
- const opt = document.createElement('option')
89
- opt.value = ''
90
- opt.textContent = '-- No maps available --'
91
- chartSelect.appendChild(opt)
90
+ // if no regions returned, add a fallback option
91
+ if (!regionSelect.options.length) {
92
+ const opt = document.createElement('option')
93
+ opt.value = ''
94
+ opt.textContent = '-- No regions available --'
95
+ regionSelect.appendChild(opt)
96
+ }
97
+ } catch (err) {
98
+ console.error('Error loading regions:', err)
92
99
  }
93
- } catch (err) {
94
- console.error('Error loading maps:', err)
95
- }
96
100
 
97
- document
98
- .getElementById('createJobButton')
99
- .addEventListener('click', async () => {
100
- try {
101
- const chart = document.getElementById('chart').value
102
- const regionGUID = document.getElementById('region').value
103
- const maxZoom = document.getElementById('maxZoom').value
104
- const bbox = {
105
- minLon: parseFloat(document.getElementById('bboxMinLon').value),
106
- minLat: parseFloat(document.getElementById('bboxMinLat').value),
107
- maxLon: parseFloat(document.getElementById('bboxMaxLon').value),
108
- maxLat: parseFloat(document.getElementById('bboxMaxLat').value)
109
- }
110
- // const tile = {
111
- // z: parseInt(document.getElementById('tileZ').value),
112
- // x: parseInt(document.getElementById('tileX').value),
113
- // y: parseInt(document.getElementById('tileY').value),
101
+ //Fetch available maps and populate the chart select box
102
+ const chartSelect = document.getElementById('chart')
103
+ if (!chartSelect) return
114
104
 
115
- // };
116
- // console.log(tile);
117
- const type = document.querySelector(
118
- 'input[name="inputType"]:checked'
119
- ).value
120
- const body = {}
121
- if (type === 'region') {
122
- body.regionGUID = regionGUID
123
- } else if (type === 'bbox') {
124
- body.bbox = bbox
125
- }
126
- // else if (type === 'tile') {
127
- // body.tile = tile;
128
- // }
129
- body.maxZoom = maxZoom
130
- const resp = await fetch(`/signalk/chart-tiles/cache/${chart}`, {
131
- method: 'POST',
132
- headers: {
133
- 'Content-Type': 'application/json'
134
- },
135
- body: JSON.stringify(body)
105
+ try {
106
+ const resp = await fetch('/signalk/v2/api/resources/charts')
107
+ if (!resp.ok) throw new Error(`Failed to fetch charts: ${resp.status}`)
108
+ const data = await resp.json()
109
+ // clear existing options
110
+ chartSelect.innerHTML = ''
111
+
112
+ const entries = Object.entries(data || {})
113
+ entries
114
+ .sort(([, a], [, b]) => {
115
+ const na = a && a.name ? String(a.name) : ''
116
+ const nb = b && b.name ? String(b.name) : ''
117
+ return na.localeCompare(nb)
118
+ })
119
+ .forEach(([id, info]) => {
120
+ if (info.proxy === true) {
121
+ const opt = document.createElement('option')
122
+ opt.value = id
123
+ opt.textContent = info && info.name ? info.name : id
124
+ chartSelect.appendChild(opt)
125
+ }
136
126
  })
137
- if (!resp.ok) throw new Error(`Failed to seed charts: ${resp.status}`)
138
- const data = await resp.json()
139
- } catch (err) {
140
- console.error('Error seeding charts:', err)
141
- }
142
- })
143
127
 
144
- const fmt = (n) => (typeof n === 'number' ? n.toLocaleString() : '-')
145
- const statusText = (s) => {
146
- switch (s) {
147
- case 0:
148
- return 'Stopped'
149
- case 1:
150
- return 'Running'
151
- default:
152
- return String(s)
128
+ // if no maps returned, add a fallback option
129
+ if (!chartSelect.options.length) {
130
+ const opt = document.createElement('option')
131
+ opt.value = ''
132
+ opt.textContent = '-- No maps available --'
133
+ chartSelect.appendChild(opt)
134
+ }
135
+ } catch (err) {
136
+ console.error('Error loading maps:', err)
153
137
  }
154
- }
155
138
 
156
- function renderJobs(items) {
157
- const tbody = document.querySelector('#activeJobsTable tbody')
158
- if (!tbody) return
159
- tbody.innerHTML = ''
160
- if (!Array.isArray(items) || items.length === 0) {
161
- const tr = document.createElement('tr')
162
- tr.innerHTML = '<td colspan="10" class="muted">No active jobs</td>'
163
- tbody.appendChild(tr)
164
- return
139
+ document
140
+ .getElementById('createJobButton')
141
+ .addEventListener('click', async () => {
142
+ try {
143
+ const chart = document.getElementById('chart').value
144
+ const regionGUID = document.getElementById('region').value
145
+ const maxZoom = document.getElementById('maxZoom').value
146
+ const bbox = {
147
+ minLon: parseFloat(document.getElementById('bboxMinLon').value),
148
+ minLat: parseFloat(document.getElementById('bboxMinLat').value),
149
+ maxLon: parseFloat(document.getElementById('bboxMaxLon').value),
150
+ maxLat: parseFloat(document.getElementById('bboxMaxLat').value)
151
+ }
152
+ // const tile = {
153
+ // z: parseInt(document.getElementById('tileZ').value),
154
+ // x: parseInt(document.getElementById('tileX').value),
155
+ // y: parseInt(document.getElementById('tileY').value),
156
+
157
+ // };
158
+ // console.log(tile);
159
+ const type = document.querySelector(
160
+ 'input[name="inputType"]:checked'
161
+ ).value
162
+ const body = {}
163
+ if (type === 'region') {
164
+ body.regionGUID = regionGUID
165
+ } else if (type === 'bbox') {
166
+ body.bbox = bbox
167
+ }
168
+ // else if (type === 'tile') {
169
+ // body.tile = tile;
170
+ // }
171
+ body.maxZoom = maxZoom
172
+ await submitSeedJob(chart, body)
173
+ // Trigger an immediate refresh so the new job appears without waiting
174
+ // for the 2s polling interval.
175
+ fetchActiveJobs()
176
+ } catch (err) {
177
+ console.error('Error seeding charts:', err)
178
+ alert(`Failed to start seeding job: ${err.message}`)
179
+ }
180
+ })
181
+
182
+ const fmt = (n) => (typeof n === 'number' ? n.toLocaleString() : '-')
183
+ const statusText = (s) => {
184
+ switch (s) {
185
+ case 0:
186
+ return 'Stopped'
187
+ case 1:
188
+ return 'Running'
189
+ default:
190
+ return String(s)
191
+ }
165
192
  }
166
193
 
167
- items.forEach((it, idx) => {
168
- const tr = document.createElement('tr')
194
+ function renderJobs(items) {
195
+ const tbody = document.querySelector('#activeJobsTable tbody')
196
+ if (!tbody) return
197
+ tbody.innerHTML = ''
198
+ if (!Array.isArray(items) || items.length === 0) {
199
+ const tr = document.createElement('tr')
200
+ tr.innerHTML = '<td colspan="10" class="muted">No active jobs</td>'
201
+ tbody.appendChild(tr)
202
+ return
203
+ }
204
+
205
+ items.forEach((it, idx) => {
206
+ const tr = document.createElement('tr')
169
207
 
170
- const pct =
171
- typeof it.progress === 'number' && isFinite(it.progress)
172
- ? Math.max(0, Math.min(100, it.progress * 100))
173
- : 0
174
- const pctText = `${pct.toFixed(1)}%`
175
- tr.innerHTML = [
176
- `<td>${idx + 1}</td>`,
177
- `<td>${it.chartName || '-'}</td>`,
178
- `<td>${it.regionName || '-'}</td>`,
179
- `<td>${fmt(it.totalTiles)}</td>`,
180
- `<td>${fmt(it.downloadedTiles)}</td>`,
181
- `<td>${fmt(it.cachedTiles)}</td>`,
182
- `<td>${fmt(it.failedTiles)}</td>`,
183
- `<td>
208
+ const pct =
209
+ typeof it.progress === 'number' && isFinite(it.progress)
210
+ ? Math.max(0, Math.min(100, it.progress * 100))
211
+ : 0
212
+ const pctText = `${pct.toFixed(1)}%`
213
+ tr.innerHTML = [
214
+ `<td>${idx + 1}</td>`,
215
+ `<td>${it.chartName || '-'}</td>`,
216
+ `<td>${it.regionName || '-'}</td>`,
217
+ `<td>${fmt(it.totalTiles)}</td>`,
218
+ `<td>${fmt(it.downloadedTiles)}</td>`,
219
+ `<td>${fmt(it.cachedTiles)}</td>`,
220
+ `<td>${fmt(it.failedTiles)}</td>`,
221
+ `<td>
184
222
  <span class="progress-bar" aria-hidden="true">
185
223
  <span class="progress-fill" style="width:${pct}%"></span>
186
224
  </span>
187
225
  <span class="percent">${pctText}</span>
188
226
  </td>`,
189
- `<td>${statusText(it.status)}</td>`,
190
- `
227
+ `<td>${statusText(it.status)}</td>`,
228
+ `
191
229
  <td class="action-buttons">
192
230
  <button class="btn startstop-btn" data-id="${it.id}" data-totalTiles="${it.totalTiles}" title="Start">
193
231
  <svg class="icon-play ${it.status === 1 ? 'hidden' : ''}" viewBox="0 0 24 24" width="16" height="16" fill="white">
@@ -209,102 +247,103 @@ document.addEventListener('DOMContentLoaded', async () => {
209
247
  </button>
210
248
  </td>
211
249
  `
212
- ].join('')
250
+ ].join('')
213
251
 
214
- tr.querySelectorAll('.startstop-btn').forEach((button) => {
215
- button.addEventListener('click', () => {
216
- const playIcon = button.querySelector('.icon-play')
217
- const pauseIcon = button.querySelector('.icon-pause')
218
- const jobId = button.getAttribute('data-id')
219
- const totalTiles = button.getAttribute('data-totalTiles')
220
- const isRunning = pauseIcon.classList.contains('hidden')
221
- console.log(isRunning)
222
- if (isRunning) {
223
- // Switch to stop
252
+ tr.querySelectorAll('.startstop-btn').forEach((button) => {
253
+ button.addEventListener('click', () => {
254
+ const playIcon = button.querySelector('.icon-play')
255
+ const pauseIcon = button.querySelector('.icon-pause')
256
+ const jobId = button.getAttribute('data-id')
257
+ const totalTiles = button.getAttribute('data-totalTiles')
258
+ const isRunning = pauseIcon.classList.contains('hidden')
259
+ console.log(isRunning)
260
+ if (isRunning) {
261
+ // Switch to stop
262
+ if (
263
+ totalTiles > 100000 &&
264
+ !confirm(
265
+ `This job has more than ${totalTiles} tiles to download. Starting it may take a long time and put high load on the server. Are you sure you want to start it?`
266
+ )
267
+ ) {
268
+ return
269
+ }
270
+ takeAction(jobId, 'start')
271
+ button.title = 'Stop'
272
+ } else {
273
+ // Switch to start
274
+ takeAction(jobId, 'stop')
275
+ button.title = 'Start'
276
+ }
277
+ })
278
+ })
279
+
280
+ tr.querySelectorAll('.delete-btn').forEach((button) => {
281
+ button.addEventListener('click', () => {
224
282
  if (
225
- totalTiles > 100000 &&
226
- !confirm(
227
- `This job has more than ${totalTiles} tiles to download. Starting it may take a long time and put high load on the server. Are you sure you want to start it?`
283
+ confirm(
284
+ 'This will delete all cached tiles for this job. Are you sure?'
228
285
  )
229
286
  ) {
230
- return
287
+ const jobId = button.getAttribute('data-id')
288
+ if (jobId) {
289
+ takeAction(jobId, 'delete')
290
+ }
231
291
  }
232
- takeAction(jobId, 'start')
233
- button.title = 'Stop'
234
- } else {
235
- // Switch to start
236
- takeAction(jobId, 'stop')
237
- button.title = 'Start'
238
- }
292
+ })
239
293
  })
240
- })
241
294
 
242
- tr.querySelectorAll('.delete-btn').forEach((button) => {
243
- button.addEventListener('click', () => {
244
- if (
245
- confirm(
246
- 'This will delete all cached tiles for this job. Are you sure?'
247
- )
248
- ) {
295
+ tr.querySelectorAll('.remove-btn').forEach((button) => {
296
+ button.addEventListener('click', () => {
249
297
  const jobId = button.getAttribute('data-id')
250
298
  if (jobId) {
251
- takeAction(jobId, 'delete')
299
+ takeAction(jobId, 'remove')
252
300
  }
253
- }
301
+ })
254
302
  })
255
- })
256
303
 
257
- tr.querySelectorAll('.remove-btn').forEach((button) => {
258
- button.addEventListener('click', () => {
259
- const jobId = button.getAttribute('data-id')
260
- if (jobId) {
261
- takeAction(jobId, 'remove')
262
- }
263
- })
304
+ tbody.appendChild(tr)
264
305
  })
265
306
 
266
- tbody.appendChild(tr)
267
- })
268
-
269
- function takeAction(jobId, action) {
270
- fetch(`/signalk/chart-tiles/cache/jobs/${jobId}`, {
271
- method: 'POST',
272
- headers: {
273
- 'Content-Type': 'application/json'
274
- },
275
- body: JSON.stringify({
276
- action: action
277
- })
278
- })
279
- .then((response) => {
280
- if (!response.ok) {
281
- throw new Error(
282
- `Failed to ${action} job ${jobId}: ${response.status}`
283
- )
284
- }
285
- fetchActiveJobs()
286
- })
287
- .catch((error) => {
288
- console.error(`Error ${action} job:`, error)
307
+ function takeAction(jobId, action) {
308
+ fetch(`/signalk/chart-tiles/cache/jobs/${jobId}`, {
309
+ method: 'POST',
310
+ headers: {
311
+ 'Content-Type': 'application/json'
312
+ },
313
+ body: JSON.stringify({
314
+ action: action
315
+ })
289
316
  })
317
+ .then((response) => {
318
+ if (!response.ok) {
319
+ throw new Error(
320
+ `Failed to ${action} job ${jobId}: ${response.status}`
321
+ )
322
+ }
323
+ fetchActiveJobs()
324
+ })
325
+ .catch((error) => {
326
+ console.error(`Error ${action} job:`, error)
327
+ })
328
+ }
290
329
  }
291
- }
292
330
 
293
- async function fetchActiveJobs() {
294
- const tbody = document.querySelector('#activeJobsTable tbody')
295
- try {
296
- const resp = await fetch('/signalk/chart-tiles/cache/jobs')
297
- if (!resp.ok) throw new Error('HTTP ' + resp.status)
298
- const data = await resp.json()
299
- renderJobs(data)
300
- } catch (err) {
301
- console.error('Error fetching active jobs:', err)
302
- tbody.innerHTML =
303
- '<tr><td colspan="10" class="muted">Error loading active jobs</td></tr>'
331
+ async function fetchActiveJobs() {
332
+ const tbody = document.querySelector('#activeJobsTable tbody')
333
+ try {
334
+ const resp = await fetch('/signalk/chart-tiles/cache/jobs')
335
+ if (!resp.ok) throw new Error('HTTP ' + resp.status)
336
+ const data = await resp.json()
337
+ renderJobs(data)
338
+ } catch (err) {
339
+ console.error('Error fetching active jobs:', err)
340
+ tbody.innerHTML =
341
+ '<tr><td colspan="10" class="muted">Error loading active jobs</td></tr>'
342
+ }
304
343
  }
305
- }
306
344
 
307
- fetchActiveJobs()
308
- // refresh every 2 seconds
309
- setInterval(fetchActiveJobs, 2000)
310
- })
345
+ fetchActiveJobs()
346
+ // refresh every 2 seconds
347
+ setInterval(fetchActiveJobs, 2000)
348
+ })
349
+ }
@@ -0,0 +1,74 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Charts">
2
+ <defs>
3
+ <linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#1b6ea8"/>
5
+ <stop offset="1" stop-color="#0b3a5e"/>
6
+ </linearGradient>
7
+ <linearGradient id="land" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0" stop-color="#f5e6b8"/>
9
+ <stop offset="1" stop-color="#d9c079"/>
10
+ </linearGradient>
11
+ <clipPath id="card">
12
+ <rect x="0" y="0" width="128" height="128" rx="22"/>
13
+ </clipPath>
14
+ </defs>
15
+
16
+ <g clip-path="url(#card)">
17
+ <rect width="128" height="128" fill="url(#sea)"/>
18
+
19
+ <g stroke="#bee3f4" stroke-opacity="0.35" stroke-width="1" fill="none">
20
+ <path d="M0 24 H128 M0 48 H128 M0 72 H128 M0 96 H128"/>
21
+ <path d="M24 0 V128 M48 0 V128 M72 0 V128 M96 0 V128"/>
22
+ </g>
23
+
24
+ <g fill="#bee3f4" fill-opacity="0.55" font-family="system-ui, sans-serif" font-size="6">
25
+ <text x="3" y="22">48°</text>
26
+ <text x="3" y="46">47°</text>
27
+ <text x="3" y="70">46°</text>
28
+ <text x="3" y="94">45°</text>
29
+ </g>
30
+
31
+ <path d="M14 92
32
+ Q 22 78, 36 80
33
+ T 60 70
34
+ Q 72 66, 78 54
35
+ Q 84 42, 100 40
36
+ Q 116 40, 128 48
37
+ L 128 128
38
+ L 0 128
39
+ L 0 96
40
+ Q 6 92, 14 92 Z"
41
+ fill="url(#land)" stroke="#8a6d2a" stroke-width="1" stroke-linejoin="round"/>
42
+
43
+ <g fill="#8a6d2a" fill-opacity="0.5">
44
+ <circle cx="46" cy="86" r="1.2"/>
45
+ <circle cx="58" cy="80" r="1"/>
46
+ <circle cx="70" cy="68" r="1.2"/>
47
+ <circle cx="88" cy="52" r="1"/>
48
+ <circle cx="106" cy="48" r="1.4"/>
49
+ </g>
50
+
51
+ <circle cx="34" cy="40" r="3.5" fill="url(#land)" stroke="#8a6d2a" stroke-width="0.8"/>
52
+ <circle cx="84" cy="22" r="2.2" fill="url(#land)" stroke="#8a6d2a" stroke-width="0.8"/>
53
+
54
+ <path d="M20 30 Q 44 34, 58 48 T 96 70"
55
+ fill="none" stroke="#e63946" stroke-width="1.6" stroke-linecap="round"
56
+ stroke-dasharray="3 3"/>
57
+ <circle cx="20" cy="30" r="2.4" fill="#e63946"/>
58
+ <g transform="translate(96 70)" stroke="#e63946" stroke-width="1.8" stroke-linecap="round">
59
+ <line x1="-3" y1="-3" x2="3" y2="3"/>
60
+ <line x1="-3" y1="3" x2="3" y2="-3"/>
61
+ </g>
62
+
63
+ <g transform="translate(108 104)">
64
+ <circle r="10" fill="#0b3a5e" fill-opacity="0.7" stroke="#bee3f4" stroke-width="0.8"/>
65
+ <polygon points="0,-8 2,0 0,0" fill="#e63946"/>
66
+ <polygon points="0,-8 -2,0 0,0" fill="#b5232f"/>
67
+ <polygon points="0,8 2,0 0,0" fill="#e2e8f0"/>
68
+ <polygon points="0,8 -2,0 0,0" fill="#cbd5e1"/>
69
+ <circle r="1" fill="#e2e8f0"/>
70
+ <text x="0" y="-11" text-anchor="middle" font-family="system-ui, sans-serif"
71
+ font-size="5" font-weight="700" fill="#e63946">N</text>
72
+ </g>
73
+ </g>
74
+ </svg>