"use strict";
import URI from "urijs";
import CesiumMath from "terriajs-cesium/Source/Core/Math";
import defined from "terriajs-cesium/Source/Core/defined";
import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid";
import { CHART_DATA_CATEGORY_NAME } from "../../../../Core/addedForCharts";
import combineFilters from "../../../../Core/combineFilters";
import CatalogMember from "../../../../Models/CatalogMember";
import hashEntity from "../../../../Core/hashEntity";
import ViewerMode from "../../../../Models/ViewerMode";
const userPropWhiteList = ["hideExplorerPanel", "activeTabId"];
export const SHARE_VERSION = "0.0.05";
/**
* Builds a share link that reflects the state of the passed Terria instance.
*
* @param terria The terria instance to serialize.
* @param {ViewState} [viewState] The viewState to read whether we're viewing the catalog or not
* @param {Object} [options] Options for building the share link.
* @param {Boolean} [options.includeStories=true] True to include stories in the share link, false to exclude them.
* @returns {String} A URI that will rebuild the current state when viewed in a browser.
*/
export function buildShareLink(
terria,
viewState,
options = { includeStories: true }
) {
const uri = new URI(window.location).fragment("").search({
start: JSON.stringify(getShareData(terria, viewState, options))
});
userPropWhiteList.forEach(key =>
uri.addSearch({ key: terria.userProperties[key] })
);
return uri
.fragment(uri.query())
.query("")
.toString(); // replace ? with #
}
/**
* Returns just the JSON that defines the current view.
* @param {Object} terria The Terria object.
* @param {ViewState} [viewState] Current viewState.
* @return {Object}
*/
export function getShareData(
terria,
viewState,
options = { includeStories: true }
) {
const { includeStories } = options;
const initSources = includeStories ? terria.initSources.slice() : [];
addUserAddedCatalog(terria, initSources);
addSharedMembers(terria, initSources);
addViewSettings(terria, viewState, initSources);
addFeaturePicking(terria, initSources);
addLocationMarker(terria, initSources);
addTimeline(terria, initSources);
if (includeStories) {
// info that are not needed in scene share data
addStories(terria, initSources);
}
return {
version: SHARE_VERSION,
initSources: initSources
};
}
/**
* Is it currently possible to generate short URLs?
* @param {Object} terria The Terria object.
* @return {Boolean}
*/
export function canShorten(terria) {
return (
(terria.urlShortener && terria.urlShortener.isUsable) ||
(terria.shareDataService && terria.shareDataService.isUsable)
);
}
/**
* Like {@link buildShareLink}, but shortens the result using {@link Terria#urlShortener}.
*
* @returns {Promise<String>} A promise that will return the shortened url when complete.
*/
export function buildShortShareLink(
terria,
viewState,
options = { includeStories: true }
) {
const urlFromToken = token =>
new URI(window.location).fragment("share=" + token).toString();
if (defined(terria.shareDataService)) {
return terria.shareDataService
.getShareToken(getShareData(terria, viewState, options))
.then(urlFromToken);
} else {
return terria.urlShortener
.shorten(buildShareLink(terria, viewState, options))
.then(urlFromToken);
} // we assume that URL shortener is defined.
}
/**
* Adds user-added catalog members to the passed initSources.
* @private
*/
export function addUserAddedCatalog(terria, initSources) {
const localDataFilterRemembering = rememberRejections(
CatalogMember.itemFilters.noLocalData
);
const userAddedCatalog = terria.catalog.serializeToJson({
itemFilter: combineFilters([
localDataFilterRemembering.filter,
CatalogMember.itemFilters.userSuppliedOnly,
function(item) {
// Chart group should be regenerated through lib/Models/Catalog.js's 'chartDataGroup' property once charting
// items are loaded again, otherwise it overwrites certain properties through including unnecessarily
// serialised items
return !(item.name === CHART_DATA_CATEGORY_NAME);
},
function(item) {
// If the parent has a URL then this item will just load from that, so don't bother serializing it.
// Properties that change when an item is enabled like opacity will be included in the shared members
// anyway.
return !item.parent || !item.parent.url;
}
])
});
// Add an init source with user-added catalog members.
if (userAddedCatalog.length > 0) {
initSources.push({
catalog: userAddedCatalog
});
}
return localDataFilterRemembering.rejections;
}
/**
* Adds existing catalog members that the user has enabled or opened to the passed initSources object.
* @private
*/
function addSharedMembers(terria, initSources) {
const catalogForSharing = flattenCatalog(
terria.catalog.serializeToJson({
itemFilter: combineFilters([
function(item) {
if (CatalogMember.itemFilters.noLocalData(item)) {
return true;
} else if (CatalogMember.itemFilters.isCsvForCharting(item)) {
return true;
}
return false;
}
]),
propertyFilter: combineFilters([
CatalogMember.propertyFilters.sharedOnly,
function(property, item) {
return property !== "name" || item.type === "csv";
}
])
})
)
.filter(function(item) {
return item.isEnabled || item.isOpen;
})
.reduce(function(soFar, item) {
soFar[item.id] = item;
item.id = undefined;
return soFar;
}, {});
// Eliminate open groups without all ancestors open
Object.keys(catalogForSharing).forEach(key => {
const item = catalogForSharing[key];
const isGroupWithClosedParent =
item.isOpen &&
item.parents.some(parentId => !catalogForSharing[parentId]);
if (isGroupWithClosedParent) {
catalogForSharing[key] = undefined;
}
});
if (Object.keys(catalogForSharing).length > 0) {
initSources.push({
sharedCatalogMembers: catalogForSharing
});
}
}
/**
* Adds the details of the current view to the init sources.
* @private
*/
function addViewSettings(terria, viewState, initSources) {
const cameraExtent = terria.currentViewer.getCurrentExtent();
// Add an init source with the camera position.
const initialCamera = {
west: CesiumMath.toDegrees(cameraExtent.west),
south: CesiumMath.toDegrees(cameraExtent.south),
east: CesiumMath.toDegrees(cameraExtent.east),
north: CesiumMath.toDegrees(cameraExtent.north)
};
if (defined(terria.cesium)) {
const cesiumCamera = terria.cesium.scene.camera;
initialCamera.position = cesiumCamera.positionWC;
initialCamera.direction = cesiumCamera.directionWC;
initialCamera.up = cesiumCamera.upWC;
}
const homeCamera = {
west: CesiumMath.toDegrees(terria.homeView.rectangle.west),
south: CesiumMath.toDegrees(terria.homeView.rectangle.south),
east: CesiumMath.toDegrees(terria.homeView.rectangle.east),
north: CesiumMath.toDegrees(terria.homeView.rectangle.north),
position: terria.homeView.position,
direction: terria.homeView.direction,
up: terria.homeView.up
};
const time = {
dayNumber: terria.clock.currentTime.dayNumber,
secondsOfDay: terria.clock.currentTime.secondsOfDay
};
let viewerMode;
switch (terria.viewerMode) {
case ViewerMode.CesiumTerrain:
viewerMode = "3d";
break;
case ViewerMode.CesiumEllipsoid:
viewerMode = "3dSmooth";
break;
case ViewerMode.Leaflet:
viewerMode = "2d";
break;
}
const terriaSettings = {
initialCamera: initialCamera,
homeCamera: homeCamera,
baseMapName: terria.baseMap.name,
viewerMode: viewerMode,
currentTime: time
};
if (defined(viewState)) {
const itemIdToUse = viewState.viewingUserData()
? defined(viewState.userDataPreviewedItem) &&
viewState.userDataPreviewedItem.uniqueId
: defined(viewState.previewedItem) && viewState.previewedItem.uniqueId;
// allow for sharing just the explorer-window-is-open if we decide the UI can do that in the future
if (viewState.explorerPanelIsVisible) {
terriaSettings.sharedFromExplorerPanel = viewState.explorerPanelIsVisible;
}
// don't persist the not-visible-to-user previewed id in the case of sharing from outside the catalog
if (viewState.explorerPanelIsVisible && itemIdToUse) {
terriaSettings.previewedItemId = itemIdToUse;
}
}
if (defined(terria.showSplitter)) {
terriaSettings.showSplitter = terria.showSplitter;
terriaSettings.splitPosition = terria.splitPosition;
}
initSources.push(terriaSettings);
}
/**
* Add details of currently picked features.
* @private
*/
function addFeaturePicking(terria, initSources) {
if (
defined(terria.pickedFeatures) &&
terria.pickedFeatures.features.length > 0
) {
const positionInRadians = Ellipsoid.WGS84.cartesianToCartographic(
terria.pickedFeatures.pickPosition
);
const pickedFeatures = {
providerCoords: terria.pickedFeatures.providerCoords,
pickCoords: {
lat: CesiumMath.toDegrees(positionInRadians.latitude),
lng: CesiumMath.toDegrees(positionInRadians.longitude),
height: positionInRadians.height
}
};
if (defined(terria.selectedFeature)) {
// Sometimes features have stable ids and sometimes they're randomly generated every time, so include both
// id and name as a fallback.
pickedFeatures.current = {
name: terria.selectedFeature.name,
hash: hashEntity(terria.selectedFeature, terria.clock)
};
}
// Remember the ids of vector features only, the raster ones we can reconstruct from providerCoords.
pickedFeatures.entities = terria.pickedFeatures.features
.filter(feature => !defined(feature.imageryLayer))
.map(entity => {
return {
name: entity.name,
hash: hashEntity(entity, terria.clock)
};
});
initSources.push({
pickedFeatures: pickedFeatures
});
}
}
/**
* Add details of the location marker if it is set.
* @private
*/
function addLocationMarker(terria, initSources) {
if (defined(terria.locationMarker)) {
const position = terria.locationMarker.entities.values[0].position.getValue();
const positionDegrees = Ellipsoid.WGS84.cartesianToCartographic(position);
initSources.push({
locationMarker: {
name: terria.locationMarker.entities.values[0].name,
latitude: CesiumMath.toDegrees(positionDegrees.latitude),
longitude: CesiumMath.toDegrees(positionDegrees.longitude)
}
});
}
}
function addTimeline(terria, initSources) {
if (terria.timeSeriesStack.topLayer) {
initSources.push({
timeline: {
shouldAnimate: terria.clock.shouldAnimate,
multiplier: terria.clock.multiplier,
currentTime: {
dayNumber: terria.clock.currentTime.dayNumber,
secondsOfDay: terria.clock.currentTime.secondsOfDay
}
}
});
}
}
function addStories(terria, initSources) {
if (defined(terria.stories)) {
initSources.push({
stories: terria.stories.slice()
});
}
}
/**
* Wraps around a filter function and records all items that are excluded by it. Does not modify the function passed in.
*
* @param filterFn The fn to wrap around
* @returns {{filter: filter, rejections: Array}} The resulting filter function that remembers rejections, and an array
* array of the rejected items. As the filter function is used, the rejections array with be populated.
*/
function rememberRejections(filterFn) {
const rejections = [];
return {
filter: function(item) {
const allowed = filterFn(item);
if (!allowed) {
rejections.push(item);
}
return allowed;
},
rejections: rejections
};
}
/**
* Takes the hierarchy of serialized catalog members returned by {@link serializeToJson} and flattens it into an Array.
* @returns {Array}
*/
function flattenCatalog(items) {
return items.reduce(function(soFar, item) {
soFar.push(item);
if (item.items) {
soFar = soFar.concat(flattenCatalog(item.items));
item.items = undefined;
}
return soFar;
}, []);
}