UNPKG

terriajs

Version:

Geospatial data visualization platform.

570 lines (521 loc) 15.6 kB
import { observer } from "mobx-react"; import { action, computed, observable } from "mobx"; import { AxisLeft, AxisBottom } from "@visx/axis"; import { RectClipPath } from "@visx/clip-path"; import { localPoint } from "@visx/event"; import { GridRows } from "@visx/grid"; import { Group } from "@visx/group"; import { withParentSize } from "@vx/responsive"; import { scaleLinear, scaleTime } from "@visx/scale"; import { Line } from "@visx/shape"; import PropTypes from "prop-types"; import React from "react"; import groupBy from "lodash-es/groupBy"; import minBy from "lodash-es/minBy"; import Legends from "./Legends"; import LineChart from "./LineChart"; import MomentLinesChart from "./MomentLinesChart"; import MomentPointsChart from "./MomentPointsChart"; import Tooltip from "./Tooltip"; import ZoomX from "./ZoomX"; import Styles from "./bottom-dock-chart.scss"; import LineAndPointChart from "./LineAndPointChart"; import PointOnMap from "./PointOnMap"; const chartMinWidth = 110; const defaultGridColor = "#efefef"; const labelColor = "#efefef"; @observer class BottomDockChart extends React.Component { static propTypes = { terria: PropTypes.object.isRequired, parentWidth: PropTypes.number, width: PropTypes.number, height: PropTypes.number, chartItems: PropTypes.array.isRequired, xAxis: PropTypes.object.isRequired, margin: PropTypes.object }; static defaultProps = { parentWidth: 0 }; render() { return ( <Chart {...this.props} width={Math.max( chartMinWidth, this.props.width || this.props.parentWidth )} /> ); } } export default withParentSize(BottomDockChart); @observer class Chart extends React.Component { static propTypes = { terria: PropTypes.object.isRequired, width: PropTypes.number, height: PropTypes.number, chartItems: PropTypes.array.isRequired, xAxis: PropTypes.object.isRequired, margin: PropTypes.object }; static defaultProps = { margin: { left: 20, right: 30, top: 10, bottom: 50 } }; @observable zoomedXScale; @observable mouseCoords; @computed get chartItems() { return sortChartItemsByType(this.props.chartItems) .map((chartItem) => { return { ...chartItem, points: chartItem.points.sort((p1, p2) => p1.x - p2.x) }; }) .filter((chartItem) => chartItem.points.length > 0); } @computed get plotHeight() { const { height, margin } = this.props; return height - margin.top - margin.bottom - Legends.maxHeightPx; } @computed get plotWidth() { const { width, margin } = this.props; return width - margin.left - margin.right - this.estimatedYAxesWidth; } @computed get adjustedMargin() { const margin = this.props.margin; return { ...margin, left: margin.left + this.estimatedYAxesWidth }; } @computed get initialXScale() { const xAxis = this.props.xAxis; const domain = calculateDomain(this.chartItems); const params = { domain: domain.x, range: [0, this.plotWidth] }; if (xAxis.scale === "linear") return scaleLinear(params); else return scaleTime(params); } @computed get xScale() { return this.zoomedXScale || this.initialXScale; } @computed get yAxes() { const range = [this.plotHeight, 0]; const chartItemsByUnit = groupBy(this.chartItems, "units"); return Object.entries(chartItemsByUnit).map(([units, chartItems]) => { return { units: units === "undefined" ? undefined : units, scale: scaleLinear({ domain: calculateDomain(chartItems).y, range }), color: chartItems[0].getColor() }; }); } @computed get initialScales() { return this.chartItems.map((c) => ({ x: this.initialXScale, y: this.yAxes.find((y) => y.units === c.units).scale })); } @computed get zoomedScales() { return this.chartItems.map((c) => ({ x: this.xScale, y: this.yAxes.find((y) => y.units === c.units).scale })); } @computed get cursorX() { if (this.pointsNearMouse.length > 0) return this.xScale(this.pointsNearMouse[0].point.x); return this.mouseCoords && this.mouseCoords.x; } @computed get pointsNearMouse() { if (!this.mouseCoords) return []; return this.chartItems .map((chartItem) => ({ chartItem, point: findNearestPoint( chartItem.points, this.mouseCoords, this.xScale, 7 ) })) .filter(({ point }) => point !== undefined); } @computed get tooltip() { const margin = this.adjustedMargin; const tooltip = { items: this.pointsNearMouse }; if (!this.mouseCoords || this.mouseCoords.x < this.plotWidth * 0.5) { tooltip.right = this.props.width - (this.plotWidth + margin.right); } else { tooltip.left = margin.left; } tooltip.bottom = this.props.height - (margin.top + this.plotHeight); return tooltip; } @computed get estimatedYAxesWidth() { const numTicks = 4; const tickLabelFontSize = 10; // We need to consider only the left most Y-axis as its label values appear // outside the chart plot area. The labels of inner y-axes appear inside // the plot area. const leftmostYAxis = this.yAxes[0]; const maxLabelDigits = Math.max( 0, ...leftmostYAxis.scale.ticks(numTicks).map((n) => n.toString().length) ); return maxLabelDigits * tickLabelFontSize; } @action setZoomedXScale(scale) { this.zoomedXScale = scale; } @action setMouseCoords(coords) { this.mouseCoords = coords; } setMouseCoordsFromEvent(event) { const coords = localPoint( event.target.ownerSVGElement || event.target, event ); if (!coords) return; this.setMouseCoords({ x: coords.x - this.adjustedMargin.left, y: coords.y - this.adjustedMargin.top }); } componentDidUpdate(prevProps) { // Unset zoom scale if any chartItems are added or removed if (prevProps.chartItems.length !== this.props.chartItems.length) { this.setZoomedXScale(undefined); } } render() { const { height, xAxis, terria } = this.props; if (this.chartItems.length === 0) return <div className={Styles.empty}>No data available</div>; return ( <ZoomX surface="#zoomSurface" initialScale={this.initialXScale} scaleExtent={[1, Infinity]} translateExtent={[ [0, 0], [Infinity, Infinity] ]} onZoom={(zoomedScale) => this.setZoomedXScale(zoomedScale)} > <Legends width={this.plotWidth} chartItems={this.chartItems} /> <div style={{ position: "relative" }}> <svg width="100%" height={height} onMouseMove={this.setMouseCoordsFromEvent.bind(this)} onMouseLeave={() => this.setMouseCoords(undefined)} > <Group left={this.adjustedMargin.left} top={this.adjustedMargin.top} > <RectClipPath id="plotClip" width={this.plotWidth} height={this.plotHeight} /> <XAxis top={this.plotHeight + 1} scale={this.xScale} label={xAxis.units || (xAxis.scale === "time" && "Date")} /> <For each="y" index="i" of={this.yAxes}> <YAxis {...y} key={`y-axis-${y.units}`} color={this.yAxes.length > 1 ? y.color : defaultGridColor} offset={i * 50} /> </For> <For each="y" index="i" of={this.yAxes}> <GridRows key={`grid-${y.units}`} width={this.plotWidth} height={this.plotHeight} scale={y.scale} numTicks={4} stroke={this.yAxes.length > 1 ? y.color : defaultGridColor} lineStyle={{ opacity: 0.3 }} /> </For> <svg id="zoomSurface" clipPath="url(#plotClip)" pointerEvents="all" > <rect width={this.plotWidth} height={this.plotHeight} fill="transparent" /> {this.cursorX && ( <Cursor x={this.cursorX} stroke={defaultGridColor} /> )} <Plot chartItems={this.chartItems} initialScales={this.initialScales} zoomedScales={this.zoomedScales} /> </svg> </Group> </svg> <Tooltip {...this.tooltip} /> <PointsOnMap terria={terria} chartItems={this.chartItems} /> </div> </ZoomX> ); } } @observer class Plot extends React.Component { static propTypes = { chartItems: PropTypes.array.isRequired, initialScales: PropTypes.array.isRequired, zoomedScales: PropTypes.array.isRequired }; @computed get chartRefs() { return this.props.chartItems.map((_) => React.createRef()); } componentDidUpdate() { Object.values(this.chartRefs).forEach(({ current: ref }, i) => { if (typeof ref.doZoom === "function") { ref.doZoom(this.props.zoomedScales[i]); } }); } render() { const { chartItems, initialScales } = this.props; return chartItems.map((chartItem, i) => { switch (chartItem.type) { case "line": return ( <LineChart key={chartItem.key} ref={this.chartRefs[i]} id={sanitizeIdString(chartItem.key)} chartItem={chartItem} scales={initialScales[i]} /> ); case "momentPoints": { // Find a basis item to stick the points on, if we can't find one, we // vertically center the points const basisItemIndex = chartItems.findIndex( (item) => (item.type === "line" || item.type === "lineAndPoint") && item.xAxis.scale === "time" ); return ( <MomentPointsChart key={chartItem.key} ref={this.chartRefs[i]} id={sanitizeIdString(chartItem.key)} chartItem={chartItem} scales={initialScales[i]} basisItem={chartItems[basisItemIndex]} basisItemScales={initialScales[basisItemIndex]} glyph={chartItem.glyphStyle} /> ); } case "momentLines": { return ( <MomentLinesChart key={chartItem.key} ref={this.chartRefs[i]} id={sanitizeIdString(chartItem.key)} chartItem={chartItem} scales={initialScales[i]} /> ); } case "lineAndPoint": { return ( <LineAndPointChart key={chartItem.key} ref={this.chartRefs[i]} id={sanitizeIdString(chartItem.key)} chartItem={chartItem} scales={initialScales[i]} glyph={chartItem.glyphStyle} /> ); } } }); } } class XAxis extends React.PureComponent { static propTypes = { top: PropTypes.number.isRequired, scale: PropTypes.func.isRequired, label: PropTypes.string.isRequired }; render() { const { scale, ...restProps } = this.props; return ( <AxisBottom stroke="#efefef" tickStroke="#efefef" tickLabelProps={() => ({ fill: "#efefef", textAnchor: "middle", fontSize: 12, fontFamily: "Arial" })} labelProps={{ fill: labelColor, fontSize: 12, textAnchor: "middle", fontFamily: "Arial" }} // .nice() rounds the scale so that the aprox beginning and // aprox end labels are shown // See: https://stackoverflow.com/questions/21753126/d3-js-starting-and-ending-tick scale={scale.nice()} {...restProps} /> ); } } class YAxis extends React.PureComponent { static propTypes = { scale: PropTypes.func.isRequired, color: PropTypes.string.isRequired, units: PropTypes.string, offset: PropTypes.number.isRequired }; render() { const { scale, color, units, offset } = this.props; return ( <AxisLeft key={`y-axis-${units}`} left={offset} scale={scale} numTicks={4} stroke={color} tickStroke={color} label={units || ""} labelOffset={10} labelProps={{ fill: color, textAnchor: "middle", fontSize: 12, fontFamily: "Arial" }} tickLabelProps={() => ({ fill: color, textAnchor: "end", fontSize: 10, fontFamily: "Arial" })} /> ); } } class Cursor extends React.PureComponent { static propTypes = { x: PropTypes.number.isRequired }; render() { const { x, ...rest } = this.props; return <Line from={{ x, y: 0 }} to={{ x, y: 1000 }} {...rest} />; } } function PointsOnMap({ chartItems, terria }) { return chartItems.map( (chartItem) => chartItem.pointOnMap && ( <PointOnMap key={`point-on-map-${chartItem.key}`} terria={terria} color={chartItem.getColor()} point={chartItem.pointOnMap} /> ) ); } /** * Calculates a combined domain of all chartItems. */ function calculateDomain(chartItems) { const xmin = Math.min(...chartItems.map((c) => c.domain.x[0])); const xmax = Math.max(...chartItems.map((c) => c.domain.x[1])); const ymin = Math.min(...chartItems.map((c) => c.domain.y[0])); const ymax = Math.max(...chartItems.map((c) => c.domain.y[1])); return { x: [xmin, xmax], y: [ymin, ymax] }; } /** * Sorts chartItems so that `momentPoints` are rendered on top then * `momentLines` and then any other types. * @param {ChartItem[]} chartItems array of chartItems to sort */ function sortChartItemsByType(chartItems) { return chartItems.slice().sort((a, b) => { if (a.type === "momentPoints") return 1; else if (b.type === "momentPoints") return -1; else if (a.type === "momentLines") return 1; else if (b.type === "momentLines") return -1; return 0; }); } function findNearestPoint(points, coords, xScale, maxDistancePx) { function distance(coords, point) { return point ? coords.x - xScale(point.x) : Infinity; } let left = 0; let right = points.length; let mid = 0; for (;;) { if (left === right) break; mid = left + Math.floor((right - left) / 2); if (distance(coords, points[mid]) === 0) break; else if (distance(coords, points[mid]) < 0) right = mid; else left = mid + 1; } const leftPoint = points[mid - 1]; const midPoint = points[mid]; const rightPoint = points[mid + 1]; const nearestPoint = minBy([leftPoint, midPoint, rightPoint], (p) => p ? Math.abs(distance(coords, p)) : Infinity ); return Math.abs(distance(coords, nearestPoint)) <= maxDistancePx ? nearestPoint : undefined; } function sanitizeIdString(id) { // delete all non-alphanum chars return id.replace(/[^a-zA-Z0-9_-]/g, ""); }