@particle-academy/fancy-map
v0.1.2
Published
Engine-agnostic Map component for the Fancy UI suite — one <Map> API over swappable providers (OpenStreetMap via Leaflet, Google Maps), live position tracking, and a Human+ MCP bridge for cohabited human/agent maps.
Maintainers
Readme
@particle-academy/fancy-map
Engine-agnostic Map component for the Fancy UI suite — one <Map> API over swappable providers, live position tracking, and a Human+ MCP bridge so a human and an agent can share the same map.
- One API, many engines. Render with OpenStreetMap (via Leaflet) or Google Maps today; swapping is a one-line
providerchange. Adding MapLibre / Mapbox GL later is just another provider. - Controlled + JSON-friendly.
view+markers+selectedIdare controlled props; markers are a plain JSON array an agent can emit directly. - Live tracking. Feed markers new positions and they move;
followkeeps the camera on a moving marker;useGeolocationTrackwires up the browser's live position. - Human+ ready. Every marker has a stable
data-map-marker-idhandle, andregisterMapBridge(in@particle-academy/agent-integrations) lets an agent pan, drop pins, fit bounds, and follow a track over MCP — no DOM scraping. - SSR-safe. Renders a sized placeholder on the server; the engine mounts in an effect. No hydration mismatch under Inertia / Next.
Install
npm install @particle-academy/fancy-map
# then the engine(s) you want:
npm install leaflet # for the OpenStreetMap / Leaflet provider
npm install @googlemaps/js-api-loader # for the Google Maps providerThe core has no map-engine dependency — you only install the engine for the provider you use (both are optional peer deps).
Quick start — OpenStreetMap (Leaflet)
import { useState } from "react";
import "leaflet/dist/leaflet.css"; // required once, anywhere in your app
import { Map } from "@particle-academy/fancy-map";
import { leafletProvider } from "@particle-academy/fancy-map/leaflet";
const provider = leafletProvider(); // OpenStreetMap tiles by default
export function StorePicker() {
const [view, setView] = useState({ center: { lat: 43.0389, lng: -87.9065 }, zoom: 12 });
const [selected, setSelected] = useState<string | null>(null);
const markers = [
{ id: "a", position: { lat: 43.0389, lng: -87.9065 }, icon: "🌮", color: "#D6482B", label: "Taquería" },
{ id: "b", position: { lat: 43.05, lng: -87.92 }, icon: "☕", color: "#6F4E37", label: "Coffee" },
];
// Give the map a container with a real height.
return (
<div style={{ height: 480 }}>
<Map provider={provider} view={view} onViewChange={setView}
markers={markers} selectedId={selected} onSelect={setSelected} />
</div>
);
}The map container needs a height (
height: 480,100%of a sized parent, or a flex child withmin-height) — like every map library, Leaflet/Google measure their container to lay out tiles.
Any XYZ raster source works — pass a tileUrl:
leafletProvider({
tileUrl: "https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png",
attribution: "© Stadia Maps © OpenStreetMap",
});Quick start — Google Maps
import { Map } from "@particle-academy/fancy-map";
import { googleProvider } from "@particle-academy/fancy-map/google";
const provider = googleProvider({ apiKey: import.meta.env.VITE_GOOGLE_MAPS_KEY });
<div style={{ height: 480 }}>
<Map provider={provider} view={view} onViewChange={setView} markers={markers} />
</div>;The Google Maps JavaScript SDK is loaded over the network at mount time (nothing Google-specific enters your SSR bundle). Requires a Google Maps JS API key.
Live tracking
import { Map, useGeolocationTrack } from "@particle-academy/fancy-map";
function LiveMe() {
const { position } = useGeolocationTrack(); // watches navigator.geolocation
const [view, setView] = useState({ center: { lat: 43.04, lng: -87.9 }, zoom: 14 });
const markers = position ? [{ id: "me", position, icon: "📍", color: "#2563eb" }] : [];
// follow="me" keeps the camera centered as you move.
return (
<div style={{ height: 420 }}>
<Map provider={provider} view={view} onViewChange={setView} markers={markers} follow="me" />
</div>
);
}Any position source works the same way — a websocket / Laravel Echo channel pushing a vehicle's coordinates, or an agent updating a marker over the bridge. The component just re-renders from controlled markers; the followed marker animates and the camera keeps up.
Human+ — a map a human and an agent share
Mount the bridge from @particle-academy/agent-integrations against the same state you pass to <Map>:
import { registerMapBridge } from "@particle-academy/agent-integrations/bridges/map";
registerMapBridge(server, {
adapter: {
getView: () => view, setView,
getMarkers: () => markers, setMarkers,
getSelected: () => selected, setSelected,
fitBounds: (pts) => handle?.fitBounds(pts), // handle from <Map onReady>
},
agent: { id: "assistant", name: "Assistant", color: "#7c3aed" },
});The agent gets map_set_view, map_pan, map_zoom, map_add_marker, map_update_marker, map_remove_marker, map_select, map_fit_bounds, map_start_track, map_stop_track — every mutation broadcasts AgentActivity and is undoable, so the human sees what the agent does and can step back.
API
<Map>
| Prop | Type | Notes |
|---|---|---|
| provider | MapProvider | Required. leafletProvider() / googleProvider({apiKey}). |
| view | MapView | Controlled camera { center: {lat,lng}, zoom, bearing?, pitch? }. |
| onViewChange | (view) => void | Fires on user pan/zoom. |
| markers | MapMarker[] | Controlled; { id, position, label?, color?, icon?, draggable?, data? }. |
| selectedId | string \| null | Controlled selection. |
| onSelect | (id, marker?) => void | Marker clicked. |
| onMarkerDragEnd | (id, position) => void | Draggable marker released. |
| onMapClick | (position) => void | Background clicked. |
| follow | string \| null | Keep this marker centered as it moves. |
| fitTo | LatLng[] | One-shot: fit the camera to these points. |
| onReady | (handle) => void | Grab the imperative MapHandle (e.g. for the bridge's fitBounds). |
Writing a provider
A provider is { name, mount(host, opts) => MapHandle }. The MapHandle implements setView / getView / setMarkers / setSelected / fitBounds / on / destroy. See src/leaflet.ts for a ~180-line reference implementation.
Documentation
Full guide: docs/Map.md.
License
MIT
⭐ Star Fancy UI
If this helps, a star on github.com/Particle-Academy/fancy-map helps the kit grow.
