From 2d9d905fc9c348ca752c93bee40c36bb16774f28 Mon Sep 17 00:00:00 2001 From: Evan Charlton Date: Thu, 30 May 2019 13:04:41 +0200 Subject: [PATCH] Refactor the data flow inside of Scaler Clean up the internal data flow inside of the Scaler component. This refactoring had a few main goals: 1. Fully-own the responsibility of providing scaled data downstream alongside the Series and Collection items. 2. Increase performance by using better checks to detect when the incoming Series and Collections objects were externally changed. 3. Stop exposing domainsByItemId / subDomainsByItemId to the downstream rendering components, in deference to Series and Collection objects directly. 4. Get closer toward enabling per-Series timeDomains / timeSubDomains as documented in #301. The end result of this is that the (internal) API has nontrivially changed. Instead of Scaler exposing maps of domainsByItemId and subDomainByItemId, it will simply pass through the series and collections arrays, but they are now all guaranteed to be fully-populated with domain and subDomain information. This means that rendering components can simply render using the timeDomain, timeSubDomain, xDomain, xSubDomain, yDomain, and ySubDomain domains (which are guaranteed to be present) and don't need to concern themselves with how they were populated (user/state, props, calculated from data, or placeholders). This does not yet fully enable separate timeDomain / timeSubdomain throughout the library, but it removes almost all blockers. The final piece to this puzzle is in DataProvider -- it needs to learn a one more trick in order to support this (it currently only knows about one timeSubDomain). That cleanup will be saved for a future PR. --- src/components/AxisCollection/YAxis.js | 23 +- src/components/ContextChart/index.js | 139 +++- src/components/DataProvider/index.js | 304 +++------ src/components/GridLines/index.tsx | 22 +- src/components/InteractionLayer/index.js | 75 +-- src/components/LineCollection/index.tsx | 51 +- src/components/PointCollection/index.tsx | 24 +- src/components/Scaler/index.tsx | 796 +++++++++++++++-------- src/components/XAxis/index.tsx | 29 +- src/components/ZoomRect/index.js | 56 +- src/context/Scaler.js | 3 +- src/internal.d.ts | 12 +- src/utils/cleaner.ts | 11 + stories/Chartjs.stories.js | 8 +- stories/Collection.stories.js | 12 + stories/Plotly.stories.js | 27 +- stories/ScalerDebugger.js | 44 ++ stories/SeriesCollections.stories.js | 22 +- stories/ToggleRenderer.js | 4 +- 19 files changed, 947 insertions(+), 715 deletions(-) create mode 100644 src/utils/cleaner.ts create mode 100644 stories/ScalerDebugger.js 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,