@leavepulse/ui 0.16.0 → 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.
- package/dist/chunks/LpAddressInput-D29vSoNd.js +366 -0
- package/dist/chunks/{LpConfirmDialog-C-iOXJIM.js → LpConfirmDialog-DnReh5pF.js} +1 -1
- package/dist/chunks/LpMapPicker-DFKrd1Ne.js +509 -0
- package/dist/chunks/{LpModal-DeZlvK1B.js → LpModal-CezA8eJ3.js} +9 -6
- package/dist/chunks/{LpNotificationBell-BtfRoL2i.js → LpNotificationBell-CDixx44E.js} +25 -12
- package/dist/chunks/{LpSelect-DznhPEWX.js → LpSelect-D5mU4cZO.js} +5 -1
- package/dist/chunks/{LpThemeSwitcher-BgWwfkxs.js → LpThemeSwitcher-DC6QygnU.js} +11 -3
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAddressInput.vue.d.ts +53 -0
- package/dist/components/LpAddressInput.vue.js +4 -0
- package/dist/components/LpAlert.vue.d.ts +1 -1
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpFileTree.vue.d.ts +2 -2
- package/dist/components/LpLightbox.vue.d.ts +1 -1
- package/dist/components/LpMapPicker.vue.d.ts +44 -0
- package/dist/components/LpMapPicker.vue.js +4 -0
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +22 -0
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.d.ts +7 -0
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/address.d.ts +107 -0
- package/dist/components/address.js +96 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +129 -117
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpAddressInput.vue +377 -0
- package/src/components/LpMapPicker.vue +665 -0
- package/src/components/LpModal.vue +15 -0
- package/src/components/LpNotificationBell.vue +47 -9
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpThemeSwitcher.vue +24 -2
- package/src/components/address.ts +250 -0
- package/src/index.ts +26 -1
- package/src/theme/presets.ts +8 -2
- package/src/tokens/tokens.css +18 -0
|
@@ -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>
|
|
@@ -37,6 +37,13 @@ const props = withDefaults(
|
|
|
37
37
|
|
|
38
38
|
defineEmits<{ (e: "update:open", value: boolean): void }>()
|
|
39
39
|
|
|
40
|
+
// Spread rather than a plain `:aria-describedby` binding: with a description
|
|
41
|
+
// the key must be absent entirely so reka-ui's own generated id survives, and
|
|
42
|
+
// only the description-less case overrides it away.
|
|
43
|
+
const describedByAttrs = computed(() =>
|
|
44
|
+
props.description ? {} : { "aria-describedby": undefined },
|
|
45
|
+
)
|
|
46
|
+
|
|
40
47
|
const widthClass = computed(() => {
|
|
41
48
|
if (props.width) return ""
|
|
42
49
|
return {
|
|
@@ -85,10 +92,18 @@ const bodyPad = computed(() =>
|
|
|
85
92
|
box it is free to overflow, so a tall body stopped handing its
|
|
86
93
|
overflow to the scroll area below and simply ran off-screen. -->
|
|
87
94
|
<div class="fixed inset-0 z-(--z-modal) flex items-center justify-center pointer-events-none">
|
|
95
|
+
<!-- Without a description reka-ui warns on every open, and the opt-out it
|
|
96
|
+
checks for is an ABSENT `aria-describedby` — despite the message
|
|
97
|
+
naming the string "undefined" (a leftover from Radix, where
|
|
98
|
+
`aria-describedby={undefined}` is how you drop the attribute).
|
|
99
|
+
Dropping it here is what silences the warning; a caller that does
|
|
100
|
+
pass a description keeps the generated id and the link to
|
|
101
|
+
DialogDescription. -->
|
|
88
102
|
<DialogContent
|
|
89
103
|
class="pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]"
|
|
90
104
|
:class="widthClass"
|
|
91
105
|
:style="width ? { width } : undefined"
|
|
106
|
+
v-bind="describedByAttrs"
|
|
92
107
|
>
|
|
93
108
|
<header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
|
|
94
109
|
<div class="flex flex-col gap-1">
|