@maplat/transform
Version:
A JavaScript library that performs coordinate transformation between two plane coordinate systems using transformation definitions generated by Maplat.
474 lines (415 loc) • 17.9 kB
text/typescript
import type { Feature, Polygon } from "geojson";
import { booleanPointInPolygon, point, polygon } from "@turf/turf";
import { Transform } from "./transform.ts";
import { zoom2Radius, mercViewpoint2Mercs, mercs2MercViewpoint } from "./viewpoint.ts";
import { xy2SysCoord, sysCoord2Xy } from "./coord-utils.ts";
import type { MapData, Viewpoint } from "./types.ts";
/** Internal representation of a loaded sub-map TIN entry */
interface SubTinEntry {
tin: Transform;
priority: number;
importance: number;
xyBounds: Feature<Polygon>;
mercBounds: Feature<Polygon>;
}
/** Tile size constant (same as MaplatCore) */
const TILE_SIZE = 256;
/**
* MapTransform — 処理2・3・4を担う座標変換クラス
*
* - 処理2: submaps 属性を持つ地図で、複数 TIN のうちどれを適用するか判定・選択し座標変換
* - 処理3: ビューポート ↔ メルカトル5点 変換
* - 処理4: ビューポート ↔ TIN 適用後メルカトル5点 変換
*
* OpenLayers への依存ゼロ。ブラウザ・Node.js 両対応。
*/
export class MapTransform {
private mainTin: Transform | null = null;
private subTins: SubTinEntry[] = [];
private _maxxy = 0;
// ─── 初期化 ────────────────────────────────────────────────────────────────
/**
* 地図データ(コンパイル済み TIN + sub_maps)をロードする
*
* @param mapData - メイン TIN と sub_maps の情報
*/
setMapData(mapData: MapData): void {
// メイン TIN を構築
const mainTin = new Transform();
mainTin.setCompiled(mapData.compiled);
this.mainTin = mainTin;
// _maxxy を計算
// 優先順位: MapData.maxZoom > compiled.wh からの自動計算
if (mapData.maxZoom !== undefined) {
this._maxxy = Math.pow(2, mapData.maxZoom) * TILE_SIZE;
} else if (mapData.compiled.wh) {
const maxDim = Math.max(mapData.compiled.wh[0], mapData.compiled.wh[1]);
const maxZoom = Math.ceil(Math.log2(maxDim / TILE_SIZE));
this._maxxy = Math.pow(2, maxZoom) * TILE_SIZE;
}
// sub_maps を構築
this.subTins = [];
if (mapData.sub_maps) {
for (const subMapData of mapData.sub_maps) {
const tin = new Transform();
tin.setCompiled(subMapData.compiled);
// bounds: SubMapData.bounds を優先し、なければ compiled.bounds を使用
const rawBounds: number[][] | undefined =
subMapData.bounds ?? subMapData.compiled.bounds;
if (!rawBounds) {
throw new Error(
"SubMapData must have bounds or compiled.bounds to create xyBounds polygon"
);
}
const xyBoundsCoords = [...rawBounds, rawBounds[0]];
const mercBoundsCoords = xyBoundsCoords.map(xy => {
const merc = tin.transform(xy, false);
if (!merc) throw new Error("Failed to transform sub-map bounds to mercator");
return merc;
});
this.subTins.push({
tin,
priority: subMapData.priority,
importance: subMapData.importance,
xyBounds: polygon([xyBoundsCoords]) as Feature<Polygon>,
mercBounds: polygon([mercBoundsCoords]) as Feature<Polygon>
});
}
}
}
// ─── 処理2: submap TIN 選択付き変換 ───────────────────────────────────────
/**
* ピクセル座標 → メルカトル座標(最適レイヤー選択)
*
* @param xy - ピクセル座標 [x, y]
* @returns メルカトル座標、または範囲外の場合は false
*/
xy2Merc(xy: number[]): number[] | false {
const result = this.xy2MercWithLayer(xy);
if (!result) return false;
return result[1];
}
/**
* メルカトル座標 → ピクセル座標(最適レイヤー選択)
*
* @param merc - メルカトル座標 [x, y]
* @returns ピクセル座標、または範囲外の場合は false
*/
merc2Xy(merc: number[]): number[] | false {
const results = this.merc2XyWithLayer(merc);
const first = results[0] || results[1];
if (!first) return false;
return first[1];
}
/**
* ピクセル座標 → メルカトル座標(レイヤーID付き)
* histmap_tin.ts xy2MercAsync_returnLayer() の同期版
*
* @param xy - ピクセル座標 [x, y]
* @returns [レイヤーインデックス, メルカトル座標] または false
*/
xy2MercWithLayer(xy: number[]): [number, number[]] | false {
this._assertMapData();
// priority 降順にソート(index 0 はメイン TIN)
const tinSorted = this._getTinsSortedByPriority();
for (let i = 0; i < tinSorted.length; i++) {
const { index, isMain } = tinSorted[i];
// メイン TIN(index 0)は常にマッチ、sub TIN はポリゴン内チェック
if (isMain || booleanPointInPolygon(point(xy), this.subTins[index - 1].xyBounds)) {
const merc = this._transformByIndex(xy, index, false);
if (merc === false) continue;
return [index, merc];
}
}
return false;
}
/**
* メルカトル座標 → ピクセル座標(複数レイヤー結果)
* histmap_tin.ts merc2XyAsync_returnLayer() の同期版
*
* 現在は MaplatCore の仕様に合わせ、最大2レイヤーまで返す。
* 3レイヤー以上返したい場合は、下記の .slice(0, 2) および .filter(i < 2) の
* 上限値を増やすか、引数で上限を指定できるようにすること。
*
* @param merc - メルカトル座標 [x, y]
* @returns 最大2要素の配列。各要素は [レイヤーインデックス, ピクセル座標] または undefined
*/
merc2XyWithLayer(merc: number[]): ([number, number[]] | undefined)[] {
this._assertMapData();
// 全 TIN で逆変換を実行し、各結果が xyBounds 内かを確認
const allTins = this._getAllTinsWithIndex();
const rawResults: ([Transform, number, number[]] | [Transform, number])[] =
allTins.map(({ index, tin, isMain }) => {
const xy = this._transformByIndex(merc, index, true);
if (xy === false) return [tin, index] as [Transform, number];
// メイン TIN(index 0)は常に有効、sub TIN はポリゴン内チェック
if (isMain || booleanPointInPolygon(point(xy), this.subTins[index - 1].xyBounds)) {
return [tin, index, xy] as [Transform, number, number[]];
}
return [tin, index] as [Transform, number];
});
// priority 降順でソート
const sorted = rawResults.sort((a, b) => {
const priA = a[0].priority ?? 0;
const priB = b[0].priority ?? 0;
return priA < priB ? 1 : -1;
});
// importance と優先度で最大2レイヤーを選択
const result = sorted.reduce(
(
ret: (undefined | [number, number[], Transform])[],
current,
priIndex,
arry
) => {
const tin = current[0];
const index = current[1];
const xy = current[2] as number[] | undefined;
if (!xy) return ret;
// より高優先レイヤーの xyBounds 内にあるか確認
for (let i = 0; i < priIndex; i++) {
const targetIndex = arry[i][1];
const isTargetMain = targetIndex === 0;
// 高優先 TIN が変換失敗している場合はスキップ
if (!arry[i][2]) continue;
if (
isTargetMain ||
booleanPointInPolygon(point(xy), this.subTins[targetIndex - 1].xyBounds)
) {
if (ret.length) {
const hide = !ret[0];
const storedTin = hide ? ret[1]![2] : ret[0]![2];
const tinImportance = tin.importance ?? 0;
const storedImportance = storedTin.importance ?? 0;
if (!hide) {
// visible な stored TIN が既に存在 → current を 2番目要素として追加
const visible = ret.filter(
(r): r is [number, number[], Transform] => r !== undefined
);
const newRet = [...visible, [index, xy, tin] as [number, number[], Transform]];
return newRet
.sort((a, b) =>
(a[2].importance ?? 0) < (b[2].importance ?? 0) ? 1 : -1
)
.slice(0, 2) as (undefined | [number, number[], Transform])[]; // 上限を増やす場合はここも変更
} else if (tinImportance < storedImportance) {
return ret;
} else {
return [undefined, [index, xy, tin]] as (undefined | [number, number[], Transform])[];
}
} else {
// ret が空なのに xyBounds 内 → 高優先 TIN が変換失敗しているだけ → visible として追加
return [[index, xy, tin]] as (undefined | [number, number[], Transform])[];
}
}
}
if (!ret.length || !ret[0]) {
return [[index, xy, tin]] as (undefined | [number, number[], Transform])[];
} else {
ret.push([index, xy, tin]);
return ret
.sort((a, b) => {
const impA = a![2].importance ?? 0;
const impB = b![2].importance ?? 0;
return impA < impB ? 1 : -1;
})
.filter((_row, i) => i < 2); // 上限を増やす場合はここも変更(例: i < 3)
}
},
[]
);
return result.map(row => {
if (!row) return undefined;
return [row[0], row[1]] as [number, number[]];
});
}
/**
* メルカトル5点 → システム座標(複数レイヤー)
* histmap_tin.ts mercs2SysCoordsAsync_multiLayer() の同期版
*
* @param mercs - 5点のメルカトル座標配列(中心+上下左右)
* @returns 各レイヤーのシステム座標配列(または undefined)
*/
mercs2SysCoords(mercs: number[][]): (number[][] | undefined)[] {
this._assertMapData();
const centerResults = this.merc2XyWithLayer(mercs[0]);
let hide = false;
return centerResults.map((result, i) => {
if (!result) {
hide = true;
return undefined;
}
const index = result[0];
const centerXy = result[1];
if (i !== 0 && !hide) return [this.xy2SysCoordInternal(centerXy)];
const xys = mercs.map((merc, j) => {
if (j === 0) return centerXy;
return this._transformByIndex(merc, index, true) as number[];
});
return xys.map(xy => this.xy2SysCoordInternal(xy));
});
}
// ─── 処理3: ビューポート変換 ───────────────────────────────────────────────
/**
* ビューポート → TIN 適用後メルカトル5点
* histmap_tin.ts viewpoint2MercsAsync() の同期版
*
* @param viewpoint - ビューポート(center, zoom, rotation)
* @param size - 画面サイズ [width, height]
* @returns TIN 変換後のメルカトル5点
*/
viewpoint2Mercs(viewpoint: Viewpoint, size: [number, number]): number[][] {
this._assertMapData();
this._assertMaxxy();
// ビューポート → メルカトル5点(処理3の純粋関数)
const mercs = mercViewpoint2Mercs(viewpoint.center, viewpoint.zoom, viewpoint.rotation, size);
// メルカトル → ピクセル座標(sysCoord2Xy)
const xys = mercs.map(merc => sysCoord2Xy(merc, this._maxxy));
// ピクセル中心 → TIN 変換 → メルカトル中心
const centerResult = this.xy2MercWithLayer(xys[0]);
if (!centerResult) throw new Error("viewpoint2Mercs: center point is out of bounds");
const centerIndex = centerResult[0];
const centerMerc = centerResult[1];
// 残り4点を同じレイヤーで変換
const resultMercs: number[][] = xys.map((xy, i) => {
if (i === 0) return centerMerc;
const merc = this._transformByIndex(xy, centerIndex, false);
if (merc === false) throw new Error(`viewpoint2Mercs: point ${i} is out of bounds`);
return merc;
});
return resultMercs;
}
/**
* TIN 適用後メルカトル5点 → ビューポート
* histmap_tin.ts mercs2ViewpointAsync() の同期版
*
* @param mercs - TIN 変換後のメルカトル5点
* @param size - 画面サイズ [width, height]
* @returns ビューポート(center, zoom, rotation)
*/
mercs2Viewpoint(mercs: number[][], size: [number, number]): Viewpoint {
this._assertMapData();
this._assertMaxxy();
// メルカトル中心 → TIN 逆変換 → ピクセル中心
const centerResults = this.merc2XyWithLayer(mercs[0]);
const result = centerResults[0] || centerResults[1];
if (!result) throw new Error("mercs2Viewpoint: center point is out of bounds");
const centerIndex = result[0];
const centerXy = result[1];
// 残り4点を同じレイヤーで TIN 逆変換
const xys: number[][] = mercs.map((merc, i) => {
if (i === 0) return centerXy;
const xy = this._transformByIndex(merc, centerIndex, true);
if (xy === false) throw new Error(`mercs2Viewpoint: point ${i} is out of bounds`);
return xy;
});
// ピクセル → システム座標(xy2SysCoord)
const sysCoords = xys.map(xy => xy2SysCoord(xy, this._maxxy));
// システム座標5点 → ビューポート(mercs2MercViewpoint)
return mercs2MercViewpoint(sysCoords, size);
}
// ─── ユーティリティ(静的メソッド)────────────────────────────────────────
/** zoom2Radius の静的ラッパー */
static zoom2Radius(size: [number, number], zoom: number): number {
return zoom2Radius(size, zoom);
}
/** mercViewpoint2Mercs の静的ラッパー */
static mercViewpoint2Mercs(
center: number[],
zoom: number,
rotation: number,
size: [number, number]
): number[][] {
return mercViewpoint2Mercs(center, zoom, rotation, size);
}
/** mercs2MercViewpoint の静的ラッパー */
static mercs2MercViewpoint(mercs: number[][], size: [number, number]): Viewpoint {
return mercs2MercViewpoint(mercs, size);
}
/** xy2SysCoord の静的ラッパー */
static xy2SysCoord(xy: number[], maxxy: number): number[] {
return xy2SysCoord(xy, maxxy);
}
/** sysCoord2Xy の静的ラッパー */
static sysCoord2Xy(sysCoord: number[], maxxy: number): number[] {
return sysCoord2Xy(sysCoord, maxxy);
}
// ─── 内部ヘルパー ──────────────────────────────────────────────────────────
private _assertMapData(): void {
if (!this.mainTin) {
throw new Error("setMapData() must be called before transformation");
}
}
private _assertMaxxy(): void {
if (this._maxxy === 0) {
throw new Error(
"MapData.maxZoom or compiled.wh must be set for viewpoint conversion (xy2SysCoord / sysCoord2Xy)"
);
}
}
/**
* レイヤーインデックスに対応する Transform インスタンスを返す(三角網描画などの用途)
*
* @param idx - 0 = メイン TIN、1以上 = sub_maps[idx-1]
* @returns 対応する Transform、または範囲外の場合は null
*/
getLayerTransform(idx: number): Transform | null {
if (idx === 0) return this.mainTin;
const sub = this.subTins[idx - 1];
return sub ? sub.tin : null;
}
/** レイヤー数を返す(メイン + sub 数) */
get layerCount(): number {
return 1 + this.subTins.length;
}
/**
* viewpoint 変換に使用する最大ピクセル幅(2^maxZoom × 256)
* stateToViewpoint / viewpointToState で zoom ↔ scale 変換に使用する
* zoom = log2(scale × maxxy / 256) の関係
*/
get maxxy(): number {
return this._maxxy;
}
/** priority 降順でソートした [index, tin, isMain] の配列を返す */
private _getTinsSortedByPriority(): { index: number; tin: Transform; isMain: boolean }[] {
const all = this._getAllTinsWithIndex();
return all.sort((a, b) => {
const priA = a.tin.priority ?? 0;
const priB = b.tin.priority ?? 0;
return priA < priB ? 1 : -1;
});
}
/** メイン TIN + 全 sub TIN を index 付きで返す */
private _getAllTinsWithIndex(): { index: number; tin: Transform; isMain: boolean }[] {
const result: { index: number; tin: Transform; isMain: boolean }[] = [
{ index: 0, tin: this.mainTin!, isMain: true }
];
this.subTins.forEach((entry, i) => {
// priority/importance を Transform インスタンスに反映(ソート・比較に使用)
entry.tin.priority = entry.priority;
entry.tin.importance = entry.importance;
result.push({ index: i + 1, tin: entry.tin, isMain: false });
});
return result;
}
/**
* 指定レイヤーインデックスで TIN 変換を実行する
* index 0 → mainTin, index 1..n → subTins[index-1]
*/
private _transformByIndex(
coord: number[],
index: number,
backward: boolean
): number[] | false {
if (index === 0) {
return this.mainTin!.transform(coord, backward);
}
const subEntry = this.subTins[index - 1];
if (!subEntry) return false;
return subEntry.tin.transform(coord, backward, true);
}
/** 内部用 xy2SysCoord(_maxxy を使用) */
private xy2SysCoordInternal(xy: number[]): number[] {
return xy2SysCoord(xy, this._maxxy);
}
}