Skip to content

Commit 8d9e3b8

Browse files
committed
fix: viewport safe areas, dvh units, drawer overflow
1 parent 1e13867 commit 8d9e3b8

File tree

11 files changed

+327
-123
lines changed

11 files changed

+327
-123
lines changed

app/app.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
</template>
44

55
<style>
6+
html,
67
body {
8+
height: 100dvh;
9+
overflow: hidden;
10+
overscroll-behavior: none;
711
color-scheme: light;
812
}
913
@@ -57,4 +61,9 @@ body {
5761
opacity: 0;
5862
}
5963
}
64+
65+
/* Hide vaul-vue pseudo-element that causes white borders during close animation */
66+
[data-vaul-drawer][data-vaul-drawer-direction='bottom']::after {
67+
background: transparent !important;
68+
}
6069
</style>

app/components/LocationDrawer.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,15 @@ function handleClose() {
7272
<DrawerPortal>
7373
<div
7474
v-if="showOverlay"
75-
bg="neutral/20" inset-0 fixed z-40
75+
bg="neutral/20" inset-0 fixed z-70
7676
:style="{
7777
opacity: overlayOpacity,
7878
transition: 'opacity 500ms cubic-bezier(0.32, 0.72, 0, 1)',
7979
}"
8080
@click="handleClose"
8181
/>
82-
<DrawerContent flex="~ col" shadow="[0_-4px_24px_rgba(0,0,0,0.1)]" outline-none rounded-t-10 bg-neutral-0 h-full max-h-95vh inset-x-0 bottom-0 fixed z-50>
83-
<DrawerHandle my-8 shrink-0 />
82+
<DrawerContent flex="~ col" shadow="[0_-4px_24px_rgba(0,0,0,0.1)]" max-h="[calc(100dvh-env(safe-area-inset-top))]" outline-none rounded-t-10 bg-neutral-0 h-full w-full inset-x-0 bottom-0 fixed z-80>
83+
<DrawerHandle left="1/2" translate-x="-1/2" m-0 bg-neutral-400 h-6 top-8 absolute z-30 />
8484
<div v-if="selectedLocation" flex-1 min-h-0 of-hidden>
8585
<LocationDrawerContent
8686
:key="selectedLocation.uuid"

app/components/LocationDrawerContent.vue

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,10 @@ const { addressRef, showCopiedTooltip } = useAddressCopy()
9797
</script>
9898

9999
<template>
100-
<div bg-neutral-0 w-full h-full relative of-hidden flex="~ col">
100+
<div bg-neutral-0 h-full w-full relative of-hidden flex="~ col">
101101
<!-- Scrollable content -->
102-
<div of-y-auto of-x-hidden flex-1 :class="isCompact ? 'max-h-[calc(450px-32px)]' : ''">
103-
<header bg-neutral-0 relative f-px-md>
102+
<div flex-1 of-x-hidden of-y-auto :class="isCompact ? 'max-h-[calc(450px-32px)]' : ''">
103+
<header pt-36 bg-neutral-0 relative f-px-md>
104104
<!-- Close Button -->
105105
<div flex="~ shrink-0 gap-8" right-16 top-4 absolute z-20>
106106
<button bg="neutral-500 hocus:neutral-600" stack rounded-full shrink-0 size-24 transition-colors @click.stop="emit('close')">
@@ -109,7 +109,7 @@ const { addressRef, showCopiedTooltip } = useAddressCopy()
109109
</div>
110110

111111
<!-- Title -->
112-
<h2 leading-tight font-bold my-0 pr-40 pt-8 line-clamp-2 text="f-xl neutral">
112+
<h2 leading-tight font-bold my-0 pr-40 line-clamp-2 text="f-xl neutral">
113113
{{ location.name }}
114114
</h2>
115115

@@ -135,20 +135,20 @@ const { addressRef, showCopiedTooltip } = useAddressCopy()
135135
</div>
136136

137137
<!-- Action Buttons -->
138-
<div flex="~ gap-8" mt-12 of-x-auto nq-scrollbar-hide mx--16 px-16>
139-
<NuxtLink :to="directionsUrl" target="_blank" outline="1.5 neutral-0/15 offset--1.5" external shadow nq-arrow nq-pill nq-pill-blue shrink-0 @click.stop>
138+
<div flex="~ gap-8" mx--16 mt-12 px-16 of-x-auto nq-scrollbar-hide>
139+
<NuxtLink :to="directionsUrl" target="_blank" outline="1.5 neutral-0/15 offset--1.5" external shrink-0 shadow nq-arrow nq-pill nq-pill-blue @click.stop>
140140
<Icon name="i-tabler:directions" size-16 />
141141
{{ t('location.directions') }}
142142
</NuxtLink>
143-
<NuxtLink v-if="location.gmapsUrl" :to="location.gmapsUrl" target="_blank" external nq-arrow nq-pill nq-pill-secondary outline="1.5 neutral-0/20 offset--1.5" shrink-0 @click.stop>
143+
<NuxtLink v-if="location.gmapsUrl" :to="location.gmapsUrl" target="_blank" outline="1.5 neutral-0/20 offset--1.5" external shrink-0 nq-arrow nq-pill nq-pill-secondary @click.stop>
144144
{{ t('location.openInGoogleMaps') }}
145145
</NuxtLink>
146146
</div>
147147
</header>
148148

149149
<PhotoCarousel v-if="location.gmapsPlaceId" :uuid="location.uuid" not-empty:mx--8 not-empty:pt-24 />
150150

151-
<div mt-24 f-px-md pb-24>
151+
<div mt-24 f-px-md pb="[max(24px,env(safe-area-inset-bottom))]">
152152
<!-- Weekly Hours -->
153153
<div v-if="rotatedHours" w-full space-y-8>
154154
<div v-for="(hours, idx) in rotatedHours.hours" :key="idx" flex="~ items-center gap-8" text-14>

app/components/MapControls.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<script setup lang="ts">
22
const { zoomIn, zoomOut, flyTo, bearing, isRotated, resetNorth } = useMapControls()
33
const { hasPointer } = usePointerType()
4-
const { isLocating, locateMe, gpsPoint, showUserLocation } = useUserLocation()
4+
const { isLocating, locateMe, gpsPoint, gpsAccuracy, showUserLocation } = useUserLocation()
55
66
function handleLocateMe() {
77
locateMe()
88
watchOnce(gpsPoint, (point) => {
99
if (point)
10-
flyTo(point)
10+
flyTo(point, { accuracyMeters: gpsAccuracy.value ?? undefined })
1111
})
1212
}
1313
</script>

app/composables/useMapControls.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,17 @@ function calculateZoomForAccuracy(lat: number, accuracyMeters: number, viewportS
2727

2828
function useMapControlsBase() {
2929
const mapInstance = ref<Map | null>(null)
30-
const { initialPoint, initialAccuracy } = useUserLocation()
30+
const { initialPoint, initialAccuracy, queryZoom, queryBearing, queryPitch } = useUserLocation()
3131

3232
// Start with null center - will be set once we know optimal zoom
3333
const initialCenter = ref<[number, number] | null>(null)
3434
const initialZoom = ref(DEFAULT_ZOOM)
3535
const isInitialized = ref(false)
3636

37+
// Initial bearing/pitch from URL params
38+
const initialBearing = ref(0)
39+
const initialPitch = ref(0)
40+
3741
// View center tracks current map position (for search API)
3842
const viewCenter = ref<{ lat: number, lng: number }>({ lat: 0, lng: 0 })
3943
const zoom = ref(DEFAULT_ZOOM)
@@ -54,19 +58,27 @@ function useMapControlsBase() {
5458
}
5559

5660
const accuracy = initialAccuracy.value
57-
if (accuracy) {
58-
// Calculate zoom to fit the accuracy circle (like main map's fitBounds on accuracy circle)
61+
initialCenter.value = [point.lng, point.lat]
62+
63+
// Use URL zoom if provided, otherwise calculate from accuracy or use default
64+
const urlZoom = queryZoom.value
65+
if (urlZoom !== undefined && !Number.isNaN(urlZoom)) {
66+
initialZoom.value = urlZoom
67+
}
68+
else if (accuracy) {
5969
const minViewportSize = Math.min(viewportWidth, viewportHeight)
60-
const zoom = calculateZoomForAccuracy(point.lat, accuracy, minViewportSize)
61-
initialCenter.value = [point.lng, point.lat]
62-
initialZoom.value = zoom
70+
initialZoom.value = calculateZoomForAccuracy(point.lat, accuracy, minViewportSize)
6371
}
6472
else {
65-
// No accuracy (e.g., query params) - use reasonable default
66-
initialCenter.value = [point.lng, point.lat]
6773
initialZoom.value = 10
6874
}
6975

76+
// Set bearing/pitch from URL params
77+
const urlBearing = queryBearing.value
78+
const urlPitch = queryPitch.value
79+
initialBearing.value = urlBearing !== undefined && !Number.isNaN(urlBearing) ? urlBearing : 0
80+
initialPitch.value = urlPitch !== undefined && !Number.isNaN(urlPitch) ? urlPitch : 0
81+
7082
isInitialized.value = true
7183
}
7284

@@ -106,8 +118,19 @@ function useMapControlsBase() {
106118
mapInstance.value?.zoomOut()
107119
}
108120

109-
function flyTo(point: Point, zoomLevel = 16) {
110-
mapInstance.value?.flyTo({ center: [point.lng, point.lat], zoom: zoomLevel, duration: 1000 })
121+
function flyTo(point: Point, options?: { zoom?: number, accuracyMeters?: number }) {
122+
const map = mapInstance.value
123+
if (!map)
124+
return
125+
126+
let zoomLevel = options?.zoom ?? 16
127+
if (options?.accuracyMeters) {
128+
const container = map.getContainer()
129+
const minViewportSize = Math.min(container.clientWidth, container.clientHeight)
130+
zoomLevel = calculateZoomForAccuracy(point.lat, options.accuracyMeters, minViewportSize)
131+
}
132+
133+
map.flyTo({ center: [point.lng, point.lat], zoom: zoomLevel, duration: 1000 })
111134
}
112135

113136
function resetNorth() {
@@ -120,6 +143,8 @@ function useMapControlsBase() {
120143
mapInstance,
121144
initialCenter,
122145
initialZoom,
146+
initialBearing,
147+
initialPitch,
123148
isInitialized,
124149
initializeView,
125150
viewCenter,

app/composables/useMapUrl.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useDebounceFn } from '@vueuse/core'
2+
3+
// Parse #@lat,lng,zoomz format from hash
4+
function parseMapHash(hash: string): { lat?: number, lng?: number, zoom?: number, bearing?: number, pitch?: number } {
5+
const match = hash.match(/^#@(-?\d+(?:\.\d*)?),(-?\d+(?:\.\d*)?),(\d+(?:\.\d*)?)z(?:,(\d+(?:\.\d*)?)b)?(?:,(\d+(?:\.\d*)?)p)?/)
6+
if (!match || !match[1] || !match[2] || !match[3])
7+
return {}
8+
return {
9+
lat: Number.parseFloat(match[1]),
10+
lng: Number.parseFloat(match[2]),
11+
zoom: Number.parseFloat(match[3]),
12+
bearing: match[4] ? Number.parseFloat(match[4]) : undefined,
13+
pitch: match[5] ? Number.parseFloat(match[5]) : undefined,
14+
}
15+
}
16+
17+
// Build hash in #@lat,lng,zoomz format
18+
function buildMapHash(lat: number, lng: number, zoom: number, bearing?: number, pitch?: number): string {
19+
const latStr = lat.toFixed(5)
20+
const lngStr = lng.toFixed(5)
21+
const zoomStr = zoom.toFixed(2).replace(/\.?0+$/, '')
22+
let hash = `#@${latStr},${lngStr},${zoomStr}z`
23+
if (bearing && bearing !== 0)
24+
hash += `,${bearing.toFixed(0)}b`
25+
if (pitch && pitch !== 0)
26+
hash += `,${pitch.toFixed(0)}p`
27+
return hash
28+
}
29+
30+
function useMapUrlBase() {
31+
const router = useRouter()
32+
const route = useRoute()
33+
const { viewCenter, zoom, bearing, pitch, isInitialized, mapInstance } = useMapControls()
34+
35+
// Prevent feedback loop: URL change → map move → URL change
36+
const isUpdatingFromMap = ref(false)
37+
38+
// Parse hash params
39+
const hashParams = computed(() => parseMapHash(route.hash))
40+
const urlLat = computed(() => hashParams.value.lat)
41+
const urlLng = computed(() => hashParams.value.lng)
42+
const urlZoom = computed(() => hashParams.value.zoom)
43+
const urlBearing = computed(() => hashParams.value.bearing)
44+
const urlPitch = computed(() => hashParams.value.pitch)
45+
46+
// Map → URL (debounced)
47+
const updateUrlFromMap = useDebounceFn(() => {
48+
if (!isInitialized.value)
49+
return
50+
isUpdatingFromMap.value = true
51+
52+
const hash = buildMapHash(
53+
viewCenter.value.lat,
54+
viewCenter.value.lng,
55+
zoom.value,
56+
bearing.value,
57+
pitch.value,
58+
)
59+
60+
router.replace({ hash, query: route.query }).finally(() => {
61+
nextTick(() => {
62+
isUpdatingFromMap.value = false
63+
})
64+
})
65+
}, 300)
66+
67+
// Watch map state → update URL
68+
watch([viewCenter, zoom, bearing, pitch], () => {
69+
if (isInitialized.value && mapInstance.value)
70+
updateUrlFromMap()
71+
}, { deep: true })
72+
73+
// URL → Map (external navigation / back-forward)
74+
watch([urlLat, urlLng, urlZoom, urlBearing, urlPitch], ([lat, lng, z, b, p]) => {
75+
if (isUpdatingFromMap.value)
76+
return
77+
if (!mapInstance.value || !isInitialized.value)
78+
return
79+
if (lat === undefined || lng === undefined)
80+
return
81+
82+
const latDiff = Math.abs(viewCenter.value.lat - lat)
83+
const lngDiff = Math.abs(viewCenter.value.lng - lng)
84+
const posChanged = latDiff > 0.0001 || lngDiff > 0.0001
85+
const zoomChanged = z !== undefined && Math.abs(zoom.value - z) > 0.5
86+
const bearingChanged = b !== undefined && Math.abs(bearing.value - b) > 1
87+
const pitchChanged = p !== undefined && Math.abs(pitch.value - p) > 1
88+
89+
if (posChanged || zoomChanged || bearingChanged || pitchChanged) {
90+
mapInstance.value.flyTo({
91+
center: [lng, lat],
92+
zoom: z ?? zoom.value,
93+
bearing: b ?? bearing.value,
94+
pitch: p ?? pitch.value,
95+
duration: 1000,
96+
})
97+
}
98+
})
99+
100+
return { urlLat, urlLng, urlZoom, urlBearing, urlPitch }
101+
}
102+
103+
export const useMapUrl = createSharedComposable(useMapUrlBase)

app/composables/useUserLocation.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,32 @@ if (import.meta.client) {
5353
fetchIpGeolocation()
5454
}
5555

56+
// Parse #@lat,lng,zoomz format from hash (e.g., #@55.8334602,13.23455,16z)
57+
function parseMapHash(hash: string): { lat?: number, lng?: number, zoom?: number, bearing?: number, pitch?: number } {
58+
// Match: #@lat,lng,zoomz or #@lat,lng,zoomz,bearingb,pitchp
59+
const match = hash.match(/^#@(-?\d+(?:\.\d*)?),(-?\d+(?:\.\d*)?),(\d+(?:\.\d*)?)z(?:,(\d+(?:\.\d*)?)b)?(?:,(\d+(?:\.\d*)?)p)?/)
60+
if (!match || !match[1] || !match[2] || !match[3])
61+
return {}
62+
return {
63+
lat: Number.parseFloat(match[1]),
64+
lng: Number.parseFloat(match[2]),
65+
zoom: Number.parseFloat(match[3]),
66+
bearing: match[4] ? Number.parseFloat(match[4]) : undefined,
67+
pitch: match[5] ? Number.parseFloat(match[5]) : undefined,
68+
}
69+
}
70+
5671
export function useUserLocation() {
5772
const route = useRoute()
5873

59-
const queryLat = route.query.lat ? Number(route.query.lat) : undefined
60-
const queryLng = route.query.lng ? Number(route.query.lng) : undefined
61-
const hasQueryParams = queryLat !== undefined && queryLng !== undefined
74+
// Parse hash format #@lat,lng,zoomz
75+
const hashParams = computed(() => parseMapHash(route.hash))
76+
const queryLat = computed(() => hashParams.value.lat)
77+
const queryLng = computed(() => hashParams.value.lng)
78+
const queryZoom = computed(() => hashParams.value.zoom)
79+
const queryBearing = computed(() => hashParams.value.bearing)
80+
const queryPitch = computed(() => hashParams.value.pitch)
81+
const hasQueryParams = computed(() => queryLat.value !== undefined && queryLng.value !== undefined)
6282

6383
const isGeoReady = computed(() => ipGeoStatus.value === 'success' || ipGeoStatus.value === 'error')
6484

@@ -95,24 +115,24 @@ export function useUserLocation() {
95115

96116
// Initial map center point (for centering map, not for blue dot)
97117
const initialPoint = computed<Point | null>(() => {
98-
if (hasQueryParams)
99-
return { lng: queryLng!, lat: queryLat! }
118+
if (hasQueryParams.value)
119+
return { lng: queryLng.value!, lat: queryLat.value! }
100120
if (ipPoint.value)
101121
return ipPoint.value
102122
return null
103123
})
104124

105125
// Accuracy for initial zoom calculation (only from IP geolocation)
106126
const initialAccuracy = computed<number | null>(() => {
107-
if (hasQueryParams)
108-
return null // Query params don't have accuracy
127+
if (hasQueryParams.value)
128+
return null // Path params don't have accuracy
109129
return ipAccuracy.value
110130
})
111131

112132
const source = computed<'gps' | 'query' | 'nimiq-geoip' | 'none'>(() => {
113133
if (gpsPoint.value)
114134
return 'gps'
115-
if (hasQueryParams)
135+
if (hasQueryParams.value)
116136
return 'query'
117137
if (ipPoint.value)
118138
return 'nimiq-geoip'
@@ -125,6 +145,9 @@ export function useUserLocation() {
125145
isGeoReady,
126146
source,
127147
hasQueryParams,
148+
queryZoom,
149+
queryBearing,
150+
queryPitch,
128151
isLocating,
129152
locateMe,
130153
gpsPoint,

0 commit comments

Comments
 (0)