Commit 580141b7 authored by Anne Blankert's avatar Anne Blankert

fix special cases, refactor attribute info UI

parent 6f1a7ae7
......@@ -2,13 +2,57 @@ const sqlTableName = require('./utils/sqltablename.js');
const sql = (params, query) => {
return `
select count(1)::integer as "count", ${params.column} as "value"
from ${params.table}
select count(1)::integer as "count", "${params.column}" as "value"
from ${sqlTableName(params.table)}
where ${query.geom_column} is not null
group by ${params.column} order by count(1) desc limit 2000;
group by "${params.column}" order by count(1) desc limit 100;
`
} // TODO, use sql place holders $1, $2 etc. instead of inserting user-parameters into query
// https://leafo.net/guides/postgresql-calculating-percentile.html
const sqlPercentiles = (params, query) => {
return `
select min(buckets.value) "from", max(buckets.value) "to", count(ntile)::integer "count", ntile as percentile
from
(select "${params.column}" as value, ntile(100) over (order by "${params.column}")
from ${sqlTableName(params.table)}
where "${params.column}" is not null and ${query.geom_column} is not null)
as buckets
group by ntile order by ntile;
`
}
const sqlPercentilesBoolean = (params, query) => {
return `
select case when min(buckets.value) = 0 then false else true end "from", case when max(buckets.value) = 0 then false else true end "to", count(ntile)::integer "count", ntile as percentile
from
(select "${params.column}"::integer as value, ntile(100) over (order by "${params.column}")
from ${sqlTableName(params.table)}
where "${params.column}" is not null and ${query.geom_column} is not null)
as buckets
group by ntile order by ntile;
`
}
let typeMap = null;
async function getTypeName(id, pool) {
if (!typeMap) {
const sql = "select oid,typname from pg_type where oid < 1000000 order by oid";
try {
const queryResult = await pool.query(sql);
typeMap = new Map(queryResult.rows.map(row=>[row.oid, row.typname]));
} catch(err) {
console.log(`error loading types: ${err}`);
return id.toString();
}
}
const result = typeMap.get(id);
if (!result) {
return id.toString();
}
return result;
}
module.exports = function(app, pool, cache) {
let cacheMiddleWare = async(req, res, next) => {
......@@ -16,7 +60,7 @@ module.exports = function(app, pool, cache) {
next();
return;
}
const cacheDir = `${req.params.table}/attrstats/`;
const cacheDir = `${req.params.table}/colstats/`;
const key = ((req.query.geom_column?req.query.geom_column:'geom') + (req.params.column?','+req.params.column:''))
.replace(/[\W]+/g, '_');
......@@ -108,22 +152,49 @@ module.exports = function(app, pool, cache) {
if (!req.query.geom_column) {
req.query.geom_column = 'geom'; // default
}
const sqlString = sql(req.params, req.query);
let sqlString = sql(req.params, req.query);
//console.log(sqlString);
try {
const result = await pool.query(sqlString);
const stats = result.rows
if (stats.length === 0) {
res.status(204).json({});
return;
let queryResult = await pool.query(sqlString);
let datatype = await getTypeName(queryResult.fields[1].dataTypeID, pool);
if (datatype === "numeric" || datatype === "int8") {
// numeric datatype, try to convert to Number
try {
queryResult.rows = queryResult.rows.map(row=>{row.value=row.value?Number(row.value):row.value; return row});
} catch(err) {
// failed Numeric conversion
}
}
res.json({
const stats = queryResult.rows
const result = {
table: req.params.table,
column: req.params.column,
datatype: datatype,
numvalues: stats.length < 2000?stats.length:null,
uniquevalues: stats[0].value !== null?stats[0].count === 1:stats.length>1?stats[1].count === 1:false,
uniquevalues: stats.length?stats[0].value !== null?stats[0].count === 1:stats.length>1?stats[1].count === 1:false:[],
values: stats
})
}
if (stats.length === 0) {
result.percentiles = [];
res.json(result);
return;
}
if (datatype === "bool") {
sqlString = sqlPercentilesBoolean(req.params, req.query);
} else {
sqlString = sqlPercentiles(req.params, req.query);
}
queryResult = await pool.query(sqlString);
if (datatype === "numeric" || datatype === "int8") {
// numeric datatype, try to convert to Number
try {
queryResult.rows = queryResult.rows.map(row=>{row.from=Number(row.from); row.to=Number(row.to); return row});
} catch(err) {
// failed Numeric conversion
}
}
result.percentiles = queryResult.rows;
res.json(result);
} catch(err) {
console.log(err);
let status = 500;
......
......@@ -6,73 +6,80 @@
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Info</title>
<style>
.canvascontainer {width: 500px; height: auto; border: 1px solid lightblue}
.medium {width: 300px; height: auto;}
#map {display: inline-block; width: 500px; height: 500px; border: 1 px solid lightblue;}
#info {display: inline-block;}
#attrinfo {
display: flex;
flex-wrap: wrap;
}
#attrinfo div {
margin: 5px;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.8.0"></script>
<script src='https://api.tiles.mapbox.com/mapbox-gl-js/v1.2.0/mapbox-gl.js'></script>
<link href='https://api.tiles.mapbox.com/mapbox-gl-js/v1.2.0/mapbox-gl.css' rel='stylesheet' />
<link href='loader.css' rel='stylesheet' />
<script>
"use strict";
let chart1 = null, chart2 = null;
function quantile(arr, p) {
let len = arr.length, id;
if ( p === 0.0 ) {
return arr[ 0 ];
}
if ( p === 1.0 ) {
return arr[ len-1 ];
}
id = ( len*p ) - 1;
let map = null;
function init() {
const urlParams = new URLSearchParams(window.location.search);
const fullTableName = urlParams.get('table');
const attrName = urlParams.get("column");
const attrType = urlParams.get("columntype");
const geomType = urlParams.get('geomtype');
const geomColumn = urlParams.get('geom_column');
document.querySelector('#tablename').innerHTML = `${fullTableName} ${geomColumn}`;
document.querySelector('#columnname').innerHTML = `${attrName} (${attrType})`;
document.querySelector('#back').innerHTML = `<a href="tableinfo.html?table=${fullTableName}&geom_column=${geomColumn}">Back to layer info</a>`;
// [2] Is the index an integer?
if ( id === Math.floor( id ) ) {
// Value is the average between the value at id and id+1:
return ( arr[ id ] + arr[ id+1 ] ) / 2.0;
}
// [3] Round up to the next index:
id = Math.ceil( id );
return arr[ id ];
}
function niceNumbers (range, round) {
const exponent = Math.floor(Math.log10(range));
const fraction = range / Math.pow(10, exponent);
let niceFraction;
if (round) {
if (fraction < 1.5) niceFraction = 1;
else if (fraction < 3) niceFraction = 2;
else if (fraction < 7) niceFraction = 5;
else niceFraction = 10;
} else {
if (fraction <= 1.0) niceFraction = 1;
else if (fraction <= 2) niceFraction = 2;
else if (fraction <= 5) niceFraction = 5;
else niceFraction = 10;
}
return niceFraction * Math.pow(10, exponent);
}
function getLinearTicks (min, max, maxTicks) {
const range = niceNumbers(max - min, false);
let tickSpacing;
if (range === 0) {
tickSpacing = 1;
} else {
tickSpacing = niceNumbers(range / (maxTicks), true);
}
return {
min: Math.floor(min / tickSpacing) * tickSpacing,
max: Math.ceil(max / tickSpacing) * tickSpacing,
tickWidth: tickSpacing
};
initMap();
fetch(`data/${fullTableName}/colstats/${attrName}?geom_column=${geomColumn}`).then(response=>{
const attrInfoElement = document.querySelector('#attrinfo');
attrInfoElement.innerHTML = "";
if (!response.ok) {
try {
response.json(json=>attrInfoElement.innerHtml = json.error);
} catch(err) {
attrInfoElement.innerHTML = err;
}
return;
}
response.json()
.then(json=>{
if (json.datatype === 'timestamptz' || json.datatype === 'date') {
json.percentiles = json.percentiles.map(percentile=>{
percentile.from = percentile.from?new Date(percentile.from):null;
percentile.to = percentile.to?new Date(percentile.to):null;
return percentile;
})
json.values = json.values.map(value=>{
value.value = value.value?new Date(value.value):null;
return value;
})
}
graphStats(json)
})
.catch(err=>
attrInfoElement.innerHTML = `Failed to parse response, message: ${err.message}`
);
})
}
let map = null;
function initMap()
{
const urlParams = new URLSearchParams(window.location.search);
const fullTableName = urlParams.get('table');
const geomType = urlParams.get('geomtype');
const geomColumn = urlParams.get('geom_column');
const attrName = urlParams.get("column");
const mapDefinition = {
container: 'map',
"style": {
......@@ -81,7 +88,7 @@
"id": "defaultbasestyle",
"glyphs": `https://free.tilehosting.com/fonts/{fontstack}/{range}.pbf?key=`,
"sources": {
"osmgrijs": {
"osmgray": {
"type":"raster",
"tileSize":256,
"tiles":[
......@@ -92,346 +99,199 @@
},
"layers": [
{
"id": "osmgrijs",
"id": "osmgray",
"type": "raster",
"source": "osmgrijs"
"source": "osmgray"
}
]
],
"filter": [
"has",
`${attrName}`
]
}
}
const urlParams = new URLSearchParams(window.location.search);
const bboxll = urlParams.get('bboxll');
if (bboxll) {
mapDefinition.bounds = JSON.parse(bboxll);
}
map = new mapboxgl.Map(mapDefinition);
map.on('load', () => {
let layerType, paint;
switch (geomType) {
case 'MULTIPOLYGON':
case 'POLYGON':
layerType = 'fill';
paint = {
"fill-color": "red",
"fill-outline-color": "white",
"fill-opacity": 0.8
}
break;
case 'MULTILINESTRING':
case 'LINESTRING':
layerType = 'line';
paint = {
"line-color": "red",
"line-width": 1
}
break;
case 'MULTIPOINT':
case 'POINT':
layerType = "circle";
paint = {
"circle-radius": 5,
"circle-color": "red",
"circle-stroke-color": "white",
"circle-stroke-width" : 1
}
break;
default:
break;
}
if (!layerType) {
document.querySelector("#layerjson").innerHTML = `Field geom of type: '${geomType}' not supported<br>Supported types: (MULTI-) POINT/LINE/POLYGON<p>`
} else {
const baseUrl = new URL(`/data`, window.location.href).href;
const layer = {
"id": "attrlayer",
"type": layerType,
"source": {
"type": "vector",
"tiles": [`${baseUrl}/${fullTableName}/mvt/{z}/{x}/{y}?geom_column=${geomColumn}&columns=${attrName}`],
},
"source-layer": fullTableName,
"paint": paint,
"filter": ['has', attrName]
}
map.addLayer(layer);
document.querySelector("#layerjson").innerHTML = `<pre>${JSON.stringify(layer, null, 2)}</pre>`;
}
})
map.on('mousemove', function (e) {
var features = map.queryRenderedFeatures(e.point).map(function(feature){ return {layer: {id: feature.layer.id, type: feature.layer.type}, properties:(feature.properties)};});
document.getElementById('info').innerHTML = JSON.stringify(features.map(feature=>feature.properties), null, 2);
});
}
const colors1 = ["#686868", "#e60000", "#000000", "#faaa00", "#734c00", "#b2b2b2", "#267300", "#beffdc", "#ffff73",
"#a3ff73", "#e9ffbe", "#b4d79e", "#7ed2fc", "#ffffff"];
const colors = ["#14a4ab", "#a9a9a9", "#4e4e4e", "#67ae00", '#febe00', "#e2fe5f", "#ada4fe", '#e8beff', '#d7d79e',
'#0afeb3', '#a83800', '#73dfff','#69d5b4', '#bee8ff', '#97dbf2', '#feee00', '#e9ffbe', '#97dbf2',
'#b1d600', '#73b2ff', '#97dbf2', '#fefac2', '#fe8419', '#7ab6f5', '#000000', '#00e6a9', '#b39d3d',
'#97fe00', '#97fe00', '#ffeabe', '#ae974b', '#97dbf2', '#bed2ff', '#73dfff' ,'#97dbf2', '#ffbebe',
'#ac7a9d']
const greenScheme = [{red:247, green:252, blue:253}, {red:0, green:68, blue:27}];
function hex (i) {
let result = i.toString(16);
if (result.length < 2) {
result = '0' + result;
}
return result;
}
function createRangeColors(classLabels) {
const length = classLabels.length;
const result = [];
if (length > 1) {
const redStep = (greenScheme[0].red - greenScheme[1].red) / (length - 1);
const greenStep = (greenScheme[0].green - greenScheme[1].green) / (length - 1);
const blueStep = (greenScheme[0].blue - greenScheme[1].blue) / (length - 1);
for (let i = 0; i < length; i++) {
result.push('#' + hex(greenScheme[0].red - Math.round(redStep * i)) + hex(greenScheme[0].green - Math.round(greenStep * i)) + hex(greenScheme[0].blue - Math.round(blueStep * i)));
result.push(classLabels[i]);
}
result.push('rgba(0,0,0,1)');
}
return result;
function addCanvas() {
const container = document.createElement('div');
container.classList.add('canvascontainer');
const canvas = document.createElement('canvas');
container.classList.add('medium');
container.appendChild(canvas);
document.querySelector('#attrinfo').appendChild(container);
return canvas;
}
function showMapLayer(fullTableName, geomColumn, geomType, attrName, attrType, classLabels) {
let layerType, paint;
const classLabelAndColors = classLabels.reduce((result, key, index)=>{
result.push(key);
result.push(colors[index % colors.length])
return result;
}, []);
const rangeColors = createRangeColors(classLabels);
let styledColors;
if (classLabels.length < 2) {
styledColors = colors[0];
} else {
switch (attrType) {
case 'varchar':
styledColors = [
"match",
["get", attrName],
...classLabelAndColors,
colors[0]
];
break;
case 'numeric':
styledColors = [
"step",
["to-number", ["get", attrName]],
...rangeColors
]
break;
default:
styledColors = [
"step",
["get", attrName],
...rangeColors
]
}
}
switch (geomType) {
case 'MULTIPOLYGON':
case 'POLYGON':
layerType = 'fill';
paint = {
"fill-color": styledColors,
"fill-outline-color": "black",
"fill-opacity": 0.8
}
break;
case 'MULTILINESTRING':
case 'LINESTRING':
layerType = 'line';
paint = {
"line-color": styledColors,
"line-width": 1
}
break;
case 'MULTIPOINT':
case 'POINT':
layerType = 'circle';
paint = {
"circle-radius": 5,
"circle-color": styledColors,
"circle-stroke-color": "black",
"circle-stroke-width" : 1
}
break;
default:
document.querySelector("#layerjson").innerHTML = `Field geom of type: '${geomType}' not supported<br>Supported types: (MULTI-) POINT/LINE/POLYGON<p>`
function graphStats(stats) {
const nullValues = stats.values.filter(value=>value.value === null).reduce((result, value)=>result+value.count,0);
const rowCount = stats.percentiles.reduce((result, percentile)=>result + percentile.count, 0);
new Chart(addCanvas(), {
type: 'doughnut',
data: {
labels: ['null','non-null',],
datasets: [{
backgroundColor: ['lightgray', 'red'],
borderColor: 'white',
borderWidth: 0,
data: [nullValues, rowCount]
}]
}
if (layerType){
const baseUrl = new URL(`/data`, window.location.href).href;
const url = `${baseUrl}/${fullTableName}/mvt/{z}/{x}/{y}?columns="${attrName}"${geomColumn?`&geom_column=${geomColumn}`:''}`
console.log(`url: ${url}`);
const layer = {
"id": "attrlayer",
"type": layerType,
"source": {
"type": "vector",
"tiles": [url],
},
"source-layer": fullTableName,
"paint": paint,
"filter": ['has', attrName]
}
map.addLayer(layer);
document.querySelector("#layerjson").innerHTML = `<pre>${JSON.stringify(layer, null, 2)}</pre>`;
}
}
function init() {
initMap();
const urlParams = new URLSearchParams(window.location.search);
const fullTableName = urlParams.get('table');
const attrName = urlParams.get("column");
const attrType = urlParams.get("columntype");
const geomType = urlParams.get('geomtype');
const geomColumn = urlParams.get('geom_column');
document.querySelector('#tablename').innerHTML = fullTableName;
document.querySelector('#columnname').innerHTML = `${attrName} (${attrType})`;
document.querySelector('#back').innerHTML = `<a href="tableinfo.html?table=${fullTableName}&geom_column=${geomColumn}">Terug naar layer informatie</a>`
const parts = fullTableName.split('.');
const tableName = (parts.length > 1) ? parts[1] : parts[0];
fetch(`data/query/${fullTableName}?columns=count("${attrName}"),count(distinct+"${attrName}")+as+distinct,min("${attrName}"),max("${attrName}")`).then(response=>{
if (response.ok) {
response.json().then(json=> {
const div = document.querySelector('#attrinfo');
div.innerHTML = `<b>count</b>:${json[0].count}<br>
<b>nr of unique values</b>:${json[0].distinct}<br>
<b>minimum</b>:${json[0].min}<br>
<b>maximum</b>:${json[0].max}<br>`;
})
}
})
fetch(`data/query/${fullTableName}?columns="${attrName}"`).then(response=>{
if (response.ok) {
response.json().then(json=> {
let histogram, verdeling;
let histLabels = [], verdLabels = [], classBorders = [];
let arr;
switch(attrType) {
case "varchar":
arr = json
.map(item=>item[attrName])
.filter(value=>value !== null)
.sort()
histogram = new Map();
for (let i = 0; i < arr.length; i++) {
const value = arr[i];
const count = histogram.get(value);
histogram.set(value, count?count+1:1);
}
histLabels = Array.from(histogram.keys());
classBorders = histLabels;
break;
case "int4":
case "int8":
case "float8":
case "numeric":
verdeling = new Map();
histogram = new Map();
arr = json
.map(item=>{
let result = item[attrName];
if (typeof result === "string") {
result = Number(result);
if (isNaN(result)) {
result = undefined;
}
}
return result;
})
.filter(value=>value !== null)
.filter(value=>value !== undefined)
.sort((value1,value2)=>value1-value2)
const sampleInterval = Math.ceil(arr.length / 100);
for (let i = 0; i * sampleInterval < arr.length; i++) {
verdeling.set(i, arr[i*sampleInterval]);
verdLabels.push(`${1 + i * sampleInterval}`);
}
const min = arr[0];
const max = arr[arr.length - 1];
const iqr = quantile(arr, 0.75) - quantile(arr, 0.25);
let bins;
if (max === min) {
bins = 1
} else {
bins = Math.round((max - min) / (2 * (iqr/Math.pow(arr.length, 1/3))));
}
if (bins > 100) {
bins = 100;
}
const linearTicks = getLinearTicks(min, max, bins);
const binwidth = linearTicks.tickWidth;
console.log(`count: ${arr.length}, min: ${min}, max: ${max}, iqr: ${iqr}, bins: ${bins}, binwidth: ${binwidth}
linearTicks.min: ${linearTicks.min}, linearTicks.max: ${linearTicks.max}, linearTicks.tickWidth: ${linearTicks.tickWidth}`)
bins = Math.ceil((linearTicks.max - linearTicks.min) / binwidth);
if (bins < 1) {
bins = 1;
}
for (let i = 0; i < bins; i++) {
histogram.set(i, 0);
const start = Math.round(10000 * (linearTicks.min + i * binwidth)) / 10000;
const end = Math.round(10000 * (linearTicks.min + (i+1) * binwidth)) / 10000;
if (linearTicks.max === linearTicks.min) {
histLabels.push(`${start}`)
classBorders.push(start);
} else {
histLabels.push(`${start} - ${end}`);
classBorders.push(end);
}
}
for (let i = 0; i < arr.length; i++) {
const value = arr[i];
const bin = Math.floor((value - linearTicks.min)/binwidth);
histogram.set(bin, histogram.get(bin) + 1);
}
break;
}
if (histogram) {
const values = Array.from(histogram.values());
const canvas = document.querySelector('#histogram');
const ctx = canvas.getContext('2d');
if (chart1) {
chart1.destroy();
}
chart1 = new Chart(ctx, {
type: 'bar',
data: {
labels: histLabels,
datasets: [{
label: `count of ${tableName}`,
data: values,
backgroundColor: "blue",
borderColor: "white",
borderWidth: 1
}]
},
options: {
title: {
});
if (stats.percentiles.length && typeof(stats.percentiles[0].from) !== 'string') {
new Chart(addCanvas(), {
type: 'line',
data: {
labels: stats.percentiles.map((percentile, index, arr)=>Math.round((index/(arr.length - 1)) * 100)),
datasets: [{
backgroundColor: 'red',
borderColor: 'lightred',
data: stats.percentiles.map((percentile,index,arr)=>(index===arr.length-1)?percentile.to:percentile.from),
fill: false
}]
},
options : {
title: {
display: false,
text : 'some title'
},
legend: {
display: false
},
scales: {
yAxes: [
{
scaleLabel: {
display: true,
position: 'bottom',
padding: 0,
text: attrName
},
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
labelString: stats.column
}
}
});
showMapLayer(fullTableName, geomColumn, geomType, attrName, attrType, classBorders);
}
if (verdeling) {
const values = Array.from(verdeling.values());
const canvas = document.querySelector('#verdeling');
const ctx = canvas.getContext('2d');
if (chart2) {
chart2.destroy();
}
chart2 = new Chart(ctx, {
type: 'line',
data: {
labels: verdLabels,
datasets: [{
label: `${attrName}`,
data: values,
backgroundColor: "blue",
borderColor: "white",
borderWidth: 1
}]
},
options: {
title: {
],
xAxes: [
{
scaleLabel: {
display: true,
position: 'bottom',
padding: 0,
text: tableName
},
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
labelString: 'percentage of rows',
padding: 0
}
}
});
]
}
})
}
})
}
const valuesSummary = stats.values.filter(value=>value.value !== null).slice(0,10);
const valuesSummeryRowCount = valuesSummary.reduce((result, value)=>result+value.count,0);
if (rowCount > valuesSummeryRowCount) {
valuesSummary.push({
value:"other",
count: rowCount - valuesSummeryRowCount
})
}
new Chart(addCanvas(), {
type: "horizontalBar",
data: {
labels: valuesSummary.map(value=>value.value),
datasets: [
{
backgroundColor: "red",
data: valuesSummary.map(value=>value.count)
}
]
},
options : {
legend: {
display: false,
},
scales: {
xAxes: [
{
ticks: {
min: 0
}
}
]
}
}
})
}
</script>
</head>
<body onload="init()">
<h1>Attribuut-information</h1>
<h1>Attribute information</h1>
<h2 id="tablename"></h2>
<h2 id="columnname"></h2>
<div id="attrinfo"></div>
<div class="canvascontainer"><canvas id="histogram" width=1000 height=500></canvas></div>
<div class="canvascontainer"><canvas id="verdeling" width=1000 height=500></canvas></div>
<div id="attrinfo">
<h2>Loading statistics...</h2>
<div class="loader">
<div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="dot"></div>
<div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="dot"></div>
</div>
</div>
<div id="map"></div>
<div id="info"></div>
<div id="layerjson"></div>
......
/* source https://codepen.io/TheDutchCoder/pen/gholk */
/* The loader container */
.loader {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 200px;
margin-top: -100px;
margin-left: -100px;
perspective: 400px;
/* transform-type: preserve-3d; */
}
/* The dot */
.dot {
position: absolute;
top: 50%;
left: 50%;
z-index: 10;
width: 40px;
height: 40px;
margin-top: -20px;
margin-left: -80px;
/* transform-type: preserve-3d;*/
transform-origin: 80px 50%;
transform: rotateY(0);
background-color: #1e3f57;
animation: dot1 2000ms cubic-bezier(.56,.09,.89,.69) infinite;
}
.dot:nth-child(2) {
z-index: 9;
animation-delay: 150ms;
}
.dot:nth-child(3) {
z-index: 8;
animation-delay: 300ms;
}
.dot:nth-child(4) {
z-index: 7;
animation-delay: 450ms;
}
.dot:nth-child(5) {
z-index: 6;
animation-delay: 600ms;
}
.dot:nth-child(6) {
z-index: 5;
animation-delay: 750ms;
}
.dot:nth-child(7) {
z-index: 4;
animation-delay: 900ms;
}
.dot:nth-child(8) {
z-index: 3;
animation-delay: 1050ms;
}
@keyframes dot1 {
0% {
transform: rotateY(0) rotateZ(0) rotateX(0);
background-color: #1e3f57;
}
45% {
transform: rotateZ(180deg) rotateY(360deg) rotateX(90deg);
background-color: #6bb2cd;
animation-timing-function: cubic-bezier(.15,.62,.72,.98);
}
90%, 100% {
transform: rotateY(0) rotateZ(360deg) rotateX(180deg);
background-color: #1e3f57;
}
}
\ No newline at end of file
......@@ -46,13 +46,21 @@
`
})
} else {
bbox.innerHTML = `Error getting bbox, response: response: ${response.status} ${response.statusText?response.statusText:''} ${response.url}`
response.json().then(json=>{
bbox.innerHTML = `Error getting bbox: ${json.error}`;
}).catch(err => {
bbox.innerHTML = `Error parsing bbox: ${err}`;
})
}
})
})
} else {
const li = document.createElement('li');
li.innerHTML = `Error getting list, response: ${response.status} ${response.statusText?response.statusText:''} ${response.url}`
response.json().then(json=>{
li.innerHTML = `Error getting column info: ${json.error}`;
}).catch(err => {
li.innerHTML = `Error parsing column info: ${err}`;
})
list.appendChild(li);
}
})
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment