UNPKG

terriajs

Version:

Geospatial data visualization platform.

268 lines (245 loc) 12.1 kB
'use strict'; // For documentation on the custom <chart> tag, see lib/Models/registerCustomComponentTypes.js. // // Two possible approaches to combining D3 and React: // 1. Render SVG element in React, let React keep control of the DOM. // 2. React treats the element like a blackbox, and D3 is in control. // We take the second approach, because it gives us much more of the power of D3 (animations etc). // // See also: // https://facebook.github.io/react/docs/working-with-the-browser.html // http://ahmadchatha.com/writings/article1.html // http://nicolashery.com/integrating-d3js-visualizations-in-a-react-app/ import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import defined from 'terriajs-cesium/Source/Core/defined'; import defaultValue from 'terriajs-cesium/Source/Core/defaultValue'; import DeveloperError from 'terriajs-cesium/Source/Core/DeveloperError'; import loadText from '../../../Core/loadText'; import when from 'terriajs-cesium/Source/ThirdParty/when'; import ChartData from '../../../Charts/ChartData'; import LineChart from '../../../Charts/LineChart'; import proxyCatalogItemUrl from '../../../Models/proxyCatalogItemUrl'; import TableStructure from '../../../Map/TableStructure'; import VarType from '../../../Map/VarType'; import Styles from './chart.scss'; const defaultHeight = 100; const defaultColor = undefined; // Allows the line color to be set by the css, esp. in the feature info panel. const Chart = createReactClass({ // this._element is updated by the ref callback attribute, https://facebook.github.io/react/docs/more-about-refs.html _element: undefined, _promise: undefined, _tooltipId: undefined, propTypes: { domain: PropTypes.object, styling: PropTypes.string, // nothing, 'feature-info' or 'histogram' -- TODO: improve height: PropTypes.number, axisLabel: PropTypes.object, catalogItem: PropTypes.object, transitionDuration: PropTypes.number, highlightX: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), updateCounter: PropTypes.any, // Change this to trigger an update. pollSeconds: PropTypes.any, // This is not used by Chart. It is used internally by registerCustomComponentTypes. // You can provide the data directly via props.data (ChartData[]): data: PropTypes.array, // Or, provide a URL to the data, along with optional xColumn, yColumns, colors url: PropTypes.string, xColumn: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), yColumns: PropTypes.array, colors: PropTypes.array, pollUrl: PropTypes.string, // Or, provide a tableStructure directly. tableStructure: PropTypes.object }, chartDataArrayFromTableStructure(table) { const xColumn = table.getColumnWithNameIdOrIndex(this.props.xColumn || 0); let yColumns = []; if (defined(this.props.yColumns)) { yColumns = this.props.yColumns.map(column => table.getColumnWithNameIdOrIndex(column)); } else { // Fall back to the first scalar that isn't the x column. yColumns = table.columns.filter(column => (column !== xColumn) && column.type === VarType.SCALAR); if (yColumns.length > 0) { yColumns = [yColumns[0]]; } else { throw new DeveloperError('No y-column available.'); } } const pointArrays = table.toPointArrays(xColumn, yColumns); // The data id should be set to something unique, eg. its source id + column index. // If we're here, the data was downloaded from a single file or table, so the column index is unique by itself. const colors = this.props.colors; return pointArrays.map((points, index) => new ChartData(points, { id: index, name: yColumns[index].name, units: yColumns[index].units, color: (colors && colors.length > 0) ? colors[index % colors.length] : defaultColor }) ); }, getChartDataPromise(data, url, catalogItem) { // Returns a promise that resolves to an array of ChartData. const that = this; if (defined(data)) { // Nothing to do - the data was provided (either as props.data or props.tableStructure). return when(data); } else if (defined(url)) { return loadIntoTableStructure(catalogItem, url) .then(that.chartDataArrayFromTableStructure) .otherwise(function(e) { // It looks better to create a blank chart than no chart. return []; }); } }, componentDidMount() { const that = this; const chartParameters = that.getChartParameters(); const promise = that.getChartDataPromise(chartParameters.data, that.props.url, that.props.catalogItem); promise.then(function(data) { chartParameters.data = data; LineChart.create(that._element, chartParameters); }); that._promise = promise.then(function() { // that.rnd = Math.random(); // React should handle the binding for you, but it doesn't seem to work here; perhaps because it is inside a Promise? // So we return the bound listener function from the promise. const resize = function() { // This function basically the same as componentDidUpdate, but it speeds up transitions. // Note same caveats - doesn't work if the data came from a URL. // That's ok for our purposes, since the URL is only used in a feature info panel, which never resizes dynamically. if (that._element) { const localChartParameters = that.getChartParameters(); if (defined(chartParameters.data)) { localChartParameters.transitionDuration = 1; LineChart.update(that._element, localChartParameters); } } else { // This would happen if event listeners were not properly removed (ie. if you get this error, a bug was introduced to this code). throw new DeveloperError('Missing chart DOM element ' + that.url); } }; // console.log('Listening for resize on', that.props.url, that.rnd, boundComponentDidUpdate); window.addEventListener('resize', resize); return resize; }); }, componentDidUpdate() { // Update the chart with props.data or props.tableStructure, if present. // If the data came from a URL, there are three possibilities: // 1. The URL has changed. // 2. The URL is the same and therefore we do not want to reload it. // 3. The URL is the same, but the chart came from a self-updating <chart> tag (ie. one with a poll-seconds attribute), // and so we do want to reload it. // Note that registerCustomComponent types wraps its charts in a div with a key based on the url, // so if the URL has changed, it actually mounts a new component, thereby triggering a load. // (Ie. we don't need to cover case (1) here.) // In case (3), props.updateCounter will be set to an integer, and we should update the data from the URL. const element = this._element; const chartParameters = this.getChartParameters(); if (defined(chartParameters.data)) { LineChart.update(element, chartParameters); } else if (this.props.updateCounter > 0) { // The risk here is if it's a time-varying csv with <chart> polling as well. const url = this.props.pollUrl || this.props.url; const promise = this.getChartDataPromise(chartParameters.data, url, this.props.catalogItem); promise.then(function(data) { chartParameters.data = data; LineChart.update(element, chartParameters); }); } }, componentWillUnmount() { const that = this; this._promise.then(function(listener) { window.removeEventListener('resize', listener); // console.log('Removed resize listener for', that.props.url, that.rnd, listener); LineChart.destroy(that._element, that.getChartParameters()); that._element = undefined; }); this._promise = undefined; }, getChartParameters() { // Return the parameters for LineChart.js (or other chart type). // If it is not a mini-chart, add tooltip settings (including a unique id for the tooltip DOM element). let margin; let tooltipSettings; let titleSettings; let grid; if (this.props.styling !== 'feature-info') { if (!defined(this._tooltipId)) { // In case there are multiple charts with tooltips. Unlikely to pick the same random number. Remove the initial "0.". this._tooltipId = 'd3-tooltip-' + Math.random().toString().substr(2); } margin = { top: 0, // So the title is flush with the top of the chart panel. right: 20, bottom: 20, left: 0 }; tooltipSettings = { className: Styles.toolTip, id: this._tooltipId, align: 'prefer-right', // With right/left alignment, the offset is relative to the svg, so need to inset. offset: {top: 40, left: 66, right: 30, bottom: 5} }; titleSettings = { type: 'legend', height: 30 }; grid = { x: true, y: true }; } if (defined(this.props.highlightX)) { tooltipSettings = undefined; } if (this.props.styling === 'histogram') { titleSettings = undefined; margin = {top: 0, right: 0, bottom: 0, left: 0}; } let chartData; if (defined(this.props.data)) { chartData = this.props.data; } else if (defined(this.props.tableStructure)) { chartData = this.chartDataArrayFromTableStructure(this.props.tableStructure); } return { data: chartData, domain: this.props.domain, width: '100%', height: defaultValue(this.props.height, defaultHeight), axisLabel: this.props.axisLabel, mini: this.props.styling === 'feature-info', transitionDuration: this.props.transitionDuration, margin: margin, tooltipSettings: tooltipSettings, titleSettings: titleSettings, grid: grid, highlightX: this.props.highlightX }; }, render() { return ( <div className={Styles.chart} ref={element=>{this._element = element;}}></div> ); } }); /** * Loads data from a URL into a table structure. * @param {String} url The URL. * @return {Promise} A promise which resolves to a table structure. */ function loadIntoTableStructure(catalogItem, url) { if (defined(catalogItem) && defined(catalogItem.loadIntoTableStructure)) { return catalogItem.loadIntoTableStructure(url); } // As a fallback, try to load in the data file as csv. const tableStructure = new TableStructure('feature info'); url = proxyCatalogItemUrl(catalogItem, url, '0d'); return loadText(url).then(tableStructure.loadFromCsv.bind(tableStructure)); } module.exports = Chart;