11import type * as maplibregl from "maplibre-gl" ;
22import type { Series } from "./types" ;
33import { autoTextColor , findSeriesStyle , isImageLabel , type SeriesStyleDefinition } from "./seriesStyle" ;
4- import { DEFAULT_PIN_SHAPE , type PinShape } from "./categoryStyle" ;
4+ import {
5+ DEFAULT_PIN_SHAPE ,
6+ PIN_PATH_VIEW_HEIGHT ,
7+ PIN_PATH_VIEW_WIDTH ,
8+ type PinShapeSpec ,
9+ } from "./categoryStyle" ;
510
611/**
712 * 地図ピン(下がとんがった吹き出し型)の画像をcanvasで生成し、
@@ -34,10 +39,8 @@ function pinTailHeight(size: number): number {
3439 * シリーズ名だけをIDにすると暫定の見た目で登録した画像がそのまま使われ続けてしまう
3540 * (ensurePinImageはmap.hasImage()で早期returnする)。
3641 */
37- function styleSignature ( style : SeriesStyleDefinition ) : string {
38- const label = isImageLabel ( style . label ) ? `img:${ style . label . image } ` : `txt:${ style . label } ` ;
39- const raw = `${ style . color } |${ style . borderColor } |${ style . size } |${ style . textColor ?? "" } |${ label } ` ;
40- // FNV-1a(ラベルが画像(base64)のこともあるため、IDに全文を入れず固定長にする)
42+ /** FNV-1a。長い値(画像のbase64・自前のパス)をIDに入れず固定長にするために使う */
43+ function fnv1a ( raw : string ) : string {
4144 let h = 0x811c9dc5 ;
4245 for ( let i = 0 ; i < raw . length ; i += 1 ) {
4346 h ^= raw . charCodeAt ( i ) ;
@@ -46,17 +49,27 @@ function styleSignature(style: SeriesStyleDefinition): string {
4649 return ( h >>> 0 ) . toString ( 36 ) ;
4750}
4851
52+ function styleSignature ( style : SeriesStyleDefinition ) : string {
53+ const label = isImageLabel ( style . label ) ? `img:${ style . label . image } ` : `txt:${ style . label } ` ;
54+ return fnv1a (
55+ `${ style . color } |${ style . borderColor } |${ style . size } |${ style . textColor ?? "" } |${ label } `
56+ ) ;
57+ }
58+
4959export function pinIconId (
5060 series : Series | null ,
5161 visited : boolean ,
5262 isPrivate : boolean ,
5363 seriesStyles : SeriesStyleDefinition [ ] ,
5464 /** 頭の形(カテゴリ由来)。**IDに混ぜないと、形を変えても既存の画像が使われ続ける** */
55- shape : PinShape = DEFAULT_PIN_SHAPE
65+ shape : PinShapeSpec = DEFAULT_PIN_SHAPE
5666) : string {
5767 const sig = styleSignature ( findSeriesStyle ( series , seriesStyles ) ) ;
5868 const base = `pin-${ visited ? "visited" : "normal" } ${ isPrivate ? "-private" : "" } ` ;
59- return `${ base } -${ sig } -${ shape } -${ series ?? "__null__" } ` ;
69+ // 自前のパスはそのまま混ぜるとIDが長くなるうえ、MapLibreの画像IDに使えない
70+ // 文字が入りうるのでハッシュにする
71+ const shapeKey = typeof shape === "string" ? shape : `p${ fnv1a ( shape . path ) } ` ;
72+ return `${ base } -${ sig } -${ shapeKey } -${ series ?? "__null__" } ` ;
6073}
6174
6275/** data URL画像をHTMLImageElementとして読み込む(base64は同期的に近いが、確実性のためdecode()を待つ) */
@@ -76,7 +89,7 @@ export async function ensurePinImage(
7689 isPrivate : boolean ,
7790 seriesStyles : SeriesStyleDefinition [ ] ,
7891 /** 頭の形(カテゴリ由来。lib/categoryStyle.ts の findPinShape で解決したもの) */
79- shape : PinShape = DEFAULT_PIN_SHAPE
92+ shape : PinShapeSpec = DEFAULT_PIN_SHAPE
8093) : Promise < string > {
8194 const id = pinIconId ( series , visited , isPrivate , seriesStyles , shape ) ;
8295 if ( map . hasImage ( id ) ) return id ;
@@ -104,45 +117,98 @@ export async function ensurePinImage(
104117 const cy = PIN_ICON_PAD + r ; // 頭(円)の中心
105118 const tipY = h - PIN_ICON_PAD ; // とんがりの先端(画像下端中央)
106119
107- // 頭ととんがりを1つのパスとして描く(塗りと縁取りを一度に済ませるため)
108- ctx . beginPath ( ) ;
109- if ( shape === "rounded-square" ) {
120+ // 頭ととんがりを1つのパスとして描く(塗りと縁取りを一度に済ませるため)。
121+ // 自前のパス(設定ファイル由来)も同じ1本として扱えるようPath2Dに組む
122+ const path = new Path2D ( ) ;
123+ if ( typeof shape !== "string" ) {
124+ // 100×145の箱に描かれたパスを、このピンの寸法へ拡大縮小して取り込む。
125+ // 箱の下端中央がスポットの位置(icon-anchor: bottom)に来る
126+ const sx = size / PIN_PATH_VIEW_WIDTH ;
127+ const sy = ( size + tail ) / PIN_PATH_VIEW_HEIGHT ;
128+ path . addPath ( new Path2D ( shape . path ) , {
129+ a : sx ,
130+ b : 0 ,
131+ c : 0 ,
132+ d : sy ,
133+ e : PIN_ICON_PAD ,
134+ f : PIN_ICON_PAD ,
135+ } ) ;
136+ } else if ( shape === "rounded-square" ) {
110137 // 角丸四角。円と同じ幅に収め、下辺の左右から先端へ引く。
111138 // 下辺は角丸の分だけ内側から始まるので、とんがりの付け根も同じ位置に合わせる
112139 const k = r * 0.42 ; // 角丸の半径
113140 const left = cx - r ;
114141 const right = cx + r ;
115142 const top = cy - r ;
116143 const bottom = cy + r ;
117- ctx . moveTo ( left + k , top ) ;
118- ctx . lineTo ( right - k , top ) ;
119- ctx . quadraticCurveTo ( right , top , right , top + k ) ;
120- ctx . lineTo ( right , bottom - k ) ;
121- ctx . quadraticCurveTo ( right , bottom , right - k , bottom ) ;
122- ctx . lineTo ( cx , tipY ) ; // 右下の角からとんがりの先端へ
123- ctx . lineTo ( left + k , bottom ) ;
124- ctx . quadraticCurveTo ( left , bottom , left , bottom - k ) ;
125- ctx . lineTo ( left , top + k ) ;
126- ctx . quadraticCurveTo ( left , top , left + k , top ) ;
144+ path . moveTo ( left + k , top ) ;
145+ path . lineTo ( right - k , top ) ;
146+ path . quadraticCurveTo ( right , top , right , top + k ) ;
147+ path . lineTo ( right , bottom - k ) ;
148+ path . quadraticCurveTo ( right , bottom , right - k , bottom ) ;
149+ path . lineTo ( cx , tipY ) ; // 右下の角からとんがりの先端へ
150+ path . lineTo ( left + k , bottom ) ;
151+ path . quadraticCurveTo ( left , bottom , left , bottom - k ) ;
152+ path . lineTo ( left , top + k ) ;
153+ path . quadraticCurveTo ( left , top , left + k , top ) ;
154+ } else if ( shape === "castle" ) {
155+ // 上辺を凸凹(狭間)にした四角。城郭の胸壁の記号。多角形より輪郭の特徴が
156+ // 分かりやすく、小さいピンでも「他と違う」ことは読み取れる
157+ const left = cx - r ;
158+ const right = cx + r ;
159+ const top = cy - r ;
160+ const bottom = cy + r ;
161+ const step = ( 2 * r ) / 5 ; // 凸凹の1区画。凸3・凹2で上辺を作る
162+ const notch = r * 0.35 ; // 凹みの深さ
163+ path . moveTo ( left , bottom ) ;
164+ path . lineTo ( left , top ) ;
165+ for ( let i = 0 ; i < 5 ; i ++ ) {
166+ const y = i % 2 === 0 ? top : top + notch ;
167+ path . lineTo ( left + i * step , y ) ;
168+ path . lineTo ( left + ( i + 1 ) * step , y ) ;
169+ }
170+ path . lineTo ( right , bottom ) ;
171+ path . lineTo ( cx , tipY ) ; // 右下の角から先端へ
172+ } else if ( shape !== "circle" ) {
173+ // 多角形。**真下に頂点が来る向きは使わない** —— とんがりと重なって
174+ // 長さ0の線分になり、輪郭が潰れるため。下側の2頂点から先端へ引く
175+ const deg = ( d : number ) : [ number , number ] => {
176+ const rad = ( d * Math . PI ) / 180 ;
177+ return [ cx + r * Math . cos ( rad ) , cy + r * Math . sin ( rad ) ] ;
178+ } ;
179+ // 頂点を時計回りに並べ、下側の2頂点の間に先端を挟む。
180+ // bottomRightはその「右下の頂点」の添字(ここを過ぎたら先端へ引く)
181+ const [ angles , bottomRight ] =
182+ shape === "diamond"
183+ ? [ [ - 90 , 0 , 180 ] , 1 ] // ひし形は左右の頂点が下側の2点を兼ねる
184+ : shape === "pentagon"
185+ ? [ [ - 90 , - 18 , 54 , 126 , 198 ] , 2 ]
186+ : [ [ - 60 , 0 , 60 , 120 , 180 , 240 ] , 2 ] ; // 六角形は上辺を平らにする
187+ ( angles as number [ ] ) . forEach ( ( d , i ) => {
188+ const [ x , y ] = deg ( d ) ;
189+ if ( i === 0 ) path . moveTo ( x , y ) ;
190+ else path . lineTo ( x , y ) ;
191+ if ( i === bottomRight ) path . lineTo ( cx , tipY ) ; // 右下の頂点から先端へ
192+ } ) ;
127193 } else {
128194 // 円の下側±45°の2点からとんがりの先端へ直線を引いた吹き出し型
129- ctx . arc ( cx , cy , r , ( 3 * Math . PI ) / 4 , Math . PI / 4 ) ;
130- ctx . lineTo ( cx , tipY ) ;
195+ path . arc ( cx , cy , r , ( 3 * Math . PI ) / 4 , Math . PI / 4 ) ;
196+ path . lineTo ( cx , tipY ) ;
131197 }
132- ctx . closePath ( ) ;
198+ path . closePath ( ) ;
133199 // shadow系プロパティはscale()の影響を受けないため実ピクセルで指定する
134200 ctx . shadowColor = "rgba(0,0,0,0.35)" ;
135201 ctx . shadowBlur = 2 * PIXEL_RATIO ;
136202 ctx . shadowOffsetY = 1 * PIXEL_RATIO ;
137203 ctx . fillStyle = fill ;
138- ctx . fill ( ) ;
204+ ctx . fill ( path ) ;
139205 ctx . shadowColor = "transparent" ;
140206
141207 // 縁取りは常に描く。非公開だけ破線にする(それ以外はシリーズの見た目のまま)
142208 ctx . setLineDash ( isPrivate ? [ 3 , 2.5 ] : [ ] ) ;
143209 ctx . lineWidth = 1.5 ;
144210 ctx . strokeStyle = borderColor ;
145- ctx . stroke ( ) ;
211+ ctx . stroke ( path ) ;
146212 ctx . setLineDash ( [ ] ) ;
147213
148214 if ( ! visited && isImageLabel ( label ) ) {
0 commit comments