diff --git a/src/client/components/timeseries-visualization-controls/split-menu.tsx b/src/client/components/timeseries-visualization-controls/split-menu.tsx new file mode 100644 index 000000000..6199fbf32 --- /dev/null +++ b/src/client/components/timeseries-visualization-controls/split-menu.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2017-2022 Allegro.pl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo, useState } from "react"; +import { colorSplitLimits } from "../../../common/models/colors/colors"; +import { granularityToString } from "../../../common/models/granularity/granularity"; +import { DimensionSortOn, SortOn } from "../../../common/models/sort-on/sort-on"; +import { useSettingsContext } from "../../views/cube-view/settings-context"; +import { getContinuousSplit } from "../../visualizations/line-chart/utils/splits"; +import { GranularityPicker } from "../split-menu/granularity-picker"; +import { LimitDropdown } from "../split-menu/limit-dropdown"; +import { SortDropdown } from "../split-menu/sort-dropdown"; +import { SplitMenuProps } from "../split-menu/split-menu"; +import { createSplit, SplitMenuBase, validateSplit } from "../split-menu/split-menu-base"; + +const TimeSeriesContinuousSplitMenu: React.FunctionComponent = props => { + const { saveSplit, containerStage, split, dimension, onClose, openOn } = props; + const [granularity, setGranularity] = useState(() => split.bucket && granularityToString(split.bucket)); + + const onSave = () => { + const newSplit = createSplit({ + dimension, + split, + granularity + }); + saveSplit(split, newSplit); + }; + + const isValid = validateSplit({ split, dimension, granularity }); + + return + + ; +}; + +const TimeSeriesCategorySplitMenu: React.FunctionComponent = props => { + const { openOn, containerStage, onClose, dimension, saveSplit, split, essence } = props; + + const sortOptions = [ + new DimensionSortOn(dimension), + ...essence.seriesSortOns(true).toArray() + ]; + + const { customization: { visualizationColors: { series } } } = useSettingsContext(); + const limitOptions = useMemo(() => colorSplitLimits(series.length), [series.length]); + + const [sort, setSort] = useState(split.sort); + const [limit, setLimit] = useState(split.limit); + + const isValid = validateSplit({ split, dimension, limit, sort }); + + const onSave = () => { + const newSplit = createSplit({ + dimension, + split, + limit, + sort + }); + saveSplit(split, newSplit); + }; + + return + + + ; +}; + +export const TimeSeriesSplitMenu: React.FunctionComponent = props => { + const { essence, split } = props; + + const isContinuousSplit = split.equals(getContinuousSplit(essence)); + + if (isContinuousSplit) { + return ; + } else { + return ; + } +}; diff --git a/src/client/components/timeseries-visualization-controls/visualization-controls.tsx b/src/client/components/timeseries-visualization-controls/visualization-controls.tsx new file mode 100644 index 000000000..fd7994351 --- /dev/null +++ b/src/client/components/timeseries-visualization-controls/visualization-controls.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2017-2022 Allegro.pl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { VisualizationControls, VisualizationControlsBaseProps } from "../../views/cube-view/center-panel/center-panel"; +import { SplitTile, SplitTileBaseProps } from "../split-tile/split-tile"; +import { SplitTilesRow, SplitTilesRowBaseProps } from "../split-tile/split-tiles-row"; +import { TimeSeriesSplitMenu } from "./split-menu"; + +const TimeSeriesSplitTile: React.FunctionComponent = props => + ; + +const TimeSeriesSplitTilesRow: React.FunctionComponent = props => + ; + +export const TimeSeriesVisualizationControls: React.FunctionComponent = props => + ; diff --git a/src/client/modals/druid-query-modal/druid-query-modal.tsx b/src/client/modals/druid-query-modal/druid-query-modal.tsx index 0c1331611..41db3b83d 100644 --- a/src/client/modals/druid-query-modal/druid-query-modal.tsx +++ b/src/client/modals/druid-query-modal/druid-query-modal.tsx @@ -34,12 +34,16 @@ export const DruidQueryModal: React.FunctionComponent = ({ const queryFn = visualization.name === "grid" ? gridQuery : standardQuery; const query = queryFn(essence, timekeeper); const external = External.fromJS({ engine: "druid", attributes, source, customAggregations, customTransforms }); - const plan = query.simulateQueryPlan({ main: external }); - const planSource = JSON.stringify(plan, null, 2); + let plan; + try { + plan = JSON.stringify(query.simulateQueryPlan({ main: external }), null, 2); + } catch (e) { + plan = "Couldn't create Druid Query Plan."; + } return ; + source={plan} />; }; diff --git a/src/client/visualizations/bar-chart/bar-chart.tsx b/src/client/visualizations/bar-chart/bar-chart.tsx index d51c18085..33e2f7b97 100644 --- a/src/client/visualizations/bar-chart/bar-chart.tsx +++ b/src/client/visualizations/bar-chart/bar-chart.tsx @@ -15,972 +15,38 @@ * limitations under the License. */ -import * as d3 from "d3"; -import { List, Set } from "immutable"; -import { Dataset, Datum, NumberRange, PlywoodRange, PseudoDatum, Range } from "plywood"; import React from "react"; -import { ChartProps } from "../../../common/models/chart-props/chart-props"; -import { DateRange } from "../../../common/models/date-range/date-range"; -import { canBucketByDefault, Dimension } from "../../../common/models/dimension/dimension"; -import { findDimensionByName } from "../../../common/models/dimension/dimensions"; -import { - BooleanFilterClause, - FilterClause, - FixedTimeFilterClause, - NumberFilterClause, - StringFilterAction, - StringFilterClause -} from "../../../common/models/filter-clause/filter-clause"; -import { Measure } from "../../../common/models/measure/measure"; -import { ConcreteSeries, SeriesDerivation } from "../../../common/models/series/concrete-series"; -import { Series } from "../../../common/models/series/series"; -import { SortDirection } from "../../../common/models/sort/sort"; -import { SplitType } from "../../../common/models/split/split"; -import { Splits } from "../../../common/models/splits/splits"; -import { Stage } from "../../../common/models/stage/stage"; -import { formatValue } from "../../../common/utils/formatter/formatter"; import { or } from "../../../common/utils/functional/functional"; import makeQuery from "../../../common/utils/query/visualization-query"; import { Predicates } from "../../../common/utils/rules/predicates"; -import { BAR_CHART_MANIFEST } from "../../../common/visualization-manifests/bar-chart/bar-chart"; -import { BucketMarks } from "../../components/bucket-marks/bucket-marks"; -import { GridLines } from "../../components/grid-lines/grid-lines"; -import { HighlightModal } from "../../components/highlight-modal/highlight-modal"; -import { MeasureBubbleContent } from "../../components/measure-bubble-content/measure-bubble-content"; -import { Scroller, ScrollerLayout } from "../../components/scroller/scroller"; -import { SegmentBubble } from "../../components/segment-bubble/segment-bubble"; -import { VerticalAxis } from "../../components/vertical-axis/vertical-axis"; -import { VisMeasureLabel } from "../../components/vis-measure-label/vis-measure-label"; -import { SPLIT, VIS_H_PADDING } from "../../config/constants"; -import { classNames, roundToPx } from "../../utils/dom/dom"; -import { ChartPanel, DefaultVisualizationControls, VisualizationProps } from "../../views/cube-view/center-panel/center-panel"; -import { SettingsContext, SettingsContextValue } from "../../views/cube-view/settings-context"; -import { hasHighlightOn } from "../highlight-controller/highlight-controller"; +import { + TimeSeriesVisualizationControls +} from "../../components/timeseries-visualization-controls/visualization-controls"; +import { + ChartPanel, + DefaultVisualizationControls, + VisualizationProps +} from "../../views/cube-view/center-panel/center-panel"; import "./bar-chart.scss"; -import { BarCoordinates } from "./bar-coordinates"; import { BarChart as ImprovedBarChart } from "./improved-bar-chart/bar-chart"; +import { BarChart } from "./old-bar-chart/old-bar-chart"; -const X_AXIS_HEIGHT = 84; -const Y_AXIS_WIDTH = 60; -const CHART_TOP_PADDING = 10; -const CHART_BOTTOM_PADDING = 0; -const MIN_CHART_HEIGHT = 200; -const MAX_STEP_WIDTH = 140; // Note that the step is bar + empty space around it. The width of the rectangle is step * BAR_PROPORTION -const MIN_STEP_WIDTH = 20; -const BAR_PROPORTION = 0.8; -const BARS_MIN_PAD_LEFT = 30; -const BARS_MIN_PAD_RIGHT = 6; -const HOVER_BUBBLE_V_OFFSET = 8; -const SELECTION_PAD = 4; - -export interface BubbleInfo { - series: ConcreteSeries; - chartIndex: number; - path: Datum[]; - coordinates: BarCoordinates; - splitIndex?: number; - segmentLabel?: string; -} - -export interface BarChartState { - hoverInfo?: BubbleInfo; - selectionInfo?: BubbleInfo; - scrollerYPosition?: number; - scrollerXPosition?: number; - scrollTop: number; - scrollLeft: number; - - // Precalculated stuff - flatData?: PseudoDatum[]; - maxNumberOfLeaves?: number[]; -} - -function getFilterFromDatum(splits: Splits, dataPath: Datum[]): List { - return List(dataPath.map((datum, i) => { - const { type, reference } = splits.getSplit(i); - const segment: any = datum[reference]; - - switch (type) { - case SplitType.boolean: - return new BooleanFilterClause({ reference, values: Set.of(segment) }); - case SplitType.number: - return new NumberFilterClause({ reference, values: List.of(segment) }); - case SplitType.time: - return new FixedTimeFilterClause({ reference, values: List.of(new DateRange(segment)) }); - case SplitType.string: - return new StringFilterClause({ reference, action: StringFilterAction.IN, values: Set.of(segment) }); - } - })); -} - -function padDataset(originalDataset: Dataset, dimension: Dimension, measures: Measure[]): Dataset { - const data = (originalDataset.data[0][SPLIT] as Dataset).data; - const dimensionName = dimension.name; - - const firstBucket: PlywoodRange = data[0][dimensionName] as PlywoodRange; - if (!firstBucket) return originalDataset; - const start = Number(firstBucket.start); - const end = Number(firstBucket.end); - - const size = end - start; - - let i = start; - let j = 0; - - const filledData: Datum[] = []; - data.forEach(d => { - const segmentValue = d[dimensionName]; - const segmentStart = (segmentValue as PlywoodRange).start; - while (i < segmentStart) { - filledData[j] = {}; - filledData[j][dimensionName] = NumberRange.fromJS({ - start: i, - end: i + size - }); - measures.forEach(m => { - filledData[j][m.name] = 0; // todo: what if effective zero is not 0? - }); - - if (d[SPLIT]) { - filledData[j][SPLIT] = new Dataset({ - data: [], - attributes: [] - }); - } - - j++; - i += size; - } - filledData[j] = d; - i += size; - j++; - }); - - const value = originalDataset.valueOf(); - (value.data[0][SPLIT] as Dataset).data = filledData; - return new Dataset(value); -} +const newVersionSupports = or( + Predicates.areExactSplitKinds("time"), + Predicates.areExactSplitKinds("*", "time"), + Predicates.areExactSplitKinds("number"), + Predicates.areExactSplitKinds("*", "number") +); export default function BarChartVisualization(props: VisualizationProps) { + if (newVersionSupports(props.essence)) { + return + + + ; + } return ; } - -class BarChart extends React.Component { - static contextType = SettingsContext; - protected className = BAR_CHART_MANIFEST.name; - - private coordinatesCache: BarCoordinates[][] = []; - private scroller = React.createRef(); - - state: BarChartState = this.initState(); - - context: SettingsContextValue; - - componentDidUpdate() { - const { scrollerYPosition, scrollerXPosition } = this.state; - - const scrollerComponent = this.scroller.current; - if (!scrollerComponent) return; - - const rect = scrollerComponent.scroller.current.getBoundingClientRect(); - - if (scrollerYPosition !== rect.top || scrollerXPosition !== rect.left) { - this.setState({ scrollerYPosition: rect.top, scrollerXPosition: rect.left }); - } - } - - calculateMousePosition(x: number, y: number): BubbleInfo { - const { essence } = this.props; - - const series = essence.getConcreteSeries(); - const chartStage = this.getSingleChartStage(); - const chartHeight = this.getOuterChartHeight(chartStage); - - if (y >= chartHeight * series.size) return null; // on x axis - if (x >= chartStage.width) return null; // on y axis - - const xScale = this.getPrimaryXScale(); - const chartIndex = Math.floor(y / chartHeight); - - const chartCoordinates = this.getBarsCoordinates(chartIndex, xScale); - - const { path, coordinates } = this.findBarCoordinatesForX(x, chartCoordinates, []); - - return { - path: this.findPathForIndices(path), - series: series.get(chartIndex), - chartIndex, - coordinates - }; - } - - findPathForIndices(indices: number[]): Datum[] { - const { data } = this.props; - const mySplitDataset = data.data[0][SPLIT] as Dataset; - - const path: Datum[] = []; - let currentData: Dataset = mySplitDataset; - indices.forEach(i => { - const datum = currentData.data[i]; - path.push(datum); - currentData = (datum[SPLIT] as Dataset); - }); - - return path; - } - - findBarCoordinatesForX(x: number, coordinates: BarCoordinates[], currentPath: number[]): { path: number[], coordinates: BarCoordinates } { - for (let i = 0; i < coordinates.length; i++) { - if (coordinates[i].isXWithin(x)) { - currentPath.push(i); - if (coordinates[i].hasChildren()) { - return this.findBarCoordinatesForX(x, coordinates[i].children, currentPath); - } else { - return { path: currentPath, coordinates: coordinates[i] }; - } - } - } - - return { path: [], coordinates: null }; - } - - onScrollerScroll = (scrollTop: number, scrollLeft: number) => { - this.setState({ - hoverInfo: null, - scrollLeft, - scrollTop - }); - }; - - onMouseMove = (x: number, y: number) => { - this.setState({ hoverInfo: this.calculateMousePosition(x, y) }); - }; - - onMouseLeave = () => { - this.setState({ hoverInfo: null }); - }; - - onClick = (x: number, y: number) => { - const { essence, highlight, dropHighlight, saveHighlight } = this.props; - - const selectionInfo = this.calculateMousePosition(x, y); - - if (!selectionInfo) return; - - if (!selectionInfo.coordinates) { - dropHighlight(); - this.setState({ selectionInfo: null }); - return; - } - - const { path, chartIndex } = selectionInfo; - - const { splits } = essence; - const series = essence.getConcreteSeries(); - - const rowHighlight = getFilterFromDatum(splits, path); - - const currentSeries = series.get(chartIndex).definition; - if (hasHighlightOn(highlight, currentSeries.key())) { - if (rowHighlight.equals(highlight.clauses)) { - dropHighlight(); - this.setState({ selectionInfo: null }); - return; - } - } - - this.setState({ selectionInfo }); - saveHighlight(rowHighlight, series.get(chartIndex).definition.key()); - }; - - getYExtent(data: Datum[], series: ConcreteSeries): number[] { - const getY = (d: Datum) => series.selectValue(d); - return d3.extent(data, getY); - } - - getYScale(series: ConcreteSeries, yAxisStage: Stage): d3.ScaleLinear { - const { essence } = this.props; - const { flatData } = this.state; - - const splitLength = essence.splits.length(); - const leafData = flatData.filter((d: Datum) => d["__nest"] === splitLength - 1); - - const extentY = this.getYExtent(leafData, series); - - return d3.scaleLinear() - .domain([Math.min(extentY[0] * 1.1, 0), Math.max(extentY[1] * 1.1, 0)]) - .range([yAxisStage.height, yAxisStage.y]); - } - - hasValidYExtent(series: ConcreteSeries, data: Datum[]): boolean { - const [yMin, yMax] = this.getYExtent(data, series); - return !isNaN(yMin) && !isNaN(yMax); - } - - getSingleChartStage(): Stage { - const xScale = this.getPrimaryXScale(); - const { essence, stage } = this.props; - - const { stepWidth } = this.getBarDimensions(xScale.bandwidth()); - const xTicks = xScale.domain(); - const width = xTicks.length > 0 ? roundToPx(xScale(xTicks[xTicks.length - 1])) + stepWidth : 0; - - const measures = essence.getConcreteSeries(); - const availableHeight = stage.height - X_AXIS_HEIGHT; - const height = Math.max(MIN_CHART_HEIGHT, Math.floor(availableHeight / measures.size)); - - return new Stage({ - x: 0, - y: CHART_TOP_PADDING, - width: Math.max(width, stage.width - Y_AXIS_WIDTH - VIS_H_PADDING * 2), - height: height - CHART_TOP_PADDING - CHART_BOTTOM_PADDING - }); - } - - getOuterChartHeight(chartStage: Stage): number { - return chartStage.height + CHART_TOP_PADDING + CHART_BOTTOM_PADDING; - } - - getAxisStages(chartStage: Stage): { xAxisStage: Stage, yAxisStage: Stage } { - const { essence, stage } = this.props; - - const xHeight = Math.max( - stage.height - (CHART_TOP_PADDING + CHART_BOTTOM_PADDING + chartStage.height) * essence.getConcreteSeries().size, - X_AXIS_HEIGHT - ); - - return { - xAxisStage: new Stage({ x: chartStage.x, y: 0, height: xHeight, width: chartStage.width }), - yAxisStage: new Stage({ x: 0, y: chartStage.y, height: chartStage.height, width: Y_AXIS_WIDTH + VIS_H_PADDING }) - }; - } - - getScrollerLayout(chartStage: Stage, xAxisStage: Stage, yAxisStage: Stage): ScrollerLayout { - const { essence } = this.props; - const measures = essence.getConcreteSeries().toArray(); - - const oneChartHeight = this.getOuterChartHeight(chartStage); - - return { - // Inner dimensions - bodyWidth: chartStage.width, - bodyHeight: oneChartHeight * measures.length - CHART_BOTTOM_PADDING, - - // Gutters - top: 0, - right: yAxisStage.width, - bottom: xAxisStage.height, - left: 0 - }; - } - - getBubbleTopOffset(y: number, chartIndex: number, chartStage: Stage): number { - const { scrollTop, scrollerYPosition } = this.state; - const oneChartHeight = this.getOuterChartHeight(chartStage); - const chartsAboveMe = oneChartHeight * chartIndex; - - return chartsAboveMe - scrollTop + scrollerYPosition + y - HOVER_BUBBLE_V_OFFSET + CHART_TOP_PADDING; - } - - getBubbleLeftOffset(x: number): number { - const { scrollLeft, scrollerXPosition } = this.state; - - return scrollerXPosition + x - scrollLeft; - } - - canShowBubble(leftOffset: number, topOffset: number): boolean { - const { stage } = this.props; - const { scrollerYPosition, scrollerXPosition } = this.state; - - if (topOffset <= 0) return false; - if (topOffset > scrollerYPosition + stage.height - X_AXIS_HEIGHT) return false; - if (leftOffset <= 0) return false; - if (leftOffset > scrollerXPosition + stage.width - Y_AXIS_WIDTH) return false; - - return true; - } - - renderSelectionBubble(hoverInfo: BubbleInfo): JSX.Element { - const { dropHighlight, acceptHighlight } = this.props; - const { series, path, chartIndex, segmentLabel, coordinates } = hoverInfo; - const chartStage = this.getSingleChartStage(); - const leftOffset = this.getBubbleLeftOffset(coordinates.middleX); - const topOffset = this.getBubbleTopOffset(coordinates.y, chartIndex, chartStage); - if (!this.canShowBubble(leftOffset, topOffset)) return null; - - const segmentValue = series.formatValue(path[path.length - 1]); - return - {segmentValue} - ; - } - - renderHoverBubble(hoverInfo: BubbleInfo): JSX.Element { - const chartStage = this.getSingleChartStage(); - const { series, path, chartIndex, segmentLabel, coordinates } = hoverInfo; - - const leftOffset = this.getBubbleLeftOffset(coordinates.middleX); - const topOffset = this.getBubbleTopOffset(coordinates.y, chartIndex, chartStage); - - if (!this.canShowBubble(leftOffset, topOffset)) return null; - - const measureContent = this.renderMeasureLabel(path[path.length - 1], series); - return ; - } - - private renderMeasureLabel(datum: Datum, series: ConcreteSeries): JSX.Element | string { - if (!this.props.essence.hasComparison()) { - return series.formatValue(datum); - } - const currentValue = series.selectValue(datum); - const previousValue = series.selectValue(datum, SeriesDerivation.PREVIOUS); - const formatter = series.formatter(); - return ; - } - - isSelected(path: Datum[], series: Series): boolean { - const { essence, highlight } = this.props; - const { splits } = essence; - return hasHighlightOn(highlight, series.key()) && highlight.clauses.equals(getFilterFromDatum(splits, path)); - } - - isFaded(): boolean { - return this.props.highlight !== null; - } - - hasAnySelectionGoingOn(): boolean { - return this.props.highlight !== null; - } - - isHovered(path: Datum[], series: ConcreteSeries): boolean { - const { essence } = this.props; - const { hoverInfo } = this.state; - const { splits } = essence; - - if (this.hasAnySelectionGoingOn()) return false; - if (!hoverInfo) return false; - if (!hoverInfo.series.equals(series)) return false; - - const filter = (path: Datum[]) => getFilterFromDatum(splits, path); - - return filter(hoverInfo.path).equals(filter(path)); - } - - renderBars( - data: Datum[], - series: ConcreteSeries, - chartIndex: number, - chartStage: Stage, - xAxisStage: Stage, - coordinates: BarCoordinates[], - splitIndex = 0, - path: Datum[] = [] - ): { bars: JSX.Element[], highlight: JSX.Element } { - const { essence } = this.props; - const { timezone } = essence; - const { customization: { visualizationColors } } = this.context; - - const bars: JSX.Element[] = []; - let highlight: JSX.Element; - - const dimension = findDimensionByName(essence.dataCube.dimensions, essence.splits.splits.get(splitIndex).reference); - const splitLength = essence.splits.length(); - - data.forEach((d, i) => { - const segmentValue = d[dimension.name]; - const segmentValueStr = formatValue(segmentValue, timezone); - const subPath = path.concat(d); - - let bar: any; - let bubble: JSX.Element = null; - const subCoordinates = coordinates[i]; - const { x, y, height, barWidth, barOffset } = coordinates[i]; - - if (splitIndex < splitLength - 1) { - const subData: Datum[] = (d[SPLIT] as Dataset).data; - const subRender = this.renderBars(subData, series, chartIndex, chartStage, xAxisStage, subCoordinates.children, splitIndex + 1, subPath); - - bar = subRender.bars; - if (!highlight && subRender.highlight) highlight = subRender.highlight; - - } else { - - const bubbleInfo: BubbleInfo = { - series, - chartIndex, - path: subPath, - coordinates: subCoordinates, - segmentLabel: segmentValueStr, - splitIndex - }; - - const isHovered = this.isHovered(subPath, series); - if (isHovered) { - bubble = this.renderHoverBubble(bubbleInfo); - } - - const selected = this.isSelected(subPath, series.definition); - const faded = this.isFaded(); - if (selected) { - bubble = this.renderSelectionBubble(bubbleInfo); - if (bubble) highlight = this.renderSelectionHighlight(chartStage, subCoordinates, chartIndex); - } - - bar = - - {bubble} - ; - - } - - bars.push(bar); - }); - - return { bars, highlight }; - } - - renderSelectionHighlight(chartStage: Stage, coordinates: BarCoordinates, chartIndex: number): JSX.Element { - const { scrollLeft, scrollTop } = this.state; - const chartHeight = this.getOuterChartHeight(chartStage); - const { barWidth, height, barOffset, y, x } = coordinates; - - const leftOffset = roundToPx(x) + barOffset - SELECTION_PAD + chartStage.x - scrollLeft; - const topOffset = roundToPx(y) - SELECTION_PAD + chartStage.y - scrollTop + chartHeight * chartIndex; - - const style: React.CSSProperties = { - left: leftOffset, - top: topOffset, - width: roundToPx(barWidth + SELECTION_PAD * 2), - height: roundToPx(Math.abs(height) + SELECTION_PAD * 2) - }; - - return
; - } - - renderXAxis(data: Datum[], coordinates: BarCoordinates[], xAxisStage: Stage): JSX.Element { - const { essence } = this.props; - const xScale = this.getPrimaryXScale(); - const xTicks = xScale.domain(); - - const split = essence.splits.splits.first(); - const dimension = findDimensionByName(essence.dataCube.dimensions, split.reference); - - const labels: JSX.Element[] = []; - if (canBucketByDefault(dimension)) { - const lastIndex = data.length - 1; - const ascending = split.sort.direction === SortDirection.ascending; - const leftThing = ascending ? "start" : "end"; - const rightThing = ascending ? "end" : "start"; - data.forEach((d, i) => { - const segmentValue = d[dimension.name]; - let segmentValueStr = String(Range.isRange(segmentValue) ? (segmentValue as any)[leftThing] : ""); - const coordinate = coordinates[i]; - - labels.push(
{segmentValueStr}
); - - if (i === lastIndex) { - segmentValueStr = String(Range.isRange(segmentValue) ? (segmentValue as any)[rightThing] : ""); - labels.push(
{segmentValueStr}
); - } - }); - } else { - data.forEach((d, i) => { - const segmentValueStr = String(d[dimension.name]); - const coordinate = coordinates[i]; - - labels.push(
{segmentValueStr}
); - }); - } - - return
- - - - {labels} -
; - } - - getYAxisStuff(dataset: Dataset, series: ConcreteSeries, chartStage: Stage, chartIndex: number): { - yGridLines: JSX.Element, yAxis: JSX.Element, yScale: d3.ScaleLinear - } { - const { yAxisStage } = this.getAxisStages(chartStage); - - const yScale = this.getYScale(series, yAxisStage); - const yTicks = yScale.ticks(5); - - const yGridLines: JSX.Element = ; - - const axisStage = yAxisStage.changeY(yAxisStage.y + (chartStage.height + CHART_TOP_PADDING + CHART_BOTTOM_PADDING) * chartIndex); - - const yAxis: JSX.Element = ; - - return { yGridLines, yAxis, yScale }; - } - - isChartVisible(chartIndex: number, xAxisStage: Stage): boolean { - const { stage } = this.props; - const { scrollTop } = this.state; - - const chartStage = this.getSingleChartStage(); - const chartHeight = this.getOuterChartHeight(chartStage); - - const topY = chartIndex * chartHeight; - const viewPortHeight = stage.height - xAxisStage.height; - const hiddenAtBottom = topY - scrollTop >= viewPortHeight; - - const bottomY = topY + chartHeight; - const hiddenAtTop = bottomY < scrollTop; - - return !hiddenAtTop && !hiddenAtBottom; - } - - renderChart( - dataset: Dataset, - coordinates: BarCoordinates[], - series: ConcreteSeries, - chartIndex: number, - chartStage: Stage - ): { yAxis: JSX.Element, chart: JSX.Element, highlight: JSX.Element } { - const { essence } = this.props; - const mySplitDataset = dataset.data[0][SPLIT] as Dataset; - - const measureLabel = ; - - // Invalid data, early return - if (!this.hasValidYExtent(series, mySplitDataset.data)) { - return { - chart:
- - {measureLabel} -
, - yAxis: null, - highlight: null - }; - } - - const { xAxisStage } = this.getAxisStages(chartStage); - - const { yAxis, yGridLines } = this.getYAxisStuff(mySplitDataset, series, chartStage, chartIndex); - - let bars: JSX.Element[]; - let highlight: JSX.Element; - if (this.isChartVisible(chartIndex, xAxisStage)) { - const renderedChart = this.renderBars(mySplitDataset.data, series, chartIndex, chartStage, xAxisStage, coordinates); - bars = renderedChart.bars; - highlight = renderedChart.highlight; - } - - const chart =
- - {yGridLines} - {bars} - - {measureLabel} -
; - - return { chart, yAxis, highlight }; - } - - private initState(): BarChartState { - const { essence, data } = this.props; - const { splits } = essence; - this.coordinatesCache = []; - if (!splits.length()) return {} as BarChartState; - const split = splits.splits.first(); - const dimension = findDimensionByName(essence.dataCube.dimensions, split.reference); - const dimensionKind = dimension.kind; - const series = essence.getConcreteSeries().toArray(); - // TODO: very suspicious - const paddedDataset = dimensionKind === "number" ? padDataset(data, dimension, series.map(s => s.measure)) : data; - const firstSplitDataSet = paddedDataset.data[0][SPLIT] as Dataset; - const flattened = firstSplitDataSet.flatten({ - order: "preorder", - nestingName: "__nest" - }); - - const maxNumberOfLeaves = splits.splits.map(() => 0).toArray(); // initializing maxima to 0 - this.maxNumberOfLeaves(firstSplitDataSet.data, maxNumberOfLeaves, 0); - const flatData = flattened.data; - return { - hoverInfo: null, - maxNumberOfLeaves, - flatData, - scrollTop: 0, - scrollLeft: 0 - }; - } - - maxNumberOfLeaves(data: Datum[], maxima: number[], level: number) { - maxima[level] = Math.max(maxima[level], data.length); - - if (data[0] && data[0][SPLIT] !== undefined) { - const n = data.length; - for (let i = 0; i < n; i++) { - this.maxNumberOfLeaves((data[i][SPLIT] as Dataset).data, maxima, level + 1); - } - } - } - - getPrimaryXScale(): d3.ScaleBand { - const { data } = this.props; - const { maxNumberOfLeaves } = this.state; - const dataset = (data.data[0][SPLIT] as Dataset).data; - - const { essence } = this.props; - const { splits, dataCube } = essence; - const firstSplit = splits.splits.first(); - const dimension = findDimensionByName(dataCube.dimensions, firstSplit.reference); - - const getX = (d: Datum) => d[dimension.name] as string; - - const { usedWidth, padLeft } = this.getXValues(maxNumberOfLeaves); - - return d3.scaleBand() - .domain(dataset.map(getX)) - .range([padLeft, padLeft + usedWidth]); - } - - getBarDimensions(xRangeBand: number): { stepWidth: number, barWidth: number, barOffset: number } { - if (isNaN(xRangeBand)) xRangeBand = 0; - const stepWidth = xRangeBand; - const barWidth = Math.max(stepWidth * BAR_PROPORTION, 0); - const barOffset = (stepWidth - barWidth) / 2; - - return { stepWidth, barWidth, barOffset }; - } - - getXValues(maxNumberOfLeaves: number[]): { padLeft: number, usedWidth: number } { - const { essence, stage } = this.props; - const overallWidth = stage.width - VIS_H_PADDING * 2 - Y_AXIS_WIDTH; - - const numPrimarySteps = maxNumberOfLeaves[0]; - const minStepWidth = MIN_STEP_WIDTH * maxNumberOfLeaves.slice(1).reduce(((a, b) => a * b), 1); - - const maxAvailableWidth = overallWidth - BARS_MIN_PAD_LEFT - BARS_MIN_PAD_RIGHT; - - let stepWidth: number; - if (minStepWidth * numPrimarySteps < maxAvailableWidth) { - stepWidth = Math.max(Math.min(maxAvailableWidth / numPrimarySteps, MAX_STEP_WIDTH * essence.splits.length()), MIN_STEP_WIDTH); - } else { - stepWidth = minStepWidth; - } - - const usedWidth = stepWidth * maxNumberOfLeaves[0]; - const padLeft = Math.max(BARS_MIN_PAD_LEFT, (overallWidth - usedWidth) / 2); - - return { padLeft, usedWidth }; - } - - getBarsCoordinates(chartIndex: number, xScale: d3.ScaleBand): BarCoordinates[] { - if (!!this.coordinatesCache[chartIndex]) { - return this.coordinatesCache[chartIndex]; - } - - const { data } = this.props; - const dataset = data.data[0][SPLIT] as Dataset; - - const { essence } = this.props; - const { splits, dataCube } = essence; - - const series = essence.getConcreteSeries().get(chartIndex); - const firstSplit = splits.splits.first(); - const dimension = findDimensionByName(dataCube.dimensions, firstSplit.reference); - - const chartStage = this.getSingleChartStage(); - const yScale = this.getYScale(series, this.getAxisStages(chartStage).yAxisStage); - - this.coordinatesCache[chartIndex] = this.getSubCoordinates( - dataset.data, - series, - chartStage, - (d: Datum) => d[dimension.name] as string, - xScale, - yScale - ); - - return this.coordinatesCache[chartIndex]; - } - - getSubCoordinates( - data: Datum[], - series: ConcreteSeries, - chartStage: Stage, - getX: (d: Datum, i: number) => string, - xScale: d3.ScaleBand, - scaleY: d3.ScaleLinear, - splitIndex = 1 - ): BarCoordinates[] { - const { essence } = this.props; - const { maxNumberOfLeaves } = this.state; - - const { stepWidth, barWidth, barOffset } = this.getBarDimensions(xScale.bandwidth()); - - const coordinates: BarCoordinates[] = data.map((d, i) => { - const x = xScale(getX(d, i)); - const y = scaleY(series.selectValue(d)); - const h = scaleY(0) - y; - const children: BarCoordinates[] = []; - const coordinate = new BarCoordinates({ - x, - y: h >= 0 ? y : scaleY(0), - width: roundToPx(barWidth), - height: roundToPx(Math.abs(h)), - stepWidth, - barWidth, - barOffset, - children - }); - - if (splitIndex < essence.splits.length()) { - const subStage: Stage = new Stage({ x, y: chartStage.y, width: barWidth, height: chartStage.height }); - const subGetX: any = (d: Datum, i: number) => String(i); - const subData: Datum[] = (d[SPLIT] as Dataset).data; - const subxScale = d3.scaleBand() - .domain(d3.range(0, maxNumberOfLeaves[splitIndex]).map(String)) - .range([x + barOffset, x + subStage.width]); - - coordinate.children = this.getSubCoordinates(subData, series, subStage, subGetX, subxScale, scaleY, splitIndex + 1); - } - - return coordinate; - }); - - return coordinates; - } - - renderRightGutter(seriesCount: number, yAxisStage: Stage, yAxes: JSX.Element[]): JSX.Element { - const yAxesStage = yAxisStage.changeHeight((yAxisStage.height + CHART_TOP_PADDING + CHART_BOTTOM_PADDING) * seriesCount); - - return - {yAxes} - ; - } - - renderSelectionContainer(selectionHighlight: JSX.Element, chartIndex: number, chartStage: Stage): JSX.Element { - return
- {selectionHighlight} -
; - } - - render() { - const { data, essence, stage, highlight, saveHighlight, acceptHighlight, dropHighlight } = this.props; - const { splits } = essence; - const newVersionSupports = or(Predicates.areExactSplitKinds("time"), Predicates.areExactSplitKinds("*", "time")); - if (newVersionSupports(essence)) { - return ; - } - - let scrollerLayout: ScrollerLayout; - const measureCharts: JSX.Element[] = []; - let xAxis: JSX.Element; - let rightGutter: JSX.Element; - let overlay: JSX.Element; - - if (splits.length()) { - const xScale = this.getPrimaryXScale(); - const yAxes: JSX.Element[] = []; - const series = essence.getConcreteSeries(); - - const chartStage = this.getSingleChartStage(); - const { xAxisStage, yAxisStage } = this.getAxisStages(chartStage); - xAxis = this.renderXAxis((data.data[0][SPLIT] as Dataset).data, this.getBarsCoordinates(0, xScale), xAxisStage); - - series.forEach((series, chartIndex) => { - const coordinates = this.getBarsCoordinates(chartIndex, xScale); - const { yAxis, chart, highlight } = this.renderChart(data, coordinates, series, chartIndex, chartStage); - - measureCharts.push(chart); - yAxes.push(yAxis); - if (highlight) { - overlay = this.renderSelectionContainer(highlight, chartIndex, chartStage); - } - }); - - scrollerLayout = this.getScrollerLayout(chartStage, xAxisStage, yAxisStage); - rightGutter = this.renderRightGutter(series.count(), chartStage, yAxes); - } - - return
- -
; - } -} diff --git a/src/client/visualizations/bar-chart/improved-bar-chart/bar-chart.tsx b/src/client/visualizations/bar-chart/improved-bar-chart/bar-chart.tsx index 4255437ee..697dce6d9 100644 --- a/src/client/visualizations/bar-chart/improved-bar-chart/bar-chart.tsx +++ b/src/client/visualizations/bar-chart/improved-bar-chart/bar-chart.tsx @@ -14,19 +14,14 @@ * limitations under the License. */ -import { List } from "immutable"; -import { Dataset } from "plywood"; import React from "react"; -import { Essence } from "../../../../common/models/essence/essence"; -import { FilterClause } from "../../../../common/models/filter-clause/filter-clause"; +import { ChartProps } from "../../../../common/models/chart-props/chart-props"; import { Stage } from "../../../../common/models/stage/stage"; -import { Binary, Nullary } from "../../../../common/utils/functional/functional"; import { MessageCard } from "../../../components/message-card/message-card"; import { Scroller } from "../../../components/scroller/scroller"; import { SPLIT } from "../../../config/constants"; import { selectMainDatum } from "../../../utils/dataset/selectors/selectors"; import { useSettingsContext } from "../../../views/cube-view/settings-context"; -import { Highlight } from "../../highlight-controller/highlight"; import { BarCharts } from "./bar-charts/bar-charts"; import { InteractionController } from "./interactions/interaction-controller"; import { Spacer } from "./spacer/spacer"; @@ -39,19 +34,9 @@ import { createXScale } from "./utils/x-scale"; import { XAxis } from "./x-axis/x-axis"; import { YAxis } from "./y-axis/y-axis"; -interface BarChartProps { - essence: Essence; - stage: Stage; - dataset: Dataset; - highlight?: Highlight; - saveHighlight: Binary, string, void>; - dropHighlight: Nullary; - acceptHighlight: Nullary; -} - -export const BarChart: React.FunctionComponent = props => { +export const BarChart: React.FunctionComponent = props => { const { customization } = useSettingsContext(); - const { dataset, essence, stage, highlight, acceptHighlight, dropHighlight, saveHighlight } = props; + const { data: dataset, essence, stage, highlight, acceptHighlight, dropHighlight, saveHighlight } = props; const model = create(essence, dataset, customization); const transposedDataset = transposeDataset(dataset, model); diff --git a/src/client/visualizations/bar-chart/improved-bar-chart/interactions/interaction-controller.tsx b/src/client/visualizations/bar-chart/improved-bar-chart/interactions/interaction-controller.tsx index abae175c6..2c86ea085 100644 --- a/src/client/visualizations/bar-chart/improved-bar-chart/interactions/interaction-controller.tsx +++ b/src/client/visualizations/bar-chart/improved-bar-chart/interactions/interaction-controller.tsx @@ -15,7 +15,7 @@ */ import { List } from "immutable"; -import { Datum, TimeRange } from "plywood"; +import { Datum, Range, TimeRange } from "plywood"; import React from "react"; import { DateRange } from "../../../../../common/models/date-range/date-range"; import { FilterClause, FixedTimeFilterClause } from "../../../../../common/models/filter-clause/filter-clause"; @@ -73,7 +73,7 @@ export class InteractionController extends React.Component { return (a: Datum, b: Datum) => { - const aRange = continuousSplit.selectValue(a); - const bRange = continuousSplit.selectValue(b); - return aRange.compare(bRange); + const aRange = continuousSplit.selectValue(a); + const bRange = continuousSplit.selectValue(b); + /* + NOTE: Conflict of variance: + `aRange` has type `TimeRange | NumberRange` and as a result + argument of `aRange.compare` has type `TimeRange & NumberRange`. + Such type is impossible, thus error + */ + return aRange.compare(bRange as TimeRange & NumberRange); }; } @@ -33,7 +39,13 @@ function equalBy(split: Split, datum: Datum): Unary { const value = split.selectValue(datum); return (d: Datum) => { const range = split.selectValue(d); - return TimeRange.isTimeRange(value) && TimeRange.isTimeRange(range) && value.equals(range); + if (TimeRange.isTimeRange(value) && TimeRange.isTimeRange(range)) { + return value.equals(range); + } + if (NumberRange.isNumberRange(value) && NumberRange.isNumberRange(range)) { + return value.equals(range); + } + return false; }; } diff --git a/src/client/visualizations/bar-chart/improved-bar-chart/x-axis/x-axis.tsx b/src/client/visualizations/bar-chart/improved-bar-chart/x-axis/x-axis.tsx index 4f93b8e73..25cc2ecad 100644 --- a/src/client/visualizations/bar-chart/improved-bar-chart/x-axis/x-axis.tsx +++ b/src/client/visualizations/bar-chart/improved-bar-chart/x-axis/x-axis.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import { TimeRange } from "plywood"; import React from "react"; +import { isContinuousSplit } from "../../../../../common/models/split/split"; import { Stage } from "../../../../../common/models/stage/stage"; -import { formatStartOfTimeRange } from "../../../../../common/utils/time/time"; +import { formatShortSegment } from "../../../../../common/utils/formatter/formatter"; import { roundToHalfPx } from "../../../../utils/dom/dom"; import { BarChartModel } from "../utils/bar-chart-model"; import { DomainValue, XDomain } from "../utils/x-domain"; @@ -34,7 +34,7 @@ const TICK_HEIGHT = 10; const TICK_TEXT_OFFSET = 12; function calculateTicks(domain: XDomain, { continuousSplit }: BarChartModel): DomainValue[] { - if (continuousSplit.type === "time") { + if (isContinuousSplit(continuousSplit)) { return domain.filter((_, idx) => idx % 8 === 0); } return domain; @@ -51,7 +51,7 @@ export const XAxis: React.FunctionComponent = props => { return - {formatStartOfTimeRange(value as TimeRange, model.timezone)} + {formatShortSegment(value, model.timezone)} ; })} diff --git a/src/client/visualizations/bar-chart/old-bar-chart/old-bar-chart.tsx b/src/client/visualizations/bar-chart/old-bar-chart/old-bar-chart.tsx new file mode 100644 index 000000000..37fb25c9f --- /dev/null +++ b/src/client/visualizations/bar-chart/old-bar-chart/old-bar-chart.tsx @@ -0,0 +1,961 @@ +/* + * Copyright 2017-2022 Allegro.pl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as d3 from "d3"; +import { List, Set } from "immutable"; +import { Dataset, Datum, NumberRange, PlywoodRange, PseudoDatum, Range } from "plywood"; +import React from "react"; +import { ChartProps } from "../../../../common/models/chart-props/chart-props"; +import { DateRange } from "../../../../common/models/date-range/date-range"; +import { canBucketByDefault, Dimension } from "../../../../common/models/dimension/dimension"; +import { findDimensionByName } from "../../../../common/models/dimension/dimensions"; +import { + BooleanFilterClause, + FilterClause, + FixedTimeFilterClause, + NumberFilterClause, + StringFilterAction, + StringFilterClause +} from "../../../../common/models/filter-clause/filter-clause"; +import { Measure } from "../../../../common/models/measure/measure"; +import { ConcreteSeries, SeriesDerivation } from "../../../../common/models/series/concrete-series"; +import { Series } from "../../../../common/models/series/series"; +import { SortDirection } from "../../../../common/models/sort/sort"; +import { SplitType } from "../../../../common/models/split/split"; +import { Splits } from "../../../../common/models/splits/splits"; +import { Stage } from "../../../../common/models/stage/stage"; +import { formatValue } from "../../../../common/utils/formatter/formatter"; +import { BAR_CHART_MANIFEST } from "../../../../common/visualization-manifests/bar-chart/bar-chart"; +import { BucketMarks } from "../../../components/bucket-marks/bucket-marks"; +import { GridLines } from "../../../components/grid-lines/grid-lines"; +import { HighlightModal } from "../../../components/highlight-modal/highlight-modal"; +import { MeasureBubbleContent } from "../../../components/measure-bubble-content/measure-bubble-content"; +import { Scroller, ScrollerLayout } from "../../../components/scroller/scroller"; +import { SegmentBubble } from "../../../components/segment-bubble/segment-bubble"; +import { VerticalAxis } from "../../../components/vertical-axis/vertical-axis"; +import { VisMeasureLabel } from "../../../components/vis-measure-label/vis-measure-label"; +import { SPLIT, VIS_H_PADDING } from "../../../config/constants"; +import { classNames, roundToPx } from "../../../utils/dom/dom"; +import { SettingsContext, SettingsContextValue } from "../../../views/cube-view/settings-context"; +import { hasHighlightOn } from "../../highlight-controller/highlight-controller"; +import { BarCoordinates } from "../bar-coordinates"; + +const X_AXIS_HEIGHT = 84; +const Y_AXIS_WIDTH = 60; +const CHART_TOP_PADDING = 10; +const CHART_BOTTOM_PADDING = 0; +const MIN_CHART_HEIGHT = 200; +const MAX_STEP_WIDTH = 140; // Note that the step is bar + empty space around it. The width of the rectangle is step * BAR_PROPORTION +const MIN_STEP_WIDTH = 20; +const BAR_PROPORTION = 0.8; +const BARS_MIN_PAD_LEFT = 30; +const BARS_MIN_PAD_RIGHT = 6; +const HOVER_BUBBLE_V_OFFSET = 8; +const SELECTION_PAD = 4; + +function getFilterFromDatum(splits: Splits, dataPath: Datum[]): List { + return List(dataPath.map((datum, i) => { + const { type, reference } = splits.getSplit(i); + const segment: any = datum[reference]; + + switch (type) { + case SplitType.boolean: + return new BooleanFilterClause({ reference, values: Set.of(segment) }); + case SplitType.number: + return new NumberFilterClause({ reference, values: List.of(segment) }); + case SplitType.time: + return new FixedTimeFilterClause({ reference, values: List.of(new DateRange(segment)) }); + case SplitType.string: + return new StringFilterClause({ reference, action: StringFilterAction.IN, values: Set.of(segment) }); + } + })); +} + +function padDataset(originalDataset: Dataset, dimension: Dimension, measures: Measure[]): Dataset { + const data = (originalDataset.data[0][SPLIT] as Dataset).data; + const dimensionName = dimension.name; + + const firstBucket: PlywoodRange = data[0][dimensionName] as PlywoodRange; + if (!firstBucket) return originalDataset; + const start = Number(firstBucket.start); + const end = Number(firstBucket.end); + + const size = end - start; + + let i = start; + let j = 0; + + const filledData: Datum[] = []; + data.forEach(d => { + const segmentValue = d[dimensionName]; + const segmentStart = (segmentValue as PlywoodRange).start; + while (i < segmentStart) { + filledData[j] = {}; + filledData[j][dimensionName] = NumberRange.fromJS({ + start: i, + end: i + size + }); + measures.forEach(m => { + filledData[j][m.name] = 0; // todo: what if effective zero is not 0? + }); + + if (d[SPLIT]) { + filledData[j][SPLIT] = new Dataset({ + data: [], + attributes: [] + }); + } + + j++; + i += size; + } + filledData[j] = d; + i += size; + j++; + }); + + const value = originalDataset.valueOf(); + (value.data[0][SPLIT] as Dataset).data = filledData; + return new Dataset(value); +} + +export interface BubbleInfo { + series: ConcreteSeries; + chartIndex: number; + path: Datum[]; + coordinates: BarCoordinates; + splitIndex?: number; + segmentLabel?: string; +} + +export interface BarChartState { + hoverInfo?: BubbleInfo; + selectionInfo?: BubbleInfo; + scrollerYPosition?: number; + scrollerXPosition?: number; + scrollTop: number; + scrollLeft: number; + + // Precalculated stuff + flatData?: PseudoDatum[]; + maxNumberOfLeaves?: number[]; +} + +export class BarChart extends React.Component { + static contextType = SettingsContext; + protected className = BAR_CHART_MANIFEST.name; + + private coordinatesCache: BarCoordinates[][] = []; + private scroller = React.createRef(); + + state: BarChartState = this.initState(); + + context: SettingsContextValue; + + componentDidUpdate() { + const { scrollerYPosition, scrollerXPosition } = this.state; + + const scrollerComponent = this.scroller.current; + if (!scrollerComponent) return; + + const rect = scrollerComponent.scroller.current.getBoundingClientRect(); + + if (scrollerYPosition !== rect.top || scrollerXPosition !== rect.left) { + this.setState({ scrollerYPosition: rect.top, scrollerXPosition: rect.left }); + } + } + + calculateMousePosition(x: number, y: number): BubbleInfo { + const { essence } = this.props; + + const series = essence.getConcreteSeries(); + const chartStage = this.getSingleChartStage(); + const chartHeight = this.getOuterChartHeight(chartStage); + + if (y >= chartHeight * series.size) return null; // on x axis + if (x >= chartStage.width) return null; // on y axis + + const xScale = this.getPrimaryXScale(); + const chartIndex = Math.floor(y / chartHeight); + + const chartCoordinates = this.getBarsCoordinates(chartIndex, xScale); + + const { path, coordinates } = this.findBarCoordinatesForX(x, chartCoordinates, []); + + return { + path: this.findPathForIndices(path), + series: series.get(chartIndex), + chartIndex, + coordinates + }; + } + + findPathForIndices(indices: number[]): Datum[] { + const { data } = this.props; + const mySplitDataset = data.data[0][SPLIT] as Dataset; + + const path: Datum[] = []; + let currentData: Dataset = mySplitDataset; + indices.forEach(i => { + const datum = currentData.data[i]; + path.push(datum); + currentData = (datum[SPLIT] as Dataset); + }); + + return path; + } + + findBarCoordinatesForX(x: number, coordinates: BarCoordinates[], currentPath: number[]): { path: number[], coordinates: BarCoordinates } { + for (let i = 0; i < coordinates.length; i++) { + if (coordinates[i].isXWithin(x)) { + currentPath.push(i); + if (coordinates[i].hasChildren()) { + return this.findBarCoordinatesForX(x, coordinates[i].children, currentPath); + } else { + return { path: currentPath, coordinates: coordinates[i] }; + } + } + } + + return { path: [], coordinates: null }; + } + + onScrollerScroll = (scrollTop: number, scrollLeft: number) => { + this.setState({ + hoverInfo: null, + scrollLeft, + scrollTop + }); + }; + + onMouseMove = (x: number, y: number) => { + this.setState({ hoverInfo: this.calculateMousePosition(x, y) }); + }; + + onMouseLeave = () => { + this.setState({ hoverInfo: null }); + }; + + onClick = (x: number, y: number) => { + const { essence, highlight, dropHighlight, saveHighlight } = this.props; + + const selectionInfo = this.calculateMousePosition(x, y); + + if (!selectionInfo) return; + + if (!selectionInfo.coordinates) { + dropHighlight(); + this.setState({ selectionInfo: null }); + return; + } + + const { path, chartIndex } = selectionInfo; + + const { splits } = essence; + const series = essence.getConcreteSeries(); + + const rowHighlight = getFilterFromDatum(splits, path); + + const currentSeries = series.get(chartIndex).definition; + if (hasHighlightOn(highlight, currentSeries.key())) { + if (rowHighlight.equals(highlight.clauses)) { + dropHighlight(); + this.setState({ selectionInfo: null }); + return; + } + } + + this.setState({ selectionInfo }); + saveHighlight(rowHighlight, series.get(chartIndex).definition.key()); + }; + + getYExtent(data: Datum[], series: ConcreteSeries): number[] { + const getY = (d: Datum) => series.selectValue(d); + return d3.extent(data, getY); + } + + getYScale(series: ConcreteSeries, yAxisStage: Stage): d3.ScaleLinear { + const { essence } = this.props; + const { flatData } = this.state; + + const splitLength = essence.splits.length(); + const leafData = flatData.filter((d: Datum) => d["__nest"] === splitLength - 1); + + const extentY = this.getYExtent(leafData, series); + + return d3.scaleLinear() + .domain([Math.min(extentY[0] * 1.1, 0), Math.max(extentY[1] * 1.1, 0)]) + .range([yAxisStage.height, yAxisStage.y]); + } + + hasValidYExtent(series: ConcreteSeries, data: Datum[]): boolean { + const [yMin, yMax] = this.getYExtent(data, series); + return !isNaN(yMin) && !isNaN(yMax); + } + + getSingleChartStage(): Stage { + const xScale = this.getPrimaryXScale(); + const { essence, stage } = this.props; + + const { stepWidth } = this.getBarDimensions(xScale.bandwidth()); + const xTicks = xScale.domain(); + const width = xTicks.length > 0 ? roundToPx(xScale(xTicks[xTicks.length - 1])) + stepWidth : 0; + + const measures = essence.getConcreteSeries(); + const availableHeight = stage.height - X_AXIS_HEIGHT; + const height = Math.max(MIN_CHART_HEIGHT, Math.floor(availableHeight / measures.size)); + + return new Stage({ + x: 0, + y: CHART_TOP_PADDING, + width: Math.max(width, stage.width - Y_AXIS_WIDTH - VIS_H_PADDING * 2), + height: height - CHART_TOP_PADDING - CHART_BOTTOM_PADDING + }); + } + + getOuterChartHeight(chartStage: Stage): number { + return chartStage.height + CHART_TOP_PADDING + CHART_BOTTOM_PADDING; + } + + getAxisStages(chartStage: Stage): { xAxisStage: Stage, yAxisStage: Stage } { + const { essence, stage } = this.props; + + const xHeight = Math.max( + stage.height - (CHART_TOP_PADDING + CHART_BOTTOM_PADDING + chartStage.height) * essence.getConcreteSeries().size, + X_AXIS_HEIGHT + ); + + return { + xAxisStage: new Stage({ x: chartStage.x, y: 0, height: xHeight, width: chartStage.width }), + yAxisStage: new Stage({ x: 0, y: chartStage.y, height: chartStage.height, width: Y_AXIS_WIDTH + VIS_H_PADDING }) + }; + } + + getScrollerLayout(chartStage: Stage, xAxisStage: Stage, yAxisStage: Stage): ScrollerLayout { + const { essence } = this.props; + const measures = essence.getConcreteSeries().toArray(); + + const oneChartHeight = this.getOuterChartHeight(chartStage); + + return { + // Inner dimensions + bodyWidth: chartStage.width, + bodyHeight: oneChartHeight * measures.length - CHART_BOTTOM_PADDING, + + // Gutters + top: 0, + right: yAxisStage.width, + bottom: xAxisStage.height, + left: 0 + }; + } + + getBubbleTopOffset(y: number, chartIndex: number, chartStage: Stage): number { + const { scrollTop, scrollerYPosition } = this.state; + const oneChartHeight = this.getOuterChartHeight(chartStage); + const chartsAboveMe = oneChartHeight * chartIndex; + + return chartsAboveMe - scrollTop + scrollerYPosition + y - HOVER_BUBBLE_V_OFFSET + CHART_TOP_PADDING; + } + + getBubbleLeftOffset(x: number): number { + const { scrollLeft, scrollerXPosition } = this.state; + + return scrollerXPosition + x - scrollLeft; + } + + canShowBubble(leftOffset: number, topOffset: number): boolean { + const { stage } = this.props; + const { scrollerYPosition, scrollerXPosition } = this.state; + + if (topOffset <= 0) return false; + if (topOffset > scrollerYPosition + stage.height - X_AXIS_HEIGHT) return false; + if (leftOffset <= 0) return false; + if (leftOffset > scrollerXPosition + stage.width - Y_AXIS_WIDTH) return false; + + return true; + } + + renderSelectionBubble(hoverInfo: BubbleInfo): JSX.Element { + const { dropHighlight, acceptHighlight } = this.props; + const { series, path, chartIndex, segmentLabel, coordinates } = hoverInfo; + const chartStage = this.getSingleChartStage(); + const leftOffset = this.getBubbleLeftOffset(coordinates.middleX); + const topOffset = this.getBubbleTopOffset(coordinates.y, chartIndex, chartStage); + if (!this.canShowBubble(leftOffset, topOffset)) return null; + + const segmentValue = series.formatValue(path[path.length - 1]); + return + {segmentValue} + ; + } + + renderHoverBubble(hoverInfo: BubbleInfo): JSX.Element { + const chartStage = this.getSingleChartStage(); + const { series, path, chartIndex, segmentLabel, coordinates } = hoverInfo; + + const leftOffset = this.getBubbleLeftOffset(coordinates.middleX); + const topOffset = this.getBubbleTopOffset(coordinates.y, chartIndex, chartStage); + + if (!this.canShowBubble(leftOffset, topOffset)) return null; + + const measureContent = this.renderMeasureLabel(path[path.length - 1], series); + return ; + } + + private renderMeasureLabel(datum: Datum, series: ConcreteSeries): JSX.Element | string { + if (!this.props.essence.hasComparison()) { + return series.formatValue(datum); + } + const currentValue = series.selectValue(datum); + const previousValue = series.selectValue(datum, SeriesDerivation.PREVIOUS); + const formatter = series.formatter(); + return ; + } + + isSelected(path: Datum[], series: Series): boolean { + const { essence, highlight } = this.props; + const { splits } = essence; + return hasHighlightOn(highlight, series.key()) && highlight.clauses.equals(getFilterFromDatum(splits, path)); + } + + isFaded(): boolean { + return this.props.highlight !== null; + } + + hasAnySelectionGoingOn(): boolean { + return this.props.highlight !== null; + } + + isHovered(path: Datum[], series: ConcreteSeries): boolean { + const { essence } = this.props; + const { hoverInfo } = this.state; + const { splits } = essence; + + if (this.hasAnySelectionGoingOn()) return false; + if (!hoverInfo) return false; + if (!hoverInfo.series.equals(series)) return false; + + const filter = (path: Datum[]) => getFilterFromDatum(splits, path); + + return filter(hoverInfo.path).equals(filter(path)); + } + + renderBars( + data: Datum[], + series: ConcreteSeries, + chartIndex: number, + chartStage: Stage, + xAxisStage: Stage, + coordinates: BarCoordinates[], + splitIndex = 0, + path: Datum[] = [] + ): { bars: JSX.Element[], highlight: JSX.Element } { + const { essence } = this.props; + const { timezone } = essence; + const { customization: { visualizationColors } } = this.context; + + const bars: JSX.Element[] = []; + let highlight: JSX.Element; + + const dimension = findDimensionByName(essence.dataCube.dimensions, essence.splits.splits.get(splitIndex).reference); + const splitLength = essence.splits.length(); + + data.forEach((d, i) => { + const segmentValue = d[dimension.name]; + const segmentValueStr = formatValue(segmentValue, timezone); + const subPath = path.concat(d); + + let bar: any; + let bubble: JSX.Element = null; + const subCoordinates = coordinates[i]; + const { x, y, height, barWidth, barOffset } = coordinates[i]; + + if (splitIndex < splitLength - 1) { + const subData: Datum[] = (d[SPLIT] as Dataset).data; + const subRender = this.renderBars(subData, series, chartIndex, chartStage, xAxisStage, subCoordinates.children, splitIndex + 1, subPath); + + bar = subRender.bars; + if (!highlight && subRender.highlight) highlight = subRender.highlight; + + } else { + + const bubbleInfo: BubbleInfo = { + series, + chartIndex, + path: subPath, + coordinates: subCoordinates, + segmentLabel: segmentValueStr, + splitIndex + }; + + const isHovered = this.isHovered(subPath, series); + if (isHovered) { + bubble = this.renderHoverBubble(bubbleInfo); + } + + const selected = this.isSelected(subPath, series.definition); + const faded = this.isFaded(); + if (selected) { + bubble = this.renderSelectionBubble(bubbleInfo); + if (bubble) highlight = this.renderSelectionHighlight(chartStage, subCoordinates, chartIndex); + } + + bar = + + {bubble} + ; + + } + + bars.push(bar); + }); + + return { bars, highlight }; + } + + renderSelectionHighlight(chartStage: Stage, coordinates: BarCoordinates, chartIndex: number): JSX.Element { + const { scrollLeft, scrollTop } = this.state; + const chartHeight = this.getOuterChartHeight(chartStage); + const { barWidth, height, barOffset, y, x } = coordinates; + + const leftOffset = roundToPx(x) + barOffset - SELECTION_PAD + chartStage.x - scrollLeft; + const topOffset = roundToPx(y) - SELECTION_PAD + chartStage.y - scrollTop + chartHeight * chartIndex; + + const style: React.CSSProperties = { + left: leftOffset, + top: topOffset, + width: roundToPx(barWidth + SELECTION_PAD * 2), + height: roundToPx(Math.abs(height) + SELECTION_PAD * 2) + }; + + return
; + } + + renderXAxis(data: Datum[], coordinates: BarCoordinates[], xAxisStage: Stage): JSX.Element { + const { essence } = this.props; + const xScale = this.getPrimaryXScale(); + const xTicks = xScale.domain(); + + const split = essence.splits.splits.first(); + const dimension = findDimensionByName(essence.dataCube.dimensions, split.reference); + + const labels: JSX.Element[] = []; + if (canBucketByDefault(dimension)) { + const lastIndex = data.length - 1; + const ascending = split.sort.direction === SortDirection.ascending; + const leftThing = ascending ? "start" : "end"; + const rightThing = ascending ? "end" : "start"; + data.forEach((d, i) => { + const segmentValue = d[dimension.name]; + let segmentValueStr = String(Range.isRange(segmentValue) ? (segmentValue as any)[leftThing] : ""); + const coordinate = coordinates[i]; + + labels.push(
{segmentValueStr}
); + + if (i === lastIndex) { + segmentValueStr = String(Range.isRange(segmentValue) ? (segmentValue as any)[rightThing] : ""); + labels.push(
{segmentValueStr}
); + } + }); + } else { + data.forEach((d, i) => { + const segmentValueStr = String(d[dimension.name]); + const coordinate = coordinates[i]; + + labels.push(
{segmentValueStr}
); + }); + } + + return
+ + + + {labels} +
; + } + + getYAxisStuff(dataset: Dataset, series: ConcreteSeries, chartStage: Stage, chartIndex: number): { + yGridLines: JSX.Element, yAxis: JSX.Element, yScale: d3.ScaleLinear + } { + const { yAxisStage } = this.getAxisStages(chartStage); + + const yScale = this.getYScale(series, yAxisStage); + const yTicks = yScale.ticks(5); + + const yGridLines: JSX.Element = ; + + const axisStage = yAxisStage.changeY(yAxisStage.y + (chartStage.height + CHART_TOP_PADDING + CHART_BOTTOM_PADDING) * chartIndex); + + const yAxis: JSX.Element = ; + + return { yGridLines, yAxis, yScale }; + } + + isChartVisible(chartIndex: number, xAxisStage: Stage): boolean { + const { stage } = this.props; + const { scrollTop } = this.state; + + const chartStage = this.getSingleChartStage(); + const chartHeight = this.getOuterChartHeight(chartStage); + + const topY = chartIndex * chartHeight; + const viewPortHeight = stage.height - xAxisStage.height; + const hiddenAtBottom = topY - scrollTop >= viewPortHeight; + + const bottomY = topY + chartHeight; + const hiddenAtTop = bottomY < scrollTop; + + return !hiddenAtTop && !hiddenAtBottom; + } + + renderChart( + dataset: Dataset, + coordinates: BarCoordinates[], + series: ConcreteSeries, + chartIndex: number, + chartStage: Stage + ): { yAxis: JSX.Element, chart: JSX.Element, highlight: JSX.Element } { + const { essence } = this.props; + const mySplitDataset = dataset.data[0][SPLIT] as Dataset; + + const measureLabel = ; + + // Invalid data, early return + if (!this.hasValidYExtent(series, mySplitDataset.data)) { + return { + chart:
+ + {measureLabel} +
, + yAxis: null, + highlight: null + }; + } + + const { xAxisStage } = this.getAxisStages(chartStage); + + const { yAxis, yGridLines } = this.getYAxisStuff(mySplitDataset, series, chartStage, chartIndex); + + let bars: JSX.Element[]; + let highlight: JSX.Element; + if (this.isChartVisible(chartIndex, xAxisStage)) { + const renderedChart = this.renderBars(mySplitDataset.data, series, chartIndex, chartStage, xAxisStage, coordinates); + bars = renderedChart.bars; + highlight = renderedChart.highlight; + } + + const chart =
+ + {yGridLines} + {bars} + + {measureLabel} +
; + + return { chart, yAxis, highlight }; + } + + private initState(): BarChartState { + const { essence, data } = this.props; + const { splits } = essence; + this.coordinatesCache = []; + if (!splits.length()) return {} as BarChartState; + const split = splits.splits.first(); + const dimension = findDimensionByName(essence.dataCube.dimensions, split.reference); + const dimensionKind = dimension.kind; + const series = essence.getConcreteSeries().toArray(); + // TODO: very suspicious + const paddedDataset = dimensionKind === "number" ? padDataset(data, dimension, series.map(s => s.measure)) : data; + const firstSplitDataSet = paddedDataset.data[0][SPLIT] as Dataset; + const flattened = firstSplitDataSet.flatten({ + order: "preorder", + nestingName: "__nest" + }); + + const maxNumberOfLeaves = splits.splits.map(() => 0).toArray(); // initializing maxima to 0 + this.maxNumberOfLeaves(firstSplitDataSet.data, maxNumberOfLeaves, 0); + const flatData = flattened.data; + return { + hoverInfo: null, + maxNumberOfLeaves, + flatData, + scrollTop: 0, + scrollLeft: 0 + }; + } + + maxNumberOfLeaves(data: Datum[], maxima: number[], level: number) { + maxima[level] = Math.max(maxima[level], data.length); + + if (data[0] && data[0][SPLIT] !== undefined) { + const n = data.length; + for (let i = 0; i < n; i++) { + this.maxNumberOfLeaves((data[i][SPLIT] as Dataset).data, maxima, level + 1); + } + } + } + + getPrimaryXScale(): d3.ScaleBand { + const { data } = this.props; + const { maxNumberOfLeaves } = this.state; + const dataset = (data.data[0][SPLIT] as Dataset).data; + + const { essence } = this.props; + const { splits, dataCube } = essence; + const firstSplit = splits.splits.first(); + const dimension = findDimensionByName(dataCube.dimensions, firstSplit.reference); + + const getX = (d: Datum) => d[dimension.name] as string; + + const { usedWidth, padLeft } = this.getXValues(maxNumberOfLeaves); + + return d3.scaleBand() + .domain(dataset.map(getX)) + .range([padLeft, padLeft + usedWidth]); + } + + getBarDimensions(xRangeBand: number): { stepWidth: number, barWidth: number, barOffset: number } { + if (isNaN(xRangeBand)) xRangeBand = 0; + const stepWidth = xRangeBand; + const barWidth = Math.max(stepWidth * BAR_PROPORTION, 0); + const barOffset = (stepWidth - barWidth) / 2; + + return { stepWidth, barWidth, barOffset }; + } + + getXValues(maxNumberOfLeaves: number[]): { padLeft: number, usedWidth: number } { + const { essence, stage } = this.props; + const overallWidth = stage.width - VIS_H_PADDING * 2 - Y_AXIS_WIDTH; + + const numPrimarySteps = maxNumberOfLeaves[0]; + const minStepWidth = MIN_STEP_WIDTH * maxNumberOfLeaves.slice(1).reduce(((a, b) => a * b), 1); + + const maxAvailableWidth = overallWidth - BARS_MIN_PAD_LEFT - BARS_MIN_PAD_RIGHT; + + let stepWidth: number; + if (minStepWidth * numPrimarySteps < maxAvailableWidth) { + stepWidth = Math.max(Math.min(maxAvailableWidth / numPrimarySteps, MAX_STEP_WIDTH * essence.splits.length()), MIN_STEP_WIDTH); + } else { + stepWidth = minStepWidth; + } + + const usedWidth = stepWidth * maxNumberOfLeaves[0]; + const padLeft = Math.max(BARS_MIN_PAD_LEFT, (overallWidth - usedWidth) / 2); + + return { padLeft, usedWidth }; + } + + getBarsCoordinates(chartIndex: number, xScale: d3.ScaleBand): BarCoordinates[] { + if (!!this.coordinatesCache[chartIndex]) { + return this.coordinatesCache[chartIndex]; + } + + const { data } = this.props; + const dataset = data.data[0][SPLIT] as Dataset; + + const { essence } = this.props; + const { splits, dataCube } = essence; + + const series = essence.getConcreteSeries().get(chartIndex); + const firstSplit = splits.splits.first(); + const dimension = findDimensionByName(dataCube.dimensions, firstSplit.reference); + + const chartStage = this.getSingleChartStage(); + const yScale = this.getYScale(series, this.getAxisStages(chartStage).yAxisStage); + + this.coordinatesCache[chartIndex] = this.getSubCoordinates( + dataset.data, + series, + chartStage, + (d: Datum) => d[dimension.name] as string, + xScale, + yScale + ); + + return this.coordinatesCache[chartIndex]; + } + + getSubCoordinates( + data: Datum[], + series: ConcreteSeries, + chartStage: Stage, + getX: (d: Datum, i: number) => string, + xScale: d3.ScaleBand, + scaleY: d3.ScaleLinear, + splitIndex = 1 + ): BarCoordinates[] { + const { essence } = this.props; + const { maxNumberOfLeaves } = this.state; + + const { stepWidth, barWidth, barOffset } = this.getBarDimensions(xScale.bandwidth()); + + const coordinates: BarCoordinates[] = data.map((d, i) => { + const x = xScale(getX(d, i)); + const y = scaleY(series.selectValue(d)); + const h = scaleY(0) - y; + const children: BarCoordinates[] = []; + const coordinate = new BarCoordinates({ + x, + y: h >= 0 ? y : scaleY(0), + width: roundToPx(barWidth), + height: roundToPx(Math.abs(h)), + stepWidth, + barWidth, + barOffset, + children + }); + + if (splitIndex < essence.splits.length()) { + const subStage: Stage = new Stage({ x, y: chartStage.y, width: barWidth, height: chartStage.height }); + const subGetX: any = (d: Datum, i: number) => String(i); + const subData: Datum[] = (d[SPLIT] as Dataset).data; + const subxScale = d3.scaleBand() + .domain(d3.range(0, maxNumberOfLeaves[splitIndex]).map(String)) + .range([x + barOffset, x + subStage.width]); + + coordinate.children = this.getSubCoordinates(subData, series, subStage, subGetX, subxScale, scaleY, splitIndex + 1); + } + + return coordinate; + }); + + return coordinates; + } + + renderRightGutter(seriesCount: number, yAxisStage: Stage, yAxes: JSX.Element[]): JSX.Element { + const yAxesStage = yAxisStage.changeHeight((yAxisStage.height + CHART_TOP_PADDING + CHART_BOTTOM_PADDING) * seriesCount); + + return + {yAxes} + ; + } + + renderSelectionContainer(selectionHighlight: JSX.Element, chartIndex: number, chartStage: Stage): JSX.Element { + return
+ {selectionHighlight} +
; + } + + render() { + const { data, essence, stage } = this.props; + const { splits } = essence; + + let scrollerLayout: ScrollerLayout; + const measureCharts: JSX.Element[] = []; + let xAxis: JSX.Element; + let rightGutter: JSX.Element; + let overlay: JSX.Element; + + if (splits.length()) { + const xScale = this.getPrimaryXScale(); + const yAxes: JSX.Element[] = []; + const series = essence.getConcreteSeries(); + + const chartStage = this.getSingleChartStage(); + const { xAxisStage, yAxisStage } = this.getAxisStages(chartStage); + xAxis = this.renderXAxis((data.data[0][SPLIT] as Dataset).data, this.getBarsCoordinates(0, xScale), xAxisStage); + + series.forEach((series, chartIndex) => { + const coordinates = this.getBarsCoordinates(chartIndex, xScale); + const { yAxis, chart, highlight } = this.renderChart(data, coordinates, series, chartIndex, chartStage); + + measureCharts.push(chart); + yAxes.push(yAxis); + if (highlight) { + overlay = this.renderSelectionContainer(highlight, chartIndex, chartStage); + } + }); + + scrollerLayout = this.getScrollerLayout(chartStage, xAxisStage, yAxisStage); + rightGutter = this.renderRightGutter(series.count(), chartStage, yAxes); + } + + return
+ +
; + } +} diff --git a/src/client/visualizations/grid/grid-split-tile.tsx b/src/client/visualizations/grid/grid-split-tile.tsx index 10ad0ac3e..f165f8a1f 100644 --- a/src/client/visualizations/grid/grid-split-tile.tsx +++ b/src/client/visualizations/grid/grid-split-tile.tsx @@ -16,7 +16,7 @@ import React from "react"; import { isContinuous } from "../../../common/models/dimension/dimension"; -import { SPLIT_CLASS_NAME, SplitTileBaseProps } from "../../components/split-tile/split-tile"; +import { SplitTileBaseProps } from "../../components/split-tile/split-tile"; import { SvgIcon } from "../../components/svg-icon/svg-icon"; import { WithRef } from "../../components/with-ref/with-ref"; import { classNames } from "../../utils/dom/dom"; diff --git a/src/client/visualizations/line-chart/line-chart.tsx b/src/client/visualizations/line-chart/line-chart.tsx index 45b3fb533..85695ae4d 100644 --- a/src/client/visualizations/line-chart/line-chart.tsx +++ b/src/client/visualizations/line-chart/line-chart.tsx @@ -20,7 +20,8 @@ import { ChartProps } from "../../../common/models/chart-props/chart-props"; import makeQuery from "../../../common/utils/query/visualization-query"; import { LINE_CHART_MANIFEST } from "../../../common/visualization-manifests/line-chart/line-chart"; import { MessageCard } from "../../components/message-card/message-card"; -import { ChartPanel, DefaultVisualizationControls, VisualizationProps } from "../../views/cube-view/center-panel/center-panel"; +import { TimeSeriesVisualizationControls } from "../../components/timeseries-visualization-controls/visualization-controls"; +import { ChartPanel, VisualizationProps } from "../../views/cube-view/center-panel/center-panel"; import { Charts } from "./charts/charts"; import { InteractionController } from "./interactions/interaction-controller"; import "./line-chart.scss"; @@ -33,7 +34,7 @@ const X_AXIS_HEIGHT = 30; export default function LineChartVisualization(props: VisualizationProps) { return - + ; } @@ -73,13 +74,13 @@ class LineChart extends React.Component { essence={essence} xScale={scale} xTicks={ticks} - dataset={data} /> + dataset={data}/>
+ timezone={essence.timezone}/>
; }} ; diff --git a/src/common/models/colors/colors.ts b/src/common/models/colors/colors.ts index cbb747c88..58ce70b2b 100644 --- a/src/common/models/colors/colors.ts +++ b/src/common/models/colors/colors.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { hsl, rgb } from "d3"; +import { hsl, range, rgb } from "d3"; export const DEFAULT_SERIES_COLORS = [ "#2D95CA", @@ -50,3 +50,11 @@ export function alphaMain(colors: VisualizationColors): string { const { r, g, b } = rgb(colors.main); return `rgba(${r}, ${g}, ${b}, ${0.14})`; } + +export function colorSplitLimits(max: number): number[] { + const limits = range(5, max, 5); + if (limits[limits.length - 1] < max) { + return [...limits, max]; + } + return limits; +} diff --git a/src/common/models/split/split.ts b/src/common/models/split/split.ts index 464f1747a..d69f52b4f 100644 --- a/src/common/models/split/split.ts +++ b/src/common/models/split/split.ts @@ -32,6 +32,10 @@ export enum SplitType { boolean = "boolean" } +export function isContinuousSplit({ type }: Split): boolean { + return type === SplitType.time || type === SplitType.number; +} + export type Bucket = number | Duration; export interface SplitValue { diff --git a/src/common/utils/formatter/formatter.mocha.ts b/src/common/utils/formatter/formatter.mocha.ts index 6bb0dd213..28069913f 100644 --- a/src/common/utils/formatter/formatter.mocha.ts +++ b/src/common/utils/formatter/formatter.mocha.ts @@ -17,10 +17,11 @@ import { expect } from "chai"; import { Timezone } from "chronoshift"; +import { NumberRange, TimeRange } from "plywood"; import * as sinon from "sinon"; import { DimensionFixtures } from "../../models/dimension/dimension.fixtures"; import * as TimeModule from "../time/time"; -import { formatFilterClause } from "./formatter"; +import { formatFilterClause, formatSegment, formatShortSegment } from "./formatter"; import { FormatterFixtures } from "./formatter.fixtures"; describe("General", () => { @@ -82,4 +83,52 @@ describe("General", () => { ).to.equal("important countries: iceland"); }); }); + + describe("formatSegment", () => { + it("should convert number to string", () => { + expect(formatSegment(42, null)).to.be.equal("42"); + }); + + it("should pass string as is", () => { + expect(formatSegment("foobar", null)).to.be.equal("foobar"); + }); + + it("should return whole number range as string", () => { + expect(formatSegment(new NumberRange({ + start: 42, + end: 120 + }), null)).to.be.equal("42 to 120"); + }); + + it("should return start of time range as string", () => { + expect(formatSegment(new TimeRange({ + start: new Date("2016-11-11"), + end: new Date("2016-12-01") + }), Timezone.UTC)).to.be.equal("11 Nov 2016"); + }); + }); + + describe("formatShortSegment", () => { + it("should convert number to string", () => { + expect(formatShortSegment(42, null)).to.be.equal("42"); + }); + + it("should pass string as is", () => { + expect(formatShortSegment("foobar", null)).to.be.equal("foobar"); + }); + + it("should return start of number range as string", () => { + expect(formatShortSegment(new NumberRange({ + start: 42, + end: 120 + }), null)).to.be.equal("42"); + }); + + it("should return start of time range as string", () => { + expect(formatShortSegment(new TimeRange({ + start: new Date("2016-11-11"), + end: new Date("2016-12-01") + }), Timezone.UTC)).to.be.equal("11 Nov 2016"); + }); + }); }); diff --git a/src/common/utils/formatter/formatter.ts b/src/common/utils/formatter/formatter.ts index 543160330..f2bb33a26 100644 --- a/src/common/utils/formatter/formatter.ts +++ b/src/common/utils/formatter/formatter.ts @@ -16,7 +16,7 @@ */ import { Duration, Timezone } from "chronoshift"; -import { NumberRange, TimeRange } from "plywood"; +import { Datum, NumberRange, PlywoodValue, TimeRange } from "plywood"; import { STRINGS } from "../../../client/config/constants"; import { DateRange } from "../../models/date-range/date-range"; import { Dimension } from "../../models/dimension/dimension"; @@ -31,10 +31,17 @@ import { TimeFilterClause, TimeFilterPeriod } from "../../models/filter-clause/filter-clause"; +import { isNil } from "../general/general"; import { formatStartOfTimeRange, formatTimeRange } from "../time/time"; +function safeFormatNumber(value: number): string { + return isNil(value) ? "any" : value.toString(10); +} + export function formatNumberRange(value: NumberRange) { - return `${formatValue(value.start || "any")} to ${formatValue(value.end || "any")}`; + const start = safeFormatNumber(value.start); + const end = safeFormatNumber(value.end); + return `${start} to ${end}`; } export function formatValue(value: any, timezone?: Timezone): string { @@ -47,7 +54,23 @@ export function formatValue(value: any, timezone?: Timezone): string { } } -export function formatSegment(value: any, timezone: Timezone): string { +/* + NOTE: + Datum is a Record of `PlywoodValue | Expression`, so DatumValue will be equivalent to `PlywoodValue | Expression`. + Don't know if there is a real possibility that Plywood query will ever return an Expression inside Datum, though. +*/ +type DatumValue = Datum[string]; + +export function formatShortSegment(value: DatumValue, timezone: Timezone): string { + if (TimeRange.isTimeRange(value)) { + return formatStartOfTimeRange(value, timezone); + } else if (NumberRange.isNumberRange(value)) { + return value.start.toString(10); + } + return String(value); +} + +export function formatSegment(value: DatumValue, timezone: Timezone): string { if (TimeRange.isTimeRange(value)) { return formatStartOfTimeRange(value, timezone); } else if (NumberRange.isNumberRange(value)) { diff --git a/src/common/utils/rules/split-adjustments.mocha.ts b/src/common/utils/rules/split-adjustments.mocha.ts index e34da7286..302e27836 100644 --- a/src/common/utils/rules/split-adjustments.mocha.ts +++ b/src/common/utils/rules/split-adjustments.mocha.ts @@ -23,18 +23,17 @@ import { measureSeries } from "../../models/series/series.fixtures"; import { DimensionSort, SeriesSort, SortDirection } from "../../models/sort/sort"; import { numberSplitCombine, stringSplitCombine, timeSplitCombine } from "../../models/split/split.fixtures"; import { - adjustColorSplit, - adjustContinuousTimeSplit, + adjustColorSplit, adjustContinuousSplit, adjustFiniteLimit, adjustLimit, adjustSort } from "./split-adjustments"; describe("Split adjustment utilities", () => { - describe("adjustContinuousTimeSplit", () => { + describe("adjustContinuousSplit", () => { it("should set limit to null", () => { const timeSplit = timeSplitCombine("time", undefined, { limit: 500 }); - const adjusted = adjustContinuousTimeSplit(timeSplit); + const adjusted = adjustContinuousSplit(timeSplit); expect(adjusted.limit).to.be.null; }); @@ -49,7 +48,7 @@ describe("Split adjustment utilities", () => { reference: "time", direction: SortDirection.ascending }); - const adjusted = adjustContinuousTimeSplit(timeSplit); + const adjusted = adjustContinuousSplit(timeSplit); expect(adjusted.sort).to.be.equivalent(expectedSort); }); }); diff --git a/src/common/utils/rules/split-adjustments.ts b/src/common/utils/rules/split-adjustments.ts index bf17b58a1..cf38aa327 100644 --- a/src/common/utils/rules/split-adjustments.ts +++ b/src/common/utils/rules/split-adjustments.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { VisualizationColors } from "../../models/colors/colors"; +import { colorSplitLimits, VisualizationColors } from "../../models/colors/colors"; import { Dimension } from "../../models/dimension/dimension"; import { SeriesList } from "../../models/series-list/series-list"; import { DimensionSort, SeriesSort, SortDirection } from "../../models/sort/sort"; @@ -23,20 +23,14 @@ import { thread } from "../functional/functional"; export function adjustColorSplit(split: Split, dimension: Dimension, series: SeriesList, visualizationColors: VisualizationColors): Split { const colorsCount = visualizationColors.series.length; - const availableLimits = new Set([ - // TODO: This magic 5 will disappear in #756 - 5, - Math.min(split.limit, colorsCount), - colorsCount - ]); return thread( split, adjustSort(dimension, series), - adjustFiniteLimit([...availableLimits], colorsCount) + adjustFiniteLimit(colorSplitLimits(colorsCount), colorsCount) ); } -export function adjustContinuousTimeSplit(split: Split): Split { +export function adjustContinuousSplit(split: Split): Split { const { reference } = split; return split .changeLimit(null) diff --git a/src/common/visualization-manifests/bar-chart/bar-chart.ts b/src/common/visualization-manifests/bar-chart/bar-chart.ts index 2445593d8..c67df4323 100644 --- a/src/common/visualization-manifests/bar-chart/bar-chart.ts +++ b/src/common/visualization-manifests/bar-chart/bar-chart.ts @@ -29,7 +29,12 @@ import { emptySettingsConfig } from "../../models/visualization-settings/empty-s import { thread } from "../../utils/functional/functional"; import { Actions } from "../../utils/rules/actions"; import { Predicates } from "../../utils/rules/predicates"; -import { adjustColorSplit, adjustContinuousTimeSplit, adjustFiniteLimit, adjustSort } from "../../utils/rules/split-adjustments"; +import { + adjustColorSplit, + adjustContinuousSplit, + adjustFiniteLimit, + adjustSort +} from "../../utils/rules/split-adjustments"; import { visualizationDependentEvaluatorBuilder } from "../../utils/rules/visualization-dependent-evaluator"; const rulesEvaluator = visualizationDependentEvaluatorBuilder @@ -37,59 +42,62 @@ const rulesEvaluator = visualizationDependentEvaluatorBuilder .then(Actions.manualDimensionSelection("The Bar Chart requires at least one split")) .when(Predicates.areExactSplitKinds("time")) + .or(Predicates.areExactSplitKinds("number")) .then(({ splits, isSelectedVisualization }) => { - const timeSplit = splits.getSplit(0); - const newTimeSplit = adjustContinuousTimeSplit(timeSplit); - if (timeSplit.equals(newTimeSplit)) return Resolve.ready(isSelectedVisualization ? 10 : 3); - return Resolve.automatic(6, { + const continuousSplit = splits.getSplit(0); + const numberBoost = continuousSplit.type === SplitType.number ? 3 : 0; + const newContinuousSplit = adjustContinuousSplit(continuousSplit); + if (continuousSplit.equals(newContinuousSplit)) return Resolve.ready(isSelectedVisualization ? 10 : 3); + return Resolve.automatic(6 + numberBoost, { splits: new Splits({ - splits: List([newTimeSplit]) + splits: List([newContinuousSplit]) }) }); }) .when(Predicates.areExactSplitKinds("time", "*")) + .or(Predicates.areExactSplitKinds("number", "*")) .then(({ splits, series, dataCube, appSettings }) => { - const timeSplit = splits.getSplit(0); + const continuousSplit = splits.getSplit(0); + const numberBoost = continuousSplit.type === SplitType.number ? 3 : 0; const nominalSplit = splits.getSplit(1); const nominalDimension = findDimensionByName(dataCube.dimensions, nominalSplit.reference); - return Resolve.automatic(6, { + return Resolve.automatic(6 + numberBoost, { // Switch splits in place and conform splits: new Splits({ splits: List([ adjustColorSplit(nominalSplit, nominalDimension, series, appSettings.customization.visualizationColors), - adjustContinuousTimeSplit(timeSplit) + adjustContinuousSplit(continuousSplit) ]) }) }); }) .when(Predicates.areExactSplitKinds("*", "time")) + .or(Predicates.areExactSplitKinds("*", "number")) .then(({ splits, series, dataCube, isSelectedVisualization, appSettings }) => { - const timeSplit = splits.getSplit(1); + const continuousSplit = splits.getSplit(1); + const numberBoost = continuousSplit.type === SplitType.number ? 3 : 0; const nominalSplit = splits.getSplit(0); const nominalDimension = findDimensionByName(dataCube.dimensions, nominalSplit.reference); const newSplits = new Splits({ splits: List([ adjustColorSplit(nominalSplit, nominalDimension, series, appSettings.customization.visualizationColors), - adjustContinuousTimeSplit(timeSplit) + adjustContinuousSplit(continuousSplit) ]) }); const changed = !splits.equals(newSplits); if (!changed) return Resolve.ready(isSelectedVisualization ? 10 : 3); - return Resolve.automatic(6, { + return Resolve.automatic(6 + numberBoost, { splits: newSplits }); - }) .when(Predicates.areExactSplitKinds("*")) .or(Predicates.areExactSplitKinds("*", "*")) .then(({ splits, series, dataCube, isSelectedVisualization }) => { - const hasNumberSplits = splits.splits.some(split => split.type === SplitType.number); - const continuousBoost = hasNumberSplits ? 4 : 0; const newSplits = splits.update("splits", splits => splits.map((split: Split) => { const splitDimension = findDimensionByName(dataCube.dimensions, split.reference); @@ -102,10 +110,10 @@ const rulesEvaluator = visualizationDependentEvaluatorBuilder const changed = !splits.equals(newSplits); if (changed) { - return Resolve.automatic(5 + continuousBoost, { splits: newSplits }); + return Resolve.automatic(5, { splits: newSplits }); } - return Resolve.ready(isSelectedVisualization ? 10 : 6 + continuousBoost); + return Resolve.ready(isSelectedVisualization ? 10 : 6); }) .otherwise(({ dataCube }) => { diff --git a/src/common/visualization-manifests/line-chart/line-chart.ts b/src/common/visualization-manifests/line-chart/line-chart.ts index ba9931cfc..f48798db4 100644 --- a/src/common/visualization-manifests/line-chart/line-chart.ts +++ b/src/common/visualization-manifests/line-chart/line-chart.ts @@ -17,7 +17,6 @@ import { List } from "immutable"; import { getDimensionsByKind } from "../../models/data-cube/data-cube"; -import { Dimension } from "../../models/dimension/dimension"; import { findDimensionByName } from "../../models/dimension/dimensions"; import { DimensionSort, SortDirection } from "../../models/sort/sort"; import { Split } from "../../models/split/split"; @@ -27,22 +26,11 @@ import { Resolve, VisualizationManifest } from "../../models/visualization-manifest/visualization-manifest"; -import { thread } from "../../utils/functional/functional"; import { Predicates } from "../../utils/rules/predicates"; -import { adjustColorSplit, adjustContinuousTimeSplit, adjustFiniteLimit } from "../../utils/rules/split-adjustments"; +import { adjustColorSplit, adjustContinuousSplit } from "../../utils/rules/split-adjustments"; import { visualizationDependentEvaluatorBuilder } from "../../utils/rules/visualization-dependent-evaluator"; import { settings } from "./settings"; -function fixNumberSplit(split: Split, dimension: Dimension): Split { - return thread( - split.changeSort(new DimensionSort({ - reference: split.reference, - direction: SortDirection.ascending - })), - adjustFiniteLimit(dimension.limits) - ); -} - const rulesEvaluator = visualizationDependentEvaluatorBuilder .when(({ dataCube }) => !(getDimensionsByKind(dataCube, "time").length || getDimensionsByKind(dataCube, "number").length)) .then(() => Resolve.NEVER) @@ -65,16 +53,14 @@ const rulesEvaluator = visualizationDependentEvaluatorBuilder .when(Predicates.areExactSplitKinds("time")) .then(({ splits, isSelectedVisualization }) => { const timeSplit = splits.getSplit(0); - const newTimeSplit = adjustContinuousTimeSplit(timeSplit); + const newTimeSplit = adjustContinuousSplit(timeSplit); if (timeSplit.equals(newTimeSplit)) return Resolve.ready(isSelectedVisualization ? 10 : 7); return Resolve.automatic(7, { splits: new Splits({ splits: List([newTimeSplit]) }) }); }) .when(Predicates.areExactSplitKinds("number")) - .then(({ splits, dataCube, isSelectedVisualization }) => { + .then(({ splits, isSelectedVisualization }) => { const numberSplit = splits.getSplit(0); - const dimension = findDimensionByName(dataCube.dimensions, numberSplit.reference); - - const newContinuousSplit = fixNumberSplit(numberSplit, dimension); + const newContinuousSplit = adjustContinuousSplit(numberSplit); if (newContinuousSplit.equals(numberSplit)) return Resolve.ready(isSelectedVisualization ? 10 : 4); return Resolve.automatic(4, { splits: new Splits({ splits: List([newContinuousSplit]) }) }); @@ -99,9 +85,8 @@ const rulesEvaluator = visualizationDependentEvaluatorBuilder .when(Predicates.areExactSplitKinds("number", "*")) .then(({ splits, series, dataCube, appSettings }) => { const numberSplit = splits.getSplit(0); - const dimension = findDimensionByName(dataCube.dimensions, numberSplit.reference); - const newNumberSplit = fixNumberSplit(numberSplit, dimension); + const newNumberSplit = adjustContinuousSplit(numberSplit); const colorSplit = splits.getSplit(1); const colorDimension = findDimensionByName(dataCube.dimensions, colorSplit.reference); @@ -115,7 +100,7 @@ const rulesEvaluator = visualizationDependentEvaluatorBuilder .when(Predicates.areExactSplitKinds("*", "time")) .then(({ splits, series, dataCube, appSettings }) => { const timeSplit = splits.getSplit(1); - const newTimeSplit = adjustContinuousTimeSplit(timeSplit); + const newTimeSplit = adjustContinuousSplit(timeSplit); const colorSplit = splits.getSplit(0); const colorDimension = findDimensionByName(dataCube.dimensions, colorSplit.reference); @@ -128,9 +113,7 @@ const rulesEvaluator = visualizationDependentEvaluatorBuilder .when(Predicates.areExactSplitKinds("*", "number")) .then(({ splits, dataCube, series, appSettings }) => { const numberSplit = splits.getSplit(1); - const numberDimension = findDimensionByName(dataCube.dimensions, numberSplit.reference); - - const newNumberSplit = fixNumberSplit(numberSplit, numberDimension); + const newNumberSplit = adjustContinuousSplit(numberSplit); const colorSplit = splits.getSplit(0); const colorDimension = findDimensionByName(dataCube.dimensions, colorSplit.reference);