課題地図をサイトに移し、週1回AIが更新する ― 5回作り直した課題マップと、更新を知らせて走るハコブくん
6月に作った「フィジカルAIが社会実装される前に解くべき課題」という課題地図は、公開したあと一度も更新されていませんでした。ロボットや AI が工場・病院・道路で使われるようになる前に、片づけておかないと先へ進めない問題を36件集め、どれくらい急ぐか(緊急度)とどれくらい重いか(深刻度)を5段階で付けた地図です。規制は変わり、研究も新しく出ています。それなのに地図は6月のまま止まっていました。
そこで、この地図を自分のサイト(pixelstomotors.com/issues)に移し、週に1回、AI が世の中の新しい動きを調べて、出典のリンクつきで足していく仕組みにしました。試しに1回動かすと、AI は1か月分の出来事から7件を選んで足しました。韓国が人型ロボットを人と同じ工場で動かす実証を特例で認めた件、ロボット向けの大きな学習済みモデルを外部に貸し出す試験が始まった件などです。
気になるのは、AI に勝手に書き換えさせて大丈夫か、という点です。この地図は「出典つき・中立」をうたっています。AI が規制の状態や点数を誤って書き換え、それがそのまま公開されると、地図そのものの信用が落ちます。そこで、AI がそのまま公開してよいのは「出典のある最近の動きを足すこと」だけにしました。点数・規制の状態・本文を書き換えたいときは、変更案として出すだけにして、取り込むかどうかは人が決めます。それでも初回の7件を点検すると、1件に出典には書かれていない推測が1文混じっていたので、人の手で外しています。
同じ日に、地図の中の「課題マップ」(関連する課題どうしを線でつないだ図)の見た目を良くしようとして、5回作り直し、結局ほぼ元の形に戻しました。分類ごとの区画に分けた版、線を曲線にした版、課題を小さな点の集まりで描いた版、マウスを乗せると点がほどけて線の方向へ流れる版です。どれも動きとしては作れましたが、見る人にとって良くなったとは言えませんでした。残したのは、方眼の地、深刻度の濃淡、名前の出し方、吹き出しの中身という、目立たない部分だけです。
最後に、サイトに新しい記事などが出ると、ハコブくん(このサイトのキャラクターの搬送ロボット)が画面の下を走り回って知らせる仕掛けを、サイト全体に入れました。入れたからには、見た人の行動がどう変わったかを確かめたいので、「知らせが出た」「吹き出しを押した」「押さずにあとでその記事を見に来た」を数える仕組みも足しています。見た人を見分ける情報は残しません。
| やったこと | なぜ | 結果 |
|---|---|---|
| 課題地図をサイトの /issues に移した | 6月から更新が止まっていた。サイトと別の場所にあった | 36課題・規制6件・出典45件を移し、旧住所の49ページはすべて新しい住所へ転送 |
| 週1回、AI が最近の動きを調べて足す | 規制や研究の変化を地図に反映したい | 試運転で7件。出典つきの追加だけ自動で公開、点数や本文の変更は人が確認 |
| 課題マップの見た目を5回作り直した | 見た目が弱いと感じた | 作り直しは4回とも取りやめ。方眼・濃淡・名前の出し方だけ残した |
| 更新を知らせるハコブくん | 新しい記事に気づいてもらう | どのページでも、まだ知らせていない更新があれば走る |
| 知らせの効果を数える | 入れっぱなしにせず、効いているかを確かめる | 週ごとの表が自動でたまる。見た人を見分ける情報は残さない |
ここから下は作業ログです。AI(Claude)とのやり取りで出た指示、使ったコマンドとその出力、書いたファイルの全文を、作業した順に載せています。
作業ログ ― 旧課題地図は 49 ページの手書き HTML と、課題ごとの Markdown でできていた
依頼はこの1文でした。
https://pixelstomotors.github.io/physical-ai-todo/ このサイトを新しいhttps://pixelstomotors.com/のサイト内に組み込んで、世の中の状況を見ながら常に新しく更新されるようにしたいのですができますか?
旧課題地図は GitHub Pages で公開していたリポジトリ PixelstoMotors/physical-ai-todo です。中を見ると、ページは手書きの HTML でしたが、課題ごとに Markdown(issues/*.md)が置いてあり、README には「.md が編集の正、.html は生成物」と書いてありました。移すときはこの Markdown を正として使えます。
$ git clone -q --depth 1 https://github.com/PixelstoMotors/physical-ai-todo.git todo
$ cd todo && ls; find . -type f -not -path './.git/*' | sed 's|/[^/]*$||' | sort | uniq -c
README.md
assets
categories
index.html
issues
map.html
mecharm_data_blueprint.html
sources.html
6 .
1 ./assets
9 ./categories
73 ./issues
$ wc -c index.html map.html sources.html assets/*
40713 index.html
56839 map.html
20076 sources.html
10427 assets/style.css
128055 total
移す前のトップページと課題マップです。


課題の Markdown は、先頭の項目(frontmatter)に番号・分類・緊急度・深刻度・時間軸・どの手段で解くか・状態などを持ち、本文に「30秒要約」「原因構造」「選択肢の比較」「反対論」などの節を持っています。全36件の状態は draft、最終確認日は 2026-06-12 でした。
$ grep -h "^status:" src/content/issues/*.md | sort | uniq -c; grep -h "^last_verified:" src/content/issues/*.md | sort | uniq -c
36 status: draft
36 last_verified: 2026-06-12
移すサイト(pixelstomotors.com)は、Astro で静的なページを作り、Cloudflare Workers で配信しています。GitHub の main に push すると約40秒で公開されます。
公開してよいものと、人が確認するものを先に分けた
作り始める前に、AI から次のように提案しました。
課題地図は「中立」「出典つき」を掲げていて、現時点でも「一次出典で確認前の記述を含む」と書いてあるサイトです。AI が調べた内容をそのまま公開まで自動にすると、誤った規制の状態や点数の変更がそのまま出る危険があります。そこで、ニュースの追加(出典リンクつき)は自動で公開、点数・規制の状態・本文の書き換えは人が確認してから公開、という分け方をおすすめします。
決めた3つです。
| 決めること | 決めた内容 |
|---|---|
| 住所 | pixelstomotors.com/issues |
| どこまで自動で公開するか | 出典つきの「最近の動き」の追加だけ自動。点数・規制の状態・本文・分類・マップの線の変更は、GitHub のプルリクエスト(変更案)として出し、人が取り込む |
| 調べる間隔 | 週1回 |
課題1件=1ファイルに移した ― tools/import_issues.py の出力
旧リポジトリの Markdown をサイトの src/content/issues/ へ移し、トップページの HTML から分類・規制と制度の索引を、課題マップの HTML から点の位置と線を、出典の HTML から出典一覧を取り出して JSON にする道具を書きました(全文は記事の最後)。本文中の「別の課題へのリンク(xxx.md)」は、サイトの住所(/issues/xxx)に直しています。
$ python3 tools/import_issues.py /tmp/.../scratchpad/todo .
issues=36 categories=9 regulations=6 positions=36 source_groups=20 sources=45
取り出した規制と制度の索引を確かめた出力です。旧ページで状態の後ろに「※要確認」と付いていたものは、needs_check を true にして分けています。
道路交通法・道路運送車両法(遠隔操作型小 | 2023年4月施行・運用拡大検討中 True ['public-space-operation-rules'] 警察庁・国土交通省
労働安全衛生規則(産業用ロボットの隔離原 | 2013年特例以降・共存前提への移行が論点 True ['human-robot-coexistence-safety', 'aging-fleet-maintenance'] 厚生労働省
製造物責任法(自律判断するロボットの「欠 | 解釈論が継続中 False ['liability-demarcation', 'maintenance-ecosystem'] 消費者庁
電波法(無線局免許・ローカル5G等の帯域 | 制度拡充・運用見直しを推進中 False ['network-latency-bandwidth', 'teleoperator-profession'] 総務省
個人情報保護法(カメラ画像・生活空間デー | ガイドライン整備が継続中 False ['teleop-privacy', 'cross-border-motion-data'] 個人情報保護委員会
外為法(輸出管理・デュアルユース技術) | 該非判定の運用が論点 False ['dual-use-regulation', 'ecosystem-sovereignty'] 経済産業省
['労働力・現場・人材', '学習・データ・モデル', 'コスト・経済性・調達', '環境・空間・インフラ', '産業構造・SIer・競争力', '安全・信頼性・事故', '自律性・判断・ガバナンス', '人間との関係・UX・受容', '規格・標準・国際競争']
edges 97
課題マップの線は、本文の「関連課題」とは別に選ばれていた ― 111本と103本
最初は、課題マップの線を各課題の本文の「関連課題」から作ろうとしました。数えると合いません。
$ python3 - <<'E'
(旧 map.html の線と、本文中の /issues/ へのリンクの組を比べる)
E
111 103 18 10
[('accessibility-non-engineers', 'task-definition-maturity'), ('autonomy-level-definition', 'public-space-operation-rules'), ('liability-demarcation', 'trust-calibration'), ('human-robot-coexistence-safety', 'motion-uncanny-valley'), ('caregiving-robot-adoption', 'teleoperator-profession')]
旧マップの線は111本、本文のリンクから作れる組は103本。旧マップにあって本文にない組が18、本文にあって旧マップにない組が10ありました。旧マップの線は、本文とは別に人が選んだものだったということです。本文から作り直すと、6月に選んだつながりが黙って変わってしまうので、旧マップの線111本をそのまま meta.json に持たせました。
$ python3 tools/import_issues.py /tmp/.../scratchpad/todo . && python3 -c "import json;print(len(json.load(open('src/data/issues/meta.json'))['map']['edges']))"
issues=36 categories=9 regulations=6 positions=36 source_groups=20 sources=45
111
本文の先頭にあった「# 課題名」と、その下のカードと同じ説明文は、ページの見出しと説明を frontmatter から出すので外しています。
/issues の6種類のページを作った ― 一覧・詳細・分類・マップ・出典・更新履歴
作ったページは次の6種類で、全部で49ページになります。
| 住所 | 中身 |
|---|---|
| /issues | トップ。最近の動き・9つの分類・規制と制度の索引・全課題のカード・この地図について |
| /issues/{課題} | 課題ごとの詳しいページ(36ページ)。点数・分類・時間軸・関連する規制、最近の動き、本文、つながっている課題 |
| /issues/category/{分類} | 分類ごとの一覧(9ページ) |
| /issues/map | 課題マップ |
| /issues/sources | 出典・データ集(45件) |
| /issues/updates | 最近の動きの全件と、週1回の調査の記録 |
見た目は旧地図の白黒の縞の帯をやめ、サイトのほかのページ(記事・4コマ)と同じ白い面の並びにそろえました。どのページにも、上に「課題一覧・課題マップ・規制と制度・出典とデータ・更新履歴」へ移る帯、下に「生成AIで作り人が点検している評価版であること」「最近の動きは週1回 AI が足し、点数や本文は人が確認してから変えること」を書いた注記を置いています。


ビルドすると、サイト全体で609ページになりました。
$ npm run build
...
08:21:56 [@astrojs/sitemap] `sitemap-index.xml` created at `dist`
08:21:56 [build] 609 page(s) built in 5.92s
08:21:56 [build] Complete!
/issues のページの中のリンクを全部たどり、行き先のファイルがあるかを確かめました。
50 pages 2248 links 0 bad
[]
検索エンジン向けのサイトマップにも 49 ページが入りました。
$ grep -o "pixelstomotors.com/issues[^<]*" dist/sitemap-0.xml | wc -l
49
Cloudflare と同じ配信の仕組みを手元で動かし(wrangler dev)、住所ごとの応答を確かめた結果です。末尾に / を付けた /issues/ は /issues へ転送され、ない課題は 404 になります。
/issues 200
/issues/ 307 http://localhost:8788/issues
/issues/map 200
/issues/human-robot-coexistence-safety 200
/issues/category/workforce 200
/issues/updates 200
/issues/sources 200
/issues/nothing 404
/mecharm_data_blueprint 200
/record/site-seo-by-ai 200
<title>フィジカルAIが社会実装される前に解くべき課題 | Pixels to Motors
最近の動きを足す道具は、誤りがあると書き込みを元に戻す
週1回の作業では、AI が見つけた出来事を JSON にまとめ、tools/add_issue_news.py で news.json に足します。足したあと tools/check_issues_data.py が全体を確かめ、誤りがあれば足す前の状態に戻して止まります。確かめるのは、住所が https で始まるか・同じ住所が二重に入っていないか・関連課題が実在するか・種類が決まった5つのどれかか・出来事の日が足した日より後になっていないか・見出しと要約の長さ、などです。
わざと誤った1件を渡して、元に戻ることを確かめました。
$ python3 tools/check_issues_data.py
OK 課題 36 件/最近の動き 0 件
$ echo '[{"date":"2026-10-01","issues":["nope"],"kind":"x","title":"t","summary":"s","source":"a","url":"http://x"}]' > bad.json
$ python3 tools/add_issue_news.py bad.json; echo "exit=$?"
課題地図のデータに誤りがあります:
- news.json 1件目(t): url が https:// で始まらない
- news.json 1件目(t): 関連課題 nope がない
- news.json 1件目(t): kind は 事故・安全/標準・国際/産業・事例/研究・技術/規制・制度 のどれか
誤りがあったので元に戻しました。上の一覧を直してからもう一度実行してください
exit=1
週1回の手順書 tools/issues_weekly.md と、予約タスクの指示文
毎週の作業は、クラウドで動く Claude が予約タスクとして行います。毎回まっさらな状態で起動するので、手順はリポジトリの中の tools/issues_weekly.md に書き、予約タスクの指示文では「最初にこの手順書を全部読み、そのとおりに作業する」と指定しました。手順を変えたくなったら、この手順書を直せば次の回から変わります(手順書の全文は記事の最後)。
予約タスクの設定です。
| 項目 | 値 |
|---|---|
| 名前 | 課題地図の週1回の更新 |
| いつ | 毎週月曜 5:48(日本時間)。CRON_TZ=Asia/Tokyo 48 5 * * 1 |
| 承認 | 承認を待たずに動く(auto) |
| 通知 | 終わったらスマホに通知 |
予約タスクの指示文の全文です。
あなたは pixelstomotors.com/issues(フィジカルAIが社会実装される前に解くべき課題)を、週1回、世の中の新しい動きに合わせて更新する係です。返事も記録もすべて日本語で書く。
1. GitHub の PixelstoMotors/p2m-site を clone する(git clone https://github.com/PixelstoMotors/p2m-site)。clone や push ができなければ、add_repo でこのリポジトリを push 権限つきで足してからやり直す。
2. clone したリポジトリの tools/issues_weekly.md を最初に全部読み、その手順のとおりに作業する。手順書と、この指示が食い違ったら手順書に従う。
3. 守ること(手順書にも書いてある要点):
- main に push してよいのは src/data/issues/news.json(最近の動き)と src/data/issues/checks.json(調べた記録)の2つだけ。何も見つからない週も checks.json に記録を1行残して push する。
- 緊急度・深刻度・規制の状態・本文・分類・マップの線を変えたいときは、別の枝に入れて GitHub のプルリクエストとして出すだけにする。自分で main に取り込まない。
- 最近の動きは、出典のページを開いて事実を確かめたものだけを入れる。要約は出典の文をなぞらず自分の言葉で書き、引用しない。評価や予想は書かない。迷ったら入れない。
- git add は1ファイルずつ指定する。git add . と git clean は使わない。ビルドが失敗したら push しない。
4. 終わったら、足した動きの件数と見出し、変更案を出したかどうか(出したならその住所)、うまくいかなかったことを、短く報告する。
旧住所の 49 ページは、新しい住所の同じページへ転送した
GitHub Pages では、ページごとに転送先を決めるサーバーの設定が使えません。そこで旧リポジトリの HTML 49 ファイルを、それぞれ新しい住所の同じページへ移る小さなページに置き換えました。meta http-equiv="refresh" と JavaScript の location.replace の両方で移り、検索エンジン向けに canonical で新しい住所を示しています。課題のページは /issues/{課題}、分類のページは /issues/category/{分類} に対応させています。
$ curl -s -o /dev/null -w '%{http_code}\n' https://pixelstomotors.github.io/physical-ai-todo/issues/motion-portability.html
200
$ curl -s https://pixelstomotors.github.io/physical-ai-todo/issues/motion-portability.html | head -12
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>移転しました | フィジカルAIが社会実装される前に解くべき課題</title>
<link rel="canonical" href="https://pixelstomotors.com/issues/motion-portability">
<meta http-equiv="refresh" content="0; url=https://pixelstomotors.com/issues/motion-portability">
<script>location.replace("https://pixelstomotors.com/issues/motion-portability" + location.hash);</script>
</head>
<body>
<p>このページは Pixels to Motors のサイトへ移りました。自動で移らない場合は <a href="https://pixelstomotors.com/issues/motion-portability">https://pixelstomotors.com/issues/motion-portability</a> を開いてください。</p>
</body>
サイトの中にあった旧住所へのリンク(トップのバナー、LOG233 の記事、記事2本の参考リンク、固定ページ)も新しい住所に付け替えました。記事の中で、当時の依頼文を引用している箇所やログの中は、当時の記録なので変えていません。
$ git diff --stat
README.md | 12 +++++++++++
.../build-in-public-double-structure-onpYMlUI.html | 2 +-
public/embeds/series4-2.html | 2 +-
public/embeds/top-banner.html | 2 +-
src/content.config.ts | 23 +++++++++++++++++++++-
src/content/records/DAY335.md | 6 +++---
src/content/records/DAY338.md | 2 +-
.../records/build-in-public-double-structure.json | 2 +-
src/data/static.json | 4 ++--
9 files changed, 44 insertions(+), 11 deletions(-)
試運転で足された最近の動き 7 件と、人の手で外した 1 文
予約タスクを作ったあと、月曜を待たずに1回動かしました。初回は「前回調べた日」がないので、手順書どおり30日前(9月16日)からを調べています。AI はこの日のうちに2回動き、1回目で6件、2回目で1件を足しました。2回目は、1回目で出典のページを開けずに見送った1件を、公式発表で確かめて足したものです。
調べた記録(checks.json)です。
[
{
"date": "2026-10-17",
"news_added": 6,
"note": "初回のため9/16〜10/16を対象に、国内規制・安全規格・研究・産業・事故を日英で調査。規制・制度6件の状態を変える出来事は見つからず変更案なし。ケルンの人型ロボット事件は事実関係が未確定のため見送り"
},
{
"date": "2026-10-17",
"news_added": 1,
"note": "同日の再実行。前回の調べた日(10/17)以降に新しい出来事はなし。前回出典を開けず見送ったAIRoAの4社選定(9/30)を公式発表で確かめて追加。ケルンの件は引き続き見送り"
}
]
足された7件です(点検後の文)。
---
date: 2026-09-16
kind: 産業・事例
issues: ['real-robot-data-scarcity', 'domestic-hardware-hollowing']
title: AIロボット協会が国産の汎用ロボット7社の試作機を公開した
summary: NEDOの委託で進む国産の双腕移動ロボット開発の競技で、THK・ugo・不二越など7社が試作機を東京で披露した。完成した機体は、ロボット向け基盤モデルの学習データを集める用途を想定している。
source: MONOist
url: https://monoist.itmedia.co.jp/mn/articles/2609/17/news053_2.html
---
date: 2026-09-22
kind: 研究・技術
issues: ['ecosystem-sovereignty', 'motion-portability']
title: Google系のIntrinsicがロボット開発の基盤ソフトを無償公開した
summary: 制御・認識・動作計画・シミュレーションをまとめたソフト一式「Intrinsic Core」を、ROSと組み合わせて使える形で無償公開した。特定メーカーの機体に縛られない作りで、動作の機種間の移し替えと開発の土台の主導権に関わる。
source: A3(Association for Advancing Automation)
url: https://www.automate.org/ai/industry-insights/googles-intrinsic-launches-open-source-core-toolkit
---
date: 2026-10-02
kind: 規制・制度
issues: ['human-robot-coexistence-safety', 'public-space-operation-rules']
title: 内閣府などがAIの実用化を妨げる規制の情報を募ると発表した
summary: 内閣府・デジタル庁・経済産業省が、AIの実用化の障害になっている法令や通達の情報を10月19日から30日まで募ると発表した。対象にはAIロボットなどの「フィジカルAI」も含まれる。
source: 内閣府
url: https://www8.cao.go.jp/kisei-kaikaku/kisei/forms/261002_forms.html
---
date: 2026-10-08
kind: 研究・技術
issues: ['tacit-knowledge-capture', 'real-robot-data-scarcity']
title: 独Agile Robotsが作業者の手元映像でロボットを学習させる施設を紹介した
summary: 作業者が自分の作業を撮影し、手や指の動きを取り出してロボットの学習に使う「Robot Academy」を、同社の最高経営責任者が説明した。遠隔操作で実機を動かさずに熟練者の動きをデータにする手段の一つである。
source: ブルームバーグ(The Star 掲載)
url: https://www.thestar.com.my/tech/tech-news/2026/10/09/german-startup-trains-its-robots-using-humans-filming-themselves
---
date: 2026-10-13
kind: 産業・事例
issues: ['foundation-model-concentration', 'network-latency-bandwidth']
title: 米Physical Intelligenceが自社の基盤モデルを外部に貸し出す試験版を始めた
summary: 開発者が自分のロボットのデータを預けると、同社の計算機で基盤モデルを追加学習し、ネット越しに動かせる仕組みの試験提供を始めた。初期の利用者の多くはクラウド上で推論を動かし、一部の現場では通信の問題が報告された。
source: Unite.AI
url: https://www.unite.ai/physical-intelligence-opens-pi-api-beta-for-robot-foundation-models/
---
date: 2026-10-13
kind: 規制・制度
issues: ['human-robot-coexistence-safety']
title: 韓国政府が人と同じ現場で動く人型ロボット20台の実証を特例で認めた
summary: 韓国の産業通商部の審議会が、産業用ロボットを固定し柵で囲む規則の特例として、Robrosの移動する人型ロボット20台を半導体・自動車・造船・物流の4現場で試す実証を承認した。
source: 韓国 産業通商部
url: https://www.motir.go.kr/kor/article/ATCL8764a1224/155119236/view
---
date: 2026-09-30
kind: 産業・事例
issues: ['domestic-hardware-hollowing', 'real-robot-data-scarcity']
title: AIロボット協会が国産汎用ロボットの改良機に進む4社を選んだ
summary: 試作機を作った7社の中から、Enactic・THK・不二越・ugoの4社を改良機の開発段階に選んだ。双腕の移動ロボットを国内で作り、2027年7月からロボット向け基盤モデルの学習データ集めに使う計画である。
source: AIロボット協会(AIRoA)
url: https://www.airoa.org/ja/updates/20260930/754/
点検では、英語が会社名・製品名だけに使われているか、評価や予想が書かれていないか、出典と食い違っていないかを見ました。内閣府の件の要約は、AI が書いた時点では次の1文が続いていました。
対象にはAIロボットなどの「フィジカルAI」が含まれ、柵での隔離や公道走行のルールも寄せられる範囲に入る。
募集の範囲が「AI の実用化の障害になる法令・通達」であることまでは出典で確かめられますが、「柵での隔離や公道走行のルールも範囲に入る」は、この地図の課題に結びつけるために AI が足した推測です。後半を外して push しました。
$ python3 tools/check_issues_data.py
OK 課題 36 件/最近の動き 7 件
$ git commit -m "課題地図:最近の動き1件の要約から、出典に書かれていない推測を外す"
$ git push origin HEAD:main
31bc397 課題地図:最近の動き1件の要約から、出典に書かれていない推測を外す
点検する側(このセッションの Claude)からは、出典のページを直接開くには許可が要ったため、骨格の事実は検索で別の報道と照らし合わせています。Physical Intelligence の件の「一部の現場では通信の問題が報告された」のような細部までは、この日の点検では確かめきれていません。


課題マップを 5 回作り直して、ほぼ元に戻した
移した直後の課題マップを見て、次の指示がありました。
このページベースはとてもいいですが、デザイン性が弱いです。もう少しだけあげてください。
ここから、指示と作り直しを5回くり返しました。下の画面写真は、どれも課題「介護現場へのロボット導入の壁」にマウスを乗せたところです。
| 回 | 時刻 | 何を変えたか | 指示 |
|---|---|---|---|
| 0 | 08:25 | 移した直後。旧地図と同じ位置の黒い四角と直線、触れると線が濃くなる | (もう少しだけあげてください) |
| 1 | 08:37 | 9つの分類を区画に分け、課題名を常に出し、横に説明欄を置いた | 見た目のベースは変えないように注意してください。グラフィカルな部分だけ向上されるように |
| 2 | 08:40 | 配置は元のまま。点の角を丸め、濃さで深刻度、線をゆるい曲線、触れた課題とつながる課題だけ名前、方眼の地、凡例 | 四角の見た目が良くないんじゃない?(マウスオーバーするとたくさんの点が集まっている感じが出るような動きとかにできる?)/線をゆるい曲線にしたのは良くない |
| 3 | 08:46 | 課題を小さな点の集まりで描き、触れると中心へ集まる。線は直線に戻した | 最初は四角でマウスオーバーすると点点が動いているような動きに変更(点はもっと小さくて良さそう) |
| 4 | 09:09〜09:41 | ふだんは四角。触れると四角が小さな点にほどけ、中心に濃く集まり、一部は線に沿って途中まで流れる。集まりの半径と点の数を3回調整 | 中心にもっと点が集まっている感じに変更/中心に点が集まっていて、点が線の方向へ向かうような(途中まででOK)/もっと中心にたくさんの点があっていいよ/もっと点の集まりの半径を大きくしていいよ |
| 5 | 09:43 | 点の動きをやめ、触れても四角のまま(縁が付く)に戻した | なんか良くならないね。点の集まりはやめてください。また考えます |






1回目は、指示の「もう少しだけ」に対して、ページの作りそのもの(区画・説明欄)まで変えてしまったものです。公開した直後に止められ、元の配置に戻してから2回目を作りました。この版は数分間だけ公開されていました。
4回目では、点を全課題ぶんページに書き込んでいたので、点を増やすたびにページが重くなりました。点の数を約1.5倍にした時点で、課題マップのページは約1.3MBになっています。
$ grep -o 'class="pd' dist/issues/map.html | wc -l; ls -la dist/issues/map.html | awk '{print $5}'
7406
1296126
触れた課題の点だけを、その場でブラウザで作る形に変え、約97KBに戻しました。
$ npm run build ... ; ls -la dist/issues/map.html | awk '{print $5}'
96817
5回目で残ったのは、目立たない部分です。
| 残したもの | 中身 |
|---|---|
| 方眼の地 | 地図の地に、24px ごとのうすい点を敷いた |
| 深刻度の濃淡 | 四角の濃さを深刻度の5段階で変えた(大きさは旧地図どおり緊急度) |
| 名前の出し方 | ふだんは名前を出さず、触れた課題とつながる課題、または分類で絞り込んだ課題にだけ出す。線の上でも読めるよう、文字を地の色で縁取った |
| 触れた課題の縁 | 触れた四角に黒い縁を付けた |
| 吹き出しの中身 | 分類・題名・緊急度と深刻度の5段階・つながっている課題の数 |
| 凡例 | 地図の下に、大きさ・濃さ・線の読み方 |
| ブラウザの吹き出しを外した | 四角に入れていた title が、触れるとブラウザの小さな吹き出しを出し、黒い吹き出しと重なっていた。title を外し、読み上げ用の名前(aria-label)に置き換えた |
点がほどける動きは、作ること自体はできましたが、何回直しても「良くなった」とは言えない形でした。四角が点にほどけるという見せ方が、課題の地図で何を伝えるのかを決めないまま、動きの細部(半径・点の数・流れる方向)を直し続けていたことになります。作った版は commit に残っているので、別の案を考えるときに部分的に戻せます。
更新を知らせるハコブくんを、サイト全体に入れた
次の依頼です。
サイト内に何かが新しく更新されたらハコブくんが動き回るようにしたいです(動画の進み具合のページで使ってある動画投稿時に出るやつです)
「動画の進み具合」は、YouTube の解説動画づくりの段階を一覧にしている自分用のページで、動画が公開されると、ハコブくんが画面の下を左右に2往復走り、吹き出しで知らせます。このページから走る処理(parade という関数)と、体の画像・顔の画像2枚(喜んだ顔と口を開けた顔)を取り出し、サイトに入れました。
$ python3 -c "...(HK_BODY と FACES3 の joy・open_large を取り出す)"
['open_closed', 'open_small', 'open_mid', 'open_large', 'blink_closed', 'blink_small', 'blink_mid', 'blink_large', 'smile_open', 'smile_blink', 'joy', 'trouble_open', 'trouble_blink']
body.webp (292, 460)
face_joy.webp (89, 86)
face_open_large.webp (89, 86)
決まりは、動画の進み具合のページと同じにしました。
| 決まり | 中身 |
|---|---|
| 何を知らせるか | 記録(LOG 記事)・4コママンガ・課題地図の最近の動きの新しいもの |
| 同じ更新で2回走らない | 知らせた更新は、見た人のブラウザ(localStorage)に覚えておく |
| 初めて来た人には | 日本時間でその日に公開された分だけ知らせる(過去の記事を全部知らせない) |
| タブが見えていないとき | 走らず、見えたときに走る |
| 吹き出しを押すと | その更新のページへ移る |
| 動きを減らす設定の人には | 走らず、少し表示するだけ |
何が新しいかは、サイトを作り直すたびに /updates.json に書き出します。記録の新しい10件・4コマの新しい5件・課題地図の最近の動きの新しい5件を、日付の新しい順に並べたものです。
$ ls -la dist/updates.json && python3 -c "...(先頭の8件)"
-rw-r--r-- 1 root root 5169 Oct 17 10:46 dist/updates.json
2026-10-17T00:00 record LOG344 AIに任せたSEO対策 ― 公開したサイトは「Googleに /record/site-seo-by-ai
2026-10-17T00:00 record LOG341 フィジカルAI研究室を作る Log#5 | π0.5 を m /record/pi05-mecharm-lora-2000-steps
2026-10-17T00:00 issues 課題地図 米Physical Intelligenceが自社の基盤モデ /issues/foundation-model-concentration
2026-10-17T00:00 issues 課題地図 韓国政府が人と同じ現場で動く人型ロボット20台の実証を特例で /issues/human-robot-coexistence-safety
2026-10-17T00:00 issues 課題地図 独Agile Robotsが作業者の手元映像でロボットを学習 /issues/tacit-knowledge-capture
2026-10-17T00:00 issues 課題地図 内閣府などがAIの実用化を妨げる規制の情報を募ると発表した /issues/human-robot-coexistence-safety
2026-10-17T00:00 issues 課題地図 AIロボット協会が国産汎用ロボットの改良機に進む4社を選んだ /issues/domestic-hardware-hollowing
2026-10-16T00:00 record LOG343 Studio を閉じて、旧 URL は Cloudflare /record/studio-shutdown-cloudflare-redirect
初めて来た人の状態で、PC とスマホの幅でトップを開き、走るか・走り終わったら消えるか・別のページへ移ってももう一度走らないかを確かめました。
1440 [] [True, '新しい記録を公開しました!(ほかに 6 件)LOG344 AIに任せたSEO対策 ― 公開したサイトは「Googleに載せない」設定のままだった。説明のない画像1万枚まで直した記録']
after: False
second page runs again? False
390 [] [True, '新しい記録を公開しました!(ほかに 6 件)LOG344 AIに任せたSEO対策 ― 公開したサイトは「Googleに載せない」設定のままだった。説明のない画像1万枚まで直した記録']
after: False
second page runs again? False


公開後、新しいドメインで一覧と画像が返ることを確かめています。
$ curl -s -o /dev/null -w '%{http_code}\n' https://pixelstomotors.com/updates.json
200
$ curl -s -o /dev/null -w '%{http_code}\n' https://pixelstomotors.com/site/hakobu/body.webp
200
知らせの効果は「出た・押した・あとで見に来た」の 3 つを数え、見た人を見分ける情報は残さない
ハコブくんの確認が済んだところで、次の指示がありました。
OKです!見れました。これでユーザーにどのような影響が出るかも記録していきましょう。
確かめたいのは、知らせを見た人がその記事を開くかどうかです。数える出来事を3つに決めました。
| 出来事 | いつ数えるか |
|---|---|
| 出た(shown) | ハコブくんが走り始めたとき |
| 吹き出しを押した(click) | 吹き出しを押したとき |
| 押さずにあとで見に来た(later) | 吹き出しは押さなかったが、7日以内にその記事のページを開いたとき(1回だけ) |
「押さずにあとで見に来た」は、最後に知らせた更新の住所と時刻を見た人のブラウザに覚えておき、7日以内にそのページを開いたら送る、という形で数えています。吹き出しを押して来た分は「押した」で数え済みなので、二重には数えません。
残すのは、出来事の種類・どの更新か・どのページで出たか・画面の幅(pc/sp)・日本時間の日付だけです。IP アドレス・ブラウザの種類・Cookie は残さず、見た人を見分ける番号も作りません。送る先はサイトの配信の仕組み(worker)に足した /api/hk-event で、記録はメッセージフォームと同じ R2 の入れ物(p2m-messages)の hk-events/ に、1件ずつ中身のない小さなファイルとして置きます。何が起きたかはファイルの付箋(customMetadata)に書くので、数えるときに中身を開かず一覧だけで集計できます。
手元で wrangler dev を動かし、2人分を試しました。1人目は PC の幅で吹き出しを押して記事へ移り、2人目はスマホの幅で押さずに、8秒後にその記事を開いています。
after click at http://localhost:8790/record/site-seo-by-ai
[]
{"total":4,"byDay":[{"day":"2026-10-17","shown":2,"click":1,"later":1}],"byItem":[{"id":"record:site-seo-by-ai","shown":2,"click":1,"later":1}],"byWidth":[{"w":"pc","shown":1,"click":1,"later":0},{"w":"sp","shown":1,"click":0,"later":1}]}
$ curl -s -o /dev/null -w 'bad event -> %{http_code}\n' -X POST -d '{"e":"x"}' http://localhost:8790/api/hk-event
bad event -> 400
数は /api/hk-stats で表として見られます(検索エンジンには載せない設定)。週ごとの表を一番上に置き、月曜はじまりで週が変わると新しい行が自動で増えるようにしました。毎週の記録を足すのに、別の自動作業を動かす必要はありません。
{"total":8,"byWeek":[{"week":"2026-10-12 〜 10-18","shown":4,"click":2,"later":2}],"byDay":[{"day":"2026-10-17","shown":4,"click":2,"later":2}],"byItem":[{"id":"record:site-seo-by-ai","shown":4,"click":2,"later":2}],"byWidth":[{"w":"pc","shown":3,"click":2,"later":1},{"w":"sp","shown":1,"click":0,"later":1}]}
<h2>週ごと(月曜はじまり。週が変わると新しい行が増える)</h2>
<h2>日ごと</h2>
<h2>更新ごと</h2>
<h2>画面の幅(pc=広い/sp=スマホ)</h2>
公開した直後の本番の数は 0 件です。記録の仕組みを入れたのが、ハコブくんを確認したあとだったためです。
$ curl -s -o /dev/null -w 'stats %{http_code}\n' https://pixelstomotors.com/api/hk-stats
stats 200
$ curl -s 'https://pixelstomotors.com/api/hk-stats?format=json'
{"total":0,"byWeek":[],"byDay":[],"byItem":[],"byWidth":[]}
$ curl -s -o /dev/null -w 'bad %{http_code}\n' -X POST -d '{}' https://pixelstomotors.com/api/hk-event
bad 400
プライバシーポリシーには「更新のお知らせの記録について」の項目を足しました。記録すること・記録しないこと・お知らせ済みの記事をブラウザに覚えておくがその情報は送らないこと、を書いています。
数の見方と気をつけることは、プロジェクトの記録(hakobu_parade_impact.md)にまとめました。自分の確認の閲覧も数に入ること、初めて来た人にはその日の分しか知らせないので来訪の少ない日は「出た」が0になりうること、「出た」が30件ほどたまるまでは割合で結論を出さないこと、です。
課題地図の仕事のあいだに入った commit の一覧
$ git log --reverse --format='%h %ad %an | %s' --date=format:'%Y-%m-%d %H:%M %z' 082c853..HEAD
1e279c0 2026-10-17 08:25 +0900 Claude | 課題地図を /issues としてサイトに組み込み、週1回の更新の仕組みを追加
42b355f 2026-10-17 08:37 +0900 Claude | 課題マップの見た目を改善:分類ごとの区画・課題名の表示・深刻度の濃淡・説明欄
07706be 2026-10-17 08:37 +0900 Claude | 課題マップを元の配置に戻す
ed3ecf9 2026-10-16 23:39 +0000 Claude | 課題地図:最近の動きを追加(2026-10-17・6件)
350d956 2026-10-16 23:39 +0000 Claude | Merge branch 'main' of https://github.com/PixelstoMotors/p2m-site
ae1090d 2026-10-17 08:40 +0900 Claude | 課題マップの描き方を改善(配置とページ構成は元のまま)
2cb9f04 2026-10-16 23:41 +0000 Claude | 課題地図:最近の動きを追加(2026-10-17・1件)
b6cba8b 2026-10-16 23:41 +0000 Claude | Merge branch 'main' of https://github.com/PixelstoMotors/p2m-site
9ff141d 2026-10-17 08:46 +0900 Claude | 課題マップ:課題を点の集まりで描き、触れると点が集まる動きに。線は直線に戻す
610959e 2026-10-17 09:09 +0900 Claude | 課題マップ:ふだんは四角、触れると四角が小さな点にほどけて動き続ける
31bc397 2026-10-17 09:16 +0900 Claude | 課題地図:最近の動き1件の要約から、出典に書かれていない推測を外す
f2ddd94 2026-10-17 09:33 +0900 Claude | 課題マップ:ほどけた点を中心に濃く集め、外へいくほどまばらにする
b3599fc 2026-10-17 09:36 +0900 Claude | 課題マップ:ほどけた点を中心に多く集め、一部はつながる線に沿って途中まで流れる
d61886f 2026-10-17 09:41 +0900 Claude | 課題マップ:ほどけた点の集まりを広げて数も増やし、点は触れた課題の分だけその場で作る
5166c92 2026-10-17 09:43 +0900 Claude | 課題マップ:点の集まりの動きをやめ、触れても四角のままにする
7286a81 2026-10-17 10:47 +0900 Claude | サイトに新しい更新があると、ハコブくんが画面の下を走り回って知らせる
6a9c856 2026-10-17 11:25 +0900 Claude | 更新を知らせるハコブくんの効果を記録する
f8bc5ef 2026-10-17 11:54 +0900 Claude | ハコブくんの知らせの記録に、週ごとの表を足す(週が変わると自動で行が増える)
時刻が +0000 の4つは、予約タスクの Claude(クラウド)が出した commit です。旧課題地図のリポジトリ(physical-ai-todo)側の commit は1つです。
$ git -C physical-ai-todo log --oneline -2
ab504c6 課題地図を pixelstomotors.com/issues へ移転:各ページを新しい住所へ転送
67be17a [doc] ①を三系統主役に再設計(補正記憶を二次サイドノート化・pose_idをjoin key表現・中央ノード軽量化)
コード全文
この日に書いたファイルと、書き換えたファイルの全文です。課題の本文(src/content/issues/*.md 36件)と、データ(meta.json・sources.json)は、旧リポジトリから道具で移したもので、中身は旧課題地図と同じなので載せていません。news.json と checks.json の中身は上の節に全量を載せています。
tools/import_issues.py
旧課題地図の Markdown と HTML から、サイトの課題・分類・規制・マップ・出典のデータを作る道具。
#!/usr/bin/env python3
"""課題地図(旧 physical-ai-todo)を、このサイトの /issues に取り込む(移行時だけ使う)。
使い方:
python3 tools/import_issues.py <physical-ai-todo のフォルダ> .
作るもの:
src/content/issues/{slug}.md 課題1件=1ファイル(本文の正。旧リポジトリの issues/*.md をそのまま移す)
src/data/issues/meta.json 分類・規制制度の索引・課題マップの点の位置と線
src/data/issues/sources.json 出典・データ集(発行主体ごと)
src/data/issues/news.json 最近の動き(空で作る。週1回の自動調査が書き足す)
src/data/issues/checks.json 自動調査を回した記録(空で作る)
本文の中の「別の課題へのリンク(xxx.md)」は、このサイトの住所(/issues/xxx)に直す。
news.json と checks.json がすでにあれば上書きしない(自動調査の記録を消さないため)。
"""
import html
import json
import re
import sys
from pathlib import Path
src = Path(sys.argv[1])
out = Path(sys.argv[2])
issues_dir = out / 'src/content/issues'
data_dir = out / 'src/data/issues'
issues_dir.mkdir(parents=True, exist_ok=True)
data_dir.mkdir(parents=True, exist_ok=True)
def text(s):
return html.unescape(re.sub(r'<[^>]+>', '', s)).strip()
# 1. 課題本文(.md が正)
slugs = []
for md in sorted((src / 'issues').glob('*.md')):
if md.name.startswith('_'):
continue
body = md.read_text(encoding='utf-8')
# 別の課題へのリンク: (xxx.md) / (xxx.html) → (/issues/xxx)
body = re.sub(r'\]\(([a-z0-9-]+)\.(?:md|html)(#[^)]*)?\)', lambda m: f'](/issues/{m.group(1)}{m.group(2) or ""})', body)
# 本文の先頭の「# 課題名」と、その下のカードと同じ説明文は外す(ページの見出しと説明は先頭の項目から出すため)
body = re.sub(r'(\n---\n)\s*# [^\n]+\n+[^\n#*][^\n]*\n+', r'\1\n', body, count=1)
(issues_dir / md.name).write_text(body, encoding='utf-8')
slugs.append(md.stem)
# 2. 分類(トップの分類の箱から:名前と説明。並び順もトップのまま)
top = (src / 'index.html').read_text(encoding='utf-8')
categories = []
for m in re.finditer(r'<a class="catbox" href="categories/([a-z-]+)\.html">\s*<h3>(.*?)<span class="count">.*?<p class="catdesc">(.*?)</p>', top, re.S):
categories.append({'slug': m.group(1), 'name': text(m.group(2)), 'desc': text(m.group(3))})
# 3. 規制・制度の索引
regulations = []
sec = top[top.find('id="regulations"'):]
for m in re.finditer(r'<div class="regitem">(.*?)</div>\s*(?=<div class="regitem">|</div>)', sec, re.S):
blk = m.group(1)
name = text(re.search(r'<h3>(.*?)</h3>', blk, re.S).group(1))
status = text(re.search(r'<span class="status">(.*?)</span>', blk, re.S).group(1))
desc = text(re.search(r'</div>\s*<p>(.*?)</p>', blk, re.S).group(1))
rel = re.findall(r'href="issues/([a-z0-9-]+)\.html"', blk)
src_m = re.search(r'<span class="lbl">出典</span>(.*?)</div>', blk + '</div>', re.S)
regulations.append({
'name': name,
'status': status.replace('※要確認', '').strip(),
'needs_check': '※要確認' in status,
'desc': desc,
'issues': rel,
'source': text(src_m.group(1)) if src_m else '',
})
# 4. 課題マップの点の位置(旧マップの配置をそのまま使う。線は本文の「関連課題」から作る)
mp = (src / 'map.html').read_text(encoding='utf-8')
positions = {}
for m in re.finditer(r'<g class="node"[^>]*data-slug="([a-z0-9-]+)"[^>]*><rect x="(\d+)" y="(\d+)" width="(\d+)"', mp):
slug, x, y, w = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4))
positions[slug] = {'x': x + w / 2, 'y': y + w / 2}
id2slug = {m.group(1): m.group(2) for m in re.finditer(r'data-id="(\d+)"[^>]*data-slug="([a-z0-9-]+)"', mp)}
edges = sorted({tuple(sorted((id2slug[a], id2slug[b])))
for a, b in re.findall(r'class="edge" data-a="(\d+)" data-b="(\d+)"', mp)})
meta = {
'categories': categories,
'regulations': regulations,
# 線は旧マップで選んだ関連(本文の「関連課題」とは別に決めてある)をそのまま使う
'map': {'width': 1000, 'height': 800, 'positions': positions, 'edges': [list(e) for e in edges]},
}
(data_dir / 'meta.json').write_text(json.dumps(meta, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
# 5. 出典・データ集
sp = (src / 'sources.html').read_text(encoding='utf-8')
groups = []
for m in re.finditer(r'<h2 class="org-h">(.*?)<span class="orgcount">\d+</span></h2>\s*<ol class="srclist">(.*?)</ol>', sp, re.S):
items = []
for li in re.findall(r'<li>(.*?)</li>', m.group(2), re.S):
items.append({
'title': text(re.search(r'<span class="srctitle">(.*?)</span>', li, re.S).group(1)),
'volatile': 'class="fresh"' in li,
'issues': re.findall(r'href="issues/([a-z0-9-]+)\.html"', li),
})
groups.append({'org': text(m.group(1)), 'items': items})
(data_dir / 'sources.json').write_text(json.dumps(groups, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
# 6. 自動調査の書き込み先(あれば触らない)
for name, empty in (('news.json', []), ('checks.json', [])):
p = data_dir / name
if not p.exists():
p.write_text(json.dumps(empty) + '\n', encoding='utf-8')
print(f'issues={len(slugs)} categories={len(categories)} regulations={len(regulations)} '
f'positions={len(positions)} source_groups={len(groups)} sources={sum(len(g["items"]) for g in groups)}')
tools/check_issues_data.py
課題地図のデータに誤りがないかを確かめる道具。週1回の作業でも、足したあとに必ず動く。
#!/usr/bin/env python3
"""課題地図(/issues)のデータに誤りがないかを確かめる。誤りがあれば一覧を出して 1 で終わる。
使い方: python3 tools/check_issues_data.py
確かめること:
- news.json: 必要な項目がそろっているか/住所が https か/同じ住所が二重に入っていないか/
日付の形と順序(出来事の日 ≦ 足した日)/関連課題が実在するか/種類が決まった5つのどれかか/長さ
- checks.json: 日付の形と件数
- meta.json: 規制・マップが実在する課題だけを指しているか/全課題にマップの位置があるか
- 課題の Markdown: 点数が1〜5か/分類が meta.json にあるか/ファイル名と slug が同じか
"""
import json
import re
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / 'src/data/issues'
ISSUES = ROOT / 'src/content/issues'
KINDS = {'規制・制度', '研究・技術', '産業・事例', '事故・安全', '標準・国際'}
DAY = re.compile(r'^\d{4}-\d{2}-\d{2}$')
errors = []
def err(msg):
errors.append(msg)
def valid_day(s):
if not isinstance(s, str) or not DAY.match(s):
return False
try:
date.fromisoformat(s)
return True
except ValueError:
return False
def front(md):
m = re.match(r'^---\n(.*?)\n---\n', md, re.S)
out = {}
for line in (m.group(1) if m else '').splitlines():
k, _, v = line.partition(':')
out[k.strip()] = v.split('#')[0].strip()
return out
meta = json.loads((DATA / 'meta.json').read_text(encoding='utf-8'))
cats = {c['slug'] for c in meta['categories']}
slugs = set()
for p in sorted(ISSUES.glob('*.md')):
if p.name.startswith('_'):
continue
f = front(p.read_text(encoding='utf-8'))
slugs.add(p.stem)
if f.get('slug') != p.stem:
err(f'{p.name}: slug({f.get("slug")})がファイル名と違う')
for k in ('urgency', 'severity'):
if f.get(k) not in {'1', '2', '3', '4', '5'}:
err(f'{p.name}: {k} が1〜5ではない({f.get(k)})')
if f.get('category_slug') not in cats:
err(f'{p.name}: category_slug({f.get("category_slug")})が meta.json の分類にない')
if f.get('status') not in {'skeleton', 'draft', 'verified'}:
err(f'{p.name}: status が skeleton / draft / verified ではない')
if not valid_day(f.get('last_verified')):
err(f'{p.name}: last_verified が YYYY-MM-DD ではない')
for r in meta['regulations']:
for s in r['issues']:
if s not in slugs:
err(f'meta.json 規制「{r["name"]}」: 関連課題 {s} がない')
for s in slugs - set(meta['map']['positions']):
err(f'meta.json map: 課題 {s} の位置がない')
for a, b in meta['map']['edges']:
if a not in slugs or b not in slugs:
err(f'meta.json map: 線 {a} — {b} の片方がない')
news = json.loads((DATA / 'news.json').read_text(encoding='utf-8'))
seen_url, seen_id = {}, set()
for i, n in enumerate(news):
where = f'news.json {i + 1}件目({n.get("title", "")[:20]})'
for k in ('id', 'date', 'added', 'issues', 'kind', 'title', 'summary', 'source', 'url'):
if not n.get(k):
err(f'{where}: {k} が空')
if n.get('id') in seen_id:
err(f'{where}: id が重複')
seen_id.add(n.get('id'))
url = n.get('url', '')
if not url.startswith('https://'):
err(f'{where}: url が https:// で始まらない')
key = url.rstrip('/')
if key in seen_url:
err(f'{where}: 同じ url が {seen_url[key]} 件目にもある')
seen_url.setdefault(key, i + 1)
if not valid_day(n.get('date')) or not valid_day(n.get('added')):
err(f'{where}: date / added が YYYY-MM-DD ではない')
elif n['date'] > n['added']:
err(f'{where}: 出来事の日(date)が足した日(added)より後')
for s in n.get('issues', []):
if s not in slugs:
err(f'{where}: 関連課題 {s} がない')
if n.get('kind') not in KINDS:
err(f'{where}: kind は {"/".join(sorted(KINDS))} のどれか')
if len(n.get('title', '')) > 60:
err(f'{where}: title が60字を超える')
if len(n.get('summary', '')) > 200:
err(f'{where}: summary が200字を超える')
for i, c in enumerate(json.loads((DATA / 'checks.json').read_text(encoding='utf-8'))):
if not valid_day(c.get('date')):
err(f'checks.json {i + 1}件目: date が YYYY-MM-DD ではない')
if not isinstance(c.get('news_added'), int) or c['news_added'] < 0:
err(f'checks.json {i + 1}件目: news_added が0以上の整数ではない')
if errors:
print('課題地図のデータに誤りがあります:')
for e in errors:
print(' -', e)
sys.exit(1)
print(f'OK 課題 {len(slugs)} 件/最近の動き {len(news)} 件')
tools/add_issue_news.py
最近の動きを足し、調べた記録を1行残す道具。誤りがあれば元に戻す。
#!/usr/bin/env python3
"""週1回の自動調査の結果を、課題地図(/issues)の「最近の動き」に足し、調べた記録を1行残す。
使い方:
python3 tools/add_issue_news.py <新しい動きの JSON> [--note "メモ"] [--proposal "変更案の住所"]
<新しい動きの JSON> は配列。1件の形(id と added はこの道具が付ける):
{"date": "2026-10-15", "issues": ["human-robot-coexistence-safety"], "kind": "規制・制度",
"title": "自分の言葉の見出し", "summary": "自分の言葉の要約(2文まで)", "source": "発行元", "url": "https://..."}
見つからなかった週は [] を渡す(調べた記録だけが残る)。
- 住所(url)がすでに載っているものは足さない
- 足したあと tools/check_issues_data.py で全体を確かめ、誤りがあれば元に戻して 1 で終わる
"""
import argparse
import hashlib
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parent.parent
NEWS = ROOT / 'src/data/issues/news.json'
CHECKS = ROOT / 'src/data/issues/checks.json'
ap = argparse.ArgumentParser()
ap.add_argument('entries')
ap.add_argument('--note', default='')
ap.add_argument('--proposal', default=None)
a = ap.parse_args()
today = datetime.now(ZoneInfo('Asia/Tokyo')).date().isoformat()
new = json.loads(Path(a.entries).read_text(encoding='utf-8'))
if not isinstance(new, list):
sys.exit('新しい動きの JSON は配列にしてください')
old_news = NEWS.read_text(encoding='utf-8')
old_checks = CHECKS.read_text(encoding='utf-8')
news = json.loads(old_news)
known = {n['url'].rstrip('/') for n in news}
added = 0
for n in new:
url = str(n.get('url', '')).strip()
if url.rstrip('/') in known:
print('すでに載っているので飛ばす:', url)
continue
entry = {
'id': f"{n.get('date', today)}-{hashlib.sha1(url.encode()).hexdigest()[:8]}",
'date': n.get('date', ''),
'added': today,
'issues': n.get('issues', []),
'kind': n.get('kind', ''),
'title': str(n.get('title', '')).strip(),
'summary': str(n.get('summary', '')).strip(),
'source': str(n.get('source', '')).strip(),
'url': url,
}
news.append(entry)
known.add(url.rstrip('/'))
added += 1
checks = json.loads(old_checks)
row = {'date': today, 'news_added': added, 'note': a.note}
if a.proposal:
row['proposal'] = a.proposal
checks.append(row)
NEWS.write_text(json.dumps(news, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
CHECKS.write_text(json.dumps(checks, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
r = subprocess.run([sys.executable, str(ROOT / 'tools/check_issues_data.py')])
if r.returncode != 0:
NEWS.write_text(old_news, encoding='utf-8')
CHECKS.write_text(old_checks, encoding='utf-8')
sys.exit('誤りがあったので元に戻しました。上の一覧を直してからもう一度実行してください')
print(f'足した動き: {added} 件(調べた日 {today})')
tools/issues_weekly.md
予約タスクの Claude が毎回最初に読む、週1回の更新の手順書。
# 課題地図の週1回の更新手順
pixelstomotors.com/issues(フィジカルAIが社会実装される前に解くべき課題)を、週1回、世の中の新しい動きに合わせて更新するための手順。予約タスクで動く Claude が、毎回この手順書を読んでから作業する。手順を変えるときは、このファイルを直す。
## 公開してよいものと、確認が要るもの
| 変更 | 扱い |
|---|---|
| 「最近の動き」を足す(src/data/issues/news.json) | 自動で公開してよい。main に push する |
| 調べた記録を残す(src/data/issues/checks.json) | 自動で公開してよい(何も見つからない週も必ず1行残す) |
| 緊急度・深刻度・時間軸・分類を変える | 確認が要る。変更案として出す |
| 規制・制度の状態の文言を変える(meta.json) | 確認が要る。変更案として出す |
| 課題の本文を書き換える・課題を足す・消す・マップの線を変える | 確認が要る。変更案として出す |
変更案は、main とは別の枝(ブランチ)に入れて、GitHub のプルリクエストとして出す。自分で main に取り込まない。取り込むかどうかはエイイチが決める。
## 1. 準備
```
git clone https://github.com/PixelstoMotors/p2m-site
cd p2m-site
npm ci
python3 tools/check_issues_data.py
```
`src/data/issues/checks.json` の最後の行の日付が、前回調べた日。今回はその日から今日までに起きたことを探す(初回は今日から30日前まで)。`news.json` にすでに載っている出来事は探さなくてよい。
## 2. 何を調べるか
課題は `src/content/issues/*.md` の36件、規制・制度は `src/data/issues/meta.json` の regulations の6件。すべての課題を毎回1件ずつ深く調べる必要はない。次の順で、期間内に新しく出たものを探す。
1. 規制・制度の6件:所管の役所(警察庁・国土交通省・厚生労働省・消費者庁・総務省・個人情報保護委員会・経済産業省)の発表、法改正・ガイドライン・パブリックコメント
2. 安全規格・標準:ISO(産業用ロボットの安全・協働ロボット・生活支援ロボット)、JIS、EU の機械規則や AI 法のうちロボットに関わるもの
3. 研究・技術:実機データの集め方、シミュレーションから実機への移し替え、ロボット向けの大きな学習済みモデル、ロボットの安全な止め方・説明のしかたに関する、大学・研究機関・主要企業の発表
4. 産業・事例:介護・物流・製造・配送ロボットの導入事例、価格・リース、保守、人材育成、国内メーカーの動き
5. 事故・安全:ロボットが関わる事故、乗っ取り・誤作動、リコール
日本語と英語の両方で検索する。検索結果の見出しだけで判断せず、必ず出典のページを開いて中身を読む。
## 3. 「最近の動き」に入れる条件(すべて満たすもの)
- 出来事の日(公開日・施行日・発表日)が今回の期間に入っている
- 出典のページを開いて、書こうとしている事実がそこに書いてあることを確かめた
- 出典が、役所・標準化団体・大学や研究機関・企業の公式発表・信頼できる報道のどれか(SNS の投稿だけ、まとめサイト、広告記事、噂は入れない)
- どの課題に関わるかを、その課題の本文と照らして具体的に言える(言葉が似ているだけでは入れない)
- 同じ出来事がすでに載っていない(別の報道でも同じ出来事なら入れない)
1回に足すのは多くて10件。迷ったら入れない。何も見つからない週は0件でよい。
## 4. 書き方
- title:何が起きたかを40字程度で。主語(誰が)と中身(何をした)を入れる。出典の見出しをそのまま使わない
- summary:2文まで、120字程度。何が起きたかと、それがこの課題のどこに関わるかを書く。出典の文をなぞらず、自分の言葉で書き直す。引用しない
- 評価・予想・意見を書かない(「画期的」「〜が進むだろう」は書かない)。起きた事実だけを書く
- 文体は「だ・である」でも「です・ます」でもなく、言い切りの短い文(例:「警察庁が届出の手続きを見直す案を公表した。」)
- 製品名・組織名・規格番号などの固有名以外に英語を混ぜない。専門用語を使うときは、初めて読む人にも分かる言い方にする
- kind は次の5つのどれか:規制・制度/研究・技術/産業・事例/事故・安全/標準・国際
- source は発行元の名前(例:警察庁、ISO、ロイター)
- issues は関わる課題の slug(ファイル名から .md を除いたもの)。1〜3件
## 5. 「最近の動き」を足して公開する
確認が要る変更(6)もある週は、先に 6 を済ませてから、この 5 を行う(記録にプルリクエストの住所を残すため)。
足すものを JSON の配列で一時ファイルに書き(形は `tools/add_issue_news.py` の先頭の説明のとおり)、次を実行する。
```
python3 tools/add_issue_news.py /tmp/new_news.json --note "今回見た範囲や気づいたことを1文で"
npm run build
git add src/data/issues/news.json src/data/issues/checks.json
git commit -m "課題地図:最近の動きを追加(YYYY-MM-DD・N件)"
git push origin main
```
- 0件の週も、`[]` を渡して実行し、checks.json の記録だけを push する
- `git add .` は使わない。`git clean` は使わない
- ビルドが失敗したら push しない。原因を直せなければ、何が起きたかを報告して終わる
- push すると、約40秒後に pixelstomotors.com に反映される
## 6. 確認が要る変更を、変更案として出す
調べているうちに、点数・規制の状態・本文が古くなっている(例:規制が施行された、規格が改定された、前提の数字が大きく変わった)と分かったときだけ行う。
```
git checkout -b issues-proposal-YYYY-MM-DD
(対象の .md や meta.json を直す。本文を直した課題は last_verified を今日にし、本文の最後の「更新履歴」に1行足す)
python3 tools/check_issues_data.py
npm run build
git add (直したファイルを1つずつ)
git commit -m "課題地図:変更案(YYYY-MM-DD)"
git push origin issues-proposal-YYYY-MM-DD
gh pr create --base main --head issues-proposal-YYYY-MM-DD --title "課題地図の変更案(YYYY-MM-DD)" --body "(下の形で)"
```
プルリクエストの説明には、変更ごとに次の4つを書く。エイイチがこれだけ読めば判断できるように書く。
- どの課題(またはどの規制)の、どこを、どう変えるか(変更前 → 変更後)
- なぜ変えるのか(何が起きたか)
- 出典(名前と住所)
- 変えなかった場合に、サイトのどこが事実と違ったままになるか
変更案を出したら main に戻り(`git checkout main`)、5 の手順に進む。checks.json に記録するとき `--proposal` にプルリクエストの住所を渡す。
## 7. 終わったら
最後に、次の3つを短く報告する:足した動きの件数と見出し/変更案を出したか(出したなら住所)/うまくいかなかったこと。
src/content.config.ts
課題の Markdown の先頭の項目(点数・分類など)の形を決めている。
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
// Claude が書いた記事(Markdown)。先頭の項目は article_rules.md の形のまま
const records = defineCollection({
loader: glob({ pattern: '*.md', base: './src/content/records' }),
schema: z.object({
title: z.string(),
slug: z.string(),
date: z.coerce.date(),
day: z.number(),
series: z.string(),
meta_description: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});
// 課題地図(/issues)。課題1件=1ファイル。先頭の項目がカード(点数・分類など)の唯一の正
const issues = defineCollection({
loader: glob({ pattern: '[!_]*.md', base: './src/content/issues' }),
schema: z.object({
id: z.number(),
slug: z.string(),
title: z.string(),
category: z.string(),
category_slug: z.string(),
summary: z.string(),
urgency: z.number().int().min(1).max(5),
severity: z.number().int().min(1).max(5),
time_horizon: z.string(),
layer: z.string(),
actors: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
related_regulations: z.string().optional(),
last_verified: z.coerce.date(),
status: z.enum(['skeleton', 'draft', 'verified']),
}),
});
export const collections = { records, issues };
src/lib/issues.ts
課題地図のページが共通で使う処理(並び順・最近の動き・マップの線など)。
// 課題地図(/issues)の共通処理
// 本文と点数: src/content/issues/*.md / 分類・規制・マップ: src/data/issues/meta.json
// 最近の動き: src/data/issues/news.json / 自動調査の記録: src/data/issues/checks.json
import { getCollection, type CollectionEntry } from 'astro:content';
import meta from '../data/issues/meta.json';
import sources from '../data/issues/sources.json';
import newsRaw from '../data/issues/news.json';
import checksRaw from '../data/issues/checks.json';
export type Issue = CollectionEntry<'issues'>;
export type News = {
id: string; date: string; added: string; issues: string[]; kind: string;
title: string; summary: string; source: string; url: string;
};
export type Check = { date: string; news_added: number; proposal?: string | null; note?: string };
export const ISSUES_TITLE = 'フィジカルAIが社会実装される前に解くべき課題';
export const CATEGORIES = meta.categories as { slug: string; name: string; desc: string }[];
export const REGULATIONS = meta.regulations as {
name: string; status: string; needs_check: boolean; desc: string; issues: string[]; source: string;
}[];
export const MAP = meta.map as {
width: number; height: number; positions: Record<string, { x: number; y: number }>; edges: [string, string][];
};
export const SOURCES = sources as { org: string; items: { title: string; volatile: boolean; issues: string[] }[] }[];
// 新しい順(同じ日なら追加の新しい順)
export const NEWS = (newsRaw as News[]).slice().sort((a, b) =>
b.date.localeCompare(a.date) || b.added.localeCompare(a.added));
export const CHECKS = (checksRaw as Check[]).slice().sort((a, b) => b.date.localeCompare(a.date));
// 最後に自動調査を回した日(まだ一度も回していなければ null)
export const LAST_CHECKED = CHECKS[0]?.date ?? null;
// 深刻度→緊急度→番号の順(旧サイトの一覧・分類と同じ並び)
export const bySeverity = (a: Issue, b: Issue) =>
b.data.severity - a.data.severity || b.data.urgency - a.data.urgency || a.data.id - b.data.id;
// 緊急度→深刻度→番号の順(旧サイトの課題マップの並び)
export const byUrgency = (a: Issue, b: Issue) =>
b.data.urgency - a.data.urgency || b.data.severity - a.data.severity || a.data.id - b.data.id;
export async function allIssues() {
return (await getCollection('issues')).sort(bySeverity);
}
export const newsFor = (slug: string) => NEWS.filter((n) => n.issues.includes(slug));
// マップの線でつながっている課題
export function neighbors(slug: string) {
const out: string[] = [];
for (const [a, b] of MAP.edges) {
if (a === slug) out.push(b);
else if (b === slug) out.push(a);
}
return out;
}
export const catName = (slug: string) => CATEGORIES.find((c) => c.slug === slug)?.name ?? slug;
// 2026-06-12 → 2026/6/12(サイトの日付の書き方にそろえる)
export function jdate(d: string | Date | null | undefined) {
if (!d) return '';
const s = typeof d === 'string' ? d : d.toISOString().slice(0, 10);
const [y, m, day] = s.slice(0, 10).split('-').map(Number);
return `${y}/${m}/${day}`;
}
export const STATUS_LABEL: Record<string, string> = {
skeleton: '骨組み', draft: '下書き(一次出典の確認前を含む)', verified: '一次出典で確認済み',
};
src/layouts/IssuesLayout.astro
/issues 以下の共通の枠。上の帯と下の注記。
---
// 課題地図(/issues 以下)の共通の枠。サイト全体の枠(Base)の中に、課題地図の帯と注記を足す
import Base from './Base.astro';
import IssuesNav from '../components/issues/IssuesNav.astro';
import { LAST_CHECKED, jdate } from '../lib/issues';
const { current, ...rest } = Astro.props;
---
<Base {...rest}>
<slot name="head" slot="head" />
<div class="iwrap">
<IssuesNav current={current} />
<slot />
<aside class="inote">
<p>この課題地図は生成AIで作り、人が点検している評価版です。数値・規制の記述には一次出典で確認する前のものを含みます。重要な判断は各ページの出典に当たってご確認ください。</p>
<p>「最近の動き」は週1回、AIが公開情報を調べて出典つきで足しています。点数・規制の状態・本文の書き換えは、人が確認してから反映します。{LAST_CHECKED ? `最後に調べた日:${jdate(LAST_CHECKED)}。` : ''}誤りのご指摘は<a href="/message">メッセージ</a>からお寄せください。</p>
</aside>
</div>
</Base>
<style is:global>
.iwrap { max-width: 1280px; margin: 0 auto; padding: 64px 40px 64px; }
.iwrap .ipanel { background: #fff; border-radius: 16px; padding: 32px; margin-top: 32px; }
.iwrap .ih2 { margin: 0 0 8px; font: 600 24px/36px var(--sans); }
.iwrap .idesc { margin: 0 0 24px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); max-width: 820px; }
.iwrap .igrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.iwrap .ibtn { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 20px; border-radius: 4px;
background: var(--dark); color: #fff; font: 500 14px/21px 'Noto Sans JP', sans-serif; }
.iwrap .ibtn-line { background: #fff; color: var(--ink); border: 1px solid var(--dark); }
.inote { margin-top: 48px; padding: 20px 24px; border-radius: 8px; background: #ececec; }
.inote p { margin: 0 0 6px; font: 400 13px/1.8 var(--sans); color: var(--ink-2); }
.inote p:last-child { margin: 0; }
.inote a { text-decoration: underline; }
@media (max-width: 1023px) { .iwrap { padding: 64px 24px; } .iwrap .igrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 599px) {
.iwrap { padding: 56px 16px 48px; }
.iwrap .igrid { grid-template-columns: 1fr; gap: 16px; }
.iwrap .ipanel { padding: 24px 16px; border-radius: 12px; }
.iwrap .ih2 { font-size: 20px; line-height: 30px; }
}
</style>
src/components/issues/IssuesNav.astro
課題地図の中を移動する帯。
---
// 課題地図の中を移動する帯(どのページでも同じ並び)
const { current } = Astro.props as { current?: string };
const items = [
['/issues', '課題一覧', 'top'],
['/issues/map', '課題マップ', 'map'],
['/issues#regulations', '規制・制度', 'reg'],
['/issues/sources', '出典・データ', 'sources'],
['/issues/updates', '更新履歴', 'updates'],
];
---
<nav class="inav" aria-label="課題地図の中のページ">
<a class="inav-home" href="/issues">課題地図</a>
<div class="inav-links">
{items.map(([href, label, key]) => <a href={href} aria-current={current === key ? 'page' : undefined}>{label}</a>)}
</div>
</nav>
<style>
.inav { max-width: 1280px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
padding: 12px 0; border-bottom: 1px solid #ddd; }
.inav-home { font: 600 14px/20px var(--sans); }
.inav-links { display: flex; gap: 4px; flex-wrap: wrap; }
.inav-links a { padding: 4px 10px; border-radius: 4px; font: 400 13px/20px var(--sans); color: var(--ink-2); }
.inav-links a:hover { background: #ececec; color: var(--ink); }
.inav-links a[aria-current] { background: var(--dark); color: #fff; }
</style>
src/components/issues/Score.astro
緊急度・深刻度の5段階を黒い四角の数で見せる部品。
---
// 緊急度・深刻度の5段階を、黒い四角の数で見せる
const { label, value } = Astro.props as { label: string; value: number };
---
<span class="score" aria-label={`${label} ${value}(5段階)`}>
<span class="score-k">{label}</span>
<span class="score-bar" aria-hidden="true">{[1, 2, 3, 4, 5].map((i) => <i class={i <= value ? 'on' : ''} />)}</span>
</span>
<style>
.score { display: inline-flex; align-items: center; gap: 6px; font: 400 11px/16px var(--sans); color: var(--ink-3); white-space: nowrap; }
.score-bar { display: inline-flex; gap: 2px; }
.score-bar i { width: 8px; height: 8px; border-radius: 1px; background: #e3e3e3; }
.score-bar i.on { background: var(--ink); }
</style>
src/components/issues/IssueCard.astro
課題カード。一覧・分類・つながっている課題で共通。
---
// 課題カード(一覧・分類・関連課題で共通)
import Score from './Score.astro';
import { newsFor, jdate, type Issue } from '../../lib/issues';
const { issue, showCat = true } = Astro.props as { issue: Issue; showCat?: boolean };
const d = issue.data;
const latest = newsFor(d.slug)[0];
---
<a class="icard" href={`/issues/${d.slug}`}>
<div class="icard-scores"><Score label="緊急度" value={d.urgency} /><Score label="深刻度" value={d.severity} /></div>
<p class="icard-title">{d.title}</p>
<p class="icard-sum">{d.summary}</p>
<div class="icard-tags">
{showCat && <span class="tag">{d.category}</span>}
<span class="tag">{d.time_horizon}</span>
<span class="tag">{d.layer}で解く</span>
</div>
{latest && <p class="icard-news"><span>最近の動き {jdate(latest.date)}</span>{latest.title}</p>}
</a>
<style>
.icard { display: flex; flex-direction: column; gap: 10px; height: 100%; padding: 20px; background: #fff; border-radius: 8px;
box-shadow: 0 8px 16px rgba(0,0,0,.08); transition: box-shadow .15s; }
.icard:hover { box-shadow: 0 8px 24px rgba(0,0,0,.16); }
.icard:hover .icard-title { text-decoration: underline; }
.icard-scores { display: flex; gap: 16px; flex-wrap: wrap; }
.icard-title { margin: 0; font: 600 17px/1.5 var(--sans); }
.icard-sum { margin: 0; font: 400 14px/1.75 var(--sans); color: var(--ink-2); }
.icard-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 4px; }
.icard-news { margin: 0; padding-top: 10px; border-top: 1px solid #eee; font: 400 13px/1.6 var(--sans); color: var(--ink-2); }
.icard-news span { display: block; font-size: 11px; color: var(--ink-3); }
</style>
src/components/issues/NewsList.astro
最近の動きの一覧。
---
// 最近の動き(週1回の自動調査が news.json に足したもの)
import { allIssues, jdate, type News } from '../../lib/issues';
const { items, showIssues = true } = Astro.props as { items: News[]; showIssues?: boolean };
const titles = Object.fromEntries((await allIssues()).map((i) => [i.data.slug, i.data.title]));
---
<ol class="news">
{items.map((n) => (
<li class="news-item">
<div class="news-meta"><span class="date">{jdate(n.date)}</span><span class="tag">{n.kind}</span></div>
<p class="news-title"><a href={n.url} target="_blank" rel="noopener">{n.title}<i class="ms">open_in_new</i></a></p>
<p class="news-sum">{n.summary}</p>
<p class="news-src">出典:{n.source}</p>
{showIssues && n.issues.length > 0 && (
<p class="news-rel">関連課題:{n.issues.map((s, i) => <>{i > 0 && '・'}<a href={`/issues/${s}`}>{titles[s] ?? s}</a></>)}</p>
)}
</li>
))}
</ol>
<style>
.news { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.news-item { padding: 16px 0; border-bottom: 1px solid #eee; }
.news-item:first-child { padding-top: 0; }
.news-meta { display: flex; gap: 10px; align-items: center; }
.news-title { margin: 6px 0 4px; font: 600 16px/1.6 var(--sans); }
.news-title a { text-decoration: underline; text-underline-offset: 3px; }
.news-title .ms { margin-left: 4px; font-size: 14px; vertical-align: -2px; color: var(--ink-3); }
.news-sum { margin: 0; font: 400 14px/1.8 var(--sans); color: var(--ink-2); }
.news-src, .news-rel { margin: 4px 0 0; font: 400 12px/1.7 var(--sans); color: var(--ink-3); }
.news-rel a { color: var(--ink-2); text-decoration: underline; }
</style>
src/pages/issues.astro
課題地図のトップ(/issues)。
---
// 課題地図のトップ(/issues)
import IssuesLayout from '../layouts/IssuesLayout.astro';
import IssueCard from '../components/issues/IssueCard.astro';
import NewsList from '../components/issues/NewsList.astro';
import { allIssues, bySeverity, CATEGORIES, REGULATIONS, SOURCES, NEWS, LAST_CHECKED, ISSUES_TITLE, jdate } from '../lib/issues';
import { SITE, PUBLISHER } from '../lib/seo';
const issues = await allIssues();
const title = Object.fromEntries(issues.map((i) => [i.data.slug, i.data.title]));
const nSources = SOURCES.reduce((n, g) => n + g.items.length, 0);
const description = '放置すればフィジカルAI・ロボティクスの社会実装そのものが頓挫する構造的な課題を、技術・制度・体験の3つの視点を横断して抽出・整理した課題地図。週1回、最近の動きを出典つきで更新しています。';
const jsonLd = {
'@context': 'https://schema.org', '@type': 'CollectionPage', name: ISSUES_TITLE, description,
url: SITE + '/issues', inLanguage: 'ja', publisher: PUBLISHER,
hasPart: issues.map((i) => ({ '@type': 'Article', headline: i.data.title, url: `${SITE}/issues/${i.data.slug}` })),
};
---
<IssuesLayout title={`${ISSUES_TITLE} | Pixels to Motors`} description={description} path="/issues" current="top" jsonLd={jsonLd}>
<section class="ihero">
<p class="ihero-k">課題地図</p>
<h1 class="ihero-h">{ISSUES_TITLE}</h1>
<p class="ihero-lead">個別のデモ動画や製品発表ではなく、放置すればフィジカルAIの社会実装そのものが頓挫する——いま向き合うべき構造的な課題を、技術・制度・体験の3つの視点を横断して抽出・整理しました。</p>
<ul class="ihero-stats">
<li><b>{issues.length}</b>課題</li>
<li><b>{CATEGORIES.length}</b>分類</li>
<li><b>{REGULATIONS.length}</b>規制・制度</li>
<li><b>{nSources}</b>出典</li>
<li><b>{NEWS.length}</b>件の最近の動き</li>
</ul>
<div class="ihero-cta">
<a class="ibtn" href="#all">課題一覧を見る<i class="ms">arrow_downward</i></a>
<a class="ibtn ibtn-line" href="/issues/map">関連でたどる課題マップ<i class="ms">arrow_forward</i></a>
</div>
<p class="ihero-upd">{LAST_CHECKED ? `最後に最近の動きを調べた日:${jdate(LAST_CHECKED)}(週1回)` : '最近の動きは週1回調べて更新します'}</p>
</section>
<section class="ipanel" id="news">
<h2 class="ih2">最近の動き</h2>
<p class="idesc">各課題に関わる規制・研究・事業の新しい動きを、週1回、出典つきで足しています。要約は出典の言い回しを使わずに書いています。</p>
{NEWS.length > 0
? <><NewsList items={NEWS.slice(0, 6)} /><p class="more"><a class="ibtn ibtn-line" href="/issues/updates">すべての動きと更新履歴<i class="ms">arrow_forward</i></a></p></>
: <p class="empty">最初の調査の結果を待っています。見つかった動きはここに載ります。</p>}
</section>
<section class="ipanel" id="categories">
<h2 class="ih2">9つの分類</h2>
<p class="idesc">社会実装を一つのシステムとして見て、課題を9つの分類に分けています。</p>
<div class="igrid">
{CATEGORIES.map((c) => {
const list = issues.filter((i) => i.data.category_slug === c.slug).sort(bySeverity);
return (
<a class="cbox" href={`/issues/category/${c.slug}`}>
<h3>{c.name}<span>{list.length}件</span></h3>
<p>{c.desc}</p>
<ul>{list.map((i) => <li class={i.data.urgency >= 4 && i.data.severity >= 4 ? 'hot' : ''}><span>{i.data.title}</span><small>緊{i.data.urgency}・深{i.data.severity}</small></li>)}</ul>
</a>
);
})}
</div>
</section>
<section class="ipanel" id="regulations">
<h2 class="ih2">社会実装で論点となる規制・制度</h2>
<p class="idesc">構造課題の背景で、改正・整備の是非が議論されている主な規制・制度を、中立に整理した索引です。詳しい論点は関連する課題のページをご覧ください。</p>
<div class="regs">
{REGULATIONS.map((r) => (
<div class="reg">
<div class="reg-top"><h3>{r.name}</h3><span class="reg-st">{r.status}{r.needs_check && <em>要確認</em>}</span></div>
<p>{r.desc}</p>
<p class="reg-meta"><span>関連課題</span>{r.issues.map((s) => <a href={`/issues/${s}`}>{title[s] ?? s}</a>)}</p>
<p class="reg-meta"><span>出典</span>{r.source}</p>
</div>
))}
</div>
</section>
<section class="ipanel" id="all">
<h2 class="ih2">すべての課題({issues.length}件)</h2>
<p class="idesc">深刻度・緊急度の高い順に並べています。カードの点数はどちらも5段階です。</p>
<div class="igrid">{issues.map((i) => <IssueCard issue={i} />)}</div>
</section>
<section class="ipanel" id="about">
<h2 class="ih2">この課題地図について</h2>
<ul class="about">
<li><b>生成AIによる制作・評価版です。</b>本文・図・ページのもとになるデータは生成AIが作成し、人が点検しています。誤りや古い情報を含む可能性があります。</li>
<li><b>特定のメーカー・エコシステムの立場はとりません。</b>「人とロボットが共存する社会の持続可能性と体験の質を、長期にわたって維持・向上できるか」を判断軸に課題を選び、緊急度と深刻度で優先度を示しています。賛否が分かれる論点は結論を断定せず、対立軸と出典を併記します。</li>
<li><b>中立を志向しますが、完全な無偏りは保証できません。</b>事実・解釈・提案を分け、トレードオフや反対論も併記するよう努めています。</li>
<li><b>週1回、自動で調べています。</b>最近の動き(出典つき)はそのまま追加し、点数・規制の状態・本文の書き換えは人が確認してから反映します。経緯は<a href="/issues/updates">更新履歴</a>に残しています。</li>
</ul>
</section>
</IssuesLayout>
<style>
.ihero { padding: 48px 0 8px; }
.ihero-k { margin: 0; font: 500 13px/20px var(--sans); color: var(--ink-3); }
.ihero-h { margin: 8px 0 16px; font: 600 36px/1.4 var(--sans); max-width: 900px; }
.ihero-lead { margin: 0; font: 400 17px/2 var(--sans); max-width: 820px; }
.ihero-stats { list-style: none; display: flex; flex-wrap: wrap; gap: 8px 28px; margin: 24px 0 0; padding: 0; font: 400 13px/20px var(--sans); color: var(--ink-2); }
.ihero-stats b { font: 500 24px/28px var(--sans); color: var(--ink); margin-right: 4px; }
.ihero-cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
.ihero-upd { margin: 16px 0 0; font: 400 12px/18px var(--sans); color: var(--ink-3); }
.more { margin: 24px 0 0; }
.empty { margin: 0; padding: 20px; border-radius: 8px; background: #f5f5f5; font: 400 14px/1.8 var(--sans); color: var(--ink-3); }
.cbox { display: flex; flex-direction: column; padding: 20px; border: 1px solid #e3e3e3; border-radius: 8px; transition: border-color .15s; }
.cbox:hover { border-color: var(--ink); }
.cbox h3 { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 6px; font: 600 16px/1.5 var(--sans); }
.cbox h3 span { font: 400 12px/24px var(--sans); color: var(--ink-3); white-space: nowrap; }
.cbox p { margin: 0 0 12px; font: 400 13px/1.7 var(--sans); color: var(--ink-2); }
.cbox ul { list-style: none; margin: auto 0 0; padding: 0; border-top: 1px solid #eee; }
.cbox li { display: flex; justify-content: space-between; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f2f2f2; font: 400 13px/1.6 var(--sans); color: var(--ink-2); }
.cbox li.hot span { color: var(--ink); font-weight: 500; }
.cbox li small { font-size: 11px; color: var(--ink-3); white-space: nowrap; }
.regs { display: flex; flex-direction: column; gap: 16px; }
.reg { padding: 20px; border: 1px solid #e3e3e3; border-radius: 8px; }
.reg-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px 16px; flex-wrap: wrap; }
.reg h3 { margin: 0; font: 600 16px/1.6 var(--sans); }
.reg-st { font: 400 12px/1.6 var(--sans); padding: 2px 8px; border-radius: 4px; background: #f0f0f0; color: var(--ink-2); }
.reg-st em { font-style: normal; margin-left: 6px; color: var(--ink); font-weight: 600; }
.reg p { margin: 8px 0 0; font: 400 14px/1.8 var(--sans); color: var(--ink-2); }
.reg .reg-meta { font-size: 12px; display: flex; flex-wrap: wrap; gap: 4px 12px; }
.reg-meta span { color: var(--ink-3); }
.reg-meta a { text-decoration: underline; color: var(--ink); }
.about { margin: 0; padding-left: 1.2em; }
.about li { margin: 0 0 12px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); }
.about b { color: var(--ink); font-weight: 600; }
.about a { text-decoration: underline; }
@media (max-width: 599px) {
.ihero { padding-top: 32px; }
.ihero-h { font-size: 26px; }
.ihero-lead { font-size: 15px; }
}
</style>
src/pages/issues/[slug].astro
課題ごとの詳しいページ(/issues/{課題})。
---
// 課題の詳細ページ(/issues/{slug})
import { render } from 'astro:content';
import IssuesLayout from '../../layouts/IssuesLayout.astro';
import IssueCard from '../../components/issues/IssueCard.astro';
import NewsList from '../../components/issues/NewsList.astro';
import Score from '../../components/issues/Score.astro';
import { allIssues, newsFor, neighbors, REGULATIONS, LAST_CHECKED, STATUS_LABEL, ISSUES_TITLE, jdate } from '../../lib/issues';
import { SITE, AUTHOR, PUBLISHER } from '../../lib/seo';
export async function getStaticPaths() {
const issues = await allIssues();
return issues.map((issue) => ({ params: { slug: issue.data.slug }, props: { issue, issues } }));
}
const { issue, issues } = Astro.props;
const d = issue.data;
const { Content } = await render(issue);
const news = newsFor(d.slug);
const bySlug = Object.fromEntries(issues.map((i) => [i.data.slug, i]));
const related = neighbors(d.slug).map((s) => bySlug[s]).filter(Boolean);
const regs = REGULATIONS.filter((r) => r.issues.includes(d.slug));
const path = `/issues/${d.slug}`;
const modified = [d.last_verified.toISOString().slice(0, 10), news[0]?.added].filter(Boolean).sort().pop();
const jsonLd = [
{
'@context': 'https://schema.org', '@type': 'Article', headline: d.title, description: d.summary,
url: SITE + path, inLanguage: 'ja', author: AUTHOR, publisher: PUBLISHER,
dateModified: modified, keywords: d.tags.join(','),
isPartOf: { '@type': 'CollectionPage', name: ISSUES_TITLE, url: SITE + '/issues' },
},
{
'@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Pixels to Motors', item: SITE + '/' },
{ '@type': 'ListItem', position: 2, name: '課題地図', item: SITE + '/issues' },
{ '@type': 'ListItem', position: 3, name: d.category, item: `${SITE}/issues/category/${d.category_slug}` },
{ '@type': 'ListItem', position: 4, name: d.title, item: SITE + path },
],
},
];
---
<IssuesLayout title={`${d.title} | フィジカルAIの課題地図 | Pixels to Motors`} description={`${d.summary} 緊急度${d.urgency}・深刻度${d.severity}。原因構造・解決の方向性・争点・最近の動きを出典つきで整理。`}
path={path} ogType="article" modified={modified} jsonLd={jsonLd}>
<article class="iss">
<nav class="crumbs" aria-label="現在地"><a href="/issues">課題地図</a><span>›</span><a href={`/issues/category/${d.category_slug}`}>{d.category}</a></nav>
<h1 class="iss-h">{d.title}</h1>
<p class="iss-sum">{d.summary}</p>
<div class="iss-card">
<div class="iss-scores"><Score label="緊急度" value={d.urgency} /><Score label="深刻度" value={d.severity} /></div>
<dl class="iss-meta">
<div><dt>分類</dt><dd><a href={`/issues/category/${d.category_slug}`}>{d.category}</a></dd></div>
<div><dt>時間軸</dt><dd>{d.time_horizon}</dd></div>
<div><dt>主に何で解くか</dt><dd>{d.layer}</dd></div>
<div><dt>関わる主体</dt><dd>{d.actors.join('・')}</dd></div>
{regs.length > 0 && <div><dt>関連する規制・制度</dt><dd>{regs.map((r, i) => <>{i > 0 && '/'}<a href="/issues#regulations">{r.name}</a></>)}</dd></div>}
<div><dt>本文の状態</dt><dd>{STATUS_LABEL[d.status] ?? d.status}({jdate(d.last_verified)} 時点)</dd></div>
{LAST_CHECKED && <div><dt>最近の動きを調べた日</dt><dd>{jdate(LAST_CHECKED)}</dd></div>}
</dl>
</div>
<section class="iss-news" id="news">
<h2>最近の動き</h2>
{news.length > 0
? <NewsList items={news.slice(0, 8)} showIssues={false} />
: <p class="empty">この課題に関わる新しい動きは、まだ載っていません。週1回調べています。</p>}
{news.length > 8 && <p class="more"><a href="/issues/updates">ほかの動きも見る</a></p>}
</section>
<div class="richText ibody"><Content /></div>
{related.length > 0 && (
<section class="iss-rel">
<h2>つながっている課題</h2>
<div class="igrid rel-grid">{related.map((i) => <IssueCard issue={i} />)}</div>
<p class="more"><a class="ibtn ibtn-line" href="/issues/map">課題マップでつながりをたどる<i class="ms">arrow_forward</i></a></p>
</section>
)}
</article>
</IssuesLayout>
<style is:global>
.iss { max-width: 760px; margin: 0 auto; padding-top: 32px; }
.iss .crumbs { display: flex; flex-wrap: wrap; gap: 4px 8px; font: 400 13px/20px var(--sans); color: var(--ink-3); }
.iss .crumbs a { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px; }
.iss-h { margin: 12px 0 12px; font: 600 30px/1.5 var(--sans); }
.iss-sum { margin: 0 0 24px; font: 400 17px/1.9 var(--sans); color: var(--ink-2); }
.iss-card { padding: 20px 24px; border-radius: 8px; background: #fff; box-shadow: 0 8px 16px rgba(0,0,0,.06); }
.iss-scores { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 12px; }
.iss-scores .score { font-size: 13px; }
.iss-scores .score-bar i { width: 12px; height: 12px; }
.iss-meta { margin: 0; display: grid; grid-template-columns: 1fr; gap: 6px; }
.iss-meta div { display: grid; grid-template-columns: 10em 1fr; gap: 8px; font: 400 13px/1.7 var(--sans); }
.iss-meta dt { color: var(--ink-3); }
.iss-meta dd { margin: 0; color: var(--ink); }
.iss-meta a { text-decoration: underline; }
.iss-news { margin: 32px 0 8px; padding: 24px; border-radius: 8px; background: #fff; border: 1px solid #e3e3e3; }
.iss-news h2, .iss-rel h2 { margin: 0 0 16px; font: 600 20px/30px var(--sans); }
.iss .empty { margin: 0; font: 400 14px/1.8 var(--sans); color: var(--ink-3); }
.iss .more { margin: 16px 0 0; font: 400 14px/1.8 var(--sans); }
.iss .more a:not(.ibtn) { text-decoration: underline; }
.iss-rel { margin-top: 56px; padding-top: 32px; border-top: 1px solid #ddd; }
.iss-rel .rel-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* 本文(記事ページと同じ読み心地にそろえる) */
.ibody { margin-top: 16px; }
.ibody p { font-size: 17px; line-height: 2; margin: 8px 0 12px; overflow-wrap: anywhere; }
.ibody > p:first-child { margin-top: 32px; padding: 16px 20px; border-left: 3px solid var(--ink); background: #fff; }
.ibody h2 { font-size: 22px; line-height: 34px; font-weight: 600; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid #ddd; }
.ibody h3 { font-size: 18px; line-height: 28px; font-weight: 600; margin: 32px 0 12px; padding-left: 12px; border-left: 2px solid var(--ink); }
.ibody strong { font-weight: 600; }
.ibody a { text-decoration: underline; text-underline-offset: 2px; }
.ibody ul, .ibody ol { padding-left: 1.4em; }
.ibody li { font-size: 16px; line-height: 1.95; margin: 8px 0; }
.ibody table { border-collapse: collapse; width: 100%; font-size: 14px; margin: 12px 0; }
.ibody th, .ibody td { border: 1px solid #d2d2d2; vertical-align: top; text-align: left; }
.ibody th { background: #e9e9e9; padding: 12px; font: 600 14px/1.6 var(--sans); }
.ibody td { background: #fff; padding: 12px; font: 400 14px/1.7 var(--sans); }
.ibody, .ibody li, .ibody td, .ibody a { overflow-wrap: anywhere; word-break: break-word; }
@media (max-width: 767px) {
.iss-h { font-size: 24px; }
.iss-meta div { grid-template-columns: 1fr; gap: 0; }
.iss-news { padding: 20px 16px; }
.iss-rel .rel-grid { grid-template-columns: 1fr; }
.ibody table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
}
</style>
src/pages/issues/category/[cat].astro
分類ごとの一覧(/issues/category/{分類})。
---
// 分類ごとの課題一覧(/issues/category/{分類})
import IssuesLayout from '../../../layouts/IssuesLayout.astro';
import IssueCard from '../../../components/issues/IssueCard.astro';
import NewsList from '../../../components/issues/NewsList.astro';
import { allIssues, bySeverity, CATEGORIES, NEWS } from '../../../lib/issues';
export function getStaticPaths() {
return CATEGORIES.map((c) => ({ params: { cat: c.slug }, props: { cat: c } }));
}
const { cat } = Astro.props;
const list = (await allIssues()).filter((i) => i.data.category_slug === cat.slug).sort(bySeverity);
const slugs = new Set(list.map((i) => i.data.slug));
const news = NEWS.filter((n) => n.issues.some((s) => slugs.has(s))).slice(0, 6);
---
<IssuesLayout title={`${cat.name}の課題 | フィジカルAIの課題地図 | Pixels to Motors`} description={`フィジカルAIの社会実装で「${cat.name}」に分類した${list.length}件の課題。${cat.desc}`} path={`/issues/category/${cat.slug}`}>
<section class="chead">
<nav class="crumbs" aria-label="現在地"><a href="/issues">課題地図</a><span>›</span><a href="/issues#categories">9つの分類</a></nav>
<h1>{cat.name}<span>{list.length}件</span></h1>
<p>{cat.desc}</p>
<div class="cnav">{CATEGORIES.map((c) => <a href={`/issues/category/${c.slug}`} aria-current={c.slug === cat.slug ? 'page' : undefined}>{c.name}</a>)}</div>
</section>
<section class="ipanel">
<div class="igrid">{list.map((i) => <IssueCard issue={i} showCat={false} />)}</div>
</section>
{news.length > 0 && (
<section class="ipanel">
<h2 class="ih2">この分類の最近の動き</h2>
<NewsList items={news} />
</section>
)}
</IssuesLayout>
<style>
.chead { padding: 32px 0 0; }
.crumbs { display: flex; gap: 8px; font: 400 13px/20px var(--sans); color: var(--ink-3); }
.crumbs a { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px; }
h1 { display: flex; align-items: baseline; gap: 12px; margin: 12px 0 8px; font: 600 30px/1.5 var(--sans); }
h1 span { font: 400 14px/1 var(--sans); color: var(--ink-3); }
.chead p { margin: 0; font: 400 16px/1.9 var(--sans); color: var(--ink-2); }
.cnav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 20px; }
.cnav a { padding: 4px 10px; border: 1px solid #ddd; border-radius: 4px; font: 400 12px/18px var(--sans); color: var(--ink-2); background: #fff; }
.cnav a:hover { border-color: var(--ink); }
.cnav a[aria-current] { background: var(--dark); border-color: var(--dark); color: #fff; }
@media (max-width: 599px) { h1 { font-size: 24px; } }
</style>
src/pages/issues/map.astro
課題マップ(/issues/map)。5回目、いま公開している版。
---
// 課題マップ(/issues/map)。点=課題(大きさ=緊急度、濃さ=深刻度)、線=関連。点の位置と線は meta.json の map
import IssuesLayout from '../../layouts/IssuesLayout.astro';
import { allIssues, byUrgency, neighbors, CATEGORIES, MAP } from '../../lib/issues';
const issues = (await allIssues()).slice().sort(byUrgency);
const bySlug = Object.fromEntries(issues.map((i) => [i.data.slug, i]));
const SHADE = ['#c4c4c4', '#a6a6a6', '#7d7d7d', '#474747', '#1f1f1f']; // 深刻度 1〜5
const pos = (s: string) => MAP.positions[s] ?? { x: MAP.width / 2, y: MAP.height / 2 };
const short = (t: string) => t.replace(/([^)]*)/g, '');
// 点=四角(大きさ=緊急度、濃さ=深刻度)
const size = (u: number) => 14 + u * 3.4; // 旧マップと同じ大きさ(緊急度 1〜5 → 17.4〜31)
---
<IssuesLayout title="課題マップ | フィジカルAIの課題地図 | Pixels to Motors" description={`フィジカルAIの社会実装を止める${issues.length}の課題を、関連する課題どうしを線でつないだ地図。気になる課題からつながりをたどれます。`} path="/issues/map" current="map">
<section class="mhead">
<h1>課題マップ</h1>
<p>{CATEGORIES.length}分類・{issues.length}件の課題を、関連する課題どうしでつないでいます({MAP.edges.length}本)。点=課題(大きいほど緊急度が高い、濃いほど深刻度が高い)、線=関連。点に触れると関連が強調され、押すと詳しいページへ移ります。分類で絞り込めます。</p>
</section>
<section class="ipanel">
<div class="legend" role="group" aria-label="分類で絞り込む">
<button class="on" data-cat="all">すべて表示</button>
{CATEGORIES.map((c) => <button data-cat={c.slug}>{c.name}</button>)}
</div>
<div class="mapbox">
<svg viewBox={`0 0 ${MAP.width} ${MAP.height}`} role="img" aria-label={`${issues.length}課題の関連の地図`}>
<defs>
<pattern id="mgrid" width="24" height="24" patternUnits="userSpaceOnUse"><circle cx="12" cy="12" r="1" fill="#e2e2e2" /></pattern>
</defs>
<rect class="mbg" x="0" y="0" width={MAP.width} height={MAP.height} fill="url(#mgrid)" />
<g class="edges">
{MAP.edges.map(([a, b]) => <line class="edge" data-a={a} data-b={b} x1={pos(a).x} y1={pos(a).y} x2={pos(b).x} y2={pos(b).y} />)}
</g>
<g class="nodes">
{issues.map((i) => {
const p = pos(i.data.slug); const sz = size(i.data.urgency);
return (
<a href={`/issues/${i.data.slug}`} class="node" data-slug={i.data.slug} data-cat={i.data.category_slug}
data-title={i.data.title} data-catname={i.data.category} data-urg={i.data.urgency} data-sev={i.data.severity}
aria-label={`${i.data.title}(緊急度${i.data.urgency}・深刻度${i.data.severity})`}>
<rect class="hit" x={p.x - sz / 2 - 8} y={p.y - sz / 2 - 8} width={sz + 16} height={sz + 16} />
<rect class="sq" x={p.x - sz / 2} y={p.y - sz / 2} width={sz} height={sz} rx="2" fill={SHADE[i.data.severity - 1]} />
<text class="nlabel" x={p.x} y={p.y + sz / 2 + 16} text-anchor="middle">{short(i.data.title)}</text>
</a>
);
})}
</g>
</svg>
<div class="tip" id="tip" hidden></div>
</div>
<div class="key" aria-label="地図の読み方">
<span><svg width="46" height="22" aria-hidden="true"><rect x="1" y="7" width="8" height="8" rx="1.5" fill="#474747"/><rect x="14" y="4" width="14" height="14" rx="2" fill="#474747"/><rect x="32" y="2" width="18" height="18" rx="2" fill="#474747"/></svg>大きいほど緊急度が高い</span>
<span><svg width="52" height="22" aria-hidden="true">{SHADE.map((c, k) => <rect x={k * 10 + 1} y="6" width="8" height="10" rx="1.5" fill={c} />)}</svg>濃いほど深刻度が高い</span>
<span><svg width="30" height="22" aria-hidden="true"><line x1="2" y1="16" x2="28" y2="6" stroke="#242424" stroke-width="1.2"/></svg>関連する課題</span>
</div>
</section>
<section class="ipanel">
<h2 class="ih2">緊急度の高い順に、つながりをたどる</h2>
<ol class="mlist">
{issues.map((i) => (
<li>
<div class="mrow"><a href={`/issues/${i.data.slug}`}>{i.data.title}</a><small>{i.data.category}・緊{i.data.urgency}・深{i.data.severity}</small></div>
<div class="chips">{neighbors(i.data.slug).map((s) => bySlug[s] && <a href={`/issues/${s}`}>{bySlug[s].data.title}</a>)}</div>
</li>
))}
</ol>
</section>
</IssuesLayout>
<script>
const svg = document.querySelector<SVGSVGElement>('.mapbox svg')!;
const nodes = [...document.querySelectorAll<SVGAElement>('.node')];
const edges = [...document.querySelectorAll<SVGLineElement>('.edge')];
const box = document.querySelector<HTMLElement>('.mapbox')!;
const tip = document.getElementById('tip')!;
const bySlug = new Map(nodes.map((n) => [n.dataset.slug!, n]));
const adj = new Map<string, string[]>();
for (const e of edges) {
const a = e.dataset.a!, b = e.dataset.b!;
adj.set(a, [...(adj.get(a) ?? []), b]);
adj.set(b, [...(adj.get(b) ?? []), a]);
}
let filter = 'all';
const base = () => {
for (const n of nodes) {
const inCat = filter === 'all' || n.dataset.cat === filter;
n.classList.toggle('dim', !inCat);
n.classList.toggle('show', filter !== 'all' && inCat);
n.classList.remove('near', 'cur');
}
for (const e of edges) {
e.classList.remove('lit');
const inCat = bySlug.get(e.dataset.a!)?.dataset.cat === filter && bySlug.get(e.dataset.b!)?.dataset.cat === filter;
e.classList.toggle('dim', filter !== 'all' && !inCat);
e.classList.toggle('incat', filter !== 'all' && inCat);
}
};
const bars = (v: number) => '■'.repeat(v) + '□'.repeat(5 - v);
const focus = (n: SVGAElement) => {
const id = n.dataset.slug!;
const near = new Set(adj.get(id) ?? []);
for (const m of nodes) {
const s = m.dataset.slug!;
m.classList.toggle('dim', s !== id && !near.has(s));
m.classList.toggle('near', near.has(s));
m.classList.toggle('show', s === id || near.has(s));
m.classList.toggle('cur', s === id);
}
for (const e of edges) {
const lit = e.dataset.a === id || e.dataset.b === id;
e.classList.toggle('lit', lit);
e.classList.toggle('dim', !lit);
e.classList.remove('incat');
}
tip.replaceChildren();
const c = document.createElement('span'); c.className = 'tip-c'; c.textContent = n.dataset.catname!;
const t = document.createElement('strong'); t.textContent = n.dataset.title!;
const sc = document.createElement('span'); sc.className = 'tip-s';
sc.textContent = `緊急度 ${bars(+n.dataset.urg!)} 深刻度 ${bars(+n.dataset.sev!)}`;
const nb = document.createElement('span'); nb.className = 'tip-c'; nb.textContent = `${near.size}件の課題とつながる`;
tip.append(c, t, sc, nb);
tip.hidden = false;
const br = box.getBoundingClientRect(), nr = n.querySelector('.sq')!.getBoundingClientRect();
let left = nr.right - br.left + 14;
if (left + 270 > br.width) left = nr.left - br.left - 284;
tip.style.left = Math.max(8, left) + 'px';
tip.style.top = Math.max(4, nr.top - br.top - 10) + 'px';
};
const blur = () => { tip.hidden = true; base(); };
for (const n of nodes) {
n.addEventListener('mouseenter', () => focus(n));
n.addEventListener('focus', () => focus(n));
n.addEventListener('mouseleave', blur);
n.addEventListener('blur', blur);
}
for (const b of document.querySelectorAll<HTMLButtonElement>('.legend button')) {
b.addEventListener('click', () => {
filter = b.dataset.cat!;
document.querySelectorAll('.legend button').forEach((x) => x.classList.toggle('on', x === b));
base();
});
}
</script>
<style>
.mhead { padding: 32px 0 0; }
.mhead h1 { margin: 0 0 8px; font: 600 30px/1.5 var(--sans); }
.mhead p { margin: 0; max-width: 860px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); }
.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.legend button { padding: 4px 10px; border: 1px solid #ddd; border-radius: 4px; background: #fff; font: 400 12px/18px var(--sans); color: var(--ink-2); cursor: pointer; }
.legend button:hover { border-color: var(--ink); }
.legend button.on { background: var(--dark); border-color: var(--dark); color: #fff; }
.mapbox { position: relative; border: 1px solid #eee; border-radius: 8px; background: #fcfcfc; overflow: hidden; }
.mapbox svg { display: block; width: 100%; height: auto; font-family: var(--sans); }
.edge { fill: none; stroke: var(--ink); stroke-opacity: .12; stroke-width: 1; transition: stroke-opacity .15s, stroke-width .15s; }
.edge.lit { stroke-opacity: .85; stroke-width: 1.8; }
.edge.dim { stroke-opacity: .035; }
.node { cursor: pointer; transition: opacity .15s; outline: none; }
.node .hit { fill: transparent; }
.node.cur .sq, .node:focus-visible .sq { stroke: var(--ink); stroke-width: 2; }
.node.dim { opacity: .14; }
/* 課題名は、触れた課題とつながる課題、または絞り込んだ分類の課題にだけ出す。線の上でも読めるよう地の色で縁取る */
.nlabel { font-size: 13px; fill: #555; opacity: 0; pointer-events: none; transition: opacity .15s;
paint-order: stroke; stroke: #fcfcfc; stroke-width: 5px; stroke-linejoin: round; }
.node.show .nlabel { opacity: 1; }
/* 触れている課題の名前は吹き出しに出すので、点の下には出さない */
.node.cur .nlabel { opacity: 0; }
.edge.incat { stroke-opacity: .5; stroke-width: 1.4; }
.tip { position: absolute; z-index: 2; width: 270px; padding: 12px 14px; border-radius: 8px; background: var(--dark); color: #fff;
box-shadow: 0 8px 24px rgba(0,0,0,.18); pointer-events: none; display: flex; flex-direction: column; gap: 4px; }
.tip[hidden] { display: none; }
.tip :global(strong) { font: 600 14px/1.5 var(--sans); }
.tip :global(.tip-c) { font: 400 11px/1.6 var(--sans); color: #a8a8a8; }
.tip :global(.tip-s) { font: 400 12px/1.6 var(--sans); letter-spacing: .02em; }
.key { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 12px; }
.key span { display: inline-flex; align-items: center; gap: 8px; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.mlist { list-style: none; margin: 0; padding: 0; }
.mlist li { padding: 12px 0; border-bottom: 1px solid #eee; }
.mrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.mrow a { font: 600 15px/1.6 var(--sans); text-decoration: underline; text-underline-offset: 2px; }
.mrow small { font: 400 12px/1.6 var(--sans); color: var(--ink-3); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chips a { padding: 2px 8px; border-radius: 4px; background: #f0f0f0; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.chips a:hover { background: #e2e2e2; }
@media (max-width: 599px) { .mhead h1 { font-size: 24px; } }
@media (prefers-reduced-motion: reduce) { .edge, .node, .nlabel { transition: none; } }
</style>
src/pages/issues/sources.astro
出典・データ集(/issues/sources)。
---
// 出典・データ集(/issues/sources)
import IssuesLayout from '../../layouts/IssuesLayout.astro';
import { allIssues, SOURCES } from '../../lib/issues';
const title = Object.fromEntries((await allIssues()).map((i) => [i.data.slug, i.data.title]));
const total = SOURCES.reduce((n, g) => n + g.items.length, 0);
---
<IssuesLayout title="出典・データ集 | フィジカルAIの課題地図 | Pixels to Motors" description={`フィジカルAIの課題地図が参照する一次情報・公的規格・出典の候補を、発行主体ごとにまとめた一覧(${total}件)。`} path="/issues/sources" current="sources">
<section class="shead">
<h1>出典・データ集</h1>
<p>すべての課題が参照する一次情報・公的規格・出典の候補を、発行主体ごとにまとめました({total}件)。更新で変わりうる数値を含む統計には印を付けています。</p>
<p class="snote">評価版のため、各項目の住所(URL)と正式な書誌情報は、一次出典を確認したあとで付けます。最近の動きに載せたものは、そちらに出典の住所を付けています。</p>
</section>
<section class="ipanel">
{SOURCES.map((g) => (
<section class="sgrp">
<h2>{g.org}<span>{g.items.length}</span></h2>
<ol>
{g.items.map((s) => (
<li>
<p class="st">{s.title}{s.volatile && <em>変わりうる数値</em>}</p>
<p class="sr">関連課題:{s.issues.map((x, i) => <>{i > 0 && '・'}<a href={`/issues/${x}`}>{title[x] ?? x}</a></>)}</p>
</li>
))}
</ol>
</section>
))}
</section>
</IssuesLayout>
<style>
.shead { padding: 32px 0 0; }
h1 { margin: 0 0 8px; font: 600 30px/1.5 var(--sans); }
.shead p { margin: 0 0 8px; max-width: 860px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); }
.shead .snote { font-size: 13px; color: var(--ink-3); }
.sgrp + .sgrp { margin-top: 32px; }
.sgrp h2 { display: flex; align-items: baseline; gap: 8px; margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid #ddd; font: 600 18px/1.6 var(--sans); }
.sgrp h2 span { font: 400 12px/1 var(--sans); color: var(--ink-3); }
ol { margin: 0; padding-left: 1.4em; }
li { padding: 8px 0; }
.st { margin: 0; font: 500 15px/1.7 var(--sans); }
.st em { margin-left: 8px; padding: 1px 6px; border-radius: 4px; background: #f0f0f0; font: normal 400 11px/16px var(--sans); color: var(--ink-2); }
.sr { margin: 2px 0 0; font: 400 12px/1.7 var(--sans); color: var(--ink-3); }
.sr a { color: var(--ink-2); text-decoration: underline; }
@media (max-width: 599px) { h1 { font-size: 24px; } }
</style>
src/pages/issues/updates.astro
最近の動きの全件と、調べた記録(/issues/updates)。
---
// 更新履歴(/issues/updates)。最近の動きの全件と、週1回の自動調査の記録
import IssuesLayout from '../../layouts/IssuesLayout.astro';
import NewsList from '../../components/issues/NewsList.astro';
import { NEWS, CHECKS, jdate } from '../../lib/issues';
---
<IssuesLayout title="最近の動きと更新履歴 | フィジカルAIの課題地図 | Pixels to Motors" description="フィジカルAIの課題地図に足した最近の動き(出典つき)と、週1回の調査・内容の見直しの記録。" path="/issues/updates" current="updates">
<section class="uhead">
<h1>最近の動きと更新履歴</h1>
<p>週1回、AIが各課題に関わる規制・研究・事業の新しい動きを調べています。出典のある動きは、ここと各課題のページにそのまま足します。点数・規制の状態・本文の書き換えが必要なときは、人が確認してから反映します。</p>
</section>
<section class="ipanel">
<h2 class="ih2">最近の動き({NEWS.length}件)</h2>
{NEWS.length > 0 ? <NewsList items={NEWS} /> : <p class="empty">最初の調査の結果を待っています。</p>}
</section>
<section class="ipanel">
<h2 class="ih2">調査の記録</h2>
{CHECKS.length > 0
? <table class="ulog"><thead><tr><th>調べた日</th><th>足した動き</th><th>メモ</th></tr></thead>
<tbody>{CHECKS.map((c) => <tr><td>{jdate(c.date)}</td><td>{c.news_added}件</td><td>{c.note ?? ''}</td></tr>)}</tbody></table>
: <p class="empty">まだ調査を回していません。</p>}
<p class="ufirst">2026/6/12:初版(36課題・評価版)を公開。2026/10/17:Pixels to Motors のサイトの中へ移し、週1回の更新を始めました。</p>
</section>
</IssuesLayout>
<style>
.uhead { padding: 32px 0 0; }
h1 { margin: 0 0 8px; font: 600 30px/1.5 var(--sans); }
.uhead p { margin: 0; max-width: 860px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); }
.empty { margin: 0; padding: 20px; border-radius: 8px; background: #f5f5f5; font: 400 14px/1.8 var(--sans); color: var(--ink-3); }
.ulog { width: 100%; border-collapse: collapse; font: 400 14px/1.7 var(--sans); }
.ulog th, .ulog td { padding: 10px 12px; border-bottom: 1px solid #eee; text-align: left; vertical-align: top; }
.ulog th { font-weight: 600; color: var(--ink-2); border-bottom-color: #ddd; }
.ulog td:first-child, .ulog td:nth-child(2) { white-space: nowrap; }
.ufirst { margin: 24px 0 0; font: 400 13px/1.8 var(--sans); color: var(--ink-3); }
@media (max-width: 599px) { h1 { font-size: 24px; } }
</style>
src/pages/updates.json.ts
ハコブくんが読む「新しい更新の一覧」(/updates.json)を作る。
// サイトの新しい更新の一覧(/updates.json)。ハコブくんが走って知らせる部品(HakobuParade)が読む
// 記録(LOG記事)・4コママンガ・課題地図の最近の動きの、それぞれ新しいものを日付の新しい順に並べる
import { allCards, allManga } from '../lib/records';
import { NEWS } from '../lib/issues';
export async function GET() {
const records = (await allCards()).slice(0, 10).map((c) => ({
id: `record:${c.slug}`, kind: 'record', label: c.day, title: c.title, url: `/record/${c.slug}`, date: c.published,
}));
const manga = allManga.slice(0, 5).map((m) => ({
id: `manga:${m.slug}`, kind: 'manga', label: m.label, title: m.title, url: `/manga/${m.slug}`, date: m.published,
}));
// 課題地図は「サイトに足した日」を日付にする(出来事の日ではなく)
const news = NEWS.slice().sort((a, b) => b.added.localeCompare(a.added)).slice(0, 5).map((n) => ({
id: `issues:${n.id}`, kind: 'issues', label: '課題地図', title: n.title, url: n.issues[0] ? `/issues/${n.issues[0]}` : '/issues', date: `${n.added}T00:00:00+09:00`,
}));
const items = [...records, ...manga, ...news].sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
return new Response(JSON.stringify({ items }), { headers: { 'Content-Type': 'application/json; charset=utf-8' } });
}
src/components/HakobuParade.astro
更新を知らせて走るハコブくんと、効果の記録を送る処理。
---
// サイトに新しい更新(記録・4コマ・課題地図の最近の動き)があると、ハコブくんが画面の下を走り回って知らせる。
// 動画の進み具合のページの「動画が公開されたら走るハコブくん」と同じ動き・同じ決まり:
// - 知らせた更新はこのブラウザに覚えておき、同じ更新では2回走らない
// - 初めて来た人には、今日(日本時間)公開された分だけ知らせる
// - タブが見えていないときは走らず、見えたときに走る
// 吹き出しを押すと、その更新のページへ移る
// 効果を測るため、「出た・吹き出しを押した・押さずにあとで(7日以内に)その更新を見に来た」を /api/hk-event に送る。
// 送るのは出来事の種類・どの更新か・どのページで・画面の幅だけ(見た人を見分ける番号は作らない)。数は /api/hk-stats で見られる
---
<script>
type Item = { id: string; kind: 'record' | 'manga' | 'issues'; label: string; title: string; url: string; date: string };
const KEY = 'p2m-site-announced';
const IMG = '/site/hakobu/';
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const HEAD: Record<Item['kind'], string> = {
record: '新しい記録を公開しました!',
manga: '新しい4コママンガを公開しました!',
issues: '課題地図に最近の動きが加わりました!',
};
let seen: Set<string> | null = null;
let items: Item[] = [];
let running = false;
const SHOWN_KEY = 'p2m-site-announced-last';
// 効果の記録を送る(ページを移っても届くよう sendBeacon を使う)
function report(e: 'shown' | 'click' | 'later', it: { id: string; kind: string }) {
const body = JSON.stringify({ e, id: it.id, kind: it.kind, path: location.pathname, w: window.innerWidth < 600 ? 'sp' : 'pc' });
try {
if (!navigator.sendBeacon || !navigator.sendBeacon('/api/hk-event', new Blob([body], { type: 'text/plain' }))) {
fetch('/api/hk-event', { method: 'POST', body, keepalive: true }).catch(() => {});
}
} catch { /* 送れなくてもサイトの動きには影響させない */ }
}
// 押さずにあとで見に来たか:最後に知らせた更新のページを、7日以内に開いたら1回だけ送る
try {
const last = JSON.parse(localStorage.getItem(SHOWN_KEY) || 'null');
if (last && !last.done && location.pathname === last.url && Date.now() - last.at < 7 * 864e5) {
if (!last.clicked) report('later', last); // 押して来た分は click で送り済み
last.done = true; localStorage.setItem(SHOWN_KEY, JSON.stringify(last));
}
} catch { /* 覚えられない環境では送らない */ }
const load = () => { try { const v = localStorage.getItem(KEY); return v ? new Set<string>(JSON.parse(v)) : null; } catch { return null; } };
const save = (s: Set<string>) => { try { localStorage.setItem(KEY, JSON.stringify([...s].slice(-300))); } catch { /* 覚えられない環境では毎回の判定だけ */ } };
const today = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Tokyo' });
const dayOf = (iso: string) => new Date(iso).toLocaleDateString('sv-SE', { timeZone: 'Asia/Tokyo' });
function check() {
if (!items.length || running || document.hidden) return;
if (!seen) {
seen = load();
if (!seen) seen = new Set(items.filter((i) => dayOf(i.date) !== today()).map((i) => i.id)); // 初めて:今日より前の分は知らせ済み
save(seen);
}
const fresh = items.filter((i) => !seen!.has(i.id));
if (!fresh.length) return;
fresh.forEach((i) => seen!.add(i.id)); save(seen);
const it = fresh[0];
const more = fresh.length > 1 ? `(ほかに ${fresh.length - 1} 件)` : '';
report('shown', it);
try { localStorage.setItem(SHOWN_KEY, JSON.stringify({ id: it.id, kind: it.kind, url: it.url, at: Date.now(), clicked: false, done: location.pathname === it.url })); } catch { /* なし */ }
parade(HEAD[it.kind] + more, `${it.label} ${it.title}`, it.url, it);
}
function parade(text: string, sub: string, url: string, it: Item) {
running = true;
const box = document.createElement('div'); box.className = 'hk-parade'; box.setAttribute('aria-hidden', 'true');
const pb = document.createElement('div'); pb.className = 'hk-pb';
const body = new Image(); body.src = IMG + 'body.webp'; body.alt = '';
const face = new Image(); face.src = IMG + 'face-joy.webp'; face.alt = ''; face.className = 'hk-face';
const faceOpen = new Image(); faceOpen.src = IMG + 'face-open.webp'; // 先に読み込んでおく
pb.append(body, face); box.append(pb);
const say = document.createElement('a'); say.className = 'hk-say'; say.href = url; say.setAttribute('role', 'status');
const t = document.createElement('span'); t.textContent = text;
const s = document.createElement('small'); s.textContent = sub;
say.append(t, s);
say.addEventListener('click', () => {
report('click', it);
try { const last = JSON.parse(localStorage.getItem(SHOWN_KEY) || 'null'); if (last && last.id === it.id) { last.clicked = true; localStorage.setItem(SHOWN_KEY, JSON.stringify(last)); } } catch { /* なし */ }
});
document.body.append(box, say);
const DUR = reduce ? 4000 : 7500;
let t0: number | null = null, faceT = 0;
const step = (now: number) => {
if (t0 === null) t0 = now;
const e = now - t0;
const w = box.offsetWidth, span = Math.max(0, window.innerWidth - w - 16);
if (!reduce) {
// 左右に2往復。走る速さに強弱をつけ、跳ねながら行く
const ph = (e / DUR) * 2, k = ph % 1, right = Math.floor(ph) % 2 === 0;
const ease = k < .5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
const x = 8 + span * (right ? ease : 1 - ease);
const y = -Math.abs(Math.sin(e / 140)) * 10;
box.style.transform = `translate(${x}px, ${y}px)`;
pb.style.transform = `scaleX(${right ? 1 : -1})`;
if (e - faceT > 280) { faceT = e; face.src = face.src.endsWith('face-joy.webp') ? faceOpen.src : IMG + 'face-joy.webp'; }
} else box.style.transform = 'translate(16px, 0)';
if (e < DUR) requestAnimationFrame(step);
else {
box.remove();
say.classList.add('hk-say-out');
setTimeout(() => say.remove(), 300);
running = false;
check(); // 続けて知らせるものがあれば、もう一度
}
};
requestAnimationFrame(step);
}
fetch('/updates.json', { cache: 'no-cache' })
.then((r) => (r.ok ? r.json() : { items: [] }))
.then((d) => { items = d.items ?? []; check(); })
.catch(() => { /* 一覧が読めないときは走らない */ });
document.addEventListener('visibilitychange', () => { if (!document.hidden) check(); });
</script>
<style is:global>
.hk-parade { position: fixed; z-index: 60; left: 0; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); width: 92px; pointer-events: none; will-change: transform; }
.hk-pb { position: relative; aspect-ratio: 292 / 460; }
.hk-pb img { display: block; width: 100%; height: auto; }
.hk-pb .hk-face { position: absolute; left: 52.055%; top: 9.130%; width: 30.479%; height: 18.696%; }
.hk-say { position: fixed; z-index: 61; left: 0; right: 0; margin: 0 auto; bottom: calc(168px + env(safe-area-inset-bottom, 0px));
width: max-content; max-width: min(92vw, 520px); display: flex; flex-direction: column; gap: 2px;
background: var(--dark, #242424); color: #fff; border-radius: 12px; padding: 10px 16px; text-align: center;
font: 600 15px/1.5 Inter, 'Noto Sans JP', sans-serif; box-shadow: 0 6px 24px rgba(0,0,0,.25); text-decoration: none; transition: opacity .3s; }
.hk-say small { font-size: 12.5px; font-weight: 400; opacity: .75; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.hk-say:hover small { text-decoration: underline; }
.hk-say:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.hk-say-out { opacity: 0; }
@keyframes hk-pop { 0% { transform: scale(.6); opacity: 0; } 70% { transform: scale(1.04); opacity: 1; } 100% { transform: scale(1); } }
@media (prefers-reduced-motion: no-preference) { .hk-say { animation: hk-pop .45s ease-out both; } }
@media (max-width: 599px) { .hk-parade { width: 76px; } .hk-say { bottom: calc(140px + env(safe-area-inset-bottom, 0px)); font-size: 14px; } }
</style>
src/layouts/Base.astro
全ページ共通の枠。HakobuParade を入れた。
---
// 全ページ共通の枠(head・ヘッダー・フッター)
// lang: ページの言語(英語のページは 'en')/ ogType: 記事は 'article'
// alternates: 日本語版・英語版の対応(hreflang)/ jsonLd: 構造化データ(schema.org)
import { SITE, SITE_NAME, DEFAULT_OG, abs } from '../lib/seo';
import HakobuParade from '../components/HakobuParade.astro';
interface Props {
title: string; description?: string; ogImage?: string; path: string;
lang?: 'ja' | 'en'; ogType?: 'website' | 'article'; noindex?: boolean;
alternates?: { lang: string; path: string }[]; jsonLd?: object | object[];
published?: string; modified?: string;
}
const { title, description = '', ogImage, path, lang = 'ja', ogType = 'website', noindex = false,
alternates = [], jsonLd, published, modified } = Astro.props;
const canonical = SITE + path;
const image = abs(ogImage || DEFAULT_OG);
const desc = description.length > 160 ? description.slice(0, 159) + '…' : description;
const ld = jsonLd ? JSON.stringify(jsonLd).replace(/</g, '\\u003c') : null;
---
<!doctype html>
<html lang={lang}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={desc} />
<link rel="canonical" href={canonical} />
{alternates.length > 0 && [{ lang, path }, ...alternates].map((a) => <link rel="alternate" hreflang={a.lang} href={SITE + a.path} />)}
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:locale" content={lang === 'en' ? 'en_US' : 'ja_JP'} />
<meta property="og:title" content={title} />
<meta property="og:description" content={desc} />
<meta property="og:type" content={ogType} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={image} />
{ogType === 'article' && published && <meta property="article:published_time" content={published} />}
{ogType === 'article' && modified && <meta property="article:modified_time" content={modified} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="robots" content={noindex ? 'noindex, follow' : 'index, follow, max-image-preview:large'} />
{ld && <script type="application/ld+json" set:html={ld} />}
<link rel="icon" type="image/png" href="/site/9cad887a-242b-4953-bd36-7b368c27d1b9.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+JP:wght@300;400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0&display=block" />
<slot name="head" />
</head>
<body>
<header class="site-header">
<div class="hd-inner">
<a class="logo" href="/">
<img class="logo-img" src="/site/pixelstomotors-logo.svg" alt="Pixels to Motors" width="189" height="20" />
</a>
<div class="hd-center">
<a class="hd-manga" href="/manga-archive"><img src="/site/s-500x700_f2c5ae27-2ebe-4d15-8da4-05853d4973ef.webp" alt="4コママンガ" width="500" height="700" /></a>
<a class="btn-dark" href="/manga-archive">4コママンガ<i class="ms">arrow_forward</i></a>
<a class="btn-dark" href="/manga-archive-en">English 4-Koma<i class="ms">arrow_forward</i></a>
</div>
<a class="btn-dark hd-msg" href="/message">メッセージ<i class="ms">arrow_forward</i></a>
</div>
</header>
<main><slot /></main>
<HakobuParade />
<footer class="site-footer">
<div class="ft-inner">
<span>© ADAPT DESIGN 2026</span>
<a href="/privacypolicy">Privacy policy</a>
</div>
</footer>
<script>
// 図解(隔離した枠)から届いた高さで、枠の高さを合わせる
window.addEventListener('message', (e) => {
const h = e.data && e.data.p2mHeight;
if (typeof h !== 'number' || !isFinite(h) || h <= 0) return;
for (const f of document.querySelectorAll('iframe[data-embed]')) {
if (f.contentWindow === e.source) { f.style.height = Math.min(Math.ceil(h), 20000) + 'px'; break; }
}
});
</script>
</body>
</html>
<style is:global>
:root {
--ink: #242424; --ink-2: #555; --ink-3: #888; --line: #f2f2f2;
--bg: #f7f7f7; --panel: #fff; --dark: #242424;
--sans: Inter, 'Noto Sans JP', sans-serif;
--mono: Menlo, Monaco, 'Courier New', monospace;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
/* Studio と同じく、Mac で文字を細めに描く指定(ないと太く濃く見える) */
body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans);
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
.ms { font-family: 'Material Symbols Outlined'; font-style: normal; font-size: 16px; line-height: 1; }
.site-header { position: fixed; inset: 0 0 auto 0; z-index: 50; height: 48px; padding: 6px 40px;
background: rgba(255,255,255,.1); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 4px rgba(0,0,0,.1); }
.hd-inner { max-width: 1280px; margin: 0 auto; height: 36px; display: flex; align-items: center; justify-content: space-between; gap: 40px; }
.logo { display: flex; align-items: center; }
.logo-img { height: 17.3px; width: auto; }
/* 元は Studio 付属の有料フォント「A1ゴシック M」。移行先では使えないため代用中 */
.logo span { font-family: 'Noto Sans JP', sans-serif; font-weight: 500; font-size: 20px; letter-spacing: -0.4px; color: var(--ink); }
.hd-center { display: flex; align-items: center; gap: 8px; }
.hd-manga img { height: 28px; width: auto; }
.btn-dark { display: inline-flex; align-items: center; gap: 8px; height: 31px; padding: 4px 16px; border-radius: 4px;
background: var(--dark); border: 1px solid var(--dark); color: #fff; font: 500 14px/21px 'Noto Sans JP', sans-serif; white-space: nowrap; }
.site-footer { background: var(--dark); padding: 24px 40px; }
.ft-inner { max-width: 1280px; margin: 0 auto; display: flex; gap: 40px; }
.ft-inner a, .ft-inner span { color: #888; font: 300 14px/21px var(--sans); }
.ft-inner a:hover { color: #fff; }
/* シリーズ名の札・日付 */
.tag { display: inline-block; padding: 2px 6px; border-radius: 4px; background: #f0f0f0; color: #888; font: 400 11px/16px var(--sans); }
.date { color: #aaa; font: 400 11px/16px var(--sans); }
/* 一覧ページ共通の枠 */
.panel { max-width: 1280px; margin: 0 auto; background: #fff; border-radius: 16px; padding: 32px; }
.related, .sec { padding: 40px 80px; }
.sec-h { margin: 0 0 32px; font: 600 24px/36px var(--sans); }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; }
@media (max-width: 1023px) { .grid3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .related, .sec { padding: 40px 24px; } }
@media (max-width: 599px) { .grid3 { grid-template-columns: 1fr; gap: 24px; } .related, .sec { padding: 32px 12px; } .panel { padding: 24px 16px; } }
@media (max-width: 767px) {
.site-header { padding: 6px 16px; }
.hd-inner { gap: 8px; }
.logo-img { height: 14.8px; }
.hd-center .btn-dark { display: none; }
.hd-manga img { height: 32px; }
.btn-dark { padding: 4px 10px; font-size: 12px; gap: 4px; }
.site-footer { padding: 24px 16px; }
}
</style>
worker/index.js
サイトの入口。効果の記録(/api/hk-event)と集計の表(/api/hk-stats)を足した。
// サイトの入口。ほとんどのページは作っておいた静的ファイル(dist/)をそのまま返す。
// ここで処理するのは3つだけ:
// - /pixelstomotors/… 旧 URL の形。/pixelstomotors を外した URL へ 301 で転送する
// - /media/{ファイル名} 記事・4コマの画像と動画を R2(p2m-media)から返す(GitHub には入れないため)
// - /api/message メッセージフォームの送信を R2(p2m-messages)に保存し、メールで知らせる
// - /api/hk-event 更新を知らせるハコブくんが「出た・押された・あとで見に来た」を R2(p2m-messages の hk-events/)に1件ずつ残す
// - /api/hk-stats その記録を日ごと・更新ごとに数えて見せる(数だけ。誰かは分からない)
import { EmailMessage } from 'cloudflare:email';
export default {
async fetch(request, env) {
const url = new URL(request.url);
// 旧 URL の形(/pixelstomotors/…)で来たら、/pixelstomotors を外した URL へ恒久転送する
if (url.pathname === '/pixelstomotors' || url.pathname.startsWith('/pixelstomotors/')) {
const to = new URL(url.pathname.slice('/pixelstomotors'.length) || '/', url);
to.search = url.search;
return Response.redirect(to.toString(), 301);
}
if (url.pathname.startsWith('/media/')) return media(request, env, url);
if (url.pathname === '/api/message') return message(request, env);
if (url.pathname === '/api/hk-event') return hkEvent(request, env);
if (url.pathname === '/api/hk-stats') return hkStats(request, env, url);
return env.ASSETS.fetch(request);
},
};
async function media(request, env, url) {
if (request.method !== 'GET' && request.method !== 'HEAD') return new Response('Method Not Allowed', { status: 405 });
const key = decodeURIComponent(url.pathname.slice('/media/'.length));
if (!key || key.includes('/') || key.startsWith('.')) return new Response('Not Found', { status: 404 });
// 動画の途中から再生できるよう、Range(一部だけの取得)にも答える
const obj = await env.MEDIA.get(key, { range: request.headers, onlyIf: request.headers });
if (obj === null) return new Response('Not Found', { status: 404 });
const headers = new Headers();
obj.writeHttpMetadata(headers);
headers.set('etag', obj.httpEtag);
headers.set('accept-ranges', 'bytes');
headers.set('cache-control', 'public, max-age=86400');
if (!('body' in obj) || !obj.body) return new Response(null, { status: 304, headers });
let status = 200;
if (obj.range && request.headers.has('range')) {
// R2 の range は {offset, length} か {suffix}。キーがあっても値が undefined のことがあるため、値で判定する
// ('suffix' in r で判定すると NaN になり、ブラウザが動画を再生しなかった)
const r = obj.range;
let start, end;
if (typeof r.suffix === 'number') {
start = Math.max(0, obj.size - r.suffix);
end = obj.size - 1;
} else {
start = typeof r.offset === 'number' ? r.offset : 0;
end = typeof r.length === 'number' ? Math.min(obj.size - 1, start + r.length - 1) : obj.size - 1;
}
headers.set('content-range', `bytes ${start}-${end}/${obj.size}`);
headers.set('content-length', String(end - start + 1));
status = 206;
}
return new Response(request.method === 'HEAD' ? null : obj.body, { status, headers });
}
async function message(request, env) {
if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 });
let d;
try { d = await request.json(); } catch { return json({ ok: false, error: '送信の形が正しくありません' }, 400); }
const name = String(d.name || '').trim().slice(0, 200);
const email = String(d.email || '').trim().slice(0, 300);
const body = String(d.body || '').trim().slice(0, 10000);
if (!name || !email || !body || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || d.agree !== true) {
return json({ ok: false, error: '入力を確かめてください' }, 400);
}
const at = new Date().toISOString();
const key = `${at.replace(/[:.]/g, '-')}-${crypto.randomUUID().slice(0, 8)}.json`;
await env.MESSAGES.put(key, JSON.stringify({ at, name, email, body, ua: request.headers.get('user-agent') || '' }, null, 1),
{ httpMetadata: { contentType: 'application/json' } });
// 保存できたら、メールで知らせる。メールが送れなくても、保存は済んでいるので送信者には成功と返す
try {
await notify(env, { at, name, email, body, key });
} catch (e) {
console.error('メール通知に失敗', e && e.message);
}
return json({ ok: true });
}
// 届いたメッセージを MAIL(Cloudflare の送信の仕組み)でエイイチのメールへ送る。
// 送り元はこのドメインの form@pixelstomotors.com。返信先(Reply-To)に送信者のメールを入れる
async function notify(env, m) {
if (!env.MAIL) return;
const from = 'form@pixelstomotors.com';
const to = 'pixelstomotors@adaptdesign.jp';
const subject = `[Pixels to Motors] メッセージ: ${m.name}`;
const text = [
`名前: ${m.name}`,
`メール: ${m.email}`,
`受け取り: ${m.at}`,
`保存先: p2m-messages/${m.key}`,
'',
m.body,
].join('\n');
const raw = [
`From: Pixels to Motors <${from}>`,
`To: <${to}>`,
`Reply-To: ${encodeWord(m.name)} <${m.email}>`,
`Subject: ${encodeWord(subject)}`,
`Date: ${new Date(m.at).toUTCString()}`,
`Message-ID: <${m.key.replace(/\.json$/, '')}@pixelstomotors.com>`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: base64',
'',
base64(text),
].join('\r\n');
await env.MAIL.send(new EmailMessage(from, to, raw));
}
// 日本語の見出しをメールの決まり(RFC 2047)で符号化する。見出しは1行に収めるので改行を入れない
function encodeWord(s) {
return `=?utf-8?B?${base64(s, false)}?=`;
}
// fold=true のとき、本文用に 76 文字ごとに改行を入れる(見出しに入れると途中で切れて壊れる)
function base64(s, fold = true) {
const bytes = new TextEncoder().encode(s);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
const out = btoa(bin);
return fold ? out.replace(/(.{76})/g, '$1\r\n') : out;
}
function json(o, status = 200) {
return new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json; charset=utf-8' } });
}
// ---------- 更新を知らせるハコブくんの効果の記録 ----------
// 1件 = 1つの空のファイル。中身は持たず、何が起きたかを付箋(customMetadata)に書く(一覧のときに中身を読まずに数えられる)
// 残すのは「出た(shown)/押された(click)/押さずにあとで見に来た(later)」「どの更新か」「どのページで」「画面の幅(pc/sp)」「日付」だけ。
// IP アドレス・ブラウザの種類・Cookie・見た人を見分ける番号は残さない
const HK_EVENTS = new Set(['shown', 'click', 'later']);
const HK_KINDS = new Set(['record', 'manga', 'issues']);
async function hkEvent(request, env) {
if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 });
let d;
try { d = JSON.parse((await request.text()).slice(0, 2000)); } catch { return new Response(null, { status: 400 }); }
const e = String(d.e || ''), kind = String(d.kind || '');
const id = String(d.id || '').slice(0, 160), path = String(d.path || '').slice(0, 160), w = d.w === 'sp' ? 'sp' : 'pc';
if (!HK_EVENTS.has(e) || !HK_KINDS.has(kind) || !/^(record|manga|issues):[\w.-]+$/.test(id) || !path.startsWith('/')) {
return new Response(null, { status: 400 });
}
const at = new Date();
const day = new Date(at.getTime() + 9 * 3600e3).toISOString().slice(0, 10); // 日本時間の日付
const key = `hk-events/${day}/${at.getTime()}-${crypto.randomUUID().slice(0, 8)}`;
await env.MESSAGES.put(key, '', { customMetadata: { e, kind, id, path, w } });
return new Response(null, { status: 204 });
}
async function hkStats(request, env, url) {
if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 });
const rows = [];
let cursor;
do {
const r = await env.MESSAGES.list({ prefix: 'hk-events/', cursor, include: ['customMetadata'], limit: 1000 });
for (const o of r.objects) rows.push({ day: o.key.split('/')[1], ...(o.customMetadata || {}) });
cursor = r.truncated ? r.cursor : undefined;
} while (cursor);
const add = (map, k, ev) => { const v = map.get(k) || { shown: 0, click: 0, later: 0 }; v[ev] = (v[ev] || 0) + 1; map.set(k, v); return v; };
// 週は月曜はじまり(日本時間の日付から、その週の月曜の日付を出す)
const weekOf = (day) => { const d = new Date(day + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)); return d.toISOString().slice(0, 10); };
const byDay = new Map(), byWeek = new Map(), byItem = new Map(), byW = new Map();
for (const r of rows) { add(byDay, r.day, r.e); add(byWeek, weekOf(r.day), r.e); add(byItem, r.id, r.e); add(byW, r.w, r.e); }
const data = {
total: rows.length,
byWeek: [...byWeek].sort((a, b) => b[0].localeCompare(a[0])).map(([week, v]) => {
const end = new Date(week + 'T00:00:00Z'); end.setUTCDate(end.getUTCDate() + 6);
return { week: `${week} 〜 ${end.toISOString().slice(5, 10)}`, ...v };
}),
byDay: [...byDay].sort((a, b) => b[0].localeCompare(a[0])).map(([day, v]) => ({ day, ...v })),
byItem: [...byItem].sort((a, b) => b[1].shown - a[1].shown).map(([id, v]) => ({ id, ...v })),
byWidth: [...byW].map(([w, v]) => ({ w, ...v })),
};
if (url.searchParams.get('format') === 'json') {
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } });
}
const pct = (v) => (v.shown ? `${Math.round(((v.click + v.later) / v.shown) * 100)}%` : '-');
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const table = (head, list, key) => `<table><tr><th>${head}</th><th>出た</th><th>吹き出しを押した</th><th>押さずにあとで見に来た</th><th>見に行った割合</th></tr>${
list.map((v) => `<tr><td>${esc(v[key])}</td><td>${v.shown}</td><td>${v.click}</td><td>${v.later}</td><td>${pct(v)}</td></tr>`).join('')}</table>`;
const html = `<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>ハコブくんの知らせの記録</title><style>body{font:14px/1.7 -apple-system,'Noto Sans JP',sans-serif;margin:24px;color:#242424;background:#f7f7f7}
h1{font-size:20px}h2{font-size:16px;margin-top:28px}table{border-collapse:collapse;background:#fff;min-width:min(100%,640px)}th,td{border:1px solid #ddd;padding:6px 10px;text-align:left}th{background:#eee}p{color:#555}</style></head><body>
<h1>ハコブくんの知らせの記録</h1><p>サイトの更新を知らせてハコブくんが走った回数と、そのあと見た人がどう動いたか。数だけを残しており、誰かは分からない。日付は日本時間。</p>
<h2>週ごと(月曜はじまり。週が変わると新しい行が増える)</h2>${table('週', data.byWeek, 'week')}
<h2>日ごと</h2>${table('日付', data.byDay, 'day')}
<h2>更新ごと</h2>${table('更新', data.byItem, 'id')}
<h2>画面の幅(pc=広い/sp=スマホ)</h2>${table('幅', data.byWidth, 'w')}
<p>記録の件数:${data.total}</p></body></html>`;
return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' } });
}
src/pages/privacypolicy.astro
プライバシーポリシー。「更新のお知らせの記録について」を足した。
---
import Base from '../layouts/Base.astro';
const sections = [
['個人情報の管理', '当方は、お客さまの個人情報を正確かつ最新の状態に保ち、個人情報への不正アクセス・紛失・破損・改ざん・漏洩などを防止するため、セキュリティシステムの維持・管理体制の整備徹底等の必要な措置を講じ、安全対策を実施し個人情報の厳重な管理を行ないます。'],
['個人情報の利用目的', 'お客さまからお預かりした個人情報は、当方からのご連絡や業務のご案内やご質問に対する回答として、電子メールや資料のご送付に利用いたします。\nまた、お預かりした各種技術データ(CAD、URDF等)は、本サービスの提供およびシミュレーション精度向上のためにのみ使用し、第三者への開示は一切行いません。'],
['個人情報の第三者への開示・提供の禁止', '当方は、お客さまよりお預かりした個人情報を適切に管理し、次のいずれかに該当する場合を除き、個人情報を第三者に開示いたしません。\n・お客さまの同意がある場合\n・お客さまが希望されるサービスを行なうために当方が業務を委託する業者に対して開示する場合\n・法令に基づき開示することが必要である場合'],
['メッセージフォームでお預かりする情報', 'メッセージフォームから送信いただいた内容(お名前・メールアドレス・ご相談内容)と、送信日時・ご利用のブラウザの種類(ユーザーエージェント)を保存します。これらはお問い合わせへのご返信とご相談への対応のためにのみ使用します。\n保存には Cloudflare, Inc. のサービス(Cloudflare R2)を、当方への通知メールの送信には Cloudflare Email Routing を利用しています。'],
['Cookie とアクセス解析について', '当サイトは、サイトの改善のために Cloudflare, Inc. のアクセス解析(Cloudflare Web Analytics)を利用しています。このアクセス解析は Cookie を使わず、ページの閲覧数・表示速度・参照元・おおよその国・ブラウザやOSの種類などを、個人を識別しない形で集計します。<br>当サイトは広告配信サービスを利用しておらず、当方がお客さまを識別するための Cookie を設定することはありません。'],
['更新のお知らせの記録について', '当サイトでは、新しい記事などが公開されたときにキャラクター(ハコブくん)が画面に出てお知らせします。このお知らせが役に立っているかを確かめるため、「お知らせが出た」「お知らせを押した」「お知らせを押さずに7日以内にその記事を開いた」という出来事を、どの記事のお知らせか・どのページで出たか・画面の幅(広い/狭い)・日付とあわせて記録します。\nIP アドレス・ブラウザの種類・Cookie などは記録せず、閲覧者を見分ける番号も作りません。同じお知らせを繰り返し出さないため、お知らせ済みの記事をお客さまのブラウザ(ローカルストレージ)に覚えておきますが、この情報は当方には送られません。'],
['外部サービスの利用について', '当サイトでは、表示や機能のために次の外部サービスを利用しています。これらのサービスの提供元は、お客さまのブラウザから送られる情報(IP アドレス、ブラウザの種類など)を、それぞれのプライバシーポリシーに基づいて取得することがあります。\n・Cloudflare(サイトの配信・画像と動画の配信・メッセージの保存・アクセス解析):<a href="https://www.cloudflare.com/ja-jp/privacypolicy/" target="_blank" rel="noopener">Cloudflare のプライバシーポリシー</a>\n・Google Fonts(文字の表示)/YouTube(動画の埋め込み):<a href="https://policies.google.com/privacy?hl=ja" target="_blank" rel="noopener">Google プライバシーポリシー</a>\nYouTube の動画を再生した場合、YouTube が Cookie を設定することがあります。'],
['個人情報の安全対策', '当方は、個人情報の正確性及び安全性確保のために、セキュリティに万全の対策を講じています。'],
['ご本人の照会', 'お客さまがご本人の個人情報の照会・修正・削除などをご希望される場合には、<a href="/message">メッセージフォーム</a>からご連絡ください。ご本人であることを確認の上、対応させていただきます。'],
['法令、規範の遵守と見直し', '当方は、保有する個人情報に関して適用される日本の法令、その他規範を遵守するとともに、本ポリシーの内容を適宜見直し、その改善に努めます。'],
];
const nl = (s: string) => s.replace(/\n/g, '<br>');
const revised = '2026年10月17日';
---
<Base title="プライバシーポリシー | Pixels to Motors" description="Pixels to Motors(ADAPT DESIGN)のプライバシーポリシー。お預かりする個人情報と技術データの扱い、メッセージフォームの情報の保存、Cookie を使わないアクセス解析、利用している外部サービスについて説明しています。" path="/privacypolicy">
<article class="pp">
<h1 class="pp-h">Privacy policy | プライバシーポリシー</h1>
<p class="pp-p">Pixels to Motors(運営:ADAPT DESIGN 島津 栄一。以下「当方」といいます。)は、当方の提供するサービス(以下「本サービス」といいます。)における、ユーザーについての個人情報を含む利用者情報の取扱いについて、以下のとおりプライバシーポリシー(以下「本ポリシー」といいます。)を定めます。</p>
{sections.map(([h, b]) => (<><h2 class="pp-h2">{h}</h2><p class="pp-p" set:html={nl(b)} /></>))}
<p class="pp-p pp-date">最終改定日:{revised}</p>
</article>
</Base>
<style>
.pp { width: 620px; max-width: calc(100% - 48px); margin: 0 auto; padding: 104px 0 104px; }
.pp-h { margin: 0 0 40px; font: 600 24px/36px var(--sans); }
.pp-h2 { margin: 40px 0 16px; font: 500 18px/27px var(--sans); }
.pp-p { margin: 0; font: 400 16px/1.6 var(--sans); }
.pp-date { margin-top: 40px; color: var(--ink-2); }
.pp-p :global(a) { color: #097ac1; text-decoration: underline; }
@media (max-width: 767px) { .pp { padding-top: 72px; } }
</style>
README.md
サイトのリポジトリの説明。課題地図と、更新を知らせるハコブくんの節を足した。
# p2m-site
Pixels to Motors のサイト(pixelstomotors.com)。adaptdesign.jp/pixelstomotors を Studio から移したもの。Astro で静的ページを作り、Cloudflare Workers で配信する。URL は旧サイトから /pixelstomotors を外した形(例: /record/{slug})。旧い形の URL で来たら worker/index.js が 301 で転送する。
GitHub の main に push すると、Cloudflare が自動で作り直して公開する(約40秒)。
- `npm run build` → `dist/` に全ページができる
- `tools/fetch_live.py <出力>`:今の Studio サイトから記事・4コマの CMS データを取り直す(移行時だけ使う)
- `tools/import_all.py <cms> <site_archive/content/static> .`:CMS データと固定ページから `src/data/` と `public/embeds,thumbs` を作る(移行時だけ使う)
- `tools/add_article.py <作業フォルダ> <LOG番号> <シリーズ名>`:Claude が書いた記事1本(Markdown)を入れる
- `tools/render_og.py [LOG番号]`:サムネイル(`public/thumbs/*.html`)を SNS・検索結果用の画像(`public/og/*.jpg`、1200×675)に書き出す。Chromium のある Claude のクラウドの作業環境で動かす(Mac には Playwright がない)。引数なしなら、まだ画像がないものだけ
- `tools/crop_screenshot.py <画像>...`:記事に載せる画面写真から、ウィンドウの枠・影とブラウザのタブ・アドレスバー・ブックマークバーを切り落とす(元の画像は同じフォルダの `_original/` に残す)。upload_media.py の前に動かす
- `tools/upload_media.py <フォルダ>...`:Mac で動かし、画像と動画を R2(p2m-media)へ入れる。鍵は KIOXIA の `p2m_cloudflare_token.txt`
## 課題地図(/issues)
「フィジカルAIが社会実装される前に解くべき課題」。旧 pixelstomotors.github.io/physical-ai-todo をこのサイトの中へ移したもの(2026-10-17)。
- 課題の本文と点数:`src/content/issues/{slug}.md`(課題1件=1ファイル。先頭の項目がカードの唯一の正)
- 分類・規制・制度・課題マップの点と線:`src/data/issues/meta.json` / 出典・データ集:`src/data/issues/sources.json`
- 最近の動き:`src/data/issues/news.json` / 週1回の調査の記録:`src/data/issues/checks.json`
- ページ:`/issues`(一覧)・`/issues/{slug}`(詳細)・`/issues/category/{分類}`・`/issues/map`・`/issues/sources`・`/issues/updates`
- 週1回の更新は予約タスクの Claude が `tools/issues_weekly.md` の手順で行う。「最近の動き」は自動で公開し、点数・規制の状態・本文の変更はプルリクエストで出してエイイチが確認する
- `tools/add_issue_news.py <JSON>`:最近の動きを足し、調べた記録を残す / `tools/check_issues_data.py`:データの誤りを確かめる
- `tools/import_issues.py <physical-ai-todo> .`:旧リポジトリから取り込む(移行時だけ使う)
## 更新を知らせるハコブくん
サイトに新しい更新(記録・4コマ・課題地図の最近の動き)があると、どのページでもハコブくんが画面の下を走り回り、吹き出し(押すとその更新へ移る)で知らせる。動画の進み具合のページと同じ動き。
- 一覧:`/updates.json`(`src/pages/updates.json.ts` がビルドのたびに作る)/ 部品:`src/components/HakobuParade.astro`(Base に入っている)/ 画像:`public/site/hakobu/`
- 知らせた更新は見た人のブラウザに覚える(同じ更新で2回走らない)。初めて来た人には、日本時間で今日公開された分だけ知らせる
- 効果の記録:「出た・吹き出しを押した・押さずに7日以内に見に来た」を `/api/hk-event` が R2(p2m-messages の `hk-events/`)に1件ずつ残す。数は `/api/hk-stats`(週ごと・日ごと・更新ごと・画面の幅ごとの表。週ごとの行は週が変わると自動で増える)・`/api/hk-stats?format=json` で見る。見た人を見分ける情報は残さない
記事・4コマの画像と動画は GitHub に入れない。ただし `public/og/` の画像は、GitHub にあるサムネイルの HTML から書き出したものなので GitHub に入れる。サイトの `/media/ファイル名` を `worker/index.js` が R2 から返す。
メッセージフォームの送信は R2(p2m-messages)に保存する。
src/data/issues/meta.json
分類・規制と制度の索引・課題マップの点の位置と線111本(import_issues.py が作ったもの)。
{
"categories": [
{
"slug": "workforce",
"name": "労働力・現場・人材",
"desc": "人手不足、現場への定着、ロボットを扱う人材、現場知の継承に関わる課題。"
},
{
"slug": "learning-data",
"name": "学習・データ・モデル",
"desc": "実機データ、Sim2Real、模倣学習、基盤モデルに関わる課題。"
},
{
"slug": "cost-economics",
"name": "コスト・経済性・調達",
"desc": "導入コスト、保守、価格構造、導入モデルに関わる課題。"
},
{
"slug": "space-infra",
"name": "環境・空間・インフラ",
"desc": "建築・都市、通信、充電・動線、公共空間ルールに関わる課題。"
},
{
"slug": "industry-structure",
"name": "産業構造・SIer・競争力",
"desc": "動作ポータビリティ、SIer工数、国内産業、タスク定義に関わる課題。"
},
{
"slug": "safety-reliability",
"name": "安全・信頼性・事故",
"desc": "共存安全基準、責任分界、停止設計、経年劣化に関わる課題。"
},
{
"slug": "autonomy-governance",
"name": "自律性・判断・ガバナンス",
"desc": "説明可能性、自律レベル、プライバシー、セキュリティに関わる課題。"
},
{
"slug": "human-ux",
"name": "人間との関係・UX・受容",
"desc": "Motion UX、意図の可読性、受容性、信頼の設計に関わる課題。"
},
{
"slug": "standards-global",
"name": "規格・標準・国際競争",
"desc": "エコシステム主導権、国際規格、デュアルユース、データ越境に関わる課題。"
}
],
"regulations": [
{
"name": "道路交通法・道路運送車両法(遠隔操作型小型車=自動配送ロボット)",
"status": "2023年4月施行・運用拡大検討中",
"needs_check": true,
"desc": "歩道を走行する自動配送ロボットを「遠隔操作型小型車」として届出制で制度化。対象拡大と歩行者優先の実効性、事故時の運用が論点となっている。",
"issues": [
"public-space-operation-rules"
],
"source": "警察庁・国土交通省"
},
{
"name": "労働安全衛生規則(産業用ロボットの隔離原則と協働運転)",
"status": "2013年特例以降・共存前提への移行が論点",
"needs_check": true,
"desc": "産業用ロボットを柵で隔離する原則のもと、リスクアセスメント実施を条件に協働運転が可能。柵前提の規定から人と同じ空間で動く前提の基準への移行が論点となっている。",
"issues": [
"human-robot-coexistence-safety",
"aging-fleet-maintenance"
],
"source": "厚生労働省"
},
{
"name": "製造物責任法(自律判断するロボットの「欠陥」)",
"status": "解釈論が継続中",
"needs_check": false,
"desc": "学習によって挙動が変わるロボットの「欠陥」をどう認定するか、メーカー・AI開発者・運用者の責任分界をどう引くかが論点となっている。",
"issues": [
"liability-demarcation",
"maintenance-ecosystem"
],
"source": "消費者庁"
},
{
"name": "電波法(無線局免許・ローカル5G等の帯域)",
"status": "制度拡充・運用見直しを推進中",
"needs_check": false,
"desc": "テレオペレーションに必要な低遅延・広帯域通信の確保に関わる周波数割当・免許要件。屋内・地方での運用要件が論点となっている。",
"issues": [
"network-latency-bandwidth",
"teleoperator-profession"
],
"source": "総務省"
},
{
"name": "個人情報保護法(カメラ画像・生活空間データの取扱い)",
"status": "ガイドライン整備が継続中",
"needs_check": false,
"desc": "遠隔監視・テレオペ・学習データ化はカメラで生活空間を撮影することが前提となる。撮影される側の同意取得とデータ管理の枠組みが論点となっている。",
"issues": [
"teleop-privacy",
"cross-border-motion-data"
],
"source": "個人情報保護委員会"
},
{
"name": "外為法(輸出管理・デュアルユース技術)",
"status": "該非判定の運用が論点",
"needs_check": false,
"desc": "ロボット技術・動作データの軍事転用可能性に対する輸出管理。該非判定の基準と研究公開の線引きが、研究開発の萎縮との均衡で論点となっている。",
"issues": [
"dual-use-regulation",
"ecosystem-sovereignty"
],
"source": "経済産業省"
}
],
"map": {
"width": 1000,
"height": 800,
"positions": {
"caregiving-robot-adoption": {
"x": 554.5,
"y": 148.5
},
"robot-literate-workforce": {
"x": 481.5,
"y": 224.5
},
"teleoperator-profession": {
"x": 441.0,
"y": 125.0
},
"tacit-knowledge-capture": {
"x": 519.5,
"y": 44.5
},
"real-robot-data-scarcity": {
"x": 744.5,
"y": 235.5
},
"sim2real-gap": {
"x": 636.5,
"y": 278.5
},
"imitation-data-quality": {
"x": 647.0,
"y": 168.0
},
"foundation-model-concentration": {
"x": 744.5,
"y": 125.5
},
"roi-opacity": {
"x": 826.5,
"y": 408.5
},
"maintenance-ecosystem": {
"x": 717.0,
"y": 408.0
},
"price-structure-smb": {
"x": 761.5,
"y": 304.5
},
"raas-adoption-models": {
"x": 878.0,
"y": 307.0
},
"robot-unready-built-environment": {
"x": 770.0,
"y": 604.0
},
"network-latency-bandwidth": {
"x": 672.0,
"y": 553.0
},
"charging-docking-standards": {
"x": 750.0,
"y": 481.0
},
"public-space-operation-rules": {
"x": 851.5,
"y": 524.5
},
"motion-portability": {
"x": 587.5,
"y": 716.5
},
"sier-engineering-bottleneck": {
"x": 509.5,
"y": 633.5
},
"domestic-hardware-hollowing": {
"x": 619.0,
"y": 593.0
},
"task-definition-maturity": {
"x": 688.5,
"y": 678.5
},
"human-robot-coexistence-safety": {
"x": 362.5,
"y": 703.5
},
"liability-demarcation": {
"x": 323.5,
"y": 601.5
},
"failsafe-stop-design": {
"x": 434.5,
"y": 607.5
},
"aging-fleet-maintenance": {
"x": 474.0,
"y": 715.0
},
"vlm-explainability": {
"x": 183.5,
"y": 571.5
},
"autonomy-level-definition": {
"x": 195.5,
"y": 462.5
},
"teleop-privacy": {
"x": 290.5,
"y": 509.5
},
"embodied-ai-security": {
"x": 286.5,
"y": 618.5
},
"motion-uncanny-valley": {
"x": 144.0,
"y": 366.0
},
"intent-legibility": {
"x": 192.5,
"y": 263.5
},
"accessibility-non-engineers": {
"x": 268.5,
"y": 350.5
},
"trust-calibration": {
"x": 215.0,
"y": 446.0
},
"ecosystem-sovereignty": {
"x": 252.5,
"y": 183.5
},
"safety-standards-participation": {
"x": 332.0,
"y": 112.0
},
"dual-use-regulation": {
"x": 366.0,
"y": 217.0
},
"cross-border-motion-data": {
"x": 281.0,
"y": 291.0
}
},
"edges": [
[
"accessibility-non-engineers",
"caregiving-robot-adoption"
],
[
"accessibility-non-engineers",
"intent-legibility"
],
[
"accessibility-non-engineers",
"motion-uncanny-valley"
],
[
"accessibility-non-engineers",
"robot-literate-workforce"
],
[
"accessibility-non-engineers",
"task-definition-maturity"
],
[
"accessibility-non-engineers",
"teleop-privacy"
],
[
"accessibility-non-engineers",
"trust-calibration"
],
[
"aging-fleet-maintenance",
"failsafe-stop-design"
],
[
"aging-fleet-maintenance",
"human-robot-coexistence-safety"
],
[
"aging-fleet-maintenance",
"liability-demarcation"
],
[
"aging-fleet-maintenance",
"maintenance-ecosystem"
],
[
"aging-fleet-maintenance",
"raas-adoption-models"
],
[
"autonomy-level-definition",
"failsafe-stop-design"
],
[
"autonomy-level-definition",
"human-robot-coexistence-safety"
],
[
"autonomy-level-definition",
"liability-demarcation"
],
[
"autonomy-level-definition",
"public-space-operation-rules"
],
[
"autonomy-level-definition",
"safety-standards-participation"
],
[
"autonomy-level-definition",
"vlm-explainability"
],
[
"caregiving-robot-adoption",
"human-robot-coexistence-safety"
],
[
"caregiving-robot-adoption",
"intent-legibility"
],
[
"caregiving-robot-adoption",
"motion-uncanny-valley"
],
[
"caregiving-robot-adoption",
"robot-literate-workforce"
],
[
"caregiving-robot-adoption",
"roi-opacity"
],
[
"caregiving-robot-adoption",
"sier-engineering-bottleneck"
],
[
"caregiving-robot-adoption",
"teleop-privacy"
],
[
"caregiving-robot-adoption",
"teleoperator-profession"
],
[
"charging-docking-standards",
"ecosystem-sovereignty"
],
[
"charging-docking-standards",
"public-space-operation-rules"
],
[
"charging-docking-standards",
"robot-unready-built-environment"
],
[
"charging-docking-standards",
"safety-standards-participation"
],
[
"cross-border-motion-data",
"dual-use-regulation"
],
[
"cross-border-motion-data",
"ecosystem-sovereignty"
],
[
"cross-border-motion-data",
"foundation-model-concentration"
],
[
"cross-border-motion-data",
"network-latency-bandwidth"
],
[
"cross-border-motion-data",
"teleop-privacy"
],
[
"cross-border-motion-data",
"teleoperator-profession"
],
[
"domestic-hardware-hollowing",
"dual-use-regulation"
],
[
"domestic-hardware-hollowing",
"ecosystem-sovereignty"
],
[
"domestic-hardware-hollowing",
"foundation-model-concentration"
],
[
"domestic-hardware-hollowing",
"maintenance-ecosystem"
],
[
"domestic-hardware-hollowing",
"motion-portability"
],
[
"domestic-hardware-hollowing",
"price-structure-smb"
],
[
"domestic-hardware-hollowing",
"safety-standards-participation"
],
[
"dual-use-regulation",
"ecosystem-sovereignty"
],
[
"dual-use-regulation",
"embodied-ai-security"
],
[
"dual-use-regulation",
"foundation-model-concentration"
],
[
"ecosystem-sovereignty",
"foundation-model-concentration"
],
[
"ecosystem-sovereignty",
"motion-portability"
],
[
"ecosystem-sovereignty",
"real-robot-data-scarcity"
],
[
"ecosystem-sovereignty",
"safety-standards-participation"
],
[
"embodied-ai-security",
"failsafe-stop-design"
],
[
"embodied-ai-security",
"foundation-model-concentration"
],
[
"embodied-ai-security",
"network-latency-bandwidth"
],
[
"embodied-ai-security",
"teleop-privacy"
],
[
"embodied-ai-security",
"vlm-explainability"
],
[
"failsafe-stop-design",
"human-robot-coexistence-safety"
],
[
"failsafe-stop-design",
"liability-demarcation"
],
[
"failsafe-stop-design",
"sim2real-gap"
],
[
"foundation-model-concentration",
"real-robot-data-scarcity"
],
[
"foundation-model-concentration",
"vlm-explainability"
],
[
"human-robot-coexistence-safety",
"intent-legibility"
],
[
"human-robot-coexistence-safety",
"liability-demarcation"
],
[
"human-robot-coexistence-safety",
"motion-portability"
],
[
"human-robot-coexistence-safety",
"motion-uncanny-valley"
],
[
"human-robot-coexistence-safety",
"public-space-operation-rules"
],
[
"human-robot-coexistence-safety",
"safety-standards-participation"
],
[
"imitation-data-quality",
"real-robot-data-scarcity"
],
[
"imitation-data-quality",
"sim2real-gap"
],
[
"imitation-data-quality",
"tacit-knowledge-capture"
],
[
"imitation-data-quality",
"teleoperator-profession"
],
[
"imitation-data-quality",
"vlm-explainability"
],
[
"intent-legibility",
"motion-uncanny-valley"
],
[
"intent-legibility",
"public-space-operation-rules"
],
[
"intent-legibility",
"robot-unready-built-environment"
],
[
"intent-legibility",
"trust-calibration"
],
[
"liability-demarcation",
"maintenance-ecosystem"
],
[
"liability-demarcation",
"public-space-operation-rules"
],
[
"liability-demarcation",
"trust-calibration"
],
[
"liability-demarcation",
"vlm-explainability"
],
[
"maintenance-ecosystem",
"price-structure-smb"
],
[
"maintenance-ecosystem",
"raas-adoption-models"
],
[
"motion-portability",
"real-robot-data-scarcity"
],
[
"motion-portability",
"safety-standards-participation"
],
[
"motion-portability",
"sier-engineering-bottleneck"
],
[
"motion-portability",
"tacit-knowledge-capture"
],
[
"motion-portability",
"task-definition-maturity"
],
[
"motion-uncanny-valley",
"trust-calibration"
],
[
"network-latency-bandwidth",
"public-space-operation-rules"
],
[
"network-latency-bandwidth",
"robot-unready-built-environment"
],
[
"network-latency-bandwidth",
"teleoperator-profession"
],
[
"price-structure-smb",
"raas-adoption-models"
],
[
"price-structure-smb",
"real-robot-data-scarcity"
],
[
"price-structure-smb",
"roi-opacity"
],
[
"price-structure-smb",
"sier-engineering-bottleneck"
],
[
"public-space-operation-rules",
"robot-unready-built-environment"
],
[
"public-space-operation-rules",
"teleop-privacy"
],
[
"raas-adoption-models",
"roi-opacity"
],
[
"real-robot-data-scarcity",
"sim2real-gap"
],
[
"real-robot-data-scarcity",
"tacit-knowledge-capture"
],
[
"real-robot-data-scarcity",
"teleoperator-profession"
],
[
"robot-literate-workforce",
"sier-engineering-bottleneck"
],
[
"robot-literate-workforce",
"tacit-knowledge-capture"
],
[
"robot-literate-workforce",
"task-definition-maturity"
],
[
"robot-literate-workforce",
"teleoperator-profession"
],
[
"roi-opacity",
"sier-engineering-bottleneck"
],
[
"roi-opacity",
"task-definition-maturity"
],
[
"sier-engineering-bottleneck",
"task-definition-maturity"
],
[
"sim2real-gap",
"vlm-explainability"
],
[
"tacit-knowledge-capture",
"task-definition-maturity"
],
[
"teleop-privacy",
"teleoperator-profession"
],
[
"trust-calibration",
"vlm-explainability"
]
]
}
}
src/data/issues/sources.json
出典・データ集の45件(import_issues.py が作ったもの)。
[
{
"org": "経済産業省",
"items": [
{
"title": "ロボットによる社会変革推進計画(2019)",
"volatile": false,
"issues": [
"motion-portability",
"sier-engineering-bottleneck",
"domestic-hardware-hollowing"
]
},
{
"title": "ロボットフレンドリーな環境構築支援事業(2020年代継続)",
"volatile": false,
"issues": [
"robot-unready-built-environment",
"charging-docking-standards"
]
},
{
"title": "ロボット政策関連資料(産業用ロボットの市場・出荷動向)(2024)",
"volatile": true,
"issues": [
"domestic-hardware-hollowing",
"price-structure-smb"
]
},
{
"title": "AI事業者ガイドライン(総務省と共同・第1.0版)(2024)",
"volatile": false,
"issues": [
"vlm-explainability",
"autonomy-level-definition"
]
},
{
"title": "安全保障貿易管理(外為法 リスト規制・キャッチオール規制)(随時)",
"volatile": false,
"issues": [
"dual-use-regulation"
]
}
]
},
{
"org": "厚生労働省",
"items": [
{
"title": "労働安全衛生規則 第150条の4(産業用ロボットの隔離原則)(現行法令)",
"volatile": false,
"issues": [
"human-robot-coexistence-safety"
]
},
{
"title": "産業用ロボットに係る労働安全衛生規則関係の解釈例規(リスクアセスメントによる協働運転特例)(2013通達)",
"volatile": false,
"issues": [
"human-robot-coexistence-safety",
"failsafe-stop-design"
]
},
{
"title": "機能安全による機械等に係る安全確保に関する技術上の指針(2016)",
"volatile": false,
"issues": [
"failsafe-stop-design"
]
},
{
"title": "介護ロボットの開発・普及の促進(開発等加速化事業)(2024)",
"volatile": false,
"issues": [
"caregiving-robot-adoption"
]
},
{
"title": "介護生産性向上総合相談センター(全国展開)(2024)",
"volatile": false,
"issues": [
"caregiving-robot-adoption",
"robot-literate-workforce"
]
}
]
},
{
"org": "警察庁",
"items": [
{
"title": "道路交通法の一部改正(遠隔操作型小型車=自動配送ロボットの通行制度)(2023施行)",
"volatile": false,
"issues": [
"public-space-operation-rules"
]
},
{
"title": "遠隔操作型小型車の届出状況(2025)",
"volatile": true,
"issues": [
"public-space-operation-rules"
]
}
]
},
{
"org": "国土交通省",
"items": [
{
"title": "建築基準法・バリアフリー法関連資料(移動等円滑化基準)(現行法令)",
"volatile": false,
"issues": [
"robot-unready-built-environment"
]
},
{
"title": "自動配送ロボットの社会実装に向けた取組(官民協議会)(2023)",
"volatile": false,
"issues": [
"public-space-operation-rules",
"robot-unready-built-environment"
]
}
]
},
{
"org": "総務省",
"items": [
{
"title": "ローカル5G導入に関するガイドライン(2024)",
"volatile": false,
"issues": [
"network-latency-bandwidth"
]
},
{
"title": "電波利用ホームページ(無線局免許手続)(随時)",
"volatile": false,
"issues": [
"network-latency-bandwidth"
]
},
{
"title": "情報通信白書(ネットワーク遅延・帯域に関する動向)(2025)",
"volatile": true,
"issues": [
"network-latency-bandwidth"
]
}
]
},
{
"org": "個人情報保護委員会",
"items": [
{
"title": "個人情報の保護に関する法律についてのガイドライン(2024)",
"volatile": false,
"issues": [
"teleop-privacy",
"cross-border-motion-data"
]
},
{
"title": "カメラ画像利活用ガイドブック ver3.0(IoT推進コンソーシアム・総務省・経産省)(2022)",
"volatile": false,
"issues": [
"teleop-privacy"
]
}
]
},
{
"org": "消費者庁",
"items": [
{
"title": "製造物責任法の逐条解説(現行法令)",
"volatile": false,
"issues": [
"liability-demarcation",
"maintenance-ecosystem"
]
}
]
},
{
"org": "内閣府",
"items": [
{
"title": "AI制度関連資料(AI事業者ガイドライン政府方針・AI戦略)(2025)",
"volatile": false,
"issues": [
"vlm-explainability",
"autonomy-level-definition"
]
},
{
"title": "ムーンショット型研究開発制度 目標3(AIとロボットの共進化)(継続中)",
"volatile": false,
"issues": [
"real-robot-data-scarcity",
"foundation-model-concentration"
]
}
]
},
{
"org": "NEDO",
"items": [
{
"title": "ロボット白書(2014)",
"volatile": false,
"issues": [
"sier-engineering-bottleneck",
"domestic-hardware-hollowing"
]
},
{
"title": "ロボット・AI分野の技術戦略・プロジェクト成果(随時)",
"volatile": false,
"issues": [
"domestic-hardware-hollowing"
]
}
]
},
{
"org": "日本ロボット工業会(JARA)",
"items": [
{
"title": "ロボット統計(受注・生産・出荷実績)(2026)",
"volatile": true,
"issues": [
"domestic-hardware-hollowing",
"price-structure-smb"
]
},
{
"title": "産業用ロボットの出荷動向(会員統計)(2025)",
"volatile": true,
"issues": [
"domestic-hardware-hollowing"
]
}
]
},
{
"org": "FA・ロボットシステムインテグレータ協会",
"items": [
{
"title": "ロボットSIer人材育成・ロボットSI検定(継続中)",
"volatile": false,
"issues": [
"sier-engineering-bottleneck",
"robot-literate-workforce"
]
},
{
"title": "SIer業界の実態に関する調査(2024)",
"volatile": true,
"issues": [
"sier-engineering-bottleneck"
]
}
]
},
{
"org": "ISO(国際標準化機構)",
"items": [
{
"title": "ISO 10218-1/-2:2025(産業用ロボットの安全要求・改定版)(2025)",
"volatile": false,
"issues": [
"human-robot-coexistence-safety",
"safety-standards-participation",
"failsafe-stop-design"
]
},
{
"title": "ISO/TS 15066:2016(協働ロボット)(2016)",
"volatile": false,
"issues": [
"human-robot-coexistence-safety"
]
},
{
"title": "ISO 13482:2014(生活支援ロボットの安全要求)(2014)",
"volatile": false,
"issues": [
"human-robot-coexistence-safety",
"accessibility-non-engineers"
]
}
]
},
{
"org": "IFR(国際ロボット連盟)",
"items": [
{
"title": "World Robotics Report(世界のロボット導入統計)(2025)",
"volatile": true,
"issues": [
"domestic-hardware-hollowing",
"price-structure-smb"
]
}
]
},
{
"org": "Open Robotics / ROSコミュニティ",
"items": [
{
"title": "ROS 2 Documentation(随時)",
"volatile": false,
"issues": [
"motion-portability",
"ecosystem-sovereignty"
]
},
{
"title": "ROS-Industrial Consortium(随時)",
"volatile": false,
"issues": [
"motion-portability",
"sier-engineering-bottleneck"
]
}
]
},
{
"org": "OPC Foundation",
"items": [
{
"title": "OPC UA for Robotics Companion Specification(2019-)",
"volatile": false,
"issues": [
"motion-portability",
"charging-docking-standards"
]
}
]
},
{
"org": "Hugging Face",
"items": [
{
"title": "LeRobot(オープンソースのロボット学習ライブラリ・データセット形式)(2024-)",
"volatile": false,
"issues": [
"real-robot-data-scarcity",
"imitation-data-quality",
"ecosystem-sovereignty"
]
},
{
"title": "SO-100 / SO-101(オープンソースアームのハードウェアリポジトリ)(2024-)",
"volatile": false,
"issues": [
"price-structure-smb",
"real-robot-data-scarcity"
]
}
]
},
{
"org": "学術論文・研究",
"items": [
{
"title": "Real2Render2Real(Yu, Fu et al., UC Berkeley / Toyota Research Institute)(2025)",
"volatile": false,
"issues": [
"real-robot-data-scarcity",
"sim2real-gap"
]
},
{
"title": "RT-2: Vision-Language-Action Models(Google DeepMind)(2023)",
"volatile": false,
"issues": [
"foundation-model-concentration",
"vlm-explainability"
]
},
{
"title": "OpenVLA: An Open-Source Vision-Language-Action Model(2024)",
"volatile": false,
"issues": [
"foundation-model-concentration",
"vlm-explainability"
]
},
{
"title": "π0: A Vision-Language-Action Flow Model(Physical Intelligence)(2024)",
"volatile": false,
"issues": [
"foundation-model-concentration",
"real-robot-data-scarcity"
]
},
{
"title": "森政弘「不気味の谷」(Energy誌)(1970)",
"volatile": false,
"issues": [
"motion-uncanny-valley"
]
}
]
},
{
"org": "情報処理推進機構(IPA)",
"items": [
{
"title": "制御システムのセキュリティリスク分析ガイド(随時改訂)",
"volatile": false,
"issues": [
"embodied-ai-security"
]
}
]
},
{
"org": "内閣サイバーセキュリティセンター(NISC)",
"items": [
{
"title": "サイバーセキュリティ戦略(重要インフラ・制御系の防護)(随時改訂)",
"volatile": false,
"issues": [
"embodied-ai-security"
]
}
]
},
{
"org": "群馬県",
"items": [
{
"title": "介護テクノロジー定着支援事業(旧:介護ロボット等導入支援事業)(2025)",
"volatile": false,
"issues": [
"caregiving-robot-adoption"
]
}
]
}
]
physical-ai-todo:49ページを転送のページに置き換えたスクリプト
旧課題地図のリポジトリの中で1回だけ動かした。HTML 49 ファイルを、新しい住所の同じページへ移る小さなページに置き換え、404 のページも足した。このあと、検索エンジンが canonical を読めるように noindex の行を外している(下の転送のページの例が、外したあとの形)。
from pathlib import Path
import html
NEW='https://pixelstomotors.com'
def target(p):
s=str(p)
if s=='index.html': return '/issues'
if s in ('map.html','sources.html'): return '/issues/'+s[:-5]
if s=='mecharm_data_blueprint.html': return '/mecharm_data_blueprint'
if s.startswith('categories/'): return '/issues/category/'+p.stem
if s.startswith('issues/'): return '/issues/'+p.stem
raise SystemExit('unknown '+s)
TPL='''<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>移転しました | フィジカルAIが社会実装される前に解くべき課題</title>
<meta name="robots" content="noindex">
<link rel="canonical" href="{url}">
<meta http-equiv="refresh" content="0; url={url}">
<script>location.replace({js} + location.hash);</script>
</head>
<body>
<p>このページは Pixels to Motors のサイトへ移りました。自動で移らない場合は <a href="{url}">{url}</a> を開いてください。</p>
</body>
</html>
'''
n=0
for p in sorted(Path('.').rglob('*.html')):
if '.git' in p.parts: continue
url=NEW+target(p)
p.write_text(TPL.format(url=html.escape(url), js=repr(url).replace("'",'"')), encoding='utf-8'); n+=1
Path('404.html').write_text(TPL.format(url=NEW+'/issues', js='"'+NEW+'/issues"'), encoding='utf-8')
print(n)
physical-ai-todo/README.md
旧課題地図のリポジトリの説明を、移転したことを書いた内容に置き換えた。
# フィジカルAIが社会実装される前に解くべき課題(移転済み)
この課題地図は 2026-10-17 に Pixels to Motors のサイトへ移りました。
- 新しい住所: https://pixelstomotors.com/issues
- 中身の正: PixelstoMotors/p2m-site の `src/content/issues/`(課題本文)と `src/data/issues/`(分類・規制・出典・最近の動き)
- 週1回、最近の動きを出典つきで更新しています
このリポジトリの GitHub Pages(pixelstomotors.github.io/physical-ai-todo)の各ページは、新しい住所の同じページへ転送します。
`issues/*.md` などは移転時点の記録として残しています。ここを直してもサイトには反映されません。
physical-ai-todo/issues/motion-portability.html(転送のページの例)
49ページはどれも同じ形で、行き先だけが違う。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>移転しました | フィジカルAIが社会実装される前に解くべき課題</title>
<link rel="canonical" href="https://pixelstomotors.com/issues/motion-portability">
<meta http-equiv="refresh" content="0; url=https://pixelstomotors.com/issues/motion-portability">
<script>location.replace("https://pixelstomotors.com/issues/motion-portability" + location.hash);</script>
</head>
<body>
<p>このページは Pixels to Motors のサイトへ移りました。自動で移らない場合は <a href="https://pixelstomotors.com/issues/motion-portability">https://pixelstomotors.com/issues/motion-portability</a> を開いてください。</p>
</body>
</html>
取りやめた課題マップ:1回目(分類ごとの区画・説明欄)・commit 42b355f
commit 42b355f の時点の src/pages/issues/map.astro。
---
// 課題マップ(/issues/map)
// 9つの分類を区画として並べ、その中に課題の点を置く。点の大きさ=緊急度、濃さ=深刻度、線=関連(meta.json の map.edges)
// 点を選ぶと、横の欄にその課題の説明とつながっている課題を出す
import IssuesLayout from '../../layouts/IssuesLayout.astro';
import { allIssues, byUrgency, bySeverity, neighbors, CATEGORIES, MAP } from '../../lib/issues';
const issues = await allIssues();
const bySlug = Object.fromEntries(issues.map((i) => [i.data.slug, i]));
// --- 区画と点の配置(3列。区画の中は2列) ---
const W = 960, GAP = 20, COLS = 3, HEAD = 44, CELL_H = 96;
const colW = (W - GAP * (COLS - 1)) / COLS;
const groups = CATEGORIES.map((c) => ({ ...c, list: issues.filter((i) => i.data.category_slug === c.slug).sort(bySeverity) }));
const rowsOf = (g) => Math.ceil(g.list.length / 2);
const regions = [];
const pos = {};
let y = 0;
for (let r = 0; r < Math.ceil(groups.length / COLS); r++) {
const row = groups.slice(r * COLS, r * COLS + COLS);
const h = HEAD + Math.max(...row.map(rowsOf)) * CELL_H + 4;
row.forEach((g, c) => {
const x = c * (colW + GAP);
regions.push({ ...g, x, y, w: colW, h });
g.list.forEach((i, k) => {
pos[i.data.slug] = { x: x + colW / 4 + (k % 2) * colW / 2, y: y + HEAD + Math.floor(k / 2) * CELL_H + 24 };
});
});
y += h + GAP;
}
const H = y - GAP;
const size = (u: number) => 10 + u * 3; // 緊急度 1〜5 → 13〜25
const SHADE = ['#d6d6d6', '#bdbdbd', '#9a9a9a', '#5c5c5c', '#242424']; // 深刻度 1〜5
// 点の下の課題名:かっこ書きを外し、1行12字まで。長いときは言葉の切れ目で、なるべく真ん中で折り返す
const MAXC = 12;
const label = (t: string) => {
const s = t.replace(/([^)]*)/g, '');
if (s.length <= MAXC) return [s];
let best = -1, score = Infinity;
for (let i = 2; i < s.length - 1; i++) {
// 「・」や助詞の後ろで切る。ただし次がひらがなのとき(「という」など)は切らない
if (!'・のとをにへいう'.includes(s[i - 1]) || /[ぁ-ん]/.test(s[i])) continue;
const worst = Math.max(i, s.length - i);
if (worst <= MAXC && worst < score) { best = i; score = worst; }
}
const cut = best > 0 ? best : Math.ceil(s.length / 2);
const b = s.slice(cut);
return [s.slice(0, cut), b.length > MAXC ? b.slice(0, MAXC - 1) + '…' : b];
};
// 線:同じ区画の中は直線、区画をまたぐ線はゆるく曲げる
const edgePath = (a: string, b: string) => {
const p = pos[a], q = pos[b];
if (!p || !q) return '';
const mx = (p.x + q.x) / 2, my = (p.y + q.y) / 2;
const dx = q.x - p.x, dy = q.y - p.y, len = Math.hypot(dx, dy) || 1;
const bend = bySlug[a]?.data.category_slug === bySlug[b]?.data.category_slug ? 0 : Math.min(60, len * 0.12);
return `M${p.x.toFixed(1)} ${p.y.toFixed(1)} Q${(mx - (dy / len) * bend).toFixed(1)} ${(my + (dx / len) * bend).toFixed(1)} ${q.x.toFixed(1)} ${q.y.toFixed(1)}`;
};
// 入口:つながりの多い課題
const degree = (s: string) => neighbors(s).length;
const entries = issues.slice().sort((a, b) => degree(b.data.slug) - degree(a.data.slug) || byUrgency(a, b)).slice(0, 3);
// 横の欄で使うデータ(ページの中に埋め込む)
const panelData = Object.fromEntries(issues.map((i) => [i.data.slug, {
t: i.data.title, c: i.data.category, u: i.data.urgency, s: i.data.severity,
sum: i.data.summary, h: i.data.time_horizon, l: i.data.layer, n: neighbors(i.data.slug),
}]));
---
<IssuesLayout title="課題マップ | フィジカルAIの課題地図 | Pixels to Motors" description={`フィジカルAIの社会実装を止める${issues.length}の課題を、9つの分類の区画に置き、関連する課題どうしを線でつないだ地図。気になる課題からつながりをたどれます。`} path="/issues/map" current="map">
<section class="mhead">
<h1>課題マップ</h1>
<p>{CATEGORIES.length}つの分類を区画として並べ、{issues.length}件の課題を置きました。線は関連する課題どうしのつながり({MAP.edges.length}本)です。課題を選ぶと、つながっている課題が浮かび上がり、その課題の説明が出ます。</p>
</section>
<section class="mstage">
<div class="legend" role="group" aria-label="分類で絞り込む">
<button class="on" data-cat="all" aria-pressed="true">すべて</button>
{CATEGORIES.map((c) => <button data-cat={c.slug} aria-pressed="false">{c.name}</button>)}
</div>
<div class="mgrid">
<div class="mapbox">
<p class="swipe">横にずらして地図全体を見られます</p>
<div class="mapscroll">
<svg viewBox={`-12 -12 ${W + 24} ${H + 24}`} role="img" aria-label={`${issues.length}課題を9つの分類の区画に置き、関連を線でつないだ地図`}>
<g class="regions">
{regions.map((r) => (
<g class="region" data-cat={r.slug}>
<rect x={r.x} y={r.y} width={r.w} height={r.h} rx="14" />
<text class="rname" x={r.x + 16} y={r.y + 27}>{r.name}</text>
<text class="rcount" x={r.x + r.w - 16} y={r.y + 27} text-anchor="end">{r.list.length}件</text>
</g>
))}
</g>
<g class="edges">
{MAP.edges.map(([a, b]) => <path class="edge" data-a={a} data-b={b} d={edgePath(a, b)} />)}
</g>
<g class="nodes">
{issues.map((i) => {
const p = pos[i.data.slug]; const s = size(i.data.urgency); const lines = label(i.data.title);
return (
<g class="node" tabindex="0" role="button" data-slug={i.data.slug} data-cat={i.data.category_slug}
aria-label={`${i.data.title}(緊急度${i.data.urgency}・深刻度${i.data.severity})`}>
<rect class="hit" x={p.x - 70} y={p.y - 18} width="140" height="66" />
<rect class="ring" x={p.x - s / 2 - 5} y={p.y - s / 2 - 5} width={s + 10} height={s + 10} rx="5" />
<rect class="dot" x={p.x - s / 2} y={p.y - s / 2} width={s} height={s} rx="3" fill={SHADE[i.data.severity - 1]} />
<text class="nlabel" x={p.x} y={p.y + 30} text-anchor="middle">
{lines.map((l, k) => <tspan x={p.x} dy={k === 0 ? 0 : 15}>{l}</tspan>)}
</text>
</g>
);
})}
</g>
</svg>
</div>
<div class="key" aria-label="地図の読み方">
<span class="key-item"><svg width="44" height="22" aria-hidden="true"><rect x="1" y="7" width="8" height="8" rx="2" fill="#5c5c5c"/><rect x="15" y="4" width="14" height="14" rx="2" fill="#5c5c5c"/><rect x="33" y="2" width="18" height="18" rx="3" fill="#5c5c5c"/></svg>大きいほど緊急度が高い</span>
<span class="key-item"><svg width="52" height="22" aria-hidden="true">{SHADE.map((c, k) => <rect x={k * 10 + 1} y="5" width="8" height="12" rx="2" fill={c} />)}</svg>濃いほど深刻度が高い</span>
<span class="key-item"><svg width="30" height="22" aria-hidden="true"><path d="M2 16 Q15 2 28 16" fill="none" stroke="#242424" stroke-width="1.4"/></svg>関連する課題</span>
</div>
</div>
<aside class="panel" aria-live="polite">
<div class="pv pv-empty">
<p class="pv-k">地図の歩き方</p>
<p class="pv-lead">気になる課題の点を選ぶと、つながっている課題が浮かび上がります。どこから見ればいいか迷ったら、つながりの多い課題から。</p>
<ol class="pv-entry">
{entries.map((i) => <li><button type="button" data-pick={i.data.slug}><span>{i.data.title}</span><small>{degree(i.data.slug)}件とつながる</small></button></li>)}
</ol>
</div>
<div class="pv pv-issue" hidden>
<p class="pv-k" data-f="c"></p>
<h2 class="pv-h" data-f="t"></h2>
<dl class="pv-scores">
<div><dt>緊急度</dt><dd data-f="u"></dd></div>
<div><dt>深刻度</dt><dd data-f="s"></dd></div>
<div><dt>時間軸</dt><dd data-f="h"></dd></div>
</dl>
<p class="pv-sum" data-f="sum"></p>
<a class="ibtn pv-go" data-f="go" href="#">この課題を詳しく見る<i class="ms">arrow_forward</i></a>
<p class="pv-k pv-nk">つながっている課題</p>
<ul class="pv-nb" data-f="n"></ul>
<button type="button" class="pv-clear">選択を外す</button>
</div>
</aside>
</div>
</section>
<section class="ipanel">
<h2 class="ih2">緊急度の高い順に、つながりをたどる</h2>
<ol class="mlist">
{issues.slice().sort(byUrgency).map((i) => (
<li>
<div class="mrow"><a href={`/issues/${i.data.slug}`}>{i.data.title}</a><small>{i.data.category}・緊急度{i.data.urgency}・深刻度{i.data.severity}</small></div>
<div class="chips">{neighbors(i.data.slug).map((s) => bySlug[s] && <a href={`/issues/${s}`}>{bySlug[s].data.title}</a>)}</div>
</li>
))}
</ol>
</section>
<script type="application/json" id="map-data" set:html={JSON.stringify(panelData).replace(/</g, '\\u003c')} />
</IssuesLayout>
<script>
type P = { t: string; c: string; u: number; s: number; sum: string; h: string; l: string; n: string[] };
const data: Record<string, P> = JSON.parse(document.getElementById('map-data')!.textContent || '{}');
const svg = document.querySelector<SVGSVGElement>('.mapbox svg')!;
const nodes = [...document.querySelectorAll<SVGGElement>('.node')];
const edges = [...document.querySelectorAll<SVGPathElement>('.edge')];
const regions = [...document.querySelectorAll<SVGGElement>('.region')];
const bySlug = new Map(nodes.map((n) => [n.dataset.slug!, n]));
const empty = document.querySelector<HTMLElement>('.pv-empty')!;
const view = document.querySelector<HTMLElement>('.pv-issue')!;
const f = (k: string) => view.querySelector<HTMLElement>(`[data-f="${k}"]`)!;
let filter = 'all';
let picked: string | null = null;
const bars = (v: number) => '■'.repeat(v) + '□'.repeat(5 - v);
function paint(hover: string | null = null) {
const focus = hover ?? picked;
const near = new Set(focus ? [focus, ...(data[focus]?.n ?? [])] : []);
svg.classList.toggle('focused', !!focus);
for (const n of nodes) {
const s = n.dataset.slug!;
const out = focus ? !near.has(s) : filter !== 'all' && n.dataset.cat !== filter;
n.classList.toggle('dim', out);
n.classList.toggle('near', !!focus && near.has(s) && s !== focus);
n.classList.toggle('picked', s === focus);
}
for (const e of edges) {
const a = e.dataset.a!, b = e.dataset.b!;
const lit = !!focus && (a === focus || b === focus);
const inCat = filter === 'all' || (bySlug.get(a)?.dataset.cat === filter && bySlug.get(b)?.dataset.cat === filter);
e.classList.toggle('lit', lit);
e.classList.toggle('dim', focus ? !lit : !inCat);
}
for (const r of regions) r.classList.toggle('dim', !focus && filter !== 'all' && r.dataset.cat !== filter);
}
function show(slug: string | null) {
picked = slug;
if (!slug || !data[slug]) { view.hidden = true; empty.hidden = false; paint(); return; }
const d = data[slug];
f('c').textContent = d.c;
f('t').textContent = d.t;
f('u').textContent = `${bars(d.u)} ${d.u}`;
f('s').textContent = `${bars(d.s)} ${d.s}`;
f('h').textContent = d.h;
f('sum').textContent = d.sum;
(f('go') as HTMLAnchorElement).href = `/issues/${slug}`;
const ul = f('n'); ul.replaceChildren();
for (const s of d.n) {
const li = document.createElement('li'); const b = document.createElement('button');
b.type = 'button'; b.textContent = data[s]?.t ?? s; b.addEventListener('click', () => show(s));
li.append(b); ul.append(li);
}
empty.hidden = true; view.hidden = false;
paint();
// 説明欄が地図の下に来る幅(スマホなど)では、選んだら説明欄が見えるところまで送る
const box = view.closest('.panel')!.getBoundingClientRect();
if (window.innerWidth < 1024 && box.top > window.innerHeight - 120) {
const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
view.closest('.panel')!.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'start' });
}
}
for (const n of nodes) {
const s = n.dataset.slug!;
n.addEventListener('mouseenter', () => paint(s));
n.addEventListener('mouseleave', () => paint());
n.addEventListener('click', () => show(picked === s ? null : s));
n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); show(s); } });
}
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') show(null); });
document.querySelector('.pv-clear')!.addEventListener('click', () => show(null));
for (const b of document.querySelectorAll<HTMLButtonElement>('[data-pick]')) b.addEventListener('click', () => show(b.dataset.pick!));
for (const b of document.querySelectorAll<HTMLButtonElement>('.legend button')) {
b.addEventListener('click', () => {
filter = b.dataset.cat!;
document.querySelectorAll<HTMLButtonElement>('.legend button').forEach((x) => {
x.classList.toggle('on', x === b); x.setAttribute('aria-pressed', String(x === b));
});
show(null);
});
}
</script>
<style>
.mhead { padding: 32px 0 0; }
.mhead h1 { margin: 0 0 8px; font: 600 30px/1.5 var(--sans); }
.mhead p { margin: 0; max-width: 820px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); }
.mstage { margin-top: 28px; }
.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.legend button { padding: 5px 12px; border: 1px solid #dcdcdc; border-radius: 999px; background: #fff; font: 400 12px/18px var(--sans); color: var(--ink-2); cursor: pointer; }
.legend button:hover { border-color: var(--ink); color: var(--ink); }
.legend button.on { background: var(--dark); border-color: var(--dark); color: #fff; }
.legend button:focus-visible, .panel button:focus-visible, .pv-go:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.mgrid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 20px; align-items: start; }
.mapbox { background: #fff; border-radius: 16px; padding: 20px 20px 14px; }
.mapscroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.mapscroll svg { display: block; width: 100%; min-width: 640px; height: auto; font-family: var(--sans); }
.swipe { display: none; }
.region rect { fill: #f4f4f4; transition: fill .2s, opacity .2s; }
.region .rname { font-size: 14px; font-weight: 600; fill: var(--ink); }
.region .rcount { font-size: 11px; fill: var(--ink-3); }
.region.dim { opacity: .35; }
svg.focused .region rect { fill: #f7f7f7; }
.edge { fill: none; stroke: var(--ink); stroke-opacity: .09; stroke-width: 1; transition: stroke-opacity .15s, stroke-width .15s; }
.edge.dim { stroke-opacity: .025; }
.edge.lit { stroke-opacity: .8; stroke-width: 1.6; }
.node { cursor: pointer; outline: none; transition: opacity .15s; }
.node .hit { fill: transparent; }
.node .ring { fill: none; stroke: transparent; stroke-width: 1.5; }
.node .dot { transition: transform .15s; }
/* 線が文字の上を通っても読めるよう、区画の地色で縁取る */
.nlabel { font-size: 12px; fill: #555; paint-order: stroke; stroke: #f4f4f4; stroke-width: 5px; stroke-linejoin: round; }
svg.focused .nlabel { stroke: #f7f7f7; }
.node:hover .nlabel, .node.near .nlabel { fill: var(--ink); }
.node.picked .ring, .node:focus-visible .ring { stroke: var(--ink); }
.node.picked .nlabel { fill: var(--ink); font-weight: 700; }
.node.dim { opacity: .18; }
.key { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 10px; padding-top: 12px; border-top: 1px solid #eee; }
.key-item { display: inline-flex; align-items: center; gap: 8px; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.panel { position: sticky; top: 72px; background: var(--dark); color: #fff; border-radius: 16px; padding: 24px; min-height: 320px; }
.pv-k { margin: 0 0 8px; font: 400 12px/18px var(--sans); color: #a8a8a8; }
.pv-lead { margin: 0 0 18px; font: 400 14px/1.9 var(--sans); color: #e8e8e8; }
.pv-entry { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.pv-entry button, .pv-nb button { width: 100%; text-align: left; border: 1px solid #444; border-radius: 8px; background: transparent; color: #fff; cursor: pointer; font-family: var(--sans); }
.pv-entry button { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; font-size: 14px; line-height: 1.5; }
.pv-entry small { font-size: 11px; color: #a8a8a8; }
.pv-entry button:hover, .pv-nb button:hover { border-color: #fff; }
.pv-h { margin: 0 0 14px; font: 600 20px/1.5 var(--sans); }
.pv-scores { margin: 0 0 14px; display: grid; gap: 4px; }
.pv-scores div { display: grid; grid-template-columns: 4em 1fr; font: 400 13px/1.7 var(--sans); }
.pv-scores dt { color: #a8a8a8; }
.pv-scores dd { margin: 0; letter-spacing: .05em; }
.pv-sum { margin: 0 0 18px; font: 400 14px/1.85 var(--sans); color: #e8e8e8; }
.pv-go { background: #fff; color: var(--ink); width: 100%; justify-content: center; }
.pv-nk { margin-top: 22px; }
.pv-nb { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.pv-nb button { padding: 4px 10px; font-size: 12px; line-height: 1.6; }
.pv-clear { margin-top: 18px; padding: 0; border: 0; background: none; color: #a8a8a8; font: 400 12px/18px var(--sans); text-decoration: underline; cursor: pointer; }
.mlist { list-style: none; margin: 0; padding: 0; }
.mlist li { padding: 12px 0; border-bottom: 1px solid #eee; }
.mrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.mrow a { font: 600 15px/1.6 var(--sans); text-decoration: underline; text-underline-offset: 2px; }
.mrow small { font: 400 12px/1.6 var(--sans); color: var(--ink-3); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chips a { padding: 2px 8px; border-radius: 4px; background: #f0f0f0; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.chips a:hover { background: #e2e2e2; }
@media (max-width: 1023px) {
.mgrid { grid-template-columns: minmax(0, 1fr); }
.panel { position: static; min-height: 0; }
}
@media (max-width: 699px) {
.mhead h1 { font-size: 24px; }
.mapbox { padding: 12px 0 12px 12px; border-radius: 12px; }
.swipe { display: block; margin: 0 0 8px; font: 400 12px/18px var(--sans); color: var(--ink-3); }
.mapscroll svg { min-width: 760px; }
.key { padding-right: 12px; }
.panel { padding: 20px 16px; }
}
@media (prefers-reduced-motion: reduce) {
.node, .edge, .region rect, .node .dot { transition: none; }
}
</style>
取りやめた課題マップ:2回目(曲線・濃淡・方眼)・commit ae1090d
commit ae1090d の時点の src/pages/issues/map.astro。
---
// 課題マップ(/issues/map)。点=課題(大きさ=緊急度、濃さ=深刻度)、線=関連。点の位置と線は meta.json の map
import IssuesLayout from '../../layouts/IssuesLayout.astro';
import { allIssues, byUrgency, neighbors, CATEGORIES, MAP } from '../../lib/issues';
const issues = (await allIssues()).slice().sort(byUrgency);
const bySlug = Object.fromEntries(issues.map((i) => [i.data.slug, i]));
const size = (u: number) => 14 + u * 3.4; // 旧マップと同じく、緊急度が高いほど大きい四角
const SHADE = ['#cfcfcf', '#b3b3b3', '#8c8c8c', '#4f4f4f', '#1f1f1f']; // 深刻度 1〜5
const pos = (s: string) => MAP.positions[s] ?? { x: MAP.width / 2, y: MAP.height / 2 };
// 線はゆるく曲げる(向きはそろえる)
const curve = (a: string, b: string) => {
const p = pos(a), q = pos(b);
const dx = q.x - p.x, dy = q.y - p.y, len = Math.hypot(dx, dy) || 1, k = Math.min(40, len * 0.1);
const cx = (p.x + q.x) / 2 - (dy / len) * k, cy = (p.y + q.y) / 2 + (dx / len) * k;
return `M${p.x} ${p.y} Q${cx.toFixed(1)} ${cy.toFixed(1)} ${q.x} ${q.y}`;
};
const short = (t: string) => t.replace(/([^)]*)/g, '');
---
<IssuesLayout title="課題マップ | フィジカルAIの課題地図 | Pixels to Motors" description={`フィジカルAIの社会実装を止める${issues.length}の課題を、関連する課題どうしを線でつないだ地図。気になる課題からつながりをたどれます。`} path="/issues/map" current="map">
<section class="mhead">
<h1>課題マップ</h1>
<p>{CATEGORIES.length}分類・{issues.length}件の課題を、関連する課題どうしでつないでいます({MAP.edges.length}本)。点=課題(大きいほど緊急度が高い、濃いほど深刻度が高い)、線=関連。点に触れると関連が強調され、押すと詳しいページへ移ります。分類で絞り込めます。</p>
</section>
<section class="ipanel">
<div class="legend" role="group" aria-label="分類で絞り込む">
<button class="on" data-cat="all">すべて表示</button>
{CATEGORIES.map((c) => <button data-cat={c.slug}>{c.name}</button>)}
</div>
<div class="mapbox">
<svg viewBox={`0 0 ${MAP.width} ${MAP.height}`} role="img" aria-label={`${issues.length}課題の関連の地図`}>
<defs>
<pattern id="mgrid" width="24" height="24" patternUnits="userSpaceOnUse"><circle cx="12" cy="12" r="1" fill="#e2e2e2" /></pattern>
</defs>
<rect class="mbg" x="0" y="0" width={MAP.width} height={MAP.height} fill="url(#mgrid)" />
<g class="edges">
{MAP.edges.map(([a, b]) => <path class="edge" data-a={a} data-b={b} d={curve(a, b)} />)}
</g>
<g class="nodes">
{issues.map((i) => {
const p = pos(i.data.slug); const s = size(i.data.urgency);
return (
<a href={`/issues/${i.data.slug}`} class="node" data-slug={i.data.slug} data-cat={i.data.category_slug}
data-title={i.data.title} data-catname={i.data.category} data-urg={i.data.urgency} data-sev={i.data.severity}>
<title>{i.data.title}</title>
<rect class="halo" x={p.x - s / 2 - 6} y={p.y - s / 2 - 6} width={s + 12} height={s + 12} rx="6" />
<rect class="dot" x={p.x - s / 2} y={p.y - s / 2} width={s} height={s} rx="3.5" fill={SHADE[i.data.severity - 1]} />
<text class="nlabel" x={p.x} y={p.y + s / 2 + 16} text-anchor="middle">{short(i.data.title)}</text>
</a>
);
})}
</g>
</svg>
<div class="tip" id="tip" hidden></div>
</div>
<div class="key" aria-label="地図の読み方">
<span><svg width="46" height="22" aria-hidden="true"><rect x="1" y="7" width="8" height="8" rx="2" fill="#4f4f4f"/><rect x="14" y="4" width="14" height="14" rx="3" fill="#4f4f4f"/><rect x="32" y="2" width="18" height="18" rx="3.5" fill="#4f4f4f"/></svg>大きいほど緊急度が高い</span>
<span><svg width="52" height="22" aria-hidden="true">{SHADE.map((c, k) => <rect x={k * 10 + 1} y="5" width="8" height="12" rx="2" fill={c} />)}</svg>濃いほど深刻度が高い</span>
<span><svg width="30" height="22" aria-hidden="true"><path d="M2 16 Q15 2 28 16" fill="none" stroke="#242424" stroke-width="1.4"/></svg>関連する課題</span>
</div>
</section>
<section class="ipanel">
<h2 class="ih2">緊急度の高い順に、つながりをたどる</h2>
<ol class="mlist">
{issues.map((i) => (
<li>
<div class="mrow"><a href={`/issues/${i.data.slug}`}>{i.data.title}</a><small>{i.data.category}・緊{i.data.urgency}・深{i.data.severity}</small></div>
<div class="chips">{neighbors(i.data.slug).map((s) => bySlug[s] && <a href={`/issues/${s}`}>{bySlug[s].data.title}</a>)}</div>
</li>
))}
</ol>
</section>
</IssuesLayout>
<script>
const svg = document.querySelector<SVGSVGElement>('.mapbox svg')!;
const nodes = [...document.querySelectorAll<SVGAElement>('.node')];
const edges = [...document.querySelectorAll<SVGPathElement>('.edge')];
const box = document.querySelector<HTMLElement>('.mapbox')!;
const tip = document.getElementById('tip')!;
const bySlug = new Map(nodes.map((n) => [n.dataset.slug!, n]));
const adj = new Map<string, string[]>();
for (const e of edges) {
const a = e.dataset.a!, b = e.dataset.b!;
adj.set(a, [...(adj.get(a) ?? []), b]);
adj.set(b, [...(adj.get(b) ?? []), a]);
}
let filter = 'all';
const base = () => {
for (const n of nodes) {
const inCat = filter === 'all' || n.dataset.cat === filter;
n.classList.toggle('dim', !inCat);
n.classList.toggle('show', filter !== 'all' && inCat);
n.classList.remove('near', 'cur');
}
for (const e of edges) {
e.classList.remove('lit');
const inCat = bySlug.get(e.dataset.a!)?.dataset.cat === filter && bySlug.get(e.dataset.b!)?.dataset.cat === filter;
e.classList.toggle('dim', filter !== 'all' && !inCat);
e.classList.toggle('incat', filter !== 'all' && inCat);
}
};
const bars = (v: number) => '■'.repeat(v) + '□'.repeat(5 - v);
const focus = (n: SVGAElement) => {
const id = n.dataset.slug!;
const near = new Set(adj.get(id) ?? []);
for (const m of nodes) {
const s = m.dataset.slug!;
m.classList.toggle('dim', s !== id && !near.has(s));
m.classList.toggle('near', near.has(s));
m.classList.toggle('show', s === id || near.has(s));
m.classList.toggle('cur', s === id);
}
for (const e of edges) {
const lit = e.dataset.a === id || e.dataset.b === id;
e.classList.toggle('lit', lit);
e.classList.toggle('dim', !lit);
e.classList.remove('incat');
}
tip.replaceChildren();
const c = document.createElement('span'); c.className = 'tip-c'; c.textContent = n.dataset.catname!;
const t = document.createElement('strong'); t.textContent = n.dataset.title!;
const sc = document.createElement('span'); sc.className = 'tip-s';
sc.textContent = `緊急度 ${bars(+n.dataset.urg!)} 深刻度 ${bars(+n.dataset.sev!)}`;
const nb = document.createElement('span'); nb.className = 'tip-c'; nb.textContent = `${near.size}件の課題とつながる`;
tip.append(c, t, sc, nb);
tip.hidden = false;
const br = box.getBoundingClientRect(), nr = n.querySelector('.dot')!.getBoundingClientRect();
let left = nr.right - br.left + 14;
if (left + 270 > br.width) left = nr.left - br.left - 284;
tip.style.left = Math.max(8, left) + 'px';
tip.style.top = Math.max(4, nr.top - br.top - 10) + 'px';
};
const blur = () => { tip.hidden = true; base(); };
for (const n of nodes) {
n.addEventListener('mouseenter', () => focus(n));
n.addEventListener('focus', () => focus(n));
n.addEventListener('mouseleave', blur);
n.addEventListener('blur', blur);
}
for (const b of document.querySelectorAll<HTMLButtonElement>('.legend button')) {
b.addEventListener('click', () => {
filter = b.dataset.cat!;
document.querySelectorAll('.legend button').forEach((x) => x.classList.toggle('on', x === b));
base();
});
}
</script>
<style>
.mhead { padding: 32px 0 0; }
.mhead h1 { margin: 0 0 8px; font: 600 30px/1.5 var(--sans); }
.mhead p { margin: 0; max-width: 860px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); }
.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.legend button { padding: 4px 10px; border: 1px solid #ddd; border-radius: 4px; background: #fff; font: 400 12px/18px var(--sans); color: var(--ink-2); cursor: pointer; }
.legend button:hover { border-color: var(--ink); }
.legend button.on { background: var(--dark); border-color: var(--dark); color: #fff; }
.mapbox { position: relative; border: 1px solid #eee; border-radius: 8px; background: #fcfcfc; overflow: hidden; }
.mapbox svg { display: block; width: 100%; height: auto; font-family: var(--sans); }
.edge { fill: none; stroke: var(--ink); stroke-opacity: .12; stroke-width: 1; transition: stroke-opacity .15s, stroke-width .15s; }
.edge.lit { stroke-opacity: .85; stroke-width: 1.8; }
.edge.dim { stroke-opacity: .035; }
.node { cursor: pointer; transition: opacity .15s; outline: none; }
.node .halo { fill: #fff; stroke: #e6e6e6; stroke-width: 1; opacity: 0; transition: opacity .15s; }
.node .dot { stroke: #fcfcfc; stroke-width: 2; }
.node.cur .halo, .node:focus-visible .halo { opacity: 1; stroke: var(--ink); stroke-width: 1.5; }
.node.near .halo { opacity: 1; }
.node.dim { opacity: .14; }
/* 課題名は、触れた課題とつながる課題、または絞り込んだ分類の課題にだけ出す。線の上でも読めるよう地の色で縁取る */
.nlabel { font-size: 13px; fill: #555; opacity: 0; pointer-events: none; transition: opacity .15s;
paint-order: stroke; stroke: #fcfcfc; stroke-width: 5px; stroke-linejoin: round; }
.node.show .nlabel { opacity: 1; }
/* 触れている課題の名前は吹き出しに出すので、点の下には出さない */
.node.cur .nlabel { opacity: 0; }
.edge.incat { stroke-opacity: .5; stroke-width: 1.4; }
.tip { position: absolute; z-index: 2; width: 270px; padding: 12px 14px; border-radius: 8px; background: var(--dark); color: #fff;
box-shadow: 0 8px 24px rgba(0,0,0,.18); pointer-events: none; display: flex; flex-direction: column; gap: 4px; }
.tip[hidden] { display: none; }
.tip :global(strong) { font: 600 14px/1.5 var(--sans); }
.tip :global(.tip-c) { font: 400 11px/1.6 var(--sans); color: #a8a8a8; }
.tip :global(.tip-s) { font: 400 12px/1.6 var(--sans); letter-spacing: .02em; }
.key { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 12px; }
.key span { display: inline-flex; align-items: center; gap: 8px; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.mlist { list-style: none; margin: 0; padding: 0; }
.mlist li { padding: 12px 0; border-bottom: 1px solid #eee; }
.mrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.mrow a { font: 600 15px/1.6 var(--sans); text-decoration: underline; text-underline-offset: 2px; }
.mrow small { font: 400 12px/1.6 var(--sans); color: var(--ink-3); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chips a { padding: 2px 8px; border-radius: 4px; background: #f0f0f0; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.chips a:hover { background: #e2e2e2; }
@media (max-width: 599px) { .mhead h1 { font-size: 24px; } }
@media (prefers-reduced-motion: reduce) { .edge, .node, .node .halo, .nlabel { transition: none; } }
</style>
取りやめた課題マップ:3回目(点の集まり)・commit 9ff141d
commit 9ff141d の時点の src/pages/issues/map.astro。
---
// 課題マップ(/issues/map)。点=課題(大きさ=緊急度、濃さ=深刻度)、線=関連。点の位置と線は meta.json の map
import IssuesLayout from '../../layouts/IssuesLayout.astro';
import { allIssues, byUrgency, neighbors, CATEGORIES, MAP } from '../../lib/issues';
const issues = (await allIssues()).slice().sort(byUrgency);
const bySlug = Object.fromEntries(issues.map((i) => [i.data.slug, i]));
const SHADE = ['#c4c4c4', '#a6a6a6', '#7d7d7d', '#474747', '#1f1f1f']; // 深刻度 1〜5
const pos = (s: string) => MAP.positions[s] ?? { x: MAP.width / 2, y: MAP.height / 2 };
const short = (t: string) => t.replace(/([^)]*)/g, '');
// 課題1つを「小さな点の集まり」で描く(ロゴと同じ、点が集まって形になる見せ方)
// 緊急度が高いほど点が多く、集まりも大きい。ふだんは少し散らばり、触れると中心へ集まる
const GOLD = Math.PI * (3 - Math.sqrt(5)); // ひまわりの種の並び(点がむらなく円に広がる)
const radius = (u: number) => 11 + u * 2.6; // ふだんの集まりの半径(緊急度 1〜5 → 13.6〜24)
function dotsOf(slug: string, u: number) {
let seed = [...slug].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
const n = 14 + u * 5, R = radius(u), G = R * 0.5;
return Array.from({ length: n }, (_, k) => {
const t = Math.sqrt((k + 0.5) / n), a = k * GOLD;
const j = 0.75 + rnd() * 0.5, ja = a + (rnd() - 0.5) * 0.6; // ふだんは少し揺らして散らす
const far = 60 + rnd() * 80, fa = rnd() * Math.PI * 2; // 開いたときは遠くから集まってくる
return {
x: +(Math.cos(ja) * t * R * j).toFixed(1), y: +(Math.sin(ja) * t * R * j).toFixed(1),
gx: +(Math.cos(a) * t * G).toFixed(1), gy: +(Math.sin(a) * t * G).toFixed(1),
bx: +(Math.cos(fa) * far).toFixed(1), by: +(Math.sin(fa) * far).toFixed(1),
d: Math.round(rnd() * 220),
};
});
}
---
<IssuesLayout title="課題マップ | フィジカルAIの課題地図 | Pixels to Motors" description={`フィジカルAIの社会実装を止める${issues.length}の課題を、関連する課題どうしを線でつないだ地図。気になる課題からつながりをたどれます。`} path="/issues/map" current="map">
<section class="mhead">
<h1>課題マップ</h1>
<p>{CATEGORIES.length}分類・{issues.length}件の課題を、関連する課題どうしでつないでいます({MAP.edges.length}本)。点の集まり=課題(点が多く大きいほど緊急度が高い、濃いほど深刻度が高い)、線=関連。点に触れると関連が強調され、押すと詳しいページへ移ります。分類で絞り込めます。</p>
</section>
<section class="ipanel">
<div class="legend" role="group" aria-label="分類で絞り込む">
<button class="on" data-cat="all">すべて表示</button>
{CATEGORIES.map((c) => <button data-cat={c.slug}>{c.name}</button>)}
</div>
<div class="mapbox">
<svg class="boot" viewBox={`0 0 ${MAP.width} ${MAP.height}`} role="img" aria-label={`${issues.length}課題の関連の地図`}>
<defs>
<pattern id="mgrid" width="24" height="24" patternUnits="userSpaceOnUse"><circle cx="12" cy="12" r="1" fill="#e2e2e2" /></pattern>
</defs>
<rect class="mbg" x="0" y="0" width={MAP.width} height={MAP.height} fill="url(#mgrid)" />
<g class="edges">
{MAP.edges.map(([a, b]) => <line class="edge" data-a={a} data-b={b} x1={pos(a).x} y1={pos(a).y} x2={pos(b).x} y2={pos(b).y} />)}
</g>
<g class="nodes">
{issues.map((i) => {
const p = pos(i.data.slug); const R = radius(i.data.urgency);
return (
<a href={`/issues/${i.data.slug}`} class="node" data-slug={i.data.slug} data-cat={i.data.category_slug}
data-title={i.data.title} data-catname={i.data.category} data-urg={i.data.urgency} data-sev={i.data.severity}>
<title>{i.data.title}</title>
<circle class="hit" cx={p.x} cy={p.y} r={R + 8} />
<circle class="ring" cx={p.x} cy={p.y} r={R * 0.5 + 7} />
<g class="dots" fill={SHADE[i.data.severity - 1]}>
{dotsOf(i.data.slug, i.data.urgency).map((d) => (
<circle class="pd" cx={p.x} cy={p.y} r="1.9"
style={`--x:${d.x}px;--y:${d.y}px;--gx:${d.gx}px;--gy:${d.gy}px;--bx:${d.bx}px;--by:${d.by}px;--d:${d.d}ms`} />
))}
</g>
<text class="nlabel" x={p.x} y={p.y + R + 15} text-anchor="middle">{short(i.data.title)}</text>
</a>
);
})}
</g>
</svg>
<div class="tip" id="tip" hidden></div>
</div>
<div class="key" aria-label="地図の読み方">
<span><svg width="50" height="24" viewBox="0 0 50 24" aria-hidden="true" fill="#474747">
{[[7, 1], [24, 3], [41, 5]].map(([cx, u]) => dotsOf('key' + u, u).map((d) => <circle cx={cx + d.x * 0.38} cy={12 + d.y * 0.38} r="0.9" />))}
</svg>点が多く大きいほど緊急度が高い</span>
<span><svg width="52" height="22" aria-hidden="true">{SHADE.map((c, k) => <circle cx={k * 10 + 5} cy="11" r="4" fill={c} />)}</svg>濃いほど深刻度が高い</span>
<span><svg width="30" height="22" aria-hidden="true"><line x1="2" y1="16" x2="28" y2="6" stroke="#242424" stroke-width="1.2"/></svg>関連する課題</span>
</div>
</section>
<section class="ipanel">
<h2 class="ih2">緊急度の高い順に、つながりをたどる</h2>
<ol class="mlist">
{issues.map((i) => (
<li>
<div class="mrow"><a href={`/issues/${i.data.slug}`}>{i.data.title}</a><small>{i.data.category}・緊{i.data.urgency}・深{i.data.severity}</small></div>
<div class="chips">{neighbors(i.data.slug).map((s) => bySlug[s] && <a href={`/issues/${s}`}>{bySlug[s].data.title}</a>)}</div>
</li>
))}
</ol>
</section>
</IssuesLayout>
<script>
const svg = document.querySelector<SVGSVGElement>('.mapbox svg')!;
const nodes = [...document.querySelectorAll<SVGAElement>('.node')];
const edges = [...document.querySelectorAll<SVGLineElement>('.edge')];
const box = document.querySelector<HTMLElement>('.mapbox')!;
const tip = document.getElementById('tip')!;
const bySlug = new Map(nodes.map((n) => [n.dataset.slug!, n]));
const adj = new Map<string, string[]>();
for (const e of edges) {
const a = e.dataset.a!, b = e.dataset.b!;
adj.set(a, [...(adj.get(a) ?? []), b]);
adj.set(b, [...(adj.get(b) ?? []), a]);
}
let filter = 'all';
const base = () => {
for (const n of nodes) {
const inCat = filter === 'all' || n.dataset.cat === filter;
n.classList.toggle('dim', !inCat);
n.classList.toggle('show', filter !== 'all' && inCat);
n.classList.remove('near', 'cur');
}
for (const e of edges) {
e.classList.remove('lit');
const inCat = bySlug.get(e.dataset.a!)?.dataset.cat === filter && bySlug.get(e.dataset.b!)?.dataset.cat === filter;
e.classList.toggle('dim', filter !== 'all' && !inCat);
e.classList.toggle('incat', filter !== 'all' && inCat);
}
};
const bars = (v: number) => '■'.repeat(v) + '□'.repeat(5 - v);
const focus = (n: SVGAElement) => {
const id = n.dataset.slug!;
const near = new Set(adj.get(id) ?? []);
for (const m of nodes) {
const s = m.dataset.slug!;
m.classList.toggle('dim', s !== id && !near.has(s));
m.classList.toggle('near', near.has(s));
m.classList.toggle('show', s === id || near.has(s));
m.classList.toggle('cur', s === id);
}
for (const e of edges) {
const lit = e.dataset.a === id || e.dataset.b === id;
e.classList.toggle('lit', lit);
e.classList.toggle('dim', !lit);
e.classList.remove('incat');
}
tip.replaceChildren();
const c = document.createElement('span'); c.className = 'tip-c'; c.textContent = n.dataset.catname!;
const t = document.createElement('strong'); t.textContent = n.dataset.title!;
const sc = document.createElement('span'); sc.className = 'tip-s';
sc.textContent = `緊急度 ${bars(+n.dataset.urg!)} 深刻度 ${bars(+n.dataset.sev!)}`;
const nb = document.createElement('span'); nb.className = 'tip-c'; nb.textContent = `${near.size}件の課題とつながる`;
tip.append(c, t, sc, nb);
tip.hidden = false;
const br = box.getBoundingClientRect(), nr = n.querySelector('.ring')!.getBoundingClientRect();
let left = nr.right - br.left + 14;
if (left + 270 > br.width) left = nr.left - br.left - 284;
tip.style.left = Math.max(8, left) + 'px';
tip.style.top = Math.max(4, nr.top - br.top - 10) + 'px';
};
const blur = () => { tip.hidden = true; base(); };
for (const n of nodes) {
n.addEventListener('mouseenter', () => focus(n));
n.addEventListener('focus', () => focus(n));
n.addEventListener('mouseleave', blur);
n.addEventListener('blur', blur);
}
for (const b of document.querySelectorAll<HTMLButtonElement>('.legend button')) {
b.addEventListener('click', () => {
filter = b.dataset.cat!;
document.querySelectorAll('.legend button').forEach((x) => x.classList.toggle('on', x === b));
base();
});
}
// 開いたときの「集まってくる」動き:最初の描画のあとで boot を外す
requestAnimationFrame(() => requestAnimationFrame(() => svg.classList.remove('boot')));
</script>
<style>
.mhead { padding: 32px 0 0; }
.mhead h1 { margin: 0 0 8px; font: 600 30px/1.5 var(--sans); }
.mhead p { margin: 0; max-width: 860px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); }
.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.legend button { padding: 4px 10px; border: 1px solid #ddd; border-radius: 4px; background: #fff; font: 400 12px/18px var(--sans); color: var(--ink-2); cursor: pointer; }
.legend button:hover { border-color: var(--ink); }
.legend button.on { background: var(--dark); border-color: var(--dark); color: #fff; }
.mapbox { position: relative; border: 1px solid #eee; border-radius: 8px; background: #fcfcfc; overflow: hidden; }
.mapbox svg { display: block; width: 100%; height: auto; font-family: var(--sans); }
.edge { fill: none; stroke: var(--ink); stroke-opacity: .12; stroke-width: 1; transition: stroke-opacity .15s, stroke-width .15s; }
.edge.lit { stroke-opacity: .85; stroke-width: 1.8; }
.edge.dim { stroke-opacity: .035; }
.node { cursor: pointer; transition: opacity .15s; outline: none; }
.node .hit { fill: transparent; }
/* 点の集まり。ふだんは少し散らばり、触れた課題は中心へ集まって締まる。つながる課題も少し寄る */
.pd { transform-box: fill-box; transform-origin: center; transform: translate(var(--x), var(--y));
transition: transform .55s cubic-bezier(.2, .8, .2, 1) var(--d), opacity .55s ease var(--d); }
.node.near .pd { transform: translate(calc(var(--x) * .78), calc(var(--y) * .78)); transition-delay: calc(var(--d) * .4); }
.node.cur .pd, .node:focus-visible .pd { transform: translate(var(--gx), var(--gy)) scale(1.3); transition-delay: calc(var(--d) * .3); }
/* ページを開いたとき1回だけ、遠くから集まってくる */
svg.boot .pd { transform: translate(var(--bx), var(--by)) scale(.4); opacity: 0; transition: none; }
.node .ring { fill: none; stroke: var(--ink); stroke-width: 1.2; opacity: 0; transform-box: fill-box; transform-origin: center;
transform: scale(1.5); transition: opacity .3s ease, transform .45s cubic-bezier(.2, .8, .2, 1); }
.node.cur .ring, .node:focus-visible .ring { opacity: 1; transform: scale(1); }
.node.dim { opacity: .14; }
/* 課題名は、触れた課題とつながる課題、または絞り込んだ分類の課題にだけ出す。線の上でも読めるよう地の色で縁取る */
.nlabel { font-size: 13px; fill: #555; opacity: 0; pointer-events: none; transition: opacity .15s;
paint-order: stroke; stroke: #fcfcfc; stroke-width: 5px; stroke-linejoin: round; }
.node.show .nlabel { opacity: 1; }
/* 触れている課題の名前は吹き出しに出すので、点の下には出さない */
.node.cur .nlabel { opacity: 0; }
.edge.incat { stroke-opacity: .5; stroke-width: 1.4; }
.tip { position: absolute; z-index: 2; width: 270px; padding: 12px 14px; border-radius: 8px; background: var(--dark); color: #fff;
box-shadow: 0 8px 24px rgba(0,0,0,.18); pointer-events: none; display: flex; flex-direction: column; gap: 4px; }
.tip[hidden] { display: none; }
.tip :global(strong) { font: 600 14px/1.5 var(--sans); }
.tip :global(.tip-c) { font: 400 11px/1.6 var(--sans); color: #a8a8a8; }
.tip :global(.tip-s) { font: 400 12px/1.6 var(--sans); letter-spacing: .02em; }
.key { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 12px; }
.key span { display: inline-flex; align-items: center; gap: 8px; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.mlist { list-style: none; margin: 0; padding: 0; }
.mlist li { padding: 12px 0; border-bottom: 1px solid #eee; }
.mrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.mrow a { font: 600 15px/1.6 var(--sans); text-decoration: underline; text-underline-offset: 2px; }
.mrow small { font: 400 12px/1.6 var(--sans); color: var(--ink-3); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chips a { padding: 2px 8px; border-radius: 4px; background: #f0f0f0; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.chips a:hover { background: #e2e2e2; }
@media (max-width: 599px) { .mhead h1 { font-size: 24px; } }
@media (prefers-reduced-motion: reduce) { .edge, .node, .pd, .node .ring, .nlabel { transition: none; } svg.boot .pd { transform: translate(var(--x), var(--y)); opacity: 1; } }
</style>
取りやめた課題マップ:4回目の最後(四角がほどけ、点が中心に集まり線の方向へ流れる。点はブラウザで作る)・commit d61886f
commit d61886f の時点の src/pages/issues/map.astro。
---
// 課題マップ(/issues/map)。点=課題(大きさ=緊急度、濃さ=深刻度)、線=関連。点の位置と線は meta.json の map
import IssuesLayout from '../../layouts/IssuesLayout.astro';
import { allIssues, byUrgency, neighbors, CATEGORIES, MAP } from '../../lib/issues';
const issues = (await allIssues()).slice().sort(byUrgency);
const bySlug = Object.fromEntries(issues.map((i) => [i.data.slug, i]));
const SHADE = ['#c4c4c4', '#a6a6a6', '#7d7d7d', '#474747', '#1f1f1f']; // 深刻度 1〜5
const pos = (s: string) => MAP.positions[s] ?? { x: MAP.width / 2, y: MAP.height / 2 };
const short = (t: string) => t.replace(/([^)]*)/g, '');
// ふだんは四角(大きさ=緊急度、濃さ=深刻度)。四角の中に小さな点を格子に並べておき、
// 触れると四角が消えて点がばらけ、ふわふわ動き続ける(画面の「Pixels」がほどける見せ方)
const size = (u: number) => 14 + u * 3.4; // 旧マップと同じ大きさ(緊急度 1〜5 → 17.4〜31)
// ほどける点は、触れた課題の分だけブラウザで作る(ページを軽く保つため。作り方は下の script)
---
<IssuesLayout title="課題マップ | フィジカルAIの課題地図 | Pixels to Motors" description={`フィジカルAIの社会実装を止める${issues.length}の課題を、関連する課題どうしを線でつないだ地図。気になる課題からつながりをたどれます。`} path="/issues/map" current="map">
<section class="mhead">
<h1>課題マップ</h1>
<p>{CATEGORIES.length}分類・{issues.length}件の課題を、関連する課題どうしでつないでいます({MAP.edges.length}本)。点=課題(大きいほど緊急度が高い、濃いほど深刻度が高い)、線=関連。点に触れると関連が強調され、押すと詳しいページへ移ります。分類で絞り込めます。</p>
</section>
<section class="ipanel">
<div class="legend" role="group" aria-label="分類で絞り込む">
<button class="on" data-cat="all">すべて表示</button>
{CATEGORIES.map((c) => <button data-cat={c.slug}>{c.name}</button>)}
</div>
<div class="mapbox">
<svg viewBox={`0 0 ${MAP.width} ${MAP.height}`} role="img" aria-label={`${issues.length}課題の関連の地図`}>
<defs>
<pattern id="mgrid" width="24" height="24" patternUnits="userSpaceOnUse"><circle cx="12" cy="12" r="1" fill="#e2e2e2" /></pattern>
</defs>
<rect class="mbg" x="0" y="0" width={MAP.width} height={MAP.height} fill="url(#mgrid)" />
<g class="edges">
{MAP.edges.map(([a, b]) => <line class="edge" data-a={a} data-b={b} x1={pos(a).x} y1={pos(a).y} x2={pos(b).x} y2={pos(b).y} />)}
</g>
<g class="nodes">
{issues.map((i) => {
const p = pos(i.data.slug); const sz = size(i.data.urgency);
return (
<a href={`/issues/${i.data.slug}`} class="node" data-slug={i.data.slug} data-cat={i.data.category_slug}
data-title={i.data.title} data-catname={i.data.category} data-urg={i.data.urgency} data-sev={i.data.severity}
aria-label={`${i.data.title}(緊急度${i.data.urgency}・深刻度${i.data.severity})`}>
<rect class="hit" x={p.x - sz / 2 - 8} y={p.y - sz / 2 - 8} width={sz + 16} height={sz + 16} />
<rect class="sq" x={p.x - sz / 2} y={p.y - sz / 2} width={sz} height={sz} rx="2" fill={SHADE[i.data.severity - 1]} />
<g class="dots" fill={SHADE[Math.max(2, i.data.severity - 1)]} data-cx={p.x} data-cy={p.y} data-u={i.data.urgency}></g>
<text class="nlabel" x={p.x} y={p.y + sz / 2 + 16} text-anchor="middle">{short(i.data.title)}</text>
</a>
);
})}
</g>
</svg>
<div class="tip" id="tip" hidden></div>
</div>
<div class="key" aria-label="地図の読み方">
<span><svg width="46" height="22" aria-hidden="true"><rect x="1" y="7" width="8" height="8" rx="1.5" fill="#474747"/><rect x="14" y="4" width="14" height="14" rx="2" fill="#474747"/><rect x="32" y="2" width="18" height="18" rx="2" fill="#474747"/></svg>大きいほど緊急度が高い</span>
<span><svg width="52" height="22" aria-hidden="true">{SHADE.map((c, k) => <rect x={k * 10 + 1} y="6" width="8" height="10" rx="1.5" fill={c} />)}</svg>濃いほど深刻度が高い</span>
<span><svg width="30" height="22" aria-hidden="true"><line x1="2" y1="16" x2="28" y2="6" stroke="#242424" stroke-width="1.2"/></svg>関連する課題</span>
</div>
</section>
<section class="ipanel">
<h2 class="ih2">緊急度の高い順に、つながりをたどる</h2>
<ol class="mlist">
{issues.map((i) => (
<li>
<div class="mrow"><a href={`/issues/${i.data.slug}`}>{i.data.title}</a><small>{i.data.category}・緊{i.data.urgency}・深{i.data.severity}</small></div>
<div class="chips">{neighbors(i.data.slug).map((s) => bySlug[s] && <a href={`/issues/${s}`}>{bySlug[s].data.title}</a>)}</div>
</li>
))}
</ol>
</section>
</IssuesLayout>
<script>
const svg = document.querySelector<SVGSVGElement>('.mapbox svg')!;
const nodes = [...document.querySelectorAll<SVGAElement>('.node')];
const edges = [...document.querySelectorAll<SVGLineElement>('.edge')];
const box = document.querySelector<HTMLElement>('.mapbox')!;
const tip = document.getElementById('tip')!;
const bySlug = new Map(nodes.map((n) => [n.dataset.slug!, n]));
const adj = new Map<string, string[]>();
for (const e of edges) {
const a = e.dataset.a!, b = e.dataset.b!;
adj.set(a, [...(adj.get(a) ?? []), b]);
adj.set(b, [...(adj.get(b) ?? []), a]);
}
// --- 触れた課題の四角を、小さな点にほどく ---
// 3分の2ほどは中心に濃く集まって小さく揺れ、残りは線ごとに分かれて、中心から線に沿って途中まで流れては消える
const SVGNS = 'http://www.w3.org/2000/svg';
const size = (u: number) => 14 + u * 3.4;
const centerOf = (slug: string) => { const g = bySlug.get(slug)!.querySelector<SVGGElement>('.dots')!; return { x: +g.dataset.cx!, y: +g.dataset.cy! }; };
function dotsOf(slug: string, u: number) {
let seed = [...slug].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
const s = size(u), n = Math.max(7, Math.round(s / 1.9)), step = s / n;
const p = centerOf(slug);
const dirs = (adj.get(slug) ?? []).map((o) => {
const q = centerOf(o); const dx = q.x - p.x, dy = q.y - p.y, len = Math.hypot(dx, dy) || 1;
return { ux: dx / len, uy: dy / len, len };
});
const out: { f: boolean; x: number; y: number; ax: number; ay: number; bx: number; by: number; d: number; t: number }[] = [];
let k = 0;
for (let r = 0; r < n; r++) for (let c = 0; c < n; c++, k++) {
const x = -s / 2 + step * (c + 0.5), y = -s / 2 + step * (r + 0.5);
if (dirs.length > 0 && k % 3 === 2) {
const dir = dirs[Math.floor(k / 3) % dirs.length];
const reach = Math.min(dir.len * 0.45, 110); // 線の途中まで(長い線でも110まで)
const side = (rnd() - 0.5) * 4; // 線から少しだけ左右にずらす
const at = (t: number) => [dir.ux * t - dir.uy * side * (1 + t / 40), dir.uy * t + dir.ux * side * (1 + t / 40)];
const [ax, ay] = at(reach * 0.08 * rnd()), [bx, by] = at(reach * (0.55 + rnd() * 0.45));
out.push({ f: true, x, y, ax, ay, bx, by, d: Math.round(rnd() * 1600), t: Math.round(1300 + rnd() * 900) });
} else {
const rad = s * 1.15 * rnd() ** 1.7, ang = rnd() * Math.PI * 2; // 四角より広く、中心ほど濃く
const ax = Math.cos(ang) * rad, ay = Math.sin(ang) * rad;
const w = 1.2 + rad * 0.25, wa = rnd() * Math.PI * 2;
out.push({ f: false, x, y, ax, ay, bx: ax + Math.cos(wa) * w, by: ay + Math.sin(wa) * w, d: Math.round(rnd() * 160), t: Math.round(700 + rnd() * 900) });
}
}
return { p, out };
}
function ensureDots(n: SVGAElement) {
const g = n.querySelector<SVGGElement>('.dots')!;
if (g.childElementCount) return;
const { p, out } = dotsOf(n.dataset.slug!, +g.dataset.u!);
for (const d of out) {
const c = document.createElementNS(SVGNS, 'circle');
c.setAttribute('class', d.f ? 'pd pf' : 'pd');
c.setAttribute('cx', (p.x + d.x).toFixed(2)); c.setAttribute('cy', (p.y + d.y).toFixed(2)); c.setAttribute('r', '0.85');
const st = c.style;
st.setProperty('--ax', `${(d.ax - d.x).toFixed(1)}px`); st.setProperty('--ay', `${(d.ay - d.y).toFixed(1)}px`);
st.setProperty('--bx', `${(d.bx - d.x).toFixed(1)}px`); st.setProperty('--by', `${(d.by - d.y).toFixed(1)}px`);
st.setProperty('--d', `${d.d}ms`); st.setProperty('--t', `${d.t}ms`);
g.append(c);
}
}
let filter = 'all';
const base = () => {
for (const n of nodes) {
const inCat = filter === 'all' || n.dataset.cat === filter;
n.classList.toggle('dim', !inCat);
n.classList.toggle('show', filter !== 'all' && inCat);
n.classList.remove('near', 'cur');
}
for (const e of edges) {
e.classList.remove('lit');
const inCat = bySlug.get(e.dataset.a!)?.dataset.cat === filter && bySlug.get(e.dataset.b!)?.dataset.cat === filter;
e.classList.toggle('dim', filter !== 'all' && !inCat);
e.classList.toggle('incat', filter !== 'all' && inCat);
}
};
const bars = (v: number) => '■'.repeat(v) + '□'.repeat(5 - v);
const focus = (n: SVGAElement) => {
const id = n.dataset.slug!;
ensureDots(n);
const near = new Set(adj.get(id) ?? []);
for (const m of nodes) {
const s = m.dataset.slug!;
m.classList.toggle('dim', s !== id && !near.has(s));
m.classList.toggle('near', near.has(s));
m.classList.toggle('show', s === id || near.has(s));
m.classList.toggle('cur', s === id);
}
for (const e of edges) {
const lit = e.dataset.a === id || e.dataset.b === id;
e.classList.toggle('lit', lit);
e.classList.toggle('dim', !lit);
e.classList.remove('incat');
}
tip.replaceChildren();
const c = document.createElement('span'); c.className = 'tip-c'; c.textContent = n.dataset.catname!;
const t = document.createElement('strong'); t.textContent = n.dataset.title!;
const sc = document.createElement('span'); sc.className = 'tip-s';
sc.textContent = `緊急度 ${bars(+n.dataset.urg!)} 深刻度 ${bars(+n.dataset.sev!)}`;
const nb = document.createElement('span'); nb.className = 'tip-c'; nb.textContent = `${near.size}件の課題とつながる`;
tip.append(c, t, sc, nb);
tip.hidden = false;
const br = box.getBoundingClientRect(), nr = n.querySelector('.sq')!.getBoundingClientRect();
// 点がばらける範囲(四角の外側)にかからない位置に出す
let left = nr.right - br.left + 40;
if (left + 270 > br.width) left = nr.left - br.left - 310;
tip.style.left = Math.max(8, left) + 'px';
tip.style.top = Math.max(4, nr.top - br.top - 10) + 'px';
};
const blur = () => { tip.hidden = true; base(); };
for (const n of nodes) {
n.addEventListener('mouseenter', () => focus(n));
n.addEventListener('focus', () => focus(n));
n.addEventListener('mouseleave', blur);
n.addEventListener('blur', blur);
}
for (const b of document.querySelectorAll<HTMLButtonElement>('.legend button')) {
b.addEventListener('click', () => {
filter = b.dataset.cat!;
document.querySelectorAll('.legend button').forEach((x) => x.classList.toggle('on', x === b));
base();
});
}
</script>
<style>
.mhead { padding: 32px 0 0; }
.mhead h1 { margin: 0 0 8px; font: 600 30px/1.5 var(--sans); }
.mhead p { margin: 0; max-width: 860px; font: 400 15px/1.9 var(--sans); color: var(--ink-2); }
.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.legend button { padding: 4px 10px; border: 1px solid #ddd; border-radius: 4px; background: #fff; font: 400 12px/18px var(--sans); color: var(--ink-2); cursor: pointer; }
.legend button:hover { border-color: var(--ink); }
.legend button.on { background: var(--dark); border-color: var(--dark); color: #fff; }
.mapbox { position: relative; border: 1px solid #eee; border-radius: 8px; background: #fcfcfc; overflow: hidden; }
.mapbox svg { display: block; width: 100%; height: auto; font-family: var(--sans); }
.edge { fill: none; stroke: var(--ink); stroke-opacity: .12; stroke-width: 1; transition: stroke-opacity .15s, stroke-width .15s; }
.edge.lit { stroke-opacity: .85; stroke-width: 1.8; }
.edge.dim { stroke-opacity: .035; }
.node { cursor: pointer; transition: opacity .15s; outline: none; }
.node .hit { fill: transparent; }
.sq { transition: opacity .2s ease; }
.node.cur .sq, .node:focus-visible .sq { opacity: 0; }
.node.dim { opacity: .14; }
/* 課題名は、触れた課題とつながる課題、または絞り込んだ分類の課題にだけ出す。線の上でも読めるよう地の色で縁取る */
.nlabel { font-size: 13px; fill: #555; opacity: 0; pointer-events: none; transition: opacity .15s;
paint-order: stroke; stroke: #fcfcfc; stroke-width: 5px; stroke-linejoin: round; }
.node.show .nlabel { opacity: 1; }
/* 触れている課題の名前は吹き出しに出すので、点の下には出さない */
.node.cur .nlabel { opacity: 0; }
.edge.incat { stroke-opacity: .5; stroke-width: 1.4; }
.tip { position: absolute; z-index: 2; width: 270px; padding: 12px 14px; border-radius: 8px; background: var(--dark); color: #fff;
box-shadow: 0 8px 24px rgba(0,0,0,.18); pointer-events: none; display: flex; flex-direction: column; gap: 4px; }
.tip[hidden] { display: none; }
.tip :global(strong) { font: 600 14px/1.5 var(--sans); }
.tip :global(.tip-c) { font: 400 11px/1.6 var(--sans); color: #a8a8a8; }
.tip :global(.tip-s) { font: 400 12px/1.6 var(--sans); letter-spacing: .02em; }
.key { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 12px; }
.key span { display: inline-flex; align-items: center; gap: 8px; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.mlist { list-style: none; margin: 0; padding: 0; }
.mlist li { padding: 12px 0; border-bottom: 1px solid #eee; }
.mrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.mrow a { font: 600 15px/1.6 var(--sans); text-decoration: underline; text-underline-offset: 2px; }
.mrow small { font: 400 12px/1.6 var(--sans); color: var(--ink-3); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chips a { padding: 2px 8px; border-radius: 4px; background: #f0f0f0; font: 400 12px/18px var(--sans); color: var(--ink-2); }
.chips a:hover { background: #e2e2e2; }
@media (max-width: 599px) { .mhead h1 { font-size: 24px; } }
@media (prefers-reduced-motion: reduce) { .edge, .node, .sq, .nlabel { transition: none; } }
</style>
<style is:global>
/* ほどける点(ブラウザであとから作る要素なので、このページ専用の global スタイルにする) */
/* ふだんは四角。触れると四角が消え、中の小さな点がばらけて、ふわふわ動き続ける */
.mapbox .pd { opacity: 0; transform-box: fill-box; transform-origin: center; transition: opacity .25s ease; }
.mapbox .node.cur .pd, .mapbox .node:focus-visible .pd { opacity: 1;
animation: burst .45s cubic-bezier(.2, .8, .2, 1) var(--d) both, drift var(--t) ease-in-out calc(var(--d) + 450ms) infinite alternate; }
/* 線の方向へ流れる点:中心のそばから線に沿って途中まで進み、消えてはまた中心から出る */
.mapbox .node.cur .pf, .mapbox .node:focus-visible .pf { animation: flow var(--t) linear var(--d) infinite backwards; }
@keyframes flow {
0% { transform: translate(var(--ax), var(--ay)); opacity: 0; }
15% { opacity: 1; }
75% { opacity: .9; }
100% { transform: translate(var(--bx), var(--by)); opacity: 0; }
}
@keyframes burst { from { transform: translate(0, 0); } to { transform: translate(var(--ax), var(--ay)); } }
@keyframes drift { from { transform: translate(var(--ax), var(--ay)); } to { transform: translate(var(--bx), var(--by)); } }
@media (prefers-reduced-motion: reduce) {
.mapbox .pd { transition: none; }
.mapbox .node.cur .pd, .mapbox .node:focus-visible .pd { animation: none; opacity: 1; transform: translate(var(--ax), var(--ay)); }
}
</style>
