Skip to content

Instantly share code, notes, and snippets.

@laurenchen0631
Created June 15, 2024 18:33
Show Gist options
  • Save laurenchen0631/2c0aeff205a2fc9c0cf64b5bdabd19d9 to your computer and use it in GitHub Desktop.
Save laurenchen0631/2c0aeff205a2fc9c0cf64b5bdabd19d9 to your computer and use it in GitHub Desktop.
Pixi.ks v8 + Leaflet v1.9 in typescript
import L, {
LeafletEvent,
LeafletEventHandlerFn,
LeafletMouseEvent,
ZoomAnimEvent,
} from "leaflet";
import {
AutoDetectOptions,
Container,
Renderer,
autoDetectRenderer,
} from "pixi.js";
interface PixiOverlayOptions extends L.LayerOptions {
padding: number;
forceCanvas: boolean;
doubleBuffering: boolean;
resolution: number;
projectionZoom: (map: L.Map) => number;
destroyInteractionManager: boolean;
autoPreventDefault: boolean;
preserveDrawingBuffer: boolean;
clearBeforeRender: boolean;
shouldRedrawOnMove: (e: LeafletMouseEvent) => boolean;
}
class PixiOverlay extends L.Layer {
options: PixiOverlayOptions = {
// How much to extend the clip area around the map view (relative to its size)
// e.g. 0.1 would be 10% of map view in each direction
padding: 0.1,
// Force use of a 2d-canvas
forceCanvas: false,
// Help to prevent flicker when refreshing display on some devices (e.g. iOS devices)
// It is ignored if rendering is done with 2d-canvas
doubleBuffering: false,
// Resolution of the renderer canvas
resolution: L.Browser.retina ? 2 : 1,
// return the layer projection zoom level
projectionZoom(map: L.Map): number {
const maxZoom = map.getMaxZoom();
const minZoom = map.getMinZoom();
if (maxZoom === Infinity) return minZoom + 8;
return (maxZoom + minZoom) / 2;
},
// Destroy PIXI EventSystem
destroyInteractionManager: false,
// Customize PIXI EventSystem autoPreventDefault property
// This option is ignored if destroyInteractionManager is set
autoPreventDefault: true,
// Enables drawing buffer preservation
preserveDrawingBuffer: false,
// Clear the canvas before the new render pass
clearBeforeRender: true,
// filter move events that should trigger a layer redraw
shouldRedrawOnMove: function () {
return false;
},
};
private mapSetting = {
initialZoom: 0,
wgsOrigin: L.latLng([0, 0]),
wgsInitialShift: L.point(0, 0),
mapInitialZoom: 0,
};
private __map?: L.Map;
private domContainer?: HTMLElement;
private renderer?: Renderer;
private auxRenderer?: Renderer;
public bounds = new L.Bounds([0, 0], [0, 0]);
public center = new L.LatLng(0, 0);
public zoom = 0;
constructor(
public drawCallback: (renderer: Renderer, e: Partial<LeafletEvent>) => void,
private _container: Container,
options: Partial<PixiOverlayOptions> = {},
) {
super();
this.initialize(drawCallback, _container, options);
}
private get rendererOptions(): Partial<AutoDetectOptions> {
return {
resolution: this.options.resolution,
antialias: true,
// forceCanvas: this.options.forceCanvas,
preserveDrawingBuffer: this.options.preserveDrawingBuffer,
clearBeforeRender: this.options.clearBeforeRender,
backgroundAlpha: 0,
};
}
private get doubleBuffering() {
return this.options.doubleBuffering && !this.options.forceCanvas;
}
public initialize(
drawCallback: (renderer: Renderer, e: Partial<LeafletEvent>) => void,
container: Container,
options: Partial<PixiOverlayOptions>,
) {
// merge options
L.setOptions(this, options);
// L.stamp(this);
this.drawCallback = drawCallback;
this._container = container;
}
public latLngToLayerPoint(
this: PixiOverlay,
latLng: Parameters<typeof L.latLng>[0],
_zoom?: number,
): L.Point {
if (!this.map) {
return L.point(0, 0);
}
const zoom = _zoom ?? this.mapSetting.initialZoom;
return this.map.project(L.latLng(latLng), zoom);
}
public layerPointToLatLng(
this: PixiOverlay,
point: L.PointTuple,
_zoom?: number,
): L.LatLng {
if (!this.map) {
return L.latLng(0, 0);
}
const zoom = _zoom ?? this.mapSetting.initialZoom;
return this.map.unproject(L.point(point), zoom);
}
public getScale(_zoom?: number): number {
const zoom = _zoom ?? this.map?.getZoom() ?? 1;
const { initialZoom } = this.mapSetting;
return this.map?.getZoomScale(zoom, initialZoom) ?? 1;
}
get map() {
return this.__map;
}
get container() {
return this._container;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async onAdd(map: L.Map): any {
this.__map = map;
await this.initializeRenderer();
const initialZoom = this.options.projectionZoom(map);
const wgsOrigin = L.latLng([0, 0]);
this.mapSetting = {
initialZoom,
wgsOrigin,
wgsInitialShift: map.project(wgsOrigin, initialZoom),
mapInitialZoom: map.getZoom(),
};
this.update({
type: "add",
target: this,
sourceTarget: this,
});
return this;
}
onRemove(): this {
this.__map = undefined;
if (this.domContainer) {
L.DomUtil.remove(this.domContainer);
}
return this;
}
destroy(): void {
this.remove();
if (this.renderer) {
this.renderer.destroy();
this.renderer = undefined;
}
if (this.auxRenderer) {
this.auxRenderer.destroy();
this.auxRenderer = undefined;
}
}
getEvents(): { [name: string]: LeafletEventHandlerFn } {
return {
zoom: this.onZoom,
move: this.onMove as LeafletEventHandlerFn,
moveend: this.update,
zoomanim: this.onAnimZoom as LeafletEventHandlerFn,
};
}
private updateTransform(center: L.LatLng, zoom: number) {
if (!this.map || !this.domContainer) return;
const scale = this.map.getZoomScale(zoom, this.zoom);
const viewHalf = this.map.getSize().multiplyBy(0.5 + this.options.padding);
const currentCenterPoint = this.map.project(this.center, zoom);
const topLeftOffset = viewHalf
.multiplyBy(-scale)
.add(currentCenterPoint)
.subtract(this.map._getNewPixelOrigin(center, zoom));
if (L.Browser.any3d) {
L.DomUtil.setTransform(this.domContainer, topLeftOffset, scale);
} else {
L.DomUtil.setPosition(this.domContainer, topLeftOffset);
}
}
private onZoom = (_: LeafletEvent) => {
if (this.map) {
this.updateTransform(this.map.getCenter(), this.map.getZoom());
}
};
private onAnimZoom = (e: ZoomAnimEvent) => {
this.updateTransform(e.center, e.zoom);
};
private onMove = (e: LeafletMouseEvent) => {
if (this.options.shouldRedrawOnMove(e)) {
this.update(e);
}
};
private update = (e: Partial<LeafletEvent>) => {
if (!this.map || !this.renderer || !this.domContainer) {
return;
}
// Update pixel bounds of renderer container
const p = this.options.padding;
const mapSize = this.map.getSize();
const min = this.map
.containerPointToLayerPoint(mapSize.multiplyBy(-p))
.round();
this.bounds = new L.Bounds(
min,
min.add(mapSize.multiplyBy(1 + p * 2)).round(),
);
this.center = this.map.getCenter();
this.zoom = this.map.getZoom();
if (this.doubleBuffering && this.auxRenderer) {
[this.renderer, this.auxRenderer] = [this.auxRenderer, this.renderer];
}
const view = this.renderer.canvas;
const b = this.bounds;
const container = this.domContainer;
const size = b.getSize();
const curWidth = parseInt(this.renderer.canvas.style.width, 10) || 0;
const curHeight = parseInt(this.renderer.canvas.style.height, 10) || 0;
if (curWidth !== size.x || curHeight !== size.y) {
if ("gl" in this.renderer) {
const gl = this.renderer.gl;
if (gl.drawingBufferWidth !== this.renderer.width) {
const resolution = gl.drawingBufferWidth / this.renderer.width;
this.renderer.resolution = resolution;
// if (this.renderer.rootRenderTarget) {
// this.renderer.rootRenderTarget.resolution = resolution;
// }
}
}
this.renderer.resize(size.x, size.y);
view.style.width = `${size.x.toString()}px`;
view.style.height = `${size.y.toString()}px`;
console.log(this.renderer.width, size);
}
const offset = b.min ?? new L.Point(0, 0);
if (this.doubleBuffering) {
// const self = this;
requestAnimationFrame(() => {
this.redraw(offset, e);
if (this.renderer && "gl" in this.renderer) {
this.renderer.gl.finish();
}
view.style.visibility = "visible";
if (this.auxRenderer) {
this.auxRenderer.canvas.style.visibility = "hidden";
}
L.DomUtil.setPosition(container, offset);
});
} else {
this.redraw(offset, e);
L.DomUtil.setPosition(container, offset);
}
};
// private disableLeafletRounding() {
// L.Point.prototype._round = no_round;
// }
// private enableLeafletRounding() {
// L.Point.prototype._round = round;
// }
private redraw(offset: L.Point, e: Partial<LeafletEvent>) {
if (!this.map || !this.renderer) {
return;
}
// this.disableLeafletRounding();
const { initialZoom, wgsOrigin, wgsInitialShift } = this.mapSetting;
const scale = this.map.getZoomScale(this.zoom, initialZoom);
const shift = this.map
.latLngToLayerPoint(wgsOrigin)
.subtract(wgsInitialShift.multiplyBy(scale))
.subtract(offset);
this.container.scale.set(scale);
this.container.position.set(shift.x, shift.y);
this.drawCallback(this.renderer, e);
// this.enableLeafletRounding();
}
private setEventSystem(
renderer: Renderer,
destroyInteractionManager: boolean,
autoPreventDefault: boolean,
) {
if (destroyInteractionManager) {
renderer.events.destroy();
} else if (!autoPreventDefault) {
renderer.events.autoPreventDefault = false;
}
}
private async initializeRenderer() {
if (this.domContainer) {
return;
}
this.domContainer = L.DomUtil.create(
"div",
"leaflet-pixi-overlay leaflet-zoom-animated absolute",
);
this.getPane()?.appendChild(this.domContainer);
this.map?.getPanes().overlayPane.appendChild(this.domContainer);
this.renderer = await autoDetectRenderer(this.rendererOptions);
this.setEventSystem(
this.renderer,
this.options.destroyInteractionManager,
this.options.autoPreventDefault,
);
this.domContainer.appendChild(this.renderer.canvas);
if (this.options.doubleBuffering) {
this.auxRenderer = await autoDetectRenderer(this.rendererOptions);
this.setEventSystem(
this.auxRenderer,
this.options.destroyInteractionManager,
this.options.autoPreventDefault,
);
this.domContainer.appendChild(this.auxRenderer.canvas);
this.renderer.canvas.style.position = "absolute";
this.auxRenderer.canvas.style.position = "absolute";
}
}
}
export default PixiOverlay;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment