Skip to content

Commit 1abfd3a

Browse files
committed
ピンの形を増やし、設定ファイルにSVGパスを書けるようにする
カテゴリで種類を見分けられるよう、組み込みの形に diamond・pentagon・hexagon・ castle(上辺を凸凹にした城郭の胸壁)を足した。多角形は真下に頂点が来る向きに しない —— ピンのとんがりと重なって長さ0の線分になり、輪郭が潰れるため。 あわせて、組み込みの形で足りないときは category_styles に path として SVGのパス(d)を直接書けるようにした。形を増やすたびにアプリを直して デプロイする必要をなくすため。幅100・高さ145の箱に描き、箱の下端中央が スポットの位置になる(下がとんがっている必要は無い)。 - 描画は Path2D に寄せ、自前パスは addPath に変換行列を渡して取り込む - 画像ID(pinIconId)にはパスのハッシュを混ぜる。混ぜないとパスを書き換えても 既存のピン画像が使われ続ける - パスはcanvasで図形を描くだけでスクリプトは走らないので設定から受け取って よいが、打ち間違いを黙って空のピンにしないよう字面を検査する (isValidPinPath。travel-log-data の validate_data.py にも同じ検査を置く) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 63c34b6 commit 1abfd3a

3 files changed

Lines changed: 157 additions & 37 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,30 @@ GitHub Actions(`.github/workflows/docker-publish.yml`)がビルド時に`<JST日
145145
**カテゴリに握らせる見た目は「形」だけ**にしてある(`lib/categoryStyle.ts`)。色・大きさ・
146146
ラベルはシリーズが、塗りの上書き(緑+✓)は訪問済みが、破線の縁取りは非公開スポットが
147147
すでに使っており、そこへカテゴリを重ねるとどちらが効いているのか読めない見た目になるため。
148-
形はそれらと直交していて、最小のピン(size 12)でも見分けられる。用途は「その場所が
149-
どういう種類か」ではなく**シリーズとは別の軸をひと目で見せたいとき**(観光地の
150-
`じっくり` = 立ち寄るのに手間がかかる、など)
148+
形はそれらと直交している。用途は「その場所がどういう種類か」(観光地の神社仏閣・城・
149+
商業施設・美術館博物館)と**シリーズとは別の軸**(同じく`じっくり` = 立ち寄るのに
150+
手間がかかる)の両方。**ただし1スポットに出せる形は1つ**なので、両方を同時には示せない
151151

152152
`spot_type_settings``category_styles`キー(`CATEGORY_STYLES_SETTING_KEY`)に
153-
JSON文字列(`CategoryStyleDefinition[]` = `{ category, shape }`の配列)を保存する。
154-
`shape``PIN_SHAPES`(`circle` / `rounded-square`)のいずれか。**シリーズと違い既定は
155-
空配列**(=すべて既定の丸)で、使いたい種別だけが設定するもの。取得は
156-
`useCategoryStyles(typeKey)`フック(`useSeriesStyles`のカテゴリ版)。
153+
JSON文字列(`CategoryStyleDefinition[]` = `{ category, shape? , path? }`の配列)を
154+
保存する。`shape``PIN_SHAPES`(`circle` / `rounded-square` / `diamond` / `pentagon` /
155+
`hexagon` / `castle`)のいずれか。**シリーズと違い既定は空配列**(=すべて既定の丸)で、
156+
使いたい種別だけが設定するもの。取得は`useCategoryStyles(typeKey)`フック
157+
(`useSeriesStyles`のカテゴリ版)。
158+
159+
**組み込みの形で足りないときは`path`にSVGのパス(`d`)を書ける**(`shape`より優先。
160+
アプリを直さずに設定側で形を増やせるようにするため)。**幅100・高さ145の箱**に描き、
161+
**箱の下端中央がスポットの位置**(`icon-anchor: bottom`)。**下がとんがっている必要は
162+
無い**。ラベル(シリーズの文字)は頭の中心(50,50)に描かれるのでそこは塗らない。
163+
描画は`Path2D`に組み、自前パスは`addPath`に変換行列を渡して取り込む
164+
(`lib/pinIcon.ts`)。**画像ID(`pinIconId`)にはパスのハッシュを混ぜる** ——
165+
混ぜないとパスを書き換えても既存の画像が使われ続ける。
166+
**パスはcanvasで図形を描くだけでスクリプトは走らない**ので設定から受け取ってよいが、
167+
打ち間違いを黙って空のピンにしないよう字面を検査する(`isValidPinPath`
168+
travel-log-dataの`validate_data.py`にも同じ検査がある)。
169+
**組み込みの形を足すときは`PIN_SHAPES``lib/pinIcon.ts`の描画・
170+
travel-log-dataの`SHAPES`の3か所**をそろえること。多角形は**真下に頂点が来る向きに
171+
しない**(ピンのとんがりと重なって輪郭が潰れる)。
157172

158173
**1スポットは複数カテゴリを持てるので、設定の配列順で最初に一致したものを採用する**
159174
(`findPinShape`)。シリーズの配列順が並び順を決めているのと同じ考え方。

lib/categoryStyle.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,52 @@ import type { Category, SpotType } from "./types";
2020
*/
2121

2222
/** ピンの頭の形。増やすときはlib/pinIcon.tsの描画にも分岐を足すこと */
23-
export const PIN_SHAPES = ["circle", "rounded-square"] as const;
23+
export const PIN_SHAPES = [
24+
"circle",
25+
"rounded-square",
26+
"diamond",
27+
"pentagon",
28+
"hexagon",
29+
"castle",
30+
] as const;
2431
export type PinShape = (typeof PIN_SHAPES)[number];
2532

2633
/** 設定が無いカテゴリ・設定そのものが無い種別で使う形 */
2734
export const DEFAULT_PIN_SHAPE: PinShape = "circle";
2835

36+
/**
37+
* 自前の形を書くときの座標系。**幅100・高さ145の箱**に、SVGのパス(`d`)で描く。
38+
* 145 は「頭100 + とんがり45」の比で、組み込みの形と同じ縦横比になる。
39+
*
40+
* **箱の下端中央(50,145)がスポットの位置**(symbolレイヤーは`icon-anchor: bottom`)。
41+
* とんがりを付けたければそこまで伸ばし、付けないなら図形の下端がその位置に接する。
42+
* ラベル(シリーズの文字)は**頭の中心(50,50)**に描くので、そこは塗りを空けておく。
43+
*/
44+
export const PIN_PATH_VIEW_WIDTH = 100;
45+
export const PIN_PATH_VIEW_HEIGHT = 145;
46+
47+
/**
48+
* パス(`d`)として妥当な文字だけでできているか。**canvasはパスを描くだけで
49+
* スクリプトを実行しないので危険は無い**が、打ち間違いを黙って空のピンにしない
50+
* ための最低限の検査(Path2Dは不正な断片を例外にせず無視する)。
51+
*/
52+
const PATH_D_RE = /^[Mm][\s0-9.,+\-eE]*[MmLlHhVvCcSsQqTtAaZz][A-Za-z0-9.,+\-eE\s]*$/;
53+
54+
export function isValidPinPath(v: unknown): v is string {
55+
return typeof v === "string" && v.length > 0 && v.length <= 4000 && PATH_D_RE.test(v);
56+
}
57+
2958
export interface CategoryStyleDefinition {
3059
category: string;
31-
shape: PinShape;
60+
/** 組み込みの形。`path`を書いたときは省略できる */
61+
shape?: PinShape;
62+
/** 自前の形(SVGのパス。100×145の箱に描く)。`shape`より優先する */
63+
path?: string;
3264
}
3365

66+
/** 解決済みの形。文字列なら組み込み、オブジェクトなら自前のパス */
67+
export type PinShapeSpec = PinShape | { path: string };
68+
3469
/** spot_type_settingsにおける、カテゴリの見た目設定を保存するキー(値はJSON文字列) */
3570
export const CATEGORY_STYLES_SETTING_KEY = "category_styles";
3671

@@ -48,6 +83,8 @@ export function isValidCategoryStyle(v: unknown): v is CategoryStyleDefinition {
4883
if (typeof v !== "object" || v === null) return false;
4984
const o = v as Record<string, unknown>;
5085
if (typeof o.category !== "string" || !o.category) return false;
86+
// pathがあればそちらを使う(shapeは省略可)。両方無い・両方不正なら無効
87+
if (o.path !== undefined) return isValidPinPath(o.path);
5188
return isPinShape(o.shape);
5289
}
5390

@@ -81,10 +118,12 @@ export function resolveCategoryStyles(
81118
export function findPinShape(
82119
categories: Category[] | null | undefined,
83120
styles: CategoryStyleDefinition[]
84-
): PinShape {
121+
): PinShapeSpec {
85122
if (!categories || categories.length === 0 || styles.length === 0) {
86123
return DEFAULT_PIN_SHAPE;
87124
}
88125
const found = styles.find((s) => categories.includes(s.category));
89-
return found ? found.shape : DEFAULT_PIN_SHAPE;
126+
if (!found) return DEFAULT_PIN_SHAPE;
127+
if (found.path) return { path: found.path };
128+
return found.shape ?? DEFAULT_PIN_SHAPE;
90129
}

lib/pinIcon.ts

Lines changed: 92 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type * as maplibregl from "maplibre-gl";
22
import type { Series } from "./types";
33
import { 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+
4959
export 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

Comments
 (0)