'use client'; /** * Map block — thin chat wrapper around `LazyMapContainer`. Renders point * markers plus optional routes (polylines) and polygons (filled zones) by * building GeoJSON from the block's lat/lng payloads and feeding the map's * existing `MapSource` + layer factories — no bespoke layer logic here. */ import { LazyMapContainer, MapMarker, MapPopup, MarkerCard, MapSource, MapLayer, useMarkerCard } from '../../../../dev/Map/lazy'; import type { MarkerData } from '../../../../dev/Map/lazy'; import type { MapContainerProps } from '../../../../dev/Map/components'; import { createRouteLayers, createPolygonLayer, createPolygonOutlineLayer, } from '../../../../dev/Map/layers'; import { calculateBounds, createLineString, createPolygon, createFeatureCollection, } from '../../../../dev/Map/utils'; import type { MapBlock } from '../../../types/block'; import type { BlockRendererProps } from './types'; /** `[lng, lat]` tuple — GeoJSON / maplibre coordinate order. */ type LngLat = [number, number]; /** * Derive an `initialViewport` that frames every coordinate. The map exposes * an imperative `fitBounds` only through `useMapControl` (inside its own * provider), which this frameless wrapper sits outside of — so instead we * seed the initial center/zoom from the bounding box. Mirrors maplibre's own * fit math: zoom for whichever axis (lng/lat) needs the most room. */ function viewportForBounds(points: LngLat[]): { longitude: number; latitude: number; zoom: number } { const [[minLng, minLat], [maxLng, maxLat]] = calculateBounds(points) as [LngLat, LngLat]; const longitude = (minLng + maxLng) / 2; const latitude = (minLat + maxLat) / 2; const lngSpan = Math.max(maxLng - minLng, 1e-6); const latSpan = Math.max(maxLat - minLat, 1e-6); // World is 360° at zoom 0; each zoom level halves the visible span. Add a // little padding (0.6) so geometry never hugs the edges, and clamp. const zoomForSpan = (span: number, fullSpan: number) => Math.log2((fullSpan / span) * 0.6); const zoom = Math.min(16, Math.max(1, Math.min(zoomForSpan(lngSpan, 360), zoomForSpan(latSpan, 180)))); return { longitude, latitude, zoom }; } type BlockMarker = NonNullable[number]; /** * Marker layer for the chat map block. Renders each marker (preserving the * `icon` thumbnail / `color` pin) and, when a marker carries a serializable * `card`, opens a `MarkerCard` popup ABOVE the pin on click. Selection is * single-card via `useMarkerCard`. Lives inside `LazyMapContainer` so it has * the MapProvider context. */ function MapBlockMarkers({ markers }: { markers: BlockMarker[] }) { const { openId, toggle, close } = useMarkerCard(); const open = openId ? markers.find((m) => m.id === openId) : undefined; return ( <> {markers.map((m) => { const marker: MarkerData = { id: m.id, longitude: m.lng, latitude: m.lat, data: m.label ? { label: m.label } : undefined, }; return ( toggle(m.id) : undefined} > {m.icon ? ( {m.label ) : undefined} ); })} {open?.card && ( )} ); } export default function MapBlockRenderer({ block, ctx }: BlockRendererProps) { const height = ctx.appearance === 'compact' ? 240 : 380; // The serializable map shape (center / markers / routes / polygons) is // validated centrally at the registry dispatch point (MessageBlocks) before // this renderer runs, so we can trust the payload here. const routes = block.routes ?? []; const polygons = block.polygons ?? []; const markers = block.markers ?? []; // Collect every coordinate so the viewport can frame the whole scene when // the block doesn't pin an explicit center/zoom. const allPoints: LngLat[] = [ ...markers.map((m): LngLat => [m.lng, m.lat]), ...routes.flatMap((r) => r.points.map((p): LngLat => [p.lng, p.lat])), ...polygons.flatMap((p) => p.points.map((pt): LngLat => [pt.lng, pt.lat])), ]; // Respect an explicit zoom (author intent); otherwise auto-fit to geometry. // Center always defaults to the block's `center` unless we have geometry to // frame and no explicit zoom was given. const fitted = block.zoom === undefined && allPoints.length > 1 ? viewportForBounds(allPoints) : null; const initialViewport = { longitude: fitted?.longitude ?? block.center.lng, latitude: fitted?.latitude ?? block.center.lat, zoom: block.zoom ?? fitted?.zoom ?? 11, }; // Build the optional props into ONE typed object before spreading, rather // than several inline conditional `{...(cond ? {…} : {})}` spreads. The // inline form unions each branch to `{} | {prop}` and widens the call to a // shape no single `MapContainer` overload accepts (TS2769) under stricter // consumer resolution; a single `Partial` literal is // checked against the prop type as a unit. const mapOptions: Partial = { ...(block.basemap ? { mapStyle: block.basemap } : {}), ...(block.terrain ? { terrain: true } : {}), ...(block.userLocation ? { geolocate: true } : {}), }; // Frame (radius / border / shadow / clip) comes from the shared // `BLOCK_SURFACE` in MessageBlocks — here we only set the height. return (
{/* Filled zones first so routes/markers stack above them. */} {polygons.map((poly) => { const sourceId = `poly-${poly.id}`; const data = createFeatureCollection([ createPolygon(poly.points.map((p): LngLat => [p.lng, p.lat]), poly.label ? { label: poly.label } : {}), ]); const fill = createPolygonLayer(`${sourceId}-fill`, sourceId, { fillColor: poly.fillColor, strokeColor: poly.strokeColor, }); const outline = createPolygonOutlineLayer(`${sourceId}-outline`, sourceId, { color: poly.strokeColor, }); return ( ); })} {/* Routes (polylines) — white-outlined line via the route factory. */} {routes.map((route) => { const sourceId = `route-${route.id}`; const data = createFeatureCollection([ createLineString(route.points.map((p): LngLat => [p.lng, p.lat]), route.label ? { label: route.label } : {}), ]); const { outline, route: line } = createRouteLayers({ sourceId, color: route.color }); return ( ); })}
); }