Skip to content

Comments

feat: react-map-gl integration#26

Open
junwen-k wants to merge 6 commits intoAnmolSaini16:mainfrom
junwen-k:feat/react-map-gl
Open

feat: react-map-gl integration#26
junwen-k wants to merge 6 commits intoAnmolSaini16:mainfrom
junwen-k:feat/react-map-gl

Conversation

@junwen-k
Copy link

@junwen-k junwen-k commented Jan 5, 2026

Overview

In this PR, I’ve explored refactoring the map components to build on top of react-map-gl (I’ve added this as a separate map-gl.tsx under registry for comparison) rather than working directly with MapLibre GL. While this is a significant architectural change, but in my opinion, a more declarative API could help improve code clarity, maintainability, and overall developer experience.

Addresses: #24

Example Comparison

without `react-map-gl` with `react-map-gl`
function MapRoute({
  id,
  coordinates,
  color = "#4285F4",
  width = 3,
  opacity = 0.8,
  dashArray,
  onClick,
  onMouseEnter,
  onMouseLeave,
  interactive = true,
}: MapRouteProps) {
  const { map, isLoaded } = useMap();
  const autoId = useId();
  const sourceId = id ?? `route-source-${autoId}`;
  const layerId = id ?? `route-layer-${autoId}`;

  // Add source and layer on mount
  useEffect(() => {
    if (!isLoaded || !map) return;

    map.addSource(sourceId, {
      type: "geojson",
      data: {
        type: "Feature",
        properties: {},
        geometry: { type: "LineString", coordinates: [] },
      },
    });

    map.addLayer({
      id: layerId,
      type: "line",
      source: sourceId,
      layout: { "line-join": "round", "line-cap": "round" },
      paint: {
        "line-color": color,
        "line-width": width,
        "line-opacity": opacity,
        ...(dashArray && { "line-dasharray": dashArray }),
      },
    });

    return () => {
      try {
        if (map.getLayer(layerId)) map.removeLayer(layerId);
        if (map.getSource(sourceId)) map.removeSource(sourceId);
      } catch {
        // ignore
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoaded, map]);

  // When coordinates change, update the source data
  useEffect(() => {
    if (!isLoaded || !map || coordinates.length < 2) return;

    const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
    if (source) {
      source.setData({
        type: "Feature",
        properties: {},
        geometry: { type: "LineString", coordinates },
      });
    }
  }, [isLoaded, map, coordinates, sourceId]);

  useEffect(() => {
    if (!isLoaded || !map || !map.getLayer(layerId)) return;

    map.setPaintProperty(layerId, "line-color", color);
    map.setPaintProperty(layerId, "line-width", width);
    map.setPaintProperty(layerId, "line-opacity", opacity);
    if (dashArray) {
      map.setPaintProperty(layerId, "line-dasharray", dashArray);
    }
  }, [isLoaded, map, layerId, color, width, opacity, dashArray]);

  // Handle click and hover events
  useEffect(() => {
    if (!isLoaded || !map || !interactive) return;

    const handleClick = () => {
      onClick?.();
    };
    const handleMouseEnter = () => {
      map.getCanvas().style.cursor = "pointer";
      onMouseEnter?.();
    };
    const handleMouseLeave = () => {
      map.getCanvas().style.cursor = "";
      onMouseLeave?.();
    };

    map.on("click", layerId, handleClick);
    map.on("mouseenter", layerId, handleMouseEnter);
    map.on("mouseleave", layerId, handleMouseLeave);

    return () => {
      map.off("click", layerId, handleClick);
      map.off("mouseenter", layerId, handleMouseEnter);
      map.off("mouseleave", layerId, handleMouseLeave);
    };
  }, [
    isLoaded,
    map,
    layerId,
    onClick,
    onMouseEnter,
    onMouseLeave,
    interactive,
  ]);

  return null;
}
function MapRoute({
  id: idProp,
  coordinates,
  color = "#4285F4",
  width = 3,
  opacity = 0.8,
  dashArray,
  onClick,
  onMouseEnter,
  onMouseLeave,
}: MapRouteProps) {
  const { current: map } = useMap();

  const autoId = useId();
  const id = idProp ?? autoId;

  const sourceId = `route-source-${id}`;
  const layerId = `route-layer-${id}`;

  useEffect(() => {
    if (!onClick || !map) {
      return;
    }

    const handleClick = () => {
      onClick?.();
    };

    const handleMouseEnter = () => {
      map.getCanvas().style.cursor = "pointer";
      onMouseEnter?.();
    };

    const handleMouseLeave = () => {
      map.getCanvas().style.cursor = "";
      onMouseLeave?.();
    };

    map.on("click", layerId, handleClick);
    map.on("mouseenter", layerId, handleMouseEnter);
    map.on("mouseleave", layerId, handleMouseLeave);

    return () => {
      map.off("click", layerId, handleClick);
      map.off("mouseenter", layerId, handleMouseEnter);
      map.off("mouseleave", layerId, handleMouseLeave);
    };
  }, [map, layerId, onClick, onMouseEnter, onMouseLeave]);

  return (
    <MapLibreGLSource
      id={sourceId}
      type="geojson"
      data={{
        type: "Feature",
        properties: {},
        geometry: { type: "LineString", coordinates },
      }}
    >
      <MapLibreGLLayer
        id={layerId}
        type="line"
        layout={{
          "line-join": "round",
          "line-cap": "round",
        }}
        paint={{
          "line-color": color,
          "line-width": width,
          "line-opacity": opacity,
          ...(dashArray && { "line-dasharray": dashArray }),
        }}
      />
    </MapLibreGLSource>
  );
}

Additional Context

While refactoring the map components, I've also discovered a few potential issues in the original implementation.

  • Fixed ID collision bug: The original implementation in MapRoute component had a bug where sourceId and layerId could end up with identical IDs if an id prop was passed, leading to potential conflicts.

      // If id is provided, both sourceId and layerId will be the same.
      const autoId = useId();
      const sourceId = id ?? `route-source-${autoId}`;
      const layerId = id ?? `route-layer-${autoId}`;
  • Smarter interactive behavior: Instead of requiring a separate interactive prop for MapRoute component, we can infer it from the onClick prop to dynamically handle the cursor style.

To allow internal and external consumers to manage the map ref, I’ve inlined Radix UI’s useComposedRefs utility. We can consider adding this as a shared dependency within the registry for consistency and future reuse.

Rationale and Motivation

While I realize this is a significant refactor and may not be feasible to merge directly, I wanted to share it with the community in case others find value in a more declarative approach. My motivation came from building custom integrations where I didn't want to manage all the map reactivity imperatively, naturally leading me to consider react-map-gl. I refactored mapcn to see if it could also be built on top of react-map-gl. I hope this PR sparks inspiration among the community or inspires new integrations.

Note

I only roughly updated the docs without going through every detail. My changes were mostly focused on getting the example to work and I didn't put too much effort into the rest of the documentation.

@vercel
Copy link

vercel bot commented Jan 5, 2026

@junwen-k is attempting to deploy a commit to the anmolsaini16's projects Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant