Support multiple sources for layers

This commit is contained in:
Andreas Hocevar
2022-01-10 20:37:50 +01:00
parent 1a8df049e4
commit 0004b2594d
20 changed files with 348 additions and 67 deletions

View File

@@ -4,9 +4,8 @@ title: GeoTIFF tile pyramid
shortdesc: Rendering a COG tile pyramid as layer group.
docs: >
Data from a Cloud Optimized GeoTIFF (COG) tile pyramid can be rendered as a set of layers. In this
example, a pyramid of 3-band GeoTIFFs is used to render RGB data. For each tile of the pyramid, a
separate layer is created on demand. The lowest resolution layer serves as preview while higher resolutions are
loading.
example, a pyramid of 3-band GeoTIFFs is used to render RGB data. The `ol/source.sourcesFromTileGrid`
helper function creates sources from this pyramid on demand.
tags: "cog, tilepyramid, stac"
---
<div id="map" class="map"></div>

View File

@@ -1,16 +1,16 @@
import GeoTIFF from '../src/ol/source/GeoTIFF.js';
import LayerGroup from '../src/ol/layer/Group.js';
import Map from '../src/ol/Map.js';
import TileGrid from '../src/ol/tilegrid/TileGrid.js';
import View from '../src/ol/View.js';
import WebGLTileLayer from '../src/ol/layer/WebGLTile.js';
import {getIntersection} from '../src/ol/extent.js';
import {createXYZ} from '../src/ol/tilegrid.js';
import {sourcesFromTileGrid} from '../src/ol/source.js';
// Metadata from https://s2downloads.eox.at/demo/EOxCloudless/2019/rgb/2019_EOxCloudless_rgb.json
// Tile grid of the GeoTIFF pyramid layout
const tileGrid = new TileGrid({
origin: [-180, 90],
extent: [-180, -90, 180, 90],
resolutions: [0.703125, 0.3515625, 0.17578125, 8.7890625e-2, 4.39453125e-2],
tileSizes: [
[512, 256],
@@ -21,30 +21,25 @@ const tileGrid = new TileGrid({
],
});
const pyramid = new LayerGroup();
const layerForUrl = {};
const zs = tileGrid.getResolutions().length;
function useLayer(z, x, y) {
const url = `https://s2downloads.eox.at/demo/EOxCloudless/2019/rgb/${z}/${y}/${x}.tif`;
if (!(url in layerForUrl)) {
pyramid.getLayers().push(
new WebGLTileLayer({
minZoom: z,
maxZoom: z === 0 || z === zs - 1 ? undefined : z + 1,
extent: tileGrid.getTileCoordExtent([z, x, y]),
source: new GeoTIFF({
sources: [
{
url: url,
},
],
const pyramid = new WebGLTileLayer({
sources: sourcesFromTileGrid(
tileGrid,
([z, x, y]) =>
new GeoTIFF({
tileGrid: createXYZ({
extent: tileGrid.getTileCoordExtent([z, x, y]),
minZoom: z,
maxZoom:
z === tileGrid.getResolutions().length - 1 ? undefined : z + 1,
}),
sources: [
{
url: `https://s2downloads.eox.at/demo/EOxCloudless/2019/rgb/${z}/${y}/${x}.tif`,
},
],
})
);
layerForUrl[url] = true;
}
}
),
});
const map = new Map({
target: 'map',
@@ -56,16 +51,3 @@ const map = new Map({
showFullExtent: true,
}),
});
// Add overview layer
useLayer(0, 0, 0);
// Add layer for specific extent on demand
map.on('moveend', () => {
const view = map.getView();
tileGrid.forEachTileCoord(
getIntersection([-180, -90, 180, 90], view.calculateExtent()),
tileGrid.getZForResolution(view.getResolution()),
([z, x, y]) => useLayer(z, x, y)
);
});

View File

@@ -163,7 +163,6 @@ class BaseLayer extends BaseObject {
});
const zIndex = this.getZIndex();
state.opacity = clamp(Math.round(this.getOpacity() * 100) / 100, 0, 1);
state.sourceState = this.getSourceState();
state.visible = this.getVisible();
state.extent = this.getExtent();
state.zIndex = zIndex === undefined && !state.managed ? Infinity : zIndex;

View File

@@ -57,7 +57,7 @@ import {listen, unlistenByKey} from '../events.js';
* @typedef {Object} State
* @property {import("./Layer.js").default} layer Layer.
* @property {number} opacity Opacity, the value is rounded to two digits to appear after the decimal point.
* @property {import("../source/State.js").default} sourceState SourceState.
* @property {import("../source/Source.js").default|undefined} source Source being rendered (only for multi-source layers).
* @property {boolean} visible Visible.
* @property {boolean} managed Managed.
* @property {import("../extent.js").Extent} [extent] Extent.
@@ -196,6 +196,13 @@ class Layer extends BaseLayer {
return /** @type {SourceType} */ (this.get(LayerProperty.SOURCE)) || null;
}
/**
* @return {SourceType} The source being rendered.
*/
getRenderSource() {
return this.getSource();
}
/**
* @return {import("../source/State.js").default} Source state.
*/

View File

@@ -3,6 +3,7 @@
*/
import BaseTileLayer from './BaseTile.js';
import LayerProperty from '../layer/Property.js';
import SourceState from '../source/State.js';
import WebGLTileLayerRenderer, {
Attributes,
Uniforms,
@@ -64,6 +65,11 @@ import {assign} from '../obj.js';
* @property {number} [preload=0] Preload. Load low-resolution tiles up to `preload` levels. `0`
* means no preloading.
* @property {SourceType} [source] Source for this layer.
* @property {Array<SourceType>|function(import("../extent.js").Extent, number):Array<SourceType>} [sources] Array
* of sources for this layer. Takes precedence over `source`. Can either be an array of sources, or a function that
* expects an extent and a resolution (in view projection units per pixel) and returns an array of sources. See
* {@link module:ol/source.sourcesFromTileGrid} for a helper function to generate sources that are organized in a
* pyramid following the same pattern as a tile grid.
* @property {import("../PluggableMap.js").default} [map] Sets the layer as overlay on a map. The map will not manage
* this layer in its layers collection, and the layer will be rendered on top. This is useful for
* temporary layers. The standard way to add a layer to a map and have it managed by the map is to
@@ -291,6 +297,18 @@ class WebGLTileLayer extends BaseTileLayer {
super(options);
/**
* @type {Array<SourceType>|function(import("../extent.js").Extent, number):Array<SourceType>}
* @private
*/
this.sources_ = options.sources;
/**
* @type {number}
* @private
*/
this.renderedResolution_ = NaN;
/**
* @type {Style}
* @private
@@ -312,6 +330,41 @@ class WebGLTileLayer extends BaseTileLayer {
this.addChangeListener(LayerProperty.SOURCE, this.handleSourceUpdate_);
}
/**
* Gets the sources for this layer, for a given extent and resolution.
* @param {import("../extent.js").Extent} extent Extent.
* @param {number} resolution Resolution.
* @return {Array<SourceType>} Sources.
*/
getSources(extent, resolution) {
const source = this.getSource();
return this.sources_
? typeof this.sources_ === 'function'
? this.sources_(extent, resolution)
: this.sources_
: source
? [source]
: [];
}
/**
* @return {SourceType} The source being rendered.
*/
getRenderSource() {
return (
/** @type {SourceType} */ (this.getLayerState().source) ||
this.getSource()
);
}
/**
* @return {import("../source/State.js").default} Source state.
*/
getSourceState() {
const source = this.getRenderSource();
return source ? source.getState() : SourceState.UNDEFINED;
}
/**
* @private
*/
@@ -340,6 +393,66 @@ class WebGLTileLayer extends BaseTileLayer {
});
}
/**
* @param {import("../PluggableMap").FrameState} frameState Frame state.
* @param {Array<SourceType>} sources Sources.
* @return {HTMLElement} Canvas.
*/
renderSources(frameState, sources) {
const layerRenderer = this.getRenderer();
let canvas;
for (let i = 0, ii = sources.length; i < ii; ++i) {
this.getLayerState().source = sources[i];
if (layerRenderer.prepareFrame(frameState)) {
canvas = layerRenderer.renderFrame(frameState);
}
}
return canvas;
}
/**
* @param {?import("../PluggableMap.js").FrameState} frameState Frame state.
* @param {HTMLElement} target Target which the renderer may (but need not) use
* for rendering its content.
* @return {HTMLElement} The rendered element.
*/
render(frameState, target) {
const viewState = frameState.viewState;
const sources = this.getSources(frameState.extent, viewState.resolution);
let ready = true;
for (let i = 0, ii = sources.length; i < ii; ++i) {
const source = sources[i];
const sourceState = source.getState();
if (sourceState == SourceState.LOADING) {
const onChange = () => {
if (source.getState() == SourceState.READY) {
source.removeEventListener('change', onChange);
this.changed();
}
};
source.addEventListener('change', onChange);
}
ready = ready && sourceState == SourceState.READY;
}
const canvas = this.renderSources(frameState, sources);
if (this.getRenderer().renderComplete && ready) {
// Fully rendered, done.
this.renderedResolution_ = viewState.resolution;
return canvas;
}
// Render sources from previously fully rendered frames
if (this.renderedResolution_ > 0.5 * viewState.resolution) {
const altSources = this.getSources(
frameState.extent,
this.renderedResolution_
).filter((source) => !sources.includes(source));
if (altSources.length > 0) {
return this.renderSources(frameState, altSources);
}
}
return canvas;
}
/**
* Update the layer style. The `updateStyleVariables` function is a more efficient
* way to update layer rendering. In cases where the whole style needs to be updated,

View File

@@ -110,15 +110,17 @@ class CompositeMapRenderer extends MapRenderer {
for (let i = 0, ii = layerStatesArray.length; i < ii; ++i) {
const layerState = layerStatesArray[i];
frameState.layerIndex = i;
const layer = layerState.layer;
const sourceState = layer.getSourceState();
if (
!inView(layerState, viewState) ||
(layerState.sourceState != SourceState.READY &&
layerState.sourceState != SourceState.UNDEFINED)
(sourceState != SourceState.READY &&
sourceState != SourceState.UNDEFINED)
) {
continue;
}
const layer = layerState.layer;
const element = layer.render(frameState, previousElement);
if (!element) {
continue;

View File

@@ -161,7 +161,7 @@ class WebGLLayerRenderer extends LayerRenderer {
* @return {boolean} Layer is ready to be rendered.
*/
prepareFrame(frameState) {
if (this.getLayer().getSource()) {
if (this.getLayer().getRenderSource()) {
let incrementGroup = true;
let groupNumber = -1;
let className;

View File

@@ -20,7 +20,6 @@ import {
} from '../../vec/mat4.js';
import {
createOrUpdate as createTileCoord,
getKeyZXY,
getKey as getTileCoordKey,
} from '../../tilecoord.js';
import {fromUserExtent} from '../../proj.js';
@@ -98,7 +97,7 @@ function getRenderExtent(frameState, extent) {
);
}
const source =
/** {import("../../source/Tile.js").default} */ layerState.layer.getSource();
/** {import("../../source/Tile.js").default} */ layerState.layer.getRenderSource();
if (!source.getWrapX()) {
const gridExtent = source
.getTileGridForProjection(frameState.viewState.projection)
@@ -110,6 +109,10 @@ function getRenderExtent(frameState, extent) {
return extent;
}
function getCacheKey(source, tileCoord) {
return `${source.getKey()},${getTileCoordKey(tileCoord)}`;
}
/**
* @typedef {Object} Options
* @property {string} vertexShader Vertex shader source.
@@ -140,6 +143,12 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
uniforms: options.uniforms,
});
/**
* The last call to `renderFrame` was completed with all tiles loaded
* @type {boolean}
*/
this.renderComplete = false;
/**
* This transform converts tile i, j coordinates to screen coordinates.
* @type {import("../../transform.js").Transform}
@@ -273,7 +282,7 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
*/
prepareFrameInternal(frameState) {
const layer = this.getLayer();
const source = layer.getSource();
const source = layer.getRenderSource();
if (!source) {
return false;
}
@@ -293,7 +302,7 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
enqueueTiles(frameState, extent, z, tileTexturesByZ) {
const viewState = frameState.viewState;
const tileLayer = this.getLayer();
const tileSource = tileLayer.getSource();
const tileSource = tileLayer.getRenderSource();
const tileGrid = tileSource.getTileGridForProjection(viewState.projection);
const tileTextureCache = this.tileTextureCache_;
const tileRange = tileGrid.getTileRangeForExtentAndZ(
@@ -314,7 +323,7 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
for (let x = tileRange.minX; x <= tileRange.maxX; ++x) {
for (let y = tileRange.minY; y <= tileRange.maxY; ++y) {
const tileCoord = createTileCoord(z, x, y, this.tempTileCoord_);
const tileCoordKey = getTileCoordKey(tileCoord);
const cacheKey = getCacheKey(tileSource, tileCoord);
/**
* @type {TileTexture}
@@ -326,8 +335,8 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
*/
let tile;
if (tileTextureCache.containsKey(tileCoordKey)) {
tileTexture = tileTextureCache.get(tileCoordKey);
if (tileTextureCache.containsKey(cacheKey)) {
tileTexture = tileTextureCache.get(cacheKey);
tile = tileTexture.tile;
}
if (!tileTexture || tileTexture.tile.key !== tileSource.getKey()) {
@@ -340,7 +349,7 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
);
if (!tileTexture) {
tileTexture = new TileTexture(tile, tileGrid, this.helper);
tileTextureCache.set(tileCoordKey, tileTexture);
tileTextureCache.set(cacheKey, tileTexture);
} else {
if (this.isDrawableTile_(tile)) {
tileTexture.setTile(tile);
@@ -379,12 +388,13 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
* @return {HTMLElement} The rendered element.
*/
renderFrame(frameState) {
this.renderComplete = true;
const gl = this.helper.getGL();
this.preRender(gl, frameState);
const viewState = frameState.viewState;
const tileLayer = this.getLayer();
const tileSource = tileLayer.getSource();
const tileSource = tileLayer.getRenderSource();
const tileGrid = tileSource.getTileGridForProjection(viewState.projection);
const extent = getRenderExtent(frameState, frameState.extent);
const z = tileGrid.getZForResolution(
@@ -438,6 +448,7 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
const tileCoordKey = getTileCoordKey(tileCoord);
alphaLookup[tileCoordKey] = alpha;
}
this.renderComplete = false;
// first look for child tiles (at z + 1)
const coveredByChildren = this.findAltTiles_(
@@ -635,9 +646,10 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
let covered = true;
const tileTextureCache = this.tileTextureCache_;
const source = this.getLayer().getRenderSource();
for (let x = tileRange.minX; x <= tileRange.maxX; ++x) {
for (let y = tileRange.minY; y <= tileRange.maxY; ++y) {
const cacheKey = getKeyZXY(altZ, x, y);
const cacheKey = getCacheKey(source, [altZ, x, y]);
let loaded = false;
if (tileTextureCache.containsKey(cacheKey)) {
const tileTexture = tileTextureCache.get(cacheKey);

View File

@@ -2,6 +2,9 @@
* @module ol/source
*/
import LRUCache from './structs/LRUCache.js';
import {getIntersection} from './extent.js';
export {default as BingMaps} from './source/BingMaps.js';
export {default as CartoDB} from './source/CartoDB.js';
export {default as Cluster} from './source/Cluster.js';
@@ -31,3 +34,35 @@ export {default as VectorTile} from './source/VectorTile.js';
export {default as WMTS} from './source/WMTS.js';
export {default as XYZ} from './source/XYZ.js';
export {default as Zoomify} from './source/Zoomify.js';
/**
* Creates a sources function from a tile grid. This function can be used as value for the
* `sources` property of the {@link module:ol/layer/Layer~Layer} subclasses that support it.
* @param {import("./tilegrid/TileGrid.js").default} tileGrid Tile grid.
* @param {function(import("./tilecoord.js").TileCoord): import("./source/Source.js").default} factory Source factory.
* This function takes a {@link module:ol/tilecoord~TileCoord} as argument and is expected to return a
* {@link module:ol/source/Source~Source}.
* @return {function(import("./extent.js").Extent, number): Array<import("./source/Source.js").default>} Sources function.
* @api
*/
export function sourcesFromTileGrid(tileGrid, factory) {
const sourceCache = new LRUCache(32);
const tileGridExtent = tileGrid.getExtent();
return function (extent, resolution) {
sourceCache.expireCache();
if (tileGridExtent) {
extent = getIntersection(tileGridExtent, extent);
}
const z = tileGrid.getZForResolution(resolution);
const wantedSources = [];
tileGrid.forEachTileCoord(extent, z, (tileCoord) => {
const key = tileCoord.toString();
if (!sourceCache.containsKey(key)) {
const source = factory(tileCoord);
sourceCache.set(key, source);
}
wantedSources.push(sourceCache.get(key));
});
return wantedSources;
};
}

View File

@@ -66,6 +66,16 @@ class LRUCache {
return this.highWaterMark > 0 && this.getCount() > this.highWaterMark;
}
/**
* Expire the cache.
* @param {!Object<string, boolean>} [keep] Keys to keep. To be implemented by subclasses.
*/
expireCache(keep) {
while (this.canExpireCache()) {
this.pop();
}
}
/**
* FIXME empty description for jsdoc
*/

View File

@@ -44,7 +44,6 @@ describe('ol/layer/Group', function () {
opacity: 1,
visible: true,
managed: true,
sourceState: 'ready',
extent: undefined,
zIndex: undefined,
maxResolution: Infinity,
@@ -161,7 +160,6 @@ describe('ol/layer/Group', function () {
opacity: 0.5,
visible: false,
managed: true,
sourceState: 'ready',
extent: undefined,
zIndex: 10,
maxResolution: 500,
@@ -203,7 +201,6 @@ describe('ol/layer/Group', function () {
opacity: 0.5,
visible: false,
managed: true,
sourceState: 'ready',
extent: groupExtent,
zIndex: undefined,
maxResolution: 500,
@@ -399,7 +396,6 @@ describe('ol/layer/Group', function () {
opacity: 0.3,
visible: false,
managed: true,
sourceState: 'ready',
extent: groupExtent,
zIndex: 10,
maxResolution: 500,
@@ -417,7 +413,6 @@ describe('ol/layer/Group', function () {
opacity: 0,
visible: false,
managed: true,
sourceState: 'ready',
extent: undefined,
zIndex: undefined,
maxResolution: Infinity,
@@ -433,7 +428,6 @@ describe('ol/layer/Group', function () {
opacity: 1,
visible: true,
managed: true,
sourceState: 'ready',
extent: undefined,
zIndex: undefined,
maxResolution: Infinity,
@@ -599,7 +593,6 @@ describe('ol/layer/Group', function () {
opacity: 0.25,
visible: false,
managed: true,
sourceState: 'ready',
extent: undefined,
zIndex: undefined,
maxResolution: 150,

View File

@@ -56,7 +56,6 @@ describe('ol/layer/Layer', function () {
opacity: 1,
visible: true,
managed: true,
sourceState: 'ready',
extent: undefined,
zIndex: undefined,
maxResolution: Infinity,
@@ -95,7 +94,6 @@ describe('ol/layer/Layer', function () {
opacity: 0.5,
visible: false,
managed: true,
sourceState: 'ready',
extent: undefined,
zIndex: 10,
maxResolution: 500,
@@ -430,7 +428,6 @@ describe('ol/layer/Layer', function () {
opacity: 0.33,
visible: false,
managed: true,
sourceState: 'ready',
extent: undefined,
zIndex: 10,
maxResolution: 500,

View File

@@ -373,4 +373,37 @@ describe('ol/layer/WebGLTile', function () {
done();
});
});
it('handles multiple sources correctly', () => {
const source = layer.getSource();
expect(layer.getRenderSource()).to.be(source);
layer.sources_ = (extent, resolution) => {
return [
{
getState: () => 'ready',
extent,
resolution,
id: 'source1',
},
{
getState: () => 'ready',
extent,
resolution,
id: 'source2',
},
];
};
const sourceIds = [];
layer.getRenderer().prepareFrame = (frameState) => {
const renderedSource = layer.getRenderSource();
expect(renderedSource.extent).to.eql([0, 0, 100, 100]);
expect(renderedSource.resolution).to.be(1);
sourceIds.push(renderedSource.id);
};
layer.render({
extent: [0, 0, 100, 100],
viewState: {resolution: 1},
});
expect(sourceIds).to.eql(['source1', 'source2']);
});
});

View File

@@ -0,0 +1,41 @@
import TileGrid from '../../../../src/ol/tilegrid/TileGrid.js';
import XYZ from '../../../../src/ol/source/XYZ.js';
import {createXYZ} from '../../../../src/ol/tilegrid.js';
import {get} from '../../../../src/ol/proj.js';
import {sourcesFromTileGrid} from '../../../../src/ol/source.js';
describe('ol/source', function () {
describe('sourcesFromTileGrid()', function () {
it('returns a function that returns the correct source', function () {
const resolutions = createXYZ({maxZoom: 1}).getResolutions();
const tileGrid = new TileGrid({
extent: get('EPSG:3857').getExtent(),
resolutions: [resolutions[1]],
tileSizes: [[256, 512]],
});
const factory = function (tileCoord) {
return new XYZ({
url: tileCoord.join('-') + '/{z}/{x}/{y}.png',
tileGrid: new TileGrid({
resolutions,
minZoom: tileCoord[0],
maxZoom: tileCoord[0] + 1,
extent: tileGrid.getTileCoordExtent(tileCoord),
origin: [-20037508.342789244, 20037508.342789244],
}),
});
};
const getSources = sourcesFromTileGrid(tileGrid, factory);
expect(getSources(tileGrid.getExtent(), resolutions[1]).length).to.be(2);
expect(
getSources(
[-10000, -10000, -5000, 10000],
resolutions[1]
)[0].getUrls()[0]
).to.be('0-0-0/{z}/{x}/{y}.png');
expect(
getSources([5000, -10000, 10000, 10000], resolutions[1])[0].getUrls()[0]
).to.be('0-1-0/{z}/{x}/{y}.png');
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,58 @@
import Map from '../../../../src/ol/Map.js';
import TileGrid from '../../../../src/ol/tilegrid/TileGrid.js';
import TileLayer from '../../../../src/ol/layer/WebGLTile.js';
import View from '../../../../src/ol/View.js';
import XYZ from '../../../../src/ol/source/XYZ.js';
import {createXYZ} from '../../../../src/ol/tilegrid.js';
import {get} from '../../../../src/ol/proj.js';
import {sourcesFromTileGrid} from '../../../../src/ol/source.js';
const resolutions = createXYZ({maxZoom: 1}).getResolutions();
const tilePyramid = new TileGrid({
extent: get('EPSG:3857').getExtent(),
resolutions: [resolutions[1]],
tileSizes: [[256, 512]],
});
new Map({
target: 'map',
layers: [
new TileLayer({
sources: sourcesFromTileGrid(tilePyramid, (tileCoord) => {
let source;
switch (tileCoord.toString()) {
case '0,1,0':
source = new XYZ({
url: '/data/tiles/osm/{z}/{x}/{y}.png',
tileGrid: new TileGrid({
resolutions,
minZoom: tileCoord[0],
maxZoom: tileCoord[0] + 1,
extent: tilePyramid.getTileCoordExtent(tileCoord),
origin: [-20037508.342789244, 20037508.342789244],
}),
});
break;
default:
source = new XYZ({
url: '/data/tiles/satellite/{z}/{x}/{y}.jpg',
tileGrid: new TileGrid({
resolutions,
minZoom: tileCoord[0],
maxZoom: tileCoord[0] + 1,
extent: tilePyramid.getTileCoordExtent(tileCoord),
origin: [-20037508.342789244, 20037508.342789244],
}),
});
}
return source;
}),
}),
],
view: new View({
center: [0, 0],
zoom: 1,
}),
});
render();

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB