[Astro] #165 サイトマップ #01 — 3D の星座と、JavaScript なしの一覧で、サイト全体を見渡せるページを作成

[Astro] #165 サイトマップ #01 — 3D の星座と、JavaScript なしの一覧で、サイト全体を見渡せるページを作成

はじめに

前回(#164)は、地形を立体にして歩くツールでした。

今回は、前回とは関係のない、サイトマップの話です。

作品が増えて、記事が増えて、気付くと、作者の自分にも全体が分からなくなっていました。メニューには約 95 項目のアプリがあり、記事は 800 本を超えています。ここから探したいものを見つけるのは、もう大変です。

そこで、サイトマップを作りました。人間が見るためのもので、検索エンジン向けの sitemap.xml とは別物です。

要点は次の 3 つです。

  1. 3D 版:カテゴリ・アプリ・記事を星にして、クリックでワープする
  2. 2D 版:JavaScript なしで読める一覧。木の表示にも切り替えられる
  3. データは JSON と記事の frontmatter だけ。配置も一覧も、全部ビルド時に決める

以降は、実装の全体を簡単に残しておきます。

完成画面

3D 版(/sitemap-3d/)

3D サイトマップ — カテゴリが大きな星、アプリが周りの星になっている 3D サイトマップ — カテゴリが大きな星、アプリが周りの星になっている

2D 版(/sitemap/)

2D サイトマップ — カテゴリごとのカード

2D 版 (Tree表示)(/sitemap/)

2D サイトマップ — Tree表示

3D 版の使い方は次のとおりです。

  • 左のカテゴリ一覧、または星をクリックすると、その場所へワープする
  • 項目の星をクリックすると、画像と説明のカードが出る(LAUNCH APP / DOCUMENTATION)
  • 左右の矢印キーで、隣のカテゴリへ移る。Esc で全体に戻る
  • 「DUST」を押すと、記事が星屑として出る

2D 版は、カテゴリごとのカードの下に、作品(GALLERY)、記事(ARTICLES)、ショート動画(SHORTS)が続きます。「Tree で表示」を押すと、全部が 1 本の木になります。

データ:全部、すでにあったもの

新しく書いたデータは、ほとんどありません。

データ中身使い道
start_menu.jsonカテゴリとアプリ(タイトル、URL、画像、説明)3D の星、2D のカード、Tree
works.json過去作品 14 本と解説記事のリンクGALLERY
shorts.jsonYouTube ショート 90 本SHORTS
記事の frontmatterタイトル、日付、画像、説明星屑、ARTICLES

どれも、他のページのために作っていた JSON です。ここまで JSON で作っていたとは、作った自分も気付いていませんでした。サイトマップを作るのは、思ったより楽でした。

読み込みは、src/lib/sitemap-data.ts にまとめました。3D・カード・Tree・記事・ショートの全部が、ここを通ります。

3D 版

星座の配置

配置は、ビルド時に全部決めています。ブラウザでは、物理演算をしません。

  1. メニューの第 1 階層をカテゴリにする
  2. カテゴリは、球面にほぼ均等に並べる(フィボナッチ球面)
  3. アプリは、カテゴリの周りの球殻に並べる
  4. カテゴリどうしは、近い 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 つの記事が多くのアプリに結びついて、線が毛玉になります。

  1. アプリ名が、タイトルに語として入っている
  2. アプリの URL の最後の語や、アプリ名を詰めた文字列が、タイトルか URL の中にある
  3. アプリ名が 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 で歩けるものにしたいと考えています。カテゴリを島に、アプリを小島にして、道でつなぐ構想です。実装の前に、試作を作ってからにします。