How to use 'chunky' (coarse-grained) zoom levels with Leaflet maps? - javascript
I am using Leaflet.js for desktop and mobile browser-based maps, and need to support a variety of map tile services. Some of these tile services are defined with coarse zoom levels (like 1, 5, 10, 15), and if I make a request for an unsupported zoom level, the service does not return a tile. (For example, if I request service/tiles/6/x/y when zoom level 6 is not supported).
Leaflet tile layers support minZoom and maxZoom but I'm trying to figure out if there is a best practice for doing coarse zoom levels, and if other folks have encountered this.
I found this post on GitHub that addresses tile scaling for unsupported zoom levels: https://github.com/Leaflet/Leaflet/pull/1802
But I am not sure if this applies. (I'm not sure I want to scale or 'tween' the zoom levels... but if this makes sense and is not too difficult I am willing to try.)
I've started experimenting with this approach which gets messy because zooming can cause more zooming and I have to differentiate user-driven zooms from system-driven zooms:
// layer metadata (assumption: Levels is in ascending order of zoom)
var layerDef = { Title: 'Service lines', Levels: [10, 15, 19] };
// create Leaflet Tile Layer and show on map
var layer = L.tileLayer('://host/service/{z}/{x}/{y}');
layer.minZoom = layerDef.Levels[0];
layer.maxZoom = layerDef.Levels[layerDef.Levels-1];
layer.addTo(map);
// initialize lastZoom for coarse zoom management
var lastZoom = map.getZoom();
var userZoom = true;
// handle supported zoom levels when zoom changes
map.on('zoomend', function (e)
{
// get new zoom level
var z = e.target._zoom || map.getZoom();
if (userZoom) // assume user initiated this zoom
{
// is this zoom level supported?
var zIdx = $.inArray(z, layerDef.Levels);
if (zIdx === -1)
{
// zoom level is not supported; zoom out or in to supported level
// delta: < 0 for zoom out, > 0 for zoom in
var zDelta = z - lastZoom;
var zLastIdx = $.inArray(lastZoom, layerDef.Levels);
var newZoom = -1;
if (zDelta > 0)
{
// user zoomed in to unsupported level.
// zoom in to next supported level (rely on layer.maxZoom)
newZoom = layerDef.Levels[zLastIdx + 1];
}
else
{
// user zoomed out to unsupported level.
// zoom out to next supported level (rely on layer.minZoom)
newZoom = layerDef.Levels[zLastIdx - 1];
}
if (newZoom !== -1)
{
userZoom = false; // set flag
setTimeout(function ()
{
map.setZoom(newZoom); // set zoom -- seems to not work if called from within zoomend handler
}, 100); // delay call to setZoom() to fix issue
}
}
}
else
{
userZoom = true; // clear flag
}
lastZoom = z;
});
(Side note: I hope the reason for coarse zoom levels is obvious: it can get expensive to create and store raster tiles at each zoom level, especially for large geographic areas, and especially when used with offline mobile devices with their own [local] tile servers, limited wireless data plans, limited storage capacity, etc. This is perhaps not something you might encounter with toy apps and Google maps, for example, but rather with domain-specific and mobile applications in which space and bandwidth are at a premium.)
Thanks!
UPDATE: I found that the problem I was having with this code is that map.setZoom(z) does not work right when called from within the zoomEnd handler (it does set the zoom, but causes display issue with gray/nonexistent tiles, perhaps because Leaflet is still in process of scaling / zooming). Fix was to use setTimeout to delay the call to setZoom() a bit. However, I'm still really curious if anybody else has dealt with this, and if there is a 'better way'... (I updated above code to work with setZoom fix)
There is currently a commit under review in Leaflet's repository on GitHub. It adds zoomFactor to the map's options. Maybe that's what you're looking for. At least, i think it will work just as long as your available tileset has zoomlevels which are (don't know if this is the correct technical term) multiples of the lowest available zoomlevel.
See: https://github.com/Leaflet/Leaflet/pull/3285
The following (no guarantees, based on this) should work with Leaflet v1.7.3 but probably not with current master.
It uses a serverZooms option to specify available zoom levels on the tile server as an ordered array.
Overrides L.TileLayer._getZoomForUrl to return a matching or the next lower available server zoom. Also overrides L.TileLayer._getTileSize to increase tile size in order to scale the tiles in between server zooms.
L.TileLayer.Overzoom = L.TileLayer.extend({
options: {
// List of available server zoom levels in ascending order. Empty means all
// client zooms are available (default). Allows to only request tiles at certain
// zooms and resizes tiles on the other zooms.
serverZooms: []
},
// add serverZooms (when maxNativeZoom is not defined)
// #override
_getTileSize: function() {
var map = this._map,
options = this.options,
zoom = map.getZoom() + options.zoomOffset,
zoomN = options.maxNativeZoom || this._getServerZoom(zoom);
// increase tile size when overscaling
return zoomN && zoom !== zoomN ?
Math.round(map.getZoomScale(zoom) / map.getZoomScale(zoomN) * options.tileSize) :
options.tileSize;
},
// #override
_getZoomForUrl: function () {
var zoom = L.TileLayer.prototype._getZoomForUrl.call(this);
return this._getServerZoom(zoom);
},
// Returns the appropriate server zoom to request tiles for the current zoom level.
// Next lower or equal server zoom to current zoom, or minimum server zoom if no lower
// (should be restricted by setting minZoom to avoid loading too many tiles).
_getServerZoom: function(zoom) {
var serverZooms = this.options.serverZooms || [],
result = zoom;
// expects serverZooms to be sorted ascending
for (var i = 0, len = serverZooms.length; i < len; i++) {
if (serverZooms[i] <= zoom) {
result = serverZooms[i];
} else {
if (i === 0) {
// zoom < smallest serverZoom
result = serverZooms[0];
}
break;
}
}
return result;
}
});
(function () {
var map = new L.Map('map');
map.setView([50, 10], 5);
new L.TileLayer.Overzoom('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
serverZooms: [0, 1, 2, 3, 6, 9, 12, 15, 17],
attribution : '© <a target="_parent" href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
})();
body {
margin: 0;
}
html, body, #map {
width: 100%;
height: 100%;
}
<link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.7.3/leaflet.css" />
<script src="http://cdn.leafletjs.com/leaflet-0.7.3/leaflet.js"></script>
<div id="map"></div>
Related
Vertical alignment of TMS tiles in Leaflet using EPSG:25832 projection
I am using Leaflet with Proj4Leaflet to work with tiles in 25832. The application is fairly simple: I am trying to overlay tiles in EPSG:25832 onto a omniscale basemap. I have copied the individual resolutions and origin from the tilemap meta information. The problem I am facing is that the map is not aligned and once I zoom in the tiles are not placed in the correct order. I'd appreciate any kind of support here (by the way, this is a working example which is using openlayers). I guess I am doing something wrong here: // Set resolutions var resolutions = [156367.7919628329,78183.89598141646,39091.94799070823,19545.973995354114,9772.986997677057,4886.4934988385285,2443.2467494192642,1221.6233747096321,610.8116873548161,305.40584367740803,152.70292183870401,76.35146091935201,38.175730459676004,19.087865229838002,9.543932614919001,4.7719663074595005,2.3859831537297502,1.1929915768648751]; // Define CRS var rs25832 = new L.Proj.CRS( 'EPSG:25832', proj4rs25832def, { origin: [ 273211.2532533697, 6111822.37943825 ], resolutions: resolutions } ); ...using the tiles information from https://mapproxy.bba.atenekom.eu/tms/1.0.0/privat_alle_50_mbit/germany . Afterwards I add a tile layer var url = 'https://mapproxy.bba.atenekom.eu/tms/1.0.0/privat_alle_50_mbit/germany/{z}/{x}/{y}.png'; var tileLayer = L.tileLayer( url, { tms: true, crs: rs25832, continuousWorld: true, maxZoom: resolutions.length } ); And add them to the map.. // Setup map var map = L.map('map', { crs: rs25832, center: [ 50.8805, 7.3389 ], zoom:5, maxZoom: resolutions.length, layers: [ baseWms, tileLayer ] }); The bare minimum of code can be found here: https://jsfiddle.net/6gcam7w5/8/
This boils down to how the Y coordinate of TMS tiles is inverted (it becomes higher when going north, as opposed to default TileLayers, in which the Y coordinate becomes larger when going south). Having a look on the Leaflet code that takes care of this specific feature will shed some light on the issue: if (this._map && !this._map.options.crs.infinite) { var invertedY = this._globalTileRange.max.y - coords.y; if (this.options.tms) { data['y'] = invertedY; } data['-y'] = invertedY; } There are two things critical to calculating the right Y coordinate for your tiles here: The CRS must be finite (it must have bounds) There must be a finite global tile range (which in Leaflet is ultimately defined by the CRS bounds and not the TileLayer bounds) Long story short, your CRS should be defined with known bounds. For this particular case, taking information from the TMS capabilities document... <BoundingBox minx="273211.2532533697" miny="5200000.0" maxx="961083.6232988155" maxy="6111822.37943825"/> ...and turned into a L.Bounds definition when defining the Leaflet CRS, like... // Define CRS var rs25832 = new L.Proj.CRS( 'EPSG:25832', proj4rs25832def, { origin: [ 273211.2532533697, 6111822.37943825 ], resolutions: resolutions, bounds: [[273211.2532533697, 5200000],[961083.6232988155, 6111822.37943825]] } ); Stuff should just work (with no need to pass the CRS to the tilelayers, since they will all use the map's), as in this working example.
Fast and smooth scrolling using google maps api possible?
i am working on a react application in which we use google maps. The goal that we want to achieve is to make the map's scrolling as fast and as smooth as possible, and ideally as close to jslancerteam's vector map's scrolling, which can be found here: https://jslancerteam.github.io/crystal-dashboard/#/maps/vector-map . By fast i mean skip some zoom levels per mousewheel scroll as can be seen on the vector map above. I have tried many things i found online but the closest i got was by disabling the default scrollwheel option of the map and trying to make a custom function called on every mousewheel scroll captured by a listener. I have tried two different approaches , one by adding an integer value z as increment to the zoom level of the map (i think we need a value between 2 and 3 levels for each scroll.). I wrote the second approach myself and the idea behind it is to give different multipliers to map's zoom level depending on the value of the current zoom level.( For example having faster zoomIn if you are at a low zoom level and slower if you are already pretty zoomed in. The opposite is true for the zoomOut.). Although i think the second approach has some potential to become smooth, sadly i don't believe the google maps can achieve the same kind of smoothness and fast scrolling as in the vector maps example. In google maps every layer(tiles) is going to be rendered (grey tiles) even if you try to increase the zoom level by a great value on every scroll because they are served on demand. When scrolling the mousewheel gently , the results are close to what we would like. When scrolling the mousewheel very fast, the map tiles don't have time to get rendered On the other hand, we believe that other libraries don't render said layers , and thus they get that smooth feeling. Unfortunately the application is very dependent on the maps and has a lot of it's basic functionality built around them , so we would like to avoid migrating to another maps library if we are able to for now. // Need to point out that in some other point in the app // the scrollwheel map option is set to false and // the max zoom level is set to 22 // //1st Approach // // Having disabled the default scrollwheel i wanted to // make the custom scroll follow the mouse // so i get the coordinates of the mouse through a listener // and then i set it as the center of the map on every scroll maps.event.addListener(map, 'mousemove', function (event) { displayCoordinates(event.latLng); }); let displayCoordinates=function (pnt) { var lat = pnt.lat(); lat = lat.toFixed(4); var lng = pnt.lng(); lng = lng.toFixed(4); mouselatlng =new LatLng(lat,lng) } // the listener used to capture the mousewheel scroll maps.event.addDomListener(map.getDiv(), 'mousewheel', fx); // the function called on every scroll // depending if you zoomIn or zoomOut // a z is added to the zoom level of the map to replicate // the zoom functionality let fx = function(e) { e.preventDefault(); map.setCenter(mouselatlng); //setting the mouse coords as center // // increase by 3 levels on zoomIn : decrease by 3 (-3) on zoomOut // var z = e.wheelDelta > 0 || e.detail < 0 ? 3 : -3; map.setZoom(Math.max(0, Math.min(22, map.getZoom() + z))); setTimeout(()=>{ map.setZoom(map.getZoom + z); },80) return false; }; // //2nd Approach // maps.event.addListener(map, 'mousemove', function (event) { displayCoordinates(event.latLng); }); let displayCoordinates=function (pnt) { var lat = pnt.lat(); lat = lat.toFixed(4); var lng = pnt.lng(); lng = lng.toFixed(4); mouselatlng =new LatLng(lat,lng) } maps.event.addDomListener(map.getDiv(), 'mousewheel', fx); let fx = function(e) { e.preventDefault(); map.setCenter(mouselatlng); // Using different multipliers for some zoom levels // the logic is not finished and the numbers used are not // final. This was a test to see if the multipliers could // make a difference. if(map.getZoom >= 10){ var z = e.wheelDelta > 0 || e.detail < 0 ? 1.1 : 0.91; } else if ((map.getZoom >= 7)){ var z = e.wheelDelta > 0 || e.detail < 0 ? 1.2 : 0.7; }else{ var z = e.wheelDelta > 0 || e.detail < 0 ? 1.4 : 0.6; } map.setZoom(Math.max(0, Math.min(22, map.getZoom() * z))); return false; };
How to change cluster zoom level on click with Leaflet map?
I have a leaflet map that has zoom levels 2-7 and uses the MarkerCluster plugin, by default I have the L.MarkerClusterGroup disable clustering a zoom level 2 (which means no clustering) and I'm trying to allow the user to click a button that then changes the clustering zoom level to 5. Is this possible? I know I could do it by making two markercluster groups, one that has no clustering and one that has clustering and remove/add it based on click but that just seems incredibly messy. Really, there's several ways to do it but they are so incredibly clunky. Code: Default (2 is the lowest level of zoom): var markers = new L.MarkerClusterGroup ( { disableClusteringAtZoom: 2, maxClusterRadius: 100, animateAddingMarkers: true }); What I want to do be able to do: $('#mcluster').click(function() { //do some code that sets the disableClusterAtZoom to 5 });
I could not find a way to disable clustering or set a new value for disableClustering at zoom, but I found a less clunky way of achieving this. var markers = new L.LayerGroup(); //non cluster layer is added to map markers.addTo(map); var clusters = new L.MarkerClusterGroup ( { disableClusteringAtZoom: 5, maxClusterRadius: 100, animateAddingMarkers: true }); //cluster layer is set and waiting to be used var clusterStatus = 'no'; //since non cluster group is on by default, the status for cluster is set to no $('#mcluster').click(function( event ) { if(clusterStatus === 'no'){ clusterStatus = 'yes'; var current1 = markers.getLayers(); //get current layers in markers map.removeLayer(markers); // remove markers from map clusters.clearLayers(); // clear any layers in clusters just in case current1.forEach(function(item) { //loop through the current layers and add them to clusters clusters.addLayer(item); }); map.addLayer(clusters); } else { clusterStatus = 'no'; //we're turning off clustering here var current2 = clusters.getLayers(); //same code as before just reversed map.removeLayer(clusters); markers.clearLayers(); current2.forEach(function(item) { markers.addLayer(item); }); map.addLayer(markers); } }); I'm sure there is a more elegant solution but with my still growing knowledge this is what I came up with.
I know you needed a solution a few months ago, but just to let you know that I released recently a sub-plugin for Leaflet.markercluster that can perform exactly what you are looking for (with a few extra code): Leaflet.MarkerCluster.Freezable (demo here). var mcg = L.markerClusterGroup().addTo(map), disableClusteringAtZoom = 2; function changeClustering() { if (map.getZoom() >= disableClusteringAtZoom) { mcg.disableClustering(); // New method from sub-plugin. } else { mcg.enableClustering(); // New method from sub-plugin. } } map.on("zoomend", changeClustering); $('#mcluster').click(function () { disableClusteringAtZoom = (disableClusteringAtZoom === 2) ? 5 : 2; changeClustering(); }); mcg.addLayers(arrayOfMarkers); // Initially disabled, as if disableClusteringAtZoom option were at 2. changeClustering(); Demo: http://jsfiddle.net/fqnbwg3q/3/ Note: in the above demo I used a refinement to make sure the markers merge with animation when clustering is re-enabled. Simply use a timeout before using enableClustering(): // Use a timeout to trigger clustering after the zoom has ended, // and make sure markers animate. setTimeout(function () { mcg.enableClustering(); }, 0);
mouse position to isometric tile including height
Struggeling translating the position of the mouse to the location of the tiles in my grid. When it's all flat, the math looks like this: this.position.x = Math.floor(((pos.y - 240) / 24) + ((pos.x - 320) / 48)); this.position.y = Math.floor(((pos.y - 240) / 24) - ((pos.x - 320) / 48)); where pos.x and pos.y are the position of the mouse, 240 and 320 are the offset, 24 and 48 the size of the tile. Position then contains the grid coordinate of the tile I'm hovering over. This works reasonably well on a flat surface. Now I'm adding height, which the math does not take into account. This grid is a 2D grid containing noise, that's being translated to height and tile type. Height is really just an adjustment to the 'Y' position of the tile, so it's possible for two tiles to be drawn in the same spot. I don't know how to determine which tile I'm hovering over. edit: Made some headway... Before, I was depending on the mouseover event to calculate grid position. I just changed this to do the calculation in the draw loop itself, and check if the coordinates are within the limits of the tile currently being drawn. creates some overhead tho, not sure if I'm super happy with it but I'll confirm if it works. edit 2018: I have no answer, but since this ha[sd] an open bounty, help yourself to some code and a demo The grid itself is, simplified; let grid = [[10,15],[12,23]]; which leads to a drawing like: for (var i = 0; i < grid.length; i++) { for (var j = 0; j < grid[0].length; j++) { let x = (j - i) * resourceWidth; let y = ((i + j) * resourceHeight) + (grid[i][j] * -resourceHeight); // the "+" bit is the adjustment for height according to perlin noise values } } edit post-bounty: See GIF. The accepted answer works. The delay is my fault, the screen doesn't update on mousemove (yet) and the frame rate is low-ish. It's clearly bringing back the right tile. Source
Intresting task. Lets try to simplify it - lets resolve this concrete case Solution Working version is here: https://github.com/amuzalevskiy/perlin-landscape (changes https://github.com/jorgt/perlin-landscape/pull/1 ) Explanation First what came into mind is: Just two steps: find an vertical column, which matches some set of tiles iterate tiles in set from bottom to top, checking if cursor is placed lower than top line Step 1 We need two functions here: Detects column: function getColumn(mouseX, firstTileXShiftAtScreen, columnWidth) { return (mouseX - firstTileXShiftAtScreen) / columnWidth; } Function which extracts an array of tiles which correspond to this column. Rotate image 45 deg in mind. The red numbers are columnNo. 3 column is highlighted. X axis is horizontal function tileExists(x, y, width, height) { return x >= 0 & y >= 0 & x < width & y < height; } function getTilesInColumn(columnNo, width, height) { let startTileX = 0, startTileY = 0; let xShift = true; for (let i = 0; i < columnNo; i++) { if (tileExists(startTileX + 1, startTileY, width, height)) { startTileX++; } else { if (xShift) { xShift = false; } else { startTileY++; } } } let tilesInColumn = []; while(tileExists(startTileX, startTileY, width, height)) { tilesInColumn.push({x: startTileX, y: startTileY, isLeft: xShift}); if (xShift) { startTileX--; } else { startTileY++; } xShift = !xShift; } return tilesInColumn; } Step 2 A list of tiles to check is ready. Now for each tile we need to find a top line. Also we have two types of tiles: left and right. We already stored this info during building matching tiles set. function getTileYIncrementByTileZ(tileZ) { // implement here return 0; } function findExactTile(mouseX, mouseY, tilesInColumn, tiles2d, firstTileXShiftAtScreen, firstTileYShiftAtScreenAt0Height, tileWidth, tileHeight) { // we built a set of tiles where bottom ones come first // iterate tiles from bottom to top for(var i = 0; i < tilesInColumn; i++) { let tileInfo = tilesInColumn[i]; let lineAB = findABForTopLineOfTile(tileInfo.x, tileInfo.y, tiles2d[tileInfo.x][tileInfo.y], tileInfo.isLeft, tileWidth, tileHeight); if ((mouseY - firstTileYShiftAtScreenAt0Height) > (mouseX - firstTileXShiftAtScreen)*lineAB.a + lineAB.b) { // WOHOO !!! return tileInfo; } } } function findABForTopLineOfTile(tileX, tileY, tileZ, isLeftTopLine, tileWidth, tileHeight) { // find a top line ~~~ a,b // y = a * x + b; let a = tileWidth / tileHeight; if (isLeftTopLine) { a = -a; } let b = isLeftTopLine ? tileY * 2 * tileHeight : - (tileX + 1) * 2 * tileHeight; b -= getTileYIncrementByTileZ(tileZ); return {a: a, b: b}; }
Please don't judge me as I am not posting any code. I am just suggesting an algorithm that can solve it without high memory usage. The Algorithm: Actually to determine which tile is on mouse hover we don't need to check all the tiles. At first we think the surface is 2D and find which tile the mouse pointer goes over with the formula OP posted. This is the farthest probable tile mouse cursor can point at this cursor position. This tile can receive mouse pointer if it's at 0 height, by checking it's current height we can verify if this is really at the height to receive pointer, we mark it and move forward. Then we find the next probable tile which is closer to the screen by incrementing or decrementing x,y grid values depending on the cursor position. Then we keep on moving forward in a zigzag fashion until we reach a tile which cannot receive pointer even if it is at it's maximum height. When we reach this point the last tile found that were at a height to receive pointer is the tile that we are looking for. In this case we only checked 8 tiles to determine which tile is currently receiving pointer. This is very memory efficient in comparison to checking all the tiles present in the grid and yields faster result.
One way to solve this would be to follow the ray that goes from the clicked pixel on the screen into the map. For that, just determine the camera position in relation to the map and the direction it is looking at: const camPos = {x: -5, y: -5, z: -5} const camDirection = { x: 1, y:1, z:1} The next step is to get the touch Position in the 3D world. In this certain perspective that is quite simple: const touchPos = { x: camPos.x + touch.x / Math.sqrt(2), y: camPos.y - touch.x / Math.sqrt(2), z: camPos.z - touch.y / Math.sqrt(2) }; Now you just need to follow the ray into the layer (scale the directions so that they are smaller than one of your tiles dimensions): for(let delta = 0; delta < 100; delta++){ const x = touchPos.x + camDirection.x * delta; const y = touchPos.y + camDirection.y * delta; const z = touchPos.z + camDirection.z * delta; Now just take the tile at xz and check if y is smaller than its height; const absX = ~~( x / 24 ); const absZ = ~~( z / 24 ); if(tiles[absX][absZ].height >= y){ // hanfle the over event }
I had same situation on a game. first I tried with mathematics, but when I found that the clients wants to change the map type every day, I changed the solution with some graphical solution and pass it to the designer of the team. I captured the mouse position by listening the SVG elements click. the main graphic directly used to capture and translate the mouse position to my required pixel. https://blog.lavrton.com/hit-region-detection-for-html5-canvas-and-how-to-listen-to-click-events-on-canvas-shapes-815034d7e9f8 https://code.sololearn.com/Wq2bwzSxSnjl/#html
Here is the grid input I would define for the sake of this discussion. The output should be some tile (coordinate_1, coordinate_2) based on visibility on the users screen of the mouse: I can offer two solutions from different perspectives, but you will need to convert this back into your problem domain. The first methodology is based on coloring tiles and can be more useful if the map is changing dynamically. The second solution is based on drawing coordinate bounding boxes based on the fact that tiles closer to the viewer like (0, 0) can never be occluded by tiles behind it (1,1). Approach 1: Transparently Colored Tiles The first approach is based on drawing and elaborated on here. I must give the credit to #haldagan for a particularly beautiful solution. In summary it relies on drawing a perfectly opaque layer on top of the original canvas and coloring every tile with a different color. This top layer should be subject to the same height transformations as the underlying layer. When the mouse hovers over a particular layer you can detect the color through canvas and thus the tile itself. This is the solution I would probably go with and this seems to be a not so rare issue in computer visualization and graphics (finding positions in a 3d isometric world). Approach 2: Finding the Bounding Tile This is based on the conjecture that the "front" row can never be occluded by "back" rows behind it. Furthermore, "closer to the screen" tiles cannot be occluded by tiles "farther from the screen". To make precise the meaning of "front", "back", "closer to the screen" and "farther from the screen", take a look at the following: . Based on this principle the approach is to build a set of polygons for each tile. So firstly we determine the coordinates on the canvas of just box (0, 0) after height scaling. Note that the height scale operation is simply a trapezoid stretched vertically based on height. Then we determine the coordinates on the canvas of boxes (1, 0), (0, 1), (1, 1) after height scaling (we would need to subtract anything from those polygons which overlap with the polygon (0, 0)). Proceed to build each boxes bounding coordinates by subtracting any occlusions from polygons closer to the screen, to eventually get coordinates of polygons for all boxes. With these coordinates and some care you can ultimately determine which tile is pointed to by a binary search style through overlapping polygons by searching through bottom rows up.
It also matters what else is on the screen. Maths attempts work if your tiles are pretty much uniform. However if you are displaying various objects and want the user to pick them, it is far easier to have a canvas-sized map of identifiers. function poly(ctx){var a=arguments;ctx.beginPath();ctx.moveTo(a[1],a[2]); for(var i=3;i<a.length;i+=2)ctx.lineTo(a[i],a[i+1]);ctx.closePath();ctx.fill();ctx.stroke();} function circle(ctx,x,y,r){ctx.beginPath();ctx.arc(x,y,r,0,2*Math.PI);ctx.fill();ctx.stroke();} function Tile(h,c,f){ var cnv=document.createElement("canvas");cnv.width=100;cnv.height=h; var ctx=cnv.getContext("2d");ctx.lineWidth=3;ctx.lineStyle="black"; ctx.fillStyle=c;poly(ctx,2,h-50,50,h-75,98,h-50,50,h-25); poly(ctx,50,h-25,2,h-50,2,h-25,50,h-2); poly(ctx,50,h-25,98,h-50,98,h-25,50,h-2); f(ctx);return ctx.getImageData(0,0,100,h); } function put(x,y,tile,image,id,map){ var iw=image.width,tw=tile.width,th=tile.height,bdat=image.data,fdat=tile.data; for(var i=0;i<tw;i++) for(var j=0;j<th;j++){ var ijtw4=(i+j*tw)*4,a=fdat[ijtw4+3]; if(a!==0){ var xiyjiw=x+i+(y+j)*iw; for(var k=0;k<3;k++)bdat[xiyjiw*4+k]=(bdat[xiyjiw*4+k]*(255-a)+fdat[ijtw4+k]*a)/255; bdat[xiyjiw*4+3]=255; map[xiyjiw]=id; } } } var cleanimage; var pickmap; function startup(){ var water=Tile(77,"blue",function(){}); var field=Tile(77,"lime",function(){}); var tree=Tile(200,"lime",function(ctx){ ctx.fillStyle="brown";poly(ctx,50,50,70,150,30,150); ctx.fillStyle="forestgreen";circle(ctx,60,40,30);circle(ctx,68,70,30);circle(ctx,32,60,30); }); var sheep=Tile(200,"lime",function(ctx){ ctx.fillStyle="white";poly(ctx,25,155,25,100);poly(ctx,75,155,75,100); circle(ctx,50,100,45);circle(ctx,50,80,30); poly(ctx,40,70,35,80);poly(ctx,60,70,65,80); }); var cnv=document.getElementById("scape"); cnv.width=500;cnv.height=400; var ctx=cnv.getContext("2d"); cleanimage=ctx.getImageData(0,0,500,400); pickmap=new Uint8Array(500*400); var tiles=[water,field,tree,sheep]; var map=[[[0,0],[1,1],[1,1],[1,1],[1,1]], [[0,0],[1,1],[1,2],[3,2],[1,1]], [[0,0],[1,1],[2,2],[3,2],[1,1]], [[0,0],[1,1],[1,1],[1,1],[1,1]], [[0,0],[0,0],[0,0],[0,0],[0,0]]]; for(var x=0;x<5;x++) for(var y=0;y<5;y++){ var desc=map[y][x],tile=tiles[desc[0]]; put(200+x*50-y*50,200+x*25+y*25-tile.height-desc[1]*20, tile,cleanimage,x+1+(y+1)*10,pickmap); } ctx.putImageData(cleanimage,0,0); } var mx,my,pick; function mmove(event){ mx=Math.round(event.offsetX); my=Math.round(event.offsetY); if(mx>=0 && my>=0 && mx<cleanimage.width && my<cleanimage.height && pick!==pickmap[mx+my*cleanimage.width]) requestAnimationFrame(redraw); } function redraw(){ pick=pickmap[mx+my*cleanimage.width]; document.getElementById("pick").innerHTML=pick; var ctx=document.getElementById("scape").getContext("2d"); ctx.putImageData(cleanimage,0,0); if(pick!==0){ var temp=ctx.getImageData(0,0,cleanimage.width,cleanimage.height); for(var i=0;i<pickmap.length;i++) if(pickmap[i]===pick) temp.data[i*4]=255; ctx.putImageData(temp,0,0); } } startup(); // in place of body.onload <div id="pick">Move around</div> <canvas id="scape" onmousemove="mmove(event)"></canvas> Here the "id" is a simple x+1+(y+1)*10 (so it is nice when displayed) and fits into a byte (Uint8Array), which could go up to 15x15 display grid already, and there are wider types available too. (Tried to draw it small, and it looked ok on the snippet editor screen but apparently it is still too large here)
Computer graphics is fun, right? This is a special case of the more standard computational geometry "point location problem". You could also express it as a nearest neighbour search. To make this look like a point location problem you just need to express your tiles as non-overlapping polygons in a 2D plane. If you want to keep your shapes in a 3D space (e.g. with a z buffer) this becomes the related "ray casting problem". One source of good geometry algorithms is W. Randolf Franklin's website and turf.js contains an implementation of his PNPOLY algorithm. For this special case we can be even faster than the general algorithms by treating our prior knowledge about the shape of the tiles as a coarse R-tree (a type of spatial index).
ArcGIS Javascript - Zoom to show all points
I am trying to add some functionality that will zoom the map in/out depending on the points that are returned from a query. So for example, say we're zoomed in on the state of Texas. If I execute a query and the service returns back points that are in Texas AND some located in California, I would like the map to then zoom out and then display both California and Texas. I have been looking through the ArcGIS JS API to see how I could implement it but I'm having trouble figuring out what properties and/or methods to use to accomplish this.
The FeatureSet provided to the QueryTask's onComplete callback has the property features that is an array of Graphics. The javascript api provides the esri.graphicsExtent(graphics) function that can accept that array of Graphics and calculate their extent. Once the extent has been calculated, map.setExtent(extent) can be used to zoom the map to that extent. It should be noted that the documentation for esri.graphicsExtent(...) specifies that 'If the extent height and width are 0, null is returned.' This case will occur if the returned Graphics array only has a single point in it, so you'll want to check for it. Here's an example QueryTask onComplete callback that could be used to zoom the map to the extents of points returned by the query: function onQueryComplete(returnedPointFeatureSet){ var featureSet = returnedPointFeatureSet || {}; var features = featureSet.features || []; var extent = esri.graphicsExtent(features); if(!extent && features.length == 1) { // esri.getExtent returns null for a single point, so we'll build the extent by hand by subtracting/adding 1 to create x and y min/max values var point = features[0]; extent = new esri.geometry.Extent(point.x - 1, point.y - 1, point.x + 1, point.y + 1, point.spatialReference); } if(extent) { // assumes the esri map object is stored in the globally-scoped variable 'map' map.setExtent(extent) } }
I agree, map.setExtent(extent, true) is the way to go here. Another observation: In case we have only a single point it's worth considering simply using map.centerAndZoom(point, ZOOM_LEVEL) instead of creating an extent. Then, we could just have this: function onQueryComplete(returnedPointFeatureSet){ var featureSet = returnedPointFeatureSet || {}; var features = featureSet.features || []; var extent = esri.graphicsExtent(features); if(!extent && features.length == 1) { var point = features[0]; map.centerAndZoom(point, 12); } else { map.setExtent(extent, true); } }
Not a good idea to create an extent from a point that way. If the units are in degrees you could get a huge extent. Instead, you could do a buffer around the point using the geometryEngine function onQueryComplete(featureSet){ if (featureSet.features.length) { var extent = esri.graphicsUtils.graphicsExtent(featureSet.features); if(!extent && featureSet.features.length == 1 && featureSet.features[0].geometry.type == "point") { var point = featureSet.features[0]; var extent = esri.geometry.geometryEngine.buffer(point.geometry, 1, "meters").getExtent(); } // assumes the esri map object is stored in the globally-scoped variable 'map' map.setExtent(extent) } }