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 = [];