terriajs
Version:
Geospatial data visualization platform.
390 lines • 16.5 kB
JavaScript
import { action, runInAction } from "mobx";
import { createElement } from "react";
import createGuid from "terriajs-cesium/Source/Core/createGuid";
import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError";
import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid";
import JulianDate from "terriajs-cesium/Source/Core/JulianDate";
import CesiumMath from "terriajs-cesium/Source/Core/Math";
import filterOutUndefined from "../../Core/filterOutUndefined";
import { getName } from "../../ModelMixins/CatalogMemberMixin";
import SplitItemReference from "../../Models/Catalog/CatalogReferences/SplitItemReference";
import CommonStrata from "../../Models/Definition/CommonStrata";
import createStratumInstance from "../../Models/Definition/createStratumInstance";
import hasTraits from "../../Models/Definition/hasTraits";
import ChartPointOnMapTraits from "../../Traits/TraitsClasses/ChartPointOnMapTraits";
import DiscretelyTimeVaryingTraits from "../../Traits/TraitsClasses/DiscretelyTimeVaryingTraits";
import LatLonHeightTraits from "../../Traits/TraitsClasses/LatLonHeightTraits";
import ChartPreviewStyles from "./Chart/chart-preview.scss";
import ChartExpandAndDownloadButtons from "./Chart/ChartExpandAndDownloadButtons";
import Chart from "./Chart/FeatureInfoPanelChart";
import CustomComponent from "./CustomComponent";
/**
* Valid attributes of a <chart> component.
*/
export const ChartAttributes = [
"src",
"src-preview",
"sources",
"source-names",
"downloads",
"download-names",
"preview-x-label",
"data",
"identifier",
"x-column",
"y-column",
"y-columns",
"column-titles",
"column-units",
"styling",
"highlight-x",
"title",
"can-download",
"hide-buttons"
];
/**
* A chart custom component. It displays an interactive chart along with
* "expand" and "download" buttons. The expand button adds a catalog item with
* the data to the workbench, causing it to be displayed on the Chart Panel.
* The chart detects if it appears in the second column of a <table> and, if so,
* rearranges itself to span two columns.
*
* See {see ChartCustomComponentAttributes} for a full list of attributes.
*
* Provide the data in one of these four ways:
* - [sources]: {see ChartCustomComponentAttributes.sources}
* - [source-names]: {see ChartCustomComponentAttributes.sourceNames}
* - [downloads]: {see ChartCustomComponentAttributes.downloads}
* - [download-names]: {see ChartCustomComponentAttributes.downloadNames}
* Or:
* - [src]: {see ChartCustomComponentAttributes.src}
* - [src-preview]: {see ChartCustomComponentAttributes.srcPreview}
* Or:
* - [data]: {see ChartCustomComponentAttributes.data}
* Or:
* - None of the above, but supply csv or json-formatted data as the content of the chart data, with \n for newlines.
* Eg. `<chart>time,a,b\n2016-01-01,2,3\n2016-01-02,5,6</chart>`.
* or `<chart>[["x","y","z"],[1,10,3],[2,15,9],[3,8,12],[5,25,4]]</chart>`.
*/
export default class ChartCustomComponent extends CustomComponent {
chartItemId;
get attributes() {
return ChartAttributes;
}
shouldProcessNode(_context, node) {
return (this.isChart(node) ||
this.isFirstColumnOfChartRow(node) ||
this.isSecondColumnOfChartRow(node));
}
processNode(context, node, children, index) {
if (this.isChart(node)) {
return this.processChart(context, node, children, index);
}
else if (this.isFirstColumnOfChartRow(node)) {
return this.processFirstColumn(context, node, children, index);
}
else if (this.isSecondColumnOfChartRow(node)) {
return this.processSecondColumn(context, node, children, index);
}
throw new DeveloperError("processNode called unexpectedly.");
}
/**
* Is this node the chart element itself?
* @param node The node to test.
*/
isChart(node) {
return node.name === this.name;
}
/**
* For some catalog types, for the chart item to be shareable, it needs to be
* constructed as a reference to the original item. This method can be
* overriden to make a shareable chart. See SOSChartCustomComponent for an
* implementation.
*
* This method is used only for constructing a chart item to show
* in the chart panel, not for the feature info panel chart item.
*/
constructShareableCatalogItem = undefined;
/**
* Construct a download URL from the chart body text.
* This URL will be used to present a download link when other download
* options are not specified for the chart.
*
* See {@CsvChartCustomComponent} for an example implementation.
*
* @param body The body string.
* @return URL to be passed as `href` for the download link.
*/
constructDownloadUrlFromBody;
processChart(context, node, children, _index) {
if (node.attribs === undefined ||
!context.terria ||
!context.feature ||
!context.catalogItem) {
return undefined;
}
checkAllPropertyKeys(node.attribs, this.attributes);
const featurePosition = getFeaturePosition(context.feature);
const attrs = this.parseNodeAttrs(node.attribs);
const child = children[0];
const body = typeof child === "string" ? child : undefined;
const chartElements = [];
this.chartItemId = this.chartItemId ?? createGuid();
// If downloads not specified but we have a body string, convert it to a downloadable data URI.
if (attrs.downloads === undefined &&
body &&
this.constructDownloadUrlFromBody !== undefined) {
attrs.downloads = [this.constructDownloadUrlFromBody?.(body)];
}
if (!attrs.hideButtons) {
// Build expand/download buttons
const sourceItems = (attrs.downloads || attrs.sources || [""]).map((source, i) => {
// When expanding a chart for this item and there is already an
// expanded chart for the item, there are 2 possibilities.
// 1. Remove it an show the new chart
// 2. Show the new chart alongside the existing chart
//
// If title & source names for the two expanded charts are the same then
// we only show the latest one, otherwise we show both.
// To do this we make the id dependant on the parentId, title & source.
const id = `${context.catalogItem.uniqueId}:${attrs.title}:${source}`;
const itemOrPromise = this.constructShareableCatalogItem
? this.constructShareableCatalogItem(id, context, undefined)
: this.constructCatalogItem(id, context, undefined);
return Promise.resolve(itemOrPromise).then(action((item) => {
if (item) {
this.setTraitsFromParent(item, context.catalogItem);
this.setTraitsFromAttrs(item, attrs, i);
if (body) {
this.setTraitsFromBody?.(item, body);
}
if (featurePosition &&
hasTraits(item, ChartPointOnMapTraits, "chartPointOnMap")) {
item.setTrait(CommonStrata.user, "chartPointOnMap", createStratumInstance(LatLonHeightTraits, featurePosition));
}
}
return item;
}));
});
chartElements.push(createElement(ChartExpandAndDownloadButtons, {
key: "button",
terria: context.terria,
sourceItems: sourceItems,
sourceNames: attrs.sourceNames,
canDownload: attrs.canDownload === true,
downloads: attrs.downloads,
downloadNames: attrs.downloadNames,
raiseToTitle: !!getInsertedTitle(node)
}));
}
// Build chart item to show in the info panel
const chartItem = this.constructCatalogItem(this.chartItemId, context, undefined);
if (chartItem) {
runInAction(() => {
this.setTraitsFromParent(chartItem, context.catalogItem);
this.setTraitsFromAttrs(chartItem, attrs, 0);
if (body) {
this.setTraitsFromBody?.(chartItem, body);
}
});
chartElements.push(createElement(Chart, {
key: "chart",
item: chartItem,
xAxisLabel: attrs.previewXLabel,
// Currently implementation supports showing only one column in the
// feature info panel chart
yColumn: attrs.yColumns?.[0],
height: 110
// styling: attrs.styling,
// highlightX: attrs.highlightX,
// transitionDuration: 300
}));
}
return createElement("div", {
key: "chart-wrapper",
className: ChartPreviewStyles.previewChartWrapper
}, chartElements);
}
/**
* Populate traits in the supplied catalog item with the values from the body of the component.
* Assume it will be run in an action.
* @param item
* @param attrs
* @param sourceIndex
*/
setTraitsFromBody;
setTraitsFromParent(chartItem, parentItem) {
if (hasTraits(chartItem, DiscretelyTimeVaryingTraits, "chartDisclaimer") &&
hasTraits(parentItem, DiscretelyTimeVaryingTraits, "chartDisclaimer") &&
parentItem.chartDisclaimer !== undefined) {
chartItem.setTrait(CommonStrata.user, "chartDisclaimer", parentItem.chartDisclaimer);
}
}
/**
* Is this node the first column of a two-column table where the second
* column contains a `<chart>`?
* @param node The node to test
*/
isFirstColumnOfChartRow(node) {
return (node.name === "td" &&
node.children !== undefined &&
node.children.length === 1 &&
node.parent !== undefined &&
node.parent.name === "tr" &&
node.parent.children !== undefined &&
node.parent.children.length === 2 &&
node === node.parent.children[0] &&
node.parent.children[1].name === "td" &&
node.parent.children[1].children !== undefined &&
node.parent.children[1].children.length === 1 &&
node.parent.children[1].children[0].name === "chart");
}
processFirstColumn(_context, _node, _children, _index) {
// Do not return a node.
return undefined;
}
/**
* Is this node the second column of a two-column table where the second
* column contains a `<chart>`?
* @param node The node to test
*/
isSecondColumnOfChartRow(node) {
return (node.name === "td" &&
node.children !== undefined &&
node.children.length === 1 &&
node.children[0].name === "chart" &&
node.parent !== undefined &&
node.parent.name === "tr" &&
node.parent.children !== undefined &&
node.parent.children.length === 2);
}
processSecondColumn(_context, node, children, _index) {
const title = node.parent.children[0].children[0].data;
const revisedChildren = [
createElement("div", {
key: "title",
className: ChartPreviewStyles.chartTitleFromTable
}, title)
].concat(children);
return createElement("td", { key: "chart", colSpan: 2, className: ChartPreviewStyles.chartTd }, node.data, revisedChildren);
}
/**
* Parse node attrs to an easier to process structure.
*/
parseNodeAttrs(nodeAttrs) {
let sources = splitStringIfDefined(nodeAttrs.sources);
if (sources === undefined && nodeAttrs.src !== undefined) {
// [src-preview, src], or [src] if src-preview is not defined.
sources = [nodeAttrs.src];
const srcPreview = nodeAttrs["src-preview"];
if (srcPreview !== undefined) {
sources.unshift(srcPreview);
}
}
const sourceNames = splitStringIfDefined(nodeAttrs["source-names"]);
const downloads = splitStringIfDefined(nodeAttrs.downloads) || sources;
const downloadNames = splitStringIfDefined(nodeAttrs["download-names"]) || sourceNames;
const columnTitles = filterOutUndefined((nodeAttrs["column-titles"] || "").split(",").map((s) => {
const [a, b] = rsplit2(s, ":");
if (a && b) {
return { name: a, title: b };
}
else {
const title = a;
return title;
}
}));
const columnUnits = filterOutUndefined((nodeAttrs["column-units"] || "").split(",").map((s) => {
const [a, b] = rsplit2(s, ":");
if (a && b) {
return { name: a, units: b };
}
else {
const units = a;
return units;
}
}));
const yColumns = splitStringIfDefined(nodeAttrs["y-columns"] || nodeAttrs["y-column"]);
return {
title: nodeAttrs["title"],
identifier: nodeAttrs["identifier"],
hideButtons: nodeAttrs["hide-buttons"] === "true",
sources,
sourceNames,
canDownload: !(nodeAttrs["can-download"] === "false"),
downloads,
downloadNames,
styling: nodeAttrs["styling"] || "feature-info",
highlightX: nodeAttrs["highlight-x"],
columnTitles,
columnUnits,
xColumn: nodeAttrs["x-column"],
previewXLabel: nodeAttrs["preview-x-label"],
yColumns
};
}
/**
* A helper method to create a shareable reference to an item.
*/
async createItemReference(sourceItem) {
const terria = sourceItem.terria;
const ref = new SplitItemReference(createGuid(), terria);
ref.setTrait(CommonStrata.user, "splitSourceItemId", sourceItem.uniqueId);
(await ref.loadReference()).raiseError(terria, `Failed to create SplitItemReference for ${getName(sourceItem)}`);
if (ref.target) {
terria.addModel(ref);
return ref.target;
}
}
}
function checkAllPropertyKeys(object, allowedKeys) {
for (const key in object) {
if (Object.hasOwnProperty.call(object, key)) {
if (allowedKeys.indexOf(key) === -1) {
console.log("Unknown attribute " + key);
}
}
}
}
export function splitStringIfDefined(s) {
return s !== undefined ? s.split(",") : undefined;
}
/*
* Split string `s` from last using `sep` into 2 pieces.
*/
function rsplit2(s, sep) {
const pieces = s.split(sep);
if (pieces.length === 1) {
return pieces;
}
else {
const head = pieces.slice(0, pieces.length - 1).join(sep);
const last = pieces[pieces.length - 1];
return [head, last];
}
}
function getInsertedTitle(node) {
// Check if there is a title in the position 'Title' relative to node <chart>:
// <tr><td>Title</td><td><chart></chart></tr>
if (node.parent !== undefined &&
node.parent.name === "td" &&
node.parent.parent !== undefined &&
node.parent.parent.name === "tr" &&
node.parent.parent.children !== undefined &&
node.parent.parent.children[0] !== undefined &&
node.parent.parent.children[0].children !== undefined &&
node.parent.parent.children[0].children[0] !== undefined) {
return node.parent.parent.children[0].children[0].data;
}
}
function getFeaturePosition(feature) {
const cartesian = feature?.position?.getValue(JulianDate.now());
if (cartesian) {
const carto = Ellipsoid.WGS84.cartesianToCartographic(cartesian);
return {
longitude: CesiumMath.toDegrees(carto.longitude),
latitude: CesiumMath.toDegrees(carto.latitude)
};
}
}
//# sourceMappingURL=ChartCustomComponent.js.map