shadcn-map refactor — refined tasks¶
Source idea (verbatim from the request):
for a nice consistent styling, analyze the following shadcn map component. https://shadcn-map.vercel.app/docs lay out an implementation plan to refactor our current implementation to leverage the shadcn map component.
What shadcn-map actually is¶
shadcn-map (MIT, tonghohin) is a
single-file shadcn registry component — registry/new-york-v4/ui/map.tsx — that
wraps react-leaflet in shadcn/ui-styled primitives. It is installed with:
Facts verified by reading registry.json and the registry source, not assumed:
- It is a thin wrapper, not a replacement engine.
MaprendersMapContainerwithattributionControl={false},zoomControl={false},zoom = 15,maxZoom = 18andclassName="z-50 size-full min-h-96 flex-1 rounded-md", then spreads...props. Everything else fromMapContainerPropspasses through, socrs={L.CRS.Simple},zoomSnap,minZoometc. keep working exactly as today. - It solves our client-only problem internally.
createLazyComponentwraps every react-leaflet import inlazy()+ anisMountedgate, anduseLeaflet()dynamically importsleaflet,leaflet-drawandleaflet.fullscreenin an effect. It exportsuseLeafletfor consumers. - It exports
MapTileLayer, but that is not our tile layer. It is a light/dark Carto basemap switcher driven bynext-themes, plus registration into theMapLayerscontext. OurGridTileLayerURL scheme ({path}/{z-minZoom}/map_{x}_{y}.webpwith negativey) cannot be expressed as a{z}/{x}/{y}template, and image uploads are anL.ImageOverlay. MapMarkerbuilds a freshL.divIconfromrenderToString(icon)on every render. This is precisely the churn the comment in app/components/map.tsx documents (setIconrebinds popups and resets marker DOM).- Registry deps do not match our style.
registry.jsonlistsbutton,button-group,dropdown-menuand the remoteplace-autocomplete.json. Ours arebase-rhea/ Base UI (app/components/ui/dropdown-menu.tsx imports@base-ui/react/menu); the registry source is Radix-flavoured (DropdownMenuContent container={map.getContainer()}). registry.jsonships acssblock of@layer baseLeaflet overrides (.leaflet-container,.leaflet-popup-*,.leaflet-tooltip,.leaflet-div-icon,.leaflet-control-attribution,.marker-cluster div, …) that the CLI appends to app/app.css.- New runtime deps:
leaflet-draw,leaflet.markercluster,leaflet.fullscreen,react-leaflet-markercluster,next-themes; dev:@types/leaflet-draw,@types/leaflet.markercluster.
Decisions taken¶
| Question | Decision |
|---|---|
| Install method | Run the CLI, then adapt the generated file to Base UI |
| Scope | Full component — core, zoom, fullscreen, layers, draw, cluster, locate, search |
GridTileLayer / upload overlay |
Keep as our own child components inside <Map> |
| POI pins | Move to MapMarker, wrapped in React.memo |
Known frictions to resolve during implementation¶
- Base UI vs Radix dropdown.
MapLayersControluses<DropdownMenuContent align="end" container={map.getContainer()}>. OurDropdownMenuContentis a Base UIMenu.Portal+Positioner+Popup; the portal target prop is onMenu.Portal.DropdownMenuRadioGroup,DropdownMenuRadioItemandDropdownMenuCheckboxItemexist in our file but their prop names must be checked one-for-one. next-themesinMapTileLayer. The app is dark-only — app/root.tsx hardcodes<body className="dark">. Rather than installnext-themesfor a Carto basemap we will never render, drop theuseTheme()call and thedarkUrl/darkAttributionprops fromMapTileLayer.place-autocompleteforMapSearchControl. The remote registry dep is also Radix-based and geocodes real-world addresses. It will be installed and adapted, butMapSearchControlstays unmounted in our routes until there is a fictional-map place index to search.react-leafletversion. We pin5.0.0-rc.2; shadcn-map targets stable5.x. Bump and re-verify before touching component code.Map's defaultclassName.z-50 ... rounded-md min-h-96will fight our full-bleedh-full w-fullcontainer and layer over the sidebars. Override viaclassNameat the call site.- Button sizes. The registry uses
size="icon-sm"/variant="secondary"; both exist in app/components/ui/button.tsx, but our--radiusis0.25remwithrounded-2xlbuttons, so control clusters will look different from the docs screenshots. Expect a styling pass.
Tasks¶
1. Dependencies and install¶
- Bump
react-leafletto stable5.x; runpnpm typecheck,pnpm buildto confirm the current map still works before any refactor. -
pnpm dlx shadcn@latest add @shadcn-map/map, answering no to overwritingbutton,button-group,dropdown-menu. - Verify the added deps landed in package.json:
leaflet-draw,leaflet.markercluster,leaflet.fullscreen,react-leaflet-markercluster(+ the two@typespackages). Removenext-themesif the CLI added it. - Review the
@layer baseblock the CLI appended to app/app.css; keep it, but check.leaflet-container { @apply !bg-card !font-[inherit] }against our Faculty Glyphic / Playfair theme.
2. Adapt app/components/ui/map.tsx¶
- Rewrite
MapLayersControl's dropdown usage against our Base UIDropdownMenu*API (portal container, radio group, checkbox items). - Strip
useTheme/next-themesfromMapTileLayer; keepname,url,attributionand theMapLayersregistration. - Adapt
MapSearchControlto whateverplace-autocompletethe CLI wrote, or stub it out behind the same export name if the Radix version cannot be reconciled cheaply. - Replace
@/registry/new-york-v4/ui/*and@/lib/utilsimports with our~/components/ui/*and~/lib/utilsaliases (the CLI should do this via components.json — verify). -
pnpm typecheckmust pass with the file untouched by our app code yet.
3. Port app/components/map.tsx¶
The file keeps its role as the client-only, lazily imported map module. It stops owning container/marker styling and starts composing shadcn-map primitives.
- Swap
MapContainer→Map, passingcrs,center,zoom,minZoom,maxZoom,zoomSnap,zoomDelta,wheelPxPerZoomLevelthrough, andclassName="h-full w-full rounded-none"to neutralise the defaults. - Keep
GridTileLayer,TileSetLayerandUploadImageLayerunchanged as children — including the[map, tileSet.id]dependency comment, which still applies. - Replace
buildPoiIcon/usePoiIcons/renderToStaticMarkupwith aPoiMarkerIconReact component (coloured circle + lucide icon fromPOI_TYPE_ICON_COMPONENTS) rendered throughMapMarker'siconprop. Delete the now-deadL.divIconhelpers. - Wrap
PoiMarkerinReact.memokeyed onpoi.id,poi.position,poi.typeand the resolved type definition, so loader revalidations do not rebuild everydivIcon. Verify the marker DOM is not recreated when toggling view/edit mode. - Replace the
TooltipinPendingPoiMarkerwithMapTooltip(permanent,side="top"). - Keep
MapClickHandler(useMapEvent("click", …)) as-is. - Add
<MapZoomControl />and<MapFullscreenControl />inside<Map>; pick positions that clear the edit sidebars. - Collapse the duplicated
upload/tileSetbranches into one<Map>with a switched source layer, now that container props are the only difference.
4. Route wiring¶
- In app/routes/project-map.tsx, keep the
lazy()+mountedgate — our module still importsleafletat the top level forGridTileLayer. Note in a comment that shadcn-map's own lazy-loading does not remove this requirement. - Re-verify the POI flows end to end: place, add, select, edit, delete, and the "click empty map deselects" behaviour.
5. Groundwork for the backlog (no behaviour change yet)¶
These are wired but left inert; they exist so the next features do not need another refactor.
- Wrap the map body in
<MapLayers>and register the tile/overlay source as a namedMapTileLayer+MapLayerGroup, then render<MapLayersControl />— this is the hook for Map Layers in docs/backlog.md. - Confirm
MapDrawControl+MapDrawPolyline/MapDrawPolygonmount correctly underCRS.Simple(leaflet-draw does distance/area math that assumes geographic CRS — expect wrong readouts; we render withshowLength: false/showArea: falsealready as defaults). This is the hook for routes and regions/borders. - Confirm
MapMarkerClusterGroupworks withCRS.Simplebefore committing to it for the avesmaps dataset (docs/v0.0.3/avesmaps-dataset.md). - Do not mount
MapLocateControlorMapSearchControlin the map route; geolocation is meaningless on fictional maps.
Verification¶
Per .github/copilot-instructions.md:
Manual checks, since there is no test suite:
- Grid tile map (
avesmaps) pans/zooms and stays insidemaxBounds. - Uploaded-image map fits bounds on load.
- POI popups/sidebar do not close on unrelated re-renders.
- Zoom and fullscreen controls do not pan the map when clicked
(
MapControlContainerhandles propagation — confirm it does under our CSS).