Compare commits
6 Commits
feature/ma
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5e821308 | ||
|
|
a6a5fe892d | ||
|
|
2b1ed5c09e | ||
|
|
d8ee9a20e0 | ||
|
|
b10e6cbd20 | ||
|
|
49c48c1347 |
@@ -1,21 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import { useMap } from '@/stores/map'
|
||||
import { PhMapPin, PhSpinner } from '@phosphor-icons/vue'
|
||||
import { useGeolocation, usePermission } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ParkingSpaceRange from './filters/ParkingSpaceRange.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<{
|
||||
nbUncovered?: number
|
||||
nbCovered?: 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>
|
||||
|
||||
<template>
|
||||
<aside class="max-w-xs absolute top-5 left-5 z-10 p-2 bg-slate-50 text-slate-950 rounded-xs">
|
||||
<form class="grid grid-cols-1 gap-3">
|
||||
<div class="max-w-xs absolute top-5 left-5 z-10 grid gap-2">
|
||||
<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>
|
||||
<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">
|
||||
@@ -43,5 +94,5 @@ defineProps<{
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import 'vue-leaflet-markercluster/dist/style.css'
|
||||
|
||||
import L from 'leaflet'
|
||||
import L, { Map } from 'leaflet'
|
||||
globalThis.L = L
|
||||
|
||||
import { useMap } from '@/stores/map'
|
||||
@@ -10,27 +10,45 @@ import type { ApiResponse } from '@/types/Api'
|
||||
import type { BikeParking } from '@/types/Bikes'
|
||||
import { API_BASE_URL, API_LIMIT, MAP_TILELAYER_URL, SpotAccess, SpotType } from '@/utils/const'
|
||||
import { useQuery } from '@pinia/colada'
|
||||
import { LControlZoom, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet'
|
||||
import { LControlZoom, LIcon, LMap, LMarker, LTileLayer } from '@vue-leaflet/vue-leaflet'
|
||||
import BikeFilters from './BikeFilters.vue'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import BikeClusterLayer from './BikeClusterLayer.vue'
|
||||
import { PhCircleNotch, PhMapPin } from '@phosphor-icons/vue'
|
||||
|
||||
// Map setup
|
||||
const mapRef = useTemplateRef<{ leafletObject: Map }>('map')
|
||||
|
||||
// Data fetching
|
||||
const fetchedPages = ref(0)
|
||||
const totalPages = ref(1)
|
||||
const progress = computed(() => Math.round((fetchedPages.value / totalPages.value) * 100))
|
||||
|
||||
async function fetchAllBikeParkings(): Promise<BikeParking[]> {
|
||||
fetchedPages.value = 0
|
||||
|
||||
const firstPage = await fetch(`${API_BASE_URL}?limit=${API_LIMIT}`).then(r => r.json()) as ApiResponse
|
||||
const { total_count } = firstPage
|
||||
|
||||
const remainingPages = await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.ceil((total_count - API_LIMIT) / API_LIMIT) },
|
||||
(_, i) => fetch(`${API_BASE_URL}?limit=${API_LIMIT}&offset=${(i + 1) * API_LIMIT}`).then(r => r.json()) as Promise<ApiResponse>
|
||||
const remainingCount = Math.ceil((total_count - API_LIMIT) / API_LIMIT)
|
||||
totalPages.value = remainingCount + 1
|
||||
fetchedPages.value = 1
|
||||
|
||||
const results: BikeParking[] = [...(firstPage.results ?? [])]
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: remainingCount }, (_, i) =>
|
||||
fetch(`${API_BASE_URL}?limit=${API_LIMIT}&offset=${(i + 1) * API_LIMIT}`)
|
||||
.then(r => r.json() as Promise<ApiResponse>)
|
||||
.then(page => {
|
||||
results.push(...(page.results ?? []))
|
||||
fetchedPages.value++
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
...(firstPage.results ?? []),
|
||||
...remainingPages.flatMap(page => page.results ?? [])
|
||||
]
|
||||
return results
|
||||
}
|
||||
|
||||
const { state } = useQuery({
|
||||
@@ -42,53 +60,72 @@ const { state } = useQuery({
|
||||
const nonCoveredParkings = computed(() =>
|
||||
state.value.data?.filter(p => p.type === SpotType.Uncovered) ?? []
|
||||
)
|
||||
const nonCoveredParkingsSpots = computed(() => {
|
||||
return nonCoveredParkings.value.reduce((acc, val) => acc + val.nb_total_place, 0)
|
||||
})
|
||||
const nonCoveredParkingsSpots = computed(() =>
|
||||
nonCoveredParkings.value.reduce((acc, val) => acc + val.nb_total_place, 0)
|
||||
)
|
||||
|
||||
const coveredTypes = new Set([SpotType.Covered, SpotType.Boxed])
|
||||
|
||||
const coveredParkings = computed(() =>
|
||||
state.value.data?.filter(p => coveredTypes.has(p.type as SpotType)) ?? []
|
||||
)
|
||||
const coveredParkingsSpots = computed(() => {
|
||||
return coveredParkings.value.reduce((acc, val) => acc + val.nb_total_place, 0)
|
||||
})
|
||||
const coveredParkingsSpots = computed(() =>
|
||||
coveredParkings.value.reduce((acc, val) => acc + val.nb_total_place, 0)
|
||||
)
|
||||
|
||||
const premiumParkings = computed(() =>
|
||||
state.value.data?.filter(p => p.condition_acces === SpotAccess.Korrigo) ?? []
|
||||
)
|
||||
const premiumParkingsSpots = computed(() => {
|
||||
return premiumParkings.value.reduce((acc, val) => acc + val.nb_total_place, 0)
|
||||
})
|
||||
const premiumParkingsSpots = computed(() =>
|
||||
premiumParkings.value.reduce((acc, val) => acc + val.nb_total_place, 0)
|
||||
)
|
||||
|
||||
const { zoom, minZoom, center, maxBounds, maxBoundsViscosity, maxClusterRadius, disableClusteringAtZoom, extraOptions } = 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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<main class="relative z-0 h-screen w-screen grid place-items-center">
|
||||
<div v-if="state.status === 'pending'">Loading...</div>
|
||||
<div v-if="state.status === 'pending'" class="flex flex-col items-center gap-3 text-sm">
|
||||
<span>Chargement des parkings… {{ progress }}%</span>
|
||||
<progress :value="progress" max="100"
|
||||
class="w-48 h-1.5 rounded-full bg-muted [&::-webkit-progress-bar]:bg-muted-foreground [&::-webkit-progress-value]:bg-primary [&::-moz-progress-bar]:bg-primary" />
|
||||
<PhCircleNotch :size="24" class="animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="state.status === 'error'">Error: {{ state.error.message }}</div>
|
||||
|
||||
<template v-else>
|
||||
<LMap ref="map" :min-zoom v-model:zoom="zoom" :center="center" :max-bounds :max-bounds-viscosity
|
||||
:options="extraOptions" :useGlobalLeaflet="true">
|
||||
<LControlZoom position="bottomright" />
|
||||
|
||||
<LTileLayer v-once :url="MAP_TILELAYER_URL" layer-type="base" />
|
||||
|
||||
<LMarker v-if="userCoords" :lat-lng="[userCoords.latitude, userCoords.longitude]">
|
||||
<LIcon>
|
||||
<PhMapPin size="24" weight="fill" class="text-amber-400" />
|
||||
</LIcon>
|
||||
</LMarker>
|
||||
|
||||
<BikeClusterLayer :parkings="coveredParkings" group="covered" :visible="filterCovered" :max-cluster-radius
|
||||
:disable-clustering-at-zoom />
|
||||
|
||||
<BikeClusterLayer :parkings="nonCoveredParkings" group="non-covered" :visible="filterUncovered"
|
||||
:max-cluster-radius :disable-clustering-at-zoom />
|
||||
|
||||
<BikeClusterLayer :parkings="premiumParkings" group="premium" :visible="filterKorrigo" :max-cluster-radius
|
||||
:disable-clustering-at-zoom />
|
||||
</LMap>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const SPOTS_MIN = 1
|
||||
export const SPOTS_MAX = 99
|
||||
|
||||
export const useMap = defineStore('map', () => {
|
||||
// Map config and options
|
||||
const zoom = ref(10)
|
||||
const minZoom = 8
|
||||
const center = ref<PointTuple>([48.11180645878813, -1.6637869497745246])
|
||||
@@ -18,9 +19,14 @@ export const useMap = defineStore('map', () => {
|
||||
zoomControl: false,
|
||||
}
|
||||
|
||||
const maxClusterRadius = 28
|
||||
// Cluster layers options
|
||||
const maxClusterRadius = 40
|
||||
const disableClusteringAtZoom = 17
|
||||
|
||||
// Geolocation
|
||||
const userCoords = ref<Omit<GeolocationCoordinates, 'toJSON'> | null>(null)
|
||||
|
||||
// Active filters
|
||||
const filterUncovered = ref(true)
|
||||
const filterCovered = ref(true)
|
||||
const filterKorrigo = ref(true)
|
||||
@@ -36,9 +42,10 @@ export const useMap = defineStore('map', () => {
|
||||
center,
|
||||
maxBounds,
|
||||
maxBoundsViscosity,
|
||||
extraOptions,
|
||||
maxClusterRadius,
|
||||
disableClusteringAtZoom,
|
||||
extraOptions,
|
||||
userCoords,
|
||||
filterUncovered,
|
||||
filterCovered,
|
||||
filterKorrigo,
|
||||
|
||||
Reference in New Issue
Block a user