[Astro] #165 サイトマップ #01 — 3D の星座と、JavaScript なしの一覧で、サイト全体を見渡せるページを作成
はじめに
前回(#164)は、地形を立体にして歩くツールでした。
[Astro] #164 Terrain Explorer — 地理院タイルの地形を立体にして、VRで歩くツールを作成 // PROTOCOL.LAIN
国土地理院の標高タイルから地形を立体にして、その上を歩けるツールを作りました。地図で場所を選ぶと、標高と航空写真を読み込んで 3D になります。
lain-lab.com今回は、前回とは関係のない、サイトマップの話です。
作品が増えて、記事が増えて、気付くと、作者の自分にも全体が分からなくなっていました。メニューには約 95 項目のアプリがあり、記事は 800 本を超えています。ここから探したいものを見つけるのは、もう大変です。
そこで、サイトマップを作りました。人間が見るためのもので、検索エンジン向けの sitemap.xml とは別物です。
要点は次の 3 つです。
- 3D 版:カテゴリ・アプリ・記事を星にして、クリックでワープする
- 2D 版:JavaScript なしで読める一覧。木の表示にも切り替えられる
- データは JSON と記事の frontmatter だけ。配置も一覧も、全部ビルド時に決める
以降は、実装の全体を簡単に残しておきます。
3D サイトマップ // PROTOCOL.LAIN
PROTOCOL.LAIN // Archive
lain-lab.com完成画面
3D 版(/sitemap-3d/)
2D 版(/sitemap/)
2D 版 (Tree表示)(/sitemap/)
3D 版の使い方は次のとおりです。
- 左のカテゴリ一覧、または星をクリックすると、その場所へワープする
- 項目の星をクリックすると、画像と説明のカードが出る(LAUNCH APP / DOCUMENTATION)
- 左右の矢印キーで、隣のカテゴリへ移る。Esc で全体に戻る
- 「DUST」を押すと、記事が星屑として出る
2D 版は、カテゴリごとのカードの下に、作品(GALLERY)、記事(ARTICLES)、ショート動画(SHORTS)が続きます。「Tree で表示」を押すと、全部が 1 本の木になります。
データ:全部、すでにあったもの
新しく書いたデータは、ほとんどありません。
| データ | 中身 | 使い道 |
|---|---|---|
start_menu.json | カテゴリとアプリ(タイトル、URL、画像、説明) | 3D の星、2D のカード、Tree |
works.json | 過去作品 14 本と解説記事のリンク | GALLERY |
shorts.json | YouTube ショート 90 本 | SHORTS |
| 記事の frontmatter | タイトル、日付、画像、説明 | 星屑、ARTICLES |
どれも、他のページのために作っていた JSON です。ここまで JSON で作っていたとは、作った自分も気付いていませんでした。サイトマップを作るのは、思ったより楽でした。
読み込みは、src/lib/sitemap-data.ts にまとめました。3D・カード・Tree・記事・ショートの全部が、ここを通ります。
3D 版
星座の配置
配置は、ビルド時に全部決めています。ブラウザでは、物理演算をしません。
- メニューの第 1 階層をカテゴリにする
- カテゴリは、球面にほぼ均等に並べる(フィボナッチ球面)
- アプリは、カテゴリの周りの球殻に並べる
- カテゴリどうしは、近い 2 つへ線(道)を引く
座標は小数 1 桁に丸めて、JSON にして、ページに埋め込みます。クライアントは、それを読んで描くだけです。
道は、最初は並び順に輪でつないでいました。すると画面を横切る長い線ができたので、近い 2 つにつなぐ形に変えました。
ワープ
ワープは、カメラを直線で動かします。約 1.2 秒、ゆっくり出てゆっくり止まる動きにして、移動中だけ視野角を少し広げています(60 → 94 → 60)。移動中に視野が広がると、速く動いている感じが出ます。
選んだカテゴリ以外は暗くして、名前も薄くします。ワープ先に着くと、そのカテゴリの項目名が出ます。
ホバーは、画面の座標で決める
星の上にマウスが乗っているかは、3D の交差判定ではなく、画面上の座標で最も近い星を探して、20 ピクセル以内なら拾う方法にしました。
星は小さい点なので、3D の交差判定だと当てにくくなります。画面上の距離なら、回転中でもずれません。
重くしないために
以前、別のページ(タグの関係図)を軽くしたときの方針をそのまま使いました。
- 描画ループは 1 本だけ
- 記事の星屑は 1 つの
Points、アプリと記事を結ぶ線は 1 本のLineSegmentsにまとめる - ホバーの判定は、マウスが動いたときと、カメラが動いている間だけ
記事を星屑にする
「DUST」を押すと、記事が星屑として出ます。記事の星の数は 831 です。
frontmatter だけを読む
記事の全文は読みません。各ファイルの先頭 8KB だけを読んで、frontmatter を取り出します。
const headOf = (p: string) => {
const fd = openSync(p, 'r');
try {
const b = Buffer.alloc(8192);
const n = readSync(fd, b, 0, 8192, 0);
return b.toString('utf8', 0, n);
} finally { closeSync(fd); }
};
最初は、Vite の import.meta.glob で全記事の全文を読んでいましたが、開発サーバーが重くなりました。試験用に作った約 930 本(26MB)のダミー記事で測ると、初回の表示が 12 秒、サーバーのメモリは約 1.5GB でした。先頭だけ読む形にすると、同じ条件で初回 0.18 秒になりました。これは開発用の環境で測った数値で、手元のマシンの体感とは違う場合があります。
記事をアプリに結びつける
星屑は、ただ散らばっているだけでは面白くないので、「この記事は、このアプリの記事」という関係を付けました。
基準は、記事のタイトルにアプリ名が入っているかどうか、だけです。タグは使いません。タグで結ぶと、1 つの記事が多くのアプリに結びついて、線が毛玉になります。
- アプリ名が、タイトルに語として入っている
- アプリの URL の最後の語や、アプリ名を詰めた文字列が、タイトルか URL の中にある
- アプリ名が 2 語以上のときは、その語がすべてタイトルに入っている
複数当たったときは、いちばん長い名前を採ります。1 つの記事は、最大でも 1 つのアプリにしか結びつきません。
精度は、ガイド記事のタイトル 91 件で試しました。URL を使わず、タイトルだけで判定して、正解 83 件、結びつかなかったもの 8 件、誤り 0 件でした。結びつかなかったのは、メニューの名前が英語で、記事の見出しが日本語のものです。連載記事([Astro] #129 … のような形)の精度は、数えていません。
結びついた記事は、そのアプリの周りに置きます。アプリを選ぶと、そのアプリの記事だけが明るくなって、線で結ばれます。
無数の記事(星屑)の上に、アプリ(惑星)が浮かんでいる。そういう見え方を狙っています。
2D 版
3D 版は、JavaScript が使えない環境や、検索エンジンには見えません。そのために、静的な 2D 版を残しています。
記事(ARTICLES)
記事は、タイトルの先頭の [ ](連載名)ごとにまとめて、折りたたみにしました。
<details name="sm-art" class="grp">
<summary>Astro <small>165</small></summary>
<ul>…</ul>
</details>
<details> の name を同じにすると、1 つ開けば他が閉じる「排他アコーディオン」になります。JavaScript は要りません。
3 本未満の連載は、「その他の記事」に寄せました。連載の中は日付の古い順です。講座は、第 1 回から順に読めます。
作品(GALLERY)
works.json を、そのまま使っています。解説記事の URL は、末尾のスラッシュが付いているものと、付いていないものが混ざっていたので、揃えてから出しています。
記事と作品の境目が曖昧だったので、見出しの説明で分けました。「ここから先は作品ではなく、開発の記録と学習記事です」。
ショート(SHORTS)
shorts.json から、タイトル・日付・リンクを出します。リンクは、YouTube の URL です。
https://www.youtube.com/shorts/の後ろに ID を付ける- 日付は、日本時間で出す(UTC のままだと、夜の投稿が前日になる)
- タイトルの末尾のハッシュタグは、外す
ハッシュタグを外す正規表現は、次のとおりです。
/(?:\s*#(?!\d+(?:\s|$|[||]))[^\s#||]+)+\s*$/u
最初は、単純に「末尾の # から後ろ」を消していました。すると、#04|… のような、連番のタイトルまで消えてしまいます。数字だけの # は残すようにしました。
ショートから記事へのリンクは、載せていません。そのつながりは、YouTube の概要欄とコメントの側にあって、shorts.json には入っていないためです。
Tree 表示
「Tree で表示」を押すと、全部が 1 本の木になります。Windows の tree コマンドの出力のようなものです。
lain-lab.com
├─ ▸ 3D & CAD
├─ ▸ VRM & MMD & FBX
├─ ▸ GALLERY
├─ ▸ ARTICLES
└─ ▸ SHORTS
JavaScript は使っていません。
- 切り替えは、非表示のチェックボックスと、兄弟セレクタ(
.sm-vt:checked ~ .sm-wrap …) - 枝は
<details>。全部、閉じた状態で始まる - 線は CSS の
borderだけ。縦線はli::before、横線はli::after。最後の項目は縦線を短くして、└ の形にする
ヘッダーとフッターを付ける
サイトの共通レイアウト(BaseLayout)で、両方のページを包みました。ヘッダーとフッターは固定表示なので、ページの上下に 70px の余白を取っています。
ページ遷移で 3D が動かなくなる問題
共通レイアウトで包んだら、3D ページが、ページ遷移のあとで動かなくなる可能性がありました。サイトが Astro の ClientRouter(ビュー遷移)を使っているためです。
ClientRouter があると、ページの <script type="module"> は、最初の 1 回しか実行されません。別のページから 3D ページへ遷移しても、スクリプトは走りません。
そこで、3D の処理を全部 init() に入れました。
astro:page-loadと DOMContentLoaded で、init()を呼ぶastro:before-swapで、後片付けをする(イベントの解除、描画ループの停止、controls.dispose()、renderer.dispose()、WebGL コンテキストの破棄)
イベントのリスナーは、登録する関数を 1 つ作って、全部まとめて解除できるようにしています。
画面のサイズも、ウィンドウ全体ではなく、3D を描く枠(#sm-stage)を基準に変えました。座標も、枠の中の位置で計算します。
遷移のあとで動かなくなる現象そのものは、私の環境では再現できていません。対策は、起こりうることを想定して入れたものです。ページを行き来しても動くことは、ヘッドレスブラウザで確かめました。
検証
テストは、ヘッドレスの Chromium(Playwright)で動かしています。
- 試験用の Astro プロジェクトに、本物の
start_menu.jsonと、合成した記事(約 960 本)を置いてビルド - 全体表示、ホバー、リストからのワープ、星のクリックでカード、Esc、矢印キー
- カードのボタンの表示、記事の紐づけ、線の点灯
- ページの往復遷移、ウィンドウのサイズ変更
実機での見た目(星の大きさ、明るさ、距離)は、ヘッドレスでは判断しにくいので、手元のブラウザで確かめました。
ハマりどころ
1. 開発サーバーが 504 で、真っ黒になる
three/examples/jsm/controls/OrbitControls.js と、three/examples/jsm/controls/OrbitControls は、Vite の開発サーバーでは別のモジュールとして扱われます。他のページと書き方が違うと、再最適化の途中でサーバーが詰まって、504 になりました。
import の書き方を、既存のコードと同じ(.js なし)にして解決です。本番のビルドでは起きません。
2. hidden を付けたのに、消えない
カードに DOCUMENTATION ボタンを出し分けるために、hidden 属性を使っていました。ところが、CSS で display: inline-block を指定していると、hidden が負けて、常に表示されます。
.go[hidden] { display: none; }
これを足しました。しかも、確かめるときに el.hidden(プロパティ)しか見ていなかったので、気付くのが遅れました。実際に見えているかは、getComputedStyle か、表示サイズで確かめます。
3. 色が明るくなりすぎる
three.js は、色をリニア空間で扱います。「半透明で暗くする」見た目を、色に係数を掛けて作ると、思ったより明るくなります。
const lin = (k) => Math.pow(k, 2.2); // 暗くする係数を、リニア空間に直す
掛ける前に、この変換を通します。
4. ワープ中に、カメラが勝手に戻る
OrbitControls は、enabled = false でも、update() を呼ぶと、ズームの最小・最大距離で位置を補正します。ワープ中は距離が大きく変わるので、補正に引っ張られます。
ワープ中は、最小・最大の距離を一度外して、update() を呼ばずに lookAt だけにしました。移動が終わったら、元に戻します。
5. 2D 版に、空のカードが 10 枚出ていた
静的版は、メニューの最上位の項目を全部カードにしていました。子を持たない項目(区切り線、Web ブラウザ、WINAMP など)が、件数 0 の空のカードとして出ていました。20 枚のうち 10 枚です。
カードにするのは、子を持つ項目だけにして、ページに飛ぶ項目は「その他のページ」の 1 枚にまとめました。
6. Tree の「開く範囲」が広すぎる
Tree の枝は <summary> です。これは、初期状態では行いっぱいに広がるため、文字の右側の何もない所をクリックしても、開閉してしまいます。
.sm-tree summary { display: inline-block; }
inline-block にして、反応する範囲を、文字の幅だけにしました。
7. スタートメニューから飛べない
3D 版への入口として、トップページのスタートメニューに「SITEMAP 3D」を足しました。ところが、押すとターミナルに、UNKNOWN COMMAND '/sitemap-3d/' と出ました。
メニューの action を持つ項目は、ターミナルのコマンドとして処理される作りでした。先に書いてあった「GALLERY」と同じ書き方だから動くはず、と、確かめずに決めつけていました。
action が / か http で始まるときは、コマンドにせず、ページ遷移にしました。
8. 入口が見つからない
ページを作っても、サイトのどこからもたどれませんでした。ヘッダーのナビは、PC の幅で 7 項目にすると、769px 付近で溢れる計算です。そこで、次のようにしました。
- スマホ:ナビのドロワーに「[MAP]」を足す
- PC:フッターの中央に「[SITEMAP]」を足す
フッターには、最初、コピーライトの隣に置いたのですが、周りに溶け込んで見つかりませんでした。中央に置いて、ヘッダーのナビと同じ括弧書きにしました。
まだ入れていないこと
- 時間の回廊:記事を日付順に並べて、音ゲーのように手前へ迫ってくるビュー
- VR:Quest で、島を歩くようなサイトマップ(構想だけ)
- 項目ごとのスクリーンショットと動画
- 連載記事の、アプリへの紐づけ精度の確認
- 作品(GALLERY の 14 本)を、3D の星にすること
おわりに
作ってみて分かったのは、必要なデータは、すでに全部そろっていた、ということでした。
メニュー、作品、ショート、記事。どれも別の目的で作った JSON や frontmatter です。それを、読んで、並べて、星にしただけです。新しく作ったのは、並べ方と、見せ方の部分でした。
記事が約 830 本になると、自分でも「何を作ってきたか」が見えなくなります。3D の星座に全部を並べると、アプリの周りに、それを作る過程で書いた記事が集まっているのが見えて、思っていたより面白い絵になりました。
次は、このサイトマップを、VR で歩けるものにしたいと考えています。カテゴリを島に、アプリを小島にして、道でつなぐ構想です。実装の前に、試作を作ってからにします。