Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions api/js-sdk-core.api.d.ts

Large diffs are not rendered by default.

167 changes: 145 additions & 22 deletions src/marker/AbstractDefaultIcon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -99,36 +150,105 @@ 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
? `<rect x="0.5" y="0.5" width="${fmt(bitmapWidth - 1)}" height="${fmt(bitmapHeight - 1)}" fill="none" stroke="#000000" stroke-width="1"/>`
: "";

return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${bitmapWidth}" height="${bitmapHeight}" viewBox="0 0 ${bitmapWidth} ${bitmapHeight}">`,
this.createFillSvg(markerPath, markerSize, markerOffsetX),
this.createFillSvg(markerPath, markerSize, markerOffsetX, markerOffsetY),
`<path d="${markerPath}" fill="none" stroke="${escapeXml(this.strokeColor)}" stroke-width="${fmt(strokeWidth)}" stroke-linejoin="round" stroke-linecap="round"/>`,
label,
debug,
Expand All @@ -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 "";
Expand All @@ -147,18 +267,21 @@ 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 ${attrs} fill="none" stroke="${stroke}" stroke-width="${fmt(layout.outlineStroke)}" stroke-linejoin="round" stroke-linecap="round">${text}</text>`,
`<text ${attrs} fill="${fill}">${text}</text>`,
].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;
}
37 changes: 24 additions & 13 deletions src/marker/ColorDefaultIcon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import {

// オプション型は DefaultIconTypes.ts にある。以前からこのモジュール名で公開しているので、
// そのまま再エクスポートして import 元を変えずに済ませる。
export type { DefaultMarkerIconOptions, ImageDefaultIconOptions } from "./DefaultIconTypes";
export type {
DefaultMarkerIconOptions,
ImageDefaultIconOptions,
MarkerLabelHorizontalPosition,
MarkerLabelPosition,
MarkerLabelVerticalPosition,
} from "./DefaultIconTypes";

/**
* 単色で塗る既定アイコン。`DefaultMarkerIcon` はこれの別名。
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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 と同じ計算式)
Expand All @@ -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}`;
Expand All @@ -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) {
Expand All @@ -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 [
`<defs><clipPath id="${clipId}"><path d="${markerPath}"/></clipPath></defs>`,
`<image href="${href}" x="${fmt(markerOffsetX)}" y="0" width="${markerSize}" height="${markerSize}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${clipId})"/>`,
`<image href="${href}" x="${fmt(markerOffsetX)}" y="${fmt(markerOffsetY)}" width="${markerSize}" height="${markerSize}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${clipId})"/>`,
].join("");
}

Expand Down
Loading