diff --git a/.github/workflows/api-surface.yml b/.github/workflows/api-surface.yml new file mode 100644 index 0000000..eec3156 --- /dev/null +++ b/.github/workflows/api-surface.yml @@ -0,0 +1,53 @@ +name: Public API Surface + +# 公開 API サーフェスの凍結ゲート。 +# +# ドライバー層の共通化中、アプリ開発者向け API を変えていないことを機械的に保証する。 +# 意図した変更なら `node scripts/api-surface.mjs dump ` でベースラインを +# 更新してコミットすること。android-sdk の lint.yml の apiCheck ステップ、 +# ios-sdk の api-surface.yml に対応する。 +# +# 読むのは各パッケージの `dist/index.d.ts`。`/** @internal */` が付いた宣言は +# 記録から除かれる(= ドライバー実装点は自由に変えてよい)。 + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + branches: + - main + workflow_dispatch: + inputs: + packages: + description: "空白区切りのパッケージ名。空なら全パッケージ。" + required: false + default: "" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + api-surface: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + name: Check public API surface + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install + run: npm ci + + - name: Check + env: + PACKAGES: ${{ github.event.inputs.packages }} + run: node scripts/api-surface.mjs check ${PACKAGES:-} diff --git a/.github/workflows/deploy-basic-example.yml b/.github/workflows/deploy-basic-example.yml index a956fc3..933f5f2 100644 --- a/.github/workflows/deploy-basic-example.yml +++ b/.github/workflows/deploy-basic-example.yml @@ -6,10 +6,21 @@ name: Deploy basic example # FIREBASE_SERVICE_ACCOUNT - JSON key of a service account for the # `mapconductor` Firebase project with the # "Firebase Hosting Admin" role -# VITE_GOOGLE_MAPS_API_KEY - Google Maps JavaScript API key -# VITE_ARCGIS_API_KEY - ArcGIS Location Platform API key -# VITE_MAPBOX_ACCESS_TOKEN - Mapbox access token -# VITE_HERE_API_KEY - HERE platform API key +# VITE_GOOGLE_MAPS_API_KEY - Google Maps JavaScript API key +# VITE_ARCGIS_API_KEY - ArcGIS Location Platform API key +# VITE_MAPBOX_ACCESS_TOKEN - Mapbox access token +# VITE_HERE_API_KEY - HERE platform API key +# VITE_MAPKIT_TOKEN - Apple MapKit JS JWT +# VITE_AZURE_MAPS_SUBSCRIOTION_KEY - Azure Maps subscription key (typo is the +# committed spelling; examples/basic reads +# this exact name) +# VITE_TOMTOM_API_KEY - TomTom Maps SDK key +# VITE_MAPTILER - MapTiler Cloud key +# VITE_LONGDO - Longdo Map key +# VITE_MAPPLS_API_KEY - Mappls (MapmyIndia) REST/Map SDK key +# +# The list must stay in sync with examples/basic/.env; a missing entry does not +# fail the build, it just ships a bundle where that one provider is blank. # The VITE_* values are embedded into the static bundle at build time. on: @@ -69,6 +80,7 @@ jobs: VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }} VITE_MAPTILER: ${{ secrets.VITE_MAPTILER }} VITE_LONGDO: ${{ secrets.VITE_LONGDO }} + VITE_MAPPLS_API_KEY: ${{ secrets.VITE_MAPPLS_API_KEY }} - name: Deploy to Firebase Hosting uses: FirebaseExtended/action-hosting-deploy@v0 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 1a575b0..bd27ee5 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -43,10 +43,37 @@ jobs: run: npm run build:packages # link-local-workspaces.mjs changes internal dependency specs to "*". - # Restore the committed manifests after the build so those temporary - # specs are never included in a published package. + # Restore them before publishing so no package ships a "*" dependency on + # another @mapconductor package. + # + # This used to be `git submodule foreach 'git restore -- package.json'`, + # which only covered submodules. react-for-mappls, react-kml and every + # reactnative-for-* package live directly in this repo, so their "*" + # specs survived and would have been published verbatim. None of them had + # been published before, so it never shipped - but react-for-mappls and + # react-kml publish for the first time in 0.2.0. - name: Restore package manifests for publishing - run: git submodule foreach --recursive 'git restore --source=HEAD -- package.json' + run: node scripts/unlink-local-workspaces.mjs + + # Nothing may reach the registry depending on "*". Fail loudly rather + # than publish an unpinned graph. + - name: Assert no wildcard internal dependencies + shell: bash + run: | + set -euo pipefail + if grep -rn '"@mapconductor/[a-z0-9-]*": "\*"' --include=package.json . \ + | grep -v node_modules | grep -vE '(^|\./)examples/'; then + echo "::error::internal dependency left as \"*\" outside examples/" + exit 1 + fi + echo "no wildcard internal dependencies outside examples/" + + # Inspect the actual tarballs before anything is uploaded: version, + # license, internal dep pins, and that each one really contains dist/. + # react-for-mapkit@0.1.3 shipped with no code because it was missing from + # build:packages, and npm published it without complaint. + - name: Preflight publishable packages + run: node scripts/preflight-publish.mjs - name: Preview npm packages shell: bash diff --git a/.gitignore b/.gitignore index ee4ad45..16e65e4 100644 --- a/.gitignore +++ b/.gitignore @@ -50,9 +50,14 @@ dist/* *.mts *.map *.mjs +# 雛形の適合テストはバージョン管理する(外部の作者がコピーする出発点なので)。 +!react-for-template/test/*.mjs !examples/basic/server.mjs !examples/basic/scripts/generate-static.mjs -!scripts/link-local-workspaces.mjs +# scripts/ はソースしか置かないので丸ごと追跡する。1 本ずつ negation を足す運用だと +# 足し忘れたスクリプトが `git add -A` で無言のまま無視され、CI のチェックアウトにだけ +# 存在しない状態になる(unlink-local-workspaces.mjs で実際に踏んだ)。 +!scripts/*.mjs *.cjs !eslint.config.cjs dist/*.d.ts @@ -68,3 +73,8 @@ qiita.md # Firebase CLI cache .firebase/ firebase-debug.log +smoke-shots/ + +# RN example build output (xcodebuild -derivedDataPath / Kotlin daemon state) +examples/reactnative-basic/ios/build_*/ +examples/reactnative-basic/android/.kotlin/ diff --git a/.gitmodules b/.gitmodules index 285c44c..32ec99d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,9 +16,9 @@ [submodule "reactnative-for-maplibre"] path = reactnative-for-maplibre url = git@github.com:mapconductor/reactnative-for-maplibre.git -[submodule "react-geojson-layer"] - path = react-geojson-layer - url = git@github.com:mapconductor/react-geojson-layer.git +[submodule "react-geojson"] + path = react-geojson + url = git@github.com:mapconductor/react-geojson.git [submodule "react-heatmap"] path = react-heatmap url = git@github.com:mapconductor/react-heatmap.git @@ -67,3 +67,15 @@ [submodule "react-for-maptiler"] path = react-for-maptiler url = git@github.com:mapconductor/react-for-maptiler +[submodule "react-for-mappls"] + path = react-for-mappls + url = git@github.com:MapConductor/react-for-mappls.git +[submodule "react-icons-jp"] + path = react-icons-jp + url = git@github.com:MapConductor/react-icons-jp.git +[submodule "react-icons-us"] + path = react-icons-us + url = git@github.com:MapConductor/react-icons-us.git +[submodule "react-icons-weather"] + path = react-icons-weather + url = git@github.com:MapConductor/react-icons-weather.git diff --git a/docs/package.json b/docs/package.json index 9e3c186..a8c276b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "@mapconductor/react-sdk-docs", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/docs/src/content/docs/guides/extensions.mdx b/docs/src/content/docs/guides/extensions.mdx index e44f96c..9d5af72 100644 --- a/docs/src/content/docs/guides/extensions.mdx +++ b/docs/src/content/docs/guides/extensions.mdx @@ -9,7 +9,7 @@ Extension packages build on the same map scope and state model as the core overl | Package | Purpose | | --- | --- | -| `@mapconductor/react-geojson-layer` | Parse GeoJSON/GeoJSON Sequence data and render feature layers | +| `@mapconductor/react-geojson` | Parse GeoJSON/GeoJSON Sequence data and render feature layers | | `@mapconductor/react-heatmap` | Density visualization from weighted geographic points | | `@mapconductor/react-marker-clustering` | Group nearby markers and render cluster icons | | `@mapconductor/react-icons` | Ready-made `MarkerIcon` shapes (circle, flag, info-bubble tails) | @@ -19,8 +19,8 @@ Extension packages build on the same map scope and state model as the core overl `` rasterizes features into tiles and renders them as a `RasterLayer` under the hood, so large feature sets stay off the main render path. Load static GeoJSON with `GeoJSONParser` and pass the parsed features directly: ```tsx -import { GeoJSONLayer, GeoJSONLayerState, GeoJSONParser, colorArgb } from '@mapconductor/react-geojson-layer'; -import type { GeoJSONFeatureData } from '@mapconductor/react-geojson-layer'; +import { GeoJSONLayer, GeoJSONLayerState, GeoJSONParser, colorArgb } from '@mapconductor/react-geojson'; +import type { GeoJSONFeatureData } from '@mapconductor/react-geojson'; const layerState = useMemo(() => new GeoJSONLayerState({ fillColor: colorArgb(127, 0x3b, 0xb2, 0xd0), diff --git a/docs/src/content/docs/ja/guides/extensions.mdx b/docs/src/content/docs/ja/guides/extensions.mdx index fa82295..cd18f26 100644 --- a/docs/src/content/docs/ja/guides/extensions.mdx +++ b/docs/src/content/docs/ja/guides/extensions.mdx @@ -7,7 +7,7 @@ description: GeoJSON、heatmap、marker clustering、icon package。 | パッケージ | 用途 | | --- | --- | -| `@mapconductor/react-geojson-layer` | GeoJSON / GeoJSON sequence の解析と layer 描画 | +| `@mapconductor/react-geojson` | GeoJSON / GeoJSON sequence の解析と layer 描画 | | `@mapconductor/react-heatmap` | 重み付き座標の密度可視化 | | `@mapconductor/react-marker-clustering` | 近接 marker の grouping と cluster icon | | `@mapconductor/react-icons` | 共通 icon helper | diff --git a/docs/src/content/docs/ja/reference/packages.mdx b/docs/src/content/docs/ja/reference/packages.mdx index 72037ef..93db8ba 100644 --- a/docs/src/content/docs/ja/reference/packages.mdx +++ b/docs/src/content/docs/ja/reference/packages.mdx @@ -11,7 +11,7 @@ description: MapConductor React SDK のパッケージ構成。 | 共通 | `@mapconductor/js-sdk-react` | overlay、MapView scope、InfoBubble、Web/native entry point | | Web | `@mapconductor/react-for-*` | React DOM の provider view | | Native | `@mapconductor/reactnative-for-*` | React Native view と Android/iOS bridge | -| 拡張 | `react-icons`, `react-geojson-layer`, `react-heatmap`, `react-marker-clustering` | 任意機能 | +| 拡張 | `react-icons`, `react-geojson`, `react-heatmap`, `react-marker-clustering` | 任意機能 | Web provider package に Android/iOS 実装を含めません。native bridge はそれぞれの `reactnative-for-*` package が所有し、可能な範囲で Web sibling の design/state type を再利用します。 diff --git a/docs/src/content/docs/reference/packages.mdx b/docs/src/content/docs/reference/packages.mdx index 5243701..5bc43ec 100644 --- a/docs/src/content/docs/reference/packages.mdx +++ b/docs/src/content/docs/reference/packages.mdx @@ -22,7 +22,7 @@ description: MapConductor React SDK package responsibilities. ## Extension packages -`@mapconductor/react-icons`, `react-geojson-layer`, `react-heatmap`, and `react-marker-clustering` add optional capabilities without changing provider controller interfaces. +`@mapconductor/react-icons`, `react-geojson`, `react-heatmap`, and `react-marker-clustering` add optional capabilities without changing provider controller interfaces. ## Import rule of thumb diff --git a/docs/src/content/docs/states/rasterlayer-state.mdx b/docs/src/content/docs/states/rasterlayer-state.mdx index 58d1825..2888811 100644 --- a/docs/src/content/docs/states/rasterlayer-state.mdx +++ b/docs/src/content/docs/states/rasterlayer-state.mdx @@ -5,7 +5,7 @@ description: Retained, observable state for a tiled raster overlay. # RasterLayerState -`RasterLayerState` holds a raster tile layer's source and styling as mutable, observable properties. It's the state type `` renders, and also the type extension packages like `react-geojson-layer` and `react-heatmap` build on internally — both rasterize their content into tiles and mount a `RasterLayer` underneath. +`RasterLayerState` holds a raster tile layer's source and styling as mutable, observable properties. It's the state type `` renders, and also the type extension packages like `react-geojson` and `react-heatmap` build on internally — both rasterize their content into tiles and mount a `RasterLayer` underneath. ## Creating a RasterLayerState diff --git a/examples/basic/.env.example b/examples/basic/.env.example index cacea7d..ff9f0b7 100644 --- a/examples/basic/.env.example +++ b/examples/basic/.env.example @@ -26,3 +26,6 @@ VITE_TOMTOM_API_KEY=your_api_key_here # MapTiler Cloud API Key # Get your API key from: https://cloud.maptiler.com/ VITE_MAPTILER=your_api_key_here + +# Mappls (MapmyIndia) +VITE_MAPPLS_API_KEY=your_api_key_here diff --git a/examples/basic/package.json b/examples/basic/package.json index 3e6d17a..5bb4955 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -26,7 +26,8 @@ "@mapconductor/react-for-cesium": "*", "@mapconductor/react-for-here": "*", "@mapconductor/react-for-tomtom": "*", - "@mapconductor/react-geojson-layer": "*", + "@mapconductor/react-geojson": "*", + "@mapconductor/react-kml": "*", "@mapconductor/react-heatmap": "*", "@mapconductor/react-icons": "*", "@mapconductor/react-marker-clustering": "*", @@ -36,7 +37,8 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.18.0", - "three": "^0.185.1" + "three": "^0.185.1", + "@mapconductor/react-for-mappls": "*" }, "devDependencies": { "@types/react": "^19.2.17", diff --git a/examples/basic/public/geojson/N02-22_GML.zip b/examples/basic/public/geojson/N02-22_GML.zip index f673fad..baed25a 100644 Binary files a/examples/basic/public/geojson/N02-22_GML.zip and b/examples/basic/public/geojson/N02-22_GML.zip differ diff --git a/examples/basic/public/sample.kml b/examples/basic/public/sample.kml new file mode 100644 index 0000000..164c805 --- /dev/null +++ b/examples/basic/public/sample.kml @@ -0,0 +1,116 @@ + + + + MapConductor KML Sample + + + + + + + + + + normal + #bluePoly + + + highlight + #bluePoly + + + + + Imperial Palace Area + A polygon with a hole near the Imperial Palace. + #bluePolyMap + + + + + 139.744,35.688,0 + 139.762,35.688,0 + 139.762,35.676,0 + 139.744,35.676,0 + 139.744,35.688,0 + + + + + + + 139.750,35.685,0 + 139.756,35.685,0 + 139.756,35.680,0 + 139.750,35.680,0 + 139.750,35.685,0 + + + + + + + + Chuo Line (approx.) + A red poly-line running east to west. + #redLine + + + 139.700,35.700,0 + 139.730,35.695,0 + 139.770,35.690,0 + 139.810,35.700,0 + + + + + + Tokyo Station + A styled point placemark. + #greenPoint + + + station + + + + 139.767,35.681,0 + + + + + Points of Interest + + Ginza + + 139.764,35.671,0 + + + + Akihabara + + 139.774,35.698,0 + + + + + diff --git a/examples/basic/src/ClientMapRoutes.tsx b/examples/basic/src/ClientMapRoutes.tsx index 05b7859..cec0ec4 100644 --- a/examples/basic/src/ClientMapRoutes.tsx +++ b/examples/basic/src/ClientMapRoutes.tsx @@ -30,6 +30,7 @@ const StyledInfoBubblePage = lazy(() => import('./pages/infobubble/StyledInfoBub const MapDesignPage = lazy(() => import('./pages/map/design/MapDesignPage').then(m => ({ default: m.MapDesignPage }))); const FlyToPage = lazy(() => import('./pages/map/flyto/FlyToPage').then(m => ({ default: m.FlyToPage }))); const FitBoundsPage = lazy(() => import('./pages/map/fitbounds/FitBoundsPage').then(m => ({ default: m.FitBoundsPage }))); +const CameraRestrictionPage = lazy(() => import('./pages/map/camerarestriction/CameraRestrictionPage').then(m => ({ default: m.CameraRestrictionPage }))); const TiltPage = lazy(() => import('./pages/map/tilt/TiltPage').then(m => ({ default: m.TiltPage }))); const UISettingsPage = lazy(() => import('./pages/map/uisettings/UISettingsPage').then(m => ({ default: m.UISettingsPage }))); const VisibleRegionPage = lazy(() => import('./pages/map/visibleregion/VisibleRegionPage').then(m => ({ default: m.VisibleRegionPage }))); @@ -45,6 +46,7 @@ const RasterLayerPage = lazy(() => import('./pages/rasterlayer/RasterLayerPage') const HeatmapLayerPage = lazy(() => import('./pages/heatmaplayer/HeatmapLayerPage').then(m => ({ default: m.HeatmapLayerPage }))); const BasicGeoJSONPage = lazy(() => import('./pages/geojson/basic/BasicGeoJSONPage').then(m => ({ default: m.BasicGeoJSONPage }))); const GeoJSONLayerPage = lazy(() => import('./pages/geojson/layer/GeoJSONLayerPage').then(m => ({ default: m.GeoJSONLayerPage }))); +const KMLLayerPage = lazy(() => import('./pages/kml/layer/KMLLayerPage').then(m => ({ default: m.KMLLayerPage }))); const ThreeJsObjectPage = lazy(() => import('./pages/threejs/ThreeJsObjectPage').then(m => ({ default: m.ThreeJsObjectPage }))); const HelloMapTutorialPage = lazy(() => import('./pages/hellomap/HelloMapTutorialPage').then(m => ({ default: m.HelloMapTutorialPage }))); @@ -76,6 +78,7 @@ function pageContent(page: string | undefined) { case 'map-design': return ; case 'fly-to': return ; case 'fit-bounds': return ; + case 'camera-restriction': return ; case 'tilt': return ; case 'ui-settings': return ; case 'visible-region': return ; @@ -100,6 +103,7 @@ function pageContent(page: string | undefined) { case 'heatmap-layer': return ; case 'geojson-basic': return ; case 'geojson-layer': return ; + case 'kml-layer': return ; case 'threejs-object': return ; default: return ; } @@ -115,7 +119,7 @@ function ProviderPageRoute() { if (requestedPage === 'camera-sync' || requestedPage === 'hello-map') { return ; } - if (provider !== 'maplibre' && provider !== 'maplibre-3d' && provider !== 'mapbox' && provider !== 'leaflet' && provider !== 'openlayers' && provider !== 'google-maps' && provider !== 'google-maps-3d' && provider !== 'arcgis' && provider !== 'arcgis-3d' && provider !== 'mapkit' && provider !== 'azuremaps' && provider !== 'cesium' && provider !== 'here' && provider !== 'tomtom' && provider !== 'maptiler' && provider !== 'longdo') { + if (provider !== 'maplibre' && provider !== 'maplibre-3d' && provider !== 'mapbox' && provider !== 'leaflet' && provider !== 'openlayers' && provider !== 'google-maps' && provider !== 'google-maps-3d' && provider !== 'arcgis' && provider !== 'arcgis-3d' && provider !== 'mapkit' && provider !== 'azuremaps' && provider !== 'cesium' && provider !== 'here' && provider !== 'tomtom' && provider !== 'maptiler' && provider !== 'longdo' && provider !== 'mappls') { return ; } diff --git a/examples/basic/src/MapViewContainer.tsx b/examples/basic/src/MapViewContainer.tsx index a0789a1..8ca1d9c 100644 --- a/examples/basic/src/MapViewContainer.tsx +++ b/examples/basic/src/MapViewContainer.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, type ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import type { + CameraRestriction, GeoPoint, GeoRectBounds, MapCameraPosition, @@ -44,6 +45,11 @@ interface MapViewContainerProps { * page unmounts, so it never leaks into other pages for that provider. */ restrictBounds?: GeoRectBounds; + /** + * 矩形だけでなく minZoom / maxZoom も指定したいページ向け(camera-restriction)。 + * 指定された場合は restrictBounds より優先される。 + */ + cameraRestriction?: CameraRestriction | null; } function SingletonProviderView({ @@ -56,14 +62,15 @@ function SingletonProviderView({ onCameraMoveEnd, onStateReady, restrictBounds, + cameraRestriction: cameraRestrictionProp, }: ProviderViewProps & { id: SingletonMapId }) { const cameraPosition = useInitialCameraPosition(initialCamera); const state = useSingletonMapState(id, cameraPosition); // The SDK normalizes `restrictBounds` / `minZoom` / `maxZoom` into a single // CameraRestriction, so a page that only needs a rectangle can pass just that. const cameraRestriction = useMemo( - () => (restrictBounds ? { bounds: restrictBounds } : null), - [restrictBounds], + () => cameraRestrictionProp ?? (restrictBounds ? { bounds: restrictBounds } : null), + [cameraRestrictionProp, restrictBounds], ); useEffect(() => { @@ -87,6 +94,7 @@ function SingletonProviderView({ export function MapViewContainer({ children, initialCamera = DEFAULT_CAMERA, + cameraRestriction, onMapClick, onCameraMoveStart, onCameraMove, @@ -112,6 +120,7 @@ export function MapViewContainer({ const isTomTom = location.pathname.startsWith('/tomtom'); const isMapTiler = location.pathname.startsWith('/maptiler'); const isLongdo = location.pathname.startsWith('/longdo'); + const isMappls = location.pathname.startsWith('/mappls'); const commonProps: ProviderViewProps = { children, @@ -123,6 +132,7 @@ export function MapViewContainer({ markerTilingOptions, onStateReady, restrictBounds, + cameraRestriction, }; switch (true) { @@ -178,6 +188,10 @@ export function MapViewContainer({ return ; } + case isMappls: { + return ; + } + default: { return (
No provider can be detected
diff --git a/examples/basic/src/SingletonMaps.tsx b/examples/basic/src/SingletonMaps.tsx index 8b81f3c..15690da 100644 --- a/examples/basic/src/SingletonMaps.tsx +++ b/examples/basic/src/SingletonMaps.tsx @@ -38,6 +38,7 @@ import { HereMapDesign, useHereViewState } from '@mapconductor/react-for-here'; import { TomTomDesign, useTomTomViewState } from '@mapconductor/react-for-tomtom'; import { MapTilerDesign, useMapTilerViewState } from '@mapconductor/react-for-maptiler'; import { LongdoDesign, useLongdoViewState } from '@mapconductor/react-for-longdo'; +import { MapplsDesign, useMapplsViewState } from '@mapconductor/react-for-mappls'; import type { SingletonMapContent } from './providers/singleton/types'; export type { SingletonMapContent }; @@ -58,7 +59,8 @@ export type SingletonMapId = | 'here' | 'tomtom' | 'maptiler' - | 'longdo'; + | 'longdo' + | 'mappls'; type AnyMapViewState = MapViewStateInterface>; type AnyMapDesignType = MapDesignTypeInterface; @@ -124,6 +126,7 @@ const LazyCesiumSingletonView = lazy(() => import('./providers/singleton/CesiumS const LazyHereSingletonView = lazy(() => import('./providers/singleton/HereSingletonView')); const LazyTomTomSingletonView = lazy(() => import('./providers/singleton/TomTomSingletonView')); const LazyMapTilerSingletonView = lazy(() => import('./providers/singleton/MapTilerSingletonView')); +const LazyMapplsSingletonView = lazy(() => import('./providers/singleton/MapplsSingletonView')); const LazyLongdoSingletonView = lazy(() => import('./providers/singleton/LongdoSingletonView')); export function SingletonMapsProvider({ children }: { children: ReactNode }) { @@ -135,6 +138,7 @@ export function SingletonMapsProvider({ children }: { children: ReactNode }) { const tomtomApiKey = import.meta.env.VITE_TOMTOM_API_KEY || ''; const mapTilerApiKey = import.meta.env.VITE_MAPTILER || ''; const longdoApiKey = import.meta.env.VITE_LONGDO || ''; + const mapplsApiKey = import.meta.env.VITE_MAPPLS_API_KEY || ''; // Each useViewState() hook only creates lightweight camera/config // state (no heavy SDK import), so calling all of them eagerly here is @@ -157,6 +161,7 @@ export function SingletonMapsProvider({ children }: { children: ReactNode }) { const tomtomState = useTomTomViewState({ apiKey: tomtomApiKey, mapDesignType: TomTomDesign.Standard, cameraPosition: DEFAULT_CAMERA }); const mapTilerState = useMapTilerViewState({ apiKey: mapTilerApiKey, mapDesignType: MapTilerDesign.Streets, cameraPosition: DEFAULT_CAMERA }); const longdoState = useLongdoViewState({ apiKey: longdoApiKey, mapDesignType: LongdoDesign.Normal, cameraPosition: DEFAULT_CAMERA }); + const mapplsState = useMapplsViewState({ apiKey: mapplsApiKey, mapDesignType: MapplsDesign.Default, cameraPosition: DEFAULT_CAMERA }); const statesById = useMemo>(() => ({ 'google-2d': google2DState, @@ -175,9 +180,10 @@ export function SingletonMapsProvider({ children }: { children: ReactNode }) { tomtom: tomtomState, maptiler: mapTilerState, longdo: longdoState, + mappls: mapplsState, }), [ google2DState, google3DState, maplibre2DState, maplibre3DState, mapboxState, - leafletState, openLayersState, arcgis2DState, arcgis3DState, mapkitState, azuremapsState, cesiumState, hereState, tomtomState, mapTilerState, longdoState, + leafletState, openLayersState, arcgis2DState, arcgis3DState, mapkitState, azuremapsState, cesiumState, hereState, tomtomState, mapTilerState, longdoState, mapplsState, ]); // Capture each provider's default map design once, up front, before any page @@ -341,6 +347,10 @@ export function SingletonMapsProvider({ children }: { children: ReactNode }) { id: 'longdo', node: , }, + { + id: 'mappls', + node: , + }, ]; return ( diff --git a/examples/basic/src/app/AppHeader.tsx b/examples/basic/src/app/AppHeader.tsx index bffa916..abddf8d 100644 --- a/examples/basic/src/app/AppHeader.tsx +++ b/examples/basic/src/app/AppHeader.tsx @@ -17,6 +17,7 @@ const PROVIDERS: Array<{ value: MapProvider; label: string }> = [ { value: 'tomtom', label: 'TomTom' }, { value: 'maptiler', label: 'MapTiler' }, { value: 'longdo', label: 'Longdo' }, + { value: 'mappls', label: 'Mappls' }, { value: 'google', label: 'Google Maps' }, { value: 'google-3d', label: 'Google Maps 3D' }, ]; diff --git a/examples/basic/src/app/appRouting.tsx b/examples/basic/src/app/appRouting.tsx index 511dc22..fd99b29 100644 --- a/examples/basic/src/app/appRouting.tsx +++ b/examples/basic/src/app/appRouting.tsx @@ -3,13 +3,13 @@ import { SamplePageLayout } from '../components/SamplePageLayout'; import { getLanguageFromPath } from '../samples/i18n'; import { DEFAULT_SAMPLE_PAGE, isKnownSamplePage, isSupportedLanguage, resolveProviderForPage, type SupportedLanguage } from '../samples/sampleRegistry'; -export type MapProvider = 'maplibre' | 'mapbox' | 'leaflet' | 'openlayers' | 'google' | 'google-3d' | 'arcgis' | 'arcgis-3d' | 'mapkit' | 'azuremaps' | 'cesium' | 'here' | 'tomtom' | 'maptiler' | 'longdo'; +export type MapProvider = 'maplibre' | 'mapbox' | 'leaflet' | 'openlayers' | 'google' | 'google-3d' | 'arcgis' | 'arcgis-3d' | 'mapkit' | 'azuremaps' | 'cesium' | 'here' | 'tomtom' | 'maptiler' | 'longdo' | 'mappls'; const providerByPath = new Map([ ['maplibre', 'maplibre'], ['maplibre-3d', 'maplibre'], ['mapbox', 'mapbox'], ['leaflet', 'leaflet'], ['openlayers', 'openlayers'], ['google-maps', 'google'], ['google', 'google'], ['google-maps-3d', 'google-3d'], ['google-3d', 'google-3d'], - ['arcgis', 'arcgis'], ['arcgis-3d', 'arcgis-3d'], ['mapkit', 'mapkit'], ['azuremaps', 'azuremaps'], ['cesium', 'cesium'], ['here', 'here'], ['tomtom', 'tomtom'], ['maptiler', 'maptiler'], ['longdo', 'longdo'], + ['arcgis', 'arcgis'], ['arcgis-3d', 'arcgis-3d'], ['mapkit', 'mapkit'], ['azuremaps', 'azuremaps'], ['cesium', 'cesium'], ['here', 'here'], ['tomtom', 'tomtom'], ['maptiler', 'maptiler'], ['longdo', 'longdo'], ['mappls', 'mappls'], ]); export function parseSamplePath(pathname: string) { @@ -28,7 +28,7 @@ export function providerPath(provider: MapProvider, page: string): string { maplibre: resolveProviderForPage('maplibre', page), mapbox: 'mapbox', leaflet: 'leaflet', openlayers: 'openlayers', google: 'google-maps', 'google-3d': 'google-maps-3d', - arcgis: 'arcgis', 'arcgis-3d': 'arcgis-3d', mapkit: 'mapkit', azuremaps: 'azuremaps', cesium: 'cesium', here: 'here', tomtom: 'tomtom', maptiler: 'maptiler', longdo: 'longdo', + arcgis: 'arcgis', 'arcgis-3d': 'arcgis-3d', mapkit: 'mapkit', azuremaps: 'azuremaps', cesium: 'cesium', here: 'here', tomtom: 'tomtom', maptiler: 'maptiler', longdo: 'longdo', mappls: 'mappls', }; return paths[provider]; } diff --git a/examples/basic/src/index.css b/examples/basic/src/index.css index 2c78db3..c309657 100644 --- a/examples/basic/src/index.css +++ b/examples/basic/src/index.css @@ -322,43 +322,36 @@ body, 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); +.swatch-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; } -.right-tail-info-bubble::before, -.right-tail-info-bubble::after { - content: ''; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 0; - height: 0; +.swatch-row-label { + flex: 0 0 64px; + font-size: 12px; + color: #374151; } -.right-tail-info-bubble::before { - left: -10px; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: 10px solid #000000; +.swatch-row-colors { + display: flex; + gap: 6px; } -.right-tail-info-bubble::after { - left: -7px; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-right: 8px solid #ffffff; +.color-swatch { + width: 20px; + height: 20px; + padding: 0; + border: 1px solid rgba(17, 24, 39, 0.25); + border-radius: 50%; + cursor: pointer; +} + +.color-swatch.selected { + outline: 2px solid #2563eb; + outline-offset: 1px; } .rich-bubble { @@ -810,6 +803,17 @@ body, text-align: center; } +.tilt-camera-diagram { + display: block; +} + +/* スマホ縦画面ではカメラ図を出さない(パネルが地図を覆いすぎるため) */ +@media (max-width: 760px) and (orientation: portrait) { + .tilt-camera-diagram { + display: none; + } +} + @media (max-width: 760px) { .header { min-height: 58px; diff --git a/examples/basic/src/pages/geojson/basic/BasicGeoJSONPage.tsx b/examples/basic/src/pages/geojson/basic/BasicGeoJSONPage.tsx index 205177a..57b0f24 100644 --- a/examples/basic/src/pages/geojson/basic/BasicGeoJSONPage.tsx +++ b/examples/basic/src/pages/geojson/basic/BasicGeoJSONPage.tsx @@ -4,8 +4,8 @@ import { GeoJSONLayerState, GeoJSONParser, colorArgb, -} from '@mapconductor/react-geojson-layer'; -import type { GeoJSONFeatureData } from '@mapconductor/react-geojson-layer'; +} from '@mapconductor/react-geojson'; +import type { GeoJSONFeatureData } from '@mapconductor/react-geojson'; import { ControlPanel } from '../../../components/ControlPanel'; import { MapViewContainer } from '../../../MapViewContainer'; import { useSampleI18n } from '../../../samples/i18n'; diff --git a/examples/basic/src/pages/geojson/layer/GeoJSONLayerPage.tsx b/examples/basic/src/pages/geojson/layer/GeoJSONLayerPage.tsx index 60b8464..645d771 100644 --- a/examples/basic/src/pages/geojson/layer/GeoJSONLayerPage.tsx +++ b/examples/basic/src/pages/geojson/layer/GeoJSONLayerPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { createGeoPoint, type GeoPoint, type MapDesignTypeInterface, type MapViewStateInterface } from '@mapconductor/js-sdk-core'; import { InfoBubble } from '@mapconductor/js-sdk-react'; -import { GeoJSONLayer, GeoJSONLayerState, colorArgb, type GeoJSONFeatureData } from '@mapconductor/react-geojson-layer'; +import { GeoJSONLayer, GeoJSONLayerState, colorArgb, type GeoJSONFeatureData } from '@mapconductor/react-geojson'; import { ControlPanel } from '../../../components/ControlPanel'; import { MapViewContainer } from '../../../MapViewContainer'; import { useSampleI18n } from '../../../samples/i18n'; diff --git a/examples/basic/src/pages/geojson/layer/PropertyTable.tsx b/examples/basic/src/pages/geojson/layer/PropertyTable.tsx index 2f86994..e2796bd 100644 --- a/examples/basic/src/pages/geojson/layer/PropertyTable.tsx +++ b/examples/basic/src/pages/geojson/layer/PropertyTable.tsx @@ -1,17 +1,53 @@ -const labels: Record = { - N02_001: '鉄道区分', N02_002: '事業者種別', N02_003: '路線名', N02_004: '運営会社', +import { useSampleI18n } from '../../../samples/i18n'; + +/** + * 国土数値情報の鉄道データ(N02)の属性名。 + * + * 生の `N02_001` のままだと何の値か分からないので、吹き出しでは名前に置き換える。 + * android / ios と**同じ文言**にしてある(3 プラットフォームを並べて見比べるサンプルなので、 + * ここが違うと同じ地物を選んでいるのか判断できない)。 + * + * ここに無いキーは生のキー名をそのまま出す。データ側に属性が増えても表から消えないように。 + */ +const labels: Record = { + N02_001: { ja: '鉄道区分', en: 'Railway category' }, + N02_002: { ja: '事業者区分', en: 'Business category' }, + N02_003: { ja: '路線名', en: 'Railway name' }, + N02_004: { ja: '運営会社', en: 'Railway company' }, }; + +/** + * 値の英語表記が入っている属性の接尾辞。 + * + * geojson 側が `N02_003`(路線名)に対して `N02_003_en` を持っている。アプリに + * 対訳表を置くと 4 プラットフォーム分そろえる羽目になるので、データに持たせてある。 + */ +const ENGLISH_SUFFIX = '_en'; + const cellStyle: React.CSSProperties = { border: '1px solid #bbb', padding: '4px 8px', color: '#222' }; +/** + * 表示言語が日本語なら日本語、それ以外は英語で出す。 + * 英語のときは値も `N02_003_en` の側へ差し替え、`_en` の行そのものは出さない + * (同じ項目が 2 行に増えてしまうため)。 + */ export function PropertyTable({ properties }: { properties: Record }) { - const entries = Object.entries(properties); - if (entries.length === 0) return

プロパティなし

; + const { language } = useSampleI18n(); + const ja = language === 'ja'; + const entries = Object.entries(properties).filter(([key]) => !key.endsWith(ENGLISH_SUFFIX)); + if (entries.length === 0) return

{ja ? 'プロパティなし' : 'No properties'}

; return ( - - {entries.map(([key, value]) => - - )} + + + + {entries.map(([key, value]) => { + const shown = ja ? value : properties[key + ENGLISH_SUFFIX] ?? value; + return + + + ; + })}
プロパティ
{labels[key] ?? key}{value == null ? '' : String(value)}
{ja ? 'プロパティ' : 'Property'}{ja ? '値' : 'Value'}
{(ja ? labels[key]?.ja : labels[key]?.en) ?? key}{shown == null ? '' : String(shown)}
); } diff --git a/examples/basic/src/pages/geojson/layer/railroadGeoJSON.ts b/examples/basic/src/pages/geojson/layer/railroadGeoJSON.ts index 0acc928..5e81f2f 100644 --- a/examples/basic/src/pages/geojson/layer/railroadGeoJSON.ts +++ b/examples/basic/src/pages/geojson/layer/railroadGeoJSON.ts @@ -1,5 +1,5 @@ import { ZipReaderStream } from '@zip.js/zip.js'; -import { colorArgb, GeoJSONParser, type GeoJSONFeatureData } from '@mapconductor/react-geojson-layer'; +import { colorArgb, GeoJSONParser, type GeoJSONFeatureData } from '@mapconductor/react-geojson'; interface RailroadStyleEntry { company: { name: string; lines: Array<{ name: string; color: string }> }; diff --git a/examples/basic/src/pages/heatmaplayer/HeatmapLayerPage.tsx b/examples/basic/src/pages/heatmaplayer/HeatmapLayerPage.tsx index 059a272..46dbfc4 100644 --- a/examples/basic/src/pages/heatmaplayer/HeatmapLayerPage.tsx +++ b/examples/basic/src/pages/heatmaplayer/HeatmapLayerPage.tsx @@ -8,6 +8,7 @@ import { OpenLayersDesign, OpenLayersMapView, useOpenLayersMapViewState, type Op import { ArcGISDesign, ArcGISMapView2D, useArcGISViewState, type ArcGISViewState } from '@mapconductor/react-for-arcgis'; import { TomTomDesign, TomTomMapView2D, useTomTomViewState, type TomTomViewState } from '@mapconductor/react-for-tomtom'; import { MapTilerDesign, MapTilerMapView2D, useMapTilerViewState, type MapTilerViewState } from '@mapconductor/react-for-maptiler'; +import { MapplsDesign, MapplsMapView2D, useMapplsViewState, type MapplsViewState } from '@mapconductor/react-for-mappls'; import { HeatmapOverlay, HeatmapPoints, HeatmapPointState } from '@mapconductor/react-heatmap'; import { ControlPanel } from '../../components/ControlPanel'; import { SingletonMapSlot, useSingletonMapState } from '../../SingletonMaps'; @@ -161,6 +162,19 @@ function TomTomHeatmapLayerPage() { ); } +function MapplsHeatmapLayerPage() { + const mapViewState = useMapplsViewState({ + apiKey: import.meta.env.VITE_MAPPLS_API_KEY ?? '', + mapDesignType: MapplsDesign.Default, + cameraPosition: INIT_CAMERA_POSITION, + }); + return ( + {children}} + /> + ); +} + function MapTilerHeatmapLayerPage() { const mapViewState = useMapTilerViewState({ apiKey: import.meta.env.VITE_MAPTILER ?? '', @@ -179,6 +193,7 @@ export function HeatmapLayerPage() { if (location.pathname.startsWith('/google-maps')) return ; if (location.pathname.startsWith('/maptiler')) return ; if (location.pathname.startsWith('/tomtom')) return ; + if (location.pathname.startsWith('/mappls')) return ; if (location.pathname.startsWith('/mapbox')) return ; if (location.pathname.startsWith('/leaflet')) return ; if (location.pathname.startsWith('/openlayers')) return ; diff --git a/examples/basic/src/pages/infobubble/StyledInfoBubblePage.tsx b/examples/basic/src/pages/infobubble/StyledInfoBubblePage.tsx index 97048ef..43b58dd 100644 --- a/examples/basic/src/pages/infobubble/StyledInfoBubblePage.tsx +++ b/examples/basic/src/pages/infobubble/StyledInfoBubblePage.tsx @@ -1,49 +1,118 @@ -import { useMemo, useState } from 'react'; -import { ColorDefaultIcon, createGeoPoint, createMarkerState } from '@mapconductor/js-sdk-core'; -import { InfoBubbleCustom, Markers } from '@mapconductor/js-sdk-react'; +import { useEffect, useMemo, useState } from 'react'; +import { DefaultMarkerIcon, createGeoPoint, createMarkerState } from '@mapconductor/js-sdk-core'; +import { InfoBubble, Marker } from '@mapconductor/js-sdk-react'; +import { ControlPanel, SliderControl } from '../../components/ControlPanel'; import { MapViewContainer } from '../../MapViewContainer'; +import { useSampleI18n } from '../../samples/i18n'; -const INIT_CAMERA = { lat: 37.7849, lng: -122.4094, zoom: 15 }; +// android の StyledInfoBubblePage.kt / ios の StyledInfoBubblePage.swift と同一仕様: +// マーカー 1 個と常時表示の InfoBubble を置き、パネルの 8 色スウォッチ 4 行 +// (バブル塗り / バブル枠線 / 文字 / マーカー)と 2 本のスライダー +// (枠線幅・マーカースケール 0.5〜2.0、0.25 刻み)でスタイルを組み替える。 +const INIT_CAMERA = { lat: 35.6812, lng: 139.7671, zoom: 14 }; +const POSITION = createGeoPoint({ latitude: 35.6812, longitude: 139.7671 }); + +// 4 行で共有する 8 色。白と黒を含めておくと塗り=白 / 文字=黒の既定も同じ列で選べる。 +const PALETTE = [ + '#ffffff', '#111827', '#ef4444', '#f97316', + '#eab308', '#22c55e', '#3b82f6', '#a855f7', +] as const; + +function SwatchRow({ + label, + selected, + onSelect, +}: { + label: string; + selected: string; + onSelect: (color: string) => void; +}) { + return ( +
+ {label} +
+ {PALETTE.map(color => ( +
+
+ ); +} export function StyledInfoBubblePage() { - const [selectedMarkerId, setSelectedMarkerId] = useState('marker1'); - const markers = useMemo(() => { - const marker1 = createMarkerState({ - id: 'marker1', - position: createGeoPoint({ latitude: 37.7749, longitude: -122.4194 }), - icon: new ColorDefaultIcon({ fillColor: '#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({ fillColor: '#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); + const { t } = useSampleI18n(); + const [fillColor, setFillColor] = useState('#ffffff'); + const [strokeColor, setStrokeColor] = useState('#111827'); + const [fontColor, setFontColor] = useState('#111827'); + const [markerColor, setMarkerColor] = useState('#ef4444'); + const [strokeWidth, setStrokeWidth] = useState(2.0); + const [markerScale, setMarkerScale] = useState(1.0); + + const marker = useMemo( + () => createMarkerState({ + id: 'styled-bubble-marker', + position: POSITION, + icon: new DefaultMarkerIcon({ fillColor: '#ef4444' }), + }), + [], + ); + + useEffect(() => { + marker.icon = new DefaultMarkerIcon({ fillColor: markerColor, scale: markerScale }); + }, [marker, markerColor, markerScale]); + + // InfoBubble はスタイルとマーカーアイコンを登録時に焼き込むので、 + // 変更のたびに key で再マウントして最新の見た目・配置に揃える。 + const bubbleKey = `${fillColor}|${strokeColor}|${fontColor}|${strokeWidth}|${markerScale}`; return ( - setSelectedMarkerId(null)}> - - {activeMarker && ( - -
- {activeMarker.position.toUrlValue(6)} -
-
- )} + + + +
+ {t('Custom Styled Bubble', 'カスタムスタイル吹き出し')} +
+
+ + + + + + value.toFixed(2)} + onChange={setStrokeWidth} + /> + value.toFixed(2)} + onChange={setMarkerScale} + /> +
); } diff --git a/examples/basic/src/pages/kml/layer/KMLLayerPage.tsx b/examples/basic/src/pages/kml/layer/KMLLayerPage.tsx new file mode 100644 index 0000000..8ef6289 --- /dev/null +++ b/examples/basic/src/pages/kml/layer/KMLLayerPage.tsx @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { createGeoPoint, type GeoPoint, type MapDesignTypeInterface, type MapViewStateInterface } from '@mapconductor/js-sdk-core'; +import { InfoBubble } from '@mapconductor/js-sdk-react'; +import { KMLLayer, KMLLayerState, KMLParser, colorArgb, type KMLFeatureData } from '@mapconductor/react-kml'; +import { ControlPanel } from '../../../components/ControlPanel'; +import { MapViewContainer } from '../../../MapViewContainer'; +import { useSampleI18n } from '../../../samples/i18n'; +import { PropertyTable } from './PropertyTable'; + +const KML_ASSET = 'sample.kml'; + +interface SelectedFeature { position: GeoPoint; properties: Record } + +export function KMLLayerPage() { + const { t } = useSampleI18n(); + const [mapState, setMapState] = useState> | null>(null); + const [features, setFeatures] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); + const layerState = useMemo(() => new KMLLayerState({ + // Fallback style used when a placemark carries no KML + + line + #s + 139.7,35.6,0 139.8,35.7,0 + +`; + const features = kml.KMLParser.parse(text); + const feature = features[0]; + return { + count: features.length, + name: feature.properties.name, + strokeWidth: feature.strokeWidth, + strokeColor: feature.strokeColor, + geometryType: feature.geometry.type, + coordinateCount: feature.geometry.coordinates.length, + firstLongitude: feature.geometry.coordinates[0].longitude, + }; + }); + expect(result.count).toBe(1); + expect(result.name).toBe('line'); + expect(result.strokeWidth).toBe(3); + expect(result.strokeColor).toBe(RED_ARGB); + expect(result.geometryType).toBe('LineString'); + expect(result.coordinateCount).toBe(2); + expect(result.firstLongitude).toBeCloseTo(139.7, 9); + }); + + test('20,000-deep folder nesting does not overflow the stack', async ({ page }) => { + const result = await evalInKml(page, (kml) => { + const depth = 20_000; + const text = '' + + ''.repeat(depth) + + '139.7,35.6' + + ''.repeat(depth) + + ''; + const features = kml.KMLParser.parse(text); + return { count: features.length, geometryType: features[0]?.geometry.type }; + }); + expect(result.count).toBe(1); + expect(result.geometryType).toBe('Point'); + }); + + test('20,000-deep nested MultiGeometry does not overflow the stack', async ({ page }) => { + const result = await evalInKml(page, (kml) => { + const depth = 20_000; + const text = '' + + ''.repeat(depth) + + '139.7,35.6' + + ''.repeat(depth) + + ''; + const features = kml.KMLParser.parse(text); + let geometry = features[0].geometry; + let unwrapped = 0; + let singleChildEverywhere = true; + while (geometry.type === 'GeometryCollection') { + if (geometry.geometries.length !== 1) singleChildEverywhere = false; + geometry = geometry.geometries[0]; + unwrapped++; + } + return { count: features.length, unwrapped, singleChildEverywhere, leafType: geometry.type }; + }); + expect(result.count).toBe(1); + expect(result.unwrapped).toBe(20_000); + expect(result.singleChildEverywhere).toBe(true); + expect(result.leafType).toBe('Point'); + }); + + test('MultiGeometry keeps sibling leaves and nesting', async ({ page }) => { + const result = await evalInKml(page, (kml) => { + const text = ` + 1,2 + + 1,2 3,4 + + + 0,0 1,0 1,1 0,0 + +`; + const collection = kml.KMLParser.parse(text)[0].geometry; + return { + type: collection.type, + childCount: collection.geometries.length, + first: collection.geometries[0].type, + nestedType: collection.geometries[1].type, + nestedChild: collection.geometries[1].geometries[0].type, + third: collection.geometries[2].type, + }; + }); + expect(result.type).toBe('GeometryCollection'); + expect(result.childCount).toBe(3); + expect(result.first).toBe('Point'); + expect(result.nestedType).toBe('GeometryCollection'); + expect(result.nestedChild).toBe('LineString'); + expect(result.third).toBe('Polygon'); + }); + + test('collects NetworkLinks including the legacy Url tag', async ({ page }) => { + const result = await evalInKml(page, async (kml) => { + const text = ` + https://example.com/a.kml + + 0hidden.kml + legacy.kml + + 1,2 +`; + const document = await kml.KMLParser.parseDocument(text); + return { + featureCount: document.features.length, + linkCount: document.networkLinks.length, + firstHref: document.networkLinks[0].href, + secondVisibility: document.networkLinks[1].visibility, + thirdHref: document.networkLinks[2].href, + }; + }); + expect(result.featureCount).toBe(1); + expect(result.linkCount).toBe(3); + expect(result.firstHref).toBe('https://example.com/a.kml'); + expect(result.secondVisibility).toBe(false); + expect(result.thirdHref).toBe('legacy.kml'); + }); +}); + +// ─── KMZ ───────────────────────────────────────────────────────────────────── + +/** + * テスト内で ZIP をバイト単位で組み立てるヘルパのソース。ブラウザ側の + * evaluate に埋め込んで使う(Node 側では実行しない)。 + */ +const ZIP_HELPERS = ` + const u16 = (n) => [n & 255, (n >> 8) & 255]; + const u32 = (n) => [n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >> 24) & 255]; + const buildZip = (entries) => { + // entries: { name, data: Uint8Array, method: 0 | 8, stored: Uint8Array } + const chunks = []; + const central = []; + const encoder = new TextEncoder(); + let offset = 0; + for (const entry of entries) { + const nameBytes = encoder.encode(entry.name); + const stored = entry.stored; + const local = new Uint8Array([ + 0x50, 0x4b, 0x03, 0x04, + ...u16(20), ...u16(0), ...u16(entry.method), ...u16(0), ...u16(0), + ...u32(0), ...u32(stored.length), ...u32(entry.data.length), + ...u16(nameBytes.length), ...u16(0), + ]); + central.push({ nameBytes, method: entry.method, csize: stored.length, usize: entry.data.length, offset }); + chunks.push(local, nameBytes, stored); + offset += local.length + nameBytes.length + stored.length; + } + let cdSize = 0; + for (const c of central) { + const record = new Uint8Array([ + 0x50, 0x4b, 0x01, 0x02, + ...u16(20), ...u16(20), ...u16(0), ...u16(c.method), ...u16(0), ...u16(0), + ...u32(0), ...u32(c.csize), ...u32(c.usize), + ...u16(c.nameBytes.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0), + ...u32(0), ...u32(c.offset), + ]); + chunks.push(record, c.nameBytes); + cdSize += record.length + c.nameBytes.length; + } + const eocd = new Uint8Array([ + 0x50, 0x4b, 0x05, 0x06, + ...u16(0), ...u16(0), ...u16(central.length), ...u16(central.length), + ...u32(cdSize), ...u32(offset), ...u16(0), + ]); + chunks.push(eocd); + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let position = 0; + for (const c of chunks) { out.set(c, position); position += c.length; } + return out; + }; + const deflateRaw = async (data) => { + const stream = new Blob([data]).stream().pipeThrough(new CompressionStream('deflate-raw')); + return new Uint8Array(await new Response(stream).arrayBuffer()); + }; + const pointKml = (lon, lat) => + '' + + lon + ',' + lat + ''; +`; + +const evalWithZip = (page: Page, body: string): Promise => + page.evaluate(async ({ helpers, source }) => { + const kml = await import('/__react-kml.mjs'); + // eslint-disable-next-line no-new-func + const fn = new Function('kml', `${helpers} return (async () => { ${source} })();`); + return await fn(kml); + }, { helpers: ZIP_HELPERS, source: body }) as Promise; + +test.describe('KMLParser KMZ', () => { + test('parses a KMZ archive using the first .kml entry (stored)', async ({ page }) => { + const result = await evalWithZip<{ count: number; type: string; lon: number }>(page, ` + const encoder = new TextEncoder(); + const icon = new Uint8Array([1, 2, 3]); + const doc = encoder.encode(pointKml(139.7, 35.6)); + const second = encoder.encode(pointKml(0, 0)); + const zip = buildZip([ + { name: 'images/icon.png', data: icon, method: 0, stored: icon }, + { name: '__MACOSX/ignored.kml', data: icon, method: 0, stored: icon }, + { name: 'doc.kml', data: doc, method: 0, stored: doc }, + { name: 'second.kml', data: second, method: 0, stored: second }, + ]); + const document = await kml.KMLParser.parseDocument(zip); + return { + count: document.features.length, + type: document.features[0].geometry.type, + lon: document.features[0].geometry.longitude, + }; + `); + expect(result.count).toBe(1); + expect(result.type).toBe('Point'); + expect(result.lon).toBeCloseTo(139.7, 9); + }); + + test('parses a KMZ archive with a deflate-compressed entry', async ({ page }) => { + const result = await evalWithZip<{ count: number; type: string; lon: number }>(page, ` + const encoder = new TextEncoder(); + const doc = encoder.encode(pointKml(139.7, 35.6)); + const zip = buildZip([ + { name: 'doc.kml', data: doc, method: 8, stored: await deflateRaw(doc) }, + ]); + const document = await kml.KMLParser.parseDocument(zip); + return { + count: document.features.length, + type: document.features[0].geometry.type, + lon: document.features[0].geometry.longitude, + }; + `); + expect(result.count).toBe(1); + expect(result.type).toBe('Point'); + expect(result.lon).toBeCloseTo(139.7, 9); + }); + + test('KMZ without a .kml entry rejects', async ({ page }) => { + const result = await evalWithZip<{ message: string }>(page, ` + const encoder = new TextEncoder(); + const readme = encoder.encode('no kml here'); + const zip = buildZip([ + { name: 'readme.txt', data: readme, method: 0, stored: readme }, + ]); + try { + await kml.KMLParser.parseDocument(zip); + return { message: '' }; + } catch (error) { + return { message: String(error) }; + } + `); + expect(result.message).toContain('no .kml entry'); + }); +}); + +// ─── KMLLoader ──────────────────────────────────────────────────────────────── + +/** ローダテストのスタブ環境を作るヘルパのソース(evaluate に埋め込む)。 */ +const LOADER_HELPERS = ` + const kmlWithPoint = (name, links = []) => { + const linkTags = links + .map((href) => '' + href + '') + .join(''); + return '' + linkTags + + '' + name + '1,2' + + ''; + }; + const loaderFor = (kml, documents, options = {}) => { + const fetchLog = []; + const errors = []; + const loader = new kml.KMLLoader({ + maxDocuments: options.maxDocuments, + onDocumentError: (url) => errors.push(url), + fetch: async (url) => { + fetchLog.push(url); + const doc = documents[url]; + if (doc === undefined) throw new Error('not found: ' + url); + return doc; + }, + }); + return { loader, fetchLog, errors }; + }; + const names = (features) => features.map((f) => f.properties.name); +`; + +const evalLoader = (page: Page, body: string): Promise => + page.evaluate(async ({ helpers, source }) => { + const kml = await import('/__react-kml.mjs'); + // eslint-disable-next-line no-new-func + const fn = new Function('kml', `${helpers} return (async () => { ${source} })();`); + return await fn(kml); + }, { helpers: LOADER_HELPERS, source: body }) as Promise; + +test.describe('KMLLoader', () => { + test('follows absolute and relative NetworkLinks', async ({ page }) => { + const result = await evalLoader(page, ` + const { loader } = loaderFor(kml, { + 'https://example.com/maps/root.kml': + kmlWithPoint('root', ['sub/child.kml', 'https://other.com/abs.kml']), + 'https://example.com/maps/sub/child.kml': kmlWithPoint('child'), + 'https://other.com/abs.kml': kmlWithPoint('abs'), + }); + return names(await loader.load('https://example.com/maps/root.kml')); + `); + expect(result).toEqual(['root', 'child', 'abs']); + }); + + test('cyclic links are fetched only once', async ({ page }) => { + const result = await evalLoader<{ names: string[]; fetches: number }>(page, ` + const { loader, fetchLog } = loaderFor(kml, { + 'https://example.com/a.kml': kmlWithPoint('a', ['https://example.com/b.kml']), + 'https://example.com/b.kml': kmlWithPoint('b', ['https://example.com/a.kml']), + }); + const features = await loader.load('https://example.com/a.kml'); + return { names: names(features), fetches: fetchLog.length }; + `); + expect(result.names).toEqual(['a', 'b']); + expect(result.fetches).toBe(2); + }); + + test('maxDocuments caps the chain', async ({ page }) => { + const result = await evalLoader(page, ` + const chain = {}; + for (let i = 0; i < 10; i++) { + chain['https://example.com/' + i + '.kml'] = + kmlWithPoint('doc' + i, ['https://example.com/' + (i + 1) + '.kml']); + } + const { loader } = loaderFor(kml, chain, { maxDocuments: 3 }); + return names(await loader.load('https://example.com/0.kml')); + `); + expect(result).toEqual(['doc0', 'doc1', 'doc2']); + }); + + test('a failed link is skipped and reported', async ({ page }) => { + const result = await evalLoader<{ names: string[]; errors: string[] }>(page, ` + const { loader, errors } = loaderFor(kml, { + 'https://example.com/root.kml': + kmlWithPoint('root', ['https://example.com/missing.kml', 'https://example.com/ok.kml']), + 'https://example.com/ok.kml': kmlWithPoint('ok'), + }); + const features = await loader.load('https://example.com/root.kml'); + return { names: names(features), errors }; + `); + expect(result.names).toEqual(['root', 'ok']); + expect(result.errors).toEqual(['https://example.com/missing.kml']); + }); + + test('text input resolves relative links against baseUrl', async ({ page }) => { + const result = await evalLoader<{ names: string[]; fetchLog: string[] }>(page, ` + const { loader, fetchLog } = loaderFor(kml, { + 'https://example.com/data/child.kml': kmlWithPoint('child'), + }); + const features = await loader.load( + kmlWithPoint('root', ['child.kml']), + 'https://example.com/data/root.kml', + ); + return { names: names(features), fetchLog }; + `); + expect(result.names).toEqual(['root', 'child']); + expect(result.fetchLog).toEqual(['https://example.com/data/child.kml']); + }); + + test('text input without baseUrl skips relative links', async ({ page }) => { + const result = await evalLoader<{ names: string[]; fetchLog: string[] }>(page, ` + const { loader, fetchLog } = loaderFor(kml, { + 'https://other.com/abs.kml': kmlWithPoint('abs'), + }); + const features = await loader.load( + kmlWithPoint('root', ['child.kml', 'https://other.com/abs.kml']), + ); + return { names: names(features), fetchLog }; + `); + expect(result.names).toEqual(['root', 'abs']); + expect(result.fetchLog).toEqual(['https://other.com/abs.kml']); + }); + + test('resolveHref handles absolute, relative, and missing base', async ({ page }) => { + const result = await evalInKml(page, (kml) => ({ + absolute: kml.KMLLoader.resolveHref('https://b.com/base.kml', 'https://a.com/x.kml'), + relative: kml.KMLLoader.resolveHref('https://b.com/dir/base.kml', 'x.kml'), + rooted: kml.KMLLoader.resolveHref('https://b.com/dir/base.kml', '/x.kml'), + noBase: kml.KMLLoader.resolveHref(null, 'x.kml'), + })); + expect(result.absolute).toBe('https://a.com/x.kml'); + expect(result.relative).toBe('https://b.com/dir/x.kml'); + expect(result.rooted).toBe('https://b.com/x.kml'); + expect(result.noBase).toBeNull(); + }); +}); + +// ─── KMLHitTester (polygon) ────────────────────────────────────────────────── + +// ポリゴンのヒット判定。ピクセル許容差(lineTolSq)を渡しても内部(穴を除く)は +// 当たり、輪郭のすぐ外側は許容差の範囲で拾い、穴の中と遠方は外れる。 +// android-sdk / ios-sdk の同名テストと同じ期待値。 +test.describe('KMLHitTester polygon', () => { + test('interior hits, hole and far-outside miss, near-outline hits', async ({ page }) => { + const result = await evalInKml(page, (kml) => { + const polygonKml = `poly + + + 139.744,35.688 139.762,35.688 139.762,35.676 139.744,35.676 139.744,35.688 + + + 139.750,35.685 139.756,35.685 139.756,35.680 139.750,35.680 139.750,35.685 + + `; + const features = kml.KMLParser.parse(polygonKml); + const renderer = new kml.KMLTileRenderer(512); + renderer.update(features, [], { strokeColor: 1, fillColor: 2, strokeWidth: 3, pointRadius: 8 }); + // processClick(point, 12, 13) が計算するのと同じ許容差 + const worldSize = 512 * Math.pow(2, 13); + const lineTolSq = (12 / worldSize) ** 2; + const name = (hit: any) => hit?.feature?.properties?.name ?? null; + return { + interiorWithTol: name(renderer.hitTest(139.746, 35.683, lineTolSq, undefined)), + holeWithTol: name(renderer.hitTest(139.753, 35.6825, lineTolSq, undefined)), + nearOutlineWithTol: name(renderer.hitTest(139.7435, 35.683, lineTolSq, undefined)), + farOutsideWithTol: name(renderer.hitTest(139.735, 35.683, lineTolSq, undefined)), + interiorDefault: name(renderer.hitTest(139.746, 35.683)), + holeDefault: name(renderer.hitTest(139.753, 35.6825)), + interiorNullTol: name(renderer.hitTest(139.746, 35.683, null, null)), + }; + }); + expect(result.interiorWithTol).toBe('poly'); + expect(result.holeWithTol).toBeNull(); + expect(result.nearOutlineWithTol).toBe('poly'); + expect(result.farOutsideWithTol).toBeNull(); + expect(result.interiorDefault).toBe('poly'); + expect(result.holeDefault).toBeNull(); + expect(result.interiorNullTol).toBe('poly'); + }); +}); + +// ─── KMLStyleProvider ──────────────────────────────────────────────────────── + +test.describe('DefaultKMLStyleProvider', () => { + test('uses feature values before layer defaults', async ({ page }) => { + const result = await evalInKml(page, (kml) => { + const defaults = { strokeColor: 1, fillColor: 2, strokeWidth: 3, pointRadius: 4 }; + const feature = { + geometry: { type: 'Empty' }, + properties: {}, + strokeColor: 10, + pointRadius: 40, + visible: true, + }; + return kml.DefaultKMLStyleProvider(feature, defaults); + }); + expect(result).toEqual({ strokeColor: 10, fillColor: 2, strokeWidth: 3, pointRadius: 40 }); + }); +});