Skip to content

Commit

Permalink
improve the event handling performance (#165)
Browse files Browse the repository at this point in the history
* reformat the changelog

* feature event testing framework

* make the addFeature test an end-to-end test

* more end-to-end testing

* more end-to-end testing

* implement true global layer handlers

* clean up dead code

* move the RStyle sub-tree out of the main hierarchy

* implement event counting for layers

* update the doc

* openlayers 6 compatibility

* add a pointermove bypass

* more OL6 compatibility

* separate drag event from pointermove events

* improve typings

* remove dead code

* end-to-end testing of popups

* add the rlayer components to the context

* support installing handlers via a method

* avoid a warning for the deprecated constructor form

* forgo the safety

* adapt RPopup to the new events

* switch handlers to a getter

* use object encapsulation

* add doc

* additional roverlay options

* this needs to be protected

* add a note

* prefer the generics form

* add hooks

* openlayers 6 compatibility

* merge main
  • Loading branch information
mmomtchev committed Jun 30, 2023
1 parent d0b6d21 commit ece6f35
Show file tree
Hide file tree
Showing 86 changed files with 802 additions and 460 deletions.
14 changes: 12 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,15 @@
"react": {
"version": "detect"
}
}
}
},
"overrides": [
{
"files": [
"test/*.tsx"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
}
]
}
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

# [2.0.0]

- Vastly improved event handling performance avoiding expensive `forEachFeatureAtPixel` on layers that do not have event handlers
- Add the `useOL()` and `useRLayersComponent()` component hooks allowing to easily access the containing OpenLayers and _rlayers_ components
- Layer event handlers are now independent of feature event handlers, if both the feature and its containing layer have declared an event handler, both will be called
- Fix `onClick` handlers on `RLayerVectorTiles` layers
- Use TypeScript `protected` and `private` to restrict methods that are not expected to be directly used from user code
- Support all positioning options in `ROverlay`

### [1.5.3]

- Add `onFeaturesLoadStart` and `onFeaturesLoadError` events to all vector layers
Expand All @@ -18,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Declare `RLayerRasterMBTiles` and `RLayerVectorMBTiles` as public exports

### [1.5.0] 2023-05-30
## [1.5.0] 2023-05-30

- Add remote `.mbtiles` support with [`ol-mbtiles`](https://github.com/mmomtchev/ol-mbtiles)
- Add new _rlayers_-specific events to `RLayerWMTS` and `RLayerRasterMBTiles` to distinguish them from the OpenLayers `onSourceReady` events
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,20 +134,25 @@ You can also check the GPLed [XC-DB](https://github.com/mmomtchev/xc-db.git) for

Composition works by using _React_ Contexts. Every nested element uses the context of its nearest parent.

Currently a context has an `RContextType` and can contain the following elements:
The underlying OpenLayers objects can be accessed using the `useOL()` hook - check the [`Geolocation`](https://mmomtchev.github.io/rlayers/#/geolocation) example to see how.

- `RContext.map` provided by a map, every other element, except an `RStyle` must have a map parent
- `RContext.layer` and `RContext.source` provided by all layers - not required for anything at the moment, but can be used to access the underlying _OpenLayers_ objects
- `RContext.vectorlayer` and `RContext.vectorsource` provided by vector layers only - required for `<RFeature>`
- `RContext.location` and `RContext.feature` provided by a map feature - required for `<ROverlay>` and `<RPopup>`
- `RContext.style` provided by a style definition - the only one which can be outside of a map
Currently `useOL()` has an `RContextType` and can contain the following elements:

- `map` provided by a map, every other element, except an `RStyle` must have a map parent
- `layer` and `source` provided by all layers - not required for anything at the moment, but can be used to access the underlying _OpenLayers_ objects
- `vectorlayer` and `vectorsource` provided by vector layers only - required for `<RFeature>`
- `vectortilelayer` provided by vector tile layers only
- `location` and `feature` provided by a map feature - required for `<ROverlay>` and `<RPopup>`
- `style` provided by a style definition - the only one which can be outside of a map

Additionally, `useRLayersComponent()` allows retrieving the containing _rlayers_ component.

#### Accessing the underlying _OpenLayers_ objects and API

The underlying _OpenLayers_ objects can be accessed in a number of different ways:

- Through the context objects by using `React.Context.Consumer` - [the custom controls example](https://mmomtchev.github.io/rlayers/#/controls) contains an example for using the _OpenLayers_ `map` from `RContext`
- In an event handler, when it is a normal function and not an arrow lambda, `this` will contain the _rlayers_ component and `this.context` will contain the context - [the geolocation example](https://mmomtchev.github.io/rlayers/#/geolocation) accesses `this.context.map` to adjust the view
- Through the context objects by using `React.Context.Consumer`
- In an event handler that is a normal function and not an arrow lambda, `this` will contain the _rlayers_ component and `this.context` will contain the context - this is an alternative to using `useOL()`
- In all event handlers, _OpenLayers_ will pass the target object in `event.target` and the map in `event.map` - [the popups example](https://mmomtchev.github.io/rlayers/#/popups) uses this method
- And finally, accessing arbitrary elements, even outside their contexts, is possible by using React references - `React.RefObject`s. [The high performance example](https://mmomtchev.github.io/rlayers/#/igc) contains an example of this. The underlying _OpenLayers_ objects can be accessed through the `ol` property of every component. Additionaly, for `layer` objects, the underlying _OpenLayers_ source can be accessed through `source`:
```ts
Expand Down
2 changes: 1 addition & 1 deletion examples/Addon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class MyLayerMapbox extends RLayer<MyLayerMapboxProps> {
// Tiled layers must extend RLayerRaster, non-tiled vector layers must extend RLayerVector
// This allows you to have the same features as RLayers built-in components
// Completely custom layers must extend RLayer
constructor(props: Readonly<MyLayerMapboxProps>, context: React.Context<RContextType>) {
constructor(props: Readonly<MyLayerMapboxProps>, context?: React.Context<RContextType>) {
// You must call the parent constructor
super(props, context);

Expand Down
2 changes: 1 addition & 1 deletion examples/Controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {fromLonLat, toLonLat} from 'ol/proj';
import 'ol/ol.css';
import 'rlayers/control/layers.css';

import {RMap, RContext, ROSM, RControl} from 'rlayers';
import {RMap, ROSM, RControl} from 'rlayers';
import {RView} from 'rlayers/RMap';

const origin = [2.364, 48.82];
Expand Down
3 changes: 2 additions & 1 deletion examples/GeoTIFF.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class RLayerGeoTIFF extends RLayer<RLayerGeoTIFFProps> {
ol: LayerTile;
source: GeoTIFF;

constructor(props: Readonly<RLayerGeoTIFFProps>, context: React.Context<RContextType>) {
constructor(props: Readonly<RLayerGeoTIFFProps>, context?: React.Context<RContextType>) {
super(props, context);
this.createSource();
this.ol = new LayerTile({source: this.source});
Expand Down Expand Up @@ -65,6 +65,7 @@ class RLayerGeoTIFF extends RLayer<RLayerGeoTIFFProps> {

this.createSource();
this.ol.setSource(this.source);
// Call this after replacing a source
this.attachOldEventHandlers(this.source);
}
}
Expand Down
43 changes: 27 additions & 16 deletions examples/Geolocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,33 @@ import {Geometry, Point} from 'ol/geom';
import {Geolocation as OLGeoLoc} from 'ol';
import 'ol/ol.css';

import {RMap, ROSM, RLayerVector, RFeature, RGeolocation, RStyle} from 'rlayers';
import {RMap, ROSM, RLayerVector, RFeature, RGeolocation, RStyle, useOL} from 'rlayers';
import locationIcon from './svg/location.svg';

export default function Geolocation(): JSX.Element {
function GeolocComp(): JSX.Element {
const [pos, setPos] = React.useState(new Point(fromLonLat([0, 0])));
const [accuracy, setAccuracy] = React.useState(undefined as Geometry | undefined);
// Low-level access to the OpenLayers API
const {map} = useOL();

return (
<RMap className='example-map' initial={{center: fromLonLat([0, 0]), zoom: 4}}>
<ROSM />
<>
<RGeolocation
tracking={true}
trackingOptions={{enableHighAccuracy: true}}
onChange={React.useCallback(function (e) {
// Note the use of function instead of an arrow lambda
// which does not have this
const geoloc = e.target as OLGeoLoc;
setPos(new Point(geoloc.getPosition()));
setAccuracy(geoloc.getAccuracyGeometry());
onChange={React.useCallback(
(e) => {
const geoloc = e.target as OLGeoLoc;
setPos(new Point(geoloc.getPosition()));
setAccuracy(geoloc.getAccuracyGeometry());

// Low-level access to the OpenLayers API
this.context.map.getView().fit(geoloc.getAccuracyGeometry(), {
duration: 250,
maxZoom: 15
});
}, [])}
map.getView().fit(geoloc.getAccuracyGeometry(), {
duration: 250,
maxZoom: 15
});
},
[map]
)}
/>
<RLayerVector zIndex={10}>
<RStyle.RStyle>
Expand All @@ -38,6 +40,15 @@ export default function Geolocation(): JSX.Element {
<RFeature geometry={pos}></RFeature>
<RFeature geometry={accuracy}></RFeature>
</RLayerVector>
</>
);
}

export default function Geolocation(): JSX.Element {
return (
<RMap className='example-map' initial={{center: fromLonLat([0, 0]), zoom: 4}}>
<ROSM />
<GeolocComp />
</RMap>
);
}
13 changes: 7 additions & 6 deletions examples/IGC.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {fromLonLat} from 'ol/proj';
import IGC from 'ol/format/IGC';
import {getVectorContext} from 'ol/render';
import {Geometry, LineString, Point} from 'ol/geom';
import {Coordinate} from 'ol/coordinate';

import {
RMap,
Expand Down Expand Up @@ -52,10 +53,10 @@ const origin = fromLonLat([6, 45.7]);
// This part is re-rendered on every pointermove
export default function IGCComp(): JSX.Element {
const [time, setTime] = React.useState('');
const [point, setPoint] = React.useState(null as Point);
const [line, setLine] = React.useState(null as LineString);
const [point, setPoint] = React.useState<Point>(null);
const [line, setLine] = React.useState<LineString>(null);
const [slider, setSlider] = React.useState(0);
const [highlights, setHighlights] = React.useState([]);
const [highlights, setHighlights] = React.useState<Coordinate[]>([]);
const [flight, setFlight] = React.useState({
start: Infinity,
stop: -Infinity,
Expand All @@ -71,12 +72,12 @@ export default function IGCComp(): JSX.Element {
blueCircle: useRStyle(),
// This is a technique for an array of React.RefObjects
// It is ugly but it works
flightPath: React.useRef([]) as React.RefObject<RStyle[]>
flightPath: React.useRef<RStyle[]>([])
};

// createRef instead of useRef here will severely impact performance
const igcVectorLayer = React.useRef() as React.RefObject<RLayerVector>;
const highlightVectorLayer = React.useRef() as React.RefObject<RLayerVector>;
const igcVectorLayer = React.useRef<RLayerVector>();
const highlightVectorLayer = React.useRef<RLayerVector>();

return (
<React.Fragment>
Expand Down
138 changes: 103 additions & 35 deletions src/REvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,57 +3,123 @@ import React from 'react';
import {RContext, RContextType} from './context';
import debug from './debug';

export const handlersSymbol = '_rlayers_handlers';
export type OLEvent = 'change';
export type Handler = (e: unknown) => boolean | void;
export type Handlers = Record<OLEvent, Handler>;

export class RlayersBase<P, S> extends React.PureComponent<P, S> {
static contextType = RContext;
context: RContextType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ol: any;
ol: BaseObject;
eventSources: BaseObject[];
handlers: Record<string, (e: unknown) => boolean | void>;

// 'change' is available on all objects
olEventName(ev: string): 'change' {
return ev.substring(2).toLowerCase() as 'change';
protected static getOLObject<T>(prop: string, ol: BaseObject) {
let handlers = ol.get(prop);
if (handlers === undefined) {
handlers = {};
ol.set(prop, handlers);
}
return handlers as T;
}

protected get handlers() {
return RlayersBase.getOLObject<Handlers>(handlersSymbol, this.ol);
}

/**
* Get the lowercase names of the currently installed handlers
*/
protected getCurrentEvents() {
return Object.keys(this.props)
.filter((p) => p.startsWith('on'))
.map((ev) => ({event: ev.toLowerCase().slice(2), prop: ev}))
.reduce((a, x) => ({...a, [x.event]: this.props[x.prop]}), {}) as Handlers;
}

/**
* Get the uppercase name of this event
*/
protected getHandlerProp(event: OLEvent): string | void {
for (const p of Object.keys(this.props)) if (p.toLowerCase() === 'on' + event) return p;
}

attachEventHandlers(): void {
protected incrementHandlers(ev: OLEvent): void {
return;
}
protected decrementHandlers(ev: OLEvent): void {
return;
}

protected attachEventHandlers(): void {
const handlers = this.handlers;
const handlersList = Object.keys(handlers ?? {});
const eventSources = this.eventSources ?? [this.ol];
const newEvents = Object.keys(this.props).filter((p) => p.startsWith('on'));
const eventsToCheck = newEvents.concat(
Object.keys(this.handlers ?? {}).filter((ev) => !newEvents.includes(ev))
);
const newEvents = this.getCurrentEvents();
const newEventsList = Object.keys(newEvents);
const eventsToCheck = newEventsList.concat(
handlersList.filter((ev) => !newEventsList.includes(ev))
) as OLEvent[];
for (const p of eventsToCheck) {
if (this.handlers === undefined) this.handlers = {};
if (this.handlers[p] !== undefined && this.props[p] === undefined) {
debug('removing previously installed handler', this, p, this.handlers[p]);
for (const source of eventSources) source.un(this.olEventName(p), this.handlers[p]);
this.handlers[p] = undefined;
if (handlers[p] !== undefined && newEvents[p] === undefined) {
debug('removing previously installed handler', this, p, handlers[p]);
for (const source of eventSources) source.un(p, handlers[p]);
handlers[p] = undefined;
this.decrementHandlers(p);
}
if (this.handlers[p] === undefined && this.props[p] !== undefined) {
debug('installing handler', this, p, this.props[p]);
this.handlers[p] = (e: unknown) => this.props[p].call(this, e);
for (const source of eventSources) source.on(this.olEventName(p), this.handlers[p]);
if (handlers[p] === undefined && newEvents[p] !== undefined) {
debug('installing handler', this, p, newEvents[p]);
const prop = this.getHandlerProp(p);
if (!prop) throw new Error('Internal error');
handlers[p] = (e: unknown) => this.props[prop].call(this, e);
for (const source of eventSources) source.on(p as OLEvent, handlers[p]);
this.incrementHandlers(p);
}
}
}

// Used when replacing a source
attachOldEventHandlers(newSource: BaseObject): void {
// No events have been attached yet
if (!this.handlers) return;
const events = Object.keys(this.props).filter((p) => p.startsWith('on'));
for (const e of events) {
if (this.props[e]) {
debug('reinstalling existing handler', this, e, this.props[e]);
newSource.on(this.olEventName(e), this.handlers[e]);
protected attachOldEventHandlers(newSource: BaseObject): void {
const handlers = this.handlers;
const events = this.getCurrentEvents();
for (const e of Object.keys(events)) {
if (events[e]) {
debug('reinstalling existing handler', this, e, events[e]);
newSource.on(e as OLEvent, handlers[e]);
}
}
}

refresh(prevProps?: P): void {
protected refresh(prevProps?: P): void {
this.attachEventHandlers();
}

/**
* Programmatically add an event handler to an RLayers component.
*
* @param {string} ev OpenLayers event
* @param {Handler} cb Callback
*/
on(ev: OLEvent, cb: Handler): void {
this.ol.on(ev, cb);
this.incrementHandlers(ev);
}

/**
* Programmatically add an event handler to an RLayers component.
*
* Although public, use of this method is discouraged as it lacks
* any safety against calling un on a method that has not been
* registered.
*
* @param {string} ev OpenLayers event
* @param {Handler} cb Callback
*/
un(ev: OLEvent, cb: Handler): void {
this.decrementHandlers(ev);
this.ol.un(ev, cb);
}

componentDidMount(): void {
debug('didMount', this);
this.refresh();
Expand Down Expand Up @@ -84,13 +150,15 @@ export class RlayersBase<P, S> extends React.PureComponent<P, S> {
}

componentWillUnmount(): void {
debug('willUnmount', this, this.handlers);
const handlers = this.handlers;
debug('willUnmount', this, handlers);
const eventSources = this.eventSources ?? [this.ol];
for (const h of Object.keys(this.handlers ?? {})) {
debug('cleaning up handler', this, h, this.handlers[h]);
if (this.handlers[h]) {
for (const source of eventSources) source.un(this.olEventName(h), this.handlers[h]);
this.handlers[h] = undefined;
for (const h of Object.keys(handlers ?? {}) as OLEvent[]) {
debug('cleaning up handler', this, h, handlers[h]);
if (handlers[h]) {
for (const source of eventSources) source.un(h, handlers[h]);
handlers[h] = undefined;
this.decrementHandlers(h);
}
}
}
Expand Down
Loading

0 comments on commit ece6f35

Please sign in to comment.