diff --git a/components/fleet/FleetMapClient.tsx b/components/fleet/FleetMapClient.tsx index 1af2058..2e3f1fd 100644 --- a/components/fleet/FleetMapClient.tsx +++ b/components/fleet/FleetMapClient.tsx @@ -1,10 +1,16 @@ 'use client'; -import { useEffect, useMemo } from 'react'; -import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet'; +import { useEffect, useMemo, useState } from 'react'; +import { + MapContainer, + TileLayer, + CircleMarker, + Tooltip, + useMapEvents, +} from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; -import { clusterDrivers } from './clustering'; +import { cellSizeForZoom, clusterDrivers } from './clustering'; import type { Driver } from '@/types/fleet'; interface FleetMapClientProps { @@ -12,6 +18,7 @@ interface FleetMapClientProps { } const DEFAULT_CENTER: [number, number] = [9.082, 8.6753]; // Nigeria centroid +const INITIAL_ZOOM = 6; function pickCenter(drivers: Driver[]): [number, number] { if (drivers.length === 0) return DEFAULT_CENTER; @@ -29,30 +36,32 @@ function radiusFor(count: number): number { return 24; } -export default function FleetMapClient({ drivers }: FleetMapClientProps) { - const clusters = useMemo(() => clusterDrivers(drivers), [drivers]); - const center = useMemo(() => pickCenter(drivers), [drivers]); +/** + * Renders one marker per cluster and re-buckets whenever the map zoom changes. + * + * Lives inside `MapContainer` because `useMapEvents` needs the Leaflet map from + * context. Zooming out widens the clustering grid, so nearby drivers merge into + * a single marker instead of overlapping pins. + */ +function DriverClusterLayer({ drivers }: FleetMapClientProps) { + const [zoomFromEvent, setZoomFromEvent] = useState(null); - // Ensure Leaflet picks up the CSS-loaded marker icons in environments - // (Next.js bundles) where the default detection misfires. Without this - // the default markers can render broken even when CSS is imported. - useEffect(() => { - const proto = L.Icon.Default.prototype as { _getIconUrl?: unknown }; - if (proto._getIconUrl) delete proto._getIconUrl; - }, []); + const map = useMapEvents({ + zoomend: () => setZoomFromEvent(map.getZoom()), + }); + + // Before the first `zoomend` the map may already sit at a zoom other than + // INITIAL_ZOOM (a restored view, or `fitBounds`), so read the live value and + // let subsequent events take over. + const zoom = zoomFromEvent ?? map?.getZoom?.() ?? INITIAL_ZOOM; + + const clusters = useMemo( + () => clusterDrivers(drivers, cellSizeForZoom(zoom)), + [drivers, zoom], + ); return ( - - + <> {clusters.map((cluster) => { const count = cluster.drivers.length; const isCluster = count > 1; @@ -78,6 +87,34 @@ export default function FleetMapClient({ drivers }: FleetMapClientProps) { ); })} + + ); +} + +export default function FleetMapClient({ drivers }: FleetMapClientProps) { + const center = useMemo(() => pickCenter(drivers), [drivers]); + + // Ensure Leaflet picks up the CSS-loaded marker icons in environments + // (Next.js bundles) where the default detection misfires. Without this + // the default markers can render broken even when CSS is imported. + useEffect(() => { + const proto = L.Icon.Default.prototype as { _getIconUrl?: unknown }; + if (proto._getIconUrl) delete proto._getIconUrl; + }, []); + + return ( + + + ); } diff --git a/components/fleet/__tests__/FleetMapClient.test.tsx b/components/fleet/__tests__/FleetMapClient.test.tsx new file mode 100644 index 0000000..fd0d12b --- /dev/null +++ b/components/fleet/__tests__/FleetMapClient.test.tsx @@ -0,0 +1,344 @@ +/** + * FleetMapClient component tests. + * + * Covers the fleet manager live map: marker clustering as the manager zooms + * out, re-clustering when driver telemetry arrives, and the empty/degenerate + * data paths. + * + * Leaflet is mocked wholesale - jsdom has no layout engine, so the real + * MapContainer cannot mount. The stubs expose the props the component passes + * down (center, radius, colour) as data attributes so the assertions describe + * what a fleet manager would see on the map. + */ + +import { render, screen, act } from '@testing-library/react'; +import FleetMapClient from '@/components/fleet/FleetMapClient'; +import { cellSizeForZoom } from '@/components/fleet/clustering'; +import type { Driver } from '@/types/fleet'; + +// Captured across renders so tests can drive Leaflet's `zoomend` event. +const mockMapState = { zoom: 6 }; +let mockZoomHandler: (() => void) | undefined; + +jest.mock('leaflet/dist/leaflet.css', () => ({})); + +jest.mock('leaflet', () => ({ + __esModule: true, + default: { + Icon: { Default: { prototype: { _getIconUrl: () => 'icon.png' } } }, + }, +})); + +jest.mock('react-leaflet', () => { + const React = require('react'); + return { + __esModule: true, + MapContainer: ({ children, center, zoom, ...rest }: any) => + React.createElement( + 'div', + { + 'data-testid': 'map-container', + 'data-center': center.join(','), + 'data-zoom': String(zoom), + 'aria-label': rest['aria-label'], + }, + children, + ), + TileLayer: ({ url }: any) => + React.createElement('div', { + 'data-testid': 'tile-layer', + 'data-url': url, + }), + CircleMarker: ({ children, center, radius, pathOptions }: any) => + React.createElement( + 'div', + { + 'data-testid': 'cluster-marker', + 'data-center': center.join(','), + 'data-radius': String(radius), + 'data-color': pathOptions.color, + }, + children, + ), + Tooltip: ({ children }: any) => + React.createElement('div', { 'data-testid': 'marker-tooltip' }, children), + useMapEvents: (handlers: { zoomend?: () => void }) => { + mockZoomHandler = handlers.zoomend; + return { getZoom: () => mockMapState.zoom }; + }, + }; +}); + +function driver(id: string, lat: number, lng: number): Driver { + return { + id, + name: `Driver ${id}`, + phone: '+2348000000000', + vehicleType: 'Van', + vehiclePlate: `PLATE-${id}`, + status: 'active', + rating: 4.5, + activeDeliveries: 1, + completedDeliveries: 10, + location: { lat, lng, updatedAt: '2026-04-25T00:00:00Z' }, + }; +} + +/** Simulates the manager zooming the map, mirroring Leaflet's `zoomend`. */ +function zoomTo(level: number) { + mockMapState.zoom = level; + act(() => { + mockZoomHandler?.(); + }); +} + +function markers() { + return screen.queryAllByTestId('cluster-marker'); +} + +function tooltipTexts() { + return screen.queryAllByTestId('marker-tooltip').map((el) => el.textContent); +} + +// Two drivers a few km apart: distinct at street zoom, one pin at country zoom. +const LAGOS_A = driver('a', 6.51, 3.41); +const LAGOS_B = driver('b', 6.59, 3.49); +// A third, far away in Abuja, that must never merge with the Lagos pair. +const ABUJA = driver('c', 9.06, 7.49); + +describe('FleetMapClient', () => { + beforeEach(() => { + mockMapState.zoom = 6; + mockZoomHandler = undefined; + }); + + describe('base rendering', () => { + it('renders the map shell and OpenStreetMap tiles', () => { + render(); + + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + expect(screen.getByTestId('tile-layer')).toHaveAttribute( + 'data-url', + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + }); + + it('centres the map on the mean driver position', () => { + render(); + + const [lat, lng] = screen + .getByTestId('map-container') + .getAttribute('data-center')! + .split(',') + .map(Number); + + expect(lat).toBeCloseTo(6.55, 5); + expect(lng).toBeCloseTo(3.45, 5); + }); + + it('falls back to the default centre when there are no drivers', () => { + render(); + + expect(screen.getByTestId('map-container')).toHaveAttribute( + 'data-center', + '9.082,8.6753', + ); + }); + + it('renders one marker per driver when they are far apart', () => { + render(); + + expect(markers()).toHaveLength(2); + expect(tooltipTexts()).toEqual( + expect.arrayContaining(['Driver a', 'Driver c']), + ); + }); + }); + + describe('clustering on zoom', () => { + it('keeps nearby drivers separate while zoomed in', () => { + render(); + + zoomTo(13); + + expect(markers()).toHaveLength(2); + expect(tooltipTexts()).toEqual( + expect.arrayContaining(['Driver a', 'Driver b']), + ); + }); + + it('merges nearby drivers into one cluster when zoomed out', () => { + render(); + + zoomTo(13); + expect(markers()).toHaveLength(2); + + zoomTo(3); + + const merged = markers(); + expect(merged).toHaveLength(1); + expect(merged[0]).toHaveTextContent('2 drivers'); + }); + + it('splits a cluster back apart when the manager zooms in again', () => { + render(); + + zoomTo(3); + expect(markers()).toHaveLength(1); + + zoomTo(14); + + expect(markers()).toHaveLength(2); + expect(tooltipTexts()).not.toContain('2 drivers'); + }); + + it('never merges drivers in different cities at a regional zoom', () => { + render(); + + zoomTo(3); + + const rendered = markers(); + expect(rendered).toHaveLength(2); + expect(tooltipTexts()).toEqual( + expect.arrayContaining(['2 drivers', 'Driver c']), + ); + }); + + it('grows the marker radius and switches colour for clusters', () => { + render(); + + zoomTo(3); + + const [cluster, single] = markers().sort( + (a, b) => + Number(b.getAttribute('data-radius')) - + Number(a.getAttribute('data-radius')), + ); + + expect(cluster).toHaveAttribute('data-radius', '12'); + expect(cluster).toHaveAttribute('data-color', '#1d4ed8'); + expect(single).toHaveAttribute('data-radius', '8'); + expect(single).toHaveAttribute('data-color', '#10b981'); + }); + + it('positions a cluster marker at the centroid of its drivers', () => { + render(); + + zoomTo(3); + + const [lat, lng] = markers()[0] + .getAttribute('data-center')! + .split(',') + .map(Number); + + expect(lat).toBeCloseTo(6.55, 5); + expect(lng).toBeCloseTo(3.45, 5); + }); + + it('adopts the map instance zoom on mount instead of assuming the initial one', () => { + mockMapState.zoom = 3; + + render(); + + expect(markers()).toHaveLength(1); + expect(markers()[0]).toHaveTextContent('2 drivers'); + }); + }); + + describe('live driver updates', () => { + it('re-clusters when a driver location update arrives', () => { + const { rerender } = render( + , + ); + expect(markers()).toHaveLength(2); + + // The Abuja driver's telemetry moves them next to the Lagos driver. + rerender( + , + ); + + expect(markers()).toHaveLength(1); + expect(markers()[0]).toHaveTextContent('2 drivers'); + }); + + it('adds a marker when a new driver comes online', () => { + const { rerender } = render(); + expect(markers()).toHaveLength(1); + + rerender(); + + expect(markers()).toHaveLength(2); + }); + + it('removes markers when every driver goes offline', () => { + const { rerender } = render( + , + ); + + rerender(); + + expect(markers()).toHaveLength(0); + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + }); + + it('keeps the cluster stable when an unrelated driver field changes', () => { + const { rerender } = render( + , + ); + zoomTo(3); + expect(markers()).toHaveLength(1); + + rerender( + , + ); + + expect(markers()).toHaveLength(1); + expect(markers()[0]).toHaveTextContent('2 drivers'); + }); + }); + + describe('empty and edge cases', () => { + it('renders no markers for an empty fleet', () => { + render(); + + expect(markers()).toHaveLength(0); + expect(screen.getByTestId('tile-layer')).toBeInTheDocument(); + }); + + it('renders a single marker for drivers sharing an exact position', () => { + const shared = { lat: 6.51, lng: 3.41, updatedAt: '2026-04-25T00:00:00Z' }; + render( + , + ); + + expect(markers()).toHaveLength(1); + expect(markers()[0]).toHaveTextContent('2 drivers'); + }); + + it('survives a NaN zoom by falling back to the default cell size', () => { + render(); + + zoomTo(Number.NaN); + + expect(cellSizeForZoom(Number.NaN)).toBe(0.05); + expect(markers()).toHaveLength(3); + }); + + it('keeps drivers near the equator and prime meridian separate when zoomed in', () => { + render( + , + ); + + zoomTo(12); + + expect(markers()).toHaveLength(2); + }); + }); +}); diff --git a/components/fleet/__tests__/clustering.test.ts b/components/fleet/__tests__/clustering.test.ts index 3142901..eea4f6a 100644 --- a/components/fleet/__tests__/clustering.test.ts +++ b/components/fleet/__tests__/clustering.test.ts @@ -1,4 +1,9 @@ -import { clusterDrivers } from '@/components/fleet/clustering'; +import { + cellSizeForZoom, + clusterDrivers, + DEFAULT_CLUSTER_CELL_SIZE, + REFERENCE_ZOOM, +} from '@/components/fleet/clustering'; import type { Driver } from '@/types/fleet'; function driver(id: string, lat: number, lng: number): Driver { @@ -57,3 +62,53 @@ describe('clusterDrivers', () => { expect(cluster.lng).toBeCloseTo(3.42, 3); }); }); + +describe('cellSizeForZoom', () => { + it('returns the default cell size at the reference zoom', () => { + expect(cellSizeForZoom(REFERENCE_ZOOM)).toBe(DEFAULT_CLUSTER_CELL_SIZE); + }); + + it('doubles the cell size for every zoom level the user zooms out', () => { + expect(cellSizeForZoom(REFERENCE_ZOOM - 1)).toBeCloseTo( + DEFAULT_CLUSTER_CELL_SIZE * 2, + 6, + ); + expect(cellSizeForZoom(REFERENCE_ZOOM - 3)).toBeCloseTo( + DEFAULT_CLUSTER_CELL_SIZE * 8, + 6, + ); + }); + + it('halves the cell size for every zoom level the user zooms in', () => { + expect(cellSizeForZoom(REFERENCE_ZOOM + 1)).toBeCloseTo( + DEFAULT_CLUSTER_CELL_SIZE / 2, + 6, + ); + }); + + it('is monotonically decreasing as zoom increases', () => { + const sizes = [0, 3, 6, 9, 12, 18].map(cellSizeForZoom); + const sorted = [...sizes].sort((a, b) => b - a); + expect(sizes).toEqual(sorted); + }); + + it('clamps extreme zoom levels to a usable range', () => { + expect(cellSizeForZoom(-100)).toBeLessThanOrEqual(45); + expect(cellSizeForZoom(-100)).toBeGreaterThan(0); + expect(cellSizeForZoom(100)).toBeGreaterThanOrEqual(0.001); + }); + + it('falls back to the default for a non-finite zoom', () => { + expect(cellSizeForZoom(Number.NaN)).toBe(DEFAULT_CLUSTER_CELL_SIZE); + expect(cellSizeForZoom(Number.POSITIVE_INFINITY)).toBe( + DEFAULT_CLUSTER_CELL_SIZE, + ); + }); + + it('merges drivers when zoomed out that stay separate when zoomed in', () => { + const drivers = [driver('a', 6.51, 3.41), driver('b', 6.59, 3.49)]; + + expect(clusterDrivers(drivers, cellSizeForZoom(13))).toHaveLength(2); + expect(clusterDrivers(drivers, cellSizeForZoom(3))).toHaveLength(1); + }); +}); diff --git a/components/fleet/clustering.ts b/components/fleet/clustering.ts index 0eaaf34..528d437 100644 --- a/components/fleet/clustering.ts +++ b/components/fleet/clustering.ts @@ -17,7 +17,7 @@ export interface MapCluster { */ export function clusterDrivers( drivers: Driver[], - cellSize = 0.05, + cellSize: number = DEFAULT_CLUSTER_CELL_SIZE, ): MapCluster[] { const buckets: Record = {}; @@ -44,3 +44,30 @@ export function clusterDrivers( } return clusters; } + +/** + * Cell size used at {@link REFERENCE_ZOOM}, matching the historical default. + */ +export const DEFAULT_CLUSTER_CELL_SIZE = 0.05; + +/** Leaflet zoom level at which {@link DEFAULT_CLUSTER_CELL_SIZE} applies. */ +export const REFERENCE_ZOOM = 6; + +const MIN_CELL_SIZE = 0.001; +const MAX_CELL_SIZE = 45; + +/** + * Grid cell size (in degrees) to use for a given Leaflet zoom level. + * + * One Leaflet zoom step halves the ground distance covered by a pixel, so the + * bucket has to double in size for every level the user zooms out. That keeps + * the on-screen distance between merged pins roughly constant: zooming out + * collapses neighbouring drivers into a single cluster, zooming in splits them + * back apart. The result is clamped so extreme zooms cannot degenerate into + * one bucket per driver or a single bucket for the whole world. + */ +export function cellSizeForZoom(zoom: number): number { + if (!Number.isFinite(zoom)) return DEFAULT_CLUSTER_CELL_SIZE; + const size = DEFAULT_CLUSTER_CELL_SIZE * 2 ** (REFERENCE_ZOOM - zoom); + return Math.min(MAX_CELL_SIZE, Math.max(MIN_CELL_SIZE, size)); +}