From a533fbf18b4dbee2174f328b93a41e0be3e86017 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 21:14:36 -0800 Subject: [PATCH 1/2] Interpolate the closing edge of geodesic polygon rings createInterpolatePoints only interpolates between neighbouring points, and polygon rings arrive open (last != first). The closing edge (last -> first) of a geodesic polygon was therefore never densified and was drawn as a straight chord, while the outline - which closes the ring and densifies it as a polyline - followed the great circle. Fill and outline disagreed on that edge (e.g. Boston -> London -> Iceland: the Iceland -> Boston edge's great circle passes 55.6N at 53.2W, the chord passes 51.6N there). Add densifyGeodesicRing, which closes the ring before densifying and keeps the input's open/closed shape, and use it for outer rings and holes in buildPolygonRings / buildUnwrappedPolygonRings and for the PolygonManager hit test. geodesic=false is unchanged. The public API only gains the new function. --- api/js-sdk-core.api.d.ts | 14 +++- src/geometry/OverlayGeometry.ts | 52 +++++++++++- src/polygon/PolygonManager.ts | 6 +- test/GeodesicPolygonRing.test.mjs | 131 ++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 test/GeodesicPolygonRing.test.mjs diff --git a/api/js-sdk-core.api.d.ts b/api/js-sdk-core.api.d.ts index 1391977..dbda4b3 100644 --- a/api/js-sdk-core.api.d.ts +++ b/api/js-sdk-core.api.d.ts @@ -5382,6 +5382,18 @@ interface PolygonRings { } /** geodesic に応じた補間で点列を密度化し、緯度経度を正規化して返す。 */ declare function densifyAndNormalize(points: GeoPoint[], geodesic: boolean, maxSegmentLength?: number): GeoPoint[]; +/** + * ポリゴンのリング(外周・穴)を測地線(大圏)で密度化する。 + * + * `WGS84Geodesic.createInterpolatePoints` は隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)を渡すと閉じる辺(末尾 → 先頭)が補間されず、描画側で閉じたときに直線の弦に + * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で + * 閉じる辺の形が食い違う。ここでは閉じる辺も含めて補間する。 + * + * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた + * まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 + */ +declare function densifyGeodesicRing(ring: GeoPoint[], maxSegmentLength?: number): GeoPoint[]; /** * ポリライン用パイプライン(分割版)。密度化・正規化後に子午線で分割したセグメント列を返す。 * 頂点 2 未満の入力、および分割で 2 点未満になったセグメントは除く(空配列になり得る)。 @@ -5606,5 +5618,5 @@ declare function segmentIntersectsRegion({ start, end, region, geodesic, }: { declare function splitByMeridian(points: GeoPoint[], geodesic: boolean): GeoPoint[][]; -export { AbstractCircleOverlayRenderer, AbstractGroundImageOverlayRenderer, AbstractMarkerController, AbstractMarkerIcon, AbstractMarkerOverlayRenderer, AbstractMarkerRenderingStrategy, AbstractPolygonOverlayRenderer, AbstractPolylineOverlayRenderer, AbstractViewportStrategy, AbstractZoomAltitudeConverter, BLANK_MAP_STYLE, BLANK_MAP_STYLE_URL, BaseMapViewController, CANONICAL_ORDER, CircleController, CircleManager, CircleOverlay, CollectorMarkerOverlayRenderer, ColorDefaultIcon, DEFAULT_CIRCLE_SEGMENTS, DEFAULT_RASTER_LAYER_USER_AGENT, DefaultMarkerEventController, DefaultMarkerIcon, Direction6, Direction6Delta, DriverConformanceViolation, Earth, EmptyMapServiceRegistry, GROUND_IMAGE_DEFAULT_TILE_SIZE, GeoGridIndex, GeoPoint, GroundImageController, GroundImageManager, GroundImageOverlay, GroundScaleZoomAltitudeConverter, HexGeocellImpl, IconImageCache, ImageDefaultIcon, ImageIcon, InitState, LocalTileServer, MAP_CAPABILITIES, MARKER_HIT_RADIUS_MOUSE_PX, MARKER_HIT_RADIUS_TOUCH_PX, MARKER_HIT_SEARCH_RADIUS_PX, MARKER_RENDER_BATCH_SIZE, MAX_TILE_PIXEL_RATIO, MapCameraPosition, MapCapabilityStatus, MapDiagnostics, MapDriverConformance, MapOverlayRegistry, MapPaddings, MapProjection, MapProvider, MapProviderType, MapServiceRegistrations, MapUISettings, MapUISettingsDiagnostics, MapViewBase, MapViewHolderBase, MapViewState, MapViewStyleHost, MarkerAnimation, MarkerGrid, MarkerIconSize, MarkerManager, MarkerOverlay, MarkerRenderingSupportKey, MarkerTileRenderer, MarkerTilingOptions, MutableMapServiceRegistry, Mutex, NoCameraRestriction, OVERLAY_KINDS, OverlayCollector, OverlayGeoJson, OverlayHitResolver, Planar, PolygonController, PolygonManager, PolygonOverlay, PolylineController, PolylineManager, PolylineOverlay, RasterHeaderRuleSet, RasterLayerController, RasterLayerManager, RasterLayerOverlay, RasterLayerSource, RasterTilePreferenceKey, ScreenProjectionRequirement, Settings, Spherical, StrategyMarkerController, StyleMutationStore, TRANSPARENT_TILE_PNG, TileRenderFailedError, TileScheme, TileServerRegistry, VectorStyleAsDesign, VectorStyleMutationSupportKey, VectorStyleSupportKey, WEB_MERCATOR_MAX_EXTENT_METERS, WEB_MERCATOR_MAX_LATITUDE, WGS84, WGS84Geodesic, WebMercator, WebMercatorZoomAltitudeConverter, applyGlMapUISettings, applyStyleMutations, bearingFromNativeHeading, bearingFromNativeRotation, bounceInterpolation, bridgeHolesIntoSingleRing, bridgeHolesIntoSingleRingWrapAware, buildPolygonRings, buildPolylineSegments, buildUnwrappedPolygonRings, buildUnwrappedPolylinePath, buildVisibleRegion, calculateMetersPerPixel, capabilityOfGesture, circleToRing, closeRing, closestPointOnSegment, combineHash, computeArea$1 as computeArea, computeDistanceBetween$1 as computeDistanceBetween, computeFitBoundsCameraPosition, computeHeading$1 as computeHeading, computeLength$1 as computeLength, computeOffset$1 as computeOffset, computeOffsetOrigin$1 as computeOffsetOrigin, computeSignedArea$1 as computeSignedArea, computeDistanceBetween as computeWGS84DistanceBetween, createCircleEntity, createCircleState, createCollectorMarkerRenderingSupport, createDefaultIcon, createFingerPrint, createGeoPoint, createGeoRectBounds, createGroundImageEntity, createGroundImageState, createHexCell, createHexCoord, createIdentifiedHexCell, createMapCameraPosition, createMapServiceKey, createMarkerEntity, createMarkerFingerPrint, createMarkerState, createOffset, createOppositeMeridianPoint, createOverlayHit, createPolygonEntity, createPolygonState, createPolylineEntity, createPolylineState, createRandomId, createRasterLayerEntity, createRasterLayerState, createSegmentBounds, createSubject, createTileWorkerHandler, densifyAndNormalize, describeStyleMutations, expandBounds, fingerPrintEquals, fromGeoPoint, fromLatLng, fromLatLong, fromLngLat, fromLongLat, generateIdFromHashes, getNeighbors, hashBool, hashDefaultMarkerIcon, hashGeoPoint, hashNullable, hashNum, hashObj, hashStr, hexCellToIdPrefix, hexCoordToString, ingestMarkers, interpolate$1 as interpolate, interpolateAtMeridianGeodesic, interpolateAtMeridianLinear, interpolate as interpolateWGS84, isEmptyCameraRestriction, isFullySupported, isKnownUnsupported, isNativeDescribableStyle, isNativeMapExtensionCapable, isSlottedOverlayController, isUsable, longHashCode, mapCapabilityFromId, mapViewStateInternal, markerHitShape, markerIconExtentPx, markerIconHashCode, markerSearchBounds, normalizeLngDegrees, parseStyleMutations, parseTilePixelRatio, pickMarkerHit, pickMarkerIndex, preferMarkerHit, resolveAttributionRules, resolveCameraRestriction, resolveHoles, resolveMapUISettings, resolveRasterAttributions, reverseStyleMutation, segmentIntersectsRegion, splitByMeridian, splitRingByMeridian, styleMutationLayerId, styleMutationProperty, styleMutationTarget, tilePixelRatio, tilePixelRatioSuffix, toDegrees, toInt, toNativeHeading, toNativeRotation, toRadians, unionHoleRings, unionHoles, unionHolesInPlace, vectorStyleKey, viewportSizeOf, withRasterHeaderTransform, wrapClickedPoint }; +export { AbstractCircleOverlayRenderer, AbstractGroundImageOverlayRenderer, AbstractMarkerController, AbstractMarkerIcon, AbstractMarkerOverlayRenderer, AbstractMarkerRenderingStrategy, AbstractPolygonOverlayRenderer, AbstractPolylineOverlayRenderer, AbstractViewportStrategy, AbstractZoomAltitudeConverter, BLANK_MAP_STYLE, BLANK_MAP_STYLE_URL, BaseMapViewController, CANONICAL_ORDER, CircleController, CircleManager, CircleOverlay, CollectorMarkerOverlayRenderer, ColorDefaultIcon, DEFAULT_CIRCLE_SEGMENTS, DEFAULT_RASTER_LAYER_USER_AGENT, DefaultMarkerEventController, DefaultMarkerIcon, Direction6, Direction6Delta, DriverConformanceViolation, Earth, EmptyMapServiceRegistry, GROUND_IMAGE_DEFAULT_TILE_SIZE, GeoGridIndex, GeoPoint, GroundImageController, GroundImageManager, GroundImageOverlay, GroundScaleZoomAltitudeConverter, HexGeocellImpl, IconImageCache, ImageDefaultIcon, ImageIcon, InitState, LocalTileServer, MAP_CAPABILITIES, MARKER_HIT_RADIUS_MOUSE_PX, MARKER_HIT_RADIUS_TOUCH_PX, MARKER_HIT_SEARCH_RADIUS_PX, MARKER_RENDER_BATCH_SIZE, MAX_TILE_PIXEL_RATIO, MapCameraPosition, MapCapabilityStatus, MapDiagnostics, MapDriverConformance, MapOverlayRegistry, MapPaddings, MapProjection, MapProvider, MapProviderType, MapServiceRegistrations, MapUISettings, MapUISettingsDiagnostics, MapViewBase, MapViewHolderBase, MapViewState, MapViewStyleHost, MarkerAnimation, MarkerGrid, MarkerIconSize, MarkerManager, MarkerOverlay, MarkerRenderingSupportKey, MarkerTileRenderer, MarkerTilingOptions, MutableMapServiceRegistry, Mutex, NoCameraRestriction, OVERLAY_KINDS, OverlayCollector, OverlayGeoJson, OverlayHitResolver, Planar, PolygonController, PolygonManager, PolygonOverlay, PolylineController, PolylineManager, PolylineOverlay, RasterHeaderRuleSet, RasterLayerController, RasterLayerManager, RasterLayerOverlay, RasterLayerSource, RasterTilePreferenceKey, ScreenProjectionRequirement, Settings, Spherical, StrategyMarkerController, StyleMutationStore, TRANSPARENT_TILE_PNG, TileRenderFailedError, TileScheme, TileServerRegistry, VectorStyleAsDesign, VectorStyleMutationSupportKey, VectorStyleSupportKey, WEB_MERCATOR_MAX_EXTENT_METERS, WEB_MERCATOR_MAX_LATITUDE, WGS84, WGS84Geodesic, WebMercator, WebMercatorZoomAltitudeConverter, applyGlMapUISettings, applyStyleMutations, bearingFromNativeHeading, bearingFromNativeRotation, bounceInterpolation, bridgeHolesIntoSingleRing, bridgeHolesIntoSingleRingWrapAware, buildPolygonRings, buildPolylineSegments, buildUnwrappedPolygonRings, buildUnwrappedPolylinePath, buildVisibleRegion, calculateMetersPerPixel, capabilityOfGesture, circleToRing, closeRing, closestPointOnSegment, combineHash, computeArea$1 as computeArea, computeDistanceBetween$1 as computeDistanceBetween, computeFitBoundsCameraPosition, computeHeading$1 as computeHeading, computeLength$1 as computeLength, computeOffset$1 as computeOffset, computeOffsetOrigin$1 as computeOffsetOrigin, computeSignedArea$1 as computeSignedArea, computeDistanceBetween as computeWGS84DistanceBetween, createCircleEntity, createCircleState, createCollectorMarkerRenderingSupport, createDefaultIcon, createFingerPrint, createGeoPoint, createGeoRectBounds, createGroundImageEntity, createGroundImageState, createHexCell, createHexCoord, createIdentifiedHexCell, createMapCameraPosition, createMapServiceKey, createMarkerEntity, createMarkerFingerPrint, createMarkerState, createOffset, createOppositeMeridianPoint, createOverlayHit, createPolygonEntity, createPolygonState, createPolylineEntity, createPolylineState, createRandomId, createRasterLayerEntity, createRasterLayerState, createSegmentBounds, createSubject, createTileWorkerHandler, densifyAndNormalize, densifyGeodesicRing, describeStyleMutations, expandBounds, fingerPrintEquals, fromGeoPoint, fromLatLng, fromLatLong, fromLngLat, fromLongLat, generateIdFromHashes, getNeighbors, hashBool, hashDefaultMarkerIcon, hashGeoPoint, hashNullable, hashNum, hashObj, hashStr, hexCellToIdPrefix, hexCoordToString, ingestMarkers, interpolate$1 as interpolate, interpolateAtMeridianGeodesic, interpolateAtMeridianLinear, interpolate as interpolateWGS84, isEmptyCameraRestriction, isFullySupported, isKnownUnsupported, isNativeDescribableStyle, isNativeMapExtensionCapable, isSlottedOverlayController, isUsable, longHashCode, mapCapabilityFromId, mapViewStateInternal, markerHitShape, markerIconExtentPx, markerIconHashCode, markerSearchBounds, normalizeLngDegrees, parseStyleMutations, parseTilePixelRatio, pickMarkerHit, pickMarkerIndex, preferMarkerHit, resolveAttributionRules, resolveCameraRestriction, resolveHoles, resolveMapUISettings, resolveRasterAttributions, reverseStyleMutation, segmentIntersectsRegion, splitByMeridian, splitRingByMeridian, styleMutationLayerId, styleMutationProperty, styleMutationTarget, tilePixelRatio, tilePixelRatioSuffix, toDegrees, toInt, toNativeHeading, toNativeRotation, toRadians, unionHoleRings, unionHoles, unionHolesInPlace, vectorStyleKey, viewportSizeOf, withRasterHeaderTransform, wrapClickedPoint }; export type { AddParams, AnyMarkerRenderingSupport, AttributionRule, BitmapIcon, CameraOptions, CameraRestriction, ChangeParams, ChangeParamsInterface, CircleAddParams, CircleCapable, CircleChangeParams, CircleEntity, CircleEvent, CircleFingerPrint, CircleManagerInterface, CircleOptions, CircleOverlayRenderer, CircleState, CircleStateCopyParams, CreateMarkerStateParams, DefaultMarkerIconOptions, FitBoundsCameraResult, GeoPointInterface, GeoRectBounds, GlGestureHandler, GlGestureHandlers, GlMarkerDragMap, GlTouchZoomRotateHandler, GroundImageAddParams, GroundImageCapable, GroundImageChangeParams, GroundImageEntity, GroundImageEvent, GroundImageFingerPrint, GroundImageManagerInterface, GroundImageOverlayRenderer, GroundImageState, GroundImageStateCopyParams, GroundOverlayOptions, HexCell, HexCoord, HexGeocell, HexGeocell as HexGeocellInterface, IdentifiedHexCell, ImageDefaultIconOptions, ImageIconOptions, ImageSource, InfoBubbleEntry, MapCameraPositionCopyParams, MapCameraPositionInterface, MapCapability, MapConfig, MapDesignTypeInterface, MapDiagnosticLevel, MapDiagnosticsSink, MapGesture, MapOverlayInterface, MapPaddingsInterface, MapServiceKey, MapServiceRegistration, MapServiceRegistry, MapStyleHost, MapStyleInstallation, MapViewBaseProps, MapViewControllerInterface, MapViewHolder, MapViewStateInterface, MapViewStateInternal, MapViewStateOptions, MapViewStyle, MarkerAnimationOverlayEntry, MarkerAnimationOverlayHost, MarkerCapable, MarkerEntity, MarkerEventController, MarkerEventHost, MarkerFingerPrint, MarkerHit, MarkerHitRect, MarkerIcon, MarkerIngestionResult, MarkerLabelHorizontalPosition, MarkerLabelPosition, MarkerLabelVerticalPosition, MarkerManagerStats, MarkerOptions, MarkerOverlayRenderer, MarkerRenderingStrategy, MarkerRenderingSupport, MarkerState, MarkerStateCopyParams, NativeDescribableStyle, NativeMapExtensionCapable, NativeMapExtensionDescriptor, NativeMapExtensionEvent, NativeMapExtensionEventHandler, NativeStyleDescriptor, Offset, OnCameraMoveHandler, OnCircleEventHandler, OnGroundImageEventHandler, OnMapEventHandler, OnMapInitializedHandler, OnMapLoadedHandler, OnMarkerEventHandler, OnPolygonEventHandler, OnPolylineEventHandler, OnRasterLayerEventHandler, OverlayController, OverlayControllerLike, OverlayHit, OverlayKind, OverlayRendererInterface, PolygonAddParams, PolygonCapable, PolygonChangeParams, PolygonEntity, PolygonEvent, PolygonFingerPrint, PolygonManagerInterface, PolygonOptions, PolygonOverlayRenderer, PolygonRings, PolygonState, PolygonStateCopyParams, PolylineAddParams, PolylineCapable, PolylineChangeParams, PolylineEntity, PolylineEvent, PolylineFingerPrint, PolylineHitResult, PolylineManagerInterface, PolylineOptions, PolylineOverlayRenderer, PolylineState, PolylineStateCopyParams, PreparedLabel, PreparedMarker, Projection, RasterAttributionRule, RasterHeaderRule, RasterHeaderSupport, RasterLayerAddParams, RasterLayerCapable, RasterLayerChangeParams, RasterLayerEntity, RasterLayerEvent, RasterLayerFingerPrint, RasterLayerManagerInterface, RasterLayerMounting, RasterLayerOverlayRenderer, RasterLayerState, RasterLayerStateCopyParams, RasterRequestParameters, RasterTilePreference, RasterTransformRequest, ScreenOffsetResult, Serializable, SlottedOverlayController, StyleMutation, StylePatchableMap, TileOutcome, TileProvider, TileRenderRequest, TileRenderResponse, TileRequest, VectorStyleMutationSupport, VectorStyleSupport, ViewportSize, VisibleRegion }; diff --git a/src/geometry/OverlayGeometry.ts b/src/geometry/OverlayGeometry.ts index 2ea53e8..fa9c606 100644 --- a/src/geometry/OverlayGeometry.ts +++ b/src/geometry/OverlayGeometry.ts @@ -32,6 +32,50 @@ export function densifyAndNormalize( return interpolated.map((point) => point.normalize()); } +/** + * ポリゴンのリング(外周・穴)を測地線(大圏)で密度化する。 + * + * `WGS84Geodesic.createInterpolatePoints` は隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)を渡すと閉じる辺(末尾 → 先頭)が補間されず、描画側で閉じたときに直線の弦に + * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で + * 閉じる辺の形が食い違う。ここでは閉じる辺も含めて補間する。 + * + * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた + * まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 + */ +export function densifyGeodesicRing( + ring: GeoPoint[], + maxSegmentLength: number = 10000.0, +): GeoPoint[] { + if (ring.length < 2 || isClosedRing(ring)) { + return WGS84Geodesic.createInterpolatePoints(ring, maxSegmentLength); + } + const densified = WGS84Geodesic.createInterpolatePoints( + [...ring, ring[0]], + maxSegmentLength, + ); + return densified.slice(0, -1); +} + +function isClosedRing(ring: GeoPoint[]): boolean { + const first = ring[0]; + const last = ring[ring.length - 1]; + return first.latitude === last.latitude && first.longitude === last.longitude; +} + +/** + * リング用の densifyAndNormalize。geodesic のときは閉じる辺も補間する(densifyGeodesicRing)。 + * geodesic=false は従来どおり densifyAndNormalize と同じ。 + */ +function densifyRingAndNormalize( + ring: GeoPoint[], + geodesic: boolean, + maxSegmentLength: number = 10000.0, +): GeoPoint[] { + if (!geodesic) return densifyAndNormalize(ring, false, maxSegmentLength); + return densifyGeodesicRing(ring, maxSegmentLength).map((point) => point.normalize()); +} + // ─── 分割版パイプライン(経度 ±180 に制約のある SDK 向け) ───────────────────── // // 経度 ±180 を超える座標を受け付けない SDK は、密度化・正規化後に子午線で分割した複数 @@ -66,14 +110,14 @@ export function buildPolygonRings( ): PolygonRings { if (points.length < 3) return { outerRings: [], holeRings: [] }; const outerRings = splitRingByMeridian( - densifyAndNormalize(points, geodesic), + densifyRingAndNormalize(points, geodesic), geodesic, ).filter((ring) => ring.length >= 3); const includeHoles = holes.length > 0 && outerRings.length === 1; const holeRings = includeHoles ? holes .filter((hole) => hole.length >= 3) - .map((hole) => densifyAndNormalize(hole, geodesic)) + .map((hole) => densifyRingAndNormalize(hole, geodesic)) .filter((hole) => hole.length >= 3) : []; return { outerRings, holeRings }; @@ -128,14 +172,14 @@ export function buildUnwrappedPolygonRings( ): PolygonRings { if (points.length < 3) return { outerRings: [], holeRings: [] }; const outer = unwrapContinuous( - densifyAndNormalize(points, geodesic, maxSegmentLength), + densifyRingAndNormalize(points, geodesic, maxSegmentLength), ); const anchor = outer[0].longitude; const holeRings = holes .filter((hole) => hole.length >= 3) .map((hole) => unwrapContinuous( - densifyAndNormalize(hole, geodesic, maxSegmentLength), + densifyRingAndNormalize(hole, geodesic, maxSegmentLength), anchor, ), ) diff --git a/src/polygon/PolygonManager.ts b/src/polygon/PolygonManager.ts index ad72b34..5d892c3 100644 --- a/src/polygon/PolygonManager.ts +++ b/src/polygon/PolygonManager.ts @@ -1,5 +1,5 @@ import { GeoPoint } from "../features"; -import { WGS84Geodesic } from "../spherical"; +import { densifyGeodesicRing } from "../geometry/OverlayGeometry"; import { PolygonEntity } from "./PolygonEntity"; function normalizeLng(lng: number): number { @@ -67,7 +67,7 @@ export class PolygonManager implements PolygonManagerInterface implements PolygonManagerInterface createGeoPoint({ latitude, longitude }); +const BOSTON = p(42.36, -71.06); +const LONDON = p(51.5, -0.12); +const ICELAND = p(64.1, -21.9); +// 実機検証と同じ順序。閉じる辺は アイスランド → ボストン。 +const TRIANGLE = [BOSTON, LONDON, ICELAND]; + +const same = (a, b) => Math.abs(a.latitude - b.latitude) < 1e-9 && Math.abs(a.longitude - b.longitude) < 1e-9; + +function assertSamePoints(actual, expected) { + assert.equal(actual.length, expected.length); + for (let i = 0; i < actual.length; i++) { + assert.ok(same(actual[i], expected[i]), `point ${i}: ${JSON.stringify(actual[i])} != ${JSON.stringify(expected[i])}`); + } +} + +/** 輪郭(閉じたリングをポリラインとして密度化)から、末尾の重複点を除いたもの。 */ +function outlineOf(points, geodesic) { + const path = buildUnwrappedPolylinePath([...points, points[0]], geodesic); + return path.slice(0, -1); +} + +test('大圏の中間点: ボストン → アイスランドは北西へ約 4° 膨らむ', () => { + const mid = WGS84Geodesic.interpolate({ from: BOSTON, to: ICELAND, fraction: 0.5 }); + // Web メルカトルでの直線(弦)が同じ経度で通る緯度。 + const merc = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); + const t = (mid.longitude - BOSTON.longitude) / (ICELAND.longitude - BOSTON.longitude); + const y = merc(BOSTON.latitude) + t * (merc(ICELAND.latitude) - merc(BOSTON.latitude)); + const chordLat = ((2 * Math.atan(Math.exp(y)) - Math.PI / 2) * 180) / Math.PI; + assert.ok(Math.abs(mid.latitude - 55.63) < 0.05, `mid ${mid.latitude}`); + assert.ok(Math.abs(chordLat - 51.6) < 0.05, `chord ${chordLat}`); +}); + +test('densifyGeodesicRing は閉じる辺も補間し、開いたリングのまま返す', () => { + const ring = densifyGeodesicRing(TRIANGLE); + assert.ok(same(ring[0], BOSTON)); + // 先頭の重複点を末尾に付けない(閉じて補間した結果から末尾の 1 点だけを除いた長さ)。 + // createInterpolatePoints は分割比を足し込むため、末尾の手前に終点とほぼ同じ点が残ることがある + // (ポリラインでも同じ。描画には影響しない)。 + assert.equal(ring.length, WGS84Geodesic.createInterpolatePoints([...TRIANGLE, BOSTON]).length - 1); + assert.notEqual(ring[ring.length - 1], BOSTON); + // 閉じる辺の中間点(大圏)が含まれる。 + const expected = WGS84Geodesic.createInterpolatePoints([ICELAND, BOSTON]); + const tail = ring.slice(ring.length - (expected.length - 2)); + assertSamePoints(tail, expected.slice(1, -1)); +}); + +test('densifyGeodesicRing は閉じたリングを閉じたまま返す(二重に閉じない)', () => { + const closed = [...TRIANGLE, BOSTON]; + assertSamePoints(densifyGeodesicRing(closed), WGS84Geodesic.createInterpolatePoints(closed)); +}); + +test('geodesic の塗り(unwrap 版)は輪郭と同じ点列になる', () => { + const fill = buildUnwrappedPolygonRings(TRIANGLE, [], true).outerRings[0]; + assertSamePoints(fill, outlineOf(TRIANGLE, true)); + // 閉じる辺の中間点(北西へ膨らんだ点)を通る。 + const mid = WGS84Geodesic.interpolate({ from: ICELAND, to: BOSTON, fraction: 0.5 }); + const nearest = Math.min(...fill.map((q) => Math.hypot(q.latitude - mid.latitude, q.longitude - mid.longitude))); + assert.ok(nearest < 0.1, `nearest ${nearest}`); +}); + +test('geodesic の塗り(分割版)も閉じる辺を補間する', () => { + const rings = buildPolygonRings(TRIANGLE, [], true).outerRings; + assert.equal(rings.length, 1); + assertSamePoints(rings[0], outlineOf(TRIANGLE, true)); +}); + +test('geodesic=false は変わらない(閉じる辺を補間しない)', () => { + const fill = buildUnwrappedPolygonRings(TRIANGLE, [], false).outerRings[0]; + assertSamePoints(fill, densifyAndNormalize(TRIANGLE, false)); + assert.ok(same(fill[fill.length - 1], ICELAND)); + const split = buildPolygonRings(TRIANGLE, [], false).outerRings[0]; + assertSamePoints(split, densifyAndNormalize(TRIANGLE, false)); +}); + +test('穴も閉じる辺を補間する', () => { + const outer = [p(30, -80), p(30, 10), p(70, 10), p(70, -80)]; + const hole = TRIANGLE; + const unwrapped = buildUnwrappedPolygonRings(outer, [hole], true); + assertSamePoints(unwrapped.holeRings[0], outlineOf(hole, true)); + assertSamePoints(unwrapped.outerRings[0], outlineOf(outer, true)); + const split = buildPolygonRings(outer, [hole], true); + assertSamePoints(split.holeRings[0], outlineOf(hole, true)); + // false の穴は従来どおり。 + assertSamePoints(buildUnwrappedPolygonRings(outer, [hole], false).holeRings[0], densifyAndNormalize(hole, false)); +}); + +test('タップ判定も閉じる辺の大圏に従う', () => { + // 弦と大圏の間(北西へ膨らんだ部分)の点。 + const mid = WGS84Geodesic.interpolate({ from: ICELAND, to: BOSTON, fraction: 0.5 }); + const t = (mid.longitude - ICELAND.longitude) / (BOSTON.longitude - ICELAND.longitude); + const chordLat = ICELAND.latitude + t * (BOSTON.latitude - ICELAND.latitude); + const bulge = p((mid.latitude + chordLat) / 2, mid.longitude); + + const manager = new PolygonManager(); + const geodesic = createPolygonState({ id: 'g', points: TRIANGLE, geodesic: true }); + manager.registerEntity({ state: geodesic }); + assert.equal(manager.find(bulge)?.state.id, 'g'); + + geodesic.geodesic = false; + assert.equal(manager.find(bulge), null); +}); From 4c99a652be910161e7a9bdd914a83ea7b26d5884 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:22:40 -0800 Subject: [PATCH 2/2] Densify the closing edge of non-geodesic polygon rings too The closing edge (last vertex -> first) of a geodesic=false polygon had the same gap: Planar.createInterpolatePoints also only interpolates between neighbouring points, so the fill's closing edge stayed a single straight Web Mercator chord, while the outline - which closes the ring and densifies it as a polyline - interpolated latitude/longitude linearly and bent slightly on screen. (Boston -> London -> Iceland: halfway along the Iceland -> Boston edge, the linear edge passes 53.2N, the chord 54.7N.) Generalise densifyGeodesicRing (unreleased) into densifyPolygonRing(ring, geodesic, maxSegmentLength), which closes the ring before densifying with WGS84Geodesic or Planar, and use it for every ring in buildPolygonRings / buildUnwrappedPolygonRings (like densifyAndNormalize, the linear case keeps its 10 km segments). The hit test keeps testing geodesic=false against the raw vertices: in the lat/lng plane that is the same line as the linear interpolation. Compared with develop the public API only gains densifyPolygonRing. Same change as android-sdk-core and ios-sdk-core. --- api/js-sdk-core.api.d.ts | 14 ++++--- src/geometry/OverlayGeometry.ts | 36 +++++++++-------- src/polygon/PolygonManager.ts | 6 +-- test/GeodesicPolygonRing.test.mjs | 67 +++++++++++++++++++++++++------ 4 files changed, 84 insertions(+), 39 deletions(-) diff --git a/api/js-sdk-core.api.d.ts b/api/js-sdk-core.api.d.ts index dbda4b3..206f03d 100644 --- a/api/js-sdk-core.api.d.ts +++ b/api/js-sdk-core.api.d.ts @@ -5383,17 +5383,19 @@ interface PolygonRings { /** geodesic に応じた補間で点列を密度化し、緯度経度を正規化して返す。 */ declare function densifyAndNormalize(points: GeoPoint[], geodesic: boolean, maxSegmentLength?: number): GeoPoint[]; /** - * ポリゴンのリング(外周・穴)を測地線(大圏)で密度化する。 + * ポリゴンのリング(外周・穴)を、閉じる辺(末尾 → 先頭)も含めて密度化する。 * - * `WGS84Geodesic.createInterpolatePoints` は隣り合う点の間しか補間しないため、開いたリング - * (末尾 ≠ 先頭)を渡すと閉じる辺(末尾 → 先頭)が補間されず、描画側で閉じたときに直線の弦に + * geodesic なら大圏(`WGS84Geodesic`)、そうでなければ緯度経度の線形補間(`Planar`)。 + * どちらの `createInterpolatePoints` も隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)をそのまま渡すと閉じる辺だけが補間されず、描画側で閉じたときに直線の弦に * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で - * 閉じる辺の形が食い違う。ここでは閉じる辺も含めて補間する。 + * 閉じる辺の形が食い違う(geodesic=false でも、線形補間の辺はメルカトル上でわずかに曲がる)。 + * ここでは閉じる辺も他の辺と同じ方式で補間する。 * * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた * まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 */ -declare function densifyGeodesicRing(ring: GeoPoint[], maxSegmentLength?: number): GeoPoint[]; +declare function densifyPolygonRing(ring: GeoPoint[], geodesic: boolean, maxSegmentLength?: number): GeoPoint[]; /** * ポリライン用パイプライン(分割版)。密度化・正規化後に子午線で分割したセグメント列を返す。 * 頂点 2 未満の入力、および分割で 2 点未満になったセグメントは除く(空配列になり得る)。 @@ -5618,5 +5620,5 @@ declare function segmentIntersectsRegion({ start, end, region, geodesic, }: { declare function splitByMeridian(points: GeoPoint[], geodesic: boolean): GeoPoint[][]; -export { AbstractCircleOverlayRenderer, AbstractGroundImageOverlayRenderer, AbstractMarkerController, AbstractMarkerIcon, AbstractMarkerOverlayRenderer, AbstractMarkerRenderingStrategy, AbstractPolygonOverlayRenderer, AbstractPolylineOverlayRenderer, AbstractViewportStrategy, AbstractZoomAltitudeConverter, BLANK_MAP_STYLE, BLANK_MAP_STYLE_URL, BaseMapViewController, CANONICAL_ORDER, CircleController, CircleManager, CircleOverlay, CollectorMarkerOverlayRenderer, ColorDefaultIcon, DEFAULT_CIRCLE_SEGMENTS, DEFAULT_RASTER_LAYER_USER_AGENT, DefaultMarkerEventController, DefaultMarkerIcon, Direction6, Direction6Delta, DriverConformanceViolation, Earth, EmptyMapServiceRegistry, GROUND_IMAGE_DEFAULT_TILE_SIZE, GeoGridIndex, GeoPoint, GroundImageController, GroundImageManager, GroundImageOverlay, GroundScaleZoomAltitudeConverter, HexGeocellImpl, IconImageCache, ImageDefaultIcon, ImageIcon, InitState, LocalTileServer, MAP_CAPABILITIES, MARKER_HIT_RADIUS_MOUSE_PX, MARKER_HIT_RADIUS_TOUCH_PX, MARKER_HIT_SEARCH_RADIUS_PX, MARKER_RENDER_BATCH_SIZE, MAX_TILE_PIXEL_RATIO, MapCameraPosition, MapCapabilityStatus, MapDiagnostics, MapDriverConformance, MapOverlayRegistry, MapPaddings, MapProjection, MapProvider, MapProviderType, MapServiceRegistrations, MapUISettings, MapUISettingsDiagnostics, MapViewBase, MapViewHolderBase, MapViewState, MapViewStyleHost, MarkerAnimation, MarkerGrid, MarkerIconSize, MarkerManager, MarkerOverlay, MarkerRenderingSupportKey, MarkerTileRenderer, MarkerTilingOptions, MutableMapServiceRegistry, Mutex, NoCameraRestriction, OVERLAY_KINDS, OverlayCollector, OverlayGeoJson, OverlayHitResolver, Planar, PolygonController, PolygonManager, PolygonOverlay, PolylineController, PolylineManager, PolylineOverlay, RasterHeaderRuleSet, RasterLayerController, RasterLayerManager, RasterLayerOverlay, RasterLayerSource, RasterTilePreferenceKey, ScreenProjectionRequirement, Settings, Spherical, StrategyMarkerController, StyleMutationStore, TRANSPARENT_TILE_PNG, TileRenderFailedError, TileScheme, TileServerRegistry, VectorStyleAsDesign, VectorStyleMutationSupportKey, VectorStyleSupportKey, WEB_MERCATOR_MAX_EXTENT_METERS, WEB_MERCATOR_MAX_LATITUDE, WGS84, WGS84Geodesic, WebMercator, WebMercatorZoomAltitudeConverter, applyGlMapUISettings, applyStyleMutations, bearingFromNativeHeading, bearingFromNativeRotation, bounceInterpolation, bridgeHolesIntoSingleRing, bridgeHolesIntoSingleRingWrapAware, buildPolygonRings, buildPolylineSegments, buildUnwrappedPolygonRings, buildUnwrappedPolylinePath, buildVisibleRegion, calculateMetersPerPixel, capabilityOfGesture, circleToRing, closeRing, closestPointOnSegment, combineHash, computeArea$1 as computeArea, computeDistanceBetween$1 as computeDistanceBetween, computeFitBoundsCameraPosition, computeHeading$1 as computeHeading, computeLength$1 as computeLength, computeOffset$1 as computeOffset, computeOffsetOrigin$1 as computeOffsetOrigin, computeSignedArea$1 as computeSignedArea, computeDistanceBetween as computeWGS84DistanceBetween, createCircleEntity, createCircleState, createCollectorMarkerRenderingSupport, createDefaultIcon, createFingerPrint, createGeoPoint, createGeoRectBounds, createGroundImageEntity, createGroundImageState, createHexCell, createHexCoord, createIdentifiedHexCell, createMapCameraPosition, createMapServiceKey, createMarkerEntity, createMarkerFingerPrint, createMarkerState, createOffset, createOppositeMeridianPoint, createOverlayHit, createPolygonEntity, createPolygonState, createPolylineEntity, createPolylineState, createRandomId, createRasterLayerEntity, createRasterLayerState, createSegmentBounds, createSubject, createTileWorkerHandler, densifyAndNormalize, densifyGeodesicRing, describeStyleMutations, expandBounds, fingerPrintEquals, fromGeoPoint, fromLatLng, fromLatLong, fromLngLat, fromLongLat, generateIdFromHashes, getNeighbors, hashBool, hashDefaultMarkerIcon, hashGeoPoint, hashNullable, hashNum, hashObj, hashStr, hexCellToIdPrefix, hexCoordToString, ingestMarkers, interpolate$1 as interpolate, interpolateAtMeridianGeodesic, interpolateAtMeridianLinear, interpolate as interpolateWGS84, isEmptyCameraRestriction, isFullySupported, isKnownUnsupported, isNativeDescribableStyle, isNativeMapExtensionCapable, isSlottedOverlayController, isUsable, longHashCode, mapCapabilityFromId, mapViewStateInternal, markerHitShape, markerIconExtentPx, markerIconHashCode, markerSearchBounds, normalizeLngDegrees, parseStyleMutations, parseTilePixelRatio, pickMarkerHit, pickMarkerIndex, preferMarkerHit, resolveAttributionRules, resolveCameraRestriction, resolveHoles, resolveMapUISettings, resolveRasterAttributions, reverseStyleMutation, segmentIntersectsRegion, splitByMeridian, splitRingByMeridian, styleMutationLayerId, styleMutationProperty, styleMutationTarget, tilePixelRatio, tilePixelRatioSuffix, toDegrees, toInt, toNativeHeading, toNativeRotation, toRadians, unionHoleRings, unionHoles, unionHolesInPlace, vectorStyleKey, viewportSizeOf, withRasterHeaderTransform, wrapClickedPoint }; +export { AbstractCircleOverlayRenderer, AbstractGroundImageOverlayRenderer, AbstractMarkerController, AbstractMarkerIcon, AbstractMarkerOverlayRenderer, AbstractMarkerRenderingStrategy, AbstractPolygonOverlayRenderer, AbstractPolylineOverlayRenderer, AbstractViewportStrategy, AbstractZoomAltitudeConverter, BLANK_MAP_STYLE, BLANK_MAP_STYLE_URL, BaseMapViewController, CANONICAL_ORDER, CircleController, CircleManager, CircleOverlay, CollectorMarkerOverlayRenderer, ColorDefaultIcon, DEFAULT_CIRCLE_SEGMENTS, DEFAULT_RASTER_LAYER_USER_AGENT, DefaultMarkerEventController, DefaultMarkerIcon, Direction6, Direction6Delta, DriverConformanceViolation, Earth, EmptyMapServiceRegistry, GROUND_IMAGE_DEFAULT_TILE_SIZE, GeoGridIndex, GeoPoint, GroundImageController, GroundImageManager, GroundImageOverlay, GroundScaleZoomAltitudeConverter, HexGeocellImpl, IconImageCache, ImageDefaultIcon, ImageIcon, InitState, LocalTileServer, MAP_CAPABILITIES, MARKER_HIT_RADIUS_MOUSE_PX, MARKER_HIT_RADIUS_TOUCH_PX, MARKER_HIT_SEARCH_RADIUS_PX, MARKER_RENDER_BATCH_SIZE, MAX_TILE_PIXEL_RATIO, MapCameraPosition, MapCapabilityStatus, MapDiagnostics, MapDriverConformance, MapOverlayRegistry, MapPaddings, MapProjection, MapProvider, MapProviderType, MapServiceRegistrations, MapUISettings, MapUISettingsDiagnostics, MapViewBase, MapViewHolderBase, MapViewState, MapViewStyleHost, MarkerAnimation, MarkerGrid, MarkerIconSize, MarkerManager, MarkerOverlay, MarkerRenderingSupportKey, MarkerTileRenderer, MarkerTilingOptions, MutableMapServiceRegistry, Mutex, NoCameraRestriction, OVERLAY_KINDS, OverlayCollector, OverlayGeoJson, OverlayHitResolver, Planar, PolygonController, PolygonManager, PolygonOverlay, PolylineController, PolylineManager, PolylineOverlay, RasterHeaderRuleSet, RasterLayerController, RasterLayerManager, RasterLayerOverlay, RasterLayerSource, RasterTilePreferenceKey, ScreenProjectionRequirement, Settings, Spherical, StrategyMarkerController, StyleMutationStore, TRANSPARENT_TILE_PNG, TileRenderFailedError, TileScheme, TileServerRegistry, VectorStyleAsDesign, VectorStyleMutationSupportKey, VectorStyleSupportKey, WEB_MERCATOR_MAX_EXTENT_METERS, WEB_MERCATOR_MAX_LATITUDE, WGS84, WGS84Geodesic, WebMercator, WebMercatorZoomAltitudeConverter, applyGlMapUISettings, applyStyleMutations, bearingFromNativeHeading, bearingFromNativeRotation, bounceInterpolation, bridgeHolesIntoSingleRing, bridgeHolesIntoSingleRingWrapAware, buildPolygonRings, buildPolylineSegments, buildUnwrappedPolygonRings, buildUnwrappedPolylinePath, buildVisibleRegion, calculateMetersPerPixel, capabilityOfGesture, circleToRing, closeRing, closestPointOnSegment, combineHash, computeArea$1 as computeArea, computeDistanceBetween$1 as computeDistanceBetween, computeFitBoundsCameraPosition, computeHeading$1 as computeHeading, computeLength$1 as computeLength, computeOffset$1 as computeOffset, computeOffsetOrigin$1 as computeOffsetOrigin, computeSignedArea$1 as computeSignedArea, computeDistanceBetween as computeWGS84DistanceBetween, createCircleEntity, createCircleState, createCollectorMarkerRenderingSupport, createDefaultIcon, createFingerPrint, createGeoPoint, createGeoRectBounds, createGroundImageEntity, createGroundImageState, createHexCell, createHexCoord, createIdentifiedHexCell, createMapCameraPosition, createMapServiceKey, createMarkerEntity, createMarkerFingerPrint, createMarkerState, createOffset, createOppositeMeridianPoint, createOverlayHit, createPolygonEntity, createPolygonState, createPolylineEntity, createPolylineState, createRandomId, createRasterLayerEntity, createRasterLayerState, createSegmentBounds, createSubject, createTileWorkerHandler, densifyAndNormalize, densifyPolygonRing, describeStyleMutations, expandBounds, fingerPrintEquals, fromGeoPoint, fromLatLng, fromLatLong, fromLngLat, fromLongLat, generateIdFromHashes, getNeighbors, hashBool, hashDefaultMarkerIcon, hashGeoPoint, hashNullable, hashNum, hashObj, hashStr, hexCellToIdPrefix, hexCoordToString, ingestMarkers, interpolate$1 as interpolate, interpolateAtMeridianGeodesic, interpolateAtMeridianLinear, interpolate as interpolateWGS84, isEmptyCameraRestriction, isFullySupported, isKnownUnsupported, isNativeDescribableStyle, isNativeMapExtensionCapable, isSlottedOverlayController, isUsable, longHashCode, mapCapabilityFromId, mapViewStateInternal, markerHitShape, markerIconExtentPx, markerIconHashCode, markerSearchBounds, normalizeLngDegrees, parseStyleMutations, parseTilePixelRatio, pickMarkerHit, pickMarkerIndex, preferMarkerHit, resolveAttributionRules, resolveCameraRestriction, resolveHoles, resolveMapUISettings, resolveRasterAttributions, reverseStyleMutation, segmentIntersectsRegion, splitByMeridian, splitRingByMeridian, styleMutationLayerId, styleMutationProperty, styleMutationTarget, tilePixelRatio, tilePixelRatioSuffix, toDegrees, toInt, toNativeHeading, toNativeRotation, toRadians, unionHoleRings, unionHoles, unionHolesInPlace, vectorStyleKey, viewportSizeOf, withRasterHeaderTransform, wrapClickedPoint }; export type { AddParams, AnyMarkerRenderingSupport, AttributionRule, BitmapIcon, CameraOptions, CameraRestriction, ChangeParams, ChangeParamsInterface, CircleAddParams, CircleCapable, CircleChangeParams, CircleEntity, CircleEvent, CircleFingerPrint, CircleManagerInterface, CircleOptions, CircleOverlayRenderer, CircleState, CircleStateCopyParams, CreateMarkerStateParams, DefaultMarkerIconOptions, FitBoundsCameraResult, GeoPointInterface, GeoRectBounds, GlGestureHandler, GlGestureHandlers, GlMarkerDragMap, GlTouchZoomRotateHandler, GroundImageAddParams, GroundImageCapable, GroundImageChangeParams, GroundImageEntity, GroundImageEvent, GroundImageFingerPrint, GroundImageManagerInterface, GroundImageOverlayRenderer, GroundImageState, GroundImageStateCopyParams, GroundOverlayOptions, HexCell, HexCoord, HexGeocell, HexGeocell as HexGeocellInterface, IdentifiedHexCell, ImageDefaultIconOptions, ImageIconOptions, ImageSource, InfoBubbleEntry, MapCameraPositionCopyParams, MapCameraPositionInterface, MapCapability, MapConfig, MapDesignTypeInterface, MapDiagnosticLevel, MapDiagnosticsSink, MapGesture, MapOverlayInterface, MapPaddingsInterface, MapServiceKey, MapServiceRegistration, MapServiceRegistry, MapStyleHost, MapStyleInstallation, MapViewBaseProps, MapViewControllerInterface, MapViewHolder, MapViewStateInterface, MapViewStateInternal, MapViewStateOptions, MapViewStyle, MarkerAnimationOverlayEntry, MarkerAnimationOverlayHost, MarkerCapable, MarkerEntity, MarkerEventController, MarkerEventHost, MarkerFingerPrint, MarkerHit, MarkerHitRect, MarkerIcon, MarkerIngestionResult, MarkerLabelHorizontalPosition, MarkerLabelPosition, MarkerLabelVerticalPosition, MarkerManagerStats, MarkerOptions, MarkerOverlayRenderer, MarkerRenderingStrategy, MarkerRenderingSupport, MarkerState, MarkerStateCopyParams, NativeDescribableStyle, NativeMapExtensionCapable, NativeMapExtensionDescriptor, NativeMapExtensionEvent, NativeMapExtensionEventHandler, NativeStyleDescriptor, Offset, OnCameraMoveHandler, OnCircleEventHandler, OnGroundImageEventHandler, OnMapEventHandler, OnMapInitializedHandler, OnMapLoadedHandler, OnMarkerEventHandler, OnPolygonEventHandler, OnPolylineEventHandler, OnRasterLayerEventHandler, OverlayController, OverlayControllerLike, OverlayHit, OverlayKind, OverlayRendererInterface, PolygonAddParams, PolygonCapable, PolygonChangeParams, PolygonEntity, PolygonEvent, PolygonFingerPrint, PolygonManagerInterface, PolygonOptions, PolygonOverlayRenderer, PolygonRings, PolygonState, PolygonStateCopyParams, PolylineAddParams, PolylineCapable, PolylineChangeParams, PolylineEntity, PolylineEvent, PolylineFingerPrint, PolylineHitResult, PolylineManagerInterface, PolylineOptions, PolylineOverlayRenderer, PolylineState, PolylineStateCopyParams, PreparedLabel, PreparedMarker, Projection, RasterAttributionRule, RasterHeaderRule, RasterHeaderSupport, RasterLayerAddParams, RasterLayerCapable, RasterLayerChangeParams, RasterLayerEntity, RasterLayerEvent, RasterLayerFingerPrint, RasterLayerManagerInterface, RasterLayerMounting, RasterLayerOverlayRenderer, RasterLayerState, RasterLayerStateCopyParams, RasterRequestParameters, RasterTilePreference, RasterTransformRequest, ScreenOffsetResult, Serializable, SlottedOverlayController, StyleMutation, StylePatchableMap, TileOutcome, TileProvider, TileRenderRequest, TileRenderResponse, TileRequest, VectorStyleMutationSupport, VectorStyleSupport, ViewportSize, VisibleRegion }; diff --git a/src/geometry/OverlayGeometry.ts b/src/geometry/OverlayGeometry.ts index fa9c606..7052323 100644 --- a/src/geometry/OverlayGeometry.ts +++ b/src/geometry/OverlayGeometry.ts @@ -33,28 +33,29 @@ export function densifyAndNormalize( } /** - * ポリゴンのリング(外周・穴)を測地線(大圏)で密度化する。 + * ポリゴンのリング(外周・穴)を、閉じる辺(末尾 → 先頭)も含めて密度化する。 * - * `WGS84Geodesic.createInterpolatePoints` は隣り合う点の間しか補間しないため、開いたリング - * (末尾 ≠ 先頭)を渡すと閉じる辺(末尾 → 先頭)が補間されず、描画側で閉じたときに直線の弦に + * geodesic なら大圏(`WGS84Geodesic`)、そうでなければ緯度経度の線形補間(`Planar`)。 + * どちらの `createInterpolatePoints` も隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)をそのまま渡すと閉じる辺だけが補間されず、描画側で閉じたときに直線の弦に * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で - * 閉じる辺の形が食い違う。ここでは閉じる辺も含めて補間する。 + * 閉じる辺の形が食い違う(geodesic=false でも、線形補間の辺はメルカトル上でわずかに曲がる)。 + * ここでは閉じる辺も他の辺と同じ方式で補間する。 * * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた * まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 */ -export function densifyGeodesicRing( +export function densifyPolygonRing( ring: GeoPoint[], + geodesic: boolean, maxSegmentLength: number = 10000.0, ): GeoPoint[] { - if (ring.length < 2 || isClosedRing(ring)) { - return WGS84Geodesic.createInterpolatePoints(ring, maxSegmentLength); - } - const densified = WGS84Geodesic.createInterpolatePoints( - [...ring, ring[0]], - maxSegmentLength, - ); - return densified.slice(0, -1); + const densify = (points: GeoPoint[]): GeoPoint[] => + geodesic + ? WGS84Geodesic.createInterpolatePoints(points, maxSegmentLength) + : Planar.createInterpolatePoints(points, maxSegmentLength); + if (ring.length < 2 || isClosedRing(ring)) return densify(ring); + return densify([...ring, ring[0]]).slice(0, -1); } function isClosedRing(ring: GeoPoint[]): boolean { @@ -64,16 +65,17 @@ function isClosedRing(ring: GeoPoint[]): boolean { } /** - * リング用の densifyAndNormalize。geodesic のときは閉じる辺も補間する(densifyGeodesicRing)。 - * geodesic=false は従来どおり densifyAndNormalize と同じ。 + * リング用の densifyAndNormalize。閉じる辺も補間する(densifyPolygonRing)。 + * densifyAndNormalize と同じく、maxSegmentLength は geodesic のときだけ使う(線形補間は 10 km)。 */ function densifyRingAndNormalize( ring: GeoPoint[], geodesic: boolean, maxSegmentLength: number = 10000.0, ): GeoPoint[] { - if (!geodesic) return densifyAndNormalize(ring, false, maxSegmentLength); - return densifyGeodesicRing(ring, maxSegmentLength).map((point) => point.normalize()); + return densifyPolygonRing(ring, geodesic, geodesic ? maxSegmentLength : 10000.0).map((point) => + point.normalize(), + ); } // ─── 分割版パイプライン(経度 ±180 に制約のある SDK 向け) ───────────────────── diff --git a/src/polygon/PolygonManager.ts b/src/polygon/PolygonManager.ts index 5d892c3..0d6388f 100644 --- a/src/polygon/PolygonManager.ts +++ b/src/polygon/PolygonManager.ts @@ -1,5 +1,5 @@ import { GeoPoint } from "../features"; -import { densifyGeodesicRing } from "../geometry/OverlayGeometry"; +import { densifyPolygonRing } from "../geometry/OverlayGeometry"; import { PolygonEntity } from "./PolygonEntity"; function normalizeLng(lng: number): number { @@ -67,7 +67,7 @@ export class PolygonManager implements PolygonManagerInterface implements PolygonManagerInterface { - const ring = densifyGeodesicRing(TRIANGLE); +test('densifyPolygonRing は閉じる辺も補間し、開いたリングのまま返す', () => { + const ring = densifyPolygonRing(TRIANGLE, true); assert.ok(same(ring[0], BOSTON)); // 先頭の重複点を末尾に付けない(閉じて補間した結果から末尾の 1 点だけを除いた長さ)。 // createInterpolatePoints は分割比を足し込むため、末尾の手前に終点とほぼ同じ点が残ることがある @@ -74,9 +75,19 @@ test('densifyGeodesicRing は閉じる辺も補間し、開いたリングのま assertSamePoints(tail, expected.slice(1, -1)); }); -test('densifyGeodesicRing は閉じたリングを閉じたまま返す(二重に閉じない)', () => { +test('densifyPolygonRing は閉じたリングを閉じたまま返す(二重に閉じない)', () => { const closed = [...TRIANGLE, BOSTON]; - assertSamePoints(densifyGeodesicRing(closed), WGS84Geodesic.createInterpolatePoints(closed)); + assertSamePoints(densifyPolygonRing(closed, true), WGS84Geodesic.createInterpolatePoints(closed)); + assertSamePoints(densifyPolygonRing(closed, false), Planar.createInterpolatePoints(closed)); +}); + +test('densifyPolygonRing は geodesic=false でも閉じる辺を線形補間する', () => { + const ring = densifyPolygonRing(TRIANGLE, false); + assert.ok(same(ring[0], BOSTON)); + assert.equal(ring.length, Planar.createInterpolatePoints([...TRIANGLE, BOSTON]).length - 1); + const expected = Planar.createInterpolatePoints([ICELAND, BOSTON]); + const tail = ring.slice(ring.length - (expected.length - 2)); + assertSamePoints(tail, expected.slice(1, -1)); }); test('geodesic の塗り(unwrap 版)は輪郭と同じ点列になる', () => { @@ -94,12 +105,17 @@ test('geodesic の塗り(分割版)も閉じる辺を補間する', () => { assertSamePoints(rings[0], outlineOf(TRIANGLE, true)); }); -test('geodesic=false は変わらない(閉じる辺を補間しない)', () => { +// geodesic=false の輪郭は閉じる辺も緯度経度で線形補間する(メルカトルではわずかに曲がる)。 +// 塗りも同じ点列であること。以前は閉じる辺だけ補間されず、メルカトル上の直線(弦)だった。 +test('geodesic=false の塗りも閉じる辺を含めて輪郭と同じ点列になる', () => { const fill = buildUnwrappedPolygonRings(TRIANGLE, [], false).outerRings[0]; - assertSamePoints(fill, densifyAndNormalize(TRIANGLE, false)); - assert.ok(same(fill[fill.length - 1], ICELAND)); + assertSamePoints(fill, outlineOf(TRIANGLE, false)); const split = buildPolygonRings(TRIANGLE, [], false).outerRings[0]; - assertSamePoints(split, densifyAndNormalize(TRIANGLE, false)); + assertSamePoints(split, outlineOf(TRIANGLE, false)); + // 閉じる辺(アイスランド → ボストン)の緯度経度の中点が点列に含まれる(弦の上ではない)。 + const mid = p((ICELAND.latitude + BOSTON.latitude) / 2, (ICELAND.longitude + BOSTON.longitude) / 2); + const nearest = Math.min(...fill.map((q) => Math.hypot(q.latitude - mid.latitude, q.longitude - mid.longitude))); + assert.ok(nearest < 0.1, `nearest ${nearest}`); }); test('穴も閉じる辺を補間する', () => { @@ -110,8 +126,15 @@ test('穴も閉じる辺を補間する', () => { assertSamePoints(unwrapped.outerRings[0], outlineOf(outer, true)); const split = buildPolygonRings(outer, [hole], true); assertSamePoints(split.holeRings[0], outlineOf(hole, true)); - // false の穴は従来どおり。 - assertSamePoints(buildUnwrappedPolygonRings(outer, [hole], false).holeRings[0], densifyAndNormalize(hole, false)); +}); + +test('geodesic=false の穴も閉じる辺を補間する', () => { + const outer = [p(30, -80), p(30, 10), p(70, 10), p(70, -80)]; + const hole = TRIANGLE; + const unwrapped = buildUnwrappedPolygonRings(outer, [hole], false); + assertSamePoints(unwrapped.holeRings[0], outlineOf(hole, false)); + assertSamePoints(unwrapped.outerRings[0], outlineOf(outer, false)); + assertSamePoints(buildPolygonRings(outer, [hole], false).holeRings[0], outlineOf(hole, false)); }); test('タップ判定も閉じる辺の大圏に従う', () => { @@ -129,3 +152,21 @@ test('タップ判定も閉じる辺の大圏に従う', () => { geodesic.geodesic = false; assert.equal(manager.find(bulge), null); }); + +// geodesic=false のタップ判定は生の頂点を緯度経度平面で判定する。閉じる辺は緯度経度の直線 +// (= 線形補間した塗り・輪郭と同じ線)になり、メルカトルの弦ではない。 +test('geodesic=false のタップ判定は閉じる辺の線形補間に従う', () => { + const midLng = (ICELAND.longitude + BOSTON.longitude) / 2; + const linearLat = (ICELAND.latitude + BOSTON.latitude) / 2; + const merc = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); + const chordLat = ((2 * Math.atan(Math.exp((merc(ICELAND.latitude) + merc(BOSTON.latitude)) / 2)) - Math.PI / 2) * 180) / Math.PI; + // メルカトルの弦は緯度経度の直線より北を通る。その間は塗りの外。 + assert.ok(chordLat > linearLat + 0.5, `chord ${chordLat} linear ${linearLat}`); + const between = p((chordLat + linearLat) / 2, midLng); + const inside = p(linearLat - 0.5, midLng); + + const manager = new PolygonManager(); + manager.registerEntity({ state: createPolygonState({ id: 'p', points: TRIANGLE, geodesic: false }) }); + assert.equal(manager.find(between), null); + assert.equal(manager.find(inside)?.state.id, 'p'); +});