Compare commits
6 Commits
ux/map-loa
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47bd534f4 | ||
|
|
b26969d682 | ||
|
|
cd5e821308 | ||
|
|
a6a5fe892d | ||
|
|
2b1ed5c09e | ||
|
|
d8ee9a20e0 |
@@ -10,11 +10,14 @@ defineProps<{
|
|||||||
maxClusterRadius: number
|
maxClusterRadius: number
|
||||||
disableClusteringAtZoom: number
|
disableClusteringAtZoom: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ nudgeGeolocation: [] }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LMarkerClusterGroup v-if="parkings.length > 0" :visible :max-cluster-radius :disable-clustering-at-zoom>
|
<LMarkerClusterGroup v-if="parkings.length > 0" :visible :max-cluster-radius :disable-clustering-at-zoom>
|
||||||
<BikeMarker v-once v-for="park in parkings" :key="park.id_parc_velo" :park :group />
|
<BikeMarker @nudge-geolocation="emit('nudgeGeolocation')" v-once v-for="park in parkings" :key="park.id_parc_velo"
|
||||||
|
:park :group />
|
||||||
</LMarkerClusterGroup>
|
</LMarkerClusterGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,72 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useMap } from '@/stores/map'
|
import { useMap } from '@/stores/map'
|
||||||
|
import { PhMapPin, PhSpinner } from '@phosphor-icons/vue'
|
||||||
|
import { useGeolocation, usePermission } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import ParkingSpaceRange from './filters/ParkingSpaceRange.vue'
|
||||||
import Checkbox from './forms/Checkbox.vue'
|
import Checkbox from './forms/Checkbox.vue'
|
||||||
import ParkingSpaceRange from './filters/ParkingSpaceRange.vue';
|
|
||||||
|
|
||||||
const { filterCovered, filterUncovered, filterKorrigo, filterCargoOnly, filterStdOnly } = storeToRefs(useMap())
|
const { userCoords, filterCovered, filterUncovered, filterKorrigo, filterCargoOnly, filterStdOnly } = storeToRefs(useMap())
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
nbUncovered?: number
|
nbUncovered?: number
|
||||||
nbCovered?: number
|
nbCovered?: number
|
||||||
nbKorrigo?: number
|
nbKorrigo?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Geolocation features
|
||||||
|
const { coords, error, resume, pause, locatedAt } = useGeolocation({ immediate: false })
|
||||||
|
|
||||||
|
const geolocationPermission = usePermission('geolocation')
|
||||||
|
|
||||||
|
const isRequesting = ref(false)
|
||||||
|
const hasLocation = computed(() => locatedAt.value !== null && !error.value)
|
||||||
|
|
||||||
|
// Auto-start if permission was already granted
|
||||||
|
watch(geolocationPermission, (permission) => {
|
||||||
|
if (permission === 'granted' && !hasLocation.value) {
|
||||||
|
resume()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function toggleGeolocation() {
|
||||||
|
if (hasLocation.value) {
|
||||||
|
pause()
|
||||||
|
isRequesting.value = false
|
||||||
|
emit('click-geoloc')
|
||||||
|
} else {
|
||||||
|
isRequesting.value = true
|
||||||
|
resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(locatedAt, () => { isRequesting.value = false })
|
||||||
|
watch(error, () => { isRequesting.value = false })
|
||||||
|
watch(coords, () => {
|
||||||
|
if (coords.value.latitude && coords.value.longitude) {
|
||||||
|
userCoords.value = coords.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click-geoloc'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="max-w-xs absolute top-5 left-5 z-10 p-2 bg-slate-50 text-slate-950 rounded-xs">
|
<div class="max-w-xs absolute top-5 left-5 z-10 grid gap-2">
|
||||||
<form class="grid grid-cols-1 gap-3">
|
<button @click="toggleGeolocation" :title="error?.message ?? (hasLocation ? 'Aller à ma position' : 'Me localiser')"
|
||||||
|
:class="[
|
||||||
|
'size-9 grid place-items-center border shadow rounded-full cursor-pointer transition-colors',
|
||||||
|
hasLocation
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-slate-50 text-slate-950 border-transparent hover:bg-background hover:text-primary hover:border-primary',
|
||||||
|
error ? 'text-destructive border-destructive hover:text-destructive hover:border-destructive' : ''
|
||||||
|
]">
|
||||||
|
<PhSpinner v-if="isRequesting" :size="20" class="animate-spin" />
|
||||||
|
<PhMapPin v-else :size="20" weight="fill" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form class="p-2 bg-slate-50 text-slate-950 rounded-xs grid grid-cols-1 gap-3">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend
|
<legend
|
||||||
class="mb-1.5 text-sm font-medium relative isolate before:contents-[''] before:block before:w-full before:bg-amber-500/15 before:h-2 before:absolute before:bottom-0.5 before:-z-10">
|
class="mb-1.5 text-sm font-medium relative isolate before:contents-[''] before:block before:w-full before:bg-amber-500/15 before:h-2 before:absolute before:bottom-0.5 before:-z-10">
|
||||||
@@ -43,5 +94,5 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import 'vue-leaflet-markercluster/dist/style.css'
|
import 'vue-leaflet-markercluster/dist/style.css'
|
||||||
|
|
||||||
import L from 'leaflet'
|
import L, { Map } from 'leaflet'
|
||||||
globalThis.L = L
|
globalThis.L = L
|
||||||
|
|
||||||
import { useMap } from '@/stores/map'
|
import { useMap } from '@/stores/map'
|
||||||
@@ -10,13 +10,18 @@ import type { ApiResponse } from '@/types/Api'
|
|||||||
import type { BikeParking } from '@/types/Bikes'
|
import type { BikeParking } from '@/types/Bikes'
|
||||||
import { API_BASE_URL, API_LIMIT, MAP_TILELAYER_URL, SpotAccess, SpotType } from '@/utils/const'
|
import { API_BASE_URL, API_LIMIT, MAP_TILELAYER_URL, SpotAccess, SpotType } from '@/utils/const'
|
||||||
import { useQuery } from '@pinia/colada'
|
import { useQuery } from '@pinia/colada'
|
||||||
import { LControlZoom, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet'
|
import { LControlZoom, LGeoJson, LIcon, LMap, LMarker, LTileLayer } from '@vue-leaflet/vue-leaflet'
|
||||||
import BikeFilters from './BikeFilters.vue'
|
import BikeFilters from './BikeFilters.vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, useTemplateRef } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import BikeClusterLayer from './BikeClusterLayer.vue'
|
import BikeClusterLayer from './BikeClusterLayer.vue'
|
||||||
import { PhCircleNotch } from '@phosphor-icons/vue'
|
import { PhCircleNotch, PhMapPin } from '@phosphor-icons/vue'
|
||||||
|
import { useRouting } from '@/composables/useRouting'
|
||||||
|
|
||||||
|
// Map setup
|
||||||
|
const mapRef = useTemplateRef<{ leafletObject: Map }>('map')
|
||||||
|
|
||||||
|
// Data fetching
|
||||||
const fetchedPages = ref(0)
|
const fetchedPages = ref(0)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const progress = computed(() => Math.round((fetchedPages.value / totalPages.value) * 100))
|
const progress = computed(() => Math.round((fetchedPages.value / totalPages.value) * 100))
|
||||||
@@ -78,13 +83,33 @@ const premiumParkingsSpots = computed(() =>
|
|||||||
|
|
||||||
const { zoom, minZoom, center, maxBounds, maxBoundsViscosity, maxClusterRadius, disableClusteringAtZoom, extraOptions } = useMap()
|
const { zoom, minZoom, center, maxBounds, maxBoundsViscosity, maxClusterRadius, disableClusteringAtZoom, extraOptions } = useMap()
|
||||||
const { filterUncovered, filterCovered, filterKorrigo } = storeToRefs(useMap())
|
const { filterUncovered, filterCovered, filterKorrigo } = storeToRefs(useMap())
|
||||||
|
|
||||||
|
// Geolocation
|
||||||
|
const { userCoords } = storeToRefs(useMap())
|
||||||
|
|
||||||
|
function handleClickGeoloc() {
|
||||||
|
if (userCoords.value) {
|
||||||
|
mapRef.value?.leafletObject.flyTo([userCoords.value.latitude, userCoords.value.longitude], 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routing
|
||||||
|
const { activeRoute } = useRouting()
|
||||||
|
const showGeolocationNudge = ref(false)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition enter-from-class="opacity-0 translate-y-2"
|
<Transition enter-from-class="opacity-0 translate-y-2"
|
||||||
enter-active-class="transition-all duration-300 ease-out delay-500" enter-to-class="opacity-100 translate-y-0">
|
enter-active-class="transition-all duration-300 ease-out delay-500" enter-to-class="opacity-100 translate-y-0">
|
||||||
<BikeFilters v-if="state.status === 'success'" :nb-uncovered="nonCoveredParkingsSpots"
|
<BikeFilters v-if="state.status === 'success'" :nb-uncovered="nonCoveredParkingsSpots"
|
||||||
:nb-covered="coveredParkingsSpots" :nb-korrigo="premiumParkingsSpots" />
|
:nb-covered="coveredParkingsSpots" :nb-korrigo="premiumParkingsSpots" @click-geoloc="handleClickGeoloc" />
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition>
|
||||||
|
<div v-if="showGeolocationNudge" class="...">
|
||||||
|
Activez la géolocalisation pour obtenir un itinéraire
|
||||||
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<main class="relative z-0 h-screen w-screen grid place-items-center">
|
<main class="relative z-0 h-screen w-screen grid place-items-center">
|
||||||
@@ -103,12 +128,20 @@ const { filterUncovered, filterCovered, filterKorrigo } = storeToRefs(useMap())
|
|||||||
<LControlZoom position="bottomright" />
|
<LControlZoom position="bottomright" />
|
||||||
<LTileLayer v-once :url="MAP_TILELAYER_URL" layer-type="base" />
|
<LTileLayer v-once :url="MAP_TILELAYER_URL" layer-type="base" />
|
||||||
|
|
||||||
<BikeClusterLayer :parkings="coveredParkings" group="covered" :visible="filterCovered" :max-cluster-radius
|
<LGeoJson v-if="activeRoute" :geojson="activeRoute" />
|
||||||
:disable-clustering-at-zoom />
|
|
||||||
<BikeClusterLayer :parkings="nonCoveredParkings" group="non-covered" :visible="filterUncovered"
|
<LMarker v-if="userCoords" :lat-lng="[userCoords.latitude, userCoords.longitude]">
|
||||||
:max-cluster-radius :disable-clustering-at-zoom />
|
<LIcon>
|
||||||
<BikeClusterLayer :parkings="premiumParkings" group="premium" :visible="filterKorrigo" :max-cluster-radius
|
<PhMapPin size="24" weight="fill" class="text-amber-400" />
|
||||||
:disable-clustering-at-zoom />
|
</LIcon>
|
||||||
|
</LMarker>
|
||||||
|
|
||||||
|
<BikeClusterLayer @nudge-geolocation="showGeolocationNudge = true" :parkings="coveredParkings" group="covered"
|
||||||
|
:visible="filterCovered" :max-cluster-radius :disable-clustering-at-zoom />
|
||||||
|
<BikeClusterLayer @nudge-geolocation="showGeolocationNudge = true" :parkings="nonCoveredParkings"
|
||||||
|
group="non-covered" :visible="filterUncovered" :max-cluster-radius :disable-clustering-at-zoom />
|
||||||
|
<BikeClusterLayer @nudge-geolocation="showGeolocationNudge = true" :parkings="premiumParkings" group="premium"
|
||||||
|
:visible="filterKorrigo" :max-cluster-radius :disable-clustering-at-zoom />
|
||||||
</LMap>
|
</LMap>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn } from '@/utils/cn';
|
import { useRouting } from '@/composables/useRouting';
|
||||||
|
import { SPOTS_MAX, useMap } from '@/stores/map';
|
||||||
import type { BikeParking } from '@/types/Bikes';
|
import type { BikeParking } from '@/types/Bikes';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { BOXED_KEY, COVERED_KEY, KORRIGO_KEY, UNCOVERED_KEY } from '@/utils/const';
|
||||||
import { PhBicycle, PhLockKey, PhSquareHalf, PhWarehouse } from '@phosphor-icons/vue';
|
import { PhBicycle, PhLockKey, PhSquareHalf, PhWarehouse } from '@phosphor-icons/vue';
|
||||||
import { LIcon, LMarker, LPopup } from '@vue-leaflet/vue-leaflet';
|
import { LIcon, LMarker, LPopup } from '@vue-leaflet/vue-leaflet';
|
||||||
import type { PointTuple } from 'leaflet';
|
import type { PointTuple } from 'leaflet';
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { UNCOVERED_KEY, COVERED_KEY, BOXED_KEY, KORRIGO_KEY } from '@/utils/const';
|
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { SPOTS_MAX, useMap } from '@/stores/map';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
park: BikeParking
|
park: BikeParking
|
||||||
@@ -40,10 +41,28 @@ const isMarkerVisibleEquipments = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isMarkerVisible = computed(() => isMarkerVisibleLayers.value && isMarkerVisibleEquipments.value)
|
const isMarkerVisible = computed(() => isMarkerVisibleLayers.value && isMarkerVisibleEquipments.value)
|
||||||
|
|
||||||
|
// Routing
|
||||||
|
const { routeTo } = useRouting()
|
||||||
|
const { userCoords } = storeToRefs(useMap())
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
nudgeGeolocation: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
async function handleMarkerClick() {
|
||||||
|
if (!userCoords.value) {
|
||||||
|
emit('nudgeGeolocation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await routeTo(props.park)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LMarker :lat-lng="[park.geo_point_2d.lat, park.geo_point_2d.lon]" :visible="isMarkerVisible">
|
<LMarker :lat-lng="[park.geo_point_2d.lat, park.geo_point_2d.lon]" :visible="isMarkerVisible"
|
||||||
|
@click="handleMarkerClick">
|
||||||
<LPopup :options="{ offset: popupOffset, maxWidth: 520, minWidth: 240 }">
|
<LPopup :options="{ offset: popupOffset, maxWidth: 520, minWidth: 240 }">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-bold">
|
<h2 class="font-bold">
|
||||||
|
|||||||
27
src/composables/useRouting.ts
Normal file
27
src/composables/useRouting.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useMap } from '@/stores/map'
|
||||||
|
import type { BikeParking } from '@/types/Bikes'
|
||||||
|
|
||||||
|
export function useRouting() {
|
||||||
|
const { userCoords, activeRoute, isRoutingLoading } = storeToRefs(useMap())
|
||||||
|
|
||||||
|
async function routeTo(park: BikeParking) {
|
||||||
|
if (!userCoords.value) return false // caller handles the nudge
|
||||||
|
|
||||||
|
isRoutingLoading.value = true
|
||||||
|
activeRoute.value = null
|
||||||
|
|
||||||
|
const { latitude: uLat, longitude: uLon } = userCoords.value
|
||||||
|
const { lat: pLat, lon: pLon } = park.geo_point_2d
|
||||||
|
|
||||||
|
const url = `https://router.project-osrm.org/route/v1/bike/${uLon},${uLat};${pLon},${pLat}?geometries=geojson&overview=full`
|
||||||
|
const data = await fetch(url).then((r) => r.json())
|
||||||
|
|
||||||
|
activeRoute.value = data.routes[0].geometry
|
||||||
|
isRoutingLoading.value = false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routeTo, activeRoute, isRoutingLoading }
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export const SPOTS_MIN = 1
|
|||||||
export const SPOTS_MAX = 99
|
export const SPOTS_MAX = 99
|
||||||
|
|
||||||
export const useMap = defineStore('map', () => {
|
export const useMap = defineStore('map', () => {
|
||||||
|
// Map config and options
|
||||||
const zoom = ref(10)
|
const zoom = ref(10)
|
||||||
const minZoom = 8
|
const minZoom = 8
|
||||||
const center = ref<PointTuple>([48.11180645878813, -1.6637869497745246])
|
const center = ref<PointTuple>([48.11180645878813, -1.6637869497745246])
|
||||||
@@ -18,9 +19,14 @@ export const useMap = defineStore('map', () => {
|
|||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cluster layers options
|
||||||
const maxClusterRadius = 40
|
const maxClusterRadius = 40
|
||||||
const disableClusteringAtZoom = 17
|
const disableClusteringAtZoom = 17
|
||||||
|
|
||||||
|
// Geolocation
|
||||||
|
const userCoords = ref<Omit<GeolocationCoordinates, 'toJSON'> | null>(null)
|
||||||
|
|
||||||
|
// Active filters
|
||||||
const filterUncovered = ref(true)
|
const filterUncovered = ref(true)
|
||||||
const filterCovered = ref(true)
|
const filterCovered = ref(true)
|
||||||
const filterKorrigo = ref(true)
|
const filterKorrigo = ref(true)
|
||||||
@@ -30,20 +36,27 @@ export const useMap = defineStore('map', () => {
|
|||||||
|
|
||||||
const spotsRange = ref<[number, number]>([SPOTS_MIN, SPOTS_MAX])
|
const spotsRange = ref<[number, number]>([SPOTS_MIN, SPOTS_MAX])
|
||||||
|
|
||||||
|
// Routing
|
||||||
|
const activeRoute = ref<GeoJSON.LineString | null>(null)
|
||||||
|
const isRoutingLoading = ref(false)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zoom,
|
zoom,
|
||||||
minZoom,
|
minZoom,
|
||||||
center,
|
center,
|
||||||
maxBounds,
|
maxBounds,
|
||||||
maxBoundsViscosity,
|
maxBoundsViscosity,
|
||||||
|
extraOptions,
|
||||||
maxClusterRadius,
|
maxClusterRadius,
|
||||||
disableClusteringAtZoom,
|
disableClusteringAtZoom,
|
||||||
extraOptions,
|
userCoords,
|
||||||
filterUncovered,
|
filterUncovered,
|
||||||
filterCovered,
|
filterCovered,
|
||||||
filterKorrigo,
|
filterKorrigo,
|
||||||
filterCargoOnly,
|
filterCargoOnly,
|
||||||
filterStdOnly,
|
filterStdOnly,
|
||||||
spotsRange,
|
spotsRange,
|
||||||
|
activeRoute,
|
||||||
|
isRoutingLoading,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user