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
27 changes: 22 additions & 5 deletions examples/basic/src/pages/marker/streettree/StreetTreePage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import {
MarkerTilingOptions,
type MapDesignTypeInterface,
Expand All @@ -14,7 +14,19 @@ import { StreetTreeInfoBubble } from './StreetTreeInfoBubble';
import { browserStreetTreeDataSource } from './streetTreeData';
import { useStreetTreeMarkers } from './useStreetTreeMarkers';

const INIT_CAMERA = { lat: 35.6812, lng: 139.7671, zoom: 11 };
const DEFAULT_CAMERA = { lat: 35.6812, lng: 139.7671, zoom: 11 };

/**
* `?camera=lat,lng,zoom` で開く場所を変えられる。ラベルの付き方を同じ場所で見比べるためのもの
* (android-sdk のサンプルの `camera` extra と同じ)。
*/
function initialCamera(): { lat: number; lng: number; zoom: number } {
if (typeof window === 'undefined') return DEFAULT_CAMERA;
const value = new URLSearchParams(window.location.search).get('camera');
const [lat, lng, zoom] = (value ?? '').split(',').map(Number);
if ([lat, lng, zoom].some(n => !Number.isFinite(n))) return DEFAULT_CAMERA;
return { lat, lng, zoom };
}

const MARKER_TILING_OPTIONS: MarkerTilingOptions = {
...MarkerTilingOptions.Default,
Expand All @@ -30,8 +42,11 @@ export function StreetTreePage() {
const { t } = useSampleI18n();
const [selected, setSelected] = useState<MarkerState | null>(null);
const selectMarker = useCallback((marker: MarkerState) => setSelected(marker), []);
const { error, markerStates, trees, species, isLoading } =
useStreetTreeMarkers(browserStreetTreeDataSource, selectMarker);
const mapState = useRef<MapViewStateInterface<MapDesignTypeInterface<unknown>> | null>(null);
const currentCamera = useCallback(() => mapState.current?.cameraPosition ?? null, []);
const { error, markerStates, trees, species, isLoading, relabel } =
useStreetTreeMarkers(browserStreetTreeDataSource, selectMarker, currentCamera);
const [startCamera] = useState(initialCamera);

/**
* Swaps in a basemap stripped down to what a tree map needs: land, water,
Expand All @@ -45,6 +60,7 @@ export function StreetTreePage() {
*/
const applyTreeStyle = useCallback(
(state: MapViewStateInterface<MapDesignTypeInterface<unknown>>) => {
mapState.current = state;
const maplibre = state as unknown as { mapDesignType?: unknown };
if (!('mapDesignType' in maplibre)) return;
const design = state.mapDesignType as { styleJsonURL?: string } | undefined;
Expand All @@ -67,9 +83,10 @@ export function StreetTreePage() {

return (
<MapViewContainer
initialCamera={INIT_CAMERA}
initialCamera={startCamera}
markerTilingOptions={MARKER_TILING_OPTIONS}
onMapClick={() => setSelected(null)}
onCameraMoveEnd={relabel}
onStateReady={applyTreeStyle}
>
<Markers states={markerStates} />
Expand Down
179 changes: 179 additions & 0 deletions examples/basic/src/pages/marker/streettree/streetTreeLabels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { GeoRectBounds } from '@mapconductor/js-sdk-core';
import type { StreetTree } from './streetTreeData';

/** 点のどちら側にラベルを描くか。 */
export type LabelSide = 'right' | 'left' | 'top' | 'bottom';

/** 点の上下に置くラベルは縦書きにする。横の通りでは点が横に詰まって並ぶので、横書きだと数本に 1 つしか名前を付けられない。 */
export function isVerticalSide(side: LabelSide): boolean {
return side === 'top' || side === 'bottom';
}

interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}

function overlaps(a: Rect, b: Rect): boolean {
return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
}

/** 置いたラベルの箱。64px のマス目に振り分けて、近くだけを見る。 */
class PlacedRects {
private static readonly CELL = 64;
private readonly cells = new Map<string, Rect[]>();

private keysOf(r: Rect): string[] {
const keys: string[] = [];
const cell = PlacedRects.CELL;
for (let cx = Math.floor(r.left / cell); cx <= Math.floor(r.right / cell); cx++) {
for (let cy = Math.floor(r.top / cell); cy <= Math.floor(r.bottom / cell); cy++) keys.push(`${cx},${cy}`);
}
return keys;
}

overlaps(r: Rect): boolean {
return this.keysOf(r).some(key => this.cells.get(key)?.some(other => overlaps(other, r)) === true);
}

add(r: Rect): void {
for (const key of this.keysOf(r)) {
const list = this.cells.get(key);
if (list) list.push(r);
else this.cells.set(key, [r]);
}
}
}

export interface ChooseLabelsParams {
trees: readonly StreetTree[];
bounds: GeoRectBounds;
zoom: number;
/** ラベルのアイコンに掛かる倍率(タイルの iconScaleCallback と同じ値)。 */
scale: number;
dotSize: number;
/** 点とラベルのすき間(SDK の配置と同じ値)。 */
gap: number;
labelSize: (text: string, vertical: boolean) => { width: number; height: number };
/** 前回の道路名 -> 向き。 */
previousSides?: ReadonlyMap<string, LabelSide>;
}

/**
* 画面内の街路樹のうち、どれにどちら向きのラベルを付けるか。android-sdk の
* `StreetTreeLabels`、ios-sdk の `StreetTreeLabels` と同じ規則。
*
* - 向きは**道路ごとに 1 つ**。道路の木の並びが横向きなら上か下、縦向きなら右か左
* (道路に直交する向き)。同じ通りのラベルが揃って並ぶ。
* - 2 つの候補のうち、置けるラベルが多い方を選ぶ。木の多い道路から決める。
* ただし前回その道路で選んだ向き(`previousSides`)は、もう一方の 2/3 以上置けるなら
* そのまま使う。見えている範囲が変わるたびに数の多い方へ乗り換えると、パンするたびに
* ラベルが上下や左右で入れ替わる。
* - すでに置いたラベルか、ほかの木の点の中心を覆うラベルは付けない(その木は点のまま)。
* ラベルは点の後に描かれるので、端が隣の点に少しかかるのは許す。
* - 上下に置くラベルは縦書き(`isVerticalSide`)なので、縦書きの寸法で測る。
*
* 座標はカメラのズームでの CSS px。返り値は `trees` の添字 -> 向き。
*/
export function chooseLabels(params: ChooseLabelsParams): Map<number, LabelSide> {
const { trees, bounds, zoom, scale, dotSize, gap: gapPx, labelSize, previousSides } = params;
const southWest = bounds.southWest;
const northEast = bounds.northEast;
const sides = new Map<number, LabelSide>();
if (!southWest || !northEast) return sides;
const world = 256 * 2 ** zoom;
const x = (longitude: number) => ((longitude + 180) / 360) * world;
const y = (latitude: number) => {
const phi = (latitude * Math.PI) / 180;
return ((1 - Math.log(Math.tan(phi) + 1 / Math.cos(phi)) / Math.PI) / 2) * world;
};

// 道路ごとの、画面内の木
const byRoad = new Map<string, number[]>();
trees.forEach((tree, index) => {
if (tree.latitude < southWest.latitude || tree.latitude > northEast.latitude) return;
if (tree.longitude < southWest.longitude || tree.longitude > northEast.longitude) return;
const members = byRoad.get(tree.roadName);
if (members) members.push(index);
else byRoad.set(tree.roadName, [index]);
});

const placed = new PlacedRects();
const radius = (dotSize * scale) / 2;
const gap = gapPx * scale;

// 画面内のすべての点の中心。ラベルがほかの木の点の中心を覆わないかを見る
const dots = new PlacedRects();
for (const members of byRoad.values()) {
for (const index of members) {
const cx = x(trees[index].longitude);
const cy = y(trees[index].latitude);
dots.add({ left: cx - 0.5, top: cy - 0.5, right: cx + 0.5, bottom: cy + 0.5 });
}
}

const roads = [...byRoad.entries()].sort((a, b) => b[1].length - a[1].length);
for (const [road, members] of roads) {
const xs = members.map(index => x(trees[index].longitude));
const ys = members.map(index => y(trees[index].latitude));

// 並びの向き(主成分)
const mx = xs.reduce((sum, v) => sum + v, 0) / xs.length;
const my = ys.reduce((sum, v) => sum + v, 0) / ys.length;
let sxx = 0;
let syy = 0;
let sxy = 0;
for (let i = 0; i < xs.length; i++) {
const dx = xs[i] - mx;
const dy = ys[i] - my;
sxx += dx * dx;
syy += dy * dy;
sxy += dx * dy;
}
const angle = 0.5 * Math.atan2(2 * sxy, sxx - syy);
const horizontal = Math.abs(Math.cos(angle)) >= Math.abs(Math.sin(angle));
const candidates: LabelSide[] = horizontal ? ['top', 'bottom'] : ['right', 'left'];

// 道路に沿って並べてから置いていく
const order = xs.map((_, i) => i)
.sort((a, b) => (xs[a] * Math.cos(angle) + ys[a] * Math.sin(angle)) - (xs[b] * Math.cos(angle) + ys[b] * Math.sin(angle)));

const rect = (i: number, side: LabelSide): Rect => {
const size = labelSize(trees[members[i]].species, isVerticalSide(side));
const w = size.width * scale;
const h = size.height * scale;
const cx = xs[i];
const cy = ys[i];
switch (side) {
case 'right': return { left: cx + radius + gap, top: cy - h / 2, right: cx + radius + gap + w, bottom: cy + h / 2 };
case 'left': return { left: cx - radius - gap - w, top: cy - h / 2, right: cx - radius - gap, bottom: cy + h / 2 };
case 'top': return { left: cx - w / 2, top: cy - radius - gap - h, right: cx + w / 2, bottom: cy - radius - gap };
case 'bottom': return { left: cx - w / 2, top: cy + radius + gap, right: cx + w / 2, bottom: cy + radius + gap + h };
}
};

const attempt = (side: LabelSide): Array<[number, Rect]> => {
const own = new PlacedRects();
const accepted: Array<[number, Rect]> = [];
for (const i of order) {
const r = rect(i, side);
if (placed.overlaps(r) || own.overlaps(r) || dots.overlaps(r)) continue;
own.add(r);
accepted.push([i, r]);
}
return accepted;
};

const attempts = candidates.map(side => [side, attempt(side)] as const);
const most = attempts.reduce((a, b) => (b[1].length > a[1].length ? b : a));
const kept = attempts.find(([side]) => side === previousSides?.get(road));
const best = kept && kept[1].length * 3 >= most[1].length * 2 ? kept : most;
for (const [i, r] of best[1]) {
placed.add(r);
sides.set(members[i], best[0]);
}
}
return sides;
}
66 changes: 66 additions & 0 deletions examples/basic/src/pages/marker/streettree/treeIcons.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,70 @@
import { ImageIcon } from '@mapconductor/js-sdk-core';
import { isVerticalSide, type LabelSide } from './streetTreeLabels';

/** ラベルの文字の大きさ(CSS px)。ラベルの重なり判定もこの値で測る。 */
export const LABEL_TEXT_PX = 11;

/** ラベルの字体。SDK はこれを canvas の font と SVG の font-family にそのまま使う。 */
const LABEL_TYPE_FACE = 'sans-serif';

/**
* 縁取りの太さ(CSS px)。SDK は `max(アイコンの scale, 2)` で、ラベルの幅と高さにこの 2 倍を足す。
* 点とラベルのすき間も同じ値になる(`max(縁取り, 点の大きさ × 0.05)`)。
*/
export const LABEL_OUTLINE_PX = 2;

const labelSizes = new Map<string, { width: number; height: number }>();
let measureContext: CanvasRenderingContext2D | null = null;

/**
* ラベルの幅と高さ(CSS px、縁取りを含む)。樹種名と向きごとに 1 回だけ測る。
* SDK と同じ測り方(縦書きは 1 文字 1em に積み、列の幅は字の幅と 1em の大きい方)。
*/
export function labelSize(text: string, vertical: boolean): { width: number; height: number } {
const key = `${vertical ? 'v' : 'h'}/${text}`;
const cached = labelSizes.get(key);
if (cached) return cached;
measureContext ??= document.createElement('canvas').getContext('2d');
const measure = (value: string) => {
if (!measureContext) return value.length * LABEL_TEXT_PX * 0.6;
measureContext.font = `${LABEL_TEXT_PX}px ${LABEL_TYPE_FACE}`;
return measureContext.measureText(value).width;
};
const outline = LABEL_OUTLINE_PX * 2;
let size: { width: number; height: number };
if (vertical) {
const glyphs = Array.from(text);
let column = LABEL_TEXT_PX;
for (const glyph of glyphs) column = Math.max(column, measure(glyph));
size = { width: Math.ceil(column) + outline, height: glyphs.length * LABEL_TEXT_PX + outline };
} else {
size = { width: measure(text) + outline, height: LABEL_TEXT_PX + outline };
}
labelSizes.set(key, size);
return size;
}

/**
* 点の `side` 側に樹種名を添えたアイコン。
*
* SDK の `ImageIcon` のラベルを使う。点の画像は作り直さない -- タイル描画は点の画像と
* 文字を別々に描くので、ラベル付きのアイコンをいくつ作っても点の画像は 1 枚のまま。
* android-sdk / ios-sdk の `StreetTreeIcons.labelled` と同じ。
*/
export function labelledIcon(dot: ImageIcon, text: string, side: LabelSide): ImageIcon {
return new ImageIcon(dot.image, {
iconSize: dot.iconSize,
anchor: { x: 0.5, y: 0.5 },
infoAnchor: { x: 0.5, y: 0 },
label: text,
labelTextColor: '#282828',
labelTextSize: LABEL_TEXT_PX,
labelTypeFace: LABEL_TYPE_FACE,
labelStrokeColor: '#FFFFFF',
labelPosition: side,
labelWritingMode: isVerticalSide(side) ? 'vertical-rl' : 'horizontal-tb',
});
}

/**
* One coloured dot per species, shared by every tree of that species.
Expand Down
Loading
Loading