From 83a9e53f20fdc6d16c72aa30f4106cbfd832b823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20de=20Metz?= Date: Sun, 14 Nov 2021 16:48:39 +0100 Subject: [PATCH] Add heatmap layer. --- README.md | 11 +++++++ package.json | 1 + rollup.config.js | 3 ++ src/index.js | 2 +- src/indoorequal.js | 61 +++++++++++++++++++++++--------------- src/layer.js | 44 +++++++++++++++++++++++---- test/indoorequal.test.js | 37 ++++++++++------------- test/level_control.test.js | 2 +- test/setup.js | 1 + 9 files changed, 109 insertions(+), 53 deletions(-) create mode 100644 test/setup.js diff --git a/README.md b/README.md index 8f541bc..0e799d9 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ const indoorequal = new IndoorEqual(map, { apiKey: 'myKey', spriteBaseUrl: '/ind * [Parameters](#parameters-1) * [setStyle](#setstyle) * [Parameters](#parameters-2) + * [setHeatmapVisible](#setheatmapvisible) + * [Parameters](#parameters-3) * [IndoorEqual#change:levels](#indoorequalchangelevels) * [IndoorEqual#levelchange](#indoorequallevelchange) @@ -150,6 +152,7 @@ Load the indoor= source and layers in your map. * `options.spriteBaseUrl` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)?** The base url of the sprite (without .json or .png). If not set, no sprite will be used in the default style. * `options.url` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)?** Override the default tiles URL (). * `options.apiKey` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)?** The API key if you use the default tile URL (get your free key at [indoorequal.com](https://indoorequal.com)). + * `options.heatmap` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)?** Should the heatmap layer be visible at start (true : visible, false : hidden). Defaults to true/visible. Returns **[IndoorEqual](#indoorequal)** `this` @@ -161,6 +164,14 @@ Set the style for displayed features. This function takes a feature and resoluti * `styleFunction` **[function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** the style function +#### setHeatmapVisible + +Change the heatmap layer visibility + +##### Parameters + +* `visible` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** True to make it visible, false to hide it + ### IndoorEqual#change:levels Emitted when the list of available levels has been updated diff --git a/package.json b/package.json index 9ff4193..fcb83d4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "jest" }, "jest": { + "setupFiles": ["./test/setup.js"], "testEnvironment": "jsdom", "transformIgnorePatterns": [ "node_modules/(?!(ol)/)" diff --git a/rollup.config.js b/rollup.config.js index 477d417..1399790 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -32,9 +32,12 @@ export default [ 'ol/Object', 'ol/control', 'ol/format/MVT', + 'ol/layer/Heatmap', 'ol/layer/VectorTile', + 'ol/loadingstrategy', 'ol/proj', 'ol/source/TileJSON', + 'ol/source/Vector', 'ol/source/VectorTile', 'ol/style', 'ol/tilegrid/TileGrid', diff --git a/src/index.js b/src/index.js index 93fbcb0..744a63a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -export { getLayer, loadSourceFromTileJSON } from './layer'; +export { getLayer, getHeatmapLayer, loadSourceFromTileJSON } from './layer'; export LevelControl from './level_control'; export defaultStyle from './defaultstyle'; export default from './indoorequal'; diff --git a/src/indoorequal.js b/src/indoorequal.js index 1a01fe0..436aaab 100644 --- a/src/indoorequal.js +++ b/src/indoorequal.js @@ -1,7 +1,7 @@ import BaseObject from 'ol/Object'; import debounce from 'debounce'; -import { loadSourceFromTileJSON, getLayer } from './layer'; +import { loadSourceFromTileJSON, getLayer, getHeatmapLayer, createHeatmapSource } from './layer'; import findAllLevels from './levels'; import defaultStyle from './defaultstyle'; @@ -13,13 +13,14 @@ import defaultStyle from './defaultstyle'; * @param {string} [options.spriteBaseUrl] The base url of the sprite (without .json or .png). If not set, no sprite will be used in the default style. * @param {string} [options.url] Override the default tiles URL (https://tiles.indoorequal.org/). * @param {string} [options.apiKey] The API key if you use the default tile URL (get your free key at [indoorequal.com](https://indoorequal.com)). + * @param {boolean} [options.heatmap] Should the heatmap layer be visible at start (true : visible, false : hidden). Defaults to true/visible. * @fires change:levels * @fires change:level * @return {IndoorEqual} `this` */ export default class IndoorEqual extends BaseObject { constructor(map, options = {}) { - const defaultOpts = { url: 'https://tiles.indoorequal.org/', defaultStyle: true, spriteBaseUrl: null }; + const defaultOpts = { url: 'https://tiles.indoorequal.org/', defaultStyle: true, spriteBaseUrl: null, heatmap: true }; const opts = { ...defaultOpts, ...options }; if (opts.url === defaultOpts.url && !opts.apiKey) { throw 'You must register your apiKey at https://indoorequal.com before and set it as apiKey param.'; @@ -30,8 +31,9 @@ export default class IndoorEqual extends BaseObject { this.url = opts.url; this.apiKey = opts.apiKey; - this._addLayer(); - this.styleFunction = opts.defaultStyle ? defaultStyle(this.map, this.layer, opts.spriteBaseUrl) : null; + this._createLayers(opts.heatmap); + this._loadSource(); + this.styleFunction = opts.defaultStyle ? defaultStyle(this.map, this.indoorLayer, opts.spriteBaseUrl) : null; this._changeLayerOnLevelChange(); this._setLayerStyle(); this._resetLevelOnLevelsChange(); @@ -45,41 +47,52 @@ export default class IndoorEqual extends BaseObject { this.styleFunction = styleFunction; } - _addLayer() { + /** + * Change the heatmap layer visibility + * @param {boolean} visible True to make it visible, false to hide it + */ + setHeatmapVisible(visible) { + this.heatmapLayer.setVisible(visible); + } + + async _loadSource() { const urlParams = this.apiKey ? `?key=${this.apiKey}` : ''; - this.layer = getLayer(); - this.map.addLayer(this.layer); - loadSourceFromTileJSON(`${this.url}${urlParams}`).then((source) => { - this.source = source; - this.layer.setSource(source); - this.layer.setVisible(true); - }); + this.source = await loadSourceFromTileJSON(`${this.url}${urlParams}`); + + this.indoorLayer.setSource(this.source); + this.heatmapLayer.setSource(createHeatmapSource(this.source)); this._listenForLevels(); } + _createLayers(heatmapVisible) { + this.indoorLayer = getLayer(); + this.heatmapLayer = getHeatmapLayer({ visible: heatmapVisible }); + [this.indoorLayer, this.heatmapLayer].forEach((layer) => { + this.map.addLayer(layer); + }); + } + _listenForLevels() { - this.layer.on('change:source', () => { - const source = this.layer.getSource(); + const source = this.source; - const refreshLevels = debounce(() => { - const extent = this.map.getView().calculateExtent(this.map.getSize()); - const features = source.getFeaturesInExtent(extent); - this.set('levels', findAllLevels(features)); - }, 1000); + const refreshLevels = debounce(() => { + const extent = this.map.getView().calculateExtent(this.map.getSize()); + const features = source.getFeaturesInExtent(extent); + this.set('levels', findAllLevels(features)); + }, 1000); - source.on('tileloadend', refreshLevels); - this.map.getView().on('change:center', refreshLevels); - }); + source.on('tileloadend', refreshLevels); + this.map.getView().on('change:center', refreshLevels); } _changeLayerOnLevelChange() { this.on('change:level', () => { - this.layer.changed(); + this.indoorLayer.changed(); }); } _setLayerStyle() { - this.layer.setStyle((feature, resolution) => { + this.indoorLayer.setStyle((feature, resolution) => { if (feature.getProperties().level === this.get('level')) { return this.styleFunction && this.styleFunction(feature, resolution); } diff --git a/src/layer.js b/src/layer.js index 6d07f2d..964c655 100644 --- a/src/layer.js +++ b/src/layer.js @@ -1,12 +1,16 @@ +import Feature from 'ol/Feature'; +import HeatmapLayer from 'ol/layer/Heatmap'; +import MVT from 'ol/format/MVT'; +import TileGrid from 'ol/tilegrid/TileGrid'; +import TileJSON from 'ol/source/TileJSON'; +import VectorSource from 'ol/source/Vector'; import VectorTileLayer from 'ol/layer/VectorTile'; import VectorTileSource from 'ol/source/VectorTile'; -import TileJSON from 'ol/source/TileJSON'; -import MVT from 'ol/format/MVT'; import { fromLonLat } from 'ol/proj'; -import TileGrid from 'ol/tilegrid/TileGrid'; -import Feature from 'ol/Feature'; +import { tile } from 'ol/loadingstrategy'; const MIN_ZOOM_INDOOR = 17; +const MAX_ZOOM_HEATMAP = MIN_ZOOM_INDOOR; function extentFromTileJSON(tileJSON) { const bounds = tileJSON.bounds; @@ -73,8 +77,36 @@ export async function loadSourceFromTileJSON(url) { export function getLayer(options) { return new VectorTileLayer({ declutter: true, - visible: false, - minZoom: MIN_ZOOM_INDOOR, + ...options, + }); +} + +export function createHeatmapSource(source) { + const tilegrid = source.getTileGrid(); + const vectorSource = new VectorSource({ + loader(extent, resolution, projection, success, failure) { + const refresh = () => { + const features = source.getFeaturesInExtent(extent); + vectorSource.clear(true); + vectorSource.addFeatures(features); + success(features); + } + source.on('tileloadend', refresh); + refresh(); + }, + loadingstrategy: tile(tilegrid) + }); + return vectorSource; +} + +export function getHeatmapLayer(options) { + return new HeatmapLayer({ + maxZoom: MAX_ZOOM_HEATMAP, + gradient: [ + 'rgba(102, 103, 173, 0)', + 'rgba(102, 103, 173, 0.2)', + 'rgba(102, 103, 173, 0.7)' + ], ...options, }); } diff --git a/test/indoorequal.test.js b/test/indoorequal.test.js index 14fae35..7458fdd 100644 --- a/test/indoorequal.test.js +++ b/test/indoorequal.test.js @@ -1,7 +1,7 @@ jest.mock('../src/layer'); import Feature from 'ol/Feature'; -import IndoorEqual, { getLayer, loadSourceFromTileJSON } from '../src/'; +import IndoorEqual, { getLayer, getHeatmapLayer, loadSourceFromTileJSON } from '../src/'; describe('IndoorEqual', () => { it('get the indoorequal layer with the default url', () => { @@ -10,8 +10,7 @@ describe('IndoorEqual', () => { getLayer.mockReturnValueOnce(getLayerReturn); loadSourceFromTileJSON.mockReturnValueOnce(new Promise(() => {})); new IndoorEqual(map, { apiKey: 'test' }); - expect(loadSourceFromTileJSON -).toHaveBeenCalledWith('https://tiles.indoorequal.org/?key=test'); + expect(loadSourceFromTileJSON).toHaveBeenCalledWith('https://tiles.indoorequal.org/?key=test'); }); it('throws an error if the apiKey is not defined with the default tiles url', () => { @@ -29,18 +28,16 @@ describe('IndoorEqual', () => { expect(loadSourceFromTileJSON).toHaveBeenCalledWith('http://localhost:8090/'); }); - it('load and add the the indoorequal layer', () => { + it('load and add the the indoorequal layer and the heatmap layer', () => { const map = { addLayer: jest.fn() }; const getLayerReturn = { on: jest.fn(), setStyle: jest.fn() }; getLayer.mockReturnValueOnce(getLayerReturn); loadSourceFromTileJSON.mockReturnValueOnce(new Promise(() => {})); new IndoorEqual(map, { apiKey: 'test' }); - expect(map.addLayer.mock.calls.length).toEqual(1); + expect(map.addLayer.mock.calls.length).toEqual(2); }); it('expose the available levels', (done) => { - let tileLoadEndCallback; - const map = { addLayer: jest.fn(), getView: () => { @@ -51,33 +48,31 @@ describe('IndoorEqual', () => { }, getSize: jest.fn(), }; + const source = { + on: (_eventName, callback) => callback(), + getFeaturesInExtent: () => { + return [ + new Feature({ layer: 'area', level: 0 }), + new Feature({ layer: 'area', level: 1 }), + new Feature({ layer: 'area', level: -2 }), + ]; + } + } const getLayerReturn = { on: (_eventName, callback) => callback(), setStyle: jest.fn(), setSource: jest.fn(), setVisible: jest.fn(), - getSource: () => { - return { - on: (_eventName, callback) => tileLoadEndCallback = callback, - getFeaturesInExtent: () => { - return [ - new Feature({ layer: 'area', level: 0 }), - new Feature({ layer: 'area', level: 1 }), - new Feature({ layer: 'area', level: -2 }), - ]; - } - }; - } }; getLayer.mockReturnValueOnce(getLayerReturn); - loadSourceFromTileJSON.mockReturnValueOnce(Promise.resolve()); + getHeatmapLayer.mockReturnValueOnce(getLayerReturn); + loadSourceFromTileJSON.mockReturnValueOnce(Promise.resolve(source)); const indoorEqual = new IndoorEqual(map, { apiKey: 'test' }); expect(indoorEqual.get('levels')).toEqual([]); indoorEqual.on('change:levels', (levels) => { expect(indoorEqual.get('levels')).toEqual([1, 0, -2]); done(); }); - tileLoadEndCallback(); }); it('reset the level if the current one is not available', () => { diff --git a/test/level_control.test.js b/test/level_control.test.js index 0bf15bd..efae383 100644 --- a/test/level_control.test.js +++ b/test/level_control.test.js @@ -1,4 +1,4 @@ -import { LevelControl } from '../src'; +import LevelControl from '../src/level_control'; describe('LevelControl', () => { it('create a container', () => { diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..126439b --- /dev/null +++ b/test/setup.js @@ -0,0 +1 @@ +window.URL.createObjectURL = () => {}