位置から空間アドレスを求める
経度・緯度・楕円体高とレベルを入れると、その地点を含むセルの空間アドレス、セルの番号、セルの中心を表示します。空間アドレスからセルの番号と中心に戻すこともできます。
コード
ビルドせずにブラウザで読み込める形で書いています。index.html の import map で、@mirgrid/web-sdk をこのサイトに置いた SDK に結び付けています。npm でインストールしてバンドラを使うプロジェクトでは、import map は要りません。
main.js
import init, {
spatial_geodetic_to_ecef,
spatial_ecef_to_voxel,
spatial_hilbert_cellid_hex,
spatial_voxel_size_m,
spatial_voxel_to_ecef,
spatial_hilbert_cellid_decode,
spatial_cellid_to_ecef,
spatial_ecef_to_llh_rad,
spatial_ecef_height,
spatial_ecef_to_enu,
} from '@mirgrid/web-sdk';
await init();
const $ = (id) => document.getElementById(id);
const DEG = 180 / Math.PI;
const num = (value, digits) => value.toLocaleString('ja-JP', { maximumFractionDigits: digits });
// セルの一辺を読みやすい単位で書く(レベル 1 は約 8,389 km、レベル 30 は約 1.6 cm)
function formatSize(meters) {
if (meters >= 1000) return `${num(meters / 1000, 3)} km`;
if (meters >= 1) return `${num(meters, 3)} m`;
return `${num(meters * 100, 4)} cm`;
}
// ECEF の点を「経度・緯度・楕円体高」の 3 行にする
function formatLlh([x, y, z]) {
const { lon, lat } = spatial_ecef_to_llh_rad(x, y, z);
return `経度 ${(lon * DEG).toFixed(8)}\n緯度 ${(lat * DEG).toFixed(8)}\n楕円体高 ${spatial_ecef_height(x, y, z).toFixed(3)} m`;
}
// 1 つ粗いレベルと同じ桁(s)、新しく決まった桁(d)、末尾の 0(z) に分けて色を付ける
function hexHtml(hex, prev) {
const zeros = hex.length - hex.replace(/0+$/, '').length;
const kind = (i) => (i >= hex.length - zeros ? 'z' : !prev ? 'p' : prev[i] === hex[i] ? 's' : 'd');
let html = '';
for (let i = 0; i < hex.length; ) {
let j = i;
while (j < hex.length && kind(j) === kind(i)) j++;
html += `<span class="${kind(i)}">${hex.slice(i, j)}</span>`;
i = j;
}
return html;
}
for (let level = 1; level <= 30; level++) {
$('level').add(new Option(`${level}(一辺 ${formatSize(spatial_voxel_size_m(level))})`, level, false, level === 24));
}
// 入力に問題があればその説明を、なければ空文字を返す
function checkPoint([lon, lat, height]) {
// 空欄や数でない入力は valueAsNumber が NaN になる
if (![lon, lat, height].every(Number.isFinite)) return '経度・緯度・楕円体高に数を入れてください。';
if (Math.abs(lon) > 180) return '経度は −180〜180 の範囲で入れてください。';
if (Math.abs(lat) > 90) return '緯度は −90〜90 の範囲で入れてください。';
return '';
}
// 同じ地点を全レベルで変換しておく(表と結果の両方で使う)
function addressesAt(point) {
const [x, y, z] = spatial_geodetic_to_ecef(...point);
return Array.from({ length: 30 }, (_, i) => {
const level = i + 1;
const [vx, vy, vz] = spatial_ecef_to_voxel(x, y, z, level);
return { level, vx, vy, vz, hex: spatial_hilbert_cellid_hex(vx, vy, vz, level) };
});
}
function showError(id, message) {
$(`${id}-error`).textContent = message;
$(`${id}-error`).hidden = !message;
$(id).hidden = Boolean(message);
}
function update() {
const point = ['lon', 'lat', 'height'].map((id) => $(id).valueAsNumber);
let addresses;
let problem = checkPoint(point);
if (!problem) {
try {
addresses = addressesAt(point);
} catch {
// 格子は地球を包む立方体なので、それより外(極端に高い・深い地点)はセルにできない
problem = 'この地点は格子の範囲の外にあります。楕円体高を確かめてください。';
}
}
showError('forward', problem);
if (problem) {
$('rows').replaceChildren();
return;
}
const level = Number($('level').value);
const { vx, vy, vz, hex } = addresses[level - 1];
const center = spatial_voxel_to_ecef(vx, vy, vz, level);
// 入れた地点を原点にした ENU に直すと、東・北・上のずれ(m)がそのまま読める
const [east, north, up] = spatial_ecef_to_enu(...center, ...point);
$('address').textContent = hex;
$('voxel').textContent = `${vx}, ${vy}, ${vz}`;
$('size').textContent = formatSize(spatial_voxel_size_m(level));
$('center').textContent = formatLlh(center);
$('distance').textContent = `${num(Math.hypot(east, north, up), 3)} m\n(東 ${num(east, 3)} m、北 ${num(north, 3)} m、上 ${num(up, 3)} m)`;
$('rows').innerHTML = addresses
.map(
(row, i) =>
`<tr data-level="${row.level}" aria-current="${row.level === level}"><td>${row.level}</td><td>${formatSize(spatial_voxel_size_m(row.level))}</td><td class="hex">${hexHtml(row.hex, addresses[i - 1]?.hex)}</td></tr>`,
)
.join('');
$('reverse-input').value = hex;
decode();
}
function decode() {
const hex = $('reverse-input').value.trim().toLowerCase();
if (!/^[0-9a-f]{32}$/.test(hex)) {
showError('reverse', '空間アドレスは 0〜9 と a〜f の 32 文字で入れてください。');
return;
}
try {
const { vx, vy, vz, level } = spatial_hilbert_cellid_decode(hex);
$('reverse-level').textContent = `${level}(一辺 ${formatSize(spatial_voxel_size_m(level))})`;
$('reverse-voxel').textContent = `${vx}, ${vy}, ${vz}`;
$('reverse-center').textContent = formatLlh(spatial_cellid_to_ecef(hex));
showError('reverse', '');
} catch {
showError('reverse', 'この空間アドレスはセルに戻せません。値を確かめてください。');
}
}
for (const id of ['lon', 'lat', 'height', 'level']) $(id).addEventListener('input', update);
$('reverse-input').addEventListener('input', decode);
$('rows').addEventListener('click', (event) => {
const row = event.target.closest('tr');
if (!row) return;
$('level').value = row.dataset.level;
update();
});
update();
index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>位置から空間アドレスを求める</title>
<link rel="stylesheet" href="/assets/sample.css">
<script type="importmap">
{ "imports": { "@mirgrid/web-sdk": "/sdk/web-sdk/voxel_core.js", "@mirgrid/web-sdk/": "/sdk/web-sdk/" } }
</script>
<script type="module" src="main.js"></script>
<style>
.column { display: grid; gap: 16px; align-content: start; }
@media (min-width: 900px) {
.app { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
}
.address { margin: 4px 0 12px; font: 20px/1.3 var(--mono); color: var(--navy); overflow-wrap: anywhere; }
#reverse, #reverse-error { margin-top: 12px; }
.facts dd { white-space: pre-line; }
.levels table { font-size: 12.5px; }
.levels th, .levels td { padding: 4px 6px; white-space: nowrap; }
.levels td:nth-child(2) { color: var(--muted); }
.levels tbody tr { cursor: pointer; }
.levels tbody tr:hover { background: var(--film); }
.levels tbody tr[aria-current="true"] { background: var(--tint); }
.hex { font-family: var(--mono); }
.hex .s { color: var(--faint); }
.hex .d { color: var(--link); font-weight: 600; }
.hex .z { color: #aab4c3; }
.hex .p { color: var(--navy); }
@media (max-width: 420px) {
.levels th, .levels td { padding: 4px 3px; white-space: normal; }
.levels .hex { font-size: 11px; letter-spacing: -0.02em; white-space: nowrap; }
.address { font-size: 15px; }
}
</style>
</head>
<body>
<main class="app">
<div class="column">
<section class="panel">
<h1>地点とレベル</h1>
<div class="fields">
<label>経度(度)<input id="lon" type="number" step="any" value="139.7671248"></label>
<label>緯度(度)<input id="lat" type="number" step="any" value="35.6812362"></label>
<label>楕円体高(m)<input id="height" type="number" step="any" value="40"></label>
<label>レベル<select id="level"></select></label>
</div>
<p class="note">初期値は東京駅のあたりです。値を変えると、すぐに結果が変わります。</p>
<h2>結果</h2>
<p id="forward-error" class="error" hidden></p>
<div id="forward">
<div class="status">空間アドレス</div>
<div id="address" class="address"></div>
<dl class="facts">
<dt>セルの番号</dt><dd id="voxel"></dd>
<dt>セルの一辺</dt><dd id="size"></dd>
<dt>セルの中心</dt><dd id="center"></dd>
<dt>中心までの距離</dt><dd id="distance"></dd>
</dl>
<p class="note">距離は、入れた地点を原点にした ENU(東・北・上)で測っています。</p>
</div>
</section>
<section class="panel">
<h2>空間アドレスから戻す</h2>
<label>空間アドレス(32 桁の 16 進数)<input id="reverse-input" class="mono" spellcheck="false" autocomplete="off" maxlength="32"></label>
<p id="reverse-error" class="error" hidden></p>
<dl id="reverse" class="facts">
<dt>レベル</dt><dd id="reverse-level"></dd>
<dt>セルの番号</dt><dd id="reverse-voxel"></dd>
<dt>セルの中心</dt><dd id="reverse-center"></dd>
</dl>
</section>
</div>
<section class="panel levels">
<h2>同じ地点のレベルごとの空間アドレス</h2>
<p class="note">青い桁は、1 つ上のレベルから新しく決まった桁です。行を押すと、そのレベルを選びます。</p>
<table>
<thead><tr><th scope="col">レベル</th><th scope="col">セルの一辺</th><th scope="col">空間アドレス</th></tr></thead>
<tbody id="rows"></tbody>
</table>
</section>
</main>
</body>
</html>