@leavepulse/ui 0.16.2 → 0.17.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,665 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Point picker on a raster tile map — drag to pan, wheel or the buttons to
4
+ * zoom, click to drop the pin, arrows and +/−/0 from the keyboard.
5
+ *
6
+ * No mapping library: a slippy map is a grid of <img> tiles placed by the Web
7
+ * Mercator maths in ./address.ts, which is a fraction of what wrapping one
8
+ * would cost — and the kit stays dependency-free.
9
+ *
10
+ * Two things keep it smooth. Panning moves ONE transform on the tile layer:
11
+ * tiles are positioned against a fixed world anchor, so dragging never touches
12
+ * their styles and Vue has nothing to patch per frame. Zooming scales that same
13
+ * layer first and swaps the tile pyramid only once the animation lands, which
14
+ * is how the zoom reads as continuous instead of as a hard cut between levels.
15
+ */
16
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
17
+ import { type LatLon, OSM_TILES, type TileSource, project, tileUrl, unproject } from "./address"
18
+ import LpIcon from "./LpIcon.vue"
19
+
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ /** Picked point. Bind with v-model; undefined leaves the map unpinned. */
23
+ modelValue?: LatLon
24
+ /** Tile server. Defaults to OpenStreetMap; swap for your own. */
25
+ tiles?: TileSource
26
+ /** Zoom used on first paint, and when a point arrives from outside. */
27
+ zoom?: number
28
+ /** Where to look when there is no point yet. */
29
+ center?: LatLon
30
+ disabled?: boolean
31
+ /** Any CSS length. */
32
+ height?: string
33
+ /** Offer the "use my location" control (needs a secure context). */
34
+ locatable?: boolean
35
+ zoomInLabel?: string
36
+ zoomOutLabel?: string
37
+ locateLabel?: string
38
+ recentreLabel?: string
39
+ }>(),
40
+ {
41
+ tiles: () => OSM_TILES,
42
+ zoom: 13,
43
+ center: () => ({ lat: 50.11, lon: 14.44 }),
44
+ height: "20rem",
45
+ locatable: true,
46
+ zoomInLabel: "Zoom in",
47
+ zoomOutLabel: "Zoom out",
48
+ locateLabel: "Use my location",
49
+ recentreLabel: "Back to the pin",
50
+ },
51
+ )
52
+
53
+ const emit = defineEmits<{ (e: "update:modelValue", value: LatLon): void }>()
54
+
55
+ const PAN_STEP = 80
56
+ const ZOOM_MS = 220
57
+
58
+ /*
59
+ * Tiles already fetched this session, by URL. The browser's HTTP cache stops
60
+ * the network request, but a fresh <img> still fades in from nothing — which
61
+ * looks like a reload every time you zoom back to a level you've seen. Tracking
62
+ * them here lets a known tile paint at full opacity straight away. Module-level
63
+ * on purpose: two pickers, or one remounted inside a dialog, share the benefit.
64
+ */
65
+ const seenTiles = new Set<string>()
66
+
67
+ const tileSize = computed(() => props.tiles.tileSize ?? 256)
68
+ const minZoom = computed(() => props.tiles.minZoom ?? 1)
69
+ const maxZoom = computed(() => props.tiles.maxZoom ?? 19)
70
+
71
+ const viewport = ref<HTMLElement | null>(null)
72
+ const viewWidth = ref(0)
73
+ const viewHeight = ref(0)
74
+ const level = ref(clampZoom(props.zoom))
75
+ const mapCenter = ref<LatLon>(props.modelValue ?? props.center)
76
+
77
+ // True for the length of a zoom, which is the only time the layers' transforms
78
+ // are allowed to transition — during a pan they change every frame and easing
79
+ // them would lag the map behind the cursor.
80
+ const zooming = ref(false)
81
+ let zoomTimer: ReturnType<typeof setTimeout> | undefined
82
+ let pruneTimer: ReturnType<typeof setTimeout> | undefined
83
+
84
+ function clampZoom(value: number) {
85
+ return Math.round(Math.min(maxZoom.value, Math.max(minZoom.value, value)))
86
+ }
87
+
88
+ /** World-pixel coordinate of the viewport's top-left corner, at `level`. */
89
+ const origin = computed(() => {
90
+ const point = project(mapCenter.value.lat, mapCenter.value.lon, level.value, tileSize.value)
91
+ return { x: point.x - viewWidth.value / 2, y: point.y - viewHeight.value / 2 }
92
+ })
93
+
94
+ interface Tile {
95
+ key: string
96
+ url: string
97
+ left: number
98
+ top: number
99
+ loaded: boolean
100
+ }
101
+
102
+ /*
103
+ * One zoom level's worth of tiles. Each layer keeps its own anchor — a whole
104
+ * tile near the view it was built for — and every tile offset is measured from
105
+ * it, so panning never touches a tile's style: only the layer's translate
106
+ * moves.
107
+ *
108
+ * Zooming pushes a new layer instead of replacing the old one. The outgoing
109
+ * level stays mounted underneath, scaled to match, until the incoming one has
110
+ * painted — otherwise every zoom flashes the background through the gap while
111
+ * the new tiles are still in flight.
112
+ */
113
+ interface Layer {
114
+ id: number
115
+ level: number
116
+ anchor: { x: number; y: number }
117
+ tiles: Tile[]
118
+ range: { firstX: number; firstY: number; lastX: number; lastY: number }
119
+ }
120
+
121
+ const layers = ref<Layer[]>([])
122
+ let nextLayerId = 1
123
+
124
+ const active = computed<Layer | undefined>(() => layers.value[layers.value.length - 1])
125
+
126
+ function anchorFor(atLevel: number) {
127
+ const size = tileSize.value
128
+ const factor = 2 ** (atLevel - level.value)
129
+ return {
130
+ x: Math.floor((origin.value.x * factor) / size) * size,
131
+ y: Math.floor((origin.value.y * factor) / size) * size,
132
+ }
133
+ }
134
+
135
+ function pushLayer() {
136
+ layers.value = [
137
+ ...layers.value,
138
+ {
139
+ id: nextLayerId++,
140
+ level: level.value,
141
+ anchor: anchorFor(level.value),
142
+ tiles: [],
143
+ range: { firstX: 0, firstY: 0, lastX: -1, lastY: -1 },
144
+ },
145
+ ]
146
+ refreshTiles()
147
+ }
148
+
149
+ /** Drop everything under the active layer once it has something to show. */
150
+ function pruneLayers() {
151
+ if (layers.value.length < 2) return
152
+ layers.value = [layers.value[layers.value.length - 1]]
153
+ }
154
+
155
+ function reanchor() {
156
+ const layer = active.value
157
+ if (!layer) return
158
+ const next = anchorFor(layer.level)
159
+ const dx = layer.anchor.x - next.x
160
+ const dy = layer.anchor.y - next.y
161
+ if (!dx && !dy) return
162
+ layer.anchor = next
163
+ // Offsets are measured from the anchor, so moving it has to move them by the
164
+ // same amount or the grid jumps.
165
+ for (const tile of layer.tiles) {
166
+ tile.left += dx
167
+ tile.top += dy
168
+ }
169
+ }
170
+
171
+ /** Rebuild the visible set. Existing tiles keep their identity (and their
172
+ * loaded flag), so a pan only appends the row or column that came into view. */
173
+ /** Rebuild the active layer's tiles. Mounted tiles keep their identity (and
174
+ * their loaded flag), so a pan only appends the row that came into view. */
175
+ function refreshTiles() {
176
+ const layer = active.value
177
+ if (!layer || !viewWidth.value || !viewHeight.value) return
178
+ const size = tileSize.value
179
+ const count = 2 ** layer.level
180
+ // A margin of one tile all round means the edge is already painted by the
181
+ // time a drag exposes it.
182
+ const firstX = Math.floor(origin.value.x / size) - 1
183
+ const firstY = Math.floor(origin.value.y / size) - 1
184
+ const lastX = Math.floor((origin.value.x + viewWidth.value) / size) + 1
185
+ const lastY = Math.floor((origin.value.y + viewHeight.value) / size) + 1
186
+
187
+ // Most frames of a drag stay inside the tiles already mounted; rebuilding the
188
+ // list for those would hand Vue a fresh array 60 times a second for nothing.
189
+ const seen = layer.range
190
+ if (
191
+ seen.firstX === firstX &&
192
+ seen.firstY === firstY &&
193
+ seen.lastX === lastX &&
194
+ seen.lastY === lastY
195
+ ) {
196
+ return
197
+ }
198
+ layer.range = { firstX, firstY, lastX, lastY }
199
+
200
+ const previous = new Map(layer.tiles.map((tile) => [tile.key, tile]))
201
+ const next: Tile[] = []
202
+ for (let y = firstY; y <= lastY; y++) {
203
+ // No tile rows exist past the poles; the x axis wraps instead, so the map
204
+ // keeps going east and west forever.
205
+ if (y < 0 || y >= count) continue
206
+ for (let x = firstX; x <= lastX; x++) {
207
+ const wrapped = ((x % count) + count) % count
208
+ const key = `${layer.level}/${x}/${y}`
209
+ const existing = previous.get(key)
210
+ if (existing) {
211
+ next.push(existing)
212
+ continue
213
+ }
214
+ const url = tileUrl(props.tiles, layer.level, wrapped, y)
215
+ next.push({
216
+ key,
217
+ url,
218
+ left: x * size - layer.anchor.x,
219
+ top: y * size - layer.anchor.y,
220
+ // Anything fetched before paints at once: replaying the fade on a
221
+ // cached tile is what makes a zoom look like a reload.
222
+ loaded: seenTiles.has(url),
223
+ })
224
+ }
225
+ }
226
+ layer.tiles = next
227
+ }
228
+
229
+ const loadingTiles = computed(() => active.value?.tiles.some((tile) => !tile.loaded) ?? false)
230
+
231
+ function onTileLoad(tile: Tile) {
232
+ tile.loaded = true
233
+ seenTiles.add(tile.url)
234
+ // The outgoing level is only worth keeping until the new one has painted.
235
+ if (!loadingTiles.value) pruneLayers()
236
+ }
237
+
238
+ /*
239
+ * Where a layer sits right now. Its own level's pixels are scaled to the
240
+ * current level's, then translated — so an older layer under the active one
241
+ * lines up exactly and, once `zooming` turns the transition on, glides into
242
+ * place instead of cutting.
243
+ */
244
+ function layerStyle(layer: Layer) {
245
+ const factor = 2 ** (layer.level - level.value)
246
+ const originX = origin.value.x * factor
247
+ const originY = origin.value.y * factor
248
+ return {
249
+ transform: `scale(${1 / factor}) translate3d(${layer.anchor.x - originX}px, ${layer.anchor.y - originY}px, 0)`,
250
+ transformOrigin: "0 0",
251
+ transition: zooming.value
252
+ ? "transform var(--duration-medium) var(--ease-emphasized)"
253
+ : undefined,
254
+ }
255
+ }
256
+
257
+ /** Pin position in viewport pixels — it belongs to the map, not to a layer. */
258
+ const pin = computed(() => {
259
+ if (!props.modelValue) return null
260
+ const point = project(props.modelValue.lat, props.modelValue.lon, level.value, tileSize.value)
261
+ return { left: point.x - origin.value.x, top: point.y - origin.value.y }
262
+ })
263
+
264
+ function viewportPoint(e: { clientX: number; clientY: number }) {
265
+ const rect = viewport.value?.getBoundingClientRect()
266
+ return rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: 0, y: 0 }
267
+ }
268
+
269
+ function toLatLon(at: { x: number; y: number }): LatLon {
270
+ return unproject(origin.value.x + at.x, origin.value.y + at.y, level.value, tileSize.value)
271
+ }
272
+
273
+ const panning = ref(false)
274
+ // A drag is a pan, not a pick — track whether the pointer actually travelled so
275
+ // releasing after a drag doesn't also drop the pin.
276
+ let moved = false
277
+ let last = { x: 0, y: 0 }
278
+ // Pointer velocity in px/ms, smoothed so one jittery frame at the end of a
279
+ // flick doesn't decide where the map coasts to.
280
+ let velocity = { x: 0, y: 0 }
281
+ let lastMove = 0
282
+ let glideFrame: number | undefined
283
+
284
+ function stopGlide() {
285
+ if (glideFrame !== undefined) cancelAnimationFrame(glideFrame)
286
+ glideFrame = undefined
287
+ velocity = { x: 0, y: 0 }
288
+ }
289
+
290
+ function onPointerDown(e: PointerEvent) {
291
+ if (props.disabled || e.button !== 0 || zooming.value) return
292
+ stopGlide()
293
+ panning.value = true
294
+ moved = false
295
+ last = { x: e.clientX, y: e.clientY }
296
+ lastMove = performance.now()
297
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
298
+ }
299
+
300
+ function onPointerMove(e: PointerEvent) {
301
+ if (!panning.value) return
302
+ const dx = e.clientX - last.x
303
+ const dy = e.clientY - last.y
304
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true
305
+
306
+ const now = performance.now()
307
+ const dt = now - lastMove
308
+ if (dt > 0) {
309
+ velocity = {
310
+ x: velocity.x * 0.7 + (dx / dt) * 0.3,
311
+ y: velocity.y * 0.7 + (dy / dt) * 0.3,
312
+ }
313
+ }
314
+ lastMove = now
315
+ last = { x: e.clientX, y: e.clientY }
316
+ panBy(-dx, -dy)
317
+ }
318
+
319
+ function onPointerUp(e: PointerEvent) {
320
+ if (!panning.value) return
321
+ panning.value = false
322
+ ;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
323
+ if (!moved && !props.disabled) {
324
+ emit("update:modelValue", toLatLon(viewportPoint(e)))
325
+ return
326
+ }
327
+ // Nothing to coast from if the pointer was resting when it lifted.
328
+ if (performance.now() - lastMove > 90) velocity = { x: 0, y: 0 }
329
+ glide()
330
+ }
331
+
332
+ /*
333
+ * Momentum: keep panning after release and bleed the speed off exponentially,
334
+ * so letting go of a flick drifts to a stop instead of stopping dead. Time-based
335
+ * rather than per-frame, which keeps the feel identical at 60 and 144 Hz.
336
+ */
337
+ function glide() {
338
+ if (reducedMotion() || Math.hypot(velocity.x, velocity.y) < 0.05) {
339
+ velocity = { x: 0, y: 0 }
340
+ return
341
+ }
342
+ let previous = performance.now()
343
+ const step = (now: number) => {
344
+ const dt = Math.min(now - previous, 32)
345
+ previous = now
346
+ const friction = Math.exp(-dt / 240)
347
+ velocity = { x: velocity.x * friction, y: velocity.y * friction }
348
+ panBy(-velocity.x * dt, -velocity.y * dt)
349
+ if (Math.hypot(velocity.x, velocity.y) > 0.015) {
350
+ glideFrame = requestAnimationFrame(step)
351
+ } else {
352
+ stopGlide()
353
+ }
354
+ }
355
+ glideFrame = requestAnimationFrame(step)
356
+ }
357
+
358
+ function reducedMotion() {
359
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
360
+ }
361
+
362
+ function panBy(dx: number, dy: number) {
363
+ mapCenter.value = unproject(
364
+ origin.value.x + dx + viewWidth.value / 2,
365
+ origin.value.y + dy + viewHeight.value / 2,
366
+ level.value,
367
+ tileSize.value,
368
+ )
369
+ refreshTiles()
370
+ }
371
+
372
+ /*
373
+ * Zoom: adopt the new level immediately and stack a layer for it. The layers
374
+ * below still describe the old level, so their transforms change too — with
375
+ * `zooming` on, that change is a transition, which is what makes the outgoing
376
+ * tiles scale smoothly under the arriving ones rather than blink out.
377
+ */
378
+ function zoomBy(delta: number, at?: { x: number; y: number }) {
379
+ if (props.disabled) return
380
+ const target = clampZoom(level.value + delta)
381
+ if (target === level.value) return
382
+ stopGlide()
383
+
384
+ const point = at ?? { x: viewWidth.value / 2, y: viewHeight.value / 2 }
385
+ const ground = toLatLon(point)
386
+ // Recentre so the ground under the anchor point stays under it afterwards.
387
+ const world = project(ground.lat, ground.lon, target, tileSize.value)
388
+
389
+ level.value = target
390
+ mapCenter.value = unproject(
391
+ world.x - point.x + viewWidth.value / 2,
392
+ world.y - point.y + viewHeight.value / 2,
393
+ target,
394
+ tileSize.value,
395
+ )
396
+
397
+ zooming.value = true
398
+ pushLayer()
399
+
400
+ clearTimeout(zoomTimer)
401
+ zoomTimer = setTimeout(() => (zooming.value = false), ZOOM_MS)
402
+ // Backstop: a tile that never loads (offline, 404) must not leave the old
403
+ // level stacked underneath forever.
404
+ clearTimeout(pruneTimer)
405
+ pruneTimer = setTimeout(pruneLayers, 1200)
406
+ }
407
+
408
+ function onWheel(e: WheelEvent) {
409
+ if (props.disabled) return
410
+ e.preventDefault()
411
+ zoomBy(e.deltaY < 0 ? 1 : -1, viewportPoint(e))
412
+ }
413
+
414
+ function onDoubleClick(e: MouseEvent) {
415
+ zoomBy(1, viewportPoint(e))
416
+ }
417
+
418
+ function onKeydown(e: KeyboardEvent) {
419
+ if (props.disabled) return
420
+ switch (e.key) {
421
+ case "ArrowUp":
422
+ e.preventDefault()
423
+ panBy(0, -PAN_STEP)
424
+ break
425
+ case "ArrowDown":
426
+ e.preventDefault()
427
+ panBy(0, PAN_STEP)
428
+ break
429
+ case "ArrowLeft":
430
+ e.preventDefault()
431
+ panBy(-PAN_STEP, 0)
432
+ break
433
+ case "ArrowRight":
434
+ e.preventDefault()
435
+ panBy(PAN_STEP, 0)
436
+ break
437
+ case "+":
438
+ case "=":
439
+ e.preventDefault()
440
+ zoomBy(1)
441
+ break
442
+ case "-":
443
+ e.preventDefault()
444
+ zoomBy(-1)
445
+ break
446
+ case "0":
447
+ e.preventDefault()
448
+ recentre()
449
+ break
450
+ }
451
+ }
452
+
453
+ const canRecentre = computed(() => {
454
+ if (!pin.value) return false
455
+ const { left, top } = pin.value
456
+ return left < 0 || top < 0 || left > viewWidth.value || top > viewHeight.value
457
+ })
458
+
459
+ function recentre() {
460
+ if (!props.modelValue) return
461
+ stopGlide()
462
+ mapCenter.value = props.modelValue
463
+ reanchor()
464
+ refreshTiles()
465
+ }
466
+
467
+ const locating = ref(false)
468
+
469
+ function locate() {
470
+ if (!navigator.geolocation || locating.value) return
471
+ locating.value = true
472
+ navigator.geolocation.getCurrentPosition(
473
+ (position) => {
474
+ locating.value = false
475
+ const next = { lat: position.coords.latitude, lon: position.coords.longitude }
476
+ emit("update:modelValue", next)
477
+ mapCenter.value = next
478
+ reanchor()
479
+ refreshTiles()
480
+ },
481
+ () => {
482
+ locating.value = false
483
+ },
484
+ { enableHighAccuracy: true, timeout: 10_000 },
485
+ )
486
+ }
487
+
488
+ // A point set from outside (a suggestion picked in the form, say) recentres the
489
+ // map; panning afterwards must not yank it back, so only the value is watched.
490
+ watch(
491
+ () => props.modelValue,
492
+ (value) => {
493
+ if (!value || panning.value) return
494
+ mapCenter.value = value
495
+ if (level.value < props.zoom) {
496
+ level.value = clampZoom(props.zoom)
497
+ pushLayer()
498
+ return
499
+ }
500
+ reanchor()
501
+ refreshTiles()
502
+ },
503
+ )
504
+
505
+ watch(
506
+ () => props.tiles,
507
+ () => {
508
+ layers.value = []
509
+ pushLayer()
510
+ },
511
+ )
512
+
513
+ let observer: ResizeObserver | undefined
514
+
515
+ onMounted(() => {
516
+ const el = viewport.value
517
+ if (!el) return
518
+ observer = new ResizeObserver(([entry]) => {
519
+ viewWidth.value = entry.contentRect.width
520
+ viewHeight.value = entry.contentRect.height
521
+ if (!layers.value.length) {
522
+ pushLayer()
523
+ return
524
+ }
525
+ reanchor()
526
+ refreshTiles()
527
+ })
528
+ observer.observe(el)
529
+ })
530
+
531
+ onBeforeUnmount(() => {
532
+ observer?.disconnect()
533
+ clearTimeout(zoomTimer)
534
+ clearTimeout(pruneTimer)
535
+ stopGlide()
536
+ })
537
+
538
+ defineExpose({ zoomIn: () => zoomBy(1), zoomOut: () => zoomBy(-1), recentre, locate })
539
+
540
+ const CONTROL =
541
+ "flex size-8 items-center justify-center rounded-control bg-black/45 text-white/90 outline-none backdrop-blur-sm transition-[background-color,scale] duration-[var(--duration-fast)] hover:bg-black/65 hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
542
+ </script>
543
+
544
+ <template>
545
+ <div
546
+ class="relative overflow-hidden rounded-control border border-line bg-surface-soft"
547
+ :style="{ height }"
548
+ >
549
+ <div
550
+ ref="viewport"
551
+ class="absolute inset-0 touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-ring"
552
+ :class="disabled ? 'cursor-not-allowed opacity-55' : panning ? 'cursor-grabbing' : 'cursor-grab'"
553
+ :tabindex="disabled ? -1 : 0"
554
+ role="application"
555
+ :aria-label="recentreLabel"
556
+ @pointerdown="onPointerDown"
557
+ @pointermove="onPointerMove"
558
+ @pointerup="onPointerUp"
559
+ @pointercancel="onPointerUp"
560
+ @wheel="onWheel"
561
+ @dblclick="onDoubleClick"
562
+ @keydown="onKeydown"
563
+ >
564
+ <!-- Oldest level first, so an arriving one paints over the one it
565
+ replaces and the swap has no gap to show through. -->
566
+ <div
567
+ v-for="layer in layers"
568
+ :key="layer.id"
569
+ class="absolute left-0 top-0 will-change-transform motion-reduce:transition-none"
570
+ :style="layerStyle(layer)"
571
+ >
572
+ <img
573
+ v-for="tile in layer.tiles"
574
+ :key="tile.key"
575
+ :src="tile.url"
576
+ alt=""
577
+ draggable="false"
578
+ class="absolute max-w-none"
579
+ :class="tile.loaded ? '' : 'animate-[map-tile-in_var(--duration-medium)_ease] motion-reduce:animate-none'"
580
+ :style="{
581
+ left: `${tile.left}px`,
582
+ top: `${tile.top}px`,
583
+ width: `${tileSize}px`,
584
+ height: `${tileSize}px`,
585
+ }"
586
+ @load="onTileLoad(tile)"
587
+ />
588
+ </div>
589
+
590
+ <span
591
+ v-if="pin"
592
+ class="pointer-events-none absolute"
593
+ :style="{ left: `${pin.left}px`, top: `${pin.top}px`, transform: 'translate(-50%, -100%)' }"
594
+ >
595
+ <LpIcon
596
+ name="lucide:map-pin"
597
+ :size="30"
598
+ class="block animate-[map-pin-in_var(--duration-medium)_var(--ease-emphasized)] text-danger drop-shadow-[0_2px_4px_rgba(0,0,0,0.45)] motion-reduce:animate-none"
599
+ />
600
+ </span>
601
+ </div>
602
+
603
+ <div class="absolute right-2 top-2 flex flex-col gap-1.5">
604
+ <button
605
+ :class="CONTROL"
606
+ type="button"
607
+ :disabled="disabled || level >= maxZoom"
608
+ :title="zoomInLabel"
609
+ :aria-label="zoomInLabel"
610
+ @click="zoomBy(1)"
611
+ >
612
+ <LpIcon name="lucide:plus" :size="16" />
613
+ </button>
614
+ <button
615
+ :class="CONTROL"
616
+ type="button"
617
+ :disabled="disabled || level <= minZoom"
618
+ :title="zoomOutLabel"
619
+ :aria-label="zoomOutLabel"
620
+ @click="zoomBy(-1)"
621
+ >
622
+ <LpIcon name="lucide:minus" :size="16" />
623
+ </button>
624
+ <button
625
+ v-if="canRecentre"
626
+ :class="CONTROL"
627
+ type="button"
628
+ :title="recentreLabel"
629
+ :aria-label="recentreLabel"
630
+ @click="recentre"
631
+ >
632
+ <LpIcon name="lucide:locate-fixed" :size="16" />
633
+ </button>
634
+ <button
635
+ v-if="locatable"
636
+ :class="CONTROL"
637
+ type="button"
638
+ :disabled="disabled || locating"
639
+ :title="locateLabel"
640
+ :aria-label="locateLabel"
641
+ @click="locate"
642
+ >
643
+ <LpIcon
644
+ :name="locating ? 'lucide:loader-circle' : 'lucide:crosshair'"
645
+ :size="16"
646
+ :class="locating ? 'animate-spin' : ''"
647
+ />
648
+ </button>
649
+ </div>
650
+
651
+ <LpIcon
652
+ v-if="loadingTiles"
653
+ name="lucide:loader-circle"
654
+ :size="16"
655
+ class="pointer-events-none absolute left-2 top-2 animate-spin text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]"
656
+ />
657
+
658
+ <p
659
+ v-if="props.tiles.attribution"
660
+ class="pointer-events-none absolute bottom-0 right-0 rounded-tl-control bg-black/45 px-1.5 py-0.5 text-[10px] text-white/75 backdrop-blur-sm"
661
+ >
662
+ {{ props.tiles.attribution }}
663
+ </p>
664
+ </div>
665
+ </template>