From 92f544dde5972a48748321626eb52b1f917cd5d4 Mon Sep 17 00:00:00 2001 From: Oscar Lorentzon Date: Thu, 9 Mar 2023 14:17:03 -0800 Subject: [PATCH] feat: event api to delete cluster from graph Handle deletion in graph. Remove cluster from spatial on delete. Unit tests. --- declarations/mapillary.js.flow | 18 +- doc/.eslintrc.js | 1 + doc/src/js/utils/ChunkDataProvider.js | 160 ++++++++++ .../utils/DeletableProceduralDataProvider.js | 49 ++++ doc/src/js/utils/ProceduralDataProvider.js | 159 +--------- doc/src/js/utils/provider.js | 155 ++++++++++ examples/debug/chunk.html | 166 +++++++++++ examples/debug/delete.html | 95 ++++++ examples/debug/reset.html | 8 +- examples/debug/spatial.html | 1 + src/api/DataProviderBase.ts | 13 + src/api/events/ProviderClusterEvent.ts | 17 ++ src/api/events/ProviderEventType.ts | 3 +- src/api/interfaces/IDataProvider.ts | 58 ++++ src/component/spatial/SpatialCache.ts | 223 ++++++++------ src/component/spatial/SpatialComponent.ts | 9 + src/component/spatial/SpatialScene.ts | 60 +++- src/component/spatial/scene/SpatialCell.ts | 77 ++++- src/external/api.ts | 1 + src/graph/Graph.ts | 273 +++++++++++++++--- src/graph/GraphService.ts | 74 ++++- src/graph/Image.ts | 13 + src/graph/ImageCache.ts | 8 +- src/viewer/CacheService.ts | 21 +- src/viewer/Navigator.ts | 11 +- test/component/spatial/SpatialCache.test.ts | 108 +++++++ test/graph/Graph.test.ts | 223 ++++++++++---- test/graph/GraphService.test.ts | 106 ++++++- test/helper/GraphServiceMockCreator.ts | 1 + test/viewer/CacheService.test.ts | 64 +++- test/viewer/Navigator.test.ts | 12 +- 31 files changed, 1823 insertions(+), 364 deletions(-) create mode 100644 doc/src/js/utils/ChunkDataProvider.js create mode 100644 doc/src/js/utils/DeletableProceduralDataProvider.js create mode 100644 doc/src/js/utils/provider.js create mode 100644 examples/debug/chunk.html create mode 100644 examples/debug/delete.html create mode 100644 src/api/events/ProviderClusterEvent.ts diff --git a/declarations/mapillary.js.flow b/declarations/mapillary.js.flow index 80bfdc3e..4754e5e4 100644 --- a/declarations/mapillary.js.flow +++ b/declarations/mapillary.js.flow @@ -585,7 +585,7 @@ export interface ImageTilesRequestContract { /** * @event */ -export type ProviderEventType = "datacreate"; +export type ProviderEventType = "datacreate" | "datadelete"; /** * @interface IGeometryProvider * @@ -775,6 +775,22 @@ export type ProviderCellEvent = { type: "datacreate", ... } & ProviderEvent; +/** + * + * Interface for data provider cluster events. + */ +export type ProviderClusterEvent = { + /** + * Cluster ids for clusters that have been deleted. + */ + clusterIds: string[], + + /** + * Provider event type. + */ + type: "datadelete", + ... +} & ProviderEvent; /** * @class DataProviderBase diff --git a/doc/.eslintrc.js b/doc/.eslintrc.js index c6e6141a..15c8514e 100644 --- a/doc/.eslintrc.js +++ b/doc/.eslintrc.js @@ -56,6 +56,7 @@ module.exports = { 'react-hooks/rules-of-hooks': ERROR, 'react/prop-types': OFF, // PropTypes aren't used much these days. 'no-console': ['error', {allow: ['info', 'warn', 'error']}], + 'no-continue': OFF, 'no-underscore-dangle': ['error', {allowAfterThis: true}], 'no-plusplus': ['error', {allowForLoopAfterthoughts: true}], 'no-restricted-syntax': [ diff --git a/doc/src/js/utils/ChunkDataProvider.js b/doc/src/js/utils/ChunkDataProvider.js new file mode 100644 index 00000000..524248b5 --- /dev/null +++ b/doc/src/js/utils/ChunkDataProvider.js @@ -0,0 +1,160 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import { + DataProviderBase, + S2GeometryProvider, +} from '../../mapillary-js/dist/mapillary.module'; + +import {generateCells, generateImageBuffer} from './provider'; + +const IMAGE_TILE_SIZE = 10; +const IMAGE_TILES_Y = 10; + +export const REFERENCE = {alt: 0, lat: 0, lng: 0}; + +export class ChunkDataProvider extends DataProviderBase { + constructor() { + super(new S2GeometryProvider()); + + this.chunks = new Map(); + this.cells = new Map(); + this.clusters = new Map(); + this.images = new Map(); + this.sequences = new Map(); + } + + addChunk(chunk) { + if (this.chunks.has(chunk.id)) { + throw new Error(`Chunk already exists ${chunk.id}`); + } + + const {cluster, images, sequence} = chunk; + + if (this.clusters.has(cluster.id)) { + throw new Error(`Cluster already exists ${cluster.id}`); + } + if (this.sequences.has(sequence.id)) { + throw new Error(`Sequence already exists ${sequence.id}`); + } + for (const imageId of images.keys()) { + if (this.images.has(imageId)) { + throw new Error(`Image already exists ${imageId}`); + } + } + + this.chunks.set(chunk.id, chunk); + this.clusters.set(cluster.id, cluster); + this.sequences.set(sequence.id, sequence); + + for (const image of images.values()) { + this.images.set(image.id, image); + } + const cells = generateCells(this.images.values(), this._geometry); + for (const [cellId, cellImages] of cells.entries()) { + if (!this.cells.has(cellId)) { + this.cells.set(cellId, new Map()); + } + const cell = this.cells.get(cellId); + for (const cellImage of cellImages) { + cell.set(cellImage.id, cellImage); + } + } + + const dataCreateEvent = { + cellIds: Array.from(cells.keys()), + target: this, + type: 'datacreate', + }; + this.fire(dataCreateEvent.type, dataCreateEvent); + } + + deleteChunks(chunkIds) { + for (const chunkId of chunkIds) { + if (!this.chunks.has(chunkId)) { + throw new Error(`Chunk does not exist ${chunkId}`); + } + + const {cluster, images, sequence} = this.chunks.get(chunkId); + + this.chunks.delete(chunkId); + this.clusters.delete(cluster.id); + this.sequences.delete(sequence.id); + + for (const image of images.values()) { + this.images.delete(image.id); + const cellId = this._geometry.lngLatToCellId(image.geometry); + this.cells.get(cellId).delete(image.id); + } + } + + const dataDeleteEvent = { + clusterIds: chunkIds, + target: this, + type: 'datadelete', + }; + this.fire(dataDeleteEvent.type, dataDeleteEvent); + } + + getCluster(url) { + if (this.clusters.has(url)) { + return Promise.resolve(this.clusters.get(url)); + } + + return Promise.reject(new Error(`Cluster does not exist ${url}`)); + } + + getCoreImages(cellId) { + const images = this.cells.has(cellId) ? this.cells.get(cellId) : new Map(); + return Promise.resolve({ + cell_id: cellId, + images: Array.from(images.values()), + }); + } + + getImages(imageIds) { + const images = []; + for (const imageId of imageIds) { + if (!this.images.has(imageId)) { + return Promise.reject(new Error(`Image does not exist ${imageId}`)); + } + images.push({ + node: this.images.get(imageId), + node_id: imageId, + }); + } + return Promise.resolve(images); + } + + // eslint-disable-next-line class-methods-use-this + getImageBuffer(url) { + return generateImageBuffer({ + tileSize: IMAGE_TILE_SIZE, + tilesY: IMAGE_TILES_Y, + url, + }); + } + + // eslint-disable-next-line class-methods-use-this + getMesh(_url) { + return Promise.resolve({faces: [], vertices: []}); + } + + getSequence(sequenceId) { + if (this.sequences.has(sequenceId)) { + return Promise.resolve(this.sequences.get(sequenceId)); + } + + return Promise.reject(new Error(`Sequence does not exist ${sequenceId}`)); + } + + getSpatialImages(imageIds) { + return this.getImages(imageIds); + } +} diff --git a/doc/src/js/utils/DeletableProceduralDataProvider.js b/doc/src/js/utils/DeletableProceduralDataProvider.js new file mode 100644 index 00000000..d1786d19 --- /dev/null +++ b/doc/src/js/utils/DeletableProceduralDataProvider.js @@ -0,0 +1,49 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {ProceduralDataProvider} from './ProceduralDataProvider'; + +export class DeletableProceduralDataProvider extends ProceduralDataProvider { + delete() { + if (!this.clusters.size) { + return; + } + + const clusterId = this.clusters.keys().next().value; + this.clusters.delete(clusterId); + + for (const [imageId, image] of this.images.entries()) { + if (image.cluster.id !== clusterId) { + continue; + } + + this.images.delete(imageId); + this.sequences.delete(image.sequence.id); + for (const cellImages of this.cells.values()) { + const index = cellImages.indexOf(image); + if (index !== -1) { + cellImages.splice(index, 1); + } + } + } + + this._fire([clusterId]); + } + + _fire(clusterIds) { + const target = this; + const type = 'datadelete'; + const event = { + clusterIds, + target, + type, + }; + this.fire(type, event); + } +} diff --git a/doc/src/js/utils/ProceduralDataProvider.js b/doc/src/js/utils/ProceduralDataProvider.js index e1290432..179bf561 100644 --- a/doc/src/js/utils/ProceduralDataProvider.js +++ b/doc/src/js/utils/ProceduralDataProvider.js @@ -8,146 +8,23 @@ */ import { - enuToGeodetic, DataProviderBase, S2GeometryProvider, } from '../../mapillary-js/dist/mapillary.module'; -import {generateImageBuffer} from './image'; - -const ASPECT_FISHEYE = 3 / 2; -const ASPECT_PERSPECTIVE = 4 / 3; -const ASPECT_SPHERICAL = 2; - -const FISHEYE = 'fisheye'; -const PERSPECTIVE = 'perspective'; -const SPHERICAL = 'spherical'; +import { + CAMERA_TYPE_FISHEYE, + CAMERA_TYPE_PERSPECTIVE, + CAMERA_TYPE_SPHERICAL, + cameraTypeToAspect, + generateCells, + generateCluster, + generateImageBuffer, +} from './provider'; export const DEFAULT_REFERENCE = {alt: 0, lat: 0, lng: 0}; export const DEFAULT_INTERVALS = 10; -function cameraTypeToAspect(cameraType) { - switch (cameraType) { - case FISHEYE: - return ASPECT_FISHEYE; - case PERSPECTIVE: - return ASPECT_PERSPECTIVE; - case SPHERICAL: - return ASPECT_SPHERICAL; - default: - throw new Error(`Camera type ${cameraType} not supported`); - } -} - -function generateCells(images, geometryProvider) { - const cells = new Map(); - for (const image of images) { - const cellId = geometryProvider.lngLatToCellId(image.geometry); - if (!cells.has(cellId)) { - cells.set(cellId, []); - } - cells.get(cellId).push(image); - } - return cells; -} - -function generateCluster(options, intervals) { - const {cameraType, color, east, focal, height, k1, k2, width} = options; - let {idCounter} = options; - const {alt, lat, lng} = options.reference; - - const distance = 5; - - const images = []; - const thumbUrl = `${cameraType}`; - const clusterId = `cluster|${cameraType}`; - const sequenceId = `sequence|${cameraType}`; - const sequence = {id: sequenceId, image_ids: []}; - - const start = idCounter; - const end = idCounter + intervals; - - while (idCounter <= end) { - const imageId = `image|${cameraType}|${idCounter}`; - const thumbId = `thumb|${cameraType}|${idCounter}`; - const meshId = `mesh|${cameraType}|${idCounter}`; - sequence.image_ids.push(imageId); - - const index = idCounter - start; - const north = (-intervals * distance) / 2 + distance * index; - const up = 0; - const [computedLng, computedLat, computedAlt] = enuToGeodetic( - east, - north, - up, - lng, - lat, - alt, - ); - const computedGeometry = {lat: computedLat, lng: computedLng}; - const rotation = [Math.PI / 2, 0, 0]; - const compassAngle = 0; - const cameraParameters = cameraType === SPHERICAL ? [] : [focal, k1, k2]; - - images.push({ - altitude: computedAlt, - atomic_scale: 1, - camera_parameters: cameraParameters, - camera_type: cameraType, - captured_at: 0, - cluster: {id: clusterId, url: clusterId}, - computed_rotation: rotation, - compass_angle: compassAngle, - computed_compass_angle: compassAngle, - computed_altitude: computedAlt, - computed_geometry: computedGeometry, - creator: {id: null, username: null}, - geometry: computedGeometry, - height, - id: imageId, - merge_id: 'merge_id', - mesh: {id: meshId, url: meshId}, - exif_orientation: 1, - private: null, - quality_score: 1, - sequence: {id: sequenceId}, - thumb: {id: thumbId, url: thumbUrl}, - owner: {id: null}, - width, - }); - - idCounter += 1; - } - - const cluster = { - colors: [], - coordinates: [], - id: clusterId, - pointIds: [], - reference: options.reference, - }; - for (let i = 0; i <= intervals; i++) { - const easts = [-3, 3]; - const north = (-intervals * distance) / 2 + distance * i; - const up = 0; - for (let y = 0; y < distance; y++) { - for (let z = 0; z < distance; z++) { - for (const x of easts) { - const pointId = `${i}-${z}-${y}-${x}`; - const cx = east + x; - const cy = north + y; - const cz = up + z; - cluster.pointIds.push(pointId); - cluster.coordinates.push(cx, cy, cz); - cluster.colors.push(...color); - } - } - } - } - - return {cluster, images, sequence}; -} - function generateClusters(options) { const {height, intervals, reference} = options; let {idCounter} = options; @@ -158,7 +35,7 @@ function generateClusters(options) { const clusterConfigs = [ { - cameraType: PERSPECTIVE, + cameraType: CAMERA_TYPE_PERSPECTIVE, color: [1, 0, 0], east: -9, focal: 0.8, @@ -167,7 +44,7 @@ function generateClusters(options) { reference, }, { - cameraType: FISHEYE, + cameraType: CAMERA_TYPE_FISHEYE, color: [0, 1, 0], east: 9, focal: 0.45, @@ -176,7 +53,7 @@ function generateClusters(options) { reference, }, { - cameraType: SPHERICAL, + cameraType: CAMERA_TYPE_SPHERICAL, color: [1, 1, 0], east: 0, reference, @@ -202,16 +79,6 @@ function generateClusters(options) { }; } -function getImageBuffer(options) { - const {tileSize, tilesY, url} = options; - const aspect = cameraTypeToAspect(url); - return generateImageBuffer({ - tileSize, - tilesX: aspect * tilesY, - tilesY, - }); -} - export class ProceduralDataProvider extends DataProviderBase { constructor(options) { super(options.geometry ?? new S2GeometryProvider()); @@ -249,7 +116,7 @@ export class ProceduralDataProvider extends DataProviderBase { getImageBuffer(url) { const {imageTileSize, imageTilesY} = this; const options = {tileSize: imageTileSize, tilesY: imageTilesY, url}; - return getImageBuffer(options); + return generateImageBuffer(options); } getMesh(url) { diff --git a/doc/src/js/utils/provider.js b/doc/src/js/utils/provider.js new file mode 100644 index 00000000..66df10bc --- /dev/null +++ b/doc/src/js/utils/provider.js @@ -0,0 +1,155 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {enuToGeodetic} from '../../mapillary-js/dist/mapillary.module'; + +import {generateImageBuffer as genImageBuffer} from './image'; + +const ASPECT_FISHEYE = 3 / 2; +const ASPECT_PERSPECTIVE = 4 / 3; +const ASPECT_SPHERICAL = 2; + +export const CAMERA_TYPE_FISHEYE = 'fisheye'; +export const CAMERA_TYPE_PERSPECTIVE = 'perspective'; +export const CAMERA_TYPE_SPHERICAL = 'spherical'; + +export function cameraTypeToAspect(cameraType) { + switch (cameraType) { + case CAMERA_TYPE_FISHEYE: + return ASPECT_FISHEYE; + case CAMERA_TYPE_PERSPECTIVE: + return ASPECT_PERSPECTIVE; + case CAMERA_TYPE_SPHERICAL: + return ASPECT_SPHERICAL; + default: + throw new Error(`Camera type ${cameraType} not supported`); + } +} + +export function generateImageBuffer(options) { + const {tileSize, tilesY, url} = options; + const aspect = cameraTypeToAspect(url); + return genImageBuffer({ + tileSize, + tilesX: aspect * tilesY, + tilesY, + }); +} + +export function generateCells(images, geometryProvider) { + const cells = new Map(); + for (const image of images) { + const cellId = geometryProvider.lngLatToCellId(image.geometry); + if (!cells.has(cellId)) { + cells.set(cellId, []); + } + cells.get(cellId).push(image); + } + return cells; +} + +export function generateCluster(options, intervals) { + const {cameraType, color, east, focal, height, id, k1, k2, width} = options; + let {idCounter} = options; + const {alt, lat, lng} = options.reference; + + const distance = 5; + + const images = []; + const thumbUrl = `${cameraType}`; + const clusterId = id ?? `cluster|${cameraType}`; + const sequenceId = id ?? `sequence|${cameraType}`; + const sequence = {id: sequenceId, image_ids: []}; + + const start = idCounter; + const end = idCounter + intervals; + + while (idCounter <= end) { + const imageId = id + ? `image|${id}|${cameraType}|${idCounter}` + : `image|${cameraType}|${idCounter}`; + const thumbId = `thumb|${cameraType}|${idCounter}`; + const meshId = `mesh|${cameraType}|${idCounter}`; + sequence.image_ids.push(imageId); + + const index = idCounter - start; + const north = (-intervals * distance) / 2 + distance * index; + const up = 0; + const [computedLng, computedLat, computedAlt] = enuToGeodetic( + east, + north, + up, + lng, + lat, + alt, + ); + const computedGeometry = {lat: computedLat, lng: computedLng}; + const rotation = [Math.PI / 2, 0, 0]; + const compassAngle = 0; + const cameraParameters = + cameraType === CAMERA_TYPE_SPHERICAL ? [] : [focal, k1, k2]; + + images.push({ + altitude: computedAlt, + atomic_scale: 1, + camera_parameters: cameraParameters, + camera_type: cameraType, + captured_at: 0, + cluster: {id: clusterId, url: clusterId}, + computed_rotation: rotation, + compass_angle: compassAngle, + computed_compass_angle: compassAngle, + computed_altitude: computedAlt, + computed_geometry: computedGeometry, + creator: {id: null, username: null}, + geometry: computedGeometry, + height, + id: imageId, + merge_id: 'merge_id', + mesh: {id: meshId, url: meshId}, + exif_orientation: 1, + private: null, + quality_score: 1, + sequence: {id: sequenceId}, + thumb: {id: thumbId, url: thumbUrl}, + owner: {id: null}, + width, + }); + + idCounter += 1; + } + + const cluster = { + colors: [], + coordinates: [], + id: clusterId, + pointIds: [], + reference: options.reference, + }; + for (let i = 0; i <= intervals; i++) { + const easts = [-3, 3]; + const north = (-intervals * distance) / 2 + distance * i; + const up = 0; + for (let y = 0; y < distance; y++) { + for (let z = 0; z < distance; z++) { + for (const x of easts) { + const pointId = `${i}-${z}-${y}-${x}`; + const cx = east + x; + const cy = north + y; + const cz = up + z; + cluster.pointIds.push(pointId); + cluster.coordinates.push(cx, cy, cz); + cluster.colors.push(...color); + } + } + } + } + + return {cluster, images, sequence}; +} diff --git a/examples/debug/chunk.html b/examples/debug/chunk.html new file mode 100644 index 00000000..676167a0 --- /dev/null +++ b/examples/debug/chunk.html @@ -0,0 +1,166 @@ + + + + Chunk + + + + + + + + + + + + + diff --git a/examples/debug/delete.html b/examples/debug/delete.html new file mode 100644 index 00000000..06caffba --- /dev/null +++ b/examples/debug/delete.html @@ -0,0 +1,95 @@ + + + + Delete + + + + + + + + + + + + + diff --git a/examples/debug/reset.html b/examples/debug/reset.html index 35802a69..78e1f159 100644 --- a/examples/debug/reset.html +++ b/examples/debug/reset.html @@ -69,10 +69,12 @@ }; viewer = new Viewer(options); viewer.on("image", (event) => - console.log(event.image ? event.image.id : null) + console.log("image", event.image ? event.image.id : null) ); - viewer.on("reference", (event) => console.log(event)); - viewer.on("reset", (event) => console.log(event)); + viewer.on("reference", (event) => + console.log("reference", event) + ); + viewer.on("reset", (event) => console.log("reset", event)); viewer .moveTo(dataProvider.images.keys().next().value) .catch((error) => console.error(error)); diff --git a/examples/debug/spatial.html b/examples/debug/spatial.html index 2cafd92e..dbf2eb9f 100644 --- a/examples/debug/spatial.html +++ b/examples/debug/spatial.html @@ -66,6 +66,7 @@ let hoveredImageId = null; let paintedClusterId = null; viewer.on("mousemove", async (event) => { + return; function paintCluster(clusterId, color) { spatial.setPointOverrideColor(clusterId, color); spatial.setCameraOverrideColor(clusterId, color); diff --git a/src/api/DataProviderBase.ts b/src/api/DataProviderBase.ts index d5482ef2..d5189ca1 100644 --- a/src/api/DataProviderBase.ts +++ b/src/api/DataProviderBase.ts @@ -15,6 +15,7 @@ import { ProviderEvent } from "./events/ProviderEvent"; import { ProviderCellEvent } from "./events/ProviderCellEvent"; import { IDataProvider } from "./interfaces/IDataProvider"; import { IGeometryProvider } from "./interfaces/IGeometryProvider"; +import { ProviderClusterEvent } from "./events/ProviderClusterEvent"; /** * @class DataProviderBase @@ -85,6 +86,10 @@ export abstract class DataProviderBase extends EventEmitter implements IDataProv type: "datacreate", event: ProviderCellEvent) : void; + public fire( + type: "datadelete", + event: ProviderClusterEvent) + : void; /** @ignore */ public fire( type: ProviderEventType, @@ -225,6 +230,10 @@ export abstract class DataProviderBase extends EventEmitter implements IDataProv type: ProviderCellEvent["type"], handler: (event: ProviderCellEvent) => void) : void; + public off( + type: ProviderClusterEvent["type"], + handler: (event: ProviderClusterEvent) => void) + : void; /** @ignore */ public off( type: ProviderEventType, @@ -259,6 +268,10 @@ export abstract class DataProviderBase extends EventEmitter implements IDataProv type: "datacreate", handler: (event: ProviderCellEvent) => void) : void; + public on( + type: "datadelete", + handler: (event: ProviderClusterEvent) => void) + : void; /** @ignore */ public on( type: ProviderEventType, diff --git a/src/api/events/ProviderClusterEvent.ts b/src/api/events/ProviderClusterEvent.ts new file mode 100644 index 00000000..88ead92a --- /dev/null +++ b/src/api/events/ProviderClusterEvent.ts @@ -0,0 +1,17 @@ +import { ProviderEvent } from "./ProviderEvent"; + +/** + * + * Interface for data provider cluster events. + */ +export interface ProviderClusterEvent extends ProviderEvent { + /** + * Cluster ids for clusters that have been deleted. + */ + clusterIds: string[]; + + /** + * Provider event type. + */ + type: "datadelete"; +} diff --git a/src/api/events/ProviderEventType.ts b/src/api/events/ProviderEventType.ts index 042f3aca..a64db300 100644 --- a/src/api/events/ProviderEventType.ts +++ b/src/api/events/ProviderEventType.ts @@ -2,4 +2,5 @@ * @event */ export type ProviderEventType = - | "datacreate"; + | "datacreate" + | "datadelete"; diff --git a/src/api/interfaces/IDataProvider.ts b/src/api/interfaces/IDataProvider.ts index bd3c80fa..848e5455 100644 --- a/src/api/interfaces/IDataProvider.ts +++ b/src/api/interfaces/IDataProvider.ts @@ -13,6 +13,7 @@ import { ProviderEventType } from "../events/ProviderEventType"; import { ProviderEvent } from "../events/ProviderEvent"; import { ProviderCellEvent } from "../events/ProviderCellEvent"; import { IGeometryProvider } from "./IGeometryProvider"; +import { ProviderClusterEvent } from "../events/ProviderClusterEvent"; /** * @interface IDataProvider @@ -63,6 +64,37 @@ export interface IDataProvider extends EventEmitter { type: "datacreate", event: ProviderCellEvent) : void; + /** + * Fire when data has been created in the data provider + * after initial load. + * + * @param type datacreate + * @param event Provider cell event + * + * @example + * ```js + * // Initialize the data provider + * class MyDataProvider extends DataProviderBase { + * // Class implementation + * } + * var provider = new MyDataProvider(); + * // Create the event + * var clusterIds = [ // Determine deleted clusters ]; + * var target = provider; + * var type = "datadelete"; + * var event = { + * clusterIds, + * target, + * type, + * }; + * // Fire the event + * provider.fire(type, event); + * ``` + */ + fire( + type: "datadelete", + event: ProviderClusterEvent) + : void; /** @ignore */ fire( type: ProviderEventType, @@ -183,6 +215,10 @@ export interface IDataProvider extends EventEmitter { type: ProviderCellEvent["type"], handler: (event: ProviderCellEvent) => void) : void; + off( + type: ProviderClusterEvent["type"], + handler: (event: ProviderClusterEvent) => void) + : void; /** @ignore */ off( type: ProviderEventType, @@ -216,6 +252,28 @@ export interface IDataProvider extends EventEmitter { type: "datacreate", handler: (event: ProviderCellEvent) => void) : void; + /** + * Fired when data has been deleted in the data provider + * after initial load. + * + * @event datacreate + * @example + * ```js + * // Initialize the data provider + * class MyDataProvider extends DataProviderBase { + * // implementation + * } + * var provider = new MyDataProvider(); + * // Set an event listener + * provider.on("datadelete", function() { + * console.log("A datacreate event has occurred."); + * }); + * ``` + */ + on( + type: "datadelete", + handler: (event: ProviderClusterEvent) => void) + : void; /** @ignore */ on( type: ProviderEventType, diff --git a/src/component/spatial/SpatialCache.ts b/src/component/spatial/SpatialCache.ts index ae8e1630..caea58bb 100644 --- a/src/component/spatial/SpatialCache.ts +++ b/src/component/spatial/SpatialCache.ts @@ -27,33 +27,45 @@ type ClusterData = { url: string; }; +type Cluster = { + cellIds: Set; + contract: ClusterContract; +}; + +type Cell = { + clusters: Map; + images: Map; +}; + +type ClusterRequest = { + cancel: Function; + request: Observable; +}; + export class SpatialCache { private _graphService: GraphService; private _api: APIWrapper; - private _cacheRequests: { [cellId: string]: Function[]; }; - private _cells: { [cellId: string]: Image[]; }; - - private _clusters: { [key: string]: ClusterContract; }; - private _clusterCells: { [key: string]: string[]; }; + private _cells: Map; + private _clusters: { [key: string]: Cluster; }; private _cellClusters: { [cellId: string]: ClusterData[]; }; - private _cachingClusters$: { [cellId: string]: Observable; }; - private _cachingCells$: { [cellId: string]: Observable; }; + private _cellClusterRequests: { [cellId: string]: ClusterRequest; }; + private _cellImageRequests: { [cellId: string]: Observable; }; + private _clusterRequests: Set; constructor(graphService: GraphService, api: APIWrapper) { this._graphService = graphService; this._api = api; - this._cells = {}; - this._cacheRequests = {}; + this._cells = new Map(); this._clusters = {}; - this._clusterCells = {}; this._cellClusters = {}; - this._cachingCells$ = {}; - this._cachingClusters$ = {}; + this._cellImageRequests = {}; + this._cellClusterRequests = {}; + this._clusterRequests = new Set(); } public cacheClusters$(cellId: string): Observable { @@ -66,7 +78,7 @@ export class SpatialCache { } if (this.isCachingClusters(cellId)) { - return this._cachingClusters$[cellId]; + return this._cellClusterRequests[cellId].request; } const duplicatedClusters: ClusterData[] = this.getCell(cellId) @@ -89,31 +101,26 @@ export class SpatialCache { .values()); this._cellClusters[cellId] = clusters; - this._cacheRequests[cellId] = []; - let aborter: Function; - const abort: Promise = new Promise( + let cancel: Function; + const cancellationToken: Promise = new Promise( (_, reject): void => { - aborter = reject; + cancel = reject; }); - this._cacheRequests[cellId].push(aborter); + this._cellClusterRequests[cellId] = { + cancel, request: + this._cacheClusters$(clusters, cellId, cancellationToken).pipe( + finalize( + (): void => { + if (cellId in this._cellClusterRequests) { + delete this._cellClusterRequests[cellId]; + } + }), + publish(), + refCount()) + }; - this._cachingClusters$[cellId] = - this._cacheClusters$(clusters, cellId, abort).pipe( - finalize( - (): void => { - if (cellId in this._cachingClusters$) { - delete this._cachingClusters$[cellId]; - } - - if (cellId in this._cacheRequests) { - delete this._cacheRequests[cellId]; - } - }), - publish(), - refCount()); - - return this._cachingClusters$[cellId]; + return this._cellClusterRequests[cellId].request; } public cacheCell$(cellId: string): Observable { @@ -122,10 +129,10 @@ export class SpatialCache { } if (this.isCachingCell(cellId)) { - return this._cachingCells$[cellId]; + return this._cellImageRequests[cellId]; } - this._cachingCells$[cellId] = this._graphService.cacheCell$(cellId).pipe( + this._cellImageRequests[cellId] = this._graphService.cacheCell$(cellId).pipe( catchError( (error: Error): Observable => { console.error(error); @@ -134,37 +141,50 @@ export class SpatialCache { }), filter( (): boolean => { - return !(cellId in this._cells); + return !this._cells.has(cellId); }), tap( (images: Image[]): void => { - this._cells[cellId] = []; - this._cells[cellId].push(...images); + const cell: Cell = { + clusters: new Map(), + images: new Map(), + }; + this._cells.set(cellId, cell); + for (const image of images) { + cell.images.set(image.id, image); + const clusterId = image.clusterId; + if (!cell.clusters.has(clusterId)) { + cell.clusters.set(clusterId, []); + } + const clusterImageIds = + cell.clusters.get(clusterId); + clusterImageIds.push(image.id); + } - delete this._cachingCells$[cellId]; + delete this._cellImageRequests[cellId]; }), finalize( (): void => { - if (cellId in this._cachingCells$) { - delete this._cachingCells$[cellId]; + if (cellId in this._cellImageRequests) { + delete this._cellImageRequests[cellId]; } }), publish(), refCount()); - return this._cachingCells$[cellId]; + return this._cellImageRequests[cellId]; } public isCachingClusters(cellId: string): boolean { - return cellId in this._cachingClusters$; + return cellId in this._cellClusterRequests; } public isCachingCell(cellId: string): boolean { - return cellId in this._cachingCells$; + return cellId in this._cellImageRequests; } public hasClusters(cellId: string): boolean { - if (cellId in this._cachingClusters$ || + if (cellId in this._cellClusterRequests || !(cellId in this._cellClusters)) { return false; } @@ -179,7 +199,7 @@ export class SpatialCache { } public hasCell(cellId: string): boolean { - return !(cellId in this._cachingCells$) && cellId in this._cells; + return !(cellId in this._cellImageRequests) && this._cells.has(cellId); } public getClusters(cellId: string): ClusterContract[] { @@ -187,7 +207,8 @@ export class SpatialCache { this._cellClusters[cellId] .map( (cd: ClusterData): ClusterContract => { - return this._clusters[cd.key]; + const cluster = this._clusters[cd.key]; + return cluster ? cluster.contract : null; }) .filter( (reconstruction: ClusterContract): boolean => { @@ -197,20 +218,53 @@ export class SpatialCache { } public getCell(cellId: string): Image[] { - return cellId in this._cells ? this._cells[cellId] : []; + return this._cells.has(cellId) ? + Array.from(this._cells.get(cellId).images.values()) : []; + } + + public removeCluster(clusterId: string): void { + this._clusterRequests.delete(clusterId); + + if (clusterId in this._clusters) { + delete this._clusters[clusterId]; + } + + const cellIds: string[] = []; + for (const [cellId, cell] of this._cells.entries()) { + if (cell.clusters.has(clusterId)) { + cellIds.push(cellId); + } + } + + for (const cellId of cellIds) { + if (!this._cells.has(cellId)) { + continue; + } + const cell = this._cells.get(cellId); + const clusterImages = cell.clusters.get(clusterId) ?? []; + for (const imageId of clusterImages) { + cell.images.delete(imageId); + } + cell.clusters.delete(clusterId); + + if (cellId in this._cellClusters) { + const cellClusters = this._cellClusters[cellId]; + const index = cellClusters.findIndex(cd => cd.key === clusterId); + if (index !== -1) { + cellClusters.splice(index, 1); + } + } + } } public uncache(keepCellIds?: string[]): void { - for (let cellId of Object.keys(this._cacheRequests)) { + for (const cellId of Object.keys(this._cellClusterRequests)) { if (!!keepCellIds && keepCellIds.indexOf(cellId) !== -1) { continue; } - for (const aborter of this._cacheRequests[cellId]) { - aborter(); - } - - delete this._cacheRequests[cellId]; + this._cellClusterRequests[cellId].cancel(); + delete this._cellClusterRequests[cellId]; } for (let cellId of Object.keys(this._cellClusters)) { @@ -219,34 +273,28 @@ export class SpatialCache { } for (const cd of this._cellClusters[cellId]) { - if (!(cd.key in this._clusterCells)) { + if (!(cd.key in this._clusters)) { continue; } - const index: number = this._clusterCells[cd.key].indexOf(cellId); - if (index === -1) { + const { cellIds } = this._clusters[cd.key]; + cellIds.delete(cellId); + if (cellIds.size > 0) { continue; } - this._clusterCells[cd.key].splice(index, 1); - - if (this._clusterCells[cd.key].length > 0) { - continue; - } - - delete this._clusterCells[cd.key]; delete this._clusters[cd.key]; } delete this._cellClusters[cellId]; } - for (let cellId of Object.keys(this._cells)) { + for (let cellId of this._cells.keys()) { if (!!keepCellIds && keepCellIds.indexOf(cellId) !== -1) { continue; } - delete this._cells[cellId]; + this._cells.delete(cellId); } } @@ -264,12 +312,21 @@ export class SpatialCache { }), filter( (): boolean => { - return cellId in this._cells; + return this._cells.has(cellId); }), tap( (images: Image[]): void => { - this._cells[cellId] = []; - this._cells[cellId].push(...images); + const cell = this._cells.get(cellId); + for (const image of images) { + cell.images.set(image.id, image); + const clusterId = image.clusterId; + if (!cell.clusters.has(clusterId)) { + cell.clusters.set(clusterId, []); + } + const clusterImageIds = + cell.clusters.get(clusterId); + clusterImageIds.push(image.id); + } }), publish(), refCount()); @@ -324,6 +381,7 @@ export class SpatialCache { this._getCluster(cd.key)); } + this._clusterRequests.add(cd.key); return this._getCluster$( cd.url, cd.key, @@ -341,27 +399,28 @@ export class SpatialCache { }, 6), filter( - (): boolean => { - return cellId in this._cellClusters; + (cluster: ClusterContract): boolean => { + return cellId in this._cellClusters && + this._clusterRequests.has(cluster.id); }), tap( - (reconstruction: ClusterContract): void => { - if (!this._hasCluster(reconstruction.id)) { - this._clusters[reconstruction.id] = reconstruction; + (cluster: ClusterContract): void => { + if (!this._hasCluster(cluster.id)) { + this._clusters[cluster.id] = { + cellIds: new Set(), + contract: cluster, + }; } - if (!(reconstruction.id in this._clusterCells)) { - this._clusterCells[reconstruction.id] = []; - } + const { cellIds } = this._clusters[cluster.id]; + cellIds.add(cellId); - if (this._clusterCells[reconstruction.id].indexOf(cellId) === -1) { - this._clusterCells[reconstruction.id].push(cellId); - } + this._clusterRequests.delete(cluster.id); })); } private _getCluster(id: string): ClusterContract { - return this._clusters[id]; + return this._clusters[id].contract; } private _getCluster$(url: string, clusterId: string, abort: Promise): Observable { diff --git a/src/component/spatial/SpatialComponent.ts b/src/component/spatial/SpatialComponent.ts index c65d6c02..0adada2e 100644 --- a/src/component/spatial/SpatialComponent.ts +++ b/src/component/spatial/SpatialComponent.ts @@ -597,6 +597,15 @@ export class SpatialComponent extends Component { })) .subscribe(this._container.glRenderer.render$)); + subs.push(this._navigator.graphService.dataDeleted$ + .subscribe( + (clusterIds: string[]): void => { + for (const clusterId of clusterIds) { + this._cache.removeCluster(clusterId); + this._scene.uncacheCluster(clusterId); + } + })); + const updatedCell$ = this._navigator.graphService.dataAdded$ .pipe( filter( diff --git a/src/component/spatial/SpatialScene.ts b/src/component/spatial/SpatialScene.ts index 13759d93..4fab9f10 100644 --- a/src/component/spatial/SpatialScene.ts +++ b/src/component/spatial/SpatialScene.ts @@ -69,6 +69,7 @@ export class SpatialScene { private _imageCellMap: Map; private _clusterCellMap: Map>; + private _clusterImageMap: Map>; private _colors: { hover: string, select: string; }; private _cameraOverrideColors: Map; @@ -79,6 +80,7 @@ export class SpatialScene { scene?: Scene) { this._imageCellMap = new Map(); this._clusterCellMap = new Map(); + this._clusterImageMap = new Map(); this._scene = !!scene ? scene : new Scene(); this._scene.autoUpdate = false; @@ -145,8 +147,8 @@ export class SpatialScene { this._scene.add(points); this._clusters[clusterId] = { - points: points, cellIds: [], + points: points, }; } @@ -159,6 +161,9 @@ export class SpatialScene { if (this._cellClusters[cellId].keys.indexOf(clusterId) === -1) { this._cellClusters[cellId].keys.push(clusterId); } + if (!this._clusterImageMap.has(clusterId)) { + this._clusterImageMap.set(clusterId, new Set()); + } this._needsRender = true; } @@ -220,6 +225,11 @@ export class SpatialScene { } this._imageCellMap.set(imageId, cellId); + if (!this._clusterImageMap.has(idMap.clusterId)) { + this._clusterImageMap.set(idMap.clusterId, new Set()); + } + this._clusterImageMap.get(idMap.clusterId).add(imageId); + if (imageId === this._selectedId) { this._highlight( imageId, @@ -560,6 +570,54 @@ export class SpatialScene { this._needsRender = true; } + public uncacheCluster(clusterId: string): void { + const cellIds = new Set(); + if (clusterId in this._clusters) { + const cluster = this._clusters[clusterId]; + for (const cellId of cluster.cellIds) { + cellIds.add(cellId); + } + this._scene.remove(cluster.points); + cluster.points.dispose(); + delete this._clusters[clusterId]; + } + + for (const cellId of this._clusterCellMap.get(clusterId) ?? []) { + cellIds.add(cellId); + } + this._clusterCellMap.delete(clusterId); + + for (const cellId of cellIds.values()) { + if (!(cellId in this._cellClusters)) { + continue; + } + const cellClusters = this._cellClusters[cellId]; + const index = cellClusters.keys.indexOf(clusterId); + if (index !== -1) { + cellClusters.keys.splice(index, 1); + } + } + + for (const cellId of cellIds.values()) { + if (!(cellId in this._images)) { + continue; + } + const cell = this._images[cellId]; + cell.disposeCluster(clusterId); + } + + if (this._clusterImageMap.has(clusterId)) { + const imageCellMap = this._imageCellMap; + const imageIds = this._clusterImageMap.get(clusterId).values(); + for (const imageId of imageIds) { + imageCellMap.delete(imageId); + } + this._clusterImageMap.delete(clusterId); + } + + this._needsRender = true; + } + private _applyCameraColor(cellIds: string[]): void { const mode = this._cameraVisualizationMode; diff --git a/src/component/spatial/scene/SpatialCell.ts b/src/component/spatial/scene/SpatialCell.ts index 2d4cd69a..f25ab996 100644 --- a/src/component/spatial/scene/SpatialCell.ts +++ b/src/component/spatial/scene/SpatialCell.ts @@ -15,7 +15,7 @@ import { isSpherical } from "../../../geo/Geo"; import { SphericalCameraFrame } from "./SphericalCameraFrame"; import { PerspectiveCameraFrame } from "./PerspectiveCameraFrame"; import { LngLatAlt } from "../../../api/interfaces/LngLatAlt"; -import { resetEnu, SPATIAL_DEFAULT_COLOR } from "../SpatialCommon"; +import { resetEnu } from "../SpatialCommon"; type IDCamera = { camera: CameraFrameBase, @@ -70,6 +70,8 @@ export class SpatialCell { private _frameMaterial: LineBasicMaterial; private _positionMaterial: LineBasicMaterial; + private readonly _clusterImages: Map; + constructor( public readonly id: string, private _scene: Scene, @@ -97,6 +99,8 @@ export class SpatialCell { color: 0xff0000, }); + this._clusterImages = new Map(); + this._scene.add( this.cameras, this._positions); @@ -133,6 +137,11 @@ export class SpatialCell { ids: { ccId, clusterId: cId, sequenceId: sId }, }; this.keys.push(id); + + if (!this._clusterImages.has(cId)) { + this._clusterImages.set(cId, []); + } + this._clusterImages.get(cId).push(id); } public applyCameraColor(imageId: string, color: string | number): void { @@ -182,6 +191,49 @@ export class SpatialCell { this._intersection = null; } + + public disposeCluster(clusterId: string): void { + if (!this._clusterImages.has(clusterId)) { + return; + } + + const { + _cameraFrames, + _intersection, + _positionLines, + _positions, + _props, + cameras, + keys, + } = this; + + const imageIds = this._clusterImages.get(clusterId); + for (const imageId of imageIds) { + this._disposeCamera( + _cameraFrames[imageId], + cameras, + _intersection); + this._disposePosition( + _positionLines[imageId], + _positions + ); + + delete _cameraFrames[imageId]; + delete _positionLines[imageId]; + + const index = keys.indexOf(imageId); + if (index !== -1) { + keys.splice(index, 1); + } + + delete _props[imageId]; + } + + this._clusterImages.delete(clusterId); + this._clusters.delete(clusterId); + delete this.clusterVisibles[clusterId]; + } + public getCamerasByMode(mode: CameraVisualizationMode): ColorIdCamerasMap { switch (mode) { case CameraVisualizationMode.Cluster: @@ -295,22 +347,35 @@ export class SpatialCell { this._positionLines[id] = position; } + private _disposeCamera( + camera: CameraFrameBase, + cameras: Object3D, + intersection: SpatialIntersection): void { + camera.dispose(); + intersection.remove(camera); + cameras.remove(camera); + } + private _disposeCameras(): void { const intersection = this._intersection; const cameras = this.cameras; for (const camera of cameras.children.slice()) { - (camera).dispose(); - intersection.remove(camera); - cameras.remove(camera); + this._disposeCamera(camera, cameras, intersection); } this._scene.remove(this.cameras); } + private _disposePosition( + position: PositionLine, + positions: Object3D): void { + position.dispose(); + positions.remove(position); + } + private _disposePositions(): void { const positions = this._positions; for (const position of positions.children.slice()) { - (position).dispose(); - positions.remove(position); + this._disposePosition(position, positions); } this._scene.remove(this._positions); } diff --git a/src/external/api.ts b/src/external/api.ts index d32a2ef6..d548e7c8 100644 --- a/src/external/api.ts +++ b/src/external/api.ts @@ -35,6 +35,7 @@ export { S2GeometryProvider } from "../api/S2GeometryProvider"; // Event export { ProviderCellEvent } from "../api/events/ProviderCellEvent"; +export { ProviderClusterEvent } from "../api/events/ProviderClusterEvent"; export { ProviderEvent } from "../api/events/ProviderEvent"; export { ProviderEventType } from "../api/events/ProviderEventType"; diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 28061bd0..a1d55148 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -11,6 +11,7 @@ import { import { catchError, + filter as filterObservable, finalize, map, mergeAll, @@ -70,6 +71,8 @@ type SequenceAccess = { }; export type NodeIndexItem = { + cellId: string, + id: string, lat: number; lng: number; node: Image; @@ -153,6 +156,11 @@ export class Graph { private _filter$: Observable; private _filterSubscription: Subscription; + /** + * Nodes of clusters. + */ + private _clusterNodes: Map>; + /** * All nodes in the graph. */ @@ -163,11 +171,17 @@ export class Graph { */ private _nodeIndex: any; + /** + * All node index items sorted in tiles for easy uncache. + */ + private _nodeIndexNodes: Map; + /** * All node index items sorted in tiles for easy uncache. */ private _nodeIndexTiles: { [h: string]: NodeIndexItem[]; }; + /** * Node to tile dictionary for easy tile access updates. */ @@ -178,6 +192,11 @@ export class Graph { */ private _preStored: { [h: string]: { [key: string]: Image; }; }; + /** + * Nodes deleted through event, not yet possible to determine if they can be disposed. + */ + private _preDeletedNodes: Map; + /** * Tiles required for a node to retrive spatial area. */ @@ -251,12 +270,15 @@ export class Graph { maxUnusedTiles: 20, }; + this._clusterNodes = new Map(); this._nodes = {}; this._nodeIndex = nodeIndex ?? new Graph._spatialIndex(16); + this._nodeIndexNodes = new Map(); this._nodeIndexTiles = {}; this._nodeToTile = {}; this._preStored = {}; + this._preDeletedNodes = new Map(); this._requiredNodeTiles = {}; this._requiredSpatialArea = {}; @@ -566,7 +588,12 @@ export class Graph { `Image has no sequence key (${key}).`); } - const node = new Image(item.node); + let node: Image = null; + if (this._preDeletedNodes.has(id)) { + node = this._unDeleteNode(id); + } else { + node = new Image(item.node); + } this._makeFull(node, item.node); const cellId = this._api.data.geometry @@ -720,7 +747,13 @@ export class Graph { console.warn(`Sequence missing, discarding node (${item.node_id})`); } - const node = new Image(item.node); + let node: Image = null; + if (this._preDeletedNodes.has(id)) { + node = this._unDeleteNode(id); + } else { + node = new Image(item.node); + } + this._makeFull(node, item.node); const cellId = this._api.data.geometry @@ -795,6 +828,10 @@ export class Graph { let spatialNodeBatch$: Observable = this._api.getSpatialImages$(batch).pipe( tap( (items: SpatialImagesContract): void => { + if (!(key in this._cachingSpatialArea$)) { + return; + } + for (const item of items) { if (!item.node) { console.warn(`Image is empty (${item.node_id})`); @@ -1303,45 +1340,31 @@ export class Graph { * Reset all spatial edges of the graph nodes. */ public resetSpatialEdges(): void { - let cachedKeys: string[] = Object.keys(this._cachedSpatialEdges); - - for (let cachedKey of cachedKeys) { - let node: Image = this._cachedSpatialEdges[cachedKey]; + for (const nodeId of Object.keys(this._cachedSpatialEdges)) { + const node = this._cachedSpatialEdges[nodeId]; node.resetSpatialEdges(); - - delete this._cachedSpatialEdges[cachedKey]; } + + this._cachedSpatialEdges = {}; } /** - * Reset the complete graph but keep the nodes corresponding - * to the supplied keys. All other nodes will be disposed. - * - * @param {Array} keepKeys - Keys for nodes to keep - * in graph after reset. + * Reset all spatial areas of the graph nodes. */ - public reset(keepKeys: string[]): void { - const nodes: Image[] = []; - for (const key of keepKeys) { - if (!this.hasNode(key)) { - throw new Error(`Image does not exist ${key}`); - } - - const node: Image = this.getNode(key); - node.resetSequenceEdges(); - node.resetSpatialEdges(); - nodes.push(node); - } + public resetSpatialArea(): void { + this._requiredSpatialArea = {}; + this._cachingSpatialArea$ = {}; + } + /** + * Reset the complete graph and disposed all nodes. + */ + public reset(): void { for (let cachedKey of Object.keys(this._cachedNodes)) { - if (keepKeys.indexOf(cachedKey) !== -1) { - continue; - } - this._cachedNodes[cachedKey].node.dispose(); - delete this._cachedNodes[cachedKey]; } + this._cachedNodes = {}; this._cachedNodeTiles = {}; this._cachedSpatialEdges = {}; this._cachedTiles = {}; @@ -1352,23 +1375,19 @@ export class Graph { this._cachingSpatialArea$ = {}; this._cachingTiles$ = {}; + this._clusterNodes = new Map(); this._nodes = {}; this._nodeToTile = {}; this._preStored = {}; - - for (const node of nodes) { - this._nodes[node.id] = node; - - const h: string = this._api.data.geometry.lngLatToCellId(node.originalLngLat); - this._preStore(h, node); - } + this._preDeletedNodes = new Map(); this._requiredNodeTiles = {}; this._requiredSpatialArea = {}; this._sequences = {}; + this._nodeIndexNodes = new Map(); this._nodeIndexTiles = {}; this._nodeIndex.clear(); } @@ -1457,6 +1476,7 @@ export class Graph { for (const id in idsInUse) { if (!idsInUse.hasOwnProperty(id)) { continue; } + if (!this.hasNode(id)) { continue; } const node = this._nodes[id]; const nodeCellId = geometry.lngLatToCellId(node.lngLat); @@ -1570,6 +1590,19 @@ export class Graph { } } + for (const [nodeId, node] of this._preDeletedNodes.entries()) { + if (nodeId in idsInUse) { + continue; + } + + if (nodeId in this._cachedNodes) { + delete this._cachedNodes[nodeId]; + } + + this._preDeletedNodes.delete(nodeId); + node.dispose(); + } + const potentialSequences: SequenceAccess[] = []; for (let sequenceId in this._sequences) { if (!this._sequences.hasOwnProperty(sequenceId) || @@ -1613,7 +1646,7 @@ export class Graph { * cached. * * @param {Array} cellIds - Cell ids. - * @returns {Observable>} Observable + * @returns {Observable>} Observable * emitting the updated cells. */ public updateCells$(cellIds: string[]): Observable { @@ -1639,6 +1672,80 @@ export class Graph { )); } + /** + * Deletes clusters. + * + * @description Existing nodes for the clusters are deleted + * and placed in a deleted store. The deleted store will be + * purged during uncaching if the nodes are no longer in use. + * + * Nodes in the deleted store are always removed on reset. + * + * @param {Array} clusterIds - Cluster ids. + * @returns {Observable>} Observable + * emitting the IDs for the deleted clusters. + */ + public deleteClusters$(clusterIds: string[]): Observable { + if (!clusterIds.length) { + return observableEmpty(); + } + + return observableFrom(clusterIds) + .pipe( + map( + (clusterId: string): string | null => { + if (!this._clusterNodes.has(clusterId)) { + return null; + } + + const clusterNodes = this._clusterNodes.get(clusterId); + for (const nodeId of clusterNodes.values()) { + const node = this._nodes[nodeId]; + delete this._nodes[nodeId]; + + if (nodeId in this._cachedNodeTiles) { + delete this._cachedNodeTiles[nodeId]; + } + + if (nodeId in this._cachedNodeTiles) { + delete this._cachedNodeTiles[nodeId]; + } + + if (nodeId in this._nodeToTile) { + const nodeCellId = this._nodeToTile[nodeId]; + if (nodeCellId in this._cachedTiles) { + const tileIndex = + this._cachedTiles[nodeCellId].nodes + .findIndex(n => n.id === nodeId); + if (tileIndex !== -1) { + this._cachedTiles[nodeCellId].nodes.splice(tileIndex, 1); + } + } + delete this._nodeToTile[nodeId]; + } + + const item = this._nodeIndexNodes.get(nodeId); + this._nodeIndex.remove(item); + this._nodeIndexNodes.delete(nodeId); + const cell = this._nodeIndexTiles[item.cellId]; + const nodeIndex = cell.indexOf(item); + if (nodeIndex === -1) { + throw new GraphMapillaryError(`Corrupt graph index cell (${nodeId})`); + } + cell.splice(nodeIndex, 1); + + this._preDeletedNodes.set(nodeId, node); + } + + this._clusterNodes.delete(clusterId); + + return clusterId; + }), + filterObservable((clusterId: string | null): boolean => { + return clusterId != null; + })); + } + /** * Unsubscribes all subscriptions. * @@ -1719,6 +1826,7 @@ export class Graph { }; const hCache = this._cachedTiles[cellId].nodes; const preStored = this._removeFromPreStore(cellId); + const preDeleted = this._preDeletedNodes; for (const core of cores) { if (!core) { break; } @@ -1734,26 +1842,39 @@ export class Graph { delete preStored[core.id]; hCache.push(preStoredNode); const preStoredNodeIndexItem: NodeIndexItem = { + cellId, + id: core.id, lat: preStoredNode.lngLat.lat, lng: preStoredNode.lngLat.lng, node: preStoredNode, }; this._nodeIndex.insert(preStoredNodeIndexItem); + this._nodeIndexNodes.set(core.id, preStoredNodeIndexItem); this._nodeIndexTiles[cellId] .push(preStoredNodeIndexItem); this._nodeToTile[preStoredNode.id] = cellId; continue; } - const node = new Image(core); + let node: Image = null; + if (preDeleted.has(core.id)) { + node = preDeleted.get(core.id); + preDeleted.delete(core.id); + } else { + node = new Image(core); + } + hCache.push(node); const nodeIndexItem: NodeIndexItem = { + cellId, + id: node.id, lat: node.lngLat.lat, lng: node.lngLat.lng, node: node, }; this._nodeIndex.insert(nodeIndexItem); + this._nodeIndexNodes.set(node.id, nodeIndexItem); this._nodeIndexTiles[cellId].push(nodeIndexItem); this._nodeToTile[node.id] = cellId; @@ -1775,6 +1896,36 @@ export class Graph { return this._cachingTiles$[cellId]; } + private _addClusterNode(node: Image): void { + const clusterId = node.clusterId; + if (clusterId == null) { + throw new GraphMapillaryError(`Image does not have cluster (${node.id}).`); + } + + if (!this._clusterNodes.has(clusterId)) { + this._clusterNodes.set(clusterId, new Set()); + } + + const clusterNodes = this._clusterNodes.get(clusterId); + if (clusterNodes.has(node.id)) { + throw new GraphMapillaryError(`Cluster has image (${clusterId}, ${node.id}).`); + } + clusterNodes.add(node.id); + } + + private _removeClusterNode(node: Image): void { + const clusterId = node.clusterId; + if (clusterId == null || !this._clusterNodes.has(clusterId)) { + return; + } + + const clusterNodes = this._clusterNodes.get(clusterId); + clusterNodes.delete(node.id); + if (!clusterNodes.size) { + this._clusterNodes.delete(clusterId); + } + } + private _makeFull(node: Image, fillNode: SpatialImageEnt): void { if (fillNode.computed_altitude == null) { fillNode.computed_altitude = this._defaultAlt; @@ -1785,6 +1936,12 @@ export class Graph { } node.makeComplete(fillNode); + this._addClusterNode(node); + } + + private _disposeNode(node: Image): void { + this._removeClusterNode(node); + node.dispose(); } private _preStore(h: string, node: Image): void { @@ -1818,7 +1975,7 @@ export class Graph { private _uncacheTile(h: string, keepSequenceKey: string): void { for (let node of this._cachedTiles[h].nodes) { - let key: string = node.id; + let key = node.id; delete this._nodeToTile[key]; @@ -1844,12 +2001,13 @@ export class Graph { delete this._cachedSequenceNodes[node.sequenceId]; } - node.dispose(); + this._disposeNode(node); } } for (let nodeIndexItem of this._nodeIndexTiles[h]) { this._nodeIndex.remove(nodeIndexItem); + this._nodeIndexNodes.delete(nodeIndexItem.id); } delete this._nodeIndexTiles[h]; @@ -1867,15 +2025,13 @@ export class Graph { delete this._cachedNodes[key]; } - let node: Image = this._preStored[h][key]; - + const node = this._preStored[h][key]; if (node.sequenceId in this._cachedSequenceNodes) { delete this._cachedSequenceNodes[node.sequenceId]; } - delete this._preStored[h][key]; - node.dispose(); + this._disposeNode(node); hs[h] = true; } @@ -1913,6 +2069,7 @@ export class Graph { const nodeIndex = this._nodeIndex; const nodeIndexCell = this._nodeIndexTiles[cellId]; + const nodeIndexNodes = this._nodeIndexNodes; const nodeToCell = this._nodeToTile; const cell = this._cachedTiles[cellId]; cell.accessed = new Date().getTime(); @@ -1920,6 +2077,7 @@ export class Graph { const cores = contract.images; for (const core of cores) { + if (core == null) { break; } if (this.hasNode(core.id)) { continue; } @@ -1929,15 +2087,24 @@ export class Graph { continue; } - const node = new Image(core); + let node: Image = null; + if (this._preDeletedNodes.has(core.id)) { + node = this._unDeleteNode(core.id); + } else { + node = new Image(core); + } + cellNodes.push(node); const nodeIndexItem: NodeIndexItem = { + cellId, + id: node.id, lat: node.lngLat.lat, lng: node.lngLat.lng, node: node, }; nodeIndex.insert(nodeIndexItem); nodeIndexCell.push(nodeIndexItem); + nodeIndexNodes.set(node.id, nodeIndexItem); nodeToCell[node.id] = cellId; this._setNode(node); } @@ -1949,4 +2116,18 @@ export class Graph { return observableEmpty(); })); } + + private _unDeleteNode(id: string): Image { + if (!this._preDeletedNodes.has(id)) { + throw new GraphMapillaryError(`Pre-deleted node does not exist ${id}`); + } + const node = this._preDeletedNodes.get(id); + this._preDeletedNodes.delete(id); + + if (node.isComplete) { + this._addClusterNode(node); + } + + return node; + } } diff --git a/src/graph/GraphService.ts b/src/graph/GraphService.ts index 9a887072..26ad8904 100644 --- a/src/graph/GraphService.ts +++ b/src/graph/GraphService.ts @@ -35,6 +35,7 @@ import { ProviderCellEvent } from "../api/events/ProviderCellEvent"; import { GraphMapillaryError } from "../error/GraphMapillaryError"; import { ProjectionService } from "../viewer/ProjectionService"; import { ICameraFactory } from "../geometry/interfaces/ICameraFactory"; +import { ProviderClusterEvent } from "../api/events/ProviderClusterEvent"; /** * @class GraphService @@ -50,6 +51,7 @@ export class GraphService { private _firstGraphSubjects$: Subject[]; private _dataAdded$: Subject = new Subject(); + private _dataDeleted$: Subject = new Subject(); private _dataReset$: Subject = new Subject(); private _initializeCacheSubscriptions: Subscription[]; @@ -93,6 +95,7 @@ export class GraphService { this._spatialSubscriptions = []; graph.api.data.on("datacreate", this._onDataAdded); + graph.api.data.on("datadelete", this._onDataDeleted); } /** @@ -105,6 +108,16 @@ export class GraphService { return this._dataAdded$; } + /** + * Get dataDeleted$. + * + * @returns {Observable} Observable emitting + * a cluster id every time a cluster has been deleted. + */ + public get dataDeleted$(): Observable { + return this._dataDeleted$; + } + public get dataReset$(): Observable { return this._dataReset$; } @@ -299,7 +312,12 @@ export class GraphService { sequenceSubscription = graphSequence$.pipe( tap( (graph: Graph): void => { - if (!graph.getNode(id).sequenceEdges.cached) { + const node = graph.getNode(id); + if (!node.hasInitializedCache()) { + return; + } + + if (!node.sequenceEdges.cached) { graph.cacheSequenceEdges(id); } }), @@ -382,7 +400,12 @@ export class GraphService { }), tap( (graph: Graph): void => { - if (!graph.getNode(id).spatialEdges.cached) { + const node = graph.getNode(id); + if (!node.hasInitializedCache()) { + return; + } + + if (!node.spatialEdges.cached) { graph.cacheSpatialEdges(id); } }), @@ -489,6 +512,23 @@ export class GraphService { this._subscriptions.unsubscribe(); } + /** + * Check if an image exists in the graph. + * + * @description If a node has been deleted it will not exist. + * + * @return {Observable} Observable emitting a single item, + * a value indicating if the image exists in the graph. + */ + public hasImage$(id: string): Observable { + return this._graph$.pipe( + first(), + map( + (graph: Graph): boolean => { + return graph.hasNode(id); + })); + } + /** * Set a spatial edge filter on the graph. * @@ -546,11 +586,10 @@ export class GraphService { * @description Resets the graph but keeps the images of the * supplied ids. * - * @param {Array} keepIds - Ids of images to keep in graph. * @return {Observable} Observable emitting a single item, * the graph, when it has been reset. */ - public reset$(keepIds: string[]): Observable { + public reset$(): Observable { this._abortSubjects(this._firstGraphSubjects$); this._resetSubscriptions(this._initializeCacheSubscriptions); this._resetSubscriptions(this._sequenceSubscriptions); @@ -560,7 +599,7 @@ export class GraphService { first(), tap( (graph: Graph): void => { - graph.reset(keepIds); + graph.reset(); this._dataReset$.next(); }), map( @@ -617,12 +656,33 @@ export class GraphService { first(), mergeMap( graph => { - return graph.updateCells$(event.cellIds).pipe( - tap(() => { graph.resetSpatialEdges(); })); + graph.resetSpatialArea(); + graph.resetSpatialEdges(); + return graph.updateCells$(event.cellIds); })) .subscribe(cellId => { this._dataAdded$.next(cellId); }); }; + private _onDataDeleted = (event: ProviderClusterEvent): void => { + if (!event.clusterIds.length) { + return; + } + + this._graph$ + .pipe( + first(), + mergeMap( + graph => { + graph.resetSpatialArea(); + graph.resetSpatialEdges(); + return graph.deleteClusters$(event.clusterIds); + })) + .subscribe( + null, + null, + () => { this._dataDeleted$.next(event.clusterIds); }); + }; + private _removeFromArray(object: T, objects: T[]): void { const index: number = objects.indexOf(object); if (index !== -1) { diff --git a/src/graph/Image.ts b/src/graph/Image.ts index 5cdeaddf..30726ed5 100644 --- a/src/graph/Image.ts +++ b/src/graph/Image.ts @@ -331,6 +331,19 @@ export class Image { return !this._core; } + /** + * Get is complete. + * + * @returns {boolean} Value indicating that this image + * is complete. + * + * @ignore + */ + public get isComplete(): boolean { + return !!this._core && !!this._spatial; + } + + /** * Get lngLat. * diff --git a/src/graph/ImageCache.ts b/src/graph/ImageCache.ts index 7de3a4c2..322f8efa 100644 --- a/src/graph/ImageCache.ts +++ b/src/graph/ImageCache.ts @@ -198,7 +198,7 @@ export class ImageCache { return this._cachingAssets$; } - this._cachingAssets$ = observableCombineLatest( + const cachingAssets$ = observableCombineLatest( this._cacheImage$(spatial), this._cacheMesh$(spatial, merged)).pipe( map( @@ -215,7 +215,9 @@ export class ImageCache { publishReplay(1), refCount()); - this._cachingAssets$.pipe( + this._cachingAssets$ = cachingAssets$; + + cachingAssets$.pipe( first( (imageCache: ImageCache): boolean => { return !!imageCache._image; @@ -226,7 +228,7 @@ export class ImageCache { }, (): void => { /*noop*/ }); - return this._cachingAssets$; + return cachingAssets$; } /** diff --git a/src/viewer/CacheService.ts b/src/viewer/CacheService.ts index 41dd0864..0c59d5bd 100644 --- a/src/viewer/CacheService.ts +++ b/src/viewer/CacheService.ts @@ -1,5 +1,6 @@ import { empty as observableEmpty, + merge as observableMerge, from as observableFrom, Observable, } from "rxjs"; @@ -8,6 +9,7 @@ import { bufferCount, catchError, distinctUntilChanged, + filter, first, map, mergeMap, @@ -153,12 +155,27 @@ export class CacheService { .subscribe(() => { /*noop*/ })); subs.push( - this._graphService.dataAdded$ + observableMerge( + this._graphService.dataAdded$, + this._graphService.dataDeleted$) .pipe( withLatestFrom(this._stateService.currentId$), switchMap( ([_, imageId]: [string, string]): Observable => { - return this._graphService.cacheImage$(imageId); + return this._graphService.hasImage$(imageId).pipe( + filter((exists: boolean): boolean => { + return exists; + }), + mergeMap((): Observable => { + return this._graphService.cacheImage$(imageId) + .pipe(catchError( + (error): Observable => { + console.warn( + `Cache service data event caching failed ${imageId}`, + error); + return observableEmpty(); + })); + })); })) .subscribe(() => { /*noop*/ })); diff --git a/src/viewer/Navigator.ts b/src/viewer/Navigator.ts index 41cf09c3..97948261 100644 --- a/src/viewer/Navigator.ts +++ b/src/viewer/Navigator.ts @@ -9,6 +9,7 @@ import { } from "rxjs"; import { + filter as observableFilter, finalize, first, last, @@ -274,7 +275,13 @@ export class Navigator { const cacheImages$ = ids .map( (id: string): Observable => { - return this._graphService.cacheImage$(id); + return this._graphService.hasImage$(id).pipe( + observableFilter((exists: boolean): boolean => { + return exists; + }), + mergeMap((): Observable => { + return this._graphService.cacheImage$(id); + })); }); return observableFrom(cacheImages$).pipe( @@ -356,7 +363,7 @@ export class Navigator { tap((): void => { if (preCallback) { preCallback(); }; }), mergeMap( (): Observable => { - return this._graphService.reset$([]); + return this._graphService.reset$(); })); } diff --git a/test/component/spatial/SpatialCache.test.ts b/test/component/spatial/SpatialCache.test.ts index 1939f494..7db23cf4 100644 --- a/test/component/spatial/SpatialCache.test.ts +++ b/test/component/spatial/SpatialCache.test.ts @@ -518,3 +518,111 @@ describe("SpatialCache.updateReconstructions$", () => { resolver(cluster); }); }); + +describe("SpatialCache.removeCluster", () => { + const createCluster = (id: string): ClusterContract => { + return { + colors: [], + coordinates: [], + id, + pointIds: [], + reference: { lat: 0, lng: 0, alt: 0 }, + rotation: [0, 0, 0] + }; + }; + + it("should have cell and cell clusters after remove", () => { + const image = new ImageHelper().createImage(); + const cellId = "123"; + + let resolver: Function; + const promise: any = { + then: (resolve: (value: ClusterContract) => void): void => { + resolver = resolve; + }, + }; + + const dataProvider = new DataProvider(); + spyOn(dataProvider, "getCluster").and.returnValue(promise); + const graphService = new GraphServiceMockCreator().create(); + const api = new APIWrapper(dataProvider); + const cache = new SpatialCache(graphService, api); + + cacheTile(cellId, cache, graphService, [image]); + + cache.cacheClusters$(cellId).subscribe(); + + const cluster = createCluster(image.clusterId); + resolver(cluster); + + expect(cache.hasCell(cellId)).toBe(true); + expect(cache.hasClusters(cellId)).toBe(true); + + cache.removeCluster(cluster.id); + + expect(cache.hasCell(cellId)).toBe(true); + expect(cache.hasClusters(cellId)).toBe(true); + }); + + it("should remove cluster", () => { + const image = new ImageHelper().createImage(); + const cellId = "123"; + + let resolver: Function; + const promise: any = { + then: (resolve: (value: ClusterContract) => void): void => { + resolver = resolve; + }, + }; + + const dataProvider = new DataProvider(); + spyOn(dataProvider, "getCluster").and.returnValue(promise); + const graphService = new GraphServiceMockCreator().create(); + const api = new APIWrapper(dataProvider); + const cache = new SpatialCache(graphService, api); + + cacheTile(cellId, cache, graphService, [image]); + + cache.cacheClusters$(cellId).subscribe(); + + const cluster = createCluster(image.clusterId); + resolver(cluster); + + expect(cache.getClusters(cellId).length).toBe(1); + + cache.removeCluster(cluster.id); + + expect(cache.getClusters(cellId).length).toBe(0); + }); + + it("should remove images", () => { + const image = new ImageHelper().createImage(); + const cellId = "123"; + + let resolver: Function; + const promise: any = { + then: (resolve: (value: ClusterContract) => void): void => { + resolver = resolve; + }, + }; + + const dataProvider = new DataProvider(); + spyOn(dataProvider, "getCluster").and.returnValue(promise); + const graphService = new GraphServiceMockCreator().create(); + const api = new APIWrapper(dataProvider); + const cache = new SpatialCache(graphService, api); + + cacheTile(cellId, cache, graphService, [image]); + + cache.cacheClusters$(cellId).subscribe(); + + const cluster = createCluster(image.clusterId); + resolver(cluster); + + expect(cache.getCell(cellId).length).toBe(1); + + cache.removeCluster(cluster.id); + + expect(cache.getCell(cellId).length).toBe(0); + }); +}); diff --git a/test/graph/Graph.test.ts b/test/graph/Graph.test.ts index 86765525..811dda35 100644 --- a/test/graph/Graph.test.ts +++ b/test/graph/Graph.test.ts @@ -2682,7 +2682,7 @@ describe("Graph.reset", () => { const nodeDisposeSpy = spyOn(node, "dispose"); nodeDisposeSpy.and.stub(); - graph.reset([]); + graph.reset(); expect(nodeDisposeSpy.calls.count()).toBe(0); expect(graph.hasNode(node.id)).toBe(false); @@ -2722,57 +2722,11 @@ describe("Graph.reset", () => { const nodeDisposeSpy = spyOn(node, "dispose"); nodeDisposeSpy.and.stub(); - graph.reset([]); + graph.reset(); expect(nodeDisposeSpy.calls.count()).toBe(1); expect(graph.hasNode(node.id)).toBe(false); }); - - it("should keep supplied node", () => { - const geometryProvider = new GeometryProvider(); - const dataProvider = new DataProvider( - - geometryProvider); - const api = new APIWrapper(dataProvider); - const calculator = new GraphCalculator(); - - const h = "h"; - spyOn(geometryProvider, "lngLatToCellId").and.returnValue(h); - - const getImages = new Subject(); - spyOn(api, "getImages$").and.returnValue(getImages); - - const graph = new Graph(api, undefined, calculator); - - const fullNode = helper.createImageEnt(); - const result: ImagesContract = [{ - node: fullNode, - node_id: fullNode.id, - }]; - graph.cacheFull$(fullNode.id).subscribe(() => { /*noop*/ }); - - getImages.next(result); - getImages.complete(); - - expect(graph.hasNode(fullNode.id)).toBe(true); - - const node = graph.getNode(fullNode.id); - graph.initializeCache(node.id); - - const nodeDisposeSpy = spyOn(node, "dispose"); - nodeDisposeSpy.and.stub(); - const nodeResetSequenceSpy = spyOn(node, "resetSequenceEdges"); - nodeResetSequenceSpy.and.stub(); - const nodeResetSpatialSpy = spyOn(node, "resetSpatialEdges"); - nodeResetSpatialSpy.and.stub(); - - graph.reset([node.id]); - - expect(nodeDisposeSpy.calls.count()).toBe(0); - expect(nodeResetSequenceSpy.calls.count()).toBe(1); - expect(nodeResetSpatialSpy.calls.count()).toBe(1); - expect(graph.hasNode(node.id)).toBe(true); - }); }); describe("Graph.uncache", () => { @@ -4293,9 +4247,7 @@ describe("Graph.updateCells$", () => { it("should update currently caching cell", (done: Function) => { const geometryProvider = new GeometryProvider(); - const dataProvider = new DataProvider( - - geometryProvider); + const dataProvider = new DataProvider(geometryProvider); const api = new APIWrapper(dataProvider); const calculator = new GraphCalculator(); @@ -4353,9 +4305,7 @@ describe("Graph.updateCells$", () => { it("should add new nodes to existing cell", (done: Function) => { const geometryProvider = new GeometryProvider(); - const dataProvider = new DataProvider( - - geometryProvider); + const dataProvider = new DataProvider(geometryProvider); const api = new APIWrapper(dataProvider); const calculator = new GraphCalculator(); @@ -4419,3 +4369,168 @@ describe("Graph.updateCells$", () => { coreImagesUpdate.complete(); }); }); + +describe("Graph.deleteClusters$", () => { + it("should not delete when empty array", (done: Function) => { + const geometryProvider = new GeometryProvider(); + const dataProvider = new DataProvider(geometryProvider); + const api = new APIWrapper(dataProvider); + const calculator = new GraphCalculator(); + + const cellIdSpy = spyOn(geometryProvider, "lngLatToCellId") + .and.returnValue("cell-id"); + + const graph = new Graph(api, undefined, calculator); + + let count = 0; + graph.deleteClusters$([]) + .subscribe( + (): void => { count++; }, + undefined, + (): void => { + expect(count).toBe(0); + expect(cellIdSpy.calls.count()).toBe(0); + + done(); + }); + }); + + it("should not emit when cluster does not exist", (done: Function) => { + const geometryProvider = new GeometryProvider(); + const dataProvider = new DataProvider(geometryProvider); + const api = new APIWrapper(dataProvider); + const calculator = new GraphCalculator(); + + const cellIdSpy = spyOn(geometryProvider, "lngLatToCellId") + .and.returnValue("cell-id"); + + const graph = new Graph(api, undefined, calculator); + + let count = 0; + graph.deleteClusters$(["non-existing-cluster-id"]) + .subscribe( + (): void => { count++; }, + undefined, + (): void => { + expect(count).toBe(0); + expect(cellIdSpy.calls.count()).toBe(0); + + done(); + }); + }); + + it("should emit when deleting a cluster", (done: Function) => { + const geometryProvider = new GeometryProvider(); + const dataProvider = new DataProvider(geometryProvider); + const api = new APIWrapper(dataProvider); + const calculator = new GraphCalculator(); + + const cellId = "cell-id"; + const coreImages = + new Subject(); + spyOn(api, "getCoreImages$").and.returnValue(coreImages); + + const getSpatialImages = new Subject(); + spyOn(api, "getSpatialImages$").and.returnValue(getSpatialImages); + + const id = "node-id"; + const fullNode = new ImageHelper().createImageEnt(); + fullNode.id = id; + + const graph = new Graph(api, undefined, calculator); + + graph.cacheCell$(cellId).subscribe(); + + const tileResult: CoreImagesContract = { + cell_id: cellId, + images: [fullNode], + }; + coreImages.next(tileResult); + coreImages.complete(); + + const spatialImages: SpatialImagesContract = [{ + node: fullNode, + node_id: fullNode.id, + }]; + getSpatialImages.next(spatialImages); + getSpatialImages.complete(); + + expect(graph.hasNode(id)).toBe(true); + + let count = 0; + + graph.deleteClusters$([fullNode.cluster.id]) + .subscribe( + (clusterId: string): void => { + count++; + expect(clusterId).toBe(fullNode.cluster.id); + + }, + undefined, + (): void => { + expect(count).toBe(1); + + expect(graph.hasNode(id)).toBe(false); + + done(); + }); + }); + + it("should not dispose deleted node", (done: Function) => { + const geometryProvider = new GeometryProvider(); + const dataProvider = new DataProvider(geometryProvider); + const api = new APIWrapper(dataProvider); + const calculator = new GraphCalculator(); + + const cellId = "cell-id"; + const coreImages = + new Subject(); + spyOn(api, "getCoreImages$").and.returnValue(coreImages); + + const getSpatialImages = new Subject(); + spyOn(api, "getSpatialImages$").and.returnValue(getSpatialImages); + + const id = "node-id"; + const fullNode = new ImageHelper().createImageEnt(); + fullNode.id = id; + + const graph = new Graph(api, undefined, calculator); + + graph.cacheCell$(cellId).subscribe(); + + const tileResult: CoreImagesContract = { + cell_id: cellId, + images: [fullNode], + }; + coreImages.next(tileResult); + coreImages.complete(); + + const spatialImages: SpatialImagesContract = [{ + node: fullNode, + node_id: fullNode.id, + }]; + getSpatialImages.next(spatialImages); + getSpatialImages.complete(); + + const node = graph.getNode(id); + expect(node.isDisposed).toBe(false); + + let count = 0; + + graph.deleteClusters$([fullNode.cluster.id]) + .subscribe( + (clusterId: string): void => { + count++; + expect(clusterId).toBe(fullNode.cluster.id); + + }, + undefined, + (): void => { + expect(count).toBe(1); + + expect(node.isDisposed).toBe(false); + + done(); + }); + }); +}); diff --git a/test/graph/GraphService.test.ts b/test/graph/GraphService.test.ts index 84e2af17..4e824188 100644 --- a/test/graph/GraphService.test.ts +++ b/test/graph/GraphService.test.ts @@ -120,7 +120,7 @@ describe("GraphService.dataAdded$", () => { }); }); - it("should reset spatial edges for each updated cell", (done: Function) => { + it("should reset spatial edges one time for multiple updated cells", (done: Function) => { const dataProvider = new DataProvider(); const api = new APIWrapper(dataProvider); const graph = new Graph(api); @@ -142,7 +142,7 @@ describe("GraphService.dataAdded$", () => { expect(cellIds.includes(cellId)).toBe(true); expect(updateCellsSpy.calls.count()).toBe(1); - expect(resetSpatialEdgesSpy.calls.count()).toBe(count); + expect(resetSpatialEdgesSpy.calls.count()).toBe(1); if (count === 2) { done(); } }); @@ -156,6 +156,98 @@ describe("GraphService.dataAdded$", () => { }); }); +describe("GraphService.dataDeleted$", () => { + it("should not do work if list empty", () => { + const dataProvider = new DataProvider(); + const api = new APIWrapper(dataProvider); + const graph = new Graph(api); + const graphService = new GraphService(graph); + + let count = 0; + graphService.dataDeleted$ + .subscribe( + (): void => { + count++; + }); + + dataProvider.fire("datadelete", { + type: "datadelete", + target: dataProvider, + clusterIds: [], + }); + + expect(count).toBe(0); + }); + + it("should call graph", (done: Function) => { + const dataProvider = new DataProvider(); + const api = new APIWrapper(dataProvider); + const graph = new Graph(api); + + const deleteClustersSpy = spyOn(graph, "deleteClusters$"); + deleteClustersSpy.and.returnValue(observableOf("cluster-id")); + + const resetSpatialEdgesSpy = + spyOn(graph, "resetSpatialEdges").and.stub(); + + const graphService = new GraphService(graph); + + graphService.dataDeleted$ + .subscribe( + (clusterIds): void => { + expect(clusterIds.length).toBe(1); + expect(clusterIds[0]).toBe("cluster-id"); + + expect(deleteClustersSpy.calls.count()).toBe(1); + expect(resetSpatialEdgesSpy.calls.count()).toBe(1); + + done(); + }); + + dataProvider.fire("datadelete", { + type: "datadelete", + target: dataProvider, + clusterIds: ["cluster-id"], + }); + }); + + it("should reset spatial edges once", (done: Function) => { + const dataProvider = new DataProvider(); + const api = new APIWrapper(dataProvider); + const graph = new Graph(api); + + const deleteClustersSpy = spyOn(graph, "deleteClusters$"); + const clusterIds = ["cluster-id1", "cluster-id2"]; + deleteClustersSpy.and.returnValue(observableFrom(clusterIds.slice())); + + const resetSpatialEdgesSpy = + spyOn(graph, "resetSpatialEdges").and.stub(); + + const graphService = new GraphService(graph); + + let count = 0; + graphService.dataDeleted$ + .subscribe( + (deletedIds): void => { + count++; + expect(clusterIds.includes(deletedIds[0])).toBe(true); + expect(clusterIds.includes(deletedIds[1])).toBe(true); + + expect(deleteClustersSpy.calls.count()).toBe(1); + expect(resetSpatialEdgesSpy.calls.count()).toBe(1); + + if (count === 1) { done(); } + }); + + const type: ProviderEventType = "datadelete"; + dataProvider.fire(type, { + type, + target: dataProvider, + clusterIds: clusterIds.slice(), + }); + }); +}); + describe("GraphService.cacheSequence$", () => { it("should cache sequence when graph does not have sequence", () => { const api: APIWrapper = new APIWrapper(new DataProvider()); @@ -488,6 +580,10 @@ class TestNode extends Image { public get spatialEdges(): NavigationEdgeStatus { return this._spatialEdges; } + + public hasInitializedCache(): boolean { + return true; + } } describe("GraphService.cacheNode$", () => { @@ -851,7 +947,7 @@ describe("GraphService.reset$", () => { expect(e).toBeDefined(); }); - graphService.reset$([]); + graphService.reset$(); cacheFull$.next(graph); @@ -914,7 +1010,7 @@ describe("GraphService.reset$", () => { image.assetsCached = true; cacheAssets$.next(image); - graphService.reset$([]); + graphService.reset$(); cacheNodeSequence$.next(graph); @@ -972,7 +1068,7 @@ describe("GraphService.reset$", () => { image.assetsCached = true; cacheAssets$.next(image); - graphService.reset$([]); + graphService.reset$(); cacheTiles$[0].next(graph); diff --git a/test/helper/GraphServiceMockCreator.ts b/test/helper/GraphServiceMockCreator.ts index eccf00ed..8320f60e 100644 --- a/test/helper/GraphServiceMockCreator.ts +++ b/test/helper/GraphServiceMockCreator.ts @@ -12,6 +12,7 @@ export class GraphServiceMockCreator extends MockCreatorBase { const mock: GraphService = new MockCreator().create(GraphService, "GraphService"); this._mockProperty(mock, "dataAdded$", new Subject()); + this._mockProperty(mock, "dataDeleted$", new Subject()); this._mockProperty(mock, "dataReset$", new Subject()); this._mockProperty(mock, "graphMode$", new Subject()); this._mockProperty(mock, "filter$", new Subject()); diff --git a/test/viewer/CacheService.test.ts b/test/viewer/CacheService.test.ts index 0ce83af2..2ad9391b 100644 --- a/test/viewer/CacheService.test.ts +++ b/test/viewer/CacheService.test.ts @@ -1,7 +1,7 @@ import { bootstrap } from "../Bootstrap"; bootstrap(); -import { of as observableOf, Subject } from "rxjs"; +import { BehaviorSubject, of as observableOf, Subject } from "rxjs"; import { ImageHelper } from "../helper/ImageHelper"; @@ -244,6 +244,68 @@ describe("CacheService.start", () => { cacheService.stop(); }); + it("should cache current image on data added event", () => { + const api = new APIWrapper(new DataProvider()); + const graph = new Graph(api); + const graphService = new GraphService(graph); + + spyOn(graphService, "uncache$").and.returnValue(observableOf(null)); + + const stateService = new StateServiceMockCreator().create(); + + const cacheImageSpy = spyOn(graphService, "cacheImage$"); + const cacheImageSubject = new Subject(); + cacheImageSpy.and.returnValue(cacheImageSubject); + spyOn(graphService, "hasImage$").and.returnValue(new BehaviorSubject(true)); + + const cacheService = new CacheService(graphService, stateService, api); + + cacheService.start(); + + const coreImage1 = helper.createCoreImageEnt(); + coreImage1.id = "image1"; + + (>stateService.currentId$).next('image-id'); + (>graphService.dataAdded$).next('cell-id'); + + expect(cacheImageSpy.calls.count()).toBe(1); + expect(cacheImageSpy.calls.first().args.length).toBe(1); + expect(cacheImageSpy.calls.first().args[0]).toBe('image-id'); + + cacheService.stop(); + }); + + it("should cache current image on data deleted event", () => { + const api = new APIWrapper(new DataProvider()); + const graph = new Graph(api); + const graphService = new GraphService(graph); + + spyOn(graphService, "uncache$").and.returnValue(observableOf(null)); + + const stateService = new StateServiceMockCreator().create(); + + const cacheImageSpy = spyOn(graphService, "cacheImage$"); + const cacheImageSubject = new Subject(); + cacheImageSpy.and.returnValue(cacheImageSubject); + spyOn(graphService, "hasImage$").and.returnValue(new BehaviorSubject(true)); + + const cacheService = new CacheService(graphService, stateService, api); + + cacheService.start(); + + const coreImage1 = helper.createCoreImageEnt(); + coreImage1.id = "image1"; + + (>stateService.currentId$).next('image-id'); + (>graphService.dataDeleted$).next(['cluster-id']); + + expect(cacheImageSpy.calls.count()).toBe(1); + expect(cacheImageSpy.calls.first().args.length).toBe(1); + expect(cacheImageSpy.calls.first().args[0]).toBe('image-id'); + + cacheService.stop(); + }); + it("should cache all trajectory images ahead if switching to spatial graph mode", () => { const api = new APIWrapper(new DataProvider()); const graph = new Graph(api); diff --git a/test/viewer/Navigator.test.ts b/test/viewer/Navigator.test.ts index aa08e13d..7d347f17 100644 --- a/test/viewer/Navigator.test.ts +++ b/test/viewer/Navigator.test.ts @@ -7,6 +7,7 @@ import { throwError as observableThrowError, Observable, Subject, + BehaviorSubject, } from "rxjs"; import { first } from "rxjs/operators"; @@ -592,6 +593,9 @@ describe("Navigator.setFilter$", () => { return cacheImageSubject2$; }); + const hasImageSubject = new Subject(); + const hasImageSpy = spyOn(graphService, "hasImage$").and.returnValue(hasImageSubject); + const navigator: Navigator = new Navigator( { container: "co" }, @@ -640,6 +644,8 @@ describe("Navigator.setFilter$", () => { currentStateSubject$.complete(); setFilterSubject$.next(graph); setFilterSubject$.complete(); + hasImageSubject.next(true); + hasImageSubject.complete(); cacheImageSubject2$.next(image1); cacheImageSubject2$.next(image2); cacheImageSubject2$.complete(); @@ -691,8 +697,7 @@ describe("Navigator.setToken$", () => { expect(setTokenSpy.calls.first().args[0]).toBe("token"); expect(resetSpy.calls.count()).toBe(1); - expect(resetSpy.calls.first().args.length).toBe(1); - expect(resetSpy.calls.first().args[0]).toEqual([]); + expect(resetSpy.calls.first().args.length).toBe(0); done(); }); @@ -777,8 +782,7 @@ describe("Navigator.setToken$", () => { expect(setTokenSpy.calls.first().args[0]).toBe("token"); expect(resetSpy.calls.count()).toBe(1); - expect(resetSpy.calls.first().args.length).toBe(1); - expect(resetSpy.calls.first().args[0].length).toBe(0); + expect(resetSpy.calls.first().args.length).toBe(0); done(); });