diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..7ebe3c36c2 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +**/*.d.ts +**/*.json +@here/generator-harp.gl/**/*.ts +dist/** +**/*.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..53c4084b53 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,81 @@ +{ + "plugins": ["@typescript-eslint", "prettier"], + "extends": ["standard-with-typescript", "prettier/@typescript-eslint"], + "parserOptions": { + "project": "./tsconfig.json" + }, + "rules": { + "prettier/prettier": "error", + + "@typescript-eslint/no-for-in-array": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/default-param-last": "off", + "@typescript-eslint/prefer-reduce-type-parameter": "off", + "@typescript-eslint/prefer-ts-expect-error": "off", + "@typescript-eslint/no-misused-promises": "off", + "@typescript-eslint/no-array-constructor": "off", + "@typescript-eslint/no-dynamic-delete": "off", + "@typescript-eslint/no-useless-constructor": "off", + "@typescript-eslint/require-array-sort-compare": "off", + "@typescript-eslint/no-base-to-string": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-extraneous-class": "off", + "@typescript-eslint/consistent-type-assertions": "off", + "@typescript-eslint/no-invalid-void-type": "off", + "@typescript-eslint/no-unnecessary-boolean-literal-compare": "off", + "@typescript-eslint/naming-convention": "off", + "@typescript-eslint/restrict-plus-operands": "off", + "@typescript-eslint/dot-notation": "off", + "@typescript-eslint/return-await": "off", + "@typescript-eslint/prefer-includes": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/prefer-optional-chain": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/promise-function-async": "off", + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/no-unnecessary-type-assertion": "off", + "@typescript-eslint/method-signature-style": "off", + "@typescript-eslint/lines-between-class-members": "off", + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/strict-boolean-expressions": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/explicit-function-return-type": "off", + + "accessor-pairs": "off", + "generator-star-spacing": "off", + "handle-callback-err": "off", + "import/export": "off", + "import/first": "off", + "import/no-webpack-loader-syntax": "off", + "new-cap": "off", + "no-async-promise-executor": "off", + "no-case-declarations": "off", + "no-extra-boolean-cast": "off", + "no-inner-declarations": "off", + "no-irregular-whitespace": "off", + "no-multi-spaces": "off", + "no-multi-str": "off", + "no-new": "off", + "no-path-concat": "off", + "no-proto": "off", + "no-prototype-builtins": "off", + "no-redeclare": "off", + "no-self-assign": "off", + "no-tabs": "off", + "no-unneeded-ternary": "off", + "no-useless-call": "off", + "no-useless-computed-key": "off", + "no-useless-escape": "off", + "no-useless-return": "off", + "operator-linebreak": "off", + "prefer-const": "off", + "prefer-promise-reject-errors": "off", + "promise/param-names": "off", + "quote-props": "off", + "space-in-parens": "off", + "spaced-comment": "off", + "standard/no-callback-literal": "off", + "valid-typeof": "off", + "wrap-iife": "off" + } +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9089597ea6..ae6cefa307 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,16 +39,19 @@ jobs: id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-${{ matrix.node_version }}--yarn-${{ hashFiles('**/yarn.lock') }} + key: yarn-${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('**/yarn.lock') }} restore-keys: | - ${{ runner.os }}-yarn- + yarn-${{ runner.os }}-${{ matrix.node_version }}- + yarn-${{ runner.os }}- - name: Use Node.js ${{ matrix.node_version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node_version }} - name: Install dependencies - run: yarn + run: yarn --frozen-lockfile + - name: Disable hard-source-webpack-plugin + run: echo "::set-env name=HARP_NO_HARD_SOURCE_CACHE::true" - name: Pretest run: yarn run pre-test shell: bash @@ -80,22 +83,22 @@ jobs: yarn test-browser --headless-chrome --timeout ${{ env.browserTestTimeout }} shell: bash if: matrix.os == 'windows-latest' + - name: Tests on Firefox (Windows) + run: | + export PATH=`pwd`:$PATH + cp node_modules/geckodriver/geckodriver.exe . + yarn test-browser --headless-firefox --timeout ${{ env.browserTestTimeout }} + if: matrix.os == 'windows-latest' + shell: bash - name: Tests on Chrome (Linux) run: | set -ex - yarn + yarn --frozen-lockfile google-chrome --version whereis google-chrome yarn test-browser --headless-chrome --timeout ${{ env.browserTestTimeout }} shell: bash if: matrix.os == 'ubuntu-latest' - - name: Tests on Firefox (Windows) - run: | - export PATH=`pwd`:$PATH - cp node_modules/geckodriver/geckodriver.exe . - yarn test-browser --headless-firefox --timeout ${{ env.browserTestTimeout }} - if: matrix.os == 'windows-latest' - shell: bash - name: Tests on Firefox (Linux) run: | set -ex @@ -104,11 +107,11 @@ jobs: yarn test-browser --headless-firefox --timeout ${{ env.browserTestTimeout }} shell: bash if: matrix.os == 'ubuntu-latest' - - name: Build examples - run: yarn run build-examples - shell: bash - name: Build bundle run: yarn run build-bundle + shell: bash + - name: Build examples + run: yarn run build-examples shell: bash - name: Generate doc run: yarn run typedoc diff --git a/.gitignore b/.gitignore index 994f105643..c46bca92ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ doc/ +coverage/ test-npm-packages-app/ .vscode/ .DS_Store diff --git a/.travis.yml b/.travis.yml index 8ce151c1ea..5086c83cb0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,7 @@ branches: env: - DETECT_CHROMEDRIVER_VERSION=true + - HARP_NO_HARD_SOURCE_CACHE=true before_install: - yarn global add codecov @@ -44,22 +45,13 @@ jobs: script: ./scripts/publish-packages.sh skip_cleanup: true on: - branch: release - - provider: pages - skip_cleanup: true - committer-from-gh: true - keep-history: false - local-dir: dist/gh_deploy - github-token: $GITHUB_TOKEN - on: - branch: release + tags: true - provider: s3 access_key_id: $AWS_ACCESS_KEY_ID secret_access_key: $AWS_SECRET_ACCESS_KEY bucket: "harp.gl" skip_cleanup: true local_dir: dist/s3_deploy - upload-dir: docs region: us-east-1 acl: public_read cache_control: no-cache diff --git a/@here/generator-harp.gl/generators/app/templates/javascript/View.js b/@here/generator-harp.gl/generators/app/templates/javascript/View.js index 90b8ec2ba5..8436b67953 100644 --- a/@here/generator-harp.gl/generators/app/templates/javascript/View.js +++ b/@here/generator-harp.gl/generators/app/templates/javascript/View.js @@ -5,8 +5,8 @@ */ import { MapControls } from "@here/harp-map-controls"; -import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; -import { APIFormat, AuthenticationMethod, OmvDataSource } from "@here/harp-omv-datasource"; +import { MapView } from "@here/harp-mapview"; +import { OmvDataSource } from "@here/harp-omv-datasource"; const defaultTheme = "resources/berlin_tilezen_base.json"; @@ -23,25 +23,8 @@ export class View { theme: this.theme, decoderUrl: "decoder.bundle.js" }); - CopyrightElementHandler.install("copyrightNotice") - .attach(mapView) - .setDefaults([ - { - id: "here.com", - label: "HERE", - link: "https://legal.here.com/terms", - year: 2019 - } - ]); const omvDataSource = new OmvDataSource({ - baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", - apiFormat: APIFormat.XYZOMV, - styleSetName: "tilezen", - authenticationCode: "<%= apikey %>", - authenticationMethod: { - method: AuthenticationMethod.QueryString, - name: "apikey" - } + authenticationCode: "<%= apikey %>" }); mapView.addDataSource(omvDataSource); MapControls.create(mapView); diff --git a/@here/generator-harp.gl/generators/app/templates/typescript/View.ts b/@here/generator-harp.gl/generators/app/templates/typescript/View.ts index 044fef541b..681077337d 100644 --- a/@here/generator-harp.gl/generators/app/templates/typescript/View.ts +++ b/@here/generator-harp.gl/generators/app/templates/typescript/View.ts @@ -6,8 +6,8 @@ import { Theme } from "@here/harp-datasource-protocol"; import { MapControls } from "@here/harp-map-controls"; -import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; -import { APIFormat, AuthenticationMethod, OmvDataSource } from "@here/harp-omv-datasource"; +import { MapView } from "@here/harp-mapview"; +import { OmvDataSource } from "@here/harp-omv-datasource"; const defaultTheme = "resources/berlin_tilezen_base.json"; @@ -35,26 +35,8 @@ export class View { decoderUrl: "decoder.bundle.js" }); - CopyrightElementHandler.install("copyrightNotice") - .attach(mapView) - .setDefaults([ - { - id: "here.com", - label: "HERE", - link: "https://legal.here.com/terms", - year: 2019 - } - ]); - const omvDataSource = new OmvDataSource({ - baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", - apiFormat: APIFormat.XYZOMV, - styleSetName: "tilezen", - authenticationCode: "<%= apikey %>", - authenticationMethod: { - method: AuthenticationMethod.QueryString, - name: "apikey" - } + authenticationCode: "<%= apikey %>" }); mapView.addDataSource(omvDataSource); diff --git a/@here/harp-datasource-protocol/lib/DecodedTile.ts b/@here/harp-datasource-protocol/lib/DecodedTile.ts index 6935faf9dc..ee81fb4d21 100644 --- a/@here/harp-datasource-protocol/lib/DecodedTile.ts +++ b/@here/harp-datasource-protocol/lib/DecodedTile.ts @@ -62,6 +62,12 @@ export interface DecodedTile { * @see [[CopyrightInfo]] */ copyrightHolderIds?: string[]; + + /** + * List of {@link @here/harp-geoutils#TileKey}s stored as mortonCodes representing + * {@link @here/harp-mapview#Tile}s that have geometry covering this `Tile`. + */ + dependencies?: number[]; } /** @@ -164,6 +170,12 @@ export interface Geometry { */ featureStarts?: number[]; + /** + * Optional sorted list of feature start indices for the outline geometry. + * Equivalent to {@link featureStarts} but pointing into the edgeIndex attribute. + */ + edgeFeatureStarts?: number[]; + /** * Optional array of objects. It can be used to pass user data from the geometry to the mesh. */ @@ -372,7 +384,7 @@ export function getFeatureName( } if (languages !== undefined) { for (const lang of languages) { - name = env.lookup(`${basePropName}:${lang}`) || env.lookup(`${basePropName}_${lang}`); + name = env.lookup(`${basePropName}:${lang}`) ?? env.lookup(`${basePropName}_${lang}`); if (typeof name === "string" && name.length > 0) { return name; } diff --git a/@here/harp-datasource-protocol/lib/Expr.ts b/@here/harp-datasource-protocol/lib/Expr.ts index 6be626ae3a..60f406b3e8 100644 --- a/@here/harp-datasource-protocol/lib/Expr.ts +++ b/@here/harp-datasource-protocol/lib/Expr.ts @@ -277,7 +277,7 @@ export abstract class Expr { ? { definitions, lockedNames: new Set(), - cache: definitionExprCache || new Map() + cache: definitionExprCache ?? new Map() } : undefined; @@ -577,7 +577,7 @@ export class CallExpr extends Expr { /** @override */ protected exprIsDynamic() { - const descriptor = this.descriptor || ExprEvaluator.getOperator(this.op); + const descriptor = this.descriptor ?? ExprEvaluator.getOperator(this.op); if (descriptor && descriptor.isDynamicOperator && descriptor.isDynamicOperator(this)) { return true; diff --git a/@here/harp-datasource-protocol/lib/ExprParser.ts b/@here/harp-datasource-protocol/lib/ExprParser.ts index d2f1a9b296..c448766a8f 100644 --- a/@here/harp-datasource-protocol/lib/ExprParser.ts +++ b/@here/harp-datasource-protocol/lib/ExprParser.ts @@ -208,7 +208,7 @@ class Lexer { * Parsed text. */ text(): string { - return this.m_text || ""; + return this.m_text ?? ""; } /** @@ -223,7 +223,7 @@ class Lexer { } private yyinp(): void { - this.m_char = this.code.codePointAt(this.m_index++) || 0; + this.m_char = this.code.codePointAt(this.m_index++) ?? 0; } private yylex(): Token { diff --git a/@here/harp-datasource-protocol/lib/StyleSetEvaluator.ts b/@here/harp-datasource-protocol/lib/StyleSetEvaluator.ts index 36b25b26d0..05dc734ad8 100644 --- a/@here/harp-datasource-protocol/lib/StyleSetEvaluator.ts +++ b/@here/harp-datasource-protocol/lib/StyleSetEvaluator.ts @@ -49,6 +49,34 @@ const logger = LoggerManager.instance.create("StyleSetEvaluator"); const emptyTechniqueDescriptor = mergeTechniqueDescriptor({}); +const DEFAULT_TECHNIQUE_ATTR_SCOPE = AttrScope.TechniqueGeometry; + +/** + * Get the attribute scope of the given style property. + * + * @remarks + * Certain Style properties change their dynamic scope behavior + * based on other properties. For example, the `color` property + * of `extruded-polygon` change behavior based on the usage + * of `vertexColors`. + * + * @param style A valid Style. + * @param attrName The name of the attribute of the {@link style}. + */ +function getStyleAttributeScope(style: InternalStyle, attrName: string): AttrScope { + if (style.technique === "extruded-polygon") { + if (attrName === "color" && style.vertexColors !== false) { + return DEFAULT_TECHNIQUE_ATTR_SCOPE; + } + } + + const descriptions = techniqueDescriptors as any; + + const techniqueDescriptor = descriptions[style.technique] ?? emptyTechniqueDescriptor; + + return techniqueDescriptor.attrScopes[attrName] ?? DEFAULT_TECHNIQUE_ATTR_SCOPE; +} + interface StyleInternalParams { /** * Optimization: Lazy creation and storage of expression in a style object. @@ -738,9 +766,6 @@ export class StyleSetEvaluator { const dynamicForwardedAttributes = style._dynamicForwardedAttributes; const targetStaticAttributes = style._staticAttributes; - const techniqueDescriptor = - techniqueDescriptors[style.technique] || emptyTechniqueDescriptor; - const processAttribute = (attrName: string, attrValue: Value | JsonExpr | undefined) => { if (attrValue === undefined) { return; @@ -777,14 +802,7 @@ export class StyleSetEvaluator { } if (Expr.isExpr(attrValue)) { - let attrScope: AttrScope | undefined = (techniqueDescriptor.attrScopes as any)[ - attrName as any - ]; - - if (attrScope === undefined) { - // Use [[AttrScope.TechniqueGeometry]] as default scope for the attribute. - attrScope = AttrScope.TechniqueGeometry; - } + const attrScope = getStyleAttributeScope(style, attrName); const deps = attrValue.dependencies(); diff --git a/@here/harp-datasource-protocol/lib/TechniqueDescriptor.ts b/@here/harp-datasource-protocol/lib/TechniqueDescriptor.ts index a1bfbf6793..812cd1a62c 100644 --- a/@here/harp-datasource-protocol/lib/TechniqueDescriptor.ts +++ b/@here/harp-datasource-protocol/lib/TechniqueDescriptor.ts @@ -52,7 +52,7 @@ export type TechniquePropScopes = { [P in TechniquePropNames]?: AttrScope; }; -export interface TechniqueDescriptor { +export interface TechniqueDescriptor { attrTransparencyColor?: string; attrScopes: TechniquePropScopes; } diff --git a/@here/harp-datasource-protocol/lib/TechniqueParams.ts b/@here/harp-datasource-protocol/lib/TechniqueParams.ts index 8716800611..de6ce8c37e 100644 --- a/@here/harp-datasource-protocol/lib/TechniqueParams.ts +++ b/@here/harp-datasource-protocol/lib/TechniqueParams.ts @@ -1017,6 +1017,7 @@ export interface SolidLineTechniqueParams extends BaseTechniqueParams, Polygonal outlineWidth?: DynamicProperty; /** * Clip the line outside the tile if `true`. + * @default false */ clipping?: DynamicProperty; /** @@ -1081,6 +1082,12 @@ export interface SolidLineTechniqueParams extends BaseTechniqueParams, Polygonal * Size in world units how far to offset the line perpendicular to its direction. */ offset?: DynamicProperty; + /** + * Skip rendering clobbered pixels. + * See https://threejs.org/docs/#api/en/materials/Material.depthTest. + * @default `false` + */ + depthTest?: boolean; } /** diff --git a/@here/harp-datasource-protocol/test/ExprTest.ts b/@here/harp-datasource-protocol/test/ExprTest.ts index 083c996fa7..019eaaf5d0 100644 --- a/@here/harp-datasource-protocol/test/ExprTest.ts +++ b/@here/harp-datasource-protocol/test/ExprTest.ts @@ -21,7 +21,7 @@ function evaluate(expr: string | JsonExpr | Expr, env?: Env | ValueMap): Value { ? // tslint:disable-next-line: deprecation Expr.parse(expr) : Expr.fromJSON(expr) - ).evaluate(env || new Env()); + ).evaluate(env ?? new Env()); } describe("Expr", function() { diff --git a/@here/harp-debug-datasource/lib/DebugTileDataSource.ts b/@here/harp-debug-datasource/lib/DebugTileDataSource.ts index 52e24cc35a..3849d9ed6a 100644 --- a/@here/harp-debug-datasource/lib/DebugTileDataSource.ts +++ b/@here/harp-debug-datasource/lib/DebugTileDataSource.ts @@ -32,7 +32,7 @@ export class DebugTile extends Tile { private readonly geometry = new THREE.Geometry(); private readonly m_labelPositions = new THREE.BufferAttribute(new Float32Array(3), 3); - private m_textRenderStyle = new TextRenderStyle({ + private readonly m_textRenderStyle = new TextRenderStyle({ fontSize: { unit: FontUnit.Pixel, size: 16, @@ -118,13 +118,14 @@ export class DebugTile extends Tile { export class DebugTileDataSource extends DataSource { constructor( - private m_tilingScheme: TilingScheme, + private readonly m_tilingScheme: TilingScheme, name = "debug", public maxDbgZoomLevel: number = 20 ) { super({ name, minDataLevel: 1, maxDataLevel: 20, storageLevelOffset: -1 }); this.cacheable = true; + this.enablePicking = false; } /** @override */ diff --git a/@here/harp-examples/decoder/custom_decoder.ts b/@here/harp-examples/decoder/custom_decoder.ts index b414278df5..3ce3d6fce3 100644 --- a/@here/harp-examples/decoder/custom_decoder.ts +++ b/@here/harp-examples/decoder/custom_decoder.ts @@ -42,12 +42,17 @@ class CustomDecoder extends ThemedTileDecoder projection: Projection ): Promise { const geometries: Geometry[] = []; - this.processLineFeatures(data, tileKey, styleSetEvaluator, projection, geometries); + + const array = new Float32Array(data as ArrayBuffer); + this.processLineFeatures(array, tileKey, styleSetEvaluator, projection, geometries); this.processMeshFeatures(tileKey, styleSetEvaluator, geometries); + const dependencies: number[] = []; + this.processDependencies(array, dependencies); - const decodedTile = { + const decodedTile: DecodedTile = { techniques: styleSetEvaluator.techniques, - geometries + geometries, + dependencies }; return Promise.resolve(decodedTile); } @@ -85,7 +90,7 @@ class CustomDecoder extends ThemedTileDecoder } private processLineFeatures( - data: ArrayBufferLike | {}, + data: Float32Array, tileKey: TileKey, styleSetEvaluator: StyleSetEvaluator, projection: Projection, @@ -119,23 +124,24 @@ class CustomDecoder extends ThemedTileDecoder } private convertToLocalWorldCoordinates( - data: {} | ArrayBuffer | SharedArrayBuffer, + data: Float32Array, geoCenter: GeoCoordinates, projection: Projection, worldCenter: Vector3 ) { // We assume that the input data is in relative-geo-coordinates - // (i.e. relative lat/long to the tile center). + // (i.e. relative lat/long to the tile center). The first number is the + // number of points, the points are after that. - const points = new Float32Array(data as ArrayBuffer); + const numPoints = data[0]; const tmpGeoPoint = geoCenter.clone(); const tmpWorldPoint = new Vector3(); - const worldPoints = new Array((points.length / 2) * 3); - for (let i = 0; i < points.length; i += 2) { + const worldPoints = new Array((numPoints / 2) * 3); + for (let i = 0; i < numPoints; i += 2) { tmpGeoPoint.copy(geoCenter); - tmpGeoPoint.latitude += points[i]; - tmpGeoPoint.longitude += points[i + 1]; - tmpGeoPoint.altitude = 100; + // We add +1 to skip the first entry which has the number of points + tmpGeoPoint.latitude += data[i + 1]; + tmpGeoPoint.longitude += data[i + 2]; projection.projectPoint(tmpGeoPoint, tmpWorldPoint); tmpWorldPoint.sub(worldCenter).toArray(worldPoints, (i / 2) * 3); } @@ -166,6 +172,17 @@ class CustomDecoder extends ThemedTileDecoder geometries.push(geometry); } } + + private processDependencies(data: Float32Array, dependencies: number[]) { + const numberPoints = data[0]; + const numberDependenciesIndex = numberPoints + 1; + // Add 1 because we need to skip the first element + const numberDependencies = data[numberDependenciesIndex]; + for (let i = 0; i < numberDependencies; i++) { + // Add 1 to skip the number of dependencies + dependencies.push(data[numberDependenciesIndex + i + 1]); + } + } } // snippet:custom_datasource_example_custom_decoder_service.ts diff --git a/@here/harp-examples/index.html b/@here/harp-examples/index.html index 6001f8bc65..3fa7bf94e7 100644 --- a/@here/harp-examples/index.html +++ b/@here/harp-examples/index.html @@ -356,7 +356,7 @@

harp.gl

diff --git a/@here/harp-examples/src/datasource_webtile.ts b/@here/harp-examples/src/datasource_custom-texture-tile.ts similarity index 75% rename from @here/harp-examples/src/datasource_webtile.ts rename to @here/harp-examples/src/datasource_custom-texture-tile.ts index 8818bde35b..71297d54f4 100644 --- a/@here/harp-examples/src/datasource_webtile.ts +++ b/@here/harp-examples/src/datasource_custom-texture-tile.ts @@ -3,21 +3,23 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - import { MapControls, MapControlsUI } from "@here/harp-map-controls"; -import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; +import { + CopyrightElementHandler, + CopyrightInfo, + MapView, + TextureLoader, + Tile +} from "@here/harp-mapview"; import { WebTileDataSource } from "@here/harp-webtile-datasource"; -import { apikey } from "../config"; +import * as THREE from "three"; // tslint:disable:max-line-length /** - * A simple example using the webtile data source. Tiles are retrieved from - * ``` - * https://1.base.maps.ls.hereapi.com/maptile/2.1/maptile/newest/normal.day/${level}/${column}/${row}/512/png8?apikey=${apikey} - * ``` + * A simple example using the webtile data source. Tiles are generated from a + * texture * - * A [[WebTileDataSource]] is created with specified applications' apikey passed - * as [[WebTileDataSourceOptions]] + * A [[WebTileDataSource]] is created with specified getTexture function * ```typescript * [[include:harp_gl_datasource_webtile_1.ts]] * ``` @@ -26,7 +28,7 @@ import { apikey } from "../config"; * [[include:harp_gl_datasource_webtile_2.ts]] * ``` */ -export namespace WebTileDataSourceExample { +export namespace CustomTextureTileDatasourceExample { // creates a new MapView for the HTMLCanvasElement of the given id export function initializeMapView(id: string): MapView { const canvas = document.getElementById(id) as HTMLCanvasElement; @@ -59,9 +61,14 @@ export namespace WebTileDataSourceExample { const mapView = initializeMapView("mapCanvas"); // snippet:harp_gl_datasource_webtile_1.ts + function getTexture(tile: Tile): Promise<[THREE.Texture, CopyrightInfo[]]> { + return Promise.all([new TextureLoader().load("resources/wests_textures/clover.png"), []]); + } + const webTileDataSource = new WebTileDataSource({ - apikey, - ppi: WebTileDataSource.ppiValue.ppi320 + dataProvider: { + getTexture + } }); // end:harp_gl_datasource_webtile_1.ts diff --git a/@here/harp-examples/src/datasource_custom.ts b/@here/harp-examples/src/datasource_custom.ts index bece260b76..803268c1e6 100644 --- a/@here/harp-examples/src/datasource_custom.ts +++ b/@here/harp-examples/src/datasource_custom.ts @@ -126,7 +126,7 @@ export namespace CustomDatasourceExample { const y = Math.sin(t) * t * scale; data.push(x, y); } - return Promise.resolve(new Float32Array(data)); + return Promise.resolve(new Float32Array([data.length, ...data])); } } diff --git a/@here/harp-examples/src/datasource_features_lines-and-points.ts b/@here/harp-examples/src/datasource_features_lines-and-points.ts index 6e3061f656..4fb9661443 100644 --- a/@here/harp-examples/src/datasource_features_lines-and-points.ts +++ b/@here/harp-examples/src/datasource_features_lines-and-points.ts @@ -3,7 +3,6 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - import { StyleSet, Theme } from "@here/harp-datasource-protocol"; import { FeaturesDataSource, @@ -13,8 +12,9 @@ import { } from "@here/harp-features-datasource"; import { GeoCoordinates, sphereProjection } from "@here/harp-geoutils"; import { MapControls, MapControlsUI } from "@here/harp-map-controls"; -import { MapView } from "@here/harp-mapview"; +import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; import { OmvDataSource } from "@here/harp-omv-datasource"; + import { apikey } from "../config"; import { faults, hotspots } from "../resources/geology"; @@ -223,6 +223,8 @@ export namespace LinesPointsFeaturesExample { window.addEventListener("resize", () => mapView.resize(innerWidth, innerHeight)); + CopyrightElementHandler.install("copyrightNotice", mapView); + const baseMap = new OmvDataSource({ baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", authenticationCode: apikey diff --git a/@here/harp-examples/src/datasource_features_polygons.ts b/@here/harp-examples/src/datasource_features_polygons.ts index e11fdb8dd0..917449d6b0 100644 --- a/@here/harp-examples/src/datasource_features_polygons.ts +++ b/@here/harp-examples/src/datasource_features_polygons.ts @@ -3,7 +3,6 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - import { StyleDeclaration, StyleSet } from "@here/harp-datasource-protocol"; import { FeaturesDataSource, @@ -12,9 +11,10 @@ import { } from "@here/harp-features-datasource"; import { GeoCoordinates, sphereProjection } from "@here/harp-geoutils"; import { MapControls, MapControlsUI } from "@here/harp-map-controls"; -import { MapView } from "@here/harp-mapview"; +import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; import { OmvDataSource } from "@here/harp-omv-datasource"; import * as THREE from "three"; + import { apikey } from "../config"; import { COUNTRIES } from "../resources/countries"; @@ -332,6 +332,8 @@ export namespace PolygonsFeaturesExample { window.addEventListener("resize", () => mapView.resize(innerWidth, innerHeight)); + CopyrightElementHandler.install("copyrightNotice", mapView); + const baseMap = new OmvDataSource({ baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", authenticationCode: apikey diff --git a/@here/harp-examples/src/datasource_geojson_choropleth.ts b/@here/harp-examples/src/datasource_geojson_choropleth.ts index 84cf7df7a5..7da2095a6d 100644 --- a/@here/harp-examples/src/datasource_geojson_choropleth.ts +++ b/@here/harp-examples/src/datasource_geojson_choropleth.ts @@ -3,13 +3,13 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - import { StyleDeclaration, StyleSet, Theme } from "@here/harp-datasource-protocol"; import { GeoCoordinates } from "@here/harp-geoutils"; import { MapControls, MapControlsUI } from "@here/harp-map-controls"; -import { MapView } from "@here/harp-mapview"; +import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; import { GeoJsonDataProvider, OmvDataSource } from "@here/harp-omv-datasource"; import * as THREE from "three"; + import { apikey } from "../config"; /** @@ -86,6 +86,8 @@ export namespace GeoJsonHeatmapExample { mapView.resize(window.innerWidth, window.innerHeight); }); + CopyrightElementHandler.install("copyrightNotice", mapView); + const baseMapDataSource = new OmvDataSource({ baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", authenticationCode: apikey diff --git a/@here/harp-examples/src/datasource_here-webtile.ts b/@here/harp-examples/src/datasource_here-webtile.ts new file mode 100644 index 0000000000..509b7156bd --- /dev/null +++ b/@here/harp-examples/src/datasource_here-webtile.ts @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ +import { MapControls, MapControlsUI } from "@here/harp-map-controls"; +import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; +import { HereWebTileDataSource, WebTileDataSource } from "@here/harp-webtile-datasource"; + +import { Theme } from "@here/harp-datasource-protocol"; +import { ProjectionType } from "@here/harp-geoutils"; +import { apikey } from "../config"; + +const FLAT_THEME: Theme = { + clearColor: "#f8fbfd" +}; + +const GLOBE_THEME: Theme = { + clearColor: "#99ceff", + sky: { + type: "cubemap", + positiveX: "./resources/Sky_px.png", + negativeX: "./resources/Sky_nx.png", + positiveY: "./resources/Sky_py.png", + negativeY: "./resources/Sky_ny.png", + positiveZ: "./resources/Sky_pz.png", + negativeZ: "./resources/Sky_nz.png" + }, + + styles: { + polar: [ + { + description: "North pole", + when: ["==", ["get", "kind"], "north_pole"], + technique: "fill", + renderOrder: 5, + color: "#99ceff" + }, + { + description: "South pole", + when: ["==", ["get", "kind"], "south_pole"], + technique: "fill", + renderOrder: 5, + color: "#f5f8fa" + } + ] + } +}; + +// tslint:disable:max-line-length +/** + * A simple example using the herewebtile data source. Tiles are retrieved from + * ``` + * https://1.base.maps.ls.hereapi.com/maptile/2.1/maptile/newest/normal.day/${level}/${column}/${row}/512/png8?apikey=${apikey} + * ``` + * + * A [[HereWebTileDataSource]] is created with specified applications' apikey passed + * as [[WebTileDataSourceOptions]] + * ```typescript + * [[include:harp_gl_datasource_herewebtile_1.ts]] + * ``` + * Then added to the [[MapView]] + * ```typescript + * [[include:harp_gl_datasource_herewebtile_2.ts]] + * ``` + */ +export namespace HereWebTileDataSourceExample { + // creates a new MapView for the HTMLCanvasElement of the given id + export function initializeMapView(id: string): MapView { + const canvas = document.getElementById(id) as HTMLCanvasElement; + + const map = new MapView({ + canvas, + theme: FLAT_THEME + }); + + // instantiate the default map controls, allowing the user to pan around freely. + const controls = new MapControls(map); + + // Add an UI. + const ui = new MapControlsUI(controls, { zoomLevel: "input", projectionSwitch: true }); + ui.projectionSwitchElement?.addEventListener("click", () => { + map.theme = map.projection.type === ProjectionType.Spherical ? GLOBE_THEME : FLAT_THEME; + }); + canvas.parentElement!.appendChild(ui.domElement); + + CopyrightElementHandler.install("copyrightNotice", map); + + // resize the mapView to maximum + map.resize(window.innerWidth, window.innerHeight); + + // react on resize events + window.addEventListener("resize", () => { + map.resize(window.innerWidth, window.innerHeight); + }); + + return map; + } + + const mapView = initializeMapView("mapCanvas"); + + // snippet:harp_gl_datasource_herewebtile_1.ts + const hereWebTileDataSource = new HereWebTileDataSource({ + apikey, + ppi: WebTileDataSource.ppiValue.ppi320 + }); + // end:harp_gl_datasource_herewebtile_1.ts + + // snippet:harp_gl_datasource_herewebtile_2.ts + mapView.addDataSource(hereWebTileDataSource); + // end:harp_gl_datasource_herewebtile_2.ts +} diff --git a/@here/harp-examples/src/datasource_here_vector_tile.ts b/@here/harp-examples/src/datasource_here_vector_tile.ts index 481dfe54ac..3ac9aae600 100644 --- a/@here/harp-examples/src/datasource_here_vector_tile.ts +++ b/@here/harp-examples/src/datasource_here_vector_tile.ts @@ -3,10 +3,10 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - import { MapControls, MapControlsUI } from "@here/harp-map-controls"; -import { MapView } from "@here/harp-mapview"; +import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; import { APIFormat, AuthenticationMethod, OmvDataSource } from "@here/harp-omv-datasource"; + import { apikey, copyrightInfo } from "../config"; /** @@ -71,6 +71,9 @@ export namespace DatasourceHEREVectorTileExample { // end:harp_gl_datasource_here_vector_tile_example_1.ts // snippet:harp_gl_datasource_here_vector_tile_example_2.ts + + CopyrightElementHandler.install("copyrightNotice", map); + const mapControls = new MapControls(map); mapControls.maxTiltAngle = 50; const ui = new MapControlsUI(mapControls, { zoomLevel: "input", projectionSwitch: true }); @@ -90,23 +93,27 @@ export namespace DatasourceHEREVectorTileExample { return map; } - const mapView = initializeMapView("mapCanvas"); + function main() { + const mapView = initializeMapView("mapCanvas"); - // snippet:harp_gl_datasource_here_vector_tile_example_4.ts - const omvDataSource = new OmvDataSource({ - baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", - apiFormat: APIFormat.XYZOMV, - styleSetName: "tilezen", - authenticationCode: apikey, - authenticationMethod: { - method: AuthenticationMethod.QueryString, - name: "apikey" - }, - copyrightInfo - }); - // end:harp_gl_datasource_here_vector_tile_example_4.ts + // snippet:harp_gl_datasource_here_vector_tile_example_4.ts + const omvDataSource = new OmvDataSource({ + baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", + apiFormat: APIFormat.XYZOMV, + styleSetName: "tilezen", + authenticationCode: apikey, + authenticationMethod: { + method: AuthenticationMethod.QueryString, + name: "apikey" + }, + copyrightInfo + }); + // end:harp_gl_datasource_here_vector_tile_example_4.ts + + // snippet:harp_gl_datasource_here_vector_tile_example_5.ts + mapView.addDataSource(omvDataSource); + // end:harp_gl_datasource_here_vector_tile_example_5.ts + } - // snippet:harp_gl_datasource_here_vector_tile_example_5.ts - mapView.addDataSource(omvDataSource); - // end:harp_gl_datasource_here_vector_tile_example_5.ts + main(); } diff --git a/@here/harp-examples/src/datasource_satellite-tile.ts b/@here/harp-examples/src/datasource_satellite-tile.ts index ac4bae2d60..08828f9360 100644 --- a/@here/harp-examples/src/datasource_satellite-tile.ts +++ b/@here/harp-examples/src/datasource_satellite-tile.ts @@ -3,10 +3,10 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - import { MapControls, MapControlsUI } from "@here/harp-map-controls"; import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; -import { WebTileDataSource } from "@here/harp-webtile-datasource"; +import { HereTileProvider, HereWebTileDataSource } from "@here/harp-webtile-datasource"; + import { apikey } from "../config"; // tslint:disable:max-line-length @@ -59,9 +59,9 @@ export namespace SatelliteDataSourceExample { const mapView = initializeMapView("mapCanvas"); // snippet:harp_gl_datasource_satellitetile_1.ts - const webTileDataSource = new WebTileDataSource({ + const webTileDataSource = new HereWebTileDataSource({ apikey, - tileBaseAddress: WebTileDataSource.TILE_AERIAL_SATELLITE + tileBaseAddress: HereTileProvider.TILE_AERIAL_SATELLITE }); // end:harp_gl_datasource_satellitetile_1.ts diff --git a/@here/harp-examples/src/performance_benchmark.ts b/@here/harp-examples/src/performance_benchmark.ts index 06e91d7a73..019c100738 100644 --- a/@here/harp-examples/src/performance_benchmark.ts +++ b/@here/harp-examples/src/performance_benchmark.ts @@ -648,6 +648,8 @@ export namespace PerformanceBenchmark { "6": 6, "8": 8 }, + throttlingEnabled: mapViewApp.mapView.throttlingEnabled, + maxTilesPerFrame: mapViewApp.mapView.visibleTileSet.maxTilesPerFrame, PhasedLoading: false, Berlin: () => { openMapBerlin(); @@ -799,6 +801,13 @@ export namespace PerformanceBenchmark { }) .setValue(decoderCount === undefined ? undefined : decoderCount.toFixed(0)); + benchmarksFolder + .add(guiOptions, "throttlingEnabled") + .onFinishChange(value => { + mapViewApp.mapView.throttlingEnabled = value; + }) + .listen(); + benchmarksFolder .add(guiOptions, "PixelRatio", guiOptions.PixelRatio) .onChange((ratioString: string) => { @@ -834,6 +843,13 @@ export namespace PerformanceBenchmark { showLabels = labels === true; }); + benchmarksFolder + .add(guiOptions, "maxTilesPerFrame", 0, 10, 1) + .onFinishChange(value => { + mapViewApp.mapView.visibleTileSet.maxTilesPerFrame = value; + }) + .listen(); + openAndZoomFolder = benchmarksFolder.addFolder("OpenAndZoom"); openAndZoomFolder.add(guiOptions, "Berlin"); diff --git a/@here/harp-examples/src/rendering_globe-atmosphere.ts b/@here/harp-examples/src/rendering_globe-atmosphere.ts index 5ec3c4b465..f05316aa94 100644 --- a/@here/harp-examples/src/rendering_globe-atmosphere.ts +++ b/@here/harp-examples/src/rendering_globe-atmosphere.ts @@ -3,7 +3,6 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - import { EarthConstants, GeoCoordinates, sphereProjection } from "@here/harp-geoutils"; import { MapControls, MapControlsUI } from "@here/harp-map-controls"; import { @@ -13,7 +12,8 @@ import { MapViewAtmosphere } from "@here/harp-mapview"; import { APIFormat, AuthenticationMethod, OmvDataSource } from "@here/harp-omv-datasource"; -import { WebTileDataSource } from "@here/harp-webtile-datasource"; +import { HereTileProvider, HereWebTileDataSource } from "@here/harp-webtile-datasource"; + import { apikey, copyrightInfo } from "../config"; export namespace GlobeAtmosphereExample { @@ -62,9 +62,9 @@ export namespace GlobeAtmosphereExample { copyrightInfo }); } else { - dataSource = new WebTileDataSource({ + dataSource = new HereWebTileDataSource({ apikey, - tileBaseAddress: WebTileDataSource.TILE_AERIAL_SATELLITE + tileBaseAddress: HereTileProvider.TILE_AERIAL_SATELLITE }); } map.addDataSource(dataSource); diff --git a/@here/harp-examples/src/rendering_synchronous.ts b/@here/harp-examples/src/rendering_synchronous.ts index 3f4048ddad..bdadee10a9 100644 --- a/@here/harp-examples/src/rendering_synchronous.ts +++ b/@here/harp-examples/src/rendering_synchronous.ts @@ -83,7 +83,7 @@ export namespace SynchronousRendering { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; - constructor(text: string, private coordinates: GeoCoordinates) { + constructor(text: string, private readonly coordinates: GeoCoordinates) { this.addHTMLElements(text); this.canvas = document.getElementById("popupLine") as HTMLCanvasElement; diff --git a/@here/harp-examples/src/styling_square-technique.ts b/@here/harp-examples/src/styling_square-technique.ts new file mode 100644 index 0000000000..c22cf403ce --- /dev/null +++ b/@here/harp-examples/src/styling_square-technique.ts @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ +import { GeoCoordinates } from "@here/harp-geoutils"; +import { MapControls, MapControlsUI } from "@here/harp-map-controls"; +import { CopyrightElementHandler, MapView } from "@here/harp-mapview"; +import { OmvDataSource } from "@here/harp-omv-datasource"; + +import { apikey } from "../config"; + +/** + * A small example using the {@links SquaresTechnique} to add a pink suqare to + * each place in the map. + * + * The {@links MapView} will be initialized with a theme that extends the + * default theme with a style that overrides the places with a square + * ```typescript + * [[include:squares_technique_example.ts]] + * ``` + */ +export namespace SquaresTechniqueExample { + // Create a new MapView for the HTMLCanvasElement of the given id. + function initializeMapView(id: string): MapView { + const canvas = document.getElementById(id) as HTMLCanvasElement; + + // Look at New York. + const NY = new GeoCoordinates(40.707, -74.01); + const map = new MapView({ + canvas, + // snippet:squares_technique_example.ts + theme: { + extends: "resources/berlin_tilezen_base.json", + styles: { + tilezen: [ + { + layer: "places", + technique: "squares", + when: ["==", ["geometry-type"], "Point"], + color: "#ff00ff", + size: 500 + } + ] + } + }, + // end:squares_technique_example.ts + target: NY, + tilt: 50, + heading: -20, + zoomLevel: 15.1 + }); + + CopyrightElementHandler.install("copyrightNotice", map); + + const mapControls = new MapControls(map); + mapControls.maxTiltAngle = 50; + + const ui = new MapControlsUI(mapControls, { zoomLevel: "input" }); + canvas.parentElement!.appendChild(ui.domElement); + + map.resize(window.innerWidth, window.innerHeight); + + window.addEventListener("resize", () => { + map.resize(window.innerWidth, window.innerHeight); + }); + + addOmvDataSource(map); + + return map; + } + + function addOmvDataSource(map: MapView) { + const omvDataSource = new OmvDataSource({ + baseUrl: "https://vector.hereapi.com/v2/vectortiles/base/mc", + authenticationCode: apikey + }); + + map.addDataSource(omvDataSource); + + return map; + } + + export const mapView = initializeMapView("mapCanvas"); +} diff --git a/@here/harp-examples/src/tile_dependencies.ts b/@here/harp-examples/src/tile_dependencies.ts new file mode 100644 index 0000000000..4d4ba57268 --- /dev/null +++ b/@here/harp-examples/src/tile_dependencies.ts @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Theme } from "@here/harp-datasource-protocol"; +import { DebugTileDataSource } from "@here/harp-debug-datasource"; +import { TileKey, webMercatorTilingScheme } from "@here/harp-geoutils"; +import { MapControls, MapControlsUI } from "@here/harp-map-controls"; +import { CopyrightElementHandler, MapView, PickResult, Tile } from "@here/harp-mapview"; +import { + DataProvider, + TileDataSource, + TileDataSourceOptions, + TileFactory +} from "@here/harp-mapview-decoder"; +import { GUI } from "dat.gui"; +import { CUSTOM_DECODER_SERVICE_TYPE } from "../decoder/custom_decoder_defs"; + +/** + * This example shows how to use the {@link @here/harp-mapview#Tile.dependencies} property to ensure + * that tiles which have geometry overlapping other. + * + * If you pan further enough left / right, you will see that the tile disappears. + * + * It combines part of the https://www.harp.gl/docs/master/examples/#object-picking.html and + * https://www.harp.gl/docs/master/examples/#datasource_custom.html examples. + * ``` + * {@link @here/harp-mapview#Tile}s that contain the geometry from another Tile need to have a + * reference to the Tile containing the overlapping geometry, this is achieved using the + * `dependencies` property of the {@link @here/harp-datasource-protocol#DecodedTile} + * ```typescript + * [[include:tile_dependencies.ts]] + * ``` + **/ + +export namespace TileDependenciesExample { + const guiOptions = { tileDependencies: false }; + document.body.innerHTML += ` + +

Click/touch a feature on the map to read its data (Land masses are not features). +

+

+    `;
+    class CustomDataProvider implements DataProvider {
+        enableTileDependencies: boolean = false;
+        connect() {
+            // Here you could connect to the service.
+            return Promise.resolve();
+        }
+
+        ready() {
+            // Return true if connect was successful.
+            return true;
+        }
+
+        getTile(tileKey: TileKey, abortSignal?: AbortSignal): Promise {
+            // Generate some artifical data. Normally you would do a fetch here.
+            // In this example we create some geometry in geo space that will be converted to
+            // local world space by [[CustomDecoder.convertToLocalWorldCoordinates]]
+
+            const data = new Array();
+            // Do some scaling so that the data fits into the tile.
+            // tslint:disable-next-line: no-bitwise
+            const scale = 10.0 / (1 << tileKey.level);
+            data.push(0.0, 0.0);
+            for (let t = 0.0; t < Math.PI * 4; t += 0.1) {
+                const x = Math.cos(t) * t * scale;
+                const y = Math.sin(t) * t * scale * 10;
+                data.push(x, y);
+            }
+
+            const mainTileKey = new TileKey(16384, 16384, 15);
+            if (tileKey.mortonCode() === mainTileKey.mortonCode()) {
+                return Promise.resolve(new Float32Array([data.length, ...data, 0]));
+            } else {
+                // Simulate that the tile contents spread over multiple tiles
+                if (
+                    this.enableTileDependencies &&
+                    (tileKey.column >= mainTileKey.column - 3 ||
+                        tileKey.column <= mainTileKey.column + 3)
+                ) {
+                    //snippet:tile_dependencies.ts
+                    return Promise.resolve(new Float32Array([0, 1, mainTileKey.mortonCode()]));
+                    //end:tile_dependencies.ts
+                }
+            }
+            return Promise.resolve({});
+        }
+    }
+
+    // It is not mandatory to create a derived class to represent the tiles from the
+    // CustomDataSource. It is just done here to show that it's possible.
+    class CustomTile extends Tile {}
+
+    class CustomDataSource extends TileDataSource {
+        constructor(options: TileDataSourceOptions) {
+            super(new TileFactory(CustomTile), options);
+        }
+    }
+
+    // Create a custom theme that will be used to style the data from the CustomDataSource.
+    function customTheme(): Theme {
+        const theme: Theme = {
+            // Create some lights for the "standard" technique.
+            lights: [
+                {
+                    type: "ambient",
+                    color: "#FFFFFF",
+                    name: "ambientLight",
+                    intensity: 0.9
+                }
+            ],
+            styles: {
+                // "customStyleSet" has to match the StyleSetName that is passed when creating
+                // the CustomDataSource.
+                // We distinguish different data by using the layer attribute that comes with the
+                // data.
+                customStyleSet: [
+                    {
+                        when: ["==", ["get", "layer"], "line-layer"],
+                        technique: "solid-line",
+                        attr: {
+                            color: "#ff0000",
+                            lineWidth: "10px",
+                            clipping: false
+                        }
+                    }
+                ]
+            }
+        };
+        return theme;
+    }
+
+    function getCanvasPosition(
+        event: MouseEvent | Touch,
+        canvas: HTMLCanvasElement
+    ): { x: number; y: number } {
+        const { left, top } = canvas.getBoundingClientRect();
+        return { x: event.clientX - Math.floor(left), y: event.clientY - Math.floor(top) };
+    }
+
+    let lastCanvasPosition: { x: number; y: number } | undefined;
+
+    // Trigger picking event only if there's (almost) no dragging.
+    function isPick(eventPosition: { x: number; y: number }) {
+        const MAX_MOVE = 5;
+        return (
+            lastCanvasPosition &&
+            Math.abs(lastCanvasPosition.x - eventPosition.x) <= MAX_MOVE &&
+            Math.abs(lastCanvasPosition.y - eventPosition.y) <= MAX_MOVE
+        );
+    }
+
+    const element = document.getElementById("mouse-picked-result") as HTMLPreElement;
+    let current: PickResult | undefined;
+
+    function handlePick(mapViewUsed: MapView, x: number, y: number) {
+        // get an array of intersection results from MapView
+
+        let usableIntersections = mapViewUsed
+            .intersectMapObjects(x, y)
+            .filter(pr => (pr.intersection as any).object.userData.dataSource !== "background");
+        if (usableIntersections.length > 1) {
+            usableIntersections = usableIntersections.filter(item => item !== current);
+        }
+
+        if (usableIntersections.length === 0) {
+            // Hide helper box
+            element.style.visibility = "hidden";
+            return;
+        }
+
+        // Get userData from the first result;
+        current = usableIntersections[0];
+
+        // Show helper box
+        element.style.visibility = "visible";
+
+        // Display userData inside of helper box
+        element.innerText = JSON.stringify(current.point, undefined, 2);
+    }
+
+    // Create a new MapView for the HTMLCanvasElement of the given id.
+    function initializeMapView(id: string): MapView {
+        const canvas = document.getElementById(id) as HTMLCanvasElement;
+
+        const map = new MapView({
+            canvas,
+            theme: customTheme(),
+            decoderUrl: "decoder.bundle.js"
+        });
+
+        CopyrightElementHandler.install("copyrightNotice", map);
+
+        const mapControls = new MapControls(map);
+        mapControls.tiltEnabled = false;
+
+        map.lookAt({ target: [0, 0], zoomLevel: 16 });
+        const ui = new MapControlsUI(mapControls);
+        canvas.parentElement!.appendChild(ui.domElement);
+        map.resize(window.innerWidth, window.innerHeight);
+
+        window.addEventListener("resize", () => {
+            map.resize(window.innerWidth, window.innerHeight);
+        });
+
+        const customDataProvider = new CustomDataProvider();
+        // snippet:tile_dependencies_create.ts
+        const customDatasource = new CustomDataSource({
+            name: "customDatasource",
+            styleSetName: "customStyleSet",
+            tilingScheme: webMercatorTilingScheme,
+            dataProvider: customDataProvider,
+            concurrentDecoderServiceName: CUSTOM_DECODER_SERVICE_TYPE,
+            storageLevelOffset: -1
+        });
+        map.addDataSource(customDatasource);
+
+        // Also visualize the tile borders:
+        const debugDataSource = new DebugTileDataSource(webMercatorTilingScheme, "debug", 20);
+        map.addDataSource(debugDataSource);
+
+        canvas.addEventListener("mouseup", event => {
+            const canvasPos = getCanvasPosition(event, canvas);
+            if (isPick(canvasPos)) {
+                handlePick(map, canvasPos.x, canvasPos.y);
+            }
+        });
+
+        canvas.addEventListener("mousedown", event => {
+            lastCanvasPosition = getCanvasPosition(event, canvas);
+        });
+
+        const gui = new GUI({ width: 300 });
+        gui.add(guiOptions, "tileDependencies").onChange(val => {
+            customDataProvider.enableTileDependencies = !customDataProvider.enableTileDependencies;
+            map.clearTileCache();
+            map.update();
+        });
+        return map;
+    }
+
+    initializeMapView("mapCanvas");
+
+    const instructions = `
+Pan to the left / right until the tile in the center disappears.
+To enable usage of the tile dependencies, check the checkbox,
+the geometry will now always be visible.`; + const message = document.createElement("div"); + message.style.position = "absolute"; + message.style.cssFloat = "right"; + message.style.top = "60px"; + message.style.right = "10px"; + message.style.backgroundColor = "grey"; + message.innerHTML = instructions; + document.body.appendChild(message); +} diff --git a/@here/harp-examples/webpack.config.js b/@here/harp-examples/webpack.config.js index 53ec98a363..6e0941778b 100644 --- a/@here/harp-examples/webpack.config.js +++ b/@here/harp-examples/webpack.config.js @@ -102,13 +102,16 @@ const commonConfig = { // @ts-ignore mode: process.env.NODE_ENV || "development", plugins: [ - new HardSourceWebpackPlugin(), new webpack.DefinePlugin({ THEMES: JSON.stringify(themeList) }) ] }; +if (!process.env.HARP_NO_HARD_SOURCE_CACHE) { + commonConfig.plugins.push(new HardSourceWebpackPlugin()); +} + const decoderConfig = merge(commonConfig, { target: "webworker", entry: { diff --git a/@here/harp-geometry/lib/ClipPolygon.ts b/@here/harp-geometry/lib/ClipPolygon.ts index 1c8bde2cd6..86d85c02c4 100644 --- a/@here/harp-geometry/lib/ClipPolygon.ts +++ b/@here/harp-geometry/lib/ClipPolygon.ts @@ -6,63 +6,192 @@ import { Vector2 } from "three"; -const tmpBA = new Vector2(); -const tmpQP = new Vector2(); -const tmpA = new Vector2(); -const tmpB = new Vector2(); - /** - * Clip the given polygon using the Sutherland-Hodgman algorithm. + * Abstract helper class used to implement the Sutherland-Hodgman clipping algorithm. + * + * @remarks + * Concrete implementation of this class are used to clip a polygon + * against one edge of a bounding box. + * + * @internal */ -export function clipPolygon(polygon: Vector2[], clip: Vector2[]): Vector2[] { - if (polygon.length === 0) { - return polygon; - } - if (!polygon[0].equals(polygon[polygon.length - 1])) { - // close the polygon if needed. - polygon = [...polygon, polygon[0]]; - } - let outputList = polygon; - for (let e = 0; e < clip.length; ++e) { - const p = clip[e]; - const q = clip[(e + 1) % clip.length]; - const inputList = outputList; - outputList = []; +abstract class ClippingEdge { + /** + * Tests if the given point is inside this clipping edge. + * + * @param point A point of the polygon. + * @param extent The extent of the bounding box. + */ + abstract inside(point: Vector2, extent: number): boolean; + + /** + * Computes the intersection of a line and this clipping edge. + * + * @remarks + * Specialization of {@link https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection + * | line-line intersection}. + * + * @param a A point of the segment to clip. + * @param b A point of the segment to clip. + * @param extent The extent of the bounding box. + */ + abstract computeIntersection(a: Vector2, b: Vector2, extent: number): Vector2; + + /** + * Clip the polygon against this clipping edge. + * + * @param polygon Clip the polygon against this edge. + * @param extent The extent of the bounding box. + */ + clipPolygon(polygon: Vector2[], extent: number): Vector2[] { + const inputList = polygon; + + polygon = []; + + const pushPoint = (point: Vector2) => { + if (polygon.length === 0 || !polygon[polygon.length - 1].equals(point)) { + polygon.push(point); + } + }; + for (let i = 0; i < inputList.length; ++i) { const currentPoint = inputList[i]; const prevPoint = inputList[(i + inputList.length - 1) % inputList.length]; - if (inside(currentPoint, p, q)) { - if (!inside(prevPoint, p, q)) { - outputList.push(computeIntersection(prevPoint, currentPoint, p, q)); + if (this.inside(currentPoint, extent)) { + if (!this.inside(prevPoint, extent)) { + pushPoint(this.computeIntersection(prevPoint, currentPoint, extent)); } - outputList.push(currentPoint); - } else if (inside(prevPoint, p, q)) { - outputList.push(computeIntersection(prevPoint, currentPoint, p, q)); + pushPoint(currentPoint); + } else if (this.inside(prevPoint, extent)) { + pushPoint(this.computeIntersection(prevPoint, currentPoint, extent)); } } + + return polygon; + } +} + +class TopClippingEdge extends ClippingEdge { + /** @override */ + inside(point: Vector2): boolean { + return point.y > 0; + } + + /** + * Computes the intersection of a line and this clipping edge. + * + * @remarks + * Find the intersection point between the line defined by the points `a` and `b` + * and the edge defined by the points `(0, 0)` and `(0, extent)`. + * + * @override + * + */ + computeIntersection(a: Vector2, b: Vector2): Vector2 { + const { x: x1, y: y1 } = a; + const { x: x2, y: y2 } = b; + return new Vector2((x1 * y2 - y1 * x2) / -(y1 - y2), 0).round(); + } +} + +class RightClippingEdge extends ClippingEdge { + /** @override */ + inside(point: Vector2, extent: number): boolean { + return point.x < extent; + } + + /** + * Computes the intersection of a line and this clipping edge. + * + * @remarks + * Find the intersection point between the line defined by the points `a` and `b` + * and the edge defined by the points `(extent, 0)` and `(extent, extent)`. + * + * @override + * + */ + computeIntersection(a: Vector2, b: Vector2, extent: number): Vector2 { + const { x: x1, y: y1 } = a; + const { x: x2, y: y2 } = b; + return new Vector2(extent, (x1 * y2 - y1 * x2 - (y1 - y2) * -extent) / (x1 - x2)).round(); } - return outputList; } -function computeIntersection( - a: Vector2, - b: Vector2, - p: Vector2, - q: Vector2, - result = new Vector2() -): Vector2 { - tmpBA.subVectors(b, a); - tmpQP.subVectors(q, p); - const c1 = a.cross(tmpBA); - const c2 = p.cross(tmpQP); - const D = tmpBA.cross(tmpQP); - const x = (tmpBA.x * c2 - tmpQP.x * c1) / D; - const y = (tmpBA.y * c2 - tmpQP.y * c1) / D; - return result.set(x, y).round(); +class BottomClipEdge extends ClippingEdge { + /** @override */ + inside(point: Vector2, extent: number): boolean { + return point.y < extent; + } + + /** + * Computes the intersection of a line and this clipping edge. + * + * @remarks + * Find the intersection point between the line defined by the points `a` and `b` + * and the edge defined by the points `(extent, extent)` and `(0, extent)`. + * + * @override + * + */ + computeIntersection(a: Vector2, b: Vector2, extent: number): Vector2 { + const { x: x1, y: y1 } = a; + const { x: x2, y: y2 } = b; + return new Vector2((x1 * y2 - y1 * x2 - (x1 - x2) * extent) / -(y1 - y2), extent).round(); + } } -function inside(point: Vector2, p: Vector2, q: Vector2) { - tmpA.subVectors(q, p); - tmpB.subVectors(point, p); - return tmpA.cross(tmpB) > 0; +class LeftClippingEdge extends ClippingEdge { + /** @override */ + inside(point: Vector2) { + return point.x > 0; + } + + /** + * Computes the intersection of a line and this clipping edge. + * + * @remarks + * Find the intersection point between the line defined by the points `a` and `b` + * and the edge defined by the points `(0, extent)` and `(0, 0)`. + * + * @override + * + */ + computeIntersection(a: Vector2, b: Vector2): Vector2 { + const { x: x1, y: y1 } = a; + const { x: x2, y: y2 } = b; + return new Vector2(0, (x1 * y2 - y1 * x2) / (x1 - x2)).round(); + } +} + +const clipEdges = [ + new TopClippingEdge(), + new RightClippingEdge(), + new BottomClipEdge(), + new LeftClippingEdge() +]; + +/** + * Clip the given polygon using the Sutherland-Hodgman algorithm. + * + * @remarks + * The coordinates of the polygon must be integer numbers. + * + * @param polygon The vertices of the polygon to clip. + * @param extent The extents of the rectangle to clip against. + */ +export function clipPolygon(polygon: Vector2[], extent: number): Vector2[] { + if (polygon.length === 0) { + return polygon; + } + + if (!polygon[0].equals(polygon[polygon.length - 1])) { + // close the polygon if needed. + polygon = [...polygon, polygon[0]]; + } + + for (const clip of clipEdges) { + polygon = clip.clipPolygon(polygon, extent); + } + + return polygon; } diff --git a/@here/harp-geometry/lib/EdgeLengthGeometrySubdivisionModifier.ts b/@here/harp-geometry/lib/EdgeLengthGeometrySubdivisionModifier.ts index 7a7cfb7eae..8b7107814b 100644 --- a/@here/harp-geometry/lib/EdgeLengthGeometrySubdivisionModifier.ts +++ b/@here/harp-geometry/lib/EdgeLengthGeometrySubdivisionModifier.ts @@ -27,10 +27,10 @@ export enum SubdivisionMode { * length of edges. */ export class EdgeLengthGeometrySubdivisionModifier extends SubdivisionModifier { - private m_projectedBox: Box3Like; - private m_maxLength: number; - private m_maxLengthX: number; - private m_maxLengthY: number; + private readonly m_projectedBox: Box3Like; + private readonly m_maxLength: number; + private readonly m_maxLengthX: number; + private readonly m_maxLengthY: number; /** * Constructs a new [[EdgeLengthGeometrySubdivisionModifier]]. diff --git a/@here/harp-geometry/package.json b/@here/harp-geometry/package.json index 564f2f00ff..c9b2032616 100644 --- a/@here/harp-geometry/package.json +++ b/@here/harp-geometry/package.json @@ -27,13 +27,15 @@ "@types/mocha": "^7.0.2", "@types/node": "^14.0.5", "@types/sinon": "^9.0.4", + "@types/earcut": "^2.1.1", "chai": "^4.0.2", "cross-env": "^7.0.2", "mocha": "^7.2.0", "sinon": "^9.0.2", "source-map-support": "^0.5.19", "three": "^0.118", - "typescript": "^3.9.3" + "typescript": "^3.9.3", + "earcut": "^2.2.2" }, "peerDependencies": { "three": "^0.118" diff --git a/@here/harp-geometry/test/ClipPolygonTest.ts b/@here/harp-geometry/test/ClipPolygonTest.ts index da74cd8726..7665425530 100644 --- a/@here/harp-geometry/test/ClipPolygonTest.ts +++ b/@here/harp-geometry/test/ClipPolygonTest.ts @@ -5,6 +5,7 @@ */ import { assert } from "chai"; +import earcut from "earcut"; import { ShapeUtils, Vector2 } from "three"; import { clipPolygon } from "../lib/ClipPolygon"; @@ -20,7 +21,7 @@ describe("ClipPolygon", () => { it("Full quad convering the tile (outer ring)", () => { const polygon = [...tileBounds]; - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(ShapeUtils.isClockWise(clippedPolygon), ShapeUtils.isClockWise(polygon)); assert.strictEqual(ShapeUtils.area(clippedPolygon), ShapeUtils.area(polygon)); @@ -28,7 +29,7 @@ describe("ClipPolygon", () => { it("Full quad convering the tile (inter ring)", () => { const polygon = [...tileBounds].reverse(); - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(ShapeUtils.isClockWise(clippedPolygon), ShapeUtils.isClockWise(polygon)); assert.strictEqual(ShapeUtils.area(clippedPolygon), ShapeUtils.area(polygon)); @@ -41,7 +42,7 @@ describe("ClipPolygon", () => { new Vector2(extents + 20, extents + 20), new Vector2(-20, extents + 20) ]; - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(ShapeUtils.isClockWise(clippedPolygon), ShapeUtils.isClockWise(polygon)); assert.strictEqual(ShapeUtils.area(clippedPolygon), ShapeUtils.area(tileBounds)); @@ -54,7 +55,7 @@ describe("ClipPolygon", () => { new Vector2(extents + 20, extents + 20), new Vector2(-20, extents + 20) ].reverse(); - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(ShapeUtils.isClockWise(clippedPolygon), ShapeUtils.isClockWise(polygon)); assert.strictEqual(ShapeUtils.area(clippedPolygon), -ShapeUtils.area(tileBounds)); @@ -69,8 +70,9 @@ describe("ClipPolygon", () => { new Vector2(extents * 4, extents * 10) ]; - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); + assert.strictEqual(clippedPolygon.length, 4); assert.strictEqual(ShapeUtils.isClockWise(clippedPolygon), ShapeUtils.isClockWise(polygon)); assert.strictEqual(ShapeUtils.area(clippedPolygon), ShapeUtils.area(tileBounds)); @@ -83,7 +85,7 @@ describe("ClipPolygon", () => { new Vector2(extents * 4, extents * 10) ].reverse(); - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(clippedPolygon.length, 4); assert.strictEqual(ShapeUtils.isClockWise(clippedPolygon), ShapeUtils.isClockWise(polygon)); @@ -98,7 +100,7 @@ describe("ClipPolygon", () => { new Vector2(-1000, 1000) ]; - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(clippedPolygon.length, 0); }); @@ -111,7 +113,7 @@ describe("ClipPolygon", () => { new Vector2(-1000, 1000) ].reverse(); - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(clippedPolygon.length, 0); }); @@ -131,7 +133,7 @@ describe("ClipPolygon", () => { new Vector2(0, 1000) ]; - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(clippedPolygon.length, 4); assert.strictEqual(JSON.stringify(clippedPolygon), JSON.stringify(expectedClippedPolygon)); @@ -145,7 +147,7 @@ describe("ClipPolygon", () => { new Vector2(-1000, 1000) ].reverse(); - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.notStrictEqual(clippedPolygon, polygon); assert.strictEqual(clippedPolygon.length, 4); assert.strictEqual(ShapeUtils.area(clippedPolygon), -(20 * 1000)); @@ -178,7 +180,7 @@ describe("ClipPolygon", () => { assert.isTrue(polygon.some(vert => vert.y < 0)); assert.isTrue(polygon.some(vert => vert.y > extents)); - const clippedPolygon = clipPolygon(polygon, tileBounds); + const clippedPolygon = clipPolygon(polygon, extents); assert.isNotEmpty(clippedPolygon); @@ -202,4 +204,26 @@ describe("ClipPolygon", () => { assert.isDefined(clippedPolygon.find(p => p.equals(v))); }); }); + + it("Concave polygon resulting into 2 parts after clipping", () => { + const polygon: Vector2[] = [ + new Vector2(-100, 0), + new Vector2(4096, 0), + new Vector2(-50, 2048), + new Vector2(4096, 4096), + new Vector2(-100, 4096) + ]; + + const clippedPolygon = clipPolygon(polygon, extents); + + const expectedPolygon = clippedPolygon.reduce((points, { x, y }) => { + // tslint:disable-next-line: no-bitwise + points.push(x | 0, y | 0); + return points; + }, [] as number[]); + + assert.deepEqual(expectedPolygon, [0, 0, 4096, 0, 0, 2023, 0, 2073, 4096, 4096, 0, 4096]); + + assert.deepEqual(earcut(expectedPolygon), [0, 1, 2, 3, 4, 5]); + }); }); diff --git a/@here/harp-geoutils/lib/coordinates/GeoBox.ts b/@here/harp-geoutils/lib/coordinates/GeoBox.ts index a9f5d269f5..a4260d464a 100644 --- a/@here/harp-geoutils/lib/coordinates/GeoBox.ts +++ b/@here/harp-geoutils/lib/coordinates/GeoBox.ts @@ -215,7 +215,7 @@ export class GeoBox implements GeoBoxExtentLike { * Clones this `GeoBox` instance. */ clone(): GeoBox { - return new GeoBox(this.southWest, this.northEast); + return new GeoBox(this.southWest.clone(), this.northEast.clone()); } /** diff --git a/@here/harp-geoutils/lib/projection/EquirectangularProjection.ts b/@here/harp-geoutils/lib/projection/EquirectangularProjection.ts index ca1f35d3a2..eca2ca7ce4 100644 --- a/@here/harp-geoutils/lib/projection/EquirectangularProjection.ts +++ b/@here/harp-geoutils/lib/projection/EquirectangularProjection.ts @@ -73,7 +73,7 @@ class EquirectangularProjection extends Projection { (THREE.MathUtils.degToRad(geoPoint.latitude) + Math.PI * 0.5) * EquirectangularProjection.geoToWorldScale * this.unitScale; - result.z = geoPoint.altitude || 0; + result.z = geoPoint.altitude ?? 0; return result; } @@ -128,7 +128,7 @@ class EquirectangularProjection extends Projection { result.position.z = worldCenter.z; result.extents.x = sizeX * 0.5 * this.unitScale; result.extents.y = sizeY * 0.5 * this.unitScale; - result.extents.z = Math.max(Number.EPSILON, (altitudeSpan || 0) * 0.5); + result.extents.z = Math.max(Number.EPSILON, (altitudeSpan ?? 0) * 0.5); } return result; } diff --git a/@here/harp-geoutils/lib/projection/IdentityProjection.ts b/@here/harp-geoutils/lib/projection/IdentityProjection.ts index 0f70d14e85..06b9ea22bc 100644 --- a/@here/harp-geoutils/lib/projection/IdentityProjection.ts +++ b/@here/harp-geoutils/lib/projection/IdentityProjection.ts @@ -53,7 +53,7 @@ class IdentityProjection extends Projection { } result.x = THREE.MathUtils.degToRad(geoPoint.longitude); result.y = THREE.MathUtils.degToRad(geoPoint.latitude); - result.z = geoPoint.altitude || 0; + result.z = geoPoint.altitude ?? 0; return result; } diff --git a/@here/harp-geoutils/lib/projection/MercatorProjection.ts b/@here/harp-geoutils/lib/projection/MercatorProjection.ts index 356e90bd01..c91a528ed3 100644 --- a/@here/harp-geoutils/lib/projection/MercatorProjection.ts +++ b/@here/harp-geoutils/lib/projection/MercatorProjection.ts @@ -93,7 +93,7 @@ class MercatorProjection extends Projection { result.y = (MercatorProjection.latitudeClampProject(geoPoint.latitudeInRadians) * 0.5 + 0.5) * this.unitScale; - result.z = geoPoint.altitude || 0; + result.z = geoPoint.altitude ?? 0; return result; } @@ -157,7 +157,7 @@ class MercatorProjection extends Projection { result.position.z = worldCenter.z; result.extents.x = longitudeSpan * 0.5; result.extents.y = latitudeSpan * 0.5; - result.extents.z = Math.max(Number.EPSILON, (geoBox.altitudeSpan || 0) * 0.5); + result.extents.z = Math.max(Number.EPSILON, (geoBox.altitudeSpan ?? 0) * 0.5); } else { throw new Error("invalid bounding box"); } @@ -262,7 +262,7 @@ class WebMercatorProjection extends MercatorProjection { result.x = ((geoPoint.longitude + 180) / 360) * this.unitScale; const sy = Math.sin(MercatorProjection.latitudeClamp(geoPoint.latitudeInRadians)); result.y = (0.5 - Math.log((1 + sy) / (1 - sy)) / (4 * Math.PI)) * this.unitScale; - result.z = geoPoint.altitude || 0; + result.z = geoPoint.altitude ?? 0; return result; } diff --git a/@here/harp-geoutils/lib/projection/SphereProjection.ts b/@here/harp-geoutils/lib/projection/SphereProjection.ts index 1daba3d22a..af80d32adb 100644 --- a/@here/harp-geoutils/lib/projection/SphereProjection.ts +++ b/@here/harp-geoutils/lib/projection/SphereProjection.ts @@ -70,7 +70,7 @@ function makeBox3( worldBox: Bounds, unitScale: number ): Bounds { - const halfEquatorialRadius = (unitScale + (geoBox.maxAltitude || 0)) * 0.5; + const halfEquatorialRadius = (unitScale + (geoBox.maxAltitude ?? 0)) * 0.5; const minLongitude = THREE.MathUtils.degToRad(geoBox.west); const maxLongitude = THREE.MathUtils.degToRad(geoBox.east); @@ -144,7 +144,7 @@ function project( worldpoint: WorldCoordinates, unitScale: number ): typeof worldpoint { - const radius = unitScale + (geoPoint.altitude || 0); + const radius = unitScale + (geoPoint.altitude ?? 0); const latitude = THREE.MathUtils.degToRad(geoPoint.latitude); const longitude = THREE.MathUtils.degToRad(geoPoint.longitude); const cosLatitude = Math.cos(latitude); @@ -296,8 +296,8 @@ class SphereProjection extends Projection { sinMidY * cosSouth * (cosMidX * cosEast + sinMidX * sinEast); } - const rMax = (this.unitScale + (geoBox.maxAltitude || 0)) * 0.5; - const rMin = (this.unitScale + (geoBox.minAltitude || 0)) * 0.5; + const rMax = (this.unitScale + (geoBox.maxAltitude ?? 0)) * 0.5; + const rMin = (this.unitScale + (geoBox.minAltitude ?? 0)) * 0.5; // min(dot(southEast, zAxis), dot(northEast, zAxis)) diff --git a/@here/harp-geoutils/lib/projection/TransverseMercatorProjection.ts b/@here/harp-geoutils/lib/projection/TransverseMercatorProjection.ts index 571689c4c0..14382d1a71 100644 --- a/@here/harp-geoutils/lib/projection/TransverseMercatorProjection.ts +++ b/@here/harp-geoutils/lib/projection/TransverseMercatorProjection.ts @@ -68,8 +68,8 @@ class TransverseMercatorProjection extends Projection { /** @override */ readonly type: ProjectionType = ProjectionType.Planar; - private m_phi0: number = 0; - private m_lambda0: number = 0; + private readonly m_phi0: number = 0; + private readonly m_lambda0: number = 0; constructor(readonly unitScale: number) { super(unitScale); @@ -124,7 +124,7 @@ class TransverseMercatorProjection extends Projection { this.unitScale * (THREE.MathUtils.clamp(result.x * outScale + 0.5, 0, 1) + offset); result.y = this.unitScale * THREE.MathUtils.clamp(result.y * outScale + 0.5, 0, 1); - result.z = geoPoint.altitude || 0; + result.z = geoPoint.altitude ?? 0; return result; } @@ -301,7 +301,7 @@ class TransverseMercatorProjection extends Projection { const latitudes = geoPoints.map(g => g.latitude); const longitudes = geoPoints.filter(g => Math.abs(g.latitude) < 90).map(g => g.longitude); - const altitudes = geoPoints.map(g => g.altitude || 0); + const altitudes = geoPoints.map(g => g.altitude ?? 0); const minGeo = new GeoCoordinates( Math.min(...latitudes), diff --git a/@here/harp-geoutils/lib/tiling/TileTreeTraverse.ts b/@here/harp-geoutils/lib/tiling/TileTreeTraverse.ts index edb1cbd5ec..53eaf1e602 100644 --- a/@here/harp-geoutils/lib/tiling/TileTreeTraverse.ts +++ b/@here/harp-geoutils/lib/tiling/TileTreeTraverse.ts @@ -9,7 +9,7 @@ import { SubTiles } from "./SubTiles"; import { TileKey } from "./TileKey"; export class TileTreeTraverse { - private m_subdivisionScheme: SubdivisionScheme; + private readonly m_subdivisionScheme: SubdivisionScheme; constructor(subdivisionScheme: SubdivisionScheme) { this.m_subdivisionScheme = subdivisionScheme; diff --git a/@here/harp-geoutils/test/GeoBoxTest.ts b/@here/harp-geoutils/test/GeoBoxTest.ts index 3ffddf155f..cbfa67dac5 100644 --- a/@here/harp-geoutils/test/GeoBoxTest.ts +++ b/@here/harp-geoutils/test/GeoBoxTest.ts @@ -7,7 +7,7 @@ // tslint:disable:only-arrow-functions // Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions -import { assert } from "chai"; +import { assert, expect } from "chai"; import { GeoBox } from "../lib/coordinates/GeoBox"; import { GeoCoordinates } from "../lib/coordinates/GeoCoordinates"; import { MathUtils } from "../lib/math/MathUtils"; @@ -65,4 +65,11 @@ describe("GeoBox", function() { GEOCOORDS_EPSILON ); }); + + it("clone is not affected by changes in original", function() { + const original = new GeoBox(new GeoCoordinates(0, 0), new GeoCoordinates(1, 1)); + const clone = original.clone(); + expect(clone.southWest).not.equals(original.southWest); + expect(clone.northEast).not.equals(original.northEast); + }); }); diff --git a/@here/harp-lines/test/rendering/RenderLines.ts b/@here/harp-lines/test/rendering/RenderLines.ts index 9ec62badcc..c6a7313639 100644 --- a/@here/harp-lines/test/rendering/RenderLines.ts +++ b/@here/harp-lines/test/rendering/RenderLines.ts @@ -86,6 +86,10 @@ describe("Rendering lines: ", function() { { name: "line at obtuse angle - right", points: [-50, 0, 10, 0, 0, 10, 50, -50, 10] + }, + { + name: "lines that cross", + points: [-50, 0, 0, 50, 0, 0, 0, 50, 0, 0, -50, 0] } ]; @@ -193,6 +197,10 @@ describe("Rendering lines: ", function() { { name: "line 3 points - right: -3", points: [-50, 0, 0, 0, 0, 0, 0, -3, 0] + }, + { + name: "lines that cross", + points: [-50, 0, 0, 50, 0, 0, 0, 50, 0, 0, -50, 0] } ]; diff --git a/@here/harp-lrucache/lib/LRUCache.ts b/@here/harp-lrucache/lib/LRUCache.ts index 4f14508426..a4354fbcbf 100644 --- a/@here/harp-lrucache/lib/LRUCache.ts +++ b/@here/harp-lrucache/lib/LRUCache.ts @@ -48,7 +48,7 @@ export class LRUCache { /** * The internal map object that keeps the key-value pairs and their order. */ - private m_map = new Map>(); + private readonly m_map = new Map>(); /** * The newest entry, i.e. the most recently used item. diff --git a/@here/harp-map-controls/lib/CameraAnimationBuilder.ts b/@here/harp-map-controls/lib/CameraAnimationBuilder.ts index 654107897f..d458592df8 100644 --- a/@here/harp-map-controls/lib/CameraAnimationBuilder.ts +++ b/@here/harp-map-controls/lib/CameraAnimationBuilder.ts @@ -104,7 +104,7 @@ export class CameraAnimationBuilder { const startWorldTarget: THREE.Vector3 = new THREE.Vector3(); mapView.projection.projectPoint(startControlPoint.target, startWorldTarget); let maxAltitude = - altitude || + altitude ?? 2 * startWorldTarget.distanceTo( mapView.projection.projectPoint(targetControlPoint.target) diff --git a/@here/harp-map-controls/lib/CameraKeyTrackAnimation.ts b/@here/harp-map-controls/lib/CameraKeyTrackAnimation.ts index 1d9e3f2984..aae24bdacc 100644 --- a/@here/harp-map-controls/lib/CameraKeyTrackAnimation.ts +++ b/@here/harp-map-controls/lib/CameraKeyTrackAnimation.ts @@ -15,6 +15,7 @@ import { LoggerManager } from "@here/harp-utils"; import THREE = require("three"); const logger = LoggerManager.instance.create("CameraKeyTrackAnimation"); +const MIN_DISTANCE = 0; /** * The Options used to create a ControlPoint @@ -55,7 +56,7 @@ export class ControlPoint : new GeoCoordinates(0, 0); this.tilt = options.tilt ?? 0; this.heading = options.heading ?? 0; - this.distance = options.distance ?? 0; + this.distance = options.distance ?? MIN_DISTANCE; this.name = options.name ?? Date.now().toString(); } } @@ -115,20 +116,23 @@ class AnimationDummy extends THREE.Object3D { * @beta */ export class CameraKeyTrackAnimation { - private m_animationClip: THREE.AnimationClip; - private m_animationMixer: THREE.AnimationMixer; - private m_animationAction: THREE.AnimationAction; - private m_dummy: AnimationDummy = new AnimationDummy("dummy"); - private m_azimuthAxis = new THREE.Vector3(0, 0, 1); - private m_altitudeAxis = new THREE.Vector3(1, 0, 0); + private readonly m_animationClip: THREE.AnimationClip; + private readonly m_animationMixer: THREE.AnimationMixer; + private readonly m_animationAction: THREE.AnimationAction; + private readonly m_dummy: AnimationDummy = new AnimationDummy("dummy"); + private readonly m_azimuthAxis = new THREE.Vector3(0, 0, 1); + private readonly m_altitudeAxis = new THREE.Vector3(1, 0, 0); private m_running: boolean = false; private m_onFinished: (() => void) | undefined; - private m_name?: string; + private readonly m_name?: string; private m_lastFrameTime: number = 0; - private m_animateCb: (event: RenderEvent) => void; + private readonly m_animateCb: (event: RenderEvent) => void; - constructor(private m_mapView: MapView, private m_options: CameraKeyTrackAnimationOptions) { + constructor( + private readonly m_mapView: MapView, + private m_options: CameraKeyTrackAnimationOptions + ) { const interpolation = this.m_options.interpolation !== undefined ? this.m_options.interpolation @@ -139,7 +143,7 @@ export class CameraKeyTrackAnimation { this.m_options.rotateOnlyClockwise = this.m_options.rotateOnlyClockwise ?? true; - this.m_name = this.m_options.name || "CameraKeyTrackAnimation" + Date.now(); + this.m_name = (this.m_options.name ?? "CameraKeyTrackAnimation") + Date.now(); const timestamps = this.m_options.controlPoints.map(point => { return point.timestamp; @@ -238,7 +242,7 @@ export class CameraKeyTrackAnimation { this.stop(); } this.m_onFinished = onFinished; - this.m_animationAction.play(); + this.m_animationAction.reset().play(); this.m_lastFrameTime = Date.now(); this.m_mapView.addEventListener(MapViewEventNames.Render, this.m_animateCb); this.m_mapView.beginAnimation(); @@ -282,7 +286,7 @@ export class CameraKeyTrackAnimation { const target = this.m_mapView.projection.unprojectPoint(this.m_dummy.position); - const distance = Math.max(0, this.m_dummy.distance); + const distance = Math.max(MIN_DISTANCE, this.m_dummy.distance); if (isNaN(tilt) || isNaN(heading) || isNaN(distance) || !target.isValid()) { logger.error("Cannot update due to invalid data", tilt, heading, distance, target); } diff --git a/@here/harp-map-controls/lib/MapAnimations.ts b/@here/harp-map-controls/lib/MapAnimations.ts index df07fbc2a2..50c106105d 100644 --- a/@here/harp-map-controls/lib/MapAnimations.ts +++ b/@here/harp-map-controls/lib/MapAnimations.ts @@ -121,7 +121,7 @@ export abstract class CameraAnimation { */ update(time?: number): boolean { if (this.tween) { - return this.tween.update(time || PerformanceTimer.now()); + return this.tween.update(time ?? PerformanceTimer.now()); } return false; } @@ -209,7 +209,7 @@ export class CameraRotationAnimation extends CameraAnimation { this.easing = typeof options.easing === "function" ? options.easing - : easingMap.get(options.easing) || TWEEN.Easing.Linear.None; + : easingMap.get(options.easing) ?? TWEEN.Easing.Linear.None; } this.m_lastRotationValue = this.startAngle; @@ -280,13 +280,13 @@ export class CameraRotationAnimation extends CameraAnimation { } } - private beginInteractionListener = (): void => { + private readonly beginInteractionListener = (): void => { if (!this.stopped) { this.stopTween(); } }; - private endInteractionListener = (): void => { + private readonly endInteractionListener = (): void => { if (!this.stopped) { this.startTween(); } @@ -368,7 +368,7 @@ export class CameraPanAnimation extends CameraAnimation { */ readonly interpolation = TWEEN.Interpolation.CatmullRom; - private m_geoCoordinates: GeoCoordinatesLike[]; + private readonly m_geoCoordinates: GeoCoordinatesLike[]; /** * Creates a new `CameraPanAnimation` object. @@ -390,13 +390,13 @@ export class CameraPanAnimation extends CameraAnimation { this.easing = typeof options.easing === "function" ? options.easing - : easingMap.get(options.easing) || TWEEN.Easing.Linear.None; + : easingMap.get(options.easing) ?? TWEEN.Easing.Linear.None; } if (options.interpolation !== undefined) { this.interpolation = typeof options.interpolation === "function" ? options.interpolation - : interpolationMap.get(options.interpolation) || TWEEN.Interpolation.Linear; + : interpolationMap.get(options.interpolation) ?? TWEEN.Interpolation.Linear; } this.m_geoCoordinates = options.geoCoordinates !== undefined ? options.geoCoordinates : []; } @@ -441,7 +441,7 @@ export class CameraPanAnimation extends CameraAnimation { for (const pos of this.m_geoCoordinates) { to.latitude.push(pos.latitude); to.longitude.push(pos.longitude); - to.altitude.push(pos.altitude || this.mapView.camera.position.z); + to.altitude.push(pos.altitude ?? this.mapView.camera.position.z); } this.tween = new TWEEN.Tween(from) diff --git a/@here/harp-map-controls/lib/MapControls.ts b/@here/harp-map-controls/lib/MapControls.ts index ae5f3e073d..8005d72c6a 100644 --- a/@here/harp-map-controls/lib/MapControls.ts +++ b/@here/harp-map-controls/lib/MapControls.ts @@ -235,16 +235,16 @@ export class MapControls extends THREE.EventDispatcher { // Internal variables for animating panning (planar + spherical panning). private m_panIsAnimated: boolean = false; - private m_panDistanceFrameDelta: THREE.Vector3 = new THREE.Vector3(); + private readonly m_panDistanceFrameDelta: THREE.Vector3 = new THREE.Vector3(); private m_panAnimationTime: number = 0; private m_panAnimationStartTime: number = 0; private m_lastAveragedPanDistanceOrAngle: number = 0; private m_currentInertialPanningSpeed: number = 0; - private m_lastPanVector: THREE.Vector3 = new THREE.Vector3(); - private m_rotateGlobeQuaternion: THREE.Quaternion = new THREE.Quaternion(); - private m_lastRotateGlobeAxis: THREE.Vector3 = new THREE.Vector3(); + private readonly m_lastPanVector: THREE.Vector3 = new THREE.Vector3(); + private readonly m_rotateGlobeQuaternion: THREE.Quaternion = new THREE.Quaternion(); + private readonly m_lastRotateGlobeAxis: THREE.Vector3 = new THREE.Vector3(); private m_lastRotateGlobeAngle: number = 0; - private m_lastRotateGlobeFromVector: THREE.Vector3 = new THREE.Vector3(); + private readonly m_lastRotateGlobeFromVector: THREE.Vector3 = new THREE.Vector3(); private m_recentPanDistancesOrAngles: [number, number, number, number, number] = [ 0, 0, @@ -257,7 +257,7 @@ export class MapControls extends THREE.EventDispatcher { // Internal variables for animating zoom. private m_zoomIsAnimated: boolean = false; private m_zoomDeltaRequested: number = 0; - private m_zoomTargetNormalizedCoordinates: THREE.Vector2 = new THREE.Vector2(); + private readonly m_zoomTargetNormalizedCoordinates: THREE.Vector2 = new THREE.Vector2(); private m_zoomAnimationTime: number = 0; private m_zoomAnimationStartTime: number = 0; private m_startZoom: number = 0; @@ -276,8 +276,8 @@ export class MapControls extends THREE.EventDispatcher { private m_tiltState?: TiltState; private m_state: State = State.NONE; - private m_tmpVector2: THREE.Vector2 = new THREE.Vector2(); - private m_tmpVector3: THREE.Vector3 = new THREE.Vector3(); + private readonly m_tmpVector2: THREE.Vector2 = new THREE.Vector2(); + private readonly m_tmpVector3: THREE.Vector3 = new THREE.Vector3(); // Internal variables for animating double tap. private m_tapStartTime: number = 0; @@ -760,7 +760,7 @@ export class MapControls extends THREE.EventDispatcher { Math.min(1, this.m_zoomAnimationTime / this.zoomInertiaDampingDuration) ); - MapViewUtils.zoomOnTargetPosition( + const success = MapViewUtils.zoomOnTargetPosition( this.mapView, this.m_zoomTargetNormalizedCoordinates.x, this.m_zoomTargetNormalizedCoordinates.y, @@ -768,7 +768,7 @@ export class MapControls extends THREE.EventDispatcher { this.m_maxTiltAngle ); - if (resetZoomState) { + if (resetZoomState || !success) { this.m_targetedZoom = undefined; this.m_currentZoom = undefined; } diff --git a/@here/harp-map-controls/lib/MapControlsUI.ts b/@here/harp-map-controls/lib/MapControlsUI.ts index 21ab966054..66662fbfc7 100644 --- a/@here/harp-map-controls/lib/MapControlsUI.ts +++ b/@here/harp-map-controls/lib/MapControlsUI.ts @@ -38,27 +38,27 @@ export class MapControlsUI { */ readonly domElement = document.createElement("div"); - private m_buttonsElement: HTMLDivElement = document.createElement("div"); + private readonly m_buttonsElement: HTMLDivElement = document.createElement("div"); /** * Displays zoom level if [[MapControlsUIOptions.zoomLevel]] is defined. */ - private m_zoomLevelElement: HTMLDivElement | HTMLInputElement | null = null; + private readonly m_zoomLevelElement: HTMLDivElement | HTMLInputElement | null = null; /** * Displays zoom level if [[MapControlsUIOptions.projectionSwitch]] is defined. */ - private m_projectionSwitchElement: HTMLButtonElement | null = null; + private readonly m_projectionSwitchElement: HTMLButtonElement | null = null; /** * Removes focus from input element. */ - private m_onWindowClick: (event: MouseEvent) => void; + private readonly m_onWindowClick: (event: MouseEvent) => void; /** * Updates the display of the zoom level. */ - private m_onMapViewRenderEvent: () => void; + private readonly m_onMapViewRenderEvent: () => void; /** * Constructor of the UI. @@ -255,6 +255,10 @@ export class MapControlsUI { return this; } + get projectionSwitchElement(): HTMLButtonElement | null { + return this.m_projectionSwitchElement; + } + /** * Destroy this [[MapControlsUI]] instance. Unregisters all event handlers used. This method * should be called when you stop using [[MapControlsUI]]. diff --git a/@here/harp-map-controls/test/MapControlsTest.ts b/@here/harp-map-controls/test/MapControlsTest.ts index 8b0eb4562f..43389e2d32 100644 --- a/@here/harp-map-controls/test/MapControlsTest.ts +++ b/@here/harp-map-controls/test/MapControlsTest.ts @@ -7,13 +7,8 @@ // tslint:disable:only-arrow-functions // Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions -import { - GeoCoordinates, - mercatorProjection, - Projection, - sphereProjection -} from "@here/harp-geoutils"; -import { ElevationProvider, MapView, MapViewUtils } from "@here/harp-mapview"; +import { GeoCoordinates, mercatorProjection, sphereProjection } from "@here/harp-geoutils"; +import { MapView, MapViewUtils } from "@here/harp-mapview"; import { expect } from "chai"; import * as sinon from "sinon"; import * as THREE from "three"; @@ -296,8 +291,6 @@ describe("MapControls", function() { }); describe("zoomOnTargetPosition", function() { - const elevationProvider = ({} as any) as ElevationProvider; - function resetCamera(pitch: number, zoomLevel?: number) { const target = GeoCoordinates.fromDegrees(0, 0); const heading = 0; @@ -322,10 +315,9 @@ describe("MapControls", function() { camera.updateMatrixWorld(true); } - function computeZoomLevel(projection: Projection) { - const distance = - projection.unprojectAltitude(camera.position) / - Math.cos(MapViewUtils.extractAttitude(mapView, camera).pitch); + function computeZoomLevel() { + // tslint:disable-next-line: deprecation + const distance = MapViewUtils.getTargetAndDistance(mapView.projection, camera).distance; return MapViewUtils.calculateZoomLevelFromDistance(mapView, distance); } @@ -335,35 +327,58 @@ describe("MapControls", function() { ]) { describe(`${projName} projection`, function() { beforeEach(function() { + const worldTarget = projection.projectPoint( + GeoCoordinates.fromDegrees(0, 0), + new THREE.Vector3() + ); sandbox.stub(mapView, "projection").get(() => projection); sandbox.stub(mapView, "focalLength").get(() => 2000); sandbox.stub(mapView, "minZoomLevel").get(() => 1); sandbox.stub(mapView, "maxZoomLevel").get(() => 20); + sandbox.stub(mapView, "worldTarget").get(() => { + return worldTarget; + }); mapControls = new MapControls(mapView); }); for (const pitch of [0, 45]) { - it(`camera distance is offset by elevation (pitch ${pitch})`, function() { + it(`camera is moved along view direction (pitch ${pitch})`, function() { resetCamera(pitch); - elevationProvider.getHeight = sandbox.stub().returns(0); - sandbox.stub(mapView, "elevationProvider").get(() => elevationProvider); - mapControls.zoomOnTargetPosition(0, 0, 10); - const altitudeWithoutElevation = projection.unprojectAltitude( - camera.position - ); + const initWorldDir = mapView.worldTarget + .clone() + .sub(camera.position) + .normalize(); + + mapControls.zoomOnTargetPosition(0, 0, 11); + const endWorldDir = mapView.worldTarget + .clone() + .sub(camera.position) + .normalize(); + + expect(initWorldDir.dot(endWorldDir)).closeTo(1, 1e-5); + }); + it(`zoom target stays at the same screen coords (pitch ${pitch})`, function() { resetCamera(pitch); - const elevation = 333; - elevationProvider.getHeight = sandbox.stub().returns(elevation); - mapControls.zoomOnTargetPosition(0, 0, 10); - const altitudeWithElevation = projection.unprojectAltitude(camera.position); - const eps = 1e-5; - expect(altitudeWithElevation).closeTo( - altitudeWithoutElevation + elevation, - eps + const initZoomTarget = MapViewUtils.rayCastWorldCoordinates( + mapView, + 0.5, + 0.5 ); + + mapControls.zoomOnTargetPosition(0.5, 0.5, 10); + const endZoomTarget = MapViewUtils.rayCastWorldCoordinates( + mapView, + 0.5, + 0.5 + ); + + expect(initZoomTarget).to.not.equal(undefined); + expect(endZoomTarget).to.not.equal(undefined); + + expect(initZoomTarget!.distanceTo(endZoomTarget!)).to.be.closeTo(0, 1); }); it(`zl is applied even if target is not valid (pitch ${pitch})`, function() { @@ -375,14 +390,14 @@ describe("MapControls", function() { { const expectedZl = 2; mapControls.zoomOnTargetPosition(1, 1, expectedZl); - const actualZl = computeZoomLevel(projection); + const actualZl = computeZoomLevel(); expect(actualZl).closeTo(expectedZl, eps); } resetCamera(pitch, 3); { const expectedZl = 4; mapControls.zoomOnTargetPosition(1, 1, expectedZl); - const actualZl = computeZoomLevel(projection); + const actualZl = computeZoomLevel(); expect(actualZl).closeTo(expectedZl, eps); } }); @@ -406,6 +421,13 @@ describe("MapControls", function() { sandbox.stub(mapView, "zoomLevel").get(() => { return initialZoomLevel; }); + const worldTarget = mapView.projection.projectPoint( + GeoCoordinates.fromDegrees(0, 0), + new THREE.Vector3() + ); + sandbox.stub(mapView, "worldTarget").get(() => { + return worldTarget; + }); // needed to get the initial zoom level from MapView. (mapControls as any).assignZoomAfterTouchZoomRender(); expect(mapControls.zoomLevelTargeted).to.equal(initialZoomLevel); diff --git a/@here/harp-map-theme/package.json b/@here/harp-map-theme/package.json index 3c7646d0f8..3fc47cc6fe 100644 --- a/@here/harp-map-theme/package.json +++ b/@here/harp-map-theme/package.json @@ -26,7 +26,7 @@ "devDependencies": { "@here/harp-atlas-tools": "^0.17.2", "@here/harp-datasource-protocol": "^0.17.2", - "@here/harp-fontcatalog": "^0.1.4", + "@here/harp-fontcatalog": "^0.1.6", "@here/harp-test-utils": "^0.17.2", "@types/ajv": "^1.0.0", "@types/chai": "^4.2.11", diff --git a/@here/harp-mapview-decoder/lib/DataProvider.ts b/@here/harp-mapview-decoder/lib/DataProvider.ts index b734f8274d..54e05e4254 100644 --- a/@here/harp-mapview-decoder/lib/DataProvider.ts +++ b/@here/harp-mapview-decoder/lib/DataProvider.ts @@ -8,9 +8,12 @@ import "@here/harp-fetch"; import { TileKey } from "@here/harp-geoutils"; /** - * Interface for all `DataProvider` subclasses. The `DataProvider` is an abstraction of the tile - * loader which is only responsible for loading the binary data of a specific tile, without any - * relation to displaying or even decoding the data. + * Interface for all `DataProvider` subclasses. + * + * @remarks + * The `DataProvider` is an abstraction of the tile + * loader which is only responsible for loading the data of a specific tile, + * without any relation to displaying or even decoding the data. */ export interface DataProvider { /** @@ -26,11 +29,26 @@ export interface DataProvider { ready(): boolean; /** - * Load the data of a [[Tile]] asynchronously in form of an [[ArrayBufferLike]]. + * Load the data of a {@link @here/map-view@Tile} asynchronously. * * @param tileKey - Address of a tile. * @param abortSignal - Optional AbortSignal to cancel the request. * @returns A promise delivering the data as an [[ArrayBufferLike]], or any object. */ getTile(tileKey: TileKey, abortSignal?: AbortSignal): Promise; + + /** + * An event which fires when this `DataProvider` is invalidated. + * + * @param listener - A function to call when this `DataProvider` is invalidated. + * @returns The function to call to unregister the listener from this event. + * + * @example + * ```typescript + * const dispose = dataProvider.onDidInvalidate?.(() => { + * console.log("invalidated"); + * }); + * ``` + */ + onDidInvalidate?(listener: () => void): () => void; } diff --git a/@here/harp-mapview-decoder/lib/GeoJsonTiler.ts b/@here/harp-mapview-decoder/lib/GeoJsonTiler.ts index b037431688..4fd4f1bf41 100644 --- a/@here/harp-mapview-decoder/lib/GeoJsonTiler.ts +++ b/@here/harp-mapview-decoder/lib/GeoJsonTiler.ts @@ -7,10 +7,20 @@ import { GeoJson, ITiler } from "@here/harp-datasource-protocol"; import { TileKey } from "@here/harp-geoutils"; -// tslint:disable-next-line:no-var-requires -const geojsonvtExport = require("geojson-vt"); +// @ts-ignore +import * as geojsonvtExport from "geojson-vt"; + // to be able to run tests on nodejs -const geojsonvt = geojsonvtExport.default || geojsonvtExport; +const geojsonvt = geojsonvtExport.default ?? geojsonvtExport; + +const EXTENT = 4096; + +// the factor used to compute the size of the buffer. +const BUFFER_FACTOR = 0.05; + +// align the buffer to the next integer multiple of 2. +// tslint:disable-next-line: no-bitwise +const BUFFER = -(-Math.ceil(EXTENT * BUFFER_FACTOR) & -2); interface GeoJsonVtIndex { geojson: GeoJson; @@ -57,8 +67,8 @@ export class GeoJsonTiler implements ITiler { indexMaxZoom: 5, // max zoom in the tile index indexMaxPoints: 100000, // max number of points per tile in the tile index tolerance: 3, // simplification tolerance (higher means simpler) - extent: 4096, // tile extent - buffer: 0, // tile buffer on each side + extent: EXTENT, // tile extent + buffer: BUFFER, // tile buffer on each side lineMetrics: false, // whether to calculate line metrics promoteId: null, // name of a feature property to be promoted to feature.id generateId: true, // whether to generate feature ids. Cannot be used with promoteId diff --git a/@here/harp-mapview-decoder/lib/TileDataSource.ts b/@here/harp-mapview-decoder/lib/TileDataSource.ts index 41f53a5a1b..280d490194 100644 --- a/@here/harp-mapview-decoder/lib/TileDataSource.ts +++ b/@here/harp-mapview-decoder/lib/TileDataSource.ts @@ -85,7 +85,10 @@ export class TileFactory { * @param m_modelConstructor - Constructor of (subclass of) [[Tile]]. */ constructor( - private m_modelConstructor: new (dataSource: DataSource, tileKey: TileKey) => TileType + private readonly m_modelConstructor: new ( + dataSource: DataSource, + tileKey: TileKey + ) => TileType ) {} /** @@ -109,6 +112,7 @@ export class TileDataSource extends DataSource { protected readonly logger: ILogger = LoggerManager.instance.create("TileDataSource"); protected readonly m_decoder: ITileDecoder; private m_isReady: boolean = false; + private readonly m_unregisterClearTileCache?: () => void; /** * Set up the `TileDataSource`. @@ -150,10 +154,15 @@ export class TileDataSource extends DataSource { } this.useGeometryLoader = true; this.cacheable = true; + + this.m_unregisterClearTileCache = this.dataProvider().onDidInvalidate?.(() => + this.mapView.clearTileCache(this.name) + ); } /** @override */ dispose() { + this.m_unregisterClearTileCache?.(); this.decoder.dispose(); } @@ -183,7 +192,7 @@ export class TileDataSource extends DataSource { /** @override */ setStyleSet(styleSet?: StyleSet, definitions?: Definitions, languages?: string[]): void { this.m_decoder.configure(styleSet, definitions, languages); - this.mapView.markTilesDirty(this); + this.mapView.clearTileCache(this.name); } /** @@ -199,9 +208,7 @@ export class TileDataSource extends DataSource { ? theme.styles[this.styleSetName] : undefined; - if (styleSet !== undefined) { - this.setStyleSet(styleSet, theme.definitions, languages); - } + this.setStyleSet(styleSet, theme.definitions, languages); } /** @@ -223,9 +230,11 @@ export class TileDataSource extends DataSource { * this data source. * * @param tileKey - Quadtree address of the requested tile. + * @param delayLoad - If true, the Tile will be created, but Tile.load will not be called. + * @default false. * @override */ - getTile(tileKey: TileKey): TileType | undefined { + getTile(tileKey: TileKey, delayLoad: boolean = false): TileType | undefined { const tile = this.m_tileFactory.create(this, tileKey); tile.tileLoader = new TileLoader( this, @@ -246,7 +255,9 @@ export class TileDataSource extends DataSource { this.requestUpdate(); }); } - tile.load(); + if (!delayLoad) { + tile.load(); + } return tile; } diff --git a/@here/harp-mapview-decoder/lib/TileLoader.ts b/@here/harp-mapview-decoder/lib/TileLoader.ts index 31a71069c2..aa32426ce2 100644 --- a/@here/harp-mapview-decoder/lib/TileLoader.ts +++ b/@here/harp-mapview-decoder/lib/TileLoader.ts @@ -12,7 +12,7 @@ import { TileInfo } from "@here/harp-datasource-protocol"; import { TileKey } from "@here/harp-geoutils"; -import { DataSource, TileLoaderState } from "@here/harp-mapview"; +import { DataSource, ITileLoader, TileLoaderState } from "@here/harp-mapview"; import { LoggerManager } from "@here/harp-utils"; import { DataProvider } from "./DataProvider"; @@ -26,7 +26,7 @@ const logger = LoggerManager.instance.create("TileLoader"); * The [[TileLoader]] manages the different states of loading and decoding for a [[Tile]]. Used by * the [[TileDataSource]]. */ -export class TileLoader { +export class TileLoader implements ITileLoader { /** * Current state of `TileLoader`. */ diff --git a/@here/harp-mapview-decoder/lib/WorkerService.ts b/@here/harp-mapview-decoder/lib/WorkerService.ts index d472683859..53d75bf437 100644 --- a/@here/harp-mapview-decoder/lib/WorkerService.ts +++ b/@here/harp-mapview-decoder/lib/WorkerService.ts @@ -60,7 +60,7 @@ interface RequestEntry { * Communication peer for [[ConcurrentWorkerSet]]. */ export abstract class WorkerService { - private m_pendingRequests: Map = new Map(); + private readonly m_pendingRequests: Map = new Map(); /** * Sets up the `WorkerService` with the specified name, and starts processing messages. @@ -112,7 +112,7 @@ export abstract class WorkerService { * * @param message - Message to be dispatched. */ - private onMessage = (message: MessageEvent) => { + private readonly onMessage = (message: MessageEvent) => { if (typeof message.data.service !== "string" || message.data.service !== this.serviceId) { return; } diff --git a/@here/harp-mapview-decoder/lib/WorkerServiceManager.ts b/@here/harp-mapview-decoder/lib/WorkerServiceManager.ts index e00de49e66..d452e335ad 100644 --- a/@here/harp-mapview-decoder/lib/WorkerServiceManager.ts +++ b/@here/harp-mapview-decoder/lib/WorkerServiceManager.ts @@ -47,12 +47,12 @@ export class WorkerServiceManager extends WorkerService { /** * Contains all registered service factories indexed by `serviceType`. */ - private m_factories = new Map(); + private readonly m_factories = new Map(); /** * Contains all managed worker services indexed by their `serviceId`. */ - private m_services = new Map(); + private readonly m_services = new Map(); private constructor( serviceId: string = WorkerServiceProtocol.WORKER_SERVICE_MANAGER_SERVICE_ID diff --git a/@here/harp-mapview-decoder/test/TileGeometryLoaderTest.ts b/@here/harp-mapview-decoder/test/TileGeometryLoaderTest.ts index ae32c38a31..e1b6eed9a3 100644 --- a/@here/harp-mapview-decoder/test/TileGeometryLoaderTest.ts +++ b/@here/harp-mapview-decoder/test/TileGeometryLoaderTest.ts @@ -3,10 +3,6 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - -// tslint:disable:only-arrow-functions -// Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions - import { DecodedTile } from "@here/harp-datasource-protocol"; import { TileKey, @@ -15,16 +11,21 @@ import { webMercatorTilingScheme } from "@here/harp-geoutils"; import { DataSource, MapView, Statistics, Tile } from "@here/harp-mapview"; -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -chai.use(chaiAsPromised); -const { expect } = chai; import { TileGeometryCreator } from "@here/harp-mapview/lib/geometry/TileGeometryCreator"; import { TileGeometryLoader } from "@here/harp-mapview/lib/geometry/TileGeometryLoader"; +import { TileTaskGroups } from "@here/harp-mapview/lib/MapView"; import { ITileLoader, TileLoaderState } from "@here/harp-mapview/lib/Tile"; import { willEventually } from "@here/harp-test-utils"; +import { TaskQueue } from "@here/harp-utils"; +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; import * as sinon from "sinon"; +// tslint:disable:only-arrow-functions +// Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions + +chai.use(chaiAsPromised); +const { expect } = chai; class FakeVisibleTileSet { // tslint:disable-next-line: no-empty disposeTile(tile: Tile) {} @@ -33,6 +34,7 @@ class FakeVisibleTileSet { class FakeTileLoader implements ITileLoader { state: TileLoaderState = TileLoaderState.Ready; isFinished: boolean = false; + priority: number = 0; loadAndDecode(): Promise { return new Promise(() => this.state); @@ -74,7 +76,10 @@ function createFakeMapView() { statistics: new Statistics(), frameNumber: 5, // must be higher then 0, for tile visibility check visibleTileSet: new FakeVisibleTileSet(), - theme: {} + theme: {}, + taskQueue: new TaskQueue({ + groups: [TileTaskGroups.CREATE, TileTaskGroups.FETCH_AND_DECODE] + }) } as any) as MapView; } @@ -104,7 +109,7 @@ describe("TileGeometryLoader", function() { beforeEach(function() { tile = dataSource.getTile(tileKey)!; - geometryLoader = new TileGeometryLoader(tile); + geometryLoader = new TileGeometryLoader(tile, mapView.taskQueue); tile.tileGeometryLoader = geometryLoader; sandbox = sinon.createSandbox(); }); @@ -154,6 +159,8 @@ describe("TileGeometryLoader", function() { // tslint:disable-next-line: no-unused-expression expect(geometryLoader.geometryCreationPending).to.be.true; + mapView.taskQueue.processNext(TileTaskGroups.CREATE); + await willEventually(() => { // tslint:disable-next-line: no-unused-expression expect(geometryLoader.isFinished).to.be.true; @@ -274,6 +281,10 @@ describe("TileGeometryLoader", function() { geometryLoader!.update(undefined, undefined); await wait(); + expect(mapView.taskQueue.numItemsLeft(TileTaskGroups.CREATE)).equal(1); + + expect(mapView.taskQueue.processNext(TileTaskGroups.CREATE)).equal(true); + expect(spySetDecodedTile.callCount).equal(1); expect(spyProcessTechniques.callCount).equal(1); @@ -283,7 +294,7 @@ describe("TileGeometryLoader", function() { }); }); - it("should create geometry for decoded tile only once (via timeout)", async function() { + it("should create geometry for decoded tile only once (via taskqueue)", async function() { tile.decodedTile = createFakeDecodedTile(); const geometryCreator = TileGeometryCreator.instance; @@ -300,6 +311,10 @@ describe("TileGeometryLoader", function() { geometryLoader!.update(undefined, undefined); await wait(); + expect(mapView.taskQueue.numItemsLeft(TileTaskGroups.CREATE)).equal(1); + + expect(mapView.taskQueue.processNext(TileTaskGroups.CREATE)).equal(true); + await willEventually(() => { expect(spyProcessTechniques.callCount).equal(1); expect(spyCreateGeometries.callCount).equal(1); @@ -308,7 +323,7 @@ describe("TileGeometryLoader", function() { }); }); - it("should not create geometry for invisible tile while in timeout", async function() { + it("should not create geometry for invisible tile ", async function() { tile.decodedTile = createFakeDecodedTile(); const geometryCreator = TileGeometryCreator.instance; @@ -317,11 +332,17 @@ describe("TileGeometryLoader", function() { expect(spyCreateGeometries.callCount).equal(0); expect(spyProcessTechniques.callCount).equal(0); - // Mimic multiple frame updates. geometryLoader!.update(undefined, undefined); - // Make immediately invisible - if flaky remove this test. + expect(mapView.taskQueue.numItemsLeft(TileTaskGroups.CREATE)).equal(1); + tile.isVisible = false; - await wait(); + + //should remove expired task + mapView.taskQueue.update(); + + expect(mapView.taskQueue.numItemsLeft(TileTaskGroups.CREATE)).equal(0); + + expect(mapView.taskQueue.processNext(TileTaskGroups.CREATE)).equal(false); await willEventually(() => { expect(spyProcessTechniques.callCount).equal(1); @@ -341,9 +362,18 @@ describe("TileGeometryLoader", function() { expect(spyProcessTechniques.callCount).equal(0); geometryLoader!.update(undefined, undefined); - // Make immediately disposed - if flaky remove this test. + expect(mapView.taskQueue.numItemsLeft(TileTaskGroups.CREATE)).equal(1); + + // Make immediately disposed tile.dispose(); + //should remove expired task + mapView.taskQueue.update(); + + expect(mapView.taskQueue.numItemsLeft(TileTaskGroups.CREATE)).equal(0); + + expect(mapView.taskQueue.processNext(TileTaskGroups.CREATE)).equal(false); + await willEventually(() => { expect(spyProcessTechniques.callCount).equal(1); expect(spyCreateGeometries.callCount).equal(0); diff --git a/@here/harp-mapview-decoder/test/TileLoaderTest.ts b/@here/harp-mapview-decoder/test/TileLoaderTest.ts index ff66a48361..a68caf8b31 100644 --- a/@here/harp-mapview-decoder/test/TileLoaderTest.ts +++ b/@here/harp-mapview-decoder/test/TileLoaderTest.ts @@ -257,4 +257,27 @@ describe("TileLoader", function() { return expect(loadPromise).to.eventually.be.rejected; }); }); + + describe("tile load", function() { + // Note, this test can't be in TileTest.ts because the TileLoader is not part of the + // @here/harp-mapview package, and trying to add package which contains the TileLoader + // as a dependency causes a loop which isn't allowed. + it("tile load sets dependencies from decoded tile", async function() { + const dependencies: number[] = [0, 1]; + const decodedTile: DecodedTile = { + techniques: [], + geometries: [], + dependencies + }; + const tileLoader = sinon.createStubInstance(TileLoader); + tileLoader.decodedTile = decodedTile; + tileLoader.loadAndDecode.returns(Promise.resolve(TileLoaderState.Ready)); + const tile = new Tile(dataSource, tileKey); + tile.tileLoader = tileLoader; + await tile.load(); + expect(tile.dependencies).to.be.deep.eq( + dependencies.map(morton => TileKey.fromMortonCode(morton)) + ); + }); + }); }); diff --git a/@here/harp-mapview/lib/AnimatedExtrusionHandler.ts b/@here/harp-mapview/lib/AnimatedExtrusionHandler.ts index 280d1bf688..f22ab1937d 100644 --- a/@here/harp-mapview/lib/AnimatedExtrusionHandler.ts +++ b/@here/harp-mapview/lib/AnimatedExtrusionHandler.ts @@ -50,7 +50,7 @@ export class AnimatedExtrusionHandler { private m_forceEnabled: boolean = false; // key is tile's morton code. - private m_tileMap: Map = new Map(); + private readonly m_tileMap: Map = new Map(); private m_state: AnimatedExtrusionState = AnimatedExtrusionState.None; private m_startTime: number = -1; @@ -59,7 +59,7 @@ export class AnimatedExtrusionHandler { * * @param m_mapView - Instance of {@link MapView} on which the animation will run. */ - constructor(private m_mapView: MapView) {} + constructor(private readonly m_mapView: MapView) {} /** * Returns whether the extrusion animation is force enabled or not. diff --git a/@here/harp-mapview/lib/BackgroundDataSource.ts b/@here/harp-mapview/lib/BackgroundDataSource.ts index dc86cd3604..49d522f31a 100644 --- a/@here/harp-mapview/lib/BackgroundDataSource.ts +++ b/@here/harp-mapview/lib/BackgroundDataSource.ts @@ -21,6 +21,7 @@ export class BackgroundDataSource extends DataSource { super({ name: "background" }); this.cacheable = true; this.addGroundPlane = true; + this.enablePicking = false; } updateStorageLevelOffset() { @@ -55,7 +56,7 @@ export class BackgroundDataSource extends DataSource { } setTilingScheme(tilingScheme?: TilingScheme) { - const newScheme = tilingScheme || BackgroundDataSource.DEFAULT_TILING_SCHEME; + const newScheme = tilingScheme ?? BackgroundDataSource.DEFAULT_TILING_SCHEME; if (newScheme === this.m_tilingScheme) { return; } diff --git a/@here/harp-mapview/lib/CameraMovementDetector.ts b/@here/harp-mapview/lib/CameraMovementDetector.ts index a7ad3b5fe8..615cc89551 100644 --- a/@here/harp-mapview/lib/CameraMovementDetector.ts +++ b/@here/harp-mapview/lib/CameraMovementDetector.ts @@ -22,8 +22,8 @@ const DEFAULT_THROTTLING_TIMEOUT = 300; */ export class CameraMovementDetector { private m_lastAttitude?: MapViewUtils.Attitude; - private m_lastCameraPos = new Vector3(); - private m_newCameraPos = new Vector3(); + private readonly m_lastCameraPos = new Vector3(); + private readonly m_newCameraPos = new Vector3(); private m_cameraMovedLastFrame: boolean | undefined; private m_throttlingTimerId?: number = undefined; private m_movementDetectorDeadline: number = 0; @@ -38,7 +38,7 @@ export class CameraMovementDetector { * @param m_movementFinishedFunc - Callback function, called when the user stops interacting. */ constructor( - private m_throttlingTimeout: number | undefined, + private readonly m_throttlingTimeout: number | undefined, private m_movementStartedFunc: (() => void) | undefined, private m_movementFinishedFunc: (() => void) | undefined ) { @@ -152,7 +152,7 @@ export class CameraMovementDetector { } } - private onDeadlineTimer = () => { + private readonly onDeadlineTimer = () => { this.m_throttlingTimerId = undefined; const now = performance.now(); if (now >= this.m_movementDetectorDeadline) { diff --git a/@here/harp-mapview/lib/ClipPlanesEvaluator.ts b/@here/harp-mapview/lib/ClipPlanesEvaluator.ts index fddbb2d307..182cdf41b8 100644 --- a/@here/harp-mapview/lib/ClipPlanesEvaluator.ts +++ b/@here/harp-mapview/lib/ClipPlanesEvaluator.ts @@ -27,6 +27,7 @@ export interface ClipPlanesEvaluator { /** * Compute near and far clipping planes distance. * + * @remarks * Evaluation method should be called on every frame and camera clip planes updated. * This is related to evaluator implementation and its input data, that may suddenly change * such as camera position or angle, projection type or so. @@ -50,6 +51,7 @@ export interface ClipPlanesEvaluator { /** * Simplest camera clip planes evaluator, interpolates near/far planes based on ground distance. * + * @remarks * At general ground distance to camera along the surface normal is used as reference point for * planes evaluation, where near plane distance is set as fraction of this distance refereed as * [[nearMultiplier]]. Far plane equation has its own multiplier - [[nearFarMultiplier]], @@ -154,6 +156,7 @@ export class InterpolatedClipPlanesEvaluator implements ClipPlanesEvaluator { /** * Abstract evaluator class that adds support for elevation constraints. * + * @remarks * Classes derived from this should implement algorithms that takes into account rendered * features height (elevations), such as ground plane is no more flat (or spherical), but * contains geometry that should be overlapped by frustum planes. @@ -177,6 +180,7 @@ export abstract class ElevationBasedClipPlanesEvaluator implements ClipPlanesEva /** * Set maximum elevation above sea level to be rendered. * + * @remarks * @param elevation - the elevation (altitude) value in world units (meters). * @note If you set this exactly to the maximum rendered feature height (altitude above * the sea, you may notice some flickering or even polygons disappearing related to rounding @@ -203,6 +207,7 @@ export abstract class ElevationBasedClipPlanesEvaluator implements ClipPlanesEva /** * Set minimum elevation to be rendered, values beneath the sea level are negative. * + * @remarks * @param elevation - the minimum elevation (depression) in world units (meters). * @note If you set this parameter to zero you may not see any features rendered if they are * just below the sea level more than half of [[nearFarMargin]] assumed. Similarly if set to @@ -232,6 +237,7 @@ export abstract class ElevationBasedClipPlanesEvaluator implements ClipPlanesEva /** * Top view, clip planes evaluator that computes view ranges based on ground distance and elevation. * + * @remarks * This evaluator supports both planar and spherical projections, although it behavior is * slightly different in each case. General algorithm sets near plane and far plane close * to ground level, but taking into account maximum and minimum elevation of features on the ground. @@ -254,11 +260,13 @@ export class TopViewClipPlanesEvaluator extends ElevationBasedClipPlanesEvaluato * Helper object for reducing performance impact. */ protected m_tmpQuaternion: THREE.Quaternion = new THREE.Quaternion(); - private m_minimumViewRange: ViewRanges; + private readonly m_minimumViewRange: ViewRanges; /** * Allows to setup near/far offsets (margins), rendered geometry elevation relative to sea * level as also minimum near plane and maximum far plane distance constraints. + * + * @remarks * It is strongly recommended to set some reasonable [[nearFarMargin]] (offset) between near * and far planes to avoid flickering. * @param maxElevation - defines near plane offset from the ground in the surface normal @@ -453,6 +461,7 @@ export class TopViewClipPlanesEvaluator extends ElevationBasedClipPlanesEvaluato /** * Calculate distance from a point to the tangent point of a sphere. * + * @remarks * Returns zero if point is below surface or only very slightly above surface of sphere. * @param d - Distance from point to center of sphere * @param r - Radius of sphere @@ -472,6 +481,7 @@ export class TopViewClipPlanesEvaluator extends ElevationBasedClipPlanesEvaluato /** * Calculate far plane depending on furthest visible distance from camera position. * + * @remarks * Furthest visible distance is assumed to be distance from camera to horizon * plus distance from elevated geometry to horizon(so that high objects behind horizon * remain visible). @@ -640,12 +650,15 @@ export class TopViewClipPlanesEvaluator extends ElevationBasedClipPlanesEvaluato /** * Evaluates camera clipping planes taking into account ground distance and camera angles. * + * @remarks * This evaluator provides support for camera with varying tilt (pitch) angle, the angle * between camera __look at__ vector and the ground surface normal. */ export class TiltViewClipPlanesEvaluator extends TopViewClipPlanesEvaluator { /** * Calculate the lengths of frustum planes intersection with the ground plane. + * + * @remarks * This evaluates distances between eye vector (or eye plane in orthographic projection) and * ground intersections of top and bottom frustum planes. * @note This method assumes the world surface (ground) to be flat and @@ -1054,5 +1067,6 @@ export class FixedClipPlanesEvaluator implements ClipPlanesEvaluator { * on ground distance and camera orientation. * * Creates {@link TiltViewClipPlanesEvaluator}. + * @internal */ export const createDefaultClipPlanesEvaluator = () => new TiltViewClipPlanesEvaluator(); diff --git a/@here/harp-mapview/lib/ColorCache.ts b/@here/harp-mapview/lib/ColorCache.ts index 6929d39e6d..eb6bc0e4a0 100644 --- a/@here/harp-mapview/lib/ColorCache.ts +++ b/@here/harp-mapview/lib/ColorCache.ts @@ -20,8 +20,8 @@ export class ColorCache { return this.m_instance; } - private static m_instance: ColorCache = new ColorCache(); - private m_map: Map = new Map(); + private static readonly m_instance: ColorCache = new ColorCache(); + private readonly m_map: Map = new Map(); /** * Returns the color for the given `colorCode`. This function may reuse a previously generated diff --git a/@here/harp-mapview/lib/ConcurrentWorkerSet.ts b/@here/harp-mapview/lib/ConcurrentWorkerSet.ts index 771a751e64..db0ae4d904 100644 --- a/@here/harp-mapview/lib/ConcurrentWorkerSet.ts +++ b/@here/harp-mapview/lib/ConcurrentWorkerSet.ts @@ -103,7 +103,7 @@ export const DEFAULT_WORKER_INITIALIZATION_TIMEOUT = 10000; * in m_availableWorkers. */ export class ConcurrentWorkerSet { - private m_workerChannelLogger = LoggerManager.instance.create("WorkerChannel"); + private readonly m_workerChannelLogger = LoggerManager.instance.create("WorkerChannel"); private readonly m_eventListeners = new Map void>(); private m_workers = new Array(); @@ -471,7 +471,7 @@ export class ConcurrentWorkerSet { * @param workerId - The workerId of the web worker. * @param event - The event to dispatch. */ - private onWorkerMessage = (workerId: number, event: MessageEvent) => { + private readonly onWorkerMessage = (workerId: number, event: MessageEvent) => { if (WorkerServiceProtocol.isResponseMessage(event.data)) { const response = event.data; if (response.messageId === null) { diff --git a/@here/harp-mapview/lib/DataSource.ts b/@here/harp-mapview/lib/DataSource.ts index 944a5b6211..0904240b58 100644 --- a/@here/harp-mapview/lib/DataSource.ts +++ b/@here/harp-mapview/lib/DataSource.ts @@ -69,6 +69,14 @@ export interface DataSourceOptions { * @default true */ allowOverlappingTiles?: boolean; + + /** + * Whether features from these data source can picked by calling + * {@link MapView.intersectMapObjects}. Disabling picking for data sources that don't need it + * will improve picking performance. + * @default true + */ + enablePicking?: boolean; } /** @@ -130,6 +138,9 @@ export abstract class DataSource extends THREE.EventDispatcher { maxDisplayLevel: number = 20; allowOverlappingTiles: boolean = true; + + enablePicking: boolean = true; + /** * @internal * @hidden @@ -177,7 +188,8 @@ export abstract class DataSource extends THREE.EventDispatcher { minDisplayLevel, maxDisplayLevel, storageLevelOffset, - allowOverlappingTiles + allowOverlappingTiles, + enablePicking } = options; if (name === undefined || name.length === 0) { name = `anonymous-datasource#${++DataSource.uniqueNameCounter}`; @@ -212,6 +224,10 @@ export abstract class DataSource extends THREE.EventDispatcher { if (allowOverlappingTiles !== undefined) { this.allowOverlappingTiles = allowOverlappingTiles; } + + if (enablePicking !== undefined) { + this.enablePicking = enablePicking; + } } /** @@ -424,8 +440,10 @@ export abstract class DataSource extends THREE.EventDispatcher { * {@link @here/harp-geoutils#TileKey}. * * @param tileKey - The unique identifier for a map tile. + * @param delayLoad - If true, the Tile will be created, but Tile.load will not be called + * @default false. */ - abstract getTile(tileKey: TileKey): Tile | undefined; + abstract getTile(tileKey: TileKey, delayLoad?: boolean): Tile | undefined; /** * This method is called by {@link MapView} before the diff --git a/@here/harp-mapview/lib/DebugContext.ts b/@here/harp-mapview/lib/DebugContext.ts index 731f78a277..595f339110 100644 --- a/@here/harp-mapview/lib/DebugContext.ts +++ b/@here/harp-mapview/lib/DebugContext.ts @@ -71,7 +71,7 @@ class DebugOption extends THREE.EventDispatcher { * names. */ export class DebugContext { - private m_optionsMap: Map; + private readonly m_optionsMap: Map; /** * Builds a `DebugContext`. diff --git a/@here/harp-mapview/lib/DecodedTileHelpers.ts b/@here/harp-mapview/lib/DecodedTileHelpers.ts index 63c8f6ec40..2a4ed2a983 100644 --- a/@here/harp-mapview/lib/DecodedTileHelpers.ts +++ b/@here/harp-mapview/lib/DecodedTileHelpers.ts @@ -88,6 +88,8 @@ export interface MaterialOptions { * e.g. after texture loading. * * @returns new material instance that matches `technique.name` + * + * @internal */ export function createMaterial( options: MaterialOptions, @@ -234,6 +236,7 @@ export function createMaterial( * {@link @here/harp-datasource-protocol#BufferAttribute} object. * * @param attribute - BufferAttribute a WebGL compliant buffer + * @internal */ export function getBufferAttribute(attribute: BufferAttribute): THREE.BufferAttribute { switch (attribute.type) { @@ -287,6 +290,7 @@ export function getBufferAttribute(attribute: BufferAttribute): THREE.BufferAttr * Determines if a technique uses THREE.Object3D instances. * @param technique - The technique to check. * @returns true if technique uses THREE.Object3D, false otherwise. + * @internal */ export function usesObject3D(technique: Technique): boolean { const name = technique.name; @@ -307,6 +311,8 @@ export function usesObject3D(technique: Technique): boolean { * @param material - The object's material. * @param tile - The tile where the object is located. * @param elevationEnabled - True if elevation is enabled, false otherwise. + * + * @internal */ export function buildObject( technique: Technique, @@ -376,21 +382,25 @@ export function buildObject( } /** - * Non material properties of [[BaseTechnique]] + * Non material properties of `BaseTechnique`. + * @internal */ export const BASE_TECHNIQUE_NON_MATERIAL_PROPS = ["name", "id", "renderOrder", "transient"]; /** * Generic material type constructor. + * @internal */ export type MaterialConstructor = new (params?: {}) => THREE.Material; /** - * Returns a [[MaterialConstructor]] basing on provided technique object. + * Returns a `MaterialConstructor` basing on provided technique object. * - * @param technique - [[Technique]] object which the material will be based on. + * @param technique - `Technique` object which the material will be based on. * @param shadowsEnabled - Whether the material can accept shadows, this is required for some - * techniques to decide which material to create. + * techniques to decide which material to create. + * + * @internal */ export function getMaterialConstructor( technique: Technique, @@ -462,7 +472,8 @@ function getMainMaterialStyledProps(technique: Technique): StyledProperties { "dashColor", "polygonOffset", "polygonOffsetFactor", - "polygonOffsetUnits" + "polygonOffsetUnits", + "depthTest" ]); baseProps.lineWidth = buildMetricValueEvaluator( technique.lineWidth ?? 0, // Compatibility: `undefined` lineWidth means hidden. @@ -550,6 +561,7 @@ function getMainMaterialStyledProps(technique: Technique): StyledProperties { /** * Convert metric style property to expression that accounts {@link MapView.pixelToWorld} if * `metricUnit === 'Pixel'`. + * @internal */ export function buildMetricValueEvaluator( value: Expr | Value | undefined, @@ -580,15 +592,18 @@ export function buildMetricValueEvaluator( /** * Allows to easy parse/encode technique's base color property value as number coded color. * + * @remarks * Function takes care about property parsing, interpolation and encoding if neccessary. * * @see ColorUtils * @param technique - the technique where we search for base (transparency) color value * @param env - {@link @here/harp-datasource-protocol#Env} instance * used to evaluate {@link @here/harp-datasource-protocol#Expr} - * based properties of [[Technique]] - * @returns [[number]] encoded color value (in custom #TTRRGGBB) format or `undefined` if + * based properties of `Technique` + * @returns `number` encoded color value (in custom #TTRRGGBB) format or `undefined` if * base color property is not defined in the technique passed. + * + * @internal */ export function evaluateBaseColorProperty(technique: Technique, env: Env): number | undefined { const baseColorProp = getBaseColorProp(technique); @@ -599,10 +614,12 @@ export function evaluateBaseColorProperty(technique: Technique, env: Env): numbe } /** - * Apply [[ShaderTechnique]] parameters to material. + * Apply `ShaderTechnique` parameters to material. * - * @param technique - the [[ShaderTechnique]] which requires special handling + * @param technique - the `ShaderTechnique` which requires special handling * @param material - material to which technique will be applied + * + * @internal */ function applyShaderTechniqueToMaterial(technique: ShaderTechnique, material: THREE.Material) { if (technique.transparent) { @@ -683,6 +700,7 @@ function applyTechniquePropertyToMaterial( /** * Apply technique color to material taking special care with transparent (RGBA) colors. * + * @remarks * @note This function is intended to be used with secondary, triary etc. technique colors, * not the base ones that may contain transparency information. Such colors should be processed * with [[applyTechniqueBaseColorToMaterial]] function. @@ -693,7 +711,9 @@ function applyTechniquePropertyToMaterial( * @param value - color value * @param env - {@link @here/harp-datasource-protocol#Env} instance used * to evaluate {@link @here/harp-datasource-protocol#Expr} - * based properties of [[Technique]] + * based properties of `Technique`. + * + * @internal */ export function applySecondaryColorToMaterial( materialColor: THREE.Color, @@ -717,6 +737,7 @@ export function applySecondaryColorToMaterial( /** * Apply technique base color (transparency support) to material with modifying material opacity. * + * @remarks * This method applies main (or base) technique color with transparency support to the corresponding * material color, with an effect on entire [[THREE.Material]] __opacity__ and __transparent__ * attributes. @@ -731,6 +752,8 @@ export function applySecondaryColorToMaterial( * @param value - color value in custom number format * @param env - {@link @here/harp-datasource-protocol#Env} instance used to evaluate * {@link @here/harp-datasource-protocol#Expr} based properties of [[Technique]] + * + * @internal */ export function applyBaseColorToMaterial( material: THREE.Material, @@ -786,6 +809,7 @@ function evaluateProperty(value: any, env?: Env): any { /** * Calculates the numerical value of the technique defined color property. * + * @remarks * Function takes care about color interpolation (when @param `env is set) as also parsing * string encoded colors. * @@ -793,6 +817,7 @@ function evaluateProperty(value: any, env?: Env): any { * @param value - the value of color property defined in technique * @param env - {@link @here/harp-datasource-protocol#Env} instance used to evaluate * {@link @here/harp-datasource-protocol#Expr} based properties of [[Technique]] + * @internal */ export function evaluateColorProperty(value: Value, env?: Env): number | undefined { value = evaluateProperty(value, env); diff --git a/@here/harp-mapview/lib/DepthPrePass.ts b/@here/harp-mapview/lib/DepthPrePass.ts index 32db42ef4a..4378094b91 100644 --- a/@here/harp-mapview/lib/DepthPrePass.ts +++ b/@here/harp-mapview/lib/DepthPrePass.ts @@ -14,6 +14,7 @@ import { evaluateBaseColorProperty } from "./DecodedTileHelpers"; /** * Bitmask used for the depth pre-pass to prevent multiple fragments in the same screen position * from rendering color. + * @internal */ export const DEPTH_PRE_PASS_STENCIL_MASK = 0x01; @@ -25,13 +26,16 @@ const DEPTH_PRE_PASS_RENDER_ORDER_OFFSET = 1e-6; /** * Check if technique requires (and not disables) use of depth prepass. * + * @remarks * Depth prepass is enabled if correct opacity is specified (in range `(0,1)`) _and_ not explicitly * disabled by `enableDepthPrePass` option. * - * @param technique - [[BaseStandardTechnique]] instance to be checked + * @param technique - `BaseStandardTechnique` instance to be checked * @param env - {@link @here/harp-datasource-protocol#Env} instance used * to evaluate {@link @here/harp-datasource-protocol#Expr} - * based properties of [[Technique]] + * based properties of `Technique` + * + * @internal */ export function isRenderDepthPrePassEnabled(technique: ExtrudedPolygonTechnique, env: Env) { // Depth pass explicitly disabled @@ -68,12 +72,15 @@ export interface DepthPrePassProperties { /** * Creates material for depth prepass. * + * @remarks * Creates material that writes only to the z-buffer. Updates the original material instance, to * support depth prepass. * * @param baseMaterial - The base material of mesh that is updated to work with depth prepass * and then used. This parameter is a template for depth prepass material that is returned. * @returns depth prepass material, which is a clone of `baseMaterial` with the adapted settings. + * + * @internal */ export function createDepthPrePassMaterial(baseMaterial: THREE.Material): THREE.Material { baseMaterial.depthWrite = false; @@ -92,9 +99,30 @@ export function createDepthPrePassMaterial(baseMaterial: THREE.Material): THREE. return depthPassMaterial; } +/** + * Checks if a given object is a depth prepass mesh. + * + * @param object - The object to check whether it's a depth prepass mesh. + * @returns `true` if the object is a depth prepass mesh, `false` otherwise. + * + * @internal + */ +export function isDepthPrePassMesh(object: THREE.Object3D): boolean { + if ((object as any).isMesh !== true) { + return false; + } + const mesh = object as THREE.Mesh; + return mesh.material instanceof Array + ? mesh.material.every(material => (material as any).isDepthPrepassMaterial === true) + : (mesh.material as any).isDepthPrepassMaterial === true; +} + // tslint:disable:max-line-length /** - * Clones a given mesh to render it in the depth prepass with another material. Both the original + * Clones a given mesh to render it in the depth prepass with another material. + * + * @remarks + * Both the original * and depth prepass meshes, when rendered in the correct order, create the proper depth prepass * effect. The original mesh material is slightly modified by [[createDepthPrePassMaterial]] to * support the depth prepass. This method is usable only if the material of this mesh has an @@ -105,6 +133,8 @@ export function createDepthPrePassMaterial(baseMaterial: THREE.Material): THREE. * * @param mesh - original mesh * @returns `Mesh` depth pre pass + * + * @internal */ // tslint:enable:max-line-length export function createDepthPrePassMesh(mesh: THREE.Mesh): THREE.Mesh { @@ -156,11 +186,13 @@ export function createDepthPrePassMesh(mesh: THREE.Mesh): THREE.Mesh { /** * Sets up all the needed stencil logic needed for the depth pre-pass. * + * @remarks * This logic is in place to avoid z-fighting artifacts that can appear in geometries that have * coplanar triangles inside the same mesh. * * @param depthMesh - Mesh created by `createDepthPrePassMesh`. * @param colorMesh - Original mesh. + * @internal */ export function setDepthPrePassStencil(depthMesh: THREE.Mesh, colorMesh: THREE.Mesh) { // Set up depth mesh stencil logic. diff --git a/@here/harp-mapview/lib/IntersectParams.ts b/@here/harp-mapview/lib/IntersectParams.ts new file mode 100644 index 0000000000..80b16a3f2e --- /dev/null +++ b/@here/harp-mapview/lib/IntersectParams.ts @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Parameters to customize behaviour of {@link (MapView.intersectMapObjects)}. + */ +export interface IntersectParams { + /** + * The maximum number of results to be retrieved from the intersection test. If set, only the + * first maxResultCount results will be returned, following an order by distance first, then + * by reversed render order (topmost/highest render order first). + */ + maxResultCount?: number; +} diff --git a/@here/harp-mapview/lib/MapMaterialAdapter.ts b/@here/harp-mapview/lib/MapMaterialAdapter.ts index 6e2202c3b5..40253382f1 100644 --- a/@here/harp-mapview/lib/MapMaterialAdapter.ts +++ b/@here/harp-mapview/lib/MapMaterialAdapter.ts @@ -98,7 +98,7 @@ export class MapMaterialAdapter { readonly currentStyledProperties: { [name: string]: Value | undefined }; private m_lastUpdateFrameNumber = -1; - private m_dynamicProperties: Array<[string, Expr | StylePropertyEvaluator]>; + private readonly m_dynamicProperties: Array<[string, Expr | StylePropertyEvaluator]>; constructor(material: THREE.Material, styledProperties: StyledProperties) { this.material = material; diff --git a/@here/harp-mapview/lib/MapObjectAdapter.ts b/@here/harp-mapview/lib/MapObjectAdapter.ts index 52be1d2851..ff018be0f0 100644 --- a/@here/harp-mapview/lib/MapObjectAdapter.ts +++ b/@here/harp-mapview/lib/MapObjectAdapter.ts @@ -6,7 +6,7 @@ import * as THREE from "three"; -import { GeometryKind, Technique } from "@here/harp-datasource-protocol"; +import { GeometryKind, getPropertyValue, MapEnv, Technique } from "@here/harp-datasource-protocol"; import { MapAdapterUpdateEnv, MapMaterialAdapter } from "./MapMaterialAdapter"; /** @@ -17,6 +17,7 @@ import { MapAdapterUpdateEnv, MapMaterialAdapter } from "./MapMaterialAdapter"; export interface MapObjectAdapterParams { technique?: Technique; kind?: GeometryKind[]; + pickable?: boolean; // TODO: Move here in following refactor. //featureData?: TileFeatureData; @@ -69,6 +70,7 @@ export class MapObjectAdapter { */ readonly kind: GeometryKind[] | undefined; + private readonly m_pickable: boolean; private m_lastUpdateFrameNumber = -1; private m_notCompletlyTransparent = true; @@ -76,6 +78,7 @@ export class MapObjectAdapter { this.object = object; this.technique = params.technique; this.kind = params.kind; + this.m_pickable = params.pickable ?? true; this.m_notCompletlyTransparent = this.getObjectMaterials().some( material => material.opacity > 0 ); @@ -119,6 +122,20 @@ export class MapObjectAdapter { return this.object.visible && this.m_notCompletlyTransparent; } + /** + * Whether underlying `THREE.Object3D` should be pickable by {@link PickHandler}. + * @param env - Property lookup environment. + */ + isPickable(env: MapEnv) { + // An object is pickable only if it's visible, not transient and it's not explicitely marked + // as non-pickable. + return ( + this.m_pickable && + this.isVisible() && + getPropertyValue(this.technique?.transient, env) !== true + ); + } + private updateMaterials(context: MapAdapterUpdateEnv) { let somethingChanged: boolean = false; const materials = this.getObjectMaterials(); diff --git a/@here/harp-mapview/lib/MapTileCuller.ts b/@here/harp-mapview/lib/MapTileCuller.ts index 27ee1cc4ff..428ee6baeb 100644 --- a/@here/harp-mapview/lib/MapTileCuller.ts +++ b/@here/harp-mapview/lib/MapTileCuller.ts @@ -18,7 +18,7 @@ export class MapTileCuller { private m_globalFrustumMin = new THREE.Vector3(); private m_globalFrustumMax = new THREE.Vector3(); - private m_frustumCorners = [ + private readonly m_frustumCorners = [ new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), @@ -34,7 +34,7 @@ export class MapTileCuller { * * @param m_camera - A `THREE.Camera`. */ - constructor(private m_camera: THREE.Camera) {} + constructor(private readonly m_camera: THREE.Camera) {} /** * Sets up culling and computes frustum corners. You mus call this function before the culling diff --git a/@here/harp-mapview/lib/MapView.ts b/@here/harp-mapview/lib/MapView.ts index 062d2c243c..cc504aaa32 100644 --- a/@here/harp-mapview/lib/MapView.ts +++ b/@here/harp-mapview/lib/MapView.ts @@ -6,7 +6,6 @@ import { Env, Expr, - GeometryKind, getFeatureId, getPropertyValue, GradientSky, @@ -30,20 +29,24 @@ import { isGeoCoordinatesLike, isVector3Like, mercatorProjection, + OrientedBox3, Projection, ProjectionType, TilingScheme, Vector3Like } from "@here/harp-geoutils"; +import { SolidLineMaterial } from "@here/harp-materials"; import { assert, getOptionValue, LoggerManager, LogLevel, PerformanceTimer, + TaskQueue, UriResolver } from "@here/harp-utils"; import * as THREE from "three"; + import { AnimatedExtrusionHandler } from "./AnimatedExtrusionHandler"; import { BackgroundDataSource } from "./BackgroundDataSource"; import { CameraMovementDetector } from "./CameraMovementDetector"; @@ -58,9 +61,11 @@ import { FrustumIntersection } from "./FrustumIntersection"; import { overlayOnElevation } from "./geometry/overlayOnElevation"; import { TileGeometryManager } from "./geometry/TileGeometryManager"; import { MapViewImageCache } from "./image/MapViewImageCache"; +import { IntersectParams } from "./IntersectParams"; import { MapAnchors } from "./MapAnchors"; import { MapObjectAdapter } from "./MapObjectAdapter"; import { MapViewFog } from "./MapViewFog"; +import { MapViewTaskScheduler } from "./MapViewTaskScheduler"; import { PickHandler, PickResult } from "./PickHandler"; import { PickingRaycaster } from "./PickingRaycaster"; import { PoiManager } from "./poi/PoiManager"; @@ -94,6 +99,12 @@ if (isProduction) { // In dev: silence logging below log (silences "debug" and "trace" levels). LoggerManager.instance.setLogLevelForAll(LogLevel.Log); } +export enum TileTaskGroups { + FETCH_AND_DECODE = "fetch", + //DECODE = "decode", + CREATE = "create" + //UPLOAD = "upload" +} export enum MapViewEventNames { /** Called before this `MapView` starts to render a new frame. */ @@ -106,7 +117,10 @@ export enum MapViewEventNames { AfterRender = "didrender", /** Called after the first frame has been rendered. */ FirstFrame = "first-render", - /** Called when the first view has all the necessary tiles loaded and rendered. */ + /** + * Called when the rendered frame was complete, i.e. all the necessary tiles and resources + * are loaded and rendered. + */ FrameComplete = "frame-complete", /** Called when the theme has been loaded with the internal {@link ThemeLoader}. */ ThemeLoaded = "theme-loaded", @@ -137,7 +151,12 @@ const DEFAULT_CAM_NEAR_PLANE = 0.1; const DEFAULT_CAM_FAR_PLANE = 4000000; const MAX_FIELD_OF_VIEW = 140; const MIN_FIELD_OF_VIEW = 10; -// All objects in fallback tiles are reduced by this amount. + +/** + * All objects in fallback tiles are reduced by this amount. + * + * @internal + */ export const FALLBACK_RENDER_ORDER_OFFSET = 20000; const DEFAULT_MIN_ZOOM_LEVEL = 1; @@ -157,6 +176,8 @@ const DEFAULT_MIN_CAMERA_HEIGHT = 20; */ const DEFAULT_POLAR_STYLE_SET_NAME = "polar"; +const DEFAULT_STENCIL_VALUE = 1; + /** * The type of `RenderEvent`. */ @@ -628,6 +649,18 @@ export interface MapViewOptions extends TextElementsRendererOptions, Partial(); private readonly m_failedDataSources = new Set(); - private m_backgroundDataSource?: BackgroundDataSource; - private m_polarDataSource?: PolarTileDataSource; - private m_enablePolarDataSource: boolean = true; + private readonly m_backgroundDataSource?: BackgroundDataSource; + private readonly m_polarDataSource?: PolarTileDataSource; + private readonly m_enablePolarDataSource: boolean = true; // gestures private readonly m_raycaster: PickingRaycaster; @@ -856,7 +884,7 @@ export class MapView extends THREE.EventDispatcher { private readonly m_visibleTileSetOptions: VisibleTileSetOptions; private m_theme: Theme = {}; - private m_uriResolver?: UriResolver; + private readonly m_uriResolver?: UriResolver; private m_themeIsLoading: boolean = false; private m_previousFrameTimeStamp?: number; @@ -864,32 +892,38 @@ export class MapView extends THREE.EventDispatcher { private m_firstFrameComplete = false; private m_initialTextPlacementDone = false; - private handleRequestAnimationFrame: (frameStartTime: number) => void; + private readonly handleRequestAnimationFrame: (frameStartTime: number) => void; - private m_pickHandler: PickHandler; + private readonly m_pickHandler: PickHandler; - private m_imageCache: MapViewImageCache = new MapViewImageCache(this); + private readonly m_imageCache: MapViewImageCache = new MapViewImageCache(this); + private readonly m_userImageCache: MapViewImageCache = new MapViewImageCache(this); - private m_poiManager: PoiManager = new PoiManager(this); + private readonly m_poiManager: PoiManager = new PoiManager(this); - private m_poiTableManager: PoiTableManager = new PoiTableManager(this); + private readonly m_poiTableManager: PoiTableManager = new PoiTableManager(this); - private m_collisionDebugCanvas: HTMLCanvasElement | undefined; + private readonly m_collisionDebugCanvas: HTMLCanvasElement | undefined; // Detection of camera movement and scene change: - private m_movementDetector: CameraMovementDetector; + private readonly m_movementDetector: CameraMovementDetector; private m_thisFrameTilesChanged: boolean | undefined; private m_lastTileIds: string = ""; private m_languages: string[] | undefined; private m_politicalView: string | undefined; private m_copyrightInfo: CopyrightInfo[] = []; - private m_animatedExtrusionHandler: AnimatedExtrusionHandler; + private readonly m_animatedExtrusionHandler: AnimatedExtrusionHandler; - private m_env: MapEnv = new MapEnv({}); + private readonly m_env: MapEnv = new MapEnv({}); private m_enableMixedLod: boolean | undefined; + private readonly m_renderOrderStencilValues = new Map(); + // Valid values start at 1, because the screen is cleared to zero + private m_stencilValue: number = DEFAULT_STENCIL_VALUE; + private m_taskScheduler: MapViewTaskScheduler; + /** * Constructs a new `MapView` with the given options or canvas element. * @@ -915,6 +949,10 @@ export class MapView extends THREE.EventDispatcher { this.m_minCameraHeight = this.m_options.minCameraHeight; } + if (this.m_options.maxBounds !== undefined) { + this.m_geoMaxBounds = this.m_options.maxBounds; + } + if (this.m_options.decoderUrl !== undefined) { ConcurrentDecoderFacade.defaultScriptUrl = this.m_uriResolver ? this.m_uriResolver.resolveUri(this.m_options.decoderUrl) @@ -969,7 +1007,7 @@ export class MapView extends THREE.EventDispatcher { } this.m_pixelRatio = options.pixelRatio; - this.maxFps = options.maxFps === undefined ? 0 : options.maxFps; + this.m_options.maxFps = this.m_options.maxFps ?? 0; this.m_options.enableStatistics = this.m_options.enableStatistics === true; @@ -1045,11 +1083,15 @@ export class MapView extends THREE.EventDispatcher { this.projection.projectPoint(this.m_targetGeoPos, this.m_targetWorldPos); this.m_scene.add(this.m_camera); // ensure the camera is added to the scene. this.m_screenProjector = new ScreenProjector(this.m_camera); - // setup camera with initial position + // Must be initialized before setupCamera, because the VisibleTileSet is created as part + // of the setupCamera method and it needs the TaskQueue instance. + this.m_taskScheduler = new MapViewTaskScheduler(this.maxFps); + + // setup camera with initial position this.setupCamera(); - this.m_raycaster = new PickingRaycaster(width, height); + this.m_raycaster = new PickingRaycaster(width, height, this.m_env); this.m_movementDetector = new CameraMovementDetector( this.m_options.movementThrottleTimeout, @@ -1100,6 +1142,14 @@ export class MapView extends THREE.EventDispatcher { this.m_backgroundDataSource.setTilingScheme(this.m_options.backgroundTilingScheme); } + this.m_taskScheduler.addEventListener(MapViewEventNames.Update, () => { + this.update(); + }); + + if (options.throttlingEnabled !== undefined) { + this.m_taskScheduler.throttlingEnabled = options.throttlingEnabled; + } + this.initTheme(); this.m_textElementsRenderer = this.createTextRenderer(); @@ -1115,6 +1165,10 @@ export class MapView extends THREE.EventDispatcher { return this.m_createdLights ?? []; } + get taskQueue(): TaskQueue { + return this.m_taskScheduler.taskQueue; + } + /** * @returns Whether label rendering is enabled. */ @@ -1198,6 +1252,7 @@ export class MapView extends THREE.EventDispatcher { /** * Disposes this `MapView`. * + * @remarks * This function cleans the resources that are managed manually including those that exist in * shared caches. * @@ -1408,7 +1463,7 @@ export class MapView extends THREE.EventDispatcher { if (this.m_backgroundDataSource) { this.m_backgroundDataSource.setTheme(this.m_theme); } - this.m_theme.styles = theme.styles || {}; + this.m_theme.styles = theme.styles ?? {}; this.m_theme.definitions = theme.definitions; for (const dataSource of this.m_tileDataSources) { @@ -1595,7 +1650,9 @@ export class MapView extends THREE.EventDispatcher { /** * The THREE.js camera used by this `MapView` to render the main scene. - * @note When modifying the camera all derived properties like: + * + * @remarks + * When modifying the camera all derived properties like: * - {@link MapView.target} * - {@link MapView.zoomLevel} * - {@link MapView.tilt} @@ -1668,6 +1725,8 @@ export class MapView extends THREE.EventDispatcher { this.clearTileCache(); this.textElementsRenderer.clearRenderStates(); this.m_visibleTiles = this.createVisibleTileSet(); + // Set geo max bounds to compute world bounds with new projection. + this.geoMaxBounds = this.geoMaxBounds; this.lookAtImpl({ tilt, heading }); } @@ -1695,8 +1754,10 @@ export class MapView extends THREE.EventDispatcher { /** * Get geo coordinates of camera focus (target) point. + * + * @remarks * This point is not necessarily on the ground, i.e.: - * - if the tilt is high and projection is [[sphereProjection]] + * - if the tilt is high and projection is {@link @here/harp-geoutils#sphereProjection}` * - if the camera was modified directly and is not pointing to the ground. * In any case the projection of the target point will be in the center of the screen. * @@ -1709,6 +1770,7 @@ export class MapView extends THREE.EventDispatcher { /** @internal * Get world coordinates of camera focus point. * + * @remarks * @note The focus point coordinates are updated with each camera update so you don't need * to re-calculate it, although if the camera started looking to the void, the last focus * point is stored. @@ -1734,6 +1796,8 @@ export class MapView extends THREE.EventDispatcher { /** * Get object describing frustum planes distances and min/max visibility range for actual * camera setup. + * + * @remarks * Near and far plane distance are self explanatory while minimum and maximum visibility range * describes the extreme near/far planes distances that may be achieved with current camera * settings, meaning at current zoom level (ground distance) and any possible orientation. @@ -1757,6 +1821,8 @@ export class MapView extends THREE.EventDispatcher { /** * The position in geo coordinates of the center of the scene. + * + * @remarks * Longitude values outside of -180 and +180 are acceptable. */ set geoCenter(geoCenter: GeoCoordinates) { @@ -1776,7 +1842,9 @@ export class MapView extends THREE.EventDispatcher { } /** - * The node in this MapView's scene containing the user [[MapAnchor]]s. + * The node in this MapView's scene containing the user {@link MapAnchor}s. + * + * @remarks * All (first level) children of this node will be positioned in world space according to the * [[MapAnchor.geoPosition]]. * Deeper level children can be used to position custom objects relative to the anchor node. @@ -1801,11 +1869,27 @@ export class MapView extends THREE.EventDispatcher { /** * Get the {@link ImageCache} that belongs to this `MapView`. + * + * Images stored in this cache are primarily used for POIs (icons) and they are used with the + * current theme. Although images can be explicitly added and removed from the cache, it is + * adviced not to remove images from this cache. If an image that is part of client code + * should be removed at any point other than changing the theme, the {@link useImageCache} + * should be used instead. */ get imageCache(): MapViewImageCache { return this.m_imageCache; } + /** + * Get the {@link ImageCache} for user images that belongs to this `MapView`. + * + * Images added to this cache can be removed if no longer required. If images with identical + * names are stored in imageCache and userImageCache, the userImageCache will take precedence. + */ + get userImageCache(): MapViewImageCache { + return this.m_userImageCache; + } + /** * @hidden * Get the {@link PoiManager} that belongs to this `MapView`. @@ -1859,6 +1943,41 @@ export class MapView extends THREE.EventDispatcher { this.update(); } + /** + * The view's maximum bounds in geo coordinates if any. + */ + get geoMaxBounds(): GeoBox | undefined { + return this.m_geoMaxBounds; + } + + /** + * Sets or clears the view's maximum bounds in geo coordinates. + * + * @remarks + * If set, the view will be + * constrained to the given geo bounds. + */ + set geoMaxBounds(bounds: GeoBox | undefined) { + this.m_geoMaxBounds = bounds; + this.m_worldMaxBounds = this.m_geoMaxBounds + ? this.projection.projectBox( + this.m_geoMaxBounds, + this.projection.type === ProjectionType.Planar + ? new THREE.Box3() + : new OrientedBox3() + ) + : undefined; + } + + /** + * @hidden + * @internal + * The view's maximum bounds in world coordinates if any. + */ + get worldMaxBounds(): THREE.Box3 | OrientedBox3 | undefined { + return this.m_worldMaxBounds; + } + /** * Returns the zoom level for the given camera setup. */ @@ -1909,6 +2028,7 @@ export class MapView extends THREE.EventDispatcher { /** * Returns the storage level for the given camera setup. + * @remarks * Actual storage level of the rendered data also depends * on {@link DataSource.storageLevelOffset}. */ @@ -1946,9 +2066,11 @@ export class MapView extends THREE.EventDispatcher { } /** - * Set's the way in which the fov is calculated on the map view. Note, for - * this to take visual effect, the map should be rendered after calling this - * function. + * Set's the way in which the fov is calculated on the map view. + * + * @remarks + * Note, for this to take visual effect, the map should be rendered + * after calling this function. * @param fovCalculation - How the FOV is calculated. */ setFovCalculation(fovCalculation: FovCalculation) { @@ -1985,9 +2107,9 @@ export class MapView extends THREE.EventDispatcher { /** * Adds a new {@link DataSource} to this `MapView`. - * `MapView` needs at least one {@link DataSource} to - * display something. * + * @remarks + * `MapView` needs at least one {@link DataSource} to display something. * @param dataSource - The data source. */ addDataSource(dataSource: DataSource): Promise { @@ -2111,6 +2233,7 @@ export class MapView extends THREE.EventDispatcher { /** * Adjusts the camera to look at a given geo coordinate with tilt and heading angles. * + * @remarks * #### Note on `target` and `bounds` * * If `bounds` are specified, `zoomLevel` and `distance` parameters are ignored and `lookAt` @@ -2138,7 +2261,7 @@ export class MapView extends THREE.EventDispatcher { * * #### Examples * - * ``` + * ```typescript * mapView.lookAt({heading: 90}) * // look east retaining current `target`, `zoomLevel` and `tilt` * @@ -2162,6 +2285,7 @@ export class MapView extends THREE.EventDispatcher { * The method that sets the camera to the desired angle (`tiltDeg`) and `distance` (in meters) * to the `target` location, from a certain heading (`headingAngle`). * + * @remarks * @param target - The location to look at. * @param distance - The distance of the camera to the target in meters. * @param tiltDeg - The camera tilt angle in degrees (0 is vertical), curbed below 89deg @@ -2200,7 +2324,10 @@ export class MapView extends THREE.EventDispatcher { /** * Moves the camera to the specified {@link @here/harp-geoutils#GeoCoordinates}, * sets the desired `zoomLevel` and - * adjusts the yaw and pitch. The pitch of the camera is + * adjusts the yaw and pitch. + * + * @remarks + * The pitch of the camera is * always curbed so that the camera cannot * look above the horizon. This paradigm is necessary * in {@link @here/harp-map-controls#MapControls}, where the center of @@ -2238,6 +2365,7 @@ export class MapView extends THREE.EventDispatcher { /** * Updates the value of a dynamic property. * + * @remarks * Property names starting with a `$`-sign are reserved and any attempt to change their value * will result in an error. * @@ -2260,6 +2388,7 @@ export class MapView extends THREE.EventDispatcher { /** * Removes the given dynamic property from this {@link MapView}. * + * @remarks * Property names starting with a `$`-sign are reserved and any attempt to change their value * will result in an error. * @@ -2372,6 +2501,7 @@ export class MapView extends THREE.EventDispatcher { * PixelRatio in the WebGlRenderer. May contain values > 1.0 for high resolution screens * (HiDPI). * + * @remarks * A value of `undefined` will make the getter return `window.devicePixelRatio`, setting a value * of `1.0` will disable the use of HiDPI on all devices. * @@ -2389,9 +2519,33 @@ export class MapView extends THREE.EventDispatcher { } /** - * PixelRatio ratio for rendering when the camera is moving or an animation is running. Useful - * when rendering on high resolution displays with low performance GPUs that may be - * fill-rate-limited. + * Maximum FPS (Frames Per Second). + * + * @remarks + * If VSync in enabled, the specified number may not be + * reached, but instead the next smaller number than `maxFps` that is equal to the refresh rate + * divided by an integer number. + * + * E.g.: If the monitors refresh rate is set to 60hz, and if `maxFps` is set to a value of `40` + * (60hz/1.5), the actual used FPS may be 30 (60hz/2). For displays that have a refresh rate of + * 60hz, good values for `maxFps` are 30, 20, 15, 12, 10, 6, 3 and 1. A value of `0` is ignored. + */ + set maxFps(value: number) { + this.m_options.maxFps = value; + this.m_taskScheduler.maxFps = value; + } + + get maxFps(): number { + //this cannot be undefined, as it is defaulting to 0 in the constructor + return this.m_options.maxFps as number; + } + + /** + * PixelRatio ratio for rendering when the camera is moving or an animation is running. + * + * @remarks + * Useful when rendering on high resolution displays with low performance GPUs + * that may be fill-rate-limited. * * If a value is specified, a low resolution render pass is used to render the scene into a * low resolution render target, before it is copied to the screen. @@ -2455,9 +2609,11 @@ export class MapView extends THREE.EventDispatcher { } /** - * Returns the world space position from the given screen position. The return value can be - * `null`, in case the camera is facing the horizon and the given `(x, y)` value is not - * intersecting the ground plane. + * Returns the world space position from the given screen position. + * + * @remarks + * The return value can be `null`, in case the camera is facing the horizon + * and the given `(x, y)` value is not intersecting the ground plane. * * @param x - The X position in css/client coordinates (without applied display ratio). * @param y - The Y position in css/client coordinates (without applied display ratio). @@ -2471,7 +2627,10 @@ export class MapView extends THREE.EventDispatcher { /** * Returns the {@link @here/harp-geoutils#GeoCoordinates} from the - * given screen position. The return value can be + * given screen position. + * + * @remarks + * The return value can be * `null`, in case the camera is facing the horizon and * the given `(x, y)` value is not * intersecting the ground plane. @@ -2501,8 +2660,11 @@ export class MapView extends THREE.EventDispatcher { } /** - * Do a raycast on all objects in the scene. Useful for picking. Limited to objects that - * THREE.js can raycast, the solid lines that get their geometry in the shader cannot be tested + * Do a raycast on all objects in the scene. Useful for picking. + * + * @remarks + * Limited to objects that THREE.js can raycast, the solid lines + * that get their geometry in the shader cannot be tested * for intersection. * * Note, if a {@link DataSource} adds an [[Object3D]] @@ -2515,10 +2677,12 @@ export class MapView extends THREE.EventDispatcher { * * @param x - The X position in css/client coordinates (without applied display ratio). * @param y - The Y position in css/client coordinates (without applied display ratio). + * @param parameters - The intersection test behaviour may be adjusted by providing an instance + * of {@link IntersectParams}. * @returns The list of intersection results. */ - intersectMapObjects(x: number, y: number): PickResult[] { - return this.m_pickHandler.intersectMapObjects(x, y); + intersectMapObjects(x: number, y: number, parameters?: IntersectParams): PickResult[] { + return this.m_pickHandler.intersectMapObjects(x, y, parameters); } /** @@ -2557,6 +2721,7 @@ export class MapView extends THREE.EventDispatcher { /** * Redraws scene immediately * + * @remarks * @note Before using this method, set `synchronousRendering` to `true` * in the {@link MapViewOptions} * @@ -2606,13 +2771,15 @@ export class MapView extends THREE.EventDispatcher { /** * Clear the tile cache. * + * @remarks * Remove the {@link Tile} objects created by cacheable * {@link DataSource}s. If a {@link DataSource} name is * provided, this method restricts the eviction the {@link DataSource} with the given name. * * @param dataSourceName - The name of the {@link DataSource}. + * @param filter Optional tile filter */ - clearTileCache(dataSourceName?: string) { + clearTileCache(dataSourceName?: string, filter?: (tile: Tile) => boolean) { if (this.m_visibleTiles === undefined) { // This method is called in the shadowsEnabled function, which is initialized in the // setupRenderer function, @@ -2621,11 +2788,11 @@ export class MapView extends THREE.EventDispatcher { if (dataSourceName !== undefined) { const dataSource = this.getDataSourceByName(dataSourceName); if (dataSource) { - this.m_visibleTiles.clearTileCache(dataSource); + this.m_visibleTiles.clearTileCache(dataSource, filter); dataSource.clearCache(); } } else { - this.m_visibleTiles.clearTileCache(); + this.m_visibleTiles.clearTileCache(undefined, filter); this.m_tileDataSources.forEach(dataSource => dataSource.clearCache()); } @@ -2655,6 +2822,7 @@ export class MapView extends THREE.EventDispatcher { /** * Visit each tile in visible, rendered, and cached sets. * + * @remarks * * Visible and temporarily rendered tiles will be marked for update and retained. * * Cached but not rendered/visible will be evicted. * @@ -2667,8 +2835,10 @@ export class MapView extends THREE.EventDispatcher { /** * Sets the DataSource which contains the elevations, the elevation range source, and the - * elevation provider. Only a single elevation source is possible per {@link MapView} + * elevation provider. * + * @remarks + * Only a single elevation source is possible per {@link MapView}. * If the terrain-datasource is merged with this repository, we could internally construct * the {@link ElevationRangeSource} and the {@link ElevationProvider} * and access would be granted to @@ -2732,6 +2902,27 @@ export class MapView extends THREE.EventDispatcher { return this.m_fog; } + private getStencilValue(renderOrder: number) { + if (!this.m_drawing) { + throw new Error("failed to get the stencil value"); + } + + return ( + this.m_renderOrderStencilValues.get(renderOrder) ?? + this.allocateStencilValue(renderOrder) + ); + } + + private allocateStencilValue(renderOrder: number) { + if (!this.m_drawing) { + throw new Error("failed to allocate stencil value"); + } + + const stencilValue = this.m_stencilValue++; + this.m_renderOrderStencilValues.set(renderOrder, stencilValue); + return stencilValue; + } + private setPostEffects() { // First clear all the effects, then enable them from what is specified. this.mapRenderingManager.bloom.enabled = false; @@ -2763,6 +2954,20 @@ export class MapView extends THREE.EventDispatcher { return this.m_elevationProvider; } + /** + * @beta + */ + get throttlingEnabled(): boolean { + return this.m_taskScheduler.throttlingEnabled === true; + } + + /** + * @beta + */ + set throttlingEnabled(enabled: boolean) { + this.m_taskScheduler.throttlingEnabled = enabled; + } + get shadowsEnabled(): boolean { return this.m_options.enableShadows === true; } @@ -2838,6 +3043,19 @@ export class MapView extends THREE.EventDispatcher { private lookAtImpl(params: Partial): void { const tilt = Math.min(getOptionValue(params.tilt, this.tilt), MapViewUtils.MAX_TILT_DEG); const heading = getOptionValue(params.heading, this.heading); + const distance = + params.zoomLevel !== undefined + ? MapViewUtils.calculateDistanceFromZoomLevel( + this, + THREE.MathUtils.clamp( + params.zoomLevel, + this.m_minZoomLevel, + this.m_maxZoomLevel + ) + ) + : params.distance !== undefined + ? params.distance + : this.m_targetDistance; let target: GeoCoordinates | undefined; if (params.bounds !== undefined) { @@ -2879,6 +3097,16 @@ export class MapView extends THREE.EventDispatcher { } else { this.projection.projectPoint(target, worldTarget); } + + if (params.zoomLevel !== undefined || params.distance !== undefined) { + return this.lookAtImpl({ + tilt, + heading, + distance, + target + }); + } + return this.lookAtImpl( MapViewUtils.getFitBoundsLookAtParams(target, worldTarget, worldPoints, { tilt, @@ -2895,20 +3123,6 @@ export class MapView extends THREE.EventDispatcher { target = params.target !== undefined ? GeoCoordinates.fromObject(params.target) : this.target; - const distance = - params.zoomLevel !== undefined - ? MapViewUtils.calculateDistanceFromZoomLevel( - this, - THREE.MathUtils.clamp( - params.zoomLevel, - this.m_minZoomLevel, - this.m_maxZoomLevel - ) - ) - : params.distance !== undefined - ? params.distance - : this.m_targetDistance; - // MapViewUtils#setRotation uses pitch, not tilt, which is different in sphere projection. // But in sphere, in the tangent space of the target of the camera, pitch = tilt. So, put // the camera on the target, so the tilt can be passed to getRotation as a pitch. @@ -2958,6 +3172,8 @@ export class MapView extends THREE.EventDispatcher { /** * Updates the camera and the projections and resets the screen collisions, * note, setupCamera must be called before this is called. + * + * @remarks * @param viewRanges - optional parameter that supplies new view ranges, most importantly * near/far clipping planes distance. If parameter is not provided view ranges will be * calculated from [[ClipPlaneEvaluator]] used in {@link VisibleTileSet}. @@ -3021,11 +3237,18 @@ export class MapView extends THREE.EventDispatcher { */ private updateLookAtSettings() { // tslint:disable-next-line: deprecation - const { target, distance } = MapViewUtils.getTargetAndDistance( + let { target, distance } = MapViewUtils.getTargetAndDistance( this.projection, this.camera, this.elevationProvider ); + if (this.geoMaxBounds) { + ({ target, distance } = MapViewUtils.constrainTargetAndDistanceToViewBounds( + target, + distance, + this + )); + } this.m_targetWorldPos.copy(target); this.m_targetGeoPos = this.projection.unprojectPoint(this.m_targetWorldPos); @@ -3235,6 +3458,9 @@ export class MapView extends THREE.EventDispatcher { RENDER_EVENT.time = frameStartTime; this.dispatchEvent(RENDER_EVENT); + this.m_stencilValue = DEFAULT_STENCIL_VALUE; + this.m_renderOrderStencilValues.clear(); + ++this.m_frameNumber; let currentFrameEvent: FrameStats | undefined; @@ -3299,6 +3525,7 @@ export class MapView extends THREE.EventDispatcher { this.storageLevel, Math.floor(this.zoomLevel), this.getEnabledTileDataSources(), + this.m_frameNumber, this.m_elevationRangeSource ); // View ranges has changed due to features (with elevation) that affects clip planes @@ -3441,6 +3668,16 @@ export class MapView extends THREE.EventDispatcher { this.checkCopyrightUpdates(); + // do this post paint therefore use a Timeout, if it has not been executed cancel and + // create a new one + if (this.m_taskSchedulerTimeout !== undefined) { + clearTimeout(this.m_taskSchedulerTimeout); + } + this.m_taskSchedulerTimeout = setTimeout(() => { + this.m_taskSchedulerTimeout = undefined; + this.m_taskScheduler.processPending(frameStartTime); + }, 0); + if (currentFrameEvent !== undefined) { endTime = PerformanceTimer.now(); @@ -3477,16 +3714,18 @@ export class MapView extends THREE.EventDispatcher { // frame, with no more tiles, geometry and labels waiting to be added, and no animation // running. The initial placement of text in this render call may have changed the loading // state of the TextElementsRenderer, so this has to be checked again. + // HARP-10919: Fading is currently ignored by the frame complete event. if ( - !this.m_firstFrameComplete && + !this.textElementsRenderer.loading && + this.m_visibleTiles.allVisibleTilesLoaded && this.m_initialTextPlacementDone && - !this.isDynamicFrame && - !this.textElementsRenderer.loading + !this.m_animatedExtrusionHandler.isAnimating ) { - this.m_firstFrameComplete = true; - - if (gatherStatistics) { - stats.appResults.set("firstFrameComplete", frameStartTime); + if (this.m_firstFrameComplete === false) { + this.m_firstFrameComplete = true; + if (gatherStatistics) { + stats.appResults.set("firstFrameComplete", frameStartTime); + } } FRAME_COMPLETE_EVENT.time = frameStartTime; @@ -3502,6 +3741,13 @@ export class MapView extends THREE.EventDispatcher { if (!this.processTileObject(tile, object, mapObjectAdapter)) { continue; } + + // TODO: acquire a new style value of if transparent + const material: SolidLineMaterial | undefined = (object as any).material; + if (object.renderOrder !== undefined && material instanceof SolidLineMaterial) { + material.stencilRef = this.getStencilValue(object.renderOrder); + } + object.position.copy(tile.center); if (object.displacement !== undefined) { object.position.add(object.displacement); @@ -3512,35 +3758,63 @@ export class MapView extends THREE.EventDispatcher { object.setRotationFromMatrix(tile.boundingBox.getRotationMatrix()); } object.frustumCulled = false; - if (object._backupRenderOrder === undefined) { - object._backupRenderOrder = object.renderOrder; - } - - const isBuilding = mapObjectAdapter?.kind?.includes(GeometryKind.Building); - - // When falling back to a parent tile (i.e. tile.levelOffset < 0) there will - // be overlaps with the already loaded tiles. Therefore all (flat) objects - // in a fallback tile must be shifted, such that their renderOrder is less - // than the groundPlane that each neighbouring Tile has (it has a renderOrder - // of -10000, see addGroundPlane in TileGeometryCreator), only then can we be - // sure that nothing of the parent will be rendered on top of the children, - // as such, we shift using the FALLBACK_RENDER_ORDER_OFFSET. - // This does not apply to buildings b/c they are 3d and the overlaps - // are resolved with a depth prepass. Note we set this always to ensure that if - // the Tile is used as a fallback, and then used normally, that we have the correct - // renderOrder. - object.renderOrder = - object._backupRenderOrder + - (!isBuilding && tile.levelOffset < 0 - ? FALLBACK_RENDER_ORDER_OFFSET * tile.levelOffset - : 0); + this.adjustRenderOrderForFallback(object, mapObjectAdapter, tile); this.m_sceneRoot.add(object); } tile.didRender(); } } + private adjustRenderOrderForFallback( + object: TileObject, + mapObjectAdapter: MapObjectAdapter | undefined, + tile: Tile + ) { + // When falling back to a parent tile (i.e. tile.levelOffset < 0) there will + // be overlaps with the already loaded tiles. Therefore all (flat) objects + // in a fallback tile must be shifted, such that their renderOrder is less + // than the groundPlane that each neighbouring Tile has (it has a renderOrder + // of -10000, see addGroundPlane in TileGeometryCreator), only then can we be + // sure that nothing of the parent will be rendered on top of the children, + // as such, we shift using the FALLBACK_RENDER_ORDER_OFFSET. + // This does not apply to buildings b/c they are 3d and the overlaps + // are resolved with a depth prepass. Note we set this always to ensure that if + // the Tile is used as a fallback, and then used normally, that we have the correct + // renderOrder. + + if (tile.levelOffset >= 0) { + if (object._backupRenderOrder !== undefined) { + // We messed up the render order when this tile was used as fallback. + // Now we render normally, so restore the original renderOrder. + object.renderOrder = object._backupRenderOrder; + } + return; + } + let offset = FALLBACK_RENDER_ORDER_OFFSET; + const technique = mapObjectAdapter?.technique; + if (technique?.name === "extruded-polygon") { + // Don't adjust render order for extruded-polygon b/c it's not flat. + return; + } + + if ((technique as any)?._category?.startsWith("road") === true) { + // Don't adjust render order for roads b/c the outline of the child tile + // would overlap the outline of the fallback parent. + // Road geometry would be duplicated but since it's rendered with two passes + // it would just appear a bit wider. That artefact is not as disturbing + // as seeing the cap outlines. + // NOTE: Since our tests do pixel perfect image comparison we also need to add a + // tiny offset in this case so that the order is well defined. + offset = 1e-6; + } + + if (object._backupRenderOrder === undefined) { + object._backupRenderOrder = object.renderOrder; + } + object.renderOrder = object._backupRenderOrder + offset * tile.levelOffset; + } + /** * Process dynamic updates of [[TileObject]]'s style. * @@ -3742,7 +4016,8 @@ export class MapView extends THREE.EventDispatcher { enableMixedLod ), this.m_tileGeometryManager, - this.m_visibleTileSetOptions + this.m_visibleTileSetOptions, + this.taskQueue ); } @@ -4035,7 +4310,7 @@ export class MapView extends THREE.EventDispatcher { * * Note: The renderer `this.m_renderer` may not be initialized when this function is called. */ - private onWebGLContextLost = (event: Event) => { + private readonly onWebGLContextLost = (event: Event) => { this.dispatchEvent(CONTEXT_LOST_EVENT); logger.warn("WebGL context lost", event); }; @@ -4045,7 +4320,7 @@ export class MapView extends THREE.EventDispatcher { * * Note: The renderer `this.m_renderer` may not be initialized when this function is called. */ - private onWebGLContextRestored = (event: Event) => { + private readonly onWebGLContextRestored = (event: Event) => { this.dispatchEvent(CONTEXT_RESTORED_EVENT); if (this.m_renderer !== undefined) { if (this.m_theme !== undefined && this.m_theme.clearColor !== undefined) { diff --git a/@here/harp-mapview/lib/MapViewAtmosphere.ts b/@here/harp-mapview/lib/MapViewAtmosphere.ts index c8f76a8cb6..222695c4cd 100644 --- a/@here/harp-mapview/lib/MapViewAtmosphere.ts +++ b/@here/harp-mapview/lib/MapViewAtmosphere.ts @@ -95,7 +95,7 @@ export class MapViewAtmosphere { private m_groundMaterial?: THREE.Material; private m_groundMesh?: THREE.Mesh; - private m_clipPlanesEvaluator = new TiltViewClipPlanesEvaluator( + private readonly m_clipPlanesEvaluator = new TiltViewClipPlanesEvaluator( EarthConstants.EQUATORIAL_RADIUS * SKY_ATMOSPHERE_ALTITUDE_FACTOR, 0, 1.0, @@ -126,12 +126,12 @@ export class MapViewAtmosphere { * testing and tweaking purposes. */ constructor( - private m_mapAnchors: MapAnchors, - private m_sceneCamera: THREE.Camera, - private m_projection: Projection, - private m_updateCallback?: () => void, - private m_atmosphereVariant: AtmosphereVariant = AtmosphereVariant.SkyAndGround, - private m_materialVariant = AtmosphereShadingVariant.ScatteringShader + private readonly m_mapAnchors: MapAnchors, + private readonly m_sceneCamera: THREE.Camera, + private readonly m_projection: Projection, + private readonly m_updateCallback?: () => void, + private readonly m_atmosphereVariant: AtmosphereVariant = AtmosphereVariant.SkyAndGround, + private readonly m_materialVariant = AtmosphereShadingVariant.ScatteringShader ) { // tslint:disable-next-line: no-bitwise if (this.m_atmosphereVariant & AtmosphereVariant.Sky) { diff --git a/@here/harp-mapview/lib/MapViewPoints.ts b/@here/harp-mapview/lib/MapViewPoints.ts index a4c98e9e38..6b71189bf1 100644 --- a/@here/harp-mapview/lib/MapViewPoints.ts +++ b/@here/harp-mapview/lib/MapViewPoints.ts @@ -8,9 +8,11 @@ import * as THREE from "three"; import { PickingRaycaster } from "./PickingRaycaster"; /** - * `MapViewPoints` is a class to extend for the `"circles"` and `"squares"` [[Technique]]s to - * implement raycasting of [[THREE.Points]] as expected in - * {@link MapView}, that are in screen space. + * `MapViewPoints` is a class to extend for the `"circles"` and `"squares"` techniques to + * implement raycasting of `THREE.Points` as expected in {@link MapView}, + * that are in screen space. + * + * @remarks * It copies the behaviour of the `raycast` method in [[THREE.Points]] and dispatches it to its * children classes, {@link Circles} and {@link Squares}, who hold the intersection testing in the * `testPoint` method. This class also has the ability to dismiss the testing via the @@ -19,6 +21,8 @@ import { PickingRaycaster } from "./PickingRaycaster"; * Its main motivation is to handle the point styles of XYZ projects. * * @see https://github.com/mrdoob/three.js/blob/master/src/objects/Points.js + * + * @internal */ export abstract class MapViewPoints extends THREE.Points { /** @@ -166,6 +170,7 @@ function getPointInfo( /** * Point object that implements the raycasting of circles in screen space. + * @internal */ export class Circles extends MapViewPoints { /** @override */ @@ -195,6 +200,7 @@ export class Circles extends MapViewPoints { /** * Point object that implements the raycasting of squares in screen space. + * @internal */ export class Squares extends MapViewPoints { /** @override */ diff --git a/@here/harp-mapview/lib/MapViewTaskScheduler.ts b/@here/harp-mapview/lib/MapViewTaskScheduler.ts new file mode 100644 index 0000000000..1997490f95 --- /dev/null +++ b/@here/harp-mapview/lib/MapViewTaskScheduler.ts @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ +import { PerformanceTimer, Task, TaskQueue } from "@here/harp-utils"; +import THREE = require("three"); + +import { TileTaskGroups } from "./MapView"; +import { PerformanceStatistics } from "./Statistics"; + +const DEFAULT_MAX_FPS = 60; +const DEFAULT_PROCESSING_ESTIMATE_TIME = 2; +const UPDATE_EVENT = { type: "update" }; + +export class MapViewTaskScheduler extends THREE.EventDispatcher { + private m_taskQueue: TaskQueue; + private m_throttlingEnabled: boolean = false; + + constructor(private m_maxFps: number = DEFAULT_MAX_FPS) { + super(); + this.m_taskQueue = new TaskQueue({ + groups: [TileTaskGroups.FETCH_AND_DECODE, TileTaskGroups.CREATE], + prioSortFn: (a: Task, b: Task) => { + return a.getPriority() - b.getPriority(); + } + }); + this.maxFps = m_maxFps; + } + + set maxFps(fps: number) { + this.m_maxFps = fps <= 0 ? DEFAULT_MAX_FPS : fps; + } + + get maxFps(): number { + return this.m_maxFps; + } + + get taskQueue() { + return this.m_taskQueue; + } + + get throttlingEnabled(): boolean { + return this.m_throttlingEnabled === true; + } + + set throttlingEnabled(enabled: boolean) { + this.m_throttlingEnabled = enabled; + } + + /** + * Sends a request to the [[MapView]] to redraw the scene. + */ + requestUpdate() { + this.dispatchEvent(UPDATE_EVENT); + } + + /** + * Processes the pending Tasks of the underlying [[TaskQueue]] + * !! This should run at the end of the renderLoop, so the calculations of the available + * frame time are better estimated + * + * @param frameStartTime the start time of the current frame, is used to calculate the + * still available time in the frame to process Tasks + * + */ + processPending(frameStartTime: number) { + const stats = PerformanceStatistics.instance; + const currentFrameEvent = stats.enabled ? stats.currentFrame : undefined; + let startTime: number | undefined; + if (stats.enabled) { + startTime = PerformanceTimer.now(); + } + + //update the task queue, to remove expired and sort with priority + this.m_taskQueue.update(); + let numItemsLeft = this.taskQueue.numItemsLeft(); + currentFrameEvent?.setValue("TaskScheduler.numPendingTasks", numItemsLeft); + + if (this.throttlingEnabled) { + // get the available time in this frame to achieve a max fps rate + let availableTime = this.spaceInFrame(frameStartTime); + // get some buffer to balance the inaccurate estimates + availableTime = availableTime > 2 ? availableTime - 2 : availableTime; + currentFrameEvent?.setValue("TaskScheduler.estimatedAvailableTime", availableTime); + + let counter = 0; + // check if ther is still time available and tasks left + while (availableTime > 0 && numItemsLeft > 0) { + counter++; + // create a processing condition for the tasks + function shouldProcess(task: Task) { + // if there is a time estimate use it, otherwise default to 1 ms + // TODO: check whats a sane default, 1 seems to do it for now + availableTime -= + task.estimatedProcessTime?.() || DEFAULT_PROCESSING_ESTIMATE_TIME; + // always process at least 1 Task, so in the worst case the fps over tiles + // paradigma is sacrificed to not have an empty screen + if (availableTime > 0 || counter === 1) { + return true; + } + return false; + } + + // process the CREATE tasks first, as they will have a faster result on the + // visual outcome and have already spend time in the application during + // fetching and decoding + // fetching has lower priority as it wont make to much of a difference if not + // called at the exact frame, and the tile might expire in the next anyway + [TileTaskGroups.CREATE, TileTaskGroups.FETCH_AND_DECODE].forEach(tag => { + if (this.m_taskQueue.numItemsLeft(tag)) { + //TODO: + // * if one tag task does not fit another might, how to handle this? + // * ** what if a task of another group could fit instead + // * whats the average of time we have here at this point in the programm? + this.m_taskQueue.processNext(tag, shouldProcess); + } + }); + numItemsLeft = this.m_taskQueue.numItemsLeft(); + } + // if there is tasks left in the TaskQueue, request an update to be able to process them + // in a next frame + numItemsLeft = this.m_taskQueue.numItemsLeft(); + if (numItemsLeft > 0) { + currentFrameEvent?.setValue( + "TaskScheduler.pendingTasksNotYetProcessed", + numItemsLeft + ); + this.requestUpdate(); + } + } else { + //if throttling is disabled, process all pending tasks + this.m_taskQueue.processNext( + TileTaskGroups.CREATE, + undefined, + this.m_taskQueue.numItemsLeft(TileTaskGroups.CREATE) + ); + this.m_taskQueue.processNext( + TileTaskGroups.FETCH_AND_DECODE, + undefined, + this.m_taskQueue.numItemsLeft(TileTaskGroups.FETCH_AND_DECODE) + ); + } + + if (stats.enabled) { + currentFrameEvent?.setValue( + "TaskScheduler.pendingTasksTime", + PerformanceTimer.now() - startTime! + ); + } + } + + private spaceInFrame(frameStartTime: number): number { + const passedTime = (performance || Date).now() - frameStartTime; + return Math.max(1000 / this.m_maxFps - passedTime, 0); + } +} diff --git a/@here/harp-mapview/lib/PickHandler.ts b/@here/harp-mapview/lib/PickHandler.ts index 32370c045e..11ce992516 100644 --- a/@here/harp-mapview/lib/PickHandler.ts +++ b/@here/harp-mapview/lib/PickHandler.ts @@ -8,9 +8,11 @@ import { GeometryType, getFeatureId, Technique } from "@here/harp-datasource-pro import * as THREE from "three"; import { OrientedBox3 } from "@here/harp-geoutils"; +import { IntersectParams } from "./IntersectParams"; import { MapView } from "./MapView"; import { MapViewPoints } from "./MapViewPoints"; -import { TileFeatureData } from "./Tile"; +import { PickListener } from "./PickListener"; +import { Tile, TileFeatureData } from "./Tile"; /** * Describes the general type of a picked object. @@ -73,6 +75,11 @@ export interface PickResult { */ distance: number; + /** + * Render order of the intersected object. + */ + renderOrder?: number; + /** * An optional feature ID of the picked object; typically applies to the Optimized Map * Vector (OMV) format. @@ -103,6 +110,34 @@ export interface PickResult { const tmpOBB = new OrientedBox3(); +// Intersects the dependent tile objects using the supplied raycaster. Note, because multiple +// tiles can point to the same dependency we need to store which results we have already +// raycasted, see checkedDependencies. +function intersectDependentObjects( + tile: Tile, + intersects: THREE.Intersection[], + rayCaster: THREE.Raycaster, + checkedDependencies: Set, + mapView: MapView +) { + for (const tileKey of tile.dependencies) { + const mortonCode = tileKey.mortonCode(); + if (checkedDependencies.has(mortonCode)) { + continue; + } + checkedDependencies.add(mortonCode); + const otherTile = mapView.visibleTileSet.getCachedTile( + tile.dataSource, + tileKey, + tile.offset, + mapView.frameNumber + ); + if (otherTile !== undefined) { + rayCaster.intersectObjects(otherTile.objects, true, intersects); + } + } +} + /** * Handles the picking of scene geometry and roads. * @internal @@ -121,24 +156,125 @@ export class PickHandler { * * @param x - The X position in CSS/client coordinates, without the applied display ratio. * @param y - The Y position in CSS/client coordinates, without the applied display ratio. + * @param parameters - The intersection test behaviour may be adjusted by providing an instance + * of {@link IntersectParams}. * @returns the list of intersection results. */ - intersectMapObjects(x: number, y: number): PickResult[] { + intersectMapObjects(x: number, y: number, parameters?: IntersectParams): PickResult[] { const worldPos = this.mapView.getNormalizedScreenCoordinates(x, y); const rayCaster = this.mapView.raycasterFromScreenPoint(x, y); - const pickResults: PickResult[] = []; + + const pickListener = new PickListener(parameters); if (this.mapView.textElementsRenderer !== undefined) { const { clientWidth, clientHeight } = this.mapView.canvas; const screenX = worldPos.x * clientWidth * 0.5; const screenY = worldPos.y * clientHeight * 0.5; const scenePosition = new THREE.Vector2(screenX, screenY); - this.mapView.textElementsRenderer.pickTextElements(scenePosition, pickResults); + this.mapView.textElementsRenderer.pickTextElements(scenePosition, pickListener); } const intersects: THREE.Intersection[] = []; + const intersectedTiles = this.getIntersectedTiles(rayCaster); + + // This ensures that we check a given dependency only once (because multiple tiles could + // have the same dependency). + const checkedDependencies = new Set(); + + for (const { tile, distance } of intersectedTiles) { + if (pickListener.done && pickListener.furthestResult!.distance < distance) { + // Stop when the listener has all results it needs and remaining tiles are further + // away than then furthest pick result found so far. + break; + } + + intersects.length = 0; + rayCaster.intersectObjects(tile.objects, true, intersects); + intersectDependentObjects( + tile, + intersects, + rayCaster, + checkedDependencies, + this.mapView + ); + + for (const intersect of intersects) { + pickListener.addResult(this.createResult(intersect)); + } + } + + pickListener.finish(); + return pickListener.results; + } + + private createResult(intersection: THREE.Intersection): PickResult { + const pickResult: PickResult = { + type: PickObjectType.Unspecified, + point: intersection.point, + distance: intersection.distance, + intersection + }; + + if ( + intersection.object.userData === undefined || + intersection.object.userData.feature === undefined + ) { + return pickResult; + } + + if (this.enablePickTechnique) { + pickResult.technique = intersection.object.userData.technique; + } + pickResult.renderOrder = intersection.object?.renderOrder; + + const featureData: TileFeatureData = intersection.object.userData.feature; + this.addObjInfo(featureData, intersection, pickResult); + if (pickResult.userData) { + const featureId = getFeatureId(pickResult.userData); + pickResult.featureId = featureId === 0 ? undefined : featureId; + } + + let pickObjectType: PickObjectType; + + switch (featureData.geometryType) { + case GeometryType.Point: + case GeometryType.Text: + pickObjectType = PickObjectType.Point; + break; + case GeometryType.Line: + case GeometryType.ExtrudedLine: + case GeometryType.SolidLine: + case GeometryType.TextPath: + pickObjectType = PickObjectType.Line; + break; + case GeometryType.Polygon: + case GeometryType.ExtrudedPolygon: + pickObjectType = PickObjectType.Area; + break; + case GeometryType.Object3D: + pickObjectType = PickObjectType.Object3D; + break; + default: + pickObjectType = PickObjectType.Unspecified; + } + + pickResult.type = pickObjectType; + return pickResult; + } + + private getIntersectedTiles( + rayCaster: THREE.Raycaster + ): Array<{ tile: Tile; distance: number }> { + const tiles = new Array<{ + tile: Tile; + distance: number; + }>(); const tileList = this.mapView.visibleTileSet.dataSourceTileList; tileList.forEach(dataSourceTileList => { + if (!dataSourceTileList.dataSource.enablePicking) { + return; + } + dataSourceTileList.renderedTiles.forEach(tile => { tmpOBB.copy(tile.boundingBox); tmpOBB.position.sub(this.mapView.worldCenter); @@ -146,77 +282,19 @@ export class PickHandler { // MapView const worldOffsetX = tile.computeWorldOffsetX(); tmpOBB.position.x += worldOffsetX; - - if (tmpOBB.intersectsRay(rayCaster.ray) !== undefined) { - rayCaster.intersectObjects(tile.objects, true, intersects); + const distance = tmpOBB.intersectsRay(rayCaster.ray); + if (distance !== undefined) { + tiles.push({ tile, distance }); } }); }); - for (const intersect of intersects) { - const pickResult: PickResult = { - type: PickObjectType.Unspecified, - point: intersect.point, - distance: intersect.distance, - intersection: intersect - }; - - if ( - intersect.object.userData === undefined || - intersect.object.userData.feature === undefined - ) { - pickResults.push(pickResult); - continue; + tiles.sort( + (lhs: { tile: Tile; distance: number }, rhs: { tile: Tile; distance: number }) => { + return lhs.distance - rhs.distance; } - - const featureData: TileFeatureData = intersect.object.userData.feature; - if (this.enablePickTechnique) { - pickResult.technique = intersect.object.userData.technique; - } - - this.addObjInfo(featureData, intersect, pickResult); - - if (featureData.objInfos !== undefined) { - const featureId = - featureData.objInfos.length === 1 - ? getFeatureId(featureData.objInfos[0]) - : undefined; - pickResult.featureId = featureId; - } - - let pickObjectType: PickObjectType; - - switch (featureData.geometryType) { - case GeometryType.Point: - case GeometryType.Text: - pickObjectType = PickObjectType.Point; - break; - case GeometryType.Line: - case GeometryType.ExtrudedLine: - case GeometryType.SolidLine: - case GeometryType.TextPath: - pickObjectType = PickObjectType.Line; - break; - case GeometryType.Polygon: - case GeometryType.ExtrudedPolygon: - pickObjectType = PickObjectType.Area; - break; - case GeometryType.Object3D: - pickObjectType = PickObjectType.Object3D; - break; - default: - pickObjectType = PickObjectType.Unspecified; - } - - pickResult.type = pickObjectType; - pickResults.push(pickResult); - } - - pickResults.sort((a: PickResult, b: PickResult) => { - return a.distance - b.distance; - }); - - return pickResults; + ); + return tiles; } private addObjInfo( @@ -238,6 +316,9 @@ export class PickHandler { featureData.starts.length === 0 || (intersect.faceIndex === undefined && intersect.index === undefined) ) { + if (featureData.objInfos.length === 1) { + pickResult.userData = featureData.objInfos[0]; + } return; } diff --git a/@here/harp-mapview/lib/PickListener.ts b/@here/harp-mapview/lib/PickListener.ts new file mode 100644 index 0000000000..91469ed5b5 --- /dev/null +++ b/@here/harp-mapview/lib/PickListener.ts @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ + +import { assert } from "@here/harp-utils"; +import { IntersectParams } from "./IntersectParams"; +import { PickResult } from "./PickHandler"; + +// Default sorting by distance first and then by reversed render order. +function defaultSort(lhs: PickResult, rhs: PickResult) { + const distanceDiff = lhs.distance - rhs.distance; + const haveRenderOrder = lhs.renderOrder !== undefined && rhs.renderOrder !== undefined; + if (distanceDiff !== 0 || !haveRenderOrder) { + return distanceDiff; + } + + return rhs.renderOrder! - lhs.renderOrder!; +} + +/** + * Collects results from a picking (intersection) test. + * + * @internal + */ +export class PickListener { + private m_results: PickResult[] = []; + private m_sorted: boolean = true; + private m_finished: boolean = true; + + /** + * Constructs a new `PickListener`. + * + * @param m_parameters - Optional parameters to customize picking behaviour. + */ + constructor(private readonly m_parameters?: IntersectParams) {} + + /** + * Adds a pick result. + * + * @param result - The result to be added. + */ + addResult(result: PickResult): void { + // Add the result only if it's a different feature from the ones already collected. + const foundFeatureIdx = this.m_results.findIndex(otherResult => { + const sameType = otherResult.type === result.type; + const dataSource = result.intersection?.object.userData?.dataSource; + const sameDataSource = + dataSource && otherResult.intersection?.object.userData?.dataSource === dataSource; + const sameId = + result.featureId !== undefined && otherResult.featureId === result.featureId; + const noId = result.featureId === undefined && otherResult.featureId === undefined; + const sameUserData = result.userData && otherResult.userData === result.userData; + return sameType && sameDataSource && (sameId || (noId && sameUserData)); + }); + + if (foundFeatureIdx < 0) { + this.m_sorted = false; + this.m_finished = false; + this.m_results.push(result); + return; + } + + // Replace the result for the same feature if it's sorted after the new result. + const oldResult = this.m_results[foundFeatureIdx]; + if (defaultSort(result, oldResult) < 0) { + this.m_results[foundFeatureIdx] = result; + this.m_sorted = false; + this.m_finished = false; + } + } + + /** + * Indicates whether the listener is satisfied with the results already provided. + * @returns `True` if the listener doesn't expect more results, `False` otherwise. + */ + get done(): boolean { + return this.maxResults ? this.m_results.length >= this.maxResults : false; + } + + /** + * Orders the collected results by distance first, then by reversed render order + * (topmost/highest render order first), and limits the number of results to the maximum + * accepted number, see {@link IntersectParams.maxResultCount}. + */ + finish(): void { + // Keep only the closest max results. + this.sortResults(); + if (this.maxResults && this.m_results.length > this.maxResults) { + this.m_results.length = this.maxResults; + } + this.m_finished = true; + } + + /** + * Returns the collected results. {@link PickListener.finish} should be called first to ensure + * the proper sorting and result count. + * @returns The pick results. + */ + get results(): PickResult[] { + assert(this.m_finished, "finish() was not called before getting the results"); + return this.m_results; + } + + /** + * Returns the closest result collected so far, following the order documented in + * {@link PickListener.finish} + * @returns The closest pick result, or `undefined` if no result was collected. + */ + get closestResult(): PickResult | undefined { + this.sortResults(); + return this.m_results.length > 0 ? this.m_results[0] : undefined; + } + + /** + * Returns the furtherst result collected so far, following the order documented in + * {@link PickListener.results} + * @returns The furthest pick result, or `undefined` if no result was collected. + */ + get furthestResult(): PickResult | undefined { + this.sortResults(); + return this.m_results.length > 0 ? this.m_results[this.m_results.length - 1] : undefined; + } + + private get maxResults(): number | undefined { + const maxCount = this.m_parameters?.maxResultCount ?? 0; + return maxCount > 0 ? maxCount : undefined; + } + + private sortResults(): void { + if (!this.m_sorted) { + this.m_results.sort(defaultSort); + this.m_sorted = true; + } + } +} diff --git a/@here/harp-mapview/lib/PickingRaycaster.ts b/@here/harp-mapview/lib/PickingRaycaster.ts index e7114559a6..0870a2f65b 100644 --- a/@here/harp-mapview/lib/PickingRaycaster.ts +++ b/@here/harp-mapview/lib/PickingRaycaster.ts @@ -4,7 +4,30 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { MapEnv } from "@here/harp-datasource-protocol"; import * as THREE from "three"; +import { MapObjectAdapter } from "./MapObjectAdapter"; + +function intersectObject( + object: THREE.Object3D, + raycaster: PickingRaycaster, + env: MapEnv, + intersects: THREE.Intersection[], + recursive?: boolean +) { + if (object.layers.test(raycaster.layers) && object.visible) { + const mapObjectAdapter = MapObjectAdapter.get(object); + if (!mapObjectAdapter || mapObjectAdapter.isPickable(env)) { + object.raycast(raycaster, intersects); + } + } + + if (recursive === true) { + for (const child of object.children) { + intersectObject(child, raycaster, env, intersects, true); + } + } +} /** * Raycasting points is not supported as necessary in Three.js. This class extends a @@ -19,8 +42,41 @@ export class PickingRaycaster extends THREE.Raycaster { * * @param width - the canvas width. * @param height - the canvas height. + * @param m_env - the view enviroment. */ - constructor(public width: number, public height: number) { + constructor(public width: number, public height: number, private m_env: MapEnv) { super(); } + + // HARP-9585: Override of base class method, however tslint doesn't recognize overrides of + // three.js classes. + // tslint:disable-next-line: explicit-override + intersectObject( + object: THREE.Object3D, + recursive?: boolean, + optionalTarget?: THREE.Intersection[] + ): THREE.Intersection[] { + const intersects: THREE.Intersection[] = optionalTarget ?? []; + + intersectObject(object, this, this.m_env, intersects, recursive); + + return intersects; + } + + // HARP-9585: Override of base class method, however tslint doesn't recognize overrides of + // three.js classes. + // tslint:disable-next-line: explicit-override + intersectObjects( + objects: THREE.Object3D[], + recursive?: boolean, + optionalTarget?: THREE.Intersection[] + ): THREE.Intersection[] { + const intersects: THREE.Intersection[] = optionalTarget ?? []; + + for (const object of objects) { + intersectObject(object, this, this.m_env, intersects, recursive); + } + + return intersects; + } } diff --git a/@here/harp-mapview/lib/PolarTileDataSource.ts b/@here/harp-mapview/lib/PolarTileDataSource.ts index c9c0495fc3..c59e319178 100644 --- a/@here/harp-mapview/lib/PolarTileDataSource.ts +++ b/@here/harp-mapview/lib/PolarTileDataSource.ts @@ -51,10 +51,10 @@ interface TechniqueEntry { * {@link DataSource} providing geometry for poles */ export class PolarTileDataSource extends DataSource { - private m_tilingScheme: TilingScheme = polarTilingScheme; - private m_maxLatitude = THREE.MathUtils.radToDeg(MercatorConstants.MAXIMUM_LATITUDE); + private readonly m_tilingScheme: TilingScheme = polarTilingScheme; + private readonly m_maxLatitude = THREE.MathUtils.radToDeg(MercatorConstants.MAXIMUM_LATITUDE); private m_geometryLevelOffset: number; - private m_debugTiles: boolean; + private readonly m_debugTiles: boolean; private m_styleSetEvaluator?: StyleSetEvaluator; private m_northPoleEntry?: TechniqueEntry; @@ -84,6 +84,7 @@ export class PolarTileDataSource extends DataSource { this.m_geometryLevelOffset = geometryLevelOffset; this.m_debugTiles = debugTiles; this.cacheable = false; + this.enablePicking = false; } /** @override */ @@ -139,11 +140,13 @@ export class PolarTileDataSource extends DataSource { /** @override */ setTheme(theme: Theme, languages?: string[]): void { - const styleSet = - (this.styleSetName !== undefined && theme.styles && theme.styles[this.styleSetName]) || - []; + let styleSet: StyleSet | undefined; - this.setStyleSet(styleSet, theme.definitions, languages); + if (this.styleSetName !== undefined && theme.styles !== undefined) { + styleSet = theme.styles[this.styleSetName]; + } + + this.setStyleSet(styleSet ?? [], theme.definitions, languages); } /** @override */ diff --git a/@here/harp-mapview/lib/ScreenCollisions.ts b/@here/harp-mapview/lib/ScreenCollisions.ts index 3437c8ef6c..2445383acf 100644 --- a/@here/harp-mapview/lib/ScreenCollisions.ts +++ b/@here/harp-mapview/lib/ScreenCollisions.ts @@ -92,7 +92,7 @@ export class ScreenCollisions { /** Tree of allocated bounds. */ - private rtree = new RBush(); + private readonly rtree = new RBush(); /** * Constructs a new ScreenCollisions object. diff --git a/@here/harp-mapview/lib/SkyGradientTexture.ts b/@here/harp-mapview/lib/SkyGradientTexture.ts index 8de8f80b56..4f1ed45906 100644 --- a/@here/harp-mapview/lib/SkyGradientTexture.ts +++ b/@here/harp-mapview/lib/SkyGradientTexture.ts @@ -46,18 +46,18 @@ const cameraUp = [ * hemisphere.. */ export class SkyGradientTexture { - private m_width: number; - private m_faceCount: number; - private m_faces: DataTexture[]; - private m_skybox?: CubeTexture; + private readonly m_width: number; + private readonly m_faceCount: number; + private readonly m_faces: DataTexture[]; + private readonly m_skybox?: CubeTexture; // Used only in the planar case. - private m_farClipPlaneDividedVertically?: THREE.Line3; - private m_groundPlane?: THREE.Plane; - private m_bottomMidFarPoint?: THREE.Vector3; - private m_topMidFarPoint?: THREE.Vector3; - private m_horizonPosition?: THREE.Vector3; - private m_farClipPlaneCorners?: THREE.Vector3[]; + private readonly m_farClipPlaneDividedVertically?: THREE.Line3; + private readonly m_groundPlane?: THREE.Plane; + private readonly m_bottomMidFarPoint?: THREE.Vector3; + private readonly m_topMidFarPoint?: THREE.Vector3; + private readonly m_horizonPosition?: THREE.Vector3; + private readonly m_farClipPlaneCorners?: THREE.Vector3[]; /** * Constructs a new `SkyGradientTexture`. @@ -68,8 +68,8 @@ export class SkyGradientTexture { */ constructor( sky: GradientSky, - private m_projectionType: ProjectionType, - private m_height: number = DEFAULT_TEXTURE_SIZE + private readonly m_projectionType: ProjectionType, + private readonly m_height: number = DEFAULT_TEXTURE_SIZE ) { const topColor = new Color(sky.topColor); const bottomColor = new Color(sky.bottomColor); diff --git a/@here/harp-mapview/lib/Statistics.ts b/@here/harp-mapview/lib/Statistics.ts index da862dc2b6..9469cd96a8 100644 --- a/@here/harp-mapview/lib/Statistics.ts +++ b/@here/harp-mapview/lib/Statistics.ts @@ -149,7 +149,7 @@ export namespace RingBuffer { * @param m_buffer - `Ringbuffer` to iterate over. * @param m_index - Start index. */ - constructor(private m_buffer: RingBuffer, private m_index: number = 0) {} + constructor(private readonly m_buffer: RingBuffer, private m_index: number = 0) {} /** * Gets the iterator's current value. This function does not fail even if an overrun occurs. @@ -174,8 +174,12 @@ export namespace RingBuffer { } /** - * An interface for a Timer class, that abstracts the basic functions of a Timer. Implemented - * by SimpleTimer, SampledTimer, and MultiStageTimer. + * An interface for a Timer class, that abstracts the basic functions of a Timer. + * + * @remarks + * Implemented by SimpleTimer, SampledTimer, and MultiStageTimer. + * + * @internal */ export interface Timer { readonly name: string; @@ -215,6 +219,8 @@ export interface Timer { /** * A simple timer that stores only the latest measurement. + * + * @internal */ export class SimpleTimer implements Timer { /** `true` if timer has been started. */ @@ -274,7 +280,7 @@ export class SimpleTimer implements Timer { throw new Error("Timer '" + this.name + "' has not been started"); } else { // this.currentValue is a number now! - const t = PerformanceTimer.now() - (this.m_currentValue || 0); + const t = PerformanceTimer.now() - (this.m_currentValue ?? 0); this.m_currentValue = t; this.setValue(t); this.running = false; @@ -294,7 +300,7 @@ export class SimpleTimer implements Timer { if (!this.running) { throw new Error("Timer '" + this.name + "' has not been started"); } else { - const t = PerformanceTimer.now() - (this.m_currentValue || 0); + const t = PerformanceTimer.now() - (this.m_currentValue ?? 0); return t; } } @@ -302,6 +308,8 @@ export class SimpleTimer implements Timer { /** * Simple statistics about the values in an array. + * + * @internal */ export interface Stats { /** @@ -362,6 +370,8 @@ export interface Stats { /** * A timer that stores the last `n` samples in a ring buffer. + * + * @internal */ export class SampledTimer extends SimpleTimer { /** @@ -428,11 +438,14 @@ export class SampledTimer extends SimpleTimer { * Only exported for testing * @ignore * + * @remarks * Compute the [[ArrayStats]] for the passed in array of numbers. * * @param {number[]} samples Array containing sampled values. Will be modified (!) by sorting the * entries. * @returns {(Stats | undefined)} + * + * @internal */ export function computeArrayStats(samples: number[]): Stats | undefined { if (samples.length === 0) { @@ -504,10 +517,13 @@ export function computeArrayStats(samples: number[]): Stats | undefined { * Only exported for testing * @ignore * + * @remarks * Compute the averages for the passed in array of numbers. * * @param {number[]} samples Array containing sampled values. * @returns {(Stats | undefined)} + * + * @internal */ export function computeArrayAverage(samples: number[]): number | undefined { if (samples.length === 0) { @@ -527,11 +543,15 @@ export function computeArrayAverage(samples: number[]): number | undefined { /** * Measures a sequence of connected events, such as multiple processing stages in a function. + * + * @remarks * Each stage is identified with a timer name, that must be a valid timer in the statistics * object. Additionally, all timers within a `MultiStageTimer` must be unique. * * Internally, the `MultiStageTimer` manages a list of timers where at the end of each stage, * one timer stops and the next timer starts. + * + * @internal */ export class MultiStageTimer { private currentStage: string | undefined; @@ -543,7 +563,11 @@ export class MultiStageTimer { * @param name - Name of this `MultiStageTimer`. * @param stages - List of timer names. */ - constructor(private statistics: Statistics, readonly name: string, public stages: string[]) { + constructor( + private readonly statistics: Statistics, + readonly name: string, + public stages: string[] + ) { if (stages.length < 1) { throw new Error("MultiStageTimer needs stages"); } @@ -581,7 +605,7 @@ export class MultiStageTimer { start(): number { this.stage = this.stages[0]; - return this.statistics.getTimer(this.stages[0]).value || -1; + return this.statistics.getTimer(this.stages[0]).value ?? -1; } /** @@ -625,13 +649,18 @@ export class MultiStageTimer { } /** - * Manages a set of timers. The main objective of `Statistics` is to log these timers. You can + * Manages a set of timers. + * + * @remarks + * The main objective of `Statistics` is to log these timers. You can * disable statistics to minimize their impact on performance. + * + * @internal */ export class Statistics { - private timers: Map; + private readonly timers: Map; - private nullTimer: Timer; + private readonly nullTimer: Timer; /** * Sets up a group of timers. @@ -754,6 +783,8 @@ export class Statistics { /** * Class containing all counters, timers and events of the current frame. + * + * @internal */ export class FrameStats { readonly entries: Map = new Map(); @@ -820,6 +851,7 @@ export class FrameStats { * @ignore * Only exported for testing. * + * @remarks * Instead of passing around an array of objects, we store the frame statistics as an object of * arrays. This allows convenient computations from {@link RingBuffer}, */ @@ -904,6 +936,9 @@ interface ChromeMemoryInfo { jsHeapSizeLimit: number; } +/** + * @internal + */ export interface SimpleFrameStatistics { configs: Map; appResults: Map; @@ -915,11 +950,14 @@ export interface SimpleFrameStatistics { } /** - * Performance measurement central. Maintains the current - * {@link FrameStats}, which holds all individual - * performance numbers. + * Performance measurement central. + * + * @remarks + * Maintains the current. Implemented as an instance for easy access. + * + * {@link FrameStats}, which holds all individual performance numbers. * - * Implemented as an instance for easy access. + * @internal */ export class PerformanceStatistics { /** @@ -982,7 +1020,7 @@ export class PerformanceStatistics { readonly configs: Map = new Map(); // Current array of frame events. - private m_frameEvents: FrameStatsArray; + private readonly m_frameEvents: FrameStatsArray; /** * Creates an instance of PerformanceStatistics. Overrides the current `instance`. diff --git a/@here/harp-mapview/lib/TextureLoader.ts b/@here/harp-mapview/lib/TextureLoader.ts index 87c9528f04..d4fddd0ac1 100644 --- a/@here/harp-mapview/lib/TextureLoader.ts +++ b/@here/harp-mapview/lib/TextureLoader.ts @@ -13,7 +13,7 @@ export interface RequestHeaders { * A texture loader that supports request headers(e.g. for Authorization) */ export class TextureLoader { - private m_textureLoader = new THREE.TextureLoader(); + private readonly m_textureLoader = new THREE.TextureLoader(); /** * Load an image from url and create a texture diff --git a/@here/harp-mapview/lib/ThemeLoader.ts b/@here/harp-mapview/lib/ThemeLoader.ts index 13148af460..e598e4fb79 100644 --- a/@here/harp-mapview/lib/ThemeLoader.ts +++ b/@here/harp-mapview/lib/ThemeLoader.ts @@ -35,6 +35,9 @@ import { SkyCubemapFaceId, SKY_CUBEMAP_FACE_COUNT } from "./SkyCubemapTexture"; import "@here/harp-fetch"; +/** + * @internal + */ export const DEFAULT_MAX_THEME_INTHERITANCE_DEPTH = 4; /** @@ -128,7 +131,7 @@ export class ThemeLoader { theme: string | Theme | FlatTheme, options?: ThemeLoadOptions ): Promise { - options = options || {}; + options = options ?? {}; if (typeof theme === "string") { const uriResolver = options.uriResolver; const themeUrl = uriResolver !== undefined ? uriResolver.resolveUri(theme) : theme; @@ -157,7 +160,7 @@ export class ThemeLoader { theme = await ThemeLoader.resolveBaseThemes(theme, options); if (resolveDefinitions) { const contextLoader = new ContextLogger( - options.logger || console, + options.logger ?? console, `when processing Theme ${theme.url}:` ); ThemeLoader.resolveThemeReferences(theme, contextLoader); @@ -484,7 +487,7 @@ export class ThemeLoader { theme: Theme, options?: ThemeLoadOptions ): Promise { - options = options || {}; + options = options ?? {}; if (theme.extends === undefined) { return theme; } diff --git a/@here/harp-mapview/lib/Tile.ts b/@here/harp-mapview/lib/Tile.ts index ba9fd746c8..4eeedeca24 100644 --- a/@here/harp-mapview/lib/Tile.ts +++ b/@here/harp-mapview/lib/Tile.ts @@ -77,6 +77,8 @@ const MINIMUM_OBJECT_SIZE_ESTIMATION = 100; /** * Compute the memory footprint of `TileFeatureData`. + * + * @internal */ export function getFeatureDataSize(featureData: TileFeatureData): number { let numBytes = MINIMUM_OBJECT_SIZE_ESTIMATION; @@ -93,7 +95,7 @@ export function getFeatureDataSize(featureData: TileFeatureData): number { } /** - * Missing Typedoc + * The state the {@link ITileLoader}. */ export enum TileLoaderState { Initialized, @@ -105,6 +107,9 @@ export enum TileLoaderState { Failed } +/** + * The interface for managing tile loading. + */ export interface ITileLoader { state: TileLoaderState; payload?: ArrayBufferLike | {}; @@ -112,6 +117,8 @@ export interface ITileLoader { isFinished: boolean; + priority: number; + loadAndDecode(): Promise; waitSettled(): Promise; @@ -151,7 +158,10 @@ export interface TileResourceUsage { } /** - * Simple information about resource usage by the {@link Tile}. Heap and GPU information are + * Simple information about resource usage by the {@link Tile}. + * + * @remarks + * Heap and GPU information are * estimations. */ export interface TileResourceInfo { @@ -178,6 +188,9 @@ export interface TileResourceInfo { numUserTextElements: number; } +/** + * @internal + */ export interface TextElementIndex { groupIndex: number; elementIndex: number; @@ -195,10 +208,10 @@ export class Tile implements CachedResource { readonly objects: TileObject[] = []; /** - * The optional list of HERE TileKeys of tiles with geometries that cross - * the boundaries of this `Tile`. + * The optional list of HERE TileKeys of tiles with geometries that cross the boundaries of this + * `Tile`. */ - readonly dependencies: string[] = new Array(); + readonly dependencies: TileKey[] = []; /** * The bounding box of this `Tile` in geocoordinates. @@ -260,7 +273,15 @@ export class Tile implements CachedResource { * impact. Setting this value directly affects the [[willRender]] method, unless * overriden by deriving classes. */ - skipRendering = false; + skipRendering: boolean = false; + + /** + * If the tile should not yet be rendered, this is used typically when the tile in question + * does not fit into the gpu upload limit of the current frame. + * Setting this value directly affects the [[willRender]] method, unless + * overriden by deriving classes. + */ + delayRendering = false; /** * @hidden @@ -276,7 +297,7 @@ export class Tile implements CachedResource { private m_disposed: boolean = false; private m_disposeCallback?: TileCallback; - private m_localTangentSpace = false; + private readonly m_localTangentSpace: boolean; private m_forceHasGeometry: boolean | undefined = undefined; @@ -306,9 +327,9 @@ export class Tile implements CachedResource { private m_resourceInfo: TileResourceInfo | undefined; // List of owned textures for disposal - private m_ownedTextures: WeakSet = new WeakSet(); + private readonly m_ownedTextures: WeakSet = new WeakSet(); - private m_textStyleCache: TileTextStyleCache; + private readonly m_textStyleCache: TileTextStyleCache; private m_uniqueKey: number; private m_offset: number; /** @@ -331,7 +352,7 @@ export class Tile implements CachedResource { this.geoBox = this.dataSource.getTilingScheme().getGeoBox(this.tileKey); this.updateBoundingBox(); this.m_worldCenter.copy(this.boundingBox.position); - this.m_localTangentSpace = localTangentSpace !== undefined ? localTangentSpace : false; + this.m_localTangentSpace = localTangentSpace ?? false; this.m_textStyleCache = new TileTextStyleCache(this); this.m_offset = offset; this.m_uniqueKey = TileOffsetUtils.getKeyForTileKeyAndOffset(this.tileKey, this.offset); @@ -346,7 +367,15 @@ export class Tile implements CachedResource { // This happens in order to prevent that, during VisibleTileSet visibility evaluation, // visible tiles that haven't yet been evaluated for the current frame are preemptively // removed from [[DataSourceCache]]. - return this.frameNumLastRequested >= this.dataSource.mapView.frameNumber - 1; + // There is cases when a tile was already removed from the MapView, i.e. the PolaCaps + // Datasource might get remove on a change of projection, in this case + // this.dataSource.mapView will throw an error + try { + return this.frameNumLastRequested >= this.dataSource.mapView.frameNumber - 1; + } catch (error) { + logger.debug(error); + return false; + } } set isVisible(visible: boolean) { @@ -369,6 +398,8 @@ export class Tile implements CachedResource { /** * Whether the data of this tile is in local tangent space or not. + * + * @remarks * If the data is in local tangent space (i.e. up vector is (0,0,1) for high zoomlevels) then * {@link MapView} will rotate the objects before rendering using the rotation matrix of the * oriented [[boundingBox]]. @@ -396,8 +427,10 @@ export class Tile implements CachedResource { /** * Gets the key to uniquely represent this tile (based on - * the {@link tileKey} and {@link offset}), note - * this key is only unique within the given {@link DataSource}, + * the {@link tileKey} and {@link offset}). + * + * @remarks + * This key is only unique within the given {@link DataSource}, * to get a key which is unique across * {@link DataSource}s see [[DataSourceCache.getKeyForTile]]. */ @@ -426,7 +459,10 @@ export class Tile implements CachedResource { } /** - * Compute {@link TileResourceInfo} of this `Tile`. May be using a cached value. The method + * Compute {@link TileResourceInfo} of this `Tile`. + * + * @remarks + * May be using a cached value. The method * `invalidateResourceInfo` can be called beforehand to force a recalculation. * * @returns `TileResourceInfo` for this `Tile`. @@ -439,7 +475,10 @@ export class Tile implements CachedResource { } /** - * Force invalidation of the cached {@link TileResourceInfo}. Useful after the `Tile` has been + * Force invalidation of the cached {@link TileResourceInfo}. + * + * @remarks + * Useful after the `Tile` has been * modified. */ invalidateResourceInfo(): void { @@ -447,8 +486,10 @@ export class Tile implements CachedResource { } /** - * Add ownership of a texture to this tile. The texture will be disposed if the `Tile` is - * disposed. + * Add ownership of a texture to this tile. + * + * @remarks + * The texture will be disposed if the `Tile` is disposed. * @param texture - Texture to be owned by the `Tile` */ addOwnedTexture(texture: THREE.Texture): void { @@ -459,8 +500,10 @@ export class Tile implements CachedResource { * @internal * @deprecated User text elements are deprecated. * - * Gets the list of developer-defined {@link TextElement} in this `Tile`. This list is always - * rendered first. + * Gets the list of developer-defined {@link TextElement} in this `Tile`. + * + * @remarks + * This list is always rendered first. */ get userTextElements(): TextElementGroup { let group = this.m_textElementGroups.groups.get(TextElement.HIGHEST_PRIORITY); @@ -490,7 +533,7 @@ export class Tile implements CachedResource { /** * Removes a developer-defined {@link TextElement} from this `Tile`. * - * @deprecated use [[removeTextElement]]. + * @deprecated use `removeTextElement`. * * @param textElement - A developer-defined TextElement to remove. * @returns `true` if the element has been removed successfully; `false` otherwise. @@ -502,8 +545,10 @@ export class Tile implements CachedResource { /** * Adds a {@link TextElement} to this `Tile`, which is added to the visible set of - * {@link TextElement}s based on the capacity and visibility. The {@link TextElement}'s priority - * controls if or when it becomes visible. + * {@link TextElement}s based on the capacity and visibility. + * + * @remarks + * The {@link TextElement}'s priority controls if or when it becomes visible. * * To ensure that a TextElement is visible, use a high value for its priority, such as * `TextElement.HIGHEST_PRIORITY`. Since the number of visible TextElements is limited by the @@ -524,7 +569,10 @@ export class Tile implements CachedResource { } /** - * Adds a [[PathBlockingElement]] to this `Tile`. This path has the highest priority and blocks + * Adds a `PathBlockingElement` to this `Tile`. + * + * @remarks + * This path has the highest priority and blocks * all other labels. There maybe in future a use case to give it a priority, but as that isn't * yet required, it is left to be implemented later if required. * @param blockingElement - Element which should block all other labels. @@ -561,7 +609,7 @@ export class Tile implements CachedResource { /** * @internal * - * Gets the current [[GroupedPriorityList]] which + * Gets the current `GroupedPriorityList` which * contains a list of all {@link TextElement}s to be * selected and placed for rendering. */ @@ -571,8 +619,10 @@ export class Tile implements CachedResource { /** * Gets the current modification state for the list - * of {@link TextElement}s in the `Tile`. If the - * value is `true` the TextElement is placed for + * of {@link TextElement}s in the `Tile`. + * + * @remarks + * If the value is `true` the `TextElement` is placed for * rendering during the next frame. */ get textElementsChanged(): boolean { @@ -600,12 +650,13 @@ export class Tile implements CachedResource { /** * Called before {@link MapView} starts rendering this `Tile`. * + * @remarks * @param zoomLevel - The current zoom level. * @returns Returns `true` if this `Tile` should be rendered. Influenced directly by the - * [[skipRendering]] property unless specifically overriden in deriving classes. + * `skipRendering` property unless specifically overriden in deriving classes. */ willRender(_zoomLevel: number): boolean { - return !this.skipRendering; + return !this.skipRendering && !this.delayRendering; } /** @@ -674,6 +725,8 @@ export class Tile implements CachedResource { /** * Applies the decoded tile to the tile. + * + * @remarks * If the geometry is empty, then the tile's forceHasGeometry flag is set. * Map is updated. * @param decodedTile - The decoded tile to set. @@ -859,19 +912,24 @@ export class Tile implements CachedResource { /** * Loads this `Tile` geometry. + * + * @returns Promise which can be used to wait for the loading to be finished. */ - load() { + async load(): Promise { const tileLoader = this.tileLoader; if (tileLoader === undefined) { - return; + return Promise.resolve(); } - tileLoader + return tileLoader .loadAndDecode() .then(tileLoaderState => { assert(tileLoaderState === TileLoaderState.Ready); const decodedTile = tileLoader.decodedTile; this.decodedTile = decodedTile; + decodedTile?.dependencies?.forEach(mortonCode => { + this.dependencies.push(TileKey.fromMortonCode(mortonCode)); + }); }) .catch(tileLoaderState => { if ( @@ -894,6 +952,7 @@ export class Tile implements CachedResource { /** * Frees the rendering resources allocated by this `Tile`. * + * @remarks * The default implementation of this method frees the geometries and the materials for all the * reachable objects. * Textures are freed if they are owned by this `Tile` (i.e. if they where created by this @@ -970,8 +1029,10 @@ export class Tile implements CachedResource { } /** - * Adds a callback that will be called whenever the tile is disposed. Multiple callbacks may be - * added. + * Adds a callback that will be called whenever the tile is disposed. + * + * @remarks + * Multiple callbacks may be added. * @internal * @param callback - The callback to be called when the tile is disposed. */ @@ -1005,7 +1066,8 @@ export class Tile implements CachedResource { /** * Computes the offset in the x world coordinates corresponding to this tile, based on - * its [[offset]]. + * its {@link offset}. + * * @returns The x offset. */ computeWorldOffsetX(): number { @@ -1033,8 +1095,8 @@ export class Tile implements CachedResource { /** * Updates the tile's world bounding box. - * @param [newBoundingBox] The new bounding box to set. If undefined, the bounding box will be - * computed by projecting the tile's geoBox. + * @param newBoundingBox - The new bounding box to set. If undefined, the bounding box will be + * computed by projecting the tile's geoBox. */ private updateBoundingBox(newBoundingBox?: OrientedBox3) { if (newBoundingBox) { diff --git a/@here/harp-mapview/lib/Utils.ts b/@here/harp-mapview/lib/Utils.ts index 711b6972ed..58293ba6de 100644 --- a/@here/harp-mapview/lib/Utils.ts +++ b/@here/harp-mapview/lib/Utils.ts @@ -11,6 +11,7 @@ import { GeoCoordinates, GeoCoordLike, MathUtils, + OrientedBox3, Projection, ProjectionType, TileKey @@ -58,8 +59,11 @@ const tangentSpace = { z: new THREE.Vector3() }; const cache = { + box3: [new THREE.Box3()], + obox3: [new OrientedBox3()], quaternions: [new THREE.Quaternion(), new THREE.Quaternion()], - vector3: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()], + vector2: [new THREE.Vector2()], + vector3: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()], matrix4: [new THREE.Matrix4(), new THREE.Matrix4()], transforms: [ { @@ -137,6 +141,8 @@ export namespace MapViewUtils { * @param targetNDCy - Target y position in NDC space. * @param zoomLevel - The desired zoom level. * @param maxTiltAngle - The maximum tilt angle to comply by, in globe projection, in radian. + * @returns `false` if requested zoom cannot be achieved due to the map view's maximum bounds + * {@link MapView.geoMaxBounds},`true` otherwise. */ export function zoomOnTargetPosition( mapView: MapView, @@ -144,37 +150,50 @@ export namespace MapViewUtils { targetNDCy: number, zoomLevel: number, maxTiltAngle: number = MAX_TILT_RAD - ): void { - const { elevationProvider, camera } = mapView; + ): boolean { + const { elevationProvider, camera, projection } = mapView; // Use for now elevation at camera position. See getTargetAndDistance. const elevation = elevationProvider ? elevationProvider.getHeight( - mapView.projection.unprojectPoint(camera.position), + projection.unprojectPoint(camera.position), TERRAIN_ZOOM_LEVEL ) : undefined; // Get current target position in world space before we zoom. - const worldTarget = rayCastWorldCoordinates(mapView, targetNDCx, targetNDCy, elevation); - const groundDistance = calculateDistanceToGroundFromZoomLevel(mapView, zoomLevel); - const cameraHeight = groundDistance + (elevation ?? 0); - - // Set the cameras height according to the given zoom level. - if (mapView.projection.type === ProjectionType.Planar) { - camera.position.setZ(cameraHeight); - } else if (mapView.projection.type === ProjectionType.Spherical) { - camera.position.setLength(EarthConstants.EQUATORIAL_RADIUS + cameraHeight); + const zoomTarget = rayCastWorldCoordinates(mapView, targetNDCx, targetNDCy, elevation); + const cameraTarget = mapView.worldTarget; + const newCameraDistance = calculateDistanceFromZoomLevel(mapView, zoomLevel); + + if (mapView.geoMaxBounds) { + // If map view has maximum bounds set, constrain camera target and distance to ensure + // they remain within bounds. + const constrained = constrainTargetAndDistanceToViewBounds( + cameraTarget, + newCameraDistance, + mapView + ); + if (constrained.distance !== newCameraDistance) { + // Only indicate failure when zooming out. This avoids zoom in cancellations when + // camera is already at the maximum distance allowed by the view bounds. + return zoomLevel >= mapView.zoomLevel; + } } + // Set the camera distance according to the given zoom level. + camera + .getWorldDirection(camera.position) + .multiplyScalar(-newCameraDistance) + .add(cameraTarget); // In sphere, we may have to also orbit the camera around the position located at the // center of the screen, in order to limit the tilt to `maxTiltAngle`, as we change // this tilt by changing the camera's height above. - if (mapView.projection.type === ProjectionType.Spherical) { + if (projection.type === ProjectionType.Spherical) { // FIXME: We cannot use mapView.tilt here b/c it does not reflect the latest camera // changes. // tslint:disable-next-line: deprecation - const tilt = extractCameraTilt(camera, mapView.projection); + const tilt = extractCameraTilt(camera, projection); const deltaTilt = tilt - maxTiltAngle; if (deltaTilt > 0) { orbitFocusPoint(mapView, 0, deltaTilt, maxTiltAngle); @@ -182,19 +201,20 @@ export namespace MapViewUtils { } // Get new target position after the zoom - const newWorldTarget = rayCastWorldCoordinates(mapView, targetNDCx, targetNDCy, elevation); - if (!worldTarget || !newWorldTarget) { - return; + const newZoomTarget = rayCastWorldCoordinates(mapView, targetNDCx, targetNDCy, elevation); + if (!zoomTarget || !newZoomTarget) { + return true; } - if (mapView.projection.type === ProjectionType.Planar) { + if (projection.type === ProjectionType.Planar) { // Calculate the difference and pan the map to maintain the map relative to the target // position. - worldTarget.sub(newWorldTarget); - panCameraAboveFlatMap(mapView, worldTarget.x, worldTarget.y); - } else if (mapView.projection.type === ProjectionType.Spherical) { - panCameraAroundGlobe(mapView, worldTarget, newWorldTarget); + zoomTarget.sub(newZoomTarget); + panCameraAboveFlatMap(mapView, zoomTarget.x, zoomTarget.y); + } else if (projection.type === ProjectionType.Spherical) { + panCameraAroundGlobe(mapView, zoomTarget, newZoomTarget); } + return true; } /** @@ -296,6 +316,7 @@ export namespace MapViewUtils { /** * Returns the height of the camera above the earths surface. * + * @remarks * If there is an ElevationProvider, this is used. Otherwise the projection is used to determine * how high the camera is above the surface. * @@ -319,6 +340,145 @@ export namespace MapViewUtils { return Math.abs(projection.groundDistance(location)); } + /** + * Constrains given camera target and distance to {@link MapView.maxBounds}. + * + * @remarks + * The resulting + * target and distance will keep the view within the maximum bounds for a camera with tilt and + * yaw set to 0. + * @param target - The camera target. + * @param distance - The camera distance. + * @param mapView - The map view whose maximum bounds will be used as constraints. + * @returns constrained target and distance, or the unchanged input arguments if the view + * does not have maximum bounds set. + */ + export function constrainTargetAndDistanceToViewBounds( + target: THREE.Vector3, + distance: number, + mapView: MapView + ): { target: THREE.Vector3; distance: number } { + const unconstrained = { target, distance }; + const worldMaxBounds = mapView.worldMaxBounds; + const camera = mapView.camera; + const projection = mapView.projection; + + if (!worldMaxBounds) { + return unconstrained; + } + + /** + * Constraints are checked similarly for planar and sphere. The extents of a top down view + * (even if camera isn't top down) using the given camera distance are compared with those + * of the maximum bounds to compute a scale. There are two options: + * a) scale > 1. The view covers a larger area than the maximum bounds. The distance is + * is reduced to match the bounds extents and the target is set at the bounds center. + * b) scale <= 1. The view may fit within the bounds without changing the distance, only the + * target is moved to fit the whole view within the bounds. + **/ + + const boundsSize = worldMaxBounds.getSize(cache.vector3[1]); + const screenSize = mapView.renderer.getSize(cache.vector2[0]); + const viewHeight = calculateWorldSizeByFocalLength( + mapView.focalLength, + unconstrained.distance, + screenSize.height + ); + const viewWidth = viewHeight * camera.aspect; + const scale = Math.max(viewWidth / boundsSize.x, viewHeight / boundsSize.y); + const viewHalfSize = new THREE.Vector3(viewWidth / 2, viewHeight / 2, 0); + + const constrained = { + target: unconstrained.target.clone(), + distance: unconstrained.distance + }; + + if (projection.type === ProjectionType.Planar) { + if (scale > 1) { + constrained.distance /= scale; + camera + .getWorldDirection(camera.position) + .multiplyScalar(-constrained.distance) + .add(worldMaxBounds.getCenter(constrained.target)); + } else { + const targetBounds = cache.box3[0] + .copy(worldMaxBounds as THREE.Box3) + .expandByVector(viewHalfSize.multiplyScalar(-1)); + targetBounds + .clampPoint(unconstrained.target, constrained.target) + .setZ(unconstrained.target.z); + if (constrained.target.equals(unconstrained.target)) { + return unconstrained; + } + + camera.position.x += constrained.target.x - unconstrained.target.x; + camera.position.y += constrained.target.y - unconstrained.target.y; + } + return constrained; + } + + // Spherical projection + if (scale > 1) { + // Set target to center of max bounds but keeping same height as unconstrained target. + worldMaxBounds.getCenter(constrained.target); + constrained.target.setLength(unconstrained.target.length()); + constrained.distance /= scale; + } else { + // Compute the bounds where the target must be to ensure a top down view remains within + // the maximum bounds. + const targetMaxBounds = cache.obox3[0]; + targetMaxBounds.copy(worldMaxBounds as OrientedBox3); + targetMaxBounds.position.setLength(unconstrained.target.length()); + targetMaxBounds.extents.sub(viewHalfSize); + + // Project unconstrained target to local tangent plane at the max bounds center. + const rotMatrix = targetMaxBounds.getRotationMatrix(cache.matrix4[0]); + const localTarget = cache.vector3[1] + .copy(constrained.target) + .sub(targetMaxBounds.position) + .applyMatrix4(cache.matrix4[1].copy(rotMatrix).transpose()) + .setZ(0); + + // Clamp the projected target with the target bounds and check if it changes. + const constrainedLocalTarget = cache.vector3[2] + .copy(localTarget) + .clamp( + cache.vector3[3].copy(targetMaxBounds.extents).multiplyScalar(-1), + targetMaxBounds.extents + ); + if (constrainedLocalTarget.equals(localTarget)) { + return unconstrained; + } + + // Project the local constrained target back into the sphere. + constrained.target + .copy(constrainedLocalTarget) + .applyMatrix4(rotMatrix) + .add(targetMaxBounds.position); + const targetHeightSq = targetMaxBounds.position.lengthSq(); + const constTargetDistSq = constrained.target.distanceToSquared( + targetMaxBounds.position + ); + const constTargetDistToGround = + Math.sqrt(targetHeightSq) - Math.sqrt(targetHeightSq - constTargetDistSq); + constrained.target.addScaledVector(targetMaxBounds.zAxis, -constTargetDistToGround); + + // Set the constrained target to the same height as the unconstrained one. + constrained.target.setLength(unconstrained.target.length()); + } + + // Pan camera to constrained target and set constrained distance. + MapViewUtils.panCameraAroundGlobe( + mapView, + cache.vector3[1].copy(constrained.target), + cache.vector3[2].copy(unconstrained.target) + ); + camera + .getWorldDirection(camera.position) + .multiplyScalar(-constrained.distance) + .add(constrained.target); + return constrained; + } /** * @internal * @deprecated This method will be moved to MapView. @@ -454,6 +614,7 @@ export namespace MapViewUtils { * * Add offset to geo points for minimal view box in flat projection with tile wrapping. * + * @remarks * In flat projection, with wrap around enabled, we should detect clusters of points around that * wrap antimeridian. * @@ -510,6 +671,7 @@ export namespace MapViewUtils { * Given `cameraPos`, force all points that lie on non-visible sphere half to be "near" max * possible viewable circle from given camera position. * + * @remarks * Assumes that shpere projection with world center is in `(0, 0, 0)`. */ export function wrapWorldPointsToView(points: THREE.Vector3[], cameraPos: THREE.Vector3) { @@ -530,8 +692,8 @@ export namespace MapViewUtils { * @hidden * @internal * - * Return [[GeoPoints]] bounding {@link @here/harp-geoutils#GeoBox} - * applicable for [[getFitBoundsDistance]]. + * Return `GeoPoints` bounding {@link @here/harp-geoutils#GeoBox} + * applicable for {@link getFitBoundsDistance}. * * @returns {@link @here/harp-geoutils#GeoCoordinates} set that covers `box` */ @@ -1508,7 +1670,8 @@ export namespace MapViewUtils { estimateTextureSize(standardMaterial.envMap, objectSize, visitedObjects); } else if ( material instanceof THREE.LineBasicMaterial || - material instanceof THREE.LineDashedMaterial + material instanceof THREE.LineDashedMaterial || + material instanceof THREE.PointsMaterial ) { // Nothing to be done here } else { diff --git a/@here/harp-mapview/lib/VisibleTileSet.ts b/@here/harp-mapview/lib/VisibleTileSet.ts index 2fba4cc6ed..58bca4fac9 100644 --- a/@here/harp-mapview/lib/VisibleTileSet.ts +++ b/@here/harp-mapview/lib/VisibleTileSet.ts @@ -13,7 +13,7 @@ import { TilingScheme } from "@here/harp-geoutils"; import { LRUCache } from "@here/harp-lrucache"; -import { assert, MathUtils } from "@here/harp-utils"; +import { assert, MathUtils, TaskQueue } from "@here/harp-utils"; import * as THREE from "three"; import { BackgroundDataSource } from "./BackgroundDataSource"; import { ClipPlanesEvaluator } from "./ClipPlanesEvaluator"; @@ -21,6 +21,7 @@ import { DataSource } from "./DataSource"; import { ElevationRangeSource } from "./ElevationRangeSource"; import { FrustumIntersection, TileKeyEntry } from "./FrustumIntersection"; import { TileGeometryManager } from "./geometry/TileGeometryManager"; +import { TileTaskGroups } from "./MapView"; import { Tile } from "./Tile"; import { TileOffsetUtils } from "./Utils"; @@ -84,6 +85,15 @@ export interface VisibleTileSetOptions { * Number of levels to go down when searching for fallback tiles. */ quadTreeSearchDistanceDown: number; + + /** + * Maximal number of new tiles, that can be added to the scene per frame. + * if set to `0`the limit will be ignored and all available tiles be uploaded. + * @beta + * @internal + * @defaultValue 0 + */ + maxTilesPerFrame: number; } const MB_FACTOR = 1.0 / (1024.0 * 1024.0); @@ -379,11 +389,11 @@ export class VisibleTileSet { allVisibleTilesLoaded: boolean = false; private readonly m_cameraOverride = new THREE.PerspectiveCamera(); - private m_dataSourceCache: DataSourceCache; + private readonly m_dataSourceCache: DataSourceCache; private m_viewRange: ViewRanges = { near: 0.1, far: Infinity, minimum: 0.1, maximum: Infinity }; // Maps morton codes to a given Tile, used to find overlapping Tiles. We only need to have this // for a single TilingScheme, i.e. that of the BackgroundDataSource. - private m_coveringMap = new Map(); + private readonly m_coveringMap = new Map(); private m_resourceComputationType: ResourceComputationType = ResourceComputationType.EstimationInMb; @@ -391,8 +401,11 @@ export class VisibleTileSet { constructor( private readonly m_frustumIntersection: FrustumIntersection, private readonly m_tileGeometryManager: TileGeometryManager, - public options: VisibleTileSetOptions + public options: VisibleTileSetOptions, + private readonly m_taskQueue: TaskQueue ) { + this.options = options; + this.options.maxTilesPerFrame = Math.floor(this.options.maxTilesPerFrame ?? 0); this.m_resourceComputationType = options.resourceComputationType === undefined ? ResourceComputationType.EstimationInMb @@ -443,6 +456,28 @@ export class VisibleTileSet { this.options.maxVisibleDataSourceTiles = size; } + /** + * Gets the maximum number of tiles that can be added to the scene per frame + * @beta + * @internal + */ + get maxTilesPerFrame(): number { + return this.options.maxTilesPerFrame; + } + + /** + * Gets the maximum number of tiles that can be added to the scene per frame + * @beta + * @internal + * @param value + */ + set maxTilesPerFrame(value: number) { + if (value < 0) { + throw new Error("Invalid value, this will result in no tiles ever showing"); + } + this.options.maxTilesPerFrame = Math.floor(value); + } + /** * The way the cache usage is computed, either based on size in MB (mega bytes) or in number of * tiles. @@ -495,9 +530,11 @@ export class VisibleTileSet { storageLevel: number, zoomLevel: number, dataSources: DataSource[], + frameNumber: number, elevationRangeSource?: ElevationRangeSource ): { viewRanges: ViewRanges; viewRangesChanged: boolean } { let allVisibleTilesLoaded: boolean = true; + let newTilesPerFrame = 0; const visibleTileKeysResult = this.getVisibleTileKeysForDataSources( zoomLevel, @@ -536,8 +573,12 @@ export class VisibleTileSet { i++ ) { const tileEntry = visibleTileKeys[i]; - - const tile = this.getTile(dataSource, tileEntry.tileKey, tileEntry.offset); + const tile = this.getTile( + dataSource, + tileEntry.tileKey, + tileEntry.offset, + frameNumber + ); if (tile === undefined) { continue; } @@ -546,14 +587,27 @@ export class VisibleTileSet { if (!tile.allGeometryLoaded) { numTilesLoading++; } else { - tile.numFramesVisible++; // If this tile's data source is "covering" then other tiles beneath it have // their rendering skipped, see [[Tile.willRender]]. this.skipOverlappedTiles(dataSource, tile); - if (tile.frameNumVisible < 0) { - // Store the fist frame the tile became visible. - tile.frameNumVisible = dataSource.mapView.frameNumber; + if ( + // if set to 0, it will ignore the limit and upload all available + this.options.maxTilesPerFrame !== 0 && + newTilesPerFrame > this.options.maxTilesPerFrame && + //if the tile was already visible last frame dont delay it + !(tile.frameNumLastVisible === frameNumber - 1) + ) { + tile.delayRendering = true; + tile.mapView.update(); + } else { + if (tile.frameNumVisible < 0) { + // Store the fist frame the tile became visible. + tile.frameNumVisible = frameNumber; + newTilesPerFrame++; + } + tile.numFramesVisible++; + tile.delayRendering = false; } } // Update the visible area of the tile. This is used for those tiles that are @@ -562,10 +616,27 @@ export class VisibleTileSet { tile.elevationRange = tileEntry; actuallyVisibleTiles.push(tile); + + // Add any dependent tileKeys if not already visible. Consider to optimize with a + // Set if this proves to be a bottleneck (because of O(n^2) search). Given the fact + // that dependencies are rare and used for non tiled data, this shouldn't be a + // problem. + for (const tileKey of tile.dependencies) { + if ( + visibleTileKeys.find( + tileKeyEntry => + tileKeyEntry.tileKey.mortonCode() === tileKey.mortonCode() + ) === undefined + ) { + visibleTileKeys.push(new TileKeyEntry(tileKey, 0)); + } + } } + // creates geometry if not yet available this.m_tileGeometryManager.updateTiles(actuallyVisibleTiles); + // used to actually render the tiles or find alternatives for incomplete tiles this.dataSourceTileList.push({ dataSource, storageLevel, @@ -635,11 +706,17 @@ export class VisibleTileSet { * @param dataSource - The data source the tile belongs to. * @param tileKey - The key identifying the tile. * @param offset - Tile offset. + * @param frameNumber - Frame in which the tile was requested * @return The tile if it was found or created, undefined otherwise. */ - getTile(dataSource: DataSource, tileKey: TileKey, offset: number = 0): Tile | undefined { + getTile( + dataSource: DataSource, + tileKey: TileKey, + offset: number, + frameNumber: number + ): Tile | undefined { const cacheOnly = false; - return this.getTileImpl(dataSource, tileKey, offset, cacheOnly); + return this.getTileImpl(dataSource, tileKey, offset, cacheOnly, frameNumber); } /** @@ -648,12 +725,18 @@ export class VisibleTileSet { * @param dataSource - The data source the tile belongs to. * @param tileKey - The key identifying the tile. * @param offset - Tile offset. + * @param frameNumber - Frame in which the tile was requested * @return The tile if found in cache, undefined otherwise. */ - getCachedTile(dataSource: DataSource, tileKey: TileKey, offset: number = 0): Tile | undefined { + getCachedTile( + dataSource: DataSource, + tileKey: TileKey, + offset: number, + frameNumber: number + ): Tile | undefined { assert(dataSource.cacheable); const cacheOnly = true; - return this.getTileImpl(dataSource, tileKey, offset, cacheOnly); + return this.getTileImpl(dataSource, tileKey, offset, cacheOnly, frameNumber); } /** @@ -778,12 +861,17 @@ export class VisibleTileSet { * the {@link DataSource} with the given name. * * @param dataSourceName - The name of the {@link DataSource}. + * @param filter Optional tile filter */ - clearTileCache(dataSource?: DataSource) { + clearTileCache(dataSource?: DataSource, filter?: (tile: Tile) => boolean) { if (dataSource !== undefined) { this.m_dataSourceCache.evictSelected((tile: Tile, _) => { - return tile.dataSource === dataSource; + return ( + tile.dataSource === dataSource && (filter !== undefined ? filter(tile) : true) + ); }); + } else if (filter !== undefined) { + this.m_dataSourceCache.evictSelected(filter); } else { this.m_dataSourceCache.evictAll(); } @@ -929,7 +1017,7 @@ export class VisibleTileSet { // ("incompleteTiles"). renderListEntry.visibleTiles.forEach(tile => { tile.levelOffset = 0; - if (tile.hasGeometry) { + if (tile.hasGeometry && !tile.delayRendering) { renderedTiles.set(tile.uniqueKey, tile); } else { // if dataSource supports cache and it was existing before this render @@ -1005,8 +1093,9 @@ export class VisibleTileSet { ); const nextLevelDiff = Math.abs(childTileKey.level - dataZoomLevel); - if (childTile !== undefined && childTile.hasGeometry) { - // childTile has geometry, so can be reused as fallback + if (childTile !== undefined && childTile.hasGeometry && !childTile.delayRendering) { + //childTile has geometry and was/can be uploaded to the GPU, + //so we can use it as fallback renderedTiles.set(childTileCode, childTile); childTile.levelOffset = nextLevelDiff; continue; @@ -1050,7 +1139,7 @@ export class VisibleTileSet { const parentTile = this.m_dataSourceCache.get(mortonCode, offset, dataSource); const parentTileKey = parentTile ? parentTile.tileKey : TileKey.fromMortonCode(mortonCode); const nextLevelDiff = Math.abs(dataZoomLevel - parentTileKey.level); - if (parentTile !== undefined && parentTile.hasGeometry) { + if (parentTile !== undefined && parentTile.hasGeometry && !parentTile.delayRendering) { checkedTiles.set(parentCode, true); // parentTile has geometry, so can be reused as fallback renderedTiles.set(parentCode, parentTile); @@ -1086,19 +1175,20 @@ export class VisibleTileSet { dataSource: DataSource, tileKey: TileKey, offset: number, - cacheOnly: boolean + cacheOnly: boolean, + frameNumber: number ): Tile | undefined { - function updateTile(tileToUpdate?: Tile) { - if (tileToUpdate === undefined) { - return; - } + function touchTile(tileToUpdate: Tile) { // Keep the tile from being removed from the cache. - tileToUpdate.frameNumLastRequested = dataSource.mapView.frameNumber; + tileToUpdate.frameNumLastRequested = frameNumber; } if (!dataSource.cacheable && !cacheOnly) { - const resultTile = dataSource.getTile(tileKey); - updateTile(resultTile); + const resultTile = dataSource.getTile(tileKey, true); + if (resultTile !== undefined) { + this.addToTaskQueue(resultTile); + touchTile(resultTile); + } return resultTile; } @@ -1106,7 +1196,7 @@ export class VisibleTileSet { let tile = tileCache.get(tileKey.mortonCode(), offset, dataSource); if (tile !== undefined && tile.offset === offset) { - updateTile(tile); + touchTile(tile); return tile; } @@ -1114,17 +1204,34 @@ export class VisibleTileSet { return undefined; } - tile = dataSource.getTile(tileKey); + tile = dataSource.getTile(tileKey, true); // TODO: Update all tile information including area, min/max elevation from TileKeyEntry if (tile !== undefined) { + this.addToTaskQueue(tile); tile.offset = offset; - updateTile(tile); + touchTile(tile); tileCache.set(tileKey.mortonCode(), offset, dataSource, tile); this.m_tileGeometryManager.initTile(tile); } return tile; } + private addToTaskQueue(tile: Tile) { + this.m_taskQueue.add({ + execute: tile.load.bind(tile), + group: TileTaskGroups.FETCH_AND_DECODE, + getPriority: () => { + return tile?.tileLoader?.priority || 0; + }, + isExpired: () => { + return !tile?.isVisible; + }, + estimatedProcessTime: () => { + return 1; + } + }); + } + private markDataSourceTilesDirty(renderListEntry: DataSourceTileList) { const dataSourceCache = this.m_dataSourceCache; const retainedTiles: Set = new Set(); diff --git a/@here/harp-mapview/lib/WorkerBasedDecoder.ts b/@here/harp-mapview/lib/WorkerBasedDecoder.ts index a2681f792b..eb97726611 100644 --- a/@here/harp-mapview/lib/WorkerBasedDecoder.ts +++ b/@here/harp-mapview/lib/WorkerBasedDecoder.ts @@ -34,7 +34,7 @@ let nextUniqueServiceId = 0; * - configuration. */ export class WorkerBasedDecoder implements ITileDecoder { - private serviceId: string; + private readonly serviceId: string; private m_serviceCreated: boolean = false; /** diff --git a/@here/harp-mapview/lib/WorkerBasedTiler.ts b/@here/harp-mapview/lib/WorkerBasedTiler.ts index 9eaf27dedc..44ae40949b 100644 --- a/@here/harp-mapview/lib/WorkerBasedTiler.ts +++ b/@here/harp-mapview/lib/WorkerBasedTiler.ts @@ -28,7 +28,7 @@ let nextUniqueServiceId = 0; * - configuration. */ export class WorkerBasedTiler implements ITiler { - private serviceId: string; + private readonly serviceId: string; private m_serviceCreated: boolean = false; /** diff --git a/@here/harp-mapview/lib/composing/MapRenderingManager.ts b/@here/harp-mapview/lib/composing/MapRenderingManager.ts index 3cdfe233f0..a694a70cd7 100644 --- a/@here/harp-mapview/lib/composing/MapRenderingManager.ts +++ b/@here/harp-mapview/lib/composing/MapRenderingManager.ts @@ -176,13 +176,13 @@ export class MapRenderingManager implements IMapRenderingManager { private m_outlineEffect?: OutlineEffect; private m_msaaPass: MSAARenderPass; - private m_renderPass: RenderPass = new RenderPass(); - private m_target1: THREE.WebGLRenderTarget = new THREE.WebGLRenderTarget(1, 1); - private m_target2: THREE.WebGLRenderTarget = new THREE.WebGLRenderTarget(1, 1); + private readonly m_renderPass: RenderPass = new RenderPass(); + private readonly m_target1: THREE.WebGLRenderTarget = new THREE.WebGLRenderTarget(1, 1); + private readonly m_target2: THREE.WebGLRenderTarget = new THREE.WebGLRenderTarget(1, 1); private m_bloomPass?: BloomPass; private m_sepiaPass: ShaderPass = new ShaderPass(SepiaShader); private m_vignettePass: ShaderPass = new ShaderPass(VignetteShader); - private m_readBuffer: THREE.WebGLRenderTarget; + private readonly m_readBuffer: THREE.WebGLRenderTarget; private m_dynamicMsaaSamplingLevel: MSAASampling; private m_staticMsaaSamplingLevel: MSAASampling; private m_lowResPass: LowResRenderPass; diff --git a/@here/harp-mapview/lib/composing/Outline.ts b/@here/harp-mapview/lib/composing/Outline.ts index d2ca18b0c9..739243031c 100644 --- a/@here/harp-mapview/lib/composing/Outline.ts +++ b/@here/harp-mapview/lib/composing/Outline.ts @@ -114,17 +114,17 @@ export class OutlineEffect { shadowMap: THREE.WebGLShadowMap; private m_defaultThickness: number = 0.02; - private m_defaultColor: THREE.Color = new THREE.Color(0, 0, 0); - private m_defaultAlpha: number = 1; - private m_defaultKeepAlive: boolean = false; + private readonly m_defaultColor: THREE.Color = new THREE.Color(0, 0, 0); + private readonly m_defaultAlpha: number = 1; + private readonly m_defaultKeepAlive: boolean = false; private m_ghostExtrudedPolygons: boolean = false; private m_cache: any = {}; - private m_removeThresholdCount: number = 60; + private readonly m_removeThresholdCount: number = 60; private m_originalMaterials: any = {}; private m_originalOnBeforeRenders: any = {}; - private m_shaderIDs: { [key: string]: string } = { + private readonly m_shaderIDs: { [key: string]: string } = { MeshBasicMaterial: "basic", MeshLambertMaterial: "lambert", MeshPhongMaterial: "phong", @@ -132,7 +132,7 @@ export class OutlineEffect { MeshStandardMaterial: "physical", MeshPhysicalMaterial: "physical" }; - private m_uniformsChunk = { + private readonly m_uniformsChunk = { outlineThickness: { value: this.m_defaultThickness }, outlineColor: { value: this.m_defaultColor }, outlineAlpha: { value: this.m_defaultAlpha } diff --git a/@here/harp-mapview/lib/composing/Pass.ts b/@here/harp-mapview/lib/composing/Pass.ts index b01d3eddce..d6d70b4eca 100644 --- a/@here/harp-mapview/lib/composing/Pass.ts +++ b/@here/harp-mapview/lib/composing/Pass.ts @@ -122,7 +122,7 @@ export class ShaderPass extends Pass { uniforms: { [uniform: string]: THREE.IUniform }; material: THREE.Material; fsQuad: FullScreenQuad; - constructor(shader: THREE.Shader, private textureID: string = "tDiffuse") { + constructor(shader: THREE.Shader, private readonly textureID: string = "tDiffuse") { super(); if (shader instanceof THREE.ShaderMaterial) { this.uniforms = shader.uniforms; @@ -158,7 +158,7 @@ export class ShaderPass extends Pass { class FullScreenQuad { private m_mesh: THREE.Mesh; - private m_camera: THREE.Camera; + private readonly m_camera: THREE.Camera; constructor(material: THREE.Material) { this.m_camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const geometry = new THREE.PlaneBufferGeometry(2, 2); diff --git a/@here/harp-mapview/lib/composing/UnrealBloomPass.ts b/@here/harp-mapview/lib/composing/UnrealBloomPass.ts index 90f0c0aa42..449c6a91a7 100644 --- a/@here/harp-mapview/lib/composing/UnrealBloomPass.ts +++ b/@here/harp-mapview/lib/composing/UnrealBloomPass.ts @@ -19,22 +19,29 @@ export class BloomPass extends Pass { radius: number; threshold: number; resolution: THREE.Vector2 = new THREE.Vector2(256, 256); - private m_renderTargetsHorizontal: THREE.WebGLRenderTarget[] = []; - private m_renderTargetsVertical: THREE.WebGLRenderTarget[] = []; - private m_nMips: number = 5; - private m_highPassUniforms: any; - private m_materialHighPassFilter: THREE.ShaderMaterial; - private m_separableBlurMaterials: THREE.ShaderMaterial[] = []; - private m_materialCopy: THREE.ShaderMaterial; - private m_copyUniforms: any; - private m_compositeMaterial: THREE.ShaderMaterial; - - private m_camera: THREE.OrthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - private m_scene: THREE.Scene = new THREE.Scene(); + private readonly m_renderTargetsHorizontal: THREE.WebGLRenderTarget[] = []; + private readonly m_renderTargetsVertical: THREE.WebGLRenderTarget[] = []; + private readonly m_nMips: number = 5; + private readonly m_highPassUniforms: any; + private readonly m_materialHighPassFilter: THREE.ShaderMaterial; + private readonly m_separableBlurMaterials: THREE.ShaderMaterial[] = []; + private readonly m_materialCopy: THREE.ShaderMaterial; + private readonly m_copyUniforms: any; + private readonly m_compositeMaterial: THREE.ShaderMaterial; + + private readonly m_camera: THREE.OrthographicCamera = new THREE.OrthographicCamera( + -1, + 1, + 1, + -1, + 0, + 1 + ); + private readonly m_scene: THREE.Scene = new THREE.Scene(); private m_basic = new THREE.MeshBasicMaterial(); private m_quad = new THREE.Mesh(new THREE.PlaneBufferGeometry(2, 2)); - private m_bloomTintColors: THREE.Vector3[] = [ + private readonly m_bloomTintColors: THREE.Vector3[] = [ new THREE.Vector3(1, 1, 1), new THREE.Vector3(1, 1, 1), new THREE.Vector3(1, 1, 1), @@ -42,7 +49,7 @@ export class BloomPass extends Pass { new THREE.Vector3(1, 1, 1) ]; - private m_renderTargetBright: THREE.WebGLRenderTarget; + private readonly m_renderTargetBright: THREE.WebGLRenderTarget; constructor(resolution: THREE.Vector2, strength: number, radius: number, threshold: number) { super(); diff --git a/@here/harp-mapview/lib/copyrights/CopyrightElementHandler.ts b/@here/harp-mapview/lib/copyrights/CopyrightElementHandler.ts index 815ae967a2..30f7d8d70e 100644 --- a/@here/harp-mapview/lib/copyrights/CopyrightElementHandler.ts +++ b/@here/harp-mapview/lib/copyrights/CopyrightElementHandler.ts @@ -39,7 +39,7 @@ export class CopyrightElementHandler { */ staticInfo: CopyrightInfo[] | undefined; - private m_defaults: Map = new Map(); + private readonly m_defaults: Map = new Map(); private m_element: HTMLElement; private m_mapViews: MapView[] = []; @@ -143,7 +143,7 @@ export class CopyrightElementHandler { update = () => { const mergedCopyrightInfo = this.m_mapViews .map(mapView => mapView.copyrightInfo) - .reduce(CopyrightInfo.mergeArrays, this.staticInfo || []); + .reduce(CopyrightInfo.mergeArrays, this.staticInfo ?? []); // Conditionally hiding of element with copyright information. // If nothing to show we schould to avoid empty white rectangle in right bottom corner. diff --git a/@here/harp-mapview/lib/copyrights/UrlCopyrightProvider.ts b/@here/harp-mapview/lib/copyrights/UrlCopyrightProvider.ts index 48126e4761..167f74fbcf 100644 --- a/@here/harp-mapview/lib/copyrights/UrlCopyrightProvider.ts +++ b/@here/harp-mapview/lib/copyrights/UrlCopyrightProvider.ts @@ -29,10 +29,10 @@ export class UrlCopyrightProvider extends CopyrightCoverageProvider { * @param m_requestHeaders - Optional request headers for requests(e.g. Authorization) */ constructor( - private m_fetchURL: string, - private m_baseScheme: string, + private readonly m_fetchURL: string, + private readonly m_baseScheme: string, private m_requestHeaders?: RequestHeaders, - private m_transferManager: ITransferManager = TransferManager.instance() + private readonly m_transferManager: ITransferManager = TransferManager.instance() ) { super(); } diff --git a/@here/harp-mapview/lib/geometry/DisplacedBufferAttribute.ts b/@here/harp-mapview/lib/geometry/DisplacedBufferAttribute.ts index 262598b9ad..a1b2fcc415 100644 --- a/@here/harp-mapview/lib/geometry/DisplacedBufferAttribute.ts +++ b/@here/harp-mapview/lib/geometry/DisplacedBufferAttribute.ts @@ -15,14 +15,14 @@ import { VertexCache } from "./VertexCache"; * specified displacement map. */ export class DisplacedBufferAttribute extends THREE.BufferAttribute { - private static MAX_CACHE_SIZE = 6; + private static readonly MAX_CACHE_SIZE = 6; private m_texture?: Float32Array; private m_textureWidth: number = 0; private m_textureHeight: number = 0; - private m_cache = new VertexCache(DisplacedBufferAttribute.MAX_CACHE_SIZE); + private readonly m_cache = new VertexCache(DisplacedBufferAttribute.MAX_CACHE_SIZE); private m_lastBufferIndex?: number; - private m_lastPos = new THREE.Vector3(); - private m_tmpNormal = new THREE.Vector3(); + private readonly m_lastPos = new THREE.Vector3(); + private readonly m_tmpNormal = new THREE.Vector3(); /** * Creates an instance of displaced buffer attribute. diff --git a/@here/harp-mapview/lib/geometry/DisplacedBufferGeometry.ts b/@here/harp-mapview/lib/geometry/DisplacedBufferGeometry.ts index 58b141a941..5458747456 100644 --- a/@here/harp-mapview/lib/geometry/DisplacedBufferGeometry.ts +++ b/@here/harp-mapview/lib/geometry/DisplacedBufferGeometry.ts @@ -49,8 +49,8 @@ export function displaceBox( * displacement map. */ export class DisplacedBufferGeometry extends THREE.BufferGeometry { - private m_displacedPositions: DisplacedBufferAttribute; - private m_originalBoundingBox = new THREE.Box3(); + private readonly m_displacedPositions: DisplacedBufferAttribute; + private readonly m_originalBoundingBox = new THREE.Box3(); /** * Creates an instance of displaced buffer geometry. diff --git a/@here/harp-mapview/lib/geometry/DisplacedMesh.ts b/@here/harp-mapview/lib/geometry/DisplacedMesh.ts index 46381b9c1e..40e98ed017 100644 --- a/@here/harp-mapview/lib/geometry/DisplacedMesh.ts +++ b/@here/harp-mapview/lib/geometry/DisplacedMesh.ts @@ -71,8 +71,8 @@ export class DisplacedMesh extends THREE.Mesh { constructor( geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[], - private m_getDisplacementRange: () => DisplacementRange, - private m_raycastStrategy?: ( + private readonly m_getDisplacementRange: () => DisplacementRange, + private readonly m_raycastStrategy?: ( mesh: THREE.Mesh, raycaster: THREE.Raycaster, intersects: THREE.Intersection[] diff --git a/@here/harp-mapview/lib/geometry/TileDataAccessor.ts b/@here/harp-mapview/lib/geometry/TileDataAccessor.ts index ca93d4238c..337ed2b0d8 100644 --- a/@here/harp-mapview/lib/geometry/TileDataAccessor.ts +++ b/@here/harp-mapview/lib/geometry/TileDataAccessor.ts @@ -111,10 +111,10 @@ export interface TileDataAccessorOptions { * the visitor itself. */ export class TileDataAccessor { - private m_wantsPoints = true; - private m_wantsLines = true; - private m_wantsAreas = true; - private m_wantsObject3D = true; + private readonly m_wantsPoints: boolean; + private readonly m_wantsLines: boolean; + private readonly m_wantsAreas: boolean; + private readonly m_wantsObject3D: boolean; /** * Constructs a `TileDataAccessor` instance. @@ -125,7 +125,7 @@ export class TileDataAccessor { */ constructor( public tile: Tile, - private visitor: ITileDataVisitor, + private readonly visitor: ITileDataVisitor, options: TileDataAccessorOptions ) { const wantsAll = options.wantsAll === true; diff --git a/@here/harp-mapview/lib/geometry/TileGeometry.ts b/@here/harp-mapview/lib/geometry/TileGeometry.ts index a87bd11c3c..2438e67194 100644 --- a/@here/harp-mapview/lib/geometry/TileGeometry.ts +++ b/@here/harp-mapview/lib/geometry/TileGeometry.ts @@ -58,6 +58,7 @@ export interface ILineAccessor { * Helper function to check if an accessor is of type `ILineAccessor`. * * @param arg - `true` if `arg` is `ILineAccessor`. + * @internal */ export function isLineAccessor(arg: any): arg is ILineAccessor { /** @@ -107,6 +108,7 @@ export interface IObject3dAccessor { * Helper function to check if an accessor is of type `IObject3dAccessor`. * * @param arg - `true` if `arg` is `IObject3dAccessor`. + * @internal */ export function isObject3dAccessor(arg: any): arg is IObject3dAccessor { return typeof arg.isObject3dAccessor === "function" && arg.isObject3dAccessor() === true; diff --git a/@here/harp-mapview/lib/geometry/TileGeometryCreator.ts b/@here/harp-mapview/lib/geometry/TileGeometryCreator.ts index ecb8440493..5382035d81 100644 --- a/@here/harp-mapview/lib/geometry/TileGeometryCreator.ts +++ b/@here/harp-mapview/lib/geometry/TileGeometryCreator.ts @@ -392,7 +392,7 @@ export class TileGeometryCreator { * @param tile - The {@link Tile} to add the object to. * @param object - The object to add to the root of the tile. * @param geometryKind - The kind of object. Can be used for filtering. - * @param custom - additional parameters for [[MapObjectAdapter]] + * @param mapAdapterParams - additional parameters for [[MapObjectAdapter]] */ registerTileObject( tile: Tile, @@ -625,8 +625,8 @@ export class TileGeometryCreator { textStyleCache.getRenderStyle(technique), textStyleCache.getLayoutStyle(technique), priority, - technique.xOffset || 0.0, - technique.yOffset || 0.0, + technique.xOffset ?? 0.0, + technique.yOffset ?? 0.0, featureId, technique.style, undefined, @@ -807,7 +807,7 @@ export class TileGeometryCreator { assert(!isHighPrecisionLineMaterial(material)); const lineMaterial = material as SolidLineMaterial; if ( - technique.clipping !== false && + technique.clipping === true && tile.projection.type === ProjectionType.Planar ) { tile.boundingBox.getSize(tmpVector3); @@ -931,10 +931,12 @@ export class TileGeometryCreator { if (renderDepthPrePass) { const depthPassMesh = createDepthPrePassMesh(object as THREE.Mesh); + this.addUserData(tile, srcGeometry, technique, depthPassMesh); // Set geometry kind for depth pass mesh so that it gets the displacement map // for elevation overlay. this.registerTileObject(tile, depthPassMesh, techniqueKind, { - technique + technique, + pickable: false }); objects.push(depthPassMesh); @@ -945,13 +947,23 @@ export class TileGeometryCreator { setDepthPrePassStencil(depthPassMesh, object as THREE.Mesh); } + // register all objects as pickable except solid lines with outlines, in that case + // it's enough to make outlines pickable. this.registerTileObject(tile, object, techniqueKind, { - technique + technique, + pickable: !hasSolidLinesOutlines }); objects.push(object); // Add the extruded building edges as a separate geometry. if (isBuilding) { + // When the source geometry is split in groups, we + // should create objects with an array of materials. + const hasEdgeFeatureGroups = + Expr.isExpr(technique.enabled) && + srcGeometry.edgeFeatureStarts && + srcGeometry.edgeFeatureStarts.length > 0; + const buildingTechnique = technique as ExtrudedPolygonTechnique; const edgeGeometry = new THREE.BufferGeometry(); edgeGeometry.setAttribute("position", bufferGeometry.getAttribute("position")); @@ -994,10 +1006,16 @@ export class TileGeometryCreator { colorMix: fadingParams.colorMix, fadeNear: fadingParams.lineFadeNear, fadeFar: fadingParams.lineFadeFar, - extrusionRatio: extrusionAnimationEnabled ? 0 : undefined + extrusionRatio: extrusionAnimationEnabled ? 0 : undefined, + vertexColors: bufferGeometry.getAttribute("color") ? true : false }; const edgeMaterial = new EdgeMaterial(materialParams); - const edgeObj = new THREE.LineSegments(edgeGeometry, edgeMaterial); + const edgeObj = new THREE.LineSegments( + edgeGeometry, + hasEdgeFeatureGroups ? [edgeMaterial] : edgeMaterial + ); + + this.addUserData(tile, srcGeometry, technique, edgeObj); // Set the correct render order. edgeObj.renderOrder = object.renderOrder + 0.1; @@ -1015,10 +1033,12 @@ export class TileGeometryCreator { } this.registerTileObject(tile, edgeObj, techniqueKind, { - technique + technique, + pickable: false }); MapMaterialAdapter.create(edgeMaterial, { color: buildingTechnique.lineColor, + objectColor: buildingTechnique.color, opacity: buildingTechnique.opacity }); objects.push(edgeObj); @@ -1035,6 +1055,11 @@ export class TileGeometryCreator { // Add the fill area edges as a separate geometry. if (isFillTechnique(technique) && attachment.info.edgeIndex) { + const hasEdgeFeatureGroups = + Expr.isExpr(technique.enabled) && + srcGeometry.edgeFeatureStarts && + srcGeometry.edgeFeatureStarts.length > 0; + const outlineGeometry = new THREE.BufferGeometry(); outlineGeometry.setAttribute( "position", @@ -1053,10 +1078,14 @@ export class TileGeometryCreator { color: fadingParams.color, colorMix: fadingParams.colorMix, fadeNear: fadingParams.lineFadeNear, - fadeFar: fadingParams.lineFadeFar + fadeFar: fadingParams.lineFadeFar, + vertexColors: bufferGeometry.getAttribute("color") ? true : false }; const outlineMaterial = new EdgeMaterial(materialParams); - const outlineObj = new THREE.LineSegments(outlineGeometry, outlineMaterial); + const outlineObj = new THREE.LineSegments( + outlineGeometry, + hasEdgeFeatureGroups ? [outlineMaterial] : outlineMaterial + ); outlineObj.renderOrder = object.renderOrder + 0.1; FadingFeature.addRenderHelper( @@ -1067,11 +1096,15 @@ export class TileGeometryCreator { false ); + this.addUserData(tile, srcGeometry, technique, outlineObj); + this.registerTileObject(tile, outlineObj, techniqueKind, { - technique + technique, + pickable: false }); MapMaterialAdapter.create(outlineMaterial, { color: fillTechnique.lineColor, + objectColor: fillTechnique.color, opacity: fillTechnique.opacity }); objects.push(outlineObj); @@ -1265,7 +1298,7 @@ export class TileGeometryCreator { THREE.MathUtils.degToRad(10), sourceProjection ); - const enableMixedLod = mapView.enableMixedLod || mapView.enableMixedLod === undefined; + const enableMixedLod = mapView.enableMixedLod ?? mapView.enableMixedLod === undefined; if (enableMixedLod) { // Use a [[LodMesh]] to adapt tesselation of tile depending on zoom level @@ -1315,7 +1348,7 @@ export class TileGeometryCreator { const mesh = this.createGroundPlane(tile, material, false, shadowsEnabled); mesh.receiveShadow = shadowsEnabled; mesh.renderOrder = renderOrder; - this.registerTileObject(tile, mesh, GeometryKind.Background); + this.registerTileObject(tile, mesh, GeometryKind.Background, { pickable: false }); tile.objects.push(mesh); } @@ -1517,9 +1550,12 @@ export class TileGeometryCreator { } else { // Set the feature data for picking with `MapView.intersectMapObjects()` except for // solid-line which uses tile-based picking. + const isOutline = + object.type === "LineSegments" && + (isExtrudedPolygonTechnique(technique) || isFillTechnique(technique)); const featureData: TileFeatureData = { geometryType: srcGeometry.type, - starts: srcGeometry.featureStarts, + starts: isOutline ? srcGeometry.edgeFeatureStarts : srcGeometry.featureStarts, objInfos: srcGeometry.objInfos }; object.userData.feature = featureData; diff --git a/@here/harp-mapview/lib/geometry/TileGeometryLoader.ts b/@here/harp-mapview/lib/geometry/TileGeometryLoader.ts index 978be08d83..b125abe74f 100644 --- a/@here/harp-mapview/lib/geometry/TileGeometryLoader.ts +++ b/@here/harp-mapview/lib/geometry/TileGeometryLoader.ts @@ -18,8 +18,9 @@ import { isTextTechnique, Technique } from "@here/harp-datasource-protocol"; -import { PerformanceTimer } from "@here/harp-utils"; +import { PerformanceTimer, TaskQueue } from "@here/harp-utils"; +import { TileTaskGroups } from "../MapView"; import { PerformanceStatistics } from "../Statistics"; import { Tile } from "../Tile"; import { TileGeometryCreator } from "./TileGeometryCreator"; @@ -102,9 +103,18 @@ export class TileGeometryLoader { private m_availableGeometryKinds: GeometryKindSet | undefined; private m_enabledKinds: GeometryKindSet | undefined; private m_disabledKinds: GeometryKindSet | undefined; - private m_timeout: any; + private m_priority: number = 0; - constructor(private m_tile: Tile) {} + constructor(private m_tile: Tile, private m_taskQueue: TaskQueue) {} + + set priority(value: number) { + this.m_priority = value; + } + + //This is not a getter as it need to be bound to this for the taskqueue + getPriority(): number { + return this.m_priority; + } /** * The {@link Tile} this `TileGeometryLoader` is managing. @@ -238,11 +248,6 @@ export class TileGeometryLoader { this.m_decodedTile = undefined; this.m_isFinished = false; - - if (this.m_timeout !== undefined) { - clearTimeout(this.m_timeout); - this.m_timeout = undefined; - } } private finish() { @@ -251,11 +256,6 @@ export class TileGeometryLoader { this.m_decodedTile = undefined; this.m_isFinished = true; - - if (this.m_timeout !== undefined) { - clearTimeout(this.m_timeout); - this.m_timeout = undefined; - } } /** @@ -266,6 +266,32 @@ export class TileGeometryLoader { enabledKinds: GeometryKindSet | undefined, disabledKinds: GeometryKindSet | undefined ) { + const decodedTile = this.m_decodedTile; + + // Just a sanity check that satisfies compiler check below. + if (decodedTile === undefined) { + this.finish(); + return; + } + + this.m_taskQueue.add({ + execute: this.prepare.bind(this, enabledKinds, disabledKinds), + group: TileTaskGroups.CREATE, + getPriority: this.getPriority.bind(this), + isExpired: this.discardNeedlessTile.bind(this, this.tile), + estimatedProcessTime: () => { + //TODO: this seems to be close in many cases, but take some measures to confirm + return (this.tile.decodedTile?.decodeTime || 30) / 6; + } + }); + } + + private prepare( + enabledKinds: GeometryKindSet | undefined, + disabledKinds: GeometryKindSet | undefined + ) { + // Reset timeout so it is untouched during processing. + //this.m_timeout = undefined; const tile = this.tile; const decodedTile = this.m_decodedTile; @@ -275,64 +301,59 @@ export class TileGeometryLoader { return; } - this.m_timeout = setTimeout(() => { - // Reset timeout so it is untouched during processing. - this.m_timeout = undefined; + if (this.discardNeedlessTile(tile)) { + return; + } + + const stats = PerformanceStatistics.instance; + let now = 0; + if (stats.enabled) { + now = PerformanceTimer.now(); + } - if (this.discardNeedlessTile(tile)) { - return; - } + const geometryCreator = TileGeometryCreator.instance; - const stats = PerformanceStatistics.instance; - let now = 0; - if (stats.enabled) { - now = PerformanceTimer.now(); - } + tile.clear(); + // Set up techniques which should be processed. + geometryCreator.initDecodedTile(decodedTile, enabledKinds, disabledKinds); + geometryCreator.createAllGeometries(tile, decodedTile); - const geometryCreator = TileGeometryCreator.instance; + if (stats.enabled) { + const geometryCreationTime = PerformanceTimer.now() - now; + const currentFrame = stats.currentFrame; - tile.clear(); - // Set up techniques which should be processed. - geometryCreator.initDecodedTile(decodedTile, enabledKinds, disabledKinds); - geometryCreator.createAllGeometries(tile, decodedTile); + // Account for the geometry creation in the current frame. + currentFrame.addValue("render.fullFrameTime", geometryCreationTime); + currentFrame.addValue("render.geometryCreationTime", geometryCreationTime); - if (stats.enabled) { - const geometryCreationTime = PerformanceTimer.now() - now; - const currentFrame = stats.currentFrame; - - // Account for the geometry creation in the current frame. - currentFrame.addValue("render.fullFrameTime", geometryCreationTime); - currentFrame.addValue("render.geometryCreationTime", geometryCreationTime); - - currentFrame.addValue("geometry.geometryCreationTime", geometryCreationTime); - currentFrame.addValue("geometryCount.numGeometries", decodedTile.geometries.length); - currentFrame.addValue("geometryCount.numTechniques", decodedTile.techniques.length); - currentFrame.addValue( - "geometryCount.numPoiGeometries", - decodedTile.poiGeometries !== undefined ? decodedTile.poiGeometries.length : 0 - ); - currentFrame.addValue( - "geometryCount.numTextGeometries", - decodedTile.textGeometries !== undefined ? decodedTile.textGeometries.length : 0 - ); - currentFrame.addValue( - "geometryCount.numTextPathGeometries", - decodedTile.textPathGeometries !== undefined - ? decodedTile.textPathGeometries.length - : 0 - ); - currentFrame.addValue( - "geometryCount.numPathGeometries", - decodedTile.pathGeometries !== undefined ? decodedTile.pathGeometries.length : 0 - ); - currentFrame.addMessage( - // tslint:disable-next-line: max-line-length - `Decoded tile: ${tile.dataSource.name} # lvl=${tile.tileKey.level} col=${tile.tileKey.column} row=${tile.tileKey.row}` - ); - } - this.finish(); - tile.dataSource.requestUpdate(); - }, 0); + currentFrame.addValue("geometry.geometryCreationTime", geometryCreationTime); + currentFrame.addValue("geometryCount.numGeometries", decodedTile.geometries.length); + currentFrame.addValue("geometryCount.numTechniques", decodedTile.techniques.length); + currentFrame.addValue( + "geometryCount.numPoiGeometries", + decodedTile.poiGeometries !== undefined ? decodedTile.poiGeometries.length : 0 + ); + currentFrame.addValue( + "geometryCount.numTextGeometries", + decodedTile.textGeometries !== undefined ? decodedTile.textGeometries.length : 0 + ); + currentFrame.addValue( + "geometryCount.numTextPathGeometries", + decodedTile.textPathGeometries !== undefined + ? decodedTile.textPathGeometries.length + : 0 + ); + currentFrame.addValue( + "geometryCount.numPathGeometries", + decodedTile.pathGeometries !== undefined ? decodedTile.pathGeometries.length : 0 + ); + currentFrame.addMessage( + // tslint:disable-next-line: max-line-length + `Decoded tile: ${tile.dataSource.name} # lvl=${tile.tileKey.level} col=${tile.tileKey.column} row=${tile.tileKey.row}` + ); + } + this.finish(); + tile.dataSource.requestUpdate(); } private discardNeedlessTile(tile: Tile): boolean { diff --git a/@here/harp-mapview/lib/geometry/TileGeometryManager.ts b/@here/harp-mapview/lib/geometry/TileGeometryManager.ts index a6329a2687..5b8e757151 100644 --- a/@here/harp-mapview/lib/geometry/TileGeometryManager.ts +++ b/@here/harp-mapview/lib/geometry/TileGeometryManager.ts @@ -84,7 +84,7 @@ export class TileGeometryManager { */ initTile(tile: Tile): void { if (tile.dataSource.useGeometryLoader) { - tile.tileGeometryLoader = new TileGeometryLoader(tile); + tile.tileGeometryLoader = new TileGeometryLoader(tile, this.mapView.taskQueue); } } @@ -92,8 +92,15 @@ export class TileGeometryManager { * Process the {@link Tile}s for rendering. May alter the content of the tile per frame. */ updateTiles(tiles: Tile[]): void { + let prio = 0; for (const tile of tiles) { const geometryLoader = tile.tileGeometryLoader; + if (geometryLoader) { + //this assumes the tiles are ordered by priority, this is currently done in + // the visible tile set with 0 as the highest priority + geometryLoader.priority = prio++; + } + if (geometryLoader !== undefined) { geometryLoader.update( this.enableFilterByKind ? this.enabledGeometryKinds : undefined, diff --git a/@here/harp-mapview/lib/image/Image.ts b/@here/harp-mapview/lib/image/Image.ts index 5ec64e02e2..d0d0b85dc2 100644 --- a/@here/harp-mapview/lib/image/Image.ts +++ b/@here/harp-mapview/lib/image/Image.ts @@ -16,6 +16,8 @@ export interface ImageItem { mipMaps?: ImageData[]; /** Turns to `true` when the data has finished loading. */ loaded: boolean; + /** Turns to `true` if the loading has been cancelled. */ + cancelled?: boolean; /** `loadingPromise` is only used during loading/generating the image. */ loadingPromise?: Promise; } diff --git a/@here/harp-mapview/lib/image/ImageCache.ts b/@here/harp-mapview/lib/image/ImageCache.ts index f846a51310..4c42673ec6 100644 --- a/@here/harp-mapview/lib/image/ImageCache.ts +++ b/@here/harp-mapview/lib/image/ImageCache.ts @@ -26,7 +26,7 @@ declare function createImageBitmap( ): Promise; /** - * Combines an {@link ImageItem} with a list of [[MapViews]] that reference it. + * Combines an {@link ImageItem} with a list of {@link MapView}s that reference it. */ class ImageCacheItem { /** @@ -49,8 +49,11 @@ class ImageCacheItem { } /** - * `ImageCache` is a singleton, so it can be used with multiple MapViews on a single page. This - * allows to have an image loaded only once for multiple views. THREE is doing something similar, + * `ImageCache` is a singleton, so it can be used with multiple MapViews on a single page. + * + * @remarks + * This allows to have an image loaded only once for multiple views. + * THREE is doing something similar, * but does not allow to share images that have been loaded from a canvas (which we may need to do * if we use SVG images for textures). * @@ -71,8 +74,10 @@ export class ImageCache { } /** - * Dispose the singleton object. Not normally implemented for singletons, but good for - * debugging. + * Dispose the singleton object. + * + * @remarks + * Not normally implemented for singletons, but good for debugging. */ static dispose(): void { ImageCache.m_instance = undefined; @@ -89,11 +94,7 @@ export class ImageCache { * @param url - URL of image. * @param imageData - Optional [ImageData]] containing the image content. */ - registerImage( - mapView: MapView, - url: string, - imageData: ImageData | ImageBitmap | undefined - ): ImageItem { + registerImage(mapView: MapView, url: string, imageData?: ImageData | ImageBitmap): ImageItem { let imageCacheItem = this.findImageCacheItem(url); if (imageCacheItem !== undefined) { if (mapView !== undefined && imageCacheItem.mapViews.indexOf(mapView) < 0) { @@ -141,6 +142,55 @@ export class ImageCache { return imageItem; } + /** + * Remove an image from the cache.. + * + * @param url - URL of the image. + * @returns `true` if image has been removed. + */ + removeImage(url: string): boolean { + const cacheItem = this.m_images.get(url); + if (cacheItem !== undefined) { + this.m_images.delete(url); + this.cancelLoading(cacheItem.imageItem); + return true; + } + return false; + } + + /** + * Remove an image from the cache. + * + * @param imageItem - Item identifying the image. + * @returns `true` if image has been removed. + */ + removeImageItem(imageItem: ImageItem): boolean { + if (this.m_images.has(imageItem.url) !== undefined) { + this.m_images.delete(imageItem.url); + this.cancelLoading(imageItem); + return true; + } + return false; + } + + /** + * Remove images from the cache using a filter function. + * + * @param itemFilter - Filter to identify images to remove. Should return `true` if item + * should be removed. + * @returns Number of images removed. + */ + removeImageItems(itemFilter: (item: ImageItem) => boolean): number { + const oldSize = this.m_images.size; + [...this.m_images.values()].filter((cacheItem: ImageCacheItem) => { + if (itemFilter(cacheItem.imageItem)) { + this.m_images.delete(cacheItem.imageItem.url); + this.cancelLoading(cacheItem.imageItem); + } + }); + return oldSize - this.m_images.size; + } + /** * Find {@link ImageItem} for the specified URL. * @@ -148,41 +198,51 @@ export class ImageCache { * @returns `ImageItem` for the URL if the URL is registered, `undefined` otherwise. */ findImage(url: string): ImageItem | undefined { - const imageItem = this.m_images.get(url); - if (imageItem !== undefined) { - return imageItem.imageItem; + const cacheItem = this.m_images.get(url); + if (cacheItem !== undefined) { + return cacheItem.imageItem; } return undefined; } /** - * Clear all {@link ImageItem}s belonging to a {@link MapView}. May remove cached items if no + * Clear all {@link ImageItem}s belonging to a {@link MapView}. + * + * @remarks + * May remove cached items if no * {@link MapView} are registered anymore. * * @param mapView - MapView to remove all {@link ImageItem}s from. + * @returns Number of images removed. */ - clear(mapView: MapView) { + clear(mapView: MapView): number { + const oldSize = this.m_images.size; const itemsToRemove: string[] = []; - this.m_images.forEach(imageItem => { - const mapViewIndex = imageItem.mapViews.indexOf(mapView); + this.m_images.forEach(cacheItem => { + const mapViewIndex = cacheItem.mapViews.indexOf(mapView); if (mapViewIndex >= 0) { - imageItem.mapViews.splice(mapViewIndex, 1); + cacheItem.mapViews.splice(mapViewIndex, 1); } - if (imageItem.mapViews.length === 0) { - itemsToRemove.push(imageItem.imageItem.url); + if (cacheItem.mapViews.length === 0) { + itemsToRemove.push(cacheItem.imageItem.url); + this.cancelLoading(cacheItem.imageItem); } }); for (const keyToDelete of itemsToRemove) { this.m_images.delete(keyToDelete); } + return oldSize - this.m_images.size; } /** * Clear all {@link ImageItem}s from all {@link MapView}s. */ clearAll() { + this.m_images.forEach(cacheItem => { + this.cancelLoading(cacheItem.imageItem); + }); this.m_images = new Map(); } @@ -194,8 +254,10 @@ export class ImageCache { } /** - * Load an {@link ImageItem}. If the loading process is already running, it returns the current - * promise. + * Load an {@link ImageItem}. + * + * @remarks + * If the loading process is already running, it returns the current promise. * * @param imageItem - `ImageItem` containing the URL to load image from. * @returns An {@link ImageItem} if the image has already been loaded, a promise otherwise. @@ -213,36 +275,56 @@ export class ImageCache { imageItem.loadingPromise = new Promise(resolve => { logger.debug(`Loading image: ${imageItem.url}`); - imageLoader.load( - imageItem.url, - image => { - logger.debug(`... finished loading image: ${imageItem.url}`); - this.renderImage(imageItem, image) - .then(() => { - imageItem.mipMaps = mipMapGenerator.generateTextureAtlasMipMap( - imageItem - ); - imageItem.loadingPromise = undefined; - resolve(imageItem); - }) - .catch(ex => { - logger.error(`... loading image failed: ${imageItem.url} : ${ex}`); + if (imageItem.cancelled === true) { + logger.debug(`Cancelled loading image: ${imageItem.url}`); + resolve(undefined); + } else { + imageLoader.load( + imageItem.url, + image => { + logger.debug(`... finished loading image: ${imageItem.url}`); + if (imageItem.cancelled === true) { + logger.debug(`Cancelled loading image: ${imageItem.url}`); resolve(undefined); - }); - }, - // Loading events no longer supported - undefined, - errorEvent => { - logger.error(`... loading image failed: ${imageItem.url} : ${errorEvent}`); - - imageItem.loadingPromise = undefined; - resolve(undefined); - } - ); + } + this.renderImage(imageItem, image) + .then(() => { + imageItem.mipMaps = mipMapGenerator.generateTextureAtlasMipMap( + imageItem + ); + imageItem.loadingPromise = undefined; + resolve(imageItem); + }) + .catch(ex => { + logger.error(`... loading image failed: ${imageItem.url} : ${ex}`); + resolve(undefined); + }); + }, + // Loading events no longer supported + undefined, + errorEvent => { + logger.error(`... loading image failed: ${imageItem.url} : ${errorEvent}`); + + imageItem.loadingPromise = undefined; + resolve(undefined); + } + ); + } }); return imageItem.loadingPromise; } + /** + * Apply a function to every `ImageItem` in the cache. + * + * @param func - Function to apply to every `ImageItem`. + */ + apply(func: (imageItem: ImageItem) => void) { + this.m_images.forEach(cacheItem => { + func(cacheItem.imageItem); + }); + } + /** * Find the cached {@link ImageItem} by URL. * @@ -254,16 +336,19 @@ export class ImageCache { /** * Render the `ImageItem` by using `createImageBitmap()` or by rendering the image into a - * [[HTMLCanvasElement]]. + * `HTMLCanvasElement`. * * @param imageItem - {@link ImageItem} to assign image data to. - * @param image - [[HTMLImageElement]] to + * @param image - `HTMLImageElement` */ private renderImage( imageItem: ImageItem, image: HTMLImageElement ): Promise { return new Promise((resolve, reject) => { + if (imageItem.cancelled) { + resolve(undefined); + } // use createImageBitmap if it is available. It should be available in webworkers as // well if (typeof createImageBitmap === "function") { @@ -274,6 +359,9 @@ export class ImageCache { logger.debug(`Creating bitmap image: ${imageItem.url}`); createImageBitmap(image, 0, 0, image.width, image.height, options) .then(imageBitmap => { + if (imageItem.cancelled) { + resolve(undefined); + } logger.debug(`... finished creating bitmap image: ${imageItem.url}`); imageItem.loadingPromise = undefined; @@ -339,4 +427,16 @@ export class ImageCache { } }); } + + /** + * Cancel loading an image. + * + * @param imageItem - Item to cancel loading. + */ + private cancelLoading(imageItem: ImageItem) { + if (imageItem.loadingPromise !== undefined) { + // Notify that we are cancelling. + imageItem.cancelled = true; + } + } } diff --git a/@here/harp-mapview/lib/image/MapViewImageCache.ts b/@here/harp-mapview/lib/image/MapViewImageCache.ts index c8659ecf2d..acf11eabd1 100644 --- a/@here/harp-mapview/lib/image/MapViewImageCache.ts +++ b/@here/harp-mapview/lib/image/MapViewImageCache.ts @@ -12,9 +12,9 @@ import { ImageCache } from "./ImageCache"; * Cache images wrapped into {@link ImageItem}s for a {@link MapView}. * * @remarks - * An image may have multiple names in - * a theme, the `MapViewImageCache` will take care of that. - * Registering multiple images with the + * An image may have multiple names in a theme, the `MapViewImageCache` maps different names to the + * same image URL, and allows to share the image by URL to different MapViews. + * Within a MapView instance, the (optional) name is unique, so registering multiple images with the * same name is invalid. * * The `MapViewImageCache` uses a global {@link ImageCache} to actually store (and generate) the @@ -34,30 +34,26 @@ export class MapViewImageCache { /** * Register an existing image by name. * + * Names are unique within a {@link MapView}. URLs are not unique, multiple images with + * different names can have the same URL. Still, URLs are are loaded only once. + * * @param name - Name of the image from {@link @here/harp-datasource-protocol#Theme}. * @param url - URL of image. * @param image - Optional [[ImageData]] of image. */ - registerImage( - name: string | undefined, - url: string, - image: ImageData | ImageBitmap | undefined - ): ImageItem { - if (name !== undefined) { - if (this.hasName(name)) { - throw new Error("duplicate name in cache"); - } - - const oldNames = this.m_url2Name.get(url); - if (oldNames !== undefined) { - if (oldNames.indexOf(name) < 0) { - oldNames.push(name); - } - } else { - this.m_url2Name.set(url, [name]); + registerImage(name: string, url: string, image?: ImageData | ImageBitmap): ImageItem { + if (this.hasName(name)) { + throw new Error("duplicate name in cache"); + } + const oldNames = this.m_url2Name.get(url); + if (oldNames !== undefined) { + if (oldNames.indexOf(name) < 0) { + oldNames.push(name); } - this.m_name2Url.set(name, url); + } else { + this.m_url2Name.set(url, [name]); } + this.m_name2Url.set(name, url); const imageItem = ImageCache.instance.findImage(url); if (imageItem === undefined) { @@ -70,6 +66,9 @@ export class MapViewImageCache { * Add an image and optionally start loading it. Once done, the [[ImageData]] or [[ImageBitmap]] * will be stored in the {@link ImageItem}. * + * Names are unique within a {@link MapView}. URLs are not unique, multiple images with + * different names can have the same URL. Still, URLs are are loaded only once. + * * @param name - Name of image from {@link @here/harp-datasource-protocol#Theme}. * @param url - URL of image. * @param startLoading - Optional. Pass `true` to start loading the image in the background. @@ -83,10 +82,56 @@ export class MapViewImageCache { if (startLoading === true) { return ImageCache.instance.loadImage(imageItem); } - return imageItem; } + /** + * Remove the image with this name from the cache. + * + * @param name - Name of the image. + * @returns `true` if item has been removed. + */ + removeImage(name: string): boolean { + return this.removeImageInternal(name); + } + + /** + * Remove images using the URL from the cache. + * + * @param url - URL of the image. + * @returns `true` if image has been removed. If multiple images are referring to the same + * image URL, they are all removed. + */ + removeImageByUrl(url: string): boolean { + const names = this.m_url2Name.get(url); + if (names !== undefined) { + for (const name of [...names]) { + this.removeImageInternal(name); + } + return true; + } + return false; + } + + /** + * Remove images from the cache. + * + * @param itemFilter - Filter to identify images to remove. Should return `true` if item + * should be removed. + * @returns Number of images removed. + */ + removeImages(itemFilter: (name: string, url: string) => boolean): number { + let numImagesRemoved = 0; + for (const [name, url] of [...this.m_name2Url]) { + if (itemFilter(name, url)) { + if (this.removeImage(name)) { + numImagesRemoved++; + } + } + } + return numImagesRemoved; + } + /** * Find {@link ImageItem} by its name. * @@ -124,22 +169,27 @@ export class MapViewImageCache { * @remarks * Also removes all {@link ImageItem}s that belong to this * {@link MapView} from the global {@link ImageCache}. + * @returns Number of images removed. */ - clear() { + clear(): number { + const oldSize = ImageCache.instance.size; ImageCache.instance.clear(this.mapView); this.m_name2Url = new Map(); this.m_url2Name = new Map(); + return oldSize; } /** - * Returns number of image names stored in the cache. + * Returns number of mappings from name to URL in the cache. Only items with a name can get + * mapped to URL. */ get numberOfNames(): number { return this.m_name2Url.size; } /** - * Returns number of image URLs in the cache. + * Returns number of mappings from URL to name in the cache. Only items with a name can get + * mapped from URL to name. */ get numberOfUrls(): number { return this.m_url2Name.size; @@ -155,7 +205,9 @@ export class MapViewImageCache { } /** - * Return `true` if an image with the given URL is known. + * Return `true` if an image with the given URL is known. Only items with a name can get + * mapped from URL to name. + * * @param url - URL of image. */ hasUrl(url: string): boolean { @@ -163,9 +215,34 @@ export class MapViewImageCache { } /** - * Return the names under which an image with the given URL is saved. + * Return the names under which an image with the given URL is saved. Only items with a name + * can get mapped from URL to name. */ findNames(url: string): string[] | undefined { return this.m_url2Name.get(url); } + + /** + * Remove the image with this name from the cache. + * + * @param name - Name of the image. + * @returns `true` if item has been removed. + */ + private removeImageInternal(name: string): boolean { + const url = this.m_name2Url.get(name); + if (url !== undefined) { + this.m_name2Url.delete(name); + const names = this.m_url2Name.get(url); + if (names !== undefined && names.length > 1) { + // There is another name sharing this URL. + this.m_url2Name.set(url, names.splice(names.indexOf(name), 1)); + } else { + // URL was used by this image only, remove the image. + this.m_url2Name.delete(url); + ImageCache.instance.removeImage(url); + } + return true; + } + return false; + } } diff --git a/@here/harp-mapview/lib/image/MipMapGenerator.ts b/@here/harp-mapview/lib/image/MipMapGenerator.ts index a6f7185ba9..9d9d8b73bf 100644 --- a/@here/harp-mapview/lib/image/MipMapGenerator.ts +++ b/@here/harp-mapview/lib/image/MipMapGenerator.ts @@ -28,10 +28,10 @@ export class MipMapGenerator { }; } - private m_paddingCanvas?: HTMLCanvasElement; - private m_paddingContext?: CanvasRenderingContext2D; - private m_resizeCanvas?: HTMLCanvasElement; - private m_resizeContext?: CanvasRenderingContext2D; + private readonly m_paddingCanvas?: HTMLCanvasElement; + private readonly m_paddingContext?: CanvasRenderingContext2D; + private readonly m_resizeCanvas?: HTMLCanvasElement; + private readonly m_resizeContext?: CanvasRenderingContext2D; constructor() { if (!isNode) { @@ -70,11 +70,14 @@ export class MipMapGenerator { let width = paddedWidth * 0.5; let height = paddedHeight * 0.5; - while (width >= 1 && height >= 1) { + // HARP-10765 WebGL complains if we don't generate down to a 1x1 texture (this was the case + // previously when height != width), and thus the final texture generated was 2x1 texture + // and not 1x1. + while (width >= 1 || height >= 1) { const mipMapLevel = mipMaps.length; const previousImage = mipMaps[mipMapLevel - 1]; // Resize previous mip map level - mipMaps.push(this.resizeImage(previousImage, width, height)); + mipMaps.push(this.resizeImage(previousImage, Math.max(width, 1), Math.max(height, 1))); width *= 0.5; height *= 0.5; } diff --git a/@here/harp-mapview/lib/poi/BoxBuffer.ts b/@here/harp-mapview/lib/poi/BoxBuffer.ts index 80ddd78f8f..03eff8b581 100644 --- a/@here/harp-mapview/lib/poi/BoxBuffer.ts +++ b/@here/harp-mapview/lib/poi/BoxBuffer.ts @@ -270,9 +270,10 @@ export class BoxBuffer { const { s0, t0, s1, t1 } = uvBox; const { x, y, w, h } = screenBox; - const r = Math.round(color.r * 255); - const g = Math.round(color.g * 255); - const b = Math.round(color.b * 255); + // Premultiply alpha into vertex colors + const r = Math.round(color.r * opacity * 255); + const g = Math.round(color.g * opacity * 255); + const b = Math.round(color.b * opacity * 255); const a = Math.round(opacity * 255); const positionAttribute = this.positionAttribute!; diff --git a/@here/harp-mapview/lib/poi/PoiManager.ts b/@here/harp-mapview/lib/poi/PoiManager.ts index 65089e55c1..8c76ac8064 100644 --- a/@here/harp-mapview/lib/poi/PoiManager.ts +++ b/@here/harp-mapview/lib/poi/PoiManager.ts @@ -54,8 +54,8 @@ interface ImageTextureDef { */ export class PoiManager { // Keep track of the missing POI table names, but only warn once. - private static m_missingPoiTableName: Map = new Map(); - private static m_missingPoiName: Map = new Map(); + private static readonly m_missingPoiTableName: Map = new Map(); + private static readonly m_missingPoiName: Map = new Map(); /** * Warn about a missing POI table name, but only once. @@ -100,8 +100,8 @@ export class PoiManager { } } - private m_imageTextures: Map = new Map(); - private m_poiShieldGroups: Map = new Map(); + private readonly m_imageTextures: Map = new Map(); + private readonly m_poiShieldGroups: Map = new Map(); /** * The constructor of the `PoiManager`. @@ -370,7 +370,7 @@ export class PoiManager { if (poiGeometry.stringCatalog !== undefined) { assert(poiGeometry.texts.length > 0); - text = poiGeometry.stringCatalog[poiGeometry.texts[0]] || ""; + text = poiGeometry.stringCatalog[poiGeometry.texts[0]] ?? ""; if (poiGeometry.objInfos !== undefined) { userData = poiGeometry.objInfos[0]; featureId = getFeatureId(userData); @@ -459,7 +459,7 @@ export class PoiManager { assert(poiGeometry.texts.length > i); let imageTextureName = techniqueTextureName; - const text: string = poiGeometry.stringCatalog[poiGeometry.texts[i]] || ""; + const text: string = poiGeometry.stringCatalog[poiGeometry.texts[i]] ?? ""; const userData = poiGeometry.objInfos !== undefined ? poiGeometry.objInfos[i] : undefined; const featureId = getFeatureId(userData); diff --git a/@here/harp-mapview/lib/poi/PoiRenderer.ts b/@here/harp-mapview/lib/poi/PoiRenderer.ts index b8a0131d54..a0e1ebd2ca 100644 --- a/@here/harp-mapview/lib/poi/PoiRenderer.ts +++ b/@here/harp-mapview/lib/poi/PoiRenderer.ts @@ -3,7 +3,6 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - import { Env, getPropertyValue, ImageTexture } from "@here/harp-datasource-protocol"; import { IconMaterial } from "@here/harp-materials"; import { MemoryUsage, TextCanvas } from "@here/harp-text-canvas"; @@ -11,6 +10,7 @@ import { assert, LoggerManager, Math2D } from "@here/harp-utils"; import * as THREE from "three"; import { ImageItem } from "../image/Image"; +import { MapViewImageCache } from "../image/MapViewImageCache"; import { MipMapGenerator } from "../image/MipMapGenerator"; import { MapView } from "../MapView"; import { ScreenCollisions } from "../ScreenCollisions"; @@ -393,13 +393,13 @@ export class PoiRenderer { return screenBox; } // keep track of the missing textures, but only warn once - private static m_missingTextureName: Map = new Map(); + private static readonly m_missingTextureName: Map = new Map(); // the render buffer containing all batches, one batch per texture/material. - private m_renderBuffer: PoiRenderBuffer; + private readonly m_renderBuffer: PoiRenderBuffer; // temporary variable to save allocations - private m_tempScreenBox = new Math2D.Box(); + private readonly m_tempScreenBox = new Math2D.Box(); /** * Create the `PoiRenderer` for the specified {@link MapView}. @@ -544,11 +544,21 @@ export class PoiRenderer { const imageDefinition = imageTexture.image; - let imageItem = this.mapView.imageCache.findImageByName(imageDefinition); + // Check user image cache first. + let imageItem = this.mapView.userImageCache.findImageByName(imageDefinition); + let imageCache: MapViewImageCache | undefined; if (imageItem === undefined) { - logger.error(`init: No imageItem found with name '${imageDefinition}'`); - poiInfo.isValid = false; - return; + // Then check default image cache. + imageItem = this.mapView.imageCache.findImageByName(imageDefinition); + if (imageItem === undefined) { + logger.error(`init: No imageItem found with name '${imageDefinition}'`); + poiInfo.isValid = false; + return; + } else { + imageCache = this.mapView.imageCache; + } + } else { + imageCache = this.mapView.userImageCache; } if (!imageItem.loaded) { @@ -557,7 +567,7 @@ export class PoiRenderer { return; } const imageUrl = imageItem.url; - const loading = this.mapView.imageCache.loadImage(imageItem); + const loading = imageCache.loadImage(imageItem); if (loading instanceof Promise) { loading .then(loadedImageItem => { diff --git a/@here/harp-mapview/lib/poi/PoiTableManager.ts b/@here/harp-mapview/lib/poi/PoiTableManager.ts index 52ebae70e7..0216b1bb3a 100644 --- a/@here/harp-mapview/lib/poi/PoiTableManager.ts +++ b/@here/harp-mapview/lib/poi/PoiTableManager.ts @@ -114,7 +114,10 @@ class PoiTableEntry implements PoiTableEntryDef { } /** - * The `PoiTable` stores individual information for each POI type. If a {@link TextElement} has a + * The `PoiTable` stores individual information for each POI type. + * + * @remarks + * If a {@link TextElement} has a * reference to a PoiTable (if TextElement.poiInfo.poiTableName is set), information for the * TextElement and its icon are read from the PoiTable. * diff --git a/@here/harp-mapview/lib/text/RenderState.ts b/@here/harp-mapview/lib/text/RenderState.ts index 8c1b8dbc88..866237c8a2 100644 --- a/@here/harp-mapview/lib/text/RenderState.ts +++ b/@here/harp-mapview/lib/text/RenderState.ts @@ -125,12 +125,22 @@ export class RenderState { * unchanged. * * @param time - Current time. + * @param disableFading - Optional flag to disable fading. */ - startFadeIn(time: number) { + startFadeIn(time: number, disableFading?: boolean) { if (this.m_state === FadingState.FadingIn || this.m_state === FadingState.FadedIn) { return; } + if (disableFading === true) { + this.value = 1; + this.opacity = 1; + this.m_state = FadingState.FadedIn; + this.startTime = time; + + return; + } + if (this.m_state === FadingState.FadingOut) { // The fadeout is not complete: compute the virtual fadingStartTime in the past, to get // a correct end time: diff --git a/@here/harp-mapview/lib/text/TextElement.ts b/@here/harp-mapview/lib/text/TextElement.ts index 47f97660f8..a535d3c71d 100644 --- a/@here/harp-mapview/lib/text/TextElement.ts +++ b/@here/harp-mapview/lib/text/TextElement.ts @@ -191,6 +191,7 @@ export interface PoiInfo { * Return 'true' if the POI has been successfully prepared for rendering. * * @param poiInfo - PoiInfo containing information for rendering the POI icon. + * @internal */ export function poiIsRenderable(poiInfo: PoiInfo): boolean { return poiInfo.poiRenderBatch !== undefined; diff --git a/@here/harp-mapview/lib/text/TextElementGroupState.ts b/@here/harp-mapview/lib/text/TextElementGroupState.ts index 54cc74de90..96b41c9407 100644 --- a/@here/harp-mapview/lib/text/TextElementGroupState.ts +++ b/@here/harp-mapview/lib/text/TextElementGroupState.ts @@ -21,7 +21,7 @@ export type TextElementFilter = (textElementState: TextElementState) => number | * they're being rendered. */ export class TextElementGroupState { - private m_textElementStates: TextElementState[]; + private readonly m_textElementStates: TextElementState[]; private m_visited: boolean = false; /** diff --git a/@here/harp-mapview/lib/text/TextElementsRenderer.ts b/@here/harp-mapview/lib/text/TextElementsRenderer.ts index 191957303c..eba9fc0258 100644 --- a/@here/harp-mapview/lib/text/TextElementsRenderer.ts +++ b/@here/harp-mapview/lib/text/TextElementsRenderer.ts @@ -28,7 +28,8 @@ import { TileKey } from "@here/harp-geoutils"; import { DataSource } from "../DataSource"; import { debugContext } from "../DebugContext"; import { overlayTextElement } from "../geometry/overlayOnElevation"; -import { PickObjectType, PickResult } from "../PickHandler"; +import { PickObjectType } from "../PickHandler"; +import { PickListener } from "../PickListener"; import { PoiManager } from "../poi/PoiManager"; import { PoiRenderer } from "../poi/PoiRenderer"; import { PoiRendererFactory } from "../poi/PoiRendererFactory"; @@ -85,6 +86,7 @@ enum Pass { /** * Default distance scale. Will be applied if distanceScale is not defined in the technique. * Defines the scale that will be applied to labeled icons (icon and text) in the distance. + * @internal */ export const DEFAULT_TEXT_DISTANCE_SCALE = 0.5; @@ -309,7 +311,7 @@ export class TextElementsRenderer { private readonly m_options: TextElementsRendererOptions; private readonly m_textStyleCache: TextStyleCache; - private m_textRenderers: TextCanvasRenderer[] = []; + private readonly m_textRenderers: TextCanvasRenderer[] = []; private m_overlayTextElements?: TextElement[]; @@ -317,9 +319,9 @@ export class TextElementsRenderer { private m_debugGlyphTextureCacheMesh?: THREE.Mesh; private m_debugGlyphTextureCacheWireMesh?: THREE.LineSegments; - private m_tmpVector = new THREE.Vector2(); - private m_tmpVector3 = new THREE.Vector3(); - private m_cameraLookAt = new THREE.Vector3(); + private readonly m_tmpVector = new THREE.Vector2(); + private readonly m_tmpVector3 = new THREE.Vector3(); + private readonly m_cameraLookAt = new THREE.Vector3(); private m_overloaded: boolean = false; private m_cacheInvalidated: boolean = false; private m_forceNewLabelsPass: boolean = false; @@ -347,16 +349,16 @@ export class TextElementsRenderer { * [[TextElementsRendererOptions]]. */ constructor( - private m_viewState: ViewState, - private m_viewCamera: THREE.Camera, - private m_viewUpdateCallback: ViewUpdateCallback, - private m_screenCollisions: ScreenCollisions, - private m_screenProjector: ScreenProjector, - private m_textCanvasFactory: TextCanvasFactory, - private m_poiManager: PoiManager, - private m_poiRendererFactory: PoiRendererFactory, - private m_fontCatalogLoader: FontCatalogLoader, - private m_theme: Theme, + private readonly m_viewState: ViewState, + private readonly m_viewCamera: THREE.Camera, + private readonly m_viewUpdateCallback: ViewUpdateCallback, + private readonly m_screenCollisions: ScreenCollisions, + private readonly m_screenProjector: ScreenProjector, + private readonly m_textCanvasFactory: TextCanvasFactory, + private readonly m_poiManager: PoiManager, + private readonly m_poiRendererFactory: PoiRendererFactory, + private readonly m_fontCatalogLoader: FontCatalogLoader, + private readonly m_theme: Theme, options: TextElementsRendererOptions ) { this.m_textStyleCache = new TextStyleCache(this.m_theme); @@ -543,41 +545,23 @@ export class TextElementsRenderer { * @param screenPosition - Screen coordinate of picking position. * @param pickResults - Array filled with pick results. */ - pickTextElements(screenPosition: THREE.Vector2, pickResults: PickResult[]) { + pickTextElements(screenPosition: THREE.Vector2, pickListener: PickListener) { const pickHandler = (pickData: any | undefined, pickObjectType: PickObjectType) => { - const textElement = pickData as TextElement; - - if (textElement === undefined) { + if (pickData === undefined) { return; } - - let isDuplicate = false; - - if (textElement.featureId !== undefined) { - isDuplicate = pickResults.some(pickResult => { - return ( - pickResult !== undefined && - pickObjectType === pickResult.type && - ((pickResult.featureId !== undefined && - pickResult.featureId === textElement.featureId) || - (pickResult.userData !== undefined && - pickResult.userData === textElement.userData)) - ); - }); - - if (!isDuplicate) { - const pickResult: TextPickResult = { - type: pickObjectType, - point: screenPosition, - distance: 0, - featureId: textElement.featureId, - userData: textElement.userData, - text: textElement.text - }; - - pickResults.push(pickResult); - } - } + const textElement = pickData as TextElement; + const pickResult: TextPickResult = { + type: pickObjectType, + point: screenPosition, + distance: 0, + renderOrder: textElement.renderOrder, + featureId: textElement.featureId, + userData: textElement.userData, + text: textElement.text + }; + + pickListener.addResult(pickResult); }; for (const textRenderer of this.m_textRenderers) { @@ -868,7 +852,7 @@ export class TextElementsRenderer { continue; } - const layer = textCanvas.getLayer(textElement.renderOrder || DEFAULT_TEXT_CANVAS_LAYER); + const layer = textCanvas.getLayer(textElement.renderOrder ?? DEFAULT_TEXT_CANVAS_LAYER); // Move onto the next TextElement if we cannot continue adding glyphs to this layer. if (layer !== undefined) { @@ -1444,7 +1428,7 @@ export class TextElementsRenderer { continue; } - const layer = textCanvas.getLayer(textElement.renderOrder || DEFAULT_TEXT_CANVAS_LAYER); + const layer = textCanvas.getLayer(textElement.renderOrder ?? DEFAULT_TEXT_CANVAS_LAYER); // Move onto the next TextElement if we cannot continue adding glyphs to this layer. if (layer !== undefined) { @@ -1684,7 +1668,7 @@ export class TextElementsRenderer { if (textNeedsDraw) { if (!textRejected) { - textRenderState!.startFadeIn(renderParams.time); + textRenderState!.startFadeIn(renderParams.time, this.m_options.disableFading); } renderParams.fadeAnimationRunning = renderParams.fadeAnimationRunning || textRenderState!.isFading(); @@ -1707,7 +1691,7 @@ export class TextElementsRenderer { if (iconRejected) { iconRenderState!.startFadeOut(renderParams.time); } else { - iconRenderState!.startFadeIn(renderParams.time); + iconRenderState!.startFadeIn(renderParams.time, this.m_options.disableFading); } renderParams.fadeAnimationRunning = @@ -1952,7 +1936,7 @@ export class TextElementsRenderer { return false; } - labelState.textRenderState!.startFadeIn(renderParams.time); + labelState.textRenderState!.startFadeIn(renderParams.time, this.m_options.disableFading); let opacity = pathLabel.renderStyle!.opacity; diff --git a/@here/harp-mapview/lib/text/TextStyleCache.ts b/@here/harp-mapview/lib/text/TextStyleCache.ts index 3a081cf3a8..503c09379d 100644 --- a/@here/harp-mapview/lib/text/TextStyleCache.ts +++ b/@here/harp-mapview/lib/text/TextStyleCache.ts @@ -79,7 +79,7 @@ export interface TextElementStyle { } export class TextStyleCache { - private m_textStyles: Map = new Map(); + private readonly m_textStyles: Map = new Map(); private m_defaultStyle: TextElementStyle = { name: DEFAULT_STYLE_NAME, fontCatalog: "", @@ -420,7 +420,7 @@ export class TextStyleCache { fontSize: { unit: FontUnit.Pixel, size: 32, - backgroundSize: style.backgroundSize || 8 + backgroundSize: style.backgroundSize ?? 8 }, fontStyle: style.fontStyle === "Regular" || diff --git a/@here/harp-mapview/lib/text/TileTextStyleCache.ts b/@here/harp-mapview/lib/text/TileTextStyleCache.ts index 8f585af1c2..770cec15fe 100644 --- a/@here/harp-mapview/lib/text/TileTextStyleCache.ts +++ b/@here/harp-mapview/lib/text/TileTextStyleCache.ts @@ -16,7 +16,7 @@ import { Tile } from "../Tile"; export class TileTextStyleCache { private textRenderStyles: TextRenderStyle[] = []; private textLayoutStyles: TextLayoutStyle[] = []; - private tile: Tile; + private readonly tile: Tile; constructor(tile: Tile) { this.tile = tile; diff --git a/@here/harp-mapview/test/DecodedTileHelpersTest.ts b/@here/harp-mapview/test/DecodedTileHelpersTest.ts index 7d72ef3f2d..a889996dbd 100644 --- a/@here/harp-mapview/test/DecodedTileHelpersTest.ts +++ b/@here/harp-mapview/test/DecodedTileHelpersTest.ts @@ -69,6 +69,31 @@ describe("DecodedTileHelpers", function() { assert.exists(material); }, /Unsupported color format/); }); + + it("disables depthTest for solid lines by default", function() { + const technique: SolidLineTechnique = { + name: "solid-line", + lineWidth: 10, + renderOrder: 0, + color: "#f0f7" + }; + const material = createMaterial({ technique, env })! as SolidLineMaterial; + assert.exists(material); + assert.isFalse(material.depthTest); + }); + + it("enables depthTest for solid lines if specified in the technique", function() { + const technique: SolidLineTechnique = { + name: "solid-line", + lineWidth: 10, + renderOrder: 0, + color: "#f0f7", + depthTest: true + }; + const material = createMaterial({ technique, env })! as SolidLineMaterial; + assert.exists(material); + assert.isTrue(material.depthTest); + }); }); it("#applyBaseColorToMaterial toggles opacity with material", function() { const material = new THREE.MeshBasicMaterial(); diff --git a/@here/harp-mapview/test/FakeOmvDataSource.ts b/@here/harp-mapview/test/FakeOmvDataSource.ts index e02fa55119..6b2387d7c7 100644 --- a/@here/harp-mapview/test/FakeOmvDataSource.ts +++ b/@here/harp-mapview/test/FakeOmvDataSource.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { DecodedTile } from "@here/harp-datasource-protocol"; import { mercatorProjection, Projection, @@ -12,8 +13,35 @@ import { webMercatorTilingScheme } from "@here/harp-geoutils"; import { DataSource, DataSourceOptions } from "../lib/DataSource"; -import { Tile } from "../lib/Tile"; +import { ITileLoader, Tile, TileLoaderState } from "../lib/Tile"; +export class FakeTileLoader implements ITileLoader { + state: TileLoaderState = TileLoaderState.Initialized; + payload?: ArrayBufferLike | {}; + priority: number = 1; + decodedTile?: DecodedTile = { + techniques: [], + geometries: [] + }; + + isFinished: boolean = false; + + loadAndDecode(): Promise { + return Promise.resolve(TileLoaderState.Ready); + } + + waitSettled(): Promise { + return Promise.resolve(TileLoaderState.Ready); + } + + updatePriority(area: number): void { + // Not covered with tests yet + } + + cancel(): void { + // Not covered with tests yet + } +} export class FakeOmvDataSource extends DataSource { constructor(options: DataSourceOptions) { super(options); @@ -32,6 +60,8 @@ export class FakeOmvDataSource extends DataSource { /** @override */ getTile(tileKey: TileKey): Tile { const tile = new Tile(this, tileKey); + tile.tileLoader = new FakeTileLoader(); + tile.load(); return tile; } /** @override */ diff --git a/@here/harp-mapview/test/ImageCacheTest.ts b/@here/harp-mapview/test/ImageCacheTest.ts index e5be339265..657e08b309 100644 --- a/@here/harp-mapview/test/ImageCacheTest.ts +++ b/@here/harp-mapview/test/ImageCacheTest.ts @@ -3,17 +3,17 @@ * Licensed under Apache 2.0, see full license in LICENSE * SPDX-License-Identifier: Apache-2.0 */ - -// tslint:disable:only-arrow-functions -// Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions - +import { getTestResourceUrl } from "@here/harp-test-utils"; import { assert } from "chai"; -import { getTestResourceUrl } from "@here/harp-test-utils"; +import { ImageItem } from "../lib/image/Image"; import { ImageCache } from "../lib/image/ImageCache"; import { MapViewImageCache } from "../lib/image/MapViewImageCache"; import { MapView } from "../lib/MapView"; +// tslint:disable:only-arrow-functions +// Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions + class ImageData { constructor(public width: number, public height: number) {} close() { @@ -25,6 +25,10 @@ describe("MapViewImageCache", function() { // tslint:disable-next-line:no-object-literal-type-assertion const mapView: MapView = {} as MapView; + beforeEach(function() { + ImageCache.instance.clearAll(); + }); + it("#empty", function() { const cache = new MapViewImageCache(mapView); assert.equal(cache.numberOfNames, 0); @@ -52,8 +56,25 @@ describe("MapViewImageCache", function() { assert.equal(imageData, testImage2!.imageData); }); + it("#addImage", function() { + const cache = new MapViewImageCache(mapView); + cache.clear(); + + const imageName = "headshot.png"; + const imageUrl = getTestResourceUrl("@here/harp-mapview", "test/resources/headshot.png"); + + const imageItem = cache.addImage(imageName, imageUrl, false); + assert.isDefined(imageItem); + assert.isFalse(imageItem instanceof Promise); + + const testImage = cache.findImageByName(imageName); + assert.exists(testImage); + assert.isUndefined(testImage!.imageData); + assert.isFalse(testImage!.loaded); + }); + if (typeof document !== "undefined") { - it("#addImage", async function() { + it("#addImage with load", async function() { const cache = new MapViewImageCache(mapView); cache.clear(); @@ -80,7 +101,73 @@ describe("MapViewImageCache", function() { assert.isTrue(loadedImageItem!.loaded); const image = loadedImageItem!.imageData!; assert.equal(image.width, 37); - assert.equal(image.height, 36); + assert.equal(image.height, 32); + } + }); + + it("#addImage (load cancelled)", async function() { + const cache = new MapViewImageCache(mapView); + cache.clear(); + + const imageName = "headshot.png"; + const imageUrl = getTestResourceUrl( + "@here/harp-mapview", + "test/resources/headshot.png" + ); + + const promise = cache.addImage(imageName, imageUrl, true); + + const testImage = cache.findImageByName(imageName); + assert.exists(testImage); + assert.isUndefined(testImage!.imageData); + assert.isFalse(testImage!.loaded); + + assert.isTrue(promise instanceof Promise); + + // removal leads to cancel + assert.isTrue(cache.removeImage(imageName), "remove failed"); + + if (promise instanceof Promise) { + const imageItem = cache.findImageByName(imageName); + assert.notExists(imageItem, "image is still in cache"); + assert.isTrue(testImage!.cancelled); + // result of promise ignored, it depends on timing of load and image generation: + await promise; + // only assured that cancelled is set to `true`, loaded may also be set to true if + // cancelled after/during image generation. + assert.isTrue(testImage!.cancelled); + } + }); + + it("#loadImage", async function() { + const cache = new MapViewImageCache(mapView); + cache.clear(); + + const imageName = "headshot.png"; + const imageUrl = getTestResourceUrl( + "@here/harp-mapview", + "test/resources/headshot.png" + ); + + const imageItem: ImageItem = cache.addImage(imageName, imageUrl, false) as ImageItem; + assert.isDefined(imageItem); + assert.isFalse(imageItem instanceof Promise); + + assert.isUndefined(imageItem.imageData); + assert.isFalse(imageItem!.loaded); + + const promise = cache.loadImage(imageItem); + assert.isTrue(promise instanceof Promise); + + if (promise instanceof Promise) { + await promise; + const loadedImageItem = cache.findImageByName(imageName); + assert.exists(loadedImageItem); + assert.isDefined(loadedImageItem!.imageData); + assert.isTrue(loadedImageItem!.loaded); + const image = loadedImageItem!.imageData!; + assert.equal(image.width, 37); + assert.equal(image.height, 32); } }); } @@ -91,12 +178,16 @@ describe("MapViewImageCache", function() { const imageData = new ImageData(16, 16); cache.registerImage("testImage", "httpx://naxos.de", imageData); - assert.equal(cache.numberOfNames, 1); + cache.registerImage("testImage2", "httpx://naxos.de", imageData); + assert.equal(cache.numberOfNames, 2); assert.equal(cache.numberOfUrls, 1); - cache.clear(); + const numImagesRemoved = cache.clear(); - assert.equal(cache.numberOfNames, 0); + assert.equal(ImageCache.instance.size, 0, "wrong cache size"); + assert.equal(cache.numberOfNames, 0, "wrong number of names"); + assert.equal(cache.numberOfUrls, 0, "wrong number of urls"); + assert.equal(numImagesRemoved, 1, "wrong number of removed images"); }); it("#add images", function() { @@ -161,38 +252,222 @@ describe("MapViewImageCache", function() { cache.registerImage("testImage", "httpx://naxos.de-2", undefined); }); }); + + it("#remove image", function() { + const cache = new MapViewImageCache(mapView); + + const imageData1 = new ImageData(16, 16); + + cache.registerImage("testImage1", "httpx://naxos.de", imageData1); + + assert.exists(cache.findImageByName("testImage1")); + + assert.equal(ImageCache.instance.size, 1); + assert.equal(cache.numberOfNames, 1, "wrong number of names"); + assert.equal(cache.numberOfUrls, 1, "wrong number of urls"); + + const imageRemoved = cache.removeImage("testImage1"); + assert.equal(imageRemoved, true); + assert.equal(cache.numberOfNames, 0, "wrong number of names"); + assert.equal(cache.numberOfUrls, 0, "wrong number of urls"); + }); + + it("#remove image 2", function() { + const cache = new MapViewImageCache(mapView); + + const imageData1 = new ImageData(16, 16); + + cache.registerImage("testImage1", "httpx://naxos.de", imageData1); + cache.registerImage("testImage2", "httpx://naxos.de", imageData1); + + assert.exists(cache.findImageByName("testImage1")); + assert.exists(cache.findImageByName("testImage2")); + + assert.equal(ImageCache.instance.size, 1, "wrong cache size"); + assert.equal(cache.numberOfNames, 2, "wrong number of names"); + assert.equal(cache.numberOfUrls, 1, "wrong number of urls"); + + const imageRemoved = cache.removeImage("testImage2"); + assert.equal(imageRemoved, true); + assert.equal(ImageCache.instance.size, 1, "wrong cache size"); + assert.equal(cache.numberOfNames, 1, "wrong number of names"); + assert.equal(cache.numberOfUrls, 1, "wrong number of urls"); + assert.exists(cache.findImageByName("testImage1")); + }); + + it("#remove image by URL", function() { + const cache = new MapViewImageCache(mapView); + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage("testImage1", "httpx://naxos.de", imageData1); + cache.registerImage("testImage2", "httpx://naxos.de-2", imageData2); + assert.equal(ImageCache.instance.size, 2); + assert.equal(cache.numberOfNames, 2); + assert.equal(cache.numberOfUrls, 2); + + const imageRemoved = cache.removeImageByUrl("httpx://naxos.de"!); + assert.equal(imageRemoved, true); + assert.equal(cache.numberOfNames, 1, "wrong number of names"); + assert.equal(cache.numberOfUrls, 1, "wrong number of urls"); + + assert.equal(ImageCache.instance.size, 1); + const testImage2 = cache.findImageByUrl("httpx://naxos.de-2"); + assert.exists(testImage2); + }); + + it("#remove image by name", function() { + const cache = new MapViewImageCache(mapView); + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage("testImage1", "httpx://naxos.de", imageData1); + cache.registerImage("testImage2", "httpx://naxos.de-2", imageData2); + assert.equal(ImageCache.instance.size, 2); + + const imageRemoved = cache.removeImage("testImage1"); + assert.equal(imageRemoved, true); + assert.equal(cache.numberOfNames, 1, "wrong number of names"); + assert.equal(cache.numberOfUrls, 1, "wrong number of urls"); + + assert.equal(ImageCache.instance.size, 1); + const testImage2 = cache.findImageByUrl("httpx://naxos.de-2"); + assert.exists(testImage2); + }); + + it("#remove image by filter", function() { + const cache = new MapViewImageCache(mapView); + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage("testImage1", "httpx://naxos.de", imageData1); + cache.registerImage("testImage2", "httpx://naxos.de-2", imageData2); + cache.registerImage("testImage3", "httpx://naxos.de-3", imageData2); + assert.equal(ImageCache.instance.size, 3); + + const imagesRemoved = cache.removeImages((name: string, url: string) => { + return url === "httpx://naxos.de-2"; + }); + assert.equal(imagesRemoved, 1); + assert.equal(cache.numberOfNames, 2, "wrong number of names"); + assert.equal(cache.numberOfUrls, 2, "wrong number of urls"); + + assert.equal(ImageCache.instance.size, 2); + assert.exists(cache.findImageByUrl("httpx://naxos.de")); + assert.exists(cache.findImageByUrl("httpx://naxos.de-3")); + assert.notExists(cache.findImageByUrl("httpx://naxos.de-2")); + }); + + it("#remove image by filter 2", function() { + const cache = new MapViewImageCache(mapView); + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage("testImage1", "httpx://naxos.de", imageData1); + cache.registerImage("testImage2", "httpx://naxos.de", imageData2); + cache.registerImage("testImage3", "httpx://naxos.de", imageData2); + assert.equal(ImageCache.instance.size, 1, "wrong cache size"); + assert.equal(cache.numberOfNames, 3, "wrong number of names"); + assert.equal(cache.numberOfUrls, 1, "wrong number of urls"); + + const imagesRemoved = cache.removeImages((name: string, url: string) => { + return name === "testImage3"; + }); + + assert.equal(imagesRemoved, 1, "wrong number of removed images"); + assert.equal(cache.numberOfNames, 2, "wrong number of names"); + assert.equal(cache.numberOfUrls, 1, "wrong number of urls"); + + assert.equal(ImageCache.instance.size, 1, "wrong cache size"); + assert.exists(cache.findImageByName("testImage1")); + assert.exists(cache.findImageByName("testImage2")); + assert.notExists(cache.findImageByName("testImage3")); + }); + + it("#remove image with name by filter", function() { + const cache = new MapViewImageCache(mapView); + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage("testImage1", "httpx://naxos.de", imageData1); + cache.registerImage("testImage2", "httpx://naxos.de-2", imageData2); + cache.registerImage("undefined", "httpx://naxos.de-3", imageData2); + assert.equal(cache.numberOfNames, 3, "wrong number of names"); + assert.equal(ImageCache.instance.size, 3, "wrong cache size"); + + const imagesRemoved = cache.removeImages((name: string, url: string) => { + return url === "httpx://naxos.de-2"; + }); + assert.equal(imagesRemoved, 1); + assert.equal(cache.numberOfNames, 2, "wrong number of names"); + assert.equal(cache.numberOfUrls, 2, "wrong number of urls"); + + assert.equal(ImageCache.instance.size, 2); + assert.exists(cache.findImageByUrl("httpx://naxos.de")); + assert.exists(cache.findImageByUrl("httpx://naxos.de-3")); + assert.notExists(cache.findImageByUrl("httpx://naxos.de-2")); + }); + + it("#remove all images by filter", function() { + const cache = new MapViewImageCache(mapView); + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage("testImage1", "httpx://naxos.de", imageData1); + cache.registerImage("testImage2", "httpx://naxos.de-2", imageData2); + cache.registerImage("testImage3", "httpx://naxos.de-3", imageData2); + + const imagesRemoved = cache.removeImages((name: string, url: string) => { + return true; + }); + assert.equal(imagesRemoved, 3); + assert.equal(cache.numberOfNames, 0, "wrong number of names"); + assert.equal(cache.numberOfUrls, 0); + + assert.equal(ImageCache.instance.size, 0, "cache is not empty"); + }); }); describe("ImageCache", function() { + beforeEach(function() { + ImageCache.instance.clearAll(); + }); + it("#instance", function() { - const instance = ImageCache.instance; + const cache = ImageCache.instance; const instance2 = ImageCache.instance; - assert.exists(instance); - assert.equal(instance, instance2); - instance.clearAll(); + assert.exists(cache); + assert.equal(cache, instance2); + cache.clearAll(); }); it("#empty", function() { - const instance = ImageCache.instance; - assert.equal(instance.size, 0); - const found = instance.findImage("xxx"); + const cache = ImageCache.instance; + assert.equal(cache.size, 0); + const found = cache.findImage("xxx"); assert.notExists(found); }); it("#registerImage", function() { // tslint:disable-next-line:no-object-literal-type-assertion const mapView: MapView = {} as MapView; - const instance = ImageCache.instance; - instance.clearAll(); + const cache = ImageCache.instance; + cache.clearAll(); const imageData = new ImageData(16, 16); - instance.registerImage(mapView, "httpx://naxos.de", imageData); + cache.registerImage(mapView, "httpx://naxos.de", imageData); - const testImage = instance.findImage("httpx://naxos.de"); + const testImage = cache.findImage("httpx://naxos.de"); - assert.equal(instance.size, 1); - assert.notExists(instance.findImage("xxx")); + assert.equal(cache.size, 1); + assert.notExists(cache.findImage("xxx")); assert.exists(testImage); assert.equal(imageData, testImage!.imageData); }); @@ -201,32 +476,66 @@ describe("ImageCache", function() { it("#addImage", async function() { // tslint:disable-next-line:no-object-literal-type-assertion const mapView: MapView = {} as MapView; - const instance = ImageCache.instance; - instance.clearAll(); + const cache = ImageCache.instance; + cache.clearAll(); + + const imageUrl = getTestResourceUrl( + "@here/harp-mapview", + "test/resources/headshot.png" + ); + + const promise = cache.addImage(mapView, imageUrl, true); + + const testImage = cache.findImage(imageUrl); + assert.exists(testImage); + assert.isUndefined(testImage!.imageData); + assert.isFalse(testImage!.loaded); + + assert.isTrue(promise instanceof Promise); + + if (promise instanceof Promise) { + await promise; + const loadedImageItem = cache.findImage(imageUrl); + assert.exists(loadedImageItem); + assert.isDefined(loadedImageItem!.imageData); + assert.isTrue(loadedImageItem!.loaded); + const image = loadedImageItem!.imageData!; + assert.equal(image.width, 37); + assert.equal(image.height, 32); + } + }); + + it("#loadImage", async function() { + // tslint:disable-next-line:no-object-literal-type-assertion + const mapView: MapView = {} as MapView; + const cache = ImageCache.instance; + cache.clearAll(); const imageUrl = getTestResourceUrl( "@here/harp-mapview", "test/resources/headshot.png" ); - const promise = instance.addImage(mapView, imageUrl, true); + const cacheItem = cache.registerImage(mapView, imageUrl, undefined); - const testImage = instance.findImage(imageUrl); + const testImage = cache.findImage(imageUrl); assert.exists(testImage); assert.isUndefined(testImage!.imageData); assert.isFalse(testImage!.loaded); + const promise = cache.loadImage(cacheItem); + assert.isTrue(promise instanceof Promise); if (promise instanceof Promise) { await promise; - const loadedImageItem = instance.findImage(imageUrl); + const loadedImageItem = cache.findImage(imageUrl); assert.exists(loadedImageItem); assert.isDefined(loadedImageItem!.imageData); assert.isTrue(loadedImageItem!.loaded); const image = loadedImageItem!.imageData!; assert.equal(image.width, 37); - assert.equal(image.height, 36); + assert.equal(image.height, 32); } }); } @@ -234,22 +543,21 @@ describe("ImageCache", function() { it("#clearAll", function() { // tslint:disable-next-line:no-object-literal-type-assertion const mapView: MapView = {} as MapView; - const instance = ImageCache.instance; + const cache = ImageCache.instance; const imageData = new ImageData(16, 16); - instance.registerImage(mapView, "httpx://naxos.de", imageData); + cache.registerImage(mapView, "httpx://naxos.de", imageData); - instance.clearAll(); + cache.clearAll(); - assert.equal(instance.size, 0); - assert.notExists(instance.findImage("testImage")); + assert.equal(cache.size, 0); + assert.notExists(cache.findImage("testImage")); }); it("#dispose", function() { // tslint:disable-next-line:no-object-literal-type-assertion const mapView: MapView = {} as MapView; - const instance = ImageCache.instance; const imageData = new ImageData(16, 16); - instance.registerImage(mapView, "httpx://naxos.de", imageData); + ImageCache.instance.registerImage(mapView, "httpx://naxos.de", imageData); ImageCache.dispose(); @@ -257,8 +565,8 @@ describe("ImageCache", function() { }); it("#register same image in multiple MapViews", function() { - const instance = ImageCache.instance; - instance.clearAll(); + const cache = ImageCache.instance; + cache.clearAll(); // tslint:disable-next-line:no-object-literal-type-assertion const mapView1: MapView = {} as MapView; @@ -267,20 +575,20 @@ describe("ImageCache", function() { const imageData1 = new ImageData(16, 16); - instance.registerImage(mapView1, "httpx://naxos.de", imageData1); - instance.registerImage(mapView2, "httpx://naxos.de", imageData1); + cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + cache.registerImage(mapView2, "httpx://naxos.de", imageData1); - const testImage = instance.findImage("httpx://naxos.de"); + const testImage = cache.findImage("httpx://naxos.de"); - assert.equal(instance.size, 1); - assert.notExists(instance.findImage("xxx")); + assert.equal(cache.size, 1); + assert.notExists(cache.findImage("xxx")); assert.exists(testImage); assert.equal(imageData1, testImage!.imageData); }); it("#register different images in multiple MapViews", function() { - const instance = ImageCache.instance; - instance.clearAll(); + const cache = ImageCache.instance; + cache.clearAll(); // tslint:disable-next-line:no-object-literal-type-assertion const mapView1: MapView = {} as MapView; @@ -290,14 +598,14 @@ describe("ImageCache", function() { const imageData1 = new ImageData(16, 16); const imageData2 = new ImageData(32, 32); - instance.registerImage(mapView1, "httpx://naxos.de", imageData1); - instance.registerImage(mapView2, "httpx://naxos.de-2", imageData2); + cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + cache.registerImage(mapView2, "httpx://naxos.de-2", imageData2); - const testImage1 = instance.findImage("httpx://naxos.de"); - const testImage2 = instance.findImage("httpx://naxos.de-2"); + const testImage1 = cache.findImage("httpx://naxos.de"); + const testImage2 = cache.findImage("httpx://naxos.de-2"); - assert.equal(instance.size, 2); - assert.notExists(instance.findImage("xxx")); + assert.equal(cache.size, 2); + assert.notExists(cache.findImage("xxx")); assert.exists(testImage1); assert.equal(imageData1, testImage1!.imageData); assert.exists(testImage2); @@ -305,8 +613,8 @@ describe("ImageCache", function() { }); it("#clear images in multiple MapViews", function() { - const instance = ImageCache.instance; - instance.clearAll(); + const cache = ImageCache.instance; + cache.clearAll(); // tslint:disable-next-line:no-object-literal-type-assertion const mapView1: MapView = {} as MapView; @@ -316,14 +624,137 @@ describe("ImageCache", function() { const imageData1 = new ImageData(16, 16); const imageData2 = new ImageData(32, 32); - instance.registerImage(mapView1, "httpx://naxos.de", imageData1); - instance.registerImage(mapView2, "httpx://naxos.de-2", imageData2); + cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + cache.registerImage(mapView2, "httpx://naxos.de-2", imageData2); + + cache.clear(mapView1); - instance.clear(mapView1); + assert.equal(cache.size, 1); - assert.equal(instance.size, 1); + assert.notExists(cache.findImage("httpx://naxos.de")); + assert.exists(cache.findImage("httpx://naxos.de-2")); + }); + + it("#remove image item", function() { + const cache = ImageCache.instance; + // tslint:disable-next-line:no-object-literal-type-assertion + const mapView1: MapView = {} as MapView; + // tslint:disable-next-line:no-object-literal-type-assertion + + const imageData1 = new ImageData(16, 16); + + const item = cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + + assert.equal(cache.size, 1, "wrong cache size"); + + const imageRemoved = cache.removeImageItem(item); + assert.equal(imageRemoved, true); + assert.equal(cache.size, 0, "wrong cache size"); + }); + + it("#remove image", function() { + const cache = ImageCache.instance; + // tslint:disable-next-line:no-object-literal-type-assertion + const mapView1: MapView = {} as MapView; + // tslint:disable-next-line:no-object-literal-type-assertion + + const imageData1 = new ImageData(16, 16); + + cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + + assert.equal(cache.size, 1, "wrong cache size"); + + const imageRemoved = cache.removeImage("httpx://naxos.de"); + assert.equal(imageRemoved, true); + assert.equal(cache.size, 0, "wrong cache size"); + }); + + it("#remove image by filter", function() { + const cache = ImageCache.instance; + // tslint:disable-next-line:no-object-literal-type-assertion + const mapView1: MapView = {} as MapView; + // tslint:disable-next-line:no-object-literal-type-assertion + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + cache.registerImage(mapView1, "httpx://naxos.de-2", imageData2); + cache.registerImage(mapView1, "httpx://naxos.de-3", imageData2); + assert.equal(ImageCache.instance.size, 3, "wrong cache size"); + + const imagesRemoved = cache.removeImageItems((imageItem: ImageItem) => { + return imageItem.url === "httpx://naxos.de-2"; + }); + assert.equal(imagesRemoved, 1, "wrong number of images removed"); + assert.equal(cache.size, 2, "wrong cache size"); + assert.exists(cache.findImage("httpx://naxos.de")); + assert.notExists(cache.findImage("httpx://naxos.de-2")); + assert.exists(cache.findImage("httpx://naxos.de-3")); + }); + + it("#remove image by filter 2", function() { + const cache = ImageCache.instance; + // tslint:disable-next-line:no-object-literal-type-assertion + const mapView1: MapView = {} as MapView; + // tslint:disable-next-line:no-object-literal-type-assertion + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + cache.registerImage(mapView1, "httpx://naxos.de-2", imageData2); + cache.registerImage(mapView1, "httpx://XXX", imageData2); + assert.equal(ImageCache.instance.size, 3, "wrong cache size"); + + const imagesRemoved = cache.removeImageItems((imageItem: ImageItem) => { + return imageItem.url.startsWith("httpx://naxos.de"); + }); + assert.equal(imagesRemoved, 2, "wrong number of images removed"); + assert.equal(cache.size, 1, "wrong cache size"); + assert.exists(cache.findImage("httpx://XXX")); + }); + + it("#remove all images by filter", function() { + const cache = ImageCache.instance; + // tslint:disable-next-line:no-object-literal-type-assertion + const mapView1: MapView = {} as MapView; + // tslint:disable-next-line:no-object-literal-type-assertion + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + cache.registerImage(mapView1, "httpx://naxos.de-2", imageData2); + cache.registerImage(mapView1, "httpx://naxos.de-3", imageData2); + + const imagesRemoved = cache.removeImageItems((imageItem: ImageItem) => { + return true; + }); + assert.equal(imagesRemoved, 3, "wrong number of images removed"); + assert.equal(cache.size, 0, "cache is not empty"); + }); + + it("#apply", function() { + const cache = ImageCache.instance; + // tslint:disable-next-line:no-object-literal-type-assertion + const mapView1: MapView = {} as MapView; + // tslint:disable-next-line:no-object-literal-type-assertion + + const imageData1 = new ImageData(16, 16); + const imageData2 = new ImageData(32, 32); + + cache.registerImage(mapView1, "httpx://naxos.de", imageData1); + cache.registerImage(mapView1, "httpx://naxos.de-2", imageData2); + cache.registerImage(mapView1, "httpx://naxos.de-3", imageData2); + + let numImagesInCache = 0; + cache.apply(imageItem => { + assert.equal(imageItem.url.split("-")[0], "httpx://naxos.de"); + numImagesInCache++; + }); - assert.notExists(instance.findImage("httpx://naxos.de")); - assert.exists(instance.findImage("httpx://naxos.de-2")); + assert.equal(numImagesInCache, 3, "wrong count"); + assert.equal(cache.size, 3, "wrong cache size"); }); }); diff --git a/@here/harp-mapview/test/MapViewTest.ts b/@here/harp-mapview/test/MapViewTest.ts index 1040bc3e03..89b79aeae9 100644 --- a/@here/harp-mapview/test/MapViewTest.ts +++ b/@here/harp-mapview/test/MapViewTest.ts @@ -21,6 +21,7 @@ import * as nodeUrl from "url"; const URL = typeof window !== "undefined" ? window.URL : nodeUrl.URL; import { + GeoBox, GeoCoordinates, mercatorProjection, sphereProjection, @@ -82,7 +83,9 @@ describe("MapView", function() { const theGlobal: any = global; theGlobal.window = { window: { devicePixelRatio: 10 } }; theGlobal.navigator = {}; - theGlobal.requestAnimationFrame = () => {}; + theGlobal.requestAnimationFrame = (callback: (time: DOMHighResTimeStamp) => void) => { + setTimeout(callback, 0); + }; } addEventListenerSpy = sinon.stub(); removeEventListenerSpy = sinon.stub(); @@ -195,6 +198,46 @@ describe("MapView", function() { tilt: 30, heading: -160 } + }, + { + testName: "berlin bounds only", + lookAtParams: { + bounds: new GeoBox( + new GeoCoordinates(52.438917, 13.275001), + new GeoCoordinates(52.590844, 13.522331) + ) + } + }, + { + testName: "berlin bounds + zoomLevel", + lookAtParams: { + bounds: new GeoBox( + new GeoCoordinates(52.438917, 13.275001), + new GeoCoordinates(52.590844, 13.522331) + ), + zoomLevel: 10 + } + }, + { + testName: "berlin bounds + distance", + lookAtParams: { + bounds: new GeoBox( + new GeoCoordinates(52.438917, 13.275001), + new GeoCoordinates(52.590844, 13.522331) + ), + distance: 38200 + } + }, + { + testName: "berlin bounds + distance + angles", + lookAtParams: { + bounds: new GeoBox( + new GeoCoordinates(52.438917, 13.275001), + new GeoCoordinates(52.590844, 13.522331) + ), + tilt: 45, + heading: 45 + } } ]) { it(`obeys constructor params - ${testName}`, function() { @@ -251,6 +294,30 @@ describe("MapView", function() { if (lookAtParams.heading !== undefined) { expect(mapView.heading).to.be.closeTo(lookAtParams.heading, epsilon); } + if (lookAtParams.bounds !== undefined) { + expect(mapView.target.latitude).to.be.closeTo( + lookAtParams.bounds.center.latitude, + epsilon + ); + expect(mapView.target.longitude).to.be.closeTo( + lookAtParams.bounds.center.longitude, + epsilon + ); + + if (lookAtParams.zoomLevel) { + expect(mapView.zoomLevel).to.be.closeTo( + lookAtParams.zoomLevel, + epsilon + ); + } + + if (lookAtParams.distance) { + expect(mapView.targetDistance).to.be.closeTo( + lookAtParams.distance, + 1e-8 + ); + } + } }); } }); @@ -291,6 +358,19 @@ describe("MapView", function() { expect(mapView.heading).to.be.closeTo(20, 1e-3); }); + it("Check getters", function() { + // Make codecov happy ;) + mapView = new MapView({ + canvas, + tilt: 45, + heading: 90 + }); + + expect(mapView.imageCache.numberOfNames).to.be.equal(0); + expect(mapView.userImageCache.numberOfNames).to.be.equal(0); + assert.isDefined(mapView.pickHandler); + }); + it("Correctly set and get zoom", function() { mapView = new MapView({ canvas, @@ -1039,4 +1119,33 @@ describe("MapView", function() { }); }); }); + + describe("frame complete", function() { + it("MapView emits frame complete for empty map", async function() { + this.timeout(100); + mapView = new MapView({ canvas }); + return waitForEvent(mapView, MapViewEventNames.FrameComplete); + }); + it("MapView emits frame complete after map initialized", async function() { + this.timeout(100); + mapView = new MapView({ canvas }); + + const dataSource = new FakeOmvDataSource({ name: "omv" }); + mapView.addDataSource(dataSource); + + return waitForEvent(mapView, MapViewEventNames.FrameComplete); + }); + it("MapView emits frame complete again after map update", async function() { + this.timeout(100); + mapView = new MapView({ canvas }); + + const dataSource = new FakeOmvDataSource({ name: "omv" }); + mapView.addDataSource(dataSource); + + await waitForEvent(mapView, MapViewEventNames.FrameComplete); + + mapView.update(); + return waitForEvent(mapView, MapViewEventNames.FrameComplete); + }); + }); }); diff --git a/@here/harp-mapview/test/MipMapGeneratorTest.ts b/@here/harp-mapview/test/MipMapGeneratorTest.ts index a5b28a792a..ac04bf5ed6 100644 --- a/@here/harp-mapview/test/MipMapGeneratorTest.ts +++ b/@here/harp-mapview/test/MipMapGeneratorTest.ts @@ -73,7 +73,10 @@ describe("MipMapGenerator", function() { const size = Math.pow(2, 6 - level); const image = mipMaps[level]; expect(image.width).to.equal(size); - expect(image.height).to.equal(size); + // The image's height is 32, and not padded to 64 as the width is, hence we + // ensure the correct height of the mipmap, note we need to ensure we clamp to 1 + // hence the `ceil`. + expect(image.height).to.equal(Math.ceil(size / 2)); } }); @@ -89,7 +92,8 @@ describe("MipMapGenerator", function() { const size = Math.pow(2, 6 - level); const image = mipMaps[level]; expect(image.width).to.equal(size); - expect(image.height).to.equal(size); + // See comment above. + expect(image.height).to.equal(Math.ceil(size / 2)); } }); } diff --git a/@here/harp-mapview/test/PickListenerTest.ts b/@here/harp-mapview/test/PickListenerTest.ts new file mode 100644 index 0000000000..ab535a3ad5 --- /dev/null +++ b/@here/harp-mapview/test/PickListenerTest.ts @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ + +// tslint:disable:only-arrow-functions +// Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions +// tslint:disable:no-unused-expression +// Chai uses properties instead of functions for some expect checks. + +import { expect } from "chai"; +import * as THREE from "three"; +import { PickObjectType, PickResult } from "../lib/PickHandler"; +import { PickListener } from "../lib/PickListener"; + +function createIntersection(dataSource: string | undefined): THREE.Intersection { + const object = new THREE.Object3D(); + object.userData = { dataSource }; + return { distance: 0, point: new THREE.Vector3(), object }; +} +describe("PickListener", function() { + const point = new THREE.Vector3(); + + describe("addResult", function() { + it("adds all results for new features", function() { + const listener = new PickListener(); + const userData = {}; + const intersection = createIntersection("ds1"); + listener.addResult({ + type: PickObjectType.Point, + point, + distance: 1, + featureId: 1, + userData, + intersection + }); + + // different type + listener.addResult({ + type: PickObjectType.Area, + point, + distance: 1, + featureId: 1, + userData, + intersection + }); + + // different data source. + listener.addResult({ + type: PickObjectType.Point, + point, + distance: 1, + featureId: 1, + userData, + intersection: createIntersection("ds2") + }); + + // different feature id + listener.addResult({ + type: PickObjectType.Point, + point, + distance: 1, + featureId: 2, + userData, + intersection + }); + + // no feature id and different user data. + listener.addResult({ + type: PickObjectType.Point, + point, + distance: 1, + userData: {}, + intersection + }); + listener.finish(); + expect(listener.results).to.have.lengthOf(5); + }); + + it("ignores a result for the same feature of an older closer result", function() { + const userData = {}; + const intersection = createIntersection("ds1"); + + { + // matching results by feature id. + const listener = new PickListener(); + const firstResult: PickResult = { + type: PickObjectType.Point, + point, + distance: 1, + featureId: 1, + userData, + intersection + }; + listener.addResult(firstResult); + + listener.addResult({ + type: PickObjectType.Point, + point, + distance: 2, + featureId: 1, + intersection + }); + listener.finish(); + + expect(listener.results).to.have.lengthOf(1); + expect(listener.results[0]).equals(firstResult); + } + { + // matching results by user data. + const listener = new PickListener(); + + const firstResult: PickResult = { + type: PickObjectType.Point, + point, + distance: 1, + userData, + intersection + }; + listener.addResult(firstResult); + + listener.addResult({ + type: PickObjectType.Point, + point, + distance: 2, + userData, + intersection + }); + listener.finish(); + + expect(listener.results).to.have.lengthOf(1); + expect(listener.results[0]).equals(firstResult); + } + }); + + it("replaces an older result with a closer result for the same feature", function() { + const listener = new PickListener(); + const userData = {}; + const intersection = createIntersection("ds1"); + listener.addResult({ + type: PickObjectType.Point, + point, + distance: 2, + featureId: 1, + userData, + intersection + }); + + const closerResult: PickResult = { + type: PickObjectType.Point, + point, + distance: 1, + featureId: 1, + intersection + }; + listener.addResult(closerResult); + listener.finish(); + + expect(listener.results).to.have.lengthOf(1); + expect(listener.results[0]).equals(closerResult); + }); + }); + + describe("finish", function() { + it("orders the results", function() { + const listener = new PickListener(); + const expectedResults: PickResult[] = [ + { + type: PickObjectType.Point, + point, + distance: 0, + renderOrder: 1 + }, + { + type: PickObjectType.Point, + point, + distance: 1, + renderOrder: 3 + }, + { + type: PickObjectType.Point, + point, + distance: 1, + renderOrder: 2 + } + ]; + listener.addResult(expectedResults[2]); + listener.addResult(expectedResults[1]); + listener.addResult(expectedResults[0]); + listener.finish(); + + expect(listener.results).to.have.ordered.members(expectedResults); + }); + + it("keeps only the closest maximum result count if specified", function() { + const maxResultCount = 1; + const listener = new PickListener({ maxResultCount }); + const results: PickResult[] = [ + { + type: PickObjectType.Point, + point, + distance: 0, + renderOrder: 1 + }, + { + type: PickObjectType.Point, + point, + distance: 1, + renderOrder: 3 + } + ]; + listener.addResult(results[1]); + listener.addResult(results[0]); + listener.finish(); + + expect(listener.results).to.have.lengthOf(maxResultCount); + expect(listener.results[0]).equals(results[0]); + }); + }); + + describe("closestResult", function() { + it("returns undefined if there's no results", function() { + const listener = new PickListener(); + expect(listener.closestResult).to.be.undefined; + }); + + it("returns closest result when there's some results", function() { + const listener = new PickListener(); + const results: PickResult[] = [ + { + type: PickObjectType.Point, + point, + distance: 0, + renderOrder: 1 + }, + { + type: PickObjectType.Point, + point, + distance: 1, + renderOrder: 3 + } + ]; + listener.addResult(results[1]); + listener.addResult(results[0]); + listener.finish(); + + expect(listener.closestResult).to.equal(results[0]); + }); + }); + + describe("furthestResult", function() { + it("returns undefined if there's no results", function() { + const listener = new PickListener(); + expect(listener.furthestResult).to.be.undefined; + }); + + it("returns furtherst result when there's some results", function() { + const listener = new PickListener(); + const results: PickResult[] = [ + { + type: PickObjectType.Point, + point, + distance: 0, + renderOrder: 1 + }, + { + type: PickObjectType.Point, + point, + distance: 1, + renderOrder: 3 + } + ]; + listener.addResult(results[1]); + listener.addResult(results[0]); + listener.finish(); + + expect(listener.furthestResult).to.equal(results[1]); + }); + }); +}); diff --git a/@here/harp-mapview/test/PickingRaycasterTest.ts b/@here/harp-mapview/test/PickingRaycasterTest.ts new file mode 100644 index 0000000000..53e07b33b6 --- /dev/null +++ b/@here/harp-mapview/test/PickingRaycasterTest.ts @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ + +// tslint:disable:only-arrow-functions +// Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions +// tslint:disable:no-unused-expression +// Chai uses properties instead of functions for some expect checks. + +import { MapEnv } from "@here/harp-datasource-protocol"; +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as THREE from "three"; +import { MapObjectAdapter } from "../lib/MapObjectAdapter"; +import { PickingRaycaster } from "../lib/PickingRaycaster"; + +function createFakeObject(type: new () => T): T { + const object = new type(); + sinon.stub(object, "raycast").callsFake((_, intersects) => { + intersects.push({ distance: 0, point: new THREE.Vector3(), object }); + }); + return object; +} +describe("PickingRaycaster", function() { + let raycaster: PickingRaycaster; + + beforeEach(function() { + raycaster = new PickingRaycaster(0, 0, new MapEnv({})); + }); + + describe("intersectObject(s)", function() { + it("skips invisible objects", function() { + const object = createFakeObject(THREE.Object3D); + object.visible = false; + { + const intersections = raycaster.intersectObject(object); + expect(intersections).to.be.empty; + } + { + const intersections = raycaster.intersectObjects([object, object]); + expect(intersections).to.be.empty; + } + }); + + it("skips fully transparent objects", function() { + const mesh = createFakeObject(THREE.Mesh); + mesh.material = new THREE.Material(); + mesh.material.opacity = 0; + MapObjectAdapter.create(mesh, {}); + { + const intersections = raycaster.intersectObject(mesh); + expect(intersections).to.be.empty; + } + { + const intersections = raycaster.intersectObjects([mesh, mesh]); + expect(intersections).to.be.empty; + } + }); + + it("skips non-pickable objects", function() { + const object = createFakeObject(THREE.Object3D); + MapObjectAdapter.create(object, { pickable: false }); + { + const intersections = raycaster.intersectObject(object); + expect(intersections).to.be.empty; + } + { + const intersections = raycaster.intersectObjects([object, object]); + expect(intersections).to.be.empty; + } + }); + + it("tests pickable objects", function() { + const object = createFakeObject(THREE.Object3D); + const mesh = createFakeObject(THREE.Mesh); + mesh.material = new THREE.Material(); + mesh.material.opacity = 1; + MapObjectAdapter.create(mesh, { pickable: true }); + + { + const intersections = raycaster.intersectObject(object); + expect(intersections).to.have.length(1); + } + { + const intersections = raycaster.intersectObjects([object, mesh]); + expect(intersections).to.have.length(2); + } + }); + + it("tests object descendants if recursive is true", function() { + const object = createFakeObject(THREE.Object3D); + const child = createFakeObject(THREE.Object3D); + const grandchild = createFakeObject(THREE.Object3D); + child.children = [grandchild]; + object.children = [child]; + { + const intersections = raycaster.intersectObject(object, true); + expect(intersections).to.have.length(3); + } + { + const intersections = raycaster.intersectObjects([object, object], true); + expect(intersections).to.have.length(6); + } + }); + + it("skips object descendants if recursive is false", function() { + const object = createFakeObject(THREE.Object3D); + const child = createFakeObject(THREE.Object3D); + const grandchild = createFakeObject(THREE.Object3D); + child.children = [grandchild]; + object.children = [child]; + { + const intersections = raycaster.intersectObject(object, false); + expect(intersections).to.have.length(1); + } + { + const intersections = raycaster.intersectObjects([object, object], false); + expect(intersections).to.have.length(2); + } + }); + }); +}); diff --git a/@here/harp-mapview/test/PoiInfoBuilder.ts b/@here/harp-mapview/test/PoiInfoBuilder.ts index 44dd335c7b..a84330f8f5 100644 --- a/@here/harp-mapview/test/PoiInfoBuilder.ts +++ b/@here/harp-mapview/test/PoiInfoBuilder.ts @@ -27,18 +27,18 @@ export class PoiInfoBuilder { }; static readonly DEF_TECHNIQUE = PoiInfoBuilder.POI_TECHNIQUE; - private m_iconMinZl: number = PoiInfoBuilder.DEF_ICON_TEXT_MIN_ZL; - private m_iconMaxZl: number = PoiInfoBuilder.DEF_ICON_TEXT_MAX_ZL; - private m_textMinZl: number = PoiInfoBuilder.DEF_ICON_TEXT_MIN_ZL; - private m_textMaxZl: number = PoiInfoBuilder.DEF_ICON_TEXT_MAX_ZL; - private m_textOpt: boolean = PoiInfoBuilder.DEF_TEXT_OPT; - private m_iconOpt: boolean = PoiInfoBuilder.DEF_ICON_OPT; + private readonly m_iconMinZl: number = PoiInfoBuilder.DEF_ICON_TEXT_MIN_ZL; + private readonly m_iconMaxZl: number = PoiInfoBuilder.DEF_ICON_TEXT_MAX_ZL; + private readonly m_textMinZl: number = PoiInfoBuilder.DEF_ICON_TEXT_MIN_ZL; + private readonly m_textMaxZl: number = PoiInfoBuilder.DEF_ICON_TEXT_MAX_ZL; + private readonly m_textOpt: boolean = PoiInfoBuilder.DEF_TEXT_OPT; + private readonly m_iconOpt: boolean = PoiInfoBuilder.DEF_ICON_OPT; private m_mayOverlap: boolean = PoiInfoBuilder.DEF_MAY_OVERLAP; - private m_reserveSpace: boolean = PoiInfoBuilder.DEF_RESERVE_SPACE; - private m_valid: boolean = PoiInfoBuilder.DEF_VALID; - private m_renderOnMove: boolean = PoiInfoBuilder.DEF_RENDER_ON_MOVE; - private m_width: number = PoiInfoBuilder.DEF_WIDTH_HEIGHT; - private m_height: number = PoiInfoBuilder.DEF_WIDTH_HEIGHT; + private readonly m_reserveSpace: boolean = PoiInfoBuilder.DEF_RESERVE_SPACE; + private readonly m_valid: boolean = PoiInfoBuilder.DEF_VALID; + private readonly m_renderOnMove: boolean = PoiInfoBuilder.DEF_RENDER_ON_MOVE; + private readonly m_width: number = PoiInfoBuilder.DEF_WIDTH_HEIGHT; + private readonly m_height: number = PoiInfoBuilder.DEF_WIDTH_HEIGHT; private m_technique: PoiTechnique | LineMarkerTechnique = PoiInfoBuilder.DEF_TECHNIQUE; withPoiTechnique(): PoiInfoBuilder { diff --git a/@here/harp-mapview/test/RenderStateTest.ts b/@here/harp-mapview/test/RenderStateTest.ts index 5ed36f2132..de92f0034a 100644 --- a/@here/harp-mapview/test/RenderStateTest.ts +++ b/@here/harp-mapview/test/RenderStateTest.ts @@ -170,6 +170,16 @@ describe("RenderState", function() { expect(renderState.opacity).to.equal(0.0); }); + it("fade in transitions to final state if fading disabled", function() { + const renderState = new RenderState(); + renderState.startFadeIn(100, true); + + expect(renderState.isFadedIn()).to.be.true; + expect(renderState.startTime).to.equal(100); + expect(renderState.value).to.equal(1.0); + expect(renderState.opacity).to.equal(1.0); + }); + it("does not change an already fading in state", function() { const renderState = new RenderState(); renderState.startFadeIn(100); diff --git a/@here/harp-mapview/test/SolidLineMeshTest.ts b/@here/harp-mapview/test/SolidLineMeshTest.ts index 98864bfb51..a0ffb421aa 100644 --- a/@here/harp-mapview/test/SolidLineMeshTest.ts +++ b/@here/harp-mapview/test/SolidLineMeshTest.ts @@ -172,12 +172,19 @@ class SolidLineGeometryBuilder extends BufferGeometryBuilder { } class SolidLineMeshBuilder { - private static DEFAULT_MATERIAL = new SolidLineMaterial({ lineWidth: 1, outlineWidth: 1 }); + private static readonly DEFAULT_MATERIAL = new SolidLineMaterial({ + lineWidth: 1, + outlineWidth: 1 + }); readonly geometryBuilder: SolidLineGeometryBuilder; - private m_materials: THREE.Material[] = [SolidLineMeshBuilder.DEFAULT_MATERIAL]; + private readonly m_materials: THREE.Material[] = [SolidLineMeshBuilder.DEFAULT_MATERIAL]; private m_featureStarts?: number[]; - private m_groups = new Array<{ start: number; count: number; materialIndex?: number }>(); + private readonly m_groups = new Array<{ + start: number; + count: number; + materialIndex?: number; + }>(); constructor(polyline: number[], bitangents: number[]) { this.geometryBuilder = new SolidLineGeometryBuilder(polyline, bitangents); diff --git a/@here/harp-mapview/test/StatisticsTest.ts b/@here/harp-mapview/test/StatisticsTest.ts index e0efcf0b3f..f7b8a624a7 100644 --- a/@here/harp-mapview/test/StatisticsTest.ts +++ b/@here/harp-mapview/test/StatisticsTest.ts @@ -263,11 +263,11 @@ describe("mapview-statistics", function() { assert.equal(stagedTimer.stage, undefined); assert.isNumber(stats.getTimer("init").value); - assert.isAbove(stats.getTimer("init").value || 0, 0); + assert.isAbove(stats.getTimer("init").value ?? 0, 0); assert.isNumber(stats.getTimer("draw").value); - assert.isAbove(stats.getTimer("draw").value || 0, 0); + assert.isAbove(stats.getTimer("draw").value ?? 0, 0); assert.isNumber(stats.getTimer("post").value); - assert.isAbove(stats.getTimer("post").value || 0, 0); + assert.isAbove(stats.getTimer("post").value ?? 0, 0); stats.log(); diff --git a/@here/harp-mapview/test/TextElementsRendererTestFixture.ts b/@here/harp-mapview/test/TextElementsRendererTestFixture.ts index b2637fe918..84299e225f 100644 --- a/@here/harp-mapview/test/TextElementsRendererTestFixture.ts +++ b/@here/harp-mapview/test/TextElementsRendererTestFixture.ts @@ -115,7 +115,7 @@ export class TestFixture { private m_textRenderer: TextElementsRenderer | undefined; private m_defaultTile: Tile | undefined; private m_allTiles: Tile[] = []; - private m_allTextElements: TextElement[][] = []; + private readonly m_allTextElements: TextElement[][] = []; constructor(readonly sandbox: sinon.SinonSandbox) { this.m_screenCollisions = new ScreenCollisions(); diff --git a/@here/harp-mapview/test/TileGeometryCreatorTest.ts b/@here/harp-mapview/test/TileGeometryCreatorTest.ts index b9be523367..9e26b591f5 100644 --- a/@here/harp-mapview/test/TileGeometryCreatorTest.ts +++ b/@here/harp-mapview/test/TileGeometryCreatorTest.ts @@ -25,12 +25,14 @@ import { assert, expect } from "chai"; import * as sinon from "sinon"; import * as THREE from "three"; import { DataSource } from "../lib/DataSource"; +import { isDepthPrePassMesh } from "../lib/DepthPrePass"; import { DisplacementMap } from "../lib/DisplacementMap"; import { TileGeometryCreator } from "../lib/geometry/TileGeometryCreator"; +import { MapObjectAdapter } from "../lib/MapObjectAdapter"; import { Tile } from "../lib/Tile"; class FakeMapView { - private m_scene = new THREE.Scene(); + private readonly m_scene = new THREE.Scene(); get zoomLevel(): number { return 0; @@ -83,6 +85,9 @@ describe("TileGeometryCreator", () => { mockDatasource.getTilingScheme.callsFake(() => webMercatorTilingScheme); sinon.stub(mockDatasource, "projection").get(() => mercatorProjection); sinon.stub(mockDatasource, "mapView").get(() => mapView); + }); + + beforeEach(function() { newTile = new Tile( (mockDatasource as unknown) as DataSource, TileKey.fromRowColumnLevel(0, 0, 0) @@ -133,6 +138,160 @@ describe("TileGeometryCreator", () => { assert.equal(imageData.height, decodedDisplacementMap.yCountVertices); }); + it("background geometry is registered as non-pickable", () => { + tgc.addGroundPlane(newTile, 0); + assert.equal(newTile.objects.length, 1); + const adapter = MapObjectAdapter.get(newTile.objects[0]); + expect(adapter).not.equals(undefined); + expect(adapter!.isPickable(new MapEnv({}))).to.equal(false); + }); + + it("extruded polygon depth prepass and edges geometries are registered as non-pickable", () => { + const decodedTile: DecodedTile = { + geometries: [ + { + type: GeometryType.Polygon, + vertexAttributes: [ + { + name: "position", + buffer: new Float32Array([1.0, 2.0, 3.0]), + type: "float", + itemCount: 3 + } + ], + groups: [{ start: 0, count: 1, technique: 0, createdOffsets: [] }], + edgeIndex: { + name: "index", + buffer: new Uint16Array([0]), + type: "float", + itemCount: 1 + } + } + ], + techniques: [ + { + name: "extruded-polygon", + lineWidth: 0, + opacity: 0.1, + renderOrder: 0, + _index: 0, + _styleSetIndex: 0 + } + ] + }; + tgc.createObjects(newTile, decodedTile); + assert.equal(newTile.objects.length, 3); + + newTile.objects.forEach(object => { + const adapter = MapObjectAdapter.get(object); + expect(adapter).not.equals(undefined); + expect(adapter!.isPickable(new MapEnv({}))).to.equal( + !isDepthPrePassMesh(object) && !(object as any).isLine + ); + }); + }); + + it("fill outline geometry is registered as non-pickable", () => { + const decodedTile: DecodedTile = { + geometries: [ + { + type: GeometryType.Polygon, + vertexAttributes: [ + { + name: "position", + buffer: new Float32Array([1.0, 2.0, 3.0]), + type: "float", + itemCount: 3 + } + ], + groups: [{ start: 0, count: 1, technique: 0, createdOffsets: [] }], + edgeIndex: { + name: "index", + buffer: new Uint16Array([0]), + type: "float", + itemCount: 1 + } + } + ], + techniques: [ + { + name: "fill", + renderOrder: 0, + _index: 0, + _styleSetIndex: 0 + } + ] + }; + tgc.createObjects(newTile, decodedTile); + assert.equal(newTile.objects.length, 2); + const adapter0 = MapObjectAdapter.get(newTile.objects[0]); + expect(adapter0).not.equals(undefined); + expect(adapter0!.isPickable(new MapEnv({}))).to.equal(true); + + const adapter1 = MapObjectAdapter.get(newTile.objects[1]); + expect(adapter1).not.equals(undefined); + expect(adapter1!.isPickable(new MapEnv({}))).to.equal(false); + }); + + it("solid line without outline is registered as pickable", () => { + const decodedTile: DecodedTile = { + geometries: [ + { + type: GeometryType.Polygon, + vertexAttributes: [], + groups: [{ start: 0, count: 1, technique: 0, createdOffsets: [] }] + } + ], + techniques: [ + { + name: "solid-line", + color: "red", + lineWidth: 1, + renderOrder: 0, + _index: 0, + _styleSetIndex: 0 + } + ] + }; + tgc.createObjects(newTile, decodedTile); + assert.equal(newTile.objects.length, 1); + const adapter = MapObjectAdapter.get(newTile.objects[0]); + expect(adapter).not.equals(undefined); + expect(adapter!.isPickable(new MapEnv({}))).to.equal(true); + }); + + it("only outline geometry from solid line with outline is registered as pickable", () => { + const decodedTile: DecodedTile = { + geometries: [ + { + type: GeometryType.Polygon, + vertexAttributes: [], + groups: [{ start: 0, count: 1, technique: 0, createdOffsets: [] }] + } + ], + techniques: [ + { + name: "solid-line", + color: "red", + lineWidth: 1, + secondaryWidth: 2, + renderOrder: 0, + _index: 0, + _styleSetIndex: 0 + } + ] + }; + tgc.createObjects(newTile, decodedTile); + assert.equal(newTile.objects.length, 2); + const adapter0 = MapObjectAdapter.get(newTile.objects[0]); + expect(adapter0).not.equals(undefined); + expect(adapter0!.isPickable(new MapEnv({}))).to.equal(false); + + const adapter1 = MapObjectAdapter.get(newTile.objects[1]); + expect(adapter1).not.equals(undefined); + expect(adapter1!.isPickable(new MapEnv({}))).to.equal(true); + }); + it("categories", () => { type IndexedDecodedTile = Omit & { techniques?: IndexedTechnique[]; @@ -187,9 +346,9 @@ describe("TileGeometryCreator", () => { tgc.processTechniques(newTile, undefined, undefined); tgc.createObjects(newTile, decodedTile as DecodedTile); - assert.strictEqual(newTile.objects.length, 3); - assert.strictEqual(newTile.objects[1].renderOrder, 20); - assert.strictEqual(newTile.objects[2].renderOrder, 10); + assert.strictEqual(newTile.objects.length, 2); + assert.strictEqual(newTile.objects[0].renderOrder, 20); + assert.strictEqual(newTile.objects[1].renderOrder, 10); newTile.mapView.theme = savedTheme; }); diff --git a/@here/harp-mapview/test/TileTest.ts b/@here/harp-mapview/test/TileTest.ts index a61d036d5c..11642979fe 100644 --- a/@here/harp-mapview/test/TileTest.ts +++ b/@here/harp-mapview/test/TileTest.ts @@ -41,7 +41,10 @@ function createFakeTextElement(): TextElement { describe("Tile", function() { const tileKey = TileKey.fromRowColumnLevel(0, 0, 0); const stubDataSource = new TileTestStubDataSource({ name: "test-data-source" }); - const mapView = { projection: mercatorProjection }; + const mapView = { + projection: mercatorProjection, + frameNumber: 0 + }; stubDataSource.attach(mapView as MapView); it("set empty decoded tile forces hasGeometry to be true", function() { @@ -183,12 +186,12 @@ describe("Tile", function() { it("elevationRange setter does not elevate bbox if maxGeometryHeight is not set", function() { const tile = new Tile(stubDataSource, tileKey); - const oldGeoBox = tile.geoBox.clone(); const oldBBox = tile.boundingBox.clone(); tile.elevationRange = { minElevation: 5, maxElevation: 10 }; - expect(tile.geoBox).deep.equals(oldGeoBox); + expect(tile.geoBox.minAltitude).equals(tile.elevationRange.minElevation); + expect(tile.geoBox.maxAltitude).equals(tile.elevationRange.maxElevation); expect(tile.boundingBox).deep.equals(oldBBox); tile.decodedTile = { techniques: [], geometries: [], boundingBox: new OrientedBox3() }; @@ -256,4 +259,15 @@ describe("Tile", function() { expect(tile.geoBox).deep.equals(expectedGeoBox); expect(tile.boundingBox).deep.equals(expectedBBox); }); + + it("doesnt throw on isVisble if not attached to a MapView", function() { + const tile = new Tile(stubDataSource, tileKey); + mapView.frameNumber = 2; + tile.frameNumLastRequested = 2; + expect(tile.isVisible).not.throw; + expect(tile.isVisible).is.true; + stubDataSource.detach(mapView as MapView); + expect(tile.isVisible).not.throw; + expect(tile.isVisible).is.false; + }); }); diff --git a/@here/harp-mapview/test/UtilsTest.ts b/@here/harp-mapview/test/UtilsTest.ts index 910086b85d..95b036798c 100644 --- a/@here/harp-mapview/test/UtilsTest.ts +++ b/@here/harp-mapview/test/UtilsTest.ts @@ -9,9 +9,12 @@ import { EarthConstants, + GeoBox, GeoCoordinates, mercatorProjection, + OrientedBox3, Projection, + ProjectionType, sphereProjection, TileKey } from "@here/harp-geoutils"; @@ -31,6 +34,32 @@ const cameraMock = { matrixWorld: new THREE.Matrix4() }; +function setCamera( + camera: THREE.Camera, + projection: Projection, + geoTarget: GeoCoordinates, + heading: number, + tilt: number, + distance: number +) { + MapViewUtils.getCameraRotationAtTarget( + projection, + geoTarget, + -heading, + tilt, + camera.quaternion + ); + MapViewUtils.getCameraPositionFromTargetCoordinates( + geoTarget, + distance, + -heading, + tilt, + projection, + camera.position + ); + camera.updateMatrixWorld(true); +} + describe("map-view#Utils", function() { describe("calculateZoomLevelFromDistance", function() { const mapViewMock = { @@ -341,43 +370,25 @@ describe("map-view#Utils", function() { expect(objSize.gpuSize).to.be.equal(24); }); - describe("getTargetAndDistance", function() { - const elevationProvider = ({} as any) as ElevationProvider; - let sandbox: sinon.SinonSandbox; - let camera: THREE.Camera; - const geoTarget = GeoCoordinates.fromDegrees(0, 0); - - function resetCamera(projection: Projection) { - const heading = 0; - const tilt = 0; - const distance = 1e6; - MapViewUtils.getCameraRotationAtTarget( - projection, - geoTarget, - -heading, - tilt, - camera.quaternion - ); - MapViewUtils.getCameraPositionFromTargetCoordinates( - geoTarget, - distance, - -heading, - tilt, - projection, - camera.position - ); - camera.updateMatrixWorld(true); - } + for (const { projName, projection } of [ + { projName: "mercator", projection: mercatorProjection }, + { projName: "sphere", projection: sphereProjection } + ]) { + describe(`${projName} projection`, function() { + describe("getTargetAndDistance", function() { + const elevationProvider = ({} as any) as ElevationProvider; + let sandbox: sinon.SinonSandbox; + let camera: THREE.Camera; + const geoTarget = GeoCoordinates.fromDegrees(0, 0); + + function resetCamera() { + setCamera(camera, projection, geoTarget, 0, 0, 1e6); + } - for (const { projName, projection } of [ - { projName: "mercator", projection: mercatorProjection }, - { projName: "sphere", projection: sphereProjection } - ]) { - describe(`${projName} projection`, function() { beforeEach(function() { sandbox = sinon.createSandbox(); camera = new THREE.PerspectiveCamera(); - resetCamera(projection); + resetCamera(); }); it("camera target and distance are offset by elevation", function() { @@ -413,8 +424,163 @@ describe("map-view#Utils", function() { ); }); }); - } - }); + + describe("constrainTargetAndDistanceToViewBounds", function() { + const camera: THREE.Camera = new THREE.PerspectiveCamera(undefined, 1); + const mapViewMock = { + maxZoomLevel: 20, + minZoomLevel: 1, + camera, + projection, + focalLength: 256, + worldMaxBounds: undefined as THREE.Box3 | OrientedBox3 | undefined, + renderer: { + getSize() { + return new THREE.Vector2(300, 300); + } + } + }; + const mapView = (mapViewMock as any) as MapView; + + it("target and distance are unchanged when no bounds set", function() { + const geoTarget = GeoCoordinates.fromDegrees(0, 0); + const worldTarget = mapView.projection.projectPoint( + geoTarget, + new THREE.Vector3() + ); + const distance = 1e7; + setCamera(camera, mapView.projection, geoTarget, 0, 0, distance); + + const constrained = MapViewUtils.constrainTargetAndDistanceToViewBounds( + worldTarget, + distance, + mapView + ); + expect(constrained.target).deep.equals(worldTarget); + expect(constrained.distance).equals(distance); + }); + + it("target and distance are unchanged when view within bounds", function() { + const geoTarget = GeoCoordinates.fromDegrees(0, 0); + const geoBounds = new GeoBox( + GeoCoordinates.fromDegrees(-50, -50), + GeoCoordinates.fromDegrees(50, 50) + ); + const worldTarget = mapView.projection.projectPoint( + geoTarget, + new THREE.Vector3() + ); + mapViewMock.worldMaxBounds = mapView.projection.projectBox( + geoBounds, + mapView.projection.type === ProjectionType.Planar + ? new THREE.Box3() + : new OrientedBox3() + ); + const distance = 100; + setCamera(camera, mapView.projection, geoTarget, 0, 0, distance); + + const constrained = MapViewUtils.constrainTargetAndDistanceToViewBounds( + worldTarget, + distance, + mapView + ); + + expect(constrained.target).deep.equals(worldTarget); + expect(constrained.distance).equals(distance); + }); + + it("target and distance are constrained when camera is too far", function() { + const tilt = 0; + const heading = 0; + const geoTarget = GeoCoordinates.fromDegrees(0, 0); + const geoBounds = new GeoBox( + GeoCoordinates.fromDegrees(-1, -1), + GeoCoordinates.fromDegrees(1, 1) + ); + const worldTarget = mapView.projection.projectPoint( + geoTarget, + new THREE.Vector3() + ); + mapViewMock.worldMaxBounds = mapView.projection.projectBox( + geoBounds, + mapView.projection.type === ProjectionType.Planar + ? new THREE.Box3() + : new OrientedBox3() + ); + const distance = 1e6; + setCamera(camera, mapView.projection, geoTarget, heading, tilt, distance); + + const constrained = MapViewUtils.constrainTargetAndDistanceToViewBounds( + worldTarget, + distance, + mapView + ); + + const boundsCenter = (mapViewMock.worldMaxBounds as THREE.Box3).getCenter( + new THREE.Vector3() + ); + if (mapView.projection.type === ProjectionType.Planar) { + boundsCenter.setZ(worldTarget.z); + } else { + boundsCenter.setLength(worldTarget.length()); + } + expect(constrained.target).deep.equals(boundsCenter); + expect(constrained.distance).to.be.lessThan(distance); + + const constrainedGeoTarget = mapView.projection.unprojectPoint( + constrained.target + ); + const newTilt = MapViewUtils.extractTiltAngleFromLocation( + mapView.projection, + camera, + constrainedGeoTarget + ); + expect(THREE.MathUtils.radToDeg(newTilt)).to.be.closeTo(tilt, 1e-3); + }); + + it("target and distance are constrained if target is out of bounds", function() { + const tilt = 50; + const heading = 10; + const geoTarget = GeoCoordinates.fromDegrees(10.1, 10); + const geoBounds = new GeoBox( + GeoCoordinates.fromDegrees(-10, -10), + GeoCoordinates.fromDegrees(10, 10) + ); + const worldTarget = mapView.projection.projectPoint( + geoTarget, + new THREE.Vector3() + ); + mapViewMock.worldMaxBounds = mapView.projection.projectBox( + geoBounds, + mapView.projection.type === ProjectionType.Planar + ? new THREE.Box3() + : new OrientedBox3() + ); + const distance = 100; + setCamera(camera, mapView.projection, geoTarget, heading, tilt, distance); + + const constrained = MapViewUtils.constrainTargetAndDistanceToViewBounds( + worldTarget, + distance, + mapView + ); + + const constrainedGeoTarget = mapView.projection.unprojectPoint( + constrained.target + ); + expect(geoBounds.contains(constrainedGeoTarget)).to.equal(true); + expect(constrained.distance).equals(distance); + + const newTilt = MapViewUtils.extractTiltAngleFromLocation( + mapView.projection, + camera, + constrainedGeoTarget + ); + expect(THREE.MathUtils.radToDeg(newTilt)).to.be.closeTo(tilt, 1e-3); + }); + }); + }); + } }); describe("tile-offset#Utils", function() { diff --git a/@here/harp-mapview/test/VisibleTileSetTest.ts b/@here/harp-mapview/test/VisibleTileSetTest.ts index c6520dd203..c1be18fb76 100644 --- a/@here/harp-mapview/test/VisibleTileSetTest.ts +++ b/@here/harp-mapview/test/VisibleTileSetTest.ts @@ -14,17 +14,18 @@ import { TilingScheme, webMercatorTilingScheme } from "@here/harp-geoutils"; -import { getOptionValue } from "@here/harp-utils"; +import { getOptionValue, TaskQueue } from "@here/harp-utils"; import { assert, expect } from "chai"; import * as sinon from "sinon"; import * as THREE from "three"; + import { BackgroundDataSource } from "../lib/BackgroundDataSource"; import { createDefaultClipPlanesEvaluator } from "../lib/ClipPlanesEvaluator"; import { DataSource, DataSourceOptions } from "../lib/DataSource"; -import { FrustumIntersection } from "../lib/FrustumIntersection"; +import { FrustumIntersection, TileKeyEntry } from "../lib/FrustumIntersection"; import { TileGeometryCreator } from "../lib/geometry/TileGeometryCreator"; import { TileGeometryManager } from "../lib/geometry/TileGeometryManager"; -import { MapView } from "../lib/MapView"; +import { MapView, TileTaskGroups } from "../lib/MapView"; import { Tile } from "../lib/Tile"; import { TileOffsetUtils } from "../lib/Utils"; import { @@ -39,6 +40,10 @@ import { FakeOmvDataSource } from "./FakeOmvDataSource"; // Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions class FakeMapView { + taskQueue = new TaskQueue({ + groups: [TileTaskGroups.CREATE, TileTaskGroups.FETCH_AND_DECODE] + }); + constructor(readonly projection: Projection) {} get frameNumber(): number { @@ -103,12 +108,14 @@ class Fixture { tileCacheSize: 200, resourceComputationType: ResourceComputationType.EstimationInMb, quadTreeSearchDistanceUp: params.quadTreeSearchDistanceUp ?? 3, - quadTreeSearchDistanceDown: params.quadTreeSearchDistanceDown ?? 2 + quadTreeSearchDistanceDown: params.quadTreeSearchDistanceDown ?? 2, + maxTilesPerFrame: 0 }; this.vts = new VisibleTileSet( this.frustumIntersection, this.tileGeometryManager, - vtsOptions + vtsOptions, + this.mapView.taskQueue ); } @@ -145,7 +152,13 @@ describe("VisibleTileSet", function() { // TODO: Update for new interface of updateRenderList function updateRenderList(zoomLevel: number, storageLevel: number) { - const intersectionCount = fixture.vts.updateRenderList(zoomLevel, storageLevel, fixture.ds); + const frameNumber = 42; + const intersectionCount = fixture.vts.updateRenderList( + zoomLevel, + storageLevel, + fixture.ds, + frameNumber + ); return { tileList: fixture.vts.dataSourceTileList, intersectionCount @@ -189,7 +202,7 @@ describe("VisibleTileSet", function() { * is satellite data or terrain. */ class FakeWebTile extends DataSource { - constructor(private tilingScheme?: TilingScheme) { + constructor(private readonly tilingScheme?: TilingScheme) { super(); } /** @override */ @@ -255,6 +268,50 @@ describe("VisibleTileSet", function() { assert.equal(renderedTiles.size, 0); }); + it("dependencies of tiles are added to visible tile set", function() { + const tileKey1 = TileKey.fromMortonCode(371506850); + const tileKey2 = TileKey.fromMortonCode(371506851); + + // Test where we have 2 actually visible tiles (in the sense that they are in the frustum, + // see visibleTileKeys below) and one visible tile because it is a dependency. + // tslint:disable-next-line: no-string-literal + fixture.vts["getVisibleTileKeysForDataSources"] = sinon.stub().returns({ + tileKeys: [ + { + dataSource: fixture.ds[0], + visibleTileKeys: [new TileKeyEntry(tileKey1, 0), new TileKeyEntry(tileKey2, 0)] + } + ], + allBoundingBoxesFinal: true + }); + + // Adding the dependency to make sure it is visible + const dataSource = fixture.ds[0]; + const tile1 = fixture.vts.getTile(dataSource, tileKey1, 0, 0); + assert.notEqual(tile1, undefined); + const tileKey3 = TileKey.fromMortonCode(371506852); + tile1!.dependencies.push(tileKey3); + + const zoomLevel = 15; + const storageLevel = 14; + + const dataSourceTileList = updateRenderList(zoomLevel, storageLevel).tileList; + + assert.equal(dataSourceTileList.length, 1); + assert.equal(dataSourceTileList[0].visibleTiles.length, 3); + + const visibleTiles = dataSourceTileList[0].visibleTiles; + assert.equal(visibleTiles[0].tileKey.mortonCode(), tileKey1.mortonCode()); + assert.equal(visibleTiles[1].tileKey.mortonCode(), tileKey2.mortonCode()); + assert.equal(visibleTiles[2].tileKey.mortonCode(), tileKey3.mortonCode()); + + // Check that the dependent tile exists in the cache. + assert.notEqual(fixture.vts.getCachedTile(dataSource, tileKey3, 0, 0), undefined); + + const renderedTiles = dataSourceTileList[0].renderedTiles; + assert.equal(renderedTiles.size, 0); + }); + it("#no fallback doesn't put loading tiles in renderedTiles", function() { fixture = new Fixture({ quadTreeSearchDistanceDown: 0, quadTreeSearchDistanceUp: 0 }); setupBerlinCenterCameraFromSamples(); @@ -313,6 +370,8 @@ describe("VisibleTileSet", function() { const zoomLevel = 15; const storageLevel = 14; + const offset = 0; + const frameNumber = 42; // same as first found code few lines below const parentCode = TileKey.parentMortonCode(371506851); @@ -321,7 +380,12 @@ describe("VisibleTileSet", function() { // fake MapView to think that it has already loaded // parent of both found tiles - const parentTile = fixture.vts.getTile(fixture.ds[0], parentTileKey) as Tile; + const parentTile = fixture.vts.getTile( + fixture.ds[0], + parentTileKey, + offset, + frameNumber + ) as Tile; assert.exists(parentTile); parentTile.forceHasGeometry(true); @@ -365,6 +429,8 @@ describe("VisibleTileSet", function() { setupBerlinCenterCameraFromSamples(); const zoomLevel = 15; const storageLevel = 14; + const offset = 0; + const frameNumber = 42; const dataSourceTileList = updateRenderList(zoomLevel, storageLevel).tileList; @@ -373,7 +439,9 @@ describe("VisibleTileSet", function() { const parentTileKey = TileKey.parentMortonCode(371506851); const parentTile = fixture.vts.getTile( fixture.ds[0], - TileKey.fromMortonCode(parentTileKey) + TileKey.fromMortonCode(parentTileKey), + offset, + frameNumber ) as Tile; const parentDisposeSpy = sinon.spy(parentTile, "dispose"); const parentReloadSpy = sinon.spy(parentTile, "load"); @@ -651,4 +719,113 @@ describe("VisibleTileSet", function() { const result = updateRenderList(zoomLevel, storageLevel).tileList; assert.equal(result[0].visibleTiles.length, 100); }); + + it("#updateRenderList get tiles from cache", function() { + const vts = fixture.vts; + const dataSource = fixture.ds[0]; + + const tileKey0 = TileKey.fromMortonCode(371506850); + const tileKey1 = TileKey.fromMortonCode(371506851); + + // Pre-populate cache. + const tile0 = vts.getTile(dataSource, tileKey0, 0, 0); + const tile1 = vts.getTile(dataSource, tileKey1, 0, 0); + + const cachedTile0 = fixture.vts.getCachedTile(dataSource, tileKey0, 0, 0); + const cachedTile1 = fixture.vts.getCachedTile(dataSource, tileKey1, 0, 0); + + // Test that the cached tiles are returned. + assert.equal(tile0, cachedTile0); + assert.equal(tile1, cachedTile1); + }); + + it("#updateRenderList clear all tiles from cache", function() { + const vts = fixture.vts; + const dataSource = fixture.ds[0]; + + const secondDataSource = new FakeOmvDataSource({ name: "omv2" }); + fixture.addDataSource(secondDataSource); + + const tileKey0 = TileKey.fromMortonCode(371506850); + const tileKey1 = TileKey.fromMortonCode(371506851); + + // Pre-populate cache. + assert.notEqual(vts.getTile(dataSource, tileKey0, 0, 0), undefined); + assert.notEqual(vts.getTile(secondDataSource, tileKey1, 0, 0), undefined); + + fixture.vts.clearTileCache(); + + const cachedTile0 = fixture.vts.getCachedTile(dataSource, tileKey0, 0, 0); + const cachedTile1 = fixture.vts.getCachedTile(secondDataSource, tileKey1, 0, 0); + + // Test that the cached tiles are gone. + assert.isUndefined(cachedTile0); + assert.isUndefined(cachedTile1); + }); + + it("#updateRenderList clear tiles from cache by datasource", function() { + const vts = fixture.vts; + const dataSource = fixture.ds[0]; + + const secondDataSource = new FakeOmvDataSource({ name: "omv2" }); + fixture.addDataSource(secondDataSource); + + const tileKey0 = TileKey.fromMortonCode(371506850); + const tileKey1 = TileKey.fromMortonCode(371506851); + + // Pre-populate cache. + assert.notEqual(vts.getTile(dataSource, tileKey0, 0, 0), undefined); + assert.notEqual(vts.getTile(secondDataSource, tileKey1, 0, 0), undefined); + + fixture.vts.clearTileCache(dataSource); + + const cachedTile0 = fixture.vts.getCachedTile(dataSource, tileKey0, 0, 0); + const cachedTile1 = fixture.vts.getCachedTile(secondDataSource, tileKey1, 0, 0); + + // Test that the dataSource's cached tiles are gone. + assert.isUndefined(cachedTile0); + assert.notEqual(cachedTile1, undefined); + }); + + it("#updateRenderList clear tiles from cache by datasource and predicate", function() { + const vts = fixture.vts; + const dataSource = fixture.ds[0]; + + const tileKey0 = TileKey.fromMortonCode(371506850); + const tileKey1 = TileKey.fromMortonCode(371506851); + + // Pre-populate cache. + assert.notEqual(vts.getTile(dataSource, tileKey0, 0, 0), undefined); + assert.notEqual(vts.getTile(dataSource, tileKey1, 0, 0), undefined); + + fixture.vts.clearTileCache(dataSource, tile => tile.tileKey === tileKey0); + + const cachedTile0 = fixture.vts.getCachedTile(dataSource, tileKey0, 0, 0); + const cachedTile1 = fixture.vts.getCachedTile(dataSource, tileKey1, 0, 0); + + // Test that the dataSource's cached tiles that match the tileKey0 are gone. + assert.isUndefined(cachedTile0); + assert.notEqual(cachedTile1, undefined); + }); + + it("#updateRenderList clear tiles from cache by predicate", function() { + const vts = fixture.vts; + const dataSource = fixture.ds[0]; + + const tileKey0 = TileKey.fromMortonCode(371506850); + const tileKey1 = TileKey.fromMortonCode(371506851); + + // Pre-populate cache. + assert.notEqual(vts.getTile(dataSource, tileKey0, 0, 0), undefined); + assert.notEqual(vts.getTile(dataSource, tileKey1, 0, 0), undefined); + + fixture.vts.clearTileCache(undefined, tile => tile.tileKey === tileKey1); + + const cachedTile0 = fixture.vts.getCachedTile(dataSource, tileKey0, 0, 0); + const cachedTile1 = fixture.vts.getCachedTile(dataSource, tileKey1, 0, 0); + + //Test that the cached tiles which have tileKey equal to tileKey1 are gone. + assert.isUndefined(cachedTile1); + assert.notEqual(cachedTile0, undefined); + }); }); diff --git a/@here/harp-mapview/test/resources/headshot.png b/@here/harp-mapview/test/resources/headshot.png index fbdb5b9335..bd0e393540 100644 Binary files a/@here/harp-mapview/test/resources/headshot.png and b/@here/harp-mapview/test/resources/headshot.png differ diff --git a/@here/harp-materials/lib/CirclePointsMaterial.ts b/@here/harp-materials/lib/CirclePointsMaterial.ts index 095dc15455..156c9c4e7d 100644 --- a/@here/harp-materials/lib/CirclePointsMaterial.ts +++ b/@here/harp-materials/lib/CirclePointsMaterial.ts @@ -59,7 +59,7 @@ export interface CirclePointsMaterialParameters extends THREE.ShaderMaterialPara export class CirclePointsMaterial extends THREE.ShaderMaterial { static readonly DEFAULT_CIRCLE_SIZE = 1; - private m_color: THREE.Color; + private readonly m_color: THREE.Color; private m_opacity: number; /** diff --git a/@here/harp-materials/lib/EdgeMaterial.ts b/@here/harp-materials/lib/EdgeMaterial.ts index 39fa4c0c4f..8b0d03f85e 100644 --- a/@here/harp-materials/lib/EdgeMaterial.ts +++ b/@here/harp-materials/lib/EdgeMaterial.ts @@ -19,7 +19,11 @@ import { enforceBlending, setShaderDefine, setShaderMaterialDefine } from "./Uti const vertexSource: string = ` #define EDGE_DEPTH_OFFSET 0.0001 +#ifdef USE_COLOR attribute vec4 color; +#else +uniform vec3 color; +#endif // SHADER_NAME may be defined by THREE.JS own shaders in which case these attributes & uniforms are // already defined @@ -51,11 +55,7 @@ varying vec3 vColor; void main() { - #ifdef USE_COLOR vColor = mix(edgeColor.rgb, color.rgb, edgeColorMix); - #else - vColor = edgeColor.rgb; - #endif vec3 transformed = vec3( position ); @@ -124,6 +124,12 @@ export interface EdgeMaterialParameters * Color mix value. Mixes between vertexColors and edgeColor. */ colorMix?: number; + + /** + * Defines whether vertex coloring is used. + * @defaultValue false + */ + vertexColors?: boolean; } /** @@ -154,12 +160,15 @@ export class EdgeMaterial extends THREE.RawShaderMaterial if (hasExtrusion) { setShaderDefine(defines, "USE_EXTRUSION", true); } - + if (params?.vertexColors === true) { + setShaderDefine(defines, "USE_COLOR", true); + } const shaderParams = { name: "EdgeMaterial", vertexShader: vertexSource, fragmentShader: fragmentSource, uniforms: { + color: new THREE.Uniform(new THREE.Color(EdgeMaterial.DEFAULT_COLOR)), edgeColor: new THREE.Uniform(new THREE.Color(EdgeMaterial.DEFAULT_COLOR)), edgeColorMix: new THREE.Uniform(EdgeMaterial.DEFAULT_COLOR_MIX), fadeNear: new THREE.Uniform(FadingFeature.DEFAULT_FADE_NEAR), @@ -204,6 +213,28 @@ export class EdgeMaterial extends THREE.RawShaderMaterial } } + /** + * The color of the object that is rendered + * together with this edge. + * + * @remarks + * The final color of the edge is computed by + * interpolating the {@link edgeColor} with this color + * using the {@link colorMix} factor. + * + * Note that {@link objectColor} is used only + * when the geometry associated with this material + * does not have a vertex color buffer. + * + */ + get objectColor(): THREE.Color { + return this.uniforms.color.value as THREE.Color; + } + + set objectColor(value: THREE.Color) { + this.uniforms.color.value.copy(value); + } + /** * Edge color. */ @@ -225,7 +256,6 @@ export class EdgeMaterial extends THREE.RawShaderMaterial return; } this.uniforms.edgeColorMix.value = value; - setShaderMaterialDefine(this, "USE_COLOR", value > 0.0); } get fadeNear(): number { diff --git a/@here/harp-materials/lib/IconMaterial.ts b/@here/harp-materials/lib/IconMaterial.ts index 5be89796b4..ce00b57207 100644 --- a/@here/harp-materials/lib/IconMaterial.ts +++ b/@here/harp-materials/lib/IconMaterial.ts @@ -76,8 +76,7 @@ export class IconMaterial extends THREE.RawShaderMaterial { transparent: true, vertexColors: true, - premultipliedAlpha: true, - blending: THREE.NormalBlending + premultipliedAlpha: true }; super(shaderParams); } diff --git a/@here/harp-materials/lib/SolidLineMaterial.ts b/@here/harp-materials/lib/SolidLineMaterial.ts index b9ec3ff1b5..57533b01a3 100644 --- a/@here/harp-materials/lib/SolidLineMaterial.ts +++ b/@here/harp-materials/lib/SolidLineMaterial.ts @@ -228,7 +228,17 @@ void main() { // Decrease the line opacity by the distToEdge, making the transition steeper when the slope // of distToChange increases (i.e. the line is further away). float width = fwidth(distToEdge); - alpha *= (1.0 - smoothstep(-width, width, distToEdge)); + + float s = opacity < 0.98 + ? clamp((distToEdge + width) / (2.0 * width), 0.0, 1.0) // prefer a boxstep + : smoothstep(-width, width, distToEdge); + + if (opacity < 0.98 && 1.0 - s < opacity) { + // drop the fragment when the line is using opacity. + discard; + } + + alpha *= 1.0 - s; #ifdef USE_DASHED_LINE // Compute the distance to the dash origin (0.0: dashOrigin, 1.0: dashEnd, (d+g)/d: gapEnd). @@ -496,6 +506,12 @@ export class SolidLineMaterial extends THREE.RawShaderMaterial this.m_fog = fogParam; this.m_opacity = opacityParam; + // initialize the stencil pass + this.stencilFunc = THREE.NotEqualStencilFunc; + this.stencilZPass = THREE.ReplaceStencilOp; + this.stencilRef = 1; + this.stencilWrite = false; + enforceBlending(this); this.extensions.derivatives = true; @@ -631,6 +647,8 @@ export class SolidLineMaterial extends THREE.RawShaderMaterial if (this.uniforms !== undefined) { this.uniforms.opacity.value = value; } + + this.stencilWrite = this.m_opacity < 0.98; } /** @@ -714,6 +732,10 @@ export class SolidLineMaterial extends THREE.RawShaderMaterial set gapSize(value: number) { this.uniforms.gapSize.value = value; setShaderMaterialDefine(this, "USE_DASHED_LINE", value > 0.0); + + if (this.uniforms?.gapSize?.value === 0) { + this.stencilWrite = this.m_opacity < 0.98; + } } /** diff --git a/@here/harp-materials/lib/Utils.ts b/@here/harp-materials/lib/Utils.ts index bfd918a82d..25c937cf45 100644 --- a/@here/harp-materials/lib/Utils.ts +++ b/@here/harp-materials/lib/Utils.ts @@ -81,7 +81,7 @@ export function enforceBlending( export function enableBlending( material: (THREE.Material | THREE.ShaderMaterialParameters) & ForcedBlending ) { - if (material.transparent || material.forcedBlending) { + if (material.transparent === true || material.forcedBlending === true) { // Nothing to do return; } @@ -111,7 +111,7 @@ export function enableBlending( export function disableBlending( material: (THREE.Material | THREE.ShaderMaterialParameters) & ForcedBlending ) { - if (material.transparent || material.forcedBlending) { + if (material.transparent === true || material.forcedBlending === true) { // Nothing to do return; } diff --git a/@here/harp-olp-utils/lib/OlpCopyrightProvider.ts b/@here/harp-olp-utils/lib/OlpCopyrightProvider.ts index 63bd65f506..d259332974 100644 --- a/@here/harp-olp-utils/lib/OlpCopyrightProvider.ts +++ b/@here/harp-olp-utils/lib/OlpCopyrightProvider.ts @@ -62,7 +62,7 @@ export class OlpCopyrightProvider extends CopyrightCoverageProvider { * Default constructor. * @param m_params - Copyright provider configuration. */ - constructor(private m_params: OlpCopyrightProviderParams) { + constructor(private readonly m_params: OlpCopyrightProviderParams) { super(); } @@ -79,7 +79,7 @@ export class OlpCopyrightProvider extends CopyrightCoverageProvider { const hrn = HRN.fromString(this.m_params.hrn); const settings = new OlpClientSettings({ getToken: this.m_params.getToken, - environment: this.m_params.environment || hrn.data.partition + environment: this.m_params.environment ?? hrn.data.partition }); const client = new VersionedLayerClient( hrn, diff --git a/@here/harp-omv-datasource/lib/DecodeInfo.ts b/@here/harp-omv-datasource/lib/DecodeInfo.ts index 9be9276146..8025a1df89 100644 --- a/@here/harp-omv-datasource/lib/DecodeInfo.ts +++ b/@here/harp-omv-datasource/lib/DecodeInfo.ts @@ -52,6 +52,16 @@ export class DecodeInfo { */ readonly tileSizeOnScreen: number; + /** + * The maximum number of columns. + */ + readonly columnCount: number; + + /** + * The maximum number of rows. + */ + readonly rowCount: number; + /** * Constructs a new [[DecodeInfo]]. * @@ -72,6 +82,14 @@ export class DecodeInfo { this.tilingScheme.getWorldBox(tileKey, this.tileBounds); this.tileBounds.getSize(this.tileSize); this.tileSizeOnScreen = 256 * Math.pow(2, -this.storageLevelOffset); + + this.columnCount = webMercatorTilingScheme.subdivisionScheme.getLevelDimensionX( + this.tileKey.level + ); + + this.rowCount = webMercatorTilingScheme.subdivisionScheme.getLevelDimensionY( + this.tileKey.level + ); } /** diff --git a/@here/harp-omv-datasource/lib/GeoJsonDataProvider.ts b/@here/harp-omv-datasource/lib/GeoJsonDataProvider.ts index 5113215293..b7c2124ce0 100644 --- a/@here/harp-omv-datasource/lib/GeoJsonDataProvider.ts +++ b/@here/harp-omv-datasource/lib/GeoJsonDataProvider.ts @@ -11,19 +11,25 @@ import { ConcurrentTilerFacade } from "@here/harp-mapview"; import { DataProvider } from "@here/harp-mapview-decoder"; import { LoggerManager } from "@here/harp-utils"; +import { EventDispatcher } from "three"; + const logger = LoggerManager.instance.create("GeoJsonDataProvider"); +const INVALIDATED = "invalidated"; + export interface GeoJsonDataProviderOptions { /** - * Worker script hosting [[Tiler Service]] + * Worker script hosting `Tiler` service. * @default `./decoder.bundle.ts` */ workerTilerUrl?: string; /** * Custom tiler instance. - * If not provided, [[GeoJsonDataProvider]] will obtain [[WorkerBasedTiler]] - * from [[ConcurrentTilerFacade]]. + * + * @remarks + * If not provided, {@link GeoJsonDataProvider} will obtain `WorkerBasedTiler` + * from `ConcurrentTilerFacade`. */ tiler?: ITiler; } @@ -31,10 +37,13 @@ export interface GeoJsonDataProviderOptions { let missingTilerServiceInfoEmitted: boolean = false; /** - * GeoJson [[DataProvider]]. Automatically handles tiling and simplification of static GeoJson. + * GeoJson {@link @here/harp-mapview-decoder@DataProvider}. + * + * @remarks + * Automatically handles tiling and simplification of static GeoJson. */ -export class GeoJsonDataProvider implements DataProvider { - private m_tiler: ITiler; +export class GeoJsonDataProvider extends EventDispatcher implements DataProvider { + private readonly m_tiler: ITiler; private m_registered = false; /** @@ -50,8 +59,10 @@ export class GeoJsonDataProvider implements DataProvider { public input: URL | GeoJson, options?: GeoJsonDataProviderOptions ) { + super(); + this.m_tiler = - (options && options.tiler) || + options?.tiler ?? ConcurrentTilerFacade.getTiler("omv-tiler", options && options.workerTilerUrl); } @@ -79,6 +90,7 @@ export class GeoJsonDataProvider implements DataProvider { updateInput(input: URL | GeoJson) { this.input = input; this.m_tiler.updateIndex(this.name, this.input); + this.dispatchEvent({ type: INVALIDATED }); } ready(): boolean { @@ -88,4 +100,9 @@ export class GeoJsonDataProvider implements DataProvider { async getTile(tileKey: TileKey): Promise<{}> { return this.m_tiler.getTile(this.name, tileKey); } + + onDidInvalidate(listener: () => void) { + this.addEventListener(INVALIDATED, listener); + return () => this.removeEventListener(INVALIDATED, listener); + } } diff --git a/@here/harp-omv-datasource/lib/OmvData.ts b/@here/harp-omv-datasource/lib/OmvData.ts index a8a941dda9..f8e8472756 100644 --- a/@here/harp-omv-datasource/lib/OmvData.ts +++ b/@here/harp-omv-datasource/lib/OmvData.ts @@ -323,7 +323,7 @@ function createFeatureEnv( const attributes: ValueMap = { $layer: layer.name, $level: storageLevel, - $zoom: Math.max(0, storageLevel - (storageLevelOffset || 0)), + $zoom: Math.max(0, storageLevel - (storageLevelOffset ?? 0)), $geometryType: geometryType }; @@ -455,7 +455,7 @@ export class OmvProtobufDataAdapter implements OmvDataAdapter, OmvVisitor { const storageLevel = this.m_tileKey.level; const layerName = this.m_layer.name; - const layerExtents = this.m_layer.extent || 4096; + const layerExtents = this.m_layer.extent ?? 4096; if ( this.m_dataFilter !== undefined && @@ -502,7 +502,7 @@ export class OmvProtobufDataAdapter implements OmvDataAdapter, OmvVisitor { const storageLevel = this.m_tileKey.level; const layerName = this.m_layer.name; - const layerExtents = this.m_layer.extent || 4096; + const layerExtents = this.m_layer.extent ?? 4096; if ( this.m_dataFilter !== undefined && @@ -553,7 +553,7 @@ export class OmvProtobufDataAdapter implements OmvDataAdapter, OmvVisitor { const storageLevel = this.m_tileKey.level; const layerName = this.m_layer.name; - const layerExtents = this.m_layer.extent || 4096; + const layerExtents = this.m_layer.extent ?? 4096; if ( this.m_dataFilter !== undefined && diff --git a/@here/harp-omv-datasource/lib/OmvDataFilter.ts b/@here/harp-omv-datasource/lib/OmvDataFilter.ts index 33fdedaaaa..f8b35668f7 100644 --- a/@here/harp-omv-datasource/lib/OmvDataFilter.ts +++ b/@here/harp-omv-datasource/lib/OmvDataFilter.ts @@ -124,10 +124,10 @@ export interface OmvFeatureModifier { * */ export class OmvFeatureFilterDescriptionBuilder { - private m_processLayersDefault: boolean = true; - private m_processPointsDefault: boolean = true; - private m_processLinesDefault: boolean = true; - private m_processPolygonsDefault: boolean = true; + private readonly m_processLayersDefault: boolean = true; + private readonly m_processPointsDefault: boolean = true; + private readonly m_processLinesDefault: boolean = true; + private readonly m_processPolygonsDefault: boolean = true; private readonly m_layersToProcess = new Array(); private readonly m_layersToIgnore = new Array(); @@ -563,10 +563,10 @@ export class OmvGenericFeatureFilter implements OmvFeatureFilter { return false; } - private disabledKinds: GeometryKindSet | undefined; - private enabledKinds: GeometryKindSet | undefined; + private readonly disabledKinds: GeometryKindSet | undefined; + private readonly enabledKinds: GeometryKindSet | undefined; - constructor(private description: OmvFeatureFilterDescription) { + constructor(private readonly description: OmvFeatureFilterDescription) { if (this.description.kindsToProcess.length > 0) { this.enabledKinds = new GeometryKindSet( this.description.kindsToProcess as GeometryKind[] @@ -768,7 +768,7 @@ export class OmvGenericFeatureModifier implements OmvFeatureModifier { return false; } - constructor(private description: OmvFeatureFilterDescription) {} + constructor(private readonly description: OmvFeatureFilterDescription) {} doProcessPointFeature(layer: string, env: MapEnv): boolean { return this.doProcessFeature( diff --git a/@here/harp-omv-datasource/lib/OmvDataSource.ts b/@here/harp-omv-datasource/lib/OmvDataSource.ts index 6b02250096..102c054776 100644 --- a/@here/harp-omv-datasource/lib/OmvDataSource.ts +++ b/@here/harp-omv-datasource/lib/OmvDataSource.ts @@ -190,7 +190,7 @@ function getDataProvider(params: OmvWithRestClientParams | OmvWithCustomDataProv if ((params as OmvWithCustomDataProvider).dataProvider) { return (params as OmvWithCustomDataProvider).dataProvider; } else if ( - (params as OmvWithRestClientParams).baseUrl || + (params as OmvWithRestClientParams).baseUrl ?? (params as OmvWithRestClientParams).url ) { return new OmvRestClient(params as OmvRestClientParameters); @@ -277,8 +277,8 @@ function completeDataSourceParameters( export class OmvDataSource extends TileDataSource { private readonly m_decoderOptions: OmvDecoderOptions; - constructor(private m_params: OmvWithRestClientParams | OmvWithCustomDataProvider) { - super(m_params.tileFactory || new TileFactory(OmvTile), { + constructor(private readonly m_params: OmvWithRestClientParams | OmvWithCustomDataProvider) { + super(m_params.tileFactory ?? new TileFactory(OmvTile), { styleSetName: m_params.styleSetName ?? "omv", concurrentDecoderServiceName: OMV_TILE_DECODER_SERVICE_TYPE, minDataLevel: m_params.minDataLevel ?? 1, diff --git a/@here/harp-omv-datasource/lib/OmvDebugLabelsTile.ts b/@here/harp-omv-datasource/lib/OmvDebugLabelsTile.ts index b7914e43e0..95afffdac2 100644 --- a/@here/harp-omv-datasource/lib/OmvDebugLabelsTile.ts +++ b/@here/harp-omv-datasource/lib/OmvDebugLabelsTile.ts @@ -301,9 +301,9 @@ export class OmvDebugLabelsTile extends OmvTile { ), textRenderStyle, textLayoutStyle, - getPropertyValue(technique.priority || 0, env), - technique.xOffset || 0.0, - technique.yOffset || 0.0 + getPropertyValue(technique.priority ?? 0, env), + technique.xOffset ?? 0.0, + technique.yOffset ?? 0.0 ); labelElement.minZoomLevel = technique.minZoomLevel; labelElement.mayOverlap = true; diff --git a/@here/harp-omv-datasource/lib/OmvDecodedTileEmitter.ts b/@here/harp-omv-datasource/lib/OmvDecodedTileEmitter.ts index 373893a7c0..f12786dc55 100644 --- a/@here/harp-omv-datasource/lib/OmvDecodedTileEmitter.ts +++ b/@here/harp-omv-datasource/lib/OmvDecodedTileEmitter.ts @@ -65,13 +65,14 @@ import { import { ILineGeometry, IPolygonGeometry } from "./IGeometryProcessor"; import { LinesGeometry } from "./OmvDataSource"; -import { IOmvEmitter, Ring } from "./OmvDecoder"; +import { IOmvEmitter } from "./OmvDecoder"; import { tile2world, webMercatorTile2TargetTile, webMercatorTile2TargetWorld, world2tile } from "./OmvUtils"; +import { Ring } from "./Ring"; import { AttrEvaluationContext, @@ -177,6 +178,11 @@ class MeshBuffers implements IMeshBuffers { */ readonly featureStarts: number[] = []; + /** + * Optional list of edge feature start indices. The indices point into the edge index attribute. + */ + readonly edgeFeatureStarts: number[] = []; + /** * An optional list of additional data that can be used as additional data for the object * picking. @@ -207,7 +213,7 @@ export enum LineType { Complex } -type TexCoordsFunction = (tilePos: THREE.Vector2, tileExtents: number) => { u: number; v: number }; +type TexCoordsFunction = (tilePos: THREE.Vector2, tileExtents: number) => THREE.Vector2; const tmpColor = new THREE.Color(); export class OmvDecodedTileEmitter implements IOmvEmitter { @@ -261,6 +267,23 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { const env = context.env; this.processFeatureCommon(env); + const { tileKey, columnCount, rowCount } = this.m_decodeInfo; + + // adjust the extents to ensure that points on the right and bottom edges + // of the tile are discarded. + const xextent = tileKey.column + 1 < columnCount ? extents - 1 : extents; + const yextent = tileKey.row + 1 < rowCount ? extents - 1 : extents; + + // get the point positions (in tile space) that are inside the tile bounds. + const tilePositions = geometry.filter(p => { + return p.x >= 0 && p.x <= xextent && p.y >= 0 && p.y <= yextent; + }); + + if (tilePositions.length === 0) { + // nothing to do, no geometry within the tile bound. + return; + } + for (const technique of techniques) { if (technique === undefined) { continue; @@ -298,7 +321,7 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { } const featureId = getFeatureId(env.entries); - for (const pos of geometry) { + for (const pos of tilePositions) { if (shouldCreateTextGeometries) { const textTechnique = technique as TextTechnique; const text = getFeatureText(context, textTechnique, this.m_languages); @@ -443,14 +466,8 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { worldLine.push(tmpV4.x, tmpV4.y, tmpV4.z); if (computeTexCoords) { - { - const { u, v } = computeTexCoords(pos1, extents); - lineUvs.push(u, v); - } - { - const { u, v } = computeTexCoords(pos2, extents); - lineUvs.push(u, v); - } + computeTexCoords(pos1, extents).toArray(lineUvs, lineUvs.length); + computeTexCoords(pos2, extents).toArray(lineUvs, lineUvs.length); } if (hasUntiledLines) { // Find where in the [0...1] range relative to the line our current @@ -503,8 +520,7 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { worldLine.push(tmpV3.x, tmpV3.y, tmpV3.z); if (computeTexCoords) { - const { u, v } = computeTexCoords(pos, extents); - lineUvs.push(u, v); + computeTexCoords(pos, extents).toArray(lineUvs, lineUvs.length); } if (hasUntiledLines) { // Find where in the [0...1] range relative to the line our current vertex @@ -782,34 +798,29 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { isStandard || (isShaderTechnique(technique) && technique.primitive === "mesh"); const computeTexCoords = this.getComputeTexCoordsFunc(technique, objectBounds); - const vertexStride = computeTexCoords !== undefined ? 4 : 2; - - let clipRing: THREE.Vector2[] | undefined; for (const polygon of geometry) { const rings: Ring[] = []; for (const outline of polygon.rings) { - const ringContour: number[] = []; - let coords = outline; - - if (isFilled || isStandard) { + let clippedPointIndices: Set | undefined; + + // disable clipping for the polygon geometries + // rendered using the extruded-polygon technique. + // We can't clip these polygons for now because + // otherwise we could break the current assumptions + // used to add oultines around the extruded geometries. + if (isPolygon && !isExtruded) { const shouldClipPolygon = coords.some( p => p.x < 0 || p.x > extents || p.y < 0 || p.y > extents ); if (shouldClipPolygon) { - if (!clipRing) { - clipRing = [ - new THREE.Vector2(0, 0), - new THREE.Vector2(extents, 0), - new THREE.Vector2(extents, extents), - new THREE.Vector2(0, extents) - ]; - } - - coords = clipPolygon(coords, clipRing); + coords = clipPolygon(coords, extents); + clippedPointIndices = Ring.computeClippedPointIndices(coords, outline); + } else { + clippedPointIndices = new Set(); } } @@ -817,15 +828,13 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { continue; } - for (const coord of coords) { - ringContour.push(coord.x, coord.y); - if (computeTexCoords !== undefined) { - const { u, v } = computeTexCoords(coord, extents); - ringContour.push(u, v); - } + let textureCoords: THREE.Vector2[] | undefined; + + if (computeTexCoords !== undefined) { + textureCoords = coords.map(coord => computeTexCoords(coord, extents)); } - rings.push(new Ring(extents, vertexStride, ringContour)); + rings.push(new Ring(coords, textureCoords, extents, clippedPointIndices)); } if (rings.length === 0) { @@ -861,7 +870,7 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { ? [] : undefined; rings.forEach(ring => { - const length = ring.contour.length / ring.vertexStride; + const length = ring.points.length; let line: number[] = []; // Compute length of whole line and offsets of individual segments. @@ -879,26 +888,19 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { } const nextIdx = (i + 1) % length; - const currX = ring.contour[i * ring.vertexStride]; - const currY = ring.contour[i * ring.vertexStride + 1]; - const nextX = ring.contour[nextIdx * ring.vertexStride]; - const nextY = ring.contour[nextIdx * ring.vertexStride + 1]; - - const isOutline = !( - (currX <= 0 && nextX <= 0) || - (currX >= ring.extents && nextX >= ring.extents) || - (currY <= 0 && nextY <= 0) || - (currY >= ring.extents && nextY >= ring.extents) - ); + const curr = ring.points[i]; + const next = ring.points[nextIdx]; + + const properEdge = ring.isProperEdge(i); - if (!isOutline && line.length !== 0) { + if (!properEdge && line.length !== 0) { lines.push(line); line = []; - } else if (isOutline && line.length === 0) { + } else if (properEdge && line.length === 0) { webMercatorTile2TargetTile( extents, this.m_decodeInfo, - tmpV2.set(currX, currY), + tmpV2.copy(curr), tmpV3 ); line.push(tmpV3.x, tmpV3.y, tmpV3.z); @@ -908,7 +910,7 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { webMercatorTile2TargetTile( extents, this.m_decodeInfo, - tmpV2.set(nextX, nextY), + tmpV2.copy(next), tmpV4 ); line.push(tmpV4.x, tmpV4.y, tmpV4.z); @@ -922,11 +924,11 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { segmentOffsets!.push(lastSegmentOffset); } } - if (isOutline && !needIndividualLineSegments) { + if (properEdge && !needIndividualLineSegments) { webMercatorTile2TargetTile( extents, this.m_decodeInfo, - tmpV2.set(nextX, nextY), + tmpV2.copy(next), tmpV3 ); line.push(tmpV3.x, tmpV3.y, tmpV3.z); @@ -1138,36 +1140,37 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { switch (texCoordType) { case TextureCoordinateType.TileSpace: - return (tilePos: THREE.Vector2, tileExtents: number) => { - const { x: u, y: v } = new THREE.Vector2() - .copy(tilePos) - .divideScalar(tileExtents); - return { u, v: 1 - v }; + return (tilePos: THREE.Vector2, tileExtents: number): THREE.Vector2 => { + const uv = tilePos.clone().divideScalar(tileExtents); + uv.y = 1 - uv.y; + return uv; }; case TextureCoordinateType.EquirectangularSpace: - return (tilePos: THREE.Vector2, extents: number) => { + return (tilePos: THREE.Vector2, extents: number): THREE.Vector2 => { const worldPos = tile2world(extents, this.m_decodeInfo, tilePos, false, tmpV2r); - const { x: u, y: v } = normalizedEquirectangularProjection.reprojectPoint( + const uv = normalizedEquirectangularProjection.reprojectPoint( webMercatorProjection, new THREE.Vector3(worldPos.x, worldPos.y, 0) ); - return { u, v }; + return new THREE.Vector2(uv.x, uv.y); }; case TextureCoordinateType.FeatureSpace: if (!objectBounds) { return undefined; } - return (tilePos: THREE.Vector2, extents: number) => { - const uv = tile2world(extents, this.m_decodeInfo, tilePos, false, tmpV2r); + return (tilePos: THREE.Vector2, extents: number): THREE.Vector2 => { + const uv = new THREE.Vector2(); + tile2world(extents, this.m_decodeInfo, tilePos, false, uv); if (objectBounds) { uv.x -= objectBounds.min.x; uv.y -= objectBounds.min.y; uv.x /= objectBounds.max.x - objectBounds.min.x; uv.y /= objectBounds.max.y - objectBounds.min.y; } - return { u: uv.x, v: 1 - uv.y }; + uv.y = 1 - uv.y; + return uv; }; default: @@ -1197,13 +1200,7 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { lines: lineGroup }; - const techniqueTransient = evaluateTechniqueAttr( - context, - technique.transient, - false - ); - if (!techniqueTransient && this.m_gatherFeatureAttributes) { - // if this technique is transient, do not save the featureIds with the geometry + if (this.m_gatherFeatureAttributes) { aLine.objInfos = [featureAttributes]; aLine.featureStarts = [0]; } @@ -1244,6 +1241,10 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { context: AttrEvaluationContext, extents: number ): void { + if (polygons.length === 0) { + return; + } + const isExtruded = isExtrudedPolygonTechnique(technique); const geometryType = isExtruded ? GeometryType.ExtrudedPolygon : GeometryType.Polygon; @@ -1310,14 +1311,12 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { groups } = meshBuffers; - const featureStride = texCoordType !== undefined ? 4 : 2; - const vertexStride = featureStride + 2; const isSpherical = this.m_decodeInfo.targetProjection.type === ProjectionType.Spherical; const edgeWidth = isExtruded ? extrudedPolygonTechnique.lineWidth || 0.0 : isFilled - ? fillTechnique.lineWidth || 0.0 + ? fillTechnique.lineWidth ?? 0.0 : 0.0; const hasEdges = edgeWidth > 0.0; @@ -1350,35 +1349,37 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { for (const polygon of polygons) { const startIndexCount = indices.length; + const edgeStartIndexCount = edgeIndices.length; for (let ringIndex = 0; ringIndex < polygon.length; ) { const vertices: number[] = []; const polygonBaseVertex = positions.length / 3; - const { contour, winding } = polygon[ringIndex++]; - for (let i = 0; i < contour.length / featureStride; ++i) { + const ring = polygon[ringIndex++]; + + const featureStride = ring.vertexStride; + const vertexStride = featureStride + 2; + const winding = ring.winding; + + for (let i = 0; i < ring.points.length; ++i) { + const point = ring.points[i]; + // Invert the Y component to preserve the correct winding without transforming // from webMercator's local to global space. - for (let j = 0; j < featureStride; ++j) { - vertices.push((j === 1 ? -1 : 1) * contour[i * featureStride + j]); + vertices.push(point.x, -point.y); + + if (ring.textureCoords !== undefined) { + vertices.push(ring.textureCoords[i].x, ring.textureCoords[i].y); } - // Calculate nextEdge and nextWall. - const nextIdx = (i + 1) % (contour.length / featureStride); - const currX = contour[i * featureStride]; - const currY = contour[i * featureStride + 1]; - const nextX = contour[nextIdx * featureStride]; - const nextY = contour[nextIdx * featureStride + 1]; - const insideExtents = !( - (currX <= 0 && nextX <= 0) || - (currX >= extents && nextX >= extents) || - (currY <= 0 && nextY <= 0) || - (currY >= extents && nextY >= extents) - ); + const nextIdx = (i + 1) % ring.points.length; + + const properEdge = ring.isProperEdge(i); + // Calculate nextEdge and nextWall. vertices.push( - insideExtents ? nextIdx : -1, - boundaryWalls || insideExtents ? nextIdx : -1 + properEdge ? nextIdx : -1, + boundaryWalls || properEdge ? nextIdx : -1 ); } @@ -1389,26 +1390,21 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { const vertexOffset = vertices.length / vertexStride; holes.push(vertexOffset); - const hole = polygon[ringIndex++].contour; - for (let i = 0; i < hole.length / featureStride; ++i) { + const hole = polygon[ringIndex++]; + for (let i = 0; i < hole.points.length; ++i) { + const nextIdx = (i + 1) % hole.points.length; + const point = hole.points[i]; + // Invert the Y component to preserve the correct winding without // transforming from webMercator's local to global space. - for (let j = 0; j < featureStride; ++j) { - vertices.push((j === 1 ? -1 : 1) * hole[i * featureStride + j]); + vertices.push(point.x, -point.y); + + if (hole.textureCoords !== undefined) { + vertices.push(hole.textureCoords[i].x, hole.textureCoords[i].y); } // Calculate nextEdge and nextWall. - const nextIdx = (i + 1) % (hole.length / featureStride); - const currX = hole[i * featureStride]; - const currY = hole[i * featureStride + 1]; - const nextX = hole[nextIdx * featureStride]; - const nextY = hole[nextIdx * featureStride + 1]; - const insideExtents = !( - (currX <= 0 && nextX <= 0) || - (currX >= extents && nextX >= extents) || - (currY <= 0 && nextY <= 0) || - (currY >= extents && nextY >= extents) - ); + const insideExtents = hole.isProperEdge(i); vertices.push( insideExtents ? vertexOffset + nextIdx : -1, @@ -1647,6 +1643,7 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { if (this.m_gatherFeatureAttributes) { meshBuffers.objInfos.push(context.env.entries); meshBuffers.featureStarts.push(startIndexCount); + meshBuffers.edgeFeatureStarts.push(edgeStartIndexCount); } const count = indices.length - startIndexCount; @@ -1819,6 +1816,7 @@ export class OmvDecodedTileEmitter implements IOmvEmitter { } geometry.featureStarts = meshBuffers.featureStarts; + geometry.edgeFeatureStarts = meshBuffers.edgeFeatureStarts; geometry.objInfos = meshBuffers.objInfos; this.m_geometries.push(geometry); diff --git a/@here/harp-omv-datasource/lib/OmvDecoder.ts b/@here/harp-omv-datasource/lib/OmvDecoder.ts index b015b23078..18dbefb311 100644 --- a/@here/harp-omv-datasource/lib/OmvDecoder.ts +++ b/@here/harp-omv-datasource/lib/OmvDecoder.ts @@ -49,41 +49,6 @@ import { VTJsonDataAdapter } from "./VTJsonDataAdapter"; const logger = LoggerManager.instance.create("OmvDecoder", { enabled: false }); -export class Ring { - readonly winding: boolean; - - /** - * Constructs a new [[Ring]]. - * - * @param extents - The extents of the enclosing layer. - * @param vertexStride - The stride of this elements stored in 'contour'. - * @param contour - The [[Array]] containing the projected world coordinates. - */ - constructor( - readonly extents: number, - readonly vertexStride: number, - readonly contour: number[] - ) { - this.winding = this.area() < 0; - } - - area(): number { - const points = this.contour; - const stride = this.vertexStride; - const n = points.length / stride; - - let area = 0.0; - - for (let p = n - 1, q = 0; q < n; p = q++) { - area += - points[p * stride] * points[q * stride + 1] - - points[q * stride] * points[p * stride + 1]; - } - - return area / 2; - } -} - export interface IOmvEmitter { processPointFeature( layer: string, diff --git a/@here/harp-omv-datasource/lib/OmvRestClient.ts b/@here/harp-omv-datasource/lib/OmvRestClient.ts index 5e6d64e8d3..227795e529 100644 --- a/@here/harp-omv-datasource/lib/OmvRestClient.ts +++ b/@here/harp-omv-datasource/lib/OmvRestClient.ts @@ -395,7 +395,7 @@ export class OmvRestClient implements DataProvider { if (authenticationCode === undefined) { return url; } - const authMethod = this.params.authenticationMethod || this.getDefaultAuthMethod(); + const authMethod = this.params.authenticationMethod ?? this.getDefaultAuthMethod(); if (authMethod === undefined) { return url; } @@ -404,10 +404,10 @@ export class OmvRestClient implements DataProvider { if (init.headers === undefined) { init.headers = new Headers(); } - const authType = authMethod.name || "Bearer"; + const authType = authMethod.name ?? "Bearer"; (init.headers as Headers).append("Authorization", `${authType} ${authenticationCode}`); } else if (authMethod.method === AuthenticationMethod.QueryString) { - const attrName: string = authMethod.name || "access_token"; + const attrName: string = authMethod.name ?? "access_token"; const authParams: { [key: string]: string } = {}; authParams[attrName] = authenticationCode; url = this.addQueryParams(url, authParams); diff --git a/@here/harp-omv-datasource/lib/OmvUtils.ts b/@here/harp-omv-datasource/lib/OmvUtils.ts index 56604be386..c852e90e30 100644 --- a/@here/harp-omv-datasource/lib/OmvUtils.ts +++ b/@here/harp-omv-datasource/lib/OmvUtils.ts @@ -7,7 +7,6 @@ import { EarthConstants, webMercatorProjection } from "@here/harp-geoutils"; import * as THREE from "three"; import { DecodeInfo } from "./DecodeInfo"; -import { VTJsonDataAdapterId } from "./VTJsonDataAdapter"; /** * @hidden @@ -23,12 +22,8 @@ export function isArrayBufferLike(data: any): data is ArrayBufferLike { /** * @hidden */ -export function lat2tile( - lat: number, - zoom: number, - func: (x: number) => number = Math.floor -): number { - return func( +export function lat2tile(lat: number, zoom: number): number { + return Math.round( ((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / @@ -59,12 +54,8 @@ export function createWorldTileTransformationCookie(extents: number, decodeInfo: return { extents, scale, - top: lat2tile( - north, - decodeInfo.tileKey.level + N, - decodeInfo.adapterId === VTJsonDataAdapterId ? Math.round : Math.floor - ), - left: ((west + 180) / 360) * scale + top: lat2tile(north, decodeInfo.tileKey.level + N), + left: Math.round(((west + 180) / 360) * scale) }; } diff --git a/@here/harp-omv-datasource/lib/Ring.ts b/@here/harp-omv-datasource/lib/Ring.ts new file mode 100644 index 0000000000..1c8fb3eaf9 --- /dev/null +++ b/@here/harp-omv-datasource/lib/Ring.ts @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ShapeUtils, Vector2 } from "three"; + +/** + * A class representing a ring of a polygon geometry. + */ +export class Ring { + /** + * Returns a `Set` containing the indices of the elements + * of `clippedPoints` that are clipped (not included in `originalPoints`). + * + * @param clippedPoints `Array` of clipped positions. + * @param originalPoints `Array` of unclipped positions. + */ + static computeClippedPointIndices( + clippedPoints: Vector2[], + originalPoints: Vector2[] + ): Set { + const isClipped = (p: THREE.Vector2) => originalPoints.find(q => q.equals(p)) === undefined; + return new Set(clippedPoints.map((p, i) => (isClipped(p) ? i : -1)).filter(i => i !== -1)); + } + + /** + * The signed area of this `Ring`. + * + * @remarks + * The sign of the area depends on the projection and the axis orientation + * of the ring coordinates. + */ + readonly area: number; + + /** + * The winding of this `Ring`. + * + * @remarks + * Derived from the sign of the `area` of this Ring. + */ + readonly winding: boolean; + + /** + * The vertex stride. + */ + readonly vertexStride: number; + + /** + * Creates a new `Ring`. + * + * @param points The coordinates of the rings. + * @param textureCoords The optional `Array` of texture coordinates. + * @param extents The extents of the tile bounds. + * @param clippedPointIndices Optional `Set` containing the indices of the clipped points. + */ + constructor( + readonly points: Vector2[], + readonly textureCoords?: Vector2[], + readonly extents: number = 4 * 1024, + readonly clippedPointIndices?: Set + ) { + if (textureCoords !== undefined && textureCoords.length !== points.length) { + throw new Error( + `the array of texture coordinates must have the same number of elements of the array of points` + ); + } + + this.vertexStride = 2; + + if (textureCoords !== undefined) { + this.vertexStride = this.vertexStride + 2; + } + + this.area = ShapeUtils.area(this.points); + this.winding = this.area < 0; + } + + /** + * Returns a flattened `Array` containing the position and texture coordinates of this `Ring`. + * + * @param array The target `Array`. + * @param offset Optional offset into the array. + */ + toArray(array: number[] = [], offset: number = 0): number[] { + this.points.forEach((p, i) => p.toArray(array, offset + this.vertexStride * i)); + this.textureCoords?.forEach((p, i) => p.toArray(array, offset + this.vertexStride * i + 2)); + return array; + } + + /** + * Tests if the edge connecting the vertex at `index` with + * the vertex at `index+1` should be connected by a line + * when stroking the polygon. + * + * @param index The index of the first vertex of the outline edge. + */ + isProperEdge(index: number): boolean { + const extents = this.extents; + const nextIdx = (index + 1) % this.points.length; + const curr = this.points[index]; + const next = this.points[nextIdx]; + + if (this.clippedPointIndices !== undefined) { + if (curr.x !== next.x && curr.y !== next.y) { + // `curr` and `next` must be connected with a line + // because they don't form a vertical or horizontal lines. + return true; + } + + const currAtEdge = curr.x % this.extents === 0 || curr.y % this.extents === 0; + + if (!currAtEdge) { + // the points are connected with a line + // because at least one of the points is not on + // the tile boundary. + return true; + } + + const nextAtEdge = next.x % this.extents === 0 || next.y % this.extents === 0; + + if (!nextAtEdge) { + // the points are connected with a line + // because at least one of the points is not on + // the tile boundary. + return true; + } + + const currWasClipped = this.clippedPointIndices.has(index); + const nextWasClipped = this.clippedPointIndices.has(nextIdx); + + return !currWasClipped && !nextWasClipped; + } + + return !( + (curr.x <= 0 && next.x <= 0) || + (curr.x >= extents && next.x >= extents) || + (curr.y <= 0 && next.y <= 0) || + (curr.y >= extents && next.y >= extents) + ); + } +} diff --git a/@here/harp-omv-datasource/lib/TiledGeoJsonAdapter.ts b/@here/harp-omv-datasource/lib/TiledGeoJsonAdapter.ts index bde3666eaa..250ee2139f 100644 --- a/@here/harp-omv-datasource/lib/TiledGeoJsonAdapter.ts +++ b/@here/harp-omv-datasource/lib/TiledGeoJsonAdapter.ts @@ -182,7 +182,7 @@ export class TiledGeoJsonDataAdapter implements OmvDataAdapter { const { tileKey } = decodeInfo; const $level = tileKey.level; - const $zoom = Math.max(0, tileKey.level - (this.m_processor.storageLevelOffset || 0)); + const $zoom = Math.max(0, tileKey.level - (this.m_processor.storageLevelOffset ?? 0)); const $layer = "geojson"; for (const feature of featureCollection.features) { diff --git a/@here/harp-omv-datasource/lib/VTJsonDataAdapter.ts b/@here/harp-omv-datasource/lib/VTJsonDataAdapter.ts index 3f5ed8f1ca..a09920871b 100644 --- a/@here/harp-omv-datasource/lib/VTJsonDataAdapter.ts +++ b/@here/harp-omv-datasource/lib/VTJsonDataAdapter.ts @@ -10,14 +10,14 @@ import { MapEnv, ValueMap } from "@here/harp-datasource-protocol/index-decoder"; -import { GeoCoordinates } from "@here/harp-geoutils"; +import { webMercatorProjection } from "@here/harp-geoutils"; import { ILogger } from "@here/harp-utils"; -import { Vector2 } from "three"; +import { Vector2, Vector3 } from "three"; import { DecodeInfo } from "./DecodeInfo"; import { IGeometryProcessor, ILineGeometry, IPolygonGeometry } from "./IGeometryProcessor"; import { OmvFeatureFilter } from "./OmvDataFilter"; import { OmvDataAdapter } from "./OmvDecoder"; -import { isArrayBufferLike } from "./OmvUtils"; +import { isArrayBufferLike, tile2world } from "./OmvUtils"; const VT_JSON_EXTENTS = 4096; @@ -67,14 +67,17 @@ interface VTJsonTileInterface { layer: string; } +const tmpPos = new Vector2(); +const worldPos = new Vector3(); + /** - * [[OmvDataAdapter]] id for [[VTJsonDataAdapter]]. + * Unique ID of {@link VTJsonDataAdapter}. */ export const VTJsonDataAdapterId: string = "vt-json"; /** - * The class [[VTJsonDataAdapter]] converts VT-json data to geometries for the given - * [[IGeometryProcessor]]. + * The class `VTJsonDataAdapter` converts VT-json data to geometries for the given + * {@link IGeometryProcessor}. */ export class VTJsonDataAdapter implements OmvDataAdapter { id = VTJsonDataAdapterId; @@ -119,7 +122,7 @@ export class VTJsonDataAdapter implements OmvDataAdapter { $layer: tile.layer, $geometryType: this.convertGeometryType(feature.type), $level: tileKey.level, - $zoom: Math.max(0, tileKey.level - (this.m_processor.storageLevelOffset || 0)), + $zoom: Math.max(0, tileKey.level - (this.m_processor.storageLevelOffset ?? 0)), $id: feature.id, ...feature.tags }); @@ -143,37 +146,45 @@ export class VTJsonDataAdapter implements OmvDataAdapter { break; } case VTJsonGeometryType.LineString: { - let untiledPositions: GeoCoordinates[] | undefined; - if (feature.originalGeometry.type === "LineString") { - untiledPositions = []; - for (const [x, y] of feature.originalGeometry.coordinates) { - untiledPositions.push(new GeoCoordinates(y, x)); - } - } else if (feature.originalGeometry.type === "MultiLineString") { - untiledPositions = []; - for (const lineGeometry of feature.originalGeometry - .coordinates as VTJsonPosition[][]) { - for (const [x, y] of lineGeometry) { - untiledPositions.push(new GeoCoordinates(y, x)); + const lineGeometries = feature.geometry as VTJsonPosition[][]; + + let lastLine: ILineGeometry | undefined; + const lines: ILineGeometry[] = []; + + lineGeometries.forEach(lineGeometry => { + const lastPos = lastLine?.positions[lastLine.positions.length - 1]; + const [startx, starty] = lineGeometry[0]; + if (lastPos?.x === startx && lastPos?.y === starty) { + // continue the last line + for (let i = 1; i < lineGeometry.length; ++i) { + const [x, y] = lineGeometry[i]; + lastLine?.positions.push(new Vector2(x, y)); } - } - } + } else { + // start a new line + const positions = lineGeometry.map(([x, y]) => new Vector2(x, y)); + lines.push({ positions }); - for (const lineGeometry of feature.geometry as VTJsonPosition[][]) { - const line: ILineGeometry = { positions: [], untiledPositions }; - for (const [x, y] of lineGeometry) { - const position = new Vector2(x, y); - line.positions.push(position); + lastLine = lines[lines.length - 1]; } + }); + + lines.forEach(line => { + (line as any).untiledPositions = line.positions.map(tilePos => { + tile2world(VT_JSON_EXTENTS, decodeInfo, tilePos, false, tmpPos); + worldPos.set(tmpPos.x, tmpPos.y, 0); + return webMercatorProjection.unprojectPoint(worldPos); + }); + }); + + this.m_processor.processLineFeature( + tile.layer, + VT_JSON_EXTENTS, + lines, + env, + tileKey.level + ); - this.m_processor.processLineFeature( - tile.layer, - VT_JSON_EXTENTS, - [line], - env, - tileKey.level - ); - } break; } case VTJsonGeometryType.Polygon: { diff --git a/@here/harp-omv-datasource/test/MapViewPickingTest.ts b/@here/harp-omv-datasource/test/MapViewPickingTest.ts index 64676b5969..8cff0a7f70 100644 --- a/@here/harp-omv-datasource/test/MapViewPickingTest.ts +++ b/@here/harp-omv-datasource/test/MapViewPickingTest.ts @@ -180,7 +180,8 @@ describe("MapView Picking", async function() { fontCatalog: getTestResourceUrl( "@here/harp-fontcatalog", "resources/Default_FontCatalog.json" - ) + ), + addBackgroundDatasource: false }); await waitForEvent(mapView, MapViewEventNames.ThemeLoaded); @@ -261,6 +262,30 @@ describe("MapView Picking", async function() { }); describe("Picking tests", async function() { + const pickPolygonAt: number[] = [13.084716796874998, 22.61401087437029]; + const pickLineAt: number[] = ((GEOJSON_DATA.features[1].geometry as any) + .coordinates as number[][])[0]; + const pickLabelAt: number[] = (GEOJSON_DATA.features[2].geometry as any).coordinates; + const offCenterLabelLookAt: number[] = [pickLabelAt[0] + 10.0, pickLabelAt[1] + 10.0]; + + it("Features in a data source with picking disabled are not picked", async () => { + geoJsonDataSource.enablePicking = false; + const target = new GeoCoordinates(pickPolygonAt[1], pickPolygonAt[0]); + mapView.lookAt({ target, tilt: 60, zoomLevel: 2 }); + await waitForEvent(mapView, MapViewEventNames.FrameComplete); + + const screenPointLocation = mapView.getScreenPosition(target) as THREE.Vector2; + assert.isDefined(screenPointLocation); + + mapView.scene.updateWorldMatrix(false, true); + + const usableIntersections = mapView + .intersectMapObjects(screenPointLocation.x, screenPointLocation.y) + .filter(item => item.userData !== undefined); + + assert.equal(usableIntersections.length, 0); + }); + interface TestCase { name: string; rayOrigGeo: number[]; @@ -271,12 +296,8 @@ describe("MapView Picking", async function() { lookAt?: number[]; // Whether to test a shift of 360.0 degrees shiftLongitude: boolean; + addDependency?: boolean; } - const pickPolygonAt: number[] = [13.084716796874998, 22.61401087437029]; - const pickLineAt: number[] = ((GEOJSON_DATA.features[1].geometry as any) - .coordinates as number[][])[0]; - const pickLabelAt: number[] = (GEOJSON_DATA.features[2].geometry as any).coordinates; - const offCenterLabelLookAt: number[] = [pickLabelAt[0] + 10.0, pickLabelAt[1] + 10.0]; const testCases: TestCase[] = []; @@ -339,6 +360,17 @@ describe("MapView Picking", async function() { } } + testCases.push({ + name: `Pick polygon in planar projection with dependency`, + rayOrigGeo: pickPolygonAt, + featureIdx: 0, + projection: mercatorProjection, + elevation: false, + shiftLongitude: false, + addDependency: true, + lookAt: offCenterLabelLookAt + }); + for (const testCase of testCases) { it(testCase.name, async () => { mapView.projection = testCase.projection; @@ -372,8 +404,42 @@ describe("MapView Picking", async function() { testCase.lookAt.length > 2 ? testCase.lookAt[2] : undefined ) : rayOrigin; + + let stub: sinon.SinonStub | undefined; + if (testCase.addDependency === true) { + stub = sinon + .stub(geoJsonDataSource, "getTile") + .callsFake((_tileKey: TileKey, delayLoad?: boolean) => { + const tile = stub!.wrappedMethod.bind(geoJsonDataSource)( + _tileKey, + delayLoad + ); + if (tile !== undefined) { + tile.dependencies.push( + TileKey.fromRowColumnLevel( + (_tileKey.row + 1) % _tileKey.rowCount(), + _tileKey.column, + _tileKey.level + ) + ); + tile.dependencies.push( + TileKey.fromRowColumnLevel( + _tileKey.row, + (_tileKey.column + 1) % _tileKey.columnCount(), + _tileKey.level + ) + ); + } + return tile; + }); + } + mapView.lookAt({ target, tilt: 60, zoomLevel: 2 }); await waitForEvent(mapView, MapViewEventNames.FrameComplete); + // Reset back to the original function + if (stub !== undefined) { + stub.restore(); + } const screenPointLocation = mapView.getScreenPosition(rayOrigin) as THREE.Vector2; assert.isDefined(screenPointLocation); diff --git a/@here/harp-omv-datasource/test/OmvDataSourceTest.ts b/@here/harp-omv-datasource/test/OmvDataSourceTest.ts index 0688d9d937..5aad548763 100644 --- a/@here/harp-omv-datasource/test/OmvDataSourceTest.ts +++ b/@here/harp-omv-datasource/test/OmvDataSourceTest.ts @@ -7,12 +7,17 @@ // tslint:disable:only-arrow-functions // Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions +import { FeatureCollection } from "@here/harp-datasource-protocol"; import "@here/harp-fetch"; import { TileKey } from "@here/harp-geoutils"; import { DataProvider } from "@here/harp-mapview-decoder"; +import { GeoJsonTiler } from "@here/harp-mapview-decoder/index-worker"; import { assert } from "chai"; import { APIFormat, AuthenticationTypeAccessToken, OmvDataSource, OmvRestClient } from "../index"; import { OmvTileDecoder } from "../index-worker"; +import { GeoJsonDataProvider } from "../lib/GeoJsonDataProvider"; + +import * as sinon from "sinon"; class MockDataProvider implements DataProvider { /** Overriding abstract method, in this case doing nothing. */ @@ -143,4 +148,59 @@ describe("DataProviders", function() { assert.equal((omvDataSource.decoder as any).m_storageLevelOffset, 2); }); }); + + it("DataProvider clears the cache", function() { + const geojson: FeatureCollection = { + type: "FeatureCollection", + features: [] + }; + + const markTilesDirty = sinon.fake(); + const clearTileCache = sinon.fake(); + + const mapView: any = { markTilesDirty, clearTileCache }; + + const tiler = new GeoJsonTiler(); + + const dataProvider = new GeoJsonDataProvider("geojson", geojson, { + tiler + }); + + const decoder = new OmvTileDecoder(); + + const omvDataSource = new OmvDataSource({ dataProvider, decoder }); + + omvDataSource.attach(mapView); + + omvDataSource.connect(); + + dataProvider.updateInput({ + type: "FeatureCollection", + features: [] + }); + + assert.isTrue(clearTileCache.called); + + clearTileCache.resetHistory(); + + assert.isFalse(clearTileCache.called); + + dataProvider.updateInput({ + type: "FeatureCollection", + features: [] + }); + + assert.isTrue(clearTileCache.called); + + clearTileCache.resetHistory(); + + omvDataSource.dispose(); + + dataProvider.updateInput({ + type: "FeatureCollection", + features: [] + }); + + assert.isFalse(clearTileCache.called); + }); }); diff --git a/@here/harp-omv-datasource/test/OmvTomTomFeatureModifierTest.ts b/@here/harp-omv-datasource/test/OmvTomTomFeatureModifierTest.ts index 82ea813ece..017d76f34a 100644 --- a/@here/harp-omv-datasource/test/OmvTomTomFeatureModifierTest.ts +++ b/@here/harp-omv-datasource/test/OmvTomTomFeatureModifierTest.ts @@ -14,7 +14,7 @@ import { OmvFeatureFilterDescription } from "../lib/OmvDecoderDefs"; import { OmvTomTomFeatureModifier } from "../lib/OmvTomTomFeatureModifier"; export class OmvTomTomModifierMock extends OmvTomTomFeatureModifier { - private m_description: OmvFeatureFilterDescription; + private readonly m_description: OmvFeatureFilterDescription; constructor(description: OmvFeatureFilterDescription) { super(description); diff --git a/@here/harp-omv-datasource/test/RingTest.ts b/@here/harp-omv-datasource/test/RingTest.ts new file mode 100644 index 0000000000..9bd74a4b6d --- /dev/null +++ b/@here/harp-omv-datasource/test/RingTest.ts @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2017-2020 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ + +// tslint:disable:no-unused-expression +// expect-type assertions are unused expressions and are perfectly valid + +// tslint:disable:no-empty +// lots of stubs are needed which are just placeholders and are empty + +// tslint:disable:only-arrow-functions +// Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions + +import { clipPolygon } from "@here/harp-geometry/lib/ClipPolygon"; +import { assert } from "chai"; +import { Vector2 } from "three"; +import { Ring } from "../lib/Ring"; + +const DEFAULT_EXTENTS = 4 * 1024; + +describe("Ring", function() { + describe("Empty ring", () => { + it("Defaults of empty ring", () => { + const ring = new Ring([]); + assert.strictEqual(ring.area, 0); + assert.strictEqual(ring.winding, false); + assert.strictEqual(ring.extents, DEFAULT_EXTENTS); + assert.deepEqual(ring.toArray(), []); + }); + + it("with texture coordinates", () => { + const ring = new Ring([], []); + assert.strictEqual(ring.area, 0); + assert.strictEqual(ring.winding, false); + assert.strictEqual(ring.extents, DEFAULT_EXTENTS); + assert.deepEqual(ring.toArray(), []); + }); + + it("with texture coordinates and extents", () => { + const extent = 16 * 1024; + const ring = new Ring([], [], extent); + assert.strictEqual(ring.area, 0); + assert.strictEqual(ring.winding, false); + assert.strictEqual(ring.extents, extent); + assert.deepEqual(ring.toArray(), []); + }); + + it("throws exception", () => { + assert.throws(() => { + // tslint:disable-next-line:no-unused-variable + const ring = new Ring([new Vector2(0, 0)], []); + }, "the array of texture coordinates must have the same number of elements of the array of points"); + + assert.throws(() => { + // tslint:disable-next-line:no-unused-variable + const ring = new Ring([], [new Vector2(0, 0)]); + }, "the array of texture coordinates must have the same number of elements of the array of points"); + }); + }); + + describe("Full quad outer ring", () => { + const points: Vector2[] = [ + new Vector2(0, 0), + new Vector2(100, 0), + new Vector2(100, 100), + new Vector2(0, 100), + new Vector2(0, 0) + ]; + + const texCoords: Vector2[] = [ + new Vector2(0, 0), + new Vector2(1, 0), + new Vector2(1, 1), + new Vector2(0, 1), + new Vector2(0, 0) + ]; + + it("no texture coordinates", () => { + const ring = new Ring(points, undefined, DEFAULT_EXTENTS); + assert.strictEqual(ring.area, 100 * 100); + assert.strictEqual(ring.winding, false); + assert.strictEqual(ring.extents, DEFAULT_EXTENTS); + assert.deepEqual(ring.toArray(), [0, 0, 100, 0, 100, 100, 0, 100, 0, 0]); + }); + + it("with texture coordinates", () => { + const ring = new Ring(points, texCoords, DEFAULT_EXTENTS); + assert.strictEqual(ring.area, 100 * 100); + assert.strictEqual(ring.winding, false); + assert.strictEqual(ring.extents, DEFAULT_EXTENTS); + assert.deepEqual(ring.toArray(), [ + 0, + 0, + 0, + 0, + 100, + 0, + 1, + 0, + 100, + 100, + 1, + 1, + 0, + 100, + 0, + 1, + 0, + 0, + 0, + 0 + ]); + }); + }); + + describe("Full quad inner ring", () => { + const points: Vector2[] = [ + new Vector2(0, 0), + new Vector2(0, 100), + new Vector2(100, 100), + new Vector2(100, 0), + new Vector2(0, 0) + ]; + + const texCoords: Vector2[] = [ + new Vector2(0, 0), + new Vector2(0, 1), + new Vector2(1, 1), + new Vector2(1, 0), + new Vector2(0, 0) + ]; + + it("without texture coordinates", () => { + const ring = new Ring(points, undefined, DEFAULT_EXTENTS); + assert.strictEqual(ring.area, -(100 * 100)); + assert.strictEqual(ring.winding, true); + assert.strictEqual(ring.extents, DEFAULT_EXTENTS); + assert.deepEqual(ring.toArray(), [0, 0, 0, 100, 100, 100, 100, 0, 0, 0]); + }); + + it("with texture coordinates", () => { + const ring = new Ring(points, texCoords, DEFAULT_EXTENTS); + assert.strictEqual(ring.area, -(100 * 100)); + assert.strictEqual(ring.winding, true); + assert.strictEqual(ring.extents, DEFAULT_EXTENTS); + assert.deepEqual(ring.toArray(), [ + 0, + 0, + 0, + 0, + 0, + 100, + 0, + 1, + 100, + 100, + 1, + 1, + 100, + 0, + 1, + 0, + 0, + 0, + 0, + 0 + ]); + }); + + it("flatten to array at a specific offset", () => { + const ring = new Ring(points, undefined, DEFAULT_EXTENTS); + assert.deepEqual(ring.toArray([123, 321], 2), [ + 123, + 321, + 0, + 0, + 0, + 100, + 100, + 100, + 100, + 0, + 0, + 0 + ]); + }); + + it("flatten to array at a specific offset", () => { + const ring = new Ring(points, texCoords, DEFAULT_EXTENTS); + assert.deepEqual(ring.toArray(undefined, 6), [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 0, + 0, + 0, + 0, + 0, + 100, + 0, + 1, + 100, + 100, + 1, + 1, + 100, + 0, + 1, + 0, + 0, + 0, + 0, + 0 + ]); + }); + + it("outlines", () => { + const ring = new Ring(points, texCoords); + assert.strictEqual(ring.isProperEdge(0), false); + assert.strictEqual(ring.isProperEdge(1), true); + assert.strictEqual(ring.isProperEdge(2), true); + assert.strictEqual(ring.isProperEdge(3), false); + assert.strictEqual(ring.isProperEdge(4), false); + }); + }); + + describe("Concave polygon resulting into 2 parts after clipping", () => { + const polygon: Vector2[] = [ + new Vector2(-100, 0), + new Vector2(4096, 0), + new Vector2(-50, 2048), + new Vector2(4096, 4096), + new Vector2(-100, 4096) + ]; + + const clippedPolygon = clipPolygon(polygon, DEFAULT_EXTENTS); + + it("edge outlines", () => { + const ring = new Ring(clippedPolygon, undefined, DEFAULT_EXTENTS); + assert.strictEqual(ring.winding, false); + const outlines = ring.points.map((_, i) => ring.isProperEdge(i)); + + assert.deepEqual( + // tslint:disable-next-line: no-bitwise + ring.toArray().map(x => x | 0), + [0, 0, 4096, 0, 0, 2023, 0, 2073, 4096, 4096, 0, 4096] + ); + + assert.deepEqual(outlines, [false, true, false, true, false, false]); + }); + }); +}); diff --git a/@here/harp-test-utils/lib/ProfileHelper.ts b/@here/harp-test-utils/lib/ProfileHelper.ts index 972a1be1d0..c327adf278 100644 --- a/@here/harp-test-utils/lib/ProfileHelper.ts +++ b/@here/harp-test-utils/lib/ProfileHelper.ts @@ -495,7 +495,7 @@ export function reportPerformanceAndReset() { function saveBaselineIfRequested(results: PerformanceTestResults) { if (typeof window === "undefined" && process.env.PROFILEHELPER_COMMAND === "baseline") { const baselineFileName = - process.env.PROFILEHELPER_OUTPUT || ".profile-helper-baseline.json"; + process.env.PROFILEHELPER_OUTPUT ?? ".profile-helper-baseline.json"; if (!baselineFileName) { return; } @@ -508,7 +508,7 @@ function saveBaselineIfRequested(results: PerformanceTestResults) { function loadBaseLineIfAvailable() { if (typeof window === "undefined") { const baselineFileName = - process.env.PROFILEHELPER_OUTPUT || ".profile-helper-baseline.json"; + process.env.PROFILEHELPER_OUTPUT ?? ".profile-helper-baseline.json"; if (!baselineFileName || !fs.existsSync(baselineFileName)) { return undefined; } @@ -546,7 +546,7 @@ function sleepPromised(time: number = 1): Promise { * #countCall: Foo#push called=123 */ export function countCall(name: string, delta = 1) { - let current = occurenceResults.get(name) || 0; + let current = occurenceResults.get(name) ?? 0; current += delta; occurenceResults.set(name, current); } diff --git a/@here/harp-test-utils/lib/rendering/DomImageUtils.ts b/@here/harp-test-utils/lib/rendering/DomImageUtils.ts index 7016937d44..f063a09d32 100644 --- a/@here/harp-test-utils/lib/rendering/DomImageUtils.ts +++ b/@here/harp-test-utils/lib/rendering/DomImageUtils.ts @@ -87,7 +87,6 @@ export function loadImageData(url: string): Promise { new THREE.ImageLoader().load( url, image => { - logger.info(`#loadImageData loaded: ${url}, size=${image.width},${image.height}`); const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height; diff --git a/@here/harp-test-utils/lib/rendering/HtmlReport.ts b/@here/harp-test-utils/lib/rendering/HtmlReport.ts index 1e005d911b..9e33d283c3 100644 --- a/@here/harp-test-utils/lib/rendering/HtmlReport.ts +++ b/@here/harp-test-utils/lib/rendering/HtmlReport.ts @@ -121,7 +121,7 @@ export async function genHtmlReport( const html = ` - ${options.title || "Image Based Tests Results"} + ${options.title ?? "Image Based Tests Results"}