Destroy canvas in plots if not visible (#7263)

* first draft

* add some more debugging

* add test and remove debug

* Remove debug function

* consolidate destroy

* add better canvas name and handle if gl has gone missing

* extra check for extension
This commit is contained in:
Scott Bell 2023-12-04 22:28:24 +01:00 committed by GitHub
parent 2dc1388737
commit e7b9481aa9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 84 additions and 31 deletions

View File

@ -54,21 +54,35 @@ test.describe('Tabs View', () => {
// ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
// no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas')).toBeHidden();
// select second tab
await page.getByLabel(`${notebook.name} tab`).click();
// ensure notebook visible
await expect(page.locator('.c-notebook__drag-area')).toBeVisible();
// no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas')).toBeHidden();
// select third tab
await page.getByLabel(`${sineWaveGenerator.name} tab`).click();
// expect sine wave generator visible
expect(await page.locator('.c-plot').isVisible()).toBe(true);
await expect(page.locator('.c-plot')).toBeVisible();
// expect two canvases (i.e., overlay & main canvas for sine wave generator) to be visible
await expect(page.locator('canvas')).toHaveCount(2);
await expect(page.locator('canvas').nth(0)).toBeVisible();
await expect(page.locator('canvas').nth(1)).toBeVisible();
// now try to select the first tab again
await page.getByLabel(`${table.name} tab`).click();
// ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
// no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas')).toBeHidden();
});
});

View File

@ -22,8 +22,8 @@
<template>
<div ref="chart" class="gl-plot-chart-area">
<canvas :style="canvasStyle"></canvas>
<canvas :style="canvasStyle"></canvas>
<canvas :style="canvasStyle" class="js-overlay-canvas"></canvas>
<canvas :style="canvasStyle" class="js-main-canvas"></canvas>
<div ref="limitArea" class="js-limit-area">
<limit-label
v-for="(limitLabel, index) in visibleLimitLabels"
@ -197,6 +197,10 @@ export default {
}
},
mounted() {
this.chartVisible = true;
this.chartContainer = this.$refs.chart;
this.visibilityObserver = new IntersectionObserver(this.visibilityChanged);
this.visibilityObserver.observe(this.chartContainer);
eventHelpers.extend(this);
this.seriesModels = [];
this.config = this.getConfig();
@ -239,10 +243,8 @@ export default {
this.seriesElements = new WeakMap();
this.seriesLimits = new WeakMap();
let canvasEls = this.$parent.$refs.chartContainer.querySelectorAll('canvas');
const mainCanvas = canvasEls[1];
const overlayCanvas = canvasEls[0];
if (this.initializeCanvas(mainCanvas, overlayCanvas)) {
const canvasReadyForDrawing = this.readyCanvasForDrawing();
if (canvasReadyForDrawing) {
this.draw();
}
@ -256,6 +258,7 @@ export default {
},
beforeUnmount() {
this.destroy();
this.visibilityObserver.unobserve(this.chartContainer);
},
methods: {
getConfig() {
@ -272,6 +275,26 @@ export default {
return config;
},
visibilityChanged([entry]) {
if (entry.target === this.chartContainer) {
const wasVisible = this.chartVisible;
this.chartVisible = entry.isIntersecting;
if (!this.chartVisible) {
// destroy the chart
this.destroyCanvas();
} else if (!wasVisible && this.chartVisible) {
// rebuild the chart
this.buildCanvasElements();
const canvasInitialized = this.readyCanvasForDrawing();
if (canvasInitialized) {
this.draw();
}
this.$emit('plot-reinitialize-canvas');
} else if (wasVisible && this.chartVisible) {
// ignore, moving on
}
}
},
reDraw(newXKey, oldXKey, series) {
this.changeInterpolate(newXKey, oldXKey, series);
this.changeMarkers(newXKey, oldXKey, series);
@ -417,13 +440,12 @@ export default {
this.scheduleDraw();
},
destroy() {
this.destroyCanvas();
this.isDestroyed = true;
this.stopListening();
this.lines.forEach((line) => line.destroy());
this.limitLines.forEach((line) => line.destroy());
this.pointSets.forEach((pointSet) => pointSet.destroy());
this.alarmSets.forEach((alarmSet) => alarmSet.destroy());
DrawLoader.releaseDrawAPI(this.drawAPI);
},
resetYOffsetAndSeriesDataForYAxis(yAxisId) {
delete this.offset[yAxisId].y;
@ -477,33 +499,49 @@ export default {
return this.offset[yAxisId].y(pSeries.getYVal(point));
}.bind(this);
},
initializeCanvas(canvas, overlay) {
this.canvas = canvas;
this.overlay = overlay;
this.drawAPI = DrawLoader.getDrawAPI(canvas, overlay);
destroyCanvas() {
if (this.isDestroyed) {
return;
}
this.stopListening(this.drawAPI);
DrawLoader.releaseDrawAPI(this.drawAPI);
if (this.chartContainer) {
const canvasElements = this.chartContainer.querySelectorAll('canvas');
canvasElements.forEach((canvas) => {
canvas.parentNode.removeChild(canvas);
});
}
},
readyCanvasForDrawing() {
const canvasEls = this.chartContainer.querySelectorAll('canvas');
const mainCanvas = canvasEls[1];
const overlayCanvas = canvasEls[0];
this.canvas = mainCanvas;
this.overlay = overlayCanvas;
this.drawAPI = DrawLoader.getDrawAPI(mainCanvas, overlayCanvas);
if (this.drawAPI) {
this.listenTo(this.drawAPI, 'error', this.fallbackToCanvas, this);
}
return Boolean(this.drawAPI);
},
fallbackToCanvas() {
this.stopListening(this.drawAPI);
DrawLoader.releaseDrawAPI(this.drawAPI);
// Have to throw away the old canvas elements and replace with new
// canvas elements in order to get new drawing contexts.
buildCanvasElements() {
const div = document.createElement('div');
div.innerHTML = `
<canvas style="position: absolute; background: none; width: 100%; height: 100%;"></canvas>
<canvas style="position: absolute; background: none; width: 100%; height: 100%;"></canvas>
<canvas style="position: absolute; background: none; width: 100%; height: 100%;" class="js-overlay-canvas"></canvas>
<canvas style="position: absolute; background: none; width: 100%; height: 100%;" class="js-main-canvas"></canvas>
`;
const mainCanvas = div.querySelectorAll('canvas')[1];
const overlayCanvas = div.querySelectorAll('canvas')[0];
this.canvas.parentNode.replaceChild(mainCanvas, this.canvas);
this.chartContainer.appendChild(mainCanvas, this.canvas);
this.canvas = mainCanvas;
this.overlay.parentNode.replaceChild(overlayCanvas, this.overlay);
this.chartContainer.appendChild(overlayCanvas, this.overlay);
this.overlay = overlayCanvas;
},
fallbackToCanvas() {
console.warn(`📈 fallback to 2D canvas`);
this.destroyCanvas();
this.buildCanvasElements();
this.drawAPI = DrawLoader.getFallbackDrawAPI(this.canvas, this.overlay);
this.$emit('plot-reinitialize-canvas');
},
@ -653,7 +691,7 @@ export default {
},
draw() {
this.drawScheduled = false;
if (this.isDestroyed) {
if (this.isDestroyed || !this.chartVisible) {
return;
}
@ -681,6 +719,9 @@ export default {
});
},
updateViewport(yAxisId) {
if (!this.chartVisible) {
return;
}
const mainYAxisId = this.config.yAxis.get('id');
const xRange = this.config.xAxis.get('displayRange');
let yRange;

View File

@ -154,14 +154,14 @@ DrawWebGL.prototype.initContext = function () {
DrawWebGL.prototype.destroy = function () {
// Lose the context and delete all associated resources
// https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices#lose_contexts_eagerly
this.gl.getExtension('WEBGL_lose_context').loseContext();
this.gl.deleteBuffer(this.buffer);
this.gl?.getExtension('WEBGL_lose_context')?.loseContext();
this.gl?.deleteBuffer(this.buffer);
this.buffer = undefined;
this.gl.deleteProgram(this.program);
this.gl?.deleteProgram(this.program);
this.program = undefined;
this.gl.deleteShader(this.vertexShader);
this.gl?.deleteShader(this.vertexShader);
this.vertexShader = undefined;
this.gl.deleteShader(this.fragmentShader);
this.gl?.deleteShader(this.fragmentShader);
this.fragmentShader = undefined;
this.gl = undefined;

View File

@ -38,8 +38,6 @@ export default class VisibilityObserver {
if (!element) {
throw new Error(`VisibilityObserver must be created with an element`);
}
// set the id to some random 4 letters
this.id = Math.random().toString(36).substring(2, 6);
this.#element = element;
this.isIntersecting = true;