vgrid-maplibre
Version:
DGGS Visualization for MapLibre GL JS
565 lines (482 loc) • 15.5 kB
JavaScript
class GeohashGrid {
constructor(map, options = {}) {
this.BASE32_CODES = "0123456789bcdefghjkmnpqrstuvwxyz";
this.BASE32_CODES_DICT = {};
for (let i = 0; i < this.BASE32_CODES.length; i++) {
this.BASE32_CODES_DICT[this.BASE32_CODES.charAt(i)] = i;
}
this.ENCODE_AUTO = "auto";
this.SIGFIG_HASH_LENGTH = [0, 5, 7, 8, 11, 12, 13, 15, 16, 17, 18];
this.map = map;
this.options = {
color: options.color || 'rgba(255, 0, 0, 1)',
width: options.width || 1,
redraw: options.redraw || 'move', // Default to redraw on move
};
this.sourceId = 'geohash-grid';
this.gridLayerId = 'geohash-grid-layer';
// this.labelLayerId = 'geohash-label-layer';
this.initialize();
}
initialize() {
if (!this.map.getSource(this.sourceId)) {
this.map.addSource(this.sourceId, {
type: 'geojson',
data: this.generateGrid(),
});
}
if (!this.map.getLayer(this.gridLayerId)) {
this.map.addLayer({
id: this.gridLayerId,
source: this.sourceId,
type: 'fill',
layout: {},
paint: {
'fill-color': 'transparent',
'fill-opacity': 1,
}
});
}
if (!this.map.getLayer('outline')) {
this.map.addLayer({
id: 'outline',
type: 'line',
source: this.sourceId,
layout: {},
paint: {
'line-color': this.options.color,
'line-width': this.options.width,
}
});
}
if (!this._hasListener) {
this.map.on(this.options.redraw, () => this.updateGrid());
this._hasListener = true;
}
}
updateGrid() {
const newGrid = this.generateGrid();
const source = this.map.getSource(this.sourceId);
if (source) {
source.setData(newGrid);
}
}
show() {
if (!this.map.getLayer(this.gridLayerId)) {
this.map.addLayer({
id: this.gridLayerId,
source: this.sourceId,
type: 'fill',
layout: {},
paint: {
'fill-color': 'transparent',
'fill-opacity': 1,
},
});
}
if (!this.map.getLayer('outline')) {
this.map.addLayer({
id: 'outline',
type: 'line',
source: this.sourceId,
layout: {},
paint: {
'line-color': this.options.color,
'line-width': this.options.width,
}
});
}
}
remove() {
if (this.map.getLayer(this.gridLayerId)) {
this.map.removeLayer(this.gridLayerId);
}
if (this.map.getLayer('outline')) {
this.map.removeLayer('outline');
}
}
generateGrid() {
const zoom = this.map.getZoom();
const resolution = this.getResolution(zoom);
const bounds = this.map.getBounds();
let minLat = bounds.getSouth();
let minLon = bounds.getWest();
let maxLat = bounds.getNorth();
let maxLon = bounds.getEast();
let lonWidth, latWidth;
const factor = Math.pow(4, resolution - 1);
if (resolution === 1) {
lonWidth = 360 / 8;
latWidth = 180 / 4;
}
else if(resolution === 2) {
lonWidth = 360 / (8 * 4);
latWidth = 180 / (8 * 4);
}
else if(resolution === 3) {
lonWidth = 360 / (8 * 32);
latWidth = 180 / (4 * 32);
}
else if(resolution === 4) {
lonWidth = 360 / (8 * 32*4);
latWidth = 180 / (4 * 32*8);
}
else if(resolution === 5) {
lonWidth = 360 / (8 * 32*4*8);
latWidth = 180 / (4 * 32*8*4);
}
else if(resolution === 6) {
lonWidth = 360 / (8 * 32*4*8*4);
latWidth = 180 / (4 * 32*8*4*8);
}
else if(resolution === 7) {
lonWidth = 360 / (8 * 32*4*8*4*8);
latWidth = 180 / (4 * 32*8*4*8*4);
}
else if(resolution === 8) {
lonWidth = 360 / (8 * 32*4*8*4*8*4);
latWidth = 180 / (4 * 32*8*4*8*4*8);
}
else if(resolution === 9) {
lonWidth = 360 / (8 * 32*4*8*4*8*4*8);
latWidth = 180 / (4 * 32*8*4*8*4*8*4);
}
else if(resolution === 10) {
lonWidth = 360 / (8 * 32*4*8*4*8*4*8*4);
latWidth = 180 / (4 * 32*8*4*8*4*8*4*8);
}
const baseLon = -180;
const baseLat = -90;
const startLon = Math.floor((minLon - baseLon) / lonWidth) * lonWidth + baseLon;
const endLon = Math.ceil((maxLon - baseLon) / lonWidth) * lonWidth + baseLon;
const startLat = Math.floor((minLat - baseLat) / latWidth) * latWidth + baseLat;
const endLat = Math.ceil((maxLat - baseLat) / latWidth) * latWidth + baseLat;
const longitudes = [];
const latitudes = [];
for (let lon = startLon; lon < endLon; lon += lonWidth) {
if (lon >= -180 && lon <= 180) longitudes.push(lon);
}
for (let lat = startLat; lat < endLat; lat += latWidth) {
if (lat >= -90 && lat <= 90) latitudes.push(lat);
}
const features = [];
for (const lon of longitudes) {
for (const lat of latitudes) {
const minLon = lon;
const minLat = lat;
const maxLon = lon + lonWidth;
const maxLat = lat + latWidth;
const coords = [[
[minLon, minLat],
[maxLon, minLat],
[maxLon, maxLat],
[minLon, maxLat],
[minLon, minLat] // close polygon
]];
const centroidLat = (minLat + maxLat) / 2
const centroidLon = (minLon + maxLon) / 2
const geohash_id = this.encode(centroidLat, centroidLon, resolution);
const exists = features.some(f => f.properties.geohash_id === geohash_id);
if (exists) continue;
const feature = {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: coords,
},
properties: {
geohash_id: geohash_id,
resolution,
}
};
features.push(feature);
}
}
return {
type: "FeatureCollection",
features: features
};
}
getResolution(zoom) {
if (zoom < 4) return 1;
if (zoom >= 4 && zoom < 6) return 2;
if (zoom >= 6 && zoom < 8) return 3;
if (zoom >= 8 && zoom < 10) return 4;
if (zoom >= 10 && zoom < 12) return 5;
if (zoom >= 12 && zoom < 14) return 6;
if (zoom >= 14 && zoom < 16) return 7;
if (zoom >= 16 && zoom < 18) return 8;
if (zoom >= 18 && zoom < 20) return 9;
return 10;
}
encode(latitude, longitude, numberOfChars) {
if (numberOfChars === this.ENCODE_AUTO) {
if (typeof (latitude) === 'number' || typeof (longitude) === 'number') {
throw new Error('string notation required for auto resolution.');
}
var decSigFigsLat = latitude.split('.')[1].length;
var decSigFigsLong = longitude.split('.')[1].length;
var numberOfSigFigs = Math.max(decSigFigsLat, decSigFigsLong);
numberOfChars = this.SIGFIG_HASH_LENGTH[numberOfSigFigs];
} else if (numberOfChars === undefined) {
numberOfChars = 9;
}
var chars = [],
bits = 0,
bitsTotal = 0,
hash_value = 0,
maxLat = 90,
minLat = -90,
maxLon = 180,
minLon = -180,
mid;
while (chars.length < numberOfChars) {
if (bitsTotal % 2 === 0) {
mid = (maxLon + minLon) / 2;
if (longitude > mid) {
hash_value = (hash_value << 1) + 1;
minLon = mid;
} else {
hash_value = (hash_value << 1) + 0;
maxLon = mid;
}
} else {
mid = (maxLat + minLat) / 2;
if (latitude > mid) {
hash_value = (hash_value << 1) + 1;
minLat = mid;
} else {
hash_value = (hash_value << 1) + 0;
maxLat = mid;
}
}
bits++;
bitsTotal++;
if (bits === 5) {
var code = this.BASE32_CODES[hash_value];
chars.push(code);
bits = 0;
hash_value = 0;
}
}
return chars.join('');
}
encode_int(latitude, longitude, bitDepth) {
bitDepth = bitDepth || 52;
var bitsTotal = 0,
maxLat = 90,
minLat = -90,
maxLon = 180,
minLon = -180,
mid,
combinedBits = 0;
while (bitsTotal < bitDepth) {
combinedBits *= 2;
if (bitsTotal % 2 === 0) {
mid = (maxLon + minLon) / 2;
if (longitude > mid) {
combinedBits += 1;
minLon = mid;
} else {
maxLon = mid;
}
} else {
mid = (maxLat + minLat) / 2;
if (latitude > mid) {
combinedBits += 1;
minLat = mid;
} else {
maxLat = mid;
}
}
bitsTotal++;
}
return combinedBits;
}
decode_bbox(hash_string) {
var isLon = true,
maxLat = 90,
minLat = -90,
maxLon = 180,
minLon = -180,
mid;
var hashValue = 0;
for (var i = 0, l = hash_string.length; i < l; i++) {
var code = hash_string[i].toLowerCase();
hashValue = this.BASE32_CODES_DICT[code];
for (var bits = 4; bits >= 0; bits--) {
var bit = (hashValue >> bits) & 1;
if (isLon) {
mid = (maxLon + minLon) / 2;
if (bit === 1) {
minLon = mid;
} else {
maxLon = mid;
}
} else {
mid = (maxLat + minLat) / 2;
if (bit === 1) {
minLat = mid;
} else {
maxLat = mid;
}
}
isLon = !isLon;
}
}
return [minLat, minLon, maxLat, maxLon];
}
decode_bbox_int(hashInt, bitDepth) {
bitDepth = bitDepth || 52;
var maxLat = 90,
minLat = -90,
maxLon = 180,
minLon = -180;
var latBit = 0, lonBit = 0;
var step = bitDepth / 2;
for (var i = 0; i < step; i++) {
lonBit = this.get_bit(hashInt, ((step - i) * 2) - 1);
latBit = this.get_bit(hashInt, ((step - i) * 2) - 2);
if (latBit === 0) {
maxLat = (maxLat + minLat) / 2;
}
else {
minLat = (maxLat + minLat) / 2;
}
if (lonBit === 0) {
maxLon = (maxLon + minLon) / 2;
}
else {
minLon = (maxLon + minLon) / 2;
}
}
return [minLat, minLon, maxLat, maxLon];
}
get_bit(bits, position) {
return (bits / Math.pow(2, position)) & 0x01;
}
decode(hashString) {
var bbox = this.decode_bbox(hashString);
var lat = (bbox[0] + bbox[2]) / 2;
var lon = (bbox[1] + bbox[3]) / 2;
var latErr = bbox[2] - lat;
var lonErr = bbox[3] - lon;
return {
latitude: lat, longitude: lon,
error: { latitude: latErr, longitude: lonErr }
}
}
decode_int(hash_int, bitDepth) {
var bbox = this.decode_bbox_int(hash_int, bitDepth);
var lat = (bbox[0] + bbox[2]) / 2;
var lon = (bbox[1] + bbox[3]) / 2;
var latErr = bbox[2] - lat;
var lonErr = bbox[3] - lon;
return {
latitude: lat, longitude: lon,
error: { latitude: latErr, longitude: lonErr }
}
}
neighbor(hashString, direction) {
var lonLat = this.decode(hashString);
var neighborLat = lonLat.latitude
+ direction[0] * lonLat.error.latitude * 2;
var neighborLon = lonLat.longitude
+ direction[1] * lonLat.error.longitude * 2;
return this.encode(neighborLat, neighborLon, hashString.length);
}
neighbor_int(hash_int, direction, bitDepth) {
bitDepth = bitDepth || 52;
var lonlat = this.decode_int(hash_int, bitDepth);
var neighbor_lat = lonlat.latitude + direction[0] * lonlat.error.latitude * 2;
var neighbor_lon = lonlat.longitude + direction[1] * lonlat.error.longitude * 2;
return this.encode_int(neighbor_lat, neighbor_lon, bitDepth);
}
neighbors(hashString) {
const hashStringLength = hashString.length;
const lonlat = this.decode(hashString);
const lat = lonlat.latitude;
const lon = lonlat.longitude;
const latErr = lonlat.error.latitude * 2;
const lonErr = lonlat.error.longitude * 2;
const neighborHashList = [
this._encodeNeighbor(1, 0),
this._encodeNeighbor(1, 1),
this._encodeNeighbor(0, 1),
this._encodeNeighbor(-1, 1),
this._encodeNeighbor(-1, 0),
this._encodeNeighbor(-1, -1),
this._encodeNeighbor(0, -1),
this._encodeNeighbor(1, -1),
];
return neighborHashList;
}
// Helper function for encoding neighbors
_encodeNeighbor(neighborLatDir, neighborLonDir) {
const neighborLat = lat + neighborLatDir * latErr;
const neighborLon = lon + neighborLonDir * lonErr;
return this.encode(neighborLat, neighborLon, hashStringLength);
}
neighbors_int(hashInt, bitDepth = 52) {
const lonlat = this.decode_int(hashInt, bitDepth);
const lat = lonlat.latitude;
const lon = lonlat.longitude;
const latErr = lonlat.error.latitude * 2;
const lonErr = lonlat.error.longitude * 2;
// Generate neighbor geohashes
return [
this.neighbor_int(lat, lon, 1, 0, latErr, lonErr, bitDepth),
this.neighbor_int(lat, lon, 1, 1, latErr, lonErr, bitDepth),
this.neighbor_int(lat, lon, 0, 1, latErr, lonErr, bitDepth),
this.neighbor_int(lat, lon, -1, 1, latErr, lonErr, bitDepth),
this.neighbor_int(lat, lon, -1, 0, latErr, lonErr, bitDepth),
this.neighbor_int(lat, lon, -1, -1, latErr, lonErr, bitDepth),
this.neighbor_int(lat, lon, 0, -1, latErr, lonErr, bitDepth),
this.neighbor_int(lat, lon, 1, -1, latErr, lonErr, bitDepth),
];
}
neighbor_int(lat, lon, neighborLatDir, neighborLonDir, latErr, lonErr, bitDepth) {
const neighborLat = lat + neighborLatDir * latErr;
const neighborLon = lon + neighborLonDir * lonErr;
return this.encode_int(neighborLat, neighborLon, bitDepth);
}
bboxes(minLat, minLon, maxLat, maxLon, numberOfChars) {
numberOfChars = numberOfChars || 9;
var hashSouthWest = this.encode(minLat, minLon, numberOfChars);
var hashNorthEast = this.encode(maxLat, maxLon, numberOfChars);
var latLon = this.decode(hashSouthWest);
var perLat = latLon.error.latitude * 2;
var perLon = latLon.error.longitude * 2;
var boxSouthWest = this.decode_bbox(hashSouthWest);
var boxNorthEast = this.decode_bbox(hashNorthEast);
var latStep = Math.round((boxNorthEast[0] - boxSouthWest[0]) / perLat);
var lonStep = Math.round((boxNorthEast[1] - boxSouthWest[1]) / perLon);
var hashList = [];
for (var lat = 0; lat <= latStep; lat++) {
for (var lon = 0; lon <= lonStep; lon++) {
hashList.push(this.neighbor(hashSouthWest, [lat, lon]));
}
}
return hashList;
}
bboxes_int(minLat, minLon, maxLat, maxLon, bitDepth = 52) {
const hashSouthWest = this.encode_int(minLat, minLon, bitDepth);
const hashNorthEast = this.encode_int(maxLat, maxLon, bitDepth);
const latlon = this.decode_int(hashSouthWest, bitDepth);
const perLat = latlon.error.latitude * 2;
const perLon = latlon.error.longitude * 2;
const boxSouthWest = this.decode_bbox_int(hashSouthWest, bitDepth);
const boxNorthEast = this.decode_bbox_int(hashNorthEast, bitDepth);
const latStep = Math.round((boxNorthEast[0] - boxSouthWest[0]) / perLat);
const lonStep = Math.round((boxNorthEast[1] - boxSouthWest[1]) / perLon);
const hashList = [];
// Iterate over latitude and longitude ranges to generate geohashes
for (let lat = 0; lat <= latStep; lat++) {
for (let lon = 0; lon <= lonStep; lon++) {
hashList.push(this.neighbor_int(hashSouthWest, [lat, lon], bitDepth));
}
}
return hashList;
}
}
export default GeohashGrid;