diff --git a/data/core.yaml b/data/core.yaml index 937f885ca..16b52890f 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -25,7 +25,9 @@ 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: section_title: Plateau tags additions: Add from Plateau diff --git a/data/l10n/core.en.json b/data/l10n/core.en.json index 1ab7ea448..2b5c31b0e 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..fdd4a706e 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/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..caa1bfcca --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-plateau-osm-layer-off-dialog.md @@ -0,0 +1,610 @@ +# 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(); + }); + + // `no-new` を避けるために関数の戻り値として返す。 + // この部品は組み立てた時点で `osmlayeroff` の購読を始めるので、戻り値は使わない試験が多い。 + function mountDialog(ctx) { + return new Rapid.UiPlateauOsmLayerOffDialog(ctx); + } + + + it('opens the dialog when the service announces the switched-off layer', () => { + mountDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal').size()).to.equal(1); + }); + + it('shows the title, the reason and the checkbox', () => { + mountDialog(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'); + mountDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal').size()).to.equal(0); + }); + + it('remembers the choice when the checkbox is ticked', () => { + mountDialog(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', () => { + mountDialog(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; + }); +}); +``` + +最後の試験は、開いたダイアログの中でチェックを入れてから外します。 +利用者も同じ操作ができるので、試験のためだけの引数は要りません。 + +- [ ] **手順 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 + * ダイアログを開く。「次から表示しない」を選んだ利用者には開かない。 + * @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; + } + +} +``` + +`modules/ui/index.js` の 55 行目(`export { UiPhotoViewer } ...`)の次に、次の 1 行を足します。 + +```js +export { UiPlateauOsmLayerOffDialog } from './UiPlateauOsmLayerOffDialog.js'; +``` + +- [ ] **手順 5: 通ることを確かめる** + +実行: + +```bash +cd /Users/nyampire/git/Rapid && npx eslint modules test && npm run build && npm run dist && npx karma start karma.conf.cjs --single-run +``` + +期待する結果: 静的検査はエラー 0 件、警告 42 件。試験は 776 件成功、5 件スキップ、失敗 0 件。Task 1 の 771 件に、この課題の 5 件が足されます。 + +試験を足す課題では、試験だけでなく静的検査も実行します。 +この計画の初版では Task 2 に静的検査を入れておらず、`no-new` の違反を 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); +``` + +描画処理には手を入れません。この部品は出来事を受け取ったときだけ画面に要素を足します。 + +あわせて、`UiSystem` の constructor にある持ち物の一覧に `this.PlateauOsmLayerOffDialog = null;` を足します。 +このファイルでは、`initAsync` で組み立てる部品をすべて constructor で `null` にしてから代入しており、それに合わせます。 +(この 1 行は計画の初版に書き漏らしていました。実装後に追記しています。) + +- [ ] **手順 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 件) 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 件です。 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/modules/services/PlateauService.js b/modules/services/PlateauService.js index 20c0509b5..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 { @@ -48,7 +50,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 +344,12 @@ 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; + // `getData` は Pixi の描画の途中で呼ばれる。受け取り側がここで例外を投げると + // 描画の繰り返しがそのページの間止まるため、描画の外に出してから知らせる。 + window.setTimeout(() => this.emit('osmlayeroff'), 0); + } return []; } entities = this._filterPlateauOverlaps(entities, ds.graph); @@ -368,7 +375,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 +389,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/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/services/PlateauService.test.js b/test/browser/services/PlateauService.test.js index 99306347f..1cf9f2256 100644 --- a/test/browser/services/PlateauService.test.js +++ b/test/browser/services/PlateauService.test.js @@ -774,49 +774,54 @@ 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; + // 出来事は描画の外に出してから発生するので、1 拍待ってから数える。 + function nextTurn() { + return new Promise(resolve => { window.setTimeout(resolve, 0); }); } - it('tells the user once while the OSM layer stays switched off', () => { - const flash = withUi(_service); + it('announces the switched-off OSM layer', async () => { + 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'); + await nextTurn(); + 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', async () => { + const seen = countOsmLayerOff(_service); + setOsmState(_service, { layerEnabled: false }); + _service.getData('ds1'); _service.getData('ds1'); - expect(flash.calls).to.have.lengthOf(0); + await nextTurn(); + 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', async () => { + 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); + await nextTurn(); + expect(seen.count).to.equal(1, 'ページを開き直すまでは 1 回だけ'); + }); + + 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); }); }); diff --git a/test/browser/ui/UiPlateauOsmLayerOffDialog.js b/test/browser/ui/UiPlateauOsmLayerOffDialog.js new file mode 100644 index 000000000..9350ba952 --- /dev/null +++ b/test/browser/ui/UiPlateauOsmLayerOffDialog.js @@ -0,0 +1,105 @@ +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; + + // `no-new` を避けるために関数の戻り値として返す。 + // この部品は組み立てた時点で `osmlayeroff` の購読を始めるので、戻り値は使わない試験が多い。 + function mountDialog(ctx) { + return new Rapid.UiPlateauOsmLayerOffDialog(ctx); + } + + 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', () => { + mountDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal').size()).to.equal(1); + }); + + it('shows the title, the reason and the checkbox', () => { + mountDialog(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'); + mountDialog(context); + context.services.plateau.emit('osmlayeroff'); + expect(elem.selectAll('.modal').size()).to.equal(0); + }); + + it('remembers the choice when the checkbox is ticked', () => { + mountDialog(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', () => { + mountDialog(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; + }); +});