From e8f472b0da29222a402c1351d200cf1bdb9c79c2 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 26 Jun 2026 12:01:41 +0200 Subject: [PATCH 1/3] Add custom marker clustering and viewport-aware overlay sync. --- .gitignore | 1 + bun.lock | 40 + docs/architecture.md | 4 +- example/App.tsx | 853 ++++++++++++++---- example/examples/advancedFeatures.ts | 126 ++- example/examples/types.ts | 17 +- example/index.js | 1 + example/metro.config.js | 7 + example/package.json | 4 +- .../nitro/nitromaps/ClusterBadgeMetrics.kt | 21 + .../nitro/nitromaps/ClusterIconFactory.kt | 70 ++ .../margelo/nitro/nitromaps/HybridMapView.kt | 46 +- .../nitro/nitromaps/MapOverlayController.kt | 309 +++++-- .../nitro/nitromaps/MarkerClusterEngine.kt | 248 +++++ .../MarkerDescriptor+MarkerOptions.kt | 2 + .../nitro/nitromaps/MarkerSpatialIndex.kt | 99 ++ .../nitro/nitromaps/MarkerViewportFilter.kt | 104 +++ .../nitro/nitromaps/NitroClusterItem.kt | 28 - package/ios/ClusterBadgeMetrics.swift | 34 + package/ios/HybridMapView.swift | 72 +- package/ios/HybridMapViewDelegate.swift | 75 +- package/ios/MKMapView+ScaleAppearance.swift | 58 ++ package/ios/MapClusterAnnotation.swift | 29 + package/ios/MapOverlayController.swift | 236 ++++- package/ios/MarkerClusterEngine.swift | 266 ++++++ package/ios/MarkerSpatialIndex.swift | 101 +++ package/ios/MarkerViewportFilter.swift | 102 +++ package/ios/NitroClusterAnnotationView.swift | 74 ++ package/ios/NitroMKMapView.swift | 9 + package/ios/NitroPinAnnotationView.swift | 113 +++ package/src/components/Circle.tsx | 4 + package/src/components/MapView.tsx | 77 +- package/src/components/Marker.tsx | 4 + package/src/components/Polygon.tsx | 4 + package/src/components/Polyline.tsx | 4 + package/src/hooks/useCollectedOverlays.ts | 291 +++--- package/src/overlays/overlayType.ts | 18 + package/src/types/map.ts | 36 + 38 files changed, 3009 insertions(+), 578 deletions(-) create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterBadgeMetrics.kt create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterIconFactory.kt create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt delete mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/NitroClusterItem.kt create mode 100644 package/ios/ClusterBadgeMetrics.swift create mode 100644 package/ios/MKMapView+ScaleAppearance.swift create mode 100644 package/ios/MapClusterAnnotation.swift create mode 100644 package/ios/MarkerClusterEngine.swift create mode 100644 package/ios/MarkerSpatialIndex.swift create mode 100644 package/ios/MarkerViewportFilter.swift create mode 100644 package/ios/NitroClusterAnnotationView.swift create mode 100644 package/ios/NitroMKMapView.swift create mode 100644 package/ios/NitroPinAnnotationView.swift create mode 100644 package/src/overlays/overlayType.ts diff --git a/.gitignore b/.gitignore index 612864b..e43de33 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ local.properties **/ios/build/ # IDE +.cursor/ .idea/ .vscode/ *.swp diff --git a/bun.lock b/bun.lock index e5582f9..9061dd8 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,8 @@ "react-native": "0.85.3", "react-native-nitro-maps": "workspace:*", "react-native-nitro-modules": "^0.35.10", + "react-native-reanimated": "4.3.1", + "react-native-worklets": "0.8.3", }, "devDependencies": { "@babel/core": "^7.27.4", @@ -235,6 +237,10 @@ "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA=="], "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw=="], @@ -401,6 +407,8 @@ "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.85.3", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.85.3" } }, "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA=="], + "@react-native/babel-preset": ["@react-native/babel-preset@0.86.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@react-native/babel-plugin-codegen": "0.86.0", "babel-plugin-syntax-hermes-parser": "0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-bYQcWiPySNvF4dns9Ls9gMmwgq66ohvM9Fwc/Kn8r85t66UNHxch3p1QwPiSorDelFauZwJbgo9+ReibTgvpbA=="], + "@react-native/codegen": ["@react-native/codegen@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.33.3", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA=="], "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.85.3", "", { "dependencies": { "@react-native/dev-middleware": "0.85.3", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", "metro-config": "^0.84.3", "metro-core": "^0.84.3", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.85.3" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw=="], @@ -415,6 +423,10 @@ "@react-native/js-polyfills": ["@react-native/js-polyfills@0.85.3", "", {}, "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A=="], + "@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.86.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.86.0", "hermes-parser": "0.36.0", "nullthrows": "^1.1.1" } }, "sha512-SjKej3E5qIahqo/G+rSOrmJUQM44RyKtWtO+VfmKAAMoJWkBFomM22hTLKCIS5cdbIAJ9COAmU+KAi2wVSO0wQ=="], + + "@react-native/metro-config": ["@react-native/metro-config@0.86.0", "", { "dependencies": { "@react-native/js-polyfills": "0.86.0", "@react-native/metro-babel-transformer": "0.86.0", "metro-config": "^0.84.3", "metro-runtime": "^0.84.3" } }, "sha512-7v+xbTeEci9ZcQ/Z1OqI4RXcqN69wSMDYL5BAMvOReZ7U04+aDQ0/SQhClYPn6x2/RxM4WzMKSAuNyLKqvYVtw=="], + "@react-native/normalize-colors": ["@react-native/normalize-colors@0.85.3", "", {}, "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw=="], "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.85.3", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.85.3" }, "optionalPeers": ["@types/react"] }, "sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig=="], @@ -1175,6 +1187,10 @@ "react-native-nitro-modules": ["react-native-nitro-modules@0.35.10", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-KsySOAIkbSTjNiX2GvXiNT9P5DMeZd99yvtE/+Lw7RXV7Ztxh9Z+YyAbYkBqadhN5J/KXXuPjhRYgyLOIZgsbQ=="], + "react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="], + + "react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="], + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], @@ -1441,8 +1457,16 @@ "@expo/ws-tunnel/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.86.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.86.0" } }, "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA=="], + + "@react-native/babel-preset/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.0", "", { "dependencies": { "hermes-parser": "0.36.0" } }, "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q=="], + "@react-native/community-cli-plugin/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@react-native/metro-babel-transformer/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + + "@react-native/metro-config/@react-native/js-polyfills": ["@react-native/js-polyfills@0.86.0", "", {}, "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ=="], + "@ts-morph/common/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -1529,6 +1553,10 @@ "react-native-builder-bob/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "react-native-reanimated/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "react-native-worklets/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], @@ -1541,6 +1569,12 @@ "@expo/fingerprint/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.86.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.36.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA=="], + + "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + + "@react-native/metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -1585,6 +1619,10 @@ "@expo/fingerprint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + + "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -1601,6 +1639,8 @@ "react-native-builder-bob/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.34.0", "", {}, "sha512-6qLylexjmuKa/YYhMiNn/3VejBsdzwmYUGmNpc693/pJzymmbufhkRW/2K6GqFgu0ApRWoqF0NbM6u82jFcOXA=="], + "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/docs/architecture.md b/docs/architecture.md index c9d07fc..439d434 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,8 +62,8 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C | Prop / method | Notes | | --- | --- | -| `clusteringEnabled` | Platform-native clustering (MapKit `clusteringIdentifier` on iOS; `ClusterManager` from maps-utils on Android). | -| `Marker.clusterable` | Opt-out per marker (defaults to `true`). | +| `clusteringEnabled` | Custom grid-based clustering via `MarkerClusterEngine` on both platforms (viewport-aware, background compute). | +| `Marker.clusterable` | Opt-out per marker (defaults to `true`). Non-clusterable markers always render individually. | | `customMapStyle` | JSON string. Full support on Android via `MapStyleOptions`. iOS 16+ applies a curated subset (POI/transit visibility, elevation) via `MKMapConfiguration`. | | `showsUserLocation` / `followsUserLocation` | Toggles the native user-location layer. Host app must request location permission (`NSLocationWhenInUseUsageDescription` on iOS; `ACCESS_FINE_LOCATION` on Android). | | `showsCompass` / `showsScale` | Compass on both platforms. Scale is iOS-only (`showsScale` is a no-op on Android). | diff --git a/example/App.tsx b/example/App.tsx index 9b95065..ee78b19 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,20 +1,43 @@ -import { memo, useCallback, useRef, useState } from 'react'; +import { + forwardRef, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { StatusBar } from 'expo-status-bar'; import { - Button, + Platform, Pressable, ScrollView, StyleSheet, Text, View, + type StyleProp, + type ViewStyle, } from 'react-native'; +import Animated, { + Easing, + FadeIn, + FadeInDown, + FadeInUp, + FadeOut, + LinearTransition, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated'; import { - Circle, MapView, - Marker, - Polygon, - Polyline, type Coordinate, + type EdgePadding, type MapType, type MapViewRef, } from 'react-native-nitro-maps'; @@ -22,72 +45,403 @@ import { MAP_SCENARIOS, type MapScenario } from './examples'; const MAP_TYPES: MapType[] = ['standard', 'satellite', 'hybrid']; -type ScenarioInfoProps = { - scenario: MapScenario; - scenarioIndex: number; - onSelect: (index: number) => void; +const MAP_TYPE_LABELS: Record = { + standard: 'Standard', + satellite: 'Satellite', + hybrid: 'Hybrid', + terrain: 'Terrain', }; -// Memoized so frequent `status` updates on the parent don't re-render the -// scenario title, description, and chip picker (none depend on `status`). -const ScenarioInfo = memo(function ScenarioInfo({ - scenario, - scenarioIndex, - onSelect, -}: ScenarioInfoProps) { +/** Left inset reserved for the native map scale control. */ +const SCALE_GUTTER_WIDTH = 156; + +const springSnappy = { damping: 22, stiffness: 420 }; +const springSoft = { damping: 20, stiffness: 240 }; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +function mergeMapPadding( + padding: EdgePadding | undefined, + showsScale: boolean, + showsCompass: boolean, +): EdgePadding | undefined { + if (padding == null && !showsScale) { + return undefined; + } + + const base = padding ?? { top: 0, right: 0, bottom: 0, left: 0 }; + + if (!showsScale) { + return base; + } + + return { + top: Math.max(base.top, 56), + right: Math.max(base.right, showsCompass ? 52 : 16), + bottom: base.bottom, + left: Math.max(base.left, 12), + }; +} + +const palette = { + surface: 'rgba(18, 18, 20, 0.9)', + surfaceElevated: 'rgba(28, 28, 32, 0.96)', + border: 'rgba(255, 255, 255, 0.1)', + borderStrong: 'rgba(255, 255, 255, 0.18)', + accent: '#3B82F6', + accentSoft: 'rgba(59, 130, 246, 0.22)', + text: '#FFFFFF', + textSecondary: 'rgba(255, 255, 255, 0.72)', + textMuted: 'rgba(255, 255, 255, 0.48)', + success: '#34D399', + warning: '#FBBF24', +}; + +type ScalePressableProps = { + onPress: () => void; + style?: StyleProp; + children: ReactNode; + hitSlop?: number; +}; + +const ScalePressable = memo(function ScalePressable({ + onPress, + style, + children, + hitSlop, +}: ScalePressableProps) { + const scale = useSharedValue(1); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return ( + { + scale.value = withSpring(0.96, springSnappy); + }} + onPressOut={() => { + scale.value = withSpring(1, springSnappy); + }} + > + {children} + + ); +}); + +type StatusDotProps = { + mapReady: boolean; +}; + +const StatusDot = memo(function StatusDot({ mapReady }: StatusDotProps) { + const opacity = useSharedValue(1); + + useEffect(() => { + if (mapReady) { + cancelAnimation(opacity); + opacity.value = withTiming(1, { duration: 200 }); + return; + } + + opacity.value = withRepeat( + withSequence( + withTiming(0.35, { + duration: 700, + easing: Easing.inOut(Easing.ease), + }), + withTiming(1, { + duration: 700, + easing: Easing.inOut(Easing.ease), + }), + ), + -1, + false, + ); + }, [mapReady, opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + backgroundColor: mapReady ? palette.success : palette.warning, + })); + + return ; +}); + +type ScenarioChipProps = { + index: number; + label: string; + active: boolean; + onPress: () => void; +}; + +const ScenarioChip = memo(function ScenarioChip({ + index, + label, + active, + onPress, +}: ScenarioChipProps) { return ( - <> - {scenario.name} - {scenario.description} - + - {MAP_SCENARIOS.map((item, index) => ( - onSelect(index)} - > - - {item.name} - - - ))} - - + + {index + 1} + + + {label} + + + ); }); -type MapControlsProps = { +type ScenarioDockProps = { + scenario: MapScenario; + scenarioIndex: number; + expanded: boolean; mapTypeLabel: MapType; + onToggleExpanded: () => void; + onSelect: (index: number) => void; onAnimateCamera: () => void; onGetCamera: () => void; onCycleMapType: () => void; }; -// Memoized so the button row bails out of `status`-driven re-renders. -const MapControls = memo(function MapControls({ +const ScenarioDock = memo(function ScenarioDock({ + scenario, + scenarioIndex, + expanded, mapTypeLabel, + onToggleExpanded, + onSelect, onAnimateCamera, onGetCamera, onCycleMapType, -}: MapControlsProps) { +}: ScenarioDockProps) { + const chevronRotation = useSharedValue(0); + + useEffect(() => { + chevronRotation.value = withTiming(expanded ? 180 : 0, { + duration: 240, + easing: Easing.out(Easing.cubic), + }); + }, [expanded, chevronRotation]); + + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${chevronRotation.value}deg` }], + })); + return ( - -