maplibre-react-native icon indicating copy to clipboard operation
maplibre-react-native copied to clipboard

Heatmaps shape issue for react-native on iOS, and behaviour during zooming differs from android

Open slacki123 opened this issue 2 months ago • 1 comments

Describe and reproduce the Bug

Describe the bug

A clear and concise description of what the bug is.

To Reproduce

Steps to reproduce the behavior:

  1. Create a heat map layer using react-native
  2. Display it on the map
  3. view it on android (Works well, scales nicely with zoom, looks good no issues)
  4. view it on iOS (Looks bad, the points are too intense and cannot be winded down, zooming causes the points to reset size and grow)

Expected behavior

It should look the same on iOS as it does on android

Android: Image

where as on iOS it looks like this with the same settings:

Image

also when zooming in and out, the heatpoints get smaller and bigger during zooming. I see another user posted something similar on a lower version for pure iOS, perhaps it has never been fixed. (zooming video attached) https://github.com/maplibre/maplibre-native/issues/3168

Platform information (please complete the following information):

  • Operating System: MacOS Sequoia 15.5
  • Platform: React native
  • React native: 0.81.4
  • expo SDK 54
  • MapLibre Version: v10.2.1 and v11.0.0.alpha5

Additional context

Map style used "https://tiles.openfreemap.org/styles/liberty"

map setup in react native:

import React, { useMemo, useRef, useEffect } from "react";
import { StyleSheet, View } from "react-native";
import {
  Camera,
  HeatmapLayer,
  Images,
  MapView,
  OnPressEvent,
  ShapeSource,
  SymbolLayer,
  type CameraRef,
} from "@maplibre/maplibre-react-native";
import type { FeatureCollection, Point } from "geojson";
import type { Coordinates, HeatmapPoint } from "../types";
import type { GymVenue } from "types/firestoreStructure/gyms";

export type MapLibreMapProps = {
  center: Coordinates;
  gyms: GymVenue[];
  heat: HeatmapPoint[];
  onSelectGym: (gym: GymVenue) => void;
};

// Free styles with labels (no key)
const DEFAULT_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";

const mapStyleVars = {
  gymIconSize: 0.08,
  myPinSize: 0.8,
  heatmap: { radius: 20, opacity: 0.7, intensity: 0.6 } as const,
} as const;

const MapLibreMap: React.FC<MapLibreMapProps> = ({ center, gyms, heat, onSelectGym }) => {
  const cameraRef = useRef<CameraRef>(null);

  useEffect(() => {
    cameraRef.current?.setCamera({
      centerCoordinate: [center.longitude, center.latitude],
      zoomLevel: 11,
      animationDuration: 600,
    });
  }, [center]);

  const gymFeatures = useMemo<FeatureCollection<Point, { venueId: string; name: string }>>(
    () => ({
      type: "FeatureCollection",
      features: gyms
        .filter((g) => !!g.venueLocation?.coordinates)
        .map((g) => ({
          type: "Feature",
          id: g.venueId,
          properties: {
            venueId: g.venueId,
            name: g.venueName || "",
          },
          geometry: {
            type: "Point",
            coordinates: [
              g.venueLocation!.coordinates!.longitude,
              g.venueLocation!.coordinates!.latitude,
            ],
          },
        })),
    }),
    [gyms]
  );

  const myLocationFeature = useMemo<FeatureCollection<Point>>(
    () => ({
      type: "FeatureCollection",
      features: [
        {
          type: "Feature",
          id: "me",
          properties: {},
          geometry: { type: "Point", coordinates: [center.longitude, center.latitude] },
        },
      ],
    }),
    [center.longitude, center.latitude]
  );

  const heatFeatures = useMemo<FeatureCollection<Point, { weight: number }>>(
    () => ({
      type: "FeatureCollection",
      features: heat.map((p, idx) => ({
        type: "Feature",
        id: `h-${idx}`,
        properties: { weight: p.weight ?? 1 },
        geometry: { type: "Point", coordinates: [p.longitude, p.latitude] },
      })),
    }),
    [heat]
  );

  const handleGymPress = (event: OnPressEvent) => {
    const features = event?.features[0];
    if (!features) return;

    console.log(features)
    if(features?.properties?.["cluster"]){
      const numberOfItemsHere = features?.properties?.["point_count"];
      alert(`There are ${numberOfItemsHere} gyms in this area. Zoom in to view more.`)
      return;
    }

    const venueId: string | undefined = features?.properties?.["venueId"];
    if (!venueId) return;
    const gym = gyms.find((g) => g.venueId === venueId);
    if (gym) onSelectGym(gym);
  };

  return (
    <View style={styles.container}>
      <MapView style={styles.map} logoEnabled={false} mapStyle={DEFAULT_STYLE_URL}>
        <Camera
          ref={cameraRef}
          defaultSettings={{
            centerCoordinate: [center.longitude, center.latitude],
            zoomLevel: 11,
          }}
        />

        <Images images={{
          gym: require("assets/images/gym-icon.png"),
          me: require("assets/images/lpin.png")
        }} />

        {/* HEATMAP FIRST (underneath) */}
        {heat.length > 0 && (
          <ShapeSource id="heat" shape={heatFeatures}>
            <HeatmapLayer
              id="heatLayer"
              style={{
                heatmapWeight: ["get", "weight"],
                heatmapOpacity: mapStyleVars.heatmap.opacity,
                heatmapRadius: mapStyleVars.heatmap.radius,
                heatmapIntensity: mapStyleVars.heatmap.intensity,
                heatmapColor: [
                  "interpolate",
                  ["linear"],
                  ["heatmap-density"],
                  0, "rgba(0, 255, 0, 0)",
                  0.5, "rgba(0, 255, 0, 0.8)",
                  1, "rgba(255, 0, 0, 0.8)",
                ],
              }}
            />
          </ShapeSource>
        )}

        {/* GYMS ABOVE */}
        <ShapeSource
          id="gyms"
          shape={gymFeatures}
          onPress={handleGymPress}
          cluster={true}
        >
          <SymbolLayer
            id="gymSymbols"
            // If TS complains about aboveLayerID in your version, remove this line;
            // render order already keeps symbols above.
            aboveLayerID="heatLayer"
            style={{
              iconImage: "gym",
              iconSize: mapStyleVars.gymIconSize,
              iconAllowOverlap: false,
              iconIgnorePlacement: false,
              iconPadding: [0],
            }}
          />
        </ShapeSource>

        {/* Custom "my location" pin (render last so it's on top) */}
        <ShapeSource id="me" shape={myLocationFeature}>
          <SymbolLayer
            aboveLayerID="gymSymbols"
            id="mePin"
            style={
              {
                iconImage: "me",
                iconSize: mapStyleVars.myPinSize,
                iconAllowOverlap: true,
                iconIgnorePlacement: true,
              }
            }
          />
        </ShapeSource>
      </MapView>
    </View>
  );
};

const styles = StyleSheet.create({
    container: { 
      flex: 1,
    },
    map: { 
      flex: 1,
    },
});
export default MapLibreMap;

@maplibre/maplibre-react-native Version

10.2.1

Which platforms does this occur on?

iOS Simulator

Which frameworks does this occur on?

Expo

Which architectures does this occur on?

New Architecture

Environment

expo-env-info 2.0.7 environment info:
    System:
      OS: macOS 15.5
      Shell: 5.9 - /bin/zsh
    Binaries:
      Node: 20.19.4 - ~/.nvm/versions/node/v20.19.4/bin/node
      Yarn: 1.22.22 - /opt/homebrew/bin/yarn
      npm: 11.6.0 - ~/.nvm/versions/node/v20.19.4/bin/npm
      Watchman: 2025.06.23.00 - /opt/homebrew/bin/watchman
    Managers:
      CocoaPods: 1.16.2 - /opt/homebrew/lib/ruby/gems/3.4.0/bin/pod
    SDKs:
      iOS SDK:
        Platforms: DriverKit 24.5, iOS 18.5, macOS 15.5, tvOS 18.5, visionOS 2.5, watchOS 11.5
    IDEs:
      Android Studio: 2024.2 AI-242.23726.103.2422.13103373
      Xcode: 16.4/16F6 - /usr/bin/xcodebuild
    npmGlobalPackages:
      eas-cli: 16.19.3
    Expo Workflow: managed

slacki123 avatar Sep 18 '25 19:09 slacki123

Thanks for the report, I think most if not all issues are upstream. Did you try setting different Radius on iOS and Android? I don't know if this should be exactly the same on both platforms, maybe there is some issue around pixel density.

KiwiKilian avatar Sep 24 '25 05:09 KiwiKilian