29 Commits

Author SHA1 Message Date
Alexis
c47bd534f4 Added subpar car-first routing 2026-04-12 15:10:22 +02:00
Alexis
b26969d682 Merge branch 'feature/map-geoloc' into dev 2026-04-10 22:03:49 +02:00
Alexis
cd5e821308 Added flyTo on geoloc click 2026-04-10 22:03:40 +02:00
Alexis
a6a5fe892d Added geoloc marker to map 2026-04-10 21:06:18 +02:00
Alexis
2b1ed5c09e Added geoloc button to filters 2026-04-06 22:26:51 +02:00
Alexis
d8ee9a20e0 Merge branch 'ux/map-loading' into dev 2026-04-04 22:45:43 +02:00
Alexis
b10e6cbd20 Added progress bar to loading process 2026-04-04 22:45:33 +02:00
Alexis
49c48c1347 Merge branch 'feature/map-filters' into dev 2026-04-04 17:57:15 +02:00
Alexis
74f1629bf4 Added more options to leaflet map instance 2026-04-04 17:57:01 +02:00
Alexis
1d57cc3c3d Switched marker cluster colours 2026-04-04 17:09:51 +02:00
Alexis
d386a57928 Added extra filters for cargo / std 2026-04-03 23:01:03 +02:00
Alexis
3cb6043859 Better filter appearance and performance 2026-04-01 22:28:49 +02:00
Alexis
3b81dea46c Refactored some other component and cleanup 2026-03-30 22:12:03 +02:00
Alexis
83a7e14355 Refactored slider range into its own component 2026-03-30 22:04:18 +02:00
Alexis
d6565f8e03 Refactored checkbox into its own component 2026-03-30 21:59:07 +02:00
Alexis
b665982abb Changed filter slider layout 2026-03-30 21:55:22 +02:00
Alexis
f5170e5b04 Fixed flash in dark mode caused by leaflet container 2026-03-29 17:43:32 +02:00
Alexis
45fd18afa7 Changed primary colour 2026-03-28 21:36:52 +01:00
Alexis
05e0bb58ee Fixed layer visibility swap 2026-03-28 20:44:17 +01:00
Alexis
8b88b2bebb Added place count to filters 2026-03-27 22:43:08 +01:00
Alexis
782162d410 Added base types filters 2026-03-27 22:15:55 +01:00
Alexis
07feed0355 Merge branch 'feature/map-draft' into dev 2026-03-25 18:51:59 +01:00
Alexis
18a71bdacf Color theme for different types 2026-03-25 18:51:52 +01:00
Alexis
51e624ba2e Added more icons for park types 2026-03-25 18:05:35 +01:00
Alexis
ff773e1d1c Basic map and cluster setup + popup info 2026-03-24 23:13:23 +01:00
Alexis
e7cc6c432e Pinia colada query setup 2026-03-24 22:14:52 +01:00
Alexis
6ab92ba64f Moved consts to folder 2026-03-24 21:53:40 +01:00
Alexis
2685a7c868 Moved types to folder 2026-03-24 21:51:58 +01:00
Alexis
5a52b08107 Rough data setup 2026-03-24 21:50:04 +01:00
20 changed files with 1431 additions and 31 deletions

View File

@@ -15,19 +15,27 @@
"format": "oxfmt src/"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"@pinia/colada": "^1.0.0",
"@tailwindcss/vite": "^4.2.2",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vueuse/core": "^14.2.1",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"reka-ui": "^2.9.2",
"tailwindcss": "^4.2.2",
"vue": "^3.5.30",
"vue-leaflet-markercluster": "^0.6.1",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.9.0",
"clsx": "^2.1.1",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.51.0",
@@ -36,6 +44,8 @@
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.37.0",
"oxlint": "~1.51.0",
"sass-embedded": "^1.98.0",
"tailwind-merge": "^3.5.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.7",

728
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,3 @@
onlyBuiltDependencies:
- esbuild
- vue-demi

BIN
public/korrigo-logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -9,8 +9,8 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--primary: oklch(87.173% 0.17182 90.995);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
@@ -45,7 +45,7 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary: oklch(87.173% 0.17182 90.995);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
@@ -120,3 +120,7 @@
@apply bg-background text-foreground;
}
}
.flow > * + * {
margin: .5em 0 0 !important;
}

View File

@@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { BikeParking } from '@/types/Bikes'
import { LMarkerClusterGroup } from 'vue-leaflet-markercluster'
import BikeMarker from './BikeMarker.vue'
defineProps<{
parkings: BikeParking[]
group: 'covered' | 'non-covered' | 'premium'
visible: boolean
maxClusterRadius: number
disableClusteringAtZoom: number
}>()
const emit = defineEmits<{ nudgeGeolocation: [] }>()
</script>
<template>
<LMarkerClusterGroup v-if="parkings.length > 0" :visible :max-cluster-radius :disable-clustering-at-zoom>
<BikeMarker @nudge-geolocation="emit('nudgeGeolocation')" v-once v-for="park in parkings" :key="park.id_parc_velo"
:park :group />
</LMarkerClusterGroup>
</template>
<style lang="scss">
.marker-cluster div {
font-family: var(--default-font-family);
font-size: .9em;
}
.marker-cluster span {
font-weight: var(--font-weight-bold);
opacity: .85;
}
.marker-cluster-small {
background-color: color-mix(in srgb, var(--color-green-400) 10%, transparent);
div {
background-color: color-mix(in srgb, var(--color-green-400) 15%, transparent);
}
}
.marker-cluster-medium {
background-color: color-mix(in srgb, var(--color-amber-500) 12.5%, transparent);
div {
background-color: color-mix(in srgb, var(--color-amber-500) 20%, transparent);
}
}
.marker-cluster-large {
background-color: color-mix(in srgb, var(--color-rose-500) 15%, transparent);
div {
background-color: color-mix(in srgb, var(--color-rose-500) 25%, transparent);
}
}
</style>

View File

@@ -0,0 +1,98 @@
<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'
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>
<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">
Types d'emplacements
</legend>
<div class="grid grid-cols-1 gap-1.5">
<Checkbox v-model="filterUncovered" :count="nbUncovered">Non-abrités</Checkbox>
<Checkbox v-model="filterCovered" :count="nbCovered">Abrités</Checkbox>
<Checkbox v-model="filterKorrigo" :count="nbKorrigo">Abonnement Korrigo</Checkbox>
</div>
</fieldset>
<ParkingSpaceRange />
<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">
Équipements
</legend>
<div class="grid grid-cols-1 gap-1.5">
<Checkbox v-model="filterCargoOnly">Cargos</Checkbox>
<Checkbox v-model="filterStdOnly">STD</Checkbox>
</div>
</fieldset>
</form>
</div>
</template>

154
src/components/BikeMap.vue Normal file
View File

@@ -0,0 +1,154 @@
<script lang="ts" setup>
import 'leaflet/dist/leaflet.css'
import 'vue-leaflet-markercluster/dist/style.css'
import L, { Map } from 'leaflet'
globalThis.L = L
import { useMap } from '@/stores/map'
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, LGeoJson, LIcon, LMap, LMarker, LTileLayer } from '@vue-leaflet/vue-leaflet'
import BikeFilters from './BikeFilters.vue'
import { computed, ref, useTemplateRef } from 'vue'
import { storeToRefs } from 'pinia'
import BikeClusterLayer from './BikeClusterLayer.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 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 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 results
}
const { state } = useQuery({
key: ['bike-parkings'],
query: fetchAllBikeParkings,
})
// Groups different parking spot by types
const nonCoveredParkings = computed(() =>
state.value.data?.filter(p => p.type === SpotType.Uncovered) ?? []
)
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(() =>
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(() =>
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)
}
}
// Routing
const { activeRoute } = useRouting()
const showGeolocationNudge = ref(false)
</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" @click-geoloc="handleClickGeoloc" />
</Transition>
<Transition>
<div v-if="showGeolocationNudge" class="...">
Activez la géolocalisation pour obtenir un itinéraire
</div>
</Transition>
<main class="relative z-0 h-screen w-screen grid place-items-center">
<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" />
<LGeoJson v-if="activeRoute" :geojson="activeRoute" />
<LMarker v-if="userCoords" :lat-lng="[userCoords.latitude, userCoords.longitude]">
<LIcon>
<PhMapPin size="24" weight="fill" class="text-amber-400" />
</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>
</template>
</main>
</template>
<style lang="scss">
.leaflet-container {
background-color: var(--background);
}
</style>

View File

@@ -0,0 +1,148 @@
<script lang="ts" setup>
import { useRouting } from '@/composables/useRouting';
import { SPOTS_MAX, useMap } from '@/stores/map';
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 { LIcon, LMarker, LPopup } from '@vue-leaflet/vue-leaflet';
import type { PointTuple } from 'leaflet';
import { storeToRefs } from 'pinia';
import { computed, ref } from 'vue';
const props = defineProps<{
park: BikeParking
group: 'covered' | 'non-covered' | 'premium'
}>()
const normalIconSize: PointTuple = [30, 30]
const iconSize = ref<PointTuple>(normalIconSize)
const iconAnchor = computed<PointTuple>(() => [iconSize.value[0] / 2, iconSize.value[1]])
const popupOffset = [0, iconSize.value[0] * -0.66]
const { spotsRange, filterCargoOnly, filterStdOnly } = storeToRefs(useMap())
const isMarkerVisibleLayers = computed(() => {
const [min, max] = spotsRange.value
const spots = props.park.nb_total_place
if (max >= SPOTS_MAX) return spots >= min
return spots >= min && spots <= max
})
const isMarkerVisibleEquipments = computed(() => {
const checks = [
[filterCargoOnly.value, (props.park.nb_support_cargo ?? 0) > 0],
[filterStdOnly.value, (props.park.nb_support_std ?? 0) > 0],
] as const
return checks.every(([active, passes]) => !active || passes)
})
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>
<template>
<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 }">
<div>
<h2 class="font-bold">
{{ park.nom_voie }}, {{ park.nom_commune }}
</h2>
<div class="text-xs">
{{ park.type }}, <strong>{{ park.nb_total_place }} places</strong>
<template v-if="park.nb_support_cargo">
<br>
dont <strong>{{ park.nb_support_cargo }} cargo</strong>
</template>
<template v-if="park.nb_support_std">
<br>
dont <strong>{{ park.nb_support_std }} STD</strong>
</template>
</div>
<div v-if="park.condition_acces === 'Abonnement Korrigo'">
<img src="/korrigo-logo.webp" alt="" width="50" class="max-w-12" title="Requiert un abonnement Korrigo">
</div>
<hr class="border-muted-foreground opacity-50 my-1">
<div v-if="park.annee_mes" class="italic text-xs">
Mis en service en {{ park.annee_mes }}
</div>
<div v-if="park.commentaire" v-html="park.commentaire"
class="before:content-[''] before:inline-block before:w-1 before:h-[1em] before:-my-0.5 before:mr-1 before:rounded-xs before:bg-muted-foreground italic text-xs text-muted-foreground" />
</div>
</LPopup>
<LIcon :icon-size :icon-anchor :class-name="cn(
`group-${group}`,
{ 'korrigo': park.condition_acces === KORRIGO_KEY },
{ 'highlight': park.type === BOXED_KEY || park.type === COVERED_KEY },
)">
<PhBicycle v-if="park.type === UNCOVERED_KEY" size="18" />
<PhWarehouse v-else-if="park.type === COVERED_KEY" size="18" weight="light" />
<PhSquareHalf v-else-if="park.type === BOXED_KEY" size="18" weight="light" />
<PhLockKey v-else-if="park.condition_acces === KORRIGO_KEY" size="18" weight="fill" />
</LIcon>
</LMarker>
</template>
<style lang="scss">
.leaflet-marker-icon:not(.marker-cluster) {
border-radius: 50%;
color: var(--color-muted-foreground);
background-color: var(--color-background);
display: grid;
place-items: center;
&.korrigo {
color: var(--color-background);
background-color: var(--color-amber-400);
}
&.highlight {
color: var(--color-amber-400);
border: 2px solid color-mix(in srgb, var(--color-amber-400) 50%, var(--color-background));
}
}
.leaflet-popup-content-wrapper {
border-radius: 5px;
.leaflet-popup-content {
margin: 10px 20px 10px 15px;
p {
margin-bottom: 0;
}
}
}
.marker-cluster span {
font-weight: var(--font-weight-medium);
}
</style>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { SliderRange, SliderRoot, SliderThumb, SliderTrack } from 'reka-ui'
import { storeToRefs } from 'pinia'
import { useMap, SPOTS_MIN, SPOTS_MAX } from '@/stores/map'
import { ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
const { spotsRange } = storeToRefs(useMap())
const MIN = SPOTS_MIN
const MAX = SPOTS_MAX
const localRange = ref<[number, number]>([...spotsRange.value] as [number, number])
const commitToStore = useDebounceFn((val: [number, number]) => {
spotsRange.value = val
}, 500)
function onRangeUpdate(val: number[] | undefined) {
if (!val) return
localRange.value = val as [number, number]
commitToStore(val as [number, number])
}
</script>
<template>
<div>
<div class="flex items-center justify-between">
<span
class="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">Places
totales</span>
<span class="text-xs text-muted-foreground">
{{ localRange[0] }} {{ localRange[1] }}<template v-if="localRange[1] === MAX">+</template>
</span>
</div>
<div class="mt-1">
<SliderRoot :model-value="localRange" @update:model-value="onRangeUpdate"
class="relative flex items-center select-none touch-none w-full pt-2 pb-1" :min="MIN" :max="MAX" :step="5">
<SliderTrack class="bg-muted/10 relative grow rounded-full h-1.5">
<SliderRange class="absolute bg-primary rounded-full h-full" />
</SliderTrack>
<SliderThumb
class="cursor-pointer size-4 bg-white rounded-full hover:bg-primary shadow-sm focus:outline-none focus:ring-2" />
<SliderThumb
class="cursor-pointer size-4 bg-white rounded-full hover:bg-primary shadow-sm focus:outline-none focus:ring-2" />
</SliderRoot>
</div>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup>
import { PhCheck } from '@phosphor-icons/vue'
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
defineProps<{
count?: number
}>()
const checked = defineModel<boolean>()
</script>
<template>
<label class="flex flex-row gap-1.5 items-center text-sm cursor-pointer group">
<CheckboxRoot v-model="checked"
class="hover:bg-stone-100 flex size-4 appearance-none items-center justify-center rounded-xs bg-foreground shadow-sm border focus-within:ring-1 cursor-pointer">
<CheckboxIndicator class="size-full flex items-center justify-center bg-primary text-primary-foreground">
<PhCheck :size="10" weight="bold" />
</CheckboxIndicator>
</CheckboxRoot>
<span class="select-none">
<span class="group-hover:underline underline-offset-2">
<slot />
</span>
<small v-if="count" class="ml-1 opacity-50">({{ count }})</small>
</span>
</label>
</template>

View 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 }
}

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'
import App from './App.vue'
import router from './router'
@@ -7,6 +8,8 @@ import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(PiniaColada)
app.use(router)
app.mount('#app')

View File

@@ -1,5 +1,15 @@
<script setup lang="ts">
import BikeMap from '@/components/BikeMap.vue';
</script>
<template>
<main>
Home
</main>
<header class="sr-only">
<h1>
Carte interactive des parkings vélo de Rennes Métropole
</h1>
</header>
<Suspense>
<BikeMap />
</Suspense>
</template>

62
src/stores/map.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { PointTuple } from 'leaflet'
import { defineStore } from 'pinia'
import { ref } from 'vue'
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])
const topLeftCorner: PointTuple = [48.88261491330733, -3.437430524894775]
const bottomRightCorner: PointTuple = [47.31027605502938, 0.6437600778583776]
const maxBounds: PointTuple[] = [topLeftCorner, bottomRightCorner]
const maxBoundsViscosity = 0.9
const extraOptions: Record<string, any> = {
zoomControl: false,
}
// 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)
const filterCargoOnly = ref(false)
const filterStdOnly = ref(false)
const spotsRange = ref<[number, number]>([SPOTS_MIN, SPOTS_MAX])
// Routing
const activeRoute = ref<GeoJSON.LineString | null>(null)
const isRoutingLoading = ref(false)
return {
zoom,
minZoom,
center,
maxBounds,
maxBoundsViscosity,
extraOptions,
maxClusterRadius,
disableClusteringAtZoom,
userCoords,
filterUncovered,
filterCovered,
filterKorrigo,
filterCargoOnly,
filterStdOnly,
spotsRange,
activeRoute,
isRoutingLoading,
}
})

6
src/types/Api.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { BikeParking } from './Bikes'
export interface ApiResponse {
total_count: number
results: BikeParking[]
}

23
src/types/Bikes.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { GeoPoint, GeoShape } from './Geo'
export interface BikeParking {
geo_point_2d: GeoPoint
geo_shape: GeoShape
gml_id: string
id_parc_velo: number
nom: string | null
code_insee: number
nom_commune: string
nom_voie: string
id_voie: number
type: string
gestionnaire: string
localisation: string
condition_acces: string
annee_mes: number
nb_support_std: number | null
nb_support_cargo: number | null
nb_total_place: number
date_maj: string
commentaire: string | null
}

13
src/types/Geo.ts Normal file
View File

@@ -0,0 +1,13 @@
export interface GeoPoint {
lon: number
lat: number
}
export interface GeoShape {
type: 'Feature'
geometry: {
coordinates: [number, number]
type: 'Point'
}
properties: Record<string, unknown>
}

4
src/utils/cn.ts Normal file
View File

@@ -0,0 +1,4 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))

21
src/utils/const.ts Normal file
View File

@@ -0,0 +1,21 @@
export const MAP_TILELAYER_URL =
'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png'
export const API_BASE_URL =
'https://data.rennesmetropole.fr/api/explore/v2.1/catalog/datasets/parkings_velos_sur_rennes_metropole/records'
export const API_LIMIT = 100
export const UNCOVERED_KEY = 'Non abrité'
export const COVERED_KEY = 'Abrité'
export const BOXED_KEY = 'Box individuel'
export const KORRIGO_KEY = 'Abonnement Korrigo'
export enum SpotType {
Uncovered = UNCOVERED_KEY,
Covered = COVERED_KEY,
Boxed = BOXED_KEY,
}
export enum SpotAccess {
Korrigo = KORRIGO_KEY,
}