Skip to content

Instantly share code, notes, and snippets.

@vincentsarago
Created February 4, 2022 22:07
Show Gist options
  • Save vincentsarago/33810b53dbafc6ec81ea6d1691ec7595 to your computer and use it in GitHub Desktop.
Save vincentsarago/33810b53dbafc6ec81ea6d1691ec7595 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>Simple STAC API Viewer</title>
<meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
<script src='https://api.tiles.mapbox.com/mapbox-gl-js/v1.6.1/mapbox-gl.js'></script>
<link href='https://api.tiles.mapbox.com/mapbox-gl-js/v1.6.1/mapbox-gl.css' rel='stylesheet' />
<link rel='stylesheet' href='https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.2.0/mapbox-gl-draw.css' type='text/css' />
<script src='https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.2.0/mapbox-gl-draw.js'></script>
<link href='https://api.mapbox.com/mapbox-assembly/v0.23.2/assembly.min.css' rel='stylesheet'>
<script src='https://api.mapbox.com/mapbox-assembly/v0.23.2/assembly.js'></script>
<script src='https://unpkg.com/[email protected]/builds/index.js'></script>
<style>
body { margin: 0; padding: 0; width: 100%; height: 100%;}
#map {
position: fixed;
left: 600px;
width: calc(100% - 600px);
height: 100%;
background-color: #000;
}
#menu {
position: fixed;
left: 0;
width: 600px;
height: 100%;
-o-transition: all .5s ease;
-webkit-transition: all .5s ease;
-moz-transition: all .5s ease;
-ms-transition: all .5s ease;
transition: all ease .5s;
background-color: #FFF;
}
.loading-map {
position: absolute;
width: 100%;
height: 100%;
color: #FFF;
background-color: #000;
text-align: center;
opacity: 0.5;
font-size: 45px;
}
.loading-map.off{
opacity: 0;
-o-transition: all .5s ease;
-webkit-transition: all .5s ease;
-moz-transition: all .5s ease;
-ms-transition: all .5s ease;
transition: all ease .5s;
visibility:hidden;
}
.middle-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.middle-center * {
display: block;
padding: 5px;
}
#toolbar {
height: 35px;
}
#toolbar li {
display: block;
color: #fff;
background-color: #556671;
font-weight: 700;
font-size: 12px;
padding: 5px;
height: 100%;
width: 100%;
text-transform: uppercase;
text-align: center;
text-decoration: none;
outline: 0;
cursor: pointer;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#toolbar li svg {
font-size: 25px;
line-height: 25px;
padding-bottom: 0;
}
#toolbar li:hover {
background-color: #28333b;
}
#toolbar li.active {
color: #000;
background-color: #fff;
}
#toolbar li.disabled {
pointer-events:none;
opacity:0.4;
}
#menu-content {
height: calc(100% - 35px);
}
#menu-content section {
display: none;
position: relative;
height: 100%;
overflow-y: scroll;
}
#menu-content section.active {
display: inherit;
}
#results-items {
text-align: left;
color: #393939;
}
#results-items .list-element .col{
border-top: 1px solid #bfbfbf;
}
#results-items .list-element .col {
color: #393939;
cursor: pointer;
}
#results-items .list-element .col:hover {
background-color: rgb(88, 84, 84);
background-color: rgba(88, 84, 84, 0.2);
}
.item-descr {
font-weight: 100;
display: inline-block;
padding: 3px;
}
.item-descr .id {
display: block;
color: #636262;
line-height: 18px;
font-size: 12px;
-webkit-transition: all .5s ease;
-moz-transition: all .5s ease;
-ms-transition: all .5s ease;
transition: all .5s ease;
}
.item-descr .date {
display: block;
font-size: 10px;
}
.mapboxgl-popup {
max-width: 400px !important;
font: 12px/20px 'Helvetica Neue', Arial, Helvetica, sans-serif;
}
@media(max-width: 767px) {
#map {
left: 300px;
width: calc(100% - 300px);
}
#menu {
width: 300px;
}
.mapboxgl-ctrl-attrib {
font-size: 10px;
}
}
</style>
</head>
<body>
<div id='selector' class='fixed top right bottom left scroll-auto bg-darken50 z3'>
<div class='bg-white middle-center w600 px12 py12 round'>
<div class='txt-h5 mt6 mb6 color-black'>Enter STAC API url</div>
<input id="stacapi" class='input wmax-full inline-block' value="" placeholder='STAC API url here' />
<button id="launch" class='btn bts--xs btn--stroke bg-darken25-on-hover inline-block mt12 '>Apply</button>
</div>
</div>
<div id='menu' class='bg-white z1'>
<ul id='toolbar' class='grid'>
<li id='query' class="col col--6 active" title="query" onclick="switchPane(this)">
Query
</li>
<li id='results' class="col col--6" title="results" onclick="switchPane(this)">
Results
</li>
</ul>
<div id='menu-content' class='relative'>
<!-- Query -->
<section id='query-section' class='px12 pt12 pb6 active'>
<div class='align-center mb12'>
<button id='btn-query' class='btn center' title='Submit'>Submit Query</button>
<button id='btn-reset' class='btn center' title='Reset'>Reset</button>
</div>
<div class='align-center mb12'>
<button id='bbox' class='btn btn--stroke color-black round center txt-s opacity75-on-hover' title='bbox'>DRAW BBOX ON MAP
<svg class='icon w18 h18 inline-block align-middle'><use xlink:href='#icon-polygon'/></svg>
</button>
</div>
<div class='px12 pt12 pb6'>
<div class='txt-h5 mt6 mb6 color-black'>Date Range</div>
<div class='align-center'>
<input id="start-date" class='input input--s wmax120 wmax180-ml inline-block align-center color-black opacity75-on-hover'/>
<input id="end-date" class='input input--s wmax120 wmax180-ml inline-block align-center color-black opacity75-on-hover'/>
</div>
</div>
<div class='px12 pt12 pb6'>
<div class='txt-h5 mt6 mb6 color-black'>Collection Filter</div>
<div id='collection-filters' class='align-center'></div>
<div class='align-center mt12'>
<button id='add-collection' class='btn btn--s btn--stroke color-black round mt16 wmax180 w300-ml opacity75-on-hover' title='Add'>
Add filter
</button>
</div>
</div>
<div class='px12 pt12 pb6'>
<div class='txt-h5 mt6 mb6 color-black'>Property Filters</div>
<div id='property-filters' class='align-center '></div>
<div class='align-center mt12'>
<button id='add-filter' class='btn btn--s btn--stroke color-black round mt16 wmax180 w300-ml opacity75-on-hover' title='Add'>
Add filter
</button>
</div>
</div>
</section>
<!-- Results -->
<section id='results-section'>
<div id="results-items" class='px12 pt12 pb6'></div>
<div class='align-center mb12'>
<button id='btn-load-more' class='btn center none' title='load more'>More Results</button>
</div>
<!--
found
displaying
load more -->
</section>
</div>
</div>
<div id='map'></div>
<script>
var scope = {endpoint: undefined, collections: undefined, features: [], next_token: undefined, limit: 100, query: {}}
mapboxgl.accessToken = ''
var map = new mapboxgl.Map({
container: 'map',
style: {
version: 8,
sources: {
'toner-lite': {
type: 'raster',
tiles: [
'https://stamen-tiles-a.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png',
'https://stamen-tiles-b.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png',
'https://stamen-tiles-c.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png',
'https://stamen-tiles-d.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png'
],
tileSize: 256,
attribution:
'Map tiles by <a href="https://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.'
}
},
layers: [
{
'id': 'basemap',
'type': 'raster',
'source': 'toner-lite',
'minzoom': 0,
'maxzoom': 20
}
]
},
center: [0, 0],
zoom: 1
})
map.addControl(new mapboxgl.NavigationControl(), "top-right");
var Draw = new MapboxDraw({
displayControlsDefault: false,
});
document.getElementById('bbox').addEventListener('click', () => {
if (Draw.getMode() !== 'simple_select') Draw.changeMode('simple_select');
Draw.deleteAll();
Draw.changeMode('draw_polygon')
})
map.addControl(Draw);
// date picker
const startpicker = window.pickadate.create()
const startdate = document.getElementById('start-date')
window.pickadate.render(startdate, startpicker)
startdate.addEventListener('pickadate:change', () => {
startdate.value = startpicker.getValue('YYYY-MM-DD[T]HH:mm:ss[Z]')
})
const endpicker = window.pickadate.create()
const enddate = document.getElementById('end-date')
window.pickadate.render(enddate, endpicker)
enddate.addEventListener('pickadate:change', () => {
enddate.value = endpicker.getValue('YYYY-MM-DD[T]HH:mm:ss[Z]')
})
const zoomtofeature = (id) => {
const bbox = scope.features.filter(e => {return e.id === id})[0].bbox
map.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]])
}
const query = (body) => {
return fetch(`${scope.endpoint}/search`, {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json'
},
})
.then(res => {
if (res.ok) return res.json()
throw new Error('Network response was not ok.')
})
.then((data) => {
data.features = data.features.map(e => {
e.properties.id = e.id;
e.properties.collection = e.collection;
return e;
})
if (data.context.matched === 0) {
throw Error("No item found")
}
if (map.getLayer('shapes')) map.removeLayer('shapes')
if (map.getLayer('shapes-selected')) map.removeLayer('shapes-selected')
if (map.getSource('shapes')) map.removeSource('shapes')
map.addSource('shapes', {
'type': 'geojson',
'data': {
type: 'FeatureCollection',
features: data.features,
}
})
map.addLayer({
id: 'shapes',
type: 'fill',
source: 'shapes',
paint: {
'fill-color': 'rgba(200, 100, 240, 0.4)',
'fill-outline-color': '#FFF',
'fill-opacity': 1
}
})
map.addLayer({
id: 'shapes-selected',
type: 'line',
source: 'shapes',
layout: {
'line-cap': 'round',
'line-join': 'round'
},
paint: {
'line-color': '#F00',
'line-width': 3
},
filter: ['==', 'id', '']
})
// next_token
const tokens = data.links.filter(e => {return e.rel === 'next'})
if (tokens.length === 1) {
scope.next_token = tokens[0].body.token
document.getElementById('btn-load-more').classList.remove('none')
} else {
document.getElementById('btn-load-more').classList.add('none')
}
const items_el = document.getElementById('results-items')
// Feed result table
for(let i = 0; i < data.features.length; i++) {
items_el.innerHTML +=
`<div class="list-element" onclick="zoomtofeature('${data.features[i].id}')">` +
'<div class="col">' +
'<div class="item-descr">'+
`<span class="id">${data.features[i].collection} | ${data.features[i].id}</span>` +
`<span class="date"><svg class='icon icon--l inline-block'><use xlink:href='#icon-clock'/></svg> ${data.features[i].properties.datetime || data.features[i].properties.start_datetime}</span>` +
'</div>' +
'</div>' +
'</div>'
}
return data
})
}
// Add Collection filter
document.getElementById('add-collection').addEventListener('click', () => {
const collection_el = document.getElementById('collection-filters')
collection_el.insertAdjacentHTML(
'beforeend',
`<div><div class='select-container'>` +
`<select id='collection-name' class='select select--s select--border-gray wmax180 w300-ml'>` +
scope.collections.map(e => {return `<option value='${e}'>${e}</option>`}).join() +
`</select>` +
`<div class='select-arrow color-black'></div>` +
'</div>'+
`<button class='btn btn--s btn--stroke color-black round ml6 opacity75-on-hover' title='Add' onclick='delete_filter(this)'><svg class='icon icon--l inline-block'><use xlink:href='#icon-trash'/></svg></button>` +
'</div>'
);
})
// Add property filter
document.getElementById('add-filter').addEventListener('click', () => {
const collection_el = document.getElementById('property-filters')
collection_el.insertAdjacentHTML(
'beforeend',
`<div>` +
`<input id="filter-field" class='input input--s wmax60 wmax180-ml inline-block align-center color-black'/>` +
`<div class='select-container'>` +
`<select id='filter-operator' class='select select--s select--stroke color-black'>` +
`<option value='eq'>eq</option>` +
`<option value='ne'>ne</option>` +
`<option value='lt'>lt</option>` +
`<option value='le'>le</option>` +
`<option value='gt'>gt</option>` +
`<option value='ge'>ge</option>` +
`</select>` +
`<div class='select-arrow color-black'></div>` +
`</div>` +
`<input id="filter-value" class='input input--s wmax60 wmax180-ml inline-block align-center color-black'/>` +
`<div class='select-container'>` +
`<select id='filter-value-type' class='select select--s select--stroke color-black'>` +
`<option value='str'>str</option>` +
`<option value='int'>int</option>` +
`<option value='float'>float</option>` +
`</select>` +
`<div class='select-arrow color-black'></div>` +
`</div>` +
`<button class='btn btn--s btn--stroke color-black round ml6 opacity75-on-hover' title='Add' onclick='delete_filter(this)'><svg class='icon icon--l inline-block'><use xlink:href='#icon-trash'/></svg></button>` +
`</div>`
)
})
const delete_filter = (e) => {
const parent = e.parentNode
parent.parentNode.removeChild(parent)
}
const renderItems = (features) => {
if (map.getLayer('shapes')) map.removeLayer('shapes')
if (map.getLayer('shapes-selected')) map.removeLayer('shapes-selected')
if (map.getSource('shapes')) map.removeSource('shapes')
map.addSource('shapes', {
'type': 'geojson',
'data': {
type: 'FeatureCollection',
features: features,
}
})
map.addLayer({
id: 'shapes',
type: 'fill',
source: 'shapes',
paint: {
'fill-color': 'rgba(200, 100, 240, 0.4)',
'fill-outline-color': '#FFF',
'fill-opacity': 1
}
})
map.addLayer({
id: 'shapes-selected',
type: 'line',
source: 'shapes',
layout: {
'line-cap': 'round',
'line-join': 'round'
},
paint: {
'line-color': '#F00',
'line-width': 3
},
filter: ['==', 'id', '']
})
}
document.getElementById('btn-load-more').addEventListener('click', () => {
body = scope.query
if (scope.next_token !== undefined) body.token = scope.next_token
query(body)
.then(data => {
scope.features = scope.features.concat(data.features)
minx = Math.min(...scope.features.map(e => e.bbox[0]))
miny = Math.min(...scope.features.map(e => e.bbox[1]))
maxx = Math.max(...scope.features.map(e => e.bbox[2]))
maxy = Math.max(...scope.features.map(e => e.bbox[3]))
map.fitBounds([[minx, miny], [maxx, maxy]])
renderItems(scope.features)
})
})
// RESET UI
document.getElementById('btn-reset').addEventListener('click', () => {
cleanup()
Draw.deleteAll()
document.getElementById('collection-filters').innerHTML = ''
document.getElementById('property-filters').innerHTML = ''
document.getElementById('start-date').value = ''
document.getElementById('end-date').value = ''
})
const cleanup = () => {
if (map.getLayer('shapes')) map.removeLayer('shapes')
if (map.getLayer('shapes-selected')) map.removeLayer('shapes-selected')
if (map.getSource('shapes')) map.removeSource('shapes')
scope.features = []
scope.next_token = undefined
scope.query = {}
document.getElementById('results-items').innerHTML = ''
document.getElementById('btn-load-more').classList.add('none')
}
// START QUERY
document.getElementById('btn-query').addEventListener('click', () => {
cleanup()
// CQL2 query
body = {limit: scope.limit}
filter = {op: 'and', args: []}
// geom filter
const draws = Draw.getAll()
if (draws.features.length !== 0) filter.args.push({"op": "s_intersects", "args": [{"property": "geometry"}, draws.features[0].geometry]})
// date filters
const startdate = document.getElementById('start-date').value
const enddate = document.getElementById('end-date').value
if (startdate !== '' && enddate !== '') {
filter.args.push(
{"op": "t_intersects", "args": [{"property": "datetime"}, [startdate, enddate]]}
)
} else if (startdate !== '' && enddate === '') {
filter.args.push(
{
"op": "ge",
"args": [ { "property": "datetime" }, startdate ]
}
)
} else if (startdate === '' && enddate !== '') {
filter.args.push(
{
"op": "le",
"args": [ { "property": "datetime" }, enddate ]
}
)
}
// collection filters
let collections = []
document.getElementById('collection-filters').childNodes.forEach((e) => {
const collection = e.querySelector("#collection-name").value
collections.push(collection)
})
if (collections.length > 0) filter.args.push({"op": "in", "args": [{"property": "collection"}, collections]})
// Properties filters
document.getElementById('property-filters').childNodes.forEach((e) => {
const field = e.querySelector("#filter-field").value
if (field === '') return
const operator = e.querySelector("#filter-operator").value
let value = e.querySelector("#filter-value").value
if (value === '') return
const type = e.querySelector("#filter-value-type").value
if (type === "int"){
value = parseInt(value)
} else if (type === "float") {
value = parseFloat(value)
}
filter.args.push({"op": operator, "args": [{"property": field}, value]})
})
if (filter.args.length > 0) body.filter = filter
scope.query = body
query(body)
.then(data => {
scope.features = data.features
minx = Math.min(...scope.features.map(e => e.bbox[0]))
miny = Math.min(...scope.features.map(e => e.bbox[1]))
maxx = Math.max(...scope.features.map(e => e.bbox[2]))
maxy = Math.max(...scope.features.map(e => e.bbox[3]))
map.fitBounds([[minx, miny], [maxx, maxy]])
renderItems(scope.features)
})
.catch(err => {
console.log(err)
})
.then(() => {
document.getElementById('query').classList.toggle('active')
document.getElementById('results').classList.toggle('active')
document.getElementById(`query-section`).classList.toggle('active')
document.getElementById(`results-section`).classList.toggle('active')
})
})
const switchPane = (event) => {
const cur = document.getElementById('toolbar').querySelector(".active")
const activeViz = cur.id
const nextViz = event.id
cur.classList.toggle('active')
event.classList.toggle('active')
const curSection = document.getElementById(`${activeViz}-section`)
curSection.classList.toggle('active')
const nextSection = document.getElementById(`${nextViz}-section`)
nextSection.classList.toggle('active')
}
map.on('load', () => {
map.on('click', 'shapes', (e) => {
let html = '<table>'
let feat = e.features[0]
console.log(e.features)
for (const [key, value] of Object.entries(feat.properties)) {
html += `<tr><td class="align-l">${key}</td><td class="px3 align-r">${value}</td></tr>`
}
html += '</table>'
new mapboxgl.Popup()
.setLngLat(e.lngLat)
.setHTML(html)
.addTo(map);
});
})
document.getElementById('launch').addEventListener('click', () => {
scope.endpoint = document.getElementById('stacapi').value
document.getElementById('selector').classList.toggle('none')
// get collections
fetch(`${scope.endpoint}/collections`)
.then(res => {
if (res.ok) return res.json()
throw new Error('Network response was not ok.')
})
.then((data) => {
console.log(data)
scope.collections = data.collections.map(d => {return d.id})
})
})
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment