diff --git a/CHANGELOG.md b/CHANGELOG.md index 945b501c8d..60eede6100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### ✨ Features and improvements - Text labels can now include relatively uncommon Chinese, Japanese, Korean, and Vietnamese characters, as well as characters from historical writing systems. When using server-side fonts, the map may request glyph PBFs beyond U+FFFF from the server instead of throwing an error as before. ([#6640](https://github.com/maplibre/maplibre-gl-js/pull/6640)) (by [@1ec5](https://github.com/1ec5)) +- Speed up the cross tile symbol index in certain circumstances ([#6641](https://github.com/maplibre/maplibre-gl-js/pull/6641)) (by [@bradymadden97](https://github.com/bradymadden97)) - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/package-lock.json b/package-lock.json index 7d02b07d0a..af251c408b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -469,8 +469,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.18.tgz", "integrity": "sha512-EF77RqROHL+4LhMGW5NTeKqfUd/e4OOv6EDFQ/UQQiFyWuqkEKyEz0NDILxOFxWUEVdjT2GQ2cC7t12B6pESwg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-dart": { "version": "2.3.1", @@ -610,16 +609,14 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.12.tgz", "integrity": "sha512-JFffQ1dDVEyJq6tCDWv0r/RqkdSnV43P2F/3jJ9rwLgdsOIXwQbXrz6QDlvQLVvNSnORH9KjDtenFTGDyzfCaA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.4.tgz", "integrity": "sha512-afea+0rGPDeOV9gdO06UW183Qg6wRhWVkgCFwiO3bDupAoyXRuvupbb5nUyqSTsLXIKL8u8uXQlJ9pkz07oVXw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -817,8 +814,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -983,7 +979,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1027,7 +1022,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3992,7 +3986,6 @@ "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4030,7 +4023,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4196,7 +4188,6 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -4562,7 +4553,6 @@ "integrity": "sha512-aIFPci9xoTmVkxpqsSKcRG/Hn0lTy421jsCehHydYeIMd+getn0Pue0JqY5cW8yZglZjMeX0YfIy5wDtQDHEcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.7", "fflate": "^0.8.2", @@ -4612,7 +4602,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5344,7 +5333,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -5536,7 +5524,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" @@ -6703,7 +6690,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7046,8 +7032,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1541592.tgz", "integrity": "sha512-FUUSjWbtgaCWSQ9bDC7mXEM4pL/V6GZ9bv05E33+/7TD1DFg3rJVTw3kxx+NPyzrgFfwneOGLEJ7MYoLpcLm/g==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "8.0.2", @@ -7543,7 +7528,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11765,7 +11749,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12365,7 +12348,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12742,7 +12724,6 @@ "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12978,7 +12959,6 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14774,8 +14754,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -14904,7 +14883,6 @@ "integrity": "sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@gerrit0/mini-shiki": "^3.12.0", "lunr": "^2.3.9", @@ -14942,7 +14920,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15066,7 +15043,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -15142,7 +15118,6 @@ "integrity": "sha512-xQroKAadK503CrmbzCISvQUjeuvEZzv6U0wlnlVFOi5i3gnzfH4onyQ29f3lzpe0FresAiTAd3aqK0Bi/jLI8w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.7", "@vitest/mocker": "4.0.7", diff --git a/src/symbol/cross_tile_symbol_index.test.ts b/src/symbol/cross_tile_symbol_index.test.ts index 497ae31b51..113aa9bdbb 100644 --- a/src/symbol/cross_tile_symbol_index.test.ts +++ b/src/symbol/cross_tile_symbol_index.test.ts @@ -214,6 +214,213 @@ describe('CrossTileSymbolIndex.addLayer', () => { }); +}); + +describe('CrossTileSymbolIndex.addLayer with a scale that causes indexing', () => { + test('matches ids', () => { + const index = new CrossTileSymbolIndex(); + const INSTANCE_COUNT = KDBUSH_THRESHHOLD + 1; + + const mainID = new OverscaledTileID(6, 0, 6, 8, 8); + const mainInstances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(1000, 1000, 'Detroit')); + mainInstances.push(makeSymbolInstance(2000, 2000, 'Toronto')); + const mainTile = makeTile(mainID, mainInstances); + + index.addLayer(styleLayer, [mainTile], 0); + // Assigned new IDs + for (let i = 1; i <= INSTANCE_COUNT + 1; i++) { + expect(mainInstances.find(j => j.crossTileID === i)).toBeDefined(); + } + + const childID = new OverscaledTileID(7, 0, 7, 16, 16); + const childInstances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(2000, 2000, 'Detroit')); + childInstances.push(makeSymbolInstance(2000, 2000, 'Windsor')); + childInstances.push(makeSymbolInstance(3000, 3000, 'Toronto')); + childInstances.push(makeSymbolInstance(4001, 4001, 'Toronto')); + const childTile = makeTile(childID, childInstances); + + index.addLayer(styleLayer, [mainTile, childTile], 0); + // matched parent tile for all Detroit + const detroitChildren = childInstances.filter(i => i.key === 'Detroit'); + for (let i = 1; i <= INSTANCE_COUNT; i++) { + expect(detroitChildren.find(j => j.crossTileID === i)).toBeDefined(); + } + + // does not match Windsor because of different key + const windsorInstance = childInstances.find(i => i.key === 'Windsor'); + expect(windsorInstance.crossTileID).toEqual(131); + + // does not match Toronto @ 3000 because of different location + const toronto3000Instance = childInstances.find(i => i.key === 'Toronto' && i.anchorX === 3000); + expect(toronto3000Instance.crossTileID).toEqual(132); + + // matches Toronto @ 4001 even though it has a slightly updated location + const toronto4001Instance = childInstances.find(i => i.key === 'Toronto' && i.anchorX === 4001); + expect(toronto4001Instance.crossTileID).toBeLessThanOrEqual(INSTANCE_COUNT + 1); + + const parentID = new OverscaledTileID(5, 0, 5, 4, 4); + const parentInstances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(500, 500, 'Detroit')); + const parentTile = makeTile(parentID, parentInstances); + + index.addLayer(styleLayer, [mainTile, childTile, parentTile], 0); + // matched Detroit children tiles from parent + for (let i = 1; i < INSTANCE_COUNT; i++) { + expect(parentInstances.find(j => j.crossTileID === i)).toBeDefined(); + } + + const grandchildID = new OverscaledTileID(8, 0, 8, 32, 32); + const grandchildInstances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(4000, 4000, 'Detroit')); + grandchildInstances.push(makeSymbolInstance(4000, 4000, 'Windsor')); + const grandchildTile = makeTile(grandchildID, grandchildInstances); + + index.addLayer(styleLayer, [mainTile], 0); + index.addLayer(styleLayer, [mainTile, grandchildTile], 0); + // matches Detroit grandchildren with mainBucket + const detroitGrandchildren = grandchildInstances.filter(i => i.key === 'Detroit'); + for (let i = 1; i <= INSTANCE_COUNT; i++) { + expect(detroitGrandchildren.find(j => j.crossTileID === i)).toBeDefined(); + } + + // Does not match the Windsor value because that was removed + const windsorGrandchild = grandchildInstances.find(i => i.key === 'Windsor'); + expect(windsorGrandchild.crossTileID).toEqual(133); + }); + + test('overwrites ids when re-adding', () => { + const index = new CrossTileSymbolIndex(); + const INSTANCE_COUNT = KDBUSH_THRESHHOLD + 1; + + const mainID = new OverscaledTileID(6, 0, 6, 8, 8); + const mainInstances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(1000, 1000, 'Detroit')); + const mainTile = makeTile(mainID, mainInstances); + + const childID = new OverscaledTileID(7, 0, 7, 16, 16); + const childInstances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(2000, 2000, 'Detroit')); + const childTile = makeTile(childID, childInstances); + + // Assigns new ids 1 -> INSTANCE_COUNT + index.addLayer(styleLayer, [mainTile], 0); + expect(Math.max(...mainInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT); + + // Removes the layer + index.addLayer(styleLayer, [], 0); + + // Assigns new ids INSTANCE_COUNT + 1 -> 2 * INSTANCE_COUNT + index.addLayer(styleLayer, [childTile], 0); + expect(Math.min(...childInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT + 1); + expect(Math.max(...childInstances.map(i => i.crossTileID))).toBe(2 * INSTANCE_COUNT); + + // Expect all to have a crossTileID + expect(mainInstances.some(i => i.crossTileID === 0)).toBeFalsy(); + expect(childInstances.some(i => i.crossTileID === 0)).toBeFalsy(); + + // Overwrites the old id to match the already-added tile + index.addLayer(styleLayer, [mainTile, childTile], 0); + expect(Math.min(...mainInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT + 1); + expect(Math.max(...mainInstances.map(i => i.crossTileID))).toBe(2 * INSTANCE_COUNT); + expect(Math.min(...childInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT + 1); + expect(Math.max(...childInstances.map(i => i.crossTileID))).toBe(2 * INSTANCE_COUNT); + }); + + test('does not duplicate ids within one zoom level', () => { + const index = new CrossTileSymbolIndex(); + const INSTANCE_COUNT = KDBUSH_THRESHHOLD + 1; + + const mainID = new OverscaledTileID(6, 0, 6, 8, 8); + const mainInstances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(1000, 1000, '')); + const mainTile = makeTile(mainID, mainInstances); + + const childID = new OverscaledTileID(7, 0, 7, 16, 16); + const childInstances = Array.from({length: INSTANCE_COUNT + 1}, () => makeSymbolInstance(2000, 2000, '')); + const childTile = makeTile(childID, childInstances); + + // Assigns new ids 1 -> INSTANCE_COUNT + index.addLayer(styleLayer, [mainTile], 0); + expect(mainInstances.some(i => i.crossTileID === 0)).toBeFalsy(); + expect(Math.min(...mainInstances.map(i => i.crossTileID))).toBe(1); + expect(Math.max(...mainInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT); + + const layerIndex = index.layerIndexes[styleLayer.id]; + expect(Object.keys(layerIndex.usedCrossTileIDs[6]).length).toEqual(INSTANCE_COUNT); + for (let i = 1; i <= INSTANCE_COUNT; i++) { + expect(layerIndex.usedCrossTileIDs[6][String(i)]).toBeDefined(); + } + + // copies parent ids without duplicate ids in this tile + index.addLayer(styleLayer, [childTile], 0); + for (let i = 1; i <= INSTANCE_COUNT; i++) { + // 1 -> INSTANCE_COUNT are copied + expect(childInstances.find(j => j.crossTileID === i)).toBeDefined(); + } + // We have one new key generated for INSTANCE_COUNT + 1 + expect(Math.max(...childInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT + 1); + + // Updates per-zoom usedCrossTileIDs + expect(Object.keys(layerIndex.usedCrossTileIDs[6])).toEqual([]); + for (let i = 1; i <= INSTANCE_COUNT + 1; i++) { + expect(layerIndex.usedCrossTileIDs[7][String(i)]).toBeDefined(); + } + }); + + test('does not regenerate ids for same zoom', () => { + const index = new CrossTileSymbolIndex(); + const INSTANCE_COUNT = KDBUSH_THRESHHOLD + 1; + + const tileID = new OverscaledTileID(6, 0, 6, 8, 8); + const firstInstances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(1000, 1000, '')); + const firstTile = makeTile(tileID, firstInstances); + + const secondInstances = Array.from({length: INSTANCE_COUNT + 1}, () => makeSymbolInstance(1000, 1000, '')); + const secondTile = makeTile(tileID, secondInstances); + + // Assigns new ids 1 -> INSTANCE_COUNT + index.addLayer(styleLayer, [firstTile], 0); + expect(firstInstances.some(i => i.crossTileID === 0)).toBeFalsy(); + expect(Math.min(...firstInstances.map(i => i.crossTileID))).toBe(1); + expect(Math.max(...firstInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT); + + const layerIndex = index.layerIndexes[styleLayer.id]; + for (let i = 1; i <= INSTANCE_COUNT; i++) { + expect(layerIndex.usedCrossTileIDs[6][String(i)]).toBeDefined(); + } + + // Uses same ids when tile gets updated + index.addLayer(styleLayer, [secondTile], 0); + for (let i = 1; i <= INSTANCE_COUNT; i++) { + // 1 -> INSTANCE_COUNT are copied + expect(secondInstances.find(j => j.crossTileID === i)).toBeDefined(); + } + // We have one new key generated for INSTANCE_COUNT + 1 + expect(Math.max(...secondInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT + 1); + + // Updates usedCrossTileIDs + for (let i = 1; i <= INSTANCE_COUNT + 1; i++) { + expect(layerIndex.usedCrossTileIDs[6][String(i)]).toBeDefined(); + } + }); + + test('reuses indexes when longitude is wrapped', () => { + const index = new CrossTileSymbolIndex(); + const INSTANCE_COUNT = KDBUSH_THRESHHOLD + 1; + const longitude = 370; + + const tileID = new OverscaledTileID(6, 1, 6, 8, 8); + const instances = Array.from({length: INSTANCE_COUNT}, () => makeSymbolInstance(1000, 1000, '')); + const tile = makeTile(tileID, instances); + + index.addLayer(styleLayer, [tile], longitude); + for (let i = 1; i <= INSTANCE_COUNT; i++) { + expect(instances.find(j => j.crossTileID === i)).toBeDefined(); + } + + tile.tileID = tileID.wrapped(); + + index.addLayer(styleLayer, [tile], longitude % 360); + for (let i = 1; i <= INSTANCE_COUNT; i++) { + expect(instances.find(j => j.crossTileID === i)).toBeDefined(); + } + }); + test('indexes data for findMatches perf', () => { const index = new CrossTileSymbolIndex(); @@ -223,7 +430,8 @@ describe('CrossTileSymbolIndex.addLayer', () => { const mainInstances: any[] = []; const childInstances: any[] = []; - for (let i = 0; i < KDBUSH_THRESHHOLD + 1; i++) { + const INSTANCE_COUNT = KDBUSH_THRESHHOLD + 1; + for (let i = 0; i < INSTANCE_COUNT; i++) { mainInstances.push(makeSymbolInstance(0, 0, '')); childInstances.push(makeSymbolInstance(0, 0, '')); } @@ -232,9 +440,11 @@ describe('CrossTileSymbolIndex.addLayer', () => { index.addLayer(styleLayer, [mainTile], 0); index.addLayer(styleLayer, [childTile], 0); - // check that we matched the parent tile - expect(childInstances[0].crossTileID).toBe(1); - + // all child instances matched a crossTileID from the parent, otherwise + // we would have generated a new crossTileID, and the number would + // exceed INSTANCE_COUNT + expect(childInstances.every(i => i.crossTileID <= INSTANCE_COUNT)).toBeTruthy(); + expect(Math.max(...childInstances.map(i => i.crossTileID))).toBe(INSTANCE_COUNT); }); }); diff --git a/src/symbol/cross_tile_symbol_index.ts b/src/symbol/cross_tile_symbol_index.ts index c9d56647e6..e0c25e1dfc 100644 --- a/src/symbol/cross_tile_symbol_index.ts +++ b/src/symbol/cross_tile_symbol_index.ts @@ -28,14 +28,25 @@ const roundingFactor = 512 / EXTENT / 2; export const KDBUSH_THRESHHOLD = 128; -interface SymbolsByKeyEntry { - index?: KDBush; - positions?: {x: number; y: number}[]; - crossTileIDs: number[]; -} +const SymbolKindType = { + INDEXED: 0, + UNINDEXED: 1, +} as const; + +type IndexedSymbolKind = { + readonly type: typeof SymbolKindType.INDEXED; + readonly index: KDBush; + readonly crossTileIDs: number[]; +}; + +type UnindexedSymbolKind = { + readonly type: typeof SymbolKindType.UNINDEXED; + readonly positions: {x: number; y: number}[]; + readonly crossTileIDs: number[]; +}; class TileLayerIndex { - _symbolsByKey: Record = {}; + _symbolsByKey: Record = {}; constructor(public tileID: OverscaledTileID, symbolInstances: SymbolInstanceArray, public bucketInstanceId: number) { // group the symbolInstances by key @@ -57,21 +68,17 @@ class TileLayerIndex { for (const [key, symbols] of symbolInstancesByKey) { const positions = symbols.map(symbolInstance => ({x: Math.floor(symbolInstance.anchorX * roundingFactor), y: Math.floor(symbolInstance.anchorY * roundingFactor)})); const crossTileIDs = symbols.map(v => v.crossTileID); - const entry: SymbolsByKeyEntry = {positions, crossTileIDs}; // once we get too many symbols for a given key, it becomes much faster to index it before queries - if (entry.positions.length > KDBUSH_THRESHHOLD) { - - const index = new KDBush(entry.positions.length, 16, Uint16Array); - for (const {x, y} of entry.positions) index.add(x, y); + if (positions.length > KDBUSH_THRESHHOLD) { + const index = new KDBush(positions.length, 16, Uint16Array); + for (const {x, y} of positions) index.add(x, y); index.finish(); - - // clear all references to the original positions data - delete entry.positions; - entry.index = index; + this._symbolsByKey[key] = {type: SymbolKindType.INDEXED, index, crossTileIDs}; + } else { + this._symbolsByKey[key] = {type: SymbolKindType.UNINDEXED, positions, crossTileIDs}; } - this._symbolsByKey[key] = entry; } } @@ -99,11 +106,42 @@ class TileLayerIndex { return result; } + getCrossTileIDsLists() { + return Object.values(this._symbolsByKey).map(({crossTileIDs}) => crossTileIDs); + } + findMatches(symbolInstances: SymbolInstanceArray, newTileID: OverscaledTileID, zoomCrossTileIDs: { [crossTileID: number]: boolean; }) { const tolerance = this.tileID.canonical.z < newTileID.canonical.z ? 1 : Math.pow(2, this.tileID.canonical.z - newTileID.canonical.z); + const instancesByKey = this.groupSymbolInstancesByKey(symbolInstances); + // For each key, find the entry, then match the symbol instances + // to the entry contents + for (const key of instancesByKey.keys()) { + const entry = this._symbolsByKey[key]; + if (!entry) { + // No symbol with this key in this bucket + continue; + } + + const instances = instancesByKey.get(key); + if (entry.type === SymbolKindType.INDEXED) { + this.matchForIndexedEntry(entry, instances, newTileID, zoomCrossTileIDs, tolerance); + } else { + this.matchForUnindexedEntry(entry, instances, newTileID, zoomCrossTileIDs, tolerance); + } + } + } + + /** + * Groups all symbol instances by common key. + * + * @returns A map keyed by {@link SymbolInstance.key} to an array of + * {@link SymbolInstance}. + */ + private groupSymbolInstancesByKey(symbolInstances: SymbolInstanceArray): Map { + const instancesByKey = new Map(); for (let i = 0; i < symbolInstances.length; i++) { const symbolInstance = symbolInstances.get(i); if (symbolInstance.crossTileID) { @@ -111,60 +149,120 @@ class TileLayerIndex { continue; } - const entry = this._symbolsByKey[symbolInstance.key]; - if (!entry) { - // No symbol with this key in this bucket - continue; + if (instancesByKey.has(symbolInstance.key)) { + instancesByKey.get(symbolInstance.key).push(symbolInstance); + } else { + instancesByKey.set(symbolInstance.key, [symbolInstance]); } + } + return instancesByKey; + } + /** + * Finds matches for entries without an index built. This method modifies + * instances within {@link symbolInstances} as well as adds entries to + * {@link zoomCrossTileIDs} when it finds a symbol within {@link entry} + * that can be matched with a {@link symbolInstances} instance. + */ + private matchForUnindexedEntry( + entry: UnindexedSymbolKind, + symbolInstances: SymbolInstance[], + newTileID: OverscaledTileID, + zoomCrossTileIDs: {[crossTileID: number]: boolean}, + tolerance: number + ): void { + for (const symbolInstance of symbolInstances) { const scaledSymbolCoord = this.getScaledCoordinates(symbolInstance, newTileID); - if (entry.index) { + for (let i = 0; i < entry.positions.length; i++) { + const thisTileSymbol = entry.positions[i]; + const crossTileID = entry.crossTileIDs[i]; + // Return any symbol with the same keys whose coordinates are within 1 // grid unit. (with a 4px grid, this covers a 12px by 12px area) - const indexes = entry.index.range( - scaledSymbolCoord.x - tolerance, - scaledSymbolCoord.y - tolerance, - scaledSymbolCoord.x + tolerance, - scaledSymbolCoord.y + tolerance).sort(); + if (Math.abs(thisTileSymbol.x - scaledSymbolCoord.x) <= tolerance && + Math.abs(thisTileSymbol.y - scaledSymbolCoord.y) <= tolerance && + !zoomCrossTileIDs[crossTileID]) { + // Once we've marked ourselves duplicate against this parent symbol, + // don't let any other symbols at the same zoom level duplicate against + // the same parent (see issue #5993) + zoomCrossTileIDs[crossTileID] = true; + symbolInstance.crossTileID = crossTileID; + break; + } + } + } + } - for (const i of indexes) { - const crossTileID = entry.crossTileIDs[i]; + /** + * Finds matches for entries with an index built. This method modifies + * instances within {@link symbolInstances} as well as adds entries to + * {@link zoomCrossTileIDs} when it finds a symbol from the index within + * {@link entry} that can be matched with a {@link symbolInstances} + * instance. + */ + private matchForIndexedEntry( + entry: IndexedSymbolKind, + symbolInstances: SymbolInstance[], + newTileID: OverscaledTileID, + zoomCrossTileIDs: {[crossTileID: number]: boolean}, + tolerance: number + ): void { + // Map instances by their scaled coordinate. The map is keyed by X, + // then keyed by Y coordinate. + const instancesByScaledCoordinate = new Map>(); + for (const symbolInstance of symbolInstances) { + const scaledSymbolCoord = this.getScaledCoordinates(symbolInstance, newTileID); - if (!zoomCrossTileIDs[crossTileID]) { - // Once we've marked ourselves duplicate against this parent symbol, - // don't let any other symbols at the same zoom level duplicate against - // the same parent (see issue #5993) - zoomCrossTileIDs[crossTileID] = true; - symbolInstance.crossTileID = crossTileID; - break; - } + const xInstances = instancesByScaledCoordinate.get(scaledSymbolCoord.x); + if (xInstances) { + const xyInstances = xInstances.get(scaledSymbolCoord.y); + if (xyInstances) { + xyInstances.push(symbolInstance); + } else { + xInstances.set(scaledSymbolCoord.y, [symbolInstance]); } - } else if (entry.positions) { - for (let i = 0; i < entry.positions.length; i++) { - const thisTileSymbol = entry.positions[i]; - const crossTileID = entry.crossTileIDs[i]; - - // Return any symbol with the same keys whose coordinates are within 1 - // grid unit. (with a 4px grid, this covers a 12px by 12px area) - if (Math.abs(thisTileSymbol.x - scaledSymbolCoord.x) <= tolerance && - Math.abs(thisTileSymbol.y - scaledSymbolCoord.y) <= tolerance && - !zoomCrossTileIDs[crossTileID]) { + } else { + instancesByScaledCoordinate.set( + scaledSymbolCoord.x, + new Map([[scaledSymbolCoord.y, [symbolInstance]]]) + ); + } + } + + // For each scaled coordinate, match instances with indexed results + // at the same location. + for (const [x, yMap] of instancesByScaledCoordinate.entries()) { + for (const [y, instances] of yMap.entries()) { + const indexes = entry.index.range( + x - tolerance, + y - tolerance, + x + tolerance, + y + tolerance); + + // Iterate through cross tile entries at this quadrant _and_ + // symbol instances and pair them up until one of the lists + // runs out. This is faster than re-running a range query + // for every symbol instance, as each of these symbol + // instances already have the same key, and thus can be + // paired with any entry in the index. + let i = 0; + let j = 0; + while (i < indexes.length && j < instances.length) { + const crossTileID = entry.crossTileIDs[indexes[i]]; + if (!zoomCrossTileIDs[crossTileID]) { // Once we've marked ourselves duplicate against this parent symbol, // don't let any other symbols at the same zoom level duplicate against // the same parent (see issue #5993) zoomCrossTileIDs[crossTileID] = true; - symbolInstance.crossTileID = crossTileID; - break; + instances[j].crossTileID = crossTileID; + j++; } + i++; } } } } - - getCrossTileIDsLists() { - return Object.values(this._symbolsByKey).map(({crossTileIDs}) => crossTileIDs); - } } class CrossTileIDs {