diff --git a/api/js-sdk-core.api.d.ts b/api/js-sdk-core.api.d.ts index 1391977..206f03d 100644 --- a/api/js-sdk-core.api.d.ts +++ b/api/js-sdk-core.api.d.ts @@ -5382,6 +5382,20 @@ interface PolygonRings { } /** geodesic に応じた補間で点列を密度化し、緯度経度を正規化して返す。 */ declare function densifyAndNormalize(points: GeoPoint[], geodesic: boolean, maxSegmentLength?: number): GeoPoint[]; +/** + * ポリゴンのリング(外周・穴)を、閉じる辺(末尾 → 先頭)も含めて密度化する。 + * + * geodesic なら大圏(`WGS84Geodesic`)、そうでなければ緯度経度の線形補間(`Planar`)。 + * どちらの `createInterpolatePoints` も隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)をそのまま渡すと閉じる辺だけが補間されず、描画側で閉じたときに直線の弦に + * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で + * 閉じる辺の形が食い違う(geodesic=false でも、線形補間の辺はメルカトル上でわずかに曲がる)。 + * ここでは閉じる辺も他の辺と同じ方式で補間する。 + * + * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた + * まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 + */ +declare function densifyPolygonRing(ring: GeoPoint[], geodesic: boolean, maxSegmentLength?: number): GeoPoint[]; /** * ポリライン用パイプライン(分割版)。密度化・正規化後に子午線で分割したセグメント列を返す。 * 頂点 2 未満の入力、および分割で 2 点未満になったセグメントは除く(空配列になり得る)。 @@ -5606,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, 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 2ea53e8..7052323 100644 --- a/src/geometry/OverlayGeometry.ts +++ b/src/geometry/OverlayGeometry.ts @@ -32,6 +32,52 @@ export function densifyAndNormalize( return interpolated.map((point) => point.normalize()); } +/** + * ポリゴンのリング(外周・穴)を、閉じる辺(末尾 → 先頭)も含めて密度化する。 + * + * geodesic なら大圏(`WGS84Geodesic`)、そうでなければ緯度経度の線形補間(`Planar`)。 + * どちらの `createInterpolatePoints` も隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)をそのまま渡すと閉じる辺だけが補間されず、描画側で閉じたときに直線の弦に + * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で + * 閉じる辺の形が食い違う(geodesic=false でも、線形補間の辺はメルカトル上でわずかに曲がる)。 + * ここでは閉じる辺も他の辺と同じ方式で補間する。 + * + * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた + * まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 + */ +export function densifyPolygonRing( + ring: GeoPoint[], + geodesic: boolean, + maxSegmentLength: number = 10000.0, +): GeoPoint[] { + 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 { + const first = ring[0]; + const last = ring[ring.length - 1]; + return first.latitude === last.latitude && first.longitude === last.longitude; +} + +/** + * リング用の densifyAndNormalize。閉じる辺も補間する(densifyPolygonRing)。 + * densifyAndNormalize と同じく、maxSegmentLength は geodesic のときだけ使う(線形補間は 10 km)。 + */ +function densifyRingAndNormalize( + ring: GeoPoint[], + geodesic: boolean, + maxSegmentLength: number = 10000.0, +): GeoPoint[] { + return densifyPolygonRing(ring, geodesic, geodesic ? maxSegmentLength : 10000.0).map((point) => + point.normalize(), + ); +} + // ─── 分割版パイプライン(経度 ±180 に制約のある SDK 向け) ───────────────────── // // 経度 ±180 を超える座標を受け付けない SDK は、密度化・正規化後に子午線で分割した複数 @@ -66,14 +112,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 +174,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..0d6388f 100644 --- a/src/polygon/PolygonManager.ts +++ b/src/polygon/PolygonManager.ts @@ -1,5 +1,5 @@ import { GeoPoint } from "../features"; -import { WGS84Geodesic } from "../spherical"; +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 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('densifyPolygonRing は閉じる辺も補間し、開いたリングのまま返す', () => { + const ring = densifyPolygonRing(TRIANGLE, true); + 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('densifyPolygonRing は閉じたリングを閉じたまま返す(二重に閉じない)', () => { + const closed = [...TRIANGLE, BOSTON]; + 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 版)は輪郭と同じ点列になる', () => { + 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)); +}); + +// geodesic=false の輪郭は閉じる辺も緯度経度で線形補間する(メルカトルではわずかに曲がる)。 +// 塗りも同じ点列であること。以前は閉じる辺だけ補間されず、メルカトル上の直線(弦)だった。 +test('geodesic=false の塗りも閉じる辺を含めて輪郭と同じ点列になる', () => { + const fill = buildUnwrappedPolygonRings(TRIANGLE, [], false).outerRings[0]; + assertSamePoints(fill, outlineOf(TRIANGLE, false)); + const split = buildPolygonRings(TRIANGLE, [], false).outerRings[0]; + 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('穴も閉じる辺を補間する', () => { + 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)); +}); + +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('タップ判定も閉じる辺の大圏に従う', () => { + // 弦と大圏の間(北西へ膨らんだ部分)の点。 + 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); +}); + +// 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'); +});