terriajs
Version:
Geospatial data visualization platform.
700 lines (601 loc) • 27 kB
JavaScript
'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;