diff --git a/examples/basic/src/pages/marker/streettree/StreetTreePage.tsx b/examples/basic/src/pages/marker/streettree/StreetTreePage.tsx index b9b5ecf..8796df1 100644 --- a/examples/basic/src/pages/marker/streettree/StreetTreePage.tsx +++ b/examples/basic/src/pages/marker/streettree/StreetTreePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { MarkerTilingOptions, type MapDesignTypeInterface, @@ -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, @@ -30,8 +42,11 @@ export function StreetTreePage() { const { t } = useSampleI18n(); const [selected, setSelected] = useState(null); const selectMarker = useCallback((marker: MarkerState) => setSelected(marker), []); - const { error, markerStates, trees, species, isLoading } = - useStreetTreeMarkers(browserStreetTreeDataSource, selectMarker); + const mapState = useRef> | 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, @@ -45,6 +60,7 @@ export function StreetTreePage() { */ const applyTreeStyle = useCallback( (state: MapViewStateInterface>) => { + mapState.current = state; const maplibre = state as unknown as { mapDesignType?: unknown }; if (!('mapDesignType' in maplibre)) return; const design = state.mapDesignType as { styleJsonURL?: string } | undefined; @@ -67,9 +83,10 @@ export function StreetTreePage() { return ( setSelected(null)} + onCameraMoveEnd={relabel} onStateReady={applyTreeStyle} > diff --git a/examples/basic/src/pages/marker/streettree/streetTreeLabels.ts b/examples/basic/src/pages/marker/streettree/streetTreeLabels.ts new file mode 100644 index 0000000..c400cc9 --- /dev/null +++ b/examples/basic/src/pages/marker/streettree/streetTreeLabels.ts @@ -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(); + + 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; +} + +/** + * 画面内の街路樹のうち、どれにどちら向きのラベルを付けるか。android-sdk の + * `StreetTreeLabels`、ios-sdk の `StreetTreeLabels` と同じ規則。 + * + * - 向きは**道路ごとに 1 つ**。道路の木の並びが横向きなら上か下、縦向きなら右か左 + * (道路に直交する向き)。同じ通りのラベルが揃って並ぶ。 + * - 2 つの候補のうち、置けるラベルが多い方を選ぶ。木の多い道路から決める。 + * ただし前回その道路で選んだ向き(`previousSides`)は、もう一方の 2/3 以上置けるなら + * そのまま使う。見えている範囲が変わるたびに数の多い方へ乗り換えると、パンするたびに + * ラベルが上下や左右で入れ替わる。 + * - すでに置いたラベルか、ほかの木の点の中心を覆うラベルは付けない(その木は点のまま)。 + * ラベルは点の後に描かれるので、端が隣の点に少しかかるのは許す。 + * - 上下に置くラベルは縦書き(`isVerticalSide`)なので、縦書きの寸法で測る。 + * + * 座標はカメラのズームでの CSS px。返り値は `trees` の添字 -> 向き。 + */ +export function chooseLabels(params: ChooseLabelsParams): Map { + const { trees, bounds, zoom, scale, dotSize, gap: gapPx, labelSize, previousSides } = params; + const southWest = bounds.southWest; + const northEast = bounds.northEast; + const sides = new Map(); + 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(); + 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; +} diff --git a/examples/basic/src/pages/marker/streettree/treeIcons.ts b/examples/basic/src/pages/marker/streettree/treeIcons.ts index 5b3dc7e..772b1eb 100644 --- a/examples/basic/src/pages/marker/streettree/treeIcons.ts +++ b/examples/basic/src/pages/marker/streettree/treeIcons.ts @@ -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(); +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. diff --git a/examples/basic/src/pages/marker/streettree/useStreetTreeMarkers.ts b/examples/basic/src/pages/marker/streettree/useStreetTreeMarkers.ts index d8e7a9a..7fe2555 100644 --- a/examples/basic/src/pages/marker/streettree/useStreetTreeMarkers.ts +++ b/examples/basic/src/pages/marker/streettree/useStreetTreeMarkers.ts @@ -1,24 +1,62 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createGeoPoint, + createGeoRectBounds, createMarkerState, + type GeoRectBounds, type ImageIcon, + type MapCameraPosition, type MarkerState, } from '@mapconductor/js-sdk-core'; -import { createSpeciesIcons } from './treeIcons'; +import { createSpeciesIcons, LABEL_OUTLINE_PX, labelledIcon, labelSize } from './treeIcons'; +import { chooseLabels, type LabelSide } from './streetTreeLabels'; import type { StreetTree, StreetTreeDataSource } from './streetTreeData'; +/** これより寄ったら樹種名を付ける。 */ +export const LABEL_MIN_ZOOM = 16; + +/** 点の大きさ(CSS px)。 */ +const DOT_SIZE = 10; + +/** zoom > 15 の iconScaleCallback と同じ値。ラベルの重なりはこの大きさで測る。 */ +export const LABEL_ICON_SCALE = 1.4; + +/** + * 見えている範囲が分からないときに使う、中心から上下左右 `AROUND_CENTER_PX` の箱。 + * web の地図は開いた直後のカメラに見えている範囲を持たないことがある。画面より広めに + * 取っても、画面の外の木にラベルが付くだけで害は無い。 + */ +const AROUND_CENTER_PX = 1024; + +function aroundCenter(camera: MapCameraPosition): GeoRectBounds { + const world = 256 * 2 ** camera.zoom; + const { latitude, longitude } = camera.position; + const x = ((longitude + 180) / 360) * world; + const phi = (latitude * Math.PI) / 180; + const y = ((1 - Math.log(Math.tan(phi) + 1 / Math.cos(phi)) / Math.PI) / 2) * world; + const lng = (px: number) => (px / world) * 360 - 180; + const lat = (py: number) => (Math.atan(Math.sinh(Math.PI * (1 - (2 * py) / world))) * 180) / Math.PI; + return createGeoRectBounds({ + southWest: createGeoPoint({ latitude: lat(y + AROUND_CENTER_PX), longitude: lng(x - AROUND_CENTER_PX) }), + northEast: createGeoPoint({ latitude: lat(y - AROUND_CENTER_PX), longitude: lng(x + AROUND_CENTER_PX) }), + }); +} + interface StreetTreeMarkerResult { error: string | null; markerStates: MarkerState[]; trees: StreetTree[] | null; species: string[] | null; isLoading: boolean; + /** カメラが止まったら呼ぶ。見えている木に樹種名を付け直す。 */ + relabel: (camera: MapCameraPosition) => void; } export function useStreetTreeMarkers( dataSource: StreetTreeDataSource, onMarkerClick: (marker: MarkerState) => void, + /** 開いた直後の位置。web の地図は開いたときにカメラの停止を知らせないことがある。 */ + currentCamera?: () => MapCameraPosition | null, ): StreetTreeMarkerResult { const [trees, setTrees] = useState(null); const [species, setSpecies] = useState(null); @@ -31,7 +69,7 @@ export function useStreetTreeMarkers( if (!active) return; setTrees(data.trees); setSpecies(data.species); - setIcons(createSpeciesIcons(data.species.length)); + setIcons(createSpeciesIcons(data.species.length, DOT_SIZE)); }).catch(reason => { if (active) setError(String(reason)); }); @@ -50,5 +88,59 @@ export function useStreetTreeMarkers( [icons, onMarkerClick, trees], ); - return { error, markerStates, trees, species, isLoading: trees === null && error === null }; + // 付けているラベル(木の添字 -> 向き)と、道路ごとの前回の向き。 + const labelled = useRef(new Map()); + const roadSides = useRef(new Map()); + const labelIcons = useRef(new Map()); + // カメラは木を読み終える前に止まる。そのときの位置を覚えておき、読み終えたら付ける。 + const lastCamera = useRef(null); + + /** + * 見えている木のうち、どれにどちら向きのラベルを付けるかを決め直し、変わった木だけ + * アイコンを差し替える(`MarkerState.icon` をその場で書き換える)。android-sdk の + * `StreetTreeViewModel.onCameraMoveEnd` と同じ。 + */ + const relabel = useCallback((camera: MapCameraPosition) => { + lastCamera.current = camera; + if (!trees || !icons || markerStates.length !== trees.length) return; + let sides = new Map(); + if (camera.zoom >= LABEL_MIN_ZOOM) { + const bounds = camera.visibleRegion?.bounds ?? aroundCenter(camera); + sides = chooseLabels({ + trees, + bounds, + zoom: camera.zoom, + scale: LABEL_ICON_SCALE, + dotSize: DOT_SIZE, + gap: LABEL_OUTLINE_PX, + labelSize, + previousSides: roadSides.current, + }); + for (const [index, side] of sides) roadSides.current.set(trees[index].roadName, side); + } + for (const index of labelled.current.keys()) { + if (!sides.has(index)) markerStates[index].icon = icons[trees[index].speciesIndex]; + } + for (const [index, side] of sides) { + if (labelled.current.get(index) === side) continue; + const tree = trees[index]; + const key = `${tree.speciesIndex}/${side}`; + let icon = labelIcons.current.get(key); + if (!icon) { + icon = labelledIcon(icons[tree.speciesIndex], tree.species, side); + labelIcons.current.set(key, icon); + } + markerStates[index].icon = icon; + } + labelled.current = sides; + }, [icons, markerStates, trees]); + + useEffect(() => { + labelled.current = new Map(); + labelIcons.current = new Map(); + const camera = lastCamera.current ?? currentCamera?.(); + if (camera) relabel(camera); + }, [relabel, currentCamera]); + + return { error, markerStates, trees, species, isLoading: trees === null && error === null, relabel }; } diff --git a/js-sdk-core b/js-sdk-core index cacdfc4..734f13a 160000 --- a/js-sdk-core +++ b/js-sdk-core @@ -1 +1 @@ -Subproject commit cacdfc43fbf3bd476975ffbd4575feb4c3296cc5 +Subproject commit 734f13ae77861fdb8f587d232872b6b2e2209f0f diff --git a/react-for-here b/react-for-here index e277e84..f9fedf9 160000 --- a/react-for-here +++ b/react-for-here @@ -1 +1 @@ -Subproject commit e277e84d49ae9546da961aad359a0a4edaec1756 +Subproject commit f9fedf9e2e131ff91ba88b580cf267d847ec3e12