diff --git a/src/components/AxisCollection/YAxis.js b/src/components/AxisCollection/YAxis.js index be6fb335..acb14235 100644 --- a/src/components/AxisCollection/YAxis.js +++ b/src/components/AxisCollection/YAxis.js @@ -4,10 +4,7 @@ import PropTypes from 'prop-types'; import { createYScale } from '../../utils/scale-helpers'; import GriffPropTypes, { singleSeriePropType } from '../../utils/proptypes'; import AxisPlacement from '../AxisPlacement'; -import ScalerContext from '../../context/Scaler'; import ZoomRect from '../ZoomRect'; -import Axes from '../../utils/Axes'; -import { withDisplayName } from '../../utils/displayName'; const propTypes = { zoomable: PropTypes.bool, @@ -23,9 +20,6 @@ const propTypes = { tickFormatter: PropTypes.func.isRequired, defaultColor: PropTypes.string, ticks: PropTypes.number, - - // These are populated by Griff. - subDomainsByItemId: GriffPropTypes.subDomainsByItemId.isRequired, }; const defaultProps = { @@ -39,8 +33,6 @@ const defaultProps = { ticks: 0, }; -const getItem = (series, collection) => series || collection; - const getLineProps = ({ strokeWidth, tickSizeInner, @@ -156,16 +148,15 @@ const YAxis = ({ onMouseEnter, onMouseLeave, series, - subDomainsByItemId, tickFormatter, ticks, width, yAxisPlacement, zoomable, }) => { - const item = getItem(series, collection); + const item = series || collection; const color = item.color || defaultColor; - const scale = createYScale(Axes.y(subDomainsByItemId[item.id]), height); + const scale = createYScale(item.ySubDomain, height); const axis = d3.axisRight(scale); const tickFontSize = 14; const strokeWidth = 2; @@ -243,7 +234,7 @@ const YAxis = ({ width={width} height={height} zoomAxes={{ y: true }} - itemIds={[getItem(series, collection).id]} + itemIds={[(series || collection).id]} /> )} @@ -253,10 +244,4 @@ const YAxis = ({ YAxis.propTypes = propTypes; YAxis.defaultProps = defaultProps; -export default withDisplayName('YAxis', props => ( - - {({ subDomainsByItemId }) => ( - - )} - -)); +export default YAxis; diff --git a/src/components/ContextChart/index.js b/src/components/ContextChart/index.js index 27bfe664..ab8cfea2 100644 --- a/src/components/ContextChart/index.js +++ b/src/components/ContextChart/index.js @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { SizeMe } from 'react-sizeme'; +import DataContext from '../../context/Data'; import ScalerContext from '../../context/Scaler'; import LineCollection from '../LineCollection'; import XAxis from '../XAxis'; @@ -11,9 +12,8 @@ import AxisPlacement from '../AxisPlacement'; import { multiFormat } from '../../utils/multiFormat'; import Axes from '../../utils/Axes'; import { createYScale, createXScale } from '../../utils/scale-helpers'; -import { firstResolvedDomain } from '../Scaler'; -import { calculateDomainFromData } from '../DataProvider'; import { withDisplayName } from '../../utils/displayName'; +import { calculateDomains } from '../Scaler'; const propTypes = { height: PropTypes.number, @@ -25,9 +25,9 @@ const propTypes = { xAxisPlacement: GriffPropTypes.axisPlacement, // These are all provided by Griff. - domainsByItemId: GriffPropTypes.domainsByItemId.isRequired, + collections: GriffPropTypes.collections.isRequired, + unscaledSeries: GriffPropTypes.multipleSeries.isRequired, series: GriffPropTypes.multipleSeries.isRequired, - subDomainsByItemId: GriffPropTypes.subDomainsByItemId.isRequired, updateDomains: GriffPropTypes.updateDomains.isRequired, width: PropTypes.number, }; @@ -76,12 +76,55 @@ const renderXAxis = (position, xAxis, { xAxisPlacement }) => { return null; }; +const domainToString = domain => { + if (!domain) { + return 'undefined'; + } + if (!Array.isArray(domain)) { + return 'not-an-array'; + } + return `[${domain[0]},${domain[1]}]`; +}; + +const dataRange = item => { + const { data, timeAccessor } = item; + if (!data) { + return 'undefined'; + } + if (!Array.isArray(data)) { + return 'not-an-array'; + } + if (!timeAccessor) { + return 'inaccessible'; + } + if (data.length === 0) { + return 'no-data'; + } + return `[${timeAccessor(data[0])},${timeAccessor(data[data.length - 1])}]`; +}; + +// A helper function to provide checksum-ish hashes to React.useMemo so that we +// can only recompute the domains when relevant information changes. +const getDomainHashes = (...items) => + items.map(itemGroup => + itemGroup + .map( + item => + `${item.id}: { data: { ${(item.data || []).length} [${dataRange( + item + )}] }, collectionId: ${item.collectionId}, yDomain: ${domainToString( + item.yDomain + )}, ySubDomain: ${domainToString(item.ySubDomain)} }` + ) + .join(', ') + ); + const ContextChart = ({ annotations: propsAnnotations, - domainsByItemId, height: propsHeight, + unscaledSeries, + collections, series, - subDomainsByItemId, updateDomains, width, xAxisFormatter, @@ -93,19 +136,48 @@ const ContextChart = ({ return null; } - const getYScale = (s, height) => { - const domain = - firstResolvedDomain( - s.yDomain, - Axes.y(domainsByItemId[s.collectionId || s.id]) - ) || - calculateDomainFromData(s.data, s.yAccessor, s.y0Accessor, s.y1Accessor); + const reconciledDomains = useMemo(() => { + // First things first: figure out what domain each series wants to have. + const domainsByItemId = {}; + unscaledSeries.forEach(s => { + const { collectionId, id } = s; + + const domain = s.yDomain || calculateDomains(s).y; + + domainsByItemId[id] = domain; + + if (collectionId) { + const collectedDomain = domainsByItemId[collectionId] || [ + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + ]; + domainsByItemId[collectionId] = [ + Math.min(domain[0], collectedDomain[0]), + Math.max(domain[1], collectedDomain[1]), + ]; + } + }); + + // Do another pass over it to update the collected items' domains. + unscaledSeries.forEach(s => { + const { id, collectionId } = s; + if (!collectionId) { + return; + } + + domainsByItemId[id] = domainsByItemId[collectionId]; + }); + + return domainsByItemId; + }, getDomainHashes(unscaledSeries, collections)); + + const getYScale = (seriesIndex, height) => { + const scaled = series[seriesIndex]; + const domain = reconciledDomains[scaled.id]; return createYScale(domain, height); }; - const firstItemId = series[0].id; - const timeDomain = Axes.time(domainsByItemId[firstItemId]); - const timeSubDomain = Axes.time(subDomainsByItemId[firstItemId]); + const { timeDomain, timeSubDomain } = series[0]; const height = getChartHeight({ height: propsHeight, xAxisHeight, @@ -144,7 +216,6 @@ const ContextChart = ({ yScalerFactory={getYScale} scaleY={false} scaleX={false} - subDomainsByItemId={subDomainsByItemId} /> ( - - {({ domainsByItemId, subDomainsByItemId, updateDomains, series }) => ( - - {({ size }) => ( - + + {({ series: unscaledSeries }) => ( + + {({ updateDomains, series, collections }) => ( + + {({ size }) => ( + + )} + )} - + )} - + )); diff --git a/src/components/DataProvider/index.js b/src/components/DataProvider/index.js index 84170c15..92ab1f70 100644 --- a/src/components/DataProvider/index.js +++ b/src/components/DataProvider/index.js @@ -88,21 +88,6 @@ const getTimeSubDomain = ( return newTimeSubDomain; }; -const smallerDomain = (domain, subDomain) => { - if (!domain && !subDomain) { - return undefined; - } - - if (!domain || !subDomain) { - return domain || subDomain; - } - - return [Math.max(domain[0], subDomain[0]), Math.min(domain[1], subDomain[1])]; -}; - -const boundedDomain = (a, b) => - a && b ? [Math.min(a[0], b[0]), Math.max(a[1], b[1])] : a || b; - const DEFAULT_ACCESSORS = { time: d => d.timestamp, x: d => d.x, @@ -138,9 +123,6 @@ export default class DataProvider extends Component { limitTimeSubDomain ), timeDomain, - timeSubDomains: {}, - xSubDomains: {}, - ySubDomains: {}, collectionsById: {}, seriesById: {}, }; @@ -196,7 +178,6 @@ export default class DataProvider extends Component { { timeDomain: propsTimeDomain, timeSubDomain: newTimeSubDomain, - ySubDomains: {}, }, () => { Object.keys(seriesById).map(id => this.fetchData(id, 'MOUNTED')); @@ -215,7 +196,7 @@ export default class DataProvider extends Component { } } - getSeriesObjects = () => { + getCollectionObjects = () => { const { drawLines, drawPoints, @@ -238,13 +219,69 @@ export default class DataProvider extends Component { opacityAccessor, pointWidthAccessor, } = this.props; + const { collectionsById } = this.state; + return Object.keys(collectionsById).reduce((acc, id) => { + const collection = collectionsById[id]; + const dataProvider = { + drawLines, + drawPoints, + pointWidth, + strokeWidth, + opacity, + opacityAccessor, + pointWidthAccessor, + timeAccessor, + xAccessor, + x0Accessor, + x1Accessor, + yAccessor, + y0Accessor, + y1Accessor, + timeDomain, + timeSubDomain, + xDomain, + xSubDomain, + yDomain, + ySubDomain, + }; + const completedSeries = { + // First copy in the base-level configuration. + ...DEFAULT_SERIES_CONFIG, + + // Then the global props from DataProvider, if any are set. + ...dataProvider, + + // Finally, the collection configuration itself. + ...collection, + }; + return [...acc, completedSeries]; + }, []); + }; + + getSeriesObjects = () => { const { - collectionsById, - seriesById, - timeSubDomains, - xSubDomains, - ySubDomains, - } = this.state; + drawLines, + drawPoints, + timeAccessor, + xAccessor, + x0Accessor, + x1Accessor, + yAccessor, + y0Accessor, + y1Accessor, + timeDomain, + timeSubDomain, + xDomain, + xSubDomain, + yDomain, + ySubDomain, + pointWidth, + strokeWidth, + opacity, + opacityAccessor, + pointWidthAccessor, + } = this.props; + const { collectionsById, seriesById } = this.state; return Object.keys(seriesById).reduce((acc, id) => { const series = seriesById[id]; const dataProvider = { @@ -262,6 +299,12 @@ export default class DataProvider extends Component { yAccessor, y0Accessor, y1Accessor, + timeDomain, + timeSubDomain, + xDomain, + xSubDomain, + yDomain, + ySubDomain, }; const collection = series.collectionId !== undefined @@ -274,18 +317,6 @@ export default class DataProvider extends Component { // Then the global props from DataProvider, if any are set. ...dataProvider, - // Then the domains because these are in the DataProvider state, which - // supercedes the props. - timeSubDomain: smallerDomain( - timeDomain, - timeSubDomain || timeSubDomains[id] - ), - xSubDomain: smallerDomain(xDomain, xSubDomain || xSubDomains[id]), - ySubDomain: smallerDomain(yDomain, ySubDomain || ySubDomains[id]), - timeDomain, - xDomain, - yDomain, - // Next, copy over defaults from the parent collection, if there is one. ...collection, @@ -337,13 +368,6 @@ export default class DataProvider extends Component { defaultLoader, onFetchData, pointsPerSeries, - timeAccessor, - x0Accessor, - x1Accessor, - xAccessor, - y0Accessor, - y1Accessor, - yAccessor, onFetchDataError, } = this.props; const { timeDomain, timeSubDomain, seriesById } = this.state; @@ -371,14 +395,7 @@ export default class DataProvider extends Component { } this.setState( - ({ - collectionsById, - seriesById: { [id]: freshSeries }, - seriesById: freshSeriesById, - timeSubDomains: freshTimeSubDomains, - xSubDomains: freshXSubDomains, - ySubDomains: freshYSubDomains, - }) => { + ({ seriesById: { [id]: freshSeries }, seriesById: freshSeriesById }) => { const stateUpdates = {}; const series = { @@ -386,51 +403,6 @@ export default class DataProvider extends Component { ...loaderResult, }; - if ( - // We either couldn't have any data before ... - reason === 'MOUNTED' || - // ... or we didn't have data before, but do now! - (freshSeries.data.length === 0 && loaderResult.data.length > 0) - ) { - const collection = series.collectionId - ? collectionsById[series.collectionId] || {} - : {}; - - stateUpdates.timeSubDomains = { - ...freshTimeSubDomains, - [id]: calculateDomainFromData( - series.data, - series.timeAccessor || timeAccessor || DEFAULT_ACCESSORS.time - ), - }; - stateUpdates.xSubDomains = { - ...freshXSubDomains, - [id]: calculateDomainFromData( - series.data, - series.xAccessor || - collection.xAccessor || - xAccessor || - DEFAULT_ACCESSORS.x, - series.x0Accessor || collection.x0Accessor || x0Accessor, - series.x1Accessor || collection.x1Accessor || x1Accessor - ), - }; - stateUpdates.ySubDomains = { - ...freshYSubDomains, - [id]: calculateDomainFromData( - series.data, - series.yAccessor || - collection.yAccessor || - yAccessor || - DEFAULT_ACCESSORS.y, - series.y0Accessor || collection.y0Accessor || y0Accessor, - series.y1Accessor || collection.y1Accessor || y1Accessor - ), - }; - - series.timeSubDomain = series.timeSubDomain || series.timeDomain; - } - stateUpdates.seriesById = { ...freshSeriesById, [id]: series, @@ -572,7 +544,7 @@ export default class DataProvider extends Component { }; render() { - const { collectionsById, timeDomain, timeSubDomain } = this.state; + const { timeDomain, timeSubDomain } = this.state; const { children, limitTimeSubDomain, @@ -582,139 +554,9 @@ export default class DataProvider extends Component { onUpdateDomains, } = this.props; - const seriesObjects = this.getSeriesObjects(); - - // // Compute the domains for all of the collections with one pass over all of - // // the series objects. - const domainsByCollectionId = seriesObjects.reduce((acc, series) => { - const { collectionId } = series; - if (!collectionId) { - return acc; - } - - const { - timeDomain: seriesTimeDomain, - timeSubDomain: seriesTimeSubDomain, - xDomain: seriesXDomain, - xSubDomain: seriesXSubDomain, - yDomain: seriesYDomain, - ySubDomain: seriesYSubDomain, - } = series; - - const { - timeDomain: collectionTimeDomain = [ - Number.MAX_SAFE_INTEGER, - Number.MIN_SAFE_INTEGER, - ], - timeSubDomain: collectionTimeSubDomain = [ - Number.MAX_SAFE_INTEGER, - Number.MIN_SAFE_INTEGER, - ], - xDomain: collectionXDomain = [ - Number.MAX_SAFE_INTEGER, - Number.MIN_SAFE_INTEGER, - ], - xSubDomain: collectionXSubDomain = [ - Number.MAX_SAFE_INTEGER, - Number.MIN_SAFE_INTEGER, - ], - yDomain: collectionYDomain = [ - Number.MAX_SAFE_INTEGER, - Number.MIN_SAFE_INTEGER, - ], - ySubDomain: collectionYSubDomain = [ - Number.MAX_SAFE_INTEGER, - Number.MIN_SAFE_INTEGER, - ], - } = acc[collectionId] || {}; - - return { - ...acc, - [collectionId]: { - timeDomain: seriesTimeDomain - ? boundedDomain(collectionTimeDomain, seriesTimeDomain) - : undefined, - timeSubDomain: boundedDomain( - collectionTimeSubDomain, - seriesTimeSubDomain - ), - xDomain: seriesXDomain - ? boundedDomain(collectionXDomain, seriesXDomain) - : undefined, - xSubDomain: boundedDomain(collectionXSubDomain, seriesXSubDomain), - yDomain: seriesYDomain - ? boundedDomain(collectionYDomain, seriesYDomain) - : undefined, - ySubDomain: boundedDomain(collectionYSubDomain, seriesYSubDomain), - }, - }; - }, {}); - - // Then we want to enrich the collection objects with their above-computed - // domains. - const collectionsWithDomains = Object.keys(collectionsById).reduce( - (acc, id) => { - if (!domainsByCollectionId[id]) { - return acc; - } - return [ - ...acc, - { - ...collectionsById[id], - ...domainsByCollectionId[id], - }, - ]; - }, - [] - ); - - // Then take a final pass over all of the series and replace their - // yDomain and ySubDomain arrays with the one from their collections (if - // they're a member of a collection). - const collectedSeries = seriesObjects.map(s => { - const { collectionId } = s; - if (collectionId === undefined) { - return s; - } - const copy = { ...s }; - if (!collectionsById[collectionId]) { - // It's pointing to a collection that doesn't exist. - delete copy.collectionId; - } else { - const { - timeDomain: collectionTimeDomain, - timeSubDomain: collectionTimeSubDomain, - xDomain: collectionXDomain, - xSubDomain: collectionXSubDomain, - yDomain: collectionYDomain, - ySubDomain: collectionYSubDomain, - } = domainsByCollectionId[collectionId] || {}; - - if (collectionTimeDomain) { - copy.timeDomain = collectionTimeDomain; - } - if (collectionTimeSubDomain) { - copy.timeSubDomain = collectionTimeSubDomain; - } - if (collectionXDomain) { - copy.xDomain = collectionXDomain; - } - if (collectionXSubDomain) { - copy.xSubDomain = collectionXSubDomain; - } - if (collectionYDomain) { - copy.yDomain = collectionYDomain; - } - if (collectionYSubDomain) { - copy.ySubDomain = collectionYSubDomain; - } - } - return copy; - }); - const context = { - series: collectedSeries, - collections: collectionsWithDomains, + series: this.getSeriesObjects(), + collections: this.getCollectionObjects(), timeDomain, // This is used to signal external changes vs internal changes externalTimeDomain, diff --git a/src/components/GridLines/index.tsx b/src/components/GridLines/index.tsx index 414a89f3..001ab765 100644 --- a/src/components/GridLines/index.tsx +++ b/src/components/GridLines/index.tsx @@ -39,7 +39,6 @@ export interface Props { interface InternalProps { series: Series[]; - subDomainsByItemId: DomainsByItemId; } const GridLines: React.FunctionComponent = ({ @@ -49,7 +48,6 @@ const GridLines: React.FunctionComponent = ({ opacity = 0.6, series, strokeWidth = 1, - subDomainsByItemId, width, x, y, @@ -70,7 +68,7 @@ const GridLines: React.FunctionComponent = ({ .filter(s => seriesIdMap[s.id]) .forEach(s => { // This is heavily inspired by YAxis -- maybe we could consolidate? - const scale = createYScale(Axes.y(subDomainsByItemId[s.id]), height); + const scale = createYScale(s.ySubDomain, height); const nTicks = y.count || Math.floor(height / 50) || 1; const values = scale.ticks(nTicks); @@ -160,7 +158,7 @@ const GridLines: React.FunctionComponent = ({ // This heavily inspired by XAxis -- maybe we can consolidate them? // FIXME: Remove this when we support multiple X axes const timeSubDomain = - subDomainsByItemId[Object.keys(subDomainsByItemId)[0]][axes.x]; + axes.x === 'x' ? series[0].xSubDomain : series[0].timeSubDomain; const scale = createXScale(timeSubDomain, width); const values = scale.ticks(x.ticks || Math.floor(width / 100) || 1); values.forEach(v => { @@ -210,20 +208,8 @@ export default withDisplayName( 'GridLines', ({ width, height, ...props }: Props & SizeProps) => ( - {({ - series, - subDomainsByItemId, - }: { - series: Series[]; - subDomainsByItemId: DomainsByItemId; - }) => ( - + {({ series }: { series: Series[] }) => ( + )} ) diff --git a/src/components/InteractionLayer/index.js b/src/components/InteractionLayer/index.js index befe4103..4a02084f 100644 --- a/src/components/InteractionLayer/index.js +++ b/src/components/InteractionLayer/index.js @@ -14,7 +14,6 @@ import Annotation from '../Annotation'; import Ruler from '../Ruler'; import Area from '../Area'; import ZoomRect from '../ZoomRect'; -import Axes from '../../utils/Axes'; import { withDisplayName } from '../../utils/displayName'; export const ZoomMode = { @@ -53,7 +52,6 @@ class InteractionLayer extends React.Component { // These are all populated by Griff. series: seriesPropType, collections: GriffPropTypes.collections, - subDomainsByItemId: GriffPropTypes.subDomainsByItemId.isRequired, }; static defaultProps = { @@ -97,24 +95,13 @@ class InteractionLayer extends React.Component { } componentWillReceiveProps(nextProps) { - const { - subDomainsByItemId: prevSubDomainsByItemId, - ruler, - width: prevWidth, - } = this.props; - // FIXME: Don't assume a single time domain - const { - width: nextWidth, - subDomainsByItemId: nextSubDomainsByItemId, - } = nextProps; + const { ruler, width: prevWidth, series: prevSeries } = this.props; + const { width: nextWidth, series: nextSeries } = nextProps; const { touchX, touchY } = this.state; - const prevTimeSubDomain = Axes.time( - prevSubDomainsByItemId[Object.keys(prevSubDomainsByItemId)[0]] - ); - const nextTimeSubDomain = Axes.time( - nextSubDomainsByItemId[Object.keys(nextSubDomainsByItemId)[0]] - ); + // FIXME: Don't assume a single time domain + const prevTimeSubDomain = prevSeries.length > 0 ? prevSeries[0] : undefined; + const nextTimeSubDomain = nextSeries.length > 0 ? nextSeries[0] : undefined; if ( ruler && @@ -258,17 +245,15 @@ class InteractionLayer extends React.Component { width, annotations, areas, - subDomainsByItemId, + series, } = this.props; - if (this.dragging) { + if (this.dragging || series.length === 0) { return; } if (onClickAnnotation || onAreaClicked) { let notified = false; // FIXME: Don't assume a single time domain - const timeSubDomain = Axes.time( - subDomainsByItemId[Object.keys(subDomainsByItemId)[0]] - ); + const { timeSubDomain } = series[0]; const xScale = createXScale(timeSubDomain, width); const xpos = e.nativeEvent.offsetX; const ypos = e.nativeEvent.offsetY; @@ -334,14 +319,11 @@ class InteractionLayer extends React.Component { // TODO: This extrapolate thing is super gross and so hacky. getDataForCoordinate = (xpos, ypos, extrapolate = false) => { - const { subDomainsByItemId, width, series, height } = this.props; + const { width, series, height } = this.props; const output = { xpos, ypos, points: [] }; series.forEach(s => { - const { - [Axes.time]: timeSubDomain, - [Axes.y]: ySubDomain, - } = subDomainsByItemId[s.id]; + const { timeSubDomain, ySubDomain } = s; const xScale = createXScale(timeSubDomain, width); const rawTimestamp = xScale.invert(xpos); const { data, xAccessor, yAccessor } = s; @@ -386,16 +368,10 @@ class InteractionLayer extends React.Component { }; getRulerPoints = xpos => { - const { series, height, width, subDomainsByItemId } = this.props; + const { series, height, width } = this.props; const newPoints = []; series.forEach(s => { - if (!subDomainsByItemId[s.id]) { - return; - } - const { - [Axes.time]: timeSubDomain, - [Axes.y]: ySubDomain, - } = subDomainsByItemId[s.id]; + const { timeSubDomain, ySubDomain } = s; const xScale = createXScale(timeSubDomain, width); const rawTimestamp = xScale.invert(xpos); const { data, xAccessor, yAccessor } = s; @@ -443,10 +419,15 @@ class InteractionLayer extends React.Component { }); return; } - const { width, subDomainsByItemId } = this.props; - const timeSubDomain = Axes.time( - subDomainsByItemId[Object.keys(subDomainsByItemId)[0]] - ); + + const { series, width } = this.props; + + if (series.length === 0) { + return; + } + + // FIXME: Don't rely on a single time subdomain + const { timeSubDomain } = series[0]; const xScale = createXScale(timeSubDomain, width); const xpos = xScale(timestamp); this.setRulerPoints(xpos); @@ -495,7 +476,6 @@ class InteractionLayer extends React.Component { onAreaDefined, ruler, series, - subDomainsByItemId, width, zoomAxes, } = this.props; @@ -535,7 +515,7 @@ class InteractionLayer extends React.Component { ); } // FIXME: Don't rely on a single time domain - const timeSubDomain = Axes.time(subDomainsByItemId[series[0].id]); + const { timeSubDomain } = series[0]; const xScale = createXScale(timeSubDomain, width); const annotations = propsAnnotations.map(a => ( @@ -557,7 +537,7 @@ class InteractionLayer extends React.Component { if (a.seriesId) { s = series.find(s1 => s1.id === a.seriesId); if (s) { - const { [Axes.y]: ySubDomain } = subDomainsByItemId[s.id]; + const { ySubDomain } = s; const yScale = createYScale(ySubDomain, height); if (a.start.yval) { scaledArea.start.ypos = yScale(a.start.yval); @@ -620,13 +600,8 @@ class InteractionLayer extends React.Component { export default withDisplayName('InteractionLayer', props => ( - {({ collections, series, subDomainsByItemId }) => ( - + {({ collections, series }) => ( + )} )); diff --git a/src/components/LineCollection/index.tsx b/src/components/LineCollection/index.tsx index 0c4933cd..5c7de3bc 100644 --- a/src/components/LineCollection/index.tsx +++ b/src/components/LineCollection/index.tsx @@ -9,7 +9,6 @@ import Line from '../Line'; import AxisDisplayMode from '../../utils/AxisDisplayMode'; import Axes, { Dimension } from '../../utils/Axes'; import { Series } from '../../external'; -import { DomainsByItemId } from '../Scaler'; import { withDisplayName } from '../../utils/displayName'; const { time, x } = Axes; @@ -21,21 +20,22 @@ export interface Props { series?: Series[]; pointWidth?: number; scaleX?: boolean; - yScalerFactory?: (series: Series, height: number) => ScalerFunction; + yScalerFactory?: (seriesIndex: number, h: number) => ScalerFunction; } interface InternalProps { series: Series[]; - domainsByItemId: DomainsByItemId; - subDomainsByItemId: DomainsByItemId; } +const defaultYScaler = (series: Series[]) => ( + seriesIndex: number, + height: number +) => createYScale(series[seriesIndex].ySubDomain, height); + const LineCollection: React.FunctionComponent< Props & InternalProps > = props => { const { - domainsByItemId, - subDomainsByItemId, series = new Array(), width, height, @@ -44,9 +44,6 @@ const LineCollection: React.FunctionComponent< pointWidth = 6, scaleX = true, } = props; - if (!subDomainsByItemId) { - return null; - } const clipPath = `clip-path-${width}-${height}-${series .filter(s => !s.hidden) .map( @@ -57,21 +54,24 @@ const LineCollection: React.FunctionComponent< ) .join('/')}`; - const yScaler = - yScalerFactory || - ((s, h) => - createYScale(Axes.y(subDomainsByItemId[s.collectionId || s.id]), h)); - - const lines = series.reduce((l, s) => { + const lines = series.reduce((l, s, i) => { if (s.hidden) { return l; } - const { id } = s; - const xScale = createXScale( - scaleX ? xAxis(subDomainsByItemId[id]) : xAxis(domainsByItemId[id]), - width - ); - const yScale = yScaler(s, height); + let domain = s.timeSubDomain; + if (scaleX && String(xAxis) === 'time') { + domain = s.timeSubDomain; + } else if (scaleX && String(xAxis) === 'x') { + domain = s.xSubDomain; + } else if (!scaleX && String(xAxis) === 'time') { + domain = s.timeDomain; + } else if (!scaleX && String(xAxis) === 'x') { + domain = s.xDomain; + } else { + // No idea what we're doing up here. + } + const xScale = createXScale(domain, width); + const yScale = (yScalerFactory || defaultYScaler(series))(i, height); return [ ...l, ( - {({ domainsByItemId, subDomainsByItemId, series }: InternalProps) => ( - + {({ series }: InternalProps) => ( + )} )); diff --git a/src/components/PointCollection/index.tsx b/src/components/PointCollection/index.tsx index 39f96ee4..29813dbe 100644 --- a/src/components/PointCollection/index.tsx +++ b/src/components/PointCollection/index.tsx @@ -14,20 +14,18 @@ export interface Props {} interface InternalProps { series: Series[]; - subDomainsByItemId: DomainsByItemId; } const propTypes = { width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, series: seriesPropType.isRequired, - subDomainsByItemId: GriffPropTypes.subDomainsByItemId.isRequired, }; const defaultProps = {}; const PointCollection: React.FunctionComponent< Props & SizeProps & InternalProps -> = ({ width, height, series, subDomainsByItemId }) => { +> = ({ width, height, series }) => { const points = series .filter(s => !s.hidden && s.drawPoints !== false) .map(s => { @@ -35,11 +33,9 @@ const PointCollection: React.FunctionComponent< // entirely sure why; I think it's because the collection's x domain is not // correctly calculated to the data's extent. I have not looked into it // because it doesn't really matter yet, but it will at some point. - const xScale = createXScale(Axes.x(subDomainsByItemId[s.id]), width); - const yScale = createYScale( - Axes.y(subDomainsByItemId[s.collectionId || s.id]), - height - ); + const xScale = createXScale(s.xSubDomain, width); + // TODO: How will this handle collections? + const yScale = createYScale(s.ySubDomain, height); // Only show points which are relevant for the current time subdomain. // We don't need to do this for lines because we want lines to be drawn to // infinity so that they go to the ends of the graph, but points are special @@ -47,8 +43,8 @@ const PointCollection: React.FunctionComponent< // subdomain. const pointFilter = (d: Datapoint, i: number, arr: Datapoint[]) => { const timestamp = s.timeAccessor(d, i, arr); - const subDomain = Axes.time(subDomainsByItemId[s.id]); - return subDomain[0] <= timestamp && timestamp <= subDomain[1]; + const { timeSubDomain } = s; + return timeSubDomain[0] <= timestamp && timestamp <= timeSubDomain[1]; }; return ( ( - {({ subDomainsByItemId, series }: InternalProps) => ( - + {({ series }: InternalProps) => ( + )} ) diff --git a/src/components/Scaler/index.tsx b/src/components/Scaler/index.tsx index f1109207..25995f5c 100644 --- a/src/components/Scaler/index.tsx +++ b/src/components/Scaler/index.tsx @@ -3,10 +3,10 @@ import * as PropTypes from 'prop-types'; import DataContext from '../../context/Data'; import ScalerContext from '../../context/Scaler'; import GriffPropTypes, { seriesPropType } from '../../utils/proptypes'; -import Axes, { Domains } from '../../utils/Axes'; -import { Domain, Series, Collection } from '../../external'; -import { Item } from '../../internal'; +import Axes from '../../utils/Axes'; +import { Domain, Series, Collection, ItemId } from '../../external'; import { withDisplayName } from '../../utils/displayName'; +import { Item } from '../../internal'; // TODO: Move this to DataProvider. type OnTimeSubDomainChanged = (timeSubDomain: Domain) => void; @@ -23,23 +23,58 @@ interface DataContext { timeSubDomain: Domain; timeSubDomainChanged: OnTimeSubDomainChanged; limitTimeSubDomain: LimitTimeSubDomain | undefined; - externalXSubDomain: Domain | undefined; series: Series[]; collections: Collection[]; onUpdateDomains: OnUpdateDomains; } +interface SeriesWithDomains extends Series { + timeDomain: Domain; + timeSubDomain: Domain; + xDomain: Domain; + xSubDomain: Domain; + yDomain: Domain; + ySubDomain: Domain; +} + +interface CollectionWithDomains extends Collection { + timeDomain: Domain; + timeSubDomain: Domain; + xDomain: Domain; + xSubDomain: Domain; + yDomain: Domain; + ySubDomain: Domain; +} + export interface Props { children: React.ReactChild | React.ReactChild[]; - dataContext: DataContext; + timeDomain: Domain; + timeSubDomain: Domain; + timeSubDomainChanged: OnTimeSubDomainChanged; + limitTimeSubDomain: LimitTimeSubDomain | undefined; + series: Series[]; + collections: Collection[]; + onUpdateDomains: OnUpdateDomains; } export interface DomainsByItemId { - [itemId: string]: Domains; + [itemId: string]: { + time: Domain; + x?: Domain; + y?: Domain; + }; +} + +export interface PopulatedDomainsByItemId { + [itemId: string]: { + time: Domain; + x: Domain; + y: Domain; + }; } interface State { - domainsByItemId: DomainsByItemId; + /** Subdomains of the items, according to the current state. */ subDomainsByItemId: DomainsByItemId; } @@ -47,15 +82,6 @@ export interface OnDomainsUpdated extends Function {} type DomainAxis = 'time' | 'x' | 'y'; -interface StateUpdates { - domainsByItemId: DomainsByItemId; - subDomainsByItemId: DomainsByItemId; -} - -// If the timeSubDomain is within this margin, consider it to be attached to -// the leading edge of the timeDomain. -const FRONT_OF_WINDOW_THRESHOLD = 0.05; - /** * Provide a placeholder domain so that we can test for validity later, but * it can be safely operated on like a real domain. @@ -66,58 +92,135 @@ export const placeholder = (min: number, max: number): Domain => { return domain; }; -const haveDomainsChanged = (before: Item, after: Item) => - before.timeDomain !== after.timeDomain || - before.timeSubDomain !== after.timeSubDomain || - before.xDomain !== after.xDomain || - before.xSubDomain !== after.xSubDomain || - before.yDomain !== after.yDomain || - before.ySubDomain !== after.ySubDomain; - -const findItemsWithChangedDomains = ( - previousItems: Item[], - currentItems: Item[] -) => { - const previousItemsById: { [itemId: string]: Item } = previousItems.reduce( - (acc, s) => ({ - ...acc, - [s.id]: s, - }), - {} - ); - return currentItems.reduce((acc: Item[], s) => { - if ( - !previousItemsById[s.id] || - haveDomainsChanged(previousItemsById[s.id] || {}, s) - ) { - return [...acc, s]; - } - return acc; - }, []); +export const firstResolvedDomain = ( + domain: Domain | undefined, + // tslint:disable-next-line + ...domains: (undefined | Domain)[] +): Domain | undefined => { + if (domain && domain.placeholder !== true) { + return [...domain] as Domain; + } + if (domains.length === 0) { + return undefined; + } + return firstResolvedDomain(domains[0], ...(domains.splice(1) as Domain[])); }; const isEqual = (a: Domain, b: Domain): boolean => { if (a === b) { return true; } - if ((!a && b) || (a && !b)) { + if (!!a !== !!b) { return false; } return a[0] === b[0] && a[1] === b[1]; }; -export const firstResolvedDomain = ( - domain: Domain | undefined, - // tslint:disable-next-line - ...domains: (undefined | Domain)[] -): Domain | undefined => { - if (domain && domain.placeholder !== true) { - return [...domain] as Domain; +const withPadding = (extent: Domain): Domain => { + const diff = extent[1] - extent[0]; + if (Math.abs(diff) < 1e-3) { + if (extent[0] === 0) { + // If 0 is the only value present in the series, hard code domain. + return [-0.25, 0.25]; + } + const domain = [(1 / 2) * extent[0], (3 / 2) * extent[0]]; + if (domain[1] < domain[0]) { + return [domain[1], domain[0]]; + } + return domain as Domain; } - if (domains.length === 0) { - return undefined; + return [extent[0] - diff * 0.025, extent[1] + diff * 0.025]; +}; + +const getDomain = (series: Series, axis: 'time' | 'x' | 'y') => { + switch (String(axis)) { + case 'time': + return series.timeDomain; + case 'x': + return series.xDomain; + case 'y': + return series.yDomain; + default: + return null; } - return firstResolvedDomain(domains[0], ...(domains.splice(1) as Domain[])); +}; + +const getSubDomain = (series: Series, axis: 'time' | 'x' | 'y') => { + switch (String(axis)) { + case 'time': + return series.timeSubDomain; + case 'x': + return series.xSubDomain; + case 'y': + return series.ySubDomain; + default: + return null; + } +}; + +const getLimitedSubDomain = (subDomain: Domain, domain: Domain): Domain => { + return [Math.max(subDomain[0], domain[0]), Math.min(subDomain[1], domain[1])]; +}; + +export const calculateDomains = ( + s: Series +): { time: Domain; x: Domain; y: Domain } => { + const { + data, + timeAccessor, + xAccessor, + x0Accessor, + x1Accessor, + yAccessor, + y0Accessor, + y1Accessor, + } = s; + + if (!data || data.length === 0) { + // There isn't any data -- return some default domains. + return { + time: placeholder(0, Date.now()), + x: placeholder(-0.25, 0.25), + y: placeholder(-0.25, 0.25), + }; + } + + const timeExtent: [number, number] = [ + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + ]; + const xExtent: [number, number] = [ + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + ]; + const yExtent: [number, number] = [ + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + ]; + for (let i = 0; i < data.length; i += 1) { + const point = data[i]; + const time = timeAccessor(point); + timeExtent[0] = Math.min(timeExtent[0], time); + timeExtent[1] = Math.max(timeExtent[1], time); + + const x = xAccessor(point); + const x0 = x0Accessor ? x0Accessor(point) : x; + const x1 = x1Accessor ? x1Accessor(point) : x; + xExtent[0] = Math.min(xExtent[0], x, x0); + xExtent[1] = Math.max(xExtent[1], x, x1); + + const y = yAccessor(point); + const y0 = y0Accessor ? y0Accessor(point) : y; + const y1 = y1Accessor ? y1Accessor(point) : y; + yExtent[0] = Math.min(yExtent[0], y, y0); + yExtent[1] = Math.max(yExtent[1], y, y1); + } + + return { + time: withPadding(timeExtent), + x: withPadding(xExtent), + y: withPadding(yExtent), + }; }; /** @@ -139,196 +242,310 @@ export const firstResolvedDomain = ( class Scaler extends React.Component { static propTypes = { children: PropTypes.node.isRequired, - dataContext: PropTypes.shape({ - timeDomain: PropTypes.arrayOf(PropTypes.number).isRequired, - timeSubDomain: PropTypes.arrayOf(PropTypes.number).isRequired, - timeSubDomainChanged: PropTypes.func.isRequired, - limitTimeSubDomain: PropTypes.func, - externalXSubDomain: PropTypes.arrayOf(PropTypes.number), - series: seriesPropType.isRequired, - collections: GriffPropTypes.collections.isRequired, - }).isRequired, + timeDomain: PropTypes.arrayOf(PropTypes.number).isRequired, + timeSubDomain: PropTypes.arrayOf(PropTypes.number).isRequired, + timeSubDomainChanged: PropTypes.func.isRequired, + limitTimeSubDomain: PropTypes.func, + series: seriesPropType.isRequired, + collections: GriffPropTypes.collections.isRequired, }; static defaultProps = {}; - static getDerivedStateFromProps( - { dataContext: { timeDomain, timeSubDomain, series, collections } }: Props, - state: State - ) { - // Make sure that all items in the props are present in the domainsByItemId - // and subDomainsByItemId state objects. - const { domainsByItemId, subDomainsByItemId } = state; - let updated = false; - const stateUpdates = series.concat(collections).reduce( - (acc: StateUpdates, item: Item): StateUpdates => { - const updates: StateUpdates = { ...acc }; - if (!domainsByItemId[item.id]) { - updated = true; - updates.domainsByItemId = { - ...updates.domainsByItemId, - [item.id]: { - time: [...timeDomain] as Domain, - x: - firstResolvedDomain(item.xDomain) || - placeholder(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), - y: - firstResolvedDomain(item.yDomain) || - placeholder(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), - }, - }; - } - - if (!subDomainsByItemId[item.id]) { - updated = true; - updates.subDomainsByItemId = { - ...updates.subDomainsByItemId, - [item.id]: { - time: [...timeSubDomain] as Domain, - x: firstResolvedDomain(item.xSubDomain) || placeholder(0, 1), - y: firstResolvedDomain(item.ySubDomain) || placeholder(0, 1), - }, - }; - } - - return updates; - }, - { domainsByItemId, subDomainsByItemId } - ); - return updated ? stateUpdates : null; - } - state: State = { - domainsByItemId: {}, subDomainsByItemId: {}, }; + seriesById: { [seriesId: string]: SeriesWithDomains } = {}; + collectionsById: { [collectionsId: string]: Collection } = {}; + seriesByCollectionId: { [collectionId: string]: ItemId[] } = {}; + componentDidUpdate(prevProps: Props) { - const { dataContext } = this.props; - const { - domainsByItemId: oldDomainsByItemId, - subDomainsByItemId: oldSubDomainsByItemId, - } = this.state; + // We need to find when a Series' defined subDomains change because + // then the state needs to be updated. - const changedSeries = findItemsWithChangedDomains( - prevProps.dataContext.series, - dataContext.series - ); - const changedCollections = findItemsWithChangedDomains( - prevProps.dataContext.collections, - dataContext.collections - ); - if (changedSeries.length > 0 || changedCollections.length > 0) { - const domainsByItemId = { ...oldDomainsByItemId }; - const subDomainsByItemId = { ...oldSubDomainsByItemId }; - - [...changedSeries, ...changedCollections].forEach(item => { - domainsByItemId[item.id] = { - time: - firstResolvedDomain( - dataContext.timeDomain, - item.timeDomain, - Axes.time(oldDomainsByItemId[item.id]) - ) || placeholder(0, Date.now()), - x: - firstResolvedDomain( - item.xDomain, - Axes.x(oldDomainsByItemId[item.id]) - ) || - // Set a large range because this is a domain. - placeholder(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), - y: - firstResolvedDomain( - item.yDomain, - Axes.y(oldDomainsByItemId[item.id]) - ) || - // Set a large range because this is a domain. - placeholder(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), - }; - subDomainsByItemId[item.id] = { - time: - firstResolvedDomain( - dataContext.timeSubDomain || - (item.timeSubDomain || - Axes.time(oldSubDomainsByItemId[item.id])) - ) || - // Set a large range because this is a subdomain. - placeholder(0, Date.now()), - x: - firstResolvedDomain( - item.xSubDomain, - Axes.x(oldSubDomainsByItemId[item.id]) - ) || - // Set a small range because this is a subdomain. - placeholder(0, 1), - y: - firstResolvedDomain( - item.ySubDomain || Axes.y(oldSubDomainsByItemId[item.id]) - ) || - // Set a small range because this is a subdomain. - placeholder(0, 1), - }; - }); - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ subDomainsByItemId, domainsByItemId }); - return; + const { series: prevSeries, collections: prevCollections } = prevProps; + + const prevSeriesById: { [id: string]: Series } = prevSeries + .concat(prevCollections) + .reduce((acc, s) => ({ ...acc, [s.id]: s }), {}); + + interface SubDomainChanges { + time?: boolean; + x?: boolean; + y?: boolean; } - if (!isEqual(prevProps.dataContext.timeDomain, dataContext.timeDomain)) { - const { timeDomain: prevTimeDomain } = prevProps.dataContext; - const { timeDomain: nextTimeDomain } = dataContext; + const changedSubDomainsById: { + [itemId: string]: SubDomainChanges; + } = {}; + const { series, collections } = this.props; + let updateRequired = false; - // When timeDomain changes, we need to update everything downstream. - const domainsByItemId = { ...oldDomainsByItemId }; - Object.keys(domainsByItemId).forEach(itemId => { - domainsByItemId[itemId].time = nextTimeDomain; - }); + const { subDomainsByItemId } = this.state; + + const findChangedSubDomains = (item: Item) => { + const p = prevSeriesById[item.id]; + + const changes: SubDomainChanges = {}; + let changed = false; + if (p) { + const subDomains = subDomainsByItemId[item.id] || {}; - const subDomainsByItemId = { ...oldSubDomainsByItemId }; - Object.keys(subDomainsByItemId).forEach(itemId => { - const { time: timeSubDomain } = oldSubDomainsByItemId[itemId]; - subDomainsByItemId[itemId] = { - ...oldSubDomainsByItemId[itemId], - }; - const dt = timeSubDomain[1] - timeSubDomain[0]; if ( - Math.abs((timeSubDomain[1] - prevTimeDomain[1]) / dt) <= - FRONT_OF_WINDOW_THRESHOLD + item.timeSubDomain && + subDomains.time && + !isEqual(item.timeSubDomain, p.timeSubDomain) ) { - // Looking at the front of the window -- continue to track that. - subDomainsByItemId[itemId].time = [ - nextTimeDomain[1] - dt, - nextTimeDomain[1], - ]; - } else if (timeSubDomain[0] <= prevTimeDomain[0]) { - // Looking at the back of the window -- continue to track that. - subDomainsByItemId[itemId].time = [ - prevTimeDomain[0], - prevTimeDomain[0] + dt, - ]; + changes.time = true; + changed = true; } - }); + if ( + item.xSubDomain && + subDomains.x && + !isEqual(item.xSubDomain, p.xSubDomain) + ) { + changes.x = true; + changed = true; + } + if ( + item.ySubDomain && + subDomains.y && + !isEqual(item.ySubDomain, p.ySubDomain) + ) { + changes.y = true; + changed = true; + } + } - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ domainsByItemId, subDomainsByItemId }); - } + changedSubDomainsById[item.id] = changes; + updateRequired = updateRequired || changed; + }; - if ( - !isEqual(prevProps.dataContext.timeSubDomain, dataContext.timeSubDomain) - ) { - // When timeSubDomain changes, we need to update everything downstream. - const newSubDomainsByItemId: DomainsByItemId = {}; - Object.keys(oldSubDomainsByItemId).forEach(itemId => { - newSubDomainsByItemId[itemId] = { - ...oldSubDomainsByItemId[itemId], - time: dataContext.timeSubDomain, - }; + series.forEach(findChangedSubDomains); + collections.forEach(findChangedSubDomains); + + if (updateRequired) { + this.setState(({ subDomainsByItemId }) => { + const newSubDomainsByItemId = Object.keys(changedSubDomainsById).reduce( + (acc, id) => { + const subDomains = { ...subDomainsByItemId[id] }; + const changedSubDomains = changedSubDomainsById[id]; + + if (changedSubDomains.time) { + delete subDomains.time; + } + + if (changedSubDomains.x) { + delete subDomains.x; + } + + if (changedSubDomains.y) { + delete subDomains.y; + } + + return { ...acc, [id]: subDomains }; + }, + {} + ); + return { subDomainsByItemId: newSubDomainsByItemId }; }); - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ subDomainsByItemId: newSubDomainsByItemId }); } } + /** + * Return an all of the series, with domains/subdomains guaranteed to be + * populated. + */ + getSeriesWithDomains = (): SeriesWithDomains[] => { + const { series } = this.props; + + const { subDomainsByItemId } = this.state; + + return series.map((s: Series) => { + const { + id, + timeDomain, + timeSubDomain, + xDomain, + xSubDomain, + yDomain, + ySubDomain, + } = s; + + const subDomains = subDomainsByItemId[id] || {}; + + const { time, x, y } = calculateDomains(s); + + const newTimeDomain = timeDomain || time || placeholder(0, Date.now()); + const newXDomain = + xDomain || + placeholder(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); + const newYDomain = + yDomain || + placeholder(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); + + return { + ...s, + timeDomain: newTimeDomain, + timeSubDomain: getLimitedSubDomain( + subDomains.time || + timeSubDomain || + time || + placeholder(0, Date.now()), + newTimeDomain + ), + xDomain: newXDomain, + xSubDomain: getLimitedSubDomain( + subDomains.x || xSubDomain || x, + newXDomain + ), + yDomain: newYDomain, + ySubDomain: getLimitedSubDomain( + subDomains.y || ySubDomain || y, + newYDomain + ), + }; + }); + }; + + getCollectionsWithDomains = ( + series: SeriesWithDomains[] + ): CollectionWithDomains[] => { + const { collections } = this.props; + if (collections.length === 0) { + return []; + } + + // We can't store these in this.collectionsById because the collections + // have not been fully-resolved at this point in time and might have missing + // domains. + const collectionsById: { [id: string]: Collection } = collections.reduce( + (acc, c) => ({ ...acc, [c.id]: c }), + {} + ); + + const collectionDomainsById: PopulatedDomainsByItemId = {}; + const collectionSubDomainsById: PopulatedDomainsByItemId = {}; + + series.forEach(s => { + if (!s.collectionId) { + return; + } + + const c = collectionsById[s.collectionId]; + if (!c) { + // This is pointing to a ficticious collection. + return; + } + + const domains = collectionDomainsById[s.collectionId]; + const subDomains = collectionSubDomainsById[s.collectionId]; + + let skip = false; + if (!domains) { + collectionDomainsById[s.collectionId] = { + time: s.timeDomain, + x: s.xDomain, + y: s.yDomain, + }; + skip = true; + } + + if (!subDomains) { + collectionSubDomainsById[s.collectionId] = { + time: s.timeSubDomain, + x: s.xSubDomain, + y: s.ySubDomain, + }; + skip = true; + } + + if (skip) { + // All done; we can skip to the next one. + return; + } + + collectionDomainsById[s.collectionId] = { + time: [ + Math.min(domains.time[0], s.timeDomain[0]), + Math.max(domains.time[1], s.timeDomain[1]), + ], + x: [ + Math.min(domains.x[0], s.xDomain[0]), + Math.max(domains.x[1], s.xDomain[1]), + ], + y: [ + Math.min(domains.y[0], s.yDomain[0]), + Math.max(domains.y[1], s.yDomain[1]), + ], + }; + + collectionSubDomainsById[s.collectionId] = { + time: [ + Math.min(subDomains.time[0], s.timeSubDomain[0]), + Math.max(subDomains.time[1], s.timeSubDomain[1]), + ], + x: [ + Math.min(subDomains.x[0], s.xSubDomain[0]), + Math.max(subDomains.x[1], s.xSubDomain[1]), + ], + y: [ + Math.min(subDomains.y[0], s.ySubDomain[0]), + Math.max(subDomains.y[1], s.ySubDomain[1]), + ], + }; + }); + + // Now we need to assemble the information we just computed! + return collections.reduce((acc, c) => { + const domains = collectionDomainsById[c.id]; + const subDomains = collectionSubDomainsById[c.id]; + if (!domains || !subDomains) { + // This represents a collection without any children. + return acc; + } + + return [ + ...acc, + { + ...c, + timeDomain: domains.time, + xDomain: domains.x, + yDomain: domains.y, + timeSubDomain: subDomains.time, + xSubDomain: subDomains.x, + ySubDomain: subDomains.y, + }, + ]; + }, new Array()); + }; + + getSeriesWithCollectedDomains = ( + series: SeriesWithDomains[] + ): SeriesWithDomains[] => { + return series.map(s => { + if (!s.collectionId) { + return s; + } + + const collection = this.collectionsById[s.collectionId]; + if (!collection) { + // This should never ever happen. But hey, solar flares ... + return s; + } + return { + ...s, + timeDomain: collection.timeDomain, + timeSubDomain: collection.timeSubDomain, + xDomain: collection.xDomain, + xSubDomain: collection.xSubDomain, + yDomain: collection.yDomain, + ySubDomain: collection.ySubDomain, + }; + }); + }; + /** * Update the subdomains for the given items. This is a patch update and will * be merged with the current state of the subdomains. An example payload @@ -348,40 +565,66 @@ class Scaler extends React.Component { * object. */ updateDomains = ( - changedDomainsById: DomainsByItemId, + mixedChangedDomainsById: DomainsByItemId, callback: OnDomainsUpdated ) => { // FIXME: This is not multi-series aware. let newTimeSubDomain = null; - const { dataContext } = this.props; - const { domainsByItemId, subDomainsByItemId } = this.state; + const { + limitTimeSubDomain, + onUpdateDomains, + timeSubDomainChanged, + } = this.props; + const { subDomainsByItemId } = this.state; const newSubDomains = { ...subDomainsByItemId }; + + // Convert collections into their component series IDs. + const changedDomainsById: DomainsByItemId = Object.keys( + mixedChangedDomainsById + ).reduce((acc, itemId) => { + if (this.seriesByCollectionId[itemId]) { + // This is a collection; we need to add in all of its component series. + return this.seriesByCollectionId[itemId].reduce( + (domains, seriesId) => ({ + ...domains, + [seriesId]: mixedChangedDomainsById[itemId], + }), + acc + ); + } else if (this.seriesById[itemId]) { + // Great, this is a series; copy it and move on. + return { ...acc, [itemId]: mixedChangedDomainsById[itemId] }; + } else { + // Wat. + return acc; + } + }, {}); + Object.keys(changedDomainsById).forEach(itemId => { newSubDomains[itemId] = { ...(subDomainsByItemId[itemId] || {}) }; + + // At this point, changeDomainsById only contains IDs which are series + // objects. + const s = this.seriesById[itemId] || {}; + Object.keys(changedDomainsById[itemId]).forEach(uncastAxis => { const axis: DomainAxis = uncastAxis as DomainAxis; - let newSubDomain = changedDomainsById[itemId][axis]; + let newSubDomain = + changedDomainsById[itemId][axis] || + subDomainsByItemId[itemId][axis] || + placeholder(0, 0); if (axis === String(Axes.time)) { - if (dataContext.limitTimeSubDomain) { - newSubDomain = dataContext.limitTimeSubDomain(newSubDomain); + if (limitTimeSubDomain) { + newSubDomain = limitTimeSubDomain(newSubDomain); } } - const newSpan = newSubDomain[1] - newSubDomain[0]; - - const existingSubDomain = - subDomainsByItemId[itemId][axis] || newSubDomain; + const existingSubDomain = getSubDomain(s, axis) || newSubDomain; const existingSpan = existingSubDomain[1] - existingSubDomain[0]; const limits = - firstResolvedDomain( - ((domainsByItemId || {})[itemId] || {})[axis], - axis === String(Axes.time) - ? // FIXME: Phase out this single timeDomain thing. - dataContext.timeDomain - : undefined - ) || + getDomain(s, axis) || // Set a large range because this is a limiting range. placeholder(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); @@ -399,43 +642,67 @@ class Scaler extends React.Component { Math.min(limits[1], newSubDomain[1]), ]; } - newSubDomains[itemId][axis] = newSubDomain; - if (axis === String(Axes.time)) { newTimeSubDomain = newSubDomain; } }); }); // expose newSubDomains to DataProvider - if (dataContext.onUpdateDomains) { - dataContext.onUpdateDomains(newSubDomains); + if (onUpdateDomains) { + onUpdateDomains(newSubDomains); } this.setState( { subDomainsByItemId: newSubDomains }, callback ? () => callback(changedDomainsById) : undefined ); if (newTimeSubDomain) { - dataContext.timeSubDomainChanged(newTimeSubDomain); + timeSubDomainChanged(newTimeSubDomain); } }; render() { - const { domainsByItemId, subDomainsByItemId } = this.state; - const { - children, - dataContext: { collections, series }, - } = this.props; + const { children } = this.props; + + // Do a first pass over all of the series to make copies of the Series so + // that they're all guaranteed to have domains populated. + const seriesWithDomains = this.getSeriesWithDomains(); + + // Next, we need to do another pass in order to find the domains for any + // collections which may be present. + const collectionsWithDomains = this.getCollectionsWithDomains( + seriesWithDomains + ); + + // Stash these on the object so that they can quickly fetched elsewhere. + this.collectionsById = collectionsWithDomains.reduce( + (acc, c) => ({ ...acc, [c.id]: c }), + {} + ); + + // Now the valid collections have domains -- loop over them and assign the + // domains to their collected series. + const seriesWithCollectedDomains = this.getSeriesWithCollectedDomains( + seriesWithDomains + ); + + seriesWithCollectedDomains.forEach(s => { + this.seriesById[s.id] = s; + if (s.collectionId) { + if (!this.seriesByCollectionId[s.collectionId]) { + this.seriesByCollectionId[s.collectionId] = []; + } + this.seriesByCollectionId[s.collectionId].push(s.id); + } + }); const finalContext = { - // Pick what we need out of the dataContext instead of spreading the - // entire object into the context. - collections, - series, + collections: collectionsWithDomains, + collectionsById: this.collectionsById, + series: seriesWithCollectedDomains, + seriesById: this.seriesById, updateDomains: this.updateDomains, - domainsByItemId, - subDomainsByItemId, }; return ( @@ -448,8 +715,23 @@ class Scaler extends React.Component { export default withDisplayName('Scaler', (props: Props) => ( - {(dataContext: DataContext) => ( - + {({ + timeDomain, + timeSubDomain, + timeSubDomainChanged, + limitTimeSubDomain, + series, + collections, + }: DataContext) => ( + )} )); diff --git a/src/components/XAxis/index.tsx b/src/components/XAxis/index.tsx index a7137fab..681f8036 100644 --- a/src/components/XAxis/index.tsx +++ b/src/components/XAxis/index.tsx @@ -8,7 +8,6 @@ import ScalerContext from '../../context/Scaler'; import ZoomRect from '../ZoomRect'; import { createXScale, ScalerFunctionFactory } from '../../utils/scale-helpers'; import { Domain, Series } from '../../external'; -import { DomainsByItemId } from '../Scaler'; import { withDisplayName } from '../../utils/displayName'; export interface Props { @@ -22,8 +21,6 @@ export interface Props { } interface ScalerProps { - domainsByItemId: DomainsByItemId; - subDomainsByItemId: DomainsByItemId; series: Series[]; } @@ -151,13 +148,11 @@ const getTextProps = ({ const XAxis: React.FunctionComponent = ({ axis: a = 'time', - domainsByItemId, height = 50, placement = AxisPlacement.BOTTOM, scaled = true, series, stroke = 'black', - subDomainsByItemId, tickFormatter = Number, ticks = 0, width = 1, @@ -166,9 +161,19 @@ const XAxis: React.FunctionComponent = ({ return null; } + const { timeDomain, timeSubDomain, xDomain, xSubDomain } = series[0]; + const domains = { + time: timeDomain, + x: xDomain, + }; + const subDomains = { + time: timeSubDomain, + x: xSubDomain, + }; + // TODO: Update this to be multi-series aware. Right now this assumes one // single x axis, which isn't scalable. - const domain = (scaled ? subDomainsByItemId : domainsByItemId)[series[0].id]; + const domain = scaled ? subDomains[a] : domains[a]; // The system hasn't fully booted-up yet (domains / subdomains are still being // calculated and populated), so we need to wait a heartbeat. @@ -179,7 +184,7 @@ const XAxis: React.FunctionComponent = ({ // @ts-ignore - I think that TypeScript is wrong here because nothing here // will be void .. ? const scale: d3.ScaleLinear = X_SCALER_FACTORY[a]( - domain[a], + domain, width ); const axis = d3.axisBottom(scale); @@ -266,16 +271,10 @@ const XAxis: React.FunctionComponent = ({ export default withDisplayName('XAxis', (props: Props) => ( - {({ domainsByItemId, subDomainsByItemId, series }: ScalerProps) => ( + {({ series }: ScalerProps) => ( {({ size }: { size: SizeProps }) => ( - + )} )} diff --git a/src/components/ZoomRect/index.js b/src/components/ZoomRect/index.js index 7980c28a..c7c67749 100644 --- a/src/components/ZoomRect/index.js +++ b/src/components/ZoomRect/index.js @@ -38,7 +38,12 @@ const propTypes = { // These are provided by Griff. updateDomains: GriffPropTypes.updateDomains.isRequired, - subDomainsByItemId: GriffPropTypes.subDomainsByItemId.isRequired, + seriesById: PropTypes.shape({ + [PropTypes.string.isRequired]: GriffPropTypes.singleSeries, + }), + collectionsById: PropTypes.shape({ + [PropTypes.string.isRequired]: GriffPropTypes.collection, + }), }; const defaultProps = { @@ -50,6 +55,21 @@ const defaultProps = { onDoubleClick: null, onTouchMove: null, onTouchMoveEnd: null, + seriesById: {}, + collectionsById: {}, +}; + +const getSubDomain = (item, axis) => { + switch (String(axis)) { + case 'time': + return item.timeSubDomain; + case 'x': + return item.xSubDomain; + case 'y': + return item.ySubDomain; + default: + throw new Error(`Unknown axis: ${axis}`); + } }; class ZoomRect extends React.Component { @@ -167,7 +187,7 @@ class ZoomRect extends React.Component { }; performTouchDrag = (touches, totalDistances) => { - const { itemIds, subDomainsByItemId, zoomAxes } = this.props; + const { collectionsById, itemIds, seriesById, zoomAxes } = this.props; const [touch] = touches; const newTouchPosition = { [Axes.time]: touch.pageX, @@ -178,7 +198,10 @@ class ZoomRect extends React.Component { itemIds.forEach(itemId => { updates[itemId] = {}; Axes.ALL.filter(axis => zoomAxes[axis]).forEach(axis => { - const subDomain = (subDomainsByItemId[itemId] || {})[axis]; + const subDomain = getSubDomain( + seriesById[itemId] || collectionsById[itemId], + axis + ); const subDomainRange = subDomain[1] - subDomain[0]; let newSubDomain = null; const percentMovement = @@ -196,7 +219,14 @@ class ZoomRect extends React.Component { }; performTouchZoom = (touches, totalDistances) => { - const { itemIds, subDomainsByItemId, zoomAxes, width, height } = this.props; + const { + collectionsById, + itemIds, + seriesById, + zoomAxes, + width, + height, + } = this.props; const [touchOne, touchTwo] = touches; const { x: touchOneX, y: touchOneY } = this.getOffset(touchOne); const { x: touchTwoX, y: touchTwoY } = this.getOffset(touchTwo); @@ -237,7 +267,10 @@ class ZoomRect extends React.Component { updates[itemId] = {}; Axes.ALL.filter(axis => zoomAxes[axis] && this.lastDeltas[axis]).forEach( axis => { - const subDomain = (subDomainsByItemId[itemId] || {})[axis]; + const subDomain = getSubDomain( + seriesById[itemId] || collectionsById[itemId], + axis + ); const subDomainRange = subDomain[1] - subDomain[0]; const percentFromEnd = centers[axis] / measurements[axis]; @@ -310,11 +343,14 @@ class ZoomRect extends React.Component { }; const updates = {}; - const { subDomainsByItemId, updateDomains } = this.props; + const { collectionsById, seriesById, updateDomains } = this.props; itemIds.forEach(itemId => { updates[itemId] = {}; Axes.ALL.filter(axis => zoomAxes[axis]).forEach(axis => { - const subDomain = (subDomainsByItemId[itemId] || {})[axis]; + const subDomain = getSubDomain( + seriesById[itemId] || collectionsById[itemId] || {}, + axis + ); const subDomainRange = subDomain[1] - subDomain[0]; let newSubDomain = null; if (sourceEvent.deltaY) { @@ -389,11 +425,11 @@ ZoomRect.defaultProps = defaultProps; export default withDisplayName('ZoomRect', props => ( - {({ domainsByItemId, subDomainsByItemId, updateDomains }) => ( + {({ collectionsById, seriesById, updateDomains }) => ( )} diff --git a/src/context/Scaler.js b/src/context/Scaler.js index c4e29fd1..fc56ac10 100644 --- a/src/context/Scaler.js +++ b/src/context/Scaler.js @@ -2,8 +2,7 @@ import React from 'react'; export default React.createContext({ series: [], + seriesById: {}, collections: [], - domainsByItemId: {}, - subDomainsByItemId: {}, updateDomains: () => null, }); diff --git a/src/internal.d.ts b/src/internal.d.ts index ee2572ae..98684e8f 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -14,12 +14,12 @@ export interface Item { yAccessor: AccessorFunction; y0Accessor: AccessorFunction; y1Accessor: AccessorFunction; - timeDomain?: Domain; - timeSubDomain?: Domain; - xDomain?: Domain; - xSubDomain?: Domain; - yDomain?: Domain; - ySubDomain?: Domain; + timeDomain: Domain; + timeSubDomain: Domain; + xDomain: Domain; + xSubDomain: Domain; + yDomain: Domain; + ySubDomain: Domain; yAxisDisplayMode?: AxisDisplayMode; pointWidth?: number; } diff --git a/src/utils/cleaner.ts b/src/utils/cleaner.ts new file mode 100644 index 00000000..f89838c8 --- /dev/null +++ b/src/utils/cleaner.ts @@ -0,0 +1,11 @@ +export function deleteUndefinedFromObject(obj: T): T { + const copy = { ...obj }; + Object.keys(copy).forEach(key => { + // @ts-ignore - Implicit any is okay here. + if (copy[key] === undefined) { + // @ts-ignore - Implicit any is okay here. + delete copy[key]; + } + }); + return copy; +} diff --git a/stories/Chartjs.stories.js b/stories/Chartjs.stories.js index 6b0d4496..2f378124 100644 --- a/stories/Chartjs.stories.js +++ b/stories/Chartjs.stories.js @@ -23,7 +23,7 @@ storiesOf('integrations/ChartJS', module) - {({ series, subDomainsByItemId }) => ( + {({ series }) => ( { - const timeSubDomain = subDomainsByItemId[s.id].time; + const { timeSubDomain } = s; const groupedData = s.data .filter( ({ timestamp }) => @@ -72,7 +72,7 @@ storiesOf('integrations/ChartJS', module) - {({ series, subDomainsByItemId }) => ( + {({ series }) => ( { - const timeSubDomain = subDomainsByItemId[s.id].time; + const { timeSubDomain } = s; const groupedData = s.data .filter( ({ timestamp }) => diff --git a/stories/Collection.stories.js b/stories/Collection.stories.js index 0ffa0653..1bdb3fd6 100644 --- a/stories/Collection.stories.js +++ b/stories/Collection.stories.js @@ -10,6 +10,7 @@ import { import { staticLoader } from './loaders'; import ToggleRenderer from './ToggleRenderer'; +import ScalerDebugger from './ScalerDebugger'; import { scatterplotloader } from './Scatterplot.stories'; const staticXDomain = [Date.now() - 1000 * 60 * 60 * 24 * 30, Date.now()]; @@ -28,6 +29,17 @@ storiesOf('components/Collection', module) + + + )) + .add('LineChart with two items', () => ( + + + + +(d.value + 2)} /> + + + )) .add('Basic Scatterplot', () => ( diff --git a/stories/Plotly.stories.js b/stories/Plotly.stories.js index ef661445..29f69c8e 100644 --- a/stories/Plotly.stories.js +++ b/stories/Plotly.stories.js @@ -13,14 +13,13 @@ import { staticLoader } from './loaders'; const staticXDomain = [+moment().subtract(1, 'week'), +moment()]; -const seriesToPlotly = ({ color, data, id }, subDomainsByItemId) => { - const filteredData = - subDomainsByItemId && subDomainsByItemId[id] && subDomainsByItemId[id].time - ? data.filter(({ timestamp }) => { - const timeSubDomain = subDomainsByItemId[id].time; - return timestamp >= timeSubDomain[0] && timestamp <= timeSubDomain[1]; - }) - : data; +const seriesToPlotly = ({ color, data, timeSubDomain }) => { + const filteredData = timeSubDomain + ? data.filter( + ({ timestamp }) => + timestamp >= timeSubDomain[0] && timestamp <= timeSubDomain[1] + ) + : data; return { x: filteredData.map(({ timestamp }) => new Date(timestamp)), y: filteredData.map(({ value }) => value), @@ -62,9 +61,9 @@ storiesOf('integrations/Plotly', module) - {({ series, subDomainsByItemId }) => ( + {({ series }) => ( seriesToPlotly(s, subDomainsByItemId))} + data={series.map(s => seriesToPlotly(s))} layout={{ width: '100%', height: 400, @@ -83,10 +82,10 @@ storiesOf('integrations/Plotly', module) - {({ series, domainsByItemId, subDomainsByItemId, updateDomains }) => ( + {({ series, updateDomains }) => ( s.id).join('-')} - data={series.map(s => seriesToPlotly(s, subDomainsByItemId))} + data={series.map(s => seriesToPlotly(s))} layout={{ width: '100%', height: 400, @@ -100,11 +99,11 @@ storiesOf('integrations/Plotly', module) } = input; updateDomains( series.reduce( - (update, { id }) => ({ + (update, { id, timeDomain }) => ({ ...update, [id]: { time: autorange - ? domainsByItemId[id].time + ? timeDomain : [lowerTime, upperTime].map(d => new Date(d).getTime() ), diff --git a/stories/ScalerDebugger.js b/stories/ScalerDebugger.js new file mode 100644 index 00000000..0b4b094c --- /dev/null +++ b/stories/ScalerDebugger.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { ScalerContext } from '../build/src'; + +export default () => ( + + {({ series, collections, seriesById }) => ( +
+
+          {JSON.stringify(
+            {
+              seriesById: Object.keys(seriesById).reduce(
+                (acc, id) => ({
+                  ...acc,
+                  [id]: { ...seriesById[id], data: [] },
+                }),
+                {}
+              ),
+            },
+            null,
+            2
+          )}
+        
+
+          {JSON.stringify(
+            {
+              series: series.map(s => ({ ...s, data: [] })),
+            },
+            null,
+            2
+          )}
+        
+
+          {JSON.stringify(
+            {
+              collections,
+            },
+            null,
+            2
+          )}
+        
+
+ )} +
+); diff --git a/stories/SeriesCollections.stories.js b/stories/SeriesCollections.stories.js index da36ceda..9bfe3a46 100644 --- a/stories/SeriesCollections.stories.js +++ b/stories/SeriesCollections.stories.js @@ -25,17 +25,17 @@ storiesOf('Series Collections', module) , - - - - d.value + 2} /> - - - , + // + // + // + // d.value + 2} /> + // + // + // , ]) .add('Multiple collections', () => ( diff --git a/stories/ToggleRenderer.js b/stories/ToggleRenderer.js index 2d48dbdb..542d3231 100644 --- a/stories/ToggleRenderer.js +++ b/stories/ToggleRenderer.js @@ -39,6 +39,8 @@ const opacityAccessor = d => ((d.value * 100) % 100) / 100; opacityAccessor.toString = () => 'custom opacity'; const OPTIONS = { + yDomain: [[-1, 2], [0, 10], [0.25, 0.75]].map(makePrintable), + ySubDomain: [[-1, 2], [0, 10], [0.25, 0.75]].map(makePrintable), color: ['maroon', 'steelblue', 'darkgreen', 'lightsalmon'], collectionId: ['missing-collection'], drawLines: [true, false], @@ -52,8 +54,6 @@ const OPTIONS = { step: [true, false], zoomable: [true, false], name: ['readable-name'], - yDomain: [[-1, 2], [0, 10], [0.25, 0.75]].map(makePrintable), - ySubDomain: [[-1, 2], [0, 10], [0.25, 0.75]].map(makePrintable), yAxisPlacement: [ AxisPlacement.LEFT, AxisPlacement.RIGHT,