From 63b4bccb0b3089d40d39f52c2e640ca931d6739c Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 22 Oct 2026 18:53:56 -0700 Subject: [PATCH] Add labelPosition to the default marker icon and pick the marker drawn under the tap --- api/js-sdk-core.api.d.ts | 54 +++++--- src/marker/AbstractDefaultIcon.ts | 167 +++++++++++++++++++++---- src/marker/ColorDefaultIcon.ts | 37 ++++-- src/marker/DefaultIconGeometry.ts | 83 ++++++++++-- src/marker/DefaultIconTypes.ts | 20 +++ src/marker/MarkerHitPick.ts | 122 ++++++++++++++++++ src/marker/MarkerIconExtent.ts | 23 ++++ src/marker/MarkerTileRenderer.ts | 67 +++++----- src/marker/StrategyMarkerController.ts | 53 ++++---- src/marker/index.ts | 2 + test/DefaultIconLabelPosition.test.mjs | 84 +++++++++++++ test/MarkerHitPick.test.mjs | 43 +++++++ 12 files changed, 638 insertions(+), 117 deletions(-) create mode 100644 src/marker/MarkerHitPick.ts create mode 100644 src/marker/MarkerIconExtent.ts create mode 100644 test/DefaultIconLabelPosition.test.mjs create mode 100644 test/MarkerHitPick.test.mjs diff --git a/api/js-sdk-core.api.d.ts b/api/js-sdk-core.api.d.ts index bcc05de..08a5ad8 100644 --- a/api/js-sdk-core.api.d.ts +++ b/api/js-sdk-core.api.d.ts @@ -1015,13 +1015,6 @@ declare abstract class MapViewState void) | null): void; - /** - * ズーム・ベアリング・チルトがすべて 0 の「未指定」カメラは、位置だけを差し替える。 - * - * アプリが位置だけを渡してきたときに、いまの縮尺を保ったまま移動するための救済。 - * 全プロバイダが同じ判定をしていた(react-for-arcgis だけ抜けていたのでここで揃う)。 - */ - private resolveCameraPosition; } interface FitBoundsCameraResult { @@ -2165,6 +2158,18 @@ declare abstract class AbstractMarkerRenderingStrategy implements * オプション群(fillColor を含む全パラメータを 1 つのオブジェクトで受ける)。 * `labelTypeFace` は Android の Typeface / iOS の UIFont に対応する Web の font-family 文字列。 */ +/** + * ラベルをピンに対してどこへ置くか。CSS の `background-position` と同じ書き方で、 + * 横(left / center / right)と縦(top / center / bottom)のキーワードを空白区切りで並べる。 + * 順序は問わず、片方だけなら残りは `center`。 + * + * - `left` / `right` はピンの外側に置き、縦はピンの上端・頭の中心・先端に揃える。 + * - `center` 列の `top` / `bottom` はピンの上・下の外側に置く。 + * - `"center center"`(既定)はピンの頭の中に重ねる(従来どおり)。 + */ +type MarkerLabelHorizontalPosition = "left" | "center" | "right"; +type MarkerLabelVerticalPosition = "top" | "center" | "bottom"; +type MarkerLabelPosition = MarkerLabelHorizontalPosition | MarkerLabelVerticalPosition | `${MarkerLabelHorizontalPosition} ${MarkerLabelVerticalPosition}` | `${MarkerLabelVerticalPosition} ${MarkerLabelHorizontalPosition}`; /** * 既定マーカーアイコンの見た目を決めるオプションと、その既定値。 * @@ -2181,6 +2186,7 @@ interface DefaultMarkerIconOptions { labelTextSize?: number; labelTypeFace?: string; labelStrokeColor?: string; + labelPosition?: MarkerLabelPosition; infoAnchor?: Offset; iconSize?: number; debug?: boolean; @@ -2198,6 +2204,7 @@ interface BaseIconProperties { labelTextSize: number; labelTypeFace: string; labelStrokeColor: string; + labelPosition: MarkerLabelPosition; infoAnchor: Offset; iconSize: number; debug: boolean; @@ -2216,10 +2223,14 @@ interface IconLayout { bitmapWidth: number; /** ビットマップ高さ(= markerSize)。 */ bitmapHeight: number; - /** マーカーを中央寄せするための水平オフセット。 */ + /** ビットマップ内でのマーカー本体の左上。 */ markerOffsetX: number; + markerOffsetY: number; /** ラベルのアウトライン幅(Android: max(1*scale, 2))。 */ outlineStroke: number; + /** ラベル文字列の中心(text-anchor="middle" / dominant-baseline="central" で描く)。 */ + labelCenterX: number; + labelCenterY: number; } /** @@ -2228,8 +2239,13 @@ interface IconLayout { */ declare abstract class AbstractDefaultIcon extends AbstractMarkerIcon { protected readonly baseProperties: BaseIconProperties; - readonly anchor: Offset; + private cachedLayout; protected constructor(baseProperties: BaseIconProperties); + /** + * ピン先端の位置(ビットマップに対する割合)。ラベルをピンの横や上に置くと + * ビットマップが片側へ伸びるので、固定の (0.5, 1) ではなくレイアウトから求める。 + */ + get anchor(): Offset; get strokeColor(): string; get strokeWidth(): number; get scale(): number; @@ -2238,7 +2254,12 @@ declare abstract class AbstractDefaultIcon extends AbstractMarkerIcon { get labelTextSize(): number; get labelTypeFace(): string; get labelStrokeColor(): string; + get labelPosition(): MarkerLabelPosition; get iconSize(): number; + /** + * 指定値はピン本体に対する割合として扱い、ビットマップに対する割合へ直して返す。 + * ラベルが中央にある限りは指定値と一致する(従来どおり)。 + */ get infoAnchor(): Offset; get debug(): boolean; toBitmapIcon(): BitmapIcon; @@ -2248,15 +2269,18 @@ declare abstract class AbstractDefaultIcon extends AbstractMarkerIcon { * すべてピクセル空間で行い、ラベルが広い場合はビットマップを横に広げてマーカーを * 中央寄せする(正方形固定にしない)。ラベルの文字サイズはマーカー scale に追従せず * 絶対値(Android/iOS と同じ)。 + * + * `labelPosition` が中央以外なら、ラベルをピンの輪郭の外側へ置き、ビットマップを + * その側へだけ伸ばす。アイコンは不変なので結果は 1 回だけ計算して使い回す。 */ protected computeLayout(): IconLayout; protected createSvg(layout: IconLayout): string; /** - * ラベル描画。Android drawLabel() と同じく円形部分の中心(markerSize * 0.35)へ - * 通常ウェイト・絶対サイズで配置し、アウトライン(stroke)を下に敷いてから塗りを重ねる。 + * ラベル描画。既定では Android drawLabel() と同じく円形部分の中心(markerSize * 0.35)へ + * 通常ウェイト・絶対サイズで配置し(位置は computeLayout が決める)、アウトライン(stroke)を下に敷いてから塗りを重ねる。 */ protected createLabelSvg(layout: IconLayout): string; - protected abstract createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number): string; + protected abstract createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number, markerOffsetY: number): string; protected abstract getUniqueProperties(): unknown; } @@ -2284,7 +2308,7 @@ declare class ImageDefaultIcon extends AbstractDefaultIcon { */ toBitmapIcon(): BitmapIcon; private toCanvasBitmapIcon; - protected createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number): string; + protected createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number, markerOffsetY: number): string; protected getUniqueProperties(): unknown; private imageSourceUrl; } @@ -2706,7 +2730,7 @@ declare class MarkerTileRenderer; /** - * Find the nearest item to `click` within `hitRadiusPx` pixels at the given zoom level. + * Find the item under `click` at the given zoom level, or the nearest one within `hitRadiusPx`. * * Use `MARKER_HIT_RADIUS_TOUCH_PX` for finger taps and `MARKER_HIT_RADIUS_MOUSE_PX` * for mouse/pen, or supply a custom radius. Returns `null` when nothing is close enough. @@ -4495,4 +4519,4 @@ 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, type AddParams, type AnyMarkerRenderingSupport, type AttributionRule, BaseMapViewController, type BitmapIcon, CANONICAL_ORDER, type CameraOptions, type CameraRestriction, type ChangeParams, type ChangeParamsInterface, type CircleAddParams, type CircleCapable, type CircleChangeParams, CircleController, type CircleEntity, type CircleEvent, type CircleFingerPrint, CircleManager, type CircleManagerInterface, type CircleOptions, CircleOverlay, type CircleOverlayRenderer, type CircleState, type CircleStateCopyParams, CollectorMarkerOverlayRenderer, ColorDefaultIcon, type CreateMarkerStateParams, DEFAULT_CIRCLE_SEGMENTS, DEFAULT_RASTER_LAYER_USER_AGENT, DefaultMarkerEventController, DefaultMarkerIcon, type DefaultMarkerIconOptions, Direction6, Direction6Delta, DriverConformanceViolation, Earth, EmptyMapServiceRegistry, type FitBoundsCameraResult, GROUND_IMAGE_DEFAULT_TILE_SIZE, GeoGridIndex, GeoPoint, type GeoPointInterface, type GeoRectBounds, type GlGestureHandler, type GlGestureHandlers, type GlMarkerDragMap, type GlTouchZoomRotateHandler, type GroundImageAddParams, type GroundImageCapable, type GroundImageChangeParams, GroundImageController, type GroundImageEntity, type GroundImageEvent, type GroundImageFingerPrint, GroundImageManager, type GroundImageManagerInterface, GroundImageOverlay, type GroundImageOverlayRenderer, type GroundImageState, type GroundImageStateCopyParams, type GroundOverlayOptions, GroundScaleZoomAltitudeConverter, type HexCell, HexCellRegistry, type HexCellWithDistance, type HexCoord, type HexGeocell, HexGeocellImpl, type HexGeocell as HexGeocellInterface, IconImageCache, type IdentifiedHexCell, ImageDefaultIcon, type ImageDefaultIconOptions, ImageIcon, type ImageIconOptions, type ImageSource, type InfoBubbleEntry, InitState, KDTree, type KDTreeStats, LocalTileServer, MAP_CAPABILITIES, MARKER_HIT_RADIUS_MOUSE_PX, MARKER_HIT_RADIUS_TOUCH_PX, MARKER_RENDER_BATCH_SIZE, MapCameraPosition, type MapCameraPositionCopyParams, type MapCameraPositionInterface, type MapCapability, MapCapabilityStatus, type MapConfig, type MapDesignTypeInterface, type MapDiagnosticLevel, MapDiagnostics, type MapDiagnosticsSink, MapDriverConformance, type MapGesture, type MapOverlayInterface, MapOverlayRegistry, MapPaddings, type MapPaddingsInterface, MapProjection, MapProvider, MapProviderType, type MapServiceKey, type MapServiceRegistration, MapServiceRegistrations, type MapServiceRegistry, MapUISettings, MapUISettingsDiagnostics, MapViewBase, type MapViewBaseProps, type MapViewControllerInterface, type MapViewHolder, MapViewHolderBase, MapViewState, type MapViewStateInterface, type MapViewStateInternal, type MapViewStateOptions, MarkerAnimation, type MarkerAnimationOverlayEntry, type MarkerAnimationOverlayHost, type MarkerCapable, type MarkerEntity, type MarkerEventController, type MarkerEventHost, type MarkerFingerPrint, type MarkerIcon, MarkerIconSize, type MarkerIngestionResult, MarkerManager, type MarkerManagerStats, type MarkerOptions, MarkerOverlay, type MarkerOverlayRenderer, type MarkerRenderingStrategy, type MarkerRenderingSupport, MarkerRenderingSupportKey, type MarkerState, type MarkerStateCopyParams, MarkerTileRenderer, MarkerTilingOptions, MutableMapServiceRegistry, Mutex, type NativeMapExtensionCapable, type NativeMapExtensionDescriptor, type NativeMapExtensionEvent, type NativeMapExtensionEventHandler, NoCameraRestriction, OVERLAY_KINDS, type Offset, type OnCameraMoveHandler, type OnCircleEventHandler, type OnGroundImageEventHandler, type OnMapEventHandler, type OnMapInitializedHandler, type OnMapLoadedHandler, type OnMarkerEventHandler, type OnPolygonEventHandler, type OnPolylineEventHandler, type OnRasterLayerEventHandler, OverlayCollector, type OverlayController, type OverlayControllerLike, OverlayGeoJson, type OverlayHit, OverlayHitResolver, type OverlayKind, type OverlayRendererInterface, Planar, type PolygonAddParams, type PolygonCapable, type PolygonChangeParams, PolygonController, type PolygonEntity, type PolygonEvent, type PolygonFingerPrint, PolygonManager, type PolygonManagerInterface, type PolygonOptions, PolygonOverlay, type PolygonOverlayRenderer, type PolygonRings, type PolygonState, type PolygonStateCopyParams, type PolylineAddParams, type PolylineCapable, type PolylineChangeParams, PolylineController, type PolylineEntity, type PolylineEvent, type PolylineFingerPrint, type PolylineHitResult, PolylineManager, type PolylineManagerInterface, type PolylineOptions, PolylineOverlay, type PolylineOverlayRenderer, type PolylineState, type PolylineStateCopyParams, type PreparedMarker, type Projection, type RasterAttributionRule, type RasterHeaderRule, RasterHeaderRuleSet, type RasterHeaderSupport, type RasterLayerAddParams, type RasterLayerCapable, type RasterLayerChangeParams, RasterLayerController, type RasterLayerEntity, type RasterLayerEvent, type RasterLayerFingerPrint, RasterLayerManager, type RasterLayerManagerInterface, RasterLayerOverlay, type RasterLayerOverlayRenderer, RasterLayerSource, type RasterLayerState, type RasterLayerStateCopyParams, type RasterRequestParameters, type RasterTransformRequest, type RegistryStats, type ScreenOffsetResult, ScreenProjectionRequirement, type Serializable, Settings, type SlottedOverlayController, type SpatialIndex, type SpatialIndexConfig, Spherical, StrategyMarkerController, type TileProvider, type TileRenderRequest, type TileRenderResponse, type TileRequest, TileScheme, TileServerRegistry, type ViewportSize, type VisibleRegion, WEB_MERCATOR_MAX_EXTENT_METERS, WGS84, WGS84Geodesic, WebMercator, WebMercatorZoomAltitudeConverter, applyGlMapUISettings, 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, createHexCellWithDistance, createHexCoord, createIdentifiedHexCell, createMapCameraPosition, createMapServiceKey, createMarkerEntity, createMarkerFingerPrint, createMarkerState, createOffset, createOppositeMeridianPoint, createOverlayHit, createPolygonEntity, createPolygonState, createPolylineEntity, createPolylineState, createRandomId, createRasterLayerEntity, createRasterLayerState, createSegmentBounds, createSubject, createTileWorkerHandler, densifyAndNormalize, 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, isNativeMapExtensionCapable, isSlottedOverlayController, isUsable, longHashCode, mapCapabilityFromId, mapViewStateInternal, markerIconHashCode, normalizeLngDegrees, resolveAttributionRules, resolveCameraRestriction, resolveHoles, resolveMapUISettings, resolveRasterAttributions, segmentIntersectsRegion, splitByMeridian, splitRingByMeridian, toDegrees, toInt, toNativeHeading, toNativeRotation, toRadians, unionHoleRings, unionHoles, unionHolesInPlace, viewportSizeOf, withRasterHeaderTransform, wrapClickedPoint }; +export { AbstractCircleOverlayRenderer, AbstractGroundImageOverlayRenderer, AbstractMarkerController, AbstractMarkerIcon, AbstractMarkerOverlayRenderer, AbstractMarkerRenderingStrategy, AbstractPolygonOverlayRenderer, AbstractPolylineOverlayRenderer, AbstractViewportStrategy, AbstractZoomAltitudeConverter, type AddParams, type AnyMarkerRenderingSupport, type AttributionRule, BaseMapViewController, type BitmapIcon, CANONICAL_ORDER, type CameraOptions, type CameraRestriction, type ChangeParams, type ChangeParamsInterface, type CircleAddParams, type CircleCapable, type CircleChangeParams, CircleController, type CircleEntity, type CircleEvent, type CircleFingerPrint, CircleManager, type CircleManagerInterface, type CircleOptions, CircleOverlay, type CircleOverlayRenderer, type CircleState, type CircleStateCopyParams, CollectorMarkerOverlayRenderer, ColorDefaultIcon, type CreateMarkerStateParams, DEFAULT_CIRCLE_SEGMENTS, DEFAULT_RASTER_LAYER_USER_AGENT, DefaultMarkerEventController, DefaultMarkerIcon, type DefaultMarkerIconOptions, Direction6, Direction6Delta, DriverConformanceViolation, Earth, EmptyMapServiceRegistry, type FitBoundsCameraResult, GROUND_IMAGE_DEFAULT_TILE_SIZE, GeoGridIndex, GeoPoint, type GeoPointInterface, type GeoRectBounds, type GlGestureHandler, type GlGestureHandlers, type GlMarkerDragMap, type GlTouchZoomRotateHandler, type GroundImageAddParams, type GroundImageCapable, type GroundImageChangeParams, GroundImageController, type GroundImageEntity, type GroundImageEvent, type GroundImageFingerPrint, GroundImageManager, type GroundImageManagerInterface, GroundImageOverlay, type GroundImageOverlayRenderer, type GroundImageState, type GroundImageStateCopyParams, type GroundOverlayOptions, GroundScaleZoomAltitudeConverter, type HexCell, HexCellRegistry, type HexCellWithDistance, type HexCoord, type HexGeocell, HexGeocellImpl, type HexGeocell as HexGeocellInterface, IconImageCache, type IdentifiedHexCell, ImageDefaultIcon, type ImageDefaultIconOptions, ImageIcon, type ImageIconOptions, type ImageSource, type InfoBubbleEntry, InitState, KDTree, type KDTreeStats, LocalTileServer, MAP_CAPABILITIES, MARKER_HIT_RADIUS_MOUSE_PX, MARKER_HIT_RADIUS_TOUCH_PX, MARKER_HIT_SEARCH_RADIUS_PX, MARKER_RENDER_BATCH_SIZE, MapCameraPosition, type MapCameraPositionCopyParams, type MapCameraPositionInterface, type MapCapability, MapCapabilityStatus, type MapConfig, type MapDesignTypeInterface, type MapDiagnosticLevel, MapDiagnostics, type MapDiagnosticsSink, MapDriverConformance, type MapGesture, type MapOverlayInterface, MapOverlayRegistry, MapPaddings, type MapPaddingsInterface, MapProjection, MapProvider, MapProviderType, type MapServiceKey, type MapServiceRegistration, MapServiceRegistrations, type MapServiceRegistry, MapUISettings, MapUISettingsDiagnostics, MapViewBase, type MapViewBaseProps, type MapViewControllerInterface, type MapViewHolder, MapViewHolderBase, MapViewState, type MapViewStateInterface, type MapViewStateInternal, type MapViewStateOptions, MarkerAnimation, type MarkerAnimationOverlayEntry, type MarkerAnimationOverlayHost, type MarkerCapable, type MarkerEntity, type MarkerEventController, type MarkerEventHost, type MarkerFingerPrint, type MarkerHit, type MarkerHitRect, type MarkerIcon, MarkerIconSize, type MarkerIngestionResult, type MarkerLabelHorizontalPosition, type MarkerLabelPosition, type MarkerLabelVerticalPosition, MarkerManager, type MarkerManagerStats, type MarkerOptions, MarkerOverlay, type MarkerOverlayRenderer, type MarkerRenderingStrategy, type MarkerRenderingSupport, MarkerRenderingSupportKey, type MarkerState, type MarkerStateCopyParams, MarkerTileRenderer, MarkerTilingOptions, MutableMapServiceRegistry, Mutex, type NativeMapExtensionCapable, type NativeMapExtensionDescriptor, type NativeMapExtensionEvent, type NativeMapExtensionEventHandler, NoCameraRestriction, OVERLAY_KINDS, type Offset, type OnCameraMoveHandler, type OnCircleEventHandler, type OnGroundImageEventHandler, type OnMapEventHandler, type OnMapInitializedHandler, type OnMapLoadedHandler, type OnMarkerEventHandler, type OnPolygonEventHandler, type OnPolylineEventHandler, type OnRasterLayerEventHandler, OverlayCollector, type OverlayController, type OverlayControllerLike, OverlayGeoJson, type OverlayHit, OverlayHitResolver, type OverlayKind, type OverlayRendererInterface, Planar, type PolygonAddParams, type PolygonCapable, type PolygonChangeParams, PolygonController, type PolygonEntity, type PolygonEvent, type PolygonFingerPrint, PolygonManager, type PolygonManagerInterface, type PolygonOptions, PolygonOverlay, type PolygonOverlayRenderer, type PolygonRings, type PolygonState, type PolygonStateCopyParams, type PolylineAddParams, type PolylineCapable, type PolylineChangeParams, PolylineController, type PolylineEntity, type PolylineEvent, type PolylineFingerPrint, type PolylineHitResult, PolylineManager, type PolylineManagerInterface, type PolylineOptions, PolylineOverlay, type PolylineOverlayRenderer, type PolylineState, type PolylineStateCopyParams, type PreparedMarker, type Projection, type RasterAttributionRule, type RasterHeaderRule, RasterHeaderRuleSet, type RasterHeaderSupport, type RasterLayerAddParams, type RasterLayerCapable, type RasterLayerChangeParams, RasterLayerController, type RasterLayerEntity, type RasterLayerEvent, type RasterLayerFingerPrint, RasterLayerManager, type RasterLayerManagerInterface, RasterLayerOverlay, type RasterLayerOverlayRenderer, RasterLayerSource, type RasterLayerState, type RasterLayerStateCopyParams, type RasterRequestParameters, type RasterTransformRequest, type RegistryStats, type ScreenOffsetResult, ScreenProjectionRequirement, type Serializable, Settings, type SlottedOverlayController, type SpatialIndex, type SpatialIndexConfig, Spherical, StrategyMarkerController, type TileProvider, type TileRenderRequest, type TileRenderResponse, type TileRequest, TileScheme, TileServerRegistry, type ViewportSize, type VisibleRegion, WEB_MERCATOR_MAX_EXTENT_METERS, WGS84, WGS84Geodesic, WebMercator, WebMercatorZoomAltitudeConverter, applyGlMapUISettings, 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, createHexCellWithDistance, createHexCoord, createIdentifiedHexCell, createMapCameraPosition, createMapServiceKey, createMarkerEntity, createMarkerFingerPrint, createMarkerState, createOffset, createOppositeMeridianPoint, createOverlayHit, createPolygonEntity, createPolygonState, createPolylineEntity, createPolylineState, createRandomId, createRasterLayerEntity, createRasterLayerState, createSegmentBounds, createSubject, createTileWorkerHandler, densifyAndNormalize, 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, isNativeMapExtensionCapable, isSlottedOverlayController, isUsable, longHashCode, mapCapabilityFromId, mapViewStateInternal, markerIconExtentPx, markerIconHashCode, markerSearchBounds, normalizeLngDegrees, pickMarkerHit, pickMarkerIndex, preferMarkerHit, resolveAttributionRules, resolveCameraRestriction, resolveHoles, resolveMapUISettings, resolveRasterAttributions, segmentIntersectsRegion, splitByMeridian, splitRingByMeridian, toDegrees, toInt, toNativeHeading, toNativeRotation, toRadians, unionHoleRings, unionHoles, unionHolesInPlace, viewportSizeOf, withRasterHeaderTransform, wrapClickedPoint }; diff --git a/src/marker/AbstractDefaultIcon.ts b/src/marker/AbstractDefaultIcon.ts index 03cab29..77c2b76 100644 --- a/src/marker/AbstractDefaultIcon.ts +++ b/src/marker/AbstractDefaultIcon.ts @@ -8,9 +8,11 @@ import { BitmapIcon } from "./MarkerOverlayRenderer"; * オプション群(fillColor を含む全パラメータを 1 つのオブジェクトで受ける)。 * `labelTypeFace` は Android の Typeface / iOS の UIFont に対応する Web の font-family 文字列。 */ -import { type BaseIconProperties } from "./DefaultIconTypes"; +import { type BaseIconProperties, type MarkerLabelPosition } from "./DefaultIconTypes"; import { + computeMarkerPinBounds, createMarkerPathData, + parseLabelPosition, escapeXml, measureLabelWidth, fmt, @@ -24,12 +26,24 @@ import { */ export abstract class AbstractDefaultIcon extends AbstractMarkerIcon { - readonly anchor: Offset = { x: 0.5, y: 1 }; + private cachedLayout: IconLayout | null = null; protected constructor(protected readonly baseProperties: BaseIconProperties) { super(); } + /** + * ピン先端の位置(ビットマップに対する割合)。ラベルをピンの横や上に置くと + * ビットマップが片側へ伸びるので、固定の (0.5, 1) ではなくレイアウトから求める。 + */ + get anchor(): Offset { + const layout = this.computeLayout(); + return { + x: (layout.markerOffsetX + layout.markerSize / 2) / layout.bitmapWidth, + y: (layout.markerOffsetY + layout.markerSize) / layout.bitmapHeight, + }; + } + get strokeColor(): string { return this.baseProperties.strokeColor; } @@ -62,14 +76,51 @@ export abstract class AbstractDefaultIcon extends AbstractMarkerIcon { return this.baseProperties.labelStrokeColor; } + get labelPosition(): MarkerLabelPosition { + return this.baseProperties.labelPosition; + } + get iconSize(): number { return this.baseProperties.iconSize; } - get infoAnchor(): Offset { + /** + * 指定されたままの infoAnchor(ピン本体に対する割合)。`infoAnchor` はビットマップに対する + * 割合へ直した値なので、ネイティブへ渡して描き直させるときはこちらを使う(二重に変換しない)。 + * + * @internal + */ + get requestedInfoAnchor(): Offset { return this.baseProperties.infoAnchor; } + /** + * ビットマップの幅と高さが、ピン本体(iconSize × scale の正方形)の何倍か。 + * ラベルを横や上に置くと 1 を超える。当たり判定がアイコンの実寸を使うためのもの。 + * + * @internal + */ + get extentRatio(): { width: number; height: number } { + const layout = this.computeLayout(); + return { + width: layout.bitmapWidth / layout.markerSize, + height: layout.bitmapHeight / layout.markerSize, + }; + } + + /** + * 指定値はピン本体に対する割合として扱い、ビットマップに対する割合へ直して返す。 + * ラベルが中央にある限りは指定値と一致する(従来どおり)。 + */ + get infoAnchor(): Offset { + const layout = this.computeLayout(); + const { x, y } = this.baseProperties.infoAnchor; + return { + x: (layout.markerOffsetX + x * layout.markerSize) / layout.bitmapWidth, + y: (layout.markerOffsetY + y * layout.markerSize) / layout.bitmapHeight, + }; + } + get debug(): boolean { return this.baseProperties.debug; } @@ -99,28 +150,97 @@ export abstract class AbstractDefaultIcon extends AbstractMarkerIcon { * すべてピクセル空間で行い、ラベルが広い場合はビットマップを横に広げてマーカーを * 中央寄せする(正方形固定にしない)。ラベルの文字サイズはマーカー scale に追従せず * 絶対値(Android/iOS と同じ)。 + * + * `labelPosition` が中央以外なら、ラベルをピンの輪郭の外側へ置き、ビットマップを + * その側へだけ伸ばす。アイコンは不変なので結果は 1 回だけ計算して使い回す。 */ protected computeLayout(): IconLayout { + if (this.cachedLayout) return this.cachedLayout; const markerSize = Math.max(1, Math.round(this.iconSize * this.scale)); // Android: outlineStrokeWidth = max(dpToPx(1 * iconScale), 2) const outlineStroke = Math.max(this.scale, 2); - let labelWidth = 0; - if (this.label) { - labelWidth = - measureLabelWidth(this.label, Math.max(1, this.labelTextSize), this.labelTypeFace) + - outlineStroke * 2; - } + // 円形部分の中心(Android drawLabel() と同じ)。 + const headCenterY = markerSize * 0.35; const padding = markerSize * 0.1; - const bitmapWidth = Math.max(markerSize, Math.round(labelWidth + padding)); - const bitmapHeight = markerSize; - const markerOffsetX = (bitmapWidth - markerSize) / 2; - return { markerSize, bitmapWidth, bitmapHeight, markerOffsetX, outlineStroke }; + + if (!this.label) { + this.cachedLayout = { + markerSize, + bitmapWidth: markerSize, + bitmapHeight: markerSize, + markerOffsetX: 0, + markerOffsetY: 0, + outlineStroke, + labelCenterX: markerSize / 2, + labelCenterY: headCenterY, + }; + return this.cachedLayout; + } + + const fontSize = Math.max(1, this.labelTextSize); + const labelWidth = measureLabelWidth(this.label, fontSize, this.labelTypeFace) + outlineStroke * 2; + const labelHeight = fontSize + outlineStroke * 2; + const { h, v } = parseLabelPosition(this.labelPosition); + const pin = computeMarkerPinBounds(markerSize, this.scale, this.strokeWidth); + const gap = Math.max(outlineStroke, markerSize * 0.05); + + // マーカー本体の左上を原点とした座標でラベルの中心を決める。 + let cx = markerSize / 2; + if (h < 0) cx = pin.left - gap - labelWidth / 2; + else if (h > 0) cx = pin.right + gap + labelWidth / 2; + + let cy = headCenterY; + if (h !== 0) { + // 横に置くときは、縦はピンの上端・頭の中心・先端に揃える。 + if (v < 0) cy = pin.top + labelHeight / 2; + else if (v > 0) cy = pin.bottom - labelHeight / 2; + } else { + // 中央列の top / bottom はピンと重ならないよう上下の外側に置く。 + if (v < 0) cy = pin.top - gap - labelHeight / 2; + else if (v > 0) cy = pin.bottom + gap + labelHeight / 2; + } + + // 横方向。中央なら従来どおり左右対称に広げる(出力を変えない)。 + let bitmapWidth: number; + let markerOffsetX: number; + if (h === 0) { + bitmapWidth = Math.max(markerSize, Math.round(labelWidth + padding)); + markerOffsetX = (bitmapWidth - markerSize) / 2; + } else { + const minX = Math.min(0, cx - labelWidth / 2 - padding / 2); + const maxX = Math.max(markerSize, cx + labelWidth / 2 + padding / 2); + bitmapWidth = Math.ceil(maxX - minX); + markerOffsetX = -minX; + } + + const minY = Math.min(0, cy - labelHeight / 2); + const maxY = Math.max(markerSize, cy + labelHeight / 2); + const markerOffsetY = Math.ceil(-minY); + const bitmapHeight = markerOffsetY + Math.ceil(maxY); + + this.cachedLayout = { + markerSize, + bitmapWidth, + bitmapHeight, + markerOffsetX, + markerOffsetY, + outlineStroke, + labelCenterX: markerOffsetX + cx, + labelCenterY: markerOffsetY + cy, + }; + return this.cachedLayout; } protected createSvg(layout: IconLayout): string { - const { markerSize, bitmapWidth, bitmapHeight, markerOffsetX } = layout; + const { markerSize, bitmapWidth, bitmapHeight, markerOffsetX, markerOffsetY } = layout; const strokeWidth = Math.max(0, this.strokeWidth * this.scale); - const markerPath = createMarkerPathData(markerSize, this.scale, this.strokeWidth, markerOffsetX); + const markerPath = createMarkerPathData( + markerSize, + this.scale, + this.strokeWidth, + markerOffsetX, + markerOffsetY, + ); const label = this.createLabelSvg(layout); const debug = this.debug ? `` @@ -128,7 +248,7 @@ export abstract class AbstractDefaultIcon extends AbstractMarkerIcon { return [ ``, - this.createFillSvg(markerPath, markerSize, markerOffsetX), + this.createFillSvg(markerPath, markerSize, markerOffsetX, markerOffsetY), ``, label, debug, @@ -137,8 +257,8 @@ export abstract class AbstractDefaultIcon extends AbstractMarkerIcon { } /** - * ラベル描画。Android drawLabel() と同じく円形部分の中心(markerSize * 0.35)へ - * 通常ウェイト・絶対サイズで配置し、アウトライン(stroke)を下に敷いてから塗りを重ねる。 + * ラベル描画。既定では Android drawLabel() と同じく円形部分の中心(markerSize * 0.35)へ + * 通常ウェイト・絶対サイズで配置し(位置は computeLayout が決める)、アウトライン(stroke)を下に敷いてから塗りを重ねる。 */ protected createLabelSvg(layout: IconLayout): string { if (!this.label) return ""; @@ -147,10 +267,8 @@ export abstract class AbstractDefaultIcon extends AbstractMarkerIcon { const fill = escapeXml(this.labelTextColor ?? "#000000"); const stroke = escapeXml(this.labelStrokeColor); const size = Math.max(1, this.labelTextSize); - const cx = layout.markerOffsetX + layout.markerSize / 2; - const cy = layout.markerSize * 0.35; const attrs = - `x="${fmt(cx)}" y="${fmt(cy)}" text-anchor="middle" dominant-baseline="central" ` + + `x="${fmt(layout.labelCenterX)}" y="${fmt(layout.labelCenterY)}" text-anchor="middle" dominant-baseline="central" ` + `font-family="${escapeXml(this.labelTypeFace)}" font-size="${size}"`; return [ `${text}`, @@ -158,7 +276,12 @@ export abstract class AbstractDefaultIcon extends AbstractMarkerIcon { ].join(""); } - protected abstract createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number): string; + protected abstract createFillSvg( + markerPath: string, + markerSize: number, + markerOffsetX: number, + markerOffsetY: number, + ): string; protected abstract getUniqueProperties(): unknown; } diff --git a/src/marker/ColorDefaultIcon.ts b/src/marker/ColorDefaultIcon.ts index 85e34c9..c9b8f23 100644 --- a/src/marker/ColorDefaultIcon.ts +++ b/src/marker/ColorDefaultIcon.ts @@ -23,7 +23,13 @@ import { // オプション型は DefaultIconTypes.ts にある。以前からこのモジュール名で公開しているので、 // そのまま再エクスポートして import 元を変えずに済ませる。 -export type { DefaultMarkerIconOptions, ImageDefaultIconOptions } from "./DefaultIconTypes"; +export type { + DefaultMarkerIconOptions, + ImageDefaultIconOptions, + MarkerLabelHorizontalPosition, + MarkerLabelPosition, + MarkerLabelVerticalPosition, +} from "./DefaultIconTypes"; /** * 単色で塗る既定アイコン。`DefaultMarkerIcon` はこれの別名。 @@ -48,7 +54,8 @@ export class ColorDefaultIcon extends AbstractDefaultIcon { labelTextSize: this.labelTextSize, labelTypeFace: this.labelTypeFace, labelStrokeColor: this.labelStrokeColor, - infoAnchor: this.infoAnchor, + labelPosition: this.labelPosition, + infoAnchor: this.baseProperties.infoAnchor, iconSize: this.iconSize, debug: this.debug, ...options, @@ -89,7 +96,8 @@ export class ImageDefaultIcon extends AbstractDefaultIcon { labelTextSize: this.labelTextSize, labelTypeFace: this.labelTypeFace, labelStrokeColor: this.labelStrokeColor, - infoAnchor: this.infoAnchor, + labelPosition: this.labelPosition, + infoAnchor: this.baseProperties.infoAnchor, iconSize: this.iconSize, debug: this.debug, ...options, @@ -114,7 +122,7 @@ export class ImageDefaultIcon extends AbstractDefaultIcon { private toCanvasBitmapIcon(img: HTMLImageElement): BitmapIcon { const layout = this.computeLayout(); - const { markerSize, bitmapWidth, bitmapHeight, markerOffsetX } = layout; + const { markerSize, bitmapWidth, bitmapHeight, markerOffsetX, markerOffsetY } = layout; const canvas = document.createElement("canvas"); canvas.width = bitmapWidth; canvas.height = bitmapHeight; @@ -123,13 +131,13 @@ export class ImageDefaultIcon extends AbstractDefaultIcon { // ピクセル空間で描画(createSvg と同じ座標系) const markerPath = new Path2D( - createMarkerPathData(markerSize, this.scale, this.strokeWidth, markerOffsetX), + createMarkerPathData(markerSize, this.scale, this.strokeWidth, markerOffsetX, markerOffsetY), ); // マーカー形状でクリップし、正方形領域へ画像をセンタークロップ(cover) ctx.save(); ctx.clip(markerPath); - drawImageCover(ctx, img, markerOffsetX, 0, markerSize, markerSize); + drawImageCover(ctx, img, markerOffsetX, markerOffsetY, markerSize, markerSize); ctx.restore(); // ストローク(createSvg と同じ計算式) @@ -140,7 +148,7 @@ export class ImageDefaultIcon extends AbstractDefaultIcon { ctx.lineCap = "round"; ctx.stroke(markerPath); - // ラベル(createLabelSvg と同じロジック:円形部分の中心・通常ウェイト・絶対サイズ) + // ラベル(createLabelSvg と同じロジック:位置は computeLayout・通常ウェイト・絶対サイズ) if (this.label) { const fontSize = Math.max(1, this.labelTextSize); ctx.font = `${fontSize}px ${this.labelTypeFace}`; @@ -150,11 +158,9 @@ export class ImageDefaultIcon extends AbstractDefaultIcon { ctx.lineCap = "round"; ctx.strokeStyle = this.labelStrokeColor; ctx.lineWidth = layout.outlineStroke; - const cx = markerOffsetX + markerSize / 2; - const cy = markerSize * 0.35; - ctx.strokeText(this.label, cx, cy); + ctx.strokeText(this.label, layout.labelCenterX, layout.labelCenterY); ctx.fillStyle = this.labelTextColor ?? "#000000"; - ctx.fillText(this.label, cx, cy); + ctx.fillText(this.label, layout.labelCenterX, layout.labelCenterY); } if (this.debug) { @@ -168,12 +174,17 @@ export class ImageDefaultIcon extends AbstractDefaultIcon { }; } - protected createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number): string { + protected createFillSvg( + markerPath: string, + markerSize: number, + markerOffsetX: number, + markerOffsetY: number, + ): string { const href = escapeXml(this.imageSourceUrl()); const clipId = `marker-fill-${(imageClipCounter += 1)}`; return [ ``, - ``, + ``, ].join(""); } diff --git a/src/marker/DefaultIconGeometry.ts b/src/marker/DefaultIconGeometry.ts index 0f59f51..b2afca9 100644 --- a/src/marker/DefaultIconGeometry.ts +++ b/src/marker/DefaultIconGeometry.ts @@ -16,19 +16,22 @@ import { DEFAULT_BASE_PROPERTIES, type BaseIconProperties, type DefaultMarkerIco export const MARKER_ORIGINAL_SIZE = { width: 23.5, height: 25.6 } as const; /** - * Android AbstractDefaultIcon.createMarkerPath() の移植。 - * - * @param canvasSize 描画座標系のサイズ(SVG viewBox の場合は 48) - * @param iconScale アイコン全体のスケール(this.scale に対応) - * @param strokeWidth ストローク幅(スケール適用前の値 this.strokeWidth に対応) - * @param horizontalOffset 水平オフセット(複数マーカー並置時に使用) + * ピン形を canvasSize の正方形へ収めたときの倍率と原点。 + * `createMarkerPathData` と、ラベルをピンの輪郭に沿わせる `computeLayout` が共有する。 */ -export function createMarkerPathData( +export interface MarkerPinMetrics { + /** 設計座標 1 単位あたりの px。 */ + s: number; + /** 設計座標の原点(パス外接箱の左上)。 */ + ox: number; + oy: number; +} + +export function computeMarkerPinMetrics( canvasSize: number, iconScale: number, strokeWidth: number, - horizontalOffset: number = 0, -): string { +): MarkerPinMetrics { // Android: scaledStrokeWidth = dpToPx(strokeWidth * iconScale) const scaledStrokeWidth = strokeWidth * iconScale; const epsilon = 0.75; @@ -48,8 +51,44 @@ export function createMarkerPathData( // Android: offsetX = (canvasSize - scaledWidth) / 2 + horizontalOffset // offsetY = (canvasSize - scaledHeight + strokeWidth.value * markerScale) / 2 // strokeWidth.value はスケール前の dp 値(TypeScript では strokeWidth に対応) - const ox = (canvasSize - scaledWidth) / 2 + horizontalOffset; + const ox = (canvasSize - scaledWidth) / 2; const oy = (canvasSize - scaledHeight + strokeWidth * s) / 2; + return { s, ox, oy }; +} + +/** + * ピンの見えている輪郭(設計座標で x: 4〜20, y: 0〜24)を px で返す。 + * 外接箱(23.5 × 25.6)より細いので、ラベルを横に置くときはこちらに沿わせる。 + */ +export function computeMarkerPinBounds( + canvasSize: number, + iconScale: number, + strokeWidth: number, +): { left: number; right: number; top: number; bottom: number } { + const { s, ox, oy } = computeMarkerPinMetrics(canvasSize, iconScale, strokeWidth); + return { left: ox + 4 * s, right: ox + 20 * s, top: oy, bottom: oy + 24 * s }; +} + +/** + * Android AbstractDefaultIcon.createMarkerPath() の移植。 + * + * @param canvasSize 描画座標系のサイズ(SVG viewBox の場合は 48) + * @param iconScale アイコン全体のスケール(this.scale に対応) + * @param strokeWidth ストローク幅(スケール適用前の値 this.strokeWidth に対応) + * @param horizontalOffset 水平オフセット(ラベルの分だけビットマップを広げたときに使用) + * @param verticalOffset 垂直オフセット(ラベルをピンの上に置いたときに使用) + */ +export function createMarkerPathData( + canvasSize: number, + iconScale: number, + strokeWidth: number, + horizontalOffset: number = 0, + verticalOffset: number = 0, +): string { + const metrics = computeMarkerPinMetrics(canvasSize, iconScale, strokeWidth); + const s = metrics.s; + const ox = metrics.ox + horizontalOffset; + const oy = metrics.oy + verticalOffset; let cx = 12 * s + ox; let cy = oy; @@ -123,10 +162,31 @@ export interface IconLayout { bitmapWidth: number; /** ビットマップ高さ(= markerSize)。 */ bitmapHeight: number; - /** マーカーを中央寄せするための水平オフセット。 */ + /** ビットマップ内でのマーカー本体の左上。 */ markerOffsetX: number; + markerOffsetY: number; /** ラベルのアウトライン幅(Android: max(1*scale, 2))。 */ outlineStroke: number; + /** ラベル文字列の中心(text-anchor="middle" / dominant-baseline="central" で描く)。 */ + labelCenterX: number; + labelCenterY: number; +} + +/** + * `labelPosition` を横・縦それぞれ -1 / 0 / 1 に読み替える。CSS の background-position と + * 同じく、キーワードの順序は問わず、片方だけなら残りは center。読めない値は中央扱い。 + */ +export function parseLabelPosition(position: string): { h: -1 | 0 | 1; v: -1 | 0 | 1 } { + const words = position.trim().toLowerCase().split(/\s+/).filter(Boolean); + let h: -1 | 0 | 1 | undefined; + let v: -1 | 0 | 1 | undefined; + for (const word of words) { + if (word === "left") h = -1; + else if (word === "right") h = 1; + else if (word === "top") v = -1; + else if (word === "bottom") v = 1; + } + return { h: h ?? 0, v: v ?? 0 }; } // ラベル幅測定用の使い回しキャンバス(ブラウザのみ)。 @@ -192,6 +252,7 @@ export const createBaseProperties = (options: DefaultMarkerIconOptions = {}): Ba labelTextSize: options.labelTextSize ?? DEFAULT_BASE_PROPERTIES.labelTextSize, labelTypeFace: options.labelTypeFace ?? DEFAULT_BASE_PROPERTIES.labelTypeFace, labelStrokeColor: normalizeHexColor(options.labelStrokeColor ?? DEFAULT_BASE_PROPERTIES.labelStrokeColor), + labelPosition: options.labelPosition ?? DEFAULT_BASE_PROPERTIES.labelPosition, infoAnchor: options.infoAnchor ?? DEFAULT_BASE_PROPERTIES.infoAnchor, iconSize: options.iconSize ?? DEFAULT_BASE_PROPERTIES.iconSize, debug: options.debug ?? DEFAULT_BASE_PROPERTIES.debug, diff --git a/src/marker/DefaultIconTypes.ts b/src/marker/DefaultIconTypes.ts index af9a9bd..3459881 100644 --- a/src/marker/DefaultIconTypes.ts +++ b/src/marker/DefaultIconTypes.ts @@ -7,6 +7,23 @@ import { Offset } from "../types"; * `labelTypeFace` は Android の Typeface / iOS の UIFont に対応する Web の font-family 文字列。 */ +/** + * ラベルをピンに対してどこへ置くか。CSS の `background-position` と同じ書き方で、 + * 横(left / center / right)と縦(top / center / bottom)のキーワードを空白区切りで並べる。 + * 順序は問わず、片方だけなら残りは `center`。 + * + * - `left` / `right` はピンの外側に置き、縦はピンの上端・頭の中心・先端に揃える。 + * - `center` 列の `top` / `bottom` はピンの上・下の外側に置く。 + * - `"center center"`(既定)はピンの頭の中に重ねる(従来どおり)。 + */ +export type MarkerLabelHorizontalPosition = "left" | "center" | "right"; +export type MarkerLabelVerticalPosition = "top" | "center" | "bottom"; +export type MarkerLabelPosition = + | MarkerLabelHorizontalPosition + | MarkerLabelVerticalPosition + | `${MarkerLabelHorizontalPosition} ${MarkerLabelVerticalPosition}` + | `${MarkerLabelVerticalPosition} ${MarkerLabelHorizontalPosition}`; + /** * 既定マーカーアイコンの見た目を決めるオプションと、その既定値。 * @@ -23,6 +40,7 @@ export interface DefaultMarkerIconOptions { labelTextSize?: number; labelTypeFace?: string; labelStrokeColor?: string; + labelPosition?: MarkerLabelPosition; infoAnchor?: Offset; iconSize?: number; debug?: boolean; @@ -42,6 +60,7 @@ export interface BaseIconProperties { labelTextSize: number; labelTypeFace: string; labelStrokeColor: string; + labelPosition: MarkerLabelPosition; infoAnchor: Offset; iconSize: number; debug: boolean; @@ -59,6 +78,7 @@ export const DEFAULT_BASE_PROPERTIES: BaseIconProperties = { labelTextSize: 18, labelTypeFace: DEFAULT_LABEL_TYPE_FACE, labelStrokeColor: "#FFFFFF", + labelPosition: "center center", infoAnchor: { x: 0.5, y: 0 }, iconSize: Settings.Default.iconSize, debug: false, diff --git a/src/marker/MarkerHitPick.ts b/src/marker/MarkerHitPick.ts new file mode 100644 index 0000000..939756e --- /dev/null +++ b/src/marker/MarkerHitPick.ts @@ -0,0 +1,122 @@ +import type { Offset } from "../types"; +import { createGeoPoint, type GeoPointInterface } from "../features/GeoPoint"; +import { createGeoRectBounds, type GeoRectBounds } from "../features/GeoRectBounds"; + +// タップ点に当たるマーカーを選ぶ規則(android-sdk / ios-sdk の `MarkerHitTest.pickIndex` と同じ)。 +// +// 1. アイコン(ラベル込み)の矩形そのものにタップ点が入っているものがあれば、その中で +// 画面上いちばん下にアンカーがあるもの(手前に描かれるもの)を選ぶ。近くに別のマーカーの +// 先端があっても、指の下に描かれているマーカーが勝つ。 +// 2. どの矩形にも入っていなければ、矩形 + tolerance に入るもののうち、アンカーが +// タップ点にいちばん近いものを選ぶ。 +// +// 通常マーカーとタイル化マーカーのように別々に探した結果は {@link preferMarkerHit} で +// 同じ規則に従って 1 つにまとめる。 + +/** @internal アンカー点を投影した画面座標と、そのアイコンの矩形(CSS px)。 */ +export interface MarkerHitRect { + anchorScreen: Offset; + width: number; + height: number; + anchor: Offset; +} + +/** @internal 1 つの候補が当たった結果。`exact` は矩形そのもの(許容量なし)に入ったか。 */ +export interface MarkerHit { + item: T; + exact: boolean; + /** アンカーの画面 y。`exact` 同士はこれが大きい(手前の)方が勝つ。 */ + anchorY: number; + /** タップ点とアンカーの距離(px)。`exact` でない同士はこれが小さい方が勝つ。 */ + distance: number; +} + +/** + * 候補を探す半径(CSS px)。ラベルの長さの上限の目安で、これより遠くまで伸びたラベルの端は拾えない。 + * android-sdk / ios-sdk の `SEARCH_RADIUS` と同じ値。 + * + * @internal + */ +export const MARKER_HIT_SEARCH_RADIUS_PX = 256; + +function containsRect(dx: number, dy: number, rect: MarkerHitRect, tolerance: number): boolean { + const left = -rect.anchor.x * rect.width - tolerance; + const right = (1 - rect.anchor.x) * rect.width + tolerance; + const top = -rect.anchor.y * rect.height - tolerance; + const bottom = (1 - rect.anchor.y) * rect.height + tolerance; + return dx >= left && dx <= right && dy >= top && dy <= bottom; +} + +/** @internal `a` と `b` のうち、上の規則で勝つ方。どちらかが null ならもう一方。 */ +export function preferMarkerHit(a: MarkerHit | null, b: MarkerHit | null): MarkerHit | null { + if (!a) return b; + if (!b) return a; + if (a.exact !== b.exact) return a.exact ? a : b; + if (a.exact) return b.anchorY > a.anchorY ? b : a; + return b.distance < a.distance ? b : a; +} + +/** @internal `candidates` の中からタップ点に当たるものを選ぶ。`rectOf` が null を返した候補は飛ばす。 */ +export function pickMarkerHit( + touch: Offset, + candidates: Iterable, + rectOf: (item: T) => MarkerHitRect | null, + tolerance: number, +): MarkerHit | null { + let best: MarkerHit | null = null; + for (const item of candidates) { + const rect = rectOf(item); + if (!rect) continue; + const dx = touch.x - rect.anchorScreen.x; + const dy = touch.y - rect.anchorScreen.y; + let hit: MarkerHit | null = null; + if (containsRect(dx, dy, rect, 0)) { + hit = { item, exact: true, anchorY: rect.anchorScreen.y, distance: Math.hypot(dx, dy) }; + } else if (containsRect(dx, dy, rect, tolerance)) { + hit = { item, exact: false, anchorY: rect.anchorScreen.y, distance: Math.hypot(dx, dy) }; + } + best = preferMarkerHit(best, hit); + } + return best; +} + +/** @internal {@link pickMarkerHit} の、矩形の配列からインデックスを返す版(テスト用の純粋な幾何)。 */ +export function pickMarkerIndex(touch: Offset, rects: MarkerHitRect[], tolerance: number): number | null { + const hit = pickMarkerHit(touch, rects.map((rect, index) => ({ rect, index })), c => c.rect, tolerance); + return hit ? hit.item.index : null; +} + +/** + * 候補を集める範囲。タップ点から画面上 {@link MARKER_HIT_SEARCH_RADIUS_PX} 以内に先端があるマーカーを含む。 + * + * ラベルを横に置くとアイコンはピンより大きく広がるので、最寄りの 1 つだけでは足りない。 + * px → 度の換算は、タップ点の近くを投影して局所的に求める(回転していても各軸の長さは保たれる)。 + * + * @internal + */ +export function markerSearchBounds( + position: GeoPointInterface, + touchScreen: Offset, + project: (position: GeoPointInterface) => Offset | null, + searchRadiusPx: number = MARKER_HIT_SEARCH_RADIUS_PX, +): GeoRectBounds | null { + const epsilon = 1e-4; + const north = project(createGeoPoint({ latitude: position.latitude + epsilon, longitude: position.longitude })); + const east = project(createGeoPoint({ latitude: position.latitude, longitude: position.longitude + epsilon })); + if (!north || !east) return null; + const pxPerDegLat = Math.hypot(north.x - touchScreen.x, north.y - touchScreen.y) / epsilon; + const pxPerDegLon = Math.hypot(east.x - touchScreen.x, east.y - touchScreen.y) / epsilon; + if (!(pxPerDegLat > 0) || !(pxPerDegLon > 0)) return null; + const halfLat = searchRadiusPx / pxPerDegLat; + const halfLon = searchRadiusPx / pxPerDegLon; + return createGeoRectBounds({ + southWest: createGeoPoint({ + latitude: Math.max(position.latitude - halfLat, -90), + longitude: position.longitude - halfLon, + }), + northEast: createGeoPoint({ + latitude: Math.min(position.latitude + halfLat, 90), + longitude: position.longitude + halfLon, + }), + }); +} diff --git a/src/marker/MarkerIconExtent.ts b/src/marker/MarkerIconExtent.ts new file mode 100644 index 0000000..1abfa31 --- /dev/null +++ b/src/marker/MarkerIconExtent.ts @@ -0,0 +1,23 @@ +import { AbstractDefaultIcon } from "./AbstractDefaultIcon"; +import type { MarkerIcon } from "./MarkerIcon"; + +/** + * アイコンが画面上で占める幅と高さ(CSS px)。`anchor` / `infoAnchor` はこの矩形に対する割合。 + * + * 既定アイコンはラベルの分だけビットマップがピン本体(iconSize × scale の正方形)より広い + * ことがあるので、その倍率を掛ける。それ以外のアイコンは従来どおり正方形として扱う。 + * アイコンが無ければ 0(吹き出しの尾がマーカー位置そのものを指す)。 + * + * android-sdk の `MarkerIconExtent`、ios-sdk の `MarkerIconMetrics` に対応する。 + * + * @internal ドライバー実装点。公開 API サーフェスには含めない。 + */ +export function markerIconExtentPx(icon: MarkerIcon | null | undefined): { width: number; height: number } { + if (!icon) return { width: 0, height: 0 }; + const size = icon.iconSize * icon.scale; + if (icon instanceof AbstractDefaultIcon) { + const ratio = icon.extentRatio; + return { width: size * ratio.width, height: size * ratio.height }; + } + return { width: size, height: size }; +} diff --git a/src/marker/MarkerTileRenderer.ts b/src/marker/MarkerTileRenderer.ts index a79f14b..791b055 100644 --- a/src/marker/MarkerTileRenderer.ts +++ b/src/marker/MarkerTileRenderer.ts @@ -1,4 +1,5 @@ import { copyIconBitmap } from './swIconBitmaps'; +import { MARKER_HIT_SEARCH_RADIUS_PX, pickMarkerHit, type MarkerHit } from './MarkerHitPick'; import type { TileProvider } from '../tileserver/TileProvider'; import type { TileRequest } from '../tileserver/TileRequest'; import type { GeoPoint } from '../features'; @@ -390,23 +391,31 @@ export class MarkerTileRenderer | null { const cosLat = Math.cos((click.latitude * Math.PI) / 180); const metersPerPx = (156543.03392 * cosLat) / Math.pow(2, zoom); if (!(metersPerPx > 0)) return null; const metersPerLatDeg = 111111; const metersPerLngDeg = 111111 * Math.max(cosLat, 1e-6); - // A marker is "hit" when the click falls inside its ICON's on-screen - // rectangle (anchor-adjusted) plus the tap tolerance — the same rule the - // providers' native `find()` uses. Testing only a fixed radius around the - // anchor made a large icon clickable only near its center, so taps that - // visually landed on the marker missed. const drawSizeOf = (item: T): { drawW: number; drawH: number; bitmapIcon: BitmapIcon } => { const bitmapIcon = this.bitmapIconOf(item); const scale = Math.max(Math.max(this.iconScale(item, zoom), 0) * this.extraIconScale, 0); @@ -414,10 +423,10 @@ export class MarkerTileRenderer 0) { const { drawW, drawH } = drawSizeOf(this.items[0]); maxHalfPx = Math.max(maxHalfPx, drawW, drawH); @@ -433,25 +442,23 @@ export class MarkerTileRenderer= left && dxPx <= right && dyPx >= top && dyPx <= bottom) { - const d = dxPx * dxPx + dyPx * dyPx; - if (d < minDist) { - minDist = d; - nearest = item; - } - } - } - return nearest; + // Click position relative to each marker's anchor, in screen pixels (y down). The click + // itself sits at the origin, so each anchor is placed at minus that offset. + return pickMarkerHit( + { x: 0, y: 0 }, + candidates, + item => { + const { drawW, drawH, bitmapIcon } = drawSizeOf(item); + const dxPx = ((click.longitude - item.position.longitude) * metersPerLngDeg) / metersPerPx; + const dyPx = ((item.position.latitude - click.latitude) * metersPerLatDeg) / metersPerPx; + return { + anchorScreen: { x: -dxPx, y: -dyPx }, + width: drawW, + height: drawH, + anchor: bitmapIcon.anchor, + }; + }, + hitRadiusPx, + ); } } diff --git a/src/marker/StrategyMarkerController.ts b/src/marker/StrategyMarkerController.ts index a6aa723..fa6c56a 100644 --- a/src/marker/StrategyMarkerController.ts +++ b/src/marker/StrategyMarkerController.ts @@ -7,6 +7,8 @@ import type { OverlayController } from "../controller/OverlayController"; import { MapCameraPosition, Offset } from "../types"; import { Settings } from "../settings"; import { Mutex } from "../base/Mutex"; +import { markerIconExtentPx } from "./MarkerIconExtent"; +import { markerSearchBounds, pickMarkerHit } from "./MarkerHitPick"; import { createDefaultIcon } from "./ColorDefaultIcon"; import { MarkerEntity } from "./MarkerEntity"; import { MarkerManager } from "./MarkerManager"; @@ -129,42 +131,41 @@ export class StrategyMarkerController } find(position: GeoPoint): MarkerEntity | null { - const nearest = this.strategy.markerManager.findNearest(position); - if (nearest == null) return null; - const holder = this.renderer.holder; - if (holder == null) return nearest; + if (holder == null) return this.strategy.markerManager.findNearest(position); const touchScreen = this.resolveSyncOffset(holder.toScreenOffset(position)); - const markerScreen = - touchScreen === undefined - ? undefined - : this.resolveSyncOffset(holder.toScreenOffset(nearest.state.position)); // Screen projection only resolves asynchronously on this holder; the // native icon-bounds hit test needs synchronous offsets, so degrade to // the geo-nearest result instead of dropping the hit entirely. - if (touchScreen === undefined || markerScreen === undefined) return nearest; - if (touchScreen === null || markerScreen === null) return null; + if (touchScreen === undefined) return this.strategy.markerManager.findNearest(position); + if (touchScreen === null) return null; + + // android-sdk / ios-sdk の StrategyMarkerController.find と同じ。タップ点の周りのマーカーを + // 候補にし、指の下にアイコン(ラベル込み)が描かれているものを優先する(pickMarkerHit)。 + const project = (p: GeoPointInterface): Offset | null => + this.resolveSyncOffset(holder.toScreenOffset(p as GeoPoint)) ?? null; + const bounds = markerSearchBounds(position, touchScreen, project); + if (!bounds) return null; // Native multiplies tapTolerance (dp) by the display density; on the // web both tolerance and offsets are CSS pixels, so no scaling applies. const tolerancePx = Settings.Default.tapTolerance; - const icon = nearest.state.icon ?? createDefaultIcon(); - - const iconWidthPx = icon.iconSize * icon.scale; - const iconHeightPx = icon.iconSize * icon.scale; - const anchorX = icon.anchor.x; - const anchorY = icon.anchor.y; - - const dx = touchScreen.x - markerScreen.x; - const dy = touchScreen.y - markerScreen.y; - - const left = -anchorX * iconWidthPx - tolerancePx; - const right = (1.0 - anchorX) * iconWidthPx + tolerancePx; - const top = -anchorY * iconHeightPx - tolerancePx; - const bottom = (1.0 - anchorY) * iconHeightPx + tolerancePx; - - return dx >= left && dx <= right && dy >= top && dy <= bottom ? nearest : null; + const hit = pickMarkerHit( + touchScreen, + this.strategy.markerManager.findMarkersInBounds(bounds), + entity => { + const anchorScreen = project(entity.state.position); + if (!anchorScreen) return null; + const icon = entity.state.icon ?? createDefaultIcon(); + // 既定アイコンはラベルの分だけビットマップがピン本体より広い。anchor はその + // ビットマップに対する割合なので、矩形も実寸で取る(android / iOS の MarkerHitTest と同じ)。 + const extent = markerIconExtentPx(icon); + return { anchorScreen, width: extent.width, height: extent.height, anchor: icon.anchor }; + }, + tolerancePx, + ); + return hit?.item ?? null; } /** `undefined` means the holder projects asynchronously (no sync value available). */ diff --git a/src/marker/index.ts b/src/marker/index.ts index 8655006..f26ac81 100644 --- a/src/marker/index.ts +++ b/src/marker/index.ts @@ -9,6 +9,8 @@ export * from "./MarkerEntity" export * from "./MarkerIngestionEngine" export * from "./MarkerFingerPrint" export * from "./MarkerIcon" +export * from "./MarkerIconExtent" +export * from "./MarkerHitPick" export * from "./MarkerManager" export * from "./MarkerOverlay" export * from "./MarkerOverlayRenderer" diff --git a/test/DefaultIconLabelPosition.test.mjs b/test/DefaultIconLabelPosition.test.mjs new file mode 100644 index 0000000..9a47e99 --- /dev/null +++ b/test/DefaultIconLabelPosition.test.mjs @@ -0,0 +1,84 @@ +// 既定アイコンの labelPosition がビットマップの寸法とアンカーをどう変えるか。 +// +// ラベルをピンの横や上に置くと、ビットマップはその側へだけ伸びる。そのとき +// アンカー(ピン先端)がビットマップの (0.5, 1) からずれるので、レンダラが +// 地図上の位置を取り違えないことをここで確かめる。DOM が無いので文字幅は近似。 + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ColorDefaultIcon, markerIconExtentPx } from '../dist/index.mjs'; + +const label = '信州大学'; + +function bitmapTipPx(icon) { + const { anchor, size } = icon.toBitmapIcon(); + return { x: anchor.x * size.width, y: anchor.y * size.height, size }; +} + +test('既定(center center)は従来どおり左右対称でアンカーは (0.5, 1)', () => { + const icon = new ColorDefaultIcon({ label }); + const { anchor, size } = icon.toBitmapIcon(); + assert.deepEqual(anchor, { x: 0.5, y: 1 }); + assert.equal(size.height, icon.iconSize); + assert.deepEqual(icon.infoAnchor, { x: 0.5, y: 0 }); +}); + +test('right center はビットマップを右へだけ伸ばし、ピン先端は左寄りになる', () => { + const icon = new ColorDefaultIcon({ label, labelPosition: 'right center' }); + const tip = bitmapTipPx(icon); + assert.equal(tip.size.height, icon.iconSize); + assert.equal(tip.x, icon.iconSize / 2); + assert.equal(tip.y, tip.size.height); + assert.ok(tip.x < tip.size.width / 2); + const svg = decodeURIComponent(icon.toBitmapIcon().url); + const x = Number(/ icon.iconSize, `label center ${x} should be right of the pin`); +}); + +// ピン形は外接箱の中で 0.25 単位だけ右に寄っているので、1px までの差は許す。 +test('left は right のほぼ鏡像', () => { + const right = bitmapTipPx(new ColorDefaultIcon({ label, labelPosition: 'right' })); + const left = bitmapTipPx(new ColorDefaultIcon({ label, labelPosition: 'left' })); + assert.ok(Math.abs(left.size.width - right.size.width) <= 1); + assert.ok(Math.abs(left.x - (left.size.width - right.x)) <= 1); +}); + +test('center top はピンの上に置き、ビットマップを上へ伸ばす', () => { + const icon = new ColorDefaultIcon({ label, labelPosition: 'center top' }); + const tip = bitmapTipPx(icon); + assert.ok(tip.size.height > icon.iconSize); + assert.equal(tip.y, tip.size.height); + // 吹き出しは引き続きピンの頭に付く。 + assert.equal(icon.infoAnchor.y * tip.size.height, tip.size.height - icon.iconSize); +}); + +test('キーワードの順序は問わない(CSS と同じ)', () => { + const a = new ColorDefaultIcon({ label, labelPosition: 'right top' }).toBitmapIcon(); + const b = new ColorDefaultIcon({ label, labelPosition: 'top right' }).toBitmapIcon(); + assert.equal(a.url, b.url); + assert.deepEqual(a.anchor, b.anchor); +}); + +test('labelPosition はハッシュと copy に載る', () => { + const a = new ColorDefaultIcon({ label }); + const b = a.copy({ labelPosition: 'right center' }); + assert.notEqual(a.hashCode(), b.hashCode()); + assert.equal(b.copy({ fillColor: '#0000ff' }).labelPosition, 'right center'); +}); + +test('copy は指定どおりの infoAnchor を引き継ぐ(変換後の値を二重に変換しない)', () => { + const icon = new ColorDefaultIcon({ label, labelPosition: 'right center' }); + const copied = icon.copy({ fillColor: '#00ff00' }); + assert.deepEqual(copied.infoAnchor, icon.infoAnchor); +}); + +// 当たり判定と吹き出しはアイコンの実寸(ビットマップ)で矩形を取る。anchor は +// ビットマップに対する割合なので、ピン本体の正方形で取ると矩形がずれる。 +test('markerIconExtentPx は既定アイコンのビットマップ寸法を返す', () => { + const icon = new ColorDefaultIcon({ label, labelPosition: 'center top' }); + assert.deepEqual(markerIconExtentPx(icon), icon.toBitmapIcon().size); + const plain = new ColorDefaultIcon(); + assert.deepEqual(markerIconExtentPx(plain), { width: plain.iconSize, height: plain.iconSize }); + assert.deepEqual(markerIconExtentPx(null), { width: 0, height: 0 }); +}); diff --git a/test/MarkerHitPick.test.mjs b/test/MarkerHitPick.test.mjs new file mode 100644 index 0000000..d5b3306 --- /dev/null +++ b/test/MarkerHitPick.test.mjs @@ -0,0 +1,43 @@ +// タップ点に当たるマーカーの選び方。android-sdk の `MarkerHitTestPickTest`、 +// ios-sdk の `MarkerHitTestPickTests` と同じ観点を見る。 +// +// 指の下にアイコン(ラベル込み)が描かれているマーカーは、近くに別のマーカーの先端が +// あっても勝つ。どの矩形にも入らないときだけ、許容範囲内でアンカーの近いものを選ぶ。 + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { pickMarkerIndex, preferMarkerHit } from '../dist/index.mjs'; + +const rect = (x, y, { width = 48, height = 48, anchorX = 0.5 } = {}) => ({ + anchorScreen: { x, y }, + width, + height, + anchor: { x: anchorX, y: 1 }, +}); + +test('指の下のラベルは、先端の近い別のマーカーより勝つ', () => { + // labelled: 先端 (0, 100)、ラベルを右へ伸ばして幅 200(矩形 x -24..176, y 52..100)。 + // nearTip: 先端 (150, 70) でタップ点にずっと近いが、矩形 (126..174, 22..70) はタップ点に届かない。 + const labelled = rect(0, 100, { width: 200, anchorX: 0.12 }); + const nearTip = rect(150, 70); + assert.equal(pickMarkerIndex({ x: 150, y: 80 }, [nearTip, labelled], 14), 1); +}); + +test('アイコンが重なっていたら手前(画面下)のもの', () => { + assert.equal(pickMarkerIndex({ x: 100, y: 90 }, [rect(100, 100), rect(100, 120)], 14), 1); +}); + +test('どの矩形にも入らないときだけ、許容範囲内でアンカーの近いもの', () => { + const rects = [rect(100, 100), rect(140, 100)]; + assert.equal(pickMarkerIndex({ x: 170, y: 105 }, rects, 40), 1); + assert.equal(pickMarkerIndex({ x: 400, y: 400 }, rects, 14), null); +}); + +test('別々に探した結果も同じ規則でまとめる(真下 > 近接、近接同士は距離)', () => { + const exact = { item: 'tiled', exact: true, anchorY: 10, distance: 50 }; + const near = { item: 'regular', exact: false, anchorY: 90, distance: 2 }; + assert.equal(preferMarkerHit(near, exact).item, 'tiled'); + assert.equal(preferMarkerHit(near, { ...near, item: 'closer', distance: 1 }).item, 'closer'); + assert.equal(preferMarkerHit(null, near).item, 'regular'); +});