diff --git a/modules/core/GraphicsSystem.js b/modules/core/GraphicsSystem.js index d50211309..e270d3fe4 100644 --- a/modules/core/GraphicsSystem.js +++ b/modules/core/GraphicsSystem.js @@ -7,8 +7,6 @@ import { PixiScene } from '../pixi/PixiScene.js'; import { PixiTextures } from '../pixi/PixiTextures.js'; import { utilSetTransform } from '../util/util.js'; -let _sharedTextures; // singleton (for now) - const THROTTLE = 250; // throttled rendering milliseconds (for now) @@ -30,9 +28,10 @@ const THROTTLE = 250; // throttled rendering milliseconds (for now) * `textures` PixiTextures manages the textures * * Events available: - * `draw` Fires after a full redraw - * `move` Fires after the map's transform has changed (can fire frequently) - * ('move' is mostly for when you want to update some content that floats over the map) + * `draw` Fires after a full redraw + * `move` Fires after the map's transform has changed (can fire frequently) + * ('move' is mostly for when you want to update some content that floats over the map) + * `contextchange` Fires after the WebGLContext has changed (to let listeners know Pixi has been replaced) */ export class GraphicsSystem extends AbstractSystem { @@ -44,7 +43,8 @@ export class GraphicsSystem extends AbstractSystem { super(context); this.id = 'gfx'; - this.dependencies = new Set(['assets', 'map', 'urlhash']); + this.dependencies = new Set(['assets', 'map', 'ui', 'urlhash']); + this.highQuality = true; // this can go false if we detect poor performance // Create these early this.supersurface = document.createElement('div'); // parent `div` temporary transforms between redraws @@ -60,7 +60,7 @@ export class GraphicsSystem extends AbstractSystem { this.textures = null; // Properties used to manage the scene transform - this.pixiViewport = new Viewport(); + this._pixiViewport = null; this._prevTransform = { x: 0, y: 0, k: 256 / Math.PI, r: 0 }; // transform at time of last draw this._isTempTransformed = false; // is the supersurface transformed? this._transformEase = null; @@ -77,6 +77,32 @@ export class GraphicsSystem extends AbstractSystem { this.deferredRedraw = this.deferredRedraw.bind(this); this.immediateRedraw = this.immediateRedraw.bind(this); this._tick = this._tick.bind(this); + + // If we are using WebGL, watch for context loss - Rapid#1658 + this._handleGLContextLost = this._handleGLContextLost.bind(this); + this._handleGLContextRestored = this._handleGLContextRestored.bind(this); + this._isContextLost = false; + + // Anything involving PIXI globals can be set up here, to ensure it only happens one time. + // We'll use the Pixi shared ticker, but we don't want it started yet. + const ticker = PIXI.Ticker.shared; + ticker.autoStart = false; + ticker.stop(); + ticker.add(this._tick, this); + this.ticker = ticker; + + // Prepare a basic bitmap font that we can use for things like debug messages + PIXI.BitmapFont.install({ + name: 'rapid-debug', + style: { + fill: { color: 0xffffff }, + fontSize: 14, + stroke: { color: 0x333333 } + }, + chars: PIXI.BitmapFontManager.ASCII, + resolution: 2 + }); + } @@ -131,7 +157,7 @@ export class GraphicsSystem extends AbstractSystem { return this._startPromise = prerequisites .then(() => { this._started = true; - this.pixi.ticker.start(); + this.ticker.start(); }); } @@ -179,7 +205,7 @@ export class GraphicsSystem extends AbstractSystem { _tick() { if (!this._started || this._paused) return; - const ticker = this.pixi.ticker; + const ticker = this.ticker; // console.log('FPS=' + ticker.FPS.toFixed(1)); // For now, we will perform either APP (Rapid prepares scene graph) or DRAW (Pixi render) during a tick. @@ -320,7 +346,7 @@ export class GraphicsSystem extends AbstractSystem { const context = this.context; const mapViewport = context.viewport; - const pixiViewport = this.pixiViewport; + const pixiViewport = this._pixiViewport; // Calculate the transform easing, if any if (this._transformEase) { @@ -431,7 +457,7 @@ export class GraphicsSystem extends AbstractSystem { if (context.container().classed('resizing')) return; const mapViewport = context.viewport; - const pixiViewport = this.pixiViewport; + const pixiViewport = this._pixiViewport; // At this point, the map transform is settled // (`_tform` is called immediately before `_app`) @@ -489,7 +515,7 @@ export class GraphicsSystem extends AbstractSystem { _draw() { // Resize Pixi canvas if needed.. // It will clear the canvas, so do this immediately before we render. - const pixiDims = this.pixiViewport.dimensions; + const pixiDims = this._pixiViewport.dimensions; const canvasDims = [this.pixi.screen.width, this.pixi.screen.height]; if (!vecEqual(pixiDims, canvasDims)) { @@ -567,7 +593,7 @@ export class GraphicsSystem extends AbstractSystem { // debug2.zIndex = 102; // origin.addChild(debug2); // } -// const centerLoc = this.pixiViewport.project(mapViewport.centerLoc()); +// const centerLoc = this._pixiViewport.project(mapViewport.centerLoc()); // debug2.position.set(centerLoc[0], centerLoc[1]); // debugging the contents of the texture atlas @@ -644,9 +670,9 @@ export class GraphicsSystem extends AbstractSystem { }); const options = { - antialias: true, - autoDensity: true, - autoStart: false, // Don't start the ticker yet + antialias: this.highQuality, + autoDensity: this.highQuality, + autoStart: false, // Avoid the ticker canvas: this.surface, events: { move: false, @@ -659,9 +685,9 @@ export class GraphicsSystem extends AbstractSystem { preference: renderPreference, preferWebGLVersion: renderGLVersion, preserveDrawingBuffer: true, - resolution: window.devicePixelRatio, + resolution: this.highQuality ? window.devicePixelRatio : 1, sharedLoader: true, - sharedTicker: true, + sharedTicker: false, // Avoid the ticker textureGCActive: true, useBackBuffer: false }; @@ -677,19 +703,16 @@ export class GraphicsSystem extends AbstractSystem { * Set up scene, events, textures, stage, etc. */ _afterPixiInit() { - if (this.scene) return; // done already? - - // Prepare a basic bitmap font that we can use for things like debug messages - PIXI.BitmapFont.install({ - name: 'rapid-debug', - style: { - fill: { color: 0xffffff }, - fontSize: 14, - stroke: { color: 0x333333 } - }, - chars: PIXI.BitmapFontManager.ASCII, - resolution: 2 - }); + if (this.stage) return; // done already? + + // Watch for WebGL context loss on context canvas - Rapid#1658 + const renderer = this.pixi.renderer; + if (renderer.type === PIXI.RendererType.WEBGL) { + // Note that with multiview rendering the context canvas is not the view canvas (aka surface) + const canvas = renderer.context.canvas; + canvas.addEventListener('webglcontextlost', this._handleGLContextLost); + canvas.addEventListener('webglcontextrestored', this._handleGLContextRestored); + } // Enable debugging tools if (window.Rapid.isDebug) { @@ -703,6 +726,10 @@ export class GraphicsSystem extends AbstractSystem { }; } + // Create or replace the Pixi viewport + // This viewport will closely follow the map viewport but can be offset from it. + this._pixiViewport = new Viewport(); + // Setup the stage // The `stage` should be positioned so that `[0,0]` is at the center of the viewport, // and this is the pivot point for map rotation. @@ -724,22 +751,161 @@ export class GraphicsSystem extends AbstractSystem { stage.addChild(origin); this.origin = origin; - // Setup the ticker - const ticker = this.pixi.ticker; - const defaultListener = ticker._head.next; - ticker.remove(defaultListener._fn, defaultListener._context); - ticker.add(this._tick, this); + // The Pixi Application comes with its own ticker that just calls `render()`, + // and we don't want to ever use it. Disable it. + const appTicker = this.pixi.ticker; + let next = appTicker._head.next; + while (next) { + next = next.destroy(true); // remove any listeners + } + this.pixi.start = () => {}; + this.pixi.stop = () => {}; - this.scene = new PixiScene(this); - this.events = new PixiEvents(this); + // Create these classes if we haven't already + if (!this.scene) { + this.scene = new PixiScene(this); + } else { + this.scene.reset(); + } + + if (!this.textures) { + this.textures = new PixiTextures(this); + } else { + this.textures.reset(); + } - // Texture Manager should only be created once - // This is because it will start loading assets and Pixi's asset loader is not reentrant. - // (it causes test failures if we create a bunch of these) - if (!_sharedTextures) { - _sharedTextures = new PixiTextures(this); + if (!this.events) { + this.events = new PixiEvents(this); } - this.textures = _sharedTextures; + } + + + /** + * _handleGLContextLost + * Handler for webglcontextlost events on the canvas. + * @param {WebGLContextEvent} e - The context event + */ + _handleGLContextLost(e) { + e.preventDefault(); + + this._isContextLost = true; + this._drawPending = false; + + this.ticker.stop(); // stop ticking + this.pause(); // stop rendering + this.events.disable(); // stop listening for events + this.highQuality = false; // back off when we get the context restored.. + + // We'll try to keep the Pixi environment around, so that code elsewhere + // that references things like `scene`, `events`, etc has a chance of working. + + // Nothing will be rendered anyway, but at least browse mode doesn't + // need Pixi for anything like the drawing/editing modes do. + // If the user happened to be editing something when the context was lost, that's too bad. + // We may be able to handle this better eventually, but for now we will just + // assume the whole graphics system is getting thrown out. + this.context.enter('browse'); + + // Normally Pixi's `GLContextSystem` would try to restore context if we call `render()` + // see https://pixijs.download/release/docs/rendering.GlContextSystem.html + // But this process is buggy (see Pixi#10403) and we're paused and not calling render. + // So instead, we'll try to restore the context ourselves here and replace Pixi completely. + const renderer = this.pixi.renderer; + const ext = renderer.context.extensions.loseContext; // WEBGL_lose_context extension + if (!ext) return; // I think all browsers we target should have this + + Promise.resolve() + .then(() => new Promise(resolve => { window.setTimeout(resolve, 10); })) // wait 10ms + .then(() => ext.restoreContext()); + } + + + /** + * _handleGLContextRestored + * Handler for webglcontextrestored events on the canvas. + * @param {WebGLContextEvent} e - The context event + */ + _handleGLContextRestored(e) { + Promise.resolve() + .then(() => this._destroyPixi()) + .then(() => this._initPixiAsync()) + .then(() => this._afterPixiInit()) + .then(() => { + // We just replaced the texture manager, so we have to tell it about the available SVG icons. + const context = this.context; + const $container = context.container(); + $container.selectAll('#rapid-defs symbol') + .each((d, i, nodes) => { + const symbol = nodes[i]; + const iconID = symbol.getAttribute('id'); + this.textures.registerSvgIcon(iconID, symbol); + }); + + this._isContextLost = false; + this.events.enable(); // resume listening + this.resume(); // resume rendering + this.ticker.start(); // resume ticking + this.emit('contextchange'); + }); + } + + + /** + * _destroyPixi + * After a WebGL context loss, replace the parts of Pixi that need replacing. + * Basically we need to destroy the `PIXI.Application` + * then force`_initPixiAsync` and `_afterPixiInit` to run again. + * + * Note: It might be possible avoid some of this, but I did hit this issue in testing: + * https://github.com/pixijs/pixijs/issues/10403 + * So for now, we will just replace the whole thing. + * + * To test, try: `rapidContext.systems.gfx.testContextLoss()` + * and see whether Pixi can deal with it. + */ + _destroyPixi() { + if (!this.pixi) return; // already destroyed + + const renderer = this.pixi.renderer; + if (renderer.type === PIXI.RendererType.WEBGL) { + // note that with multiview rendering the context canvas is not the view canvas (aka surface) + const canvas = renderer.context.canvas; + canvas.removeEventListener('webglcontextlost', this._handleGLContextLost); + canvas.removeEventListener('webglcontextrestored', this._handleGLContextRestored); + } + + const rendererDestroyOptions = { + removeView: false // leave the surface attached to the DOM + }; + const applicationDestroyOptions = { + children: true, + texture: true, + textureSource: true, + context: true + }; + + this.pixi.destroy(rendererDestroyOptions, applicationDestroyOptions); + this.pixi = null; + + this.origin = null; + this.stage = null; + } + + + /** + * testContextLoss + * For testing, attempt to lose the WebGL context and get it back. + */ + testContextLoss() { + if (!this.pixi) return; + + const renderer = this.pixi.renderer; + if (renderer.type !== PIXI.RendererType.WEBGL) return; + + const ext = renderer.context.extensions.loseContext; // WEBGL_lose_context extension + if (!ext) return; // I think all browsers we target should have this + ext.loseContext(); + // We'll end up in `_handleGLContextLost()` listener above } } diff --git a/modules/core/MapSystem.js b/modules/core/MapSystem.js index 5ad4947a3..10e8eefab 100644 --- a/modules/core/MapSystem.js +++ b/modules/core/MapSystem.js @@ -177,7 +177,7 @@ export class MapSystem extends AbstractSystem { rapid .on('datasetchange', () => { - scene.dirtyLayers(['rapid', 'rapid-overlay', 'overture']); + scene.dirtyLayers(['rapid', 'rapidoverlay']); gfx.immediateRedraw(); }); diff --git a/modules/pixi/AbstractLayer.js b/modules/pixi/AbstractLayer.js index f3c3fd829..a6dd69fda 100644 --- a/modules/pixi/AbstractLayer.js +++ b/modules/pixi/AbstractLayer.js @@ -66,17 +66,17 @@ export class AbstractLayer { /** * reset - * Every Layer should have a reset function to clear out any state when a reset occurs. + * Every Layer should have a reset function to replace any Pixi objects and internal state. * Override in a subclass with needed logic. * @abstract */ reset() { this._featureHasData.clear(); this._dataHasFeature.clear(); - this._parentHasChildren.clear(); - this._childHasParents.clear(); - this._dataHasClass.clear(); - this._classHasData.clear(); + this._parentHasChildren.clear(); // maybe don't clear this (should pseudo dom survive a reset?) + this._childHasParents.clear(); // maybe don't clear this (should pseudo dom survive a reset?) + this._dataHasClass.clear(); // maybe don't clear this (should pseudo css survive a reset?) + this._classHasData.clear(); // maybe don't clear this (should pseudo css survive a reset?) for (const feature of this.features.values()) { feature.destroy(); diff --git a/modules/pixi/PixiEvents.js b/modules/pixi/PixiEvents.js index 96a750107..1a08a5af9 100644 --- a/modules/pixi/PixiEvents.js +++ b/modules/pixi/PixiEvents.js @@ -99,7 +99,7 @@ export class PixiEvents extends EventEmitter { surface.addEventListener('pointerover', this._pointerover); surface.addEventListener('pointerout', this._pointerout); - const stage = gfx.pixi.stage; + const stage = gfx.stage; stage.addEventListener('click', this._click); stage.addEventListener('rightclick', this._click); // pixi has a special 'rightclick' event stage.addEventListener('pointerdown', this._pointerdown); @@ -132,7 +132,7 @@ export class PixiEvents extends EventEmitter { surface.removeEventListener('pointerover', this._pointerover); surface.removeEventListener('pointerout', this._pointerout); - const stage = gfx.pixi.stage; + const stage = gfx.stage; stage.removeEventListener('click', this._click); stage.removeEventListener('rightclick', this._click); stage.removeEventListener('pointerdown', this._pointerdown); diff --git a/modules/pixi/PixiFeatureLine.js b/modules/pixi/PixiFeatureLine.js index d3a66c284..30dfffbcf 100644 --- a/modules/pixi/PixiFeatureLine.js +++ b/modules/pixi/PixiFeatureLine.js @@ -80,7 +80,7 @@ export class PixiFeatureLine extends AbstractFeature { update(viewport, zoom) { if (!this.dirty) return; // nothing to do - const wireframeMode = this.context.systems.map.wireframeMode; + const isWireframe = this.context.systems.map.wireframeMode; const textureManager = this.gfx.textures; const container = this.container; const style = this._style; @@ -214,7 +214,7 @@ export class PixiFeatureLine extends AbstractFeature { width = minwidth; } - if (wireframeMode) { + if (isWireframe) { width = 1; } @@ -238,64 +238,66 @@ export class PixiFeatureLine extends AbstractFeature { if (this.casing.renderable) { - updateGraphic('casing', this.casing, this.geometry.coords, style, wireframeMode); + this.updateGraphic('casing', this.casing, this.geometry.coords, style, zoom, isWireframe); } if (this.stroke.renderable) { - updateGraphic('stroke', this.stroke, this.geometry.coords, style, wireframeMode); + this.updateGraphic('stroke', this.stroke, this.geometry.coords, style, zoom, isWireframe); } this.updateHalo(); + } - function updateGraphic(which, graphic, points, style, wireframeMode) { - const minwidth = which === 'casing' ? 3 : 2; - let width = style[which].width; - - // Apply effectiveZoom style adjustments - if (zoom < 16) { - width -= 4; - } else if (zoom < 17) { - width -= 2; - } - if (width < minwidth) { - width = minwidth; - } - - if (wireframeMode) { - width = 1; - } - - let g = graphic.clear(); - if (style[which].alpha === 0) return; - - const strokeStyle = { - color: style[which].color, - width: width, - alpha: style[which].alpha || 1.0, - join: style[which].join, - cap: style[which].cap, - }; + /** + * updateGraphic + */ + updateGraphic(which, graphic, points, style, zoom, isWireframe) { + const minwidth = which === 'casing' ? 3 : 2; + let width = style[which].width; + + // Apply effectiveZoom style adjustments + if (zoom < 16) { + width -= 4; + } else if (zoom < 17) { + width -= 2; + } + if (width < minwidth) { + width = minwidth; + } - if (style[which].dash) { - strokeStyle.dash = style[which].dash; - g = new DashLine(g, strokeStyle); - drawLineFromPoints(points, g); - } else { - drawLineFromPoints(points, g); - g = g.stroke(strokeStyle); - } + if (isWireframe) { + width = 1; + } - function drawLineFromPoints(points, graphics) { - points.forEach(([x, y], i) => { - if (i === 0) { - graphics.moveTo(x, y); - } else { - graphics.lineTo(x, y); - } - }); - } + let g = graphic.clear(); + if (style[which].alpha === 0) return; + + const strokeStyle = { + color: style[which].color, + width: width, + alpha: style[which].alpha || 1.0, + join: style[which].join, + cap: style[which].cap, + }; + + if (style[which].dash) { + strokeStyle.dash = style[which].dash; + g = new DashLine(this.gfx, g, strokeStyle); + drawLineFromPoints(points, g); + } else { + drawLineFromPoints(points, g); + g = g.stroke(strokeStyle); } + function drawLineFromPoints(points, graphics) { + points.forEach(([x, y], i) => { + if (i === 0) { + graphics.moveTo(x, y); + } else { + graphics.lineTo(x, y); + } + }); + } } @@ -344,7 +346,7 @@ export class PixiFeatureLine extends AbstractFeature { }; this.halo.clear(); - const dl = new DashLine(this.halo, HALO_STYLE); + const dl = new DashLine(this.gfx, this.halo, HALO_STYLE); if (this._bufferdata) { if (this._bufferdata.outer && this._bufferdata.inner) { // closed line dl.poly(this._bufferdata.outer); diff --git a/modules/pixi/PixiFeaturePoint.js b/modules/pixi/PixiFeaturePoint.js index 47c1ec2ed..114dad5b5 100644 --- a/modules/pixi/PixiFeaturePoint.js +++ b/modules/pixi/PixiFeaturePoint.js @@ -368,7 +368,7 @@ export class PixiFeaturePoint extends AbstractFeature { this.halo.clear(); const shape = this.container.hitArea; - const dl = new DashLine(this.halo, HALO_STYLE); + const dl = new DashLine(this.gfx, this.halo, HALO_STYLE); if (shape instanceof PIXI.Circle) { dl.circle(shape.x, shape.y, shape.radius, 20); } else if (shape instanceof PIXI.Rectangle) { diff --git a/modules/pixi/PixiFeaturePolygon.js b/modules/pixi/PixiFeaturePolygon.js index 59a0e68a2..2cf1024b1 100644 --- a/modules/pixi/PixiFeaturePolygon.js +++ b/modules/pixi/PixiFeaturePolygon.js @@ -317,7 +317,7 @@ export class PixiFeaturePolygon extends AbstractFeature { if (dash) { strokeStyle.dash = dash; - const dl = new DashLine(stroke, strokeStyle); + const dl = new DashLine(this.gfx, stroke, strokeStyle); dl .poly(ring); @@ -499,7 +499,7 @@ export class PixiFeaturePolygon extends AbstractFeature { }; this.halo.clear(); - const dl = new DashLine(this.halo, HALO_STYLE); + const dl = new DashLine(this.gfx, this.halo, HALO_STYLE); if (this._bufferdata) { dl.poly(this._bufferdata.outer); if (wireframeMode) { diff --git a/modules/pixi/PixiLayerBackgroundTiles.js b/modules/pixi/PixiLayerBackgroundTiles.js index 3278a36c3..180054c9e 100644 --- a/modules/pixi/PixiLayerBackgroundTiles.js +++ b/modules/pixi/PixiLayerBackgroundTiles.js @@ -1,5 +1,5 @@ import * as PIXI from 'pixi.js'; -import { interpolateNumber as d3_interpolateNumber } from 'd3-interpolate'; +import { interpolateNumber } from 'd3-interpolate'; import { AdjustmentFilter, ConvolutionFilter } from 'pixi-filters'; import { Tiler, geoScaleToZoom, vecScale } from '@rapid-sdk/math'; @@ -32,10 +32,6 @@ export class PixiLayerBackgroundTiles extends AbstractLayer { this.enabled = true; // background imagery should be enabled by default this.isMinimap = isMinimap; - // Items in this layer don't need to be interactive - const groupContainer = this.scene.groups.get('background'); - groupContainer.eventMode = 'none'; - this.filters = { brightness: 1, contrast: 1, @@ -51,10 +47,15 @@ export class PixiLayerBackgroundTiles extends AbstractLayer { /** * reset - * Every Layer should have a reset function to clear out any state when a reset occurs. + * Every Layer should have a reset function to replace any Pixi objects and internal state. */ reset() { super.reset(); + + // Items in this layer don't need to be interactive + const groupContainer = this.scene.groups.get('background'); + groupContainer.eventMode = 'none'; + this.destroyAll(); this._tileMaps.clear(); this._failed.clear(); @@ -412,7 +413,7 @@ export class PixiLayerBackgroundTiles extends AbstractLayer { // The central pixel (at index 4 of our 3x3 array) starts at 1 and increases const convolutionArray = sharpenMatrix.map((n, i) => { if (i === 4) { - const interp = d3_interpolateNumber(1, 2)(this.filters.sharpness); + const interp = interpolateNumber(1, 2)(this.filters.sharpness); const result = n * interp; return result; } else { @@ -424,7 +425,7 @@ export class PixiLayerBackgroundTiles extends AbstractLayer { sourceContainer.filters= [...sourceContainer.filters, this.convolutionFilter]; } else if (this.filters.sharpness < 1) { - const blurFactor = d3_interpolateNumber(1, 8)(1 - this.filters.sharpness); + const blurFactor = interpolateNumber(1, 8)(1 - this.filters.sharpness); this.blurFilter = new PIXI.BlurFilter({ strength: blurFactor, quality: 4 diff --git a/modules/pixi/PixiLayerCustomData.js b/modules/pixi/PixiLayerCustomData.js index dcbe82756..25a6ada8d 100644 --- a/modules/pixi/PixiLayerCustomData.js +++ b/modules/pixi/PixiLayerCustomData.js @@ -65,6 +65,17 @@ export class PixiLayerCustomData extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + // note: we don't need to call this._clear() to remove custom data here. + // Custom data can persist through a reset of the graphics system. + } + + /** * render * Render the GeoJSON custom data diff --git a/modules/pixi/PixiLayerEditBlocks.js b/modules/pixi/PixiLayerEditBlocks.js index 68c744909..3a244a6e0 100644 --- a/modules/pixi/PixiLayerEditBlocks.js +++ b/modules/pixi/PixiLayerEditBlocks.js @@ -34,14 +34,24 @@ export class PixiLayerEditBlocks extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + this._lastv = null; + } + + /** * render * Render any edit blocking polygons that are visible in the viewport - * @param frame Integer frame being rendered - * @param pixiViewport Pixi viewport to use for rendering - * @param zoom Effective zoom to use for rendering (not used here) + * @param frame Integer frame being rendered + * @param viewport Pixi viewport to use for rendering + * @param zoom Effective zoom to use for rendering */ - render(frame, pixiViewport) { + render(frame, viewport) { const context = this.context; const locations = context.systems.locations; const mapViewport = context.viewport; // context viewport !== pixi viewport (they are offset) @@ -54,7 +64,7 @@ export class PixiLayerEditBlocks extends AbstractLayer { if (zoom >= MINZOOM) { const searchRect = mapViewport.visibleExtent().rectangle(); blocks = locations.wpblocks().bbox(searchRect); - this.renderEditBlocks(frame, pixiViewport, zoom, blocks); + this.renderEditBlocks(frame, viewport, zoom, blocks); } // setup the explanation @@ -85,12 +95,12 @@ export class PixiLayerEditBlocks extends AbstractLayer { /** * renderEditBlocks - * @param frame Integer frame being rendered - * @param pixiViewport Pixi viewport to use for rendering - * @param zoom Effective zoom to use for rendering - * @param blocks Array of block data visible in the view + * @param frame Integer frame being rendered + * @param viewport Pixi viewport to use for rendering + * @param zoom Effective zoom to use for rendering + * @param blocks Array of block data visible in the view */ - renderEditBlocks(frame, pixiViewport, zoom, blocks) { + renderEditBlocks(frame, viewport, zoom, blocks) { const locations = this.context.systems.locations; const parentContainer = this.scene.groups.get('blocks'); const BLOCK_STYLE = { @@ -120,7 +130,7 @@ export class PixiLayerEditBlocks extends AbstractLayer { } this.syncFeatureClasses(feature); - feature.update(pixiViewport, zoom); + feature.update(viewport, zoom); this.retainFeature(feature, frame); } } diff --git a/modules/pixi/PixiLayerGeoScribble.js b/modules/pixi/PixiLayerGeoScribble.js index ab4f0434c..f58e63369 100644 --- a/modules/pixi/PixiLayerGeoScribble.js +++ b/modules/pixi/PixiLayerGeoScribble.js @@ -24,14 +24,7 @@ export class PixiLayerGeoScribble extends AbstractLayer { constructor(scene, layerID) { super(scene, layerID); - const geoscribbles = new PIXI.Container(); - geoscribbles.label = `${this.layerID}-geoscribbles`; - geoscribbles.sortableChildren = false; - geoscribbles.interactiveChildren = true; - this.scribblesContainer = geoscribbles; - - const basemapContainer = this.scene.groups.get('basemap'); - basemapContainer.addChild(geoscribbles); + this.scribblesContainer = null; } @@ -70,6 +63,33 @@ export class PixiLayerGeoScribble extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + + const groupContainer = this.scene.groups.get('basemap'); + + // Remove any existing containers + for (const child of groupContainer.children) { + if (child.label.startsWith(this.layerID + '-')) { // 'geoScribble-*' + groupContainer.removeChild(child); + child.destroy({ children: true }); // recursive + } + } + + const geoscribbles = new PIXI.Container(); + geoscribbles.label = `${this.layerID}-geoscribbles`; + geoscribbles.sortableChildren = false; + geoscribbles.interactiveChildren = true; + this.scribblesContainer = geoscribbles; + + groupContainer.addChild(geoscribbles); + } + + /** * render * Render the geojson custom data diff --git a/modules/pixi/PixiLayerKartaPhotos.js b/modules/pixi/PixiLayerKartaPhotos.js index ae7e9a0ca..9bed7edcb 100644 --- a/modules/pixi/PixiLayerKartaPhotos.js +++ b/modules/pixi/PixiLayerKartaPhotos.js @@ -75,6 +75,15 @@ export class PixiLayerKartaPhotos extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + /** * filterImages * @param {Array} images - all images diff --git a/modules/pixi/PixiLayerKeepRight.js b/modules/pixi/PixiLayerKeepRight.js index 525696c0a..8d5720e0c 100644 --- a/modules/pixi/PixiLayerKeepRight.js +++ b/modules/pixi/PixiLayerKeepRight.js @@ -55,6 +55,15 @@ export class PixiLayerKeepRight extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + /** * renderMarkers * @param frame Integer frame being rendered diff --git a/modules/pixi/PixiLayerLabels.js b/modules/pixi/PixiLayerLabels.js index aaecbba5c..f668a53a1 100644 --- a/modules/pixi/PixiLayerLabels.js +++ b/modules/pixi/PixiLayerLabels.js @@ -57,31 +57,9 @@ export class PixiLayerLabels extends AbstractLayer { super(scene, layerID); this.enabled = true; // labels should be enabled by default - // Items in this layer don't actually need to be interactive - const groupContainer = this.scene.groups.get('labels'); - groupContainer.eventMode = 'none'; - - const labelOriginContainer = new PIXI.Container(); - labelOriginContainer.label= 'labelorigin'; - labelOriginContainer.eventMode = 'none'; - this.labelOriginContainer = labelOriginContainer; - - const debugContainer = new PIXI.Container(); //PIXI.ParticleContainer(50000); - debugContainer.label= 'debug'; - debugContainer.eventMode = 'none'; - debugContainer.roundPixels = false; - debugContainer.sortableChildren = false; - this.debugContainer = debugContainer; - - const labelContainer = new PIXI.Container(); - labelContainer.label= 'labels'; - labelContainer.eventMode = 'none'; - labelContainer.sortableChildren = true; - this.labelContainer = labelContainer; - - groupContainer.addChild(labelOriginContainer); - labelOriginContainer.addChild(debugContainer, labelContainer); - + this.labelOriginContainer = null; + this.debugContainer = null; + this.labelContainer = null; // A RBush spatial index that stores all the placement boxes this._rbush = new RBush(); @@ -128,14 +106,12 @@ export class PixiLayerLabels extends AbstractLayer { /** * reset - * Every Layer should have a reset function to clear out any state when a reset occurs. + * Every Layer should have a reset function to replace any Pixi objects and internal state. */ reset() { super.reset(); - this.labelContainer.removeChildren(); - this.debugContainer.removeChildren(); - + // Destroy any Pixi display objects for (const dObj of this._dObjs.values()) { dObj.destroy(); } @@ -147,6 +123,38 @@ export class PixiLayerLabels extends AbstractLayer { // } } + // Items in this layer don't actually need to be interactive + const groupContainer = this.scene.groups.get('labels'); + groupContainer.eventMode = 'none'; + + // Remove any existing containers + for (const child of groupContainer.children) { + groupContainer.removeChild(child); + child.destroy({ children: true }); // recursive + } + + // Add containers + const labelOriginContainer = new PIXI.Container(); + labelOriginContainer.label= 'labelorigin'; + labelOriginContainer.eventMode = 'none'; + this.labelOriginContainer = labelOriginContainer; + + const debugContainer = new PIXI.Container(); //PIXI.ParticleContainer(50000); + debugContainer.label= 'debug'; + debugContainer.eventMode = 'none'; + debugContainer.roundPixels = false; + debugContainer.sortableChildren = false; + this.debugContainer = debugContainer; + + const labelContainer = new PIXI.Container(); + labelContainer.label= 'labels'; + labelContainer.eventMode = 'none'; + labelContainer.sortableChildren = true; + this.labelContainer = labelContainer; + + groupContainer.addChild(labelOriginContainer); + labelOriginContainer.addChild(debugContainer, labelContainer); + this._avoidBoxes.clear(); this._labelBoxes.clear(); this._dObjs.clear(); diff --git a/modules/pixi/PixiLayerMapRoulette.js b/modules/pixi/PixiLayerMapRoulette.js index ba06874f9..cb467ef6b 100644 --- a/modules/pixi/PixiLayerMapRoulette.js +++ b/modules/pixi/PixiLayerMapRoulette.js @@ -55,6 +55,15 @@ export class PixiLayerMapRoulette extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + /** * renderMarkers * @param frame Integer frame being rendered diff --git a/modules/pixi/PixiLayerMapUI.js b/modules/pixi/PixiLayerMapUI.js index 0b93f59e6..8866d4f38 100644 --- a/modules/pixi/PixiLayerMapUI.js +++ b/modules/pixi/PixiLayerMapUI.js @@ -31,14 +31,43 @@ export class PixiLayerMapUI extends AbstractLayer { this._oldk = 0; - // setup the child containers - // these only go visible if they have something to show - - // GEOLOCATION this._geolocationData = null; this._geolocationDirty = false; + + this._lassoData = null; + this._lassoDirty = false; + + this.geolocation = null; + this.tileDebug = null; + this.selected = null; + this.halo = null; + this.lasso = null; + } + + + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + + this._oldk = 0; + + const groupContainer = this.scene.groups.get('ui'); + + // Remove any existing containers + for (const child of groupContainer.children) { + groupContainer.removeChild(child); + child.destroy({ children: true }); // recursive + } + + // Add containers + // These only go visible if they have something to show + + // GEOLOCATION const geolocation = new PIXI.Container(); - geolocation.label= 'geolocation'; + geolocation.label = 'geolocation'; geolocation.eventMode = 'none'; geolocation.sortableChildren = false; geolocation.visible = false; @@ -46,7 +75,7 @@ export class PixiLayerMapUI extends AbstractLayer { // TILE DEBUGGING const tileDebug = new PIXI.Container(); - tileDebug.label= 'tile-debug'; + tileDebug.label = 'tile-debug'; tileDebug.eventMode = 'none'; tileDebug.sortableChildren = false; tileDebug.visible = false; @@ -66,34 +95,25 @@ export class PixiLayerMapUI extends AbstractLayer { halo.visible = true; this.halo = halo; - // Lasso polygon - this._lassoData = null; - this._lassoDirty = false; + // LASSO + if (this._lassoLine) this._lassoLine.destroy(); + if (this._lassoFill) this._lassoFill.destroy(); + this._lassoLine = new PIXI.Graphics(); this._lassoFill = new PIXI.Graphics(); + this._lassoData = null; + const lasso = new PIXI.Container(); - lasso.label= 'lasso'; + lasso.label = 'lasso'; lasso.eventMode = 'none'; lasso.sortableChildren = false; lasso.visible = false; this.lasso = lasso; - const groupContainer = this.scene.groups.get('ui'); groupContainer.addChild(geolocation, tileDebug, selected, halo, lasso); } - /** - * reset - * Every Layer should have a reset function to clear out any state when a reset occurs. - */ - reset() { - super.reset(); - this._lassoData = null; - this.lasso.removeChildren(); - } - - /** * enabled * This layer should always be enabled - it contains important UI stuff @@ -190,7 +210,7 @@ export class PixiLayerMapUI extends AbstractLayer { // line const lineStyle = { alpha: 0.7, dash: [6, 3], width: 1, color: 0xffffff }; line.clear(); - new DashLine(line, lineStyle).poly(flatCoords); + new DashLine(this.gfx, line, lineStyle).poly(flatCoords); // fill const fillStyle = { alpha: 0.5, color: 0xaaaaaa }; diff --git a/modules/pixi/PixiLayerMapillaryDetections.js b/modules/pixi/PixiLayerMapillaryDetections.js index 6547389ce..1d4ee7f17 100644 --- a/modules/pixi/PixiLayerMapillaryDetections.js +++ b/modules/pixi/PixiLayerMapillaryDetections.js @@ -57,6 +57,15 @@ export class PixiLayerMapillaryDetections extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + /** * filterDetections * @param {Array} detections - all detections diff --git a/modules/pixi/PixiLayerMapillaryPhotos.js b/modules/pixi/PixiLayerMapillaryPhotos.js index b7e4c1bb0..de01f3413 100644 --- a/modules/pixi/PixiLayerMapillaryPhotos.js +++ b/modules/pixi/PixiLayerMapillaryPhotos.js @@ -1,4 +1,4 @@ -import { scaleLinear as d3_scaleLinear } from 'd3-scale'; +import { scaleLinear } from 'd3-scale'; import { AbstractLayer } from './AbstractLayer.js'; import { PixiFeatureLine } from './PixiFeatureLine.js'; @@ -25,8 +25,8 @@ const MARKERSTYLE = { fovLength: 1 }; -const fovWidthInterp = d3_scaleLinear([90, 10], [1.3, 0.7]); -const fovLengthInterp = d3_scaleLinear([90, 10], [0.7, 1.5]); +const fovWidthInterp = scaleLinear([90, 10], [1.3, 0.7]); +const fovLengthInterp = scaleLinear([90, 10], [0.7, 1.5]); @@ -62,6 +62,15 @@ export class PixiLayerMapillaryPhotos extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + /** * _bearingchanged * Called whenever the viewer's compass bearing has changed (user pans around) diff --git a/modules/pixi/PixiLayerMapillarySigns.js b/modules/pixi/PixiLayerMapillarySigns.js index 75df2f220..6a9bc8a5d 100644 --- a/modules/pixi/PixiLayerMapillarySigns.js +++ b/modules/pixi/PixiLayerMapillarySigns.js @@ -55,6 +55,15 @@ export class PixiLayerMapillarySigns extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + filterDetections(detections) { const photos = this.context.systems.photos; const fromDate = photos.fromDate; diff --git a/modules/pixi/PixiLayerOsm.js b/modules/pixi/PixiLayerOsm.js index 475903162..a1288afce 100644 --- a/modules/pixi/PixiLayerOsm.js +++ b/modules/pixi/PixiLayerOsm.js @@ -25,24 +25,14 @@ export class PixiLayerOsm extends AbstractLayer { super(scene, layerID); this.enabled = true; // OSM layers should be enabled by default - const basemapContainer = this.scene.groups.get('basemap'); - this._resolved = new Map(); // Map (entity.id -> GeoJSON feature) + this.areaContainer = null; + this.lineContainer = null; + + this._resolved = new Map(); // Map // experiment for benchmarking // this._alreadyDownloaded = false; // this._saveCannedData = false; - - const areas = new PIXI.Container(); - areas.label = `${this.layerID}-areas`; - areas.sortableChildren = true; - this.areaContainer = areas; - - const lines = new PIXI.Container(); - lines.label = `${this.layerID}-lines`; - lines.sortableChildren = true; - this.lineContainer = lines; - - basemapContainer.addChild(areas, lines); } @@ -110,11 +100,35 @@ export class PixiLayerOsm extends AbstractLayer { /** * reset - * Every Layer should have a reset function to clear out any state when a reset occurs. + * Every Layer should have a reset function to replace any Pixi objects and internal state. */ reset() { super.reset(); - this._resolved.clear(); + + this._resolved.clear(); // cached geojson features + + const groupContainer = this.scene.groups.get('basemap'); + + // Remove any existing containers + for (const child of groupContainer.children) { + if (child.label.startsWith(this.layerID + '-')) { // 'osm-*' + groupContainer.removeChild(child); + child.destroy({ children: true }); // recursive + } + } + + // Add containers + const areas = new PIXI.Container(); + areas.label = `${this.layerID}-areas`; // e.g. osm-areas + areas.sortableChildren = true; + this.areaContainer = areas; + + const lines = new PIXI.Container(); + lines.label = `${this.layerID}-lines`; // e.g. osm-lines + lines.sortableChildren = true; + this.lineContainer = lines; + + groupContainer.addChild(areas, lines); } diff --git a/modules/pixi/PixiLayerOsmNotes.js b/modules/pixi/PixiLayerOsmNotes.js index 3d228b777..b57ec91e5 100644 --- a/modules/pixi/PixiLayerOsmNotes.js +++ b/modules/pixi/PixiLayerOsmNotes.js @@ -55,6 +55,15 @@ export class PixiLayerOsmNotes extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + /** * renderMarkers * @param frame Integer frame being rendered diff --git a/modules/pixi/PixiLayerOsmose.js b/modules/pixi/PixiLayerOsmose.js index 8289a2fc9..2b76f3e98 100644 --- a/modules/pixi/PixiLayerOsmose.js +++ b/modules/pixi/PixiLayerOsmose.js @@ -55,6 +55,15 @@ export class PixiLayerOsmose extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + /** * renderMarkers * @param frame Integer frame being rendered diff --git a/modules/pixi/PixiLayerRapid.js b/modules/pixi/PixiLayerRapid.js index 4c7ec112f..9f12217fd 100644 --- a/modules/pixi/PixiLayerRapid.js +++ b/modules/pixi/PixiLayerRapid.js @@ -25,7 +25,7 @@ export class PixiLayerRapid extends AbstractLayer { super(scene, layerID); this.enabled = true; // Rapid features should be enabled by default - this._resolved = new Map(); // Map (entity.id -> GeoJSON feature) + this._resolved = new Map(); // Map //// shader experiment: //this._uniforms = { @@ -154,11 +154,23 @@ export class PixiLayerRapid extends AbstractLayer { /** * reset - * Every Layer should have a reset function to clear out any state when a reset occurs. + * Every Layer should have a reset function to replace any Pixi objects and internal state. */ reset() { super.reset(); - this._resolved.clear(); + this._resolved.clear(); // cached geojson features + + const groupContainer = this.scene.groups.get('basemap'); + + // Remove any existing containers + for (const child of groupContainer.children) { + if (child.label.startsWith(this.layerID + '-')) { // 'rapid-*' + groupContainer.removeChild(child); + child.destroy({ children: true }); // recursive + } + } + + // We don't add area or line containers here - `renderDataset()` does it as needed } @@ -180,7 +192,7 @@ export class PixiLayerRapid extends AbstractLayer { //this._uniforms.u_time = frame/10; for (const dataset of rapid.datasets.values()) { - this.renderDataset(dataset, frame, viewport, zoom); + this.renderDataset(dataset, frame, viewport, zoom); } } @@ -263,7 +275,6 @@ export class PixiLayerRapid extends AbstractLayer { } const entities = service.getData(datasetID); - for (const entity of entities) { if (isAcceptedOrIgnored(entity)) continue; // skip features already accepted/ignored by the user const geom = entity.geometry(dsGraph); @@ -275,8 +286,8 @@ export class PixiLayerRapid extends AbstractLayer { data.polygons.push(entity); } } - } else if (dataset.service === 'overture') { + } else if (dataset.service === 'overture') { if (zoom >= 16) { // avoid firing off too many API requests service.loadTiles(datasetID); // fetch more } @@ -284,9 +295,9 @@ export class PixiLayerRapid extends AbstractLayer { // Just support points (for now) for (const entity of entities) { - entity.overture = true; - entity.__datasetid__ = datasetID; - data.points.push(entity); + entity.overture = true; + entity.__datasetid__ = datasetID; + data.points.push(entity); } } diff --git a/modules/pixi/PixiLayerRapidOverlay.js b/modules/pixi/PixiLayerRapidOverlay.js index 3d257fdbc..65e5a11e3 100644 --- a/modules/pixi/PixiLayerRapidOverlay.js +++ b/modules/pixi/PixiLayerRapidOverlay.js @@ -18,19 +18,39 @@ export class PixiLayerRapidOverlay extends AbstractLayer { */ constructor(scene, layerID) { super(scene, layerID); - this._clear(); + this._enabled = true; + this._overlaysDefined = null; + this.overlaysContainer = null; + } + + + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + + const groupContainer = this.scene.groups.get('basemap'); + // Remove any existing containers + for (const child of groupContainer.children) { + if (child.label === this.layerID) { // 'rapidoverlay' + groupContainer.removeChild(child); + child.destroy({ children: true }); // recursive + } + } + + // Add containers const overlays = new PIXI.Container(); - overlays.label = `${this.layerID}`; + overlays.label = `${this.layerID}`; // 'rapidoverlay' overlays.sortableChildren = false; overlays.interactiveChildren = true; this.overlaysContainer = overlays; this._overlaysDefined = null; - - const basemapContainer = this.scene.groups.get('basemap'); - basemapContainer.addChild(overlays); + groupContainer.addChild(overlays); } @@ -48,7 +68,7 @@ export class PixiLayerRapidOverlay extends AbstractLayer { const datasets = this.context.systems.rapid.datasets; const parentContainer = this.overlaysContainer; - //Extremely inefficient but we're not drawing anything else at this zoom + // Extremely inefficient but we're not drawing anything else at this zoom parentContainer.removeChildren(); for (const dataset of datasets.values()) { @@ -57,8 +77,8 @@ export class PixiLayerRapidOverlay extends AbstractLayer { const overlay = dataset.overlay; if (vtService) { if ((zoom >= overlay.minZoom ) && (zoom <= overlay.maxZoom)) { // avoid firing off too many API requests - vtService.loadTiles(overlay.url); - } + vtService.loadTiles(overlay.url); + } const overlayData = vtService.getData(overlay.url).map(d => d.geojson); const points = overlayData.filter(d => d.geometry.type === 'Point' || d.geometry.type === 'MultiPoint'); this.renderPoints(frame, viewport, zoom, points, customColor); @@ -112,19 +132,9 @@ export class PixiLayerRapidOverlay extends AbstractLayer { this._overlaysDefined = true; } } - } return this._overlaysDefined; } - - /** - * _clear - * Clear state to prepare for new custom data - */ - _clear() { - this._overlaysDefined = null; - } - } diff --git a/modules/pixi/PixiLayerStreetsidePhotos.js b/modules/pixi/PixiLayerStreetsidePhotos.js index 88f8741c1..d0b2b91ab 100644 --- a/modules/pixi/PixiLayerStreetsidePhotos.js +++ b/modules/pixi/PixiLayerStreetsidePhotos.js @@ -1,4 +1,4 @@ -import { scaleLinear as d3_scaleLinear } from 'd3-scale'; +import { scaleLinear } from 'd3-scale'; import { AbstractLayer } from './AbstractLayer.js'; import { PixiFeatureLine } from './PixiFeatureLine.js'; @@ -25,8 +25,8 @@ const MARKERSTYLE = { fovLength: 1 }; -const fovWidthInterp = d3_scaleLinear([90, 10], [1.3, 0.7]); -const fovLengthInterp = d3_scaleLinear([90, 10], [0.7, 1.5]); +const fovWidthInterp = scaleLinear([90, 10], [1.3, 0.7]); +const fovLengthInterp = scaleLinear([90, 10], [0.7, 1.5]); @@ -55,6 +55,15 @@ export class PixiLayerStreetsidePhotos extends AbstractLayer { } + /** + * reset + * Every Layer should have a reset function to replace any Pixi objects and internal state. + */ + reset() { + super.reset(); + } + + /** * _dirtyCurrentPhoto * If we are interacting with the viewer (zooming / panning), diff --git a/modules/pixi/PixiScene.js b/modules/pixi/PixiScene.js index 86265c2bf..c0b8d4b6f 100644 --- a/modules/pixi/PixiScene.js +++ b/modules/pixi/PixiScene.js @@ -44,9 +44,9 @@ function asSet(vals) { * - `classID` - A pseudoclass identifier like 'hover' or 'select' * * Properties you can access: - * `groups` `Map (groupID -> PIXI.Container)` of all groups - * `layers` `Map (layerID -> Layer)` of all layers in the scene - * `features` `Map (featureID -> Feature)` of all features in the scene + * `groups` `Map` of all groups + * `layers` `Map` of all layers in the scene + * `features` `Map` of all features in the scene * * Events available: * `layerchange` Fires when layers are toggled from enabled/disabled @@ -62,30 +62,9 @@ export class PixiScene extends EventEmitter { this.gfx = gfx; this.context = gfx.context; - this.groups = new Map(); // Map (groupID -> PIXI.Container) - this.layers = new Map(); // Map (layerID -> Layer) - this.features = new Map(); // Map (featureID -> Feature) - - // Create Groups, and add them to the origin.. - // Groups are pre-established Containers that the Layers can add - // their Features to, so that the scene can be sorted reasonably. - [ - 'background', // Background imagery - 'basemap', // Editable basemap (OSM/Rapid) - 'points', // Editable points (OSM/Rapid) - 'streetview', // Streetview imagery, sequences - 'qa', // Q/A items, issues, notes - 'labels', // Text labels - 'blocks', // Blocked out regions - 'ui' // Misc UI draw above everything (select lasso, geocoding circle, debug shapes) - ].forEach((groupID, i) => { - const container = new PIXI.Container(); - container.label = groupID; - container.sortableChildren = true; - container.zIndex = i; - gfx.origin.addChild(container); - this.groups.set(groupID, container); - }); + this.groups = new Map(); // Map + this.layers = new Map(); // Map + this.features = new Map(); // Map // Create Layers [ @@ -93,7 +72,7 @@ export class PixiScene extends EventEmitter { new PixiLayerGeoScribble(this, 'geoScribble'), new PixiLayerOsm(this, 'osm'), new PixiLayerRapid(this, 'rapid'), - new PixiLayerRapidOverlay(this, 'rapid-overlay'), + new PixiLayerRapidOverlay(this, 'rapidoverlay'), new PixiLayerMapillaryDetections(this, 'mapillary-detections'), new PixiLayerMapillarySigns(this, 'mapillary-signs'), @@ -113,18 +92,53 @@ export class PixiScene extends EventEmitter { new PixiLayerMapUI(this, 'map-ui') ].forEach(layer => this.layers.set(layer.id, layer)); + this.reset(); } /** * reset - * Calls each Layer's `reset' method. - * This is used to clear out any state when a reset occurs. + * Replace any Pixi objects and internal state. + * Also calls each Layer's `reset' method to do the same for that layer. */ reset() { + const gfx = this.gfx; + const origin = gfx.origin; + if (!origin) return; // need the `origin` container to exist first + + // Remove any existing containers + for (const child of origin.children) { + origin.removeChild(child); + child.destroy({ children: true }); // recursive + } + + // Create group containers, and add them to the origin.. + // Groups are pre-established Containers that the Layers can add + // their Features to, so that the scene can be sorted reasonably. + [ + 'background', // Background imagery + 'basemap', // Editable basemap (OSM/Rapid) + 'points', // Editable points (OSM/Rapid) + 'streetview', // Streetview imagery, sequences + 'qa', // Q/A items, issues, notes + 'labels', // Text labels + 'blocks', // Blocked out regions + 'ui' // Misc UI draw above everything (select lasso, geocoding circle, debug shapes) + ].forEach((groupID, i) => { + const container = new PIXI.Container(); + container.label = groupID; + container.sortableChildren = true; + container.zIndex = i; + origin.addChild(container); + this.groups.set(groupID, container); + }); + + // Reset/setup each layer for (const layer of this.layers.values()) { layer.reset(); } + + this.emit('layerchange'); } diff --git a/modules/pixi/PixiTextures.js b/modules/pixi/PixiTextures.js index d687e1f95..5016723ca 100644 --- a/modules/pixi/PixiTextures.js +++ b/modules/pixi/PixiTextures.js @@ -10,7 +10,7 @@ import { AtlasAllocator, registerAtlasUploader } from './lib/AtlasAllocator.js'; * This helps pack them efficiently and avoids swapping textures frequently as WebGL draws the scene. * * Properties you can access: - * `loaded` `true` after the spritesheets and textures have finished loading + * `loaded` `true` after the patterns have finished loading */ export class PixiTextures { @@ -21,9 +21,51 @@ export class PixiTextures { constructor(gfx) { this.gfx = gfx; this.context = gfx.context; + this.loaded = false; + // We store textures in 3 atlases, each one is for holding similar sized things. See `reset()` below. + // Each "atlas" manages its own store of "TextureSources" - real textures that upload to the GPU. + // This helps pack them efficiently and avoids swapping textures frequently as WebGL draws the scene. + this._atlas = null; + + // All the named textures we know about. + // Each mapping is a unique identifying key to a PIXI.Texture + // The Texture is not necessarily packed in an Atlas (but ideally it should be) + // Important! Make sure these texture keys don't conflict + this._textureData = new Map(); // Map (e.g. 'symbol-boldPin') + + // Because SVGs take some time to texturize, store the svg string and texturize only if needed + this._svgIcons = new Map(); // Map (e.g. 'temaki-school') + + // DashLine plugin needs its own cache - we could eventually put these in an atlas. + this._dashTextureCache = {}; + + // Prepare a "bundle" to load the pattern textures const assets = this.context.systems.assets; + const filenames = [ + 'bushes', 'cemetery', 'cemetery_buddhist', 'cemetery_christian', 'cemetery_jewish', 'cemetery_muslim', + 'construction', 'dots', 'farmland', 'farmyard', 'forest', 'forest_broadleaved', 'forest_leafless', + 'forest_needleleaved', 'grass', 'landfill', 'lines', 'orchard', 'pond', 'quarry', 'vineyard', + 'waves', 'wetland', 'wetland_bog', 'wetland_marsh', 'wetland_reedbed', 'wetland_swamp' + ]; + const bundle = {}; + for (const k of filenames) { + bundle[k] = assets.getFileURL(`img/pattern/${k}.png`); + } + + PIXI.Assets.addBundle('patterns', bundle); + + this.reset(); + } + + + /** + * reset + * Replace any Pixi objects and internal state. + */ + reset() { + const gfx = this.gfx; // Before using the atlases, we need to register the upload function with the renderer. // This will choose the correct function for either webGL or webGPU renderer type. @@ -32,16 +74,17 @@ export class PixiTextures { // Try to get the max texture size. // We will prefer large atlas size of 8192 to avoid texture swapping, but can settle for less. + const trysize = 8192; let maxsize = 2048; // a reasonable default - if (renderer.type === PIXI.RendererType.WEBGL) { - const gl = renderer.gl; - maxsize = gl.getParameter(gl.MAX_TEXTURE_SIZE); - } else if (renderer.type === PIXI.RendererType.WEBGPU) { - const gpu = renderer.gpu; - maxsize = gpu.adapter.limits.maxTextureDimension2D; + if (gfx.highQuality) { + if (renderer.type === PIXI.RendererType.WEBGL) { + const gl = renderer.gl; + maxsize = gl.getParameter(gl.MAX_TEXTURE_SIZE); + } else if (renderer.type === PIXI.RendererType.WEBGPU) { + const gpu = renderer.gpu; + maxsize = gpu.adapter.limits.maxTextureDimension2D; + } } - - const trysize = 8192; const size = Math.min(trysize, maxsize); // We store textures in 3 atlases, each one is for holding similar sized things. @@ -53,33 +96,25 @@ export class PixiTextures { tile: new AtlasAllocator('tile', size) // 256 or 512px square imagery tiles }; + this._textureData.clear(); + this._svgIcons.clear(); + this._dashTextureCache = {}; - // All the named textures we know about. - // Each mapping is a unique identifying key to a PIXI.Texture - // The Texture is not necessarily packed in an Atlas (but ideally it should be) - // Important! Make sure these texture keys don't conflict - this._textureData = new Map(); // Map (e.g. 'symbol-boldPin') - - // Because SVGs take some time to texturize, store the svg string and texturize only if needed - this._svgIcons = new Map(); // Map (e.g. 'temaki-school') - - // Load patterns - const PATTERNS = [ - 'bushes', 'cemetery', 'cemetery_buddhist', 'cemetery_christian', 'cemetery_jewish', 'cemetery_muslim', - 'construction', 'dots', 'farmland', 'farmyard', 'forest', 'forest_broadleaved', 'forest_leafless', - 'forest_needleleaved', 'grass', 'landfill', 'lines', 'orchard', 'pond', 'quarry', 'vineyard', - 'waves', 'wetland', 'wetland_bog', 'wetland_marsh', 'wetland_reedbed', 'wetland_swamp' - ]; - let patternBundle = {}; - for (const k of PATTERNS) { - patternBundle[k] = assets.getFileURL(`img/pattern/${k}.png`); - } - - PIXI.Assets.addBundle('patterns', patternBundle); + this._cacheGraphics(); - PIXI.Assets.loadBundle(['patterns']) + // Replace or load patterns + Promise.resolve() + .then(() => { + // If they've been loaded before, force-unload them to get new Textures. + // This might happen after a WebGL context loss. + if (this.loaded) { + PIXI.Assets.unloadBundle(['patterns']); + this.loaded = false; + } + }) + .then(() => PIXI.Assets.loadBundle(['patterns'])) .then(result => { - // note that we can't pack patterns into an atlas yet - see PixiFeaturePolygon.js. + // note that we can't pack patterns into an atlas yet - see PixiFeaturePolygon.js for (const [textureID, texture] of Object.entries(result.patterns)) { this._textureData.set(textureID, { texture: texture, refcount: 1 }); } @@ -94,8 +129,6 @@ export class PixiTextures { this.loaded = true; }) .catch(e => console.error(e)); // eslint-disable-line no-console - - this._cacheGraphics(); } @@ -346,7 +379,9 @@ export class PixiTextures { // see https://github.com/facebook/Rapid/commit/dd24e912 const viewBox = symbol.getAttribute('viewBox'); - const size = 64; // somewhat large, but some mapillary signs have a lot of detail/text in them + + // Somewhat large, but some Mapillary signs have a lot of detail/text in them + const size = this.gfx.highQuality ? 64 : 32; // Make a new container let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); diff --git a/modules/pixi/lib/DashLine.js b/modules/pixi/lib/DashLine.js index 49c03895d..7460fc245 100644 --- a/modules/pixi/lib/DashLine.js +++ b/modules/pixi/lib/DashLine.js @@ -29,14 +29,14 @@ const dashLineOptionsDefault = { }; -let _dashTextureCache = {}; // cache of Textures for dashed lines export class DashLine { /** * Create a DashLine - * @param graphics + * @param {GraphicsSystem} gfx - Reference back to the GraphicsSystem, so we can find the texture cache + * @param {PIXI.Graphics} graphics - The Pixi graphics object to draw with a dashed-line style * @param [options] * @param [options.useTexture=false] - use the texture based render (useful for very large or very small dashed lines) * @param [options.dash=[10,5] - an array holding the dash and gap (eg, [10, 5, 20, 5, ...]) @@ -47,7 +47,9 @@ export class DashLine { * @param [options.join] - add a LINE_JOIN style to the dashed lines (only works for useTexture: false) * @param [options.alignment] - The alignment of any lines drawn (0.5 = middle, 1 = outer, 0 = inner) */ - constructor(graphics, options = {}) { + constructor(gfx, graphics, options = {}) { + this.gfx = gfx; + options = { ...dashLineOptionsDefault, ...options }; this.options = options; @@ -391,9 +393,15 @@ export class DashLine { * @return {PIXI.Texture} */ _getTexture(options, dashSize) { + const dashTextureCache = this.gfx.textures?._dashTextureCache; + if (!dashTextureCache) { // called too early? + console.error('No DashTextureCache found'); // eslint-disable-line no-console + return null; + } + const key = options.dash.toString(); - if (_dashTextureCache[key]) { - return _dashTextureCache[key]; + if (dashTextureCache[key]) { + return dashTextureCache[key]; } // For WebGL1 support, this canvas should have power of 2 dimensions. @@ -404,7 +412,7 @@ export class DashLine { canvas.height = PIXI.nextPow2(drawHeight); const ctx = canvas.getContext('2d'); if (!ctx) { - console.warn('Did not get context from canvas'); // eslint-disable-line no-console + console.error('Did not get context from canvas'); // eslint-disable-line no-console return null; } @@ -431,7 +439,7 @@ export class DashLine { } ctx.stroke(); - const texture = (_dashTextureCache[key] = PIXI.Texture.from(canvas)); + const texture = (dashTextureCache[key] = PIXI.Texture.from(canvas)); texture.source.scaleMode = 'nearest'; return texture; diff --git a/modules/ui/UiMinimap.js b/modules/ui/UiMinimap.js index 9c5e2fe64..72eec0103 100644 --- a/modules/ui/UiMinimap.js +++ b/modules/ui/UiMinimap.js @@ -45,9 +45,10 @@ export class UiMinimap { // (This is also necessary when using `d3-selection.call`) this.render = this.render.bind(this); this.toggle = this.toggle.bind(this); - this.drawMinimap = this.drawMinimap.bind(this); this._setupKeybinding = this._setupKeybinding.bind(this); - this._tick = this._tick.bind(this); + this._draw = this._draw.bind(this); + this._resetAsync = this._resetAsync.bind(this); + this._update = this._update.bind(this); this._zoomStarted = this._zoomStarted.bind(this); this._zoomed = this._zoomed.bind(this); this._zoomEnded = this._zoomEnded.bind(this); @@ -124,15 +125,15 @@ export class UiMinimap { .attr('height', h * 2); this.initAsync() - .then(() => this.drawMinimap()); + .then(() => this._update()); } /** - * drawMinimap + * _update * Call this whenever something about the minimap needs to change */ - drawMinimap() { + _update() { if (this._isHidden) return; const gfx = this.context.systems.gfx; @@ -140,7 +141,7 @@ export class UiMinimap { this._updateTransform(); this._updateBoundingBox(); - this._tick(); + this._draw(); } @@ -194,7 +195,7 @@ export class UiMinimap { // update `_zDiff` (difference in zoom between main and mini) this._zDiff = viewMain.transform.zoom - viewMini.transform.zoom; - this.drawMinimap(); + this._update(); } @@ -213,7 +214,7 @@ export class UiMinimap { this._tStart = null; this._gesture = null; - this.drawMinimap(); + this._update(); } @@ -364,7 +365,7 @@ export class UiMinimap { }); } else { - this.drawMinimap(); + this._update(); $wrap .style('display', 'block') .style('opacity', '0') @@ -387,10 +388,10 @@ export class UiMinimap { /** - * _tick + * _draw * Draw the minimap */ - _tick() { + _draw() { if (this._isHidden) return; const gfx = this.context.systems.gfx; @@ -445,7 +446,8 @@ renderer.view.canvas = mainCanvas; // restore main canvas if (!gfx.pixi || !gfx.textures) return Promise.reject(); // called too early? // event handlers - gfx.on('draw', this.drawMinimap); + gfx.on('draw', this._update); + gfx.on('contextchange', this._resetAsync); // Mock Stage const stage = new PIXI.Container(); @@ -492,6 +494,37 @@ renderer.view.canvas = mainCanvas; // restore main canvas } + /** + * _resetAsync + * Replace the Minimap after a context loss + * @return {Promise} Promise resolved when this component has completed reset and init + */ + _resetAsync() { + const context = this.context; + const gfx = context.systems.gfx; + + // event handlers + gfx.off('draw', this._update); + gfx.off('contextchange', this._resetAsync); + + if (this.layer) { + this.layer.destroyAll(); + this.layer = null; + } + + if (this.stage) { + this.stage.destroy({ children: true }); + this.stage = null; + } + + this._initPromise = null; + + // redo init and draw + return this.initAsync() + .then(() => this._update()); + } + + /** * _setupKeybinding * This sets up the keybinding, replacing existing if needed diff --git a/modules/ui/UiRapidDatasetToggle.js b/modules/ui/UiRapidDatasetToggle.js index 43b3960c2..b5ea1e3cc 100644 --- a/modules/ui/UiRapidDatasetToggle.js +++ b/modules/ui/UiRapidDatasetToggle.js @@ -436,7 +436,7 @@ export class UiRapidDatasetToggle { if (dataset) { dataset.color = color; - scene.dirtyLayers(['rapid', 'rapid-overlay']); + scene.dirtyLayers(['rapid', 'rapidoverlay']); gfx.immediateRedraw(); this.render(); diff --git a/modules/ui/UiSpector.js b/modules/ui/UiSpector.js index 4d3301d99..801505028 100644 --- a/modules/ui/UiSpector.js +++ b/modules/ui/UiSpector.js @@ -18,6 +18,7 @@ export class UiSpector { this.context = context; this._isHidden = true; // start out hidden + this._spyCanvas = null; // Child components, we will defer creating these until `_initSpectorUI()` this.Spector = null; @@ -110,22 +111,35 @@ export class UiSpector { */ _initSpectorUI() { if (!this.$wrap) return; // called too early? - if (this.Spector) return; // already done if (!window.SPECTOR) return; // no spector - production build? const context = this.context; const gfx = context.systems.gfx; - const renderer = gfx.pixi.renderer; + const renderer = gfx.pixi?.renderer; + + if (!renderer) { // This could happen if the WebGL context is lost and `pixi` is gone temporarily. + this._reset(); // Throw everything away, but the user can try toggling it on again later. + return; + } // Spector will only work with the WebGL renderer if (renderer.type !== PIXI.RendererType.WEBGL) return; // webgpu? - const spector = new window.SPECTOR.Spector(); - this.Spector = spector; // The default behavior of the CaptureMenu is to search the document for canvases to spy. // This doesn't work in our situation because Pixi is setup with `multiView: true` // and will render to an offscreen canvas - instead we will tell it what canvas to spy on. + + // If canvas has changed because we replaced Pixi after a context loss, reset but continue on.. const canvas = renderer.context.canvas; + if (canvas !== this._spyCanvas) { + this._reset(); + } + + if (this.Spector) return; // init steps below were already done + + const spector = new window.SPECTOR.Spector(); + this.Spector = spector; + this._spyCanvas = canvas; spector.spyCanvas(canvas); // override of spector.getCaptureUI() @@ -167,4 +181,14 @@ export class UiSpector { this.ResultView = spector.resultView = rv; } + + /** + * _reset + */ + _reset() { + this._spyCanvas = null; + this.Spector = null; + this.CaptureMenu = null; + this.ResultView = null; + } } diff --git a/modules/ui/intro/helper.js b/modules/ui/intro/helper.js index 9a72d0d5d..2d2677b23 100644 --- a/modules/ui/intro/helper.js +++ b/modules/ui/intro/helper.js @@ -298,5 +298,5 @@ export function transitionTime(loc1, loc2) { * @return Promise that settles after the delay */ export function delayAsync(ms = 300) { - return new Promise(resolve => window.setTimeout(resolve, ms)); // eslint-disable-line no-promise-executor-return + return new Promise(resolve => { window.setTimeout(resolve, ms); }); }