From 76c141eb8532cc3f40c96982fda98fc9b674898a Mon Sep 17 00:00:00 2001 From: nyampire Date: Tue, 8 Sep 2026 22:40:12 +0900 Subject: [PATCH 1/9] =?UTF-8?q?docs(plateau):=20OSM=20=E3=81=AE=E3=83=AC?= =?UTF-8?q?=E3=82=A4=E3=83=A4=E3=83=BC=E6=B6=88=E7=81=AF=E3=82=92=E3=83=80?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=A7=E4=BC=9D=E3=81=88?= =?UTF-8?q?=E3=82=8B=E8=A8=AD=E8=A8=88=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面下端の一時的な通知をやめ、ページを開き直すごとに 1 回だけ ダイアログで理由を伝える。「次から表示しない」で以後止められる。 判定の側は出来事を 1 回だけ発生させるだけにし、画面に何を出すかは 新しい画面部品が決める。 Co-Authored-By: Claude Opus 5 --- ...-plateau-osm-layer-off-dialog-design.ja.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-08-plateau-osm-layer-off-dialog-design.ja.md diff --git a/docs/superpowers/specs/2026-09-08-plateau-osm-layer-off-dialog-design.ja.md b/docs/superpowers/specs/2026-09-08-plateau-osm-layer-off-dialog-design.ja.md new file mode 100644 index 000000000..36a128570 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-plateau-osm-layer-off-dialog-design.ja.md @@ -0,0 +1,135 @@ +# OSM のデータのレイヤーが消えている理由をダイアログで伝える設計 + +- 日付: 2026-09-08 +- ブランチ: `feature/plateau-osm-layer-off-notice` +- 起点: `fb97052fc`(Pull Request #52 のマージ地点) + +## 背景 + +Pull Request #52 で、重複の判定に使う OSM の建物が編集ソフトの中に無いあいだは、PLATEAU の候補を出さないようにしました。 +OSM のデータのレイヤーが消えている場合は、利用者が `Shift` + `O` で戻せる状態なので、その理由を画面に出しています。 + +この文言を、画面下端に一時的に出る通知で表示しています。 +表示は 5 秒で終わり、レイヤーが戻ったかどうかは見ていません。 + +利用者から 2 つの指摘がありました。 +レイヤーを戻しても文言がその場で消えないこと、そして通知よりも見落としにくい形で伝えてほしいことです。 + +実機で確認したところ、文言は 5 秒後に画面から外れました。 +残り続ける不具合ではなく、レイヤーの状態と表示の寿命が結び付いていないことが原因です。 + +## 決めたこと + +画面下端の通知をやめて、画面中央のダイアログで伝えます。 + +ダイアログはページを開き直すごとに 1 回だけ出します。 +「次から表示しない」を選んだ場合は、以後ずっと出しません。 + +## 検討して採らなかった案 + +### 画面下端に帯を出し続ける案 + +OSM の API が使えないときに出る赤い帯と同じ仕組みで、レイヤーが消えているあいだ文言を出し続ける案です。 +レイヤーが戻った時点で消えるため、指摘の 1 つ目をそのまま満たします。 + +採りませんでした。 +背景を暗くしない分だけ見落とされやすく、指摘の 2 つ目に応えられないためです。 + +### レイヤーを消すたびにダイアログを出す案 + +見落とす可能性は最も低くなります。 + +採りませんでした。 +レイヤーの切り替えは `Shift` + `O` の 1 打で起きるため、意図して消した人の作業が毎回止まります。 + +## 設計 + +### 1. 判定の側から表示の責任を外す + +`PlateauService` は「候補を伏せた理由がレイヤーの消灯である」ことを一度だけ通知し、画面に何を出すかは決めません。 + +`_notifyOsmLayerOff()` と、そこから呼んでいる画面下端の通知を削除します。 +代わりに `getData()` の中で `osmlayeroff` という出来事を 1 回だけ発生させます。 +1 回に限る判断には、既存の `_osmLayerOffNotified` をそのまま使います。 +`false` のときだけ出来事を発生させ、同時に `true` にします。 + +`PlateauService` は `AbstractSystem` を継承しており、`AbstractSystem` は `EventEmitter` なので、追加の仕組みは要りません。 + +`_osmDataMissing()` の中にある `this._osmLayerOffNotified = false;` の行を削除します。 +この行は「レイヤーが戻ったらもう一度出せるようにする」ためのものでしたが、ページを開き直すごとに 1 回という方針では不要です。 +削除すると `_osmDataMissing()` は状態を変えない問い合わせだけになり、読み違えの余地が減ります。 + +候補を伏せる判定そのものは変えません。 +タイルが未取得のときに何も出さない扱いも変えません。 + +### 2. ダイアログを出す部品を新しく作る + +`modules/ui/UiPlateauOsmLayerOffDialog.js` を追加します。 + +この部品は組み立て時に `context.services.plateau` の `osmlayeroff` を購読します。 +出来事を受け取ったら、保存された設定を読み、「次から表示しない」が選ばれていなければダイアログを開きます。 + +ダイアログは既存の `uiConfirm` を使います。 +`uiConfirm` は見出しと本文と OK ボタンを持つ形で、`uiModal` を土台にしています。 +本文の下にチェックボックスを 1 つ足します。 + +チェックボックスが選ばれたら `context.systems.storage` に印を保存し、外されたら消します。 +保存する名前は `plateau.osm-layer-off-dialog.hidden` とします。 + +### 3. 部品を組み立てる場所 + +`UiSystem` の初期化処理で、他の画面部品と並べて 1 度だけ組み立てます。 +読み込みの 1 行と組み立ての 1 行を足すだけで、描画処理には手を入れません。 + +`UiApiStatus` も組み立て時に `context.services.osm` を参照しており、同じ順序で動きます。 + +### 4. 文言 + +既存の `plateau_conflation.osm_layer_off` を本文として使い回します。 +見出しと、チェックボックスの説明を新しく足します。 + +| 名前 | 用途 | +|---|---| +| `plateau_conflation.osm_layer_off_title` | ダイアログの見出し | +| `plateau_conflation.osm_layer_off` | 本文(既存のものを使う) | +| `plateau_conflation.dont_show_again` | チェックボックスの説明 | + +`data/core.yaml`、`data/l10n/core.en.json`、`data/l10n/core.ja.json` の 3 つに、日本語と英語の両方を書きます。 + +## この設計の弱点 + +「次から表示しない」を選んだ利用者には、以後この理由が一切伝わりません。 +数か月後に同じ状況に陥ったとき、候補が出ない理由を自力で探すことになります。 + +保存する印はブラウザごとに残ります。 +別の端末や別のブラウザで開いた場合は、もう一度ダイアログが出ます。 + +ページを開いているあいだに 2 回目以降レイヤーを消しても、何も出ません。 +1 回目のダイアログを読み飛ばした利用者は、その回の作業中に理由を知る手段がありません。 + +## 試験 + +### `PlateauService` の試験 + +いまある画面下端の通知に関する試験 3 件を削除し、次の 4 件に置き換えます。 + +- レイヤーが消えているとき、`osmlayeroff` が発生すること +- 同じ状態が続いても、`osmlayeroff` は 1 回しか発生しないこと +- レイヤーが戻ってからもう一度消しても、`osmlayeroff` は再発しないこと +- タイルが未取得のときは `osmlayeroff` が発生しないこと + +候補を伏せる判定の試験は、いまのまま残します。 + +### ダイアログの部品の試験 + +- `osmlayeroff` を受け取るとダイアログが開くこと +- 「次から表示しない」が保存されているとき、ダイアログが開かないこと +- チェックボックスを選ぶと設定が保存されること +- チェックボックスを外すと設定が消えること + +### 実行 + +`npm run build` と `npm run dist` を実行してから `npm run test:browser` を実行します。 +作り直さないと古い版を測ります。 + +起点の `fb97052fc` では 770 件成功、5 件スキップ、失敗 0 件です。 From 5ddd2e6fc3bd5a8611b2f6e855337b51e9f2788c Mon Sep 17 00:00:00 2001 From: nyampire Date: Tue, 8 Sep 2026 22:55:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?docs(plateau):=20=E3=83=AC=E3=82=A4?= =?UTF-8?q?=E3=83=A4=E3=83=BC=E6=B6=88=E7=81=AF=E3=81=AE=E3=83=80=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=AE=E5=AE=9F=E8=A3=85=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 つの課題に分ける。判定の側を出来事に変え、ダイアログの部品を作り、 UiSystem に組み込む。それぞれ試験を先に書く手順で進める。 Co-Authored-By: Claude Opus 5 --- ...2026-09-08-plateau-osm-layer-off-dialog.md | 597 ++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md diff --git a/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md b/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md new file mode 100644 index 000000000..3b3045ad3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md @@ -0,0 +1,597 @@ +# OSM のレイヤー消灯をダイアログで伝える実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**目標:** OSM のデータのレイヤーが消えているために PLATEAU の候補を伏せたことを、画面下端の一時的な通知ではなく、ページを開き直すごとに 1 回だけ出るダイアログで伝えます。 + +**構成:** `PlateauService` は `osmlayeroff` という出来事を 1 回だけ発生させるだけにし、画面に何を出すかは新しい部品 `UiPlateauOsmLayerOffDialog` が決めます。ダイアログは既存の `uiConfirm` で組み立て、「次から表示しない」の状態を `StorageSystem` に保存します。 + +**設計文書:** `docs/superpowers/specs/2026-09-08-plateau-osm-layer-off-dialog-design.ja.md` + +**使う道具:** JavaScript (ES modules)、d3-selection、karma と mocha と chai、happen + +## 全体の制約 + +- リポジトリは `/Users/nyampire/git/Rapid`、ブランチは `feature/plateau-osm-layer-off-notice`、起点は `fb97052fc` です。 +- 保存する名前は `plateau.osm-layer-off-dialog.hidden` です。値は文字列の `'true'` だけを使います。 +- 出来事の名前は `osmlayeroff` です。 +- 文言の名前は `plateau_conflation.osm_layer_off_title`、`plateau_conflation.osm_layer_off`、`plateau_conflation.dont_show_again` の 3 つです。 +- 試験を動かす前に `npm run build` と `npm run dist` を実行します。実行しないと古い版を測ります。 +- 起点での試験は 770 件成功、5 件スキップ、失敗 0 件です。 +- 日本語のコメントはですます調では書かず、既存のコメントの文体に合わせます。 + +--- + +### Task 1: 判定の側を、出来事を 1 回発生させるだけにする + +**ファイル:** +- 変更: `modules/services/PlateauService.js`(47 行目、340 行目から 350 行目、390 行目から 408 行目) +- 試験: `test/browser/services/PlateauService.test.js`(777 行目から 820 行目) + +**やり取りする名前:** +- 提供するもの: `PlateauService` が `osmlayeroff` を発生させます。引数はありません。ページを開き直すまでに 1 回だけです。 +- 消えるもの: `PlateauService.prototype._notifyOsmLayerOff` を削除します。以後どこからも呼びません。 + +- [ ] **手順 1: 落ちる試験を書く** + +`test/browser/services/PlateauService.test.js` の 777 行目から 820 行目、つまり `// 候補が出ない理由を利用者に伝える。` で始まるコメントから `it('tells the user again after the layer is switched on and off', ...)` の終わりまでを、次の内容で置き換えます。 + +```js + // 候補を伏せた理由がレイヤーの消灯であることを、一度だけ知らせる。 + // 画面に何を出すかは `UiPlateauOsmLayerOffDialog` が決める。 + function countOsmLayerOff(service) { + const seen = { count: 0 }; + service.on('osmlayeroff', () => { seen.count++; }); + return seen; + } + + it('announces the switched-off OSM layer', () => { + const seen = countOsmLayerOff(_service); + setOsmState(_service, { layerEnabled: false }); + _service.getData('ds1'); + expect(seen.count).to.equal(1); + }); + + it('announces it only once while the layer stays switched off', () => { + const seen = countOsmLayerOff(_service); + setOsmState(_service, { layerEnabled: false }); + _service.getData('ds1'); + _service.getData('ds1'); + expect(seen.count).to.equal(1, '同じ状態で何度も知らせない'); + }); + + it('does not announce it again after the layer is switched on and off', () => { + const seen = countOsmLayerOff(_service); + setOsmState(_service, { layerEnabled: false }); + _service.getData('ds1'); + setOsmState(_service, { layerEnabled: true }); + _service.getData('ds1'); + setOsmState(_service, { layerEnabled: false }); + _service.getData('ds1'); + expect(seen.count).to.equal(1, 'ページを開き直すまでは 1 回だけ'); + }); + + it('stays quiet while the tiles are still loading', () => { + const seen = countOsmLayerOff(_service); + setOsmState(_service, { tilesLoaded: false }); + _service.getData('ds1'); + expect(seen.count).to.equal(0); + }); +``` + +`_service` は一番外側の `beforeEach`(27 行目)で毎回作り直されるので、`on()` で足した受け取り口と `_osmLayerOffNotified` は試験ごとに初期化されます。 + +- [ ] **手順 2: 落ちることを確かめる** + +実行: + +```bash +cd /Users/nyampire/git/Rapid && npm run build && npm run dist && npx karma start karma.conf.cjs --single-run +``` + +期待する結果: `announces it only once while the layer stays switched off` と `does not announce it again after the layer is switched on and off` が失敗します。前者は `osmlayeroff` を誰も発生させないため `0` になり、後者も `0` になります。 + +- [ ] **手順 3: 出来事を発生させる** + +`modules/services/PlateauService.js` の 46 行目から 47 行目を次のように書き換えます。 + +変更前: + +```js + // OSM のレイヤーが消えている件を伝えたかどうか。レイヤーが戻ると false に戻す。 + this._osmLayerOffNotified = false; +``` + +変更後: + +```js + // OSM のレイヤーが消えている件を知らせたかどうか。ページを開き直すまで戻さない。 + this._osmLayerOffNotified = false; +``` + +次に `getData()` の中(340 行目付近)を書き換えます。 + +変更前: + +```js + const missing = this._osmDataMissing(); + if (missing) { + if (missing === 'layer-off') this._notifyOsmLayerOff(); + return []; + } +``` + +変更後: + +```js + const missing = this._osmDataMissing(); + if (missing) { + if (missing === 'layer-off' && !this._osmLayerOffNotified) { + this._osmLayerOffNotified = true; + this.emit('osmlayeroff'); + } + return []; + } +``` + +次に `_osmDataMissing()` の中から、印を戻す 1 行を削除します。 + +変更前: + +```js + const layer = this.context.systems.gfx?.scene?.layers?.get('osm'); + if (layer && layer.enabled === false) return 'layer-off'; + this._osmLayerOffNotified = false; +``` + +変更後: + +```js + const layer = this.context.systems.gfx?.scene?.layers?.get('osm'); + if (layer && layer.enabled === false) return 'layer-off'; +``` + +最後に `_notifyOsmLayerOff()` を、その直前の説明コメントごと削除します。削除する範囲は次のとおりです。 + +```js + /** + * _notifyOsmLayerOff + * 候補が出ない理由を利用者に伝える。 + * レイヤーが消えたままなのは利用者が直せる状態なので伝える。 + * タイルの取得は待てば終わるので伝えない。 + * 同じ状態が続くあいだは一度だけ出し、レイヤーが戻ったときに出し直せるようにする。 + */ + _notifyOsmLayerOff() { + if (this._osmLayerOffNotified) return; + this._osmLayerOffNotified = true; + + const flash = this.context.systems.ui?.Flash; + if (typeof flash !== 'function') return; + + const l10n = this.context.systems.l10n; + const key = 'plateau_conflation.osm_layer_off'; + flash.duration(5000).label(l10n ? l10n.t(key) : key); + flash(); + } +``` + +- [ ] **手順 4: 通ることを確かめる** + +実行: + +```bash +cd /Users/nyampire/git/Rapid && npm run build && npm run dist && npx karma start karma.conf.cjs --single-run +``` + +期待する結果: 771 件成功、5 件スキップ、失敗 0 件。試験の数は、消した 3 件と足した 4 件の差で 1 件増えます。 + +- [ ] **手順 5: 記録する** + +```bash +cd /Users/nyampire/git/Rapid +git add modules/services/PlateauService.js test/browser/services/PlateauService.test.js +git commit -m "$(cat <<'EOF' +refactor(plateau): レイヤー消灯の知らせを出来事に変える + +画面下端の通知を出す処理を削除し、候補を伏せた理由がレイヤーの消灯で +あることを osmlayeroff として 1 回だけ発生させる。何を画面に出すかは +受け取る側が決める。 + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 2: ダイアログを出す部品を作る + +**ファイル:** +- 作成: `modules/ui/UiPlateauOsmLayerOffDialog.js` +- 変更: `modules/ui/index.js`(55 行目の次) +- 変更: `data/core.yaml`(28 行目の次) +- 変更: `data/l10n/core.en.json`(31 行目) +- 変更: `data/l10n/core.ja.json`(2966 行目) +- 試験: `test/browser/ui/UiPlateauOsmLayerOffDialog.js`(作成) + +**やり取りする名前:** +- 使うもの: Task 1 の `osmlayeroff`。`context.services.plateau.on('osmlayeroff', fn)` で受け取ります。 +- 提供するもの: `UiPlateauOsmLayerOffDialog` という class。`new UiPlateauOsmLayerOffDialog(context)` で組み立て、`show()` を持ちます。`show()` はダイアログの d3 選択を返すか、出さなかった場合は `undefined` を返します。 + +- [ ] **手順 1: 文言を足す** + +`data/core.yaml` の 28 行目の次に、2 行を足します。足したあとは次の形になります。 + +```yaml + plateau_conflation: + osm_layer_off: "Plateau buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on." + osm_layer_off_title: PLATEAU buildings are hidden + dont_show_again: Do not show this again +``` + +`data/l10n/core.en.json` の 30 行目から 32 行目を、次の形にします。 + +```json + "plateau_conflation": { + "osm_layer_off": "Plateau buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on.", + "osm_layer_off_title": "PLATEAU buildings are hidden", + "dont_show_again": "Do not show this again" + }, +``` + +`data/l10n/core.ja.json` の 2965 行目から 2967 行目を、次の形にします。 + +```json + "plateau_conflation": { + "osm_layer_off": "OpenStreetMap のデータのレイヤーが消えているあいだは、Plateau の建物を表示しません。既存の建物と重なるかを確かめられないためです。Shift+O で戻せます。", + "osm_layer_off_title": "PLATEAU の建物を表示していません", + "dont_show_again": "次から表示しない" + }, +``` + +- [ ] **手順 2: 落ちる試験を書く** + +`test/browser/ui/UiPlateauOsmLayerOffDialog.js` を作り、次の内容を書きます。 + +```js +describe('UiPlateauOsmLayerOffDialog', () => { + const HIDDEN_KEY = 'plateau.osm-layer-off-dialog.hidden'; + let elem; + + class MockLocalizationSystem { + constructor() { } + initAsync() { return Promise.resolve(); } + t(id) { return id; } + tHtml(id) { return id; } + } + + class MockStorageSystem { + constructor() { this._map = new Map(); } + getItem(k) { return this._map.has(k) ? this._map.get(k) : null; } + setItem(k, v) { this._map.set(k, v); return true; } + removeItem(k) { this._map.delete(k); } + } + + class MockPlateauService { + constructor() { this._handlers = new Map(); } + on(type, fn) { + if (!this._handlers.has(type)) this._handlers.set(type, []); + this._handlers.get(type).push(fn); + return this; + } + emit(type) { + for (const fn of this._handlers.get(type) ?? []) fn(); + } + } + + class MockContext { + constructor() { + this.systems = { + l10n: new MockLocalizationSystem(), + storage: new MockStorageSystem() + }; + this.services = { plateau: new MockPlateauService() }; + } + container() { return elem; } + } + + let context; + + beforeEach(() => { + elem = d3.select('body') + .append('div') + .attr('class', 'plateau-dialog-wrap'); + context = new MockContext(); + }); + + afterEach(() => { + d3.select('.plateau-dialog-wrap').remove(); + d3.selectAll('.shaded').remove(); + }); + + + it('opens the dialog when the service announces the switched-off layer', () => { + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal').size()).to.equal(1); + }); + + it('shows the title, the reason and the checkbox', () => { + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal-section.header h3').text()) + .to.equal('plateau_conflation.osm_layer_off_title'); + expect(elem.selectAll('.modal-section.message-text p').text()) + .to.equal('plateau_conflation.osm_layer_off'); + expect(elem.selectAll('.plateau-dont-show-again input').size()).to.equal(1); + }); + + it('does not open the dialog when the user asked not to see it', () => { + context.systems.storage.setItem(HIDDEN_KEY, 'true'); + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal').size()).to.equal(0); + }); + + it('remembers the choice when the checkbox is ticked', () => { + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + const node = elem.select('.plateau-dont-show-again input').node(); + node.checked = true; + happen.once(node, { type: 'change' }); + expect(context.systems.storage.getItem(HIDDEN_KEY)).to.equal('true'); + }); + + it('forgets the choice when the checkbox is unticked', () => { + context.systems.storage.setItem(HIDDEN_KEY, 'true'); + const dialog = new Rapid.UiPlateauOsmLayerOffDialog(context); + dialog.show({ force: true }); + const node = elem.select('.plateau-dont-show-again input').node(); + node.checked = false; + happen.once(node, { type: 'change' }); + expect(context.systems.storage.getItem(HIDDEN_KEY)).to.be.null; + }); +}); +``` + +最後の試験だけ `show({ force: true })` を直接呼びます。保存された印がある状態で出来事を発生させてもダイアログが開かないため、チェックを外す操作を試せないからです。 + +- [ ] **手順 3: 落ちることを確かめる** + +実行: + +```bash +cd /Users/nyampire/git/Rapid && npm run build && npm run dist && npx karma start karma.conf.cjs --single-run +``` + +期待する結果: `UiPlateauOsmLayerOffDialog` の 5 件がすべて失敗します。理由は `Rapid.UiPlateauOsmLayerOffDialog is not a constructor` です。 + +- [ ] **手順 4: 部品を作る** + +`modules/ui/UiPlateauOsmLayerOffDialog.js` を作り、次の内容を書きます。 + +```js +import { uiConfirm } from './confirm.js'; + +const HIDDEN_KEY = 'plateau.osm-layer-off-dialog.hidden'; + + +/** + * UiPlateauOsmLayerOffDialog + * OSM のデータのレイヤーが消えているために PLATEAU の候補を伏せたことを、 + * 画面中央のダイアログで伝える。 + * + * 出すかどうかの判断は `PlateauService` が持ち、この部品は `osmlayeroff` を + * 受け取るだけ。`PlateauService` はページを開き直すまでに 1 回しか発生させない。 + * + * 「次から表示しない」を選んだ利用者には、以後まったく出さない。 + */ +export class UiPlateauOsmLayerOffDialog { + + /** + * @constructor + * @param `context` Global shared application context + */ + constructor(context) { + this.context = context; + + // Ensure methods used as callbacks always have `this` bound correctly. + this.show = this.show.bind(this); + + const plateau = context.services?.plateau; + if (plateau) { + plateau.on('osmlayeroff', this.show); + } + } + + + /** + * show + * ダイアログを開く。 + * @param {Object} options? + * @param {boolean} options.force? true なら保存された印を無視して開く + * @return {d3-selection?} 開いたダイアログ。開かなかった場合は undefined + */ + show(options = {}) { + const context = this.context; + const storage = context.systems.storage; + const l10n = context.systems.l10n; + + if (!options.force && storage?.getItem(HIDDEN_KEY) === 'true') return; + + const $modal = uiConfirm(context, context.container()); + + $modal.select('.modal-section.header') + .append('h3') + .text(l10n.t('plateau_conflation.osm_layer_off_title')); + + const $message = $modal.select('.modal-section.message-text'); + + $message + .append('p') + .text(l10n.t('plateau_conflation.osm_layer_off')); + + const $label = $message + .append('label') + .attr('class', 'plateau-dont-show-again'); + + $label + .append('input') + .attr('type', 'checkbox') + .on('change', function() { + if (this.checked) { + storage?.setItem(HIDDEN_KEY, 'true'); + } else { + storage?.removeItem(HIDDEN_KEY); + } + }); + + $label + .append('span') + .text(l10n.t('plateau_conflation.dont_show_again')); + + $modal.okButton(); + + return $modal; + } + +} +``` + +`modules/ui/index.js` の 55 行目(`export { UiPhotoViewer } ...`)の次に、次の 1 行を足します。 + +```js +export { UiPlateauOsmLayerOffDialog } from './UiPlateauOsmLayerOffDialog.js'; +``` + +- [ ] **手順 5: 通ることを確かめる** + +実行: + +```bash +cd /Users/nyampire/git/Rapid && npm run build && npm run dist && npx karma start karma.conf.cjs --single-run +``` + +期待する結果: 776 件成功、5 件スキップ、失敗 0 件。Task 1 の 771 件に、この課題の 5 件が足されます。 + +- [ ] **手順 6: 記録する** + +```bash +cd /Users/nyampire/git/Rapid +git add modules/ui/UiPlateauOsmLayerOffDialog.js modules/ui/index.js \ + data/core.yaml data/l10n/core.en.json data/l10n/core.ja.json \ + test/browser/ui/UiPlateauOsmLayerOffDialog.js +git commit -m "$(cat <<'EOF' +feat(plateau): レイヤー消灯の理由をダイアログで出す部品を足す + +osmlayeroff を受け取り、見出しと理由と「次から表示しない」を持つ +ダイアログを開く。印は StorageSystem に保存し、選ばれていれば開かない。 + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 3: 部品を組み立てて、画面でつながるようにする + +**ファイル:** +- 変更: `modules/core/UiSystem.js`(8 行目から 11 行目の読み込み、100 行目付近の組み立て) + +**やり取りする名前:** +- 使うもの: Task 2 の `UiPlateauOsmLayerOffDialog`。 +- 提供するもの: `context.systems.ui.PlateauOsmLayerOffDialog`。他の場所からは参照しません。 + +- [ ] **手順 1: 読み込みに足す** + +`modules/core/UiSystem.js` の 7 行目から 11 行目を、次の形にします。 + +変更前: + +```js +import { + UiApiStatus, UiDefs, uiEditMenu, uiFlash, UiFullscreen, uiIntro, + uiLoading, UiMapFooter, UiMapToolbar, uiMapRouletteMenu, UiOvermap, + uiSplash, uiRestore, UiShortcuts, UiSidebar, uiWhatsNew +} from '../ui/index.js'; +``` + +変更後: + +```js +import { + UiApiStatus, UiDefs, uiEditMenu, uiFlash, UiFullscreen, uiIntro, + uiLoading, UiMapFooter, UiMapToolbar, uiMapRouletteMenu, UiOvermap, + UiPlateauOsmLayerOffDialog, uiSplash, uiRestore, UiShortcuts, UiSidebar, + uiWhatsNew +} from '../ui/index.js'; +``` + +- [ ] **手順 2: 組み立てに足す** + +`modules/core/UiSystem.js` の `this.Overmap = new UiOvermap(context);` の次の行に、1 行を足します。足したあとは次の形になります。 + +```js + this.Overmap = new UiOvermap(context); + this.PlateauOsmLayerOffDialog = new UiPlateauOsmLayerOffDialog(context); + this.Shortcuts = new UiShortcuts(context); +``` + +描画処理には手を入れません。この部品は出来事を受け取ったときだけ画面に要素を足します。 + +- [ ] **手順 3: 静的検査と試験を通す** + +実行: + +```bash +cd /Users/nyampire/git/Rapid && npm run lint && npm run build && npm run dist && npx karma start karma.conf.cjs --single-run +``` + +期待する結果: `npm run lint` はエラー 0 件です。警告は起点と同じ 42 件で、すべて元からある todo コメントです。試験は 776 件成功、5 件スキップ、失敗 0 件です。 + +- [ ] **手順 4: 画面で確かめる** + +リポジトリに入っている開発用サーバを起動します。`scripts/server.js` がリポジトリの根をポート 8080 で配信します。 + +```bash +cd /Users/nyampire/git/Rapid && npm run start:server +``` + +ブラウザで `http://localhost:8080/dist/#map=17.50/35.64780/139.70200` を開き、次の 3 点を確かめます。 + +1. `Shift` + `O` で OSM のデータのレイヤーを消すと、ダイアログが 1 回出ること +2. OK で閉じたあと、もう一度 `Shift` + `O` を 2 回押しても、ダイアログが出ないこと +3. 「次から表示しない」を選んで閉じ、ページを開き直してから `Shift` + `O` を押しても、ダイアログが出ないこと + +3 を試したあとは、開発者コンソールで印を消して元に戻します。 + +```js +localStorage.removeItem('plateau.osm-layer-off-dialog.hidden'); +``` + +この確認は自動の試験では代えられません。`PlateauService` と画面部品のつなぎ込みが実際に動くかは、組み立てた `UiSystem` の上でしか分からないためです。 + +- [ ] **手順 5: 記録する** + +```bash +cd /Users/nyampire/git/Rapid +git add modules/core/UiSystem.js +git commit -m "$(cat <<'EOF' +feat(plateau): レイヤー消灯のダイアログを画面に組み込む + +UiSystem で 1 度だけ組み立てる。描画処理には手を入れない。 + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +## 完了の条件 + +- `npm run lint` がエラー 0 件で通ること +- 試験が 776 件成功、5 件スキップ、失敗 0 件であること +- Task 3 手順 4 の 3 点が画面で確かめられていること +- `_notifyOsmLayerOff` がコードのどこにも残っていないこと(`grep -rn "_notifyOsmLayerOff" modules test` が 0 件) From 0b9fb405a7ba832018e726f63027a3de622fcfff Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 9 Sep 2026 10:29:09 +0900 Subject: [PATCH 3/9] =?UTF-8?q?docs(plateau):=20=E8=A9=A6=E9=A8=93?= =?UTF-8?q?=E3=81=AE=E3=81=9F=E3=82=81=E3=81=A0=E3=81=91=E3=81=AE=E5=BC=95?= =?UTF-8?q?=E6=95=B0=E3=82=92=E8=A8=88=E7=94=BB=E3=81=8B=E3=82=89=E5=A4=96?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show() の force は製品の動作では使わない。ダイアログの中でチェックを 入れてから外せば同じ分岐を通るので、引数なしで試験できる。 Co-Authored-By: Claude Opus 5 --- ...2026-09-08-plateau-osm-layer-off-dialog.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md b/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md index 3b3045ad3..64ff5a391 100644 --- a/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md +++ b/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md @@ -216,7 +216,7 @@ EOF **やり取りする名前:** - 使うもの: Task 1 の `osmlayeroff`。`context.services.plateau.on('osmlayeroff', fn)` で受け取ります。 -- 提供するもの: `UiPlateauOsmLayerOffDialog` という class。`new UiPlateauOsmLayerOffDialog(context)` で組み立て、`show()` を持ちます。`show()` はダイアログの d3 選択を返すか、出さなかった場合は `undefined` を返します。 +- 提供するもの: `UiPlateauOsmLayerOffDialog` という class。`new UiPlateauOsmLayerOffDialog(context)` で組み立て、引数を取らない `show()` を持ちます。`show()` はダイアログの d3 選択を返すか、出さなかった場合は `undefined` を返します。 - [ ] **手順 1: 文言を足す** @@ -343,10 +343,11 @@ describe('UiPlateauOsmLayerOffDialog', () => { }); it('forgets the choice when the checkbox is unticked', () => { - context.systems.storage.setItem(HIDDEN_KEY, 'true'); - const dialog = new Rapid.UiPlateauOsmLayerOffDialog(context); - dialog.show({ force: true }); + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); const node = elem.select('.plateau-dont-show-again input').node(); + node.checked = true; + happen.once(node, { type: 'change' }); node.checked = false; happen.once(node, { type: 'change' }); expect(context.systems.storage.getItem(HIDDEN_KEY)).to.be.null; @@ -354,7 +355,8 @@ describe('UiPlateauOsmLayerOffDialog', () => { }); ``` -最後の試験だけ `show({ force: true })` を直接呼びます。保存された印がある状態で出来事を発生させてもダイアログが開かないため、チェックを外す操作を試せないからです。 +最後の試験は、開いたダイアログの中でチェックを入れてから外します。 +利用者も同じ操作ができるので、試験のためだけの引数は要りません。 - [ ] **手順 3: 落ちることを確かめる** @@ -407,17 +409,15 @@ export class UiPlateauOsmLayerOffDialog { /** * show - * ダイアログを開く。 - * @param {Object} options? - * @param {boolean} options.force? true なら保存された印を無視して開く + * ダイアログを開く。「次から表示しない」を選んだ利用者には開かない。 * @return {d3-selection?} 開いたダイアログ。開かなかった場合は undefined */ - show(options = {}) { + show() { const context = this.context; const storage = context.systems.storage; const l10n = context.systems.l10n; - if (!options.force && storage?.getItem(HIDDEN_KEY) === 'true') return; + if (storage?.getItem(HIDDEN_KEY) === 'true') return; const $modal = uiConfirm(context, context.container()); From 0619cead9450ec84ca0668881ff0107f09120524 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 9 Sep 2026 10:34:11 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor(plateau):=20=E3=83=AC=E3=82=A4?= =?UTF-8?q?=E3=83=A4=E3=83=BC=E6=B6=88=E7=81=AF=E3=81=AE=E7=9F=A5=E3=82=89?= =?UTF-8?q?=E3=81=9B=E3=82=92=E5=87=BA=E6=9D=A5=E4=BA=8B=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面下端の通知を出す処理を削除し、候補を伏せた理由がレイヤーの消灯で あることを osmlayeroff として 1 回だけ発生させる。何を画面に出すかは 受け取る側が決める。 Co-Authored-By: Claude Opus 5 --- modules/services/PlateauService.js | 29 ++--------- test/browser/services/PlateauService.test.js | 52 +++++++++----------- 2 files changed, 29 insertions(+), 52 deletions(-) diff --git a/modules/services/PlateauService.js b/modules/services/PlateauService.js index 20c0509b5..fe083865e 100644 --- a/modules/services/PlateauService.js +++ b/modules/services/PlateauService.js @@ -48,7 +48,7 @@ export class PlateauService extends AbstractSystem { rejected: new Set() // Set(entityID) - overlapping with OSM }; - // OSM のレイヤーが消えている件を伝えたかどうか。レイヤーが戻ると false に戻す。 + // OSM のレイヤーが消えている件を知らせたかどうか。ページを開き直すまで戻さない。 this._osmLayerOffNotified = false; // Cache for coverage area GeoJSON (loaded once, used by PixiLayerPlateauCoverage) @@ -342,7 +342,10 @@ export class PlateauService extends AbstractSystem { if (useConflationStr !== 'false' && useConflationStr !== 'no') { const missing = this._osmDataMissing(); if (missing) { - if (missing === 'layer-off') this._notifyOsmLayerOff(); + if (missing === 'layer-off' && !this._osmLayerOffNotified) { + this._osmLayerOffNotified = true; + this.emit('osmlayeroff'); + } return []; } entities = this._filterPlateauOverlaps(entities, ds.graph); @@ -368,7 +371,6 @@ export class PlateauService extends AbstractSystem { _osmDataMissing() { const layer = this.context.systems.gfx?.scene?.layers?.get('osm'); if (layer && layer.enabled === false) return 'layer-off'; - this._osmLayerOffNotified = false; // タイルの取得に失敗したまま再取得されない経路もあるため、取得済みかどうかも見る。 // 取得済みの一覧は上流のファイルの持ち物で、上流を取り込んだときに形が変わりうる。 @@ -383,27 +385,6 @@ export class PlateauService extends AbstractSystem { } - /** - * _notifyOsmLayerOff - * 候補が出ない理由を利用者に伝える。 - * レイヤーが消えたままなのは利用者が直せる状態なので伝える。 - * タイルの取得は待てば終わるので伝えない。 - * 同じ状態が続くあいだは一度だけ出し、レイヤーが戻ったときに出し直せるようにする。 - */ - _notifyOsmLayerOff() { - if (this._osmLayerOffNotified) return; - this._osmLayerOffNotified = true; - - const flash = this.context.systems.ui?.Flash; - if (typeof flash !== 'function') return; - - const l10n = this.context.systems.l10n; - const key = 'plateau_conflation.osm_layer_off'; - flash.duration(5000).label(l10n ? l10n.t(key) : key); - flash(); - } - - /** * loadTiles * Schedule any data requests needed to cover the current map view diff --git a/test/browser/services/PlateauService.test.js b/test/browser/services/PlateauService.test.js index 99306347f..16d3038c5 100644 --- a/test/browser/services/PlateauService.test.js +++ b/test/browser/services/PlateauService.test.js @@ -774,49 +774,45 @@ describe('PlateauService', () => { expect(ways).to.have.lengthOf(1); }); - // 候補が出ない理由を利用者に伝える。 - // レイヤーを消したままなのは利用者が直せる状態なので伝える。 - // タイルの取得は待てば終わるので伝えない。 - function mockFlash() { - const f = () => { f.calls.push(f._label); return f; }; - f.calls = []; - f.duration = () => f; - f.label = (t) => { f._label = t; return f; }; - return f; + // 候補を伏せた理由がレイヤーの消灯であることを、一度だけ知らせる。 + // 画面に何を出すかは `UiPlateauOsmLayerOffDialog` が決める。 + function countOsmLayerOff(service) { + const seen = { count: 0 }; + service.on('osmlayeroff', () => { seen.count++; }); + return seen; } - function withUi(service) { - const flash = mockFlash(); - service.context.systems.ui = { Flash: flash }; - service.context.systems.l10n = { t: (k) => k }; - return flash; - } - - it('tells the user once while the OSM layer stays switched off', () => { - const flash = withUi(_service); + it('announces the switched-off OSM layer', () => { + const seen = countOsmLayerOff(_service); setOsmState(_service, { layerEnabled: false }); _service.getData('ds1'); - _service.getData('ds1'); - expect(flash.calls).to.have.lengthOf(1, '同じ状態で何度も出さない'); - expect(flash.calls[0]).to.equal('plateau_conflation.osm_layer_off'); + expect(seen.count).to.equal(1); }); - it('stays quiet while the tiles are still loading', () => { - const flash = withUi(_service); - setOsmState(_service, { tilesLoaded: false }); + it('announces it only once while the layer stays switched off', () => { + const seen = countOsmLayerOff(_service); + setOsmState(_service, { layerEnabled: false }); _service.getData('ds1'); - expect(flash.calls).to.have.lengthOf(0); + _service.getData('ds1'); + expect(seen.count).to.equal(1, '同じ状態で何度も知らせない'); }); - it('tells the user again after the layer is switched on and off', () => { - const flash = withUi(_service); + it('does not announce it again after the layer is switched on and off', () => { + const seen = countOsmLayerOff(_service); setOsmState(_service, { layerEnabled: false }); _service.getData('ds1'); setOsmState(_service, { layerEnabled: true }); _service.getData('ds1'); setOsmState(_service, { layerEnabled: false }); _service.getData('ds1'); - expect(flash.calls).to.have.lengthOf(2); + expect(seen.count).to.equal(1, 'ページを開き直すまでは 1 回だけ'); + }); + + it('stays quiet while the tiles are still loading', () => { + const seen = countOsmLayerOff(_service); + setOsmState(_service, { tilesLoaded: false }); + _service.getData('ds1'); + expect(seen.count).to.equal(0); }); }); From 29a66e69780789a8bb6caffe1067466aa81870b6 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 9 Sep 2026 10:42:46 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat(plateau):=20=E3=83=AC=E3=82=A4?= =?UTF-8?q?=E3=83=A4=E3=83=BC=E6=B6=88=E7=81=AF=E3=81=AE=E7=90=86=E7=94=B1?= =?UTF-8?q?=E3=82=92=E3=83=80=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=A7?= =?UTF-8?q?=E5=87=BA=E3=81=99=E9=83=A8=E5=93=81=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit osmlayeroff を受け取り、見出しと理由と「次から表示しない」を持つ ダイアログを開く。印は StorageSystem に保存し、選ばれていれば開かない。 Co-Authored-By: Claude Opus 5 --- data/core.yaml | 2 + data/l10n/core.en.json | 4 +- data/l10n/core.ja.json | 4 +- modules/ui/UiPlateauOsmLayerOffDialog.js | 83 ++++++++++++++++ modules/ui/index.js | 1 + test/browser/ui/UiPlateauOsmLayerOffDialog.js | 99 +++++++++++++++++++ 6 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 modules/ui/UiPlateauOsmLayerOffDialog.js create mode 100644 test/browser/ui/UiPlateauOsmLayerOffDialog.js diff --git a/data/core.yaml b/data/core.yaml index 937f885ca..0cb08f93d 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -26,6 +26,8 @@ en: zoom_overview_tooltip: Snap to zoom 14 so tiles stop loading and panning stays fast.
Buildings render from z16. plateau_conflation: osm_layer_off: "Plateau buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on." + osm_layer_off_title: PLATEAU buildings are hidden + dont_show_again: Do not show this again height_transfer: section_title: Plateau tags additions: Add from Plateau diff --git a/data/l10n/core.en.json b/data/l10n/core.en.json index 1ab7ea448..05095ff78 100644 --- a/data/l10n/core.en.json +++ b/data/l10n/core.en.json @@ -28,7 +28,9 @@ "zoom_overview_tooltip": "Snap to zoom 14 so tiles stop loading and panning stays fast.
Buildings render from z16." }, "plateau_conflation": { - "osm_layer_off": "Plateau buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on." + "osm_layer_off": "Plateau buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on.", + "osm_layer_off_title": "PLATEAU buildings are hidden", + "dont_show_again": "Do not show this again" }, "height_transfer": { "section_title": "Plateau tags", diff --git a/data/l10n/core.ja.json b/data/l10n/core.ja.json index 7da163cab..e13cfcb1f 100644 --- a/data/l10n/core.ja.json +++ b/data/l10n/core.ja.json @@ -2963,7 +2963,9 @@ "hires": "高解像度" }, "plateau_conflation": { - "osm_layer_off": "OpenStreetMap のデータのレイヤーが消えているあいだは、Plateau の建物を表示しません。既存の建物と重なるかを確かめられないためです。Shift+O で戻せます。" + "osm_layer_off": "OpenStreetMap のデータのレイヤーが消えているあいだは、Plateau の建物を表示しません。既存の建物と重なるかを確かめられないためです。Shift+O で戻せます。", + "osm_layer_off_title": "PLATEAU の建物を表示していません", + "dont_show_again": "次から表示しない" }, "preferences": { "color_selection": { diff --git a/modules/ui/UiPlateauOsmLayerOffDialog.js b/modules/ui/UiPlateauOsmLayerOffDialog.js new file mode 100644 index 000000000..7e1e80b6c --- /dev/null +++ b/modules/ui/UiPlateauOsmLayerOffDialog.js @@ -0,0 +1,83 @@ +import { uiConfirm } from './confirm.js'; + +const HIDDEN_KEY = 'plateau.osm-layer-off-dialog.hidden'; + + +/** + * UiPlateauOsmLayerOffDialog + * OSM のデータのレイヤーが消えているために PLATEAU の候補を伏せたことを、 + * 画面中央のダイアログで伝える。 + * + * 出すかどうかの判断は `PlateauService` が持ち、この部品は `osmlayeroff` を + * 受け取るだけ。`PlateauService` はページを開き直すまでに 1 回しか発生させない。 + * + * 「次から表示しない」を選んだ利用者には、以後まったく出さない。 + */ +export class UiPlateauOsmLayerOffDialog { + + /** + * @constructor + * @param `context` Global shared application context + */ + constructor(context) { + this.context = context; + + // Ensure methods used as callbacks always have `this` bound correctly. + this.show = this.show.bind(this); + + const plateau = context.services?.plateau; + if (plateau) { + plateau.on('osmlayeroff', this.show); + } + } + + + /** + * show + * ダイアログを開く。「次から表示しない」を選んだ利用者には開かない。 + * @return {d3-selection?} 開いたダイアログ。開かなかった場合は undefined + */ + show() { + const context = this.context; + const storage = context.systems.storage; + const l10n = context.systems.l10n; + + if (storage?.getItem(HIDDEN_KEY) === 'true') return; + + const $modal = uiConfirm(context, context.container()); + + $modal.select('.modal-section.header') + .append('h3') + .text(l10n.t('plateau_conflation.osm_layer_off_title')); + + const $message = $modal.select('.modal-section.message-text'); + + $message + .append('p') + .text(l10n.t('plateau_conflation.osm_layer_off')); + + const $label = $message + .append('label') + .attr('class', 'plateau-dont-show-again'); + + $label + .append('input') + .attr('type', 'checkbox') + .on('change', function() { + if (this.checked) { + storage?.setItem(HIDDEN_KEY, 'true'); + } else { + storage?.removeItem(HIDDEN_KEY); + } + }); + + $label + .append('span') + .text(l10n.t('plateau_conflation.dont_show_again')); + + $modal.okButton(); + + return $modal; + } + +} diff --git a/modules/ui/index.js b/modules/ui/index.js index 76144e1d0..9dfb20070 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -53,6 +53,7 @@ export { uiOsmoseHeader } from './osmose_header.js'; export { UiOvermap } from './UiOvermap.js'; export { uiPane } from './pane.js'; export { UiPhotoViewer } from './UiPhotoViewer.js'; +export { UiPlateauOsmLayerOffDialog } from './UiPlateauOsmLayerOffDialog.js'; export { uiPopover } from './popover.js'; export { uiPresetIcon } from './preset_icon.js'; export { uiPresetList } from './preset_list.js'; diff --git a/test/browser/ui/UiPlateauOsmLayerOffDialog.js b/test/browser/ui/UiPlateauOsmLayerOffDialog.js new file mode 100644 index 000000000..a97242ca0 --- /dev/null +++ b/test/browser/ui/UiPlateauOsmLayerOffDialog.js @@ -0,0 +1,99 @@ +describe('UiPlateauOsmLayerOffDialog', () => { + const HIDDEN_KEY = 'plateau.osm-layer-off-dialog.hidden'; + let elem; + + class MockLocalizationSystem { + constructor() { } + initAsync() { return Promise.resolve(); } + t(id) { return id; } + tHtml(id) { return id; } + } + + class MockStorageSystem { + constructor() { this._map = new Map(); } + getItem(k) { return this._map.has(k) ? this._map.get(k) : null; } + setItem(k, v) { this._map.set(k, v); return true; } + removeItem(k) { this._map.delete(k); } + } + + class MockPlateauService { + constructor() { this._handlers = new Map(); } + on(type, fn) { + if (!this._handlers.has(type)) this._handlers.set(type, []); + this._handlers.get(type).push(fn); + return this; + } + emit(type) { + for (const fn of this._handlers.get(type) ?? []) fn(); + } + } + + class MockContext { + constructor() { + this.systems = { + l10n: new MockLocalizationSystem(), + storage: new MockStorageSystem() + }; + this.services = { plateau: new MockPlateauService() }; + } + container() { return elem; } + } + + let context; + + beforeEach(() => { + elem = d3.select('body') + .append('div') + .attr('class', 'plateau-dialog-wrap'); + context = new MockContext(); + }); + + afterEach(() => { + d3.select('.plateau-dialog-wrap').remove(); + d3.selectAll('.shaded').remove(); + }); + + + it('opens the dialog when the service announces the switched-off layer', () => { + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal').size()).to.equal(1); + }); + + it('shows the title, the reason and the checkbox', () => { + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal-section.header h3').text()) + .to.equal('plateau_conflation.osm_layer_off_title'); + expect(elem.selectAll('.modal-section.message-text p').text()) + .to.equal('plateau_conflation.osm_layer_off'); + expect(elem.selectAll('.plateau-dont-show-again input').size()).to.equal(1); + }); + + it('does not open the dialog when the user asked not to see it', () => { + context.systems.storage.setItem(HIDDEN_KEY, 'true'); + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal').size()).to.equal(0); + }); + + it('remembers the choice when the checkbox is ticked', () => { + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + const node = elem.select('.plateau-dont-show-again input').node(); + node.checked = true; + happen.once(node, { type: 'change' }); + expect(context.systems.storage.getItem(HIDDEN_KEY)).to.equal('true'); + }); + + it('forgets the choice when the checkbox is unticked', () => { + new Rapid.UiPlateauOsmLayerOffDialog(context); + context.services.plateau.emit('osmlayeroff'); + const node = elem.select('.plateau-dont-show-again input').node(); + node.checked = true; + happen.once(node, { type: 'change' }); + node.checked = false; + happen.once(node, { type: 'change' }); + expect(context.systems.storage.getItem(HIDDEN_KEY)).to.be.null; + }); +}); From 9b1e33dbea030991c83257283db3e82456eb32b1 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 9 Sep 2026 10:49:33 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(plateau):=20=E3=83=AC=E3=82=A4?= =?UTF-8?q?=E3=83=A4=E3=83=BC=E6=B6=88=E7=81=AF=E3=81=AE=E3=83=80=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=83=AD=E3=82=B0=E3=82=92=E7=94=BB=E9=9D=A2=E3=81=AB?= =?UTF-8?q?=E7=B5=84=E3=81=BF=E8=BE=BC=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiSystem で 1 度だけ組み立てる。描画処理には手を入れない。 Co-Authored-By: Claude Opus 5 --- modules/core/UiSystem.js | 5 ++++- test/browser/ui/UiPlateauOsmLayerOffDialog.js | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/core/UiSystem.js b/modules/core/UiSystem.js index 9da9b7e8b..1bd971fcd 100644 --- a/modules/core/UiSystem.js +++ b/modules/core/UiSystem.js @@ -7,7 +7,8 @@ import { utilDetect } from '../util/detect.js'; import { UiApiStatus, UiDefs, uiEditMenu, uiFlash, UiFullscreen, uiIntro, uiLoading, UiMapFooter, UiMapToolbar, uiMapRouletteMenu, UiOvermap, - uiSplash, uiRestore, UiShortcuts, UiSidebar, uiWhatsNew + UiPlateauOsmLayerOffDialog, uiSplash, uiRestore, UiShortcuts, UiSidebar, + uiWhatsNew } from '../ui/index.js'; @@ -45,6 +46,7 @@ export class UiSystem extends AbstractSystem { this.MapFooter = null; this.MapToolbar = null; this.Overmap = null; + this.PlateauOsmLayerOffDialog = null; this.Shortcuts = null; this.Sidebar = null; @@ -107,6 +109,7 @@ export class UiSystem extends AbstractSystem { this.MapFooter = new UiMapFooter(context); this.MapToolbar = new UiMapToolbar(context); this.Overmap = new UiOvermap(context); + this.PlateauOsmLayerOffDialog = new UiPlateauOsmLayerOffDialog(context); this.Shortcuts = new UiShortcuts(context); this.Sidebar = new UiSidebar(context); diff --git a/test/browser/ui/UiPlateauOsmLayerOffDialog.js b/test/browser/ui/UiPlateauOsmLayerOffDialog.js index a97242ca0..29fcf6657 100644 --- a/test/browser/ui/UiPlateauOsmLayerOffDialog.js +++ b/test/browser/ui/UiPlateauOsmLayerOffDialog.js @@ -55,12 +55,14 @@ describe('UiPlateauOsmLayerOffDialog', () => { it('opens the dialog when the service announces the switched-off layer', () => { + // eslint-disable-next-line no-new new Rapid.UiPlateauOsmLayerOffDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal').size()).to.equal(1); }); it('shows the title, the reason and the checkbox', () => { + // eslint-disable-next-line no-new new Rapid.UiPlateauOsmLayerOffDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal-section.header h3').text()) @@ -72,12 +74,14 @@ describe('UiPlateauOsmLayerOffDialog', () => { it('does not open the dialog when the user asked not to see it', () => { context.systems.storage.setItem(HIDDEN_KEY, 'true'); + // eslint-disable-next-line no-new new Rapid.UiPlateauOsmLayerOffDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal').size()).to.equal(0); }); it('remembers the choice when the checkbox is ticked', () => { + // eslint-disable-next-line no-new new Rapid.UiPlateauOsmLayerOffDialog(context); context.services.plateau.emit('osmlayeroff'); const node = elem.select('.plateau-dont-show-again input').node(); @@ -87,6 +91,7 @@ describe('UiPlateauOsmLayerOffDialog', () => { }); it('forgets the choice when the checkbox is unticked', () => { + // eslint-disable-next-line no-new new Rapid.UiPlateauOsmLayerOffDialog(context); context.services.plateau.emit('osmlayeroff'); const node = elem.select('.plateau-dont-show-again input').node(); From 5c86bd933d7f1d9a091f5e99b01d51130afe6b4c Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 9 Sep 2026 11:06:27 +0900 Subject: [PATCH 7/9] =?UTF-8?q?test(plateau):=20=E6=8A=91=E6=AD=A2?= =?UTF-8?q?=E3=81=AE=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92=E3=82=84?= =?UTF-8?q?=E3=82=81=E3=80=81=E7=94=9F=E6=88=90=E3=82=92=E9=96=A2=E6=95=B0?= =?UTF-8?q?=E3=81=AE=E6=88=BB=E3=82=8A=E5=80=A4=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no-new は文として置かれた new だけを対象にする。関数から返せば規則に 触れないので、eslint-disable の 5 行が要らなくなる。 Co-Authored-By: Claude Opus 5 --- test/browser/ui/UiPlateauOsmLayerOffDialog.js | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/test/browser/ui/UiPlateauOsmLayerOffDialog.js b/test/browser/ui/UiPlateauOsmLayerOffDialog.js index 29fcf6657..9350ba952 100644 --- a/test/browser/ui/UiPlateauOsmLayerOffDialog.js +++ b/test/browser/ui/UiPlateauOsmLayerOffDialog.js @@ -41,6 +41,12 @@ describe('UiPlateauOsmLayerOffDialog', () => { let context; + // `no-new` を避けるために関数の戻り値として返す。 + // この部品は組み立てた時点で `osmlayeroff` の購読を始めるので、戻り値は使わない試験が多い。 + function mountDialog(ctx) { + return new Rapid.UiPlateauOsmLayerOffDialog(ctx); + } + beforeEach(() => { elem = d3.select('body') .append('div') @@ -55,15 +61,13 @@ describe('UiPlateauOsmLayerOffDialog', () => { it('opens the dialog when the service announces the switched-off layer', () => { - // eslint-disable-next-line no-new - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal').size()).to.equal(1); }); it('shows the title, the reason and the checkbox', () => { - // eslint-disable-next-line no-new - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal-section.header h3').text()) .to.equal('plateau_conflation.osm_layer_off_title'); @@ -74,15 +78,13 @@ describe('UiPlateauOsmLayerOffDialog', () => { it('does not open the dialog when the user asked not to see it', () => { context.systems.storage.setItem(HIDDEN_KEY, 'true'); - // eslint-disable-next-line no-new - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal').size()).to.equal(0); }); it('remembers the choice when the checkbox is ticked', () => { - // eslint-disable-next-line no-new - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); const node = elem.select('.plateau-dont-show-again input').node(); node.checked = true; @@ -91,8 +93,7 @@ describe('UiPlateauOsmLayerOffDialog', () => { }); it('forgets the choice when the checkbox is unticked', () => { - // eslint-disable-next-line no-new - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); const node = elem.select('.plateau-dont-show-again input').node(); node.checked = true; From 710c625bccdc4d9d760c6b04c3d20317914edc49 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 9 Sep 2026 11:29:18 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(plateau):=20=E7=9F=A5=E3=82=89=E3=81=9B?= =?UTF-8?q?=E3=82=92=E6=8F=8F=E7=94=BB=E3=81=AE=E5=A4=96=E3=81=AB=E5=87=BA?= =?UTF-8?q?=E3=81=97=E3=80=81=E5=90=8D=E5=89=8D=E3=81=AE=E8=A1=A8=E8=A8=98?= =?UTF-8?q?=E3=82=92=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getData は Pixi の描画の途中で呼ばれる。受け取り側の例外で描画が止まら ないよう、出来事の発生を 1 拍遅らせる。試験も 1 拍待つ形に直す。 説明欄に osmlayeroff を足し、本文の Plateau を PLATEAU に揃えて見出しと 同じ表記にする。 Co-Authored-By: Claude Opus 5 --- data/core.yaml | 2 +- data/l10n/core.en.json | 2 +- data/l10n/core.ja.json | 2 +- modules/services/PlateauService.js | 6 +++++- test/browser/services/PlateauService.test.js | 17 +++++++++++++---- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/data/core.yaml b/data/core.yaml index 0cb08f93d..16b52890f 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -25,7 +25,7 @@ en: zoom_overview_label: Zoom 14 zoom_overview_tooltip: Snap to zoom 14 so tiles stop loading and panning stays fast.
Buildings render from z16. plateau_conflation: - osm_layer_off: "Plateau buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on." + osm_layer_off: "PLATEAU buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on." osm_layer_off_title: PLATEAU buildings are hidden dont_show_again: Do not show this again height_transfer: diff --git a/data/l10n/core.en.json b/data/l10n/core.en.json index 05095ff78..2b5c31b0e 100644 --- a/data/l10n/core.en.json +++ b/data/l10n/core.en.json @@ -28,7 +28,7 @@ "zoom_overview_tooltip": "Snap to zoom 14 so tiles stop loading and panning stays fast.
Buildings render from z16." }, "plateau_conflation": { - "osm_layer_off": "Plateau buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on.", + "osm_layer_off": "PLATEAU buildings stay hidden while the OpenStreetMap data layer is off, because existing buildings cannot be checked for duplicates. Press Shift+O to turn it back on.", "osm_layer_off_title": "PLATEAU buildings are hidden", "dont_show_again": "Do not show this again" }, diff --git a/data/l10n/core.ja.json b/data/l10n/core.ja.json index e13cfcb1f..fdd4a706e 100644 --- a/data/l10n/core.ja.json +++ b/data/l10n/core.ja.json @@ -2963,7 +2963,7 @@ "hires": "高解像度" }, "plateau_conflation": { - "osm_layer_off": "OpenStreetMap のデータのレイヤーが消えているあいだは、Plateau の建物を表示しません。既存の建物と重なるかを確かめられないためです。Shift+O で戻せます。", + "osm_layer_off": "OpenStreetMap のデータのレイヤーが消えているあいだは、PLATEAU の建物を表示しません。既存の建物と重なるかを確かめられないためです。Shift+O で戻せます。", "osm_layer_off_title": "PLATEAU の建物を表示していません", "dont_show_again": "次から表示しない" }, diff --git a/modules/services/PlateauService.js b/modules/services/PlateauService.js index fe083865e..ff974a9e6 100644 --- a/modules/services/PlateauService.js +++ b/modules/services/PlateauService.js @@ -27,6 +27,8 @@ const TILEZOOM = 16; * * Events available: * `loadedData` + * `osmlayeroff` OSM のデータのレイヤーが消えているために候補を伏せたときに発生する。 + * 引数は無い。ページを開き直すまでに 1 回しか発生しない。 */ export class PlateauService extends AbstractSystem { @@ -344,7 +346,9 @@ export class PlateauService extends AbstractSystem { if (missing) { if (missing === 'layer-off' && !this._osmLayerOffNotified) { this._osmLayerOffNotified = true; - this.emit('osmlayeroff'); + // `getData` は Pixi の描画の途中で呼ばれる。受け取り側がここで例外を投げると + // 描画の繰り返しがそのページの間止まるため、描画の外に出してから知らせる。 + window.setTimeout(() => this.emit('osmlayeroff'), 0); } return []; } diff --git a/test/browser/services/PlateauService.test.js b/test/browser/services/PlateauService.test.js index 16d3038c5..1cf9f2256 100644 --- a/test/browser/services/PlateauService.test.js +++ b/test/browser/services/PlateauService.test.js @@ -782,22 +782,29 @@ describe('PlateauService', () => { return seen; } - it('announces the switched-off OSM layer', () => { + // 出来事は描画の外に出してから発生するので、1 拍待ってから数える。 + function nextTurn() { + return new Promise(resolve => { window.setTimeout(resolve, 0); }); + } + + it('announces the switched-off OSM layer', async () => { const seen = countOsmLayerOff(_service); setOsmState(_service, { layerEnabled: false }); _service.getData('ds1'); + await nextTurn(); expect(seen.count).to.equal(1); }); - it('announces it only once while the layer stays switched off', () => { + it('announces it only once while the layer stays switched off', async () => { const seen = countOsmLayerOff(_service); setOsmState(_service, { layerEnabled: false }); _service.getData('ds1'); _service.getData('ds1'); + await nextTurn(); expect(seen.count).to.equal(1, '同じ状態で何度も知らせない'); }); - it('does not announce it again after the layer is switched on and off', () => { + it('does not announce it again after the layer is switched on and off', async () => { const seen = countOsmLayerOff(_service); setOsmState(_service, { layerEnabled: false }); _service.getData('ds1'); @@ -805,13 +812,15 @@ describe('PlateauService', () => { _service.getData('ds1'); setOsmState(_service, { layerEnabled: false }); _service.getData('ds1'); + await nextTurn(); expect(seen.count).to.equal(1, 'ページを開き直すまでは 1 回だけ'); }); - it('stays quiet while the tiles are still loading', () => { + it('stays quiet while the tiles are still loading', async () => { const seen = countOsmLayerOff(_service); setOsmState(_service, { tilesLoaded: false }); _service.getData('ds1'); + await nextTurn(); expect(seen.count).to.equal(0); }); }); From 5e93847d98292b4c6e1f4c33795acf33024cf7d3 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 9 Sep 2026 11:32:09 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs(plateau):=20=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E3=81=A7=E5=88=86=E3=81=8B=E3=81=A3=E3=81=9F=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=82=92=E8=A8=88=E7=94=BB=E3=81=AB=E5=8F=8D=E6=98=A0=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 試験を足す課題では静的検査も実行する。初版は Task 2 に入れておらず no-new の違反 5 件を見逃した。試験の生成は mountDialog 経由に直す。 UiSystem の持ち物の null 初期化を Task 3 に書き足す。同ファイルの 既存の慣習で、初版に書き漏らしていた。 Co-Authored-By: Claude Opus 5 --- ...2026-09-08-plateau-osm-layer-off-dialog.md | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md b/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md index 64ff5a391..caa1bfcca 100644 --- a/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md +++ b/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md @@ -309,15 +309,21 @@ describe('UiPlateauOsmLayerOffDialog', () => { d3.selectAll('.shaded').remove(); }); + // `no-new` を避けるために関数の戻り値として返す。 + // この部品は組み立てた時点で `osmlayeroff` の購読を始めるので、戻り値は使わない試験が多い。 + function mountDialog(ctx) { + return new Rapid.UiPlateauOsmLayerOffDialog(ctx); + } + it('opens the dialog when the service announces the switched-off layer', () => { - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal').size()).to.equal(1); }); it('shows the title, the reason and the checkbox', () => { - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal-section.header h3').text()) .to.equal('plateau_conflation.osm_layer_off_title'); @@ -328,13 +334,13 @@ describe('UiPlateauOsmLayerOffDialog', () => { it('does not open the dialog when the user asked not to see it', () => { context.systems.storage.setItem(HIDDEN_KEY, 'true'); - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); expect(elem.selectAll('.modal').size()).to.equal(0); }); it('remembers the choice when the checkbox is ticked', () => { - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); const node = elem.select('.plateau-dont-show-again input').node(); node.checked = true; @@ -343,7 +349,7 @@ describe('UiPlateauOsmLayerOffDialog', () => { }); it('forgets the choice when the checkbox is unticked', () => { - new Rapid.UiPlateauOsmLayerOffDialog(context); + mountDialog(context); context.services.plateau.emit('osmlayeroff'); const node = elem.select('.plateau-dont-show-again input').node(); node.checked = true; @@ -469,10 +475,13 @@ export { UiPlateauOsmLayerOffDialog } from './UiPlateauOsmLayerOffDialog.js'; 実行: ```bash -cd /Users/nyampire/git/Rapid && npm run build && npm run dist && npx karma start karma.conf.cjs --single-run +cd /Users/nyampire/git/Rapid && npx eslint modules test && npm run build && npm run dist && npx karma start karma.conf.cjs --single-run ``` -期待する結果: 776 件成功、5 件スキップ、失敗 0 件。Task 1 の 771 件に、この課題の 5 件が足されます。 +期待する結果: 静的検査はエラー 0 件、警告 42 件。試験は 776 件成功、5 件スキップ、失敗 0 件。Task 1 の 771 件に、この課題の 5 件が足されます。 + +試験を足す課題では、試験だけでなく静的検査も実行します。 +この計画の初版では Task 2 に静的検査を入れておらず、`no-new` の違反を 5 件見落としました。 - [ ] **手順 6: 記録する** @@ -540,6 +549,10 @@ import { 描画処理には手を入れません。この部品は出来事を受け取ったときだけ画面に要素を足します。 +あわせて、`UiSystem` の constructor にある持ち物の一覧に `this.PlateauOsmLayerOffDialog = null;` を足します。 +このファイルでは、`initAsync` で組み立てる部品をすべて constructor で `null` にしてから代入しており、それに合わせます。 +(この 1 行は計画の初版に書き漏らしていました。実装後に追記しています。) + - [ ] **手順 3: 静的検査と試験を通す** 実行: