core_spatial_ZFXYConverter.js - hiro-nyon/cesium-heatbox GitHub Wiki
日本語 | English
See also: Class: ZFXYConverter
import { COORDINATE_CONSTANTS } from '../../utils/constants.js';
/**
* ZFXYConverter - Built-in ZFXY (3D tile coordinates) converter
* 内蔵ZFXY(3次元タイル座標)コンバーター
*
* Provides Web Mercator-based ZFXY conversion without external dependencies.
* This is a fallback when ouranos-gex-lib-for-javascript is not available.
*
* Features:
* - Web Mercator projection for X/Y tile calculation
* - Fixed altitude binning for F (vertical) coordinate
* - 8-vertex bounding box generation
* - Coordinate normalization and clamping
*
* 外部依存なしでWeb MercatorベースのZFXY変換を提供。
* ouranos-gex-lib-for-javascriptが利用できない場合のフォールバック。
*
* 機能:
* - X/Yタイル計算にWeb Mercator投影を使用
* - F(垂直)座標の固定高度ビニング
* - 8頂点バウンディングボックス生成
* - 座標の正規化とクランプ
*
* @class
* @version 0.1.17
* @since 0.1.17
*
* @example
* // Basic usage
* const result = ZFXYConverter.convert(139.7, 35.69, 50, 25);
*
* console.log(result.zfxy); // {z: 25, f: 5, x: 28765, y: 12850}
* console.log(result.zfxyStr); // "/25/5/28765/12850"
* console.log(result.vertices.length); // 8
*
* @example
* // Coordinates are automatically normalized
* const normalized = ZFXYConverter.convert(200, 90, -50, 25);
* // lng: 200 → -160 (normalized to -180..180)
* // lat: 90 → 85.0511 (clamped to Web Mercator limits)
* // alt: -50 → valid (negative altitudes supported)
*/
export class ZFXYConverter {
/**
* Convert lng/lat/alt to ZFXY coordinates and bounding box
* lng/lat/altをZFXY座標とバウンディングボックスに変換
*
* @param {number} lng - Longitude (degrees, -180 to 180)
* @param {number} lat - Latitude (degrees, -85.0511 to 85.0511)
* @param {number} alt - Altitude (meters)
* @param {number} zoom - Zoom level (0-35)
* @returns {{zfxy: {z, f, x, y}, zfxyStr: string, vertices: Array<{lng, lat, alt}>}}
*/
static convert(lng, lat, alt, zoom) {
// Clamp inputs to valid ranges
lng = ZFXYConverter._normalizeLongitude(lng);
lat = ZFXYConverter._clampLatitude(lat);
zoom = Math.max(0, Math.min(35, Math.floor(zoom)));
// Calculate XY tile coordinates (Web Mercator)
const n = Math.pow(2, zoom);
const x = Math.floor((lng + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
// Calculate F (altitude index)
// Simplified: use fixed altitude bins (e.g., 10m per level at zoom 25)
// This is an approximation; actual F calculation depends on zoom level
const altitudePerBin = ZFXYConverter._getAltitudePerBin(zoom, lat);
const f = Math.floor(alt / altitudePerBin);
const zfxy = { z: zoom, f, x, y };
const zfxyStr = `/${zoom}/${f}/${x}/${y}`;
// Calculate 8 vertices (bounding box corners)
const vertices = ZFXYConverter._calculateVertices(lng, lat, alt, zoom, x, y, f, altitudePerBin);
return { zfxy, zfxyStr, vertices };
}
/**
* Normalize longitude to [-180, 180]
* 経度を[-180, 180]に正規化
*
* @param {number} lng - Longitude (degrees)
* @returns {number} Normalized longitude
* @private
*/
static _normalizeLongitude(lng) {
// Handle ±180 equivalence: 180 -> -180
if (lng === 180) return -180;
// Wrap to [-180, 180]
while (lng > 180) lng -= 360;
while (lng < -180) lng += 360;
return lng;
}
/**
* Clamp latitude to Web Mercator limits
* 緯度をWeb Mercator制限にクランプ
*
* @param {number} lat - Latitude (degrees)
* @returns {number} Clamped latitude
* @private
*/
static _clampLatitude(lat) {
const MAX_LAT = 85.0511287798; // Web Mercator limit (≈ arctan(sinh(π)))
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/**
* Get altitude per bin for given zoom level
* 指定されたズームレベルの高度ビンを取得
*
* @param {number} zoom - Zoom level
* @returns {number} Altitude per bin in meters
* @private
*/
static _getAltitudePerBin(zoom, lat) {
const EARTH_CIRCUMFERENCE = COORDINATE_CONSTANTS.EARTH_CIRCUMFERENCE_EQUATOR; // meters
const MIN_BIN_SIZE = 0.5; // meters
const MAX_BIN_SIZE = 5000; // meters
const ALTITUDE_SCALE = 1.0; // Ratio between XY tile size and altitude slice
const safeZoom = Math.max(0, Math.min(35, Math.floor(zoom)));
const latRadians = Number.isFinite(lat) ? lat * Math.PI / 180 : 0;
const cosLat = Math.max(Math.cos(latRadians), 1e-5);
const tileSizeXY = (EARTH_CIRCUMFERENCE * cosLat) / Math.pow(2, safeZoom);
const scaled = tileSizeXY * ALTITUDE_SCALE;
return Math.min(MAX_BIN_SIZE, Math.max(MIN_BIN_SIZE, scaled));
}
/**
* Calculate 8 vertices of the voxel bounding box
* ボクセルバウンディングボックスの8頂点を計算
*
* @param {number} lng - Center longitude
* @param {number} lat - Center latitude
* @param {number} alt - Center altitude
* @param {number} zoom - Zoom level
* @param {number} x - X tile coordinate
* @param {number} y - Y tile coordinate
* @param {number} f - F altitude index
* @param {number} altitudePerBin - Altitude per bin
* @returns {Array<{lng, lat, alt}>} 8 corner vertices
* @private
*/
static _calculateVertices(lng, lat, alt, zoom, x, y, f, altitudePerBin) {
const n = Math.pow(2, zoom);
// Calculate tile bounds in lng/lat
const minLng = x / n * 360 - 180;
const maxLng = (x + 1) / n * 360 - 180;
const minLatRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n)));
const maxLatRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)));
const minLat = minLatRad * 180 / Math.PI;
const maxLat = maxLatRad * 180 / Math.PI;
// Calculate altitude bounds
const minAlt = f * altitudePerBin;
const maxAlt = (f + 1) * altitudePerBin;
// 8 vertices: bottom 4 + top 4
const vertices = [
// Bottom face (minAlt)
{ lng: minLng, lat: minLat, alt: minAlt }, // 0: SW bottom
{ lng: maxLng, lat: minLat, alt: minAlt }, // 1: SE bottom
{ lng: maxLng, lat: maxLat, alt: minAlt }, // 2: NE bottom
{ lng: minLng, lat: maxLat, alt: minAlt }, // 3: NW bottom
// Top face (maxAlt)
{ lng: minLng, lat: minLat, alt: maxAlt }, // 4: SW top
{ lng: maxLng, lat: minLat, alt: maxAlt }, // 5: SE top
{ lng: maxLng, lat: maxLat, alt: maxAlt }, // 6: NE top
{ lng: minLng, lat: maxLat, alt: maxAlt } // 7: NW top
];
return vertices;
}
/**
* Validate ZFXY coordinates
* ZFXY座標を検証
*
* @param {Object} zfxy - ZFXY coordinates
* @param {number} zfxy.z - Zoom level
* @param {number} zfxy.f - Altitude index
* @param {number} zfxy.x - X tile coordinate
* @param {number} zfxy.y - Y tile coordinate
* @returns {boolean} True if valid
*/
static validateZFXY(zfxy) {
if (!zfxy || typeof zfxy !== 'object') return false;
const { z, f, x, y } = zfxy;
// Check types
if (!Number.isInteger(z) || !Number.isInteger(f) ||
!Number.isInteger(x) || !Number.isInteger(y)) {
return false;
}
// Check ranges
if (z < 0 || z > 35) return false;
const n = Math.pow(2, z);
if (x < 0 || x >= n) return false;
if (y < 0 || y >= n) return false;
// F can be negative (below sea level) or very large
// No strict bounds on F in this implementation
return true;
}
/**
* Parse zfxyStr to ZFXY object
* zfxyStr文字列をZFXYオブジェクトにパース
*
* @param {string} zfxyStr - ZFXY string in format "/z/f/x/y"
* @returns {{z, f, x, y}|null} Parsed ZFXY or null if invalid
*/
static parseZFXYStr(zfxyStr) {
if (typeof zfxyStr !== 'string') return null;
const parts = zfxyStr.split('/').filter(p => p.length > 0);
if (parts.length !== 4) return null;
const [z, f, x, y] = parts.map(p => parseInt(p, 10));
if (Number.isNaN(z) || Number.isNaN(f) || Number.isNaN(x) || Number.isNaN(y)) {
return null;
}
const zfxy = { z, f, x, y };
return ZFXYConverter.validateZFXY(zfxy) ? zfxy : null;
}
}関連: ZFXYConverterクラス
import { COORDINATE_CONSTANTS } from '../../utils/constants.js';
/**
* ZFXYConverter - Built-in ZFXY (3D tile coordinates) converter
* 内蔵ZFXY(3次元タイル座標)コンバーター
*
* Provides Web Mercator-based ZFXY conversion without external dependencies.
* This is a fallback when ouranos-gex-lib-for-javascript is not available.
*
* Features:
* - Web Mercator projection for X/Y tile calculation
* - Fixed altitude binning for F (vertical) coordinate
* - 8-vertex bounding box generation
* - Coordinate normalization and clamping
*
* 外部依存なしでWeb MercatorベースのZFXY変換を提供。
* ouranos-gex-lib-for-javascriptが利用できない場合のフォールバック。
*
* 機能:
* - X/Yタイル計算にWeb Mercator投影を使用
* - F(垂直)座標の固定高度ビニング
* - 8頂点バウンディングボックス生成
* - 座標の正規化とクランプ
*
* @class
* @version 0.1.17
* @since 0.1.17
*
* @example
* // Basic usage
* const result = ZFXYConverter.convert(139.7, 35.69, 50, 25);
*
* console.log(result.zfxy); // {z: 25, f: 5, x: 28765, y: 12850}
* console.log(result.zfxyStr); // "/25/5/28765/12850"
* console.log(result.vertices.length); // 8
*
* @example
* // Coordinates are automatically normalized
* const normalized = ZFXYConverter.convert(200, 90, -50, 25);
* // lng: 200 → -160 (normalized to -180..180)
* // lat: 90 → 85.0511 (clamped to Web Mercator limits)
* // alt: -50 → valid (negative altitudes supported)
*/
export class ZFXYConverter {
/**
* Convert lng/lat/alt to ZFXY coordinates and bounding box
* lng/lat/altをZFXY座標とバウンディングボックスに変換
*
* @param {number} lng - Longitude (degrees, -180 to 180)
* @param {number} lat - Latitude (degrees, -85.0511 to 85.0511)
* @param {number} alt - Altitude (meters)
* @param {number} zoom - Zoom level (0-35)
* @returns {{zfxy: {z, f, x, y}, zfxyStr: string, vertices: Array<{lng, lat, alt}>}}
*/
static convert(lng, lat, alt, zoom) {
// Clamp inputs to valid ranges
lng = ZFXYConverter._normalizeLongitude(lng);
lat = ZFXYConverter._clampLatitude(lat);
zoom = Math.max(0, Math.min(35, Math.floor(zoom)));
// Calculate XY tile coordinates (Web Mercator)
const n = Math.pow(2, zoom);
const x = Math.floor((lng + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
// Calculate F (altitude index)
// Simplified: use fixed altitude bins (e.g., 10m per level at zoom 25)
// This is an approximation; actual F calculation depends on zoom level
const altitudePerBin = ZFXYConverter._getAltitudePerBin(zoom, lat);
const f = Math.floor(alt / altitudePerBin);
const zfxy = { z: zoom, f, x, y };
const zfxyStr = `/${zoom}/${f}/${x}/${y}`;
// Calculate 8 vertices (bounding box corners)
const vertices = ZFXYConverter._calculateVertices(lng, lat, alt, zoom, x, y, f, altitudePerBin);
return { zfxy, zfxyStr, vertices };
}
/**
* Normalize longitude to [-180, 180]
* 経度を[-180, 180]に正規化
*
* @param {number} lng - Longitude (degrees)
* @returns {number} Normalized longitude
* @private
*/
static _normalizeLongitude(lng) {
// Handle ±180 equivalence: 180 -> -180
if (lng === 180) return -180;
// Wrap to [-180, 180]
while (lng > 180) lng -= 360;
while (lng < -180) lng += 360;
return lng;
}
/**
* Clamp latitude to Web Mercator limits
* 緯度をWeb Mercator制限にクランプ
*
* @param {number} lat - Latitude (degrees)
* @returns {number} Clamped latitude
* @private
*/
static _clampLatitude(lat) {
const MAX_LAT = 85.0511287798; // Web Mercator limit (≈ arctan(sinh(π)))
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/**
* Get altitude per bin for given zoom level
* 指定されたズームレベルの高度ビンを取得
*
* @param {number} zoom - Zoom level
* @returns {number} Altitude per bin in meters
* @private
*/
static _getAltitudePerBin(zoom, lat) {
const EARTH_CIRCUMFERENCE = COORDINATE_CONSTANTS.EARTH_CIRCUMFERENCE_EQUATOR; // meters
const MIN_BIN_SIZE = 0.5; // meters
const MAX_BIN_SIZE = 5000; // meters
const ALTITUDE_SCALE = 1.0; // Ratio between XY tile size and altitude slice
const safeZoom = Math.max(0, Math.min(35, Math.floor(zoom)));
const latRadians = Number.isFinite(lat) ? lat * Math.PI / 180 : 0;
const cosLat = Math.max(Math.cos(latRadians), 1e-5);
const tileSizeXY = (EARTH_CIRCUMFERENCE * cosLat) / Math.pow(2, safeZoom);
const scaled = tileSizeXY * ALTITUDE_SCALE;
return Math.min(MAX_BIN_SIZE, Math.max(MIN_BIN_SIZE, scaled));
}
/**
* Calculate 8 vertices of the voxel bounding box
* ボクセルバウンディングボックスの8頂点を計算
*
* @param {number} lng - Center longitude
* @param {number} lat - Center latitude
* @param {number} alt - Center altitude
* @param {number} zoom - Zoom level
* @param {number} x - X tile coordinate
* @param {number} y - Y tile coordinate
* @param {number} f - F altitude index
* @param {number} altitudePerBin - Altitude per bin
* @returns {Array<{lng, lat, alt}>} 8 corner vertices
* @private
*/
static _calculateVertices(lng, lat, alt, zoom, x, y, f, altitudePerBin) {
const n = Math.pow(2, zoom);
// Calculate tile bounds in lng/lat
const minLng = x / n * 360 - 180;
const maxLng = (x + 1) / n * 360 - 180;
const minLatRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n)));
const maxLatRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)));
const minLat = minLatRad * 180 / Math.PI;
const maxLat = maxLatRad * 180 / Math.PI;
// Calculate altitude bounds
const minAlt = f * altitudePerBin;
const maxAlt = (f + 1) * altitudePerBin;
// 8 vertices: bottom 4 + top 4
const vertices = [
// Bottom face (minAlt)
{ lng: minLng, lat: minLat, alt: minAlt }, // 0: SW bottom
{ lng: maxLng, lat: minLat, alt: minAlt }, // 1: SE bottom
{ lng: maxLng, lat: maxLat, alt: minAlt }, // 2: NE bottom
{ lng: minLng, lat: maxLat, alt: minAlt }, // 3: NW bottom
// Top face (maxAlt)
{ lng: minLng, lat: minLat, alt: maxAlt }, // 4: SW top
{ lng: maxLng, lat: minLat, alt: maxAlt }, // 5: SE top
{ lng: maxLng, lat: maxLat, alt: maxAlt }, // 6: NE top
{ lng: minLng, lat: maxLat, alt: maxAlt } // 7: NW top
];
return vertices;
}
/**
* Validate ZFXY coordinates
* ZFXY座標を検証
*
* @param {Object} zfxy - ZFXY coordinates
* @param {number} zfxy.z - Zoom level
* @param {number} zfxy.f - Altitude index
* @param {number} zfxy.x - X tile coordinate
* @param {number} zfxy.y - Y tile coordinate
* @returns {boolean} True if valid
*/
static validateZFXY(zfxy) {
if (!zfxy || typeof zfxy !== 'object') return false;
const { z, f, x, y } = zfxy;
// Check types
if (!Number.isInteger(z) || !Number.isInteger(f) ||
!Number.isInteger(x) || !Number.isInteger(y)) {
return false;
}
// Check ranges
if (z < 0 || z > 35) return false;
const n = Math.pow(2, z);
if (x < 0 || x >= n) return false;
if (y < 0 || y >= n) return false;
// F can be negative (below sea level) or very large
// No strict bounds on F in this implementation
return true;
}
/**
* Parse zfxyStr to ZFXY object
* zfxyStr文字列をZFXYオブジェクトにパース
*
* @param {string} zfxyStr - ZFXY string in format "/z/f/x/y"
* @returns {{z, f, x, y}|null} Parsed ZFXY or null if invalid
*/
static parseZFXYStr(zfxyStr) {
if (typeof zfxyStr !== 'string') return null;
const parts = zfxyStr.split('/').filter(p => p.length > 0);
if (parts.length !== 4) return null;
const [z, f, x, y] = parts.map(p => parseInt(p, 10));
if (Number.isNaN(z) || Number.isNaN(f) || Number.isNaN(x) || Number.isNaN(y)) {
return null;
}
const zfxy = { z, f, x, y };
return ZFXYConverter.validateZFXY(zfxy) ? zfxy : null;
}
}