Merge pull request #4 from bernard-ng/codex/fix-map-reset-position-and-scale-on-data-update

Keep viewport persistent and modernize map layout
This commit is contained in:
Bernard Ngandu
2025-10-10 11:29:05 +02:00
committed by GitHub
6 changed files with 324 additions and 71 deletions
+146 -29
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { AppHeader } from '@/components/layout/AppHeader'
@@ -16,10 +17,12 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useHotspotFeed } from '@/hooks/useHotspotFeed'
import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap'
import { useLeafletHeatmap, type TileProvider } from '@/hooks/useLeafletHeatmap'
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'
const RADIUS_KM = 1
@@ -28,6 +31,19 @@ export default function App() {
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = 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 locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US'
const distanceFormatter = useMemo(
@@ -39,6 +55,14 @@ export default function App() {
[locale],
)
const tileOptions = useMemo(
() => [
{ value: 'openstreetmap' as TileProvider, label: t('map.tiles.openstreetmap') },
{ value: 'mapbox' as TileProvider, label: t('map.tiles.mapbox') },
],
[t],
)
const {
status,
error,
@@ -108,6 +132,7 @@ export default function App() {
setPendingSpot(position)
setIsConfirmOpen(true)
},
tileProvider,
})
const handleConfirmSignal = useCallback(async () => {
@@ -207,26 +232,127 @@ export default function App() {
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--'
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 (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<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}
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
<MapViewport
containerRef={mapContainerRef}
isPosting={isPosting || isConfirming}
isLoading={isLoading}
confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null}
className="min-h-screen"
/>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-4 py-6 sm:px-6">
<section className="flex flex-col gap-6 lg:flex-row">
<div className="order-2 flex w-full flex-col gap-4 lg:order-1 lg:max-w-sm">
<div className="pointer-events-none absolute inset-0 flex flex-col">
<div className="flex items-start justify-between gap-3 p-4 sm:p-6">
<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
nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors}
@@ -248,17 +374,8 @@ export default function App() {
/>
<ActivityPanel items={recentActivity} emptyMessage={t('activity.empty')} />
</div>
<div className="order-1 flex w-full flex-1 lg:order-2">
<MapViewport
containerRef={mapContainerRef}
isPosting={isPosting || isConfirming}
isLoading={isLoading}
confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null}
/>
</div>
</section>
</main>
</ScrollArea>
</aside>
<AlertDialog
open={isConfirmOpen}
+57 -26
View File
@@ -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 { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { ThemeToggle } from '@/components/layout/ThemeToggle'
import { cn } from '@/lib/utils'
import type { FeedStatus } from '@/types/api'
interface AppHeaderProps {
@@ -19,6 +20,9 @@ interface AppHeaderProps {
disableHeat: boolean
disableLocate: boolean
disableMySignal: boolean
collapsed: boolean
onToggleCollapse: () => void
className?: string
}
export function AppHeader({
@@ -33,42 +37,69 @@ export function AppHeader({
disableHeat,
disableLocate,
disableMySignal,
collapsed,
onToggleCollapse,
className,
}: AppHeaderProps) {
const { t } = useTranslation()
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 (
<header className="sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur">
<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">
<header
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">
<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" />
</span>
<div className="flex flex-col">
<span className="text-lg font-semibold sm:text-xl">{t('app.name')}</span>
<span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</span>
<span className="text-base font-semibold sm:text-lg">{t('app.name')}</span>
{!collapsed && <span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</span>}
</div>
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
<Badge
variant="secondary"
className={
'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'
}
>
<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>
<span className="text-[10px] uppercase text-muted-foreground">
{t('header.badge.updated', { time: lastUpdatedLabel })}
</span>
</Badge>
<Button
variant="ghost"
size="icon"
onClick={onToggleCollapse}
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"
>
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button>
</div>
<div className="flex items-center gap-2">{statusBadge}</div>
{!collapsed && (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="icon"
@@ -108,7 +139,7 @@ export function AppHeader({
<LanguageToggle />
<ThemeToggle />
</div>
</div>
)}
</header>
)
}
+14 -2
View File
@@ -8,13 +8,25 @@ interface MapViewportProps {
isPosting: boolean
isLoading: boolean
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()
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 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" />
+75 -10
View File
@@ -1,6 +1,12 @@
import { useCallback, useEffect, useRef } 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 type { ApiDensityCell, LatLng } from '@/types/api'
@@ -20,6 +26,7 @@ interface UseLeafletHeatmapParams {
heatCells: ApiDensityCell[]
userLocation: LatLng | null
onRequestSpot?: (position: LatLng) => void
tileProvider: TileProvider
}
interface UseLeafletHeatmapResult {
@@ -29,15 +36,54 @@ interface UseLeafletHeatmapResult {
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 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 heatLayerRef = useRef<LeafletHeatLayer | null>(null)
const userLayerRef = useRef<LayerGroup | null>(null)
const tileLayerRef = useRef<TileLayer | null>(null)
const hasCenteredOnUserRef = useRef(false)
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(() => {
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)
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
crossOrigin: true,
maxZoom: 19,
})
.addTo(map)
const tileLayer = createTileLayer(tileProviderRef.current)
tileLayer.addTo(map)
const heatLayer =
typeof leaflet.heatLayer === 'function'
@@ -89,7 +130,7 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us
const handleClick = (event: LeafletMouseEvent) => {
const { lat, lng } = event.latlng
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
heatLayerRef.current = heatLayer
userLayerRef.current = userLayer
tileLayerRef.current = tileLayer
return () => {
map.off('click', handleClick)
@@ -120,8 +162,13 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us
mapRef.current = null
heatLayerRef.current = null
userLayerRef.current = null
tileLayerRef.current = null
hasCenteredOnUserRef.current = false
}
}, [createTileLayer])
useEffect(() => {
onRequestSpotRef.current = onRequestSpot
}, [onRequestSpot])
useEffect(() => {
@@ -134,6 +181,24 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us
}
}, [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(() => {
const heatLayer = heatLayerRef.current
if (!heatLayer) {
+16 -2
View File
@@ -18,7 +18,9 @@
"refresh": "Refresh now",
"focusHeat": "Focus heatmap",
"locate": "Locate me",
"mySignal": "My last signal"
"mySignal": "My last signal",
"collapse": "Hide toolbar",
"expand": "Show toolbar"
}
},
"overview": {
@@ -59,7 +61,19 @@
"map": {
"posting": "Sending your signal…",
"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": {
"confirmSignal": {
+16 -2
View File
@@ -18,7 +18,9 @@
"refresh": "Actualiser",
"focusHeat": "Centrer la chaleur",
"locate": "Me localiser",
"mySignal": "Mon dernier signal"
"mySignal": "Mon dernier signal",
"collapse": "Masquer la barre",
"expand": "Afficher la barre"
}
},
"overview": {
@@ -59,7 +61,19 @@
"map": {
"posting": "Envoi de votre signal…",
"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": {
"confirmSignal": {