From 34b172c42daa01b678c29e67304132321c8f8183 Mon Sep 17 00:00:00 2001 From: Matthias Mohr Date: Wed, 13 Apr 2022 17:54:01 +0200 Subject: [PATCH] Refactored the Viewer environment and components, includes basic support for GeoJSON #226, GeoTiff #38, CSV #224 and WMTS #31 --- src/components/DataViewer.vue | 135 --------------- src/components/Viewer.vue | 154 ++++++------------ src/components/datatypes/MapAreaSelect.vue | 4 +- src/components/datatypes/MapGeoJsonEditor.vue | 4 +- src/components/maps/GeoTiffMixin.vue | 35 ++-- src/components/maps/MapExtentViewer.vue | 4 +- src/components/maps/MapMixin.vue | 111 +++++++++++-- src/components/maps/ProjectionMixin.vue | 53 ------ src/components/maps/WebServiceMixin.vue | 23 +-- src/components/viewer/DataViewer.vue | 78 +++++++++ src/components/{ => viewer}/ImageViewer.vue | 25 +-- src/components/{ => viewer}/LogViewer.vue | 4 +- src/components/{ => viewer}/MapViewer.vue | 85 +++++----- src/components/viewer/TableViewer.vue | 65 ++++++++ src/formats/browserImage.js | 26 +++ src/formats/csv.js | 60 +++++++ src/formats/format.js | 115 +++++++++++++ src/formats/formatRegistry.js | 74 +++++++++ src/formats/geojson.js | 15 ++ src/formats/geotiff.js | 38 +++++ src/formats/json.js | 22 +++ src/formats/native.js | 11 ++ src/formats/tsv.js | 11 ++ src/utils.js | 25 ++- 24 files changed, 753 insertions(+), 424 deletions(-) delete mode 100644 src/components/DataViewer.vue delete mode 100644 src/components/maps/ProjectionMixin.vue create mode 100644 src/components/viewer/DataViewer.vue rename src/components/{ => viewer}/ImageViewer.vue (86%) rename src/components/{ => viewer}/LogViewer.vue (96%) rename src/components/{ => viewer}/MapViewer.vue (63%) create mode 100644 src/components/viewer/TableViewer.vue create mode 100644 src/formats/browserImage.js create mode 100644 src/formats/csv.js create mode 100644 src/formats/format.js create mode 100644 src/formats/formatRegistry.js create mode 100644 src/formats/geojson.js create mode 100644 src/formats/geotiff.js create mode 100644 src/formats/json.js create mode 100644 src/formats/native.js create mode 100644 src/formats/tsv.js diff --git a/src/components/DataViewer.vue b/src/components/DataViewer.vue deleted file mode 100644 index 46f70aac3..000000000 --- a/src/components/DataViewer.vue +++ /dev/null @@ -1,135 +0,0 @@ - - - - - diff --git a/src/components/Viewer.vue b/src/components/Viewer.vue index e63080f53..20be07df9 100644 --- a/src/components/Viewer.vue +++ b/src/components/Viewer.vue @@ -3,10 +3,12 @@ @@ -16,22 +18,19 @@ import EventBusMixin from './EventBusMixin.vue'; import Utils from '../utils.js'; import Tabs from '@openeo/vue-components/components/Tabs.vue'; -import DataViewer from './DataViewer.vue'; -import ImageViewer from './ImageViewer.vue'; -import LogViewer from './LogViewer.vue'; -import MapViewer from './MapViewer.vue' -import contentType from 'content-type'; -import { OpenEO, Service } from '@openeo/js-client'; +import { Service } from '@openeo/js-client'; +import FormatRegistry from '../formats/formatRegistry'; export default { name: 'Viewer', mixins: [EventBusMixin], components: { Tabs, - DataViewer, - ImageViewer, - LogViewer, - MapViewer + DataViewer: () => import('./viewer/DataViewer.vue'), + TableViewer: () => import('./viewer/TableViewer.vue'), + ImageViewer: () => import('./viewer/ImageViewer.vue'), + LogViewer: () => import('./viewer/LogViewer.vue'), + MapViewer: () => import('./viewer/MapViewer.vue') }, mounted() { this.listen('viewSyncResult', this.showSyncResults); @@ -44,6 +43,7 @@ export default { }, data() { return { + registry: new FormatRegistry(), tabTitleCounter: {}, tabIdCounter: 0, logViewerIcons: [ @@ -97,22 +97,7 @@ export default { this.showMapViewer(service, service.id, Utils.getResourceTitle(collection, true), true); }, showWebService(service) { - this.showMapViewer(service, service.id); - }, - showSyncResults(result) { - let title = this.makeTitle("Result"); - this.showViewer(result.data, result.type, title, null, true); - if (Array.isArray(result.logs) && result.logs.length > 0) { - this.showLogs(result.logs, title, false); - } - }, - showJobResults(stac, job) { - // ToDo: Put all GeoTiffs on a single map - for(var key in stac.assets) { - var asset = stac.assets[key]; - let jobTitle = Utils.getResourceTitle(job, true); - this.showViewer(asset, asset.type, this.makeTitle(jobTitle, asset.title), job.id, false, stac); - } + this.showMapViewer(service, service.id, null, true); }, showLogs(resource, defaultTitle = 'Logs', selectTab = true, faIcon = 'fa-bug') { let title = Array.isArray(resource) ? defaultTitle : Utils.getResourceTitle(resource, "Logs"); @@ -133,6 +118,24 @@ export default { this.$refs.tabs.closeTab(tab); } }, + showSyncResults(result) { + let title = this.makeTitle("Result"); + // result.data should always be a blob + let files = this.registry.createFilesFromBlob(result.data); + // Download files to disc so that nothing gets lost + files.forEach(file => file.download()); + // Show the data in the viewer + this.showViewer(files, title).finally(() => { + // Open the log files after the data tab has been opened -> it's in finally to spawn after the data tab + if (Array.isArray(result.logs) && result.logs.length > 0) { + this.showLogs(result.logs, title, false); + } + }); + }, + showJobResults(stac, job) { + let files = this.registry.createFilesFromSTAC(stac, job); + this.showViewer(files); + }, showMapViewer(resource, id = null, title = null, reUseExistingTab = false) { if (!title) { title = Utils.getResourceTitle(resource, true); @@ -156,81 +159,26 @@ export default { tab => this.onHide(tab) ); }, - async showViewer(meta, type, title = null, id = null, synchronous = false, context = null) { - var data = {}; - if (Utils.isObject(meta) && typeof meta.href === 'string') { - Object.assign(data, meta, { url: meta.href }); - } - else if (meta instanceof Blob) { - data.blob = meta; - } - - try { - let mime = contentType.parse(type); - data.type = mime.type; - data.parameters = mime.parameters; - } catch (error) { - data.type = type; - console.log(error); - } - - // Try to show the file - let shown = false; - switch(data.type) { - case 'image/png': - case 'image/jpg': - case 'image/jpeg': - case 'image/gif': - if (!title) { - title = this.makeTitle("Image"); - } - this.$refs.tabs.addTab(title, "fa-image", data, id, true, true); - shown = true; - break; - case 'application/json': - case 'text/plain': - case 'text/csv': - if (!title) { - title = this.makeTitle("Data"); + async showViewer(files, title = null) { + for(let file of files) { + try { + let context = file.getContext(); + let id = context ? context.id : null; + if (!title && context) { + title = Utils.getResourceTitle(context, true); } - this.$refs.tabs.addTab(title, "fa-database", data, id, true, true); - shown = true; - break; - case 'image/tiff': - if (data.parameters.application === 'geotiff') { - if (!title) { - title = this.makeTitle("GeoTiff"); - } - if (id === null && Utils.isObject(id)) { - id = context.id; - } - let initTiff = async tab => await this.callChildFunction(tab, 'updateGeoTiffLayer', data, title, context); - this.showMapViewer(context, id, initTiff, title, false); - shown = true; - } - break; - } - - if (synchronous || !shown) { - if (data.blob instanceof Blob) { - OpenEO.Environment.saveToFile(data.blob, Utils.makeFileName("result", data.type)); - return; - } - else if (data.url) { - try { - let response = await this.connection._get(data.url, "", "blob"); - let filename = Utils.getFileNameFromURL(data.url); - if (response.data instanceof Blob) { - OpenEO.Environment.saveToFile(response.data, Utils.makeFileName(filename, data.type)); - return; - } - } catch (error) { - Utils.exception(this, error, "Sorry, can't load data from URL."); - return; + else if (!title) { + title = this.makeTitle("Untitled"); } + await file.getData(this.connection); + this.$refs.tabs.addTab( + title, file.getIcon(), file, id, true, true, + tab => this.onShow(tab), + tab => this.onHide(tab) + ); + } catch (error) { + Utils.exception(this, error, "Viewer Error"); } - - Utils.exception(this, 'Sorry, this type of data is not supported by the editor and can\'t be downloaded.'); } }, async callChildFunction(tab, fn, ...args) { @@ -304,7 +252,7 @@ export default { diff --git a/src/components/ImageViewer.vue b/src/components/viewer/ImageViewer.vue similarity index 86% rename from src/components/ImageViewer.vue rename to src/components/viewer/ImageViewer.vue index d2566fe58..fd4ebb73c 100644 --- a/src/components/ImageViewer.vue +++ b/src/components/viewer/ImageViewer.vue @@ -10,8 +10,8 @@ - + diff --git a/src/formats/browserImage.js b/src/formats/browserImage.js new file mode 100644 index 000000000..c5282973f --- /dev/null +++ b/src/formats/browserImage.js @@ -0,0 +1,26 @@ +import { SupportedFormat } from './format'; + +class BrowserImage extends SupportedFormat { + + constructor(asset) { + super(asset, "ImageViewer"); + } + + getIcon() { + return 'fa-image'; + } + + isBinary() { + return true; + } + + async fetchData() { + let img = new Image(); + img.crossOrigin = "anonymous"; + img.src = this.getUrl(); + return img; + } + +} + +export default BrowserImage; \ No newline at end of file diff --git a/src/formats/csv.js b/src/formats/csv.js new file mode 100644 index 000000000..2c7ede762 --- /dev/null +++ b/src/formats/csv.js @@ -0,0 +1,60 @@ +import { SupportedFormat } from './format'; + +class CSV extends SupportedFormat { + + constructor(asset, delim = [',', ';']) { + super(asset, "TableViewer"); + this.delim = delim; + } + + getIcon() { + return 'fa-table'; + } + + async parseData(data) { + if (typeof data === 'string') { + return this.parseCSV(data.trim()); + } + return data; + } + + // From https://stackoverflow.com/questions/1293147/example-javascript-code-to-parse-csv-data + parseCSV(str) { + var arr = []; + var quote = false; // 'true' means we're inside a quoted field + + // Iterate over each character, keep track of current row and column (of the returned array) + for (var row = 0, col = 0, c = 0; c < str.length; c++) { + var cc = str[c], nc = str[c+1]; // Current character, next character + arr[row] = arr[row] || []; // Create a new row if necessary + arr[row][col] = arr[row][col] || ''; // Create a new column (start with empty string) if necessary + + // If the current character is a quotation mark, and we're inside a + // quoted field, and the next character is also a quotation mark, + // add a quotation mark to the current column and skip the next character + if (cc == '"' && quote && nc == '"') { arr[row][col] += cc; ++c; continue; } + + // If it's just one quotation mark, begin/end quoted field + if (cc == '"') { quote = !quote; continue; } + + // If it's a elimiter and we're not in a quoted field, move on to the next column + if (this.delim.includes(cc) && !quote) { ++col; continue; } + + // If it's a newline (CRLF) and we're not in a quoted field, skip the next character + // and move on to the next row and move to column 0 of that new row + if (cc == '\r' && nc == '\n' && !quote) { ++row; col = 0; ++c; continue; } + + // If it's a newline (LF or CR) and we're not in a quoted field, + // move on to the next row and move to column 0 of that new row + if (cc == '\n' && !quote) { ++row; col = 0; continue; } + if (cc == '\r' && !quote) { ++row; col = 0; continue; } + + // Otherwise, append the current character to the current column + arr[row][col] += cc; + } + return arr; + } + +} + +export default CSV; \ No newline at end of file diff --git a/src/formats/format.js b/src/formats/format.js new file mode 100644 index 000000000..4150d3503 --- /dev/null +++ b/src/formats/format.js @@ -0,0 +1,115 @@ +import Utils from '../utils.js'; + +export class Format { + + constructor(asset) { + Object.assign(this, asset); + this.context = null; + } + + setContext(context) { + this.context = context; + } + + getContext() { + return this.context; + } + + getUrl() { + return this.href; + } + + canGroup() { + return false; + } + + isBinary() { + return true; + } + + download(filename = null) { + let tempLink = document.createElement('a'); + tempLink.style.display = 'none'; + tempLink.href = this.getUrl(); + tempLink.setAttribute('download', filename ? filename : Utils.makeFileName("result", this.type)); + if (typeof tempLink.download === 'undefined') { + tempLink.setAttribute('target', '_blank'); + } + document.body.appendChild(tempLink); + tempLink.click(); + document.body.removeChild(tempLink); + } + + async getData(connection) { + if (!this.loaded) { + this.data = await this.fetchData(connection); + this.loaded = true; + } + return this.data; + } + + async fetchData(connection) { + let blob; + let url = this.getUrl(); + if (url.startsWith('blob:')) { + let response = await fetch(url); + blob = await response.blob(); + } + else { + blob = await connection.download(url); + } + let promise = new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onload = event => resolve(event.target.result); + reader.onerror = reject; + if (this.isBinary()) { + reader.readAsBinaryString(blob); + } + else { + reader.readAsText(blob); + } + }); + let data = await promise; + return this.parseData(data); + } + + async parseData(data) { + return data; + } + +} + +export class SupportedFormat extends Format { + + constructor(asset, component = null, props = {}, events = {}) { + super(asset); + this.loaded = false; + this.component = component; + this.props = props; + if (!this.props.data) { + this.props.data = this; + } + this.events = events; + } + + isBinary() { + return false; + } + + getIcon() { + return 'fa-database'; + } + +} + +export class UnsupportedFormat extends Format { + + constructor(asset) { + super(asset); + } + +} + +export class FormatCollection extends SupportedFormat { + +} \ No newline at end of file diff --git a/src/formats/formatRegistry.js b/src/formats/formatRegistry.js new file mode 100644 index 000000000..c4067940f --- /dev/null +++ b/src/formats/formatRegistry.js @@ -0,0 +1,74 @@ +import contentType from 'content-type'; + +import BrowserImage from '../formats/browserImage'; +import CSV from '../formats/csv'; +import GeoJSON from '../formats/geojson'; +import GeoTIFF from '../formats/geotiff'; +import JSON_ from '../formats/json'; +import NativeType from './native'; +import TSV from '../formats/tsv'; +import { UnsupportedFormat } from './format'; + +export default class FormatRegistry { + + constructor() { + } + + createFilesFromSTAC(stac, resource = null) { + let files = Object.values(stac.assets) + .map(asset => this.createFileFromAsset(asset, stac)); + if (resource) { + files.forEach(file => file.setContext(resource)); + } + return files; + } + + createFilesFromBlob(data) { + if (!(data instanceof Blob)) { + throw new Error("Given data is not a valid Blob"); + } + return this.createFilesFromSTAC({ + assets: { + result: { + href: URL.createObjectURL(data), + type: data.type + } + } + }); + } + + createFileFromAsset(asset, stac) { + let type = typeof asset.type === 'string' ? asset.type : 'application/octet-stream'; + try { + let mime = contentType.parse(type.toLowerCase()); + switch(mime.type) { + case 'image/png': + case 'image/jpg': + case 'image/jpeg': + case 'image/gif': + case 'image/webp': + return new BrowserImage(asset); + case 'application/json': + case 'text/json': + return new JSON_(asset); + case 'application/geo+json': + return new GeoJSON(asset); + case 'text/plain': + return new NativeType(asset); + case 'text/csv': + return new CSV(asset); + case 'text/tab-separated-values': + return new TSV(asset); + case 'image/tiff': + // We should check for the following, but not all back-ends send correct headers... + // if (mime.parameters.application === 'geotiff') { ... } + return new GeoTIFF(asset); + } + } catch (error) { + console.log(error); + } + + return new UnsupportedFormat(asset); + } + +} \ No newline at end of file diff --git a/src/formats/geojson.js b/src/formats/geojson.js new file mode 100644 index 000000000..6d82172b1 --- /dev/null +++ b/src/formats/geojson.js @@ -0,0 +1,15 @@ +import JSON from './json'; + +class GeoJSON extends JSON { + + constructor(asset) { + super(asset, "MapViewer"); + } + + getIcon() { + return 'fa-map'; + } + +} + +export default GeoJSON; \ No newline at end of file diff --git a/src/formats/geotiff.js b/src/formats/geotiff.js new file mode 100644 index 000000000..645fdbab5 --- /dev/null +++ b/src/formats/geotiff.js @@ -0,0 +1,38 @@ +import { SupportedFormat } from './format'; + +import GeoTIFFSource from 'ol/source/GeoTIFF'; + +class GeoTIFF extends SupportedFormat { + + constructor(asset) { + super(asset, "MapViewer", {removableLayers: true}); + this.view = null; + } + + getIcon() { + return 'fa-map'; + } + + isBinary() { + return true; + } + + canGroup() { + return true; + } + + getView() { + return this.view; + } + + async getData() { + let geotiff = new GeoTIFFSource({ sources: [{ + url: this.getUrl() + }] }); + this.view = await geotiff.getView(); + return geotiff; + } + +} + +export default GeoTIFF; \ No newline at end of file diff --git a/src/formats/json.js b/src/formats/json.js new file mode 100644 index 000000000..490a49305 --- /dev/null +++ b/src/formats/json.js @@ -0,0 +1,22 @@ +import { SupportedFormat } from './format'; + +class JSON_ extends SupportedFormat { + + constructor(asset, component = "DataViewer") { + super(asset, component); + } + + async parseData(data) { + if (typeof data === 'string') { + try { + return JSON.parse(data); + } + catch (error) { + console.log(error); + } + } + return data; + } +} + +export default JSON_; \ No newline at end of file diff --git a/src/formats/native.js b/src/formats/native.js new file mode 100644 index 000000000..020b65475 --- /dev/null +++ b/src/formats/native.js @@ -0,0 +1,11 @@ +import { SupportedFormat } from './format'; + +class NativeType extends SupportedFormat { + + constructor(asset) { + super(asset, "DataViewer"); + } + +} + +export default NativeType; \ No newline at end of file diff --git a/src/formats/tsv.js b/src/formats/tsv.js new file mode 100644 index 000000000..2ac4e7aad --- /dev/null +++ b/src/formats/tsv.js @@ -0,0 +1,11 @@ +import CSV from './csv'; + +class TSV extends CSV { + + constructor(asset) { + super(asset, ["\t"]); + } + +} + +export default TSV; \ No newline at end of file diff --git a/src/utils.js b/src/utils.js index 72586d606..431b5dffc 100644 --- a/src/utils.js +++ b/src/utils.js @@ -17,6 +17,10 @@ class Utils extends VueUtils { } static displayRGBA(value, min = 0, max = 255, nodata = null, precision = null) { + let NA = 'no data'; + if (typeof value === 'undefined' || value === null) { + return NA; + } let rgba = Array.from(value); if (rgba.length === 0) { return '-'; @@ -33,10 +37,19 @@ class Utils extends VueUtils { return x; }); } - let [r,g,b] = rgba; + let r, g, b; + if (rgba.length >= 3) { + [r,g,b] = rgba; + } + else if (rgba.length === 1) { + r = g = b = rgba[0]; + } + else { + r = g = b = nodata; + } if (a === 0 || r === nodata || g === nodata || b === nodata) { // Transparent (no-data) - return 'no data'; + return NA; } else if (r == g && g === b) { if (a === 255) { @@ -122,13 +135,7 @@ class Utils extends VueUtils { }; vm.$snotify.confirm(message, null, Object.assign({}, vm.$config.snotifyDefaults, typeDefaults)); } - - static blobToText(blob, callback) { - var reader = new FileReader(); - reader.onload = callback; - reader.readAsText(blob.blob); - } - + static isChildOfModal(that) { return that.$parent && that.$parent.$options.name == 'Modal'; }