diff --git a/.github/workflows/deploy-basic-example.yml b/.github/workflows/deploy-basic-example.yml
new file mode 100644
index 0000000..8a10099
--- /dev/null
+++ b/.github/workflows/deploy-basic-example.yml
@@ -0,0 +1,64 @@
+name: Deploy basic example
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ submodules: recursive
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v6
+
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build basic example
+ run: pnpm --filter @mapconductor/example-basic build
+ env:
+ VITE_BASE_PATH: /${{ github.event.repository.name }}/
+ VITE_GOOGLE_MAPS_API_KEY: ${{ secrets.VITE_GOOGLE_MAPS_API_KEY }}
+
+ - name: Add SPA fallback
+ run: cp examples/basic/dist/index.html examples/basic/dist/404.html
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: examples/basic/dist
+
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitmodules b/.gitmodules
index db60de9..e79c27b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -19,3 +19,15 @@
[submodule "reactnative-for-maplibre"]
path = reactnative-for-maplibre
url = ./reactnative-for-maplibre
+[submodule "react-geojson-layer"]
+ path = react-geojson-layer
+ url = ./react-geojson-layer
+[submodule "react-heatmap"]
+ path = react-heatmap
+ url = ./react-heatmap
+[submodule "react-icons"]
+ path = react-icons
+ url = ./react-icons
+[submodule "react-marker-clustering"]
+ path = react-marker-clustering
+ url = ./react-marker-clustering
diff --git a/examples/basic/package.json b/examples/basic/package.json
index a8beb9b..68ff014 100644
--- a/examples/basic/package.json
+++ b/examples/basic/package.json
@@ -13,7 +13,11 @@
"@mapconductor/js-sdk-core": "workspace:*",
"@mapconductor/react-for-googlemaps": "workspace:*",
"@mapconductor/react-for-maplibre": "workspace:*",
+ "@mapconductor/react-icons": "workspace:*",
+ "@mapconductor/react-marker-clustering": "workspace:*",
"@mapconductor/js-sdk-react": "workspace:*",
+ "@mapconductor/react-geojson-layer": "workspace:*",
+ "@mapconductor/react-heatmap": "workspace:*",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.0"
diff --git a/examples/basic/public/geojson/sample-railway.geojson b/examples/basic/public/geojson/sample-railway.geojson
new file mode 100644
index 0000000..86b5167
--- /dev/null
+++ b/examples/basic/public/geojson/sample-railway.geojson
@@ -0,0 +1,149 @@
+{
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "id": "yamanote-1",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [139.7744, 35.6812],
+ [139.7703, 35.6818],
+ [139.7664, 35.6830],
+ [139.7627, 35.6855],
+ [139.7599, 35.6882],
+ [139.7587, 35.6916],
+ [139.7592, 35.6950],
+ [139.7614, 35.6980],
+ [139.7649, 35.6999],
+ [139.7692, 35.7007],
+ [139.7732, 35.6998],
+ [139.7764, 35.6977],
+ [139.7785, 35.6947],
+ [139.7784, 35.6913],
+ [139.7766, 35.6882],
+ [139.7744, 35.6812]
+ ]
+ },
+ "properties": {
+ "N02_002": "普通鉄道",
+ "N02_003": "幹線鉄道",
+ "N02_004": "山手線",
+ "N02_005": "東日本旅客鉄道"
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "chuo-1",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [139.7320, 35.6980],
+ [139.7420, 35.6980],
+ [139.7520, 35.6974],
+ [139.7620, 35.6967],
+ [139.7665, 35.6958],
+ [139.7744, 35.6812]
+ ]
+ },
+ "properties": {
+ "N02_002": "普通鉄道",
+ "N02_003": "幹線鉄道",
+ "N02_004": "中央線快速",
+ "N02_005": "東日本旅客鉄道"
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "ginza-1",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [139.7353, 35.6673],
+ [139.7432, 35.6727],
+ [139.7516, 35.6753],
+ [139.7595, 35.6762],
+ [139.7659, 35.6773],
+ [139.7707, 35.6804],
+ [139.7744, 35.6812],
+ [139.7760, 35.6775],
+ [139.7781, 35.6742],
+ [139.7822, 35.6719]
+ ]
+ },
+ "properties": {
+ "N02_002": "普通鉄道",
+ "N02_003": "地下鉄",
+ "N02_004": "銀座線",
+ "N02_005": "東京地下鉄"
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "sobu-1",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [139.7744, 35.6812],
+ [139.7817, 35.6856],
+ [139.7891, 35.6892],
+ [139.7972, 35.6909],
+ [139.8063, 35.6934],
+ [139.8133, 35.6979]
+ ]
+ },
+ "properties": {
+ "N02_002": "普通鉄道",
+ "N02_003": "幹線鉄道",
+ "N02_004": "総武線",
+ "N02_005": "東日本旅客鉄道"
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "keihin-1",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [139.7744, 35.6812],
+ [139.7712, 35.6770],
+ [139.7687, 35.6726],
+ [139.7668, 35.6682],
+ [139.7649, 35.6621],
+ [139.7635, 35.6560]
+ ]
+ },
+ "properties": {
+ "N02_002": "普通鉄道",
+ "N02_003": "幹線鉄道",
+ "N02_004": "京浜東北線",
+ "N02_005": "東日本旅客鉄道"
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "hibiya-1",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [139.7150, 35.6450],
+ [139.7243, 35.6511],
+ [139.7327, 35.6572],
+ [139.7411, 35.6634],
+ [139.7500, 35.6697],
+ [139.7600, 35.6750],
+ [139.7687, 35.6795],
+ [139.7744, 35.6812],
+ [139.7800, 35.6844],
+ [139.7851, 35.6876]
+ ]
+ },
+ "properties": {
+ "N02_002": "普通鉄道",
+ "N02_003": "地下鉄",
+ "N02_004": "日比谷線",
+ "N02_005": "東京地下鉄"
+ }
+ }
+ ]
+}
diff --git a/examples/basic/public/postoffice/cluster_red.png b/examples/basic/public/postoffice/cluster_red.png
new file mode 100644
index 0000000..7fd5c60
Binary files /dev/null and b/examples/basic/public/postoffice/cluster_red.png differ
diff --git a/examples/basic/public/postoffice/postoffice.webp b/examples/basic/public/postoffice/postoffice.webp
new file mode 100644
index 0000000..a8f16aa
Binary files /dev/null and b/examples/basic/public/postoffice/postoffice.webp differ
diff --git a/examples/basic/src/App.tsx b/examples/basic/src/App.tsx
index 057b42c..a9c4a45 100644
--- a/examples/basic/src/App.tsx
+++ b/examples/basic/src/App.tsx
@@ -3,7 +3,7 @@ import { Routes, Route, Navigate, useNavigate, useLocation, useParams } from 're
import { PageNav } from './components/PageNav';
import { CirclePage } from './pages/CirclePage';
import { MapPage } from './pages/map/basic/StoreMapPage';
-import { MarkerPage } from './pages/MarkerPage';
+import { MarkerIconsPage } from './pages/marker/icons/MarkerIconsPage';
import { PolygonPage } from './pages/PolygonPage';
import { PolylinePage } from './pages/PolylinePage';
import { GroundImagePage } from './pages/groundimage/GroundImagePage';
@@ -15,25 +15,49 @@ import { MapDesignPage } from './pages/map/design/MapDesignPage';
import { FlyToPage } from './pages/map/flyto/FlyToPage';
import { TiltPage } from './pages/map/tilt/TiltPage';
import { VisibleRegionPage } from './pages/map/visibleregion/VisibleRegionPage';
+import { CameraSyncPage } from './pages/map/camerasync/CameraSyncPage';
import { MarkerAnimationPage } from './pages/marker/animation/MarkerAnimationPage';
import { PostOfficePage } from './pages/marker/postoffice/PostOfficePage';
+import { PostOfficeClusterPage } from './pages/marker/postofficecluster/PostOfficeClusterPage';
import { PolygonClickPage } from './pages/polygon/click/PolygonClickPage';
import { PolygonGeodesicPage } from './pages/polygon/geodesic/PolygonGeodesicPage';
import { PolygonHolePage } from './pages/polygon/hole/PolygonHolePage';
import { PolylineClickPage } from './pages/polyline/click/PolylineClickPage';
import { RasterLayerPage } from './pages/rasterlayer/RasterLayerPage';
-import { UnsupportedSamplePage } from './pages/unsupported/UnsupportedSamplePage';
-import { DEFAULT_SAMPLE_PAGE, SAMPLE_PAGES, isKnownSamplePage } from './sampleRegistry';
+import { HeatmapLayerPage } from './pages/heatmaplayer/HeatmapLayerPage';
+import { BasicGeoJSONPage } from './pages/geojson/basic/BasicGeoJSONPage';
+import { GeoJSONLayerPage } from './pages/geojson/layer/GeoJSONLayerPage';
+import { DEFAULT_SAMPLE_PAGE, getSamplePageDefinition, isKnownSamplePage } from './sampleRegistry';
type MapProvider = 'maplibre' | 'google' | 'google-3d';
+function providerRouteId(provider: string | undefined): string {
+ return provider ?? '';
+}
+
+function isPageUnavailableOnProvider(page: string | undefined, provider: string | undefined): boolean {
+ const definition = getSamplePageDefinition(page);
+ return definition?.unavailableProviders?.includes(providerRouteId(provider)) ?? false;
+}
+
+function ProviderUnavailableOverlay() {
+ return (
+
+
+ This feature is not available on this provider
+
+
+ );
+}
+
function pageContent(page: string | undefined) {
switch (page) {
case 'map-design': return ;
case 'fly-to': return ;
case 'tilt': return ;
case 'visible-region': return ;
- case 'marker': return ;
+ case 'camera-sync': return ;
+ case 'marker': return ;
case 'marker-animation': return ;
case 'post-office': return ;
case 'circle': return ;
@@ -49,12 +73,10 @@ function pageContent(page: string | undefined) {
case 'info-bubble-styled': return ;
case 'info-bubble-multiple': return ;
case 'info-bubble-rich': return ;
- case 'post-office-cluster':
- case 'geojson-basic':
- case 'geojson-layer':
- case 'heatmap-layer':
- case 'camera-sync':
- return item.id === page)?.label ?? 'Unsupported'} />;
+ case 'post-office-cluster': return ;
+ case 'heatmap-layer': return ;
+ case 'geojson-basic': return ;
+ case 'geojson-layer': return ;
default: return ;
}
}
@@ -68,7 +90,14 @@ function ProviderPageRoute() {
return ;
}
- return pageContent(page);
+ const isUnavailable = isPageUnavailableOnProvider(requestedPage, provider);
+
+ return (
+ <>
+ {isUnavailable ? : pageContent(page)}
+ {isUnavailable && }
+ >
+ );
}
function App() {
@@ -81,6 +110,8 @@ function App() {
location.pathname.startsWith('/google-maps-3d') ? 'google-3d' :
location.pathname.startsWith('/google-maps') ? 'google' :
null;
+ const currentPage = location.pathname.split('/').filter(Boolean)[1] || DEFAULT_SAMPLE_PAGE;
+ const showProviderSelector = getSamplePageDefinition(currentPage)?.showProviderSelector ?? true;
const switchProvider = (provider: MapProvider) => {
const pathParts = location.pathname.split('/').filter(Boolean);
@@ -111,17 +142,19 @@ function App() {
-
+ {showProviderSelector && (
+
+ )}
diff --git a/examples/basic/src/MapViewContainer.tsx b/examples/basic/src/MapViewContainer.tsx
index 01abe4a..b7385a1 100644
--- a/examples/basic/src/MapViewContainer.tsx
+++ b/examples/basic/src/MapViewContainer.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
-import { useNavigate, useLocation } from 'react-router-dom';
+import { useLocation } from 'react-router-dom';
import { MapLibreDesign, MapLibreView, useMapLibreViewState, type MapLibreViewState } from '@mapconductor/react-for-maplibre';
import { GoogleMapDesign, GoogleMapsView, GoogleMapsView2D, useGoogleMapViewState, type GoogleMapViewState } from '@mapconductor/react-for-googlemaps';
import { createGeoPoint, createMapCameraPosition, MarkerTilingOptions, type GeoPoint, type MapCameraPosition, type MapViewStateInterface, type MapDesignTypeInterface } from '@mapconductor/js-sdk-core';
@@ -12,6 +12,8 @@ export { DEFAULT_CAMERA };
interface MapViewContainerProps {
children?: React.ReactNode;
onMapClick?: (point: GeoPoint) => void;
+ onCameraMoveStart?: (cam: MapCameraPosition) => void;
+ onCameraMove?: (cam: MapCameraPosition) => void;
onCameraMoveEnd?: (cam: MapCameraPosition) => void;
markerTilingOptions?: MarkerTilingOptions;
state: MapViewStateInterface>;
@@ -38,18 +40,26 @@ export function useSampleMapViewState(initialCamera: InitialCamera = DEFAULT_CAM
return mapLibreState;
}
-function MapLibreContainer({ children, onMapClick, onCameraMoveEnd, markerTilingOptions, state }: MapViewContainerProps) {
+function MapLibreContainer({ children, onMapClick, onCameraMoveStart, onCameraMove, onCameraMoveEnd, markerTilingOptions, state }: MapViewContainerProps) {
const mapState = state as MapLibreViewState;
- const isActive = useRef(true);
- useEffect(() => () => { isActive.current = false; }, []);
+ const isActive = useRef(false);
+ useEffect(() => () => { isActive.current = true; }, []);
return (
{
+ if (!isActive.current) return;
+ onCameraMoveStart?.(newCam);
+ }}
+ onCameraMove={(newCam: any) => {
+ if (!isActive.current) return;
+ onCameraMove?.(newCam);
+ }}
onCameraMoveEnd={(newCam: any) => {
if (!isActive.current) return;
onCameraMoveEnd?.(newCam);
@@ -60,12 +70,11 @@ function MapLibreContainer({ children, onMapClick, onCameraMoveEnd, markerTiling
);
}
-function GoogleMapsContainer2D({ children, onMapClick, onCameraMoveEnd, markerTilingOptions, state }: MapViewContainerProps) {
- const navigate = useNavigate();
+function GoogleMapsContainer2D({ children, onMapClick, onCameraMoveStart, onCameraMove, onCameraMoveEnd, markerTilingOptions, state }: MapViewContainerProps) {
const mapState = state as GoogleMapViewState;
- const isActive = useRef(true);
- useEffect(() => () => { isActive.current = false; }, []);
+ const isActive = useRef(false);
+ useEffect(() => () => { isActive.current = true; }, []);
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '';
if (!apiKey || apiKey === 'your_api_key_here') {
@@ -86,7 +95,16 @@ function GoogleMapsContainer2D({ children, onMapClick, onCameraMoveEnd, markerTi
apiKey={apiKey}
mapId={'DEMO_MAP_ID'}
version='alpha'
+ libraries={'map3d'} // for demo
markerTilingOptions={markerTilingOptions}
+ onCameraMoveStart={(newCam: any) => {
+ if (!isActive.current) return;
+ onCameraMoveStart?.(newCam);
+ }}
+ onCameraMove={(newCam: any) => {
+ if (!isActive.current) return;
+ onCameraMove?.(newCam);
+ }}
onMapClick={onMapClick}
onCameraMoveEnd={(newCam: any) => {
if (!isActive.current) return;
@@ -98,11 +116,11 @@ function GoogleMapsContainer2D({ children, onMapClick, onCameraMoveEnd, markerTi
);
}
-function GoogleMapsContainer3D({ children, onMapClick, onCameraMoveEnd, state }: MapViewContainerProps) {
+function GoogleMapsContainer3D({ children, onMapClick, onCameraMoveStart, onCameraMove, onCameraMoveEnd, state }: MapViewContainerProps) {
const mapState = state as GoogleMapViewState;
- const isActive = useRef(true);
- useEffect(() => () => { isActive.current = false; }, []);
+ const isActive = useRef(false);
+ useEffect(() => () => { isActive.current = true; }, []);
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '';
if (!apiKey || apiKey === 'your_api_key_here') {
@@ -124,6 +142,14 @@ function GoogleMapsContainer3D({ children, onMapClick, onCameraMoveEnd, state }:
mapId={'DEMO_MAP_ID'}
version='alpha'
onMapClick={onMapClick}
+ onCameraMoveStart={(newCam: any) => {
+ if (!isActive.current) return;
+ onCameraMoveStart?.(newCam);
+ }}
+ onCameraMove={(newCam: any) => {
+ if (!isActive.current) return;
+ onCameraMove?.(newCam);
+ }}
onCameraMoveEnd={(newCam: any) => {
if (!isActive.current) return;
onCameraMoveEnd?.(newCam);
@@ -134,7 +160,7 @@ function GoogleMapsContainer3D({ children, onMapClick, onCameraMoveEnd, state }:
);
}
-export function MapViewContainer({ children, onMapClick, onCameraMoveEnd, markerTilingOptions, state }: MapViewContainerProps) {
+export function MapViewContainer({ children, onMapClick, onCameraMoveStart, onCameraMove, onCameraMoveEnd, markerTilingOptions, state }: MapViewContainerProps) {
const location = useLocation();
const isGoogle3D = location.pathname.startsWith('/google-maps-3d');
const isGoogle2D = !isGoogle3D && location.pathname.startsWith('/google-maps');
@@ -142,6 +168,8 @@ export function MapViewContainer({ children, onMapClick, onCameraMoveEnd, marker
if (isGoogle3D) {
return
{children}
@@ -150,6 +178,8 @@ export function MapViewContainer({ children, onMapClick, onCameraMoveEnd, marker
if (isGoogle2D) {
return
@@ -158,6 +188,8 @@ export function MapViewContainer({ children, onMapClick, onCameraMoveEnd, marker
}
return
diff --git a/examples/basic/src/hooks/useNavigation.ts b/examples/basic/src/hooks/useNavigation.ts
deleted file mode 100644
index 35240ce..0000000
--- a/examples/basic/src/hooks/useNavigation.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useState, useCallback } from 'react';
-import { createGeoPoint, createMapCameraPosition } from '@mapconductor/js-sdk-core';
-import { useCamera } from '@mapconductor/js-sdk-react';
-
-/**
- * Custom hook for handling map navigation with loading state
- */
-export function useNavigation() {
- const { moveCamera } = useCamera();
- const [isNavigating, setIsNavigating] = useState(false);
-
- const flyToLocation = useCallback(
- async (lat: number, lng: number) => {
- setIsNavigating(true);
- try {
- await moveCamera(createMapCameraPosition({
- position: createGeoPoint({ latitude: lat, longitude: lng }),
- zoom: 10,
- }));
- } catch (error) {
- console.error('Navigation error:', error);
- } finally {
- setIsNavigating(false);
- }
- },
- [moveCamera]
- );
-
- return {
- flyToLocation,
- isNavigating,
- };
-}
diff --git a/examples/basic/src/index.css b/examples/basic/src/index.css
index 700b1fb..eac03a5 100644
--- a/examples/basic/src/index.css
+++ b/examples/basic/src/index.css
@@ -1,4 +1,5 @@
@import './pages/map/design/map-design-page.css';
+@import './pages/map/camerasync/camera-sync-page.css';
* {
margin: 0;
@@ -196,6 +197,57 @@ body,
color: white;
}
+.simple-text-bubble {
+ color: #2563eb;
+ padding: 4px;
+}
+
+.marker-icon-info-bubble {
+ max-width: min(360px, 72vw);
+ white-space: pre-wrap;
+ color: #111827;
+ font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
+}
+
+.right-tail-info-bubble {
+ position: relative;
+ width: max-content;
+ max-width: 220px;
+ padding: 8px;
+ margin-left: 8px;
+ border: 2px solid #000000;
+ border-radius: 4px;
+ background: #ffffff;
+ color: #2563eb;
+ font-size: 13px;
+ line-height: 1.35;
+ box-shadow: 0 3px 14px rgba(15, 23, 42, 0.18);
+}
+
+.right-tail-info-bubble::before,
+.right-tail-info-bubble::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 0;
+ height: 0;
+}
+
+.right-tail-info-bubble::before {
+ left: -10px;
+ border-top: 7px solid transparent;
+ border-bottom: 7px solid transparent;
+ border-right: 10px solid #000000;
+}
+
+.right-tail-info-bubble::after {
+ left: -7px;
+ border-top: 5px solid transparent;
+ border-bottom: 5px solid transparent;
+ border-right: 8px solid #ffffff;
+}
+
.rich-bubble {
display: grid;
gap: 6px;
@@ -208,6 +260,62 @@ body,
font-size: 13px;
}
+.rich-location-bubble {
+ display: grid;
+ gap: 8px;
+ width: 200px;
+ color: #111827;
+ font-size: 13px;
+}
+
+.rich-location-bubble strong {
+ font-size: 18px;
+ line-height: 1.2;
+}
+
+.rich-location-bubble p {
+ color: #6b7280;
+ line-height: 1.35;
+}
+
+.rating-row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+}
+
+.rating-stars {
+ display: flex;
+ gap: 1px;
+}
+
+.rating-stars span {
+ color: #9ca3af;
+}
+
+.rating-stars .filled {
+ color: #facc15;
+}
+
+.multi-bubble-content {
+ display: grid;
+ gap: 2px;
+ min-height: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: #111827;
+ cursor: pointer;
+ text-align: left;
+ font: inherit;
+}
+
+.multi-bubble-content span {
+ color: #6b7280;
+ font-size: 12px;
+}
+
.map-container {
flex: 1;
min-width: 0;
@@ -262,6 +370,23 @@ body,
margin-top: 8px;
}
+.circle-radius-label {
+ position: absolute;
+ z-index: 1200;
+ transform: translate(-50%, -50%);
+ color: #dc2626;
+ font-size: 14px;
+ font-weight: 700;
+ line-height: 1;
+ text-shadow:
+ -2px -2px 0 #ffffff,
+ 2px -2px 0 #ffffff,
+ -2px 2px 0 #ffffff,
+ 2px 2px 0 #ffffff,
+ 0 0 5px rgba(255, 255, 255, 0.95);
+ pointer-events: none;
+}
+
.toast-stack {
position: absolute;
bottom: 80px;
@@ -284,6 +409,31 @@ body,
white-space: pre-line;
}
+.provider-unavailable-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 2500;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(15, 23, 42, 0.42);
+ pointer-events: auto;
+}
+
+.provider-unavailable-box {
+ width: min(420px, 100%);
+ padding: 18px 22px;
+ border: 1px solid rgba(148, 163, 184, 0.45);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.96);
+ color: #111827;
+ box-shadow: 0 18px 42px rgba(15, 23, 42, 0.3);
+ font-size: 0.98rem;
+ font-weight: 600;
+ line-height: 1.35;
+ text-align: center;
+}
+
@media (max-width: 760px) {
.header {
min-height: 58px;
diff --git a/examples/basic/src/main.tsx b/examples/basic/src/main.tsx
index a814b52..4f982c8 100644
--- a/examples/basic/src/main.tsx
+++ b/examples/basic/src/main.tsx
@@ -6,7 +6,7 @@ import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
-
+
diff --git a/examples/basic/src/pages/CirclePage.tsx b/examples/basic/src/pages/CirclePage.tsx
index 53b2ab3..9951f20 100644
--- a/examples/basic/src/pages/CirclePage.tsx
+++ b/examples/basic/src/pages/CirclePage.tsx
@@ -1,49 +1,160 @@
-import { useMemo, useState } from 'react';
-import { createCircleState } from '@mapconductor/js-sdk-core';
-import { Circle, MarkerFromProps } from '@mapconductor/js-sdk-react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ ColorDefaultIcon,
+ Spherical,
+ calculatePositionAtDistance,
+ computeDistanceBetween,
+ createCircleState,
+ createGeoPoint,
+ type GeoPoint,
+ type MarkerState,
+ type Offset,
+} from '@mapconductor/js-sdk-core';
+import { Circle, Marker, Polyline } from '@mapconductor/js-sdk-react';
import { ControlPanel, SliderControl } from '../components/ControlPanel';
import { Toast, useToast } from '../components/Toast';
-import { CIRCLE_CENTER } from '../data/storeData';
import { MapViewContainer, useSampleMapViewState } from '../MapViewContainer';
-const INIT_CAMERA = { lat: 21.3825, lng: -157.9330, zoom: 12 };
+const CIRCLE_CENTER = createGeoPoint({ latitude: 21.382314, longitude: -157.933097 });
+const INIT_CAMERA = { lat: CIRCLE_CENTER.latitude, lng: CIRCLE_CENTER.longitude, zoom: 12 };
+const INITIAL_RADIUS_METERS = 1000;
+const SUPPRESS_CIRCLE_CLICK_AFTER_MARKER_DRAG_MS = 300;
+const CIRCLE_COLORS = ['#0000ff', '#ff0000', '#008000', '#00ffff', '#d3d3d3', '#ff00ff'];
+
+function rgba(hex: string, alpha: number): string {
+ const value = hex.replace('#', '');
+ const r = parseInt(value.slice(0, 2), 16);
+ const g = parseInt(value.slice(2, 4), 16);
+ const b = parseInt(value.slice(4, 6), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+function radiusLabelPosition(
+ center: GeoPoint,
+ edge: GeoPoint,
+ mapViewState: ReturnType,
+): Offset | null | Promise {
+ const holder = mapViewState.getMapViewHolder();
+ if (!holder) return null;
+ const midpoint = Spherical.linearInterpolate({ from: center, to: edge, fraction: 0.5 });
+ return holder.toScreenOffset(midpoint);
+}
export function CirclePage() {
const mapViewState = useSampleMapViewState(INIT_CAMERA);
- const [radius, setRadius] = useState(1000);
+ const [edgePosition, setEdgePosition] = useState(() =>
+ calculatePositionAtDistance({
+ center: CIRCLE_CENTER,
+ distanceMeters: INITIAL_RADIUS_METERS,
+ bearingDegrees: 90,
+ }),
+ );
+ const [colorIndex, setColorIndex] = useState(0);
const [fillOpacity, setFillOpacity] = useState(0.3);
const [strokeWidth, setStrokeWidth] = useState(3);
+ const [labelOffset, setLabelOffset] = useState(null);
+ const [cameraTick, setCameraTick] = useState(0);
+ const suppressCircleClickUntilRef = useRef(0);
const { messages, showToast, dismissToast } = useToast();
+ const radius = useMemo(
+ () => computeDistanceBetween(CIRCLE_CENTER, edgePosition),
+ [edgePosition],
+ );
+
+ const updateLabelPosition = useCallback(() => {
+ const next = radiusLabelPosition(CIRCLE_CENTER, edgePosition, mapViewState);
+ if (next instanceof Promise) {
+ next.then(setLabelOffset).catch(() => setLabelOffset(null));
+ } else {
+ setLabelOffset(next);
+ }
+ }, [edgePosition, mapViewState]);
+
+ useEffect(() => {
+ updateLabelPosition();
+ const raf = requestAnimationFrame(updateLabelPosition);
+ return () => cancelAnimationFrame(raf);
+ }, [cameraTick, updateLabelPosition]);
+
+ const handleMarkerMove = useCallback((dragged: MarkerState) => {
+ setEdgePosition(dragged.position);
+ }, []);
+
+ const handleMarkerDragEnd = useCallback((dragged: MarkerState) => {
+ suppressCircleClickUntilRef.current =
+ Date.now() + SUPPRESS_CIRCLE_CLICK_AFTER_MARKER_DRAG_MS;
+ setEdgePosition(dragged.position);
+ }, []);
const circleState = useMemo(
() =>
createCircleState({
- id: 'demo-circle',
+ id: 'circle',
center: CIRCLE_CENTER,
radiusMeters: radius,
- fillColor: `rgba(0, 100, 255, ${fillOpacity})`,
- strokeColor: '#0064ff',
+ fillColor: rgba(CIRCLE_COLORS[colorIndex], fillOpacity),
+ strokeColor: 'rgba(0, 0, 255, 0.5)',
strokeWidth,
clickable: true,
- onClick: () => showToast(`Circle clicked - radius: ${radius.toFixed(0)}m`),
+ onClick: () => {
+ if (Date.now() < suppressCircleClickUntilRef.current) return;
+ setColorIndex((index) => (index + 1) % CIRCLE_COLORS.length);
+ showToast(`Circle clicked - Radius: ${radius.toFixed(0)}m`);
+ },
}),
- [radius, fillOpacity, strokeWidth, showToast]
+ [colorIndex, fillOpacity, radius, showToast, strokeWidth],
+ );
+
+ const centerIcon = useMemo(
+ () => new ColorDefaultIcon('#ff0000', { strokeColor: '#ffffff', label: 'C' }),
+ [],
+ );
+ const edgeIcon = useMemo(
+ () => new ColorDefaultIcon('#008000', { strokeColor: '#ffffff', label: 'E' }),
+ [],
);
return (
-
+ setCameraTick((tick) => tick + 1)}
+ onCameraMoveEnd={() => setCameraTick((tick) => tick + 1)}
+ >
-
+
+
+
+ {labelOffset && (
+
+ {radius.toFixed(0)} m
+
+ )}
- `${value.toFixed(0)}m`}
- onChange={setRadius}
- />
`${value.toFixed(1)}px`}
onChange={setStrokeWidth}
/>
diff --git a/examples/basic/src/pages/common/sampleHelpers.ts b/examples/basic/src/pages/common/sampleHelpers.ts
index 184dd22..c46af5e 100644
--- a/examples/basic/src/pages/common/sampleHelpers.ts
+++ b/examples/basic/src/pages/common/sampleHelpers.ts
@@ -1,11 +1,8 @@
import {
ColorDefaultIcon,
createGeoPoint,
- createMapCameraPosition,
createMarkerState,
- type GeoPoint,
} from '@mapconductor/js-sdk-core';
-import { useMapViewController } from '@mapconductor/js-sdk-react';
export const HONOLULU = createGeoPoint({ latitude: 21.382314, longitude: -157.933097 });
export const SAPPORO = createGeoPoint({ latitude: 43.06417, longitude: 141.34694 });
@@ -18,16 +15,3 @@ export function cityMarker(id: string, label: string, latitude: number, longitud
icon: new ColorDefaultIcon('#2563eb', { label: label.slice(0, 1), labelTextColor: '#ffffff' }),
});
}
-
-export function useCameraActions() {
- const controller = useMapViewController();
-
- return {
- getBounds: () => controller?.getBounds() ?? null,
- getCameraPosition: () => controller?.getCameraPosition() ?? null,
- animateCamera: (center: GeoPoint, duration = 800, zoom = 13, bearing = 0, tilt = 0) => {
- if (!controller) return;
- void controller.animateCamera(createMapCameraPosition({ position: center, zoom, bearing, tilt }), { duration });
- },
- };
-}
diff --git a/examples/basic/src/pages/geojson/basic/BasicGeoJSONPage.tsx b/examples/basic/src/pages/geojson/basic/BasicGeoJSONPage.tsx
new file mode 100644
index 0000000..f2a4b70
--- /dev/null
+++ b/examples/basic/src/pages/geojson/basic/BasicGeoJSONPage.tsx
@@ -0,0 +1,83 @@
+import { useMemo, useState, useEffect } from 'react';
+import {
+ GeoJSONLayer,
+ GeoJSONLayerState,
+ GeoJSONParser,
+ colorArgb,
+} from '@mapconductor/react-geojson-layer';
+import type { GeoJSONFeatureData } from '@mapconductor/react-geojson-layer';
+import { ControlPanel } from '../../../components/ControlPanel';
+import { MapViewContainer, useSampleMapViewState } from '../../../MapViewContainer';
+
+const INIT_CAMERA = { lat: 25.255377, lng: 55.3089185, zoom: 13 };
+
+const BASIC_GEOJSON = `{
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [55.30122473231012, 25.26476622289597],
+ [55.29743486255916, 25.25827212207261],
+ [55.28978863411328, 25.251356725509737],
+ [55.300027931336984, 25.246425506635504],
+ [55.307474692951274, 25.244200378933655],
+ [55.31212891895635, 25.256408010450187],
+ [55.30774064871093, 25.26266169122738],
+ [55.301357710197806, 25.264946609615492],
+ [55.30122473231012, 25.26476622289597]
+ ],
+ [
+ [55.30084858315658, 25.256531695820797],
+ [55.298280197635705, 25.252243254705405],
+ [55.30163885563897, 25.250501032248863],
+ [55.304059065092645, 25.254700192612702],
+ [55.30084858315658, 25.256531695820797]
+ ],
+ [
+ [55.30173763969924, 25.262517391695198],
+ [55.301095543307355, 25.26122200491396],
+ [55.30396028103232, 25.259479911263526],
+ [55.30489872958182, 25.261132667394975],
+ [55.30173763969924, 25.262517391695198]
+ ]
+ ]
+ },
+ "properties": {}
+ }
+ ]
+}`;
+
+export function BasicGeoJSONPage() {
+ const mapViewState = useSampleMapViewState(INIT_CAMERA);
+
+ const layerState = useMemo(
+ () =>
+ new GeoJSONLayerState({
+ fillColor: colorArgb(127, 0x3b, 0xb2, 0xd0),
+ strokeColor: colorArgb(255, 0x1d, 0x70, 0x82),
+ strokeWidth: 2,
+ }),
+ [],
+ );
+
+ const [features, setFeatures] = useState([]);
+
+ useEffect(() => {
+ setFeatures(GeoJSONParser.parseFeatures(BASIC_GEOJSON));
+ }, []);
+
+ return (
+
+
+
+
+ GeoJSON ポリゴン(ホール付き)を表示しています。
+
+
+
+ );
+}
diff --git a/examples/basic/src/pages/geojson/layer/GeoJSONLayerPage.tsx b/examples/basic/src/pages/geojson/layer/GeoJSONLayerPage.tsx
new file mode 100644
index 0000000..979ed21
--- /dev/null
+++ b/examples/basic/src/pages/geojson/layer/GeoJSONLayerPage.tsx
@@ -0,0 +1,142 @@
+import { useCallback, useMemo, useState, useEffect } from 'react';
+import { createGeoPoint, type GeoPoint } from '@mapconductor/js-sdk-core';
+import { InfoBubbleAtPosition } from '@mapconductor/js-sdk-react';
+import {
+ GeoJSONLayer,
+ GeoJSONLayerState,
+ GeoJSONParser,
+ colorArgb,
+} from '@mapconductor/react-geojson-layer';
+import type { GeoJSONFeatureData } from '@mapconductor/react-geojson-layer';
+import { ControlPanel } from '../../../components/ControlPanel';
+import { MapViewContainer, useSampleMapViewState } from '../../../MapViewContainer';
+
+const INIT_CAMERA = { lat: 35.68, lng: 139.77, zoom: 13 };
+
+interface SelectedFeature {
+ position: GeoPoint;
+ properties: Record;
+}
+
+const PROPERTY_LABELS: Record = {
+ N02_002: '鉄道区分',
+ N02_003: '事業者種別',
+ N02_004: '路線名',
+ N02_005: '運営会社',
+};
+
+export function GeoJSONLayerPage() {
+ const mapViewState = useSampleMapViewState(INIT_CAMERA);
+ const [features, setFeatures] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selected, setSelected] = useState(null);
+
+ const layerState = useMemo(
+ () =>
+ new GeoJSONLayerState({
+ strokeColor: colorArgb(200, 250, 36, 29),
+ strokeWidth: 6,
+ onClick: (feature, position) => {
+ setSelected({
+ position: createGeoPoint({ latitude: position.latitude, longitude: position.longitude }),
+ properties: feature.properties ?? {},
+ });
+ },
+ }),
+ [],
+ );
+
+ useEffect(() => {
+ fetch(`${import.meta.env.BASE_URL}geojson/sample-railway.geojson`)
+ .then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.text();
+ })
+ .then(text => {
+ setFeatures(GeoJSONParser.parseFeatures(text));
+ setIsLoading(false);
+ })
+ .catch(err => {
+ setError(String(err));
+ setIsLoading(false);
+ });
+ }, []);
+
+ const handleMapClick = useCallback(
+ (point: GeoPoint) => {
+ if (!layerState.processClick(point)) {
+ setSelected(null);
+ }
+ },
+ [layerState],
+ );
+
+ if (error) {
+ return (
+
+
データの読み込みに失敗しました: {error}
+
+ );
+ }
+
+ return (
+
+
+
+ {selected && (
+
+
+
+ )}
+
+
+ {isLoading ? (
+ GeoJSON 読み込み中...
+ ) : (
+
+ 路線をタップするとプロパティが表示されます。
+
+ )}
+
+
+ );
+}
+
+function PropertyTable({ properties }: { properties: Record }) {
+ const entries = Object.entries(properties);
+ if (entries.length === 0) return プロパティなし
;
+
+ return (
+
+
+
+ | プロパティ |
+ 値 |
+
+
+
+ {entries.map(([key, value]) => (
+
+ | {PROPERTY_LABELS[key] ?? key} |
+ {value == null ? '' : String(value)} |
+
+ ))}
+
+
+ );
+}
+
+const thStyle: React.CSSProperties = {
+ border: '1px solid #bbb',
+ padding: '4px 8px',
+ textAlign: 'left',
+ fontWeight: 600,
+ color: '#333',
+};
+
+const tdStyle: React.CSSProperties = {
+ border: '1px solid #bbb',
+ padding: '4px 8px',
+ color: '#222',
+};
diff --git a/examples/basic/src/pages/groundimage/GroundImagePage.tsx b/examples/basic/src/pages/groundimage/GroundImagePage.tsx
index 071e3f6..5006a7f 100644
--- a/examples/basic/src/pages/groundimage/GroundImagePage.tsx
+++ b/examples/basic/src/pages/groundimage/GroundImagePage.tsx
@@ -12,8 +12,8 @@ import { Toast, useToast } from '../../components/Toast';
import { MapViewContainer, useSampleMapViewState } from '../../MapViewContainer';
const INIT_CAMERA = { lat: 40.7410, lng: -74.1758, zoom: 11 };
-const GROUND_IMAGE_URL = '/newark_nj_1922_0.webp';
-const CLICKED_GROUND_IMAGE_URL = '/newark_nj_1922_1.webp';
+const GROUND_IMAGE_URL = `${import.meta.env.BASE_URL}newark_nj_1922_0.webp`;
+const CLICKED_GROUND_IMAGE_URL = `${import.meta.env.BASE_URL}newark_nj_1922_1.webp`;
export function GroundImagePage() {
const mapViewState = useSampleMapViewState(INIT_CAMERA);
diff --git a/examples/basic/src/pages/heatmaplayer/HeatmapLayerPage.tsx b/examples/basic/src/pages/heatmaplayer/HeatmapLayerPage.tsx
new file mode 100644
index 0000000..963b09f
--- /dev/null
+++ b/examples/basic/src/pages/heatmaplayer/HeatmapLayerPage.tsx
@@ -0,0 +1,95 @@
+import { useEffect, useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import { createGeoPoint, createMapCameraPosition } from '@mapconductor/js-sdk-core';
+import { GoogleMapDesign, useGoogleMapViewState } from '@mapconductor/react-for-googlemaps';
+import { MapLibreDesign, useMapLibreViewState } from '@mapconductor/react-for-maplibre';
+import { HeatmapOverlay, HeatmapPoints, HeatmapPointState } from '@mapconductor/react-heatmap';
+import { ControlPanel } from '../../components/ControlPanel';
+import { MapViewContainer } from '../../MapViewContainer';
+import type { MapDesignTypeInterface, MapViewStateInterface } from '@mapconductor/js-sdk-core';
+
+const INIT_CAMERA_POSITION = createMapCameraPosition({
+ position: createGeoPoint({ latitude: 35.68049, longitude: 139.76669 }),
+ zoom: 10,
+});
+
+function HeatmapLayerPageContent({
+ mapViewState,
+}: {
+ mapViewState: MapViewStateInterface>;
+}) {
+ const [heatmapPoints, setHeatmapPoints] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetch(`${import.meta.env.BASE_URL}postoffice/postoffices.json`)
+ .then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json() as Promise<[number, number, string, string][]>;
+ })
+ .then(data => {
+ const points = data.map(([lat, lng], i) =>
+ new HeatmapPointState({
+ id: `po-${i}`,
+ position: createGeoPoint({ latitude: lat, longitude: lng }),
+ }),
+ );
+ setHeatmapPoints(points);
+ setIsLoading(false);
+ })
+ .catch(err => {
+ setError(String(err));
+ setIsLoading(false);
+ });
+ }, []);
+
+ if (error) {
+ return (
+
+
データの読み込みに失敗しました: {error}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {isLoading ? (
+ データ読み込み中...
+ ) : (
+
+ 郵便局データをヒートマップで表示しています。
+
+ )}
+
+
+ );
+}
+
+function GoogleHeatmapLayerPage() {
+ const mapViewState = useGoogleMapViewState({
+ mapDesignType: GoogleMapDesign.Normal,
+ cameraPosition: INIT_CAMERA_POSITION,
+ });
+ return ;
+}
+
+function MapLibreHeatmapLayerPage() {
+ const mapViewState = useMapLibreViewState({
+ mapDesignType: MapLibreDesign.MapTilerTonerEn,
+ cameraPosition: INIT_CAMERA_POSITION,
+ });
+ return ;
+}
+
+export function HeatmapLayerPage() {
+ const location = useLocation();
+ return location.pathname.startsWith('/google-maps')
+ ?
+ : ;
+}
diff --git a/examples/basic/src/pages/infobubble/MultipleBubblesPage.tsx b/examples/basic/src/pages/infobubble/MultipleBubblesPage.tsx
index e17d6dd..b32926d 100644
--- a/examples/basic/src/pages/infobubble/MultipleBubblesPage.tsx
+++ b/examples/basic/src/pages/infobubble/MultipleBubblesPage.tsx
@@ -1,26 +1,75 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
+import { ColorDefaultIcon, createGeoPoint, createMarkerState } from '@mapconductor/js-sdk-core';
import { InfoBubble, Markers } from '@mapconductor/js-sdk-react';
-import { cityMarker } from '../common/sampleHelpers';
import { MapViewContainer, useSampleMapViewState } from '../../MapViewContainer';
-const INIT_CAMERA = { lat: 43.065, lng: 141.345, zoom: 12 };
+const INIT_CAMERA = { lat: 37.7749, lng: -122.4194, zoom: 15, pitch: 45 };
export function MultipleBubblesPage() {
const mapViewState = useSampleMapViewState(INIT_CAMERA);
- const markers = useMemo(() => [
- cityMarker('bubble-1', 'One', 43.06417, 141.34694),
- cityMarker('bubble-2', 'Two', 43.07, 141.36),
- cityMarker('bubble-3', 'Three', 43.055, 141.33),
- ], []);
+ const [selectedMarkerIds, setSelectedMarkerIds] = useState>(
+ () => new Set(['marker_0', 'marker_1', 'marker_2'])
+ );
+ const markers = useMemo(
+ () =>
+ [
+ {
+ position: createGeoPoint({ latitude: 37.7749, longitude: -122.4194 }),
+ name: 'Restaurant A',
+ color: '#ef4444',
+ },
+ {
+ position: createGeoPoint({ latitude: 37.7849, longitude: -122.4094 }),
+ name: 'Hotel B',
+ color: '#2563eb',
+ },
+ {
+ position: createGeoPoint({ latitude: 37.7649, longitude: -122.4294 }),
+ name: 'Shop C',
+ color: '#22c55e',
+ },
+ ].map((data, index) =>
+ createMarkerState({
+ id: `marker_${index}`,
+ position: data.position,
+ icon: new ColorDefaultIcon(data.color, { label: `${index + 1}`, labelTextColor: '#ffffff' }),
+ extra: data.name,
+ onClick: state => {
+ setSelectedMarkerIds(prev => {
+ const next = new Set(prev);
+ if (next.has(state.id)) next.delete(state.id);
+ else next.add(state.id);
+ return next;
+ });
+ },
+ })
+ ),
+ []
+ );
return (
-
+ setSelectedMarkerIds(new Set())}>
- {markers.map(marker => (
-
- {marker.extra as string}
-
- ))}
+ {markers.map(marker =>
+ selectedMarkerIds.has(marker.id) ? (
+
+
+
+ ) : null
+ )}
);
}
diff --git a/examples/basic/src/pages/infobubble/RichContentBubblePage.tsx b/examples/basic/src/pages/infobubble/RichContentBubblePage.tsx
index e50968d..c1774f1 100644
--- a/examples/basic/src/pages/infobubble/RichContentBubblePage.tsx
+++ b/examples/basic/src/pages/infobubble/RichContentBubblePage.tsx
@@ -1,23 +1,60 @@
-import { useMemo } from 'react';
-import { InfoBubbleCustom, Marker } from '@mapconductor/js-sdk-react';
-import { cityMarker } from '../common/sampleHelpers';
+import { useMemo, useState } from 'react';
+import { ColorDefaultIcon, createGeoPoint, createMarkerState } from '@mapconductor/js-sdk-core';
+import { InfoBubble, Marker } from '@mapconductor/js-sdk-react';
import { MapViewContainer, useSampleMapViewState } from '../../MapViewContainer';
-const INIT_CAMERA = { lat: 43.0642, lng: 141.3469, zoom: 13 };
+interface LocationInfo extends Record {
+ name: string;
+ description: string;
+ rating: number;
+}
+
+const INIT_CAMERA = { lat: 37.7749, lng: -122.4194, zoom: 10 };
export function RichContentBubblePage() {
const mapViewState = useSampleMapViewState(INIT_CAMERA);
- const marker = useMemo(() => cityMarker('rich-bubble', 'Rich', 43.06417, 141.34694), []);
+ const [selectedMarkerId, setSelectedMarkerId] = useState('golden-gate-park');
+ const marker = useMemo(() => {
+ const locationInfo: LocationInfo = {
+ name: 'Golden Gate Park',
+ description: 'A large urban park with gardens, museums, and recreational areas.',
+ rating: 4.5,
+ };
+ return createMarkerState({
+ id: 'golden-gate-park',
+ position: createGeoPoint({ latitude: 37.7694, longitude: -122.4862 }),
+ icon: new ColorDefaultIcon('#22c55e', { label: '🌳' }),
+ extra: locationInfo,
+ onClick: state => setSelectedMarkerId(state.id),
+ });
+ }, []);
+ const info = marker.extra as LocationInfo;
+
return (
-
+ setSelectedMarkerId(null)}>
-
-
- Rich content
- Status: open
-
-
-
+ {selectedMarkerId === marker.id && (
+
+
+
{info.name}
+
{info.description}
+
+
+ {Array.from({ length: 5 }, (_, index) => (
+ ★
+ ))}
+
+ {info.rating}/5
+
+
+
+ )}
);
}
diff --git a/examples/basic/src/pages/infobubble/SimpleInfoBubblePage.tsx b/examples/basic/src/pages/infobubble/SimpleInfoBubblePage.tsx
index 1a0c3fa..0b8ccb0 100644
--- a/examples/basic/src/pages/infobubble/SimpleInfoBubblePage.tsx
+++ b/examples/basic/src/pages/infobubble/SimpleInfoBubblePage.tsx
@@ -1,19 +1,32 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
+import { ColorDefaultIcon, createGeoPoint, createMarkerState } from '@mapconductor/js-sdk-core';
import { InfoBubble, Marker } from '@mapconductor/js-sdk-react';
-import { cityMarker } from '../common/sampleHelpers';
import { MapViewContainer, useSampleMapViewState } from '../../MapViewContainer';
-const INIT_CAMERA = { lat: 43.0642, lng: 141.3469, zoom: 13 };
+const SAN_FRANCISCO = createGeoPoint({ latitude: 37.7749, longitude: -122.4194 });
+const INIT_CAMERA = { lat: 37.7749, lng: -122.4194, zoom: 10 };
export function SimpleInfoBubblePage() {
const mapViewState = useSampleMapViewState(INIT_CAMERA);
- const marker = useMemo(() => cityMarker('simple-bubble', 'Sapporo', 43.06417, 141.34694), []);
+ const [selectedMarkerId, setSelectedMarkerId] = useState('simple-text-bubble');
+ const marker = useMemo(() => createMarkerState({
+ id: 'simple-text-bubble',
+ position: SAN_FRANCISCO,
+ icon: new ColorDefaultIcon('#2563eb', { label: 'SF', labelTextColor: '#ffffff' }),
+ extra: 'San Francisco - The Golden Gate City',
+ onClick: state => setSelectedMarkerId(state.id),
+ }), []);
+
return (
-
+ setSelectedMarkerId(null)}>
-
- Simple text bubble
-
+ {selectedMarkerId === marker.id && (
+
+
+ {marker.extra as string}
+
+
+ )}
);
}
diff --git a/examples/basic/src/pages/infobubble/StyledInfoBubblePage.tsx b/examples/basic/src/pages/infobubble/StyledInfoBubblePage.tsx
index 01256b1..c06db05 100644
--- a/examples/basic/src/pages/infobubble/StyledInfoBubblePage.tsx
+++ b/examples/basic/src/pages/infobubble/StyledInfoBubblePage.tsx
@@ -1,29 +1,52 @@
-import { useState } from 'react';
-import { ColorDefaultIcon, createMarkerState } from '@mapconductor/js-sdk-core';
-import { InfoBubble, Marker } from '@mapconductor/js-sdk-react';
-import { SAPPORO } from '../common/sampleHelpers';
+import { useMemo, useState } from 'react';
+import { ColorDefaultIcon, createGeoPoint, createMarkerState } from '@mapconductor/js-sdk-core';
+import { InfoBubbleCustom, Markers } from '@mapconductor/js-sdk-react';
import { MapViewContainer, useSampleMapViewState } from '../../MapViewContainer';
-const INIT_CAMERA = { lat: 43.0642, lng: 141.3469, zoom: 13 };
+const INIT_CAMERA = { lat: 37.7849, lng: -122.4094, zoom: 15 };
export function StyledInfoBubblePage() {
const mapViewState = useSampleMapViewState(INIT_CAMERA);
- const [marker] = useState(() => createMarkerState({
- id: 'styled-bubble',
- position: SAPPORO,
- draggable: true,
- icon: new ColorDefaultIcon('#7c3aed', { label: 'S', labelTextColor: '#ffffff' }),
- }));
+ const [selectedMarkerId, setSelectedMarkerId] = useState('marker1');
+ const markers = useMemo(() => {
+ const marker1 = createMarkerState({
+ id: 'marker1',
+ position: createGeoPoint({ latitude: 37.7749, longitude: -122.4194 }),
+ icon: new ColorDefaultIcon('#2563eb', {
+ label: '1',
+ labelTextColor: '#ffffff',
+ infoAnchor: { x: 0.5, y: 0.25 },
+ }),
+ draggable: true,
+ onClick: state => setSelectedMarkerId(state.id),
+ onDragStart: state => console.log(`マーカーのドラッグを開始: ${state.id}`),
+ onDrag: state => console.log('マーカーをドラッグ中:', state.position),
+ onDragEnd: state => console.log(`マーカーのドラッグが終了: ${state.id}`),
+ });
+ const marker2 = createMarkerState({
+ id: 'marker2',
+ position: createGeoPoint({ latitude: 37.7849, longitude: -122.4094 }),
+ icon: new ColorDefaultIcon('#ef4444', {
+ label: '2',
+ labelTextColor: '#ffffff',
+ infoAnchor: { x: 0.5, y: 0.25 },
+ }),
+ onClick: state => setSelectedMarkerId(state.id),
+ });
+ return [marker1, marker2];
+ }, []);
+ const activeMarker = markers.find(marker => marker.id === selectedMarkerId);
return (
-
-
-
-
- Styled bubble
- Drag the marker.
-
-
+ setSelectedMarkerId(null)}>
+
+ {activeMarker && (
+
+
+ {activeMarker.position.toUrlValue(6)}
+
+
+ )}
);
}
diff --git a/examples/basic/src/pages/map/basic/StoreMapPage.tsx b/examples/basic/src/pages/map/basic/StoreMapPage.tsx
index b7f75ca..96650fa 100644
--- a/examples/basic/src/pages/map/basic/StoreMapPage.tsx
+++ b/examples/basic/src/pages/map/basic/StoreMapPage.tsx
@@ -13,10 +13,10 @@ import { StoreInfoView } from './StoreInfoView';
const INIT_CAMERA = { lat: 21.382314, lng: -157.933097, zoom: 10 };
const STORE_ICON_URLS: Record = {
- coffee_bean: '/store-icons/coffee_bean.webp',
- honolulu_coffee: '/store-icons/honolulu_coffee.webp',
- coffee_extra: '/store-icons/coffee_extra.webp',
- starbucks: '/store-icons/starbucks.webp',
+ coffee_bean: `${import.meta.env.BASE_URL}store-icons/coffee_bean.webp`,
+ honolulu_coffee: `${import.meta.env.BASE_URL}store-icons/honolulu_coffee.webp`,
+ coffee_extra: `${import.meta.env.BASE_URL}store-icons/coffee_extra.webp`,
+ starbucks: `${import.meta.env.BASE_URL}store-icons/starbucks.webp`,
};
export function MapPage() {
diff --git a/examples/basic/src/pages/map/camerasync/CameraSyncPage.tsx b/examples/basic/src/pages/map/camerasync/CameraSyncPage.tsx
new file mode 100644
index 0000000..adb310e
--- /dev/null
+++ b/examples/basic/src/pages/map/camerasync/CameraSyncPage.tsx
@@ -0,0 +1,555 @@
+import { useMemo, useRef, useState } from 'react';
+import {
+ createGeoPoint,
+ createMapCameraPosition,
+ createPolygonState,
+ createPolylineState,
+ type GeoPoint,
+ type MapCameraPosition,
+ type MapDesignTypeInterface,
+ type MapViewStateInterface,
+ type PolygonState,
+ type PolylineState,
+} from '@mapconductor/js-sdk-core';
+import { Polygon, Polyline } from '@mapconductor/js-sdk-react';
+import {
+ GoogleMapDesign,
+ GoogleMapsView,
+ GoogleMapsView2D,
+ useGoogleMapViewState,
+ type GoogleMapViewState,
+} from '@mapconductor/react-for-googlemaps';
+import {
+ MapLibreDesign,
+ MapLibreView,
+ useMapLibreViewState,
+ type MapLibreViewState,
+} from '@mapconductor/react-for-maplibre';
+
+type PaneId = 'left' | 'right';
+type PaneProvider = 'maplibre' | 'google-maps' | 'google-maps-3d';
+
+interface CameraLocationInfo {
+ name: string;
+ bounds: {
+ southWest: GeoPoint;
+ northEast: GeoPoint;
+ };
+ center: GeoPoint;
+ zoom: number;
+}
+
+interface ProgrammaticMoveState {
+ key: number | null;
+ target: MapCameraPosition | null;
+ untilMs: number;
+ sinceMs: number;
+}
+
+interface PairedFlyToState {
+ active: boolean;
+ untilMs: number;
+ leftEnded: boolean;
+ rightEnded: boolean;
+}
+
+interface PaneState {
+ provider: PaneProvider;
+ mapState: MapViewStateInterface>;
+}
+
+const INITIAL_CAMERA = createMapCameraPosition({
+ position: createGeoPoint({ latitude: 35.6812, longitude: 139.7671, altitude: 0 }),
+ zoom: 12,
+ bearing: 0,
+ tilt: 0,
+});
+
+const PROGRAMMATIC_TTL_MS = 1200;
+const PROGRAMMATIC_GRACE_MS = 250;
+const MOVE_SYNC_INTERVAL_MS = 33;
+const FLY_TO_DURATION_MS = 1000;
+
+function cameraKey(camera: MapCameraPosition): number {
+ const latE5 = Math.trunc(camera.position.latitude * 1e5);
+ const lonE5 = Math.trunc(camera.position.longitude * 1e5);
+ const zoom100 = Math.trunc(camera.zoom * 100);
+ const bearing10 = Math.trunc(camera.bearing * 10);
+ return (((latE5 * 31 + lonE5) * 31 + zoom100) * 31 + bearing10);
+}
+
+function bearingDeltaDeg(a: number, b: number): number {
+ const d = ((a - b) % 360 + 360) % 360;
+ return d > 180 ? 360 - d : d;
+}
+
+function isCloseToTarget(camera: MapCameraPosition, target: MapCameraPosition): boolean {
+ return (
+ Math.abs(camera.position.latitude - target.position.latitude) < 0.0012 &&
+ Math.abs(camera.position.longitude - target.position.longitude) < 0.0012 &&
+ Math.abs(camera.zoom - target.zoom) < 0.75 &&
+ bearingDeltaDeg(camera.bearing, target.bearing) < 12 &&
+ Math.abs(camera.tilt - target.tilt) < 6
+ );
+}
+
+function markProgrammaticMove(
+ ref: React.MutableRefObject,
+ target: MapCameraPosition,
+ nowMs: number,
+ ttlMs = PROGRAMMATIC_TTL_MS,
+) {
+ ref.current = {
+ key: cameraKey(target),
+ target,
+ sinceMs: nowMs,
+ untilMs: nowMs + ttlMs,
+ };
+}
+
+function clearProgrammaticMove(ref: React.MutableRefObject) {
+ ref.current = { key: null, target: null, sinceMs: 0, untilMs: 0 };
+}
+
+function clearPairedFlyTo(ref: React.MutableRefObject) {
+ ref.current = { active: false, untilMs: 0, leftEnded: false, rightEnded: false };
+}
+
+function isProgrammaticMove(
+ ref: React.MutableRefObject,
+ camera: MapCameraPosition,
+ nowMs: number,
+): boolean {
+ const state = ref.current;
+ if (state.key == null || nowMs > state.untilMs) return false;
+ if (cameraKey(camera) === state.key) return true;
+ return state.target ? isCloseToTarget(camera, state.target) : false;
+}
+
+function defaultLocations(): CameraLocationInfo[] {
+ return [
+ {
+ name: 'Tokyo',
+ bounds: {
+ southWest: createGeoPoint({ latitude: 35.62, longitude: 139.7, altitude: 0 }),
+ northEast: createGeoPoint({ latitude: 35.74, longitude: 139.84, altitude: 0 }),
+ },
+ center: createGeoPoint({ latitude: 35.6812, longitude: 139.7671, altitude: 0 }),
+ zoom: 12,
+ },
+ {
+ name: 'French Southern and Antarctic Lands',
+ bounds: {
+ southWest: createGeoPoint({ latitude: -49.5, longitude: 50, altitude: 0 }),
+ northEast: createGeoPoint({ latitude: -37.5, longitude: 77, altitude: 0 }),
+ },
+ center: createGeoPoint({ latitude: -43.5, longitude: 63.5, altitude: 0 }),
+ zoom: 4,
+ },
+ {
+ name: 'Finland',
+ bounds: {
+ southWest: createGeoPoint({ latitude: 59.8, longitude: 19.1, altitude: 0 }),
+ northEast: createGeoPoint({ latitude: 70.1, longitude: 31.6, altitude: 0 }),
+ },
+ center: createGeoPoint({ latitude: 64.95, longitude: 25.35, altitude: 0 }),
+ zoom: 5,
+ },
+ {
+ name: 'Iceland',
+ bounds: {
+ southWest: createGeoPoint({ latitude: 63.3, longitude: -24.5, altitude: 0 }),
+ northEast: createGeoPoint({ latitude: 66.6, longitude: -13.5, altitude: 0 }),
+ },
+ center: createGeoPoint({ latitude: 64.95, longitude: -19, altitude: 0 }),
+ zoom: 6,
+ },
+ {
+ name: 'Kiribati',
+ bounds: {
+ southWest: createGeoPoint({ latitude: -11.5, longitude: -174.5, altitude: 0 }),
+ northEast: createGeoPoint({ latitude: 5, longitude: -147, altitude: 0 }),
+ },
+ center: createGeoPoint({ latitude: -3.25, longitude: -160.75, altitude: 0 }),
+ zoom: 4.5,
+ },
+ {
+ name: 'Oahu Island',
+ bounds: {
+ southWest: createGeoPoint({ latitude: 21.25, longitude: -158.3, altitude: 0 }),
+ northEast: createGeoPoint({ latitude: 21.7, longitude: -157.65, altitude: 0 }),
+ },
+ center: createGeoPoint({ latitude: 21.475, longitude: -157.975, altitude: 0 }),
+ zoom: 9.5,
+ },
+ ];
+}
+
+function boundsPolyline(location: CameraLocationInfo, index: number): PolylineState {
+ const sw = location.bounds.southWest;
+ const ne = location.bounds.northEast;
+ return createPolylineState({
+ id: `camera_sync_bounds_${index}`,
+ points: [
+ sw,
+ createGeoPoint({ latitude: sw.latitude, longitude: ne.longitude, altitude: 0 }),
+ ne,
+ createGeoPoint({ latitude: ne.latitude, longitude: sw.longitude, altitude: 0 }),
+ sw,
+ ],
+ strokeColor: '#dc2626',
+ strokeWidth: 3,
+ geodesic: true,
+ });
+}
+
+function referenceRectangles(locations: CameraLocationInfo[]): PolygonState[] {
+ const size = 1;
+ return locations.map((location, index) => {
+ const lat = location.center.latitude;
+ const lng = location.center.longitude;
+ return createPolygonState({
+ id: `camera_sync_reference_${index}`,
+ points: [
+ createGeoPoint({ latitude: lat - size / 2, longitude: lng - size / 2, altitude: 0 }),
+ createGeoPoint({ latitude: lat - size / 2, longitude: lng + size / 2, altitude: 0 }),
+ createGeoPoint({ latitude: lat + size / 2, longitude: lng + size / 2, altitude: 0 }),
+ createGeoPoint({ latitude: lat + size / 2, longitude: lng - size / 2, altitude: 0 }),
+ createGeoPoint({ latitude: lat - size / 2, longitude: lng - size / 2, altitude: 0 }),
+ ],
+ strokeColor: '#2563eb',
+ strokeWidth: 2,
+ fillColor: 'rgba(37, 99, 235, 0.1)',
+ geodesic: false,
+ zIndex: 1,
+ });
+ });
+}
+
+function usePaneState(
+ provider: PaneProvider,
+ mapLibreState: MapLibreViewState,
+ google2DState: GoogleMapViewState,
+ google3DState: GoogleMapViewState,
+): PaneState {
+ if (provider === 'google-maps') {
+ return { provider, mapState: google2DState as MapViewStateInterface> };
+ }
+ if (provider === 'google-maps-3d') {
+ return { provider, mapState: google3DState as MapViewStateInterface> };
+ }
+ return { provider, mapState: mapLibreState as MapViewStateInterface> };
+}
+
+function providerLabel(provider: PaneProvider): string {
+ if (provider === 'google-maps') return 'Google Maps';
+ if (provider === 'google-maps-3d') return 'Google Maps 3D';
+ return 'MapLibre';
+}
+
+function CameraInfoCard({ label, position }: { label: string; position: MapCameraPosition }) {
+ return (
+
+ {label}
+ Lat: {position.position.latitude.toFixed(5)}
+ Lng: {position.position.longitude.toFixed(5)}
+ Zoom: {position.zoom.toFixed(2)}
+ Tilt: {position.tilt.toFixed(1)}deg
+ Bearing: {position.bearing.toFixed(1)}deg
+ Alt: {(position.position.altitude ?? 0).toFixed(0)} m
+
+ );
+}
+
+function CameraSyncMapView({
+ paneState,
+ children,
+ onCameraMove,
+ onCameraMoveEnd,
+}: {
+ paneState: PaneState;
+ children: React.ReactNode;
+ onCameraMove: (camera: MapCameraPosition) => void;
+ onCameraMoveEnd: (camera: MapCameraPosition) => void;
+}) {
+ if (paneState.provider === 'google-maps' || paneState.provider === 'google-maps-3d') {
+ const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '';
+ if (!apiKey || apiKey === 'your_api_key_here') {
+ return (
+
+
Google Maps API Key is Missing
+
Add VITE_GOOGLE_MAPS_API_KEY to examples/basic/.env, or switch this pane to MapLibre.
+
+ );
+ }
+
+ if (paneState.provider === 'google-maps-3d') {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+function CameraSyncMapPane({
+ label,
+ paneState,
+ selectedProvider,
+ onProviderChange,
+ cameraPosition,
+ onCameraMove,
+ onCameraMoveEnd,
+ boundsPolylines,
+ referenceRectangles,
+}: {
+ label: string;
+ paneState: PaneState;
+ selectedProvider: PaneProvider;
+ onProviderChange: (provider: PaneProvider) => void;
+ cameraPosition: MapCameraPosition;
+ onCameraMove: (position: MapCameraPosition) => void;
+ onCameraMoveEnd: (position: MapCameraPosition) => void;
+ boundsPolylines: PolylineState[];
+ referenceRectangles: PolygonState[];
+}) {
+ return (
+
+
+ {boundsPolylines.map(polyline => )}
+ {referenceRectangles.map(polygon => )}
+
+
+
+
+
+
+ );
+}
+
+export function CameraSyncPage() {
+ const locations = useMemo(defaultLocations, []);
+ const boundsPolylines = useMemo(() => locations.map(boundsPolyline), [locations]);
+ const rectangles = useMemo(() => referenceRectangles(locations), [locations]);
+
+ const leftMapLibreState = useMapLibreViewState({
+ id: 'camera-sync-left-maplibre',
+ mapDesignType: MapLibreDesign.OsmBrightJa,
+ cameraPosition: INITIAL_CAMERA,
+ });
+ const rightMapLibreState = useMapLibreViewState({
+ id: 'camera-sync-right-maplibre',
+ mapDesignType: MapLibreDesign.MapTilerBasicJa,
+ cameraPosition: INITIAL_CAMERA,
+ });
+ const leftGoogle2DState = useGoogleMapViewState({
+ id: 'camera-sync-left-google-2d',
+ mapDesignType: GoogleMapDesign.Normal,
+ cameraPosition: INITIAL_CAMERA,
+ });
+ const rightGoogle2DState = useGoogleMapViewState({
+ id: 'camera-sync-right-google-2d',
+ mapDesignType: GoogleMapDesign.Normal,
+ cameraPosition: INITIAL_CAMERA,
+ });
+ const leftGoogle3DState = useGoogleMapViewState({
+ id: 'camera-sync-left-google-3d',
+ mapDesignType: GoogleMapDesign.Normal,
+ cameraPosition: INITIAL_CAMERA,
+ });
+ const rightGoogle3DState = useGoogleMapViewState({
+ id: 'camera-sync-right-google-3d',
+ mapDesignType: GoogleMapDesign.Normal,
+ cameraPosition: INITIAL_CAMERA,
+ });
+
+ const [leftProvider, setLeftProvider] = useState('maplibre');
+ const [rightProvider, setRightProvider] = useState('google-maps-3d');
+ const [leftCameraPosition, setLeftCameraPosition] = useState(INITIAL_CAMERA);
+ const [rightCameraPosition, setRightCameraPosition] = useState(INITIAL_CAMERA);
+
+ const leftPaneState = usePaneState(leftProvider, leftMapLibreState, leftGoogle2DState, leftGoogle3DState);
+ const rightPaneState = usePaneState(rightProvider, rightMapLibreState, rightGoogle2DState, rightGoogle3DState);
+
+ const leftProgrammaticRef = useRef({ key: null, target: null, sinceMs: 0, untilMs: 0 });
+ const rightProgrammaticRef = useRef({ key: null, target: null, sinceMs: 0, untilMs: 0 });
+ const pairedFlyToRef = useRef({
+ active: false,
+ untilMs: 0,
+ leftEnded: false,
+ rightEnded: false,
+ });
+ const lastLeftMoveSyncAtMs = useRef(0);
+ const lastRightMoveSyncAtMs = useRef(0);
+
+ function programmaticRefFor(pane: PaneId) {
+ return pane === 'left' ? leftProgrammaticRef : rightProgrammaticRef;
+ }
+
+ function selectedPaneState(pane: PaneId) {
+ return pane === 'left' ? leftPaneState : rightPaneState;
+ }
+
+ function setCameraForPane(pane: PaneId, camera: MapCameraPosition) {
+ if (pane === 'left') setLeftCameraPosition(camera);
+ else setRightCameraPosition(camera);
+ }
+
+ function syncFromPane(source: PaneId, position: MapCameraPosition, fromMove: boolean) {
+ const now = performance.now();
+
+ if (pairedFlyToRef.current.active) {
+ if (now > pairedFlyToRef.current.untilMs) {
+ clearPairedFlyTo(pairedFlyToRef);
+ clearProgrammaticMove(leftProgrammaticRef);
+ clearProgrammaticMove(rightProgrammaticRef);
+ } else {
+ setCameraForPane(source, position);
+ if (!fromMove) {
+ if (source === 'left') pairedFlyToRef.current.leftEnded = true;
+ else pairedFlyToRef.current.rightEnded = true;
+
+ if (pairedFlyToRef.current.leftEnded && pairedFlyToRef.current.rightEnded) {
+ clearPairedFlyTo(pairedFlyToRef);
+ clearProgrammaticMove(leftProgrammaticRef);
+ clearProgrammaticMove(rightProgrammaticRef);
+ }
+ }
+ return;
+ }
+ }
+
+ const sourceRef = programmaticRefFor(source);
+
+ if (sourceRef.current.key != null) {
+ if (now > sourceRef.current.untilMs) {
+ clearProgrammaticMove(sourceRef);
+ } else {
+ const age = now - sourceRef.current.sinceMs;
+ if (age <= PROGRAMMATIC_GRACE_MS || isProgrammaticMove(sourceRef, position, now)) {
+ setCameraForPane(source, position);
+ return;
+ }
+ clearProgrammaticMove(sourceRef);
+ }
+ }
+
+ const lastSyncRef = source === 'left' ? lastLeftMoveSyncAtMs : lastRightMoveSyncAtMs;
+ if (fromMove && now - lastSyncRef.current < MOVE_SYNC_INTERVAL_MS) return;
+ lastSyncRef.current = now;
+
+ const target: PaneId = source === 'left' ? 'right' : 'left';
+ const targetRef = programmaticRefFor(target);
+ setCameraForPane(source, position);
+ setCameraForPane(target, position);
+ markProgrammaticMove(targetRef, position, now);
+ selectedPaneState(target).mapState.moveCameraTo(position, 0);
+ }
+
+ function flyToLocation(location: CameraLocationInfo) {
+ const now = performance.now();
+ const position = createMapCameraPosition({
+ position: location.center,
+ zoom: location.zoom,
+ bearing: 0,
+ tilt: 0,
+ });
+
+ pairedFlyToRef.current = {
+ active: true,
+ untilMs: now + FLY_TO_DURATION_MS + PROGRAMMATIC_TTL_MS,
+ leftEnded: false,
+ rightEnded: false,
+ };
+
+ leftPaneState.mapState.moveCameraTo(position, FLY_TO_DURATION_MS);
+ rightPaneState.mapState.moveCameraTo(position, FLY_TO_DURATION_MS);
+ setLeftCameraPosition(position);
+ setRightCameraPosition(position);
+ markProgrammaticMove(leftProgrammaticRef, position, now, FLY_TO_DURATION_MS + PROGRAMMATIC_TTL_MS);
+ markProgrammaticMove(rightProgrammaticRef, position, now, FLY_TO_DURATION_MS + PROGRAMMATIC_TTL_MS);
+ }
+
+ return (
+
+
+
Camera Sync
+
+ {locations.map(location => (
+
+ ))}
+
+
+
+
+ syncFromPane('left', position, true)}
+ onCameraMoveEnd={position => syncFromPane('left', position, false)}
+ boundsPolylines={boundsPolylines}
+ referenceRectangles={rectangles}
+ />
+ syncFromPane('right', position, true)}
+ onCameraMoveEnd={position => syncFromPane('right', position, false)}
+ boundsPolylines={boundsPolylines}
+ referenceRectangles={rectangles}
+ />
+
+
+ );
+}
diff --git a/examples/basic/src/pages/map/camerasync/camera-sync-page.css b/examples/basic/src/pages/map/camerasync/camera-sync-page.css
new file mode 100644
index 0000000..57b6b37
--- /dev/null
+++ b/examples/basic/src/pages/map/camerasync/camera-sync-page.css
@@ -0,0 +1,174 @@
+.camera-sync-page {
+ height: 100%;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ background: #f7f8fb;
+}
+
+.camera-sync-toolbar {
+ display: grid;
+ gap: 10px;
+ padding: 10px;
+ border-bottom: 1px solid #d9dde7;
+ background: #ffffff;
+ z-index: 20;
+}
+
+.camera-sync-toolbar h2 {
+ color: #172033;
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.camera-sync-location-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.camera-sync-location-list button {
+ min-height: 32px;
+ max-width: 220px;
+ padding: 0.35rem 0.65rem;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ background: #f8fafc;
+ color: #1f2937;
+ cursor: pointer;
+ font-size: 0.82rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.camera-sync-location-list button:hover {
+ background: #e2e8f0;
+}
+
+.camera-sync-grid {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 1px;
+ background: #94a3b8;
+}
+
+.camera-sync-pane {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ background: #e5e7eb;
+ overflow: hidden;
+}
+
+.camera-sync-pane > .maplibregl-map,
+.camera-sync-pane > div:first-child {
+ width: 100%;
+ height: 100%;
+}
+
+.camera-sync-provider {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ z-index: 1000;
+ width: min(72%, 320px);
+ display: grid;
+ gap: 4px;
+ padding: 8px;
+ border: 1px solid rgba(148, 163, 184, 0.7);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.94);
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);
+ color: #1f2937;
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.camera-sync-provider select {
+ min-width: 0;
+ min-height: 32px;
+ padding: 0.32rem 0.45rem;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ background: #ffffff;
+ color: #1f2937;
+ font-size: 0.86rem;
+}
+
+.camera-sync-info {
+ position: absolute;
+ left: 10px;
+ bottom: 10px;
+ z-index: 1000;
+ min-width: 150px;
+ display: grid;
+ gap: 3px;
+ padding: 9px 10px;
+ border: 1px solid rgba(148, 163, 184, 0.7);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.94);
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);
+ color: #1f2937;
+ font-size: 0.76rem;
+ line-height: 1.25;
+}
+
+.camera-sync-info strong {
+ margin-bottom: 2px;
+ color: #111827;
+ font-size: 0.8rem;
+}
+
+.camera-sync-missing-key {
+ height: 100%;
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ gap: 8px;
+ padding: 24px;
+ text-align: center;
+ background: #f8fafc;
+ color: #1f2937;
+}
+
+.camera-sync-missing-key h2 {
+ font-size: 1rem;
+}
+
+.camera-sync-missing-key p {
+ max-width: 440px;
+ color: #475569;
+ font-size: 0.88rem;
+ line-height: 1.45;
+}
+
+@media (max-width: 860px), (orientation: portrait) {
+ .camera-sync-grid {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ .camera-sync-toolbar {
+ max-height: 155px;
+ overflow-y: auto;
+ }
+}
+
+@media (max-width: 560px) {
+ .camera-sync-provider {
+ width: min(86%, 300px);
+ }
+
+ .camera-sync-info {
+ max-width: calc(100% - 20px);
+ grid-template-columns: repeat(2, minmax(0, auto));
+ column-gap: 10px;
+ }
+
+ .camera-sync-info strong {
+ grid-column: 1 / -1;
+ }
+}
diff --git a/examples/basic/src/pages/map/flyto/FlyToPage.tsx b/examples/basic/src/pages/map/flyto/FlyToPage.tsx
index 5a0ff3f..61732f7 100644
--- a/examples/basic/src/pages/map/flyto/FlyToPage.tsx
+++ b/examples/basic/src/pages/map/flyto/FlyToPage.tsx
@@ -1,13 +1,17 @@
import { useMemo } from 'react';
+import {
+ createMapCameraPosition,
+ type MapDesignTypeInterface,
+ type MapViewStateInterface,
+} from '@mapconductor/js-sdk-core';
import { Markers } from '@mapconductor/js-sdk-react';
import { ControlPanel } from '../../../components/ControlPanel';
-import { cityMarker, useCameraActions } from '../../common/sampleHelpers';
+import { cityMarker } from '../../common/sampleHelpers';
import { MapViewContainer, useSampleMapViewState } from '../../../MapViewContainer';
const INIT_CAMERA = { lat: 35.0, lng: 0.0, zoom: 3 };
-function FlyToContent() {
- const { animateCamera } = useCameraActions();
+function FlyToContent({ mapViewState }: { mapViewState: MapViewStateInterface> }) {
const markers = useMemo(() => [
cityMarker('tokyo', 'Tokyo', 35.6812, 139.7671),
cityMarker('sapporo', 'Sapporo', 43.0642, 141.3469),
@@ -21,7 +25,13 @@ function FlyToContent() {
{markers.map(marker => (
-