-
Notifications
You must be signed in to change notification settings - Fork 6
/
iNat_UTFGrid_data_interpreter.html
352 lines (331 loc) · 21 KB
/
iNat_UTFGrid_data_interpreter.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, minimum-scale=1.0" />
<meta name="description" content="iNaturalist UTFGrid Data Interpreter" />
<title>iNaturalist UTFGrid Data Interpreter</title>
<style>
:root {
background: var(--color-base);
color: var(--color-text);
font: 14px Sans-Serif;
--color-base: white;
--color-alt: whitesmoke;
--color-brand: forestgreen;
--color-text: black;
--color-text-invert: white;
--color-text-link: royalblue;
--color-border: lightgray;
--color-hover: lightgray;
--color-base-translucent: rgba(255,255,255,0.85);
--color-invisible: rgba(255,255,255,0);
--filter-invert-value: 0%;
}
@media (prefers-color-scheme: dark) {
:root {
--color-base: black;
--color-alt: #171717;
--color-brand: forestgreen;
--color-text: #bababa;
--color-text-invert: black;
--color-text-link: cornflowerblue;
--color-border: #444;
--color-hover: #444;
--color-base-translucent: rgba(0,0,0,0.85);
--filter-invert-value: 100%;
}
}
#main { width:100%; }
table, td, th { border-collapse:collapse; margin:0; padding:4px; }
th { position:-webkit-sticky /*Safari*/; position:sticky; top:0; font-weight:600; background:var(--color-brand); color:var(--color-text-invert); text-align:left; vertical-align:bottom; }
tbody>tr { border-width:1px 0px; border-style:solid; border-color:var(--color-border); background:var(--color-alt);}
tr:nth-child(even) { background:var(--color-base); }
.tar { text-align:right; }
.car { text-align:center; }
.icon { height:48px; width:48px; border-radius:50%; }
.photo { height:64px; width:64px; }
img { margin:0; padding:0; border:0; }
a { text-decoration:none; color:var(--color-text-link); }
a:hover { background:var(--color-hover); }
#mapdiv { background:var(--color-alt); position:relative; }
#maptilebase { filter:grayscale(100%) invert(var(--filter-invert-value)); position:absolute; top:0; left:0; height:100%; width:100%; }
#maptileover { visibility:hidden; position:absolute; top:0; left:0; height:100%; width:100%; }
#mapgrid { height:100%; width:100%; position:absolute; top:0; left:0; padding:0; margin:0; border-collapse:collapse; }
#mapgrid td { padding:0; margin:0; border-width:1px; border-style:dotted; text-align:center; vertical-align:middle; }
.mgcharon td { color:var(--color-text); }
.mgcharoff td { color:var(--color-invisible); }
.mglineson td { border-color:var(--color-text); }
.mglinesoff td { border-color:var(--color-invisible); }
.mapsel { display:none; position:absolute; top:-1px; left:-1px; border:2px solid var(--color-brand); }
#infodiv { height:100%; width:calc(100% / 2); position:absolute; top:0; left:100%; }
#infodiv h2 { margin-top:5px; margin-bottom:10px; }
#infodiv table { margin-top:0px; margin-bottom:20px; }
</style>
</head>
<body>
<script>
let winurlstr = window.location.href;
let winurlsearchstr = window.location.search;
let winurlexsearchstr = winurlstr.replace(winurlsearchstr,'');
let winurlparams = new URLSearchParams(winurlsearchstr.substring(1));
let p_x = winurlparams.get('x');
let p_y = winurlparams.get('y');
let p_z = winurlparams.get('z');
if (p_x) { p_x = Number(p_x) };
if (p_y) { p_y = Number(p_y) };
if (p_z) { p_z = Number(p_z) };
winurlparams.delete('x');
winurlparams.delete('y');
winurlparams.delete('z');
//let p_per_page = winurlparams.get('per_page') || 10;
//let p_page = winurlparams.get('page') || 1;
function fdate(str,dateonly=false) {
str = str.replace(/t/i,' '); //replaces T (case insensitive) with a space
if (dateonly) { str = str.split(' ')[0]; }
else {
str = str.replace(/([+-]\d{2}\:?\d{2})/,' ($1)'); //puts parenthesis around time zone offset
str = str.replace(/z/i,' (+00:00)'); //replaces Z (case insensitve) with UTC
str = str.replace('+00:00','±00:00');
};
return str;
};
function furl(url,txt=url) { return '<a href="'+url+'">'+txt+'</a>'; };
function famp(str) { return str.replace(/&/g,'&'); };
function fcomnum(n) { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g,',') };
function fround(num,places) {
let n = num*1;
return n.toFixed(places);
};
function faddelem(etype,eparent=null,eattributes={}) {
let eobj = document.createElement(etype);
for (let [key,value] of Object.entries(eattributes)) {
if ( typeof value === 'object' && value !== null ) {
for (let [subkey,subvalue] of Object.entries(value)) { eobj[key][subkey] = subvalue; };
}
else { eobj[key] = value; };
};
if (eparent) { eparent.appendChild(eobj); };
return eobj;
};
function faddelems(etype,eparent=null,eattributes=[]) { for (let e of eattributes) { faddelem(etype,eparent,e); }; };
function faddinfotr(parent,label,value) {
let infotr = faddelem('tr',parent);
faddelems('td',infotr,[label,value]);
};
function faddoptioncheckbox(parent,label,name,value=true) {
let infotr = faddelem('tr',parent);
faddelems('td',infotr,[{},{}]);
faddelem('label',infotr.childNodes[0],{htmlFor:name,innerText:label});
return faddelem('input',infotr.childNodes[1],{type:'checkbox',id:name,name:name,checked:value});
};
function futfgridlink(xyzobj) { return (xyzobj && !xyzobj.exceedsMaxZoom)?furl(`${winurlexsearchstr}?z=${xyzobj.z}&x=${xyzobj.x}&y=${xyzobj.y}&${winurlparams}`,`z:${xyzobj.z}, x:${xyzobj.x}, y:${xyzobj.y}`):'-'; };
function furlparams(url,params={}) {
for (param of Object.entries(params)) { url = url.replace(`{${param[0]}}`,param[1]); };
return url;
};
function flngfromgrid(x,z) { return (x/Math.pow(2,z)*360-180); };
function flatfromgrid(y,z) {
let n = Math.PI-2*Math.PI*y/Math.pow(2,z);
return (180/Math.PI*Math.atan(0.5*(Math.exp(n)-Math.exp(-n))));
};
function fexplorelink(x,y,z){
let addtl_params = new URLSearchParams(winurlparams);
addtl_params.set('nelat',flatfromgrid(y,z));
addtl_params.set('nelng',flngfromgrid(x+1,z));
addtl_params.set('swlat',flatfromgrid(y+1,z));
addtl_params.set('swlng',flngfromgrid(x,z));
if (addtl_params.get('place_id')===null) {addtl_params.set('place_id','any')};
if (addtl_params.get('verifiable')===null) {addtl_params.set('verifiable','any')};
return `https://www.inaturalist.org/observations?${addtl_params}`;
};
function fresults(xobj) {
//faddelem('p',document.body,{innerHTML:furl(famp(apiurl))});
let utfgrid = xobj;
if (utfgrid) {
//although a standard UTFgrid is 64x64, iNat's "grid"-style map tile implementation actually visualizes data in a 32x32 grid.
//theoretically, a 2x2 block of cells from the 64x64 UTFgrid should exactly correspond to a single cell from the 32x32 "grid"-style tile grid; however, that is not actually the case (see https://forum.inaturalist.org/t/open-test-of-map-tile-improvements/7833/88).
//so we need to take the bottom-right cell from each 2x2 block of UTFgrid cells and use that cell specifically to effectively represent the corresponding "grid"-style map tile cell.
let gridsize = {px:256, vizzoom:3, base:64, basetoeff:2};
gridsize.eff = gridsize.base/gridsize.basetoeff;
gridsize.effpx = gridsize.px/gridsize.eff;
gridsize.effzstep = Math.log2(gridsize.eff);
gridsize.vizpx = gridsize.px*gridsize.vizzoom;
gridsize.vizcellpx = gridsize.effpx*gridsize.vizzoom-1;
//faddelem('h2',document.body,{innerText:'Visualization'});
//we'll prefer the Standard OSM tiles in most cases, but because it is only served to zoom level 19, we'll fall back to OSM Deutschland at level 20
let basemap_osm_std = {url:'https://tile.openstreetmap.org/{z}/{x}/{y}.png', maxZoom:19, attribution:'© <a href="https://osm.org/copyright">OpenStreetMap</a>/ODbL from <a href="https://osm.org/">OpenStreetMap</a>'};
let basemap_osm_de = {url:'https://tile.openstreetmap.de/{z}/{x}/{y}.png', attribution:'© <a href="https://osm.org/copyright">OpenStreetMap</a>/ODbL from <a href="https://openstreetmap.de/">OpenStreetMap Deutschland</a>'};
//let basemap_otm = {url:'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',attribution:'Kartendaten: © <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende, SRTM | Kartendarstellung: © <a href="http://opentopomap.org/">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)'};
let mapdiv = faddelem('div',document.body,{id:'mapdiv',style:{height:gridsize.vizpx+'px',width:gridsize.vizpx+'px'}});
let maptilebase = faddelem('img',mapdiv,{id:'maptilebase',src:furlparams((p_z<=basemap_osm_std.maxZoom)?basemap_osm_std.url:basemap_osm_de.url,{z:p_z,x:p_x,y:p_y})});
let maptileover = faddelem('img',mapdiv,{id:'maptileover',src:furlparams(`https://api.inaturalist.org/v1/grid/{z}/{x}/{y}.png${((winurlparams!='')?('?'+winurlparams):'')}`,{z:p_z,x:p_x,y:p_y})});
let mapsel = {
x: faddelem('div',mapdiv,{classList:'mapsel',id:'mapselx',style:{height:'calc(100% - 1px)',width:gridsize.vizcellpx+'px'}}),
y: faddelem('div',mapdiv,{classList:'mapsel',id:'mapsely',style:{width:'calc(100% - 1px)',height:gridsize.vizcellpx+'px'}})
};
let mapgrid = faddelem('table',mapdiv,{classList:'mglineson mgcharon',id:'mapgrid'});
for (let gy=0; gy<gridsize.eff; gy++) {
let mgrow = faddelem('tr',mapgrid,{style:{padding:0,margin:0,background:'none'}});
for (let gx=0; gx<gridsize.eff; gx++) {
faddelem('td',mgrow,{innerText:'',x:gx,y:gy,style:{height:gridsize.vizcellpx+'px',width:gridsize.vizcellpx+'px',fontSize:gridsize.vizcellpx-10+'px'}});
};
};
let infodiv = faddelem('div',mapdiv,{id:'infodiv',style:{marginLeft:gridsize.vizcellpx*0.6+'px'}});
let maxZoom = 20;
let arrayCount = Object.values(utfgrid.data).map(d => d.cellCount);
utfgrid.cellCountTotal = arrayCount.reduce((a,b) => a+b,0);
utfgrid.cellCountMax = Math.max(...arrayCount);
faddelem('h2',infodiv,{innerText:'Options'});
let optionstable = faddelem('table',infodiv,{id:'optionstable'});
let optionstbody = faddelem('tbody',optionstable,{id:'optionstbody'});
let options = {
showchar: faddoptioncheckbox(optionstbody,'Show UTF-8 Char','showchar'),
showlines: faddoptioncheckbox(optionstbody,'Show Grid Lines','showgrid'),
showoverlay: faddoptioncheckbox(optionstbody,'Show iNat Obs Grid','showoverlay',false),
showbasemap: faddoptioncheckbox(optionstbody,'Show Basemap','showbasemap'),
};
options.showchar.addEventListener('change', function() {
mapgrid.classList.add((this.checked)?'mgcharon':'mgcharoff');
mapgrid.classList.remove(!(this.checked)?'mgcharon':'mgcharoff');
});
options.showlines.addEventListener('change', function() {
mapgrid.classList.add((this.checked)?'mglineson':'mglinesoff');
mapgrid.classList.remove(!(this.checked)?'mglineson':'mglinesoff');
});
options.showbasemap.addEventListener('change', function() { maptilebase.style.visibility=(this.checked)?'visible':'hidden'; });
options.showoverlay.addEventListener('change', function() { maptileover.style.visibility=(this.checked)?'visible':'hidden'; });
faddelem('h2',infodiv,{innerText:'Grid Info'});
let infotablegrid = faddelem('table',infodiv,{id:'infotablegrid'});
let infotbodygrid = faddelem('tbody',infotablegrid,{id:'infotbodygrid'});
faddinfotr(infotbodygrid,{innerText:'Z Index (Zoom Level)'},{innerText:p_z});
faddinfotr(infotbodygrid,{innerText:'X Index'},{innerText:p_x});
faddinfotr(infotbodygrid,{innerText:'Y Index'},{innerText:p_y});
faddinfotr(infotbodygrid,{innerText:'Total Observations'},{innerHTML:furl(fexplorelink(p_x,p_y,p_z),fcomnum(utfgrid.cellCountTotal))});
faddinfotr(infotbodygrid,{innerText:'Max Obs Per Cell'},{innerText:fcomnum(utfgrid.cellCountMax)});
let zoomoututfgrid = (p_z-1 < 0 ) ? null : {z:p_z-1, x:Math.floor(Number(p_x)/2), y:Math.floor(p_y/2)};
faddinfotr(infotbodygrid,{innerText:'Parent (Z-1) UTFGrid'},{innerHTML:futfgridlink(zoomoututfgrid)});
faddelem('h2',infodiv,{innerText:'Cell Info'});
let infotablecell = faddelem('table',infodiv,{id:'infotablecell'});
let infotbodycell = faddelem('tbody',infotablecell,{id:'infotbodycell',innerHTML:'< Select a grid cell to view its details.'});
faddelem('p',infodiv,{style:{position:'absolute',bottom:0, marginBottom:0},innerHTML:`<a href="${furlparams((p_z<=basemap_osm_std.maxZoom)?basemap_osm_std.url:basemap_osm_de.url,{z:p_z,x:p_x,y:p_y})}">Basemap</a> ${(p_z<=basemap_osm_std.maxZoom)?basemap_osm_std.attribution:basemap_osm_de.attribution}`});
faddelem('h2',document.body,{style:{marginTop:gridsize.vizcellpx*1.25+'px'},innerText:'Details for All Cells in Grid'});
let table = faddelem('table',document.body,{id:'main'});
let thead = faddelem('thead',table);
let hrow = faddelem('tr',thead);
let labels = [
{innerText:'X Index'},
{innerText:'Y Index'},
{innerText:'UTF-8 Char'},
{innerText:'UTF-8 Code'},
{classList:'tar',innerText:'Obs Count'},
{classList:'tar',innerText:'% of Max in Grid'},
{classList:'tar',innerText:'% of Total in Grid'},
//{innerText:'N Lat'},
//{innerText:'E Lng'},
//{innerText:'S Lat'},
//{innerText:'W Lng'},
{classList:'car',innerText:`Equiv (Z+${gridsize.effzstep}) UTFGrid`},
{classList:'car',innerText:'Z+1 UTFGrid'},
{innerText:'Center Lat'},
{innerText:'Center Lng'},
//{innerText:'Center Lat (calc)'},
//{innerText:'Center Lng (Calc)'},
{innerText:'Latest Obs ID'},
];
faddelems('th',hrow,labels);
let tbody = faddelem('tbody',table);
for (let cy=0; cy<gridsize.eff; cy++) {
for (let cx=0; cx<gridsize.eff; cx++) {
let cell = {
eff: {x:cx, y:cy},
base: {x:cx*gridsize.basetoeff+1, y:cy*gridsize.basetoeff+1}
};
//for details about decoding the UTFgrid, see https://github.com/mapbox/utfgrid-spec/blob/master/1.2/utfgrid.md
cell.char = utfgrid.grid[cell.base.y].substring(cell.base.x,cell.base.x+1);
cell.charCode = utfgrid.grid[cell.base.y].charCodeAt(cell.base.x);
let i = cell.charCode-((cell.charCode>=93)?34:(cell.charCode>=35)?33:32);
cell.data = utfgrid.data[utfgrid.keys[i]];
let equivutfgrid = {
z:p_z+gridsize.effzstep,
x:p_x*Math.pow(2,gridsize.effzstep)+cell.eff.x,
y:p_y*Math.pow(2,gridsize.effzstep)+cell.eff.y,
exceedsMaxZoom:((p_z+gridsize.effzstep) > maxZoom)?true:false,
};
let zoominutfgrid = ((p_z+1) > maxZoom) ? null : {
z:p_z+1,
x:p_x*2+Math.round(cell.eff.x/gridsize.eff),
y:p_y*2+Math.round(cell.eff.y/gridsize.eff),
};
let brow = faddelem('tr',tbody);
let values = [
{innerText:cell.eff.x},
{innerText:cell.eff.y},
{innerText:cell.char},
{innerText:cell.charCode},
{classList:'tar',innerHTML:furl(fexplorelink(equivutfgrid.x,equivutfgrid.y,equivutfgrid.z),((cell.data)?fcomnum(cell.data?.cellCount):'-'))},
{classList:'tar',innerText:(cell.data)?fround((cell.data?.cellCount)/utfgrid.cellCountMax*100,7):'-'},
{classList:'tar',innerText:(cell.data)?fround((cell.data?.cellCount)/utfgrid.cellCountTotal*100,9):'-'},
{classList:'car',innerHTML:futfgridlink(equivutfgrid)},
{classList:'car',innerHTML:futfgridlink(zoominutfgrid)},
{innerText:cell.data?.latitude??''},
{innerText:cell.data?.longitude??''},
//{innerText:flatfromgrid(p_y*Math.pow(2,gridsize.effzstep)+cell.eff.y+0.5,p_z+gridsize.effzstep)},
//{innerText:flngfromgrid(p_x*Math.pow(2,gridsize.effzstep)+cell.eff.x+0.5,p_z+gridsize.effzstep)},
{innerHTML:(cell.data)?furl(`https://www.inaturalist.org/observations/${cell.data.id}`,cell.data.id):''},
];
faddelems('td',brow,values);
let mgcell = mapgrid.childNodes[cell.eff.y].childNodes[cell.eff.x];
mgcell.innerText = cell.char;
let mgcellcolor = `hsla(120,100%,50%,${cell.data?cell.data.cellCount/utfgrid.cellCountMax:0})`;
mgcell.style.background = mgcellcolor;
mgcell.addEventListener('mouseover', function() { this.style.background = 'yellow'; });
mgcell.addEventListener('mouseout', function() { this.style.background = mgcellcolor; });
mgcell.addEventListener('click', function() {
infotbodycell.innerHTML = '';
for (let c=0; c<hrow.childNodes.length; c++) {
faddinfotr(infotbodycell,{innerHTML:hrow.childNodes[c].innerHTML},{innerHTML:tbody.childNodes[this.y*gridsize.eff+this.x].childNodes[c].innerHTML});
};
mapsel.x.style.display='block';
mapsel.y.style.display='block';
mapsel.x.style.left=cell.eff.x*gridsize.effpx*gridsize.vizzoom-1+'px';
mapsel.y.style.top=cell.eff.y*gridsize.effpx*gridsize.vizzoom-1+'px';
});
};
};
}
else { faddelem('p',document.body,{innerText:'No results returned.'}); };
};
let apibase = 'https://api.inaturalist.org/v1/grid/{z}/{x}/{y}.grid.json';
let apiurl = `${furlparams(apibase,{z:p_z,x:p_x,y:p_y})}${((winurlparams!='')?('?'+winurlparams):'')}`
let apirefurl = 'https://api.inaturalist.org/v1/docs/#!/UTFGrid/get_grid_zoom_x_y_grid_json';
let apirefname = 'iNaturalist "Grid"-style UTFGrid';
let apiref = furl(apirefurl,apirefname);
faddelem('h1',document.body,{innerText:'iNaturalist UTFGrid Data Interpreter'});
//if (winurlsearchstr==='') {
if ([p_z,p_x,p_y].includes(null)) {
let instructions = [
{innerHTML:`This page translates the response from the ${apiref} API endpoint into a human-friendly format to help visualize the underlying data in a corresponding <a href='https://api.inaturalist.org/v1/docs/#!/Observation_Tiles/get_grid_zoom_x_y_png'>"Grid"-style Observation Map Tile</a>.`},
{innerHTML:`To use this page, you must specify x, y, and z parameters in the URL. You may also add other query parameters to filter for specific observations. (See ${furl(apirefurl)} for available parameters.)`},
{innerHTML:`For example, suppose you want to interpret the data from the level 0 (whole world) UTFGrid (Z=0, X=0, Y=0), filtered for only bird observations (taxon_id=3). Then you would open ${furl(famp(winurlexsearchstr+'?z=0&x=0&y=0&taxon_id=3'))} in your browser.`},
{innerHTML:`Usually, UTFGrids are meant only to provide a way for users to interact with online maps. However, iNaturalist includes observation count in its UTFGrid data, and one of the available the map tile styles visualizes data in a grid layout. So the UTFGrids associated with the "Grid"-style map tiles can be particularly useful for more than just user interaction. The main use case for interpreting the data from these UTFGrids is to make custom map visualizations involving lots of iNatualist observations, without having to download massive sets of individual observations. It should be noted that the numbers from a given UTFGrid won't always tie exactly with observation counts reported by the system, nor between parent and child UTFGrids, but the UTFGrid data will still be good enough for most general visualization purposes.`},
{innerHTML:`A general primer for XYZ map tiles is available here: ${furl('https://forum.inaturalist.org/t/in-pursuit-of-mappiness-part-1/21864#what-are-map-tiles-3')}. A good reference for UTFGrids is here: ${furl('https://github.com/mapbox/utfgrid-spec/blob/master/1.2/utfgrid.md')}. It's worth noting that UTFGrids are technically 64x64 grids, but the "Grid"-style map tiles in iNaturalist visualize data in 32x32 grids. So this page maps the data from the bottom-right cell from each 2x2 block of cells in the 64x64 UTFGrid to the corresponding 32x32 grid.`},
];
faddelems('p',document.body,instructions);
}
else {
faddelem('p',document.body,{innerHTML:'This is the base query: '+furl(famp(apiurl))+'. (This page will accept most parameters from the '+apiref+' API endpoint.)'});
fetch(apiurl)
.then((response) => {
if (!response.ok) { throw new Error(response.status+' ('+response.statusText+') returned from '+response.url); };
return response.json();
})
.then((data) => { fresults(data); })
.catch((err) => {
console.error(err.message);
faddelem('p',document.body,{innerText:'There was a problem retrieving data. Error '+err.message+'.'});
});
};
</script>
</body>
</html>