random-streetview
Version:
Finds a random valid StreetView location in a given polygon.
465 lines (408 loc) • 17.7 kB
JavaScript
import EventEmitter from 'events';
import Google from "./Google.js";
export default class StreetView extends EventEmitter {
constructor() {
super();
this.slowCpu = false;
this.coverageCache = this.importCoverageCache();
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
//google maps coverage images are 256x256
this.canvas.width = 256;
this.canvas.height = 256;
this.enableCaching = true;
this.cacheKey = 'rsv__world';
this.polygon = false;
this.google = false;
this.area = 1;
this.smallestContainingTile = {x: 0, y: 0, zoom: 0};
this.typeColors = [
{color: [84, 160, 185], id: 'sv'},
{color: [18, 158, 175], id: 'sv'},
{color: [165, 224, 250, 102], id: 'photo'},
];
}
setParameters(polygon, enableCaching, cacheKey, google) {
this.google = google;
this.cacheKey = cacheKey;
this.enableCaching = enableCaching;
this.smallestContainingTile = this.polygonToSmallestContainingTile(polygon);
this.polygon = polygon;
let area = 0;
if (polygon)
polygon.getPaths().forEach(path => {
area += this.google.maps.geometry.spherical.computeArea(path);
});
this.area = area;
}
async randomValidLocation({
endZoom = 13,
type = 'sv',
distribution = 'weighted'
}) {
this.distribution = distribution;
let tile = await this.randomValidTile(endZoom, type, this.smallestContainingTile);
if (tile === false)
return false;
let canvas = document.createElement("canvas");
let context = canvas.getContext("2d");
if (tile.img === false)
tile.img = await this.getTileImage(tile.x, tile.y, tile.zoom);
let img = tile.img;
canvas.width = img.width;
canvas.height = img.height;
context.drawImage(img, 0, 0);
let data = context.getImageData(0, 0, img.width, img.height).data;
let pixelCounts = {count: 0, indices: []};
for (let i = 0; i < data.length; i += 4) {
let color = data.slice(i, i + 4);
let colorType = this.getColorType(color);
if (colorType === type || (colorType !== 'empty' && type === 'both')) {
pixelCounts.count++;
pixelCounts.indices.push(i);
}
}
if (pixelCounts.count === 0) {
console.error("No blue pixel found");
return this.randomValidLocation({endZoom, type, distribution});
}
let randomSvPixel = Math.floor(Math.random() * pixelCounts.count);
let randomSvIndex = pixelCounts.indices[randomSvPixel];
let x = (randomSvIndex / 4) % img.width;
let y = Math.floor((randomSvIndex / 4) / img.width);
this.saveCoverageCache();
return this.tilePixelToCoordinate(tile.x, tile.y, tile.zoom, x, y);
}
containsLocation(location, polygon) {
if (polygon === false)
return true;
return this.google.maps.geometry.poly.containsLocation(location, polygon);
}
async waitSleep(time) {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}
async randomValidTile(endZoom, type, chosenTile = {x: 0, y: 0, zoom: 0}, startZoom = chosenTile.zoom) {
if (chosenTile.zoom >= endZoom) {
return chosenTile;
}
const photoSphereZoomLevel = 12;
let subTiles = await this.getSubTiles(chosenTile.x, chosenTile.y, chosenTile.zoom);
let validTiles = subTiles
.filter(tile =>
type === 'sv' && tile.types.sv ||
type === 'photo' && tile.types.photo ||
type === 'both' && (tile.types.photo || tile.types.sv) ||
//When under photosphere zoom level, also consider sv tiles valid tiles, because photospheres aren't visible yet
tile.zoom <= photoSphereZoomLevel && tile.types.sv)
.filter(tile => this.tileIntersectsMap(tile.x, tile.y, tile.zoom));
if (chosenTile.zoom === startZoom && validTiles.length === 0 && chosenTile.zoom <= 7) {
//OH OH SPAGHETTIOS
//Can't find anything in the start tile, trying to go ahead by ignoring street view coverage
validTiles = subTiles
.filter(tile => this.tileIntersectsMap(tile.x, tile.y, tile.zoom));
startZoom = validTiles[0].zoom;
}
let tilesInfo = subTiles.map(tile => ({
...tile,
valid: validTiles.includes(tile),
}));
this.emit('tiles', tilesInfo);
let shuffleFun = this.distribution === 'uniform' ?
array => this.shuffle(array) :
array => this.shuffleWeighted(array, item => item.coverage[chosenTile.zoom + 1 <= photoSphereZoomLevel ? 'both' : type]);
let shuffledTiles = shuffleFun(validTiles);
for (let tile of shuffledTiles) {
let subTile = await this.randomValidTile(endZoom, type, tile, startZoom);
if (subTile !== false && (subTile.types.sv || subTile.types.photo))
return subTile;
}
return false;
}
tileEquals(tileA, tileB) {
return (tileA.x === tileB.x && tileA.y === tileB.y && tileA.zoom === tileB.zoom);
}
getTileCornerCoordinates(tileX, tileY, zoom) {
return [
this.tilePixelToCoordinate(tileX, tileY, zoom, 0, 0),// top left
this.tilePixelToCoordinate(tileX, tileY, zoom, 256, 0),// top right
this.tilePixelToCoordinate(tileX, tileY, zoom, 256, 256),// bottom right
this.tilePixelToCoordinate(tileX, tileY, zoom, 0, 256),// bottom left
];
}
tileIntersectsMap(tileX, tileY, zoom) {
if (this.polygon === false)
return true;
let tileCoordinates = this.getTileCornerCoordinates(tileX, tileY, zoom);
//Check if tile corners are in map bounds
for (let coordinate of tileCoordinates)
if (this.containsLocation(coordinate, this.polygon)) {
return true;
}
// return false;
//Maybe one of the 4 tile corners don't intersect, doesn't mean the two polygons don't intersect
let mapsBounds = new this.google.maps.LatLngBounds();
for (let coordinate of tileCoordinates)
mapsBounds.extend(coordinate);
// Check if map coordinates are in within tile bounds
let mapContains = false;
this.polygon.getPaths().forEach(path => {
path.forEach(point => {
if (mapsBounds.contains(point))
mapContains = true;
});
});
// console.log("Using mapContains");
return mapContains;
}
async getSubTiles(x, y, zoom) {
//Zooming multiplies coordinates by 2 (4 sub tiles in a tile)
let startX = x * 2;
let startY = y * 2;
let endX = startX + 2;
let endY = startY + 2;
return this.getTileGrid(startX, endX, startY, endY, zoom + 1);
}
async getTileGrid(startX, endX, startY, endY, zoom) {
let tasks = [];
for (let y = startY; y < endY; y++)
for (let x = startX; x < endX; x++)
tasks.push(this.getTile(x, y, zoom));
return await Promise.all(tasks);
}
tilePixelToCoordinate(tileX, tileY, zoom, pixelX, pixelY) {
tileX += pixelX / 256;
tileY += pixelY / 256;
tileX *= 2 ** (8 - zoom);
tileY *= 2 ** (8 - zoom);
let lng = tileX / 256 * 360 - 180;
let n = Math.PI - 2 * Math.PI * tileY / 256;
let lat = (180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))));
return new this.google.maps.LatLng(lat, lng);
}
toRadians(degrees) {
return degrees * Math.PI / 180;
}
polygonToBounds(polygon) {
const bounds = new this.google.maps.LatLngBounds();
polygon.getPaths().forEach(path => {
path.forEach(pos => {
bounds.extend(pos);
});
});
return bounds;
}
polygonToSmallestContainingTile(polygon) {
if (polygon === false)
return {x: 0, y: 0, zoom: 0};
let bounds = this.polygonToBounds(polygon);
let ne = bounds.getNorthEast();
let sw = bounds.getSouthWest();
let startZoom = 0;
let endZoom = 18;
let resultTile = {x: 0, y: 0, zoom: startZoom};
for (let zoom = startZoom; zoom <= endZoom; zoom++) {
let neTile = this.coordinateToTile(ne, zoom);
let swTile = this.coordinateToTile(sw, zoom);
let equals = this.tileEquals(neTile, swTile);
if (!equals)
break;
resultTile = neTile;
}
return resultTile;
}
coordinateToTile(coordinate, zoom) {
let latRad = this.toRadians(coordinate.lat());
let n = 2.0 ** zoom;
let xTile = Math.floor((coordinate.lng() + 180.0) / 360.0 * n);
let yTile = Math.floor((1.0 - Math.log(Math.tan(latRad) + (1 / Math.cos(latRad))) / Math.PI) / 2.0 * n);
return {x: xTile, y: yTile, zoom};
}
getUrl(x, y, zoom) {
return `https://maps.googleapis.com/maps/vt?pb=!1m5!1m4!1i${zoom}!2i${x}!3i${y}!4i256!2m8!1e2!2ssvv!4m2!1scb_client!2sapiv3!4m2!1scc!2s*211m3*211e3*212b1*213e2*211m3*211e2*212b1*213e2!3m3!3sUS!12m1!1e68!4e0`;
// return `https://mts1.this.googleapis.com/vt?hl=en-US&lyrs=svv|cb_client:apiv3&style=40,18&x=${x}&y=${y}&z=${zoom}`;
}
async getTileImage(x, y, zoom) {
return new Promise(async resolve => {
const url = this.getUrl(x, y, zoom);
// console.log(x, y, zoom, url);
let response = await fetch(url);
let blob = await response.blob();
const img = new Image();
// document.querySelector('.temp').prepend(img);
img.src = URL.createObjectURL(blob);
img.onload = () => resolve(img);
});
}
async getTile(x, y, zoom) {
return new Promise(async resolve => {
if (this.coverageCacheContains(x, y, zoom)) {
let {coverage, types} = this.getCoverageCache(x, y, zoom);
// console.log("Using cache!", x, y, zoom, coverage);
resolve({
coverage, types, img: false, x, y, zoom
});
return;
}
let img = await this.getTileImage(x, y, zoom);
let c = await this.getTileCoverage(x, y, zoom, img);
this.setCoverageCache(x, y, zoom, c);
let {coverage, types} = this.getCoverageCache(x, y, zoom);
resolve({
coverage, types, img, x, y, zoom
});
});
}
getColorType(rgba) {
if (rgba[2] === 0)
return 'empty';
const allowedColorDiff = 4;
typeLoop:
for (let {id, color} of this.typeColors) {
for (let i = 0; i < color.length; i++) {
const componentDifference = Math.abs(color[i] - rgba[i]);
if (componentDifference > allowedColorDiff)
continue typeLoop;
}
return id;
}
return 'empty';
}
isTileFullyContainedInMap(tileX, tileY, zoom) {
let coordinates = this.getTileCornerCoordinates(tileX, tileY, zoom);
for (let coordinate of coordinates) {
if (!this.containsLocation(coordinate, this.polygon))
return false;
}
return true;
}
async getTileCoverage(tileX, tileY, zoom, img) {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(img, 0, 0);
let data = this.context.getImageData(0, 0, img.width, img.height).data;
//Coverage [sv, photo]
let coverage = [0, 0];
let isFullyContained = this.polygon !== false && this.isTileFullyContainedInMap(tileX, tileY, zoom);
//asia area: 87868883173444
//spain area: 680475474716
//EU area: 12047591207736
//russia area: 16934010870404
let massiveArea = /**/ 5000000000000;
let bigArea = /* */ 1000000000000;
let chunkSize = 16;
if (zoom <= 2 && this.area < massiveArea)//0, 1, 2
chunkSize = 4;
else if (zoom <= 4 && this.area < massiveArea)//3
chunkSize = 4;
else if (zoom <= 7 && this.area < bigArea)
chunkSize = 8;
let pixelChunkSize;
if (zoom <= 6)
pixelChunkSize = 16;
else if (zoom <= 7)
pixelChunkSize = 16;
else if (zoom <= 8)
pixelChunkSize = 8;
else if (zoom <= 9)
pixelChunkSize = 4;
else
pixelChunkSize = 2;
pixelChunkSize = Math.min(pixelChunkSize, chunkSize);
// console.log("Using", {chunkSize, pixelChunkSize, zoom, img})
for (let y = 0; y < img.height; y += chunkSize) {
for (let x = 0; x < img.width; x += chunkSize) {
if (this.slowCpu)
await this.waitSleep(10);
if (!isFullyContained) {
let coordinate = this.tilePixelToCoordinate(tileX, tileY, zoom, x + chunkSize / 2, y + chunkSize / 2);
if (!this.containsLocation(coordinate, this.polygon)) {
continue;
}
// console.log("Chunk is in polygon!");
}
for (let pY = y + pixelChunkSize / 2; pY < y + chunkSize; pY += pixelChunkSize) {
for (let pX = x + pixelChunkSize / 2; pX < x + chunkSize; pX += pixelChunkSize) {
// console.log(pX, pY);
let i = (pY * img.width + pX) * 4;
let color = data.slice(i, i + 4);
let colorType = this.getColorType(color);
if (colorType === 'sv')
coverage[0]++;
if (colorType === 'photo')
coverage[1]++;
}
}
}
}
return coverage;
}
coverageCacheContains(x, y, zoom) {
let id = this.cacheKey;
return this.coverageCache[id] && this.coverageCache[id][zoom] && this.coverageCache[id][zoom][x] && this.coverageCache[id][zoom][x][y];
}
getCoverageCache(x, y, zoom) {
let id = this.cacheKey;
let [svCoverage, photoCoverage] = this.coverageCache[id][zoom][x][y];
return {
types: {
sv: svCoverage > 0,
photo: photoCoverage > 0,
},
coverage: {
sv: svCoverage,
photo: photoCoverage,
both: svCoverage + photoCoverage,
}
}
}
setCoverageCache(x, y, zoom, value) {
let id = this.cacheKey;
if (!this.coverageCache[id])
this.coverageCache[id] = {};
if (!this.coverageCache[id][zoom])
this.coverageCache[id][zoom] = {};
if (!this.coverageCache[id][zoom][x])
this.coverageCache[id][zoom][x] = {};
this.coverageCache[id][zoom][x][y] = value;
}
importCoverageCache() {
return localStorage.getItem('tileCoverage') === null ? {} : JSON.parse(localStorage.tileCoverage);
}
saveCoverageCache() {
if (this.enableCaching)
localStorage.tileCoverage = JSON.stringify(this.coverageCache);
}
shuffleWeighted(array, weightField = item => item.weight) {
if (array.length === 0)
return array;
let result = [];
let len = array.length;
let totalWeights = array.map(weightField).reduce((a, b) => a + b);
for (let i = 0; i < len; i++) {
let randomWeightValue = Math.random() * totalWeights;
let weightedRandomIndex = -1;
for (let j = 0; j < array.length; j++) {
let item = array[j];
if (weightField(item) > randomWeightValue) {
weightedRandomIndex = j;
break;
}
randomWeightValue -= weightField(item);
}
let item = array.splice(weightedRandomIndex, 1)[0];
totalWeights -= weightField(item);
result.push(item);
}
return result;
}
shuffle(input) {
for (let i = input.length - 1; i >= 0; i--) {
const randomIndex = Math.floor(Math.random() * (i + 1));
const itemAtIndex = input[randomIndex];
input[randomIndex] = input[i];
input[i] = itemAtIndex;
}
return input;
}
}