leaflet.glify
Version:
web gl renderer plugin for leaflet
445 lines (388 loc) • 11.9 kB
text/typescript
import {
Feature,
FeatureCollection,
Point as GeoPoint,
Position,
} from "geojson";
import { BaseGlLayer, IBaseGlLayerSettings } from "./base-gl-layer";
import { ICanvasOverlayDrawEvent } from "./canvas-overlay";
import * as Color from "./color";
import { LeafletMouseEvent, Map, Point, LatLng } from "leaflet";
import { IPixel } from "./pixel";
import { locationDistance, pixelInCircle } from "./utils";
export interface IPointsSettings extends IBaseGlLayerSettings {
data: number[][] | FeatureCollection<GeoPoint>;
size?: ((i: number, latLng: LatLng | null) => number) | number | null;
eachVertex?: (pointVertex: IPointVertex) => void;
sensitivity?: number;
sensitivityHover?: number;
}
const defaults: Partial<IPointsSettings> = {
color: Color.random,
opacity: 0.8,
className: "",
sensitivity: 2,
sensitivityHover: 0.03,
shaderVariables: {
vertex: {
type: "FLOAT",
start: 0,
size: 2,
},
color: {
type: "FLOAT",
start: 2,
size: 4,
},
pointSize: {
type: "FLOAT",
start: 6,
size: 1,
},
},
};
export interface IPointVertex {
latLng: LatLng;
pixel: IPixel;
chosenColor: Color.IColor;
chosenSize: number;
key: string;
feature?: any;
}
export class Points extends BaseGlLayer<IPointsSettings> {
static defaults = defaults;
static maps = [];
bytes = 7;
latLngLookup: {
[key: string]: IPointVertex[];
} = {};
allLatLngLookup: IPointVertex[] = [];
vertices: number[] = [];
typedVertices: Float32Array = new Float32Array();
dataFormat: "Array" | "GeoJson.FeatureCollection";
settings: Partial<IPointsSettings>;
active: boolean;
get size(): ((i: number, latLng: LatLng | null) => number) | number | null {
if (typeof this.settings.size === "number") {
return this.settings.size;
}
if (typeof this.settings.size === "function") {
return this.settings.size;
}
return null;
}
constructor(settings: Partial<IPointsSettings>) {
super(settings);
this.settings = { ...defaults, ...settings };
this.active = true;
const { data, map } = this;
if (Array.isArray(data)) {
this.dataFormat = "Array";
} else if (data.type === "FeatureCollection") {
this.dataFormat = "GeoJson.FeatureCollection";
} else {
throw new Error(
"unhandled data type. Supported types are Array and GeoJson.FeatureCollection"
);
}
if (map.options.crs?.code !== "EPSG:3857") {
console.warn("layer designed for SphericalMercator, alternate detected");
}
this.setup().render();
}
render(): this {
this.resetVertices();
// look up the locations for the inputs to our shaders.
const { gl, canvas, layer, vertices, mapMatrix } = this;
const matrix = (this.matrix = this.getUniformLocation("matrix"));
const verticesBuffer = this.getBuffer("vertices");
const verticesTyped = (this.typedVertices = new Float32Array(vertices));
const byteCount = verticesTyped.BYTES_PER_ELEMENT;
// set the matrix to some that makes 1 unit 1 pixel.
mapMatrix.setSize(canvas.width, canvas.height);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniformMatrix4fv(matrix, false, mapMatrix.array);
gl.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
gl.bufferData(gl.ARRAY_BUFFER, verticesTyped, gl.STATIC_DRAW);
this.attachShaderVariables(byteCount);
layer.redraw();
return this;
}
getPointLookup(key: string): IPointVertex[] {
return this.latLngLookup[key] || (this.latLngLookup[key] = []);
}
addLookup(lookup: IPointVertex): this {
this.getPointLookup(lookup.key).push(lookup);
this.allLatLngLookup.push(lookup);
return this;
}
resetVertices(): this {
// empty vertices and repopulate
this.latLngLookup = {};
this.allLatLngLookup = [];
this.vertices = [];
const {
vertices,
settings,
map,
size,
latitudeKey,
longitudeKey,
color,
opacity,
data,
} = this;
const { eachVertex } = settings;
let colorFn:
| ((i: number, latLng: LatLng | any) => Color.IColor)
| null = null;
let chosenColor: Color.IColor;
let chosenSize: number;
let sizeFn;
let rawLatLng: [number, number] | Position;
let latLng: LatLng;
let pixel: Point;
let key;
if (!color) {
throw new Error("color is not properly defined");
} else if (typeof color === "function") {
colorFn = color as (i: number, latLng: LatLng) => Color.IColor;
}
if (!size) {
throw new Error("size is not properly defined");
} else if (typeof size === "function") {
sizeFn = size;
}
if (this.dataFormat === "Array") {
const max = data.length;
for (let i = 0; i < max; i++) {
rawLatLng = data[i];
key =
rawLatLng[latitudeKey].toFixed(2) +
"x" +
rawLatLng[longitudeKey].toFixed(2);
latLng = new LatLng(rawLatLng[latitudeKey], rawLatLng[longitudeKey]);
pixel = map.project(latLng, 0);
if (colorFn) {
chosenColor = colorFn(i, latLng);
} else {
chosenColor = color as Color.IColor;
}
chosenColor = { ...chosenColor, a: chosenColor.a ?? opacity ?? 0 };
if (sizeFn) {
chosenSize = sizeFn(i, latLng);
} else {
chosenSize = size as number;
}
vertices.push(
// vertex
pixel.x,
pixel.y,
// color
chosenColor.r,
chosenColor.g,
chosenColor.b,
chosenColor.a ?? 0,
// size
chosenSize
);
const vertex = {
latLng,
key,
pixel,
chosenColor,
chosenSize,
feature: rawLatLng,
};
this.addLookup(vertex);
if (eachVertex) {
eachVertex(vertex);
}
}
} else if (this.dataFormat === "GeoJson.FeatureCollection") {
const max = data.features.length;
for (let i = 0; i < max; i++) {
const feature = data.features[i] as Feature<GeoPoint>;
rawLatLng = feature.geometry.coordinates;
key =
rawLatLng[latitudeKey].toFixed(2) +
"x" +
rawLatLng[longitudeKey].toFixed(2);
latLng = new LatLng(rawLatLng[latitudeKey], rawLatLng[longitudeKey]);
pixel = map.project(latLng, 0);
if (colorFn) {
chosenColor = colorFn(i, feature);
} else {
chosenColor = color as Color.IColor;
}
chosenColor = { ...chosenColor, a: chosenColor.a ?? opacity ?? 0 };
if (sizeFn) {
chosenSize = sizeFn(i, latLng);
} else {
chosenSize = size as number;
}
vertices.push(
// vertex
pixel.x,
pixel.y,
// color
chosenColor.r,
chosenColor.g,
chosenColor.b,
chosenColor.a ?? 0,
// size
chosenSize
);
const vertex: IPointVertex = {
latLng,
key,
pixel,
chosenColor,
chosenSize,
feature,
};
this.addLookup(vertex);
if (eachVertex) {
eachVertex(vertex);
}
}
}
return this;
}
// TODO: remove?
pointSize(pointIndex: number): number {
const { map, size } = this;
const pointSize =
typeof size === "function" ? size(pointIndex, null) : size;
// -- Scale to current zoom
const zoom = map.getZoom();
return pointSize === null ? Math.max(zoom - 4.0, 1.0) : pointSize;
}
drawOnCanvas(e: ICanvasOverlayDrawEvent): this {
if (!this.gl) return this;
const { gl, canvas, mapMatrix, matrix, map, allLatLngLookup } = this;
const { offset } = e;
const zoom = map.getZoom();
const scale = Math.pow(2, zoom);
// set base matrix to translate canvas pixel coordinates -> webgl coordinates
mapMatrix
.setSize(canvas.width, canvas.height)
.scaleTo(scale)
.translateTo(-offset.x, -offset.y);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniformMatrix4fv(matrix, false, mapMatrix.array);
gl.drawArrays(gl.POINTS, 0, allLatLngLookup.length);
return this;
}
lookup(coords: LatLng): IPointVertex | null {
const latMax: number = coords.lat + 0.03;
const lngMax: number = coords.lng + 0.03;
const matches: IPointVertex[] = [];
let lat = coords.lat - 0.03;
let lng: number;
let foundI: number;
let foundMax: number;
let found: IPointVertex[];
let key: string;
for (; lat <= latMax; lat += 0.01) {
lng = coords.lng - 0.03;
for (; lng <= lngMax; lng += 0.01) {
key = lat.toFixed(2) + "x" + lng.toFixed(2);
found = this.latLngLookup[key];
if (found) {
foundI = 0;
foundMax = found.length;
for (; foundI < foundMax; foundI++) {
matches.push(found[foundI]);
}
}
}
}
const { map } = this;
// try matches first, if it is empty, try the data, and hope it isn't too big
return Points.closest(
coords,
matches.length > 0 ? matches : this.allLatLngLookup,
map
);
}
static closest(
targetLocation: LatLng,
points: IPointVertex[],
map: Map
): IPointVertex | null {
if (points.length < 1) return null;
return points.reduce((prev, curr) => {
const prevDistance = locationDistance(targetLocation, prev.latLng, map);
const currDistance = locationDistance(targetLocation, curr.latLng, map);
return prevDistance < currDistance ? prev : curr;
});
}
// attempts to click the top-most Points instance
static tryClick(
e: LeafletMouseEvent,
map: Map,
instances: Points[]
): boolean | undefined {
const closestFromEach: IPointVertex[] = [];
const instancesLookup: { [key: string]: Points } = {};
let result;
let settings: Partial<IPointsSettings> | null = null;
let pointLookup: IPointVertex | null;
instances.forEach((_instance: Points) => {
settings = _instance.settings;
if (!_instance.active) return;
if (_instance.map !== map) return;
pointLookup = _instance.lookup(e.latlng);
if (pointLookup === null) return;
instancesLookup[pointLookup.key] = _instance;
closestFromEach.push(pointLookup);
});
if (closestFromEach.length < 1) return;
if (!settings) return;
const found = this.closest(e.latlng, closestFromEach, map);
if (!found) return;
const instance = instancesLookup[found.key];
if (!instance) return;
const { sensitivity } = instance;
const foundLatLng = found.latLng;
const xy = map.latLngToLayerPoint(foundLatLng);
if (
pixelInCircle(xy, e.layerPoint, found.chosenSize * (sensitivity ?? 1))
) {
result = instance.click(e, found.feature || found.latLng);
return result !== undefined ? result : true;
}
}
// hovers all touching Points instances
static tryHover(
e: LeafletMouseEvent,
map: Map,
instances: Points[]
): Array<boolean | undefined> {
const results: boolean[] = [];
instances.forEach((_instance: Points): void => {
if (!_instance.active) return;
if (_instance.map !== map) return;
const pointLookup = _instance.lookup(e.latlng);
if (!pointLookup) return;
if (
pixelInCircle(
map.latLngToLayerPoint(pointLookup.latLng),
e.layerPoint,
pointLookup.chosenSize * _instance.sensitivityHover * 30
)
) {
const result = _instance.hover(
e,
pointLookup.feature || pointLookup.latLng
);
if (result !== undefined) {
results.push(result);
}
}
});
return results;
}
}