UNPKG

terriajs

Version:

Geospatial data visualization platform.

700 lines (601 loc) 27 kB
'use strict'; /*global require*/ var Cartesian3 = require('terriajs-cesium/Source/Core/Cartesian3'); var Color = require('terriajs-cesium/Source/Core/Color'); var ColorMaterialProperty = require('terriajs-cesium/Source/DataSources/ColorMaterialProperty'); var defined = require('terriajs-cesium/Source/Core/defined'); var defineProperties = require('terriajs-cesium/Source/Core/defineProperties'); var DeveloperError = require('terriajs-cesium/Source/Core/DeveloperError'); var Entity = require('terriajs-cesium/Source/DataSources/Entity'); var knockout = require('terriajs-cesium/Source/ThirdParty/knockout'); var loadBlob = require('../Core/loadBlob'); var loadJson = require('../Core/loadJson'); var PolylineGraphics = require('terriajs-cesium/Source/DataSources/PolylineGraphics'); var Rectangle = require('terriajs-cesium/Source/Core/Rectangle'); var when = require('terriajs-cesium/Source/ThirdParty/when'); var defaultValue = require('terriajs-cesium/Source/Core/defaultValue'); var zip = require('terriajs-cesium/Source/ThirdParty/zip'); var topojson = require('terriajs-cesium/Source/ThirdParty/topojson'); var PointGraphics = require('terriajs-cesium/Source/DataSources/PointGraphics'); var DataSourceCatalogItem = require('./DataSourceCatalogItem'); var standardCssColors = require('../Core/standardCssColors'); var formatPropertyValue = require('../Core/formatPropertyValue'); var hashFromString = require('../Core/hashFromString'); var inherit = require('../Core/inherit'); var Metadata = require('./Metadata'); var promiseFunctionToExplicitDeferred = require('../Core/promiseFunctionToExplicitDeferred'); var proxyCatalogItemUrl = require('./proxyCatalogItemUrl'); var readJson = require('../Core/readJson'); var TerriaError = require('../Core/TerriaError'); var Reproject = require('../Map/Reproject'); /** * A {@link CatalogItem} representing GeoJSON feature data. * * @alias GeoJsonCatalogItem * @constructor * @extends CatalogItem * * @param {Terria} terria The Terria instance. * @param {String} [url] The URL from which to retrieve the GeoJSON data. */ var GeoJsonCatalogItem = function(terria, url) { DataSourceCatalogItem.call(this, terria); this._dataSource = undefined; this._readyData = undefined; this.url = url; /** * Gets or sets the GeoJSON data, represented as a binary blob, object literal, or a Promise for one of those things. * If this property is set, {@link CatalogItem#url} is ignored. * This property is observable. * @type {Blob|Object|Promise} */ this.data = undefined; /** * Gets or sets the URL from which the {@link GeoJsonCatalogItem#data} was obtained. This will be used * to resolve any resources linked in the GeoJSON file, if any. * @type {String} */ this.dataSourceUrl = undefined; /** * Gets or sets an object of style information which will be used instead of the default, but won't override * styles set on individual GeoJSON features. Styles follow the SimpleStyle spec: https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0 * `marker-opacity` and numeric values for `marker-size` are also supported. * @type {Object} */ this.style = undefined; knockout.track(this, ['data', 'dataSourceUrl', 'style']); }; inherit(DataSourceCatalogItem, GeoJsonCatalogItem); defineProperties(GeoJsonCatalogItem.prototype, { /** * Gets the type of data member represented by this instance. * @memberOf GeoJsonCatalogItem.prototype * @type {String} */ type : { get : function() { return 'geojson'; } }, /** * Gets a human-readable name for this type of data source, 'GeoJSON'. * @memberOf GeoJsonCatalogItem.prototype * @type {String} */ typeName : { get : function() { return 'GeoJSON'; } }, /** * Gets the metadata associated with this data source and the server that provided it, if applicable. * @memberOf GeoJsonCatalogItem.prototype * @type {Metadata} */ metadata : { get : function() { // TODO: maybe return the FeatureCollection's properties? var result = new Metadata(); result.isLoading = false; result.dataSourceErrorMessage = 'This data source does not have any details available.'; result.serviceErrorMessage = 'This service does not have any details available.'; return result; } }, /** * Gets the data source associated with this catalog item. * @memberOf GeoJsonCatalogItem.prototype * @type {DataSource} */ dataSource : { get : function() { return this._dataSource; } } }); GeoJsonCatalogItem.prototype._getValuesThatInfluenceLoad = function() { return [this.url, this.data]; }; var zipFileRegex = /.zip\b/i; var geoJsonRegex = /.geojson\b/i; var simpleStyleIdentifiers = ['title', 'description', // 'marker-size', 'marker-symbol', 'marker-color', 'stroke', // 'stroke-opacity', 'stroke-width', 'fill', 'fill-opacity']; // This next function modelled on Cesium.geoJsonDataSource's defaultDescribe. function describeWithoutUnderscores(properties, nameProperty) { var html = ''; for (var key in properties) { if (properties.hasOwnProperty(key)) { if (key === nameProperty || simpleStyleIdentifiers.indexOf(key) !== -1) { continue; } var value = properties[key]; if (typeof value === 'object') { value = describeWithoutUnderscores(value); } else { value = formatPropertyValue(value); } key = key.replace(/_/g, ' '); if (defined(value)) { html += '<tr><th>' + key + '</th><td>' + value + '</td></tr>'; } } } if (html.length > 0) { html = '<table class="cesium-infoBox-defaultTable"><tbody>' + html + '</tbody></table>'; } return html; } GeoJsonCatalogItem.prototype._load = function() { var codeSplitDeferred = when.defer(); var that = this; require.ensure('terriajs-cesium/Source/DataSources/GeoJsonDataSource', function() { var GeoJsonDataSource = require('terriajs-cesium/Source/DataSources/GeoJsonDataSource'); promiseFunctionToExplicitDeferred(codeSplitDeferred, function() { // If there is an existing data source, remove it first. var reAdd = false; if (defined(that._dataSource)) { reAdd = that.terria.dataSources.remove(that._dataSource, true); } that._dataSource = new GeoJsonDataSource(that.name); if (reAdd) { that.terria.dataSources.add(that._dataSource); } if (defined(that.data)) { return when(that.data, function(data) { var promise; if (typeof Blob !== 'undefined' && data instanceof Blob) { promise = readJson(data); } else if (data instanceof String || typeof data === 'string') { try { promise = JSON.parse(data); } catch(e) { throw new TerriaError({ sender: that, title: 'Error loading GeoJSON', message: '\ An error occurred parsing the provided data as JSON. This may indicate that the file is invalid or that it \ is not supported by '+that.terria.appName+'. If you would like assistance or further information, please email us \ at <a href="mailto:'+that.terria.supportEmail+'">'+that.terria.supportEmail+'</a>.' }); } } else { promise = data; } return when(promise, function(json) { that.data = json; return updateModelFromData(that, json); }).otherwise(function() { throw new TerriaError({ sender: that, title: 'Error loading GeoJSON', message: '\ An error occurred while loading a GeoJSON file. This may indicate that the file is invalid or that it \ is not supported by '+that.terria.appName+'. If you would like assistance or further information, please email us \ at <a href="mailto:'+that.terria.supportEmail+'">'+that.terria.supportEmail+'</a>.' }); }); }); } else { var jsonPromise; if (zipFileRegex.test(that.url)) { if (typeof FileReader === 'undefined') { throw new TerriaError({ sender: that, title: 'Unsupported web browser', message: '\ Sorry, your web browser does not support the File API, which '+that.terria.appName+' requires in order to \ load this dataset. Please upgrade your web browser. For the best experience, we recommend the latest versions of \ <a href="http://www.google.com/chrome" target="_blank">Google Chrome</a>, or \ <a href="http://www.mozilla.org/firefox" target="_blank">Mozilla Firefox</a>, or \ <a href="http://www.microsoft.com/ie" target="_blank">Internet Explorer 11</a>.' }); } jsonPromise = loadBlob(proxyCatalogItemUrl(that, that.url, '1d')).then(function(blob) { var deferred = when.defer(); zip.createReader(new zip.BlobReader(blob), function(reader) { // Look for a file with a .geojson extension. reader.getEntries(function(entries) { var resolved = false; for (var i = 0; i < entries.length; i++) { var entry = entries[i]; if (geoJsonRegex.test(entry.filename)) { getJson(entry, deferred); resolved = true; } } if (!resolved) { deferred.reject(); } }); }, function(e) { deferred.reject(e); }); return deferred.promise; }); } else { jsonPromise = loadJson(proxyCatalogItemUrl(that, that.url, '1d')); } return jsonPromise.then(function(json) { return updateModelFromData(that, json); }).otherwise(function(e) { if (e instanceof TerriaError) { throw e; } throw new TerriaError({ sender: that, title: 'Could not load JSON', message: '\ An error occurred while retrieving JSON data from the provided link. \ <p>If you entered the link manually, please verify that the link is correct.</p>\ <p>This error may also indicate that the server does not support <a href="http://enable-cors.org/" target="_blank">CORS</a>. If this is your \ server, verify that CORS is enabled and enable it if it is not. If you do not control the server, \ please contact the administrator of the server and ask them to enable CORS. Or, contact the '+that.terria.appName+' \ team by emailing <a href="mailto:'+that.terria.supportEmail+'">'+that.terria.supportEmail+'</a> \ and ask us to add this server to the list of non-CORS-supporting servers that may be proxied by '+that.terria.appName+' \ itself.</p>\ <p>If you did not enter this link manually, this error may indicate that the data source you\'re trying to add is temporarily unavailable or there is a \ problem with your internet connection. Try adding the data source again, and if the problem persists, please report it by \ sending an email to <a href="mailto:'+that.terria.supportEmail+'">'+that.terria.supportEmail+'</a>.</p>' }); }); } }); }, 'Cesium-DataSources'); return codeSplitDeferred.promise; }; function getJson(entry, deferred) { entry.getData(new zip.Data64URIWriter(), function(uri) { deferred.resolve(loadJson(uri)); }); } function updateModelFromData(geoJsonItem, geoJson) { // If this GeoJSON data is an object literal with a single property, treat that // property as the name of the data source, and the property's value as the // actual GeoJSON. var numProperties = 0; var propertyName; for (propertyName in geoJson) { if (geoJson.hasOwnProperty(propertyName)) { ++numProperties; if (numProperties > 1) { break; // no need to count past 2 properties. } } } var name; if (numProperties === 1) { name = propertyName; geoJson = geoJson[propertyName]; // If we don't already have a name, or our name is just derived from our URL, update the name. if (!defined(geoJsonItem.name) || geoJsonItem.name.length === 0 || nameIsDerivedFromUrl(geoJsonItem.name, geoJsonItem.url)) { geoJsonItem.name = name; } } // Reproject the features if they're not already EPSG:4326. var promise = reprojectToGeographic(geoJsonItem, geoJson); return when(promise, function() { // If we don't already have a rectangle, compute one. if (!defined(geoJsonItem.rectangle) || Rectangle.equals(geoJsonItem.rectangle, Rectangle.MAX_VALUE)) { geoJsonItem.rectangle = getGeoJsonExtent(geoJson); } geoJsonItem._readyData = geoJson; return loadGeoJson(geoJsonItem); }); } function nameIsDerivedFromUrl(name, url) { if (name === url) { return true; } if (!url) { return false; } // Is the name just the end of the URL? var indexOfNameInUrl = url.lastIndexOf(name); if (indexOfNameInUrl >= 0 && indexOfNameInUrl === url.length - name.length) { return true; } return false; } /** * Get a random color for the data based on the passed string (usually dataset name). * @private * @param {String[]} cssColors Array of css colors, eg. ['#AAAAAA', 'red']. * @param {String} name Name to base the random choice on. * @return {String} A css color, eg. 'red'. */ function getRandomCssColor(cssColors, name) { var index = hashFromString(name || '') % cssColors.length; return cssColors[index]; } function loadGeoJson(geoJsonItem) { /* Style information is applied as follows, in decreasing priority: - simple-style properties set directly on individual features in the GeoJSON file - simple-style properties set as the 'Style' property on the catalog item - our 'options' set below (and point styling applied after Cesium loads the GeoJSON) - if anything is underspecified there, then Cesium's defaults come in. See https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0 */ function defaultColor(colorString, name) { if (colorString === undefined) { var color = Color.fromCssColorString(getRandomCssColor(standardCssColors.highContrast, name)); color.alpha = 1; return color; } else { return Color.fromCssColorString(colorString); } } function getColor(color) { if (typeof color === 'string' || color instanceof String) { return Color.fromCssColorString(color); } else { return color; } } function parseMarkerSize(sizeString) { var sizes = { small : 24, medium : 48, large : 64 }; if (sizeString === undefined) { return undefined; } if (sizes[sizeString]) { return sizes[sizeString]; } return parseInt(sizeString, 10); // SimpleStyle doesn't allow 'marker-size: 20', but people will do it. } var dataSource = geoJsonItem._dataSource; var style = defaultValue(geoJsonItem.style, {}); var options = { describe: describeWithoutUnderscores, markerSize : defaultValue(parseMarkerSize(style['marker-size']), 20), markerSymbol: style['marker-symbol'], // and undefined if none markerColor : defaultColor(style['marker-color'], geoJsonItem.name), strokeWidth : defaultValue(style['stroke-width'], 2), polygonStroke: getColor(defaultValue(style.stroke, '#000000')), polylineStroke: defaultColor(style.stroke, geoJsonItem.name), markerOpacity: style['marker-opacity'] // not in SimpleStyle spec or supported by Cesium but see below }; options.fill = defaultColor(style.fill, (geoJsonItem.name || '') + ' fill'); if (defined(style['stroke-opacity'])) { options.stroke.alpha = parseFloat(style['stroke-opacity']); } if (defined(style['fill-opacity'])) { options.fill.alpha = parseFloat(style['fill-opacity']); } else { options.fill.alpha = 0.75; } return dataSource.load(geoJsonItem._readyData, options).then(function() { var entities = dataSource.entities.values; for (var i = 0; i < entities.length; ++i) { var entity = entities[i]; /* If no marker symbol was provided but Cesium has generated one for a point, then turn it into a filled circle instead of the default marker. */ var properties = entity.properties || {}; if (defined(entity.billboard) && !defined(properties['marker-symbol']) && !defined(options.markerSymbol)) { entity.point = new PointGraphics({ color: getColor(defaultValue(properties['marker-color'], options.markerColor)), pixelSize: defaultValue(properties['marker-size'], options.markerSize / 2), outlineWidth: defaultValue(properties['stroke-width'], options.strokeWidth), outlineColor: getColor(defaultValue(properties.stroke, options.polygonStroke)) }); if (defined (properties['marker-opacity'])) { // not part of SimpleStyle spec, but why not? entity.point.color.alpha = parseFloat(properties['marker-opacity']); } entity.billboard = undefined; } if (defined(entity.billboard) && defined(properties['marker-opacity'])) { entity.billboard.color = new Color(1.0, 1.0, 1.0, parseFloat(properties['marker-opacity'])); } // Cesium on Windows can't render polygons with a stroke-width > 1.0. And even on other platforms it // looks bad because WebGL doesn't mitre the lines together nicely. // As a workaround for the special case where the polygon is unfilled anyway, change it to a polyline. if (defined(entity.polygon) && polygonHasWideOutline(entity.polygon) && !polygonIsFilled(entity.polygon)) { entity.polyline = new PolylineGraphics(); entity.polyline.show = entity.polygon.show; if (defined(entity.polygon.outlineColor)) { entity.polyline.material = new ColorMaterialProperty(entity.polygon.outlineColor.getValue()); } var hierarchy = entity.polygon.hierarchy.getValue(); var positions = hierarchy.positions; closePolyline(positions); entity.polyline.positions = positions; entity.polyline.width = entity.polygon.outlineWidth; createEntitiesFromHoles(dataSource.entities, hierarchy.holes, entity); entity.polygon = undefined; } } }); } function createEntitiesFromHoles(entityCollection, holes, mainEntity) { if (!defined(holes)) { return; } for (var i = 0; i < holes.length; ++i) { createEntityFromHole(entityCollection, holes[i], mainEntity); } } function createEntityFromHole(entityCollection, hole, mainEntity) { if (!defined(hole) || !defined(hole.positions) || hole.positions.length === 0) { return; } var entity = new Entity(); entity.name = mainEntity.name; entity.availability = mainEntity.availability; entity.description = mainEntity.description; entity.properties = mainEntity.properties; entity.polyline = new PolylineGraphics(); entity.polyline.show = mainEntity.polyline.show; entity.polyline.material = mainEntity.polyline.material; entity.polyline.width = mainEntity.polyline.width; closePolyline(hole.positions); entity.polyline.positions = hole.positions; entityCollection.add(entity); createEntitiesFromHoles(entityCollection, hole.holes, mainEntity); } function closePolyline(positions) { // If the first and last positions are more than a meter apart, duplicate the first position so the polyline is closed. if (positions.length >= 2 && !Cartesian3.equalsEpsilon(positions[0], positions[positions.length - 1], 0.0, 1.0)) { positions.push(positions[0]); } } function polygonHasWideOutline(polygon) { return defined(polygon.outlineWidth) && polygon.outlineWidth.getValue() > 1; } function polygonIsFilled(polygon) { var fill = true; if (defined(polygon.fill)) { fill = polygon.fill.getValue(); } if (!fill) { return false; } if (!defined(polygon.material)) { // The default is solid white. return true; } var materialProperties = polygon.material.getValue(); if (defined(materialProperties) && defined(materialProperties.color) && materialProperties.color.alpha === 0.0) { return false; } return true; } function reprojectToGeographic(geoJsonItem, geoJson) { var code; if (!defined(geoJson.crs)) { code = undefined; } else if (geoJson.crs.type === 'EPSG') { code = 'EPSG:' + geoJson.crs.properties.code; } else if (geoJson.crs.type === 'name' && defined(geoJson.crs.properties) && defined(geoJson.crs.properties.name)) { code = Reproject.crsStringToCode(geoJson.crs.properties.name); } geoJson.crs = { type: 'EPSG', properties: { code: '4326' } }; if (!Reproject.willNeedReprojecting(code)) { return true; } return when(Reproject.checkProjection(geoJsonItem.terria.configParameters.proj4ServiceBaseUrl, code), function(result) { if (result) { filterValue( geoJson, 'coordinates', function(obj, prop) { obj[prop] = filterArray( obj[prop], function(pts) { return reprojectPointList(pts, code); }); }); } else { throw new DeveloperError('The crs code for this datasource is unsupported.'); } }); } // Reproject a point list based on the supplied crs code. function reprojectPointList(pts, code) { if (!(pts[0] instanceof Array)) { return Reproject.reprojectPoint(pts, code, "EPSG:4326"); } var pts_out = []; for (var i = 0; i < pts.length; i++) { pts_out.push(Reproject.reprojectPoint(pts[i], code, "EPSG:4326")); } return pts_out; } // Find a member by name in the gml. function filterValue(obj, prop, func) { for (var p in obj) { if (obj.hasOwnProperty(p) === false) { continue; } else if (p === prop) { if (func && (typeof func === 'function')) { (func)(obj, prop); } } else if (typeof obj[p] === 'object') { filterValue(obj[p], prop, func); } } } // Filter a geojson coordinates array structure. function filterArray(pts, func) { if (!(pts[0] instanceof Array) || !((pts[0][0]) instanceof Array) ) { pts = func(pts); return pts; } var result = new Array(pts.length); for (var i = 0; i < pts.length; i++) { result[i] = filterArray(pts[i], func); //at array of arrays of points } return result; } // Get Extent of geojson. function getExtent(pts, ext) { if (!(pts[0] instanceof Array) ) { if (pts[0] < ext.west) { ext.west = pts[0]; } if (pts[0] > ext.east) { ext.east = pts[0]; } if (pts[1] < ext.south) { ext.south = pts[1]; } if (pts[1] > ext.north) { ext.north = pts[1]; } } else if (!((pts[0][0]) instanceof Array) ) { for (var i = 0; i < pts.length; i++) { getExtent(pts[i], ext); } } else { for (var j = 0; j < pts.length; j++) { getExtent(pts[j], ext); // An array of arrays of points. } } } function getGeoJsonExtent(geoJson) { var testGeometry = geoJson; if(defined(geoJson.type) && geoJson.type === 'Topology') { testGeometry = topoJsonToFeaturesArray(geoJson); } var ext = {west:180, east:-180, south:90, north: -90}; filterValue(testGeometry, 'coordinates', function(obj, prop) { getExtent(obj[prop], ext); }); return Rectangle.fromDegrees(ext.west, ext.south, ext.east, ext.north); } function topoJsonToFeaturesArray(topoJsonData) { var result = []; for(var object in topoJsonData.objects) { if(topoJsonData.objects.hasOwnProperty(object)) { result.push(topojson.feature(topoJsonData, topoJsonData.objects[object])); } } return result; } module.exports = GeoJsonCatalogItem;