Refine map layout and preserve viewport state
This commit is contained in:
+146
-29
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { AppHeader } from '@/components/layout/AppHeader'
|
import { AppHeader } from '@/components/layout/AppHeader'
|
||||||
@@ -16,10 +17,12 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { useHotspotFeed } from '@/hooks/useHotspotFeed'
|
import { useHotspotFeed } from '@/hooks/useHotspotFeed'
|
||||||
import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap'
|
import { useLeafletHeatmap, type TileProvider } from '@/hooks/useLeafletHeatmap'
|
||||||
import { useUserLocation } from '@/hooks/useUserLocation'
|
import { useUserLocation } from '@/hooks/useUserLocation'
|
||||||
import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils'
|
import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils'
|
||||||
import type { LatLng } from '@/types/api'
|
import type { LatLng } from '@/types/api'
|
||||||
|
|
||||||
const RADIUS_KM = 1
|
const RADIUS_KM = 1
|
||||||
@@ -28,6 +31,19 @@ export default function App() {
|
|||||||
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
|
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
|
||||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
||||||
const [isConfirming, setIsConfirming] = useState(false)
|
const [isConfirming, setIsConfirming] = useState(false)
|
||||||
|
const [tileProvider, setTileProvider] = useState<TileProvider>('openstreetmap')
|
||||||
|
const [isDetailsOpen, setIsDetailsOpen] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return window.innerWidth >= 1024
|
||||||
|
})
|
||||||
|
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return window.innerWidth < 768
|
||||||
|
})
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US'
|
const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US'
|
||||||
const distanceFormatter = useMemo(
|
const distanceFormatter = useMemo(
|
||||||
@@ -39,6 +55,14 @@ export default function App() {
|
|||||||
[locale],
|
[locale],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tileOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'openstreetmap' as TileProvider, label: t('map.tiles.openstreetmap') },
|
||||||
|
{ value: 'mapbox' as TileProvider, label: t('map.tiles.mapbox') },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
@@ -108,6 +132,7 @@ export default function App() {
|
|||||||
setPendingSpot(position)
|
setPendingSpot(position)
|
||||||
setIsConfirmOpen(true)
|
setIsConfirmOpen(true)
|
||||||
},
|
},
|
||||||
|
tileProvider,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleConfirmSignal = useCallback(async () => {
|
const handleConfirmSignal = useCallback(async () => {
|
||||||
@@ -207,26 +232,127 @@ export default function App() {
|
|||||||
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--'
|
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--'
|
||||||
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--'
|
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--'
|
||||||
const isDialogDisabled = !pendingSpot || isConfirming
|
const isDialogDisabled = !pendingSpot || isConfirming
|
||||||
|
const detailsToggleLabel = isDetailsOpen ? t('details.close') : t('details.open')
|
||||||
|
const detailsPanelClassName = cn(
|
||||||
|
'pointer-events-auto fixed inset-x-0 bottom-0 z-40 flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-3xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur transition-transform duration-300 sm:left-auto sm:right-6 sm:top-24 sm:bottom-6 sm:max-h-[calc(100vh-8rem)] sm:w-[min(380px,calc(100vw-4rem))] sm:rounded-3xl',
|
||||||
|
isDetailsOpen
|
||||||
|
? 'translate-y-0 sm:translate-x-0'
|
||||||
|
: 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]',
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
|
||||||
<AppHeader
|
<MapViewport
|
||||||
status={status}
|
containerRef={mapContainerRef}
|
||||||
statusLabel={statusLabel}
|
isPosting={isPosting || isConfirming}
|
||||||
lastUpdatedLabel={lastUpdatedLabel}
|
isLoading={isLoading}
|
||||||
onRefresh={handleRefresh}
|
confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null}
|
||||||
onFocusHeat={handleFocusHeat}
|
className="min-h-screen"
|
||||||
onLocateUser={handleLocateUser}
|
|
||||||
onFocusMySignal={handleFocusMySignal}
|
|
||||||
disableRefresh={isLoading || isRefreshing || isPosting}
|
|
||||||
disableHeat={visibleDensity.length === 0}
|
|
||||||
disableLocate={!hasLocation}
|
|
||||||
disableMySignal={!myVisibleSignal}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-4 py-6 sm:px-6">
|
<div className="pointer-events-none absolute inset-0 flex flex-col">
|
||||||
<section className="flex flex-col gap-6 lg:flex-row">
|
<div className="flex items-start justify-between gap-3 p-4 sm:p-6">
|
||||||
<div className="order-2 flex w-full flex-col gap-4 lg:order-1 lg:max-w-sm">
|
<div className="pointer-events-auto">
|
||||||
|
<AppHeader
|
||||||
|
status={status}
|
||||||
|
statusLabel={statusLabel}
|
||||||
|
lastUpdatedLabel={lastUpdatedLabel}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
onFocusHeat={handleFocusHeat}
|
||||||
|
onLocateUser={handleLocateUser}
|
||||||
|
onFocusMySignal={handleFocusMySignal}
|
||||||
|
disableRefresh={isLoading || isRefreshing || isPosting}
|
||||||
|
disableHeat={visibleDensity.length === 0}
|
||||||
|
disableLocate={!hasLocation}
|
||||||
|
disableMySignal={!myVisibleSignal}
|
||||||
|
collapsed={isHeaderCollapsed}
|
||||||
|
onToggleCollapse={() => setIsHeaderCollapsed((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto hidden sm:flex">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsDetailsOpen((prev) => !prev)}
|
||||||
|
aria-label={detailsToggleLabel}
|
||||||
|
aria-expanded={isDetailsOpen}
|
||||||
|
className="h-11 w-11 rounded-full border border-border/60 bg-background/80 shadow-lg backdrop-blur hover:bg-background"
|
||||||
|
>
|
||||||
|
{isDetailsOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isDetailsOpen && (
|
||||||
|
<div className="pointer-events-auto fixed bottom-4 right-4 z-30 sm:hidden">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsDetailsOpen(true)}
|
||||||
|
aria-label={detailsToggleLabel}
|
||||||
|
className="flex items-center gap-2 rounded-full border border-border/60 bg-background/85 px-4 py-2 text-xs font-semibold uppercase tracking-wide shadow-lg backdrop-blur"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
<span>{t('details.open')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={detailsPanelClassName}
|
||||||
|
role="complementary"
|
||||||
|
aria-label={t('details.title')}
|
||||||
|
aria-hidden={!isDetailsOpen}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between gap-2 border-b border-border/60 bg-background/80 px-4 py-3 backdrop-blur">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold">{t('details.title')}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('details.description')}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsDetailsOpen(false)}
|
||||||
|
aria-label={t('details.close')}
|
||||||
|
className="h-9 w-9 rounded-full border border-border/60 bg-muted/60 backdrop-blur hover:bg-muted"
|
||||||
|
>
|
||||||
|
<PanelRightClose className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="flex flex-col gap-4 p-4 pb-6">
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 shadow-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-primary">
|
||||||
|
<Layers className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<span className="text-sm font-semibold">{t('map.tiles.title')}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('map.tiles.subtitle')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{tileOptions.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant={tileProvider === option.value ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTileProvider(option.value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border border-border/60 px-4 py-1 text-xs font-medium transition-colors',
|
||||||
|
tileProvider === option.value
|
||||||
|
? 'shadow-sm'
|
||||||
|
: 'bg-background/80 text-muted-foreground hover:bg-background',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<OverviewPanel
|
<OverviewPanel
|
||||||
nearbySignals={localTotals.points}
|
nearbySignals={localTotals.points}
|
||||||
uniqueContributors={localTotals.contributors}
|
uniqueContributors={localTotals.contributors}
|
||||||
@@ -248,17 +374,8 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<ActivityPanel items={recentActivity} emptyMessage={t('activity.empty')} />
|
<ActivityPanel items={recentActivity} emptyMessage={t('activity.empty')} />
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
<div className="order-1 flex w-full flex-1 lg:order-2">
|
</aside>
|
||||||
<MapViewport
|
|
||||||
containerRef={mapContainerRef}
|
|
||||||
isPosting={isPosting || isConfirming}
|
|
||||||
isLoading={isLoading}
|
|
||||||
confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={isConfirmOpen}
|
open={isConfirmOpen}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react'
|
import { ChevronDown, ChevronUp, Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { LanguageToggle } from '@/components/layout/LanguageToggle'
|
import { LanguageToggle } from '@/components/layout/LanguageToggle'
|
||||||
import { ThemeToggle } from '@/components/layout/ThemeToggle'
|
import { ThemeToggle } from '@/components/layout/ThemeToggle'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { FeedStatus } from '@/types/api'
|
import type { FeedStatus } from '@/types/api'
|
||||||
|
|
||||||
interface AppHeaderProps {
|
interface AppHeaderProps {
|
||||||
@@ -19,6 +20,9 @@ interface AppHeaderProps {
|
|||||||
disableHeat: boolean
|
disableHeat: boolean
|
||||||
disableLocate: boolean
|
disableLocate: boolean
|
||||||
disableMySignal: boolean
|
disableMySignal: boolean
|
||||||
|
collapsed: boolean
|
||||||
|
onToggleCollapse: () => void
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({
|
export function AppHeader({
|
||||||
@@ -33,42 +37,69 @@ export function AppHeader({
|
|||||||
disableHeat,
|
disableHeat,
|
||||||
disableLocate,
|
disableLocate,
|
||||||
disableMySignal,
|
disableMySignal,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
className,
|
||||||
}: AppHeaderProps) {
|
}: AppHeaderProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isError = status === 'error'
|
const isError = status === 'error'
|
||||||
|
|
||||||
|
const statusBadge = (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-1 text-xs font-medium uppercase tracking-wide',
|
||||||
|
collapsed && 'px-2 py-1 text-[11px] font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-2 ${isError ? 'text-destructive' : 'text-primary'}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="relative block h-2.5 w-2.5 rounded-full bg-current">
|
||||||
|
<span className="absolute inset-[-0.35rem] rounded-full border border-current opacity-40 animate-status-pulse" />
|
||||||
|
</span>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="text-[10px] uppercase text-muted-foreground">
|
||||||
|
{t('header.badge.updated', { time: lastUpdatedLabel })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur">
|
<header
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
className={cn(
|
||||||
|
'flex w-full max-w-[420px] flex-col gap-3 rounded-3xl border border-border/60 bg-background/90 p-4 text-sm shadow-xl backdrop-blur transition-all',
|
||||||
|
collapsed && 'max-w-[240px] bg-background/80 p-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-border/60 bg-primary/10 text-primary">
|
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-border/60 bg-primary/10 text-primary">
|
||||||
<Flame className="h-5 w-5" />
|
<Flame className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-lg font-semibold sm:text-xl">{t('app.name')}</span>
|
<span className="text-base font-semibold sm:text-lg">{t('app.name')}</span>
|
||||||
<span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</span>
|
{!collapsed && <span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
|
<Button
|
||||||
<Badge
|
variant="ghost"
|
||||||
variant="secondary"
|
size="icon"
|
||||||
className={
|
onClick={onToggleCollapse}
|
||||||
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-1 text-xs font-medium uppercase tracking-wide'
|
aria-label={collapsed ? t('header.actions.expand') : t('header.actions.collapse')}
|
||||||
}
|
className="h-9 w-9 rounded-full border border-border/50 bg-muted/60 backdrop-blur hover:bg-muted"
|
||||||
>
|
>
|
||||||
<span
|
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||||
className={`flex items-center gap-2 ${isError ? 'text-destructive' : 'text-primary'}`}
|
</Button>
|
||||||
aria-live="polite"
|
</div>
|
||||||
>
|
<div className="flex items-center gap-2">{statusBadge}</div>
|
||||||
<span className="relative block h-2.5 w-2.5 rounded-full bg-current">
|
{!collapsed && (
|
||||||
<span className="absolute inset-[-0.35rem] rounded-full border border-current opacity-40 animate-status-pulse" />
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</span>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] uppercase text-muted-foreground">
|
|
||||||
{t('header.badge.updated', { time: lastUpdatedLabel })}
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -108,7 +139,7 @@ export function AppHeader({
|
|||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,25 @@ interface MapViewportProps {
|
|||||||
isPosting: boolean
|
isPosting: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
confirmationHint?: string | null
|
confirmationHint?: string | null
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) {
|
export function MapViewport({
|
||||||
|
containerRef,
|
||||||
|
isPosting,
|
||||||
|
isLoading,
|
||||||
|
confirmationHint,
|
||||||
|
className,
|
||||||
|
}: MapViewportProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-[360px] flex-1 overflow-hidden rounded-3xl border border-border/50 bg-muted/40 shadow-inner">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative h-full min-h-screen w-full overflow-hidden bg-muted/40 shadow-inner',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div ref={containerRef} className={cn('absolute inset-0 z-0', 'leaflet-wrapper')} />
|
<div ref={containerRef} className={cn('absolute inset-0 z-0', 'leaflet-wrapper')} />
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background/70 to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background/70 to-transparent" />
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-gradient-to-t from-background/70 to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-gradient-to-t from-background/70 to-transparent" />
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import type { MutableRefObject } from 'react'
|
import type { MutableRefObject } from 'react'
|
||||||
import L, { type LeafletMouseEvent, type Map as LeafletMap, type LayerGroup } from 'leaflet'
|
import L, {
|
||||||
|
type LeafletMouseEvent,
|
||||||
|
type Map as LeafletMap,
|
||||||
|
type LayerGroup,
|
||||||
|
type TileLayer,
|
||||||
|
type TileLayerOptions,
|
||||||
|
} from 'leaflet'
|
||||||
import 'leaflet.heat'
|
import 'leaflet.heat'
|
||||||
|
|
||||||
import type { ApiDensityCell, LatLng } from '@/types/api'
|
import type { ApiDensityCell, LatLng } from '@/types/api'
|
||||||
@@ -20,6 +26,7 @@ interface UseLeafletHeatmapParams {
|
|||||||
heatCells: ApiDensityCell[]
|
heatCells: ApiDensityCell[]
|
||||||
userLocation: LatLng | null
|
userLocation: LatLng | null
|
||||||
onRequestSpot?: (position: LatLng) => void
|
onRequestSpot?: (position: LatLng) => void
|
||||||
|
tileProvider: TileProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseLeafletHeatmapResult {
|
interface UseLeafletHeatmapResult {
|
||||||
@@ -29,15 +36,54 @@ interface UseLeafletHeatmapResult {
|
|||||||
map: LeafletMap | null
|
map: LeafletMap | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TILE_SOURCES = {
|
||||||
|
openstreetmap: {
|
||||||
|
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
},
|
||||||
|
mapbox: {
|
||||||
|
url: 'https://api.mapbox.com/styles/v1/mapbox/navigation-day-v1/tiles/{z}/{x}/{y}?access_token=pk.eyJ1IjoiMWNhbnNhIiwiYSI6ImNsdzZ5cHp3bTFheWUydHJ6dHA4empteWEifQ.a3bODguIOY5HqhsVIvW48Q',
|
||||||
|
attribution: '© Mapbox, © OpenStreetMap contributors',
|
||||||
|
options: {
|
||||||
|
tileSize: 512,
|
||||||
|
zoomOffset: -1,
|
||||||
|
} satisfies TileLayerOptions,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TileProvider = keyof typeof TILE_SOURCES
|
||||||
|
|
||||||
const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 }
|
const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 }
|
||||||
const DEFAULT_ZOOM = 3
|
const DEFAULT_ZOOM = 3
|
||||||
|
|
||||||
export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: UseLeafletHeatmapParams): UseLeafletHeatmapResult {
|
export function useLeafletHeatmap({
|
||||||
|
heatCells,
|
||||||
|
userLocation,
|
||||||
|
onRequestSpot,
|
||||||
|
tileProvider,
|
||||||
|
}: UseLeafletHeatmapParams): UseLeafletHeatmapResult {
|
||||||
const mapRef = useRef<LeafletMap | null>(null)
|
const mapRef = useRef<LeafletMap | null>(null)
|
||||||
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
|
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
|
||||||
const userLayerRef = useRef<LayerGroup | null>(null)
|
const userLayerRef = useRef<LayerGroup | null>(null)
|
||||||
|
const tileLayerRef = useRef<TileLayer | null>(null)
|
||||||
const hasCenteredOnUserRef = useRef(false)
|
const hasCenteredOnUserRef = useRef(false)
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const onRequestSpotRef = useRef(onRequestSpot)
|
||||||
|
const tileProviderRef = useRef<TileProvider>(tileProvider)
|
||||||
|
|
||||||
|
const createTileLayer = useCallback(
|
||||||
|
(provider: TileProvider) => {
|
||||||
|
const leaflet = L as LeafletWithHeat
|
||||||
|
const source = TILE_SOURCES[provider]
|
||||||
|
return leaflet.tileLayer(source.url, {
|
||||||
|
attribution: source.attribution,
|
||||||
|
crossOrigin: true,
|
||||||
|
maxZoom: 19,
|
||||||
|
...(source.options ?? {}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const initialiseMap = useCallback(() => {
|
const initialiseMap = useCallback(() => {
|
||||||
if (mapRef.current || !mapContainerRef.current) {
|
if (mapRef.current || !mapContainerRef.current) {
|
||||||
@@ -56,13 +102,8 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us
|
|||||||
})
|
})
|
||||||
.setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM)
|
.setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM)
|
||||||
|
|
||||||
leaflet
|
const tileLayer = createTileLayer(tileProviderRef.current)
|
||||||
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
tileLayer.addTo(map)
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
crossOrigin: true,
|
|
||||||
maxZoom: 19,
|
|
||||||
})
|
|
||||||
.addTo(map)
|
|
||||||
|
|
||||||
const heatLayer =
|
const heatLayer =
|
||||||
typeof leaflet.heatLayer === 'function'
|
typeof leaflet.heatLayer === 'function'
|
||||||
@@ -89,7 +130,7 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us
|
|||||||
const handleClick = (event: LeafletMouseEvent) => {
|
const handleClick = (event: LeafletMouseEvent) => {
|
||||||
const { lat, lng } = event.latlng
|
const { lat, lng } = event.latlng
|
||||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||||
onRequestSpot?.({ lat, lng })
|
onRequestSpotRef.current?.({ lat, lng })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +153,7 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us
|
|||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
heatLayerRef.current = heatLayer
|
heatLayerRef.current = heatLayer
|
||||||
userLayerRef.current = userLayer
|
userLayerRef.current = userLayer
|
||||||
|
tileLayerRef.current = tileLayer
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.off('click', handleClick)
|
map.off('click', handleClick)
|
||||||
@@ -120,8 +162,13 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us
|
|||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
heatLayerRef.current = null
|
heatLayerRef.current = null
|
||||||
userLayerRef.current = null
|
userLayerRef.current = null
|
||||||
|
tileLayerRef.current = null
|
||||||
hasCenteredOnUserRef.current = false
|
hasCenteredOnUserRef.current = false
|
||||||
}
|
}
|
||||||
|
}, [createTileLayer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onRequestSpotRef.current = onRequestSpot
|
||||||
}, [onRequestSpot])
|
}, [onRequestSpot])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,6 +181,24 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us
|
|||||||
}
|
}
|
||||||
}, [initialiseMap])
|
}, [initialiseMap])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tileProviderRef.current = tileProvider
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLayer = createTileLayer(tileProvider)
|
||||||
|
const currentLayer = tileLayerRef.current
|
||||||
|
|
||||||
|
if (currentLayer) {
|
||||||
|
currentLayer.removeFrom(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLayer.addTo(map)
|
||||||
|
tileLayerRef.current = nextLayer
|
||||||
|
}, [createTileLayer, tileProvider])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const heatLayer = heatLayerRef.current
|
const heatLayer = heatLayerRef.current
|
||||||
if (!heatLayer) {
|
if (!heatLayer) {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
"refresh": "Refresh now",
|
"refresh": "Refresh now",
|
||||||
"focusHeat": "Focus heatmap",
|
"focusHeat": "Focus heatmap",
|
||||||
"locate": "Locate me",
|
"locate": "Locate me",
|
||||||
"mySignal": "My last signal"
|
"mySignal": "My last signal",
|
||||||
|
"collapse": "Hide toolbar",
|
||||||
|
"expand": "Show toolbar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -59,7 +61,19 @@
|
|||||||
"map": {
|
"map": {
|
||||||
"posting": "Sending your signal…",
|
"posting": "Sending your signal…",
|
||||||
"loading": "Syncing map…",
|
"loading": "Syncing map…",
|
||||||
"confirmationHint": "Confirm the new signal in the dialog to send it."
|
"confirmationHint": "Confirm the new signal in the dialog to send it.",
|
||||||
|
"tiles": {
|
||||||
|
"title": "Base map",
|
||||||
|
"subtitle": "Choose the background layer you prefer for navigation.",
|
||||||
|
"openstreetmap": "OpenStreetMap",
|
||||||
|
"mapbox": "Mapbox Navigation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"title": "Live feed details",
|
||||||
|
"description": "View stats, switch tiles and review nearby activity.",
|
||||||
|
"open": "Details",
|
||||||
|
"close": "Close details"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"confirmSignal": {
|
"confirmSignal": {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
"refresh": "Actualiser",
|
"refresh": "Actualiser",
|
||||||
"focusHeat": "Centrer la chaleur",
|
"focusHeat": "Centrer la chaleur",
|
||||||
"locate": "Me localiser",
|
"locate": "Me localiser",
|
||||||
"mySignal": "Mon dernier signal"
|
"mySignal": "Mon dernier signal",
|
||||||
|
"collapse": "Masquer la barre",
|
||||||
|
"expand": "Afficher la barre"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -59,7 +61,19 @@
|
|||||||
"map": {
|
"map": {
|
||||||
"posting": "Envoi de votre signal…",
|
"posting": "Envoi de votre signal…",
|
||||||
"loading": "Synchronisation de la carte…",
|
"loading": "Synchronisation de la carte…",
|
||||||
"confirmationHint": "Confirmez le nouveau signal dans la fenêtre pour l'envoyer."
|
"confirmationHint": "Confirmez le nouveau signal dans la fenêtre pour l'envoyer.",
|
||||||
|
"tiles": {
|
||||||
|
"title": "Fond de carte",
|
||||||
|
"subtitle": "Choisissez la couche de fond adaptée à votre navigation.",
|
||||||
|
"openstreetmap": "OpenStreetMap",
|
||||||
|
"mapbox": "Navigation Mapbox"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"title": "Détails du flux en direct",
|
||||||
|
"description": "Consultez les statistiques, changez de fond de carte et suivez l'activité proche.",
|
||||||
|
"open": "Détails",
|
||||||
|
"close": "Fermer les détails"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"confirmSignal": {
|
"confirmSignal": {
|
||||||
|
|||||||
Reference in New Issue
Block a user