diff --git a/data/core.yaml b/data/core.yaml index 9d0a141fa..937f885ca 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -24,6 +24,8 @@ en: dashboard_tooltip: Open the Rapid Plateau progress dashboard (new tab) 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." 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 f7501dac0..1ab7ea448 100644 --- a/data/l10n/core.en.json +++ b/data/l10n/core.en.json @@ -27,6 +27,9 @@ "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." + }, "height_transfer": { "section_title": "Plateau tags", "additions": "Add from Plateau", diff --git a/data/l10n/core.ja.json b/data/l10n/core.ja.json index f5e572f35..7da163cab 100644 --- a/data/l10n/core.ja.json +++ b/data/l10n/core.ja.json @@ -2962,6 +2962,9 @@ "photos": { "hires": "高解像度" }, + "plateau_conflation": { + "osm_layer_off": "OpenStreetMap のデータのレイヤーが消えているあいだは、Plateau の建物を表示しません。既存の建物と重なるかを確かめられないためです。Shift+O で戻せます。" + }, "preferences": { "color_selection": { "default": "既定" diff --git a/modules/services/PlateauService.js b/modules/services/PlateauService.js index 112f485ca..20c0509b5 100644 --- a/modules/services/PlateauService.js +++ b/modules/services/PlateauService.js @@ -48,6 +48,9 @@ export class PlateauService extends AbstractSystem { rejected: new Set() // Set(entityID) - overlapping with OSM }; + // OSM のレイヤーが消えている件を伝えたかどうか。レイヤーが戻ると false に戻す。 + this._osmLayerOffNotified = false; + // Cache for coverage area GeoJSON (loaded once, used by PixiLayerPlateauCoverage) this._coverageData = null; // GeoJSON FeatureCollection or null this._coveragePromise = null; // Promise when inflight @@ -337,6 +340,11 @@ export class PlateauService extends AbstractSystem { // Client-side conflation: hide Plateau buildings that overlap existing OSM const useConflationStr = utilStringQs(window.location.hash).plateau_conflation; if (useConflationStr !== 'false' && useConflationStr !== 'no') { + const missing = this._osmDataMissing(); + if (missing) { + if (missing === 'layer-off') this._notifyOsmLayerOff(); + return []; + } entities = this._filterPlateauOverlaps(entities, ds.graph); } @@ -344,6 +352,58 @@ export class PlateauService extends AbstractSystem { } + /** + * _osmDataMissing + * 重なりの判定は、編集ソフトの中にある OSM の建物だけを材料にする。 + * 材料が集まっていない状態では「OSM に無い建物」と「まだ確かめられていない建物」を + * 区別できない。区別しないまま候補を出すと、すでに OSM にある建物を重ねて + * 登録することになるため、そのときは候補を出さない。 + * + * OSM のレイヤーを消すと `PixiLayerOsm` の描画が先頭で止まり、その先の + * `context.loadTiles()` に届かない。画面から消えるだけでなく、編集ソフトの中身も + * 空のままになる。 + * + * @return {string?} 材料が揃っていない理由。'layer-off' か 'tiles'。揃っていれば null + */ + _osmDataMissing() { + const layer = this.context.systems.gfx?.scene?.layers?.get('osm'); + if (layer && layer.enabled === false) return 'layer-off'; + this._osmLayerOffNotified = false; + + // タイルの取得に失敗したまま再取得されない経路もあるため、取得済みかどうかも見る。 + // 取得済みの一覧は上流のファイルの持ち物で、上流を取り込んだときに形が変わりうる。 + // 読めないときは判断せず、これまでどおり判定に進む。 + const loaded = this.context.services?.osm?._tileCache?.loaded; + if (!(loaded instanceof Set)) return null; + + const tiles = this._tiler.getTiles(this.context.viewport).tiles; + if (!tiles.length) return null; + + return tiles.some(tile => !loaded.has(tile.id)) ? 'tiles' : null; + } + + + /** + * _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 2d0da468b..99306347f 100644 --- a/test/browser/services/PlateauService.test.js +++ b/test/browser/services/PlateauService.test.js @@ -693,6 +693,134 @@ describe('PlateauService', () => { }); + describe('#getData の材料の確認', () => { + // 判定は編集ソフトの中にある OSM の建物だけを材料にする。 + // 材料が集まらない状態では、重なりが無いのか確かめられていないのかを + // 区別できない。区別できないまま候補を出すと、すでに OSM にある建物を + // 重ねて登録することになる。 + + // 表示範囲を覆うタイルの id を、判定と同じ計算で求める + function tileIDsInView(service) { + return service._tiler.getTiles(service.context.viewport).tiles.map(t => t.id); + } + + function setupDataset(coords) { + const base = new Rapid.Graph(); + const tree = new Rapid.Tree(base); // 空の graph から作り、差分で登録させる + let graph = base; + const nodeIds = []; + for (let i = 0; i < coords.length; i++) { + const nodeId = 'pgd-n' + i; + nodeIds.push(nodeId); + graph = graph.replace(Rapid.osmNode({ id: nodeId, loc: coords[i] })); + } + nodeIds.push(nodeIds[0]); + const way = Rapid.osmWay({ id: 'pgdWay', nodes: nodeIds, tags: { building: 'yes' } }); + graph = graph.replace(way); + _service._datasets.ds1 = { id: 'ds1', graph, tree, cache: {}, lastv: null }; + return way; + } + + function setOsmState(service, { layerEnabled = true, tilesLoaded = true } = {}) { + const ctx = service.context; + const loaded = new Set(tilesLoaded ? tileIDsInView(service) : []); + ctx.services = { osm: { _tileCache: { loaded: loaded } } }; + ctx.systems.gfx.scene = { layers: new Map([['osm', { id: 'osm', enabled: layerEnabled }]]) }; + } + + beforeEach(() => { + const c = _service.context.viewport.visibleExtent().center(); + setupDataset([ + [c[0] - 0.0001, c[1] - 0.0001], [c[0] + 0.0001, c[1] - 0.0001], + [c[0] + 0.0001, c[1] + 0.0001], [c[0] - 0.0001, c[1] + 0.0001] + ]); + }); + + it('sanity: the test dataset yields the building', () => { + setOsmState(_service, {}); + const ways = _service.getData('ds1', { skipConflation: true }).filter(e => e.type === 'way'); + expect(ways).to.have.lengthOf(1, '下ごしらえが効いている'); + }); + + it('returns no candidates while the OSM layer is switched off', () => { + setOsmState(_service, { layerEnabled: false }); + expect(_service.getData('ds1')).to.have.lengthOf(0, 'OSM のレイヤーが消えている'); + }); + + it('returns no candidates while the OSM tiles covering the view are not loaded', () => { + setOsmState(_service, { tilesLoaded: false }); + expect(_service.getData('ds1')).to.have.lengthOf(0, 'タイルが未取得'); + }); + + it('returns candidates once the layer is on and the tiles are loaded', () => { + setOsmState(_service, {}); + const ways = _service.getData('ds1').filter(e => e.type === 'way'); + expect(ways).to.have.lengthOf(1, '材料が揃っている'); + }); + + it('still returns everything for the height transfer path', () => { + // 高さの転記は、OSM の建物と重なる PLATEAU 建物を必要とする。 + // 材料の有無で結果を変えない。 + setOsmState(_service, { layerEnabled: false, tilesLoaded: false }); + const ways = _service.getData('ds1', { skipConflation: true }).filter(e => e.type === 'way'); + expect(ways).to.have.lengthOf(1); + }); + + it('judges as before when the OSM state cannot be read', () => { + // 上流の取り込みで持ち物の形が変わったときに、候補が出なくなるのを避ける。 + _service.context.services = {}; + _service.context.systems.gfx.scene = undefined; + const ways = _service.getData('ds1').filter(e => e.type === 'way'); + 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; + } + + 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); + 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'); + }); + + it('stays quiet while the tiles are still loading', () => { + const flash = withUi(_service); + setOsmState(_service, { tilesLoaded: false }); + _service.getData('ds1'); + expect(flash.calls).to.have.lengthOf(0); + }); + + it('tells the user again after the layer is switched on and off', () => { + const flash = withUi(_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); + }); + }); + + describe('#_checkWayOverlapsOsmBuildings', () => { function makePlateauWay(graph, wayId, coords) { const nodeIds = [];